42 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
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
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
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
umbra audio
03f8a858dd Add Apache-2.0 LICENSE, NOTICE, and SPDX headers 2026-10-18 19:01:04 -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
2f46be9520 Sample translations: formal -> casual register, all 10 languages
The Korean render sounded formal because the Korean translation was in
합쇼체 (-입니다/-합니다/-습니다) - the written formal register, like a
news anchor. Every line used it.

Korean politeness is grammatical, not stylistic: it lives in the verb
endings. 합쇼체 -습니다, 해요체 -이에요/-예요, 해체 -아/-어 are different
morphemes, so no TTS instruction can convert them. The fix has to be in
the text. The same applies to Japanese です/ます and to the T/V languages,
where Sie->du changes the words themselves.

This was not a Korean-only problem. All 9 translated languages were in
formal register while the English source is neutral and friendly:

  DE 12 markers  FR 13  PT 5  ES 4  IT 3  RU 3  JA polite  KO 합쇼체
  ZH 3 honorific 您          EN 0 (source)

Applied:
  KO  합쇼체 -> 해요체. 해체/반말 deliberately NOT used: speech level is
      social, and Nanny speaking 반말 to Kainda would be wrong.
  DE/FR/ES/IT/PT  Sie/vous/usted/Lei/você -> du/tu/tú/tu/tu
  RU  kept вы; ты is too intimate for narration. Softened instead.
  JA  polite-but-warm, fewer ください
  ZH  您 -> 你

Re-verified against the formal-pronoun probe (not the earlier loose one,
which flagged possessives: su/suo/seu are 'his', and Japanese です is the
copula while います is existential - both register-neutral).

This is Pass 3 from specs/27 (register and voice consistency), which was
specified and then never applied.

Also fixed a drift trap: build_sample_translations.py wrote to
scripts/translations, a directory nothing reads and nothing tracks, while
the tracked record is samples/translations and the renderer reads
<project>/samples/translations. One regeneration silently left both stale.
It now writes the repo copy and, with --project, the renderer's copy in
the same run.

Re-rendered 120 clips, 0 failed. All 120 bitwise-different from the
formal render, confirming the new text was synthesized rather than a
replay. Korean total 117.8s -> 110.1s, consistent with the shorter 해요체
endings. Previous render kept as Lang_test_formalRegister.

Suite: 721 passed, 8 pre-existing.
2026-10-13 20:54:36 -07:00
B.D. Murphy
332aeb4b65 Narrator uses the speaker-page reference in every language
The per-language narrator references were wrong. Generating a VoiceDesign
voice per language invents a different narrator each time: measured
0.77-0.96 band-energy similarity against the speaker page's own
narrator_ref.wav, so the voice changed with the language. That is the
opposite of using the vmatrix, and it is why the narrator did not sound
the same across languages.

The narrator now uses narrator_ref.wav in every language. The language
parameter supplies phonology; the single reference supplies identity. That
reference is what the vmatrix was built from, so it is the same voice - just
at x_vector resolution rather than the 8k matrix, which is the only form
this machine can run (one prompt_slug request takes the server 5.4 GB ->
19.1 GB and never returns).

Cross-language narrator similarity, same utterance, all 10 languages:
  per-language designed refs : mean 0.945, min 0.795
  single speaker-page ref    : mean 0.973, min 0.910

The previous render is kept in Lang_test_perLangRefs for comparison. 120
clips re-rendered, 0 failed. Note the proxy is a band-energy fingerprint,
not a true speaker embedding, and it cannot judge accent - only timbre
consistency. Suite: 721 passed, 8 pre-existing.
2026-10-13 19:36:20 -07:00
B.D. Murphy
d7fc0236bc Narrator speaks the translation language; characters keep their own accent
Specification change. Two different rules, and they need different
mechanisms.

Narrator: accent = translation language. Its reference was generated by
VoiceDesign from a description that hardcoded '{accent} English', baking an
American-English timbre into the WAV; cloning that into German gives German
phonology from an American voice. Ten per-language references now exist
(narrator_EN.wav ... narrator_ZH.wav), each designed from the narrator's
timbre plus 'Speaking {language} as a native {language} speaker'.

Characters: keep the accent on the speaker page. Their accents were already
encoded in their existing refs - what destroyed them was the language
parameter forced onto every utterance, which made every voice adopt the
target language's prosody. That is why Deal Hunter came out French-native.
Phonology and accent are now separate controls: language carries phonology,
an explicit voice_description carries accent character. Deal Hunter gets
'with a strong Australian accent' in every language.

Measured A/B, French line from Deal Hunter: with and without the Australian
instruction the waveforms differ by 152% of signal amplitude and duration
moves 4.24s -> 4.08s, so the instruction is reaching the model. Whether it is
convincingly Australian is an ear test, not something measurable here.

Re-rendered all 120 clips under the new spec, 0 failures. The previous
render is kept in Lang_test_preSpec for A/B. 24 speakers carry a non-default
accent; the narrator is deliberately excluded from that set.

Suite: 721 passed, 8 pre-existing.
2026-10-13 06:19:43 -07:00
B.D. Murphy
f381c58160 Let the TTS server be told what language to speak
Every generation branch hardcoded language="english", and the endpoint
accepted no language parameter at all. German, French and the rest were
synthesised with English phonology and prosody - which is why the German
samples sounded like an English narrator reading German.

- /v1/tts now accepts a language form field, passed to all four branches.
  Default stays "english" so the existing English pipeline is unchanged;
  the renderer sends the target language from the model's own
  codec_language_id keys (exactly ten: chinese english french german
  italian japanese korean portuguese russian spanish).
- A/B on one German line with the same cloned voice: language=english
  gives 4.56s, language=german 4.08s, waveforms differing by 151% of
  signal amplitude. Audibly different renditions, not a no-op.

Also documented the ICL memory finding in the server comments: the branch
that consumes a cached prompt leaves x_vector_only_mode at its default of
False, so it decodes the 8k ref_code matrix on every request. One such
request takes the footprint from 5.4GB to 19.1GB and does not return. The
ref_audio branch passes x_vector_only_mode=True and is stable - 120 clips,
0 failures, 13.1GB.

Note the language fix does NOT address that; it was tested and still
blows up with language=german. Separate problem.

