Записи звонков Asterisk в текст: локальный Whisper без облака
Москва, Щёлковское шоссе, д. 92, корп. 7 · Пн–Пт 9:00–19:00 · +7 903 729-62-41
АйТи Фреш
ИИ и нейросети

Как превратить записи разговоров Asterisk в текст на своём сервере и находить любой звонок за секунды

Автор: , директор ООО «АйТи-Фреш» · · ~19 мин чтения
Кассетный диктофон, лента которого превращается в печатные строки, рядом сервер с одной видеокартой для распознавания звонков Asterisk
Разговор, который нельзя искать, — это просто шум на диске.

Записи звонков Asterisk превращаются в текст на вашем сервере так: MixMonitor пишет раздельные дорожки, очередь передаёт файлы в faster-whisper, а текст и метаданные уходят в PostgreSQL с полнотекстовым поиском. Ниже конфиги, код, железо и грабли.

Сотни записей на диске и ни одного ответа на вопрос «кто что обещал»

Классика жанра: клиент звонит директору и говорит, что менеджер обещал скидку десять процентов на вторую отгрузку. Менеджер уверяет, что ничего подобного не говорил. Запись разговора у вас есть, она лежит где-то среди 54 000 файлов за последний год. Найти её можно двумя способами: помнить точную дату и номер клиента или слушать подряд. Первое у людей получается плохо, второе занимает рабочий день, и никто его не отдаст. В итоге скидку дают, «чтобы не ссориться», а записи продолжают занимать место на диске и радовать аудиторов самим фактом существования.

Записи разговоров почти везде пишут, а вот пользы из них извлекают мало: аудио нельзя искать глазами. Если превратить его в текст, картина меняется. Фразу «обещали скидку» вы вводите в строку поиска и через секунду видите все разговоры, где она звучала, с датой, менеджером и кусочком текста вокруг. Так можно разбирать жалобы, проверять, говорят ли менеджеры по скрипту, и спорить с клиентами не на эмоциях. Если вы хотите собрать такой конвейер на собственном сервере и привлечь для этого внедрение открытых решений, то хорошая новость: все нужные компоненты бесплатные, а умеет это всё обычный Asterisk плюс Python и PostgreSQL.

Почему я принципиально не отдаю такие записи облачным сервисам распознавания. Первое: в разговоре звучат фамилии, телефоны, суммы, адреса доставки, иногда реквизиты. Это персональные данные, и отправка их стороннему сервису превращается в передачу третьему лицу с отдельным договором и вопросами к месту обработки. Второе: деньги. Облако считает минутами, а у компании, которая ведёт 180 разговоров в день, набегает около 720 минут ежедневно. Платить за них каждый день, пока собственная видеокарта простаивает ночью, мне не нравится. Третье: зависимость. Сервис поменял тариф или закрыл доступ из вашей страны, и поиск по звонкам перестаёт работать.

Дальше я разберу схему целиком, так как мы собираем её в «АйТи-Фреш» для компаний до 50 рабочих мест. Запись звонков в файлы делает Asterisk. Каждый файл ставится в очередь. Ночью воркер на faster-whisper превращает речь в текст, а текст вместе с метаданными уходит в PostgreSQL с полнотекстовым индексом. Утром директор вводит фразу и получает список звонков. Ничего из этого не покидает ваш сервер. Всё, что я пишу про железо и качество, — из практики; конкретные числа для вашей нагрузки вы получите после пробного прогона на ваших же записях.

Цифры и версии: Сотни записей на диске и ни одного ответа на вопрос «кто что обещал» — схема
Цифры и версии: Сотни записей на диске и ни одного ответа на вопрос «кто что обещал». Открыть схему в полном размере

Как записывать звонки, чтобы потом их можно было распознать

Хорошая транскрибация начинается не в Whisper, а в диалплане. Я видел много установок, где записи пишутся одним смешанным файлом в формате GSM, а потом удивляются, что в распознанном тексте нельзя понять, где говорит менеджер, а где клиент. Основа всего — приложение MixMonitor. Его синтаксис такой: MixMonitor(filename.extension,[options,[command]]). Абсолютный путь в имени файла используется как есть, а относительный отсчитывается от каталога мониторинга из asterisk.conf, как правило /var/spool/asterisk/monitor. Расширение задаёт формат; для распознавания я выбираю wav. Реальное имя итогового файла после записи лежит в переменной MIXMONITOR_FILENAME.

Для распознавания я пишу каждое направление в отдельный файл. Опции r(file) и t(file) сохраняют принимаемое и передаваемое направления отдельно, а опция b ограничивает запись временем, пока канал находится в бридже, то есть без IVR и ожидания в очереди. Модель получает чистую речь каждой стороны, и кто говорил, понятно без диаризации, которая на телефонной записи работает так себе. Минус очевиден: файлов два, и текст потом нужно собрать по таймкодам, что решает word_timestamps. Опцию n, отключающую вставку тишины, я не включаю: именно эта тишина синхронизирует r- и t-файлы по времени.

Теперь про то место, ради которого всё затевалось. Третий аргумент MixMonitor — command: он выполняется, когда запись завершена. Именно сюда удобно поставить скрипт, который кладёт имя файла в очередь. Вариант из жизни:

; extensions.conf, фрагмент входящего маршрута
exten => _X.,1,NoOp(incoming)
 same => n,Set(CID=${FILTER(0-9,${CALLERID(num)})})
 same => n,Set(REC=${STRFTIME(${EPOCH},,%Y%m%d-%H%M%S)}-${CID}-${UNIQUEID})
 same => n,MixMonitor(${REC}.wav,br(${REC}-r.wav)t(${REC}-t.wav),/usr/local/bin/enqueue_call.sh ${REC})
 same => n,Dial(PJSIP/${EXTEN},30)

Обратите внимание на FILTER. В имя файла и в command нельзя подставлять сырые CALLERID(num) и CALLERID(name): звонящий сам решает, что у него в caller ID, и строка вида «;rm -rf» превращается в инъекцию команд на вашем сервере. FILTER(0-9,...) оставляет только цифры, и это самый дешёвый способ спать спокойно. Какой из файлов чей, определяется каналом, на котором стоит MixMonitor: r — это то, что Asterisk принимает от этого канала, t — то, что в него отдаёт. В примере выше запись висит на канале звонящего, значит r — голос клиента, t — голос менеджера; на исходящем звонке с канала менеджера будет наоборот. На первом тестовом звонке всё равно проверьте это ушами и зафиксируйте в скрипте: переставить две буквы дешевле, чем полгода читать расшифровки с перепутанными ролями.

Что делает enqueue_call.sh? Почти ничего: он атомарно кладёт маркер в каталог очереди, и всё. Ни распознавания, ни обращения к базе внутри обработчика Asterisk быть не должно: если скрипт зависнет, вы получите подвисшие каналы на АТС. Атомарность важна, чтобы воркер не подхватил полузаписанный файл: пишем под временным именем и переименовываем. Если вы хотите заодно навести порядок в самих файлах записей, у нас есть отдельные разборы где лежат файлы записей и сроков их хранения.

Отдельный вопрос — MixMonitor или другие способы записи. Если записи лежат на том же диске, что и система, а расписания чистки нет, транскрибация порядка не наведёт: сама по себе она диск не забьёт — текст рядом с каждым файлом прибавляет килобайты, а не мегабайты, — но и проблему переполнения не решит. Про разницу MixMonitor и AsyncRecord и то, как это влияет на диск, я писал отдельно: для нашей задачи достаточно MixMonitor, как и в большинстве типовых установок на 20–50 рабочих мест.

Как превратить записи разговоров Asterisk в текст на своём сервере и находить любой звонок за секунды — схема
Схема к статье. Открыть схему в полном размере

Whisper на своём железе: сколько стоит ночь распознавания и где модель врёт

