Compare commits

29 Commits

Author SHA1 Message Date
B.D. Murphy
2cafbabe82 Measure whether Qwen3-TTS speaks Chinese with an English voice reference
This was the one open prerequisite for any target-language audio lane: the
survey in spec 26 says the model covers Chinese, but that is a model-card
claim, and our whole voice catalogue is English references. Nothing had been
measured about what happens when an English reference is asked to speak
Chinese.

scripts/probe_cross_language_tts.py now also takes an optional second English
reference and makes the identity question differential rather than a
transcription question - a transcript has no timbre. Two English references
reading the same text should stay as far apart when they read Chinese as
when they read English; if they do, the reference is carrying identity into
the Chinese output rather than being ignored. The measurement is made with
ECAPA-TDNN cosine similarity.

The result, on the real Deal Hunter narrator and chief-of-the-guard
references, is in logs/cross_language_tts_probe/probe-report.json:

- the English reference speaks Chinese. Whisper hears zh on every
  Chinese-text clip and en on every English-text clip, recovering 12 of the
  source's 22 distinct characters.
- the text's script decides the language, not the `language` parameter.
  Chinese text with language=english still comes back as Chinese, so a
  caller that trusted the parameter to select a language would get the
  wrong phonology without an error.
- the reference is carried: different voices are 0.383 apart reading English
  and 0.369 apart reading Chinese.

What the measurement does not establish is stated in the spec rather than
glossed: ECAPA is an English VoxCeleb model, so its Chinese embeddings are
out of domain and the same-speaker/different-speaker separation collapses in
Chinese. It can show that two clips differ; it cannot certify that two clips
are the same speaker across a language boundary. How much of the identity
survives is still a listening question, and the clips are saved for it.

Spec 26 gains the measurement and a sixth open question - whether the
identity survives for all 61 speakers, not just the two measured.

Spec 27 gains the parameterisation record (UmbraTrans 3f05489) and a
measurement of what the Chinese edition actually contains: 4,305 delivery
rows, of which the 90 not-voiced rows are 44 normal collapses and 46
drift-dropped paragraphs covering 33 paragraphs and 356 source words, about
2.4 minutes of speech. Two of the dropped paragraphs are upstream
translation defects the aligner cannot fix - ch-15-p0100 and ch-25-p0089
carry text that belongs to a different sentence - so fixing them means
re-translating.

Suite: 913 passed, 8 failed (the same pre-existing pronunciation and
voice_profile_manager accent failures), 6 skipped.
2026-11-07 06:27:09 -08:00
Umbra Tools
b259060808 Record two more consequences of the mislabelled Spanish production
Measured on oldbox:

* The UmbraTrans dashboard reports the Spanish lane as "complete".
  project_status.py derives each language's state from the presence and
  chapter count of deal-hunter-<tag>/runs/ch-N/translation.json, and
  deal-hunter-es has 32 of 32. It never looks at what the text says, so a
  directory name and a file count are standing in for a measurement. The
  other nine target languages correctly report not_started.

* A real Spanish run would silently reuse the Chinese translations.
  pipeline.py caches by file existence and the run directory is chosen by
  the caller, so a Spanish run pointed at deal-hunter-es without --force
  would skip drafting and publish the Chinese under a Spanish name.

Neither was fixed here: the UmbraTrans checkout on oldbox is behind and
dirty, and the script check belongs in the same place as the ingest's.
2026-11-06 22:03:15 -08:00
Umbra Tools
ff73c99f8d Show the reviewer why a translation lane has a gap, not just that it has one
A delivered gap is not a missing translation. The delivery arrives and says
this target language does not voice this utterance, with a reason - the
translation renders a different paragraph, say. That is a different problem
with a different fix, and the reviewer needs to be able to tell them apart.

The reason was being thrown away. The row is stored with an empty text and
a review_reason; the resolver skipped every row with empty text, so the gap
was dropped and the review lane said "Chinese has not been translated yet".
That is a false statement about a translation that was delivered and
rejected.

So the resolver now keeps a not-voiced row as a gap with its reason, the
chapter page renders it as a gap rather than as missing text, and the
language page lists the delivered gaps under "Needs attention" with their
reasons. A gap row is also no longer counted as translated text, so the
coverage the reviewer sees is the coverage the book has.

4 new tests. Suite: 913 passed, 8 failed (the same 8 pre-existing failures).

Also adds scripts/probe_cross_language_tts.py: the open prerequisite for any
target-language audio lane is whether Qwen3-TTS can speak Chinese with an
English voice reference. The probe answers it by transcription rather than
by listening - Whisper transcribes each clip and reports what language it
heard, and the English-reference clip reading Chinese is compared against
the same clip reading Chinese with English phonology, so the only variable
is the language parameter.
2026-11-06 21:56:28 -08:00
Umbra Tools
25605e2cad Record the paragraph-boundary drift findings and the verification stage
Also records the negative result from the independent cross-check attempt:
names are not a drift signal, and the glossary-anchor detector conflates
drift with ordinary glossary non-compliance. Both would otherwise be
tried again.
2026-11-06 21:40:37 -08:00
Umbra Tools
31cd9f7ce4 Record the paragraph-boundary drift and how it is now caught
The production Chinese re-flows the passage across paragraph ids in the
places where Pass 0 split the original mid-sentence. Measured: 439 of
3,038 translated paragraphs (14.5%) are sentence fragments, and in ch-15
the drift runs p0094-p0104 - eleven paragraphs whose Chinese is good but
attached to the wrong paragraph.

