MAATRIX / Блог / Как поднять API транскрибации звонков на сервере

Как поднять API транскрибации звонков на сервере

Как поднять API транскрибации звонков на сервере

MAATRIX

Записи разговоров копятся в АТС гигабайтами, а достать из них текст руками невозможно: нужен эндпоинт, которому отдали файл — и получили расшифровку с репликами по ролям. Своё API транскрибации звонков закрывает два вопроса разом: облако берёт деньги за каждую минуту, а разговоры клиентов уезжают на сторону. Ниже — как собрать такой сервис, чтобы он не захлебнулся на телефонном звуке 8 кГц и не отваливался по таймауту на часовой записи.

Обсудить статью, задать вопрос или начать новую тему

Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество 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, failed200
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 — десятки моделей в одном окне. Оплата картой РФ и по СБП.