Типичный вопрос директора: «А сервер под это нужен какой, неужели с видеокартой за полмиллиона?». Нет. Берём faster-whisper — реализацию Whisper на CTranslate2. По README проекта, она до 4 раз быстрее оригинального openai/whisper при той же точности и расходует меньше памяти, а 8-битная квантизация работает и на CPU, и на GPU. Установка — pip install faster-whisper на Python не ниже 3.9. FFmpeg ставить не нужно, декодирование делает PyAV. Для видеокарты нужны CUDA 12 и cuDNN 9; для старых окружений в README описан пин более ранних версий ctranslate2, так что смотрите свой случай там, а не в моей памяти.

Цифры лучше брать из измерений, а не из рекламы. Бенчмарк из README: 13 минут аудио, модель large-v2, видеокарта RTX 3070 Ti на 8 ГБ, beam 5. Оригинальный openai/whisper в fp16 справляется за 2 минуты 23 секунды и занимает 4708 МБ видеопамяти. faster-whisper в fp16 — 1 минута 03 секунды и 4525 МБ. В int8 — 59 секунд и 2926 МБ. А в пакетном режиме с batch_size=8 в int8 — 16 секунд при 4500 МБ. На CPU картина скромнее: на i7-12700K с 8 потоками и моделью small оригинал в fp32 тратит 6 минут 58 секунд и 2335 МБ памяти, faster-whisper int8 — 1 минуту 42 секунды и 1477 МБ, а int8 с batch 8 — 51 секунду и 3608 МБ. Простая арифметика: если 13 минут аудио обрабатываются за 59 секунд, то 12 часов разговоров на видеокарте в int8 — это порядка часа, а с пакетным режимом ещё в несколько раз быстрее. Но это мои пересчёты по README, а не гарантия для вашего сервера: у вас другая карта, другой звук и другой процессор.

Рабочий код воркера выглядит так. Модель грузим один раз при старте, а не на каждый звонок, иначе половина ночи уйдёт на загрузку:

import json, logging
from faster_whisper import WhisperModel

logging.basicConfig()
logging.getLogger("faster_whisper").setLevel(logging.INFO)

model = WhisperModel("large-v3", device="cuda", compute_type="int8_float16")

def recognize(path, who):
    segments, info = model.transcribe(
        path, beam_size=5, language="ru",
        vad_filter=True,
        vad_parameters=dict(min_silence_duration_ms=500),
        word_timestamps=True)
    # segments - генератор: распознавание идёт при переборе
    return [{"who": who, "start": round(s.start, 2),
             "end": round(s.end, 2), "text": s.text.strip()}
            for s in segments]

def process(base):
    rows = recognize(base + "-r.wav", "сторона r") + recognize(base + "-t.wav", "сторона t")
    rows.sort(key=lambda r: r["start"])
    with open(base + ".json", "w", encoding="utf-8") as f:
        json.dump(rows, f, ensure_ascii=False)
    with open(base + ".txt", "w", encoding="utf-8") as f:
        f.write("\n".join(r["who"] + ": " + r["text"] for r in rows))

Обратите внимание: segments — генератор, и пока вы по нему не прошли, ничего не распознано. Это частая ловушка: человек вызывает transcribe, видит мгновенный результат и радуется, а работа делается позже, на первом же цикле. Для больших объёмов в библиотеке есть BatchedInferencePipeline: в примере там batch_size=16, и VAD в нём включён по умолчанию. Модели large-v3, turbo и distil-large-v3 скачиваются с Hugging Face автоматически при первом запуске, поэтому серверу нужен доступ к huggingface.co один раз. Если сервер живёт в закрытом контуре, скачайте модель заранее и положите в локальный каталог.

Теперь о том, где модель врёт. Первое — звук. Телефонный канал 8 кГц (G.711) — худший вход, какой можно придумать для распознавания. Если транк и телефоны отдают широкополосный кодек, пишите в формат wav16 (расширение .wav16, 16 кГц) — обычный .wav в Asterisk это 8 кГц; на узкой полосе готовьтесь к ошибкам в фамилиях, названиях и цифрах. Номера телефонов и суммы я всегда рекомендую перепроверять по аудио, а не принимать текст на веру: модель услышала «пятнадцать» там, где сказали «пятьдесят», и это ровно та ошибка, ради которой потом звонят директору. Второе — галлюцинации на тишине: модель может «досочинить» фразу в паузе. Лечит vad_filter. Учтите, что VAD по умолчанию консервативный и режет только тишину дольше 2 секунд, поэтому min_silence_duration_ms=500 я задаю явно.

