No description
  • TypeScript 98.3%
  • JavaScript 1.7%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-08-28 23:27:42 +02:00
bin add setup wizard and local model support 2026-08-28 23:27:42 +02:00
docs add setup wizard and local model support 2026-08-28 23:27:42 +02:00
src add setup wizard and local model support 2026-08-28 23:27:42 +02:00
.gitignore init 2026-08-26 13:23:45 +02:00
.prettierignore init 2026-08-26 13:23:45 +02:00
.prettierrc init 2026-08-26 13:23:45 +02:00
CLAUDE.md add setup wizard and local model support 2026-08-28 23:27:42 +02:00
eslint.config.mjs tui 2026-08-28 22:09:49 +02:00
mise.toml tui 2026-08-28 22:09:49 +02:00
package.json tui 2026-08-28 22:09:49 +02:00
pnpm-lock.yaml tui 2026-08-28 22:09:49 +02:00
README.md improves sync and bleed cancellation 2026-08-28 00:37:19 +02:00
Taskfile.yml init 2026-08-26 13:23:45 +02:00
tsconfig.json tui 2026-08-28 22:09:49 +02:00
vitest.config.ts tui 2026-08-28 22:09:49 +02:00

listenin

Offline meeting recording and transcription for macOS. Record your mic and system audio, keep the audio, and produce a strong, speaker-labeled transcript — re-runnable later with better models or settings. Everything runs on your own machine (or your own infrastructure); nothing goes to a third party.

The canonical transcript is schematized YAML (speakers, timestamps, per-segment text); markdown is a derived rendering. Point your coding agent at ~/.listenin/recordings/ and ask it questions — listenin deliberately has no chat features of its own.

Why not real-time?

Most transcription tools transcribe live. listenin deliberately records first and transcribes after, because the two goals conflict and we optimize for the transcript that gets kept:

  • Context is most of the quality. Whisper decodes far better when each window is conditioned on everything said before it and can use beam search without a latency budget. We A/B'd this on a real hour-long meeting: streaming-style short-context decoding misheard "Scrum of Scrums" as "storm of scum"; long-form batch decoding got it right. Real-time is structurally the low-quality variant.
  • Speaker attribution needs the whole recording. Diarization clusters voiceprints across the full meeting; live diarization must commit labels before it has the evidence, and speakers get renamed mid-transcript.
  • Kept audio means transcripts improve retroactively. Passes are append-only: a better model, a tuned speaker hint, or an updated glossary can re-transcribe last month's meeting. A live transcript is frozen at capture-day quality.
  • Your machine is busy during the meeting. Batch runs afterwards instead of competing with the call for CPU/GPU — and if transcription ever fails, the audio is still there; run it again.

The price is ~10 minutes of processing after an hour-long meeting. For the mid-meeting "what did they just say?" moment, listenin peek transcribes the audio captured so far on demand — liveness where it's useful, without paying its cost for the whole recording.

Setup

Prerequisites:

  • macOS (Apple Silicon), with Screen Recording permission granted to your terminal (System Settings → Privacy & Security → Screen Recording) — required for system-audio capture
  • ffmpeg (with ffprobe): brew install ffmpeg
  • whisper.cpp (the transcription engine): brew install whisper-cpp
  • Node 24+ and pnpm — mise install in the repo handles both

Install (npm/binary distribution comes later — for now, from source):

git clone ssh://git@code.olsen.cloud:2206/incubator/listenin
cd listenin
mise install        # pinned node + tooling
task install        # pnpm install via corepack
task build
npm link            # puts `listenin` on your PATH

One-time model download (~680 MB — Whisper large-v3-turbo (ggml) plus the speaker segmentation/embedding models; the only step that touches the network):

listenin setup

Optional, for the clean and summarize commands: an OpenAI-compatible endpoint (Ollama, LM Studio, llama-swap, …). Configure it once in ~/.listenin/config.json:

{
  "refiner": {
    "baseUrl": "http://localhost:11434/v1",
    "model": "qwen3:4b"
  }
}

Daily use

Before the meeting:

listenin record

Answer the prompts — title, audio source (mic + system is the default), and which microphone (your system default is preselected; pick the one you actually speak into). A warning prints a few seconds in if the mic track is silent. Press Enter when the meeting ends.

After the meeting:

listenin transcribe                      # best-quality diarized transcript (the default)
listenin transcribe --language da        # pin the language (default: auto-detect)
listenin transcribe --speakers 5         # hint: 5 people total, you alone at your desk
listenin transcribe --speakers 3:4       # hint: 3 sharing your room mic, 4 remote

That writes the canonical YAML (transcripts/001-full.yaml) and a readable transcripts/full.md. Unknown speakers come out as Speaker 1/2/…; the next section is how they get real names.

Putting names on speakers

