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.
18 KiB
Spec: Web Review UI
Overview
FastAPI + HTMX web interface for human review of generated audiobooks. Provides dashboards, voice review, chapter review with audio playback, and quality reports.
Technology Stack
- Framework: FastAPI 0.104+
- Templating: Jinja2 3.1+
- Interactive: HTMX 1.9 (CDN)
- Styling: Custom dark theme (no CSS framework)
- Database: Synchronous sqlite3 (not aiosqlite — web runs in sync context)
App Factory
# umbraaudio/web/__init__.py
def create_app(config: AppConfig) -> FastAPI
- Creates FastAPI app (title: "UmbraAudio Review", version: "0.1.0")
- Sets up Jinja2Templates from
templates/directory - Mounts static files if
static/directory exists - Includes router from
umbraaudio.web.routes - Stores config on
app.state.config - Singleton pattern via global
_app
Routes
HTML Pages (GET)
| Route | Function | Template | Description |
|---|---|---|---|
GET / |
index() |
index.html |
Dashboard with stats, scan results, pipeline status, export files |
GET /voices |
voice_review() |
voice_review.html |
Voice review page |
GET /voices/partial |
voice_list_partial() |
_voice_list.html |
HTMX partial |
GET /voices/{name}/history |
voice_history() |
_voice_history.html |
Per-character voice review history |
GET /speakers |
speakers_review() |
speakers.html |
Speaker management (CRUD, aliases, merge) |
GET /speakers/partial |
speakers_list_partial() |
_speakers_list.html |
HTMX partial |
GET /proper-names |
proper_names_review() |
proper_names.html |
Proper-noun pronunciation review (/character-names is an alias) |
GET /orphaned-utterances |
orphaned_utterances_page() |
orphaned_utterances.html |
Utterances whose speaker is unknown or __unassigned__ |
GET /voice-tests |
voice_tests_review() |
voice_tests.html |
Verification-phrase test samples |
GET /voice-tests/partial |
voice_tests_partial() |
_voice_tests_list.html |
HTMX partial |
GET /chapters/{id} |
chapter_review() |
chapter_review.html |
Chapter review |
GET /chapters/{id}/partial |
chapter_utterances_partial() |
_utterance_list.html |
HTMX partial |
GET /chapters-audio |
chapters_audio_review() |
chapters_audio.html |
Per-chapter audio listing |
GET /chapters-audio/{chapter_dir} |
chapter_audio_review() |
chapter_audio_detail.html |
Utterance WAV review with speaker/text editing |
GET /quality |
quality_report() |
quality_report.html |
Quality dashboard |
GET /registry |
registry_index() |
registry.html |
Cross-project voice registry |
GET /registry/partial |
registry_list_partial() |
_registry_list.html |
HTMX partial |
GET /registry/search?q= |
registry_search() |
— (JSON) | Search registry by character or alias |
Audio Serving (GET)
| Route | Description |
|---|---|
GET /audio/{chapter_id}/{filename} |
Serve WAV (prefers audio_normalized, falls back to audio) |
GET /voice-audio/{filename} |
Serve voice reference WAVs from .umbra/voices |
GET /voice-test-audio/{filename} |
Serve test sample (prefers .umbra/voices, falls back to .umbra/voice_tests) |
GET /chapter-audio-file/{chapter_dir}/{filename} |
Serve per-utterance chapter WAV |
GET /registry-audio/{registry_id}/{filename} |
Serve a registered voice reference from the registry DB path |
API
| Route | Description |
|---|---|
POST /api/upload-manuscript |
Upload .docx/.epub/.txt/.md/.pdf, parse chapters, rebuild utterances; archives prior state in mode=new, else keeps speakers |
POST /api/speakers |
Create speaker (form fields: name, gender, type, aliases, traits, register, description, sample dialogue, narrator/group flags, accent) |
PUT /api/speakers/{index} |
Update speaker |
DELETE /api/speakers/{index} |
Delete speaker; flags their utterances __unassigned__ |
POST /api/speakers/{index}/alias |
Add alias |
DELETE /api/speakers/{index}/alias/{alias} |
Remove alias |
POST /api/speakers/{index}/alias/{alias}/move/{target} |
Move alias to another speaker |
POST /api/speakers/{index}/alias/{alias}/create |
Promote alias to a new speaker |
POST /api/speakers/{source}/merge/{target} |
Merge speakers (attributes, aliases, chapters merged; utterances repointed) |
POST /api/speakers/voice-status |
Set voice_status.json entry; on approved, upsert into voice_registry |
POST /api/speakers/regenerate-voice |
Regenerate one speaker's voice reference (background) |
POST /api/speakers/generate-all-voices |
Generate all missing voice references (background, skips existing) |
PUT /api/phonetic/{speaker_name} |
Set/edit phonetic spelling; stored as an unconfirmed user candidate, regenerates preview audio (clearing the value deletes the record + audio) |
GET /api/pronunciation/{name} |
Look up the most common pronunciation (dictionaryapi.dev for common words, Wiktionary API for names) and return the candidate without saving |
POST /api/pronunciations/{name}/lookup |
Internet lookup → candidate record in phonetic_names.json + generated preview audio; dictionary hits auto-confirm as common, Wiktionary hits are stored needs review |
POST /api/pronunciations/{name}/confirm |
Mark a candidate confirmed (only confirmed spellings are applied to TTS) |
POST /api/pronunciations/{name}/clear |
Delete the record and its preview audio |
GET /api/pronunciations/audio?name= |
Serve the preview WAV from .umbra/pronunciation_audio/{sanitized}.wav |
GET /api/pronunciations/status |
Read batch progress from pronunciation_status.json ({status, detail, done, total}) |
POST /api/auto-pronunciations |
Background batch lookup over all proper nouns; auto-confirms common words, generates preview audio for Wiktionary hits, re-looks-up legacy records, writes progress to pronunciation_status.json |
POST /api/voices/{id}/approve |
Approve character voice (records voice_review_history) |
POST /api/voices/{id}/reject |
Reject character voice |
POST /api/voices/{id}/edit |
Queue a voice edit prompt (stored in history) |
POST /api/chapters/{id}/approve |
Approve chapter (review_decisions) |
POST /api/chapters/{id}/regenerate |
Stub (returns {"status": "queued"}) |
PUT /api/utterance/{utt_id}/speaker |
Reassign utterance speaker; deletes stale WAV and regenerates via Edge fallback |
PUT /api/utterance/{utt_id}/text |
Edit utterance text; deletes stale WAV |
DELETE /api/utterance/{utt_id} |
Delete utterance; renumber chapter; regenerate chapter WAVs via Qwen3 |
POST /api/chapter/{chapter_id}/utterance |
Append utterance; generate its WAV in background |
POST /api/orphaned-utterances/{utt_id}/reassign |
Reassign one orphaned utterance |
POST /api/orphaned-utterances/reassign-all |
Reassign all utterances that had the given old names |
POST /api/analyze |
Run character discovery in background (analysis_status.json) |
GET /api/analysis-status |
Read analysis_status.json |
POST /api/segment-utterances |
Run segmentation + LLM attribution + emotion analysis (skips attribution if LM Studio offline) |
POST /api/generate-audiobook |
Run full Qwen3-TTS generation in background (scripts.generate_all_audio.main) |
GET /api/generation/status |
Live generation progress (items, success/failure counts, interrupted flag) |
GET /api/pipeline/status |
Pipeline state machine status |
POST /api/pipeline/status |
Update pipeline status fields |
POST /api/pipeline/start |
Start pipeline (run_pipeline_background) |
POST /api/pipeline/pause |
Pause pipeline |
POST /api/pipeline/resume |
Resume paused pipeline |
POST /api/pipeline/cancel |
Cancel pipeline |
POST /api/pipeline/reset?step= |
Reset discover (speakers/voices/phonetics) / voices / segment (utterances) |
POST /api/pipeline/run-all |
Full pipeline: discover → voices → segment+attribute → generate → quality gate → export M4B/MP3 |
POST /api/chapter/{id}/build |
Stitch chapter WAVs into ch{id}_full.wav after quality gate |
GET /api/quality/check |
Quality-check all chapters (JSON) |
GET /api/quality/check/{chapter_id} |
Quality-check one chapter (JSON) |
GET /api/export/files |
List export directory files with download URLs |
GET /api/export/download/{path} |
Download export file (path-traversal protected) |
Pages
Dashboard (/)
Stats displayed:
- Project name, source path, and format (from
projectrow +scan_results.json) - Chapter count, character count (non-narrator), utterance count, audio segment count
- Scene count (
<<<>>>markers across chapter source text) - Speaker count (from
speakers.json) and voice reference count (*_ref.wavin.umbra/voices) - Pipeline status (current step, progress, error)
- Export files (name + size, with download links)
Voice Review (/voices)
HTMX-powered page that loads character data on demand.
_voice_list.html partial:
| Column | Content |
|---|---|
| Name | Character name |
| Description | Truncated voice_description JSON |
| Role | Badge: Narrator / Character |
| Status | Badge: voiced_approved / rejected / pending |
| Energy params | Derived exaggeration / cfg_weight from ATTRIBUTE_PARAM_MAP[energy] (deterministic from name hash; narrator uses fixed values) |
| Audio | HTML5 <audio> player (if voice_ref exists) |
| Actions | Approve / Reject / Edit-prompt buttons (HTMX POST) |
Approve/Reject flow:
- HTMX POST to
/api/voices/{id}/approveor/api/voices/{id}/reject - Inserts a
voice_review_historyrecord (action, prompt, resulting exaggeration/cfg weight) - Updates
characters.statustovoiced_approvedorrejected
Speakers (/speakers)
Speaker management backed by .umbra/speakers.json (not the DB characters table). HTMX _speakers_list.html partial.
Columns: primary name, aliases (with move/promote/remove actions), demographics (type/gender), vocal traits, register, flagged status (no traits + unknown gender + no description → flagged for review), voice status, audio player.
Operations:
- Add / edit / delete speaker
- Add / remove / move / promote aliases
- Merge one speaker into another (attributes and aliases merged; utterances repointed to the target)
- Delete flags the speaker's utterances as
__unassigned__for later review - Set voice status (
pending/approved); approving upserts the reference into the cross-projectvoice_registry - Regenerate voice reference (single speaker or all missing)
Proper Names (/proper-names)
Extracts capitalized noun phrases from chapter source text, filters titles/stopwords, counts frequency, and flags which are known speakers. Pronunciation is reviewed per name through a lookup → listen → confirm flow:
- Lookup (
POST /api/pronunciations/{name}/lookup): searches dictionaryapi.dev (on the name and its lowercase form) then the Wiktionary API. Dictionary hits are well-understood common words — auto-confirmed and marked Common. Wiktionary hits are real names/places — stored as unconfirmed candidates (Needs review). - Preview audio: the phonetic spelling is synthesized with the narrator's cloned voice (
.umbra/pronunciation_audio/{sanitized}.wav) so the reviewer hears exactly what the substitution will sound like. TTS failures (e.g. transient server errors) are captured intoaudio_erroron the record and surfaced in the UI without blocking manual entry. - Confirm: after listening, the reviewer confirms the candidate; only confirmed spellings are returned by
load_phonetic_names()and substituted into utterance text before synthesis. - Edit: changing the spelling stores it as an unconfirmed user candidate and regenerates the preview audio.
- Batch ("Look Up All Pronunciations"): background pass over every proper noun, skipping records that already have a real source, re-looking-up legacy records, with progress polled from
/api/pronunciations/status. - Fictional/invented names with no dictionary or Wiktionary entry show "not found" and are set manually.
- The page offers a text filter and a status filter (Needs review / Confirmed / Common / No pronunciation) with per-row status badges.
Shared logic lives in umbraaudio/pronunciation.py (record load/save with legacy {name: "SPELLING"} migration, IPA→ASCII respelling, lookups, preview synthesis). scripts/generate_all_audio.py loads confirmed spellings once and applies them (longest-name-first) to every utterance before synthesis.
Orphaned Utterances (/orphaned-utterances)
Lists utterances whose speaker_name is (a) not a known speaker name/alias, or (b) __unassigned__. Each can be reassigned to any known speaker; "reassign all" repoints every utterance that previously used a given old name.
Voice Tests (/voice-tests)
Lists verification-phrase test samples from .umbra/voice_tests/*.wav joined with voice_assignments.json and speakers.json for voice/demographics context. Audio served via /voice-test-audio/{filename} (falls back to .umbra/voices).
Chapter Audio Review (/chapters-audio)
- Listing page: one row per chapter (title, utterance count, directory
ch{id}) - Detail page (
/chapters-audio/ch{id}): every utterance with index, speaker, truncated text, resolved voice, and an audio player forutt_{index:04d}.wav(if present) - Per-utterance actions: reassign speaker (
PUT /api/utterance/{id}/speaker), edit text (PUT /api/utterance/{id}/text), delete (DELETE /api/utterance/{id}), append utterance (POST /api/chapter/{chapter_id}/utterance); edits delete the stale WAV and trigger background regeneration - Speaker dropdown canonicalization:
chapter_audio_reviewbuilds alower_to_canonicalmap from the primary names, all aliases, and the first word of each primary name, so an utterance whose stored speaker is an alias/first-word (e.g.kaindaforKainda Walters Foster Murphy) still selects the correct dropdown option instead of defaulting to AI-1 - Chapter build (
POST /api/chapter/{id}/build): quality-gates then stitches intoch{id}_full.wav
Registry (/registry)
Cross-project voice reference registry (voice_registry_path DB, default ~/.umbra/voice_registry.db). Lists approved characters with their reference paths and audio, searchable by name or alias.
Chapter Review (/chapters/{id})
Features:
- Chapter navigation links (all 35 chapters, highlights current)
- Approve Chapter button (HTMX POST)
- HTMX loads
_utterance_list.htmlpartial
_utterance_list.html partial:
For each utterance:
- Speaker name (from
speaker_namecolumn) - Type badge (narration / dialogue)
- Utterance text
- Audio player (if audio exists)
Audio playback:
<audio controls preload="none">
<source src="/audio/{chapter_id}/{filename}" type="audio/wav">
</audio>
Quality Report (/quality)
Sections:
- Defects table — All logged quality issues with severity badges
- Chapter summary — Per-chapter audio count, MOS scores, similarity
SQL for chapter summary:
SELECT ch.id, ch.title,
COUNT(ga.id) as audio_count,
MIN(ga.mos_score) as min_mos,
MAX(ga.mos_score) as max_mos,
AVG(ga.speaker_similarity) as avg_sim
FROM chapters ch
LEFT JOIN utterances u ON u.chapter_id = ch.id
LEFT JOIN generated_audio ga ON ga.utterance_id = u.id
GROUP BY ch.id ORDER BY ch.chapter_index
Database Access
The web routes use synchronous sqlite3 (not aiosqlite) and run SCHEMA_SQL (CREATE TABLE IF NOT EXISTS for project, chapters, characters, voice_review_history, pronunciation_guide, utterances, generated_audio, quality_reports, review_decisions, plus indexes) on every connection:
def _get_db(request: Request) -> sqlite3.Connection:
config = request.app.state.config
db_path = config.data_dir / "project.db"
db = sqlite3.connect(str(db_path))
db.row_factory = sqlite3.Row
db.executescript(SCHEMA_SQL)
return db
State JSON files are also read/written directly by routes: speakers.json, voice_assignments.json, voice_status.json, phonetic_names.json, pipeline_status.json, analysis_status.json, scan_results.json, defects.json.
Background Job Pattern
Long-running work (analysis, segmentation, voice generation, audiobook generation, pronunciation lookup, chapter builds, utterance regeneration) runs in daemon threading.Threads. Status surfaces through:
pipeline_status.json— coarse step/status/detail/progress for pipeline flowsanalysis_status.json— character discovery progressgeneration_manager— item-level live progress for/api/generation/status(rejects concurrent runs with HTTP 409)- WAV file existence — the generation script's natural resume marker
Theme
Dark theme with custom CSS:
- Background:
#1a1a2e - Text:
#e0e0e0 - Accent:
#7c3aed(purple) - Card background:
#16213e - Button approve:
#22c55e(green) - Button reject:
#ef4444(red)
HTMX Integration
Pages use HTMX for partial updates:
hx-trigger="load"— Load content on page loadhx-get="/voices/partial"— Fetch partial HTMLhx-target="#voice-list"— Target element for replacementhx-swap="innerHTML"— Replace content
Starting the Server
# Via CLI
umbraaudio review --host 0.0.0.0 --port 8420
# Via run_review.sh
setsid /home/bm/Apps/UmbraAudio/run_review.sh > /tmp/umbraaudio_review.log 2>&1 &
Known Limitations
POST /api/chapters/{id}/regenerateis a stub (returns{"status": "queued"}); real regeneration is file-targeted via utterance edit/delete endpointsPOST /api/utterance/{id}/speakerregenerates the utterance with the Edge-TTS fallback, not Qwen3-TTS (inconsistent with bulk generation)- No authentication/authorization
- No WebSocket for real-time updates (long-running jobs poll JSON status files)
- No pagination for large utterance lists
- Database connection opened per-request (no connection pooling)
- Dashboard speaker/voice counts read
speakers.json/.umbra/voices, which may drift from the DBcharacterstable