voxtream2-ru / demo /app.py
simba9's picture
demo: self-contained Gradio app (run locally)
2a076e4 verified
Raw
History Blame Contribute Delete
28.2 kB
"""VoXtream2-RU — Gradio-демо (аналог HF space herimor/voxtream2, но с русской моделью).
Работает в двух режимах:
1) Локально: python ru_finetune/ft4/space/app.py --model-dir ru_finetune/ft4/infer_model
2) HF Space: переменная окружения MODEL_REPO=<user>/voxtream2-ru (repo с файлами
model.safetensors, config.json, phoneme_to_token.json, ru_tokens.json) —
файлы скачиваются через hf_hub_download.
Отличия от EN-демо: RUAccent ставит ударения (решает омографы за́мок/замо́к),
espeak-ru фонемизация, спец-токены расширенного словаря.
"""
import argparse
import json
import os
import re
import sys
from pathlib import Path
HERE = Path(__file__).resolve().parent
# HF Space: пакет voxtream и модули (v10_fix_dict, build_groups_v3) вендорены рядом
if str(HERE) not in sys.path:
sys.path.insert(0, str(HERE))
from v10_fix_dict import FIX as INTERJ_FIX, redup_phones as _interj_redup # noqa: E402
LOCAL_REPO_ID = "LOCAL_RU"
VOWEL = set("aeiouyɑɛɔʌəɵɨæøœɐɒʉʊɪ")
RU_VOWELS = set("аеёиоуыэюя")
PUNCT = (".", ",", "?", "!")
# v4: односимвольные согласные слова = проклитики (один звук), а не названия букв.
# espeak-ru даёт «к» -> k ˈɑ («ка») во ВСЕХ контекстах — источник жалобы
# «говорит "ка" вместо "к"». Совпадает с mfa_dict_v4.txt (обучение).
PROCLITIC = {"в": "v", "с": "s", "к": "k", "ж": "ʒ", "б": "b"}
STRIP_WORD = ".,?!—-«»\"'()…:;–„“”+"
# символы, которые НЕ являются фонемами: если прилипнут к фонеме, токен уходит в UNK
NON_PHONE = ".,?!—-«»\"'()…:;–„“”"
# после каких знаков вставлять паузу-токен (как в обучающих TextGrid)
SIL_AFTER = ".,!?"
# Модель знает только .,?! — остальные знаки нормализуем в ближайший по интонации
# (иначе espeak приклеивает их к фонеме: «добавил:» -> «ɭ:» -> UNK -> «добаве»).
_PUNCT_MAP = {":": ",", ";": ",", "—": ",", "–": ",", "…": ".", "«": "", "»": "",
"„": "", "“": "", "”": "", '"': "", "(": "", ")": ""}
TARGET_LUFS = -23.0 # = уровень корпуса v10 (клипы приводятся при рендере групп)
def normalize_lufs(wav, sr):
"""v10: промпт -> -23 LUFS. Тихая бытовая запись для модели OOD; корпус
нормализован к тому же уровню (стандарт NeMo/NVIDIA voice cloning)."""
import numpy as np
import pyloudnorm
mono = wav.mean(dim=0).numpy().astype("float32")
if len(mono) < int(0.5 * sr): # метру BS.1770 нужно >= 0.4 c
return wav
try:
loud = pyloudnorm.Meter(sr).integrated_loudness(mono)
except Exception: # noqa: BLE001
return wav
if not np.isfinite(loud) or loud < -70:
return wav
out = wav * float(10 ** ((TARGET_LUFS - loud) / 20))
peak = float(out.abs().max())
if peak > 0.99: # true-peak защита
out = out * (0.99 / peak)
return out
def condition_prompt(wav, sr):
"""v10.1: подготовка границы промпта. Замер: 5/6 тестовых промптов обрезаны
ПОСРЕДИ слова (жёсткий срез на 8 с), и провалы тембра сидят в первых ~1 с
генерации (окно 0.5 с: sim 0.11-0.29), а единственный промпт с тихим концом
(sanya_agin) — единственный без переключений. В обучении модель всегда
продолжает ПОСЛЕ паузы с комнатным тоном. Делаем так же:
1) если конец промпта — речь, режем назад до ближайшей тишины (>= 80 мс
ниже -35 dB от пика, в последних 2.5 с);
2) добавляем 0.35 с комнатного тона, синтезированного из тихого окна
самого промпта (build_groups_v3.synth_tone). VOXTREAM_PROMPT_COND=0 выкл."""
import numpy as np
if os.environ.get("VOXTREAM_PROMPT_COND", "1") != "1":
return wav
mono = wav.mean(dim=0).numpy().astype("float32")
n = len(mono)
fr = max(int(0.02 * sr), 1)
peak = float(np.abs(mono).max()) + 1e-9
if n > int(3.0 * sr):
# порог тишины адаптивный: 20-й перцентиль энергии 20-мс кадров записи
# (паузы ~20-30% речи), но не выше -25 дБ от пика (шумные бытовые записи)
nf_all = n // fr
e_all = 20 * np.log10(np.sqrt((mono[: nf_all * fr].reshape(nf_all, fr) ** 2).mean(1)) + 1e-9) - 20 * np.log10(peak)
thr = min(float(np.percentile(e_all, 20)), -25.0)
win = int(min(4.0 * sr, n - 2.0 * sr))
seg_e = e_all[-(win // fr):]
run, cut = 0, None
for i, q in enumerate(seg_e < thr):
run = run + 1 if q else 0
if run >= 3: # >= 60 мс тишины
cut = i
if cut is None:
# паузы нет (сплошная речь): режем в самом глубоком провале энергии
# последних 2 с (смычка/межсловный спад — не середина гласной)
tail_e = e_all[-int(1.2 * sr) // fr:]
deep = np.where(tail_e < float(np.median(e_all)) - 6.0)[0]
if len(deep): # ближайший к концу провал — теряем минимум промпта
cut = len(seg_e) - len(tail_e) + int(deep[-1]) + 1
if cut is not None:
end = (nf_all - len(seg_e) + cut - 1) * fr # внутри найденной тишины/провала
if end > int(2.0 * sr):
wav = wav[:, :end]
mono = mono[:end]
try:
import build_groups_v3 as B
tmpl = B.room_tone(mono)
tone = B.synth_tone(tmpl, int(0.35 * sr), seed=7).astype("float32")
except Exception: # noqa: BLE001
tone = (np.random.randn(int(0.35 * sr)) * 1e-4).astype("float32")
import torch
tone_t = torch.from_numpy(tone)[None].expand(wav.shape[0], -1)
return torch.cat([wav, tone_t.to(wav.dtype)], dim=1)
def normalize_punct(text: str) -> str:
for src, dst in _PUNCT_MAP.items():
text = text.replace(src, dst)
text = re.sub(r"\s+([,.!?])", r"\1", text) # пробел перед знаком
text = re.sub(r"([,.!?])\1+", r"\1", text) # дубли знаков
return re.sub(r"\s{2,}", " ", text).strip()
def is_vowel(t):
return any(c in VOWEL for c in t)
def stressed_idx(aw):
vi, cnt, s, i = -1, 0, aw.lower(), 0
while i < len(s):
if s[i] == "+":
if i + 1 < len(s) and s[i + 1] in RU_VOWELS:
vi = cnt
i += 1
continue
if s[i] in RU_VOWELS:
cnt += 1
i += 1
return vi
class RUAccentPhonemizer:
"""Интерфейс ESpeak.phonemize + RUAccent-ударения (перенос ˈ на нужную гласную)."""
def __init__(self):
from ruaccent import RUAccent
from voxtream.utils.text.phonemizer import ESpeak
self.acc = RUAccent()
self.acc.load(omograph_model_size="turbo3.1", use_dictionary=True)
self.esp = ESpeak("ru")
self._runorm = None # ленивая загрузка: нужна только для текстов с цифрами
def _normalize_digits(self, text: str) -> str:
"""v10: цифры/числа -> слова (RUNorm, падежи/род учитывает). Пользователь
пишет «в 2024 году» — работает без ручной нормализации. «Ё», потерянную
RUNorm'ом («четвертом»), ниже восстановит RUAccent."""
if not re.search(r"\d", text):
return text
if self._runorm is None:
from runorm import RUNorm
self._runorm = RUNorm()
self._runorm.load(model_size="medium",
workdir=str(HERE / "runorm_cache"))
# только предложения С цифрами: на остальных RUNorm вредит — разворачивает
# междометия как аббревиатуры («Ммм» -> «эм эм эм») мимо нашей таблицы
try:
parts = re.split(r"(?<=[.!?…])\s+", text)
return " ".join(
self._runorm.norm(p) if re.search(r"\d", p) else p for p in parts
)
except Exception as e: # noqa: BLE001 — цифры хуже, чем необработанный текст
print(f"[runorm] fail: {e}; текст без нормализации")
return text
def _accent(self, text: str) -> str:
"""Ударения: ручной '+' перед гласной (зам+ок) имеет приоритет,
RUAccent ставит только в словах без ручной пометки."""
if "+" not in text:
return self.acc.process_all(text)
plain = re.sub(r"\+", "", text)
auto = self.acc.process_all(plain)
manual_w, auto_w = text.split(), auto.split()
if len(manual_w) != len(auto_w):
return auto # рассинхрон токенизации — безопасный фолбэк
return " ".join(
mw if "+" in mw else aw for mw, aw in zip(manual_w, auto_w)
)
def phonemize(self, text, separator="|", language="ru"):
text = self._normalize_digits(text)
text = normalize_punct(text)
accented = self._accent(text)
clean = re.sub(r"\+", "", accented)
seq = self.esp.phonemize(clean, separator=separator, language="ru")
esp_words, acc_words = seq.split(), accented.split()
if len(esp_words) != len(acc_words):
return seq
out = []
for ew, aw in zip(esp_words, acc_words):
phones = [p for p in ew.split(separator) if p]
# фикс v4: espeak озвучивает предлог «к» как НАЗВАНИЕ буквы (k ˈɑ ->
# «ка тебе»). Односимвольные согласные слова — проклитики в один звук.
bare = aw.strip(STRIP_WORD).lower()
if bare in PROCLITIC:
keep = PROCLITIC[bare]
tail_p = phones[-1][-1] if phones and phones[-1][-1] in "".join(PUNCT) else ""
phones = [keep + tail_p] if tail_p else [keep]
# v10: междометия (хм/ммм/тсс...) espeak читает НАЗВАНИЯМИ букв
# («ха-эм»); в словаре v5 они — чистые согласные. Таблица общая
# с v10_fix_dict, чтобы цикл обучение<->инференс не расходился.
elif (ij := INTERJ_FIX.get(bare) or _interj_redup(bare)) is not None:
tail_p = phones[-1][-1] if phones and phones[-1][-1] in "".join(PUNCT) else ""
phones = ij.split()
if tail_p:
phones[-1] += tail_p
tail = ""
if phones and phones[-1] and phones[-1][-1] in "".join(PUNCT):
tail = phones[-1][-1]
phones[-1] = phones[-1][:-1]
if not phones[-1]:
phones.pop()
# страховка: любой НЕ-фонемный хвост (":", ";", "»", ")"…) прилипает к
# последней фонеме -> токен «ɭ:» отсутствует в словаре -> UNK -> звук
# пропадает («добавил:» звучало как «добаве»). Чистим остатки.
phones = [p for p in (q.strip(NON_PHONE) for q in phones) if p]
ti = stressed_idx(aw)
if ti >= 0 and phones:
cl = [p.replace("ˈ", "").replace("ˌ", "") for p in phones]
vp = [j for j, p in enumerate(cl) if is_vowel(p)]
if ti < len(vp):
cl[vp[ti]] = "ˈ" + cl[vp[ti]]
phones = cl
w = separator.join(phones)
out.append(w + tail if tail else w)
# ФИКС ПАУЗ: в обучении паузы стоят в потоке ЯВНЫМИ токенами 'sil'
# (MFA-разметка, 6.3% токенов), а espeak их не даёт — модель получала
# непрерывный поток и произносила всё слитно (на «Раз, два, три,
# четыре, пять.» — НОЛЬ пауз; звуки проглатывались). Вставляем sil
# после знака: длительность паузы модель выберет сама.
if tail and tail in SIL_AFTER:
out.append("sil")
return " ".join(out)
def resolve_model_files():
"""-> dict имя_файла -> локальный путь (из --model-dir или MODEL_REPO)."""
ap = argparse.ArgumentParser()
ap.add_argument("--model-dir", default=os.environ.get("MODEL_DIR", ""))
args, _ = ap.parse_known_args()
names = ["model.safetensors", "config.json", "phoneme_to_token.json", "ru_tokens.json"]
if args.model_dir:
d = Path(args.model_dir).resolve()
return {n: str(d / n) for n in names}
repo = os.environ.get("MODEL_REPO")
assert repo, "укажите --model-dir или env MODEL_REPO"
from huggingface_hub import hf_hub_download
return {n: hf_hub_download(repo, n) for n in names}
def main():
files = resolve_model_files()
ru_tokens = json.load(open(files["ru_tokens.json"]))
# --- монки-патчи ДО импорта app ---
import voxtream.utils.generator.setup as S
import voxtream.utils.generator.text as T
from huggingface_hub import hf_hub_download as _real_hf
def _hf(repo_id, filename, **kw):
if repo_id == LOCAL_REPO_ID:
return files[filename]
return _real_hf(repo_id, filename, **kw)
S.hf_hub_download = _hf
_orig_ttp = T.text_to_phone_tokens
# E1 (question-prefix модели): '?' после первого слова вопросительного
# предложения — как в обучении (PT видит «впереди вопрос» с самого начала).
# Включается флагом QUESTION_PREFIX=1 (для infer_model_e).
q_prefix = os.environ.get("QUESTION_PREFIX", "0") == "1"
def _add_q_prefix(text: str) -> str:
out = []
for sent in re.split(r"(?<=[.!?])\s+", str(text).strip()):
words = sent.split()
if sent.rstrip().endswith("?") and len(words) > 1:
words[0] += "?"
out.append(" ".join(words))
return " ".join(out)
def _ttp(*a, **kw):
kw["normalize"] = False
kw["language"] = "ru"
if q_prefix and a and isinstance(a[0], str):
a = (_add_q_prefix(a[0]),) + a[1:]
elif q_prefix and "text" in kw:
kw["text"] = _add_q_prefix(kw["text"])
return _orig_ttp(*a, **kw)
T.text_to_phone_tokens = _ttp
# v10: LUFS-нормализация промпта — шим над torchaudio ТОЛЬКО внутри prompt.py
# (глобальный torchaudio.load не трогаем). Внимание: .prompt.npy-кэши,
# созданные до нормализации, устаревают — их надо удалить.
import voxtream.utils.generator.prompt as PR
_orig_ta = PR.torchaudio
class _LufsTorchaudio:
def __getattr__(self, name):
return getattr(_orig_ta, name)
@staticmethod
def load(path, *a, **kw):
wav, sr = _orig_ta.load(path, *a, **kw)
return condition_prompt(normalize_lufs(wav, sr), sr), sr
PR.torchaudio = _LufsTorchaudio()
from voxtream.generator import SpeechGenerator
_orig_init = SpeechGenerator.__init__
def _patched_init(self, *a, **kw):
_orig_init(self, *a, **kw)
self.ctx.phonemizer = RUAccentPhonemizer()
SpeechGenerator.__init__ = _patched_init
# v10.1: синтез ПО ПРЕДЛОЖЕНИЯМ с повторной привязкой к промпту.
# Замер 384 сэмплов: переключение голоса после паузы между предложениями —
# 12-16% на многопредложенческих фразах против 3% на одиночных; трудные
# голоса (shibakov 33%) теряются после паузы. Каждое предложение стартует
# сразу после промпта, где привязка максимальна; между ними — пауза
# (медиана корпуса по знаку). VOXTREAM_SENT_SPLIT=0 выключает.
_orig_gs = SpeechGenerator.generate_stream
_PAUSE = {".": 0.39, "?": 0.42, "!": 0.41} # эмпирика 4.5 млн пауз (build_groups)
def _split_sentences(text: str):
parts = [p.strip() for p in re.split(r"(?<=[.!?…])\s+", text.strip()) if p.strip()]
return parts if len(parts) >= 2 else [text]
def _gs_split(self, prompt_audio_path, text, speaking_rate=None, enhance_prompt=None,
apply_vad=None, return_progress=False, min_streaming_rtf=None):
if os.environ.get("VOXTREAM_SENT_SPLIT", "1") != "1" or not isinstance(text, str):
yield from _orig_gs(self, prompt_audio_path, text, speaking_rate, enhance_prompt,
apply_vad, return_progress, min_streaming_rtf)
return
sents = _split_sentences(text)
if len(sents) < 2:
yield from _orig_gs(self, prompt_audio_path, text, speaking_rate, enhance_prompt,
apply_vad, return_progress, min_streaming_rtf)
return
import numpy as _np
sr = int(self.config.mimi_sr)
pos_off, time_off, last_prog = 0, 0.0, None
for k, sent in enumerate(sents):
last_pos, last_t = 0, 0.0
for item in _orig_gs(self, prompt_audio_path, sent, speaking_rate, enhance_prompt,
apply_vad, return_progress, min_streaming_rtf):
if return_progress:
frame, gt, prog = item
prog = dict(prog)
last_pos = max(last_pos, int(prog.get("phone_position", 0) or 0))
last_t = max(last_t, float(prog.get("time_sec", 0.0) or 0.0))
prog["phone_position"] = pos_off + int(prog.get("phone_position", 0) or 0)
prog["time_sec"] = time_off + float(prog.get("time_sec", 0.0) or 0.0)
last_prog = prog
yield frame, gt, prog
else:
yield item
pos_off += last_pos + 1
time_off += last_t
if k < len(sents) - 1:
gap = _PAUSE.get(sent[-1], 0.39)
n = int(gap * sr)
noise = (_np.random.randn(n) * 1e-4).astype(_np.float32) # не «цифровой» ноль
time_off += gap
if return_progress:
prog = dict(last_prog or {})
prog["time_sec"] = time_off
yield noise, 0.0, prog
else:
yield noise, 0.0
# v10.1: BEST-OF-N по удержанию голоса (offline-режим). Замер: 8-12% сэмплов
# с провалом оконной spk-sim к промпту (>0.25 от медианы), реальная речь тех
# же людей — 0/8; ни данные (S2b), ни сэмплирование/CFG/SRC/подготовка
# промпта цифру не сдвинули. Best-of-2 -> ~0.7% ожидаемо. Скорим тем же
# ReDimNet, что кондиционирует модель (ctx.spk_enc). VOXTREAM_BEST_OF=1 выкл.
from voxtream.utils.generator.prompt import extract_speaker_template as _est
import voxtream.utils.generator.prompt as _PR
def _spk_score(self, prompt_audio_path, audio_np):
import numpy as _np
import torch as _t
wav, sr = _PR.torchaudio.load(str(prompt_audio_path)) # шим: LUFS + граница
pe = _est(wav.mean(0, keepdim=True), sr, self.ctx.spk_enc, self.config.spk_enc_sr,
self.ctx.device, self.ctx.dtype).float().reshape(-1)
gen = _t.from_numpy(audio_np.astype("float32"))[None]
osr = int(self.config.mimi_sr)
W, H = int(1.5 * osr), int(0.5 * osr)
sims = []
for st in range(0, max(gen.shape[1] - W, 1), H):
e = _est(gen[:, st:st + W], osr, self.ctx.spk_enc, self.config.spk_enc_sr,
self.ctx.device, self.ctx.dtype).float().reshape(-1)
sims.append(float(pe @ e))
if not sims:
return 0.0, 0.0
return float(min(sims)), float(_np.median(sims))
def _gs_bestof(self, prompt_audio_path, text, speaking_rate=None, enhance_prompt=None,
apply_vad=None, return_progress=False, min_streaming_rtf=None):
try:
N = int(os.environ.get("VOXTREAM_BEST_OF", "2"))
except ValueError:
N = 1
if N <= 1 or not isinstance(text, str):
yield from _gs_split(self, prompt_audio_path, text, speaking_rate, enhance_prompt,
apply_vad, return_progress, min_streaming_rtf)
return
import numpy as _np
best, best_key = None, None
for k in range(N):
items = list(_gs_split(self, prompt_audio_path, text, speaking_rate, enhance_prompt,
apply_vad, return_progress, min_streaming_rtf))
audio = _np.concatenate([it[0] for it in items]) if items else _np.zeros(1, "float32")
mn, med = _spk_score(self, prompt_audio_path, audio)
key = (mn >= med - 0.25, mn) # сначала «без провала», затем по худшему окну
print(f"[best-of-{N}] кандидат {k + 1}: sim_min {mn:.2f} med {med:.2f}", flush=True)
if best_key is None or key > best_key:
best, best_key = items, key
if best_key[0]: # чистый кандидат — хватит (перегенерация только при провале)
break
yield from best
SpeechGenerator.generate_stream = _gs_bestof
# --- конфиги: базовые из репо + RU-переопределения ---
root = HERE
gen_cfg = json.load(open(root / "configs/generator.json"))
gen_cfg.update(
model_repo=LOCAL_REPO_ID,
unk_token=ru_tokens["unk"], eop_token=ru_tokens["unk"],
bos_token=ru_tokens["bos"], eos_token=ru_tokens["eos"],
sil_token=ru_tokens["sil"],
enhance_prompt=False, apply_vad=False,
# v10: свип 12 ячеек temp x topk на промптах пользователя (v9, 2026-08-08):
# 0.8/50 -> CER 0.0105, CV 0.159 против 0.023/0.177 у унаследованных 0.9/5;
# t1.0/k5 давал CER 0.0096, но худший CV 0.202 и темп 4.89 — не взят.
temperature=0.8, topk=50,
# v4 (свипы на ПРОМПТАХ ПОЛЬЗОВАТЕЛЯ ru_finetune/unseen — «удобные»
# корпусные промпты регрессию не ловили): при gain=25 cfg_gamma 1.5 даёт
# CER 0.021 против 0.056 у 2.0 и 0.024 у 3.0; unseen spk_sim 0.602 против
# 0.616 у 3.0, НО худший случай 0.422 против 0.269 — провалов нет.
cfg_gamma=1.5,
)
# Усиление SPS-кондиционирования. gain=40 подбирался на stage D, где бины
# считались по БУКВАМ и эмбеддинг почти не обучался. В v4 бины из ФОНЕМ —
# эмбеддинг информативнее, и 40 РАЗРУШАЕТ генерацию: на промптах пользователя
# CER 0.366 и скачки темпа (жалоба «неразборчиво, то быстро то медленно»).
# Свип по CER: gain 1-20 → 0.016-0.019, 25 → 0.024, 30 → 0.075, 40 → 0.366.
# 25 — компромисс: разборчивость вдвое лучше v3b при самом медленном темпе
# среди «чистых» режимов. Замедление ниже ~5.7 SPS этим механизмом ЛОМАЕТ речь
# (gain20/rate3.5 → CER 0.121; gain30/rate3.5 → 0.584) — темп чинить обучением.
os.environ.setdefault("VOXTREAM_SPS_GAIN", "25")
# Управление темпом: SRC вместо SPS-эмбеддинга. Замер на v7 (промпты юзера):
# SPS gain30/rate3.5 -> темп 4.64 но CER 0.245 (речь рвётся); SRC rate3.5 ->
# темп 5.37 при CER 0.025 и паузах 0.39с (= корпусная норма). SRC не искажает
# эмбеддинг, поэтому замедление безопасно.
os.environ.setdefault("VOXTREAM_TEMPO_MODE", "src")
# v10.1: лимит удержания фонемы по классу (кадры по 80 мс). Жалоба «буквы
# повторяются» = залипание на последней согласной фразы («Потомммм,» — 26
# кадров при защите frame_repeat_counter=25). В данных 97% согласных перед
# знаком <= 5 кадров, 99% гласных <= 12. A/B на S2-e2: без лимита макс согл.
# 11 кадров, CER 0.029; с лимитом — макс 5-6, CER 0.017-0.023, тембр не хуже.
os.environ.setdefault("VOXTREAM_DWELL_CAPS", "cons=5,vow=12")
ru_gen = HERE / "generator_ru.json"
# точечные переопределения инференс-параметров: GEN_OVERRIDES='{"cfg_gamma":2.0}'
overrides = os.environ.get("GEN_OVERRIDES")
if overrides:
gen_cfg.update(json.loads(overrides))
print(f"[app] generator overrides: {overrides}")
json.dump(gen_cfg, open(ru_gen, "w"), indent=2)
examples = HERE / "examples_ru.json"
if not examples.exists():
json.dump({"examples": []}, open(examples, "w"))
sys.argv = [
"voxtream-app",
"--config", str(ru_gen),
"--app-config", str(p if (p := HERE / "app_ru.json").exists()
else root / "configs/app.json"),
# RU-гистограммы SRC (пересчитаны по ft4-корпусу), фолбэк — EN-оригинал
"--spk-rate-config", str(
p if (p := root / "configs/speaking_rate_ru.json").exists()
else root / "configs/speaking_rate.json"
),
"--examples-config", str(examples),
]
from voxtream.app import main as app_main
app_main()
if __name__ == "__main__":
main()