Commit Graph

81 Commits

Author SHA1 Message Date
umbra audio
19ee5db43a Merge branch 'main' into chore/untrack-env
# Conflicts:
#	.env
2026-10-19 04:36:00 -07:00
umbra audio
03f8a858dd Add Apache-2.0 LICENSE, NOTICE, and SPDX headers 2026-10-18 19:01:04 -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
948034c365 Add an SLang tab listing the multilingual sample chapters
The rendered sample chapters existed on disk but had no way to be listened
to through the UI. New /lang-samples page, reachable from a new SLang nav
tab, lists every language chapter with an inline player plus the combined
all-languages file.

- Serves from <project>/Lang_test, which is where render_lang_test.py writes.
  Deliberately project_dir and not data_dir: .umbra is gitignored state and
  the render output lives beside the rest of the project.
- Languages are discovered from disk in a fixed order rather than hardcoded,
  so an unrendered language simply does not appear instead of offering a
  player that 404s.
- The audio path validates a two-to-three letter code and builds the
  filename server-side, so it cannot be walked out of the sample directory.
  Verified: %2e%2e and over-long codes are rejected 400, unauthenticated
  requests 303 to login.
- A missing language now 404s rather than falling back to the combined file.
  It did before, so an unrendered language would silently play the wrong
  audio - the same class of bug as a stale speaker name resolving to no
  voice at all.

Verified in a browser: 11 players, durations resolve (EN 92.3s, ZH 92.0s,
combined 1067.8s), no load errors, range requests 206 so seeking works.

routes.py stays at its 76 pre-existing ruff errors. Suite: 721 passed,
8 pre-existing.
2026-10-13 02:39:29 -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
43a2853ee3 Research: multilingual TTS models for the 15-language catalogue
Survey of cross-lingual voice cloning, as a companion to the speaker-review
audit that gates the translation handoff. Recorded as research, not a
decision.

Terminology correction up front: this is a TTS problem, not an LLM problem.
Reproducing a voice in another language is cross-lingual voice cloning; an
LLM is still needed for the translation itself but plays no part in voice
fidelity.

Headline findings:

- Qwen3-TTS, already running locally, covers 10 of the 15 languages
  (en zh ja ko de fr ru pt es it). The gaps are Dutch, Norwegian, Polish,
  Arabic and Hindi - so no single model does this job.
- Licence is the deciding constraint, because the books are sold. XTTS v2
  covers 14 of 15 and is the obvious capability choice, but its weights are
  Coqui Public Model License (non-commercial) and Coqui shut down in Jan 2024,
  so no commercial licence can be bought. Fish Audio S2 (83 languages) and
  F5-TTS are likewise non-commercial weights.
- Recommendation is a two-engine split: Qwen3-TTS (Apache 2.0, verified from
  the licence file) for its 10, Chatterbox Multilingual (MIT) for the 5 gaps.
  Together they cover all 15.

The 'voice matrix' question, which the operator raised directly:
voice_registry stores two different things. voice_ref_path is the reference
WAV and is portable to any clone-from-audio engine; vmatrix_path is a
Qwen3-TTS ICL artefact (8k ref_code + speaker embedding, .pt) and is
engine-specific and unreadable by anything else. Deal Hunter already has 91
reference WAVs, so voice fidelity across languages needs no retraining and no
matrix translation - just handing the same WAV to whichever engine covers that
language.

Recorded honestly: the Chatterbox language list came from a third-party API
wrapper, not Resemble AI's own model card, and that build reports Chinese as
disabled. Verification against upstream is listed as open question 1.
2026-10-11 21:59:06 -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
e5bb555e01 Name the deleted speaker in the confirmation toast
The handler read the name back out of the response, but the endpoint returns
the re-rendered list as HTML, so the JSON parse fell back to a placeholder
and the reviewer saw "Deleted speaker." instead of the name they clicked.
The name is already known at the call site - pass it through, and drop the
unused nameOf helper.

Verified in a browser: "Deleted ZZ Browser Delete."
2026-10-11 21:40:02 -07:00
B.D. Murphy
970e6e884a Implement DELETE /api/speakers/{index}; the Delete button did nothing
The speakers page has carried a Delete button that called
DELETE /api/speakers/{index}. That endpoint did not exist - the server
answered 405 Method Not Allowed and the button silently did nothing.

Deleting is more than dropping an entry, so the endpoint refuses to orphan
dialogue: a speaker with utterances returns 409 with the count and the list
of possible targets, and the UI asks where that dialogue should go.
reassign_to then moves every utterance recorded under the speaker's name OR
any of its aliases - utterances are frequently stored under an alias - and
keeps the old name as an alias on the target so existing references still
resolve. A speaker with no dialogue is simply removed.

Also removes the speaker's own reference audio (never the shared narrator or
general ref) and its voice_status approval entry, so a speaker re-created
later cannot inherit an approval nobody gave for it.