The alignment pass cannot see this by construction: a one-to-one paragraph
is a pure copy, so there is nothing to divide and nothing to disagree.

The verification stage finds it, and the spec records how it was made
precise. The first version asked two yes/no questions per paragraph and
was wrong in both directions - five of fifteen flags were paragraphs it
claimed were missing an adverb that was present, and it missed two
paragraphs that are drifted. The stage now asks which paragraph a
translation renders, shows the model its neighbours, and judges the
paragraphs as a window rather than one at a time.

On the real ch-15 window it now finds exactly the drift: ten paragraphs
dropped, p0094 kept and flagged because its Chinese correctly renders its
first two sentences.

Also recorded: the cost of the choice (a gap where a fragment inside a
broken window is dropped on an unreliable verdict) and the root cause
(the drafter is not told that each id must cover only its own English).
2026-11-06 21:26:21 -08:00
Umbra Tools
6bf0baa2f8 Record the Chinese-only pipeline finding and the delivery script gate
The "Spanish" production on oldbox is a second Chinese draft, and the
drafter's hard-coded Simplified Chinese instructions are why. Also recorded:
the target-language audio render belongs to UmbraTrans per spec 18, the
Qwen3-TTS cross-language clone probe is still outstanding, and the live UI
was re-measured after the gate.
2026-11-06 20:09:23 -08:00
Umbra Tools
3db6ac3b8b Refuse a delivery whose text is not written in the language it claims
The production directory oldbox calls "deal-hunter-es" declares
target_language "es-ES", its glossary says Spanish, and its 32-chapter run
finished - and all 3,038 translated paragraphs are in Chinese. It is a second
Chinese draft filed under a Spanish name.

The cause is in the pipeline: the drafter's instructions are hard-coded to
Simplified Chinese in three places (SYSTEM_DRAFTER, draft_prompt,
SYSTEM_CRITIC), so the glossary's target language was ignored. No non-Chinese
translation has ever been produced by that pipeline.

The danger at this end is that the rows would be stored under "es", the review
lane would look populated, and a reviewer would approve Chinese as Spanish.
So the ingest now checks the script of the delivered text against the
declared language, and refuses the delivery before writing a single row.

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. Japanese needs its kana and Korean its Hangul, and a
Chinese delivery carrying kana is a Japanese translation filed in the wrong
lane. An unknown language is not checked - there is no expectation to check
it against.

Proven on the real data: the es delivery is refused with "the text is mostly
Han characters, so it is not Spanish", and the 4,300-row Chinese delivery
still imports unchanged.

19 new tests. Suite: 909 passed, 8 failed (the same 8 pre-existing failures).
2026-11-06 20:06:59 -08:00
Umbra Tools
81915b4ec1 Record the translation alignment session, and fix the summary header
The "Current Session Summary" heading had run into the first session's
heading on one line, so the first session read as part of the header and
never appeared in the outline. Split them, and add this session's entry
above.
2026-11-06 19:44:41 -08:00
Umbra Tools
301ff17f3e Replace a translation delivery instead of merging into it
A re-delivery could leave a stale row behind for an utterance the new
delivery does not cover: the upsert only touches rows the delivery mentions.
--replace clears the language's draft rows first, so what the reviewer sees
is what the delivery actually says.

Confirmed rows are never deleted. A confirmed translation is an approval, and
the import skips confirmed rows anyway, so the approved text survives a
re-delivery.

2 new tests.
2026-11-06 19:36:46 -08:00
Umbra Tools
c476cfea1d Ingest the translation delivery, and stop mis-reporting locale codes
UmbraTrans delivers one row per utterance per target language, in the shape
translations.import_translations already accepts. There was no script to run
that ingest, so the delivery side of the contract existed only as a function.

scripts/import_translations.py runs it: report-only by default, --write to
store, --confirm to store as confirmed rather than draft. It prints the
coverage before and after, so a delivery can be checked without writing it.

Two bugs in the ingest, both found by running a real delivery:

* import_translations folded the target language with lang.normalize, which
  does not know script subtags. A delivery labelled "zh-Hans" - which is
  what UmbraTrans writes - matched nothing, so every row was dropped and
  reported as a problem. It now folds with canonical_code, and an unknown
  language is still stored: the text is reviewable even when the render
  will be refused.

* coverage and not_voiced_rows compared the stored code to the requested
  code as strings, so a row stored as "zh-Hans" read as "nothing delivered"
  when the reviewer asked about "zh". The coverage report was wrong in the
  direction that makes a reviewer distrust it.

Measured on the real Deal Hunter Chinese delivery: 4,300 rows ingested, the
review UI's resolver then renders Chinese for 12/12 utterances of chapter 1
and 415/416 of chapter 6 - the missing one being the spoken chapter
announcement, which is deliberately not part of the reading edition.

