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

188 lines
5.4 KiB
Python

#!/usr/bin/env python3
"""Assign unique Edge-TTS voices to all speakers."""
from __future__ import annotations
import hashlib
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
from umbraaudio.config import load_config
# Edge-TTS voices organized by gender and pitch
VOICES = {
"male_low": [
"en-US-GuyNeural",
"en-GB-ThomasNeural",
"en-NG-AbeoNeural",
],
"male_mid": [
"en-US-ChristopherNeural",
"en-US-JasonNeural",
"en-US-TonyNeural",
"en-GB-RyanNeural",
"en-AU-WilliamMultilingualNeural",
"en-IN-PrabhatNeural",
"en-IE-ConnorNeural",
"en-CA-LiamNeural",
"en-KE-ChilembaNeural",
],
"female_mid": [
"en-US-JennyNeural",
"en-US-AvaNeural",
"en-US-MichelleNeural",
"en-GB-SoniaNeural",
"en-AU-NatashaNeural",
"en-IN-NeerjaNeural",
"en-IE-EmilyNeural",
"en-CA-ClaraNeural",
"en-NG-EzinneNeural",
"en-KE-AsiliaNeural",
],
"female_high": [
"en-US-SaraNeural",
"en-GB-LibbyNeural",
"en-HK-YanNeural",
],
"child": [
"en-US-AnaNeural",
],
}
# All voices in a flat list for fallback
ALL_VOICES = []
for voice_list in VOICES.values():
ALL_VOICES.extend(voice_list)
def get_voice_category(speaker: dict) -> str:
"""Determine which voice category a speaker should use."""
demos = speaker.get("demographics", {})
gender = demos.get("gender", "Unknown").lower()
stype = demos.get("type", "Human").lower()
# Check for AI/Robot/Computer types
if stype in ("robot", "ai", "computer", "machine", "system"):
return "male_mid" # Robots get a neutral mid voice
# Check for child
if "child" in stype.lower():
return "child"
# Gender-based assignment
if gender == "male":
return "male_mid"
elif gender == "female":
return "female_mid"
else:
# Unknown gender - use mid voices
return "male_mid"
def assign_voices(speakers: list[dict]) -> dict[str, str]:
"""Assign unique voices to all speakers."""
voice_assignments = {}
used_voices = set()
# Track available voices by category
available = {cat: list(voices) for cat, voices in VOICES.items()}
# Sort speakers by chapter count (more chapters = higher priority)
sorted_speakers = sorted(
speakers,
key=lambda s: len(s.get("chapters_appeared", [])),
reverse=True
)
for speaker in sorted_speakers:
name = speaker["primary_name"]
category = get_voice_category(speaker)
# Try to find an unused voice in the preferred category
assigned = False
if available.get(category):
for voice in available[category]:
if voice not in used_voices:
voice_assignments[name] = voice
used_voices.add(voice)
available[category].remove(voice)
assigned = True
break
# If no voice in preferred category, try other categories
if not assigned:
for cat, voices in available.items():
for voice in voices:
if voice not in used_voices:
voice_assignments[name] = voice
used_voices.add(voice)
available[cat].remove(voice)
assigned = True
break
if assigned:
break
# If still no voice, use a hash-based fallback with pitch shifting
if not assigned:
name_hash = int(hashlib.md5(name.encode()).hexdigest()[:4], 16)
base_voice = ALL_VOICES[name_hash % len(ALL_VOICES)]
# Add pitch shift indicator
pitch_shift = (name_hash % 200) - 100 # -100 to +100
voice_assignments[name] = f"{base_voice}:{pitch_shift:+d}"
return voice_assignments
def main():
import argparse
parser = argparse.ArgumentParser(description="Assign unique Edge-TTS voices to speakers")
parser.add_argument(
"--project", type=str, default=None, help="Project name or path"
)
args = parser.parse_args()
cfg = load_config(args.project)
# Load speakers
speakers_path = cfg.data_dir / "speakers.json"
with open(speakers_path) as f:
data = json.load(f)
speakers = data["speakers"]
print(f"Assigning voices to {len(speakers)} speakers...")
# Assign voices
assignments = assign_voices(speakers)
# Save assignments
output_path = cfg.data_dir / "voice_assignments.json"
with open(output_path, "w") as f:
json.dump(assignments, f, indent=2)
print(f"\nVoice assignments saved to {output_path}")
# Print summary
print("\n=== Voice Assignments ===")
for name, voice in sorted(assignments.items()):
print(f" {name:35} -> {voice}")
# Count unique base voices
base_voices = set()
for voice in assignments.values():
if ":" in voice:
base_voices.add(voice.split(":")[0])
else:
base_voices.add(voice)
print("\n=== Summary ===")
print(f"Total speakers: {len(speakers)}")
print(f"Unique base voices: {len(base_voices)}")
print(f"Speakers with pitch shift: {sum(1 for v in assignments.values() if ':' in v)}")
if __name__ == "__main__":
main()