Wires the commit-cadence policy to an actual reminder instead of a script nobody runs. - scripts/commit_check_notify.py: runs scripts/commit_check.sh (piped to bash -s so the script is never opened on the external volume), logs to logs/commit_check.log and posts a macOS notification via osascript when the tree is dirty. Defaults to --any (week's work) ; --age-only restores the >threshold behaviour; --no-notify for testing. - scripts/commit_check.sh: new --any mode (warn on any uncommitted change, ignoring age) for the weekly job. - LaunchAgent fires Sunday 18:00; launcher ~/.umbraaudio/umbra_commit_check.sh mirrors umbraweb_login.sh (cd project, export PATH, exec .venv/bin/python). The venv python is the only interpreter approved for the external volume, so a shell never reads a project file. - Verified under launchd: detects dirty tree, notification sent, exit 1; clean tree exits 0. Probe artifacts removed.
31 KiB
UmbraAudio — Development Conventions
Commit Cadence (do not skip)
- Commit at least once every Sunday — never let a week of work sit uncommitted. On
2026-09-20 a single accidental
git checkout -- umbraaudio/web/routes.pydestroyed ~2,300 lines of a month's uncommitted work; the file only survived because OpenCode keeps its own editor snapshots, not because of git. Do not rely on that again. - End-of-Sunday ritual:
git status→ stage the code (source, tests, specs, scripts, config) →git commit. Book projects,tmp_out/, logs and the one-offumbraaudio/web/static/samples/A/B demo are intentionally ignored (see.gitignore); nevergit add -Awithout checking, and never leave a dirtyumbraaudio/web/routes.pyfor days. - Mechanical nudge:
scripts/commit_check.shexits non-zero (1) when the tree has been dirty longer than a week — it measures the oldest uncommitted edit, ignores submodule working-tree dirt, and prints a reminder block. Options:--days N,--any(warn on any dirt, ignoring age),--quiet,--help. Run it by hand or via the reminder below. - Weekly reminder (installed): LaunchAgent
com.umbraaudio.commitcheckfires Sunday 18:00 and posts a macOS notification when the tree is dirty. Plist loaded from/Users/worstauthor/Library/LaunchAgents/com.umbraaudio.commitcheck.plistand mirrored to/Volumes/Dock/worstauthor/Library/LaunchAgents/(login-scanned). It runs/bin/zsh /Users/worstauthor/.umbraaudio/umbra_commit_check.sh→cdproject →exec .venv/bin/python scripts/commit_check_notify.py. As with the web server, a shell never opens an external-volume file: the venv python reads the repo, pipesscripts/commit_check.shtobash -s(so the script itself is never opened on the volume), and shellsosascriptfor the notification. Logs tologs/commit_check.log; exit 1 = dirty (deliberate). Test withlaunchctl kickstart -k gui/$(id -u)/com.umbraaudio.commitcheck.
System & Deployment Notes
- macOS 26.6.2, home relocated: user
worstauthor's home dir is/Volumes/Dock/worstauthor(dscl +$HOME);/Users/worstauthorstill exists as a legacy/boot-volume path and is fine for scratch scripts. launchd scans the relocated home'sLibrary/LaunchAgents. - Web UI access:
umbraudio.bdmurphy.meis served by a Caddy reverse proxy on ANOTHER machine on the LAN, forwarding to this Mac — so the web server MUST bind0.0.0.0(it does, port 8420). LAN IPs:192.168.123.12(en0) and192.168.123.154(en1). macOS App Firewall is disabled (inbound 8420 open). Any new firewall/user grant must keep0.0.0.0:8420open. - launchd PATH is bare (
/usr/bin:/bin:/usr/sbin:/sbin): anything depending on Homebrew tools (notably ffmpeg -> m4b/MP3 export) fails with[Errno 2] No such file or directory: 'ffmpeg'when run under the web server.umbraweb_login.shnow exportsPATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"before exec'ing python — keep that export if editing the launcher. A server started before 2026-09-09 needs a restart to pick it up. - macOS 26 external-volume restriction (critical): processes NOT launched from an approved
context (everything spawned by launchd/cron — shells like
/bin/zsh/cat, systempython3) getEPERM: Operation not permittedon any read/write of/Volumes/1TB_APL_Storage. Only the venv python binary (.venv/bin/python) is volume-approved, and only in its own process — verified: under launchd it canimport umbraaudio,open()/write project files, but a shell cannot even open a script on that volume (/bin/zsh: can't open input file), and launchd dies withEX_CONFIG 78if a job's StandardOut/ErrorPath points at the volume. Consequence: auto-start must keep ALL project I/O insidepython -c '...'— never let a shell open an external-volume script, never point launchd stdio at the volume. - Login auto-start (not boot): LaunchAgent
com.umbraaudio.web(RunAtLoad+KeepAlive, crash → auto-restart) runs/bin/zsh /Users/worstauthor/.umbraaudio/umbraweb_login.sh, whichcds into the project andexec .venv/bin/python -c "<inline uvicorn>", with pythondup2- ing stdout/stderr intologs/web_server.log. Plist lives at/Volumes/Dock/worstauthor/ Library/LaunchAgents/com.umbraaudio.web.plist(login-scanned) AND/Users/worstauthor/ Library/LaunchAgents/com.umbraaudio.web.plist(manuallaunchctl load). Starts whenworstauthorlogs in. For headless/pre-login (boot) start: a root LaunchDaemon (sudo launchctl bootstrap system) using the same python-inline pattern is the proven route. - Rejected/unused: boot-time LaunchAgent/LaunchDaemon with shell-opened external scripts or
external-volume stdio (TCC-blocked, EX_CONFIG/127); cron (same TCC denial); a
sudoers.dNOPASSWD-to-worstauthorbridge was trialed and removed — unnecessary once everything stays in the venv python. Test plists/probes were cleaned up. - Qwen TTS server (port 8881) is MANUAL start only — the login LaunchAgent starts only the
web server. After any reboot the Qwen server is down and speaker "re-voice" silently degrades
to Edge-TTS (web log:
Qwen3-TTS voice reference failed, falling back to Edge-TTS: Cannot connect to Qwen TTS server at http://localhost:8881), which cannot do raspy/texture and may pick an arbitrary voice (e.g. a male for a female-low request). Restore withnohup .venv/bin/python scripts/qwen_tts_server.py(matches the prover-2821 pattern; LLM and Qwen must not be co-resident on the 16 GB Mac — wait until any LLM attribution batch is done). - GPU Qwen3-TTS box (192.168.123.14) + Local Network TCC (critical): macOS 15+'s Local
Network privacy blocks launchd-spawned user processes (incl. the web server) from ANY LAN
IP — they get
EHOSTUNREACH("No route to host") while GUI/terminal processes reach the LAN fine. Loopback is NOT blocked. Workaround (deployed): a root LaunchDaemon relay (/Library/LaunchDaemons/com.umba.qwen-relay.plist+qwen_relay.py,sudo launchctl bootstrap system ...) forwards127.0.0.1:18881 -> 192.168.123.14:8881; the web app targetsUMBRA_TTS__QWEN_TTS_URL=http://127.0.0.1:18881. Root system daemons DO bypass Local Network TCC (verified LAN_OK 200). Box runsscripts/qwen_tts_server.pywithQWEN_DEVICE=cudaon an RTX 3060 (12 GiB), models at/home/bm/umba/models/…, prompt cache at/home/bm/umba/voice_prompts, deps in/home/bm/umba/venv(torch 2.11.0+cu128, accelerate==1.12.0, python-multipart + onnxruntime REQUIRED), started manually vianohup ./gpu_server_env.sh. Box GPU shared with an LM Studio LLM (~6.6 GiB); when TTS runs, the LLM must be unloaded first (curl -X POST http://192.168.123.14:1234/api/v1/models/unload -d '{"instance_id":"qwen/qwen3.5-9b"}') or model swaps OOM and the connection drops ("Server disconnected without sending a response." → Edge fallback). Relay bug history: asocket.create_connection(..., timeout=10)left a 10s recv timeout on upstream — any TTS call10s was killed mid-response while
/healthand/loadsurvived; fixed withup.settimeout(None)after connect. Test plists/probes (com.umba.netcheck,netpost,rootnetcheck) were bootstrapped into launchd and cleaned up; after any of these env changes, restart web vialaunchctl kickstart -k "gui/$(id -u)/com.umbraaudio.web".
Project Overview
Quality-first multi-pass audiobook generation from English manuscripts. Each pass has quality gates. Quality is the primary concern; generation speed is secondary.
Architecture
- 5-pass pipeline: Analysis → Voice Design → Mapping → Test → Generate
- Multi-engine TTS: Edge-TTS (primary for voice variety), Chatterbox (secondary for cloning)
- LLM-enhanced: google/gemma-4-12b-qat for character discovery and profiling
- Web review: FastAPI + HTMX at checkpoints for human approval
Voice System
- Narrator: Dedicated soft-spoken male voice (en-US-GuyNeural:-10), deep, radio speaker style
- Characters: 59 unique voices from Edge-TTS with pitch shifting
- Voice Assignment: Stored in
.umbra/voice_assignments.json - Test Samples: Verification phrase uses the Rainbow Passage (phonetically balanced paragraph covering all English phonemes)
Tech Stack
- Python 3.11+, asyncio throughout
- SQLite via aiosqlite for project state
- FastAPI + HTMX for web review UI
- Click + Rich for CLI
- Edge-TTS for voice generation
- LM Studio (
google/gemma-4-e4b) for character discovery and speaker attribution
Commands
- Lint:
ruff check umbraaudio/ scripts/ - Format:
ruff format umbraaudio/ scripts/ - Type check:
mypy umbraaudio/ - Test:
pytest tests/ -v - Run:
python -m umbraaudio.cli - Web:
python -c "from pathlib import Path; from umbraaudio.web import create_app; from umbraaudio.config import load_config; import uvicorn; config = load_config(Path('.')); app = create_app(config); uvicorn.run(app, host='0.0.0.0', port=8420)" - Mechanical re-segment:
python scripts/resegment_all.py --mechanical-only - LLM speaker attribution:
python scripts/reattribute_all.py --model google/gemma-4-e4b - LLM character discovery: 4-pass pipeline via
/api/analyzeendpoint (see specs/02-analysis.md)
Code Conventions
- Type hints on all function signatures
- Docstrings on public classes/methods
from __future__ import annotationsin every file- No comments unless explaining why, not what
- Use
loggingmodule, neverprint()for debug output - Async for all I/O-bound operations
- Dataclasses or Pydantic models for structured data
- Paths via
pathlib.Path, never string paths
Testing
- Tests mirror source structure:
umbraaudio/parsers/→tests/test_parsers.py - Use pytest fixtures for database, config, mock TTS engine
- Mock external services (LLM APIs, TTS engines) in tests
- Integration tests for full pipeline with sample manuscript
File Naming
- Modules:
snake_case.py - Classes:
PascalCase - Functions:
snake_case - Constants:
UPPER_SNAKE_CASE - Private:
_leading_underscore
Dependencies
- Check
pyproject.tomlbefore adding new dependencies - Never add a dependency if stdlib suffices
- Prefer: stdlib → third-party → custom implementation
Current Session Summary
Session: Xylia hiss fixed (post-TTS white-noise) + GPU box live
- Xylia's "hissing at the end" was NOT the TTS: her
vocal_traitspile inspeakers.json(LLM-discovery dump incl. "raspy"/"gravelly") triggeredTRAIT_EFFECTS["raspy"]=noise_inject(uniform white noise @0.04) + pitch_jitter applied POST-TTS to every utterance — most audible as hiss when speech stops. Measured E>6 kHz: ref 18%, her clip 33.8%, raw clone w/o effects 19.4%, after fix 16.6% (tail rms 0.001 = calm stop). Fixed by cleaning Xylia'svocal_traitsto["deep","measured","ancient","highly articulate"](+ keptdistortion_effects: []) — no effect matched; the Qwen ref/ICL already carries the gravel. She was the ONLY speaker whose traits matched effects (audit over all speakers). Existing Xylia utterances across ch24–46 still carry the old noise layer until regenerated; ch1 sample utt_0012 regenerated clean (16.6% HF). Backup:/tmp/speakers.json.bak.
Session: Dramatic utterance variations (emotion/pace/custom instruct)
Goal: The per-utterance Emotion/Pace/Custom-instruct controls in the chapter UI didn't
"change from neutral". Root cause: variations were dropped, not weak — every voice with a
ref clones via generate_voice_clone, which never received the instruct, and pace was read
nowhere.
Root cause chain: _remote_generate (qwen_tts.py) only sent voice_description in the
no-ref branch → any speaker with a _ref.wav (narrator + all 50 characters) ignored emotion/
instruct; generate_voice_clone (emb = _clone) and ICL (_icl) in qwen_tts_server.py had no
instruct slot; pace never consumed. Model already supports instruct+clone simultaneously via
instruct_ids prepended to the talker input (modeling_qwen3_tts.py:2076).
Fixes:
scripts/qwen_tts_server.py:_icl+_cloneacceptvoice_description→ buildinstruct_idsviamodel._tokenize_texts([model._build_instruct_text(s)])[0]and pass through**kwargs(flows via_merge_generate_kwargs→model.generate(instruct_ids=...)).qwen_tts.py _remote_generate: sendsvoice_descriptionalongsidevoice_ref/prompt_slug(server keeps speaker identity, instruct bends delivery).generation/chapter.py: 26 emotions (EMOTION_OPTIONS: original 18 +tense/urgent/horror+ drama tiersfurious/joyful/terrified/griefstricken/shocked), all presets rewritten as short directorial directives (≤100 char, ruff E501);PACE_OPTIONSslow/moderate/ fast +PACE_INSTRUCT; newcompose_utterance_instruct(instruct, emotion, pace)— neutral/ moderate contribute NOTHING so no-variation clones stay natural;_build_utterance_instructcomposes base profile + custom + emotion + pace.- All three generation sites use the composer and pass instruct through every voice mode:
chapter_builder.py(embedding/prompt_slug/ref branches),scripts/generate_all_audio.py,routes.pysingle-utt/api/utterance/{id}/generate.write_markernow records the instruct (staleness regenerates on variation changes). - UI
chapter_audio_detail.html: emotion dropdown now lists all 26 options. - Verification: A/B on narrator clone (
/tmp/var_{neutral,angry,furious_fast,slow}.wav) — angry hotter/louder (mean −20.6 vs −23.5 dB, peak −3.6 vs −6.5), furious+fast ~8% shorter + darker, slow distinct; audible delivery change while voice identity holds. Newtests/test_variations.py(14 tests: composer, sync checks, remote payload carriesvoice_descriptionwith ref). Full suite green; ruff clean; mypy only pre-existing env noise. - Full regen — COMPLETE: cleared all 4101
*.wav.txtmarkers (+ch*_full.wav/mp3andch*_build_status.json) because newer JSON markers lackinstructand would have SKIPPED regen. Rannohup .venv/bin/python scripts/generate_all_audio.py --project DealHunterShip_4.0e_whole-to-audio. Result (09-09 04:18 local):=== COMPLETE: 4304 generated, 0 failed, 678.6 min ===(11.3 h). All 32 chapters re-stitched fresh; markers now carryinstruct(variation edits re-trigger regen). Vocalsproject (expression showcase) rebuilt: Rainbow Passage × all 26 emotions × 2 voices (Narrator utt0–17, Deal Hunter utt18–35). Cleared its 36 markers and ranChapterBuilder(config, chapter_id=1).run()in a fresh process (the :8420 web server still held pre-wiring imports). 36/36 generated, quality passed, 6m12s. Every marker now records the composed instruct; delivery measurably distinct (angry +4.9 dB mean vs neutral, romantic 8.7 s vs 6.2 s, whisper soft peak, shock longer/wilder). User confirmed "usable".- Transient TTS stall episode (was NOT content): regen and a probe both stalled ~20 min at ch4
utt_0002, blocking
_model_lock. Suspected©/℗— falsified: direct/v1/ttswith raw text (27.7 s), exact batch params +ref_text+ phonetics (18.7 s) both fine. It's the known random MPS/orphan-generation class (per-token_local_scalar_dense_mps→MPSStream::synchronizecrawl). Recovery per docs: kill -9 qwen server + restart (scripts/qwen_tts_server.py); batch resumed marker-safe at utt_0002 and completed. If a stall recurs: restart the server, don't rebuild the batch. - AR package rebuilt + verified (auto-watcher):
logs/ar_pre_rebuild_snapshot.jsoncaptured the stale package (built_at 2026-09-08T02:29:59Z + 32 MP3 md5s); on regen completion the one-shot watcher/tmp/auto_rebuild_ar.pyranARPackageExporter(data_dir).build(discover_chapters(data_dir), load_meta(data_dir), force=True). Verified (logs/ar_rebuild_report.json):built_at2026-09-09T11:21:11Z, 32 tracks, 0 warnings, all 32 tracks changed vs snapshot,loudness_ok/peak_okall true, ch4 on-demand MP3 spot check RMS −20.5 / peak −3.0. Dashboard shows the newbuilt {{ ar_manifest.built_at }}automatically. - Operational notes: web
POST /api/audiobook/export-mp3only guards web-side generation, NOT external batch processes — do not click AR export while agenerate_all_audio.pybatch runs (ch12+ would be silently dropped while theirch*_full.wavare missing). Watcher-side, completion gate = batch PID gone +=== COMPLETE: … 0 failed ===inlogs/regen_variations.log+ all 32ch{id}_full.wavmtime ≥ regen start; on failure it aborts without rebuilding.
Goal
Fix speaker-attribution errors so the user can review/accept voices with confidence. Reported failures in chapter "Salvage" (ch6): utt35 "We have strict orders…" (after "she asks the lead guard… He replies") was tagged Kabir instead of the lead guard, and utt156–157 "The voice calls again" / "Help me, please." was tagged Deal Hunter instead of the female space-survivor (Kainda).
Root Cause Analysis
- No rule for unnamed role tags: narration "the lead guard", "the robot" → LLM guessed a named character present in the scene (Kabir) instead of the role.
- Old prompt rule 3e over-applied: any "voice"/comm → Deal Hunter, so a human voice ("Help me, please.") became the ship AI.
- Context window too narrow:
_build_position_contextonly looked 8 utterances back → "It sounds female," (13 lines before) fell outside, so a female survivor's voice went to Deal Hunter. - No verification pass: attribution was trust-on-faith; wrong speakers were never caught mechanically.
ModelCoordinator.load_lmwas not idempotent: re-entering CHAPTER_ANALYSIS always re-POSTed/v1/models/load→ a second copy of a 9 GB model failed to allocate on the 16 GB Mac.
Fixes Implemented (this session)
umbraaudio/analysis/llm_attributor.pyprompt: new rule 3e (Deal Hunter/AI-1 ONLY when narration explicitly names a computer/AI/comm system; a bare "a voice is heard"/"the voice calls again" over radio is HUMAN — use gender cues like "it sounds female"), 3f (pronoun coreference; never resolve to an absent character or a named char when the antecedent is an unnamed role), 3g (unnamed roles → the role speaker's EXACT primary_name e.g. "Chief of the Guard", else a Title Case role name; NEVER a random named character), 3h (repeated lines — "The voice calls again, 'Help me, please.'" — keep the first occurrence's speaker). Context widened:_extract_scene_narration12→20 lines;_build_position_context8→40 utterances back, labelled dialogue, 4200-char / 45-part caps.- New
umbraaudio/analysis/attribution_qa.py(mechanical cross-check):check_attribution()flagsspeech_tag_mismatch(conf 0.9 resolved / 0.7 unresolved-name),narrator_dialogue(0.95),role_tag_guessed(0.5). Tag applied only from the IMMEDIATELY preceding narration (never across a dialogue line — prevents shared-tag bleed like "Kainda asks," + reply). Pronoun subjects skipped (coreference is the LLM's job)._names_match()falls back to word-overlap for unresolved titles ("Wakam" vs "Trader Wakam", "Admiral Wake" vs "Admiral Wilbur Wake").fix_high_confidence()writes back only resolved exact-name mismatches. Role regex capped at 2 role words and rejects trailing conjunctions ("the robot and tells"). - New
scripts/qa_attribution.pyCLI:--chapters N,--fix,--type._build_alias_mapincludes primary, aliases, and first-word mappings. umbraaudio/voices/model_coordinator.py:load_lm(sync + async) now no-ops when the requested model already has a loaded instance — re-entering CHAPTER_ANALYSIS no longer tries to allocate a second copy..umbra/speakers.jsonaliases added:Admiral Wake/The Admiral→Admiral Wilbur Wake,Captain Antlion/Antlion→Captain Wolfgang Antlion,Wakam/Wakam the Trader→Trader Wakam,Vicky→Duchess Victoria,Pirate Leader/The Pirate Leader→Pirate Clan Leader,Niñera/Nini/The Nanny→Nanny (Niñera = the nanny robot, Support Robot 74532). This session:Lead Guard→Chief of the Guard,Lead Trainer→Training Officer,Bill→Worker Robot (Bill),Captain Alec Weston→Captain Weston.
Completed Work
- Full 35-chapter LLM re-attribute with the new prompt (
google/gemma-4-e4b, ~21 tok/s at ~1.3 min/batch; total ~2 h): 1878 dialogue utterances, 0 left as narrator. Chapters 0–4 and 33–35 are front/back matter with no dialogue (skipped as "no utterances" or 0 dialogue). - QA + auto-fix loop: book-wide QA fell from 38 → 10 issues. 21 auto-fixes applied (resolved tag mismatches) plus 5 evidence-driven role fixes.
- Fixed speakers: ch6 utt157→Kainda Walters Foster Murphy ("Help me, please." = female survivor); ch6 utt44/49/52/56/211→Nanny (the "robot"/Niñera who rushes in and explains the attack is the nanny bot); ch8 utt18/33→Trader Wakam; ch9 utt22→Duchess Victoria (tag "Vicky says assertively"); ch16 utt120→Pirate Clan Leader (the "modified voice" on the call); ch17 utt94→Deal Hunter; ch18 utt154→Xu Ming, utt168→Kainda; ch20 utt230/utt345→Warbots / Pirate Clan Leader; ch23 utt63→Deal Hunter; ch24 utt15→Kainda; ch25 utt131/utt224→Kainda, utt133→Warbots; ch30 utt82/115→Admiral Wilbur Wake, utt113/133→Duchess Victoria, utt162/170→Kabir, utt194/241→Captain Wolfgang Antlion; ch32 utt68/87→Captain Wolfgang Antlion.
- Ghost-speaker sweep (post-review, driven by "Kabir isn't in ch6"): script cross-checked every
assigned speaker against all name/alias tokens present in the chapter text (stopwords filtered,
alias-aware — Willamina's "Captain" alias counts). Found and fixed:
- ch6 utt38/40→Nanny (were
Kabir; the "service robot … named Niñera" who enters with the suit says these; Kabir appears only once in ch6, in narration as the off-screen brother). - ch9 utt2/8/11/13→Training Officer (were
Admiral Wilbur Wake; narration: "She is talking to the lead trainer for the young royals" / "the argument with the training officer"; profile: [9]). - ch16 utt122/123/124→Pirate Clan Leader (were
Admiral Wilbur Wake; the "modified voice" scolding the failing pirate, same voice as utt120). - ch24 utt16→Pilot (Freighter) (were
Alec; same freighter pilot as utt18/20; Alec is a ch8-only Wakam associate). - ch26 utt128/139→Frigate Captain (were
Admiral Wilbur Wake; "the pirate captain … yells" on the frigate bridge), ch26 utt135→Frigate Captain (were Willamina; "Seal the bridge" order after the captain sees the video). Profile: [24,26]. ch26 utt194→Kainda Walters Foster Murphy (were Admiral Wilbur Wake; she's the one who made it to 12.5G in the pilot chair — Warbots reply "Yes, Captain").
- ch6 utt38/40→Nanny (were
- Remaining QA flags are deliberate assignments the checker can't see intent behind (Nanny=robot, Kainda="voice", Warbots, Pirate Clan Leader) — treat as clean.
- pytest: 181 passed, 3 warnings (17 in
tests/test_attribution_qa.pycovering tag mismatch, alias resolution, tag isolation, pronouns, word-overlap fallback, conjunction rejection, auto-fix gating).ruff check+mypyclean onattribution_qa.py,qa_attribution.py,model_coordinator.py, tests. - Hardware reality (16 GB Mac): gemma-4-12b needs 16.88 GB → cannot fit; qwen3.5-9b fits but generates ~1 tok/s under swap (~15 h/book) → not viable; gemma-4-e4b is the practical best (~21 tok/s). LLM and Qwen TTS must not be resident simultaneously (coordinator enforces).
Flagged for User Review (ambiguous, left as-is)
| Loc | Line | Currently | Question |
|---|---|---|---|
| ch16 utt121/125 | "The ship landed with a different designation…" / "Yes." | Adonai Maul | The unnamed pirate subordinate failing the Clan Leader (Adonai Maul is a ch27 female bounty — wrong). No role profile; suggest a new "Pirate Commander" |
| ch20 utt223 | "You are taking out multiple air pressure areas…" | Deal Hunter | Ship AI warning — likely already correct |
| ch24 utt108 | "We don't know. Checking." | Deal Hunter | Probably a bridge/crew voice, not Deal Hunter |
| ch26 utt129 | "Captain, it is a real alarm. Warbots just boarded…" | Willamina | Frigate bridge crew reporting to the pirate captain (not Willamina, she's on Deal Hunter); no crew role profile |
| ch26 utt155 | "Four minutes and fifty seconds." | Warbots | Who is "the speaker"? A leader/announcer? |
| ch26 utt158 | "Captain, they have taken the bridge…" | Willamina | Narration: "Status info is radioed to Deal Hunter, who tells Kainda" — could be Deal Hunter the AI, not Willamina |
Known Pre-existing Bugs (reported, not fixed)
scripts/generate_chapter_audio.py:132references undefinedphonetic(NameError on first utterance; superseded bygenerate_all_audio.py).start_server.shlaunches the Gradio qwen-tts-demo on port 8000 — NOT the API serverscripts/qwen_tts_server.py(port 8881) the app actually uses. Useqwen_tts_server.pyfor restarts.- Orphaned Qwen generations: a client that times out does NOT cancel the server-side thread; the
model keeps generating under
_model_lockfor minutes, blocking subsequent requests. Recovery =kill -9the qwen server PID and restartscripts/qwen_tts_server.py. - dictionaryapi.dev free tier rate-limits; residual bad records no longer matter for audio (dictionaryapi records are not applied to speech), would need bulk re-lookup to clean review panel.
Audio-Generation Prep (voice resolution + staleness)
umbraaudio/generation/staleness.py: markers are now JSON{"speaker", "text"}instead of plain text.is_current(text, wav, speaker=...)only returns True when BOTH match. Legacy plain-text markers are ALWAYS stale → any existing ch1–7 wavs regenerate with corrected attribution on the next run. All call sites updated (generate_all_audio.py,routes.py×3), speaker default normalized to"narrator".scripts/generate_all_audio.py:load_voice_referencesnow usesrglob(finds nested refs likehuman_staff_person/messenger_ref.wav);resolve_referencenow does case-insensitive canonical alias lookup, unique first-word match, and longest-substring fallback (was exact/casefold only → silent narrator fallback). 50/50 distinct speakers now resolve to a voice ref (0 fallback).- New speakers added (speakers.json + voice_profiles.json + generated refs):
Crew Member(ch24 bridge crew, male, clipped urgency;crew_member_ref.wav) andSupervisor(ch10 female production supervisor, stressed/assertive;supervisor_ref.wav). Both generated viascripts/generate_voice_references.py. - All 184 pytest tests pass; ruff clean on
staleness.py,generate_all_audio.py, tests. - Next: run
scripts/generate_all_audio.py(nohup) — Qwen TTS server up (port 8881); all 4406 utterances will regenerate since legacy markers are stale (~6.4 s/utt, multi-hour run).
Remaining Edge Cases
Chapter-5 audio verified end-to-end (files, markers, stitch, ASR). A/B samples:
/tmp/author_note_before.wav, /tmp/author_note_natural.wav, /tmp/ch5_combined.wav. Optional
future tooling: a per-utterance Whisper-based QA transcription pass over ch*_full.wav.
Previous Session (chapter-5 audio fix)
Complete — ch5 audio (markers, staleness, pronunciation, curly punctuation) verified. History below this section documents the model-coordinator session and speaker-attribution completion.
Session: Edge-TTS fallback visibility + GPU Qwen3 box probe
Goal: the user had no way to know when a voice silently fell back to Edge-TTS, wants Qwen3 used almost always, and asked whether a GPU box's Qwen3-TTS (192.168.123.14:1234) could serve generation.
Done (this session):
- Fallback is no longer silent.
VoiceDesigner.generate_voice_reference(voice_designer.py) now retries Qwen up toTTSConfig.max_retries(default 3, backoffretry_delay, tests used 1-2) and on exhausted attempts logs ERROR "FALLING BACK to Edge-TTS" and records it durably in a new per-project ledger{project}/.umbra/voice_engines.json:{speaker: {engine: qwen|edge, reason, at, ref_mtime}}. Ledger written atomically (tmp+replace) next to thevoices/dir. - Web UI indication (
routes.py_load_speakers+ templates): each speaker row shows a red "⚠ Edge-TTS fallback" badge (title = reason) when the ledger saysedgeANDref_mtimematches the current ref wav (so stale entries don't flag), else a green "Qwen3-TTS ✓" after a Qwen generation./speakers/partialalso runs a live health check onconfig.tts.qwen_tts_url(viaQwenTTSEngine. check_health) and_speakers_list.htmlrenders an amber banner before generating when Qwen is unreachable ("will fall back to Edge-TTS") or a red N-voices banner when fallbacks exist. - Verified: real Qwen gen records engine=qwen; forced bogus-URL run retries 2×, falls back, records engine=edge with connection reason + matching ref_mtime; both project /speakers pages render (200). ruff clean on changed regions (routes.py has 116 pre-existing errors, none from these edits); mypy only pre-existing stubs noise (soundfile/parselmouth/numpy pyi).
- Note: existing refs predate the feature (no ledger) → no badge until next regen. Regenerate any speaker flagged "edge" after restarting Qwen to get the real Qwen3 voice.
GPU box (192.168.123.14:1234) probe — findings:
- It is an LM Studio server.
/v1/chat/completionsis a real endpoint; every audio path (/v1/audio/speech,/tts,/v1/tts,/audio/speech) returns LM Studio's catch-all{"error":"Unexpected endpoint or method. (POST ...)"}→ this LM Studio build cannot synthesize audio. Catalog only has theqwen3-tts-tokenizerGGUF (Q8_0, 291 MB) downloaded — the 12 Hz codec/tokenizer component, NOT a usable full TTS model. - Our app does NOT speak LM Studio's protocol anyway:
QwenTTSEngine(qwen_tts.py) talks the custom FastAPI/v1/tts+/v1/voice-promptprotocol ofscripts/qwen_tts_server.py(transformersQwen3-TTS-12Hz-1.7B-VoiceDesign,QWEN_DEVICEenv selects cpu/mps/cuda, default mps). - To use the GPU for real: deploy
scripts/qwen_tts_server.pyon the GPU box (venv with CUDA torch +qwen_ttspip package + the HF Qwen3-TTS-12Hz-1.7B-VoiceDesign model — local copies already exist undermodels/; if using LM Studio instead, a TTS-capable LM Studio version + full model set would still need a client rewrite, so it's NOT worth it). Then point the Mac at it viaTTSConfig.qwen_tts_url(envUMBRA_TTS__QWEN_TTS_URL=http://192.168.123.14:8881). SSH from this Mac to the box is denied (key+password) — needs user-provided access or manual provisioning. - Xylia (Last Dwarf): regenerated lower — median F0 141.6 → 111.9 Hz via a pinned
voice_descriptioninspeakers.json("lowest baritone register possible, much deeper than any man… light Irish accent"). Pin overrides auto-built description verbatim (voice_profile_manager.py~619) and is preserved on future regenerations.