Третье — выбор модели. По моему опыту, русский язык на large-v3 распознаётся заметно лучше, чем на small или medium; если слово «обещали» превращается в «общали», поиск вас подведёт. Одна карта на 12–24 ГБ тянет large-v3 с запасом. CPU-вариант годится для десятков звонков в день, а не сотен: сотни разговоров на процессоре превращаются в очередь, которая не успевает разгрестись за ночь. Сравнение для типового случая смотрите на схеме ниже.

Распознанные цифры и фамилии — это гипотеза, а не документ. Для спорных ситуаций держите рядом ссылку на аудио и таймкод: в json у каждого сегмента есть start и end, по ним запись открывается ровно в нужном месте.
Если у вас записи звонков уже лежат на диске, а искать по ним невозможно, мы поставим на ваш сервер очередь, faster-whisper и поиск на PostgreSQL и прогоним пробные звонки, чтобы вы услышали качество на своих записях. Обсудить задачу
Цифры и версии: Whisper на своём железе: сколько стоит ночь распознавания и где модель врёт — схема
Цифры и версии: Whisper на своём железе: сколько стоит ночь распознавания и где модель врёт. Открыть схему в полном размере

Текст рядом с записью и полнотекстовый поиск в PostgreSQL

Вечный соблазн — положить результат куда-нибудь «пока поближе», например в таблицу CRM или в общую папку. Через полгода выясняется, что поиск по ней — это grep по тысячам файлов и десять минут ожидания. Я делаю по-другому. Рядом с записью лежит текст в том же пути с расширением .txt и .json — чтобы файлы переносились и архивировались вместе. А метаданные и сам текст дублируются в PostgreSQL: уникальный id звонка, номера, длительность, менеджер, время начала, путь к файлам. Файлы остаются источником истины, база — быстрым индексом, который при необходимости пересобирается из них.

Поиск делает штатный механизм PostgreSQL: тип tsvector, GIN-индекс и русский словарь 'russian'. Он приводит слова к основам, поэтому «обещали скидку», «обещал скидку» и «скидку обещали» находятся одним запросом. Для пользовательского ввода удобен websearch_to_tsquery: он понимает кавычки, слово «or» и минус, и им можно кормить строку поиска как есть. Таблица, индекс и запрос:

CREATE TABLE call_text (
    call_id     text PRIMARY KEY,
    started_at  timestamptz NOT NULL,
    src         text,
    dst         text,
    manager     text,
    duration_s  integer,
    txt_path    text,
    body        text NOT NULL,
    tsv         tsvector GENERATED ALWAYS AS (to_tsvector('russian', body)) STORED
);

CREATE INDEX call_text_tsv_idx ON call_text USING GIN (tsv);

SELECT call_id, started_at, manager,
       ts_headline('russian', body, q, 'MaxFragments=2') AS fragment
FROM call_text, websearch_to_tsquery('russian', 'обещали скидку') AS q
WHERE tsv @@ q
ORDER BY ts_rank(tsv, q) DESC
LIMIT 20;

Сгенерированный столбец требует PostgreSQL 12 или новее; на более старой версии tsvector придётся обновлять триггером. Проверьте в документации своей версии синтаксис и параметры ts_headline.

Теперь про то, что директор действительно увидит. Не psql, конечно. Хватает маленькой веб-страницы: строка поиска, фильтр по менеджеру и датам, результаты с фрагментом текста и кнопкой «послушать» на нужном таймкоде. Второй вариант — выгрузка в CRM по id звонка: тогда текст и ссылка появляются прямо в карточке клиента. Если у вас уже болит история, когда записи теряются на пути между АТС и CRM, как чинить пропажу записей в CRM мы разбирали отдельно, и всё, что там написано про общий id звонка, здесь пригодится как фундамент.