spec 27 records what the shipped samples actually are: single-pass drafts
made against a hand-cleaned paraphrase of the English, matching the
speaker-verified database text in 0 of 12 utterances. The production Chinese
edition on oldbox does use the intent-based process; what it lacked was the
alignment back onto the utterances, which is now implemented in UmbraTrans.

7 new tests. Suite: 890 passed, 8 failed (the same 8 pre-existing failures).
2026-11-06 19:14:38 -08:00
B.D. Murphy
6bb69729b9 Correct the SSH note: the :2222 connect is intermittently refused, not blocked
The earlier note blamed git-over-HTTP on Local Network privacy and used a
url.insteadOf workaround. The real finding: HTTP genuinely does not
work for git (per-binary entitlement - curl reaches it), while SSH on
2222 works but the connect is intermittently refused with 'Undefined
error: 0'. The Host mini-eq ssh block now sets BatchMode, ConnectTimeout
and ConnectionAttempts 5, so ssh retries the refused connect; plain
git-over-ssh is 5/5 reliable and needs no GIT_SSH_COMMAND.

Also recorded both pinned Mini-EQ host keys - :22 and :2222 keep separate
keys - and that ssh-keyscan without -p 2222 scans the wrong port.
2026-11-06 18:00:16 -08:00
B.D. Murphy
b53a868d04 Verify the SSH push path (empty commit, no tree change) 2026-11-06 17:56:57 -08:00
B.D. Murphy
ff5fe0402d Record the git-over-HTTP TCC blocker and the SSH push route
Git cannot reach Mini-EQ over HTTP on this machine - it fails with 'No
route to host' while curl to the same address returns 200, because the
Local Network entitlement is per-binary. The remote URL is unchanged and
now transparently rides SSH via url.insteadOf. Also recorded that the
remote main can move without the local branch.
2026-11-06 17:13:41 -08:00
B.D. Murphy
bf83bb35cc Merge minieq/main: Apache-2.0 LICENSE, NOTICE and SPDX headers
The remote had one commit this branch did not (03f8a85), which adds the
Apache-2.0 LICENSE, the NOTICE and SPDX headers to every .py and .sh
file. The merge was clean - the headers sit at the top of each file and
my changes are further down.

The new files this branch added (languages.py, translations.py, the
cluster package, the new tests and scripts) were written after the
merge base, so the licence commit never saw them. They now carry the
same header, so the convention holds across the whole tree.

Nothing else changed. 883 tests passed / 8 failed - the same 8
pre-existing failures.
2026-11-06 17:12:24 -08:00
B.D. Murphy
26de44feb1 Cluster worker for the UmbraTrans integration
The UmbraAudio side of the cross-machine contract: this node registers
with the UmbraTrans controller, polls for jobs, claims them under a
lease, and writes artifacts back over NFS.

* `umbraaudio/cluster/` - configuration, PostgreSQL access, node
  registration and heartbeat, the job queue, artifact storage and NFS
  mount management.
* `scripts/cluster_worker.py` - the CLI: `worker`, `check-cluster`,
  `register-node`.
* `scripts/backup_system.sh` / `scripts/verify_backup.sh` - whole-system
  backup and integrity verification.
* `.env` - the `UMBRA_CLUSTER__*` settings. `UMBRA_CLUSTER__PG_PASSWORD`
  is deliberately empty; the password comes from the environment, not
  from a committed file.

`k3s-agent.yaml` is NOT committed: it contains a K3s server join token.
It is now gitignored, and the token should be rotated if that file has
ever left this machine.

ruff clean on the whole package.
2026-11-06 17:03:16 -08:00
B.D. Murphy
2b294d7fa4 Per-utterance language flag + a working language selector (spec 31)
The review UI's language dropdown did nothing: every selection kept
rendering the English text. Two independent causes, both fixed.

1. The translations were never being read. The pipeline's
   `utterance_translations` table holds one row in Deal Hunter, and that
   row stores the *English* text under a `zh-Hans` code. The real
   translations live in the on-disk sample files under
   `samples/translations/`. `umbraaudio/translations.py` now resolves both
   sources and merges them by (chapter_id, utterance_index): the table wins
   where it holds a real translation, the sample files fill the gaps. A
   candidate whose text is identical to the source is a placeholder, not a
   translation, so it is ignored - otherwise the page would show English
   under a Chinese heading and claim it was translated.

2. The query that read them was broken. `language_routes.py` selected
   `u.index` and `u.speaker`; the columns are `utterance_index` and
   `speaker_name`, so every read raised `sqlite3.OperationalError: near
   "index": syntax error` and `/language-mockup` returned 500. It also
   opened `<project>/project.db` - a 0-byte decoy - instead of
   `<project>/.umbra/project.db`, the file that actually holds the data.

Also in this commit:

* The chapter review page honours `?lang=`, remembers the choice in a
  cookie (set by a middleware, so it survives on every page), and shows the
  selected language's *name* in the "has not been translated yet" message.
* The page now shows the translation, not the source, and the edit box is
  bound to the source text so saving cannot overwrite the English with the
  Chinese.
* The stray language selector that sat after `</html>` in base.html is
  gone; the chapters-list page's dropdown had zero options because the
  route never passed the language list.