Diarization gives you anonymous clusters (mic-S1, system-S2). listenin speakers turns them into people, and remembers them for next time:

listenin speakers                        # walk the speakers, play a clip of each, name them
listenin speakers --list                 # just show them (speech time, matches, confidence)
listenin speakers --identify             # re-match against known people, no prompting
listenin profiles                        # who listenin knows
listenin profiles rename morten "Morten Olsen"
listenin profiles rm morten

The interactive loop takes the most talkative speaker first, plays a clip of them, and asks:

system-S1 · Speaker 2 · 47.2 min · 628 segments · suggestion: Morten Olsen (0.71)
[0:14:12] "…so the capture seam is done, I'll pick up diarization next."
   confirm "Morten Olsen"   enter a name   play another clip   replay   skip   quit

Naming someone stores a voiceprint for them under ~/.listenin/speakers/, so the next recording labels them automatically — and listenin speakers --identify back-fills the names into older transcripts too, in milliseconds, without touching the audio.

Things worth knowing:

  • Confirming is what enrolls. Automatic matches are only ever suggestions; nothing is learned from them. One bad guess feeding itself back would drift a profile until it matched everyone.
  • Refusing beats guessing. A speaker is left anonymous if the best match is weak or if the top two candidates are too close to call. Naming them costs one keystroke; a confidently wrong name is worse.
  • Enroll people more than once. The same voice on a headset, a room mic and a laptop mic lands in different places; a profile keeps up to 8 voiceprints and matches on the closest one. Cross-device recognition is much weaker than same-device, so a second enrollment in a different setting is the single best thing you can do for accuracy.
  • A speaker needs ~8 s of speech to be identifiable at all; shorter clusters are reported as such rather than guessed at.
  • Room loudspeakers are handled. In a hybrid meeting the mic re-captures the remote participants playing over a speaker, which otherwise shows up as phantom extra people. If a mic cluster is the same voice as a system cluster, the mic copy is dropped and the clean system copy kept.
  • Names are also fed to the recognizer as vocabulary, so it spells them right.
  • Labels remain hand-editable in the YAML (label: + listenin render) if you'd rather not use profiles at all.

Voiceprints never leave your machine, and a transcript carries the voiceprint it was matched on — worth knowing before sharing a transcript file, since that is biometric data.

Recognition of names and jargon improves a lot with a glossary — company terms, product names, people. It lives in ~/.listenin/glossary.md and is fed to the recognizer on every transcription:

listenin glossary                        # edit it in $EDITOR (created on first use)
listenin transcribe --force              # re-run a recording with the updated glossary

Optional follow-ups (need the configured endpoint):

listenin clean                           # disfluency cleanup → a derived 'clean' pass
listenin summarize                       # key points / decisions / action items → summary.md
listenin summarize --context "focus on the budget discussion"   # apply an angle (prompted for interactively too)

During a meeting, when you need to check what was said ten minutes ago:

listenin peek                            # fast partial transcript of the recording so far

Everything else:

listenin open                            # open a transcript/summary in $EDITOR (pickers for both)
listenin open --kind summary             # jump straight to the summary
listenin list                            # recordings, newest first
listenin devices                         # microphone inputs (system default marked)
listenin transcribe --force              # re-run: new numbered pass, old ones untouched
listenin render --kind clean             # regenerate markdown for any pass kind

Where things live

~/.listenin/
  config.json                     refiner endpoint/model, sticky mic device, match thresholds
  glossary.md                     vocabulary priming (names, jargon)
  models/                         downloaded by `listenin setup`
  speakers/<person>.yaml          known people + their voiceprints
  recordings/<id>/
    recording.json                metadata
    audio/{mic,system}.ogg        32 kbps Opus — ~14 MB/hour/track
    transcripts/NNN-<kind>.yaml   canonical transcripts, append-only (never overwritten)
    transcripts/<kind>.md         derived markdown, regenerable
    transcripts/summary.md        derived summary, regenerable

Transcription runs are append-only: re-running with different settings mints a new numbered pass, so you can compare and nothing is ever lost. Recordings are plain directories — delete with rm -rf, search with grep.

Future improvements

  • Repairing pre-2026-08-28 recordings: mic capture used to lose ~1017% of its samples without leaving a gap, which compressed that track's timeline (a 56-minute meeting produced a 46-minute mic track). Capture is fixed and now warns when a track comes out short, but recordings made before the fix still have mic segments misplaced in the merged transcript. The repair is timestamp remapping, not re-recording — see docs/track-alignment.md.
  • Live peek: a peek --watch mode that re-renders the partial transcript every N seconds during a recording (and possibly auto-transcribe-on-stop), giving near-real-time visibility without compromising the batch pass that becomes the historical record.

Development

task check (types), task lint:eslint, task test, task build. Design and invariants: docs/design.md.