Fetch speaker genders from upstream LibriSpeech SPEAKERS.TXT
This commit is contained in:
parent
b77af6d603
commit
35b083ed34
1 changed files with 104 additions and 76 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue