Files
UmbraAudio/scripts/generate_long_voice_refs.py
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

164 lines
5.8 KiB
Python

#!/usr/bin/env python3
"""Generate longer voice references (30+ seconds) for speakers using VoiceDesign."""
from __future__ import annotations
import sys
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
import io
import httpx
import soundfile as sf
from umbraaudio.config import load_config
TTS_URL = "http://localhost:8881"
# Rainbow Passage - phonetically balanced paragraph for voice samples
# Source: Grant Fairbanks, Voice and Articulation Drillbook (1960)
# Covers all English phonemes including r-colored vowels
# Repeated to generate 30+ seconds of speech for voice cloning references
VOICE_REFERENCE_TEXT = """
When the sunlight strikes raindrops in the air, they act as a prism and form a rainbow. The rainbow is a division of white light into many beautiful colors. These take the shape of a long round arch, with its path high above, and its two ends apparently beyond the horizon. There is, according to legend, a boiling pot of gold at one end. People look, but no one ever finds it. When a man looks for something beyond his reach, his friends say he is looking for the pot of gold at the end of the rainbow.
When the sunlight strikes raindrops in the air, they act as a prism and form a rainbow. The rainbow is a division of white light into many beautiful colors. These take the shape of a long round arch, with its path high above, and its two ends apparently beyond the horizon. There is, according to legend, a boiling pot of gold at one end. People look, but no one ever finds it. When a man looks for something beyond his reach, his friends say he is looking for the pot of gold at the end of the rainbow.
""".strip()
# Speaker descriptions for VoiceDesign
SPEAKER_DESCRIPTIONS = {
"narrator": (
"A professional male audiobook narrator with a deep, clear, "
"authoritative voice. Calm and measured delivery."
),
"captain_weston": (
"A confident male ship captain in his 40s with a commanding but "
"warm voice. Authoritative yet approachable."
),
"brutus": ("A gruff male voice with a deep, gravelly tone. Tough and no-nonsense demeanor."),
"alec": (
"A young male voice in his 20s with an energetic and friendly tone. Slightly higher pitch."
),
"admiral_wilbur_wake": (
"An older male voice with a distinguished, formal tone. Commanding presence with wisdom."
),
"captain_wolfgang_antlion": (
"A stern male voice with a German accent. Precise and formal military bearing."
),
"ai-2": (
"A synthesized female AI voice. Clear, precise, and slightly "
"robotic with good pronunciation."
),
"aunt_vicky": ("A warm, motherly female voice in her 50s. Caring and gentle tone."),
"duchess_victoria": (
"An elegant female voice with refined pronunciation. Aristocratic and poised."
),
"gisfrid": (
"A middle-aged male voice with a scholarly tone. Intelligent and measured speaking style."
),
}
def generate_voice_reference(
speaker_name: str,
description: str,
output_path: Path,
text: str = VOICE_REFERENCE_TEXT,
) -> bool:
"""Generate a voice reference using VoiceDesign."""
print(f"Generating voice reference for: {speaker_name}")
try:
response = httpx.post(
f"{TTS_URL}/v1/tts",
data={
"text": text,
"voice_description": description,
"sample_rate": 24000,
},
timeout=600.0,
)
if response.status_code != 200:
print(f" Error: HTTP {response.status_code}")
return False
# Parse the WAV response properly (server returns a WAV file, not raw PCM)
wav, sr = sf.read(io.BytesIO(response.content), dtype="float32")
sf.write(str(output_path), wav, sr)
# Check duration
info = sf.info(str(output_path))
print(f" Generated: {info.duration:.1f}s, {output_path.stat().st_size} bytes")
return info.duration >= 20.0 # At least 20 seconds
except Exception as e:
print(f" Error: {e}")
return False
def main():
import argparse
parser = argparse.ArgumentParser(description="Generate longer voice references")
parser.add_argument(
"--project",
type=str,
default=None,
help="Project name or path",
)
parser.add_argument(
"--speakers",
type=str,
default=None,
help="Comma-separated speaker names (e.g. 'narrator,captain_weston')",
)
parser.add_argument(
"--all", action="store_true", help="Generate for all speakers with descriptions"
)
args = parser.parse_args()
cfg = load_config(args.project)
voices_dir = cfg.voices_dir
voices_dir.mkdir(parents=True, exist_ok=True)
if args.all:
speakers = SPEAKER_DESCRIPTIONS
elif args.speakers:
names = [s.strip() for s in args.speakers.split(",")]
speakers = {n: SPEAKER_DESCRIPTIONS.get(n, f"A clear voice for {n}") for n in names}
else:
print("Specify --speakers or --all")
return
success = 0
failed = 0
for speaker_name, description in speakers.items():
output_path = voices_dir / f"{speaker_name}_ref.wav"
# Skip if already exists and is long enough
if output_path.exists():
info = sf.info(str(output_path))
if info.duration >= 25.0:
print(f"Skipping {speaker_name}: already {info.duration:.1f}s")
success += 1
continue
if generate_voice_reference(speaker_name, description, output_path):
success += 1
else:
failed += 1
time.sleep(1) # Rate limiting
print(f"\nDone: {success} succeeded, {failed} failed")
if __name__ == "__main__":
main()