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