The button is no longer hx-delete: htmx does not swap a 409, and deleting
renumbers every speaker after it, so the handler re-renders the whole list.

Verified live on Deal Hunter, reversibly: create throwaway -> delete (200,
gone) -> recreate with an utterance attached -> delete (409, count 1, 61
targets offered) -> reassign_to=Warbots (200, utterance restored, name kept
as alias) -> compared against a pre-test backup, speaker count and utterance
count identical.

10 new tests. Suite: 708 passed, 8 pre-existing.
2026-10-11 21:37:59 -07:00
B.D. Murphy
44d77cdc9b Resolve a queued voice to its speaker by alias, not just exact name
Every item in Deal Hunter's voice_queue.json had failed with
error: "'primary_name'". _speaker_info matched only an exact primary_name,
so any other spelling returned {} and build_profile_from_speaker subscripts
that key directly - the job died before synthesising a single sample and the
reviewer saw only an error tag.

That is a live trap for Save & Re-voice specifically: renaming a speaker and
then re-voicing it leaves the queued name matching nothing.

- Case-insensitive, whitespace-tolerant match, then an alias pass.
- An unknown name now raises LookupError naming the speaker, instead of a
  KeyError about a dictionary key three frames away.

Fixed in the queue layer rather than voice_profile_manager.py, which has 6
pre-existing failures in that area and should not be disturbed by this.

8 new tests. Suite: 698 passed, 8 pre-existing.
2026-10-11 21:00:43 -07:00
B.D. Murphy
4ac0388d7b Stop the voice-queue poller when the payload is not what it expects
pollVoiceQueue began with 'if (!s || !s.items) return;'. On an unexpected
payload that returned before the code that clears the interval, so a page
left open polled /api/voices/queue every 2s forever. The web log shows 626
such polls - about 21 minutes of continuous requests from a browser through
the Caddy proxy, with CLOSE_WAIT sockets accumulating alongside.

An unexpected response now stops the poll rather than skipping the check.
Verified in a real browser against a deliberately malformed payload: one
request, then stopped, where it previously never stopped.
2026-10-11 20:58:52 -07:00
B.D. Murphy
9328159ec1 Fix re-voice never firing, by driving it from the event that carries the form
Reported: Save & Re-voice still gave no response after the previous fix.

That fix bound hx-on::after-request to the form. It never fires. htmx
reports a different detail.elt for each phase of this request - measured in
a real browser:

  beforeRequest  FORM
  beforeSwap     TR
  afterRequest   TBODY

Row swaps get re-parented into a table context, so afterRequest is
dispatched on the enclosing TBODY. A listener on the form never sees it,
because the event does not bubble downwards. An earlier probe suggested the
attribute worked, but that probe used a form outside a table, where elt
stays the FORM - it did not reproduce the real page.

So the profile was being saved (the PUT fired and swapped) while the
re-voice never ran and nothing on screen confirmed anything. That is
exactly "no response".

- Capture the row index at beforeRequest, where detail.elt is still the
  form, and match it to the completing request by path at afterRequest so
  a different response can never trigger a re-voice for the wrong speaker.
- Saving now shows a confirmation toast. The button previously looked dead
  even when the edit did save.
- Tests now guard the mechanism, including that hx-on::after-request is not
  reintroduced on this form.

Verified by clicking the button in a real browser on Deal Hunter's Dealer:
POST /api/voices/queue with {"speaker_name":"Dealer","row_index":14} and
the confirmation shown. Suite: 690 passed, 8 pre-existing.
2026-10-10 20:20:43 -07:00
B.D. Murphy
305f6a3912 Fix Save & Re-voice silently discarding every speaker edit
Reported: editing a speaker, then "Save & Re-voice", did nothing.

This is a regression from e810b49 (the voice-queue commit), which deleted
the body of queueVoiceRegenOnSave but left the edit form calling it as
onsubmit="return queueVoiceRegenOnSave(...)". A JS handler that falls off
the end returns undefined, which is falsy, so the browser cancelled the
submit. The PUT never fired: no request, no error, no change. It affected
every field on the speaker edit form - name, gender, traits, accent - not
just re-voicing.

- Re-voicing is now hooked to hx-on::after-request, so the new profile is
  on disk before the voice is rebuilt from it, and a failed save does not
  queue work. The handler re-reads the name from the swapped row, so a
  rename regenerates under the new name instead of the old one.
- The backend was never at fault: a full round-trip through
  PUT /api/speakers/0 with the record's own values leaves every field
  byte-identical.
- New tests/test_speaker_edit_submit.py guards the class of bug, not just
  this instance: no onsubmit="return ..." in the row template, and any
  onsubmit that returns a value anywhere must return a literal true/false.
  Comments are stripped before scanning, since the hazard is documented in
  one. Verified by reintroducing the bug - 3 of the 4 tests fail.

