Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
352296b84a | ||
|
|
26d4ca2f4a | ||
|
|
bbc2bdacfe | ||
|
|
99ddf77af3 | ||
|
|
b8092aade5 | ||
|
|
0fdeebb256 | ||
|
|
8c55eaa87f | ||
|
|
388de09c42 | ||
|
|
4c7f2920c1 | ||
|
|
1cb6a36c92 | ||
|
|
87030e9718 | ||
|
|
a65cb74d88 | ||
|
|
7e348e400f | ||
|
|
3378aac474 | ||
|
|
668faf57e2 | ||
|
|
9ae013ad0f | ||
|
|
dae9d107c9 | ||
|
|
9f11c32979 | ||
|
|
995dbe61f8 | ||
|
|
136e93765c | ||
|
|
12e17020bf | ||
|
|
f043f4e3b5 | ||
|
|
a65e5039f5 | ||
|
|
2e69c2f33d | ||
|
|
88e25c983c | ||
|
|
6f2dbf8e1c |
+1
-2
@@ -5,5 +5,4 @@ __pycache__/
|
||||
dist/
|
||||
*.pyc
|
||||
.codex
|
||||
.qwen/
|
||||
.scratch/
|
||||
.qwen/
|
||||
@@ -0,0 +1,6 @@
|
||||
# модели диаризации — 33 МБ, скачиваются по README
|
||||
models/
|
||||
|
||||
# выход замеров
|
||||
segments-*.tsv
|
||||
conflict-*.json
|
||||
@@ -0,0 +1,65 @@
|
||||
# Обвязка замеров диаризации
|
||||
|
||||
Исследовательские скрипты для карты
|
||||
[Карта: диаризация спикеров в транскрипте](https://git.dementev.space/ddmitry/local-transcriber/issues/8) (#8).
|
||||
Не часть пакета: они опираются на `sherpa-onnx`, которого нет в зависимостях
|
||||
проекта, и живут в `.scratch/`, а не в `src/`.
|
||||
|
||||
Результаты первого прогона описаны в
|
||||
[разведочном замере](../../docs/benchmarks/2026-08-12-diarization-feasibility.md).
|
||||
|
||||
## Модели
|
||||
|
||||
Скачиваются один раз в `models/`, в git не попадают (см. `.gitignore` рядом).
|
||||
|
||||
```bash
|
||||
mkdir -p models && cd models
|
||||
curl -sSL -O https://github.com/k2-fsa/sherpa-onnx/releases/download/speaker-segmentation-models/sherpa-onnx-pyannote-segmentation-3-0.tar.bz2
|
||||
tar xjf sherpa-onnx-pyannote-segmentation-3-0.tar.bz2
|
||||
curl -sSL -O https://github.com/k2-fsa/sherpa-onnx/releases/download/speaker-recongition-models/wespeaker_en_voxceleb_resnet34_LM.onnx
|
||||
```
|
||||
|
||||
Сегментация — 6,9 МБ, эмбеддинги — 26,5 МБ. Опечатка `recongition` в URL
|
||||
относится к самому релизу sherpa-onnx, это не ошибка набора.
|
||||
|
||||
## Скрипты
|
||||
|
||||
| Скрипт | Что делает | Тикеты |
|
||||
|---|---|---|
|
||||
| `bench_asr.py` | ASR тем же путём, что CLI: время, RTF, память | #13 |
|
||||
| `bench_diar.py` | один прогон диаризации, сохраняет разметку в `segments-<порог>.tsv` | #12, #13 |
|
||||
| `bench_sweep.py` | свип порога кластеризации и явного числа говорящих | #10 |
|
||||
| `bench_conflict.py` | доля ASR-сегментов, внутри которых меняется говорящий | #11 |
|
||||
| `common.py` | пути, конфигурация диаризатора, замер памяти | — |
|
||||
|
||||
## Запуск
|
||||
|
||||
Из корня репозитория. `PYTHONIOENCODING=utf-8` нужен, иначе вывод падает на
|
||||
консоли cp1251.
|
||||
|
||||
```bash
|
||||
export PYTHONIOENCODING=utf-8
|
||||
|
||||
uv run python .scratch/diarization/bench_asr.py "<путь к записи>"
|
||||
uv run --with sherpa-onnx python .scratch/diarization/bench_diar.py "<путь>" 8 0.89
|
||||
uv run --with sherpa-onnx python .scratch/diarization/bench_sweep.py "<путь>"
|
||||
uv run --with sherpa-onnx python .scratch/diarization/bench_conflict.py "<путь>"
|
||||
```
|
||||
|
||||
## Что стоит знать до запуска
|
||||
|
||||
- **Порог кластеризации откалиброван.** По умолчанию стоит 0,89 — единственное
|
||||
проверенное значение, которое без знания числа участников дало правильные
|
||||
3 / 2 / 2 кластера на трёх калибровочных фрагментах. Решение и ограничения
|
||||
описаны в
|
||||
[отчёте о калибровке](../../docs/benchmarks/2026-08-14-diarization-calibration.md).
|
||||
- **Свип дорогой.** Каждая конфигурация — полный прогон сегментации и
|
||||
эмбеддингов, около 2,5 минут на 26-минутную запись, и время от настроек
|
||||
кластеризации практически не зависит. Свип вести на коротком фрагменте.
|
||||
- **Чистота сегментов меряется относительно диаризации.** Если её границы
|
||||
систематически смещены, метрика измеряет не то, что кажется. Проверка границ
|
||||
на слух — тикет #12, и он намеренно идёт до калибровки.
|
||||
- **Замер памяти чинился.** В разведке `psapi.GetProcessMemoryInfo` молча
|
||||
возвращал ноль; `common.peak_rss_mb()` теперь зовёт `K32GetProcessMemoryInfo`
|
||||
из kernel32 и проверяет код возврата. На Linux и macOS используется
|
||||
`resource.getrusage()` с поправкой на разные единицы измерения.
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Замер ASR тем же путём, что использует CLI — для соотношения с диаризацией.
|
||||
|
||||
uv run python .scratch/diarization/bench_asr.py <файл> [модель]
|
||||
|
||||
sherpa-onnx здесь не нужен: скрипт зовёт бэкенд проекта напрямую.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from common import peak_rss_mb, use_project_sources
|
||||
|
||||
use_project_sources()
|
||||
|
||||
from local_transcriber.backends.onnx_asr import OnnxAsrBackend # noqa: E402
|
||||
|
||||
DEFAULT_MODEL = "gigaam-v3-e2e-rnnt"
|
||||
COMPUTE_TYPE = "int8"
|
||||
|
||||
|
||||
def main(audio_path: str, model_name: str) -> None:
|
||||
backend = OnnxAsrBackend(compute_type_explicit=False)
|
||||
|
||||
t0 = time.perf_counter()
|
||||
model_path = backend.ensure_model_available(model_name, COMPUTE_TYPE)
|
||||
model = backend.create_model(model_path, "onnx", COMPUTE_TYPE)
|
||||
t_load = time.perf_counter() - t0
|
||||
|
||||
t0 = time.perf_counter()
|
||||
result = backend.transcribe(model, Path(audio_path), "ru")
|
||||
t_asr = time.perf_counter() - t0
|
||||
rss = peak_rss_mb()
|
||||
|
||||
print(f"файл: {audio_path}")
|
||||
print(f"модель: {model_name} ({COMPUTE_TYPE})")
|
||||
print(f"длительность: {result.duration / 60:.1f} мин")
|
||||
print(f"загрузка модели: {t_load:.1f} с")
|
||||
print(f"ASR: {t_asr:.1f} с -> {result.duration / t_asr:.1f}x RTF")
|
||||
print(f"пиковая память процесса: {rss:.0f} МБ" if rss else "память: снять не удалось")
|
||||
print(f"сегментов: {len(result.segments)}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 2:
|
||||
raise SystemExit(__doc__)
|
||||
main(sys.argv[1], sys.argv[2] if len(sys.argv) > 2 else DEFAULT_MODEL)
|
||||
@@ -0,0 +1,161 @@
|
||||
"""Чистота ASR-сегментов: как часто внутри одного сегмента меняется говорящий.
|
||||
|
||||
uv run --with sherpa-onnx python .scratch/diarization/bench_conflict.py <файл>
|
||||
|
||||
Прогоняет ASR и диаризацию по одному файлу и считает, какая доля ASR-сегментов
|
||||
содержит чужую речь. Это мера того, насколько огрубляет привязка спикера к
|
||||
целому сегменту по мажоритарному перекрытию.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
from common import (
|
||||
DEFAULT_THREADS,
|
||||
DISCOVERY_THRESHOLD,
|
||||
HERE,
|
||||
load_audio,
|
||||
make_diarizer,
|
||||
use_project_sources,
|
||||
)
|
||||
|
||||
use_project_sources()
|
||||
|
||||
ASR_MODEL = "gigaam-v3-e2e-rnnt"
|
||||
COMPUTE_TYPE = "int8"
|
||||
|
||||
# чужая речь короче порога — поддакивание, дольше — потерянная реплика
|
||||
INTERJECTION_S = 1.0
|
||||
|
||||
PURITY_LEVELS = (0.95, 0.90, 0.80, 0.70)
|
||||
|
||||
|
||||
def run_asr(audio_path: str):
|
||||
from local_transcriber.backends.onnx_asr import OnnxAsrBackend
|
||||
|
||||
backend = OnnxAsrBackend(compute_type_explicit=False)
|
||||
path = backend.ensure_model_available(ASR_MODEL, COMPUTE_TYPE)
|
||||
model = backend.create_model(path, "onnx", COMPUTE_TYPE)
|
||||
t0 = time.perf_counter()
|
||||
result = backend.transcribe(model, Path(audio_path), "ru")
|
||||
print(f"ASR: {time.perf_counter() - t0:.0f} с, {len(result.segments)} сегм.")
|
||||
return result
|
||||
|
||||
|
||||
def run_diar(samples, threshold: float, threads: int):
|
||||
diarizer = make_diarizer(threshold=threshold, threads=threads)
|
||||
t0 = time.perf_counter()
|
||||
segments = diarizer.process(samples).sort_by_start_time()
|
||||
print(f"диаризация: {time.perf_counter() - t0:.0f} с, {len(segments)} интервалов")
|
||||
return [(s.start, s.end, s.speaker) for s in segments]
|
||||
|
||||
|
||||
def main(audio_path: str, threshold: float, threads: int) -> None:
|
||||
samples = load_audio(audio_path)
|
||||
asr = run_asr(audio_path)
|
||||
diar = run_diar(samples, threshold, threads)
|
||||
print(f"речи по диаризации: {sum(e - s for s, e, _ in diar) / 60:.1f} мин\n")
|
||||
|
||||
rows = []
|
||||
for seg in asr.segments:
|
||||
per_speaker: dict[int, float] = defaultdict(float)
|
||||
for start, end, speaker in diar:
|
||||
overlap = min(seg.end, end) - max(seg.start, start)
|
||||
if overlap > 0:
|
||||
per_speaker[speaker] += overlap
|
||||
total = sum(per_speaker.values())
|
||||
if total <= 0:
|
||||
rows.append((seg, None, 0.0, 0.0, {}))
|
||||
continue
|
||||
major = max(per_speaker, key=lambda k: per_speaker[k])
|
||||
rows.append(
|
||||
(
|
||||
seg,
|
||||
major,
|
||||
per_speaker[major] / total,
|
||||
total - per_speaker[major],
|
||||
dict(per_speaker),
|
||||
)
|
||||
)
|
||||
|
||||
n = len(rows)
|
||||
unattributed = [r for r in rows if r[1] is None]
|
||||
attributed = [r for r in rows if r[1] is not None]
|
||||
lost = [r for r in attributed if r[3] >= INTERJECTION_S]
|
||||
interjection = [r for r in attributed if 0 < r[3] < INTERJECTION_S]
|
||||
clean = [r for r in attributed if r[3] == 0]
|
||||
|
||||
def minutes(rs) -> float:
|
||||
return sum(r[0].end - r[0].start for r in rs) / 60
|
||||
|
||||
print("=" * 64)
|
||||
print(f"ASR-сегментов: {n} ({minutes(rows):.1f} мин)\n")
|
||||
for label, group in (
|
||||
("чистых (один говорящий)", clean),
|
||||
(f"с поддакиванием (<{INTERJECTION_S:.0f} с чужой)", interjection),
|
||||
(f"с чужой репликой (>={INTERJECTION_S:.0f} с)", lost),
|
||||
("без говорящего вообще", unattributed),
|
||||
):
|
||||
print(
|
||||
f" {label:<34} {len(group):4d} {len(group) / n * 100:5.1f}% {minutes(group):5.1f} мин"
|
||||
)
|
||||
|
||||
print()
|
||||
for level in PURITY_LEVELS:
|
||||
bad = [r for r in attributed if r[2] < level]
|
||||
print(
|
||||
f" чистота мажоритарного < {level:.2f}: {len(bad):4d} сегм. "
|
||||
f"({len(bad) / n * 100:.1f}%), {minutes(bad):.1f} мин"
|
||||
)
|
||||
|
||||
print("\n" + "=" * 64)
|
||||
print("ХУДШИЕ 12 СЕГМЕНТОВ (больше всего чужой речи внутри):")
|
||||
for seg, major, purity, others, per_speaker in sorted(
|
||||
attributed, key=lambda r: -r[3]
|
||||
)[:12]:
|
||||
share = ", ".join(
|
||||
f"spk{k}={v:.1f}с"
|
||||
for k, v in sorted(per_speaker.items(), key=lambda x: -x[1])
|
||||
)
|
||||
print(
|
||||
f"\n [{seg.start:7.1f}-{seg.end:7.1f}] ({seg.end - seg.start:4.1f} с) "
|
||||
f"мажор spk{major}, чистота {purity:.2f}, чужой {others:.1f} с"
|
||||
)
|
||||
print(f" {share}")
|
||||
print(f" «{seg.text.strip()[:150]}»")
|
||||
|
||||
out = HERE / f"conflict-{Path(audio_path).stem[:40]}.json"
|
||||
out.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"file": Path(audio_path).name,
|
||||
"threshold": threshold,
|
||||
"asr_segments": n,
|
||||
"clean": len(clean),
|
||||
"interjection": len(interjection),
|
||||
"lost_utterance": len(lost),
|
||||
"unattributed": len(unattributed),
|
||||
"minutes_lost_utterance": round(minutes(lost), 2),
|
||||
"minutes_total": round(minutes(rows), 2),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
print(f"\nсводка сохранена: {out.name}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 2:
|
||||
raise SystemExit(__doc__)
|
||||
main(
|
||||
sys.argv[1],
|
||||
float(sys.argv[2]) if len(sys.argv) > 2 else DISCOVERY_THRESHOLD,
|
||||
int(sys.argv[3]) if len(sys.argv) > 3 else DEFAULT_THREADS,
|
||||
)
|
||||
@@ -0,0 +1,87 @@
|
||||
"""Один прогон диаризации: скорость, память, распределение по говорящим.
|
||||
|
||||
uv run --with sherpa-onnx python .scratch/diarization/bench_diar.py <файл> [потоки]
|
||||
|
||||
Сохраняет разметку в ``segments-<порог>.tsv`` рядом со скриптом — она нужна
|
||||
тикету про проверку границ на слух и скрипту bench_conflict.py.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
|
||||
from common import (
|
||||
DEFAULT_THREADS,
|
||||
DISCOVERY_THRESHOLD,
|
||||
HERE,
|
||||
SAMPLE_RATE,
|
||||
load_audio,
|
||||
make_diarizer,
|
||||
peak_rss_mb,
|
||||
)
|
||||
|
||||
|
||||
def main(audio_path: str, threads: int, threshold: float) -> None:
|
||||
print(f"файл: {audio_path}")
|
||||
print(f"потоков: {threads}, порог кластеризации: {threshold}")
|
||||
|
||||
t0 = time.perf_counter()
|
||||
samples = load_audio(audio_path)
|
||||
t_decode = time.perf_counter() - t0
|
||||
duration = len(samples) / SAMPLE_RATE
|
||||
print(f"длительность: {duration / 60:.1f} мин ({duration:.0f} с)")
|
||||
print(f"декодирование: {t_decode:.1f} с ({duration / t_decode:.0f}x RTF)")
|
||||
|
||||
t0 = time.perf_counter()
|
||||
diarizer = make_diarizer(threshold=threshold, threads=threads)
|
||||
print(f"инициализация моделей: {time.perf_counter() - t0:.1f} с")
|
||||
|
||||
progress = {"shown": 0.0}
|
||||
t_start = time.perf_counter()
|
||||
|
||||
def on_progress(processed: int, total: int, _arg=None) -> int:
|
||||
pct = processed / total * 100
|
||||
if pct - progress["shown"] >= 20:
|
||||
progress["shown"] = pct
|
||||
print(f" ... {pct:.0f}% ({time.perf_counter() - t_start:.0f} с)", flush=True)
|
||||
return 0
|
||||
|
||||
segments = diarizer.process(samples, callback=on_progress).sort_by_start_time()
|
||||
t_diar = time.perf_counter() - t_start
|
||||
|
||||
speakers = sorted({s.speaker for s in segments})
|
||||
speech = sum(s.end - s.start for s in segments)
|
||||
rss = peak_rss_mb()
|
||||
|
||||
print()
|
||||
print(f"ДИАРИЗАЦИЯ: {t_diar:.1f} с -> {duration / t_diar:.1f}x RTF")
|
||||
print(f"пиковая память процесса: {rss:.0f} МБ" if rss else "память: снять не удалось")
|
||||
print(f"спикеров: {len(speakers)}, интервалов: {len(segments)}")
|
||||
print(f"речи: {speech / 60:.1f} мин ({speech / duration * 100:.0f}% файла)")
|
||||
print()
|
||||
print("распределение по говорящим:")
|
||||
for spk in speakers:
|
||||
own = [s for s in segments if s.speaker == spk]
|
||||
total = sum(s.end - s.start for s in own)
|
||||
median = np.median([s.end - s.start for s in own])
|
||||
print(f" spk{spk:<3} {total / 60:6.1f} мин {len(own):4d} интерв. медиана {median:.1f} с")
|
||||
|
||||
out = HERE / f"segments-{threshold}.tsv"
|
||||
out.write_text(
|
||||
"\n".join(f"{s.start:.3f}\t{s.end:.3f}\t{s.speaker}" for s in segments),
|
||||
encoding="utf-8",
|
||||
)
|
||||
print(f"\nразметка сохранена: {out.name}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 2:
|
||||
raise SystemExit(__doc__)
|
||||
main(
|
||||
sys.argv[1],
|
||||
int(sys.argv[2]) if len(sys.argv) > 2 else DEFAULT_THREADS,
|
||||
float(sys.argv[3]) if len(sys.argv) > 3 else DISCOVERY_THRESHOLD,
|
||||
)
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Свип порога кластеризации и явного числа говорящих.
|
||||
|
||||
uv run --with sherpa-onnx python .scratch/diarization/bench_sweep.py <файл> [потоки]
|
||||
|
||||
Каждая конфигурация — полный прогон сегментации и эмбеддингов (около 2,5 минут
|
||||
на 26-минутную запись), поэтому свип имеет смысл вести на коротком фрагменте, а
|
||||
полные записи оставить для проверки финального кандидата.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import time
|
||||
|
||||
from common import DEFAULT_THREADS, SAMPLE_RATE, load_audio, make_diarizer
|
||||
|
||||
# подпись, num_clusters, threshold
|
||||
CONFIGS = [
|
||||
("авто, порог 0.5", -1, 0.5),
|
||||
("авто, порог 0.7", -1, 0.7),
|
||||
("авто, порог 0.9", -1, 0.9),
|
||||
("явно k=5", 5, 0.5),
|
||||
]
|
||||
|
||||
# говорящий с речью короче порога считается остаточным кластером, не участником
|
||||
MIN_SPEAKER_S = 30.0
|
||||
|
||||
|
||||
def main(audio_path: str, threads: int) -> None:
|
||||
samples = load_audio(audio_path)
|
||||
duration = len(samples) / SAMPLE_RATE
|
||||
print(f"файл: {audio_path}")
|
||||
print(f"длительность: {duration / 60:.1f} мин, потоков: {threads}\n")
|
||||
|
||||
for label, num_clusters, threshold in CONFIGS:
|
||||
diarizer = make_diarizer(
|
||||
threshold=threshold, num_clusters=num_clusters, threads=threads
|
||||
)
|
||||
t0 = time.perf_counter()
|
||||
segments = diarizer.process(samples).sort_by_start_time()
|
||||
elapsed = time.perf_counter() - t0
|
||||
|
||||
totals: dict[int, float] = {}
|
||||
for seg in segments:
|
||||
totals[seg.speaker] = totals.get(seg.speaker, 0.0) + (seg.end - seg.start)
|
||||
real = [spk for spk, t in totals.items() if t >= MIN_SPEAKER_S]
|
||||
top = sorted(totals.values(), reverse=True)[:8]
|
||||
|
||||
print(f"--- {label}")
|
||||
print(
|
||||
f" {elapsed:.0f} с ({duration / elapsed:.1f}x RTF), "
|
||||
f"говорящих: {len(totals)}, из них >= {MIN_SPEAKER_S:.0f} с речи: {len(real)}, "
|
||||
f"интервалов: {len(segments)}"
|
||||
)
|
||||
print(" топ по времени (мин): " + ", ".join(f"{t / 60:.1f}" for t in top))
|
||||
print()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 2:
|
||||
raise SystemExit(__doc__)
|
||||
main(sys.argv[1], int(sys.argv[2]) if len(sys.argv) > 2 else DEFAULT_THREADS)
|
||||
@@ -0,0 +1,143 @@
|
||||
"""Общая обвязка для замеров диаризации.
|
||||
|
||||
Скрипты в этом каталоге — исследовательские, не часть пакета. Они опираются на
|
||||
``sherpa-onnx``, которого нет в зависимостях проекта, поэтому запускаются через
|
||||
``uv run --with sherpa-onnx``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ctypes
|
||||
import ctypes.wintypes as wt
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
REPO_ROOT = HERE.parents[1]
|
||||
MODELS = HERE / "models"
|
||||
|
||||
SEGMENTATION = MODELS / "sherpa-onnx-pyannote-segmentation-3-0" / "model.onnx"
|
||||
EMBEDDING = MODELS / "wespeaker_en_voxceleb_resnet34_LM.onnx"
|
||||
|
||||
SAMPLE_RATE = 16_000
|
||||
|
||||
# Конфигурация, выбранная калибровкой 2026-08-14 на трёх записях.
|
||||
# На 0.5 из примеров sherpa-onnx получалось 29 говорящих вместо трёх.
|
||||
DISCOVERY_THRESHOLD = 0.89
|
||||
DEFAULT_THREADS = 8
|
||||
|
||||
|
||||
def use_project_sources() -> None:
|
||||
"""Делает пакет проекта импортируемым без установки."""
|
||||
src = str(REPO_ROOT / "src")
|
||||
if src not in sys.path:
|
||||
sys.path.insert(0, src)
|
||||
|
||||
|
||||
def require_models() -> None:
|
||||
"""Останавливает запуск с внятным сообщением, если модели не скачаны."""
|
||||
missing = [p for p in (SEGMENTATION, EMBEDDING) if not p.exists()]
|
||||
if missing:
|
||||
names = "\n ".join(str(p) for p in missing)
|
||||
raise SystemExit(
|
||||
f"Не найдены модели диаризации:\n {names}\n\n"
|
||||
"Скачайте их по инструкции из README.md в этом каталоге."
|
||||
)
|
||||
|
||||
|
||||
class _ProcessMemoryCounters(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("cb", wt.DWORD),
|
||||
("PageFaultCount", wt.DWORD),
|
||||
("PeakWorkingSetSize", ctypes.c_size_t),
|
||||
("WorkingSetSize", ctypes.c_size_t),
|
||||
("QuotaPeakPagedPoolUsage", ctypes.c_size_t),
|
||||
("QuotaPagedPoolUsage", ctypes.c_size_t),
|
||||
("QuotaPeakNonPagedPoolUsage", ctypes.c_size_t),
|
||||
("QuotaNonPagedPoolUsage", ctypes.c_size_t),
|
||||
("PagefileUsage", ctypes.c_size_t),
|
||||
("PeakPagefileUsage", ctypes.c_size_t),
|
||||
]
|
||||
|
||||
|
||||
def peak_rss_mb() -> float | None:
|
||||
"""Пиковая рабочая память процесса в МБ; None, если снять не удалось.
|
||||
|
||||
На Linux ``ru_maxrss`` измеряется в КиБ, на macOS — в байтах. В Windows
|
||||
используются системные счётчики процесса.
|
||||
|
||||
Два подвоха, на которых замер в разведке 2026-08-12 вернул ноль в Windows:
|
||||
экспорт на современных Windows живёт в kernel32 как
|
||||
``K32GetProcessMemoryInfo``, а без явных ``restype``/``argtypes``
|
||||
псевдодескриптор процесса уезжает в вызов как 32-битное число и функция
|
||||
молча не срабатывает.
|
||||
"""
|
||||
if sys.platform != "win32":
|
||||
import resource
|
||||
|
||||
peak = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
|
||||
divisor = 1024 * 1024 if sys.platform == "darwin" else 1024
|
||||
return peak / divisor
|
||||
|
||||
kernel32 = ctypes.windll.kernel32
|
||||
kernel32.GetCurrentProcess.restype = ctypes.c_void_p
|
||||
handle = kernel32.GetCurrentProcess()
|
||||
|
||||
pmc = _ProcessMemoryCounters()
|
||||
pmc.cb = ctypes.sizeof(_ProcessMemoryCounters)
|
||||
|
||||
for dll, name in (
|
||||
(kernel32, "K32GetProcessMemoryInfo"),
|
||||
(ctypes.windll.psapi, "GetProcessMemoryInfo"),
|
||||
):
|
||||
func = getattr(dll, name, None)
|
||||
if func is None:
|
||||
continue
|
||||
func.argtypes = [
|
||||
ctypes.c_void_p,
|
||||
ctypes.POINTER(_ProcessMemoryCounters),
|
||||
wt.DWORD,
|
||||
]
|
||||
func.restype = wt.BOOL
|
||||
if func(handle, ctypes.byref(pmc), pmc.cb):
|
||||
return pmc.PeakWorkingSetSize / 1024 / 1024
|
||||
return None
|
||||
|
||||
|
||||
def load_audio(audio_path: str | Path):
|
||||
"""Декодирует файл в моно 16 кГц — тот же путь, что использует ONNX-бэкенд."""
|
||||
from faster_whisper import decode_audio
|
||||
|
||||
return decode_audio(str(audio_path), sampling_rate=SAMPLE_RATE)
|
||||
|
||||
|
||||
def make_diarizer(
|
||||
threshold: float = DISCOVERY_THRESHOLD,
|
||||
num_clusters: int = -1,
|
||||
threads: int = DEFAULT_THREADS,
|
||||
) -> Any:
|
||||
"""Собирает OfflineSpeakerDiarization с параметрами разведки."""
|
||||
import sherpa_onnx as so
|
||||
|
||||
require_models()
|
||||
config = so.OfflineSpeakerDiarizationConfig(
|
||||
segmentation=so.OfflineSpeakerSegmentationModelConfig(
|
||||
pyannote=so.OfflineSpeakerSegmentationPyannoteModelConfig(
|
||||
model=str(SEGMENTATION)
|
||||
),
|
||||
num_threads=threads,
|
||||
provider="cpu",
|
||||
),
|
||||
embedding=so.SpeakerEmbeddingExtractorConfig(
|
||||
model=str(EMBEDDING), num_threads=threads, provider="cpu"
|
||||
),
|
||||
clustering=so.FastClusteringConfig(
|
||||
num_clusters=num_clusters, threshold=threshold
|
||||
),
|
||||
min_duration_on=0.3,
|
||||
min_duration_off=0.5,
|
||||
)
|
||||
diarizer = so.OfflineSpeakerDiarization(config)
|
||||
assert diarizer.sample_rate == SAMPLE_RATE, diarizer.sample_rate
|
||||
return diarizer
|
||||
@@ -0,0 +1,326 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>PROTOTYPE — формат транскрипта со спикерами</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--paper: #fbfaf7;
|
||||
--ink: #25221f;
|
||||
--muted: #746e67;
|
||||
--line: #ddd7ce;
|
||||
--accent: #9b3f2f;
|
||||
--code: #f0ece5;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
background: #e9e4dc;
|
||||
color: var(--ink);
|
||||
font: 16px/1.58 system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
}
|
||||
main {
|
||||
width: min(1180px, calc(100% - 32px));
|
||||
margin: 28px auto 100px;
|
||||
}
|
||||
.prototype-note {
|
||||
margin-bottom: 18px;
|
||||
padding: 12px 16px;
|
||||
border: 1px dashed #a59d92;
|
||||
background: #fffdf8;
|
||||
color: #5f5850;
|
||||
}
|
||||
.prototype-note strong { color: var(--accent); }
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(340px, .72fr);
|
||||
gap: 18px;
|
||||
align-items: start;
|
||||
}
|
||||
.paper, .source-panel {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
background: var(--paper);
|
||||
box-shadow: 0 8px 28px rgb(49 40 31 / 9%);
|
||||
}
|
||||
.paper { padding: clamp(22px, 4vw, 54px); }
|
||||
.source-panel { position: sticky; top: 18px; overflow: hidden; }
|
||||
.source-panel h2 {
|
||||
margin: 0;
|
||||
padding: 13px 16px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
letter-spacing: .06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
pre {
|
||||
max-height: calc(100vh - 120px);
|
||||
margin: 0;
|
||||
padding: 18px;
|
||||
overflow: auto;
|
||||
background: var(--code);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font: 13px/1.55 ui-monospace, "Cascadia Code", Consolas, monospace;
|
||||
}
|
||||
h1 { margin: 0 0 22px; font: 700 clamp(26px, 3vw, 38px)/1.15 Georgia, serif; }
|
||||
h2 { margin: 28px 0 12px; font-size: 20px; }
|
||||
h3 { margin: 25px 0 7px; font-size: 17px; }
|
||||
ul { padding-left: 21px; }
|
||||
hr { margin: 28px 0; border: 0; border-top: 1px solid var(--line); }
|
||||
.turn { margin: 18px 0; }
|
||||
.time { color: var(--muted); font: 13px ui-monospace, "Cascadia Code", monospace; }
|
||||
.speaker { color: #783427; }
|
||||
blockquote {
|
||||
margin: 8px 0 22px;
|
||||
padding: 10px 16px;
|
||||
border-left: 4px solid #b88772;
|
||||
background: #f5f0e9;
|
||||
}
|
||||
blockquote p { margin: 0; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 14px; }
|
||||
th, td { padding: 9px 8px; border: 1px solid var(--line); vertical-align: top; text-align: left; }
|
||||
th { background: #eee8de; }
|
||||
.warning {
|
||||
margin: 17px 0;
|
||||
padding: 10px 13px;
|
||||
border-left: 4px solid #c47b23;
|
||||
background: #fff2dc;
|
||||
}
|
||||
.overlap { background: #f7e9e4; }
|
||||
.switcher {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
bottom: 22px;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
transform: translateX(-50%);
|
||||
padding: 7px;
|
||||
border: 1px solid rgb(255 255 255 / 30%);
|
||||
border-radius: 999px;
|
||||
background: #201d1a;
|
||||
box-shadow: 0 8px 32px rgb(0 0 0 / 25%);
|
||||
color: white;
|
||||
}
|
||||
.switcher button {
|
||||
width: 38px;
|
||||
height: 34px;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: #39332e;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
font-size: 20px;
|
||||
}
|
||||
.switcher button:hover { background: #554b43; }
|
||||
#variant-label { min-width: 230px; text-align: center; font-size: 14px; }
|
||||
@media (max-width: 820px) {
|
||||
.layout { grid-template-columns: 1fr; }
|
||||
.source-panel { position: static; }
|
||||
pre { max-height: none; }
|
||||
#variant-label { min-width: 190px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Три варианта markdown-транскрипта, переключаемые через ?variant=, в отдельном throwaway-прототипе. -->
|
||||
<main>
|
||||
<div class="prototype-note">
|
||||
<strong>PROTOTYPE — не часть продукта.</strong>
|
||||
Реальный фрагмент рабочей встречи слегка сокращён и обезличен; интервалы
|
||||
и статистика взяты из уже выполненного замера. Слева — вид документа,
|
||||
справа — буквальный Markdown для оценки последующей обработки ИИ.
|
||||
</div>
|
||||
<div class="layout">
|
||||
<article class="paper" id="preview"></article>
|
||||
<section class="source-panel">
|
||||
<h2>Markdown-источник</h2>
|
||||
<pre id="source"></pre>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<nav class="switcher" aria-label="Переключение вариантов">
|
||||
<button id="previous" aria-label="Предыдущий вариант">←</button>
|
||||
<span id="variant-label"></span>
|
||||
<button id="next" aria-label="Следующий вариант">→</button>
|
||||
</nav>
|
||||
|
||||
<script>
|
||||
const variants = {
|
||||
A: {
|
||||
name: "Линейные реплики",
|
||||
source: `# Транскрипт: пример-встречи.mp4
|
||||
|
||||
- **Дата транскрипции**: 2026-08-14 12:00:00
|
||||
- **Модель**: gigaam-v3-e2e-rnnt
|
||||
- **Язык**: ru (задан явно)
|
||||
- **Длительность**: 25:59
|
||||
- **Устройство**: ONNX (CPU)
|
||||
- **Диаризация**: 4 голосовых кластера
|
||||
- **Внимание**: Speaker 4 — малый кластер (00:19; 1,3% речи), возможна ошибка разделения
|
||||
|
||||
---
|
||||
|
||||
[08:50] Speaker 3: А показываем, получается, в их контуре, не в нашем?
|
||||
|
||||
[08:54] Speaker 2: В нашем, по-моему.
|
||||
|
||||
[08:55] Speaker 3: В нашем. А, отлично. У нас просто есть тестовый контур, который на самом деле…
|
||||
|
||||
[09:07] Speaker 1: Мы же у них не разворачиваемся. Мне гораздо проще накатывать обновления на наш контур.
|
||||
|
||||
[11:34] Speaker 2: Результаты проверок пишутся туда же.
|
||||
|
||||
[11:39] Speaker 1: По сути, нам нужно переписать только ту часть, которая обрабатывает файл.
|
||||
|
||||
[11:43] Speaker 3: Да, а дальше переиспользовать существующую запись результатов.`,
|
||||
preview: `
|
||||
<h1>Транскрипт: пример-встречи.mp4</h1>
|
||||
<ul>
|
||||
<li><strong>Дата транскрипции</strong>: 2026-08-14 12:00:00</li>
|
||||
<li><strong>Модель</strong>: gigaam-v3-e2e-rnnt</li>
|
||||
<li><strong>Язык</strong>: ru (задан явно)</li>
|
||||
<li><strong>Длительность</strong>: 25:59</li>
|
||||
<li><strong>Устройство</strong>: ONNX (CPU)</li>
|
||||
<li><strong>Диаризация</strong>: 4 голосовых кластера</li>
|
||||
</ul>
|
||||
<div class="warning"><strong>Внимание:</strong> Speaker 4 — малый кластер (00:19; 1,3% речи), возможна ошибка разделения.</div>
|
||||
<hr>
|
||||
<p class="turn"><span class="time">[08:50]</span> Speaker 3: А показываем, получается, в их контуре, не в нашем?</p>
|
||||
<p class="turn"><span class="time">[08:54]</span> Speaker 2: В нашем, по-моему.</p>
|
||||
<p class="turn"><span class="time">[08:55]</span> Speaker 3: В нашем. А, отлично. У нас просто есть тестовый контур, который на самом деле…</p>
|
||||
<p class="turn"><span class="time">[09:07]</span> Speaker 1: Мы же у них не разворачиваемся. Мне гораздо проще накатывать обновления на наш контур.</p>
|
||||
<p class="turn"><span class="time">[11:34]</span> Speaker 2: Результаты проверок пишутся туда же.</p>
|
||||
<p class="turn"><span class="time">[11:39]</span> Speaker 1: По сути, нам нужно переписать только ту часть, которая обрабатывает файл.</p>
|
||||
<p class="turn overlap"><span class="time">[11:43]</span> Speaker 3: Да, а дальше переиспользовать существующую запись результатов.</p>`
|
||||
},
|
||||
B: {
|
||||
name: "Сценарий",
|
||||
source: `# Транскрипт: пример-встречи.mp4
|
||||
|
||||
> Диаризация нашла четыре голосовых кластера. Speaker 4 занимает 19 секунд и может быть ошибкой разделения.
|
||||
|
||||
---
|
||||
|
||||
### Speaker 3 · [08:50.75 - 08:54.73]
|
||||
|
||||
> А показываем, получается, в их контуре, не в нашем?
|
||||
|
||||
### Speaker 2 · [08:54.73 - 08:55.91]
|
||||
|
||||
> В нашем, по-моему.
|
||||
|
||||
### Speaker 3 · [08:55.81 - 09:07.96]
|
||||
|
||||
> В нашем. А, отлично. У нас просто есть тестовый контур, который на самом деле…
|
||||
|
||||
### Speaker 1 · [09:07.96 - 09:18.80]
|
||||
|
||||
> Мы же у них не разворачиваемся. Мне гораздо проще накатывать обновления на наш контур.
|
||||
|
||||
## Перекрывающаяся речь · [11:43.57 - 11:44.82]
|
||||
|
||||
> **Speaker 1:** По сути, нам нужно переписать только ту часть, которая обрабатывает файл.
|
||||
>
|
||||
> **Speaker 3:** Да, а дальше переиспользовать существующую запись результатов.`,
|
||||
preview: `
|
||||
<h1>Транскрипт: пример-встречи.mp4</h1>
|
||||
<blockquote><p>Диаризация нашла четыре голосовых кластера. Speaker 4 занимает 19 секунд и может быть ошибкой разделения.</p></blockquote>
|
||||
<hr>
|
||||
<h3>Speaker 3 · <span class="time">[08:50.75 - 08:54.73]</span></h3>
|
||||
<blockquote><p>А показываем, получается, в их контуре, не в нашем?</p></blockquote>
|
||||
<h3>Speaker 2 · <span class="time">[08:54.73 - 08:55.91]</span></h3>
|
||||
<blockquote><p>В нашем, по-моему.</p></blockquote>
|
||||
<h3>Speaker 3 · <span class="time">[08:55.81 - 09:07.96]</span></h3>
|
||||
<blockquote><p>В нашем. А, отлично. У нас просто есть тестовый контур, который на самом деле…</p></blockquote>
|
||||
<h3>Speaker 1 · <span class="time">[09:07.96 - 09:18.80]</span></h3>
|
||||
<blockquote><p>Мы же у них не разворачиваемся. Мне гораздо проще накатывать обновления на наш контур.</p></blockquote>
|
||||
<h2>Перекрывающаяся речь · <span class="time">[11:43.57 - 11:44.82]</span></h2>
|
||||
<blockquote class="overlap"><p><strong>Speaker 1:</strong> По сути, нам нужно переписать только ту часть, которая обрабатывает файл.<br><br><strong>Speaker 3:</strong> Да, а дальше переиспользовать существующую запись результатов.</p></blockquote>`
|
||||
},
|
||||
C: {
|
||||
name: "Таблица событий",
|
||||
source: `# Транскрипт: пример-встречи.mp4
|
||||
|
||||
| Speaker | Речь | Доля | Примечание |
|
||||
|---|---:|---:|---|
|
||||
| Speaker 1 | 09:33 | 40,3% | основной кластер |
|
||||
| Speaker 2 | 08:07 | 34,3% | основной кластер |
|
||||
| Speaker 3 | 05:42 | 24,1% | основной кластер |
|
||||
| Speaker 4 | 00:19 | 1,3% | возможная ошибка разделения |
|
||||
|
||||
| Начало | Конец | Кто | Текст | Событие |
|
||||
|---:|---:|---|---|---|
|
||||
| 08:50.75 | 08:54.73 | S3 | А показываем, получается, в их контуре, не в нашем? | — |
|
||||
| 08:54.73 | 08:55.91 | S2 | В нашем, по-моему. | — |
|
||||
| 08:55.81 | 09:07.96 | S3 | В нашем. А, отлично. У нас просто есть тестовый контур… | — |
|
||||
| 09:07.96 | 09:18.80 | S1 | Мы же у них не разворачиваемся. Мне проще накатывать обновления на наш контур. | — |
|
||||
| 11:39.91 | 11:44.82 | S1 | Нам нужно переписать только ту часть, которая обрабатывает файл. | overlap:S3 |
|
||||
| 11:43.57 | 11:46.47 | S3 | Да, а дальше переиспользовать существующую запись результатов. | overlap:S1 |`,
|
||||
preview: `
|
||||
<h1>Транскрипт: пример-встречи.mp4</h1>
|
||||
<table>
|
||||
<thead><tr><th>Speaker</th><th>Речь</th><th>Доля</th><th>Примечание</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>Speaker 1</td><td>09:33</td><td>40,3%</td><td>основной кластер</td></tr>
|
||||
<tr><td>Speaker 2</td><td>08:07</td><td>34,3%</td><td>основной кластер</td></tr>
|
||||
<tr><td>Speaker 3</td><td>05:42</td><td>24,1%</td><td>основной кластер</td></tr>
|
||||
<tr class="warning"><td>Speaker 4</td><td>00:19</td><td>1,3%</td><td>возможная ошибка разделения</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<h2>События</h2>
|
||||
<table>
|
||||
<thead><tr><th>Начало</th><th>Конец</th><th>Кто</th><th>Текст</th><th>Событие</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>08:50.75</td><td>08:54.73</td><td>S3</td><td>А показываем, получается, в их контуре, не в нашем?</td><td>—</td></tr>
|
||||
<tr><td>08:54.73</td><td>08:55.91</td><td>S2</td><td>В нашем, по-моему.</td><td>—</td></tr>
|
||||
<tr><td>08:55.81</td><td>09:07.96</td><td>S3</td><td>В нашем. А, отлично. У нас просто есть тестовый контур…</td><td>—</td></tr>
|
||||
<tr><td>09:07.96</td><td>09:18.80</td><td>S1</td><td>Мы же у них не разворачиваемся. Мне проще накатывать обновления на наш контур.</td><td>—</td></tr>
|
||||
<tr class="overlap"><td>11:39.91</td><td>11:44.82</td><td>S1</td><td>Нам нужно переписать только ту часть, которая обрабатывает файл.</td><td>overlap:S3</td></tr>
|
||||
<tr class="overlap"><td>11:43.57</td><td>11:46.47</td><td>S3</td><td>Да, а дальше переиспользовать существующую запись результатов.</td><td>overlap:S1</td></tr>
|
||||
</tbody>
|
||||
</table>`
|
||||
}
|
||||
};
|
||||
|
||||
const keys = Object.keys(variants);
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
let current = (params.get("variant") || "A").toUpperCase();
|
||||
if (!variants[current]) current = "A";
|
||||
|
||||
function render() {
|
||||
const variant = variants[current];
|
||||
document.getElementById("preview").innerHTML = variant.preview;
|
||||
document.getElementById("source").textContent = variant.source;
|
||||
document.getElementById("variant-label").textContent = `${current} — ${variant.name}`;
|
||||
document.title = `${current} — ${variant.name} · PROTOTYPE`;
|
||||
}
|
||||
|
||||
function move(offset) {
|
||||
const index = keys.indexOf(current);
|
||||
current = keys[(index + offset + keys.length) % keys.length];
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("variant", current);
|
||||
window.history.replaceState({}, "", url);
|
||||
render();
|
||||
}
|
||||
|
||||
document.getElementById("previous").addEventListener("click", () => move(-1));
|
||||
document.getElementById("next").addEventListener("click", () => move(1));
|
||||
window.addEventListener("keydown", (event) => {
|
||||
const target = event.target;
|
||||
if (target.matches("input, textarea, [contenteditable]")) return;
|
||||
if (event.key === "ArrowLeft") move(-1);
|
||||
if (event.key === "ArrowRight") move(1);
|
||||
});
|
||||
|
||||
render();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+16
@@ -15,3 +15,19 @@ _Avoid_: Доступная модель, дефолт
|
||||
**Модель по умолчанию**:
|
||||
Поддерживаемая модель, которую проект выбирает без явного указания модели пользователем для определённого пути выполнения.
|
||||
_Avoid_: Рекомендуемая модель, поддерживаемая модель
|
||||
|
||||
**Опорная разметка**:
|
||||
Разметка, принятая за точку отсчёта при измерении чего-то другого. Опорной её делает роль в измерении, а не качество: она не выверена вручную и сама может содержать ошибки, поэтому посчитанная по ней величина осмысленна как порядок, но не как точное значение.
|
||||
_Avoid_: Эталонная разметка, истинная разметка, ground truth
|
||||
|
||||
**Сегмент распознавания**:
|
||||
Непрерывный временной фрагмент аудио, для которого движок возвращает связный текст с общим контекстом. Может содержать речь нескольких говорящих и не равен реплике говорящего.
|
||||
_Avoid_: Реплика, фраза говорящего
|
||||
|
||||
**Слово с временной привязкой**:
|
||||
Распознанное слово, положение которого известно на временной шкале записи. Минимальная единица, которой назначается говорящий.
|
||||
_Avoid_: Токен, ASR-сегмент
|
||||
|
||||
**Реплика говорящего**:
|
||||
Последовательность соседних слов с временной привязкой, назначенных одному говорящему. Это единица структуры готового транскрипта, а не запуска модели распознавания.
|
||||
_Avoid_: Сегмент распознавания, ASR-сегмент
|
||||
|
||||
@@ -8,7 +8,7 @@ transcribe meeting.mp4
|
||||
```
|
||||
|
||||
- **Полностью локально** — данные не покидают машину
|
||||
- **Авто-ускорение** — NVIDIA CUDA, Intel GPU (OpenVINO), ONNX (CPU), OpenVINO CPU или CPU fallback
|
||||
- **Авто-ускорение** — NVIDIA CUDA при наличии GPU, иначе ONNX на CPU
|
||||
- **Батч-режим** — обработка нескольких файлов за один вызов
|
||||
- **Из проводника Windows** — пункт Transcribe в меню «Отправить» ([установка](#контекстное-меню-проводника-windows))
|
||||
- **Markdown с таймкодами** — удобен для суммаризации ИИ
|
||||
@@ -28,23 +28,29 @@ powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | ie
|
||||
**2. Установить transcriber:**
|
||||
|
||||
```bash
|
||||
uv tool install git+https://github.com/dementev-dev/local-transcriber
|
||||
uv tool install git+https://git.dementev.space/ddmitry/local-transcriber.git
|
||||
```
|
||||
|
||||
**3. Ускорение (ставится автоматически):**
|
||||
|
||||
- **Intel GPU** (Arc, встроенная графика): работает через OpenVINO — ускорение в ~2x vs CPU
|
||||
- **OpenVINO** (Intel/AMD x86 CPU): ставится автоматически на Linux и Windows — ускорение в 2-4x
|
||||
- **NVIDIA CUDA** (GPU): если есть GPU — транскрипция в 5-10× быстрее
|
||||
- **Windows**: `winget install -e --id Nvidia.CUDA --version 12.9` (от администратора), перезапустить терминал
|
||||
- **Linux / WSL2**: работает из коробки (нужен только драйвер: `nvidia-smi`)
|
||||
- **Без NVIDIA GPU**: автоматически используется ONNX с GigaAM RNN-T на CPU.
|
||||
Эта модель понимает только русскую речь
|
||||
- **Другие языки без NVIDIA**: выбирайте Whisper явно —
|
||||
`--device openvino-cpu --model medium` на x86 или `--device cpu --model medium`
|
||||
на любой платформе
|
||||
- **OpenVINO** для Intel GPU или x86 CPU остаётся доступен через явный
|
||||
`--device openvino`, `--device openvino-gpu` или `--device openvino-cpu`
|
||||
|
||||
**4. Готово:**
|
||||
|
||||
```bash
|
||||
transcribe meeting.mp4
|
||||
```
|
||||
Модели скачиваются автоматически при первом запуске (~1.5 GB для medium), нужен доступ в интернет.
|
||||
Модели скачиваются автоматически при первом запуске; размер зависит от выбранного
|
||||
профиля, нужен доступ в интернет.
|
||||
|
||||
<details>
|
||||
<summary><code>transcribe: command not found</code></summary>
|
||||
@@ -56,7 +62,7 @@ transcribe meeting.mp4
|
||||
**Обновление:**
|
||||
|
||||
```bash
|
||||
uv tool install --force git+https://github.com/dementev-dev/local-transcriber
|
||||
uv tool install --force git+https://git.dementev.space/ddmitry/local-transcriber.git
|
||||
```
|
||||
|
||||
**Удаление:**
|
||||
@@ -103,7 +109,7 @@ HuggingFace Hub использует симлинки для экономии м
|
||||
## Использование
|
||||
|
||||
```bash
|
||||
# Простой запуск (medium, русский, автодетект устройства)
|
||||
# Простой запуск (CUDA medium или ONNX GigaAM RNN-T, язык ru)
|
||||
transcribe meeting.mp4
|
||||
|
||||
# Указать язык
|
||||
@@ -180,11 +186,11 @@ transcribe --uninstall-menu
|
||||
|
||||
| Опция | Сокращение | По умолчанию | Описание |
|
||||
|-------|-----------|-------------|----------|
|
||||
| `--model` | `-m` | `medium` | Модель Whisper |
|
||||
| `--language` | `-l` | `ru` | Язык (ru, en, auto и др.) |
|
||||
| `--model` | `-m` | medium (CUDA) / gigaam-v3-e2e-rnnt (ONNX) | Модель распознавания |
|
||||
| `--language` | `-l` | `ru` | Язык (ru, en, auto и др.); автоматический профиль без NVIDIA понимает только русский |
|
||||
| `--output` | `-o` | `<файл>-transcript.md` | Путь к выходному файлу |
|
||||
| `--device` | `-d` | `auto` | Устройство (auto, cpu, cuda, openvino, openvino-gpu, openvino-cpu, onnx) |
|
||||
| `--compute-type` | — | float16 (CUDA) / int8 (OpenVINO/ONNX) / float32 (CPU) | Тип вычислений |
|
||||
| `--compute-type` | — | float16 (CUDA) / int8 (ONNX/OpenVINO) / float32 (CPU) | Тип вычислений |
|
||||
| `--threads` | `-t` | 0 (авто) | Потоки CPU (рекомендуется = число физ. ядер) |
|
||||
| `--force` | `-f` | — | Перезаписать существующие транскрипты |
|
||||
| `--verbose` | `-v` | — | Подробный вывод |
|
||||
@@ -193,12 +199,14 @@ transcribe --uninstall-menu
|
||||
|
||||
| | Linux / WSL2 | macOS | Windows |
|
||||
|---|---|---|---|
|
||||
| CPU | ✅ | ✅ | ✅ |
|
||||
| OpenVINO (x86 CPU) | ✅ авто | — | ✅ авто |
|
||||
| OpenVINO (Intel GPU) | ✅ авто | — | ✅ авто |
|
||||
| ONNX (CPU) | ✅ явно | ✅ явно | ✅ явно |
|
||||
| CPU через ONNX | ✅ авто | ✅ авто | ✅ авто |
|
||||
| OpenVINO (x86 CPU) | ✅ явно | — | ✅ явно |
|
||||
| OpenVINO (Intel GPU) | ✅ явно | — | ✅ явно |
|
||||
| GPU (NVIDIA) | ✅ авто | — | ✅ (нужен CUDA 12) |
|
||||
|
||||
Данные по macOS основаны на доступности пакетов onnxruntime: прогонов на этой
|
||||
платформе не было.
|
||||
|
||||
<details>
|
||||
<summary>Linux / WSL2</summary>
|
||||
|
||||
@@ -237,8 +245,10 @@ transcribe --uninstall-menu
|
||||
Дефолтные параметры можно задать в `.transcriber.toml`:
|
||||
|
||||
```toml
|
||||
model = "large-v3"
|
||||
language = "en"
|
||||
device = "openvino-cpu"
|
||||
model = "large-v3-turbo"
|
||||
compute_type = "int8"
|
||||
language = "ru"
|
||||
```
|
||||
|
||||
Порядок поиска:
|
||||
@@ -247,6 +257,13 @@ language = "en"
|
||||
|
||||
Приоритет: **CLI-аргумент > конфиг > device-aware дефолт > встроенный дефолт**.
|
||||
|
||||
При `device = "auto"` выбирается CUDA, если доступен `nvidia-smi`, иначе ONNX.
|
||||
Явный `device` из CLI или конфига отключает этот автоматический выбор.
|
||||
Каталоги моделей различаются между бэкендами, поэтому при закреплении `model`
|
||||
в конфиге рекомендуется явно закрепить и совместимый `device`. То же с языком:
|
||||
автоматический ONNX-профиль рассчитан на русскую речь, а для остальных языков
|
||||
нужен Whisper — например, `device = "openvino-cpu"` и `model = "medium"`.
|
||||
|
||||
Дефолты зависят от устройства:
|
||||
|
||||
| Параметр | CUDA | OpenVINO (GPU) | OpenVINO (CPU) | ONNX | CPU |
|
||||
@@ -258,17 +275,20 @@ language = "en"
|
||||
## Модели и GPU
|
||||
|
||||
Рекомендации:
|
||||
- **По умолчанию для ONNX:** `gigaam-v3-e2e-rnnt` — читаемый русский текст с
|
||||
- **По умолчанию без CUDA:** ONNX `gigaam-v3-e2e-rnnt` — читаемый русский текст с
|
||||
пунктуацией почти без потери скорости относительно сырого `gigaam-v3`
|
||||
- **Макс. качество (NVIDIA):** `large-v3` + `--compute-type float16`
|
||||
- **Макс. качество (Intel GPU):** `large-v3` + `--device openvino-gpu`
|
||||
- **Макс. скорость CPU (русский):** `--device onnx --model gigaam-v3` (17-29× RTF, без пунктуации; рекомендуется LLM-нормализация терминов после)
|
||||
- **CPU с пунктуацией (русский):** `--device openvino-cpu --model medium` (5-6× RTF; для встреч ≤30 мин с равномерной громкостью — на длинных файлах с тихими фрагментами возможны галлюцинации)
|
||||
- **Быстрый OpenVINO с низким WER:** `--device openvino-cpu --model large-v3-turbo --compute-type int8` (7,2× RTFx на контрольном Intel CPU; пунктуация может быть слабой)
|
||||
- **OpenVINO для чтения и конспекта:** `--device openvino-cpu --model medium` (около 6× RTFx; независимая оценка показала лучшую сохранность содержания, чем turbo)
|
||||
- **Быстрый тест:** `tiny` — для проверки пайплайна
|
||||
|
||||
<details>
|
||||
<summary>Таблица моделей</summary>
|
||||
|
||||
#### Whisper через faster-whisper (`--device cuda`, `--device cpu`)
|
||||
|
||||
| Модель | Размер на диске | VRAM (int8) | Скорость (GPU) | Качество |
|
||||
|--------|----------------|-------------|----------------|----------|
|
||||
| `tiny` | ~75 MB | ~1 GB | ★★★★★ | ★ |
|
||||
@@ -277,6 +297,15 @@ language = "en"
|
||||
| `medium` | ~1.5 GB | ~2.5 GB | ★★ | ★★★★ |
|
||||
| `large-v3` | ~3 GB | ~2.5 GB | ★ | ★★★★★ |
|
||||
|
||||
#### Whisper через OpenVINO (`--device openvino-cpu`, `--device openvino-gpu`)
|
||||
|
||||
Здесь те же модели Whisper, но предквантизированные, поэтому на диске они
|
||||
занимают меньше места: `medium` int8 — 748 MB, `large-v3-turbo` int8 — 790 MB,
|
||||
`large-v3-turbo` fp16 — 1552 MB. Модель `large-v3-turbo` доступна только здесь:
|
||||
faster-whisper её не поддерживает, и запуск с `--device cuda` завершится
|
||||
ошибкой. Полный список репозиториев —
|
||||
[docs/gpu.md](docs/gpu.md#доступные-openvino-модели).
|
||||
|
||||
#### ONNX-модели (`--device onnx`)
|
||||
|
||||
Другие архитектуры, не Whisper. Работают через onnxruntime на CPU:
|
||||
@@ -308,6 +337,9 @@ language = "en"
|
||||
кыргызский и узбекский внутри одной записи. `onnx-asr` не передаёт этим моделям
|
||||
подсказку языка, поэтому `--language` не управляет выбором языка.
|
||||
|
||||
Если модель не понимает запрошенный язык, CLI предупреждает об этом до начала
|
||||
распознавания и подсказывает совместимый профиль, но работу не прерывает.
|
||||
|
||||
Для моделей из таблицы опубликованы `int8` и `float32`. Если неявный
|
||||
device-aware дефолт недоступен для выбранной модели, CLI сообщит о подстановке
|
||||
доступного варианта. Явное значение из `--compute-type` или
|
||||
@@ -331,11 +363,15 @@ device-aware дефолт недоступен для выбранной мод
|
||||
`float16`/`fp16` и `float32` значительно стабильнее на записях >20 минут.
|
||||
|
||||
> Для OpenVINO `--compute-type` выбирает предквантизированную модель (int8 или fp16),
|
||||
> а не runtime-параметр. Для `large-v3` по умолчанию выбирается `fp16`.
|
||||
> а не параметр времени выполнения. Для `large-v3` по умолчанию выбирается
|
||||
> `fp16`; для `large-v3-turbo` доступны явные варианты `int8` и `fp16`, а
|
||||
> неявный профиль OpenVINO использует `int8`.
|
||||
|
||||
</details>
|
||||
|
||||
Подробнее: бенчмарки, OpenVINO, совместимость GPU, результаты тестирования — [docs/gpu.md](docs/gpu.md).
|
||||
Подробнее: бенчмарки, OpenVINO, совместимость GPU, результаты тестирования —
|
||||
[docs/gpu.md](docs/gpu.md). Сравнение `large-v3-turbo` с CPU-профилями:
|
||||
[OpenVINO 2026.3 и large-v3-turbo](docs/benchmarks/2026-08-12-openvino-large-v3-turbo-comparison.md).
|
||||
|
||||
<details>
|
||||
<summary>Формат вывода</summary>
|
||||
@@ -345,7 +381,7 @@ device-aware дефолт недоступен для выбранной мод
|
||||
|
||||
- **Дата транскрипции**: 2026-03-17 14:30:05
|
||||
- **Модель**: large-v3
|
||||
- **Язык**: ru (detected)
|
||||
- **Язык**: ru (задан явно)
|
||||
- **Длительность**: 01:23:45
|
||||
- **Устройство**: CUDA (NVIDIA GeForce RTX 3060)
|
||||
|
||||
@@ -360,6 +396,14 @@ device-aware дефолт недоступен для выбранной мод
|
||||
Близкие по времени сегменты автоматически объединяются в абзацы (пауза > 2 сек или длительность > 60 сек разделяет абзацы).
|
||||
Таймкоды: `MM:SS.ss`, для записей длиннее 1 часа — `HH:MM:SS.ss`.
|
||||
|
||||
В скобках после языка указан его источник:
|
||||
|
||||
- `задан явно` — язык взят из `--language` или конфига;
|
||||
- `определён автоматически` — распознан моделью при `--language auto`;
|
||||
- `из профиля модели` — у модели всего один язык, как у GigaAM.
|
||||
|
||||
Если язык определить не удалось, строка выглядит так: `- **Язык**: не определён`.
|
||||
|
||||
</details>
|
||||
|
||||
## Поддерживаемые форматы
|
||||
|
||||
+34
-32
@@ -24,8 +24,9 @@ transcribe meeting-2026-03-17.mp4
|
||||
### 3.1. Основной flow
|
||||
|
||||
1. Пользователь вызывает CLI, передаёт путь к файлу (или glob-маску, post-MVP)
|
||||
2. Проверка: ffmpeg доступен в PATH
|
||||
3. Файл передаётся в faster-whisper (он сам обрабатывает и аудио, и видео через libav/ffmpeg — отдельное извлечение аудиодорожки не нужно)
|
||||
2. Файл валидируется по пути, размеру и расширению
|
||||
3. По `device` выбирается backend; аудио декодируется через PyAV без отдельного
|
||||
извлечения дорожки
|
||||
4. Результат форматируется в markdown с таймкодами
|
||||
5. Файл `<имя>-transcript.md` сохраняется рядом с исходным (кодировка: UTF-8)
|
||||
|
||||
@@ -41,16 +42,16 @@ transcribe meeting-2026-03-17.mp4
|
||||
transcribe <путь_к_файлу> [опции]
|
||||
|
||||
Опции:
|
||||
--model, -m Модель Whisper (tiny|base|small|medium|large-v3)
|
||||
По умолчанию: large-v3
|
||||
--model, -m Модель распознавания
|
||||
По умолчанию: medium (CUDA) / gigaam-v3-e2e-rnnt (ONNX)
|
||||
--language, -l Язык (ru|en|auto)
|
||||
По умолчанию: auto (автодетект)
|
||||
По умолчанию: ru
|
||||
--output, -o Путь к выходному файлу
|
||||
По умолчанию: <input_stem>-transcript.md
|
||||
--device, -d Устройство (auto|cpu|cuda|openvino|openvino-gpu|openvino-cpu)
|
||||
По умолчанию: auto (CUDA → OpenVINO GPU → OpenVINO CPU → CPU)
|
||||
--compute-type Тип вычислений (float16|int8|int8_float16|float32)
|
||||
По умолчанию: int8 (универсален для GPU 4-8 GB и CPU)
|
||||
--device, -d Устройство (auto|cpu|cuda|onnx|openvino|openvino-gpu|openvino-cpu)
|
||||
По умолчанию: auto (CUDA при наличии, иначе ONNX CPU)
|
||||
--compute-type Тип вычислений
|
||||
По умолчанию: float16 (CUDA) / int8 (ONNX)
|
||||
--verbose, -v Подробный вывод (прогресс сегментов)
|
||||
```
|
||||
|
||||
@@ -63,7 +64,7 @@ transcribe <путь_к_файлу> [опции]
|
||||
|
||||
- **Дата транскрипции**: 2026-03-17 14:30:05
|
||||
- **Модель**: large-v3
|
||||
- **Язык**: ru (detected) / ru (forced)
|
||||
- **Язык**: ru (задан явно) / ru (определён автоматически)
|
||||
- **Длительность**: 01:23:45
|
||||
- **Устройство**: CUDA (NVIDIA GeForce RTX 3060)
|
||||
|
||||
@@ -82,16 +83,17 @@ transcribe <путь_к_файлу> [опции]
|
||||
**Правила форматирования**:
|
||||
- Таймкоды в формате `[MM:SS.ss - MM:SS.ss]` (минуты:секунды.сотые)
|
||||
- Для записей длиннее 1 часа — `[HH:MM:SS.ss - HH:MM:SS.ss]`
|
||||
- Каждый сегмент — отдельный абзац
|
||||
- Соседние сегменты объединяются в абзац до паузы 2 секунды или длительности 60 секунд
|
||||
- Метаданные в шапке файла
|
||||
- Пустая строка между сегментами для читаемости
|
||||
- Пустая строка между абзацами для читаемости
|
||||
|
||||
### 3.4. Поддерживаемые форматы
|
||||
|
||||
**Аудио**: mp3, wav, flac, ogg, m4a, wma, aac
|
||||
**Видео**: mp4, mkv, avi, mov, webm, ts
|
||||
|
||||
Определение типа — по расширению. Фактическое декодирование выполняет ffmpeg внутри faster-whisper; если формат не поддерживается, ошибка будет от ffmpeg.
|
||||
Определение типа — по расширению. Фактическое декодирование выполняет PyAV с
|
||||
встроенными библиотеками FFmpeg; системная установка `ffmpeg` не требуется.
|
||||
|
||||
## 4. Нефункциональные требования
|
||||
|
||||
@@ -102,6 +104,8 @@ transcribe <путь_к_файлу> [опции]
|
||||
| RTX 3060 + large-v3 | ~10-15x (1 час аудио ≈ 4-6 мин) |
|
||||
| RTX 4050 + large-v3 | ~12-18x (1 час аудио ≈ 3-5 мин) |
|
||||
| Quadro M3000M + large-v3 | ~3-5x (1 час аудио ≈ 12-20 мин) |
|
||||
| CPU + ONNX GigaAM RNN-T | ~10-14x (1 час аудио ≈ 4-6 мин) |
|
||||
| CPU + OpenVINO Turbo INT8 | ~7-10x (1 час аудио ≈ 6-9 мин) |
|
||||
| CPU (modern) + large-v3 | ~0.5-1x (1 час аудио ≈ 60-120 мин) |
|
||||
| CPU + small | ~3-5x (1 час аудио ≈ 12-20 мин) |
|
||||
|
||||
@@ -112,23 +116,22 @@ transcribe <путь_к_файлу> [опции]
|
||||
| RTX 3060 | 6 GB | ✅ | ✅ (впритык) | int8 — безопасный выбор |
|
||||
| RTX 4050 | 6 GB | ✅ | ✅ (впритык) | int8 — безопасный выбор |
|
||||
| Quadro M3000M | 4 GB | ✅ | ⚠️ может OOM | int8 обязательно |
|
||||
| Без GPU | — | CPU int8 | — | int8 на CPU |
|
||||
| Без GPU | — | ONNX GigaAM INT8 | — | auto выбирает ONNX |
|
||||
|
||||
Дефолт `int8` выбран как универсальный: работает на всех GPU от 4 GB и на CPU, при минимальной потере качества относительно float16.
|
||||
Device-aware дефолты выбирают `float16` для CUDA и `int8` для ONNX/OpenVINO.
|
||||
|
||||
### 4.2. Требования к окружению
|
||||
|
||||
- Python ≥ 3.13
|
||||
- ffmpeg в PATH (используется faster-whisper внутри для декодирования любых медиаформатов)
|
||||
- Для GPU: Linux/WSL2 — cuBLAS из nvidia-cublas-cu12 (ставится автоматически через `uv sync`); Windows — системный CUDA toolkit (см. ADR-001)
|
||||
- Дисковое пространство для моделей: ~3 GB (large-v3)
|
||||
- Дисковое пространство для моделей: зависит от выбранного backend и модели
|
||||
- Выходные файлы: UTF-8 (явная кодировка при записи)
|
||||
|
||||
### 4.3. Кроссплатформенность
|
||||
|
||||
- Linux: нативный запуск
|
||||
- Windows: нативный Python или WSL2
|
||||
- macOS: не приоритет, но faster-whisper поддерживает CPU-режим
|
||||
- macOS: ONNX CPU в auto-режиме; FasterWhisper CPU доступен явно
|
||||
|
||||
## 5. Технический стек
|
||||
|
||||
@@ -136,19 +139,18 @@ transcribe <путь_к_файлу> [опции]
|
||||
|---------------------|-------------------------------------------------|
|
||||
| Язык | Python 3.13+ |
|
||||
| Управление проектом | uv (pyproject.toml) |
|
||||
| Распознавание речи | faster-whisper (CTranslate2 backend) |
|
||||
| Медиа-декодирование | ffmpeg (системная зависимость, используется faster-whisper внутри) |
|
||||
| Распознавание речи | faster-whisper, ONNX Runtime, OpenVINO GenAI |
|
||||
| Медиа-декодирование | PyAV со встроенными библиотеками FFmpeg |
|
||||
| CLI-фреймворк | typer |
|
||||
| Прогресс | rich (progress bar + статус) |
|
||||
|
||||
### 5.1. Почему faster-whisper
|
||||
### 5.1. Почему несколько backend
|
||||
|
||||
- В 4× быстрее оригинального OpenAI Whisper при том же качестве
|
||||
- Меньше потребление VRAM (large-v3 влезает в 6 GB с float16/int8)
|
||||
- Нативный Python API, без Docker
|
||||
- Поддержка CPU fallback из коробки
|
||||
- Активное сообщество, регулярные обновления
|
||||
- Автоматическая загрузка моделей из Hugging Face Hub
|
||||
- faster-whisper оптимизирован для NVIDIA CUDA и поддерживает много языков
|
||||
- ONNX GigaAM RNN-T даёт быстрый читаемый результат на CPU
|
||||
- OpenVINO предоставляет явные профили для Intel GPU и x86 CPU
|
||||
- Все backend работают локально через Python API, без Docker и облачных ключей
|
||||
- Модели загружаются автоматически и кешируются локально
|
||||
|
||||
### 5.2. Структура проекта
|
||||
|
||||
@@ -160,9 +162,10 @@ local-transcriber/
|
||||
│ └── local_transcriber/
|
||||
│ ├── __init__.py
|
||||
│ ├── cli.py # CLI entry point (typer)
|
||||
│ ├── transcriber.py # Обёртка над faster-whisper
|
||||
│ ├── transcriber.py # Оркестрация backend и fallback
|
||||
│ ├── backends/ # Адаптеры FasterWhisper, ONNX и OpenVINO
|
||||
│ ├── formatter.py # Форматирование в markdown
|
||||
│ └── utils.py # Проверки (ffmpeg), определение device и т.д.
|
||||
│ └── utils.py # Проверки файлов и определение device
|
||||
└── tests/
|
||||
└── ...
|
||||
```
|
||||
@@ -173,11 +176,10 @@ local-transcriber/
|
||||
|------|---------|-----------|
|
||||
| Качество распознавания русского текста | Среднее | large-v3 хорошо справляется с ru; при проблемах — попробовать `--language ru` вместо auto |
|
||||
| Нет разделения по спикерам | Низкое | Осознанно выведено за скоуп MVP; добавление diarization (pyannote.audio) — возможное расширение |
|
||||
| ffmpeg отсутствует в системе | Высокое | Проверка при старте + понятное сообщение об ошибке с инструкцией по установке |
|
||||
| Первый запуск: долгая загрузка модели | Низкое | Прогресс-бар при скачивании; модели кешируются в `~/.cache/huggingface/` |
|
||||
| CUDA несовместимость на Windows | Среднее | Автоматический fallback на CPU + предупреждение; в README — инструкция по CUDA |
|
||||
| Большие файлы (>2 часов) | Низкое | faster-whisper работает потоково, не грузит всё в память |
|
||||
| OOM на GPU с 4 GB VRAM | Среднее | Дефолт int8 (~2.5 GB); при OOM — fallback на CPU с предупреждением |
|
||||
| Большие файлы (>2 часов) | Среднее | Учитывать память выбранного backend; чанкование рассматривается отдельно |
|
||||
| OOM на GPU с 4 GB VRAM | Среднее | CUDA использует float16; при OOM — fallback на CPU с предупреждением или явный более лёгкий профиль |
|
||||
| Файл без речи (тишина, музыка, шум) | Низкое | Создаётся транскрипт с шапкой метаданных и `*Речь не обнаружена.*` в теле + предупреждение в stderr |
|
||||
|
||||
## 7. Вне скоупа MVP
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
**Статус**: Принято
|
||||
**Дата**: 2026-03-21
|
||||
**Обновлено**: 2026-08-12
|
||||
|
||||
## Контекст
|
||||
|
||||
@@ -39,7 +40,8 @@ shared libraries. Ни то, ни другое не должно происхо
|
||||
Вместо отдельного `--backend` флага устройство само определяет бэкенд:
|
||||
- `cuda`, `cpu` → FasterWhisperBackend
|
||||
- `openvino` → OpenVINOBackend
|
||||
- `auto` → CUDA (nvidia-smi) → OpenVINO (import check + x86) → CPU
|
||||
- `onnx` → OnnxAsrBackend
|
||||
- `auto` → CUDA при наличии `nvidia-smi`, иначе ONNX на CPU
|
||||
|
||||
### load_model() — единственный владелец pipeline
|
||||
|
||||
@@ -71,18 +73,19 @@ OpenVINO модели предквантизированы (int8/fp16), compute_
|
||||
- Из дефолтов: для large-v3 автоматически выбирается fp16 (стабильнее по качеству)
|
||||
- Несуществующая пара (model + compute_type) при явном выборе → ошибка
|
||||
|
||||
### Обе зависимости по умолчанию
|
||||
### Зависимости бэкендов по умолчанию
|
||||
|
||||
faster-whisper (~37MB) и openvino-genai (~69MB) ставятся вместе — суммарно ~106MB,
|
||||
приемлемо. Модели скачиваются только для активного бэкенда. CUDA (nvidia-cublas-cu12,
|
||||
~554MB) остаётся conditional (Linux x86_64). OpenVINO — conditional (x86_64/AMD64, не macOS).
|
||||
faster-whisper, onnx-asr/onnxruntime и openvino-genai ставятся вместе. Модели
|
||||
скачиваются только для активного бэкенда. CUDA (`nvidia-cublas-cu12`) остаётся
|
||||
conditional для Linux x86_64. OpenVINO — conditional для x86_64/AMD64, кроме
|
||||
macOS; ONNX обеспечивает автоматический CPU-путь на остальных платформах.
|
||||
|
||||
## Последствия
|
||||
|
||||
- Обратная совместимость: `transcribe()` сохранён; `load_model()` изменил сигнатуру (возвращает 4-tuple вместо 2-tuple, добавлен `compute_type_explicit`)
|
||||
- Новый бэкенд добавляется одним файлом в `backends/` + регистрацией в `__init__.py`
|
||||
- Модели скачиваются по запросу — CUDA пользователь не качает OpenVINO модели, и наоборот
|
||||
- ARM и macOS: OpenVINO не ставится (platform markers), работает CPU через faster-whisper
|
||||
- ARM и macOS: OpenVINO не ставится (platform markers), auto использует ONNX
|
||||
|
||||
## Отклонённые альтернативы
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
**Статус**: Принято
|
||||
**Дата**: 2026-04-25
|
||||
**Обновлено**: 2026-08-11
|
||||
**Обновлено**: 2026-08-12
|
||||
|
||||
## Контекст
|
||||
|
||||
@@ -101,10 +101,16 @@ Mm-hmm-редукция и иностранные вставки **не обна
|
||||
|
||||
## Решение
|
||||
|
||||
**Принять onnx-asr как экспериментальный бэкенд с явным `--device onnx`.
|
||||
GigaAM v3 E2E RNN-T — модель по умолчанию для русских встреч на CPU.**
|
||||
**Принять onnx-asr как CPU-бэкенд автоматического профиля.
|
||||
GigaAM v3 E2E RNN-T — модель по умолчанию на машинах без CUDA.**
|
||||
|
||||
Бэкенд **не в auto-detect** — только при явном указании пользователем (политика experimental backend, как для openvino).
|
||||
Порядок `--device auto`: CUDA при наличии `nvidia-smi`, иначе ONNX. OpenVINO и
|
||||
FasterWhisper CPU остаются доступными через явный CLI-аргумент или конфиг.
|
||||
|
||||
GigaAM понимает только русскую речь, поэтому для остальных языков автоматический
|
||||
профиль не годится — нужен явный Whisper. Несовместимый язык работу не
|
||||
останавливает: CLI предупреждает о нём до начала распознавания и называет
|
||||
подходящий профиль.
|
||||
|
||||
Модели:
|
||||
- **`gigaam-v3-e2e-rnnt`** — модель по умолчанию: практически равна обычному
|
||||
@@ -122,16 +128,20 @@ GigaAM v3 E2E RNN-T — модель по умолчанию для русски
|
||||
- Пользователи CPU-only с русскоязычным контентом получают 3-5× ускорение по сравнению с OpenVINO medium **при превосходящем качестве** (4/5 vs 1-2/5 summary utility на длинных файлах).
|
||||
- Пользователи ONNX без явного `--model` получают пунктуацию и нормализацию
|
||||
RNN-T; более точный сырой CTC остаётся доступен как `--model gigaam-v3`.
|
||||
- Whisper medium (`--device openvino-cpu`) **остаётся допустимым** для коротких (≤30 мин) встреч с равномерной громкостью; на длинных файлах с тихими участками он галлюцинирует целыми блоками — этот риск зафиксирован, но решение не выводит OpenVINO из списка дефолтов (часть пользователей всё ещё нуждается в пунктуации, и для коротких файлов галлюцинации не воспроизводятся).
|
||||
- Whisper medium (`--device openvino-cpu`) **остаётся допустимым явным профилем**
|
||||
для коротких (≤30 мин) встреч с равномерной громкостью; на длинных файлах с
|
||||
тихими участками он может галлюцинировать целыми блоками.
|
||||
- Parakeet-v3 формально доступен, но в README рекомендуется только для англоязычного контента — для русского явно не годится.
|
||||
- GPU faster-whisper large-v3 остаётся эталоном по качеству (для пользователей с NVIDIA GPU).
|
||||
- Пост-процессинг GigaAM-транскрипта LLM-этапом нормализации (восстановление латинских терминов и имён компаний) — рекомендуемая практика для финального конспекта.
|
||||
|
||||
## Открытые вопросы / следующие шаги
|
||||
|
||||
- **Galлюцинации Whisper medium на длинных файлах** — отдельный продуктовый риск, требующий собственного исследования. Возможно, имеет смысл ограничить максимальную длину чанка для openvino-medium, или дать предупреждение пользователю.
|
||||
- **Галлюцинации Whisper medium на длинных файлах** — отдельный продуктовый риск, требующий собственного исследования. Возможно, имеет смысл ограничить максимальную длину чанка для openvino-medium, или дать предупреждение пользователю.
|
||||
- **Canary** (`nemo-canary-1b-v2`) — тяжелее, но multilingual + пунктуация. Кандидат на «лучшее качество за разумную скорость» для тех, кому важна пунктуация.
|
||||
- **Auto-detect onnx**: после стабилизации в production-использовании (несколько недель) — рассмотреть включение в auto-detect как первый CPU-бэкенд (ниже CUDA, выше OpenVINO).
|
||||
- Проверить качество на других языках и при необходимости дополнить
|
||||
многоязычные рекомендации. Сама автоматическая политика от языка не зависит:
|
||||
она предупреждает о несовместимости, но профиль за пользователя не меняет.
|
||||
|
||||
## Отклонённые альтернативы
|
||||
|
||||
@@ -139,5 +149,4 @@ GigaAM v3 E2E RNN-T — модель по умолчанию для русски
|
||||
|---|---|
|
||||
| NeMo Parakeet напрямую (без onnx-asr) | Требует PyTorch + CUDA, Python ≥ 3.12, ~2 GB зависимостей — слишком тяжело для CLI |
|
||||
| Замена faster-whisper на onnx-asr | faster-whisper поддерживает 99+ языков и пунктуацию, остаётся лучшим GPU-бэкендом |
|
||||
| GigaAM как auto-detect default | Экспериментальный бэкенд, политика — не сюрпризить существующих пользователей; включение в auto-detect — после периода стабилизации |
|
||||
| Parakeet-v3 как multilingual default | Воспроизведённые проблемы из ADR-005 (Mm-hmm-редукция, иноязычные вставки) делают его непригодным для русского; для других языков не валидировано в этом эксперименте |
|
||||
|
||||
@@ -64,3 +64,55 @@ tea labels list --remote origin
|
||||
|
||||
За пределами рабочего дерева явно указывать репозиторий
|
||||
`ddmitry/local-transcriber` и настроенный Gitea login.
|
||||
|
||||
## Wayfinding operations
|
||||
|
||||
Навык `wayfinder` ведёт карту как issue с меткой `wayfinder:map`, а её тикеты —
|
||||
как отдельные issue с метками `wayfinder:research`, `wayfinder:prototype`,
|
||||
`wayfinder:grilling` и `wayfinder:task`.
|
||||
|
||||
### Принадлежность карте
|
||||
|
||||
Gitea 1.27 не имеет подзадач в API: среди эндпоинтов `issues/{index}` есть
|
||||
`dependencies` и `blocks`, но родительских связей нет. Поэтому принадлежность
|
||||
тикета карте выражается двумя способами сразу: меткой `wayfinder:<тип>` и первой
|
||||
строкой тела со ссылкой на карту.
|
||||
|
||||
```markdown
|
||||
Часть карты: [<заголовок карты>](<url>) (#<номер>)
|
||||
```
|
||||
|
||||
### Блокировки
|
||||
|
||||
Блокировки — нативные зависимости Gitea, они отображаются в интерфейсе. Тикет
|
||||
разблокирован, когда закрыты все блокирующие его тикеты.
|
||||
|
||||
```powershell
|
||||
tea api --remote origin -X POST `
|
||||
repos/ddmitry/local-transcriber/issues/<блокируемый>/dependencies `
|
||||
-d '{"index": <блокирующий>, "owner": "ddmitry", "repo": "local-transcriber"}'
|
||||
```
|
||||
|
||||
### Запросы фронтира
|
||||
|
||||
Фронтир — открытые, разблокированные и никому не назначенные тикеты карты.
|
||||
Заявка на тикет — назначение его на себя до начала работы.
|
||||
|
||||
```powershell
|
||||
tea issues list --remote origin --labels wayfinder:map
|
||||
tea issues list --remote origin --labels wayfinder:research,wayfinder:prototype,wayfinder:grilling,wayfinder:task
|
||||
tea api --remote origin repos/ddmitry/local-transcriber/issues/<номер>/dependencies
|
||||
```
|
||||
|
||||
### Особенности `tea api`
|
||||
|
||||
Три вещи, на которых легко потерять время:
|
||||
|
||||
- **Путь без ведущего слэша.** `repos/{owner}/{repo}/...` работает,
|
||||
`/repos/...` возвращает `404 page not found`. Подстановка `{owner}` и `{repo}`
|
||||
из контекста репозитория при этом не срабатывает — писать владельца и имя явно.
|
||||
- **Тело зависимости требует `owner` и `repo`.** Только `{"index": N}` даёт
|
||||
`repository does not exist [id: 0, uid: 0, owner_name: , name: ]`.
|
||||
- **Код возврата не отражает HTTP-статус.** `tea api` завершается с нулевым
|
||||
кодом даже на 404, поэтому скрипты должны запрашивать `-i` и разбирать строку
|
||||
`HTTP/...` из stderr, иначе ошибки пройдут незамеченными.
|
||||
|
||||
+53
-18
@@ -181,27 +181,56 @@ LLM для чистки текста, сопоставление Speaker N с и
|
||||
|
||||
### Диаризация — разделение говорящих
|
||||
|
||||
**Что:** Опциональный пост-процессинг (не четвёртый бэкенд): сегменты
|
||||
уже несут таймкоды; диаризация даёт интервалы «кто когда говорил»;
|
||||
merge по перекрытию интервалов; formatter ломает абзац на смене спикера
|
||||
и подписывает `Speaker 1:`. Ставится как extra:
|
||||
`uv sync --extra diarization`.
|
||||
**Что:** Опциональный пост-процессинг (не четвёртый бэкенд): диаризация
|
||||
даёт интервалы «кто когда говорил», результат сводится с сегментами ASR,
|
||||
formatter ломает абзац на смене спикера и подписывает `Speaker 1:`.
|
||||
Ставится как extra: `uv sync --extra diarization`.
|
||||
|
||||
**Почему:** Без спикеров MoM не собрать — это ключевой разрыв с облаком
|
||||
по внешнему ревью, и никакое качество распознавания его не компенсирует.
|
||||
Заодно естественно решает «разбивку на реплики» (приоритет №4).
|
||||
|
||||
**Варианты реализации (ключевое решение, нужен ADR):**
|
||||
**Промежуточный статус 2026-08-12:** проведена разведка, описанная в
|
||||
[разведочном замере диаризации](benchmarks/2026-08-12-diarization-feasibility.md).
|
||||
Она закрыла вопрос о движке и открыла более важный вопрос о единице привязки.
|
||||
|
||||
- **sherpa-onnx** — диаризация целиком на onnxruntime (сегментация
|
||||
pyannote в ONNX + спикер-эмбеддинги), без torch, в духе нашего
|
||||
onnx-стека и «no cloud, no API keys».
|
||||
- **pyannote.audio** — стандарт качества, но тянет torch и требует
|
||||
HF-токен с принятием лицензии моделей — трение с духом проекта.
|
||||
**Движок — вопрос практически закрыт.** `sherpa-onnx` ставится на Windows с
|
||||
Python 3.13, содержит готовый `OfflineSpeakerDiarization`, не тянет torch и не
|
||||
требует токена Hugging Face; модели сегментации и эмбеддингов весят около 33 МБ.
|
||||
Скорость — 11,1× RTFx, то есть примерно полторы длительности ASR. Вариант
|
||||
`pyannote.audio` остаётся отклонённым по прежней причине: torch и HF-токен с
|
||||
принятием лицензии. Отдельный ADR имеет смысл заводить вместе с решением о
|
||||
единице привязки, а не только про движок.
|
||||
|
||||
**Уточнить перед запуском:** качество обоих вариантов на русской речи
|
||||
и перекрывающихся репликах; скорость на CPU (диаризация — второй проход
|
||||
по всему аудио); лицензии моделей сегментации/эмбеддингов.
|
||||
Замечание для будущих заходов: обе ML-части диаризации уже лежат в
|
||||
`onnx-asr` 0.12 — `PyAnnoteVad` содержит полную локальную сегментацию pyannote
|
||||
(powerset на трёх спикеров, склейка окон), а `WespeakerEmbeddings` даёт
|
||||
эмбеддинги. Публичный API схлопывает сегментацию до речь/не-речь, `load_se` не
|
||||
экспортирован, кластеризации нет. Собирать диаризацию самим на этих деталях —
|
||||
экономия 33 МБ ценой опоры на приватный API; при разведке этот путь не
|
||||
выбирался.
|
||||
|
||||
**Единица привязки — настоящая развилка, решения нет.** Схема «мажоритарный
|
||||
спикер на весь ASR-сегмент», записанная здесь раньше, замером не подтвердилась:
|
||||
27% сегментов содержат не менее секунды чужой речи, и на них приходится больше
|
||||
половины времени транскрипта. Причина — границы сегментов идут по тишине
|
||||
(Silero VAD), а в ВКС собеседники отвечают встык. Варианты:
|
||||
|
||||
- **пословная привязка** — `onnx-asr` отдаёт потокенные таймкоды
|
||||
(`TimestampedResult`), сегмент режется на границе токена при смене
|
||||
говорящего; ASR по-прежнему видит длинное аудио, контекст RNN-T и пунктуация
|
||||
не страдают. Недоступно на OpenVINO GenAI — там потокенных таймкодов нет;
|
||||
- **диаризация первым проходом**, ASR по интервалам говорящего — чистота
|
||||
гарантирована, но короткие куски лишают RNN-T контекста и портят пунктуацию;
|
||||
- **привязка к сегменту с честной пометкой** — оставить огрубление, но считать
|
||||
чистоту и предупреждать в шапке, как уже делается для повторов и потери
|
||||
хвоста.
|
||||
|
||||
**Уточнить перед запуском:** воспроизводится ли доля 27% на других записях,
|
||||
включая разговор на двоих; правильность границ диаризации на слух, а не только
|
||||
совпадение числа говорящих; калибровка порога кластеризации (на пороге из
|
||||
примеров получилось 29 спикеров вместо трёх); эмбеддинги, обученные не только
|
||||
на английском; производительность на целевом Intel Core i5.
|
||||
|
||||
---
|
||||
|
||||
@@ -252,13 +281,19 @@ SQL`), словарь пользовательский в `.transcriber.toml`.
|
||||
|
||||
---
|
||||
|
||||
### Включение `onnx` в `--device auto`
|
||||
### `large-v3-turbo` для faster-whisper
|
||||
|
||||
**Что:** После периода стабилизации `--device onnx` (несколько недель production-использования без жалоб) — рассмотреть включение в auto-detect chain.
|
||||
**Что:** Добавить `large-v3-turbo` в каталог faster-whisper, чтобы модель была
|
||||
доступна с `--device cuda` и `--device cpu`, а не только через OpenVINO.
|
||||
|
||||
**Порядок в chain (предложение):** CUDA → onnx (если CPU x86_64) → OpenVINO → CPU.
|
||||
**Почему:** На CPU turbo INT8 оказался быстрее `medium` и точнее по WER
|
||||
([сравнение от 2026-08-12](benchmarks/2026-08-12-openvino-large-v3-turbo-comparison.md)),
|
||||
и владельцам NVIDIA этот профиль сейчас недоступен без ручного указания
|
||||
репозитория HuggingFace.
|
||||
|
||||
**Почему откладывается:** политика experimental backend — не сюрпризить существующих пользователей до накопления опыта. Источник: [ADR-006](adr/006-onnx-asr-backend.md#решение).
|
||||
**Почему откладывается:** каталог faster-whisper в проекте собран из
|
||||
репозиториев `Systran`, и для turbo нужно сначала выяснить, какая CT2-сборка
|
||||
годится, проверить её происхождение и качество, и только потом вносить в код.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
# Разведочный замер диаризации sherpa-onnx
|
||||
|
||||
**Дата:** 2026-08-12
|
||||
|
||||
**Статус:** разведка на одной записи и одной нецелевой машине. Не приёмка.
|
||||
|
||||
## Цель
|
||||
|
||||
Ответить на два вопроса перед проектированием диаризации:
|
||||
|
||||
1. Сколько времени диаризация добавляет к транскрипции.
|
||||
2. Достаточно ли приписывать спикера целому ASR-сегменту по мажоритарному
|
||||
перекрытию — то есть допустима ли схема из
|
||||
[бэклога](../backlog.md#диаризация--разделение-говорящих) без пословной
|
||||
привязки.
|
||||
|
||||
Ни модель по умолчанию, ни код проекта в рамках замера не менялись. Все скрипты
|
||||
выполнялись вне репозитория.
|
||||
|
||||
## Ограничения замера
|
||||
|
||||
Результаты ниже — разведка, а не основание для решения:
|
||||
|
||||
- **одна запись** вместо трёх, принятых в [ADR-006](../adr/006-onnx-asr-backend.md);
|
||||
- **нецелевая машина**: AMD Ryzen 7 8845H, тогда как приёмка производительности
|
||||
по бэклогу требует Intel Core i5 11-го поколения;
|
||||
- **границы диаризации не проверены на слух** — сверялось только число
|
||||
говорящих и косвенный текстовый признак;
|
||||
- **порог кластеризации подобран по этой же записи**, то есть на ней же и
|
||||
проверен.
|
||||
|
||||
## Оборудование и условия
|
||||
|
||||
- ноутбук Lenovo 83D5 (та же машина, что в
|
||||
[сравнении turbo](2026-08-12-openvino-large-v3-turbo-comparison.md#повторный-прогон-на-amd-ryzen-7-8845h));
|
||||
- AMD Ryzen 7 8845H, 8 ядер / 16 логических процессоров;
|
||||
- 29,8 ГиБ LPDDR5X;
|
||||
- Windows 11 Корпоративная, сборка 26200, схема питания «Сбалансированная»;
|
||||
- Python 3.13.13, `onnx-asr` 0.12.0, `onnxruntime` 1.28.0,
|
||||
`faster-whisper` 1.2.1, `sherpa-onnx` 1.13.5;
|
||||
- прогоны последовательные, без конкурирующей нагрузки;
|
||||
- модели предварительно скачаны; время загрузки моделей в замер не входит.
|
||||
|
||||
## Контрольная запись
|
||||
|
||||
| Файл | Длительность | Размер | SHA-256 |
|
||||
|---|---|---|---|
|
||||
| `2026-07-10 Data Test внутренний статус.mp4` | 26:00 | 34 217 769 | `1057616B42E8ADD00E0EB975B02BDEF0EC9F6CDFEC6DBF488E0C60423C9B7B87` |
|
||||
|
||||
Запись выбрана потому, что рядом лежит согласованный MoM, из которого известен
|
||||
состав: **три участника** — Маша, Дима, Роман. Это даёт независимую опорную
|
||||
точку для проверки числа говорящих.
|
||||
|
||||
## Модели диаризации
|
||||
|
||||
| Роль | Модель | Размер | Источник |
|
||||
|---|---|---|---|
|
||||
| Сегментация | `sherpa-onnx-pyannote-segmentation-3-0` | 6,9 МБ | релизы `k2-fsa/sherpa-onnx` |
|
||||
| Эмбеддинги | `wespeaker_en_voxceleb_resnet34_LM.onnx` | 26,5 МБ | релизы `k2-fsa/sherpa-onnx` |
|
||||
|
||||
Обе загружаются в `onnxruntime`, torch и токен Hugging Face не требуются.
|
||||
Эмбеддинги обучены на англоязычном VoxCeleb; их пригодность для русской речи в
|
||||
этом замере не проверялась.
|
||||
|
||||
## Стоимость по времени
|
||||
|
||||
Параметры ASR — модель по умолчанию ONNX-пути, `gigaam-v3-e2e-rnnt` INT8,
|
||||
язык задан явно.
|
||||
|
||||
| Стадия | Время | RTFx |
|
||||
|---|---:|---:|
|
||||
| Декодирование аудио | 1,6 с | ~990× |
|
||||
| ASR `gigaam-v3-e2e-rnnt` INT8 | 95,3 с | 16,4× |
|
||||
| Диаризация, 8 потоков | 140,7 с | 11,1× |
|
||||
| **Последовательно, итого** | **236 с** | **6,6×** |
|
||||
|
||||
Диаризация дороже самой транскрипции и занимает около 60% общего времени. При
|
||||
последовательном исполнении 26-минутная запись обрабатывается 3,9 минуты вместо
|
||||
1,6; часовая — примерно 9 минут вместо 3,7.
|
||||
|
||||
Масштабирование по потокам слабое: 4 потока дают 154 с, 8 потоков — 141 с,
|
||||
выигрыш 9%. Закладываться на увеличение числа потоков не следует.
|
||||
|
||||
Проходы ASR и диаризации независимы по данным, поэтому их можно совместить во
|
||||
времени; тогда общее время стремится к максимуму из двух, а не к сумме. Прямой
|
||||
замер параллельного режима не проводился.
|
||||
|
||||
Пиковую память процесса снять не удалось из-за ошибки в измерительном скрипте.
|
||||
|
||||
## Порог кластеризации
|
||||
|
||||
Число говорящих подбиралось автоматически (`num_clusters=-1`); варьировался
|
||||
порог `FastClusteringConfig.threshold`.
|
||||
|
||||
| Конфигурация | Найдено спикеров | Из них с речью ≥30 с | Речь по спикерам, мин |
|
||||
|---|---:|---:|---|
|
||||
| порог 0,5 | 29 | 11 | 7,4 / 5,5 / 1,6 / 1,2 |
|
||||
| порог 0,7 | 12 | 7 | 7,4 / 7,0 / 2,2 / 2,2 |
|
||||
| **порог 0,9** | **4** | **3** | **9,6 / 8,1 / 5,7 / 0,3** |
|
||||
| явное `k=5`, порог 0,5 | 4 | 3 | 9,6 / 8,1 / 5,7 / 0,3 |
|
||||
|
||||
На пороге 0,9 число содержательных кластеров совпало с составом из MoM: три
|
||||
говорящих с 9,6, 8,1 и 5,7 минуты речи плюс остаточный кластер на 0,3 минуты.
|
||||
На пороге 0,5 получилось 29 говорящих вместо трёх — десятикратное
|
||||
переразбиение.
|
||||
|
||||
Время от порога не зависит (153–157 с во всех конфигурациях): кластеризация
|
||||
стоит доли секунды, платится за сегментацию и эмбеддинги.
|
||||
|
||||
Два следствия. Первое: порог кластеризации — основная ручка качества, и
|
||||
значение по умолчанию из примеров `sherpa-onnx` для этого материала непригодно.
|
||||
Второе: одного удачного совпадения на одной записи недостаточно, чтобы принять
|
||||
0,9 за дефолт, а явное указание числа участников нужно как страховка.
|
||||
|
||||
## Чистота ASR-сегментов
|
||||
|
||||
Основной вопрос замера. Для каждого из 274 ASR-сегментов посчитано перекрытие
|
||||
с интервалами каждого говорящего (диаризация на пороге 0,9, 340 интервалов).
|
||||
Чистота — доля мажоритарного говорящего в суммарном перекрытии сегмента.
|
||||
|
||||
| Категория | Сегментов | Доля | Времени |
|
||||
|---|---:|---:|---:|
|
||||
| Чистых, один говорящий | 164 | 59,9% | 8,3 мин |
|
||||
| С поддакиванием, <1 с чужой речи | 30 | 10,9% | 2,4 мин |
|
||||
| **С чужой репликой, ≥1 с** | **74** | **27,0%** | **11,2 мин** |
|
||||
| Без спикера вообще | 6 | 2,2% | 0,0 мин |
|
||||
|
||||
| Порог чистоты | Сегментов ниже порога | Доля | Времени |
|
||||
|---|---:|---:|---:|
|
||||
| < 0,95 | 100 | 36,5% | 13,0 мин |
|
||||
| < 0,90 | 91 | 33,2% | 11,6 мин |
|
||||
| < 0,80 | 72 | 26,3% | 9,1 мин |
|
||||
| < 0,70 | 56 | 20,4% | 7,1 мин |
|
||||
|
||||
**27% сегментов содержат не менее секунды чужой речи, и на них приходится
|
||||
11,2 минуты из 21,9 — больше половины транскрипта.** У 20% сегментов
|
||||
мажоритарный говорящий занимает менее двух третей сегмента.
|
||||
|
||||
### Подтверждение из текста ASR
|
||||
|
||||
Загрязнённые сегменты содержат диалог, и это видно независимо от диаризации —
|
||||
`gigaam-v3-e2e` обучена с диалоговой пунктуацией и сама ставит тире на смене
|
||||
реплики:
|
||||
|
||||
```
|
||||
[533,8-551,1] 17,3 с, мажоритарный spk3, чистота 0,67
|
||||
«— В нашем, по-моему.— В нашем?— В нашем.— А, отлично.— Ну, у нас просто
|
||||
есть некий дата-тест, который на самом деле...— Мы же у них не
|
||||
разворачиваем.—»
|
||||
|
||||
[432,9-448,6] 15,7 с, мажоритарный spk2, чистота 0,58
|
||||
«Дим, а вот то, что ты из образа вытаскивал, там есть чё-то на что
|
||||
посмотреть?— Бэг, бэг там есть, пи»
|
||||
|
||||
[694,2-706,5] 12,3 с, мажоритарный spk2, чистота 0,37
|
||||
spk2 = 5,4 с, spk1 = 4,9 с, spk3 = 4,3 с — три человека в одном сегменте
|
||||
```
|
||||
|
||||
Акустическая диаризация и пунктуация ASR указывают на одно и то же, поэтому
|
||||
доля 27% вряд ли объясняется только ошибками кластеризации.
|
||||
|
||||
Причина загрязнения — в способе нарезки: границы сегментов на ONNX-пути даёт
|
||||
Silero VAD, то есть они проходят по тишине. В разговоре по ВКС участники
|
||||
отвечают встык, паузы длиной с порог VAD не возникает, и диалог попадает в один
|
||||
сегмент. Предположение о том, что задержка канала связи сама создаёт паузу на
|
||||
смене говорящего, этими данными не подтверждается.
|
||||
|
||||
Приведённые сегменты — сырой выход ASR. `formatter.py` объединяет их в абзацы
|
||||
длиной до 60 секунд, поэтому в готовом транскрипте загрязнение будет выше.
|
||||
|
||||
## Выводы
|
||||
|
||||
- Диаризация через `sherpa-onnx` работает на целевой платформе без torch и без
|
||||
токена Hugging Face; связка сегментация + эмбеддинги весит около 33 МБ.
|
||||
- Стоимость — 11,1× RTFx, примерно 1,5 длительности ASR. Последовательный
|
||||
запуск даёт 2,5-кратное замедление, совмещение проходов может сократить
|
||||
накладные расходы, но отдельно не измерялось.
|
||||
- Автоматическая оценка числа говорящих с порогом из примеров даёт 29 спикеров
|
||||
вместо трёх. Порог требует калибровки, а явное указание числа участников —
|
||||
отдельной ручки.
|
||||
- Привязка спикера к целому ASR-сегменту по мажоритарному перекрытию
|
||||
огрубляет результат существенно: 27% сегментов и больше половины времени
|
||||
транскрипта содержат чужую речь длиннее секунды. Схема из бэклога в этом виде
|
||||
непригодна.
|
||||
- `onnx-asr` отдаёт потокенные таймкоды (`TimestampedResult`), поэтому
|
||||
пословная привязка на ONNX-пути достижима. У OpenVINO GenAI такого выхода
|
||||
нет, что ограничивает диаризацию на `--device openvino-*`.
|
||||
|
||||
## Что нужно проверить дальше
|
||||
|
||||
- Повторить измерение чистоты сегментов ещё на двух-трёх записях, включая
|
||||
разговор на двоих, и убедиться, что 27% — не свойство именно этой встречи.
|
||||
- Проверить границы диаризации на слух или сверкой с внешним транскриптом:
|
||||
совпадение числа говорящих не доказывает правильность интервалов.
|
||||
- Сравнить эмбеддинги, обученные не только на английском, на русской речи.
|
||||
- Измерить производительность на доступном слабом Intel baseline — выполнено в
|
||||
[отдельном отчёте](2026-08-14-diarization-intel-i7.md); конкретный Core i5
|
||||
11-го поколения недоступен.
|
||||
- Измерить параллельный режим ASR и диаризации.
|
||||
@@ -0,0 +1,348 @@
|
||||
# Сравнение OpenVINO large-v3-turbo с CPU-профилями
|
||||
|
||||
**Дата:** 2026-08-12
|
||||
|
||||
**Статус:** завершённое сравнение на трёх русскоязычных встречах и двух CPU
|
||||
|
||||
## Цель
|
||||
|
||||
Проверить обновление OpenVINO с линии 2026.0 до 2026.3 и определить, даёт ли
|
||||
`large-v3-turbo` практическое преимущество перед `medium` и быстрым профилем
|
||||
ONNX на CPU. Модель по умолчанию и порядок выбора `--device auto` в рамках
|
||||
сравнения не менялись.
|
||||
|
||||
## Оборудование и условия
|
||||
|
||||
- ноутбук Lenovo 82JD;
|
||||
- Intel Core i7-11800H, 8 ядер / 16 логических процессоров, 2,30 ГГц;
|
||||
- 64 ГиБ DDR4-3200, два модуля Kingston по 32 ГиБ;
|
||||
- Windows 11 Корпоративная, сборка 26200;
|
||||
- схема питания «Сбалансированная»;
|
||||
- OpenVINO 2026.3.0, OpenVINO GenAI и Tokenizers 2026.3.0.0;
|
||||
- Python 3.13.13;
|
||||
- язык во всех командах задан явно: `--language ru`;
|
||||
- модели запускались последовательно, без конкурирующих прогонов.
|
||||
|
||||
Кэш каждой модели предварительно прогревался. Время измерялось от запуска
|
||||
команды до её завершения и включает чтение и декодирование локального файла,
|
||||
но не скачивание модели. Загрузка процессора и свободная память опрашивались
|
||||
раз в две секунды. Минимум свободной памяти характеризует всю систему, а не
|
||||
только процесс распознавания.
|
||||
|
||||
Во время прогонов система оставалась пригодной для обычной интерактивной
|
||||
работы. На OpenVINO средняя загрузка составляла 54–62%. ONNX на двух записях
|
||||
кратковременно занимал процессор полностью, но наблюдаемого зависания системы
|
||||
не было. Это субъективное наблюдение, а не числовой порог приёмки.
|
||||
|
||||
## Контрольный набор
|
||||
|
||||
Использованы те же три записи общей длительностью 43:37 и те же внешние
|
||||
транскрипты, что в [сравнении GigaAM и Whisper](2026-08-11-gigaam-model-comparison.md).
|
||||
Размеры и SHA-256 всех шести локальных файлов совпали с опубликованным там
|
||||
манифестом.
|
||||
|
||||
Внешние транскрипты сами содержат ошибки, редактируют разговорную речь и
|
||||
добавляют разделение по говорящим. Поэтому WER служит сравнительным сигналом
|
||||
внутри одной записи, а не абсолютной оценкой качества.
|
||||
|
||||
## Методика
|
||||
|
||||
Команды OpenVINO имели следующий вид:
|
||||
|
||||
```powershell
|
||||
uv run transcribe <recording.mp4> `
|
||||
--device openvino-cpu `
|
||||
--model <medium|large-v3-turbo> `
|
||||
--compute-type <int8|fp16> `
|
||||
--language ru `
|
||||
--force
|
||||
```
|
||||
|
||||
Для ONNX использовались `--device onnx`, `--model gigaam-v3-e2e-rnnt` и
|
||||
`--compute-type int8`. WER считался после удаления метаданных, таймкодов и
|
||||
обозначений говорящих, приведения к нижнему регистру, замены `ё` на `е` и
|
||||
удаления пунктуации. Итоговый WER — микроусреднение: сумма замен, удалений и
|
||||
вставок делится на общее число слов во внешних текстах. Плотность пунктуации —
|
||||
число знаков `.,!?…:;` на 1000 распознанных слов.
|
||||
|
||||
## Проверка обновления OpenVINO
|
||||
|
||||
До изменения зависимостей OpenVINO 2026.0 `medium` INT8 был повторно запущен
|
||||
на всём наборе с прогретым кэшем. После обновления тот же профиль успешно
|
||||
прошёл автоматические проверки и реальный прогон.
|
||||
|
||||
| Стек и профиль | Суммарное время | RTFx | WER | Пунктуация / 1000 слов | Предупреждения |
|
||||
|---|---:|---:|---:|---:|---:|
|
||||
| OpenVINO 2026.0 medium INT8 | 417,0 с | 6,28× | 28,9% | 191 | 1 |
|
||||
| OpenVINO 2026.3 medium INT8 | 436,7 с | 5,99× | 25,3% | 185 | 0 |
|
||||
|
||||
На линии 2026.0 RSQM содержал 11 одинаковых последовательных сегментов и
|
||||
получил предупреждение о возможной галлюцинации. После обновления повтор не
|
||||
воспроизвёлся, а WER этой записи снизился с 34,2% до 23,3%. Суммарное время
|
||||
выросло на 4,7%; из-за естественного разброса единичных запусков это не следует
|
||||
считать устойчивой регрессией без серии повторов.
|
||||
|
||||
## Результаты после обновления
|
||||
|
||||
### Сводка по трём записям
|
||||
|
||||
| Профиль | Время | RTFx | WER | Пунктуация / 1000 слов | Средний / пиковый CPU | Минимум свободной памяти |
|
||||
|---|---:|---:|---:|---:|---:|---:|
|
||||
| OpenVINO medium INT8 | 436,7 с | 5,99× | 25,3% | 185 | 54,3% / 85% | 36,25 ГиБ |
|
||||
| **OpenVINO large-v3-turbo INT8** | **365,5 с** | **7,16×** | **24,0%** | 96 | 62,0% / 86% | 35,04 ГиБ |
|
||||
| OpenVINO large-v3-turbo FP16 | 545,0 с | 4,80× | 24,8% | 103 | 57,1% / 87% | 32,96 ГиБ |
|
||||
| ONNX GigaAM v3 E2E RNN-T INT8 | **265,7 с** | **9,85×** | 29,1% | **290** | 70,7% / 100% | 35,52 ГиБ |
|
||||
|
||||
### По отдельным записям
|
||||
|
||||
| Запись | Профиль | Время | RTFx | WER | Пунктуация / 1000 слов |
|
||||
|---|---|---:|---:|---:|---:|
|
||||
| BDMA | medium INT8 | 137,0 с | 6,50× | **23,0%** | 194 |
|
||||
| BDMA | turbo INT8 | **118,7 с** | **7,51×** | 24,4% | 79 |
|
||||
| BDMA | turbo FP16 | 180,0 с | 4,95× | 26,2% | 60 |
|
||||
| BDMA | GigaAM RNN-T INT8 | **87,6 с** | **10,17×** | 29,0% | **326** |
|
||||
| RSQM | medium INT8 | 125,7 с | 6,72× | 23,3% | 171 |
|
||||
| RSQM | turbo INT8 | 116,9 с | 7,23× | **22,4%** | 4 |
|
||||
| RSQM | turbo FP16 | 170,2 с | 4,96× | 23,3% | 31 |
|
||||
| RSQM | GigaAM RNN-T INT8 | **92,9 с** | **9,09×** | 27,4% | **286** |
|
||||
| DataCat | medium INT8 | 174,0 с | 5,07× | 28,1% | 188 |
|
||||
| DataCat | turbo INT8 | **129,9 с** | **6,79×** | **24,7%** | 169 |
|
||||
| DataCat | turbo FP16 | 194,7 с | 4,53× | **24,7%** | 181 |
|
||||
| DataCat | GigaAM RNN-T INT8 | **85,2 с** | **10,36×** | 30,4% | **265** |
|
||||
|
||||
## Качественная оценка
|
||||
|
||||
Два независимых субагента читали полные транскрипты без таблицы численных
|
||||
метрик. Один оценивал читаемость и пригодность для конспекта и протокола,
|
||||
второй — сохранность содержания и терминов, пропуски, повторы и галлюцинации.
|
||||
|
||||
### Читаемость и пригодность результата
|
||||
|
||||
| Профиль | Читаемость | Для конспекта | Для протокола |
|
||||
|---|---:|---:|---:|
|
||||
| ONNX GigaAM RNN-T INT8 | **4/5** | **4/5** | **3/5** |
|
||||
| OpenVINO medium INT8 | 3/5 | 3/5 | **3/5** |
|
||||
| OpenVINO turbo INT8 | 2/5 | 3/5 | 2/5 |
|
||||
| OpenVINO turbo FP16 | 2/5 | 3/5 | 2/5 |
|
||||
|
||||
Проверяющий поставил GigaAM на первое место благодаря пунктуации, отделению
|
||||
реплик и естественному ритму. Из текста без труда извлекаются решения: собрать
|
||||
вопросы для архитектуры и сайзинга, не связываться с «дата-рентгеном» в пилоте,
|
||||
уточнить способ доступа к FineBI. При этом протокол требует сверки терминов:
|
||||
`XML` превращается в «Максмельная», `Spark` — в «парк», `MPP` — в «MP-шный».
|
||||
|
||||
Medium в основном сохраняет ход мысли, но нестабилен в названиях: «бутылочка в
|
||||
FineBI» вместо учётной записи, `NiFi` как `I5`, `edge cases` как `HKEYS`.
|
||||
Оба turbo-профиля иногда лучше узнают отдельные имена и термины, включая
|
||||
«Рома Иваницкий», `Open Lineage` и `DBC tables V`, однако длинные блоки почти
|
||||
без пунктуации приходится разбирать вручную. Практического преимущества FP16
|
||||
над INT8 по читаемости не найдено.
|
||||
|
||||
### Сохранность содержания
|
||||
|
||||
| Профиль | Смысл | Термины | Пропуски | Повторы и выдумки |
|
||||
|---|---:|---:|---:|---:|
|
||||
| ONNX GigaAM RNN-T INT8 | **5/5** | **4/5** | **4/5** | **4/5** |
|
||||
| OpenVINO medium INT8 | 4/5 | 3/5 | **4/5** | **4/5** |
|
||||
| OpenVINO turbo FP16 | 4/5 | 3/5 | 3/5 | **4/5** |
|
||||
| OpenVINO turbo INT8 | 3/5 | 3/5 | 3/5 | **4/5** |
|
||||
|
||||
Второй проверяющий также поставил GigaAM первым: он лучше удерживает ход
|
||||
рассуждения, отрицания и поручения. Среди OpenVINO medium оказался надёжнее
|
||||
обоих turbo-профилей по сохранности содержания, несмотря на худший WER.
|
||||
|
||||
Наиболее опасные искажения:
|
||||
|
||||
- turbo FP16 в DataCat пропустил оговорку о том, что API-контракт извлечён из
|
||||
JAR-файлов, а не документации; предположение выглядит установленным фактом;
|
||||
- turbo INT8 почти потерял обсуждение доступа к DBC, риска перегрузить FineBI
|
||||
через API и необходимости ограничить частоту запросов;
|
||||
- medium превратил поручение посчитать объём и квоту Teradata в плохо читаемое
|
||||
«нам требование это продать… конкретный объём в продате квоту»;
|
||||
- GigaAM один раз заменил «МТС хочет продать дата-рентген» на «ты хочешь
|
||||
продать дата-рентген», хотя соседняя фраза восстанавливает инициатора;
|
||||
- ключ поиска `IMSI плюс время` не сохранился точно ни в одном профиле;
|
||||
- у medium после фактического окончания RSQM появился одиночный сегмент «В».
|
||||
|
||||
Длинных повторяющихся или полностью выдуманных фрагментов после обновления
|
||||
проверяющие не нашли. Общий недостаток всех профилей — отсутствие надёжного
|
||||
разделения по участникам, что ограничивает пригодность для формального протокола.
|
||||
|
||||
## Повторный прогон на AMD Ryzen 7 8845H
|
||||
|
||||
Повторный прогон выполнен на втором ноутбуке тем же коммитом, на тех же шести
|
||||
файлах и с теми же параметрами. Размеры и SHA-256 всех файлов совпали с
|
||||
манифестом. Результаты Intel выше не перезаписывались.
|
||||
|
||||
### Оборудование и условия
|
||||
|
||||
- ноутбук Lenovo 83D5;
|
||||
- AMD Ryzen 7 8845H, 8 ядер / 16 логических процессоров;
|
||||
- 32 ГиБ LPDDR5X, четыре чипа Micron, фактическая скорость 6400 MT/s;
|
||||
- Windows 11 Корпоративная, сборка 26200;
|
||||
- схема питания «Сбалансированная»;
|
||||
- OpenVINO 2026.3.0, OpenVINO GenAI и Tokenizers 2026.3.0.0;
|
||||
- Python 3.13.13;
|
||||
- язык во всех командах задан явно: `--language ru`;
|
||||
- кэш каждой модели предварительно прогрет, скачивание не включено во время;
|
||||
- загрузка CPU и свободная память опрашивались раз в две секунды.
|
||||
|
||||
Во время всех прогонов ноутбук оставался пригодным для интерактивной работы.
|
||||
Минимум свободной памяти ниже, чем на Intel, из-за 32 ГиБ физической памяти и
|
||||
фоновой нагрузки; это показатель всей системы, а не потребления одной модели.
|
||||
|
||||
### Сводка по трём записям
|
||||
|
||||
| Профиль | Время | RTFx | WER | Пунктуация / 1000 слов | Средний / пиковый CPU | Минимум свободной памяти |
|
||||
|---|---:|---:|---:|---:|---:|---:|
|
||||
| OpenVINO medium INT8 | 438,5 с | 5,97× | 22,9% | 193 | 59,2% / 89% | 6,65 ГиБ |
|
||||
| **OpenVINO large-v3-turbo INT8** | **267,4 с** | **9,79×** | **19,6%** | 108 | 57,4% / 72% | 8,93 ГиБ |
|
||||
| OpenVINO large-v3-turbo FP16 | 333,6 с | 7,85× | 20,1% | 114 | 59,0% / 78% | 8,39 ГиБ |
|
||||
| **ONNX GigaAM v3 E2E RNN-T INT8** | **194,8 с** | **13,44×** | 26,9% | **290** | 56,2% / 82% | 11,71 ГиБ |
|
||||
|
||||
Для `medium` на RSQM получились три измеряемых времени: 158,8, 118,3 и
|
||||
129,3 секунды. Все три транскрипта и отдельный прогревочный проход содержательно
|
||||
совпали. В таблице использована медиана 129,3 секунды, чтобы не выбирать
|
||||
произвольно единичный фоновый выброс.
|
||||
|
||||
### По отдельным записям
|
||||
|
||||
| Запись | Профиль | Время | RTFx | WER | Пунктуация / 1000 слов |
|
||||
|---|---|---:|---:|---:|---:|
|
||||
| BDMA | medium INT8 | 140,3 с | 6,35× | 25,0% | 202 |
|
||||
| BDMA | turbo INT8 | **88,8 с** | **10,03×** | 24,5% | 55 |
|
||||
| BDMA | turbo FP16 | 109,1 с | 8,16× | **24,4%** | 61 |
|
||||
| BDMA | GigaAM RNN-T INT8 | **67,5 с** | **13,21×** | 29,0% | **326** |
|
||||
| RSQM | medium INT8 | 129,3 с | 6,53× | 20,2% | **175** |
|
||||
| RSQM | turbo INT8 | **82,1 с** | **10,28×** | **16,7%** | 61 |
|
||||
| RSQM | turbo FP16 | 104,6 с | 8,07× | 20,4% | 48 |
|
||||
| RSQM | GigaAM RNN-T INT8 | **58,6 с** | **14,41×** | 25,4% | **286** |
|
||||
| DataCat | medium INT8 | 168,9 с | 5,22× | 23,2% | **199** |
|
||||
| DataCat | turbo INT8 | **96,5 с** | **9,14×** | 18,0% | 178 |
|
||||
| DataCat | turbo FP16 | 119,8 с | 7,36× | **16,8%** | 197 |
|
||||
| DataCat | GigaAM RNN-T INT8 | **68,7 с** | **12,83×** | 26,4% | **265** |
|
||||
|
||||
### Повторы `medium` на паузах
|
||||
|
||||
На линии 2026.0 один контрольный прогон RSQM на Intel содержал 11 одинаковых
|
||||
последовательных сегментов. После обновления на Ryzen выполнены четыре прохода
|
||||
того же файла: один прогревочный и три измеряемых. Во всех четырёх получены
|
||||
одинаковые 152 сегмента, объединённые форматтером в 17 абзацев. Детектор серий
|
||||
из четырёх и более одинаковых сегментов не сработал, различий в содержательной
|
||||
части файлов нет, ложного хвоста также нет.
|
||||
|
||||
На этом контрольном файле повтор после OpenVINO 2026.3 устойчиво не
|
||||
воспроизводится на двух машинах. Это сильнее единичного успешного запуска, но
|
||||
не доказывает устранение всего класса Whisper-галлюцинаций на тишине: набор
|
||||
содержит только одну ранее проблемную запись.
|
||||
|
||||
### Отличия Ryzen от Intel
|
||||
|
||||
- `medium` показал практически одинаковое суммарное время: 438,5 против
|
||||
436,7 секунды.
|
||||
- Turbo INT8 на Ryzen быстрее на 27%: 267,4 против 365,5 секунды; FP16 быстрее
|
||||
на 39%: 333,6 против 545,0 секунды.
|
||||
- GigaAM на Ryzen быстрее на 27%: 194,8 против 265,7 секунды.
|
||||
- На Ryzen Turbo INT8 ускоряет обработку относительно `medium` на 39%, а
|
||||
GigaAM — на 56%.
|
||||
- Численные WER на Ryzen ниже у всех четырёх профилей. Поскольку файлы,
|
||||
версии библиотек и заявленная методика совпадают, но сами выходы различаются.
|
||||
Сравнение характеризует всю среду выполнения; приписывать разницу только
|
||||
архитектуре CPU без отдельного исследования нельзя.
|
||||
- Пунктуация Turbo остаётся нестабильной: на Ryzen она лучше результата Intel
|
||||
для RSQM, но всё ещё значительно реже, чем у `medium` и GigaAM.
|
||||
|
||||
### Независимая оценка качества на Ryzen
|
||||
|
||||
Как и на первом ноутбуке, два субагента читали полные транскрипты вслепую, без
|
||||
названий профилей и таблицы численных метрик. Их оценки разошлись по ожидаемой
|
||||
причине: один ставил выше готовность текста к чтению, второй — точность
|
||||
технических деталей.
|
||||
|
||||
| Профиль | Читаемость | Для конспекта | Для протокола |
|
||||
|---|---:|---:|---:|
|
||||
| ONNX GigaAM RNN-T INT8 | **3/5** | **4/5** | **3/5** |
|
||||
| OpenVINO medium INT8 | **3/5** | 3/5 | 2/5 |
|
||||
| OpenVINO turbo FP16 | 2/5 | 3/5 | 2/5 |
|
||||
| OpenVINO turbo INT8 | 2/5 | 2/5 | 2/5 |
|
||||
|
||||
По читаемости GigaAM снова занял первое место: он лучше размечает смысловые
|
||||
границы, сохраняет бытовые и организационные реплики и требует меньше ручной
|
||||
чистки для чернового конспекта. `medium` оказался вторым. У обоих Turbo начало
|
||||
нескольких записей превращается в длинные блоки нижнего регистра почти без
|
||||
пунктуации. Ни один вариант не признан готовым формальным протоколом без сверки.
|
||||
|
||||
| Профиль | Смысл | Термины | Полнота | Нет повторов и выдумок |
|
||||
|---|---:|---:|---:|---:|
|
||||
| OpenVINO turbo FP16 | **4/5** | **4/5** | 4/5 | **4/5** |
|
||||
| OpenVINO turbo INT8 | 4/5 | **4/5** | 4/5 | **4/5** |
|
||||
| OpenVINO medium INT8 | 4/5 | **4/5** | 4/5 | 3/5 |
|
||||
| ONNX GigaAM RNN-T INT8 | 4/5 | 3/5 | **5/5** | 3/5 |
|
||||
|
||||
По технической сохранности первым стал Turbo FP16, особенно на DataCat; Turbo
|
||||
INT8 почти равен ему. Они лучше удерживают `FineBI`, `OpenLineage`, `Spark`,
|
||||
`XML`, `DBC TablesV/ColumnsV`, `Open Platform` и архитектурные связи. GigaAM
|
||||
самый полный, но чаще заменяет неразобранное гладко звучащими ложными терминами.
|
||||
|
||||
Наиболее опасные места:
|
||||
|
||||
- GigaAM потерял `IMSI` в требуемом ключе `IMSI плюс время`, исказил основание
|
||||
оставить MPP-вычисления в Teradata и не сохранил Alation как технический
|
||||
ориентир интеграции с FineBI;
|
||||
- `medium` заменил Tele2 на `TeraData 2` и «админскую учётку» на «учётку от
|
||||
Минска», то есть правдоподобно изменил адресата и источник доступа;
|
||||
- Turbo INT8 почти потерял риск DDoS FineBI через API и необходимость
|
||||
ограничивать частоту запросов; в другом месте ответ о production-базах
|
||||
локально перевёрнут;
|
||||
- Turbo FP16 хуже остальных передал `edge cases` на RSQM и потерял поручение
|
||||
оценить требования и квоту Teradata, но лучше всего сохранил технически
|
||||
насыщенный DataCat.
|
||||
|
||||
Ни один проверяющий не нашёл длинных повторяющихся или автономно выдуманных
|
||||
блоков. Основной остаточный риск всех профилей — локальная правдоподобная
|
||||
подстановка термина, числа или адресата.
|
||||
|
||||
### Что это означает для профиля по умолчанию
|
||||
|
||||
Результаты поддерживают ONNX GigaAM RNN-T как основной CPU-профиль: он быстрее
|
||||
всех на обеих машинах, дважды занял первое место по читаемости и пригодности
|
||||
для конспекта и использует VAD. После обсуждения принята простая политика:
|
||||
`--device auto` выбирает CUDA при наличии `nvidia-smi`, иначе ONNX.
|
||||
|
||||
Ограничения выбора сохраняются:
|
||||
|
||||
- GigaAM ориентирован на русский язык и хуже сохраняет латиницу, аббревиатуры,
|
||||
названия систем и компаний;
|
||||
- OpenVINO Whisper остаётся многоязычным путём и умеет использовать Intel GPU;
|
||||
- Turbo лучше GigaAM по WER и техническим терминам, хотя хуже подготовлен для
|
||||
непосредственного чтения;
|
||||
- изменение существующего auto-пути является несовместимым изменением поведения;
|
||||
явные значения из CLI и конфигурации остаются способом сохранить прежний
|
||||
OpenVINO/Whisper-профиль или выбрать Turbo для технически насыщенных встреч.
|
||||
|
||||
## Вывод
|
||||
|
||||
- `large-v3-turbo` реально работает с OpenVINO 2026.3 в вариантах INT8 и FP16.
|
||||
- Turbo INT8 оказался на 16% быстрее medium INT8 и снизил суммарный WER с
|
||||
25,3% до 24,0%. Это лучший OpenVINO-профиль по численным показателям на
|
||||
данном CPU, но не по независимой оценке сохранности содержания и читаемости.
|
||||
- Turbo FP16 на этом CPU на 49% медленнее turbo INT8 и не дал выигрыша по WER.
|
||||
Его стоит оставлять доступным для явного выбора и других устройств, но не
|
||||
рекомендовать как основной CPU-профиль.
|
||||
- GigaAM RNN-T остаётся самым быстрым и лучше всех расставляет пунктуацию. Он
|
||||
уступает turbo по WER и сильнее загружает CPU, но оба независимых проверяющих
|
||||
признали его лучшим для чтения и сохранения делового содержания.
|
||||
- У turbo INT8 пунктуация нестабильна: на RSQM её почти нет. Для готового
|
||||
протокола может потребоваться последующая расстановка пунктуации.
|
||||
- Автоматических предупреждений о повторах или потере хвоста после обновления
|
||||
не было; ручная проверка нашла у medium одиночный ложный сегмент в конце RSQM.
|
||||
- В самом benchmark-коммите модель по умолчанию и порядок `--device auto` не
|
||||
менялись; по итогам анализа отдельно принято решение использовать ONNX на
|
||||
машинах без CUDA.
|
||||
- Повторный Ryzen-прогон подтвердил, что прежний блок из 11 повторов `medium`
|
||||
не воспроизводится: четыре последовательных прохода дали одинаковый текст без
|
||||
повторов и ложного хвоста. Это подтверждение для контрольного файла, а не
|
||||
гарантия устранения всех Whisper-галлюцинаций на тишине.
|
||||
- Для русской речи на CPU ONNX GigaAM RNN-T выглядит лучшим пользовательским
|
||||
дефолтом по скорости и готовности текста к чтению. OpenVINO сохраняет сильные
|
||||
аргументы для Intel GPU, других языков и технически насыщенных записей.
|
||||
@@ -0,0 +1,102 @@
|
||||
# Смешение говорящих внутри ASR-сегментов
|
||||
|
||||
**Дата:** 2026-08-14
|
||||
|
||||
**Статус:** проверка на трёх русскоязычных рабочих созвонах. Не оценка DER и
|
||||
не решение о единице привязки спикера к тексту.
|
||||
|
||||
## Вопрос
|
||||
|
||||
Воспроизводится ли смешение говорящих внутри ASR-сегментов на других записях,
|
||||
или результат разведки — особенность одной встречи на троих?
|
||||
|
||||
В [разведочном замере](2026-08-12-diarization-feasibility.md) 27% сегментов
|
||||
контрольной записи содержали не меньше секунды чужой речи. На них приходилось
|
||||
больше половины времени ASR-сегментов. Проверка повторена на тех же трёх
|
||||
записях, на которых калибровалась диаризация, включая два разговора на двоих.
|
||||
|
||||
## Метод
|
||||
|
||||
ASR выполнялся через модель по умолчанию ONNX-пути
|
||||
`gigaam-v3-e2e-rnnt` INT8 с языком `ru`. Диаризация выполнялась через
|
||||
`sherpa-onnx` 1.13.5 с выбранной в
|
||||
[калибровке](2026-08-14-diarization-calibration.md) конфигурацией:
|
||||
|
||||
- сегментация `sherpa-onnx-pyannote-segmentation-3-0`;
|
||||
- эмбеддинги `wespeaker_en_voxceleb_resnet34_LM.onnx`;
|
||||
- `FastClusteringConfig.threshold=0.89`;
|
||||
- автоматическое определение числа кластеров (`num_clusters=-1`).
|
||||
|
||||
Для каждого ASR-сегмента считалось перекрытие с интервалами каждого кластера.
|
||||
Кластер с максимальным перекрытием считался мажоритарным, а сумма перекрытий
|
||||
остальных кластеров — чужой речью. Сегменты разделены на четыре категории:
|
||||
|
||||
- **чистый** — перекрытие только с одним кластером;
|
||||
- **с поддакиванием** — меньше 1 секунды чужой речи;
|
||||
- **с чужой репликой** — не меньше 1 секунды чужой речи;
|
||||
- **без спикера** — нет перекрытия с интервалами диаризации.
|
||||
|
||||
Время категории — сумма длительностей попавших в неё ASR-сегментов. Это не
|
||||
сумма времени речи: интервалы разных кластеров могут перекрываться при
|
||||
наложенной речи. Метрика отвечает на узкий вопрос, насколько огрубляет текст
|
||||
одна метка спикера на весь ASR-сегмент. Она не измеряет точность диаризации.
|
||||
|
||||
## Результаты
|
||||
|
||||
| Запись | Участников | ASR-сегментов | Чистые | Поддакивание <1 с | Чужая реплика ≥1 с | Без спикера |
|
||||
|---|---:|---:|---:|---:|---:|---:|
|
||||
| Data Test | 3 | 274 | 164 (59,9%) | 30 (10,9%) | **74 (27,0%)** | 6 (2,2%) |
|
||||
| T2 BDMA | 2 | 223 | 167 (74,9%) | 34 (15,2%) | **14 (6,3%)** | 8 (3,6%) |
|
||||
| Yantar | 2 | 339 | 275 (81,1%) | 20 (5,9%) | **24 (7,1%)** | 20 (5,9%) |
|
||||
|
||||
| Запись | Время всех ASR-сегментов | Время сегментов с чужой репликой ≥1 с | Доля времени |
|
||||
|---|---:|---:|---:|
|
||||
| Data Test | 21,89 мин | **11,20 мин** | **51,2%** |
|
||||
| T2 BDMA | 12,56 мин | **1,53 мин** | **12,2%** |
|
||||
| Yantar | 15,93 мин | **2,67 мин** | **16,8%** |
|
||||
|
||||
На двух разговорах на двоих вместе чужая реплика не меньше секунды встречается
|
||||
в 38 из 562 сегментов (6,8%) и затрагивает 4,20 из 28,49 минуты (14,7%). По
|
||||
сравнению со встречей на троих это в четыре раза меньше по доле сегментов и
|
||||
примерно в 3,5 раза меньше по доле времени.
|
||||
|
||||
## Вывод
|
||||
|
||||
Смешение говорящих внутри ASR-сегмента — общее свойство проверенного материала,
|
||||
а не аномалия одной записи: оно воспроизвелось на обоих разговорах на двоих.
|
||||
Однако тяжесть сильно зависит от характера разговора. Значение 27% сегментов и
|
||||
51% времени не переносится на двухсторонние созвоны: там получено 6–7%
|
||||
сегментов и 12–17% времени.
|
||||
|
||||
Мажоритарная метка на весь ASR-сегмент поэтому остаётся заметным огрублением
|
||||
даже на разговорах на двоих, а на плотной встрече втроём теряет реплики в
|
||||
массовом масштабе. Эти данные не выбирают единицу привязки сами по себе, но
|
||||
исключают предположение, что сегментная привязка безопасна для всех обычных
|
||||
созвонов без пословной привязки или явной пометки качества.
|
||||
|
||||
## Ограничения
|
||||
|
||||
- Все три записи — русскоязычные рабочие созвоны одного пользователя; другие
|
||||
микрофоны, шумы и стили разговора не представлены.
|
||||
- Диаризация служит опорной разметкой и сама содержит ошибки. На контрольной
|
||||
записи есть ложный остаточный кластер, редкие пропуски и неполная разметка
|
||||
перекрывающейся речи.
|
||||
- На двухсторонних записях один голос заметно доминирует по времени. Баланс
|
||||
реплик может влиять на долю смешанных сегментов.
|
||||
- Порог 1 секунда разделяет короткие вставки и потенциально потерянные реплики,
|
||||
но не доказывает смысловую важность каждого фрагмента.
|
||||
|
||||
## Воспроизводимость
|
||||
|
||||
Расчёт выполняется скриптом
|
||||
[`bench_conflict.py`](../../.scratch/diarization/bench_conflict.py):
|
||||
|
||||
```powershell
|
||||
$env:PYTHONIOENCODING = "utf-8"
|
||||
uv run --with sherpa-onnx python .scratch/diarization/bench_conflict.py `
|
||||
"<путь к записи>" 0.89 8
|
||||
```
|
||||
|
||||
Медиа и сырые JSON не добавлены в репозиторий: они содержат локальные пути и
|
||||
относятся к рабочим созвонам. SHA-256 всех трёх исходных файлов зафиксированы в
|
||||
[отчёте о калибровке](2026-08-14-diarization-calibration.md).
|
||||
@@ -0,0 +1,210 @@
|
||||
# Калибровка модели эмбеддингов и порога диаризации
|
||||
|
||||
**Дата:** 2026-08-14
|
||||
|
||||
**Статус:** выбор конфигурации для проектирования. Производительность отдельно
|
||||
проверена на [доступном старом Intel baseline](2026-08-14-diarization-intel-i7.md).
|
||||
|
||||
## Решение
|
||||
|
||||
Для автоматического определения числа участников использовать:
|
||||
|
||||
- эмбеддинги `wespeaker_en_voxceleb_resnet34_LM.onnx`;
|
||||
- `FastClusteringConfig.threshold=0.89`;
|
||||
- `num_clusters=-1` по умолчанию.
|
||||
|
||||
Если число участников известно, передавать его через `num_clusters`: это
|
||||
устраняет остаточные кластеры и служит страховкой от особенностей записи. На
|
||||
трёх проверенных фрагментах явное число участников не ухудшило прокси-метрику
|
||||
качества WeSpeaker.
|
||||
|
||||
Двуязычная `3dspeaker_speech_campplus_sv_zh_en_16k-common_advanced.onnx`
|
||||
быстрее и при известном числе участников лучше на одной из двух записей с
|
||||
таймкодами, но для автоматического режима не нашлось общего порога без лишних
|
||||
кластеров или склейки реальных голосов. Поэтому она не выбрана по умолчанию.
|
||||
|
||||
## Что проверялось
|
||||
|
||||
Свип выполнялся на трёх русскоязычных рабочих созвонах с известным составом:
|
||||
|
||||
| Запись | Участников | Короткий фрагмент | Полный прогон кандидата | SHA-256 |
|
||||
|---|---:|---:|---:|---|
|
||||
| `2026-07-10 Data Test внутренний статус.mp4` | 3 | 07:00–12:00 | 25:59,9 | `1057616B42E8ADD00E0EB975B02BDEF0EC9F6CDFEC6DBF488E0C60423C9B7B87` |
|
||||
| `2026-07-29 T2 BDMA уточнение задачи от Ильи.mp4` | 2 | 00:00–05:00 | 14:50,9 | `51866D247FE3EDA134CDD884F707B1F1DB8855B492E8BD14D2B56B62476255ED` |
|
||||
| `2026-08-12 Созвон с Максом Мерлином по T2 Forecast и Yantar.mp4` | 2 | 00:00–05:00 | 20:22,2 | `4422F04E2771091A0648E5422D14A31DD7CA2C8EEF7ED9F4A5C63F43D8CA6400` |
|
||||
|
||||
Для первой записи число участников взято из согласованного MoM и не зависит от
|
||||
диаризации. Для двух остальных рядом с медиа лежат транскрипты Hypescribe с
|
||||
таймкодами и метками спикеров. Они получены другим инструментом и использованы
|
||||
как независимая грубая опорная разметка.
|
||||
|
||||
Hypescribe ставит метку только в начале реплики и не размечает точные границы,
|
||||
тишину и наложения голосов. Поэтому ниже считается не DER, а **mapped speaker
|
||||
purity**: лучший взаимно-однозначный маппинг кластеров на опорные метки по
|
||||
суммарному перекрытию. Метрика подходит для сравнения конфигураций на одной
|
||||
записи, но не является абсолютной оценкой диаризации.
|
||||
|
||||
Кластер считается содержательным, если в нём не меньше `max(5 с, 2% длины
|
||||
записи)` речи. Это только диагностический показатель: готовый CLI не должен
|
||||
молча отбрасывать малые кластеры без отдельного решения.
|
||||
|
||||
## Модели
|
||||
|
||||
Во всех прогонах использовалась одна сегментация
|
||||
`sherpa-onnx-pyannote-segmentation-3-0`.
|
||||
|
||||
| Роль | Модель | Языки обучения | Размер | SHA-256 |
|
||||
|---|---|---|---:|---|
|
||||
| выбранная | `wespeaker_en_voxceleb_resnet34_LM.onnx` | английский, VoxCeleb2 | 26 530 550 | `E9848563DA86F263117134DFD7AD63C92355B37DE492B55E325400C9D9C39012` |
|
||||
| многоязычная альтернатива | `3dspeaker_speech_campplus_sv_zh_en_16k-common_advanced.onnx` | китайский + английский | 28 281 164 | `AA3CFC16963A10586A9393F5035D6D6B57E98D358B347F80C2A30BF4F00CEBA2` |
|
||||
| дополнительная разведка | `3dspeaker_speech_eres2net_base_sv_zh-cn_3dspeaker_16k.onnx` | китайский | 39 593 761 | `1A331345F04805BADBB495C775A6DDFFCDD1A732567D5EC8B3D5749E3C7A5E4B` |
|
||||
| сегментация | `model.onnx` из `sherpa-onnx-pyannote-segmentation-3-0` | — | 5 992 913 | `220AD67CA923BEF2FA91F2390C786097BF305BCEB5E261D4AF67B38E938E1079` |
|
||||
|
||||
WeSpeaker сам помечает VoxCeleb-модель как английскую и распространяет её под
|
||||
CC BY 4.0. Репозиторий 3D-Speaker и модель CAMPPlus на ModelScope используют
|
||||
Apache 2.0; в исходниках 3D-Speaker модель явно описана как обученная на
|
||||
большом китайско-английском корпусе. ONNX-файлы брались из официального релиза
|
||||
`k2-fsa/sherpa-onnx`, а не из сторонних зеркал.
|
||||
|
||||
Источники:
|
||||
|
||||
- [список и лицензирование моделей WeSpeaker](https://github.com/wenet-e2e/wespeaker/blob/master/docs/pretrained.md);
|
||||
- [карточка `wespeaker-voxceleb-resnet34-LM`](https://huggingface.co/Wespeaker/wespeaker-voxceleb-resnet34-LM);
|
||||
- [исходники и лицензия 3D-Speaker](https://github.com/modelscope/3D-Speaker);
|
||||
- [официальный релиз ONNX-моделей sherpa-onnx](https://github.com/k2-fsa/sherpa-onnx/releases/tag/speaker-recongition-models).
|
||||
|
||||
## Свип WeSpeaker
|
||||
|
||||
Порог сначала проверялся крупным шагом, затем уточнялся около переходов между
|
||||
числом кластеров. В ячейках — общее число кластеров; жирным выделено точное
|
||||
совпадение с известным числом участников.
|
||||
|
||||
| Порог | Data Test, 3 | T2 BDMA, 2 | Yantar, 2 |
|
||||
|---:|---:|---:|---:|
|
||||
| 0,85 | **3** | **2** | 3 |
|
||||
| 0,87 | **3** | **2** | 3 |
|
||||
| 0,88 | **3** | **2** | 3 |
|
||||
| **0,89** | **3** | **2** | **2** |
|
||||
| 0,90 | 2 | **2** | **2** |
|
||||
| 0,95 | 2 | **2** | 1 |
|
||||
| явное `num_clusters` | **3** | **2** | **2** |
|
||||
|
||||
`0,89` — единственное проверенное значение, которое без знания числа
|
||||
участников дало правильное количество кластеров на всех трёх фрагментах. На
|
||||
двух записях с опорными метками purity составила 0,767 и 0,787. Явное число
|
||||
участников дало те же значения.
|
||||
|
||||
## Сравнение с 3D-Speaker
|
||||
|
||||
### CAMPPlus, китайский + английский
|
||||
|
||||
| Порог | Data Test, 3 | T2 BDMA, 2 | Yantar, 2 |
|
||||
|---:|---:|---:|---:|
|
||||
| 0,85 | 7 | 7 | 7 |
|
||||
| 0,90 | 6 | 5 | 5 |
|
||||
| 0,95 | 5 | 5 | 5 |
|
||||
| 0,99 | 4 | 4 | 4 |
|
||||
| 1,00 | 4 | 4 | 4 |
|
||||
| 1,05 | **3** | 3 | 3 |
|
||||
| 1,10 | 2 | **2** | 3 |
|
||||
| явное `num_clusters` | **3** | **2** | **2** |
|
||||
|
||||
При `1,05` на двух записях остаётся по одному малому остаточному кластеру, а
|
||||
при `1,10` трёхсторонняя встреча уже склеивается до двух голосов. Общего
|
||||
автоматического порога нет.
|
||||
|
||||
При явном числе участников purity равна 0,801 на T2 BDMA и 0,911 на Yantar.
|
||||
Это лучше WeSpeaker на 0,034 и 0,124 соответственно. Однако на контрольной
|
||||
трёхсторонней записи один из трёх принудительных кластеров оказался меньше
|
||||
порога содержательности, поэтому улучшение по двум текстовым прокси нельзя
|
||||
обобщать на все записи.
|
||||
|
||||
### ERes2Net base, китайский
|
||||
|
||||
Эта модель проверялась дополнительно, но не считается выполнением требования
|
||||
о многоязычной альтернативе. Даже на пороге 0,99 она дала 5 / 4 / 7 кластеров
|
||||
вместо 3 / 2 / 2. При явном числе участников purity составила 0,688 и 0,907:
|
||||
результат неоднородный и автоматический режим заметно хуже выбранного.
|
||||
|
||||
## Полные прогоны выбранного кандидата
|
||||
|
||||
После свипа `WeSpeaker + 0,89` прогнан на всех трёх записях целиком.
|
||||
|
||||
| Запись | Кластеры | Содержательные | Речь по кластерам, с | Остаток сверх ожидаемых | Purity | Время | RTF |
|
||||
|---|---:|---:|---|---:|---:|---:|---:|
|
||||
| Data Test | 4 | 3 | 573,1 / 487,4 / 342,2 / 19,1 | 1,3% | — | 189,0 с | 0,121 |
|
||||
| T2 BDMA | 2 | 2 | 748,7 / 52,3 | 0% | 0,749 | 112,3 с | 0,126 |
|
||||
| Yantar | 2 | 2 | 733,2 / 259,8 | 0% | 0,906 | 151,4 с | 0,124 |
|
||||
|
||||
На полной контрольной записи остаётся ложный кластер на 19,1 с, но три
|
||||
содержательных кластера совпадают с известным составом. Повторный полный прогон
|
||||
на 0,9 дал тот же результат: JSON-массивы всех 340 интервалов на 0,89 и 0,9
|
||||
совпали в точности, включая границы и номера кластеров. Поэтому к кандидату
|
||||
0,89 непосредственно применима слуховая проверка отрезка 07:00–12:00,
|
||||
выполненная для результата из
|
||||
[разведочного замера](2026-08-12-diarization-feasibility.md): три основных
|
||||
голоса стабильны, остаточный кластер ложный, есть небольшие пропуски второго
|
||||
голоса, а наложения голосов определяются не полностью. Новая калибровка не
|
||||
устраняет эти ограничения сегментации.
|
||||
|
||||
На двух полных разговорах purity отличается от короткого фрагмента: 0,749
|
||||
против 0,767 и 0,906 против 0,787. Это подтверждает, что короткий свип годится
|
||||
для отсева конфигураций, а финальный кандидат надо проверять целиком.
|
||||
|
||||
## Производительность
|
||||
|
||||
Условия: AMD Ryzen 7 8845H, Windows 11 build 26200, Python 3.13.13,
|
||||
`sherpa-onnx` 1.13.5, `onnxruntime` 1.28.0, NumPy 2.4.3, 8 потоков CPU.
|
||||
Загрузка моделей и декодирование медиа не входят в измерение.
|
||||
|
||||
Средний RTF на коротких фрагментах:
|
||||
|
||||
| Модель | RTF | Относительно WeSpeaker |
|
||||
|---|---:|---:|
|
||||
| WeSpeaker ResNet34 LM | 0,118 | 1,00× |
|
||||
| CAMPPlus zh/en | 0,083 | 0,70× |
|
||||
| ERes2Net base zh | 0,152 | 1,29× |
|
||||
|
||||
CAMPPlus примерно на 30% быстрее WeSpeaker в этом эксперименте. Это плюс для
|
||||
варианта с известным числом участников. Производительность выбранной WeSpeaker
|
||||
отдельно проверена на доступном старом Intel Core i7.
|
||||
|
||||
## Воспроизводимость
|
||||
|
||||
Свип выполняется скриптом
|
||||
[`scripts/benchmarks/diarization_calibration.py`](../../scripts/benchmarks/diarization_calibration.py).
|
||||
Он принимает JSON-манифест с путями к моделям и записям, декодирует указанные
|
||||
фрагменты через ffmpeg, последовательно сохраняет каждый результат и может
|
||||
возобновить прерванный прогон.
|
||||
|
||||
Пример:
|
||||
|
||||
```powershell
|
||||
uv run python scripts/benchmarks/diarization_calibration.py `
|
||||
--manifest diarization-calibration.json `
|
||||
--output diarization-calibration-results.json `
|
||||
--work-dir .scratch/diarization-calibration `
|
||||
--threads 8
|
||||
```
|
||||
|
||||
Сырые JSON содержат локальные пути к конфиденциальным рабочим записям и сами
|
||||
интервалы диаризации, поэтому в репозиторий не добавляются. Для проверки
|
||||
артефактов выше приведены SHA-256 медиа и моделей.
|
||||
|
||||
## Ограничения и следующий шаг
|
||||
|
||||
- Три записи принадлежат одному типу русскоязычных рабочих созвонов; это не
|
||||
репрезентативная выборка для всех микрофонов, шумов и акцентов.
|
||||
- Опорные метки двух записей грубые и не дают посчитать DER.
|
||||
- На слух проверен только фрагмент 07:00–12:00 контрольной записи; перед
|
||||
выпуском нужен слуховой контроль плотного диалога на финальной сборке.
|
||||
- Калибровка выбирает эмбеддинги и кластеризацию, но не решает ошибки границ и
|
||||
неполное распознавание наложений голосов.
|
||||
- Производительность принята на доступном старом Intel Core i7; конкретный Core
|
||||
i5 11-го поколения остаётся непроверенным, потому что такого устройства нет.
|
||||
|
||||
Для спецификации зафиксировать WeSpeaker + 0,89 как автоматический дефолт,
|
||||
отдельную опцию явного числа участников и отсутствие автоматического
|
||||
отбрасывания малых кластеров. CAMPPlus zh/en можно оставить кандидатом для
|
||||
будущего режима с обязательным `num_clusters` после расширенной слуховой
|
||||
проверки.
|
||||
@@ -0,0 +1,96 @@
|
||||
# Производительность диаризации на старом Intel Core i7
|
||||
|
||||
**Дата:** 2026-08-14
|
||||
|
||||
**Статус:** приёмка на доступном слабом Intel baseline. Не эквивалент замеру на
|
||||
Intel Core i5 11-го поколения.
|
||||
|
||||
## Решение
|
||||
|
||||
Диаризация проходит по стоимости как **опциональная функция**. На доступном
|
||||
ноутбуке обработка остаётся заметно быстрее реального времени: час записи
|
||||
занимает около 23 минут при последовательном запуске ASR и диаризации.
|
||||
|
||||
Цена функции существенная: диаризация медленнее ASR и увеличивает полное время
|
||||
примерно в 2,4 раза. Поэтому включать её без явного запроса пользователя нельзя.
|
||||
Теоретическое совмещение независимых проходов уменьшило бы время часа записи до
|
||||
примерно 13,6 минуты, но параллельный режим здесь не измерялся.
|
||||
|
||||
Запланированный Intel Core i5 11-го поколения недоступен и в обозримом будущем
|
||||
не появится. Вместо бессрочного блокирующего требования принят доступный старый
|
||||
Intel Core i7 как практический слабый baseline. Результат не переносится на
|
||||
конкретный SKU i5 и не является сравнением микроархитектур.
|
||||
|
||||
## Оборудование и условия
|
||||
|
||||
- HP ZBook 17 G3, BIOS N81 01.61;
|
||||
- Intel Core i7-6820HQ, 4 ядра / 8 логических процессоров, 2,7–3,6 ГГц;
|
||||
- 29 ГиБ доступной RAM;
|
||||
- Ubuntu, Linux 7.0.0-29-generic x86_64;
|
||||
- питание от сети, профиль `balanced`, governor `powersave`; доступная мощность
|
||||
была урезана, и ноутбук ограничивал производительность;
|
||||
- Python 3.13.13, `onnx-asr` 0.12.0, `onnxruntime` 1.28.0,
|
||||
`faster-whisper` 1.2.1, `sherpa-onnx` 1.13.5, NumPy 2.4.3;
|
||||
- 8 потоков CPU, порог кластеризации 0,89;
|
||||
- прогоны последовательные, без намеренно запущенной конкурирующей нагрузки;
|
||||
- модели после первого запуска находились в локальном кеше.
|
||||
|
||||
По наблюдению владельца, ограничение питания снижало производительность примерно
|
||||
на 30%. Это визуальная оценка, а не результат отдельного A/B-замера, поэтому
|
||||
фактические времена ниже не пересчитываются. Их следует читать как консервативный
|
||||
результат именно в зафиксированном режиме питания.
|
||||
|
||||
Использованы те же три записи и те же SHA-256, что в
|
||||
[отчёте о калибровке](2026-08-14-diarization-calibration.md). Модель ASR —
|
||||
`gigaam-v3-e2e-rnnt` INT8; диаризация — Pyannote segmentation 3.0 и WeSpeaker
|
||||
ResNet34 LM.
|
||||
|
||||
## Результаты
|
||||
|
||||
Для самой длинной записи сделано три прогона каждого прохода. Для двух
|
||||
остальных — по одному подтверждающему прогону: разброс трёх повторов был мал,
|
||||
а коэффициенты на записях другой длины подтвердили линейное масштабирование.
|
||||
|
||||
| Запись | Длительность | ASR | RTFx ASR | Диаризация | RTFx диаризации |
|
||||
|---|---:|---:|---:|---:|---:|
|
||||
| Data Test | 26:00 | **257,1 с** (медиана: 261,2 / 257,1 / 253,7) | 6,1× | **352,6 с** (медиана: 352,6 / 349,8 / 358,8) | 4,4× |
|
||||
| T2 BDMA | 14:51 | 149,0 с | 6,0× | 203,7 с | 4,4× |
|
||||
| Yantar | 20:22 | 185,3 с | 6,6× | 276,0 с | 4,4× |
|
||||
| **Взвешенно, три записи** | **61:13** | **591,4 с** | **6,2×** | **832,3 с** | **4,4×** |
|
||||
|
||||
Последовательная обработка трёх записей занимает около 1424 секунд, или
|
||||
23,7 минуты, при общей длительности 61,2 минуты: **2,58× realtime**. В пересчёте
|
||||
на час это около 9,7 минуты ASR и 13,6 минуты диаризации, всего **23,3 минуты**.
|
||||
|
||||
## Инициализация и память
|
||||
|
||||
| Проход | Инициализация из локального кеша | Peak RSS |
|
||||
|---|---:|---:|
|
||||
| ASR | 2,0–2,3 с | 900–1035 МБ на тёплых прогонах |
|
||||
| Диаризация | 0,2 с | 366–469 МБ |
|
||||
|
||||
Первый ASR-запуск показал 23,6 секунды, но включал скачивание файлов модели,
|
||||
поэтому не считается чистым cold start. Peak RSS этого процесса достиг 1174 МБ.
|
||||
Пиковая память замерена отдельно для каждого последовательного прохода; для
|
||||
будущего параллельного режима значения нельзя механически считать измеренным
|
||||
общим пиком.
|
||||
|
||||
## Сопоставление с разведкой на Ryzen
|
||||
|
||||
На Ryzen 7 8845H для Data Test были получены 16,4× RTFx у ASR и 11,1× у
|
||||
диаризации. На старом Intel оба прохода медленнее примерно в 2,6 раза, а их
|
||||
соотношение почти не изменилось. Следовательно, слабое железо ухудшает абсолютное
|
||||
время, но не меняет основной архитектурный вывод: диаризация дороже ASR, а
|
||||
совмещение проходов потенциально полезно.
|
||||
|
||||
## Ограничения
|
||||
|
||||
- Core i7-6820HQ не моделирует производительность Core i5 11-го поколения;
|
||||
- влияние урезанного питания оценивается примерно в 30% только на глаз; прогон с
|
||||
полным питанием для сравнения не проводился;
|
||||
- медиана трёх прогонов снята только на одной полной записи, на двух других есть
|
||||
по одному подтверждающему прогону;
|
||||
- чистый cold start ASR без скачивания, но с холодным файловым кешем не измерен;
|
||||
- параллельный запуск ASR и диаризации не измерен;
|
||||
- результат отвечает только на стоимость выбранных моделей и конфигурации, а не
|
||||
на качество диаризации.
|
||||
+32
-7
@@ -2,11 +2,11 @@
|
||||
|
||||
## Режимы `--device`
|
||||
|
||||
- `auto` (по умолчанию) — CUDA → OpenVINO GPU → OpenVINO CPU → CPU (первый доступный)
|
||||
- `auto` (по умолчанию) — CUDA при наличии `nvidia-smi`, иначе ONNX на CPU
|
||||
- `cuda` — строго NVIDIA GPU, ошибка если недоступен
|
||||
- `openvino` — авто-выбор OpenVINO GPU или CPU
|
||||
- `openvino-gpu` — строго Intel GPU через OpenVINO
|
||||
- `openvino-cpu` — строго CPU через OpenVINO (ускорение 2-4x на x86)
|
||||
- `openvino-cpu` — строго CPU через OpenVINO (3-10x на x86, зависит от модели)
|
||||
- `cpu` — строго CPU (faster-whisper/CTranslate2)
|
||||
|
||||
## Какой бэкенд на каком оборудовании
|
||||
@@ -14,12 +14,17 @@
|
||||
| Оборудование | Рекомендуемый `--device` | Бэкенд | Ожидаемая скорость |
|
||||
|---|---|---|---|
|
||||
| NVIDIA GPU (6+ GB VRAM) | `auto` / `cuda` | faster-whisper (CTranslate2) | 7-19x реалтайм |
|
||||
| Intel Arc iGPU / dGPU | `auto` / `openvino-gpu` | OpenVINO GenAI (GPU) | TBD |
|
||||
| Intel/AMD x86 CPU | `auto` / `openvino-cpu` | OpenVINO GenAI (CPU) | 3-6x реалтайм* |
|
||||
| Любой CPU (fallback) | `cpu` | faster-whisper (CTranslate2) | ~1.5x реалтайм |
|
||||
| Apple Silicon (macOS) | `cpu` | faster-whisper (CTranslate2) | ~2x реалтайм |
|
||||
| Любой CPU без NVIDIA | `auto` / `onnx` | ONNX GigaAM RNN-T | 10-14x реалтайм* |
|
||||
| Intel Arc iGPU / dGPU | `openvino-gpu` | OpenVINO GenAI (GPU) | TBD |
|
||||
| Intel/AMD x86 CPU | `openvino-cpu` | OpenVINO GenAI (CPU) | 3-10x реалтайм* |
|
||||
| Любой CPU, FasterWhisper | `cpu` | faster-whisper (CTranslate2) | ~1.5x реалтайм |
|
||||
|
||||
\* По результатам тестирования на Intel и AMD CPU. Реальная скорость зависит от CPU и модели.
|
||||
\* По результатам контрольных прогонов на Intel и AMD CPU. Реальная скорость
|
||||
зависит от CPU, модели и записи.
|
||||
|
||||
Автоматический профиль без NVIDIA рассчитан на русскую речь: GigaAM других
|
||||
языков не понимает. Для них берите Whisper — `openvino-cpu` на x86 или `cpu`
|
||||
на любой платформе.
|
||||
|
||||
## OpenVINO
|
||||
|
||||
@@ -28,6 +33,8 @@ OpenVINO ускоряет inference на x86 процессорах (Intel и AM
|
||||
|
||||
- **Модели**: предконвертированные из [HuggingFace](https://huggingface.co/OpenVINO) (int8/fp16)
|
||||
- **Дефолт**: `medium` + `int8` (для `large-v3` автоматически выбирается `fp16`)
|
||||
- **Быстрый профиль с низким WER**: явный `large-v3-turbo` + `int8`; для
|
||||
читаемости и сохранности содержания `medium` остаётся предпочтительнее
|
||||
- **Аудиодекодирование**: через PyAV (бандлит FFmpeg), системный ffmpeg не нужен
|
||||
|
||||
### Доступные OpenVINO модели
|
||||
@@ -39,9 +46,23 @@ OpenVINO ускоряет inference на x86 процессорах (Intel и AM
|
||||
| small | OpenVINO/whisper-small-int8-ov | — |
|
||||
| medium | OpenVINO/whisper-medium-int8-ov | OpenVINO/whisper-medium-fp16-ov |
|
||||
| large-v3 | OpenVINO/whisper-large-v3-int8-ov | OpenVINO/whisper-large-v3-fp16-ov |
|
||||
| large-v3-turbo | OpenVINO/whisper-large-v3-turbo-int8-ov | OpenVINO/whisper-large-v3-turbo-fp16-ov |
|
||||
|
||||
Размер в кеше HuggingFace: `medium` int8 — 748 MB, `large-v3-turbo` int8 —
|
||||
790 MB, `large-v3-turbo` fp16 — 1552 MB. Это меньше, чем у тех же моделей для
|
||||
faster-whisper, потому что веса уже квантизированы.
|
||||
|
||||
Модель `large-v3-turbo` доступна только в OpenVINO: для `--device cuda` и
|
||||
`--device cpu` каталог faster-whisper заканчивается на `large-v3`.
|
||||
|
||||
### Результаты тестирования OpenVINO
|
||||
|
||||
Актуальное сравнение OpenVINO 2026.3 на трёх русскоязычных встречах:
|
||||
[medium, large-v3-turbo и GigaAM](benchmarks/2026-08-12-openvino-large-v3-turbo-comparison.md).
|
||||
На Intel Core i7-11800H `large-v3-turbo` INT8 обработал 43:37 аудио за
|
||||
365,5 секунды (7,16× RTFx) при WER 24,0%. FP16 занял 545,0 секунды и получил
|
||||
WER 24,8%, поэтому для CPU рекомендуется INT8.
|
||||
|
||||
Реальные записи рабочих созвонов (русский, техтермины: SQL, PostgreSQL, LDAP, DLP и др.).
|
||||
|
||||
**Скорость (эталонный файл 16 мин, OpenVINO, medium int8):**
|
||||
@@ -76,6 +97,10 @@ OpenVINO ускоряет inference на x86 процессорах (Intel и AM
|
||||
- **small** — для быстрого сканирования большого объёма видео по маске (`*.mp4`). Ошибки в отдельных словах; для обработки ИИ (МОМ, конспект) рискованно — "рецензия" вместо "лицензия" может исказить смысл.
|
||||
- **medium** — для повседневного использования и обработки ИИ. Ключевые термины верные, единичные ляпы не влияют на смысл конспекта. Оптимальный баланс скорости и качества.
|
||||
- **large-v3** — для важных записей, где нужна дословная точность. Лучшая пунктуация и связность. На OpenVINO (416с) быстрее, чем medium на чистом CPU (734с) — лучшее качество при выше скорости.
|
||||
- **large-v3-turbo** — явный профиль для CPU с приоритетом скорости и низкого
|
||||
WER. В проверенном наборе INT8 быстрее и численно точнее medium, но хуже по
|
||||
независимой оценке читаемости и сохранности содержания; FP16 на CPU пользы
|
||||
не показал.
|
||||
|
||||
## CPU бэкенд (CTranslate2 / faster-whisper)
|
||||
|
||||
|
||||
@@ -0,0 +1,491 @@
|
||||
# Куда движутся ONNX Runtime и OpenVINO: жизненный цикл и переносимость моделей
|
||||
|
||||
**Дата:** 2026-08-12
|
||||
|
||||
**Статус:** исследование для карты диаризации. Не архитектурное решение и не
|
||||
основание для консолидации всех движков распознавания на ONNX Runtime.
|
||||
|
||||
## Вопрос и границы
|
||||
|
||||
Исследование отвечает на два связанных вопроса:
|
||||
|
||||
1. Насколько устойчивы ONNX Runtime (ORT), его аппаратные Execution Provider
|
||||
(EP), Windows ML и нативный стек OpenVINO/OpenVINO GenAI?
|
||||
2. Что эти пути практически дают текущим моделям проекта — GigaAM E2E RNN-T,
|
||||
OpenVINO Whisper и связке диаризации PyAnnote + WeSpeaker — на Intel, AMD,
|
||||
Apple Silicon и в браузере?
|
||||
|
||||
Терминология следует [`CONTEXT.md`](../../CONTEXT.md): ORT, OpenVINO и OpenVINO
|
||||
GenAI — **движки распознавания**. Обновление движка само по себе не меняет
|
||||
поддерживаемую модель или модель по умолчанию. Архитектурная точка отсчёта —
|
||||
независимые бэкенды из [ADR-003](../adr/003-pluggable-backends.md) и принятый
|
||||
ONNX CPU-путь из [ADR-006](../adr/006-onnx-asr-backend.md).
|
||||
|
||||
Исследование дополняет [срез обновлений движков](2026-08-10-engine-model-updates.md)
|
||||
и [разведку диаризации](../benchmarks/2026-08-12-diarization-feasibility.md).
|
||||
Оно основано на первичных источниках: официальной документации, release notes,
|
||||
репозиториях владельцев и фактических метаданных PyPI на 2026-08-12.
|
||||
|
||||
Вне границ документа:
|
||||
|
||||
- решение о консолидации проекта на одном runtime;
|
||||
- выбор нового устройства или модели по умолчанию;
|
||||
- обещание производительности без model-specific benchmark;
|
||||
- разработка браузерной версии `local-transcriber`.
|
||||
|
||||
## Краткий ответ
|
||||
|
||||
- **Переносимый фундамент проекта — ONNX-артефакт плюс ORT CPU EP.** ORT core
|
||||
активно развивается и имеет наиболее широкую поставку для CPython 3.13:
|
||||
Windows и Linux x86-64/ARM64, macOS ARM64.
|
||||
- **Жизненный цикл ядра ORT не переносится автоматически на каждый EP.**
|
||||
DirectML уже в sustained engineering, OpenVINO EP активен, но отстаёт от
|
||||
ORT и OpenVINO, CoreML остаётся Preview, а удалённый ROCm EP сменяется
|
||||
MIGraphX.
|
||||
- **Долгосрочный Intel-путь — нативный OpenVINO/OpenVINO GenAI.** Он имеет
|
||||
собственную release/LTS policy, развивает Whisper и NPU и уже предоставляет
|
||||
word-level timestamps. OpenVINO EP полезен как мост для ONNX-моделей, но не
|
||||
даёт автоматически последние возможности нативного стека.
|
||||
- **Новый Windows-слой — Windows ML, а не DirectML.** Windows ML остаётся ORT,
|
||||
но добавляет обнаружение устройств и управляемый каталог vendor EP. Для AMD
|
||||
там доступен MIGraphX; Python-приложению всё равно нужны bootstrap, загрузка
|
||||
и явная регистрация EP.
|
||||
- **На AMD и Apple готовый пакет ещё не означает ускорение конкретной модели.**
|
||||
Linux AMD имеет wheel MIGraphX для CPython 3.13, Apple Silicon — CoreML EP в
|
||||
обычном ORT wheel; полный offload GigaAM и моделей диаризации не подтверждён
|
||||
ни для одного из этих путей.
|
||||
- **Браузеры используют ту же архитектурную идею:** ORT Web даёт единый API,
|
||||
WASM — переносимый CPU baseline, WebGPU/WebNN — опциональные ускорители с
|
||||
ограниченным набором операторов и fallback. Сам ONNX-файл не устраняет
|
||||
различия preprocessing, decoding и доступных kernels.
|
||||
- **Главная находка для карты диаризации:** OpenVINO GenAI уже умеет возвращать
|
||||
пословные таймкоды. Их отсутствие в результате `local-transcriber` — пробел
|
||||
проектного `Backend`/`TranscribeResult`, а не ограничение OpenVINO.
|
||||
|
||||
Общий принцип: поддержка пути доказана только тогда, когда подтверждены
|
||||
поставка, создание сессии, фактическое размещение графа, сохранение выходного
|
||||
контракта и end-to-end стоимость. Наличие wheel или имени EP закрывает только
|
||||
первый из этих пунктов.
|
||||
|
||||
## Как устроены исследуемые слои
|
||||
|
||||
Сравниваемые названия относятся к разным уровням и не являются
|
||||
взаимозаменяемыми пакетами.
|
||||
|
||||
| Слой | Роль | Что фиксирует приложение |
|
||||
|---|---|---|
|
||||
| ONNX | Формат графа, операторов и типов данных | Артефакт модели и opset ([ONNX About](https://onnx.ai/about)) |
|
||||
| ONNX Runtime | Движок, который загружает ONNX-граф и распределяет узлы между EP | API сессии, версия ORT и порядок EP ([архитектура ORT](https://onnxruntime.ai/docs/reference/high-level-design.html)) |
|
||||
| Execution Provider | Адаптер ORT к CPU, GPU или NPU; получает только поддержанные узлы/подграфы | Аппаратный runtime, provider options и CPU fallback ([архитектура EP](https://onnxruntime.ai/docs/execution-providers/)) |
|
||||
| Windows ML | Windows-поставка ORT с каталогом, установкой и обновлением vendor EP | Windows App SDK, deployment mode и политика выбора EP ([обзор](https://learn.microsoft.com/en-us/windows/ai/new-windows-ml/overview)) |
|
||||
| OpenVINO | Runtime, компилятор и device plugins для CPU/GPU/NPU; читает в том числе ONNX | API OpenVINO, устройство и поддержанные форматы ([поддержанные модели](https://docs.openvino.ai/2026/documentation/compatibility-and-support/supported-models.html)) |
|
||||
| OpenVINO GenAI | Высокоуровневые pipelines поверх OpenVINO, включая Whisper и общий ASR API | OpenVINO IR, pipeline API и согласованные версии компонентов ([GenAI PyPI](https://pypi.org/project/openvino-genai/2026.3.0.0/)) |
|
||||
| ORT Web | Отдельная JavaScript/WebAssembly-поставка ORT для браузера | JS API, WASM runtime и browser EP ([обзор ORT Web](https://onnxruntime.ai/docs/tutorials/web/)) |
|
||||
|
||||
Один ONNX-артефакт можно исполнять обычным ORT CPU EP, передавать его
|
||||
поддержанные подграфы OpenVINO EP или загружать напрямую в OpenVINO. Результат
|
||||
различается по покрытию операторов, квантованию, fallback и производительности
|
||||
([ORT partitioning](https://onnxruntime.ai/docs/execution-providers/),
|
||||
[OpenVINO EP coverage](https://onnxruntime.ai/docs/execution-providers/OpenVINO-ExecutionProvider.html),
|
||||
[чтение ONNX в OpenVINO](https://docs.openvino.ai/2026/openvino-workflow/model-preparation/convert-model-onnx.html)).
|
||||
|
||||
### Лестница доказательства
|
||||
|
||||
Для каждой пары «модель × устройство × движок» используются пять уровней:
|
||||
|
||||
1. **Поставка:** существует совместимый wheel/runtime.
|
||||
2. **Загрузка:** все модельные сессии создаются без ошибки.
|
||||
3. **Размещение:** profiler показывает, какие узлы действительно исполняет EP,
|
||||
а какие ушли в CPU fallback.
|
||||
4. **Контракт:** текст, таймкоды, сегменты и эмбеддинги остаются допустимыми.
|
||||
5. **Пригодность:** end-to-end скорость, память и качество проходят проектную
|
||||
приёмку.
|
||||
|
||||
Ниже «подтверждено» означает прямое upstream-обещание или локальный результат;
|
||||
«вывод» следует из архитектуры, но не проверен на конкретной модели;
|
||||
«эксперимент» означает, что неизвестен хотя бы один уровень после поставки.
|
||||
|
||||
## Жизненный цикл движков и аппаратных путей
|
||||
|
||||
### ONNX Runtime core
|
||||
|
||||
ORT core активно развивается. Версии 1.26, 1.27 и 1.28 вышли 8 мая, 19 июня и
|
||||
25 июля 2026 года; в них продолжалось развитие plugin EP API, ядра,
|
||||
безопасности и аппаратных провайдеров
|
||||
([1.26.0](https://github.com/microsoft/onnxruntime/releases/tag/v1.26.0),
|
||||
[1.27.0](https://github.com/microsoft/onnxruntime/releases/tag/v1.27.0),
|
||||
[1.28.0](https://github.com/microsoft/onnxruntime/releases/tag/v1.28.0)).
|
||||
|
||||
Официальные страницы расходятся в обещанном cadence: servicing-документ говорит
|
||||
о full releases примерно раз в квартал, roadmap — о ежемесячных релизах и
|
||||
промежуточных patch-релизах. Публичной LTS/EOL policy нет, поэтому текущий
|
||||
почти месячный темп нельзя считать гарантией
|
||||
([servicing](https://onnxruntime.ai/docs/reference/releases-servicing.html),
|
||||
[roadmap](https://onnxruntime.ai/roadmap),
|
||||
[support policy](https://github.com/microsoft/onnxruntime/blob/main/SUPPORT.md)).
|
||||
|
||||
С ORT 1.23 новые EP рекомендуется делать отдельными plugins. В 1.24–1.28 API
|
||||
получил prepacking, EP Context, zero-copy I/O, profiling и model packages
|
||||
([инструкция для нового EP](https://onnxruntime.ai/docs/execution-providers/add-execution-provider.html),
|
||||
[1.24.1](https://github.com/microsoft/onnxruntime/releases/tag/v1.24.1),
|
||||
[1.28.0](https://github.com/microsoft/onnxruntime/releases/tag/v1.28.0)). Это
|
||||
укрепляет ORT как общий движок, но одновременно отделяет lifecycle конкретного
|
||||
ускорителя от lifecycle ядра.
|
||||
|
||||
### Нативный OpenVINO и OpenVINO GenAI
|
||||
|
||||
OpenVINO публикует несколько регулярных релизов в год. Каждый поддерживается до
|
||||
следующего, а последняя версия года становится LTS: security updates выходят
|
||||
два года либо до двух следующих LTS, исправления новых bugs — один год.
|
||||
Preview-компоненты этой гарантией не покрываются
|
||||
([release notes](https://docs.openvino.ai/2026/about-openvino/release-notes-openvino.html),
|
||||
[release policy](https://docs.openvino.ai/2026/about-openvino/release-notes-openvino/release-policy.html)).
|
||||
|
||||
OpenVINO GenAI — pipeline-библиотека поверх OpenVINO и OpenVINO Tokenizers.
|
||||
Их `major.minor.patch` должны совпадать; разъезд версий может привести к
|
||||
ABI/import errors. PyPI wheel нельзя смешивать с C++ archive другого ABI
|
||||
([правила совместимости](https://pypi.org/project/openvino-genai/2026.3.0.0/)).
|
||||
|
||||
Whisper остаётся активным направлением:
|
||||
|
||||
- OpenVINO 2026.0 добавил word-level timestamps в `WhisperPipeline` на CPU,
|
||||
GPU и NPU; 2026.3 добавил язык в результат
|
||||
([2026.0](https://docs.openvino.ai/2026/about-openvino/release-notes-openvino.html#openvino-2026-0-0),
|
||||
[2026.3](https://docs.openvino.ai/2026/about-openvino/release-notes-openvino.html#openvino-2026-3-0));
|
||||
- OpenVINO 2026.3 ввёл общий `ASRPipeline` и Qwen3-ASR, расширив speech API за
|
||||
пределы Whisper ([2026.3](https://docs.openvino.ai/2026/about-openvino/release-notes-openvino.html#openvino-2026-3-0));
|
||||
- удалён только ранее deprecated stateless Whisper decoder; рекомендуемый путь
|
||||
использует stateful model
|
||||
([deprecations](https://docs.openvino.ai/2026/about-openvino/release-notes-openvino.html#deprecation-and-support)).
|
||||
|
||||
NPU — полноценное устройство OpenVINO, но требует отдельного driver, работает
|
||||
со static shapes, а совместимость compiled blobs между версиями не
|
||||
гарантируется. `WhisperPipeline` поддерживает NPU, однако целевой Core i5 11-го
|
||||
поколения NPU не имеет: для него OpenVINO означает CPU/iGPU
|
||||
([NPU device](https://docs.openvino.ai/2026/openvino-workflow/running-inference/inference-devices-and-modes/npu-device.html),
|
||||
[Whisper on NPU](https://docs.openvino.ai/2026/openvino-workflow-generative/inference-with-genai/inference-with-genai-on-npu.html#whisper-inference-on-npu),
|
||||
[AUTO priority](https://docs.openvino.ai/2026/openvino-workflow/running-inference/inference-devices-and-modes/auto-device-selection.html)).
|
||||
|
||||
### Аппаратные пути ORT
|
||||
|
||||
| Путь | Состояние на 2026-08-12 | Практическое следствие |
|
||||
|---|---|---|
|
||||
| CPU EP | Часть ORT core, production baseline | Самая широкая поставка; аппаратного ускорителя не обещает |
|
||||
| DirectML EP | Sustained engineering; feature development перешёл в Windows ML | Поддерживается, но не подходит как новый долгосрочный GPU default ([DirectML EP](https://onnxruntime.ai/docs/execution-providers/DirectML-ExecutionProvider.html)) |
|
||||
| Windows ML | Production-поставка ORT для Windows с управляемым каталогом EP | Стратегический Windows-слой, но требует platform-specific bootstrap ([deployment](https://learn.microsoft.com/en-us/windows/ai/new-windows-ml/distributing-your-app)) |
|
||||
| OpenVINO EP | Активен; deprecated только часть старых provider options | Мост к Intel-ускорению, но готовый wheel отстаёт от ORT/OpenVINO ([OpenVINO EP](https://onnxruntime.ai/docs/execution-providers/OpenVINO-ExecutionProvider.html)) |
|
||||
| MIGraphX EP | Активный AMD-путь; прежний ROCm EP удалён из ORT 1.23 | Долгосрочнее ROCm EP, но зависит от ROCm/GPU/OS ([ORT 1.23](https://github.com/microsoft/onnxruntime/releases/tag/v1.23.0)) |
|
||||
| CoreML EP | Preview | Доступен в macOS ORT wheel, но требует проверки partitioning ([CoreML EP](https://onnxruntime.ai/docs/execution-providers/CoreML-ExecutionProvider.html)) |
|
||||
| Native WebGPU EP | Новый plugin поверх Dawn/D3D12/Vulkan/Metal | Кросс-вендорный кандидат; browser WebGPU использует другой runtime path ([WebGPU EP](https://onnxruntime.ai/docs/execution-providers/WebGPU-ExecutionProvider.html)) |
|
||||
|
||||
#### DirectML и Windows ML
|
||||
|
||||
DirectML EP использует DirectML 1.15.2, поддерживает ONNX только до opset 20 и
|
||||
не допускает parallel execution одной session. Последний
|
||||
`onnxruntime-directml` на дату среза — 1.24.4, тогда как ORT core уже 1.28.0.
|
||||
Исправления DML всё ещё входят в ORT, то есть sustained engineering означает
|
||||
поддержку без прежнего feature cadence, а не удаление
|
||||
([DirectML EP](https://onnxruntime.ai/docs/execution-providers/DirectML-ExecutionProvider.html),
|
||||
[PyPI](https://pypi.org/project/onnxruntime-directml/),
|
||||
[ORT 1.28](https://github.com/microsoft/onnxruntime/releases/tag/v1.28.0)).
|
||||
|
||||
Windows ML не меняет формат модели и не заменяет ORT: runtime содержит
|
||||
`onnxruntime.dll`, DirectML и Windows ML API. Новый слой добавляет обнаружение
|
||||
устройств, каталог vendor EP, их установку, регистрацию и обновление. DirectML
|
||||
остаётся встроенным legacy EP; MIGraphX, VitisAI, OpenVINO, QNN и
|
||||
NvTensorRtRtx поставляются через каталог или вместе с приложением
|
||||
([обзор](https://learn.microsoft.com/en-us/windows/ai/new-windows-ml/overview),
|
||||
[состав runtime](https://learn.microsoft.com/en-us/windows/ai/new-windows-ml/distributing-your-app),
|
||||
[каталог EP](https://learn.microsoft.com/en-us/windows/ai/new-windows-ml/supported-execution-providers)).
|
||||
|
||||
Python-пакет называется `onnxruntime-windowsml`. Версия
|
||||
`1.27.1.202607110137` имеет статус `Production/Stable`, требует Python 3.11+ и
|
||||
публикует `cp313` wheels для Windows x86-64 и ARM64
|
||||
([PyPI](https://pypi.org/project/onnxruntime-windowsml/)). Отдельная ONNX
|
||||
Runtime GenAI Windows ML library 0.x остаётся Preview; её статус не относится
|
||||
к обычному ONNX-инференсу
|
||||
([GenAI Preview](https://learn.microsoft.com/en-us/windows/ai/new-windows-ml/run-genai-onnx-models)).
|
||||
|
||||
Для Python поддержан только framework-dependent unpackaged deployment: нужны
|
||||
Windows App SDK Runtime и bootstrap packages. Динамический каталог аппаратных
|
||||
EP требует Windows 11 24H2 build 26100+
|
||||
([get started](https://learn.microsoft.com/en-us/windows/ai/new-windows-ml/get-started),
|
||||
[deployment](https://learn.microsoft.com/en-us/windows/ai/new-windows-ml/distributing-your-app)).
|
||||
Приложение должно скачать выбранный EP через `ensure_ready_async()` и
|
||||
зарегистрировать библиотеку в ORT; `EnsureAndRegisterCertifiedAsync()` не
|
||||
регистрирует EP в Python environment
|
||||
([инициализация EP](https://learn.microsoft.com/en-us/windows/ai/new-windows-ml/initialize-execution-providers)).
|
||||
|
||||
#### OpenVINO EP
|
||||
|
||||
OpenVINO EP не объявлен deprecated или maintenance-only. Intel продолжает
|
||||
публиковать пакет, а ORT 1.26 и 1.28 содержат его изменения. Но последний
|
||||
готовый `onnxruntime-openvino` 1.24.1 включает OpenVINO 2025.4.1 на Linux и
|
||||
требует отдельный OpenVINO на Windows. Нативный OpenVINO уже достиг 2026.3, ORT
|
||||
core — 1.28
|
||||
([PyPI](https://pypi.org/project/onnxruntime-openvino/1.24.1/),
|
||||
[матрица совместимости](https://onnxruntime.ai/docs/execution-providers/OpenVINO-ExecutionProvider.html),
|
||||
[ORT 1.26](https://github.com/microsoft/onnxruntime/releases/tag/v1.26.0),
|
||||
[ORT 1.28](https://github.com/microsoft/onnxruntime/releases/tag/v1.28.0)).
|
||||
|
||||
Следствие: EP остаётся рабочим мостом для ONNX-моделей, но не является способом
|
||||
автоматически получить последние Whisper/NPU-возможности OpenVINO. Deprecated
|
||||
provider options, заменённые `load_config`, не означают deprecation самого EP.
|
||||
|
||||
## Практическая поставка по платформам
|
||||
|
||||
Срез сделан по фактическим wheel, а не только по classifiers.
|
||||
|
||||
| Платформа и устройство | Готовый runtime/EP | Статус | `cp313` | Текущий CLI без новой интеграции |
|
||||
|---|---|---|---|---|
|
||||
| Intel x86 CPU | ORT CPU; OpenVINO CPU | Production | Да | Оба пути уже есть |
|
||||
| Intel GPU/NPU | Нативный OpenVINO | Production; часть NPU-функций Preview | Да, Windows/Linux x86-64 | OpenVINO GPU есть; NPU потребует нового device profile |
|
||||
| Windows, AMD CPU | ORT CPU | Production baseline | Да, `win_amd64` | Да |
|
||||
| Windows, AMD GPU | DirectML | Sustained engineering | Да, `onnxruntime-directml` | Нужен новый provider/device UX |
|
||||
| Windows 11 24H2+, AMD GPU | Windows ML + MIGraphX | Windows ML production; EP зависит от driver/device | Да, `onnxruntime-windowsml` | Нужны bootstrap и регистрация EP |
|
||||
| Linux, AMD CPU | ORT CPU | Production baseline | Да, manylinux x86-64 | Да |
|
||||
| Linux, AMD GPU | MIGraphX | Активная замена удалённого ROCm EP | Да, `onnxruntime-migraphx 1.27.1` | Нужны ROCm stack и provider integration |
|
||||
| Apple Silicon, CPU | ORT CPU | Production baseline | Да, macOS 14 ARM64 | Да |
|
||||
| Apple Silicon, GPU/ANE | CoreML EP | Preview | Да, в обычном ORT wheel | Нужны provider integration и profiling |
|
||||
| Apple Silicon, CPU | OpenVINO GenAI Whisper | Production package; CPU-only на macOS | Да | Текущий dependency marker исключает macOS |
|
||||
|
||||
Обычный `onnxruntime` 1.28.0 поставляет `cp313` wheels для Windows x86-64 и
|
||||
ARM64, Linux x86-64 и ARM64, macOS 14 ARM64
|
||||
([files](https://pypi.org/project/onnxruntime/1.28.0/#files)). Для сравнения,
|
||||
`onnxruntime-directml` 1.24.4 ограничен Windows x86-64, а
|
||||
`onnxruntime-openvino` 1.24.1 — Windows/Linux x86-64
|
||||
([DirectML files](https://pypi.org/project/onnxruntime-directml/1.24.4/#files),
|
||||
[OpenVINO EP files](https://pypi.org/project/onnxruntime-openvino/1.24.1/#files)).
|
||||
|
||||
### AMD
|
||||
|
||||
На AMD x86 CPU поддерживаемая опора — ORT CPU EP. OpenVINO 2026.3 официально
|
||||
перечисляет Intel и ARM/Apple CPU, но не AMD x86; наличие x86 wheel само по себе
|
||||
не является обещанием поддержки AMD
|
||||
([OpenVINO requirements](https://docs.openvino.ai/2026/about-openvino/release-notes-openvino/system-requirements.html)).
|
||||
|
||||
Под Windows DirectML поддерживает AMD GCN первого поколения и новее, но его
|
||||
ограниченный lifecycle делает Windows ML + MIGraphX более перспективным путём.
|
||||
Текущий Windows ML MIGraphX требует совместимый GPU/driver и не поддерживает
|
||||
GenAI scenarios; применимость этой формулировки к GigaAM RNN-T не определена и
|
||||
должна проверяться экспериментом
|
||||
([Windows ML EP](https://learn.microsoft.com/en-us/windows/ai/new-windows-ml/supported-execution-providers)).
|
||||
|
||||
Под Linux прежний ROCm EP удалён из ORT 1.23. Пакет `onnxruntime-rocm`
|
||||
1.22.2.post3 всё ещё имеет `cp313`, но закреплён на ветке до удаления EP.
|
||||
Активный `onnxruntime-migraphx` 1.27.1 публикует
|
||||
`cp313-manylinux_2_34_x86_64`; реальные ограничения теперь лежат в ROCm/GPU/OS
|
||||
и покрытии графа
|
||||
([ROCm PyPI JSON](https://pypi.org/pypi/onnxruntime-rocm/json),
|
||||
[MIGraphX PyPI JSON](https://pypi.org/pypi/onnxruntime-migraphx/json),
|
||||
[MIGraphX EP](https://onnxruntime.ai/docs/execution-providers/MIGraphX-ExecutionProvider.html)).
|
||||
|
||||
### Apple Silicon
|
||||
|
||||
ORT CPU — готовый baseline. `onnx-asr` документирует CoreML в обычном
|
||||
`onnxruntime` package. CoreML EP может использовать CPU, GPU и Apple Neural
|
||||
Engine через `MLComputeUnits`, но забирает только поддержанные подграфы.
|
||||
Dynamic shapes могут быть дорогими; offload внутри `Loop`/`Scan`/`If` по
|
||||
умолчанию выключен. `ProfileComputePlan` позволяет увидеть размещение
|
||||
([onnx-asr installation](https://istupakov.github.io/onnx-asr/installation/),
|
||||
[CoreML EP](https://onnxruntime.ai/docs/execution-providers/CoreML-ExecutionProvider.html)).
|
||||
|
||||
OpenVINO/OpenVINO GenAI имеют `cp313-macosx_11_0_arm64` wheels и поддерживают
|
||||
Apple Silicon, но на macOS исполняются только на CPU. GPU plugin рассчитан на
|
||||
Intel GPU, NPU plugin — на Intel NPU
|
||||
([OpenVINO requirements](https://docs.openvino.ai/2026/about-openvino/release-notes-openvino/system-requirements.html),
|
||||
[OpenVINO GenAI files](https://pypi.org/project/openvino-genai/2026.3.0.0/)).
|
||||
|
||||
## Возможности текущих моделей
|
||||
|
||||
Таблица применяет одну и ту же лестницу доказательства к трем модельным путям.
|
||||
|
||||
| Модель и требуемый контракт | Переносимый baseline | Intel accelerator | AMD accelerator | Apple accelerator | Browser |
|
||||
|---|---|---|---|---|---|
|
||||
| GigaAM v3 E2E RNN-T: текст + token timestamps | **Подтверждено:** `onnx-asr` + ORT CPU на x86/ARM | OpenVINO EP или конверсия в IR — **эксперимент** | DirectML/WinML MIGraphX/Linux MIGraphX — **эксперимент** | CoreML — **эксперимент** | Нужен порт Python preprocessing/decoder и проверка kernels |
|
||||
| OpenVINO GenAI Whisper: текст + word timestamps | Нативный OpenVINO CPU на поддержанных платформах | **Подтверждено:** OpenVINO CPU/GPU/NPU | AMD GPU не поддержан; AMD CPU не входит в official hardware | **Подтверждено:** только OpenVINO CPU | Это другой runtime/model artifact; не подтверждено |
|
||||
| PyAnnote segmentation + WeSpeaker embeddings: интервалы + кластеры | **Подтверждено локально:** sherpa-onnx + ORT CPU на одной записи | OpenVINO EP/native — **эксперимент** | DML/MIGraphX — **эксперимент**, для sherpa может потребоваться rebuild | CoreML — **эксперимент** | sherpa имеет WASM demo, но выбранная пара моделей не подтверждена |
|
||||
|
||||
### GigaAM E2E RNN-T
|
||||
|
||||
`onnx-asr` работает на x86/ARM CPU и перечисляет CoreML, DirectML, ROCm и
|
||||
WebGPU. GigaAM создаёт обычные ORT-сессии encoder/decoder/joint, поэтому смена
|
||||
EP архитектурно возможна
|
||||
([onnx-asr](https://istupakov.github.io/onnx-asr/),
|
||||
[installation](https://istupakov.github.io/onnx-asr/installation/),
|
||||
[model card](https://huggingface.co/istupakov/gigaam-v3-onnx)). Но это не
|
||||
доказывает operator coverage или полный offload конкретного E2E RNN-T.
|
||||
|
||||
Таймкоды формирует `onnx-asr.with_timestamps()` из тензорных выходов модели.
|
||||
Если EP сохраняет эти выходы, `TimestampedResult` должен сохраниться — это
|
||||
**вывод**, который требует golden test. Mixed precision, graph transforms и
|
||||
CPU fallback могут менять численные результаты
|
||||
([timestamps API](https://istupakov.github.io/onnx-asr/usage/),
|
||||
[архитектура пакета](https://github.com/istupakov/onnx-asr/tree/v0.12.0)).
|
||||
|
||||
Официальный ONNX helper исходного GigaAM проверяет только text parity и теряет
|
||||
emission frames. Поэтому проверять нужно именно контракт `onnx-asr`, а не
|
||||
произвольный GigaAM ONNX export
|
||||
([GigaAM](https://github.com/salute-developers/GigaAM),
|
||||
[ONNX parity test](https://github.com/salute-developers/GigaAM/blob/main/tests/test_onnx.py),
|
||||
[ONNX helper](https://github.com/salute-developers/GigaAM/blob/main/gigaam/onnx_utils.py)).
|
||||
|
||||
### OpenVINO Whisper
|
||||
|
||||
OpenVINO GenAI подтверждает Whisper tiny/base/small/medium/large-v3 и
|
||||
Distil-Whisper. Word timestamps доступны на CPU/GPU/NPU, stateful model
|
||||
обязателен
|
||||
([ASR guide](https://openvinotoolkit.github.io/openvino.genai/docs/use-cases/speech-recognition/),
|
||||
[supported models](https://openvinotoolkit.github.io/openvino.genai/docs/supported-models/)).
|
||||
Это наиболее доказанный accelerator-путь из рассматриваемых, но только для
|
||||
поддержанного OpenVINO hardware. На Apple Silicon он остаётся CPU-путём; на AMD
|
||||
GPU не работает.
|
||||
|
||||
Проектный OpenVINO backend уже запрашивает timestamps, но сводит результат к
|
||||
chunk-сегментам. Поэтому для диаризации нужно сначала определить и протянуть
|
||||
word-level контракт через `Backend`/`TranscribeResult`.
|
||||
|
||||
### PyAnnote + WeSpeaker для диаризации
|
||||
|
||||
Локальная разведка доказала, что связка PyAnnote segmentation + WeSpeaker
|
||||
embeddings создаёт интервалы на CPU ORT для одной записи. Это закрывает базовую
|
||||
совместимость, но не upstream-гарантию пары и не переносимость на другие EP
|
||||
([разведка](../benchmarks/2026-08-12-diarization-feasibility.md)).
|
||||
|
||||
Официальный sherpa recipe перечисляет PyAnnote с 3D-Speaker или NeMo
|
||||
embeddings, а WeSpeaker публикует собственные ONNX-модели. Поэтому на новом EP
|
||||
нужно отдельно проверять обе сессии и полный pipeline
|
||||
([sherpa models](https://k2-fsa.github.io/sherpa/onnx/speaker-diarization/models.html),
|
||||
[WeSpeaker models](https://github.com/wenet-e2e/wespeaker/blob/master/docs/pretrained.md)).
|
||||
|
||||
Итоговые сегменты создаёт sherpa после двух ONNX-моделей и clustering. Ускорение
|
||||
одной сессии не означает ускорение pipeline; численные изменения эмбеддингов
|
||||
могут изменить кластеры даже при совпадающем текстовом контракте
|
||||
([C API](https://k2-fsa.github.io/sherpa/onnx/c-api/html/speaker_diarization.html)).
|
||||
|
||||
## Почему ONNX Runtime Web работает между браузерами
|
||||
|
||||
Браузерная переносимость появляется не из ONNX-файла отдельно, а из сочетания
|
||||
трёх решений:
|
||||
|
||||
1. ONNX задаёт общий сериализованный граф.
|
||||
2. ORT Web даёт один JavaScript `InferenceSession` API.
|
||||
3. WASM служит широким CPU baseline, а WebGPU/WebNN подключаются как
|
||||
ускорители с fallback на WASM.
|
||||
|
||||
На 2026-08-12 официальный browser matrix выглядит так
|
||||
([матрица](https://onnxruntime.ai/docs/get-started/with-javascript/web.html)):
|
||||
|
||||
- WASM работает в Chrome/Edge, Safari и Firefox на основных desktop/mobile
|
||||
платформах и имеет наиболее полное покрытие операторов;
|
||||
- WebGPU поддерживается Chromium на Windows/macOS/Android, остаётся experimental
|
||||
в ORT Web и имеет собственный operator subset;
|
||||
- WebNN experimental и в официальной матрице требует feature flag в
|
||||
Chrome/Edge Windows; неподдержанные узлы могут уйти в WASM
|
||||
([WebNN guide](https://onnxruntime.ai/docs/tutorials/web/ep-webnn.html));
|
||||
- WebGL находится в maintenance mode.
|
||||
|
||||
Один model artifact не означает одинаковую работоспособность. WebGPU имеет
|
||||
отдельную [таблицу операторов](https://github.com/microsoft/onnxruntime/blob/main/js/web/docs/webgpu-operators.md),
|
||||
а preprocessing и decoding остаются кодом приложения. Большие модели упираются
|
||||
примерно в 2 GB для ArrayBuffer/Protobuf и 4 GB WebAssembly memory; external
|
||||
data нужно загружать отдельно
|
||||
([large models](https://onnxruntime.ai/docs/tutorials/web/large-models.html)).
|
||||
WASM threading требует `crossOriginIsolated`; proxy worker несовместим с
|
||||
WebGPU, а dynamic shapes и CPU fallback ограничивают graph capture
|
||||
([environment flags](https://onnxruntime.ai/docs/tutorials/web/env-flags-and-session-options.html),
|
||||
[WebGPU guide](https://onnxruntime.ai/docs/tutorials/web/ep-webgpu.html)).
|
||||
|
||||
`onnx-asr` заявляет WebGPU для **native Python package**. Это не browser port:
|
||||
GigaAM потребует JavaScript preprocessing/decoder, загрузки нескольких
|
||||
артефактов и проверки kernels. У sherpa-onnx есть отдельная однопоточная WASM
|
||||
speaker-diarization demo, но она не доказывает работу проектной пары PyAnnote +
|
||||
WeSpeaker через ORT Web WebGPU
|
||||
([onnx-asr installation](https://istupakov.github.io/onnx-asr/installation/),
|
||||
[sherpa JS diarization](https://k2-fsa.github.io/sherpa/onnx/speaker-diarization/javascript.html)).
|
||||
|
||||
Native WebGPU EP также не равен browser WebGPU: Python plugin использует Dawn
|
||||
поверх D3D12/Vulkan/Metal, ORT Web — browser JSEP/WASM path
|
||||
([native WebGPU EP](https://onnxruntime.ai/docs/execution-providers/WebGPU-ExecutionProvider.html),
|
||||
[plugin PyPI JSON](https://pypi.org/pypi/onnxruntime-ep-webgpu/json)).
|
||||
|
||||
Полезный для CLI вывод из браузерной архитектуры — не новый продукт, а строгая
|
||||
политика capabilities:
|
||||
|
||||
- всегда сохранять переносимый CPU baseline;
|
||||
- обнаруживать ускоритель во время запуска;
|
||||
- различать наличие API, успешную сессию, размещение графа и сохранение
|
||||
контракта;
|
||||
- измерять end-to-end pipeline, а не отдельное имя provider.
|
||||
|
||||
## Что это меняет для `local-transcriber`
|
||||
|
||||
1. **CPU ORT остаётся переносимым baseline.** Он не зависит от затухающего EP и
|
||||
обеспечивает самый широкий CPython/platform coverage для GigaAM и
|
||||
диаризации.
|
||||
2. **Нативный OpenVINO остаётся отдельным долгосрочным Intel ASR-путём.** Его
|
||||
не следует заменять OpenVINO EP только ради единого ORT API: EP отстаёт и не
|
||||
даёт автоматически pipeline-возможности OpenVINO GenAI.
|
||||
3. **Пословная диаризация на `openvino-*` технически достижима.** Upstream уже
|
||||
возвращает word timestamps; карта должна решить контракт и fallback, а не
|
||||
считать отсутствие таймкодов свойством движка.
|
||||
4. **AMD/Apple acceleration нельзя добавлять по факту наличия wheel.** Сначала
|
||||
нужны model-specific smoke/profile/golden tests; только затем device UX и
|
||||
dependency markers.
|
||||
5. **Диаризацию не нужно связывать с немедленным выбором аппаратного EP.**
|
||||
Переносимый CPU-вариант может быть специфицирован независимо; ускорение двух
|
||||
моделей — отдельная работа.
|
||||
6. **Консолидация на ORT из исследования не следует.** FasterWhisper сохраняет
|
||||
CUDA и языковое покрытие, нативный OpenVINO — актуальный Intel ASR API, ORT —
|
||||
переносимый ONNX-путь.
|
||||
|
||||
Для текущей карты это даёт два входа:
|
||||
|
||||
- [«Выбрать единицу привязки спикера к тексту»](https://git.dementev.space/ddmitry/local-transcriber/issues/14)
|
||||
должен назвать timestamp-aware изменение `Backend`/`TranscribeResult`;
|
||||
- [«UX диаризации: флаг, число участников, зависимость и поведение на OpenVINO»](https://git.dementev.space/ddmitry/local-transcriber/issues/16)
|
||||
должен определить поведение там, где конкретный backend/model не отдаёт
|
||||
нужных таймкодов.
|
||||
|
||||
Реализация и приёмка WinML, MIGraphX, CoreML и browser-путей остаются за
|
||||
пунктом назначения карты.
|
||||
|
||||
## Минимальная экспериментальная матрица
|
||||
|
||||
Будущий platform experiment должен использовать один 5–10-минутный fixture с
|
||||
перекрывающейся речью и зафиксированным CPU output.
|
||||
|
||||
| Объект | Сравнение | Что фиксировать |
|
||||
|---|---|---|
|
||||
| GigaAM E2E RNN-T | ORT CPU против DirectML, WinML MIGraphX, Linux MIGraphX, CoreML | Создание всех сессий, node placement, CPU fallback, текст, token timestamps, численное расхождение |
|
||||
| PyAnnote segmentation | Те же EP отдельно от остального pipeline | Placement, интервалы и расхождение выходных тензоров |
|
||||
| WeSpeaker embeddings | Те же EP отдельно | Placement, cosine drift и влияние на clustering |
|
||||
| Полная диаризация | CPU baseline против каждого прошедшего EP | Число спикеров, границы, стабильность кластеров, время и память |
|
||||
| OpenVINO Whisper | Intel CPU/GPU/NPU и Apple CPU | Word timestamps, проектный adapter, время и память |
|
||||
| Browser, только если станет целью | WASM baseline против WebGPU/WebNN | Размер артефактов, kernels/fallback, preprocessing/decoder и память |
|
||||
|
||||
Путь можно предлагать пользователю только после прохождения всех пяти уровней
|
||||
доказательства; выигрыш отдельной ONNX-сессии не считается выигрышем
|
||||
end-to-end транскрипции или диаризации.
|
||||
|
||||
## Открытые вопросы после исследования
|
||||
|
||||
### Внутри карты диаризации
|
||||
|
||||
- Какой точный word/token timestamp contract нужен formatter и объединению с
|
||||
интервалами спикеров?
|
||||
- Как представить capability backend/model и какое fallback-поведение выбрать,
|
||||
если пословных таймкодов нет?
|
||||
|
||||
### Отдельные будущие работы
|
||||
|
||||
- Проходят ли GigaAM E2E RNN-T, PyAnnote и WeSpeaker все пять уровней на
|
||||
DirectML, Windows ML MIGraphX, Linux MIGraphX и CoreML?
|
||||
- Насколько устойчива локально работающая пара PyAnnote + WeSpeaker между EP,
|
||||
если upstream recipe её не фиксирует?
|
||||
- Окупает ли Windows ML bootstrap/catalog преимущество над низкофрикционным,
|
||||
но legacy DirectML?
|
||||
- Даёт ли OpenVINO EP выигрыш моделям диаризации на целевом Intel Core i5 после
|
||||
учёта fallback, загрузки и памяти?
|
||||
- Нужен ли отдельный browser experiment, или браузерная ветка остаётся только
|
||||
архитектурным примером переносимого baseline и опциональных ускорителей?
|
||||
+1
-1
@@ -10,7 +10,7 @@ dependencies = [
|
||||
"faster-whisper>=1.2.1,<2",
|
||||
"socksio>=1.0.0,<2",
|
||||
"nvidia-cublas-cu12>=12.4,<13; sys_platform == 'linux' and platform_machine == 'x86_64'",
|
||||
"openvino-genai>=2026.0.0.0,<2026.1; sys_platform != 'darwin' and (platform_machine == 'x86_64' or platform_machine == 'AMD64')",
|
||||
"openvino-genai>=2026.3.0.0,<2026.4; sys_platform != 'darwin' and (platform_machine == 'x86_64' or platform_machine == 'AMD64')",
|
||||
"onnx-asr[cpu,hub]>=0.12,<0.13",
|
||||
"onnxruntime>=1.28,<2",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
"""Воспроизводимый свип параметров офлайн-диаризации sherpa-onnx."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import itertools
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import time
|
||||
import wave
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import sherpa_onnx
|
||||
|
||||
TURN_RE = re.compile(r"^\*\*\[(\d{2}):(\d{2})(?::(\d{2}))?\] Speaker (\d+):\*\*")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Recording:
|
||||
name: str
|
||||
path: Path
|
||||
start: float
|
||||
duration: float
|
||||
expected_speakers: int
|
||||
reference: Path | None
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--manifest", type=Path, required=True)
|
||||
parser.add_argument("--output", type=Path, required=True)
|
||||
parser.add_argument("--work-dir", type=Path, required=True)
|
||||
parser.add_argument("--threads", type=int, default=8)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def file_sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as source:
|
||||
for chunk in iter(lambda: source.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest().upper()
|
||||
|
||||
|
||||
def decode_clip(recording: Recording, work_dir: Path) -> Path:
|
||||
output = work_dir / f"{recording.name}.wav"
|
||||
if output.exists():
|
||||
return output
|
||||
|
||||
command = [
|
||||
"ffmpeg",
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-y",
|
||||
"-ss",
|
||||
str(recording.start),
|
||||
"-t",
|
||||
str(recording.duration),
|
||||
"-i",
|
||||
str(recording.path),
|
||||
"-vn",
|
||||
"-ac",
|
||||
"1",
|
||||
"-ar",
|
||||
"16000",
|
||||
"-c:a",
|
||||
"pcm_s16le",
|
||||
str(output),
|
||||
]
|
||||
subprocess.run(command, check=True)
|
||||
return output
|
||||
|
||||
|
||||
def read_wav(path: Path) -> np.ndarray:
|
||||
with wave.open(str(path), "rb") as source:
|
||||
if source.getnchannels() != 1 or source.getsampwidth() != 2:
|
||||
raise ValueError(f"Ожидался mono PCM16 WAV: {path}")
|
||||
if source.getframerate() != 16000:
|
||||
raise ValueError(f"Ожидалась частота 16 кГц: {path}")
|
||||
samples = np.frombuffer(source.readframes(source.getnframes()), np.int16)
|
||||
return samples.astype(np.float32) / 32768.0
|
||||
|
||||
|
||||
def timestamp_seconds(match: re.Match[str]) -> float:
|
||||
first, second, third = match.group(1), match.group(2), match.group(3)
|
||||
if third is None:
|
||||
return int(first) * 60 + int(second)
|
||||
return int(first) * 3600 + int(second) * 60 + int(third)
|
||||
|
||||
|
||||
def read_reference_turns(recording: Recording) -> list[dict[str, Any]]:
|
||||
if recording.reference is None:
|
||||
return []
|
||||
|
||||
starts: list[tuple[float, str]] = []
|
||||
for line in recording.reference.read_text(encoding="utf-8").splitlines():
|
||||
match = TURN_RE.match(line)
|
||||
if match:
|
||||
starts.append((timestamp_seconds(match), match.group(4)))
|
||||
|
||||
clip_end = recording.start + recording.duration
|
||||
turns: list[dict[str, Any]] = []
|
||||
for index, (start, speaker) in enumerate(starts):
|
||||
end = starts[index + 1][0] if index + 1 < len(starts) else clip_end
|
||||
overlap_start = max(start, recording.start)
|
||||
overlap_end = min(end, clip_end)
|
||||
if overlap_end > overlap_start:
|
||||
turns.append(
|
||||
{
|
||||
"speaker": speaker,
|
||||
"start": overlap_start - recording.start,
|
||||
"end": overlap_end - recording.start,
|
||||
}
|
||||
)
|
||||
return turns
|
||||
|
||||
|
||||
def interval_overlap(left: dict[str, Any], right: dict[str, Any]) -> float:
|
||||
return max(0.0, min(left["end"], right["end"]) - max(left["start"], right["start"]))
|
||||
|
||||
|
||||
def best_mapping(
|
||||
segments: list[dict[str, Any]],
|
||||
reference_turns: list[dict[str, Any]],
|
||||
) -> dict[str, Any] | None:
|
||||
if not reference_turns or not segments:
|
||||
return None
|
||||
|
||||
predicted = sorted({str(segment["speaker"]) for segment in segments})
|
||||
reference = sorted({str(turn["speaker"]) for turn in reference_turns})
|
||||
overlap: defaultdict[tuple[str, str], float] = defaultdict(float)
|
||||
total = 0.0
|
||||
for segment in segments:
|
||||
predicted_speaker = str(segment["speaker"])
|
||||
for turn in reference_turns:
|
||||
value = interval_overlap(segment, turn)
|
||||
if value:
|
||||
reference_speaker = str(turn["speaker"])
|
||||
overlap[(predicted_speaker, reference_speaker)] += value
|
||||
total += value
|
||||
|
||||
best_score = -1.0
|
||||
best_pairs: list[tuple[str, str]] = []
|
||||
if len(predicted) >= len(reference):
|
||||
for candidate in itertools.permutations(predicted, len(reference)):
|
||||
pairs = list(zip(candidate, reference, strict=True))
|
||||
score = sum(overlap[pair] for pair in pairs)
|
||||
if score > best_score:
|
||||
best_score, best_pairs = score, pairs
|
||||
else:
|
||||
for candidate in itertools.permutations(reference, len(predicted)):
|
||||
pairs = list(zip(predicted, candidate, strict=True))
|
||||
score = sum(overlap[pair] for pair in pairs)
|
||||
if score > best_score:
|
||||
best_score, best_pairs = score, pairs
|
||||
|
||||
return {
|
||||
"mapped_speaker_purity": best_score / total if total else None,
|
||||
"mapped_overlap_seconds": best_score,
|
||||
"total_overlap_seconds": total,
|
||||
"mapping": {predicted: reference for predicted, reference in best_pairs},
|
||||
}
|
||||
|
||||
|
||||
def make_config(
|
||||
segmentation_model: Path,
|
||||
embedding_model: Path,
|
||||
threshold: float,
|
||||
num_clusters: int,
|
||||
threads: int,
|
||||
) -> sherpa_onnx.OfflineSpeakerDiarizationConfig:
|
||||
pyannote = sherpa_onnx.OfflineSpeakerSegmentationPyannoteModelConfig(
|
||||
model=str(segmentation_model)
|
||||
)
|
||||
segmentation = sherpa_onnx.OfflineSpeakerSegmentationModelConfig(
|
||||
pyannote=pyannote,
|
||||
num_threads=threads,
|
||||
)
|
||||
embedding = sherpa_onnx.SpeakerEmbeddingExtractorConfig(
|
||||
model=str(embedding_model),
|
||||
num_threads=threads,
|
||||
)
|
||||
clustering = sherpa_onnx.FastClusteringConfig(
|
||||
num_clusters=num_clusters,
|
||||
threshold=threshold,
|
||||
)
|
||||
return sherpa_onnx.OfflineSpeakerDiarizationConfig(
|
||||
segmentation=segmentation,
|
||||
embedding=embedding,
|
||||
clustering=clustering,
|
||||
)
|
||||
|
||||
|
||||
def summarize_segments(
|
||||
segments: list[dict[str, Any]],
|
||||
recording: Recording,
|
||||
) -> dict[str, Any]:
|
||||
durations: defaultdict[str, float] = defaultdict(float)
|
||||
for segment in segments:
|
||||
durations[str(segment["speaker"])] += segment["end"] - segment["start"]
|
||||
|
||||
ordered = sorted(durations.items(), key=lambda item: item[1], reverse=True)
|
||||
total = sum(durations.values())
|
||||
residual = sum(duration for _, duration in ordered[recording.expected_speakers :])
|
||||
substantial_threshold = max(5.0, recording.duration * 0.02)
|
||||
return {
|
||||
"clusters": len(ordered),
|
||||
"substantial_clusters": sum(
|
||||
duration >= substantial_threshold for _, duration in ordered
|
||||
),
|
||||
"substantial_threshold_seconds": substantial_threshold,
|
||||
"cluster_durations_seconds": dict(ordered),
|
||||
"speaker_time_seconds": total,
|
||||
"residual_seconds_after_expected": residual,
|
||||
"residual_share_after_expected": residual / total if total else None,
|
||||
}
|
||||
|
||||
|
||||
def save_output(path: Path, output: dict[str, Any]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = path.with_suffix(path.suffix + ".tmp")
|
||||
temporary.write_text(
|
||||
json.dumps(output, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
temporary.replace(path)
|
||||
|
||||
|
||||
def manifest_shape(manifest: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Отделить параметры эксперимента от машинно-зависимых путей."""
|
||||
return {
|
||||
"models": [item["name"] for item in manifest["models"]],
|
||||
"recordings": [
|
||||
{
|
||||
key: item[key]
|
||||
for key in ("name", "start", "duration", "expected_speakers")
|
||||
}
|
||||
for item in manifest["recordings"]
|
||||
],
|
||||
"runs": manifest["runs"],
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
manifest = json.loads(args.manifest.read_text(encoding="utf-8"))
|
||||
args.work_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
recordings = [
|
||||
Recording(
|
||||
name=item["name"],
|
||||
path=Path(item["path"]),
|
||||
start=float(item["start"]),
|
||||
duration=float(item["duration"]),
|
||||
expected_speakers=int(item["expected_speakers"]),
|
||||
reference=Path(item["reference"]) if item.get("reference") else None,
|
||||
)
|
||||
for item in manifest["recordings"]
|
||||
]
|
||||
if args.output.exists():
|
||||
output = json.loads(args.output.read_text(encoding="utf-8"))
|
||||
if (
|
||||
manifest_shape(output["manifest"]) != manifest_shape(manifest)
|
||||
or output["threads"] != args.threads
|
||||
):
|
||||
raise ValueError("Существующий output создан с другим manifest/threads")
|
||||
output["manifest"] = manifest
|
||||
else:
|
||||
output = {
|
||||
"manifest": manifest,
|
||||
"sherpa_onnx_version": sherpa_onnx.__version__,
|
||||
"threads": args.threads,
|
||||
"results": [],
|
||||
}
|
||||
completed = {
|
||||
(item["recording"], item["model"], item["run"]) for item in output["results"]
|
||||
}
|
||||
|
||||
segmentation_model = Path(manifest["segmentation_model"])
|
||||
for recording in recordings:
|
||||
print(f"Декодирование {recording.name}", flush=True)
|
||||
wav_path = decode_clip(recording, args.work_dir)
|
||||
samples = read_wav(wav_path)
|
||||
reference_turns = read_reference_turns(recording)
|
||||
source_hash = file_sha256(recording.path)
|
||||
|
||||
for model in manifest["models"]:
|
||||
embedding_model = Path(model["path"])
|
||||
for run in manifest["runs"]:
|
||||
run_key = (recording.name, model["name"], run["name"])
|
||||
if run_key in completed:
|
||||
print(f"Пропуск готового прогона: {run_key}", flush=True)
|
||||
continue
|
||||
num_clusters = run["num_clusters"]
|
||||
if num_clusters == "expected":
|
||||
num_clusters = recording.expected_speakers
|
||||
threshold = float(run["threshold"])
|
||||
print(
|
||||
f"{recording.name}: {model['name']} / {run['name']}",
|
||||
flush=True,
|
||||
)
|
||||
config = make_config(
|
||||
segmentation_model=segmentation_model,
|
||||
embedding_model=embedding_model,
|
||||
threshold=threshold,
|
||||
num_clusters=int(num_clusters),
|
||||
threads=args.threads,
|
||||
)
|
||||
diarizer = sherpa_onnx.OfflineSpeakerDiarization(config)
|
||||
started = time.perf_counter()
|
||||
result = diarizer.process(samples)
|
||||
elapsed = time.perf_counter() - started
|
||||
segments = [
|
||||
{
|
||||
"speaker": int(segment.speaker),
|
||||
"start": float(segment.start),
|
||||
"end": float(segment.end),
|
||||
}
|
||||
for segment in result.sort_by_start_time()
|
||||
]
|
||||
item = {
|
||||
"recording": recording.name,
|
||||
"source": recording.path.name,
|
||||
"source_sha256": source_hash,
|
||||
"clip_start": recording.start,
|
||||
"clip_duration": recording.duration,
|
||||
"expected_speakers": recording.expected_speakers,
|
||||
"reference": recording.reference.name
|
||||
if recording.reference
|
||||
else None,
|
||||
"model": model["name"],
|
||||
"model_file": embedding_model.name,
|
||||
"run": run["name"],
|
||||
"threshold": threshold,
|
||||
"num_clusters": int(num_clusters),
|
||||
"elapsed_seconds": elapsed,
|
||||
"rtf": elapsed / recording.duration,
|
||||
"summary": summarize_segments(segments, recording),
|
||||
"reference_mapping": best_mapping(segments, reference_turns),
|
||||
"segments": segments,
|
||||
}
|
||||
output["results"].append(item)
|
||||
completed.add(run_key)
|
||||
save_output(args.output, output)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -2,45 +2,91 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import warnings
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from local_transcriber.types import Segment, TranscribeResult
|
||||
from local_transcriber.types import UNKNOWN_LANGUAGE, Segment, TranscribeResult
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OnnxModelSpec:
|
||||
"""Имя onnx-asr и опубликованные варианты квантизации модели."""
|
||||
"""Имя onnx-asr, варианты квантизации и поддерживаемые языки."""
|
||||
|
||||
model_id: str
|
||||
quantizations: frozenset[str | None]
|
||||
supported_languages: frozenset[str]
|
||||
|
||||
|
||||
_INT8_AND_FLOAT32 = frozenset({"int8", None})
|
||||
_RUSSIAN_ONLY = frozenset({"ru"})
|
||||
_GIGAAM_MULTILINGUAL_LANGUAGES = frozenset({"ru", "en", "kk", "ky", "uz"})
|
||||
_PARAKEET_V3_LANGUAGES = frozenset(
|
||||
{
|
||||
"bg",
|
||||
"hr",
|
||||
"cs",
|
||||
"da",
|
||||
"nl",
|
||||
"en",
|
||||
"et",
|
||||
"fi",
|
||||
"fr",
|
||||
"de",
|
||||
"el",
|
||||
"hu",
|
||||
"it",
|
||||
"lv",
|
||||
"lt",
|
||||
"mt",
|
||||
"pl",
|
||||
"pt",
|
||||
"ro",
|
||||
"sk",
|
||||
"sl",
|
||||
"es",
|
||||
"sv",
|
||||
"ru",
|
||||
"uk",
|
||||
}
|
||||
)
|
||||
_WHISPER_MODEL_NAMES = frozenset(
|
||||
{"tiny", "base", "small", "medium", "large-v3", "large-v3-turbo"}
|
||||
)
|
||||
# faster-whisper не знает turbo, поэтому для cpu и cuda подсказываем medium.
|
||||
# Источник правды — MODEL_REPOS в backends/faster_whisper.py и backends/openvino.py
|
||||
_OPENVINO_ONLY_WHISPER_MODELS = frozenset({"large-v3-turbo"})
|
||||
|
||||
MODEL_CATALOG: dict[str, OnnxModelSpec] = {
|
||||
"gigaam-v3": OnnxModelSpec("gigaam-v3-ctc", _INT8_AND_FLOAT32),
|
||||
"gigaam-v3": OnnxModelSpec(
|
||||
"gigaam-v3-ctc", _INT8_AND_FLOAT32, _RUSSIAN_ONLY
|
||||
),
|
||||
"parakeet-v3": OnnxModelSpec(
|
||||
"nemo-parakeet-tdt-0.6b-v3",
|
||||
_INT8_AND_FLOAT32,
|
||||
_PARAKEET_V3_LANGUAGES,
|
||||
),
|
||||
"gigaam-multilingual-ctc": OnnxModelSpec(
|
||||
"gigaam-multilingual-ctc",
|
||||
_INT8_AND_FLOAT32,
|
||||
_GIGAAM_MULTILINGUAL_LANGUAGES,
|
||||
),
|
||||
"gigaam-multilingual-large-ctc": OnnxModelSpec(
|
||||
"gigaam-multilingual-large-ctc",
|
||||
_INT8_AND_FLOAT32,
|
||||
_GIGAAM_MULTILINGUAL_LANGUAGES,
|
||||
),
|
||||
"gigaam-v3-e2e-ctc": OnnxModelSpec(
|
||||
"gigaam-v3-e2e-ctc",
|
||||
_INT8_AND_FLOAT32,
|
||||
_RUSSIAN_ONLY,
|
||||
),
|
||||
"gigaam-v3-e2e-rnnt": OnnxModelSpec(
|
||||
"gigaam-v3-e2e-rnnt",
|
||||
_INT8_AND_FLOAT32,
|
||||
_RUSSIAN_ONLY,
|
||||
),
|
||||
}
|
||||
|
||||
@@ -85,6 +131,8 @@ class OnnxAsrBackend:
|
||||
self._compute_type_explicit = compute_type_explicit
|
||||
self.actual_compute_type: str | None = None
|
||||
self._resolved_model_id: str | None = None
|
||||
self._model_name: str | None = None
|
||||
self._model_spec: OnnxModelSpec | None = None
|
||||
self._vad: Any = None
|
||||
|
||||
def ensure_model_available(
|
||||
@@ -119,6 +167,8 @@ class OnnxAsrBackend:
|
||||
|
||||
self.actual_compute_type = resolved_compute_type
|
||||
self._resolved_model_id = self._resolve_model(model_name)
|
||||
self._model_name = model_name
|
||||
self._model_spec = spec
|
||||
return self._resolved_model_id
|
||||
|
||||
def create_model(
|
||||
@@ -162,13 +212,16 @@ class OnnxAsrBackend:
|
||||
"""
|
||||
from faster_whisper import decode_audio
|
||||
|
||||
self._warn_if_language_unsupported(language)
|
||||
_notify(on_status, "Загружаю аудио...")
|
||||
audio_array = decode_audio(str(file_path), sampling_rate=16000)
|
||||
duration = len(audio_array) / 16000.0
|
||||
|
||||
_notify(on_status, "Транскрибирую (onnx-asr)...")
|
||||
segments: list[Segment] = []
|
||||
detected_language = language or "unknown"
|
||||
result_language = (
|
||||
language or _model_language(self._model_spec) or UNKNOWN_LANGUAGE
|
||||
)
|
||||
|
||||
for vad_seg in model.recognize(
|
||||
audio_array, sample_rate=16000, language=language
|
||||
@@ -192,7 +245,7 @@ class OnnxAsrBackend:
|
||||
|
||||
return TranscribeResult(
|
||||
segments=segments,
|
||||
language=detected_language,
|
||||
language=result_language,
|
||||
language_probability=1.0 if language else 0.0,
|
||||
duration=duration,
|
||||
device_used="", # оркестратор проставит
|
||||
@@ -202,6 +255,15 @@ class OnnxAsrBackend:
|
||||
"""Resolve alias to onnx-asr model name. Raw names pass through."""
|
||||
if model_name in MODEL_ALIASES:
|
||||
return MODEL_ALIASES[model_name]
|
||||
if model_name in _WHISPER_MODEL_NAMES:
|
||||
fallback = _whisper_fallback_model(model_name)
|
||||
raise ValueError(
|
||||
f"Модель '{model_name}' относится к Whisper и не поддерживается "
|
||||
"ONNX-бэкендом. Без CUDA --device auto выбирает ONNX; "
|
||||
f"укажите --device openvino-cpu --model {model_name} на x86, "
|
||||
f"--device cpu --model {fallback} на любой платформе "
|
||||
f"или --device cuda --model {fallback} при NVIDIA GPU."
|
||||
)
|
||||
if "/" in model_name or model_name.count("-") >= 2:
|
||||
# Looks like a raw onnx-asr name — allow passthrough
|
||||
return model_name
|
||||
@@ -211,6 +273,26 @@ class OnnxAsrBackend:
|
||||
f"Либо укажите полное имя модели onnx-asr."
|
||||
)
|
||||
|
||||
def _warn_if_language_unsupported(self, language: str | None) -> None:
|
||||
if (
|
||||
language is None
|
||||
or self._model_spec is None
|
||||
or language in self._model_spec.supported_languages
|
||||
):
|
||||
return
|
||||
|
||||
supported = ", ".join(sorted(self._model_spec.supported_languages))
|
||||
warnings.warn(
|
||||
f"Язык '{language}' не поддерживается моделью '{self._model_name}' "
|
||||
f"(поддерживаются: {supported}). Результат может быть некорректным. "
|
||||
"Для других языков возьмите Whisper: "
|
||||
"--device openvino-cpu --model medium на x86, "
|
||||
"--device cpu --model medium на любой платформе "
|
||||
"или --device cuda --model medium при NVIDIA GPU.",
|
||||
UserWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
|
||||
def _format_compute_types(quantizations: frozenset[str | None]) -> str:
|
||||
values = [_compute_type_for_quantization(value) for value in quantizations]
|
||||
@@ -228,6 +310,19 @@ def _preferred_compute_type(quantizations: frozenset[str | None]) -> str:
|
||||
raise ValueError("Для ONNX-модели не указаны доступные квантизации")
|
||||
|
||||
|
||||
def _whisper_fallback_model(model_name: str) -> str:
|
||||
"""Модель для подсказки про faster-whisper: turbo там недоступен."""
|
||||
if model_name in _OPENVINO_ONLY_WHISPER_MODELS:
|
||||
return "medium"
|
||||
return model_name
|
||||
|
||||
|
||||
def _model_language(spec: OnnxModelSpec | None) -> str | None:
|
||||
if spec is not None and len(spec.supported_languages) == 1:
|
||||
return next(iter(spec.supported_languages))
|
||||
return None
|
||||
|
||||
|
||||
def _notify(on_status: Callable[[str], None] | None, message: str) -> None:
|
||||
if on_status is not None:
|
||||
on_status(message)
|
||||
|
||||
@@ -12,7 +12,7 @@ from typing import Any
|
||||
from huggingface_hub import snapshot_download
|
||||
from huggingface_hub.errors import LocalEntryNotFoundError
|
||||
|
||||
from local_transcriber.types import Segment, TranscribeResult
|
||||
from local_transcriber.types import UNKNOWN_LANGUAGE, Segment, TranscribeResult
|
||||
|
||||
# (model_alias, compute_type) → HF repo
|
||||
MODEL_REPOS: dict[tuple[str, str], str] = {
|
||||
@@ -23,6 +23,8 @@ MODEL_REPOS: dict[tuple[str, str], str] = {
|
||||
("medium", "fp16"): "OpenVINO/whisper-medium-fp16-ov",
|
||||
("large-v3", "int8"): "OpenVINO/whisper-large-v3-int8-ov",
|
||||
("large-v3", "fp16"): "OpenVINO/whisper-large-v3-fp16-ov",
|
||||
("large-v3-turbo", "int8"): "OpenVINO/whisper-large-v3-turbo-int8-ov",
|
||||
("large-v3-turbo", "fp16"): "OpenVINO/whisper-large-v3-turbo-fp16-ov",
|
||||
}
|
||||
|
||||
# Fallback: если точная пара не найдена, пробуем альтернативный compute_type
|
||||
@@ -157,7 +159,7 @@ class OpenVINOBackend:
|
||||
f"Транскрибирую (OpenVINO)... [{len(segments)} сегм.]",
|
||||
)
|
||||
|
||||
detected_language = language or "auto"
|
||||
detected_language = language or UNKNOWN_LANGUAGE
|
||||
language_probability = 1.0 if language else 0.0
|
||||
|
||||
return TranscribeResult(
|
||||
|
||||
@@ -12,6 +12,10 @@ from .config import apply_device_defaults, load_config, resolve_defaults
|
||||
from .context_menu import install_menu as install_context_menu
|
||||
from .context_menu import uninstall_menu as uninstall_context_menu
|
||||
from .formatter import (
|
||||
LANGUAGE_DETECTED,
|
||||
LANGUAGE_FORCED,
|
||||
LANGUAGE_FROM_MODEL,
|
||||
LANGUAGE_UNKNOWN,
|
||||
format_duration,
|
||||
format_timestamp,
|
||||
format_transcript,
|
||||
@@ -30,6 +34,7 @@ from .transcriber import (
|
||||
_transcribe_file,
|
||||
load_model,
|
||||
)
|
||||
from .types import UNKNOWN_LANGUAGE
|
||||
from .utils import (
|
||||
build_output_path,
|
||||
detect_device,
|
||||
@@ -57,6 +62,19 @@ def _format_device_info(device_used: str) -> str:
|
||||
return "CPU"
|
||||
|
||||
|
||||
def _format_language_mode(
|
||||
requested_language: str, result: TranscribeResult
|
||||
) -> str:
|
||||
"""Описывает источник языка, не выдавая профиль модели за детектор."""
|
||||
if requested_language != "auto":
|
||||
return LANGUAGE_FORCED
|
||||
if result.language_probability > 0:
|
||||
return LANGUAGE_DETECTED
|
||||
if result.language not in {"", UNKNOWN_LANGUAGE}:
|
||||
return LANGUAGE_FROM_MODEL
|
||||
return LANGUAGE_UNKNOWN
|
||||
|
||||
|
||||
def _format_repetition_blocks(
|
||||
blocks: list[RepetitionBlock],
|
||||
use_hours: bool,
|
||||
@@ -114,7 +132,11 @@ def _print_quality_warnings(result: TranscribeResult, file_name: str | None = No
|
||||
def main(
|
||||
files: list[Path] | None = typer.Argument(None, help="Пути к аудио/видеофайлам"),
|
||||
model: str | None = typer.Option(
|
||||
None, "--model", "-m", show_default=False, help="Модель Whisper [по умолч.: medium]"
|
||||
None,
|
||||
"--model",
|
||||
"-m",
|
||||
show_default=False,
|
||||
help="Модель [по умолч.: medium (CUDA) / gigaam-v3-e2e-rnnt (ONNX)]",
|
||||
),
|
||||
language: str | None = typer.Option(
|
||||
None, "--language", "-l", show_default=False, help="Язык [по умолч.: ru]"
|
||||
@@ -126,7 +148,10 @@ def main(
|
||||
),
|
||||
compute_type: str | None = typer.Option(
|
||||
None, "--compute-type", show_default=False,
|
||||
help="Тип вычислений [по умолч.: float16 (CUDA) / int8 (OpenVINO GPU/CPU) / float32 (CPU)]"
|
||||
help=(
|
||||
"Тип вычислений [по умолч.: float16 (CUDA) / "
|
||||
"int8 (ONNX/OpenVINO) / float32 (CPU)]"
|
||||
),
|
||||
),
|
||||
threads: int = typer.Option(
|
||||
0, "--threads", "-t", show_default=False, min=0,
|
||||
@@ -308,7 +333,7 @@ def _run_single(
|
||||
)
|
||||
|
||||
device_info = _format_device_info(result.device_used)
|
||||
language_mode = "detected" if defaults["language"] == "auto" else "forced"
|
||||
language_mode = _format_language_mode(defaults["language"], result)
|
||||
|
||||
content = format_transcript(
|
||||
result=result,
|
||||
@@ -394,8 +419,6 @@ def _run_batch(
|
||||
# Phase 3: Transcribe
|
||||
processed = 0
|
||||
failed = 0
|
||||
language_mode = "detected" if defaults["language"] == "auto" else "forced"
|
||||
|
||||
batch_start = time.monotonic()
|
||||
|
||||
for i, file in enumerate(to_process, 1):
|
||||
@@ -435,6 +458,7 @@ def _run_batch(
|
||||
model_path = tfr.model_path
|
||||
|
||||
result = tfr.result
|
||||
language_mode = _format_language_mode(defaults["language"], result)
|
||||
|
||||
if len(result.segments) == 0:
|
||||
console.print(
|
||||
|
||||
@@ -10,6 +10,19 @@ from .types import Segment, TranscribeResult
|
||||
_PAUSE_THRESHOLD_S = 2.0 # пауза между сегментами для разбиения на абзацы
|
||||
_MAX_PARAGRAPH_S = 60.0 # максимальная длительность абзаца
|
||||
|
||||
# Источник языка в шапке транскрипта
|
||||
LANGUAGE_FORCED = "задан явно"
|
||||
LANGUAGE_DETECTED = "определён автоматически"
|
||||
LANGUAGE_FROM_MODEL = "из профиля модели"
|
||||
LANGUAGE_UNKNOWN = "не определён"
|
||||
|
||||
LANGUAGE_MODES = (
|
||||
LANGUAGE_FORCED,
|
||||
LANGUAGE_DETECTED,
|
||||
LANGUAGE_FROM_MODEL,
|
||||
LANGUAGE_UNKNOWN,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Paragraph:
|
||||
@@ -80,7 +93,7 @@ def format_transcript(
|
||||
source_filename: str,
|
||||
model_name: str,
|
||||
device_info: str,
|
||||
language_mode: str, # "detected" | "forced"
|
||||
language_mode: str, # см. LANGUAGE_MODES
|
||||
transcription_date: datetime | None = None, # None -> datetime.now()
|
||||
) -> str:
|
||||
"""Собирает markdown-транскрипт: шапка с метаданными + абзацы с таймкодами."""
|
||||
@@ -92,7 +105,10 @@ def format_transcript(
|
||||
lines.append("")
|
||||
lines.append(f"- **Дата транскрипции**: {date.strftime('%Y-%m-%d %H:%M:%S')}")
|
||||
lines.append(f"- **Модель**: {model_name}")
|
||||
lines.append(f"- **Язык**: {result.language} ({language_mode})")
|
||||
if language_mode == LANGUAGE_UNKNOWN:
|
||||
lines.append(f"- **Язык**: {LANGUAGE_UNKNOWN}")
|
||||
else:
|
||||
lines.append(f"- **Язык**: {result.language} ({language_mode})")
|
||||
lines.append(f"- **Длительность**: {format_duration(result.duration)}")
|
||||
if tail_gap(result) > TAIL_GAP_WARN_S:
|
||||
last_end = result.segments[-1].end
|
||||
|
||||
@@ -4,6 +4,9 @@ from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
# Единый признак «язык неизвестен» для всех бэкендов
|
||||
UNKNOWN_LANGUAGE = "unknown"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Segment:
|
||||
@@ -15,10 +18,10 @@ class Segment:
|
||||
@dataclass
|
||||
class TranscribeResult:
|
||||
segments: list[Segment]
|
||||
language: str
|
||||
language: str # код языка или UNKNOWN_LANGUAGE, если он неизвестен
|
||||
language_probability: float
|
||||
duration: float # seconds
|
||||
device_used: str # "cpu" / "cuda" / "openvino-gpu" / "openvino-cpu"
|
||||
device_used: str # "cpu" / "cuda" / "onnx" / "openvino-gpu" / "openvino-cpu"
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -16,18 +16,16 @@ SUPPORTED_EXTENSIONS = {
|
||||
def detect_device(requested: str = "auto") -> str:
|
||||
"""Определяет устройство для вычислений.
|
||||
|
||||
При ``requested="auto"`` проверяет: CUDA → OpenVINO GPU → OpenVINO CPU → CPU.
|
||||
``"openvino"`` — алиас для авто-детекта внутри OpenVINO (GPU если доступен, иначе CPU).
|
||||
При ``requested="auto"`` выбирает CUDA при наличии ``nvidia-smi``,
|
||||
иначе ONNX на CPU.
|
||||
``"openvino"`` — алиас для авто-детекта внутри OpenVINO
|
||||
(GPU если доступен, иначе CPU).
|
||||
Возвращает всегда конкретное значение (не абстрактный ``"openvino"``).
|
||||
"""
|
||||
if requested == "auto":
|
||||
if shutil.which("nvidia-smi") is not None:
|
||||
return "cuda"
|
||||
if _is_openvino_gpu_available():
|
||||
return "openvino-gpu"
|
||||
if _is_openvino_available():
|
||||
return "openvino-cpu"
|
||||
return "cpu"
|
||||
return "onnx"
|
||||
if requested == "openvino":
|
||||
if _is_openvino_gpu_available():
|
||||
return "openvino-gpu"
|
||||
|
||||
@@ -11,12 +11,21 @@ from local_transcriber.backends.openvino import (
|
||||
OpenVINOBackend,
|
||||
_validate_model_dir,
|
||||
)
|
||||
from local_transcriber.types import Segment
|
||||
from local_transcriber.types import UNKNOWN_LANGUAGE, Segment
|
||||
|
||||
|
||||
# === _resolve_repo ===
|
||||
|
||||
|
||||
def test_model_catalog_contains_large_v3_turbo_profiles():
|
||||
assert {
|
||||
pair: repo for pair, repo in MODEL_REPOS.items() if pair[0] == "large-v3-turbo"
|
||||
} == {
|
||||
("large-v3-turbo", "int8"): "OpenVINO/whisper-large-v3-turbo-int8-ov",
|
||||
("large-v3-turbo", "fp16"): "OpenVINO/whisper-large-v3-turbo-fp16-ov",
|
||||
}
|
||||
|
||||
|
||||
def test_resolve_repo_exact_match():
|
||||
backend = OpenVINOBackend(compute_type_explicit=True)
|
||||
assert backend._resolve_repo("medium", "int8") == ("OpenVINO/whisper-medium-int8-ov", "int8")
|
||||
@@ -47,11 +56,20 @@ def test_resolve_repo_implicit_fallback():
|
||||
assert backend._resolve_repo("base", "int8") == ("OpenVINO/whisper-base-fp16-ov", "fp16")
|
||||
|
||||
|
||||
def test_resolve_repo_implicit_large_v3_prefers_fp16():
|
||||
"""Неявный compute_type: large-v3 автоматически получает fp16."""
|
||||
@pytest.mark.parametrize(
|
||||
("model_name", "expected_compute_type"),
|
||||
[("large-v3", "fp16"), ("large-v3-turbo", "int8")],
|
||||
)
|
||||
def test_resolve_repo_implicit_large_v3_profiles(
|
||||
model_name, expected_compute_type
|
||||
):
|
||||
"""Неявный compute_type различает обычную и turbo-модель."""
|
||||
backend = OpenVINOBackend(compute_type_explicit=False)
|
||||
# Дефолт int8, но для large-v3 override на fp16
|
||||
assert backend._resolve_repo("large-v3", "int8") == ("OpenVINO/whisper-large-v3-fp16-ov", "fp16")
|
||||
|
||||
assert backend._resolve_repo(model_name, "int8") == (
|
||||
f"OpenVINO/whisper-{model_name}-{expected_compute_type}-ov",
|
||||
expected_compute_type,
|
||||
)
|
||||
|
||||
|
||||
def test_resolve_repo_explicit_large_v3_int8_respected():
|
||||
@@ -60,6 +78,26 @@ def test_resolve_repo_explicit_large_v3_int8_respected():
|
||||
assert backend._resolve_repo("large-v3", "int8") == ("OpenVINO/whisper-large-v3-int8-ov", "int8")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("compute_type", ["int8", "fp16"])
|
||||
def test_resolve_repo_large_v3_turbo_quantization(compute_type):
|
||||
backend = OpenVINOBackend(compute_type_explicit=True)
|
||||
|
||||
assert backend._resolve_repo("large-v3-turbo", compute_type) == (
|
||||
f"OpenVINO/whisper-large-v3-turbo-{compute_type}-ov",
|
||||
compute_type,
|
||||
)
|
||||
|
||||
|
||||
def test_resolve_repo_large_v3_turbo_unsupported_quantization_raises():
|
||||
backend = OpenVINOBackend(compute_type_explicit=True)
|
||||
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match="Доступные варианты: fp16, int8",
|
||||
):
|
||||
backend._resolve_repo("large-v3-turbo", "float32")
|
||||
|
||||
|
||||
# === ensure_model_available ===
|
||||
|
||||
|
||||
@@ -101,6 +139,27 @@ def test_ensure_model_available_downloads(mock_download, tmp_path):
|
||||
assert any("Скачиваю" in s for s in statuses)
|
||||
|
||||
|
||||
@patch("local_transcriber.backends.openvino.snapshot_download")
|
||||
def test_large_v3_turbo_model_is_resolved_and_created(mock_download, tmp_path):
|
||||
model_dir = tmp_path / "large-v3-turbo"
|
||||
model_dir.mkdir()
|
||||
(model_dir / "openvino_encoder_model.xml").write_text("<xml/>")
|
||||
(model_dir / "openvino_decoder_model.xml").write_text("<xml/>")
|
||||
mock_download.return_value = str(model_dir)
|
||||
mock_ov = MagicMock()
|
||||
|
||||
backend = OpenVINOBackend(ov_device="openvino-cpu", compute_type_explicit=True)
|
||||
model_path = backend.ensure_model_available("large-v3-turbo", "int8")
|
||||
with patch.dict("sys.modules", {"openvino_genai": mock_ov}):
|
||||
backend.create_model(model_path, "openvino-cpu", "int8")
|
||||
|
||||
mock_download.assert_called_once_with(
|
||||
"OpenVINO/whisper-large-v3-turbo-int8-ov",
|
||||
local_files_only=True,
|
||||
)
|
||||
mock_ov.WhisperPipeline.assert_called_once_with(str(model_dir), "CPU")
|
||||
|
||||
|
||||
# === create_model ===
|
||||
|
||||
|
||||
@@ -242,7 +301,7 @@ def test_transcribe_no_language_auto():
|
||||
|
||||
call_kwargs = mock_model.generate.call_args.kwargs
|
||||
assert "language" not in call_kwargs
|
||||
assert result.language == "auto"
|
||||
assert result.language == UNKNOWN_LANGUAGE
|
||||
assert result.language_probability == 0.0
|
||||
|
||||
|
||||
|
||||
+77
-8
@@ -5,8 +5,15 @@ import pytest
|
||||
from rich.console import Console
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from local_transcriber.cli import _format_device_info, app
|
||||
from local_transcriber.cli import _format_device_info, _format_language_mode, app
|
||||
from local_transcriber.formatter import (
|
||||
LANGUAGE_DETECTED,
|
||||
LANGUAGE_FORCED,
|
||||
LANGUAGE_FROM_MODEL,
|
||||
LANGUAGE_UNKNOWN,
|
||||
)
|
||||
from local_transcriber.transcriber import Segment, TranscribeFileResult, TranscribeResult
|
||||
from local_transcriber.types import UNKNOWN_LANGUAGE
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
@@ -42,6 +49,24 @@ def _make_tfr(result=None, model=None, actual_device="cpu", backend=None, model_
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("requested_language", "language", "probability", "expected"),
|
||||
[
|
||||
("ru", "ru", 1.0, LANGUAGE_FORCED),
|
||||
("auto", "ru", 0.95, LANGUAGE_DETECTED),
|
||||
("auto", "ru", 0.0, LANGUAGE_FROM_MODEL),
|
||||
("auto", UNKNOWN_LANGUAGE, 0.0, LANGUAGE_UNKNOWN),
|
||||
],
|
||||
)
|
||||
def test_format_language_mode(
|
||||
requested_language, language, probability, expected
|
||||
):
|
||||
result = _make_result(language=language)
|
||||
result.language_probability = probability
|
||||
|
||||
assert _format_language_mode(requested_language, result) == expected
|
||||
|
||||
|
||||
def _single_patches(result=None, tmp_file=None, actual_device="cpu"):
|
||||
"""Patches for a standard single-file CLI happy path."""
|
||||
if result is None:
|
||||
@@ -73,27 +98,32 @@ def test_cli_happy_path_exit_code_zero(tmp_path):
|
||||
def test_cli_default_options_passed_to_transcribe(tmp_path):
|
||||
audio = tmp_path / "test.mp3"
|
||||
audio.write_bytes(b"fake")
|
||||
result = _make_result()
|
||||
result = _make_result(device_used="onnx")
|
||||
model = _make_model()
|
||||
backend = _make_backend()
|
||||
tfr = _make_tfr(result=result, model=model, backend=backend)
|
||||
tfr = _make_tfr(result=result, model=model, actual_device="onnx", backend=backend)
|
||||
mock_transcribe_file = MagicMock(return_value=tfr)
|
||||
|
||||
with (
|
||||
patch("local_transcriber.cli.load_config", return_value={}),
|
||||
patch("local_transcriber.cli.validate_input_file", return_value=audio),
|
||||
patch("local_transcriber.cli.detect_device", return_value="cpu"),
|
||||
patch("local_transcriber.cli.load_model", return_value=(model, "cpu", backend, "/models/medium")),
|
||||
patch("local_transcriber.cli.detect_device", return_value="onnx"),
|
||||
patch(
|
||||
"local_transcriber.cli.load_model",
|
||||
return_value=(model, "onnx", backend, "/models/gigaam-v3-e2e-rnnt"),
|
||||
),
|
||||
patch("local_transcriber.cli._transcribe_file", mock_transcribe_file),
|
||||
patch("local_transcriber.cli.write_transcript"),
|
||||
):
|
||||
runner.invoke(app, [str(audio)])
|
||||
out = runner.invoke(app, [str(audio)])
|
||||
|
||||
call_kwargs = mock_transcribe_file.call_args[1]
|
||||
assert call_kwargs["model_name"] == "medium"
|
||||
assert call_kwargs["compute_type"] == "float32"
|
||||
assert call_kwargs["model_name"] == "gigaam-v3-e2e-rnnt"
|
||||
assert call_kwargs["compute_type"] == "int8"
|
||||
assert call_kwargs["language"] == "ru"
|
||||
assert call_kwargs["on_segment"] is None # verbose=False
|
||||
assert "Модель: gigaam-v3-e2e-rnnt" in out.output
|
||||
assert "Устройство: onnx" in out.output
|
||||
|
||||
|
||||
def test_cli_custom_options(tmp_path):
|
||||
@@ -660,6 +690,45 @@ def test_cli_config_applied(tmp_path):
|
||||
assert mock_load_model.call_args[0][0] == "tiny"
|
||||
|
||||
|
||||
def test_cli_config_overrides_auto_device(tmp_path):
|
||||
audio = tmp_path / "test.mp3"
|
||||
audio.write_bytes(b"fake")
|
||||
model = _make_model()
|
||||
backend = _make_backend()
|
||||
result = _make_result(device_used="openvino-cpu")
|
||||
tfr = _make_tfr(
|
||||
result=result,
|
||||
model=model,
|
||||
actual_device="openvino-cpu",
|
||||
backend=backend,
|
||||
)
|
||||
mock_detect_device = MagicMock(return_value="openvino-cpu")
|
||||
mock_load_model = MagicMock(
|
||||
return_value=(model, "openvino-cpu", backend, "/models/medium")
|
||||
)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"local_transcriber.cli.load_config",
|
||||
return_value={
|
||||
"device": "openvino-cpu",
|
||||
"model": "medium",
|
||||
"compute_type": "int8",
|
||||
},
|
||||
),
|
||||
patch("local_transcriber.cli.validate_input_file", return_value=audio),
|
||||
patch("local_transcriber.cli.detect_device", mock_detect_device),
|
||||
patch("local_transcriber.cli.load_model", mock_load_model),
|
||||
patch("local_transcriber.cli._transcribe_file", return_value=tfr),
|
||||
patch("local_transcriber.cli.write_transcript"),
|
||||
):
|
||||
out = runner.invoke(app, [str(audio)])
|
||||
|
||||
assert out.exit_code == 0
|
||||
assert mock_detect_device.call_args_list[0].args == ("openvino-cpu",)
|
||||
assert mock_load_model.call_args.args[:3] == ("medium", "openvino-cpu", "int8")
|
||||
|
||||
|
||||
def test_cli_cli_overrides_config(tmp_path):
|
||||
audio = tmp_path / "test.mp3"
|
||||
audio.write_bytes(b"fake")
|
||||
|
||||
@@ -71,11 +71,17 @@ def test_load_config_invalid_device(tmp_path):
|
||||
|
||||
|
||||
def test_resolve_defaults_cli_wins():
|
||||
config = {"model": "tiny", "language": "en"}
|
||||
cli = {"model": "small", "language": None, "device": None, "compute_type": None}
|
||||
config = {"model": "tiny", "language": "en", "device": "openvino-cpu"}
|
||||
cli = {
|
||||
"model": "small",
|
||||
"language": None,
|
||||
"device": "onnx",
|
||||
"compute_type": None,
|
||||
}
|
||||
result = resolve_defaults(cli, config)
|
||||
assert result["model"] == "small"
|
||||
assert result["language"] == "en"
|
||||
assert result["device"] == "onnx"
|
||||
|
||||
|
||||
def test_resolve_defaults_config_wins():
|
||||
|
||||
+37
-12
@@ -2,12 +2,16 @@ from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from local_transcriber.formatter import (
|
||||
LANGUAGE_DETECTED,
|
||||
LANGUAGE_FORCED,
|
||||
LANGUAGE_UNKNOWN,
|
||||
_group_segments,
|
||||
format_timestamp,
|
||||
format_transcript,
|
||||
write_transcript,
|
||||
)
|
||||
from local_transcriber.transcriber import Segment, TranscribeResult
|
||||
from local_transcriber.types import UNKNOWN_LANGUAGE
|
||||
|
||||
|
||||
def test_format_timestamp_minutes():
|
||||
@@ -40,14 +44,14 @@ def test_format_transcript_basic():
|
||||
source_filename="meeting.mp4",
|
||||
model_name="large-v3",
|
||||
device_info="CUDA (NVIDIA GeForce RTX 3060)",
|
||||
language_mode="detected",
|
||||
language_mode=LANGUAGE_DETECTED,
|
||||
transcription_date=datetime(2026, 3, 17, 14, 30, 5),
|
||||
)
|
||||
|
||||
assert "# Транскрипт: meeting.mp4" in content
|
||||
assert "**Дата транскрипции**: 2026-03-17 14:30:05" in content
|
||||
assert "**Модель**: large-v3" in content
|
||||
assert "**Язык**: ru (detected)" in content
|
||||
assert "**Язык**: ru (определён автоматически)" in content
|
||||
assert "**Длительность**: 02:00" in content
|
||||
assert "**Устройство**: CUDA (NVIDIA GeForce RTX 3060)" in content
|
||||
assert "---" in content
|
||||
@@ -55,6 +59,27 @@ def test_format_transcript_basic():
|
||||
assert "[00:00.00 - 00:09.15] Добрый день, коллеги. Первый вопрос." in content
|
||||
|
||||
|
||||
def test_format_transcript_unknown_language_without_placeholder():
|
||||
"""Неизвестный язык печатается одной строкой, без служебного значения."""
|
||||
result = TranscribeResult(
|
||||
segments=[Segment(start=0.0, end=4.0, text=" Добрый день.")],
|
||||
language=UNKNOWN_LANGUAGE,
|
||||
language_probability=0.0,
|
||||
duration=120.0,
|
||||
device_used="openvino-cpu",
|
||||
)
|
||||
content = format_transcript(
|
||||
result,
|
||||
source_filename="meeting.mp4",
|
||||
model_name="medium",
|
||||
device_info="OpenVINO (CPU)",
|
||||
language_mode=LANGUAGE_UNKNOWN,
|
||||
)
|
||||
|
||||
assert "**Язык**: не определён" in content
|
||||
assert UNKNOWN_LANGUAGE not in content
|
||||
|
||||
|
||||
def test_format_transcript_segment_no_leading_space():
|
||||
"""Сегменты без ведущего пробела должны форматироваться корректно."""
|
||||
result = TranscribeResult(
|
||||
@@ -69,7 +94,7 @@ def test_format_transcript_segment_no_leading_space():
|
||||
source_filename="f.mp3",
|
||||
model_name="tiny",
|
||||
device_info="CPU",
|
||||
language_mode="detected",
|
||||
language_mode=LANGUAGE_DETECTED,
|
||||
transcription_date=datetime(2026, 1, 1, 0, 0, 0),
|
||||
)
|
||||
assert "[00:00.00 - 00:02.00] Hello" in content
|
||||
@@ -88,7 +113,7 @@ def test_format_transcript_empty():
|
||||
source_filename="silence.wav",
|
||||
model_name="tiny",
|
||||
device_info="CPU",
|
||||
language_mode="detected",
|
||||
language_mode=LANGUAGE_DETECTED,
|
||||
transcription_date=datetime(2026, 1, 1, 0, 0, 0),
|
||||
)
|
||||
|
||||
@@ -113,12 +138,12 @@ def test_format_transcript_long():
|
||||
source_filename="long.mp4",
|
||||
model_name="large-v3",
|
||||
device_info="CUDA",
|
||||
language_mode="forced",
|
||||
language_mode=LANGUAGE_FORCED,
|
||||
transcription_date=datetime(2026, 3, 17, 10, 0, 0),
|
||||
)
|
||||
|
||||
assert "**Длительность**: 01:03:20" in content
|
||||
assert "**Язык**: en (forced)" in content
|
||||
assert "**Язык**: en (задан явно)" in content
|
||||
# Timestamps should use hours format
|
||||
assert "[00:00:00.00 - 00:00:10.50] Начало." in content
|
||||
assert "[01:01:40.00 - 01:01:50.25] Конец." in content
|
||||
@@ -188,7 +213,7 @@ def test_format_transcript_tail_gap_warning():
|
||||
source_filename="tail.mp3",
|
||||
model_name="medium",
|
||||
device_info="CPU",
|
||||
language_mode="forced",
|
||||
language_mode=LANGUAGE_FORCED,
|
||||
transcription_date=datetime(2026, 1, 1, 0, 0, 0),
|
||||
)
|
||||
|
||||
@@ -210,7 +235,7 @@ def test_format_transcript_no_tail_gap_warning_for_small_gap():
|
||||
source_filename="ok.mp3",
|
||||
model_name="medium",
|
||||
device_info="CPU",
|
||||
language_mode="forced",
|
||||
language_mode=LANGUAGE_FORCED,
|
||||
transcription_date=datetime(2026, 1, 1, 0, 0, 0),
|
||||
)
|
||||
|
||||
@@ -231,7 +256,7 @@ def test_format_transcript_no_tail_gap_warning_for_exact_threshold():
|
||||
source_filename="ok.mp3",
|
||||
model_name="medium",
|
||||
device_info="CPU",
|
||||
language_mode="forced",
|
||||
language_mode=LANGUAGE_FORCED,
|
||||
transcription_date=datetime(2026, 1, 1, 0, 0, 0),
|
||||
)
|
||||
|
||||
@@ -257,7 +282,7 @@ def test_format_transcript_repetition_warning():
|
||||
source_filename="repeat.mp3",
|
||||
model_name="medium",
|
||||
device_info="CPU",
|
||||
language_mode="forced",
|
||||
language_mode=LANGUAGE_FORCED,
|
||||
transcription_date=datetime(2026, 1, 1, 0, 0, 0),
|
||||
)
|
||||
|
||||
@@ -284,7 +309,7 @@ def test_format_transcript_repetition_warning_uses_hours():
|
||||
source_filename="long-repeat.mp3",
|
||||
model_name="medium",
|
||||
device_info="CPU",
|
||||
language_mode="forced",
|
||||
language_mode=LANGUAGE_FORCED,
|
||||
transcription_date=datetime(2026, 1, 1, 0, 0, 0),
|
||||
)
|
||||
|
||||
@@ -305,7 +330,7 @@ def test_format_transcript_without_anomalies_has_no_warning_lines():
|
||||
source_filename="ok.mp3",
|
||||
model_name="medium",
|
||||
device_info="CPU",
|
||||
language_mode="forced",
|
||||
language_mode=LANGUAGE_FORCED,
|
||||
transcription_date=datetime(2026, 1, 1, 0, 0, 0),
|
||||
)
|
||||
|
||||
|
||||
+96
-2
@@ -1,9 +1,11 @@
|
||||
"""Tests for onnx-asr backend."""
|
||||
|
||||
import warnings
|
||||
|
||||
import pytest
|
||||
|
||||
from local_transcriber.backends.onnx_asr import OnnxAsrBackend
|
||||
from local_transcriber.types import Segment, TranscribeResult
|
||||
from local_transcriber.types import UNKNOWN_LANGUAGE, Segment, TranscribeResult
|
||||
|
||||
|
||||
class FakeVadSegment:
|
||||
@@ -224,6 +226,69 @@ class TestCreateModel:
|
||||
|
||||
|
||||
class TestTranscribe:
|
||||
@pytest.mark.parametrize(
|
||||
("model_name", "language", "expects_warning"),
|
||||
[
|
||||
("gigaam-v3-e2e-rnnt", "en", True),
|
||||
("gigaam-v3-e2e-rnnt", "ru", False),
|
||||
("gigaam-multilingual-ctc", "en", False),
|
||||
],
|
||||
)
|
||||
def test_warns_when_language_is_not_supported(
|
||||
self, monkeypatch, tmp_path, model_name, language, expects_warning
|
||||
):
|
||||
wav_file = tmp_path / "test.wav"
|
||||
wav_file.write_bytes(b"fake audio")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"faster_whisper.decode_audio",
|
||||
lambda path, sampling_rate=16000: [0.0] * 16000,
|
||||
)
|
||||
|
||||
class FakeModel:
|
||||
def recognize(self, waveform, sample_rate, language=None):
|
||||
return iter(())
|
||||
|
||||
backend = OnnxAsrBackend()
|
||||
backend.ensure_model_available(model_name, "int8")
|
||||
|
||||
if expects_warning:
|
||||
with pytest.warns(
|
||||
UserWarning,
|
||||
match=(
|
||||
r"Язык 'en'.*--device openvino-cpu --model medium.*"
|
||||
r"--device cpu --model medium.*"
|
||||
r"--device cuda --model medium"
|
||||
),
|
||||
):
|
||||
backend.transcribe(FakeModel(), wav_file, language=language)
|
||||
else:
|
||||
with warnings.catch_warnings(record=True) as caught:
|
||||
backend.transcribe(FakeModel(), wav_file, language=language)
|
||||
assert caught == []
|
||||
|
||||
def test_auto_language_uses_single_supported_model_language(
|
||||
self, monkeypatch, tmp_path
|
||||
):
|
||||
wav_file = tmp_path / "test.wav"
|
||||
wav_file.write_bytes(b"fake audio")
|
||||
monkeypatch.setattr(
|
||||
"faster_whisper.decode_audio",
|
||||
lambda path, sampling_rate=16000: [0.0] * 16000,
|
||||
)
|
||||
|
||||
class FakeModel:
|
||||
def recognize(self, waveform, sample_rate, language=None):
|
||||
return iter(())
|
||||
|
||||
backend = OnnxAsrBackend()
|
||||
backend.ensure_model_available("gigaam-v3-e2e-rnnt", "int8")
|
||||
|
||||
result = backend.transcribe(FakeModel(), wav_file, language=None)
|
||||
|
||||
assert result.language == "ru"
|
||||
assert result.language_probability == 0.0
|
||||
|
||||
def test_transcribe_collects_segments(self, monkeypatch, tmp_path):
|
||||
"""Verify transcribe maps VAD segments to project Segments."""
|
||||
wav_file = tmp_path / "test.wav"
|
||||
@@ -326,7 +391,7 @@ class TestTranscribe:
|
||||
result = backend.transcribe(FakeModel(), wav_file, language=None)
|
||||
|
||||
assert len(result.segments) == 0
|
||||
assert result.language == "unknown"
|
||||
assert result.language == UNKNOWN_LANGUAGE
|
||||
assert result.duration == 1.0
|
||||
|
||||
def test_transcribe_skips_zero_length_vad_segments(self, monkeypatch, tmp_path):
|
||||
@@ -386,3 +451,32 @@ class TestModelAliases:
|
||||
backend = OnnxAsrBackend()
|
||||
with pytest.raises(ValueError, match="Неподдерживаемая модель"):
|
||||
backend._resolve_model("nonexistent-model")
|
||||
|
||||
def test_whisper_alias_error_suggests_explicit_backend(self):
|
||||
backend = OnnxAsrBackend()
|
||||
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match=r"Whisper.*--device openvino-cpu.*--device cuda",
|
||||
):
|
||||
backend._resolve_model("medium")
|
||||
|
||||
def test_whisper_error_offers_platform_independent_backend(self):
|
||||
"""На macOS и ARM нет ни OpenVINO, ни CUDA — нужен путь через cpu."""
|
||||
backend = OnnxAsrBackend()
|
||||
|
||||
with pytest.raises(ValueError, match=r"--device cpu --model medium"):
|
||||
backend._resolve_model("medium")
|
||||
|
||||
def test_turbo_whisper_error_suggests_models_supported_by_backends(self):
|
||||
backend = OnnxAsrBackend()
|
||||
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
backend._resolve_model("large-v3-turbo")
|
||||
|
||||
message = str(exc_info.value)
|
||||
assert "--device openvino-cpu --model large-v3-turbo" in message
|
||||
assert "--device cuda --model medium" in message
|
||||
assert "--device cpu --model medium" in message
|
||||
assert "--device cuda --model large-v3-turbo" not in message
|
||||
assert "--device cpu --model large-v3-turbo" not in message
|
||||
|
||||
+9
-32
@@ -61,46 +61,23 @@ def test_detect_device_explicit_passthrough():
|
||||
"""Явные device strings проходят без изменений."""
|
||||
assert detect_device("cpu") == "cpu"
|
||||
assert detect_device("cuda") == "cuda"
|
||||
assert detect_device("onnx") == "onnx"
|
||||
assert detect_device("openvino-gpu") == "openvino-gpu"
|
||||
assert detect_device("openvino-cpu") == "openvino-cpu"
|
||||
|
||||
|
||||
def test_detect_device_auto_openvino_gpu():
|
||||
"""auto + нет nvidia-smi + есть OpenVINO GPU → openvino-gpu."""
|
||||
with (
|
||||
patch("local_transcriber.utils.shutil.which", return_value=None),
|
||||
patch("local_transcriber.utils._is_openvino_gpu_available", return_value=True),
|
||||
):
|
||||
assert detect_device("auto") == "openvino-gpu"
|
||||
|
||||
|
||||
def test_detect_device_auto_openvino_cpu():
|
||||
"""auto + нет nvidia-smi + есть OpenVINO, нет GPU → openvino-cpu."""
|
||||
with (
|
||||
patch("local_transcriber.utils.shutil.which", return_value=None),
|
||||
patch("local_transcriber.utils._is_openvino_gpu_available", return_value=False),
|
||||
patch("local_transcriber.utils._is_openvino_available", return_value=True),
|
||||
):
|
||||
assert detect_device("auto") == "openvino-cpu"
|
||||
|
||||
|
||||
def test_detect_device_cuda_over_openvino():
|
||||
"""nvidia-smi доступен и openvino тоже → cuda побеждает."""
|
||||
with (
|
||||
patch("local_transcriber.utils.shutil.which", return_value="/usr/bin/nvidia-smi"),
|
||||
patch("local_transcriber.utils._is_openvino_gpu_available", return_value=True),
|
||||
def test_detect_device_auto_cuda_when_nvidia_smi_available():
|
||||
"""При доступном nvidia-smi auto выбирает CUDA."""
|
||||
with patch(
|
||||
"local_transcriber.utils.shutil.which", return_value="/usr/bin/nvidia-smi"
|
||||
):
|
||||
assert detect_device("auto") == "cuda"
|
||||
|
||||
|
||||
def test_detect_device_auto_cpu_fallback():
|
||||
"""Ни nvidia-smi, ни openvino → cpu."""
|
||||
with (
|
||||
patch("local_transcriber.utils.shutil.which", return_value=None),
|
||||
patch("local_transcriber.utils._is_openvino_gpu_available", return_value=False),
|
||||
patch("local_transcriber.utils._is_openvino_available", return_value=False),
|
||||
):
|
||||
assert detect_device("auto") == "cpu"
|
||||
def test_detect_device_auto_onnx_without_cuda():
|
||||
"""Без CUDA auto выбирает ONNX CPU."""
|
||||
with patch("local_transcriber.utils.shutil.which", return_value=None):
|
||||
assert detect_device("auto") == "onnx"
|
||||
|
||||
|
||||
def test_detect_device_openvino_resolves_to_gpu():
|
||||
|
||||
@@ -79,7 +79,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "ctranslate2"
|
||||
version = "4.7.1"
|
||||
version = "4.8.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "numpy" },
|
||||
@@ -87,21 +87,21 @@ dependencies = [
|
||||
{ name = "setuptools" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/34/6d/eb49ba05db286b4ea9d5d3fcf5f5cd0a9a5e218d46349618d5041001e303/ctranslate2-4.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6b2abf2929756e3ec6246057b56df379995661560a2d776af05f9d97f63afcf5", size = 1256960, upload-time = "2026-02-04T06:11:47.487Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/5a/b9cce7b00d89fc6fdeaf27587aa52d0597b465058563e93ff50910553bdd/ctranslate2-4.7.1-cp313-cp313-macosx_11_0_x86_64.whl", hash = "sha256:857ef3959d6b1c40dc227c715a36db33db2d097164996d6c75b6db8e30828f52", size = 11918645, upload-time = "2026-02-04T06:11:49.599Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/03/c0db0a5276599fb44ceafa2f2cb1afd5628808ec406fe036060a39693680/ctranslate2-4.7.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:393a9e7e989034660526a2c0e8bb65d1924f43d9a5c77d336494a353d16ba2a4", size = 16860452, upload-time = "2026-02-04T06:11:52.276Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/03/4e3728ce29d192ee75ed9a2d8589bf4f19edafe5bed3845187de51b179a3/ctranslate2-4.7.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a3d0682f2b9082e31c73d75b45f16cde77355ab76d7e8356a24c3cb2480a6d3", size = 38995174, upload-time = "2026-02-04T06:11:55.477Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/15/6e8e87c6a201d69803a79ac2e29623ce7c2cc9cd1df9db99810cca714373/ctranslate2-4.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:baa6d2b10f57933d8c11791e8522659217918722d07bbef2389a443801125fe7", size = 18844953, upload-time = "2026-02-04T06:11:58.519Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/73/8a6b7ba18cad0c8667ee221ddab8c361cb70926440e5b8dd0e81924c28ac/ctranslate2-4.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d5dfb076566551f4959dfd0706f94c923c1931def9b7bb249a2caa6ab23353a0", size = 1257560, upload-time = "2026-02-04T06:12:00.926Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/c2/8817ca5d6c1b175b23a12f7c8b91484652f8718a76353317e5919b038733/ctranslate2-4.7.1-cp314-cp314-macosx_11_0_x86_64.whl", hash = "sha256:eecdb4ed934b384f16e8c01b185b082d6b5ffc7dcbb0b6a6eb48cd465282d957", size = 11918995, upload-time = "2026-02-04T06:12:02.875Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/33/b8eb3acc67bbca4d9872fc9ff94db78e6167a7ba5cd932f585d1560effc7/ctranslate2-4.7.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1aa6796edcc3c8d163c9e39c429d50076d266d68980fed9d1b2443f617c67e9e", size = 16844162, upload-time = "2026-02-04T06:12:05.099Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/11/6474893b07121057035069a0a483fe1cd8c47878213f282afb4c0c6fc275/ctranslate2-4.7.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:24c0482c51726430fb83724451921c0e539d769c8618dcfd46b1645e7f75960d", size = 38966728, upload-time = "2026-02-04T06:12:07.923Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/88/8fc7ff435c5e783e5fad9586d839d463e023988dbbbad949d442092d01f1/ctranslate2-4.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:76db234c0446a23d20dd8eeaa7a789cc87d1d05283f48bf3152bae9fa0a69844", size = 19100788, upload-time = "2026-02-04T06:12:10.592Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/b3/f100013a76a98d64e67c721bd4559ea4eeb54be3e4ac45f4d801769899af/ctranslate2-4.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:058c9db2277dc8b19ecc86c7937628f69022f341844b9081d2ab642965d88fc6", size = 1280179, upload-time = "2026-02-04T06:12:12.596Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/22/b77f748015667a5e2ca54a5ee080d7016fce34314f0e8cf904784549305a/ctranslate2-4.7.1-cp314-cp314t-macosx_11_0_x86_64.whl", hash = "sha256:5abcf885062c7f28a3f9a46be8d185795e8706ac6230ad086cae0bc82917df31", size = 11940166, upload-time = "2026-02-04T06:12:14.054Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/78/6d7fd52f646c6ba3343f71277a9bbef33734632949d1651231948b0f0359/ctranslate2-4.7.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9950acb04a002d5c60ae90a1ddceead1a803af1f00cadd9b1a1dc76e1f017481", size = 16849483, upload-time = "2026-02-04T06:12:17.082Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/27/58769ff15ac31b44205bd7a8aeca80cf7357c657ea5df1b94ce0f5c83771/ctranslate2-4.7.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1dcc734e92e3f1ceeaa0c42bbfd009352857be179ecd4a7ed6cccc086a202f58", size = 38949393, upload-time = "2026-02-04T06:12:21.302Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/5c/9fa0ad6462b62efd0fb5ac1100eee47bc96ecc198ff4e237c731e5473616/ctranslate2-4.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:dfb7657bdb7b8211c8f9ecb6f3b70bc0db0e0384d01a8b1808cb66fe7199df59", size = 19123451, upload-time = "2026-02-04T06:12:24.115Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/a7/3101c3a0785253a8ef386f39744ad19c28c75b7f227e7c232aee7a5c416a/ctranslate2-4.8.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ba628835e6ad4ad399261ab6cb51bf152de563e6b122a9e8eb0c61e69f925931", size = 1270478, upload-time = "2026-07-03T12:39:25.401Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/b9/e50c7558e96a054d6b1e6a6c5e729dda4a4f05584e065f2902aa5f1bc4c8/ctranslate2-4.8.1-cp313-cp313-macosx_11_0_x86_64.whl", hash = "sha256:85ef15ce0b2172ec471975b8a30d5c5bc71e7cffcd163ad6c07ea32f1943d940", size = 11930241, upload-time = "2026-07-03T12:39:26.927Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/2f/ea7a19c6d7e949b731fb034664633184bbfc7882846d107f4d790693fb76/ctranslate2-4.8.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0030670278a73cae09dff9bca72cdd248af61f9367257f18db9b3b94fbb3a50d", size = 16883512, upload-time = "2026-07-03T12:39:29.302Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/4a/21f325a9d0925d8ad24b04249adf29bf9909442967603634f7f6d4acbb79/ctranslate2-4.8.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4242a7f8e285f922525f4cffd5b1fb43cbacc61d0611cf54832e9c447d030840", size = 39529085, upload-time = "2026-07-03T12:39:32.627Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/e7/37da1a7500b57496a5269318c4f57962ea0c26dcac06b85222d7831acf00/ctranslate2-4.8.1-cp313-cp313-win_amd64.whl", hash = "sha256:d52499f05a60a791aeadee28d609efa130142f376d1ea76b2b1c593bb01f8827", size = 19220784, upload-time = "2026-07-03T12:39:35.74Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/66/39111224e418400d97fd79fbc9e72329c51f91a3e7a9c9a1a182e4f88022/ctranslate2-4.8.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b4c3246aa4a7f309109a841ca743a72cc4abad4f93c0bf7da691023323215621", size = 1271321, upload-time = "2026-07-03T12:39:37.907Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/89/13f827fae226eea51315729c00111f716813d7736ebb827fecb8f361fe0d/ctranslate2-4.8.1-cp314-cp314-macosx_11_0_x86_64.whl", hash = "sha256:c989f747789e8619cbc2e06443b3674c31bc71bad0369652485bd894b627360a", size = 11930735, upload-time = "2026-07-03T12:39:39.534Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/94/4b73f9bbaba29df4227cc65114f11d83fe6d696ef3705cb1ade79eb118fd/ctranslate2-4.8.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c90eb0bd67b6bb183712cc3fd14bf01ec4f622cd625c5b33cc6c56be7d1c9c34", size = 16872460, upload-time = "2026-07-03T12:39:42.272Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/d0/9816494d5ff0745bdf9abe5af04e57a103a416444e604cbe83a6eb0aed7b/ctranslate2-4.8.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e3e3aef4670a6c8dcea367401675f82b49b02c18f5837221bcd7cca90b1707a8", size = 39494736, upload-time = "2026-07-03T12:39:45.733Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/dc/22a2c874ca8bb6caa7018dfefdff92dddd487db31cf169891c4c6d408091/ctranslate2-4.8.1-cp314-cp314-win_amd64.whl", hash = "sha256:a2dcce0a57beee984a691d9daa8fc3fd389f5b6cada2644c34571011833bd5b1", size = 19477164, upload-time = "2026-07-03T12:39:48.952Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/39/7b8d47bf49748ba73182742683eef74b46608beb879765d9d4efc46bc345/ctranslate2-4.8.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a28c5889585cd17ee3649dfd46d9002ddf50204173f8bff476b9f76d6585795", size = 1293935, upload-time = "2026-07-03T12:39:50.924Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/20/434e30c752c433eaef5deccd4de54775bc1f205a6fe6c9e756b737018209/ctranslate2-4.8.1-cp314-cp314t-macosx_11_0_x86_64.whl", hash = "sha256:911a5cdef8a405c1804330613a1865f616eb9c092a0e932ee4648128eb20b627", size = 11951789, upload-time = "2026-07-03T12:39:52.886Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/f2/d716426220b462bbb5bb354b9c6c8d9a41285f067203c860cc79f9f19917/ctranslate2-4.8.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:84723cae6f802551bbf2438e5e4810722631a2183b89a82c31df26566b54821d", size = 16860414, upload-time = "2026-07-03T12:39:55.54Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/11/cdab0e7e2ad4e547f15ab227c09207569f1272abae05816900ecebb0797a/ctranslate2-4.8.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1910752ec541980644191fa3b407bc61dee00e88070b0aed29b4cef75010b3ea", size = 39465200, upload-time = "2026-07-03T12:39:59.017Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/03/126e963fc3237a416f3085b8a663ebd8ab449ed6c37195b4e0b49597ba0c/ctranslate2-4.8.1-cp314-cp314t-win_amd64.whl", hash = "sha256:dc9f1abef55579cc02cdc74b3a55df38491ec56d177d6e6039609d61d09ed30e", size = 19499597, upload-time = "2026-07-03T12:40:01.68Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -279,7 +279,7 @@ requires-dist = [
|
||||
{ name = "nvidia-cublas-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'", specifier = ">=12.4,<13" },
|
||||
{ name = "onnx-asr", extras = ["cpu", "hub"], specifier = ">=0.12,<0.13" },
|
||||
{ name = "onnxruntime", specifier = ">=1.28,<2" },
|
||||
{ name = "openvino-genai", marker = "(platform_machine == 'AMD64' and sys_platform != 'darwin') or (platform_machine == 'x86_64' and sys_platform != 'darwin')", specifier = ">=2026.0.0.0,<2026.1" },
|
||||
{ name = "openvino-genai", marker = "(platform_machine == 'AMD64' and sys_platform != 'darwin') or (platform_machine == 'x86_64' and sys_platform != 'darwin')", specifier = ">=2026.3.0.0,<2026.4" },
|
||||
{ name = "rich", specifier = ">=14.3.3,<15" },
|
||||
{ name = "socksio", specifier = ">=1.0.0,<2" },
|
||||
{ name = "typer", specifier = ">=0.24.1,<1" },
|
||||
@@ -416,35 +416,35 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "openvino"
|
||||
version = "2026.0.0"
|
||||
version = "2026.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "numpy" },
|
||||
{ name = "openvino-telemetry" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/71/3f/95b15760d7ae4b7dfefdbadeccae9604985d4335b221350e1bb04701a158/openvino-2026.0.0-20965-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:452902e4b8fba526e8740622cd5638c457d3ae44da7b39e6d4ef7919be0e95d4", size = 53444542, upload-time = "2026-02-23T16:10:46.343Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/26/82d326f3769c9e5c17d34ef10dd0aff4a94a3b550e1d2efe977e5754b84e/openvino-2026.0.0-20965-cp313-cp313-win_amd64.whl", hash = "sha256:14069e5af2ac8a77f6ea55d7020d34cc7d3538d49ebda91a18c00d4da830ab58", size = 69160606, upload-time = "2026-02-23T16:10:52.9Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/8e/52df01e8687f4711259729433226219c6839a0495988ddeb7e27af59aba8/openvino-2026.0.0-20965-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:79ce4b5d7f3a7f84de878752d97620b84e0c4a8ee550fb3bca9fc36fe5669450", size = 53449059, upload-time = "2026-02-23T16:10:59.54Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/a8/bc8e75d3f75ee2529a12be9522f52a8d0e5614f24c00f95a20b69f587d3c/openvino-2026.0.0-20965-cp314-cp314-win_amd64.whl", hash = "sha256:6d33440d290e67836825418134ecf9e17f150d50d3d9c07cf481997f5b1f0e6d", size = 69165824, upload-time = "2026-02-23T16:11:03.127Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/06/1a2caa07bfcb4e057048b8d5515a7aff35a2aa53ab5379762c1685cbeacc/openvino-2026.0.0-20965-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:8cbe36e0dacd8676eb69c9a4b564fa430d784f6e2b0e18e7ebc363599256c184", size = 53502042, upload-time = "2026-02-23T16:11:09.744Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/dd/6a05a399d90ad4e8b15e0d5a4dd7de90c10484cb1585bd65e7e69e779762/openvino-2026.0.0-20965-cp314-cp314t-win_amd64.whl", hash = "sha256:a9e5524322c42f6a1283148fb70085aba8640a7d3067ede896085abbff5e33fe", size = 69325734, upload-time = "2026-02-23T16:11:13.283Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/d3/69e7083339f32621359f0bce2be3a7454db3c9a71fa7492f0a0941c00037/openvino-2026.3.0-22451-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:b7d09f20f009b9167a863f615a2b27400ce025bda8086cc72a2eb6ac3bf1d2af", size = 57480757, upload-time = "2026-08-04T09:06:44.336Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/f8/f71508784562a3d427f52f59d74a6364d559e6e82bb112cab5d6a6a677bb/openvino-2026.3.0-22451-cp313-cp313-win_amd64.whl", hash = "sha256:1c41f2d1072d600c260741b350aaf4a09d53f821a9a8fc8989bdc79a46b50a2c", size = 75797507, upload-time = "2026-08-04T09:06:51.858Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/97/fdace942843da232ea06a5a67cc3a70d292873657dc933408fdb9bb796a8/openvino-2026.3.0-22451-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:81a64aebd1a80da93bc9413b3ba9c93846c7cac57161cd4d7b287b354d77ad19", size = 57482441, upload-time = "2026-08-04T09:06:59.277Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/cb/ed637225c7373d9a4267e7fa7252c1f3767fbc9853a906d78068a279b73a/openvino-2026.3.0-22451-cp314-cp314-win_amd64.whl", hash = "sha256:07a8c1cb32e3f7942aab4a0c48404b591e63c89813906c7bc5b001f94bed447c", size = 75798958, upload-time = "2026-08-04T09:07:07.28Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/3c/40c0bbd5c57fc0b5c0c41f9e6f0ec722f231ff25c37d20240d2baf446409/openvino-2026.3.0-22451-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:65f34103c28107e830e1fb70cb9c62afdb31cfde4f8b6056c942761796b1d4fe", size = 57526976, upload-time = "2026-08-04T09:07:15.131Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/ca/927354fa957dd0e8c8f53401dcd1239c4cd50d95a26ff5da3bc4b502f4dc/openvino-2026.3.0-22451-cp314-cp314t-win_amd64.whl", hash = "sha256:17581c5acbdaf9bf503cd21d5ad852df7e547073ad7a529661b19f40ab3c8db6", size = 75963071, upload-time = "2026-08-04T09:07:24.796Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openvino-genai"
|
||||
version = "2026.0.0.0"
|
||||
version = "2026.3.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "openvino-tokenizers" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/98/fe5716b9dc2f29286b68b98a4c67bc33003e3b02cf4d92236345a2d7280e/openvino_genai-2026.0.0.0-2050-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:08f73852a455d63453e7f74f22b0aebd0c3579f8cb89133e66e438c09df26b71", size = 4930444, upload-time = "2026-02-23T16:17:33.953Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/80/bc87545dfbbd7f4a3bd1667a4c9b5f1d8e1b476fec34225692bca6a86590/openvino_genai-2026.0.0.0-2050-cp313-cp313-win_amd64.whl", hash = "sha256:ada030b34a9e2fe0c6c406c2af2ce7b6e8cb16654329a469055fdac8a3eba19d", size = 2900293, upload-time = "2026-02-23T16:17:37.673Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/09/18e678f2d0ac4ee993244d3cf0e6a80ec7013add2972153ad355d889d8cc/openvino_genai-2026.0.0.0-2050-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:77b73d1947d7f62a672857ef35d187074c6918de1db1d3f981452c2d67511c13", size = 4931196, upload-time = "2026-02-23T16:17:40.423Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/be/3f02a70a16147b08b933908ef1c8af930ecd5c65ba78e14f988112d3d691/openvino_genai-2026.0.0.0-2050-cp314-cp314-win_amd64.whl", hash = "sha256:c59aeab5993c78194dc552cc9249bab509f7e44b1fb2f0d5a90c5e61869f06b8", size = 2900527, upload-time = "2026-02-23T16:17:43.244Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/e8/7da6a1b86e3178e825560442ea4460ba4c92bb417fb8edda9e2210febec6/openvino_genai-2026.0.0.0-2050-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:d807c5cabdea20bab2199ffa736831f57baaf0571e80d19cf4b65004f6b00747", size = 4943971, upload-time = "2026-02-23T16:17:46.021Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/19/221256ce3b0276bb3ba3ce7e8fc57b2968a662c2acde7427c342d917867f/openvino_genai-2026.0.0.0-2050-cp314-cp314t-win_amd64.whl", hash = "sha256:bb537f5a65203eee12326e6bb9578e834d96d94ab3fa48c806990ef04a455935", size = 2900592, upload-time = "2026-02-23T16:17:47.452Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/94/6968a1b95f6b9931741ef1a302c2e7ab2d3193f76224fead0ed736506db3/openvino_genai-2026.3.0.0-2495-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:cd9f7bc035d2a23f01617e64f8963477335f15e048c6c9fcf1cce6c925fedf3e", size = 6211947, upload-time = "2026-08-04T09:34:59.252Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/74/cec114195adf6237c000fabd6c5da61f04edf3b1d719db7601e452977e4f/openvino_genai-2026.3.0.0-2495-cp313-cp313-win_amd64.whl", hash = "sha256:08411eb51bd0bddc717e2d97c541cc5cc946bde70e74da6dd7428149e5e0cdbb", size = 3669271, upload-time = "2026-08-04T09:35:02.699Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/02/ed9f6773a40cc8b0fc166979abf6b56aed3a9f5840f9f0bf76fbf82cc349/openvino_genai-2026.3.0.0-2495-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:2df610ec970b66b95cc4987d888c543e979bda515aed0c8ffda99954d10b4133", size = 6213490, upload-time = "2026-08-04T09:35:05.799Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/9d/9e877a92ff22e9cec0cb930bcccd58013050726af91a9c1b8ff15264ed27/openvino_genai-2026.3.0.0-2495-cp314-cp314-win_amd64.whl", hash = "sha256:9331df790dcf57d796b6486ce9d9219ad4172c851cd657fafb9c5c0c82177c43", size = 3670597, upload-time = "2026-08-04T09:35:09.064Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/98/a4747cc685a5d2cc252ccb337c022e455f5761041e0d19980d0884ecadd9/openvino_genai-2026.3.0.0-2495-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:58a9e7493391ce8478440fbc14610968255a06a2717068f476b1ba519fe488a1", size = 6222203, upload-time = "2026-08-04T09:35:11.83Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/27/af5aee755635dc1cd19bb7406410a099364205c3a35e49766e54ca174c15/openvino_genai-2026.3.0.0-2495-cp314-cp314t-win_amd64.whl", hash = "sha256:0d3770d721f336549747b3ce054ad6bc726609e1fc3f5ce90c4954c63f23e4b9", size = 3670880, upload-time = "2026-08-04T09:35:14.895Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -458,14 +458,14 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "openvino-tokenizers"
|
||||
version = "2026.0.0.0"
|
||||
version = "2026.3.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "openvino" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/45/22/2b976c129d43b214034a16a999d54eb659d5b2f15c12b2be52ab44e3235d/openvino_tokenizers-2026.0.0.0-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:84ee496d9fe4ff7866ed9a1370cb71bf3af9526601a701c59d269854a2b0dfc7", size = 13693316, upload-time = "2026-02-23T16:12:13.41Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/86/4e7428a2fb350311a7f616c897ee1427d70cd814a2d64b2af9efb81fb881/openvino_tokenizers-2026.0.0.0-py3-none-win_amd64.whl", hash = "sha256:90f77c203c40623733e867754b952e8f0b86d9c86782abdbe9cf4bf2c82ce693", size = 13988492, upload-time = "2026-02-23T16:12:19.586Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/68/c1ba2177f4ce1f455aae858548aece8b6491b862a6458b03c5d5dbf356ef/openvino_tokenizers-2026.3.0.0-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:9d35bb52d353a30d1194cd21c43d3949d0fac853b5bd3721994f70acfea051e4", size = 1829895, upload-time = "2026-08-04T09:28:12.083Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/65/707874d377a245fbc5fbeaeac77e4aaf878f4e0938ab9d1203f8b01e5aab/openvino_tokenizers-2026.3.0.0-py3-none-win_amd64.whl", hash = "sha256:7c6fb2f1e9b6c3c4b2bdb992e69ef97869787d8486b193d155d24aaa9cd5fed0", size = 1541664, upload-time = "2026-08-04T09:28:15.307Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Reference in New Issue
Block a user