init
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
"""
|
||||
build the piper LJSpeech dataset from the teacher-generated dataset.
|
||||
|
||||
dataset/metadata.csv (file_path,text,mood,emotion_id) + 24kHz wavs
|
||||
→ piper/dataset/wav/<id>.wav (22050 Hz mono)
|
||||
→ piper/dataset/metadata.csv (id|stressed_text)
|
||||
|
||||
stress convention: the mood lists / teacher text use "+VOWEL" (plus before the
|
||||
stressed vowel). espeak-ng — piper's phonemizer — wants the COMBINING ACUTE
|
||||
(U+0301) AFTER the vowel. we convert here. (the uppercase-vowel form in
|
||||
generate_synthetic_voice.py's plus_to_acute is for the qwen/OmniVoice teacher,
|
||||
NOT espeak — don't reuse it.)
|
||||
|
||||
run: python piper/build_dataset.py # build
|
||||
python piper/build_dataset.py --check # self-check only
|
||||
requires: pip install soundfile librosa
|
||||
"""
|
||||
|
||||
import csv
|
||||
import sys
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
ACUTE = "́" # combining acute accent
|
||||
VOWELS = set("аеёиоуыэюяАЕЁИОУЫЭЮЯ")
|
||||
SRC_META = Path("tts/dataset/metadata.csv")
|
||||
OUT_DIR = Path("tts/piper/dataset")
|
||||
TARGET_SR = 22050
|
||||
|
||||
|
||||
def plus_to_espeak(text: str) -> str:
|
||||
"""'+VOWEL' -> 'VOWEL' + U+0301 (espeak-ng stress). Leaves other '+' intact."""
|
||||
out = []
|
||||
i = 0
|
||||
while i < len(text):
|
||||
if text[i] == "+" and i + 1 < len(text) and text[i + 1] in VOWELS:
|
||||
out.append(text[i + 1])
|
||||
out.append(ACUTE)
|
||||
i += 2
|
||||
else:
|
||||
out.append(text[i])
|
||||
i += 1
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def build():
|
||||
import soundfile as sf
|
||||
import librosa
|
||||
|
||||
wav_out = OUT_DIR / "wav"
|
||||
wav_out.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
rows = list(csv.DictReader(SRC_META.open(encoding="utf-8")))
|
||||
meta_lines, n = [], 0
|
||||
for r in rows:
|
||||
src = Path(r["file_path"])
|
||||
if not src.exists():
|
||||
print(f" [!] missing wav, skipping: {src}")
|
||||
continue
|
||||
# id: <mood>-<stem> so ids stay unique across per-mood NNNN.wav collisions
|
||||
wid = f"{r['mood']}-{src.stem}"
|
||||
audio, sr = sf.read(str(src))
|
||||
if audio.ndim > 1:
|
||||
audio = audio.mean(axis=1)
|
||||
if sr != TARGET_SR:
|
||||
audio = librosa.resample(audio, orig_sr=sr, target_sr=TARGET_SR)
|
||||
sf.write(str(wav_out / f"{wid}.wav"), audio, TARGET_SR)
|
||||
meta_lines.append(f"{wid}|{plus_to_espeak(r['text'])}")
|
||||
n += 1
|
||||
|
||||
(OUT_DIR / "metadata.csv").write_text("\n".join(meta_lines) + "\n", encoding="utf-8")
|
||||
print(f"[✓] {n} samples → {OUT_DIR} (wav {TARGET_SR}Hz, metadata id|text)")
|
||||
|
||||
|
||||
def check():
|
||||
assert plus_to_espeak("з+амок") == "за" + ACUTE + "мок"
|
||||
assert plus_to_espeak("сто+ят") == "стоя" + ACUTE + "т"
|
||||
assert plus_to_espeak("нет плюса") == "нет плюса" # no stress → untouched
|
||||
assert plus_to_espeak("1 + 2") == "1 + 2" # '+' not before vowel → kept
|
||||
print("[✓] plus_to_espeak self-check passed")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--check", action="store_true", help="run self-check only")
|
||||
args = ap.parse_args()
|
||||
check()
|
||||
if not args.check:
|
||||
build()
|
||||
@@ -0,0 +1,58 @@
|
||||
"""
|
||||
phoneme stress sanity check — run BEFORE training (Phase 6).
|
||||
|
||||
espeak-ng is piper's phonemizer. for each sampled transcript, print its IPA and
|
||||
flag any multisyllable word whose IPA carries no primary-stress mark 'ˈ'. wrong
|
||||
stress is the #1 cause of "sounds bad" — catch it here, not after GPU-days.
|
||||
|
||||
ponytail: thin wrapper over the espeak-ng CLI. ceiling = flags MISSING stress
|
||||
only, not WRONG-POSITION stress (that needs an ear on the Phase 4 audio).
|
||||
|
||||
run: python piper/check_phonemes.py [--n 30] [--meta piper/dataset/metadata.csv]
|
||||
requires: sudo apt install espeak-ng
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import random
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
STRESS = "ˈ"
|
||||
|
||||
|
||||
def ipa(text: str) -> str:
|
||||
out = subprocess.run(
|
||||
["espeak-ng", "-v", "ru", "--ipa", "-q", text],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
return out.stdout.strip()
|
||||
|
||||
|
||||
def main():
|
||||
if not shutil.which("espeak-ng"):
|
||||
sys.exit("espeak-ng not found — sudo apt install espeak-ng")
|
||||
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--meta", default="tts/piper/dataset/metadata.csv")
|
||||
ap.add_argument("--n", type=int, default=30)
|
||||
args = ap.parse_args()
|
||||
|
||||
lines = [l for l in Path(args.meta).read_text(encoding="utf-8").splitlines() if "|" in l]
|
||||
sample = random.sample(lines, min(args.n, len(lines)))
|
||||
|
||||
flagged = 0
|
||||
for line in sample:
|
||||
_id, text = line.split("|", 1)
|
||||
phon = ipa(text)
|
||||
bad = STRESS not in phon and len(text.split()) >= 1
|
||||
mark = " ⚠ NO STRESS" if bad else ""
|
||||
flagged += bad
|
||||
print(f"{text}\n → {phon}{mark}\n")
|
||||
|
||||
print(f"[{'!' if flagged else '✓'}] {flagged}/{len(sample)} samples missing stress marks")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,174 @@
|
||||
"""
|
||||
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()
|
||||
@@ -0,0 +1,43 @@
|
||||
"""
|
||||
OmniVoice teacher adapter — the ONE place the teacher API lives.
|
||||
|
||||
swaps Qwen3-TTS for k2-fsa/OmniVoice in find_voice.py + generate_synthetic_voice.py
|
||||
without touching their resume/metadata logic. two ops mirror the qwen calls:
|
||||
|
||||
design(text, instruct) ~ model.generate_voice_design(...) → audition
|
||||
clone(text, ref_wav, ref_txt) ~ model.generate_voice_clone(...) → dataset
|
||||
|
||||
returns (audio: np.ndarray, sr: int). OmniVoice emits 24kHz.
|
||||
|
||||
⚠ VERIFY THE API before running: `pip install omnivoice`, then check its README /
|
||||
`pip show omnivoice`. Method names + kwargs below are written to the model card
|
||||
description (ref audio + transcription for cloning; speaker attributes for design)
|
||||
and MUST be confirmed against the installed package. This is deferred until the GPU
|
||||
is free after the LLM CPT — adjust the two call bodies once the real signature is known.
|
||||
|
||||
requires: pip install omnivoice (after torch)
|
||||
"""
|
||||
|
||||
import functools
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=1)
|
||||
def _model():
|
||||
import torch
|
||||
from omnivoice import OmniVoice # ⚠ confirm import path
|
||||
return OmniVoice.from_pretrained("k2-fsa/OmniVoice", dtype=torch.float16, device="cuda:0")
|
||||
|
||||
|
||||
def design(text: str, instruct: str):
|
||||
"""voice-design: synth `text` in a voice described by `instruct`. → (audio, 24000)."""
|
||||
m = _model()
|
||||
audio = m.generate(text=text, language="ru", speaker_description=instruct) # ⚠ confirm
|
||||
return audio, 24000
|
||||
|
||||
|
||||
def clone(text: str, ref_wav: str, ref_txt: str):
|
||||
"""zero-shot clone: synth `text` in the voice of `ref_wav` (transcript `ref_txt`)."""
|
||||
m = _model()
|
||||
audio = m.generate(text=text, language="ru",
|
||||
ref_audio=ref_wav, ref_text=ref_txt) # ⚠ confirm
|
||||
return audio, 24000
|
||||
Executable
+29
@@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env bash
|
||||
# piper student — train FROM SCRATCH on the OmniVoice-generated dataset.
|
||||
# run on the workstation (gfx1100, ROCm) AFTER the LLM CPT frees the GPU.
|
||||
#
|
||||
# prereq: piper-train installed from rhasspy/piper (src/python): torch + pytorch-lightning.
|
||||
# espeak-ng installed. piper/dataset/ built (build_dataset.py) & sanity-checked.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/../.." # → esp32-whisper-fine-tune/
|
||||
export HSA_OVERRIDE_GFX_VERSION=11.0.0
|
||||
|
||||
DATASET=tts/piper/dataset
|
||||
TRAIN=tts/piper/train
|
||||
|
||||
# 1. preprocess: text → espeak-ng phonemes → training cache
|
||||
python -m piper_train.preprocess \
|
||||
--language ru --input-dir "$DATASET" --output-dir "$TRAIN" \
|
||||
--dataset-format ljspeech --single-speaker --sample-rate 22050
|
||||
|
||||
# 2. train from scratch (NO --resume_from_checkpoint), medium = CPU-real-time target
|
||||
python -m piper_train \
|
||||
--dataset-dir "$TRAIN" --accelerator gpu --devices 1 \
|
||||
--batch-size 16 --quality medium --precision 32 \
|
||||
--max_epochs 4000 --checkpoint-epochs 100 --validation-split 0.02
|
||||
|
||||
# 3. export best checkpoint → ONNX (adjust version_/last.ckpt path to your run)
|
||||
CKPT=$(ls -t "$TRAIN"/lightning_logs/version_*/checkpoints/*.ckpt | head -1)
|
||||
python -m piper_train.export_onnx "$CKPT" tts/piper/maven.onnx
|
||||
cp "$TRAIN"/config.json tts/piper/maven.onnx.json
|
||||
echo "[✓] exported → tts/piper/maven.onnx (+ .json). scp both to homesrv."
|
||||
Reference in New Issue
Block a user