Два практических совета. Не индексируйте текст, пока распознавание не закончилось: частично заполненные записи ломают доверие к поиску, потому что директор не найдёт звонок и решит, что его не было. Пишите в базу в самом конце, одной транзакцией, а при сбое возвращайте файл в очередь. И второе: считайте объём заранее. Текст разговора на 4 минуты — это несколько килобайт, в пересчёте на 180 звонков в день вы набираете меньше мегабайта в сутки. Основной объём — аудио, а не текст, поэтому планируйте диск под записи, а не под базу.

152-ФЗ: запись и расшифровка тоже считаются обработкой персональных данных

Ситуация: проверяющий или недовольный клиент задаёт простой вопрос — «а на каком основании вы записываете разговоры и где они хранятся?». И выясняется, что в IVR нет ни слова о записи, к расшифровкам имеет доступ любой, кто знает адрес страницы, а удалять их никто не собирался. Расшифровка разговора — это такая же обработка персональных данных, как сама запись, только удобнее для поиска и потому опаснее при утечке. Требования 152-ФЗ к обработке ПД применимы к тексту так же, как к аудио.

Что нужно сделать, не вдаваясь в юридические дебри. Предупредить собеседника о записи в приветствии IVR. Разграничить доступ к текстам по ролям: менеджер видит свои разговоры, руководитель отдела — отдела, директор и ответственный за ПД — всё. Определить срок хранения и удалять записи и тексты по регламенту, а не «когда закончится диск». Вести журнал обращений к расшифровкам, чтобы понимать, кто и когда открывал спорный разговор. Если подробно интересует именно подготовка АТС к проверке, у нас есть чек-лист по подготовке Asterisk к проверке по 152-ФЗ, а здесь я добавлю только то, что относится к расшифровкам.

Теперь аргумент в пользу локальной схемы. Облачный сервис распознавания — это передача персональных данных третьему лицу: нужен отдельный договор, нужно понимать, где физически обрабатываются данные и кто имеет к ним доступ. Локальная обработка снимает вопрос трансграничной передачи и сокращает цепочку ответственных до вас и вашего администратора. Про выбор между своей АТС и облачной, с точки зрения требований закона и бюджета, мы писали в сравнении Asterisk или облачная АТС, а текущая тема — естественное продолжение: свои записи и свои расшифровки остаются у вас.

Остаётся техническая защита самого сервера: диск с записями и базой шифруется, доступ к страницам поиска идёт по HTTPS и через доменную учётку, резервные копии хранятся так же осторожно, как оригиналы. Это стандартные меры, и если вы хотите, чтобы их оформил и проверил специалист, к вашим услугам информационная безопасность и защита данных. Юридическую часть — формулировки согласий и политику обработки — всё равно согласуйте с вашим юристом: я пишу как администратор, а не как адвокат.

Кейс: оптовая компания «Пятый Причал»

К нам пришла оптовая компания «Пятый Причал»: 34 рабочих места, 7 менеджеров продаж, Asterisk 20 и примерно 180 разговоров в день со средней длительностью 4 минуты, то есть около 12 часов аудио ежедневно. Запись давно шла в смешанном GSM-файле, всё лежало на одном диске с системой. Главная боль формулировалась ровно так, как я описывал в начале: клиенты ссылались на обещания, которых менеджеры «не помнили», а директор тратил на выяснение до часа на каждый спор. Облачное распознавание они уже пробовали и отказались: персональные данные, плюс счёт за 720 минут в день не вызвал энтузиазма.

Мы собрали схему из этой статьи. В диалплане включили MixMonitor с раздельными дорожками r и t, wav вместо GSM, FILTER на caller ID и постановку в очередь через command. Сервер взяли с одной видеокартой на 12 ГБ; под такую нагрузку хватило бы и карты попроще. Модель large-v3 в режиме int8_float16: ночное окно в 12 часов аудио обрабатывается примерно за 1–1,5 часа, то есть с огромным запасом. Мы проверили и CPU-вариант на старом Xeon клиента: восемь потоков, модель small в int8 — те же 12 часов аудио заняли около 4–5 часов, втрое дольше, чем обещает бенчмарк README на свежем i7: старые серверные процессоры без быстрых векторных инструкций считают нейросети заметно медленнее. Работает, но запаса почти нет: пара совещаний с длинными звонками, и очередь не успевает к утру. Поэтому оставили видеокарту, а CPU-вариант — как запасной.