qwen_tts_server.py stays at its 8 pre-existing ruff errors. Suite: 721
passed, 8 pre-existing.
2026-10-13 05:26:48 -07:00
B.D. Murphy
d73cdf5442 Add combined all-languages chapter to the renderer
Writes _ALL_LANGUAGES_full_sample.wav alongside the per-language chapters,
English first then the operator's list order, so the whole 10-language set
can be heard in one pass (17.8 min for the sample chapter).

Root cause found while rendering with 1.7B: the ICL prompt path is what
blows up memory, not model size or uptime. A single prompt_slug request
added ~14 GB (7.2 -> 21.6 GB) and wedged the server, after which the
otherwise-stable ref_audio path also hung. A fresh 1.7B loads in 8s at
5.4 GB, generates in 9-16s per clip, and settles at ~37 MB per additional
clip (13.1 GB after 120) with no restart needed between languages.

The 0.6B render is kept in Lang_test_0p6B for comparison; the 1.7B render
is in Lang_test.

Suite: 721 passed, 8 pre-existing.
2026-10-12 22:28:21 -07:00
B.D. Murphy
fe6337d3e5 Fix render_lang_test: /v1/tts returns raw WAV, and add a model-portable voice mode
Two bugs that would have failed on all 120 clips:

- The script parsed {"audio_base64": ...}. /v1/tts returns raw WAV bytes
  with media_type audio/wav. Now validated with a RIFF check so a bad
  response says so instead of raising a JSON error.
- It preferred the cached ICL prompt. Those are MODEL-SPECIFIC: narrator.pt
  was built by the 1.7B model and the 0.6B rejects its ref_code with
  'Expected size 1024 but got size 2048'. --voice-mode now defaults to ref,
  sending ref_audio + ref_text (the Rainbow Passage the refs were generated
  from) so the model builds its own prompt; --voice-mode prompt is available
  and documented as valid only for the model that built the cache.

Measured on a reboot with swap at 0: the 1.7B model still fails to generate
(footprint 23.4G on a 16G machine, zero POSTs completing). The 0.6B model
loads in 5s and renders 6-21s per clip.

Rendered 120 clips across 10 languages plus a stitched file each: no silent
clips, RMS -22 to -23 dB throughout. Duration ordering is itself a useful
signal - ZH 92s and EN 81s against DE 98s, FR/IT 102-107s.

Suite: 721 passed, 8 pre-existing.
2026-10-12 21:17:54 -07:00
B.D. Murphy
6b85e48b55 Multilingual samples, multi-pass translation design, UI language plan, renderer
Operator direction: English is the source and stays editable; translations
are confirm-only.

- samples/translations/: Deal Hunter sample chapter 1 in all 10 Qwen3-TTS
  languages, named deal_hunter_sample_ch1_<LANG>.json. Single-pass LLM
  drafts, labelled as such in every file - they are the failure mode the
  multi-pass design exists to fix, and they double as a bad-case corpus for
  the Pass 4 critic.
- scripts/build_sample_translations.py, with a validator that refuses to
  write on stray Latin residue, duplicated utterances or a wrong count. It
  caught three defects in my own draft before it would ship.
- specs/27-translation-pipeline-multipass.md - six passes, the point being
  that Pass 4 must be a separate critic call, not the same call that drafted.
- specs/28-ui-language-selection.md - confirm-only translations, English
  editable, selector scoped to the chapter page, language named on the page
  because the cookie is per browser.
- scripts/render_lang_test.py - renders the translations with each speaker's
  own voice into <project>/Lang_test/, plus a stitched file per language.
  Dry run resolves all 12 utterances x 10 languages; 11 of 12 use a cached
  ICL prompt, Trader Romano falls back to ref_audio.

The renderer resolves display names through the alias map rather than by
munging strings: the translations say 'Kainda' and the speaker record says
'Kainda Walters Foster Murphy'. String-munging silently produced no voice for
Kainda in the first draft - the same failure mode as the speaker-review
audit.

NOT DONE: no audio rendered. Qwen3-TTS generation on this machine is stalled
- /v1/tts accepts the connection and never completes (zero POSTs logged across
17 minutes after a clean restart), while /v1/voice-prompt and /load return 422
immediately, so requests do reach the handler and the block is inside
generation. Swap is at 16GB of 17.4GB. Left as a known blocker rather than
worked around.

Suite: 721 passed, 8 pre-existing.
2026-10-12 08:07:50 -07:00
B.D. Murphy
9bda553311 Add a speaker-review audit: is the attribution data fit for translation?
Runs after a reviewer finishes a book and answers the two questions that
gate the next stage:

  1. Are there utterances with no speaker?
  2. Now that speakers have been defined, renamed, merged or deleted, are
     there utterances that should change?

Read-only - it never edits the database, and a test asserts the files are
byte-identical afterwards. Output is a markdown table and a CSV, one row per
finding with chapter, utterance number and a text preview, so each item is
locatable rather than a count.

Deal Hunter, after the completed review (56 speakers, 0 still needing
review, 4319 utterances):

  - Dialogue with no speaker: 0. The 1803 speakerless rows are narration
    and scene breaks, which is correct and is reported as a number rather
    than silently dropped.
  - Utterances attributed to a deleted speaker: 0.
  - One speaker stored under two spellings: 6 rows across 3 speakers
    (narrator/Narrator, Kainda/Princess Kainda, Willamina Wake/Captain).
    All resolve to one voice so the audio is correct, but a per-speaker
    export would split them into six - caught before the translation stage,
    not after.
  - 6 speech-tag mismatches where the narration immediately before the line
    names a different speaker.
  - 10 dialogue rows voiced by the narrator: 9 are quoted labels ("Reactor.",
    "operations") that are correctly read by the narrator, 1 (ch28 utt46) is
    a real segmentation error. Classified advisory so the one real defect is
    not buried under nine false positives.

build_alias_map moved from scripts/qa_attribution.py into attribution_qa.py
so the CLI and the audit cannot disagree about what a speech tag resolves to;
qa_attribution output verified byte-identical.

