transcribe
Транскрибирование видео и аудио файлов. Используй когда пользователь просит транскрибировать, расшифровать запись, сделать конспект встречи, извлечь речь из видео или аудио, преобразовать речь в текст. Для аудио (m4a/mp3/wav/ogg/flac/aac/wma) по умолчанию локальный faster-whisper
Install
npx skills add https://github.com/Desko77/claude-code-skills-1c/tree/main/skills/transcribe
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install desko77-claude-code-skills-1c@llmmart
git clone https://github.com/Desko77/claude-code-skills-1c.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole desko77/claude-code-skills-1c collection as a plugin from our marketplace. Git is the plain clone.
README
transcribe
Транскрибация аудио и видео с двумя движками:
- Локальный (default для аудио):
faster-whisper(CUDA) + опц. диаризацияsherpa-onnxGPU. Бесплатно, не уходит наружу. Видео тоже можно разобрать полностью локально ---engine local(разбор экрана локальной VLM через LM Studio + распознавание спикеров по голосу). - Gemini API (default для видео и
--analyze-ui):gemini-2.5-flash, разбор экрана + скриншоты. ~$0.10/час.
Спикеры в локальном видео определяются по ГОЛОСУ (накопительная голосовая база - узнает людей между встречами) и по репликам. Подробнее - в SKILL.md, раздел "Спикеры и голосовая база".
Производительность на RTX 5070 Ti Laptop: ~7 мин на 30 мин аудио с диаризацией (RTF ~0.24).
Если нужна только локальная транскрипция аудио (без видео и Gemini) - используйте более легкий скил transcribe-audio-local в этом же репо.
Системные требования
- Python 3.10+ (рекомендуется 3.12)
- NVIDIA GPU с CUDA 12 и cuDNN 9 (для локального GPU режима)
- ffmpeg и ffprobe - либо в PATH, либо setup поставит
static-ffmpeg(pip-пакет с обоими бинарниками) в venv-whisper автоматически. Системные права не нужны. - Windows x64 или Linux x64
- ~6 ГБ свободного места (venv-whisper ~2 ГБ, venv-sherpa ~3 ГБ, модели ~91 МБ)
- Для Gemini-режима: API-ключ с https://aistudio.google.com/apikey
Установка
# 1. Скопировать папку скила в ~/.claude/skills/transcribe/
# 2. Запустить установщик
python ~/.claude/skills/transcribe/scripts/setup.py
Что произойдет:
- Проверка Python и ffmpeg.
- Создание
venv-whisper/со всеми зависимостями (faster-whisper, ctranslate2-CUDA, google-genai, python-dotenv, nvidia-*). - Создание
venv-sherpa/с sherpa-onnx GPU + onnxruntime-gpu. - Скачивание моделей в
models/с GitHub releases k2-fsa. - Создание шаблона
.env.
Время установки: 10-20 минут (зависит от скорости интернета).
Флаги setup.py
| Флаг | Когда нужен |
|---|---|
--skip-whisper |
venv-whisper уже создан |
--skip-sherpa |
Диаризация не нужна |
--skip-models |
Модели уже скачаны |
--skip-gemini |
Только локальный движок, без Gemini |
--with-pyannote |
Доп. поставить pyannote.audio 4.x для fallback диаризации (требует HF_TOKEN) |
--allow-cpu |
Разрешить установку на машине без NVIDIA GPU (CPU-режим, в 10+ раз медленнее) |
Проверка установки
python scripts/verify.py # быстрая проверка (5 сек)
python scripts/verify.py --full # с реальным прогоном локальной транскрипции (~1-2 мин)
Что проверяется: venv-whisper (faster-whisper + google-genai) и venv-sherpa с импортами, модели, ffmpeg/ffprobe в PATH, заполнен ли GEMINI_API_KEY в .env. С --full дополнительно запускает локальную транскрипцию tiny-модели.
Для AI-агентов
Установка длинная (~20-30 минут с загрузкой моделей и Gemini-зависимостей). Перед запуском setup.py через subprocess/Bash увеличьте таймаут до 30 минут или используйте фоновый режим. См. раздел "Для агента" в SKILL.md.
Заполнить .env
После setup откройте ~/.claude/skills/transcribe/.env и впишите ключ Gemini:
GEMINI_API_KEY=AIza...
Получить ключ: https://aistudio.google.com/apikey (бесплатная квота ~1500 запросов/день для Gemini 2.5 Flash).
Если установлен pyannote 4.x (флаг --with-pyannote) и хотите им пользоваться - дополнительно:
HF_TOKEN=hf_...
Read-токен с https://huggingface.co/settings/tokens, нужно принять условия моделей: pyannote/speaker-diarization-3.1, pyannote/segmentation-3.0. Для default sherpa-onnx HF_TOKEN не нужен.
Локальный разбор видео (--engine local): настройка LM Studio
Полностью локальный разбор ВИДЕО (экран + речь + спикеры по голосу, без облака) использует локальный LLM-сервер LM Studio. Базовый setup.py его НЕ ставит - настраивается отдельно.
Установите LM Studio (https://lmstudio.ai/) - Windows/Linux/Mac.
Скачайте в LM Studio 3 модели (вкладка Search):
qwen3-vl-8b-instruct- зрение по кадрам экрана (VLM);google/gemma-4-26b-a4b- связный лог + саммари;qwen2.5-32b-instruct- маппинг спикеров по репликам.
Квантизацию берите под свою VRAM (на 12-16 ГБ - Q4). Модели грузятся по очереди (скрипт свопит одну за раз).
Запустите локальный сервер: LM Studio -> вкладка Developer (Local Server) -> Start Server. По умолчанию
http://localhost:1234.Контекст: для параллельной обработки кадров поставьте context length побольше (16384+) - скрипт сам выведет число параллельных слотов под unified KV cache.
Доп. Python-зависимости для локального видео (в тот python, которым запускаете
analyze_video_local.py):pip install Pillow numpy(
Pillow- дедуп кадров,numpy- голосовые отпечатки.)Если сервер не на
localhost:1234- пропишите в.env:LOCAL_150_BASE=http://ХОСТ:ПОРТ/v1
При старте скрипт проверит /v1/models и внятно сообщит, какой модели не хватает.
Использование
Запуск из venv-whisper:
# Аудио, локально (по умолчанию)
~/.claude/skills/transcribe/venv-whisper/Scripts/python.exe \
~/.claude/skills/transcribe/scripts/transcribe_local.py \
"audio.mp3"
# Аудио с диаризацией
~/.claude/skills/transcribe/venv-whisper/Scripts/python.exe \
~/.claude/skills/transcribe/scripts/transcribe_local.py \
"audio.mp3" --diarize
# Видео через Gemini + анализ интерфейса (с скриншотами)
~/.claude/skills/transcribe/venv-whisper/Scripts/python.exe \
~/.claude/skills/transcribe/scripts/transcribe.py \
"video.mp4" --analyze-ui --with-summary
# Видео ПОЛНОСТЬЮ ЛОКАЛЬНО (без облака): экран локальной VLM + спикеры по голосу
python ~/.claude/skills/transcribe/scripts/analyze_video_local.py \
"video.mp4" --diarize --num-speakers 4 --project "МойПроект"
На Linux/Mac: venv-whisper/bin/python вместо venv-whisper/Scripts/python.exe.
Выходные файлы
Сохраняются в <каталог-входа>/Транскрипция/<имя>/:
| Файл | Когда создается |
|---|---|
<имя> - транскрипция.md |
всегда (md с таймкодами) |
<имя> - транскрипция.txt |
локальный движок (plain text) |
<имя> - со спикерами.md |
--diarize (реплики с [Имя/SPEAKER_XX, MM:SS]) |
<имя> - детальный.md |
--analyze-ui (Gemini) или --engine local - дословный лог экрана |
<имя> - связный.md |
--engine local (видео) - связный нарратив экран+речь |
<имя> - саммари.md |
Gemini --with-summary/--analyze-ui или --engine local |
<имя>.voiceprints.json |
--engine local (видео) - отпечатки голоса спикеров |
screenshots/ |
--analyze-ui (Gemini) или --engine local (PNG-кадры) |
Архитектура
скил/
├── SKILL.md # описание для Claude Code
├── README.md # эта инструкция
├── .env # ключи API (создается setup)
├── glossary.txt # термины и ослышки распознавателя (правится руками)
├── scripts/
│ ├── transcribe_local.py # orchestrator локального аудио (faster-whisper + diarize)
│ ├── transcribe.py # Gemini API клиент (видео + analyze-ui, chunked+parallel)
│ ├── analyze_video_local.py # локальный разбор видео (экран + речь + спикеры по голосу)
│ ├── local_backends.py # VLM/LLM на LM Studio + нарезка кадров ffmpeg
│ ├── text_stage.py # общий текст-модуль: спикеры->имена, связный лог, саммари
│ ├── glossary.py # подсказка терминов распознавателю + правка ослышек
│ ├── speaker_validator.py # программная проверка имен спикеров (без моделей)
│ ├── voiceprints.py # голосовая база (enrollment + матчинг по голосу)
│ ├── voiceprints_dedup.py # разбор и слияние дублей голосовой базы
│ ├── diarize_sherpa.py # worker диаризации sherpa-onnx (+ отпечатки голоса)
│ ├── diarize_moss.py # worker MOSS end-to-end (ASR + диаризация одной моделью)
│ ├── setup.py # установщик
│ └── verify.py # проверка установки
├── venv-whisper/ # создается setup: faster-whisper + Gemini + CUDA
├── venv-sherpa/ # создается setup: sherpa-onnx + onnxruntime-gpu
└── models/ # скачивается setup
├── sherpa-onnx-pyannote-segmentation-3-0/model.onnx
└── 3dspeaker_speech_eres2net_base_200k_sv_zh-cn_16k-common.onnx
Два venv нужны из-за конфликта CUDA DLL: ctranslate2 (faster-whisper) и onnxruntime-gpu (sherpa-onnx) грузят несовместимые версии cuDNN. Изоляция через subprocess.
При --diarize транскрипция и диаризация запускаются параллельно.
Стоимость
- Локальный движок: бесплатно (только электричество).
- Gemini 2.5 Flash: ~$0.10 за 1 час записи. Бесплатная квота AI Studio покрывает большинство личных задач.
Troubleshooting
ffmpeg не найден в PATH
Windows: скачайте с https://www.gyan.dev/ffmpeg/builds/, распакуйте, добавьте bin/ в PATH.
Linux: sudo apt install ffmpeg.
CUDA out of memory
Большая модель large-v3-turbo требует ~3 ГБ VRAM. Используйте меньшую:
~/.claude/skills/transcribe/venv-whisper/Scripts/python.exe \
~/.claude/skills/transcribe/scripts/transcribe_local.py \
audio.mp3 --model openai/whisper-small --compute-type int8_float16
Или --device cpu (в 10+ раз медленнее).
sherpa_onnx не устанавливается с CUDA
Setup пробует pip install sherpa-onnx -f https://k2-fsa.github.io/sherpa/onnx/cuda.html. Если упало:
- Скачайте wheel вручную с https://huggingface.co/csukuangfj2/sherpa-onnx-wheels (например
sherpa_onnx-1.13.0+cuda12.cudnn9-cp312-cp312-win_amd64.whlдля Python 3.12 Windows). - Установите:
~/.claude/skills/transcribe/venv-sherpa/Scripts/pip.exe install <wheel>.
CPU-вариант: pip install sherpa-onnx без -f.
Gemini 503 / 429 / quota
Бесплатная квота Gemini 2.5 Flash ~1500 запросов/день. На больших файлах (>1ч) может закончиться. Варианты:
- Подождать сброса квоты (00:00 PT)
- Для аудио - fallback на локальный движок (
transcribe_local.py) - Заплатить за PAYG-тарифа в AI Studio
Модели не скачиваются
URL k2-fsa releases:
- https://github.com/k2-fsa/sherpa-onnx/releases/download/speaker-segmentation-models/sherpa-onnx-pyannote-segmentation-3-0.tar.bz2
- https://github.com/k2-fsa/sherpa-onnx/releases/download/speaker-recongition-models/3dspeaker_speech_eres2net_base_200k_sv_zh-cn_16k-common.onnx
Если недоступны - скачайте вручную в models/, повторите setup.py --skip-models (распаковка архива все равно произойдет).
CUDA DLL не находятся
- Установлен ли CUDA 12 Toolkit?
- В
venv-whisper/Lib/site-packages/nvidia/есть пакеты cublas/cudnn/cuda_runtime? - Перезапустите терминал после установки CUDA.
Лицензии
- faster-whisper - MIT
- sherpa-onnx - Apache 2.0
- pyannote-segmentation-3.0 (ONNX) - MIT (k2-fsa)
- 3D-Speaker eres2net - Apache 2.0
- Gemini API - условия Google
Skill manifest
/transcribe - Транскрибация видео и аудио
Два движка:
- Локальный (default для аудио):
faster-whisper(CUDA) + опц. диаризация. Движок диаризации выбирается сам: без--num-speakers-pyannote community-1(GPU, корректный автодетект числа спикеров, RTF ~0.064); с явным--num-speakers N-sherpa-onnx GPU(pyannote-segmentation-3.0 + eres2net, RTF ~0.24, точное N). Опция--diarize-engine moss- MOSS-Transcribe-Diarize end-to-end: ASR+диаризация одной моделью (без whisper-шага), лучше текст на технических терминах, но ~2x медленнее (RTF ~0.34), требуетvenv-moss(envMOSS_PYTHON). Нет затрат, не уходит наружу. ВИДЕО тоже можно разобрать полностью локально ---engine local(разбор экрана локальной VLM + спикеры по голосу, см. ниже). - Gemini (default для видео и
--analyze-ui): облачный API, ~$0.10/час. Нужен интернет и квота. Стартовая модельgemini-2.5-flash(пин конкретной версии, дешевая); при перегрузке (503/429) переходит наgemini-2.5-flash-lite. Дорогие 3.5/pro сознательно исключены.
Выбор движка по умолчанию
| Тип файла | Движок | Причина |
|---|---|---|
| Аудио (m4a, mp3, wav, ogg, flac, aac, wma) | local | Быстро, бесплатно, диаризация |
| Видео (mp4, mkv, webm, avi, mov) | gemini | Быстро, облако. Приватный вариант - --engine local (см. ниже) |
Видео + --engine local |
local | Разбор экрана БЕЗ облака: whisper + локальная VLM (LM Studio) + спикеры по голосу |
Любой + --analyze-ui |
gemini | Детальный разбор интерфейсов в облаке |
Любой + --engine gemini |
gemini | Явный override на облако |
Аудио + --engine local |
local | Явный override (аудио) |
При 503/429 Gemini-движок сначала сам перебирает пул моделей (см. раздел "Авто-fallback по моделям Gemini"). Если весь пул недоступен и это аудио - можно вручную переключиться на local (--engine local).
Режимы
Локальный (аудио + faster-whisper + опц. pyannote)
Выходные файлы:
<имя> - транскрипция.md- таймкоды + текст<имя> - транскрипция.txt- plain text<имя> - со спикерами.md- реплики с метками[SPEAKER_XX, MM:SS](только при--diarize)
Gemini generic
Выходные файлы:
<имя> - транскрипция.md- речь с таймкодами + спикеры (если различимы)<имя> - саммари.md- протокол встречи (с флагом--with-summary); строится из текста транскрипции в 2 прохода (экстрактор всех фактов -> протокол) для полноты задач/решений
Gemini analyze-ui (только видео)
Анализ видеозаписи с разбором экранного интерфейса + скриншоты. Детальный лог и транскрипция пишутся по частям сразу (инкрементально): сбой на поздней части длинного видео не теряет ранние. Саммари строится в конце из полной транскрипции (2 прохода) - это протокол задач/решений; разбор показанных интерфейсов - в детальном логе.
Выходные файлы:
<имя> - саммари.md<имя> - детальный.md<имя> - транскрипция.mdscreenshots/- PNG-кадры
Локальный разбор видео (--engine local, только видео)
Полностью локальный разбор экрана + речи БЕЗ облака (подробности в "Инструкция" ниже). Спикеры распознаются по голосу (голосовая база) и по репликам - см. "Спикеры и голосовая база".
Выходные файлы:
<имя> - транскрипция.md/.txt<имя> - со спикерами.md(при--diarize)<имя> - детальный.md- дословный лог: описание экрана по кадрам + реплики за интервал<имя> - связный.md- связный нарратив экран+речь (если не--no-coherent)<имя> - саммари.md- протокол задач/решений (если не--no-summary)<имя>.voiceprints.json- отпечатки голоса кластеровscreenshots/- ВСЕ scene-кадры
Аргументы
| Параметр | Обязательный | По умолчанию | Описание |
|---|---|---|---|
| FilePath | да | - | Путь к аудио/видеофайлу |
| --output-dir | нет | <каталог>/Транскрипция/<имя>/ |
Каталог результатов |
| --engine | нет | auto (local для аудио, gemini для видео) | local или gemini |
| --diarize | нет | выкл | Локальный движок: разделение по спикерам |
| --num-speakers N | нет | автодетект (pyannote community-1) | Точное число спикеров; с ним движок переключается на sherpa-onnx (быстрее) |
| --min-speakers N / --max-speakers N | нет | - | Границы автодетекта (движок pyannote) |
| --diarize-engine | нет | авто: без N - pyannote, с N - sherpa-onnx; явно - moss (end-to-end) | sherpa-onnx без --num-speakers пересегментирует (242 кластера на ~7 чел). moss - ASR+диаризация одной моделью (текст точнее на терминах, RTF ~0.34, требует venv-moss) |
| --analyze-ui | нет | выкл | Gemini: анализ интерфейсов (только видео) |
| --with-summary | нет | выкл | Gemini: добавить саммари |
| --format | нет | md | Формат: md или txt |
| --model | нет | gemini-2.5-flash | Gemini: стартовая модель (или env GEMINI_MODEL) |
| --fallback-models | нет | встроенный пул | Gemini: цепочка fallback через запятую (или env GEMINI_FALLBACK_MODELS) |
| --no-fallback | нет | выкл | Gemini: только стартовая модель, без перебора |
| --project NAME | нет | - | local видео: пометить встречу в голосовой базе (провенанс) |
| --voiceprint-db PATH | нет | voiceprints/db.json скилла |
local видео: путь к голосовой базе |
| --no-voiceprints | нет | выкл | local видео: не использовать и не пополнять голосовую базу |
| --no-coherent | нет | выкл | local видео: не строить связный лог (быстрее) |
| --no-summary | нет | выкл | local видео: не строить саммари |
| --speaker-model M | нет | qwen2.5-32b | local видео: LLM для маппинга спикеров -> имена |
| --reuse-transcript | нет | выкл | local видео: не гонять whisper заново, если транскрипция уже есть |
| --no-vlm | нет | выкл | local видео: не разбирать экран моделью зрения (кадры все равно нарезаются). Штатный способ получить речь+спикеров+саммари, когда зрение не нужно или сервер занят |
| --reuse-frames | нет | выкл | local видео: взять готовые описания кадров из <имя>.status.json прошлого прогона и дораспознать только оставшиеся |
| --glossary PATH | нет | glossary.txt в корне скила |
Термины и ослышки: правильные написания подсказываются распознавателю, ослышки правятся в готовом тексте (DAX вместо "ДАКС") |
| --no-glossary | нет | выкл | Не использовать глоссарий терминов |
Поддерживаемые форматы
- Видео: mp4, mkv, webm, avi, mov
- Аудио: mp3, wav, ogg, m4a, flac, aac, wma
Зависимости
Локальный движок:
- venv whisper (отдельный, изоляция CUDA-DLL): путь в env
WHISPER_PYTHON; дефолт~/.claude/skills/transcribe/venv-whisper(faster-whisper, ctranslate2-CUDA, ffmpeg) - Для
--diarizeБЕЗ--num-speakers(default): движокpyannoteс чекпойнтомpyannote/speaker-diarization-community-1- корректный автодетект числа спикеров (16.07.26: 8 при истине ~7, RTF 0.064). Нужныtorch+pyannote.audio>=4в whisper-venv,HF_TOKENв.env(read-токен с принятыми условиямиpyannote/speaker-diarization-community-1; для старых чекпойнтов такжеspeaker-diarization-3.1,segmentation-3.0). Отпечатки голоса при этом считает venv-sherpa по готовым turns (diarize_sherpa.py --from-turns) - то же eres2net-пространство, что и голосовая база. - Для
--diarizeС--num-speakers N(default): движокsherpa-onnxGPU CUDA,~/.claude/skills/transcribe/venv-sherpaс GPU-сборкойsherpa_onnx 1.13.0+cuda12.cudnn9от k2-fsa maintainer (HuggingFacecsukuangfj2/sherpa-onnx-wheels). pyannote-segmentation-3.0 + 3D-Speaker eres2net эмбеддинги в ONNX. RTF ~0.24, никаких HF gated моделей. ВНИМАНИЕ: пороговый автодетект sherpa (без N) СЛОМАН - пересегментирует (эксперимент 04.07: пороги 0.5-0.8 давали 21-45 спикеров при истине 4; прогон 16.07: 242 кластера на ~7 человек). Слабое звено - эмбеддер eres2net-zh-cn (EER 5.3 в бенчмарке Шмырева против 1.1-1.6 у топов). - CUDA GPU обязателен для обоих движков
Gemini движок:
- Python-пакеты:
google-genai,python-dotenv - Системные:
ffmpeg,ffprobeв PATH - API-ключ в
~/.claude/skills/transcribe/.env:GEMINI_API_KEY=...
Установка и настройка (для агента)
НЕ проверяй сервер/venv/модели вручную ПЕРЕД запуском. Скрипты сами читают .env и делают свой префлайт (печатают [0/5] проверка сервера ... + какие модели резолвятся). Просто ЗАПУСТИ нужный скрипт (см. "Инструкция") и читай ЕГО вывод.
Что где (скрипт берет из .env сам, тебе знать не обязательно, руками НЕ проверяй):
- Локальный сервер VLM - из
LOCAL_150_BASEв.env(может быть удаленный хост, НЕ обязательноlocalhost). НЕ проверяйlocalhost:1234. - Python для whisper - из
WHISPER_PYTHONв.env(может быть внешний venv, НЕ обязательно skill-овыйvenv-whisper). НЕ проверяй skill-venv.
Ставить/чинить - ТОЛЬКО если скрипт при запуске сам сообщил, что сервер/модель/whisper недоступны:
- установка:
python ~/.claude/skills/transcribe/scripts/setup.py(ДОЛГО ~20-30 мин, фоном; флаги--skip-gemini/--skip-sherpa/--with-pyannote/--allow-cpu), проверкаverify.py --full; - для
--engine local(видео) нужен запущенный LM Studio (адрес изLOCAL_150_BASE) с моделями (VLMqwen3-vl-30b-a3b-instructилиqwen3-vl-8b,gemma-4-26b,qwen2.5-32b) +Pillow/numpyв python запуска. Пошагово - в README.
.env (~/.claude/skills/transcribe/.env, gitignore, НЕ коммить): GEMINI_API_KEY, HF_TOKEN (если pyannote), WHISPER_PYTHON, LOCAL_150_BASE, LOCAL_VLM_MODEL.
Инструкция
"Локально" / "без облака" = ТОЛЬКО локальный движок (--engine local / analyze_video_local.py). Если пользователь просит локально - НЕ запускай Gemini и НЕ старый analyze_video.py. Локаль-скрипт сам конфигурится из .env (сервер + whisper) - просто запусти его.
Недоступность сервера или модели больше НЕ повод останавливаться. Стадии деградируют поодиночке: нет модели зрения - будут речь, спикеры и саммари; нет сервера вовсе - будут речь и спикеры (whisper считает на своей машине, а голосовая база и проверка имен моделей не требуют). Запусти скрипт, прочитай <имя>.status.json и скажи пользователю, что именно осталось неразобранным. Останавливаться и ничего не отдавать - хуже, чем отдать неполный результат с честным перечнем дыр.
Gemini в локальном режиме запрещен ВСЕГДА (данные встреч конфиденциальны). А вот прочитать глазами непокрытые кадры - можно: это аварийный слой, ограничитель тут цена, а не приватность. Порядок такой: сперва дай скрипту отработать (при HTTP 400 по контексту он сам снижает параллельность, при выгруженной модели - сам ее поднимает), затем возьми из status.json список кадров со state не равным ok и посмотри ТОЛЬКО их файлы из screenshots/. Не читай все кадры подряд - в 24-минутной встрече их бывает под сотню.
Определи
FilePathи флаги. По расширению файла и флагам выбери движок (см. таблицу выше).Если расширение - аудио, и нет
--engine gemini, и нет--analyze-ui→ запускай локальный:
PYTHONUNBUFFERED=1 PYTHONIOENCODING=utf-8 \
~/.claude/skills/transcribe/venv-whisper/Scripts/python.exe \
~/.claude/skills/transcribe/scripts/transcribe_local.py \
"<FilePath>" [--output-dir "<OutputDir>"] [--diarize] [--num-speakers N] [--min-speakers N] [--max-speakers N] [--glossary PATH] [--no-glossary]
Локальный пайплайн:
- Транскрипция и диаризация запускаются в отдельных subprocess параллельно (изоляция CUDA-DLL ctranslate2 vs torch).
- 27-мин аудио = ~10 мин общего времени (RTF ~0.4).
- Часовое аудио = ~25 мин общего времени.
- Диаризация - только при
--diarize. Без нее ~1.5-2 мин на 27-мин файл.
- Если это ВИДЕО и указан
--engine local→ запускай ПОЛНОСТЬЮ ЛОКАЛЬНЫЙ разбор экрана (без облака):
PYTHONUNBUFFERED=1 PYTHONIOENCODING=utf-8 python ~/.claude/skills/transcribe/scripts/analyze_video_local.py "<FilePath>" [--output-dir "<OutputDir>"] [--diarize] [--num-speakers N] [--project NAME] [--voiceprint-db PATH] [--no-voiceprints] [--no-coherent] [--no-summary] [--no-vlm] [--reuse-transcript] [--reuse-frames] [--glossary PATH] [--no-glossary]
Речь - локальный whisper; разбор экрана - qwen3-vl-8b-instruct на локальном сервере LM Studio; связный лог и саммари - google/gemma-4-26b-a4b; маппинг спикеров по репликам - qwen2.5-32b. Кадры обрабатываются параллельно (число слотов выводится из контекста VLM под unified KV cache). Клиентские кадры НЕ уходят в облако. Спикеры распознаются слоями: по ГОЛОСУ (голосовая база, узнает людей между встречами) и по репликам - см. "Спикеры и голосовая база". Предусловия: сервер LM Studio доступен (по умолчанию http://localhost:1234, env LOCAL_150_BASE), модели qwen3-vl-8b-instruct + google/gemma-4-26b-a4b + qwen2.5-32b-instruct загружены (скрипт проверяет и внятно сообщает, если модели нет). Выход: транскрипция / со спикерами / детальный / связный / саммари / voiceprints.json / screenshots/ (ВСЕ scene-кадры). ВНИМАНИЕ: локальное зрение НЕ гарантирует посимвольную точность (в отличие от облака) - финансовые цифры сверять с экраном. Env-переопределения: LOCAL_150_BASE, LOCAL_VLM_MODEL, LOCAL_SUMMARY_MODEL, LOCAL_SPEAKER_MODEL, SCENE_THRESHOLD, FRAME_FLOOR_SEC, FRAME_CAP, WHISPER_PYTHON.
- Иначе (видео без
--engine local, или явный--engine gemini, или--analyze-ui) - запускай Gemini:
PYTHONUNBUFFERED=1 python ~/.claude/skills/transcribe/scripts/transcribe.py "<FilePath>" [--output-dir "<OutputDir>"] [--analyze-ui] [--with-summary] [--format md|txt] [--model MODEL] [--fallback-models "m1,m2"] [--no-fallback]
Скрипт долгий (5-15 мин), файлы >1 ч разбиваются автоматически.
Fallback при перегрузке Gemini (503 / 429): скрипт сам перебирает пул моделей (см. "Авто-fallback по моделям Gemini"), доп. действий не требуется. Если весь пул недоступен и это аудио - крайний случай: локальный движок (см. шаг 2).
После завершения покажи пользователю пути к файлам и прочитай начало транскрипции / саммари.
ВАЖНО: PYTHONUNBUFFERED=1 обязательно для прогресса.
Спикеры и голосовая база
В локальном разборе видео (--engine local) имена спикеров определяются ТРЕМЯ слоями (голос приоритетнее текста, проверка идет последней):
- По голосу (голосовая база). Диаризация считает отпечаток голоса каждого спикера (eres2net-эмбеддинг). Отпечаток сверяется с накопительной базой
voiceprints/db.jsonпо косинусной близости - так узнаются даже неназванные люди и ОДИН человек между разными встречами. Это больше, чем делает облако (оно вяжет имена только внутри одной записи). - По репликам (текст). LLM (
qwen2.5-32b) читает транскрипт и вяжет имена по обращениям ("Иван, что скажешь?"), самопредставлениям, ссылкам. Ответ запрашивается строгим JSON по схеме - формат гарантирует сервер, а не послушание модели. - Программная проверка (
speaker_validator.py). Обязательная, работает без всяких моделей. Отдельно от разбора обращений она снимает имена, которые в записи НЕ ЗВУЧАЛИ НИ РАЗУ: на встрече, где никого не назвали, модель уверенно выдает правдоподобный набор ("Роман", "Станислав"), и такое имя раньше проходило насквозь - порог улик к предложениям модели по замыслу не применяется, а других улик у выдумки нет. Пустая метка честнее выдуманного имени. Имя, подтвержденное голосовой базой, эта проверка не трогает. Причина остальных правок: ВСЕ проверенные модели (qwen2.5-32b, qwen3-vl-30b, qwen3.6 в том числе с размышлениями) систематически вешают имя на того, кто его ПРОИЗНОСИТ, хотя произносящий обращается к другому - на эталонной встрече три модели дали три разных ответа, совпав на одной метке из шести. Правило в промпте это не лечит. Проверка: имя из звательной позиции ("Марина, логика та же", "Да, Леш?") вешается на того, кто ОТВЕЧАЕТ, а не на говорящего; упоминание в третьем лице (косвенный падеж, имя с фамилией, "как Алексей просил") кандидатом не считается; усеченные формы сводятся к полной (Леш -> Алексей); роли и заглушки ("Модератор", "неизвестно") отбрасываются; род говорящего проверяется по форме глаголов ("я сделал" против "я сделала"); одно имя не висит на двух метках. Имена, подтвержденные голосом, проверка не пересматривает.
Проверку можно запустить отдельно на готовом транскрипте - она покажет все найденные обращения и оценки:
python ~/.claude/skills/transcribe/scripts/speaker_validator.py "<файл - со спикерами.md>"
Авто-пополнение (бутстрап): если человек назван текстом, но в базе его еще нет - его отпечаток заносится в базу, и на будущих встречах он узнается уже по голосу. Заносятся только имена, прошедшие проверку (она же гарантирует, что одно имя не висит на нескольких метках - иначе в одну запись базы попали бы голоса разных людей).
Провенанс: --project NAME помечает, в каком проекте/встрече встречался человек (полезно при пересечении людей между проектами).
Управление: --no-voiceprints (не трогать базу), --voiceprint-db PATH (своя база), --speaker-model (модель текстового слоя). Голоса - чувствительные данные: база хранится ЛОКАЛЬНО и не коммитится.
Просмотр / ручной enroll базы:
python ~/.claude/skills/transcribe/scripts/voiceprints.py list
python ~/.claude/skills/transcribe/scripts/voiceprints.py match --prints "<имя>.voiceprints.json"
Термины и ослышки (глоссарий)
Распознаватель уверенно ослышивается на англицизмах и жаргоне, и молча: DAX -> "ДАКС", JSON -> "G-Splone", PROD -> "Прот", гашения -> "базаты". Дальше по конвейеру ошибку никто не ловит - текстовая модель принимает ослышку за факт и тащит ее в связный лог и в саммари.
Лечится файлом glossary.txt в корне скила (UTF-8):
DAX = ДАКС, дэкс, ДАХ # слева правильное написание, справа ослышки
JSON = джейсон, G-Splone
регламентное задание # строка без "=" - только подсказка распознавателю
Как работает:
- Подсказка (hotwords). Правильные написания уходят в промпт КАЖДОГО окна распознавания - модель чаще выбирает знакомую форму. Профилактика, не гарантия: список режется по лимиту промпта.
- Правка по факту. Ослышки заменяются в готовом тексте - и в обычной транскрипции, и в файле со спикерами (он собирается заново из слов, поэтому правится отдельно). Дальше по конвейеру идет уже верный текст.
- Регистр не важен (
ДАКСловит идакс), границы слов соблюдаются (Протоколне превращается вPRODокол), ослышки короче 3 символов игнорируются. - Формы пишутся ЛИТЕРАЛЬНО, какими вышли из распознавателя: морфологии здесь нет, поэтому и слева форма под стать ослышке (
гашения = базаты).
Дополняй файл по итогам своих встреч - увидел ослышку, добавь строку. Проверить разбор и замены:
python ~/.claude/skills/transcribe/scripts/glossary.py "текст с ослышкой"
Управление: --glossary PATH (свой файл), --no-glossary (выключить), env TRANSCRIBE_GLOSSARY.
Проверка связного лога на выдумку
Связный лог собирается текстовой моделью из описаний кадров, и модель там сочиняет: на реальном прогоне 08.2026 в нарративе оказались восемь сумм, кодов счетов и годов, которых в описаниях кадров не было (4,800.00, 90.01.1, ОКС0100222, диапазоны "от 2009 до 2019"). Запрет в промпте это не держит.
Поэтому после сборки каждое число и код нарратива сверяются с исходным материалом. Не подтвержденные выносятся сноской в конец файла <имя> - связный.md:
> **Не подтверждено кадрами.** Эти числа и коды есть в нарративе, но их нет в описаниях экрана...
Вырезать их автоматически нельзя - порвется фраза, поэтому решение за человеком: проверить по скриншотам в screenshots/. Промпты зрения и связного лога дополнительно требуют помечать нечитаемое как "не читается" и не обобщать перечисления в диапазоны.
Авто-fallback по моделям Gemini
При 503 (перегрузка серверов Google) или 429 (лимит) скрипт автоматически переходит к следующей модели из пула, пока одна не ответит. Ретрай одной модели делает SDK, смену модели - скрипт.
Дефолтная цепочка (только дешевые модели 2.5):
gemini-2.5-flash -> gemini-2.5-flash-lite.
Дорогие модели (gemini-3.5-flash, *-pro, плавающие *-latest) сознательно НЕ в цепочке: плавающий gemini-flash-latest дрейфовал в gemini-3.5-flash и дал 96% счета за июнь 2026 (видео-вход в 5x дороже 2.5-flash). Нужна максимальная надежность любой ценой - добавить их через --fallback-models.
Управление:
--model MODEL- стартовая модель (или envGEMINI_MODEL).--fallback-models "m1,m2,..."- переопределить цепочку (или envGEMINI_FALLBACK_MODELS).--no-fallback- только стартовая модель, без перебора.
503 - серверная перегрузка Gemini, она НЕ зависит от тарифа (платный тариф не помогает). Перебор моделей - официально рекомендованный обход. По умолчанию перебор идет только по дешевым 2.5-моделям.
Стоимость
- Локальный движок: бесплатно (только электричество).
- Gemini: flash-класс ~$0.10-0.30 за 1 час записи. По умолчанию перебор только по дешевым 2.5-моделям (дорогие 3.5/pro исключены).
Ограничения
- Локальный АУДИО-движок (whisper) сам по себе не делает анализ интерфейсов. Для локального разбора ЭКРАНА видео есть отдельный путь
--engine local(analyze_video_local.py: whisper + локальная VLM на LM Studio + спикеры по голосу) - требует доступный сервер LM Studio и загруженные модели; посимвольная точность зрения не гарантирована. - Локальный движок требует CUDA GPU.
- Pyannote 4.x (диаризация) - модели gated, нужны принятые условия + HF-токен.
- Кириллические имена файлов: скриптом обрабатываются.
- Точность таймкодов +/- несколько секунд.
--analyze-uiс аудиофайлом → fallback на Gemini generic + саммари.
Files (claude-code-skills-1c)
-
scripts
-
analyze_video_local.py 45 KB
""" analyze_video_local.py - ПОЛНОСТЬЮ ЛОКАЛЬНЫЙ разбор видео (стадия B без облака). Аналог `transcribe.py --analyze-ui`, но без Gemini: клиентское видео не покидает сеть. Пайплайн: 1. Речь - transcribe_local.py (whisper venv, CUDA) -> `<имя> - транскрипция.md/.txt`. 2. Спикеры - имена за метками: голосовая база отпечатков, затем модель на 150, затем ОБЯЗАТЕЛЬНАЯ программная проверка по обращениям (speaker_validator). 3. Кадры - extract_scene_frames (ffmpeg scene-detect + пол + dhash-дедуп + кап) и зрение: каждый кадр -> VLM на сервере 150, лог пишется инкрементально по таймкодам. 4. Связный - `<имя> - связный.md`: нарратив из механического лога. 5. Саммари - `<имя> - саммари.md`: 2 прохода на 150, вход - только текст транскрипции. Стадии деградируют ПООТДЕЛЬНОСТИ. Нет модели зрения - будут речь, спикеры и саммари; нет 150 вообще - будут речь и спикеры (голос и разбор обращений моделей не требуют). Сбой кадра не прерывает разбор: кадр помечается, прогон идет дальше. Что сделано, а что нет, пишется в `<имя>.status.json` - по нему же работает резюм `--reuse-frames`. Спикеры считаются ДО зрения: раньше стадия стояла после него, и любая проблема с VLM обнуляла именование и авто-enroll голосовой базы, к зрению отношения не имеющие. Запуск: python analyze_video_local.py "<video>" [--output-dir DIR] [--diarize] [--no-vlm] [--no-summary] [--no-coherent] [--reuse-transcript] [--reuse-frames] """ import os import re import sys import json import argparse import threading import subprocess from concurrent.futures import ThreadPoolExecutor, as_completed from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent)) import local_backends as lb # noqa: E402 import text_stage as ts # noqa: E402 import voiceprints as vp # noqa: E402 import speaker_validator as sv # noqa: E402 import glossary as gloss # noqa: E402 # Whisper живет в отдельном venv (изоляция CUDA-DLL ctranslate2 vs torch). Дефолт - venv скилла; # реальный путь задай через env WHISPER_PYTHON (или .env, он gitignore и не в паблик-репо). # .env уже загружен импортом local_backends выше, поэтому os.environ здесь его видит. _DEFAULT_WHISPER_PY = Path.home() / ".claude" / "skills" / "transcribe" / "venv-whisper" / "Scripts" / "python.exe" WHISPER_PYTHON = os.environ.get("WHISPER_PYTHON", str(_DEFAULT_WHISPER_PY)) TRANSCRIBE_LOCAL = Path(__file__).resolve().parent / "transcribe_local.py" VIDEO_EXTS = {".mp4", ".mkv", ".webm", ".avi", ".mov"} CONSEC_FAIL_ABORT = 3 # столько сбоев подряд => проверить сервер и модель. Раньше это обрывало # разбор совсем и бросало весь хвост кадров без единой попытки; теперь по этому порогу идет # попытка восстановления, и стадия останавливается, только если сервер действительно не поднялся. # Промпты и текстовая обработка (спикеры -> имена, связный лог, саммари) вынесены в общий # модуль text_stage - единый источник истины для локального и облачного движков. def _append(path: Path, text: str): with open(path, "a", encoding="utf-8") as f: f.write(text) # ---------------- Шаг 1: речь ---------------- def run_whisper(video: Path, output_dir: Path, diarize=False, extra=None, reuse=False): """Запустить transcribe_local.py в whisper-venv. Вернуть путь к `<имя> - транскрипция.md`. Транскрипция пишется ДО ожидания диаризации, поэтому при падении ТОЛЬКО диаризации (rc != 0, но файл создан и непуст) считаем речь успешной и продолжаем. reuse=True: если транскрипция уже есть и непуста - не гонять whisper заново (resume-режим, напр. речь посчитана раньше, а разбор экрана делаем позже, когда поднялся сервер 150). """ md = output_dir / f"{video.stem} - транскрипция.md" if reuse and md.exists() and md.stat().st_size > 0: spk = output_dir / f"{video.stem} - со спикерами.md" note = "со спикерами" if spk.exists() else "без спикеров" print(f"[речь] переиспользую готовую транскрипцию ({note}, --reuse-transcript): {md.name}", flush=True) return md if not Path(WHISPER_PYTHON).exists(): raise lb.LocalBackendError( f"whisper-venv python не найден: {WHISPER_PYTHON}. " "Задай env WHISPER_PYTHON с корректным путем.") cmd = [WHISPER_PYTHON, str(TRANSCRIBE_LOCAL), str(video), "--output-dir", str(output_dir)] if diarize: cmd.append("--diarize") if extra: cmd += extra env = os.environ.copy() env["PYTHONUNBUFFERED"] = "1" env["PYTHONIOENCODING"] = "utf-8" print(f"[речь] whisper (diarize={diarize})...", flush=True) rc = subprocess.run(cmd, env=env).returncode if md.exists() and md.stat().st_size > 0: if rc != 0: print(f"[речь] whisper вернул код {rc}, но транскрипция создана - продолжаю " f"(вероятно упала только диаризация).", file=sys.stderr) return md raise lb.LocalBackendError(f"transcribe_local.py упал (код {rc}), транскрипция не создана: {md}") def parse_transcript(output_dir: Path, base: str): """Разобрать транскрипцию -> [(start_sec, text)]. При наличии диаризации берет файл со спикерами и добавляет метку спикера в текст (чтобы речь в логе/саммари была атрибутирована).""" speakers = output_dir / f"{base} - со спикерами.md" plain = output_dir / f"{base} - транскрипция.md" src = speakers if speakers.exists() else plain if not src.exists(): return [] segs = [] pat = re.compile(r"\*\*\[(?:([^,\]]+),\s*)?([\d:]+)\]\*\*\s*(.*)") for line in src.read_text(encoding="utf-8").splitlines(): m = pat.match(line.strip()) if not m: continue speaker, tstr, text = m.group(1), m.group(2), m.group(3).strip() sec = 0 for part in tstr.split(":"): sec = sec * 60 + int(part) if speaker: text = f"{speaker}: {text}" if text: segs.append((sec, text)) return segs # ---------------- Шаги 2-4: кадры + зрение + детальный лог ---------------- def _frame_block(i, frames, n, segs, desc): """Markdown-блок одного кадра: описание экрана + реплики речи за интервал до следующего кадра.""" t, fpath = frames[i] lo = 0 if i == 0 else t hi = frames[i + 1][0] if i + 1 < n else float("inf") desc = desc.replace("```", "` ` `") # нейтрализуем code-fence, чтобы не сломать рендер MD speech = [(s, txt) for s, txt in segs if lo <= s < hi and txt] block = [f"## [{lb.format_tc(t)}] экран\n", f"\n", "**На экране (распознано локально):**\n", desc, "\n"] if speech: block.append("\n**Речь в этот интервал:**\n") for s, txt in speech: block.append(f"- [{lb.format_tc(s)}] {txt}\n") block.append("\n---\n\n") return "".join(block) def load_status(path: Path): """Статус прошлого прогона. Пустой словарь, если файла нет или он испорчен.""" try: return json.loads(path.read_text(encoding="utf-8")) except (OSError, ValueError): return {} def save_status(path: Path, data): """Машиночитаемый статус рядом с выходами: что разобрано, что нет и чем упало. Нужен и человеку, и агенту: по нему видно, какие кадры остались непокрытыми, и не приходится вычитывать весь детальный лог, чтобы это выяснить. """ try: path.write_text(json.dumps(data, ensure_ascii=False, indent=1), encoding="utf-8") except OSError as e: print(f"[warn] не удалось записать статус прогона: {e}", file=sys.stderr) def _vlm_worker(fpath, vlm_model, max_tokens, state, lock): """Один кадр через VLM. Выгрузку модели по TTL лечим на месте и сбоем НЕ считаем.""" if state["abort"]: return {"state": "skipped", "error": state["abort"]} try: r = lb.vlm_read_frame(fpath, model=vlm_model, max_tokens=max_tokens) return {"state": "ok", "text": r["text"], "prompt_tokens": r["prompt_tokens"]} except lb.LocalBackendError as e: if not lb.looks_unloaded(e): return {"state": "failed", "error": str(e)} with lock: # грузим один раз на всех, а не каждым потоком ready = lb.ensure_loaded(vlm_model) if ready: try: r = lb.vlm_read_frame(fpath, model=vlm_model, max_tokens=max_tokens) return {"state": "ok", "text": r["text"], "prompt_tokens": r["prompt_tokens"], "recovered": True} except lb.LocalBackendError as again: return {"state": "failed", "error": str(again), "unloaded": True} return {"state": "failed", "error": str(e), "unloaded": True} def analyze_frames(video: Path, output_dir: Path, segs, detailed_path: Path, status_path: Path, vlm_model=None, budget=None, reuse=False): """Нарезать кадры, прогнать через VLM параллельно, детальный лог писать инкрементально СТРОГО в порядке таймкодов. Возвращает сводку dict(frames, ok, failed, skipped, reused). Сбой кадра больше не прерывает стадию: неудачный помечается маркером, разбор идет дальше. Останавливаемся только если сервер действительно не отвечает и поднять его не удалось - тогда остаток честно помечается неразобранным, а пайплайн продолжается. """ budget = budget or lb.plan_vlm_budget(vlm_model) max_tokens = budget["max_tokens"] shots_dir = output_dir / "screenshots" print(f"[кадры] нарезка scene-кадров -> {shots_dir}", flush=True) frames, truncated = lb.extract_scene_frames(video, shots_dir) n = len(frames) done = {} if reuse: # резюм: описания прошлого прогона берем готовыми, заново зрение не гоняем for item in load_status(status_path).get("frames", []): if item.get("state") == "ok" and item.get("text") and item.get("file"): done[item["file"]] = item["text"] if done: print(f"[кадры] переиспользую готовые описания: {len(done)} (--reuse-frames)", flush=True) header = (f"# Детальный лог (локальный разбор экрана): {video.name}\n\n" f"Зрение: `{vlm_model or lb.LOCAL_VLM_MODEL}` (локально, сервер 150). " f"Кадров: {n}" + (" [достигнут кап - часть прорежена]" if truncated else "") + "\n\n---\n\n") detailed_path.write_text(header, encoding="utf-8") if not frames: _append(detailed_path, "> **[!] Кадры не извлечены** (пустое или битое видео?).\n") return {"frames": 0, "ok": 0, "failed": 0, "skipped": 0, "reused": 0, "truncated": truncated} results = {} records = [None] * n next_write = 0 # пишем строго по возрастанию таймкода, независимо от порядка ответов state = {"abort": ""} lock = threading.Lock() def remember(i, res): t, fpath = frames[i] rec = {"index": i, "tc": lb.format_tc(t), "file": fpath.name, "state": res["state"]} if res.get("error"): rec["error"] = res["error"][:400] if res.get("text"): rec["text"] = res["text"] records[i] = rec if res["state"] == "ok": results[i] = res["text"] else: results[i] = (f"> **[!] Кадр не распознан.** Причина: " f"{res.get('error') or 'стадия зрения остановлена'}") def flush_ready(): nonlocal next_write while next_write < n and next_write in results: _append(detailed_path, _frame_block(next_write, frames, n, segs, results[next_write])) next_write += 1 counters = {"ok": 0, "failed": 0, "skipped": 0, "reused": 0} pending = [] for i, (t, fpath) in enumerate(frames): if fpath.name in done: remember(i, {"state": "ok", "text": done[fpath.name]}) counters["reused"] += 1 else: pending.append(i) flush_ready() # Первый кадр идем ОДИН: его prompt_tokens и есть настоящий резерв контекста под картинку # (он зависит от разрешения, а не от содержимого экрана). До этого замера параллельность # считается от оценки из конфига, обычно завышенной вдвое - и зря режет пропускную способность. if pending: first = pending.pop(0) res = _vlm_worker(frames[first][1], vlm_model, max_tokens, state, lock) remember(first, res) counters[res["state"]] += 1 flush_ready() if res.get("prompt_tokens"): budget = lb.replan_with_measured(budget, res["prompt_tokens"]) print(f"[кадры] замер на первом кадре: промпт {res['prompt_tokens']} ток. " f"-> резерв {budget['reserve']}, параллельно {budget['parallel']}", flush=True) parallel = budget["parallel"] print(f"[кадры] всего {n}, к разбору {len(pending)} (параллельно {parallel}, " f"вывод/кадр {max_tokens})", flush=True) consec_fail = 0 with ThreadPoolExecutor(max_workers=parallel) as ex: futs = {ex.submit(_vlm_worker, frames[i][1], vlm_model, max_tokens, state, lock): i for i in pending} # Ожидаемые сбои VLM возвращаются воркером как state=failed; программные ошибки # (KeyError и прочее) прилетают из fut.result() и НЕ ловятся - пусть падают громко. for fut in as_completed(futs): i = futs[fut] res = fut.result() remember(i, res) counters[res["state"]] += 1 if res["state"] == "ok": consec_fail = 0 mark = " (после перезагрузки модели)" if res.get("recovered") else "" print(f" [кадр {i+1}/{n}] {lb.format_tc(frames[i][0])} ok{mark}", flush=True) elif res["state"] == "failed": consec_fail += 1 print(f" [кадр {i+1}/{n}] {lb.format_tc(frames[i][0])} СБОЙ: " f"{res.get('error', '')[:200]}", file=sys.stderr, flush=True) flush_ready() if consec_fail >= CONSEC_FAIL_ABORT and not state["abort"]: # Подряд идущие сбои - повод проверить сервер, а не молча бросить остаток кадров. print(f"[кадры] {consec_fail} сбоя подряд - проверяю сервер и модель", file=sys.stderr, flush=True) try: lb.check_server() alive = lb.ensure_loaded(vlm_model) except lb.LocalBackendError: alive = False if alive: consec_fail = 0 print("[кадры] сервер жив, модель загружена - продолжаю", flush=True) else: state["abort"] = ("сервер 150 не отвечает или модель зрения не поднялась - " "остаток кадров не разобран") print(f"[кадры] {state['abort']}", file=sys.stderr, flush=True) # Переполнение контекста - не отказ сервера, а слишком большая параллельность: слоты делят # ОДНО окно. Политика прямо требует снизить параллельность и ПОВТОРИТЬ, а не терять кадры. # Без этого шага исправный VLM оставлял бы почти все кадры неразобранными. overflow = [i for i in range(n) if records[i] and records[i]["state"] == "failed" and lb.looks_context_overflow(records[i].get("error"))] if overflow and not state["abort"]: print(f"[кадры] {len(overflow)} кадров не влезли в контекст при параллельности {parallel}" f" - повторяю по одному", flush=True) for i in overflow: res = _vlm_worker(frames[i][1], vlm_model, max_tokens, state, lock) was = records[i]["state"] remember(i, res) counters[was] -= 1 counters[res["state"]] += 1 print(f" [кадр {i+1}/{n}] {lb.format_tc(frames[i][0])} повтор: {res['state']}", flush=True) # Лог пишется инкрементально, поэтому маркеры сбоя по этим кадрам уже на диске: # пересобираем его целиком из накопленных описаний, иначе повтор починил бы только статус. detailed_path.write_text(header, encoding="utf-8") next_write = 0 flush_ready() flush_ready() for i in range(n): # кадры, до которых очередь не дошла из-за остановки стадии if records[i] is None: remember(i, {"state": "skipped", "error": state["abort"] or "не обработан"}) counters["skipped"] += 1 flush_ready() if counters["failed"] or counters["skipped"]: _append(detailed_path, f"\n> **[!] Разбор экрана неполный:** распознано {counters['ok'] + counters['reused']}" f" из {n}, сбоев {counters['failed']}, не обработано {counters['skipped']}." + (f" Причина остановки: {state['abort']}." if state["abort"] else "") + "\n") summary = {"frames": n, "truncated": truncated, **counters, "aborted_reason": state["abort"] or None, "records": [r for r in records if r]} return summary # ---------------- Шаг 5: текстовая стадия (общий text_stage) ---------------- def _transcript_text(segs): """Текст транскрипции из сегментов [(sec, text)] для текстовой стадии.""" return "\n".join(f"[{lb.format_tc(s)}] {txt}" for s, txt in segs if txt) # ---------------- main ---------------- def main(): # Вывод содержит кириллицу. Без явного переключения печать падает с UnicodeEncodeError # везде, где консоль не в UTF-8: сборочный агент, чужая локаль. sys.stdout.reconfigure(encoding='utf-8') sys.stderr.reconfigure(encoding='utf-8') ap = argparse.ArgumentParser(description="Локальный разбор видео (whisper + VLM/LLM на 150), без облака") ap.add_argument("video", help="Путь к видеофайлу") ap.add_argument("--output-dir", "-o", default=None) ap.add_argument("--diarize", action="store_true", help="Диаризация речи (whisper); спикеры попадут в лог/саммари") ap.add_argument("--num-speakers", type=int, default=None, help="Точное число спикеров (опционально; без него автодетект pyannote community-1)") ap.add_argument("--no-summary", action="store_true", help="Не строить саммари") ap.add_argument("--no-coherent", action="store_true", help="Не строить связный лог (быстрее)") ap.add_argument("--no-vlm", action="store_true", help="Не разбирать экран моделью зрения (речь, спикеры и саммари считаются как обычно). " "Кадры при этом все равно нарезаются - их можно посмотреть глазами") ap.add_argument("--reuse-transcript", action="store_true", help="Переиспользовать готовую транскрипцию (не гонять whisper заново), если файл уже есть и непуст") ap.add_argument("--reuse-frames", action="store_true", help="Переиспользовать описания кадров из статуса прошлого прогона (дораспознать только оставшиеся)") ap.add_argument("--vlm-model", default=None, help=f"VLM на 150 (по умолч. {lb.LOCAL_VLM_MODEL})") ap.add_argument("--summary-model", default=None, help=f"Summary на 150 (по умолч. {lb.LOCAL_SUMMARY_MODEL})") ap.add_argument("--speaker-model", default=None, help=f"Маппинг спикеров (по умолч. {lb.LOCAL_SPEAKER_MODEL})") ap.add_argument("--glossary", default=None, help="Файл глоссария терминов (по умолч. glossary.txt в корне скила). " "Правильные написания подсказываются распознавателю, ослышки правятся в " "готовом тексте - иначе DAX уходит в отчет как 'ДАКС'") ap.add_argument("--no-glossary", action="store_true", help="Не использовать глоссарий терминов") ap.add_argument("--voiceprint-db", default=None, help=f"База голосов (по умолч. {vp.DEFAULT_DB})") ap.add_argument("--project", default=None, help="Проект/заказчик - провенанс в базе голосов") ap.add_argument("--no-voiceprints", action="store_true", help="Не использовать голосовую базу") args = ap.parse_args() video = Path(args.video) if not video.exists(): print(f"Файл не найден: {video}", file=sys.stderr) sys.exit(1) if video.suffix.lower() not in VIDEO_EXTS: print(f"Не видео: {video.suffix}. Локальный разбор экрана - только для видео " f"({', '.join(sorted(VIDEO_EXTS))}).", file=sys.stderr) sys.exit(1) output_dir = Path(args.output_dir) if args.output_dir else video.parent / "Транскрипция" / video.stem output_dir.mkdir(parents=True, exist_ok=True) vlm_model = args.vlm_model or lb.LOCAL_VLM_MODEL summary_model = args.summary_model or lb.LOCAL_SUMMARY_MODEL speaker_model = args.speaker_model or lb.LOCAL_SPEAKER_MODEL voiceprint_db = args.voiceprint_db or str(vp.DEFAULT_DB) def gemma_llm(data, instruction): """LLM-вызов текстовой стадии на 150 (gemma): связный лог + саммари. Callable для text_stage.""" return lb.llm_summary_pass(data, instruction, model=summary_model, max_tokens=6000) def speaker_llm(data, instruction): """Маппинг спикеров на 150 строгим JSON по схеме: формат гарантирует сервер, а не послушание модели. Отдаем текстом - text_stage разбирает ответ сам и одинаково понимает движки со строгими схемами и без них.""" return json.dumps(lb.llm_json_pass(data, instruction, ts.SPEAKERS_SCHEMA, model=speaker_model, max_tokens=2000), ensure_ascii=False) transcript_md = output_dir / f"{video.stem} - транскрипция.md" detailed_path = output_dir / f"{video.stem} - детальный.md" coherent_path = output_dir / f"{video.stem} - связный.md" summary_path = output_dir / f"{video.stem} - саммари.md" status_path = output_dir / f"{video.stem}.status.json" # ----- Предполетная проверка: постадийно, а не все-или-ничего ----- # Отсутствие одной модели гасит ТОЛЬКО свою стадию. Раньше любая недостача обрывала прогон # целиком, и человек не получал даже транскрипцию, хотя речь считается локально и от 150 # вообще не зависит. print(f"[0] проверка сервера 150: {lb.LOCAL_150_BASE}", flush=True) models = [] try: models = lb.check_server() except lb.LocalBackendError as e: print(f"[0] сервер 150 недоступен ({e}). Речь и спикеры посчитаю, разбор экрана и саммари - нет.", file=sys.stderr, flush=True) use_vlm = not args.no_vlm if args.no_vlm: print("[0] разбор экрана отключен (--no-vlm)", flush=True) elif vlm_model not in models: use_vlm = False print(f"[0] модель зрения '{vlm_model}' на 150 недоступна - кадры будут нарезаны, " f"но не разобраны", file=sys.stderr, flush=True) if use_vlm: try: lb.ffmpeg_exe() except lb.LocalBackendError as e: use_vlm = False print(f"[0] {e} - разбор экрана невозможен", file=sys.stderr, flush=True) use_text = summary_model in models if not use_text: print(f"[0] text-модель '{summary_model}' на 150 недоступна - связного лога и саммари не будет", file=sys.stderr, flush=True) use_speaker_llm = speaker_model in models if not use_speaker_llm: print(f"[0] speaker-модель '{speaker_model}' на 150 недоступна - имена определю " f"по голосовой базе и обращениям в тексте", file=sys.stderr, flush=True) budget = None if use_vlm: budget = lb.plan_vlm_budget(vlm_model) print(f"[0] бюджет зрения: контекст={budget['context']} ({budget['source']}), " f"вывод/кадр={budget['max_tokens']}, параллельно={budget['parallel']} " f"(резерв промпта {budget['reserve']}, уточню на первом кадре)", flush=True) # ----- clean start: не выдать результаты прошлого прогона за текущие ----- if not args.reuse_transcript: transcript_md.write_text("", encoding="utf-8") if args.no_summary: summary_path.unlink(missing_ok=True) # не оставляем старое саммари, раз его не просили else: summary_path.write_text("", encoding="utf-8") # detailed_path сбрасывается внутри analyze_frames # ----- 1. Речь ----- whisper_extra = ["--num-speakers", str(args.num_speakers)] if args.num_speakers else [] if args.glossary: whisper_extra += ["--glossary", args.glossary] if args.no_glossary: whisper_extra.append("--no-glossary") transcript_md = run_whisper(video, output_dir, diarize=args.diarize, reuse=args.reuse_transcript, extra=whisper_extra or None) segs = parse_transcript(output_dir, video.stem) print(f"[речь] сегментов транскрипции: {len(segs)}" + (" (со спикерами)" if (output_dir / f'{video.stem} - со спикерами.md').exists() else ""), flush=True) # Страховочная правка ослышек по глоссарию. Whisper уже правит свой вывод, но при # --reuse-transcript он не запускался вовсе, а старые транскрипции сделаны до глоссария. # Замена идемпотентна (правильные написания не входят в список ослышек), так что повтор безвреден. gl = gloss.load(args.glossary, enabled=not args.no_glossary) for w in gl.warnings: print(f"[термины] {w}", file=sys.stderr) if gl: print(f"[термины] {gloss.describe(gl)}", flush=True) seg_dicts = [{"text": t} for _s, t in segs] stats = gl.fix_segments(seg_dicts) if stats: segs = [(s, d["text"]) for (s, _t), d in zip(segs, seg_dicts)] print("[термины] исправлено: " + ", ".join(f"{k} x{v}" for k, v in sorted(stats.items())), flush=True) exit_code = 0 # ----- 2. Спикеры -> имена. ДО разбора экрана ----- # Стадия к зрению отношения не имеет, а раньше стояла после него: любая проблема с VLM # обнуляла и именование, и авто-enroll голосовой базы. Свопа моделей перенос не создает - # на 150 они со-резидентны. transcript_text = _transcript_text(segs) # СЛОЙ 1 - голос (база отпечатков, cosine > порог): узнает различимых даже неназванных и между # встречами. СЛОЙ 2 - текст (модель на 150). СЛОЙ 3 - программная проверка по обращениям # (speaker_validator): без нее модель систематически вешает имя на того, кто его ПРОИЗНОСИТ, # а не на адресата. Голос приоритетнее текста, и проверка его не пересматривает. name_map = {} if transcript_text.strip(): vp_path = output_dir / f"{video.stem}.voiceprints.json" use_voice = (not args.no_voiceprints) and vp_path.exists() db, prints, voice_ids = None, {}, {} if use_voice: try: db = vp.load_db(voiceprint_db) prints = json.loads(vp_path.read_text(encoding="utf-8")) voice_ids = vp.identify(prints, db, project=args.project) if voice_ids: print("[спикеры] по голосу: " + ", ".join(f"{k}->{n}({s})" for k, (n, s) in voice_ids.items()), flush=True) except Exception as e: use_voice = False print(f"[спикеры] голосовой слой пропущен ({e})", file=sys.stderr) text_names = {} if use_speaker_llm: # log обязателен: map_speakers гасит ошибку модели внутри себя, и без него сбой # текстового слоя прошел бы молча. text_names = ts.map_speakers( transcript_text, speaker_llm, validate=False, # проверка - ниже, по общей картине log=lambda m: print(f"[спикеры] {m}", file=sys.stderr, flush=True)) merged = {label: name for label, (name, _score) in voice_ids.items()} for label, name in text_names.items(): # голос приоритетнее текста merged.setdefault(label, name) name_map, checks = sv.validate(merged, transcript_text, voice_confirmed=set(voice_ids)) for line in checks: print(f"[спикеры] проверка: {line}", flush=True) if use_voice and db is not None and name_map: # авто-enroll: голос не узнал, но имя есть # Проверка уже гарантирует, что одно имя не висит на двух метках, поэтому отдельный # подсчет неоднозначностей больше не нужен: в базу не попадут голоса разных людей # под одной записью. added, skipped = 0, 0 for label, name in name_map.items(): if label in voice_ids or label not in prints: continue if not vp.is_plausible_name(name): # мусорное имя (КС/инициалы/огрызок) - не засоряем базу skipped += 1 continue vp.enroll(db, name, prints[label], project=args.project, meeting=video.stem) added += 1 if added: try: vp.save_db(db, voiceprint_db) msg = f"[спикеры] авто-enroll в базу: +{added} голос(ов)" if skipped: msg += f" (пропущено неоднозначных: {skipped})" print(msg, flush=True) except Exception as e: print(f"[спикеры] авто-enroll не сохранен ({e})", file=sys.stderr) elif skipped: print(f"[спикеры] авто-enroll пропущен: все {skipped} имен неоднозначны", flush=True) if name_map: print(f"[спикеры] итог: {', '.join(f'{k}->{v}' for k, v in name_map.items())}", flush=True) # Имена подставляются ДО разбора экрана, поэтому детальный лог сразу пишется с ними и # переписывать его задним числом больше не нужно. segs = [(s, ts.apply_names(t, name_map)) for s, t in segs] transcript_text = _transcript_text(segs) else: print("[спикеры] имена не определены - оставляю метки", flush=True) # ----- 3. Кадры + зрение + детальный лог ----- vision = None if use_vlm: vision = analyze_frames(video, output_dir, segs, detailed_path, status_path, vlm_model=vlm_model, budget=budget, reuse=args.reuse_frames) recognized = vision["ok"] + vision["reused"] print(f"[кадры] детальный лог готов: распознано {recognized} из {vision['frames']}" + (f", сбоев {vision['failed']}" if vision["failed"] else "") + (f", не обработано {vision['skipped']}" if vision["skipped"] else ""), flush=True) if vision["frames"] and recognized < vision["frames"]: exit_code = 3 else: # Зрение выключено - кадры все равно нарезаем: их можно посмотреть глазами, а речь по # интервалам уже разложена. Раньше единственным способом сюда попасть было убийство процесса. detailed_path.write_text( f"# Детальный лог (речь по интервалам кадров): {video.name}\n\n" f"> Разбор экрана не выполнялся" f"{' (--no-vlm)' if args.no_vlm else ' - модель зрения недоступна'}. " f"Скриншоты нарезаны в `screenshots/`.\n\n---\n\n", encoding="utf-8") try: frames, _trunc = lb.extract_scene_frames(video, output_dir / "screenshots") for i in range(len(frames)): _append(detailed_path, _frame_block(i, frames, len(frames), segs, "_(экран не разобран)_")) print(f"[кадры] нарезано без разбора: {len(frames)}", flush=True) # Кадры перечисляем поименно даже без разбора: по этому списку человек или агент # находит, что именно осталось непокрытым, не вычитывая весь детальный лог. vision = {"frames": len(frames), "ok": 0, "failed": 0, "skipped": len(frames), "reused": 0, "aborted_reason": "зрение отключено", "records": [{"index": i, "tc": lb.format_tc(t), "file": p.name, "state": "skipped"} for i, (t, p) in enumerate(frames)]} except lb.LocalBackendError as e: print(f"[кадры] нарезка не удалась: {e}", file=sys.stderr) if not args.no_vlm: # выключили не мы, а недоступность модели - это неполный результат exit_code = 3 # ----- 4. Связный лог (нарратив из механического детального) ----- coherent_path.unlink(missing_ok=True) # чистый старт: не оставить старый связный лог if args.no_coherent: print("[связный] пропущено (--no-coherent)", flush=True) elif not use_text: print("[связный] пропущено: text-модель недоступна", file=sys.stderr) elif detailed_path.exists(): print("[связный] сборка связного нарратива (чанками)...", flush=True) try: unsupported = [] coherent = ts.build_coherent_log(detailed_path.read_text(encoding="utf-8"), gemma_llm, report=unsupported) if coherent: # Числа, которых нет в описаниях кадров, выносим сноской в конец файла: молча # вырезать их из нарратива нельзя (порвется фраза), а молча оставить - значит выдать # выдумку модели за прочитанное с экрана. На реальном прогоне таких было восемь - # суммы, коды счетов и годы, которых кадры не содержали. note = "" if unsupported: note = ("\n\n---\n\n> **Не подтверждено кадрами.** Эти числа и коды есть в " "нарративе, но их нет в описаниях экрана, из которых он собран - " "проверь по скриншотам, прежде чем использовать:\n>\n" + "".join(f"> - {line}\n" for line in unsupported)) coherent_path.write_text( f"# Связный лог (экран + речь): {video.name}\n\n{coherent}\n{note}", encoding="utf-8") print(f"[связный] сохранено: {coherent_path.name}", flush=True) if unsupported: print(f"[связный] ВНИМАНИЕ: {len(unsupported)} фрагмент(ов) с числами, которых " f"нет в кадрах - сноска в конце файла", file=sys.stderr, flush=True) except lb.LocalBackendError as e: print(f"[связный] ОШИБКА (пропускаю): {e}", file=sys.stderr) # ----- 5. Саммари (протокол задач и решений из полного текста) ----- summary_done = False # именно ФАКТ построения, а не "модель была доступна": иначе статус-файл # отрапортует успех там, где в саммари лежит маркер ошибки if args.no_summary: print("[саммари] пропущено (--no-summary)", flush=True) elif not use_text: summary_path.write_text( "> **[!] Саммари не построено:** text-модель на 150 недоступна.\n", encoding="utf-8") print("[саммари] пропущено: text-модель недоступна", file=sys.stderr) exit_code = exit_code or 3 else: print("[саммари] генерация протокола...", flush=True) try: summary = ts.build_summary(transcript_text, gemma_llm) if summary: summary_path.write_text(summary, encoding="utf-8") summary_done = True print(f"[саммари] сохранено: {summary_path.name}", flush=True) else: summary_path.write_text("> Транскрипция пуста - саммари не построено.\n", encoding="utf-8") print("[саммари] пустая транскрипция - саммари не построено", file=sys.stderr) except lb.LocalBackendError as e: summary_path.write_text(f"> **[!] Саммари не построено.** Причина: {e}\n", encoding="utf-8") print(f"[саммари] ОШИБКА (транскрипция и детальный лог сохранены): {e}", file=sys.stderr) exit_code = exit_code or 3 # ----- Статус прогона: что сделано, что нет и почему ----- save_status(status_path, { "video": video.name, "speakers": name_map, "stages": { "speech": {"segments": len(segs)}, "vision": {"enabled": use_vlm, **({k: v for k, v in vision.items() if k != "records"} if vision else {})}, "coherent": {"done": coherent_path.exists()}, "summary": {"done": summary_done, "skipped": bool(args.no_summary)}, }, "exit_code": exit_code, "frames": (vision or {}).get("records", []), }) print("\n" + "=" * 60) print(f"Готово (локально, без облака). Результаты в: {output_dir}") print(f" - {transcript_md.name}") print(f" - {detailed_path.name} (дословный)") if coherent_path.exists(): print(f" - {coherent_path.name} (связный)") if not args.no_summary: print(f" - {summary_path.name}") print(f" - {status_path.name} (статус прогона)") print(" - screenshots/") if exit_code: print("[!] Результат неполный (см. предупреждения выше и статус-файл).", file=sys.stderr) print("=" * 60) sys.exit(exit_code) if __name__ == "__main__": main() -
diarize_moss.py 8.4 KB
""" Worker для MOSS-Transcribe-Diarize (end-to-end ASR + диаризация). Запускается как subprocess из transcribe_local.py orchestrator. Работает в отдельном venv: <home>/.claude/skills/transcribe/venv-moss (default) либо env MOSS_PYTHON=путь_к_python.exe готового venv. Использует OpenMOSS-Team/MOSS-Transcribe-Diarize 0.9B (Qwen3-0.6B + Whisper-Medium encoder, bfloat16 на CUDA). End-to-end: текст + спикеры + таймстампы одним проходом. 50+ языков. В отличие от sherpa/pyannote (которые только сегментируют спикеров, текст дает whisper), MOSS заменяет ОБА шага - поэтому при engine=="moss" orchestrator не запускает whisper, а берет текст и спикеры из этого воркера. Аргументы: --input <audio> путь к аудио/видео (конвертируется ffmpeg в 16kHz mono WAV) --out-json <path> путь сохранения utterances JSON --provider cuda|cpu (default cuda) --max-new-tokens N лимит генерации (default 8192; ~3-5 мин аудио; для длинных поднять) Вывод JSON: {"utterances": [{"start","end","speaker","text"}], "duration": float, "language": str, "rtf": float, "model": str} speaker в формате SPEAKER_XX (маппинг из MOSS [S01]→SPEAKER_00 по порядку появления). """ from __future__ import annotations import argparse import json import os import subprocess import sys import tempfile import time from pathlib import Path def setup_nvidia_dll_path() -> None: """Зарегистрировать bin-директории nvidia.* пакетов (CUDA 12 для torch cu128).""" venv_root = Path(sys.executable).parent.parent nvidia_root = venv_root / "Lib" / "site-packages" / "nvidia" if not nvidia_root.exists(): return for sub in nvidia_root.iterdir(): bin_dir = sub / "bin" if bin_dir.is_dir(): if hasattr(os, "add_dll_directory"): try: os.add_dll_directory(str(bin_dir)) except OSError: pass os.environ["PATH"] = str(bin_dir) + os.pathsep + os.environ.get("PATH", "") setup_nvidia_dll_path() import soundfile as sf # noqa: E402 import torch # noqa: E402 from transformers import AutoModelForCausalLM, AutoProcessor # noqa: E402 from moss_transcribe_diarize import parse_transcript # noqa: E402 from moss_transcribe_diarize.inference_utils import ( # noqa: E402 build_transcription_messages, generate_transcription, resolve_device, ) MODEL_ID = "OpenMOSS-Team/MOSS-Transcribe-Diarize" def ffmpeg_to_wav16k(input_path: Path, out_wav: Path) -> None: subprocess.run( ["ffmpeg", "-y", "-i", str(input_path), "-vn", "-ac", "1", "-ar", "16000", "-acodec", "pcm_s16le", str(out_wav)], check=True, capture_output=True, ) def moss_diarize(args) -> int: with tempfile.TemporaryDirectory() as tmp: wav_path = Path(tmp) / "audio_16k.wav" print("[D] ffmpeg → 16k mono wav...", flush=True) ffmpeg_to_wav16k(Path(args.input), wav_path) samples, sr = sf.read(str(wav_path), dtype="float32") if samples.ndim > 1: samples = samples.mean(axis=1) duration = len(samples) / sr print(f"[D] Sample rate: {sr}, длительность: {duration:.1f}с", flush=True) device = resolve_device("cuda" if args.provider == "cuda" else "cpu") dtype = torch.bfloat16 if device.type == "cuda" else torch.float32 print(f"[D] device={device} dtype={dtype}", flush=True) print("[D] Загрузка MOSS (первый запуск качает ~2GB)...", flush=True) t0 = time.time() model = AutoModelForCausalLM.from_pretrained( MODEL_ID, trust_remote_code=True, dtype="auto" ).to(dtype=dtype).to(device).eval() processor = AutoProcessor.from_pretrained(MODEL_ID, trust_remote_code=True) print(f"[D] модель за {time.time() - t0:.1f}с", flush=True) max_tokens = args.max_new_tokens if max_tokens is None: max_tokens = max(8192, int(duration * 12)) # ~12 tok/сек с запасом; минимум 8192 print(f"[D] max_new_tokens=авто: {max_tokens} (длительность {duration:.0f}с)", flush=True) messages = build_transcription_messages(str(wav_path)) print("[D] Генерация (end-to-end ASR + диаризация)...", flush=True) t0 = time.time() result = generate_transcription( model, processor, messages, max_new_tokens=max_tokens, do_sample=False, device=device, dtype=dtype, ) elapsed = time.time() - t0 rtf = elapsed / duration if duration else 0.0 print(f"[D] за {elapsed:.1f}с (RTF {rtf:.3f})", flush=True) segs = list(parse_transcript(result["text"])) # Маппинг спикеров в порядке первого появления: [S01]→SPEAKER_00 (консистентно с sherpa) spk_map: dict[str, str] = {} utterances: list[dict] = [] for s in segs: key = s.speaker if key not in spk_map: spk_map[key] = f"SPEAKER_{len(spk_map):02d}" utterances.append({ "start": float(s.start), "end": float(s.end), "speaker": spk_map[key], "text": (s.text or "").strip(), }) utterances = [u for u in utterances if u["text"]] n_spk = len({u["speaker"] for u in utterances}) print(f"[D] {len(utterances)} сегментов, {n_spk} спикеров", flush=True) # Детект truncation: если последний сегмент заметно короче длительности - # генерация уперлась в max_new_tokens (молча обрезалась). if utterances and utterances[-1]["end"] < duration - 30: print(f"[D] ВНИМАНИЕ: последний сегмент на {utterances[-1]['end']:.1f}с при длительности " f"{duration:.1f}с - возможна truncation транскрипции " f"(передайте --max-new-tokens больше {max_tokens})", file=sys.stderr) payload = { "utterances": utterances, "duration": duration, "language": args.language, "rtf": rtf, "model": MODEL_ID, } Path(args.out_json).write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8") print(f"[D] → {args.out_json}", flush=True) sys.stdout.flush() sys.stderr.flush() os._exit(0) def main() -> int: # Вывод содержит кириллицу. Без явного переключения печать падает с UnicodeEncodeError # везде, где консоль не в UTF-8: сборочный агент, чужая локаль. sys.stdout.reconfigure(encoding='utf-8') sys.stderr.reconfigure(encoding='utf-8') ap = argparse.ArgumentParser() ap.add_argument("--input", required=True) ap.add_argument("--out-json", required=True) ap.add_argument("--provider", default="cuda", choices=["cuda", "cpu"]) ap.add_argument("--language", default="ru", help="Метка языка в выходных метаданных (MOSS сама определяет язык речи)") ap.add_argument("--max-new-tokens", type=int, default=None, help="Лимит генерации; None=авто по длительности (~12 tok/сек, мин 8192)") args = ap.parse_args() return moss_diarize(args) if __name__ == "__main__": import traceback try: rc = main() except SystemExit: raise except BaseException: log_path = Path.home() / ".claude" / "skills" / "transcribe" / "diarize_moss.crash.log" log_path.parent.mkdir(parents=True, exist_ok=True) with log_path.open("a", encoding="utf-8") as f: f.write("=" * 80 + "\n") f.write(f"argv: {sys.argv}\n") f.write(traceback.format_exc()) traceback.print_exc() rc = 2 sys.exit(rc) -
diarize_sherpa.py 12.9 KB
""" Worker для sherpa-onnx диаризации (CUDA через onnxruntime-gpu). Запускается как subprocess из transcribe_local.py orchestrator. Работает в отдельном venv: <home>/.claude/skills/transcribe/venv-sherpa Использует: - pyannote-segmentation-3.0 в ONNX - 3D-Speaker eres2net 200k (multilingual) embedding extractor - Спектральная кластеризация (FastClustering) для группировки эмбеддингов Аргументы: --input <audio> путь к аудио (любой формат, конвертируется через ffmpeg в 16kHz mono WAV) --out-json <path> путь сохранения turns JSON --num-speakers N точное число спикеров (отключает кластеризацию по threshold) --threshold T порог кластеризации (default 0.5, чем меньше - тем больше кластеров) --provider cuda|cpu (default cuda) --from-turns <json> режим "только отпечатки": диаризация НЕ выполняется, turns берутся из файла (получены другим движком), считаются voiceprints на кластер (eres2net - то же пространство, что и голосовая база) Вывод JSON: list[{"start": float, "end": float, "speaker": "SPEAKER_XX"}]. """ from __future__ import annotations import argparse import json import os import subprocess import sys import tempfile import time from pathlib import Path def setup_nvidia_dll_path() -> None: """Зарегистрировать bin-директории nvidia.* пакетов (CUDA 12 для onnxruntime-gpu).""" venv_root = Path(sys.executable).parent.parent nvidia_root = venv_root / "Lib" / "site-packages" / "nvidia" if not nvidia_root.exists(): return for sub in nvidia_root.iterdir(): bin_dir = sub / "bin" if bin_dir.is_dir(): if hasattr(os, "add_dll_directory"): try: os.add_dll_directory(str(bin_dir)) except OSError: pass os.environ["PATH"] = str(bin_dir) + os.pathsep + os.environ.get("PATH", "") setup_nvidia_dll_path() import numpy as np import soundfile as sf # noqa: E402 import sherpa_onnx # noqa: E402 # Вывод содержит кириллицу. Без явного переключения печать падает с UnicodeEncodeError # везде, где консоль не в UTF-8: сборочный агент, чужая локаль. sys.stdout.reconfigure(encoding='utf-8') sys.stderr.reconfigure(encoding='utf-8') MODELS_DIR = Path(__file__).resolve().parent.parent / "models" SEG_MODEL = MODELS_DIR / "sherpa-onnx-pyannote-segmentation-3-0" / "model.onnx" EMB_MODEL = MODELS_DIR / "3dspeaker_speech_eres2net_base_200k_sv_zh-cn_16k-common.onnx" def ffmpeg_to_wav16k(input_path: Path, out_wav: Path) -> None: """Конвертировать любое аудио/видео в 16kHz mono PCM_S16LE WAV через ffmpeg.""" cmd = [ "ffmpeg", "-y", "-i", str(input_path), "-vn", "-ac", "1", "-ar", "16000", "-acodec", "pcm_s16le", str(out_wav), ] subprocess.run(cmd, check=True, capture_output=True) def compute_voiceprints(samples, sr, turns, provider, max_sec=60.0, min_seg=0.6): """Отпечаток голоса на КЛАСТЕР: eres2net-эмбеддинги сегментов SPEAKER_XX, усреднение по длительности. Тот же экстрактор, что и в диаризации (переиспользуем модель). На кластер берем самые длинные сегменты (чище) суммарно до max_sec, эмбеддинги усредняем с весом по длительности и нормируем (для косинусной близости). Возвращает {SPEAKER_XX: [float, ...], ...}. """ ext = sherpa_onnx.SpeakerEmbeddingExtractor( sherpa_onnx.SpeakerEmbeddingExtractorConfig(model=str(EMB_MODEL), provider=provider, num_threads=1)) by_spk: dict[str, list[dict]] = {} for t in turns: by_spk.setdefault(t["speaker"], []).append(t) prints: dict[str, list] = {} for spk, segs in by_spk.items(): segs = sorted(segs, key=lambda s: s["end"] - s["start"], reverse=True) embs, weights, total = [], [], 0.0 for s in segs: if total >= max_sec: break a, b = int(s["start"] * sr), int(s["end"] * sr) seg = samples[a:b] dur = (b - a) / sr if dur < min_seg: continue stream = ext.create_stream() stream.accept_waveform(sr, seg) stream.input_finished() emb = np.array(ext.compute(stream), dtype=np.float32) if emb.size: embs.append(emb) weights.append(dur) total += dur if embs: v = np.average(np.stack(embs), axis=0, weights=weights) v = v / (np.linalg.norm(v) + 1e-8) prints[spk] = [round(float(x), 6) for x in v] print(f"[D] отпечаток {spk}: {total:.1f}с речи, dim={v.size}", flush=True) return prints def voiceprints_only(args) -> int: """Посчитать отпечатки голоса по готовым turns (диаризацию делал другой движок).""" if not EMB_MODEL.exists(): print(f"[D] Не найдена модель эмбеддингов: {EMB_MODEL}", file=sys.stderr) return 1 turns = json.loads(Path(args.from_turns).read_text(encoding="utf-8")) print(f"[D] Отпечатки по готовым turns: {len(turns)} turns из {args.from_turns}", flush=True) with tempfile.TemporaryDirectory() as tmp: wav_path = Path(tmp) / "audio_16k.wav" ffmpeg_to_wav16k(Path(args.input), wav_path) samples, sr = sf.read(str(wav_path), dtype="float32") if samples.ndim > 1: samples = samples.mean(axis=1) try: prints = compute_voiceprints(samples, sr, turns, args.provider) Path(args.emit_voiceprints).write_text(json.dumps(prints, ensure_ascii=False), encoding="utf-8") print(f"[D] отпечатки → {args.emit_voiceprints} ({len(prints)} кластеров)", flush=True) except Exception as e: print(f"[D] отпечатки не посчитаны: {e}", file=sys.stderr, flush=True) return 1 sys.stdout.flush() sys.stderr.flush() os._exit(0) def diarize(args) -> int: if not SEG_MODEL.exists(): print(f"[D] Не найдена модель сегментации: {SEG_MODEL}", file=sys.stderr) return 1 if not EMB_MODEL.exists(): print(f"[D] Не найдена модель эмбеддингов: {EMB_MODEL}", file=sys.stderr) return 1 input_path = Path(args.input) print(f"[D] Файл: {input_path.name}", flush=True) print(f"[D] Provider: {args.provider}", flush=True) print(f"[D] Сегментация: {SEG_MODEL.name}", flush=True) print(f"[D] Эмбеддинги: {EMB_MODEL.name}", flush=True) with tempfile.TemporaryDirectory() as tmp: wav_path = Path(tmp) / "audio_16k.wav" print(f"[D] ffmpeg → {wav_path.name}...", flush=True) t0 = time.time() ffmpeg_to_wav16k(input_path, wav_path) print(f"[D] ffmpeg готов за {time.time() - t0:.1f}с", flush=True) samples, sr = sf.read(str(wav_path), dtype="float32") if samples.ndim > 1: samples = samples.mean(axis=1) print(f"[D] Sample rate: {sr}, длительность: {len(samples) / sr:.1f}с", flush=True) config = sherpa_onnx.OfflineSpeakerDiarizationConfig( segmentation=sherpa_onnx.OfflineSpeakerSegmentationModelConfig( pyannote=sherpa_onnx.OfflineSpeakerSegmentationPyannoteModelConfig( model=str(SEG_MODEL), ), provider=args.provider, num_threads=1, debug=False, ), embedding=sherpa_onnx.SpeakerEmbeddingExtractorConfig( model=str(getattr(args, "emb_model", None) or EMB_MODEL), provider=args.provider, num_threads=1, debug=False, ), clustering=sherpa_onnx.FastClusteringConfig( num_clusters=args.num_speakers if args.num_speakers else -1, threshold=args.threshold, ), min_duration_on=0.3, min_duration_off=0.5, ) if not config.validate(): print("[D] Конфигурация невалидна", file=sys.stderr) return 1 print("[D] Загрузка моделей...", flush=True) t0 = time.time() sd = sherpa_onnx.OfflineSpeakerDiarization(config) print(f"[D] Готово за {time.time() - t0:.1f}с", flush=True) print("[D] Диаризация...", flush=True) t0 = time.time() last_pct = -1 def progress(num_processed: int, num_total: int) -> int: nonlocal last_pct pct = int(100 * num_processed / max(num_total, 1)) if pct >= last_pct + 5: last_pct = pct print(f"[D] [{pct:3d}%]", flush=True) return 0 result = sd.process(samples, callback=progress).sort_by_start_time() elapsed = time.time() - t0 turns: list[dict] = [] for r in result: turns.append({ "start": float(r.start), "end": float(r.end), "speaker": f"SPEAKER_{int(r.speaker):02d}", }) speakers_set = sorted({t["speaker"] for t in turns}) rtf = elapsed / (len(samples) / sr) if len(samples) > 0 else 0 print(f"[D] Готово за {elapsed:.1f}с (RTF {rtf:.3f}, {len(turns)} turns, {len(speakers_set)} спикеров)", flush=True) Path(args.out_json).write_text(json.dumps(turns, ensure_ascii=False), encoding="utf-8") print(f"[D] → {args.out_json}", flush=True) if args.emit_voiceprints: print("[D] Считаю отпечатки голоса на кластер...", flush=True) try: prints = compute_voiceprints(samples, sr, turns, args.provider) Path(args.emit_voiceprints).write_text(json.dumps(prints, ensure_ascii=False), encoding="utf-8") print(f"[D] отпечатки → {args.emit_voiceprints} ({len(prints)} кластеров)", flush=True) except Exception as e: print(f"[D] отпечатки не посчитаны: {e}", file=sys.stderr, flush=True) sys.stdout.flush() sys.stderr.flush() os._exit(0) def main() -> int: ap = argparse.ArgumentParser() ap.add_argument("--input", required=True) ap.add_argument("--out-json", default=None) ap.add_argument("--num-speakers", type=int, default=None) ap.add_argument("--threshold", type=float, default=0.5) ap.add_argument("--emb-model", default=None, help="Путь к ONNX-эмбеддеру голоса (по умолчанию eres2net 200k). " "ВНИМАНИЕ: голосовая база привязана к пространству эмбеддингов - " "смена модели делает накопленные отпечатки несравнимыми.") ap.add_argument("--provider", default="cuda", choices=["cuda", "cpu"]) ap.add_argument("--emit-voiceprints", default=None, help="Путь: сохранить отпечатки голоса на кластер (JSON)") ap.add_argument("--from-turns", default=None, help="Только отпечатки: turns JSON от другого движка") args = ap.parse_args() if args.from_turns: if not args.emit_voiceprints: print("[D] --from-turns требует --emit-voiceprints", file=sys.stderr) return 1 return voiceprints_only(args) if not args.out_json: print("[D] нужен --out-json (либо режим --from-turns)", file=sys.stderr) return 1 return diarize(args) if __name__ == "__main__": import traceback try: rc = main() except SystemExit: raise except BaseException: log_path = Path.home() / ".claude" / "skills" / "transcribe" / "diarize_sherpa.crash.log" log_path.parent.mkdir(parents=True, exist_ok=True) with log_path.open("a", encoding="utf-8") as f: f.write("=" * 80 + "\n") f.write(f"argv: {sys.argv}\n") f.write(traceback.format_exc()) traceback.print_exc() rc = 2 sys.exit(rc) -
glossary.py 12.3 KB
"""glossary.py - словарь терминов: подсказка распознавателю речи + правка ослышек. Зачем. Whisper уверенно ослышивается на англицизмах и жаргоне предметной области, и молча: DAX -> "ДАКС", JSON -> "G-Splone", PROD -> "Прот", гашение -> "базаты". Ни один последующий слой это не чинит - текстовая модель принимает ослышку за факт и тащит ее в связный лог и в саммари. Два рычага, оба нужны: 1. hotwords - список правильных написаний уходит в промпт КАЖДОГО окна распознавания (faster-whisper подмешивает их в prompt при каждом вызове get_prompt, в отличие от initial_prompt, который влияет в основном на первое окно). Профилактика: модель чаще выбирает знакомую форму. Гарантии нет - слово может не влезть в лимит промпта. 2. fix() - лечение по факту: замена конкретных ослышек на правильную форму. Работает уже по готовому тексту, поэтому чинит и то, что hotwords не спасли. Формат файла (по умолчанию `<скил>/glossary.txt`, кодировка UTF-8): # строка комментария DAX = ДАКС, дакс, дэкс # слева правильное написание, справа ослышки через запятую JSON = джейсон, G-Splone чек-лист # строка без "=" - только подсказка распознавателю, замен нет Ослышки перечисляются ТЕМИ формами, какими они реально вышли из распознавателя (падеж, число). Морфологии здесь нет и не планируется: угадывать словоформы опаснее, чем пропустить одну. Модуль намеренно на голой стандартной библиотеке: он импортируется и из venv распознавателя, где из стороннего стоит только faster-whisper. """ import os import re import sys from pathlib import Path # Файл по умолчанию лежит в корне скила, рядом со SKILL.md: правит его человек, а не агент. DEFAULT_PATH = Path(__file__).resolve().parent.parent / "glossary.txt" # Ослышки короче этого не заменяем: на 1-2 символах любая замена начинает попадать в середину # посторонних слов, а выигрыш нулевой. MIN_FORM_LEN = 3 # Потолок строки hotwords. Распознаватель все равно режет промпт по своему лимиту (~223 токена), # но обрезать осмысленно - по границе термина - лучше, чем отдать ему обрубок последнего слова. HOTWORDS_MAX_CHARS = 400 class Glossary: """Термины и их ослышки. Пустой глоссарий безопасен: hotwords пуст, fix() возвращает текст как есть.""" def __init__(self, terms=None, source=None, warnings=None): self.terms = list(terms or []) # [(правильное написание, [ослышки])] self.source = source # откуда загружен - для сообщения в лог self.warnings = list(warnings or []) self._pattern, self._by_form = _compile(self.terms) def __bool__(self): return bool(self.terms) def __len__(self): return len(self.terms) @property def fixable(self): """Сколько терминов реально умеют чиниться заменой (у остальных только hotwords).""" return sum(1 for _canon, forms in self.terms if forms) def hotwords(self, max_chars=HOTWORDS_MAX_CHARS): """Строка правильных написаний для faster-whisper hotwords. Обрезается по границе термина.""" out, total = [], 0 for canon, _forms in self.terms: add = len(canon) + (2 if out else 0) if total + add > max_chars: break out.append(canon) total += add return ", ".join(out) def fix(self, text): """Заменить ослышки на правильные написания. Возвращает (текст, {правильное: сколько раз}). Один проход комбинированным регэкспом, а не цепочка re.sub: уже подставленный термин не может быть перезаписан следующим правилом (та же защита от каскада, что в apply_names). """ if not text or self._pattern is None: return text, {} stats = {} def _sub(m): canon = self._by_form[_key(m.group(0))] stats[canon] = stats.get(canon, 0) + 1 return _match_case(m.group(0), canon) return self._pattern.sub(_sub, text), stats def fix_segments(self, segments, field="text"): """Починить ослышки в списке словарей (сегменты/реплики) на месте. Возвращает общую статистику.""" total = {} for seg in segments or (): fixed, stats = self.fix(seg.get(field) or "") if stats: seg[field] = fixed for canon, n in stats.items(): total[canon] = total.get(canon, 0) + n return total def _key(form): """Ключ сопоставления: регистр и внутренние пробелы не считаются.""" return re.sub(r"\s+", " ", form.strip().lower()) def _match_case(original, canon): """Сохранить заглавную букву начала предложения, если правильное написание строчное.""" if original[:1].isupper() and canon[:1].islower(): return canon[:1].upper() + canon[1:] return canon def _compile(terms): """Собрать один регэксп по всем ослышкам. Длинные формы первыми - иначе короткая съест префикс.""" by_form = {} for canon, forms in terms: for form in forms: by_form.setdefault(_key(form), canon) if not by_form: return None, {} parts = [] for form in sorted(by_form, key=len, reverse=True): # Пробелы внутри формы - любой пробельный разрыв: распознаватель ставит их непредсказуемо. parts.append(r"\s+".join(re.escape(tok) for tok in form.split())) pattern = re.compile(r"(?<!\w)(?:" + "|".join(parts) + r")(?!\w)", re.IGNORECASE) return pattern, by_form def parse(text): """Разобрать содержимое файла глоссария. Возвращает (термины, предупреждения).""" terms, warnings, seen = [], [], {} for num, raw in enumerate(text.splitlines(), 1): line = raw.split("#", 1)[0].strip() if not line: continue canon, _sep, rest = line.partition("=") canon = canon.strip() if not canon: warnings.append(f"строка {num}: пустое правильное написание - пропущена") continue forms = [] for form in rest.split(","): form = form.strip() if not form: continue if len(form) < MIN_FORM_LEN: warnings.append(f"строка {num}: ослышка {form!r} короче {MIN_FORM_LEN} символов - пропущена") continue if _key(form) == _key(canon): continue # форма совпала с правильным написанием - заменять нечего if _key(form) in seen: # Регистр в сопоставлении не участвует, поэтому "ДАКС" и "дакс" - одна и та же # ослышка: это не конфликт, а просто повтор. Предупреждаем только когда одну форму # растащили по РАЗНЫМ терминам - там правда неоднозначность. if seen[_key(form)] != canon: warnings.append(f"строка {num}: ослышка {form!r} уже закреплена за " f"{seen[_key(form)]!r} - пропущена") continue seen[_key(form)] = canon forms.append(form) terms.append((canon, forms)) return terms, warnings def load(path=None, enabled=True): """Загрузить глоссарий. Приоритет: аргумент -> env TRANSCRIBE_GLOSSARY -> файл по умолчанию. enabled=False (ключ --no-glossary) отдает пустой глоссарий: он безвреден на всех стадиях, поэтому вызывающему не нужно ветвиться на None. Отсутствие файла - не ошибка: глоссарий необязателен. """ if not enabled: return Glossary(source=None) chosen = path or os.environ.get("TRANSCRIBE_GLOSSARY") or DEFAULT_PATH p = Path(chosen) if not p.exists(): warn = [f"файл глоссария не найден: {p}"] if path else [] return Glossary(source=None, warnings=warn) try: terms, warnings = parse(p.read_text(encoding="utf-8")) except OSError as e: return Glossary(source=None, warnings=[f"глоссарий не прочитан ({e}): {p}"]) return Glossary(terms, source=str(p), warnings=warnings) def describe(gl): """Однострочный отчет о загруженном глоссарии - для лога прогона.""" if not gl: return "глоссарий: не задан" return (f"глоссарий: {len(gl)} терминов ({gl.fixable} с правилами замены) из {gl.source}") def main(argv=None): """Проверка глоссария вручную: показать разбор и прогнать замены по строке или файлу.""" # Вывод содержит кириллицу. Без явного переключения печать падает с UnicodeEncodeError # везде, где консоль не в UTF-8: сборочный агент, чужая локаль. sys.stdout.reconfigure(encoding='utf-8') sys.stderr.reconfigure(encoding='utf-8') argv = list(sys.argv[1:] if argv is None else argv) text_arg, path = None, None while argv: a = argv.pop(0) if a == "--glossary": path = argv.pop(0) if argv else None elif a in ("-h", "--help"): print(__doc__) return 0 else: text_arg = a gl = load(path) print(describe(gl)) for w in gl.warnings: print(f" предупреждение: {w}") for canon, forms in gl.terms: print(f" {canon}" + (f" <- {', '.join(forms)}" if forms else " (только подсказка)")) print(f"\nhotwords: {gl.hotwords()!r}") if text_arg: src = Path(text_arg) text = src.read_text(encoding="utf-8") if src.exists() else text_arg fixed, stats = gl.fix(text) print(f"\nзамен: {stats if stats else 'нет'}") print(fixed if len(fixed) < 4000 else fixed[:4000] + "...") return 0 if __name__ == "__main__": raise SystemExit(main()) -
local_backends.py 38 KB
""" local_backends.py - локальные бэкенды для стадии B (разбор экрана без облака). Два независимых блока: 1. HTTP-клиент к LM Studio на сервере 150 (OpenAI-совместимый /v1): - check_server() - проверка доступности + список моделей (/v1/models). - vlm_read_frame(path) - зрение по кадру (Qwen3-VL-8B), с ретраями и guard на ужатие. - llm_summary_pass(...) - текстовый проход саммари (gemma-4-26b), с ретраями. 2. Нарезка кадров видео (ffmpeg из PATH / imageio-ffmpeg): - extract_scene_frames(video, out_dir) - scene-detect + пол по частоте + dhash-дедуп + кап, возвращает (список (timecode_sec, Path), truncated: bool). Чистит старые кадры перед стартом. Зависимости: PIL (dhash-дедуп), стандартная библиотека. ffmpeg - из PATH, fallback imageio-ffmpeg. Никаких обращений в облако: модуль работает только с локальным сервером 150 и локальным ffmpeg. """ import os import re import sys import json import time import base64 import shutil import tempfile import subprocess import urllib.request import urllib.error from pathlib import Path # Вывод содержит кириллицу. Без явного переключения печать падает с UnicodeEncodeError # везде, где консоль не в UTF-8: сборочный агент, чужая локаль. sys.stdout.reconfigure(encoding='utf-8') sys.stderr.reconfigure(encoding='utf-8') def _env_float(name, default): v = os.environ.get(name) if v is None: return default try: return float(v) except ValueError: print(f"[warn] некорректный {name}={v!r} (не число), использую {default}", file=sys.stderr) return default def _env_int(name, default): v = os.environ.get(name) if v is None: return default try: return int(v) except ValueError: print(f"[warn] некорректный {name}={v!r} (не целое), использую {default}", file=sys.stderr) return default def _load_dotenv() -> None: """Подгрузить ~/.claude/skills/transcribe/.env: LOCAL_150_BASE, WHISPER_PYTHON, GEMINI_API_KEY, HF_TOKEN. Приватные значения (адрес сервера, путь к venv, токены) держим в .env - он gitignore, не в паблик-репо. Грузится ПЕРЕД чтением конфига ниже; analyze_video_local импортирует этот модуль первым, поэтому его WHISPER_PYTHON тоже видит .env.""" env_path = Path(__file__).resolve().parent.parent / ".env" if not env_path.exists(): return for line in env_path.read_text(encoding="utf-8").splitlines(): line = line.strip() if not line or line.startswith("#") or "=" not in line: continue key, _, value = line.partition("=") key = key.strip() value = value.strip().strip('"').strip("'") if key and key not in os.environ: os.environ[key] = value _load_dotenv() # ============================ Конфиг (env с дефолтами) ============================ LOCAL_150_BASE = os.environ.get("LOCAL_150_BASE", "http://localhost:1234/v1") # адрес LM Studio; # реальный адрес сервера задается через env LOCAL_150_BASE (или .env, не в паблик-репо) LOCAL_VLM_MODEL = os.environ.get("LOCAL_VLM_MODEL", "qwen3-vl-8b-instruct") LOCAL_SUMMARY_MODEL = os.environ.get("LOCAL_SUMMARY_MODEL", "google/gemma-4-26b-a4b") LOCAL_SPEAKER_MODEL = os.environ.get("LOCAL_SPEAKER_MODEL", "qwen2.5-32b-instruct") # маппинг спикеров->имена # (qwen2.5-32b лучше gemma на связке адрес-ответ: 4/4 vs 3/4 на РБП; для саммари наоборот - gemma). SCENE_THRESHOLD = _env_float("SCENE_THRESHOLD", 0.30) # порог смены сцены (0..1) FRAME_FLOOR_SEC = _env_float("FRAME_FLOOR_SEC", 25.0) # пол: кадр минимум каждые N сек (ловит плавные # изменения 1С-форм, не триггерящие scene-detect). 0=выкл. FRAME_CAP = _env_int("FRAME_CAP", 400) # жесткий потолок кадров DEDUP_HAMMING = _env_int("DEDUP_HAMMING", 6) # dhash: <= => почти-дубль, отбрасываем FALLBACK_INTERVAL_SEC = _env_float("FALLBACK_INTERVAL_SEC", 20.0) # выборка если сцен/пола не хватило HTTP_TIMEOUT = _env_int("LOCAL_HTTP_TIMEOUT", 300) HTTP_RETRIES = _env_int("LOCAL_HTTP_RETRIES", 2) MIN_PROMPT_TOKENS = _env_int("LOCAL_MIN_PROMPT_TOKENS", 800) # ниже => кадр ужат сервером VLM_MAX_TOKENS = _env_int("LOCAL_VLM_MAX_TOKENS", 5500) # целевой потолок вывода VLM на кадр (без обрезки # плотных 1С/Excel-экранов; исчерпывающий режим ~4500). Реальный лимит на запрос ограничен КОНТЕКСТОМ # модели - см. plan_vlm_budget (маркер finish=length отловит редкий выброс сверх лимита). VLM_PARALLEL = _env_int("LOCAL_VLM_PARALLEL", 4) # ПОТОЛОК параллельных кадров (== "Max Concurrent # Predictions" в LM Studio). Фактическая параллельность урезается под контекст (слоты делят ОДНО окно - # unified KV cache): parallel*(VLM_PROMPT_RESERVE + max_tokens) <= context. VLM_PROMPT_RESERVE = _env_int("LOCAL_VLM_PROMPT_RESERVE", 2600) # НАЧАЛЬНАЯ оценка резерва контекста # на картинку+промпт одного запроса. Оценка сверху и обычно завышена вдвое (замер на видео - 1196), # поэтому фактическое значение меряется на первом кадре: см. replan_with_measured. VLM_FALLBACK_CONTEXT = _env_int("LOCAL_VLM_CONTEXT", 8192) # если /api/v0/models не отдал длину контекста. DEFAULT_FRAME_PROMPT = ( "Это кадр экрана рабочей встречи (обычно программа 1С). " "Прочитай ВЕСЬ видимый текст дословно: заголовок окна/документа, поля и их значения, " "ВСЕ строки таблиц, кнопки, пункты меню, вкладки. Точно сохраняй числа, даты, суммы и знаки, " "выписывай ВСЕ коды счетов до единого, ничего не пропуская. " "ВАЖНО: текст на РУССКОМ. Сохраняй кириллицу дословно, НЕ заменяй русские буквы на похожие " "латинские или цифры (например 'БУ' и 'НУ' - это кириллица, а не 'BU'/'HU'; 'ООО' - это буквы, не '000'). " "НИЧЕГО НЕ ДОДУМЫВАЙ. Пиши только то, что реально видно на этом кадре. Нечитаемое " "(размыто, мелко, перекрыто, обрезано) так и помечай: 'не читается'. Пустое поле описывай " "как пустое. НЕ подставляй правдоподобные названия организаций, суммы, номера документов, " "даты и коды вместо тех, что не разобрал, и не приводи примеров - лучше признать, что не " "видно, чем назвать похожее. " "Затем одной-двумя фразами опиши, какая форма/раздел открыт и что на экране происходит " "(что выделено или активно). Без рассуждений, только факты с экрана. Отвечай по-русски." ) class LocalBackendError(RuntimeError): """Ошибка локального бэкенда (сервер 150 или ffmpeg). status/elapsed нужны, чтобы отличить выгруженную из памяти модель от настоящей поломки: текст ошибки для этого не годится (LM Studio отдает generic-страницу), а время - годится. """ def __init__(self, message, status=None, elapsed=None): super().__init__(message) self.status = status # HTTP-код, если ошибка пришла от сервера self.elapsed = elapsed # сколько секунд заняла неудачная попытка # ============================ Утилиты ============================ def format_tc(seconds: float) -> str: s = int(round(seconds)) return f"{s // 60:02d}:{s % 60:02d}" def ffmpeg_exe() -> str: """Путь к ffmpeg: сначала PATH, затем imageio-ffmpeg. Бросает LocalBackendError если нет.""" exe = shutil.which("ffmpeg") if exe: return exe try: import imageio_ffmpeg return imageio_ffmpeg.get_ffmpeg_exe() except Exception: raise LocalBackendError( "ffmpeg не найден: нет в PATH и не установлен imageio-ffmpeg " "(pip install imageio-ffmpeg)") # ============================ HTTP-клиент к 150 ============================ def _post_chat(model, messages, base=None, max_tokens=1400, temperature=0.2, extra_body=None, timeout=HTTP_TIMEOUT, retries=HTTP_RETRIES, label=""): """POST /chat/completions с ретраями (backoff 2s,4s). 4xx (кроме 429) не ретраим.""" base = base or LOCAL_150_BASE url = base.rstrip("/") + "/chat/completions" payload = {"model": model, "messages": messages, "max_tokens": max_tokens, "temperature": temperature} if extra_body: payload.update(extra_body) data = json.dumps(payload).encode("utf-8") last = None for attempt in range(retries + 1): t0 = time.time() try: req = urllib.request.Request( url, data=data, headers={"Authorization": "Bearer lm-studio", "Content-Type": "application/json"}, ) with urllib.request.urlopen(req, timeout=timeout) as r: return json.loads(r.read().decode("utf-8")) except urllib.error.HTTPError as e: body = e.read().decode("utf-8", errors="replace")[:400] last = LocalBackendError(f"HTTP {e.code} от 150 [{label}]: {body}", status=e.code, elapsed=time.time() - t0) if e.code != 429 and 400 <= e.code < 500: raise last # плохой запрос/модель - ретрай не поможет except (urllib.error.URLError, TimeoutError, OSError) as e: last = LocalBackendError(f"Сеть/таймаут к 150 [{label}]: {type(e).__name__}: {e}", elapsed=time.time() - t0) if attempt < retries: time.sleep(2 * (attempt + 1)) raise last or LocalBackendError(f"150 недоступен [{label}]") UNLOADED_MAX_SECONDS = _env_float("LOCAL_UNLOADED_MAX_SECONDS", 1.0) def looks_unloaded(err): """Похоже ли, что модель просто выгружена из памяти, а не сломалась. Отличаем по ВРЕМЕНИ, а не по тексту: на запрос к выгруженной модели LM Studio отвечает generic ошибкой без внятного содержания, зато почти мгновенно, тогда как настоящая генерация занимает секунды. Нужно, чтобы выгрузка по TTL посреди прогона не засчитывалась как сбой сервера. """ status = getattr(err, "status", None) elapsed = getattr(err, "elapsed", None) return bool(status and status >= 500 and elapsed is not None and elapsed < UNLOADED_MAX_SECONDS) def looks_context_overflow(err): """Похоже ли, что запрос не влез в контекст, а не сервер сломался. Это НЕ повод бросать кадр: слоты LM Studio делят одно окно, поэтому виновата параллельность, а сам запрос корректен. Лечится снижением параллельности и повтором. Принимает и исключение, и уже сохраненный текст ошибки - в статус-файл попадает строка. """ text = str(err or "").lower() status = getattr(err, "status", None) if not (status == 400 or "http 400" in text): return False return any(k in text for k in ("context", "exceed", "too long", "too large", "token", "контекст")) def ensure_loaded(model=None, base=None, timeout=600): """Убедиться, что модель в памяти: прогреть и проверить по каталогу. True если готова.""" model = model or LOCAL_VLM_MODEL if get_loaded_info(model, base=base)["loaded"]: return True warmup_model(model, base=base, timeout=timeout) return get_loaded_info(model, base=base)["loaded"] def check_server(base=None): """Список id доступных моделей на 150. Бросает LocalBackendError если сервер не отвечает.""" base = base or LOCAL_150_BASE url = base.rstrip("/") + "/models" try: req = urllib.request.Request(url, headers={"Authorization": "Bearer lm-studio"}) with urllib.request.urlopen(req, timeout=10) as r: data = json.loads(r.read().decode("utf-8")) return [m.get("id") for m in data.get("data", [])] except Exception as e: raise LocalBackendError(f"Сервер 150 недоступен ({base}): {e}") def _api_root(base=None): """Корень сервера без суффикса /v1: нативные эндпоинты LM Studio живут от корня.""" root = (base or LOCAL_150_BASE).rstrip("/") if root.endswith("/v1"): root = root[:-3].rstrip("/") return root def _get_json(url, timeout=10): req = urllib.request.Request(url, headers={"Authorization": "Bearer lm-studio"}) with urllib.request.urlopen(req, timeout=timeout) as r: return json.loads(r.read().decode("utf-8")) def _model_matches(entry, model): """Совпадает ли запись каталога с запрошенным именем модели (с учетом варианта квантизации).""" if model in (entry.get("key"), entry.get("id"), entry.get("selected_variant")): return True return model in (entry.get("variants") or []) def get_loaded_info(model=None, base=None): """Что сервер знает о модели ПРЯМО СЕЙЧАС: загружена ли и с какими параметрами. Возвращает dict(loaded, context, parallel, reasoning); context и parallel заполняются ТОЛЬКО для загруженной модели. Паспортный max_context_length сознательно не подставляется: у выгруженной модели он на порядок больше рабочего (262144 против фактических 32000), и посчитанный от него бюджет переполняет контекст на первом же параллельном кадре. """ model = model or LOCAL_VLM_MODEL root = _api_root(base) info = {"loaded": False, "context": None, "parallel": None, "reasoning": None} try: # /api/v1 точнее: отдает конфиг конкретного загруженного инстанса for m in _get_json(root + "/api/v1/models").get("models", []): if not _model_matches(m, model): continue caps = (m.get("capabilities") or {}).get("reasoning") or {} info["reasoning"] = caps.get("allowed_options") or None inst = m.get("loaded_instances") or [] if inst: cfg = inst[0].get("config") or {} info.update(loaded=True, context=cfg.get("context_length"), parallel=cfg.get("parallel")) return info except Exception: pass try: # сборки LM Studio без /api/v1: состояние приходит отдельным полем state for m in _get_json(root + "/api/v0/models").get("data", []): if not _model_matches(m, model): continue if m.get("state") == "loaded": info.update(loaded=True, context=m.get("loaded_context_length")) return info except Exception: pass return info def get_loaded_context(model=None, base=None): """Рабочий контекст ЗАГРУЖЕННОЙ модели. None если она выгружена или сервер не отвечает.""" c = get_loaded_info(model, base=base).get("context") return int(c) if c else None def warmup_model(model=None, base=None, timeout=180): """JIT-прогрев: крошечный запрос, чтобы LM Studio загрузил модель (с сохраненным в ее конфиге контекстом) ДО расчета бюджета. Иначе на холодном старте (модель выгружена по TTL) get_loaded_context вернет None -> бюджет уйдет в fallback -> parallel=1, хотя модель грузится на 32768. Возвращает True при ответе.""" model = model or LOCAL_VLM_MODEL root = (base or LOCAL_150_BASE).rstrip("/") body = {"model": model, "messages": [{"role": "user", "content": "ok"}], "max_tokens": 1, "temperature": 0} req = urllib.request.Request( root + "/chat/completions", data=json.dumps(body).encode("utf-8"), headers={"Content-Type": "application/json", "Authorization": "Bearer lm-studio"}) try: with urllib.request.urlopen(req, timeout=timeout) as r: json.loads(r.read().decode("utf-8")) return True except Exception as e: print(f"[warmup] прогрев {model} не удался: {e}", file=sys.stderr) return False def fit_parallel(context, reserve, max_tokens, parallel_cap): """Сколько кадров слать одновременно. Слоты LM Studio делят ОДНО окно (unified KV cache), поэтому действует parallel*(reserve + max_tokens) <= context.""" per_req = max(1, reserve) + max(1, max_tokens) # max(1) - защита от порченого env return max(1, min(parallel_cap, context // per_req)) def plan_vlm_budget(model=None, base=None, max_tokens=None, parallel_cap=None): """Согласовать вывод и параллельность под контекст ЗАГРУЖЕННОЙ VLM на 150. Возвращает dict(context, parallel, max_tokens, reserve, cap, source, measured). Резерв на этой стадии - оценка из конфига; фактический меряется на первом кадре (see replan_with_measured). """ max_tokens = max_tokens or VLM_MAX_TOKENS parallel_cap = parallel_cap or VLM_PARALLEL info = get_loaded_info(model, base=base) if not info["loaded"]: # выгружена по TTL - прогреть и перемерить, иначе бюджет уйдет в fallback warmup_model(model, base=base) info = get_loaded_info(model, base=base) ctx, source = info.get("context"), "api" if not ctx: ctx, source = VLM_FALLBACK_CONTEXT, "fallback" if info.get("parallel"): # сервер знает свой реальный потолок слотов - он главнее догадки из env parallel_cap = min(parallel_cap, int(info["parallel"])) reserve = VLM_PROMPT_RESERVE if ctx < reserve + max_tokens: # контекст не вмещает даже ОДИН запрос: ужимаем вывод, max_tokens = max(256, ctx - reserve) # чтобы печатаемый бюджет не врал print(f"[warn] контекст VLM {ctx} мал для резерва {reserve}+вывода: " f"ужал max_tokens до {max_tokens}", file=sys.stderr) return {"context": ctx, "parallel": fit_parallel(ctx, reserve, max_tokens, parallel_cap), "max_tokens": max_tokens, "reserve": reserve, "cap": parallel_cap, "source": source, "measured": False} def replan_with_measured(budget, prompt_tokens, headroom=1.15): """Пересчитать бюджет под ИЗМЕРЕННЫЙ на первом кадре размер промпта. prompt_tokens кадра практически постоянен (зависит от разрешения, а не от содержимого экрана), поэтому одного замера достаточно на весь прогон. Константа из конфига - оценка сверху и обычно завышена вдвое, а завышенный резерв режет параллельность на ровном месте. Запас headroom - на разброс тайлинга между кадрами. Возвращает НОВЫЙ dict, исходный не меняет. """ if not prompt_tokens or prompt_tokens <= 0: return budget out = dict(budget) out["reserve"] = int(prompt_tokens * headroom) out["measured"] = True out["parallel"] = fit_parallel(out["context"], out["reserve"], out["max_tokens"], out["cap"]) return out def _extract_text(resp, allow_reasoning=True): """Текст ответа OpenAI-совместимого эндпоинта. allow_reasoning=False обязателен для задач со СТРОГИМ форматом (JSON спикеров): думающая модель может отдать пустой content, положив весь вывод в reasoning_content. Подстановка размышлений вместо ответа превращает явный отказ в тихо неверный результат - для строгих задач это ошибка. """ ch = (resp.get("choices") or [{}])[0] msg = ch.get("message", {}) or {} content = (msg.get("content") or "").strip() if not content and allow_reasoning: content = (msg.get("reasoning_content") or "").strip() return content, ch.get("finish_reason"), resp.get("usage", {}) or {} def vlm_read_frame(image_path, model=None, prompt=None, base=None, max_tokens=None): """Прочитать один кадр через VLM на 150. Возвращает dict(text, prompt_tokens, ...).""" model = model or LOCAL_VLM_MODEL prompt = prompt or DEFAULT_FRAME_PROMPT max_tokens = max_tokens or VLM_MAX_TOKENS b64 = base64.b64encode(Path(image_path).read_bytes()).decode("ascii") messages = [{"role": "user", "content": [ {"type": "text", "text": prompt}, {"type": "image_url", "image_url": {"url": "data:image/png;base64," + b64}}, ]}] # Зрение остается на OpenAI-совместимом эндпоинте: нативный /api/v1/chat принимает только # текстовый input, картинку туда не передать. resp = _post_chat(model, messages, base=base, max_tokens=max_tokens, label=f"vlm:{Path(image_path).name}") # allow_reasoning=False: описание экрана - строгая задача. Размышления вместо распознанного # текста выглядят как валидный ответ и молча уезжают в лог, поэтому лучше явный сбой кадра. text, finish, usage = _extract_text(resp, allow_reasoning=False) if not text: raise LocalBackendError(f"Пустой ответ VLM (finish={finish}) на {Path(image_path).name}") pt = usage.get("prompt_tokens") if pt is not None and pt < MIN_PROMPT_TOKENS: text = (f"> [!] prompt_tokens={pt} (мало) - кадр мог быть ужат сервером, " f"мелкий текст ненадежен.\n\n") + text if finish == "length": text = text + ("\n\n> [!] описание достигло лимита вывода " f"(finish=length, max_tokens={max_tokens}) - возможен обрыв хвоста, " "подними LOCAL_VLM_MAX_TOKENS.") return {"text": text, "prompt_tokens": pt, "completion_tokens": usage.get("completion_tokens"), "finish": finish} def _post_native_chat(model, input_text, base=None, reasoning="off", max_output_tokens=4000, temperature=0.2, timeout=HTTP_TIMEOUT, retries=HTTP_RETRIES, label=""): """POST /api/v1/chat - нативный эндпоинт LM Studio, ретраи как у _post_chat. Нужен ради параметра `reasoning`: на /v1/chat/completions он молча игнорируется (проверено на 150: off и on дают идентичный ответ), а chat_template_kwargs.enable_thinking для qwen3.x мертв. Размышления стоят 45-кратного времени и корневую ошибку привязки имен не лечат - по умолчанию off. """ url = _api_root(base) + "/api/v1/chat" payload = {"model": model, "input": input_text, "reasoning": reasoning, "max_output_tokens": max_output_tokens, "temperature": temperature} data = json.dumps(payload).encode("utf-8") last = None for attempt in range(retries + 1): t0 = time.time() try: req = urllib.request.Request( url, data=data, headers={"Authorization": "Bearer lm-studio", "Content-Type": "application/json"}, ) with urllib.request.urlopen(req, timeout=timeout) as r: return json.loads(r.read().decode("utf-8")) except urllib.error.HTTPError as e: body = e.read().decode("utf-8", errors="replace")[:400] last = LocalBackendError(f"HTTP {e.code} от 150 [{label}]: {body}", status=e.code, elapsed=time.time() - t0) if e.code != 429 and 400 <= e.code < 500: raise last # плохой запрос/модель - ретрай не поможет except (urllib.error.URLError, TimeoutError, OSError) as e: last = LocalBackendError(f"Сеть/таймаут к 150 [{label}]: {type(e).__name__}: {e}", elapsed=time.time() - t0) if attempt < retries: time.sleep(2 * (attempt + 1)) raise last or LocalBackendError(f"150 недоступен [{label}]") def _extract_native(resp, allow_reasoning=False): """Текст из ответа /api/v1/chat: output[] содержит элементы type=message и/или type=reasoning. При reasoning=on и упоре в потолок токенов ответ состоит ТОЛЬКО из размышлений, без единого message - вернуть их вместо ответа нельзя по той же причине, что и в _extract_text. """ out = resp.get("output") or [] text = "\n".join((o.get("content") or "") for o in out if o.get("type") == "message").strip() if not text and allow_reasoning: text = "\n".join((o.get("content") or "") for o in out if o.get("type") == "reasoning").strip() return text, resp.get("stats", {}) or {} def llm_summary_pass(data_text, instruction, model=None, base=None, max_tokens=4000, reasoning="off"): """Один СВОБОДНЫЙ текстовый проход на 150 (порядок как в build_summary: данные, затем инструкция).""" model = model or LOCAL_SUMMARY_MODEL combined = f"{data_text}\n\n---\n\n{instruction}" resp = _post_native_chat(model, combined, base=base, reasoning=reasoning, max_output_tokens=max_tokens, label=f"text:{model}") out, stats = _extract_native(resp) if not out: raise LocalBackendError( f"Пустой ответ text-модели {model} (вывод {stats.get('total_output_tokens')} ток., " f"из них размышления {stats.get('reasoning_output_tokens')})") produced = stats.get("total_output_tokens") or 0 if produced >= max_tokens: # уперлись в потолок - хвост почти наверняка обрезан print(f"[warn] {model}: вывод достиг потолка {max_tokens} токенов - возможен обрыв хвоста", file=sys.stderr) return out def llm_json_pass(data_text, instruction, schema, model=None, base=None, max_tokens=2000): """Строгий JSON-проход: формат гарантирует СЕРВЕР (response_format=json_schema), а не послушание модели. Живет на /v1/chat/completions - нативный /api/v1/chat схемы вывода не принимает. Пустой ответ и невалидный JSON - громкая ошибка: тихо неверный маппинг спикеров дороже отказа. """ model = model or LOCAL_SPEAKER_MODEL combined = f"{data_text}\n\n---\n\n{instruction}" extra = {"response_format": {"type": "json_schema", "json_schema": { "name": "result", "strict": True, "schema": schema}}} resp = _post_chat(model, [{"role": "user", "content": combined}], base=base, max_tokens=max_tokens, temperature=0, extra_body=extra, label=f"json:{model}") text, finish, _ = _extract_text(resp, allow_reasoning=False) if not text: raise LocalBackendError(f"Пустой ответ модели {model} на строгий JSON (finish={finish})") try: return json.loads(text) except json.JSONDecodeError as e: raise LocalBackendError(f"Модель {model} вернула невалидный JSON ({e}): {text[:300]}") # ============================ Нарезка кадров ============================ def _dhash(path, size=8): """Difference-hash кадра (64 бита) для отсева почти-дублей.""" from PIL import Image with Image.open(path) as im: img = im.convert("L").resize((size + 1, size), Image.LANCZOS) px = list(img.getdata()) w = size + 1 bits = 0 for row in range(size): for col in range(size): left = px[row * w + col] right = px[row * w + col + 1] bits = (bits << 1) | (1 if left > right else 0) return bits def _hamming(a, b): return bin(a ^ b).count("1") def _run_ffmpeg_select(video, out_dir, vf, prefix): """Прогнать ffmpeg с фильтром select+showinfo, вернуть [(pts_time, Path), ...] по порядку.""" ff = ffmpeg_exe() out_dir = Path(out_dir) out_dir.mkdir(parents=True, exist_ok=True) pattern = str(out_dir / f"{prefix}_%05d.png") cmd = [ff, "-hide_banner", "-y", "-i", str(video), "-vf", vf, "-vsync", "vfr", pattern] proc = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", errors="replace") times = [float(x) for x in re.findall(r"pts_time:([0-9.]+)", proc.stderr)] frames = sorted(out_dir.glob(f"{prefix}_*.png")) if not frames and proc.returncode != 0: raise LocalBackendError(f"ffmpeg завершился с кодом {proc.returncode}: " f"{proc.stderr.strip()[-400:]}") if len(times) != len(frames): print(f"[warn] ffmpeg: таймкодов {len(times)} != кадров {len(frames)} - " f"беру min, часть кадров может быть без точного времени", file=sys.stderr) n = min(len(times), len(frames)) # лишние кадры сверх n удаляем, чтобы не осели как мусор for extra in frames[n:]: extra.unlink(missing_ok=True) return list(zip(times[:n], frames[:n])) def _clean_frames(out_dir): out_dir = Path(out_dir) if out_dir.exists(): for f in list(out_dir.glob("raw_*.png")) + list(out_dir.glob("frame_*.png")): f.unlink(missing_ok=True) def extract_scene_frames(video, out_dir, threshold=None, floor_sec=None, cap=None, dedup_hamming=None): """ Нарезать ключевые кадры видео. scene-detect + пол по частоте -> dhash-дедуп -> кап. Возвращает (frames: list[(timecode_sec, Path)], truncated: bool). Первый кадр всегда берется (isnan(prev_selected_t)); при отсутствии сцен - равномерная выборка. Старые кадры в out_dir чистятся перед стартом. """ threshold = SCENE_THRESHOLD if threshold is None else threshold floor_sec = FRAME_FLOOR_SEC if floor_sec is None else floor_sec cap = FRAME_CAP if cap is None else cap dedup_hamming = DEDUP_HAMMING if dedup_hamming is None else dedup_hamming out_dir = Path(out_dir) _clean_frames(out_dir) # чистый старт: не смешивать с прошлым прогоном # isnan(...) гарантирует захват самого первого кадра и работу пола с начала записи sel = f"isnan(prev_selected_t)+gt(scene,{threshold})" if floor_sec and floor_sec > 0: sel = f"{sel}+gte(t-prev_selected_t,{floor_sec})" raw = _run_ffmpeg_select(video, out_dir, f"select='{sel}',showinfo", "raw") if not raw: # почти статичное / очень короткое видео: равномерная выборка fps = 1.0 / max(FALLBACK_INTERVAL_SEC, 1.0) raw = _run_ffmpeg_select(video, out_dir, f"fps={fps},showinfo", "raw") # dedup почти-дублей (последовательно) kept, last_hash = [], None for t, p in raw: try: h = _dhash(p) except Exception: h = None if last_hash is not None and h is not None and _hamming(h, last_hash) <= dedup_hamming: p.unlink(missing_ok=True) continue if h is not None: last_hash = h kept.append((t, p)) # кап (равномерно прореживаем) truncated = False if cap and len(kept) > cap: truncated = True step = len(kept) / cap keep_idx = {int(i * step) for i in range(cap)} new_kept = [] for i, (t, p) in enumerate(kept): if i in keep_idx: new_kept.append((t, p)) else: p.unlink(missing_ok=True) kept = new_kept # стабильные имена с таймкодом (round как в format_tc, чтобы имя и заголовок совпадали) result = [] for i, (t, p) in enumerate(kept): newp = out_dir / f"frame_{i:04d}_{int(round(t))}s.png" try: if p.resolve() != newp.resolve(): p.replace(newp) except OSError as e: print(f"[warn] не удалось переименовать {p.name} -> {newp.name}: {e}", file=sys.stderr) newp = p result.append((t, newp)) return result, truncated # ============================ Smoke-тест ============================ def _smoke(video): print(f"[smoke] сервер 150: {LOCAL_150_BASE}") models = check_server() print(f"[smoke] моделей доступно: {len(models)}") for need in (LOCAL_VLM_MODEL, LOCAL_SUMMARY_MODEL): print(f" - {need}: {'OK' if need in models else 'НЕ НАЙДЕНА'}") tmp = Path(tempfile.mkdtemp(prefix="lb_smoke_")) print(f"[smoke] нарезка кадров из: {video} -> {tmp}") t0 = time.time() frames, truncated = extract_scene_frames(video, tmp) print(f"[smoke] кадров: {len(frames)} (truncated={truncated}) за {time.time()-t0:.1f}s") for t, p in frames[:8]: print(f" {format_tc(t)} {p.name}") if not frames: print("[smoke] нет кадров - прерываю"); return print("[smoke] VLM на первом кадре...") t0 = time.time() r = vlm_read_frame(frames[0][1]) dt_vlm = time.time() - t0 print(f"[smoke] VLM {dt_vlm:.1f}s, prompt_tokens={r['prompt_tokens']}, finish={r['finish']}") print(" ---\n " + "\n ".join(r["text"].splitlines()[:12])) print("[smoke] summary-модель (замер свопа VLM->summary)...") t0 = time.time() s = llm_summary_pass("Тестовая транскрипция: обсудили отпуск и премии.", "Составь одну фразу-резюме.") dt_sum = time.time() - t0 print(f"[smoke] summary {dt_sum:.1f}s (включая своп модели): {s[:200]}") print(f"[smoke] OK. VLM/кадр~{dt_vlm:.0f}s, своп+summary~{dt_sum:.0f}s") if __name__ == "__main__": if len(sys.argv) < 2: print("Usage: python local_backends.py <video_for_smoke_test>") sys.exit(1) _smoke(sys.argv[1]) -
setup.py 16.3 KB
""" Установка зависимостей скила transcribe. Что делает: 1. Проверяет системные требования (Python, ffmpeg). 2. Создает venv-whisper и ставит: - faster-whisper + ctranslate2-CUDA + av (для локальной транскрипции аудио) - google-genai + python-dotenv (для Gemini API: видео + analyze-ui) 3. Создает venv-sherpa и ставит sherpa_onnx (GPU CUDA) + onnxruntime-gpu + soundfile 4. Скачивает модели диаризации в models/: - sherpa-onnx-pyannote-segmentation-3-0 (~7 МБ) - 3dspeaker_speech_eres2net_base_200k_sv_zh-cn_16k-common.onnx (~40 МБ) 5. Создает шаблон .env (без записи реальных ключей). Запуск: python scripts/setup.py python scripts/setup.py --skip-models # только venv'ы python scripts/setup.py --skip-sherpa # без диаризации (только транскрипция) python scripts/setup.py --skip-gemini # без Gemini (только локальный движок) python scripts/setup.py --skip-whisper # пропустить пересоздание venv-whisper python scripts/setup.py --with-pyannote # доп. поставить pyannote.audio 4.x (требует HF_TOKEN) Требования: - Python 3.10+ (рекомендуется 3.12) - NVIDIA GPU + CUDA 12 + cuDNN 9 (для GPU режима) - ffmpeg + ffprobe в PATH - Windows x64 или Linux x64 После установки заполнить ~/.claude/skills/transcribe/.env: GEMINI_API_KEY=<ключ с https://aistudio.google.com/apikey> # Опционально, только если установлен pyannote 4.x: # HF_TOKEN=<read-токен https://huggingface.co/settings/tokens> """ from __future__ import annotations import argparse import os import shutil import subprocess import sys import tarfile import urllib.request from pathlib import Path # Вывод содержит кириллицу. Без явного переключения печать падает с UnicodeEncodeError # везде, где консоль не в UTF-8: сборочный агент, чужая локаль. sys.stdout.reconfigure(encoding='utf-8') sys.stderr.reconfigure(encoding='utf-8') SKILL_ROOT = Path(__file__).resolve().parent.parent VENV_WHISPER = SKILL_ROOT / "venv-whisper" VENV_SHERPA = SKILL_ROOT / "venv-sherpa" MODELS_DIR = SKILL_ROOT / "models" ENV_FILE = SKILL_ROOT / ".env" IS_WIN = os.name == "nt" VENV_BIN = "Scripts" if IS_WIN else "bin" PY_EXE = "python.exe" if IS_WIN else "python" MODEL_URLS = { "sherpa-onnx-pyannote-segmentation-3-0.tar.bz2": "https://github.com/k2-fsa/sherpa-onnx/releases/download/speaker-segmentation-models/" "sherpa-onnx-pyannote-segmentation-3-0.tar.bz2", "3dspeaker_speech_eres2net_base_200k_sv_zh-cn_16k-common.onnx": "https://github.com/k2-fsa/sherpa-onnx/releases/download/speaker-recongition-models/" "3dspeaker_speech_eres2net_base_200k_sv_zh-cn_16k-common.onnx", } WHISPER_PACKAGES = [ "faster-whisper>=1.0", "ctranslate2>=4.5", "av>=11", "huggingface-hub", "numpy<3", "nvidia-cublas-cu12", "nvidia-cudnn-cu12", "nvidia-cuda-runtime-cu12", "nvidia-cuda-nvrtc-cu12", ] GEMINI_PACKAGES = [ "google-genai", "python-dotenv", ] PYANNOTE_PACKAGES = [ "torch", "pyannote.audio>=4.0", ] SHERPA_PACKAGES = [ "onnxruntime-gpu>=1.18", "soundfile>=0.12", "numpy<2", "nvidia-cublas-cu12", "nvidia-cudnn-cu12", "nvidia-cuda-runtime-cu12", "nvidia-cuda-nvrtc-cu12", "nvidia-cufft-cu12", "nvidia-nvjitlink-cu12", ] ENV_TEMPLATE = """# Скил transcribe - ключи API # Получите ключ Gemini на https://aistudio.google.com/apikey GEMINI_API_KEY= # HF_TOKEN нужен ТОЛЬКО если установлен pyannote 4.x как fallback диаризация. # Sherpa-onnx (default) не использует gated модели. # Токен на https://huggingface.co/settings/tokens (read). Принять условия: # pyannote/speaker-diarization-3.1, pyannote/segmentation-3.0 # HF_TOKEN= """ def step(msg: str) -> None: print(f"\n{'=' * 70}\n{msg}\n{'=' * 70}", flush=True) def info(msg: str) -> None: print(f" {msg}", flush=True) def run(cmd: list[str], **kwargs) -> int: print(f" $ {' '.join(str(c) for c in cmd)}", flush=True) return subprocess.run(cmd, **kwargs).returncode REQUIRED_GB = 6.0 MIN_DRIVER_VERSION = (525, 0) # минимум для CUDA 12.0+ def check_gpu() -> tuple[bool, bool]: """Probe NVIDIA GPU + версия драйвера. Возвращает (gpu_found, driver_ok).""" if not shutil.which("nvidia-smi"): return False, False try: result = subprocess.run( ["nvidia-smi", "--query-gpu=name,memory.total,driver_version", "--format=csv,noheader"], capture_output=True, text=True, timeout=10, ) if result.returncode != 0 or not result.stdout.strip(): return False, False except (subprocess.SubprocessError, OSError): return False, False driver_ok = True for line in result.stdout.strip().splitlines(): parts = [p.strip() for p in line.split(",")] if len(parts) < 3: continue name, mem, driver_ver = parts[0], parts[1], parts[2] info(f"OK: GPU: {name} ({mem})") info(f"OK: Драйвер NVIDIA: {driver_ver}") try: major_minor = tuple(int(x) for x in driver_ver.split(".")[:2]) if major_minor < MIN_DRIVER_VERSION: driver_ok = False info(f"WARN: Драйвер {driver_ver} ниже минимального {MIN_DRIVER_VERSION[0]}.x для CUDA 12") info(" Обновите драйверы NVIDIA:") info(" - Windows: https://www.nvidia.com/Download/index.aspx или GeForce Experience") info(" - Linux: см. инструкцию вашего дистрибутива (apt install nvidia-driver-550 и т.п.)") info(" Без обновления faster-whisper / sherpa-onnx могут падать с CUDA errors.") except ValueError: info(f"WARN: Не удалось распарсить версию драйвера '{driver_ver}'") return True, driver_ok def check_requirements(allow_cpu: bool = False) -> tuple[bool, bool]: """Возвращает (ok, need_static_ffmpeg).""" step("Проверка требований") ok = True need_static_ffmpeg = False py_version = sys.version_info if py_version < (3, 10): info(f"FAIL: Python {py_version.major}.{py_version.minor} < 3.10") ok = False else: info(f"OK: Python {py_version.major}.{py_version.minor}.{py_version.micro}") has_ffmpeg = bool(shutil.which("ffmpeg")) has_ffprobe = bool(shutil.which("ffprobe")) if not has_ffmpeg or not has_ffprobe: missing = [] if not has_ffmpeg: missing.append("ffmpeg") if not has_ffprobe: missing.append("ffprobe") info(f"INFO: не найдено в PATH: {', '.join(missing)}. Будет установлен static-ffmpeg в venv-whisper.") need_static_ffmpeg = True else: info(f"OK: ffmpeg в {shutil.which('ffmpeg')}") info(f"OK: ffprobe в {shutil.which('ffprobe')}") gpu_found, _driver_ok = check_gpu() if not gpu_found: if allow_cpu: info("WARN: NVIDIA GPU не найден (nvidia-smi). Установка продолжится для CPU-режима.") info(" Транскрипция будет работать, но в 10+ раз медленнее. Запуск с --device cpu.") else: info("FAIL: NVIDIA GPU не найден (nvidia-smi). Скил настроен для CUDA GPU.") info(" Если у вас нет GPU - перезапустите setup с флагом --allow-cpu") info(" Если GPU есть, но nvidia-smi не работает - установите драйверы NVIDIA + CUDA 12.") ok = False try: free_gb = shutil.disk_usage(SKILL_ROOT).free / (1024 ** 3) if free_gb < REQUIRED_GB: info(f"FAIL: Свободного места {free_gb:.1f} GB < требуется {REQUIRED_GB} GB") ok = False else: info(f"OK: Свободно на диске: {free_gb:.1f} GB") except OSError as e: info(f"WARN: Не удалось проверить свободное место: {e}") info(f"Платформа: {'Windows' if IS_WIN else sys.platform}") return ok, need_static_ffmpeg def create_venv(venv_path: Path) -> Path: py = venv_path / VENV_BIN / PY_EXE if py.exists(): info(f"venv уже существует: {venv_path}") return py info(f"Создание venv: {venv_path}") rc = run([sys.executable, "-m", "venv", str(venv_path)]) if rc != 0: raise RuntimeError(f"Не удалось создать venv {venv_path}") rc = run([str(py), "-m", "pip", "install", "--upgrade", "pip", "setuptools", "wheel"]) if rc != 0: raise RuntimeError("Не удалось обновить pip") return py def pip_install(py: Path, packages: list[str], extra_args: list[str] | None = None) -> None: cmd = [str(py), "-m", "pip", "install"] if extra_args: cmd += extra_args cmd += packages rc = run(cmd) if rc != 0: raise RuntimeError(f"pip install упал на: {packages}") def install_whisper(skip: bool, skip_gemini: bool, with_pyannote: bool, install_static_ffmpeg: bool) -> None: step("venv-whisper: faster-whisper + Gemini + опц. pyannote") if skip: info("Пропускаем по флагу --skip-whisper") return py = create_venv(VENV_WHISPER) pip_install(py, WHISPER_PACKAGES) if not skip_gemini: info("Установка Gemini-зависимостей (google-genai, python-dotenv)") pip_install(py, GEMINI_PACKAGES) if with_pyannote: info("Установка pyannote.audio 4.x (требует HF_TOKEN в .env при использовании)") pip_install(py, PYANNOTE_PACKAGES) if install_static_ffmpeg: info("Установка static-ffmpeg (ffmpeg+ffprobe бинарники как pip-пакет)") pip_install(py, ["static-ffmpeg"]) info("OK") def install_sherpa_gpu(py: Path) -> None: info("Установка sherpa_onnx (GPU/CUDA)...") extra_index = "https://k2-fsa.github.io/sherpa/onnx/cuda.html" try: rc = run([str(py), "-m", "pip", "install", "sherpa-onnx", "-f", extra_index]) if rc == 0: info("sherpa_onnx установлен") return except Exception as e: info(f"WARN: {e}") info("ВНИМАНИЕ: автоматическая установка sherpa_onnx с CUDA не удалась.") info("Установите вручную:") info(f" {py} -m pip install sherpa-onnx") info("Или скачайте GPU wheel с https://huggingface.co/csukuangfj2/sherpa-onnx-wheels") info(f"и установите: {py} -m pip install <путь-к-wheel.whl>") def install_sherpa(skip: bool) -> None: step("venv-sherpa: sherpa_onnx + onnxruntime-gpu") if skip: info("Пропускаем по флагу --skip-sherpa") return py = create_venv(VENV_SHERPA) pip_install(py, SHERPA_PACKAGES) install_sherpa_gpu(py) info("OK") def download_file(url: str, dest: Path) -> None: if dest.exists(): info(f"Уже скачан: {dest.name} ({dest.stat().st_size / 1e6:.1f} MB)") return info(f"Скачивание {dest.name}") info(f" url: {url}") tmp = dest.with_suffix(dest.suffix + ".tmp") try: with urllib.request.urlopen(url) as resp, open(tmp, "wb") as f: total = int(resp.headers.get("Content-Length", 0)) downloaded = 0 chunk = 1 << 16 last_pct = -1 while True: data = resp.read(chunk) if not data: break f.write(data) downloaded += len(data) if total: pct = int(100 * downloaded / total) if pct >= last_pct + 10: last_pct = pct info(f" [{pct:3d}%] {downloaded / 1e6:.1f} / {total / 1e6:.1f} MB") tmp.rename(dest) except Exception as e: if tmp.exists(): tmp.unlink() raise RuntimeError(f"Не удалось скачать {url}: {e}") from e def extract_segmentation_archive() -> None: archive = MODELS_DIR / "sherpa-onnx-pyannote-segmentation-3-0.tar.bz2" target_dir = MODELS_DIR / "sherpa-onnx-pyannote-segmentation-3-0" if (target_dir / "model.onnx").exists(): info(f"Уже распакован: {target_dir}") return info(f"Распаковка {archive.name}") with tarfile.open(archive, "r:bz2") as tf: tf.extractall(MODELS_DIR) if not (target_dir / "model.onnx").exists(): raise RuntimeError(f"После распаковки нет {target_dir / 'model.onnx'}") def download_models(skip: bool) -> None: step("Модели диаризации") if skip: info("Пропускаем по флагу --skip-models") return MODELS_DIR.mkdir(exist_ok=True) for fname, url in MODEL_URLS.items(): download_file(url, MODELS_DIR / fname) extract_segmentation_archive() info("OK") def create_env_template(skip_gemini: bool) -> None: step("Шаблон .env") if ENV_FILE.exists(): info(f"Уже существует: {ENV_FILE}. НЕ перезаписываем.") return ENV_FILE.write_text(ENV_TEMPLATE, encoding="utf-8") info(f"Создан: {ENV_FILE}") if not skip_gemini: info("Заполните GEMINI_API_KEY=<ключ> (нужен для Gemini-режима - видео).") def main() -> int: ap = argparse.ArgumentParser(description="Установка скила transcribe") ap.add_argument("--skip-whisper", action="store_true", help="Не пересоздавать venv-whisper") ap.add_argument("--skip-sherpa", action="store_true", help="Не ставить sherpa (без диаризации)") ap.add_argument("--skip-models", action="store_true", help="Не скачивать модели") ap.add_argument("--skip-gemini", action="store_true", help="Не ставить google-genai (без Gemini)") ap.add_argument("--with-pyannote", action="store_true", help="Доп. поставить pyannote.audio 4.x как альтернативу sherpa-onnx (требует HF_TOKEN)") ap.add_argument("--allow-cpu", action="store_true", help="Разрешить установку без GPU (CPU-режим, в 10+ раз медленнее)") args = ap.parse_args() ok, need_static_ffmpeg = check_requirements(allow_cpu=args.allow_cpu) if not ok: print("\nПроверка требований не пройдена. Исправьте и повторите.", file=sys.stderr) return 1 try: install_whisper(args.skip_whisper, args.skip_gemini, args.with_pyannote, install_static_ffmpeg=need_static_ffmpeg) install_sherpa(args.skip_sherpa) download_models(args.skip_models) create_env_template(args.skip_gemini) except Exception as e: print(f"\nОшибка установки: {e}", file=sys.stderr) return 1 step("Готово") info(f"Скил: {SKILL_ROOT}") info(f"venv-whisper: {VENV_WHISPER}") info(f"venv-sherpa: {VENV_SHERPA}") info(f"models: {MODELS_DIR}") info(f".env: {ENV_FILE}") info("") info("Проверка (аудио, локально):") py = VENV_WHISPER / VENV_BIN / PY_EXE info(f" {py} {SKILL_ROOT / 'scripts' / 'transcribe_local.py'} <audio.mp3> --diarize") info("") info("Проверка (видео, Gemini):") info(f" {py} {SKILL_ROOT / 'scripts' / 'transcribe.py'} <video.mp4>") return 0 if __name__ == "__main__": sys.exit(main()) -
speaker_validator.py 38.3 KB
"""speaker_validator.py - программная проверка привязки имен к меткам спикеров. Зачем отдельный слой. Все проверенные языковые модели (qwen2.5-32b, qwen3-vl-30b и qwen3.6, в том числе с включенными размышлениями) систематически вешают имя на того, кто его ПРОИЗНОСИТ, хотя произносящий как раз обращается к другому. На эталонной встрече три модели дали три разных ответа, совпав ровно на одной метке из шести; правило "названный - тот, кто отвечает" в промпте есть, и все модели его игнорируют. Поэтому проверка вынесена в код. Независимое измерение (NTT + CMU, arXiv 2606.17542) говорит о том же: на определении адресата обычный классификатор обходит все языковые модели, а переход на модель побольше результат ухудшает. Что проверяется: 1. Звательная позиция ("Марина, логика та же", "Да, Леш?") - имя принадлежит НЕ говорящему. 2. Адресат - тот, кто отвечает в соседних репликах. 3. Третье лицо (косвенный падеж, имя с фамилией, "как Алексей просил") кандидатом не считается. 4. Усеченные и уменьшительные формы сводятся к полной (Леш, Леша -> Алексей). 5. Одно имя не висит на двух метках; роли и заглушки ("Модератор", "неизвестно") именами не считаются. Модуль работает на ГОТОВОМ транскрипте с метками и ни к каким моделям не обращается. Запуск отдельно (разбор одной встречи и отчет по уликам): python speaker_validator.py "<файл - со спикерами.md>" [--names SPEAKER_01=Алексей,...] """ import re import sys import json import unicodedata from collections import defaultdict # Строчная и заглавная "е с диерезисом" - в исходнике кодами, чтобы файл оставался без нее в тексте. # Whisper ставит ее непоследовательно ("Леша"/"Лёша", "Артем"/"Артём"), поэтому при сравнении # всегда сводим к обычной "е", а таблицу форм ниже держим только в варианте без диерезиса. _YO_LOWER = "ё" # Полная форма -> известные уменьшительные и усеченные (звательные) варианты. # Список покрывает распространенные русские имена; незнакомое имя все равно будет распознано как # кандидат по звательной позиции, просто без сведения форм друг к другу. _NAME_FORMS = { "Александр": ["Саша", "Саня", "Шура", "Алекс", "Сашка"], "Александра": ["Саша", "Сашенька", "Шура"], "Алексей": ["Леша", "Леха", "Алекс", "Лешка"], "Анастасия": ["Настя", "Ася", "Настена"], "Анатолий": ["Толя", "Толик"], "Андрей": ["Андрюша", "Дюша", "Андрюха"], "Анна": ["Аня", "Анюта", "Нюра"], "Антон": ["Антоша", "Тоша"], "Артем": ["Тема", "Артемка"], "Артур": [], "Борис": ["Боря"], "Вадим": ["Вадик"], "Валентина": ["Валя"], "Валерий": ["Валера"], "Василий": ["Вася"], "Вера": ["Верочка"], "Виктор": ["Витя"], "Виктория": ["Вика"], "Виталий": ["Виталик"], "Владимир": ["Вова", "Володя", "Вован"], "Владислав": ["Влад", "Слава"], "Вячеслав": ["Слава"], "Галина": ["Галя"], "Геннадий": ["Гена"], "Георгий": ["Гоша", "Жора"], "Григорий": ["Гриша"], "Даниил": ["Даня", "Данил"], "Дарья": ["Даша", "Дашенька"], "Денис": ["Дениска"], "Дмитрий": ["Дима", "Митя", "Димон", "Димка"], "Евгений": ["Женя", "Жека"], "Евгения": ["Женя"], "Егор": ["Егорка"], "Екатерина": ["Катя", "Катюша"], "Елена": ["Лена", "Аленка", "Ленка"], "Елизавета": ["Лиза"], "Иван": ["Ваня", "Ванька"], "Игорь": ["Игорек"], "Илья": ["Илюша"], "Ирина": ["Ира", "Иришка"], "Кирилл": ["Кир"], "Константин": ["Костя"], "Ксения": ["Ксюша", "Ксю"], "Лариса": ["Лара"], "Леонид": ["Леня"], "Лидия": ["Лида"], "Любовь": ["Люба"], "Людмила": ["Люда", "Мила"], "Максим": ["Макс"], "Марина": ["Мариша"], "Мария": ["Маша", "Машенька"], "Михаил": ["Миша", "Мишка"], "Надежда": ["Надя"], "Наталья": ["Наташа", "Ната"], "Наталия": ["Наташа"], "Никита": ["Никитка"], "Николай": ["Коля", "Колян"], "Олег": ["Олежа"], "Ольга": ["Оля", "Оленька"], "Павел": ["Паша", "Пашка"], "Петр": ["Петя"], "Роман": ["Рома", "Ромка"], "Руслан": [], "Светлана": ["Света", "Светик"], "Сергей": ["Сережа", "Серега", "Серый"], "Станислав": ["Стас"], "Степан": ["Степа"], "Тамара": ["Тома"], "Татьяна": ["Таня", "Танюша"], "Федор": ["Федя"], "Юлия": ["Юля"], "Юрий": ["Юра"], "Яков": ["Яша"], "Ярослав": ["Слава", "Ярик"], } # Не имена: роли, заглушки и отказы, которые модели регулярно подставляют вместо имени. _NOT_NAMES = { "модератор", "ведущий", "ведущая", "участник", "участница", "докладчик", "спикер", "неизвестно", "неизвестный", "нет", "нет данных", "не определено", "не определен", "аноним", "гость", "заказчик", "исполнитель", "клиент", "разработчик", "аналитик", "коллега", "коллеги", "все", "никто", "unknown", "n/a", "none", "null", } _WORD_RE = re.compile(r"[А-Яа-яA-Za-zЁё-]+") # Реплика приходит в двух видах, и оба надо понимать: # **[SPEAKER_05, 05:23]** текст - файл "<имя> - со спикерами.md"; # [05:23] SPEAKER_05: текст - внутренний формат пайплайна, который и попадает в проверку. # Поддержка только первого молча отключала всю проверку на реальном прогоне. _SEGMENT_RES = ( re.compile(r"\*\*\[(?:([^,\]]+),\s*)?(\d{1,3}(?::\d{2})+)\]\*\*\s*(.*)"), re.compile(r"\[(\d{1,3}(?::\d{2})+)\]\s*([^:]{1,40}?):\s*(.*)"), ) # Слова, после которых стоящее следом имя почти всегда идет в третьем лице, а не в обращении: # "как Алексей просил", "что Марина говорила". _THIRD_PERSON_MARKERS = { "как", "что", "чтобы", "если", "когда", "пока", "раз", "потому", "поскольку", "ведь", "мол", "будто", "словно", "вон", "вот", } # Частые слова, В ТОЧНОСТИ совпадающие с усеченными формами имен из таблицы выше: "о ТОМ, что" # против Тома -> Том, "у ВАС" против Вася -> Вас, "с ТЕМ же" против Тема -> Тем. Без этого фильтра # обычная речь превращается в поток ложных обращений. Список намеренно короткий: сюда попадают # только проверенные коллизии, а не все частотные слова языка. _COMMON_WORDS = {"том", "вас", "тем", "мил", "ром", "слав", "лар", "нат"} def _norm(word): """Сравнительная форма слова: нижний регистр, е вместо е-с-диерезисом, без концевых дефисов.""" w = unicodedata.normalize("NFC", word).strip("-") return w.lower().replace(_YO_LOWER, "е") def _build_index(): """Индекс "форма имени -> полное имя". Кроме полных и уменьшительных форм включает усеченную звательную (Леша -> Леш, Марина -> Марин) - в живой речи она встречается чаще полной. Формы, которые делят между собой РАЗНЫЕ имена (Саша - и Александр, и Александра; Женя - и Евгений, и Евгения), к полному имени НЕ сводятся: молчаливый выбор мужского варианта навязал бы женщине мужское имя и вдобавок поссорился бы с проверкой рода. Такая форма остается сама собой, а род у нее считается неизвестным. """ index, ambiguous = {}, set() def add(form, canonical): key = _norm(form) if len(key) < 3 or key in _COMMON_WORDS: return if key in index and index[key] != canonical: ambiguous.add(key) else: index.setdefault(key, canonical) for canonical, shorts in _NAME_FORMS.items(): for f in [canonical] + list(shorts): add(f, canonical) if _norm(f)[-1:] in ("а", "я"): # усеченное обращение: Леша -> Леш, Марина -> Марин add(f[:-1], canonical) for key in ambiguous: # именем остается, но собственным - без сведения к чужому полному index[key] = key.capitalize() return index, ambiguous _FORM_INDEX, _AMBIGUOUS_FORMS = _build_index() def first_name_key(name): """Полная форма ТОЛЬКО личного имени, без фамилии - по ней сверяются обращения и род.""" words = _WORD_RE.findall(name or "") if not words: return None return _FORM_INDEX.get(_norm(words[0])) or words[0].capitalize() def canonical_name(name): """Свести имя к полной форме (Леш, Леша -> Алексей), СОХРАНИВ фамилию, если она названа. Отбрасывать фамилию нельзя: на встрече с двумя Алексеями "Алексей Иванов" и "Алексей Петров" схлопнулись бы в одно имя, и правило "одно имя - одной метке" выкинуло бы живого участника. """ words = _WORD_RE.findall(name or "") if not words: return None canon = first_name_key(name) rest = " ".join(w.capitalize() for w in words[1:3]) # фамилия и отчество, если названы return f"{canon} {rest}".strip() if rest else canon def is_name_like(name): """Похоже ли на настоящее имя человека: не роль, не заглушка, не аббревиатура.""" if not name: return False words = _WORD_RE.findall(name) if not words: return False if _norm(name) in _NOT_NAMES or _norm(words[0]) in _NOT_NAMES: return False first = words[0] if len(first) < 3: return False if first.isupper() and len(first) <= 4: # КС, ТСД и прочие аббревиатуры return False return bool(re.match(r"^[А-Яа-яЁё][а-яё-]+$", first)) # ---------------- Разбор транскрипта ---------------- def parse_segments(text): """Транскрипт со спикерами -> [dict(idx, sec, label, text)] в порядке следования.""" segments = [] for line in text.splitlines(): line = line.strip() md = _SEGMENT_RES[0].match(line) plain = None if md else _SEGMENT_RES[1].match(line) if md: label, tstr, body = md.group(1), md.group(2), md.group(3).strip() elif plain: tstr, label, body = plain.group(1), plain.group(2), plain.group(3).strip() else: continue if not label or not body: continue # Метка спикера - это "SPEAKER_05", "Участник 2" или имя: длинных фраз там не бывает. # Без этой отсечки строка вида "[05:23] длинный текст: с двоеточием" дала бы мусорную метку. if label.count(" ") > 2: continue sec = 0 for part in tstr.split(":"): try: sec = sec * 60 + int(part) except ValueError: sec = 0 segments.append({"idx": len(segments), "sec": sec, "label": label.strip(), "text": body}) return segments def _tokens_with_pos(text): """[(слово, начало, конец)] по тексту реплики.""" return [(m.group(0), m.start(), m.end()) for m in _WORD_RE.finditer(text)] def _classify(text, tokens, i, extra_index): """Как употреблено имя в позиции i: 'vocative' (обращение), 'third' (третье лицо) или None. Обращение в русском надежно опознается пунктуацией: имя стоит в именительной или усеченной форме и отбито запятой либо границей предложения. Третье лицо - косвенный падеж ("Алексея Иванова запросить"), имя с фамилией ("Дима Петров") или имя после союза ("как Алексей просил"). """ word, start, end = tokens[i] key = _norm(word) known_form = key in _FORM_INDEX or key in extra_index before = text[:start].rstrip() after = text[end:].lstrip() prev_word = _norm(tokens[i - 1][0]) if i else "" # Имя с фамилией: следом ВПЛОТНУЮ (без знаков препинания между) идет слово с заглавной буквы. # Проверка на разделитель обязательна: иначе заглавная буква следующего ПРЕДЛОЖЕНИЯ выдает # обращение за фамилию - на "Да, Леш? Да." это съедало главную улику встречи. if i + 1 < len(tokens): next_word, next_start = tokens[i + 1][0], tokens[i + 1][1] gap = text[end:next_start] if (next_word[:1].isupper() and not re.search(r"[^\s]", gap) and _norm(next_word) not in _FORM_INDEX): return "third" if not known_form: # Форма не из словаря именительных: почти наверняка косвенный падеж того же имени. return "third" if prev_word in _THIRD_PERSON_MARKERS: return "third" starts_clause = (not before) or before.endswith((",", ".", "!", "?", ":", ";", "-")) ends_clause = (not after) or after[:1] in (",", ".", "!", "?", ";", ":") if starts_clause and ends_clause: return "vocative" return "third" def _stem(name): """Основа имени для сопоставления косвенных падежей (Алексей -> алексе).""" key = _norm(name) return key[:-1] if key[-1:] in ("й", "ь", "а", "я") else key # Падежные окончания имен. По ним косвенная форма отличается от постороннего слова с тем же началом: # без этой проверки "максимально" опознается как Максим, а "вернули" - как Вера. _CASE_ENDINGS = ("а", "у", "е", "и", "ы", "я", "ю", "ой", "ей", "ом", "ем", "ою", "ью", "ым") _MIN_STEM = 4 # основы короче (Вера -> вер) дают слишком много ложных совпадений def _oblique_of(key, stems): """Полное имя, если key - косвенная форма известного имени, иначе None. Длинные основы проверяются первыми: совпадение по более специфичной основе точнее.""" for stem in sorted(stems, key=len, reverse=True): if len(stem) < _MIN_STEM or key == stem or not key.startswith(stem): continue if key[len(stem):] in _CASE_ENDINGS: return stems[stem] return None def collect_name_events(segments, known_names=()): """Найти все употребления имен. Возвращает [dict(seg, label, name, kind, form)]. known_names - имена, уже предложенные другими слоями (модель, голосовая база): по ним ловятся и косвенные формы, даже если само имя в словаре отсутствует. """ extra_index = {} stems = {} for n in known_names: canon = canonical_name(n) if not canon: continue extra_index[_norm(canon)] = canon stems[_stem(canon)] = canon for canon in _NAME_FORMS: stems.setdefault(_stem(canon), canon) events = [] for seg in segments: tokens = _tokens_with_pos(seg["text"]) for i, (word, _s, _e) in enumerate(tokens): key = _norm(word) canon = _FORM_INDEX.get(key) or extra_index.get(key) if not canon: # не именительная форма - проверяем, не косвенный ли это падеж имени canon = _oblique_of(key, stems) if not canon: continue kind = _classify(seg["text"], tokens, i, extra_index) events.append({"seg": seg["idx"], "label": seg["label"], "name": canon, "kind": kind, "form": word, "sec": seg["sec"]}) return events # ---------------- Род говорящего ---------------- # Мужские имена, оканчивающиеся на -а/-я: общее правило "на -а/-я значит женское" их не берет. _MALE_EXCEPTIONS = {"Никита", "Илья", "Данила", "Кузьма", "Фома", "Савва", "Лука", "Гаврила"} # "я" и его формы, после которых глагол прошедшего времени указывает на род ГОВОРЯЩЕГО. _FIRST_PERSON = {"я"} _MIN_VERB_LEN = 3 # короче не бывает даже "был"/"дал"/"шел" # Служебные слова, которые в русском вклиниваются между "я" и глаголом ("я же все равно былА"). # Все ОСТАЛЬНОЕ обрывает поиск: иначе окончание случайного существительного принимается за глагол # и "я этот стол вижу" делает говорящего мужчиной, а "я вижу, что школа закрыта" - женщиной. _GENDER_SKIP = { "же", "бы", "уж", "уже", "еще", "тоже", "все", "всё", "вот", "не", "ни", "там", "тут", "сейчас", "тогда", "вчера", "сразу", "равно", "лично", "просто", "точно", "давно", "потом", "как", "то", "так", "ведь", "видимо", "кстати", "вообще", "именно", "тут", } def name_gender(canonical): """Род имени: 'f', 'm' или None, если по имени род не определить (Саша, Женя, Слава).""" if not canonical: return None key = _norm(canonical) if key in _AMBIGUOUS_FORMS: return None if canonical in _MALE_EXCEPTIONS: return "m" return "f" if key[-1:] in ("а", "я") else "m" def detect_gender(segments, min_margin=2): """Род говорящего за каждой меткой по форме глагола: "я сделалА" против "я сделаЛ". Считаем улики, а не первое попадание: диаризация регулярно склеивает короткий обмен репликами ("Да? Да. Я же была близко") в один сегмент, и тогда маркер принадлежит собеседнику. Отсюда два режима: если противоречий нет (все улики одного рода), хватает одной - на реальной встрече единственное "была" у метки верно опознало женщину, что подтвердила голосовая база. Если улики спорят, нужен перевес min_margin, иначе род не определяем вовсе. """ counts = defaultdict(lambda: {"m": 0, "f": 0}) for seg in segments: words = [_norm(w) for w in _WORD_RE.findall(seg["text"])] for i, w in enumerate(words): if w not in _FIRST_PERSON: continue for nxt in words[i + 1:]: if nxt in _GENDER_SKIP: continue # Первое же ЗНАЧИМОЕ слово решает: либо это глагол прошедшего времени, либо улики # нет вовсе. "лись" сюда не входит - это множественное число, а не женский род. if nxt == "сама" or (len(nxt) >= _MIN_VERB_LEN and (nxt.endswith("лась") or nxt.endswith("ла"))): counts[seg["label"]]["f"] += 1 elif nxt == "сам" or (len(nxt) >= _MIN_VERB_LEN and (nxt.endswith("лся") or nxt.endswith("л"))): counts[seg["label"]]["m"] += 1 break out = {} for label, c in counts.items(): if c["f"] and not c["m"]: out[label] = "f" elif c["m"] and not c["f"]: out[label] = "m" elif c["f"] - c["m"] >= min_margin: out[label] = "f" elif c["m"] - c["f"] >= min_margin: out[label] = "m" return out # ---------------- Вывод имен по уликам ---------------- # Вес адресата по расстоянию: сильнее всего - тот, кто ответил сразу после обращения. _NEXT_WEIGHTS = (1.0, 0.4) _PREV_WEIGHT = 0.4 def infer_from_events(segments, events): """Свести улики в оценки. Возвращает (scores, banned): scores[label][name] - насколько улики поддерживают "метка label это name"; banned[label] - множество имен, которые эта метка произносила в звательной позиции, то есть заведомо ЧУЖИЕ для нее. """ scores = defaultdict(lambda: defaultdict(float)) banned = defaultdict(set) by_idx = {s["idx"]: s for s in segments} for ev in events: if ev["kind"] != "vocative": continue speaker, name, idx = ev["label"], ev["name"], ev["seg"] banned[speaker].add(name) seen = [] for j in range(idx + 1, min(idx + 6, len(segments))): # кто отвечает после обращения lbl = by_idx[j]["label"] if lbl == speaker or lbl in seen: continue seen.append(lbl) if len(seen) >= len(_NEXT_WEIGHTS): break for k, lbl in enumerate(seen): scores[lbl][name] += _NEXT_WEIGHTS[k] for j in range(idx - 1, max(idx - 3, -1), -1): # или тот, с кем говорящий уже говорил lbl = by_idx[j]["label"] if lbl != speaker: scores[lbl][name] += _PREV_WEIGHT break for label in list(scores): # свое же имя в обращении - улика против, а не за for name in list(scores[label]): if name in banned[label]: del scores[label][name] return scores, banned # Вес голого предложения модели, ничем не подтвержденного в речи. Меньше веса одной прямой улики: # иначе модель занимает имя за случайной меткой, и метка с настоящим обращением остается ни с чем - # ровно так qwen3-vl отдавала "Алексей" метке, к которой по имени не обращались ни разу. _MODEL_PRIOR = 0.5 def _assign_global(combined, banned, locked, proposed, min_new): """Раздать имена меткам: одно имя - одной метке, одна метка - одно имя. Раздача общая для всех пар, откуда бы пара ни взялась, поэтому сильная улика из речи бьет слабое предложение модели. locked (подтвержденное голосом) занимает места до раздачи и не пересматривается: голос надежнее текста и узнает человека между встречами. Пары из proposed проходят без порога min_new - его проверяют только имена, которые мы назначаем сами. Возвращает (результат, список вытесненных предложений). """ result = dict(locked) taken = set(result.values()) pairs = sorted(((sc, lbl, nm) for lbl, d in combined.items() for nm, sc in d.items()), key=lambda x: (-x[0], x[1], x[2])) dropped = [] for score, label, name in pairs: # Занятость - по ПОЛНОМУ имени: "Алексей Иванов" и "Алексей Петров" разные люди, и оба # должны получить метку. Запреты же проверяются по личному имени - обращения в речи звучат # без фамилии. На эталонной встрече двое Алексеев реально есть, так что это не гипотеза. if (label in result or name in taken or first_name_key(name) in banned[label]): if proposed.get(label) == name: dropped.append((label, name)) continue if score < min_new and proposed.get(label) != name: continue result[label] = name taken.add(name) return result, dropped # ---------------- Точка входа ---------------- def validate(name_map, transcript_text, voice_confirmed=(), min_score=1.2, require_spoken=True): """Проверить и исправить привязку имен к меткам. name_map - что предложили предыдущие слои (модель и/или голосовая база); transcript_text - транскрипт с метками спикеров; voice_confirmed - метки, чье имя подтверждено голосом: их не пересматриваем, голос надежнее текстовых улик (он же узнает человека между встречами); require_spoken - снимать имена, которые в записи не звучали ни разу. Это защита от чистой выдумки модели: порог min_score к ее предложениям не применяется, так что без этой проверки имя без единой улики проходит насквозь. Выключать имеет смысл только в отладке; min_score - порог, ниже которого улик недостаточно, чтобы НАЗНАЧИТЬ имя самим. 1.2 - это больше одного ответа на обращение (вес 1.0), то есть нужны либо два независимых обращения, либо обращение плюс подтверждение. Смысл порога - не пускать в протокол имя, за которым стоит одна слабая улика: пустая метка честнее неверного имени. На эталонной встрече подъем порога с 0.8 до 1.2 убрал больше половины ошибок, не потеряв ни одного верного имени. Возвращает (исправленный map, список строк отчета - что и почему изменено). """ report = [] segments = parse_segments(transcript_text) if not segments: return dict(name_map or {}), ["валидатор: в транскрипте нет размеченных реплик - пропускаю"] proposed = {} for label, raw in (name_map or {}).items(): if not is_name_like(raw): report.append(f"снято {label} -> {raw!r}: это роль или заглушка, а не имя") continue canon = canonical_name(raw) if canon != raw: report.append(f"нормализовано {label}: {raw} -> {canon}") proposed[label] = canon events = collect_name_events(segments, known_names=proposed.values()) scores, banned = infer_from_events(segments, events) # Имена, которые в записи ВООБЩЕ звучали - в любой позиции и в любом падеже (collect_name_events # ловит и косвенные формы предложенных имен). Все остальное модель взяла из головы: на встрече, # где никого не назвали, она уверенно выдает правдоподобный набор ("Роман", "Станислав"), и до # этой проверки такие имена проходили насквозь - порог min_score к предложениям модели не # применяется по замыслу, а других улик у них нет. Пустая метка честнее выдуманного имени. spoken = {e["name"] for e in events} genders = detect_gender(segments) names_in_play = {e["name"] for e in events} | set(proposed.values()) wrong_gender = defaultdict(set) for label, g in genders.items(): for name in names_in_play: ng = name_gender(first_name_key(name)) if ng is not None and ng != g: # None - род по имени неизвестен, запрещать не за что wrong_gender[label].add(name) banned[label].add(first_name_key(name)) locked, survived = {}, {} for label, name in proposed.items(): if label in voice_confirmed: # голос надежнее текстовых улик - не пересматриваем locked[label] = name continue if require_spoken and first_name_key(name) not in spoken and name not in spoken: report.append(f"снято {label} -> {name}: имя ни разу не звучит в записи " f"(модель его придумала)") continue if name in wrong_gender[label]: told = "женском" if genders[label] == "f" else "мужском" report.append(f"снято {label} -> {name}: по форме глаголов метка говорит о себе " f"в {told} роде") continue if first_name_key(name) in banned[label]: where = next((e for e in events if e["label"] == label and e["name"] == first_name_key(name) and e["kind"] == "vocative"), None) at = (f" (обращается к нему на {where['sec'] // 60:02d}:{where['sec'] % 60:02d})" if where else "") report.append(f"снято {label} -> {name}: метка сама произносит это имя в обращении{at}") continue survived[label] = name combined = defaultdict(dict) for label, per_name in scores.items(): for name, sc in per_name.items(): if name not in banned[label]: combined[label][name] = sc for label, name in survived.items(): combined[label][name] = combined[label].get(name, 0.0) + _MODEL_PRIOR final, dropped = _assign_global(combined, banned, locked, survived, min_score) for label, name in dropped: winner = next((l for l, n in final.items() if n == name), None) if winner: report.append(f"снято {label} -> {name}: за {winner} улик больше " f"({combined[winner].get(name, 0.0):.1f} против " f"{combined[label].get(name, 0.0):.1f})") else: report.append(f"снято {label} -> {name}: имя не подтверждено речью") for label, name in sorted(final.items()): if label not in locked and survived.get(label) != name: report.append(f"добавлено {label} -> {name}: к метке обращаются по имени, " f"улик {scores[label].get(name, 0.0):.1f}") return final, report def evidence_report(transcript_text, known_names=()): """Человекочитаемая сводка улик - для разбора спорных случаев и отладки.""" segments = parse_segments(transcript_text) events = collect_name_events(segments, known_names=known_names) scores, banned = infer_from_events(segments, events) lines = ["Обращения по имени (звательная позиция):"] for ev in events: if ev["kind"] != "vocative": continue lines.append(f" {ev['sec'] // 60:02d}:{ev['sec'] % 60:02d} {ev['label']} произносит " f"'{ev['form']}' -> {ev['name']}") lines.append("Упоминания в третьем лице (кандидатами не считаются):") for ev in events: if ev["kind"] == "third": lines.append(f" {ev['sec'] // 60:02d}:{ev['sec'] % 60:02d} {ev['label']}: " f"'{ev['form']}' -> {ev['name']}") genders = detect_gender(segments) if genders: lines.append("Род по форме глаголов:") for label in sorted(genders): lines.append(f" {label}: {'женский' if genders[label] == 'f' else 'мужской'}") lines.append("Оценки:") for label in sorted(scores): inner = ", ".join(f"{n}={s:.1f}" for n, s in sorted(scores[label].items(), key=lambda x: -x[1])) lines.append(f" {label}: {inner or '-'} заведомо чужие: " f"{', '.join(sorted(banned[label])) or '-'}") return "\n".join(lines) def main(): # Вывод содержит кириллицу. Без явного переключения печать падает с UnicodeEncodeError # везде, где консоль не в UTF-8: сборочный агент, чужая локаль. sys.stdout.reconfigure(encoding='utf-8') sys.stderr.reconfigure(encoding='utf-8') # Запрос справки не должен читаться как имя файла: без этого любой ключ превращался # в путь и давал отказ вместо подсказки. if len(sys.argv) < 2 or sys.argv[1] in ("-h", "--help", "/?"): print(__doc__) sys.exit(0 if len(sys.argv) > 1 else 1) path = sys.argv[1] preset = {} for arg in sys.argv[2:]: if arg.startswith("--names"): body = arg.split("=", 1)[1] if "=" in arg else "" for pair in body.split(","): if "=" in pair: k, v = pair.split("=", 1) preset[k.strip()] = v.strip() with open(path, encoding="utf-8") as f: text = f.read() print(evidence_report(text, known_names=preset.values())) print() final, report = validate(preset, text) print("Итог:", json.dumps(final, ensure_ascii=False)) for line in report: print(" -", line) if __name__ == "__main__": main() -
text_stage.py 21.7 KB
"""text_stage.py - общая ТЕКСТОВАЯ стадия для обоих движков (локаль и облако). Работает поверх ГОТОВОГО транскрипта + описаний экрана - видео/аудио уже не трогает. LLM передается как callable `llm(data_text, instruction) -> str`, поэтому модуль одинаково работает и с gemma на сервере 150 (локальный движок), и с Gemini (облачный). Стадии: 1. map_speakers - LLM определяет реальные имена спикеров из транскрипта -> {метка: имя}. 2. apply_names - подставить имена вместо анонимных меток в любом тексте. 3. build_coherent_log - LLM собирает СВЯЗНЫЙ нарратив из механической склейки (экран+речь), чанкуя длинный лог под контекст модели. 4. build_summary - 2-проходное саммари (экстракт всех фактов -> протокол), с дроблением. """ import json import re import speaker_validator as sv # Схема строгого ответа для маппинга спикеров. Массив, а не объект-словарь: объект с произвольными # ключами через json_schema со strict=true не выразить, а формат, гарантированный сервером, ценнее # удобства разбора. Разбор ниже понимает обе формы - на случай движка без строгих схем. SPEAKERS_SCHEMA = { "type": "object", "properties": {"speakers": {"type": "array", "items": { "type": "object", "properties": {"label": {"type": "string"}, "name": {"type": "string"}}, "required": ["label", "name"]}}}, "required": ["speakers"], } # --- Промпты (единый источник истины для обоих движков) --- PROMPT_SPEAKERS = """Ниже транскрипт рабочей встречи: каждая реплика помечена анонимной меткой спикера (SPEAKER_00, SPEAKER_01, ... или "Участник N") и таймкодом. Определи реальное имя за каждой меткой, рассуждая как человек, который слушает встречу и по ходу понимает, кто есть кто: - ОБРАЩЕНИЕ: если звучит "Имя, ...", "Имя, что скажешь?" - названный человек это тот, кто отвечает в СОСЕДНИХ (следующих по таймкоду) репликах, или к кому явно обращаются. - САМОПРЕДСТАВЛЕНИЕ / подпись: "я, Имя, сделаю...", "это Имя", "меня зовут...". - ССЫЛКИ: "как Имя говорил(а)...", "Имя предлагал(а)..." - привяжи имя к той метке, которая это ранее говорила. - Согласуй по всей встрече: если метка один раз уверенно названа - держи это имя за ней везде. Правила: - Если человека в записи НИ РАЗУ не назвали - метку НЕ включай в ответ (не угадывай). - Имя бери как звучит (обычно имя; можно имя-фамилию). Приводи к именительному падежу (звательную/усеченную форму "Саш", "Кать" разворачивай в полную). - Одна метка = одно имя; одно имя не вешай на две разные метки. Ответь СТРОГО одним JSON-объектом вида {"speakers": [{"label": "SPEAKER_00", "name": "<имя>"}]}. Метки без определенного имени в список НЕ включай. Без markdown, без пояснений, без тройных кавычек.""" PROMPT_COHERENT = """Ниже фрагмент механического лога встречи: по таймкодам идут описания того, что распознано на экране (кадры), и реплики речи за те же интервалы. Перепиши это в СВЯЗНЫЙ хронологический нарратив - единым текстом рассказывай, что показывали на экране и что при этом обсуждали участники. Требования: - Каждый смысловой абзац начинай с таймкода [MM:SS]. - Указывай говорящего по имени, если оно есть в репликах. - СТРОГО только из данного материала, НИЧЕГО не выдумывай. - Цифры, суммы, коды счетов, номера и названия (справочников, документов, файлов, организаций) - переноси ДОСЛОВНО, символ в символ. Ни одного числа, кода или названия, которого нет в исходном тексте выше, в нарративе быть не должно. - НЕ обобщай перечисления в диапазоны ("от ... до ...") и НЕ приводи примеров ("например, ...") - это порождает коды и суммы, которых на экране не было. Либо перечисли реально названные, либо напиши без чисел ("список объектов", "несколько счетов"). - Если в описании кадра сказано, что текст не читается - так и пиши, не заменяй догадкой. - Пиши по-русски, связно и по делу. Верни только сам нарратив, без вводных фраз.""" PROMPT_TASKS_EXTRACT = """Перед тобой полная дословная транскрипция рабочей встречи. Извлеки из нее АБСОЛЮТНО ВСЕ конкретные пункты, ничего не обобщая и не пропуская. Пройди транскрипцию последовательно от начала до конца и выпиши: ## Задачи и поручения Каждую задачу, поручение, договоренность что-то сделать - отдельным пунктом. Даже если упомянуто вскользь, одной фразой, между делом. Для каждой: что сделать, кто ответственный (если назван), срок (если назван), таймкод [MM:SS]. ## Решения Каждое принятое решение - отдельным пунктом, с таймкодом. ВАЖНО: если решение составное (договорились сразу о нескольких связанных вещах - например и о механизме, и о его параметрах, и о способе ввода), РАЗБЕЙ его на отдельные пункты, а не склеивай в один. ## Открытые вопросы Каждый незакрытый вопрос, разногласие, "надо уточнить" - отдельным пунктом, с таймкодом. ## Важные детали Прочие значимые факты: цифры, условия, названия систем и документов, сроки, которые прозвучали и могут понадобиться. Правила: - Полнота и дробность важнее краткости. Лучше включить лишнее и раздробить, чем упустить или склеить. - НЕ объединяй несколько пунктов в один. Каждое поручение и каждое решение - отдельной строкой. - Сохраняй конкретику дословно: имена, цифры, названия, сроки. - Только то, что реально прозвучало в транскрипции. Ничего не выдумывай. Отвечай на языке транскрипции.""" PROMPT_SUMMARY_FROM_TEXT = """Перед тобой полная транскрипция рабочей встречи и предварительно извлеченный из нее список фактов (задачи, решения, открытые вопросы, детали). Составь по ним структурированный протокол встречи. КЛЮЧЕВОЕ ТРЕБОВАНИЕ: протокол обязан включить ВСЕ пункты из списка фактов - ни одна задача, решение или открытый вопрос не должны потеряться. Список фактов - это контроль полноты; транскрипция - источник контекста, формулировок и связей. Формат протокола (строго соблюдай структуру и заголовки): --- ## Цель встречи Один абзац - зачем собрались, что хотели обсудить/решить. ## Участники Список участников с именами и ролями (если определяются). ## Ключевые темы и фокус обсуждения Нумерованный список основных тем. Каждая тема - заголовок и 1-2 предложения пояснения. ## Решения Нумерованный список всех принятых решений. Каждое - отдельным пунктом, подробно, с таймкодом. ## Открытые вопросы Нумерованный список всех нерешенных вопросов. Для каждого - почему отложено или что нужно для решения, с таймкодом. ## Задачи Группируй по ответственному. Для каждого человека - нумерованный список задач. Формат: ### Имя (Роль) 1. Описание задачи. Срок: дата или "не определен". Таймкод [MM:SS] 2. ... Задачи без явного ответственного - в группу "### Без ответственного". --- Отвечай на языке транскрипции. По делу, но с достаточной детализацией, чтобы человек не присутствовавший на встрече понял контекст. Перепроверь себя: каждый факт из списка должен найти место в протоколе.""" # --- Стадии --- def _parse_speaker_map(resp): """Ответ модели -> {метка: имя}. Понимает обе формы: массив по схеме SPEAKERS_SCHEMA и плоский объект {"SPEAKER_00": "имя"} - движки без строгих схем отвечают вторым.""" try: m = re.search(r"\{.*\}", resp, re.S) data = json.loads(m.group(0)) if m else {} except (json.JSONDecodeError, ValueError, AttributeError, TypeError): return {} if isinstance(data.get("speakers"), list): return {str(it.get("label")): str(it.get("name")) for it in data["speakers"] if isinstance(it, dict) and it.get("label") and it.get("name")} return {str(k): str(v) for k, v in data.items() if v and isinstance(v, str) and str(v) != str(k)} def map_speakers(transcript_text, llm, max_chars=48000, voice_confirmed=(), log=None, validate=True): """Определить имена спикеров по репликам. Возвращает {метка: имя} (пустой при неудаче). Ответ модели ОБЯЗАТЕЛЬНО проходит программную проверку (speaker_validator): все проверенные модели систематически вешают имя на того, кто его произносит, а не на адресата, и правило в промпте это не лечит. Проверка перевешивает такие имена на отвечающую метку, снимает роли и заглушки, сводит усеченные формы к полной и не дает одному имени висеть на двух метках. voice_confirmed - метки, чье имя уже подтверждено голосовой базой: их проверка не трогает, голос надежнее текстовых улик. log - куда писать отчет о правках (например print). max_chars большой, чтобы охватить весь транскрипт: имена часто звучат ближе к концу. """ if not transcript_text.strip(): return {} try: resp = llm(transcript_text[:max_chars], PROMPT_SPEAKERS) names = _parse_speaker_map(resp) except Exception as e: # модель недоступна или ответила мусором - улики из речи все равно есть names = {} if log: log(f"текстовый слой не ответил ({e}) - опираюсь только на разбор обращений") if not validate: return names fixed, report = sv.validate(names, transcript_text, voice_confirmed=voice_confirmed) if log: for line in report: log(f"проверка имен: {line}") return fixed def apply_names(text, name_map): """Заменить метки спикеров на имена ОДНИМ проходом (комбинированный regex, подстановка по словарю). Один проход, а не цепочка re.sub: уже подставленное имя НЕ может быть повторно перезаписано следующей меткой. Это устраняет каскадное схлопывание, если LLM перепутал метки местами (напр. вернул SPEAKER_00->SPEAKER_01): при последовательных re.sub речь двух спикеров склеилась бы в одно имя. Метки в альтернации длинными первыми (SPEAKER_00 матчится раньше SPEAKER_0).""" if not name_map or not text: return text labels = sorted(name_map, key=len, reverse=True) pattern = re.compile("|".join(re.escape(lbl) for lbl in labels)) return pattern.sub(lambda m: name_map[m.group(0)], text) def _split_frame_blocks(mechanical_log): """Разбить механический лог на блоки-кадры (граница - строка, начинающаяся с '## [' или '[MM:SS]').""" blocks = re.split(r"(?=^#{0,3}\s*\[\d{1,2}:\d{2}\])", mechanical_log, flags=re.M) return [b for b in blocks if b.strip()] # Числа и коды: то, что модель обязана переносить дословно и на чем она чаще всего сочиняет. # Токен - последовательность из цифр, букв, точек, запятых и дефисов, содержащая хотя бы одну цифру. _NUM_TOKEN = re.compile(r"[A-Za-zА-Яа-я0-9]+(?:[.,\-/][A-Za-zА-Яа-я0-9]+)*") _TIMECODE = re.compile(r"^\d{1,3}:\d{2}(:\d{2})?$") def _number_tokens(text): """Числа/коды текста в нормализованном виде: {как сравнивать: как выглядит в тексте}. Разряды числа склеиваются до разбора (1 234,56 и 1234,56 - одно и то же), регистр не учитывается, таймкоды отбрасываются (они - разметка нарратива, а не данные с экрана). """ text = re.sub(r"(?<=\d)[\s ](?=\d)", "", text) out = {} for m in _NUM_TOKEN.finditer(text): raw = m.group(0).strip(".,-/") if len(raw) < 3 or not any(c.isdigit() for c in raw) or _TIMECODE.match(raw): continue out.setdefault(raw.lower(), raw) return out def unsupported_numbers(narrative, source): """Числа и коды нарратива, которых нет в исходном материале. Список того, что модель сочинила. Проверка нужна ровно потому, что запрет в промпте не держит: на реальном прогоне модель выдала коды счетов и диапазоны кодов, которых в описаниях кадров не было. Молча вырезать их нельзя - можно порвать фразу, поэтому находки выносятся в отчет, а решение остается за человеком. """ src = _number_tokens(source) return [raw for key, raw in _number_tokens(narrative).items() if key not in src] def build_coherent_log(mechanical_log, llm, chunk_chars=18000, report=None): """Связный нарратив из механической склейки (экран+речь). Чанкуем под контекст модели. Каждый чанк - несколько кадров подряд; на выходе нарративы конкатенируются по порядку. report - список, куда дописываются строки о числах, не подтвержденных исходным материалом (по одной на чанк). Передавать необязательно: без него проверка просто не ведется. """ blocks = _split_frame_blocks(mechanical_log) if not blocks: return "" chunks, cur, cur_len = [], [], 0 for b in blocks: if cur and cur_len + len(b) > chunk_chars: chunks.append("".join(cur)) cur, cur_len = [], 0 cur.append(b) cur_len += len(b) if cur: chunks.append("".join(cur)) out = [] for i, ch in enumerate(chunks, 1): narrative = llm(ch, PROMPT_COHERENT).strip() if not narrative: continue out.append(narrative) if report is not None: bad = unsupported_numbers(narrative, ch) if bad: report.append(f"фрагмент {i} из {len(chunks)}: {', '.join(bad)}") return "\n\n".join(out) def _chunk_by_lines(text, chunk_chars): """Разбить текст на чанки ~chunk_chars по границам строк (строки не рвем).""" chunks, cur, cur_len = [], [], 0 for ln in text.splitlines(keepends=True): if cur and cur_len + len(ln) > chunk_chars: chunks.append("".join(cur)) cur, cur_len = [], 0 cur.append(ln) cur_len += len(ln) if cur: chunks.append("".join(cur)) return chunks def build_summary(transcript_text, llm, chunk_chars=40000, max_ctx_chars=80000): """2-проходное саммари с защитой от переполнения контекста на длинных встречах (H1). Проход 1 (факты): транскрипт длиннее chunk_chars извлекаем ПО ЧАНКАМ и склеиваем, чтобы длинная встреча не обрезалась в одном вызове. Короткая - как раньше, одним вызовом. Проход 2 (протокол): если транскрипт+факты влезают (<= max_ctx_chars) - подаем оба (контекст + контроль полноты); иначе (очень длинная встреча) - ТОЛЬКО факты (они и есть полный экстракт), иначе модель тихо обрежет середину. Пороги под контекст gemma (~32K); типичная встреча (<= chunk_chars, напр. 48-мин ~33K символов) идет одним проходом как раньше - без регресса.""" if not transcript_text.strip(): return None if len(transcript_text) <= chunk_chars: facts = llm(transcript_text, PROMPT_TASKS_EXTRACT) else: parts = [] for ch in _chunk_by_lines(transcript_text, chunk_chars): f = llm(ch, PROMPT_TASKS_EXTRACT) if f and f.strip(): parts.append(f.strip()) facts = "\n\n".join(parts) if not facts or not facts.strip(): return None if len(transcript_text) + len(facts) <= max_ctx_chars: data = (f"ТРАНСКРИПЦИЯ:\n\n{transcript_text}\n\n" f"---\n\nПРЕДВАРИТЕЛЬНО ИЗВЛЕЧЕННЫЕ ФАКТЫ:\n\n{facts}") else: data = ("ПОЛНЫЙ СПИСОК ИЗВЛЕЧЕННЫХ ФАКТОВ (задачи/решения/вопросы). Транскрипт слишком " "длинный для контекста - синтезируй протокол строго по этим фактам:\n\n" + facts) return llm(data, PROMPT_SUMMARY_FROM_TEXT) -
transcribe.py 47.3 KB
""" Транскрибация аудио и видео через Gemini API. Два режима: - Generic (по умолчанию): verbatim-транскрипция речи с таймкодами - Analyze-UI (--analyze-ui, только видео): саммари + детальный лог + скриншоты + транскрипция Установка: pip install google-genai python-dotenv Использование: python transcribe.py "запись.mp3" python transcribe.py "встреча.mp4" --analyze-ui python transcribe.py "подкаст.wav" --with-summary --output-dir "./результат" API-ключ: переменная окружения GEMINI_API_KEY или файл .env (ищет в ~/.claude/skills/transcribe/.env, ~/.claude/skills/video-transcribe/.env, затем в cwd). """ import argparse import json import os import re import shutil import subprocess import sys import tempfile import time from concurrent.futures import ThreadPoolExecutor, as_completed from pathlib import Path from dotenv import load_dotenv # Загрузка .env: приоритет transcribe > video-transcribe > cwd _home = Path.home() for _env_path in [ _home / ".claude" / "skills" / "transcribe" / ".env", _home / ".claude" / "skills" / "video-transcribe" / ".env", ]: if _env_path.exists(): load_dotenv(_env_path) break load_dotenv() # cwd/.env как fallback import httpx from google import genai from google.genai import errors, types VIDEO_EXTENSIONS = {".mp4", ".mkv", ".webm", ".avi", ".mov"} AUDIO_EXTENSIONS = {".mp3", ".wav", ".ogg", ".m4a", ".flac", ".aac", ".wma"} ALL_EXTENSIONS = VIDEO_EXTENSIONS | AUDIO_EXTENSIONS # === Модели Gemini и авто-fallback при перегрузке === # Стартовая модель по умолчанию: ПИН на конкретную версию gemini-2.5-flash (не плавающий алиас). # Плавающий gemini-flash-latest дрейфовал в gemini-3.5-flash (видео-вход 5x, выход 3.6x дороже) - # это дало 96% счета за Gemini в июне 2026. Явная версия не дрейфует. DEFAULT_MODEL = "gemini-2.5-flash" # Цепочка перебора при 503/429 - ТОЛЬКО дешевые модели 2.5. Сознательно НЕ уходим в дорогие # gemini-3.5-flash / *-pro / плавающие *-latest (в 4-5x дороже, именно на них утекал счет). # Если оба варианта перегружены - лучше отказ, чем молчаливая переплата в 5x. Обе видео-capable, # output 65536 / input 1M. Переопределяется через --model / --fallback-models / env. DEFAULT_FALLBACK_CHAIN = [ "gemini-2.5-flash", "gemini-2.5-flash-lite", ] # HTTP-коды, при которых переходим к следующей модели (SDK уже отретраил - модель устойчиво лежит). RETRYABLE_CODES = {408, 429, 500, 502, 503, 504} # Модель недоступна для ключа (напр. preview не у всех) - тоже к следующей, но без ожидания. MODEL_UNAVAILABLE_CODES = {404} # === Промпты: Generic === PROMPT_TRANSCRIBE = """Транскрибируй всю речь из этой записи дословно. Требования: 1. Таймкоды [MM:SS] каждые 30-60 секунд или при смене спикера 2. Идентификация спикеров (Спикер 1, Спикер 2, или по имени если названо) 3. Значимые неречевые звуки в скобках: [смех], [пауза], [шум] 4. Сохраняй оригинальный язык записи 5. Дословная транскрипция, не пересказ Отвечай на языке записи. Формат - Markdown с таймкодами.""" # Проход 1 саммари: экстрактор всех фактов из текста транскрипции (контроль полноты). PROMPT_TASKS_EXTRACT = """Перед тобой полная дословная транскрипция рабочей встречи. Извлеки из нее АБСОЛЮТНО ВСЕ конкретные пункты, ничего не обобщая и не пропуская. Пройди транскрипцию последовательно от начала до конца и выпиши: ## Задачи и поручения Каждую задачу, поручение, договоренность что-то сделать - отдельным пунктом. Даже если упомянуто вскользь, одной фразой, между делом. Для каждой: что сделать, кто ответственный (если назван), срок (если назван), таймкод [MM:SS]. ## Решения Каждое принятое решение - отдельным пунктом, с таймкодом. ## Открытые вопросы Каждый незакрытый вопрос, разногласие, "надо уточнить" - отдельным пунктом, с таймкодом. ## Важные детали Прочие значимые факты: цифры, условия, названия систем и документов, сроки, которые прозвучали и могут понадобиться. Правила: - Полнота важнее краткости. Лучше включить лишнее, чем упустить. - НЕ объединяй несколько пунктов в один. Каждое поручение - отдельной строкой. - Сохраняй конкретику дословно: имена, цифры, названия, сроки. - Только то, что реально прозвучало в транскрипции. Ничего не выдумывай. Отвечай на языке транскрипции.""" # Проход 2 саммари: протокол из текста транскрипции + извлеченных фактов (гарантия полноты). PROMPT_SUMMARY_FROM_TEXT = """Перед тобой полная транскрипция рабочей встречи и предварительно извлеченный из нее список фактов (задачи, решения, открытые вопросы, детали). Составь по ним структурированный протокол встречи. КЛЮЧЕВОЕ ТРЕБОВАНИЕ: протокол обязан включить ВСЕ пункты из списка фактов - ни одна задача, решение или открытый вопрос не должны потеряться. Список фактов - это контроль полноты; транскрипция - источник контекста, формулировок и связей. Формат протокола (строго соблюдай структуру и заголовки): --- ## Цель встречи Один абзац - зачем собрались, что хотели обсудить/решить. ## Участники Список участников с именами и ролями (если определяются). ## Ключевые темы и фокус обсуждения Нумерованный список основных тем. Каждая тема - заголовок и 1-2 предложения пояснения. ## Решения Нумерованный список всех принятых решений. Каждое - отдельным пунктом, подробно, с таймкодом. ## Открытые вопросы Нумерованный список всех нерешенных вопросов. Для каждого - почему отложено или что нужно для решения, с таймкодом. ## Задачи Группируй по ответственному. Для каждого человека - нумерованный список задач. Формат: ### Имя (Роль) 1. Описание задачи. Срок: дата или "не определен". Таймкод [MM:SS] 2. ... Задачи без явного ответственного - в группу "### Без ответственного". --- Отвечай на языке транскрипции. По делу, но с достаточной детализацией, чтобы человек не присутствовавший на встрече понял контекст. Перепроверь себя: каждый факт из списка должен найти место в протоколе.""" # === Промпты: Analyze-UI (анализ интерфейсов) === # Саммари в analyze-ui строится из текста транскрипции через build_summary # (полнее по задачам/решениям, чем разбор видео), отдельного UI-промпта саммари нет. PROMPT_UI_DETAILED = """Ты анализируешь видеозапись рабочей встречи, на которой демонстрируются бизнес-процессы и интерфейсы программ (1С и другие). Сделай МАКСИМАЛЬНО ДЕТАЛЬНЫЙ пошаговый анализ видео. Не обобщай - описывай каждое действие. ## Требования к детализации: ### 1. Пошаговый хронологический лог (основная часть) Для каждого значимого момента (каждые 10-30 секунд или при смене экрана/действия): - **[MM:SS]** Что именно происходит на экране - Какое окно/форма открыта (полное название из заголовка) - Какие поля видны и какие значения в них заполнены (читай весь текст с экрана) - Какие кнопки нажимаются, какие пункты меню выбираются - Куда переходит пользователь (навигационный путь) - Что говорят участники в этот момент (если слышно речь - перескажи суть) ### 2. Распознанные данные - Все названия справочников, документов, регистров, отчетов которые видны - Все значения полей которые можно прочитать с экрана (наименования, числа, даты) - Структура меню и навигации которая видна - Названия колонок таблиц, значения в ячейках ### 3. Речь участников - Кто говорит и что именно обсуждается (пересказ близко к тексту, не обобщение) - Вопросы, ответы, решения, замечания - каждое отдельно с таймкодом - Если кто-то что-то объясняет - передай суть объяснения подробно ### 4. Итоги - Общая тема встречи - Список всех показанных интерфейсов/форм - Принятые решения и открытые вопросы - Участники и их роли Отвечай на русском языке. Будь максимально подробным - лучше написать слишком много, чем упустить детали. Таймкоды в формате MM:SS.""" PROMPT_SCREENSHOTS = """Проанализируй видео и определи ключевые моменты, для которых нужно сделать скриншоты. Выбери моменты где: - Показан новый интерфейс/форма/документ (первое появление) - Виден важный результат (отчет, таблица с данными) - Демонстрируется ключевое действие (заполнение формы, настройка) Верни ТОЛЬКО JSON-массив объектов, без markdown-форматирования, без ```json блоков: [ {"time": "MM:SS", "description": "Краткое описание что на скриншоте"} ] Выбери 5-15 ключевых моментов, равномерно распределенных по видео.""" PROMPT_UI_ALLINONE = """Перед тобой ФРАГМЕНТ (несколько минут) видеозаписи рабочей встречи, где демонстрируются 1С и другие интерфейсы. Разбери ВЕСЬ фрагмент за один проход и верни РОВНО три раздела. Каждый раздел начинается с точной строки-разделителя на ОТДЕЛЬНОЙ строке - пиши разделители буквально, как указано. Таймкоды - ОТ НАЧАЛА этого фрагмента (фрагмент начинается с 00:00). Покрой ВСЮ длительность: таймкоды примерно каждые 15-30 секунд от 00:00 и до самого конца фрагмента, НЕ останавливайся раньше. ВСЕ строго на русском языке, включая описания скриншотов. =====ТРАНСКРИПЦИЯ===== Полная дословная транскрипция речи, ничего не пропуская. Каждая реплика отдельной строкой строго в формате: [MM:SS] Имя: текст. Имена бери из обращений и представлений участников; если имя определить нельзя - пиши "Участник 1", "Участник 2" и т.д. стабильно за одним и тем же голосом. =====ДЕТАЛЬНЫЙ===== Пошаговый хронологический лог. КАЖДАЯ запись ОБЯЗАТЕЛЬНО начинается с таймкода [MM:SS]. Для каждого момента: какое окно/форма открыты (полное название из заголовка); какие поля и значения видны - читай ВЕСЬ текст с экрана ДОСЛОВНО (названия справочников, документов, регистров, отчетов, числа, даты, названия колонок и значения ячеек); какие действия выполняются; что при этом обсуждают участники (близко к тексту). Пиши связным текстом-нарративом. Цифры и названия - строго дословно. =====СКРИНШОТЫ===== ТОЛЬКО JSON-массив 2-4 ключевых моментов этого фрагмента (новая форма/интерфейс, важный результат-таблица, ключевое действие), без markdown и без тройных кавычек: [{"time": "MM:SS", "description": "что на скриншоте, по-русски"}] Все таймкоды строго в формате MM:SS от начала фрагмента.""" # === Утилиты === def upload_file(client, path): """Загрузка файла в Gemini File API с workaround для кириллических имен.""" with tempfile.TemporaryDirectory() as tmp_dir: tmp_path = Path(tmp_dir) / f"media{path.suffix}" shutil.copy2(path, tmp_path) media_file = client.files.upload(file=str(tmp_path)) return media_file def wait_for_processing(client, media_file): """Ожидание обработки файла.""" while media_file.state.name == "PROCESSING": print(" Обработка файла...") time.sleep(5) media_file = client.files.get(name=media_file.name) if media_file.state.name == "FAILED": print(f"Ошибка обработки файла: {media_file.state}") sys.exit(1) return media_file def get_media_duration(path): """Получение длительности медиафайла в секундах через ffprobe.""" try: result = subprocess.run( ["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", str(path)], capture_output=True, text=True, timeout=30, ) return float(result.stdout.strip()) except Exception as e: print(f" Предупреждение: не удалось определить длительность ({e}). Разбивка длинных файлов отключена.") return 0 UI_CHUNK_SEC = int(os.environ.get("UI_CHUNK_SEC", "300")) # analyze-ui: длина видео-чанка (сек). Короткий # чанк = один мультимодальный проход держит таймлайн; на полном видео один проход коллапсирует таймкоды. UI_MAX_PARALLEL = int(os.environ.get("UI_MAX_PARALLEL", "6")) # analyze-ui: сколько чанков обрабатывать # одновременно (загрузка+генерация). Gemini держит конкурентность; при 429/503 - перебор моделей invoker. def split_media(path, max_duration=3600): """Разбивка медиафайла на части если превышает max_duration (сек).""" duration = get_media_duration(path) if duration <= max_duration: return [path], [0] parts = [] offsets = [] num_parts = int(duration // max_duration) + 1 part_duration = int(duration // num_parts) + 1 tmp_dir = tempfile.mkdtemp() print(f" Файл {duration/60:.0f} мин > {max_duration/60:.0f} мин лимита, разбиваю на {num_parts} частей...") for i in range(num_parts): start = i * part_duration out_path = Path(tmp_dir) / f"part_{i+1}{path.suffix}" subprocess.run( ["ffmpeg", "-y", "-ss", str(start), "-i", str(path), "-t", str(part_duration), "-c", "copy", str(out_path)], capture_output=True, timeout=120, ) if out_path.exists(): parts.append(out_path) offsets.append(start) end = min(start + part_duration, int(duration)) print(f" Часть {i+1}: {start//60}:{start%60:02d} - {end//60}:{end%60:02d}") return parts, offsets def offset_timestamps_in_text(text, offset_seconds): """Сдвиг таймкодов [MM:SS] в тексте на offset_seconds.""" if offset_seconds == 0: return text def replace_ts(match): mm, ss = int(match.group(1)), int(match.group(2)) total = mm * 60 + ss + offset_seconds new_mm, new_ss = divmod(total, 60) return f"[{new_mm:02d}:{new_ss:02d}]" return re.sub(r"\[(\d{1,2}):(\d{2})\]", replace_ts, text) def extract_screenshots(video_path, timestamps, output_dir): """Извлечение скриншотов через ffmpeg по таймкодам.""" screenshots_dir = output_dir / "screenshots" screenshots_dir.mkdir(exist_ok=True) extracted = [] for i, item in enumerate(timestamps, 1): ts = item["time"] desc = item["description"] out_file = screenshots_dir / f"{i:02d}_{ts.replace(':', '-')}.png" parts = ts.split(":") seconds = int(parts[0]) * 60 + int(parts[1]) try: subprocess.run( [ "ffmpeg", "-y", "-ss", str(seconds), "-i", str(video_path), "-frames:v", "1", "-q:v", "2", str(out_file), ], capture_output=True, timeout=30, ) if out_file.exists(): extracted.append({"file": out_file.name, "time": ts, "description": desc}) print(f" [{ts}] {out_file.name} - {desc}") except Exception as e: print(f" [{ts}] Ошибка: {e}") return extracted def insert_screenshots_into_text(text, extracted): """Вставка ссылок на скриншоты в детальный анализ рядом с соответствующими таймкодами.""" if not extracted: return text for s in reversed(extracted): ts = s["time"] img_md = f"\n\n![{s['description']}](screenshots/{s['file']})\n" pattern = re.compile( r"^(.*?" + re.escape(ts) + r".*?)$", re.MULTILINE, ) match = pattern.search(text) if match: insert_pos = match.end() text = text[:insert_pos] + img_md + text[insert_pos:] else: text += f"\n\n**[{ts}]** {s['description']}{img_md}" return text def offset_screenshot_times(timestamps, offset_seconds): """Сдвиг таймкодов скриншотов на offset_seconds.""" if offset_seconds == 0: return timestamps result = [] for item in timestamps: parts = item["time"].split(":") total = int(parts[0]) * 60 + int(parts[1]) + offset_seconds mm, ss = divmod(total, 60) result.append({"time": f"{mm:02d}:{ss:02d}", "description": item["description"]}) return result def is_video(path): return path.suffix.lower() in VIDEO_EXTENSIONS def is_audio(path): return path.suffix.lower() in AUDIO_EXTENSIONS # === Генерация через Gemini с авто-fallback по моделям === class GeminiClient: """Обертка над genai.Client с авто-перебором моделей при перегрузке (503/429). Ретрай одной модели делает SDK (http_options.retry_options). Этот класс при устойчивом отказе модели переключается на следующую из цепочки и запоминает рабочую для следующих вызовов. """ def __init__(self, api_key, models): # SDK сам ретраит каждую модель (по умолчанию retry_options=None - ретраев нет). # attempts=2 на КАЖДУЮ модель; коды ретрая - дефолтные SDK (408/429/500/502/503/504). retry = types.HttpRetryOptions(attempts=2) self.client = genai.Client( api_key=api_key, http_options=types.HttpOptions(retry_options=retry), ) self.models = models self._idx = 0 # индекс текущей рабочей модели def generate(self, media_file, prompt): """Генерация по медиафайлу (видео/аудио) с перебором моделей. Возвращает текст.""" return self._generate([media_file, prompt]) def generate_text(self, text, prompt): """Генерация по текстовому контенту (напр. саммари из транскрипции). Возвращает текст.""" # Порядок как в generate: данные, затем инструкция. return self._generate([text, prompt]) def _generate(self, contents): """Перебор моделей при перегрузке (503/429) для произвольного contents. Возвращает текст.""" n = len(self.models) tried = [] last_err = None for offset in range(n): i = (self._idx + offset) % n model = self.models[i] tried.append(model) next_model = self.models[(self._idx + offset + 1) % n] if offset < n - 1 else None tail = f"пробую {next_model}" if next_model else "модели исчерпаны" try: response = self.client.models.generate_content( model=model, contents=contents, ) if not response.text: # Пустой ответ - фильтр безопасности/контент, а не нагрузка. # Модели не перебираем: пробрасываем наружу. finish = ( getattr(response.candidates[0], "finish_reason", "unknown") if response.candidates else "no candidates" ) raise RuntimeError( f"Gemini ({model}) вернул пустой ответ (возможно, сработал фильтр " f"безопасности). finish_reason: {finish}" ) self._idx = i # запомнить рабочую модель для следующих вызовов if offset > 0: print(f" [OK] Сгенерировано моделью {model}") return response.text except errors.APIError as e: if e.code in RETRYABLE_CODES or e.code in MODEL_UNAVAILABLE_CODES: last_err = e print(f" [!] Модель {model} недоступна (код {e.code}), {tail}") continue raise # fatal (400/401/403/...) - смена модели не поможет except (httpx.TimeoutException, httpx.TransportError) as e: last_err = e print(f" [!] Сетевая ошибка на {model} ({type(e).__name__}), {tail}") continue raise RuntimeError( f"Все модели Gemini недоступны (перегрузка/недоступность). " f"Испробованы: {', '.join(tried)}. Последняя ошибка: {last_err}" ) def transcribe_generic(invoker, media_file, time_offset=0): """Generic-транскрипция: verbatim речь с таймкодами.""" text = invoker.generate(media_file, PROMPT_TRANSCRIBE) return offset_timestamps_in_text(text, time_offset) def build_summary(invoker, transcript_text): """Саммари из текста транскрипции в два прохода: экстрактор фактов -> протокол. Источник - текст транскрипции, а не видео: полнее по речи (все задачи/решения, в т.ч. сказанные вскользь) и один проход на всю встречу убирает фрагментацию по частям. Проход 1 извлекает все факты, проход 2 оформляет протокол, гарантированно включив их. Прежнее саммари из видео в один проход теряло часть задач. """ print(" [саммари 1/2] Извлечение всех задач/решений из транскрипции...") facts = _safe_call( "извлечение фактов", lambda: invoker.generate_text(transcript_text, PROMPT_TASKS_EXTRACT), ) print(" [саммари 2/2] Сборка протокола с контролем полноты...") combined = ( f"ТРАНСКРИПЦИЯ:\n\n{transcript_text}\n\n" f"---\n\nПРЕДВАРИТЕЛЬНО ИЗВЛЕЧЕННЫЕ ФАКТЫ:\n\n{facts}" ) return _safe_call( "протокол", lambda: invoker.generate_text(combined, PROMPT_SUMMARY_FROM_TEXT), ) def _safe_call(label, fn): """Выполнить вызов Gemini, не роняя весь прогон. При ожидаемом отказе вернуть маркер. Ловим только ожидаемые отказы Gemini: fatal API-код, исчерпанный пул моделей, пустой ответ фильтра, сеть. Программные ошибки (AttributeError/TypeError) НЕ маскируем - пусть падают, иначе баг кода спрячется за маркером. Прежде один упавший вызов из нескольких терял весь прогон analyze-ui. """ try: return fn() except (errors.APIError, httpx.TimeoutException, httpx.TransportError, RuntimeError) as e: print(f" [!] {label}: шаг пропущен ({e})", file=sys.stderr) return f"\n> **[!] {label}: шаг не выполнен.** Причина: {e}\n" def _safe_generate(invoker, media_file, prompt, label): """invoker.generate (по видео/файлу), не роняющий прогон analyze-ui.""" return _safe_call(label, lambda: invoker.generate(media_file, prompt)) def _append(file_path, text): """Дописать текст в файл результата (инкрементальное сохранение по частям).""" with open(file_path, "a", encoding="utf-8") as f: f.write(text) def _split_allinone(resp): """Разбить единый ответ Gemini на (транскрипт, детальный, screenshots_json). Разделители =====ТРАНСКРИПЦИЯ/ДЕТАЛЬНЫЙ/СКРИНШОТЫ=====. Если разметка не сработала (напр. маркер ошибки _safe_generate) - весь ответ уходит в детальный, остальное пусто. """ parts = re.split(r"=====\s*(ТРАНСКРИПЦИЯ|ДЕТАЛЬНЫЙ|СКРИНШОТЫ)\s*=====", resp) d = {} for i in range(1, len(parts) - 1, 2): d[parts[i]] = parts[i + 1].strip() transcript = d.get("ТРАНСКРИПЦИЯ", "") detailed = d.get("ДЕТАЛЬНЫЙ", "") shots = d.get("СКРИНШОТЫ", "") if not detailed and not transcript: detailed = resp.strip() return transcript, detailed, shots def _parse_shots(shots_json, time_offset): """Разобрать JSON-массив скриншотов и сдвинуть их таймкоды на time_offset. [] при ошибке.""" if not shots_json: return [] try: text = re.sub(r"^```json\s*", "", shots_json.strip()) text = re.sub(r"\s*```$", "", text) return offset_screenshot_times(json.loads(text), time_offset) except (json.JSONDecodeError, ValueError, KeyError) as e: print(f" Не удалось распарсить таймкоды скриншотов: {e}") return [] def analyze_ui_single(invoker, media_file, video_path, output_dir, part_label="", time_offset=0): """Analyze-UI: ОДИН мультимодальный проход по чанку (транскрипт + детальный + скриншоты). Вызывается по-чанково (~5 мин) из _process_analyze_ui. На КОРОТКОМ чанке один проход держит таймлайн (проверено де-риском); на полном 25-мин видео один проход коллапсирует таймкоды. time_offset сдвигает чанк-локальные таймкоды в глобальные. Саммари строится глобально из полной транскрипции в _process_analyze_ui. """ suffix = f" (фрагмент {part_label})" if part_label else "" print(f" [проход]{suffix} транскрипт + детальный + скриншоты...") resp = _safe_generate(invoker, media_file, PROMPT_UI_ALLINONE, f"проход{suffix}") transcript_text, detailed_text, shots_json = _split_allinone(resp) detailed_text = offset_timestamps_in_text(detailed_text, time_offset) transcript_text = offset_timestamps_in_text(transcript_text, time_offset) timestamps = _parse_shots(shots_json, time_offset) if timestamps: print(f" Извлекаю {len(timestamps)} скриншотов...") extracted = extract_screenshots(video_path, timestamps, output_dir) detailed_text = insert_screenshots_into_text(detailed_text, extracted) return detailed_text, transcript_text # === Основная логика === def process_file(path, output_dir, mode, with_summary, output_format, models): """Обработка одного файла.""" api_key = os.environ.get("GEMINI_API_KEY") if not api_key: print("API-ключ не найден. Варианты:") print(" 1. Файл ~/.claude/skills/transcribe/.env с GEMINI_API_KEY=...") print(" 2. Файл ~/.claude/skills/video-transcribe/.env с GEMINI_API_KEY=...") print(" 3. Файл .env в текущей директории") print(" 4. Переменная окружения: set GEMINI_API_KEY=ваш_ключ") sys.exit(1) size_mb = path.stat().st_size / (1024 * 1024) media_type = "видео" if is_video(path) else "аудио" print(f"Файл: {path.name} ({size_mb:.1f} MB, {media_type})") if len(models) > 1: print(f"Модель: {models[0]} (fallback при перегрузке: {', '.join(models[1:])})") else: print(f"Модель: {models[0]} (без fallback)") output_dir.mkdir(parents=True, exist_ok=True) invoker = GeminiClient(api_key, models) # Разбивка: analyze-ui режем на короткие чанки (один проход/чанк держит таймлайн), # остальные режимы - только очень длинные файлы. if mode == "analyze-ui" and is_video(path): parts, offsets = split_media(path, max_duration=UI_CHUNK_SEC) else: parts, offsets = split_media(path) if mode == "analyze-ui": _process_analyze_ui(invoker, path, parts, offsets, output_dir) else: _process_generic(invoker, path, parts, offsets, output_dir, with_summary, output_format) # Очистка временных файлов for part_path in parts: if part_path != path: part_path.unlink(missing_ok=True) try: part_path.parent.rmdir() except OSError: pass print(f"\n{'=' * 60}") print(f"Готово! Результаты в: {output_dir}") print(f"{'=' * 60}") def _process_generic(invoker, path, parts, offsets, output_dir, with_summary, output_format): """Generic-режим: транскрипция (+ опц. саммари из полной транскрипции). Саммари (при --with-summary) строится из полного текста транскрипции через build_summary - полнее по задачам/решениям, чем разбор видео в один проход. """ transcript_chunks = [] if len(parts) == 1: media_file = None try: print("Загрузка файла в Gemini...") media_file = upload_file(invoker.client, path) print(f"Загружено: {media_file.name}") media_file = wait_for_processing(invoker.client, media_file) print("\n Генерация транскрипции...") transcript_chunks.append(transcribe_generic(invoker, media_file)) finally: if media_file is not None: _cleanup_file(invoker.client, media_file) else: for i, (part_path, offset) in enumerate(zip(parts, offsets), 1): print(f"\n{'='*40} Часть {i}/{len(parts)} {'='*40}") media_file = None try: print("Загрузка части в Gemini...") media_file = upload_file(invoker.client, part_path) print(f"Загружено: {media_file.name}") media_file = wait_for_processing(invoker.client, media_file) print(" Генерация транскрипции...") t_text = transcribe_generic(invoker, media_file, offset) transcript_chunks.append(f"## Часть {i} (с {offset//60}:{offset%60:02d})\n\n{t_text}") except (Exception, SystemExit) as e: # Сбой одной части не должен терять транскрипцию остальных (симметрично analyze-ui). print(f" [!] Часть {i} не обработана ({e}), перехожу к следующей", file=sys.stderr) transcript_chunks.append( f"## Часть {i} (с {offset//60}:{offset%60:02d})\n\n" f"> **[!] Часть {i}: не обработана.** Причина: {e}\n" ) finally: if media_file is not None: _cleanup_file(invoker.client, media_file) transcript_text = "\n\n---\n\n".join(transcript_chunks) summary_text = None if with_summary: print("\n Генерация саммари из полной транскрипции...") summary_text = build_summary(invoker, transcript_text) # Сохранение ext = ".txt" if output_format == "txt" else ".md" transcript_path = output_dir / f"{path.stem} - транскрипция{ext}" transcript_path.write_text(transcript_text, encoding="utf-8") print(f"\nСохранено: {transcript_path.name}") if summary_text: summary_path = output_dir / f"{path.stem} - саммари{ext}" summary_path.write_text(summary_text, encoding="utf-8") print(f"Сохранено: {summary_path.name}") def _process_analyze_ui(invoker, path, parts, offsets, output_dir): """Analyze-UI: видео нарезано на чанки (~UI_CHUNK_SEC сек), каждый чанк - ОДИН мультимодальный проход (транскрипт+детальный+скриншоты), чанки идут ПАРАЛЛЕЛЬНО. Один проход по КОРОТКОМУ чанку держит таймлайн (на полном видео один проход коллапсирует таймкоды); чанки независимы, поэтому параллель не влияет на содержание, результаты сшиваются строго по порядку. Сбой одного чанка оставляет маркер, но не теряет остальные. Саммари строится в конце из ПОЛНОЙ транскрипции через build_summary (полнее по задачам/решениям и без фрагментации). """ multipart = len(parts) > 1 summary_path = output_dir / f"{path.stem} - саммари.md" detailed_path = output_dir / f"{path.stem} - детальный.md" transcript_path = output_dir / f"{path.stem} - транскрипция.md" # Чистый старт: перезапуск перезаписывает результат прошлого прогона for p in (summary_path, detailed_path, transcript_path): p.write_text("", encoding="utf-8") def _process_chunk(i, part_path, offset): """Обработать один чанк: загрузка -> один проход -> (детальный, транскрипт). Вернуть (i, d, t, ok). Полностью НЕЗАВИСИМ от других чанков - потому параллелится без влияния на содержание. """ media_file = None try: media_file = upload_file(invoker.client, part_path) media_file = wait_for_processing(invoker.client, media_file) d_text, t_text = analyze_ui_single( invoker, media_file, path, output_dir, part_label=f"{i}/{len(parts)}" if multipart else "", time_offset=offset, ) print(f" [готов] фрагмент {i}/{len(parts)}", flush=True) return i, d_text, t_text, True except (Exception, SystemExit) as e: # Сбой одного фрагмента не роняет остальные. SystemExit ловим намеренно: # wait_for_processing зовет sys.exit(1) при FAILED-обработке файла Gemini. print(f" [!] Фрагмент {i} не обработан ({e})", file=sys.stderr, flush=True) marker = (f"\n> **[!] Фрагмент {i} (с {offset//60}:{offset%60:02d}): не обработан.** " f"Причина: {e}\n") return i, marker, marker, False finally: if media_file is not None: _cleanup_file(invoker.client, media_file) # Параллельная обработка чанков (загрузка+генерация одновременно; Gemini держит # конкурентность, 429/503 гасит перебор моделей invoker). Каждый чанк независим, # поэтому параллель НЕ меняет содержание - результаты сшиваются строго по порядку. workers = min(len(parts), UI_MAX_PARALLEL) if multipart: print(f"\nОбработка {len(parts)} фрагментов параллельно (до {workers} одновременно)...") else: print("Загрузка видео в Gemini...") results = {} with ThreadPoolExecutor(max_workers=workers) as ex: futs = [ex.submit(_process_chunk, i, pp, off) for i, (pp, off) in enumerate(zip(parts, offsets), 1)] for fut in as_completed(futs): i, d_text, t_text, ok = fut.result() results[i] = (d_text, t_text) # Сшивка строго по порядку фрагментов (таймкоды уже глобальные -> лог сплошным потоком) transcript_chunks = [] for i in range(1, len(parts) + 1): d_text, t_text = results[i] sep = "\n\n" if i > 1 else "" _append(detailed_path, f"{sep}{d_text}") _append(transcript_path, f"{sep}{t_text}") transcript_chunks.append(t_text) # Единое саммари из полной транскрипции (полнота задач + без фрагментации по частям) print("\n Генерация саммари из полной транскрипции...") full_transcript = "\n\n".join(transcript_chunks) summary_text = build_summary(invoker, full_transcript) summary_path.write_text(summary_text, encoding="utf-8") print(f"\nСохранено: {summary_path.name}") print(f"Сохранено: {detailed_path.name}") print(f"Сохранено: {transcript_path.name}") def _cleanup_file(client, media_file): """Удаление загруженного файла из Gemini.""" try: client.files.delete(name=media_file.name) except Exception as e: print(f" Предупреждение: не удалось удалить файл из Gemini ({media_file.name}): {e}") # === CLI === def build_model_chain(cli_model=None, cli_fallback=None, no_fallback=False): """Сборка цепочки моделей. Приоритет: CLI > env > дефолт. Стартовая первой, дедупликация.""" start = cli_model or os.environ.get("GEMINI_MODEL") or DEFAULT_MODEL if no_fallback: return [start] chain_src = cli_fallback or os.environ.get("GEMINI_FALLBACK_MODELS") if chain_src: models = [m.strip() for m in chain_src.split(",") if m.strip()] else: models = list(DEFAULT_FALLBACK_CHAIN) # стартовая первой + остальные, дедупликация с сохранением порядка seen = set() result = [] for m in [start] + models: if m and m not in seen: seen.add(m) result.append(m) return result def main(): # Вывод содержит кириллицу. Без явного переключения печать падает с UnicodeEncodeError # везде, где консоль не в UTF-8: сборочный агент, чужая локаль. sys.stdout.reconfigure(encoding='utf-8') sys.stderr.reconfigure(encoding='utf-8') parser = argparse.ArgumentParser( description="Транскрибация аудио и видео через Gemini API" ) parser.add_argument("file", help="Путь к аудио/видеофайлу") parser.add_argument( "--output-dir", "-o", help="Каталог для результатов (по умолчанию: рядом с файлом в Транскрипция/<имя>/)", ) parser.add_argument( "--analyze-ui", action="store_true", help="Режим анализа интерфейсов (только видео): саммари + детальный лог + скриншоты + транскрипция", ) parser.add_argument( "--with-summary", action="store_true", help="Добавить саммари (для generic-режима)", ) parser.add_argument( "--format", choices=["md", "txt"], default="md", help="Формат вывода (по умолчанию: md)", ) parser.add_argument( "--model", help=f"Стартовая модель Gemini (по умолчанию: {DEFAULT_MODEL} или env GEMINI_MODEL)", ) parser.add_argument( "--fallback-models", help="Цепочка fallback через запятую (переопределяет дефолтную; иначе env GEMINI_FALLBACK_MODELS)", ) parser.add_argument( "--no-fallback", action="store_true", help="Отключить перебор моделей: использовать только стартовую (контроль стоимости/отладка)", ) args = parser.parse_args() path = Path(args.file) if not path.exists(): print(f"Файл не найден: {args.file}") sys.exit(1) if path.suffix.lower() not in ALL_EXTENSIONS: print(f"Неподдерживаемый формат: {path.suffix}") print(f"Видео: {', '.join(sorted(VIDEO_EXTENSIONS))}") print(f"Аудио: {', '.join(sorted(AUDIO_EXTENSIONS))}") sys.exit(1) # Определение режима mode = "generic" if args.analyze_ui: if is_audio(path): print("Предупреждение: --analyze-ui доступен только для видео. Переключаюсь на generic + саммари.", file=sys.stderr) args.with_summary = True else: mode = "analyze-ui" # Определение output_dir if args.output_dir: output_dir = Path(args.output_dir) else: output_dir = path.parent / "Транскрипция" / path.stem models = build_model_chain(args.model, args.fallback_models, args.no_fallback) process_file(path, output_dir, mode, args.with_summary, args.format, models) if __name__ == "__main__": main() -
transcribe_local.py 37.2 KB
""" Локальная транскрипция аудио/видео через faster-whisper (CUDA) + опц. диаризация (pyannote). Архитектура: orchestrator + 2 subprocess (изоляция CUDA-DLL ctranslate2 vs torch). При --diarize транскрипция и диаризация запускаются параллельно. Использование: python transcribe_local.py <input_path> [--output-dir DIR] [--model MODEL] [--language ru] [--diarize] [--num-speakers N] [--min-speakers N] [--max-speakers N] Зависимости: отдельный venv (env WHISPER_PYTHON): faster-whisper, ctranslate2-CUDA, ffmpeg. Для --diarize два движка: pyannote (default без --num-speakers): torch (CUDA), pyannote.audio>=4, HF_TOKEN в env; чекпойнт pyannote/speaker-diarization-community-1, автодетект числа спикеров. sherpa-onnx (default с --num-speakers): venv-sherpa, точное N; его пороговый автодетект пересегментирует (16.07.26: 242 кластера на ~7 человек) - не использовать. Файлы вывода: <base> - транскрипция.md Markdown c таймкодами по сегментам (без спикеров) <base> - транскрипция.txt Plain text <base> - со спикерами.md MD c [Спикер, MM:SS] (только при --diarize) """ from __future__ import annotations import argparse import json import os import subprocess import sys import tempfile import time import traceback from pathlib import Path # Вывод содержит кириллицу. Без явного переключения печать падает с UnicodeEncodeError # везде, где консоль не в UTF-8: сборочный агент, чужая локаль. sys.stdout.reconfigure(encoding='utf-8') sys.stderr.reconfigure(encoding='utf-8') def _setup_cuda_dll_path() -> None: """Добавить bin-директории nvidia.* пакетов в PATH и DLL search (Windows, CUDA 12).""" for name in ("nvidia.cublas", "nvidia.cudnn", "nvidia.cuda_runtime", "nvidia.cuda_nvrtc"): try: mod = __import__(name, fromlist=[""]) bin_dir = os.path.join(mod.__path__[0], "bin") if os.path.isdir(bin_dir): if hasattr(os, "add_dll_directory"): os.add_dll_directory(bin_dir) os.environ["PATH"] = bin_dir + os.pathsep + os.environ.get("PATH", "") except ImportError: pass def _load_dotenv() -> None: """Подгрузить переменные из ~/.claude/skills/transcribe/.env (HF_TOKEN, GEMINI_API_KEY и т.п.).""" env_path = Path(__file__).resolve().parent.parent / ".env" if not env_path.exists(): return for line in env_path.read_text(encoding="utf-8").splitlines(): line = line.strip() if not line or line.startswith("#") or "=" not in line: continue key, _, value = line.partition("=") key = key.strip() value = value.strip().strip('"').strip("'") if key and key not in os.environ: os.environ[key] = value def format_ts(seconds: float) -> str: s = int(seconds) return f"{s // 60:02d}:{s % 60:02d}" def _glossary(args): """Глоссарий терминов для этого прогона (пустой, если выключен или не найден). Импорт отложенный и защищенный: скрипт запускается в venv распознавателя, и падать из-за вспомогательного модуля он не должен - без глоссария разбор просто идет как раньше. """ if getattr(args, "no_glossary", False): return None try: import glossary as gl_mod except ImportError as e: print(f"[T] глоссарий не загружен ({e}) - продолжаю без терминов", file=sys.stderr) return None gl = gl_mod.load(getattr(args, "glossary", None)) for w in gl.warnings: print(f"[T] глоссарий: {w}", file=sys.stderr) return gl or None # ===================================================================== # WORKER: транскрипция (запускается как subprocess) # ===================================================================== def worker_transcribe(args) -> int: _setup_cuda_dll_path() from faster_whisper import WhisperModel # noqa print(f"[T] Модель: {args.model} ({args.device}/{args.compute_type})", flush=True) t0 = time.time() model = WhisperModel(args.model, device=args.device, compute_type=args.compute_type) print(f"[T] Модель загружена за {time.time() - t0:.1f}с", flush=True) # Глоссарий уходит в hotwords: faster-whisper подмешивает их в промпт КАЖДОГО окна, поэтому # термины подсказываются всю дорогу (initial_prompt влияет в основном на первое окно и дальше # вытесняется предыдущим текстом). Сам список все равно режется сервером по лимиту промпта. gl = _glossary(args) hotwords = gl.hotwords() if gl else None if hotwords: print(f"[T] Термины-подсказки: {hotwords[:120]}{'...' if len(hotwords) > 120 else ''}", flush=True) print(f"[T] Транскрипция (word_timestamps={args.need_words})...", flush=True) t0 = time.time() segments_iter, info = model.transcribe( args.input, language=args.language, beam_size=5, vad_filter=True, vad_parameters={"min_silence_duration_ms": 500}, condition_on_previous_text=True, word_timestamps=args.need_words, hotwords=hotwords or None, ) print(f"[T] Язык: {info.language} (p={info.language_probability:.2f}) длительность {info.duration:.1f}с", flush=True) segments: list[dict] = [] last_pct = -1 for seg in segments_iter: words = [] if args.need_words and seg.words: for w in seg.words: words.append({"start": float(w.start), "end": float(w.end), "text": w.word}) segments.append({ "start": float(seg.start), "end": float(seg.end), "text": seg.text.strip(), "words": words, }) pct = int(100 * seg.end / max(info.duration, 1)) if pct >= last_pct + 5: last_pct = pct print(f"[T] [{pct:3d}%] {format_ts(seg.start)}: {seg.text.strip()[:80]}", flush=True) info_dict = { "language": info.language, "language_probability": float(info.language_probability), "duration": float(info.duration), } out = {"segments": segments, "info": info_dict} Path(args.out_json).write_text(json.dumps(out, ensure_ascii=False), encoding="utf-8") print(f"[T] Готово за {time.time() - t0:.1f}с ({len(segments)} сегментов) → {args.out_json}", flush=True) # Обходим segfault в нативном cleanup ctranslate2 на Windows sys.stdout.flush() sys.stderr.flush() os._exit(0) # ===================================================================== # WORKER: диаризация (запускается как subprocess) # ===================================================================== def worker_diarize(args) -> int: _setup_cuda_dll_path() _load_dotenv() # Телеметрия pyannote 4.x падает на входах без определимой длительности # (webm: NoneType < int в track_pipeline_apply) - выключаем до импорта os.environ.setdefault("PYANNOTE_METRICS_ENABLED", "false") import torch from pyannote.audio import Pipeline if args.tf32: torch.backends.cuda.matmul.allow_tf32 = True torch.backends.cudnn.allow_tf32 = True try: from pyannote.audio.utils import reproducibility as _reprod if hasattr(_reprod, "fix_reproducibility"): _reprod.fix_reproducibility = lambda *a, **k: None for name in ("disable_tf32", "_disable_tf32"): if hasattr(_reprod, name): setattr(_reprod, name, lambda *a, **k: None) except Exception: pass print("[D] TF32 включен + monkeypatch reproducibility (быстрее, чуть менее воспроизводимо)", flush=True) hf_token = os.environ.get("HF_TOKEN") or os.environ.get("HUGGINGFACE_TOKEN") if not hf_token: print("[D] HF_TOKEN не задан в env", file=sys.stderr) return 2 print(f"[D] Загрузка pipeline {args.model}...", flush=True) t0 = time.time() pipeline = Pipeline.from_pretrained(args.model, token=hf_token) if torch.cuda.is_available(): pipeline.to(torch.device("cuda")) print("[D] pipeline на CUDA", flush=True) else: print("[D] WARN: CUDA недоступна, диаризация на CPU очень медленная", flush=True) print(f"[D] Pipeline загружен за {time.time() - t0:.1f}с", flush=True) print("[D] Диаризация...", flush=True) t0 = time.time() kwargs = {} if args.num_speakers is not None: kwargs["num_speakers"] = args.num_speakers if args.min_speakers is not None: kwargs["min_speakers"] = args.min_speakers if args.max_speakers is not None: kwargs["max_speakers"] = args.max_speakers with tempfile.TemporaryDirectory() as tmp: # ffmpeg -> 16kHz mono WAV: надежный декод любого контейнера (webm/opus и т.п.) wav_path = Path(tmp) / "audio_16k.wav" subprocess.run( ["ffmpeg", "-y", "-i", str(args.input), "-vn", "-ac", "1", "-ar", "16000", "-acodec", "pcm_s16le", str(wav_path)], check=True, capture_output=True) result = pipeline(str(wav_path), **kwargs) annotation = getattr(result, "exclusive_speaker_diarization", None) \ or getattr(result, "speaker_diarization", None) \ or result turns: list[dict] = [] for turn, _, speaker in annotation.itertracks(yield_label=True): turns.append({"start": float(turn.start), "end": float(turn.end), "speaker": str(speaker)}) turns.sort(key=lambda t: t["start"]) speakers_set = sorted({t["speaker"] for t in turns}) Path(args.out_json).write_text(json.dumps(turns, ensure_ascii=False), encoding="utf-8") print(f"[D] Готово за {time.time() - t0:.1f}с ({len(turns)} turns, {len(speakers_set)} спикеров) → {args.out_json}", flush=True) sys.stdout.flush() sys.stderr.flush() os._exit(0) # ===================================================================== # MERGE / OUTPUT # ===================================================================== def assign_speaker(midpoint: float, turns: list[dict]) -> str: for t in turns: if t["start"] <= midpoint <= t["end"]: return t["speaker"] if not turns: return "UNKNOWN" nearest = min(turns, key=lambda t: min(abs(t["start"] - midpoint), abs(t["end"] - midpoint))) return nearest["speaker"] def smooth_word_speakers(words: list[dict], min_run: float = 1.2) -> None: """Сгладить короткие пробивки спикера в словах: если короткий run X между двумя длинными run Y, переаттрибутировать слова run X на спикера Y. Изменяет words на месте. """ if not words: return runs: list[tuple[int, int, str, float]] = [] i = 0 n = len(words) while i < n: j = i spk = words[i]["speaker"] while j + 1 < n and words[j + 1]["speaker"] == spk: j += 1 duration = words[j]["end"] - words[i]["start"] runs.append((i, j, spk, duration)) i = j + 1 changed = True while changed: changed = False for k in range(1, len(runs) - 1): ri, rj, rspk, rdur = runs[k] li, lj, lspk, ldur = runs[k - 1] ni, nj, nspk, ndur = runs[k + 1] if rdur < min_run and lspk == nspk and lspk != rspk: for x in range(ri, rj + 1): words[x]["speaker"] = lspk runs[k - 1] = (li, rj, lspk, words[rj]["end"] - words[li]["start"]) runs.pop(k) if k < len(runs): nri, nrj, nrspk, _ = runs[k] if nrspk == lspk: runs[k - 1] = (li, nrj, lspk, words[nrj]["end"] - words[li]["start"]) runs.pop(k) changed = True break def merge_with_speakers(segments: list[dict], turns: list[dict]) -> list[dict]: all_words: list[dict] = [] word_segments_no_words: list[dict] = [] for seg in segments: words = seg.get("words") or [] if not words: mid = (seg["start"] + seg["end"]) / 2 spk = assign_speaker(mid, turns) word_segments_no_words.append({ "start": seg["start"], "end": seg["end"], "speaker": spk, "text": seg["text"], }) continue for w in words: mid = (w["start"] + w["end"]) / 2 spk = assign_speaker(mid, turns) all_words.append({"start": w["start"], "end": w["end"], "text": w["text"], "speaker": spk}) smooth_word_speakers(all_words, min_run=1.2) utterances: list[dict] = [] current: dict | None = None for w in all_words: if current and current["speaker"] == w["speaker"]: current["end"] = w["end"] current["text"] += w["text"] else: if current: utterances.append(current) current = {"start": w["start"], "end": w["end"], "speaker": w["speaker"], "text": w["text"]} if current: utterances.append(current) utterances.extend(word_segments_no_words) utterances.sort(key=lambda u: u["start"]) merged: list[dict] = [] for u in utterances: u["text"] = u["text"].strip() if not u["text"]: continue if merged and merged[-1]["speaker"] == u["speaker"] and u["start"] - merged[-1]["end"] < 1.5: merged[-1]["end"] = u["end"] merged[-1]["text"] += " " + u["text"] else: merged.append(u) return merged def write_basic_md(path: Path, input_name: str, info: dict, segments: list[dict], model_name: str) -> None: lines = [ f"# Транскрипция: {input_name}", "", f"- Модель: `{model_name}`", f"- Язык: {info['language']} (p={info['language_probability']:.2f})", f"- Длительность: {format_ts(info['duration'])} ({info['duration']:.1f}с)", "", "---", "", ] for seg in segments: lines.append(f"**[{format_ts(seg['start'])}]** {seg['text']}") lines.append("") path.write_text("\n".join(lines), encoding="utf-8") def write_plain(path: Path, segments: list[dict]) -> None: path.write_text(" ".join(s["text"] for s in segments), encoding="utf-8") def write_speakers_md(path: Path, input_name: str, info: dict, utterances: list[dict], model_name: str, diarization_label: str) -> None: speakers_set = sorted({u["speaker"] for u in utterances}) lines = [ f"# Транскрипция со спикерами: {input_name}", "", f"- Модель: `{model_name}`", f"- Диаризация: `{diarization_label}`", f"- Длительность: {format_ts(info['duration'])} ({info['duration']:.1f}с)", f"- Найдено спикеров: {len(speakers_set)} ({', '.join(speakers_set)})", "", "---", "", ] for u in utterances: lines.append(f"**[{u['speaker']}, {format_ts(u['start'])}]** {u['text']}") lines.append("") path.write_text("\n".join(lines), encoding="utf-8") # ===================================================================== # ORCHESTRATOR # ===================================================================== SHERPA_VENV_PYTHON = Path.home() / ".claude" / "skills" / "transcribe" / "venv-sherpa" / "Scripts" / "python.exe" SHERPA_WORKER = Path(__file__).resolve().parent / "diarize_sherpa.py" # MOSS venv: env MOSS_PYTHON перекрывает default (venv-moss скилла, по аналогии с venv-sherpa). MOSS_VENV_PYTHON = os.environ.get("MOSS_PYTHON") or str( Path.home() / ".claude" / "skills" / "transcribe" / "venv-moss" / "Scripts" / "python.exe" ) MOSS_WORKER = Path(__file__).resolve().parent / "diarize_moss.py" def spawn_worker(mode: str, env_extra: dict[str, str], cli: list[str]) -> subprocess.Popen: cmd = [sys.executable, __file__, "--worker", mode] + cli env = os.environ.copy() env["PYTHONUNBUFFERED"] = "1" env["PYTHONIOENCODING"] = "utf-8" env.update(env_extra) return subprocess.Popen(cmd, env=env) def spawn_sherpa_diarize(env_extra: dict[str, str], cli: list[str]) -> subprocess.Popen: """Запустить sherpa-onnx диаризацию из отдельного venv-sherpa.""" if not SHERPA_VENV_PYTHON.exists(): raise RuntimeError( f"venv-sherpa не найден: {SHERPA_VENV_PYTHON}. " "Установите: python -m venv venv-sherpa && pip install onnxruntime-gpu sherpa-onnx soundfile" ) cmd = [str(SHERPA_VENV_PYTHON), str(SHERPA_WORKER)] + cli env = os.environ.copy() env["PYTHONUNBUFFERED"] = "1" env["PYTHONIOENCODING"] = "utf-8" env.update(env_extra) return subprocess.Popen(cmd, env=env) def spawn_moss(env_extra: dict[str, str], cli: list[str]) -> subprocess.Popen: """Запустить MOSS end-to-end (ASR+диаризация) из отдельного venv-moss.""" if not Path(MOSS_VENV_PYTHON).exists(): raise RuntimeError( f"venv-moss не найден: {MOSS_VENV_PYTHON}. Установите MOSS-Transcribe-Diarize:\n" " python -m venv venv-moss\n" " venv-moss\\Scripts\\python -m pip install torch --index-url https://download.pytorch.org/whl/cu128\n" " git clone https://github.com/OpenMOSS/MOSS-Transcribe-Diarize moss\n" " venv-moss\\Scripts\\python -m pip install -e ./moss\n" "Либо укажите готовый venv через env MOSS_PYTHON=путь_к_python.exe" ) cmd = [MOSS_VENV_PYTHON, str(MOSS_WORKER)] + cli env = os.environ.copy() env["PYTHONUNBUFFERED"] = "1" env["PYTHONIOENCODING"] = "utf-8" env.update(env_extra) return subprocess.Popen(cmd, env=env) def orchestrate(args) -> int: input_path = Path(args.input) if not input_path.exists(): print(f"Файл не найден: {input_path}", file=sys.stderr) return 1 if args.output_dir: output_dir = Path(args.output_dir) else: output_dir = input_path.parent / "Транскрипция" / input_path.stem output_dir.mkdir(parents=True, exist_ok=True) base = input_path.stem transcribe_json = output_dir / f"{base}.transcribe.json" turns_json = output_dir / f"{base}.turns.json" print(f"Файл: {input_path.name}", flush=True) print(f"Каталог: {output_dir}", flush=True) # Выбор движка: явный --diarize-engine уважается; иначе с известным N - sherpa-onnx # (быстрее), без N - pyannote community-1 (автодетект числа спикеров; пороговая # кластеризация sherpa пересегментирует: 2026-07-16 дала 242 кластера на ~7 человек). # "moss" - end-to-end (ASR+диаризация одной моделью), заменяет whisper-шаг целиком. engine = args.diarize_engine or ("sherpa-onnx" if args.num_speakers is not None else "pyannote") # MOSS полностью заменяет whisper+diarize - отдельный путь до запуска воркеров. # MOSS всегда end-to-end (текст+спикеры вместе), поэтому engine==moss подразумевает # --diarize: иначе --diarize-engine moss без --diarize удивил бы полным разбором со спикерами # (другие движки уважают --diarize; для MOSS он не имеет смысла - модель всегда диаризует). if engine == "moss": if not args.diarize: print("--diarize-engine moss: MOSS end-to-end, диаризация включена автоматически", flush=True) args.diarize = True return orchestrate_moss(args, input_path, output_dir, base) transcribe_cli = [ "--input", str(input_path), "--out-json", str(transcribe_json), "--model", args.model, "--language", args.language, "--device", args.device, "--compute-type", args.compute_type, ] if args.diarize: transcribe_cli.append("--need-words") if args.glossary: # воркер - отдельный процесс, глоссарий ему нужен свой transcribe_cli += ["--glossary", args.glossary] if args.no_glossary: transcribe_cli.append("--no-glossary") transcribe_proc = spawn_worker("transcribe", {}, transcribe_cli) diarize_proc = None diarization_label = "" if args.diarize: if engine == "sherpa-onnx": sherpa_cli = [ "--input", str(input_path), "--out-json", str(turns_json), "--provider", "cuda", "--threshold", str(args.threshold), "--emit-voiceprints", str(output_dir / f"{base}.voiceprints.json"), ] if args.num_speakers is not None: sherpa_cli += ["--num-speakers", str(args.num_speakers)] diarization_label = f"sherpa-onnx eres2net, num_speakers={args.num_speakers}" else: diarization_label = f"sherpa-onnx eres2net, threshold={args.threshold}" print(f"Диаризация: sherpa-onnx (pyannote-segmentation-3.0 + 3D-Speaker)", flush=True) diarize_proc = spawn_sherpa_diarize({}, sherpa_cli) else: diarize_cli = [ "--input", str(input_path), "--out-json", str(turns_json), "--model", args.pyannote_model, ] if args.num_speakers is not None: diarize_cli += ["--num-speakers", str(args.num_speakers)] if args.min_speakers is not None: diarize_cli += ["--min-speakers", str(args.min_speakers)] if args.max_speakers is not None: diarize_cli += ["--max-speakers", str(args.max_speakers)] if args.tf32: diarize_cli.append("--tf32") n_label = f"num_speakers={args.num_speakers}" if args.num_speakers is not None else "автодетект N" diarization_label = f"{args.pyannote_model}, {n_label}" print(f"Диаризация: {args.pyannote_model}", flush=True) diarize_proc = spawn_worker("diarize", {}, diarize_cli) rc_t = transcribe_proc.wait() if rc_t != 0: if diarize_proc is not None: diarize_proc.terminate() print(f"Транскрипция упала с кодом {rc_t}", file=sys.stderr) return rc_t payload = json.loads(transcribe_json.read_text(encoding="utf-8")) segments = payload["segments"] info = payload["info"] # Лечение ослышек по глоссарию. Подсказка (hotwords) профилактирует, но не гарантирует: # DAX все равно может выйти как "ДАКС". Правим ДО записи файлов, чтобы дальше по конвейеру # (спикеры, связный лог, саммари) шел уже верный текст. gl = _glossary(args) if gl: stats = gl.fix_segments(segments) if stats: print(" Термины: " + ", ".join(f"{k} x{v}" for k, v in sorted(stats.items())), flush=True) transcript_md = output_dir / f"{base} - транскрипция.md" plain_txt = output_dir / f"{base} - транскрипция.txt" write_basic_md(transcript_md, input_path.name, info, segments, args.model) write_plain(plain_txt, segments) print(f" MD: {transcript_md}", flush=True) print(f" Plain: {plain_txt}", flush=True) if diarize_proc is not None: rc_d = diarize_proc.wait() if rc_d != 0: print(f"Диаризация упала с кодом {rc_d} (транскрипция сохранена)", file=sys.stderr) return rc_d turns = json.loads(turns_json.read_text(encoding="utf-8")) utterances = merge_with_speakers(segments, turns) # Реплики со спикерами собираются заново ИЗ СЛОВ, а не из текста сегментов, поэтому правку # глоссария нужно повторить: иначе файл со спикерами (именно он идет дальше в разбор) # остался бы с ослышками, хотя обычная транскрипция уже вылечена. if gl: gl.fix_segments(utterances) speakers_md = output_dir / f"{base} - со спикерами.md" write_speakers_md(speakers_md, input_path.name, info, utterances, args.model, diarization_label) print(f" Spk: {speakers_md}", flush=True) if engine != "sherpa-onnx": # Отпечатки голоса для голосовой базы живут в eres2net-пространстве - # считаем их sherpa-воркером по готовым turns (сама диаризация не повторяется). # Старый файл удаляем ДО пересчета: при провале этапа протухшие отпечатки # (метки прошлого прогона) не должны достаться analyze_video_local по exists(). voiceprints_json = output_dir / f"{base}.voiceprints.json" try: if voiceprints_json.exists(): voiceprints_json.unlink() except OSError as e: print(f"Не удалился старый {voiceprints_json.name}: {e}", file=sys.stderr) prints_cli = [ "--input", str(input_path), "--from-turns", str(turns_json), "--emit-voiceprints", str(voiceprints_json), "--provider", "cuda", ] try: rc_p = spawn_sherpa_diarize({}, prints_cli).wait() if rc_p != 0: print(f"Отпечатки голоса не посчитаны (код {rc_p}), пайплайн продолжен", file=sys.stderr) except Exception as e: print(f"Отпечатки голоса не посчитаны: {e}", file=sys.stderr) if not args.keep_intermediate: for f in (transcribe_json, turns_json): try: if f.exists(): f.unlink() except OSError: pass return 0 def orchestrate_moss(args, input_path: Path, output_dir: Path, base: str) -> int: """Путь MOSS: end-to-end ASR+диаризация одной моделью (без whisper-шага). MOSS сам дает текст + спикер-сегменты + таймстампы, поэтому whisper и отдельный diarize-воркер не запускаются. Переиспользуются write_basic_md/write_speakers_md (тот же выходной формат, что у whisper+sherpa). Отпечатки голоса для голосовой базы считаются sherpa eres2net по turns из MOSS (диаризация не повторяется). """ moss_json = output_dir / f"{base}.moss.json" moss_cli = [ "--input", str(input_path), "--out-json", str(moss_json), "--provider", "cuda" if args.device == "cuda" else "cpu", "--language", args.language, ] print("ASR+диаризация: MOSS-Transcribe-Diarize (end-to-end, без whisper)", flush=True) try: moss_proc = spawn_moss({}, moss_cli) except RuntimeError as e: print(str(e), file=sys.stderr) return 2 rc_m = moss_proc.wait() if rc_m != 0: print(f"MOSS упал с кодом {rc_m}", file=sys.stderr) return rc_m payload = json.loads(moss_json.read_text(encoding="utf-8")) utterances = payload["utterances"] info = { "duration": payload["duration"], "language": payload.get("language", "ru"), "language_probability": 1.0, } model_label = payload.get("model", "MOSS-Transcribe-Diarize") diarization_label = f"MOSS end-to-end (RTF {payload.get('rtf', 0):.3f})" # Лечение ослышек по глоссарию - до формирования обоих файлов. У MOSS реплики уже готовы, # поэтому правки хватает одной (в отличие от пути whisper+диаризация, где текст со спикерами # пересобирается из слов). gl = _glossary(args) if gl: stats = gl.fix_segments(utterances) if stats: print(" Термины: " + ", ".join(f"{k} x{v}" for k, v in sorted(stats.items())), flush=True) # Транскрипция без спикеров (тот же формат, что из whisper) segments = [{"start": u["start"], "end": u["end"], "text": u["text"]} for u in utterances] transcript_md = output_dir / f"{base} - транскрипция.md" plain_txt = output_dir / f"{base} - транскрипция.txt" write_basic_md(transcript_md, input_path.name, info, segments, model_label) write_plain(plain_txt, segments) print(f" MD: {transcript_md}", flush=True) print(f" Plain: {plain_txt}", flush=True) # Со спикерами speakers_md = output_dir / f"{base} - со спикерами.md" write_speakers_md(speakers_md, input_path.name, info, utterances, model_label, diarization_label) print(f" Spk: {speakers_md}", flush=True) # Отпечатки голоса (eres2net-пространство) по turns из utterances через sherpa-воркер. voiceprints_json = output_dir / f"{base}.voiceprints.json" turns_json = output_dir / f"{base}.turns.json" turns = [{"start": u["start"], "end": u["end"], "speaker": u["speaker"]} for u in utterances] turns_json.write_text(json.dumps(turns, ensure_ascii=False), encoding="utf-8") try: if voiceprints_json.exists(): voiceprints_json.unlink() except OSError as e: print(f"Не удалился старый {voiceprints_json.name}: {e}", file=sys.stderr) prints_cli = [ "--input", str(input_path), "--from-turns", str(turns_json), "--emit-voiceprints", str(voiceprints_json), "--provider", "cuda", ] try: rc_p = spawn_sherpa_diarize({}, prints_cli).wait() if rc_p != 0: print(f"Отпечатки голоса не посчитаны (код {rc_p}), пайплайн продолжен", file=sys.stderr) except Exception as e: print(f"Отпечатки голоса не посчитаны: {e}", file=sys.stderr) if not args.keep_intermediate: for f in (moss_json, turns_json): try: if f.exists(): f.unlink() except OSError: pass return 0 # ===================================================================== # MAIN: разбор аргументов # ===================================================================== def main() -> int: if len(sys.argv) >= 3 and sys.argv[1] == "--worker": mode = sys.argv[2] worker_args = sys.argv[3:] ap = argparse.ArgumentParser() if mode == "transcribe": ap.add_argument("--input", required=True) ap.add_argument("--out-json", required=True) ap.add_argument("--model", default="mobiuslabsgmbh/faster-whisper-large-v3-turbo") ap.add_argument("--language", default="ru") ap.add_argument("--device", default="cuda") ap.add_argument("--compute-type", default="float16") ap.add_argument("--need-words", action="store_true") ap.add_argument("--glossary", default=None) ap.add_argument("--no-glossary", action="store_true") return worker_transcribe(ap.parse_args(worker_args)) elif mode == "diarize": ap.add_argument("--input", required=True) ap.add_argument("--out-json", required=True) ap.add_argument("--model", default="pyannote/speaker-diarization-community-1") ap.add_argument("--num-speakers", type=int, default=None) ap.add_argument("--min-speakers", type=int, default=None) ap.add_argument("--max-speakers", type=int, default=None) ap.add_argument("--tf32", action="store_true") return worker_diarize(ap.parse_args(worker_args)) else: print(f"Неизвестный worker mode: {mode}", file=sys.stderr) return 1 ap = argparse.ArgumentParser(description="Локальная транскрипция через faster-whisper + опц. диаризация (parallel)") ap.add_argument("input", help="Путь к аудио/видео файлу") ap.add_argument("--output-dir", default=None, help="Каталог вывода") ap.add_argument("--model", default="mobiuslabsgmbh/faster-whisper-large-v3-turbo") ap.add_argument("--language", default="ru") ap.add_argument("--device", default="cuda", choices=["cuda", "cpu"]) ap.add_argument("--compute-type", default="float16") ap.add_argument("--diarize", action="store_true", help="Включить диаризацию (параллельно с транскрипцией)") ap.add_argument("--diarize-engine", default=None, choices=["sherpa-onnx", "pyannote", "moss"], help="Движок. Default: с --num-speakers - sherpa-onnx (быстрее), без - pyannote " "community-1 (автодетект N; пороговый автодетект sherpa пересегментирует). " "moss - MOSS-Transcribe-Diarize end-to-end (ASR+диаризация одной моделью, " "лучше текст на терминах, но ~2x медленнее; требует venv-moss).") ap.add_argument("--pyannote-model", default="pyannote/speaker-diarization-community-1", help="Чекпойнт pyannote для движка pyannote") ap.add_argument("--num-speakers", type=int, default=None) ap.add_argument("--min-speakers", type=int, default=None, help="Только для pyannote") ap.add_argument("--max-speakers", type=int, default=None, help="Только для pyannote") ap.add_argument("--threshold", type=float, default=0.5, help="Порог кластеризации для sherpa-onnx (default 0.5)") ap.add_argument("--no-tf32", dest="tf32", action="store_false", help="Отключить TF32 для pyannote (по умолчанию вкл)") ap.set_defaults(tf32=True) ap.add_argument("--keep-intermediate", action="store_true", help="Не удалять промежуточные JSON") ap.add_argument("--glossary", default=None, help="Файл глоссария терминов (по умолчанию glossary.txt в корне скила, " "перекрывается env TRANSCRIBE_GLOSSARY). Правильные написания уходят " "подсказкой распознавателю, ослышки правятся в готовом тексте") ap.add_argument("--no-glossary", action="store_true", help="Не использовать глоссарий терминов") args = ap.parse_args() return orchestrate(args) if __name__ == "__main__": try: rc = main() except SystemExit: raise except BaseException: log_path = Path.home() / ".claude" / "skills" / "transcribe" / "transcribe_local.crash.log" log_path.parent.mkdir(parents=True, exist_ok=True) with log_path.open("a", encoding="utf-8") as f: f.write("=" * 80 + "\n") f.write(f"argv: {sys.argv}\n") f.write(traceback.format_exc()) f.write("\n") print(f"\nКраш записан в {log_path}", file=sys.stderr) traceback.print_exc() rc = 2 sys.exit(rc) -
verify.py 9.2 KB
""" Smoke-тест после установки скила transcribe. Что проверяет: 1. venv-whisper существует и в нем установлены faster-whisper + google-genai 2. venv-sherpa существует и в нем установлены sherpa_onnx + onnxruntime 3. Модели sherpa-onnx и 3D-Speaker лежат в models/ 4. ffmpeg + ffprobe доступны 5. .env существует и GEMINI_API_KEY заполнен (предупреждение если нет) 6. (опционально) тестовый прогон локальной транскрипции на коротком WAV Запуск: python scripts/verify.py # все проверки кроме прогона python scripts/verify.py --full # + тестовый прогон (создает 3-сек тон и транскрибирует) Выход: 0 если все проверки прошли (предупреждения не считаются), 1 если FAIL. """ from __future__ import annotations import argparse import os import shutil import subprocess import sys import tempfile import wave from pathlib import Path # Вывод содержит кириллицу. Без явного переключения печать падает с UnicodeEncodeError # везде, где консоль не в UTF-8: сборочный агент, чужая локаль. sys.stdout.reconfigure(encoding='utf-8') sys.stderr.reconfigure(encoding='utf-8') SKILL_ROOT = Path(__file__).resolve().parent.parent VENV_BIN = "Scripts" if os.name == "nt" else "bin" PY_EXE = "python.exe" if os.name == "nt" else "python" VENV_WHISPER_PY = SKILL_ROOT / "venv-whisper" / VENV_BIN / PY_EXE VENV_SHERPA_PY = SKILL_ROOT / "venv-sherpa" / VENV_BIN / PY_EXE MODELS_DIR = SKILL_ROOT / "models" SEG_MODEL = MODELS_DIR / "sherpa-onnx-pyannote-segmentation-3-0" / "model.onnx" EMB_MODEL = MODELS_DIR / "3dspeaker_speech_eres2net_base_200k_sv_zh-cn_16k-common.onnx" ENV_FILE = SKILL_ROOT / ".env" results: list[tuple[str, str, str]] = [] def check(name: str, level: str, detail: str = "") -> None: """level: OK | FAIL | WARN""" results.append((name, level, detail)) print(f" [{level:4}] {name}" + (f": {detail}" if detail else ""), flush=True) def check_venv(name: str, py_exe: Path, test_import: str) -> bool: if not py_exe.exists(): check(f"venv-{name}", "FAIL", f"не найден: {py_exe}") return False check(f"venv-{name}", "OK", str(py_exe)) try: result = subprocess.run( [str(py_exe), "-c", test_import], capture_output=True, text=True, timeout=60, ) if result.returncode == 0: check(f"venv-{name} imports", "OK", result.stdout.strip()) return True last_line = result.stderr.strip().splitlines()[-1] if result.stderr else "ошибка импорта" check(f"venv-{name} imports", "FAIL", last_line) return False except (subprocess.SubprocessError, OSError) as e: check(f"venv-{name} imports", "FAIL", str(e)) return False def check_env_file() -> None: if not ENV_FILE.exists(): check(".env", "WARN", "не найден (для Gemini-режима нужен GEMINI_API_KEY)") return content = ENV_FILE.read_text(encoding="utf-8", errors="replace") has_gemini = False for line in content.splitlines(): line = line.strip() if line.startswith("GEMINI_API_KEY=") and len(line) > len("GEMINI_API_KEY="): value = line[len("GEMINI_API_KEY="):].strip().strip('"').strip("'") if value and value != "<ключ": has_gemini = True break if has_gemini: check(".env GEMINI_API_KEY", "OK", "заполнен") else: check(".env GEMINI_API_KEY", "WARN", "пустой - Gemini-режим (видео) работать не будет") def create_silent_wav(path: Path, duration_sec: float = 3.0, sample_rate: int = 16000) -> None: n_samples = int(duration_sec * sample_rate) silence = b"\x00\x00" * n_samples with wave.open(str(path), "wb") as w: w.setnchannels(1) w.setsampwidth(2) w.setframerate(sample_rate) w.writeframes(silence) def run_transcribe_test() -> bool: print("\n Тестовый прогон локальной транскрипции на 3-сек silent WAV...", flush=True) with tempfile.TemporaryDirectory() as tmp: wav_path = Path(tmp) / "test.wav" create_silent_wav(wav_path) out_dir = Path(tmp) / "out" out_dir.mkdir() try: result = subprocess.run( [str(VENV_WHISPER_PY), str(SKILL_ROOT / "scripts" / "transcribe_local.py"), str(wav_path), "--output-dir", str(out_dir), "--model", "openai/whisper-tiny", "--compute-type", "int8"], capture_output=True, text=True, timeout=180, ) if result.returncode == 0: check("smoke-test транскрипция", "OK", "transcribe_local прошел") return True check("smoke-test транскрипция", "FAIL", f"rc={result.returncode}: {result.stderr[-200:]}") return False except subprocess.TimeoutExpired: check("smoke-test транскрипция", "FAIL", "таймаут (>3 мин)") return False except OSError as e: check("smoke-test транскрипция", "FAIL", str(e)) return False def main() -> int: ap = argparse.ArgumentParser(description="Smoke-тест скила transcribe") ap.add_argument("--full", action="store_true", help="Включить тестовый прогон локальной транскрипции (~1-2 мин)") args = ap.parse_args() print(f"Скил: {SKILL_ROOT}\n", flush=True) print("Проверка установки:", flush=True) ffmpeg_path = shutil.which("ffmpeg") ffprobe_path = shutil.which("ffprobe") if ffmpeg_path and ffprobe_path: check("ffmpeg", "OK", f"в PATH: {ffmpeg_path}") check("ffprobe", "OK", f"в PATH: {ffprobe_path}") elif VENV_WHISPER_PY.exists(): try: r = subprocess.run( [str(VENV_WHISPER_PY), "-c", "from static_ffmpeg import add_paths; add_paths(); " "import shutil; " "print((shutil.which('ffmpeg') or 'NOT_FOUND') + '|' + (shutil.which('ffprobe') or 'NOT_FOUND'))"], capture_output=True, text=True, timeout=30, ) if r.returncode == 0: ff, fp = r.stdout.strip().split("|", 1) check("ffmpeg", "OK" if ff != "NOT_FOUND" else "FAIL", f"через static-ffmpeg: {ff}" if ff != "NOT_FOUND" else "не найден") check("ffprobe", "OK" if fp != "NOT_FOUND" else "WARN", f"через static-ffmpeg: {fp}" if fp != "NOT_FOUND" else "не найден (нужен для разбивки видео >1ч)") else: check("ffmpeg", "FAIL", "не найден в PATH, static-ffmpeg недоступен в venv-whisper") except (subprocess.SubprocessError, OSError) as e: check("ffmpeg", "FAIL", f"не найден в PATH, static-ffmpeg check failed: {e}") else: check("ffmpeg", "FAIL", "не найден в PATH") if not ffprobe_path: check("ffprobe", "WARN", "не найден (нужен для разбивки видео >1ч)") check_venv("whisper", VENV_WHISPER_PY, "import faster_whisper; " "try:\n import google.genai as g\n gv = 'OK'\nexcept ImportError:\n gv = 'MISSING'\n" "print(f'faster-whisper {faster_whisper.__version__} | google-genai {gv}')") check_venv("sherpa", VENV_SHERPA_PY, "import sherpa_onnx, onnxruntime; print('sherpa_onnx', sherpa_onnx.__version__, '| onnxruntime', onnxruntime.__version__)") check("модель сегментации (pyannote-3.0)", "OK" if SEG_MODEL.exists() else "FAIL", f"{SEG_MODEL.stat().st_size / 1e6:.1f} MB" if SEG_MODEL.exists() else "не найдена") check("модель эмбеддингов (3D-Speaker)", "OK" if EMB_MODEL.exists() else "FAIL", f"{EMB_MODEL.stat().st_size / 1e6:.1f} MB" if EMB_MODEL.exists() else "не найдена") check_env_file() if args.full and VENV_WHISPER_PY.exists(): run_transcribe_test() print("", flush=True) failed = [name for name, lvl, _ in results if lvl == "FAIL"] warned = [name for name, lvl, _ in results if lvl == "WARN"] if failed: print(f"FAIL: {len(failed)} проверок не прошли: {', '.join(failed)}", flush=True) print("Запустите 'python scripts/setup.py' для (пере)установки.", flush=True) return 1 if warned: print(f"OK с предупреждениями: {len(warned)} ({', '.join(warned)})", flush=True) else: print(f"OK: все {len(results)} проверок прошли. Скил готов к работе.", flush=True) return 0 if __name__ == "__main__": sys.exit(main()) -
voiceprints.py 8.1 KB
"""voiceprints.py - голосовая база (enrollment) + идентификация кластеров по ГОЛОСУ. Единая база с провенансом: {имя: {prints: [вектор,...], projects: [...], meetings: [...]}}. Отпечаток кластера считает diarize_sherpa (--emit-voiceprints, eres2net-эмбеддинги). Здесь - хранение, матчинг (косинус) и enrollment (ручной + авто). Голоса - чувствительные данные: хранить ЛОКАЛЬНО, папку voiceprints/ не коммитить. Слой "по голосу" (абсолютная идентификация, узнает даже неназванных, помнит между встречами) - это БОЛЬШЕ, чем делает Gemini (тот вяжет имена только в контексте одной записи). CLI: python voiceprints.py list [--db PATH] python voiceprints.py enroll --prints voiceprints.json --map SPEAKER_00=Имя1,SPEAKER_01=Имя2 \ --project МойПроект --meeting "Встреча 2026-01-01" [--db PATH] python voiceprints.py match --prints voiceprints.json [--db PATH] [--threshold 0.5] """ import argparse import json from pathlib import Path import numpy as np import sys # Вывод содержит кириллицу. Без явного переключения печать падает с UnicodeEncodeError # везде, где консоль не в UTF-8: сборочный агент, чужая локаль. sys.stdout.reconfigure(encoding='utf-8') sys.stderr.reconfigure(encoding='utf-8') DEFAULT_DB = Path.home() / ".claude" / "skills" / "transcribe" / "voiceprints" / "db.json" MATCH_THRESHOLD = 0.7 # косинус. Откалибровано на реальных записях: различимые голоса дают >0.8 между # встречами, похожие (близкий тембр) ~0.5 (размыто). 0.7 = высокая точность: голос называет только # уверенно различимых, похожих/неоднозначных отдаем text-binding'у (слои дополняют друг друга). def load_db(path=None): path = Path(path or DEFAULT_DB) if path.exists(): return json.loads(path.read_text(encoding="utf-8")) return {} def save_db(db, path=None): path = Path(path or DEFAULT_DB) path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(db, ensure_ascii=False, indent=1), encoding="utf-8") def _cos(a, b): a, b = np.asarray(a, dtype=np.float32), np.asarray(b, dtype=np.float32) return float(a @ b / (np.linalg.norm(a) * np.linalg.norm(b) + 1e-8)) def is_plausible_name(name): """Похоже на реальное имя, а не на мусор/инициалы - гард для энролла и матчинга. Мусор: короче 3 символов ИЛИ одни заглавные буквы длиной <=3 (инициалы). 'Иванов' - да, 'КС' - нет.""" n = (name or "").strip() if len(n) < 3: return False letters = [c for c in n if c.isalpha()] if letters and len(letters) <= 3 and all(c.isupper() for c in letters): return False return True def identify(cluster_prints, db, threshold=MATCH_THRESHOLD, project=None): """Сопоставить отпечатки кластеров {SPEAKER_XX: vec} с базой -> {SPEAKER_XX: (имя, близость)}. Только уверенные (близость >= threshold). Жадно по убыванию близости, одно имя не вешаем на две метки и одну метку не на два имени. Мусорные имена (КС, инициалы) в матче НЕ участвуют - чтобы кривая запись не выиграла матч и не подменила корректное имя. project: если задан, матчим только против записей ТОГО ЖЕ проекта (записи без проекта считаем глобальными и матчим всегда); записи ЧУЖОГО проекта исключаем - защита от кросс-проектных совпадений.""" cand = [] # (score, spk, name) for spk, vec in cluster_prints.items(): for name, entry in db.items(): if not is_plausible_name(name): # мусорную запись (КС) не матчим continue if project and entry.get("projects") and project not in entry["projects"]: continue # запись другого проекта - не матчим (провенанс из --project) best = max((_cos(vec, p) for p in entry.get("prints", [])), default=0.0) if best >= threshold: cand.append((best, spk, name)) cand.sort(reverse=True) used_spk, used_name, result = set(), set(), {} for score, spk, name in cand: if spk in used_spk or name in used_name: continue result[spk] = (name, round(score, 3)) used_spk.add(spk) used_name.add(name) return result def enroll(db, name, vec, project=None, meeting=None, max_prints=6): """Добавить отпечаток голоса под именем (с провенансом). До max_prints отпечатков на имя (разные микрофоны/каналы). Возвращает db (мутирует).""" entry = db.setdefault(name, {"prints": [], "projects": [], "meetings": []}) entry["prints"].append([round(float(x), 6) for x in vec]) entry["prints"] = entry["prints"][-max_prints:] for key, val in (("projects", project), ("meetings", meeting)): if val and val not in entry[key]: entry[key].append(val) return db # ---------------- CLI ---------------- def _main(): ap = argparse.ArgumentParser(description="Голосовая база спикеров") sub = ap.add_subparsers(dest="cmd", required=True) ap.add_argument("--db", default=None) p_list = sub.add_parser("list", help="Показать базу") p_enr = sub.add_parser("enroll", help="Занести отпечатки кластеров под именами") p_enr.add_argument("--prints", required=True, help="voiceprints.json от diarize_sherpa") p_enr.add_argument("--map", required=True, help="SPEAKER_00=Имя,SPEAKER_01=Имя2") p_enr.add_argument("--project", default=None) p_enr.add_argument("--meeting", default=None) p_match = sub.add_parser("match", help="Сопоставить отпечатки с базой") p_match.add_argument("--prints", required=True) p_match.add_argument("--threshold", type=float, default=MATCH_THRESHOLD) args = ap.parse_args() db = load_db(args.db) if args.cmd == "list": if not db: print("База пуста.") for name, e in db.items(): print(f" {name}: {len(e.get('prints', []))} отпечатк(ов); проекты={e.get('projects')}; " f"встречи={e.get('meetings')}") return prints = json.loads(Path(args.prints).read_text(encoding="utf-8")) if args.cmd == "enroll": mp = dict(kv.split("=", 1) for kv in args.map.split(",")) for spk, name in mp.items(): if spk in prints: enroll(db, name.strip(), prints[spk], args.project, args.meeting) print(f" занесен {spk} -> {name.strip()}") else: print(f" [!] {spk} нет в отпечатках", flush=True) save_db(db, args.db) print("База сохранена.") elif args.cmd == "match": res = identify(prints, db, args.threshold) for spk, (name, score) in res.items(): print(f" {spk} -> {name} (близость {score})") unmatched = [s for s in prints if s not in res] if unmatched: print(f" не опознаны: {unmatched}") if __name__ == "__main__": _main() -
voiceprints_dedup.py 17.4 KB
"""voiceprints_dedup.py - разбор и слияние дублей в голосовой базе. Зачем. Авто-пополнение заносит имя так, как его назвала модель, поэтому один человек со временем растекается по нескольким записям: Дмитрий / Дима / Дим / Дмитрий Иванов. Вред конкретный - отпечатки одного голоса размазаны по записям (в каждой не больше max_prints), а правило "одно имя не вешаем на две метки" тут не срабатывает, потому что имена РАЗНЫЕ. Живой пример: на интервью 07.08.2026 один и тот же интервьюер опознался и как "Дмитрий" (0.908), и как "Дмитрий Иванов" (0.745) на двух метках - то есть в протоколе он вышел бы двумя участниками. ГЛАВНОЕ ОГРАНИЧЕНИЕ, замерено на этой самой базе 07.08.2026 (команда `verify`): отпечатки НЕ различают людей при сверке записей между собой. свои пары средний косинус 0.413 (минимум 0.117) чужие пары средний косинус 0.358 (МАКСИМУМ 0.972) 44.7% чужих пар выглядят не хуже медианы своих; нормировка по когорте (AS-norm) не помогает Даже внутри ОДНОЙ записи отпечатки с разных встреч расходятся: у самой полной записи (6 отпечатков, 9 встреч) минимум 0.150. Опознание на живой встрече работает не абсолютным порогом, а тем, что берется лучший кандидат, и держится на запасе в 0.01-0.10 - то есть на грани. Поэтому сливать по ГОЛОСУ здесь нельзя: это склеит разных людей. Слияние идет по ИМЕНИ (Дима, Дим -> Дмитрий; Стас -> Станислав), а близость голоса печатается лишь как справка. Решение в любом случае за человеком: "Алексей" и "Алексей Петров" на реальной встрече оказались РАЗНЫМИ людьми, а "Станислав" и "Стас" - одним, и по голосу это неразличимо. python voiceprints_dedup.py verify [--db PATH] # разделяющая способность базы python voiceprints_dedup.py report [--db PATH] [--plan plan.json] python voiceprints_dedup.py apply --plan plan.json [--db PATH] [--dry-run] report печатает разбор и, если задан --plan, пишет ЗАГОТОВКУ: в merges попадают только слияния по имени, все остальное - в review, руками. apply делает резервную копию базы рядом (db.json.bak-<N>) до записи: база накапливается месяцами и восстановлению не подлежит. """ import argparse import json import shutil import sys from itertools import combinations from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent)) import voiceprints as vp # noqa: E402 import speaker_validator as sv # noqa: E402 # Порог "это один и тот же голос". Взят равным боевому порогу опознания: если база при таком # значении назвала бы обе записи одним человеком, значит и хранить их порознь смысла нет. SAME_VOICE = vp.MATCH_THRESHOLD # Ниже этого голоса заведомо разные - пару даже не показываем, чтобы не топить отчет в шуме. CLEARLY_DIFFERENT = 0.45 def pair_similarity(entry_a, entry_b): """Близость двух записей базы: МАКСИМУМ по всем парам отпечатков. Максимум, а не среднее: отпечатки одного человека сняты с разных встреч, микрофонов и каналов, и часть из них закономерно далека друг от друга. Одно уверенное совпадение доказывает, что это один голос, а десяток слабых этого не опровергает. """ best = 0.0 for pa in entry_a.get("prints", []): for pb in entry_b.get("prints", []): best = max(best, vp._cos(pa, pb)) return best def _looks_like_placeholder(name): """Заглушка вместо имени: Коллега1, Участник2, Спикер3 - в базе им не место.""" low = name.lower().rstrip("0123456789 ") return low in {"коллега", "участник", "спикер", "докладчик", "гость", "модератор"} def analyze(db): """Сгруппировать записи по личному имени. Голос идет справкой, решения на нем не строятся. Группа - это записи с одинаковым личным именем (Дмитрий, Дима, Дим, Дмитрий Иванов). Внутри группы разделяем два случая: голая форма имени (Дима) почти наверняка тот же человек, что и полная (Дмитрий), а запись С ФАМИЛИЕЙ - отдельный человек, пока человек не скажет иначе. """ names = sorted(db) groups = {} for name in names: groups.setdefault(sv.first_name_key(name), []).append(name) dupes = [] for key, members in sorted(groups.items()): if len(members) < 2: continue bare = [n for n in members if len(n.split()) == 1] full = [n for n in members if len(n.split()) > 1] pairs = [{"a": a, "b": b, "score": round(pair_similarity(db[a], db[b]), 3)} for a, b in combinations(members, 2)] dupes.append({"key": key, "members": members, "bare": bare, "full": full, "pairs": sorted(pairs, key=lambda p: -p["score"])}) return { "groups": dupes, "placeholders": [n for n in names if _looks_like_placeholder(n)], "implausible": [n for n in names if not vp.is_plausible_name(n)], "no_prints": [n for n in names if not db[n].get("prints")], } def discriminative_power(db): """Насколько отпечатки вообще различают людей: своя пара против чужой. Метрика честная только при условии, что имена в базе расставлены верно. База пополнялась автоматически по текстовому слою, который до 07.08.2026 систематически ошибался, поэтому часть "своих" пар может на деле быть чужими - и тогда цифры занижены. Отличить одно от другого без ручной разметки нельзя, но вывод для практики один и тот же: сливать по голосу нельзя. """ import numpy as np vecs, owner = [], [] for name, entry in db.items(): for print_ in entry.get("prints", []): vecs.append(print_) owner.append(name) if len(vecs) < 4: return None matrix = np.asarray(vecs, dtype=np.float32) matrix /= np.linalg.norm(matrix, axis=1, keepdims=True) + 1e-8 sim = matrix @ matrix.T n = len(owner) same = np.array([[owner[i] == owner[j] for j in range(n)] for i in range(n)]) iu = np.triu_indices(n, 1) own, alien = sim[iu][same[iu]], sim[iu][~same[iu]] if not len(own) or not len(alien): return None median_own = float(np.median(own)) return {"prints": n, "own_mean": float(own.mean()), "own_min": float(own.min()), "alien_mean": float(alien.mean()), "alien_max": float(alien.max()), "alien_above_own_median": float((alien >= median_own).mean())} def _merge_target(names): """Какое имя оставить при слиянии: самое информативное - с фамилией, затем самое длинное. Выбор все равно можно переопределить в плане вручную.""" with_surname = [n for n in names if len(n.split()) > 1] pool = with_surname or list(names) return max(pool, key=lambda n: (len(n.split()), len(n))) def build_draft_plan(report): """Заготовка плана. В merges идет только СЛИЯНИЕ ФОРМ ОДНОГО ИМЕНИ: Дима и Дим в Дмитрия, Стас в Станислава. Записи с фамилией остаются отдельными и уезжают в review - разные люди с одинаковым личным именем встречаются постоянно, и по голосу их здесь не различить.""" drop = set(report["placeholders"] + report["no_prints"]) merges, review = [], [] for group in report["groups"]: # Записи из drop в слияния не тянем: иначе сливаем то, что через шаг удаляем. bare = [n for n in group["bare"] if n not in drop] full = [n for n in group["full"] if n not in drop] if len(bare) > 1: target = _merge_target(bare) merges.append({"into": target, "from": sorted(n for n in bare if n != target), "why": "разные формы одного имени"}) if full: review.append({"members": group["members"], "pairs": group["pairs"][:3], "why": "есть записи с фамилией - это может быть ДРУГОЙ человек с тем же " "именем; по голосу не проверить, решать по памяти о встречах"}) return {"merges": merges, "drop": sorted(drop), "review": review} def apply_plan(db, plan, max_prints=6): """Применить план к базе. Возвращает (новая база, список строк отчета).""" log = [] for merge in plan.get("merges", []): target, sources = merge.get("into"), merge.get("from", []) if target not in db: log.append(f"пропуск: целевой записи '{target}' в базе нет") continue entry = db[target] for src in sources: if src == target or src not in db: log.append(f"пропуск: записи '{src}' в базе нет") continue other = db.pop(src) entry["prints"].extend(other.get("prints", [])) for key in ("projects", "meetings"): for val in other.get(key, []): if val not in entry.setdefault(key, []): entry[key].append(val) log.append(f"слито '{src}' -> '{target}'") # Отпечатков держим не больше max_prints - как и при обычном enroll; оставляем ПОСЛЕДНИЕ, # они сняты на свежих встречах и ближе к тому, как человек звучит сейчас. if len(entry["prints"]) > max_prints: log.append(f"'{target}': отпечатков {len(entry['prints'])} -> оставляю {max_prints}") entry["prints"] = entry["prints"][-max_prints:] for name in plan.get("drop", []): if db.pop(name, None) is not None: log.append(f"удалено '{name}'") return db, log def _backup(path): path = Path(path) for i in range(1, 100): candidate = path.with_suffix(path.suffix + f".bak-{i}") if not candidate.exists(): shutil.copy2(path, candidate) return candidate raise RuntimeError("не нашлось свободного имени для резервной копии") def cmd_verify(args): db = vp.load_db(args.db) stats = discriminative_power(db) if not stats: print("Слишком мало отпечатков для оценки.") return print(f"Отпечатков: {stats['prints']}") print(f" свои пары: средний {stats['own_mean']:.3f}, минимум {stats['own_min']:.3f}") print(f" чужие пары: средний {stats['alien_mean']:.3f}, МАКСИМУМ {stats['alien_max']:.3f}") print(f" чужих пар не хуже медианы своих: {stats['alien_above_own_median']*100:.1f}%") if stats["alien_above_own_median"] > 0.2: print("\nВЫВОД: отпечатки НЕ различают людей при сверке записей между собой. Сливать по " "голосу нельзя - склеит разных. Опознание на встрече держится на выборе лучшего " "кандидата, а не на пороге, и потому ненадежно вне однородной серии записей.") def cmd_report(args): db = vp.load_db(args.db) if not db: print("База пуста.") return report = analyze(db) print(f"Записей в базе: {len(db)}, отпечатков: " f"{sum(len(e.get('prints', [])) for e in db.values())}\n") print("ГРУППЫ ПО ЛИЧНОМУ ИМЕНИ (близость голоса - справка, решение по ней НЕ принимается):") for group in report["groups"]: print(f"\n {group['key']}: {', '.join(group['members'])}") if len(group["bare"]) > 1: print(f" -> слить формы одного имени: {', '.join(group['bare'])}") for name in group["full"]: print(f" -> {name}: с фамилией, оставляю отдельно (может быть другой человек)") for pair in group["pairs"][:3]: print(f" голос {pair['score']:.3f} {pair['a']} <-> {pair['b']}") if not report["groups"]: print(" дублей по имени нет") junk = sorted(set(report["placeholders"] + report["implausible"] + report["no_prints"])) if junk: print(f"\nМУСОРНЫЕ ЗАПИСИ (заглушки, инициалы, пустые): {', '.join(junk)}") if args.plan: plan = build_draft_plan(report) Path(args.plan).write_text(json.dumps(plan, ensure_ascii=False, indent=1), encoding="utf-8") print(f"\nЗаготовка плана записана: {args.plan}") print(" merges - слияние разных форм одного имени, можно применять;") print(" review - записи с фамилией, решать по памяти о встречах;") print(" drop - заглушки и записи без отпечатков.") def cmd_apply(args): plan = json.loads(Path(args.plan).read_text(encoding="utf-8")) db = vp.load_db(args.db) before = len(db) db, log = apply_plan(db, plan) for line in log: print(" " + line) print(f"\nЗаписей было {before}, стало {len(db)}") if args.dry_run: print("Пробный прогон (--dry-run): база НЕ изменена.") return path = Path(args.db or vp.DEFAULT_DB) if path.exists(): print(f"Резервная копия: {_backup(path).name}") vp.save_db(db, args.db) print("База сохранена.") def main(): # Вывод содержит кириллицу. Без явного переключения печать падает с UnicodeEncodeError # везде, где консоль не в UTF-8: сборочный агент, чужая локаль. sys.stdout.reconfigure(encoding='utf-8') sys.stderr.reconfigure(encoding='utf-8') ap = argparse.ArgumentParser(description="Разбор и слияние дублей в голосовой базе") ap.add_argument("--db", default=None) sub = ap.add_subparsers(dest="cmd", required=True) p_ver = sub.add_parser("verify", help="Насколько отпечатки вообще различают людей") p_ver.set_defaults(func=cmd_verify) p_rep = sub.add_parser("report", help="Показать дубли и подготовить план") p_rep.add_argument("--plan", default=None, help="Куда записать заготовку плана (JSON)") p_rep.set_defaults(func=cmd_report) p_app = sub.add_parser("apply", help="Применить план слияния") p_app.add_argument("--plan", required=True) p_app.add_argument("--dry-run", action="store_true") p_app.set_defaults(func=cmd_apply) args = ap.parse_args() args.func(args) if __name__ == "__main__": main()
-
-
.gitignore 670 B · in bundle
-
glossary.txt 2.4 KB
# Глоссарий терминов транскрибации. # # Слева - правильное написание, справа через запятую - ослышки распознавателя. # Строка без "=" - термин только для подсказки распознавателю (hotwords), замен не делает. # Все после "#" - комментарий. # # Правила: # - Ослышки пишутся ТОЙ формой, какой они реально вышли из распознавателя (падеж, число). # Морфологии здесь нет: угадывать словоформы опаснее, чем пропустить одну. # Поэтому и слева форма пишется под стать ослышке ("гашения = базаты", а не "гашение"). # - Регистр не важен: "ДАКС" ловит и "дакс", и "Дакс". Дублировать формы по регистру не нужно. # - Ослышки короче 3 символов игнорируются - на них замена лезет внутрь чужих слов. # - Дополняй файл по итогам своих встреч: увидел ослышку в расшифровке - добавь строку. # Правильные написания одновременно уходят подсказкой в распознаватель, поэтому со временем # часть ослышек перестает появляться вовсе. # # Проверить файл и замены: python scripts/glossary.py "текст с ослышкой" # --- Наблюдавшиеся ослышки --- DAX = ДАКС, дэкс, ДАХ JSON = джейсон, G-Splone, джей сон PROD = Прот, ПРОД-контур гашения = базаты # --- Термины-подсказки (правок не делают, помогают распознавателю не промахнуться) --- 1С:Предприятие EDT БСП СКД API REST XML CSV SQL ERP CRM ETL UI UX CI/CD Git Python PowerShell бэкенд фронтенд деплой релиз хотфикс регламентное задание регистр сведений регистр накопления план видов характеристик внешняя обработка расширение конфигурации -
README.md 14.4 KB
# transcribe Транскрибация аудио и видео с двумя движками: - **Локальный** (default для аудио): `faster-whisper` (CUDA) + опц. диаризация `sherpa-onnx` GPU. Бесплатно, не уходит наружу. Видео тоже можно разобрать полностью локально - `--engine local` (разбор экрана локальной VLM через LM Studio + распознавание спикеров по голосу). - **Gemini API** (default для видео и `--analyze-ui`): `gemini-2.5-flash`, разбор экрана + скриншоты. ~$0.10/час. Спикеры в локальном видео определяются по ГОЛОСУ (накопительная голосовая база - узнает людей между встречами) и по репликам. Подробнее - в `SKILL.md`, раздел "Спикеры и голосовая база". Производительность на RTX 5070 Ti Laptop: ~7 мин на 30 мин аудио с диаризацией (RTF ~0.24). Если нужна только локальная транскрипция аудио (без видео и Gemini) - используйте более легкий скил `transcribe-audio-local` в этом же репо. ## Системные требования - **Python 3.10+** (рекомендуется 3.12) - **NVIDIA GPU** с CUDA 12 и cuDNN 9 (для локального GPU режима) - **ffmpeg** и **ffprobe** - либо в PATH, либо setup поставит `static-ffmpeg` (pip-пакет с обоими бинарниками) в venv-whisper автоматически. Системные права не нужны. - **Windows x64** или **Linux x64** - **~6 ГБ свободного места** (venv-whisper ~2 ГБ, venv-sherpa ~3 ГБ, модели ~91 МБ) - Для Gemini-режима: API-ключ с https://aistudio.google.com/apikey ## Установка ```bash # 1. Скопировать папку скила в ~/.claude/skills/transcribe/ # 2. Запустить установщик python ~/.claude/skills/transcribe/scripts/setup.py ``` Что произойдет: 1. Проверка Python и ffmpeg. 2. Создание `venv-whisper/` со всеми зависимостями (faster-whisper, ctranslate2-CUDA, google-genai, python-dotenv, nvidia-*). 3. Создание `venv-sherpa/` с sherpa-onnx GPU + onnxruntime-gpu. 4. Скачивание моделей в `models/` с GitHub releases k2-fsa. 5. Создание шаблона `.env`. Время установки: 10-20 минут (зависит от скорости интернета). ### Флаги setup.py | Флаг | Когда нужен | |---|---| | `--skip-whisper` | venv-whisper уже создан | | `--skip-sherpa` | Диаризация не нужна | | `--skip-models` | Модели уже скачаны | | `--skip-gemini` | Только локальный движок, без Gemini | | `--with-pyannote` | Доп. поставить pyannote.audio 4.x для fallback диаризации (требует HF_TOKEN) | | `--allow-cpu` | Разрешить установку на машине без NVIDIA GPU (CPU-режим, в 10+ раз медленнее) | ### Проверка установки ```bash python scripts/verify.py # быстрая проверка (5 сек) python scripts/verify.py --full # с реальным прогоном локальной транскрипции (~1-2 мин) ``` Что проверяется: venv-whisper (faster-whisper + google-genai) и venv-sherpa с импортами, модели, ffmpeg/ffprobe в PATH, заполнен ли `GEMINI_API_KEY` в `.env`. С `--full` дополнительно запускает локальную транскрипцию tiny-модели. ### Для AI-агентов Установка длинная (~20-30 минут с загрузкой моделей и Gemini-зависимостей). Перед запуском `setup.py` через subprocess/Bash увеличьте таймаут до 30 минут или используйте фоновый режим. См. раздел "Для агента" в `SKILL.md`. ### Заполнить .env После setup откройте `~/.claude/skills/transcribe/.env` и впишите ключ Gemini: ``` GEMINI_API_KEY=AIza... ``` Получить ключ: https://aistudio.google.com/apikey (бесплатная квота ~1500 запросов/день для Gemini 2.5 Flash). Если установлен `pyannote 4.x` (флаг `--with-pyannote`) и хотите им пользоваться - дополнительно: ``` HF_TOKEN=hf_... ``` Read-токен с https://huggingface.co/settings/tokens, нужно принять условия моделей: `pyannote/speaker-diarization-3.1`, `pyannote/segmentation-3.0`. Для default sherpa-onnx HF_TOKEN не нужен. ### Локальный разбор видео (`--engine local`): настройка LM Studio Полностью локальный разбор ВИДЕО (экран + речь + спикеры по голосу, без облака) использует локальный LLM-сервер [LM Studio](https://lmstudio.ai/). Базовый `setup.py` его НЕ ставит - настраивается отдельно. 1. Установите **LM Studio** (https://lmstudio.ai/) - Windows/Linux/Mac. 2. Скачайте в LM Studio 3 модели (вкладка Search): - `qwen3-vl-8b-instruct` - зрение по кадрам экрана (VLM); - `google/gemma-4-26b-a4b` - связный лог + саммари; - `qwen2.5-32b-instruct` - маппинг спикеров по репликам. Квантизацию берите под свою VRAM (на 12-16 ГБ - Q4). Модели грузятся по очереди (скрипт свопит одну за раз). 3. Запустите локальный сервер: LM Studio -> вкладка Developer (Local Server) -> Start Server. По умолчанию `http://localhost:1234`. 4. Контекст: для параллельной обработки кадров поставьте context length побольше (16384+) - скрипт сам выведет число параллельных слотов под unified KV cache. 5. Доп. Python-зависимости для локального видео (в тот python, которым запускаете `analyze_video_local.py`): ```bash pip install Pillow numpy ``` (`Pillow` - дедуп кадров, `numpy` - голосовые отпечатки.) 6. Если сервер не на `localhost:1234` - пропишите в `.env`: ``` LOCAL_150_BASE=http://ХОСТ:ПОРТ/v1 ``` При старте скрипт проверит `/v1/models` и внятно сообщит, какой модели не хватает. ## Использование Запуск из venv-whisper: ```bash # Аудио, локально (по умолчанию) ~/.claude/skills/transcribe/venv-whisper/Scripts/python.exe \ ~/.claude/skills/transcribe/scripts/transcribe_local.py \ "audio.mp3" # Аудио с диаризацией ~/.claude/skills/transcribe/venv-whisper/Scripts/python.exe \ ~/.claude/skills/transcribe/scripts/transcribe_local.py \ "audio.mp3" --diarize # Видео через Gemini + анализ интерфейса (с скриншотами) ~/.claude/skills/transcribe/venv-whisper/Scripts/python.exe \ ~/.claude/skills/transcribe/scripts/transcribe.py \ "video.mp4" --analyze-ui --with-summary # Видео ПОЛНОСТЬЮ ЛОКАЛЬНО (без облака): экран локальной VLM + спикеры по голосу python ~/.claude/skills/transcribe/scripts/analyze_video_local.py \ "video.mp4" --diarize --num-speakers 4 --project "МойПроект" ``` На Linux/Mac: `venv-whisper/bin/python` вместо `venv-whisper/Scripts/python.exe`. ### Выходные файлы Сохраняются в `<каталог-входа>/Транскрипция/<имя>/`: | Файл | Когда создается | |---|---| | `<имя> - транскрипция.md` | всегда (md с таймкодами) | | `<имя> - транскрипция.txt` | локальный движок (plain text) | | `<имя> - со спикерами.md` | `--diarize` (реплики с `[Имя/SPEAKER_XX, MM:SS]`) | | `<имя> - детальный.md` | `--analyze-ui` (Gemini) или `--engine local` - дословный лог экрана | | `<имя> - связный.md` | `--engine local` (видео) - связный нарратив экран+речь | | `<имя> - саммари.md` | Gemini `--with-summary`/`--analyze-ui` или `--engine local` | | `<имя>.voiceprints.json` | `--engine local` (видео) - отпечатки голоса спикеров | | `screenshots/` | `--analyze-ui` (Gemini) или `--engine local` (PNG-кадры) | ## Архитектура ``` скил/ ├── SKILL.md # описание для Claude Code ├── README.md # эта инструкция ├── .env # ключи API (создается setup) ├── glossary.txt # термины и ослышки распознавателя (правится руками) ├── scripts/ │ ├── transcribe_local.py # orchestrator локального аудио (faster-whisper + diarize) │ ├── transcribe.py # Gemini API клиент (видео + analyze-ui, chunked+parallel) │ ├── analyze_video_local.py # локальный разбор видео (экран + речь + спикеры по голосу) │ ├── local_backends.py # VLM/LLM на LM Studio + нарезка кадров ffmpeg │ ├── text_stage.py # общий текст-модуль: спикеры->имена, связный лог, саммари │ ├── glossary.py # подсказка терминов распознавателю + правка ослышек │ ├── speaker_validator.py # программная проверка имен спикеров (без моделей) │ ├── voiceprints.py # голосовая база (enrollment + матчинг по голосу) │ ├── voiceprints_dedup.py # разбор и слияние дублей голосовой базы │ ├── diarize_sherpa.py # worker диаризации sherpa-onnx (+ отпечатки голоса) │ ├── diarize_moss.py # worker MOSS end-to-end (ASR + диаризация одной моделью) │ ├── setup.py # установщик │ └── verify.py # проверка установки ├── venv-whisper/ # создается setup: faster-whisper + Gemini + CUDA ├── venv-sherpa/ # создается setup: sherpa-onnx + onnxruntime-gpu └── models/ # скачивается setup ├── sherpa-onnx-pyannote-segmentation-3-0/model.onnx └── 3dspeaker_speech_eres2net_base_200k_sv_zh-cn_16k-common.onnx ``` Два venv нужны из-за конфликта CUDA DLL: `ctranslate2` (faster-whisper) и `onnxruntime-gpu` (sherpa-onnx) грузят несовместимые версии cuDNN. Изоляция через subprocess. При `--diarize` транскрипция и диаризация запускаются параллельно. ## Стоимость - Локальный движок: бесплатно (только электричество). - Gemini 2.5 Flash: ~$0.10 за 1 час записи. Бесплатная квота AI Studio покрывает большинство личных задач. ## Troubleshooting ### `ffmpeg не найден в PATH` Windows: скачайте с https://www.gyan.dev/ffmpeg/builds/, распакуйте, добавьте `bin/` в PATH. Linux: `sudo apt install ffmpeg`. ### `CUDA out of memory` Большая модель `large-v3-turbo` требует ~3 ГБ VRAM. Используйте меньшую: ```bash ~/.claude/skills/transcribe/venv-whisper/Scripts/python.exe \ ~/.claude/skills/transcribe/scripts/transcribe_local.py \ audio.mp3 --model openai/whisper-small --compute-type int8_float16 ``` Или `--device cpu` (в 10+ раз медленнее). ### `sherpa_onnx не устанавливается с CUDA` Setup пробует `pip install sherpa-onnx -f https://k2-fsa.github.io/sherpa/onnx/cuda.html`. Если упало: 1. Скачайте wheel вручную с https://huggingface.co/csukuangfj2/sherpa-onnx-wheels (например `sherpa_onnx-1.13.0+cuda12.cudnn9-cp312-cp312-win_amd64.whl` для Python 3.12 Windows). 2. Установите: `~/.claude/skills/transcribe/venv-sherpa/Scripts/pip.exe install <wheel>`. CPU-вариант: `pip install sherpa-onnx` без `-f`. ### Gemini 503 / 429 / quota Бесплатная квота Gemini 2.5 Flash ~1500 запросов/день. На больших файлах (>1ч) может закончиться. Варианты: - Подождать сброса квоты (00:00 PT) - Для аудио - fallback на локальный движок (`transcribe_local.py`) - Заплатить за PAYG-тарифа в AI Studio ### Модели не скачиваются URL k2-fsa releases: - https://github.com/k2-fsa/sherpa-onnx/releases/download/speaker-segmentation-models/sherpa-onnx-pyannote-segmentation-3-0.tar.bz2 - https://github.com/k2-fsa/sherpa-onnx/releases/download/speaker-recongition-models/3dspeaker_speech_eres2net_base_200k_sv_zh-cn_16k-common.onnx Если недоступны - скачайте вручную в `models/`, повторите `setup.py --skip-models` (распаковка архива все равно произойдет). ### CUDA DLL не находятся 1. Установлен ли CUDA 12 Toolkit? 2. В `venv-whisper/Lib/site-packages/nvidia/` есть пакеты cublas/cudnn/cuda_runtime? 3. Перезапустите терминал после установки CUDA. ## Лицензии - faster-whisper - MIT - sherpa-onnx - Apache 2.0 - pyannote-segmentation-3.0 (ONNX) - MIT (k2-fsa) - 3D-Speaker eres2net - Apache 2.0 - Gemini API - условия Google -
SKILL.md 32.9 KB
--- name: transcribe description: "Транскрибирование видео и аудио файлов. Используй когда пользователь просит транскрибировать, расшифровать запись, сделать конспект встречи, извлечь речь из видео или аудио, преобразовать речь в текст. Для аудио (m4a/mp3/wav/ogg/flac/aac/wma) по умолчанию локальный faster-whisper + диаризация с автодетектом числа спикеров (pyannote community-1 GPU; с явным --num-speakers N - sherpa-onnx, быстрее). Для видео (mp4/mkv/webm/avi/mov) - Gemini API (разбор экрана + скриншоты) либо полностью локально (--engine local: whisper + локальная VLM). Разделение по спикерам с распознаванием имен по голосу между встречами (голосовая база) и по репликам." argument-hint: "<FilePath> [--output-dir DIR] [--engine local|gemini] [--analyze-ui] [--with-summary] [--diarize] [--num-speakers N] [--diarize-engine sherpa-onnx|pyannote|moss] [--project NAME] [--no-voiceprints] [--no-coherent]" allowed-tools: - Bash - Read - Glob --- # /transcribe - Транскрибация видео и аудио Два движка: - **Локальный (default для аудио)**: `faster-whisper` (CUDA) + опц. диаризация. Движок диаризации выбирается сам: без `--num-speakers` - `pyannote community-1` (GPU, корректный автодетект числа спикеров, RTF ~0.064); с явным `--num-speakers N` - `sherpa-onnx GPU` (pyannote-segmentation-3.0 + eres2net, RTF ~0.24, точное N). Опция `--diarize-engine moss` - MOSS-Transcribe-Diarize end-to-end: ASR+диаризация одной моделью (без whisper-шага), лучше текст на технических терминах, но ~2x медленнее (RTF ~0.34), требует `venv-moss` (env `MOSS_PYTHON`). Нет затрат, не уходит наружу. ВИДЕО тоже можно разобрать полностью локально - `--engine local` (разбор экрана локальной VLM + спикеры по голосу, см. ниже). - **Gemini (default для видео и `--analyze-ui`)**: облачный API, ~$0.10/час. Нужен интернет и квота. Стартовая модель `gemini-2.5-flash` (пин конкретной версии, дешевая); при перегрузке (503/429) переходит на `gemini-2.5-flash-lite`. Дорогие 3.5/pro сознательно исключены. ## Выбор движка по умолчанию | Тип файла | Движок | Причина | |---|---|---| | Аудио (m4a, mp3, wav, ogg, flac, aac, wma) | local | Быстро, бесплатно, диаризация | | Видео (mp4, mkv, webm, avi, mov) | gemini | Быстро, облако. Приватный вариант - `--engine local` (см. ниже) | | Видео + `--engine local` | **local** | Разбор экрана БЕЗ облака: whisper + локальная VLM (LM Studio) + спикеры по голосу | | Любой + `--analyze-ui` | gemini | Детальный разбор интерфейсов в облаке | | Любой + `--engine gemini` | gemini | Явный override на облако | | Аудио + `--engine local` | local | Явный override (аудио) | При 503/429 Gemini-движок сначала сам перебирает пул моделей (см. раздел "Авто-fallback по моделям Gemini"). Если весь пул недоступен и это аудио - можно вручную переключиться на local (`--engine local`). ## Режимы ### Локальный (аудио + faster-whisper + опц. pyannote) Выходные файлы: - `<имя> - транскрипция.md` - таймкоды + текст - `<имя> - транскрипция.txt` - plain text - `<имя> - со спикерами.md` - реплики с метками `[SPEAKER_XX, MM:SS]` (только при `--diarize`) ### Gemini generic Выходные файлы: - `<имя> - транскрипция.md` - речь с таймкодами + спикеры (если различимы) - `<имя> - саммари.md` - протокол встречи (с флагом `--with-summary`); строится из текста транскрипции в 2 прохода (экстрактор всех фактов -> протокол) для полноты задач/решений ### Gemini analyze-ui (только видео) Анализ видеозаписи с разбором экранного интерфейса + скриншоты. Детальный лог и транскрипция пишутся по частям сразу (инкрементально): сбой на поздней части длинного видео не теряет ранние. Саммари строится в конце из полной транскрипции (2 прохода) - это протокол задач/решений; разбор показанных интерфейсов - в детальном логе. Выходные файлы: - `<имя> - саммари.md` - `<имя> - детальный.md` - `<имя> - транскрипция.md` - `screenshots/` - PNG-кадры ### Локальный разбор видео (`--engine local`, только видео) Полностью локальный разбор экрана + речи БЕЗ облака (подробности в "Инструкция" ниже). Спикеры распознаются по голосу (голосовая база) и по репликам - см. "Спикеры и голосовая база". Выходные файлы: - `<имя> - транскрипция.md` / `.txt` - `<имя> - со спикерами.md` (при `--diarize`) - `<имя> - детальный.md` - дословный лог: описание экрана по кадрам + реплики за интервал - `<имя> - связный.md` - связный нарратив экран+речь (если не `--no-coherent`) - `<имя> - саммари.md` - протокол задач/решений (если не `--no-summary`) - `<имя>.voiceprints.json` - отпечатки голоса кластеров - `screenshots/` - ВСЕ scene-кадры ## Аргументы | Параметр | Обязательный | По умолчанию | Описание | |----------|:---:|---|---| | FilePath | да | - | Путь к аудио/видеофайлу | | --output-dir | нет | `<каталог>/Транскрипция/<имя>/` | Каталог результатов | | --engine | нет | auto (local для аудио, gemini для видео) | `local` или `gemini` | | --diarize | нет | выкл | Локальный движок: разделение по спикерам | | --num-speakers N | нет | автодетект (pyannote community-1) | Точное число спикеров; с ним движок переключается на sherpa-onnx (быстрее) | | --min-speakers N / --max-speakers N | нет | - | Границы автодетекта (движок pyannote) | | --diarize-engine | нет | авто: без N - pyannote, с N - sherpa-onnx; явно - moss (end-to-end) | sherpa-onnx без --num-speakers пересегментирует (242 кластера на ~7 чел). `moss` - ASR+диаризация одной моделью (текст точнее на терминах, RTF ~0.34, требует venv-moss) | | --analyze-ui | нет | выкл | Gemini: анализ интерфейсов (только видео) | | --with-summary | нет | выкл | Gemini: добавить саммари | | --format | нет | md | Формат: md или txt | | --model | нет | gemini-2.5-flash | Gemini: стартовая модель (или env GEMINI_MODEL) | | --fallback-models | нет | встроенный пул | Gemini: цепочка fallback через запятую (или env GEMINI_FALLBACK_MODELS) | | --no-fallback | нет | выкл | Gemini: только стартовая модель, без перебора | | --project NAME | нет | - | local видео: пометить встречу в голосовой базе (провенанс) | | --voiceprint-db PATH | нет | `voiceprints/db.json` скилла | local видео: путь к голосовой базе | | --no-voiceprints | нет | выкл | local видео: не использовать и не пополнять голосовую базу | | --no-coherent | нет | выкл | local видео: не строить связный лог (быстрее) | | --no-summary | нет | выкл | local видео: не строить саммари | | --speaker-model M | нет | qwen2.5-32b | local видео: LLM для маппинга спикеров -> имена | | --reuse-transcript | нет | выкл | local видео: не гонять whisper заново, если транскрипция уже есть | | --no-vlm | нет | выкл | local видео: не разбирать экран моделью зрения (кадры все равно нарезаются). Штатный способ получить речь+спикеров+саммари, когда зрение не нужно или сервер занят | | --reuse-frames | нет | выкл | local видео: взять готовые описания кадров из `<имя>.status.json` прошлого прогона и дораспознать только оставшиеся | | --glossary PATH | нет | `glossary.txt` в корне скила | Термины и ослышки: правильные написания подсказываются распознавателю, ослышки правятся в готовом тексте (DAX вместо "ДАКС") | | --no-glossary | нет | выкл | Не использовать глоссарий терминов | ## Поддерживаемые форматы - **Видео:** mp4, mkv, webm, avi, mov - **Аудио:** mp3, wav, ogg, m4a, flac, aac, wma ## Зависимости **Локальный движок:** - venv whisper (отдельный, изоляция CUDA-DLL): путь в env `WHISPER_PYTHON`; дефолт `~/.claude/skills/transcribe/venv-whisper` (faster-whisper, ctranslate2-CUDA, ffmpeg) - Для `--diarize` БЕЗ `--num-speakers` (default): движок `pyannote` с чекпойнтом `pyannote/speaker-diarization-community-1` - корректный автодетект числа спикеров (16.07.26: 8 при истине ~7, RTF 0.064). Нужны `torch` + `pyannote.audio>=4` в whisper-venv, `HF_TOKEN` в `.env` (read-токен с принятыми условиями `pyannote/speaker-diarization-community-1`; для старых чекпойнтов также `speaker-diarization-3.1`, `segmentation-3.0`). Отпечатки голоса при этом считает venv-sherpa по готовым turns (`diarize_sherpa.py --from-turns`) - то же eres2net-пространство, что и голосовая база. - Для `--diarize` С `--num-speakers N` (default): движок `sherpa-onnx` GPU CUDA, `~/.claude/skills/transcribe/venv-sherpa` с GPU-сборкой `sherpa_onnx 1.13.0+cuda12.cudnn9` от k2-fsa maintainer (HuggingFace `csukuangfj2/sherpa-onnx-wheels`). pyannote-segmentation-3.0 + 3D-Speaker eres2net эмбеддинги в ONNX. RTF ~0.24, никаких HF gated моделей. ВНИМАНИЕ: пороговый автодетект sherpa (без N) СЛОМАН - пересегментирует (эксперимент 04.07: пороги 0.5-0.8 давали 21-45 спикеров при истине 4; прогон 16.07: 242 кластера на ~7 человек). Слабое звено - эмбеддер eres2net-zh-cn (EER 5.3 в бенчмарке Шмырева против 1.1-1.6 у топов). - CUDA GPU обязателен для обоих движков **Gemini движок:** - Python-пакеты: `google-genai`, `python-dotenv` - Системные: `ffmpeg`, `ffprobe` в PATH - API-ключ в `~/.claude/skills/transcribe/.env`: `GEMINI_API_KEY=...` ## Установка и настройка (для агента) **НЕ проверяй сервер/venv/модели вручную ПЕРЕД запуском.** Скрипты сами читают `.env` и делают свой префлайт (печатают `[0/5] проверка сервера ...` + какие модели резолвятся). Просто ЗАПУСТИ нужный скрипт (см. "Инструкция") и читай ЕГО вывод. Что где (скрипт берет из `.env` сам, тебе знать не обязательно, руками НЕ проверяй): - **Локальный сервер VLM** - из `LOCAL_150_BASE` в `.env` (может быть удаленный хост, НЕ обязательно `localhost`). НЕ проверяй `localhost:1234`. - **Python для whisper** - из `WHISPER_PYTHON` в `.env` (может быть внешний venv, НЕ обязательно skill-овый `venv-whisper`). НЕ проверяй skill-venv. Ставить/чинить - ТОЛЬКО если скрипт при запуске сам сообщил, что сервер/модель/whisper недоступны: - установка: `python ~/.claude/skills/transcribe/scripts/setup.py` (ДОЛГО ~20-30 мин, фоном; флаги `--skip-gemini/--skip-sherpa/--with-pyannote/--allow-cpu`), проверка `verify.py --full`; - для `--engine local` (видео) нужен запущенный LM Studio (адрес из `LOCAL_150_BASE`) с моделями (VLM `qwen3-vl-30b-a3b-instruct` или `qwen3-vl-8b`, `gemma-4-26b`, `qwen2.5-32b`) + `Pillow`/`numpy` в python запуска. Пошагово - в README. `.env` (`~/.claude/skills/transcribe/.env`, gitignore, НЕ коммить): `GEMINI_API_KEY`, `HF_TOKEN` (если pyannote), `WHISPER_PYTHON`, `LOCAL_150_BASE`, `LOCAL_VLM_MODEL`. ## Инструкция **"Локально" / "без облака" = ТОЛЬКО локальный движок (`--engine local` / `analyze_video_local.py`).** Если пользователь просит локально - НЕ запускай Gemini и НЕ старый `analyze_video.py`. Локаль-скрипт сам конфигурится из `.env` (сервер + whisper) - просто запусти его. **Недоступность сервера или модели больше НЕ повод останавливаться.** Стадии деградируют поодиночке: нет модели зрения - будут речь, спикеры и саммари; нет сервера вовсе - будут речь и спикеры (whisper считает на своей машине, а голосовая база и проверка имен моделей не требуют). Запусти скрипт, прочитай `<имя>.status.json` и скажи пользователю, что именно осталось неразобранным. Останавливаться и ничего не отдавать - хуже, чем отдать неполный результат с честным перечнем дыр. **Gemini в локальном режиме запрещен ВСЕГДА** (данные встреч конфиденциальны). А вот прочитать глазами непокрытые кадры - можно: это аварийный слой, ограничитель тут цена, а не приватность. Порядок такой: сперва дай скрипту отработать (при HTTP 400 по контексту он сам снижает параллельность, при выгруженной модели - сам ее поднимает), затем возьми из `status.json` список кадров со `state` не равным `ok` и посмотри ТОЛЬКО их файлы из `screenshots/`. Не читай все кадры подряд - в 24-минутной встрече их бывает под сотню. 1. Определи `FilePath` и флаги. По расширению файла и флагам выбери движок (см. таблицу выше). 2. Если расширение - аудио, и нет `--engine gemini`, и нет `--analyze-ui` → запускай локальный: ```bash PYTHONUNBUFFERED=1 PYTHONIOENCODING=utf-8 \ ~/.claude/skills/transcribe/venv-whisper/Scripts/python.exe \ ~/.claude/skills/transcribe/scripts/transcribe_local.py \ "<FilePath>" [--output-dir "<OutputDir>"] [--diarize] [--num-speakers N] [--min-speakers N] [--max-speakers N] [--glossary PATH] [--no-glossary] ``` Локальный пайплайн: - Транскрипция и диаризация запускаются в **отдельных subprocess параллельно** (изоляция CUDA-DLL ctranslate2 vs torch). - 27-мин аудио = ~10 мин общего времени (RTF ~0.4). - Часовое аудио = ~25 мин общего времени. - Диаризация - только при `--diarize`. Без нее ~1.5-2 мин на 27-мин файл. 3. Если это ВИДЕО и указан `--engine local` → запускай ПОЛНОСТЬЮ ЛОКАЛЬНЫЙ разбор экрана (без облака): ```bash PYTHONUNBUFFERED=1 PYTHONIOENCODING=utf-8 python ~/.claude/skills/transcribe/scripts/analyze_video_local.py "<FilePath>" [--output-dir "<OutputDir>"] [--diarize] [--num-speakers N] [--project NAME] [--voiceprint-db PATH] [--no-voiceprints] [--no-coherent] [--no-summary] [--no-vlm] [--reuse-transcript] [--reuse-frames] [--glossary PATH] [--no-glossary] ``` Речь - локальный whisper; разбор экрана - `qwen3-vl-8b-instruct` на локальном сервере LM Studio; связный лог и саммари - `google/gemma-4-26b-a4b`; маппинг спикеров по репликам - `qwen2.5-32b`. Кадры обрабатываются параллельно (число слотов выводится из контекста VLM под unified KV cache). Клиентские кадры НЕ уходят в облако. Спикеры распознаются слоями: по ГОЛОСУ (голосовая база, узнает людей между встречами) и по репликам - см. "Спикеры и голосовая база". Предусловия: сервер LM Studio доступен (по умолчанию `http://localhost:1234`, env `LOCAL_150_BASE`), модели `qwen3-vl-8b-instruct` + `google/gemma-4-26b-a4b` + `qwen2.5-32b-instruct` загружены (скрипт проверяет и внятно сообщает, если модели нет). Выход: транскрипция / со спикерами / детальный / связный / саммари / `voiceprints.json` / `screenshots/` (ВСЕ scene-кадры). ВНИМАНИЕ: локальное зрение НЕ гарантирует посимвольную точность (в отличие от облака) - финансовые цифры сверять с экраном. Env-переопределения: `LOCAL_150_BASE`, `LOCAL_VLM_MODEL`, `LOCAL_SUMMARY_MODEL`, `LOCAL_SPEAKER_MODEL`, `SCENE_THRESHOLD`, `FRAME_FLOOR_SEC`, `FRAME_CAP`, `WHISPER_PYTHON`. 4. Иначе (видео без `--engine local`, или явный `--engine gemini`, или `--analyze-ui`) - запускай Gemini: ```bash PYTHONUNBUFFERED=1 python ~/.claude/skills/transcribe/scripts/transcribe.py "<FilePath>" [--output-dir "<OutputDir>"] [--analyze-ui] [--with-summary] [--format md|txt] [--model MODEL] [--fallback-models "m1,m2"] [--no-fallback] ``` Скрипт долгий (5-15 мин), файлы >1 ч разбиваются автоматически. 4. **Fallback при перегрузке Gemini** (503 / 429): скрипт сам перебирает пул моделей (см. "Авто-fallback по моделям Gemini"), доп. действий не требуется. Если весь пул недоступен и это аудио - крайний случай: локальный движок (см. шаг 2). 5. После завершения покажи пользователю пути к файлам и прочитай начало транскрипции / саммари. **ВАЖНО:** `PYTHONUNBUFFERED=1` обязательно для прогресса. ## Спикеры и голосовая база В локальном разборе видео (`--engine local`) имена спикеров определяются ТРЕМЯ слоями (голос приоритетнее текста, проверка идет последней): 1. **По голосу (голосовая база).** Диаризация считает отпечаток голоса каждого спикера (eres2net-эмбеддинг). Отпечаток сверяется с накопительной базой `voiceprints/db.json` по косинусной близости - так узнаются даже неназванные люди и ОДИН человек между разными встречами. Это больше, чем делает облако (оно вяжет имена только внутри одной записи). 2. **По репликам (текст).** LLM (`qwen2.5-32b`) читает транскрипт и вяжет имена по обращениям ("Иван, что скажешь?"), самопредставлениям, ссылкам. Ответ запрашивается строгим JSON по схеме - формат гарантирует сервер, а не послушание модели. 3. **Программная проверка (`speaker_validator.py`).** Обязательная, работает без всяких моделей. Отдельно от разбора обращений она снимает имена, которые в записи НЕ ЗВУЧАЛИ НИ РАЗУ: на встрече, где никого не назвали, модель уверенно выдает правдоподобный набор ("Роман", "Станислав"), и такое имя раньше проходило насквозь - порог улик к предложениям модели по замыслу не применяется, а других улик у выдумки нет. Пустая метка честнее выдуманного имени. Имя, подтвержденное голосовой базой, эта проверка не трогает. Причина остальных правок: ВСЕ проверенные модели (qwen2.5-32b, qwen3-vl-30b, qwen3.6 в том числе с размышлениями) систематически вешают имя на того, кто его ПРОИЗНОСИТ, хотя произносящий обращается к другому - на эталонной встрече три модели дали три разных ответа, совпав на одной метке из шести. Правило в промпте это не лечит. Проверка: имя из звательной позиции ("Марина, логика та же", "Да, Леш?") вешается на того, кто ОТВЕЧАЕТ, а не на говорящего; упоминание в третьем лице (косвенный падеж, имя с фамилией, "как Алексей просил") кандидатом не считается; усеченные формы сводятся к полной (Леш -> Алексей); роли и заглушки ("Модератор", "неизвестно") отбрасываются; род говорящего проверяется по форме глаголов ("я сделал" против "я сделала"); одно имя не висит на двух метках. Имена, подтвержденные голосом, проверка не пересматривает. Проверку можно запустить отдельно на готовом транскрипте - она покажет все найденные обращения и оценки: ``` python ~/.claude/skills/transcribe/scripts/speaker_validator.py "<файл - со спикерами.md>" ``` Авто-пополнение (бутстрап): если человек назван текстом, но в базе его еще нет - его отпечаток заносится в базу, и на будущих встречах он узнается уже по голосу. Заносятся только имена, прошедшие проверку (она же гарантирует, что одно имя не висит на нескольких метках - иначе в одну запись базы попали бы голоса разных людей). Провенанс: `--project NAME` помечает, в каком проекте/встрече встречался человек (полезно при пересечении людей между проектами). Управление: `--no-voiceprints` (не трогать базу), `--voiceprint-db PATH` (своя база), `--speaker-model` (модель текстового слоя). Голоса - чувствительные данные: база хранится ЛОКАЛЬНО и не коммитится. Просмотр / ручной enroll базы: ```bash python ~/.claude/skills/transcribe/scripts/voiceprints.py list python ~/.claude/skills/transcribe/scripts/voiceprints.py match --prints "<имя>.voiceprints.json" ``` ## Термины и ослышки (глоссарий) Распознаватель уверенно ослышивается на англицизмах и жаргоне, и молча: DAX -> "ДАКС", JSON -> "G-Splone", PROD -> "Прот", гашения -> "базаты". Дальше по конвейеру ошибку никто не ловит - текстовая модель принимает ослышку за факт и тащит ее в связный лог и в саммари. Лечится файлом `glossary.txt` в корне скила (UTF-8): ``` DAX = ДАКС, дэкс, ДАХ # слева правильное написание, справа ослышки JSON = джейсон, G-Splone регламентное задание # строка без "=" - только подсказка распознавателю ``` Как работает: - **Подсказка (hotwords).** Правильные написания уходят в промпт КАЖДОГО окна распознавания - модель чаще выбирает знакомую форму. Профилактика, не гарантия: список режется по лимиту промпта. - **Правка по факту.** Ослышки заменяются в готовом тексте - и в обычной транскрипции, и в файле со спикерами (он собирается заново из слов, поэтому правится отдельно). Дальше по конвейеру идет уже верный текст. - Регистр не важен (`ДАКС` ловит и `дакс`), границы слов соблюдаются (`Протокол` не превращается в `PRODокол`), ослышки короче 3 символов игнорируются. - Формы пишутся ЛИТЕРАЛЬНО, какими вышли из распознавателя: морфологии здесь нет, поэтому и слева форма под стать ослышке (`гашения = базаты`). Дополняй файл по итогам своих встреч - увидел ослышку, добавь строку. Проверить разбор и замены: ```bash python ~/.claude/skills/transcribe/scripts/glossary.py "текст с ослышкой" ``` Управление: `--glossary PATH` (свой файл), `--no-glossary` (выключить), env `TRANSCRIBE_GLOSSARY`. ## Проверка связного лога на выдумку Связный лог собирается текстовой моделью из описаний кадров, и модель там сочиняет: на реальном прогоне 08.2026 в нарративе оказались восемь сумм, кодов счетов и годов, которых в описаниях кадров не было (`4,800.00`, `90.01.1`, `ОКС0100222`, диапазоны "от 2009 до 2019"). Запрет в промпте это не держит. Поэтому после сборки каждое число и код нарратива сверяются с исходным материалом. Не подтвержденные выносятся сноской в конец файла `<имя> - связный.md`: ``` > **Не подтверждено кадрами.** Эти числа и коды есть в нарративе, но их нет в описаниях экрана... ``` Вырезать их автоматически нельзя - порвется фраза, поэтому решение за человеком: проверить по скриншотам в `screenshots/`. Промпты зрения и связного лога дополнительно требуют помечать нечитаемое как "не читается" и не обобщать перечисления в диапазоны. ## Авто-fallback по моделям Gemini При 503 (перегрузка серверов Google) или 429 (лимит) скрипт автоматически переходит к следующей модели из пула, пока одна не ответит. Ретрай одной модели делает SDK, смену модели - скрипт. Дефолтная цепочка (только дешевые модели 2.5): `gemini-2.5-flash` -> `gemini-2.5-flash-lite`. Дорогие модели (`gemini-3.5-flash`, `*-pro`, плавающие `*-latest`) сознательно НЕ в цепочке: плавающий `gemini-flash-latest` дрейфовал в `gemini-3.5-flash` и дал 96% счета за июнь 2026 (видео-вход в 5x дороже 2.5-flash). Нужна максимальная надежность любой ценой - добавить их через `--fallback-models`. Управление: - `--model MODEL` - стартовая модель (или env `GEMINI_MODEL`). - `--fallback-models "m1,m2,..."` - переопределить цепочку (или env `GEMINI_FALLBACK_MODELS`). - `--no-fallback` - только стартовая модель, без перебора. 503 - серверная перегрузка Gemini, она НЕ зависит от тарифа (платный тариф не помогает). Перебор моделей - официально рекомендованный обход. По умолчанию перебор идет только по дешевым 2.5-моделям. ## Стоимость - Локальный движок: бесплатно (только электричество). - Gemini: flash-класс ~$0.10-0.30 за 1 час записи. По умолчанию перебор только по дешевым 2.5-моделям (дорогие 3.5/pro исключены). ## Ограничения - Локальный АУДИО-движок (whisper) сам по себе не делает анализ интерфейсов. Для локального разбора ЭКРАНА видео есть отдельный путь `--engine local` (`analyze_video_local.py`: whisper + локальная VLM на LM Studio + спикеры по голосу) - требует доступный сервер LM Studio и загруженные модели; посимвольная точность зрения не гарантирована. - Локальный движок требует CUDA GPU. - Pyannote 4.x (диаризация) - модели gated, нужны принятые условия + HF-токен. - Кириллические имена файлов: скриптом обрабатываются. - Точность таймкодов +/- несколько секунд. - `--analyze-ui` с аудиофайлом → fallback на Gemini generic + саммари.
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.