diff --git a/scripts/download_diverse_voices.py b/scripts/download_diverse_voices.py index 0c54a8f..bfd2b6a 100644 --- a/scripts/download_diverse_voices.py +++ b/scripts/download_diverse_voices.py @@ -2,6 +2,7 @@ """ Download diverse voice samples for Qwen3-TTS voice cloning. Uses HuggingFace datasets for LibriSpeech test-clean with multiple speakers. +Fetches gender from upstream SPEAKERS.TXT (OpenSLR) to ensure correct assignment. Requires: pip install datasets soundfile @@ -13,6 +14,7 @@ Usage: import os import json import argparse +import urllib.request from pathlib import Path try: @@ -23,64 +25,45 @@ except ImportError: HAS_DATASETS = False print("Install required libraries: pip install datasets soundfile") -# LibriSpeech test-clean speaker metadata -# Gender verified against official SPEAKERS.TXT from OpenSLR -# Format: speaker_id: (gender, description) -SPEAKERS = { - # Female speakers (11) - verified from SPEAKERS.TXT - "1221": ("female", "expressive, storyteller - engaging narration"), - "1284": ("female", "clear, measured - precise enunciation"), - "1580": ("female", "clear narrator - smooth reading style"), - "2094": ("female", "warm, rich - resonant tone"), - "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"), - "4970": ("female", "smooth, polished - announcer style"), - "5142": ("female", "warm, conversational - natural flow"), - "6829": ("female", "crisp, articulate - precise diction"), +# Upstream SPEAKERS.TXT URL from OpenSLR (canonical gender source) +SPEAKERS_TXT_URL = "https://www.openslr.org/resources/12/raw-metadata.tar.gz" +# GitHub mirror (plain text, easier to parse) +SPEAKERS_TXT_GITHUB = "https://raw.githubusercontent.com/oscarknagg/voicemap/master/data/LibriSpeech/SPEAKERS.TXT" - # Male speakers (10) - verified from SPEAKERS.TXT - "1089": ("male", "neutral, balanced - clear professional delivery"), - "1188": ("male", "warm, friendly - approachable delivery"), - "1320": ("male", "soft, gentle - calm and soothing"), - "2830": ("male", "young, energetic - dynamic delivery"), - "4077": ("male", "mature, professor - thoughtful pacing"), - "5105": ("male", "neutral, clear - straightforward style"), - "5639": ("male", "deep, dramatic - authoritative tone"), - "6930": ("male", "smooth, polished - radio-style delivery"), - "7021": ("male", "light, conversational - casual tone"), - "7176": ("male", "warm storyteller - engaging narrative"), -} +# Voice names - female names for female speakers, male names for male speakers +FEMALE_NAMES = ["aria", "clara", "elena", "grace", "hazel", "iris", "luna", "maya", "ruby", "sage", "sofia"] +MALE_NAMES = ["atlas", "caleb", "felix", "hugo", "jasper", "kai", "leo", "marcus", "owen", "theo"] -# 21 voices: gendered names that match the speaker -# Gender verified against LibriSpeech SPEAKERS.TXT -VOICE_MAP = { - # Female voices (11) - all verified female speakers - "aria": "1221", # F - expressive, storyteller - "clara": "1284", # F - clear, measured - "elena": "1580", # F - clear narrator - "grace": "2094", # F - warm, rich - "hazel": "3575", # F - calm, neutral - "iris": "4507", # F - young, clear - "luna": "3570", # F - bright, energetic - "maya": "5142", # F - warm, conversational - "ruby": "4446", # F - mature, authoritative - "sage": "6829", # F - crisp, articulate - "sofia": "4970", # F - smooth, polished - # Male voices (10) - all verified male speakers - "atlas": "5639", # M - deep, dramatic - "caleb": "1089", # M - neutral, balanced - "felix": "7021", # M - light, conversational - "hugo": "7176", # M - warm storyteller - "jasper": "4077", # M - mature, professor - "kai": "2830", # M - young, energetic - "leo": "6930", # M - smooth, polished - "marcus": "1188", # M - warm, friendly - "owen": "5105", # M - neutral, clear - "theo": "1320", # M - soft, gentle -} +def fetch_speaker_genders(): + """Fetch gender info from upstream LibriSpeech SPEAKERS.TXT.""" + print("Fetching speaker genders from upstream SPEAKERS.TXT...") + try: + req = urllib.request.Request(SPEAKERS_TXT_GITHUB, headers={"User-Agent": "uncloseai-speech"}) + with urllib.request.urlopen(req, timeout=15) as resp: + text = resp.read().decode("utf-8") + except Exception as e: + print(f" WARNING: Failed to fetch SPEAKERS.TXT: {e}") + return {} + + genders = {} + for line in text.splitlines(): + line = line.strip() + if not line or line.startswith(";"): + continue + # Format: ID | SEX | SUBSET | MINUTES | NAME + parts = [p.strip() for p in line.split("|")] + if len(parts) >= 2: + try: + speaker_id = parts[0].strip() + sex = parts[1].strip().upper() + if sex in ("F", "M"): + genders[speaker_id] = "female" if sex == "F" else "male" + except (ValueError, IndexError): + continue + + print(f" Loaded genders for {len(genders)} speakers") + return genders def pick_best_sample(samples, min_dur=4.0, max_dur=12.0, target=7.0): @@ -139,10 +122,15 @@ def main(): output_dir = Path(args.output_dir) output_dir.mkdir(parents=True, exist_ok=True) - print("=== Downloading Diverse Voice Samples for Qwen3-TTS ===\n") + # Fetch genders from upstream + speaker_genders = fetch_speaker_genders() + if not speaker_genders: + print("ERROR: Could not fetch speaker genders. Cannot assign gendered names.") + return 1 + + print(f"\n=== 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...") @@ -166,13 +154,49 @@ def main(): print(f"Found {len(speaker_samples)} speakers in test-clean\n") + # Split speakers by gender using upstream data + female_speakers = [] + male_speakers = [] + for sid in speaker_samples: + gender = speaker_genders.get(sid) + if gender == "female": + female_speakers.append(sid) + elif gender == "male": + male_speakers.append(sid) + else: + print(f" WARNING: No gender for speaker {sid}, skipping") + + print(f"Female speakers available: {len(female_speakers)}") + print(f"Male speakers available: {len(male_speakers)}") + + # Pick the best speakers (most samples, best quality) + # Sort by number of samples available (more = better selection) + female_speakers.sort(key=lambda s: len(speaker_samples[s]), reverse=True) + male_speakers.sort(key=lambda s: len(speaker_samples[s]), reverse=True) + + # Take top N for each gender + female_speakers = female_speakers[:len(FEMALE_NAMES)] + male_speakers = male_speakers[:len(MALE_NAMES)] + + if len(female_speakers) < len(FEMALE_NAMES): + print(f" WARNING: Only {len(female_speakers)} female speakers, need {len(FEMALE_NAMES)}") + if len(male_speakers) < len(MALE_NAMES): + print(f" WARNING: Only {len(male_speakers)} male speakers, need {len(MALE_NAMES)}") + + # Build voice map: assign names to speakers + voice_map = {} + for i, sid in enumerate(female_speakers): + if i < len(FEMALE_NAMES): + voice_map[FEMALE_NAMES[i]] = sid + for i, sid in enumerate(male_speakers): + if i < len(MALE_NAMES): + voice_map[MALE_NAMES[i]] = sid + + print(f"\nAssigned {len(voice_map)} voices ({len(female_speakers)}F + {len(male_speakers)}M)\n") + # Download and save voice samples voices = {} - for voice_name, speaker_id in VOICE_MAP.items(): - if speaker_id not in speaker_samples: - print(f" WARNING: Speaker {speaker_id} not found for '{voice_name}'") - continue - + for voice_name, speaker_id in voice_map.items(): samples = speaker_samples[speaker_id] best = pick_best_sample(samples) if best is None: @@ -187,48 +211,50 @@ def main(): 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", "")) + gender = speaker_genders.get(speaker_id, "unknown") voices[voice_name] = { "ref_audio": f"cloned-voices/{voice_name}.wav", "ref_text": transcript, "speaker_id": speaker_id, "gender": gender, - "description": desc, "duration": round(duration, 1), } - print(f" {voice_name:8s} | {gender:6s} | speaker {speaker_id:5s} | {duration:.1f}s | {desc}") + print(f" {voice_name:8s} | {gender:6s} | speaker {speaker_id:5s} | {duration:.1f}s") - print(f"\nDownloaded {len(voices)}/{len(VOICE_MAP)} voices\n") + print(f"\nDownloaded {len(voices)}/{len(voice_map)} voices\n") # Generate YAML config print("Generating voice config...") + female_list = ", ".join(n for n in FEMALE_NAMES if n in voices) + male_list = ", ".join(n for n in MALE_NAMES if n in voices) + lines = [ "# uncloseai-speech Voice Configuration", "# Diverse voice samples from LibriSpeech test-clean (public domain)", "# Each voice is a DISTINCT SPEAKER for Qwen3-TTS voice cloning", + "# Gender verified from upstream LibriSpeech SPEAKERS.TXT", "#", - "# Female voices: aria, clara, elena, grace, hazel, iris, luna, maya, ruby, sage, sofia", - "# Male voices: atlas, caleb, felix, hugo, jasper, kai, leo, marcus, owen, theo", + f"# Female voices: {female_list}", + f"# Male voices: {male_list}", "#", "# Source: LibriSpeech test-clean (public domain, LibriVox recordings)", - "# 21 distinct speakers (11 female, 10 male)", + f"# {len(voices)} distinct speakers ({sum(1 for v in voices.values() if v['gender']=='female')} female, {sum(1 for v in voices.values() if v['gender']=='male')} male)", "", "tts-1-qwen:", "", ] - # Write in voice map order - for voice_name in VOICE_MAP: + # Write female voices first, then male + for voice_name in FEMALE_NAMES + MALE_NAMES: if voice_name not in voices: continue v = voices[voice_name] - lines.append(f" # {v['description']}") + lines.append(f" # {v['gender']} - speaker {v['speaker_id']}") 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") @@ -274,9 +300,11 @@ def main(): print(f" Written: {meta_path}") 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"\nVoice mapping:") + for name in FEMALE_NAMES + MALE_NAMES: + if name in voices: + v = voices[name] + print(f" {name:8s} -> speaker {v['speaker_id']} ({v['gender']})") return 0