xk6-sip: проверка качества звука в нагрузочных и автоматизированных функциональных тестах VoIP/SIP

—

от автора

Разберём, как в xk6-sip, расширении нагрузочного инструмента k6 для тестирования VoIP/SIP-звонков, устроена проверка качества звука: запись звонка, оценка того, что услышал абонент, по эталону, и как это работает в функциональных тестах и под нагрузкой.

200 OK означает, что АТС соединила звонок, но не означает, что люди слышат друг друга. Исчерпанные порты медиасервера, ошибки NAT, перепутанные медиапотоки, транскодинг на пределе CPU дают звонок с успешной сигнализацией и тишиной, чужим голосом или хрипом в трубке. SIPp и большинство нагрузочных инструментов для телефонии проверяют сигнализацию, а RTP в лучшем случае считают пакетами. Для контакт-центра такой звонок — потерянный клиент, а в отчёте нагрузочного теста он зелёный.

В функциональных тестах телефонии эту проблему обычно решают эталонами: записывают, что должен услышать абонент в каждом кейсе, и сравнивают с записью звонка в CI. Подход рабочий, но эталон на каждый кейс нужно поддерживать, а под нагрузкой его не запустишь. В xk6-sip то же сравнение живёт прямо в скрипте k6 и работает на тысячах звонков.

В статье:

  1. Что проверяем: три уровня проверки звука.

  2. Запись звонка: почему звук раскладывается по RTP timestamp.

  3. Как считается оценка compareAudio().

  4. ViSQOL от Google: как устроен и почему он не внутри генератора.

  5. Проверка на цифрах.

  6. Функциональный тест для CI.

  7. Под нагрузкой: сколько памяти и CPU стоит проверка звука и почему дашборд показывал оценку 0,958 вместо 0,993.

  8. Ограничения и что творить дальше.

Что проверяем

«Качество звука» в телефонии — это три разных вопроса, и каждый ловит свои дефекты.

Вопрос

Чем проверяем

Что ловит

Звук вообще доходит?

isHeard(), метрика rtp_audio_heard

one-way audio (односторонняя слышимость), тишина или комфортный шум вместо речи

Тот ли звук и без искажений?

compareAudio() с фразой, которую шлёт собеседник

перепутанные потоки, эхо, потери и пропадания, съеденное начало фразы, искажения

АТС проиграла то, что надо?

compareAudio() с записанным эталоном

не то меню 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% потерь: левый канал с дырами на месте потерянных пакетов, правый — эталон

Стереозапись звонка с 20% потерь: левый канал с дырами на месте потерянных пакетов, правый — эталон

Так выглядит запись с 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, без внешних библиотек.

  1. Грубое выравнивание. Эталон нужно сначала найти в записи: сетевая задержка неизвестна, а фраза повторяется по кругу с произвольного места. Оба сигнала режутся на блоки по 10 мс, для каждого считается уровень в дБ, и эта «огибающая громкости» эталона ищется в записи по максимуму коэффициента корреляции Пирсона. Огибающая речи — слоги и паузы — переживает кодек и смену громкости, а считается в 80 раз дешевле поиска по отсчётам.

  2. Точное выравнивание. В окрестности ±10 мс от найденной точки сдвиг уточняется до одного отсчёта по нормированной корреляции формы волны на первой секунде эталона.

  3. Спектрограммы. Эталон и выровненный кусок записи переводятся в спектрограммы: окна по 32 мс с шагом 16 мс, окно Ханна, БПФ на 256 точек, мощность в дБ в телефонной полосе 300–3400 Гц. Сравниваются только кадры, где в эталоне есть речь: не тише − 55 дБFS и не дальше 40 дБ от самого громкого кадра.

  4. Корреляция изменений. Из каждой частотной полосы вычитается её среднее за время, и две спектрограммы коррелируются как два больших вектора. Итог — 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:

  1. Выравнивание — сначала общий сдвиг, потом уточнение по кускам.

  2. Слуховая спектрограмма на банке гамматоновых фильтров — модель улитки внутреннего уха: полосы узкие на низах и широкие на верхах, как их различает человек.

  3. Участки (patches). Эталон режется на короткие куски, и для каждого в записи ищется самый похожий кусок рядом. Так алгоритм не штрафует локальные сдвиги и растяжения от джиттер-буфера.

  4. NSIM (Neurogram Similarity Index Measure) — аналог SSIM из обработки изображений: сравнивает участки по яркости и структуре в каждой полосе.

  5. Перевод в MOS — функцией, откалиброванной на оценках живых слушателей.

В xk6-sip та же идея в упрощённом виде: линейные полосы БПФ вместо гамматонов, одно выравнивание на всю фразу вместо поиска по участкам, одна корреляция вместо NSIM и без калибровки на слушателях. Поэтому score — это не MOS, а мера похожести для сравнения звонков, прогонов и версий АТС между собой. Порог берётся из чистого эталонного прогона.

Почему не встроить оригинал. Лицензия это позволяет, дело в цене интеграции:

Вариант

Цена

Плюсы и минусы

ViSQOL в Docker отдельным шагом CI по сохранённым WAV

1–2 дня

эталонная реализация, настоящий MOS; оценка после прогона, а не во время

Вызов бинарника из k6 прямо в скрипте

2–3 дня

MOS в check(); бинарник нужен на каждом генераторе, сборка под Windows — отдельная задача

C++-библиотека внутри k6 через cgo

высокая

ломает простую сборку xk6 build: нужен C++-тулчейн и Bazel на каждой платформе

Порт на 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])))
Панели качества звука на дашборде xk6-sip

Панели качества звука на дашборде xk6-sip

Пробный прогон: 4 VU, 70 с, каждое пятое сравнение — с чужим эталоном. Слева медиана и худшие 5% оценок с линией 0,9, справа — доля сравнений ниже 0,9 и число сравнений в секунду.

Панели качества звука на чистом прогоне в CI

Панели качества звука на чистом прогоне в CI

Те же панели на чистом прогоне в 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 минутами.

Что дальше:

  1. ViSQOL в CI отдельным шагом по сохранённым WAV — настоящий MOS там, где он нужен.

  2. Тоновые подписи «кто кого слышит». Каждый абонент шлёт свою комбинацию частот, а детектор Гёрцеля проверяет её за доли миллисекунды. Дешёвая проверка перепутанных потоков на каждом звонке под полной нагрузкой.

  3. Распознавание речи для IVR — проверять содержание промпта без записанного эталона.

  4. Моно и сжатие записей — вдвое и в десятки раз меньше места на диске.

Код, документация и дашборд — в репозитории github.com/Dmitry-Fedotov-Dev/xk6-sip:

Предыдущие статьи серии:

ссылка на оригинал статьи https://habr.com/ru/articles/1088230/