The remote had one commit this branch did not (03f8a85), which adds the
Apache-2.0 LICENSE, the NOTICE and SPDX headers to every .py and .sh
file. The merge was clean - the headers sit at the top of each file and
my changes are further down.
The new files this branch added (languages.py, translations.py, the
cluster package, the new tests and scripts) were written after the
merge base, so the licence commit never saw them. They now carry the
same header, so the convention holds across the whole tree.
Nothing else changed. 883 tests passed / 8 failed - the same 8
pre-existing failures.
494 lines
16 KiB
Python
494 lines
16 KiB
Python
#!/usr/bin/env python3
|
|
# Copyright 2026 Umbra Tools (umbratools.com)
|
|
# SPDX-License-Identifier: Apache-2.0
|
|
|
|
"""Background chapter audio generation with Qwen3-TTS.
|
|
Run as: nohup python scripts/generate_all_audio.py [--project NAME] > generation.log 2>&1 &
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import sqlite3
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
|
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
|
|
from umbraaudio.config import load_config
|
|
from umbraaudio.generation.audio_effects import (
|
|
EMOTION_FX_VERSION,
|
|
apply_effects_to_file,
|
|
apply_effects_to_file_by_name,
|
|
apply_emotion_effects_to_file,
|
|
load_speaker_distortion_effects,
|
|
load_speaker_traits,
|
|
)
|
|
from umbraaudio.generation.chapter import compose_utterance_instruct
|
|
from umbraaudio.generation.scene_break import (
|
|
is_scene_break,
|
|
needs_silence,
|
|
write_scene_break_audio,
|
|
)
|
|
from umbraaudio.generation.staleness import is_current, write_marker
|
|
from umbraaudio.pronunciation import (
|
|
load_phonetic_names,
|
|
load_proper_names,
|
|
prepare_tts_text,
|
|
)
|
|
from umbraaudio.voices.lmstudio_lifecycle import get_vram_usage_mb
|
|
from umbraaudio.voices.qwen_tts import VOICE_REFERENCE_TEXT, QwenTTSEngine
|
|
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format="%(asctime)s %(levelname)s %(message)s",
|
|
handlers=[
|
|
logging.StreamHandler(),
|
|
],
|
|
)
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def load_voice_references(voices_dir: Path) -> dict[str, Path]:
|
|
refs: dict[str, Path] = {}
|
|
if not voices_dir.exists():
|
|
return refs
|
|
for f in voices_dir.rglob("*_ref.wav"):
|
|
name = f.stem.replace("_ref", "").replace("_", " ")
|
|
if name not in refs:
|
|
refs[name] = f
|
|
return refs
|
|
|
|
|
|
def load_speakers(data_dir: Path) -> dict[str, dict]:
|
|
path = data_dir / "speakers.json"
|
|
if not path.exists():
|
|
return {}
|
|
with open(path) as f:
|
|
data = json.load(f)
|
|
return {s["primary_name"]: s for s in data.get("speakers", [])}
|
|
|
|
|
|
def get_speaker_name_map(speakers: dict[str, dict]) -> dict[str, str]:
|
|
alias_map: dict[str, str] = {}
|
|
for name, info in speakers.items():
|
|
alias_map[name.lower()] = name
|
|
for alias in info.get("aliases", []):
|
|
if alias:
|
|
alias_map[alias.lower()] = name
|
|
return alias_map
|
|
|
|
|
|
def resolve_reference(
|
|
speaker_name: str,
|
|
voice_refs: dict[str, Path],
|
|
alias_map: dict[str, str],
|
|
narrator_ref: Path | None = None,
|
|
) -> Path | None:
|
|
if speaker_name in voice_refs:
|
|
return voice_refs[speaker_name]
|
|
low = speaker_name.lower()
|
|
for name, path in voice_refs.items():
|
|
if name.lower() == low:
|
|
return path
|
|
canonical = alias_map.get(low)
|
|
if canonical:
|
|
for name, path in voice_refs.items():
|
|
if name.lower() == canonical.lower():
|
|
return path
|
|
first_word = low.split()[0] if low.split() else ""
|
|
if first_word:
|
|
matches = [p for n, p in voice_refs.items() if n.lower().split()[:1] == [first_word]]
|
|
if len(matches) == 1:
|
|
return matches[0]
|
|
best = None
|
|
best_len = -1
|
|
for name, path in voice_refs.items():
|
|
nlow = name.lower()
|
|
if (nlow in low or low in nlow) and len(nlow) > best_len:
|
|
best = path
|
|
best_len = len(nlow)
|
|
if best is not None:
|
|
return best
|
|
return narrator_ref
|
|
|
|
|
|
def generate_chapter(
|
|
chapter_id: int,
|
|
engine: QwenTTSEngine,
|
|
voice_refs: dict[str, Path],
|
|
alias_map: dict[str, str],
|
|
output_base: Path,
|
|
db_path: Path,
|
|
narrator_ref: Path | None = None,
|
|
phonetic: dict[str, str] | None = None,
|
|
proper_names: frozenset[str] | None = None,
|
|
speaker_traits: dict[str, list[str]] | None = None,
|
|
distortion_effects: dict[str, list[str]] | None = None,
|
|
utterance_callback=None,
|
|
progress_callback=None,
|
|
progress_context=None,
|
|
emotion_fx: bool = False,
|
|
) -> tuple[int, int]:
|
|
db = sqlite3.connect(str(db_path))
|
|
db.row_factory = sqlite3.Row
|
|
|
|
chapter = db.execute("SELECT * FROM chapters WHERE id = ?", (chapter_id,)).fetchone()
|
|
if not chapter:
|
|
return 0, 0
|
|
|
|
title = chapter["title"] or f"Chapter {chapter['chapter_index'] + 1}"
|
|
|
|
utterances = db.execute(
|
|
"SELECT id, speaker_name, text, utterance_index, emotion, pace, instruct, "
|
|
"language_code "
|
|
"FROM utterances WHERE chapter_id = ? ORDER BY utterance_index",
|
|
(chapter_id,),
|
|
).fetchall()
|
|
# The project's source language. An utterance with no language tag is
|
|
# written in it (spec 31 section 6.1).
|
|
from umbraaudio.translations import source_language
|
|
|
|
project_language = source_language(db)
|
|
db.close()
|
|
|
|
chapter_dir = output_base / f"ch{chapter_id}"
|
|
chapter_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
total = len(utterances)
|
|
skipped = 0
|
|
generated = 0
|
|
failed = 0
|
|
|
|
for utt in utterances:
|
|
speaker = utt["speaker_name"] or "narrator"
|
|
text = utt["text"]
|
|
idx = utt["utterance_index"]
|
|
|
|
if not text or not text.strip():
|
|
skipped += 1
|
|
if utterance_callback:
|
|
utterance_callback()
|
|
continue
|
|
|
|
if is_scene_break(text):
|
|
output_path = chapter_dir / f"utt_{idx:04d}.wav"
|
|
if needs_silence(output_path):
|
|
write_scene_break_audio(output_path)
|
|
generated += 1
|
|
else:
|
|
skipped += 1
|
|
if utterance_callback:
|
|
utterance_callback()
|
|
continue
|
|
|
|
output_path = chapter_dir / f"utt_{idx:04d}.wav"
|
|
|
|
ref_path = resolve_reference(speaker, voice_refs, alias_map, narrator_ref)
|
|
|
|
spoken_text = prepare_tts_text(text, phonetic or {}, proper_names)
|
|
|
|
instruct = compose_utterance_instruct(
|
|
(utt["instruct"] or "").strip() or None,
|
|
utt["emotion"],
|
|
utt["pace"],
|
|
)
|
|
|
|
fx_version = EMOTION_FX_VERSION if (emotion_fx and utt["emotion"]) else None
|
|
|
|
# The language the line is written in (spec 31 source axis).
|
|
line_language = utt["language_code"] or project_language
|
|
|
|
if is_current(
|
|
spoken_text, output_path, speaker=speaker, voice_ref=ref_path,
|
|
instruct=instruct, fx_version=fx_version,
|
|
language=line_language,
|
|
):
|
|
skipped += 1
|
|
if utterance_callback:
|
|
utterance_callback()
|
|
continue
|
|
|
|
utt_success = False
|
|
try:
|
|
if ref_path:
|
|
engine.generate(
|
|
text=spoken_text,
|
|
output_path=output_path,
|
|
voice_ref=ref_path,
|
|
ref_text=VOICE_REFERENCE_TEXT,
|
|
instruct=instruct,
|
|
language=line_language,
|
|
)
|
|
else:
|
|
engine.generate(
|
|
text=spoken_text,
|
|
output_path=output_path,
|
|
instruct=instruct or "A calm, neutral voice speaking clearly.",
|
|
language=line_language,
|
|
)
|
|
|
|
if output_path.exists():
|
|
explicit = (distortion_effects or {}).get(speaker, [])
|
|
if explicit:
|
|
apply_effects_to_file_by_name(output_path, explicit)
|
|
elif speaker_traits:
|
|
traits = speaker_traits.get(speaker, [])
|
|
if traits:
|
|
apply_effects_to_file(output_path, traits)
|
|
if fx_version is not None:
|
|
apply_emotion_effects_to_file(output_path, utt["emotion"])
|
|
write_marker(
|
|
spoken_text, output_path, speaker=speaker,
|
|
voice_ref=ref_path,
|
|
voice_mode="embedding" if ref_path else None,
|
|
instruct=instruct, fx_version=fx_version,
|
|
language=line_language,
|
|
)
|
|
generated += 1
|
|
utt_success = True
|
|
else:
|
|
failed += 1
|
|
|
|
except Exception as e:
|
|
logger.error(" FAILED utt_%04d (%s): %s", idx, speaker, e)
|
|
failed += 1
|
|
|
|
if utterance_callback:
|
|
utterance_callback(success=utt_success)
|
|
|
|
if progress_callback and progress_context:
|
|
progress_callback(progress_context["completed"], progress_context["total"], title)
|
|
|
|
if (generated + failed) % 50 == 0 and generated > 0:
|
|
elapsed_min = (generated + failed) * 6.4 / 60
|
|
logger.info(
|
|
" Progress: %d/%d done (%d gen, %d fail), ETA %.0f min",
|
|
generated + failed,
|
|
total,
|
|
generated,
|
|
failed,
|
|
elapsed_min,
|
|
)
|
|
|
|
return generated, failed
|
|
|
|
|
|
def main(
|
|
status_callback=None,
|
|
gen_manager=None,
|
|
project: str | None = None,
|
|
emotion_fx: bool = False,
|
|
):
|
|
cfg = load_config(project)
|
|
data_dir = cfg.data_dir
|
|
|
|
file_handler = logging.FileHandler(data_dir / "generation.log")
|
|
file_handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s"))
|
|
logger.addHandler(file_handler)
|
|
|
|
voices_dir = cfg.voices_dir
|
|
output_base = data_dir / "chapter_audio"
|
|
output_base.mkdir(parents=True, exist_ok=True)
|
|
|
|
voice_refs = load_voice_references(voices_dir)
|
|
speakers = load_speakers(data_dir)
|
|
alias_map = get_speaker_name_map(speakers)
|
|
phonetic = load_phonetic_names(data_dir)
|
|
proper_names = load_proper_names(data_dir)
|
|
speaker_traits = load_speaker_traits(data_dir / "speakers.json")
|
|
distortion_effects = load_speaker_distortion_effects(
|
|
data_dir / "speakers.json"
|
|
)
|
|
|
|
narrator_ref = None
|
|
for name, path in voice_refs.items():
|
|
if name.lower() == "narrator":
|
|
narrator_ref = path
|
|
break
|
|
if not narrator_ref:
|
|
for name, path in voice_refs.items():
|
|
if name.lower() == "kainda":
|
|
narrator_ref = path
|
|
break
|
|
|
|
logger.info("=== Qwen3-TTS Background Generation ===")
|
|
logger.info("Voice references: %d", len(voice_refs))
|
|
logger.info("Speakers: %d", len(speakers))
|
|
logger.info("Confirmed phonetic overrides: %d", len(phonetic))
|
|
|
|
from umbraaudio.voices.model_coordinator import ModelCoordinator, PipelineStage
|
|
|
|
coordinator = ModelCoordinator(cfg)
|
|
if not coordinator.switch_to(PipelineStage.UTTERANCE_GENERATION).get("ok"):
|
|
logger.error("Failed to switch models to UTTERANCE_GENERATION stage")
|
|
sys.exit(1)
|
|
|
|
vram = get_vram_usage_mb()
|
|
logger.info("VRAM: %dMB used, %dMB free", vram["used_mb"], vram["free_mb"])
|
|
|
|
db = sqlite3.connect(str(cfg.db_path))
|
|
db.row_factory = sqlite3.Row
|
|
pid = db.execute("SELECT id FROM project ORDER BY id LIMIT 1").fetchone()["id"]
|
|
chapters = db.execute(
|
|
"SELECT id, title, chapter_index FROM chapters WHERE project_id=? ORDER BY chapter_index",
|
|
(pid,),
|
|
).fetchall()
|
|
total_utterances = db.execute("SELECT COUNT(*) as cnt FROM utterances").fetchone()["cnt"]
|
|
db.close()
|
|
|
|
total_chapters = len(chapters)
|
|
|
|
engine = QwenTTSEngine(device="cuda", sample_rate=24000, qwen_tts_url=cfg.tts.qwen_tts_url)
|
|
|
|
engine.load_remote_model(model_type="base")
|
|
|
|
total_gen = 0
|
|
total_fail = 0
|
|
completed_utterances = 0
|
|
start = time.time()
|
|
|
|
if status_callback:
|
|
status_callback(
|
|
"generate",
|
|
"running",
|
|
"Starting audio generation...",
|
|
0,
|
|
total_chapters=total_chapters,
|
|
chapters_completed=[],
|
|
)
|
|
|
|
def _utterance_done(success=True):
|
|
nonlocal completed_utterances
|
|
completed_utterances += 1
|
|
if gen_manager is not None:
|
|
gen_manager.update_item(f"utt:{completed_utterances}", success=success)
|
|
|
|
def _progress_tick(completed, total, ch_title):
|
|
if status_callback:
|
|
progress_pct = int((completed_utterances / max(total_utterances, 1)) * 100)
|
|
status_callback(
|
|
"generate",
|
|
"running",
|
|
f"Chapter {i + 1}/{total_chapters}: {ch_title}",
|
|
progress_pct,
|
|
total_chapters=total_chapters,
|
|
chapters_completed=list(range(i)),
|
|
current_chapter=chapters[i]["id"],
|
|
completed_utterances=completed_utterances,
|
|
total_utterances=total_utterances,
|
|
)
|
|
|
|
batch_total = total_utterances
|
|
_gen_ctx = None
|
|
if gen_manager is not None:
|
|
_gen_ctx = gen_manager.serialize("audio", batch_total, "audio:generate")
|
|
_gen_ctx.__enter__()
|
|
|
|
try:
|
|
for i, ch in enumerate(chapters):
|
|
ch_id = ch["id"]
|
|
ch_title = ch["title"] or f"Ch{ch['chapter_index'] + 1}"
|
|
|
|
if status_callback:
|
|
progress_pct = int((completed_utterances / max(total_utterances, 1)) * 100)
|
|
status_callback(
|
|
"generate",
|
|
"running",
|
|
f"Chapter {i + 1}/{total_chapters}: {ch_title}",
|
|
progress_pct,
|
|
total_chapters=total_chapters,
|
|
chapters_completed=list(range(i)),
|
|
current_chapter=ch_id,
|
|
completed_utterances=completed_utterances,
|
|
total_utterances=total_utterances,
|
|
)
|
|
|
|
progress_ctx = {"completed": completed_utterances, "total": total_utterances}
|
|
gen, fail = generate_chapter(
|
|
ch_id,
|
|
engine,
|
|
voice_refs,
|
|
alias_map,
|
|
output_base,
|
|
cfg.db_path,
|
|
narrator_ref,
|
|
phonetic=phonetic,
|
|
proper_names=proper_names,
|
|
speaker_traits=speaker_traits,
|
|
distortion_effects=distortion_effects,
|
|
emotion_fx=emotion_fx,
|
|
utterance_callback=_utterance_done,
|
|
progress_callback=_progress_tick,
|
|
progress_context=progress_ctx,
|
|
)
|
|
completed_utterances = progress_ctx["completed"]
|
|
total_gen += gen
|
|
total_fail += fail
|
|
|
|
ch_dir = output_base / f"ch{ch_id}"
|
|
full_wav = output_base / f"ch{ch_id}_full.wav"
|
|
utt_files = sorted(ch_dir.glob("utt_*.wav")) if ch_dir.exists() else []
|
|
needs_stitch = True
|
|
if full_wav.exists() and utt_files:
|
|
newest_utt = max(f.stat().st_mtime for f in utt_files)
|
|
needs_stitch = full_wav.stat().st_mtime < newest_utt
|
|
if utt_files and needs_stitch:
|
|
try:
|
|
from umbraaudio.assembly import AudioStitcher
|
|
|
|
stitcher = AudioStitcher()
|
|
stitcher.stitch_utterances(utt_files, full_wav)
|
|
n_files = len(utt_files)
|
|
logger.info(" Stitched ch%d -> %s (%d files)", ch_id, full_wav.name, n_files)
|
|
except Exception as e:
|
|
logger.error(" Stitch failed for ch%d: %s", ch_id, e)
|
|
|
|
elapsed = (time.time() - start) / 60
|
|
logger.info(
|
|
"Ch %d (%s): +%d gen, +%d fail | Total: %d gen, %d fail | %.1f min elapsed",
|
|
ch_id,
|
|
ch_title,
|
|
gen,
|
|
fail,
|
|
total_gen,
|
|
total_fail,
|
|
elapsed,
|
|
)
|
|
finally:
|
|
if _gen_ctx is not None:
|
|
_gen_ctx.__exit__(None, None, None)
|
|
|
|
engine.unload_all()
|
|
coordinator.switch_to(PipelineStage.IDLE)
|
|
elapsed = (time.time() - start) / 60
|
|
logger.info(
|
|
"=== COMPLETE: %d generated, %d failed, %.1f min total ===",
|
|
total_gen,
|
|
total_fail,
|
|
elapsed,
|
|
)
|
|
|
|
if status_callback:
|
|
status_callback(
|
|
"generate",
|
|
"completed",
|
|
f"Done: {total_gen} generated, {total_fail} failed in {elapsed:.1f} min",
|
|
100,
|
|
total_chapters=total_chapters,
|
|
chapters_completed=list(range(total_chapters)),
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
import argparse
|
|
|
|
parser = argparse.ArgumentParser(description="Background chapter audio generation")
|
|
parser.add_argument(
|
|
"--project", type=str, default=None, help="Project name or path"
|
|
)
|
|
args = parser.parse_args()
|
|
main(project=args.project)
|