Skip to content

Latest commit

 

History

13 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

dictator.nvim

The fate of this ft=markdown rests on your shoulders, but your eyes glaze over when you see a word count in the thousands.

You could read it silently,

you could read it aloud,

or you could have it read aloud to you, with a scrubber that follows along in the text without even leaving the comfort of your buffer.


Read a markdown buffer aloud with Piper TTS, following along in the text: the playback scrubber opens in a narrow panel beside the buffer and the line being read is highlighted in the original.

It is two pieces — a Neovim plugin, and the dictate CLI it drives. The CLI is useful on its own (transport controls, WM hotkeys), and the plugin is a thin layer over it.

The transport panel in a split beside the markdown buffer, reading this README

The panel is an ordinary buffer — p pauses, h/l seek — and the document reflows into the columns it does not use. Driven from a shell instead, the CLI draws the same thing as a terminal TUI:

  dictate ── proposal-updated-20260816.md ── en_GB-alan-medium

  ▶  chunk 12/134  ███░░░░░░░░░░░░░░░░░░░  14%  ~18:42 left  speed 0.7

  ...the GoDaddy REST Auctions API went self-serve in August 2026...

  space pause  ←/→ seek  [/] speed  r restart chunk  d detach  q quit

Neovim plugin

-- lazy.nvim
{
  "dictator.nvim",
  dir = "~/Development/dictator.nvim",
  ft = "markdown",
  build = "ln -sfn $PWD/dictate ~/.local/bin/dictate",   -- the CLI must be on PATH
  opts = {},                                            -- setup() is optional
}

Open a markdown file and run :DictatorStart. The scrubber appears in a narrow panel on the right — an ordinary buffer, not a terminal, so its keys are plain normal-mode mappings with no i / <C-\><C-n> dance — and the line being read is highlighted as playback moves. It takes a quarter of the screen and the document keeps the rest; the layout folds itself to fit whatever width it is given.

Key Effect
p Pause / resume
h l (or ) Seek a chunk back / forward
[ ] (or ) Slower / faster
r Restart the current chunk
q / <Esc> Close the panel, keep reading
Q Stop reading and close

Closing the panel leaves playback running and the buffer still following along; :DictatorPanel brings it back.

Command Effect
:DictatorStart [voice] [speed] Read the current buffer, unsaved edits included
:DictatorPanel Open (or focus) the transport panel
:DictatorToggle Pause / resume
:DictatorSpeed {N|+N|-N} Set or nudge speed (length scale, lower = faster)
:DictatorJump [line] Jump playback to a buffer line — cursor line by default, or :89DictatorJump
:DictatorSeek {N|+N|-N} Seek by chunk
:DictatorStop Stop and close the panel
:DictatorStatus Status in a notification

Options, shown with their defaults:

require("dictator").setup({
  cmd = "dictate",                  -- CLI name or absolute path
  voice = nil, speed = nil,         -- nil = the CLI defaults (alan, 0.7)
  win = {                           -- the transport panel
    style = "split",                -- "split" (right-hand) or "float"
    width = 0.25,                   -- a fraction of the editor, or a column count
    border = "rounded",             -- float only: any nvim_open_win() border
    position = "top-right",         -- float only: top-right | bottom-right | center | …
  },
  filetypes = { markdown = true },  -- set to nil to allow any buffer
  follow = true,                    -- keep the read line in view
  hl_group = "DictatorLine",        -- links to Visual unless you define it
})

A split is the default because it gives the text the most usable width: the document reflows into the remaining columns rather than hiding under an overlay. style = "float" draws the same panel as a modal instead, tucked into the top-right corner, which does cover whatever is behind it. Either way it takes ~25% of the screen, and below about 56 columns the panel folds its header, progress bar and key hints onto separate lines.

Its highlights (DictatorTitle, DictatorBar, DictatorKey, …) link to sensible defaults and can be overridden.

The buffer is read as it currently stands, unsaved changes included, so the highlighted line always matches what is on screen. :DictatorJump maps a buffer line to the chunk that starts at or before it, which is exact for list items and table rows since each gets its own chunk.

Install (CLI only)

git clone <this repo> ~/Development/dictator.nvim
ln -sfn ~/Development/dictator.nvim/dictate ~/.local/bin/dictate

Requires piper-tts and pw-play on PATH, plus jq and awk. sox generates the list-item chime (a shipped asset is used if it is missing), and fzf is needed only for the -i voice picker. On Arch: piper-tts-bin, piper-voices-en-gb, pipewire.

Usage

dictate [-i] [-b] INPUT [VOICE] [SPEED]   start reading (attaches the TUI unless -b)
dictate tui                               attach the scrubber to a running session
dictate pause | resume | toggle           pause / resume playback
dictate stop | quit                       stop playback and end the session
dictate speed N | +N | -N                 set length scale (lower = faster)
dictate seek N | +N | -N                  jump to / by chunk
dictate next | prev                       seek +1 / -1
dictate status                            one-line status

VOICE defaults to alan, resolved as en_GB-<VOICE>-medium. SPEED defaults to 0.7 and is piper's length scale, so lower is faster.

dictate notes.md                  # alan, speed 0.7, TUI attached
dictate notes.md cori 0.9         # different voice, slower
dictate -i notes.md               # pick any installed voice with fzf
dictate -i notes.md 1.1           # picker + speed (VOICE may be omitted)
dictate -i notes.md cori          # "cori" pre-fills the picker's query
dictate -b notes.md               # start detached, no TUI

Controlling a running session

Playback runs as a detached session, so the subcommands work from any shell — or from a window-manager binding.

The state directory is resolved as /run/user/$UID/dictate whenever that exists, in preference to $XDG_RUNTIME_DIR, so a hotkey-spawned process finds the same session a terminal started even when the WM passes a stripped environment. XDG_RUNTIME_DIR is also re-derived if missing, since pw-play needs it to reach the PipeWire socket. Both were verified under env -i.

AwesomeWM — add to your globalkeys table (gears.table.join), for example in keybindings.lua:

-- Absolute path so the bindings work regardless of awesome's PATH
local dictate = os.getenv("HOME") .. "/.local/bin/dictate"

awful.key({ modkey, "Control", "Shift" }, "space", function() awful.spawn({ dictate, "toggle" }) end,
  { description = "pause/resume reading", group = "dictate" }),
awful.key({ modkey, "Control", "Shift" }, "Right", function() awful.spawn({ dictate, "next" }) end,
  { description = "next chunk", group = "dictate" }),
awful.key({ modkey, "Control", "Shift" }, "Left", function() awful.spawn({ dictate, "prev" }) end,
  { description = "previous chunk", group = "dictate" }),
awful.key({ modkey, "Control", "Shift" }, "bracketright", function() awful.spawn({ dictate, "speed", "-0.05" }) end,
  { description = "read faster", group = "dictate" }),
awful.key({ modkey, "Control", "Shift" }, "bracketleft", function() awful.spawn({ dictate, "speed", "+0.05" }) end,
  { description = "read slower", group = "dictate" }),
awful.key({ modkey, "Control", "Shift" }, "Escape", function() awful.spawn({ dictate, "stop" }) end,
  { description = "stop reading", group = "dictate" }),
awful.key({ modkey, "Control", "Shift" }, "s", function()
  awful.spawn.easy_async({ dictate, "status" }, function(stdout, stderr)
    local text = stdout ~= "" and stdout or stderr
    if text == "" then text = "no session" end
    naughty.notify({ title = "dictate", text = text:gsub("%s+$", ""), timeout = 4 })
  end)
end, { description = "reading status", group = "dictate" }),

Passing an absolute path plus an argument table means the bindings need neither a shell nor ~/.local/bin on awesome's PATH — the usual reason these fail silently. Since a detached session has no visible UI, the s binding pops the status line as a naughty notification.

How it works

Tables

Markdown tables are unreadable read straight through, so each row is rewritten as labelled sentences — the column header before each cell — and each row becomes its own paragraph:

| Area            | Current Implementation                    | Why It Matters                  |
|-----------------|-------------------------------------------|---------------------------------|
| **Data entry**  | Typed into a Google Sheet                 | Fewer copy-paste mistakes       |

reads as:

Area: Data entry. Current Implementation: Typed into a Google Sheet. Why It Matters: Fewer copy-paste mistakes.

Cells get a sentence-final period, so piper's --sentence_silence gives a short pause between fields, and the paragraph break puts a longer pause between rows. Bold, code ticks, and alignment colons are stripped; empty cells are skipped; a row with more cells than headers falls back to "Column N".

Underscores become spaces rather than being deleted, so domain_name reads as "domain name" instead of "domainname".

Lists

