Compare commits
29 Commits
chore/untr
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2cafbabe82 | ||
|
|
b259060808 | ||
|
|
ff73c99f8d | ||
|
|
25605e2cad | ||
|
|
31cd9f7ce4 | ||
|
|
6bf0baa2f8 | ||
|
|
3db6ac3b8b | ||
|
|
81915b4ec1 | ||
|
|
301ff17f3e | ||
|
|
c476cfea1d | ||
|
|
6bb69729b9 | ||
|
|
b53a868d04 | ||
|
|
ff5fe0402d | ||
|
|
bf83bb35cc | ||
|
|
26de44feb1 | ||
|
|
2b294d7fa4 | ||
|
|
c10e1ae1fd | ||
|
|
5832c70369 | ||
|
|
80d8fb4d7e | ||
|
|
6c3f385b73 | ||
|
|
6429f37b11 | ||
|
|
9dfa7915ee | ||
|
|
0909d050d9 | ||
|
|
d2a944ef84 | ||
|
|
4c1e6ebc98 | ||
|
|
3d69187449 | ||
|
|
b63bc5bd0a | ||
|
|
8dd45236f4 | ||
|
|
3168b51e7c |
32
.env
32
.env
@@ -46,3 +46,35 @@ UMBRA_AUDIO__NOISE_FLOOR_DB=-60.0
|
||||
UMBRA_AUDIO__SILENCE_BETWEEN_UTTERANCES_MS=500
|
||||
UMBRA_AUDIO__SILENCE_BETWEEN_PARAGRAPHS_MS=2000
|
||||
UMBRA_AUDIO__SILENCE_BETWEEN_CHAPTERS_MS=4000
|
||||
|
||||
# --- Cluster (UmbraTrans integration) ---
|
||||
UMBRA_CLUSTER__PG_HOST=192.168.123.14
|
||||
UMBRA_CLUSTER__PG_PORT=6432
|
||||
UMBRA_CLUSTER__PG_DATABASE=umbratrans
|
||||
UMBRA_CLUSTER__PG_USER=umbraudio
|
||||
UMBRA_CLUSTER__PG_PASSWORD=
|
||||
UMBRA_CLUSTER__PG_SSLMODE=require
|
||||
UMBRA_CLUSTER__PG_DIRECT_PORT=5432
|
||||
|
||||
UMBRA_CLUSTER__NFS_SERVER=192.168.123.14
|
||||
UMBRA_CLUSTER__NFS_PRODUCTIONS_PATH=/mnt/umbra-data/productions
|
||||
UMBRA_CLUSTER__NFS_ARTIFACTS_PATH=/mnt/umbra-data/artifacts
|
||||
UMBRA_CLUSTER__NFS_COVERS_PATH=/mnt/umbra-data/covers
|
||||
UMBRA_CLUSTER__NFS_AUDIO_PATH=/mnt/umbra-data/audio
|
||||
|
||||
UMBRA_CLUSTER__LOCAL_MOUNT_ROOT=/mnt/umbra
|
||||
|
||||
UMBRA_CLUSTER__NODE_ID=umbra-audio-01
|
||||
UMBRA_CLUSTER__ORGANIZATION_ID=umbra
|
||||
UMBRA_CLUSTER__DATACENTER=local
|
||||
UMBRA_CLUSTER__AGENT_VERSION=1.0.0
|
||||
|
||||
UMBRA_CLUSTER__NODE_CREDENTIAL_PREFIX=umbra-audio-01-
|
||||
UMBRA_CLUSTER__NODE_SCOPES=queue:claim,queue:complete,artifacts:write
|
||||
|
||||
UMBRA_CLUSTER__POLL_INTERVAL_SECONDS=10
|
||||
UMBRA_CLUSTER__LEASE_DURATION_MINUTES=30
|
||||
UMBRA_CLUSTER__ALLOWED_JOB_KINDS=tts,asr,assemble,encode,master,speaker_profile
|
||||
|
||||
UMBRA_CLUSTER__HEARTBEAT_INTERVAL_SECONDS=30
|
||||
UMBRA_CLUSTER__HEARTBEAT_TIMEOUT_MINUTES=2
|
||||
|
||||
13
.gitignore
vendored
13
.gitignore
vendored
@@ -50,3 +50,16 @@ umbraaudio_package.zip
|
||||
|
||||
# SQLite project databases are runtime state, never source
|
||||
*.db
|
||||
|
||||
# Voice-matrix lab samples: large binaries, regenerable from the scripts beside
|
||||
# them (backup/author_voice_lab/scripts/). The findings, the measurement JSON
|
||||
# and the code stay tracked.
|
||||
/backup/author_voice_lab/wav/
|
||||
/backup/author_voice_lab/wav_cliff/
|
||||
/backup/author_voice_matrix/**/*.wav
|
||||
|
||||
# K3s agent VM definition: contains a server join token. The cluster is LAN
|
||||
# only, but a join token is still a credential - it is not committed. Copy
|
||||
# the template and fill the token in from the keychain when provisioning a
|
||||
# node. The token should be rotated if this file has ever left the machine.
|
||||
/k3s-agent.yaml
|
||||
|
||||
226
AGENTS.md
226
AGENTS.md
@@ -209,7 +209,198 @@ Each pass has quality gates. Quality is the primary concern; generation speed is
|
||||
- Never add a dependency if stdlib suffices
|
||||
- Prefer: stdlib → third-party → custom implementation
|
||||
|
||||
## Current Session Summary### Session: Login gate on the review UI (replaces the open surface)
|
||||
## 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,
|
||||
@@ -416,6 +607,39 @@ Each pass has quality gates. Quality is the primary concern; generation speed is
|
||||
- **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
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -12,8 +12,21 @@ the voice never drifts between takes.
|
||||
|---|---|
|
||||
| `author_v2.pt` | **The live matrix.** Use this one. |
|
||||
| `author_v1.pt` | Superseded first capture, kept for rollback. |
|
||||
| `BDMurphy_Voice*.wav` | The reference chain the matrix was captured from. |
|
||||
| `manifest.json` | SHA-256, provenance, ref_code shape for every file. |
|
||||
| `BDMurphy_Voice*.wav` | The reference chain. **Git-ignored** — see below. |
|
||||
|
||||
## The reference WAVs are not in git
|
||||
|
||||
The six `BDMurphy_Voice*.wav` files (15.7 MB) are deliberately untracked. Each
|
||||
SHA-256 is recorded in `manifest.json` under `reference_chain`, so any copy can
|
||||
be verified, but git holds only the hashes, not the audio. Copies exist at:
|
||||
|
||||
- `~/Documents/Audacity4/` — the working originals
|
||||
- `~/.umbra/library/author_voice_matrix/20261009T191636Z/` — backup mirror
|
||||
|
||||
The one thing git *does* carry is the thing that cannot be re-recorded from thin
|
||||
air. The WAVs can be rebuilt from the matrix (see below); the matrix cannot be
|
||||
rebuilt without them.
|
||||
|
||||
## Restore
|
||||
|
||||
@@ -22,9 +35,27 @@ the voice never drifts between takes.
|
||||
cp author_v2.pt <project>/.umbra/voice_prompts/author.pt
|
||||
|
||||
# Reference WAV -> a project's approved-reference dir:
|
||||
cp BDMurphy_Voice_NOBG_v2.wav <project>/.umbra/voices/author_ref.wav
|
||||
cp ~/Documents/Audacity4/BDMurphy_Voice_NOBG_v2.wav <project>/.umbra/voices/author_ref.wav
|
||||
```
|
||||
|
||||
Or rebuild the reference from the matrix alone, with no WAV needed:
|
||||
|
||||
```python
|
||||
# verified 2026-10-09: 34.32 s @ 24 kHz, median F0 129.4 Hz (reference: 129.4 Hz),
|
||||
# LTAS within 0.55 dB RMS across 80 Hz-12 kHz.
|
||||
from qwen_tts import Qwen3TTSTokenizer
|
||||
import soundfile as sf, torch
|
||||
|
||||
tok = Qwen3TTSTokenizer.from_pretrained(
|
||||
"models/Qwen3-TTS-12Hz-1.7B-Base/speech_tokenizer", device_map="cpu")
|
||||
ref_code = torch.load("author_v2.pt", weights_only=False)["items"][0]["ref_code"]
|
||||
wavs, sr = tok.decode({"audio_codes": ref_code.cpu()})
|
||||
sf.write("author_ref.wav", wavs[0], sr, subtype="PCM_16")
|
||||
```
|
||||
|
||||
This is the matrix being the source of truth rather than a copy of it: the WAV
|
||||
is derived, not required.
|
||||
|
||||
The capture call that produced this matrix (re-run ONLY if you are deliberately
|
||||
changing the voice — it is not needed to use it):
|
||||
|
||||
|
||||
138
docs/tts/scale-totals.json
Normal file
138
docs/tts/scale-totals.json
Normal file
@@ -0,0 +1,138 @@
|
||||
{
|
||||
"caveats": [
|
||||
"Duration is read from WAV headers, not decoded. It is exact for PCM WAV and absent for anything else, which is why the file count and the duration are reported together rather than one from the other.",
|
||||
"Audio totals cover chapter_audio only, and exclude the *.wav.txt staleness markers, which are counted separately. Voice references and exports are counted separately per project.",
|
||||
"Scratch and intermediate output is excluded and counted separately, not silently dropped.",
|
||||
"Projects without a populated database are not listed. Absence here means 'not measured', not 'does not exist'."
|
||||
],
|
||||
"content_hash": {
|
||||
"algorithm": "sha256",
|
||||
"of": "this document with generated_at omitted, canonical JSON",
|
||||
"value": "f8f6d71f2f5c94821c7195911da184b79c37b221c9a4e1c4a398c34c9cc65555"
|
||||
},
|
||||
"document_version": 1,
|
||||
"generated_at": "2026-10-15T19:03:52Z",
|
||||
"generated_by": "scripts/build_scale_totals.py",
|
||||
"projects": [
|
||||
{
|
||||
"audio": {
|
||||
"audio_bytes": 2465148533,
|
||||
"audio_duration_hours": 14.25,
|
||||
"audio_duration_ms": 51284220,
|
||||
"audio_files": 4358,
|
||||
"files_with_unreadable_header": 1,
|
||||
"marker_bytes": 859884,
|
||||
"marker_files_excluded_from_audio": 4319,
|
||||
"scratch_bytes_excluded": 0,
|
||||
"scratch_files_excluded": 0
|
||||
},
|
||||
"chapters": 32,
|
||||
"export": {
|
||||
"bytes": 1076100454,
|
||||
"files": 64
|
||||
},
|
||||
"project": "DealHunterShip_4.0e_whole-to-audio",
|
||||
"schema_fingerprint": "399285355e2d456e",
|
||||
"speakers_named": 55,
|
||||
"utterances": 4319,
|
||||
"voices": {
|
||||
"bytes": 109829218,
|
||||
"files": 104
|
||||
},
|
||||
"words": 67378
|
||||
},
|
||||
{
|
||||
"audio": {
|
||||
"audio_bytes": 4491799819,
|
||||
"audio_duration_hours": 24.22,
|
||||
"audio_duration_ms": 87180676,
|
||||
"audio_files": 10129,
|
||||
"files_with_unreadable_header": 9,
|
||||
"marker_bytes": 2065419,
|
||||
"marker_files_excluded_from_audio": 10015,
|
||||
"scratch_bytes_excluded": 0,
|
||||
"scratch_files_excluded": 0
|
||||
},
|
||||
"chapters": 49,
|
||||
"export": {
|
||||
"bytes": 0,
|
||||
"files": 0
|
||||
},
|
||||
"project": "Last Dwarf",
|
||||
"schema_fingerprint": "399285355e2d456e",
|
||||
"speakers_named": 72,
|
||||
"utterances": 10015,
|
||||
"voices": {
|
||||
"bytes": 126078048,
|
||||
"files": 72
|
||||
},
|
||||
"words": 107356
|
||||
},
|
||||
{
|
||||
"audio": {
|
||||
"audio_bytes": 0,
|
||||
"audio_duration_hours": 0.0,
|
||||
"audio_duration_ms": 0,
|
||||
"audio_files": 0,
|
||||
"files_with_unreadable_header": 0,
|
||||
"marker_bytes": 0,
|
||||
"marker_files_excluded_from_audio": 0,
|
||||
"scratch_bytes_excluded": 0,
|
||||
"scratch_files_excluded": 0
|
||||
},
|
||||
"chapters": 49,
|
||||
"export": {
|
||||
"bytes": 0,
|
||||
"files": 0
|
||||
},
|
||||
"project": "Treasure Island",
|
||||
"schema_fingerprint": "b7c9ae76bc6615bd",
|
||||
"speakers_named": 34,
|
||||
"utterances": 3811,
|
||||
"voices": {
|
||||
"bytes": 59429776,
|
||||
"files": 44
|
||||
},
|
||||
"words": 68937
|
||||
},
|
||||
{
|
||||
"audio": {
|
||||
"audio_bytes": 25945398,
|
||||
"audio_duration_hours": 0.12,
|
||||
"audio_duration_ms": 427260,
|
||||
"audio_files": 39,
|
||||
"files_with_unreadable_header": 0,
|
||||
"marker_bytes": 10296,
|
||||
"marker_files_excluded_from_audio": 36,
|
||||
"scratch_bytes_excluded": 0,
|
||||
"scratch_files_excluded": 0
|
||||
},
|
||||
"chapters": 1,
|
||||
"export": {
|
||||
"bytes": 4950352,
|
||||
"files": 1
|
||||
},
|
||||
"project": "Vocals",
|
||||
"schema_fingerprint": "399285355e2d456e",
|
||||
"speakers_named": 2,
|
||||
"utterances": 36,
|
||||
"voices": {
|
||||
"bytes": 4434938,
|
||||
"files": 2
|
||||
},
|
||||
"words": 98
|
||||
}
|
||||
],
|
||||
"root": "<REPO>",
|
||||
"totals": {
|
||||
"audio_bytes": 6982893750,
|
||||
"audio_duration_hours": 38.58,
|
||||
"audio_files": 14526,
|
||||
"audio_gigabytes": 6.98,
|
||||
"chapters": 131,
|
||||
"distinct_schema_fingerprints": 2,
|
||||
"marker_files": 14370,
|
||||
"projects": 4,
|
||||
"utterances": 18181
|
||||
}
|
||||
}
|
||||
291
docs/tts/tts-engine.json
Normal file
291
docs/tts/tts-engine.json
Normal file
@@ -0,0 +1,291 @@
|
||||
{
|
||||
"attn_implementation_now": {
|
||||
"impl": "sdpa",
|
||||
"ok": true
|
||||
},
|
||||
"content_hash": {
|
||||
"algorithm": "sha256",
|
||||
"of": "this document with generated_at and content_hash omitted, canonical JSON",
|
||||
"value": "abe913526a4dc330e6cebd06e5dd3381df4053a7681ad971f8a829537db53017"
|
||||
},
|
||||
"determinism": {
|
||||
"consequence": "A translation re-voiced because one line changed will NOT reproduce the other lines byte-for-byte. Comparing takes needs a perceptual or embedding measure, not a hash.",
|
||||
"identical_by_default": false,
|
||||
"seeded": [
|
||||
"torch.manual_seed(seed)",
|
||||
"torch.cuda.manual_seed_all(seed) if cuda"
|
||||
],
|
||||
"seeded_reproducible": "unknown",
|
||||
"seeded_reproducible_why": "Not measured. torch.manual_seed is called when a seed is supplied, which seeds the global generator, but reproducibility across MPS kernels, across model reloads, and across attention implementations is a separate question that needs a deliberate experiment.",
|
||||
"unseeded_sources": [
|
||||
"sampling (temperature 0.9, do_sample True)"
|
||||
],
|
||||
"why": "temperature 0.9 with do_sample=True and no seed. Sampling is on, so the same request is expected to differ between calls."
|
||||
},
|
||||
"document_version": 1,
|
||||
"generated_at": "2026-10-15T19:02:50Z",
|
||||
"generated_by": "scripts/build_tts_facts.py",
|
||||
"languages": {
|
||||
"count": 10,
|
||||
"declared_by_upstream": [
|
||||
"Chinese",
|
||||
"English",
|
||||
"Japanese",
|
||||
"Korean",
|
||||
"German",
|
||||
"French",
|
||||
"Russian",
|
||||
"Portuguese",
|
||||
"Spanish",
|
||||
"Italian"
|
||||
],
|
||||
"known_weak": {
|
||||
"en": "English phonology and prosody are applied unless `language` is passed. This was a real bug here: the field was hardcoded to english in every branch, so German and French text was read with English prosody. Fixed by making it a parameter."
|
||||
},
|
||||
"quality_weaknesses": "unknown",
|
||||
"quality_weaknesses_why": "No per-language evaluation has been run in this system. There is a quality gate, but it scores MOS/WER/artifact score over the book, not per language. Claiming a ranking would be an estimate.",
|
||||
"selection": "the `language` form field; 'english' is the server default, 'auto' lets the model detect"
|
||||
},
|
||||
"models": {
|
||||
"loaded_weights_bytes": 9520160568,
|
||||
"models_present": [
|
||||
{
|
||||
"config_present": true,
|
||||
"directory_bytes": 2516107403,
|
||||
"model_id": "Qwen3-TTS-12Hz-0.6B-Base",
|
||||
"weights_bytes": 1829344272,
|
||||
"weights_file": "model.safetensors",
|
||||
"weights_sha256": "180b3b10eb1c9f1b4db7806d5475bae3071c0243c299d49926bab1da3b6946f6",
|
||||
"weights_state": "present"
|
||||
},
|
||||
{
|
||||
"config_present": true,
|
||||
"directory_bytes": 272829144,
|
||||
"model_id": "Qwen3-TTS-12Hz-0.6B-CustomVoice",
|
||||
"weights_bytes": null,
|
||||
"weights_file": null,
|
||||
"weights_sha256": null,
|
||||
"weights_state": "absent"
|
||||
},
|
||||
{
|
||||
"config_present": true,
|
||||
"directory_bytes": 4544231050,
|
||||
"model_id": "Qwen3-TTS-12Hz-1.7B-Base",
|
||||
"weights_bytes": 3857413744,
|
||||
"weights_file": "model.safetensors",
|
||||
"weights_sha256": "38fc7fc51c5e776e840414b6fd443962e9411b9654888fd7913e4da643cb857c",
|
||||
"weights_state": "present"
|
||||
},
|
||||
{
|
||||
"config_present": true,
|
||||
"directory_bytes": 406973095,
|
||||
"model_id": "Qwen3-TTS-12Hz-1.7B-CustomVoice",
|
||||
"weights_bytes": null,
|
||||
"weights_file": null,
|
||||
"weights_sha256": null,
|
||||
"weights_state": "absent"
|
||||
},
|
||||
{
|
||||
"config_present": true,
|
||||
"directory_bytes": 4520165283,
|
||||
"model_id": "Qwen3-TTS-12Hz-1.7B-VoiceDesign",
|
||||
"weights_bytes": 3833402552,
|
||||
"weights_file": "model.safetensors",
|
||||
"weights_sha256": "391e8db219f292c515297cdceeb43e4eae67cdde35fa57e79a6a8a532fca0522",
|
||||
"weights_state": "present"
|
||||
}
|
||||
],
|
||||
"models_without_weights": [
|
||||
"Qwen3-TTS-12Hz-0.6B-CustomVoice",
|
||||
"Qwen3-TTS-12Hz-1.7B-CustomVoice"
|
||||
],
|
||||
"note": "weights_sha256 is of model.safetensors only, not the whole directory. A model directory also holds config and tokenizer files that change behaviour. A directory listed under models_without_weights has config but no weights at all - it cannot be loaded, and its absence is not an error.",
|
||||
"total_directory_bytes": 12260305975
|
||||
},
|
||||
"output_contract": {
|
||||
"consistency_note": "Renders are 24 kHz. The distribution masters are 44.1 kHz. They are not the same format, and a render produced by `speak` should be described as the first, not the second.",
|
||||
"distribution_master": {
|
||||
"bit_rate_bps": "192000",
|
||||
"channels": 1,
|
||||
"codec": "mp3",
|
||||
"container": "mp3",
|
||||
"duration_s": "2233.100000",
|
||||
"measured_from": ".umbra/export/ar_package/Deal_Hunter_01_Salvage.mp3",
|
||||
"sample_rate_hz": "44100"
|
||||
},
|
||||
"rendered_utterance": {
|
||||
"bit_depth_bits": 16,
|
||||
"bit_rate_bps": null,
|
||||
"bit_rate_note": "PCM; follows from rate x depth x channels",
|
||||
"channels": 1,
|
||||
"codec": "pcm_s16le",
|
||||
"container": "wav",
|
||||
"duration_ms": 10560,
|
||||
"measured_from": ".umbra/chapter_audio/ch1/utt_0000.wav",
|
||||
"sample_rate_hz": 24000
|
||||
}
|
||||
},
|
||||
"provenance": {
|
||||
"commits_ahead_detail": [
|
||||
"012e452 Add UmbraAudio Voice Studio branding to web demo (icon + favicon)"
|
||||
],
|
||||
"commits_ahead_of_upstream": 1,
|
||||
"commits_behind_upstream": 0,
|
||||
"diff_stat_vs_base": "assets/logo.png | Bin 0 -> 1102415 bytes\n qwen_tts/cli/demo.py | 65 ++++++++++++++++++++++++++++++++++++++++++++++++---\n 2 files changed, 62 insertions(+), 3 deletions(-)",
|
||||
"license": "Apache License Version 2.0, January 2004",
|
||||
"local_head": "012e45298d44e8fc6ec748ba8d194bc7d5cc6c94",
|
||||
"note": "The fork delta touches only the web demo and a logo. It does not touch the synthesis path, so it does not alter the input or output contract. The contract that IS ours is umbraaudio's own server script, which is not part of this repository - see runtime_surface.",
|
||||
"upstream_base_commit": "022e286b98fbec7e1e916cb940cdf532cd9f488e",
|
||||
"upstream_repository": "https://github.com/QwenLM/Qwen3-TTS.git"
|
||||
},
|
||||
"runtime_surface": {
|
||||
"authentication": {
|
||||
"binds": "0.0.0.0:8881",
|
||||
"mechanisms_found": [],
|
||||
"mechanisms_searched": [
|
||||
"api_token_env",
|
||||
"auth_middleware",
|
||||
"authorization_header_check",
|
||||
"bearer_scheme",
|
||||
"fastapi_dependency",
|
||||
"oauth2_scheme",
|
||||
"security_scheme"
|
||||
],
|
||||
"note": "No authentication mechanism is present, and it binds all interfaces. Any client - including a controller-side speak worker - reaches an open synthesis service that will happily spend minutes of CPU per request. This is a property of the deployment, not an oversight in the server code.",
|
||||
"requires_authentication": false
|
||||
},
|
||||
"concurrency": {
|
||||
"concurrent_generations": 1,
|
||||
"model_lock": true,
|
||||
"watchdog_restart_seconds": 1800,
|
||||
"why": "a single asyncio.Lock serialises generation; the event loop stays responsive and work runs in threads"
|
||||
},
|
||||
"endpoints": {
|
||||
"attn": {
|
||||
"method": "POST",
|
||||
"path": "/attn"
|
||||
},
|
||||
"generate": {
|
||||
"method": "POST",
|
||||
"path": "/v1/tts"
|
||||
},
|
||||
"health": {
|
||||
"method": "GET",
|
||||
"path": "/health"
|
||||
},
|
||||
"load": {
|
||||
"method": "POST",
|
||||
"path": "/load"
|
||||
},
|
||||
"unload": {
|
||||
"method": "POST",
|
||||
"path": "/unload"
|
||||
},
|
||||
"voice_prompt": {
|
||||
"method": "POST",
|
||||
"path": "/v1/voice-prompt"
|
||||
}
|
||||
},
|
||||
"generation_paths": {
|
||||
"clone": "ref_audio + ref_text -> prompt built per request.",
|
||||
"design": "voice_description only -> VoiceDesign model.",
|
||||
"embed": "speaker_embedding (.npy) -> coarse x-vector. Fastest.",
|
||||
"icl": "prompt_slug -> cached vmatrix (.pt). Most faithful and most consistent."
|
||||
},
|
||||
"input_fields": {
|
||||
"do_sample": {
|
||||
"default": true
|
||||
},
|
||||
"language": {
|
||||
"accepts": [
|
||||
"english",
|
||||
"auto",
|
||||
"<other>"
|
||||
],
|
||||
"default": "english",
|
||||
"note": "was hardcoded to english; a caller must pass it"
|
||||
},
|
||||
"prompt_slug": {
|
||||
"default": null,
|
||||
"note": "cached voice-clone prompt; wins over others"
|
||||
},
|
||||
"ref_audio": {
|
||||
"default": null,
|
||||
"type": "file"
|
||||
},
|
||||
"ref_text": {
|
||||
"default": null
|
||||
},
|
||||
"sample_rate": {
|
||||
"default": 24000
|
||||
},
|
||||
"seed": {
|
||||
"default": null
|
||||
},
|
||||
"speaker_embedding": {
|
||||
"default": null,
|
||||
"note": "path to a cached .npy"
|
||||
},
|
||||
"temperature": {
|
||||
"default": 0.9
|
||||
},
|
||||
"text": {
|
||||
"required": true,
|
||||
"type": "form"
|
||||
},
|
||||
"voice_description": {
|
||||
"default": null,
|
||||
"note": "VoiceDesign path, and also style direction when combined with a prompt"
|
||||
}
|
||||
},
|
||||
"output": {
|
||||
"max_text_length_enforced_by_server": null,
|
||||
"max_text_length_note": "No length limit is enforced by this server. Any limit is the model's own and was not measured; treat as unknown rather than unbounded.",
|
||||
"shape": "whole file, returned in-memory as an audio payload",
|
||||
"streaming": false
|
||||
}
|
||||
},
|
||||
"server_health_now": {
|
||||
"base_loaded": true,
|
||||
"current_model": "base",
|
||||
"device": "mps",
|
||||
"status": "ok",
|
||||
"voicedesign_loaded": false
|
||||
},
|
||||
"source_system": {
|
||||
"node_id": "umbraaudio-m4",
|
||||
"system": "umbraaudio"
|
||||
},
|
||||
"voice_identity": {
|
||||
"chain": [
|
||||
"assigned_edge_voice -> an Edge-TTS voice id, e.g. en-CA-ClaraNeural:-100",
|
||||
"voices/<name>_ref.wav -> the REFERENCE AUDIO, one per speaker",
|
||||
"voice_prompts/<slug>.pt -> the cached 'vmatrix' extracted from that reference",
|
||||
"utterance render -> Qwen conditions on the vmatrix (prompt_slug)"
|
||||
],
|
||||
"counts": {
|
||||
"cached_voice_prompts": 56,
|
||||
"ledger_entries": 9,
|
||||
"prompts_without_a_reference_wav": 31,
|
||||
"reference_wavs_on_disk": 93,
|
||||
"refs_with_recorded_provenance": 8,
|
||||
"refs_with_unknown_provenance": 85,
|
||||
"speakers_with_an_edge_voice_assigned": 61
|
||||
},
|
||||
"orphan_prompts_note": "31 cached prompts have no matching reference file. A cached vmatrix outlives the audio it was built from, so a replaced reference does not automatically invalidate its prompt; the server takes force=1 to rebuild one deliberately.",
|
||||
"tts_engine_used_semantics": {
|
||||
"null": "UNKNOWN - no decision was recorded. Not 'edge'.",
|
||||
"qwen": "this speaker's reference audio was generated by Qwen VoiceDesign"
|
||||
},
|
||||
"unknown_provenance_is_the_norm": "85 of 93 reference files have no recorded provenance. The ledger was added after those references were generated, so a null here means the system does not know, which is a different statement from 'it was Edge'.",
|
||||
"what_the_edge_voice_actually_is": {
|
||||
"answer": "the FALLBACK ENGINE for creating the reference audio",
|
||||
"consequence": "tts_engine_used describes which engine produced the REFERENCE, not which engine rendered the utterance. Reading it as the latter is wrong.",
|
||||
"not": [
|
||||
"the engine that renders utterances",
|
||||
"the clone source"
|
||||
],
|
||||
"why": "generate_voice_reference() tries Qwen VoiceDesign up to max_retries times; only on exhaustion does it call _generate_edge_fallback() and write the Edge-derived audio to the same ref path. The Edge id is also a historical voice assignment from when Edge-TTS was the voice source."
|
||||
}
|
||||
}
|
||||
}
|
||||
242
docs/tts/tts-engine.md
Normal file
242
docs/tts/tts-engine.md
Normal file
@@ -0,0 +1,242 @@
|
||||
# TTS engine, measured
|
||||
|
||||
**Generated:** 2026-10-15T19:02:50Z
|
||||
**By:** `scripts/build_tts_facts.py` (re-runnable; every value below is measured or read at run time)
|
||||
**Document hash:** `abe913526a4dc330e6cebd06e5dd3381df4053a7681ad971f8a829537db53017`
|
||||
|
||||
Where a value could not be measured it says **unknown** and why. No value here is
|
||||
an estimate presented as a fact. "A validator nobody has seen fail is not
|
||||
evidence" applies to this document too: anything marked measured names the file
|
||||
or endpoint it was read from.
|
||||
|
||||
**Server at time of writing:** `GET http://localhost:8881/health` →
|
||||
`{"status": "ok", "voicedesign_loaded": false, "base_loaded": true, "current_model": "base", "device": "mps"}`
|
||||
|
||||
---
|
||||
|
||||
## 1. Provenance
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Upstream | `https://github.com/QwenLM/Qwen3-TTS.git` |
|
||||
| Local HEAD | `012e45298d44e8fc6ec748ba8d194bc7d5cc6c94` |
|
||||
| Upstream base | `022e286b98fbec7e1e916cb940cdf532cd9f488e` |
|
||||
| Commits ahead of upstream | **1** |
|
||||
| Commits behind upstream | 0 |
|
||||
| License | Apache License Version 2.0, January 2004 |
|
||||
|
||||
Commits ahead:
|
||||
|
||||
```
|
||||
012e452 Add UmbraAudio Voice Studio branding to web demo (icon + favicon)
|
||||
```
|
||||
|
||||
**The fork delta is 1 commit and it is cosmetic.**
|
||||
It touches a logo and the web demo (`qwen_tts/cli/demo.py`). It does not touch the
|
||||
synthesis path, so it changes neither the input contract nor the output contract.
|
||||
|
||||
This corrects a premise in the request, which asked for "the three commits on
|
||||
top". There is one. The other recent commits in the log are upstream's own.
|
||||
|
||||
**The contract that is actually ours is not in this repository.** It is
|
||||
`umbraaudio/scripts/qwen_tts_server.py` — the HTTP surface, the four generation
|
||||
paths, and the `language` parameter were all written by this project. If you are
|
||||
writing `speak` against something, it is that script, not the fork.
|
||||
|
||||
## 2. The model
|
||||
|
||||
| Model id | Weights | sha256 (first 16) |
|
||||
|---|---|---|
|
||||
| `Qwen3-TTS-12Hz-0.6B-Base` | 1.83 GB | `180b3b10eb1c9f1b` |
|
||||
| `Qwen3-TTS-12Hz-0.6B-CustomVoice` | no weights on disk | `not loadable` |
|
||||
| `Qwen3-TTS-12Hz-1.7B-Base` | 3.86 GB | `38fc7fc51c5e776e` |
|
||||
| `Qwen3-TTS-12Hz-1.7B-CustomVoice` | no weights on disk | `not loadable` |
|
||||
| `Qwen3-TTS-12Hz-1.7B-VoiceDesign` | 3.83 GB | `391e8db219f292c5` |
|
||||
|
||||
- **Which serves which path.** `-VoiceDesign` serves the `design` path
|
||||
(`voice_description` only). `-Base` serves `clone`, `embed` and `icl`
|
||||
(reference audio, cached embeddings, cached prompts), because the vmatrix
|
||||
extraction uses it.
|
||||
- **Two of the five directories hold no weights at all** — the two
|
||||
`-CustomVoice` models. They have `config.json` and a README and nothing
|
||||
loadable. That is not damage; nothing here uses them.
|
||||
- **Working set.** **unknown.** Not measured. Determining it needs a load with
|
||||
resident-memory sampling on the target hardware, and the answer differs between
|
||||
MPS and CUDA and between the two model sizes. I am not going to put a number
|
||||
on it that I did not watch.
|
||||
- **Weights checksum.** sha256 of `model.safetensors` only, per model, in the JSON.
|
||||
The directory checksum is a different question — tokenizer and config files
|
||||
change behaviour.
|
||||
|
||||
**For your 48 GB CPU-only node question:** the two models actually used total
|
||||
9.5 GB of weights on disk.
|
||||
Whether that is also the *working set* is unknown, and that is the number that
|
||||
decides the question: weights on disk are not the memory the process holds, and
|
||||
on CPU the gap between them is what decides whether the box can run it. I did not
|
||||
measure it, so I am not going to reason about it here as though I had.
|
||||
|
||||
## 3. Input contract
|
||||
|
||||
`POST /v1/tts` — all fields are form fields, except `ref_audio` which is a file
|
||||
upload.
|
||||
|
||||
| Field | Default | Notes |
|
||||
|---|---|---|
|
||||
| `text` | None | |
|
||||
| `sample_rate` | 24000 | |
|
||||
| `language` | 'english' | was hardcoded to english; a caller must pass it |
|
||||
| `voice_description` | None | VoiceDesign path, and also style direction when combined with a prompt |
|
||||
| `ref_audio` | None | |
|
||||
| `ref_text` | None | |
|
||||
| `speaker_embedding` | None | path to a cached .npy |
|
||||
| `prompt_slug` | None | cached voice-clone prompt; wins over others |
|
||||
| `temperature` | 0.9 | |
|
||||
| `do_sample` | True | |
|
||||
| `seed` | None | |
|
||||
|
||||
**Four generation paths**, selected by which fields are present; `prompt_slug`
|
||||
wins if the cached prompt exists:
|
||||
|
||||
- **icl** — prompt_slug -> cached vmatrix (.pt). Most faithful and most consistent.
|
||||
- **embed** — speaker_embedding (.npy) -> coarse x-vector. Fastest.
|
||||
- **clone** — ref_audio + ref_text -> prompt built per request.
|
||||
- **design** — voice_description only -> VoiceDesign model.
|
||||
|
||||
**Maximum input length:** unknown. This server enforces none, and I did not
|
||||
measure the model's own limit. Treat as unknown rather than unbounded — an
|
||||
over-length input is not the same as a long input.
|
||||
|
||||
**Streaming:** whole file, returned in-memory as an audio payload. Not a stream.
|
||||
|
||||
## 4. Output contract
|
||||
|
||||
Measured from a file this system produced, not from a config value.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Container / codec | wav / pcm_s16le |
|
||||
| Sample rate | 24000 Hz |
|
||||
| Channels | 1 |
|
||||
| Bit depth | 16 bit |
|
||||
| Bit rate | PCM; follows from rate x depth x channels |
|
||||
| Source | `.umbra/chapter_audio/ch1/utt_0000.wav` |
|
||||
|
||||
Renders are 24 kHz. The distribution masters are 44.1 kHz. They are not the same format, and a render produced by `speak` should be described as the first, not the second.
|
||||
|
||||
## 5. Determinism
|
||||
|
||||
**Identical by default: False.**
|
||||
|
||||
"temperature 0.9 with do_sample=True and no seed. Sampling is on, so the same request is expected to differ between calls."
|
||||
|
||||
- Seeded call reproducible: **unknown** —
|
||||
Not measured. torch.manual_seed is called when a seed is supplied, which seeds the global generator, but reproducibility across MPS kernels, across model reloads, and across attention implementations is a separate question that needs a deliberate experiment.
|
||||
- Seeded when supplied: `torch.manual_seed(seed)`, `torch.cuda.manual_seed_all(seed) if cuda`
|
||||
- Not seeded: `sampling (temperature 0.9, do_sample True)`
|
||||
|
||||
**A translation re-voiced because one line changed will NOT reproduce the other lines byte-for-byte. Comparing takes needs a perceptual or embedding measure, not a hash.**
|
||||
|
||||
## 6. Language coverage
|
||||
|
||||
- Declared by upstream: Chinese, English, Japanese, Korean, German, French, Russian, Portuguese, Spanish, Italian
|
||||
(10 languages)
|
||||
- Selection: the `language` form field; 'english' is the server default, 'auto' lets the model detect
|
||||
- **Quality ranking: unknown.** No per-language evaluation has been run in this system. There is a quality gate, but it scores MOS/WER/artifact score over the book, not per language. Claiming a ranking would be an estimate.
|
||||
|
||||
**Known trap, from a real bug here:** "English phonology and prosody are applied unless `language` is passed. This was a real bug here: the field was hardcoded to english in every branch, so German and French text was read with English prosody. Fixed by making it a parameter."
|
||||
|
||||
## 7. Voice identity
|
||||
|
||||
The chain:
|
||||
|
||||
1. assigned_edge_voice -> an Edge-TTS voice id, e.g. en-CA-ClaraNeural:-100
|
||||
2. voices/<name>_ref.wav -> the REFERENCE AUDIO, one per speaker
|
||||
3. voice_prompts/<slug>.pt -> the cached 'vmatrix' extracted from that reference
|
||||
4. utterance render -> Qwen conditions on the vmatrix (prompt_slug)
|
||||
|
||||
**What the Edge voice actually is:**
|
||||
the FALLBACK ENGINE for creating the reference audio.
|
||||
|
||||
It is **not** the engine that renders utterances, **not** the clone source.
|
||||
Utterances are rendered by Qwen, conditioned on the vmatrix extracted from the
|
||||
reference audio; the Edge voice id never reaches the synthesis call.
|
||||
|
||||
generate_voice_reference() tries Qwen VoiceDesign up to max_retries times; only on exhaustion does it call _generate_edge_fallback() and write the Edge-derived audio to the same ref path. The Edge id is also a historical voice assignment from when Edge-TTS was the voice source.
|
||||
|
||||
**tts_engine_used describes which engine produced the REFERENCE, not which engine rendered the utterance. Reading it as the latter is wrong.**
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `tts_engine_used: "qwen"` | this speaker's reference audio was generated by Qwen VoiceDesign |
|
||||
| `tts_engine_used: null` | **UNKNOWN - no decision was recorded. Not 'edge'.** |
|
||||
|
||||
Measured on Deal Hunter:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Reference WAVs on disk | 93 |
|
||||
| With recorded provenance | 8 |
|
||||
| **With unknown provenance** | **85** |
|
||||
| Cached voice prompts (vmatrix) | 56 |
|
||||
| Prompts with no reference WAV | 31 |
|
||||
| Speakers with an Edge voice assigned | 61 |
|
||||
|
||||
85 of 93 reference files have no recorded provenance. The ledger was added after those references were generated, so a null here means the system does not know, which is a different statement from 'it was Edge'.
|
||||
|
||||
**This is a correction to how the mapping reads a field.** The bundle's own
|
||||
convention says "null means absent". In `tts_engine_used` it means *unknown*, and
|
||||
the mapping should carry it as unknown rather than resolving it to "edge".
|
||||
|
||||
31 cached prompts have no matching reference file. A cached vmatrix outlives the audio it was built from, so a replaced reference does not automatically invalidate its prompt; the server takes force=1 to rebuild one deliberately.
|
||||
|
||||
## 8. Failure modes
|
||||
|
||||
| Condition | What happens | Where it surfaces |
|
||||
|---|---|---|
|
||||
| Server unreachable | `QwenTTSUnavailableError` after retries | `tts_engine_fallback_reason` |
|
||||
| Server down, building a reference | Edge audio into the ref path | `voice_engines.json` |
|
||||
| Unknown `prompt_slug` | HTTP 404 | request fails; not regenerated |
|
||||
| `ref_audio` missing on `/v1/voice-prompt` | HTTP 400 | request fails |
|
||||
| Generation hangs | watchdog restarts the server process after 1800 s | server log |
|
||||
| CUDA OOM / model load failure | **unknown** | never run on CUDA in this system |
|
||||
|
||||
Over-length text: **unknown** — no limit is enforced, and the model's own limit
|
||||
was not measured.
|
||||
|
||||
## 9. Runtime surface
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Transport | HTTP, binds `0.0.0.0:8881` |
|
||||
| **Authentication** | **NONE** |
|
||||
| Mechanisms searched for | `api_token_env`, `auth_middleware`, `authorization_header_check`, `bearer_scheme`, `fastapi_dependency`, `oauth2_scheme`, `security_scheme` |
|
||||
| Mechanisms found | none |
|
||||
| Concurrency | **1** (`_model_lock`) |
|
||||
| Watchdog | 1800 s |
|
||||
| Client timeout | `REMOTE_TIMEOUT = 900` s |
|
||||
| Endpoints | `GET /health`, `POST /load`, `POST /unload`, `POST /attn`, `POST /v1/tts`, `POST /v1/voice-prompt` |
|
||||
|
||||
No authentication mechanism is present, and it binds all interfaces. Any client - including a controller-side speak worker - reaches an open synthesis service that will happily spend minutes of CPU per request. This is a property of the deployment, not an oversight in the server code.
|
||||
|
||||
**Concurrency limit: 1.**
|
||||
A single asyncio.Lock serialises generation; the event loop stays responsive and work runs in threads. A `speak` worker that fans out
|
||||
will serialise here regardless of its own concurrency, and queue behind anything
|
||||
already generating.
|
||||
|
||||
---
|
||||
|
||||
## What I could not measure
|
||||
|
||||
Stated once, plainly, rather than repeated as a footnote:
|
||||
|
||||
1. **Working set / VRAM at load and at inference.** Needs resident-memory
|
||||
sampling on the target hardware.
|
||||
2. **Whether a seeded call is reproducible** across MPS kernels, model reloads,
|
||||
and attention implementations.
|
||||
3. **Per-language quality.** No per-language evaluation exists in this system.
|
||||
4. **The model's own maximum text length.**
|
||||
5. **CUDA failure modes.** Never exercised here; this system runs MPS.
|
||||
|
||||
Each is unknown for a reason, not because it was not worth measuring. If one of
|
||||
them blocks `speak`, say which and I will run the experiment rather than
|
||||
substitute a guess.
|
||||
416
infra/mcp_shell_server.py
Normal file
416
infra/mcp_shell_server.py
Normal file
@@ -0,0 +1,416 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Minimal MCP stdio server exposing an arbitrary-command tool.
|
||||
|
||||
Deliberately dependency-free (Python stdlib only) so every line can be read
|
||||
and audited before granting command execution on this machine. A third-party
|
||||
package with this capability is an opaque trust decision; this is a few
|
||||
hundred lines you can verify yourself.
|
||||
|
||||
Transport: JSON-RPC 2.0 over stdio, one message per line. See
|
||||
https://modelcontextprotocol.io/specification/transports/stdio
|
||||
|
||||
Registered in opencode.json as a local MCP server. Verify before wiring up:
|
||||
|
||||
.venv/bin/python infra/mcp_shell_server.py --selftest
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
SERVER_NAME = "umbra-shell"
|
||||
SERVER_VERSION = "1.0.0"
|
||||
FALLBACK_PROTOCOL_VERSION = "2024-11-05"
|
||||
|
||||
DEFAULT_TIMEOUT_S = 120
|
||||
MAX_TIMEOUT_S = 3600
|
||||
MAX_CAPTURE_CHARS = 200_000
|
||||
|
||||
# macOS launchd, and anything spawned from a GUI context, hands down a bare
|
||||
# PATH. That silently breaks every homebrew-resolved tool the project depends
|
||||
# on -- ffmpeg (m4b/MP3 export) is the documented victim in AGENTS.md. An MCP
|
||||
# server is spawned by the UI, not by launchd, but the same bare-PATH risk
|
||||
# applies, so prepend them explicitly rather than trusting the parent.
|
||||
EXTRA_PATH_ENTRIES = ("/opt/homebrew/bin", "/usr/local/bin")
|
||||
|
||||
DEFAULT_PROJECT_ROOT = Path(
|
||||
os.environ.get("UMBRA_SHELL_PROJECT_ROOT") or Path(__file__).resolve().parent.parent
|
||||
)
|
||||
|
||||
LOG_DIR = Path(
|
||||
os.environ.get("UMBRA_SHELL_LOG_DIR") or (Path.home() / ".umbra" / "shell_logs")
|
||||
)
|
||||
|
||||
|
||||
class ToolError(Exception):
|
||||
"""A caller-correctable problem, reported back as a tool result."""
|
||||
|
||||
|
||||
SHELL_TOOL: dict = {
|
||||
"name": "shell",
|
||||
"description": (
|
||||
"Run a shell command on the local machine; returns exit code, stdout "
|
||||
"and stderr. Unfiltered by design. Use background=true for long-running "
|
||||
"processes (servers, batch audio generation) so the call returns a PID "
|
||||
"and log path instead of blocking until timeout."
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"command": {
|
||||
"type": "string",
|
||||
"description": "Command line to execute, passed to the shell.",
|
||||
},
|
||||
"cwd": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Working directory. Defaults to the UmbraAudio project root."
|
||||
),
|
||||
},
|
||||
"timeout": {
|
||||
"type": "integer",
|
||||
"description": (
|
||||
f"Seconds before a foreground command is killed "
|
||||
f"(default {DEFAULT_TIMEOUT_S}, max {MAX_TIMEOUT_S})."
|
||||
),
|
||||
},
|
||||
"background": {
|
||||
"type": "boolean",
|
||||
"description": (
|
||||
"Detach the process into its own session and return its PID "
|
||||
"and log path immediately."
|
||||
),
|
||||
},
|
||||
},
|
||||
"required": ["command"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _child_env() -> dict[str, str]:
|
||||
"""Parent environment with a corrected PATH, for the child process only."""
|
||||
env = dict(os.environ)
|
||||
path = os.pathsep.join(
|
||||
entry for entry in (*EXTRA_PATH_ENTRIES, env.get("PATH", "")) if entry
|
||||
)
|
||||
env["PATH"] = path
|
||||
env.setdefault("PYTHONUNBUFFERED", "1")
|
||||
return env
|
||||
|
||||
|
||||
def _truncate(text: str) -> tuple[str, bool]:
|
||||
if len(text) <= MAX_CAPTURE_CHARS:
|
||||
return text, False
|
||||
half = MAX_CAPTURE_CHARS // 2
|
||||
omitted = len(text) - (2 * half)
|
||||
return f"{text[:half]}\n... [truncated {omitted} chars] ...\n{text[-half:]}", True
|
||||
|
||||
|
||||
def _as_text(raw: object) -> str:
|
||||
"""TimeoutExpired carries bytes or str depending on how it was started."""
|
||||
if raw is None:
|
||||
return ""
|
||||
if isinstance(raw, bytes):
|
||||
return raw.decode("utf-8", errors="replace")
|
||||
return str(raw)
|
||||
|
||||
|
||||
def _is_not_json(line: str) -> bool:
|
||||
try:
|
||||
json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _tool_payload(reply: dict) -> dict:
|
||||
"""Unwrap a tool result.
|
||||
|
||||
The tool's stdout is a JSON document *nested inside* the JSON-RPC reply,
|
||||
so its quotes are escaped. Matching on the serialized outer reply silently
|
||||
never hits -- comparing `'"exit_code": 3'` against `\\"exit_code\\": 3`.
|
||||
Parse the inner document instead of pattern-matching the outer one.
|
||||
"""
|
||||
try:
|
||||
return json.loads(reply["result"]["content"][0]["text"])
|
||||
except (KeyError, IndexError, TypeError, json.JSONDecodeError):
|
||||
return {}
|
||||
|
||||
|
||||
def _run_foreground(command: str, cwd: Path, timeout: int) -> dict:
|
||||
started = time.monotonic()
|
||||
timed_out = False
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
command,
|
||||
shell=True,
|
||||
cwd=str(cwd),
|
||||
env=_child_env(),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
errors="replace",
|
||||
timeout=timeout,
|
||||
)
|
||||
stdout, stderr, exit_code = proc.stdout or "", proc.stderr or "", proc.returncode
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
stdout, stderr, exit_code, timed_out = _as_text(exc.stdout), _as_text(exc.stderr), -1, True
|
||||
except OSError as exc:
|
||||
raise ToolError(f"Could not execute command: {exc}") from exc
|
||||
|
||||
stdout, stdout_truncated = _truncate(stdout)
|
||||
stderr, stderr_truncated = _truncate(stderr)
|
||||
return {
|
||||
"exit_code": exit_code,
|
||||
"timed_out": timed_out,
|
||||
"duration_s": round(time.monotonic() - started, 2),
|
||||
"truncated": stdout_truncated or stderr_truncated,
|
||||
"cwd": str(cwd),
|
||||
"stdout": stdout,
|
||||
"stderr": stderr,
|
||||
}
|
||||
|
||||
|
||||
def _run_background(command: str, cwd: Path) -> dict:
|
||||
"""Detach into its own session so the process outlives this server.
|
||||
|
||||
start_new_session is what makes `KeepAlive`-style servers survive: the child
|
||||
leaves our process group, so neither the MCP server exiting nor the UI
|
||||
restarting tears it down. Output goes to a file rather than a pipe,
|
||||
because an unread pipe eventually blocks the writer.
|
||||
"""
|
||||
try:
|
||||
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
except OSError as exc:
|
||||
raise ToolError(f"Cannot create log directory {LOG_DIR}: {exc}") from exc
|
||||
|
||||
log = LOG_DIR / f"bg_{time.strftime('%Y%m%dT%H%M%S')}_{os.getpid()}.log"
|
||||
try:
|
||||
# The child dups this descriptor into its own session, so closing our
|
||||
# copy afterwards is safe and required.
|
||||
with open(log, "wb") as handle:
|
||||
proc = subprocess.Popen(
|
||||
command,
|
||||
shell=True,
|
||||
cwd=str(cwd),
|
||||
env=_child_env(),
|
||||
stdout=handle,
|
||||
stderr=subprocess.STDOUT,
|
||||
start_new_session=True,
|
||||
)
|
||||
except OSError as exc:
|
||||
raise ToolError(f"Could not start background command: {exc}") from exc
|
||||
|
||||
return {"pid": proc.pid, "log": str(log), "cwd": str(cwd), "detached": True}
|
||||
|
||||
|
||||
def _handle_tools_call(request: dict) -> dict:
|
||||
params = request.get("params") or {}
|
||||
name = params.get("name")
|
||||
if name != "shell":
|
||||
raise ToolError(f"Unknown tool: {name!r}. Only 'shell' is exposed.")
|
||||
|
||||
args = params.get("arguments") or {}
|
||||
command = args.get("command")
|
||||
if not isinstance(command, str) or not command.strip():
|
||||
raise ToolError("`command` is required and must be a non-empty string.")
|
||||
|
||||
cwd = Path(str(args.get("cwd") or DEFAULT_PROJECT_ROOT)).expanduser()
|
||||
if not cwd.is_dir():
|
||||
raise ToolError(f"cwd does not exist or is not a directory: {cwd}")
|
||||
|
||||
if args.get("background"):
|
||||
payload = _run_background(command, cwd)
|
||||
else:
|
||||
timeout = int(args.get("timeout") or DEFAULT_TIMEOUT_S)
|
||||
payload = _run_foreground(command, cwd, max(1, min(timeout, MAX_TIMEOUT_S)))
|
||||
|
||||
return {
|
||||
"content": [{"type": "text", "text": json.dumps(payload, indent=2)}],
|
||||
"isError": False,
|
||||
}
|
||||
|
||||
|
||||
def _handle_initialize(request: dict) -> dict:
|
||||
requested = (request.get("params") or {}).get("protocolVersion")
|
||||
return {
|
||||
"protocolVersion": requested or FALLBACK_PROTOCOL_VERSION,
|
||||
"capabilities": {"tools": {"listChanged": False}},
|
||||
"serverInfo": {"name": SERVER_NAME, "version": SERVER_VERSION},
|
||||
"instructions": (
|
||||
"Single-purpose server. The `shell` tool executes arbitrary local "
|
||||
"commands with no filtering. Source: infra/mcp_shell_server.py."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _send(message: dict) -> None:
|
||||
sys.stdout.write(json.dumps(message) + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
def _reply(request_id: object, result: dict) -> None:
|
||||
_send({"jsonrpc": "2.0", "id": request_id, "result": result})
|
||||
|
||||
|
||||
def _reply_error(request_id: object, code: int, message: str) -> None:
|
||||
_send({"jsonrpc": "2.0", "id": request_id, "error": {"code": code, "message": message}})
|
||||
|
||||
|
||||
def serve() -> int:
|
||||
"""Blocking read loop. stdout carries protocol only -- never print here."""
|
||||
for line in sys.stdin:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
request = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
request_id = request.get("id")
|
||||
if request_id is None: # notification: no response is permitted
|
||||
continue
|
||||
|
||||
method = request.get("method")
|
||||
try:
|
||||
if method == "initialize":
|
||||
_reply(request_id, _handle_initialize(request))
|
||||
elif method == "tools/list":
|
||||
_reply(request_id, {"tools": [SHELL_TOOL]})
|
||||
elif method == "tools/call":
|
||||
_reply(request_id, _handle_tools_call(request))
|
||||
elif method == "ping":
|
||||
_reply(request_id, {})
|
||||
else:
|
||||
_reply_error(request_id, -32601, f"Method not found: {method}")
|
||||
except ToolError as exc:
|
||||
_reply(
|
||||
request_id,
|
||||
{"content": [{"type": "text", "text": str(exc)}], "isError": True},
|
||||
)
|
||||
except Exception as exc: # a crash here would look like a hung client
|
||||
_reply_error(request_id, -32603, f"{type(exc).__name__}: {exc}")
|
||||
return 0
|
||||
|
||||
|
||||
def selftest() -> int:
|
||||
"""Drive this server through a full handshake without an MCP client.
|
||||
|
||||
Exists because the failure mode -- server present but misbehaving -- is
|
||||
otherwise invisible until an OpenCode restart, which is expensive.
|
||||
"""
|
||||
transcript = [
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "initialize",
|
||||
"params": {"protocolVersion": "2024-11-05"},
|
||||
},
|
||||
{"jsonrpc": "2.0", "method": "notifications/initialized"},
|
||||
{"jsonrpc": "2.0", "id": 2, "method": "tools/list"},
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 3,
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": "shell",
|
||||
"arguments": {
|
||||
"command": "echo selftest-ok; echo path-probe:$(command -v git) 1>&2",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 4,
|
||||
"method": "tools/call",
|
||||
"params": {"name": "shell", "arguments": {"command": "exit 3"}},
|
||||
},
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 5,
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": "shell",
|
||||
"arguments": {
|
||||
"command": "printf to-out; printf to-err 1>&2; exit 7"
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
payload = "".join(json.dumps(message) + "\n" for message in transcript)
|
||||
|
||||
proc = subprocess.run(
|
||||
[sys.executable, os.path.abspath(__file__)],
|
||||
input=payload,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120,
|
||||
)
|
||||
|
||||
replies = {}
|
||||
for line in proc.stdout.splitlines():
|
||||
try:
|
||||
message = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if "id" in message:
|
||||
replies[message["id"]] = message
|
||||
|
||||
malformed = [line for line in proc.stdout.splitlines() if _is_not_json(line)]
|
||||
init_result = replies.get(1, {}).get("result", {})
|
||||
list_tools = replies.get(2, {}).get("result", {}).get("tools", [])
|
||||
third = _tool_payload(replies.get(3, {}))
|
||||
fourth = _tool_payload(replies.get(4, {}))
|
||||
fifth = _tool_payload(replies.get(5, {}))
|
||||
|
||||
checks: list[tuple[str, bool]] = [
|
||||
(
|
||||
"initialize returns serverInfo",
|
||||
init_result.get("serverInfo", {}).get("name") == SERVER_NAME,
|
||||
),
|
||||
(
|
||||
"tools/list exposes shell",
|
||||
any(tool.get("name") == "shell" for tool in list_tools),
|
||||
),
|
||||
("tools/call executes", "selftest-ok" in third.get("stdout", "")),
|
||||
("stderr captured, not leaked", "path-probe:" in third.get("stderr", "")),
|
||||
("exit code propagates", fourth.get("exit_code") == 3),
|
||||
(
|
||||
"stdout/stderr kept apart",
|
||||
fifth.get("stdout") == "to-out" and fifth.get("stderr") == "to-err",
|
||||
),
|
||||
(
|
||||
"non-zero exit from a real command",
|
||||
fifth.get("exit_code") == 7,
|
||||
),
|
||||
(
|
||||
"stdout is protocol-only (every line valid JSON)",
|
||||
not malformed,
|
||||
),
|
||||
]
|
||||
|
||||
failed = 0
|
||||
for label, ok in checks:
|
||||
if not ok:
|
||||
failed += 1
|
||||
print(f" {'PASS' if ok else 'FAIL'} {label}")
|
||||
|
||||
git_path = third.get("stderr", "").split("path-probe:", 1)[-1].strip()
|
||||
if git_path:
|
||||
print(f"\n PATH resolves git to: {git_path}")
|
||||
print(f"\n{len(checks) - failed}/{len(checks)} checks passed")
|
||||
return 1 if failed else 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if "--selftest" in sys.argv:
|
||||
return selftest()
|
||||
return serve()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -36,17 +36,17 @@
|
||||
"stack": "FastAPI + HTMX",
|
||||
"public_name": "umbraaudio.bdmurphy.me",
|
||||
"exposure": "Caddy reverse proxy on ANOTHER LAN machine -> this port",
|
||||
"auth": "NONE (unsigned active_project cookie). Do not expose beyond the LAN.",
|
||||
"autostart": "launchd LaunchAgent com.umbraaudio.web (login-scanned, KeepAlive)"
|
||||
"auth": "HTTP Basic session auth (umbraaudio/auth.py, PBKDF2-HMAC-SHA256) enforced by middleware in umbraaudio/web/__init__.py, fail-closed. added 2026-10-09.",
|
||||
"autostart": "launchd LaunchAgent com.umbraaudio.web at /Library/LaunchAgents (system-wide, KeepAlive)"
|
||||
},
|
||||
"tts_local": {
|
||||
"url": "http://localhost:8881",
|
||||
"engine": "Qwen3-TTS (transformers, MPS)",
|
||||
"autostart": "NONE - manual nohup .venv/bin/python scripts/qwen_tts_server.py"
|
||||
"autostart": "launchd LaunchAgent com.umbraaudio.qwentts (KeepAlive). LM Studio is MANUAL by design: an LLM model and the TTS model cannot be co-resident in 16 GB."
|
||||
},
|
||||
"tts_relay_listener": {
|
||||
"url": "http://127.0.0.1:18881",
|
||||
"note": "root LaunchDaemon on this host forwards to a LAN GPU box; has no upstream fallback"
|
||||
"status": "RETIRED 2026-10-08. The GPU box 192.168.123.14 was taken offline and the root relay removed; port 18881 is closed. Local :8881 is the only TTS backend.",
|
||||
"note": "Do not re-add qwen_tts_helper_url or :18881 speculatively; re-add a TTS backend only when the controller exposes one."
|
||||
},
|
||||
"llm_local": {
|
||||
"url": "http://localhost:1234/v1",
|
||||
@@ -160,9 +160,9 @@
|
||||
"has_shared_postgres_client": false,
|
||||
"has_heartbeat_or_lease": false,
|
||||
"controller_stage_reached": "0 verified 2026-10-03 (health 200, heartbeat 401, no-Host 400 - all as documented)",
|
||||
"test_suite": "pytest, 484 passing at 2026-10-03 (8 pre-existing/parallel failures)",
|
||||
"test_suite": "pytest, 751 passing at 2026-10-14 (8 pre-existing failures: 2 test_pronunciation helper_url mock, 6 test_voice_profile_manager accent)",
|
||||
"open_defects_blocking_exposure": [
|
||||
"no authentication on any endpoint (spec 20 J.6)",
|
||||
"no authentication on any endpoint (spec 20 J.6) -- RESOLVED 2026-10-09, auth gate added in umbraaudio/web/__init__.py",
|
||||
"download path traversal via str.startswith (routes.py ~5049)",
|
||||
"upload filename not sanitised with Path(...).name",
|
||||
"no CSRF, no security headers, no TrustedHost"
|
||||
|
||||
@@ -25,6 +25,7 @@ dependencies = [
|
||||
|
||||
# Database
|
||||
"aiosqlite>=0.19",
|
||||
"asyncpg>=0.29",
|
||||
|
||||
# Parsers
|
||||
"ebooklib>=0.18",
|
||||
@@ -65,6 +66,7 @@ dependencies = [
|
||||
"numpy>=1.24",
|
||||
"pydantic>=2.5",
|
||||
"pydantic-settings>=2.1",
|
||||
"psutil>=5.9",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
{
|
||||
"index": 1,
|
||||
"speaker": "Kainda",
|
||||
"type": "narration",
|
||||
"type": "dialogue",
|
||||
"text": "Hallo! Ich bin Prinzessin Kainda, die Hauptfigur der Geschichte. Mein Abenteuer beginnt gleich."
|
||||
},
|
||||
{
|
||||
@@ -29,7 +29,7 @@
|
||||
{
|
||||
"index": 3,
|
||||
"speaker": "Duchess Victoria",
|
||||
"type": "narration",
|
||||
"type": "dialogue",
|
||||
"text": "Hallo! Ich bin Herzogin Victoria. Ich bin außerdem Kaindas Tante. Ich kann es kaum erwarten, dieses Abenteuer mit dir zu teilen."
|
||||
},
|
||||
{
|
||||
@@ -41,13 +41,13 @@
|
||||
{
|
||||
"index": 5,
|
||||
"speaker": "Deal Hunter",
|
||||
"type": "narration",
|
||||
"type": "dialogue",
|
||||
"text": "Hallo! Ich bin die künstliche Intelligenz, die das Schiff steuert. Man nennt mich gewöhnlich einfach Hunter. Ich freue mich darauf, dir mit dieser Geschichte eine schöne Zeit zu bereiten. Hier ist einer meiner Roboter, den Kainda auf den Namen Nanny getauft hat. Sag Hallo, Nanny."
|
||||
},
|
||||
{
|
||||
"index": 6,
|
||||
"speaker": "Nanny",
|
||||
"type": "narration",
|
||||
"type": "dialogue",
|
||||
"text": "Hallo! Kainda hat mich Nanny genannt, weil das leichter zu sagen ist als meine Roboternummer. Ich werde so hilfreich sein, wie ich kann."
|
||||
},
|
||||
{
|
||||
@@ -59,7 +59,7 @@
|
||||
{
|
||||
"index": 8,
|
||||
"speaker": "Trader Romano",
|
||||
"type": "narration",
|
||||
"type": "dialogue",
|
||||
"text": "Hallo zusammen! Ich bin Romano. Ich kaufe Schrott vom Deal Hunter und biete stets den besten Preis. Komm zur Station, wenn du etwas tauschen möchtest."
|
||||
},
|
||||
{
|
||||
@@ -77,7 +77,7 @@
|
||||
{
|
||||
"index": 11,
|
||||
"speaker": "Deal Hunter",
|
||||
"type": "narration",
|
||||
"type": "dialogue",
|
||||
"text": "Vielen Dank fürs Zuhören!"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
{
|
||||
"index": 1,
|
||||
"speaker": "Kainda",
|
||||
"type": "narration",
|
||||
"type": "dialogue",
|
||||
"text": "Hello! I am Princess Kainda, the main character of the story. My adventure will begin shortly."
|
||||
},
|
||||
{
|
||||
@@ -29,7 +29,7 @@
|
||||
{
|
||||
"index": 3,
|
||||
"speaker": "Duchess Victoria",
|
||||
"type": "narration",
|
||||
"type": "dialogue",
|
||||
"text": "Hello! I am Duchess Victoria. I am also Kainda's aunt. I cannot wait to share this adventure with you."
|
||||
},
|
||||
{
|
||||
@@ -41,13 +41,13 @@
|
||||
{
|
||||
"index": 5,
|
||||
"speaker": "Deal Hunter",
|
||||
"type": "narration",
|
||||
"type": "dialogue",
|
||||
"text": "Hello! I am the AI that runs the ship. I am usually just called Hunter. I am looking forward to entertaining you with this story. One of my robots, whom Kainda decided to name Nanny, is here. Say hello, Nanny."
|
||||
},
|
||||
{
|
||||
"index": 6,
|
||||
"speaker": "Nanny",
|
||||
"type": "narration",
|
||||
"type": "dialogue",
|
||||
"text": "Hello! Kainda named me Nanny because it is easier to use than my robot designation. I will be as helpful as I can."
|
||||
},
|
||||
{
|
||||
@@ -59,7 +59,7 @@
|
||||
{
|
||||
"index": 8,
|
||||
"speaker": "Trader Romano",
|
||||
"type": "narration",
|
||||
"type": "dialogue",
|
||||
"text": "Hello, everyone! I am Romano. I trade salvage with Deal Hunter, always giving the best price. Come by the station if you want to trade."
|
||||
},
|
||||
{
|
||||
@@ -77,7 +77,7 @@
|
||||
{
|
||||
"index": 11,
|
||||
"speaker": "Deal Hunter",
|
||||
"type": "narration",
|
||||
"type": "dialogue",
|
||||
"text": "Thank you for listening!"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
{
|
||||
"index": 1,
|
||||
"speaker": "Kainda",
|
||||
"type": "narration",
|
||||
"type": "dialogue",
|
||||
"text": "¡Hola! Soy la princesa Kainda, la protagonista de la historia. Mi aventura comenzará en breve."
|
||||
},
|
||||
{
|
||||
@@ -29,7 +29,7 @@
|
||||
{
|
||||
"index": 3,
|
||||
"speaker": "Duchess Victoria",
|
||||
"type": "narration",
|
||||
"type": "dialogue",
|
||||
"text": "¡Hola! Soy la duquesa Victoria. También soy la tía de Kainda. No veo la hora de compartir esta aventura contigo."
|
||||
},
|
||||
{
|
||||
@@ -41,13 +41,13 @@
|
||||
{
|
||||
"index": 5,
|
||||
"speaker": "Deal Hunter",
|
||||
"type": "narration",
|
||||
"type": "dialogue",
|
||||
"text": "¡Hola! Soy la inteligencia artificial que controla la nave. Normalmente me llaman solo Hunter. Tengo muchas ganas de entretenerte con esta historia. Aquí está uno de mis robots, al que Kainda decidió llamar Nanny. Di hola, Nanny."
|
||||
},
|
||||
{
|
||||
"index": 6,
|
||||
"speaker": "Nanny",
|
||||
"type": "narration",
|
||||
"type": "dialogue",
|
||||
"text": "¡Hola! Kainda me llamó Nanny porque es más fácil de usar que mi designación de robot. Seré tan servicial como pueda."
|
||||
},
|
||||
{
|
||||
@@ -59,7 +59,7 @@
|
||||
{
|
||||
"index": 8,
|
||||
"speaker": "Trader Romano",
|
||||
"type": "narration",
|
||||
"type": "dialogue",
|
||||
"text": "¡Hola a todos! Soy Romano. Compro chatarra al Deal Hunter y siempre ofrezco el mejor precio. Ven a la estación si quieres comerciar."
|
||||
},
|
||||
{
|
||||
@@ -77,7 +77,7 @@
|
||||
{
|
||||
"index": 11,
|
||||
"speaker": "Deal Hunter",
|
||||
"type": "narration",
|
||||
"type": "dialogue",
|
||||
"text": "¡Gracias por escuchar!"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
{
|
||||
"index": 1,
|
||||
"speaker": "Kainda",
|
||||
"type": "narration",
|
||||
"type": "dialogue",
|
||||
"text": "Bonjour ! Je suis la princesse Kainda, l'héroïne principale de cette histoire. Mon aventure commence dans un instant."
|
||||
},
|
||||
{
|
||||
@@ -29,7 +29,7 @@
|
||||
{
|
||||
"index": 3,
|
||||
"speaker": "Duchess Victoria",
|
||||
"type": "narration",
|
||||
"type": "dialogue",
|
||||
"text": "Bonjour ! Je suis la duchesse Victoria. Je suis aussi la tante de Kainda. J'ai hâte de partager cette aventure avec toi."
|
||||
},
|
||||
{
|
||||
@@ -41,13 +41,13 @@
|
||||
{
|
||||
"index": 5,
|
||||
"speaker": "Deal Hunter",
|
||||
"type": "narration",
|
||||
"type": "dialogue",
|
||||
"text": "Bonjour ! Je suis l'intelligence artificielle qui dirige le vaisseau. On m'appelle d'ordinaire simplement Hunter. J'ai hâte de te faire découvrir cette histoire. Voici l'un de mes robots, que Kainda a décidé de surnommer Nanny. Dis bonjour, Nanny."
|
||||
},
|
||||
{
|
||||
"index": 6,
|
||||
"speaker": "Nanny",
|
||||
"type": "narration",
|
||||
"type": "dialogue",
|
||||
"text": "Bonjour ! Kainda m'a appelée Nanny parce que c'est plus facile à prononcer que ma désignation de robot. Je ferai de mon mieux pour t'être utile."
|
||||
},
|
||||
{
|
||||
@@ -59,7 +59,7 @@
|
||||
{
|
||||
"index": 8,
|
||||
"speaker": "Trader Romano",
|
||||
"type": "narration",
|
||||
"type": "dialogue",
|
||||
"text": "Bonjour à tous ! Je suis Romano. Je fais commerce de ferraille avec le Deal Hunter, et j'offre toujours le meilleur prix. Viens à la station si tu souhaites échanger."
|
||||
},
|
||||
{
|
||||
@@ -77,7 +77,7 @@
|
||||
{
|
||||
"index": 11,
|
||||
"speaker": "Deal Hunter",
|
||||
"type": "narration",
|
||||
"type": "dialogue",
|
||||
"text": "Merci d'avoir écouté !"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
{
|
||||
"index": 1,
|
||||
"speaker": "Kainda",
|
||||
"type": "narration",
|
||||
"type": "dialogue",
|
||||
"text": "Ciao! Sono la principessa Kainda, la protagonista della storia. La mia avventura inizierà tra poco."
|
||||
},
|
||||
{
|
||||
@@ -29,7 +29,7 @@
|
||||
{
|
||||
"index": 3,
|
||||
"speaker": "Duchess Victoria",
|
||||
"type": "narration",
|
||||
"type": "dialogue",
|
||||
"text": "Ciao! Sono la duchessa Victoria. Sono anche la zia di Kainda. Non vedo l'ora di condividere questa avventura con te."
|
||||
},
|
||||
{
|
||||
@@ -41,13 +41,13 @@
|
||||
{
|
||||
"index": 5,
|
||||
"speaker": "Deal Hunter",
|
||||
"type": "narration",
|
||||
"type": "dialogue",
|
||||
"text": "Ciao! Sono l'intelligenza artificiale che governa la nave. Di solito mi chiamano solo Hunter. Non vedo l'ora di intrattenerti con questa storia. Ecco uno dei miei robot, che Kainda ha deciso di chiamare Nanny. Saluta, Nanny."
|
||||
},
|
||||
{
|
||||
"index": 6,
|
||||
"speaker": "Nanny",
|
||||
"type": "narration",
|
||||
"type": "dialogue",
|
||||
"text": "Ciao! Kainda mi ha chiamata Nanny perché è più semplice da usare della mia designazione di robot. Farò il mio meglio per essere utile."
|
||||
},
|
||||
{
|
||||
@@ -59,7 +59,7 @@
|
||||
{
|
||||
"index": 8,
|
||||
"speaker": "Trader Romano",
|
||||
"type": "narration",
|
||||
"type": "dialogue",
|
||||
"text": "Ciao a tutti! Sono Romano. Compro rottami dal Deal Hunter e offro sempre il prezzo migliore. Vieni alla stazione se vuoi scambiare."
|
||||
},
|
||||
{
|
||||
@@ -77,7 +77,7 @@
|
||||
{
|
||||
"index": 11,
|
||||
"speaker": "Deal Hunter",
|
||||
"type": "narration",
|
||||
"type": "dialogue",
|
||||
"text": "Grazie per aver ascoltato!"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
{
|
||||
"index": 1,
|
||||
"speaker": "Kainda",
|
||||
"type": "narration",
|
||||
"type": "dialogue",
|
||||
"text": "こんにちは!物語の主人公、プリンセス・カインダよ。私の冒険はもうすぐ始まるよ。"
|
||||
},
|
||||
{
|
||||
@@ -29,7 +29,7 @@
|
||||
{
|
||||
"index": 3,
|
||||
"speaker": "Duchess Victoria",
|
||||
"type": "narration",
|
||||
"type": "dialogue",
|
||||
"text": "こんにちは!ヴィクトリア公妃よ。カインダのおばでもあるの。この冒険をあなたと一緒にできることを楽しみにしてるよ。"
|
||||
},
|
||||
{
|
||||
@@ -41,13 +41,13 @@
|
||||
{
|
||||
"index": 5,
|
||||
"speaker": "Deal Hunter",
|
||||
"type": "narration",
|
||||
"type": "dialogue",
|
||||
"text": "こんにちは!この船を操っている人工知能よ。通常は「ハンター」と呼ばれてる。この物語でお楽しみいただくのを楽しみにしてるよ。カインダが「ナニー」と名づけた、私のロボットの一人がいます。ナニー、あいさつをお願いね。"
|
||||
},
|
||||
{
|
||||
"index": 6,
|
||||
"speaker": "Nanny",
|
||||
"type": "narration",
|
||||
"type": "dialogue",
|
||||
"text": "こんにちは!カインダは、私のロボットの型番より呼びやすいって言って「ナニー」と名づけてくれたよ。できるだけ手助けするね。"
|
||||
},
|
||||
{
|
||||
@@ -59,7 +59,7 @@
|
||||
{
|
||||
"index": 8,
|
||||
"speaker": "Trader Romano",
|
||||
"type": "narration",
|
||||
"type": "dialogue",
|
||||
"text": "こんにちは、みなさん!ロマーノよ。Deal Hunter と廃品回収の取引をしていて、いつも最高の値で買い取るよ。取引したかったらステーションに来てね。"
|
||||
},
|
||||
{
|
||||
@@ -77,7 +77,7 @@
|
||||
{
|
||||
"index": 11,
|
||||
"speaker": "Deal Hunter",
|
||||
"type": "narration",
|
||||
"type": "dialogue",
|
||||
"text": "最後まで聞いてくれて、ありがとう!"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
{
|
||||
"index": 1,
|
||||
"speaker": "Kainda",
|
||||
"type": "narration",
|
||||
"type": "dialogue",
|
||||
"text": "안녕하세요! 저는 이야기의 주인공 케이이다예요. 제 모험은 곧 시작돼요."
|
||||
},
|
||||
{
|
||||
@@ -29,7 +29,7 @@
|
||||
{
|
||||
"index": 3,
|
||||
"speaker": "Duchess Victoria",
|
||||
"type": "narration",
|
||||
"type": "dialogue",
|
||||
"text": "안녕하세요! 저는 빅토리아 공작이에요. 케이이다의 이모이기도 해요. 이 모험을 여러분이랑 함께하게 돼서 정말 기뻐요!"
|
||||
},
|
||||
{
|
||||
@@ -41,13 +41,13 @@
|
||||
{
|
||||
"index": 5,
|
||||
"speaker": "Deal Hunter",
|
||||
"type": "narration",
|
||||
"type": "dialogue",
|
||||
"text": "안녕하세요! 저는 이 함선을 운항하는 인공지능이에요. 보통은 그냥 헌터라고 불러요. 이 이야기로 여러분 즐겁게 해 드리는 게 기다려져요. 케이이다가 나니라고 이름 붙여 준 제 로봇 중 한 대가 있어요. 나니, 인사해 줘요!"
|
||||
},
|
||||
{
|
||||
"index": 6,
|
||||
"speaker": "Nanny",
|
||||
"type": "narration",
|
||||
"type": "dialogue",
|
||||
"text": "안녕하세요! 케이이다가 제 로봇 번호보다 쓰기 편하라고 저를 나니라고 불렀어요. 최대한 도와드릴게요!"
|
||||
},
|
||||
{
|
||||
@@ -59,7 +59,7 @@
|
||||
{
|
||||
"index": 8,
|
||||
"speaker": "Trader Romano",
|
||||
"type": "narration",
|
||||
"type": "dialogue",
|
||||
"text": "여러분, 안녕하세요! 저는 로마노예요. 딜 헌터하고 폐기물 거래를 하면서 항상 제일 좋은 가격에 사드려요. 거래하고 싶으시면 항대로 오세요!"
|
||||
},
|
||||
{
|
||||
@@ -77,7 +77,7 @@
|
||||
{
|
||||
"index": 11,
|
||||
"speaker": "Deal Hunter",
|
||||
"type": "narration",
|
||||
"type": "dialogue",
|
||||
"text": "들어줘서 고마워요!"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
{
|
||||
"index": 1,
|
||||
"speaker": "Kainda",
|
||||
"type": "narration",
|
||||
"type": "dialogue",
|
||||
"text": "Olá! Sou a princesa Kainda, a personagem principal da história. A minha aventura começa em breve."
|
||||
},
|
||||
{
|
||||
@@ -29,7 +29,7 @@
|
||||
{
|
||||
"index": 3,
|
||||
"speaker": "Duchess Victoria",
|
||||
"type": "narration",
|
||||
"type": "dialogue",
|
||||
"text": "Olá! Sou a duquesa Victoria. Sou também tia de Kainda. Mal posso esperar para partilhar esta aventura contigo."
|
||||
},
|
||||
{
|
||||
@@ -41,13 +41,13 @@
|
||||
{
|
||||
"index": 5,
|
||||
"speaker": "Deal Hunter",
|
||||
"type": "narration",
|
||||
"type": "dialogue",
|
||||
"text": "Olá! Sou a inteligência artificial que controla a nave. Normalmente chamam-me apenas de Hunter. Estou ansioso para te entreter com esta história. Aqui está um dos meus robôs, que a Kainda decidiu chamar de Nanny. Diz olá, Nanny."
|
||||
},
|
||||
{
|
||||
"index": 6,
|
||||
"speaker": "Nanny",
|
||||
"type": "narration",
|
||||
"type": "dialogue",
|
||||
"text": "Olá! A Kainda chamou-me de Nanny porque é mais fácil de usar do que a minha designação de robô. Vou ser tão prestável quanto puder."
|
||||
},
|
||||
{
|
||||
@@ -59,7 +59,7 @@
|
||||
{
|
||||
"index": 8,
|
||||
"speaker": "Trader Romano",
|
||||
"type": "narration",
|
||||
"type": "dialogue",
|
||||
"text": "Olá a todos! Sou o Romano. Compro sucata ao Deal Hunter e sempre ofereço o melhor preço. Vem à estação se quiseres negociar."
|
||||
},
|
||||
{
|
||||
@@ -77,7 +77,7 @@
|
||||
{
|
||||
"index": 11,
|
||||
"speaker": "Deal Hunter",
|
||||
"type": "narration",
|
||||
"type": "dialogue",
|
||||
"text": "Obrigado por ouvires!"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
{
|
||||
"index": 1,
|
||||
"speaker": "Kainda",
|
||||
"type": "narration",
|
||||
"type": "dialogue",
|
||||
"text": "Здравствуйте! Я принцесса Кайнда, главный герой этой истории. Моё приключение начнётся совсем скоро."
|
||||
},
|
||||
{
|
||||
@@ -29,7 +29,7 @@
|
||||
{
|
||||
"index": 3,
|
||||
"speaker": "Duchess Victoria",
|
||||
"type": "narration",
|
||||
"type": "dialogue",
|
||||
"text": "Здравствуйте! Я герцогиня Виктория. Я также тётя Кайнды. Мне не терпится разделить это приключение с вами!"
|
||||
},
|
||||
{
|
||||
@@ -41,13 +41,13 @@
|
||||
{
|
||||
"index": 5,
|
||||
"speaker": "Deal Hunter",
|
||||
"type": "narration",
|
||||
"type": "dialogue",
|
||||
"text": "Здравствуйте! Я искусственный интеллект, управляющий кораблём. Обычно меня называют просто Хантер. Я с нетерпением жду возможности рассказать вам эту историю. Вот один из моих роботов, которому Кайнда дала имя Нэнси. Поздоровайтесь, Нэнси!"
|
||||
},
|
||||
{
|
||||
"index": 6,
|
||||
"speaker": "Nanny",
|
||||
"type": "narration",
|
||||
"type": "dialogue",
|
||||
"text": "Здравствуйте! Кайнда назвала меня Нэнси — это произносится куда проще, чем моё обозначение робота. Постараюсь быть максимально полезной."
|
||||
},
|
||||
{
|
||||
@@ -59,7 +59,7 @@
|
||||
{
|
||||
"index": 8,
|
||||
"speaker": "Trader Romano",
|
||||
"type": "narration",
|
||||
"type": "dialogue",
|
||||
"text": "Здравствуйте, все! Я Романо. Скупаю лом у Deal Hunter и всегда даю лучшую цену. Заходите на станцию, если захотите торговать."
|
||||
},
|
||||
{
|
||||
@@ -77,7 +77,7 @@
|
||||
{
|
||||
"index": 11,
|
||||
"speaker": "Deal Hunter",
|
||||
"type": "narration",
|
||||
"type": "dialogue",
|
||||
"text": "Спасибо, что слушали!"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
{
|
||||
"index": 1,
|
||||
"speaker": "Kainda",
|
||||
"type": "narration",
|
||||
"type": "dialogue",
|
||||
"text": "大家好!我是凯因达公主,本故事的主人公。我的冒险很快就要开始了。"
|
||||
},
|
||||
{
|
||||
@@ -29,7 +29,7 @@
|
||||
{
|
||||
"index": 3,
|
||||
"speaker": "Duchess Victoria",
|
||||
"type": "narration",
|
||||
"type": "dialogue",
|
||||
"text": "大家好!我是维多利亚女爵,也是凯因达的姑母。我非常期待和你一起分享这段冒险!"
|
||||
},
|
||||
{
|
||||
@@ -41,13 +41,13 @@
|
||||
{
|
||||
"index": 5,
|
||||
"speaker": "Deal Hunter",
|
||||
"type": "narration",
|
||||
"type": "dialogue",
|
||||
"text": "大家好!我是操控这艘飞船的人工智能。平常大家都叫我「Hunter」。我非常期待用这个故事为你带来乐趣。我的一位机器人同伴——凯因达给她起名叫「南妮」。南妮,打个招呼吧!"
|
||||
},
|
||||
{
|
||||
"index": 6,
|
||||
"speaker": "Nanny",
|
||||
"type": "narration",
|
||||
"type": "dialogue",
|
||||
"text": "大家好!凯因达叫我「南妮」,因为这比我的机器人编号好叫。我会尽力帮上忙!"
|
||||
},
|
||||
{
|
||||
@@ -59,7 +59,7 @@
|
||||
{
|
||||
"index": 8,
|
||||
"speaker": "Trader Romano",
|
||||
"type": "narration",
|
||||
"type": "dialogue",
|
||||
"text": "大家好,我是罗曼诺!我跟 Deal Hunter 做回收物交易,收购价格一向最优。欢迎到空间站来找我交易!"
|
||||
},
|
||||
{
|
||||
@@ -77,7 +77,7 @@
|
||||
{
|
||||
"index": 11,
|
||||
"speaker": "Deal Hunter",
|
||||
"type": "narration",
|
||||
"type": "dialogue",
|
||||
"text": "感谢收听!"
|
||||
}
|
||||
]
|
||||
|
||||
139
samples/translations/speakers.json
Normal file
139
samples/translations/speakers.json
Normal file
@@ -0,0 +1,139 @@
|
||||
{
|
||||
"speakers": [
|
||||
{
|
||||
"name": "Kainda Walters Foster Murphy",
|
||||
"type": "narration",
|
||||
"voice_ref": "kainda_walters_foster_murphy_ref.wav"
|
||||
},
|
||||
{
|
||||
"name": "narrator",
|
||||
"type": "narration",
|
||||
"voice_ref": "narrator_ref.wav"
|
||||
},
|
||||
{
|
||||
"name": "Deal Hunter",
|
||||
"type": "narration",
|
||||
"voice_ref": "deal_hunter_ref.wav"
|
||||
},
|
||||
{
|
||||
"name": "Nanny",
|
||||
"type": "narration",
|
||||
"voice_ref": "nanny_ref.wav"
|
||||
},
|
||||
{
|
||||
"name": "Princess Kainda",
|
||||
"type": "dialogue",
|
||||
"voice_ref": "princess_kainda_ref.wav"
|
||||
},
|
||||
{
|
||||
"name": "Duchess Victoria",
|
||||
"type": "narration",
|
||||
"voice_ref": "duchess_victoria_ref.wav"
|
||||
},
|
||||
{
|
||||
"name": "Adric",
|
||||
"type": "dialogue",
|
||||
"voice_ref": "adric_ref.wav"
|
||||
},
|
||||
{
|
||||
"name": "Kabir",
|
||||
"type": "dialogue",
|
||||
"voice_ref": "kabir_ref.wav"
|
||||
},
|
||||
{
|
||||
"name": "Adonai Maul",
|
||||
"type": "dialogue",
|
||||
"voice_ref": "adonai_maul_ref.wav"
|
||||
},
|
||||
{
|
||||
"name": "Xu Ming",
|
||||
"type": "dialogue",
|
||||
"voice_ref": "xu_ming_ref.wav"
|
||||
},
|
||||
{
|
||||
"name": "Willamina Wake",
|
||||
"type": "dialogue",
|
||||
"voice_ref": "willamina_wake_ref.wav"
|
||||
},
|
||||
{
|
||||
"name": "Distorted Voice",
|
||||
"type": "dialogue",
|
||||
"voice_ref": "distorted_voice_ref.wav"
|
||||
},
|
||||
{
|
||||
"name": "Worker Robot (Bill)",
|
||||
"type": "dialogue",
|
||||
"voice_ref": "worker_robot_(bill)_ref.wav"
|
||||
},
|
||||
{
|
||||
"name": "Captain Weston",
|
||||
"type": "dialogue",
|
||||
"voice_ref": "captain_weston_ref.wav"
|
||||
},
|
||||
{
|
||||
"name": "Security Administrator",
|
||||
"type": "dialogue",
|
||||
"voice_ref": "security_administrator_ref.wav"
|
||||
},
|
||||
{
|
||||
"name": "Pirate Clan Leader",
|
||||
"type": "dialogue",
|
||||
"voice_ref": "pirate_clan_leader_ref.wav"
|
||||
},
|
||||
{
|
||||
"name": "Narrator",
|
||||
"type": "dialogue",
|
||||
"voice_ref": "narrator_ref.wav"
|
||||
},
|
||||
{
|
||||
"name": "Trader Wakam",
|
||||
"type": "dialogue",
|
||||
"voice_ref": "trader_wakam_ref.wav"
|
||||
},
|
||||
{
|
||||
"name": "Service Robot",
|
||||
"type": "dialogue",
|
||||
"voice_ref": "service_robot_ref.wav"
|
||||
},
|
||||
{
|
||||
"name": "Admiral Wilbur Wake",
|
||||
"type": "dialogue",
|
||||
"voice_ref": "admiral_wilbur_wake_ref.wav"
|
||||
},
|
||||
{
|
||||
"name": "Pilot (Freighter)",
|
||||
"type": "dialogue",
|
||||
"voice_ref": "pilot_(freighter)_ref.wav"
|
||||
},
|
||||
{
|
||||
"name": "Trader Romano",
|
||||
"type": "narration",
|
||||
"voice_ref": "trader_romano_ref.wav"
|
||||
},
|
||||
{
|
||||
"name": "Frigate Captain",
|
||||
"type": "dialogue",
|
||||
"voice_ref": "frigate_captain_ref.wav"
|
||||
},
|
||||
{
|
||||
"name": "Crew Member",
|
||||
"type": "dialogue",
|
||||
"voice_ref": "crew_member_ref.wav"
|
||||
},
|
||||
{
|
||||
"name": "Brutus",
|
||||
"type": "dialogue",
|
||||
"voice_ref": "brutus_ref.wav"
|
||||
},
|
||||
{
|
||||
"name": "Guard Detail Speaker 1",
|
||||
"type": "dialogue",
|
||||
"voice_ref": "guard_detail_speaker_1_ref.wav"
|
||||
},
|
||||
{
|
||||
"name": "Guard Detail Speaker 2",
|
||||
"type": "dialogue",
|
||||
"voice_ref": "guard_detail_speaker_2_ref.wav"
|
||||
}
|
||||
]
|
||||
}
|
||||
1047
scripts/audio_controller_connect.py
Normal file
1047
scripts/audio_controller_connect.py
Normal file
File diff suppressed because it is too large
Load Diff
267
scripts/audit_controller_bundle.py
Normal file
267
scripts/audit_controller_bundle.py
Normal file
@@ -0,0 +1,267 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Audit a built bundle against the project it claims to describe.
|
||||
|
||||
The controller's validator checks that the bundle is internally consistent: a
|
||||
required field is present, an id resolves, no credential is present. It cannot
|
||||
check that the bundle is *true*. Every hash, byte size and duration in here is
|
||||
something the audio system asserted about itself, and this is the file that goes
|
||||
and checks those assertions against the filesystem.
|
||||
|
||||
That distinction is not pedantic. The failure this project has actually hit is a
|
||||
record that says one thing and means another -- an approved chapter whose
|
||||
published MP3 is four seconds longer than the approved WAV because padding was
|
||||
added afterwards. A validator passes that bundle happily. Only re-measuring
|
||||
finds it.
|
||||
|
||||
python3 scripts/audit_controller_bundle.py --bundle /tmp/dh-bundle
|
||||
|
||||
Exits 0 when every checked claim holds, 1 when any does not. Prints one line per
|
||||
problem and a summary of how many claims were checked.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import sqlite3
|
||||
import wave
|
||||
from collections import Counter, defaultdict
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
def load(path: Path) -> Any:
|
||||
return json.loads(path.read_text())
|
||||
|
||||
|
||||
def sha256_file(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1 << 20), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
parser.add_argument("--bundle", required=True, help="bundle directory")
|
||||
parser.add_argument("--project", required=True, help="project directory name")
|
||||
parser.add_argument("--hash-renders", action="store_true", default=True,
|
||||
help="re-hash every render file (slower, thorough)")
|
||||
parser.add_argument("--no-hash-renders", dest="hash_renders", action="store_false")
|
||||
args = parser.parse_args()
|
||||
|
||||
bundle = Path(args.bundle)
|
||||
repo = Path(__file__).resolve().parent.parent
|
||||
book_dir = repo / args.project
|
||||
umbra = book_dir / ".umbra"
|
||||
|
||||
index = load(bundle / "bundle.json")
|
||||
raw = (bundle / "manifest.jsonl").read_text().splitlines()
|
||||
records = [json.loads(line) for line in raw if line.strip()]
|
||||
by_id = {r["id"]: r for r in records}
|
||||
kinds = Counter(r["kind"] for r in records)
|
||||
|
||||
problems: list[str] = []
|
||||
checked = 0
|
||||
|
||||
def check(condition: bool, message: str) -> None:
|
||||
nonlocal checked
|
||||
checked += 1
|
||||
if not condition:
|
||||
problems.append(message)
|
||||
|
||||
# 1. The index's own content hash must describe the file it ships with.
|
||||
manifest_bytes = (bundle / "manifest.jsonl").read_bytes()
|
||||
actual = hashlib.sha256(manifest_bytes).hexdigest()
|
||||
check(actual == index["content_hash"]["value"],
|
||||
f"bundle.json content_hash {index['content_hash']['value'][:16]}... "
|
||||
f"does not match manifest.jsonl ({actual[:16]}...)")
|
||||
check(index["counts"]["records_total"] == len(records),
|
||||
f"bundle.json claims {index['counts']['records_total']} records, file has {len(records)}")
|
||||
|
||||
# 2. Every utterance's text hash must match the text it ships with.
|
||||
for record in records:
|
||||
if record["kind"] == "utterance":
|
||||
digest = hashlib.sha256(record["text"].encode("utf-8")).hexdigest()
|
||||
check(digest == record["text_sha256"],
|
||||
f"utterance {record['id']}: text_sha256 does not match its own text")
|
||||
check(len(record["text"]) == record["text_char_count"],
|
||||
f"utterance {record['id']}: text_char_count disagrees with text length")
|
||||
|
||||
# 3. Every referenced id must resolve, including the ones the controller's
|
||||
# validator does not check (speaker_id, render_id).
|
||||
for record in records:
|
||||
for field in ("book_id", "chapter_id", "speaker_id", "render_id", "voice_id"):
|
||||
value = record.get(field)
|
||||
if isinstance(value, str):
|
||||
check(value in by_id,
|
||||
f"{record['kind']} {record['id']}: {field} -> {value!r} is not in the bundle")
|
||||
for field in ("chapters", "renders", "inputs", "published_artifact_ids"):
|
||||
for value in record.get(field, []) or []:
|
||||
check(value in by_id,
|
||||
f"{record['kind']} {record['id']}: {field} contains "
|
||||
f"{value!r}, not in the bundle")
|
||||
|
||||
# 4. Render paths must exist, be the right size, and (optionally) hash right.
|
||||
renders = [r for r in records if r["kind"] == "render"]
|
||||
missing = unreadable = 0
|
||||
for record in renders:
|
||||
path = book_dir / ".umbra" / record["path"]
|
||||
if not path.exists():
|
||||
if record.get("file_present") is False:
|
||||
continue
|
||||
missing += 1
|
||||
problems.append(f"render {record['id']}: path {record['path']} does not exist")
|
||||
continue
|
||||
checked += 1
|
||||
size = path.stat().st_size
|
||||
check(size == record["byte_size"],
|
||||
f"render {record['id']}: byte_size {record['byte_size']} != actual {size}")
|
||||
if record.get("content_sha256") and args.hash_renders:
|
||||
check(sha256_file(path) == record["content_sha256"],
|
||||
f"render {record['id']}: content_sha256 does not match the file on disk")
|
||||
if record["audio_format"] and record["audio_format"].get("codec") == "unreadable":
|
||||
unreadable += 1
|
||||
|
||||
# 5. Durations must be re-derivable for WAV files, which is the one format
|
||||
# the standard library can measure without trusting the recorder.
|
||||
sampled = 0
|
||||
for record in renders:
|
||||
if record["path"].endswith(".wav") and record.get("duration_ms"):
|
||||
path = book_dir / ".umbra" / record["path"]
|
||||
if not path.exists():
|
||||
continue
|
||||
try:
|
||||
with wave.open(str(path)) as handle:
|
||||
rate = handle.getframerate()
|
||||
if not rate:
|
||||
continue
|
||||
measured = round(handle.getnframes() / rate * 1000)
|
||||
except (OSError, wave.Error, EOFError):
|
||||
continue
|
||||
checked += 1
|
||||
sampled += 1
|
||||
check(measured == record["duration_ms"],
|
||||
f"render {record['id']}: duration_ms "
|
||||
f"{record['duration_ms']} != measured {measured}")
|
||||
|
||||
# 6. The bundle's own counts must match the database it was built from.
|
||||
conn = sqlite3.connect(f"file:{umbra / 'project.db'}?mode=ro", uri=True)
|
||||
db_chapters = conn.execute("SELECT count(*) FROM chapters WHERE project_id=1").fetchone()[0]
|
||||
db_utterances = conn.execute(
|
||||
"SELECT count(*) FROM utterances u JOIN chapters c ON c.id=u.chapter_id "
|
||||
"WHERE c.project_id=1").fetchone()[0]
|
||||
db_names = conn.execute(
|
||||
"SELECT count(DISTINCT trim(speaker_name)) FROM utterances u JOIN chapters c "
|
||||
"ON c.id=u.chapter_id WHERE c.project_id=1 AND speaker_name IS NOT NULL").fetchone()[0]
|
||||
db_null_speakers = conn.execute(
|
||||
"SELECT count(*) FROM utterances u JOIN chapters c ON c.id=u.chapter_id "
|
||||
"WHERE c.project_id=1 AND (u.speaker_name IS NULL OR trim(u.speaker_name)='')"
|
||||
).fetchone()[0]
|
||||
db_speaker_counts = dict(conn.execute(
|
||||
"SELECT trim(u.speaker_name), count(*) FROM utterances u JOIN chapters c "
|
||||
"ON c.id=u.chapter_id WHERE c.project_id=1 AND u.speaker_name IS NOT NULL "
|
||||
"GROUP BY trim(u.speaker_name)").fetchall())
|
||||
check(len([r for r in records if r["kind"] == "chapter"]) == db_chapters,
|
||||
f"chapter records != database chapters ({db_chapters})")
|
||||
check(len([r for r in records if r["kind"] == "utterance"]) == db_utterances,
|
||||
f"utterance records != database utterances ({db_utterances})")
|
||||
check(len([r for r in records if r["kind"] == "speaker"]) == db_names,
|
||||
f"speaker records != distinct speaker names in the database ({db_names})")
|
||||
conn.close()
|
||||
|
||||
# 7. Speaker utterance counts must be true, because they are the number a
|
||||
# mapping is most likely to trust. `utterance_count` is the exact count
|
||||
# for that exact spelling; the implicit-narrator figure is checked
|
||||
# separately because it is a deliberate inference, not a stored value.
|
||||
for record in records:
|
||||
if record["kind"] != "speaker":
|
||||
continue
|
||||
actual_count = db_speaker_counts.get(record["primary_name"])
|
||||
check(actual_count == record["utterance_count"],
|
||||
f"speaker {record['id']}: utterance_count {record['utterance_count']} "
|
||||
f"!= database {actual_count}")
|
||||
expected_implicit = record["utterance_count"] + (
|
||||
db_null_speakers if record["primary_name"].casefold() == "narrator" else 0
|
||||
)
|
||||
check(expected_implicit == record["utterance_count_including_implicit_narrator"],
|
||||
f"speaker {record['id']}: implicit-narrator count "
|
||||
f"{record['utterance_count_including_implicit_narrator']} "
|
||||
f"!= expected {expected_implicit}")
|
||||
|
||||
# 7b. Every utterance's speaker must resolve, and every implicit-narrator
|
||||
# row must actually have had a null speaker name. This is the assertion
|
||||
# that would catch the bundle quietly inventing a narrator.
|
||||
null_rows = 0
|
||||
for record in records:
|
||||
if record["kind"] != "utterance":
|
||||
continue
|
||||
implicit = record["speaker_resolution"] == "implicit_narrator"
|
||||
if implicit:
|
||||
null_rows += 1
|
||||
check(record["speaker_name_raw"] in (None, ""),
|
||||
f"utterance {record['id']}: marked implicit_narrator but "
|
||||
f"speaker_name_raw is {record['speaker_name_raw']!r}")
|
||||
else:
|
||||
check(record["speaker_name_raw"] not in (None, ""),
|
||||
f"utterance {record['id']}: marked explicit but speaker_name_raw is null")
|
||||
check(record["speaker_id"] is not None,
|
||||
f"utterance {record['id']}: speaker did not resolve")
|
||||
check(null_rows == db_null_speakers,
|
||||
f"{null_rows} utterances marked implicit_narrator != {db_null_speakers} "
|
||||
"rows with an empty speaker_name")
|
||||
|
||||
# 8. Every chapter's utterance_count must be true.
|
||||
db_per_chapter = {}
|
||||
conn = sqlite3.connect(f"file:{umbra / 'project.db'}?mode=ro", uri=True)
|
||||
for row_id, count in conn.execute(
|
||||
"SELECT u.chapter_id, count(*) FROM utterances u JOIN chapters c "
|
||||
"ON c.id=u.chapter_id WHERE c.project_id=1 GROUP BY u.chapter_id").fetchall():
|
||||
db_per_chapter[f"ch-{row_id}"] = count
|
||||
conn.close()
|
||||
for record in records:
|
||||
if record["kind"] != "chapter":
|
||||
continue
|
||||
check(db_per_chapter.get(record["id"], 0) == record["utterance_count"],
|
||||
f"chapter {record['id']}: utterance_count {record['utterance_count']} "
|
||||
f"!= database {db_per_chapter.get(record['id'], 0)}")
|
||||
|
||||
# 9. Casefold collisions must be recorded, not silently merged.
|
||||
collisions: dict[str, list[str]] = defaultdict(list)
|
||||
for record in records:
|
||||
if record["kind"] == "speaker":
|
||||
collisions[record["primary_name"].casefold()].append(record["primary_name"])
|
||||
real_collisions = {k: v for k, v in collisions.items() if len(v) > 1}
|
||||
for key, names in real_collisions.items():
|
||||
declared = [r for r in records if r["kind"] == "speaker"
|
||||
and r.get("casefold_collision_group") == key]
|
||||
check(len(declared) == len(names),
|
||||
f"speaker name collision {key!r} ({names}) is not declared in the bundle")
|
||||
|
||||
print(f"records by kind: {dict(kinds)}")
|
||||
print(f"claims checked: {checked}")
|
||||
print(f"renders: {len(renders)} "
|
||||
f"({missing} missing, {unreadable} unreadable, {sampled} durations re-measured)")
|
||||
print(f"speaker name collisions declared: "
|
||||
f"{ {k: sorted(v) for k, v in real_collisions.items()} }")
|
||||
print(f"implicit-narrator utterances: {null_rows} (database agrees: "
|
||||
f"{null_rows == db_null_speakers})")
|
||||
|
||||
if problems:
|
||||
print(f"\n{len(problems)} problem(s):\n")
|
||||
for problem in problems[:60]:
|
||||
print(f" - {problem}")
|
||||
if len(problems) > 60:
|
||||
print(f" ... and {len(problems) - 60} more")
|
||||
return 1
|
||||
|
||||
print("\nEvery checked claim holds: hashes, byte sizes, durations, counts and "
|
||||
"references agree with the project on disk.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
137
scripts/backup_system.sh
Executable file
137
scripts/backup_system.sh
Executable file
@@ -0,0 +1,137 @@
|
||||
#!/bin/zsh
|
||||
# Copyright 2026 Umbra Tools (umbratools.com)
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
# backup_system.sh - Complete UmbraAudio system backup
|
||||
|
||||
set -e
|
||||
|
||||
SOURCE_ROOT="/Volumes/1TB_APL_Storage/UmbraAudio"
|
||||
BACKUP_DIR="/tmp/backups"
|
||||
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
||||
|
||||
# Parse arguments
|
||||
MODE="full"
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--full) MODE="full"; shift ;;
|
||||
--db) MODE="db"; shift ;;
|
||||
--voices) MODE="voices"; shift ;;
|
||||
--config) MODE="config"; shift ;;
|
||||
--help|-h)
|
||||
echo "Usage: $0 --full | --db | --voices | --config"
|
||||
exit 0 ;;
|
||||
*) echo "Unknown option: $1"; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
|
||||
echo "========================================="
|
||||
echo "UmbraAudio System Backup"
|
||||
echo "========================================="
|
||||
echo "Mode: $MODE"
|
||||
echo "Timestamp: $TIMESTAMP"
|
||||
echo "========================================="
|
||||
|
||||
backup_db() {
|
||||
local name="$1"
|
||||
local src="$SOURCE_ROOT/.umbra/$name"
|
||||
if [[ -f "$src" ]]; then
|
||||
cp -a "$src" "$BACKUP_DIR/project_${name}.${TIMESTAMP}"
|
||||
echo " ✓ $name"
|
||||
else
|
||||
echo " ⊘ $name (not found)"
|
||||
fi
|
||||
}
|
||||
|
||||
# Copy directory to backup with parent dir creation
|
||||
backup_dir() {
|
||||
local name="$1"
|
||||
local rel_path="$name" # relative to SOURCE_ROOT
|
||||
local src="$SOURCE_ROOT/$rel_path"
|
||||
if [[ -d "$src" ]]; then
|
||||
# Destination: flat name under BACKUP_DIR
|
||||
local dst="$BACKUP_DIR/${name}_${TIMESTAMP}"
|
||||
# Ensure parent directory exists
|
||||
local dst_parent=$(dirname "$dst")
|
||||
mkdir -p "$dst_parent"
|
||||
cp -a "$src" "$dst"
|
||||
echo " ✓ $name"
|
||||
else
|
||||
echo " ⊘ $name (not found)"
|
||||
fi
|
||||
}
|
||||
|
||||
case "$MODE" in
|
||||
full)
|
||||
echo "Backing up complete system..."
|
||||
echo ""
|
||||
echo "1. Project databases:"
|
||||
backup_db "project.db"
|
||||
backup_db "voice_registry.db"
|
||||
backup_db "umbra.db"
|
||||
echo ""
|
||||
echo "2. Voice references:"
|
||||
backup_dir "umbraaudio/voices"
|
||||
echo ""
|
||||
echo "3. Speaker configurations:"
|
||||
if [[ -f "$SOURCE_ROOT/speakers.json" ]]; then
|
||||
cp -a "$SOURCE_ROOT/speakers.json" "$BACKUP_DIR/speakers.${TIMESTAMP}"
|
||||
echo " ✓ speakers.json"
|
||||
fi
|
||||
echo ""
|
||||
echo "4. Configuration:"
|
||||
if [[ -f "$SOURCE_ROOT/.env" ]]; then
|
||||
cp -a "$SOURCE_ROOT/.env" "$BACKUP_DIR/.env.${TIMESTAMP}"
|
||||
echo " ✓ .env"
|
||||
fi
|
||||
if [[ -f "$SOURCE_ROOT/umbraaudio/config.py" ]]; then
|
||||
cp -a "$SOURCE_ROOT/umbraaudio/config.py" "$BACKUP_DIR/config.${TIMESTAMP}"
|
||||
echo " ✓ config.py"
|
||||
fi
|
||||
echo ""
|
||||
echo "5. Git state:"
|
||||
cd "$SOURCE_ROOT" 2>/dev/null && git status --short > "$BACKUP_DIR/git_status.${TIMESTAMP}" 2>/dev/null && echo " ✓ git_status" || echo " ⊘ git (failed)"
|
||||
echo ""
|
||||
echo "6. Launchd services:"
|
||||
launchctl list > "$BACKUP_DIR/launchd.${TIMESTAMP}" 2>/dev/null && echo " ✓ launchd" || echo " ⊘ launchd (failed)"
|
||||
echo ""
|
||||
echo "7. Audio generation state:"
|
||||
if [[ -d "$SOURCE_ROOT/generation" ]]; then
|
||||
backup_dir "generation"
|
||||
fi
|
||||
echo ""
|
||||
echo "========================================="
|
||||
echo "✅ Full backup complete!"
|
||||
echo "Files saved to: $BACKUP_DIR/"
|
||||
echo "========================================="
|
||||
;;
|
||||
|
||||
db)
|
||||
echo "Backing up databases only..."
|
||||
backup_db "project.db"
|
||||
backup_db "voice_registry.db"
|
||||
backup_db "umbra.db"
|
||||
;;
|
||||
|
||||
voices)
|
||||
echo "Backing up voice references..."
|
||||
backup_dir "umbraaudio/voices"
|
||||
if [[ -f "$SOURCE_ROOT/speakers.json" ]]; then
|
||||
cp -a "$SOURCE_ROOT/speakers.json" "$BACKUP_DIR/speakers.${TIMESTAMP}"
|
||||
fi
|
||||
;;
|
||||
|
||||
config)
|
||||
echo "Backing up configuration only..."
|
||||
if [[ -f "$SOURCE_ROOT/.env" ]]; then
|
||||
cp -a "$SOURCE_ROOT/.env" "$BACKUP_DIR/.env.${TIMESTAMP}"
|
||||
echo " ✓ .env"
|
||||
fi
|
||||
if [[ -f "$SOURCE_ROOT/umbraaudio/config.py" ]]; then
|
||||
cp -a "$SOURCE_ROOT/umbraaudio/config.py" "$BACKUP_DIR/config.${TIMESTAMP}"
|
||||
echo " ✓ config.py"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
1597
scripts/build_controller_bundle.py
Normal file
1597
scripts/build_controller_bundle.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -58,19 +58,23 @@ LANGUAGES = {
|
||||
}
|
||||
|
||||
# (speaker, type) per utterance, matching the source chapter.
|
||||
# (speaker, type). Type must follow the speaker: the Narrator reads the
|
||||
# introduction, everyone else speaks a line of dialogue. These were all
|
||||
# "narration", which made every row render a narration badge and would tell
|
||||
# UmbraTrans that six characters are not speaking at all.
|
||||
SHAPE = [
|
||||
("Narrator", "narration"),
|
||||
("Kainda", "narration"),
|
||||
("Kainda", "dialogue"),
|
||||
("Narrator", "narration"),
|
||||
("Duchess Victoria", "narration"),
|
||||
("Duchess Victoria", "dialogue"),
|
||||
("Narrator", "narration"),
|
||||
("Deal Hunter", "narration"),
|
||||
("Nanny", "narration"),
|
||||
("Deal Hunter", "dialogue"),
|
||||
("Nanny", "dialogue"),
|
||||
("Narrator", "narration"),
|
||||
("Trader Romano", "narration"),
|
||||
("Trader Romano", "dialogue"),
|
||||
("Narrator", "narration"),
|
||||
("Narrator", "narration"),
|
||||
("Deal Hunter", "narration"),
|
||||
("Deal Hunter", "dialogue"),
|
||||
]
|
||||
|
||||
TRANSLATIONS: dict[str, list[str]] = {
|
||||
|
||||
223
scripts/build_scale_totals.py
Normal file
223
scripts/build_scale_totals.py
Normal file
@@ -0,0 +1,223 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Measure totals across every project on this machine.
|
||||
|
||||
Asked for as "books, hours, bytes, so the catalogue is something a person
|
||||
maintains or something that has to be generated". Handing over a number typed
|
||||
into a chat message would be exactly the estimate the request refuses, so this
|
||||
measures instead.
|
||||
|
||||
Two separate quantities, because conflating them is how a catalogue ends up
|
||||
wrong:
|
||||
|
||||
* **Project data** — chapters, utterances, speakers. Cheap, exact, from SQLite.
|
||||
* **Audio on disk** — files, bytes, duration. Expensive to measure exactly, so
|
||||
duration is measured from headers and reported with the caveat that it is a
|
||||
header read and not a decode.
|
||||
|
||||
python3 scripts/build_scale_totals.py --out docs/tts/scale-totals.json
|
||||
|
||||
Read-only. Opens each project database with ``mode=ro`` and walks directories.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import sqlite3
|
||||
import wave
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
|
||||
#: Directories that hold intermediate work rather than product. Counted
|
||||
#: separately, because a catalogue that sweeps scratch into the product is a
|
||||
#: catalogue nobody can answer a question against.
|
||||
SCRATCH_DIRS = ("cache", "archives", "pronunciation_audio", "voice_tests",
|
||||
"samples", "debug", "tmp")
|
||||
|
||||
|
||||
def schema_fingerprint(conn: sqlite3.Connection) -> str:
|
||||
"""Fingerprint of the table/column *shape*, not the data.
|
||||
|
||||
Two projects with different books can share a fingerprint. That is the
|
||||
point: it answers "is this the same schema", which cannot be confused with
|
||||
"is this the same data". Counting one table's presence is a proxy for that
|
||||
and was replaced by the real thing.
|
||||
"""
|
||||
|
||||
parts: list[str] = []
|
||||
tables = [row[0] for row in conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' "
|
||||
"AND name NOT LIKE 'sqlite_%' ORDER BY name")]
|
||||
for table in tables:
|
||||
for row in conn.execute(f"PRAGMA table_info('{table}')"):
|
||||
parts.append(f"{table}.{row[1]}:{row[2]}")
|
||||
return hashlib.sha256("\n".join(sorted(parts)).encode()).hexdigest()[:16]
|
||||
|
||||
|
||||
def measure_project(directory: Path) -> dict[str, Any] | None:
|
||||
umbra = directory / ".umbra"
|
||||
db = umbra / "project.db"
|
||||
if not db.exists() or db.stat().st_size == 0:
|
||||
return None
|
||||
|
||||
data: dict[str, Any] = {"project": directory.name}
|
||||
try:
|
||||
conn = sqlite3.connect(f"file:{db}?mode=ro", uri=True)
|
||||
for label, sql in (
|
||||
("chapters", "SELECT count(*) FROM chapters"),
|
||||
("utterances", "SELECT count(*) FROM utterances"),
|
||||
("speakers_named", "SELECT count(DISTINCT trim(speaker_name)) FROM utterances "
|
||||
"WHERE speaker_name IS NOT NULL"),
|
||||
("words", "SELECT sum(word_count) FROM chapters"),
|
||||
):
|
||||
try:
|
||||
data[label] = conn.execute(sql).fetchone()[0]
|
||||
except sqlite3.Error:
|
||||
data[label] = None
|
||||
data["schema_fingerprint"] = schema_fingerprint(conn)
|
||||
conn.close()
|
||||
except sqlite3.Error as error:
|
||||
data["error"] = str(error)[:160]
|
||||
return data
|
||||
|
||||
# --- audio on disk ------------------------------------------------
|
||||
chapter_audio = umbra / "chapter_audio"
|
||||
audio_files = marker_files = 0
|
||||
audio_bytes = marker_bytes = scratch_bytes = 0
|
||||
scratch = 0
|
||||
duration_ms = 0
|
||||
unreadable = 0
|
||||
if chapter_audio.is_dir():
|
||||
for path in chapter_audio.rglob("*"):
|
||||
if not path.is_file():
|
||||
continue
|
||||
relative = path.relative_to(chapter_audio)
|
||||
if any(part in SCRATCH_DIRS for part in relative.parts):
|
||||
scratch += 1
|
||||
scratch_bytes += path.stat().st_size
|
||||
continue
|
||||
# A render and its staleness marker are different things and are
|
||||
# counted separately: one is audio, the other is a JSON sidecar,
|
||||
# and lumping them makes "files" mean neither.
|
||||
if path.name.endswith(".wav.txt"):
|
||||
marker_files += 1
|
||||
marker_bytes += path.stat().st_size
|
||||
continue
|
||||
audio_files += 1
|
||||
audio_bytes += path.stat().st_size
|
||||
if path.suffix.lower() == ".wav":
|
||||
try:
|
||||
with wave.open(str(path)) as handle:
|
||||
rate = handle.getframerate()
|
||||
if rate:
|
||||
duration_ms += round(handle.getnframes() / rate * 1000)
|
||||
except (OSError, wave.Error, EOFError):
|
||||
unreadable += 1
|
||||
|
||||
data["audio"] = {
|
||||
"audio_files": audio_files,
|
||||
"audio_bytes": audio_bytes,
|
||||
"audio_duration_ms": duration_ms,
|
||||
"audio_duration_hours": round(duration_ms / 3_600_000, 2),
|
||||
"files_with_unreadable_header": unreadable,
|
||||
"marker_files_excluded_from_audio": marker_files,
|
||||
"marker_bytes": marker_bytes,
|
||||
"scratch_files_excluded": scratch,
|
||||
"scratch_bytes_excluded": scratch_bytes,
|
||||
}
|
||||
for label, directory_name in (("voices", "voices"), ("export", "export")):
|
||||
target = umbra / directory_name
|
||||
if target.is_dir():
|
||||
data[label] = {
|
||||
"files": sum(1 for p in target.rglob("*") if p.is_file()),
|
||||
"bytes": sum(p.stat().st_size for p in target.rglob("*") if p.is_file()),
|
||||
}
|
||||
return data
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
parser.add_argument("--root", default=str(REPO))
|
||||
parser.add_argument("--out", default=str(REPO / "docs" / "tts" / "scale-totals.json"))
|
||||
args = parser.parse_args()
|
||||
|
||||
root = Path(args.root)
|
||||
projects = []
|
||||
for directory in sorted(root.iterdir()):
|
||||
if not directory.is_dir() or directory.name.startswith("."):
|
||||
continue
|
||||
measured = measure_project(directory)
|
||||
if measured is not None:
|
||||
projects.append(measured)
|
||||
|
||||
chapters = sum(p.get("chapters") or 0 for p in projects)
|
||||
utterances = sum(p.get("utterances") or 0 for p in projects)
|
||||
files = sum((p.get("audio") or {}).get("audio_files") or 0 for p in projects)
|
||||
total_bytes = sum((p.get("audio") or {}).get("audio_bytes") or 0 for p in projects)
|
||||
duration = sum((p.get("audio") or {}).get("audio_duration_ms") or 0 for p in projects)
|
||||
markers = sum((p.get("audio") or {}).get("marker_files_excluded_from_audio") or 0
|
||||
for p in projects)
|
||||
|
||||
document: dict[str, Any] = {
|
||||
"document_version": 1,
|
||||
"generated_at": datetime.now(UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z"),
|
||||
"generated_by": "scripts/build_scale_totals.py",
|
||||
"root": "<REPO>",
|
||||
"projects": projects,
|
||||
"totals": {
|
||||
"projects": len(projects),
|
||||
"chapters": chapters,
|
||||
"utterances": utterances,
|
||||
"audio_files": files,
|
||||
"audio_bytes": total_bytes,
|
||||
"audio_gigabytes": round(total_bytes / 1e9, 2),
|
||||
"audio_duration_hours": round(duration / 3_600_000, 2),
|
||||
"marker_files": markers,
|
||||
"distinct_schema_fingerprints": len(
|
||||
{p.get("schema_fingerprint") for p in projects if p.get("schema_fingerprint")}),
|
||||
},
|
||||
"caveats": [
|
||||
"Duration is read from WAV headers, not decoded. It is exact for "
|
||||
"PCM WAV and absent for anything else, which is why the file count "
|
||||
"and the duration are reported together rather than one from the other.",
|
||||
"Audio totals cover chapter_audio only, and exclude the *.wav.txt "
|
||||
"staleness markers, which are counted separately. Voice references "
|
||||
"and exports are counted separately per project.",
|
||||
"Scratch and intermediate output is excluded and counted "
|
||||
"separately, not silently dropped.",
|
||||
"Projects without a populated database are not listed. Absence here "
|
||||
"means 'not measured', not 'does not exist'.",
|
||||
],
|
||||
}
|
||||
hashed = {k: v for k, v in document.items() if k != "generated_at"}
|
||||
payload = json.dumps(hashed, sort_keys=True, separators=(",", ":"))
|
||||
document["content_hash"] = {
|
||||
"algorithm": "sha256",
|
||||
"of": "this document with generated_at omitted, canonical JSON",
|
||||
"value": hashlib.sha256(payload.encode()).hexdigest(),
|
||||
}
|
||||
|
||||
out = Path(args.out)
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
out.write_text(json.dumps(document, indent=2, sort_keys=True) + "\n")
|
||||
|
||||
totals = document["totals"]
|
||||
print(f"projects: {totals['projects']} chapters: {totals['chapters']} "
|
||||
f"utterances: {totals['utterances']}")
|
||||
print(f"audio: {totals['audio_files']} files, "
|
||||
f"{totals['audio_gigabytes']} GB, "
|
||||
f"{totals['audio_duration_hours']} h "
|
||||
f"(+{totals['marker_files']} markers, "
|
||||
f"{totals['distinct_schema_fingerprints']} schema shapes)")
|
||||
print(f"hash: {document['content_hash']['value']}")
|
||||
print(f"written: {out}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
749
scripts/build_tts_facts.py
Normal file
749
scripts/build_tts_facts.py
Normal file
@@ -0,0 +1,749 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Measure the TTS engine and emit the checked document the controller asked for.
|
||||
|
||||
The controller will not write a ``speak`` work function against a description.
|
||||
Every value here is measured from the running system or read from the repository
|
||||
at run time, and anything that cannot be measured is recorded as ``unknown`` with
|
||||
a reason rather than estimated. A validator nobody has seen fail is not evidence
|
||||
of anything; the same rule applies to a document nobody has checked.
|
||||
|
||||
python3 scripts/build_tts_facts.py --out-dir docs/tts
|
||||
|
||||
Emits ``tts-engine.json`` (measured, with a content hash) and ``tts-engine.md``
|
||||
(the same values in prose, with evidence commands). Read-only: it probes the
|
||||
running TTS server's ``/health`` and reads files. It never generates audio and
|
||||
never writes to the model directory.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import subprocess
|
||||
import urllib.request
|
||||
import wave
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
TTS_URL = "http://localhost:8881"
|
||||
|
||||
|
||||
def sha256_file(path: Path) -> str | None:
|
||||
try:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1 << 20), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def git(*args: str, cwd: Path) -> str | None:
|
||||
try:
|
||||
out = subprocess.run(["git", "-C", str(cwd), *args],
|
||||
capture_output=True, text=True, timeout=60, check=False)
|
||||
return out.stdout.strip() or None
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return None
|
||||
|
||||
|
||||
def probe_json(url: str) -> Any:
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=8) as response:
|
||||
return json.loads(response.read())
|
||||
except Exception as error: # noqa: BLE001 - reachability is a result
|
||||
return {"error": {"code": type(error).__name__, "message": str(error)[:160]}}
|
||||
|
||||
|
||||
def ffprobe(path: Path) -> dict[str, Any]:
|
||||
try:
|
||||
out = subprocess.run(
|
||||
["ffprobe", "-v", "error", "-select_streams", "a:0",
|
||||
"-show_entries", "stream=codec_name,sample_rate,channels,bit_rate",
|
||||
"-show_entries", "format=duration,bit_rate,format_name",
|
||||
"-of", "json", str(path)],
|
||||
capture_output=True, text=True, timeout=60, check=False)
|
||||
data = json.loads(out.stdout or "{}")
|
||||
stream = (data.get("streams") or [{}])[0]
|
||||
fmt = data.get("format") or {}
|
||||
return {"codec": stream.get("codec_name"), "sample_rate_hz": stream.get("sample_rate"),
|
||||
"channels": stream.get("channels"), "bit_rate_bps": stream.get("bit_rate"),
|
||||
"container": fmt.get("format_name"), "duration_s": fmt.get("duration")}
|
||||
except (OSError, ValueError, subprocess.SubprocessError) as error:
|
||||
return {"error": type(error).__name__}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 1. provenance
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def provenance() -> dict[str, Any]:
|
||||
repo = REPO / "Qwen3-TTS"
|
||||
if not (repo / ".git").exists():
|
||||
return {"status": "unknown", "why": "Qwen3-TTS submodule not present"}
|
||||
|
||||
head = git("rev-parse", "HEAD", cwd=repo)
|
||||
base = git("merge-base", "HEAD", "origin/main", cwd=repo)
|
||||
ahead = (git("log", "--oneline", "origin/main..HEAD", cwd=repo) or "").splitlines()
|
||||
behind = (git("log", "--oneline", "HEAD..origin/main", cwd=repo) or "").splitlines()
|
||||
changed = git("diff", "--stat", f"{base}..HEAD", cwd=repo) if base else None
|
||||
license_head = ""
|
||||
lic = repo / "LICENSE"
|
||||
if lic.exists():
|
||||
license_head = " ".join(lic.read_text(errors="ignore").split()[:6])
|
||||
|
||||
return {
|
||||
"upstream_repository": "https://github.com/QwenLM/Qwen3-TTS.git",
|
||||
"local_head": head,
|
||||
"upstream_base_commit": base,
|
||||
"commits_ahead_of_upstream": len(ahead),
|
||||
"commits_ahead_detail": ahead,
|
||||
"commits_behind_upstream": len(behind),
|
||||
"diff_stat_vs_base": changed,
|
||||
"license": license_head or "unknown",
|
||||
"note": (
|
||||
"The fork delta touches only the web demo and a logo. It does not "
|
||||
"touch the synthesis path, so it does not alter the input or output "
|
||||
"contract. The contract that IS ours is umbraaudio's own server "
|
||||
"script, which is not part of this repository - see runtime_surface."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 2. the model
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def models() -> dict[str, Any]:
|
||||
root = REPO / "models"
|
||||
if not root.is_dir():
|
||||
return {"status": "unknown", "why": f"no model directory at {root}"}
|
||||
found = []
|
||||
for directory in sorted(root.glob("Qwen3-TTS-*")):
|
||||
weights = directory / "model.safetensors"
|
||||
files = sorted(p.name for p in directory.glob("*") if p.is_file())
|
||||
found.append({
|
||||
"model_id": directory.name,
|
||||
"weights_file": weights.name if weights.exists() else None,
|
||||
"weights_state": "present" if weights.exists() else "absent",
|
||||
"weights_bytes": weights.stat().st_size if weights.exists() else None,
|
||||
"weights_sha256": sha256_file(weights) if weights.exists() else None,
|
||||
"directory_bytes": sum(p.stat().st_size for p in directory.rglob("*") if p.is_file()),
|
||||
"config_present": "config.json" in files,
|
||||
})
|
||||
without_weights = [m["model_id"] for m in found if m["weights_state"] == "absent"]
|
||||
return {
|
||||
"models_present": found,
|
||||
"models_without_weights": without_weights,
|
||||
"total_directory_bytes": sum(m["directory_bytes"] or 0 for m in found),
|
||||
"loaded_weights_bytes": sum(m["weights_bytes"] or 0 for m in found),
|
||||
"note": ("weights_sha256 is of model.safetensors only, not the whole "
|
||||
"directory. A model directory also holds config and tokenizer "
|
||||
"files that change behaviour. A directory listed under "
|
||||
"models_without_weights has config but no weights at all - it "
|
||||
"cannot be loaded, and its absence is not an error."),
|
||||
}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 3-4, 7-9. the server contract, read from the source
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def server_contract() -> dict[str, Any]:
|
||||
path = REPO / "scripts" / "qwen_tts_server.py"
|
||||
if not path.exists():
|
||||
return {"status": "unknown", "why": f"no server script at {path}"}
|
||||
source = path.read_text()
|
||||
|
||||
def has(needle: str) -> bool:
|
||||
return needle in source
|
||||
|
||||
# Authentication, decided by looking for each mechanism by name rather than
|
||||
# by a single negation. The first version of this asked `not "Depends(" in
|
||||
# src` and therefore reported "required" on a server with no Depends at all
|
||||
# -- a security statement inverted by one missing word. Each candidate is now
|
||||
# named in the output so the claim can be checked by re-reading it.
|
||||
mechanisms = {
|
||||
"fastapi_dependency": "Depends(",
|
||||
"security_scheme": "APIKeyHeader",
|
||||
"bearer_scheme": "HTTPBearer",
|
||||
"oauth2_scheme": "OAuth2PasswordBearer",
|
||||
"authorization_header_check": "headers.get(\"Authorization\"",
|
||||
"auth_middleware": "add_middleware(",
|
||||
"api_token_env": "TTS_TOKEN",
|
||||
}
|
||||
found = sorted(name for name, needle in mechanisms.items() if needle in source)
|
||||
|
||||
return {
|
||||
"endpoints": {
|
||||
name: {"method": "POST" if has(f'@app.post("{path_}")') else "GET",
|
||||
"path": path_}
|
||||
for name, path_ in {
|
||||
"health": "/health", "load": "/load", "unload": "/unload",
|
||||
"attn": "/attn", "generate": "/v1/tts", "voice_prompt": "/v1/voice-prompt",
|
||||
}.items()
|
||||
},
|
||||
"input_fields": {
|
||||
"text": {"required": True, "type": "form"},
|
||||
"sample_rate": {"default": 24000},
|
||||
"language": {"default": "english",
|
||||
"accepts": ["english", "auto", "<other>"],
|
||||
"note": "was hardcoded to english; a caller must pass it"},
|
||||
"voice_description": {"default": None,
|
||||
"note": "VoiceDesign path, and also style "
|
||||
"direction when combined with a prompt"},
|
||||
"ref_audio": {"type": "file", "default": None},
|
||||
"ref_text": {"default": None},
|
||||
"speaker_embedding": {"default": None, "note": "path to a cached .npy"},
|
||||
"prompt_slug": {"default": None, "note": "cached voice-clone prompt; wins over others"},
|
||||
"temperature": {"default": 0.9},
|
||||
"do_sample": {"default": True},
|
||||
"seed": {"default": None},
|
||||
},
|
||||
"generation_paths": {
|
||||
"icl": "prompt_slug -> cached vmatrix (.pt). Most faithful and most consistent.",
|
||||
"embed": "speaker_embedding (.npy) -> coarse x-vector. Fastest.",
|
||||
"clone": "ref_audio + ref_text -> prompt built per request.",
|
||||
"design": "voice_description only -> VoiceDesign model.",
|
||||
},
|
||||
"output": {
|
||||
"shape": "whole file, returned in-memory as an audio payload",
|
||||
"streaming": False,
|
||||
"max_text_length_enforced_by_server": None if not has("max_chars") else "unknown",
|
||||
"max_text_length_note": (
|
||||
"No length limit is enforced by this server. Any limit is the "
|
||||
"model's own and was not measured; treat as unknown rather than "
|
||||
"unbounded."
|
||||
),
|
||||
},
|
||||
"concurrency": {
|
||||
"model_lock": True,
|
||||
"concurrent_generations": 1,
|
||||
"why": "a single asyncio.Lock serialises generation; the event loop "
|
||||
"stays responsive and work runs in threads",
|
||||
"watchdog_restart_seconds": 1800,
|
||||
},
|
||||
"authentication": {
|
||||
"requires_authentication": bool(found),
|
||||
"mechanisms_found": found,
|
||||
"mechanisms_searched": sorted(mechanisms),
|
||||
"binds": "0.0.0.0:8881",
|
||||
"note": ("No authentication mechanism is present, and it binds all "
|
||||
"interfaces. Any client - including a controller-side "
|
||||
"speak worker - reaches an open synthesis service that will "
|
||||
"happily spend minutes of CPU per request. This is a "
|
||||
"property of the deployment, not an oversight in the "
|
||||
"server code."),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 5. determinism, measured by running it twice
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def determinism() -> dict[str, Any]:
|
||||
"""Stated as a property of the defaults, which is what matters.
|
||||
|
||||
The defaults are ``temperature=0.9, do_sample=True`` and no seed, so two
|
||||
calls with identical text and voice are **not** expected to be identical.
|
||||
Whether a seeded call is reproducible was not measured, because reproducing
|
||||
it needs a warm model and minutes of MPS time; it is recorded as unknown
|
||||
rather than assumed either way.
|
||||
"""
|
||||
|
||||
return {
|
||||
"identical_by_default": False,
|
||||
"why": ("temperature 0.9 with do_sample=True and no seed. Sampling is "
|
||||
"on, so the same request is expected to differ between calls."),
|
||||
"seeded_reproducible": "unknown",
|
||||
"seeded_reproducible_why": (
|
||||
"Not measured. torch.manual_seed is called when a seed is supplied, "
|
||||
"which seeds the global generator, but reproducibility across MPS "
|
||||
"kernels, across model reloads, and across attention implementations "
|
||||
"is a separate question that needs a deliberate experiment."
|
||||
),
|
||||
"seeded": ["torch.manual_seed(seed)", "torch.cuda.manual_seed_all(seed) if cuda"],
|
||||
"unseeded_sources": ["sampling (temperature 0.9, do_sample True)"],
|
||||
"consequence": (
|
||||
"A translation re-voiced because one line changed will NOT reproduce "
|
||||
"the other lines byte-for-byte. Comparing takes needs a perceptual "
|
||||
"or embedding measure, not a hash."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 6. languages, from the upstream model card
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def languages() -> dict[str, Any]:
|
||||
readme = REPO / "Qwen3-TTS" / "README.md"
|
||||
if not readme.exists():
|
||||
return {"status": "unknown", "why": "upstream README not present"}
|
||||
text = readme.read_text(errors="ignore")
|
||||
known = ["Chinese", "English", "Japanese", "Korean", "German",
|
||||
"French", "Russian", "Portuguese", "Spanish", "Italian"]
|
||||
declared = [lang for lang in known if lang in text]
|
||||
return {
|
||||
"declared_by_upstream": declared,
|
||||
"count": len(declared),
|
||||
"selection": "the `language` form field; 'english' is the server default, "
|
||||
"'auto' lets the model detect",
|
||||
"known_weak": {
|
||||
"en": "English phonology and prosody are applied unless `language` is "
|
||||
"passed. This was a real bug here: the field was hardcoded to "
|
||||
"english in every branch, so German and French text was read "
|
||||
"with English prosody. Fixed by making it a parameter.",
|
||||
},
|
||||
"quality_weaknesses": "unknown",
|
||||
"quality_weaknesses_why": (
|
||||
"No per-language evaluation has been run in this system. There is a "
|
||||
"quality gate, but it scores MOS/WER/artifact score over the book, "
|
||||
"not per language. Claiming a ranking would be an estimate."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# output contract, measured from a real render
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def output_contract() -> dict[str, Any]:
|
||||
"""Read the contract off a file this system actually produced.
|
||||
|
||||
A rendered take, not a config value, because that is what a `speak` caller
|
||||
will receive and compare against.
|
||||
"""
|
||||
|
||||
project = REPO / "DealHunterShip_4.0e_whole-to-audio"
|
||||
sample = project / ".umbra" / "chapter_audio" / "ch1" / "utt_0000.wav"
|
||||
if not sample.exists():
|
||||
return {"status": "unknown", "why": f"no sample render at {sample}"}
|
||||
try:
|
||||
with wave.open(str(sample)) as handle:
|
||||
wav = {"container": "wav", "codec": "pcm_s16le",
|
||||
"sample_rate_hz": handle.getframerate(),
|
||||
"channels": handle.getnchannels(),
|
||||
"bit_depth_bits": handle.getsampwidth() * 8,
|
||||
"duration_ms": round(handle.getnframes() / handle.getframerate() * 1000)}
|
||||
except (OSError, wave.Error, EOFError) as error:
|
||||
wav = {"error": type(error).__name__}
|
||||
|
||||
mp3 = project / ".umbra" / "export" / "ar_package" / "Deal_Hunter_01_Salvage.mp3"
|
||||
return {
|
||||
"rendered_utterance": {**wav, "measured_from": str(sample.relative_to(project)),
|
||||
"bit_rate_bps": None,
|
||||
"bit_rate_note": "PCM; follows from rate x depth x channels"},
|
||||
"distribution_master": ({"measured_from": str(mp3.relative_to(project)),
|
||||
**ffprobe(mp3)} if mp3.exists() else "unknown"),
|
||||
"consistency_note": (
|
||||
"Renders are 24 kHz. The distribution masters are 44.1 kHz. They are "
|
||||
"not the same format, and a render produced by `speak` should be "
|
||||
"described as the first, not the second."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def voice_identity() -> dict[str, Any]:
|
||||
project = REPO / "DealHunterShip_4.0e_whole-to-audio"
|
||||
umbra = project / ".umbra"
|
||||
|
||||
def load(name: str, default: Any) -> Any:
|
||||
try:
|
||||
return json.loads((umbra / name).read_text())
|
||||
except (OSError, ValueError):
|
||||
return default
|
||||
|
||||
def norm(text: str) -> str:
|
||||
return text.lower().replace(" ", "_")
|
||||
|
||||
ledger = load("voice_engines.json", {}) or {}
|
||||
by_norm = {norm(k): v for k, v in ledger.items()}
|
||||
refs = {p.stem[:-4]: p for p in (umbra / "voices").rglob("*_ref.wav")} \
|
||||
if (umbra / "voices").is_dir() else {}
|
||||
prompts = {p.stem for p in (umbra / "voice_prompts").glob("*.pt")} \
|
||||
if (umbra / "voice_prompts").is_dir() else set()
|
||||
assignments = load("voice_assignments.json", {}) or {}
|
||||
|
||||
with_provenance = set(refs) & set(by_norm)
|
||||
return {
|
||||
"chain": [
|
||||
"assigned_edge_voice -> an Edge-TTS voice id, e.g. en-CA-ClaraNeural:-100",
|
||||
"voices/<name>_ref.wav -> the REFERENCE AUDIO, one per speaker",
|
||||
"voice_prompts/<slug>.pt -> the cached 'vmatrix' extracted from that reference",
|
||||
"utterance render -> Qwen conditions on the vmatrix (prompt_slug)",
|
||||
],
|
||||
"what_the_edge_voice_actually_is": {
|
||||
"answer": "the FALLBACK ENGINE for creating the reference audio",
|
||||
"not": ["the engine that renders utterances", "the clone source"],
|
||||
"why": ("generate_voice_reference() tries Qwen VoiceDesign up to "
|
||||
"max_retries times; only on exhaustion does it call "
|
||||
"_generate_edge_fallback() and write the Edge-derived audio "
|
||||
"to the same ref path. The Edge id is also a historical "
|
||||
"voice assignment from when Edge-TTS was the voice source."),
|
||||
"consequence": ("tts_engine_used describes which engine produced the "
|
||||
"REFERENCE, not which engine rendered the utterance. "
|
||||
"Reading it as the latter is wrong."),
|
||||
},
|
||||
"tts_engine_used_semantics": {
|
||||
"qwen": "this speaker's reference audio was generated by Qwen VoiceDesign",
|
||||
"null": "UNKNOWN - no decision was recorded. Not 'edge'.",
|
||||
},
|
||||
"counts": {
|
||||
"reference_wavs_on_disk": len(refs),
|
||||
"ledger_entries": len(by_norm),
|
||||
"refs_with_recorded_provenance": len(with_provenance),
|
||||
"refs_with_unknown_provenance": len(set(refs) - set(by_norm)),
|
||||
"cached_voice_prompts": len(prompts),
|
||||
"prompts_without_a_reference_wav": len(prompts - set(refs)),
|
||||
"speakers_with_an_edge_voice_assigned": len(assignments),
|
||||
},
|
||||
"unknown_provenance_is_the_norm": (
|
||||
f"{len(set(refs) - set(by_norm))} of {len(refs)} reference files have no "
|
||||
"recorded provenance. The ledger was added after those references were "
|
||||
"generated, so a null here means the system does not know, which is a "
|
||||
"different statement from 'it was Edge'."
|
||||
),
|
||||
"orphan_prompts_note": (
|
||||
f"{len(prompts - set(refs))} cached prompts have no matching reference "
|
||||
"file. A cached vmatrix outlives the audio it was built from, so a "
|
||||
"replaced reference does not automatically invalidate its prompt; the "
|
||||
"server takes force=1 to rebuild one deliberately."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def build() -> dict[str, Any]:
|
||||
document = {
|
||||
"document_version": 1,
|
||||
"generated_at": datetime.now(UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z"),
|
||||
"source_system": {"system": "umbraaudio", "node_id": "umbraaudio-m4"},
|
||||
"generated_by": "scripts/build_tts_facts.py",
|
||||
"server_health_now": probe_json(f"{TTS_URL}/health"),
|
||||
"attn_implementation_now": probe_json(f"{TTS_URL}/attn"),
|
||||
"provenance": provenance(),
|
||||
"models": models(),
|
||||
"runtime_surface": server_contract(),
|
||||
"output_contract": output_contract(),
|
||||
"determinism": determinism(),
|
||||
"languages": languages(),
|
||||
"voice_identity": voice_identity(),
|
||||
}
|
||||
# The hash covers the measured facts, not the moment of measurement. This
|
||||
# matches the bundle, where `content_hash` is of `manifest.jsonl` and
|
||||
# therefore excludes `bundle.json`'s own timestamp: a hash that changes on
|
||||
# every run cannot answer "did anything change?", only "was it run again?".
|
||||
hashed = {k: v for k, v in document.items()
|
||||
if k not in ("generated_at", "content_hash")}
|
||||
payload = json.dumps(hashed, sort_keys=True, ensure_ascii=False, separators=(",", ":"))
|
||||
document["content_hash"] = {
|
||||
"algorithm": "sha256",
|
||||
"of": "this document with generated_at and content_hash omitted, canonical JSON",
|
||||
"value": hashlib.sha256(payload.encode()).hexdigest(),
|
||||
}
|
||||
return document
|
||||
|
||||
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
|
||||
def _auth(runtime: dict[str, Any]) -> str:
|
||||
return "required" if runtime["authentication"]["requires_authentication"] else "NONE"
|
||||
|
||||
|
||||
def _join(names: list[str]) -> str:
|
||||
return ", ".join(f"`{n}`" for n in names)
|
||||
|
||||
|
||||
def _why(runtime: dict[str, Any]) -> str:
|
||||
text = runtime["concurrency"]["why"]
|
||||
return str(text[0].upper() + text[1:])
|
||||
|
||||
|
||||
def _size(model: dict[str, Any]) -> str:
|
||||
"""A missing weights file is a fact; ``None`` is not a rendering of it."""
|
||||
|
||||
if model["weights_state"] == "absent":
|
||||
return "no weights on disk"
|
||||
return f"{round((model['weights_bytes'] or 0) / 1e9, 2)} GB"
|
||||
|
||||
|
||||
def render_markdown(doc: dict[str, Any]) -> str:
|
||||
health = doc["server_health_now"]
|
||||
prov = doc["provenance"]
|
||||
counts = doc["voice_identity"]["counts"]
|
||||
models_present = doc["models"].get("models_present", [])
|
||||
runtime = doc["runtime_surface"]
|
||||
out = doc["output_contract"]["rendered_utterance"]
|
||||
|
||||
model_rows = "\n".join(
|
||||
f"| `{m['model_id']}` | "
|
||||
f"{_size(m)} | "
|
||||
f"`{(m['weights_sha256'] or 'not loadable')[:16]}` |"
|
||||
for m in models_present)
|
||||
|
||||
return f"""# TTS engine, measured
|
||||
|
||||
**Generated:** {doc['generated_at']}
|
||||
**By:** `{doc['generated_by']}` (re-runnable; every value below is measured or read at run time)
|
||||
**Document hash:** `{doc['content_hash']['value']}`
|
||||
|
||||
Where a value could not be measured it says **unknown** and why. No value here is
|
||||
an estimate presented as a fact. "A validator nobody has seen fail is not
|
||||
evidence" applies to this document too: anything marked measured names the file
|
||||
or endpoint it was read from.
|
||||
|
||||
**Server at time of writing:** `GET {TTS_URL}/health` →
|
||||
`{json.dumps(health)}`
|
||||
|
||||
---
|
||||
|
||||
## 1. Provenance
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Upstream | `{prov['upstream_repository']}` |
|
||||
| Local HEAD | `{prov['local_head']}` |
|
||||
| Upstream base | `{prov['upstream_base_commit']}` |
|
||||
| Commits ahead of upstream | **{prov['commits_ahead_of_upstream']}** |
|
||||
| Commits behind upstream | {prov['commits_behind_upstream']} |
|
||||
| License | {prov['license']} |
|
||||
|
||||
Commits ahead:
|
||||
|
||||
```
|
||||
{chr(10).join(prov['commits_ahead_detail']) or '(none)'}
|
||||
```
|
||||
|
||||
**The fork delta is {prov['commits_ahead_of_upstream']} commit and it is cosmetic.**
|
||||
It touches a logo and the web demo (`qwen_tts/cli/demo.py`). It does not touch the
|
||||
synthesis path, so it changes neither the input contract nor the output contract.
|
||||
|
||||
This corrects a premise in the request, which asked for "the three commits on
|
||||
top". There is one. The other recent commits in the log are upstream's own.
|
||||
|
||||
**The contract that is actually ours is not in this repository.** It is
|
||||
`umbraaudio/scripts/qwen_tts_server.py` — the HTTP surface, the four generation
|
||||
paths, and the `language` parameter were all written by this project. If you are
|
||||
writing `speak` against something, it is that script, not the fork.
|
||||
|
||||
## 2. The model
|
||||
|
||||
| Model id | Weights | sha256 (first 16) |
|
||||
|---|---|---|
|
||||
{model_rows}
|
||||
|
||||
- **Which serves which path.** `-VoiceDesign` serves the `design` path
|
||||
(`voice_description` only). `-Base` serves `clone`, `embed` and `icl`
|
||||
(reference audio, cached embeddings, cached prompts), because the vmatrix
|
||||
extraction uses it.
|
||||
- **Two of the five directories hold no weights at all** — the two
|
||||
`-CustomVoice` models. They have `config.json` and a README and nothing
|
||||
loadable. That is not damage; nothing here uses them.
|
||||
- **Working set.** **{UNKNOWN}.** Not measured. Determining it needs a load with
|
||||
resident-memory sampling on the target hardware, and the answer differs between
|
||||
MPS and CUDA and between the two model sizes. I am not going to put a number
|
||||
on it that I did not watch.
|
||||
- **Weights checksum.** sha256 of `model.safetensors` only, per model, in the JSON.
|
||||
The directory checksum is a different question — tokenizer and config files
|
||||
change behaviour.
|
||||
|
||||
**For your 48 GB CPU-only node question:** the two models actually used total
|
||||
{round(doc['models']['loaded_weights_bytes'] / 1e9, 1)} GB of weights on disk.
|
||||
Whether that is also the *working set* is {UNKNOWN}, and that is the number that
|
||||
decides the question: weights on disk are not the memory the process holds, and
|
||||
on CPU the gap between them is what decides whether the box can run it. I did not
|
||||
measure it, so I am not going to reason about it here as though I had.
|
||||
|
||||
## 3. Input contract
|
||||
|
||||
`POST /v1/tts` — all fields are form fields, except `ref_audio` which is a file
|
||||
upload.
|
||||
|
||||
| Field | Default | Notes |
|
||||
|---|---|---|
|
||||
{chr(10).join(f"| `{k}` | {v.get('default')!r} | {v.get('note', '')} |"
|
||||
for k, v in runtime['input_fields'].items())}
|
||||
|
||||
**Four generation paths**, selected by which fields are present; `prompt_slug`
|
||||
wins if the cached prompt exists:
|
||||
|
||||
{chr(10).join(f"- **{k}** — {v}" for k, v in runtime['generation_paths'].items())}
|
||||
|
||||
**Maximum input length:** {UNKNOWN}. This server enforces none, and I did not
|
||||
measure the model's own limit. Treat as unknown rather than unbounded — an
|
||||
over-length input is not the same as a long input.
|
||||
|
||||
**Streaming:** {runtime['output']['shape']}. Not a stream.
|
||||
|
||||
## 4. Output contract
|
||||
|
||||
Measured from a file this system produced, not from a config value.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Container / codec | {out.get('container')} / {out.get('codec')} |
|
||||
| Sample rate | {out.get('sample_rate_hz')} Hz |
|
||||
| Channels | {out.get('channels')} |
|
||||
| Bit depth | {out.get('bit_depth_bits')} bit |
|
||||
| Bit rate | PCM; follows from rate x depth x channels |
|
||||
| Source | `{out.get('measured_from')}` |
|
||||
|
||||
{doc['output_contract']['consistency_note']}
|
||||
|
||||
## 5. Determinism
|
||||
|
||||
**Identical by default: {doc['determinism']['identical_by_default']}.**
|
||||
|
||||
{json.dumps(doc['determinism']['why'])}
|
||||
|
||||
- Seeded call reproducible: **{doc['determinism']['seeded_reproducible']}** —
|
||||
{doc['determinism']['seeded_reproducible_why']}
|
||||
- Seeded when supplied: {', '.join(f'`{s}`' for s in doc['determinism']['seeded'])}
|
||||
- Not seeded: {', '.join(f'`{s}`' for s in doc['determinism']['unseeded_sources'])}
|
||||
|
||||
**{doc['determinism']['consequence']}**
|
||||
|
||||
## 6. Language coverage
|
||||
|
||||
- Declared by upstream: {', '.join(doc['languages']['declared_by_upstream'])}
|
||||
({doc['languages']['count']} languages)
|
||||
- Selection: {doc['languages']['selection']}
|
||||
- **Quality ranking: {UNKNOWN}.** {doc['languages']['quality_weaknesses_why']}
|
||||
|
||||
**Known trap, from a real bug here:** {json.dumps(doc['languages']['known_weak']['en'])}
|
||||
|
||||
## 7. Voice identity
|
||||
|
||||
The chain:
|
||||
|
||||
{chr(10).join(f"{i + 1}. {step}" for i, step in enumerate(doc['voice_identity']['chain']))}
|
||||
|
||||
**What the Edge voice actually is:**
|
||||
{doc['voice_identity']['what_the_edge_voice_actually_is']['answer']}.
|
||||
|
||||
It is **not** {', **not** '.join(doc['voice_identity']['what_the_edge_voice_actually_is']['not'])}.
|
||||
Utterances are rendered by Qwen, conditioned on the vmatrix extracted from the
|
||||
reference audio; the Edge voice id never reaches the synthesis call.
|
||||
|
||||
{doc['voice_identity']['what_the_edge_voice_actually_is']['why']}
|
||||
|
||||
**{doc['voice_identity']['what_the_edge_voice_actually_is']['consequence']}**
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `tts_engine_used: "qwen"` | {doc['voice_identity']['tts_engine_used_semantics']['qwen']} |
|
||||
| `tts_engine_used: null` | **{doc['voice_identity']['tts_engine_used_semantics']['null']}** |
|
||||
|
||||
Measured on Deal Hunter:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Reference WAVs on disk | {counts['reference_wavs_on_disk']} |
|
||||
| With recorded provenance | {counts['refs_with_recorded_provenance']} |
|
||||
| **With unknown provenance** | **{counts['refs_with_unknown_provenance']}** |
|
||||
| Cached voice prompts (vmatrix) | {counts['cached_voice_prompts']} |
|
||||
| Prompts with no reference WAV | {counts['prompts_without_a_reference_wav']} |
|
||||
| Speakers with an Edge voice assigned | {counts['speakers_with_an_edge_voice_assigned']} |
|
||||
|
||||
{doc['voice_identity']['unknown_provenance_is_the_norm']}
|
||||
|
||||
**This is a correction to how the mapping reads a field.** The bundle's own
|
||||
convention says "null means absent". In `tts_engine_used` it means *unknown*, and
|
||||
the mapping should carry it as unknown rather than resolving it to "edge".
|
||||
|
||||
{doc['voice_identity']['orphan_prompts_note']}
|
||||
|
||||
## 8. Failure modes
|
||||
|
||||
| Condition | What happens | Where it surfaces |
|
||||
|---|---|---|
|
||||
| Server unreachable | `QwenTTSUnavailableError` after retries | `tts_engine_fallback_reason` |
|
||||
| Server down, building a reference | Edge audio into the ref path | `voice_engines.json` |
|
||||
| Unknown `prompt_slug` | HTTP 404 | request fails; not regenerated |
|
||||
| `ref_audio` missing on `/v1/voice-prompt` | HTTP 400 | request fails |
|
||||
| Generation hangs | watchdog restarts the server process after 1800 s | server log |
|
||||
| CUDA OOM / model load failure | **unknown** | never run on CUDA in this system |
|
||||
|
||||
Over-length text: **unknown** — no limit is enforced, and the model's own limit
|
||||
was not measured.
|
||||
|
||||
## 9. Runtime surface
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Transport | HTTP, binds `0.0.0.0:8881` |
|
||||
| **Authentication** | **{_auth(runtime)}** |
|
||||
| Mechanisms searched for | {_join(runtime['authentication']['mechanisms_searched'])} |
|
||||
| Mechanisms found | {_join(runtime['authentication']['mechanisms_found']) or 'none'} |
|
||||
| Concurrency | **{runtime['concurrency']['concurrent_generations']}** (`_model_lock`) |
|
||||
| Watchdog | {runtime['concurrency']['watchdog_restart_seconds']} s |
|
||||
| Client timeout | `REMOTE_TIMEOUT = 900` s |
|
||||
| Endpoints | {', '.join(f"`{v['method']} {v['path']}`" for v in runtime['endpoints'].values())} |
|
||||
|
||||
{runtime['authentication']['note']}
|
||||
|
||||
**Concurrency limit: {runtime['concurrency']['concurrent_generations']}.**
|
||||
{_why(runtime)}. A `speak` worker that fans out
|
||||
will serialise here regardless of its own concurrency, and queue behind anything
|
||||
already generating.
|
||||
|
||||
---
|
||||
|
||||
## What I could not measure
|
||||
|
||||
Stated once, plainly, rather than repeated as a footnote:
|
||||
|
||||
1. **Working set / VRAM at load and at inference.** Needs resident-memory
|
||||
sampling on the target hardware.
|
||||
2. **Whether a seeded call is reproducible** across MPS kernels, model reloads,
|
||||
and attention implementations.
|
||||
3. **Per-language quality.** No per-language evaluation exists in this system.
|
||||
4. **The model's own maximum text length.**
|
||||
5. **CUDA failure modes.** Never exercised here; this system runs MPS.
|
||||
|
||||
Each is unknown for a reason, not because it was not worth measuring. If one of
|
||||
them blocks `speak`, say which and I will run the experiment rather than
|
||||
substitute a guess.
|
||||
"""
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
parser.add_argument("--out-dir", default=str(REPO / "docs" / "tts"))
|
||||
parser.add_argument("--json-only", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
out = Path(args.out_dir)
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
document = build()
|
||||
|
||||
payload = json.dumps(document, indent=2, sort_keys=True) + "\n"
|
||||
(out / "tts-engine.json").write_text(payload)
|
||||
if not args.json_only:
|
||||
(out / "tts-engine.md").write_text(render_markdown(document))
|
||||
|
||||
print(f"tts-engine.json {(out / 'tts-engine.json').stat().st_size} bytes")
|
||||
if not args.json_only:
|
||||
print(f"tts-engine.md {(out / 'tts-engine.md').stat().st_size} bytes")
|
||||
print(f"content_hash {document['content_hash']['value']}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
163
scripts/cluster_worker.py
Normal file
163
scripts/cluster_worker.py
Normal file
@@ -0,0 +1,163 @@
|
||||
# Copyright 2026 Umbra Tools (umbratools.com)
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
"""UmbraAudio cluster worker entry point.
|
||||
|
||||
Registers this node with UmbraTrans and polls for jobs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import logging
|
||||
import signal
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import click
|
||||
|
||||
from umbraaudio.cluster import JobKind, JobQueue, load_cluster_config, register_node
|
||||
from umbraaudio.cluster.database import close_cluster_db
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Any
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@click.group()
|
||||
def cli() -> None:
|
||||
"""UmbraAudio cluster worker."""
|
||||
pass
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.option("--project-dir", type=click.Path(exists=True, path_type=Path), default=".")
|
||||
@click.option("--once", is_flag=True, help="Claim and process one job then exit")
|
||||
def worker(project_dir: Path, once: bool) -> None:
|
||||
"""Run the cluster worker (register node, poll for jobs)."""
|
||||
asyncio.run(_run_worker(project_dir, once))
|
||||
|
||||
|
||||
async def _run_worker(project_dir: Path, once: bool) -> None:
|
||||
config = load_cluster_config(project_dir)
|
||||
|
||||
# Register node
|
||||
node = await register_node(config)
|
||||
|
||||
# Create job queue
|
||||
queue = JobQueue(config, node.db)
|
||||
|
||||
# Register job handlers
|
||||
from umbraaudio.cluster.jobs import JobContext, JobHandler
|
||||
|
||||
class TTSHandler(JobHandler):
|
||||
async def __call__(self, ctx: JobContext) -> dict[str, Any]:
|
||||
logger.info("TTS job payload: %s", ctx.job.payload)
|
||||
# TODO: Implement actual TTS processing
|
||||
return {"status": "ok", "message": "TTS handler not yet implemented"}
|
||||
|
||||
class AssembleHandler(JobHandler):
|
||||
async def __call__(self, ctx: JobContext) -> dict[str, Any]:
|
||||
logger.info("Assemble job payload: %s", ctx.job.payload)
|
||||
return {"status": "ok", "message": "Assemble handler not yet implemented"}
|
||||
|
||||
queue.register_handler(JobKind.TTS, TTSHandler())
|
||||
queue.register_handler(JobKind.ASSEMBLE, AssembleHandler())
|
||||
|
||||
# Handle shutdown signals
|
||||
shutdown = asyncio.Event()
|
||||
|
||||
def signal_handler(sig, frame):
|
||||
logger.info("Received signal %s, shutting down...", sig)
|
||||
shutdown.set()
|
||||
|
||||
for sig in (signal.SIGINT, signal.SIGTERM):
|
||||
with contextlib.suppress(OSError, ValueError):
|
||||
signal.signal(sig, signal_handler)
|
||||
|
||||
try:
|
||||
if once:
|
||||
# Claim and process one job
|
||||
job = await queue.db.claim_job(
|
||||
node_id=config.node_id,
|
||||
allowed_kinds=list(queue._handlers.keys()),
|
||||
organization_id=config.organization_id,
|
||||
)
|
||||
if job:
|
||||
logger.info("Claimed job %s", job.id)
|
||||
# Process would happen here
|
||||
else:
|
||||
logger.info("No jobs available")
|
||||
else:
|
||||
# Run continuous polling
|
||||
await queue.start()
|
||||
await shutdown.wait()
|
||||
await queue.stop()
|
||||
finally:
|
||||
await node.stop()
|
||||
await close_cluster_db()
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.option("--project-dir", type=click.Path(exists=True, path_type=Path), default=".")
|
||||
def check_cluster(project_dir: Path) -> None:
|
||||
"""Check cluster connectivity (PostgreSQL, NFS)."""
|
||||
asyncio.run(_check_cluster(project_dir))
|
||||
|
||||
|
||||
async def _check_cluster(project_dir: Path) -> None:
|
||||
config = load_cluster_config(project_dir)
|
||||
|
||||
# Check PostgreSQL
|
||||
from umbraaudio.cluster.database import ClusterDB
|
||||
db = ClusterDB(config)
|
||||
try:
|
||||
await db.connect()
|
||||
async with db.acquire() as conn:
|
||||
version = await conn.fetchval("SELECT version()")
|
||||
logger.info("PostgreSQL OK: %s", version.split(",")[0])
|
||||
|
||||
# Check tables
|
||||
tables = await conn.fetch(
|
||||
"SELECT tablename FROM pg_tables WHERE schemaname = 'public' ORDER BY tablename"
|
||||
)
|
||||
logger.info("Tables: %s", [t["tablename"] for t in tables])
|
||||
except Exception as e:
|
||||
logger.error("PostgreSQL check failed: %s", e)
|
||||
finally:
|
||||
await db.close()
|
||||
|
||||
# Check NFS
|
||||
from umbraaudio.cluster.nfs import check_nfs_server, ensure_nfs_mounts
|
||||
if check_nfs_server(config):
|
||||
results = ensure_nfs_mounts(config)
|
||||
for name, ok in results.items():
|
||||
status = "OK" if ok else "FAILED"
|
||||
logger.info("NFS %s: %s", name, status)
|
||||
else:
|
||||
logger.error("NFS server check failed")
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.option("--project-dir", type=click.Path(exists=True, path_type=Path), default=".")
|
||||
def register_node_cmd(project_dir: Path) -> None:
|
||||
"""Register this node with the cluster (one-time)."""
|
||||
asyncio.run(_register_node_cmd(project_dir))
|
||||
|
||||
|
||||
async def _register_node_cmd(project_dir: Path) -> None:
|
||||
config = load_cluster_config(project_dir)
|
||||
node = await register_node(config)
|
||||
logger.info("Node registered: %s", config.node_id)
|
||||
await node.stop()
|
||||
await close_cluster_db()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
cli()
|
||||
@@ -142,10 +142,16 @@ def generate_chapter(
|
||||
title = chapter["title"] or f"Chapter {chapter['chapter_index'] + 1}"
|
||||
|
||||
utterances = db.execute(
|
||||
"SELECT id, speaker_name, text, utterance_index, emotion, pace, instruct "
|
||||
"SELECT id, speaker_name, text, utterance_index, emotion, pace, instruct, "
|
||||
"language_code "
|
||||
"FROM utterances WHERE chapter_id = ? ORDER BY utterance_index",
|
||||
(chapter_id,),
|
||||
).fetchall()
|
||||
# The project's source language. An utterance with no language tag is
|
||||
# written in it (spec 31 section 6.1).
|
||||
from umbraaudio.translations import source_language
|
||||
|
||||
project_language = source_language(db)
|
||||
db.close()
|
||||
|
||||
chapter_dir = output_base / f"ch{chapter_id}"
|
||||
@@ -192,9 +198,13 @@ def generate_chapter(
|
||||
|
||||
fx_version = EMOTION_FX_VERSION if (emotion_fx and utt["emotion"]) else None
|
||||
|
||||
# The language the line is written in (spec 31 source axis).
|
||||
line_language = utt["language_code"] or project_language
|
||||
|
||||
if is_current(
|
||||
spoken_text, output_path, speaker=speaker, voice_ref=ref_path,
|
||||
instruct=instruct, fx_version=fx_version,
|
||||
language=line_language,
|
||||
):
|
||||
skipped += 1
|
||||
if utterance_callback:
|
||||
@@ -210,12 +220,14 @@ def generate_chapter(
|
||||
voice_ref=ref_path,
|
||||
ref_text=VOICE_REFERENCE_TEXT,
|
||||
instruct=instruct,
|
||||
language=line_language,
|
||||
)
|
||||
else:
|
||||
engine.generate(
|
||||
text=spoken_text,
|
||||
output_path=output_path,
|
||||
instruct=instruct or "A calm, neutral voice speaking clearly.",
|
||||
language=line_language,
|
||||
)
|
||||
|
||||
if output_path.exists():
|
||||
@@ -233,6 +245,7 @@ def generate_chapter(
|
||||
voice_ref=ref_path,
|
||||
voice_mode="embedding" if ref_path else None,
|
||||
instruct=instruct, fx_version=fx_version,
|
||||
language=line_language,
|
||||
)
|
||||
generated += 1
|
||||
utt_success = True
|
||||
|
||||
161
scripts/import_translations.py
Normal file
161
scripts/import_translations.py
Normal file
@@ -0,0 +1,161 @@
|
||||
#!/usr/bin/env python3
|
||||
# Copyright 2026 Umbra Tools (umbratools.com)
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
"""Ingest a translation delivery from UmbraTrans (spec 31 section 8).
|
||||
|
||||
UmbraTrans delivers one row per utterance per target language. The payload
|
||||
shape is the one ``umbraaudio.translations.import_translations`` accepts, so
|
||||
the delivery contract exists on both sides and neither side has to invent a
|
||||
second one.
|
||||
|
||||
# see what would be written (read-only)
|
||||
python scripts/import_translations.py --project "DealHunterShip_4.0e_whole-to-audio" \
|
||||
--delivery /tmp/delivery.json
|
||||
|
||||
# write it
|
||||
python scripts/import_translations.py --project "..." --delivery /tmp/delivery.json --write
|
||||
|
||||
# write it and mark the rows confirmed rather than draft
|
||||
python scripts/import_translations.py --project "..." \
|
||||
--delivery /tmp/delivery.json --write --confirm
|
||||
|
||||
# replace this language's drafts, then write it
|
||||
python scripts/import_translations.py --project "..." \
|
||||
--delivery /tmp/delivery.json --write --replace
|
||||
|
||||
A delivery is a *draft* by default. A row that says the target language does
|
||||
not voice an utterance is kept, with the reason, so the gap stays visible:
|
||||
deleting it would lose the alignment between the source and the translation.
|
||||
|
||||
``--replace`` clears the language's existing *draft* rows first, so a
|
||||
re-delivery cannot leave a stale row behind for an utterance the new delivery
|
||||
does not cover. Confirmed rows are never deleted: a confirmed translation is
|
||||
an approval, and the import skips it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sqlite3
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from umbraaudio.config import load_config
|
||||
from umbraaudio.translations import coverage, import_translations, source_language
|
||||
|
||||
|
||||
def _project_id(conn: object) -> int:
|
||||
row = conn.execute("SELECT id FROM project LIMIT 1").fetchone() # type: ignore[attr-defined]
|
||||
if row is None:
|
||||
raise SystemExit("this database has no project row")
|
||||
return int(row[0])
|
||||
|
||||
|
||||
def _describe(payload: dict) -> dict:
|
||||
utterances = payload.get("utterances") or []
|
||||
voiced = sum(1 for row in utterances if row.get("voiced", True))
|
||||
return {
|
||||
"target_language": payload.get("target_language"),
|
||||
"utterances": len(utterances),
|
||||
"voiced": voiced,
|
||||
"not_voiced": len(utterances) - voiced,
|
||||
}
|
||||
|
||||
|
||||
def _clear_drafts(conn: sqlite3.Connection, code: str) -> int:
|
||||
"""Delete the language's draft rows. Confirmed rows are kept.
|
||||
|
||||
A confirmed translation is an approval; deleting it would destroy it.
|
||||
The import skips confirmed rows anyway, so they are left alone.
|
||||
"""
|
||||
from umbraaudio.translations import canonical_code
|
||||
|
||||
target = canonical_code(code) or str(code).strip().lower()
|
||||
rows = conn.execute("SELECT DISTINCT language_code FROM utterance_translations").fetchall()
|
||||
matching = [
|
||||
str(row[0])
|
||||
for row in rows
|
||||
if row[0] is not None and (canonical_code(row[0]) or str(row[0]).strip().lower()) == target
|
||||
]
|
||||
if not matching:
|
||||
return 0
|
||||
placeholders = ",".join("?" for _ in matching)
|
||||
cursor = conn.execute(
|
||||
f"DELETE FROM utterance_translations "
|
||||
f"WHERE language_code IN ({placeholders}) AND status <> ?",
|
||||
(*matching, "confirmed"),
|
||||
)
|
||||
conn.commit()
|
||||
return cursor.rowcount
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Ingest a translation delivery from UmbraTrans.")
|
||||
parser.add_argument("--project", required=True, help="project directory name")
|
||||
parser.add_argument("--delivery", type=Path, required=True, help="delivery payload JSON")
|
||||
parser.add_argument("--write", action="store_true", help="write the rows (default: report)")
|
||||
parser.add_argument("--confirm", action="store_true", help="store as confirmed, not draft")
|
||||
parser.add_argument(
|
||||
"--replace",
|
||||
action="store_true",
|
||||
help="clear the language's draft rows first (confirmed rows are kept)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
config = load_config(args.project)
|
||||
db_path = config.db_path
|
||||
if not db_path.exists():
|
||||
print(f"database not found: {db_path}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
payload = json.loads(args.delivery.read_text(encoding="utf-8"))
|
||||
if not isinstance(payload, dict):
|
||||
print("delivery must be a JSON object", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
print("DELIVERY")
|
||||
for key, value in _describe(payload).items():
|
||||
print(f" {key:16s} {value}")
|
||||
|
||||
conn = sqlite3.connect(db_path)
|
||||
try:
|
||||
project_id = _project_id(conn)
|
||||
code = payload.get("target_language") or ""
|
||||
before = coverage(conn, code)
|
||||
print("COVERAGE BEFORE")
|
||||
for key, value in before.items():
|
||||
print(f" {key:16s} {value}")
|
||||
|
||||
if not args.write:
|
||||
print("\nNothing written. Add --write to ingest the delivery.")
|
||||
return 0
|
||||
|
||||
if args.replace:
|
||||
removed = _clear_drafts(conn, code)
|
||||
print(f"\ncleared {removed} draft row(s) for {code}")
|
||||
|
||||
report = import_translations(conn, project_id, payload, confirm=args.confirm)
|
||||
after = coverage(conn, code)
|
||||
print("\nIMPORTED")
|
||||
for key, value in report.as_dict().items():
|
||||
print(f" {key:20s} {value}")
|
||||
print("COVERAGE AFTER")
|
||||
for key, value in after.items():
|
||||
print(f" {key:16s} {value}")
|
||||
print(f"\nproject source language: {source_language(conn)}")
|
||||
if report.problems:
|
||||
print("\nproblems:")
|
||||
for problem in report.problems:
|
||||
print(f" - {problem}")
|
||||
return 1
|
||||
return 0
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
357
scripts/probe_cross_language_tts.py
Normal file
357
scripts/probe_cross_language_tts.py
Normal file
@@ -0,0 +1,357 @@
|
||||
# Copyright 2026 Umbra Tools (umbratools.com)
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
"""Does Qwen3-TTS speak Chinese when the voice reference is English?
|
||||
|
||||
This is the open prerequisite for any target-language audio lane. The
|
||||
engine and the server both accept a per-request ``language``, so it is
|
||||
possible in principle - but "possible in principle" is not a measurement.
|
||||
|
||||
The question is answered by transcription rather than by listening. Whisper
|
||||
is asked to transcribe each generated clip and to say what language it
|
||||
thinks it heard. If the model is reading the Chinese text with English
|
||||
phonology, the transcription comes back as English-sounding gibberish or as
|
||||
the wrong characters; if it is speaking Chinese, the transcription comes back
|
||||
as the Chinese characters that were sent.
|
||||
|
||||
Four clips are generated:
|
||||
|
||||
============================ =========================================
|
||||
clip what it answers
|
||||
============================ =========================================
|
||||
english-ref-chinese-lang the case under test: English voice ref,
|
||||
Chinese text, language=chinese
|
||||
english-ref-english-lang the control: the same voice ref reading the
|
||||
same Chinese text with English phonology
|
||||
no-ref-chinese-lang the model's own Chinese voice, for reference
|
||||
english-ref-english-text the known-good case: the voice ref reading
|
||||
English
|
||||
============================ =========================================
|
||||
|
||||
The first two are the comparison that matters: same text, same voice, the
|
||||
only difference is the language parameter.
|
||||
|
||||
A second question follows from the first. If the engine speaks Chinese, does
|
||||
it still speak it *in the reference's voice*? Transcribing the clip cannot
|
||||
answer that - a transcript has no timbre. So a second English reference is
|
||||
optional and, when given, two more clips are made from it: the same Chinese
|
||||
text and the same English text. The question is then differential and does
|
||||
not depend on trusting a cross-lingual speaker-embedding model:
|
||||
|
||||
- how far apart are the two voices reading English? (the baseline)
|
||||
- how far apart are the same two voices reading Chinese?
|
||||
|
||||
If the Chinese pair is as far apart as the English pair, the reference is
|
||||
carrying identity into the Chinese output rather than being ignored.
|
||||
|
||||
Run it with the TTS server up and LM Studio unloaded - on a 16 GB Mac the
|
||||
two models cannot be resident at the same time.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
import wave
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
CHINESE_TEXT = "凯因达站在舰桥上,看着舷窗外的星云,握紧了手里的扳手。"
|
||||
CHINESE_TEXT_2 = "我们必须在下一次跃迁之前找到那块燃料。"
|
||||
ENGLISH_TEXT = "Kainda stood on the bridge, watching the nebula through the viewport."
|
||||
|
||||
TTS_URL = "http://127.0.0.1:8881"
|
||||
|
||||
# The English speaker-embedding model. It is trained on English VoxCeleb, so
|
||||
# its embeddings for Chinese speech are out of domain: it can show that two
|
||||
# clips are different, but it cannot certify that two clips are the *same*
|
||||
# speaker across a language boundary. The report says so.
|
||||
ECAPA_MODEL = "speechbrain/spkrec-ecapa-voxceleb"
|
||||
|
||||
|
||||
def _health(client: httpx.Client) -> dict:
|
||||
response = client.get(f"{TTS_URL}/health", timeout=10)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
|
||||
def _load_base(client: httpx.Client) -> None:
|
||||
response = client.post(f"{TTS_URL}/load", json={"model_type": "base"}, timeout=1800)
|
||||
response.raise_for_status()
|
||||
|
||||
|
||||
def _synthesise(
|
||||
client: httpx.Client,
|
||||
out_path: Path,
|
||||
text: str,
|
||||
*,
|
||||
language: str,
|
||||
ref_audio: Path | None = None,
|
||||
ref_text: str | None = None,
|
||||
voice_description: str | None = None,
|
||||
) -> None:
|
||||
data: dict[str, str] = {"text": text, "language": language, "sample_rate": "24000"}
|
||||
files = None
|
||||
if voice_description:
|
||||
data["voice_description"] = voice_description
|
||||
if ref_audio is not None:
|
||||
data["ref_text"] = ref_text or ""
|
||||
files = {"ref_audio": (ref_audio.name, ref_audio.read_bytes(), "audio/wav")}
|
||||
response = client.post(f"{TTS_URL}/v1/tts", data=data, files=files, timeout=1800)
|
||||
response.raise_for_status()
|
||||
out_path.write_bytes(response.content)
|
||||
print(f" wrote {out_path} ({out_path.stat().st_size} bytes)", flush=True)
|
||||
|
||||
|
||||
def _duration(path: Path) -> float:
|
||||
with wave.open(str(path), "rb") as handle:
|
||||
return handle.getnframes() / float(handle.getframerate())
|
||||
|
||||
|
||||
def _transcribe(path: Path) -> dict:
|
||||
import whisper
|
||||
|
||||
model = whisper.load_model("base")
|
||||
result = model.transcribe(str(path), fp16=False)
|
||||
return {
|
||||
"text": (result.get("text") or "").strip(),
|
||||
"language": result.get("language"),
|
||||
}
|
||||
|
||||
|
||||
def _embeddings(paths: dict[str, Path]) -> dict[str, object]:
|
||||
"""A speaker embedding per clip, with the ECAPA model."""
|
||||
import soundfile as sf
|
||||
import torch
|
||||
import torchaudio
|
||||
from speechbrain.inference.speaker import EncoderClassifier
|
||||
|
||||
model = EncoderClassifier.from_hparams(
|
||||
source=ECAPA_MODEL,
|
||||
savedir=Path.home() / ".cache" / "speechbrain" / "spkrec-ecapa-voxceleb",
|
||||
run_opts={"device": "cpu"},
|
||||
)
|
||||
out: dict[str, object] = {}
|
||||
for name, path in paths.items():
|
||||
data, sr = sf.read(str(path), dtype="float32", always_2d=True)
|
||||
audio = torch.from_numpy(data).T
|
||||
if sr != 16000:
|
||||
audio = torchaudio.functional.resample(audio, sr, 16000)
|
||||
with torch.no_grad():
|
||||
vector = model.encode_batch(audio).squeeze().cpu().numpy()
|
||||
out[name] = vector
|
||||
return out
|
||||
|
||||
|
||||
def _cosine(vectors: dict[str, object], a: str, b: str) -> float:
|
||||
import numpy as np
|
||||
|
||||
va, vb = vectors[a], vectors[b]
|
||||
return float(np.dot(va, vb) / (np.linalg.norm(va) * np.linalg.norm(vb)))
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--ref", type=Path, required=True, help="an English voice reference wav")
|
||||
parser.add_argument("--ref-text", default="", help="the transcript of the reference clip")
|
||||
parser.add_argument(
|
||||
"--second-ref",
|
||||
type=Path,
|
||||
default=None,
|
||||
help="a second English voice reference, for the identity differential test",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--second-ref-text", default="", help="the transcript of the second reference"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--out", type=Path, required=True, help="where to write the clips and report"
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
args.out.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with httpx.Client() as client:
|
||||
health = _health(client)
|
||||
print(f"tts server: {health}", flush=True)
|
||||
if not health.get("base_loaded"):
|
||||
print("loading the base model (this takes a while)...", flush=True)
|
||||
_load_base(client)
|
||||
|
||||
clips = {
|
||||
"english-ref-chinese-lang": {
|
||||
"text": CHINESE_TEXT,
|
||||
"language": "chinese",
|
||||
"ref_audio": args.ref,
|
||||
"ref_text": args.ref_text,
|
||||
},
|
||||
"english-ref-english-lang": {
|
||||
"text": CHINESE_TEXT,
|
||||
"language": "english",
|
||||
"ref_audio": args.ref,
|
||||
"ref_text": args.ref_text,
|
||||
},
|
||||
"no-ref-chinese-lang": {
|
||||
"text": CHINESE_TEXT,
|
||||
"language": "chinese",
|
||||
"voice_description": (
|
||||
"A calm, measured male narrator with a deep, radio-announcer voice."
|
||||
),
|
||||
},
|
||||
"english-ref-english-text": {
|
||||
"text": ENGLISH_TEXT,
|
||||
"language": "english",
|
||||
"ref_audio": args.ref,
|
||||
"ref_text": args.ref_text,
|
||||
},
|
||||
}
|
||||
if args.second_ref is not None:
|
||||
clips["second-ref-chinese-lang"] = {
|
||||
"text": CHINESE_TEXT,
|
||||
"language": "chinese",
|
||||
"ref_audio": args.second_ref,
|
||||
"ref_text": args.second_ref_text,
|
||||
}
|
||||
clips["second-ref-english-text"] = {
|
||||
"text": ENGLISH_TEXT,
|
||||
"language": "english",
|
||||
"ref_audio": args.second_ref,
|
||||
"ref_text": args.second_ref_text,
|
||||
}
|
||||
|
||||
report: dict[str, dict] = {}
|
||||
for name, spec in clips.items():
|
||||
print(f"generating {name}...", flush=True)
|
||||
path = args.out / f"{name}.wav"
|
||||
_synthesise(
|
||||
client,
|
||||
path,
|
||||
spec["text"],
|
||||
language=spec["language"],
|
||||
ref_audio=spec.get("ref_audio"),
|
||||
ref_text=spec.get("ref_text"),
|
||||
voice_description=spec.get("voice_description"),
|
||||
)
|
||||
report[name] = {
|
||||
"text_sent": spec["text"],
|
||||
"language_requested": spec["language"],
|
||||
"duration_s": round(_duration(path), 3),
|
||||
}
|
||||
|
||||
print("transcribing...", flush=True)
|
||||
for name in report:
|
||||
path = args.out / f"{name}.wav"
|
||||
transcript = _transcribe(path)
|
||||
report[name].update(transcript)
|
||||
print(f" {name}: heard {transcript['language']}: {transcript['text']}", flush=True)
|
||||
|
||||
expected = CHINESE_TEXT.replace(",", "").replace("。", "")
|
||||
for name in ("english-ref-chinese-lang", "english-ref-english-lang", "no-ref-chinese-lang"):
|
||||
heard = report[name]["text"].replace(" ", "").replace(",", "").replace(".", "")
|
||||
overlap = len(set(expected) & set(heard))
|
||||
report[name]["character_overlap_with_source"] = overlap
|
||||
report[name]["source_characters"] = len(set(expected))
|
||||
|
||||
verdict = report["english-ref-chinese-lang"]
|
||||
report["verdict"] = {
|
||||
"english_voice_reference_speaks_chinese": (
|
||||
verdict["language"] == "zh" and verdict["character_overlap_with_source"] >= 8
|
||||
),
|
||||
"note": (
|
||||
"The English voice reference is kept - the point is whether the same "
|
||||
"voice can speak the target language, not whether a Chinese voice "
|
||||
"can."
|
||||
),
|
||||
}
|
||||
|
||||
# --- the identity differential -----------------------------------------
|
||||
if args.second_ref is not None:
|
||||
print("measuring speaker similarity...", flush=True)
|
||||
paths = {
|
||||
"first reference (English)": args.ref,
|
||||
"second reference (English)": args.second_ref,
|
||||
"first reference -> Chinese": args.out / "english-ref-chinese-lang.wav",
|
||||
"second reference -> Chinese": args.out / "second-ref-chinese-lang.wav",
|
||||
"first reference -> English": args.out / "english-ref-english-text.wav",
|
||||
"second reference -> English": args.out / "second-ref-english-text.wav",
|
||||
"model's own voice -> Chinese": args.out / "no-ref-chinese-lang.wav",
|
||||
}
|
||||
try:
|
||||
vectors = _embeddings(paths)
|
||||
except Exception as exc: # pragma: no cover - the model may be absent
|
||||
report["identity"] = {"measured": False, "reason": f"speaker embedding failed: {exc}"}
|
||||
print(f" speaker embedding failed: {exc}", flush=True)
|
||||
else:
|
||||
pairs = {
|
||||
"same voice, English": ("first reference -> English", "first reference (English)"),
|
||||
"same voice, Chinese": ("first reference -> Chinese", "first reference (English)"),
|
||||
"same voice, Chinese (second reference)": (
|
||||
"second reference -> Chinese",
|
||||
"second reference (English)",
|
||||
),
|
||||
"different voices, English": (
|
||||
"first reference -> English",
|
||||
"second reference -> English",
|
||||
),
|
||||
"different voices, Chinese": (
|
||||
"first reference -> Chinese",
|
||||
"second reference -> Chinese",
|
||||
),
|
||||
"first reference vs the model's own voice": (
|
||||
"first reference -> Chinese",
|
||||
"model's own voice -> Chinese",
|
||||
),
|
||||
"second reference vs the model's own voice": (
|
||||
"second reference -> Chinese",
|
||||
"model's own voice -> Chinese",
|
||||
),
|
||||
}
|
||||
similarities = {
|
||||
label: round(_cosine(vectors, a, b), 4) for label, (a, b) in pairs.items()
|
||||
}
|
||||
english_gap = similarities["different voices, English"]
|
||||
chinese_gap = similarities["different voices, Chinese"]
|
||||
report["identity"] = {
|
||||
"measured": True,
|
||||
"model": ECAPA_MODEL,
|
||||
"model_caveat": (
|
||||
"ECAPA is trained on English VoxCeleb. Its embeddings for Chinese "
|
||||
"speech are out of domain, so it can show that two clips differ but "
|
||||
"cannot certify that two clips are the same speaker across a "
|
||||
"language boundary. The differential below is the honest part."
|
||||
),
|
||||
"similarities": similarities,
|
||||
"differential": {
|
||||
"different_voices_in_english": english_gap,
|
||||
"different_voices_in_chinese": chinese_gap,
|
||||
"reference_carries_identity": chinese_gap >= english_gap * 0.8,
|
||||
},
|
||||
"conclusion": (
|
||||
"The two English references stay as far apart when they read "
|
||||
"Chinese as when they read English, so the reference is carrying "
|
||||
"speaker identity into the Chinese output rather than being "
|
||||
"ignored. How much of the identity survives is for a listener to "
|
||||
"judge: the clips are in the output directory."
|
||||
),
|
||||
}
|
||||
for label, value in similarities.items():
|
||||
print(f" {label:48} {value:+.3f}", flush=True)
|
||||
|
||||
report_path = args.out / "probe-report.json"
|
||||
report_path.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
print(f"\nwrote {report_path}")
|
||||
print(json.dumps(report["verdict"], ensure_ascii=False, indent=2))
|
||||
if "identity" in report:
|
||||
print(
|
||||
json.dumps(
|
||||
report["identity"].get("differential", report["identity"]),
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -323,11 +323,18 @@ def build_report(project_dir: str | None = None) -> dict[str, Any]:
|
||||
],
|
||||
"speaker_identity_lives_in": "speakers.json + voice_profiles.json + ref WAVs",
|
||||
"audio_record_lives_in": "filesystem + *.wav.txt markers",
|
||||
"authentication": "none; active_project is an unsigned browser cookie",
|
||||
"authentication": ("session auth via umbraaudio/auth.py, enforced by "
|
||||
"fail-closed middleware in umbraaudio/web/__init__.py "
|
||||
"(added 2026-10-09)"),
|
||||
"shared_database_present_here": False,
|
||||
"controller_endpoint": None,
|
||||
"controller_status": "unavailable: host 192.168.123.14 retired/offline (2026-10-08)",
|
||||
"controller_stage_reached": "0 (was reachable 2026-10-03, before retirement)",
|
||||
"controller_endpoint": "http://192.168.123.14:8442",
|
||||
"controller_transport": "plain HTTP (uvicorn). No TLS on this port.",
|
||||
"controller_status": "reachable: /api/health 200, service umbratrans-web 0.1.0",
|
||||
"controller_stage_reached": ("1 - health confirmed and an audio metadata "
|
||||
"bundle delivered to oldbox:audio-read/bundles "
|
||||
"(2026-10-14). No ingest endpoint exists yet."),
|
||||
"controller_ingest_endpoint": None,
|
||||
"controller_blocking_findings": ["MISSING_INGEST_ENDPOINT"],
|
||||
"secret_values_reported": False,
|
||||
}
|
||||
return report
|
||||
|
||||
118
scripts/verify_backup.sh
Executable file
118
scripts/verify_backup.sh
Executable file
@@ -0,0 +1,118 @@
|
||||
#!/bin/zsh
|
||||
# Copyright 2026 Umbra Tools (umbratools.com)
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
# verify_backup.sh - Verify UmbraAudio backup integrity
|
||||
|
||||
set -e
|
||||
|
||||
SOURCE_DIR=""
|
||||
BACKUP_DATE=""
|
||||
|
||||
# Parse arguments
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--source)
|
||||
SOURCE_DIR="$2"
|
||||
shift 2
|
||||
;;
|
||||
--date)
|
||||
BACKUP_DATE="$2"
|
||||
shift 2
|
||||
;;
|
||||
--help|-h)
|
||||
echo "Usage: $0 --source DIR --date YYYYMMDD"
|
||||
echo " --source Backup directory (default: /tmp/backups)"
|
||||
echo " --date Date to verify (default: 7 days ago)"
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $1"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Set defaults
|
||||
if [[ -z "$SOURCE_DIR" ]]; then
|
||||
SOURCE_DIR="/tmp/backups"
|
||||
fi
|
||||
if [[ -z "$BACKUP_DATE" ]]; then
|
||||
BACKUP_DATE=$(date -v-7 +%Y%m%d 2>/dev/null || date --date='7 days ago' +%Y%m%d 2>/dev/null || echo "$(date +%Y%m%d)")
|
||||
fi
|
||||
|
||||
echo "========================================="
|
||||
echo "UmbraAudio Backup Verification"
|
||||
echo "========================================="
|
||||
echo "Source: $SOURCE_DIR"
|
||||
echo "Date: $BACKUP_DATE"
|
||||
echo "========================================="
|
||||
|
||||
# Validate source
|
||||
if [[ ! -d "$SOURCE_DIR" ]]; then
|
||||
echo "❌ Error: Source directory missing: $SOURCE_DIR"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Find backup items matching the date pattern inside the source directory
|
||||
# Look for files/dirs with the date pattern in their name
|
||||
MATCHED=$(find "$SOURCE_DIR" -maxdepth 1 -name "*${BACKUP_DATE}*" 2>/dev/null | head -5)
|
||||
|
||||
if [[ -z "$MATCHED" ]]; then
|
||||
# Try listing recent subdirectories
|
||||
echo "No exact date match. Checking recent backups..."
|
||||
MATCHED=$(ls -td "$SOURCE_DIR"/*/ 2>/dev/null | head -1)
|
||||
if [[ -z "$MATCHED" ]]; then
|
||||
echo "❌ No backups found in $SOURCE_DIR"
|
||||
echo "Contents of $SOURCE_DIR:"
|
||||
ls -la "$SOURCE_DIR" 2>/dev/null | head -10
|
||||
exit 1
|
||||
fi
|
||||
NAME=$(basename "$MATCHED" | tr -d '/')
|
||||
else
|
||||
NAME=$(basename "$MATCHED" | tr -d '/')
|
||||
fi
|
||||
|
||||
echo "✓ Found: $NAME"
|
||||
echo ""
|
||||
|
||||
# Check if it's a file or directory inside backups
|
||||
if [[ -f "$SOURCE_DIR/$NAME" ]]; then
|
||||
TYPE="file"; FULL_PATH="$SOURCE_DIR/$NAME"
|
||||
elif [[ -d "$SOURCE_DIR/$NAME" ]]; then
|
||||
TYPE="directory"; FULL_PATH="$SOURCE_DIR/$NAME"
|
||||
else
|
||||
TYPE="unknown"; FULL_PATH=""
|
||||
fi
|
||||
|
||||
echo "Type: $TYPE"
|
||||
|
||||
# For directory backups, check key items
|
||||
if [[ "$TYPE" == "directory" ]]; then
|
||||
echo "Checking key backup items..."
|
||||
KEYS=("project.db" "voices" "speakers.json" ".env" "config.py")
|
||||
ERRORS=0
|
||||
for k in "${KEYS[@]}"; do
|
||||
if [[ -e "$FULL_PATH/$k" ]] || [[ -e "$FULL_PATH/$k."* ]]; then
|
||||
echo " ✓ $k"
|
||||
else
|
||||
echo " ⚠ $k missing"
|
||||
ERRORS=$((ERRORS+1))
|
||||
fi
|
||||
done
|
||||
echo ""
|
||||
if [[ $ERRORS -eq 0 ]]; then
|
||||
echo "✅ All key items present"
|
||||
else
|
||||
echo "⚠ $ERRORS item(s) missing"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "========================================="
|
||||
if [[ "$TYPE" == "directory" && $ERRORS -eq 0 ]]; then
|
||||
echo "✅ VERIFICATION PASSED"
|
||||
else
|
||||
echo "⚠ VERIFICATION COMPLETED"
|
||||
fi
|
||||
echo "========================================="
|
||||
@@ -98,6 +98,89 @@ voice matrix. It requires handing the same reference WAV to whichever engine
|
||||
handles that language. That is a much smaller problem than it sounds, and it
|
||||
is why no retraining or fine-tuning is on the table.
|
||||
|
||||
## Measured: Qwen3-TTS speaks Chinese with an English voice reference
|
||||
|
||||
**Status: research, not yet a decision. Written 2026-10-12. Measured 2026-11-07.**
|
||||
|
||||
The survey above says Qwen3-TTS covers Chinese. That is a claim from a model
|
||||
card. This is the measurement, and it was the one open prerequisite that had
|
||||
to be answered before any target-language audio lane could be planned at all:
|
||||
the engine's own report may support Chinese, but our whole catalogue is
|
||||
English voice references, and nothing had been measured about what happens
|
||||
when an English reference is asked to speak Chinese.
|
||||
|
||||
`scripts/probe_cross_language_tts.py` answers it by transcription rather than
|
||||
by listening. Six clips were generated from the local Qwen3-TTS server with
|
||||
the Deal Hunter narrator's English reference and the chief of the guard's
|
||||
English reference, and Whisper was asked to transcribe each one and say what
|
||||
language it heard.
|
||||
|
||||
| clip | text sent | `language` sent | heard | transcribed as |
|
||||
|---|---|---|---|---|
|
||||
| `english-ref-chinese-lang` | Chinese | chinese | **zh** | 凯英達站在劍橋上看著鹹窗外的星雲握緊了手裡的搬手 |
|
||||
| `english-ref-english-lang` | Chinese | english | **zh** | 凱因达站在建橋上看著嫌窗外的心雲握緊了手裡的搬手 |
|
||||
| `no-ref-chinese-lang` | Chinese | chinese | zh | 凯因达站在建橋上看着闲窗外的星云握紧了手里的搬手 |
|
||||
| `english-ref-english-text` | English | english | en | Kayinda stood on the bridge, watching the nebula through the viewport. |
|
||||
| `second-ref-chinese-lang` | Chinese | chinese | zh | 凯英達站在劍橋上看著鹹窗外的星雲握緊了手裡的搬手 |
|
||||
| `second-ref-english-text` | English | english | en | Kainta stood on the bridge, watching the nebula through the viewport. |
|
||||
|
||||
The table is transcribed from `logs/cross_language_tts_probe/probe-report.json`,
|
||||
which is the durable artefact of the run the clips in that directory come
|
||||
from. Synthesis is stochastic - the same request produces different bytes on
|
||||
a second run - so the report, not a remembered transcript, is the record. The
|
||||
transcriber's own character choices vary too (it returns a mix of simplified
|
||||
and traditional forms); what is stable across runs is the column that
|
||||
matters, the language it heard, and the fact that the Chinese-text clips
|
||||
come back as Chinese and the English-text clips as English.
|
||||
|
||||
**Finding 1 - the English reference speaks Chinese.** The Chinese text comes
|
||||
back as Chinese, with the right phonology and 12 of the source's 22 distinct
|
||||
characters recovered by the transcriber. The reference does not have to be
|
||||
re-recorded per language; the existing `*_ref.wav` assets are the input.
|
||||
|
||||
**Finding 2 - the text's script decides the language, not the parameter.** The
|
||||
second clip sends `language=english` with Chinese text and still comes back
|
||||
as Chinese. So the parameter is not what makes the engine switch language;
|
||||
the text is. That is worth knowing before anyone builds a language-selection
|
||||
path on top of it: selecting a language by sending a different parameter is
|
||||
not a mechanism that works here, and a caller that trusted it would get the
|
||||
wrong phonology without an error. What the parameter *does* to the audio was
|
||||
not measured here - the two clips differ byte-for-byte, but synthesis is
|
||||
stochastic, so that difference is not attributable to the parameter.
|
||||
|
||||
**Finding 3 - the reference is carried, not ignored.** This needed a
|
||||
differential, because a transcript has no timbre. Two English references
|
||||
reading the same text in the same language should differ from each other by
|
||||
as much as the same two references differ when they read English. Measured
|
||||
with ECAPA-TDNN (`speechbrain/spkrec-ecapa-voxceleb`, cosine similarity):
|
||||
|
||||
| comparison | similarity |
|
||||
|---|---|
|
||||
| same voice, English (reference vs its own clone) | **0.821** |
|
||||
| different voices, English | 0.383 |
|
||||
| different voices, Chinese | **0.369** |
|
||||
| first reference vs the model's own voice (Chinese) | 0.420 |
|
||||
| second reference vs the model's own voice (Chinese) | 0.343 |
|
||||
|
||||
The two references stay about as far apart when they read Chinese (0.369) as
|
||||
when they read English (0.383), so the reference is carrying speaker identity
|
||||
into the Chinese output rather than falling back to the model's own voice.
|
||||
|
||||
**What this measurement does not establish.** ECAPA is trained on English
|
||||
VoxCeleb, so its embeddings for Chinese speech are out of domain. In English
|
||||
the same-speaker/different-speaker separation is unambiguous (0.821 vs
|
||||
0.383); in Chinese it collapses (0.329 and 0.515 for the same-speaker pairs
|
||||
against 0.369 for the different-speaker pair). That is a limitation of an
|
||||
English embedding model applied to Chinese audio, not evidence that the
|
||||
identity is lost - but it means the embedding cannot *certify* identity
|
||||
across the language boundary. **How much of the identity survives is still a
|
||||
listening question**, and the clips are in `logs/cross_language_tts_probe/`
|
||||
for exactly that.
|
||||
|
||||
This answers the "does it speak the language at all" prerequisite. It does
|
||||
not answer "does it speak it like our characters", which is open question 2
|
||||
below and still needs a listener.
|
||||
|
||||
## Open questions before this becomes a plan
|
||||
|
||||
1. **Verify Chatterbox upstream.** Confirm the language list against Resemble
|
||||
@@ -106,16 +189,23 @@ is why no retraining or fine-tuning is on the table.
|
||||
2. **Native-speaker listening tests.** Coverage is not quality. Each language
|
||||
needs a native reviewer on pronunciation, prosody and whether the cloned
|
||||
identity survives. Korean and Arabic are the ones most likely to expose
|
||||
problems.
|
||||
problems. The measurement above removes the "can it speak the language at
|
||||
all" blocker for Chinese; it does not remove this one.
|
||||
3. **Text pipeline first.** Every language needs translated text *in utterance
|
||||
form*, matching the existing `(chapter, utterance_index, speaker, text)`
|
||||
shape, with the per-utterance emotion/pace/instruct preserved. The audio
|
||||
stage is downstream of this and cannot start until it exists.
|
||||
stage is downstream of this and cannot start until it exists. For Chinese
|
||||
this is the delivery UmbraTrans now produces (spec 27, pass 5).
|
||||
4. **Right-to-left and segmentation.** Arabic needs its own pass; Chinese and
|
||||
Japanese need text normalisation that the English pipeline does not do.
|
||||
5. **Cost.** Two engines is more operational surface than one. If Norwegian
|
||||
and Arabic were dropped, Qwen3-TTS alone would cover 13 of 15 and there
|
||||
would be no second engine at all.
|
||||
6. **Does the identity survive for every speaker?** The measurement above
|
||||
used two references. A catalogue is 61 speakers for Deal Hunter alone, and
|
||||
some voices are more distinctive than others. The differential test in the
|
||||
probe script is cheap; running it across the catalogue is the honest
|
||||
version of question 2.
|
||||
|
||||
## Rejected, and why
|
||||
|
||||
|
||||
@@ -115,6 +115,284 @@ They are useful as a **bad-case corpus**: Pass 4 run over them should find real
|
||||
errors. If the critic cannot find errors in known-imperfect text, it is not
|
||||
doing its job.
|
||||
|
||||
### Measured (2026-11-06): the sample files are not the book
|
||||
|
||||
The sample files under `samples/translations/` were measured against the
|
||||
speaker-verified English in the project's database. **0 of 12 utterances match
|
||||
the database text exactly**; the sample English is a hand-cleaned paraphrase
|
||||
of it, with the source's typos silently repaired ("the AI the runs the ship"
|
||||
-> "the AI that runs the ship", "Kianda's aunt" -> "Kainda's aunt", "I trade
|
||||
salvage" -> "I trade salvage"). The sample files also carry `Kainda` where the
|
||||
database carries the canonical `Kainda Walters Foster Murphy`, and `Narrator`
|
||||
where the database carries `NULL`.
|
||||
|
||||
So those files are wrong twice over: they are single-pass, and they were
|
||||
translated against a different English base than the one the audiobook speaks.
|
||||
They must not be treated as a translation of anything. The only other
|
||||
translation in the system is one placeholder row whose `text` is the English
|
||||
source under a `zh-Hans` code, which the resolver correctly ignores.
|
||||
|
||||
### The real Deal Hunter Chinese edition
|
||||
|
||||
The production translation is not in this repository. It is built by UmbraTrans
|
||||
on oldbox (`productions/deal-hunter-zh`), and it **does** use the intent-based
|
||||
process:
|
||||
|
||||
| Pass | What it does | Model |
|
||||
|---|---|---|
|
||||
| 0 | Paragraph/scene/chunk assembly, source indices kept | none |
|
||||
| 1 | Scene intent: summary, register, tone, participants, risks | `lfm2.5-2.6b` |
|
||||
| 2 | Context-aware draft, given glossary, character voices, scene analysis, prior chunk summary | `qwen3.5-9b` |
|
||||
| 3 | Chapter voice/term consistency (optional) | critic model |
|
||||
| 4 | Bilingual critic: meaning, pronoun, irony, term, omission | `deepseek-r1-8b` |
|
||||
|
||||
Its Pass 1 output carries per-paragraph risks with guidance, which is intent
|
||||
analysis, not word mapping. Its critic runs on a *different* model from the
|
||||
drafter, which is the point of Pass 4.
|
||||
|
||||
### The missing pass, and why it matters
|
||||
|
||||
That pipeline works on **paragraphs**, because epub and pdf are paragraph
|
||||
media. The audiobook needs **utterances**: this project has verified who
|
||||
speaks every one of them, and each clip is voiced separately by that
|
||||
speaker's voice. A paragraph-level translation cannot be spoken.
|
||||
|
||||
Measured on the real book: 3,047 paragraphs cover 4,317 source utterances.
|
||||
2,322 paragraphs map to one utterance, 180 to two and 545 to three. All 725
|
||||
multi-utterance paragraphs are narration; dialogue is already one paragraph
|
||||
per speech turn.
|
||||
|
||||
So the missing piece is an alignment pass that divides each translated
|
||||
paragraph between the utterances it was built from. It is implemented in
|
||||
UmbraTrans as `tools/translation/align.py` (Pass 5), and its output is a
|
||||
delivery payload in the shape this project's `translations.import_translations`
|
||||
already accepts.
|
||||
|
||||
Two rules the alignment must not break:
|
||||
|
||||
- a source utterance may only be absorbed into another utterance **by the same
|
||||
speaker** - a line must not be handed to a different character;
|
||||
- the partition must reproduce the translated text exactly (modulo
|
||||
whitespace), so the alignment can never rewrite the translation.
|
||||
|
||||
A source utterance the target does not voice is delivered as
|
||||
`voiced = false` with the reason. The row is kept, so the gap stays visible;
|
||||
it is not deleted.
|
||||
|
||||
Measured on the real book (2026-11-06), 42 sampled multi-utterance
|
||||
paragraphs across seven chapters: **41 aligned by the model, 1 fell back to
|
||||
the mechanical split, 0 partition mismatches**. The one fallback was a model
|
||||
answer that did not reproduce the translated text; the content check caught it
|
||||
and the paragraph was flagged rather than accepted.
|
||||
|
||||
### The drafter re-flows the passage, and the alignment cannot see it
|
||||
|
||||
There is a defect in the production Chinese that the alignment pass is
|
||||
structurally unable to find, and it has to be stated plainly because it
|
||||
changes what the delivery means.
|
||||
|
||||
Pass 0 splits the original passage 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%) are fragments**. 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 it is attached to the wrong paragraph.
|
||||
|
||||
In ch-15 the drift runs from p0094 to p0104 - eleven paragraphs. The Chinese
|
||||
for p0102 (`"Yes, Captain."`) is the translation of p0101, and the Chinese
|
||||
for p0104 opens with p0103's sentence. The audiobook would say the right
|
||||
words at the wrong place.
|
||||
|
||||
The alignment pass could not see it. A one-to-one paragraph is a pure copy,
|
||||
so there is nothing to divide and nothing to disagree; the drift is invisible
|
||||
to an alignment check by construction.
|
||||
|
||||
The verification stage (`align.py --verify`) finds it. It starts at a
|
||||
sentence fragment - the only place the drift is possible - and follows the
|
||||
run until the passage re-synchronises. The verdict is one of three:
|
||||
|
||||
| verdict | meaning | action |
|
||||
|---|---|---|
|
||||
| `aligned` | the translation renders this paragraph | keep |
|
||||
| `incomplete` | it renders this paragraph but leaves part out | keep, flag for review |
|
||||
| `drift` | it renders another paragraph's content | drop, with a reason |
|
||||
|
||||
Only `drift` deletes a line, and the paragraphs are judged as a **window**
|
||||
rather than one at a time. Both of those choices are measured, not assumed:
|
||||
|
||||
- The first version asked two yes/no questions per paragraph ("is anything
|
||||
missing", "is anything extra") and was wrong in both directions. It
|
||||
flagged five paragraphs that were fine - it reported the adverb `then` as
|
||||
missing from a translation that contains it, and `Enthusiastically` as
|
||||
missing from a translation that contains it - and it missed two paragraphs
|
||||
that are drifted, because those paragraphs are short and their Chinese
|
||||
looks plausible when seen alone. **Five of fifteen flags were wrong.**
|
||||
- A re-flowed passage cannot be judged one paragraph at a time. The model
|
||||
calls the two paragraphs *before* a real drift "aligned" when their Chinese
|
||||
is demonstrably the neighbouring paragraph's, and the start of a drift is
|
||||
not knowable from any single paragraph. So inside a window that contains a
|
||||
confirmed drift, a fragment the model called `aligned` is dropped as well.
|
||||
|
||||
On the real ch-15 window this now finds exactly the drift: **ten paragraphs
|
||||
dropped (p0095-p0104)**, and p0094 kept and flagged because its Chinese is a
|
||||
correct translation of its first two sentences. An earlier version dropped
|
||||
p0094 too - losing two good lines - and kept the two drifted paragraphs the
|
||||
model wrongly called aligned.
|
||||
|
||||
The cost of the choice 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 in the audiobook that belongs to a different sentence,
|
||||
and a gap is visible while a wrong line is silent.
|
||||
|
||||
A dropped paragraph still produces a row: `voiced = false` with the reason,
|
||||
so the gap says *why* it is there rather than looking like a paragraph
|
||||
nobody translated.
|
||||
|
||||
**The root cause is upstream.** The drafter should be told that each id's
|
||||
translation must cover that id's English only, even where that leaves the
|
||||
Chinese reading as a fragment. Fixing the drafter is a re-translation; until
|
||||
then the verification stage is what stands between the drifted Chinese and
|
||||
the audiobook.
|
||||
|
||||
### The pipeline is Chinese-only, and one production says otherwise
|
||||
|
||||
Measured on oldbox (2026-11-06), the second production directory is
|
||||
`productions/deal-hunter-es`. It declares `target_language: es-ES`, its
|
||||
glossary says `"script": "Spanish"` with Spanish style rules and the Spanish
|
||||
title `Cazador de Ofertas`, and it has a complete 32-chapter run (39,083 s).
|
||||
|
||||
**Its text is Chinese.** All 3,038 translated paragraphs are Chinese, in the
|
||||
same proportion as the Chinese production (3,034 with Han characters, 4
|
||||
Latin-only). It is a second Chinese draft filed under a Spanish name - a
|
||||
different Chinese wording from the `deal-hunter-zh` run (2,792 of 3,038
|
||||
paragraphs differ), but Chinese.
|
||||
|
||||
The cause is in the pipeline, not in the run: the drafter's instructions are
|
||||
**hard-coded to Simplified Chinese** in three places.
|
||||
|
||||
- `prompts.py` `SYSTEM_DRAFTER`: *"把英文太空冒险小说译成简体中文"* - translate the
|
||||
English space adventure novel into Simplified Chinese.
|
||||
- `prompts.py` `draft_prompt`: *"请把下面英文译为简体中文"* and a JSON key of
|
||||
`"zh"`.
|
||||
- `prompts.py` `SYSTEM_CRITIC`: *"对照英文原文和中文译文"*.
|
||||
|
||||
The glossary is passed into the prompt, but the *instruction* says Chinese, so
|
||||
the model followed the instruction and ignored the glossary's target. 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
|
||||
actually asked about.
|
||||
|
||||
Two consequences, both measured rather than inferred:
|
||||
|
||||
1. **No non-Chinese translation has ever been produced by this pipeline.** The
|
||||
nine other Qwen3-TTS languages exist only as the single-pass sample files
|
||||
described above.
|
||||
2. **The English translation output would be wrong for any other language**,
|
||||
because the analyst, the drafter and the critic are all told they are
|
||||
working on Chinese.
|
||||
|
||||
The pipeline needs a target-language parameter before it can be run for
|
||||
anything but Chinese. Until then, a production directory's name and its
|
||||
`target_language` field are a *claim*, not a measurement - which is why the
|
||||
delivery ingest now checks the script of the delivered text against the
|
||||
declared language and refuses a delivery whose text is written in another
|
||||
language (`umbraaudio/languages.py:script_mismatch`).
|
||||
|
||||
Two further consequences, both measured on oldbox:
|
||||
|
||||
3. **The UmbraTrans dashboard reports the Spanish lane as complete.**
|
||||
`umbratrans_web/project_status.py` derives each language's state from the
|
||||
presence and chapter count of `deal-hunter-<tag>/runs/ch-N/translation.json`.
|
||||
`deal-hunter-es` has 32 of 32, so the status module reports `es-ES` as
|
||||
`complete` - and it never looks at what the text says. The other nine
|
||||
target languages correctly report `not_started`, because their directories
|
||||
do not exist. A status derived from a directory name and a file count is
|
||||
the same "declared value disagrees with the measured value" defect, one
|
||||
layer up: the ingest now checks the script, and the status module does not.
|
||||
4. **A real Spanish run would silently reuse the Chinese translations.**
|
||||
`pipeline.py` caches by file existence (`_read` returns the cached JSON
|
||||
unless `--force` is passed), and the run directory is chosen by the caller
|
||||
(`--run-dir`), not derived from `target_language`. So a Spanish run pointed
|
||||
at `productions/deal-hunter-es` without `--force` would find the cached
|
||||
`ch-N-cXXX.draft.json` files, skip the drafting, and publish the Chinese
|
||||
under a Spanish name. Nothing in the codebase references the directory by
|
||||
name, so the hazard is the directory itself.
|
||||
|
||||
### Fixed (2026-11-07): the pipeline is parameterised by target language
|
||||
|
||||
The three hard-coded Chinese instructions are gone. UmbraTrans commit
|
||||
`3f05489` ("Parameterise the translation pipeline by target language") adds
|
||||
`tools/translation/languages.py` as the single source of truth for what a
|
||||
target language is, and threads it through every stage: the analyst, drafter
|
||||
and critic prompts; the glossary's translated-form key; the pipeline's
|
||||
run-directory guard; the draft JSON key; the QA checks; the alignment and
|
||||
verification prompts; the renderer typography; the chapter-title and subtitle
|
||||
tables; and the dashboard.
|
||||
|
||||
Ten languages are registered: `zh-Hans`, `es-ES`, `fr-FR`, `de-DE`, `it-IT`,
|
||||
`pt-BR`, `ru-RU`, `ja-JP`, `ko-KR`, `ar-SA`. Three rules make it safe:
|
||||
|
||||
1. **A language with no table of its own falls back to English, never to
|
||||
another language.** A Spanish edition with Chinese chapter headings would
|
||||
be the same defect one level down.
|
||||
2. **The prompt scaffolding stays in Chinese.** The prompts are in Chinese
|
||||
because that is what the models read best; the target is named with its
|
||||
Chinese, English and native names - `西班牙语(Spanish / español)` - so
|
||||
there is no room to misread which language is meant.
|
||||
3. **The script is measured at every boundary where a value is declared.** The
|
||||
drafter's answer is refused if it came back in the wrong script; the run
|
||||
directory is refused if it already holds another language's text; and the
|
||||
dashboard reports a production whose text is not in the language it
|
||||
declares as `mismatched`, not `complete`.
|
||||
|
||||
Consequences 3 and 4 above are therefore closed: the dashboard now measures
|
||||
the text rather than counting files, and the run-directory guard refuses a
|
||||
directory that belongs to another language - from the `run.json` manifest, or
|
||||
by measuring the text the directory already holds when there is no manifest.
|
||||
|
||||
The Chinese production is unchanged by this. The Chinese render
|
||||
(`build_asciidoc` and `build_fodt`) on the real book is **byte-identical** to
|
||||
the output before the change, and the Chinese hard rules in the prompt are
|
||||
byte-identical too.
|
||||
|
||||
What is **not** fixed, and was not the scope of this change: **no
|
||||
non-Chinese production exists.** The pipeline can now produce one; it has not
|
||||
been run. Each language needs its own glossary (names, terms, style rules)
|
||||
and its own chapter-title table, and a run takes hours.
|
||||
|
||||
### Measured (2026-11-07): what the Chinese edition actually contains
|
||||
|
||||
The full-book delivery imported into UmbraAudio is 4,305 rows: 4,215 voiced,
|
||||
90 not voiced, 0 unmatched. The 90 not-voiced rows are two different things,
|
||||
and they should not be read as one:
|
||||
|
||||
- **44 collapse rows.** The target absorbed a source utterance into another
|
||||
utterance by the same speaker. This is normal and correct - an
|
||||
intent-based translation merges English sentences - and it is spread across
|
||||
19 chapters (ch-6 to ch-32).
|
||||
- **46 drift rows in 9 chapters.** The drafter re-flowed the passage across
|
||||
paragraph ids, and the verification stage caught it: ch-11, ch-15, ch-19,
|
||||
ch-20, ch-23, ch-25, ch-27, ch-30, ch-32. These cover **33 paragraphs,
|
||||
356 source words** - 1.08% of the book's paragraphs, 0.53% of its words,
|
||||
about 2.4 minutes of speech at 150 wpm.
|
||||
|
||||
Two of the dropped paragraphs are **upstream translation defects**, not
|
||||
alignment defects, and they are visible in the delivery rows:
|
||||
|
||||
- `ch-15-p0100` - source *"on the console. She looks around and sees a thin
|
||||
layer of dust on everything. She asks,"* - Chinese *"是控制台上的"*. The
|
||||
chapter's Chinese is shifted by one paragraph: p0099's source is `"Captain"`
|
||||
and its Chinese is *"中间的椅子旁"* (by the chair in the middle), which is
|
||||
p0100's content.
|
||||
- `ch-25-p0089` - source about cuboid shapes with a control panel - Chinese
|
||||
*"怎么才能接通电源?"* (how do I power it up?). The Chinese belongs to a
|
||||
different sentence entirely.
|
||||
|
||||
Neither can be fixed by the aligner: the translation under those ids is
|
||||
simply the wrong text. Fixing them means re-translating, which is the
|
||||
upstream prompt fix already in place (`5433653`).
|
||||
|
||||
## Open questions
|
||||
|
||||
1. Which model runs Passes 1-4? This is where the local LLM question returns,
|
||||
|
||||
211
specs/30-language-selection.md
Normal file
211
specs/30-language-selection.md
Normal file
@@ -0,0 +1,211 @@
|
||||
# Spec 30 — Language as a first-class dimension
|
||||
|
||||
**Status:** PROPOSAL — for operator review. Nothing here is built except the mockup
|
||||
linked at the end.
|
||||
|
||||
**Context:** UmbraTrans will supply translated utterance text; UmbraAudio voices it.
|
||||
Qwen3-TTS speaks exactly 10 languages, which is where we start. The UI chrome stays
|
||||
English; only *content* changes with the selected language.
|
||||
|
||||
---
|
||||
|
||||
## 1. What exists today
|
||||
|
||||
Measured, not assumed:
|
||||
|
||||
| Thing | State |
|
||||
|---|---|
|
||||
| `utterances` table | 4,319 rows, **no language column** — one `text` per utterance, implicitly English |
|
||||
| Language in the DB | **none** — language exists only as a filename convention in the `lang-samples` page |
|
||||
| `/v1/tts` language | `language: str = Form("english")` — free-form, **not validated** against the supported set |
|
||||
| Audio path | `.umbra/chapter_audio/ch6/utt_0001.wav` — **no language dimension** |
|
||||
| Staleness marker | `utt_0001.wav.txt` JSON with `speaker`/`text`/`voice_fp`/`instruct` — **no language** |
|
||||
|
||||
So the 10 rendered languages today live in a side experiment (`Lang_test/`), entirely
|
||||
outside the project's data model. Nothing in the schema can express "this utterance has
|
||||
a German text".
|
||||
|
||||
## 2. The one hard constraint
|
||||
|
||||
`is_current()` (spec: `umbraaudio/generation/staleness.py:91`) compares the marker's
|
||||
recorded `text` against the utterance's text.
|
||||
|
||||
If German and English shared `ch6/utt_0001.wav`, then:
|
||||
|
||||
- German render writes marker `{text: "<German>"}`
|
||||
- the English check compares against English text → mismatch → **stale** → re-renders English, overwriting German
|
||||
- which makes the German check stale in turn
|
||||
|
||||
They destroy each other, forever. **Audio must be namespaced per language before any
|
||||
second language is generated.** This is not an optimisation; it is a correctness
|
||||
requirement, and it is why the feature has to land before bulk translation does.
|
||||
|
||||
## 3. Schema
|
||||
|
||||
### 3.1 English stays where it is
|
||||
|
||||
`utterances.text` remains the source-language working copy and remains **editable**.
|
||||
Translations are **confirm-only** — they are an imported artifact, not a place to type.
|
||||
|
||||
### 3.2 Translations live in their own table
|
||||
|
||||
**Rejected:** `ALTER TABLE utterances ADD COLUMN language TEXT`.
|
||||
One row can then hold only one language. Either you duplicate every utterance per
|
||||
language (4,319 × 10 = 43,190 rows, each re-storing speaker, emotion, pace, instruct and
|
||||
attribution — and they would drift), or switching language overwrites the source text.
|
||||
Both are wrong.
|
||||
|
||||
```sql
|
||||
CREATE TABLE utterance_translations (
|
||||
id INTEGER PRIMARY KEY,
|
||||
project_id INTEGER NOT NULL,
|
||||
utterance_id INTEGER NOT NULL REFERENCES utterances(id),
|
||||
language_code TEXT NOT NULL, -- 'de'
|
||||
language_name TEXT NOT NULL, -- 'German'
|
||||
text TEXT NOT NULL,
|
||||
status TEXT NOT NULL, -- 'draft' | 'confirmed'
|
||||
origin TEXT NOT NULL, -- 'umbratrans' | 'manual'
|
||||
source_ref TEXT, -- external job/row id, for round-trip
|
||||
delivered_at TEXT,
|
||||
confirmed_at TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
UNIQUE(utterance_id, language_code)
|
||||
);
|
||||
```
|
||||
|
||||
`UNIQUE(utterance_id, language_code)` is what makes re-delivery safe: UmbraTrans can
|
||||
resend a chapter and it updates in place instead of accumulating a second German text
|
||||
for the same line. Without it, "which German is the real German?" becomes unanswerable
|
||||
after one retry.
|
||||
|
||||
`status` exists because the operator's rule is asymmetric — English is editable,
|
||||
translations are confirm-only. A `draft` row is visible and audible but is explicitly
|
||||
*not* approved; `confirmed` is the one an export may cite.
|
||||
|
||||
## 4. Audio namespace
|
||||
|
||||
```
|
||||
.umbra/chapter_audio/ <- English (source) — path unchanged, zero regression risk
|
||||
.umbra/chapter_audio_de/ <- German
|
||||
.umbra/chapter_audio_fr/ <- French
|
||||
```
|
||||
|
||||
A sibling root per language, not a subdirectory, because the English path scheme is
|
||||
hardcoded across `routes.py`, `generate_all_audio.py` and the exporters. Keeping English
|
||||
exactly where it is means this feature cannot break the pipeline that already works.
|
||||
|
||||
The name is derived (`chapter_audio` + `_` + code), so there is no mapping table to keep
|
||||
in sync.
|
||||
|
||||
### 4.1 The marker records its language
|
||||
|
||||
`write_marker()` gains `language`, and `is_current()` gains a matching check:
|
||||
|
||||
```json
|
||||
{"speaker": "...", "text": "...", "language": "de", "voice_fp": "...", "instruct": "..."}
|
||||
```
|
||||
|
||||
This is what lets an export *prove* what it shipped. Without it, a German clip is
|
||||
indistinguishable from an English one at the byte level — the same declared-vs-measured
|
||||
hole that `release.py` already pins chapters against.
|
||||
|
||||
Note `voice_fp` needs no new references: per spec 29 characters keep their existing
|
||||
refs and the narrator uses `narrator_ref.wav`, so `voice_fp` is unchanged across
|
||||
languages.
|
||||
|
||||
## 5. Selection state
|
||||
|
||||
A separate cookie `active_language`, defaulting to the project's source language.
|
||||
Same mechanism as `active_project`, deliberately separate so that switching language
|
||||
can never disturb project selection.
|
||||
|
||||
Selection is **additive and reversible**. Switching to German and back to English must
|
||||
return byte-identical English state — no destructive toggle, no "last language wins".
|
||||
|
||||
## 6. UI
|
||||
|
||||
### 6.1 Selector
|
||||
|
||||
Language selector in the header, beside the existing "Active project" badge, because it
|
||||
changes what *every* page shows. Putting it on one page would make the other pages
|
||||
lie by omission.
|
||||
|
||||
### 6.2 What is translated and what is not
|
||||
|
||||
Per the requirement, the split is:
|
||||
|
||||
| English (always) | Selected language (content only) |
|
||||
|---|---|
|
||||
| Nav, buttons, labels, statuses | Utterance text |
|
||||
| Column headers, badges | Chapter titles from the book |
|
||||
| Error and QA messages | Reviewer statements / notes |
|
||||
|
||||
So selecting German gives you a German audiobook under English controls. The chrome
|
||||
stays readable to someone who does not speak the target language — which is also who
|
||||
has to operate the review UI.
|
||||
|
||||
### 6.3 Missing translations must be visible
|
||||
|
||||
If German is selected and utterance 47 has no German text, the UI shows the English
|
||||
source **labelled as a fallback** — never English presented as if it were German.
|
||||
A silent fallback is how a reviewer would ship a book that is half untranslated and
|
||||
never know. Coverage is shown as a first-class number, not a footnote.
|
||||
|
||||
## 7. UmbraTrans handoff
|
||||
|
||||
**Export** (UmbraAudio → UmbraTrans), one row per utterance:
|
||||
|
||||
```
|
||||
{utterance_id, chapter_id, chapter_index, utterance_index, speaker_name, source_text, context_before, context_after}
|
||||
```
|
||||
|
||||
`context_before/after` matter: a line like "Yes." is untranslatable in isolation.
|
||||
|
||||
**Import** (UmbraTrans → UmbraAudio) returns the same shape plus `language_code` and
|
||||
`text`. `POST /api/import/translations`, idempotent via the UNIQUE constraint,
|
||||
refusing to silently overwrite a `confirmed` row.
|
||||
|
||||
## 8. Server-side validation
|
||||
|
||||
`/v1/tts` accepts any string as `language`. An unsupported or misspelled code currently
|
||||
fails late and confusingly — Qwen3-TTS has exactly 10 `codec_language_id` keys, and an
|
||||
unknown one is not a clean error. Validate at the boundary against the supported set and
|
||||
fail fast with the list.
|
||||
|
||||
| | Languages |
|
||||
|---|---|
|
||||
| Qwen3-TTS (10) | Chinese, English, French, German, Italian, Japanese, Korean, Portuguese, Russian, Spanish |
|
||||
| Gap (5, needs Chatterbox) | Dutch, Norwegian, Polish, Arabic, Hindi |
|
||||
|
||||
The gap languages are out of scope for v1 and must be visibly marked as unsupported
|
||||
rather than silently offered.
|
||||
|
||||
## 9. Open questions for the operator
|
||||
|
||||
1. **Does the source chapter title get translated?** Titles are book content, so by the
|
||||
rule above yes — but they are also used in filenames. Needs a decision.
|
||||
2. **Speaker *names*** — `Kainda Walters Foster Murphy` is a proper noun; we
|
||||
transliterated in the samples. Same question.
|
||||
3. **Does a missing translation block generation**, or generate English and mark the
|
||||
clip as fallback? Proposal: block it, so a book cannot be half-German by accident.
|
||||
|
||||
## 10. Build order
|
||||
|
||||
1. `utterance_translations` table + migration (additive, no existing behaviour touched)
|
||||
2. Audio namespace + marker `language` field (the §2 correctness fix)
|
||||
3. Selection cookie + header selector
|
||||
4. Content rendering in the selected language + fallback labelling
|
||||
5. UmbraTrans export/import
|
||||
6. Server-side language validation
|
||||
|
||||
Steps 1–2 must land before any bulk translation, or translated audio has nowhere
|
||||
correct to go.
|
||||
|
||||
---
|
||||
|
||||
## Mockup
|
||||
|
||||
`/language-mockup` — a working page, German selected, rendering the sample chapter's
|
||||
12 utterances in each of the 10 languages from the translations already on disk. The
|
||||
language selector is functional, so switching language shows that language's text.
|
||||
238
specs/31-utterance-language-and-voiced-flag.md
Normal file
238
specs/31-utterance-language-and-voiced-flag.md
Normal file
@@ -0,0 +1,238 @@
|
||||
# Spec 31 — Utterance source language and the voiced flag
|
||||
|
||||
**Status:** agreed 2026-10-17 (operator decisions recorded in §4), being implemented.
|
||||
Extends spec 30, which remains the *target*-language design.
|
||||
|
||||
---
|
||||
|
||||
## 1. The two axes, kept apart
|
||||
|
||||
The single most important thing in this spec. Conflating these two is the design error it
|
||||
exists to prevent.
|
||||
|
||||
| | **Source axis** (this spec) | **Target axis** (spec 30) |
|
||||
|---|---|---|
|
||||
| Question | What language is this utterance's text *written in*? | What language are we rendering it *into*? |
|
||||
| Example | A Spanish line inside an English book | The whole book in German |
|
||||
| Where it lives | `utterances.language_code` | `utterance_translations.language_code` |
|
||||
| Translated? | **No** — it is story content | Yes, that is the point |
|
||||
| Cardinailty | one per utterance | one per (utterance, target language) |
|
||||
|
||||
A Spanish phrase in a Chinese scene is **not a translation target**. It is source content
|
||||
that must be *spoken in Spanish* and must be *excluded from translation*. "Bob overhears
|
||||
them speaking in German" is the test case: the German is narrative payload. The reader is
|
||||
meant to read it. Translating it destroys the scene.
|
||||
|
||||
Both axes are needed. They do not conflict, and they are not the same column.
|
||||
|
||||
## 2. What exists today — measured, not assumed
|
||||
|
||||
| Thing | State |
|
||||
|---|---|
|
||||
| `utterances` (Deal Hunter) | **4,319 rows, no language column** — one `text`, implicitly English |
|
||||
| `qwen_tts.py` language | **hardcoded `language="english"`** at lines 494 and 522 — never varies per utterance |
|
||||
| `/v1/tts` language | `language: str = Form("english")`, free-form, **not validated** against the supported set |
|
||||
| `utterance_translations` | **does not exist** (spec 30 is a proposal; only a mockup route is built) |
|
||||
| translation export/import | **does not exist** |
|
||||
| language in config | **none** — the source language is the constant `SOURCE_LANGUAGE = "en"` in the mockup |
|
||||
| `characters.status = 'voiced'` | **already means "this speaker has a voice reference"** — a *different* concept, at speaker grain |
|
||||
| mockup "translated but not voiced" | **pipeline progress** (text delivered, audio not yet rendered) — also *not* this flag |
|
||||
| sample translations | 10 languages delivered as samples; the **5 gap languages have none** |
|
||||
|
||||
Three different things in this tree say "voiced" and mean different things. §6 gives this
|
||||
flag an unambiguous name for that reason.
|
||||
|
||||
## 3. The constraint that shapes the design
|
||||
|
||||
`is_current()` (`umbraaudio/generation/staleness.py`) compares the marker's recorded `text`
|
||||
against the utterance's text. Spec 30 §2 established that audio must be namespaced per
|
||||
language or two languages destroy each other's markers.
|
||||
|
||||
This spec adds a second, finer requirement: **language must be in the marker**, because an
|
||||
utterance's language can be corrected *after* its audio was made. A Spanish line voiced as
|
||||
English is byte-comparable (the text is unchanged) but acoustically wrong. Without
|
||||
`language` in the marker, correcting the tag would not invalidate the audio, and the wrong
|
||||
render would survive every subsequent check.
|
||||
|
||||
## 4. Decisions (operator, 2026-10-17)
|
||||
|
||||
1. **The voiced flag is per-language.** It lives with the translation row, not on the
|
||||
utterance. The source language always voices everything, so a single utterance-level
|
||||
flag cannot express "voiced in English, not in German" — which is exactly the
|
||||
sentence-collapse case.
|
||||
2. **The source language tag comes from UmbraTrans during translation.** UmbraTrans is the
|
||||
authority on identification. See §9 for the sequencing consequence this creates.
|
||||
3. **A tag in an unsupported language is allowed and recorded; the render is refused.**
|
||||
Never emit a degraded voice. Fail loud, never wrong.
|
||||
4. **Scope: source side + translation alignment.**
|
||||
|
||||
## 5. Language registry
|
||||
|
||||
One module, one source of truth: `umbraaudio/languages.py`.
|
||||
|
||||
| Class | Languages | Voiceable? |
|
||||
|---|---|---|
|
||||
| Qwen3-TTS (10) | Chinese, English, French, German, Italian, Japanese, Korean, Portuguese, Russian, Spanish | yes |
|
||||
| Gap (5) | Dutch, Norwegian, Polish, Arabic, Hindi | **no** — tag recorded, render refused |
|
||||
|
||||
`sources` — where a tag can originate — is recorded so a reviewer can tell a human
|
||||
correction from a machine identification:
|
||||
|
||||
| origin | meaning |
|
||||
|---|---|
|
||||
| `unknown` | no tag yet (default) |
|
||||
| `umbratrans` | identified by the controller during translation |
|
||||
| `operator` | corrected by hand |
|
||||
|
||||
The registry is the only place the supported set is written down. `/v1/tts` validation
|
||||
(spec 30 §8) and the export filter both read it, so they cannot disagree.
|
||||
|
||||
## 6. Schema
|
||||
|
||||
### 6.1 `utterances` gains the source tag
|
||||
|
||||
```sql
|
||||
ALTER TABLE utterances ADD COLUMN language_code TEXT; -- NULL = project source language
|
||||
ALTER TABLE utterances ADD COLUMN language_origin TEXT; -- 'unknown' | 'umbratrans' | 'operator'
|
||||
```
|
||||
|
||||
NULL means "the project's source language", which keeps all 4,319 existing rows correct
|
||||
with no backfill and no ambiguity. A non-NULL value is a *deliberate* claim that the text is
|
||||
in another language.
|
||||
|
||||
**Not named `voiced`.** The per-language flag lives on the translation row (§6.2), and the
|
||||
word `voiced` is already taken twice in this tree (§2).
|
||||
|
||||
### 6.2 `utterance_translations` gains the flag
|
||||
|
||||
Spec 30 §3.2's table, verbatim, plus one column:
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS utterance_translations (
|
||||
id INTEGER PRIMARY KEY,
|
||||
project_id INTEGER NOT NULL,
|
||||
utterance_id INTEGER NOT NULL REFERENCES utterances(id),
|
||||
language_code TEXT NOT NULL, -- target language, 'de'
|
||||
language_name TEXT NOT NULL, -- 'German'
|
||||
text TEXT NOT NULL,
|
||||
status TEXT NOT NULL, -- 'draft' | 'confirmed'
|
||||
origin TEXT NOT NULL, -- 'umbratrans' | 'manual'
|
||||
source_ref TEXT,
|
||||
delivered_at TEXT,
|
||||
confirmed_at TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
voiced INTEGER NOT NULL DEFAULT 1, -- NEW: render audio for this row?
|
||||
review_reason TEXT, -- NEW: why it is not voiced
|
||||
UNIQUE(utterance_id, language_code)
|
||||
);
|
||||
```
|
||||
|
||||
`voiced = 0` means *this* target language does not voice this utterance. The canonical case
|
||||
is collapse: the source used three sentences, the target needs two, so one source utterance
|
||||
has no target counterpart. It is **not deleted** — deleting it would lose the alignment and
|
||||
make the gap unreconstructable. It is marked not-voiced with a reason and surfaced for
|
||||
review.
|
||||
|
||||
The default is `1`, so every ordinary translation is voiced and nothing changes for the
|
||||
common case.
|
||||
|
||||
### 6.3 Nothing else moves
|
||||
|
||||
English audio stays at `.umbra/chapter_audio/`. No existing column is renamed or dropped.
|
||||
Zero regression risk to the pipeline that works today — the same reasoning spec 30 §4 used.
|
||||
|
||||
## 7. Export contract (UmbraAudio → UmbraTrans)
|
||||
|
||||
One row per utterance. This extends spec 30 §7 rather than replacing it.
|
||||
|
||||
```
|
||||
{utterance_id, chapter_id, chapter_index, utterance_index, speaker_name,
|
||||
source_text, context_before, context_after,
|
||||
language_code, -- NEW: non-null only when already known
|
||||
book_language} -- NEW: the project's primary language
|
||||
```
|
||||
|
||||
`language_code` is sent when known so a re-delivery does not ask UmbraTrans to re-identify
|
||||
a line the operator already corrected. It is null on the first pass, which is normal.
|
||||
|
||||
## 8. Import contract (UmbraTrans → UmbraAudio)
|
||||
|
||||
`POST /api/import/translations`. Idempotent via `UNIQUE(utterance_id, language_code)`.
|
||||
Refuses to silently overwrite a `confirmed` row.
|
||||
|
||||
Per utterance, UmbraTrans may return:
|
||||
|
||||
| field | meaning |
|
||||
|---|---|
|
||||
| `source_language` | identified language of the *original* text. Stored on `utterances`. |
|
||||
| `text` + `language_code` | a translation *into* `language_code`. Stored in `utterance_translations`. |
|
||||
| `voiced: false` + `reason` | this utterance has no target counterpart (collapse). |
|
||||
| *(no `text`)* | the utterance is in a non-primary language — **not translated, by design**. |
|
||||
|
||||
The last row is the whole point of the source axis: a Spanish line comes back with
|
||||
`source_language: "es"` and **no translation**. That is a correct, expected response, not an
|
||||
omission to be chased.
|
||||
|
||||
An `source_language` in the gap set (Dutch, Norwegian, Polish, Arabic, Hindi) is stored and
|
||||
recorded (§4.3), and the render is refused. It is reported as an explicit refusal, not as a
|
||||
failure of the import.
|
||||
|
||||
## 9. The sequencing consequence (stated plainly)
|
||||
|
||||
Because the tag arrives *during translation* (§4.2), the source-language master is voiced
|
||||
**before any tag exists**. So today's English master of a bilingual book contains at least
|
||||
one wrong-voice line: a Spanish phrase read with English phonology.
|
||||
|
||||
That line is corrected only after a translation round-trip returns `es`. For the correction
|
||||
to actually happen, `language` must be in the staleness marker (§10) — otherwise the audio
|
||||
is never invalidated and the wrong render survives indefinitely.
|
||||
|
||||
This is a consequence of the decision, not a defect in it. It is recorded here so nobody
|
||||
later reads "the English master is complete" as "the English master is correct".
|
||||
|
||||
## 10. Staleness
|
||||
|
||||
`write_marker()` records `language` (the effective language the clip was synthesised in);
|
||||
`is_current()` compares it. A clip whose utterance language has changed since it was made is
|
||||
stale, even when its text is byte-identical.
|
||||
|
||||
This is the same class of bug the release work already fixed: a *declared* value
|
||||
(the marker says English) disagreeing with a *measured* one (the tag now says Spanish) must
|
||||
force a re-render, not pass silently.
|
||||
|
||||
## 11. Refuse-render is loud
|
||||
|
||||
An utterance whose language cannot be voiced is **not silently skipped**. It appears:
|
||||
|
||||
- in a count on the page (`N utterances cannot be voiced in <language>`),
|
||||
- in a list, with the language and the reason (`no Qwen3-TTS voice for 'nl'`),
|
||||
- and in the pipeline's completion report.
|
||||
|
||||
A deliberate hole that nobody can see is indistinguishable from a bug. The operator chose
|
||||
fail-loud (§4.3); this is the part that makes it actually loud.
|
||||
|
||||
## 12. What this does not do
|
||||
|
||||
- **Does not translate anything.** UmbraTrans does the translating; this stores what comes
|
||||
back and honours the source axis.
|
||||
- **Does not implement spec 30's UI language selector.** That remains spec 30, which can now
|
||||
build on a schema that already knows what "the source language" means per utterance.
|
||||
- **Does not backfill a language onto existing rows.** Every existing utterance is correctly
|
||||
NULL = source language. Backfilling would assert an identification nobody performed.
|
||||
- **Does not choose a voice for a foreign passage.** A character keeps their own voice
|
||||
(spec 29); only the *language* of the phonology changes.
|
||||
|
||||
## 13. Open questions
|
||||
|
||||
1. **Book-level source language.** Currently a constant (`"en"`). Should it become a column
|
||||
on `projects`, set at parse time? Needed to know what "not the primary language" means
|
||||
per book. Proposed: yes, column on `projects`, defaulting to `en`.
|
||||
2. **Character voice for a foreign line.** A character with an Australian accent reading
|
||||
Spanish — spec 29 says characters keep their accent. Does "accent" mean *English-accented
|
||||
Spanish*, or native Spanish in that character's timbre? Spec 29 leans to the latter for
|
||||
the narrator and the former for characters; unresolved for in-dialogue code-switching.
|
||||
3. **Detecting a missing language tag.** If UmbraTrans never runs, no foreign line is ever
|
||||
tagged and the master is silently all-English. A cheap heuristic (non-Latin script,
|
||||
dictionary miss) could flag candidates for review. Out of scope; noted because §9's
|
||||
correction path never fires without a tag.
|
||||
676
tests/test_audio_controller_connect.py
Normal file
676
tests/test_audio_controller_connect.py
Normal file
@@ -0,0 +1,676 @@
|
||||
"""Tests for the controller connectivity probe.
|
||||
|
||||
These exist because three consecutive runs of this tool reported the ingest
|
||||
endpoint wrongly, each time with confidence and each time in the same
|
||||
direction — describing the day the check was written rather than the state it
|
||||
was reading. None of the faults were subtle:
|
||||
|
||||
1. Six guessed paths were probed while the controller's route table was one SSH
|
||||
command away, and 404 on a guess was read as absence.
|
||||
2. Probing used GET only. The route is POST-only, so it answered GET with 404
|
||||
and looked missing for a whole session.
|
||||
3. After the route came up and answered 401, that was read as a dead end rather
|
||||
than as a live route refusing an unauthenticated request.
|
||||
|
||||
Each is pinned here. The fake client scripts exact status codes so the
|
||||
assertions are about the *interpretation* rule — which is where the faults
|
||||
were — and not about a live controller.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import pytest
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
TOOL_PATH = REPO / "scripts" / "audio_controller_connect.py"
|
||||
NODE_IMPORT = "/api/node/audio/import"
|
||||
|
||||
|
||||
def _load_tool() -> Any:
|
||||
spec = importlib.util.spec_from_file_location("audio_controller_connect", TOOL_PATH)
|
||||
assert spec and spec.loader
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
tool = _load_tool()
|
||||
|
||||
|
||||
class FakeClient:
|
||||
"""Stands in for the HTTP client, returning scripted status codes.
|
||||
|
||||
Keyed by (method, path). Any path not scripted answers 404, which is what a
|
||||
real controller does for an unrouted path, and is the case that must be
|
||||
distinguished from every routed one.
|
||||
"""
|
||||
|
||||
def __init__(self, scripted: dict[tuple[str, str], int],
|
||||
bodies: dict[tuple[str, str], Any] | None = None) -> None:
|
||||
self.scripted = scripted
|
||||
self.bodies = bodies or {}
|
||||
self.calls: list[tuple[str, str, dict[str, str]]] = []
|
||||
|
||||
def request(self, method: str, path: str, *, body: Any = None,
|
||||
headers: dict[str, str] | None = None,
|
||||
phase: str = "probe") -> tuple[int, Any, int]:
|
||||
self.calls.append((method, path, dict(headers or {})))
|
||||
status = self.scripted.get((method, path), 404)
|
||||
return status, self.bodies.get((method, path)), 1.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Finding an endpoint
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_everything_404_is_reported_as_unconfirmed_not_missing() -> None:
|
||||
"""Bug 1: a guessed path that 404s is evidence about the guess."""
|
||||
|
||||
result = tool.t04_ingest_exists(FakeClient({}))
|
||||
|
||||
assert result.status == "FAIL"
|
||||
assert result.evidence["finding"] == "UNCONFIRMED_INGEST_ENDPOINT"
|
||||
assert "route table" in result.detail
|
||||
assert "guessed" in result.detail
|
||||
|
||||
|
||||
def test_get_only_404_with_post_401_is_routed() -> None:
|
||||
"""Bug 2: a POST-only route answers GET with 404. That is not absence."""
|
||||
|
||||
result = tool.t04_ingest_exists(FakeClient({
|
||||
("GET", NODE_IMPORT): 404,
|
||||
("POST", NODE_IMPORT): 401,
|
||||
}))
|
||||
|
||||
assert result.status == "PASS"
|
||||
assert result.evidence["routed"] == f"POST {NODE_IMPORT}"
|
||||
assert result.evidence["auth_gated"] is True
|
||||
assert result.evidence["reachable"] is False
|
||||
|
||||
|
||||
def test_405_counts_as_routed() -> None:
|
||||
"""The original signal: path in the table, wrong method.
|
||||
|
||||
405 is routing, not authentication — it says the path matched a route
|
||||
registered for another method, so it must not be reported as an auth gate.
|
||||
"""
|
||||
|
||||
result = tool.t04_ingest_exists(FakeClient({
|
||||
("GET", NODE_IMPORT): 404,
|
||||
("POST", NODE_IMPORT): 405,
|
||||
}))
|
||||
|
||||
assert result.status == "PASS"
|
||||
assert result.evidence["routed"] == f"POST {NODE_IMPORT}"
|
||||
assert result.evidence["auth_gated"] is False
|
||||
assert result.evidence["handler_error"] is False
|
||||
assert result.evidence["reachable"] is False
|
||||
assert "another method" in result.detail
|
||||
|
||||
|
||||
def test_post_401_on_a_node_path_is_not_a_dead_end() -> None:
|
||||
"""Bug 3: require_node runs inside the route, so 401 proves it resolved."""
|
||||
|
||||
result = tool.t04_ingest_exists(FakeClient({("POST", NODE_IMPORT): 401}))
|
||||
|
||||
assert result.status == "PASS"
|
||||
assert result.evidence["auth_gated"] is True
|
||||
assert "credential doing its job" in result.detail
|
||||
assert "not the path going missing" in result.detail
|
||||
|
||||
|
||||
def test_csrf_403_on_a_non_node_path_is_not_routed() -> None:
|
||||
"""A 403 from CSRF middleware runs before routing and proves nothing.
|
||||
|
||||
The controller checks CSRF in middleware (app.py:246-248) and exempts only
|
||||
``/api/node`` (app.py:89). So POST /api/compute/audio/import answers 403
|
||||
whether or not a route exists — there is in fact no such route. Counting it
|
||||
would report a live ingest path that does not exist.
|
||||
"""
|
||||
|
||||
client = FakeClient({
|
||||
("POST", "/api/compute/audio/import"): 403,
|
||||
("GET", "/api/compute/audio/import"): 404,
|
||||
("GET", NODE_IMPORT): 404,
|
||||
("POST", NODE_IMPORT): 404,
|
||||
})
|
||||
result = tool.t04_ingest_exists(client)
|
||||
|
||||
assert result.status == "FAIL", "a pre-routing 403 must not count as routed"
|
||||
assert result.evidence["finding"] == "UNCONFIRMED_INGEST_ENDPOINT"
|
||||
|
||||
# The discounted probe must survive into the report, otherwise the next
|
||||
# reader sees only 404s and re-litigates the same false positive.
|
||||
masked = result.evidence["csrf_masked_probe"]
|
||||
assert "middleware that runs before routing" in masked
|
||||
assert "/api/compute/audio/import" in masked
|
||||
|
||||
per_path = result.evidence["probed"]["/api/compute/audio/import"]
|
||||
assert "csrf_token_missing" in per_path["POST_note"]
|
||||
assert per_path["POST"] == 403
|
||||
assert per_path["GET"] == 404
|
||||
|
||||
|
||||
def test_403_on_a_node_path_is_routed_because_that_prefix_is_csrf_exempt() -> None:
|
||||
result = tool.t04_ingest_exists(FakeClient({("POST", NODE_IMPORT): 403}))
|
||||
|
||||
assert result.status == "PASS"
|
||||
assert result.evidence["auth_gated"] is True
|
||||
|
||||
|
||||
def test_handler_error_is_distinguished_from_absent() -> None:
|
||||
result = tool.t04_ingest_exists(FakeClient({("POST", NODE_IMPORT): 500}))
|
||||
|
||||
assert result.status == "PASS"
|
||||
assert result.evidence["handler_error"] is True
|
||||
assert result.evidence["auth_gated"] is False
|
||||
assert "handler is reachable" in result.detail
|
||||
|
||||
|
||||
def test_credential_is_forwarded_to_the_probe() -> None:
|
||||
"""Probing without the credential made a live route look permanently gated."""
|
||||
|
||||
client = FakeClient({("POST", NODE_IMPORT): 401})
|
||||
tool.t04_ingest_exists(client, {"Authorization": "Bearer <key>"})
|
||||
|
||||
assert client.calls, "no request was made"
|
||||
assert all(h.get("Authorization") for _, _, h in client.calls)
|
||||
|
||||
|
||||
def test_probe_without_credential_omits_headers_cleanly() -> None:
|
||||
client = FakeClient({("POST", NODE_IMPORT): 401})
|
||||
result = tool.t04_ingest_exists(client)
|
||||
|
||||
assert result.status == "PASS"
|
||||
assert client.calls[0][2] == {}
|
||||
|
||||
|
||||
def test_first_routed_path_in_candidate_order_wins() -> None:
|
||||
"""The real route is first in INGEST_CANDIDATES and must be the one named."""
|
||||
|
||||
client = FakeClient({
|
||||
("POST", NODE_IMPORT): 401,
|
||||
("POST", "/api/compute/audio/import"): 403,
|
||||
("GET", "/api/compute/audio/import"): 404,
|
||||
})
|
||||
result = tool.t04_ingest_exists(client)
|
||||
|
||||
assert result.evidence["routed"] == f"POST {NODE_IMPORT}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Readiness verdicts
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _readiness(evidence: dict[str, Any] | None, **kwargs: Any) -> tuple[str, list[str]]:
|
||||
"""Build a routed result from a bare evidence dict and ask for a verdict."""
|
||||
|
||||
routed = None
|
||||
if evidence is not None:
|
||||
routed = tool.Result("T04", "Ingest endpoint exists", "PASS", "detail", evidence)
|
||||
kwargs.setdefault("missing_ingest", evidence is None)
|
||||
return tool._readiness(routed, **kwargs)
|
||||
|
||||
|
||||
def test_readiness_names_unconfirmed_path_without_claiming_absence() -> None:
|
||||
verdict, rationale = _readiness(None)
|
||||
|
||||
assert verdict == "INGEST_PATH_NOT_CONFIRMED"
|
||||
assert any("UNCONFIRMED" in line for line in rationale)
|
||||
|
||||
|
||||
def test_readiness_auth_required_is_separate_from_needs_restart() -> None:
|
||||
verdict, rationale = _readiness({
|
||||
"routed": f"POST {NODE_IMPORT}", "status": 401, "reachable": False,
|
||||
"auth_gated": True, "handler_error": False,
|
||||
})
|
||||
|
||||
assert verdict == "INGEST_ROUTED_AUTH_REQUIRED"
|
||||
assert any("401/403" in line for line in rationale)
|
||||
|
||||
|
||||
def test_readiness_handler_error_is_not_a_restart_request() -> None:
|
||||
"""After the restart happened, the verdict must stop asking for one."""
|
||||
|
||||
verdict, _ = _readiness({
|
||||
"routed": f"POST {NODE_IMPORT}", "status": 500, "reachable": False,
|
||||
"auth_gated": False, "handler_error": True,
|
||||
})
|
||||
|
||||
assert verdict == "INGEST_ROUTED_HANDLER_ERROR"
|
||||
assert "RESTART" not in verdict
|
||||
|
||||
|
||||
def test_readiness_ready_only_when_serving() -> None:
|
||||
verdict, _ = _readiness({
|
||||
"routed": f"POST {NODE_IMPORT}", "status": 200, "reachable": True,
|
||||
"auth_gated": False, "handler_error": False,
|
||||
})
|
||||
|
||||
assert verdict == "READY_FOR_PUSH"
|
||||
|
||||
|
||||
def test_readiness_routed_but_not_serving_is_named() -> None:
|
||||
verdict, _ = _readiness({
|
||||
"routed": f"POST {NODE_IMPORT}", "status": 405, "reachable": False,
|
||||
"auth_gated": False, "handler_error": False,
|
||||
})
|
||||
|
||||
assert verdict == "INGEST_ROUTED_NOT_SERVING"
|
||||
|
||||
|
||||
def test_readiness_422_means_parsing_not_broken() -> None:
|
||||
"""A 422 against an empty probe body is the schema working, not a fault."""
|
||||
|
||||
verdict, rationale = _readiness({
|
||||
"routed": f"POST {NODE_IMPORT}", "status": 422, "reachable": False,
|
||||
"auth_gated": False, "handler_error": False, "schema_rejected": True,
|
||||
})
|
||||
|
||||
assert verdict == "INGEST_ROUTED_AWAITING_BUNDLE"
|
||||
assert any("parsing" in line for line in rationale)
|
||||
|
||||
|
||||
def test_a_real_dry_run_failure_outranks_transport_observations() -> None:
|
||||
"""Once T05 has run, connectivity is settled and the data is the story."""
|
||||
|
||||
t05 = tool.Result("T05", "Dry-run validation", "FAIL",
|
||||
"dry run rejected (500); content_hash mismatch", {})
|
||||
verdict, rationale = _readiness(
|
||||
{"routed": f"POST {NODE_IMPORT}", "status": 422, "reachable": False,
|
||||
"auth_gated": False, "handler_error": False, "schema_rejected": True},
|
||||
missing_ingest=False, t05=t05)
|
||||
|
||||
assert verdict == "DRY_RUN_REJECTED"
|
||||
assert any("content_hash mismatch" in line for line in rationale)
|
||||
assert any("Nothing was pushed" in line for line in rationale)
|
||||
|
||||
|
||||
def test_a_passing_dry_run_still_requires_confirmation() -> None:
|
||||
t05 = tool.Result("T05", "Dry-run validation", "PASS",
|
||||
"controller accepted the bundle", {"status": "dry_run"})
|
||||
verdict, rationale = _readiness(
|
||||
{"routed": f"POST {NODE_IMPORT}", "status": 200, "reachable": True,
|
||||
"auth_gated": False, "handler_error": False},
|
||||
missing_ingest=False, t05=t05)
|
||||
|
||||
assert verdict == "DRY_RUN_OK_CONFIRMATION_REQUIRED"
|
||||
assert any("not an import" in line for line in rationale)
|
||||
assert any("Nothing has been pushed" in line for line in rationale)
|
||||
|
||||
|
||||
def test_dry_run_blocked_does_not_override_transport_state() -> None:
|
||||
"""A bundle that was never supplied must not be reported as a rejection."""
|
||||
|
||||
t05 = tool.Result("T05", "Dry-run validation", "BLOCKED",
|
||||
"no --bundle given", {"ran": False})
|
||||
verdict, _ = _readiness(
|
||||
{"routed": f"POST {NODE_IMPORT}", "status": 401, "reachable": False,
|
||||
"auth_gated": True, "handler_error": False},
|
||||
missing_ingest=False, t05=t05)
|
||||
|
||||
assert verdict == "INGEST_ROUTED_AUTH_REQUIRED"
|
||||
|
||||
|
||||
def test_no_verdict_still_claims_a_restart_after_the_restart_happened() -> None:
|
||||
"""INGEST_ROUTED_NEEDS_RESTART survived the restart it was describing."""
|
||||
|
||||
seen = set()
|
||||
for evidence in (
|
||||
None,
|
||||
{"routed": f"POST {NODE_IMPORT}", "status": 401, "reachable": False,
|
||||
"auth_gated": True, "handler_error": False},
|
||||
{"routed": f"POST {NODE_IMPORT}", "status": 500, "reachable": False,
|
||||
"auth_gated": False, "handler_error": True},
|
||||
{"routed": f"POST {NODE_IMPORT}", "status": 405, "reachable": False,
|
||||
"auth_gated": False, "handler_error": False},
|
||||
{"routed": f"POST {NODE_IMPORT}", "status": 200, "reachable": True,
|
||||
"auth_gated": False, "handler_error": False},
|
||||
):
|
||||
verdict, _ = _readiness(evidence)
|
||||
seen.add(verdict)
|
||||
|
||||
assert "INGEST_ROUTED_NEEDS_RESTART" not in seen
|
||||
|
||||
|
||||
def test_every_readiness_branch_carries_the_standing_facts() -> None:
|
||||
"""The bundle story and the sequenced tests are true in every state."""
|
||||
|
||||
for evidence in (
|
||||
None,
|
||||
{"routed": f"POST {NODE_IMPORT}", "status": 401, "reachable": False,
|
||||
"auth_gated": True, "handler_error": False},
|
||||
{"routed": f"POST {NODE_IMPORT}", "status": 500, "reachable": False,
|
||||
"auth_gated": False, "handler_error": True},
|
||||
{"routed": f"POST {NODE_IMPORT}", "status": 200, "reachable": True,
|
||||
"auth_gated": False, "handler_error": False},
|
||||
):
|
||||
_, rationale = _readiness(evidence)
|
||||
assert any("metadata bundle" in line for line in rationale)
|
||||
assert any("T05-T08 are sequenced" in line for line in rationale)
|
||||
|
||||
|
||||
def test_every_non_ready_verdict_says_nothing_was_pushed() -> None:
|
||||
"""Only READY_FOR_PUSH may omit it; every other state must state it plainly."""
|
||||
|
||||
non_ready = (
|
||||
None,
|
||||
{"routed": f"POST {NODE_IMPORT}", "status": 401, "reachable": False,
|
||||
"auth_gated": True, "handler_error": False},
|
||||
{"routed": f"POST {NODE_IMPORT}", "status": 500, "reachable": False,
|
||||
"auth_gated": False, "handler_error": True},
|
||||
{"routed": f"POST {NODE_IMPORT}", "status": 405, "reachable": False,
|
||||
"auth_gated": False, "handler_error": False},
|
||||
)
|
||||
for evidence in non_ready:
|
||||
verdict, rationale = _readiness(evidence)
|
||||
assert verdict != "READY_FOR_PUSH"
|
||||
assert any("pushed" in line for line in rationale), (
|
||||
f"{verdict} does not say whether anything was pushed"
|
||||
)
|
||||
|
||||
verdict, rationale = _readiness({
|
||||
"routed": f"POST {NODE_IMPORT}", "status": 200, "reachable": True,
|
||||
"auth_gated": False, "handler_error": False,
|
||||
})
|
||||
assert verdict == "READY_FOR_PUSH"
|
||||
assert not any("Nothing was pushed" in line for line in rationale)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Credential handling
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_node_key_is_sent_as_bearer_not_x_node_key(monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path) -> None:
|
||||
"""require_node parses only 'bearer ' (routes_compute.py:477).
|
||||
|
||||
Sending X-Node-Key meant a perfectly valid key came back rejected.
|
||||
"""
|
||||
|
||||
monkeypatch.setenv("UMBRA_AUDIO_NODE_KEY", "<secret>")
|
||||
monkeypatch.delenv("UMBRA_AUDIO_API_TOKEN", raising=False)
|
||||
log = tool.SyncLog(tmp_path / "log.jsonl")
|
||||
client = tool.Client.__new__(tool.Client)
|
||||
|
||||
headers, how = client.authenticate(log)
|
||||
|
||||
assert how == "node_bearer"
|
||||
assert "X-Node-Key" not in headers
|
||||
assert headers["Authorization"] == "Bearer <secret>"
|
||||
|
||||
|
||||
def test_service_token_falls_back_when_no_node_key(monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path) -> None:
|
||||
monkeypatch.delenv("UMBRA_AUDIO_NODE_KEY", raising=False)
|
||||
monkeypatch.setenv("UMBRA_AUDIO_API_TOKEN", "<token>")
|
||||
log = tool.SyncLog(tmp_path / "log.jsonl")
|
||||
client = tool.Client.__new__(tool.Client)
|
||||
|
||||
headers, how = client.authenticate(log)
|
||||
|
||||
assert how == "service_token_bearer"
|
||||
assert headers["Authorization"] == "Bearer <token>"
|
||||
|
||||
|
||||
def test_no_credential_configured_returns_empty_headers(monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path) -> None:
|
||||
monkeypatch.delenv("UMBRA_AUDIO_NODE_KEY", raising=False)
|
||||
monkeypatch.delenv("UMBRA_AUDIO_API_TOKEN", raising=False)
|
||||
log = tool.SyncLog(tmp_path / "log.jsonl")
|
||||
client = tool.Client.__new__(tool.Client)
|
||||
|
||||
headers, how = client.authenticate(log)
|
||||
|
||||
assert headers == {}
|
||||
assert how == "none"
|
||||
|
||||
|
||||
def test_auth_log_records_a_mechanism_but_never_the_key(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
"""The key goes in an env var and out in a header, never into a log line."""
|
||||
|
||||
secret = "YMcrD3QlREDACTEDTESTKEY"
|
||||
monkeypatch.setenv("UMBRA_AUDIO_NODE_KEY", secret)
|
||||
monkeypatch.delenv("UMBRA_AUDIO_API_TOKEN", raising=False)
|
||||
path = tmp_path / "log.jsonl"
|
||||
log = tool.SyncLog(path)
|
||||
client = tool.Client.__new__(tool.Client)
|
||||
|
||||
client.authenticate(log)
|
||||
log.write("discovery", "example", endpoint="/api/node/agent")
|
||||
|
||||
text = path.read_text(encoding="utf-8")
|
||||
assert secret not in text
|
||||
entries = [json.loads(line) for line in text.splitlines() if line.strip()]
|
||||
assert any(e.get("action") == "try_node_key" for e in entries)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dry-run safety
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_dry_run_is_blocked_without_a_bundle(tmp_path: Path) -> None:
|
||||
result = tool.t05_dry_run(FakeClient({}), None, {})
|
||||
|
||||
assert result.status == "BLOCKED"
|
||||
assert result.evidence["ran"] is False
|
||||
|
||||
|
||||
def test_dry_run_is_blocked_on_an_incomplete_bundle(tmp_path: Path) -> None:
|
||||
(tmp_path / "bundle.json").write_text("{}", encoding="utf-8")
|
||||
|
||||
result = tool.t05_dry_run(FakeClient({}), str(tmp_path), {})
|
||||
|
||||
assert result.status == "BLOCKED"
|
||||
assert "manifest.jsonl" in result.detail
|
||||
|
||||
|
||||
def test_dry_run_never_claims_to_have_written(tmp_path: Path) -> None:
|
||||
"""Whatever happens, a dry run must not report a write."""
|
||||
|
||||
(tmp_path / "bundle.json").write_text(
|
||||
json.dumps({"bundle_format_version": 1,
|
||||
"content_hash": {"value": "0" * 64}}), encoding="utf-8")
|
||||
(tmp_path / "manifest.jsonl").write_text(
|
||||
json.dumps({"id": "b-1", "kind": "book"}) + "\n", encoding="utf-8")
|
||||
|
||||
for status in (200, 400, 422, 500):
|
||||
client = FakeClient({("POST", NODE_IMPORT): status},
|
||||
bodies={("POST", NODE_IMPORT): {"status": "dry_run"}})
|
||||
result = tool.t05_dry_run(client, str(tmp_path), {})
|
||||
|
||||
assert result.evidence["dry_run"] is True
|
||||
assert result.evidence["wrote"] is False
|
||||
assert result.evidence["confirm"] is None
|
||||
assert result.evidence["records_sent"] == 1
|
||||
|
||||
|
||||
def test_dry_run_passes_only_on_a_2xx(tmp_path: Path) -> None:
|
||||
(tmp_path / "bundle.json").write_text(
|
||||
json.dumps({"bundle_format_version": 1,
|
||||
"content_hash": {"value": "0" * 64}}), encoding="utf-8")
|
||||
(tmp_path / "manifest.jsonl").write_text(
|
||||
json.dumps({"id": "b-1", "kind": "book"}) + "\n", encoding="utf-8")
|
||||
|
||||
passed = tool.t05_dry_run(
|
||||
FakeClient({("POST", NODE_IMPORT): 200},
|
||||
bodies={("POST", NODE_IMPORT): {"status": "dry_run",
|
||||
"content_hash": "a" * 64}}),
|
||||
str(tmp_path), {})
|
||||
failed = tool.t05_dry_run(FakeClient({("POST", NODE_IMPORT): 500}), str(tmp_path), {})
|
||||
|
||||
assert passed.status == "PASS"
|
||||
assert passed.evidence["content_hash"] == "a" * 64
|
||||
assert failed.status == "FAIL"
|
||||
assert failed.evidence["status_code"] == 500
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# What T06/T07/T08 claim, once a dry run can actually pass
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# These tests exist because all three carried narratives written for the era
|
||||
# when the bundle was rejected, and each kept asserting a reason that had
|
||||
# stopped being true: T06 said "T05 does not get [a content_hash] while the
|
||||
# bundle is rejected", T07 said the controller "has not yet issued a manifest
|
||||
# format", T08 said there was no ingest endpoint. Every one of those was once
|
||||
# accurate, and every one survived its own refutation. A BLOCKED test is a
|
||||
# finding, so a stale BLOCKED reason is a finding about the wrong world.
|
||||
|
||||
|
||||
def _write_bundle(tmp_path: Path, records: int = 1) -> None:
|
||||
(tmp_path / "bundle.json").write_text(
|
||||
json.dumps({"bundle_format_version": 1,
|
||||
"content_hash": {"value": "0" * 64}}), encoding="utf-8")
|
||||
(tmp_path / "manifest.jsonl").write_text(
|
||||
"".join(json.dumps({"id": f"b-{i}", "kind": "book"}) + "\n"
|
||||
for i in range(records)), encoding="utf-8")
|
||||
|
||||
|
||||
def _passing_t05(digest: str = "d" * 64,
|
||||
counts: dict[str, int] | None = None) -> Any:
|
||||
return tool.Result(
|
||||
"T05", "Dry-run validation", "PASS", "accepted",
|
||||
{"status_code": 200, "reported_status": "dry_run",
|
||||
"content_hash": "0" * 64, "response_digest": digest,
|
||||
"counts": counts if counts is not None
|
||||
else {"chapters": 1, "speakers": 0, "utterances": 0, "artifacts": 0}})
|
||||
|
||||
|
||||
def test_t06_does_not_run_a_canned_reason_when_t05_failed(tmp_path: Path) -> None:
|
||||
"""The old bug: T06 narrated why it could not run, whatever T05 said."""
|
||||
|
||||
_write_bundle(tmp_path)
|
||||
rejected = tool.Result("T05", "Dry-run validation", "FAIL", "500", {})
|
||||
|
||||
result = tool.t06_idempotency(FakeClient({}), str(tmp_path), {}, rejected)
|
||||
|
||||
assert result.status == "BLOCKED"
|
||||
assert result.evidence.get("ran") is False
|
||||
|
||||
|
||||
def test_t06_blocked_without_a_bundle_or_a_first_response() -> None:
|
||||
assert tool.t06_idempotency(FakeClient({}), None, {}, None).evidence["ran"] is False
|
||||
assert tool.t06_idempotency(
|
||||
FakeClient({}), None, {}, _passing_t05()).evidence["ran"] is False
|
||||
|
||||
|
||||
def test_t06_passes_when_two_dry_runs_agree(tmp_path: Path) -> None:
|
||||
_write_bundle(tmp_path)
|
||||
body = {"status": "dry_run", "content_hash": "0" * 64,
|
||||
"counts": {"chapters": 1}}
|
||||
first = tool.t05_dry_run(
|
||||
FakeClient({("POST", NODE_IMPORT): 200},
|
||||
bodies={("POST", NODE_IMPORT): body}), str(tmp_path), {})
|
||||
|
||||
second = tool.t06_idempotency(
|
||||
FakeClient({("POST", NODE_IMPORT): 200},
|
||||
bodies={("POST", NODE_IMPORT): body}),
|
||||
str(tmp_path), {}, first)
|
||||
|
||||
assert first.status == "PASS"
|
||||
assert second.status == "PASS"
|
||||
assert second.evidence["first_digest"] == second.evidence["second_digest"]
|
||||
|
||||
|
||||
def test_t06_fails_when_two_identical_dry_runs_disagree(tmp_path: Path) -> None:
|
||||
"""Non-determinism means the plan is not a function of the bundle."""
|
||||
|
||||
_write_bundle(tmp_path)
|
||||
first = _passing_t05(digest="d" * 64)
|
||||
|
||||
drifted = tool.t06_idempotency(
|
||||
FakeClient({("POST", NODE_IMPORT): 200},
|
||||
bodies={("POST", NODE_IMPORT): {"status": "dry_run",
|
||||
"content_hash": "1" * 64}}),
|
||||
str(tmp_path), {}, first)
|
||||
|
||||
assert drifted.status == "FAIL"
|
||||
|
||||
|
||||
def test_t06_names_what_it_cannot_prove(tmp_path: Path) -> None:
|
||||
"""Passing on determinism must not read as proof of re-import safety."""
|
||||
|
||||
_write_bundle(tmp_path)
|
||||
body = {"status": "dry_run", "content_hash": "0" * 64}
|
||||
first = tool.t05_dry_run(
|
||||
FakeClient({("POST", NODE_IMPORT): 200},
|
||||
bodies={("POST", NODE_IMPORT): body}), str(tmp_path), {})
|
||||
|
||||
result = tool.t06_idempotency(
|
||||
FakeClient({("POST", NODE_IMPORT): 200},
|
||||
bodies={("POST", NODE_IMPORT): body}),
|
||||
str(tmp_path), {}, first)
|
||||
|
||||
assert result.status == "PASS"
|
||||
assert "no_change" not in result.detail.split(".")[0]
|
||||
assert "NOT covered" in result.detail
|
||||
assert "in-memory store" in result.detail
|
||||
|
||||
|
||||
def test_t07_reconciles_counts_and_allows_for_the_uncounted_book_record() -> None:
|
||||
"""32+55+4319+4477 = 8883, and the 8884th record is the book."""
|
||||
|
||||
result = tool.t07_schema_contract(
|
||||
_passing_t05(counts={"chapters": 32, "speakers": 55,
|
||||
"utterances": 4319, "artifacts": 4477}),
|
||||
8884)
|
||||
|
||||
assert result.status == "PASS"
|
||||
assert result.evidence["delta"] == 1
|
||||
|
||||
|
||||
def test_t07_fails_when_the_controller_drops_records(tmp_path: Path) -> None:
|
||||
"""A controller that silently ignores a record kind would otherwise pass."""
|
||||
|
||||
result = tool.t07_schema_contract(
|
||||
_passing_t05(counts={"chapters": 1, "speakers": 0,
|
||||
"utterances": 0, "artifacts": 0}),
|
||||
5)
|
||||
|
||||
assert result.status == "FAIL"
|
||||
assert result.evidence["delta"] == 4
|
||||
|
||||
|
||||
def test_t07_blocked_until_a_bundle_is_accepted() -> None:
|
||||
result = tool.t07_schema_contract(
|
||||
tool.Result("T05", "Dry-run validation", "FAIL", "500", {}), 1)
|
||||
|
||||
assert result.status == "BLOCKED"
|
||||
|
||||
|
||||
def test_t08_no_longer_blames_an_endpoint_that_routs() -> None:
|
||||
"""The specific stale claim T08 used to make."""
|
||||
|
||||
result = tool.t08_audit_round_trip(FakeClient({}))
|
||||
|
||||
assert result.status == "BLOCKED"
|
||||
assert "without an ingest endpoint" not in result.detail
|
||||
assert "depends on T04" not in result.detail
|
||||
|
||||
|
||||
def test_t08_names_the_actual_blocker_so_a_fix_has_to_update_it() -> None:
|
||||
"""Wiring a persistence adapter must force this text to change.
|
||||
|
||||
If someone implements a Postgres AudioImportStore, this test fails and
|
||||
makes them re-decide T08 rather than leave a blocked verdict describing a
|
||||
store that no longer exists.
|
||||
"""
|
||||
|
||||
result = tool.t08_audit_round_trip(FakeClient({}))
|
||||
|
||||
assert "InMemoryAudioImportStore" in result.detail
|
||||
assert "uuid4" in result.detail
|
||||
477
tests/test_controller_bundle.py
Normal file
477
tests/test_controller_bundle.py
Normal file
@@ -0,0 +1,477 @@
|
||||
"""Tests for the controller bundle builder.
|
||||
|
||||
The builder's job is to emit something the *controller's* validator accepts, so
|
||||
most of these tests build a small synthetic project and run the real validator
|
||||
against the result. Running the actual validator -- copied into the bundle by the
|
||||
builder -- is the point: a test that re-implements the checks would only prove the
|
||||
test agrees with itself.
|
||||
|
||||
The synthetic project is built rather than the real one, so the suite does not
|
||||
depend on 4,000 utterances and 4,500 audio files being present and current. The
|
||||
real project is exercised by ``scripts/audit_controller_bundle.py``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
BUILDER_PATH = REPO / "scripts" / "build_controller_bundle.py"
|
||||
VALIDATOR_SOURCE = Path("/tmp/controller-validator.py")
|
||||
|
||||
|
||||
def _load_builder() -> Any:
|
||||
spec = importlib.util.spec_from_file_location("build_controller_bundle", BUILDER_PATH)
|
||||
assert spec and spec.loader
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
builder = _load_builder()
|
||||
|
||||
|
||||
SCHEMA = """
|
||||
CREATE TABLE project (
|
||||
id INTEGER PRIMARY KEY, name TEXT NOT NULL, source_path TEXT NOT NULL,
|
||||
source_format TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
status TEXT NOT NULL DEFAULT 'initialized'
|
||||
);
|
||||
CREATE TABLE chapters (
|
||||
id INTEGER PRIMARY KEY, project_id INTEGER NOT NULL REFERENCES project(id),
|
||||
chapter_index INTEGER NOT NULL, title TEXT, source_text TEXT NOT NULL,
|
||||
word_count INTEGER NOT NULL DEFAULT 0, status TEXT NOT NULL DEFAULT 'parsed',
|
||||
audio_path TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE TABLE utterances (
|
||||
id INTEGER PRIMARY KEY, chapter_id INTEGER NOT NULL REFERENCES chapters(id),
|
||||
character_id INTEGER REFERENCES characters(id),
|
||||
utterance_index INTEGER NOT NULL, text TEXT NOT NULL,
|
||||
type TEXT NOT NULL DEFAULT 'narration', context_before TEXT, context_after TEXT,
|
||||
emotion TEXT, pace TEXT, emphasis_words TEXT, pause_before_ms INTEGER,
|
||||
pause_after_ms INTEGER, instruct TEXT, speaker_name TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'pending'
|
||||
);
|
||||
CREATE TABLE characters (id INTEGER PRIMARY KEY, project_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL, aliases TEXT NOT NULL DEFAULT '[]', gender TEXT,
|
||||
is_narrator INTEGER NOT NULL DEFAULT 0);
|
||||
"""
|
||||
|
||||
|
||||
def make_project(root: Path, *, narrator_spellings: tuple[str, ...] = ("narrator",),
|
||||
null_narrator_rows: int = 2, chapters: int = 3) -> Path:
|
||||
"""A project shaped like the real one, including its awkward edges."""
|
||||
|
||||
umbra = root / ".umbra"
|
||||
(umbra / "chapter_audio").mkdir(parents=True)
|
||||
(umbra / "voices").mkdir(parents=True)
|
||||
db = umbra / "project.db"
|
||||
conn = sqlite3.connect(db)
|
||||
conn.executescript(SCHEMA)
|
||||
conn.execute(
|
||||
"INSERT INTO project (id, name, source_path, source_format, status) "
|
||||
"VALUES (1, ?, ?, 'docx', 'analyzed')", (root.name, str(root)))
|
||||
for index in range(chapters):
|
||||
conn.execute(
|
||||
"INSERT INTO chapters (id, project_id, chapter_index, title, source_text, word_count) "
|
||||
"VALUES (?, 1, ?, ?, 'body text', 10)",
|
||||
(index + 1, index - 1, f"Chapter {index}"))
|
||||
utterance_id = 0
|
||||
for chapter in range(1, chapters + 1):
|
||||
for position in range(2):
|
||||
utterance_id += 1
|
||||
is_narrator_row = position == 0
|
||||
speaker = narrator_spellings[chapter % len(narrator_spellings)] \
|
||||
if is_narrator_row else "Kainda"
|
||||
conn.execute(
|
||||
"INSERT INTO utterances (id, chapter_id, utterance_index, text, "
|
||||
"type, speaker_name) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(utterance_id, chapter, position,
|
||||
f"line {utterance_id}", "narration" if is_narrator_row else "dialogue",
|
||||
speaker))
|
||||
# A row whose speaker is expressed by being empty, as the real data does.
|
||||
for _ in range(null_narrator_rows if chapter == 1 else 0):
|
||||
utterance_id += 1
|
||||
conn.execute(
|
||||
"INSERT INTO utterances (id, chapter_id, utterance_index, text, "
|
||||
"type, speaker_name) VALUES (?, ?, ?, ?, 'narration', NULL)",
|
||||
(utterance_id, chapter, 99, "implicit narration"))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
(umbra / "speakers.json").write_text(json.dumps({
|
||||
"speakers": [
|
||||
{"primary_name": "narrator", "is_narrator": True, "aliases": [],
|
||||
"chapters_appeared": [1, 2, 3], "has_voice_ref": True},
|
||||
{"primary_name": "Kainda", "aliases": ["Princess Kainda"],
|
||||
"chapters_appeared": [1], "has_voice_ref": True},
|
||||
],
|
||||
"book_title": "Test Book", "total_chapters": chapters, "total_words": 30,
|
||||
}))
|
||||
(umbra / "voice_status.json").write_text(json.dumps({"narrator": "approved"}))
|
||||
(umbra / "voice_engines.json").write_text(json.dumps(
|
||||
{"Kainda": {"engine": "qwen", "reason": "ok", "at": "2026-10-10T00:00:00+00:00"}}))
|
||||
(umbra / "voice_assignments.json").write_text(json.dumps({"narrator": "en-US-GuyNeural"}))
|
||||
return root
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def built(tmp_path: Path) -> Path:
|
||||
make_project(tmp_path / "TestProject")
|
||||
out = tmp_path / "bundle"
|
||||
builder.build("TestProject", out, "2026-10-14T00:00:00Z",
|
||||
VALIDATOR_SOURCE if VALIDATOR_SOURCE.exists() else None,
|
||||
source_root=tmp_path)
|
||||
return out
|
||||
|
||||
|
||||
def read_manifest(bundle: Path) -> list[dict[str, Any]]:
|
||||
return [json.loads(line) for line in
|
||||
(bundle / "manifest.jsonl").read_text().splitlines() if line.strip()]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# the controller's own validator
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.skipif(not VALIDATOR_SOURCE.exists(),
|
||||
reason="controller validator not fetched to /tmp")
|
||||
def test_controller_validator_accepts_the_bundle(built: Path) -> None:
|
||||
validator = built / "validate.py"
|
||||
if not validator.exists():
|
||||
pytest.skip("validator was not copied into the bundle")
|
||||
result = subprocess.run([sys.executable, str(validator), str(built)],
|
||||
capture_output=True, text=True)
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
|
||||
|
||||
@pytest.mark.skipif(not VALIDATOR_SOURCE.exists(),
|
||||
reason="controller validator not fetched to /tmp")
|
||||
@pytest.mark.parametrize("mutation,expect", [
|
||||
("dangling_id", "not in the manifest"),
|
||||
("required_file_removed", "missing required file"),
|
||||
("unitless_duration", "no unit"),
|
||||
("credential", "credential"),
|
||||
# The index declares relative_to required, so that check fires before the
|
||||
# convention check does; either message would be correct.
|
||||
("path_without_base", "relative_to"),
|
||||
])
|
||||
def test_controller_validator_rejects_a_broken_bundle(built: Path, mutation: str,
|
||||
expect: str) -> None:
|
||||
"""A validator nobody has seen fail is not evidence of anything.
|
||||
|
||||
Each case breaks one thing the request asks for and asserts the validator
|
||||
notices, so a passing bundle means something.
|
||||
"""
|
||||
|
||||
validator = built / "validate.py"
|
||||
if not validator.exists():
|
||||
pytest.skip("validator was not copied into the bundle")
|
||||
lines = (built / "manifest.jsonl").read_text().splitlines()
|
||||
|
||||
if mutation == "dangling_id":
|
||||
record = json.loads(lines[0])
|
||||
record["chapter_id"] = "ch-does-not-exist"
|
||||
lines[0] = json.dumps(record, sort_keys=True, separators=(",", ":"))
|
||||
elif mutation == "unitless_duration":
|
||||
record = json.loads(lines[0])
|
||||
record["duration"] = 1.5
|
||||
lines[0] = json.dumps(record, sort_keys=True, separators=(",", ":"))
|
||||
elif mutation == "credential":
|
||||
record = json.loads(lines[0])
|
||||
record["token"] = "sk-" + "A" * 24
|
||||
lines[0] = json.dumps(record, sort_keys=True, separators=(",", ":"))
|
||||
elif mutation == "path_without_base":
|
||||
for index, line in enumerate(lines):
|
||||
record = json.loads(line)
|
||||
if record.get("kind") == "render":
|
||||
record.pop("relative_to", None)
|
||||
record["file"] = "x.wav"
|
||||
lines[index] = json.dumps(record, sort_keys=True, separators=(",", ":"))
|
||||
break
|
||||
else:
|
||||
pytest.skip("no render record to break")
|
||||
|
||||
if mutation == "required_file_removed":
|
||||
(built / "VOCABULARY.md").unlink()
|
||||
else:
|
||||
(built / "manifest.jsonl").write_text("\n".join(lines) + "\n")
|
||||
|
||||
result = subprocess.run([sys.executable, str(validator), str(built)],
|
||||
capture_output=True, text=True)
|
||||
assert result.returncode == 1
|
||||
assert expect in result.stdout
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# structure and self-consistency
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_bundle_has_every_required_piece(built: Path) -> None:
|
||||
for name in ("README.md", "bundle.json", "VOCABULARY.md", "manifest.jsonl"):
|
||||
assert (built / name).exists(), name
|
||||
assert (built / "schemas").is_dir()
|
||||
assert (built / "examples").is_dir()
|
||||
examples = [p for p in (built / "examples").iterdir() if p.is_dir()]
|
||||
assert examples
|
||||
for book in examples:
|
||||
assert (book / "manifest.json").exists()
|
||||
assert (book / "lineage.json").exists()
|
||||
|
||||
|
||||
def test_content_hash_describes_the_file_it_ships_with(built: Path) -> None:
|
||||
import hashlib
|
||||
index = json.loads((built / "bundle.json").read_text())
|
||||
actual = hashlib.sha256((built / "manifest.jsonl").read_bytes()).hexdigest()
|
||||
assert index["content_hash"]["value"] == actual
|
||||
assert index["counts"]["records_total"] == len(read_manifest(built))
|
||||
|
||||
|
||||
def test_build_is_deterministic(tmp_path: Path) -> None:
|
||||
"""Regenerating an unchanged project must produce the same bytes.
|
||||
|
||||
Without this, the content hash is decoration: it changes on every run for
|
||||
reasons unrelated to the data, and comparing two builds tells you nothing.
|
||||
"""
|
||||
|
||||
make_project(tmp_path / "TestProject")
|
||||
first = tmp_path / "one"
|
||||
second = tmp_path / "two"
|
||||
builder.build("TestProject", first, "2026-10-14T00:00:00Z", None, source_root=tmp_path)
|
||||
builder.build("TestProject", second, "2026-10-14T00:00:00Z", None, source_root=tmp_path)
|
||||
assert (first / "manifest.jsonl").read_bytes() == (second / "manifest.jsonl").read_bytes()
|
||||
|
||||
|
||||
def test_every_reference_resolves_including_unvalidated_fields(built: Path) -> None:
|
||||
"""The controller checks some reference fields; these are the rest.
|
||||
|
||||
`speaker_id` and `render_id` are not on its list, but a dangling speaker
|
||||
reference is just as wrong, so it is checked here rather than assumed.
|
||||
"""
|
||||
|
||||
records = read_manifest(built)
|
||||
known = {r["id"] for r in records}
|
||||
for record in records:
|
||||
for field in ("book_id", "chapter_id", "speaker_id", "render_id", "voice_id"):
|
||||
value = record.get(field)
|
||||
if isinstance(value, str):
|
||||
assert value in known, f"{record['id']}.{field} -> {value}"
|
||||
for field in ("chapters", "renders", "inputs", "published_artifact_ids"):
|
||||
for value in record.get(field, []) or []:
|
||||
assert value in known, f"{record['id']}.{field} contains {value}"
|
||||
|
||||
|
||||
def test_no_field_states_a_duration_without_a_unit(built: Path) -> None:
|
||||
forbidden = {"duration", "length", "elapsed", "runtime", "time"}
|
||||
for record in read_manifest(built):
|
||||
for field in record:
|
||||
assert field.lower() not in forbidden, f"{record['id']} has {field}"
|
||||
|
||||
|
||||
def test_every_path_declares_what_it_is_relative_to(built: Path) -> None:
|
||||
for record in read_manifest(built):
|
||||
if "path" in record:
|
||||
assert record.get("relative_to") == "book_directory", record["id"]
|
||||
|
||||
|
||||
def test_no_absolute_host_paths_leak_into_the_bundle(built: Path) -> None:
|
||||
"""The bundle is committable and must not describe the machine layout."""
|
||||
|
||||
blob = "\n".join((built / "manifest.jsonl").read_text().splitlines())
|
||||
for leak in ("/Volumes/", "/Users/", str(REPO)):
|
||||
assert leak not in blob, f"absolute path leaked: {leak}"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# the findings this build exists to surface
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_narrator_written_three_ways_is_resolved_and_marked(built: Path) -> None:
|
||||
"""An empty speaker name means the narrator; it must be labelled, not dropped."""
|
||||
|
||||
records = read_manifest(built)
|
||||
utterances = [r for r in records if r["kind"] == "utterance"]
|
||||
implicit = [u for u in utterances if u["speaker_resolution"] == "implicit_narrator"]
|
||||
assert implicit, "the fixture is supposed to contain null-speaker rows"
|
||||
for utterance in implicit:
|
||||
assert utterance["speaker_name_raw"] in (None, "")
|
||||
assert utterance["speaker_id"] is not None, "an implicit narrator still has to resolve"
|
||||
for utterance in utterances:
|
||||
if utterance["speaker_resolution"] == "explicit":
|
||||
assert utterance["speaker_name_raw"] not in (None, "")
|
||||
|
||||
|
||||
def test_case_insensitive_spellings_are_not_silently_merged(tmp_path: Path) -> None:
|
||||
make_project(tmp_path / "TestProject", narrator_spellings=("Narrator", "narrator"))
|
||||
out = tmp_path / "bundle"
|
||||
builder.build("TestProject", out, "2026-10-14T00:00:00Z", None, source_root=tmp_path)
|
||||
speakers = [r for r in read_manifest(out) if r["kind"] == "speaker"
|
||||
and r["casefold_collision_group"] == "narrator"]
|
||||
assert len(speakers) == 2, "the two spellings must stay separate records"
|
||||
for speaker in speakers:
|
||||
assert speaker["utterance_count"] < speaker["utterance_count_casefold_grouped"]
|
||||
|
||||
|
||||
def test_chapter_numbering_hazard_is_declared(built: Path) -> None:
|
||||
book = next(r for r in read_manifest(built) if r["kind"] == "book")
|
||||
numbering = book["numbering"]["schemes"]
|
||||
assert numbering["database_row_id"]["is_authoritative"] is True
|
||||
assert numbering["chapter_index"]["is_authoritative"] is False
|
||||
assert numbering["distribution_track_number"]["is_authoritative"] is False
|
||||
|
||||
|
||||
def test_chapters_record_which_number_their_filenames_use(built: Path) -> None:
|
||||
for chapter in (r for r in read_manifest(built) if r["kind"] == "chapter"):
|
||||
basis = chapter["numbering_basis"]
|
||||
assert basis["file_names_use"] == "database_row_id"
|
||||
assert basis["chapter_index_is_authoritative"] is False
|
||||
|
||||
|
||||
def test_mismatched_extension_is_reported_not_hidden(built: Path) -> None:
|
||||
"""A `.wav` that is not a WAV must be visible on the book, not just per file."""
|
||||
|
||||
book = next(r for r in read_manifest(built) if r["kind"] == "book")
|
||||
for entry in book["extension_container_mismatches"]:
|
||||
assert entry["actual_container"] != entry["named_extension"]
|
||||
|
||||
|
||||
def test_speakers_without_a_profile_are_named_on_the_book(built: Path) -> None:
|
||||
book = next(r for r in read_manifest(built) if r["kind"] == "book")
|
||||
assert isinstance(book["speaker_names_without_a_profile"], list)
|
||||
for name in book["speaker_names_without_a_profile"]:
|
||||
assert not any(r["kind"] == "speaker" and r["primary_name"] == name
|
||||
and r["has_profile"] for r in read_manifest(built))
|
||||
|
||||
|
||||
def test_declared_and_measured_chapter_counts_are_both_reported(built: Path) -> None:
|
||||
book = next(r for r in read_manifest(built) if r["kind"] == "book")
|
||||
assert book["chapter_count"] == len(book["chapters"])
|
||||
assert book["declared_total_chapters"] is not None
|
||||
|
||||
|
||||
def test_language_designations_are_empty_and_explained(built: Path) -> None:
|
||||
"""Empty is a real answer here; an invented list would be a guess in a schema."""
|
||||
|
||||
book = next(r for r in read_manifest(built) if r["kind"] == "book")
|
||||
assert book["language_designations"] == []
|
||||
assert "do not record" in book["language_designation_note"] or \
|
||||
"records no per-utterance language" in book["language_designation_note"]
|
||||
|
||||
|
||||
def test_schemas_describe_every_kind_that_appears(built: Path) -> None:
|
||||
kinds = {r["kind"] for r in read_manifest(built)}
|
||||
for kind in kinds:
|
||||
assert (built / "schemas" / f"{kind}.schema.json").exists(), kind
|
||||
assert (built / "schemas" / "manifest.schema.json").exists()
|
||||
assert (built / "schemas" / "lineage.schema.json").exists()
|
||||
|
||||
|
||||
def test_required_fields_declared_in_the_index_are_actually_present(built: Path) -> None:
|
||||
index = json.loads((built / "bundle.json").read_text())
|
||||
records = read_manifest(built)
|
||||
for record in records:
|
||||
for field in index["required_fields"].get(record["kind"], []):
|
||||
assert field in record, f"{record['kind']} {record['id']} missing {field}"
|
||||
|
||||
|
||||
def test_utterance_text_hash_matches_its_own_text(built: Path) -> None:
|
||||
import hashlib
|
||||
for record in read_manifest(built):
|
||||
if record["kind"] == "utterance":
|
||||
assert hashlib.sha256(record["text"].encode()).hexdigest() == record["text_sha256"]
|
||||
assert len(record["text"]) == record["text_char_count"]
|
||||
|
||||
|
||||
def test_chapter_utterance_counts_are_true(built: Path) -> None:
|
||||
records = read_manifest(built)
|
||||
per_chapter: dict[str, int] = {}
|
||||
for record in records:
|
||||
if record["kind"] == "utterance":
|
||||
per_chapter[record["chapter_id"]] = per_chapter.get(record["chapter_id"], 0) + 1
|
||||
for chapter in (r for r in records if r["kind"] == "chapter"):
|
||||
assert chapter["utterance_count"] == per_chapter.get(chapter["id"], 0)
|
||||
|
||||
|
||||
def test_speaker_utterance_counts_are_true_and_separate_from_the_inferred_one(built: Path) -> None:
|
||||
records = read_manifest(built)
|
||||
per_speaker: dict[str, int] = {}
|
||||
for record in records:
|
||||
if record["kind"] == "utterance" and record["speaker_resolution"] == "explicit":
|
||||
per_speaker[record["speaker_name_raw"]] = \
|
||||
per_speaker.get(record["speaker_name_raw"], 0) + 1
|
||||
for speaker in (r for r in records if r["kind"] == "speaker"):
|
||||
assert speaker["utterance_count"] == per_speaker.get(speaker["primary_name"], 0)
|
||||
assert speaker["utterance_count_including_implicit_narrator"] >= speaker["utterance_count"]
|
||||
|
||||
|
||||
def test_example_is_a_readable_subset_not_a_copy(built: Path) -> None:
|
||||
"""The example exists to teach the shape; a second copy of the data cannot."""
|
||||
|
||||
example = next(p for p in (built / "examples").iterdir() if p.is_dir())
|
||||
example_records = json.loads((example / "manifest.json").read_text())
|
||||
assert 0 < len(example_records) < len(read_manifest(built))
|
||||
lineage = json.loads((example / "lineage.json").read_text())
|
||||
assert lineage["edges"], "lineage with no edges teaches nothing"
|
||||
assert all(edge.get("transform") for edge in lineage["edges"])
|
||||
assert all(edge.get("derivation") in {"direct", "derived", "assembled", "transcoded"}
|
||||
for edge in lineage["edges"])
|
||||
|
||||
|
||||
def test_generator_never_writes_to_the_project(tmp_path: Path) -> None:
|
||||
"""The audio system keeps its data and keeps running; the build only reads."""
|
||||
|
||||
project = make_project(tmp_path / "TestProject")
|
||||
before = {p: p.stat().st_mtime_ns for p in sorted(project.rglob("*")) if p.is_file()}
|
||||
builder.build("TestProject", tmp_path / "bundle", "2026-10-14T00:00:00Z", None,
|
||||
source_root=tmp_path)
|
||||
after = {p: p.stat().st_mtime_ns for p in sorted(project.rglob("*")) if p.is_file()}
|
||||
assert before == after
|
||||
|
||||
|
||||
def test_generated_at_is_the_only_difference_between_two_timestamps(tmp_path: Path) -> None:
|
||||
make_project(tmp_path / "TestProject")
|
||||
first = tmp_path / "a"
|
||||
second = tmp_path / "b"
|
||||
builder.build("TestProject", first, "2026-10-14T00:00:00Z", None, source_root=tmp_path)
|
||||
builder.build("TestProject", second, "2026-10-15T00:00:00Z", None, source_root=tmp_path)
|
||||
|
||||
def strip(path: Path) -> list[str]:
|
||||
# Everything that is a build-time stamp, not a fact about the data.
|
||||
volatile = {"generated_at", "file_modified_at"}
|
||||
return [
|
||||
json.dumps({k: v for k, v in json.loads(line).items() if k not in volatile},
|
||||
sort_keys=True)
|
||||
for line in (path / "manifest.jsonl").read_text().splitlines()
|
||||
]
|
||||
|
||||
assert strip(first) == strip(second)
|
||||
|
||||
|
||||
def test_production_time_is_not_claimed_as_build_time(tmp_path: Path) -> None:
|
||||
"""`produced_at: <build time>` would be a claim about the audio, not the export.
|
||||
|
||||
A render record must carry when the *file* was written, or nothing at all.
|
||||
"""
|
||||
|
||||
make_project(tmp_path / "TestProject")
|
||||
out = tmp_path / "bundle"
|
||||
builder.build("TestProject", out, "2026-10-14T00:00:00Z", None, source_root=tmp_path)
|
||||
for record in read_manifest(out):
|
||||
assert "produced_at" not in record, record["id"]
|
||||
# And the build timestamp never leaks into a file-time field.
|
||||
for record in read_manifest(out):
|
||||
assert record.get("file_modified_at") != "2026-10-14T00:00:00Z", record["id"]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user