Author SHA1 Message Date
Dmitriy Dementiev 352296b84a docs(prototype): добавлен макет транскрипта со спикерами
- Зачем:
  - требовалось выбрать компактный Markdown-формат реплик до реализации диаризации.
- Что:
  - добавлены три переключаемых варианта на реальном обезличенном фрагменте.
  - выбранный линейный вариант обновлён до формата `[MM:SS] Speaker N: текст`.
- Проверка:
  - файл открыт локально, варианты A–C переключаются стрелками и параметром `variant`.
2026-08-14 14:38:42 +03:00
29 changed files with 582 additions and 3069 deletions
+326
View File
@@ -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>
-8
View File
@@ -28,14 +28,6 @@ _Avoid_: Реплика, фраза говорящего
Распознанное слово, положение которого известно на временной шкале записи. Минимальная единица, которой назначается говорящий.
_Avoid_: Токен, ASR-сегмент
**Разметка говорящих**:
Упорядоченный набор временных интервалов речи, каждому из которых назначена анонимная метка говорящего. Не содержит распознанного текста, имени участника или голосового эмбеддинга.
_Avoid_: Результат диаризации, сегменты говорящих
**Голосовой кластер**:
Анонимная группа интервалов разметки говорящих, которые диаризатор относит к одному голосу. Не обязательно соответствует реальному участнику встречи: диаризация может создать ложный или малый кластер.
_Avoid_: Участник, человек
**Реплика говорящего**:
Последовательность соседних слов с временной привязкой, назначенных одному говорящему. Это единица структуры готового транскрипта, а не запуска модели распознавания.
_Avoid_: Сегмент распознавания, ASR-сегмент
-44
View File
@@ -10,7 +10,6 @@ transcribe meeting.mp4
- **Полностью локально** — данные не покидают машину
- **Авто-ускорение** — NVIDIA CUDA при наличии GPU, иначе ONNX на CPU
- **Батч-режим** — обработка нескольких файлов за один вызов
- **Разделение говорящих** — локальная диаризация по флагу `--diarize`
- **Из проводника Windows** — пункт Transcribe в меню «Отправить» ([установка](#контекстное-меню-проводника-windows))
- **Markdown с таймкодами** — удобен для суммаризации ИИ
- **Аудио и видео** — mp3, wav, mp4, mkv и [другие форматы](#поддерживаемые-форматы)
@@ -136,36 +135,8 @@ transcribe meeting.wav --device onnx --model gigaam-multilingual-large-ctc
# Сохранить в конкретный файл
transcribe interview.m4a --output result.md
# Разделить встречу на реплики говорящих
transcribe meeting.mp4 --diarize
# Если число участников известно заранее
transcribe interview.m4a --speakers 2
```
### Разделение говорящих
`--diarize` добавляет к транскрипту реплики `Speaker 1`, `Speaker 2` и так
далее. `--speakers N` задаёт ожидаемое число участников и автоматически включает
диаризацию; без него число кластеров определяется автоматически.
При первом таком запуске дополнительно скачиваются две ONNX-модели Sherpa-ONNX:
сегментация (~6 МБ) и голосовые эмбеддинги (~27 МБ). Они сохраняются в кеше
Hugging Face и используются повторно. Диаризация выполняется после распознавания
речи и добавляет отдельный проход по записи. На измеренном слабом Intel Core
i7-6820HQ последовательные ASR и диаризация увеличивали полное время примерно в
2,4 раза, но оставались быстрее реального времени; фактическая скорость зависит
от процессора и режима питания ([замеры](docs/benchmarks/2026-08-14-diarization-intel-i7.md)).
Если найдено меньше двух говорящих или диаризация конкретного файла завершилась
ошибкой, текст не теряется: сохраняется обычный транскрипт, в Markdown
записывается причина, а команда завершается с кодом `1`. Если выбранный ASR-путь
не поддерживает пословные таймкоды или диаризатор не удалось инициализировать,
запуск останавливается до первого ASR и не создаёт частичных транскриптов. Малый
кластер только отмечается предупреждением и не удаляется. Слова без однозначного
говорящего попадают в реплику `Speaker ?`.
### Батч-режим
Обработка нескольких файлов за один вызов — модель загружается один раз:
@@ -184,8 +155,6 @@ transcribe *.mp4 --force
- Файлы с существующим транскриптом (`*-transcript.md`) автоматически пропускаются
- `--force` / `-f` — перезаписать существующие транскрипты
- При ошибке в одном файле остальные продолжают обрабатываться
- При ошибке диаризации сохраняется обычный транскрипт, остальные файлы
продолжают обрабатываться; итоговый код батча — `1`
- `--output` несовместим с несколькими файлами
### Контекстное меню проводника (Windows)
@@ -223,8 +192,6 @@ transcribe --uninstall-menu
| `--device` | `-d` | `auto` | Устройство (auto, cpu, cuda, openvino, openvino-gpu, openvino-cpu, onnx) |
| `--compute-type` | — | float16 (CUDA) / int8 (ONNX/OpenVINO) / float32 (CPU) | Тип вычислений |
| `--threads` | `-t` | 0 (авто) | Потоки CPU (рекомендуется = число физ. ядер) |
| `--diarize` | — | — | Разделить текст на реплики говорящих |
| `--speakers` | — | авто | Ожидаемое число говорящих; включает `--diarize` |
| `--force` | `-f` | — | Перезаписать существующие транскрипты |
| `--verbose` | `-v` | — | Подробный вывод |
@@ -437,17 +404,6 @@ device-aware дефолт недоступен для выбранной мод
Если язык определить не удалось, строка выглядит так: `- **Язык**: не определён`.
С `--diarize` при успешном обнаружении нескольких говорящих основная часть
выглядит так:
```markdown
[00:00] Speaker 1: Добрый день, коллеги.
[00:04] Speaker 2: Начнём с результатов квартала.
```
Таймкод реплики показывает начало: `MM:SS`, а после часа — `HH:MM:SS`.
</details>
## Поддерживаемые форматы
@@ -1,133 +0,0 @@
# ADR-007: Пословная диаризация через sherpa-onnx
**Статус**: Принято
**Дата**: 2026-08-14
## Контекст
Разделение говорящих — главный структурный разрыв между локальным транскриптом
и облачными сервисами в сценарии подготовки конспектов и протоколов встреч.
Диаризация при этом не является ещё одним движком распознавания: она независимо
строит [разметку говорящих](../../CONTEXT.md#language), которую затем нужно
свести с результатом ASR.
Привязка одного говорящего ко всему сегменту распознавания оказалась слишком
грубой. На трёх русскоязычных рабочих созвонах чужая реплика не короче секунды
встретилась в 6–7% сегментов разговоров на двоих и в 27% сегментов встречи
втроём. Сегменты распознавания проходят по тишине, а не по смене говорящего,
поэтому сохранить контекст RNN-T и получить реплики можно только через более
мелкую единицу сведения.
## Эксперимент
Локальная связка `sherpa-onnx` с сегментацией Pyannote 3.0 и эмбеддингами
WeSpeaker ResNet34 LM проверена на трёх записях с известным составом. Для
автоматического определения числа голосовых кластеров выбран порог 0,89: это
единственное проверенное значение, которое на трёх контрольных фрагментах дало
3 / 2 / 2 кластера. На полной встрече втроём остался ложный кластер длительностью
19,1 секунды; поэтому малые кластеры нельзя молча отбрасывать, а разметку нельзя
считать эталоном точных границ и перекрывающейся речи.
Двуязычная CAMPPlus zh/en оказалась примерно на 30% быстрее и при известном
числе участников улучшила прокси-метрику на двух записях, но для неё не нашлось
общего автоматического порога без лишних кластеров или склейки реальных голосов.
Поэтому она остаётся кандидатом только для будущего режима с обязательным
явным числом участников, а не для первой версии.
На доступном слабом Intel baseline, Core i7-6820HQ с урезанным питанием,
последовательные ASR и диаризация обработали час записи примерно за 23 минуты.
Диаризация увеличивает полное время примерно в 2,4 раза, но остаётся быстрее
реального времени и приемлема как явно включаемая функция. Конкретный Core i5
11-го поколения не проверен, поскольку такого устройства нет.
Исходные данные и ограничения зафиксированы в отчётах о
[калибровке](../benchmarks/2026-08-14-diarization-calibration.md),
[смешении говорящих](../benchmarks/2026-08-14-asr-segment-speaker-mixing.md) и
[производительности на Intel](../benchmarks/2026-08-14-diarization-intel-i7.md).
### Сглаживание неизвестного говорящего
После приёмки отдельно проверена идея автоматически назначать `Speaker ?`
известному говорящему, если короткий неизвестный фрагмент находится между двумя
репликами одного и того же `Speaker N`. На трёх контрольных транскриптах найдено
143 таких неизвестных фрагмента, содержащих 309 слов. У 79 фрагментов соседи
имели одинаковую метку, у 64 — разные.
Для T2 BDMA и Yantar результат грубо сопоставлен с независимыми Hypescribe-
транскриптами. Это не gold-разметка: их границы округлены до секунды, а готовый
speaker Markdown хранит только начало реплики. Тем не менее эвристика не показала
достаточной селективности:
| Эвристика | Изменённых фрагментов на трёх записях | Совпадение на двух записях с опорой |
|---|---:|---:|
| Все неизвестные между одинаковыми соседями | 79 | 22 / 42 (52%) |
| Только один неизвестный word | 35 | 11 / 20 (55%) |
| Не больше двух words | 50 | 15 / 30 (50%) |
| Одинаковый отображённый timestamp | 42 | 14 / 22 (64%) |
Короткие `Да`, `Нет` и `Угу` часто являются самостоятельной репликой другого
участника. В одном из контрольных случаев неизвестный фрагмент `— Угу. — А`
даже содержал границу двух голосов, хотя с обеих сторон находился один и тот же
кластер. Поэтому post-hoc сглаживание по соседям скрывает полезную
неопределённость и не применяется. `Speaker ?` остаётся явным результатом ничьей
или отсутствия временного перекрытия.
Если к этой задаче возвращаться, проверять нужно исходные границы `Word` и
`SpeakerInterval` до группировки: отдельно различать нулевой timestamp, реальный
зазор между интервалами и ничью перекрытий. По готовому Markdown такая проверка
невозможна, потому что в нём уже потеряны доли секунды, конец слова и причина
неопределённости.
## Решение
Диаризацию реализуем как явно включаемый пост-процессинг через `sherpa-onnx`.
Первая версия использует Pyannote segmentation 3.0, WeSpeaker ResNet34 LM,
порог кластеризации 0,89 и автоматическое число кластеров; известное число
участников можно передать явно.
Говорящий назначается [слову с временной
привязкой](../../CONTEXT.md#language), а не сегменту распознавания. Каждый
ASR-бэкенд приводит свой результат к общему набору слов с положением на
временной шкале. Проходы ASR и диаризации независимо получают одно аудио, после
чего отдельная операция сводит слова с интервалами разметки говорящих и
объединяет соседние слова одного говорящего в реплики. Распознавание по-прежнему
выполняется на полных сегментах и сохраняет контекст модели.
Диаризация не вводит грубый fallback на целый сегмент и не переключает
устройство ASR ради получения пословных таймкодов. Существующий GPU→CPU fallback
распознавания сохраняется и завершается до диаризации. Отсутствие пословных
таймкодов или ошибка инициализации диаризатора останавливают запуск до ASR.
Ошибка диаризации конкретного файла после успешного ASR не уничтожает полезный
результат: сохраняется обычный транскрипт с явным предупреждением и ненулевым
статусом, а батч продолжает остальные файлы. Порядок первой реализации, время
жизни диаризатора, кеш и подробная матрица поведения находятся в
[спецификации](../specs/2026-08-14-speaker-diarization.md).
## Последствия
- Общий контракт результата распознавания расширяется каноническими словами с
временной привязкой; сегменты распознавания сохраняются для совместимости и
контроля качества.
- FasterWhisper, ONNX-ASR и OpenVINO должны экспортировать один и тот же
пословный контракт. OpenVINO GenAI 2026.x уже предоставляет нужные таймкоды,
поэтому ограничение находится в адаптере проекта, а не в движке.
- `sherpa-onnx` становится обычной runtime-зависимостью, а две модели
диаризации скачиваются и кешируются лениво при первом запросе.
- Выход остаётся линейным Markdown с анонимными метками `Speaker N`.
Сопоставление голосов с именами и специальная запись перекрывающейся речи не
входят в ядро CLI.
- Последовательный режим задаёт корректный baseline. Параллельный запуск и
автоматическое включение на мощных устройствах требуют отдельных измерений
после стабилизации.
## Отклонённые альтернативы
| Альтернатива | Почему отклонена |
|---|---|
| Не делать диаризацию | Оставляет главный продуктовый разрыв, хотя измеренная стоимость допустима для явной функции |
| Мажоритарный говорящий на весь сегмент распознавания | Теряет чужие реплики на всех трёх проверенных записях |
| Сначала диаризация, затем ASR коротких интервалов | Лишает RNN-T длинного контекста и ухудшает согласование и пунктуацию |
| `pyannote.audio` | Тянет PyTorch и требует Hugging Face token с принятием лицензии |
| Сборка поверх приватных деталей `onnx-asr` | Экономит небольшую отдельную зависимость ценой нестабильного внутреннего API и собственной кластеризации |
| Параллельные проходы в первой версии | Нет прямого benchmark и измеренного общего пика памяти; сначала нужен корректный последовательный baseline |
| Автоматически назначать `Speaker ?` одинаковому соседнему кластеру | На двух записях с независимой опорой совпало только 52% назначений; лимиты по словам и округлённому времени не отделили короткие ответы другого участника |
+69
View File
@@ -179,6 +179,75 @@ openvino-cpu, запись 25:59) с облачным сервисом Hypescrib
LLM для чистки текста, сопоставление Speaker N с именами — это работа
поверх готового транскрипта.
### Диаризация — разделение говорящих
**Что:** Опциональный пост-процессинг (не четвёртый бэкенд): диаризация
даёт интервалы «кто когда говорил», результат сводится с сегментами ASR,
formatter ломает абзац на смене спикера и подписывает `Speaker 1:`.
Ставится как extra: `uv sync --extra diarization`.
**Почему:** Без спикеров MoM не собрать — это ключевой разрыв с облаком
по внешнему ревью, и никакое качество распознавания его не компенсирует.
Заодно естественно решает «разбивку на реплики» (приоритет №4).
**Промежуточный статус 2026-08-12:** проведена разведка, описанная в
[разведочном замере диаризации](benchmarks/2026-08-12-diarization-feasibility.md).
Она закрыла вопрос о движке и открыла более важный вопрос о единице привязки.
**Движок — вопрос практически закрыт.** `sherpa-onnx` ставится на Windows с
Python 3.13, содержит готовый `OfflineSpeakerDiarization`, не тянет torch и не
требует токена Hugging Face; модели сегментации и эмбеддингов весят около 33 МБ.
Скорость — 11,1× RTFx, то есть примерно полторы длительности ASR. Вариант
`pyannote.audio` остаётся отклонённым по прежней причине: torch и HF-токен с
принятием лицензии. Отдельный ADR имеет смысл заводить вместе с решением о
единице привязки, а не только про движок.
Замечание для будущих заходов: обе 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.
---
### Ручка нарезки абзацев в formatter
**Что:** «Минутные простыни» в транскрипте — не свойство модели, а наши
константы группировки `_PAUSE_THRESHOLD_S = 2.0` / `_MAX_PARAGRAPH_S =
60.0` в `formatter.py` (сырых сегментов много: 23-минутная запись — 360
сегментов, ~4 с на реплику). Вынести в опцию/конфиг или уменьшить
дефолт.
**Почему откладывается:** при диаризации абзацы будут ломаться по смене
спикера естественно — сначала решить с диаризацией, чтобы не делать
ручку, которая устареет.
---
### Словарь замен технических терминов — запасной план
**Что:** Пост-обработка текста сегментов словарём замен по границам слов
@@ -1,48 +0,0 @@
# Приёмка speaker diarization в CLI
**Дата:** 2026-08-14
**Статус:** ручная приёмка реализации задачи #24 на трёх контрольных записях.
## Профиль запуска
- ASR: `onnx`, `gigaam-v3-e2e-rnnt`, русский язык;
- диаризация: Pyannote segmentation 3.0 и WeSpeaker ResNet34 LM;
- автоматическое число говорящих, порог кластеризации `0,89`;
- 8 потоков CPU, модели в локальном кеше;
- обычный ASR и запуск с `--diarize` выполнялись последовательно.
## Результаты
| Запись | Длительность | Обычный ASR | С диаризацией | Кластеры | Неназначенные слова |
|---|---:|---:|---:|---:|---:|
| Data Test | 26:00 | 137,5 с | 326,2 с | 4 (один малый, 19,1 с) | 201 |
| T2 BDMA | 14:51 | 83,0 с | 184,2 с | 2 | 65 |
| Yantar | 20:22 | 100,1 с | 259,7 с | 2 | 43 |
Все три запуска завершились быстрее реального времени. Четвёртый прогон T2
BDMA после исправления склейки пунктуации повторно подтвердил два кластера и
65 неназначенных слов.
## Инварианты
- после удаления только форматных пробелов перед Unicode-пунктуацией и
символами текст speaker-вывода на всех трёх записях в точности совпал с
обычным ASR: 21 788, 10 520 и 12 055 символов соответственно;
- повторный T2-прогон последней версии также дал точное совпадение 10 520 из
10 520 символов;
- слова не потерялись и не поменяли порядок; неизвестный говорящий остаётся в
выводе как `Speaker ?`;
- CLI показал число кластеров, неназначенные слова и малый остаточный кластер,
не скрывая диагностические данные.
## Память и стоимость
Во время финального T2-прогона рабочий процесс наблюдался на уровне 905 МБ RSS.
Это согласуется с отдельным замером последовательных проходов: 900–1035 МБ для
ASR и 366–469 МБ для диаризации. Подробные условия и ограничения приведены в
[отчёте Intel i7](2026-08-14-diarization-intel-i7.md).
Диаризация остаётся опциональной: на контрольных записях полное время выросло
примерно в 2,2–2,6 раза. При этом сбой диаризации не удаляет готовый ASR-текст и
не останавливает обработку остальных файлов batch-запуска.
@@ -1,199 +0,0 @@
# Стоимость CPU-диаризации и облегчённые альтернативы
**Дата:** 2026-08-14
**Статус:** исследовательская записка. Production-конфигурация не менялась.
## Краткий вывод
Измеренная стоимость ожидаема для выбранного каскада, но не является
неизбежным минимумом. `WeSpeaker ResNet34 LM` — не аномально большая модель:
ONNX-файл занимает 26,5 МБ, а сама архитектура находится в младшей части
семейства WeSpeaker ResNet. Основная цена возникает из способа применения
моделей: Pyannote проходит запись перекрывающимися 10-секундными окнами с шагом
1 секунда, после чего sherpa-onnx отдельно считает speaker embedding для каждого
локального говорящего каждого окна.
На минутном профиле текущей связки на этом ноутбуке эмбеддинги заняли 5,150 с
из 6,395 с, сегментация — 1,243 с, кластеризация — меньше измеримой миллисекунды.
То есть около 81% времени в этом прогоне пришлось на многократные вызовы
WeSpeaker. Ускорять только кластеризацию или менять её порог бессмысленно.
Более лёгкие пути есть. Самый безопасный для исследования — официальная INT8
версия той же Pyannote-сегментации. Самый большой подтверждённый upstream
потенциал даёт `nemo_en_titanet_small`, но её качество и порог кластеризации на
русских созвонах проекта ещё не проверялись. Локально проверенная CAMPPlus zh/en
быстрее WeSpeaker примерно на 30%, однако не прошла требование автоматического
определения числа говорящих с единым порогом.
## Откуда берётся стоимость
`sherpa-onnx` строит результат из трёх вычислительных стадий: speaker
segmentation, speaker embeddings и clustering. Это соответствует как
[официальному API](https://k2-fsa.github.io/sherpa/onnx/c-api/html/speaker_diarization.html),
так и [реализации конвейера
Pyannote](https://github.com/k2-fsa/sherpa-onnx/blob/v1.13.5/sherpa-onnx/csrc/offline-speaker-diarization-pyannote-impl.h).
### 1. Перекрывающаяся сегментация
Pyannote segmentation 3.0 принимает 10 секунд mono 16 кГц и различает до трёх
локальных говорящих в окне, включая пары одновременно говорящих. Это явно
зафиксировано в [карточке исходной
модели](https://huggingface.co/pyannote/segmentation-3.0).
В официальном ONNX-файле `window_size=160000`, то есть те же 10 секунд при
16 кГц. В sherpa-onnx 1.13.5 `window_shift_ratio` по умолчанию равен 0,1, а
число окон вычисляется из размера окна и этого шага. Поэтому обычная длинная
запись проходит через segmentation-модель примерно десятикратно
перекрывающимися окнами: новое окно начинается каждую секунду. См.
[конфигурацию шага](https://github.com/k2-fsa/sherpa-onnx/blob/v1.13.5/sherpa-onnx/csrc/offline-speaker-segmentation-pyannote-model-config.h)
и [цикл обработки
окон](https://github.com/k2-fsa/sherpa-onnx/blob/v1.13.5/sherpa-onnx/csrc/offline-speaker-diarization-pyannote-impl.h).
Это не признак «огромной» модели: FP32-файл сегментации весит лишь 5,7 МБ.
Стоимость создаёт прежде всего частота её запуска. Официальная поставка также
содержит `model.int8.onnx` размером 1,5 МБ
([документация sherpa-onnx](https://k2-fsa.github.io/sherpa/onnx/speaker-diarization/models.html)).
### 2. Embedding для каждого локального говорящего каждого окна
После сегментации sherpa-onnx исключает перекрывающиеся кадры из материала для
эмбеддинга, собирает пары `(окно, локальный говорящий)` и для каждой пары создаёт
отдельный stream и запускает embedding extractor. Это видно непосредственно в
[методах `GetChunkSpeakerSampleIndexes` и
`ComputeEmbeddings`](https://github.com/k2-fsa/sherpa-onnx/blob/v1.13.5/sherpa-onnx/csrc/offline-speaker-diarization-pyannote-impl.h).
При одном активном голосе это уже примерно один embedding на секунду записи;
если в окне модель видит несколько локальных голосов, вызовов становится больше.
Текущий extractor — английский VoxCeleb `ResNet34_LM`; WeSpeaker поясняет, что
суффикс LM означает дополнительную large-margin донастройку, полезную на
фрагментах длиннее трёх секунд
([официальный список моделей](https://github.com/wenet-e2e/wespeaker/blob/master/docs/pretrained.md)).
### 3. Кластеризация почти бесплатна
В [первичном разведочном
замере](../benchmarks/2026-08-12-diarization-feasibility.md) смена порога не
изменила время: 153–157 с, а кластеризация занимала доли секунды. Минутный профиль
текущей production-конфигурации также показал `0.000 s` для clustering. Явное
число участников полезно для качества и стабильности количества кластеров, но не
является существенной оптимизацией CPU.
## Что показывают локальные измерения
| Среда и материал | Конфигурация | Результат |
|---|---|---:|
| Ryzen 7 8845H, Data Test 26:00 | Pyannote FP32 + WeSpeaker, 8 потоков | 140,7 с; 11,1× realtime |
| Ryzen 7 8845H, три 5-минутных фрагмента | WeSpeaker | средний RTF 0,118 |
| Ryzen 7 8845H, те же фрагменты | CAMPPlus zh/en | средний RTF 0,083; 0,70× от WeSpeaker |
| Intel Core i7-6820HQ, 61:13 аудио | WeSpeaker, 8 потоков | 832,3 с; 4,4× realtime |
| Текущий ноутбук, 60 с T2 BDMA | WeSpeaker, 8 потоков, debug profile | segmentation 1,243 с; embeddings 5,150 с; clustering 0,000 с; всего 6,395 с |
Источники полных воспроизводимых замеров: [разведка на
Ryzen](../benchmarks/2026-08-12-diarization-feasibility.md), [калибровка
эмбеддингов](../benchmarks/2026-08-14-diarization-calibration.md) и [Intel
baseline](../benchmarks/2026-08-14-diarization-intel-i7.md). Минутный профиль —
диагностический одиночный прогон той же production-конфигурации на первых 60
секундах T2 BDMA; его следует использовать для распределения стоимости по
стадиям, а не как новый общий benchmark.
Увеличение `num_threads` не решает проблему. На Data Test переход с четырёх
потоков (154 с) на восемь (141 с) дал только 9%. Это согласуется с устройством
конвейера: он выполняет много последовательных ONNX-вызовов для отдельных окон
и локальных говорящих, поэтому добавление потоков внутри одного вызова быстро
перестаёт масштабироваться.
## Официально поддерживаемые облегчённые варианты
### INT8 Pyannote segmentation 3.0
Sherpa-onnx официально поставляет FP32 и INT8 варианты одной сегментации. На его
контрольной записи замена только segmentation-модели при 3D-Speaker embedding
снизила RTF с 0,297 до 0,241, то есть примерно на 19%. С TitaNet small разница
меньше: 0,119 против 0,110. Все числа опубликованы на одной странице
[официальных примеров и
замеров](https://k2-fsa.github.io/sherpa/onnx/speaker-diarization/models.html).
Trade-off: это минимальное архитектурное изменение, но INT8 меняет границы
интервалов даже в официальном примере. Перед заменой нужно повторить на трёх
контрольных записях число кластеров, purity, малые кластеры, неназначенные слова
и полный runtime. На текущем минутном профиле segmentation занимает лишь около
19% времени, поэтому одной квантизацией нельзя ожидать кратного ускорения всей
диаризации.
### 3D-Speaker CAMPPlus zh/en
Модель уже входит в официальный релиз speaker-recognition моделей sherpa-onnx,
а 3D-Speaker публикует CAM++ как штатную архитектуру своего набора
([репозиторий и таблица
моделей](https://github.com/modelscope/3D-Speaker)). Её ONNX-файл занимает
28,3 МБ — немного больше текущих 26,5 МБ, поэтому размер файла здесь плохо
предсказывает вычислительную стоимость.
Локально CAMPPlus дала RTF 0,083 против 0,118 у WeSpeaker, то есть была примерно
на 30% быстрее. При известном числе участников она также улучшила proxy-purity
на двух разговорах. Но в автоматическом режиме не нашлось единого порога: один
порог оставлял лишние кластеры, следующий уже склеивал реальные голоса. Полные
данные находятся в [отчёте о
калибровке](../benchmarks/2026-08-14-diarization-calibration.md).
Trade-off: хороший кандидат для режима с обязательным `--speakers N`, но не
готовая замена общего автоматического режима.
### NeMo TitaNet small
Sherpa-onnx официально показывает `nemo_en_titanet_small` как совместимый
embedding extractor. На его контрольной записи Pyannote FP32 + TitaNet small
дала RTF 0,119 вместо 0,297 у Pyannote FP32 + 3D-Speaker ERes2Net; с INT8
сегментацией — 0,110 вместо 0,241. Это самый большой опубликованный upstream
выигрыш среди проверенных на одной странице комбинаций
([официальные замеры](https://k2-fsa.github.io/sherpa/onnx/speaker-diarization/models.html)).
TitaNet использует 1D depth-wise separable convolutions и channel-attention
statistics pooling
([документация NVIDIA](https://docs.nvidia.com/nemo-framework/user-guide/25.02/nemotoolkit/asr/speaker_recognition/models.html)).
Trade-off: upstream-цифры сняты на другой embedding-модели сравнения, другом
материале и оборудовании, поэтому коэффициент нельзя переносить на этот ноутбук.
Кроме того, TitaNet small не проходила локальную калибровку на русской речи:
неизвестны подходящий clustering threshold, стабильность числа голосов и
качество коротких реплик. Сначала нужен тот же свип, который уже выполнен для
WeSpeaker и CAMPPlus.
### Увеличение шага окна
В C++-конфигурации sherpa-onnx 1.13.5 есть `window_shift_ratio`, поэтому на уровне
движка можно уменьшить число перекрывающихся окон ценой более грубой разметки.
Но [Python binding этой
версии](https://github.com/k2-fsa/sherpa-onnx/blob/v1.13.5/sherpa-onnx/python/csrc/offline-speaker-diarization.cc)
экспортирует только путь `model`, а не `window_shift_ratio`. Для текущего Python
приложения это не штатная ручка без изменения upstream binding или собственного
нативного слоя. Даже после появления ручки потребуется отдельная калибровка:
более редкие окна могут ухудшить границы смены голоса и короткие ответы — как раз
самую чувствительную часть текущего результата.
## Практические следующие шаги
1. Не считать текущую стоимость дефектом реализации: выбранный результат
соответствует устройству sherpa-onnx и остаётся быстрее realtime даже на
старом Intel.
2. Первым отдельным экспериментом прогнать INT8 Pyannote на тех же трёх файлах.
Это наименьший по масштабу вариант, хотя ожидаемый выигрыш умеренный.
3. Отдельно откалибровать TitaNet small. У неё лучший опубликованный потенциал
скорости, но пока нет локальных данных о качестве.
4. CAMPPlus предлагать только как кандидат для режима с известным числом
участников, если 30% экономии оправдывает второй production-профиль.
5. Параллельный ASR и diarization исследовать независимо от выбора модели. Он не
уменьшает CPU-работу, но может сократить wall-clock latency. Обязательно
измерить конкуренцию за ядра и общий peak RSS: на Intel последовательные пики
составляли 900–1035 МБ для ASR и 366–469 МБ для диаризации, а совместный пик
пока не измерен.
## Вердикт
Текущая диаризация тяжёлая **в основном из-за каскада и плотного перекрытия
окон**, а не потому, что случайно выбрана гигантская модель. Но выбранный
WeSpeaker ResNet34 LM не самый быстрый extractor. Реалистичный резерв — умеренное
ускорение через INT8 segmentation, около 30% по локальным данным через CAMPPlus
при известном числе участников и потенциально более крупное ускорение через
TitaNet small после обязательной русскоязычной калибровки. Простое добавление
потоков или настройка clustering заметного выигрыша не даст.
@@ -1,207 +0,0 @@
# Диаризация говорящих в транскрипте
## Проблема
Текущий транскрипт знает только сегменты распознавания. Их границы проходят по
тишине и не совпадают со сменой говорящего, поэтому один сегмент может содержать
несколько реплик. Назначение одной метки всему сегменту искажает структуру
диалога и делает транскрипт слабым сырьём для конспекта или протокола встречи.
[ADR-007](../adr/007-word-level-speaker-diarization.md) выбирает явную
пословную диаризацию через `sherpa-onnx`. Эта спецификация фиксирует форму первой
реализации, не меняя принятые решения.
## Цели
- По явному запросу строить реплики говорящих, сохраняя текст и длинный контекст
ASR.
- Поддержать один контракт слов с временной привязкой на FasterWhisper,
ONNX-ASR и OpenVINO.
- Сохранить предсказуемый single- и batch-режим при отсутствии речи, ошибках
диаризации и малых голосовых кластерах.
- Выдать компактный Markdown, удобный и человеку, и последующей обработке LLM.
## Не входит
- Автоматическое включение диаризации без флага и `--no-diarize`.
- Параллельный запуск ASR и диаризации.
- Сопоставление `Speaker N` с именами участников.
- Постоянный кеш разметки говорящих, голосовые эмбеддинги и диагностические
файлы.
- Отдельный синтаксис для перекрывающейся речи.
- Изменение устройства ASR или диаризации ради восстановления функции.
## Пользовательский интерфейс
- `--diarize` включает диаризацию. По умолчанию она выключена; ключ в
`.transcriber.toml` в первой версии не добавляется.
- `--speakers N`, где `N >= 1`, задаёт известное число участников и сам включает
диаризацию. Без него число кластеров определяется автоматически.
- `--threads N` остаётся единым бюджетом активного CPU-прохода. Значение целиком
получает сначала ASR, затем диаризация; `0` оставляет настройки библиотек.
- `--verbose` показывает в консоли прогресс, число кластеров и интервалов,
длительность прохода и предупреждения, но не создаёт дополнительные файлы.
- `--force` пересчитывает и ASR, и диаризацию. Без него готовый транскрипт, как и
сейчас, пропускается целиком.
`sherpa-onnx` входит в обычные runtime-зависимости. Модели
`sherpa-onnx-pyannote-segmentation-3-0` и
`wespeaker_en_voxceleb_resnet34_LM.onnx` скачиваются и кешируются лениво при
первом запросе диаризации. Отдельного installation extra нет.
## Контракты данных
Результат ASR сохраняет существующие сегменты распознавания и дополнительно
содержит упорядоченные канонические слова. Для каждого слова известны текст,
начало и конец на временной шкале исходной записи. Backend-специфичные токены и
слова нормализуются в адаптере бэкенда; их обратная сборка должна сохранять
распознанный текст с точностью до нормализации пробелов.
Разметка говорящих хранится отдельно от результата ASR: это упорядоченные
временные интервалы с анонимным идентификатором голосового кластера. ASR-бэкенд
не знает о кластерах, а диаризатор не знает о распознанном тексте.
Операция сведения суммирует временное перекрытие слова с интервалами каждого
кластера и назначает кластер с единственным наибольшим ненулевым перекрытием.
Если пересечения нет либо несколько кластеров делят наибольшее значение, слово
получает неизвестного говорящего: порядок кластеров не используется как
искусственная развязка ничьей. Соседние слова одного говорящего объединяются в
реплику; порядок слов и исходная временная шкала не меняются.
Все три ASR-пути обязаны предоставлять пословный контракт до включения
диаризации:
| Путь | Источник временных привязок |
|---|---|
| FasterWhisper | word timestamps CTranslate2 |
| ONNX-ASR | timestamped result модели |
| OpenVINO | word-level timestamps `WhisperPipeline` |
Грубая подстановка метки на весь сегмент распознавания запрещена.
## Пайплайн и время жизни
После prescan и только при наличии файлов для обработки загружаются ASR-модель
и один batch-owned диаризатор. До первого ASR проверяются доступность пословных
таймкодов, модели диаризации и возможность создать диаризатор. Ошибка этого
этапа останавливает весь запуск без частичных транскриптов.
Каждый файл обрабатывается последовательно:
1. ASR;
2. диаризация, если ASR нашёл речь;
3. сведение слов с разметкой говорящих;
4. форматирование и запись Markdown.
Один диаризатор последовательно переиспользуется для всех файлов батча. Данные
конкретной записи не становятся состоянием следующей. Объект освобождается при
завершении команды и не переносится через `TranscribeFileResult`.
Разметка говорящих хранится только до сведения. Единственный постоянный
продуктовый артефакт — Markdown-транскрипт; локальный кеш файлов моделей живёт
по существующим правилам загрузчиков.
## Конфигурация диаризации
Автоматический режим использует:
- Pyannote segmentation 3.0;
- WeSpeaker ResNet34 LM;
- порог кластеризации 0,89;
- автоматическое число кластеров.
`--speakers N` передаёт явное число кластеров вместо автоматического. Для
предупреждения используется диагностическая граница из калибровки: малым
считается кластер с речью короче максимума из 5 секунд и 2% длительности записи.
Граница влияет только на предупреждение — кластер не отбрасывается, получает
обычный номер и не меняет статус команды.
## Формат Markdown
При двух и более найденных кластерах тело состоит из линейных реплик:
```markdown
[09:07] Speaker 1: Мы же у них не разворачиваемся…
[09:18] Speaker 2: Мне гораздо проще накатывать обновления…
```
- Печатается только начало реплики; доли секунды отбрасываются, а не округляются
(`09:07.96``[09:07]`). Для записей длиннее часа используется
`[HH:MM:SS]`, иначе `[MM:SS]`.
- Метка `Speaker N` не получает Markdown-выделение.
- Нумерация начинается заново для каждого файла; номера назначаются по порядку
первого появления кластера в словах транскрипта.
- Смена говорящего всегда начинает новую реплику.
- Речь одного говорящего дополнительно разбивается по паузе не меньше 2 секунд
и максимальной длительности реплики 60 секунд.
- Слова без назначенного кластера группируются под `Speaker ?`.
- Перекрывающаяся речь остаётся в хронологическом порядке без особого
синтаксиса.
- В шапку добавляется число голосовых кластеров и предупреждения. Таблица
длительности по кластерам не выводится.
Без успешной разметки нескольких говорящих сохраняется нынешний формат
обычного транскрипта с диапазонами времени.
## Деградация и статус команды
| Ситуация | Артефакт | Консоль и шапка | Статус |
|---|---|---|---|
| Диаризация не запрошена | Обычный транскрипт | Без новых сообщений | Текущий |
| ASR не нашёл речь | Текущий пустой Markdown | Речь не обнаружена; диаризация не запускалась | 0 |
| Найдено не меньше двух кластеров | Транскрипт с `Speaker N` | Число кластеров и предупреждения | 0, если нет иной ошибки |
| Найден один кластер | Обычный транскрипт без `Speaker 1` | Причина в консоли и шапке | Ненулевой |
| Есть слова без пересечения | Транскрипт с `Speaker ?` | Число таких слов | 0 |
| Есть малый кластер | Транскрипт со всеми кластерами | Длительность малого кластера | 0 |
| Разметка пуста при непустом ASR | Обычный транскрипт | Явное предупреждение | Ненулевой |
| Ошибка диаризации конкретного файла | Обычный транскрипт | Явное предупреждение | Ненулевой |
| Нет пословных таймкодов или не инициализировался диаризатор | Файлы не обрабатываются | Понятная ошибка до ASR | Ненулевой |
В батче деградированный файл записывается, учитывается как неуспешная
диаризация, а остальные файлы продолжают обрабатываться. Итоговый статус батча
ненулевой, если хотя бы один файл деградировал или завершился ошибкой.
## Критерии приёмки
### Автоматические проверки
- Адаптер каждого ASR-бэкенда возвращает монотонные слова с временной
привязкой; сборка слов сохраняет текст сегментов с точностью до пробелов.
- Сведение покрывает смену говорящего, отсутствие пересечения, равное
наибольшее перекрытие с результатом `Speaker ?`, пунктуацию на границе реплик
и хронологический порядок.
- Форматтер проверяется для обычных, часовых, неизвестных и малых кластеров,
`Speaker ?`, отбрасывания долей таймкода, паузы 2 секунды и предела 60 секунд.
- CLI проверяет несовместимые и граничные значения, не запускает ASR при ошибке
preflight и соблюдает всю матрицу деградации в single- и batch-режимах.
- Батч создаёт диаризатор ровно один раз, пропускает его для пустого ASR,
переиспользует между файлами и не пишет промежуточный кеш.
- `--threads`, `--verbose` и `--force` сохраняют описанную семантику.
Все автоматические тесты мокают движки и не скачивают реальные модели.
### Ручная проверка
Финальная сборка прогоняется на трёх записях из отчётов карты:
- текст до и после сведения совпадает с точностью до переносов и пробелов;
- на плотном диалоге вручную проверяются устойчивость «голос → кластер», смены
говорящего, пропуски, малый остаточный кластер и перекрывающаяся речь;
- автоматическая конфигурация воспроизводит наблюдённую форму результата:
три основных и один малый остаточный кластер на Data Test, по два кластера на
T2 BDMA и Yantar;
- последовательный прогон на доступном Intel baseline остаётся быстрее
реального времени; фактические wall time и peak RSS записываются рядом с
результатом проверки.
## Документация
README должен описать новые CLI-флаги, ленивую загрузку моделей, ожидаемую
стоимость, формат `Speaker N`, предупреждения и batch-поведение. Направления
«Диаризация» и «Ручка нарезки абзацев» удаляются из backlog: первое перешло в
эту спецификацию, второе закрыто разрывом реплики на смене говорящего.
После стабилизации отдельно рассматриваются
[параллельный запуск](https://git.dementev.space/ddmitry/local-transcriber/issues/22)
и [hardware-aware default](https://git.dementev.space/ddmitry/local-transcriber/issues/23).
-2
View File
@@ -8,9 +8,7 @@ dependencies = [
"typer>=0.24.1,<1",
"rich>=14.3.3,<15",
"faster-whisper>=1.2.1,<2",
"httpx>=0.28,<1",
"socksio>=1.0.0,<2",
"sherpa-onnx>=1.13.5,<2",
"nvidia-cublas-cu12>=12.4,<13; sys_platform == 'linux' and platform_machine == 'x86_64'",
"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",
-5
View File
@@ -16,11 +16,6 @@ class Backend(Protocol):
наследование не требуется.
"""
@property
def word_timestamps_available(self) -> bool:
"""Гарантирует ли выбранный backend/model пословные таймкоды."""
...
def ensure_model_available(
self,
model_name: str,
@@ -2,6 +2,9 @@
from __future__ import annotations
import gc
import io
import warnings
from collections.abc import Callable
from pathlib import Path
from typing import Any
@@ -15,12 +18,7 @@ from faster_whisper import WhisperModel # noqa: E402
from huggingface_hub import snapshot_download # noqa: E402
from huggingface_hub.errors import LocalEntryNotFoundError # noqa: E402
from local_transcriber.types import ( # noqa: E402
Segment,
TranscribeResult,
Word,
WordTimestampsUnavailableError,
)
from local_transcriber.types import Segment, TranscribeResult # noqa: E402
MODEL_REPOS = {
"tiny": "Systran/faster-whisper-tiny",
@@ -48,8 +46,6 @@ MODEL_REQUIRED_FILES = [
class FasterWhisperBackend:
"""Бэкенд транскрипции через faster-whisper (CTranslate2)."""
word_timestamps_available = True
def __init__(self):
self.actual_compute_type: str | None = None
@@ -96,9 +92,7 @@ class FasterWhisperBackend:
"""
try:
return WhisperModel(
model_path,
device=device,
compute_type=compute_type,
model_path, device=device, compute_type=compute_type,
cpu_threads=cpu_threads,
)
except ImportError as exc:
@@ -120,25 +114,12 @@ class FasterWhisperBackend:
) -> TranscribeResult:
"""Транскрибирует файл через faster-whisper."""
segment_generator, info = model.transcribe(
str(file_path),
language=language,
word_timestamps=True,
str(file_path), language=language,
)
total_duration = info.duration
segments: list[Segment] = []
words: list[Word] = []
for raw_seg in segment_generator:
seg = Segment(start=raw_seg.start, end=raw_seg.end, text=raw_seg.text)
raw_words = raw_seg.words or []
if seg.text.strip() and not raw_words:
raise WordTimestampsUnavailableError(
"FasterWhisper не вернул пословные таймкоды "
"для распознанного сегмента"
)
words.extend(
Word(start=raw_word.start, end=raw_word.end, text=raw_word.word)
for raw_word in raw_words
)
if on_segment is not None:
on_segment(seg)
segments.append(seg)
@@ -154,7 +135,6 @@ class FasterWhisperBackend:
language_probability=info.language_probability,
duration=info.duration,
device_used="", # оркестратор проставит actual_device
words=words,
)
@@ -175,9 +155,7 @@ def _resolve_model_repo(model_name: str) -> str:
repo_id = MODEL_REPOS.get(model_name)
if repo_id is None:
expected = ", ".join(MODEL_REPOS)
raise ValueError(
f"Неподдерживаемая модель '{model_name}'. Ожидалось одно из: {expected}"
)
raise ValueError(f"Неподдерживаемая модель '{model_name}'. Ожидалось одно из: {expected}")
return repo_id
@@ -200,17 +178,13 @@ def _snapshot_download(repo_id: str, local_files_only: bool) -> str:
def _validate_model_dir(model_dir: Path) -> None:
missing = [
filename
for filename in MODEL_REQUIRED_FILES
if not (model_dir / filename).exists()
filename for filename in MODEL_REQUIRED_FILES if not (model_dir / filename).exists()
]
if not any(model_dir.glob("vocabulary.*")):
missing.append("vocabulary.*")
if missing:
missing_str = ", ".join(missing)
raise ValueError(
f"Неполная локальная модель в '{model_dir}': отсутствуют {missing_str}"
)
raise ValueError(f"Неполная локальная модель в '{model_dir}': отсутствуют {missing_str}")
def _is_missing_socksio_error(exc: BaseException) -> bool:
+5 -69
View File
@@ -8,13 +8,7 @@ from dataclasses import dataclass
from pathlib import Path
from typing import Any
from local_transcriber.types import (
UNKNOWN_LANGUAGE,
Segment,
TranscribeResult,
Word,
WordTimestampsUnavailableError,
)
from local_transcriber.types import UNKNOWN_LANGUAGE, Segment, TranscribeResult
@dataclass(frozen=True)
@@ -66,7 +60,9 @@ _WHISPER_MODEL_NAMES = frozenset(
_OPENVINO_ONLY_WHISPER_MODELS = frozenset({"large-v3-turbo"})
MODEL_CATALOG: dict[str, OnnxModelSpec] = {
"gigaam-v3": OnnxModelSpec("gigaam-v3-ctc", _INT8_AND_FLOAT32, _RUSSIAN_ONLY),
"gigaam-v3": OnnxModelSpec(
"gigaam-v3-ctc", _INT8_AND_FLOAT32, _RUSSIAN_ONLY
),
"parakeet-v3": OnnxModelSpec(
"nemo-parakeet-tdt-0.6b-v3",
_INT8_AND_FLOAT32,
@@ -139,11 +135,6 @@ class OnnxAsrBackend:
self._model_spec: OnnxModelSpec | None = None
self._vad: Any = None
@property
def word_timestamps_available(self) -> bool:
"""Каталожные модели проверены; произвольный raw id отклоняется."""
return self._model_spec is not None
def ensure_model_available(
self,
model_name: str,
@@ -203,7 +194,7 @@ class OnnxAsrBackend:
)
vad = onnx_asr.load_vad("silero")
self._vad = vad
return model.with_vad(vad).with_timestamps()
return model.with_vad(vad)
def transcribe(
self,
@@ -224,13 +215,10 @@ class OnnxAsrBackend:
self._warn_if_language_unsupported(language)
_notify(on_status, "Загружаю аудио...")
audio_array = decode_audio(str(file_path), sampling_rate=16000)
if isinstance(audio_array, tuple):
raise TypeError("Декодер неожиданно вернул раздельные стереоканалы")
duration = len(audio_array) / 16000.0
_notify(on_status, "Транскрибирую (onnx-asr)...")
segments: list[Segment] = []
words: list[Word] = []
result_language = (
language or _model_language(self._model_spec) or UNKNOWN_LANGUAGE
)
@@ -247,12 +235,6 @@ class OnnxAsrBackend:
end=end,
text=vad_seg.text,
)
segment_words = _timestamped_segment_words(vad_seg, start, end)
if vad_seg.text.strip() and not segment_words:
raise WordTimestampsUnavailableError(
"ONNX-ASR не вернул пословные таймкоды для распознанного текста"
)
words.extend(segment_words)
if on_segment is not None:
on_segment(seg)
segments.append(seg)
@@ -267,7 +249,6 @@ class OnnxAsrBackend:
language_probability=1.0 if language else 0.0,
duration=duration,
device_used="", # оркестратор проставит
words=words,
)
def _resolve_model(self, model_name: str) -> str:
@@ -345,48 +326,3 @@ def _model_language(spec: OnnxModelSpec | None) -> str | None:
def _notify(on_status: Callable[[str], None] | None, message: str) -> None:
if on_status is not None:
on_status(message)
def _timestamped_segment_words(
vad_segment: Any,
segment_start: float,
segment_end: float,
) -> list[Word]:
tokens = getattr(vad_segment, "tokens", None)
timestamps = getattr(vad_segment, "timestamps", None)
if not tokens or not timestamps or len(tokens) != len(timestamps):
return []
grouped: list[tuple[float, str]] = []
current_start = float(timestamps[0])
current_tokens: list[str] = []
for token, timestamp in zip(tokens, timestamps, strict=True):
if token[:1].isspace() and current_tokens:
grouped.append((current_start, "".join(current_tokens)))
current_start = float(timestamp)
current_tokens = []
current_tokens.append(token)
grouped.append((current_start, "".join(current_tokens)))
words: list[Word] = []
for index, (relative_start, text) in enumerate(grouped):
start = min(
segment_end,
max(segment_start, segment_start + relative_start),
)
next_start = next(
(
candidate_start
for candidate_start, _ in grouped[index + 1 :]
if candidate_start > relative_start
),
None,
)
end = max(
start,
min(segment_end, segment_start + next_start)
if next_start is not None
else segment_end,
)
words.append(Word(start=start, end=end, text=text))
return words
+8 -55
View File
@@ -2,9 +2,9 @@
from __future__ import annotations
import json
import threading
import time
import warnings
from collections.abc import Callable
from pathlib import Path
from typing import Any
@@ -12,13 +12,7 @@ from typing import Any
from huggingface_hub import snapshot_download
from huggingface_hub.errors import LocalEntryNotFoundError
from local_transcriber.types import (
UNKNOWN_LANGUAGE,
Segment,
TranscribeResult,
Word,
WordTimestampsUnavailableError,
)
from local_transcriber.types import UNKNOWN_LANGUAGE, Segment, TranscribeResult
# (model_alias, compute_type) → HF repo
MODEL_REPOS: dict[tuple[str, str], str] = {
@@ -49,15 +43,12 @@ _IMPLICIT_COMPUTE_TYPE_OVERRIDES: dict[str, str] = {
MODEL_REQUIRED_FILES = [
"openvino_encoder_model.xml",
"openvino_decoder_model.xml",
"generation_config.json",
]
class OpenVINOBackend:
"""Бэкенд транскрипции через openvino-genai WhisperPipeline."""
word_timestamps_available = True
def __init__(
self,
ov_device: str = "openvino-cpu",
@@ -91,9 +82,7 @@ class OpenVINOBackend:
except ValueError:
_notify(on_status, f"Кэш модели {model_name} неполный, докачиваю...")
_notify(
on_status, f"Скачиваю модель {model_name} (OpenVINO) из Hugging Face..."
)
_notify(on_status, f"Скачиваю модель {model_name} (OpenVINO) из Hugging Face...")
downloaded_path = Path(snapshot_download(repo_id, local_files_only=False))
_validate_model_dir(downloaded_path)
return str(downloaded_path)
@@ -126,11 +115,7 @@ class OpenVINOBackend:
ov_dev = self._resolve_ov_device()
self.actual_ov_device = ov_dev
return ov_genai.WhisperPipeline(
model_path,
ov_dev,
word_timestamps=True,
)
return ov_genai.WhisperPipeline(model_path, ov_dev)
def transcribe(
self,
@@ -145,23 +130,16 @@ class OpenVINOBackend:
_notify(on_status, "Загружаю аудио...")
raw_speech = decode_audio(str(file_path), sampling_rate=16000)
if isinstance(raw_speech, tuple):
raise TypeError("Декодер неожиданно вернул раздельные стереоканалы")
duration = len(raw_speech) / 16000.0
kwargs: dict[str, Any] = {
"return_timestamps": True,
"word_timestamps": True,
}
kwargs: dict[str, Any] = {"return_timestamps": True}
if language:
kwargs["language"] = f"<|{language}|>"
dur_min = int(duration // 60)
duration_str = f"{dur_min} мин" if dur_min > 0 else f"{int(duration)} сек"
pcm_list = raw_speech.tolist()
result = _generate_with_progress(
model, pcm_list, kwargs, duration_str, on_status
)
result = _generate_with_progress(model, pcm_list, kwargs, duration_str, on_status)
segments: list[Segment] = []
if hasattr(result, "chunks") and result.chunks:
@@ -181,16 +159,6 @@ class OpenVINOBackend:
f"Транскрибирую (OpenVINO)... [{len(segments)} сегм.]",
)
words = []
for raw_word in getattr(result, "words", None) or []:
start = min(duration, max(0.0, raw_word.start_ts))
end = min(duration, max(start, raw_word.end_ts))
words.append(Word(start=start, end=end, text=raw_word.word))
if any(segment.text.strip() for segment in segments) and not words:
raise WordTimestampsUnavailableError(
"OpenVINO не вернул пословные таймкоды для распознанного текста"
)
detected_language = language or UNKNOWN_LANGUAGE
language_probability = 1.0 if language else 0.0
@@ -200,7 +168,6 @@ class OpenVINOBackend:
language_probability=language_probability,
duration=duration,
device_used="", # оркестратор проставит
words=words,
)
def _resolve_repo(self, model_name: str, compute_type: str) -> tuple[str, str]:
@@ -209,10 +176,7 @@ class OpenVINOBackend:
Возвращает (repo_id, actual_compute_type).
"""
# Для неявного compute_type: override для конкретных моделей
if (
not self._compute_type_explicit
and model_name in _IMPLICIT_COMPUTE_TYPE_OVERRIDES
):
if not self._compute_type_explicit and model_name in _IMPLICIT_COMPUTE_TYPE_OVERRIDES:
compute_type = _IMPLICIT_COMPUTE_TYPE_OVERRIDES[model_name]
# Точное совпадение
@@ -267,10 +231,7 @@ def _generate_with_progress(
while thread.is_alive():
elapsed = int(time.monotonic() - start)
elapsed_str = f"{elapsed // 60:02d}:{elapsed % 60:02d}"
_notify(
on_status,
f"Транскрибирую {duration_str} аудио (OpenVINO)... прошло {elapsed_str}",
)
_notify(on_status, f"Транскрибирую {duration_str} аудио (OpenVINO)... прошло {elapsed_str}")
thread.join(timeout=1.0)
if error_box[0] is not None:
@@ -290,11 +251,3 @@ def _validate_model_dir(model_dir: Path) -> None:
raise ValueError(
f"Неполная OpenVINO модель в '{model_dir}': отсутствуют {', '.join(missing)}"
)
generation_config = json.loads(
(model_dir / "generation_config.json").read_text(encoding="utf-8")
)
if not generation_config.get("alignment_heads"):
raise ValueError(
f"OpenVINO модель в '{model_dir}' не содержит alignment_heads "
"для пословных таймкодов"
)
+47 -276
View File
@@ -11,7 +11,6 @@ from rich.status import Status
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 .diarization import build_speaker_transcript
from .formatter import (
LANGUAGE_DETECTED,
LANGUAGE_FORCED,
@@ -28,7 +27,6 @@ from .quality import (
find_repetition_blocks,
tail_gap,
)
from .speaker_diarizer import SpeakerDiarizer, load_speaker_diarizer
from .transcriber import (
Segment,
TranscribeResult,
@@ -36,12 +34,7 @@ from .transcriber import (
_transcribe_file,
load_model,
)
from .types import (
UNKNOWN_LANGUAGE,
DiarizationRun,
SpeakerTranscript,
StatusCallback,
)
from .types import UNKNOWN_LANGUAGE
from .utils import (
build_output_path,
detect_device,
@@ -69,7 +62,9 @@ def _format_device_info(device_used: str) -> str:
return "CPU"
def _format_language_mode(requested_language: str, result: TranscribeResult) -> str:
def _format_language_mode(
requested_language: str, result: TranscribeResult
) -> str:
"""Описывает источник языка, не выдавая профиль модели за детектор."""
if requested_language != "auto":
return LANGUAGE_FORCED
@@ -97,9 +92,7 @@ def _format_repetition_blocks(
return summary
def _print_quality_warnings(
result: TranscribeResult, file_name: str | None = None
) -> None:
def _print_quality_warnings(result: TranscribeResult, file_name: str | None = None) -> None:
"""Печатает предупреждения о возможной потере содержания."""
is_batch = file_name is not None
use_hours = result.duration > 3600
@@ -109,7 +102,8 @@ def _print_quality_warnings(
covered = format_duration(result.segments[-1].end)
total = format_duration(result.duration)
message = (
f"транскрипт покрывает {covered} из {total}возможна потеря хвоста записи"
f"транскрипт покрывает {covered} из {total}"
"возможна потеря хвоста записи"
)
if is_batch:
console.print(f" {file_name}: {message}", style="yellow")
@@ -134,64 +128,6 @@ def _print_quality_warnings(
)
def _diarize_result(
file_path: Path,
result: TranscribeResult,
diarizer: SpeakerDiarizer,
on_status: StatusCallback,
) -> tuple[SpeakerTranscript | None, str | None, DiarizationRun | None]:
"""Запускает диаризацию и переводит ожидаемые сбои в деградацию вывода."""
try:
run = diarizer.process(file_path, on_status=on_status)
transcript = build_speaker_transcript(
result.words,
run.intervals,
result.duration,
)
if not run.intervals:
warning = "Диаризатор не нашёл интервалов при непустом распознавании"
elif transcript.cluster_count < 2:
warning = "Найден только один голосовой кластер"
else:
warning = None
return transcript, warning, run
except Exception as exc:
return None, f"Диаризация завершилась с ошибкой: {exc}", None
def _print_diarization_report(
transcript: SpeakerTranscript,
run: DiarizationRun,
verbose: bool,
file_name: str | None = None,
) -> None:
"""Печатает метрики verbose и обязательные предупреждения сведения."""
if verbose:
indent = " " if file_name is not None else ""
console.print(
f"{indent}Диаризация: {transcript.cluster_count} кластеров, "
f"{len(run.intervals)} интервалов, {run.elapsed_seconds:.1f} с"
)
warning_prefix = f" {file_name}: " if file_name is not None else "Внимание: "
if transcript.unassigned_word_count:
console.print(
f"{warning_prefix}{transcript.unassigned_word_count} слов "
"без назначенного говорящего",
style="yellow",
)
for cluster in transcript.small_clusters:
label = (
f"Speaker {cluster.speaker}"
if cluster.speaker is not None
else "кластер без номера"
)
console.print(
f"{warning_prefix}малый кластер {label}: {cluster.duration:.1f} с",
style="yellow",
)
@app.command()
def main(
files: list[Path] | None = typer.Argument(None, help="Пути к аудио/видеофайлам"),
@@ -205,54 +141,26 @@ def main(
language: str | None = typer.Option(
None, "--language", "-l", show_default=False, help="Язык [по умолч.: ru]"
),
output: Path | None = typer.Option(
None, "--output", "-o", help="Путь к выходному файлу"
),
output: Path | None = typer.Option(None, "--output", "-o", help="Путь к выходному файлу"),
device: str | None = typer.Option(
None,
"--device",
"-d",
show_default=False,
help="Устройство (auto|cpu|cuda|openvino|openvino-gpu|openvino-cpu|onnx) [по умолч.: auto]",
None, "--device", "-d", show_default=False,
help="Устройство (auto|cpu|cuda|openvino|openvino-gpu|openvino-cpu|onnx) [по умолч.: auto]"
),
compute_type: str | None = typer.Option(
None,
"--compute-type",
show_default=False,
None, "--compute-type", show_default=False,
help=(
"Тип вычислений [по умолч.: float16 (CUDA) / "
"int8 (ONNX/OpenVINO) / float32 (CPU)]"
),
),
threads: int = typer.Option(
0,
"--threads",
"-t",
show_default=False,
min=0,
help="Потоки CPU (0 = дефолт библиотеки; рекомендуется = число физ. ядер)",
),
diarize: bool = typer.Option(
False,
"--diarize",
help="Разделить транскрипт на реплики говорящих",
),
speakers: int | None = typer.Option(
None,
"--speakers",
min=1,
help="Известное число говорящих; автоматически включает --diarize",
0, "--threads", "-t", show_default=False, min=0,
help="Потоки CPU (0 = дефолт библиотеки; рекомендуется = число физ. ядер)"
),
verbose: bool = typer.Option(False, "--verbose", "-v", help="Подробный вывод"),
force: bool = typer.Option(
False, "--force", "-f", help="Перезаписать существующие транскрипты"
),
install_menu: bool = typer.Option(
False, "--install-menu", help="Установить пункт Transcribe в SendTo"
),
uninstall_menu: bool = typer.Option(
False, "--uninstall-menu", help="Удалить пункт Transcribe из SendTo"
),
force: bool = typer.Option(False, "--force", "-f", help="Перезаписать существующие транскрипты"),
install_menu: bool = typer.Option(False, "--install-menu", help="Установить пункт Transcribe в SendTo"),
uninstall_menu: bool = typer.Option(False, "--uninstall-menu", help="Удалить пункт Transcribe из SendTo"),
) -> None:
"""Транскрибирует аудио/видеофайлы в markdown с таймкодами.
@@ -262,31 +170,25 @@ def main(
if install_menu or uninstall_menu:
if install_menu and uninstall_menu:
console.print(
"--install-menu и --uninstall-menu несовместимы.", style="red bold"
)
console.print("--install-menu и --uninstall-menu несовместимы.", style="red bold")
raise SystemExit(2)
if files:
console.print(
"Флаги меню нельзя использовать вместе с файлами.", style="red bold"
)
console.print("Флаги меню нельзя использовать вместе с файлами.", style="red bold")
raise SystemExit(2)
if sys.platform != "win32":
console.print(
"Пункт меню SendTo доступен только на Windows.", style="red bold"
)
console.print("Пункт меню SendTo доступен только на Windows.", style="red bold")
raise SystemExit(1)
try:
if install_menu:
cmd_path = install_context_menu()
console.print(f'Пункт меню установлен: "{cmd_path}"', style="green")
console.print(f"Пункт меню установлен: \"{cmd_path}\"", style="green")
else:
cmd_path = uninstall_context_menu()
if cmd_path is None:
console.print("Пункт меню не был установлен.", style="yellow")
else:
console.print(f'Пункт меню удалён: "{cmd_path}"', style="green")
console.print(f"Пункт меню удалён: \"{cmd_path}\"", style="green")
except RuntimeError as exc:
console.print(f"Ошибка: {exc}", style="red bold")
raise SystemExit(1)
@@ -301,12 +203,7 @@ def main(
try:
config = load_config()
cli_values = {
"model": model,
"language": language,
"device": device,
"compute_type": compute_type,
}
cli_values = {"model": model, "language": language, "device": device, "compute_type": compute_type}
defaults = resolve_defaults(cli_values, config)
resolved_device = detect_device(defaults["device"])
@@ -321,33 +218,13 @@ def main(
is_batch = len(expanded) > 1
if is_batch and output is not None:
console.print(
"--output несовместим с несколькими файлами.", style="red bold"
)
console.print("--output несовместим с несколькими файлами.", style="red bold")
raise SystemExit(1)
if is_batch:
_run_batch(
expanded,
defaults,
verbose,
force,
ct_explicit,
cpu_threads=threads,
diarize=diarize or speakers is not None,
speakers=speakers,
)
_run_batch(expanded, defaults, verbose, force, ct_explicit, cpu_threads=threads)
else:
_run_single(
expanded[0],
defaults,
output,
verbose,
ct_explicit,
cpu_threads=threads,
diarize=diarize or speakers is not None,
speakers=speakers,
)
_run_single(expanded[0], defaults, output, verbose, ct_explicit, cpu_threads=threads)
except KeyboardInterrupt:
console.print("\nПрервано пользователем.", style="yellow")
raise SystemExit(130)
@@ -373,7 +250,9 @@ def main(
console.print_exception()
else:
console.print(f"Ошибка: {exc}", style="red bold")
console.print("Запустите с --verbose для полного traceback.", style="dim")
console.print(
"Запустите с --verbose для полного traceback.", style="dim"
)
raise SystemExit(1)
@@ -384,8 +263,6 @@ def _run_single(
verbose: bool,
compute_type_explicit: bool = False,
cpu_threads: int = 0,
diarize: bool = False,
speakers: int | None = None,
) -> None:
"""Пайплайн одного файла: валидация → модель → транскрипция → запись."""
start = time.monotonic()
@@ -402,18 +279,12 @@ def _run_single(
console.print(f" [{seg.start:.2f}s] {seg.text.strip()}")
model_obj, actual_device, backend, model_path = load_model(
defaults["model"],
resolved_device,
defaults["compute_type"],
on_status=lambda msg: console.print(msg),
strict_device=strict,
defaults["model"], resolved_device, defaults["compute_type"],
on_status=lambda msg: console.print(msg), strict_device=strict,
compute_type_explicit=compute_type_explicit,
cpu_threads=cpu_threads,
)
actual_ct = (
getattr(backend, "actual_compute_type", defaults["compute_type"])
or defaults["compute_type"]
)
actual_ct = getattr(backend, "actual_compute_type", defaults["compute_type"]) or defaults["compute_type"]
console.print(
f"Модель: [bold]{defaults['model']}[/bold] "
f"Устройство: [bold]{actual_device}[/bold] "
@@ -425,18 +296,6 @@ def _run_single(
style="dim",
)
speaker_diarizer = None
if diarize:
if not backend.word_timestamps_available:
raise ValueError(
"Выбранный движок или модель не поддерживает пословные таймкоды"
)
speaker_diarizer = load_speaker_diarizer(
speakers=speakers,
threads=cpu_threads,
on_status=lambda message: console.print(message),
)
with Status("Подготавливаю запуск...", console=console) as status:
tfr = _transcribe_file(
model=model_obj,
@@ -454,31 +313,6 @@ def _run_single(
)
result = tfr.result
speaker_transcript = None
diarization_warning = None
diarization_degraded = False
if speaker_diarizer is not None and result.segments:
with Status("Определяю говорящих...", console=console) as status:
speaker_transcript, diarization_warning, diarization_run = _diarize_result(
validated_file,
result,
speaker_diarizer,
on_status=(
(lambda message: console.print(message))
if verbose
else status.update
),
)
diarization_degraded = diarization_warning is not None
if diarization_run is not None and speaker_transcript is not None:
_print_diarization_report(
speaker_transcript,
diarization_run,
verbose,
)
if diarization_warning is not None:
console.print(f"Внимание: {diarization_warning}", style="yellow")
if tfr.actual_device != resolved_device:
if requested_device == "auto":
@@ -494,10 +328,9 @@ def _run_single(
)
if len(result.segments) == 0:
message = f"Речь не обнаружена в файле {validated_file.name}"
if speaker_diarizer is not None:
message += "; диаризация не запускалась"
console.print(message, style="yellow")
console.print(
f"Речь не обнаружена в файле {validated_file.name}", style="yellow"
)
device_info = _format_device_info(result.device_used)
language_mode = _format_language_mode(defaults["language"], result)
@@ -508,17 +341,13 @@ def _run_single(
model_name=defaults["model"],
device_info=device_info,
language_mode=language_mode,
speaker_transcript=speaker_transcript,
diarization_warning=diarization_warning,
)
write_transcript(content, output_path)
elapsed = time.monotonic() - start
console.print(f'Транскрипт сохранён: "{output_path}"', style="green")
console.print(f"Транскрипт сохранён: \"{output_path}\"", style="green")
console.print(f" Сегментов: {len(result.segments)} Время: {elapsed:.1f}с")
_print_quality_warnings(result)
if diarization_degraded:
raise SystemExit(1)
def _run_batch(
@@ -528,8 +357,6 @@ def _run_batch(
force: bool,
compute_type_explicit: bool = False,
cpu_threads: int = 0,
diarize: bool = False,
speakers: int | None = None,
) -> None:
"""Трёхфазный батч-пайплайн: prescan → загрузка модели → транскрипция."""
# Phase 1: Prescan — fail-fast + skip до загрузки модели (экономим ~2-5 сек)
@@ -552,7 +379,9 @@ def _run_batch(
to_process.append(validated)
if not to_process:
console.print(f"\nИтого: 0 обработано, {skipped} пропущено, {invalid} ошибок")
console.print(
f"\nИтого: 0 обработано, {skipped} пропущено, {invalid} ошибок"
)
if invalid > 0:
raise SystemExit(1)
return
@@ -562,11 +391,8 @@ def _run_batch(
resolved_device = detect_device(requested_device)
strict = requested_device != "auto"
model_obj, actual_device, backend, model_path = load_model(
defaults["model"],
resolved_device,
defaults["compute_type"],
on_status=lambda msg: console.print(msg),
strict_device=strict,
defaults["model"], resolved_device, defaults["compute_type"],
on_status=lambda msg: console.print(msg), strict_device=strict,
compute_type_explicit=compute_type_explicit,
cpu_threads=cpu_threads,
)
@@ -590,21 +416,8 @@ def _run_batch(
style="yellow",
)
speaker_diarizer = None
if diarize:
if not backend.word_timestamps_available:
raise ValueError(
"Выбранный движок или модель не поддерживает пословные таймкоды"
)
speaker_diarizer = load_speaker_diarizer(
speakers=speakers,
threads=cpu_threads,
on_status=lambda message: console.print(message),
)
# Phase 3: Transcribe
processed = 0
degraded = 0
failed = 0
batch_start = time.monotonic()
@@ -626,13 +439,9 @@ def _run_batch(
file_path=file,
model_name=defaults["model"],
compute_type=defaults["compute_type"],
language=defaults["language"]
if defaults["language"] != "auto"
else None,
language=defaults["language"] if defaults["language"] != "auto" else None,
on_segment=on_segment if verbose else None,
on_status=status.update
if not verbose
else lambda msg: console.print(msg),
on_status=status.update if not verbose else lambda msg: console.print(msg),
strict_device=strict,
cpu_threads=cpu_threads,
)
@@ -650,44 +459,11 @@ def _run_batch(
result = tfr.result
language_mode = _format_language_mode(defaults["language"], result)
speaker_transcript = None
diarization_warning = None
file_degraded = False
if speaker_diarizer is not None and result.segments:
with Status("Определяю говорящих...", console=console) as status:
speaker_transcript, diarization_warning, diarization_run = (
_diarize_result(
file,
result,
speaker_diarizer,
on_status=(
(lambda message: console.print(message))
if verbose
else status.update
),
)
)
file_degraded = diarization_warning is not None
if diarization_run is not None and speaker_transcript is not None:
_print_diarization_report(
speaker_transcript,
diarization_run,
verbose,
file_name=file.name,
)
if diarization_warning is not None:
console.print(
f" {file.name}: {diarization_warning}",
style="yellow",
)
if len(result.segments) == 0:
message = f" Речь не обнаружена: {file.name}"
if speaker_diarizer is not None:
message += "; диаризация не запускалась"
console.print(message, style="yellow")
console.print(
f" Речь не обнаружена: {file.name}", style="yellow"
)
device_info = _format_device_info(result.device_used)
@@ -697,8 +473,6 @@ def _run_batch(
model_name=defaults["model"],
device_info=device_info,
language_mode=language_mode,
speaker_transcript=speaker_transcript,
diarization_warning=diarization_warning,
)
write_transcript(content, build_output_path(file))
file_elapsed = time.monotonic() - file_start
@@ -708,8 +482,6 @@ def _run_batch(
style="green",
)
processed += 1
if file_degraded:
degraded += 1
_print_quality_warnings(result, file.name)
except KeyboardInterrupt:
raise
@@ -723,11 +495,10 @@ def _run_batch(
total_failed = invalid + failed
batch_elapsed = time.monotonic() - batch_start
console.print(
f"\nИтого: {processed} обработано, {skipped} пропущено, "
f"{degraded} с деградацией, {total_failed} ошибок"
f"\nИтого: {processed} обработано, {skipped} пропущено, {total_failed} ошибок"
f" Время: {batch_elapsed:.1f}с"
)
if total_failed > 0 or degraded > 0:
if total_failed > 0:
raise SystemExit(1)
-131
View File
@@ -1,131 +0,0 @@
"""Сведение слов с временной привязкой и разметки говорящих."""
from collections import defaultdict
from math import isclose
from unicodedata import category
from .types import (
SmallSpeakerCluster,
SpeakerInterval,
SpeakerTranscript,
SpeakerTurn,
Word,
)
_PAUSE_THRESHOLD_S = 2.0
_MAX_TURN_S = 60.0
def build_speaker_transcript(
words: list[Word],
intervals: list[SpeakerInterval],
recording_duration: float,
) -> SpeakerTranscript:
"""Назначает словам говорящих и собирает линейные реплики."""
cluster_numbers: dict[int, int] = {}
assigned: list[tuple[Word, int | None]] = []
unassigned = 0
for word in words:
speaker_cluster = _assign_cluster(word, intervals)
if speaker_cluster is None:
speaker = None
unassigned += 1
else:
speaker = cluster_numbers.setdefault(
speaker_cluster,
len(cluster_numbers) + 1,
)
assigned.append((word, speaker))
return SpeakerTranscript(
turns=_group_words(assigned),
cluster_count=len({interval.cluster for interval in intervals}),
unassigned_word_count=unassigned,
small_clusters=_find_small_clusters(
intervals,
cluster_numbers,
recording_duration,
),
)
def _assign_cluster(word: Word, intervals: list[SpeakerInterval]) -> int | None:
overlaps: defaultdict[int, float] = defaultdict(float)
for interval in intervals:
overlap = min(word.end, interval.end) - max(word.start, interval.start)
if overlap > 0:
overlaps[interval.cluster] += overlap
if not overlaps:
return None
largest = max(overlaps.values())
winners = [
cluster
for cluster, overlap in overlaps.items()
if isclose(overlap, largest, rel_tol=1e-9, abs_tol=1e-9)
]
return winners[0] if len(winners) == 1 else None
def _group_words(assigned: list[tuple[Word, int | None]]) -> list[SpeakerTurn]:
if not assigned:
return []
turns: list[SpeakerTurn] = []
first_word, current_speaker = assigned[0]
start = first_word.start
end = first_word.end
text = first_word.text
for word, speaker in assigned[1:]:
should_split = (
speaker != current_speaker
or word.start - end >= _PAUSE_THRESHOLD_S
or word.end - start > _MAX_TURN_S
)
if should_split:
turns.append(
SpeakerTurn(start, end, _normalize_turn_text(text), current_speaker)
)
start = word.start
text = word.text
current_speaker = speaker
else:
text = _append_word_text(text, word.text)
end = word.end
turns.append(SpeakerTurn(start, end, _normalize_turn_text(text), current_speaker))
return turns
def _append_word_text(current: str, word_text: str) -> str:
if not current or not word_text or word_text[:1].isspace():
return current + word_text
if category(word_text[0])[:1] in {"P", "S"}:
return current + word_text
return f"{current} {word_text}"
def _normalize_turn_text(text: str) -> str:
return " ".join(text.split())
def _find_small_clusters(
intervals: list[SpeakerInterval],
cluster_numbers: dict[int, int],
recording_duration: float,
) -> list[SmallSpeakerCluster]:
durations: defaultdict[int, float] = defaultdict(float)
for interval in intervals:
durations[interval.cluster] += max(0.0, interval.end - interval.start)
threshold = max(5.0, recording_duration * 0.02)
return [
SmallSpeakerCluster(
speaker=cluster_numbers.get(cluster),
duration=duration,
)
for cluster, duration in durations.items()
if duration < threshold
]
+1 -39
View File
@@ -5,7 +5,7 @@ from datetime import datetime
from pathlib import Path
from .quality import TAIL_GAP_WARN_S, find_repetition_blocks, tail_gap
from .types import Segment, SpeakerTranscript, TranscribeResult
from .types import Segment, TranscribeResult
_PAUSE_THRESHOLD_S = 2.0 # пауза между сегментами для разбиения на абзацы
_MAX_PARAGRAPH_S = 60.0 # максимальная длительность абзаца
@@ -88,18 +88,6 @@ def format_duration(seconds: float) -> str:
return f"{m:02d}:{s:02d}"
def _format_speaker_timestamp(seconds: float, use_hours: bool) -> str:
total_seconds = int(seconds)
if use_hours:
hours = total_seconds // 3600
minutes = (total_seconds % 3600) // 60
secs = total_seconds % 60
return f"{hours:02d}:{minutes:02d}:{secs:02d}"
minutes = total_seconds // 60
secs = total_seconds % 60
return f"{minutes:02d}:{secs:02d}"
def format_transcript(
result: TranscribeResult,
source_filename: str,
@@ -107,8 +95,6 @@ def format_transcript(
device_info: str,
language_mode: str, # см. LANGUAGE_MODES
transcription_date: datetime | None = None, # None -> datetime.now()
speaker_transcript: SpeakerTranscript | None = None,
diarization_warning: str | None = None,
) -> str:
"""Собирает markdown-транскрипт: шапка с метаданными + абзацы с таймкодами."""
date = transcription_date or datetime.now()
@@ -137,24 +123,6 @@ def format_transcript(
f"- **Внимание**: повторы в [{start} - {end}] ({block.count}×) "
"— возможны галлюцинации модели"
)
if speaker_transcript is not None:
lines.append(f"- **Голосовых кластеров**: {speaker_transcript.cluster_count}")
if speaker_transcript.unassigned_word_count:
lines.append(
"- **Внимание**: "
f"{speaker_transcript.unassigned_word_count} слов без назначенного говорящего"
)
for cluster in speaker_transcript.small_clusters:
label = (
f"Speaker {cluster.speaker}"
if cluster.speaker is not None
else "кластер без номера"
)
lines.append(
f"- **Внимание**: малый кластер {label}: {cluster.duration:.1f} с"
)
if diarization_warning is not None:
lines.append(f"- **Внимание**: {diarization_warning}")
lines.append(f"- **Устройство**: {device_info}")
lines.append("")
lines.append("---")
@@ -162,12 +130,6 @@ def format_transcript(
if not result.segments:
lines.append("")
lines.append("*Речь не обнаружена.*")
elif speaker_transcript is not None and speaker_transcript.cluster_count >= 2:
for turn in speaker_transcript.turns:
timestamp = _format_speaker_timestamp(turn.start, use_hours)
speaker = turn.speaker if turn.speaker is not None else "?"
lines.append("")
lines.append(f"[{timestamp}] Speaker {speaker}: {turn.text}")
else:
for para in _group_segments(result.segments):
start = format_timestamp(para.start, use_hours=use_hours)
-222
View File
@@ -1,222 +0,0 @@
"""Адаптер офлайн-диаризации через sherpa-onnx."""
import shutil
import tarfile
from hashlib import sha256
from pathlib import Path
from tempfile import NamedTemporaryFile
from time import perf_counter
from typing import Any
from .types import DiarizationRun, SpeakerInterval, StatusCallback
_SAMPLE_RATE = 16_000
_CLUSTERING_THRESHOLD = 0.89
_SEGMENTATION_FILENAME = "pyannote-segmentation-3.0.onnx"
_EMBEDDING_FILENAME = "wespeaker_en_voxceleb_resnet34_LM.onnx"
_SEGMENTATION_SHA256 = (
"220ad67ca923bef2fa91f2390c786097bf305bceb5e261d4af67b38e938e1079"
)
_EMBEDDING_SHA256 = "e9848563da86f263117134dfd7ad63c92355b37de492b55e325400c9d9c39012"
_SEGMENTATION_URL = (
"https://github.com/k2-fsa/sherpa-onnx/releases/download/"
"speaker-segmentation-models/"
"sherpa-onnx-pyannote-segmentation-3-0.tar.bz2"
)
_SEGMENTATION_ARCHIVE_MEMBER = "sherpa-onnx-pyannote-segmentation-3-0/model.onnx"
_EMBEDDING_URL = (
"https://github.com/k2-fsa/sherpa-onnx/releases/download/"
"speaker-recongition-models/wespeaker_en_voxceleb_resnet34_LM.onnx"
)
class SpeakerDiarizer:
"""Переиспользуемый в пределах команды диаризатор."""
def __init__(self, engine: Any):
self._engine = engine
def process(
self,
file_path: Path,
on_status: StatusCallback = None,
) -> DiarizationRun:
"""Строит разметку говорящих для одного файла."""
from faster_whisper import decode_audio
if on_status is not None:
on_status("Загружаю аудио для диаризации...")
samples = decode_audio(str(file_path), sampling_rate=_SAMPLE_RATE)
if isinstance(samples, tuple):
raise TypeError("Декодер неожиданно вернул раздельные стереоканалы")
if on_status is not None:
on_status("Определяю говорящих...")
started = perf_counter()
if on_status is None:
result = self._engine.process(samples)
else:
def report_progress(processed: int, total: int) -> int:
on_status(f"Определяю говорящих... {processed} / {total}")
return 0
result = self._engine.process(samples, report_progress)
elapsed = perf_counter() - started
intervals = [
SpeakerInterval(
start=float(segment.start),
end=float(segment.end),
cluster=int(segment.speaker),
)
for segment in result.sort_by_start_time()
]
return DiarizationRun(intervals=intervals, elapsed_seconds=elapsed)
def load_speaker_diarizer(
speakers: int | None,
threads: int = 0,
on_status: StatusCallback = None,
) -> SpeakerDiarizer:
"""Проверяет модели и создаёт batch-owned диаризатор."""
import sherpa_onnx
from huggingface_hub import cached_assets_path
cache_dir = cached_assets_path(
library_name="local-transcriber",
namespace="diarization",
subfolder="models-v1",
)
segmentation_path = cache_dir / _SEGMENTATION_FILENAME
embedding_path = cache_dir / _EMBEDDING_FILENAME
_ensure_cached_model(
segmentation_path,
_SEGMENTATION_SHA256,
_SEGMENTATION_URL,
on_status,
archive_member=_SEGMENTATION_ARCHIVE_MEMBER,
)
_ensure_cached_model(
embedding_path,
_EMBEDDING_SHA256,
_EMBEDDING_URL,
on_status,
)
if on_status is not None:
on_status("Инициализирую диаризатор...")
segmentation_kwargs: dict[str, Any] = {
"pyannote": sherpa_onnx.OfflineSpeakerSegmentationPyannoteModelConfig(
model=str(segmentation_path)
),
"provider": "cpu",
}
embedding_kwargs: dict[str, Any] = {
"model": str(embedding_path),
"provider": "cpu",
}
if threads > 0:
segmentation_kwargs["num_threads"] = threads
embedding_kwargs["num_threads"] = threads
config = sherpa_onnx.OfflineSpeakerDiarizationConfig(
segmentation=sherpa_onnx.OfflineSpeakerSegmentationModelConfig(
**segmentation_kwargs
),
embedding=sherpa_onnx.SpeakerEmbeddingExtractorConfig(**embedding_kwargs),
clustering=sherpa_onnx.FastClusteringConfig(
num_clusters=speakers if speakers is not None else -1,
threshold=_CLUSTERING_THRESHOLD,
),
min_duration_on=0.3,
min_duration_off=0.5,
)
if not config.validate():
raise RuntimeError("Конфигурация диаризатора недействительна")
engine = sherpa_onnx.OfflineSpeakerDiarization(config)
if engine.sample_rate != _SAMPLE_RATE:
raise RuntimeError(
f"Диаризатор ожидает частоту {engine.sample_rate} Гц вместо {_SAMPLE_RATE} Гц"
)
return SpeakerDiarizer(engine)
def _ensure_cached_model(
path: Path,
expected_sha256: str,
url: str,
on_status: StatusCallback,
archive_member: str | None = None,
) -> None:
if path.is_file() and _file_sha256(path) == expected_sha256:
return
import httpx
path.parent.mkdir(parents=True, exist_ok=True)
if on_status is not None:
on_status(f"Скачиваю модель диаризации {path.name}...")
download_path = _temporary_path(path)
extracted_path: Path | None = None
try:
with (
httpx.stream("GET", url, follow_redirects=True, timeout=60.0) as response,
download_path.open("wb") as output,
):
response.raise_for_status()
for chunk in response.iter_bytes():
output.write(chunk)
candidate = download_path
if archive_member is not None:
extracted_path = _temporary_path(path)
with tarfile.open(download_path, mode="r:bz2") as archive:
try:
member = archive.getmember(archive_member)
except KeyError as exc:
raise RuntimeError(
f"В архиве модели отсутствует {archive_member}"
) from exc
if not member.isfile():
raise RuntimeError(
f"Элемент архива модели не является файлом: {archive_member}"
)
source = archive.extractfile(member)
if source is None:
raise RuntimeError(f"Не удалось прочитать {archive_member}")
with source, extracted_path.open("wb") as output:
shutil.copyfileobj(source, output)
candidate = extracted_path
actual_sha256 = _file_sha256(candidate)
if actual_sha256 != expected_sha256:
raise RuntimeError(
f"Контрольная сумма модели {path.name} не совпала: {actual_sha256}"
)
candidate.replace(path)
finally:
download_path.unlink(missing_ok=True)
if extracted_path is not None:
extracted_path.unlink(missing_ok=True)
def _temporary_path(target: Path) -> Path:
with NamedTemporaryFile(
dir=target.parent,
prefix=f".{target.name}.",
suffix=".tmp",
delete=False,
) as temporary:
return Path(temporary.name)
def _file_sha256(path: Path) -> str:
digest = sha256()
with path.open("rb") as stream:
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
+14 -46
View File
@@ -12,7 +12,6 @@ from local_transcriber.types import ( # noqa: F401
Segment,
TranscribeFileResult,
TranscribeResult,
WordTimestampsUnavailableError,
)
@@ -38,36 +37,27 @@ def load_model(
try:
_notify_status(on_status, f"Инициализирую модель на {device}...")
model = backend.create_model(
model_path, device, compute_type, cpu_threads=cpu_threads
)
model = backend.create_model(model_path, device, compute_type, cpu_threads=cpu_threads)
# Резолвим actual_device по реальному OpenVINO device
ov_dev = getattr(backend, "actual_ov_device", None)
if ov_dev == "GPU" and actual_device != "openvino-gpu":
actual_device = "openvino-gpu"
elif (
ov_dev == "CPU"
and actual_device.startswith("openvino")
and actual_device != "openvino-cpu"
):
elif ov_dev == "CPU" and actual_device.startswith("openvino") and actual_device != "openvino-cpu":
actual_device = "openvino-cpu"
except (RuntimeError, ValueError) as exc:
if device != "cpu" and _is_backend_error(exc, device):
if strict_device:
raise
warnings.warn(
f"Не удалось загрузить модель на {device}: {exc}. Переключение на CPU.",
f"Не удалось загрузить модель на {device}: {exc}. "
"Переключение на CPU.",
stacklevel=2,
)
actual_device = "cpu"
backend = get_backend("cpu")
model_path = backend.ensure_model_available(
model_name, compute_type, on_status
)
model_path = backend.ensure_model_available(model_name, compute_type, on_status)
_notify_status(on_status, "Инициализирую модель на cpu...")
model = backend.create_model(
model_path, "cpu", compute_type, cpu_threads=cpu_threads
)
model = backend.create_model(model_path, "cpu", compute_type, cpu_threads=cpu_threads)
else:
raise
@@ -106,17 +96,11 @@ def _transcribe_file(
)
actual_device = "cpu"
backend = get_backend("cpu")
model_path = backend.ensure_model_available(
model_name, compute_type, on_status
)
model_path = backend.ensure_model_available(model_name, compute_type, on_status)
_notify_status(on_status, "Инициализирую модель на cpu...")
model = backend.create_model(
model_path, "cpu", compute_type, cpu_threads=cpu_threads
)
model = backend.create_model(model_path, "cpu", compute_type, cpu_threads=cpu_threads)
_notify_status(on_status, "Транскрибирую...")
result = backend.transcribe(
model, file_path, lang_arg, on_segment, on_status
)
result = backend.transcribe(model, file_path, lang_arg, on_segment, on_status)
result.device_used = actual_device
else:
raise
@@ -143,26 +127,14 @@ def transcribe(
) -> TranscribeResult:
"""High-level API: загрузка модели + транскрипция за один вызов."""
model, actual_device, backend, model_path = load_model(
model_name,
device,
compute_type,
on_status,
strict_device,
model_name, device, compute_type, on_status, strict_device,
compute_type_explicit=True, # Python API — caller explicitly chose compute_type
cpu_threads=cpu_threads,
)
tfr = _transcribe_file(
model,
actual_device,
backend,
model_path,
file_path,
model_name,
compute_type,
language,
on_segment,
on_status,
strict_device,
model, actual_device, backend, model_path,
file_path, model_name, compute_type,
language, on_segment, on_status, strict_device,
cpu_threads=cpu_threads,
)
return tfr.result
@@ -179,9 +151,7 @@ def ensure_model_available(
if compute_type is None:
device_defs = DEVICE_DEFAULTS.get(device, {})
compute_type = device_defs.get(
"compute_type", HARDCODED_DEFAULTS["compute_type"]
)
compute_type = device_defs.get("compute_type", HARDCODED_DEFAULTS["compute_type"])
explicit = False
else:
explicit = True
@@ -197,8 +167,6 @@ def _is_cuda_error(exc: BaseException) -> bool:
def _is_backend_error(exc: BaseException, device: str) -> bool:
"""Определяет, связана ли ошибка с конкретным бэкендом (а не с пользовательскими данными)."""
if isinstance(exc, WordTimestampsUnavailableError):
return False
if device in ("cuda", "cpu"):
return _is_cuda_error(exc)
if device.startswith("openvino"):
+1 -60
View File
@@ -1,17 +1,13 @@
"""Общие типы данных для всех бэкендов транскрипции."""
from collections.abc import Callable
from dataclasses import dataclass, field
from dataclasses import dataclass
from typing import Any
# Единый признак «язык неизвестен» для всех бэкендов
UNKNOWN_LANGUAGE = "unknown"
class WordTimestampsUnavailableError(RuntimeError):
"""ASR распознал текст, но нарушил обязательный пословный контракт."""
@dataclass
class Segment:
start: float # seconds
@@ -19,60 +15,6 @@ class Segment:
text: str
@dataclass(frozen=True)
class Word:
"""Слово с временной привязкой на шкале исходной записи."""
start: float
end: float
text: str
@dataclass(frozen=True)
class SpeakerInterval:
"""Интервал разметки говорящих с анонимным голосовым кластером."""
start: float
end: float
cluster: int
@dataclass(frozen=True)
class SpeakerTurn:
"""Реплика говорящего; ``speaker=None`` означает неизвестного говорящего."""
start: float
end: float
text: str
speaker: int | None
@dataclass(frozen=True)
class SmallSpeakerCluster:
"""Малый голосовой кластер, о котором нужно предупредить пользователя."""
speaker: int | None
duration: float
@dataclass
class SpeakerTranscript:
"""Результат сведения слов с разметкой говорящих."""
turns: list[SpeakerTurn]
cluster_count: int
unassigned_word_count: int
small_clusters: list[SmallSpeakerCluster]
@dataclass
class DiarizationRun:
"""Разметка одного файла и длительность прохода диаризации."""
intervals: list[SpeakerInterval]
elapsed_seconds: float
@dataclass
class TranscribeResult:
segments: list[Segment]
@@ -80,7 +22,6 @@ class TranscribeResult:
language_probability: float
duration: float # seconds
device_used: str # "cpu" / "cuda" / "onnx" / "openvino-gpu" / "openvino-cpu"
words: list[Word] = field(default_factory=list)
@dataclass
-66
View File
@@ -1,66 +0,0 @@
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
from local_transcriber.backends.faster_whisper import FasterWhisperBackend
from local_transcriber.types import Word
def test_transcribe_returns_canonical_words(tmp_path):
audio = tmp_path / "audio.wav"
raw_word = SimpleNamespace(start=0.2, end=0.7, word=" Привет")
raw_segment = SimpleNamespace(
start=0.0,
end=1.0,
text=" Привет",
words=[raw_word],
)
info = SimpleNamespace(duration=1.0, language="ru", language_probability=0.99)
model = MagicMock()
model.transcribe.return_value = (iter([raw_segment]), info)
result = FasterWhisperBackend().transcribe(model, audio, language="ru")
assert result.words == [Word(start=0.2, end=0.7, text=" Привет")]
model.transcribe.assert_called_once_with(
str(audio),
language="ru",
word_timestamps=True,
)
def test_transcribe_rejects_nonempty_result_without_word_timestamps(tmp_path):
raw_segment = SimpleNamespace(
start=0.0,
end=1.0,
text=" Текст есть",
words=None,
)
info = SimpleNamespace(duration=1.0, language="ru", language_probability=1.0)
model = MagicMock()
model.transcribe.return_value = (iter([raw_segment]), info)
with pytest.raises(RuntimeError, match="пословные таймкоды"):
FasterWhisperBackend().transcribe(model, tmp_path / "audio.wav", "ru")
def test_transcribe_rejects_one_nonempty_segment_without_word_timestamps(tmp_path):
timestamped = SimpleNamespace(
start=0.0,
end=1.0,
text=" Первое",
words=[SimpleNamespace(start=0.0, end=1.0, word=" Первое")],
)
missing = SimpleNamespace(
start=1.0,
end=2.0,
text=" Второе",
words=None,
)
info = SimpleNamespace(duration=2.0, language="ru", language_probability=1.0)
model = MagicMock()
model.transcribe.return_value = (iter([timestamped, missing]), info)
with pytest.raises(RuntimeError, match="пословные таймкоды"):
FasterWhisperBackend().transcribe(model, tmp_path / "audio.wav", "ru")
+20 -140
View File
@@ -11,7 +11,8 @@ from local_transcriber.backends.openvino import (
OpenVINOBackend,
_validate_model_dir,
)
from local_transcriber.types import UNKNOWN_LANGUAGE, Segment, Word
from local_transcriber.types import UNKNOWN_LANGUAGE, Segment
# === _resolve_repo ===
@@ -27,18 +28,12 @@ def test_model_catalog_contains_large_v3_turbo_profiles():
def test_resolve_repo_exact_match():
backend = OpenVINOBackend(compute_type_explicit=True)
assert backend._resolve_repo("medium", "int8") == (
"OpenVINO/whisper-medium-int8-ov",
"int8",
)
assert backend._resolve_repo("medium", "int8") == ("OpenVINO/whisper-medium-int8-ov", "int8")
def test_resolve_repo_large_v3_fp16():
backend = OpenVINOBackend(compute_type_explicit=True)
assert backend._resolve_repo("large-v3", "fp16") == (
"OpenVINO/whisper-large-v3-fp16-ov",
"fp16",
)
assert backend._resolve_repo("large-v3", "fp16") == ("OpenVINO/whisper-large-v3-fp16-ov", "fp16")
def test_resolve_repo_explicit_unsupported_pair_raises():
@@ -58,17 +53,16 @@ def test_resolve_repo_implicit_fallback():
"""Неявный compute_type: если int8 недоступен для base, fallback на fp16."""
backend = OpenVINOBackend(compute_type_explicit=False)
# base + int8 не существует, но base + fp16 есть
assert backend._resolve_repo("base", "int8") == (
"OpenVINO/whisper-base-fp16-ov",
"fp16",
)
assert backend._resolve_repo("base", "int8") == ("OpenVINO/whisper-base-fp16-ov", "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):
def test_resolve_repo_implicit_large_v3_profiles(
model_name, expected_compute_type
):
"""Неявный compute_type различает обычную и turbo-модель."""
backend = OpenVINOBackend(compute_type_explicit=False)
@@ -81,10 +75,7 @@ def test_resolve_repo_implicit_large_v3_profiles(model_name, expected_compute_ty
def test_resolve_repo_explicit_large_v3_int8_respected():
"""Явный --compute-type int8 для large-v3 → уважается."""
backend = OpenVINOBackend(compute_type_explicit=True)
assert backend._resolve_repo("large-v3", "int8") == (
"OpenVINO/whisper-large-v3-int8-ov",
"int8",
)
assert backend._resolve_repo("large-v3", "int8") == ("OpenVINO/whisper-large-v3-int8-ov", "int8")
@pytest.mark.parametrize("compute_type", ["int8", "fp16"])
@@ -116,7 +107,6 @@ def test_ensure_model_available_cache_hit(mock_download, tmp_path):
model_dir.mkdir()
(model_dir / "openvino_encoder_model.xml").write_text("<xml/>")
(model_dir / "openvino_decoder_model.xml").write_text("<xml/>")
(model_dir / "generation_config.json").write_text('{"alignment_heads": [[1, 2]]}')
mock_download.return_value = str(model_dir)
backend = OpenVINOBackend(compute_type_explicit=True)
@@ -135,7 +125,6 @@ def test_ensure_model_available_downloads(mock_download, tmp_path):
model_dir.mkdir()
(model_dir / "openvino_encoder_model.xml").write_text("<xml/>")
(model_dir / "openvino_decoder_model.xml").write_text("<xml/>")
(model_dir / "generation_config.json").write_text('{"alignment_heads": [[1, 2]]}')
mock_download.side_effect = [
LocalEntryNotFoundError("not cached"),
@@ -156,7 +145,6 @@ def test_large_v3_turbo_model_is_resolved_and_created(mock_download, tmp_path):
model_dir.mkdir()
(model_dir / "openvino_encoder_model.xml").write_text("<xml/>")
(model_dir / "openvino_decoder_model.xml").write_text("<xml/>")
(model_dir / "generation_config.json").write_text('{"alignment_heads": [[1, 2]]}')
mock_download.return_value = str(model_dir)
mock_ov = MagicMock()
@@ -169,28 +157,12 @@ def test_large_v3_turbo_model_is_resolved_and_created(mock_download, tmp_path):
"OpenVINO/whisper-large-v3-turbo-int8-ov",
local_files_only=True,
)
mock_ov.WhisperPipeline.assert_called_once_with(
str(model_dir), "CPU", word_timestamps=True
)
mock_ov.WhisperPipeline.assert_called_once_with(str(model_dir), "CPU")
# === create_model ===
def test_create_model_enables_word_timestamps():
mock_ov = MagicMock()
backend = OpenVINOBackend(ov_device="openvino-cpu")
with patch.dict("sys.modules", {"openvino_genai": mock_ov}):
backend.create_model("/path/to/model", "openvino-cpu", "int8")
mock_ov.WhisperPipeline.assert_called_once_with(
"/path/to/model",
"CPU",
word_timestamps=True,
)
def test_create_model_cpu():
mock_ov = MagicMock()
mock_pipeline = MagicMock()
@@ -200,9 +172,7 @@ def test_create_model_cpu():
with patch.dict("sys.modules", {"openvino_genai": mock_ov}):
model = backend.create_model("/path/to/model", "openvino-cpu", "int8")
mock_ov.WhisperPipeline.assert_called_once_with(
"/path/to/model", "CPU", word_timestamps=True
)
mock_ov.WhisperPipeline.assert_called_once_with("/path/to/model", "CPU")
assert model is mock_pipeline
assert backend.actual_ov_device == "CPU"
@@ -216,9 +186,7 @@ def test_create_model_gpu():
with patch.dict("sys.modules", {"openvino_genai": mock_ov}):
model = backend.create_model("/path/to/model", "openvino-gpu", "fp16")
mock_ov.WhisperPipeline.assert_called_once_with(
"/path/to/model", "GPU", word_timestamps=True
)
mock_ov.WhisperPipeline.assert_called_once_with("/path/to/model", "GPU")
assert model is mock_pipeline
assert backend.actual_ov_device == "GPU"
@@ -234,16 +202,11 @@ def test_create_model_openvino_auto_detects_gpu():
backend = OpenVINOBackend(ov_device="openvino")
with (
patch.dict(
"sys.modules",
{"openvino_genai": mock_ov, "openvino": MagicMock(Core=mock_core)},
),
patch.dict("sys.modules", {"openvino_genai": mock_ov, "openvino": MagicMock(Core=mock_core)}),
):
backend.create_model("/path/to/model", "openvino", "int8")
model = backend.create_model("/path/to/model", "openvino", "int8")
mock_ov.WhisperPipeline.assert_called_once_with(
"/path/to/model", "GPU", word_timestamps=True
)
mock_ov.WhisperPipeline.assert_called_once_with("/path/to/model", "GPU")
assert backend.actual_ov_device == "GPU"
@@ -258,16 +221,11 @@ def test_create_model_openvino_auto_falls_back_to_cpu():
backend = OpenVINOBackend(ov_device="openvino")
with (
patch.dict(
"sys.modules",
{"openvino_genai": mock_ov, "openvino": MagicMock(Core=mock_core)},
),
patch.dict("sys.modules", {"openvino_genai": mock_ov, "openvino": MagicMock(Core=mock_core)}),
):
backend.create_model("/path/to/model", "openvino", "int8")
model = backend.create_model("/path/to/model", "openvino", "int8")
mock_ov.WhisperPipeline.assert_called_once_with(
"/path/to/model", "CPU", word_timestamps=True
)
mock_ov.WhisperPipeline.assert_called_once_with("/path/to/model", "CPU")
assert backend.actual_ov_device == "CPU"
@@ -290,19 +248,13 @@ def test_transcribe_maps_chunks_to_segments():
mock_result = MagicMock()
mock_result.chunks = [chunk1, chunk2]
mock_result.words = [
MagicMock(start_ts=0.0, end_ts=3.5, word=" Привет мир"),
MagicMock(start_ts=3.5, end_ts=7.0, word=" Тестовый сегмент"),
]
mock_model.generate.return_value = mock_result
raw_audio = np.zeros(16000 * 10, dtype=np.float32) # 10 секунд
with patch("faster_whisper.decode_audio", return_value=raw_audio):
result = backend.transcribe(
mock_model,
Path("test.mp3"),
language="ru",
mock_model, Path("test.mp3"), language="ru",
)
assert len(result.segments) == 2
@@ -317,61 +269,6 @@ def test_transcribe_maps_chunks_to_segments():
assert call_kwargs.kwargs["return_timestamps"] is True
def test_transcribe_maps_word_level_timestamps():
backend = OpenVINOBackend()
mock_model = MagicMock()
raw_word = MagicMock()
raw_word.start_ts = 0.2
raw_word.end_ts = 0.8
raw_word.word = " Привет"
mock_result = MagicMock()
mock_result.chunks = []
mock_result.words = [raw_word]
mock_model.generate.return_value = mock_result
with patch(
"faster_whisper.decode_audio",
return_value=np.zeros(16_000, dtype=np.float32),
):
result = backend.transcribe(mock_model, Path("test.mp3"), language="ru")
assert result.words == [Word(start=0.2, end=0.8, text=" Привет")]
assert mock_model.generate.call_args.kwargs["word_timestamps"] is True
def test_transcribe_keeps_zero_duration_word_timestamp():
backend = OpenVINOBackend()
mock_model = MagicMock()
raw_word = MagicMock(start_ts=1.0, end_ts=1.0, word=" Слово")
mock_result = MagicMock(chunks=[], words=[raw_word])
mock_model.generate.return_value = mock_result
with patch(
"faster_whisper.decode_audio",
return_value=np.zeros(16_000, dtype=np.float32),
):
result = backend.transcribe(mock_model, Path("test.mp3"), language="ru")
assert result.words == [Word(start=1.0, end=1.0, text=" Слово")]
def test_transcribe_rejects_nonempty_result_without_word_timestamps():
backend = OpenVINOBackend()
chunk = MagicMock(start_ts=0.0, end_ts=1.0, text=" Текст")
mock_result = MagicMock(chunks=[chunk], words=None)
mock_model = MagicMock()
mock_model.generate.return_value = mock_result
with (
patch(
"faster_whisper.decode_audio",
return_value=np.zeros(16_000, dtype=np.float32),
),
pytest.raises(RuntimeError, match="пословные таймкоды"),
):
backend.transcribe(mock_model, Path("test.mp3"), language="ru")
def test_transcribe_calls_tolist():
"""raw_speech передаётся как list, не ndarray."""
backend = OpenVINOBackend()
@@ -417,7 +314,6 @@ def test_transcribe_calls_on_segment():
chunk.text = " Test"
mock_result = MagicMock()
mock_result.chunks = [chunk]
mock_result.words = [MagicMock(start_ts=0.0, end_ts=2.0, word=" Test")]
mock_model.generate.return_value = mock_result
raw_audio = np.zeros(16000, dtype=np.float32)
@@ -425,10 +321,7 @@ def test_transcribe_calls_on_segment():
with patch("faster_whisper.decode_audio", return_value=raw_audio):
backend.transcribe(
mock_model,
Path("test.mp3"),
language="en",
on_segment=callback,
mock_model, Path("test.mp3"), language="en", on_segment=callback,
)
callback.assert_called_once()
@@ -443,7 +336,6 @@ def test_transcribe_calls_on_segment():
def test_validate_model_dir_ok(tmp_path):
(tmp_path / "openvino_encoder_model.xml").write_text("<xml/>")
(tmp_path / "openvino_decoder_model.xml").write_text("<xml/>")
(tmp_path / "generation_config.json").write_text('{"alignment_heads": [[1, 2]]}')
_validate_model_dir(tmp_path) # should not raise
@@ -451,15 +343,3 @@ def test_validate_model_dir_missing(tmp_path):
(tmp_path / "openvino_encoder_model.xml").write_text("<xml/>")
with pytest.raises(ValueError, match="openvino_decoder_model.xml"):
_validate_model_dir(tmp_path)
def test_validate_model_dir_requires_alignment_heads_for_word_timestamps(tmp_path):
(tmp_path / "openvino_encoder_model.xml").write_text("<xml/>")
(tmp_path / "openvino_decoder_model.xml").write_text("<xml/>")
(tmp_path / "generation_config.json").write_text(
'{"alignment_heads": []}',
encoding="utf-8",
)
with pytest.raises(ValueError, match="alignment_heads"):
_validate_model_dir(tmp_path)
+64 -580
View File
@@ -12,26 +12,15 @@ from local_transcriber.formatter import (
LANGUAGE_FROM_MODEL,
LANGUAGE_UNKNOWN,
)
from local_transcriber.transcriber import (
Segment,
TranscribeFileResult,
TranscribeResult,
)
from local_transcriber.types import (
UNKNOWN_LANGUAGE,
DiarizationRun,
SpeakerInterval,
Word,
)
from local_transcriber.transcriber import Segment, TranscribeFileResult, TranscribeResult
from local_transcriber.types import UNKNOWN_LANGUAGE
runner = CliRunner()
def _make_result(segments=None, language="ru", device_used="cpu", duration=60.0):
return TranscribeResult(
segments=[Segment(start=0.0, end=2.0, text="Hello")]
if segments is None
else segments,
segments=[Segment(start=0.0, end=2.0, text="Hello")] if segments is None else segments,
language=language,
language_probability=0.95,
duration=duration,
@@ -47,13 +36,7 @@ def _make_backend():
return MagicMock(name="Backend")
def _make_tfr(
result=None,
model=None,
actual_device="cpu",
backend=None,
model_path="/models/medium",
):
def _make_tfr(result=None, model=None, actual_device="cpu", backend=None, model_path="/models/medium"):
if result is None:
result = _make_result()
if model is None:
@@ -61,11 +44,8 @@ def _make_tfr(
if backend is None:
backend = _make_backend()
return TranscribeFileResult(
result=result,
model=model,
actual_device=actual_device,
backend=backend,
model_path=model_path,
result=result, model=model, actual_device=actual_device,
backend=backend, model_path=model_path,
)
@@ -78,7 +58,9 @@ def _make_tfr(
("auto", UNKNOWN_LANGUAGE, 0.0, LANGUAGE_UNKNOWN),
],
)
def test_format_language_mode(requested_language, language, probability, expected):
def test_format_language_mode(
requested_language, language, probability, expected
):
result = _make_result(language=language)
result.language_probability = probability
@@ -91,17 +73,12 @@ def _single_patches(result=None, tmp_file=None, actual_device="cpu"):
result = _make_result(device_used=actual_device)
model = _make_model()
backend = _make_backend()
tfr = _make_tfr(
result=result, model=model, actual_device=actual_device, backend=backend
)
tfr = _make_tfr(result=result, model=model, actual_device=actual_device, backend=backend)
return [
patch("local_transcriber.cli.load_config", return_value={}),
patch("local_transcriber.cli.validate_input_file", return_value=tmp_file),
patch("local_transcriber.cli.detect_device", return_value=actual_device),
patch(
"local_transcriber.cli.load_model",
return_value=(model, actual_device, backend, "/models/medium"),
),
patch("local_transcriber.cli.load_model", return_value=(model, actual_device, backend, "/models/medium")),
patch("local_transcriber.cli._transcribe_file", return_value=tfr),
patch("local_transcriber.cli.write_transcript"),
]
@@ -162,28 +139,18 @@ def test_cli_custom_options(tmp_path):
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="cuda"),
patch(
"local_transcriber.cli.load_model",
return_value=(model, "cuda", backend, "/models/small"),
),
patch("local_transcriber.cli.load_model", return_value=(model, "cuda", backend, "/models/small")),
patch("local_transcriber.cli._transcribe_file", mock_transcribe_file),
patch("local_transcriber.cli.write_transcript"),
patch("local_transcriber.cli.get_gpu_name", return_value="RTX 3060"),
):
runner.invoke(
app,
[
str(audio),
"--model",
"small",
"--language",
"ru",
"--device",
"cuda",
"--compute-type",
"float16",
],
)
runner.invoke(app, [
str(audio),
"--model", "small",
"--language", "ru",
"--device", "cuda",
"--compute-type", "float16",
])
call_kwargs = mock_transcribe_file.call_args[1]
assert call_kwargs["model_name"] == "small"
@@ -191,266 +158,6 @@ def test_cli_custom_options(tmp_path):
assert call_kwargs["compute_type"] == "float16"
def test_cli_speakers_enables_diarization_and_writes_speaker_markdown(tmp_path):
audio = tmp_path / "meeting.mp3"
audio.write_bytes(b"fake")
result = _make_result(
segments=[Segment(0.0, 1.3, "Первый. Второй. Неясно.")],
duration=10.0,
)
result.words = [
Word(0.0, 0.5, "Первый."),
Word(0.5, 1.0, "Второй."),
Word(1.1, 1.3, "Неясно."),
]
model = _make_model()
backend = _make_backend()
backend.word_timestamps_available = True
tfr = _make_tfr(result=result, model=model, backend=backend)
diarizer = MagicMock()
diarizer.process.return_value = DiarizationRun(
intervals=[
SpeakerInterval(0.0, 0.5, 10),
SpeakerInterval(0.5, 1.0, 20),
],
elapsed_seconds=0.2,
)
write = MagicMock()
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._transcribe_file", return_value=tfr),
patch(
"local_transcriber.cli.load_speaker_diarizer",
return_value=diarizer,
) as load_diarizer,
patch("local_transcriber.cli.write_transcript", write),
):
out = runner.invoke(
app,
[str(audio), "--speakers", "2", "--threads", "3"],
)
assert out.exit_code == 0
load_diarizer.assert_called_once()
assert load_diarizer.call_args.kwargs["speakers"] == 2
assert load_diarizer.call_args.kwargs["threads"] == 3
diarizer.process.assert_called_once()
assert "Speaker 1: Первый." in write.call_args.args[0]
assert "Speaker 2: Второй." in write.call_args.args[0]
assert "Speaker ?: Неясно." in write.call_args.args[0]
assert "1 слов без назначенного говорящего" in out.output
assert "малый кластер Speaker 1: 0.5 с" in out.output
def test_cli_diarization_error_writes_plain_transcript_and_exits_nonzero(tmp_path):
audio = tmp_path / "meeting.mp3"
audio.write_bytes(b"fake")
result = _make_result(
segments=[Segment(0.0, 1.0, "Полезный текст.")],
duration=10.0,
)
result.words = [Word(0.0, 1.0, "Полезный текст.")]
model = _make_model()
backend = _make_backend()
backend.word_timestamps_available = True
tfr = _make_tfr(result=result, model=model, backend=backend)
diarizer = MagicMock()
diarizer.process.side_effect = RuntimeError("boom")
write = MagicMock()
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._transcribe_file", return_value=tfr),
patch(
"local_transcriber.cli.load_speaker_diarizer",
return_value=diarizer,
),
patch("local_transcriber.cli.write_transcript", write),
):
out = runner.invoke(app, [str(audio), "--diarize"])
assert out.exit_code == 1
assert write.call_count == 1
assert "Полезный текст." in write.call_args.args[0]
assert "Диаризация завершилась с ошибкой: boom" in write.call_args.args[0]
def test_cli_verbose_reports_diarization_counts_and_duration(tmp_path):
audio = tmp_path / "meeting.mp3"
audio.write_bytes(b"fake")
result = _make_result(
segments=[Segment(0.0, 1.0, "Раз два")],
duration=10.0,
)
result.words = [Word(0.0, 0.5, "Раз"), Word(0.5, 1.0, "два")]
model = _make_model()
backend = _make_backend()
backend.word_timestamps_available = True
tfr = _make_tfr(result=result, model=model, backend=backend)
diarizer = MagicMock()
diarizer.process.return_value = DiarizationRun(
intervals=[
SpeakerInterval(0.0, 0.5, 1),
SpeakerInterval(0.5, 1.0, 2),
],
elapsed_seconds=0.2,
)
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._transcribe_file", return_value=tfr),
patch(
"local_transcriber.cli.load_speaker_diarizer",
return_value=diarizer,
),
patch("local_transcriber.cli.write_transcript"),
):
out = runner.invoke(app, [str(audio), "--diarize", "--verbose"])
assert out.exit_code == 0
assert "2 кластеров, 2 интервалов" in out.output
assert "0.2 с" in out.output
def test_cli_empty_asr_skips_diarizer_and_reports_it(tmp_path):
audio = tmp_path / "silence.wav"
audio.write_bytes(b"fake")
result = _make_result(segments=[])
model = _make_model()
backend = _make_backend()
backend.word_timestamps_available = True
tfr = _make_tfr(result=result, model=model, backend=backend)
diarizer = MagicMock()
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._transcribe_file", return_value=tfr),
patch(
"local_transcriber.cli.load_speaker_diarizer",
return_value=diarizer,
),
patch("local_transcriber.cli.write_transcript"),
):
out = runner.invoke(app, [str(audio), "--diarize"])
assert out.exit_code == 0
diarizer.process.assert_not_called()
assert "диаризация не запускалась" in out.output
def test_cli_diarizer_preflight_failure_does_not_start_asr_or_write(tmp_path):
audio = tmp_path / "meeting.mp3"
audio.write_bytes(b"fake")
model = _make_model()
backend = _make_backend()
backend.word_timestamps_available = True
transcribe_file = MagicMock()
write = MagicMock()
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._transcribe_file", transcribe_file),
patch(
"local_transcriber.cli.load_speaker_diarizer",
side_effect=RuntimeError("модель повреждена"),
),
patch("local_transcriber.cli.write_transcript", write),
):
out = runner.invoke(app, [str(audio), "--diarize"])
assert out.exit_code == 1
transcribe_file.assert_not_called()
write.assert_not_called()
@pytest.mark.parametrize(
("intervals", "warning"),
[
([SpeakerInterval(0.0, 1.0, 1)], "только один голосовой кластер"),
([], "не нашёл интервалов"),
],
)
def test_cli_unsuccessful_diarization_shape_writes_plain_text_and_exits_nonzero(
tmp_path, intervals, warning
):
audio = tmp_path / "meeting.mp3"
audio.write_bytes(b"fake")
result = _make_result(
segments=[Segment(0.0, 1.0, "Раз два")],
duration=10.0,
)
result.words = [Word(0.0, 0.5, "Раз"), Word(0.5, 1.0, "два")]
model = _make_model()
backend = _make_backend()
backend.word_timestamps_available = True
tfr = _make_tfr(result=result, model=model, backend=backend)
diarizer = MagicMock()
diarizer.process.return_value = DiarizationRun(intervals, elapsed_seconds=0.1)
write = MagicMock()
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._transcribe_file", return_value=tfr),
patch(
"local_transcriber.cli.load_speaker_diarizer",
return_value=diarizer,
),
patch("local_transcriber.cli.write_transcript", write),
):
out = runner.invoke(app, [str(audio), "--diarize"])
assert out.exit_code == 1
content = write.call_args.args[0]
assert warning in content
assert "[00:00.00 - 00:01.00] Раз два" in content
def test_cli_rejects_nonpositive_speaker_count(tmp_path):
audio = tmp_path / "meeting.mp3"
audio.write_bytes(b"fake")
out = runner.invoke(app, [str(audio), "--speakers", "0"])
assert out.exit_code == 2
def test_cli_verbose_passes_on_segment_callback(tmp_path):
audio = tmp_path / "test.mp3"
audio.write_bytes(b"fake")
@@ -464,10 +171,7 @@ def test_cli_verbose_passes_on_segment_callback(tmp_path):
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.load_model", return_value=(model, "cpu", backend, "/models/medium")),
patch("local_transcriber.cli._transcribe_file", mock_transcribe_file),
patch("local_transcriber.cli.write_transcript"),
):
@@ -505,10 +209,7 @@ def test_cli_default_output_path(tmp_path):
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.load_model", return_value=(model, "cpu", backend, "/models/medium")),
patch("local_transcriber.cli._transcribe_file", return_value=tfr),
patch("local_transcriber.cli.write_transcript", mock_write),
):
@@ -533,10 +234,7 @@ def test_cli_custom_output_path(tmp_path):
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.load_model", return_value=(model, "cpu", backend, "/models/medium")),
patch("local_transcriber.cli._transcribe_file", return_value=tfr),
patch("local_transcriber.cli.write_transcript", mock_write),
):
@@ -559,10 +257,7 @@ def test_cli_passes_status_callback_to_transcribe(tmp_path):
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.load_model", return_value=(model, "cpu", backend, "/models/medium")),
patch("local_transcriber.cli._transcribe_file", mock_transcribe_file),
patch("local_transcriber.cli.write_transcript"),
):
@@ -581,9 +276,7 @@ def test_cli_load_model_called_with_model_name(tmp_path):
model = _make_model()
backend = _make_backend()
tfr = _make_tfr(result=result, model=model, backend=backend)
mock_load_model = MagicMock(
return_value=(model, "cpu", backend, "/models/large-v3")
)
mock_load_model = MagicMock(return_value=(model, "cpu", backend, "/models/large-v3"))
with (
patch("local_transcriber.cli.load_config", return_value={}),
@@ -609,14 +302,8 @@ def test_cli_windows_cuda_diagnostic(tmp_path):
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="cuda"),
patch(
"local_transcriber.cli.load_model",
return_value=(model, "cuda", backend, "/models/medium"),
),
patch(
"local_transcriber.cli._transcribe_file",
side_effect=RuntimeError("CUDA error: no device"),
),
patch("local_transcriber.cli.load_model", return_value=(model, "cuda", backend, "/models/medium")),
patch("local_transcriber.cli._transcribe_file", side_effect=RuntimeError("CUDA error: no device")),
patch("local_transcriber.cli.sys") as mock_sys,
):
mock_sys.platform = "win32"
@@ -638,14 +325,8 @@ def test_cli_linux_cuda_error_no_windows_hint(tmp_path):
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="cuda"),
patch(
"local_transcriber.cli.load_model",
return_value=(model, "cuda", backend, "/models/medium"),
),
patch(
"local_transcriber.cli._transcribe_file",
side_effect=RuntimeError("CUDA error: no device"),
),
patch("local_transcriber.cli.load_model", return_value=(model, "cuda", backend, "/models/medium")),
patch("local_transcriber.cli._transcribe_file", side_effect=RuntimeError("CUDA error: no device")),
patch("local_transcriber.cli.sys") as mock_sys,
):
mock_sys.platform = "linux"
@@ -668,10 +349,7 @@ def test_cli_device_fallback_warning(tmp_path):
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="cuda"),
patch(
"local_transcriber.cli.load_model",
return_value=(model, "cuda", backend, "/models/medium"),
),
patch("local_transcriber.cli.load_model", return_value=(model, "cuda", backend, "/models/medium")),
patch("local_transcriber.cli._transcribe_file", return_value=tfr),
patch("local_transcriber.cli.write_transcript"),
):
@@ -694,10 +372,7 @@ def test_cli_strict_device_passed_to_transcribe(tmp_path):
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="cuda"),
patch(
"local_transcriber.cli.load_model",
return_value=(model, "cuda", backend, "/models/medium"),
),
patch("local_transcriber.cli.load_model", return_value=(model, "cuda", backend, "/models/medium")),
patch("local_transcriber.cli._transcribe_file", mock_transcribe_file),
patch("local_transcriber.cli.write_transcript"),
patch("local_transcriber.cli.get_gpu_name", return_value="RTX 3060"),
@@ -715,10 +390,7 @@ def test_cli_strict_device_passed_to_transcribe(tmp_path):
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.load_model", return_value=(model, "cpu", backend, "/models/medium")),
patch("local_transcriber.cli._transcribe_file", mock_transcribe_file),
patch("local_transcriber.cli.write_transcript"),
):
@@ -738,10 +410,7 @@ def test_cli_keyboard_interrupt(tmp_path):
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.load_model", return_value=(model, "cpu", backend, "/models/medium")),
patch("local_transcriber.cli._transcribe_file", side_effect=KeyboardInterrupt),
patch("local_transcriber.cli.write_transcript"),
):
@@ -774,14 +443,8 @@ def test_cli_unexpected_error_verbose_traceback(tmp_path):
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._transcribe_file",
side_effect=RuntimeError("unexpected boom"),
),
patch("local_transcriber.cli.load_model", return_value=(model, "cpu", backend, "/models/medium")),
patch("local_transcriber.cli._transcribe_file", side_effect=RuntimeError("unexpected boom")),
patch("local_transcriber.cli.write_transcript"),
):
out = runner.invoke(app, [str(audio), "--verbose"])
@@ -801,14 +464,8 @@ def test_cli_unexpected_error_no_verbose_hint(tmp_path):
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._transcribe_file",
side_effect=RuntimeError("unexpected boom"),
),
patch("local_transcriber.cli.load_model", return_value=(model, "cpu", backend, "/models/medium")),
patch("local_transcriber.cli._transcribe_file", side_effect=RuntimeError("unexpected boom")),
patch("local_transcriber.cli.write_transcript"),
):
out = runner.invoke(app, [str(audio)])
@@ -836,10 +493,7 @@ def test_cli_batch_two_files(tmp_path):
patch("local_transcriber.cli.load_config", return_value={}),
patch("local_transcriber.cli.validate_input_file", side_effect=lambda p: p),
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.load_model", return_value=(model, "cpu", backend, "/models/medium")),
patch("local_transcriber.cli._transcribe_file", return_value=tfr),
patch("local_transcriber.cli.write_transcript"),
):
@@ -849,111 +503,6 @@ def test_cli_batch_two_files(tmp_path):
assert "2 обработано" in out.output
def test_cli_batch_reuses_one_diarizer_for_all_nonempty_files(tmp_path):
first = tmp_path / "first.mp3"
second = tmp_path / "second.mp3"
first.write_bytes(b"fake")
second.write_bytes(b"fake")
result = _make_result(
segments=[Segment(0.0, 1.0, "Раз два")],
duration=10.0,
)
result.words = [Word(0.0, 0.5, "Раз"), Word(0.5, 1.0, "два")]
model = _make_model()
backend = _make_backend()
backend.word_timestamps_available = True
tfr = _make_tfr(result=result, model=model, backend=backend)
diarizer = MagicMock()
diarizer.process.return_value = DiarizationRun(
intervals=[
SpeakerInterval(0.0, 0.5, 1),
SpeakerInterval(0.5, 1.0, 2),
],
elapsed_seconds=0.1,
)
with (
patch("local_transcriber.cli.load_config", return_value={}),
patch(
"local_transcriber.cli.validate_input_file",
side_effect=lambda path: path,
),
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._transcribe_file", return_value=tfr),
patch(
"local_transcriber.cli.load_speaker_diarizer",
return_value=diarizer,
) as load_diarizer,
patch("local_transcriber.cli.write_transcript") as write,
):
out = runner.invoke(app, [str(first), str(second), "--diarize"])
assert out.exit_code == 0
load_diarizer.assert_called_once()
assert [call.args[0] for call in diarizer.process.call_args_list] == [
first,
second,
]
assert write.call_count == 2
def test_cli_batch_continues_after_diarization_error_and_exits_nonzero(tmp_path):
first = tmp_path / "first.mp3"
second = tmp_path / "second.mp3"
first.write_bytes(b"fake")
second.write_bytes(b"fake")
result = _make_result(
segments=[Segment(0.0, 1.0, "Раз два")],
duration=10.0,
)
result.words = [Word(0.0, 0.5, "Раз"), Word(0.5, 1.0, "два")]
model = _make_model()
backend = _make_backend()
backend.word_timestamps_available = True
tfr = _make_tfr(result=result, model=model, backend=backend)
diarizer = MagicMock()
diarizer.process.side_effect = [
RuntimeError("boom"),
DiarizationRun(
[
SpeakerInterval(0.0, 0.5, 1),
SpeakerInterval(0.5, 1.0, 2),
],
elapsed_seconds=0.1,
),
]
with (
patch("local_transcriber.cli.load_config", return_value={}),
patch(
"local_transcriber.cli.validate_input_file",
side_effect=lambda path: path,
),
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._transcribe_file", return_value=tfr),
patch(
"local_transcriber.cli.load_speaker_diarizer",
return_value=diarizer,
),
patch("local_transcriber.cli.write_transcript") as write,
):
out = runner.invoke(app, [str(first), str(second), "--diarize"])
assert out.exit_code == 1
assert write.call_count == 2
assert "Диаризация завершилась с ошибкой: boom" in write.call_args_list[0].args[0]
assert "Speaker 1" in write.call_args_list[1].args[0]
assert "1 с деградацией" in out.output
def test_cli_batch_skips_existing(tmp_path):
a = tmp_path / "a.mp3"
b = tmp_path / "b.mp3"
@@ -970,10 +519,7 @@ def test_cli_batch_skips_existing(tmp_path):
patch("local_transcriber.cli.load_config", return_value={}),
patch("local_transcriber.cli.validate_input_file", side_effect=lambda p: p),
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.load_model", return_value=(model, "cpu", backend, "/models/medium")),
patch("local_transcriber.cli._transcribe_file", return_value=tfr),
patch("local_transcriber.cli.write_transcript"),
):
@@ -994,22 +540,16 @@ def test_cli_batch_all_skipped_no_model_load(tmp_path):
(tmp_path / "b-transcript.md").write_text("existing")
mock_load_model = MagicMock()
mock_load_diarizer = MagicMock()
with (
patch("local_transcriber.cli.load_config", return_value={}),
patch("local_transcriber.cli.validate_input_file", side_effect=lambda p: p),
patch("local_transcriber.cli.load_model", mock_load_model),
patch(
"local_transcriber.cli.load_speaker_diarizer",
mock_load_diarizer,
),
):
out = runner.invoke(app, [str(a), str(b), "--diarize"])
out = runner.invoke(app, [str(a), str(b)])
assert out.exit_code == 0
mock_load_model.assert_not_called()
mock_load_diarizer.assert_not_called()
def test_cli_batch_force_overwrites(tmp_path):
@@ -1028,10 +568,7 @@ def test_cli_batch_force_overwrites(tmp_path):
patch("local_transcriber.cli.load_config", return_value={}),
patch("local_transcriber.cli.validate_input_file", side_effect=lambda p: p),
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.load_model", return_value=(model, "cpu", backend, "/models/medium")),
patch("local_transcriber.cli._transcribe_file", return_value=tfr),
patch("local_transcriber.cli.write_transcript"),
):
@@ -1065,13 +602,8 @@ def test_cli_batch_per_file_error(tmp_path):
patch("local_transcriber.cli.load_config", return_value={}),
patch("local_transcriber.cli.validate_input_file", side_effect=lambda p: p),
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._transcribe_file", side_effect=transcribe_side_effect
),
patch("local_transcriber.cli.load_model", return_value=(model, "cpu", backend, "/models/medium")),
patch("local_transcriber.cli._transcribe_file", side_effect=transcribe_side_effect),
patch("local_transcriber.cli.write_transcript"),
):
out = runner.invoke(app, [str(a), str(b)])
@@ -1099,15 +631,9 @@ def test_cli_batch_invalid_in_prescan(tmp_path):
with (
patch("local_transcriber.cli.load_config", return_value={}),
patch(
"local_transcriber.cli.validate_input_file",
side_effect=validate_side_effect,
),
patch("local_transcriber.cli.validate_input_file", side_effect=validate_side_effect),
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.load_model", return_value=(model, "cpu", backend, "/models/medium")),
patch("local_transcriber.cli._transcribe_file", return_value=tfr),
patch("local_transcriber.cli.write_transcript"),
):
@@ -1242,10 +768,7 @@ def test_cli_batch_fallback_warning(tmp_path):
patch("local_transcriber.cli.load_config", return_value={}),
patch("local_transcriber.cli.validate_input_file", side_effect=lambda p: p),
patch("local_transcriber.cli.detect_device", return_value="cuda"),
patch(
"local_transcriber.cli.load_model",
return_value=(model, "cpu", backend, "/models/medium"),
),
patch("local_transcriber.cli.load_model", return_value=(model, "cpu", backend, "/models/medium")),
patch("local_transcriber.cli._transcribe_file", return_value=tfr),
patch("local_transcriber.cli.write_transcript"),
):
@@ -1272,13 +795,8 @@ def test_cli_batch_empty_speech_warning(tmp_path):
patch("local_transcriber.cli.load_config", return_value={}),
patch("local_transcriber.cli.validate_input_file", side_effect=lambda p: p),
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._transcribe_file", side_effect=[tfr_empty, tfr_ok]
),
patch("local_transcriber.cli.load_model", return_value=(model, "cpu", backend, "/models/medium")),
patch("local_transcriber.cli._transcribe_file", side_effect=[tfr_empty, tfr_ok]),
patch("local_transcriber.cli.write_transcript"),
):
out = runner.invoke(app, [str(a), str(b)])
@@ -1299,24 +817,15 @@ def test_cli_batch_midstream_fallback_warning(tmp_path):
model_cpu = _make_model()
backend = _make_backend()
result = _make_result(device_used="cpu")
tfr_fallback = _make_tfr(
result=result, model=model_cpu, actual_device="cpu", backend=backend
)
tfr_ok = _make_tfr(
result=result, model=model_cpu, actual_device="cpu", backend=backend
)
tfr_fallback = _make_tfr(result=result, model=model_cpu, actual_device="cpu", backend=backend)
tfr_ok = _make_tfr(result=result, model=model_cpu, actual_device="cpu", backend=backend)
with (
patch("local_transcriber.cli.load_config", return_value={}),
patch("local_transcriber.cli.validate_input_file", side_effect=lambda p: p),
patch("local_transcriber.cli.detect_device", return_value="cuda"),
patch(
"local_transcriber.cli.load_model",
return_value=(model_gpu, "cuda", backend, "/models/medium"),
),
patch(
"local_transcriber.cli._transcribe_file", side_effect=[tfr_fallback, tfr_ok]
),
patch("local_transcriber.cli.load_model", return_value=(model_gpu, "cuda", backend, "/models/medium")),
patch("local_transcriber.cli._transcribe_file", side_effect=[tfr_fallback, tfr_ok]),
patch("local_transcriber.cli.write_transcript"),
):
out = runner.invoke(app, [str(a), str(b)])
@@ -1355,14 +864,8 @@ def test_cli_batch_model_loaded_once(tmp_path):
def test_format_device_info_openvino_gpu():
with patch(
"local_transcriber.cli.get_intel_gpu_name",
return_value="Intel(R) Arc(TM) 140T GPU",
):
assert (
_format_device_info("openvino-gpu")
== "OpenVINO (Intel(R) Arc(TM) 140T GPU)"
)
with patch("local_transcriber.cli.get_intel_gpu_name", return_value="Intel(R) Arc(TM) 140T GPU"):
assert _format_device_info("openvino-gpu") == "OpenVINO (Intel(R) Arc(TM) 140T GPU)"
def test_format_device_info_openvino_gpu_no_name():
@@ -1397,13 +900,9 @@ def test_cli_openvino_gpu_happy_path(tmp_path):
audio.write_bytes(b"fake")
result = _make_result(device_used="openvino-gpu")
patches = _single_patches(
result=result, tmp_file=audio, actual_device="openvino-gpu"
)
patches = _single_patches(result=result, tmp_file=audio, actual_device="openvino-gpu")
with patches[0], patches[1], patches[2], patches[3], patches[4], patches[5]:
with patch(
"local_transcriber.cli.get_intel_gpu_name", return_value="Intel Arc 140T"
):
with patch("local_transcriber.cli.get_intel_gpu_name", return_value="Intel Arc 140T"):
out = runner.invoke(app, [str(audio), "--device", "openvino-gpu"])
assert out.exit_code == 0
@@ -1416,12 +915,8 @@ def test_cli_openvino_alias_resolves_to_gpu(tmp_path):
result = _make_result(device_used="openvino-gpu")
model = _make_model()
backend = _make_backend()
tfr = _make_tfr(
result=result, model=model, actual_device="openvino-gpu", backend=backend
)
mock_load_model = MagicMock(
return_value=(model, "openvino-gpu", backend, "/models/medium")
)
tfr = _make_tfr(result=result, model=model, actual_device="openvino-gpu", backend=backend)
mock_load_model = MagicMock(return_value=(model, "openvino-gpu", backend, "/models/medium"))
with (
patch("local_transcriber.cli.load_config", return_value={}),
@@ -1430,9 +925,7 @@ def test_cli_openvino_alias_resolves_to_gpu(tmp_path):
patch("local_transcriber.cli.load_model", mock_load_model),
patch("local_transcriber.cli._transcribe_file", return_value=tfr),
patch("local_transcriber.cli.write_transcript"),
patch(
"local_transcriber.cli.get_intel_gpu_name", return_value="Intel Arc 140T"
),
patch("local_transcriber.cli.get_intel_gpu_name", return_value="Intel Arc 140T"),
):
out = runner.invoke(app, [str(audio), "--device", "openvino"])
@@ -1507,9 +1000,7 @@ def test_cli_install_menu_success(tmp_path):
cmd_path = tmp_path / "Transcribe.cmd"
with (
patch(
"local_transcriber.cli.install_context_menu", return_value=cmd_path
) as mock_install,
patch("local_transcriber.cli.install_context_menu", return_value=cmd_path) as mock_install,
patch("local_transcriber.cli.load_config") as mock_load_config,
patch("local_transcriber.cli.sys") as mock_sys,
):
@@ -1527,9 +1018,7 @@ def test_cli_uninstall_menu_success(tmp_path):
cmd_path = tmp_path / "Transcribe.cmd"
with (
patch(
"local_transcriber.cli.uninstall_context_menu", return_value=cmd_path
) as mock_uninstall,
patch("local_transcriber.cli.uninstall_context_menu", return_value=cmd_path) as mock_uninstall,
patch("local_transcriber.cli.load_config") as mock_load_config,
patch("local_transcriber.cli.sys") as mock_sys,
):
@@ -1590,10 +1079,7 @@ def test_cli_menu_flags_available_only_on_windows():
def test_cli_menu_runtime_error_has_no_verbose_hint():
with (
patch(
"local_transcriber.cli.install_context_menu",
side_effect=RuntimeError("нет APPDATA"),
),
patch("local_transcriber.cli.install_context_menu", side_effect=RuntimeError("нет APPDATA")),
patch("local_transcriber.cli.sys") as mock_sys,
):
mock_sys.platform = "win32"
@@ -1683,10 +1169,7 @@ def test_cli_quality_warning_batch_includes_file_name(tmp_path):
patch("local_transcriber.cli.load_config", return_value={}),
patch("local_transcriber.cli.validate_input_file", side_effect=lambda p: p),
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.load_model", return_value=(model, "cpu", backend, "/models/medium")),
patch("local_transcriber.cli._transcribe_file", side_effect=[tfr_warn, tfr_ok]),
patch("local_transcriber.cli.write_transcript"),
patch("local_transcriber.cli.console", Console(stderr=True, width=1000)),
@@ -1695,7 +1178,8 @@ def test_cli_quality_warning_batch_includes_file_name(tmp_path):
assert out.exit_code == 0
assert (
" a.mp3: транскрипт покрывает 01:00 из 10:00 — возможна потеря хвоста записи"
" a.mp3: транскрипт покрывает 01:00 из 10:00 — "
"возможна потеря хвоста записи"
) in out.output
+1 -1
View File
@@ -125,4 +125,4 @@ def test_get_transcribe_exe_requires_existing_exe(tmp_path, monkeypatch):
monkeypatch.setattr(context_menu.sys, "executable", str(python_exe))
with pytest.raises(RuntimeError, match="uv sync"):
context_menu.get_transcribe_exe()
context_menu.get_transcribe_exe()
-120
View File
@@ -1,120 +0,0 @@
from local_transcriber.diarization import build_speaker_transcript
from local_transcriber.types import (
SmallSpeakerCluster,
SpeakerInterval,
SpeakerTurn,
Word,
)
def test_build_speaker_transcript_assigns_and_groups_words():
words = [
Word(start=0.0, end=0.8, text="Добрый"),
Word(start=0.8, end=1.4, text="день."),
Word(start=1.5, end=2.1, text="Привет!"),
]
intervals = [
SpeakerInterval(start=0.0, end=1.4, cluster=7),
SpeakerInterval(start=1.4, end=2.3, cluster=3),
]
transcript = build_speaker_transcript(words, intervals, recording_duration=30.0)
assert [
(turn.speaker, turn.start, turn.end, turn.text) for turn in transcript.turns
] == [
(1, 0.0, 1.4, "Добрый день."),
(2, 1.5, 2.1, "Привет!"),
]
assert transcript.cluster_count == 2
assert transcript.unassigned_word_count == 0
def test_build_speaker_transcript_reports_small_cluster_without_filtering_it():
words = [
Word(start=0.0, end=1.0, text="Редкая реплика."),
Word(start=5.0, end=6.0, text="Основная реплика."),
]
intervals = [
SpeakerInterval(start=0.0, end=4.9, cluster=4),
SpeakerInterval(start=5.0, end=10.0, cluster=9),
]
transcript = build_speaker_transcript(words, intervals, recording_duration=100.0)
assert [turn.speaker for turn in transcript.turns] == [1, 2]
assert transcript.small_clusters == [SmallSpeakerCluster(speaker=1, duration=4.9)]
def test_build_speaker_transcript_keeps_equal_overlap_unassigned():
words = [Word(start=0.0, end=1.0, text="Спорное слово")]
intervals = [
SpeakerInterval(start=0.0, end=0.1, cluster=8),
SpeakerInterval(start=0.3, end=0.5, cluster=8),
SpeakerInterval(start=0.0, end=0.3, cluster=2),
]
transcript = build_speaker_transcript(words, intervals, recording_duration=10.0)
assert transcript.turns[0].speaker is None
assert transcript.unassigned_word_count == 1
def test_build_speaker_transcript_keeps_word_without_overlap_unknown():
transcript = build_speaker_transcript(
[Word(start=5.0, end=6.0, text="Вне разметки")],
[SpeakerInterval(start=0.0, end=1.0, cluster=1)],
recording_duration=10.0,
)
assert transcript.turns == [SpeakerTurn(5.0, 6.0, "Вне разметки", None)]
assert transcript.unassigned_word_count == 1
def test_build_speaker_transcript_splits_at_two_second_pause():
transcript = build_speaker_transcript(
[
Word(0.0, 1.0, "До паузы."),
Word(3.0, 4.0, "После паузы."),
],
[SpeakerInterval(0.0, 4.0, 1)],
recording_duration=10.0,
)
assert [turn.text for turn in transcript.turns] == [
"До паузы.",
"После паузы.",
]
def test_build_speaker_transcript_does_not_exceed_sixty_seconds():
transcript = build_speaker_transcript(
[
Word(0.0, 30.0, "Начало."),
Word(30.0, 60.0, "Продолжение."),
Word(60.0, 61.0, "Новая реплика."),
],
[SpeakerInterval(0.0, 61.0, 1)],
recording_duration=70.0,
)
assert [turn.text for turn in transcript.turns] == [
"Начало. Продолжение.",
"Новая реплика.",
]
def test_build_speaker_transcript_preserves_punctuation_without_leading_space():
transcript = build_speaker_transcript(
[
Word(0.0, 0.4, "Тарадата"),
Word(0.4, 0.5, "+"),
Word(0.5, 0.7, "Click"),
Word(0.7, 0.8, ""),
Word(0.8, 1.0, "это"),
],
[SpeakerInterval(0.0, 1.0, 1)],
recording_duration=10.0,
)
assert transcript.turns[0].text == "Тарадата+ Click— это"
+2 -118
View File
@@ -1,4 +1,5 @@
from datetime import datetime
from pathlib import Path
from local_transcriber.formatter import (
LANGUAGE_DETECTED,
@@ -10,12 +11,7 @@ from local_transcriber.formatter import (
write_transcript,
)
from local_transcriber.transcriber import Segment, TranscribeResult
from local_transcriber.types import (
UNKNOWN_LANGUAGE,
SmallSpeakerCluster,
SpeakerTranscript,
SpeakerTurn,
)
from local_transcriber.types import UNKNOWN_LANGUAGE
def test_format_timestamp_minutes():
@@ -63,118 +59,6 @@ def test_format_transcript_basic():
assert "[00:00.00 - 00:09.15] Добрый день, коллеги. Первый вопрос." in content
def test_format_transcript_speaker_turns_use_truncated_start_timestamps():
result = TranscribeResult(
segments=[Segment(start=547.96, end=560.0, text=" Обычный текст")],
language="ru",
language_probability=0.97,
duration=700.0,
device_used="cpu",
)
speakers = SpeakerTranscript(
turns=[
SpeakerTurn(547.96, 550.0, "Первая реплика.", 1),
SpeakerTurn(558.4, 560.0, "Ответ.", 2),
],
cluster_count=2,
unassigned_word_count=0,
small_clusters=[],
)
content = format_transcript(
result,
source_filename="meeting.mp4",
model_name="medium",
device_info="CPU",
language_mode=LANGUAGE_DETECTED,
speaker_transcript=speakers,
)
assert "- **Голосовых кластеров**: 2" in content
assert "[09:07] Speaker 1: Первая реплика." in content
assert "[09:18] Speaker 2: Ответ." in content
assert "[09:07.96 -" not in content
def test_format_transcript_speaker_turns_use_hours_after_one_hour():
result = TranscribeResult(
segments=[Segment(start=3661.9, end=3663.0, text=" Длинная встреча")],
language="ru",
language_probability=1.0,
duration=3700.0,
device_used="cpu",
)
speakers = SpeakerTranscript(
turns=[SpeakerTurn(3661.9, 3663.0, "Длинная встреча", 1)],
cluster_count=2,
unassigned_word_count=0,
small_clusters=[],
)
content = format_transcript(
result,
source_filename="meeting.mp4",
model_name="medium",
device_info="CPU",
language_mode=LANGUAGE_FORCED,
speaker_transcript=speakers,
)
assert "[01:01:01] Speaker 1: Длинная встреча" in content
def test_format_transcript_reports_unknown_words_and_small_clusters():
result = TranscribeResult(
segments=[Segment(start=0.0, end=8.0, text=" Текст")],
language="ru",
language_probability=1.0,
duration=20.0,
device_used="cpu",
)
speakers = SpeakerTranscript(
turns=[SpeakerTurn(1.2, 2.0, "Неясная реплика.", None)],
cluster_count=2,
unassigned_word_count=3,
small_clusters=[SmallSpeakerCluster(speaker=2, duration=4.2)],
)
content = format_transcript(
result,
source_filename="meeting.mp4",
model_name="medium",
device_info="CPU",
language_mode=LANGUAGE_FORCED,
speaker_transcript=speakers,
)
assert "[00:01] Speaker ?: Неясная реплика." in content
assert "3 слов без назначенного говорящего" in content
assert "малый кластер Speaker 2: 4.2 с" in content
def test_format_transcript_keeps_plain_body_with_diarization_warning():
result = TranscribeResult(
segments=[Segment(start=0.0, end=2.0, text=" Полезный текст.")],
language="ru",
language_probability=1.0,
duration=5.0,
device_used="cpu",
)
content = format_transcript(
result,
source_filename="meeting.mp4",
model_name="medium",
device_info="CPU",
language_mode=LANGUAGE_FORCED,
diarization_warning="Диаризация завершилась с ошибкой: boom",
)
assert "**Внимание**: Диаризация завершилась с ошибкой: boom" in content
assert "[00:00.00 - 00:02.00] Полезный текст." in content
assert "Speaker" not in content
def test_format_transcript_unknown_language_without_placeholder():
"""Неизвестный язык печатается одной строкой, без служебного значения."""
result = TranscribeResult(
+9 -166
View File
@@ -5,18 +5,16 @@ import warnings
import pytest
from local_transcriber.backends.onnx_asr import OnnxAsrBackend
from local_transcriber.types import UNKNOWN_LANGUAGE, Segment, TranscribeResult, Word
from local_transcriber.types import UNKNOWN_LANGUAGE, Segment, TranscribeResult
class FakeVadSegment:
"""Mimics onnx-asr SegmentResult."""
def __init__(self, start, end, text, tokens=None, timestamps=None):
def __init__(self, start, end, text):
self.start = start
self.end = end
self.text = text
self.tokens = [f" {text}"] if tokens is None else tokens
self.timestamps = [0.0] if timestamps is None else timestamps
class TestEnsureModelAvailable:
@@ -61,9 +59,6 @@ class TestEnsureModelAvailable:
def with_vad(self, vad):
return self
def with_timestamps(self):
return self
def fake_load_model(*, model, quantization):
quantizations.append(quantization)
return FakeAsrAdapter()
@@ -87,45 +82,21 @@ class TestEnsureModelAvailable:
class TestCreateModel:
def test_wraps_vad_model_with_timestamps(self, monkeypatch):
timestamped_model = object()
class FakeVadAdapter:
def with_timestamps(self):
return timestamped_model
class FakeAsrAdapter:
def with_vad(self, vad):
return FakeVadAdapter()
monkeypatch.setattr("onnx_asr.load_model", lambda **kwargs: FakeAsrAdapter())
monkeypatch.setattr("onnx_asr.load_vad", lambda model: object())
model = OnnxAsrBackend().create_model("gigaam-v3-e2e-rnnt", "onnx", "int8")
assert model is timestamped_model
def test_calls_load_model_with_correct_args(self, monkeypatch):
"""Verify create_model passes correct args to onnx_asr.load_model."""
calls = []
def fake_load_model(model=None, path=None, quantization=None, **kwargs):
calls.append(
{
"model": model,
"path": path,
"quantization": quantization,
}
)
def fake_load_model(model=None, path=None, quantization=None,
**kwargs):
calls.append({
"model": model, "path": path, "quantization": quantization,
})
return FakeAsrAdapter()
class FakeAsrAdapter:
def with_vad(self, vad):
return self
def with_timestamps(self):
return self
monkeypatch.setattr("onnx_asr.load_model", fake_load_model)
backend = OnnxAsrBackend()
@@ -152,9 +123,6 @@ class TestCreateModel:
self._vad = vad
return self
def with_timestamps(self):
return self
monkeypatch.setattr("onnx_asr.load_model", fake_load_model)
monkeypatch.setattr("onnx_asr.load_vad", fake_load_vad)
@@ -175,9 +143,6 @@ class TestCreateModel:
def with_vad(self, vad):
return self
def with_timestamps(self):
return self
monkeypatch.setattr("onnx_asr.load_model", fake_load_model)
monkeypatch.setattr("onnx_asr.load_vad", lambda model, **kw: None)
@@ -202,9 +167,6 @@ class TestCreateModel:
def with_vad(self, vad):
return self
def with_timestamps(self):
return self
monkeypatch.setattr("onnx_asr.load_model", fake_load_model)
monkeypatch.setattr("onnx_asr.load_vad", lambda model, **kw: None)
@@ -225,9 +187,6 @@ class TestCreateModel:
def with_vad(self, vad):
return self
def with_timestamps(self):
return self
monkeypatch.setattr("onnx_asr.load_model", fake_load_model)
monkeypatch.setattr("onnx_asr.load_vad", lambda model, **kw: None)
@@ -248,9 +207,6 @@ class TestCreateModel:
def with_vad(self, vad):
return self
def with_timestamps(self):
return self
monkeypatch.setattr("onnx_asr.load_model", fake_load_model)
monkeypatch.setattr("onnx_asr.load_vad", lambda model, **kw: None)
@@ -342,7 +298,6 @@ class TestTranscribe:
def fake_decode_audio(path, sampling_rate=16000):
import numpy as np
return np.array(audio_samples, dtype=np.float32)
class FakeModel:
@@ -355,9 +310,7 @@ class TestTranscribe:
backend = OnnxAsrBackend()
backend.actual_compute_type = "int8"
result = backend.transcribe(
FakeModel(),
wav_file,
language=None,
FakeModel(), wav_file, language=None,
)
assert isinstance(result, TranscribeResult)
@@ -366,110 +319,6 @@ class TestTranscribe:
assert result.segments[1] == Segment(start=1.0, end=2.5, text="world")
assert result.duration == 1.0 # 16000 samples / 16000 Hz
def test_transcribe_converts_vad_token_timestamps_to_global_words(
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] * 16_000,
)
timestamped_segment = FakeVadSegment(
10.0,
12.0,
"Привет, мир",
tokens=[" ", "П", "р", "и", "в", "е", "т", ",", " ", "м", "и", "р"],
timestamps=[0.0, 0.1, 0.1, 0.1, 0.2, 0.2, 0.3, 0.3, 0.5, 0.6, 0.6, 0.7],
)
class FakeModel:
def recognize(self, waveform, sample_rate, language=None):
yield timestamped_segment
result = OnnxAsrBackend().transcribe(FakeModel(), wav_file, language="ru")
assert result.words == [
Word(start=10.0, end=10.5, text=" Привет,"),
Word(start=10.5, end=12.0, text=" мир"),
]
assert "".join(word.text for word in result.words).strip() == "Привет, мир"
def test_transcribe_rejects_nonempty_segment_without_token_timestamps(
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] * 16_000,
)
segment = FakeVadSegment(0.0, 1.0, "Текст")
segment.tokens = None
segment.timestamps = None
class FakeModel:
def recognize(self, waveform, sample_rate, language=None):
yield segment
with pytest.raises(RuntimeError, match="пословные таймкоды"):
OnnxAsrBackend().transcribe(FakeModel(), wav_file, language="ru")
def test_transcribe_keeps_words_with_equal_emission_timestamps(
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] * 16_000,
)
segment = FakeVadSegment(
10.0,
12.0,
"Да нет потом",
tokens=[" ", "Да", " ", "нет", " ", "потом"],
timestamps=[0.0, 0.0, 0.0, 0.0, 0.5, 0.5],
)
class FakeModel:
def recognize(self, waveform, sample_rate, language=None):
yield segment
result = OnnxAsrBackend().transcribe(FakeModel(), wav_file, language="ru")
assert [word.text for word in result.words] == [" Да", " нет", " потом"]
assert [(word.start, word.end) for word in result.words] == [
(10.0, 10.5),
(10.0, 10.5),
(10.5, 12.0),
]
assert "".join(word.text for word in result.words).strip() == segment.text
def test_transcribe_keeps_word_clamped_to_segment_end(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] * 16_000,
)
segment = FakeVadSegment(
10.0,
12.0,
"Позднее",
tokens=[" ", "Позднее"],
timestamps=[2.0, 2.0],
)
class FakeModel:
def recognize(self, waveform, sample_rate, language=None):
yield segment
result = OnnxAsrBackend().transcribe(FakeModel(), wav_file, language="ru")
assert result.words == [Word(start=12.0, end=12.0, text=" Позднее")]
def test_transcribe_calls_on_segment(self, monkeypatch, tmp_path):
"""Verify on_segment callback is invoked per segment."""
wav_file = tmp_path / "test.wav"
@@ -477,7 +326,6 @@ class TestTranscribe:
def fake_decode_audio(path, sampling_rate=16000):
import numpy as np
return np.array([0.0] * 16000, dtype=np.float32)
segments_captured = []
@@ -491,9 +339,7 @@ class TestTranscribe:
backend = OnnxAsrBackend()
backend.transcribe(
FakeModel(),
wav_file,
language=None,
FakeModel(), wav_file, language=None,
on_segment=lambda s: segments_captured.append(s),
)
@@ -508,7 +354,6 @@ class TestTranscribe:
def fake_decode_audio(path, sampling_rate=16000):
import numpy as np
return np.array([0.0] * 16000, dtype=np.float32)
lang_received = []
@@ -532,7 +377,6 @@ class TestTranscribe:
def fake_decode_audio(path, sampling_rate=16000):
import numpy as np
return np.array([0.0] * 16000, dtype=np.float32)
class FakeModel:
@@ -575,7 +419,6 @@ class TestTranscribe:
class TestBackendRegistration:
def test_get_backend_returns_onnx_backend(self):
from local_transcriber.backends import get_backend
backend = get_backend("onnx")
assert isinstance(backend, OnnxAsrBackend)
-231
View File
@@ -1,231 +0,0 @@
import hashlib
import io
import sys
import tarfile
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import numpy as np
import pytest
from local_transcriber.speaker_diarizer import SpeakerDiarizer, load_speaker_diarizer
from local_transcriber.types import SpeakerInterval
def test_process_returns_sorted_domain_intervals(tmp_path):
audio = tmp_path / "meeting.mp3"
raw_result = MagicMock()
raw_result.sort_by_start_time.return_value = [
SimpleNamespace(start=0.2, end=1.1, speaker=4),
SimpleNamespace(start=1.3, end=2.0, speaker=2),
]
engine = MagicMock()
engine.process.return_value = raw_result
diarizer = SpeakerDiarizer(engine)
samples = np.zeros(16_000, dtype=np.float32)
with patch("faster_whisper.decode_audio", return_value=samples) as decode:
run = diarizer.process(audio)
assert run.intervals == [
SpeakerInterval(start=0.2, end=1.1, cluster=4),
SpeakerInterval(start=1.3, end=2.0, cluster=2),
]
decode.assert_called_once_with(str(audio), sampling_rate=16_000)
engine.process.assert_called_once_with(samples)
def test_process_reports_engine_progress(tmp_path):
raw_result = MagicMock()
raw_result.sort_by_start_time.return_value = []
engine = MagicMock()
def process(samples, callback):
assert callback(2, 4) == 0
return raw_result
engine.process.side_effect = process
statuses = []
with patch(
"faster_whisper.decode_audio",
return_value=np.zeros(16_000, dtype=np.float32),
):
SpeakerDiarizer(engine).process(
tmp_path / "meeting.mp3",
on_status=statuses.append,
)
assert "Определяю говорящих... 2 / 4" in statuses
def test_load_speaker_diarizer_uses_verified_cache_and_calibrated_config(
tmp_path, monkeypatch
):
segmentation = tmp_path / "pyannote-segmentation-3.0.onnx"
embedding = tmp_path / "wespeaker_en_voxceleb_resnet34_LM.onnx"
segmentation.write_bytes(b"segmentation")
embedding.write_bytes(b"embedding")
monkeypatch.setattr(
"local_transcriber.speaker_diarizer._SEGMENTATION_SHA256",
hashlib.sha256(segmentation.read_bytes()).hexdigest(),
)
monkeypatch.setattr(
"local_transcriber.speaker_diarizer._EMBEDDING_SHA256",
hashlib.sha256(embedding.read_bytes()).hexdigest(),
)
captured = {}
def config_factory(**kwargs):
config = SimpleNamespace(**kwargs, validate=lambda: True)
captured["config"] = config
return config
engine = SimpleNamespace(sample_rate=16_000)
sherpa = SimpleNamespace(
OfflineSpeakerSegmentationPyannoteModelConfig=lambda **kwargs: SimpleNamespace(
**kwargs
),
OfflineSpeakerSegmentationModelConfig=lambda **kwargs: SimpleNamespace(
**kwargs
),
SpeakerEmbeddingExtractorConfig=lambda **kwargs: SimpleNamespace(**kwargs),
FastClusteringConfig=lambda **kwargs: SimpleNamespace(**kwargs),
OfflineSpeakerDiarizationConfig=config_factory,
OfflineSpeakerDiarization=lambda config: engine,
)
with (
patch("huggingface_hub.cached_assets_path", return_value=tmp_path),
patch.dict(sys.modules, {"sherpa_onnx": sherpa}),
patch("httpx.stream", side_effect=AssertionError("network is not expected")),
):
diarizer = load_speaker_diarizer(speakers=None, threads=0)
config = captured["config"]
assert config.clustering.num_clusters == -1
assert config.clustering.threshold == 0.89
assert config.min_duration_on == 0.3
assert config.min_duration_off == 0.5
assert not hasattr(config.segmentation, "num_threads")
assert not hasattr(config.embedding, "num_threads")
assert isinstance(diarizer, SpeakerDiarizer)
def test_load_speaker_diarizer_downloads_and_verifies_missing_models(
tmp_path, monkeypatch
):
segmentation_bytes = b"downloaded segmentation"
embedding_bytes = b"downloaded embedding"
archive_buffer = io.BytesIO()
with tarfile.open(fileobj=archive_buffer, mode="w:bz2") as archive:
member = tarfile.TarInfo("sherpa-onnx-pyannote-segmentation-3-0/model.onnx")
member.size = len(segmentation_bytes)
archive.addfile(member, io.BytesIO(segmentation_bytes))
monkeypatch.setattr(
"local_transcriber.speaker_diarizer._SEGMENTATION_SHA256",
hashlib.sha256(segmentation_bytes).hexdigest(),
)
monkeypatch.setattr(
"local_transcriber.speaker_diarizer._EMBEDDING_SHA256",
hashlib.sha256(embedding_bytes).hexdigest(),
)
class FakeResponse:
def __init__(self, content):
self.content = content
def __enter__(self):
return self
def __exit__(self, *args):
return False
def raise_for_status(self):
return None
def iter_bytes(self):
yield self.content
requested_urls = []
def fake_stream(method, url, **kwargs):
requested_urls.append(url)
content = (
archive_buffer.getvalue() if "segmentation" in url else embedding_bytes
)
return FakeResponse(content)
config = SimpleNamespace(validate=lambda: True)
engine = SimpleNamespace(sample_rate=16_000)
sherpa = SimpleNamespace(
OfflineSpeakerSegmentationPyannoteModelConfig=lambda **kwargs: SimpleNamespace(
**kwargs
),
OfflineSpeakerSegmentationModelConfig=lambda **kwargs: SimpleNamespace(
**kwargs
),
SpeakerEmbeddingExtractorConfig=lambda **kwargs: SimpleNamespace(**kwargs),
FastClusteringConfig=lambda **kwargs: SimpleNamespace(**kwargs),
OfflineSpeakerDiarizationConfig=lambda **kwargs: config,
OfflineSpeakerDiarization=lambda actual_config: engine,
)
with (
patch("huggingface_hub.cached_assets_path", return_value=tmp_path),
patch.dict(sys.modules, {"sherpa_onnx": sherpa}),
patch("httpx.stream", side_effect=fake_stream),
):
load_speaker_diarizer(speakers=2, threads=4)
assert (
tmp_path / "pyannote-segmentation-3.0.onnx"
).read_bytes() == segmentation_bytes
assert (
tmp_path / "wespeaker_en_voxceleb_resnet34_LM.onnx"
).read_bytes() == embedding_bytes
assert len(requested_urls) == 2
assert list(tmp_path.glob("*.tmp")) == []
def test_load_speaker_diarizer_keeps_corrupt_cache_when_download_is_invalid(
tmp_path, monkeypatch
):
segmentation = tmp_path / "pyannote-segmentation-3.0.onnx"
segmentation.write_bytes(b"existing corrupt model")
monkeypatch.setattr(
"local_transcriber.speaker_diarizer._SEGMENTATION_SHA256",
hashlib.sha256(b"expected model").hexdigest(),
)
archive_buffer = io.BytesIO()
with tarfile.open(fileobj=archive_buffer, mode="w:bz2") as archive:
payload = b"wrong downloaded model"
member = tarfile.TarInfo("sherpa-onnx-pyannote-segmentation-3-0/model.onnx")
member.size = len(payload)
archive.addfile(member, io.BytesIO(payload))
class FakeResponse:
def __enter__(self):
return self
def __exit__(self, *args):
return False
def raise_for_status(self):
return None
def iter_bytes(self):
yield archive_buffer.getvalue()
with (
patch("huggingface_hub.cached_assets_path", return_value=tmp_path),
patch("httpx.stream", return_value=FakeResponse()),
pytest.raises(RuntimeError, match="Контрольная сумма"),
):
load_speaker_diarizer(speakers=None)
assert segmentation.read_bytes() == b"existing corrupt model"
assert list(tmp_path.glob("*.tmp")) == []
+6 -38
View File
@@ -11,7 +11,7 @@ from local_transcriber.transcriber import (
load_model,
transcribe,
)
from local_transcriber.types import WordTimestampsUnavailableError
# === Helpers ===
@@ -314,9 +314,7 @@ def test_load_model_returns_backend_and_path(mock_get_backend):
backend = _make_backend(model_path="/mock/model/path")
mock_get_backend.return_value = backend
model, actual_device, returned_backend, model_path = load_model(
"tiny", "cpu", "int8"
)
model, actual_device, returned_backend, model_path = load_model("tiny", "cpu", "int8")
assert returned_backend is backend
assert model_path == "/mock/model/path"
@@ -391,9 +389,7 @@ def test_ensure_model_available_uses_cache_first(mock_snapshot_download, tmp_pat
@patch("local_transcriber.backends.faster_whisper._validate_model_dir")
@patch("local_transcriber.backends.faster_whisper.snapshot_download")
def test_ensure_model_available_downloads_on_cache_miss(
mock_snapshot_download, mock_validate_model_dir
):
def test_ensure_model_available_downloads_on_cache_miss(mock_snapshot_download, mock_validate_model_dir):
from huggingface_hub.errors import LocalEntryNotFoundError
mock_snapshot_download.side_effect = [
@@ -437,9 +433,7 @@ def test_ensure_model_available_rejects_unsupported_alias():
@patch("local_transcriber.backends.faster_whisper.snapshot_download")
def test_ensure_model_available_redownloads_incomplete_cache(
mock_snapshot_download, tmp_path
):
def test_ensure_model_available_redownloads_incomplete_cache(mock_snapshot_download, tmp_path):
incomplete = tmp_path / "incomplete"
incomplete.mkdir()
(incomplete / "config.json").write_text("{}")
@@ -495,9 +489,7 @@ def test_load_model_openvino_gpu_fallback_to_cpu(mock_get_backend):
with pytest.warns(UserWarning, match="Переключение на CPU"):
model, actual_device, backend, model_path = load_model(
"medium",
"openvino-gpu",
"fp16",
"medium", "openvino-gpu", "fp16",
)
assert actual_device == "cpu"
@@ -522,9 +514,7 @@ def test_load_model_openvino_cpu_fallback_to_cpu(mock_get_backend):
with pytest.warns(UserWarning, match="Переключение на CPU"):
model, actual_device, backend, model_path = load_model(
"medium",
"openvino-cpu",
"int8",
"medium", "openvino-cpu", "int8",
)
assert actual_device == "cpu"
@@ -565,28 +555,6 @@ def test_transcribe_file_openvino_gpu_midstream_fallback(mock_get_backend):
assert tfr.model_path == "/mock/cpu/model"
@patch("local_transcriber.transcriber.get_backend")
def test_transcribe_file_does_not_fallback_for_missing_word_timestamps(
mock_get_backend,
):
ov_backend = _make_backend(
transcribe_error=WordTimestampsUnavailableError("нет таймкодов"),
)
with pytest.raises(WordTimestampsUnavailableError, match="нет таймкодов"):
_transcribe_file(
model=MagicMock(),
actual_device="openvino-gpu",
backend=ov_backend,
model_path="/mock/ov/model",
file_path=Path("test.mp3"),
model_name="medium",
compute_type="fp16",
)
mock_get_backend.assert_not_called()
@patch("local_transcriber.transcriber.get_backend")
def test_transcribe_file_midstream_fallback_preserves_cpu_threads(mock_get_backend):
ov_backend = _make_backend(
Generated
-30
View File
@@ -259,13 +259,11 @@ version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "faster-whisper" },
{ name = "httpx" },
{ name = "nvidia-cublas-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" },
{ name = "onnx-asr", extra = ["cpu", "hub"] },
{ name = "onnxruntime" },
{ name = "openvino-genai", marker = "(platform_machine == 'AMD64' and sys_platform != 'darwin') or (platform_machine == 'x86_64' and sys_platform != 'darwin')" },
{ name = "rich" },
{ name = "sherpa-onnx" },
{ name = "socksio" },
{ name = "typer" },
]
@@ -278,13 +276,11 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "faster-whisper", specifier = ">=1.2.1,<2" },
{ name = "httpx", specifier = ">=0.28,<1" },
{ 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.3.0.0,<2026.4" },
{ name = "rich", specifier = ">=14.3.3,<15" },
{ name = "sherpa-onnx", specifier = ">=1.13.5,<2" },
{ name = "socksio", specifier = ">=1.0.0,<2" },
{ name = "typer", specifier = ">=0.24.1,<1" },
]
@@ -597,32 +593,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" },
]
[[package]]
name = "sherpa-onnx"
version = "1.13.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6d/a1/8a7d8751bc71a0814f3b9332717909512a5ff919d07ed53e4baa861ef8c7/sherpa_onnx-1.13.5.tar.gz", hash = "sha256:14bebfe71365a2c678dd94cd08efa8e79df06318b17fe8e97b2e802a7881fd5a", size = 1037285, upload-time = "2026-08-11T08:17:31.817Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/61/6f/b70ae4c7e367e3d2dca99efc65152d22ad5d942619add19172d24267c14b/sherpa_onnx-1.13.5-cp313-cp313-linux_armv7l.whl", hash = "sha256:15a02d9d74143f336156cb8b4da826bc65c11daa28bd5c9099e9397b3660e7df", size = 11999210, upload-time = "2026-08-11T09:26:07.356Z" },
{ url = "https://files.pythonhosted.org/packages/9e/ec/d471d042cc85c505515e3fbaed55223f54718a9ac3a8f228a8e1b81dab6d/sherpa_onnx-1.13.5-cp313-cp313-macosx_10_15_universal2.whl", hash = "sha256:371b0eb51caa12f5f3e9c327c440a4aee6f77c163c89666469844eaefb7d5351", size = 4439247, upload-time = "2026-08-11T08:30:33.777Z" },
{ url = "https://files.pythonhosted.org/packages/28/58/2f60eb29db5f41c01ad844be34ed1adfdecce9493b12cc20bbdc6b6ad5a8/sherpa_onnx-1.13.5-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:2bdfefa5d16896c90d1685bde43682818e70b95e134dccc95b91dc0819adde1c", size = 2340028, upload-time = "2026-08-11T07:18:47.753Z" },
{ url = "https://files.pythonhosted.org/packages/6e/71/25f067216edcde5e78cf0d9a7ac053a2b3b17bdf131acac69447daa23356/sherpa_onnx-1.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:05a3effd8ea5ee78735bc28909c0fce26d9f23bf61db663e6f75f04ec0247ba4", size = 2137522, upload-time = "2026-08-11T07:43:35.186Z" },
{ url = "https://files.pythonhosted.org/packages/5a/77/b36cd40906bef1b3a6593200587eea5c4e7c1e94d29c4fd88a876e1cd6ca/sherpa_onnx-1.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f5a6cc5ac96043670faa0f5c0e56310315a4600cf7b764fee014e7dd75fda00f", size = 4167203, upload-time = "2026-08-11T07:48:34.601Z" },
{ url = "https://files.pythonhosted.org/packages/9a/3b/817cdb5770f8beafd22a04a8eda0c8253a6d07a518a09ce982d6e4152e3c/sherpa_onnx-1.13.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cb7c1f5ca5455b3ec008f3e09438bca6038d22b4fe5d64adb5c09bb6b84eeb82", size = 4392107, upload-time = "2026-08-11T08:03:49.714Z" },
{ url = "https://files.pythonhosted.org/packages/55/75/b8494e30923a18db144e0035851f0364c32e63d776b62f3d2667f717f499/sherpa_onnx-1.13.5-cp313-cp313-win32.whl", hash = "sha256:80d9ec3ce2fe8b566285ef92fa2e72b4fa0aee10f73290ac74219fe415806980", size = 1961338, upload-time = "2026-08-11T08:35:46.591Z" },
{ url = "https://files.pythonhosted.org/packages/5e/28/e8644784a897e4ba6cdc7a635d821107ce31567c90e1835a0c008ba2a322/sherpa_onnx-1.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:440174433b5d3f855a800d757e66812e998ec9715f5f42690afb27a6ff456586", size = 2279915, upload-time = "2026-08-11T08:51:50.171Z" },
{ url = "https://files.pythonhosted.org/packages/02/99/1aa369391f62a2753b5d97b0b9ecd99475410c51eada10f7079628a58c4f/sherpa_onnx-1.13.5-cp313-cp313-win_arm64.whl", hash = "sha256:38de7f31754dfa7212beb4cc773ba05f78d028adb4c0e008920128388657f778", size = 2239783, upload-time = "2026-08-11T07:46:44.679Z" },
{ url = "https://files.pythonhosted.org/packages/21/a3/ac238819cca1955b6b80ae802671cc7e4d688c673fb98001c21111922045/sherpa_onnx-1.13.5-cp314-cp314-linux_armv7l.whl", hash = "sha256:a224466e750a435089311d4c763617a4d83478a2f958c311b473ea59f9cce3ae", size = 11997852, upload-time = "2026-08-11T08:54:11.47Z" },
{ url = "https://files.pythonhosted.org/packages/02/82/3f5b50f84b9a8571bdfe9939983a8e6dff75110961305315887bbc83f626/sherpa_onnx-1.13.5-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:1a4ea5fa45caf31093ff832ab4d198e097edbeeb2244a0d4f958e96bf888110a", size = 4442303, upload-time = "2026-08-11T07:36:28.234Z" },
{ url = "https://files.pythonhosted.org/packages/fe/90/6d3a1112bc4496b0b918d3a807036912f1f34fb4a4dc506f0cabf398c4a9/sherpa_onnx-1.13.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7582ca42ed2d80bacc984b46904cbdd058ec2b6149a8c8996d7263217a4cc963", size = 2340527, upload-time = "2026-08-11T07:54:35.482Z" },
{ url = "https://files.pythonhosted.org/packages/84/9d/0cb152e3fd1aa5787ade99c4863cd8cf80d800b879e8ff47f8f99412af78/sherpa_onnx-1.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:97471fe025fc1d655a1df2f4ffb5d3fe843c269f2016d5e1cca4b0d168a49169", size = 2140247, upload-time = "2026-08-11T08:23:33.405Z" },
{ url = "https://files.pythonhosted.org/packages/e6/84/38531f16e2c2a0b077257b25c34c02a1c8f7f1f833d5bb61e2b2f510e260/sherpa_onnx-1.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2a51751f6c925c405ef6ebcb2f7c44794810da79d0f2743fe57ee3daa5bc4df5", size = 4172385, upload-time = "2026-08-11T08:00:34.952Z" },
{ url = "https://files.pythonhosted.org/packages/45/91/56a439945713a5cf49080a833fbd0953936fcd50c651c17afe64478c9922/sherpa_onnx-1.13.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:eb4d580d959e16c42dbd404cef0f5764ed1c024edf48d3edc43b5e2f1c96f069", size = 4393503, upload-time = "2026-08-11T07:38:11.97Z" },
{ url = "https://files.pythonhosted.org/packages/d5/27/cbdfa8bdcf618b28ec5cf383b073be851ff3178ea06d6f1a0fcb3c7532e8/sherpa_onnx-1.13.5-cp314-cp314-win32.whl", hash = "sha256:64cb492cf8a9751f632685c6f169fef7e5a2f449efb9923bc21b6e783a911dba", size = 2000807, upload-time = "2026-08-11T08:57:11.235Z" },
{ url = "https://files.pythonhosted.org/packages/d6/c2/162840897a528a528bc0f76cf3516d4e3ac1445db9089caf7733df756cfc/sherpa_onnx-1.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:001e1c3440fe335a589c9283a68105f379ffb63f2d1d7c2878e99a5d72eaf5af", size = 2345302, upload-time = "2026-08-11T08:11:46.752Z" },
{ url = "https://files.pythonhosted.org/packages/28/5e/9a9c63e1dcd646c5f21df6d16e6770fedd956f8a54241f3382343c6289af/sherpa_onnx-1.13.5-cp314-cp314-win_arm64.whl", hash = "sha256:a7e97d8457a51725ffa3fcb44cb1d57bfdb780bc390a9460a65e05bf3d2851b4", size = 2303336, upload-time = "2026-08-11T07:33:25.917Z" },
]
[[package]]
name = "socksio"
version = "1.0.0"