Разберём, как в xk6-sip, расширении нагрузочного инструмента k6 для тестирования VoIP/SIP-звонков, устроена проверка качества звука: запись звонка, оценка того, что услышал абонент, по эталону, и как это работает в функциональных тестах и под нагрузкой.
200 OK означает, что АТС соединила звонок, но не означает, что люди слышат друг друга. Исчерпанные порты медиасервера, ошибки NAT, перепутанные медиапотоки, транскодинг на пределе CPU дают звонок с успешной сигнализацией и тишиной, чужим голосом или хрипом в трубке. SIPp и большинство нагрузочных инструментов для телефонии проверяют сигнализацию, а RTP в лучшем случае считают пакетами. Для контакт-центра такой звонок — потерянный клиент, а в отчёте нагрузочного теста он зелёный.
В функциональных тестах телефонии эту проблему обычно решают эталонами: записывают, что должен услышать абонент в каждом кейсе, и сравнивают с записью звонка в CI. Подход рабочий, но эталон на каждый кейс нужно поддерживать, а под нагрузкой его не запустишь. В xk6-sip то же сравнение живёт прямо в скрипте k6 и работает на тысячах звонков.
В статье:
-
Что проверяем: три уровня проверки звука.
-
Запись звонка: почему звук раскладывается по RTP timestamp.
-
Как считается оценка
compareAudio(). -
ViSQOL от Google: как устроен и почему он не внутри генератора.
-
Проверка на цифрах.
-
Функциональный тест для CI.
-
Под нагрузкой: сколько памяти и CPU стоит проверка звука и почему дашборд показывал оценку 0,958 вместо 0,993.
-
Ограничения и что творить дальше.
Что проверяем
«Качество звука» в телефонии — это три разных вопроса, и каждый ловит свои дефекты.
|
Вопрос |
Чем проверяем |
Что ловит |
|---|---|---|
|
Звук вообще доходит? |
|
one-way audio (односторонняя слышимость), тишина или комфортный шум вместо речи |
|
Тот ли звук и без искажений? |
|
перепутанные потоки, эхо, потери и пропадания, съеденное начало фразы, искажения |
|
АТС проиграла то, что надо? |
|
не то меню IVR, не та фраза автоинформатора, молчащая музыка на удержании |
Первый уровень в xk6-sip был и раньше: isHeard() смотрит на уровень сигнала, а не на факт прихода пакетов, поэтому поток из тишины его не обманет. Но он не отличит голос собеседника от чужого: если АТС соединила медиа не с тем абонентом, звук есть, и isHeard() вернёт true. Два других уровня требуют сравнения с эталоном, а для этого звук нужно сначала записать.
Главное отличие от подхода «эталон на каждый кейс»: то, что говорит каждый абонент, мы и так знаем — это его опция audio в скрипте. Эталоном для «что услышал B» служит то, что отправил A. Записанные эталоны нужны только там, где звук генерирует сама АТС.
Запись звонка
Запись включается опцией record на любом уровне — для всего теста, абонента или одного звонка. По умолчанию она выключена и ничего не стоит.
sip.options({ record: 'onFailure', recordDir: 'records' }); // весь тест: сохранять проваленныеconst B = new sip.Device({ /* ... */ record: true }); // один абонентA.call({ callee: B, record: true }); // один звонокinc.saveRecording('records/b.wav'); // левый канал — что B слышал, правый — что говорил
Стерео, а не моно. В левый канал пишется принятый звук, в правый — отправленный, на одной шкале времени. В аудиоредакторе сразу видно эхо (своя фраза вернулась в левый канал) и задержку между репликами.
Раскладка по RTP timestamp. Проще всего складывать пакеты в буфер по порядку прихода, но тогда запись врёт: потерянный пакет сдвигает всё, что после него, на 20 мс, а перепутанные джиттером пакеты меняются местами. Поэтому каждый кадр кладётся на позицию своего timestamp:
-
первый кадр нового SSRC привязывается к времени прихода относительно начала записи;
-
каждый следующий — со сдвигом
ts − ts0от него, включая переполнение 32-битного счётчика; -
дыра на месте потерянного пакета остаётся тишиной.
В итоге пропадания звучат в файле ровно там, где они случились, а алгоритм сравнения получает честную хронологию.
Так выглядит запись с 20% потерянных пакетов — тот же случай, что в таблице ниже (score 0,754, пропуски 560 мс). Левый канал отстаёт от правого на задержку сети, а каждая потеря — дыра на своём месте, а не сдвиг всего, что после неё.
Режимы. record: true держит запись в памяти для saveRecording() и compareAudio(), а с recordDir ещё и сохраняет каждый звонок. record: 'onFailure' сохраняет на диск только звонки с проваленным ожиданием (expect*, isHeard) или оборванные ошибкой. После прогона в CI в артефактах лежат ровно те звонки, которые нужно послушать.
Как считается оценка
compareAudio(ref) отвечает на вопрос «насколько то, что услышал абонент, похоже на эталон» и возвращает число от 0 до 1 с диагностикой:
const q = inc.compareAudio(hello);// { score: 0.993, offset: 1042, compared: 3000, gaps: 0, clippedStart: 0, gain: -0.3 }
Алгоритм — четыре шага, всё на чистом Go, без внешних библиотек.
-
Грубое выравнивание. Эталон нужно сначала найти в записи: сетевая задержка неизвестна, а фраза повторяется по кругу с произвольного места. Оба сигнала режутся на блоки по 10 мс, для каждого считается уровень в дБ, и эта «огибающая громкости» эталона ищется в записи по максимуму коэффициента корреляции Пирсона. Огибающая речи — слоги и паузы — переживает кодек и смену громкости, а считается в 80 раз дешевле поиска по отсчётам.
-
Точное выравнивание. В окрестности ±10 мс от найденной точки сдвиг уточняется до одного отсчёта по нормированной корреляции формы волны на первой секунде эталона.
-
Спектрограммы. Эталон и выровненный кусок записи переводятся в спектрограммы: окна по 32 мс с шагом 16 мс, окно Ханна, БПФ на 256 точек, мощность в дБ в телефонной полосе 300–3400 Гц. Сравниваются только кадры, где в эталоне есть речь: не тише − 55 дБFS и не дальше 40 дБ от самого громкого кадра.
-
Корреляция изменений. Из каждой частотной полосы вычитается её среднее за время, и две спектрограммы коррелируются как два больших вектора. Итог —
score.