13 new tests. Suite: 721 passed, 8 pre-existing.
2026-10-11 21:57:58 -07:00
B.D. Murphy
0722a85ffd Retire GPU box 192.168.123.14 from TTS and node-report configuration
The box is offline and being reconfigured. Removed the relay helper URL from
.env (qwen_tts_helper_url defaults to empty, so local :8881 is now the only
backend), dropped the 18881 relay entry from MEANINGFUL_PORTS, and made the
node report state plainly that no controller endpoint exists rather than
advertising one on a retired host.

Also recorded that ModelCoordinator.ensure_qwen_server() already spawns the
Qwen server on demand - it checks /health first, so a launchd-owned instance is
reused instead of double-spawned.
2026-10-08 07:33:54 -07:00
B.D. Murphy
f19ada75fb Mechanical speech-evidence gate: catches the ch6 utt29 miss (spec 24 A)
The operator's chapter 6 review found a line attributed to Hallr that the text
gives to a scum boy, and no part of the system had flagged it. Spec 24 §2
diagnosed why: the duplicate scan only compares names that already exist, so it
cannot see a line given to the wrong EXISTING speaker. Spec 24 §4A proposed a
mechanical evidence gate. This is that gate, measured.

New umbraaudio/analysis/speech_evidence.py, model-free, plus
scripts/report_speech_evidence.py (read-only) and 44 tests.

For every dialogue line: extract the speaker the narration actually names, build
the candidate set from the cast rather than from the guess, and return
supported / contradicted / unsupported / unconstrained.

Last Dwarf, all 49 chapters, 3,918 dialogue lines:
  supported       1,844  47.06%
  unconstrained   2,032  51.86%
  contradicted       34   0.87%
  unsupported         8   0.20%
  review queue      42   1.07%

It catches the chapter 6 defect exactly: "The leader on the right for Hallr
says," / "We want to be the ones to claim we killed the last dwarf" assigned to
Hallr, with the two leaders who could own it. attribution_qa reports 11 findings
book-wide and this one is not among them.

Two design decisions carried the precision, and both were forced by measurement.
First, "no tag" is not a finding: most dialogue has no tag because a tag
introduces only the first line of a conversation, so unconstrained and
unsupported are separate verdicts. The first working version queued 60% of the
book for this reason, which is the same as having no gate. Second, "ambiguous"
was removed as a verdict - it hid the case that matters, since a line assigned to
Hallr under "The leader on the right" is wrong no matter how many leaders exist.
Candidate count now decides whether a suggested fix is confident or a question.

Two corrections to spec 24, both from measurement. The 20 role-tag violations
reported in section 3 are really 11; my ad-hoc probe was worse than the checker
it was meant to replace. And identity coverage is already 100% - every dialogue
line resolves to a profile or alias - so the defect is never a missing speaker,
it is a line given to the wrong existing one.

Six extractor bugs the measurement exposed are now regression tests: a role
phrase with a spatial qualifier produced no tag at all; a coordinated predicate
spoke as its first subject instead of its last; a possessive voice resolved to a
profile called Voice; irregular plurals and superlatives never matched; and the
subject was read off trailing participial clauses. Two generalisable rules came
out of them - a speech tag's subject is a noun phrase and a prepositional
qualifier is still part of it, and a subordinating conjunction opens a clause of
its own ("The dragon swallows when Hallr says" is Hallr).

Reports carry chapter_id AND title because chapter_id and chapter_index differ
by one in every project here; a bare "ch8 utt264" sent the operator to the wrong
chapter, and the line they asked about is chapter 9 utt264.

Full suite 619 passed / 8 failed, the same 8 pre-existing failures. ruff clean.
2026-10-04 20:50:17 -07:00
B.D. Murphy
216bc78a3a Remove the dead narrator profiles; add --force for decisions a scan cannot make
The operator confirmed Narratorold and OrigNarrator are dead profiles. --force
exists for exactly this: a decision the scan is not entitled to make. It prints
the overridden conflict before acting, and never deletes a reference file.

Applied to Last Dwarf:
  profiles                        93 -> 91
  approved voices                 13 -> 13   (unchanged)
  voice ref files                 72 -> 72   (unchanged, both WAVs left on disk)
  dialogue rows labelled 'narrator'  3 -> 0   (now the approved Narrator)
  narration rows labelled 'narrator' 5978 -> 5978 (untouched)

The dead names survive as aliases of Narrator so no stray utterance can
resurrect a profile, and the 3 re-pointed rows are a genuine attribution fix:
ch5 utt26 and ch27 utt125 are narration mis-segmented as dialogue; ch42 utt290
is a first-person line that may be intended inner thought.

Rescan: 10 auto, 2 review, 3 blocked, unresolved spoken names 3 -> 2.
Full suite 575 passed / 8 failed, the same 8 pre-existing failures. ruff clean.
2026-10-04 19:05:47 -07:00
B.D. Murphy
714b11367a Duplicate-speaker scan that refuses to eat an approved voice (spec 23)
Last Dwarf has accumulated duplicate speakers from successive discovery and
designation scans: The Girl / The Girl helping / One Girl Helping, Guard 1 / The
first guard, Walking oldest / The Oldest, plus three lowercase spoken names that
differ from their profiles by one letter. The operator wants them eliminated and
was explicit that created and approved voices must not be lost.

umbraaudio/analysis/speaker_dedup.py
  Read-only fact gathering (profiles, ledger, voice refs, chapter spread) and a
  matcher that explains itself: determiner and ordinal normalization, superlative
  stemming, and three VETO rules, because string similarity alone would merge
  people who are not the same person:
    - distinguishing modifiers: The Oldest is not The Tallest; Guard 1 is not Guard 2
    - role nouns: Soldier (Rider) is not One Rider
    - proper names need ratio >= 0.88: Anfisa (0.77 from Tanisha) is a different
      character; Rielan/Rieland at 0.93 is a misspelling
  Verdicts auto / review / blocked. A group with two different voice references is
  blocked, because merging would discard a generated voice. Survivor selection
  prefers an approval, then a voice reference, then utterance volume.

Two rules were written and then removed, recorded in the spec so nobody re-adds
them: a "shared token plus role noun" rule proposed The Oldest = The Old Woman,
and a "single shared token" rule proposed Scum leader = The Leader. A missed
duplicate costs one manual merge; a wrong one silently rewrites a speaker.