* The language tag is wired into generation: `utterance.language_code`
  (NULL = project source language) reaches `engine.generate()` and the
  staleness marker, so a retagged line is re-rendered instead of keeping
  English phonology behind an unchanged text. The marker records the
  language; a marker written before the tag existed means English, so the
  ~4,300 existing clips are not invalidated.
* `project.source_language` (spec 31 section 13.1) records the book's own
  language, with a migration for existing project DBs.
* A scene-break marker deliberately records no language: it is silence,
  identical in every language, and tagging it would claim a phonology the
  audio does not have.

Tests: 883 passed / 8 failed - the same 8 pre-existing failures
(2 test_pronunciation, 6 voice_profile_manager accent). 26 new tests
cover the resolver and the generation wiring. ruff introduces no new
findings.
2026-11-06 16:55:49 -08:00
B.D. Murphy
c10e1ae1fd feat: add language selector for umbratrans UI integration
- Add active_language cookie tracking in routes.py
  - _active_language_for_template() resolves language cookie, defaults to 'en'
  - _languages_for_template() returns supported languages list
  - chapter_audio_review() route passes active_language and languages to template

- Add language selector in chapter_audio_detail.html header_extra
  - 10 languages from spec 30 SUPPORTED list
  - JavaScript handles URL param-based language switching (?lang=de, ?lang=en)
  - Persists selection across page reloads
  - Label: 'Applies to the current chapter — navigation, buttons and statuses stay English.'

- Add language selector in chapters_audio.html header_extra
  - Same dropdown for selecting language across all chapters
  - Label: 'Applies to all chapters — navigation, buttons and statuses stay English.'

Closes umbratrans UI gaps for per-language chapter audio review.
2026-11-01 06:41:20 -08:00
B.D. Murphy
5832c70369 infra: add MCP shell server (restores command execution)
OpenCode 2.0.27 withholds the built-in Shell tool regardless of config,
client (desktop app vs TUI) or model -- all three were eliminated by test.
MCP is the one channel demonstrably under user control, so add a stdio
server that restores it.

Stdlib-only and deliberately unfiltered so every line is auditable: a
third-party package with unrestricted command execution is an opaque
trust decision.

Two choices taken from AGENTS.md:

- PATH correction. OpenCode spawns MCP servers that can inherit a bare
  PATH exactly as launchd does, which is the documented cause of
  ffmpeg failing for m4b/MP3 export. /opt/homebrew/bin is prepended to
  the child only.
- background=true detaches into its own session (start_new_session) so
  long-running processes -- TTS server, web UI -- survive both the MCP
  server exiting and an OpenCode restart, and log to a file rather than
  a pipe that would eventually block the writer.

Selftest drives a full handshake and exits non-zero on failure, so a
misbehaving server is caught before an OpenCode restart rather than
after. It found two real bugs during development: a double-encoding
blind spot in an exit-code assertion (tool output is JSON nested in
JSON-RPC, so its quotes are escaped), and an f-string escape that is a
syntax error on Python 3.11, the project's stated floor.

Verified: py_compile clean, ruff clean, selftest 8/8, background mode
detached and logged, exit codes and stdout/stderr separation correct.
Full suite unchanged at 8 failed (pre-existing) / 803 passed.
2026-10-17 06:30:58 -07:00
B.D. Murphy
80d8fb4d7e Untrack the 15.7 MB reference chain; keep the matrix and fix RESTORE.md
Same reasoning as the lab renders: the WAVs are copies of files that live in
~/Documents/Audacity4/ and are mirrored at
~/.umbra/library/author_voice_matrix/20261009T191636Z/. Git does not need a
third copy to be safe.

What stays is the part that is not duplicated anywhere else in a form git can
restore: author_v2.pt and author_v1.pt, 62 KB, the actual voice identity.
manifest.json keeps a SHA-256 for each WAV, so any copy can be verified against
what was captured rather than trusted.

RESTORE.md was wrong after this change and is fixed. It listed the WAVs as
files of the backup and told you to `cp BDMurphy_Voice_NOBG_v2.wav` from that
directory, which no longer works. It now states the WAVs are untracked and where
they actually are, and adds the verified matrix->WAV decode as a first-class
restore path, because that is the point: the WAV is derived from the matrix, not
a prerequisite for it.

Note the asymmetry this leaves in place, deliberately. Loss of the matrix means
losing the captured voice, and no WAV in existence could reconstruct the exact
one. Loss of the WAVs costs nothing, because they can be rebuilt from the
matrix. So the durable, unreproducible artifact is the thing that stays tracked.
2026-10-17 01:06:03 -07:00
B.D. Murphy
6c3f385b73 Untrack the 55 voice-lab renders; the .pt matrix stays
.gitignore alone does not untrack anything already committed, so the previous
commit stopped future tracking but left all 55 WAVs in the index and in history.
This removes them from the tree so a fresh clone no longer carries 25 MB of
audio.

The files themselves are untouched: moved to tmp_out/author_lab_wav/ and
tmp_out/author_lab_wav_cliff/, which is git-ignored, so the samples are still
listenable on this machine. They are regenerable at any time from the tracked
scripts:

  backup/author_voice_lab/scripts/voice_lab.py     # the 45-sample sweep
  backup/author_voice_lab/scripts/verify_cliff.py  # the 10 cliff samples

