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:
parent
70fd8140e0
commit
377b9f16e5
@ -1,24 +1,24 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
Find the best RNG seed for a voice by generating audio samples with different
|
Find the best RNG seed for one or all voices by generating audio samples with
|
||||||
seeds and saving them as numbered WAV files for comparison.
|
different seeds and saving them as numbered WAV files for comparison.
|
||||||
|
|
||||||
How it works:
|
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)
|
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
|
4. Restores voices.json to its original state when done
|
||||||
|
|
||||||
Then just listen to the WAV files and pick the seed number you prefer.
|
Then listen to the files and pick the seed you prefer.
|
||||||
Add it to your voice in voices.json: "seed": <number>
|
Add it to voices.json: "seed": <number>
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
python find_best_seed.py --voice EN_F_NatashaNeural --seeds 1 2 3 42 100
|
# All voices, seeds 1–15:
|
||||||
python find_best_seed.py --voice EN_F_NatashaNeural --range 1 20
|
python find_best_seed.py --all-voices --range 1 15 --port 8020
|
||||||
python find_best_seed.py --voice EN_F_NatashaNeural --range 1 50 --text "Hello!"
|
|
||||||
|
|
||||||
# Different server port:
|
# Single voice:
|
||||||
python find_best_seed.py --voice DE_5_28 --range 1 10 --port 8020
|
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 argparse
|
||||||
import json
|
import json
|
||||||
@ -30,10 +30,17 @@ import time
|
|||||||
import requests
|
import requests
|
||||||
|
|
||||||
VOICES_JSON = "/config/voices.json"
|
VOICES_JSON = "/config/voices.json"
|
||||||
DEFAULT_TEXT = (
|
|
||||||
"The morning light filtered softly through the curtains, casting a warm glow "
|
DE_TEXT = (
|
||||||
"across the room. Outside, birds were already singing."
|
"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:
|
def load_voices() -> dict:
|
||||||
@ -46,7 +53,7 @@ def save_voices(voices: dict) -> None:
|
|||||||
json.dump(voices, f, indent=2, ensure_ascii=False)
|
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"
|
url = f"http://{host}:{port}/v1/audio/speech"
|
||||||
resp = requests.post(
|
resp = requests.post(
|
||||||
url,
|
url,
|
||||||
@ -58,86 +65,133 @@ def generate(voice: str, text: str, host: str, port: int, timeout: int = 60) ->
|
|||||||
return resp.content
|
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():
|
def main():
|
||||||
p = argparse.ArgumentParser(description="Compare seeds for a voice")
|
p = argparse.ArgumentParser(description="Compare seeds for one or all voices")
|
||||||
p.add_argument("--voice", required=True, help="Voice name (must exist in voices.json)")
|
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("--seeds", type=int, nargs="+", help="Explicit list of seeds to try")
|
||||||
p.add_argument("--range", type=int, nargs=2, metavar=("START", "END"),
|
p.add_argument("--range", type=int, nargs=2, metavar=("START", "END"),
|
||||||
help="Try seeds START through END (inclusive)")
|
help="Try seeds START through END (inclusive). Default: 1–15")
|
||||||
p.add_argument("--text", default=DEFAULT_TEXT, help="Text to synthesize")
|
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("--host", default="localhost")
|
||||||
p.add_argument("--port", type=int, default=8020)
|
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()
|
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 [])
|
seeds = list(args.seeds or [])
|
||||||
if args.range:
|
if args.range:
|
||||||
seeds += list(range(args.range[0], args.range[1] + 1))
|
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))
|
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)
|
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"
|
backup = VOICES_JSON + ".seed_backup"
|
||||||
shutil.copy2(VOICES_JSON, backup)
|
shutil.copy2(VOICES_JSON, backup)
|
||||||
print(f"Backed up voices.json → {backup}")
|
|
||||||
|
|
||||||
voices = load_voices()
|
print(f"voices : {len(voice_names)}")
|
||||||
if args.voice not in voices:
|
print(f"seeds : {seeds}")
|
||||||
print(f"ERROR: voice {args.voice!r} not found in voices.json", file=sys.stderr)
|
print(f"output : {os.path.abspath(args.out_dir)}/")
|
||||||
print(f"Available: {', '.join(voices.keys())}", file=sys.stderr)
|
print(f"total : ~{len(voice_names) * len(seeds)} requests\n")
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
original_seed = voices[args.voice].get("seed", "<none>")
|
summary: dict[str, list] = {}
|
||||||
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 = []
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
for seed in seeds:
|
for i, voice_name in enumerate(voice_names, 1):
|
||||||
voices[args.voice]["seed"] = seed
|
text = pick_text(voice_name, args.text)
|
||||||
save_voices(voices)
|
print(f"[{i}/{len(voice_names)}] {voice_name}")
|
||||||
# Brief pause so the server's hot-reload detects the mtime change
|
print(f" text: {text[:90]}{'...' if len(text) > 90 else ''}")
|
||||||
time.sleep(0.3)
|
results = run_voice(voice_name, voices, seeds, text, args.host, args.port, args.out_dir)
|
||||||
|
summary[voice_name] = results
|
||||||
out_path = os.path.join(args.out_dir, f"seed_{seed:05d}.wav")
|
ok = sum(1 for _, p, _ in results if p)
|
||||||
print(f" seed {seed:5d} → ", end="", flush=True)
|
print(f" → {ok}/{len(seeds)} OK\n")
|
||||||
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)))
|
|
||||||
|
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\n\nInterrupted — restoring voices.json...")
|
||||||
finally:
|
finally:
|
||||||
# Always restore the original voices.json
|
|
||||||
shutil.copy2(backup, VOICES_JSON)
|
shutil.copy2(backup, VOICES_JSON)
|
||||||
os.remove(backup)
|
os.remove(backup)
|
||||||
print(f"\nRestored voices.json (seed reset to {original_seed!r})")
|
print("voices.json restored.")
|
||||||
|
|
||||||
print(f"\n{'─'*60}")
|
# Write summary
|
||||||
print("Done! Listen to the files and find the seed you prefer.")
|
summary_path = os.path.join(args.out_dir, "summary.txt")
|
||||||
print(f"Then add it to voices.json under {args.voice!r}:")
|
with open(summary_path, "w") as f:
|
||||||
print(f' "seed": <your_chosen_number>')
|
f.write(f"Seed samples — {len(voice_names)} voices, seeds {seeds}\n")
|
||||||
print(f"{'─'*60}")
|
f.write("=" * 60 + "\n\n")
|
||||||
ok = [(s, p) for s, p, e in results if p]
|
for voice_name, results in summary.items():
|
||||||
if ok:
|
ok = [(s, path) for s, path, err in results if path]
|
||||||
print(f"\nGenerated {len(ok)} samples:")
|
failed = [(s, err) for s, path, err in results if not path]
|
||||||
for seed, path in ok:
|
f.write(f"{voice_name}:\n")
|
||||||
print(f" seed {seed:5d} → {path}")
|
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__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@ -19,9 +19,10 @@ import logging
|
|||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
|
import threading
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI, HTTPException, Request
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
# Point Python to the app directory inside the container
|
# Point Python to the app directory inside the container
|
||||||
@ -132,5 +133,47 @@ async def get_speakers():
|
|||||||
async def options_handler(path: str):
|
async def options_handler(path: str):
|
||||||
return JSONResponse(content={'status': 'ok'})
|
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__':
|
if __name__ == '__main__':
|
||||||
openai_server.main()
|
openai_server.main()
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user