Suite: 688 passed, 8 pre-existing.
2026-10-10 20:04:41 -07:00
B.D. Murphy
7dde69ebb3 Name the book on the speakers page and add a project switcher
Reported: the speakers page showed 51 needing review in Chrome on Windows
and 20 in Safari on the Mac, and showed no names starting with D including
"Deal Hunter" - despite several reloads.

Neither browser was wrong. The active project is a per-browser cookie, so
each browser was on a different book and the two lists are unrelated:

  Last Dwarf                 91 speakers, 51 need checking, 0 D-names
  DealHunterShip_4.0e        61 speakers, 19 need checking, includes
                             Deal Hunter, Dealer, Distorted Voice,
                             Duchess Victoria, Duty Nurse

"Deal Hunter" is the ship AI in the Deal Hunter book and is not a Last
Dwarf character, so its absence there was correct. The page gave no hint
that the book was even part of what was being displayed.

- The speakers page now shows a "Reviewing <project>" banner next to the
  counts, so the number and the book it belongs to are read together.
- Added a project switcher directly on that page; previously changing
  project meant navigating via the dashboard.
- activate_project accepts ?next=, restricted to same-site relative paths
  so the switcher can stay put. Verified //evil.com and https://evil.com
  both fall back to "/".
- list_projects registered as a template global, following the existing
  active_project pattern; it degrades to [] on any error.

routes.py stays at its 76 pre-existing ruff errors. Suite: 684 passed, 8
pre-existing.
2026-10-10 19:45:47 -07:00
B.D. Murphy
2fda85f9d3 Make approval visible, and fix a real key-truncation bug in the auth gate
Follow-up to the badge fix: approving a speaker removed it from the
needs-check panel with no confirmation, which reads exactly like the
speaker being deleted. Reported as "the speaker advisor isn't shown now".

- A person-approved speaker now renders "✓ reviewed" with a tooltip saying
  so. It previously rendered "✓ clear" whose tooltip claimed
  "auto-accepted" - a false claim about a decision a machine never made.
- Approving confirms itself with a toast, drops the entry from the
  worklist, and updates the header counts, all without a reload.

Separately, a pre-existing flake in tests/test_auth.py turned out to be a
real defect rather than test noise:

  _session_secret() read the key file with .strip(), but bytes.strip()
  removes ASCII whitespace from both ends. A secret beginning or ending
  with one of those six bytes (4.35% of generated keys, measured) came back
  one byte shorter on every read after the write - a different HMAC key,
  silently, so a token signed before the flip could never be verified again.
  Now read verbatim, with a length floor to reject a corrupt file.

That test also lacked the isolated_store fixture, so it read and rewrote the
real .umbra/session_secret - running the suite mutated production state.
This install's key is 32 bytes with no whitespace at either end, so no
existing session is invalidated by the fix.

Also verified: Advisor remains at display position 1 of 91 with its
utterance (utt 3668, ch15) intact - only the warning badge changed.

auth.py test flake 4/25 runs -> 0/30. Suite: 684 passed, 8 pre-existing.
2026-10-10 19:18:04 -07:00
B.D. Murphy
a4c88f8bde Make speaker approval actually clear the badge, without navigating
Reviewer report: pressing Approve left the "needs check" badge in place and
appeared to navigate to a new page.

Root cause was a split brain, not a single bug. Approve writes the
voice_status.json ledger, but speaker_confidence read the voice_status key
embedded in speakers.json. That inline copy is stale - the web layer
overwrites it from the ledger on read (_load_speakers), so the page showed
the right value while the assessment computed from the old one. In Last
Dwarf, "Advisor" was 'pending' in speakers.json and 'approved' in the
ledger, so the reviewer's click had saved and still left it flagged.

- assess_speakers() takes the ledger as an explicit argument, authoritative
  over the inline key; load_speaker_assessments() reads voice_status.json.
- New SpeakerAssessment.reviewed flag, surfaced in the serialized view.
- Approval clears the badge but keeps the reasons visible: the underlying
  facts (role-only name, one utterance) stay inspectable on hover rather
  than being silently discarded.
- Corrupt/ non-dict ledgers degrade to {} - this module never raises.
- Approve swapped #speakers-list with innerHTML, replacing the whole
  reviewer list with a single <tr>; now swaps only its own row via
  outerHTML on #speaker-row-N.
- The review panel explained only rename/merge and never mentioned Approve,
  so the process was unclear; it now lists the three outcomes.

16 new tests. Suite: 681 passed, 8 pre-existing failures unchanged.
2026-10-10 18:46:07 -07:00
B.D. Murphy
e810b49d2a Add a voice-regeneration queue with inline per-speaker state
Reviewing speaker exceptions meant clicking Regenerate, watching a modal that
blocked the page, then navigating back to find out what happened. Worse, the
route refused a second request with a 409 while one was running, and
/api/generation/status exposed only a single global `active` flag - so there
was nowhere to put a second speaker and nothing to render per row.

