Почему звук — это больно

Три предыдущие статьи были про картинку. Картинка — вещь терпимая: можно сжать, можно потерять кадр, можно догнать следующим. Человеческий глаз прощает многое.

Со звуком всё иначе.

150 миллисекунд задержки — и собеседник начинает перебивать. 200 — и вы оба замолкаете, ждёте, потом говорите одновременно. 300 — созвон превращается в пытку. Это не абстрактные цифры из RFC, это реальность, которую каждый испытывал на плохом Zoom-звонке. (Кстати, порог в 150 мс — это ITU-T G.114, одна из самых цитируемых рекомендаций в телеком-индустрии. Не потому что магическое число, а потому что дальше начинаются перебивания.)

А теперь представьте: вы построили VDI, развернули 500 рабочих мест, люди довольны. И тут приходит запрос от call-центра: «Мы хотим работать через VDI тоже». Или от отдела продаж: «Нам нужен софтфон в виртуалке».

Вот тут начинается отдельная история.

Как устроен звук в SPICE

Два канала — два направления

SPICE использует раздельные каналы для воспроизведения и записи:

Playback Channel (тип 5 в протоколе) — звук от гостевой ОС к клиенту. Музыка из браузера, системные уведомления, голос собеседника в мессенджере — всё идёт сюда.

Record Channel (тип 6) — микрофон клиента в гостевую ОС. То, что вы говорите, отправляется в обратном направлении.

Каналы независимы. У каждого своё TCP-соединение, свой набор сообщений, своя логика. Это не случайность — синхронизация между ними нетривиальна, и разделение упрощает управление потоками. В исходниках spice-gtk за них отвечают отдельные файлы: channel-playback.c и channel-record.c, на стороне сервера — sound.cpp с раздельными классами PlaybackChannelClient и RecordChannelClient.

Архитектура аудио в SPICE — Playback и Record каналы, потоки данных между гостем, хостом и клиентом
Архитектура аудио в SPICE — Playback и Record каналы, потоки данных между гостем, хостом и клиентом

От CELT к Opus: эволюция кодека

Когда Red Hat открыл исходники SPICE в декабре 2009-го, для сжатия аудио использовался CELT 0.5.1 — экспериментальный кодек от Xiph.Org с низкой задержкой. Выбор был логичен: CELT проектировался для real-time, давал низкую алгоритмическую задержку, работал на скромных битрейтах. Версию зафиксировали именно на 0.5.1, потому что формат битстрима CELT ломался между релизами — зависимость была прибита к >= 0.5.1.1 и < 0.6.0.

Проблема в том, что CELT 0.5.1 так и остался экспериментальным. В 2012 году Xiph.Org объединил наработки CELT и SILK (кодек от Skype, заточенный под голос) в Opus — стандарт IETF, RFC 6716 от сентября 2012. Старый CELT заморозили.

В протоколе SPICE режим CELT 0.5.1 формально остался, но помечен SPICE_GNUC_DEPRECATED_ENUMERATOR. В серверном коде (snd_desired_audio_mode()) CELT вообще недостижим — функция выбирает только между Opus и RAW. Клиент при согласовании тоже заявляет только SPICE_RECORD_CAP_OPUS. Если у вас что-то ещё работает на CELT — это артефакт совсем старой инсталляции.

SPICE_AUDIO_DATA_MODE_RAW = 1        // PCM без сжатия
SPICE_AUDIO_DATA_MODE_CELT_0_5_1 = 2 // deprecated, в коде мёртвая ветка
SPICE_AUDIO_DATA_MODE_OPUS = 3       // единственный реальный вариант со сжатием

Почему Opus

Opus — гибрид, который объединяет два подхода:

SILK-часть — линейное предсказание (LPC), оптимизированное для голоса. Работает в диапазоне примерно 6-40 kbps (в standalone-версии SILK от Skype так и было; внутри Opus SILK-mode используется до ~32 kbps, дальше подключается гибридный режим).

CELT-часть — MDCT-преобразование, как в музыкальных кодеках. Широкий спектр: музыка, шумы, сложные сигналы.

Гибридный режим — голос кодируется SILK, высокие частоты добивает CELT. Переключение между режимами автоматическое, без щелчков.

Discord, WhatsApp, PlayStation Network используют Opus. Zoom — отдельная история: там исторически основной кодек SILK (тот самый, от Skype, не Opus-SILK), а Opus используется в WebRTC-сценариях. Но в целом Opus стал де-факто стандартом для real-time аудио в интернете. Не потому что модно — потому что альтернативы хуже.

