Compare commits

...
10 Commits
Author SHA1 Message Date
Dmitry Dementiev b8bb78103a docs(benchmark): зафиксирован stop Ryzen-прогона
- Зачем:
  - задача #46 должна завершиться воспроизводимым решением по каноническому Ryzen-эксперименту.
- Что:
  - записан stop из-за несовпадения числа кластеров свежего W0.
  - сохранены безопасные агрегаты W0 и вторичный провал проверки swap.
  - отмечено, что Sortformer не запускался и данных об ускорении нет.
- Проверка:
  - uv run pytest -q.
  - независимая сверка отчета с закрытым результатом и проверка литературного русского.
2026-09-04 21:38:42 +03:00
Dmitry Dementiev 4c4a8be07b fix(experiment): уточнено определение активной подкачки
- Зачем:
  - занятые, но неизменные swap-страницы ошибочно блокировали канонический Ryzen-прогон.
- Что:
  - active swap определяется изменением счетчиков sin/sout во время ячейки.
  - сохранены memory gates для OOM, RSS plateau, линейного роста и пикового RSS.
- Проверка:
  - uv run pytest -q tests/test_streaming_sortformer_experiment.py tests/test_benchmark_experiment.py tests/test_speaker_diarizer.py tests/test_diarization.py.
2026-09-04 18:50:31 +03:00
Dmitry Dementiev d71aec4ab2 fix(experiment): устранен повторный gate принятия опоры
- Зачем:
  - канонический Ryzen-прогон не должен повторно запрашивать уже данное решение владельца до порога 30%.
- Что:
  - восстановлен короткий контракт approval и удалена лишняя схема owner-package.
  - разрешена парная nullable-опора для диагностик без ослабления обязательных gates.
  - NUL в путях отклоняется обезличенной ошибкой до загрузки моделей.
- Проверка:
  - uv run pytest -q: 457 passed, 2 skipped.
  - обязательный набор эксперимента: 110 passed; целевой Ruff пройден.
  - независимые code и task/taste review закрыты.
2026-09-04 17:48:36 +03:00
Dmitry Dementiev 0cd105927b chore(repo): ветка синхронизирована с origin/master
- Зачем:
  - source commit эксперимента должен включать актуальное состояние master.
- Что:
  - в рабочую ветку влиты изменения конфигурации диаризации и документации.
- Проверка:
  - слияние завершено без конфликтов, рабочее дерево чистое.
2026-09-04 17:21:42 +03:00
Dmitry Dementiev 16e5372f2d chore(repo): новые материалы .scratch исключены из Git
Зачем:
- хранить локальные handoff и launcher в постоянном каталоге без риска случайного коммита
- не терять временно закрепленный стенд диаризации между компьютерами

Что:
- весь корневой .scratch добавлен в ignore
- существующие диагностические скрипты оставлены отслеживаемыми
- описание локальных и переносимых артефактов приведено к новой политике