После выравнивания путь делится: оценка считается по спектрограммам, а пропуски и громкость — по блокам по 10 мс.
Шаг 4 — главное решение. Если коррелировать спектры как есть, две разные фразы похожего голоса получают высокую оценку: у любой речи схожий средний тембр — больше энергии на низах, меньше на верхах. После вычитания среднего сравнивается то, как спектр меняется во времени: когда начинается слог, куда уходит тон. У чужой фразы этот рисунок другой, и оценка падает ниже 0,4.
Ещё три свойства следуют из этой схемы:
-
Громкость не влияет. Усиление в дБ — это сдвиг всей спектрограммы, и вычитание среднего его убирает. Разница уровней вынесена отдельно в
gain. -
Пропадания штрафуются. Тишина в записи — это провал до нижнего пола во всех полосах там, где эталон звучит. Пропуски (
gaps) считаются отдельно на блоках по 10 мс: кадр спектрограммы в 32 мс шире потерянного пакета и прятал бы одиночные потери. -
Съеденное начало (
clippedStart) — пропуски от начала речи до первого услышанного блока. Классический дефект телефонии: медиа подключается с задержкой после ответа, и абонент не слышит «Алло».
Если эталон стационарный (синус, который xk6-sip шлёт по умолчанию), после вычитания среднего от него ничего не остаётся. Для такого случая сравнивается форма спектра: тон 1 кГц отличается от 440 Гц, но осмысленная оценка качества получается только на речи.
ViSQOL от Google и почему он не внутри генератора
Идея compareAudio() взята у ViSQOL (Virtual Speech Quality Objective Listener) — открытого алгоритма Google под лицензией Apache-2.0. Он работает с эталоном (full-reference) и выдаёт MOS от 1 до 5 — оценку по шкале, как если бы звонок оценивали живые слушатели.
Как устроен ViSQOL:
-
Выравнивание — сначала общий сдвиг, потом уточнение по кускам.
-
Слуховая спектрограмма на банке гамматоновых фильтров — модель улитки внутреннего уха: полосы узкие на низах и широкие на верхах, как их различает человек.
-
Участки (patches). Эталон режется на короткие куски, и для каждого в записи ищется самый похожий кусок рядом. Так алгоритм не штрафует локальные сдвиги и растяжения от джиттер-буфера.
-
NSIM (Neurogram Similarity Index Measure) — аналог SSIM из обработки изображений: сравнивает участки по яркости и структуре в каждой полосе.
-
Перевод в MOS — функцией, откалиброванной на оценках живых слушателей.
В xk6-sip та же идея в упрощённом виде: линейные полосы БПФ вместо гамматонов, одно выравнивание на всю фразу вместо поиска по участкам, одна корреляция вместо NSIM и без калибровки на слушателях. Поэтому score — это не MOS, а мера похожести для сравнения звонков, прогонов и версий АТС между собой. Порог берётся из чистого эталонного прогона.
Почему не встроить оригинал. Лицензия это позволяет, дело в цене интеграции:
|
Вариант |
Цена |
Плюсы и минусы |
|---|---|---|
|
ViSQOL в Docker отдельным шагом CI по сохранённым WAV |
1–2 дня |
эталонная реализация, настоящий MOS; оценка после прогона, а не во время |
|
Вызов бинарника из k6 прямо в скрипте |
2–3 дня |
MOS в |
|
C++-библиотека внутри k6 через cgo |
высокая |
ломает простую сборку |
|
Порт на Go |
1–2 недели |
работает везде без зависимостей; нужно доказать совпадение оценок с оригиналом |
Есть и два технических нюанса. Режим речи в ViSQOL рассчитан на 16 кГц, а G.711 — узкая полоса 8 кГц: запись придётся пересэмплировать, и потолок оценки даже при идеальной передаче будет ниже, чем для широкополосного звука. И слуховая модель заметно тяжелее наших ~15 мс на сравнение, так что на каждом звонке под нагрузкой её не запустишь.
Итоговое разделение труда: быстрая оценка на Go внутри генератора — для нагрузки и быстрых проверок в CI, а эталонный ViSQOL — отдельным шагом по сохранённым записям, когда нужен именно MOS.
Проверка на цифрах
Оценка разводит чистый звук, потери и чужую речь далеко друг от друга, а пропуски считаются точно до блока. Синтетические случаи — из юнит-тестов: речеподобный сигнал (слоги с плавающим основным тоном и гармониками, шумные согласные, паузы), пропущенный через кодек G.711. Реальные звонки — через тестовую АТС (регистратор и B2BUA) по сети.
|
Случай |
score |
gaps |
Источник |
|---|---|---|---|
|
Чистый G.711, задержка 421 мс |
0,995 |
0 мс |
юнит-тест |
|
То же, тише на 12 дБ |
≥ 0,93, gain − 12 дБ |
0 мс |
юнит-тест |
|
20% пакетов потеряно |
0,754 |
560 из 560 мс потерянной речи |
юнит-тест |
|
Съедены 300 мс первого слова |
— |
clippedStart ~300 мс |
юнит-тест |
|
Чужая речь |
< 0,4 |
— |
юнит-тест |
|
Тишина вместо речи |
0 |
вся фраза |
юнит-тест |
|
B слышит фразу A через АТС |
0,993 |
0 мс |
звонок |
|
A слышит фразу B |
0,994 |
0 мс |
звонок |
|
То, что услышал B, против его собственной фразы |
0,113 |
— |
звонок |
Два наблюдения.
Пропуски считаются точно не сразу. В первой версии они считались по кадрам спектрограммы, и 20% потерь давали всего 112 мс пропусков: кадр в 32 мс шире пакета в 20 мс, и одиночная потеря лишь приглушает его. После перехода на блоки по 10 мс тест стал сравнивать gaps с реально потерянной речью, а не просто проверять gaps > 0. Сейчас расхождение нулевое.
Обратная проверка нужна так же, как прямая. Высокая оценка сама по себе ничего не доказывает: она могла бы ставить 0,99 чему угодно. Оценка 0,113 для собственной фразы B на том же звонке показывает, что алгоритм различает речь, а не угадывает «звук есть».
Функциональный тест для CI
Сценарий audio-quality.js: A и B произносят каждый свою фразу, и каждый должен чисто услышать фразу собеседника и не услышать себя.
const phraseA = sip.audio(open('./refs/phrase-a.wav', 'b'));const phraseB = sip.audio(open('./refs/phrase-b.wav', 'b'));const minScore = Number(__ENV.MIN_SCORE || 0.9);sip.options({ record: 'onFailure', recordDir: 'recordings' });const A = device('A', 1, { audio: phraseA }); // A говорит фразу Aconst B = device('B', 2, { audio: phraseB }); // B — фразу Bfunction hears(who, leg, ref, whose) { const q = step(`${who} audio compared`, leg.compareAudio(ref)); audioStep(leg, `${who} hears ${whose} clearly (score ${q.score})`, q.score >= minScore); audioStep(leg, `${who}: no drop-outs (${q.gaps} ms)`, q.gaps < 100); audioStep(leg, `${who}: first word not clipped (${q.clippedStart} ms)`, q.clippedStart < 60);}export default function () { const [out, inc] = call(A, B); // звонок, ответ, isHeard в обе стороны sleep(4); // фраза длиной 3 с успевает прозвучать целиком hears('B', inc, phraseA, "A's phrase"); hears('A', out, phraseB, "B's phrase"); const self = step('B own phrase compared', inc.compareAudio(phraseB)); audioStep(inc, `B does not hear itself (score ${self.score})`, self.score < 0.5); inc.hangup(); step('A disconnected', out.expectDisconnected('5s'));}
Как это работает в CI:
-
step()записывает проверку под понятным именем и останавливает сценарий на первом провале. k6 выходит с ненулевым кодом и пишет JUnit-отчёт. -
Цифры вписаны в имя проверки, поэтому в отчёте видно не только «не прошло», но и «score 0.71».
-
audioStep()при провале сначала сохраняет WAV этого плеча, а потом останавливает тест. ВстроенныйonFailureреагирует только на ожидания расширения, а проверкаq.score >= 0.9— это логика скрипта, о ней расширение не знает.
На тестовой АТС сценарий проходит 16 проверок из 16. Если завысить порог до 0,999, он падает с кодом 99 и оставляет WAV звонка в recordings/. Против настоящей АТС абоненты передаются через -e REGISTRAR=... -e A_USER=..., а при транскодинге порог снижается через -e MIN_SCORE=0.8.
В GitHub Actions записи проваленных звонков забираются в артефакты сборки вместе с JUnit-отчётами — шаг с if: always(), чтобы он работал и на красной сборке:
- name: keep reports if: always() uses: actions/upload-artifact@v7 with: name: functional-reports path: reports/ # JUnit и reports/recordings/*.wav if-no-files-found: ignore
Под нагрузкой: память, CPU и гистограммы Prometheus
Проверка звука работает и под нагрузкой, но у неё есть цена, и главная статья расходов — память, а не CPU.
Память. Запись одного плеча — это две дорожки 16-битных отсчётов по 8 кГц, то есть 32 КБ на секунду звонка, и она держится в памяти весь звонок.

