Update diverse voices script with correct voice names
This commit is contained in:
parent
aa8af56835
commit
648281cdb6
1 changed files with 217 additions and 145 deletions
|
|
@ -1,7 +1,13 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Download diverse voice samples for Qwen3-TTS voice cloning.
|
||||
Uses HuggingFace datasets for LibriSpeech samples with multiple speakers.
|
||||
Uses HuggingFace datasets for LibriSpeech test-clean with multiple speakers.
|
||||
|
||||
Requires: pip install datasets soundfile
|
||||
|
||||
Usage:
|
||||
python scripts/download_diverse_voices.py
|
||||
python scripts/download_diverse_voices.py -o cloned-voices -c voice_to_speaker.default.yaml
|
||||
"""
|
||||
|
||||
import os
|
||||
|
|
@ -15,88 +21,133 @@ try:
|
|||
HAS_DATASETS = True
|
||||
except ImportError:
|
||||
HAS_DATASETS = False
|
||||
print("Install datasets library: pip install datasets soundfile")
|
||||
print("Install required libraries: pip install datasets soundfile")
|
||||
|
||||
# LibriSpeech test-clean speaker metadata
|
||||
# Curated for voice diversity - different genders, tones, pacing
|
||||
# Format: speaker_id: (gender, description)
|
||||
LIBRISPEECH_SPEAKERS = {
|
||||
SPEAKERS = {
|
||||
# Female speakers
|
||||
"1089": ("female", "clear, professional"),
|
||||
"1188": ("female", "warm, measured"),
|
||||
"1221": ("female", "expressive, storyteller"),
|
||||
"1320": ("female", "soft, gentle"),
|
||||
"3570": ("female", "bright, energetic"),
|
||||
"3575": ("female", "calm, neutral"),
|
||||
"4446": ("female", "mature, authoritative"),
|
||||
"4507": ("female", "young, clear"),
|
||||
"5142": ("female", "warm, friendly"),
|
||||
"6829": ("female", "crisp, articulate"),
|
||||
"6930": ("female", "smooth, radio-like"),
|
||||
"7729": ("female", "light, airy"),
|
||||
"8230": ("female", "rich, full"),
|
||||
"8455": ("female", "neutral, newsreader"),
|
||||
"1089": ("female", "neutral, balanced - clear professional delivery"),
|
||||
"1188": ("female", "warm, friendly - approachable delivery"),
|
||||
"1221": ("female", "expressive, storyteller - engaging narration"),
|
||||
"1320": ("female", "soft, gentle - calm and soothing"),
|
||||
"3570": ("female", "bright, energetic - lively and upbeat"),
|
||||
"3575": ("female", "calm, neutral - steady and even"),
|
||||
"4446": ("female", "mature, authoritative - confident tone"),
|
||||
"4507": ("female", "young, clear - crisp enunciation"),
|
||||
"5142": ("female", "warm, conversational - natural flow"),
|
||||
"6829": ("female", "crisp, articulate - precise diction"),
|
||||
|
||||
# Male speakers
|
||||
"1284": ("male", "deep, resonant"),
|
||||
"1580": ("male", "clear, narrator"),
|
||||
"2094": ("male", "warm, baritone"),
|
||||
"2830": ("male", "young, energetic"),
|
||||
"4077": ("male", "mature, professor"),
|
||||
"4970": ("male", "smooth, announcer"),
|
||||
"5105": ("male", "neutral, clear"),
|
||||
"5639": ("male", "deep, dramatic"),
|
||||
"7021": ("male", "light, conversational"),
|
||||
"7127": ("male", "authoritative, news"),
|
||||
"7176": ("male", "warm, storyteller"),
|
||||
"8224": ("male", "crisp, professional"),
|
||||
"8463": ("male", "rich, bass"),
|
||||
"1284": ("male", "clear, measured - precise enunciation"),
|
||||
"1580": ("male", "clear narrator - smooth reading style"),
|
||||
"2094": ("male", "warm baritone - rich and resonant"),
|
||||
"2830": ("male", "young, energetic - dynamic delivery"),
|
||||
"4077": ("male", "mature, professor - thoughtful pacing"),
|
||||
"4970": ("male", "smooth announcer - polished delivery"),
|
||||
"5105": ("male", "neutral, clear - straightforward style"),
|
||||
"5639": ("male", "deep, dramatic - authoritative tone"),
|
||||
"7021": ("male", "light, conversational - casual tone"),
|
||||
"7176": ("male", "warm storyteller - engaging narrative"),
|
||||
}
|
||||
|
||||
# Voice name mappings for OpenAI-compatible names
|
||||
VOICE_MAPPINGS = {
|
||||
# OpenAI standard voices
|
||||
"alloy": {"speaker": "1089", "desc": "neutral, balanced female"},
|
||||
"echo": {"speaker": "1284", "desc": "deep, clear male"},
|
||||
"fable": {"speaker": "1221", "desc": "expressive, storyteller female"},
|
||||
"onyx": {"speaker": "5639", "desc": "deep, dramatic male"},
|
||||
"nova": {"speaker": "1188", "desc": "warm, friendly female"},
|
||||
"shimmer": {"speaker": "1320", "desc": "soft, gentle female"},
|
||||
# Map voice names to speakers
|
||||
# OpenAI standard: alloy, echo, fable, onyx, nova, shimmer
|
||||
# Extended: amber, breeze, coral, dawn, ember, frost, glow, haze, ivy, jade, kite, lark, mist, nectar
|
||||
VOICE_MAP = {
|
||||
# Standard voices - mix of male and female
|
||||
"alloy": "1089", # F - neutral, balanced
|
||||
"echo": "1284", # M - clear, measured
|
||||
"fable": "1221", # F - expressive, storyteller
|
||||
"onyx": "5639", # M - deep, dramatic
|
||||
"nova": "1188", # F - warm, friendly
|
||||
"shimmer": "1320", # F - soft, gentle
|
||||
|
||||
# Extended voices - female
|
||||
"aurora": {"speaker": "3570", "desc": "bright, energetic female"},
|
||||
"bella": {"speaker": "4446", "desc": "mature, authoritative female"},
|
||||
"clara": {"speaker": "5142", "desc": "warm, conversational female"},
|
||||
"dawn": {"speaker": "6829", "desc": "crisp, articulate female"},
|
||||
"ember": {"speaker": "6930", "desc": "smooth, radio-like female"},
|
||||
"fiona": {"speaker": "7729", "desc": "light, airy female"},
|
||||
"grace": {"speaker": "8230", "desc": "rich, full female"},
|
||||
"hazel": {"speaker": "8455", "desc": "neutral, newsreader female"},
|
||||
"iris": {"speaker": "3575", "desc": "calm, neutral female"},
|
||||
"jade": {"speaker": "4507", "desc": "young, clear female"},
|
||||
# Extended female voices
|
||||
"amber": "5142", # F - warm, conversational
|
||||
"breeze": "3570", # F - bright, energetic
|
||||
"coral": "4446", # F - mature, authoritative
|
||||
"dawn": "6829", # F - crisp, articulate
|
||||
"glow": "3575", # F - calm, neutral
|
||||
"ivy": "4507", # F - young, clear
|
||||
|
||||
# Extended voices - male
|
||||
"atlas": {"speaker": "1580", "desc": "clear, narrator male"},
|
||||
"blaze": {"speaker": "2830", "desc": "young, energetic male"},
|
||||
"cedar": {"speaker": "4077", "desc": "mature, professor male"},
|
||||
"drake": {"speaker": "4970", "desc": "smooth, announcer male"},
|
||||
"eric": {"speaker": "5105", "desc": "neutral, clear male"},
|
||||
"felix": {"speaker": "7021", "desc": "light, conversational male"},
|
||||
"grant": {"speaker": "7127", "desc": "authoritative, news male"},
|
||||
"hugo": {"speaker": "7176", "desc": "warm, storyteller male"},
|
||||
"ivan": {"speaker": "8224", "desc": "crisp, professional male"},
|
||||
"jack": {"speaker": "2094", "desc": "warm, baritone male"},
|
||||
"knox": {"speaker": "8463", "desc": "rich, bass male"},
|
||||
# Extended male voices
|
||||
"ember": "2094", # M - warm baritone
|
||||
"frost": "4970", # M - smooth announcer
|
||||
"haze": "7021", # M - light, conversational
|
||||
"jade": "4077", # M - mature, professor
|
||||
"kite": "2830", # M - young, energetic
|
||||
"lark": "7176", # M - warm storyteller
|
||||
"mist": "5105", # M - neutral, clear
|
||||
"nectar": "1580", # M - clear narrator
|
||||
}
|
||||
|
||||
|
||||
def download_librispeech_samples(output_dir: Path, max_per_speaker: int = 1):
|
||||
"""Download LibriSpeech test-clean samples for each speaker."""
|
||||
def pick_best_sample(samples, min_dur=4.0, max_dur=12.0, target=7.0):
|
||||
"""Pick the best sample: prefer 5-10 seconds, clean, complete sentence."""
|
||||
best = None
|
||||
best_score = float('-inf')
|
||||
|
||||
for s in samples:
|
||||
audio = s["audio"]
|
||||
dur = len(audio["array"]) / audio["sampling_rate"]
|
||||
text = s.get("text", "")
|
||||
|
||||
# Skip too short or too long
|
||||
if dur < 3.0:
|
||||
continue
|
||||
|
||||
# Score: prefer target duration, penalize extremes
|
||||
score = -abs(dur - target)
|
||||
|
||||
# Bonus for ending with period (complete sentence)
|
||||
if text.strip().endswith('.'):
|
||||
score += 2.0
|
||||
|
||||
# Bonus for being in ideal range
|
||||
if min_dur <= dur <= max_dur:
|
||||
score += 5.0
|
||||
|
||||
# Penalty for very long text (harder for model)
|
||||
if len(text) > 300:
|
||||
score -= 3.0
|
||||
|
||||
if score > best_score:
|
||||
best = s
|
||||
best_score = score
|
||||
|
||||
return best
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Download diverse voice samples for Qwen3-TTS")
|
||||
parser.add_argument("-o", "--output-dir", default="cloned-voices",
|
||||
help="Output directory for voice WAV files")
|
||||
parser.add_argument("-c", "--config-output", default="voice_to_speaker.default.yaml",
|
||||
help="Output path for voice config YAML")
|
||||
parser.add_argument("--config-runtime", default="config/voice_to_speaker.yaml",
|
||||
help="Runtime config path (also written if dir exists)")
|
||||
parser.add_argument("--max-samples", type=int, default=20,
|
||||
help="Max samples to collect per speaker for selection")
|
||||
args = parser.parse_args()
|
||||
|
||||
if not HAS_DATASETS:
|
||||
print("Error: datasets library not installed")
|
||||
return {}
|
||||
print("ERROR: Install required libraries first:")
|
||||
print(" pip install datasets soundfile")
|
||||
return 1
|
||||
|
||||
print("Loading LibriSpeech test-clean dataset...")
|
||||
output_dir = Path(args.output_dir)
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
print("=== Downloading Diverse Voice Samples for Qwen3-TTS ===\n")
|
||||
print(f"Output directory: {output_dir}")
|
||||
print(f"Config output: {args.config_output}")
|
||||
print(f"Voices to download: {len(VOICE_MAP)}\n")
|
||||
|
||||
# Load LibriSpeech test-clean
|
||||
print("Loading LibriSpeech test-clean dataset from HuggingFace...")
|
||||
print("(First run downloads ~1.5 GB, cached after that)\n")
|
||||
dataset = load_dataset(
|
||||
"openslr/librispeech_asr",
|
||||
"clean",
|
||||
|
|
@ -105,111 +156,132 @@ def download_librispeech_samples(output_dir: Path, max_per_speaker: int = 1):
|
|||
)
|
||||
|
||||
# Group samples by speaker
|
||||
print("Grouping samples by speaker...")
|
||||
speaker_samples = {}
|
||||
for sample in dataset:
|
||||
speaker_id = str(sample["speaker_id"])
|
||||
sid = str(sample["speaker_id"])
|
||||
if sid not in speaker_samples:
|
||||
speaker_samples[sid] = []
|
||||
if len(speaker_samples[sid]) < args.max_samples:
|
||||
speaker_samples[sid].append(sample)
|
||||
|
||||
print(f"Found {len(speaker_samples)} speakers in test-clean\n")
|
||||
|
||||
# Download and save voice samples
|
||||
voices = {}
|
||||
for voice_name, speaker_id in VOICE_MAP.items():
|
||||
if speaker_id not in speaker_samples:
|
||||
speaker_samples[speaker_id] = []
|
||||
if len(speaker_samples[speaker_id]) < max_per_speaker:
|
||||
speaker_samples[speaker_id].append(sample)
|
||||
|
||||
# Download samples for our target speakers
|
||||
downloaded = {}
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
for voice_name, voice_info in VOICE_MAPPINGS.items():
|
||||
speaker_id = voice_info["speaker"]
|
||||
|
||||
if speaker_id not in speaker_samples:
|
||||
print(f" Warning: Speaker {speaker_id} not found for voice '{voice_name}'")
|
||||
print(f" WARNING: Speaker {speaker_id} not found for '{voice_name}'")
|
||||
continue
|
||||
|
||||
sample = speaker_samples[speaker_id][0]
|
||||
audio = sample["audio"]
|
||||
transcript = sample["text"]
|
||||
samples = speaker_samples[speaker_id]
|
||||
best = pick_best_sample(samples)
|
||||
if best is None:
|
||||
print(f" WARNING: No suitable sample for '{voice_name}' (speaker {speaker_id})")
|
||||
continue
|
||||
|
||||
# Save audio file
|
||||
output_path = output_dir / f"{voice_name}.wav"
|
||||
sf.write(str(output_path), audio["array"], audio["sampling_rate"])
|
||||
audio = best["audio"]
|
||||
transcript = best["text"].strip()
|
||||
duration = len(audio["array"]) / audio["sampling_rate"]
|
||||
|
||||
downloaded[voice_name] = {
|
||||
"file": str(output_path),
|
||||
"transcript": transcript,
|
||||
# Save WAV
|
||||
out_path = output_dir / f"{voice_name}.wav"
|
||||
sf.write(str(out_path), audio["array"], audio["sampling_rate"])
|
||||
|
||||
gender, desc = SPEAKERS.get(speaker_id, ("unknown", ""))
|
||||
|
||||
voices[voice_name] = {
|
||||
"ref_audio": f"cloned-voices/{voice_name}.wav",
|
||||
"ref_text": transcript,
|
||||
"speaker_id": speaker_id,
|
||||
"description": voice_info["desc"],
|
||||
"sample_rate": audio["sampling_rate"]
|
||||
"gender": gender,
|
||||
"description": desc,
|
||||
"duration": round(duration, 1),
|
||||
}
|
||||
|
||||
print(f" Downloaded: {voice_name} (speaker {speaker_id})")
|
||||
print(f" {voice_name:8s} | {gender:6s} | speaker {speaker_id:5s} | {duration:.1f}s | {desc}")
|
||||
|
||||
return downloaded
|
||||
print(f"\nDownloaded {len(voices)}/{len(VOICE_MAP)} voices\n")
|
||||
|
||||
|
||||
def generate_voice_config(voices: dict, output_file: Path):
|
||||
"""Generate voice_to_speaker.yaml config."""
|
||||
# Generate YAML config
|
||||
print("Generating voice config...")
|
||||
|
||||
lines = [
|
||||
"# uncloseai-speech Voice Configuration",
|
||||
"# Auto-generated with diverse LibriSpeech speakers",
|
||||
"# Diverse voice samples from LibriSpeech test-clean (public domain)",
|
||||
"# Each voice is a DISTINCT SPEAKER for Qwen3-TTS voice cloning",
|
||||
"#",
|
||||
"# Standard voices: alloy, echo, fable, onyx, nova, shimmer",
|
||||
"# Extended voices: amber, breeze, coral, dawn, ember, frost,",
|
||||
"# glow, haze, ivy, jade, kite, lark, mist, nectar",
|
||||
"#",
|
||||
"# Source: LibriSpeech test-clean (public domain, LibriVox recordings)",
|
||||
"# 20 distinct speakers (10 female, 10 male)",
|
||||
"",
|
||||
"tts-1-qwen:",
|
||||
" # OpenAI-compatible voice names with diverse speakers",
|
||||
" # Each voice has a unique speaker from LibriSpeech test-clean",
|
||||
"",
|
||||
]
|
||||
|
||||
for voice_name, info in sorted(voices.items()):
|
||||
# Use relative path from app root
|
||||
rel_path = info["file"].replace("/app/", "").replace(str(Path.cwd()) + "/", "")
|
||||
if not rel_path.startswith("voices/"):
|
||||
rel_path = f"voices/samples/{voice_name}.wav"
|
||||
# Write in voice map order
|
||||
for voice_name in VOICE_MAP:
|
||||
if voice_name not in voices:
|
||||
continue
|
||||
v = voices[voice_name]
|
||||
lines.append(f" # {v['description']}")
|
||||
lines.append(f" {voice_name}:")
|
||||
lines.append(f" ref_audio: {v['ref_audio']}")
|
||||
# Escape double quotes in transcript
|
||||
safe_text = v['ref_text'].replace('"', '\\"')
|
||||
lines.append(f' ref_text: "{safe_text}"')
|
||||
lines.append(f" language: English")
|
||||
lines.append("")
|
||||
|
||||
lines.extend([
|
||||
f" # {info['description']}",
|
||||
f" {voice_name}:",
|
||||
f" ref_audio: {rel_path}",
|
||||
f' ref_text: "{info["transcript"]}"',
|
||||
f" language: English",
|
||||
"",
|
||||
])
|
||||
# Disabled engines
|
||||
lines.extend([
|
||||
"# Other TTS engines (disabled by default)",
|
||||
"# Uncomment and configure to enable",
|
||||
"",
|
||||
"# tts-1:",
|
||||
"# # Piper TTS (fast CPU inference)",
|
||||
"# alloy:",
|
||||
"# model: /app/voices/en/en_US/libritts_r/medium/en_US-libritts_r-medium.onnx",
|
||||
"# speaker: 79",
|
||||
"",
|
||||
"# tts-1-hd:",
|
||||
"# # XTTS v2 (voice cloning)",
|
||||
"# alloy:",
|
||||
"# model: xtts",
|
||||
"# speaker: voices/alloy.wav",
|
||||
"",
|
||||
])
|
||||
|
||||
with open(output_file, "w") as f:
|
||||
f.write("\n".join(lines))
|
||||
config_text = "\n".join(lines)
|
||||
|
||||
print(f"\nGenerated config: {output_file}")
|
||||
# Write default config
|
||||
with open(args.config_output, "w") as f:
|
||||
f.write(config_text)
|
||||
print(f" Written: {args.config_output}")
|
||||
|
||||
# Write runtime config if dir exists
|
||||
runtime_dir = Path(args.config_runtime).parent
|
||||
if runtime_dir.exists():
|
||||
with open(args.config_runtime, "w") as f:
|
||||
f.write(config_text)
|
||||
print(f" Written: {args.config_runtime}")
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Download diverse voice samples")
|
||||
parser.add_argument("--output-dir", "-o", default="voices/samples",
|
||||
help="Output directory for voice samples")
|
||||
parser.add_argument("--config-output", "-c", default="config/voice_to_speaker.yaml",
|
||||
help="Output path for voice config")
|
||||
parser.add_argument("--max-per-speaker", "-m", type=int, default=1,
|
||||
help="Max samples per speaker")
|
||||
args = parser.parse_args()
|
||||
# Save metadata JSON
|
||||
meta_path = output_dir / "voices_metadata.json"
|
||||
with open(meta_path, "w") as f:
|
||||
json.dump(voices, f, indent=2)
|
||||
print(f" Written: {meta_path}")
|
||||
|
||||
output_dir = Path(args.output_dir)
|
||||
print(f"\n=== Done! {len(voices)} diverse voices configured ===")
|
||||
print(f"\nNext steps:")
|
||||
print(f" make deploy # Sync to server")
|
||||
print(f" # Container will pick up new voices on restart")
|
||||
|
||||
print(f"Downloading voice samples to: {output_dir}")
|
||||
voices = download_librispeech_samples(output_dir, args.max_per_speaker)
|
||||
|
||||
if voices:
|
||||
print(f"\nDownloaded {len(voices)} voice samples")
|
||||
|
||||
# Generate config
|
||||
config_path = Path(args.config_output)
|
||||
config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
generate_voice_config(voices, config_path)
|
||||
|
||||
# Also save metadata
|
||||
metadata_path = output_dir / "voices_metadata.json"
|
||||
with open(metadata_path, "w") as f:
|
||||
json.dump(voices, f, indent=2)
|
||||
print(f"Saved metadata: {metadata_path}")
|
||||
else:
|
||||
print("No voices downloaded")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
exit(main() or 0)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue