Из набора записей (mp3-ссылки, RSS-фид подкаста, локальные файлы, видео) получаем агентский RAG,
который отвечает на вопросы со ссылкой на секунду звука («момент»: запись · MM:SS · спикер,
клик — и слышно, как это было сказано). Референс-деплой: RAG по подкасту «Капитанский мостик»
(53 выпуска, 70 часов) — история решений в docs/adr/0015-0018.
- Транскрайб-бэкенд —
services/asr-adaptor(диаризация + двухпроходный Whisper с LLM-глоссарием + имена спикеров по голосу; см. ADR-0017/0018). Аудио-модели Apple-Silicon-bound — primary-деплой нативный на Mac (services/asr-adaptor/deploy/mac, ставится одной командой); оркестратор без аудио можно в Docker, указав ему внешние аудио-URL. - LLM-ключ (OpenAI-совместимый провайдер) — для глоссария/правки при транскрипции и для агента/реранкера при ответах.
- Эмбеддинги: dense (напр. Qwen3-Embedding через любого провайдера) + sparse GTE
(
services/embedder_gte, локальный сервис — публичных провайдеров нет). - Qdrant, ffmpeg (для видео и длительностей), python 3.12.
На Apple Silicon ставится установщиком из этого репозитория — четыре сервиса, четыре venv,
состояние в ~/asr-stack:
cd services/asr-adaptor/deploy/mac
./install.sh # создаст ~/.asr-stack.env и остановится — впишите OR_KEY и HF_TOKEN
./install.sh # venv-ы и зависимости (~10 мин, ~4.6 ГБ на диске)
./fetch-models.sh # модели из сети (~1.6 ГБ): Whisper, CAM++, pyannote
./stack.sh up # диаризатор грузит модель ~90 с
./stack.sh health # downstream должен быть ok у всех трёхСвободно качается всё, кроме двух gated-репозиториев pyannote, для которых понадобится HF-токен
с принятыми условиями на pyannote/segmentation-3.0 и pyannote/speaker-diarization-3.1.
Требования, таблица происхождения моделей и грабли — в
deploy/mac/README.md.
Корпус — это ваш набор записей целиком, а не отдельный файл. Конвейеру не нужно знать, встреча
перед ним, лекция или подкаст: стадии одинаковы, а имена он либо найдёт в представлениях, либо
честно оставит Speaker_N. Настраивается корпус окружением адаптера:
| переменная | что писать | на что влияет | если не заполнить |
|---|---|---|---|
OR_KEY, ASR_LLM_BASE_URL, ASR_LLM_MODEL |
ключ и модель LLM (reasoning off) | глоссарий, правка терминов, наминг | сервис не стартует вовсе — клиент LLM строится на импорте модуля |
ASR_CORPUS_DESC |
чем является материал, родительный падеж: "рабочей записи: встречи, митапа или обучающего материала по разработке" |
подсказка домена в промптах правки и наминга | останется дефолт, и правка будет искать в записи чужой домен |
ASR_ALWAYS_TERMS |
имена участников и постоянные названия через запятую | идут в подсказку распознавания ВСЕГДА и первыми | устойчивые искажения фамилий останутся: глоссарий переоткрывает термины на каждой записи и на стабильном гарбле срывается (замерено: одна фамилия искажалась 14 раз по корпусу) |
ASR_REGISTRY_PATH |
свой файл на каждый корпус | где копятся голоса — по ним имена узнаются между записями | голоса разных проектов смешаются в одном пространстве Speaker_N, и ложный матч подпишет человека чужим именем |
ASR_MAX_SPEAKERS |
сколько людей бывает в записи (дефолт 10) | верхняя граница диаризации | на совещании с десятком участников лишние голоса склеятся с чужими |
ASR_MIN_GUEST_MIN |
сколько минут голос должен наговорить, чтобы стать отдельным человеком (дефолт 2.0) | разделение докладчика и коротких реплик | на записи с вопросами из зала все сведутся в одного человека — расшифровка при этом выглядит удавшейся |
ASR_ENABLE_NAMING |
1, только если каждая запись начинается с представлений |
два вызова LLM на запись | дефолт 0 — имён не ищем. Вне «ведущий представляет гостя в начале» стадия подписывает не того: на митапе докладчика благодарят по имени в КОНЦЕ, и имя достаётся тому, кто это произнёс |
HTTPS_PROXY |
если провайдер LLM не пускает ваш регион | доступ к LLM-стадиям | вызовы упрутся в отказ провайдера |
Доменные значения держите в репозитории своего проекта (файл в git, без секретов), а секреты и
пути — в ~/.asr-stack.env на машине; см. ADR-0023 и раздел «Профили» в deploy/mac/README.md.
Speaker_N.
Папка со смешанными записями — аудио и видео вперемешку, имена произвольные:
export ASR_BASE=http://127.0.0.1:8082
services/asr-adaptor/client/run_folder.sh ~/Records --dry # показать, что будет сделано
services/asr-adaptor/client/run_folder.sh ~/RecordsРезультат ложится рядом с исходником: Планёрка 12 марта.mp4 → Планёрка 12 марта.md и .json.
Идемпотентно (готовое пропускается, папку можно доливать), последовательно (реестр голосов общий).
Пронумерованные выпуски по URL-шаблону — если это подкаст:
export ASR_BASE=http://127.0.0.1:8082
export MP3_URL_TEMPLATE='https://site/episodes/ep{n}.mp3' # {n} — номер, {pfx} — сезонный префикс
export TITLE_TEMPLATE='Мой подкаст №{pfx}{n}'
export OUT_DIR=./transcripts/season1 CACHE_DIR=./media_cache/season1
services/asr-adaptor/client/run_corpus.sh 1 2 3 4 5 # последовательно! (реестр голосов)
# одиночная запись, прямой URL или локальный файл (видео → дорожка извлечётся ffmpeg'ом):
services/asr-adaptor/client/transcribe_one.sh 6 /path/to/lecture.mp4На выходе — .md (front-matter + [Имя] <!-- t:сек --> текст) и .json (raw-сайдкар; пословные
тайминги внутри, в x_enriched.words). Реестр голосов стартует пустым: участников именует LLM из
представлений, дальше голоса узнаются между записями автоматически (ADR-0018).
Цена и время (замер на записи 91 минута, ноутбук с 16 ГБ): около 20 минут и $0.03 на запись,
166 вызовов LLM. Аудио-стадии дают 84% времени — упирается в машину, не в сеть. Померить свою:
deploy/mac/bench.sh <файл>.
Адаптер сам считает покрытие звука речью и кладёт отчёт в .json — отдельный инструмент не нужен:
python3 - <<'EOF'
import json, pathlib, sys
for f in sorted(pathlib.Path(sys.argv[1] if len(sys.argv) > 1 else '.').glob('*.json')):
xe = (json.load(open(f)).get('x_enriched') or {})
cov, words = xe.get('coverage') or {}, (xe.get('words') or {}).get('turns') or []
unheard = sum(b - a for a, b in cov.get('unheard', []))
flat = [w for t in words for w in t.get('words', [])]
ordered = all(flat[i][1] <= flat[i + 1][1] for i in range(len(flat) - 1))
print(f"{f.name}: звука {cov.get('audio_sec', 0):.0f} с | не услышано {unheard:.1f} с "
f"| слов {len(flat)} | порядок {'ок' if ordered else 'НАРУШЕН'}")
EOFПриёмка по числам: не услышано — единицы секунд на запись; метки говорящих совпадают между
записями с одними и теми же людьми; пословные тайминги строго по возрастанию (на неотсортированных
караоке и перемотка по слову врут молча). Если перегоняете записи повторно и сравниваете
с прежней расшифровкой, сравнивайте по словам и с autojunk=False: difflib на репликах
длиннее 200 элементов занижает сходство вдвое и покажет расхождение там, где отличается одно слово.
export MP3_URL_TEMPLATE='https://site/episodes/ep{n}.mp3'
export RSS_URL='https://site/feed.xml' # даты публикации
export EPISODE_PAGE_TEMPLATE='https://site/ep-{n}.html' # темы в title из meta description
export TITLE_TEMPLATE='Мой подкаст №{pfx}{n}'
export TRANSCRIPTS_DIR=./transcripts/season1 MEDIA_CACHE_DIR=./media_cache/season1
python tools/enrich_frontmatter.py 1 2 3 4 5Появятся date, duration_sec, темы в title, speakers — на них опираются каталог выпусков
и фильтры retrieval.
config.example.yml рядом — скопируйте, впишите ключи и участников. Ключевое:
chunker.mode: transcript (тематические чанки с сохранением секунд и спикеров),
retrieval.features.timestamp_citations: true (цитаты-моменты, ADR-0015),
разговорный режим ответа в prompts.section_overrides (ADR-0016).
docker run -d --name qdrant -p 6333:6333 -v "$PWD/qdrant-data:/qdrant/storage" qdrant/qdrant
docker run -d --name gte -p 8081:8081 morag-embedder-gte # или нативно, см. deploy-macos/
python -m cli.main index --config ./config.ymlOWUI + pipelines (см. корневой docker-compose) либо нативный стек без Docker —
deploy-macos/ содержит launchd-плисты нашего публичного деплоя (qdrant-бинарь + GTE +
pipelines + OWUI без авторизации, всё на loopback за реверс-прокси).
Обновление корпуса новой записью = Шаг 3 (один файл или номер) → Шаг 4 → cli.main index без --reset
(инкрементально, старые записи скипаются) — ~15 минут на свежий выпуск.
- Whisper-бэкенд обязан honor-ить
prompt(стоковые сервер-обвязки игнорируют — потому свой). - Python 3.12 для нативного стека: OWUI требует
<3.13, пины GTE без cp313-wheels. - OWUI не работает под URL-подпутём — публикуйте на поддомене.
WEBUI_AUTH=Falseвалиден только на чистойdata/OWUI.- Реестр голосов — биометрия: держите локально, в git не кладите.
- Транскрипты — производная вашего контента; чужие записи публикуйте только с разрешения.