Текст сохраняется рядом с записью, метаданные и tsvector — в PostgreSQL с GIN-индексом. Для директора мы сделали простую страницу поиска за доменной авторизацией с ролями. Весь проект занял 3 рабочих дня: первый день — запись и очередь, второй — воркер и тестовые прогоны на живых звонках, третий — база, страница поиска и регламент хранения. На пробном прогоне выяснилось несколько неприятных мелочей: в нескольких звонках путаница с цифрами в номерах заказов из-за 8 кГц у транка; с оператором договорились о широкополосном кодеке для части линий, а запись перевели в wav16.

Итог. Директор вводит «обещали скидку» и за секунды получает список разговоров с фрагментами и ссылкой на нужный момент записи. Спорные ситуации с клиентами разбираются сразу, без часа поисков, и случаев, когда приходится уступать «на слово», стало кратно меньше. Менеджеры поначалу напряглись, потом заметили, что запись защищает и их: когда клиент перекручивает слова, ответ находится одним поиском. Никаких внешних сервисов, облачных ключей и счетов за минуты. Серверу нужно только обслуживание — от 7 000 ₽/мес, если не хочется заниматься им самим.

Хорошая метрика такой системы — не точность распознавания в процентах, а время, за которое директор находит нужный звонок. Было — час, стало — несколько секунд.
Цифры и версии: Кейс: оптовая компания «Пятый Причал» — схема
Цифры и версии: Кейс: оптовая компания «Пятый Причал». Открыть схему в полном размере

Частые вопросы

Можно ли расшифровывать звонки Asterisk без облака?

Да. MixMonitor пишет записи в файлы, а faster-whisper на вашем сервере превращает их в текст. Для большой нагрузки нужна видеокарта, для десятков звонков в день хватит процессора.

Какая видеокарта нужна для large-v3?

По практике, одна карта на 12–24 ГБ тянет large-v3 с запасом. В README faster-whisper модель large-v2 в int8 занимает порядка 2926 МБ видеопамяти, но проверьте нагрузку на своих записях.

Как отделить речь менеджера от речи клиента?

Пишите стороны в отдельные файлы опциями r(file) и t(file) в MixMonitor. Тогда кто говорил, определяется по файлу, а диаризация не нужна. Какой файл чей, проверьте тестовым звонком.

Подходит ли узкополосная телефонная запись 8 кГц?

Подходит, но ошибок будет больше, особенно в фамилиях и цифрах. Если транк отдаёт широкополосный кодек, пишите в формат wav16 (16 кГц, обычный .wav в Asterisk — 8 кГц), а номера и суммы перепроверяйте по аудио.

Является ли расшифровка звонка персональными данными?

Да. Запись и расшифровка — обработка персональных данных, на неё распространяются требования 152-ФЗ: уведомление о записи, роли доступа, срок хранения и удаление по регламенту.

Столкнулись с похожей задачей? Обращайтесь — решим

Если у вас происходит что-то из описанного в этой статье — или любая другая проблема с ИТ-инфраструктурой, — обращайтесь в любое время. Мои специалисты и я лично разберём ситуацию, найдём настоящую причину и доведём до решения.

Возьмёмся и за разовую задачу, и за постоянное обслуживание. Первичная консультация — бесплатно и без обязательств.

📞 +7 903 729-62-41 ✈ Telegram @ITfresh_Boss

С уважением, Семёнов Евгений Сергеевич, директор «АйТи Фреш» — IT-аутсорсинг для компаний до 50 рабочих мест, 15+ лет практики

Источники

© ООО «АйТи-Фреш» · Москва · Все статьи
АйТи-Фреш (ООО «АЙТИ-ФРЕШ») · г. Москва, Щёлковское шоссе, д. 92, корп. 7 · +7 903 729-62-41 · info@itfresh.ru · Пн–Пт 9:00–19:00