175 lines
7.0 KiB
Python
175 lines
7.0 KiB
Python
"""
|
||
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/<mood>-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()
|