Two real bugs, both found by running against a COPY of Last Dwarf rather than by
reading code:

1. orphan_resolution.merge_speakers never touches voice_status.json, so merging
   an approved speaker into an unapproved target would have deleted the approval
   record silently. merge_group() now carries the approval to the survivor,
   drops the loser's entry, and reports approvals_carried.
2. The first re-point was unscoped and moved 5,981 rows instead of 3: `narrator`
   is the labelling convention for ~5,978 NARRATION rows too, and renaming them
   would invalidate thousands of clips because every marker records the speaker
   exactly. The re-point is now dialogue-only. Narration casing is a separate,
   deliberate decision and is flagged as such, not taken by a duplicate scan.

Verified on a copy: 93 -> 84 profiles, 23 utterances re-pointed, narration
untouched, 72 -> 72 voice ref files with zero removed, 13 -> 13 approvals with
zero lost.

Last Dwarf scan: 10 auto, 2 review, 4 blocked. Blocked groups are exactly the
ones the operator must decide - The Girl (two voices for one person), Officer,
the three-way Narrator family, and Border Soldier vs Soldier (Rider).

Nothing was applied to the live project. tests/test_speaker_dedup.py: 37 tests,
vetoes weighted as heavily as positives. Full suite 575 passed / 8 failed, the
same 8 pre-existing failures. ruff clean.
2026-10-04 18:36:37 -07:00
B.D. Murphy
9f3a0a4b18 Release comment as a release step; releasing grants implicit voice approval
Two corrections from the operator, and I had both wrong.

1. "I didn't explicitly approve all minor character voices. By releasing the
   audiobook I have given implicit approval those voices are ready and should be
   tagged." The previous session reported "24 of 61 speakers have an approval
   record; 38 do not" as if it were a gap. It is not a gap. Releasing IS the
   approval, and those voices were waiting to be tagged.

   chapter_approved and audiobook_approved now read true, with
   approved_by: "this publication record" - there is no separate audiobook
   approval in this system, and pretending otherwise understated the release.

   The boundary matters, so it is measured rather than assumed: implicit
   approval covers voices that HAVE LINES in the released chapters, computed from
   the database. A character who never speaks is not covered, because releasing a
   book does not approve a voice it never uses. On Deal Hunter edition 1 that is
   24 explicitly approved, 29 implicitly approved, and 10 in-profile voices with
   no lines. The three categories are never summed - an explicitly approved voice
   can also be one that stopped speaking.

   grant_implicit_voice_approval() writes plain status strings, the shape the web
   UI already reads, and keeps the provenance in the immutable publication record
   rather than the mutable ledger, so it cannot be edited away.

2. The m4b inter-chapter gap should be the same 2 s as the lead and trail
   padding. export.py had gap_ms=4000 in one path and a default of 1000 in the
   other, while ar_export.SILENCE_MS = 2000 governed lead and trail: three
   drifting values for one concept. The gap now imports SILENCE_MS as
   CHAPTER_GAP_MS, so all three are one number that changes in one place.

New umbraaudio/release_comments.py - the comment is a gate, not a caption.

  designator  2026-10-03 #2 14:32:05
  slug        release-20261003-143205-02

The sequence is per date, not global, which is the whole point: a problem found
and corrected on the same day is unambiguous about which was last. Two comments
authored in the same second still get distinct slugs, because the sequence is
allocated by scanning the directory. First line is the summary, the rest is the
body, like a commit message. An approved comment is FROZEN - changing an approved
note is a different claim about a different state, so it needs a new comment and
therefore a new publication.

require_approved() blocks --publish: passing a draft exits 2 and says what to do
next. Verified live.

draft_comment_text() composes the comment from the measured build rather than
from memory. Deal Hunter's draft is written and waiting:
release-20261003-133508-01, 32 chapters, the 24/29/10 voice split, 32/32
loudness and peak gates green, and the post-approval padding note.

New invariants R-8 through R-11 in spec 22. Two of my own test premises were
wrong and the implementation was right - recorded in AGENTS.md because the tests
were the weaker artefact.

Full suite 538 passed / 8 failed, the same 8 pre-existing failures. ruff clean.
Nothing was published: the Deal Hunter comment is a draft awaiting review, and
voice_status.json is untouched because publishing never ran.
2026-10-03 06:49:42 -07:00
B.D. Murphy
ed3e249d0b Add the publish flag: pinned release sets, immutability, the Deal Hunter lesson
The operator clarified the release model: the system keeps current and previous
state, there must be an explicit publish flag the user sets, there are approvals
for a speaker voice but none for the audiobook or chapters, the chapters that
make the m4b are the versions to flag, and a fix found after assembly causes a
change rather than editing what shipped.

Deal Hunter is the precedent, and it is measurable rather than theoretical.
Against the real package (built_at 2026-09-09T11:21:11Z), chapter 6:

  ch6_full.wav                    2227.26 s @ 24000 Hz   what a reviewer listens to
  Deal_Hunter_01_Salvage.mp3      2231.26 s @ 44100 Hz   what a listener received
                                  +4.00 s = 2 s lead + 2 s trail

plus a resample and a -20.5 dB RMS normalization. Found on 32 of 32 chapters.
The approved artifact and the published artifact were different files, and
nothing in the system recorded either fact. ar_package.json is rewritten by
every rebuild, so it is a build status, not a release record.

umbraaudio/release.py
  DeliveryProfile.from_ar_package reads the profile the exporter actually used,
  so the record follows the code instead of a declaration that can rot.
  build_publication is read-only and pins every chapter wav, every mp3 and the
  package by full SHA-256, plus a per-chapter source_unit_key_digest so a release
  pins which source revision produced it, not merely which bytes.
  verify_publication returns ok / drift / warnings and never silently accepts a
  changed artifact. write_publication is atomic and refuses to overwrite an
  existing publication id - immutability enforced by the writer, not by
  convention. A later fix is edition n+1 with supersedes pointing at n.

The rule that prevents a repeat: you cannot approve a chapter and then pad it.
An approval must name the artifact a listener receives. Every record states
chapter_approved: false and audiobook_approved: false in its own words, because
that is what would have caught Deal Hunter.

