A CLI app for macOS terminals: auto-listens to the microphone → realtime speech-to-text (local MLX Whisper) → open unlimited translation columns (Gemini) → synchronized hover-highlight across every column → sessions saved locally for later review.
┌──────────────────────────── TermScribe ● REC 00:12:44 ─────────────────────────┐
│ [EN · source] [VI] [JA] │
│ ┌───────────────┐ ┌───────────────┐ ┌───────────────┐ │
│ │S1 Let's start │ │S1 Bắt đầu buổi│ │S1 レビューを...│ │
│ │ the review. │ │ review nào. │ │ │ │
│ │▓S2 hovered▓ │ │▓S2 hovered▓ │ │▓S2 hovered▓ │ ◄─ synced highlight │
│ └───────────────┘ └───────────────┘ └───────────────┘ │
│ Speakers: ●S1 ●S2 status: listening… [a]dd [x]close [s]ave [space] [q]uit │
└──────────────────────────────────────────────────────────────────────────────┘
Everything runs locally except translation (Gemini API). Speech never leaves your machine.
- Apple Silicon Mac (mlx-whisper is arm64-only), macOS 13+
- Python 3.11 or 3.12 (Homebrew/pyenv recommended, not the system Python)
- ~2 GB disk for models (Whisper large-v3-turbo downloads on first run)
cd termscribe
python3.11 -m venv .venv && source .venv/bin/activate
pip install -e ".[diarize]" # full install, incl. speaker diarization
# or: pip install -e . # lighter, no speaker labelsThe app inherits the permission of the terminal running it. On first run macOS asks to grant microphone access to Terminal/iTerm — accept it. If you denied it: System Settings → Privacy & Security → Microphone → enable your terminal app. If the mic is silent, TermScribe shows a warning banner instead of crashing.
To transcribe what Chrome / a video call is playing, switch the input source to
system audio (d key, or --source system). It uses a native Core Audio process
tap (macOS 14.2+, compiled on first use with the Xcode command-line tools). macOS
delivers silence until you grant the permission once:
System Settings → Privacy & Security → Screen & System Audio Recording →
System Audio Recording Only → + → add your terminal app (Warp, iTerm2, …),
then restart the terminal.
export GEMINI_API_KEY=... # https://aistudio.google.com/apikeyWithout a key the app still transcribes; translation columns are disabled
(or use --mt mock to try the UI offline).
Free-tier keys have low request rates. If backfills hit rate limits, set
rpm = 10under[mt]in the config.
Works out of the box: the speaker-embedding model (CAM++, ONNX, ~30 MB) is public
and downloads automatically on first use — no Hugging Face token needed. (Setting
[diarize] model = "pyannote/…" selects the legacy torch backend, which does
need an HF token and accepted gated-model terms.) If the model can't load,
TermScribe runs fine without speaker labels and shows a banner explaining why.
Hover-highlight needs a terminal that reports mouse events. Terminal.app, iTerm2 and WezTerm work out of the box; in some terminals you must enable "report mouse events".
This is the short tour — see USAGE.md for the full user guide: all CLI flags, everyday workflows, model-choice guidance, configuration reference, and troubleshooting.
termscribe # start listening (same as `termscribe run`)
termscribe run --target vi --target ja # open vi + ja columns at start
termscribe run --stt mock --mt mock # demo with no mic/models/keys
termscribe run --save-audio # also record audio.wav
termscribe open <session-id-or-path> # review a saved session (read-only)First run downloads the Whisper model — the status bar shows "loading model…" until it's ready.
Transcription quality tiers ([stt] quality or --quality) — the main
model that decodes finals (RAM figures are estimates of peak use):
| Tier | Model | Est. peak RAM |
|---|---|---|
fast |
large-v3-turbo, 4-bit | ~1 GB |
balanced (default) |
large-v3-turbo, 8-bit | ~1.5 GB |
accurate |
large-v3, 8-bit | ~3 GB |
max |
large-v3, fp16 | ~5 GB |
An explicit model = "…" overrides the tier. If a model fails to download, the
app falls back to a smaller tier with a banner.
Live text tiers ([stt] interim_quality, --interim-quality, or the I
key live) — the interim (live) text you see while someone is still speaking.
Any tier below except same loads a second, small model so live text keeps
refreshing (~every 0.4 s) even while the big model is busy decoding a final;
same shares the main model instead, saving that RAM at the cost of live text
pausing for a few seconds after each segment finalizes:
| Tier | Interim model | Est. extra RAM |
|---|---|---|
off |
— (finals only) | 0 |
instant |
tiny, 4-bit — roughest text | ~0.3 GB |
fast (default) |
base, 4-bit — recommended for live meetings | ~0.5 GB |
balanced |
small, 8-bit — cleanest live text | ~0.9 GB |
same |
shares the main model — lowest RAM, highest latency | +0 |
An explicit interim_model = "…" overrides interim_quality. Live text from
the tiny/base tiers is rougher than the final that replaces it a moment later —
that's the tradeoff for the low latency; finals always come from the main model
and are never delayed by interim work. The status bar shows both active models,
e.g. interim: base-q4 (fast, ~0.5 GB) · final: large-v3-turbo-8bit (balanced, ~1.5 GB). If the interim model fails to download, live text falls back to
same with a banner.
Interim on/off ([stt] interim, --no-interim, or the i key live): with
interim off, nothing is decoded until an utterance finalizes — finals get the
whole model and are snappier; a "● speaking…" indicator in the status bar shows
capture is working while someone talks.
| Key | Action |
|---|---|
a |
Add a language column (type a code: vi, ja, ko, fr…) — translates the whole transcript so far, then everything new |
x |
Close the focused column (source column can't be closed) |
space |
Pause / resume listening |
d |
Switch input source — any microphone, system audio (what other apps are playing), or combined (mic + system mixed); choice is saved to config for next runs |
s |
Save a snapshot now (session continues) |
/ then n |
Search the transcript / next match |
m |
Switch translation engine (gemini / nllb / mock) live |
u |
Switch transcription (final) quality tier live |
i |
Toggle live interim text on/off |
I |
Switch the live-text (interim) model tier — off / instant / fast / balanced / same |
t |
Toggle speaker labels (model loads/unloads in the background) |
l |
Set language mode: auto / pin one / constrained set (e.g. vi,en) |
o |
Change the termbase domain |
g |
Fix the hovered term with Gemini (writes a verified glossary entry) |
k |
Quick lookup — type a word/phrase, get 2-3 translations with usage notes (Gemini); tab swaps direction, 1-3 saves the chosen one to the termbase; without an API key falls back to a single translation from the active engine |
? |
Show the shortcut overlay |
q or Ctrl-C |
End: save + quit |
| mouse hover | Highlight the same sentence in every column; hovering a word highlights its counterpart in the other columns |
termscribe run --source combined (or [audio] source = "combined") captures your
microphone AND the system audio (meeting apps, browser…) at the same time, mixing
them in code into one stream — no BlackHole, no Aggregate Device, no virtual
driver to install. The system tap still needs the one-time System Audio Recording
permission (see Setup).
- Combined needs BOTH permissions: Microphone AND System Audio Recording for your terminal app. Careful: when Microphone permission is missing, macOS does not raise an error — it silently delivers zeros, so your voice simply never appears. TermScribe detects this and shows a warning toast within ~5 seconds.
- Echo cancellation is on by default (
[audio] aec = "speex"), so external speakers work: the meeting audio bleeding from your speakers into the mic is subtracted using the system-tap stream as the reference — no delay tuning needed (the adaptive filter absorbs up to ~250 ms of acoustic delay). It is strong but not perfect in very reverberant rooms; moderate speaker volume and some distance between mic and speakers still help. Headphones remain the zero-echo gold standard. - Other
aecmodes:"macos"routes the mic through Apple's voice processing (noise suppression + AGC — note: in our testing macOS does not cancel other apps' audio in this setup, and its AGC can amplify speaker bleed);"off"= plain mixing. If echo cancellation can't initialize, the app degrades to plain mixing with a status note — never crashes. - Your mic is captured continuously — there is nothing to enable or toggle in TermScribe; transcription is gated purely by voice activity (a silent mic costs nothing and adds nothing). Capture follows the actual mic signal, not the meeting app's mute state: anything you say out loud is captured even while muted in the meeting — by design, so summaries get the complete conversation, including your side comments.
- If one side is unavailable — tap permission missing, mic dead — the other keeps recording and the status bar tells you which side is silent. No crash.
- Speaker labels default off in this mode: the stream is fully mixed, so
diarization can't attribute words meaningfully.
tre-enables it if you want to experiment. --save-audiowrites a single mixedaudio.wav. While paused, nothing is written (the recording matches exactly what was transcribed).- Pressing
dand picking a specific device switches out of combined mode for the rest of the session.
TermScribe keeps a domain-keyed glossary at ~/.termscribe/termbase.sqlite.
Pick a domain per meeting (--domain banking, [mt] domain in config, or the o
key); each domain has an independent term set, so "security" can be "chứng khoán"
in banking and "bảo mật" in it.
How terms get in. While Gemini is the translation engine, it harvests
domain terminology automatically from everything it translates (stored as
auto) and is instructed to reuse the agreed translations consistently. You can
verify a term at any time: hover the word and press g — Gemini proposes the
correct domain translation in context, and applying it stores a verified entry
(verified always overrides auto) and re-translates the affected segments.
Changing the termbase automatically expires the translation cache for that
domain, so stale translations are never served.
Term marking. Words that exist in the current domain's glossary are
underlined in both the source and translation columns — plain underline for
harvested (auto) terms, bold underline for verified ones — so you can see
at a glance which terminology is already agreed.
Bootstrap → local NLLB. Each session with ≥10 minutes of audio counts as a
completed meeting for its domain. After 5 meetings ([mt] graduate_after),
TermScribe suggests — once, at session end, never automatically — switching the
engine to local NLLB: free, offline, private, with the learned termbase applied.
NLLB enforces glossary terms via placeholder substitution. This enforcement is
approximate: it works well for noun phrases, but the surrounding sentence may
read slightly awkwardly under inflection, and the rest of the sentence is plain
NLLB quality. The g fix still works on NLLB (only the term lookup uses Gemini).
Saved under ~/.termscribe/sessions/<session-id>/:
segments.jsonl— append-only event log written live (crash-safe: if the app dies,termscribe openrecovers the session from this file)session.json— the finalized sessionexport.html— self-contained review page: open in any browser, no network needed, with the same synchronized hover-highlightaudio.wav— only with--save-audio
Translations are cached in ~/.termscribe/mtcache.sqlite, so reopening a session or
re-adding a language never re-translates (cost scales with languages, not views).
Optional, at ~/.termscribe/config.toml (CLI flags override):
[audio]
source = "mic" # "mic" | "system" (capture what other apps are playing)
device = "" # input device name or index; "" = system default (or press `d` in the TUI)
sample_rate = 16000
save_audio = false
[stt]
provider = "whisper_mlx" # or "mock"
quality = "balanced" # final-decode tier: fast | balanced | accurate | max
model = "" # explicit whisper repo; overrides `quality`
interim_quality = "fast" # live-text tier: off | instant | fast | balanced | same
interim_model = "" # explicit interim repo; overrides `interim_quality`
language = "" # "" = auto-detect per utterance; or pin e.g. "en"
vad = "silero"
endpoint_ms = 800 # silence that ends a sentence
interim_interval = 0.4 # min seconds between live interim updates
[diarize]
enabled = true
threshold = 0.55 # higher = stricter speaker separation
[mt]
provider = "gemini" # or "mock"
model = "gemini-flash-lite-latest"
default_targets = [] # e.g. ["vi"] to open columns at startup
max_concurrency = 4
rpm = 60 # lower this on free-tier keys
[ui]
theme = "dark"API keys are only read from the environment (GEMINI_API_KEY, HF_TOKEN),
never from the config file.
pip install -e ".[diarize,dev]"
pytest # aggregator, store, MT cache/batching, VAD segmenter, clusteringProviders sit behind STTProvider / MTProvider interfaces (stt/base.py,
mt/base.py) — swapping in another engine touches only those layers.
- Cross-column word highlight is a best-guess match (exact token, else relative position) — not true word alignment.
- Diarization labels are "good enough" anonymous clusters, not perfect turn detection.
- NLLB term enforcement is approximate (placeholder substitution).