AI Audio Lab
Local voice-cloning and long-form narration workshop for Apple Silicon. 66 orchestration scripts drive a 9-stage pipeline from raw script to approved final stitch, with filesystem-based state so any stage can resume without in-memory queues. Qwen3-TTS voice cloning, faster-whisper ASR-QA scoring, forced alignment, prosody direction, and FFmpeg mastering.
Producing long-form narration from a reference voice sample without paying per-second to a hosted TTS API. Everything runs locally on Apple Silicon. The hard part was surviving overnight runs across dozens of takes without a supervisor babysitting the queue.
Every stage reads from the filesystem and writes to the filesystem. No in-memory queue, no external orchestrator, no database — TSV manifests + per-take JSON status files are the coordination surface. Stage 0 (authoring) starts with transcripts/devlog-final.txt. Stage 1 (parse-transcript.py, 209 LoC) splits into a reftext-manifest TSV. Stage 2 (qwen-run-one-take.py 168 LoC, qwen-run-queue.py 155 LoC) generates one take per chunk, writing a status.json with running → ready|timeout|qwen_failed|postprocess_failed transitions. Stage 3 QC (qwen-asr-qc.py) transcribes each take with faster-whisper and computes similarity. Stage 4 human review works off marker WAVs. Stage 5 approval (approve-take.py, 87 LoC) writes an approvals file. Stage 6 marker polish adds room-tone bed. Stage 7 (stitch-approved-final.py, 88 LoC) produces one master WAV from approved takes only. Stage 8 (qwen-align.py 368 LoC + qwen-prosody-edit.py 196 LoC) supports word-timing and per-word prosody edits for directed performance. The 9-stage doc (agents/PIPELINE-STAGES.md) labels stages 6–8 as "planned," but the code has caught up — all 8 named scripts exist and are non-trivial.
qwen-run-one-take.py is a supervisor, not just a runner. It launches the Qwen3 clone subprocess in a fresh process group (subprocess.Popen with start_new_session=True at :108), tracks supervisor PID + child PID in status.json (:95-96, :111), and enforces per-take timeout (default 900s) with os.killpg(proc.pid, SIGKILL) at :116 so a stuck model doesn't leave zombie children. Each take's status file (SHA-256 of input text as identity at :82) transitions through running → ready | timeout | qwen_failed | postprocess_failed. A crashed supervisor doesn't lose progress — the next queue-runner picks up by re-reading status files. There's a matching qwen-retry.py (160 LoC) that grep-selects failed status files and re-runs just those.
qwen-asr-qc.py runs each generated take through faster-whisper (default small.en, int8 compute at :36-40 — chosen for CPU speed on Apple Silicon), text-normalizes both the reference script and the ASR transcript (lowercase, punctuation stripped, optional filler-word drop at :47-54), and scores similarity via difflib SequenceMatcher. Threshold classification at :77-80: `>= 0.92` = pass, `>= 0.80` = warn, otherwise fail. ffprobe measures actual audio duration for the report (:57-74). Output is a TSV that qwen-qc-shortlist.py sorts by approval decision × similarity so a reviewer opens the ambiguous cases first. This is not full WER (word-error rate) — it's a character-similarity ratio, deliberately chosen because filler tokens matter less than whether the take says the intended sentence.
Kokoro, Chatterbox, Qwen3-TTS, and openmoss-sfx have incompatible Python dependency trees — some pin different torch versions, some need mlx-audio for Apple's Metal path, some conflict on transformers. Solution: each tool gets its own uv-managed virtualenv under envs/{tool}/, and scripts explicitly invoke the right interpreter (e.g., qwen-run-one-take.py at :56 hardcodes `envs/qwen3/bin/python` when spawning the clone subprocess). The audio-status shell script surfaces which envs are provisioned; audio-env activates one. The pattern lets a single repo host four otherwise-incompatible model stacks without conda or docker overhead.
apply-pronunciation-glossary.py (79 LoC) and find-pronunciation-terms.py (86 LoC) manage per-project pronunciation overrides for acronyms, technical symbols, and proper nouns that the base TTS mispronounces. voice_direction.py (287 LoC) reads per-marker JSON hooks from prosody/ (e.g., prosody/hook-c03.json) that direct performance style — energy, pause length, emphasis — for the render. Qwen3 forced alignment (qwen-align.py, 368 LoC) produces sub-word timestamps that qwen-prosody-edit.py (196 LoC) consumes to apply per-word pause / stretch / gain adjustments to already-rendered takes. This is the difference between "clone a voice" and "direct a performance."
stitch-approved-final.py (88 LoC) concatenates only takes that passed the approval file, applying FFmpeg crossfades between segments, edge trims to strip silence, and loudness balancing so different takes don't have jarring level jumps. stitch-marker-audio.py (93 LoC) does marker-level stitching for shorter cuts. postprocess-voice.sh drives named profiles (light, deep-warm, energetic) as reusable FFmpeg chains. add-roomtone-bed.py fills sub-audible gaps with a matched room-tone loop so pauses don't sound artificially dead — the difference between an amateur and a broadcast-adjacent narration.
This isn't a productized SaaS. There's no auth, no multi-user, no cloud, no HTTP API surface. It's a workbench with strong operational discipline: filesystem as state store, supervisor + status.json as the crash-recovery mechanism, TSV manifests as the diff-able input, one script per stage. The design goal was ergonomics for a solo operator doing overnight runs — and being able to trust that whatever the pipeline is doing at 3am, the state on disk is enough to catch up in the morning.