Now: POST returns as soon as the task is captured, the row shows a status tag
(queued / working / done / failed), and further speakers can be queued while one
generates. No navigation, no modal.

umbraaudio/voices/voice_queue.py
- Ordered, serial queue. TTS is strictly serial here, so items run one at a
  time in enqueue order; the second speaker waits rather than contending.
- Per-item state, persisted to voice_queue.json. An item left `running` by a
  crash is marked `error` on load - the honest outcome, since silently
  re-running could write audio nobody asked for twice.
- Enqueueing a speaker that is already pending is a no-op returning the
  existing state, so a double-click cannot queue the same work twice.
- `cancel` withdraws a queued item but refuses one already running.
- Status vocabulary (queued/running/done/error) is deliberately small and
  stable: it is what the controller will map onto node task states.

umbraaudio/web/voice_queue_routes.py - POST /api/voices/queue (capture the task),
GET /api/voices/queue (report per-item state), POST .../{speaker}/cancel. Kept
out of the hot routes.py, following name_token_routes.

/api/speakers/regenerate-voice is now a thin shim onto the queue so a
bookmarked call still works and the two paths cannot race. Net -24 lines in
routes.py; the blocking implementation is gone, not commented out.

The UI's modal, its 409 handler and the page-load resume handshake were removed
(67 lines) rather than left dormant - queue state is re-read from the snapshot
on load and after each HTMX row swap, so it survives navigation.

Unknown speaker names are rejected with a 404 at enqueue time. Previously they
reached the profile builder and surfaced as a bare KeyError('primary_name') in
the failed tag.

Verified live: two speakers enqueued back to back, first ran while the second
stayed queued, then the second started automatically on completion; both wrote
real ref WAVs. 16 new tests. Full suite 665 passed, 8 pre-existing failures.
2026-10-09 19:45:46 -07:00
B.D. Murphy
8ad12534cd Document the login gate and its security properties 2026-10-09 12:39:00 -07:00
B.D. Murphy
2749c5249f Add single-user login gate to the review UI
The UI had no authentication at all: `active_project` is an unsigned cookie
and the server binds 0.0.0.0, so every page - including the mutation endpoints
and the audio downloads - was reachable by anything on the LAN and, through
the Caddy reverse proxy, from the internet. Spec 21/22 recorded this as an
unauthenticated mutation surface; this closes it locally until the controller
on oldbox takes over identity.

Gate is middleware, not per-route dependencies, so a newly added route is
protected by default and forgetting a decorator cannot expose the UI. It is
fail-CLOSED: an exception while checking a session denies the request, because
falling through to "no session required" would turn a bug into an open door.

- umbraaudio/auth.py: PBKDF2-HMAC-SHA256 (stdlib, per the project's
  stdlib-before-third-party rule) with a random per-user salt. Sessions are
  HMAC-signed and carry no secret, just username and expiry. All comparisons
  use hmac.compare_digest. Credential writes are atomic (tmp + os.replace) so
  an interrupted save cannot lock the user out, and both the store and the
  session secret are chmod 0600 under .umbra/, which is gitignored.
- First use: seeds username `bdumbra` with the one-time password `First321`,
  stored hashed and marked must_change, so the bootstrap password never lands
  on disk in plaintext and is dead after the first login.
- Password rule: more than six characters, at least one letter and one special.
- Sessions last 14 days, sliding.
- Login failures are deliberately vague: a wrong username and a wrong
  password produce the same message, so the form cannot be used to enumerate
  accounts.

Verified live: unauthenticated requests to /, /speakers, /name-tokens, the
export endpoints and the ZIP download all 303 to /login; the first-use password
forces /change-password before any other page; weak, mismatched and reused
passwords are rejected; the old password stops working; tampered, forged and
expired session tokens are all rejected. Static assets remain reachable so
the login page renders. 19 new tests. Full suite: 649 passed, 8 pre-existing
failures.
2026-10-09 12:38:53 -07:00
B.D. Murphy
b521f76bfe Add a single-file ZIP download for the AR MP3 package
The dashboard had per-track MP3 links but no way to get the package as one
artifact, which is what a distributor actually wants. New
GET /api/export/download-package.zip streams every track plus ar_package.json
so the download describes itself: order, roles, durations, measured RMS/peak.

Stored, not deflated - the MP3s are already compressed, so deflating 614 MB
would burn CPU for no gain.

Two bugs found while verifying rather than assuming:

- buffer.seek(0) sat INSIDE the ZipFile context manager, so it rewound before
  the central directory was written. Every download came back 1,763 bytes
  short and unzip could not find the end-of-central-directory record. Moved
  the seek after the block closes; now "No errors detected" and all 32 MP3s
  plus the manifest compare byte-for-byte identical to the originals.
- strict= is a zip() builtin parameter, not a ZipFile one. Ruff's B905 nudge
  had me add it to ZipFile(), which raised TypeError and produced an empty
  200. Reverted; the one remaining B905 is pre-existing elsewhere in the file.

