Локально транскрибировать аудио/видео запись в markdown - offline ASR с диаризацией. Брать для "транскрибируй запись" и когда надо поднять сам движок на машине. Облако - `mymeet-snapshot`.
Installs into .claude/skills of the current project.
Are you the author of Local Transcription?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/dewil-local-transcription)
---
name: local-transcription
description: Локально транскрибировать аудио/видео запись в markdown - offline ASR с диаризацией. Брать для "транскрибируй запись" и когда надо поднять сам движок на машине. Облако - `mymeet-snapshot`.
---
# local-transcription
Локальная транскрипция записи встречи в markdown - offline и приватно, как дополнение к облачным транскрипт-сервисам (mymeet, KTalk и т.п.). Оборачивается в один CLI, который берет любой аудио/видео и выдает `.md` со спикерами и таймкодами.
## Когда НЕ применять
- **Нужны имена участников** - локальный движок дает обезличенных "Спикер N": имена берутся из метаданных звонка, а их знает только облачный сервис (mymeet, KTalk). Есть раздельные дорожки - имена появляются (см. "Сначала проверь дорожки"); нет - проставляются вручную.
- **Запись публичная и облако устраивает** - там же и summary, и имена, и скорость; локальная транскрипция стоит времени машины.
- **Нужен перевод или неподдержанный язык** - скилл про распознавание речи, а не про перевод; движок специализирован под язык записи.
- **Нет самого файла** - живая встреча не транскрибируется задним числом; сначала запись.
## Пайплайн и выбор движка
Схема: **специализированный локальный ASR** (с пословными таймкодами) + **token-free диаризатор** + склейка "слово -> спикер по перекрытию таймкодов" -> markdown.
- **ASR: бери ASR, специализированный под язык записи, а не универсальный Whisper.** Проверено на русском: GigaAM v3 (SberDevices, MIT) заметно точнее локального Whisper (large-v3-turbo гарблит слова, теряет пунктуацию и регистр) и держит паритет с облачным сервисом по тексту. Для другого языка - аналогичный language-specialized открытый ASR. Whisper - дефолт только если специализированного под язык нет.
- **Диаризация: sherpa-onnx** (pyannote-segmentation ONNX + speaker-embedding ONNX). Модели свободно качаются из релизов sherpa-onnx, БЕЗ gated HF-токена (в отличие от чистого pyannote.audio). Число участников, если известно, задавай явно - точнее.
- **Apple SpeechAnalyzer (macOS 26+):** премиальный `SpeechTranscriber` поддерживает узкий список локалей и многие неанглийские языки НЕ покрывает (напр., русского нет); их тянет только `DictationTranscriber` (движок диктовки - без пунктуации, слабее). Перед ставкой на Apple для неанглийского - проверь `supportedLocales` ОБОИХ транскрайберов через мини Swift-CLI (Speech.framework есть в CLT-SDK, полный Xcode не нужен).
## Грабли ASR-библиотек (проверено на GigaAM)
- **Штатный longform может тянуть gated-модель.** У GigaAM `model.transcribe_longform()` подтягивает `pyannote.audio` с моделью под HF-токеном - то есть упирается в ту же gated-стену, от которой уводит выбор sherpa-onnx для диаризации. Вывод: **резать на куски самому**, а не штатным longform. Достаточно фиксированной нарезки под потолок ASR (так сделано в эталонной реализации) - gated-стена обходится уже этим. Плата: граница куска может пройти по середине слова, и оно теряется с обеих сторон. Улучшение, если это мешает, - нарезка по речи своим VAD (silero из релизов sherpa-onnx, токенов не требует): режет по паузам и не гоняет тишину через ASR. В эталонной реализации VAD не сделан.
- **У ASR бывает жесткий потолок длины куска.** GigaAM отвечает `ValueError: Too long wav file` на аудио длиннее 25 секунд - резать надо с запасом (24 с). Без этого пайплайн падает на первом же длинном сегменте речи.
- **Скрытые зависимости.** `torchaudio` не значится в зависимостях gigaam, но импортируется в `preprocess.py` - без него пакет не грузится вообще. Ставится отдельно, с индекса CPU-колес.
## Потолок диаризации (важное и неочевидное)
Из ОДНОЙ смешанной дорожки звонка аудио-only диаризация надежно разделяет **~2-3 голоса**. 4+ похожих голосов (особенно однополых на сжатом миксе) сливаются - и это НЕ лечится числом кластеров / порогом / более сильным эмбеддингом: упирается в различимость эмбеддингов, а не в настройку. Облачные сервисы дают чистые N спикеров с именами, потому что берут разметку из метаданных платформы звонка (active-speaker events), а не из аудио. Практический вывод: приблизительная обезличенная разметка - свойство **смешанной дорожки**, а не локальной транскрипции как таковой. За точное именованное N-way разделение отвечают раздельные дорожки участников (см. следующий раздел), не тюнинг диаризатора; нет дорожек - спикеры выходят обезличенные и приблизительные, имена проставляются вручную.
## Сначала проверь дорожки
До запуска диаризации выясни, не разложены ли голоса по дорожкам: пофайловый экспорт по участникам (умеют многие рекордеры и телефония - раздельные стороны звонка) или каналы внутри одного файла. Число каналов не угадывай - проверь: `ffprobe -v error -show_entries stream=channels -of csv=p=0 <файл>`; телефонная запись часто оказывается двухканальной со сторонами по каналам. Если дорожки есть - диаризацию не запускай вовсе: спикер известен по построению, разметка точная и именованная, и ASR чище - на изолированной дорожке голос не перекрыт чужим.
```bash
transcribe-meeting ivan.wav petr.wav --track-names "Иван,Петр" # файл = участник
transcribe-meeting call.wav --split-channels --track-names "Иван,Петр" # канал = сторона звонка
```
`--speakers` при этом не передавай - он про диаризацию смешанной дорожки. Дорожки из общего микрофона не изолированы: чужой голос протекает в соседнюю тише и с искаженным текстом; такие двойники движок отсекает сам (отключается `--no-bleed-filter`).
## Три фронта над одним CLI
Ядро - один CLI (напр. `transcribe-meeting <файл...> [--speakers N] [--no-diar] [--track-names "А,Б"] [--split-channels] [-o out.md] [--title]`), а поверх:
1. **Терминал** - прямой вызов.
2. **Скилл в проекте** - агент находит локальный файл, гонит CLI, кладет md по конвенциям встреч рядом с облачными транскриптами, цепляет summary и сверку имен.
3. **macOS Быстрое действие** (Finder, правый клик). Грабли:
- Quick Action стартует с **урезанным PATH** - обертка ОБЯЗАНА `export PATH` с brew-путем, иначе ffmpeg не найден.
- Shell-скрипт в трее Automator всегда показывает **"0%"** (он не читает прогресс) - гони прогресс через уведомления по фазам, длинный шаг (диаризацию) подписывай явно ("долгий шаг"), иначе выглядит как зависание.
- Руками положенный `.workflow` регистрируется в подменю **"Службы"**, а НЕ в секции "Быстрые действия" - функционально то же, но чтобы поднять именно в "Быстрые действия", пересохрани бандл в Automator (Cmd+S).
## Один движок на машину
Движок (venv, веса ASR, ONNX-модели диаризации) - **машинный ресурс, не проектный**: ставится один раз глобально и в проектах не дублируется. CLI кладется в `~/.local/bin` с shebang на venv - тогда он зовется из любой папки и не тянет за собой знание о том, где venv.
- Краткая справка "что уже стоит и как звать" - в **пользовательский** `~/.claude/CLAUDE.md` (он читается во всех проектах устройства). Машинный runbook с точными путями, установкой и бенчмарками - в проекте, где движок поднимали.
- Нужна проектная специализация (свои конвенции путей, шапка документа) - делать тонкую обертку поверх общего CLI, а не второй движок.
Повод для правила: параллельная сессия из другого каталога развернула на той же машине **второй** экземпляр - свой venv на 4,9 ГБ, свой клон репозитория ASR, свои копии моделей. Справку в пользовательском файле она либо не увидела, либо стартовала раньше. Поэтому первым шагом любой работы с движком - проверить, не стоит ли он уже (`which <cli>`), а не начинать с установки.
## Очередь прогонов и защита машины
Прогоны на машине идут **по очереди**, их разводит файловый лок; уникальные имена файлов для этого недостаточны. `mkdtemp` (см. ниже) снимает коллизии имен, но не конкуренцию за ресурсы: один процесс держит ~1,5 ГБ RSS, а с `--threads 0` или `--fast` ставит `num_threads` по числу ядер, поэтому два таких прогона на 4-ядерной машине дерутся за CPU и рискуют упереться в память.
Четыре предохранителя, каждый закрывает свой сценарий - по отдельности ни один не достаточен:
1. **Лок с человеческим содержимым.** Эксклюзивный `flock` на общий файл берется **до тяжелых импортов** (`import torch` и прочего). Иначе смысл теряется: ждущий процесс уже загрузил модель в память. Взял лок первым - грузишь модель; ждешь - висишь на голом интерпретаторе (~20 МБ), и очередь любой длины стоит по памяти как один прогон. В сам файл лока пиши, **кто занял**: проект, файл, время старта, pid - ждущий печатает это словами ("занято проектом X, файл Y, идет 6 мин, pid 1746675"), и непонятное ожидание превращается в понятное, а заодно видно, что снимать, если прогон завис. Ожидание ограничивай таймаутом (`--wait-timeout`, разумный дефолт - часы): иначе один зависший держатель подвешивает всех навсегда. Флаг `--no-wait` - выйти сразу вместо ожидания (для расписаний и фоновых вызовов).
2. **Проверка свободной памяти до загрузки модели.** Лок может быть свободен, а память уже съедена браузером и агентами. Тогда ждать, а не лезть (`--min-free-mb`).
3. **Лимит памяти на прогон (cgroup).** Главное против "положил машину": без лимита превышение обрабатывает **глобальный** OOM-killer и выбирает жертву по всей системе - падает база, докер, что угодно. Под своим scope (`systemd-run --user --scope -p MemoryMax=... -p MemorySwapMax=0`) OOM локальный: умирает только транскрибация. Своп режем намеренно - уход инференса в своп по последствиям не лучше нехватки памяти. Симптом упора в лимит: процесс молча "Killed" - лечится повышением лимита. Должно отключаться флагом; нет systemd-user (macOS) - предупредить и работать без лимита.
4. **Фоновый режим по умолчанию: прогон уступает машину.** Лок разводит прогоны между собой, но не защищает остальных жильцов машины (сессии, браузеры, боты, cron): три длинных записи подряд со всеми ядрами подняли нагрузку до 32 на 6-ядерном сервере. Поэтому по умолчанию потоков половина доступных ядер, не больше 4 (`max(1, min(4, cpu // 2))`), и процесс идет с `nice 19`: при простое прогон берет свое, при конкуренции уступает. Это и есть защита. Scope дополнительно получает `CPUWeight=20` и `IOWeight=20`, но они действуют, только если пользовательскому systemd делегированы контроллеры `cpu` и `io` - на части машин их нет (проверено 25.09.2026 на сервере: `io` не делегирован вовсе), и полагаться на них нельзя. Строка "Режим:" при старте печатает фактический `nice`, прочитанный у процесса; не удалось понизить приоритет - строка говорит об этом прямо. Цена - время: на двух потоках RTF около 0,7 против 0,35 на всех ядрах (замер проекта), то есть примерно вдвое дольше. Режим печатается строкой при старте, иначе медленный прогон выглядит зависшим. `--fast` - для свободной машины: все ядра, обычный приоритет; лимит памяти он не снимает (это `--no-limit`). `--threads 0` - все ядра при фоновом приоритете, явный `--threads N` сильнее `--fast`.
Отдельная причина, почему очередь, а не просто "переживем конкуренцию": докачка весов у некоторых ASR-библиотек **неатомарна** - файл пишется прямо в целевой путь, и готовым считается любой существующий. Два одновременных первых запуска на новую модель оставляют битый кэш, который потом молча грузится.
**Лок помогает только тем, кто ходит через общий CLI.** Второй движок, поставленный мимо (свой venv, свой скрипт), про лок не знает - поэтому "один движок на машину" выше не гигиена, а условие работоспособности очереди.
## Устойчивость CLI
- Все промежуточные файлы - в СВОЕМ temp-каталоге на прогон (`mkdtemp`), не по фиксированным именам: иначе **параллельные запуски конфликтуют** на одинаковых temp-именах.
- md писать **атомарно** (во временный файл, потом `os.replace`) - прерванный прогон не оставит полу-файла.
- Обработчики **SIGINT/SIGTERM + atexit** - при остановке чистить temp-каталог и недописанные файлы.
## Формат выхода
md: шапка (файл, длительность, дата, движок, число спикеров) + транскрипт блоками `**[чч:мм:сс] Спикер N:** текст`. При диаризации смешанной дорожки в шапке пометка "диаризация приблизительная - сверить имена вручную", подписи обезличенные, имена сопоставляются вручную после. При многодорожечном входе спикеры именованы по построению (`**[чч:мм:сс] Иван:**`) - сверять нечего, в шапке отметка о дорожках вместо пометки о приблизительности. По этому md проект дальше строит свое summary (смысл и задачи выводим сами, не из движка).
## Установка движка (машинно, разово)
**Эталонная реализация - `transcribe_meeting.py` рядом с этим файлом.** Это не скрипт для раскатки по проектам: он ставится на машину **один раз** (см. "Один движок на машину") и дальше зовется из любой папки. Порядок: положить его в `~/.local/share/giga-transcribe/`, создать venv с ASR и зависимостями, положить в `~/.local/bin/<cli>` трехстрочный launcher, который экспортирует `GIGA_TRANSCRIBE_HOME` и запускает движок питоном из venv. В нем реализованы решения этого скилла: своя нарезка под потолок куска 24 с (без gated longform), лок с метаданными и таймаутом, проверка памяти, cgroup-лимит, `mkdtemp` на прогон, атомарная запись md, многодорожечный вход с фильтром протечки. Чего в нем нет - нарезки по речи (VAD) и защиты от вырожденных случаев на очень длинных записях (полная матрица расстояний в автодиаризации растет квадратично от числа сегментов). Правки метода вносите в него, а не в форк в проекте.
Ниже - то, что придется решать при установке независимо от реализации:
- Ставь специализированный ASR из **исходника (git)**, если на PyPI устаревшая версия (частый случай у молодых проектов).
- На python.org-сборке Python выставь `SSL_CERT_FILE` через `certifi` - иначе скачивание весов сырым `urllib` падает на `CERTIFICATE_VERIFY_FAILED`.
- Диаризационные ONNX-модели (segmentation + speaker-embedding) - из релизов sherpa-onnx.
- Инференс на CPU у таких моделей **однопоточный** (в мониторе ~1 ядро, выглядит как простой - но не висит): на длинных файлах закладывай время, при потоке выноси на GPU-хост.
- Ориентир по скорости - всегда с указанием железа и того, что именно замерено, иначе он вводит в заблуждение. Замерено на x86 CPU, 4 ядра, GigaAM v3_e2e_rnnt: **полный прогон 42-минутной записи - ~15 минут на всех ядрах** (`--fast`; RTF ≈ 0,35 по всему пайплайну; в фоновом режиме по умолчанию на двух потоках - RTF около 0,7, примерно вдвое дольше; чистый ASR-шаг заметно быстрее, диаризация поверх него - секунды). Считай по полному прогону: ffmpeg, нарезка и склейка тоже входят в ожидание пользователя.
## Coupled tooling
Скилл - драйвер локального CLI, который ставится на машину (пути/venv свои). Здесь - переносимый метод, решения, грабли и эталонная реализация; машинно-специфичное (точные пути на этом устройстве, команды установки под его ОС, бенчмарки своего железа) держи в runbook того проекта, где движок поднимали, и краткую справку "что стоит и как звать" - в пользовательском `~/.claude/CLAUDE.md`.