qwen_asr has no quantized-weights option, so the only lever available to reduce the 1.7B model's memory footprint is avoiding concurrent instances. Previously each track (system, mic) ran its own transcribeTrack goroutine with independent qwen_asr subprocesses, so simultaneous speech on both tracks meant two ~6.9GiB model instances alive at once - over half the 24GB target machine's memory. Merge both tracks onto a single worker (asrSegs channel, dispatch() routes both tracks onto it) so invocations are strictly serialized; per-track order is preserved since VAD's output is already chronological. Verified via pgrep -x qwen_asr polling during a real dual-track capture: never more than 1 concurrent process. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
18 KiB
transcriptor-ai
Local, no-cloud meeting transcription for macOS. See README.md for what this is, how to
install dependencies, and how to run it — this file is for working on the code: decisions,
reasoning, conventions, and current implementation status.
Design/decision history lives in a long chat transcript, not in this repo yet. This file is the distilled result. If something here seems arbitrary, assume there was a reason discussed elsewhere rather than re-litigating it from scratch — but flag it if it looks wrong given the current code.
Goal
Primary: follow a meeting's transcript live while it happens, so the user can stay focused on listening instead of note-taking. Meetings mix French and English mid-sentence (code-switching), include non-native English accents, and technical/internal jargon that should be correctable via a small per-meeting glossary.
Out of scope for now, do not pull forward without being asked:
- Real-time content analysis / suggested questions during the meeting (explicitly phase 2).
- Post-processing (fuzzy-match glossary correction, LLM reread via llama.cpp). The pipeline currently publishes qwen-asr's raw output straight to the SSE broadcaster. Revisit only when asked — don't propose implementing this preemptively, it's been deferred twice.
Only live transcription (capture → VAD → ASR → SSE) is in scope today.
Target hardware
MacBook (base) M3, 24 GB unified memory. Not a high-end Max/Pro chip — size and quantize models accordingly (prefer 4-bit; avoid anything like an 8B+ reread model). Do not assume generous headroom. A separate M4 Max/128GB machine exists for development but is not the deployment target; don't let it justify heavier defaults.
Architecture
audiotee (subprocess, 2 outputs)
├─ stdout → system audio track (PCM)
└─ --mic-output → mic track (PCM, via a FIFO for live streaming, not a plain file)
│
▼ (per track, independently)
VAD (simple energy-threshold heuristic, hand-rolled, no ML dependency for v1)
│ chunks with overlap
▼
ASR: qwen-asr subprocess (Qwen3-ASR-1.7B, --stream, --prompt for glossary biasing)
│ timestamped segments: {track, text, is_final, timestamp}
▼
merge (chronological, across both tracks)
│
▼
SSE endpoint (served by this Go binary) — terminal client is the first consumer
Post-processing (fuzzy-match glossary correction, then LLM reread via llama.cpp subprocess) was in the original target architecture between merge and SSE, but is explicitly deferred — see "Out of scope for now" above. Raw qwen-asr output is published as-is today.
Why these choices (don't re-decide without reason)
- Two tracks, two independent VAD+ASR pipelines (one per track), not one merged audio stream. This is a cheap "me vs them" diarization proxy — no real diarization model needed. Rejected merging into a single flow: it was considered and reverted, keep 2+2.
- Go, not Python or Swift, for this orchestrator. All the actual ML work (ASR, LLM
reread) is delegated to external subprocess binaries regardless of orchestrator language,
so the orchestrator's job is just: spawn subprocesses, pipe bytes, run a small HTTP/SSE
server, merge by timestamp. That's Go's home turf (
os/exec,net/httpstdlib, single static binary, no runtime dependency). Python was rejected for its dependency/environment management overhead; Swift was rejected because its ML ecosystem (Silero VAD, MLX model loading for this specific model) is immature enough to require significant custom porting work that buys nothing here. Rust was a close second to Go but adds ownership/borrowing ceremony with no corresponding benefit for I/O-bound glue code. - VAD: hand-rolled energy-threshold heuristic, not Silero, for v1. No Swift/Go ML ecosystem is mature enough here to justify the integration cost. Upgrade only if segmentation quality turns out to be the actual bottleneck — verify before adding complexity.
- ASR runtime:
qwen-asr(https://github.com/antirez/qwen-asr), a pure-C implementation with--streamand--prompt(glossary biasing) already built in. Consumed as a subprocess, one instance per track. Build is trivial on macOS: no dependency install needed beyond the OS's built-in Accelerate framework (make blas). No prebuilt binaries are published, so it's vendored as a git submodule (third_party/qwen-asr, pinned to a specific commit) and built byscripts/build-dist.sh— this exists specifically sotranscriptor-ui(the planned native app, see "Related repos") can produce a fully self-contained, one-click-install.appwith no separate manual clone/build step for the end user; models are the only thing still fetched at runtime, deliberately (multi-GB, doesn't belong baked into a build). Verified empirically:--streamdoes not print incremental partial text to stdout for a single bounded stdin stream — only the final transcription, once done. Our own VAD segment boundaries are what stand in for "live" granularity, not qwen-asr's internal streaming. One subprocess call per VAD segment (measured ~0.15-0.4x realtime including process startup and model load, on both the 0.6B and 1.7B models) is fast enough — no need for a persistent daemon process. - LLM reread runtime:
llama.cpp, installed viabrew install llama.cpp(prebuilt, official Homebrew formula — no source build needed). Model: small, 4-bit quantized, sized for the 24GB target machine, not the dev machine. Not wired in yet (deferred). - Live display: SSE (Server-Sent Events), not WebSocket. The data flow is one-directional
(server → clients); SSE is plain HTTP, trivially consumable by a terminal client and later
by a browser (
EventSource, no client library needed). WebSocket would be over-engineering for a need that has no bidirectional requirement today. - No Docker. Ruled out on technical grounds, not preference: Docker Desktop on macOS runs a Linux VM with no Metal GPU passthrough, which breaks any Metal-accelerated inference, and Core Audio access from inside a container is impractical.
Message format (SSE)
One JSON object per transcript segment (defined in internal/transcript):
{"track": "system", "text": "...", "is_final": false, "timestamp": "2026-08-07T14:32:01.123Z"}
is_final: false = a streaming ASR partial hypothesis that may still be revised.
is_final: true = the segment is closed (VAD ended the utterance).
No timestamp-based reordering across tracks yet — messages are broadcast in the order
transcribeTrack produces them. Measured ASR latency is small relative to segment spacing
(3-5s), so this hasn't been observed to cause visible out-of-order delivery. Add a small
reordering buffer in internal/transcript if it ever becomes a real problem — not before.
Related repos
../audiotee — the Swift CLI this project spawns as a subprocess for audio capture (see
internal/capture). Consumed as a subprocess deliberately, not imported as a Swift library
(AudioTeeCore is exposed as one, but that only matters for a Swift consumer, which this
isn't) — see its own CONTEXT.md §6.2 for why that repo's TCC-identity signing work matters
specifically because of this. Two tracks (system audio + mic) go to two separate outputs
(stdout + a FIFO), never multiplexed onto one stream — two concurrent Core Audio IO threads
writing raw PCM chunks to a shared fd risked interleaving/corruption.
../transcriptor-ui — a native macOS SwiftUI app (separate Xcode project), the "one-click"
way to actually use this — built and working as of 2026-08-08, not just planned anymore.
It spawns transcriptor-ai as a subprocess (same pattern as transcriptor-ai → audiotee —
each layer manages the one below it via a subprocess + a clean protocol, never merged into
one project/toolchain) and is an SSE client of it, same as transcript-tail, just with a
native UI instead of a terminal. Its Xcode build phase builds this repo's scripts/build-dist.sh
and ../audiotee's scripts/build-signed.sh and embeds all three binaries in the .app
bundle — verified fully self-contained (tested with ~/bin/audiotee moved aside, capture
still worked from the embedded copy). Deliberately not a monorepo with this one — see
"Why these choices": mixing Go and Swift tooling in one Xcode project would undermine the
reason Go was chosen for this repo in the first place. transcriptor-ui will eventually let
the user pick ASR model/options and configure the glossary and post-processing
(question-detection, meeting-summary generation via a local LLM) — model selection is still a
dev-time hardcoded path there, nothing else is wired up yet; check transcriptor-ui's own
CLAUDE.md for its current state rather than assuming.
-audiotee-binary flag exists specifically for transcriptor-ui's benefit: without it,
internal/capture only finds audiotee via PATH then ~/bin/audiotee, which is fine for
CLI use but wrong for a bundled app that must not depend on anything pre-installed outside
its own .app — transcriptor-ui passes its embedded copy's path explicitly.
Conventions
- Go idioms:
gofmt/go vetclean, standard project layout (cmd/,internal/). - Prefer the standard library over third-party Go modules where it's a reasonably close fit
(
os/exec,net/http,encoding/json) — the "self-contained binary, no dependency bazar" property is a deliberate, stated goal of this project, not an accident. - Code and comments in English. Conversation with the user in French (matches the sibling
audioteerepo's convention). - Don't reintroduce Python, Swift, Docker, or WebSocket for this project without a new, explicit reason — see "Why these choices" above.
- User-facing install/usage docs belong in
README.md, not here — keep this file about reasoning and internal state, not instructions for running the tool.
Status
Capture → VAD → ASR → SSE is implemented and verified end to end:
internal/capture— audiotee subprocess wrapper (stdout + mic FIFO).internal/vad— energy-threshold segmentation.internal/asr— one qwen_asr subprocess call per segment.internal/transcript—Broadcaster(SSE),FileWriter(durable text file, one line per message, fsync'd on every write),Messageformat shared by both.cmd/transcriptor-ai— wires all of the above together; currently also the test harness.cmd/transcript-tail— minimal SSE terminal client. Exits calmly (code 0, "stream ended (server stopped?)") when the server shuts down, instead of an alarming raw connection error.scripts/build-dist.sh— buildsqwen_asr(from thethird_party/qwen-asrsubmodule) andtranscriptor-aiintodist/, ready fortranscriptor-uito embed. Verified: builds cleanly from the submodule, and the resultingdist/qwen_asractually transcribes (smoke-tested), not just "it compiled."-capture-system/-capture-micflags (transcriptor-ui's benefit: a UI toggle for what to capture). Asymmetric by necessity, document this if it ever confuses someone:-capture-mic=falseskips mic capture at theaudioteelevel entirely (no--capture-micpassed, no mic permission requested) —internal/capture.Config.CaptureMiccontrols this.-capture-system=falsecannot do the equivalent — audiotee has no flag to skip its system tap, it's always running — so system-only-disabled just meanstranscriptor-aireceives system chunks (VAD still runs on them, negligible cost) but never dispatches them to an ASR worker (main.go'sdispatchhelper drops them). Verified all three modes (system-only, mic-only, both) via CLI: mic-only correctly showed zerotrack: systemmessages even with system audio playing, confirming the drop works, not just the flag parsing.
Decision (2026-08-08): the future native macOS UI (transcriptor-ui, a separate repo/Xcode
project — see below) will not write the transcript file itself; transcriptor-ai does, via
-transcript-file, specifically because it's the more crash-resistant of the two (simpler
control flow than a UI process) and the whole point of this file is surviving a crash.
Verified: real human speech (English + French code-switching) through an actual microphone,
with both qwen3-asr-0.6b (fast iteration) and qwen3-asr-1.7b (the target model). 1.7B
measured at ~0.4x realtime (2.3s to process a 5.9s clip, model load included) — comfortably
fast enough for one-subprocess-per-segment. Switching models is a runtime flag
(-asr-model-dir), not a rebuild.
Three concurrency/signal-handling bugs were found and fixed via real testing (not just code review):
-
transcribeWorker(then still per-track, namedtranscribeTrack) was using the SIGINT-canceled context for still-queued transcriptions (fixed with a separate background context for ASR calls). -
main()wasn't waiting for the ASR worker goroutine(s) to finish before exiting (fixed with async.WaitGroup). -
internal/capture.Stop()calledcmd.Wait()before the pipe-reading goroutines had finished draining, whichos/execdocs call out as incorrect and can truncate the last bit of audio. -
qwen_asrsubprocesses were killed by the terminal's own Ctrl+C, independent of any of the above: child processes inherit the parent's process group by default, and a real terminal's Ctrl+C sends SIGINT to the whole foreground process group — not just totranscriptor-ai. So an in-flightqwen_asrcall died with "signal: interrupt" regardless of the context-handling fix above, which only guards against our own code cancelling it. Fixed ininternal/asr/runner.gowithSysProcAttr.Setpgid: true, putting eachqwen_asrin its own process group. This bug was invisible tokill -INT <transcriptor-ai-pid>, the way earlier manual tests in this session were done — that only signals one PID, not a process group, so it never reproduced the real terminal behavior. Verified instead by sending SIGINT to the process group (os.killpgfrom a small Python harness, orkill -INT -<pgid>— careful with the latter run from an interactive shell, it'll also hit the shell itself if unrelated jobs share its group). -
Duplicate text at force-cut segment boundaries, found from real usage on a real meeting-like recording (documentary audio) — visibly bad enough that the transcript was unusable, e.g. "à vivre" transcribed once at the end of a segment and again at the start of the next. Root cause: the 300ms
Overlapaudio carried into the next force-cut segment (for ASR context continuity) was being fully re-transcribed and re-published, so both copies showed up in the live transcript. Fixed by defaultingvad.Config.Overlapto0— accept an occasional word cut in half at a hard boundary rather than systematic duplication, which is far worse for actually reading the transcript. Found and fixed alongside it:tail()ininternal/vad/vad.gotreatedn <= 0as "return the whole buffer" instead of empty, which would have madeOverlap: 0carry over the entire previous segment instead of nothing — fixed to returnnilforn <= 0. If overlap is ever reintroduced, it needs text-level dedup on the published output, not just raw audio carry-over. -
System and mic tracks transcribing the same content — not a code bug, a structural limit of tapping raw Core Audio input: if audio plays through speakers (not headphones), the mic picks up the acoustic leakage and transcribes it too. Conferencing apps do their own acoustic echo cancellation on the signal they send to other participants, but that doesn't touch what our raw mic tap sees. Real-world implication: headphones are likely a hard requirement for the two-track "me vs them" diarization to actually hold during real meetings, not just a nice-to-have — unresolved, needs a decision (require headphones, or build real AEC — a much bigger feature) rather than a quick fix.
-
Shutdown could feel unresponsive during a large in-flight transcription backlog (no feedback while waiting, especially on the slower 1.7B model). Fixed with a "shutting down, waiting for in-flight transcriptions..." message and a second Ctrl+C now force-exits immediately instead of making the user wait through the full drain.
ASR memory: single shared worker instead of one per track (2026-08-10). The 1.7B model
("large" in transcriptor-ui) has no quantized-weights option — qwen_asr only supports
BF16 (see third_party/qwen-asr/README.md, "Memory Requirements": ~6.9GiB static footprint
for the 1.7B model). Before this fix, cmd/transcriptor-ai/main.go ran one transcribeTrack
goroutine per track (system, mic), each independently spawning qwen_asr subprocesses — so
whenever both tracks had speech at the same time, two 1.7B instances ran concurrently,
measured peaking at ~13GiB combined, over half of the 24GB target machine's unified memory
(see "Target hardware"). Fixed by merging both tracks onto one shared worker/channel
(asrSegs, transcribeWorker — renamed from transcribeTrack since it's no longer
per-track): dispatch() now sends every enabled track's segments onto the same channel
instead of two separate ones, so qwen_asr invocations are strictly serialized regardless of
which track they came from. Per-track chronological order is preserved (VAD's own output
channel is already chronological across both tracks; the worker just drains it in that
order). Trade-off: if both tracks talk at once, one track's transcription now waits for the
other's to finish instead of running in parallel — accepted, since the memory pressure was
the more pressing constraint on the 24GB target machine. Verified empirically, not just "it
compiles": ran the real pipeline with both tracks capturing simultaneous audio (a video
through system output, say for spoken input) and polled pgrep -x qwen_asr at 0.3s
intervals throughout — max concurrent qwen_asr processes observed was 1, confirmed never 2,
while both tracks still produced correctly-ordered transcript segments.
Not yet implemented: post-processing (deferred, see "Out of scope for now"), and anything
from audiotee's CONTEXT.md §6.4-adjacent concerns beyond what's listed here.
Resume point (2026-08-07): the live pipeline is done and validated on real speech with both models, and the force-cut duplication bug is fixed and verified. The system/mic acoustic leakage issue (headphones vs AEC) is still an open decision. No specific next task was chosen before pausing beyond that — ask the user rather than assuming which.