Also surfaced the accepted_deviations from the manifest in the dashboard: a
collapsible table of the tracks below the RMS floor, with their measurement,
peak, crest factor and the rule they depart from, so the exception is visible
where the numbers are rather than only in the JSON.

Verified error paths: no active project -> 400, project without a built
package -> 404. Lint unchanged (77 before and after). Full suite: 630 passed,
8 pre-existing failures.
2026-10-09 07:43:14 -07:00
B.D. Murphy
38da048669 Record accepted loudness deviations; quarantine orphan closing-track audio
Single master MP3 set for both distributors, with the two sub-floor chapters
left as they are rather than limited.

accepted_deviations in the manifest, for tracks that are peak-compliant but
still under the RMS floor. Both sit at exactly -3.00 dB peak, so there is no
headroom to add the ~3 dB of gain they would need: a 20-21 dB crest factor
makes RMS -20.5 and peak -3.0 mutually exclusive at this gain. A limiter would
meet the number by squashing transients, which is a worse trade for a
narration-led book. Recorded as a decision with its measurement and reasoning
so a reviewer reads it instead of rediscovering it from a warning string.

Deal Hunter: "The Poker Game Distraction" (-23.80, crest 20.80) and
"Hunting Pirates" (-23.10, crest 20.10). The 30 compliant tracks span
crest 17.60-19.90, so the two separate cleanly and nothing marginal is swept
into the exception.

Also quarantined three orphan WAVs in ch35 (utt_0003/0004/0005, 40.9 s, dated
2026-08-29). They predate the closing track being rewritten to three
utterances, have no marker and no DB row, but the stitcher globs utt_*.wav -
so they were silently included in ch35_full.wav and in the shipped m4b, whose
closing chapter was 64.5 s where the tracked audio is 20.3 s. Moved to
chapter_audio/_orphans_ch35_20261009/ rather than deleted. Only ch35 was
affected; the book-wide orphan scan found no others.

Rebuilt both artifacts from the cleaned chapters: 32 MP3s all peak/rate/bitrate
compliant, and the m4b closing chapter is now 21.3 s.
2026-10-09 00:17:25 -07:00
B.D. Murphy
253f2c8b1c Fix RMS gate: it reported pre-trim values, hiding 2 non-compliant tracks
_encode_and_enforce_peak measured RMS once, before any peak-correction trim,
then returned that stale figure. A track that needed peak correction was
therefore recorded louder than it actually is, and loudness_ok passed on a
number that described neither the intent nor the artifact.

ch15 took a -2.9 dB trim: the manifest said -20.6 dB, the delivered file
measured -23.8 dB, and the gate called it compliant. Measuring all 32 delivered
files independently found 2 real failures (ch15 -23.8, ch21 -23.1, both under
the -23 dB floor) that the manifest reported as 0.

Now both figures come from the encoded file after every trim, so the manifest
always describes the delivered bytes.

_measure_file gained a content_s argument that excludes head/tail room tone
from the RMS measurement via atrim. RMS is defined over "the entire program
(not including room tone)", and padding share varies enormously: 0.03 dB on a
30-minute chapter but ~7 dB on a 1-second fixture. Peak needs no such
treatment since padding is silence and cannot raise a peak, so one trimmed
pass serves both.

Rebuilt: 32 tracks, 7.45 h, 0 peak failures, loudness range -23.80..-20.70 -
which now matches independent measurement exactly.
2026-10-08 23:57:49 -07:00
B.D. Murphy
4bd8228cca Fix m4b truncation: 41 minutes of the book were silently missing
The delivered m4b was 24,348s where the source chapters total 26,830s. Root
cause: libsndfile stops reading at 2**30 - 1 frames, and a 7.4-hour book at
44.1 kHz is ~1.18e9. ANY read through libsndfile was capped - including a
chunked read loop, because the limit is in the library's frame counter rather
than in the call. ffprobe read the same file correctly at 26,830s, which is
what made it look like the concat had lost data rather than the read.

Both ends now stream through ffmpeg instead:
- _concat_wavs_with_silence writes with sf.SoundFile per segment rather than
  one np.concatenate() array, and as FLOAT rather than the PCM_16 default,
  which had been clipping the book to 0.00 dBFS before normalisation saw it.
- _encode_aac_m4b measures RMS with ffmpeg and encodes with a
  volume+alimiter filter chain, so libsndfile never reads the book at all.
  alimiter looks ahead, so it holds the ceiling without the flat-topped
  distortion hard clipping produces, and it survives the AAC encoder better.

The peak margin also moved from a post-hoc trim to the limiter ceiling. Trimming
after the fact cost ~3.2 dB and pushed RMS to -23.8, outside the band; clipping
to -6.0 dB instead lands at -3.10 peak / -20.70 RMS with no trim at all.

