- TypeScript 98.3%
- JavaScript 1.7%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| bin | ||
| docs | ||
| src | ||
| .gitignore | ||
| .prettierignore | ||
| .prettierrc | ||
| CLAUDE.md | ||
| eslint.config.mjs | ||
| mise.toml | ||
| package.json | ||
| pnpm-lock.yaml | ||
| README.md | ||
| Taskfile.yml | ||
| tsconfig.json | ||
| vitest.config.ts | ||
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 installin 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 ~10–17% 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 --watchmode 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.