voxtream2-ru / demo /build_groups_v3.py
simba9's picture
demo: self-contained Gradio app (run locally)
2a076e4 verified
Raw
History Blame Contribute Delete
24 kB
"""v3: пересборка 55-с групп с ЕСТЕСТВЕННЫМИ стыками (лечит «обрыв на точке»,
константные паузы и мёртвые нули между клипами).
Отличия от build_groups_ft4 (v2):
1. Паузы на стыках — семплируются из ЭМПИРИЧЕСКИХ распределений реальных пауз
корпуса (v3/pause_hist.json, посчитан по 1.14M по-клиповых MFA-выравниваний),
класс — по хвостовому знаку клипа: медианы ~0.39s после '.', 0.42s после '?',
0.27s после ',' (в v2 были константы 160/100 мс — в 2.5 раза короче естественных).
2. Фейды адаптивные: до 40 мс приподнятым косинусом, но ТОЛЬКО внутри собственной
краевой тишины клипа (речь не трогаем; минимум 2 мс от щелчков).
3. Паузы и хвост группы — не цифровой ноль, а КОМНАТНЫЙ ТОН клипа (самое тихое
60-мс окно, тайлится зеркально — C0-непрерывно, с джиттером амплитуды).
4. Клипы без MFA-выравнивания отсекаются на этапе плана (в v2 группы с ними
молча выпадали на этапе npy целиком).
5. Оверсемпл вопросов (x2 прохода) и короткие группы (10%, 5-35 c) — встроены
(в v2 это делал отдельный extend_groups_e).
Порядок клипов внутри спикера = порядок манифеста (для аудиокниг/подкастов это
порядок записи -> соседние клипы часто из одной сессии).
Выход: ft4/groups_v3.json, wav в voxtream_ru/_groups55_v3/, grouped_v3_chunk*.parquet.
Запуск:
.venv/bin/python ru/pipeline/build_groups_v3.py --plan-only # только план
.venv/bin/python ru/pipeline/build_groups_v3.py --workers 24 # план + wav
"""
import argparse
import json
import multiprocessing as mp
import random
import re
from pathlib import Path
import numpy as np
import pandas as pd
import soundfile as sf
ROOT = Path("/mnt/data/voxtream/ru_finetune/ru/data")
MFA_OUT = ROOT / "mfa_out"
GROUP_DIR = Path("/mnt/data/audio_data/voxtream_ru/_groups55_v3")
SR = 24000
TARGET = int(54.5 * SR)
MIN_GROUP = int(35.0 * SR)
MIN_UNIQUE = int(15.0 * SR)
FADE_MIN = int(0.002 * SR)
FADE_MAX = int(0.040 * SR)
TONE_WIN = int(0.060 * SR)
LATIN = re.compile(r"[a-zA-Z]")
SHORT_MIN, SHORT_MAX = int(5 * SR), int(35 * SR)
Q_EXTRA_PASSES = 2
SHORT_FRAC = 0.10
Q_MIN_GROUP = int(10 * SR)
# клампы семплированных пауз, сек (хвосты эмпирики не должны съедать бюджет группы)
# v4: клампы = p5..p95 эмпирики (v3 давал верх 1.2с — модель училась редким
# сверхдлинным паузам и на инференсе выдавала медиану 0.85с при норме 0.39с)
GAP_CLAMP = {".": (0.10, 0.90), "!": (0.10, 1.05), "?": (0.10, 1.00),
",": (0.06, 0.70), "none": (0.05, 0.60)}
OVERWRITE = False # --overwrite: перерендер существующих wav (фикс тона без пересборки плана)
# v10: приведение каждого клипа к -23 LUFS при рендере (--lufs). Тихая бытовая
# запись промпта = OOD; корпус и промпт демки нормализуются к одному уровню
# (стандарт NeMo/NVIDIA voice cloning). Метр создаётся лениво в каждом воркере.
LUFS_ON = False
TARGET_LUFS = -23.0
_meter = None
def lufs_gain(wav: np.ndarray) -> float:
global _meter
if len(wav) < SR // 2: # метру BS.1770 нужно >= 0.4 c
return 1.0
if _meter is None:
import pyloudnorm
_meter = pyloudnorm.Meter(SR)
try:
loud = _meter.integrated_loudness(wav)
except Exception: # noqa: BLE001
return 1.0
if not np.isfinite(loud) or loud < -70:
return 1.0
g = float(10 ** ((TARGET_LUFS - loud) / 20))
peak = float(np.abs(wav).max()) * g
if peak > 0.99: # true-peak защита
g *= 0.99 / peak
return min(max(g, 0.05), 20.0)
_hist = None # {cls: np.array длительностей}
def load_hist():
global _hist
h = json.load(open(ROOT / "v3" / "pause_hist.json"))
_hist = {k: np.asarray(v["sample"], dtype=np.float32) for k, v in h.items()}
return _hist
# медианы пауз по классу знака (v3/pause_hist.json, 4.5M пауз корпуса)
GAP_MED = {".": 0.39, "!": 0.41, "?": 0.42, ",": 0.27, "none": 0.11}
# внутригрупповой разброс: CV 0.54 — ИЗМЕРЕННАЯ вариативность пауз внутри одной
# записи одного диктора (lognormal sigma = sqrt(ln(1+CV^2)))
SIGMA_WITHIN = 0.50
# межгрупповой: медиана паузы '.' по спикерам гуляет 0.07..0.57с (p10..p90)
SIGMA_BETWEEN = 0.35
def gap_of(text: str, rng: random.Random, tempo: float = 1.0) -> int:
"""Пауза после клипа = медиана класса * ТЕМП ГРУППЫ * внутренний шум.
v3 брал семпл из ОБЩЕКОРПУСНОЙ эмпирики (CV 0.70) на каждый стык
независимо — внутри одной группы возникал разброс, который в реальности
бывает только МЕЖДУ дикторами; модель выучила «после точки может быть что
угодно» и на инференсе давала «то слишком коротко, то слишком долго»
(жалоба пользователя; замер синтеза: медиана паузы 0.85с при норме 0.39с).
v4 расщепляет дисперсию: tempo — один множитель на группу (междикторская
компонента), шум CV 0.54 — внутридикторская. Суммарно даёт корпусную
вариативность, но СТРУКТУРИРОВАННУЮ.
"""
tail = str(text).rstrip()[-1:]
cls = tail if tail in ".!?," else "none"
lo, hi = GAP_CLAMP[cls]
g = GAP_MED[cls] * tempo * rng.lognormvariate(0.0, SIGMA_WITHIN)
return int(min(max(g, lo), hi) * SR)
def group_tempo(rng: random.Random) -> float:
"""Логнормальный множитель темпа пауз группы (медиана 1.0, σ=0.35):
~80% групп в диапазоне 0.64-1.57x — межспикерная компонента."""
return float(min(max(rng.lognormvariate(0.0, SIGMA_BETWEEN), 0.5), 2.0))
def aligned_indices() -> set:
"""Индексы манифеста, у которых есть TextGrid (скан mfa_out, ~1 мин)."""
idx = set()
for d in MFA_OUT.iterdir():
if not d.is_dir():
continue
for f in d.iterdir():
n = f.name
if n.endswith(".TextGrid"):
try:
idx.add(int(n[:-9]))
except ValueError:
pass
return idx
# ---------------------------------------------------------------- план (pack)
def _rescue(short, pool, rng):
rescued, dropped = [], 0
uniq_total = sum(p[3] for p in pool)
for g in short:
if uniq_total < MIN_UNIQUE:
dropped += 1
continue
cand = [p for p in pool if p[0] not in set(g["idx"])] or list(pool)
rng.shuffle(cand)
ci = 0
while g["total"] < MIN_GROUP and ci < 4 * len(cand):
idx, path, samples, slot = cand[ci % len(cand)]
ci += 1
if g["total"] + slot > TARGET:
continue
for k, v in (("idx", idx), ("paths", path), ("samples", samples), ("slots", slot)):
g[k].append(v)
g["total"] += slot
g["reused"] = g.get("reused", 0) + 1
if g["total"] >= MIN_GROUP:
rescued.append(g)
else:
dropped += 1
return rescued, dropped
def _new(spk, kind="base"):
return {"speaker": spk, "idx": [], "paths": [], "slots": [], "samples": [],
"total": 0, "kind": kind}
def _push(g, idx, path, samples, slot):
g["idx"].append(idx)
g["paths"].append(path)
g["samples"].append(samples)
g["slots"].append(slot)
g["total"] += slot
def retempo(groups, texts, seed: int = 4242):
"""v4: назначить каждой группе общий множитель темпа пауз и пересчитать слоты.
Паузы внутри группы становятся согласованными (как у одного диктора в одной
сессии), а не независимыми выбросами из широкого распределения. Если после
пересчёта группа не влезает в TARGET — темп ужимается, в крайнем случае
дропается хвостовой клип.
"""
rng = random.Random(seed)
n_trim = 0
for g in groups:
tempo = group_tempo(rng)
for _ in range(6):
slots = [smp + gap_of(texts[i], rng, tempo) if i >= 0 else slot
for i, smp, slot in zip(g["idx"], g["samples"], g["slots"])]
if sum(slots) <= TARGET:
break
tempo *= 0.85
while sum(slots) > TARGET and len(slots) > 1:
for k in ("idx", "paths", "samples"):
g[k].pop()
slots.pop()
n_trim += 1
g["slots"] = slots
g["total"] = sum(slots)
g["tempo"] = round(tempo, 3)
print(f"retempo: групп {len(groups)}, обрезано хвостовых клипов {n_trim}")
return [g for g in groups if g["total"] >= 5 * SR and g["idx"]]
def tempo_key(path: str) -> str:
"""v6: версия темпа клипа (orig / slow / fast).
Замер на v5: модель почти не копирует темп промпта (наклон 0.34). Одна из
причин — 18.8% групп СМЕШИВАЛИ оригиналы с темпо-аугментированными копиями:
промпт мог быть 0.75x, а продолжение 1.3x, т.е. данные прямо учили, что
темп промпта НЕ предсказывает темп речи. Группируем по (спикер, версия) —
внутри группы темп однороден, связь промпт->продолжение становится честной.
"""
if "_tempo_aug" not in path:
return "orig"
return "slow" if "_slow" in path else "fast"
# v7: границы темпо-корзин, слог/с (clip_sps.npy: медиана 5.22, p10 3.76, p90 6.78).
# Замер на v6: 60% групп имели разброс темпа >=2 слог/с ВНУТРИ себя — промпт (начало
# группы) и продолжение записаны с разной скоростью, поэтому связь «темп промпта ->
# темп речи» в данных отсутствовала (наклон копирования 0.15-0.34 во всех версиях).
TEMPO_EDGES = (4.3, 5.2, 6.1)
def tempo_bucket(sps: float) -> str:
if not np.isfinite(sps):
return "na"
return str(int(np.searchsorted(TEMPO_EDGES, sps)))
def pack_groups(df, aligned: set, seed: int = 42, clip_sps=None):
rng = random.Random(seed)
groups, n_lat, n_noal, n_rescued, n_dropped, n_reused = [], 0, 0, 0, 0, 0
texts = df.text.astype(str)
by_spk_pool = {}
key = df.speaker.astype(str) + "|" + df.audio_path.map(tempo_key)
if clip_sps is not None:
key = key + "|" + pd.Series(
[tempo_bucket(s) for s in clip_sps[: len(df)]], index=df.index
)
df = df.assign(_spk_tempo=key)
for spk_t, sub in df.groupby("_spk_tempo", sort=False):
spk = spk_t.split("|")[0]
cur, short, pool = None, [], []
for row in sub.itertuples():
if int(row.Index) not in aligned:
n_noal += 1
continue
if LATIN.search(row.text):
n_lat += 1
continue
samples = int(round(row.duration * SR))
slot = samples + gap_of(row.text, rng)
if samples <= 0 or slot > TARGET:
continue
pool.append((int(row.Index), row.audio_path, samples, slot))
if cur is None or cur["total"] + slot > TARGET:
if cur:
(groups if cur["total"] >= MIN_GROUP else short).append(cur)
cur = _new(spk)
_push(cur, int(row.Index), row.audio_path, samples, slot)
if cur:
(groups if cur["total"] >= MIN_GROUP else short).append(cur)
if short:
rescued, dropped = _rescue(short, pool, rng)
n_rescued += len(rescued)
n_dropped += dropped
n_reused += sum(g.get("reused", 0) for g in rescued)
groups.extend(rescued)
by_spk_pool[spk_t] = pool
n_base = len(groups)
print(f"базовых групп: {n_base}; латиница={n_lat}, без выравнивания={n_noal}, "
f"спасено={n_rescued} (+{n_reused} переисп.), дропнуто={n_dropped}")
# --- оверсемпл вопросов ---
n_q = 0
for spk_t, pool in by_spk_pool.items():
spk = spk_t.rsplit("|", 1)[0]
qs = [p for p in pool if "?" in texts[p[0]]]
if not qs:
continue
for _ in range(Q_EXTRA_PASSES):
order = qs[:]
rng.shuffle(order)
cur = None
for idx, path, samples, slot in order:
if cur is None or cur["total"] + slot > TARGET:
if cur and cur["total"] >= Q_MIN_GROUP:
groups.append(cur)
cur = _new(spk, "question")
_push(cur, idx, path, samples, slot)
if cur and cur["total"] >= Q_MIN_GROUP:
groups.append(cur)
n_q = len(groups) - n_base
# --- короткие группы ---
spk_list = [s for s, p in by_spk_pool.items() if p] # ключи "спикер|версия"
n_short_target = int(SHORT_FRAC * n_base)
for _ in range(n_short_target):
spk_t = rng.choice(spk_list)
pool = by_spk_pool[spk_t]
cur = _new(spk_t.rsplit("|", 1)[0], "short")
target_len = rng.randint(SHORT_MIN, SHORT_MAX)
for _try in range(6):
idx, path, samples, slot = pool[rng.randrange(len(pool))]
if cur["total"] + slot > min(target_len + 3 * SR, TARGET):
if cur["total"] >= SHORT_MIN:
break
continue
_push(cur, idx, path, samples, slot)
if cur["total"] >= target_len:
break
if cur["idx"]:
groups.append(cur)
print(f"вопросных групп: +{n_q}, коротких: +{len(groups) - n_base - n_q}")
return retempo(groups, texts)
# ------------------------------------------------------------- рендер (wav)
def _cos_ramp(n: int) -> np.ndarray:
return (0.5 - 0.5 * np.cos(np.linspace(0, np.pi, n, dtype=np.float32)))
def adaptive_fades(wav: np.ndarray) -> np.ndarray:
"""Фейды внутри краевой тишины клипа: до 40 мс, речь не трогаем."""
peak = float(np.max(np.abs(wav))) + 1e-9
loud = np.abs(wav) > max(0.02 * peak, 1e-4)
if not loud.any():
return wav
first = int(np.argmax(loud))
last = len(wav) - int(np.argmax(loud[::-1]))
fi = min(max(first, FADE_MIN), FADE_MAX, len(wav))
fo = min(max(len(wav) - last, FADE_MIN), FADE_MAX, len(wav))
wav[:fi] *= _cos_ramp(fi)
wav[-fo:] *= _cos_ramp(fo)[::-1]
return wav
def room_tone(wav: np.ndarray) -> np.ndarray:
"""Самое тихое 120-мс окно клипа (шаблон спектра); тихих нет — глушим до -46 dBFS."""
win = 2 * TONE_WIN
if len(wav) < 2 * win:
return np.zeros(win, dtype=np.float32)
hop = win // 2
n = (len(wav) - win) // hop
view = np.lib.stride_tricks.sliding_window_view(wav, win)[::hop][:n]
rms = np.sqrt((view ** 2).mean(axis=1))
k = int(np.argmin(rms))
tone = view[k].copy()
if rms[k] > 5e-3:
tone *= 5e-3 / rms[k]
return tone
_N_FFT = 512
_HOP = 256
_HANN = np.hanning(_N_FFT).astype(np.float32)
def synth_tone(template: np.ndarray, length: int, seed: int) -> np.ndarray:
"""Стационарный шум со спектральной огибающей шаблона — БЕЗ зацикливания.
v3 первой версии тайлил 60-мс окно зеркально: период ~8 Гц слышен как
«вертолёт», модель выучила текстуру (жалоба юзера). Здесь — средняя
STFT-магнитуда шаблона + случайные фазы на каждый кадр + overlap-add:
цвет комнаты сохранён, повторов нет в принципе.
"""
if length <= 0:
return np.zeros(0, dtype=np.float32)
rng = np.random.default_rng(seed)
if len(template) < _N_FFT or not np.any(template):
return np.zeros(length, dtype=np.float32)
n = (len(template) - _N_FFT) // _HOP + 1
frames = np.lib.stride_tricks.sliding_window_view(template, _N_FFT)[::_HOP][:n]
mag = np.abs(np.fft.rfft(frames * _HANN, axis=1)).mean(axis=0)
n_frames = length // _HOP + 3
phases = rng.uniform(0, 2 * np.pi, size=(n_frames, len(mag)))
sig_frames = np.fft.irfft(mag * np.exp(1j * phases), n=_N_FFT, axis=1).real
sig_frames *= _HANN
out = np.zeros(n_frames * _HOP + _N_FFT, dtype=np.float32)
for i in range(n_frames): # OLA (hann, hop=1/2 -> COLA)
out[i * _HOP:i * _HOP + _N_FFT] += sig_frames[i]
out = out[_N_FFT: _N_FFT + length]
t_rms = float(np.sqrt((template ** 2).mean()))
o_rms = float(np.sqrt((out ** 2).mean())) + 1e-12
return (out * (t_rms / o_rms)).astype(np.float32)
def fill_tone(buf: np.ndarray, start: int, end: int, tone: np.ndarray, seed: int):
"""Заполняет [start:end) синтезированным комнатным тоном с 5-мс рампами."""
if end <= start or not len(tone):
return
buf[start:end] = synth_tone(tone, end - start, seed)
r = min(int(0.005 * SR), (end - start) // 2)
if r > 0:
buf[start:start + r] *= _cos_ramp(r)
buf[end - r:end] *= _cos_ramp(r)[::-1]
def write_group(task):
gi, g = task
out = GROUP_DIR / f"g{gi:06d}.wav"
if not OVERWRITE and out.exists():
try:
if sf.info(str(out)).frames == int(55 * SR):
return gi, str(out), "skip"
except Exception:
out.unlink(missing_ok=True)
buf = np.zeros(int(55 * SR), dtype=np.float32)
pos = 0
try:
tone = np.zeros(2 * TONE_WIN, dtype=np.float32)
for j, (path, samples, slot) in enumerate(zip(g["paths"], g["samples"], g["slots"])):
wav, sr = sf.read(path, dtype="float32")
assert sr == SR, f"{path}: sr={sr}"
if wav.ndim > 1:
wav = wav.mean(axis=1)
wav = wav[:samples].copy()
if LUFS_ON:
wav *= lufs_gain(wav)
wav = adaptive_fades(wav)
buf[pos:pos + len(wav)] = wav
tone = room_tone(wav)
fill_tone(buf, pos + len(wav), pos + slot, tone, 100_000 + gi * 64 + j)
pos += slot
fill_tone(buf, pos, len(buf), tone, 100_000 + gi * 64 + 63) # хвост группы
sf.write(out, buf, SR, subtype="PCM_16")
return gi, str(out), "ok"
except Exception as e: # noqa: BLE001
out.unlink(missing_ok=True)
return gi, "", f"fail:{e}"
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--plan-only", action="store_true")
ap.add_argument("--workers", type=int, default=24)
ap.add_argument("--chunk-size", type=int, default=70_000)
ap.add_argument("--overwrite", action="store_true",
help="перерендерить wav даже если файл на месте (план не меняется)")
global OVERWRITE, MFA_OUT, GROUP_DIR
ap.add_argument("--mfa-out", default="mfa_out", help="каталог TextGrid (v4: mfa_out_v4)")
ap.add_argument("--out-groups", default="groups_v3.json")
ap.add_argument("--group-dir", default="/mnt/data/audio_data/voxtream_ru/_groups55_v3")
ap.add_argument("--parquet-prefix", default="grouped_v3_chunk")
ap.add_argument("--tempo-buckets", action="store_true",
help="v7: группы однородны по темпу (clip_sps.npy)")
ap.add_argument("--clip-sps", default="clip_sps.npy",
help="файл SPS по клипам для темпо-корзин")
ap.add_argument("--lufs", action="store_true",
help="v10: каждый клип -> -23 LUFS при рендере")
args = ap.parse_args()
global LUFS_ON
OVERWRITE = args.overwrite
LUFS_ON = args.lufs
MFA_OUT = ROOT / args.mfa_out
GROUP_DIR = Path(args.group_dir)
if LUFS_ON:
print(f"LUFS-нормализация ВКЛ: клипы -> {TARGET_LUFS} LUFS")
load_hist()
print("скан выравниваний…", flush=True)
aligned = aligned_indices()
print(f"выровненных клипов: {len(aligned)}", flush=True)
df = pd.read_csv(ROOT / "manifest.csv", sep="|", low_memory=False)
clip_sps = None
if args.tempo_buckets:
clip_sps = np.load(ROOT / args.clip_sps)
print(f"темпо-корзины ВКЛ: границы {TEMPO_EDGES} слог/с")
groups = pack_groups(df, aligned, clip_sps=clip_sps)
total_h = sum(g["total"] for g in groups) / SR / 3600
used = len({i for g in groups for i in g["idx"]})
print(f"групп: {len(groups)}, уникальных клипов: {used}/{len(df)}, ~{total_h:.1f} ч слотов")
json.dump(groups, open(ROOT / args.out_groups, "w"), ensure_ascii=False)
if args.plan_only:
return
GROUP_DIR.mkdir(parents=True, exist_ok=True)
fails = 0
with mp.Pool(args.workers) as pool:
for gi, path, status in pool.imap(write_group, enumerate(groups), chunksize=16):
groups[gi]["group_wav"] = path
fails += status.startswith("fail")
if (gi + 1) % 10_000 == 0:
print(f" {gi + 1}/{len(groups)} (fail={fails})", flush=True)
groups = [g for g in groups if g.get("group_wav")]
json.dump(groups, open(ROOT / args.out_groups, "w"), ensure_ascii=False)
print(f"записано групп: {len(groups)} (fail={fails})")
paths = [g["group_wav"] for g in groups]
for ci in range(0, len(paths), args.chunk_size):
p = ROOT / f"{args.parquet_prefix}{ci // args.chunk_size}.parquet"
pd.DataFrame({"paths": paths[ci:ci + args.chunk_size]}).to_parquet(p, index=False)
print(f"{p.name}: {min(args.chunk_size, len(paths) - ci)} путей")
if __name__ == "__main__":
main()