From 377b9f16e557ad96ba340725acc2f3b7fbbc9a61 Mon Sep 17 00:00:00 2001 From: mARTin-B78 Date: Sun, 21 Jun 2026 14:53:31 +0200 Subject: [PATCH] feat: add /voice-seed API endpoint and update find_best_seed.py (v6.7.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- config/find_best_seed.py | 194 +++++++++++++++++++++++++-------------- config/run_server.py | 45 ++++++++- 2 files changed, 168 insertions(+), 71 deletions(-) diff --git a/config/find_best_seed.py b/config/find_best_seed.py index 9973e51..baf4b20 100644 --- a/config/find_best_seed.py +++ b/config/find_best_seed.py @@ -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 //seed_.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": +Then listen to the files and pick the seed you prefer. +Add it to voices.json: "seed": 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 1–15: + 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 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 + + +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 = [] + for seed in seeds: + voices[voice_name]["seed"] = seed + save_voices(voices) + time.sleep(0.3) # let hot-reload detect mtime change + + 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(voice_name, text, host, port) + elapsed = time.time() - t0 + with open(out_path, "wb") as f: + f.write(wav) + 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 a voice") - p.add_argument("--voice", required=True, help="Voice name (must exist in voices.json)") + 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)") - p.add_argument("--text", default=DEFAULT_TEXT, help="Text to synthesize") + help="Try seeds START through END (inclusive). Default: 1–15") + 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="Directory to save WAV files") + p.add_argument("--out-dir", default="./seed_samples", + help="Root output directory (voice subdirs created inside)") args = p.parse_args() - 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)) + if not seeds: + seeds = list(range(1, 16)) # default 1–15 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) - # Back up voices.json + # Single backup at the start; we restore on every error/exit 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) + 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") - original_seed = voices[args.voice].get("seed", "") - 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") - - results = [] + summary: dict[str, list] = {} try: - for seed in seeds: - voices[args.voice]["seed"] = seed - save_voices(voices) - # Brief pause so the server's hot-reload detects the mtime change - time.sleep(0.3) - - out_path = os.path.join(args.out_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) - elapsed = time.time() - t0 - with open(out_path, "wb") as f: - f.write(wav) - print(f"{out_path} ({elapsed:.1f}s)") - results.append((seed, out_path, None)) - except Exception as e: - print(f"FAILED: {e}") - results.append((seed, None, str(e))) + 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": ') - 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\": to your chosen voices in voices.json") if __name__ == "__main__": diff --git a/config/run_server.py b/config/run_server.py index c7ccb87..984fa9e 100644 --- a/config/run_server.py +++ b/config/run_server.py @@ -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()