Bullets lose their marker and gain punctuation, so a list reads as one flowing sentence instead of a run of abrupt fragments: a comma after every item, a full stop after the last. Items that already end in punctuation are left alone.

- Bulk add and CSV import/export for spreadsheet-style data entry
- Live status & scheduled-jobs dashboard (Turbo Streams)
- Per-domain stop control and manual re-validation

reads as:

Bulk add and CSV import/export for spreadsheet-style data entry, Live status & scheduled-jobs dashboard (Turbo Streams), Per-domain stop control and manual re-validation.

Each item also starts its own chunk, marked by a soft chime — an 880 Hz sine, 280 ms, faded in and out at -20 dBFS, so it sits well under the voice and tells you a new item has begun without interrupting the reading. Because each item is its own chunk, seek steps item by item through a list.

The chime is generated with sox at the voice's own sample rate; if sox is missing or fails, assets/chime-soft-22050.wav is used instead, and if neither is available the chime is simply skipped. DICTATE_CHIME=off disables it, DICTATE_CHIME_DB=-14 makes it more present. demo/make_demo.sh regenerates the demo and offers bell and pluck alternatives.

Wrapped items are folded into one item before punctuating, so the comma lands at the end rather than mid-sentence. Blank lines between items still count as one list, and nested items are treated as continuing the list. Applies to -, *, and + bullets; numbered lists are left as they are.

Money

Piper reads a currency symbol where it is written, so $10,000 comes out as "dollar ten thousand". Amounts are rewritten the way they are spoken instead — the number first, then the currency name, singular or plural to match:

Written Read
$1 / ₪1 one dollar / one shekel
$10 / ₪10 ten dollars / ten shekels
$0.50 / ₪0.50 fifty cents / fifty agorot
$10.99 ten dollars and ninety-nine cents
$1.5 million one point five million dollars
US$20 twenty US dollars

Under one unit the amount switches to the subunit, so $0.50 is "fifty cents" rather than "zero point five dollars", and $0.01 is "one cent" rather than "one cents". A magnitude word after the amount keeps its place between the number and the name, and k, m, bn are spelled out. $, , £, , , , ¥, and are recognised; ¥ and have no everyday subunit, so a fraction is read as-is.

A letter prefix on $ names a variant — US, A/AU, C/CA, NZ, HK, SG — and becomes an adjective. Anything else is left untouched rather than guessed at, so R$5 is not read as dollars. DICTATE_CURRENCY=off disables the whole rewrite, which is worth knowing if the document is full of shell snippets where $1 is a positional parameter.

Markup that is skipped

Raw HTML is dropped, so print scaffolding like <div style="page-break-before: always;"></div> is silent rather than spelled out; inline tags and <!-- comments --> go too. Heading # and blockquote > markers are only stripped at the start of a line, so C#, #1, and a > b survive intact.

Everything else

The input is markdown-stripped, split into ~320-character chunks on sentence and paragraph boundaries, and synthesized one chunk at a time with piper-tts, played through pw-play. The next chunk is synthesized while the current one plays, so gaps stay short.

Session state lives in $XDG_RUNTIME_DIR/dictate/session as plain files (idx, state, speed, pid, …), which is how the subcommands talk to the daemon. Pause is SIGSTOP/SIGCONT on the player; seek and speed changes queue a command and cut the current chunk short.

Speed is baked in at synthesis time, so changing it re-synthesizes from the current chunk onward — it takes effect at the chunk boundary, not mid-sentence. Rendered chunks are cached per speed for the life of the session, so seeking back is instant.

Progress is character-weighted, with a within-chunk fraction from the current wav's duration, so the bar moves continuously and the estimate reflects measured seconds-per-character rather than a guess.

Environment

Variable Default Meaning
PIPER_VOICE_ROOT /usr/share/piper-voices Where -i searches for .onnx
DICTATE_CHUNK_CHARS 320 Target chunk size; also seek granularity
DICTATE_PLAYER pw-play Player command (paplay, aplay)
DICTATE_RUN_DIR /run/user/$UID/dictate Session state dir; override to run isolated sessions
DICTATE_CHIME on off disables the list-item chime
DICTATE_CHIME_DB -20 Chime peak in dBFS; less negative = more present
DICTATE_CURRENCY on off reads currency amounts as written

The CLI also prints its session directory with dictate rundir; the plugin uses that to follow playback by reading the state files directly, including lines, which records the source line each chunk came from.

About

Markdown TTS for the Supreme Leader of the Neovim Buffer

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages