chore(diarization): добавлена обвязка замеров в .scratch
- Зачем:
- тикеты карты #10-#13 опираются на измерительную обвязку, которая до сих пор
жила во временном каталоге сессии и исчезла бы вместе с ним.
- Что:
- перенесены четыре скрипта разведки: ASR, один прогон диаризации, свип порога
кластеризации и подсчёт чистоты ASR-сегментов.
- общая часть вынесена в common.py: пути от корня репозитория вместо
захардкоженных, конфигурация диаризатора, проверка наличия моделей.
- починен замер пиковой памяти: нужен экспорт K32GetProcessMemoryInfo из
kernel32 и явные argtypes, иначе дескриптор процесса уезжает 32-битным.
- модели и выход замеров исключены из истории локальным .gitignore.
- Проверка:
- export PYTHONIOENCODING=utf-8
- uv run --with sherpa-onnx python .scratch/diarization/bench_diar.py "<запись>" 8 0.9
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
87030e9718
commit
1cb6a36c92
@@ -0,0 +1,6 @@
|
||||
# модели диаризации — 33 МБ, скачиваются по README
|
||||
models/
|
||||
|
||||
# выход замеров
|
||||
segments-*.tsv
|
||||
conflict-*.json
|
||||
@@ -0,0 +1,63 @@
|
||||
# Обвязка замеров диаризации
|
||||
|
||||
Исследовательские скрипты для карты
|
||||
[Карта: диаризация спикеров в транскрипте](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.9
|
||||
uv run --with sherpa-onnx python .scratch/diarization/bench_sweep.py "<путь>"
|
||||
uv run --with sherpa-onnx python .scratch/diarization/bench_conflict.py "<путь>"
|
||||
```
|
||||
|
||||
## Что стоит знать до запуска
|
||||
|
||||
- **Порог кластеризации не откалиброван.** По умолчанию стоит 0,9 — значение из
|
||||
разведки, подобранное на одной записи и на ней же проверенное. На пороге 0,5
|
||||
из примеров sherpa-onnx получалось 29 говорящих вместо трёх. Калибровка — это
|
||||
тикет #10, до его закрытия любое значение считается временным.
|
||||
- **Свип дорогой.** Каждая конфигурация — полный прогон сегментации и
|
||||
эмбеддингов, около 2,5 минут на 26-минутную запись, и время от настроек
|
||||
кластеризации практически не зависит. Свип вести на коротком фрагменте.
|
||||
- **Чистота сегментов меряется относительно диаризации.** Если её границы
|
||||
систематически смещены, метрика измеряет не то, что кажется. Проверка границ
|
||||
на слух — тикет #12, и он намеренно идёт до калибровки.
|
||||
- **Замер памяти чинился.** В разведке `psapi.GetProcessMemoryInfo` молча
|
||||
возвращал ноль; `common.peak_rss_mb()` теперь зовёт `K32GetProcessMemoryInfo`
|
||||
из kernel32 и проверяет код возврата.
|
||||
@@ -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,150 @@
|
||||
"""Чистота 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,133 @@
|
||||
"""Общая обвязка для замеров диаризации.
|
||||
|
||||
Скрипты в этом каталоге — исследовательские, не часть пакета. Они опираются на
|
||||
``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-12. Порог 0.9 дал верное число говорящих на
|
||||
# контрольной записи; на 0.5 из примеров sherpa-onnx получалось 29 вместо трёх.
|
||||
DISCOVERY_THRESHOLD = 0.9
|
||||
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, если снять не удалось.
|
||||
|
||||
Два подвоха, на которых замер в разведке 2026-08-12 вернул ноль:
|
||||
экспорт на современных Windows живёт в kernel32 как
|
||||
``K32GetProcessMemoryInfo``, а без явных ``restype``/``argtypes``
|
||||
псевдодескриптор процесса уезжает в вызов как 32-битное число и функция
|
||||
молча не срабатывает.
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user