From 648281cdb634ddefb2739d50444cc7fdc960fa15 Mon Sep 17 00:00:00 2001 From: "russell@unturf.com" Date: Tue, 27 Jan 2026 11:53:33 -0500 Subject: [PATCH] Update diverse voices script with correct voice names --- scripts/download_diverse_voices.py | 362 +++++++++++++++++------------ 1 file changed, 217 insertions(+), 145 deletions(-) diff --git a/scripts/download_diverse_voices.py b/scripts/download_diverse_voices.py index 3de0954..c303769 100644 --- a/scripts/download_diverse_voices.py +++ b/scripts/download_diverse_voices.py @@ -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)