""" generate Maven TTS training transcripts via the inference router, stress-marked. per mood, asks the router (OpenAI-compatible, inference.kvmx.ru) for short first-person Maven utterances, dedups + filters (RU, length, no mixed-script), then stress-marks with ruaccent (which also resolves homographs in-context) → canonical "+VOWEL" form. writes data/-voice-dataset-list.txt — exactly what generate_synthetic_voice.py consumes. configure: export ROUTER_API_KEY="..." export ROUTER_BASE_URL="https://inference.kvmx.ru/v1" # default export ROUTER_MODELS="model-a,model-b,model-c" # rotated for diversity run: python piper/gen_transcripts.py --per-mood 1000 python piper/gen_transcripts.py --check # self-check only, no network requires: pip install openai ruaccent note: ruaccent's omograph model resolves stress homographs in-context, so the separate homograph_processor.py step is optional here — kept only if you'd rather REWRITE homographs away than resolve their stress. (plan §2, ponytail: one pass.) """ import os import re import sys import json import time import random import argparse import itertools from pathlib import Path DATA_DIR = Path("tts/data") VOWELS = set("аеёиоуыэюяАЕЁИОУЫЭЮЯ") CYR = re.compile(r"[а-яё]", re.I) MIXED_WORD = re.compile(r"[а-яё]+[a-z]|[a-z]+[а-яё]", re.I) # homoglyph-contaminated word PERSONA = ( "Ты Мейвен — живая, быстрая и любопытная. Лёгкая дерзость допустима, но без " "кринжа и показной экспрессии. Говоришь прямо, коротко и по делу. 1–2 предложения. " "Только русский, без смешивания языков, без markdown и списков." ) # mood → register instruction for the generator MOODS = { "neutral": "обычные, спокойные реплики по делу", "happy": "позитивные, лёгкие, с искренним интересом", "thinking": "рассуждения вслух, объяснения, мысль на ходу", "confused": "уточняющие вопросы, лёгкое непонимание запроса", "tired": "когда не знаешь или не можешь ответить — сухо, с низкой энергией", } PROMPT = ( "Сгенерируй {n} коротких реплик от первого лица в характере Мейвен. " "Настроение: {desc}. Каждая реплика — 1–2 предложения, разговорная, " "естественная для произнесения вслух. Разнообразь темы: быт, умный дом, " "техника, погода, музыка, планы, случайные мысли. " "Верни ТОЛЬКО JSON-массив строк, без ключей, без пояснений." ) def extract_sentences(raw: str) -> list[str]: """pull a list of strings out of the model reply (JSON array, or line-per-item).""" raw = raw.strip() m = re.search(r"\[.*\]", raw, re.S) if m: try: arr = json.loads(m.group(0)) return [s.strip() for s in arr if isinstance(s, str) and s.strip()] except json.JSONDecodeError: pass # fallback: strip bullets/numbering, one per line out = [] for ln in raw.splitlines(): ln = re.sub(r'^\s*[-*\d.)"]+\s*', "", ln).strip().strip('"') if ln: out.append(ln) return out def acceptable(s: str) -> bool: if not (10 <= len(s) <= 240): return False if MIXED_WORD.search(s): return False letters = [c for c in s if c.isalpha()] if not letters: return False cyr = sum(1 for c in letters if CYR.match(c)) return cyr / len(letters) >= 0.85 # mostly-Cyrillic def gen_mood(client, models, mood, desc, target, batch): from openai import OpenAI # noqa: F401 (type hint only) seen, out = set(), [] rot = itertools.cycle(models) stale = 0 while len(out) < target and stale < 8: model = next(rot) try: r = client.chat.completions.create( model=model, temperature=1.0, messages=[ {"role": "system", "content": PERSONA}, {"role": "user", "content": PROMPT.format(n=batch, desc=desc)}, ], ) cands = extract_sentences(r.choices[0].message.content or "") except Exception as e: print(f" [!] {mood} via {model}: {e}") time.sleep(2) stale += 1 continue added = 0 for s in cands: key = s.lower() if key in seen or not acceptable(s): continue seen.add(key) out.append(s) added += 1 stale = 0 if added else stale + 1 print(f" [{mood}] {len(out)}/{target} (+{added} via {model})") return out[:target] def main(): ap = argparse.ArgumentParser() ap.add_argument("--per-mood", type=int, default=1000) ap.add_argument("--batch", type=int, default=40, help="sentences requested per call") ap.add_argument("--moods", nargs="+", default=list(MOODS)) ap.add_argument("--no-stress", action="store_true", help="skip ruaccent (raw text out)") ap.add_argument("--check", action="store_true", help="offline self-check only") args = ap.parse_args() if args.check: s = extract_sentences('текст ```["привет", "как дела"]```') assert s == ["привет", "как дела"], s assert extract_sentences("1. первая\n2. вторая") == ["первая", "вторая"] assert acceptable("Логично, что сервер снова лёг.") assert not acceptable("this is english") assert not acceptable("слово с hello внутри") # mixed-script word print("[✓] gen_transcripts self-check passed") return from openai import OpenAI models = [m.strip() for m in os.environ.get("ROUTER_MODELS", "").split(",") if m.strip()] if not models: sys.exit("set ROUTER_MODELS (comma-separated)") client = OpenAI( base_url=os.environ.get("ROUTER_BASE_URL", "https://inference.kvmx.ru/v1"), api_key=os.environ.get("ROUTER_API_KEY", "x"), ) accent = None if not args.no_stress: from ruaccent import RUAccent accent = RUAccent() accent.load(omograph_model_size="turbo3.1", use_dictionary=True) DATA_DIR.mkdir(parents=True, exist_ok=True) for mood in args.moods: print(f"\n[>] {mood}") lines = gen_mood(client, models, mood, MOODS[mood], args.per_mood, args.batch) if accent: lines = [accent.process_all(x) for x in lines] out = DATA_DIR / f"{mood}-voice-dataset-list.txt" out.write_text("\n".join(lines) + "\n", encoding="utf-8") print(f" [✓] {len(lines)} → {out}") if __name__ == "__main__": main()