Голосовые в текст на одном ядре: GigaAM v3, нарезка по паузам и очередь по кругу
Голосовые и кружки в Telegram удобно записывать и неудобно получать. Встроенная расшифровка есть, но полноценно — только с Premium. Ботов для этого много, и почти у всех одна схема: несколько бесплатных минут, дальше подписка, реклама или «подпишись на четыре канала».
Я сделал бота @chisto_text_bot, который расшифровывает голосовые, кружки и аудиофайлы бесплатно и без рекламы. Чтобы так и осталось, он должен почти ничего не стоить в работе: бот живёт на одном ядре арендованного сервера рядом с другими службами. Код открыт, ссылка в конце. Ниже — как это устроено: выбор модели с замерами, нарезка длинных записей, очередь, файлы до 2 ГБ и что бот не хранит.
Главные цифры: минута русской речи — около 2 секунд работы одного ядра, 7,9% ошибок в словах на живом подкасте. Whisper small на том же ядре ошибается в 14–18% слов и тратит 14–30 секунд на минуту звука.
Зачем это, если есть кнопка в Telegram и бот Сбера
Честный ответ: если у вас Premium и короткие голосовые от друзей, встроенной кнопки хватит.
Бот нужен в других случаях:
Нет Premium. Без него Telegram даёт только несколько пробных расшифровок в неделю.
Групповые чаты. Админ включает
/auto, и бот сам расшифровывает все голосовые и кружки. Длинный текст сворачивается под «показать полностью», чтобы не занимать весь чат.Длинные записи. Лекция, созвон, интервью: до 3 часов и до 2 ГБ одним файлом. Обычный Bot API отдаёт ботам файлы только до 20 МБ: голосовым хватает, а запись созвона в WAV или видео лекции уже не пролезет.
Приватность. Запись не уходит ни в какое облако распознавания: всё считается на своём сервере и удаляется сразу после ответа.
Есть и бот самого Сбера на SaluteSpeech, и он хороший. Только он, насколько можно судить по описанию, — витрина облачного API: запись уходит в облако Сбера. Мы взяли открытую модель того же Сбера, GigaAM v3 (лицензия MIT), и запустили у себя на процессоре. Так что качество распознавания русского у нас из той же семьи, а данные остаются на нашем сервере.
Выбор модели: замеры на одном ядре
Требования: русский с пунктуацией, одно ядро без видеокарты, память — до полутора гигабайт.
Сравнивал GigaAM (через onnx-asr, int8), Whisper через faster-whisper (CTranslate2, int8) и whisper.cpp. Все запуски — на том же сервере, на одном ядре.
Набор маленький, и это не научный бенчмарк: 22 файла. Это отрывки подкаста «Подлодка» с эталонной расшифровкой — живая разговорная речь, перебивания, жаргон. Плюс синтетическая речь: чистая и с наложенным шумом. Перед подсчётом WER текст приводится к нижнему регистру, ё → е, пунктуация убирается.
Модель | Подкаст WER / CER, % | Сек CPU на минуту звука | Память |
|---|---|---|---|
GigaAM v3 e2e RNN-T | 7,9 / 3,9 | 2,2 | 521 МБ |
GigaAM v3 e2e CTC | 8,2 / 3,5 | 2,2 | 529 МБ |
GigaAM Multilingual large | 7,9 / 4,7 | 5,6 | 1138 МБ |
faster-whisper turbo, beam 1 | 8,9 / 3,9 | 24,4 | 2131 МБ |
whisper.cpp medium, beam 1 | 9,3 / 4,7 | 61,1 | 892 МБ |
faster-whisper small, beam 5 | 14,2 / 7,2 | 14,2 | 922 МБ |
whisper.cpp small, beam 5 | 18,1 / 9,4 | 29,9 | 528 МБ |
Выводы:
Whisper на русском на CPU проигрывает по обеим осям. Turbo почти догоняет по точности, но в 11 раз медленнее и не влезает в память.
RNN-T и CTC у GigaAM почти равны. Взял RNN-T: на чистой синтетике он ошибался чуть реже. Время каждого токена onnx-asr отдаёт для обеих — оно понадобится для абзацев.
Multilingual не подошла. При той же точности она в 2,5 раза медленнее, пишет без запятых, а английский — русскими буквами.
Версия e2e ставит пунктуацию и заглавные сама, отдельная модель восстановления пунктуации не нужна.
На практике час записи распознаётся примерно за 2 минуты одного ядра.
Длинные записи: режем по паузам
У GigaAM одно ограничение: она слушает куски не длиннее 20–30 секунд. Длинную запись надо резать, и резать не посреди слова.
Готовый VAD (Silero и подобные) — это ещё одна модель и ещё память. Простой энергетический детектор тут справляется:
ffmpeg переводит запись в 16 кГц, моно, 16 бит, без заголовка. Распакованный час — 115 МБ, поэтому он читается через
np.memmap, а не целиком в память.Считается громкость кадров по 30 мс в дБ.
Кадр считается паузой, если он тихий относительно этой записи: ниже 10-го перцентиля + 8 дБ и ниже медианы − 6 дБ. Или просто тише −50 дБ. Фиксированный порог ломается на записях с улицы, где «тишина» громче, чем речь в студии.
В окне от 10-й до 20-й секунды куска ищется самая длинная пауза, режем посередине.
Если пауз нет вообще (музыка, сплошной быстрый разговор) — режем там, где сглаженная громкость минимальна.
Кусок, где громче −50 дБ набирается меньше 0,3 секунды, пропускается: это тишина, модель на ней только фантазирует.
floor, middle = float(np.percentile(db, 10)), float(np.median(db))
quiet = (db < floor + 8) & (db < middle - 6) | (db < -50)
...
for i in range(lo, hi): # окно 10–20 с от начала куска
cur = cur + 1 if quiet[i] else 0
if cur > best:
best, best_at = cur, i
end = (best_at - best / 2 + 1) * step # середина самой длинной паузы
Склейка обратно — две мелочи, без которых текст выглядит машинным:
Заглавные на стыке. Если кусок разрезан посреди фразы, модель начинает следующий с заглавной буквы. Если предыдущий кусок не кончился на
.!?…, первая буква возвращается в строчную.Абзацы по паузам. Время токенов даёт время предложений. Новый абзац начинается, если фраза закончилась и пауза перед следующей больше 1,5 с (а абзац уже длиннее 80 знаков) или больше 0,6 с при абзаце от 300 знаков. Длиннее 550 знаков — режем на ближайшем конце предложения. Часовая лекция приходит текстом с абзацами, а не одной простынёй.
Другие языки
GigaAM — модель для русского. На английском или казахском она уверенно пишет ерунду латиницей или русскими буквами. Поэтому после первых 20 секунд речи бот проверяет текст на подозрительность:
латиницы среди букв больше 40%, или
средний log-prob токенов ниже −0,25 (модель не уверена).
Если похоже, Whisper small определяет язык по первым 30 секундам. Не русский с уверенностью от 0,5 — запись целиком уходит в Whisper.
Whisper в 6–7 раз медленнее, поэтому:
иностранная запись расшифровывается не длиннее 15 минут;
в лимитах её минута считается за шесть;
модель (~0,5 ГБ) загружается при первой нужде и выгружается после 15 минут без дела.
Русские записи — подавляющее большинство, и Whisper большую часть времени даже не в памяти.
Одно ядро и много людей: очередь по кругу
Самая важная часть для бесплатного бота. Ядро одно. Если кто-то прислал двухчасовой подкаст, а следом человек — голосовое на 15 секунд, при обычной очереди второй ждёт четыре минуты. Для голосового это вечность.
Поэтому расшифровка — генератор. Он распознаёт один кусок (10–20 секунд звука, это меньше секунды работы) и отдаёт управление. Рабочий поток берёт следующий кусок у следующего человека, а не следующей записи:
def worker():
while True:
with turn:
while not order:
turn.wait()
user = order.popleft()
job = lines[user][0]
done = job.step() # один кусок ~20 с звука
with turn:
q = lines[user]
if done:
q.popleft()
if q:
order.append(user) # в конец круга
else:
del lines[user]
Это round-robin по людям. Короткое голосовое ждёт не дольше одного куска от каждого, кто сейчас в работе. Длинный подкаст при этом тоже не стоит, просто делится ядром.
Скачивание и распаковка ffmpeg идут в отдельных потоках (не больше двух разом), пока ядро распознаёт чужие куски. Записи до минуты приходят сразу текстом. У длинных бот держит одно сообщение-статус с прогресс-баром и правит его не чаще раза в 2 секунды — Telegram не любит частые правки.
Файлы до 2 ГБ: свой сервер Bot API
Облачный Bot API отдаёт ботам файлы до 20 МБ. Для голосовых хватает, для записи созвона в m4a или видео — нет.
Telegram выкладывает сервер telegram-bot-api в открытый код. В режиме --local он скачивает файлы до 2000 МБ и отдаёт боту не ссылку, а путь на диске. Бот не качает файл второй раз, а читает его на месте и удаляет после расшифровки.
Удаление чужих файлов — опасная операция, поэтому путь проверяется:
local = Path(path).resolve()
if not local.is_relative_to(TG_DIR.resolve()): # удаляем только у себя
raise TgError(f"файл не в {TG_DIR}")
Файлы от незнакомых людей
Бот открыт всем, значит, на вход приходит что угодно. Что сделано:
ffmpeg и ffprobe читают только с диска и только обычные контейнеры:
-protocol_whitelist file -format_whitelist ogg,mov,mp4,matroska,.... Без этого «аудиофайл», который на деле плейлист HLS, заставит ffmpeg ходить по ссылкам из него.Длине в заголовке не верим. Сначала лимит списывается по длине, которую сообщил Telegram. Потом — по ffprobe. Потом — по реальному размеру распакованного PCM. Если запись оказалась длиннее, разница досписывается, и при превышении лимита работа отменяется.
Битый файл не вешает бота: у распаковки таймаут, ошибки ffmpeg пишутся во временный файл, а не в трубу, которая может переполниться.
Лимиты, чтобы один человек не занял ядро всем:
Что | Предел |
|---|---|
Одна запись | 3 часа |
Один человек | 120 минут в час, 360 в сутки |
Одна группа | 180 минут в час |
Записей одного человека в работе | 5 |
Всего в работе | 100 записей или 8 часов звука |
Свободного места на диске | от 2 ГБ, иначе новые не берём |
Флуд | больше 20 сообщений в минуту — 10 минут тишины |
Изоляция на сервере
На том же сервере работают другие боты, и распознавание не должно отнимать у них ресурсы. Это делает systemd, без Docker:
CPUQuota=100% # одно ядро из трёх
MemoryHigh=1300M
MemoryMax=1536M # GigaAM ~0,5 ГБ, с Whisper ~1,2 ГБ
Nice=10
IOSchedulingClass=idle
User=tekstbot # свой системный пользователь
ProtectSystem=strict # видит только свои папки
ProtectHome=yes
PrivateTmp=yes
NoNewPrivileges=yes
# наружу — только интернет, внутренние сети закрыты
IPAddressDeny=10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 169.254.0.0/16 100.64.0.0/10 fc00::/7 fe80::/10
Свой сервер Bot API работает от того же пользователя, с потолком в половину ядра и 600 МБ.
Что бот не хранит
Базы данных нет.
Записи: скачал, расшифровал, удалил — обычно через несколько секунд.
Текст: отправил и забыл. О каждой записи в журнал попадает только строка вида «готово: 3:12 за 7,1 с (gigaam)».
Люди: для лимитов бот помнит в памяти, сколько минут человек расшифровал за час и сутки. Перезапуск — всё забыто.
Группы с
/auto: хранится только солёный SHA-256 от номера группы, чтобы помнить настройку.Статистика: три числа — сколько записей, сколько минут, сколько не на русском.
Чего бот не умеет
Чтобы не было вопросов в комментариях:
не разделяет говорящих;
не делает краткий пересказ и не переводит;
не выдаёт субтитры с таймкодами;
другие языки — через Whisper small, качество там заметно хуже, чем на русском;
в пик, когда много длинных записей разом, ответ замедляется: ядро одно.
Разделение говорящих и субтитры — самое частое, что хочется добавить. Но каждое такое улучшение — это ещё модель и ещё секунды CPU на минуту звука. Пока бот бесплатный и на одном ядре, приоритет — скорость ответа на обычное голосовое.
Итог
Открытая GigaAM v3 на процессоре распознаёт русский точнее Whisper и в разы быстрее: 2 секунды на минуту звука против 14–60. Для русского на CPU это сейчас очевидный выбор.
Энергетической нарезки по паузам с порогом относительно самой записи хватает, отдельный VAD не нужен.
Генератор плюс round-robin по людям — простой способ сделать одно ядро честным для всех.
Свой сервер Bot API снимает лимит в 20 МБ.
Код открыт под MIT: github.com/kozliq/tekst — два файла, бот и распознавание, плюс скрипты замеров и службы systemd.
Бот: @chisto_text_bot. Бесплатно, без рекламы и подписок на каналы. Если пригодился, можно кинуть звёзду через /donate — это добровольно и ничего не открывает.
Буду рад замечаниям — особенно от тех, кто гонял GigaAM на длинных шумных записях.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.