Add idempotent voice registry system for permanent speaker-to-name assignments
- voice_registry.json: append-only registry with 50 name pools per gender, locked speaker assignments, and multi-corpus support - Rewrite download script to be registry-driven: loads registry, assigns names deterministically (sorted by speaker ID), never changes existing assignments - Update docs/VOICES.md with registry system documentation - Support --registry and --corpora CLI flags for multi-corpus downloads
This commit is contained in:
parent
af5c81928d
commit
c3ac2def48
3 changed files with 527 additions and 101 deletions
164
docs/VOICES.md
Normal file
164
docs/VOICES.md
Normal file
|
|
@ -0,0 +1,164 @@
|
||||||
|
# Voice Corpus Documentation
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
uncloseai-speech uses voice samples from **LibriSpeech test-clean** for Qwen3-TTS voice cloning.
|
||||||
|
LibriSpeech is a public domain corpus of read English speech from LibriVox audiobook recordings.
|
||||||
|
|
||||||
|
- **Source:** [OpenSLR LibriSpeech](https://www.openslr.org/12)
|
||||||
|
- **HuggingFace:** [openslr/librispeech_asr](https://huggingface.co/datasets/openslr/librispeech_asr)
|
||||||
|
- **License:** Public domain (LibriVox recordings)
|
||||||
|
- **Audio:** 16kHz WAV, single speaker per file
|
||||||
|
|
||||||
|
## Voice Registry System
|
||||||
|
|
||||||
|
Voice-to-speaker assignments are managed by `voice_registry.json` -- an append-only,
|
||||||
|
idempotent registry that permanently locks each speaker to a voice name.
|
||||||
|
|
||||||
|
### How It Works
|
||||||
|
|
||||||
|
1. **Registry file** (`voice_registry.json`): Committed to git, contains all locked assignments
|
||||||
|
2. **Name pools**: 50 female + 50 male names, assigned in order as new speakers are added
|
||||||
|
3. **Deterministic assignment**: New speakers sorted by ID (ascending), names assigned in pool order
|
||||||
|
4. **Append-only**: Once a speaker is assigned a name, that assignment never changes
|
||||||
|
5. **Multi-corpus**: Registry tracks which corpus each speaker came from
|
||||||
|
|
||||||
|
### Adding a New Corpus
|
||||||
|
|
||||||
|
To add speakers from a new corpus:
|
||||||
|
|
||||||
|
1. Add the corpus config to `voice_registry.json` under `corpora`:
|
||||||
|
```json
|
||||||
|
"corpora": {
|
||||||
|
"librispeech-test-clean": { ... },
|
||||||
|
"librispeech-dev-clean": {
|
||||||
|
"dataset": "openslr/librispeech_asr",
|
||||||
|
"config": "clean",
|
||||||
|
"split": "validation",
|
||||||
|
"description": "LibriSpeech dev-clean, 40 speakers"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Run the download script with the new corpus:
|
||||||
|
```bash
|
||||||
|
python scripts/download_diverse_voices.py --corpora librispeech-test-clean librispeech-dev-clean
|
||||||
|
```
|
||||||
|
|
||||||
|
3. New speakers get the next available names from the pool. Existing assignments are untouched.
|
||||||
|
|
||||||
|
4. Commit the updated `voice_registry.json` to lock the new assignments.
|
||||||
|
|
||||||
|
## Test-Clean Corpus
|
||||||
|
|
||||||
|
The `test-clean` split contains **40 speakers** (~8 minutes each, ~350MB total):
|
||||||
|
|
||||||
|
### Female Speakers (20)
|
||||||
|
|
||||||
|
| ID | LibriVox Name | Minutes |
|
||||||
|
|----|---------------|---------|
|
||||||
|
| 121 | Nikolle Doolin | 8.01 |
|
||||||
|
| 237 | rachelellen | 8.02 |
|
||||||
|
| 367 | Kathleen Dang | 6.74 |
|
||||||
|
| 1221 | Dianne | 8.07 |
|
||||||
|
| 1284 | Daniel Anaya | 8.16 |
|
||||||
|
| 1580 | TinyPines | 8.07 |
|
||||||
|
| 1995 | (unknown) | ~8 |
|
||||||
|
| 2094 | amycsj | 8.09 |
|
||||||
|
| 2961 | Leni | 8.07 |
|
||||||
|
| 3570 | sarac | 8.05 |
|
||||||
|
| 3575 | supergirl | 8.06 |
|
||||||
|
| 3729 | Heather Hogan | 8.03 |
|
||||||
|
| 4446 | Jen Maxwell | 8.00 |
|
||||||
|
| 4507 | Rachel Nelson-Smith | 8.05 |
|
||||||
|
| 4970 | airandwaters | 8.15 |
|
||||||
|
| 4992 | Joyce Martin | 8.21 |
|
||||||
|
| 5142 | Mary Ballard-Johansson | 8.07 |
|
||||||
|
| 5570 | Ulf Bjorklund | 8.28 |
|
||||||
|
| 5683 | Rachael Lapidis | 8.01 |
|
||||||
|
| 6829 | LadyBug | 8.24 |
|
||||||
|
|
||||||
|
### Male Speakers (20)
|
||||||
|
|
||||||
|
| ID | LibriVox Name | Minutes |
|
||||||
|
|----|---------------|---------|
|
||||||
|
| 61 | Paul-Gabriel Wiener | 8.08 |
|
||||||
|
| 260 | Brad Bush | 8.05 |
|
||||||
|
| 672 | Taylor Burton-Edward | 8.27 |
|
||||||
|
| 908 | Sam Stinson | 8.05 |
|
||||||
|
| 1089 | Peter Bobbe | 8.05 |
|
||||||
|
| 1188 | Duncan Murrell | 8.20 |
|
||||||
|
| 1320 | number6 | 8.02 |
|
||||||
|
| 2300 | Mitchell L Leopard | 8.19 |
|
||||||
|
| 2830 | Tim Perkins | 8.04 |
|
||||||
|
| 4077 | Nathan Markham | 8.14 |
|
||||||
|
| 5105 | elongman | 8.12 |
|
||||||
|
| 5639 | (unknown) | ~8 |
|
||||||
|
| 6930 | Nolan Fout | 8.00 |
|
||||||
|
| 7021 | (unknown) | ~8 |
|
||||||
|
| 7127 | (unknown) | ~8 |
|
||||||
|
| 7176 | (unknown) | ~8 |
|
||||||
|
| 7729 | (unknown) | ~8 |
|
||||||
|
| 8230 | (unknown) | ~8 |
|
||||||
|
| 8455 | (unknown) | ~8 |
|
||||||
|
| 8463 | (unknown) | ~8 |
|
||||||
|
|
||||||
|
Note: Names marked (unknown) were not in the SPEAKERS.TXT mirror we fetched.
|
||||||
|
|
||||||
|
## Voice Names
|
||||||
|
|
||||||
|
These are our own names -- LibriSpeech only provides LibriVox usernames (like "supergirl",
|
||||||
|
"LadyBug", "number6"), not character-style voice names.
|
||||||
|
|
||||||
|
Names are assigned from pools in `voice_registry.json` and locked permanently.
|
||||||
|
The first 21 voices (original set) are:
|
||||||
|
|
||||||
|
**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
|
||||||
|
|
||||||
|
When all 40 test-clean speakers are registered, the remaining 19 get the next
|
||||||
|
names from the pools (amber, brooke, cora, ... for female; archer, blake, cole, ... for male).
|
||||||
|
|
||||||
|
## How Voices Are Selected
|
||||||
|
|
||||||
|
The script picks the best sample per speaker based on:
|
||||||
|
- Duration: prefers 5-10 seconds (ideal for voice cloning reference)
|
||||||
|
- Completeness: bonus for sentences ending with a period
|
||||||
|
- Length: penalty for very long text (>300 chars)
|
||||||
|
|
||||||
|
## Gender Verification
|
||||||
|
|
||||||
|
Speaker genders are fetched at runtime from the official LibriSpeech `SPEAKERS.TXT`
|
||||||
|
(via GitHub mirror). This ensures female names always map to female speakers and
|
||||||
|
male names always map to male speakers. No hardcoded gender assumptions.
|
||||||
|
|
||||||
|
Source: https://raw.githubusercontent.com/oscarknagg/voicemap/master/data/LibriSpeech/SPEAKERS.TXT
|
||||||
|
|
||||||
|
## Other LibriSpeech Splits
|
||||||
|
|
||||||
|
Additional splits can be added as new corpora in the registry:
|
||||||
|
|
||||||
|
- **train-clean-100**: ~250 speakers, 100 hours
|
||||||
|
- **train-clean-360**: ~920 speakers, 360 hours
|
||||||
|
- **train-other-500**: ~1160 speakers, 500 hours (noisier)
|
||||||
|
- **test-other**: 33 speakers (noisier conditions)
|
||||||
|
- **dev-clean**: 40 speakers (validation set)
|
||||||
|
- **dev-other**: 33 speakers
|
||||||
|
|
||||||
|
Using larger splits would give hundreds or thousands of distinct voices, but
|
||||||
|
test-clean provides the highest quality recordings.
|
||||||
|
|
||||||
|
## File Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
voice_registry.json # Idempotent voice-to-speaker assignments (committed)
|
||||||
|
cloned-voices/
|
||||||
|
├── aria.wav # Female voice sample (~7s, 16kHz)
|
||||||
|
├── atlas.wav # Male voice sample
|
||||||
|
├── ... # (40 voices when fully expanded)
|
||||||
|
└── voices_metadata.json # Speaker IDs, genders, transcripts, durations
|
||||||
|
```
|
||||||
|
|
||||||
|
The `cloned-voices/` directory is mounted into the Docker container at `/app/cloned-voices/`.
|
||||||
|
Voice config is in `voice_to_speaker.default.yaml`.
|
||||||
|
|
@ -1,14 +1,15 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
Download diverse voice samples for Qwen3-TTS voice cloning.
|
Download diverse voice samples for Qwen3-TTS voice cloning.
|
||||||
Uses HuggingFace datasets for LibriSpeech test-clean with multiple speakers.
|
Uses voice_registry.json for idempotent, permanent speaker-to-name assignments.
|
||||||
Fetches gender from upstream SPEAKERS.TXT (OpenSLR) to ensure correct assignment.
|
Fetches gender from upstream SPEAKERS.TXT (OpenSLR) to ensure correct assignment.
|
||||||
|
|
||||||
Requires: pip install datasets soundfile
|
Requires: pip install datasets soundfile
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
python scripts/download_diverse_voices.py
|
python scripts/download_diverse_voices.py
|
||||||
python scripts/download_diverse_voices.py -o cloned-voices -c voice_to_speaker.default.yaml
|
python scripts/download_diverse_voices.py --registry voice_registry.json
|
||||||
|
python scripts/download_diverse_voices.py --corpora librispeech-test-clean
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
|
@ -25,15 +26,9 @@ except ImportError:
|
||||||
HAS_DATASETS = False
|
HAS_DATASETS = False
|
||||||
print("Install required libraries: pip install datasets soundfile")
|
print("Install required libraries: pip install datasets soundfile")
|
||||||
|
|
||||||
# Upstream SPEAKERS.TXT URL from OpenSLR (canonical gender source)
|
# GitHub mirror of LibriSpeech SPEAKERS.TXT (plain text, easier to parse)
|
||||||
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"
|
SPEAKERS_TXT_GITHUB = "https://raw.githubusercontent.com/oscarknagg/voicemap/master/data/LibriSpeech/SPEAKERS.TXT"
|
||||||
|
|
||||||
# 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"]
|
|
||||||
|
|
||||||
|
|
||||||
def fetch_speaker_genders():
|
def fetch_speaker_genders():
|
||||||
"""Fetch gender info from upstream LibriSpeech SPEAKERS.TXT."""
|
"""Fetch gender info from upstream LibriSpeech SPEAKERS.TXT."""
|
||||||
|
|
@ -66,6 +61,75 @@ def fetch_speaker_genders():
|
||||||
return genders
|
return genders
|
||||||
|
|
||||||
|
|
||||||
|
def load_registry(registry_path):
|
||||||
|
"""Load voice registry from JSON file."""
|
||||||
|
if not registry_path.exists():
|
||||||
|
return None
|
||||||
|
with open(registry_path) as f:
|
||||||
|
return json.load(f)
|
||||||
|
|
||||||
|
|
||||||
|
def save_registry(registry, registry_path):
|
||||||
|
"""Save voice registry to JSON file (append-only, never remove voices)."""
|
||||||
|
with open(registry_path, "w") as f:
|
||||||
|
json.dump(registry, f, indent=2)
|
||||||
|
print(f" Registry saved: {registry_path}")
|
||||||
|
|
||||||
|
|
||||||
|
def get_used_names(registry):
|
||||||
|
"""Get set of names already assigned in the registry."""
|
||||||
|
return set(registry.get("voices", {}).keys())
|
||||||
|
|
||||||
|
|
||||||
|
def get_next_name(registry, gender):
|
||||||
|
"""Get the next available name from the pool for the given gender."""
|
||||||
|
used = get_used_names(registry)
|
||||||
|
pool = registry.get("name_pools", {}).get(gender, [])
|
||||||
|
for name in pool:
|
||||||
|
if name not in used:
|
||||||
|
return name
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def assign_speakers_to_names(registry, corpus_id, speakers_by_gender, speaker_genders):
|
||||||
|
"""Assign names to new speakers deterministically.
|
||||||
|
|
||||||
|
New speakers are sorted by ID (ascending) and assigned names in pool order.
|
||||||
|
Existing assignments are never changed.
|
||||||
|
"""
|
||||||
|
# Build reverse lookup: (corpus, speaker_id) -> name
|
||||||
|
assigned = {}
|
||||||
|
for name, info in registry.get("voices", {}).items():
|
||||||
|
key = (info["corpus"], info["speaker_id"])
|
||||||
|
assigned[key] = name
|
||||||
|
|
||||||
|
new_assignments = []
|
||||||
|
|
||||||
|
for gender in ("female", "male"):
|
||||||
|
# Sort unassigned speakers by ID for determinism
|
||||||
|
speakers = sorted(speakers_by_gender.get(gender, []), key=lambda s: int(s))
|
||||||
|
for sid in speakers:
|
||||||
|
key = (corpus_id, sid)
|
||||||
|
if key in assigned:
|
||||||
|
continue # Already has a name
|
||||||
|
|
||||||
|
name = get_next_name(registry, gender)
|
||||||
|
if name is None:
|
||||||
|
print(f" WARNING: No more {gender} names available, skipping speaker {sid}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
registry["voices"][name] = {
|
||||||
|
"corpus": corpus_id,
|
||||||
|
"speaker_id": sid,
|
||||||
|
"gender": gender,
|
||||||
|
"locked": True,
|
||||||
|
}
|
||||||
|
new_assignments.append((name, sid, gender))
|
||||||
|
print(f" NEW: {name:12s} <- speaker {sid} ({gender})")
|
||||||
|
|
||||||
|
return new_assignments
|
||||||
|
|
||||||
|
|
||||||
def pick_best_sample(samples, min_dur=4.0, max_dur=12.0, target=7.0):
|
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."""
|
"""Pick the best sample: prefer 5-10 seconds, clean, complete sentence."""
|
||||||
best = None
|
best = None
|
||||||
|
|
@ -76,7 +140,7 @@ def pick_best_sample(samples, min_dur=4.0, max_dur=12.0, target=7.0):
|
||||||
dur = len(audio["array"]) / audio["sampling_rate"]
|
dur = len(audio["array"]) / audio["sampling_rate"]
|
||||||
text = s.get("text", "")
|
text = s.get("text", "")
|
||||||
|
|
||||||
# Skip too short or too long
|
# Skip too short
|
||||||
if dur < 3.0:
|
if dur < 3.0:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|
@ -110,6 +174,10 @@ def main():
|
||||||
help="Output path for voice config YAML")
|
help="Output path for voice config YAML")
|
||||||
parser.add_argument("--config-runtime", default="config/voice_to_speaker.yaml",
|
parser.add_argument("--config-runtime", default="config/voice_to_speaker.yaml",
|
||||||
help="Runtime config path (also written if dir exists)")
|
help="Runtime config path (also written if dir exists)")
|
||||||
|
parser.add_argument("--registry", default="voice_registry.json",
|
||||||
|
help="Path to voice registry JSON (default: voice_registry.json)")
|
||||||
|
parser.add_argument("--corpora", nargs="+", default=["librispeech-test-clean"],
|
||||||
|
help="Corpora to download (default: librispeech-test-clean)")
|
||||||
parser.add_argument("--max-samples", type=int, default=20,
|
parser.add_argument("--max-samples", type=int, default=20,
|
||||||
help="Max samples to collect per speaker for selection")
|
help="Max samples to collect per speaker for selection")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
@ -121,6 +189,16 @@ def main():
|
||||||
|
|
||||||
output_dir = Path(args.output_dir)
|
output_dir = Path(args.output_dir)
|
||||||
output_dir.mkdir(parents=True, exist_ok=True)
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
registry_path = Path(args.registry)
|
||||||
|
|
||||||
|
# Load or create registry
|
||||||
|
registry = load_registry(registry_path)
|
||||||
|
if registry is None:
|
||||||
|
print(f"ERROR: Registry not found at {registry_path}")
|
||||||
|
print(" Create it or copy from voice_registry.json")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print(f"Loaded registry: {len(registry.get('voices', {}))} existing voices")
|
||||||
|
|
||||||
# Fetch genders from upstream
|
# Fetch genders from upstream
|
||||||
speaker_genders = fetch_speaker_genders()
|
speaker_genders = fetch_speaker_genders()
|
||||||
|
|
@ -131,14 +209,28 @@ def main():
|
||||||
print(f"\n=== Downloading Diverse Voice Samples for Qwen3-TTS ===\n")
|
print(f"\n=== Downloading Diverse Voice Samples for Qwen3-TTS ===\n")
|
||||||
print(f"Output directory: {output_dir}")
|
print(f"Output directory: {output_dir}")
|
||||||
print(f"Config output: {args.config_output}")
|
print(f"Config output: {args.config_output}")
|
||||||
|
print(f"Registry: {registry_path}")
|
||||||
|
print(f"Corpora: {', '.join(args.corpora)}")
|
||||||
|
|
||||||
# Load LibriSpeech test-clean
|
# Process each corpus
|
||||||
print("Loading LibriSpeech test-clean dataset from HuggingFace...")
|
all_voice_names = set()
|
||||||
|
|
||||||
|
for corpus_id in args.corpora:
|
||||||
|
corpus_config = registry.get("corpora", {}).get(corpus_id)
|
||||||
|
if corpus_config is None:
|
||||||
|
print(f"\nERROR: Unknown corpus '{corpus_id}'. Available: {list(registry.get('corpora', {}).keys())}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
print(f"\n--- Corpus: {corpus_id} ---")
|
||||||
|
print(f" {corpus_config.get('description', '')}")
|
||||||
|
|
||||||
|
# Load dataset
|
||||||
|
print(f" Loading {corpus_config['dataset']} ({corpus_config['config']}/{corpus_config['split']})...")
|
||||||
print(" (First run downloads ~1.5 GB, cached after that)\n")
|
print(" (First run downloads ~1.5 GB, cached after that)\n")
|
||||||
dataset = load_dataset(
|
dataset = load_dataset(
|
||||||
"openslr/librispeech_asr",
|
corpus_config["dataset"],
|
||||||
"clean",
|
corpus_config["config"],
|
||||||
split="test",
|
split=corpus_config["split"],
|
||||||
trust_remote_code=True
|
trust_remote_code=True
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -152,55 +244,46 @@ def main():
|
||||||
if len(speaker_samples[sid]) < args.max_samples:
|
if len(speaker_samples[sid]) < args.max_samples:
|
||||||
speaker_samples[sid].append(sample)
|
speaker_samples[sid].append(sample)
|
||||||
|
|
||||||
print(f"Found {len(speaker_samples)} speakers in test-clean\n")
|
print(f" Found {len(speaker_samples)} speakers\n")
|
||||||
|
|
||||||
# Split speakers by gender using upstream data
|
# Split speakers by gender
|
||||||
female_speakers = []
|
speakers_by_gender = {"female": [], "male": []}
|
||||||
male_speakers = []
|
|
||||||
for sid in speaker_samples:
|
for sid in speaker_samples:
|
||||||
gender = speaker_genders.get(sid)
|
gender = speaker_genders.get(sid)
|
||||||
if gender == "female":
|
if gender in ("female", "male"):
|
||||||
female_speakers.append(sid)
|
speakers_by_gender[gender].append(sid)
|
||||||
elif gender == "male":
|
|
||||||
male_speakers.append(sid)
|
|
||||||
else:
|
else:
|
||||||
print(f" WARNING: No gender for speaker {sid}, skipping")
|
print(f" WARNING: No gender for speaker {sid}, skipping")
|
||||||
|
|
||||||
print(f"Female speakers available: {len(female_speakers)}")
|
print(f" Female speakers: {len(speakers_by_gender['female'])}")
|
||||||
print(f"Male speakers available: {len(male_speakers)}")
|
print(f" Male speakers: {len(speakers_by_gender['male'])}")
|
||||||
|
|
||||||
# Pick the best speakers (most samples, best quality)
|
# Assign names to any new speakers
|
||||||
# Sort by number of samples available (more = better selection)
|
new = assign_speakers_to_names(registry, corpus_id, speakers_by_gender, speaker_genders)
|
||||||
female_speakers.sort(key=lambda s: len(speaker_samples[s]), reverse=True)
|
if new:
|
||||||
male_speakers.sort(key=lambda s: len(speaker_samples[s]), reverse=True)
|
print(f"\n Assigned {len(new)} new voices")
|
||||||
|
save_registry(registry, registry_path)
|
||||||
|
else:
|
||||||
|
print(f"\n No new speakers to assign")
|
||||||
|
|
||||||
# Take top N for each gender
|
# Download samples for all voices in this corpus
|
||||||
female_speakers = female_speakers[:len(FEMALE_NAMES)]
|
corpus_voices = {
|
||||||
male_speakers = male_speakers[:len(MALE_NAMES)]
|
name: info for name, info in registry["voices"].items()
|
||||||
|
if info["corpus"] == corpus_id
|
||||||
|
}
|
||||||
|
|
||||||
if len(female_speakers) < len(FEMALE_NAMES):
|
print(f"\n Downloading {len(corpus_voices)} voice samples...\n")
|
||||||
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
|
for voice_name, voice_info in sorted(corpus_voices.items()):
|
||||||
voice_map = {}
|
sid = voice_info["speaker_id"]
|
||||||
for i, sid in enumerate(female_speakers):
|
if sid not in speaker_samples:
|
||||||
if i < len(FEMALE_NAMES):
|
print(f" WARNING: Speaker {sid} ({voice_name}) not in dataset")
|
||||||
voice_map[FEMALE_NAMES[i]] = sid
|
continue
|
||||||
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")
|
samples = speaker_samples[sid]
|
||||||
|
|
||||||
# Download and save voice samples
|
|
||||||
voices = {}
|
|
||||||
for voice_name, speaker_id in voice_map.items():
|
|
||||||
samples = speaker_samples[speaker_id]
|
|
||||||
best = pick_best_sample(samples)
|
best = pick_best_sample(samples)
|
||||||
if best is None:
|
if best is None:
|
||||||
print(f" WARNING: No suitable sample for '{voice_name}' (speaker {speaker_id})")
|
print(f" WARNING: No suitable sample for '{voice_name}' (speaker {sid})")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
audio = best["audio"]
|
audio = best["audio"]
|
||||||
|
|
@ -211,44 +294,56 @@ def main():
|
||||||
out_path = output_dir / f"{voice_name}.wav"
|
out_path = output_dir / f"{voice_name}.wav"
|
||||||
sf.write(str(out_path), audio["array"], audio["sampling_rate"])
|
sf.write(str(out_path), audio["array"], audio["sampling_rate"])
|
||||||
|
|
||||||
gender = speaker_genders.get(speaker_id, "unknown")
|
voice_info["ref_audio"] = f"cloned-voices/{voice_name}.wav"
|
||||||
|
voice_info["ref_text"] = transcript
|
||||||
|
voice_info["duration"] = round(duration, 1)
|
||||||
|
|
||||||
voices[voice_name] = {
|
all_voice_names.add(voice_name)
|
||||||
"ref_audio": f"cloned-voices/{voice_name}.wav",
|
print(f" {voice_name:12s} | {voice_info['gender']:6s} | speaker {sid:5s} | {duration:.1f}s")
|
||||||
"ref_text": transcript,
|
|
||||||
"speaker_id": speaker_id,
|
|
||||||
"gender": gender,
|
|
||||||
"duration": round(duration, 1),
|
|
||||||
}
|
|
||||||
|
|
||||||
print(f" {voice_name:8s} | {gender:6s} | speaker {speaker_id:5s} | {duration:.1f}s")
|
print(f"\nDownloaded {len(all_voice_names)} voices total\n")
|
||||||
|
|
||||||
print(f"\nDownloaded {len(voices)}/{len(voice_map)} voices\n")
|
# Build voices dict for config generation
|
||||||
|
voices = {}
|
||||||
|
for name in sorted(all_voice_names):
|
||||||
|
info = registry["voices"][name]
|
||||||
|
if "ref_audio" in info:
|
||||||
|
voices[name] = info
|
||||||
|
|
||||||
# Generate YAML config
|
# Generate YAML config
|
||||||
print("Generating voice config...")
|
print("Generating voice config...")
|
||||||
|
|
||||||
female_list = ", ".join(n for n in FEMALE_NAMES if n in voices)
|
female_names = sorted(n for n, v in voices.items() if v["gender"] == "female")
|
||||||
male_list = ", ".join(n for n in MALE_NAMES if n in voices)
|
male_names = sorted(n for n, v in voices.items() if v["gender"] == "male")
|
||||||
|
|
||||||
|
# Order by name pool position for consistent output
|
||||||
|
female_pool = registry["name_pools"]["female"]
|
||||||
|
male_pool = registry["name_pools"]["male"]
|
||||||
|
female_names.sort(key=lambda n: female_pool.index(n) if n in female_pool else 999)
|
||||||
|
male_names.sort(key=lambda n: male_pool.index(n) if n in male_pool else 999)
|
||||||
|
|
||||||
|
female_list = ", ".join(female_names)
|
||||||
|
male_list = ", ".join(male_names)
|
||||||
|
|
||||||
lines = [
|
lines = [
|
||||||
"# uncloseai-speech Voice Configuration",
|
"# uncloseai-speech Voice Configuration",
|
||||||
"# Diverse voice samples from LibriSpeech test-clean (public domain)",
|
"# Diverse voice samples from LibriSpeech test-clean (public domain)",
|
||||||
"# Each voice is a DISTINCT SPEAKER for Qwen3-TTS voice cloning",
|
"# Each voice is a DISTINCT SPEAKER for Qwen3-TTS voice cloning",
|
||||||
"# Gender verified from upstream LibriSpeech SPEAKERS.TXT",
|
"# Gender verified from upstream LibriSpeech SPEAKERS.TXT",
|
||||||
|
"# Assignments locked in voice_registry.json (idempotent, append-only)",
|
||||||
"#",
|
"#",
|
||||||
f"# Female voices: {female_list}",
|
f"# Female voices: {female_list}",
|
||||||
f"# Male voices: {male_list}",
|
f"# Male voices: {male_list}",
|
||||||
"#",
|
"#",
|
||||||
"# Source: LibriSpeech test-clean (public domain, LibriVox recordings)",
|
"# Source: LibriSpeech test-clean (public domain, LibriVox recordings)",
|
||||||
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)",
|
f"# {len(voices)} distinct speakers ({len(female_names)} female, {len(male_names)} male)",
|
||||||
"",
|
"",
|
||||||
"tts-1-qwen:",
|
"tts-1-qwen:",
|
||||||
"",
|
"",
|
||||||
]
|
]
|
||||||
|
|
||||||
# Write female voices first, then male
|
# Write female voices first, then male (in pool order)
|
||||||
for voice_name in FEMALE_NAMES + MALE_NAMES:
|
for voice_name in female_names + male_names:
|
||||||
if voice_name not in voices:
|
if voice_name not in voices:
|
||||||
continue
|
continue
|
||||||
v = voices[voice_name]
|
v = voices[voice_name]
|
||||||
|
|
@ -294,17 +389,29 @@ def main():
|
||||||
print(f" Written: {args.config_runtime}")
|
print(f" Written: {args.config_runtime}")
|
||||||
|
|
||||||
# Save metadata JSON
|
# Save metadata JSON
|
||||||
|
meta = {}
|
||||||
|
for name in female_names + male_names:
|
||||||
|
if name in voices:
|
||||||
|
v = voices[name]
|
||||||
|
meta[name] = {
|
||||||
|
"ref_audio": v["ref_audio"],
|
||||||
|
"ref_text": v["ref_text"],
|
||||||
|
"speaker_id": v["speaker_id"],
|
||||||
|
"gender": v["gender"],
|
||||||
|
"duration": v["duration"],
|
||||||
|
}
|
||||||
|
|
||||||
meta_path = output_dir / "voices_metadata.json"
|
meta_path = output_dir / "voices_metadata.json"
|
||||||
with open(meta_path, "w") as f:
|
with open(meta_path, "w") as f:
|
||||||
json.dump(voices, f, indent=2)
|
json.dump(meta, f, indent=2)
|
||||||
print(f" Written: {meta_path}")
|
print(f" Written: {meta_path}")
|
||||||
|
|
||||||
print(f"\n=== Done! {len(voices)} diverse voices configured ===")
|
print(f"\n=== Done! {len(voices)} diverse voices configured ===")
|
||||||
print(f"\nVoice mapping:")
|
print(f"\nVoice mapping:")
|
||||||
for name in FEMALE_NAMES + MALE_NAMES:
|
for name in female_names + male_names:
|
||||||
if name in voices:
|
if name in voices:
|
||||||
v = voices[name]
|
v = voices[name]
|
||||||
print(f" {name:8s} -> speaker {v['speaker_id']} ({v['gender']})")
|
print(f" {name:12s} -> speaker {v['speaker_id']} ({v['gender']})")
|
||||||
|
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
|
||||||
155
voice_registry.json
Normal file
155
voice_registry.json
Normal file
|
|
@ -0,0 +1,155 @@
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"voices": {
|
||||||
|
"aria": {
|
||||||
|
"corpus": "librispeech-test-clean",
|
||||||
|
"speaker_id": "2094",
|
||||||
|
"gender": "female",
|
||||||
|
"locked": true
|
||||||
|
},
|
||||||
|
"clara": {
|
||||||
|
"corpus": "librispeech-test-clean",
|
||||||
|
"speaker_id": "3575",
|
||||||
|
"gender": "female",
|
||||||
|
"locked": true
|
||||||
|
},
|
||||||
|
"elena": {
|
||||||
|
"corpus": "librispeech-test-clean",
|
||||||
|
"speaker_id": "2961",
|
||||||
|
"gender": "female",
|
||||||
|
"locked": true
|
||||||
|
},
|
||||||
|
"grace": {
|
||||||
|
"corpus": "librispeech-test-clean",
|
||||||
|
"speaker_id": "8463",
|
||||||
|
"gender": "female",
|
||||||
|
"locked": true
|
||||||
|
},
|
||||||
|
"hazel": {
|
||||||
|
"corpus": "librispeech-test-clean",
|
||||||
|
"speaker_id": "1995",
|
||||||
|
"gender": "female",
|
||||||
|
"locked": true
|
||||||
|
},
|
||||||
|
"iris": {
|
||||||
|
"corpus": "librispeech-test-clean",
|
||||||
|
"speaker_id": "1284",
|
||||||
|
"gender": "female",
|
||||||
|
"locked": true
|
||||||
|
},
|
||||||
|
"luna": {
|
||||||
|
"corpus": "librispeech-test-clean",
|
||||||
|
"speaker_id": "5142",
|
||||||
|
"gender": "female",
|
||||||
|
"locked": true
|
||||||
|
},
|
||||||
|
"maya": {
|
||||||
|
"corpus": "librispeech-test-clean",
|
||||||
|
"speaker_id": "4446",
|
||||||
|
"gender": "female",
|
||||||
|
"locked": true
|
||||||
|
},
|
||||||
|
"ruby": {
|
||||||
|
"corpus": "librispeech-test-clean",
|
||||||
|
"speaker_id": "1221",
|
||||||
|
"gender": "female",
|
||||||
|
"locked": true
|
||||||
|
},
|
||||||
|
"sage": {
|
||||||
|
"corpus": "librispeech-test-clean",
|
||||||
|
"speaker_id": "4507",
|
||||||
|
"gender": "female",
|
||||||
|
"locked": true
|
||||||
|
},
|
||||||
|
"sofia": {
|
||||||
|
"corpus": "librispeech-test-clean",
|
||||||
|
"speaker_id": "3729",
|
||||||
|
"gender": "female",
|
||||||
|
"locked": true
|
||||||
|
},
|
||||||
|
"atlas": {
|
||||||
|
"corpus": "librispeech-test-clean",
|
||||||
|
"speaker_id": "6930",
|
||||||
|
"gender": "male",
|
||||||
|
"locked": true
|
||||||
|
},
|
||||||
|
"caleb": {
|
||||||
|
"corpus": "librispeech-test-clean",
|
||||||
|
"speaker_id": "1320",
|
||||||
|
"gender": "male",
|
||||||
|
"locked": true
|
||||||
|
},
|
||||||
|
"felix": {
|
||||||
|
"corpus": "librispeech-test-clean",
|
||||||
|
"speaker_id": "5639",
|
||||||
|
"gender": "male",
|
||||||
|
"locked": true
|
||||||
|
},
|
||||||
|
"hugo": {
|
||||||
|
"corpus": "librispeech-test-clean",
|
||||||
|
"speaker_id": "260",
|
||||||
|
"gender": "male",
|
||||||
|
"locked": true
|
||||||
|
},
|
||||||
|
"jasper": {
|
||||||
|
"corpus": "librispeech-test-clean",
|
||||||
|
"speaker_id": "7729",
|
||||||
|
"gender": "male",
|
||||||
|
"locked": true
|
||||||
|
},
|
||||||
|
"kai": {
|
||||||
|
"corpus": "librispeech-test-clean",
|
||||||
|
"speaker_id": "7127",
|
||||||
|
"gender": "male",
|
||||||
|
"locked": true
|
||||||
|
},
|
||||||
|
"leo": {
|
||||||
|
"corpus": "librispeech-test-clean",
|
||||||
|
"speaker_id": "8230",
|
||||||
|
"gender": "male",
|
||||||
|
"locked": true
|
||||||
|
},
|
||||||
|
"marcus": {
|
||||||
|
"corpus": "librispeech-test-clean",
|
||||||
|
"speaker_id": "7176",
|
||||||
|
"gender": "male",
|
||||||
|
"locked": true
|
||||||
|
},
|
||||||
|
"owen": {
|
||||||
|
"corpus": "librispeech-test-clean",
|
||||||
|
"speaker_id": "8455",
|
||||||
|
"gender": "male",
|
||||||
|
"locked": true
|
||||||
|
},
|
||||||
|
"theo": {
|
||||||
|
"corpus": "librispeech-test-clean",
|
||||||
|
"speaker_id": "2830",
|
||||||
|
"gender": "male",
|
||||||
|
"locked": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"name_pools": {
|
||||||
|
"female": [
|
||||||
|
"aria", "clara", "elena", "grace", "hazel", "iris", "luna", "maya", "ruby", "sage", "sofia",
|
||||||
|
"amber", "brooke", "cora", "diana", "eden", "faye", "gemma", "hope", "ivy", "jade",
|
||||||
|
"kira", "lena", "mila", "nadia", "olive", "pearl", "quinn", "rhea", "stella", "tessa",
|
||||||
|
"una", "vera", "wren", "xena", "yara", "zara", "adele", "blythe", "celeste", "daphne",
|
||||||
|
"elise", "flora", "greta", "hana", "isla", "june", "kaia", "lila", "maren", "nell"
|
||||||
|
],
|
||||||
|
"male": [
|
||||||
|
"atlas", "caleb", "felix", "hugo", "jasper", "kai", "leo", "marcus", "owen", "theo",
|
||||||
|
"archer", "blake", "cole", "dane", "ezra", "finn", "grant", "heath", "ivan", "jude",
|
||||||
|
"knox", "lance", "miles", "nash", "orion", "pierce", "reed", "seth", "trent", "wade",
|
||||||
|
"xander", "york", "zane", "anton", "brock", "cyrus", "drake", "ellis", "fox", "grey",
|
||||||
|
"holt", "ira", "joel", "keane", "lars", "milo", "noel", "otto", "pascal", "remy"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"corpora": {
|
||||||
|
"librispeech-test-clean": {
|
||||||
|
"dataset": "openslr/librispeech_asr",
|
||||||
|
"config": "clean",
|
||||||
|
"split": "test",
|
||||||
|
"description": "LibriSpeech test-clean, 40 speakers, public domain"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue