172 lines · 9.1 KB · Markdown
RawDownload

lilt

Local voice dictation for Linux, modelled on Wispr Flow. Hold a key, speak, and the text is typed into whatever window has focus. Speech recognition runs on the GPU through whisper.cpp's Vulkan backend; cleanup, Style and Transforms run through a local Ollama model. Nothing leaves the machine.

Install

Arch Linux on X11 (hotkeys use xinput test-xi2, pasting uses xdotool):

sudo pacman -S electron44 whisper-cpp ggml-vulkan ollama xdotool xclip xorg-xinput ffmpeg
git clone https://git.laellabs.com/krisyotam/lilt.git ~/src/lilt
ln -s ~/src/lilt/lilt ~/.local/bin/lilt
ollama pull qwen3.5:4b
lilt

On first start, open Settings > General and download a speech model (Whisper large-v3-turbo is a good default). Any GPU with a Vulkan driver works; without one, whisper.cpp falls back to the CPU.

For an app-menu entry, write ~/.local/share/applications/lilt.desktop with Exec= and Icon= pointing at lilt and lilt.png in the clone.

Default keys

Action Key
Push to talk hold Right Alt
Hands-free double-tap Right Alt, tap again to stop
Cancel Escape while recording
Transforms Ctrl+Alt+1, Ctrl+Alt+2 ... on selected text
Notetaker Ctrl+Alt+M
Assistant command hold Left Shift alone for 0.2 s (double-tap for hands-free)

Change the dictation key in Settings > General > Shortcuts. Right Alt is the default because many compact keyboards have no Right Ctrl. Pressing another key while holding it cancels, so Right Alt still works as a modifier.

Assistant

Hold the assistant key, say a command ("put three terminals in columns on tag 3", "mute the volume", "open my notes folder"), let go. lilt transcribes it and runs the chosen agent CLI headless. Every prompt starts by telling the agent to read ~/.config/agents/AGENTS.md, which says how to drive this desktop. The agent runs tools without approval prompts, since nobody is there to answer them. Nothing on screen changes: the bar looks the same as for dictation.

The Assistant page in the sidebar holds an optional system prompt (added to every command after the AGENTS.md pointer), a box for typed commands, and the log of every command: what was said, the full prompt sent, the reply, model, time and focused window. Model, key and agent folder stay in Settings > Assistant. Every finished command is also appended to ~/.local/share/lilt/commands.jsonl, which Delete data leaves alone.

Agent Command lilt runs
Claude Sonnet / Haiku / Opus 5.5 claude -p CMD --model M --permission-mode bypassPermissions
GPT-5.6 Terra / Luna codex exec -m M --dangerously-bypass-approvals-and-sandbox (needs CODEX_HOME, defaults to ~/.config/codex)
Grok 4.7 Build Fast grok -p CMD -m M --always-approve --output-format streaming-messages-json

A model shows as available when its CLI is on PATH. A command within five minutes of the last one continues the same session, so "undo that" works. A new command replaces one still running; the tray menu has Stop assistant. Commands time out after five minutes.

lilt --ask "open firefox on tag 2"   # typed command, e.g. from dmenu; goes to the running lilt

Testing

node check.js                        # pure pipeline self-check (unset ELECTRON_RUN_AS_NODE first)
lilt --transcribe some.wav           # run the full pipeline on a file, print JSON, exit
LILT_DRY=1 lilt                      # dictation logs to stdout instead of pasting or saving
LILT_LLM=claude-haiku lilt --transcribe some.wav   # one run with a chosen cleanup model: claude-haiku, codex-luna or ollama

Models

Model Engine Notes
Whisper large-v3-turbo whisper-server, Vulkan default, kept warm in VRAM, uses Dictionary words as a prompt
Whisper large-v3 whisper-server, Vulkan slower, slightly more accurate
Parakeet TDT 0.6B v3 parakeet-cli, Vulkan 25 languages, reloads per dictation
qwen3.5:4b Ollama default for cleanup, Style, Transforms, Notetaker summaries; offline
Claude Haiku 5.5 claude -p, low effort optional; uses your Claude Code sign-in, ~2 s per dictation
GPT-5.6 Luna codex exec, low reasoning, fast tier optional; uses your Codex sign-in, ~3.5 s per dictation

Models live in ~/.local/share/lilt/models. Errors are logged to ~/.local/share/lilt/lilt.log; lilt never shows error dialogs. Data lives in ~/.local/share/lilt/db.json and ~/.local/share/lilt/audio/.

