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

1308 lines
103 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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
`cd`s 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**:
```bash
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
### Session: Intent-based translation is real; the missing link was the utterance alignment
- **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 `cd`s 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 ("Relea**sing**",
"Clo**sing**", "Sen**sing**", "Pas**sing**") 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.