Как поднять API транскрибации звонков на сервере
Записи разговоров копятся в АТС гигабайтами, а достать из них текст руками невозможно: нужен эндпоинт, которому отдали файл — и получили расшифровку с репликами по ролям. Своё API транскрибации звонков закрывает два вопроса разом: облако берёт деньги за каждую минуту, а разговоры клиентов уезжают на сторону. Ниже — как собрать такой сервис, чтобы он не захлебнулся на телефонном звуке 8 кГц и не отваливался по таймауту на часовой записи.
Содержание
- Чем звонок отличается от подкаста
- Архитектура: почему синхронный POST не переживает первый же звонок
- Эндпоинты и ответ: FastAPI поверх faster-whisper
- Кто говорит: каналы вместо диаризации
- Как звонок попадает в API: АТС, спул и вебхуки
- Прод: пропускная способность, хранение и защита
- Какой сервер под API транскрибации звонков брать в MAATRIX
Обсудить статью, задать вопрос или начать новую тему
Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →Чем звонок отличается от подкаста
Первое, что ломает наивную сборку, — сам звук: телефония живёт в узкой полосе 8000 Гц, кодек G.711 (μ-law в Северной Америке, A-law в Европе и России), иногда GSM 6.10 или Opus. Начинайте с проверки того, что лежит в каталоге записей, а не с выбора модели.
ffprobe -v error -show_entries stream=codec_name,sample_rate,channels,duration \
-of default=noprint_wrappers=1 /var/spool/asterisk/monitor/1725012345.67.wav
codec_name=pcm_alaw
sample_rate=8000
channels=1
duration=412.480000
Whisper во всех реализациях работает с 16 кГц, и апсемплинг с 8 кГц ничего не восстанавливает — полоса выше 4 кГц остаётся пустой. На телефонном звуке ошибок заметно больше, чем на записи с петличного микрофона: сыпется в первую очередь то, что различается высокими частотами, — глухие согласные, цифры, фамилии, буквы при диктовке адреса почты. Это честное ограничение задачи, а не дефект установки.
Отсюда два вывода: модель берите крупнее, чем для подкаста — вместо small смотрите на medium/large-v3; а «улучшайзеры» полосу не вернут — агрессивная нормализация лишь вытягивает шум паузы до уровня речи, добавляя поводов для галлюцинаций.
Приводим к общему знаменателю:
ffmpeg -hide_banner -i call.wav -ar 16000 -ac 1 -c:a pcm_s16le \
-af "highpass=f=80" /var/spool/transcribe/work/call-16k.wav
Оригинал при этом не трогайте: запись из АТС — первичный документ, рабочая копия для распознавания создаётся отдельно и удаляется после обработки.
Архитектура: почему синхронный POST не переживает первый же звонок
Соблазн сделать один эндпоинт, который принимает файл и возвращает текст, заканчивается одинаково: разговор на сорок минут распознаётся на процессоре минутами, а вся цепочка перед вашим кодом сконфигурирована на секунды. Gunicorn по умолчанию убивает воркер через 30 секунд молчания:
[CRITICAL] WORKER TIMEOUT (pid:2417)
[ERROR] Worker (pid:2417) was sent SIGKILL! Perhaps out of memory?
Nginx со стандартным proxy_read_timeout 60s отдаёт 504 ещё раньше и пишет в /var/log/nginx/error.log:
upstream timed out (110: Connection timed out) while reading response header from upstream
Дальше хуже: АТС или интеграция считает запрос неудачным и повторяет его, а очередь на занятом сервере растёт быстрее, чем разгребается. Наращивать proxy_read_timeout до получаса можно, но это лечение симптома — соединение всё равно рвётся на мобильной сети.
Правильная форма — асинхронное задание: клиент кладёт файл, получает 202 Accepted и идентификатор, а результат забирает опросом или колбэком.
| Метод и путь | Что делает | Ответ |
|---|---|---|
POST /v1/jobs | принимает аудио и метаданные звонка | 202 + job_id |
GET /v1/jobs/{id} | статус: queued, running, done, failed | 200 |
GET /v1/jobs/{id}/result | сегменты и текст | 200 или 409, если не готово |
POST /v1/jobs c callback_url | шлёт результат сам | 202 |
Три вещи, без которых схема развалится:
- Идемпотентность. Ключом берите
UNIQUEIDзвонка из АТС, а не UUID: повторныйPOSTс тем же ключом возвращает существующийjob_id— половина проблем с ретраями закрыта. - Обратное давление. Когда в очереди больше заданий, чем воркеры разберут, отвечайте
429сRetry-After, а не принимайте всё подряд: тихо растущая очередь опаснее честного отказа. - Лимит тела запроса. У Nginx
client_max_body_sizeпо умолчанию 1 МБ — часовая запись упрётся в413 Request Entity Too Large. Для звонков:client_max_body_size 512m;иproxy_request_buffering off;.
Очередь не обязана быть тяжёлой: три строки на Redis и RQ, но и jobs в SQLite с одним демоном держит десятки заданий в час.
Развернуть за пару минут
Готовый образ на VPS MAATRIX: NVMe, AMD EPYC, root-доступ. Локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Развернуть WhisperЭндпоинты и ответ: FastAPI поверх faster-whisper
Движок берите CTranslate2-шный faster-whisper: он не тянет PyTorch и на процессоре считает в int8. Ключевое правило — модель грузится один раз на процесс, при старте, а не на каждый запрос.
from contextlib import asynccontextmanager
from fastapi import FastAPI, UploadFile, Form, HTTPException
from faster_whisper import WhisperModel
MODEL = {}
@asynccontextmanager
async def lifespan(app: FastAPI):
MODEL["asr"] = WhisperModel(
"medium", device="cpu", compute_type="int8",
cpu_threads=4, num_workers=1,
download_root="/var/lib/whisper/models",
)
yield
MODEL.clear()
app = FastAPI(lifespan=lifespan)
Отсюда ограничение: uvicorn --workers 4 при 4 ГБ памяти не запускайте — каждый воркер поднимет копию весов. Веб-слой держите в один процесс, параллелизм — на стороне воркеров очереди, где вы контролируете число живых моделей.
Формат ответа продумайте сразу: отдавайте не только текст, но и сегменты с таймкодами, ролью говорящего и вероятностью тишины.
{
"job_id": "1725012345.67",
"language": "ru",
"duration": 412.48,
"segments": [
{"start": 0.0, "end": 3.6, "speaker": "operator",
"text": "Компания «Ромашка», здравствуйте.", "no_speech_prob": 0.02},
{"start": 3.9, "end": 9.4, "speaker": "client",
"text": "Здравствуйте, я по поводу счёта от двадцатого.", "no_speech_prob": 0.05}
]
}
Отдельно добавьте маршрут, совместимый с OpenAI: POST /v1/audio/transcriptions с полями file, model, language, response_format — тогда клиенты и SDK переключаются на ваш сервер сменой base_url.
curl -s -X POST http://127.0.0.1:8000/v1/audio/transcriptions \
-H "Authorization: Bearer $ASR_API_KEY" \
-F "file=@call-16k.wav" -F "model=whisper-1" \
-F "language=ru" -F "response_format=verbose_json" | jq '.text'
Честно про совместимость: она частичная, синхронный маршрут годится только для коротких фрагментов — на часовом звонке вернёт к тем же таймаутам. Зато у облачного API OpenAI лимит файла 25 МБ, а у вас его нет.
Авторизацию не откладывайте: ключ в заголовке и проверка на уровне зависимости FastAPI — сервис без неё в интернете — чужие файлы в вашей очереди и ваш процессор.
Кто говорит: каналы вместо диаризации
Главный подарок телефонии — роли уже разделены физически: если АТС пишет разговор в два канала, вопрос «кто это сказал» решается точно и бесплатно, без диаризации.
В Asterisk MixMonitor пишет входящий и исходящий потоки в отдельные файлы:
same => n,MixMonitor(${UNIQUEID}.wav,r(/tmp/${UNIQUEID}-rx.wav)t(/tmp/${UNIQUEID}-tx.wav))
Склеивают их в стерео: sox -M /tmp/x-rx.wav /tmp/x-tx.wav /var/spool/transcribe/in/x.wav. В FreeSWITCH то же самое — переменная RECORD_STEREO=true.
Обратно разделить стерео на два моно-потока:
ffmpeg -hide_banner -i call.wav -filter_complex \
"[0:a]channelsplit=channel_layout=stereo[l][r]" \
-map "[l]" -ar 16000 -c:a pcm_s16le operator.wav \
-map "[r]" -ar 16000 -c:a pcm_s16le client.wav
Каждый канал распознаётся отдельно, сегменты сливаются в диалог сортировкой по start — перебивания перестают портить текст: они лежат в разных каналах.
Подвох, о котором молчат: в канал абонента подмешивается эхо оператора из-за акустической связи в гарнитуре. Модель его честно распознаёт, и в расшифровке фраза оператора дублируется репликой клиента. Лечится сравнением энергии: тот же текст с теми же таймкодами в обоих каналах — оставляйте там, где громче. Готового порога нет, подбирается на десятке записей.
Когда запись только моно, остаётся диаризация: связка WhisperX с pyannote 3.1, но репозиторий pyannote/speaker-diarization-3.1 gated — нужно принять условия на Hugging Face и передать токен, иначе получите:
Could not download 'pyannote/speaker-diarization-3.1' model.
It might be because the model is private or gated so make sure to authenticate.
Честная цена: плюс несколько гигабайт памяти, больше времени на файл и менее уверенный результат на узкой полосе 8 кГц с наложением голосов. Двухканальная запись в АТС надёжнее на порядок — настройте её, если можете.
Отдельная беда звонков — музыка ожидания, IVR, гудки, тишина на удержании: на этих участках модель выдумывает текст. Спасают vad_filter=True с vad_parameters=dict(min_silence_duration_ms=500), отброс сегментов с no_speech_prob выше 0,6 и condition_on_previous_text=False. Имена и названия подскажите через initial_prompt или hotwords (с faster-whisper 1.0.2).
Как звонок попадает в API: АТС, спул и вебхуки
Два надёжных способа связать телефонию с сервисом обходятся без хрупких синхронных вызовов из диалплана.
Спул-каталог. АТС кладёт запись в /var/spool/transcribe/in, демон забирает. Реагировать нужно на завершение записи: файл растёт до отбоя, и событие create даст обрезанный звонок.
apt-get install -y inotify-tools
inotifywait -m -e close_write --format '%w%f' /var/spool/transcribe/in
Тот же результат без демона даёт systemd:
[Unit]
Description=Ingest call recordings
[Path]
DirectoryNotEmpty=/var/spool/transcribe/in
Unit=call-ingest.service
[Install]
WantedBy=multi-user.target
Забирать файл лучше атомарно — переносом в work/ тем же mv внутри одной файловой системы: так две копии воркера не возьмут одну запись, а упавшая обработка оставит файл видимым для разбора.
Хук на отбой. В Asterisk задачу вешают на hangup handler или System() после остановки записи, в FreeSWITCH — на api_hangup_hook: короткий curl в POST /v1/jobs с метаданными — uniqueid, направление, номера, длительность, оператор. Вызов должен вернуться мгновенно: он ставит задачу, а не ждёт расшифровку.
Облачная АТС. Манго, Задарма, Билайн шлют вебхук со ссылкой на mp3. Три ловушки: ссылка живёт ограниченное время — качать сразу; вебхук приходит из интернета — проверяйте подпись и белый список адресов; приходит иногда дважды — спасает идемпотентность по идентификатору звонка.
Результат удобно возвращать колбэком в CRM: amoCRM и Битрикс24 кладут текст в примечание к сделке. Но полный JSON с таймкодами храните у себя — CRM обрежет текст, а вам потом искать по фразе и слушать секунду.
Прод: пропускная способность, хранение и защита
Главный вопрос эксплуатации — сколько минут разговоров сервер переварит за сутки. Считается это не из чужих таблиц, а из вашего собственного замера RTF (real-time factor) — отношения времени обработки к длительности аудио.
/usr/bin/time -f "%e sec" python3 transcribe.py call-16k.wav
Разделите секунды на длительность файла — это ваш RTF. Один воркер обрабатывает 86400 / RTF секунд аудио в сутки: при RTF 0,3 — около 80 часов разговоров, при RTF 1,0 — впритык, без запаса на пики. Закладывайте запас вдвое: звонки идут пачками в рабочие часы. И замеряйте на телефонной записи со своим compute_type — на студийном файле цифра будет оптимистичнее реальной.
Место на диске точнее считается без замеров: моно 8 кГц PCM 16 бит — 16 000 байт/с, около 57 МБ на час; стерео вдвое больше, G.711 — вдвое меньше PCM, Opus на 24 кбит/с — около 11 МБ на час. Текст на этом фоне не весит ничего.
О чём стоит подумать до запуска:
- Ретеншн. Запись и расшифровка — персональные данные: расшифровка не «безобиднее» аудио, она индексируется и ищется. Задайте срок хранения и удаляйте автоматически, включая рабочие копии в
work/. - Правовая сторона. Уведомление о записи, основание для обработки, место хранения — это к юристу. Технически нужно уметь удалить все данные по одному звонку или абоненту одной операцией.
- Периметр. Наружу — только Nginx с TLS, приложение слушает
127.0.0.1:8000,ufw allow 22/tcp,ufw allow 443/tcp, остальное закрыто. Ключ API — в переменной окружения юнита, не в коде. - Наблюдаемость. Минимум метрик: глубина очереди, число
failedза час, свободное место и возраст самого старого задания: его рост — честный сигнал нехватки мощности.
Отдельная честность про задержку: без видеокарты это не риалтайм. Расшифровка приезжает через минуты после отбоя — для аналитики и поиска по разговорам этого достаточно. Подсказки оператору во время разговора на такой схеме не построить: нужен потоковый режим и другое железо.
Какой сервер под API транскрибации звонков брать в MAATRIX
Профиль нагрузки простой: считает процессор, память держит веса модели, диск копит аудио. Видеокарта ускоряет в разы, но для потока в несколько сотен звонков в день обычно избыточна — CPU-схема с ночным разбором очереди дешевле и проще в обслуживании.
Честный минимум: 2 vCPU, 4 ГБ RAM, 40 ГБ NVMe. Хватает на модель small или medium в int8 и один воркер. Ориентир по памяти — число параметров: у small 244 млн, у medium — 769 млн, у large-v3 — 1,55 млрд, в int8 это примерно столько же байт весов; сверху закладывайте столько же на буферы и Python. На диске модели в float16 занимают вдвое больше: large-v3 — около трёх гигабайт. Ограничение прямое: large-v3 в 4 ГБ уже неуютно, а держать рядом Redis, Postgres и месячный архив на 40 ГБ не получится.
Комфортный вариант: 4–8 vCPU, 8–16 ГБ RAM, 80–160 ГБ NVMe. Помещается large-v3 в int8, два-три воркера, очередь на Redis, база заданий и живой архив аудио за несколько недель — запас на утренний пик, когда за час прилетает столько же звонков, сколько за остальной день. Поток в тысячи разговоров в сутки или расшифровка за минуту после отбоя — повод смотреть на выделенный сервер с видеокартой.
Whisper есть в каталоге apps.maatrix.io и при заказе сервера ставится автоматически — вручную ничего разворачивать не нужно, работает на Ubuntu и Debian. Адрес панели и ключи доступа — в личном кабинете, в разделе «Доступ»; дальше вы добавляете свой слой очереди и интеграцию с АТС.
Локация — Великобритания, Лондон. Британская площадка даёт короткий путь до вашей АТС, низкий пинг до европейских узлов и понятное GDPR-соседство. Если и АТС, и абоненты российские, а данные подпадают под 152-ФЗ, берите RU-локацию: трансграничная передача записей разговоров — та ещё история, проще её не начинать. США стоит выбирать, когда рядом с транскрибацией живут зарубежные ИИ-сервисы для суммаризации текста. Оплата — картами российских банков, по СБП, криптовалютой или токеном MAAT.
Развернуть за пару минут
Готовый образ на VPS MAATRIX: NVMe, AMD EPYC, root-доступ. Локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Развернуть WhisperОбсудить статью, задать вопрос или начать новую тему
Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →Частые вопросы
Можно ли распознавать звонок прямо во время разговора?
На процессорной сборке — нет. Реалистичный сценарий — расшифровка через минуты после отбоя. Потоковый режим требует видеокарты, нарезки аудио на окна с перекрытием и отдельной логики склейки — это другая по сложности система.
Что делать, если АТС пишет только моно?
Включите раздельную запись: в Asterisk это опции r() и t() у MixMonitor, в FreeSWITCH — RECORD_STEREO=true. Это точнее и дешевле любой диаризации. Если такой возможности нет, ставьте WhisperX с pyannote 3.1, приняв условия gated-репозитория, и закладывайте лишние гигабайты памяти и времени на обработку.
Почему в расшифровке появляются фразы, которых не было?
Почти всегда это участки без речи: музыка ожидания, гудки, тишина на удержании. Включите vad_filter=True, отбрасывайте сегменты с no_speech_prob выше 0,6 и поставьте condition_on_previous_text=False, чтобы выдуманная фраза не тянула за собой следующие.
Нужны сами нейросети для контента?
Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.