File size: 28,245 Bytes
2a076e4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
"""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()