The Daily Newsstand · Free, Always
Saturday, October 31, 2026

Голосовые в текст на одном ядре: GigaAM v3, нарезка по паузам и очередь по кругу

Translate

Голосовые и кружки в 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 и подобные) — это ещё одна модель и ещё память. Простой энергетический детектор тут справляется:

  1. ffmpeg переводит запись в 16 кГц, моно, 16 бит, без заголовка. Распакованный час — 115 МБ, поэтому он читается через np.memmap, а не целиком в память.

  2. Считается громкость кадров по 30 мс в дБ.

  3. Кадр считается паузой, если он тихий относительно этой записи: ниже 10-го перцентиля + 8 дБ и ниже медианы − 6 дБ. Или просто тише −50 дБ. Фиксированный порог ломается на записях с улицы, где «тишина» громче, чем речь в студии.

  4. В окне от 10-й до 20-й секунды куска ищется самая длинная пауза, режем посередине.

  5. Если пауз нет вообще (музыка, сплошной быстрый разговор) — режем там, где сглаженная громкость минимальна.

  6. Кусок, где громче −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 на длинных шумных записях.

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.