feat: add /voice-seed API endpoint and update find_best_seed.py (v6.7.1)

POST /voice-seed — writes or clears the seed for a named voice in
voices.json directly from the server, enabling the voice creator GUI
to save the chosen seed with one click.

Also updates find_best_seed.py to support --all-voices, per-voice
subdirectories, and the mixed DE/EN test sentence.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-06-21 14:53:31 +02:00
parent 70fd8140e0
commit 377b9f16e5
2 changed files with 168 additions and 71 deletions

View File

@ -1,24 +1,24 @@
#!/usr/bin/env python3
"""
Find the best RNG seed for a voice by generating audio samples with different
seeds and saving them as numbered WAV files for comparison.
Find the best RNG seed for one or all voices by generating audio samples with
different seeds and saving them as numbered WAV files for comparison.
How it works:
1. For each seed, temporarily writes that seed into voices.json
1. For each voice × seed, temporarily sets that seed in voices.json
2. Calls the running server (hot-reload picks it up automatically)
3. Saves the audio as seed_0001.wav, seed_0042.wav, etc.
3. Saves audio as <out-dir>/<voice-name>/seed_<N>.wav
4. Restores voices.json to its original state when done
Then just listen to the WAV files and pick the seed number you prefer.
Add it to your voice in voices.json: "seed": <number>
Then listen to the files and pick the seed you prefer.
Add it to voices.json: "seed": <number>
Usage:
python find_best_seed.py --voice EN_F_NatashaNeural --seeds 1 2 3 42 100
python find_best_seed.py --voice EN_F_NatashaNeural --range 1 20
python find_best_seed.py --voice EN_F_NatashaNeural --range 1 50 --text "Hello!"
# All voices, seeds 115:
python find_best_seed.py --all-voices --range 1 15 --port 8020
# Different server port:
python find_best_seed.py --voice DE_5_28 --range 1 10 --port 8020
# Single voice:
python find_best_seed.py --voice EN_F_NatashaNeural --range 1 20
python find_best_seed.py --voice DE_5_28 --seeds 1 7 42 100
"""
import argparse
import json
@ -30,10 +30,17 @@ import time
import requests
VOICES_JSON = "/config/voices.json"
DEFAULT_TEXT = (
"The morning light filtered softly through the curtains, casting a warm glow "
"across the room. Outside, birds were already singing."
DE_TEXT = (
"Die 3.500 neuen High-End Geräte für das Server-Update benötigen eine "
"außergewöhnlich starke Kühlung und regelmäßige Maßnahmen, um die Performance "
"bei großer Last zu gewährleisten."
)
EN_TEXT = (
"The system administrator successfully configured the customized Docker stacks "
"and benchmarked the inference engines at exactly 8:45 AM."
)
MIXED_TEXT = DE_TEXT + " - " + EN_TEXT
def load_voices() -> dict:
@ -46,7 +53,7 @@ def save_voices(voices: dict) -> None:
json.dump(voices, f, indent=2, ensure_ascii=False)
def generate(voice: str, text: str, host: str, port: int, timeout: int = 60) -> bytes:
def generate(voice: str, text: str, host: str, port: int, timeout: int = 90) -> bytes:
url = f"http://{host}:{port}/v1/audio/speech"
resp = requests.post(
url,
@ -58,86 +65,133 @@ def generate(voice: str, text: str, host: str, port: int, timeout: int = 60) ->
return resp.content
def main():
p = argparse.ArgumentParser(description="Compare seeds for a voice")
p.add_argument("--voice", required=True, help="Voice name (must exist in voices.json)")
p.add_argument("--seeds", type=int, nargs="+", help="Explicit list of seeds to try")
p.add_argument("--range", type=int, nargs=2, metavar=("START", "END"),
help="Try seeds START through END (inclusive)")
p.add_argument("--text", default=DEFAULT_TEXT, help="Text to synthesize")
p.add_argument("--host", default="localhost")
p.add_argument("--port", type=int, default=8020)
p.add_argument("--out-dir", default="./seed_samples", help="Directory to save WAV files")
args = p.parse_args()
def pick_text(voice_name: str, override: str | None) -> str:
if override:
return override
name_lower = voice_name.lower()
if name_lower.startswith("de_"):
return MIXED_TEXT
if name_lower.startswith("en_") or name_lower.startswith("gb_"):
return EN_TEXT
return MIXED_TEXT
if not args.seeds and not args.range:
print("ERROR: provide --seeds or --range", file=sys.stderr)
sys.exit(1)
seeds = list(args.seeds or [])
if args.range:
seeds += list(range(args.range[0], args.range[1] + 1))
seeds = sorted(set(seeds))
os.makedirs(args.out_dir, exist_ok=True)
# Back up voices.json
backup = VOICES_JSON + ".seed_backup"
shutil.copy2(VOICES_JSON, backup)
print(f"Backed up voices.json → {backup}")
voices = load_voices()
if args.voice not in voices:
print(f"ERROR: voice {args.voice!r} not found in voices.json", file=sys.stderr)
print(f"Available: {', '.join(voices.keys())}", file=sys.stderr)
sys.exit(1)
original_seed = voices[args.voice].get("seed", "<none>")
print(f"\nVoice: {args.voice}")
print(f"Text: {args.text[:80]}{'...' if len(args.text) > 80 else ''}")
print(f"Seeds: {seeds}")
print(f"Output: {os.path.abspath(args.out_dir)}/")
print(f"Original seed: {original_seed}\n")
def run_voice(voice_name: str, voices: dict, seeds: list[int],
text: str, host: str, port: int, out_dir: str) -> list[tuple]:
voice_dir = os.path.join(out_dir, voice_name)
os.makedirs(voice_dir, exist_ok=True)
results = []
try:
for seed in seeds:
voices[args.voice]["seed"] = seed
voices[voice_name]["seed"] = seed
save_voices(voices)
# Brief pause so the server's hot-reload detects the mtime change
time.sleep(0.3)
time.sleep(0.3) # let hot-reload detect mtime change
out_path = os.path.join(args.out_dir, f"seed_{seed:05d}.wav")
out_path = os.path.join(voice_dir, f"seed_{seed:05d}.wav")
print(f" seed {seed:5d}", end="", flush=True)
try:
t0 = time.time()
wav = generate(args.voice, args.text, args.host, args.port)
wav = generate(voice_name, text, host, port)
elapsed = time.time() - t0
with open(out_path, "wb") as f:
f.write(wav)
print(f"{out_path} ({elapsed:.1f}s)")
print(f"OK ({elapsed:.1f}s)")
results.append((seed, out_path, None))
except Exception as e:
print(f"FAILED: {e}")
results.append((seed, None, str(e)))
# Remove the temporary seed so the voice returns to its auto-seed
voices[voice_name].pop("seed", None)
save_voices(voices)
return results
def main():
p = argparse.ArgumentParser(description="Compare seeds for one or all voices")
target = p.add_mutually_exclusive_group(required=True)
target.add_argument("--voice", help="Single voice name")
target.add_argument("--all-voices", action="store_true", help="Run for every voice in voices.json")
p.add_argument("--seeds", type=int, nargs="+", help="Explicit list of seeds to try")
p.add_argument("--range", type=int, nargs=2, metavar=("START", "END"),
help="Try seeds START through END (inclusive). Default: 115")
p.add_argument("--text", default=None,
help="Override text (default: auto-picks DE/EN/mixed based on voice name)")
p.add_argument("--host", default="localhost")
p.add_argument("--port", type=int, default=8020)
p.add_argument("--out-dir", default="./seed_samples",
help="Root output directory (voice subdirs created inside)")
args = p.parse_args()
seeds = list(args.seeds or [])
if args.range:
seeds += list(range(args.range[0], args.range[1] + 1))
if not seeds:
seeds = list(range(1, 16)) # default 115
seeds = sorted(set(seeds))
voices = load_voices()
if args.voice:
voice_names = [args.voice]
if args.voice not in voices:
print(f"ERROR: voice {args.voice!r} not in voices.json", file=sys.stderr)
sys.exit(1)
else:
voice_names = list(voices.keys())
os.makedirs(args.out_dir, exist_ok=True)
# Single backup at the start; we restore on every error/exit
backup = VOICES_JSON + ".seed_backup"
shutil.copy2(VOICES_JSON, backup)
print(f"voices : {len(voice_names)}")
print(f"seeds : {seeds}")
print(f"output : {os.path.abspath(args.out_dir)}/")
print(f"total : ~{len(voice_names) * len(seeds)} requests\n")
summary: dict[str, list] = {}
try:
for i, voice_name in enumerate(voice_names, 1):
text = pick_text(voice_name, args.text)
print(f"[{i}/{len(voice_names)}] {voice_name}")
print(f" text: {text[:90]}{'...' if len(text) > 90 else ''}")
results = run_voice(voice_name, voices, seeds, text, args.host, args.port, args.out_dir)
summary[voice_name] = results
ok = sum(1 for _, p, _ in results if p)
print(f"{ok}/{len(seeds)} OK\n")
except KeyboardInterrupt:
print("\n\nInterrupted — restoring voices.json...")
finally:
# Always restore the original voices.json
shutil.copy2(backup, VOICES_JSON)
os.remove(backup)
print(f"\nRestored voices.json (seed reset to {original_seed!r})")
print("voices.json restored.")
print(f"\n{''*60}")
print("Done! Listen to the files and find the seed you prefer.")
print(f"Then add it to voices.json under {args.voice!r}:")
print(f' "seed": <your_chosen_number>')
print(f"{''*60}")
ok = [(s, p) for s, p, e in results if p]
if ok:
print(f"\nGenerated {len(ok)} samples:")
for seed, path in ok:
print(f" seed {seed:5d}{path}")
# Write summary
summary_path = os.path.join(args.out_dir, "summary.txt")
with open(summary_path, "w") as f:
f.write(f"Seed samples — {len(voice_names)} voices, seeds {seeds}\n")
f.write("=" * 60 + "\n\n")
for voice_name, results in summary.items():
ok = [(s, path) for s, path, err in results if path]
failed = [(s, err) for s, path, err in results if not path]
f.write(f"{voice_name}:\n")
for s, path in ok:
f.write(f" seed {s:5d} {path}\n")
for s, err in failed:
f.write(f" seed {s:5d} FAILED: {err}\n")
f.write("\n")
total_ok = sum(1 for r in summary.values() for _, p, _ in r if p)
total = sum(len(r) for r in summary.values())
print(f"\nDone: {total_ok}/{total} samples generated.")
print(f"Summary written to {summary_path}")
print(f"\nListen to the WAV files, then add \"seed\": <number> to your chosen voices in voices.json")
if __name__ == "__main__":

View File

@ -19,9 +19,10 @@ import logging
import sys
import os
import json
import threading
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import JSONResponse
# Point Python to the app directory inside the container
@ -132,5 +133,47 @@ async def get_speakers():
async def options_handler(path: str):
return JSONResponse(content={'status': 'ok'})
_voices_lock = threading.Lock()
@openai_server.app.post('/voice-seed')
async def set_voice_seed(request: Request):
"""Set (or clear) the seed for a voice in voices.json.
Body: {"voice": "EN_F_NatashaNeural", "seed": 7}
To remove a seed: {"voice": "EN_F_NatashaNeural", "seed": null}
"""
data = await request.json()
voice_name = data.get("voice")
seed = data.get("seed")
if not voice_name:
raise HTTPException(status_code=400, detail="'voice' field is required")
voices_path = '/config/voices.json'
with _voices_lock:
try:
with open(voices_path, 'r', encoding='utf-8') as f:
voices = json.load(f)
except FileNotFoundError:
raise HTTPException(status_code=404, detail="voices.json not found")
if voice_name not in voices:
raise HTTPException(status_code=404, detail=f"Voice {voice_name!r} not found")
if seed is None:
voices[voice_name].pop("seed", None)
else:
try:
voices[voice_name]["seed"] = int(seed)
except (TypeError, ValueError):
raise HTTPException(status_code=400, detail="'seed' must be an integer or null")
with open(voices_path, 'w', encoding='utf-8') as f:
json.dump(voices, f, indent=2, ensure_ascii=False)
return JSONResponse({"ok": True, "voice": voice_name, "seed": seed})
if __name__ == '__main__':
openai_server.main()