No description
  • Go 98.2%
  • Just 1.8%
Find a file
Viktor Varland 70e885be24
All checks were successful
ci/woodpecker/push/build Pipeline was successful
ci/woodpecker/tag/release Pipeline was successful
docs: changelog for v1.5.0
2026-08-28 17:43:33 +02:00
.just feat: install to the XDG bin dir 2026-08-01 23:42:51 +02:00
.woodpecker feat: add split functionality 2026-08-17 22:42:17 +02:00
internal fix: name Teams recordings from the meeting, not the open tab 2026-08-20 11:52:47 +02:00
.gitignore feat: add split functionality 2026-08-17 22:42:17 +02:00
CHANGELOG.md docs: changelog for v1.5.0 2026-08-28 17:43:33 +02:00
config.example.toml feat: record meetings automatically with listen watch 2026-08-10 11:12:15 +02:00
go.mod feat: add shell completion command 2026-06-25 11:43:19 +02:00
go.sum feat: add shell completion command 2026-06-25 11:43:19 +02:00
justfile feat: install to the XDG bin dir 2026-08-01 23:42:51 +02:00
main.go feat: add split functionality 2026-08-17 22:42:17 +02:00
main_test.go refactor: migrate to the gout/cli command framework 2026-06-24 12:22:41 +02:00
README.md fix: name Teams recordings from the meeting, not the open tab 2026-08-20 11:52:47 +02:00
transcript-example.md feat(transcribe): headless llama-completion summaries, VAD/silence gating, better context sizing 2026-06-24 14:33:11 +02:00

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_delay before 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_grace before 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 ~1215k tokens per hour (Mistral's tokenizer is less efficient on Norwegian/Danish, ~1.62.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 69k 8192
1 hour 1215k 16384
2 hours 2430k 32768 (model max)
34 hours 3660k 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 34 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)