- Go 98.2%
- Just 1.8%
| .just | ||
| .woodpecker | ||
| internal | ||
| .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 ~/.local/bin ($XDG_BIN_HOME)
# 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>
watch record automatically while a meeting app uses the mic
--dry-run log meeting transitions without recording
transcribe FILE transcribe (per-channel You/Them) + summarize
--no-summary just the transcript
split NAME [AT...] cut one recording into two (two meetings, one capture)
--dry-run show the plan without writing anything
--keep leave the original recording in raw/
--yes accept the proposed cut without asking
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>/.
Recording meetings automatically
listen watch records for as long as an application holds a live microphone
capture stream. That is the signal that actually means "in a call": PipeWire
only gives an app a running Stream/Input/Audio node while it is reading the
mic, so a browser tab parked on a meeting landing page has none. It needs no
per-site rules and no title matching, and Meet, Teams, Zoom and Slack huddles all
look identical from there.
Window titles are still read, but only to name the recording — if that fails
you get watch_name instead of a nice filename, never a missed recording. How
well naming works is per-product: Google Meet titles a joined call Meet – <name>
and everything before it plainly Google Meet, so the title itself says whether
you are in a call. Teams gives its own pages the same shape as a meeting
(Chat | Microsoft Teams vs Sprint review | Microsoft Teams), so its known
section names are excluded by name and anything else is taken at face value. When
nothing parses, watch logs the titles it saw next to the fallback name.
Two things it deliberately does not do naively:
- Debounce the start. A meeting's device-check screen opens the mic before
you join, so capture must persist for
watch_start_delaybefore recording begins. Backing out of a lobby leaves nothing behind. - Grace the stop. Changing microphone mid-call briefly tears the stream down,
so capture must stay gone for
watch_stop_gracebefore recording ends. One call stays one file.
Note that a single call can open several capture streams at once (Chromium opens two), so the recording ends when the last one goes away, not the first.
Run it as a user unit rather than by hand — listen watch --dry-run logs the
transitions without recording, which is the way to tune the delays against how
your conferencing app actually behaves.
Two meetings in one recording
Back-to-back calls arrive as a single file: watch stops on the mic going
quiet, and there is no quiet moment between a call that ends at 10:15 and one
that starts at 10:16. listen split cuts the capture apart afterwards.
You are not expected to know that the first call ended 40 minutes in, so with no cut point it finds the silence and proposes one:
$ listen split meeting-0935-1100
▸ looking for the gap between the meetings
silence at 10:15 → 10:20 (5m00s) looks like the handover
cut at 10:18? [Y/n, or a time like 1017 / 42m]:
Enter accepts, n backs out, and anything else is read as the real boundary —
either a wall-clock time (1015, 10:15) or an offset from the start (40m,
1h5m). Pass it up front to skip the question entirely, and pass several to cut
into more than two parts:
listen split meeting-0935-1100 1015
listen split day-0900-1600 1015 1230 1400
The cut is a stream copy — packets are re-muxed, never re-encoded — so the parts
hold the exact source audio, keep both channels, and cost a file copy rather
than a transcode. They are named <title>-<from>-<to>.ogg like any recording,
so listen lists them as pending and transcribe treats them as ordinary
meetings. The original moves aside to .ogg.bak, which the store ignores;
--keep leaves it in place instead.
Detection looks for a stretch where both sides are quiet, which is the strongest evidence of a boundary. Failing that it looks at the remote channel alone — between two calls the far end is silent by definition, while your own mic keeps hearing the room you are sitting in — and says so, because that is the weaker signal. Silence at either end of the recording is ignored: a capture that opens with eight minutes of lobby has no meeting before it to split off. Runs broken up by a cough or a keystroke are stitched back together first, so a gap is measured at its real length.
It reads the same silence_floor_db as the transcription gate and inherits its
caveat: record with normalize = false, or loudnorm lifts the silence to full
loudness and there is no gap left to find. If nothing turns up, name the time
yourself — and note that two calls in genuinely tight succession may have no
measurable gap at all.
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 |
watch_apps |
["chromium"] |
processes whose mic capture means "in a call" |
watch_app_id |
any | app_id of the window to take the name from |
watch_name |
meeting |
name used when no meeting title is readable |
watch_start_delay |
10s |
capture must persist this long before recording |
watch_stop_grace |
20s |
capture must stay gone this long before stopping |
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) |