The record also refuses to overstate approval. voice_status.json is an approval
LEDGER - it only holds speakers someone explicitly touched - so it has 24 entries
against 61 speakers in the profile. Reporting "24 approved" as if it meant "all
approved" would claim an approval nobody gave, so the counts are separate and the
38-speaker gap is named.

scripts/publish_release.py: dry run by default; --publish, --verify, --list,
--edition/--supersedes. Exits 2 while blockers exist.

Also found, reported and NOT fixed (specs/22 section 7 gap 1): export_m4b's
_stitch_chapter hard-codes 500 ms/0 ms silence while ChapterBuilder uses
AudioStitcher defaults and AudioConfig holds its own values, so the m4b path can
stitch a chapter with different silence than the approved one. Until that is
closed an m4b release record would pin the wrong bytes, so it is better not to
offer one yet.

And projects.status is never advanced to 'released' in any of the four live
projects - all sit at parsed/analyzed - so the publication record is currently the
only release signal in the system.

tests/test_release.py: 24 tests. Full suite 512 passed / 8 failed, the same 8
pre-existing failures. ruff clean. No export, TTS or project data was modified.
2026-10-03 06:20:22 -07:00
B.D. Murphy
829a03b89f Action the controller alignment proposal: source_unit_key + phase reports
The controller (oldbox) sent a data-model alignment proposal and asked for six
additive changes on this node, the validation protocol, and an honest report.
Six of the seven changes are done; the seventh is declared missing rather than faked.

Stage 0 - reachability verified exactly as documented: health 200, heartbeat 401
(the correct answer - auth is refusing), missing Host 400. Nothing else was sent.

source_unit_key (controller 5.1, the blocking item)
  umbraaudio/source_units.py. sha256("uk1" | chapter_source_text_sha256 |
  occurrence | text_sha256). Nothing positional in the key: "occurrence"
  disambiguates repeated text in document order.
  - 18,181/18,181 units keyed across all four projects, 0 collisions
  - chapter 1 renumbered +100 -> 0 keys changed (the load-bearing proof)
  - backfill 10,015 rows in 0.11s, second run stamps 0 (idempotent)
  - alignment phase 3: 200/200 probe units resolved with used_index: false
  - tolerates the older projects that have no chapters.chapter_index
  Recorded at generation time in chapter_builder, BEFORE the rows are read and
  BEFORE the marker is written, so every path that renders audio also produces
  identity and the clip can name its source unit.

5.7 recorded: utterance_index is KEPT as a display ordinal and made
  non-load-bearing. spec 21 §10.

Blob hashes and effect chain (5.2)
  Markers now carry the full sha256 of the delivered bytes and the effects that
  produced them. Verified the existing order is already correct - write_marker
  runs AFTER apply_effects - and closed the remaining hole: a changed trait list
  changed the hash with no recorded cause. sha256 verification is opt-in, since
  hashing every file on every validation pass would re-read gigabytes.

Vocabularies published (5.4) - umbraaudio/definitions.py, 19 sets, 126 values,
  each value carrying whether it is 'declared' (spec/controller) or 'extracted'
  (read out of code), so a diff shows a difference of meaning, not of wording.

Alignment report tool (6.6) - scripts/alignment_report.py --phase all. Emits the
  controller's envelope with the per-phase evidence shapes, and obeys the two
  rules they set: no evidence means blocked, never pass; a field we cannot produce
  is null PLUS a finding, never omitted. Phase 2 is deliberately 'fail' - the
  three-way store disagreement is real, and a pass there would be a check that
  passed by doing nothing.

Fingerprint discipline (5.5) - the drift detector reported its OWN fingerprint
  change when source_unit_key was added rather than hiding it. Node profile
  updated with the new fingerprints and an honest note that they moved.

Node report extended - per-project source-unit-key coverage, per-voice-asset
  sha256 + object_key, definition_set_version, controller stage reached.

Six gaps found in the proposal (spec 21 §11), including the one they invited plus
five more. The sharpest: "hash at generation" ignores that audio is
post-processed afterwards, and our key is anchored to chapters.source_text - a
second copy of the manuscript - so any change to chapter storage silently
invalidates every key in that chapter.

5.6 deliberately NOT faked: personality has no versioned artifact here, only
  unversioned LLM prose in speakers.json. Reported as phase 6b finding B-03
  instead, because wrapping unversioned inputs in a version field would produce
  artifacts claiming a lineage they do not have.

Answered the controller's two open questions (infra/controller-handoff-2026-10-03.md):
forward-only link for a revealed recurring character, and yes - a new edition may
contradict a frozen state, since this node's correction path IS a regeneration.

Full suite 484 passed / 8 failed - the same 8 pre-existing and unrelated failures.
ruff clean on every file touched. The parallel session's chapter_builder hunk
(helper_url) was filtered out of the commit and left intact in the working tree.
2026-10-02 20:16:06 -07:00
B.D. Murphy
507ed95424 Declare this machine an Umbra node + cross-machine system contract (spec 21)
A parallel effort is moving UmbraTrans into the main Umbra system on oldbox
(192.168.123.14). This commit makes this machine describable to that system and
records what the system still needs to decide. Everything is a proposal for the
controller to ratify; no architecture is decided here.

specs/21-umbra-system-nodes-tasks-and-changes.md
  Four workstreams, each with what exists, what is missing, and a proposal:
  - WS1 data model alignment / copy / verify / transition. Design is far along
    already (manifest.md + spec 20); the missing piece is ratification.
  - WS2 every machine is a node that gets assigned any task. Job types, leases
    and pools exist (spec 19 §12); the node half does not. Proposes node
    identity, capability advertisement, task/result envelopes, and refusal as a
    valid audited outcome.
  - WS3 cross-product change management. Nothing exists today. Proposes a
    requirement catalog owned by the controller, four change classes, and a
    distribute-then-confirm-with-evidence loop. Includes the worked example of a
    controller-owned entity (designation, pronunciation, voice attribute) that
    must apply across every product.
  - WS4 UI topology. Recommends hybrid: controller gets the system portal,
    the audio review UI stays on the worker because it is artifact-native and
    has no authentication at all. Adds a git-tag deploy + verify + confirm flow.

  Also: 25 questions grouped by who can answer, each naming what it blocks, and
  a task list for this machine (NOW / NEXT / LATER) where every task has a
  machine-checkable acceptance criterion.

