No description
  • Go 95.8%
  • Just 3%
  • Shell 1.2%
Find a file
Viktor Varland bd226e2e1f
All checks were successful
ci/woodpecker/push/build Pipeline was successful
ci/woodpecker/tag/release Pipeline was successful
docs: changelog for v1.2.0
2026-06-26 09:21:53 +02:00
.just chore: sync wrangle-managed files to wrangle init 2026-06-26 09:19:58 +02:00
.woodpecker ci: add Woodpecker build + release pipelines and changelog tooling 2026-06-24 13:50:57 +02:00
internal fix: correct module path to git.meatbag.se/varl/listen 2026-06-25 09:28:40 +02:00
scripts chore: sync wrangle-managed files to wrangle init 2026-06-26 09:19:58 +02:00
.gitignore feat: init listen 2026-06-24 08:52:10 +02:00
CHANGELOG.md docs: changelog for v1.2.0 2026-06-26 09:21:53 +02:00
config.example.toml feat(transcribe): headless llama-completion summaries, VAD/silence gating, better context sizing 2026-06-24 14:33:11 +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 chore: replace release pipeline with wrangle 2026-06-25 09:32:09 +02:00
main.go feat(completion): complete transcribe from available recordings 2026-06-25 12:57:38 +02:00
main_test.go refactor: migrate to the gout/cli command framework 2026-06-24 12:22:41 +02:00
README.md feat: init listen 2026-06-24 08:52:10 +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 /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 ~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)