lilt
| Name | Last commit date |
|---|---|
| fonts | |
| app.css | |
| app.js | |
| bar.html | |
| check.js | |
| index.html | |
| lilt | |
| lilt.png | |
| lilt.svg | |
| main.js | |
| package.json | |
| preload.js | |
| README.md |
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