Nothing load-bearing was removed. Still tracked:
  backup/author_voice_matrix/**/*.pt        author_v1.pt + author_v2.pt (62 KB)
  backup/author_voice_matrix/manifest.json   SHA-256 + provenance per file
  backup/author_voice_matrix/RESTORE.md
  backup/author_voice_lab/FINDINGS.md, *.json, scripts/

The .pt files stay tracked on purpose: they are 62 KB, they are the actual
voice identity, and the matrix is the thing that must survive. The WAVs are
derived from it.

Caveat worth stating: the 25 MB still exists in git history from the earlier
commit, so the repository object store does not shrink until that commit is
garbage-collected. A future push carries them over the wire once; after that
no new clone pays for them. Rewriting published history to purge them was
deliberately not attempted.
2026-10-17 01:04:50 -07:00
B.D. Murphy
6429f37b11 Ignore voice-lab WAVs; the findings and code stay tracked
The Author voice lab committed 45 sample renders (25 MB) plus the six-file
reference chain. Those are binaries and every one of them is regenerable from
the scripts sitting beside them, so tracking them buys nothing and costs a
git-ls-files full of audio.

Tracked deliberately:
  backup/author_voice_lab/FINDINGS.md      the measurements and the reasoning
  backup/author_voice_lab/*.json           results + cliff verification
  backup/author_voice_lab/scripts/*.py     everything that produces the audio
  backup/author_voice_matrix/*.pt          the vmatrix itself (62 KB, load-bearing)
  backup/author_voice_matrix/manifest.json SHA-256 + provenance per file
  backup/author_voice_matrix/RESTORE.md    how to put it back

Ignored:
  backup/author_voice_lab/wav/  wav_cliff/   25 MB of renders
  backup/author_voice_matrix/**/*.wav        the reference chain, 25 MB

Note this only stops future tracking. The 45 WAVs are already in history from
the earlier commit, so they stay in the tree until removed with
`git rm -r --cached backup/author_voice_lab/wav backup/author_voice_lab/wav_cliff`
and committed -- which is deliberately left as a separate, explicit step rather
than folded in here, since it changes what a checkout contains.
2026-10-17 01:04:23 -07:00
B.D. Murphy
9dfa7915ee Probe suite: T06/T07 now test, T08 names its real blocker
The dry run passes since routes_compute.py:1792 was aligned with read_bundle
(separators + ensure_ascii=False), so three tests had to catch up. All three
carried narratives written when the bundle was rejected, and each kept asserting
a reason that had stopped being true — a BLOCKED test is a finding, so a stale
BLOCKED reason is a finding about the wrong world.

T06 re-runs the dry run and compares responses, proving validation is
deterministic. It says plainly that re-import suppressing stored rows is NOT
covered: that path needs rows that persist, and the import still builds
InMemoryAudioImportStore.

T07 reconciles the controller's counts against the bytes sent. 8884 records in,
8883 accounted for, plus the book record it does not count — so a controller
that silently dropped a record kind now fails here instead of passing quietly.

T08 keeps BLOCKED, but for the reason that is actually true: no Postgres
AudioImportStore adapter exists, so even a successful commit would return a
uuid4 project_id while translation_utterances stayed empty. Its text names
InMemoryAudioImportStore on purpose, so wiring an adapter fails a test and
forces the verdict to be re-decided.

Suite: 9 PASS / 0 FAIL / 1 BLOCKED (T08). Tests 30 -> 40.
2026-10-16 00:27:24 -07:00
B.D. Murphy
0909d050d9 Probe the route correctly: credentials, CSRF ordering, and a real dry run
The controller's ingest endpoint came up between runs and this tool misreported
it three more times, each in the same direction — naming the state it was in
when the check was written rather than the state it was reading.

* POST was probed without a credential, so a live route kept answering 401 and
  the verdict stayed AUTH_REQUIRED after auth was configured and T02 passed.
  The probe now sends whatever headers exist, because an unauthenticated probe
  answers "is there a door", never "does it open".