Параметры, которые важны для VDI-контекста:

  • Алгоритмическая задержка: 26.5 мс по умолчанию (20 мс фрейм + 5 мс SILK lookahead + 1.5 мс ресемплинг). Можно снизить до 5 мс в restricted low-delay mode, но SPICE этого не делает

  • Битрейт: 6-510 kbps

  • Размер фрейма: 2.5, 5, 10, 20, 40 или 60 мс

  • FEC (Forward Error Correction) — может встроить информацию о предыдущем фрейме в текущий пакет

Про FEC стоит сказать отдельно. На сайте opus-codec.org есть демо, где речь остаётся разборчивой при 30% случайных потерь с включённым FEC. Звучит впечатляюще, но это best-case сценарий с равномерно распределёнными потерями. FEC в Opus во��станавливает только непосредственно предыдущий фрейм. При burst loss (когда теряется несколько пакетов подряд, что типично для TCP-ретрансмитов) — он бессилен. Не стоит на это рассчитывать как на гарантию.

Для сравнения: G.711 (классика телефонии, стандарт 1972 года) требует ровно 64 kbps и не имеет FEC. Opus даёт сопоставимое качество голоса на 16 kbps.

Pipeline: откуда берётся задержка

Прежде чем жаловаться на «плохой звук в VDI», полезно понять, через что проходит аудиосигнал.

Playback (гость → клиент)

Приложение в госте

Виртуальное аудиоустройство (HDA)

QEMU (захватывает PCM)

spice-server (кодирует Opus)

[сеть — TCP]

spice-gtk (декодирует Opus)

GStreamer pipeline → PulseAudio/PipeWire

Колонки/наушники

Каждый шаг добавляет задержку. Кодирование — десятки миллисекунд. Сеть — зависит от маршрута. Аудиоподсистема клиента — ещё миллисекунды.

Есть нюанс, который легко прочитать неправильно. В spice-gtk определена константа SPICE_PLAYBACK_DEFAULT_LATENCY_MS = 200. Документация называет это min-latency у SpicePlaybackChannel. Но это не jitter buffer в классическом смысле. В spice-gtk нет собственной реализации буфера сглаживания. Эти 200 мс — hint, который передаётся серверу через spice_playback_channel_set_delay(), чтобы сервер корректно выставлял timestamps. Реальная буферизация происходит ниже — в GStreamer pipeline и далее в PulseAudio/PipeWire. Какой там буфер на самом деле — зависит от конфигурации аудиостека клиента.

(Ещё деталь: свойство min-latency объявлено как READWRITE, но в set_property для него нет обработчика — попытка выставить его через g_object_set() даст warning. Фактически оно меняется только сервером через SPICE_MSG_PLAYBACK_LATENCY. Возможно, баг. Возможно, так и задумано. Документация молчит.)

Record (клиент → гость)

Микрофон

Аудиоподсистема клиента

spice-gtk (захватывает PCM, кодирует Opus)

[сеть — TCP]

spice-server (декодирует)

QEMU → виртуальное аудиоустройство

Приложение в госте

Путь симметричный, но есть нюанс: микрофонный сигнал проходит через echo cancellation на стороне приложения (если оно его поддерживает). А в VDI echo cancellation — отдельная головная боль, о которой ниже.

Полный audio pipeline с указанием типичных задержек на каждом этапе
Полный audio pipeline с указанием типичных задержек на каждом этапе

Буферизация и задержка: что происходит на самом деле

Jitter — вариация времени доставки пакетов. Отправляете каждые 20 мс, приходят через 18, 25, 19, 31, 17 мс. Без буферизации — заикания и щелчки.

Классическое решение — jitter buffer: накопить пакеты, выдавать равномерно. Больше буфер — лучше сглаживание, больше задержка.

В случае SPICE буферизация устроена нетипично. Сам spice-gtk не держит аудиобуфер. Он эмитит сигнал playback-data и отдаёт фреймы в GStreamer pipeline (appsrc → queue → audioconvert → audioresample → autoaudiosink). Никаких настроек buffer-time или latency-time в pipeline не выставлено — GStreamer и аудиобекенд разбираются сами. Раз в секунду spice-gtk опрашивает фактическую задержку pipeline и сообщает серверу — но это чисто информационное: никакой адаптивной подстройки не происходит.

Хинт min-latency = 200 влияет на то, как сервер таймстемпит аудиофреймы. Для LAN это значение избыточно — можно уменьшить. Но если PulseAudio на клиенте сконфигурирован с большим буфером, уменьшение min-latency ничего не даст — реальная задержка определяется аудиостеком, а не этим хинтом.

На стороне сервера буферизация минимальна: пул из 3 фреймов (NUM_AUDIO_FRAMES 3), один pending_frame. Если новый фрейм приходит, а старый не отправлен — старый отбрасывается.

TCP: слон в комнате

Помните из первой статьи? SPICE работает поверх TCP. Никакого UDP. Это подтверждается по всем трём репозиториям проекта: setsockopt(..., TCP_NODELAY, ...) в клиенте, IPTOS_LOWDELAY в сервере, нигде нет SOCK_DGRAM.

Для картинки это терпимо. Для звука — проблема.

Почему UDP лучше для real-time audio

UDP не гарантирует доставку. Пакет потерялся — идём дальше. Для голоса это нормально: лучше потерять 20 мс речи, чем ждать ретрансмита и сдвинуть весь поток.

RTP (Real-time Transport Protocol), который используют SIP-телефония и WebRTC, работает поверх UDP именно поэтому. Opus спроектирован с учётом потерь: FEC помогает при случайных единичных пропусках, а PLC (Packet Loss Concealment) в декодере интерполирует пропущенные участки.

Что происходит с TCP

TCP гарантирует доставку и порядок. Пакет потерялся — TCP делает ретрансмит, и пока потерянный пакет не доставлен, все последующие данные стоят в очереди на принимающей стороне. Это называют по-разному — head-of-line blocking, retransmission stall — суть одна: аудиопоток прерывается не на длительность одного потерянного пакета, а на время его повторной доставки плюс всё, что за ним скопилось.

При packet loss 1-2% в WAN это становится заметно. При 5% — невыносимо. Throughput TCP при 1% потерь падает драматически (есть исследования, показывающие снижение пропускной способности в разы), а для real-time аудио критичен не throughput, а стабильность задержки.

Тема UDP в контексте SPICE всплывала не раз. Самое раннее упоминание в архивах spice-devel — сентябрь 2011, но это был вопрос пользователя («а SPICE вообще TCP или UDP?»), не дискуссия разработчиков. Более предметное обсуждение — 2016 год, тред про то, что нужно для UDP-каналов. Результата нет. Это не злой умысел — UDP требует NAT traversal, STUN/TURN, отдельной инфраструктуры. TCP «просто работает» через любой корпоративный firewall. А ресурсов на переработку транспортного слоя у проекта нет (об этом ниже).

Voice: почему это отдельный класс проблем

Проиграть MP3 из браузера и провести голосовой созвон — задачи разного порядка сложности.

Однонаправленный playback

Музыка, видео, системные звуки. Задержка 300-500 мс? Пользователь не заметит, если звук не заикается. Главное — синхронизация с картинкой (lip sync).

SPICE решает lip sync через timestamps в видеокадрах QXL — клиент синхронизирует их с аудио. Работает, если задержка аудиотракта предсказуема.

Стандарт ITU-R BT.1359-1 определяет пороги обнаружения рассинхрона: от -125 мс (аудио отстаёт от видео) до +45 мс (аудио опережает). Это именно порог, при котором зритель замечает рассинхрон — порог допустимости шире (до -185 / +90 мс). SPICE в LAN обычно укладывается в порог обнаружения. В WAN — зависит от условий.

Двунаправленный voice

Софтфон, Teams, голосовой чат — здесь требования другие:

150 мс — верхняя граница комфортной односторонней задержки для разговора (ITU-T G.114). Выше — начинаются перебивания.

20-30 мс — диапазон, в котором музыканты ещё чувствуют синхронность при совместной игре. Точное значение зависит от инструмента и темпа, но 25 мс — часто цитируемая цифра из исследований. (Это примерно время, за которое звук проходит 8 метров по воздуху.)

В VDI путь сигнала удваивается: ваш голос идёт на сервер, обрабатывается софтфоном в госте, отправляется собеседнику. Ответ проходит обратный путь. Round-trip через VDI добавляет 100-300 мс сверху к обычной сетевой задержке.

При общей задержке 400+ мс разговор превращается в радиопереговоры.

Echo cancellation: где всё ломается

Когда вы говорите в микрофон, собеседник слышит вас через свои колонки. Микрофон собеседника ловит этот звук и отправляет обратно. Вы слышите своё эхо с задержкой.

Acoustic Echo Cancellation (AEC) решает это: алгоритм «вычитает» из микрофонного сигнала то, что играет на колонках. Для этого AEC должен знать точную задержку между выводом звука и его попаданием в микрофон.

В VDI эта задержка непредсказуема и плавает в зависимости от нагрузки сети. AEC не справляется. Результат:

  • Эхо, которое пробивается через подавление

  • «Роботизированный» голос — артефакты слишком агрессивного AEC

  • Подавление голоса собеседника вместо эха (AEC «путает» полезный сигнал с эхом)

Что с этим делать:

1. Гарнитура — физически изолирует динамик от микрофона. Работает всегда. Самое надёжное.

2. Media offload — вынести обработку голоса на клиент, минуя VDI. Citrix HDX RealTime Optimization Pack и HDX Media Optimization для Teams делают именно это. У VMware аналог — Media Optimization for Microsoft Teams (не путать с RTAV: RTAV — это проброс камеры и микрофона как устройств в VM, а не offload медиапотока).

