Files
UmbraAudio/AGENTS.md
Umbra Tools 25605e2cad Record the paragraph-boundary drift findings and the verification stage
Also records the negative result from the independent cross-check attempt:
names are not a drift signal, and the glossary-anchor detector conflates
drift with ordinary glossary non-compliance. Both would otherwise be
tried again.
2026-11-06 21:40:37 -08:00

103 KiB
Raw Blame History

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.py destroyed ~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-off umbraaudio/web/static/samples/ A/B demo are intentionally ignored (see .gitignore); never git add -A without checking, and never leave a dirty umbraaudio/web/routes.py for days.
  • Mechanical nudge: scripts/commit_check.sh exits 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.commitcheck fires Sunday 18:00 and posts a macOS notification when the tree is dirty. Plist loaded from /Users/worstauthor/Library/LaunchAgents/com.umbraaudio.commitcheck.plist and mirrored to /Volumes/Dock/worstauthor/Library/LaunchAgents/ (login-scanned). It runs /bin/zsh /Users/worstauthor/.umbraaudio/umbra_commit_check.sh → cd project → 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, pipes scripts/commit_check.sh to bash -s (so the script itself is never opened on the volume), and shells osascript for the notification. Logs to logs/commit_check.log; exit 1 = dirty (deliberate). Test with launchctl 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/worstauthor still exists as a legacy/boot-volume path and is fine for scratch scripts. launchd scans the relocated home's Library/LaunchAgents.
  • Web UI access: umbraudio.bdmurphy.me is served by a Caddy reverse proxy on ANOTHER machine on the LAN, forwarding to this Mac — so the web server MUST bind 0.0.0.0 (it does, port 8420). LAN IPs: 192.168.123.12 (en0) and 192.168.123.154 (en1). macOS App Firewall is disabled (inbound 8420 open). Any new firewall/user grant must keep 0.0.0.0:8420 open.
  • 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.sh now exports PATH="/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, system python3) get EPERM: Operation not permitted on 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 can import 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 with EX_CONFIG 78 if a job's StandardOut/ErrorPath points at the volume. Consequence: auto-start must keep ALL project I/O inside python -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, which cds into the project and exec .venv/bin/python -c "<inline uvicorn>", with python dup2- ing stdout/stderr into logs/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 (manual launchctl load). Starts when worstauthor logs 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.d NOPASSWD-to-worstauthor bridge 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 with nohup .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 ...) forwards 127.0.0.1:18881 -> 192.168.123.14:8881; the web app targets UMBRA_TTS__QWEN_TTS_URL=http://127.0.0.1:18881. Root system daemons DO bypass Local Network TCC (verified LAN_OK 200). Box runs scripts/qwen_tts_server.py with QWEN_DEVICE=cuda on 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 via nohup ./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: a socket.create_connection(..., timeout=10) left a 10s recv timeout on upstream — any TTS call

    10s was killed mid-response while /health and /load survived; fixed with up.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 via launchctl 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 the oldbox alias, 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=yes showed Offering public key: …id_ed25519 → Permission denied, while also showing Host … is known and matches the ED25519 host key. So the host key was always fine and the key simply was not in umbra's authorized_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, comment umbra_gpu_deploy, no passphrase, fingerprint SHA256:qrq0WqszS/MNHkYsCTaZH/PQv+bx+Ox7PwyfyOpa2WM. Loaded into the agent with ssh-add --apple-use-keychain so 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, ECDSA SHA256:fdTVV77wv/eFEjYUK2PKcwseZC015IXuW5TcELvuSGA, RSA SHA256:fa60nisrlObOOEOi4Keo8m7b4t/TVDYsww6HlRfm/8Q. oldbox.bdmurphy.me resolves to 192.168.123.14 and both forms are in known_hosts.
    • ~/.ssh/config gained a Host oldbox block (User umbra, IdentityFile ~/.ssh/id_ed25519, IdentitiesOnly yes, ServerAlive 30/3). Backup alongside it: config.bak.20261005_203303.
    • The remote-broker oldbox-umbra profile works now too (was exit_code 255 Permission denied → now exit_code 0), so agent-driven remote commands go through it. Note the profile is named oldbox-umbra, while oldbox-umbra-ssh / oldbox-umbra-sudo are 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:95qbJicDPOmBN3AORHCNFoFi7wxYX6EYZTLB2SXq4Lk matched no host key here and no local .pub file — it was neither oldbox's nor this machine's key. Verify with ssh-keyscan -T 8 -t ed25519,ecdsa,rsa <host> | ssh-keygen -lf - (read-only, stores nothing) and compare against known_hosts before 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, PCI GA106 … GeForce RTX 3060), but nvidia-smi fails for umbra with "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 the video/render group membership fixed first.
  • Relay correction (2026-09-27) — the two claims above were both wrong in practice; verified:
    1. The settimeout(None) fix was NOT in the deployed file. /Library/LaunchDaemons/qwen_relay.py (mtime Sep 10 06:33) had no settimeout call, so the 10s-mid-response-kill bug was still live. Patched (backup qwen_relay.py.bak.20260927) and the daemon restarted with sudo launchctl kickstart -k system/com.umba.qwen-relay. Re-check the deployed file before assuming a documented fix is present.
    2. The relay was never down. launchctl print system/com.umba.qwen-relay reports state = running and root holds 127.0.0.1:18881 LISTEN. An unprivileged lsof cannot see root processes, which makes the relay look absent; and curl to a relay whose upstream is down returns http_code=000 because the relay accepts then drops. So 18881 -> 000 means "relay up, upstream refused", not "relay down". Always confirm with sudo lsof/sudo pgrep.
    • Actual current failure: the GPU box 192.168.123.14:8881 refuses connections (it answers ping at 0.4 ms but the port is closed — its Qwen server is a manual nohup ./gpu_server_env.sh start and is down). qwen_relay.py has 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 is http://127.0.0.1:8881 (loopback is exempt from Local Network TCC, which is why the relay is only needed for the LAN path).

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/analyze endpoint (see specs/02-analysis.md)

Code Conventions

  • Type hints on all function signatures
  • Docstrings on public classes/methods
  • from __future__ import annotations in every file
  • No comments unless explaining why, not what
  • Use logging module, never print() 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.toml before adding new dependencies
  • Never add a dependency if stdlib suffices
  • Prefer: stdlib → third-party → custom implementation

Current Session Summary

  • Question answered with evidence: the production Chinese translation DOES use the intent-based, chapter-level, speaker-mapped process - but the audiobook link was missing. Three independent findings:
    1. The 10 samples/translations/*.json files self-declare provenance: "LLM-produced sample, single pass, NOT the multi-pass pipeline" - spec 27 calls them "Pass 2 with Pass 1 skipped". They also were translated against a hand-cleaned paraphrase of the English: 0 of 12 utterances match the speaker-verified DB text (typos silently repaired, e.g. "the AI the runs the ship" -> "the AI that runs the ship"), and they carry Kainda/Narrator where the DB carries Kainda Walters Foster Murphy/NULL. They are not a translation of anything.
    2. The real production translation lives in UmbraTrans on oldbox (/home/umbra/src/UmbraTrans/productions/deal-hunter-zh) and DOES use the process: Pass 0 paragraph assembly -> Pass 1 scene intent (summary/register/tone/participants/ per-paragraph risks + guidance) -> Pass 2 context-aware draft (glossary + character voices + scene analysis + prior-chunk summary) -> Pass 4 bilingual critic on a different model (deepseek-r1-0528 vs drafter qwen3.5-9b, analyst lfm2.5-2.6b). Outputs deal-hunter-zh.asciidoc/.epub/.fodt/.odt/.pdf (Oct 25).
    3. No intent artefacts exist in this repo - no propositions/speech_act/depends_on/ literal_risk anywhere; spec 27's status was "design, not implemented".
  • The missing link, quantified. The pipeline works on paragraphs; the audiobook needs utterances. Measured on the real book: 3,047 paragraphs cover 4,317 source utterances - 2,322 paragraphs -> 1 utterance, 180 -> 2, 545 -> 3; all 725 multi-utterance paragraphs are narration (dialogue is already 1 paragraph per speech turn). UmbraTrans's own tools/translation/README.md said it: "It does not produce a 1:1 utterance alignment for TTS ... splitting or aligning back to the 4,319 utterances is a separate pass."
  • Alignment lives in UmbraTrans, not here - spec 18 §1 makes UmbraAudio an upstream read-only provider, and book.py already anticipated "a later audio-alignment step can still map back". New tools/translation/align.py = Pass 5. Committed 996fe37, then f0c2317; pushed to ssh://git@mini-eq:2222/umbragit/UmbraTrans.git (master f0c2317).
  • Alignment is by meaning, not position. A model gets the source utterances + the translation and says which part renders which utterance. Two rules it cannot break: a source utterance may only be absorbed into another by the same speaker; and the partition must reproduce the translated text exactly (whitespace-insensitive), so the alignment never rewrites the translation. A source utterance the target absorbed is delivered as voiced=false with a reason (UmbraAudio's utterance_translations.voiced): row kept, gap visible, no clip generated.
  • Scene breaks are silence and have no translation. The spoken chapter announcement ("Deal Hunter. Chapter one, Salvage.") is deliberately excluded from the reading edition.
  • Mechanical clause-boundary split is the no-model fallback only; always flagged "needs review". It is also the only thing that can fail, and the failures are real translation defects (see below), not alignment defects.
  • Three real bugs found and fixed in the aligner (by running it on the real book): the splitter only tried the longest piece, which may be the unsplittable one; clause splitting ignored :/—— and marks at a piece's end (Chinese merges English sentences with a comma/colon/dash, not a full stop); and the CLI assigned llm.cache_dir after construction, so the directory was never created - every model answer would error and the fallback would silently turn the whole book into a mechanical split while still reporting success. Now created by the CLI and pinned by a test.
  • Two real ingest bugs fixed in umbraaudio/translations.py: import_translations folded the target with lang.normalize, which does not know script subtags, so a delivery labelled zh-Hans was dropped entirely; and coverage/not_voiced_rows compared stored vs requested codes as strings, so a row stored as zh-Hans read as "nothing delivered" when the reviewer asked about zh. Locale codes now fold to base codes on ingest and on every coverage query.
  • Delivery contract is the existing import_translations payload shape - neither side invents a second contract. scripts/import_translations.py added: report-only by default, --write, --confirm, --replace (clears the language's draft rows only; confirmed rows are never deleted - a confirmed translation is an approval).
  • Bundle verified in sync with the live DB: 428/428 ch1+ch6 bundle rows match the current UmbraAudio DB on text and index; 0 missing; 0 differing.
  • End-to-end delivery proven. 4,300 rows ingested; the review UI's resolved_translations then renders Chinese for 12/12 utterances of ch1 and 415/416 of ch6 (the missing one is the spoken chapter announcement). Live UI measured after import: /chapters-audio/ch1?lang=zh -> 12 x "✓ Chinese", 354 CJK chars, 0 "not translated"; /chapters-audio/ch6?lang=zh -> 415 x "✓ Chinese", 8,231 CJK chars, 1 "not translated" (the chapter announcement); /chapters-audio/ch6?lang=ru -> 416 "not translated"; ?lang=en -> English.
  • Model alignment validated on real data: 42 sampled multi-utterance paragraphs across 7 chapters -> 41 LLM, 1 mechanical fallback, 0 partition mismatches; the one fallback was a model answer that did not reproduce the translation, caught by the content check. Chapter 6 full run: 232 paragraphs, 131 one_to_one, 101 llm, 0 needing review, delivery 415 utterances (412 voiced, 3 collapsed) - e.g. utt-88245 "It was first launched over 450 years ago." absorbed into utt-88244, whose Chinese is "Deal Hunter 是一艘老船,早在四百五十 年前就已首航。"
  • Spec 27 updated with the measured findings, the production pipeline table, and the alignment-pass description. UmbraAudio commits c476cfe + 301ff17, pushed to minieq (ssh://git@mini-eq:2222/umbragit/UmbraAudio.git).
  • THE PRODUCTION CHINESE HAS A PARAGRAPH-BOUNDARY DRIFT, and the alignment pass cannot see it by construction. Pass 0 splits the original at sentence boundaries, so many paragraphs are sentence fragments ("The chair in the middle has", "operations", "on the console."): measured 439 of 3,038 translated paragraphs (14.5%). A fragment is exactly where an intent-based drafter is told to write natural target-language prose, and once it does that the id-to-text correspondence drifts - the Chinese is good, but attached to the wrong paragraph. In ch-15 the drift runs p0094-p0104 (11 paragraphs): p0102's Chinese ("上一次有人登上这艘舰桥...") is p0101's translation, and p0104's Chinese opens with p0103's sentence. The alignment pass cannot see it: a one-to-one paragraph is a pure copy, so there is nothing to divide and nothing to disagree.
  • The verification stage (align.py --verify) finds it, but only after two rounds of measurement. The first version asked two yes/no questions per paragraph ("is anything missing", "is anything extra") and was wrong in both directions: it flagged 5 paragraphs that were fine (it called the adverb then missing from a translation that has it, and Enthusiastically missing from one that has it - the model was reading the English and not the Chinese), and it missed 2 paragraphs that are drifted. 5 of 15 flags were wrong. The stage now asks which paragraph does this translation render (aligned / incomplete / drift), shows the model its neighbouring paragraphs, and judges the paragraphs as a window rather than one at a time - because the model calls the two paragraphs before a real drift "aligned" when their Chinese is demonstrably the neighbour's, and the start of a drift is not knowable from any single paragraph. On the real ch-15 window it now finds exactly the drift: 10 paragraphs dropped (p0095-p0104), p0094 kept and flagged because its Chinese correctly renders its first two sentences. Only drift deletes a line. The cost is explicit: a fragment inside a broken window can be dropped on an unreliable verdict, so the audiobook has a gap there. The alternative is a line that belongs to a different sentence, and a gap is visible while a wrong line is silent.
  • A dropped paragraph still gets a row: voiced=false + the reason, so the gap says why rather than looking like a paragraph nobody translated. Verified end-to-end: the ch-15 delivery imports 154 rows, 13 of them not-voiced with the reason in review_reason.
  • An independent deterministic cross-check was tried and FAILED - names are not a drift signal. A glossary-anchor detector found 179 candidate paragraphs, and a name-anchor detector found 1, which was a false positive (word order: 公主凯因达 vs 凯因达公主). The ch-15 drift window contains no names at all. Do not repeat the glossary-anchor detector: it conflates drift with ordinary glossary non-compliance (the model renders "Warbots" as 战博特 as often as 战斗机器人).
  • The root cause is upstream and is now fixed in the prompts (UmbraTrans 5433653): the drafter is told each id may cover only its own English, even where the source is cut mid-sentence, and must not move a neighbour's content in or its own content out; the critic is asked to check the id-to-text correspondence (new type misalignment, severity block). The critic cannot repair the draft - the pipeline records critic issues rather than revising - but the drift becomes visible in the run record instead of silent.
  • UmbraTrans commits pushed to minieq: acab79d (window judging), 11fd8d8 (gap rows keep the reason), 5433653 (drafter + critic prompts). Master = 5433653.
  • UmbraAudio suite: 890 passed / 8 failed / 6 skipped - the same 8 pre-existing failures (2 test_pronunciation, 6 voice_profile_manager accent); +9 new tests, no regressions. Web server restarted; all pages return 200 (auth gate intact, / -> 303 /login).
  • The pipeline is CHINESE-ONLY, and one production says otherwise. Measured on oldbox (2026-11-06): productions/deal-hunter-es declares target_language: es-ES, its glossary says "script": "Spanish" with Spanish style rules and the title Cazador de Ofertas, and it has a complete 32-chapter run (39,083 s) - but all 3,038 translated paragraphs are Chinese (3,034 with Han characters, same proportion as the zh run; 2,792 of 3,038 differ in wording from zh, so it is a second Chinese draft, not a copy). Cause: the drafter's instructions are hard-coded to Simplified Chinese in three places - prompts.py SYSTEM_DRAFTER ("把英文太空冒险小说译成简体中文"), draft_prompt ("请把下面英文译为简体中文", JSON key "zh"), SYSTEM_CRITIC ("对照英文原文和中文译文"). The glossary carries the target but the instruction says Chinese, so the model followed the instruction; the critic is hard-coded the same way, so it checked the Chinese text and found nothing wrong - there was nothing wrong with it for the language it was asked about. pipeline.py has zero references to target_language. Consequence: no non-Chinese translation has ever been produced by that pipeline; the other 9 languages exist only as the single-pass sample files.
  • That defect is now guarded at the contract boundary (commit 3db6ac3). umbraaudio/languages.py gained script expectations per language (Language.scripts, required_script, forbidden_scripts) plus script_counts() and script_mismatch(); import_translations now checks the delivered text's script against the declared language before writing a single row and refuses the delivery. Japanese needs kana, Korean needs Hangul, and Chinese carrying kana is a Japanese translation in the wrong lane. An unknown language is not checked (no expectation to check against). Proven on the real data: the es delivery is refused with "the text is mostly Han characters, so it is not Spanish" and 0 rows written; the 4,300-row Chinese delivery still imports unchanged. The check is about the script, not the language - it cannot tell Spanish from Portuguese, but it can tell Chinese from Spanish, which is the mistake that actually happens.
  • Target-language audio is not built here, and that is deliberate. Measured: umbraaudio/generation/chapter_builder.py synthesizes u["text"] (the English source) and never reads utterance_translations - the translation does not reach the TTS path at all. Spec 18 §4.1 assigns "target-language voice casting" and "target-language TTS rendering" to UmbraTrans, and §4.2 forbids UmbraTrans from reusing UmbraAudio's audio markers as target-language markers. So the renderer belongs to UmbraTrans, not here. The alignment pass is what makes it possible: it hands UmbraTrans one row per utterance, keyed to the verified speaker, with voiced=false for the collapses.
  • Open prerequisite for any target-language render: does Qwen3-TTS speak Chinese with an English voice reference? The engine and the server both accept a per-request language (QwenTTSEngine.generate(..., language=...) -> scripts/qwen_tts_server.py Form("english")), so it is possible in principle - but it has not been probed, because LM Studio and the TTS model must not be co-resident on this 16 GB Mac and the alignment run is using LM Studio. Probe after the alignment finishes.
  • IN FLIGHT: full-book model alignment running (--chapters all, output zhprod/delivery-full-llm.json); progress at ch-11 of 32, 0 needing review so far, 289 align cache entries. Auto-ingest watcher armed (ingest-after-align.sh waits for the process to exit, then runs scripts/import_translations.py --write --replace so the model-aligned delivery replaces the mechanical rows automatically).
  • Live UI re-measured after the gate commit and a server restart: ch1 zh = 12 x "✓ Chinese" / 354 CJK / 0 untranslated; ch6 zh = 415 x "✓ Chinese" / 8,231 CJK / 1 untranslated (the chapter announcement); ch6 es = 0 (the Spanish lane is correctly empty - the gate stopped the mislabelled delivery); ch6 ru = 0.
  • Two production translation defects found, NOT fixed (translation-quality, not code): ch-15-p0100 source is a passage about dust on a console but the Chinese is "是控制台上的"; ch-25-p0089 source describes cuboid shapes but the Chinese is "怎么才能接通电源?". Both flagged "cannot divide" - the aligner is correctly reporting a broken upstream paragraph.
  • Third alignment failure is legitimate: ch-32-p0001 - source ["Kainda stops.", "The servant says,"], Chinese "凯因达停下脚步。" - a genuine collapse needing the model to mark source #2 not voiced.
  • Blocked / next: oldbox cluster (PostgreSQL listens only on 127.0.0.1:5432; needs listen_addresses='*', pg_hba for 192.168.123.0/24, db umbratrans + roles umbraaudio/umbraformat/umbra_web, PgBouncer on 6432, NFS exports, firewall) - sudo on oldbox needs a password, so postgresql.conf/pg_hba.conf cannot be edited non-interactively. Controller Stage 1 still waiting on a compute_nodes row + credential with compute:read + compute:heartbeat. LM Studio at 192.168.123.16:1234 is down from both this Mac and oldbox; alignment runs against this Mac's http://127.0.0.1:1234/v1 (model qwen/qwen3.5-9b present, plus translategemma-12b-it, gemma-4-12b-qat, google/gemma-4-e4b). Other languages (de/es/fr/it/pt/ja/ko) still need production runs; productions/deal-hunter-es also exists on oldbox.

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_project is an unsigned cookie and the server binds 0.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 with hmac.compare_digest. Store writes are atomic (tmp + os.replace) so an interrupted save cannot lock the user out. Both auth.json and session_secret are chmod 0600 under /.umbra/, which is gitignored (line 42).
  • First use: username bdumbra, one-time password First321, stored hashed and marked must_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.py mirror the name_token_routes pattern, so the hot routes.py stays 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 oldbox for 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 Authors directly, 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 name Audio, 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_m4b encodes -c:a aac -b:a 128k with 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_chapter hard-codes silence_between_utterances_ms=500, silence_between_chapters_ms=0 while ChapterBuilder uses AudioStitcher() 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 stores DB[i-1] and none store DB[i]. Deliberately NOT "fixed" by shifting markers - that would make the staleness check pass while leaving utt_*.wav misaligned 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 bare END as 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.txt renders "My Website is authorbdmurphy.com". Note ch35.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-mp3 stitches whatever ch*_full.wav exist, and has no guard against a concurrent batch.

Session: GPU box retired; Qwen TTS boot start; public DNS findings

  • GPU box 192.168.123.14 is RETIRED (2026-10-08) - offline and being reconfigured. Removed from configuration: .env no longer sets UMBRA_TTS__QWEN_TTS_HELPER_URL (default is "", so this is a no-op fallback), MEANINGFUL_PORTS lost the 18881 relay entry, and umbra_node_report.py now reports controller_endpoint: None with a controller_status explaining why. config.py comment updated. Local :8881 is the only TTS backend. The root relay /Library/LaunchDaemons/com.umba.qwen-relay.plist + qwen_relay.py still 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/:18881 speculatively - re-add a TTS backend only when the controller exposes one.
  • Qwen TTS now auto-starts at boot (com.umbraaudio.qwentts, installed to /Library/LaunchAgents for 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 only cds and execs the venv python, which does all I/O including the stdout/stderr dup2 into logs/qwen_tts_server.log. KeepAlive verified by kill -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 with EX_CONFIG 78. Both plists therefore have no StandardOutPath/StandardErrorPath.
  • 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 unprivileged lsof/ps). Booted out and moved to /Users/worstauthor/.umbraaudio/retired/*.retired.20261008 - deliberately moved out of /Library/LaunchDaemons so nothing can re-scan them. :18881 is now closed.
  • The web UI already self-heals TTS: ModelCoordinator.ensure_qwen_server() (model_coordinator.py:238) spawns scripts/qwen_tts_server.py via Popen(start_new_session=True) when /health fails. It is called only from switch_to() (line 293) - i.e. on demand during pipeline work, NOT at boot or web startup. It checks qwen_health() first, so a launchd-owned instance is reused rather than double-spawned: the two mechanisms are complementary, not conflicting.
  • umbraaudio.bdmurphy.me TLS: Caddy fails with internal error (alert 80) on the vhost while bdmurphy.me itself 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 at 192.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.

Session: Voice-maintenance guard (409) + boot auto-start failure diagnosed

  • The banner lied by omission. voice_maintenance was wired into routes.py only 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. Commit eb37777 adds _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) and set_voice_status (reads an existing _ref.wav to 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.
  • 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 returns None and 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 no EX_CONFIG 78). The real fault: $HOME is /Volumes/Dock/worstauthor (relocated) and launchd scans $HOME/Library/LaunchAgents ONCE 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/LaunchAgents copy 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 $HOME mirror was removed — two plists sharing one Label is 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_server is 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.plist or ... /Users/worstauthor/Library/LaunchAgents/com.umbraaudio.web.plist.
    • launchd footgun learned the hard way: launchctl bootstrap against an already-loaded Label fails with 125 "Domain does not support specified action", and bootout + bootstrap in one scripted step can hit it if the job is still loaded. Never suppress stderr on bootout, and confirm the port is actually down before bootstrapping — otherwise you get a false "bootstrap failed" and think you broke something. Also note KeepAlive means kickstart -k is the right way to restart, not bootout/bootstrap.
    • Still worth a reboot check when convenient — the durable proof is a real login.
  • The edit tool and bare shells cannot write /Volumes/1TB_APL_Storage (EPERM), and /Volumes/Dock flapped independently over minutes — sometimes readable, sometimes not, while ls/df kept working. Use .venv/bin/python for edits on the volume (it IS volume-approved), and re-check access before assuming a file is writable.
  • umbraaudio.bdmurphy.me cannot 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 return 97.113.1.212 for the apex bdmurphy.me but no record at all for the umbraaudio subdomain. 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 on 97.113.1.212 answers :80 with a blanket 308 -> 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 LAN 192.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 at umbragit/Qwen3-TTS so 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: .gitmodules now url = ssh://git@mini-eq:2222/umbragit/Qwen3-TTS.git. Consequence to remember: a git clone --recursive of 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=osxkeychain is set locally in .git/config and a Gitea personal access token (scope read/write:repository) is in the login Keychain for host 192.168.123.18:3001, user umbragit. 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 with git credential approve. If the Keychain entry is lost, that is the only step needed.
  • Mini-EQ addressing quirk: mini-eq resolves 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 in known_hosts.
  • The Git server is SSH on port 2222. The remote is now minieq → ssh://git@mini-eq:2222/umbragit/UmbraAudio.git. The Host mini-eq block in ~/.ssh/config carries Port 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, and git push fails on the first attempt while a retry succeeds. Fix in place: the Host mini-eq block now has BatchMode yes, ConnectTimeout 10 and ConnectionAttempts 5, so ssh retries the refused connect instead of failing the whole push. With that, plain git ls-remote / git fetch / git push over SSH is 5/5 reliable and no GIT_SSH_COMMAND is 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 push to http://192.168.123.18:3001/... fails instantly with "Immediate connect fail for 192.168.123.18: No route to host" — while curl http://192.168.123.18:3001/api/v1/version returns 200 from the same shell. It is per-binary: /usr/bin/git lacks the Local Network entitlement, curl has it. The symptom is misleading because nc -z also 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_hosts stores them separately (mini-eq vs [mini-eq]:2222):
    • :2222 ED25519 SHA256:SErbWxiAY00aiE2SxzopWjcMKZZuRAh77hAnRzMt/Ls
    • :22 ED25519 SHA256:15DnUT1iUbbMSLofyggPPXDe4TvJ68CG8cCABXofvvo ssh-keyscan of the live server matched both pinned values — no key drift. Note ssh-keyscan HOST scans port 22; use ssh-keyscan -p 2222 HOST for Gitea, or you will "discover" the :22 key and think it changed.
  • The remote main can 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 AuthorizedKeysCommand is 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-eq in ~/.ssh/config is therefore Port 2222; backup config.bak.20261006_054313. Gitea keeps a separate host key from the system sshd; the pinned :2222 key is ED25519 SHA256:SErbWxiAY00aiE2SxzopWjcMKZZuRAh77hAnRzMt/Ls (verify before trusting a new one).
  • umbragit is 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 returns 403 "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-repo SSH block is just an unused manual target. The old Host umbra-repo entry 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 is UMBRA_TTS__QWEN_TTS_HELPER_URL and the committed copy holds no secret-looking values; infra/remote_credential_broker ships only config.example.json and generates secrets at runtime via read_secret/random_secret.

Session: SSH mini-m4 → oldbox established (was blocked for months)

  • Outcome: key-based SSH as umbra now works; ssh oldbox (alias) and scp both verified. The remote-broker oldbox-umbra profile went 255 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 showed Offering public key: …id_ed25519 immediately followed by Permission denied, while simultaneously proving Host … is known and matches the ED25519 host key. The host key had been correct the whole time; the key simply was not in umbra's authorized_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/config Host oldbox block added with backup config.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 - vs known_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/-sudo are Keychain credential names; the SSH profile is oldbox-umbra (config ~/.config/umbra-trans/remote-broker.json, pins known_hosts).
  • oldbox: Linux Mint 22.1, 16 cores, 31 GB RAM, 1.4 TB free. RTX 3060 is present (/dev/nvidia0, PCI GA106) but nvidia-smi fails for umbra — "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:
    1. "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_approved now read true, approved_by: "this publication record", and publishing grants implicit approval to every voice a listener hears.
    2. The m4b inter-chapter gap should be the same 2 s as the lead/trail padding.
  • 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 in speakers.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.py had gap_ms=4000 in one path and chapter_gap_ms defaulting to 1000 in the other, while ar_export.SILENCE_MS = 2000 governed lead/trail — three drifting values for one concept. The m4b gap now imports SILENCE_MS as CHAPTER_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_comment strips leading blank lines, so a padded note cannot silently become a blank summary.
  • Tests: tests/test_release_comments.py (26) + tests/test_release.py extended 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.json is 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.wav 2227.26 s @ 24 kHz (what a reviewer listens to) vs Deal_Hunter_01_Salvage.mp3 2231.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-chapter source_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 while blockers is 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.json is 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: false and audiobook_approved: false explicitly 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_chapter hard-codes silence_between_utterances_ms=500, silence_between_chapters_ms=0 while ChapterBuilder uses AudioStitcher() defaults (500/2000/4000) and AudioConfig holds 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: make export_m4b consume ch{N}_full.wav instead of re-stitching from utterances.
  • projects.status is never advanced to released in any of the four live projects (all sit at parsed/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 with supersedes, 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.py is 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, .env KEY NAMES only (never values), per-project row counts, and a schema fingerprint (sha256 over sorted table.column:type, so ALTER TABLE column reordering does not read as drift). --json for machine consumption; --check compares the declared profile to measured reality and exits 2 on drift. Verified: --check exits 0, ruff clean, --json parses. 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) and 367d5df9e546dbba (Treasure Island, Vocals: no name_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 (2 test_pronunciation helper_url mock, 6 voice_profile_manager accent).
  • manifest.md is 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, declared node_contract_version, capability advertisement, task/result envelopes with report_back evidence, 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.
  • Highest-leverage local work identified (no controller dependency): add a content-addressed source_unit_key recorded 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 chapter source_text (not utterances) so it runs before attribution.
  • New scripts/merge_designations.py: adds uncovered designations as unidentified speakers with evidence. Never removes/rewrites existing entries, atomic tmp+replace, idempotent (0 on re-run). --dry-run first, always.
  • New scripts/run_discovery.py: runs discovery to a separate output file so a discovery run can never overwrite the curated speakers.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?|sang matched INSIDE gerunds ("Releasing", "Closing", "Sensing", "Passing") until a leading \b was added before the verb alternation; The Guard captured as just Guard until 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_designation is 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_review per 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/N still 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.py guards 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-edited routes.py. Registered via a 2-line change to the clean umbraaudio/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), and POST /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() to name_tokens.py so 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 Starlette TemplateResponse(name, context) signature, so the context dict was passed as the template name → TypeError: unhashable type: 'dict'. Now TemplateResponse(request, name, context), and the hardcoded "DealHunter" project name replaced with config.project_dir.name.
  • Commit discipline under concurrency (worked): routes.py had 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_url hunks and git apply --cached it, 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-tokens 200 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_columns migrated 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 via attribution_qa._match_speech_tag, (2) un-profiled speaker_name values — 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_name tokens 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 in name_tokens.
  • New scripts/scan_name_tokens.py: scan CLI (--write) + one-shot actions (--assign/--create/--dismiss/--merge). --dismiss/--assign/--merge verified end-to-end on COPIES of the Last Dwarf DB/speakers.json (alias + 2 re-normalized utts).
  • DB: name_tokens table (project_id, token, kind, status, target_speaker, occurrences, chapters_json, evidence_json, suggestion, suggestion_score, updated_at; UNIQUE project+token) added to database.py SCHEMA and routes.py SCHEMA_SQL; created on demand by ensure_name_tokens_table(). Migration smoke-tested via Database.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 adding infra/, backup/, voices/lmstudio_gate.py, specs/19-enterprise-control-plane.md, project.db. It introduced TTSConfig.qwen_tts_helper_url and threaded it through pronunciation.py + routes.py; the test mock in tests/test_pronunciation.py was NOT updated, causing 2 failures (SimpleNamespace has no attribute qwen_tts_helper_url) — NOT ours. 6 voice_profile_manager accent failures are also pre-existing.
  • Commit discipline under concurrency: routes.py has BOTH my name_tokens schema hunk AND the parallel session's helper_url edits 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.py was 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: utterances gains attribution_evidence_kind, attribution_tag_name, attribution_uncertain, needs_review, review_reason; attribution_confidence default → NULL. Idempotent migrations in database.py._migrate, routes.py _ensure_utterance_columns, and a new _ensure_utterance_columns in scripts/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_speakers accumulates scene evidence across batches (evidence_by_idx) and stamps attribution_evidence_kind + attribution_tag_name on attributed dicts.
  • New umbraaudio/analysis/clarity.py: deterministic, LLM-free post-pass that annotates every dialogue line. extract_mechanical_evidence/evidence_kind_for classify evidence name|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). Composite needs_review: any QA issue (except long_monologue), non-unique pronoun referent, role tag with no profile entry, none/address_only evidence < 0.75, unresolved pass-1 attribution_uncertain (< 0.9), narrator-left dialogue, confidence < 0.7. review_reason is a "; "-joined readable summary.
  • Drivers persist all 8 fields: reattribute_all.py (runs assess_utterance_clarity per chapter), run_llm_attribution.py (via extended db.update_utterance_speaker), both web attribution paths in routes.py. run_llm_attribution._populate_exceptions now keys off needs_review instead of confidence<0.7 + keyword sniffing and maps reason → ambiguity_type; fixed a latent NameError (pid was 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_monologue runs (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-existing voice_profile_manager accent 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_accepted rows 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_traits pile in speakers.json (LLM-discovery dump incl. "raspy"/"gravelly") triggered TRAIT_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's vocal_traits to ["deep","measured","ancient","highly articulate"] (+ kept distortion_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 + _clone accept voice_description → build instruct_ids via model._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: sends voice_description alongside voice_ref/prompt_slug (server keeps speaker identity, instruct bends delivery).
  • generation/chapter.py: 26 emotions (EMOTION_OPTIONS: original 18 + tense/urgent/ horror + drama tiers furious/joyful/terrified/griefstricken/shocked), all presets rewritten as short directorial directives (≤100 char, ruff E501); PACE_OPTIONS slow/moderate/ fast + PACE_INSTRUCT; new compose_utterance_instruct(instruct, emotion, pace) — neutral/ moderate contribute NOTHING so no-variation clones stay natural; _build_utterance_instruct composes 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.py single-utt /api/utterance/{id}/generate. write_marker now 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. New tests/test_variations.py (14 tests: composer, sync checks, remote payload carries voice_description with ref). Full suite green; ruff clean; mypy only pre-existing env noise.
  • Full regen — COMPLETE: cleared all 4101 *.wav.txt markers (+ ch*_full.wav/mp3 and ch*_build_status.json) because newer JSON markers lack instruct and would have SKIPPED regen. Ran nohup .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 carry instruct (variation edits re-trigger regen).
  • Vocals project (expression showcase) rebuilt: Rainbow Passage × all 26 emotions × 2 voices (Narrator utt0–17, Deal Hunter utt18–35). Cleared its 36 markers and ran ChapterBuilder(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/tts with 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::synchronize crawl). 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.json captured the stale package (built_at 2026-09-08T02:29:59Z + 32 MP3 md5s); on regen completion the one-shot watcher /tmp/auto_rebuild_ar.py ran ARPackageExporter(data_dir).build(discover_chapters(data_dir), load_meta(data_dir), force=True). Verified (logs/ar_rebuild_report.json): built_at 2026-09-09T11:21:11Z, 32 tracks, 0 warnings, all 32 tracks changed vs snapshot, loudness_ok/peak_ok all true, ch4 on-demand MP3 spot check RMS −20.5 / peak −3.0. Dashboard shows the new built {{ ar_manifest.built_at }} automatically.
  • Operational notes: web POST /api/audiobook/export-mp3 only guards web-side generation, NOT external batch processes — do not click AR export while a generate_all_audio.py batch runs (ch12+ would be silently dropped while their ch*_full.wav are missing). Watcher-side, completion gate = batch PID gone + === COMPLETE: … 0 failed === in logs/regen_variations.log + all 32 ch{id}_full.wav mtime ≥ 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

  1. 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.
  2. Old prompt rule 3e over-applied: any "voice"/comm → Deal Hunter, so a human voice ("Help me, please.") became the ship AI.
  3. Context window too narrow: _build_position_context only looked 8 utterances back → "It sounds female," (13 lines before) fell outside, so a female survivor's voice went to Deal Hunter.
  4. No verification pass: attribution was trust-on-faith; wrong speakers were never caught mechanically.
  5. ModelCoordinator.load_lm was 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.py prompt: 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_narration 12→20 lines; _build_position_context 8→40 utterances back, labelled dialogue, 4200-char / 45-part caps.
  • New umbraaudio/analysis/attribution_qa.py (mechanical cross-check): check_attribution() flags speech_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.py CLI: --chapters N, --fix, --type. _build_alias_map includes 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.json aliases 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").
  • 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.py covering tag mismatch, alias resolution, tag isolation, pronouns, word-overlap fallback, conjunction rejection, auto-fix gating). ruff check + mypy clean on attribution_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:132 references undefined phonetic (NameError on first utterance; superseded by generate_all_audio.py).
  • start_server.sh launches the Gradio qwen-tts-demo on port 8000 — NOT the API server scripts/qwen_tts_server.py (port 8881) the app actually uses. Use qwen_tts_server.py for restarts.
  • Orphaned Qwen generations: a client that times out does NOT cancel the server-side thread; the model keeps generating under _model_lock for minutes, blocking subsequent requests. Recovery = kill -9 the qwen server PID and restart scripts/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_references now uses rglob (finds nested refs like human_staff_person/messenger_ref.wav); resolve_reference now 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) and Supervisor (ch10 female production supervisor, stressed/assertive; supervisor_ref.wav). Both generated via scripts/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 to TTSConfig.max_retries (default 3, backoff retry_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 the voices/ dir.
  • Web UI indication (routes.py _load_speakers + templates): each speaker row shows a red "⚠ Edge-TTS fallback" badge (title = reason) when the ledger says edge AND ref_mtime matches the current ref wav (so stale entries don't flag), else a green "Qwen3-TTS ✓" after a Qwen generation. /speakers/partial also runs a live health check on config.tts.qwen_tts_url (via QwenTTSEngine. check_health) and _speakers_list.html renders 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/completions is 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 the qwen3-tts-tokenizer GGUF (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-prompt protocol of scripts/qwen_tts_server.py (transformers Qwen3-TTS-12Hz-1.7B-VoiceDesign, QWEN_DEVICE env selects cpu/mps/cuda, default mps).
  • To use the GPU for real: deploy scripts/qwen_tts_server.py on the GPU box (venv with CUDA torch + qwen_tts pip package + the HF Qwen3-TTS-12Hz-1.7B-VoiceDesign model — local copies already exist under models/; 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 via TTSConfig.qwen_tts_url (env UMBRA_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 as umbra, ssh oldbox) — see System & Deployment Notes. It was never a network/key problem; the public key was just missing from authorized_keys.
  • Xylia (Last Dwarf): regenerated lower — median F0 141.6 → 111.9 Hz via a pinned voice_description in speakers.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.