infra/umbra-node.json
  This machine's declared profile: node_id umbraaudio-m4, role worker, services,
  capabilities, resource pools, constraints, project list with lifecycle, schema
  fingerprints, and an honest facts_for_the_controller block (empty tables, no
  stable utterance id, no auth, no shared-postgres client). No secrets - ids,
  names, labels and counts only.

scripts/umbra_node_report.py
  Read-only node report. Git state, listening ports, .env KEY NAMES only, per
  project row counts, and a schema fingerprint hashed over sorted
  table.column:type so ALTER TABLE reordering does not read as drift. --json for
  machine consumption; --check compares declared profile to measured reality and
  exits 2 on drift. Drift is deliberately limited to stable facts (contract
  version, project set, schema fingerprints) - git HEAD and live ports are
  reported but never drift.

manifest.md
  Now explicitly a dated measurement with an update log, because other machines
  read it as current. 2026-10-01 refresh: Last Dwarf speakers.json 70 -> 93
  after the designation merge, DealHunter ref WAVs 94 -> 97. Every other §1.1
  count re-verified unchanged.

Measured this machine: 4 projects, 2 distinct schema fingerprints (real
per-project drift), only 4 of 11 tables populated, 461 tests passing / 8 known
pre-existing failures. No UmbraAudio behaviour changed.
2026-10-01 07:14:54 -07:00
B.D. Murphy
4775e3b589 Add deterministic designation scanner; capture the ch5 speakers discovery missed
Ran discovery on Last Dwarf ch5 with the new over-detection prompt rules. It
improved (found 'The big one', 'The one on the left/right', 'The Leader',
'Girl helping' where it previously found none of them) but STILL dropped
'The oldest' and 'The tallest' from the same six-man crowd — the e4b model is
not deterministic enough to guarantee coverage.

So added a mechanical backstop that does not depend on the LLM at all:

- umbraaudio/analysis/designation_scan.py reads the three canonical
  speech-attribution shapes ('The oldest says,' / '"I thought they died\n
  off," says the tallest' / '"No," the old smith said.') and harvests every
  designation a speaker is referred to by. Post-filters pronouns, narration
  verbs ('Hallr Grips His Axe And'), finite-verb clauses, possessives
  ('Hallr's' -> 'Hallr', 'Sheyla's voice' -> 'Sheyla'), scene/internal labels,
  indefinite collectives, and tags that fall inside a quotation (that is
  someone being talked ABOUT, not speaking).
- scripts/merge_designations.py adds any designation the cast does not cover
  as an 'unidentified' speaker with its evidence line. Never removes or
  rewrites existing entries, atomic tmp+replace, idempotent (0 on re-run).
- scripts/run_discovery.py runs discovery to a SEPARATE output file so the
  curated speakers.json is never overwritten by a discovery run.

Result on Last Dwarf: 56 designations, 33 already covered, 23 genuinely
missing — including the ch5 crowd (The Oldest, The Tallest, The Young One,
The One With The Biggest Sword, The Biggest One, The Leader, One Girl Helping)
plus Manfred, The Sergeant, One Witch, The Soldier and others. Cast 70 -> 93,
all new entries flagged 'needs check' at 0.00 confidence.

Also: scripts/run_discovery.py, and a 14-test suite covering the ch5 passage,
possessives, quoted narration, finite-verb clauses and post-quote lowercase
subjects. Full suite 461 passed (8 pre-existing/parallel failures unchanged).
2026-10-01 07:04:44 -07:00
B.D. Murphy
3bc6945bb3 Add orphan name-token review UI (spec 17 Phase 3)
Gives the reviewer a single place to resolve every name the manuscript uses as
a speaker that doesn't resolve to a profile, so nothing reaches the translation
export unresolved.

New (separate router module, so the UI never has to merge into the hot
routes.py):
- umbraaudio/web/name_token_routes.py: /name-tokens page, /name-tokens/partial
  (HTMX), POST /api/name-tokens/scan, and per-token assign/create/dismiss, plus
  /api/speakers/merge-by-name. Registered in create_app.
- templates/name_tokens.html + _name_tokens_list.html: token table with kind,
  use count, chapters, evidence, status, and inline Assign / New speaker /
  Dismiss actions. Shows a one-click suggestion for name-like tokens.
- base.html nav: Name Tokens + Review (/exception-review) links.
- name_tokens.py: shared scan_project() + build_alias_map() so the CLI and the
  web scan use identical logic.

Fixed a pre-existing 500 on /exception-review (now linked in the nav): it called
the old Starlette TemplateResponse(name, context) signature, so the context dict
was used as the template name. Switched to (request, name, context) and replaced
the hardcoded project name DealHunter with the active project.

Verified live: /name-tokens 200 with 8 rows; scan API persisted 9 tokens;
assign error paths return 400; dismiss round-tripped through HTTP with the
scan evidence preserved (Phase 2 fix) and was then reverted. All nav pages 200.

Full suite 424 passed; the 8 failures are the pre-existing/parallel-session
voice_profile_manager + test_pronunciation ones.

routes.py was committed with ONLY the two hunks that are mine (name_tokens
schema + the exception_review fix), via a filtered patch. The parallel session's
5 uncommitted helper_url hunks are left untouched in the working tree.
2026-09-25 03:20:59 -07:00
B.D. Murphy
4d900d77a6 Fix 3 Phase 2 bugs found by infra review
1. merge_speakers never committed its utterance re-normalization. The CLI
   action path also returned without closing the connection, so Python rolled
   the transaction back on exit — merges silently lost every utterance rename.
   Now commits explicitly, and the CLI closes via try/finally.
   Verified: merging Guard 1 -> Guard 2 moved 6 utterances; a fresh connection
   sees 0/6 instead of the old 6/0.
2. assign_token_to_speaker wrote a dict into alias_evidence, but the discovery
   schema is list[str] and CharacterDiscoverer._alias_has_evidence() calls
   .lower() on each item — the next character-discovery run would have raised
   AttributeError. Now writes a descriptive sentence that also contains the
   alias, so the evidence check returns True for it.
3. dismiss_token overwrote evidence_json with the reason, discarding the scan
   evidence. Now preserves the original list and appends 'dismissed: <reason>'.

Added regression tests: cross-connection merge commit (file-backed DB),
alias-evidence string-list schema + discovery compatibility, dismiss evidence
preservation. 46 resolution/scanner tests green; 85 across Phase 1+2+discovery.
2026-09-24 11:31:33 -07:00
B.D. Murphy
c338370f19 Add orphan name-token scanner + resolution actions (spec 17 Phase 2)
Surfaces proper-noun speaker identities in the manuscript that do not resolve to
a known speaker profile, so each can be assigned/created/dismissed/merged by a
reviewer instead of silently mis-attributing.

New:
- umbraaudio/analysis/name_tokens.py: scans two high-signal evidence positions
  (unresolved proper-noun speech tags + un-profiled speaker_name values) rather
  than every capitalized word (594-token noise -> 9 real orphans on Last Dwarf).
  Groups book-wide, offers fuzzy suggestions (word-overlap / difflib / soundex /
  4-char common-prefix misspelling), and suppresses personal-name suggestions
  for role nouns. Persists to a new name_tokens table (idempotent, preserves
  human status across re-runs).
- umbraaudio/analysis/orphan_resolution.py: assign-to-speaker (add alias +
  evidence, re-normalize utterances), create-speaker, dismiss, merge-speakers.
  Merging is idempotent; matching is case-insensitive.
- scripts/scan_name_tokens.py: scan CLI (--write) + one-shot actions
  (--assign/--create/--dismiss/--merge).
- DB: name_tokens table in database.py SCHEMA (web routes create it on demand via
  ensure_name_tokens_table, so no shared-file conflict).

Last Dwarf: 9 orphan tokens (8 role speakers + 'Tobias Teppo' name-tag which
suggests 'Tobias'). Merge candidates Guard 1/Guard 2 + Narrator/OrigNarrator are
real speakers flagged for the merge action.

Tests: tests/test_name_tokens.py (30) + tests/test_orphan_resolution.py (12) green.
ruff/mypy clean (TC003 sqlite3/Path notes match the repo's existing pattern in
database.py). Full suite: 420 passed; the 2 test_pronunciation failures and 6
voice_profile_manager accent failures are pre-existing/parallel-session (a
concurrent 'qwen_tts_helper_url' change), unrelated to this work.

NOT committed: umbraaudio/web/routes.py name_tokens schema hunk — that file is
actively being edited by a parallel session (mixed with its helper_url changes);
left in the working tree to avoid a merge hazard. Table is created on demand, so
nothing is functionally blocked.
2026-09-24 11:27:33 -07:00
B.D. Murphy
1bbbe5d92c Add utterance clarity signals + review queue (spec 17 Phase 1)
Persist per-utterance attribution transparency so the system can auto-accept
high-confidence lines and surface only a small review queue:

- DB: utterances gains attribution_evidence_kind, attribution_tag_name,
  attribution_uncertain, needs_review, review_reason; attribution_confidence
  default -> NULL. Idempotent migrations in database._migrate, routes
  _ensure_utterance_columns, and a new ensure in reattribute_all (raw-sqlite
  driver must add columns itself).
- llm_attributor: accumulates scene-level mechanical evidence across batches and
  stamps evidence kind + tag name on attributed utterances.
- New umbraaudio/analysis/clarity.py: deterministic evidence classification
  (name/role/pronoun/address_only/none), in-scene pronoun resolvability
  (gender-filtered candidate count), and composite needs_review rule:
  QA issues (long_monologue excluded), non-unique pronoun referent, role tag
  with no profile, none/address_only evidence < 0.75, unresolved uncertain
  flag, narrator-left dialogue, confidence < 0.7. Addressee counts only for
  known names/titles (guards 'But where...' false positives).
- Drivers persist all fields: reattribute_all (per-chapter clarity pass),
  run_llm_attribution (via update_utterance_speaker), both web attribution
  paths. _populate_exceptions keys off needs_review (was confidence<0.7 +
  keyword sniff) and fixes a latent NameError (pid not in scope).
- tests/test_clarity.py: 25 tests. Eval on Last Dwarf: 3918 dialogue lines ->
  173 needs_review (4.4%), within the 1-5% target.

Full suite 380 passed; 6 pre-existing voice_profile_manager accent failures.
ruff/mypy clean on new code; routes.py baseline unchanged (73 pre-existing).
2026-09-23 12:15:39 -07:00
B.D. Murphy
b09e324431 Multi-pass attribution + web voice-maintenance banner + qwen demo branding
Attribution (from prior session, now committed):
- llm_attributor: mechanical narration evidence, unnamed-role rules,
  wider context (12->20 lines, 8->40 utterances), uncertain flag,
  verify pass with 20-item batches and invented-name gate
- reattribute_all: --min-id/--max-id, --dry-run, --no-verify,
  --verify-rounds, qa_accepted skip guard, per-chapter elapsed times
- test_multipass_attribution: 19 tests

Web banner:
- voice_maintenance module: pid-liveness-checked marker file so a
  crashed run self-heals the banner away
- routes/base.html: amber banner on every page while the LLM
  re-attribution run has Qwen3-TTS offline
- reattribute_all: sets banner before switching models, clears in
  finally (also on failure)
- test_voice_maintenance: 8 tests

qwen3-tts submodule: UmbraAudio Voice Studio branding in web demo
(icon data-uri + favicon logo).
2026-09-22 19:12:50 -07:00
B.D. Murphy
5ed9db7871 Add weekly launchd commit reminder (com.umbraaudio.commitcheck)
Wires the commit-cadence policy to an actual reminder instead of a script
nobody runs.

- scripts/commit_check_notify.py: runs scripts/commit_check.sh (piped to
  bash -s so the script is never opened on the external volume), logs to
  logs/commit_check.log and posts a macOS notification via osascript when the
  tree is dirty. Defaults to --any (week's work) ; --age-only restores the
  >threshold behaviour; --no-notify for testing.
- scripts/commit_check.sh: new --any mode (warn on any uncommitted change,
  ignoring age) for the weekly job.
- LaunchAgent fires Sunday 18:00; launcher
  ~/.umbraaudio/umbra_commit_check.sh mirrors umbraweb_login.sh (cd project,
  export PATH, exec .venv/bin/python). The venv python is the only interpreter
  approved for the external volume, so a shell never reads a project file.
- Verified under launchd: detects dirty tree, notification sent, exit 1;
  clean tree exits 0. Probe artifacts removed.
2026-09-21 07:09:52 -07:00
B.D. Murphy
2ea2b7c065 Add scripts/commit_check.sh weekly commit-cadence guard
Mechanical nudge for the policy in AGENTS.md -> "Commit Cadence": it measures
how long the working tree has been dirty (oldest mtime among staged, unstaged
and untracked files) and exits 1 once that exceeds --days (default 7).

- Ignores submodule working-tree dirt (separate repo); still surfaces a moved
  submodule pointer.
- Reports dirty age, last edit, last commit; prints a reminder block.
- --quiet silences the clean case only; --help documents exit codes.
- Bash 3.2 compatible (macOS /bin/bash); no external deps.
- Verified: clean/dirty/old-dirt/threshold/bad-args/quiet all behave.

Also cross-reference it from AGENTS.md and README.md.
2026-09-21 07:04:52 -07:00
B.D. Murphy
df36dad8aa Weekly commit: attribution QA, dramatic variations, GPU TTS, AR export
Squashes ~3 weeks of uncommitted work (2026-09-03 → 09-21). The repo is the
only durable copy; a single accidental `git checkout` on routes.py nearly lost
a month of work (recovered from an editor snapshot, not git). See AGENTS.md →
"Commit Cadence" — commit every Sunday.

Attribution & QA
- New umbraaudio/analysis/attribution_qa.py: mechanical check_attribution /
  fix_high_confidence / verify_conversation_flow (speech-tag mismatch, narrator
  dialogue, role-tag guessing) plus scripts/qa_attribution.py CLI.
- llm_attributor prompt rules 3e-3h (unnamed roles, pronoun coreference,
  repeated lines) with wider scene/position context.
- Per-utterance QA box in chapter audio detail UI: hover issues, Recheck
  (auto-fix loop) and Accept (persisted qa_accepted* + text/speaker signature,
  auto re-surfaces when either changes). New POST /api/utterance/{id}/recheck
  and /api/utterance/{id}/qa-accept. _ensure_utterance_columns migration fixes
  legacy DBs that lacked the columns.

Generation
- Dramatic utterance variations: 26 emotions + pace + custom instruct,
  composed into every TTS voice mode (clone/icl/ref/embedding) and all
  generation sites; markers record the instruct so edits re-trigger.
- Staleness markers are JSON {speaker,text}; legacy plain-text markers always
  stale.
- Scene-break segmentation (generation/scene_break.py) + resegment merge.
- AR package exporter (assembly/ar_export.py) with tests.
- Voice ref resolution: rglob refs, canonical alias / first-word fallback.
- Edge-TTS fallback visibility: durable voice_engines.json ledger + UI badge.

GPU / TTS server
- qwen_tts_server: instruct + clone/icl support (voice_description →
  instruct_ids prepended to the talker input).
- qwen_tts.py remote path sends voice_description alongside voice_ref/slug.

Config / ops
- .env: UMBRA_TTS__QWEN_TTS_URL=http://localhost:8881.
- .gitignore: book projects, tmp_out, logs, one-off samples A/B demo.
- AGENTS.md + README.md: Commit Cadence (commit at least weekly, on Sunday).

New tests: test_qa_acceptance, test_variations, test_ar_export,
test_scene_break, test_resegment_merge, test_vocals_project. Suite: 327 pass
(6 pre-existing voice-profile accent-wording failures, unrelated).
2026-09-21 06:44:22 -07:00
B.D. Murphy
85b1137a2e Implement multi-project isolation: no hard-coded project_id, WAL, web New-Project flow, verifiers
Phase A: derive the active project id dynamically (active_project_id) instead of
hard-coded project_id=1 in cli/orchestrator/scripts/web; enable SQLite WAL on the
project + voice-registry DBs; parameterize tts_server/sync_voice_data.sh via
UMBRA_DATA_DIR/PROJECT_SLUG.

Phase B: reusable project_bootstrap.create_project_from_manuscript (+ async
variant for web) boots an isolated project dir + DB from a manuscript; new
POST /api/projects and /projects/new page + "New Project" button let a second
book be created and activated (active_project cookie) without touching the
current project. Fix async route (asyncio.run in running loop) and source-path
persistence ordering bugs in the new endpoint.

Phase C: list_projects() skips the legacy root .umbra so /projects is clean;
load_config() now resolves a bare project name under PROJECT_BASE even when the
dir doesn't exist yet (new-project creation no longer depends on CWD); add
tests/test_multi_project.py (5 tests).

Phase D: scripts/verify_projects.py + scripts/verify_nas.py idempotent
integrity/mirror checkers.

All 249 tests pass.
2026-09-03 20:29:21 -07:00
B.D. Murphy
a172610b0e Add voice library, embedding-mode generation, and webUI improvements
- Add cross-project voice/name library (library.py + webUI library tabs)
- chapter_builder: embedding-mode generation + auto-load TTS base model in preflight
- qwen_tts: vmatrix capture, embedding/voice-prompt refinements
- registry: vmatrix_path column + migration for approved voices
- webUI: speakers list, chapter audio detail, proper names updates
- docs: update voice generation and tts_server README
2026-09-02 17:33:15 -07:00
B.D. Murphy
c86e323d7a Initial commit: UmbraAudio audiobook generation program
Quality-first multi-pass audiobook generation from English manuscripts.

Includes:
- umbraaudio/ core package (pipeline, config, voices, web review UI)
- scripts/ CLI utilities + model download script
- tts_server/ remote TTS server
- specs/ design docs, tests/, pyproject.toml + uv.lock
- Qwen3-TTS git submodule (pinned 022e286) with local demo.py UI patch
  saved at scripts/qwen3_tts_demo_umbra_patch.patch
- .env with runtime config/secrets

Excluded (see .gitignore): models/ (downloaded via scripts/download_models.sh),
output/, logs/, .venv, .umbra runtime data, and book projects
(DealHunterShip_*), which are committed separately.
2026-08-30 07:58:24 -07:00