Files
UmbraAudio/scripts/generate_long_voice_refs.py
2026-10-18 19:01:04 -07:00

167 lines
5.8 KiB
Python

#!/usr/bin/env python3
# Copyright 2026 Umbra Tools (umbratools.com)
# SPDX-License-Identifier: Apache-2.0
"""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()