285 lines
10 KiB
Python
285 lines
10 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Download diverse voice samples for Qwen3-TTS voice cloning.
|
|
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
|
|
import json
|
|
import argparse
|
|
from pathlib import Path
|
|
|
|
try:
|
|
from datasets import load_dataset
|
|
import soundfile as sf
|
|
HAS_DATASETS = True
|
|
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"),
|
|
|
|
# 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"),
|
|
}
|
|
|
|
# 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 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: Install required libraries first:")
|
|
print(" pip install datasets soundfile")
|
|
return 1
|
|
|
|
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",
|
|
split="test",
|
|
trust_remote_code=True
|
|
)
|
|
|
|
# Group samples by speaker
|
|
print("Grouping samples by speaker...")
|
|
speaker_samples = {}
|
|
for sample in dataset:
|
|
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:
|
|
print(f" WARNING: Speaker {speaker_id} not found for '{voice_name}'")
|
|
continue
|
|
|
|
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
|
|
|
|
audio = best["audio"]
|
|
transcript = best["text"].strip()
|
|
duration = len(audio["array"]) / audio["sampling_rate"]
|
|
|
|
# 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,
|
|
"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"\nDownloaded {len(voices)}/{len(VOICE_MAP)} voices\n")
|
|
|
|
# Generate YAML config
|
|
print("Generating voice config...")
|
|
|
|
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",
|
|
"#",
|
|
"# 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",
|
|
"#",
|
|
"# Source: LibriSpeech test-clean (public domain, LibriVox recordings)",
|
|
"# 21 distinct speakers (11 female, 10 male)",
|
|
"",
|
|
"tts-1-qwen:",
|
|
"",
|
|
]
|
|
|
|
# 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("")
|
|
|
|
# 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",
|
|
"",
|
|
])
|
|
|
|
config_text = "\n".join(lines)
|
|
|
|
# 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}")
|
|
|
|
# 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}")
|
|
|
|
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")
|
|
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
exit(main() or 0)
|