10 Commits

Author SHA1 Message Date
umbra audio
03f8a858dd Add Apache-2.0 LICENSE, NOTICE, and SPDX headers 2026-10-18 19:01:04 -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
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
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
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
d4d51d83f9 Build final full-book m4b with metadata/cover; validate voice_mode consistently; surface quality status in step-6 UI
- export_full_book_m4b: concat chN_full.wav chapters -> AAC m4b with full
  m4b_meta.json metadata, chapter markers, and embedded cover art
- metadata: extend BookMetadata, write rich MP4 tags, add apply_ffmpeg_cover
- fix ChapterQualityChecker.check_all_chapters to use row_factory (was crashing)
- chapter_builder/staleness: validate against accepted voice modes (embedding/icl)
  instead of a hardcoded single mode, ending over-regeneration of mixed chapters
- pipeline_manager: surface warnings/errors lists; add POST /api/audiobook/export-m4b
- step-6 webui: Export Full Book (.m4b) button with live status/warnings/errors
2026-09-03 07:17:36 -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