* The rule for reading a status was wrong twice. First it accepted only
  200/201/405 as routed, so a 401 on a live route read as absent. Then it was
  widened to "anything but 404", which would have read a CSRF 403 as a route:
  the controller checks CSRF in middleware before routing (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 — and none does. Now: on a safe method, 404
  means unrouted and anything else means routed; on an unsafe method a 403 off
  an exempt prefix is discarded and recorded as csrf_masked_probe.

* T04 dropped csrf_masked_probe from its FAIL evidence, which is exactly the
  case where the next reader needs it. Found by the new tests, not by reading.

* T05 returned BLOCKED unconditionally, so the one test that mattered was the
  least willing to run. It now executes a real dry run against --bundle.
  dry_run short-circuits before store.commit(), so running it cannot write.

* authenticate() sent X-Node-Key, but require_node parses only "bearer "
  (routes_compute.py:477) — a valid key reported as rejected. It also probed
  /api/auth/me, which resolves a browser session, so no node credential could
  ever pass it. Now Bearer on /api/node/agent, and 403 there counts as
  authenticated-but-unscoped, since the scope check runs after resolution.

* _summarise reduced a plain-text body to "<str>", discarding "Internal Server
  Error" and every handler message — the detail worth reading.

* Four verdict names, including INGEST_ROUTED_NEEDS_RESTART, kept describing a
  restart that had already happened. _readiness now derives the name from
  evidence held by this run, and a real dry run outranks every transport
  observation: the current verdict is DRY_RUN_REJECTED.

Measured against the controller: migration 0008 applied (27 -> 31 tables, guard
proven to fire, idempotent on re-run), service restarted to PID 392511, endpoint
routed and parsing. The dry run executes and is rejected on content_hash,
because the HTTP path re-serializes records (json.dumps default separators,
ensure_ascii=True) while their own read_bundle() hashes the file bytes — the
same bundle yields d0011ae6 over HTTP and 21ce5687 on disk, and their validator
reports 0 problems on disk. Reported to the controller; bundle hash unchanged.

Also reported: routes_compute.py:1809 constructs InMemoryAudioImportStore,
whose commit() appends to a list, and no PostgreSQL adapter exists — so a
successful commit would return a uuid4() project_id while all four
translation_* tables stayed empty. Read from source, not reproduced.

30 new tests pin the routing rule, the CSRF ordering, the credential header,
and every verdict branch. Full suite 799 passed / 8 failed (pre-existing
voice_profile_manager + test_pronunciation). ruff and mypy clean.
2026-10-15 20:41:05 -07:00
B.D. Murphy
d2a944ef84 The ingest endpoint exists; I reported it missing on guessed paths
T04 said MISSING_INGEST_ENDPOINT. It was wrong about the codebase and right
about the running system, and both halves were avoidable.

The route is POST /api/node/audio/import, on agent_router mounted at /api/node,
guarded by node.require("dataset:ingest"), backed by audio_import.py and
migration 0008_audio_source_import.sql. I found it by reading app.py's
include_router calls - which I had already grepped earlier in this same session
and which printed `include_router(node_router, prefix="/api/node")` on screen
while I was writing the test that declared the path missing.

Two failures, not one:

* I probed six guessed paths when I held SSH access to the source that names the
  router prefixes. A 404 to a guessed path proves only that the guess was wrong.
* I probed with GET only. The route is POST-only, so it answers GET with 404 and
  POST with 405. That is why it looked absent for a whole session even to a
  careful prober.

Both are fixed. T04 probes GET and POST and treats 405 as routed, because 405
means the path is in the table under another method. The failure case is now
UNCONFIRMED_INGEST_ENDPOINT rather than MISSING, and carries a note saying to
confirm against the route table rather than against its 404s. A new verdict,
INGEST_ROUTED_NEEDS_RESTART, distinguishes "routed but not serving" from
"absent".

Why it is routed but not serving: the process on :8442 started 2026-10-14 20:06.
audio_import.py was written 2026-10-15 11:15 and routes_compute.py 12:35, all
uncommitted. /health has been green throughout, because it probes liveness and
not the route table - so "healthy" was never evidence the new work was live.

Suite is 5 pass / 0 fail / 5 blocked now, verdict INGEST_ROUTED_NEEDS_RESTART.
48 tests pass. Nothing pushed.
2026-10-15 13:22:57 -07:00
B.D. Murphy
4c1e6ebc98 The TTS document, measured, with five honest unknowns
The controller will not write a `speak` work function against a description, so
this measures instead of describing. scripts/build_tts_facts.py emits
docs/tts/tts-engine.{md,json} from the running system and the repository at run
time; anything unmeasurable says "unknown" with a reason. The content hash
excludes the timestamp, so it is stable across runs and answers "did anything
change?" rather than "was it run again?". 18 tests enforce that it does not
estimate -- including one that fails if a missing weights file is rendered as
"None", and one that fails if the Edge-voice answer is inverted, which it was, in
the first draft.

Four things that change the controller's plans:

* The fork is ONE commit ahead of upstream and it is cosmetic -- a logo and the
  web demo. The contract being written against is not the fork, it is
  umbraaudio/scripts/qwen_tts_server.py, where the HTTP surface and the four
  generation paths were written.
* Synthesis is not deterministic. temperature 0.9, do_sample true, no seed. A
  translation re-voiced because one line changed will not reproduce the other
  lines byte-for-byte, so comparing takes needs an embedding measure, not a hash.
* The TTS server has NO authentication and binds 0.0.0.0:8881, and concurrency
  is 1 behind a single lock. The generator names the seven auth mechanisms it
  searched for so the claim can be checked rather than believed.
* assigned_edge_voice is the fallback engine for creating reference audio, not
  the engine that renders utterances.

Not measured, and said so: working set / VRAM (the one that decides the 48 GB
CPU node question -- 9.5 GB of weights on disk is not the memory the process
holds, and on CPU that gap is the whole question); whether a seeded call is
reproducible; per-language quality; the model's own maximum text length; CUDA
failure modes.

scripts/build_scale_totals.py measures across all projects rather than handing
over a typed number: 4 projects, 131 chapters, 18,181 utterances, 14,526 audio
files, 6.98 GB, 38.58 hours, and 2 distinct schema fingerprints -- matching the
two already recorded in manifest.md. Markers are counted separately from audio,
because "files" that means both is a number nobody can use. Treasure Island
contributes 49 chapters and zero audio: that is a fact about it, not a failed
measurement.

Also delivered to the controller: their accepted-format validator accepts the
bundle, exit 0, 8884 records, and I confirmed it fails on a broken copy before
believing the pass.
2026-10-15 12:07:15 -07:00
B.D. Murphy
3d69187449 Answer the controller's data request, and audit what we send
The UmbraTrans controller asked (commit 70c23fd) for machine-readable metadata
about the audio system, in a specified layout, with a validator that ships with
the request so the sender can check its own work. That request is answered here.

scripts/build_controller_bundle.py emits that bundle: one real book (Deal
Hunter), 8,884 records, no media, no credentials, no hostnames. It passes the
controller's own validator, exit 0.

Three things in this system will break a mapping that does not know them, and
the bundle's real purpose is to say so precisely rather than to look tidy:

* A chapter has THREE numbers. The database row id is what filenames use,
  chapter_index is reading order, and the distribution track number is a third
  thing again. Only the row id survives an edit. Front and back matter are
  numbered with roman numerals in the MP3 filenames.
* The narrator is written three ways, and the most common way is nothing at all.
  1,803 utterances have a NULL speaker_name, meaning narrator; 614 say
  "narrator" and 15 say "Narrator". A join that skips nulls loses most of the
  narration silently. Utterances now carry speaker_name_raw, the resolved name,
  and speaker_resolution, and the two spellings are not merged - that is a
  decision for whoever owns speaker identity, not something an export should do
  quietly.
* A .wav file is not always a WAV. 30 of the 91 voice reference files are MPEG.
  Every render reports the container measured from the bytes, with
  extension_matches_container, so a reader cannot be misled by the name.

A validator proves the bundle is internally consistent; it cannot prove the
bundle is true. scripts/audit_controller_bundle.py re-measures every hash, byte
size, duration and count against the project on disk - 66,011 claims, all
holding. It caught two real defects in the first draft of this bundle, including
one of my own: produced_at was stamped with the build time, which is a claim
about the audio that nothing supported.

The validator was also run against seven deliberately broken bundles to confirm
it can fail. A validator nobody has seen fail is not evidence of anything; those
cases are now tests.

scripts/audio_controller_connect.py runs the T01-T10 suite: 4 pass, 1 fails,
5 are blocked. T04 fails honestly - there is no ingest endpoint, and nothing was
pushed. T05-T08 are blocked because the controller's own plan sequences ingest
after the metadata arrives; a proposed contract is included rather than an
invented endpoint. T02 is blocked: no node key or service token is configured
here, and an operator has to supply one.

Two corrections to the node profile, which the controller reads: it claimed the
web UI has no authentication (a session gate was added 2026-10-09) and that the
controller host is offline (it is reachable and serving umbratrans-web 0.1.0).

Full suite 751 passed, 8 failed - the same pre-existing pronunciation and
accent failures, unchanged. ruff and mypy clean on everything added.
2026-10-14 10:56:32 -07:00
B.D. Murphy
b63bc5bd0a Fix language selector being invisible; correct sample utterance types
The dropdown was in the HTML all along and still could not be seen. Three
separate causes, found by measuring the rendered element rather than
trusting that the markup existed:

1. Clipped off-screen. The h1 is a flex row with no wrap. Measured at an
   800px viewport: the language span started at x=620 and ended at x=810,
   past the viewport edge, so only its first few pixels were painted. The
   project badge alone took 309px. Adding flex-shrink and truncating the
   badge did not fix it - there is genuinely no room in that row. The
   selector now gets its own bar directly under the header, and the
   project name ellipsises. Verified visible: x=181, right=341 in an
   800px viewport, 34px tall.

2. Empty on every other page. base.html rendered the select from a
   `languages` variable that almost no page supplies, so the login page
   and the rest of the app showed a 38px-wide dropdown with zero options -
   effectively invisible, and a dead control. Pages now opt in by
   overriding a header_bar block. Verified /, /speakers, /chapters-audio
   and /login all still render.

3. Browser cache made my own screenshot lie. It showed a stale Japanese
   string containing the word "decisiveness" that is in no file on disk.
   A cache-busted fetch confirmed the server is clean. Worth remembering:
   a screenshot taken through a warm cache is not evidence.

The screenshot also exposed a data bug. All 12 sample utterances carried
type "narration" - including Kainda, Duchess Victoria, Deal Hunter, Nanny
and Trader Romano, who are all speaking. Every row rendered a narration
badge and the file told UmbraTrans that six characters were not speaking.
SHAPE in build_sample_translations.py now marks 6 dialogue / 6 narration,
and the generated JSONs are regenerated to match.

Also removed a misleading issue. It reported "12 rendered clips have no
staleness marker" as a translation problem; the markers are absent because
render_lang_test.py does not write them, so it was sending the reader after
a defect that does not exist. Replaced with the honest one: only 12 of
4,319 translated and none confirmed - sample only, not publishable.

Suite: 721 passed, 8 pre-existing. ruff clean on changed regions.
2026-10-14 08:06:38 -07:00
B.D. Murphy
8dd45236f4 Mockup v2: header dropdown, split coverage, issue list
Rework per operator feedback that v1 was cluttered.

Language selection moves into the header beside Active project, as a
dropdown, replacing the large dedicated card. It belongs there rather than
on one page because it changes what every page shows. base.html now
carries the selector, guarded by {% if languages is defined %} so no other
page is affected until spec 30 is real. Verified the other pages still
render (dashboard, speakers, chapters, library, lang-samples all 200).

Coverage is now two independent numbers rather than one:

  text   12 / 4319 utterances translated   0.28%
  audio  12 / 4319 utterances voiced       0.28%

They fail independently: text can arrive before any audio exists, and
audio can be missing because a render failed rather than because the
translation does. One combined percentage hides which of the two is stuck,
which is the question you actually need answered before a release.

Needs attention replaces the old static coverage table. Up to 25 issues
list inline; above that it groups by chapter and links to /chapters-audio
rather than scrolling to unusable length. Verified the grouping branch
directly with 2,592 synthetic issues: 32 chapter rows, worst first, and
the per-chapter counts sum back to the total. Nothing is hidden - the
grouped view still accounts for every issue.

Also fixed a real measurement bug carried in from v1: rendered clips are
named utt<NN>_<Speaker>.wav, not utt<NNNN>.wav, so my original
exists() check matched nothing and reported 0 of 12 voiced. Now matches
by index prefix; the 12/12 figure is read from disk, not assumed.

Dropped the English reference column and the long explanatory prose;
the design notes collapsed into a <details> block so the page reads as a
page rather than a specification.

Suite: 721 passed, 8 pre-existing. ruff clean.
2026-10-14 08:00:54 -07:00
B.D. Murphy
3168b51e7c Spec 30: language as a first-class dimension, with a UI mockup
Proposal plus a working read-only mockup at /language-mockup for operator
review. German selected by default, as asked.

Measured first, so the proposal reflects reality:
  utterances   4,319 rows, no language column - one text per utterance
  language     absent from the DB entirely; exists only as a filename
               convention in the experimental lang-samples page
  /v1/tts      language: str = Form("english") - free-form, unvalidated
  marker       utt_XXXX.wav.txt JSON has speaker/text/voice_fp, no language

The load-bearing finding is that audio MUST be namespaced per language
before any second language is generated. staleness.is_current() compares
the marker's recorded text against the utterance text, so German and
English sharing ch6/utt_0001.wav would each mark the other stale and
overwrite it in a loop. That is a correctness requirement, not a tidy-up.

Schema choice: a new utterance_translations table rather than a language
column on utterances. One row can hold only one language, so a column
either forces 10x duplication of speaker/emotion/pace/attribution or
overwrites the source text. UNIQUE(utterance_id, language_code) makes
UmbraTrans re-delivery an update instead of a second competing German.

English stays in utterances.text and stays editable; translations are
confirm-only, matching the agreed asymmetry.

Audio: chapter_audio/ (English, path untouched, zero regression risk)
gains siblings chapter_audio_de/ etc. Marker JSON gains "language" so an
export can prove what it shipped.

The mockup is a separate router for the reason name_token_routes.py exists
- it keeps proposed work out of the hot routes.py until it is agreed. It
writes nothing, generates nothing, and touches the English pipeline not at
all. Verified: 200 on all 10 languages, real German text rendered,
coverage read from the real 4,319-row DB, unknown codes fall back to
German without a traceback, and the auth gate still 303s to /login.

I initially invented a routes.py helper (resolve_project_dir) that does
not exist; the real one is _get_active_config(request).project_dir. I also
removed a silent cwd fallback that would have rendered English under a
German heading and looked like a working page, and made an unreadable DB
show 'could not measure' rather than a misleading 0%.

Suite: 721 passed, 8 pre-existing. ruff clean on new files.
2026-10-14 07:52:18 -07:00
131 changed files with 14221 additions and 132 deletions

32
.env
View File

@@ -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
View File

@@ -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
View File

@@ -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

View File

@@ -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
View 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
View 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
View 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
View 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())

View File

@@ -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"

View File

@@ -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]

View File

@@ -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!"
}
]

View File

@@ -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!"
}
]

View File

@@ -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!"
}
]

View File

@@ -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é !"
}
]

View File

@@ -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!"
}
]

View File

@@ -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": "最後まで聞いてくれて、ありがとう!"
}
]

View File

@@ -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": "들어줘서 고마워요!"
}
]

View File

@@ -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!"
}
]

View File

@@ -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": "Спасибо, что слушали!"
}
]

View File

@@ -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": "感谢收听!"
}
]

View 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"
}
]
}

File diff suppressed because it is too large Load Diff

View 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
View 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

File diff suppressed because it is too large Load Diff

View File

@@ -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]] = {

View 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
View 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
View 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()

View File

@@ -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

View 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())

View 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())

View File

@@ -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
View 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 "========================================="

View File

@@ -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

View File

@@ -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,

View 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.

View 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.

View 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

View 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