Fetch speaker genders from upstream LibriSpeech SPEAKERS.TXT

This commit is contained in:
russell@unturf.com 2026-01-27 13:17:52 -05:00
parent b77af6d603
commit 35b083ed34

View file

@ -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