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.
1308 lines
103 KiB
Markdown
1308 lines
103 KiB
Markdown
# 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.
|