Verified on the real book: duration 26,799s (7.44 h), AAC 44.1 kHz mono
125 kbps, RMS -21.1, peak -5.2. 32 chapter markers, cover art embedded,
closing credit last. Full suite: 630 passed, 8 pre-existing failures.
2026-10-08 14:02:11 -07:00
B.D. Murphy
79832700f5 Fix the m4b to match the MP3 masters: 44.1 kHz mono, loudness, peak
export_m4b encoded with `-c:a aac -b:a 128k` and no `-ar`, so the delivered
file came out at 24 kHz / ~96 kbps mono while the AR MP3 masters were
44.1 kHz / 192 kbps CBR mono. Measured on the shipped Deal_Hunter.m4b:
24 kHz, ~96 kbps, and peak -2.0 dB - failing the 44.1 kHz and -3 dB
requirements at Author's Republic and Spotify. AudiobookExporter had zero
loudness or peak handling of any kind.

- _encode_aac_m4b now passes -ar 44100 and -ac 1 explicitly, and takes the
  spec constants from ar_export so the two paths cannot drift apart again.
- Added normalisation to the RMS target and peak enforcement on the delivered
  file, plus m4b_loudness_report() so the result can be verified.
- AAC overshoot is NOT monotonic. Measured +0.8, +0.9 then +1.6 dB across
  three passes, so trimming by the exact excess oscillated and exhausted the
  pass budget still breaching. Trimming by excess + 1.5 dB margin converges:
  verified -2.20 -> -4.5 dB in two passes.
- _concat_wavs_with_silence now targets 44.1 kHz rather than the TTS-native
  24 kHz.
- Closed spec 22 gap 1: _stitch_chapter hard-coded
  silence_between_chapters_ms=0 while ChapterBuilder's AudioStitcher defaults
  are 500/4000, so the packaged book had different silence than the audio a
  reviewer approved.

Trade-off, flagged rather than hidden: the 1.5 dB overshoot margin costs ~1.3
dB of RMS, so a trimmed m4b measures about -22.8 dB against the -20.5 target.
Still inside the -23..-18 band, but only ~0.2 dB from the floor; a chapter
needing more than ~2.5 dB of trim would fall out of band and loudness_ok will
flag it.

Verified on real chapter audio: AAC 44100 Hz mono, RMS -22.8, peak -4.5.
6 new tests. Full suite: 630 passed, 8 pre-existing failures unchanged.
2026-10-08 12:56:13 -07:00
B.D. Murphy
976e2bff50 Record distribution spec decisions, m4b quality gap, and the in-flight regen 2026-10-08 12:40:40 -07:00
B.D. Murphy
0962d821e6 Fix peak gate: measure and enforce the delivered file, not the intent
The compliance gate certified our intention rather than the artifact.
_encode_track measured the pre-encode float buffer, and _normalize
hard-clipped to exactly -3.0 dB, so all 32 shipped tracks recorded
peak_db = -3.0 and peak_ok = True. libmp3lame then overshot the ceiling, so
the MP3s a listener actually received peaked near -2.0 dB - above the -3 dB
limit that Author's Republic, INaudio and Spotify all require.

- _measure_file() measures an encoded file; the gate now reads the MP3.
- _encode_and_enforce_peak() re-encodes with the excess trimmed until the
  FILE complies, so codec overshoot is corrected rather than assumed away.
  Measured on real audio: -2.0 dB before, -3.1 dB after, and volumedetect,
  ebur128 true peak and the gate all agree.
- RMS is still taken from the content before padding, because every
  distributor defines RMS "across the entire program (not including room
  tone)". Measuring the padded file read ~9 dB low (-29.4 dB on a title that
  normalizes to -20.5). Peak comes from the encoded file, since silence
  padding cannot raise a peak and only the file reveals the overshoot.
- Same fix applied to encode_chapter_mp3, which shared the defect.
- Closing credit is now emitted LAST. The roles loop emitted
  ("sample","opening","closing") before the chapters, so the end-of-book
  credit sat at position 3, ahead of the story it was meant to conclude.

Tests: added coverage asserting the recorded peak equals an independently
measured peak of the delivered file, that no file exceeds the ceiling, and
that a clipping source is trimmed until it complies. Three existing
assertions had encoded the old behaviour and were corrected - including two
"-2.9" tolerances that existed only to accommodate the overshoot, and an
ordering assertion that pinned the closing track second. Those now assert the
real -3.0 limit and the correct order.

Full suite: 624 passed, 8 pre-existing failures unchanged.
2026-10-08 11:18:30 -07:00
B.D. Murphy
e48009fbde Record Qwen TTS boot start and retirement of the root qwen relay 2026-10-08 07:45:24 -07:00
B.D. Murphy
000aa99e05 Correct GPU retirement note: controller work continues, GPU returns via controller 2026-10-08 07:39:47 -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
a6ed6e86be Correct public-endpoint finding: no public A record, plus NAT hairpin blind spot 2026-10-08 07:16:51 -07:00
B.D. Murphy
f5ce26b6a4 Fix boot race: install web LaunchAgent to /Library/LaunchAgents
launchd scans $HOME/Library/LaunchAgents once at login, and $HOME is the
relocated /Volumes/Dock/worstauthor. On 2026-10-08 the Dock volume mounted at
05:59 but login was 05:58, so the plist was invisible and com.umbraaudio.web
never loaded - the web server stayed down after a reboot.