|
Записываемых плеч одновременно |
Длина звонка |
Память |
|---|---|---|
|
100 |
30 с |
~94 МБ |
|
1 000 |
30 с |
~1 ГБ |
|
1 000 |
3 мин |
~5,6 ГБ |
Если записываются обе стороны, 1 000 звонков — это 2 000 плеч и около 2 ГБ. Поэтому на нагрузке запись включается на выборку абонентов, а сохраняется в режиме onFailure. Без record ничего этого нет: на каждый RTP-пакет остаётся одна проверка указателя на nil.
CPU. Одно сравнение 3-секундного эталона с 30-секундной записью занимает ~15 мс на настольном процессоре (бенчмарк BenchmarkCompare). Время растёт с длиной всей записи: грубое выравнивание перебирает все сдвиги. Тысяча сравнений в секунду заняла бы около 15 ядер, так что сравнивать стоит выборку звонков и сразу после фразы, а не в конце длинного разговора.
Ловушка нативных гистограмм. Каждый вызов compareAudio() пишет оценку в метрику rtp_audio_score, и порог в k6 ставится обычно: rtp_audio_score: ['p(95)>0.9']. С дашбордом Grafana вышло интереснее. В пробном прогоне все звонки получили ровно 0,993, а Prometheus показал медиану 0,958 и 5-й перцентиль 0,921.
Причина — устройство нативных гистограмм. Их корзины растут экспоненциально, и со схемой, которую отдаёт k6, соседние границы отличаются примерно на 9%. Около единицы одна корзина покрывает весь диапазон 0,917–1,0, и histogram_quantile интерполирует внутри неё вслепую. Для времён ответа это незаметно, а для оценки, где вся разница между «хорошо» и «плохо» лежит между 0,9 и 1,0, критично.
Решение — вторая метрика rtp_audio_mismatch = 1 − score. Около нуля экспоненциальные корзины мелкие, и дашборд пересчитывает оценку обратно:
# медиана и худшие 5% звонков1 - histogram_quantile(0.5, sum(rate(k6_rtp_audio_mismatch{testid=~"$testid"}[$window])))1 - histogram_quantile(0.95, sum(rate(k6_rtp_audio_mismatch{testid=~"$testid"}[$window])))# доля сравнений ниже 0,9histogram_fraction(0.1, +Inf, sum(rate(k6_rtp_audio_mismatch{testid=~"$testid"}[$window])))
Пробный прогон: 4 VU, 70 с, каждое пятое сравнение — с чужим эталоном. Слева медиана и худшие 5% оценок с линией 0,9, справа — доля сравнений ниже 0,9 и число сравнений в секунду.
Те же панели на чистом прогоне в CI: 20 VU, 2 минуты, сравнение звука в каждом звонке. Оценка держится на 0,993 над линией порога, доля сравнений ниже 0,9 — ноль, сравнений — 3–4 в секунду. Ось оценки показывает 0,8–1,0 и сама расширяется вниз, когда оценки падают, как на предыдущем скриншоте.
После этого медиана на дашборде совпала с k6 до третьего знака: 0,993. В прогоне, где каждое пятое сравнение намеренно делалось с чужим эталоном, панель «ниже 0,9» показала 17–25%, а худшие 5% — около 0,02. Правило, которое осталось в проекте: величину, которая скапливается у верхней границы, в нативную гистограмму отдают как расстояние до этой границы.
Ограничения и что дальше
Что нужно знать, прежде чем опираться на оценку:
-
Это не MOS.
scoreне откалиброван на оценках слушателей. Он годится для сравнения звонков и версий между собой, а порог берётся из чистого прогона. -
Только G.711. Транскодинг в G.729 или Opus меняет форму волны и снижает оценку. Спектральное сравнение к этому устойчивее поотсчётного, но порог для такого пути нужно снижать.
-
Эталоны в примере синтетические — речеподобный сигнал без лицензионных вопросов. Для боевых тестов их лучше заменить своими записями на 3–5 секунд.
-
В
rtp_audio_scoreпопадают и намеренные обратные проверки вроде «B не слышит себя». В скриптах с порогами по этой метрике их лучше не смешивать. -
Запись держится в памяти до конца итерации; одна дорожка ограничена 15 минутами.
Что дальше:
-
ViSQOL в CI отдельным шагом по сохранённым WAV — настоящий MOS там, где он нужен.
-
Тоновые подписи «кто кого слышит». Каждый абонент шлёт свою комбинацию частот, а детектор Гёрцеля проверяет её за доли миллисекунды. Дешёвая проверка перепутанных потоков на каждом звонке под полной нагрузкой.
-
Распознавание речи для IVR — проверять содержание промпта без записанного эталона.
-
Моно и сжатие записей — вдвое и в десятки раз меньше места на диске.
Код, документация и дашборд — в репозитории github.com/Dmitry-Fedotov-Dev/xk6-sip:
Предыдущие статьи серии:
-
xk6-sip: SIP-телефония как код — обзор инструмента, устройство и примеры звонковых JS сценариев
-
xk6-sip: мониторинг нагрузочного тестирования VoIP/SIP‑звонков — про observability инструмента
ссылка на оригинал статьи https://habr.com/ru/articles/1088230/