The earlier note blamed git-over-HTTP on Local Network privacy and used a url.insteadOf workaround. The real finding: HTTP genuinely does not work for git (per-binary entitlement - curl reaches it), while SSH on 2222 works but the connect is intermittently refused with 'Undefined error: 0'. The Host mini-eq ssh block now sets BatchMode, ConnectTimeout and ConnectionAttempts 5, so ssh retries the refused connect; plain git-over-ssh is 5/5 reliable and needs no GIT_SSH_COMMAND. Also recorded both pinned Mini-EQ host keys - :22 and :2222 keep separate keys - and that ssh-keyscan without -p 2222 scans the wrong port.
87 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". - SSH from this machine to oldbox WORKS (2026-10-05) — this supersedes the old "denied, needs
manual provisioning" note. Key-based auth as
umbra; use theoldboxalias, no flags.- Root cause of the original failure was never the network or host keys — it was
authorization. Diagnosis that settled it:
ssh -v -o BatchMode=yesshowedOffering public key: …id_ed25519→Permission denied, while also showingHost … is known and matches the ED25519 host key. So the host key was always fine and the key simply was not inumbra'sauthorized_keys. Fix was one command run on oldbox:mkdir -p ~/.ssh && chmod 700 ~/.ssh echo 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIqSyRVyS65iI+IJht2HIYVrf72WCBj3P572nfYc5nvU umbra_gpu_deploy' >> ~/.ssh/authorized_keys chmod 600 ~/.ssh/authorized_keys - This machine's key:
~/.ssh/id_ed25519, commentumbra_gpu_deploy, no passphrase, fingerprintSHA256:qrq0WqszS/MNHkYsCTaZH/PQv+bx+Ox7PwyfyOpa2WM. Loaded into the agent withssh-add --apple-use-keychainso it survives reboots (the agent previously held no identities, which silently breaks every non-interactive/automated SSH path). - Host keys pinned and matching (checked 2026-10-05, no drift): ED25519
SHA256:YrZxtGNm0cN7q82k3mL5NBPeSIAjIoiLcSjKgpW90EQ, ECDSASHA256:fdTVV77wv/eFEjYUK2PKcwseZC015IXuW5TcELvuSGA, RSASHA256:fa60nisrlObOOEOi4Keo8m7b4t/TVDYsww6HlRfm/8Q.oldbox.bdmurphy.meresolves to 192.168.123.14 and both forms are inknown_hosts. ~/.ssh/configgained aHost oldboxblock (User umbra, IdentityFile~/.ssh/id_ed25519,IdentitiesOnly yes, ServerAlive 30/3). Backup alongside it:config.bak.20261005_203303.- The remote-broker
oldbox-umbraprofile works now too (wasexit_code 255Permission denied → nowexit_code 0), so agent-driven remote commands go through it. Note the profile is namedoldbox-umbra, whileoldbox-umbra-ssh/oldbox-umbra-sudoare only Keychain credential names —remote_status({profile:"oldbox-umbra-ssh"})fails with "Unknown remote profile". Config lives in~/.config/umbra-trans/remote-broker.json(it pins~/.ssh/known_hosts). - Do NOT trust a fingerprint you were handed without checking it. A supplied
SHA256:95qbJicDPOmBN3AORHCNFoFi7wxYX6EYZTLB2SXq4Lkmatched no host key here and no local.pubfile — it was neither oldbox's nor this machine's key. Verify withssh-keyscan -T 8 -t ed25519,ecdsa,rsa <host> | ssh-keygen -lf -(read-only, stores nothing) and compare againstknown_hostsbefore pinning anything. - oldbox specs (via ssh): Linux Mint 22.1, 16 cores, 31 GB RAM, 1.4 TB free on
/home. RTX 3060 is present (/dev/nvidia0, PCIGA106 … GeForce RTX 3060), butnvidia-smifails forumbrawith "Failed to initialize NVML: Insufficient Permissions" — a permissions gap, not a missing GPU. Anything that needs CUDA/NVML from this account (e.g. GPU Qwen TTS) will need thevideo/rendergroup membership fixed first.
- Root cause of the original failure was never the network or host keys — it was
authorization. Diagnosis that settled it:
- Relay correction (2026-09-27) — the two claims above were both wrong in practice; verified:
- The
settimeout(None)fix was NOT in the deployed file./Library/LaunchDaemons/qwen_relay.py(mtime Sep 10 06:33) had nosettimeoutcall, so the 10s-mid-response-kill bug was still live. Patched (backupqwen_relay.py.bak.20260927) and the daemon restarted withsudo launchctl kickstart -k system/com.umba.qwen-relay. Re-check the deployed file before assuming a documented fix is present. - The relay was never down.
launchctl print system/com.umba.qwen-relayreportsstate = runningand root holds127.0.0.1:18881LISTEN. An unprivilegedlsofcannot see root processes, which makes the relay look absent; andcurlto a relay whose upstream is down returnshttp_code=000because the relay accepts then drops. So18881 -> 000means "relay up, upstream refused", not "relay down". Always confirm withsudo lsof/sudo pgrep.
- Actual current failure: the GPU box
192.168.123.14:8881refuses connections (it answers ping at 0.4 ms but the port is closed — its Qwen server is a manualnohup ./gpu_server_env.shstart and is down).qwen_relay.pyhas no upstream fallback, so every call is dropped and the app falls through to silent Edge-TTS. Restoring/keeping the relay cannot fix this; the box must be started. The working local alternative ishttp://127.0.0.1:8881(loopback is exempt from Local Network TCC, which is why the relay is only needed for the LAN path).
- The
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: Login gate on the review UI (replaces the open surface)
- The review UI is now authenticated. Until 2026-10-09 it had no auth at all:
active_projectis an unsigned cookie and the server binds0.0.0.0, so every page - including mutation endpoints and audio downloads - was reachable from the LAN and, through the Caddy proxy, the internet. Spec 21/22 called this an unauthenticated mutation surface; it is now closed locally. - Gate is middleware, not per-route dependencies (
umbraaudio/web/__init__.py), so a newly added route is protected by default and forgetting a decorator cannot expose the UI. Fail-CLOSED: an exception while checking a session DENIES the request. Falling through to "no session required" would turn a bug into an open door. umbraaudio/auth.py- PBKDF2-HMAC-SHA256 (stdlib: the project rule is stdlib before third-party) + random per-user salt. Sessions are HMAC-signed, carry no secret (just username + expiry), compared withhmac.compare_digest. Store writes are atomic (tmp +os.replace) so an interrupted save cannot lock the user out. Bothauth.jsonandsession_secretare chmod 0600 under/.umbra/, which is gitignored (line 42).- First use: username
bdumbra, one-time passwordFirst321, stored hashed and markedmust_change, so the bootstrap password never lands on disk in plaintext and is dead after the first login. Password rule: more than six chars, >=1 letter and >=1 special. Sessions 14 days, sliding. - Login failures are deliberately vague - wrong username and wrong password give the SAME message, so the form cannot enumerate accounts.
- 19 tests in
tests/test_auth.py, incl. tampered/forged/expired tokens, plaintext absence, 0600 mode, corrupt-store recovery. Full suite 649 passed (8 pre-existing). - Deliberately separate module:
auth.py+web/auth_routes.pymirror thename_token_routespattern, so the hotroutes.pystays untouched and controller-delegated identity can replace this without touching route code. - End state is the controller on oldbox authenticating centrally (spec 21/22). Until
then this is the local stand-in - see
oldboxfor that plan.
Session: Distribution spec (AR + Spotify); peak gate fixed; Deal Hunter regen in flight
- Distribution target: Author's Republic + Spotify for Authors. INaudio is ruled out - DVN must be an unmodified LPF from Google Play Books / ElevenLabs / Spoken Press only, and submitting TTS as "Human Narrated" risks removal/account action. Umbra Audio is neither.
- Spotify route is
Spotify for Authorsdirectly, not INaudio: Spotify accepts DVN with a mandatory disclosure checkbox, and its DVN titles are not shared to referral partners - so Spotify is the entire reach of that submission. Non-exclusive, free, no title limit. - The two specs agree on everything except head silence. INaudio wants 0.5-1 s, AR wants 1-5 s; the only overlap is exactly 1.0 s (tail 2.0 s satisfies both). Aligning to AR at head 1.0 s keeps the masters INaudio-ready if that ever changes.
- Sample Track is intentional and stays: a 93 s multi-voice capability demo, not book text. My earlier flag (item 6) was INaudio's "no credits in a sample" rule, which no longer applies. Spotify only requires <10 min, no spoilers.
- Narrator rule: first name
Umbra, last nameAudio, sole narrator - all Umbra Audio books. Credit lines read<Title>, Written by <Author>, Narrated by Umbra Audio. - Peak gate fixed (commit
0962d82) - see below. Closing credit ordering fixed too. - Pending decision (operator chose "fix later"):
export_m4bencodes-c:a aac -b:a 128kwith no-ar, so it emits 24 kHz / ~96 kbps audio while the MP3 masters are 44.1 kHz / 192 kbps CBR mono. For a file sold directly this is measurably worse than the MP3 set. Re-export the m4b as-is now; fixing it to 44.1 kHz is a separate follow-up. Also open: spec 22 SS7 gap 1 -export_m4b._stitch_chapterhard-codessilence_between_utterances_ms=500, silence_between_chapters_ms=0whileChapterBuilderusesAudioStitcher()defaults (500/2000/4000). - IN FLIGHT: full Deal Hunter audio regeneration (PID from
logs/regen_full.log). 2,924 of 4,319 utterances are stale due to a pre-existing index drift: the audio holds a leading chapter-heading utterance that the DB no longer has, so in ch6 every marker storesDB[i-1]and none storeDB[i]. Deliberately NOT "fixed" by shifting markers - that would make the staleness check pass while leavingutt_*.wavmisaligned with utterance ids, the same declared-vs-measured class of bug just fixed in the audio gate. DB backup:/tmp/project.PRE_END.20261008_111927.db. The End.now replaces a bareENDas ch32's final utterance (DB +source_text); its marker was invalidated so it re-renders. Not yet in audio.- Website was already in the closing track -
ch35/utt_0001.wav.txtrenders "My Website is authorbdmurphy.com". Notech35.source_text(a 616-char author bio) disagrees with its three utterances; the utterances are the good closing credit. A re-segmentation would overwrite them - worth fixing before anything re-segments. - NEXT: after regen finishes, re-export BOTH the AR MP3 package (which applies the peak fix
and closing-last ordering) and the m4b. Never export mid-run -
export-mp3stitches whateverch*_full.wavexist, and has no guard against a concurrent batch.
Session: GPU box retired; Qwen TTS boot start; public DNS findings
- GPU box
192.168.123.14is RETIRED (2026-10-08) - offline and being reconfigured. Removed from configuration:.envno longer setsUMBRA_TTS__QWEN_TTS_HELPER_URL(default is"", so this is a no-op fallback),MEANINGFUL_PORTSlost the18881relay entry, andumbra_node_report.pynow reportscontroller_endpoint: Nonewith acontroller_statusexplaining why.config.pycomment updated. Local:8881is the only TTS backend. The root relay/Library/LaunchDaemons/com.umba.qwen-relay.plist+qwen_relay.pystill need removing with sudo.- NOT a blocker for the controller. The UmbraTrans controller is still in
development; specs 21/22 proceed unchanged. GPU capacity will return through the
controller system by a different route, not via the removed direct relay. Do not
reintroduce
qwen_tts_helper_url/:18881speculatively - re-add a TTS backend only when the controller exposes one.
- NOT a blocker for the controller. The UmbraTrans controller is still in
development; specs 21/22 proceed unchanged. GPU capacity will return through the
controller system by a different route, not via the removed direct relay. Do not
reintroduce
- Qwen TTS now auto-starts at boot (
com.umbraaudio.qwentts, installed to/Library/LaunchAgentsfor the same mount-race reason as the web server). Launcher is/Users/worstauthor/.umbraaudio/umbra_tts_login.sh, kept on the boot volume because a launchd shell cannot open anything on the external volume; it onlycds and execs the venv python, which does all I/O including the stdout/stderrdup2intologs/qwen_tts_server.log.KeepAliveverified bykill -9: respawned in ~4s. LM Studio is deliberately manual - an LLM model and the TTS model cannot be resident together in 16 GB.- Do not point launchd stdio at
/Volumes/1TB_APL_Storage- the job dies withEX_CONFIG 78. Both plists therefore have noStandardOutPath/StandardErrorPath.
- Do not point launchd stdio at
- The root qwen relay is retired (
/Library/LaunchDaemons/com.umba.qwen-relay.plist+qwen_relay.py, was running as root PID 850 and therefore invisible to unprivilegedlsof/ps). Booted out and moved to/Users/worstauthor/.umbraaudio/retired/*.retired.20261008- deliberately moved out of/Library/LaunchDaemonsso nothing can re-scan them.:18881is now closed. - The web UI already self-heals TTS:
ModelCoordinator.ensure_qwen_server()(model_coordinator.py:238) spawnsscripts/qwen_tts_server.pyviaPopen(start_new_session=True)when/healthfails. It is called only fromswitch_to()(line 293) - i.e. on demand during pipeline work, NOT at boot or web startup. It checksqwen_health()first, so a launchd-owned instance is reused rather than double-spawned: the two mechanisms are complementary, not conflicting. umbraaudio.bdmurphy.meTLS: Caddy fails withinternal error(alert 80) on the vhost whilebdmurphy.meitself presents a valid cert. Cause was the missing public A record - with no DNS entry an ACME HTTP-01 challenge cannot validate, so on-demand TLS aborts the handshake. Caddy is at192.168.123.5(NOT the public IP: port-forwarded from 97.113.1.212). Diagnosing this via the public IP is misleading: the LAN host answers :80 and :443, the public IP does not hairpin from inside the LAN.- Useful negative result: Caddy answers
308 -> https://<Host>/for any hostname, including invented ones, so a redirect Location cannot be used to infer vhost config. Only an SNI handshake (openssl s_client -servername ...) distinguishes them.
- Useful negative result: Caddy answers
Session: Voice-maintenance guard (409) + boot auto-start failure diagnosed
- The banner lied by omission.
voice_maintenancewas wired intoroutes.pyonly as a template global (_voice_maintenance_for_template, ~line 65) — a display hook, not a gate. Nothing stopped generation. Clicking Generate during an LLM run would retry Qwen, exhaust attempts, and write a degraded Edge-TTS ref to disk. Commiteb37777adds_voice_maintenance_block()returning 409 and wires it into the six endpoints that synthesize audio:generate_utterance_audio,delete_utterance,create_utterance,regenerate_speaker_voice,generate_all_speaker_voices,generate_full_audiobook.- Deliberately NOT blocked:
speakers_list_partial(a Qwen health check — that IS the banner the reviewer needs) andset_voice_status(reads an existing_ref.wavto capture a vmatrix — part of the approval workflow). Blocking either would break review while paused. - Verified live: all 6 return 409 with an explanatory body; the 2 allowed ones return 400.
- Deliberately NOT blocked:
active_voice_maintenance()is pid-gated (os.kill(pid, 0)), so a crashed run self-heals. This will bite you when testing: writing the marker from a short-lived process returnsNoneand the guard looks broken. Test with a live holder process, e.g.python -c "from umbraaudio.voice_maintenance import set_voice_maintenance; import time; set_voice_maintenance('TEST'); time.sleep(240)" &. Verified self-heal: kill the holder → marker goes inactive → endpoints unblock.- The web server did NOT restart on boot — root cause was a mount race, and it is FIXED.
The plist was always sound (
RunAtLoad+KeepAlive, no volume stdio paths, so noEX_CONFIG 78). The real fault:$HOMEis/Volumes/Dock/worstauthor(relocated) and launchd scans$HOME/Library/LaunchAgentsONCE at login. Measured 2026-10-08: boot 05:58, Dock mounted 05:59 — the scan ran before the volume existed, so the plist was invisible and the job never loaded. The/Users/worstauthor/Library/LaunchAgentscopy was never auto-scanned either, because it is not$HOME.- Fix installed: the plist now lives in
/Library/LaunchAgents/com.umbraaudio.web.plist(system-wide, on the boot volume, scanned for every user regardless of$HOME). The$HOMEmirror was removed — two plists sharing oneLabelis a race, since launchd loads only the first and errors on the other. Backup at/Users/worstauthor/.umbraaudio/com.umbraaudio.web.plist.dock.bak; the identical/Users/worstauthor/...copy is retained as a manual-bootstrap/rollback source. - Verified running from the new path (
launchctl print→path = /Library/LaunchAgents/ com.umbraaudio.web.plist,state = running, PID 9612 on *:8420). Corroborated empirically:com.carriez.RustDesk_serveris loaded from/Library/LaunchAgents, proving launchd does scan that directory in this session. - Manual recovery (either works):
launchctl bootstrap gui/$(id -u) /Library/LaunchAgents/com.umbraaudio.web.plistor... /Users/worstauthor/Library/LaunchAgents/com.umbraaudio.web.plist. - launchd footgun learned the hard way:
launchctl bootstrapagainst an already-loaded Label fails with 125 "Domain does not support specified action", andbootout+bootstrapin one scripted step can hit it if the job is still loaded. Never suppress stderr onbootout, and confirm the port is actually down before bootstrapping — otherwise you get a false "bootstrap failed" and think you broke something. Also noteKeepAlivemeanskickstart -kis the right way to restart, not bootout/bootstrap. - Still worth a reboot check when convenient — the durable proof is a real login.
- Fix installed: the plist now lives in
- The
edittool and bare shells cannot write/Volumes/1TB_APL_Storage(EPERM), and/Volumes/Dockflapped independently over minutes — sometimes readable, sometimes not, whilels/dfkept working. Use.venv/bin/pythonfor edits on the volume (it IS volume-approved), and re-check access before assuming a file is writable. umbraaudio.bdmurphy.mecannot be verified from this Mac — two independent reasons. (a) No public A record exists. Queried the authoritative nameservers (dns1/dns2.registrar-servers.com) directly, bypassing every cache: they return97.113.1.212for the apexbdmurphy.mebut no record at all for theumbraaudiosubdomain. The LAN resolver (192.168.123.254, nginx) also says NXDOMAIN. So this is DNS-side, not a cache artifact. (b) NAT hairpin failure — from inside the LAN, TCP to 443 on the public IP fails the TLS handshake even for the apex, so the app behind Caddy cannot be probed locally. Caddy on97.113.1.212answers :80 with a blanket308 -> https://<Host>/for any hostname (verified with an invented one), so vhost presence cannot be inferred. The operator states the site is running — treat (a) as "no public DNS record yet", not "down". The app itself is verified healthy on LAN192.168.123.12:8420.
Session: Gitea on Mini-EQ wired up; UmbraAudio + Qwen3-TTS pushed
- UmbraAudio now has an origin. It had zero remotes for its whole life; first
remote is
minieq→http://192.168.123.18:3001/umbragit/UmbraAudio.git. Qwen3-TTS is now its own project atumbragit/Qwen3-TTSso our branding/integration commit is actually shareable — previously the submodule pointed at upstream GitHub, so anyone cloning got vanilla upstream without our change. - Both repos are PRIVATE (login required). Verified anonymous access is genuinely
refused: repo API → 403,
info/refs?service=git-upload-pack→ 401. Chosen over "public on the LAN" because Gitea-public means anonymous clone works for anyone who can reach the host. - Push order (as requested): existing history first (all 39 commits,
29667d0), then a separate commit for the new items (0c9bea7, 49 files / +9,374 lines:infra/enterprise tooling,backup/, specs 18 + 19,lmstudio_gate.py, and the in-flight discovery/attribution/config/chapter_builder/emotion/pronunciation/qwen_tts/routes edits). Local and remote both at 40 commits, HEAD identical. - ⚠ Qwen3-TTS was a SHALLOW clone (depth 2) — pushing as-is would have published a
broken fork: an orphaned commit with no parent history, and clones would be truncated.
Ran
git fetch --unshallow(restored 14 commits, still exactly 1 ahead of upstream) before pushing. Check clone depth before mirroring any submodule. - Submodule repointed as requested:
.gitmodulesnowurl = ssh://git@mini-eq:2222/umbragit/Qwen3-TTS.git. Consequence to remember: agit clone --recursiveof UmbraAudio now depends on the LAN reaching Mini-EQ; off-network the clone breaks. That is the deliberate trade-off for getting our changes. - How to authenticate (no secret is stored in this file).
credential.helper=osxkeychainis set locally in.git/configand a Gitea personal access token (scope read/write:repository) is in the login Keychain for host192.168.123.18:3001, userumbragit. Git therefore needs no password on the command line. The token itself is deliberately not recorded here — it was pasted in chat once and should be revoked; regenerate at Gitea Settings → Applications, then re-seed withgit credential approve. If the Keychain entry is lost, that is the only step needed. - Mini-EQ addressing quirk:
mini-eqresolves to 192.168.123.138, and the server is also reachable at 192.168.123.18 — verified the same machine (identical Gitea, SSH banner, ED25519 host key). Both forms are inknown_hosts. - The Git server is SSH on port 2222. The remote is now
minieq→ssh://git@mini-eq:2222/umbragit/UmbraAudio.git. TheHost mini-eqblock in~/.ssh/configcarriesPort 2222,User umbragit,IdentityFile ~/.ssh/id_ed25519,IdentitiesOnly yes. - ⚠ The connect to :2222 is INTERMITTENTLY refused with
ssh: connect to host mini-eq port 2222: Undefined error: 0— a macOS Local-Network-privacy-shaped refusal, not a key or auth problem. It comes and goes: the same command line succeeds on one run and fails on the next, andgit pushfails on the first attempt while a retry succeeds. Fix in place: theHost mini-eqblock now hasBatchMode yes,ConnectTimeout 10andConnectionAttempts 5, so ssh retries the refused connect instead of failing the whole push. With that, plaingit ls-remote/git fetch/git pushover SSH is 5/5 reliable and noGIT_SSH_COMMANDis needed (an earlier workaround set one; it is unnecessary now). Config backup:~/.ssh/config.bak.20261106_175458. - Git over HTTP to Mini-EQ does not work on this machine.
git pushtohttp://192.168.123.18:3001/...fails instantly with "Immediate connect fail for 192.168.123.18: No route to host" — whilecurl http://192.168.123.18:3001/api/v1/versionreturns 200 from the same shell. It is per-binary:/usr/bin/gitlacks the Local Network entitlement,curlhas it. The symptom is misleading becausenc -zalso reports the port open. Use SSH, not HTTP. - Two different host keys for Mini-EQ — both pinned, both verified. The
system sshd on :22 and Gitea's SSH server on :2222 keep separate keys,
and
known_hostsstores them separately (mini-eqvs[mini-eq]:2222)::2222ED25519SHA256:SErbWxiAY00aiE2SxzopWjcMKZZuRAh77hAnRzMt/Ls:22ED25519SHA256:15DnUT1iUbbMSLofyggPPXDe4TvJ68CG8cCABXofvvossh-keyscanof the live server matched both pinned values — no key drift. Notessh-keyscan HOSTscans port 22; usessh-keyscan -p 2222 HOSTfor Gitea, or you will "discover" the :22 key and think it changed.
- The remote
maincan move without the local branch. A push was rejected "fetch first" because the remote had a commit (the Apache-2.0 LICENSE/SPDX headers) that the local branch did not — fetch before assuming the local history is the whole story, and merge rather than force. The merge was clean and is now in both histories. - Gitea SSH lives on :2222, not :22. :22 is the system sshd and its
AuthorizedKeysCommandis not wired to Gitea, so no SSH key added in the Gitea UI ever works on :22 — enabling Gitea's built-in SSH server (:2222,OpenSSH_10.0) is what made the key path viable.Host mini-eqin~/.ssh/configis therefore Port 2222; backupconfig.bak.20261006_054313. Gitea keeps a separate host key from the system sshd; the pinned :2222 key is ED25519SHA256:SErbWxiAY00aiE2SxzopWjcMKZZuRAh77hAnRzMt/Ls(verify before trusting a new one). umbragitis NOT an admin (uid 2) — which is why the forced-password-change state blocked the SSH-key settings page, and why instance-level settings needed someone else. A fresh Gitea account starts in a forced password change state that gates the whole Settings area: every API call returns403 "You must change your password"and it is web-UI only (no API route, and the admin CLI needs host access you do not have).- nas2 (192.168.123.2): nothing was ever automating a backup to it (no launchd job, no
cron, no push script), so there was nothing to stop — the
umbra-repoSSH block is just an unused manual target. The oldHost umbra-repoentry is still in~/.ssh/config. .gitignore: added*.db(SQLite project DBs are runtime state; none were tracked). Verified before staging that.env's only change isUMBRA_TTS__QWEN_TTS_HELPER_URLand the committed copy holds no secret-looking values;infra/remote_credential_brokerships onlyconfig.example.jsonand generates secrets at runtime viaread_secret/random_secret.
Session: SSH mini-m4 → oldbox established (was blocked for months)
- Outcome: key-based SSH as
umbranow works;ssh oldbox(alias) andscpboth verified. The remote-brokeroldbox-umbraprofile went255 Permission denied→exit_code 0, so agent-driven remote commands work too. Recorded in System & Deployment Notes. - Root cause was authorization, not networking or host keys. What settled it was
ssh -v -o BatchMode=yes: it showedOffering public key: …id_ed25519immediately followed byPermission denied, while simultaneously provingHost … is known and matches the ED25519 host key. The host key had been correct the whole time; the key simply was not inumbra'sauthorized_keys. One command run on oldbox fixed it. Lesson for next time: run the verbose handshake before theorising about network/TCC/relay causes — it names the failing stage. - Key facts recorded in AGENTS.md: local key
umbra_gpu_deploy(no passphrase,SHA256:qrq0WqszS/MNHkYsCTaZH/PQv+bx+Ox7PwyfyOpa2WM) added to the agent with--apple-use-keychain(the agent had held no identities, which breaks all non-interactive SSH); all three host-key fingerprints pinned and matching;~/.ssh/configHost oldboxblock added with backupconfig.bak.20261005_203303. - Fingerprint hygiene: the
SHA256:95qbJicD…the operator supplied matched nothing — not oldbox's host keys, not any local.pub. Documented the read-only verification recipe (ssh-keyscan … | ssh-keygen -lf -vsknown_hosts) and the rule to check before pinning. - Broker naming trap recorded:
remote_status({profile:"oldbox-umbra-ssh"})fails "Unknown remote profile" —oldbox-umbra-ssh/-sudoare Keychain credential names; the SSH profile isoldbox-umbra(config~/.config/umbra-trans/remote-broker.json, pinsknown_hosts). - oldbox: Linux Mint 22.1, 16 cores, 31 GB RAM, 1.4 TB free. RTX 3060 is present
(
/dev/nvidia0, PCI GA106) butnvidia-smifails forumbra— "Failed to initialize NVML: Insufficient Permissions". That is a group-membership gap, not a missing GPU; GPU CUDA work (Qwen TTS) needs it fixed first. Not yet fixed.
Session: Release comments as a release step + implicit voice approval (spec 22 §1.1)
- Two corrections from the operator, both of which I had wrong:
- "I didn't explicitly approve all minor character voices. By releasing the audiobook I
have given implicit approval those voices are ready and should be tagged." The
previous session reported "24 of 61 speakers have an approval record; 38 do not" as if
it were a gap. It is not a gap — releasing IS the approval, and the ones with no
record were waiting to be tagged.
chapter_approved/audiobook_approvednow read true,approved_by: "this publication record", and publishing grants implicit approval to every voice a listener hears. - The m4b inter-chapter gap should be the same 2 s as the lead/trail padding.
- "I didn't explicitly approve all minor character voices. By releasing the audiobook I
have given implicit approval those voices are ready and should be tagged." The
previous session reported "24 of 61 speakers have an approval record; 38 do not" as if
it were a gap. It is not a gap — releasing IS the approval, and the ones with no
record were waiting to be tagged.
- The precise rule, and its boundary: implicit approval covers voices that have lines
in the released chapters, computed from the DB (
voices_used_in_release), not every entry inspeakers.json. A character who never speaks is not covered — releasing a book does not approve a voice it never uses. Measured on Deal Hunter ed. 1: 24 explicitly approved, 29 implicitly approved, 10 in-profile voices with no lines (not covered). The three categories are never summed; an explicitly approved voice can also be one that stopped speaking.grant_implicit_voice_approval()writes plain status strings (the shape the web UI already reads) and keeps provenance in the immutable publication record, not in the mutable ledger, so it cannot be edited away. - New
umbraaudio/release_comments.py— the release comment is a gate, not a caption.designator = "2026-10-03 #2 14:32:05",slug = release-20261003-143205-02. The sequence is per date, not global — exactly so a same-day correction is unambiguous about which was last. Two comments in the same second still get distinct slugs (sequence allocated by scanning the dir). First line = summary, rest = body, like a commit message. An approved comment is frozen: editing one needs a new comment and therefore a new publication.require_approved()blocks--publish(exits 2 and says what to do next). draft_comment_text()composes the comment from the measured build, so it states what shipped rather than what someone remembers shipping. Deal Hunter's draft already exists (release-20261003-133508-01, status DRAFT) summarising the last m4b build: 32 chapters, the 24/29/10 voice split, 32/32 loudness and peak gates green, and the 32-chapter post-approval padding note.- One audible-pause value:
assembly/export.pyhadgap_ms=4000in one path andchapter_gap_msdefaulting to1000in the other, whilear_export.SILENCE_MS = 2000governed lead/trail — three drifting values for one concept. The m4b gap now importsSILENCE_MSasCHAPTER_GAP_MS, so all three are one number. - Two of my own test premises were wrong and the implementation was right — recorded
because the tests were the weaker artefact: (a) two comments in the same second do not
collide (the per-date sequence prevents it), so the test asserts distinct slugs instead of
an error; (b)
parse_commentstrips leading blank lines, so a padded note cannot silently become a blank summary. - Tests:
tests/test_release_comments.py(26) +tests/test_release.pyextended to cover the three-way approval split. Full suite 538 passed / 8 failed — the same 8 pre-existing/ parallel failures. ruff clean on all new files. - Nothing was published. The Deal Hunter comment is a draft awaiting the operator's
review and approval;
voice_status.jsonis untouched because publishing never ran.
Session: The publish flag, pinned release sets, and the Deal Hunter lesson (spec 22)
- Trigger: operator clarification of the release model — the system stores current + previous state like git, there must be an explicit publish flag the user sets, there are approvals for a speaker voice but none for the audiobook or chapters, the chapters that make the m4b are the versions to flag, and a fix found after assembly causes a change rather than editing what shipped. Deal Hunter is the precedent: 2 s of dead sound was added to the start and end of every chapter after approval.
- The precedent, measured on the real package (
ar_package.json,built_at 2026-09-09T11:21:11Z), chapter 6:ch6_full.wav2227.26 s @ 24 kHz (what a reviewer listens to) vsDeal_Hunter_01_Salvage.mp32231.26 s @ 44.1 kHz (what a listener received) = +4.00 s = 2 s lead + 2 s trail, plus resample and −20.5 dB RMS normalization. Detected on 32 of 32 chapters. The approved artifact and the published artifact were different files and the system recorded neither. - New
umbraaudio/release.py:DeliveryProfile.from_ar_package(reads the profile the exporter actually used, measured not declared),build_publication(read-only; pins every chapter wav + mp3 + package by full SHA-256, the per-chaptersource_unit_key_digest, the approvals, and the delivery profile),verify_publication(→ok/drift/warnings),write_publication(atomic tmp+os.replace, refuses to overwrite an existing publication id),list_publications. Immutability is enforced by the writer, not by convention. - New
scripts/publish_release.py: dry-run by default;--publish,--verify,--list,--edition N --supersedes <id>. Exits 2 whileblockersis non-empty. Against the real DealHunter package it reports: publishable, 32 chapters pinned, 44100 Hz + 2 s lead silence, and 24 of 61 speakers have an approval record, 38 do not (voice_status.jsonis an approval ledger — only speakers someone explicitly touched — so reporting "24 approved" as if it meant "all approved" would claim an approval that was never given; the two counts are kept separate and the gap is named). - Rule that prevents a repeat: you cannot approve a chapter and then pad it. An approval
must name the artifact a listener receives; every transform applied after chapter approval
is recorded in the release as part of it. R-6 records
chapter_approved: falseandaudiobook_approved: falseexplicitly in every record, because R-6 is what would have caught Deal Hunter. - Second, still-latent instance found (
specs/22§7 gap 1, reported not fixed):export_m4b._stitch_chapterhard-codessilence_between_utterances_ms=500, silence_between_chapters_ms=0whileChapterBuilderusesAudioStitcher()defaults (500/2000/4000) andAudioConfigholds its own values — so the m4b path can stitch a chapter with different silence than the approved one. This is the last real hole in the pinned-set invariant, and until it is closed an m4b release record would pin the wrong bytes. Proposed fix: makeexport_m4bconsumech{N}_full.wavinstead of re-stitching from utterances. projects.statusis never advanced toreleasedin any of the four live projects (all sit atparsed/analyzed). The publication record is currently the only release signal in the system.- Tests:
tests/test_release.py— 24 tests (delivery profile, pinning, approval-gap honesty, post-approval padding/resample detection with an epsilon for rounding, quality gate surfacing, blockers, immutability + successor withsupersedes, atomic write, verification drift for changed/missing chapter, changed mp3, standing silence caveat). Full suite 512 passed / 8 failed — the same 8 pre-existing/parallel failures. ruff clean. - No export, TTS, or project data was modified.
publish_release.pyis read-only unless given--publish.
Session: Umbra system node declaration + cross-machine contract (spec 21)
- Context: another machine/tab is moving UmbraTrans to the main Umbra system on
oldbox(192.168.123.14). Four questions were raised for this machine: (1) align the data model and copy UmbraAudio data into the system, verify, transition later; (2) every machine becomes a node that receives any task, not just audio; (3) how changes are managed across products, with the controller owning requirements; (4) does a central server take over the UI, and how do code updates get deployed/tested. - New
specs/21-umbra-system-nodes-tasks-and-changes.md— the shared cross-machine document. Four workstreams (WS1 data alignment, WS2 nodes/tasks, WS3 change management, WS4 UI topology), each with what already exists, what is missing, and a proposal. Proposals are drafts for the controller to ratify; nothing is decided here. - New
infra/umbra-node.json— this machine's declared node profile (node_id: umbraaudio-m4, role worker, products[umbraaudio], services, capability list, resource pools, constraints, project list, lifecycle, schema fingerprints, and an honest "facts_for_the_controller" block). No secrets — ids, names, counts only. - New
scripts/umbra_node_report.py— read-only node report. Proves what the controller needs to align: git state, listening ports,.envKEY NAMES only (never values), per-project row counts, and a schema fingerprint (sha256 over sortedtable.column:type, soALTER TABLEcolumn reordering does not read as drift).--jsonfor machine consumption;--checkcompares the declared profile to measured reality and exits 2 on drift. Verified:--checkexits 0, ruff clean,--jsonparses. Deliberately stable-facts-only drift (contract version, project set, schema fingerprints) — git HEAD and live ports are reported but never drift, or every commit would "break" it. - Measured on this machine (2026-10-01): 4 projects, 2 distinct schema fingerprints —
0078569a0fa6bc70(Last Dwarf, DealHunter: current shape) and367d5df9e546dbba(Treasure Island, Vocals: noname_tokens, missing 9 utterance columns). Confirms the manifest's drift finding is real and machine-checkable. Speaker-count disagreement is now Last Dwarf 93/68/71, DealHunter 61/64/97 (93 after the designation merge; refs up from 94). Full suite re-verified: 461 passed / 8 failed — all 8 pre-existing/parallel (2test_pronunciationhelper_url mock, 6voice_profile_manageraccent). manifest.mdis now explicitly a dated measurement, not a live view, with an update log. Every other §1.1 table count re-verified unchanged. This is the single most important fix for cross-machine trust: another machine reading the manifest must know whether a number is current.- Key answers proposed in spec 21 (all need ratification):
- Authority: controller owns identity/tenancy/requirements/definitions/task state/
lineage/release; the node owns its working area and keeps working when the controller
is down (production availability outranks synchronization). Migration state is per
project:
legacy → mirroring → dual_write → authoritative → retired. - Nodes: stable
node_id, declarednode_contract_version, capability advertisement, task/result envelopes withreport_backevidence, and refusal as a valid audited outcome. This machine must refuse any task needing LLM + local TTS co-resident. - Changes: requirement catalog in the controller; four classes (
local_product,cross_product,shared_definition,operational); distributed, then confirmed with evidence, never pushed. Code ships by git tag per product repo; the controller carries the compatibility matrix and the requirement, not the code. - UI: hybrid. Controller gets the system portal (node health, task state,
requirement conformance, definition versions, releases). The audio review UI stays
on this machine: it is artifact-native (WAV players, per-utterance regen, markers) and
has no authentication at all, so centralizing it would publish an unauthenticated
mutation surface. Deep links are short-lived and signed; the worker footer shows
node_id+definition_set_version+ last confirmation time.
- Authority: controller owns identity/tenancy/requirements/definitions/task state/
lineage/release; the node owns its working area and keeps working when the controller
is down (production availability outranks synchronization). Migration state is per
project:
- Highest-leverage local work identified (no controller dependency): add a
content-addressed
source_unit_keyrecorded at generation time (additive column, filenames untouched) — this is the one blocking identity concept that needs nothing from the controller; and extract the aligned definition set from code into versioned data (manifest.md§14.2). Both make every later migration phase cheaper. - No UmbraAudio behaviour changed. New files only, plus the manifest update log and a spec index row.
Session: Designation scanner — capture the ch5 speakers discovery missed
- Discovery re-run on ch5 with the new over-detection prompt rules (
gemma-4-e4b, ~22 tok/s; stopped Qwen TTS first per the no-co-residency rule, restarted it after). It improved — found "The big one", "The one on the left/right", "The Leader", "Girl helping" where it previously found none — but still dropped "The oldest" and "The tallest" from the same six-man crowd. The small model is not deterministic enough to guarantee coverage, so a mechanical backstop was added. - New
umbraaudio/analysis/designation_scan.py(no LLM) reads the three canonical speech-attribution shapes and harvests every designation a speaker is referred to by:"The oldest says,"/"I thought they died off," says the tallest/"No," the old smith said.Post-filters: pronouns, narration verbs (Hallr Grips His Axe And), finite-verb clauses (Treasure Is One), possessives (Hallr's→Hallr,Sheyla's voice→Sheyla), bare articles, indefinite collectives, and tags inside a quotation (someone being talked ABOUT, not speaking). Reads chaptersource_text(not utterances) so it runs before attribution. - New
scripts/merge_designations.py: adds uncovered designations asunidentifiedspeakers with evidence. Never removes/rewrites existing entries, atomic tmp+replace, idempotent (0 on re-run).--dry-runfirst, always. - New
scripts/run_discovery.py: runs discovery to a separate output file so a discovery run can never overwrite the curatedspeakers.json. - Result: Last Dwarf cast 70 → 93. 56 designations, 33 already covered, 23 genuinely missing — the ch5 crowd (The Oldest, The Tallest, The Young One, The One With The Biggest Sword, The Biggest One, The Leader, One Girl Helping) plus Manfred (a real missing speaker!), The Sergeant, One Witch, The Soldier, One Rider, One Lieutenant etc. All 23 land at 0.00 confidence / "needs check" so the reviewer triages them. Speakers page now: 93 rows, 31 clear / 62 need checking.
- Regex lessons worth keeping:
sings?|sangmatched INSIDE gerunds ("Releasing", "Closing", "Sensing", "Passing") until a leading\bwas added before the verb alternation;The Guardcaptured as justGuarduntil continuation words allowed capitals; appositives ("The biggest one, a healthy farm boy, says") need\s*not\s+before the comma. A narration-verb blocklist in_is_designationis far more maintainable than a giant allow-list regex. - Commit
4775e3b. 14 new tests. Full suite 461 passed (8 pre-existing/parallel). - NEXT (Phase 5): re-run
scripts/reattribute_all.py— the utterances currently labelled "Walking oldest"/"scum leader" should re-point to the new clean names now that they exist as profiles. Note "The Big One" vs "The Biggest One" are the same person and need a merge (the designation system flags the pair).
Previous Session: Speaker designation + discovery over-detection (spec 17 §3.1/§3.2)
- Trigger: reviewer reported obvious speakers missing from Last Dwarf (the six men in ch5 identified only as "the oldest"/"the tallest"/"the young one"/"the one with the biggest sword"; the scum group), which they had to add by hand. And: "I would rather combine speakers than be missing them" + "show all possible speakers with designations for clear and needs to be checked."
- New
umbraaudio/analysis/speaker_confidence.py: deterministic (no LLM) identification confidence +clear/needs_reviewper speaker. Categorical/blocking defects always need a human: narration verb in the name,(Scene N)label, uncapitalised, name made only of role/comparative words, probable unmerged duplicate,unidentified, zero dialogue. Graded penalties: unknown gender, no traits/description, thin utterance share, high needs_review share, unevidenced aliases.- Duplicate detection tuned to avoid FALSE merges: "Lieutenant" vs "Lieutenant Stonesplitter" are NOT duplicates (extra word is a proper name); "Walking oldest" vs "Walking Tallest" are NOT (difflib raised to 0.92). Real pairs found: Guard 1/Guard 2, Rielan/Rieland, The Girl/The Girl helping, Officer/Older Officer.
- UI (registered as a Jinja global in the clean
__init__.py, so routes.py untouched): every speaker row gets✓ clear/⚠ needs check; hover a needs-check badge for the machine-readable reasons; collapsible "N speakers need checking" panel at top + header counts. Row template guards{% if designations is undefined %}so a standalone/speakers/partial-row/Nstill works without 66 extra queries. Page loads in ~60ms. Last Dwarf: 32 clear / 34 need checking — every hand-added speaker is flagged. - Discovery prompt (
CHARACTER_DISCOVERY_PROMPT.md): new Rule 8 "Every Speaking Line Is a Speaker" (designations are valid names; if two may be the same person still list both + flag unsure — over-detect, because a bad merge is permanent and a duplicate costs one click) and Rule 9 "Name Hygiene" (no narration verbs in names, no "(Scene N)", consistent capitalisation). Rule 6 extended: a designation IS unidentified; never upgrade "the tallest" into a guessed proper name.tests/test_discovery_prompt_rules.pyguards all of it. - NOTE: the new prompt rules only affect FUTURE discovery runs. Re-running discovery is required for them to change speakers.json; existing entries are untouched meanwhile (the designation panel flags the current mess).
- Commits
6ef66f6(confidence + UI),f7094e2(prompt rules). Full suite 447 passed (8 pre-existing/parallel failures unchanged).
Previous Session: Name-token review UI (spec 17 Phase 3) — 3bc6945, abb9975
- Phase 3 UI + 2 bug fixes (broken onclick quoting, scanner missing "Witch")
Previous Session: Orphan name-token scanner + resolution (spec 17 Phase 2) — c338370, 4d900d7
- (scanner, resolution actions, CLI, name_tokens table; 3 bugs found by infra review and fixed)
Session: Orphan name-token scanner + resolution actions (spec 17 Phase 2)
- Key move: a SEPARATE router module (
umbraaudio/web/name_token_routes.py) instead of adding routes to the hot, concurrently-editedroutes.py. Registered via a 2-line change to the cleanumbraaudio/web/__init__.py(app.include_router(name_token_router)). This keeps the name-token UI fully independent of the parallel session's work. - Routes:
GET /name-tokens(page),GET /name-tokens/partial(HTMX table body),POST /api/name-tokens/scan,POST /api/name-tokens/{token}/assign|create|dismiss({token:path}so "The Kid" / "Nodding Tobias" with spaces work), andPOST /api/speakers/merge-by-name. Templates:name_tokens.html+_name_tokens_list.html(token table: kind, uses, chapters, evidence, status, inline Assign/New speaker/Dismiss). base.html nav gained "Name Tokens" + "Review". - Refactor: added
scan_project()+build_alias_map()toname_tokens.pyso the CLI and the web scan share one code path (CLI simplified to call them). - Fixed a pre-existing 500 on
/exception-review(blocking because I linked it in the nav): it used the old StarletteTemplateResponse(name, context)signature, so the context dict was passed as the template name →TypeError: unhashable type: 'dict'. NowTemplateResponse(request, name, context), and the hardcoded"DealHunter"project name replaced withconfig.project_dir.name. - Commit discipline under concurrency (worked):
routes.pyhad 7 diff hunks — 2 mine (name_tokens schema @250, exception_review fix @5960) and 5 the parallel session's (helper_url). Used a Python filter to build a patch with only the non-helper_urlhunks andgit apply --cachedit, so the commit carried only my 20 lines. The parallel session's 5 hunks are still uncommitted and intact in the working tree. - Verified live (server restarted, PID healthy on :8420):
/name-tokens200 with 8 rows; scan API persisted 9 tokens into Last Dwarf's DB; assign error paths 400; dismiss round-tripped via HTTP with scan evidence preserved (proves the Phase 2 fix) then reverted; all nav pages (/, /speakers, /name-tokens, /exception-review, /proper-names, /chapters-audio, /quality) return 200. Full suite 424 passed (8 failures are the pre-existing/parallel voice_profile_manager + test_pronunciation ones).
Previous Session: Orphan name-token scanner + resolution (spec 17 Phase 2) — c338370, 4d900d7
- (scanner, resolution actions, CLI, name_tokens table; 3 bugs found by infra review and fixed)
Session: Orphan name-token scanner + resolution actions (spec 17 Phase 2)
- Web UI restarted (launchctl kickstart com.umbraaudio.web): new PID healthy on :8420,
and its
_ensure_utterance_columnsmigrated the real Last Dwarf DB with the Phase 1 clarity columns./returns a soft 400 "no active project selected" in hub mode — normal. - New
umbraaudio/analysis/name_tokens.py: scans only two high-signal speaker-evidence positions — (1) unresolved proper-noun speech tags viaattribution_qa._match_speech_tag, (2) un-profiledspeaker_namevalues — NOT a whole-text capitalized sweep (that produced 594 mostly-noise tokens). Real result on Last Dwarf: 9 orphan tokens (8 role speakers "The first/second guard", "The Kid", "Advisor", "Royal Guard", "The voice", "Nodding Tobias" + 1 name-tag "Tobias Teppo" → suggests "Tobias"). Groups book-wide with occurrence count, chapters, sample lines. Fuzzy suggestion = max(word-overlap, difflib, soundex, 4-char common-prefix misspelling). Suppresses personal-name suggestions for role nouns (so "The Kid" no longer suggests "The King").speaker_nametokens use the RAW stored value so the assign action (exact speaker_name match) actually re-normalizes. - New
umbraaudio/analysis/orphan_resolution.py: assign-to-speaker (adds alias + alias_evidence, re-normalizes utterances case-insensitively), create-speaker (profile with sample_dialogue), dismiss (records decision only), merge-speakers (folds aliases, idempotent). Speakers written atomically (tmp+replace). All record status inname_tokens. - New
scripts/scan_name_tokens.py: scan CLI (--write) + one-shot actions (--assign/--create/--dismiss/--merge).--dismiss/--assign/--mergeverified end-to-end on COPIES of the Last Dwarf DB/speakers.json (alias + 2 re-normalized utts). - DB:
name_tokenstable (project_id, token, kind, status, target_speaker, occurrences, chapters_json, evidence_json, suggestion, suggestion_score, updated_at; UNIQUE project+token) added todatabase.pySCHEMA androutes.pySCHEMA_SQL; created on demand byensure_name_tokens_table(). Migration smoke-tested viaDatabase.connect. - CRITICAL — concurrent work in this repo (do NOT clobber): a parallel session is
actively editing
config.py,pronunciation.py,character_discovery.py,character_profiler.py,llm_attributor.py,llm_segmenter.py,chapter_builder.py,emotion.py,voices/qwen_tts.py,.env, and addinginfra/,backup/,voices/lmstudio_gate.py,specs/19-enterprise-control-plane.md,project.db. It introducedTTSConfig.qwen_tts_helper_urland threaded it throughpronunciation.py+routes.py; the test mock intests/test_pronunciation.pywas NOT updated, causing 2 failures (SimpleNamespace has no attribute qwen_tts_helper_url) — NOT ours. 6voice_profile_manageraccent failures are also pre-existing. - Commit discipline under concurrency:
routes.pyhas BOTH my name_tokens schema hunk AND the parallel session'shelper_urledits in the same file. I deliberately did NOT commit routes.py to avoid capturing/colliding with their in-flight work; my hunk stays in the working tree (table is created on demand, so nothing is blocked).database.pywas purely mine, so it was committed. Full suite: 420 passed; the only failures are the parallel-session/pre-existing ones above. - Committed
c338370. Remaining Phase 2→3 work: web UI for the token review panel (assign/create/dismiss/merge buttons on the speakers tab) — intentionally deferred since it would edit the contested routes.py + templates.
Previous Session: Utterance clarity signals (spec 17 Phase 1) — committed 1bbbe5d
- (see below for full detail)
Session: Utterance clarity signals + review queue (spec 17 Phase 1)
- Goal: persist per-utterance attribution transparency so the system auto-accepts high-confidence lines and surfaces only a small review queue (~1–5%); data feeds a downstream translation system and must be accurate for books the reviewer didn't write.
- DB:
utterancesgainsattribution_evidence_kind,attribution_tag_name,attribution_uncertain,needs_review,review_reason;attribution_confidencedefault → NULL. Idempotent migrations indatabase.py._migrate,routes.py _ensure_utterance_columns, and a new_ensure_utterance_columnsinscripts/reattribute_all.py(that driver uses raw sqlite3 and would otherwise fail on the new UPDATE columns). Migrations smoke-tested against a copy of the real Last Dwarf DB. llm_attributor.py:attribute_speakersaccumulates scene evidence across batches (evidence_by_idx) and stampsattribution_evidence_kind+attribution_tag_nameon attributed dicts.- New
umbraaudio/analysis/clarity.py: deterministic, LLM-free post-pass that annotates every dialogue line.extract_mechanical_evidence/evidence_kind_forclassify evidencename|role|pronoun|address_only|none(addressee counts only for known names/titles — guards the shared detector's "But where…" false positive). Pronoun resolvability via gender-filtered in-scene candidate count (exactly 1 → auto-accept). Compositeneeds_review: any QA issue (exceptlong_monologue), non-unique pronoun referent, role tag with no profile entry, none/address_only evidence < 0.75, unresolved pass-1attribution_uncertain(< 0.9), narrator-left dialogue, confidence < 0.7.review_reasonis a "; "-joined readable summary. - Drivers persist all 8 fields:
reattribute_all.py(runsassess_utterance_clarityper chapter),run_llm_attribution.py(via extendeddb.update_utterance_speaker), both web attribution paths in routes.py.run_llm_attribution._populate_exceptionsnow keys offneeds_reviewinstead of confidence<0.7 + keyword sniffing and maps reason →ambiguity_type; fixed a latentNameError(pidwas not in scope). - Measured on Last Dwarf (3918 dialogue, pre-re-run confidences): review queue = 173
(4.42%) — within the 1–5% target. Top signals: none-evidence low-conf (73), pronoun
ambiguity (53), narration-speaker mismatch (18), address mismatch (5), role-no-profile
(6), narrator-left (3).
long_monologueruns (43) deliberately NOT auto-queued (weak 0.5 heuristic; intentional 4+ line speeches are common) — still visible in QA reports. - Tests:
tests/test_clarity.py(25 tests) green; full suite 380 passed / 6 pre-existingvoice_profile_manageraccent failures. ruff + mypy clean on clarity.py and deltas; routes.py stays at its 73 pre-existing errors (0 new). - Committed
1bbbe5d. Next phase: Phase 2 (orphan-alias scanner + assignment) or a reattribute run on Last Dwarf ch11–49 to refresh the clarity fields on real data (web server restart needed to pick up schema + code;qa_acceptedrows skip as today).
Previous Session: Re-attribute + speaker-confidence evaluation (spec 17 written)
- ch11–49 re-attribute run: 3,192 dialogue utts, 39 chapters, QA 157→65; evaluation +
phased plan written to
specs/17-speaker-confidence-review.md(5 phases: clarity persistence → orphan-alias scanner → UI → translation export → validation).
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.SUPERSEDED 2026-10-05: SSH now works (key-based asumbra,ssh oldbox) — see System & Deployment Notes. It was never a network/key problem; the public key was just missing fromauthorized_keys. - 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.