- Go 95.8%
- Just 3%
- Shell 1.2%
| .just | ||
| .woodpecker | ||
| internal | ||
| scripts | ||
| .gitignore | ||
| CHANGELOG.md | ||
| config.example.toml | ||
| go.mod | ||
| go.sum | ||
| justfile | ||
| main.go | ||
| main_test.go | ||
| README.md | ||
| transcript-example.md | ||
listen
Transparently capture both sides of a conversation into a single file: your
mic on the left channel, the system output (everyone else) on the right.
listen rec, talk until you quit, then it tears the pipeline down and finalizes
a clean stereo Opus/Ogg — then transcribe and summarize it locally.
It taps PipeWire's monitor of your default output sink, which is a passive read — it does not change your audio routing or what you hear. Nothing in the audio graph is mutated; there is no setup to undo beyond stopping the capture.
listen rec standup # record -> raw/2026-06-23/standup-1500-1530.ogg
# ...talk... then Ctrl-C
listen # status dashboard: what's pending vs processed
listen transcribe raw/2026-06-23/standup-1500-1530.ogg
# -> minutes/2026-06-23/standup-1500-1530/{transcript,summary}.md
The store
Everything lives under output_dir, organized by date. Recordings land in
raw/; processing sorts the results into minutes/, one folder per recording:
<output_dir>/
raw/2026-06-23/standup-1500-1530.ogg
minutes/2026-06-23/standup-1500-1530/
transcript.md
summary.md
<from>-<to> are the start/stop times as HHMM (so 1500-1530 is 15:00 → 15:30;
the stop time is filled in when you hit Ctrl-C). A bare listen prints a
dashboard of what still needs transcribing versus what's done:
listen ~/data/record
raw ~/data/record/raw
minutes ~/data/record/minutes
▸ pending transcription (1)
2026-06-23 standup-1500-1530 4.3MB
run: listen transcribe ~/data/record/raw/2026-06-23/standup-1500-1530.ogg
✔ processed (12)
2026-06-23 retro-0900-0945 minutes/2026-06-23/retro-0900-0945/
…
Why stereo split
Your voice and the remote voices land on separate channels of one file:
L = you (default source / mic)
R = others (default sink's .monitor)
That keeps the file self-contained while preserving who-said-what. A transcriber can decode each channel independently and label speakers for free — no diarization guesswork. The remote side is downmixed to mono and async-resampled so the two independent capture clocks don't drift apart over a long session.
The codec is Opus (Speex's successor, the modern speech codec) in an Ogg
container, tuned voip at 48 kbit/s — tiny files, excellent voice quality.
Channel leveling
An analog mic is typically much quieter than the system output (e.g. a 30 dB
gap), so by default each channel is loudness-normalized to a common target
(−16 LUFS, true-peak limited at −1.5 dBFS, so it never clips). Your quiet voice
and the hot remote audio come out balanced — better to listen to and to
transcribe. Set normalize = false for raw levels, or nudge mic_gain_db /
remote_gain_db for fine control.
Install
just install # builds and installs to /usr/local/bin
# or just build, then ./listen
Requires ffmpeg/ffprobe and pactl (pipewire-pulse) on PATH.
Usage
listen [--config PATH] [command]
(none), status dashboard: pending vs processed recordings (the default)
rec [name] record until Ctrl-C; name -> the recording's <title>
transcribe FILE transcribe (per-channel You/Them) + summarize
--no-summary just the transcript
summarize FILE (re)summarize an existing transcript.md
devices, ls show resolved mic/remote devices and all sources
version print version
name becomes the recording's <title>; the file is named
<title>-<from>-<to>.ogg under raw/<date>/.
Config
Optional. Defaults work with no file. Copy config.example.toml to
~/.config/listen/config.toml to change any of:
| key | default | meaning |
|---|---|---|
output_dir |
~/recordings |
root of the recording store (~ expanded) |
bitrate |
48k |
Opus target bitrate |
application |
voip |
libopus tuning: voip / audio / lowdelay |
normalize |
true |
per-channel loudness leveling (no clipping) |
mic_gain_db |
0 |
fixed dB gain on the left channel |
remote_gain_db |
0 |
fixed dB gain on the right channel |
mic |
auto | pin the left-channel source |
remote |
auto | pin the right-channel monitor source |
By default the mic is your default source and the remote is the default
sink's monitor, so listen follows whatever device you're currently using. Pin
mic/remote only when you want a fixed device. listen devices prints the
exact names to use.
Transcribe + summarize
listen transcribe raw/2026-06-23/standup-1500-1530.ogg
▸ splitting channels (you / them)
▸ transcribing your channel (You) # whisper.cpp, GPU
▸ transcribing their channel (Them)
✔ transcript minutes/2026-06-23/standup-1500-1530/transcript.md
▸ summarizing with llama.cpp # one-shot llama-cli
✔ summary minutes/2026-06-23/standup-1500-1530/summary.md
Each stereo channel is transcribed separately, so every line is reliably attributed without diarization guesswork:
[00:00] You: Hei, har du sett tilbudet?
[00:04] Them: Ja, jeg kikket på det i går.
The transcript then goes to a one-shot llama-cli call that writes a Markdown
summary, key points, and action items (- [ ] checkboxes). --no-summary
stops after the transcript. Nothing leaves the machine.
Already have a transcript (or want to re-run the summary with a different model or prompt)? Skip Whisper entirely:
listen summarize minutes/2026-06-23/standup-1500-1530/transcript.md
# -> writes summary.md next to it
Long meetings & context
The summary's context window must hold the whole transcript + the generated summary. A Nordic transcript runs ~12–15k tokens per hour (Mistral's tokenizer is less efficient on Norwegian/Danish, ~1.6–2.0 tokens/word, plus per-line timestamp overhead). listen sizes the context automatically from the actual transcript and prints what it chose:
▸ summarizing with llama.cpp
(~13k transcript tokens, context 16384)
| Meeting | ≈ transcript tokens | context picked |
|---|---|---|
| 30 min | 6–9k | 8192 |
| 1 hour | 12–15k | 16384 |
| 2 hours | 24–30k | 32768 (model max) |
| 3–4 hours | 36–60k | truncates — see below |
Mistral-Small-24B tops out at 32768 (≈ 2 hours). Beyond that, listen warns
that the start of the call may be truncated. For your regular 3–4 hour
meetings, the options are: split the recording and summarize the parts, raise
llama_ctx_max with a longer-context model (e.g. a 128k model, if it fits), or
wait for the planned chunked map-reduce summarizer. (llama.cpp's own default
context is just 4096 — truncating past ~5 minutes — which is exactly the trap the
auto-sizing avoids.) Pin a context yourself by putting -c in llama_args,
which turns auto-sizing off.
Model choice
For a mixed Norwegian / Danish / English / Swedish conversation, Whisper
large-v3 is the best generalist: it transcribes each channel verbatim in
whatever language is actually spoken (auto-detected per channel). The Nordic
national fine-tunes (KB-Whisper for Swedish, NB-Whisper for Norwegian) score
lower WER on their language but are monolingual — NB-Whisper even translates
other languages into Norwegian — so they hurt a multilingual mix. whisper_bin
and whisper_model are configurable, so swap one in if your calls become
single-language. The summarizer is Mistral-Small-24B (strong Nordic prose,
fits the 32 GB GPU fully).
Setup (one-time)
This needs whisper.cpp (CUDA) and a llama.cpp build (you already have one).
# 1. Build whisper.cpp with CUDA (sm_120 = Blackwell / RTX 5090):
just setup-whisper # clones to ~/src/whisper.cpp, builds build/bin/whisper-cli
# override: WHISPER_DIR=… CUDA_ARCH=… just setup-whisper
# 2. Get the Whisper model (downloads where you choose):
just get-whisper-model large-v3 ~/models/whisper
# or manually:
# curl -L -o ~/models/whisper/ggml-large-v3.bin \
# https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v3.bin
# 3. Get the summarizer GGUF (your choice of location), e.g.:
# huggingface-cli download bartowski/Mistral-Small-24B-Instruct-2501-GGUF \
# Mistral-Small-24B-Instruct-2501-Q4_K_M.gguf --local-dir ~/models/llm
Then point the config at them (~/.config/listen/config.toml):
whisper_bin = "~/src/whisper.cpp/build/bin/whisper-cli"
whisper_model = "~/models/whisper/ggml-large-v3.bin"
llama_bin = "llama-cli" # or an absolute path to your build
llm_model = "~/models/llm/Mistral-Small-24B-Instruct-2501-Q4_K_M.gguf"
See config.example.toml for every transcribe/summarize knob (whisper_lang,
you_label/them_label, llama_args, summary_language, …).
Config keys (step two)
| key | default | meaning |
|---|---|---|
whisper_bin |
PATH | whisper.cpp CLI (or absolute build path) |
whisper_model |
required | ggml model, e.g. ggml-large-v3.bin |
whisper_lang |
auto |
language or per-channel auto-detect |
you_label / them_label |
You/Them |
speaker names in the transcript |
llama_bin |
PATH | llama.cpp CLI (one-shot) |
llm_model |
opt | summarizer GGUF; unset = skip summary |
llama_args |
["-ngl","99"] |
extra llama-cli flags (GPU offload, temp) |
llama_ctx_max |
32768 |
ceiling for auto-sized context (model's max) |
summary_max_tokens |
1200 |
summary length cap |
summary_language |
source | force summary language (empty = same as call) |