If Linux kills a window process under memory pressure, lilt reloads the hub or dictation bar automatically, up to three times per minute. Launching lilt again can also restore crashed windows. An interrupted recording is cancelled.

Files

File What
main.js Electron main: windows, tray, store, hotkeys, recording pipeline, engines, paste, Notetaker, MCP
preload.js window.lilt bridge for both windows
index.html, app.css, app.js the hub UI
bar.html the bar pill; records the microphone with Web Audio
check.js node check.js runs the text-pipeline self-check
lilt.svg, lilt.png the logo (tray, window and launcher icon)

Contract between main and the renderers

State (lilt.state() and the lilt.onState(cb) push) is the db plus a runtime object:

{
  settings, history, dictionary, snippets, transforms, notes, meetings, commands,
  runtime: {
    version, dataDir, mcpUrl,
    engine: 'ready'|'loading'|'error'|'off', engineError,
    llm: 'ready'|'loading'|'off',
    recording: false|'hold'|'handsfree', recordingKind: 'dictate'|'assistant', processing: bool, notetaker: false|{startedAt},
    assistant: false|'running', assistantDir, assistantDirOk, assistantLog,
    assistantModels: [{id, name, cli, desc, available}],
    hotkeyLabel,                     // e.g. 'Right Alt'
    models: [{id, name, engine, size, langs, installed, progress}]  // progress 0..1 while downloading
  }
}

Records: history {id, ts, text, raw, app, words, ms, audio, dismissed, flagged, edited}, dictionary {id, word, to?, auto?}, snippets {id, trigger, text}, transforms {id, name, desc, prompt, key}, notes {id, title, body, ts, updated}, meetings {id, title, ts, ms, transcript, summary, audio}, commands {id, ts, text, prompt, reply, model, ms, ok, app, title, followUp} (assistant commands, newest first, capped at 1000; the full record is also appended to commands.jsonl when each finishes).

Settings keys (defaults in DEFAULTS in main.js): theme, firstName, lastName, email, hotkey{keycode,label}, micId, micLabel, language, model, llmModel, cleanup ('none'|'light'|'medium'), styles{personal,work,email,other} ('formal'|'casual'|'veryCasual'), transformsOptIn, autoTransform{enabled,id}, launchAtLogin, showBarAlways, showInDock, sounds, muteWhileDictating, scratchpadInBar, privacy{contextAwareness,storeAudio}, vibe{variableRecognition,fileTagging}, assistant{enabled, model, hotkey{keycode,label}, systemPrompt}, dismissed{}, notetaker{notifyBefore, showNextMeeting, autoDetect, autoStart, maxMinutes, stopOnCallEnd, hideFromCapture, notepadView, liveTranscript, calendarNotice, consentNotice, linkSharing, autoShareWho, autoShareFor}. History items also carry title (window title).

window.lilt (preload):

state()                      -> Promise<state>
onState(cb)                  -> called with full state on every change
onNavigate(cb)               -> main asks the hub to open a page, e.g. 'settings/general'
set(path, value)             -> dotted settings path, e.g. 'styles.work'
upsert(coll, item)           -> Promise<item>  (assigns id when missing)
remove(coll, id)
historyAction(id, action)    -> 'dismiss'|'recover'|'delete'|'undo'|'retry'|'flag'
copy(text)
audioUrl(id)                 -> Promise<file:// url | null>
toggleRecording(), cancelRecording()
notetaker(on)                -> start/stop meeting recording
downloadModel(id)
captureHotkey(target)        -> Promise<{keycode, label} | null> next key pressed (Escape ignored,
                                10 s timeout -> null); main also saves it to settings.hotkey,
                                or settings.assistant.hotkey when target is 'assistant'
assistant(text)              -> Promise<command record> run a typed command through the agent
stopAssistant()
transform(id, text)          -> Promise<string>
mcpAdd(client)               -> Promise<string> result message ('claude' runs `claude mcp add`)
open(target)                 -> url, or 'data' for the data folder
exportData()                 -> Promise<path>
deleteData()                 -> wipes history, notes, meetings and audio
win(action)                  -> 'minimize'|'maximize'|'close'

Bar only (lilt.bar):

onCommand(cb)   -> {type:'start', micId, sounds} | {type:'stop', sounds} | {type:'cancel'}
                   | {type:'state', ...runtime, settings: {scratchpadInBar, sounds, showBarAlways}}
audio(buf, ms)  -> Float32Array PCM, 16 kHz mono, as ArrayBuffer
action(name)    -> 'dictate'|'stop'|'cancel'|'notetaker'|'scratchpad'|'hub'
resize(w, h)    -> keeps the window bottom-centred on the primary monitor