diff --git a/.gitignore b/.gitignore index 216653a..36fc9c5 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,9 @@ memory.bak/ .use_openrouter .gstack/ +# jsonlock sidecar files (src/jsonlock.py) — empty flock handles, never data +*.lock + # Runtime state — auto-modified by dashboard/cron/heartbeat approved-tasks.json dashboard/status.json diff --git a/approved-tasks.json.lock b/approved-tasks.json.lock deleted file mode 100644 index e69de29..0000000 diff --git a/tools/pocket_tts_add_voice.py b/tools/pocket_tts_add_voice.py new file mode 100755 index 0000000..eeceddd --- /dev/null +++ b/tools/pocket_tts_add_voice.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 +"""Export a pocket-tts voice state from a WAV sample into tts_voices.json. + +Must run with <.venv-pockettts>/bin/python (torch + pocket-tts installed +there), never with the main .venv: + + .venv-pockettts/bin/python tools/pocket_tts_add_voice.py \\ + --wav /path/to/sample.wav --name "Marius" + +Determines the next free index for --name by scanning tts_voices.json (an +entry counts as free if it's absent, or present but its state_path file +doesn't exist yet on disk), exports the voice state to +models/voices/.safetensors, and registers it in tts_voices.json via +src.jsonlock. +""" +import argparse +import re +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +CATALOG_PATH = REPO_ROOT / "tts_voices.json" +VOICES_DIR = REPO_ROOT / "models" / "voices" + +sys.path.insert(0, str(REPO_ROOT)) + +from src.credential_store import get_secret # noqa: E402 +from src.jsonlock import read_locked, write_locked # noqa: E402 + + +def _slugify(name: str) -> str: + return re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-") + + +def _next_free_index(catalog: dict, name: str) -> int: + n = 1 + while True: + entry = catalog.get(f"{name} {n}") + if entry is None: + return n + state_path = entry.get("state_path") + if state_path and not (REPO_ROOT / state_path).exists(): + return n + n += 1 + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--wav", required=True, help="Path to source WAV sample") + parser.add_argument("--name", required=True, help="Base voice name, e.g. 'Marius'") + args = parser.parse_args() + + wav_path = Path(args.wav).expanduser().resolve() + if not wav_path.exists(): + print(f"[pocket_tts_add_voice] ERROR: wav not found: {wav_path}", file=sys.stderr) + return 1 + + import os + + hf_token = get_secret("hf_token") + if not hf_token: + print( + "[pocket_tts_add_voice] ERROR: hf_token lipsește din keyring (service echo-core).", + file=sys.stderr, + ) + return 1 + os.environ["HF_TOKEN"] = hf_token + + from pocket_tts import TTSModel, export_model_state + + catalog = read_locked(str(CATALOG_PATH)) + index = _next_free_index(catalog, args.name) + full_name = f"{args.name} {index}" + slug = _slugify(full_name) + VOICES_DIR.mkdir(parents=True, exist_ok=True) + export_path = VOICES_DIR / f"{slug}.safetensors" + + print("[pocket_tts_add_voice] Loading pocket-tts model...") + model = TTSModel.load_model() + print(f"[pocket_tts_add_voice] Exporting voice state from {wav_path} -> {export_path}") + state = model.get_state_for_audio_prompt(audio_conditioning=str(wav_path), truncate=True) + export_model_state(state, str(export_path)) + + def _register(data: dict) -> dict: + data[full_name] = { + "engine": "pockettts", + "state_path": f"models/voices/{slug}.safetensors", + "owner": args.name, + } + return data + + write_locked(str(CATALOG_PATH), _register) + print(f"[pocket_tts_add_voice] Registered voice '{full_name}' -> {export_path}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tts_voices.json b/tts_voices.json new file mode 100644 index 0000000..12c259b --- /dev/null +++ b/tts_voices.json @@ -0,0 +1,66 @@ +{ + "M1": { + "engine": "supertonic" + }, + "M2": { + "engine": "supertonic" + }, + "M3": { + "engine": "supertonic" + }, + "M4": { + "engine": "supertonic" + }, + "M5": { + "engine": "supertonic" + }, + "F1": { + "engine": "supertonic" + }, + "F2": { + "engine": "supertonic" + }, + "F3": { + "engine": "supertonic" + }, + "F4": { + "engine": "supertonic" + }, + "F5": { + "engine": "supertonic" + }, + "Marius 1": { + "engine": "pockettts", + "state_path": "models/voices/marius-1.safetensors", + "owner": "Marius" + }, + "Marius 2": { + "engine": "pockettts", + "state_path": "models/voices/marius-2.safetensors", + "owner": "Marius" + }, + "Marius 3": { + "engine": "pockettts", + "state_path": "models/voices/marius-3.safetensors", + "owner": "Marius" + }, + "Paula 1": { + "engine": "pockettts", + "state_path": "models/voices/paula-1.safetensors", + "owner": "Paula" + }, + "Paula 2": { + "engine": "pockettts", + "state_path": "models/voices/paula-2.safetensors", + "owner": "Paula" + }, + "Paula 3": { + "engine": "pockettts", + "state_path": "models/voices/paula-3.safetensors", + "owner": "Paula" + }, + "alba": { + "engine": "pockettts", + "voice_url": "alba" + } +} \ No newline at end of file