Installed to /Library/LaunchAgents instead (system-wide, on the boot volume,
scanned regardless of $HOME). Removed the $HOME mirror since two plists with
one Label is a race; backup kept. Verified running from the new path.
2026-10-08 07:07:17 -07:00
B.D. Murphy
753d80d0b3 Remove duplicated heading in AGENTS.md session summary 2026-10-08 06:50:39 -07:00
B.D. Murphy
a85c6aa838 Document voice-maintenance guard, pid-gated marker testing, and boot mount-race 2026-10-08 06:50:32 -07:00
B.D. Murphy
eb3777759f Enforce voice-maintenance block: return 409 instead of degrading to Edge-TTS
The banner already warned that voice generation "will fall back to Edge-TTS",
but a warning is not a lock: voice_maintenance was wired into routes.py purely
as a template global, so nothing actually stopped generation. Clicking Generate
during an LLM attribution run would retry Qwen, exhaust the attempts, write a
degraded Edge-TTS ref to disk, and record it in voice_engines.json. For a
quality-first pipeline that is worse than refusing.

_add _voice_maintenance_block(), returning 409 with an explanation of why
(Qwen3-TTS cannot co-reside with the LLM on this machine), and wire it into the
six endpoints that synthesize audio: generate_utterance_audio, delete_utterance,
create_utterance, regenerate_speaker_voice, generate_all_speaker_voices,
generate_full_audiobook.

Deliberately NOT blocked: speakers_list_partial (a Qwen health check, i.e. the
banner the reviewer needs) and set_voice_status (reads an existing _ref.wav to
capture a vmatrix, part of the approval workflow). Blocking those would have
prevented auditing and approving voices while analysis is paused.

The marker is owned by a pid and checked for liveness, so a crashed run
self-heals and cannot strand generation permanently.
2026-10-08 06:49:56 -07:00
umbra audio
9f403cb873 Untrack .env now that this repository is public
Adding `.env` to .gitignore on its own would have changed nothing, which is the whole
point of doing this properly.

.gitignore only applies to files git is not already tracking. This repository has tracked
`.env` since the initial commit, so an ignore entry would have been inert: git would carry
on tracking the file and would have committed a secret placed in it later, while the
.gitignore looked like it was handling it. The untracking is what does the work, and the
comment in .gitignore says so, because "the entry is there but the file is still tracked"
is a silent failure that reads as protection.

Worth being accurate about what was and was not a risk. The committed `.env` was checked
before this change rather than assumed to be dangerous: 32 keys, all of them paths, engine
names, model locations, thresholds and ports. No API key, no token, no password. An earlier
report that this file held live secrets was wrong. `infra/umbra-node.json` likewise
declares itself to carry only ids, names, labels and counts.

So nothing needs purging from history and nothing needs rotating. What needed fixing was
the gap that would have turned the next ordinary mistake - pasting an API key into .env -
into a public commit. That is now closed, verified three ways: .env is absent from the
index, `git check-ignore` confirms the rule matches, and `git add .env` is refused.

.env is untracked with `git rm --cached`, so the working file is untouched and the
application still reads it. Untracking rather than deleting was deliberate: this file is
the machine's real configuration, and a fresh clone now needs .env.example instead.

.env.example is new, generated from .env with all 32 keys and no values. A clone now knows
what to set without anything machine-specific being shared. Verified: zero lines in it
carry a value.

On a branch rather than master, because untracking .env changes what a fresh clone gets
and that is a workflow change rather than a pure fix. Merge or cherry-pick as suits.

No behaviour change. The application reads .env at runtime through pydantic-settings in
four places and none of them care whether git is watching the file.
2026-10-06 18:30:16 -07:00
B.D. Murphy
3b8595b405 AGENTS.md: record Mini-EQ Gitea access, private repos, and submodule repoint
Records the access mechanism (osxkeychain credential helper + Keychain-stored PAT,
deliberately not the token value), the private/login-required repo posture, the
push order, the shallow-clone fix before mirroring Qwen3-TTS, the :2222-vs-:22
Gitea SSH trap with its separate host key, the forced-password-change gate that
hid the SSH key settings, the mini-eq .18/.138 addressing quirk, and the LAN
dependency the submodule repoint introduces.
2026-10-06 07:03:45 -07:00
B.D. Murphy
0c9bea72e0 Add enterprise/infra tooling and repoint Qwen3-TTS submodule to internal fork
Two groups of changes, committed together as the follow-up to the initial push:

1. .gitmodules — submodule Qwen3-TTS now points at the internal Gitea fork
   (ssh://git@mini-eq:2222/umbragit/Qwen3-TTS.git) instead of upstream GitHub,
   so a clone actually gets the UmbraAudio branding/integration commit
   012e452 rather than vanilla upstream.

2. New infra/ tooling for the cross-machine work in spec 21: agent_bridge,
   remote_credential_broker (Keychain-backed, secrets read at runtime and
   generated via read_secret/random_secret — no hardcoded credentials), the
   enterprise bootstrap scripts, and the oldbox SSH/auth prompts. Plus
   backup/ restore scripts, two specs (18 UmbraTrans, 19 enterprise control
   plane), umbraaudio/voices/lmstudio_gate.py, and in-flight edits to the
   discovery/attribution/profiling modules, config, chapter_builder, emotion,
   pronunciation, qwen_tts and routes.

Also adds *.db to .gitignore: SQLite project databases are runtime state and no
.db file was tracked before.

Verified before staging: .env's only change is UMBRA_TTS__QWEN_TTS_HELPER_URL and
the committed copy has zero secret-looking values; the broker ships only
config.example.json.
2026-10-06 06:57:41 -07:00
B.D. Murphy
29667d0b62 AGENTS.md: record SSH mini-m4 -> oldbox now working
Supersedes the long-standing 'SSH denied, needs manual provisioning' note, which
was wrong in its diagnosis. Recorded: root cause was authorization (our ed25519
public key missing from umbra's authorized_keys on oldbox), not networking,
TCC, or host keys; the local key + fingerprint; all three pinned host-key
fingerprints verified matching; the Host oldbox config block and its backup; the
remote-broker profile now returning exit 0 (and the oldbox-umbra vs
oldbox-umbra-ssh naming trap); the read-only fingerprint-verification recipe and
the rule not to pin a handed-over fingerprint unchecked; and oldbox's specs
including the NVML permission gap that blocks nvidia-smi for umbra.
2026-10-05 20:38:49 -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
60564cc056 Analysis-quality plan: why the ch6 speakers were missed (spec 24)
The operator reviewed ch6 with author knowledge and found real defects the
duplicate scan could not see. Two corrections and one structural diagnosis.

I was wrong about two pairs. I wrote "probably one person" about The Officer /
Officer, and "probably two different riders" about Border Soldier / Soldier
(Rider). Both are two different characters. Measured, The Officer (113.3 Hz) and
Officer (104.5 Hz) are 1.4 SEMITONES apart - two distinct characters with
near-identical voices, which is a genuine cast-clarity risk that a merge would
have hidden. Border Soldier and Soldier (Rider) are 12.2 semitones apart, so
their risk is narrative proximity, not vocal.

The real lesson is the principle behind it: because the author wrote the book he
knows these facts, and on a book he did not write nobody does. So every merge
decision must be resolvable from evidence in the text, and the default when
evidence is absent must be SEPARATION. Keeping two speakers separate costs one
redundant voice; merging two people costs a wrong character for the rest of the
book. Reworded the scan so a blocked group is reported as "same name-space,
different characters unproven" and never as a probable merge.

Why the scan missed ch6: it can only compare names that already exist. It cannot
see a line attributed to the wrong EXISTING speaker. ch6 utt29 - "The leader on
the right for Hallr says," / "We want to be the ones to claim we killed the last
dwarf" - is a scum boy attributed to Hallr. No duplicate existed to find. The
scum group was also missed on identity: only the case variants were caught, and
nothing compares a designation against the text that produced it.

Diagnosis, measured book-wide: a role speech-tag must not resolve to a distinct
named character. 20 dialogue utterances violate it. True positives include ch11
utt191-193 where ONE tag ("The young man replies,") was split across Kaleb,
Tobias and Hallr, and ch13 utt45 where "The girl replies," became The King. But
40% are false positives because ROLE_WORDS is incomplete - royal, king, witch,
doctor and others are missing - and a gate that noisy would be ignored. The
vocabulary must come from definitions.py, not a hand-written list.

Plan, model-independent first: (A) mechanical evidence-coverage gate, which
catches this class with certainty; (B) constrained assignment - choose from the
evidence-supported candidates only, not all 93 names, which is the actual cause
of utt29; (C) role-phrase preservation so "the leader ON THE RIGHT" stops
acquiring a new name per mention; (D) tag-run consistency, never split one
introduction across three characters; (E) a segmentation gate for narration typed
as dialogue (ch6 utt26); (F) confusability signals, a casting concern distinct
from identity; (G) an evaluation harness with a ch6 gold set and five metrics,
because without a number "use a better model" is a guess.

On a better model, honestly: 12b cannot fit in 16 GB, and the GPU box at
192.168.123.14:1234 refused a connection today (curl exit 7), so it is blocked.
And B removes the search space first, which is where the error actually was.

Full suite unchanged at 575 passed / 8 failed, the same 8 pre-existing failures.
2026-10-04 20:01:05 -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