3. Локальный софтфон — не запускать UC-приложение в VDI вообще.

SPICE не имеет встроенного media offload. Если вам нужен voice в SPICE-based VDI — гарнитуры обязательны. Без вариантов.

Проблема echo в VDI — петля обратной связи и точки, где AEC пытается её разорвать
Проблема echo в VDI — петля обратной связи и точки, где AEC пытается её разорвать

Практическая конфигурация

На стороне QEMU

qemu-system-x86_64 ... \
  -device intel-hda \
  -device hda-duplex \
  -spice port=5900,...

intel-hda — виртуальное аудиоустройство Intel HDA (эмулирует ICH6). Если у вас q35 machine type, можно использовать ich9-intel-hda (ICH9) — Proxmox, например, предпочитает его. На практике оба работают. hda-duplex — кодек с playback и record (есть ещё hda-micro — то же самое, но представляется гостю как «микрофон + динамик» вместо «line-in + line-out»; нужен для софта, который принципиально ищет именно «microphone»).

Критически важно: QEMU должен использовать SPICE audio backend. Без этого звук уйдёт в PulseAudio хоста и клиент ничего не услышит.

QEMU < 8.2:

export QEMU_AUDIO_DRV=spice

QEMU >= 8.2 (переменные QEMU_AUDIO_* удалены):

qemu-system-x86_64 ... \

-audiodev spice,id=snd0 \

-device intel-hda \

-device hda-duplex,audiodev=snd0

Или коротко:

qemu-system-x86_64 ... -audio spice,model=hda

Если вы сидите на Proxmox или libvirt — они это делают за вас. Но если собираете QEMU-команду руками — проверьте. Иначе будете час искать, почему звук «не работает», а он просто играет на хосте, где его никто не слышит.

На стороне клиента

remote-viewer подхватывает аудио автоматически. Обычно ничего настраивать не нужно.

Для тонкой настройки — свойства SpicePlaybackChannel через GObject API:

// min-latency: по умолчанию 200 (мс)
// На практике это hint для сервера, не размер буфера
// Реальная задержка зависит от GStreamer/PulseAudio

Уменьшение min-latency имеет смысл для LAN, но помните, что это не единственный источник задержки в pipeline.

Что проверить при проблемах

1. Аудиобекенд QEMU — точно spice? (проверить -audiodev или QEMU_AUDIO_DRV)

2. Аудиоустройство в госте — видит ли гостевая ОС HDA?

3. PulseAudio/PipeWire в госте — сервис запущен? Права есть?

4. Firewall — TCP-порт SPICE открыт?

5. Версия spice-gtk — поддерживает Opus? (если очень старая — может пытаться CELT и не договориться с сервером)

6. Аудио на клиентской машине — работает ли вообще? Бывает, что PipeWire на клиенте сам по себе в проблемах, а грешат на SPICE

Бизнес-реальность: кому это нужно

Где SPICE audio работает нормально

Системные звуки, уведомления — задержка не критична, качество достаточное. Тут вообще редко бывают жалобы.

Прослушивание музыки/видео — playback-only, буферизация справляется. Качество Opus на 64 kbps вполне приличное.

Вебинары, обучающие видео — односторонний поток, небольшая задержка не мешает.

Где начинаются проблемы

Софтфоны в VDI — удвоенная задержка, AEC не справляется без гарнитуры.

Видеоконференции через VDI — Teams, Zoom, Meet ожидают предсказуемую задержку. В VDI она непредсказуема.

Call-центры — критична каждая миллисекунда, VDI добавляет overhead, который бизнес не примет.

Что делают в enterprise

Citrix и VMware давно поняли проблему. Их решение — media offload: UC-трафик (голос, видео) идёт напрямую между клиентским устройством и облачным сервисом (Teams, Zoom, Webex), минуя VDI. Виртуалка обрабатывает только UI — кнопки, чат, список участников. Медиапоток обрабатывается локально.

У Citrix это HDX Media Optimization (для Teams) и RealTime Optimization Pack (для Skype for Business, который уходит, но кое-где ещё жив). У VMware — Horizon Media Optimization for Microsoft Teams, аналогичные плагины для Zoom и Webex.

Для SPICE такого механизма нет. Если бизнес требует voice — либо мириться с ограничениями и обязательными гарнитурами, либо выносить UC за периметр VDI, либо смотреть в сторону коммерческих решений.

Итог

SPICE-аудио работает и справляется с базовыми сценариями: системные звуки, прослушивание музыки и видео, вебинары. Opus — хороший кодек, pipeline в LAN даёт приемлемую задержку, настройка через QEMU проста.

Проблемы начинаются там, где нужен real-time в обе стороны: софтфоны, видеоконференции, call-центры. TCP-only транспорт, отсутствие адаптивной буферизации и media offload ставят потолок, который не обойти настройками.