Проверка:
- git check-ignore -v --no-index .scratch/new-local-artifact
- git ls-files .scratch
- git diff --check
2026-09-04 16:01:39 +03:00
ddmitry 7d672a7dc8 Merge pull request 'feat(config): добавлена настройка диаризации в TOML' (#52) from fix/51-diarize-config into master
Reviewed-on: #52
2026-09-02 14:11:19 +03:00
Dmitriy Dementiev 2e30d668ce refactor(config): уточнены типы каскада настроек
- Зачем:
  - общий union допускал несоответствие между ключом настройки и его типом.
- Что:
  - разделены типы TOML, CLI и полностью разрешённой конфигурации.
  - каскад значений сохранён в типобезопасной generic-функции.
- Проверка:
  - mypy src/local_transcriber/config.py.
  - pytest tests/test_config.py tests/test_cli.py: 109 passed.
2026-09-02 12:52:33 +03:00
Dmitriy Dementiev ef61ced2e5 feat(config): добавлена настройка диаризации в TOML
- Зачем:
  - пользователю нужна постоянная настройка диаризации с явным CLI-переопределением.
- Что:
  - добавлен булев ключ diarize и каскад CLI > TOML > false.
  - добавлены --no-diarize и проверка конфликта с --speakers.
  - обновлены документация и поведенческие тесты single/batch.
- Проверка:
  - uv run pytest tests/test_config.py tests/test_cli.py -v.
  - uv run pytest: 452 passed, 1 skipped, 1 несвязанный Windows-сбой.
2026-09-02 12:45:57 +03:00
ddmitry 5ec9c77dfa Merge pull request 'docs(install): указан Python 3.13 для uv tool' (#50) from codex/docs-uv-python into master
Reviewed-on: #50
2026-09-02 12:16:33 +03:00
Dmitriy Dementiev 78f2a8aecc docs(install): указан Python 3.13 для uv tool
- Зачем:
  - uv tool install сохранял Python 3.11 старого окружения и отклонял пакет с требованием Python 3.13.
- Что:
  - в команды установки и обновления добавлен явный выбор Python 3.13.
  - актуализировано описание выбора Python в спецификации.
- Проверка:
  - git diff --check.
2026-09-02 12:11:57 +03:00
14 changed files with 470 additions and 1347 deletions

No files matched your search

+1 -1
View File
@@ -7,4 +7,4 @@ dist/
.codex
.qwen/
tmp
.scratch/handoffs/
/.scratch/
+5
View File
@@ -5,6 +5,11 @@
Не часть пакета: они опираются на `sherpa-onnx`, которого нет в зависимостях
проекта, и живут в `.scratch/`, а не в `src/`.
Код этого каталога временно закреплен в Git: его используют несколько задач на
нескольких компьютерах, пока идет исследование диаризации. Общее правило
`/.scratch/` относится к новым локальным материалам; уже отслеживаемые файлы
этого стенда продолжают версионироваться.
Результаты первого прогона описаны в
[разведочном замере](../../docs/benchmarks/2026-08-12-diarization-feasibility.md).
+7 -4
View File
@@ -29,7 +29,7 @@ powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | ie
**2. Установить transcriber:**
```bash
uv tool install git+https://git.dementev.space/ddmitry/local-transcriber.git
uv tool install --python 3.13 git+https://git.dementev.space/ddmitry/local-transcriber.git
```
**3. Ускорение (ставится автоматически):**
@@ -63,7 +63,7 @@ transcribe meeting.mp4
**Обновление:**
```bash
uv tool install --force git+https://git.dementev.space/ddmitry/local-transcriber.git
uv tool install --python 3.13 --force git+https://git.dementev.space/ddmitry/local-transcriber.git
```
**Удаление:**
@@ -223,8 +223,8 @@ 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` |
| `--diarize` / `--no-diarize` | — | `false` | Включить или отключить разделение на реплики говорящих |
| `--speakers` | — | авто | Ожидаемое число говорящих; включает диаризацию и несовместим с `--no-diarize` |
| `--force` | `-f` | — | Перезаписать существующие транскрипты |
| `--verbose` | `-v` | — | Подробный вывод |
@@ -282,6 +282,7 @@ device = "openvino-cpu"
model = "large-v3-turbo"
compute_type = "int8"
language = "ru"
diarize = true
```
Порядок поиска:
@@ -289,6 +290,8 @@ language = "ru"
2. `~/.config/transcriber/config.toml`
Приоритет: **CLI-аргумент > конфиг > device-aware дефолт > встроенный дефолт**.
`--diarize` и `--no-diarize` позволяют переопределить `diarize` из конфига для
отдельного запуска.
При `device = "auto"` выбирается CUDA, если доступен `nvidia-smi`, иначе ONNX.
Явный `device` из CLI или конфига отключает этот автоматический выбор.
@@ -0,0 +1,73 @@
# Streaming Sortformer: формальный `stop` на Ryzen
**Дата:** 2026-09-04
**Статус:** эксперимент завершен как `stop`. Для трех объектов закрытого
манифеста были закреплены количества кластеров `2 / 2 / 3`, но W0 выдал
`2 / 2 / 2`. Кандидат не запускался.
## Решение
Свежий W0 не прошел обязательную проверку числа говорящих на одном из трех
300-секундных фрагментов. По условиям
[эксперимента](../specs/2026-09-01-streaming-sortformer-cpu-experiment.md)
такая база сравнения непригодна. Выполнена одна из двенадцати запланированных
ячеек. Sortformer и остальные ячейки не запускались, ускорение не считалось.
Данных о скорости или качестве кандидата этот прогон не дал.
Во время W0 также зафиксированы чтение из swap и запись в него. Это независимый
провал проверки среды и памяти, но первой причиной `stop` остается
`cluster-count-mismatch`: проверка качества завершилась раньше итогового снимка
системных счетчиков.
## Результат W0
| Показатель | Значение |
| --- | ---: |
| Суммарный wall, с | `103,242485` |
| Ожидаемые кластеры | `2 / 2 / 3` |
| Полученные кластеры | `2 / 2 / 2` |
| Пиковый RSS | `340 340 736` байт (`324,57` МиБ) |
| Прочитано из swap за ячейку | `503 808` байт (`492` КиБ) |
| Записано в swap за ячейку | `151 552` байт (`148` КиБ) |
Первые два фрагмента прошли обязательные проверки числа кластеров, текста,
порядка слов и интервалов. Третий фрагмент содержал подтвержденные три голоса,
но W0 сформировал два кластера. Повтор не назначался: несовпадение числа
кластеров само по себе завершает шаг приемки W0.
## Условия
W0 выполнялся по закрепленному рецепту `W0-FRESH`:
- `sherpa-onnx/core 1.13.6`, CPU provider;
- Pyannote Segmentation 3.0 FP32 и WeSpeaker ResNet34 LM FP32;
- один процесс, одна сессия, восемь физических ядер,
`intra_op_threads=8`, `inter_op_threads=1`;
- `window_shift_ratio=0.1`, `FastClustering(auto, 0.89)`,
`min_duration_on=0.3`, `min_duration_off=0.5`;
- Windows Balanced, boost mode `2`; Linux cpufreq/governor в WSL2 не
предоставляется.
До измерения успешно завершились обязательные детерминированные тесты, запуск
с `--validate-only` и предварительная проверка C ABI и конфигурации кандидата.
Последняя подтвердила четыре выходных канала, шаг `0,08` с и закрепленную
CPU-конфигурацию, но не обрабатывала аудио и не является проверкой качества
Sortformer.
## Техническая привязка
- коммит исходного кода `local-transcriber`:
`4c4a8be07b3653cede35b0435e6c9ef6cb3fb87d`;
- коммит NeMo-Speech.cpp:
`4f9676226f667d14608487df744f375db87127f8`;
- модель Streaming Sortformer v2 Q8_0:
`0679cfeb1ce356d0dea9470b31274f4bfc7eb927497d82005483770666da998a`;
- закрытый итоговый файл W0:
`ad32b3fe8a204e2fe6fbb6a13142e22c7e00ce9f0d816c3b0b8889fcab898e89`.
Последний SHA-256 нужен только для проверки неизменности закрытого результата:
он не оценивает диаризацию и не участвует в решении `stop`. Закрытая основа и
результаты сохраняются для следующих исследований. Их повторное использование
должно быть прямо предусмотрено новым экспериментом; вердикт и расписание
задачи #46 автоматически не переносятся.
+6 -6
View File
@@ -37,13 +37,13 @@
Floor берётся сразу 3.13, а не минимально достаточный 3.11: любой подъём версии
всё равно требует прогона на всех трёх платформах, и дорого именно тестирование,
а не строка в `pyproject.toml`. Один переезд вместо двух. Проект ставится там,
где доступны uv и PyPI, поэтому uv сам поднимет нужный интерпретатор; закрытых
контуров и оффлайн-зеркал среди пользователей нет.
где доступны uv и PyPI; команда установки явно запрашивает Python 3.13, поэтому
uv найдёт или скачает нужный интерпретатор. Закрытых контуров и оффлайн-зеркал
среди пользователей нет.
Рабочая версия фиксируется файлом `.python-version` (сейчас его в репозитории
нет). Floor разрешает любой интерпретатор от 3.13 и выше, а пин делает окружение
одинаковым на разных машинах — иначе расхождение вылезает именно тогда, когда
что-то отлаживаешь.
Рабочая версия фиксируется файлом `.python-version`. Floor разрешает любой
интерпретатор от 3.13 и выше, а пин делает окружение одинаковым на разных
машинах — иначе расхождение вылезает именно тогда, когда что-то отлаживаешь.
Колёса cp313 проверены для всех нативных зависимостей и всех трёх целевых
платформ — Windows, Linux, macOS: `openvino-genai` 2026.0.0.0, `ctranslate2`
+6 -4
View File
@@ -23,7 +23,7 @@
## Не входит
- Автоматическое включение диаризации без флага и `--no-diarize`.
- Аппаратно-зависимое автоматическое включение диаризации.
- Параллельный запуск ASR и диаризации.
- Сопоставление `Speaker N` с именами участников.
- Постоянный кеш разметки говорящих, голосовые эмбеддинги и диагностические
@@ -33,10 +33,12 @@
## Пользовательский интерфейс
- `--diarize` включает диаризацию. По умолчанию она выключена; ключ в
`.transcriber.toml` в первой версии не добавляется.
- `--diarize` включает диаризацию, `--no-diarize` явно выключает. Булев ключ
`diarize` в `.transcriber.toml` задаёт значение по умолчанию; без него
диаризация выключена. CLI-флаги имеют приоритет над конфигом.
- `--speakers N`, где `N >= 1`, задаёт известное число участников и сам включает
диаризацию. Без него число кластеров определяется автоматически.
диаризацию. Без него число кластеров определяется автоматически. Сочетание
`--speakers N` с `--no-diarize` считается ошибкой использования.
- `--threads N` остаётся единым бюджетом активного CPU-прохода. Значение целиком
получает сначала ASR, затем диаризация; `0` оставляет настройки библиотек.
- `--verbose` показывает в консоли прогресс, число кластеров и интервалов,
@@ -151,9 +151,10 @@
За пределами Git остаются manifest, записи, WAV-кеш, ASR-sidecar, опорные
транскрипты, сырые интервалы, результаты, worker requests, журналы и материалы
слуховой проверки. Рабочий каталог стенда нельзя размещать в обычной
`.scratch`: репозиторий игнорирует там только служебный каталог
`.scratch/handoffs/`, не связанный с переносом эксперимента между ноутбуками.
слуховой проверки. Рабочий каталог стенда нельзя размещать в `.scratch`: этот
каталог локален и целиком игнорируется, поэтому эксперимент из него не
переносится между ноутбуками. Временно закрепленные диагностические скрипты уже
отслеживаются Git и не меняют этого правила для новых материалов.
Публичный отчет содержит хеши, агрегаты, конфигурацию и выводы. В него не входят
локальные пути, распознанный текст, временные интервалы, имена участников и
@@ -186,20 +186,25 @@ mono/16 kHz float32. Публичные документы получают то
после единственного закрепленного преобразования, а ни один путь, ID или
интервал не попадает в stdout режима `--validate-only`.
### Шаг 2. Создать ASR-sidecar и опорную разметку
### Шаг 2. Подготовить ASR-sidecar и доступную опорную разметку
Действующий ASR выполняется один раз для каждой полной записи до измерений.
Sidecar хранит канонические слова с временной привязкой и нормализованный текст;
fragment-sidecar получается детерминированным срезом той же версии. Затем
человек прослушивает каждый фрагмент целиком, фиксирует ожидаемое число
голосовых кластеров для фрагментов и полных записей и создает опорную разметку,
достаточную для mapped purity и выбора мест прослушивания. Опорная разметка
остается приближенной и не объявляется истиной границ.
человек прослушивает каждый фрагмент целиком и фиксирует ожидаемое число
голосовых кластеров для фрагментов и полных записей. Опорная разметка фрагмента
необязательна: `reference_path` и `reference_sha256` либо заполнены вместе, либо
оба равны `null`. При наличии разметки стенд считает mapped purity и выбирает
места прослушивания; сама разметка остается приближенной и не объявляется
истиной границ.
Критерий завершения: для каждого фрагмента и каждой полной записи есть
подтвержденное человеком `expected_cluster_count` в диапазоне `2..4`, один
неизменяемый sidecar и SHA-256; для фрагментов также есть опорная разметка.
Числа старых контролей не копируются.
неизменяемый sidecar и SHA-256. Отсутствие опорной разметки не блокирует
измерение и не ослабляет остальные gates. Для канонического Ryzen-прогона
используются подтвержденные владельцем числа `3 / 2 / 2`; их привязывают к
источникам по хешам, а не по порядку нейтральных имен. Повторное прослушивание
для подсчета голосов не требуется.
### Шаг 3. Пройти пользовательский gate
@@ -216,6 +221,10 @@ fragment-sidecar получается детерминированным сре
Критерий завершения: в закрытом manifest есть timestamped decision `approved`.
Без него исход `needs-user`; стенд не загружает модели.
В каноническом Ryzen-прогоне решением `approved` служит датированный handoff
владельца в комментарии к задаче #46. Нового подтверждения до достижения
30%-го порога не требуется.
## Измеряемая величина и A/B
Время W0 - wall вокруг одного `diarizer.process(samples)`. Время кандидата -
@@ -333,9 +342,10 @@ hashes и один pair ID; рассчитаны ускорение, CPU и RSS
- каноническая нумерация меток детерминирована на повторном расчете метрик;
- все inputs, параметры и output artifacts имеют SHA-256.
Purity и число слов без говорящего сравниваются с новым `W0-FRESH`. Ухудшение
не дает автоматического `fail`, но обязательно добавляет все затронутые места в
слуховой gate. Старые абсолютные значения не участвуют.
Purity для фрагментов с опорной разметкой и число слов без говорящего
сравниваются с новым `W0-FRESH`. Ухудшение не дает автоматического `fail`, но
обязательно добавляет все затронутые места в слуховой gate. Старые абсолютные
значения не участвуют.
Открытый диагностикой слуховой gate не прерывает шаги 5-6. Стенд накапливает
затронутые места и добавляет их в материал шага 7. Состояние `needs-user` из-за
@@ -353,7 +363,7 @@ Purity и число слов без говорящего сравниваютс
- каждая итоговая speaker label принадлежит `1..4`;
- число и суммарная длительность интервалов по каждому каналу;
- число коротких интервалов, overlap ratio, unmatched words, малые/остаточные
кластеры и mapped purity относительно свежей опорной разметки;
кластеры и mapped purity для фрагментов с опорной разметкой;
- wall отдельно для model inference и segment post-processing.
Любой неверный shape/value/label или эффективная конфигурация, отличная от
@@ -380,17 +390,17 @@ RSS выше 2 ГиБ на машине с 16 ГиБ требует отдель
## Артефакты и приватность
Checkout и дерево сборки NeMo-Speech.cpp, скачанные веса и новое закрытое
хранилище размещаются вне рабочей копии local-transcriber. Обычная `.scratch`
внутри репозитория не подходит: `.gitignore` покрывает там только
`.scratch/handoffs/`. Ни один runtime/model/build artifact или закрытый вход не
попадает в Git.
хранилище размещаются вне рабочей копии local-transcriber. `.scratch` внутри
репозитория целиком игнорируется, но остается локальной частью конкретного
checkout и не подходит для закрытого переносимого хранилища. Ни один
runtime/model/build artifact или закрытый вход не попадает в Git.
Новое закрытое хранилище содержит:
- versioned manifest со schema, clean local-transcriber source commit,
source/model/runtime/build hashes и расписанием;
- исходные ссылки, подготовленные WAV и recipe преобразования;
- ASR-sidecar и опорную разметку;
- ASR-sidecar и имеющуюся опорную разметку;
- raw per-fragment results, intervals, word assignments и probability
diagnostics;
- machine/power/runtime guards и stderr/stdout logs;
@@ -458,8 +468,9 @@ privacy scan. Реализация стенда фиксируется чист
## Критерии завершения эксперимента
- [ ] Свежая опора подтверждена владельцем и не наследует старые контрольные
значения.
- [ ] Свежая опора собрана в новом закрытом хранилище; подтвержденные входы,
доступная разметка и числа голосов связаны по хешам, а старые manifest,
расписание и результаты не наследуются.
- [ ] Runtime, модель, сборка и оба рецепта закреплены и проверены до моделей.
- [ ] Clean local-transcriber source commit сохранен в закрытом хранилище и
публичном отчете, а `--validate-only` проверяет его до моделей.
+24 -9
View File
@@ -11,7 +11,13 @@ import typer
from rich.console import Console
from rich.status import Status
from .config import apply_device_defaults, load_config, resolve_defaults
from .config import (
CliValues,
ResolvedConfig,
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
@@ -261,9 +267,9 @@ def main(
min=0,
help="Потоки CPU (0 = дефолт библиотеки; рекомендуется = число физ. ядер)",
),
diarize: bool = typer.Option(
False,
"--diarize",
diarize: bool | None = typer.Option(
None,
"--diarize/--no-diarize",
help="Разделить транскрипт на реплики говорящих",
),
speakers: int | None = typer.Option(
@@ -328,15 +334,24 @@ def main(
)
raise SystemExit(2)
if diarize is False and speakers is not None:
console.print(
"--no-diarize и --speakers несовместимы.",
style="red bold",
)
raise SystemExit(2)
try:
config = load_config()
cli_values = {
cli_values: CliValues = {
"model": model,
"language": language,
"device": device,
"compute_type": compute_type,
"diarize": diarize,
}
defaults = resolve_defaults(cli_values, config)
diarize_enabled = defaults["diarize"] is True or speakers is not None
resolved_device = detect_device(defaults["device"])
defaults = apply_device_defaults(defaults, resolved_device, cli_values, config)
@@ -363,7 +378,7 @@ def main(
force,
ct_explicit,
cpu_threads=threads,
diarize=diarize or speakers is not None,
diarize=diarize_enabled,
speakers=speakers,
)
else:
@@ -374,7 +389,7 @@ def main(
verbose,
ct_explicit,
cpu_threads=threads,
diarize=diarize or speakers is not None,
diarize=diarize_enabled,
speakers=speakers,
)
except KeyboardInterrupt:
@@ -408,7 +423,7 @@ def main(
def _run_single(
file: Path,
defaults: dict[str, str],
defaults: ResolvedConfig,
output: Path | None,
verbose: bool,
compute_type_explicit: bool = False,
@@ -552,7 +567,7 @@ def _run_single(
def _run_batch(
files: list[Path],
defaults: dict[str, str],
defaults: ResolvedConfig,
verbose: bool,
force: bool,
compute_type_explicit: bool = False,
+88 -22
View File
@@ -3,12 +3,48 @@
import tomllib
import warnings
from pathlib import Path
from typing import TypedDict, TypeVar, cast
HARDCODED_DEFAULTS: dict[str, str] = {
type ConfigValue = str | bool
_T = TypeVar("_T")
class ConfigValues(TypedDict, total=False):
"""Проверенные значения из TOML-конфига."""
model: str
language: str
device: str
compute_type: str
diarize: bool
class CliValues(TypedDict, total=False):
"""Явные значения CLI до разрешения каскада."""
model: str | None
language: str | None
device: str | None
compute_type: str | None
diarize: bool | None
class ResolvedConfig(TypedDict):
"""Полная конфигурация после разрешения каскада."""
model: str
language: str
device: str
compute_type: str
diarize: bool
HARDCODED_DEFAULTS: ResolvedConfig = {
"model": "medium",
"language": "ru",
"device": "auto",
"compute_type": "float32",
"diarize": False,
}
DEVICE_DEFAULTS: dict[str, dict[str, str]] = {
@@ -46,7 +82,7 @@ def find_config_file() -> Path | None:
return None
def load_config(path: Path | None = None) -> dict[str, str]:
def load_config(path: Path | None = None) -> ConfigValues:
"""Загружает и валидирует TOML-конфиг.
Неизвестные ключи вызывают предупреждение (а не ошибку) для forward
@@ -70,11 +106,19 @@ def load_config(path: Path | None = None) -> dict[str, str]:
stacklevel=2,
)
result: dict[str, str] = {}
result: dict[str, ConfigValue] = {}
for key in _VALID_KEYS:
if key not in data:
continue
value = data[key]
if key == "diarize":
if not isinstance(value, bool):
raise ValueError(
f"Значение 'diarize' в {path} должно быть логическим, "
f"получено {type(value).__name__}"
)
result[key] = value
continue
if not isinstance(value, str):
raise ValueError(
f"Значение '{key}' в {path} должно быть строкой, получено {type(value).__name__}"
@@ -88,31 +132,53 @@ def load_config(path: Path | None = None) -> dict[str, str]:
raise ValueError(f"Значение 'language' в {path} не может быть пустым")
result[key] = value
return result
return cast(ConfigValues, result)
def resolve_defaults(
cli_values: dict[str, str | None], config: dict[str, str]
) -> dict[str, str]:
def resolve_defaults(cli_values: CliValues, config: ConfigValues) -> ResolvedConfig:
"""Каскад приоритетов: CLI > конфиг-файл > hardcoded-дефолты."""
result: dict[str, str] = {}
for key in HARDCODED_DEFAULTS:
cli_val = cli_values.get(key)
if cli_val is not None:
result[key] = cli_val
elif key in config:
result[key] = config[key]
else:
result[key] = HARDCODED_DEFAULTS[key]
return result
return {
"model": _resolve_value(
cli_values.get("model"), config.get("model"), HARDCODED_DEFAULTS["model"]
),
"language": _resolve_value(
cli_values.get("language"),
config.get("language"),
HARDCODED_DEFAULTS["language"],
),
"device": _resolve_value(
cli_values.get("device"),
config.get("device"),
HARDCODED_DEFAULTS["device"],
),
"compute_type": _resolve_value(
cli_values.get("compute_type"),
config.get("compute_type"),
HARDCODED_DEFAULTS["compute_type"],
),
"diarize": _resolve_value(
cli_values.get("diarize"),
config.get("diarize"),
HARDCODED_DEFAULTS["diarize"],
),
}
def _resolve_value(cli_value: _T | None, config_value: _T | None, default: _T) -> _T:
"""Выбирает первое явно заданное значение каскада."""
if cli_value is not None:
return cli_value
if config_value is not None:
return config_value
return default
def apply_device_defaults(
defaults: dict[str, str],
defaults: ResolvedConfig,
resolved_device: str,
cli_values: dict[str, str | None],
config: dict[str, str],
) -> dict[str, str]:
cli_values: CliValues,
config: ConfigValues,
) -> ResolvedConfig:
"""Применяет device-aware дефолты для model и compute_type,
если они не были явно заданы через CLI или конфиг."""
device_defs = DEVICE_DEFAULTS.get(resolved_device, {})
@@ -124,4 +190,4 @@ def apply_device_defaults(
if cli_values.get(key) is None and key not in config:
if key in device_defs:
result[key] = device_defs[key]
return result
return cast(ResolvedConfig, result)
@@ -5,13 +5,11 @@ from __future__ import annotations
import argparse
import copy
import ctypes
import errno
import hashlib
import json
import math
import os
import re
import stat
import statistics
import subprocess
import sys
@@ -46,51 +44,10 @@ TOP_LEVEL_KEYS = {
"schedule",
"rss_sample_interval_ms",
}
FRESH_BASIS_KEYS = {"marker", "owner_package", "approval"}
OWNER_PACKAGE_LINK_KEYS = {"path", "sha256", "generated_at"}
APPROVAL_LINK_KEYS = {
"path",
"sha256",
"actor",
"status",
"decision",
"timestamp",
}
OWNER_PACKAGE_SCHEMA = "local-transcriber.owner-listening-package.v1"
OWNER_PACKAGE_KEYS = {"schema", "generated_at", "references"}
OWNER_PACKAGE_REFERENCE_KEYS = {
"neutral_alias",
"canonical_fragment_audio_sha256",
"final_reference_sha256",
"full_recording_sha256",
}
REFERENCE_ACCEPTANCE_SCHEMA = "local-transcriber.reference-owner-acceptance.v1"
REFERENCE_ACCEPTANCE_KEYS = {
"schema",
"actor",
"status",
"decision",
"timestamp",
"owner_package_sha256",
"references",
"step3",
}
REFERENCE_ACCEPTANCE_ROW_KEYS = OWNER_PACKAGE_REFERENCE_KEYS | {
"fragment_expected_cluster_count",
"full_recording_expected_cluster_count",
"fragment_review_complete",
"full_recording_count_review_complete",
"reference_accepted",
}
STEP3_KEYS = {
"recording_use_approved",
"inputs_and_counts_approved",
"full_recordings_available",
"final_hearing_gate_committed",
}
FRESH_BASIS_KEYS = {"marker", "approval"}
APPROVAL_KEYS = {"decision", "timestamp"}
SOURCE_KEYS = {"commit"}
INPUT_KEYS = {
"neutral_alias",
"source_path",
"source_sha256",
"source_size_bytes",
@@ -355,294 +312,45 @@ def build_full_recording_schedule(
return result
def _aware_datetime(value: Any) -> datetime:
if not isinstance(value, str):
raise PreflightError("approval-timestamp")
try:
parsed = datetime.fromisoformat(value)
except ValueError as exc:
raise PreflightError("approval-timestamp") from exc
if parsed.tzinfo is None or parsed.utcoffset() is None:
raise PreflightError("approval-timestamp")
return parsed
def _safe_relative_parts(value: Any) -> tuple[str, ...]:
def _resolve(root: Path, value: Any) -> Path:
if not isinstance(value, str) or not value:
raise PreflightError("unsafe-relative-path")
pure = PurePosixPath(value)
if (
pure.is_absolute()
or not pure.parts
or ".." in pure.parts
or "\\" in value
or "\x00" in value
):
if pure.is_absolute() or ".." in pure.parts or "\\" in value or "\x00" in value:
raise PreflightError("path-escape")
return pure.parts
def _same_file_state(before: os.stat_result, after: os.stat_result) -> bool:
return (
before.st_dev,
before.st_ino,
before.st_mode,
before.st_nlink,
before.st_size,
before.st_mtime_ns,
before.st_ctime_ns,
) == (
after.st_dev,
after.st_ino,
after.st_mode,
after.st_nlink,
after.st_size,
after.st_mtime_ns,
after.st_ctime_ns,
)
@dataclass(frozen=True)
class _BoundFile:
sha256: str
size_bytes: int
payload: bytes | None
def _read_bound_regular_file(
root: Path, path_value: Any, *, capture_payload: bool
) -> _BoundFile:
"""Читает файл относительно доверенного неизменяемого root dirfd."""
parts = _safe_relative_parts(path_value)
directory_flags = (
os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC
)
opened_fds: list[int] = []
fd_states: list[tuple[int, os.stat_result]] = []
namespace_entries: list[tuple[int, str, os.stat_result]] = []
try:
directory_fd = os.open(root, directory_flags)
opened_fds.append(directory_fd)
root_state = os.fstat(directory_fd)
if not stat.S_ISDIR(root_state.st_mode):
raise PreflightError("file-binding")
fd_states.append((directory_fd, root_state))
for part in parts[:-1]:
parent_fd = directory_fd
directory_fd = os.open(part, directory_flags, dir_fd=parent_fd)
opened_fds.append(directory_fd)
directory_state = os.fstat(directory_fd)
if not stat.S_ISDIR(directory_state.st_mode):
raise PreflightError("file-binding")
fd_states.append((directory_fd, directory_state))
namespace_entries.append((parent_fd, part, directory_state))
file_fd = os.open(
parts[-1],
os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC,
dir_fd=directory_fd,
)
opened_fds.append(file_fd)
file_state = os.fstat(file_fd)
if not stat.S_ISREG(file_state.st_mode) or file_state.st_nlink != 1:
raise PreflightError("file-binding")
fd_states.append((file_fd, file_state))
namespace_entries.append((directory_fd, parts[-1], file_state))
digest = hashlib.sha256()
size_bytes = 0
chunks: list[bytes] | None = [] if capture_payload else None
while chunk := os.read(file_fd, 64 * 1024):
digest.update(chunk)
size_bytes += len(chunk)
if chunks is not None:
chunks.append(chunk)
for file_descriptor, original_state in fd_states:
if not _same_file_state(original_state, os.fstat(file_descriptor)):
raise PreflightError("file-binding")
for parent_fd, name, original_state in namespace_entries:
current_state = os.stat(name, dir_fd=parent_fd, follow_symlinks=False)
if not _same_file_state(original_state, current_state):
raise PreflightError("file-binding")
if size_bytes != file_state.st_size:
raise PreflightError("file-binding")
return _BoundFile(
sha256=digest.hexdigest(),
size_bytes=size_bytes,
payload=b"".join(chunks) if chunks is not None else None,
)
except PreflightError:
raise
except ValueError as exc:
raise PreflightError("path-escape") from exc
except OSError as exc:
reason = (
"path-escape"
if exc.errno in {errno.ELOOP, errno.ENOTDIR}
else "file-binding"
)
raise PreflightError(reason) from exc
finally:
for opened_fd in reversed(opened_fds):
try:
os.close(opened_fd)
except OSError:
pass
base = root.resolve()
resolved = (base / Path(value)).resolve()
if not resolved.is_relative_to(base):
raise PreflightError("path-escape")
return resolved
def _verify(root: Path, path_value: Any, expected_hash: Any, size: Any | None) -> None:
path = _resolve(root, path_value)
expected = _sha(expected_hash, "invalid-sha256")
expected_size = None if size is None else _positive_int(size, "invalid-size")
bound = _read_bound_regular_file(root, path_value, capture_payload=False)
if expected_size is not None and bound.size_bytes != expected_size:
if not path.is_file():
raise PreflightError("file-unavailable")
if size is not None and path.stat().st_size != _positive_int(size, "invalid-size"):
raise PreflightError("size-mismatch")
if bound.sha256 != expected:
if file_sha256(path) != expected:
raise PreflightError("hash-mismatch")
def _strict_json_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
value: dict[str, Any] = {}
for key, item in pairs:
if key in value:
raise PreflightError("owner-acceptance-record")
value[key] = item
return value
def _reject_json_constant(_value: str) -> None:
raise PreflightError("owner-acceptance-record")
def _bound_json(
root: Path, link: Mapping[str, Any], link_keys: set[str]
) -> Mapping[str, Any]:
value = _exact(link, link_keys, "owner-acceptance-link")
expected_sha256 = _sha(value["sha256"], "owner-acceptance-hash")
bound = _read_bound_regular_file(root, value["path"], capture_payload=True)
if bound.sha256 != expected_sha256:
raise PreflightError("owner-acceptance-hash")
if bound.payload is None:
raise PreflightError("owner-acceptance-record")
try:
loaded = json.loads(
bound.payload,
object_pairs_hook=_strict_json_object,
parse_constant=_reject_json_constant,
)
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise PreflightError("owner-acceptance-record") from exc
if not isinstance(loaded, Mapping):
raise PreflightError("owner-acceptance-record")
return loaded
def _validate_owner_package_row(row: Any) -> None:
value = _exact(row, OWNER_PACKAGE_REFERENCE_KEYS, "owner-package-reference")
alias = value["neutral_alias"]
if (
not isinstance(alias, str)
or re.fullmatch(r"[a-z][a-z0-9-]{0,63}", alias) is None
):
raise PreflightError("owner-package-reference")
for key in OWNER_PACKAGE_REFERENCE_KEYS - {"neutral_alias"}:
_sha(value[key], "owner-package-reference")
def _validate_acceptance_row(row: Any) -> None:
value = _exact(
row,
REFERENCE_ACCEPTANCE_ROW_KEYS,
"owner-acceptance-reference",
)
_validate_owner_package_row(
{key: value[key] for key in OWNER_PACKAGE_REFERENCE_KEYS}
)
for key in (
"fragment_expected_cluster_count",
"full_recording_expected_cluster_count",
):
count = value[key]
if type(count) is not int or not 2 <= count <= 4:
raise PreflightError("owner-acceptance-reference")
for key in (
"fragment_review_complete",
"full_recording_count_review_complete",
"reference_accepted",
):
if value[key] is not True:
raise PreflightError("owner-acceptance-reference")
def _validate_reference_owner_acceptance(
*,
root: Path,
basis: Mapping[str, Any],
expected_package_rows: Sequence[Mapping[str, Any]],
expected_acceptance_rows: Sequence[Mapping[str, Any]],
) -> bool:
try:
package_link = _exact(
basis["owner_package"],
OWNER_PACKAGE_LINK_KEYS,
"owner-package-link",
)
package = _exact(
_bound_json(root, package_link, OWNER_PACKAGE_LINK_KEYS),
OWNER_PACKAGE_KEYS,
"owner-package-schema",
)
if package["schema"] != OWNER_PACKAGE_SCHEMA:
raise PreflightError("owner-package-schema")
package_generated_at = _aware_datetime(package["generated_at"])
if package["generated_at"] != package_link["generated_at"]:
raise PreflightError("owner-package-timestamp")
package_rows = package["references"]
if not isinstance(package_rows, list) or len(package_rows) != 3:
raise PreflightError("owner-package-references")
for row in package_rows:
_validate_owner_package_row(row)
if package_rows != list(expected_package_rows):
raise PreflightError("owner-package-binding")
approval_link = _exact(
basis["approval"], APPROVAL_LINK_KEYS, "owner-acceptance-link"
)
acceptance = _exact(
_bound_json(root, approval_link, APPROVAL_LINK_KEYS),
REFERENCE_ACCEPTANCE_KEYS,
"owner-acceptance-schema",
)
if acceptance["schema"] != REFERENCE_ACCEPTANCE_SCHEMA:
raise PreflightError("owner-acceptance-schema")
for key in ("actor", "status", "decision", "timestamp"):
if acceptance[key] != approval_link[key]:
raise PreflightError("owner-acceptance-link-mismatch")
if (
acceptance["actor"] != "owner"
or acceptance["status"] != "approved"
or acceptance["decision"] != "approved"
):
raise PreflightError("owner-acceptance-decision")
accepted_at = _aware_datetime(acceptance["timestamp"])
if accepted_at <= package_generated_at:
raise PreflightError("owner-acceptance-timestamp")
if (
_sha(acceptance["owner_package_sha256"], "owner-package-binding")
!= package_link["sha256"]
):
raise PreflightError("owner-package-binding")
acceptance_rows = acceptance["references"]
if not isinstance(acceptance_rows, list) or len(acceptance_rows) != 3:
raise PreflightError("owner-acceptance-references")
for row in acceptance_rows:
_validate_acceptance_row(row)
if acceptance_rows != list(expected_acceptance_rows):
raise PreflightError("owner-acceptance-binding")
step3 = _exact(acceptance["step3"], STEP3_KEYS, "owner-acceptance-step3")
if any(step3[key] is not True for key in STEP3_KEYS):
raise PreflightError("owner-acceptance-step3")
except (KeyError, OSError, TypeError, PreflightError):
def _validate_approval(value: Any) -> bool:
if value is None:
return False
approval = _exact(value, APPROVAL_KEYS, "approval-schema")
if approval["decision"] != "approved":
return False
timestamp = approval["timestamp"]
if not isinstance(timestamp, str):
raise PreflightError("approval-timestamp")
try:
parsed = datetime.fromisoformat(timestamp)
except ValueError as exc:
raise PreflightError("approval-timestamp") from exc
if parsed.tzinfo is None:
raise PreflightError("approval-timestamp")
return True
@@ -749,12 +457,10 @@ def validate_manifest(
raise PreflightError("legacy-identity")
raise PreflightError("legacy-root")
basis = value["fresh_basis"]
if not isinstance(basis, Mapping):
raise PreflightError("fresh-basis-schema")
if basis.get("marker") != FRESH_BASIS_MARKER:
basis = _exact(value["fresh_basis"], FRESH_BASIS_KEYS, "fresh-basis-schema")
if basis["marker"] != FRESH_BASIS_MARKER:
raise PreflightError("fresh-basis-marker")
basis_schema_valid = set(basis) == FRESH_BASIS_KEYS
approved = _validate_approval(basis["approval"])
source = _exact(value["source"], SOURCE_KEYS, "source-schema")
source_commit = _commit(source["commit"], "source-commit")
if set(environment) != {"commit", "clean"}:
@@ -780,18 +486,8 @@ def validate_manifest(
raise PreflightError("source-hashes-not-unique")
fragment_hashes = []
fragment_sidecars = []
package_rows = []
acceptance_rows = []
aliases = []
for item in inputs:
entry = _exact(item, INPUT_KEYS, "input-schema")
alias = entry["neutral_alias"]
if (
not isinstance(alias, str)
or re.fullmatch(r"[a-z][a-z0-9-]{0,63}", alias) is None
):
raise PreflightError("neutral-alias")
aliases.append(alias)
if verify_files:
_verify(
root,
@@ -813,53 +509,29 @@ def validate_manifest(
media_hash = _sha(record["sha256"], "record-hash")
sidecar_hash = _sha(record["sidecar_sha256"], "sidecar-hash")
if role == "fragment":
_sha(record["reference_sha256"], "reference-hash")
reference_path = record["reference_path"]
reference_hash = record["reference_sha256"]
if (reference_path is None) != (reference_hash is None):
raise PreflightError("reference-binding")
if reference_hash is not None:
_sha(reference_hash, "reference-hash")
fragment_hashes.append(media_hash)
fragment_sidecars.append(sidecar_hash)
if verify_files:
_verify(root, record["path"], media_hash, record["size_bytes"])
_verify(root, record["sidecar_path"], sidecar_hash, None)
if role == "fragment":
if role == "fragment" and reference_hash is not None:
_verify(
root,
record["reference_path"],
record["reference_sha256"],
reference_path,
reference_hash,
None,
)
package_row = {
"neutral_alias": alias,
"canonical_fragment_audio_sha256": entry["fragment"]["sha256"],
"final_reference_sha256": entry["fragment"]["reference_sha256"],
"full_recording_sha256": entry["full_recording"]["sha256"],
}
package_rows.append(package_row)
acceptance_rows.append(
{
**package_row,
"fragment_expected_cluster_count": entry["fragment"][
"expected_cluster_count"
],
"full_recording_expected_cluster_count": entry["full_recording"][
"expected_cluster_count"
],
"fragment_review_complete": True,
"full_recording_count_review_complete": True,
"reference_accepted": True,
}
)
if len(set(aliases)) != 3:
raise PreflightError("neutral-alias")
_validate_schedule(value["schedule"], fragment_hashes, fragment_sidecars)
_positive_int(value["rss_sample_interval_ms"], "rss-sample-interval")
approved = basis_schema_valid and _validate_reference_owner_acceptance(
root=root,
basis=basis,
expected_package_rows=package_rows,
expected_acceptance_rows=acceptance_rows,
)
return PreflightOutcome(
state="valid" if approved else "needs-user",
reason=None if approved else "reference-owner-acceptance",
reason=None if approved else "owner-approval-required",
fragment_count=3,
full_recording_count=3,
source_commit_sha256=hashlib.sha256(source_commit.encode("ascii")).hexdigest(),
@@ -1423,8 +1095,6 @@ def memory_gate(
for value in integer_values
):
raise ValueError("memory-value")
if swap_before_bytes > 0 or swap_after_bytes > 0:
return {"state": "stop", "reason": "active-swap", **base}
if stage == "fragment":
if len(rss_after_inputs) != 3:
return {"state": "stop", "reason": "rss-no-plateau", **base}
@@ -1774,13 +1444,10 @@ def _guard_transition_reasons(
def _swap_active(before: Mapping[str, Any], after: Mapping[str, Any] | None = None) -> bool:
if before["swap_used_bytes"] > 0:
return True
if after is None:
return False
return bool(
after["swap_used_bytes"] > 0
or after["swap_sin_bytes"] != before["swap_sin_bytes"]
after["swap_sin_bytes"] != before["swap_sin_bytes"]
or after["swap_sout_bytes"] != before["swap_sout_bytes"]
)
+72 -3
View File
@@ -519,6 +519,27 @@ def test_cli_rejects_nonpositive_speaker_count(tmp_path):
assert out.exit_code == 2
def test_cli_rejects_no_diarize_with_speakers_before_model_load(tmp_path):
audio = tmp_path / "meeting.mp3"
audio.write_bytes(b"fake")
load_model = 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", load_model),
):
out = runner.invoke(
app,
[str(audio), "--no-diarize", "--speakers", "2"],
)
assert out.exit_code == 2
assert "--no-diarize и --speakers несовместимы" in out.output
load_model.assert_not_called()
def test_cli_verbose_passes_on_segment_callback(tmp_path):
audio = tmp_path / "test.mp3"
audio.write_bytes(b"fake")
@@ -917,7 +938,13 @@ 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):
@pytest.mark.parametrize(
("config", "cli_args"),
[({}, ["--diarize"]), ({"diarize": True}, [])],
)
def test_cli_batch_reuses_one_diarizer_for_all_nonempty_files(
tmp_path, config, cli_args
):
first = tmp_path / "first.mp3"
second = tmp_path / "second.mp3"
first.write_bytes(b"fake")
@@ -941,7 +968,7 @@ def test_cli_batch_reuses_one_diarizer_for_all_nonempty_files(tmp_path):
)
with (
patch("local_transcriber.cli.load_config", return_value={}),
patch("local_transcriber.cli.load_config", return_value=config),
patch(
"local_transcriber.cli.validate_input_file",
side_effect=lambda path: path,
@@ -958,7 +985,7 @@ def test_cli_batch_reuses_one_diarizer_for_all_nonempty_files(tmp_path):
) as load_diarizer,
patch("local_transcriber.cli.write_transcript") as write,
):
out = runner.invoke(app, [str(first), str(second), "--diarize"])
out = runner.invoke(app, [str(first), str(second), *cli_args])
assert out.exit_code == 0
load_diarizer.assert_called_once()
@@ -1232,6 +1259,48 @@ def test_cli_config_applied(tmp_path):
assert mock_load_model.call_args[0][0] == "tiny"
@pytest.mark.parametrize(
("config_value", "cli_args", "expected_calls"),
[
(True, [], 1),
(False, [], 0),
(False, ["--diarize"], 1),
(True, ["--no-diarize"], 0),
],
)
def test_cli_resolves_diarization_priority(
tmp_path, config_value, cli_args, expected_calls
):
audio = tmp_path / "meeting.mp3"
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)
load_diarizer = MagicMock()
with (
patch(
"local_transcriber.cli.load_config",
return_value={"diarize": config_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", load_diarizer),
patch("local_transcriber.cli.write_transcript"),
):
out = runner.invoke(app, [str(audio), *cli_args])
assert out.exit_code == 0
assert load_diarizer.call_count == expected_calls
def test_cli_config_overrides_auto_device(tmp_path):
audio = tmp_path / "test.mp3"
audio.write_bytes(b"fake")
+34 -1
View File
@@ -1,4 +1,3 @@
from pathlib import Path
from unittest.mock import patch
import pytest
@@ -41,6 +40,23 @@ def test_load_config_valid(tmp_path):
assert result == {"model": "small", "language": "ru"}
@pytest.mark.parametrize("value", [True, False])
def test_load_config_accepts_diarize_boolean(tmp_path, value):
config = tmp_path / "config.toml"
config.write_text(f"diarize = {str(value).lower()}\n")
assert load_config(config) == {"diarize": value}
@pytest.mark.parametrize("toml_value", ['"true"', "1"])
def test_load_config_rejects_non_boolean_diarize(tmp_path, toml_value):
config = tmp_path / "config.toml"
config.write_text(f"diarize = {toml_value}\n")
with pytest.raises(ValueError, match="должно быть логическим"):
load_config(config)
def test_load_config_malformed(tmp_path):
config = tmp_path / "config.toml"
config.write_text("this is not valid toml [[[")
@@ -100,9 +116,26 @@ def test_resolve_defaults_hardcoded_fallback():
"language": "ru",
"device": "auto",
"compute_type": "float32",
"diarize": False,
}
@pytest.mark.parametrize(
("cli_value", "config_value", "expected"),
[
(None, True, True),
(None, False, False),
(True, False, True),
(False, True, False),
],
)
def test_resolve_defaults_diarize_priority(cli_value, config_value, expected):
cli = {"diarize": cli_value}
config = {"diarize": config_value}
assert resolve_defaults(cli, config)["diarize"] is expected
def test_apply_device_defaults_cuda():
defaults = {"model": "medium", "language": "ru", "device": "auto", "compute_type": "float32"}
cli = {"model": None, "language": None, "device": None, "compute_type": None}
File diff suppressed because it is too large. Load diff