Если вы долго сидите за Mac в обычных наушниках — будь то студийные Beyerdynamic/Sennheiser, полноразмерные Sony/Bose или любимые проводные IEM — вы наверняка замечали усталость ушей через пару часов. Причина проста: обычное стерео изначально сводится для колонок в комнате, где левое ухо слышит и левый, и (с микрозадержкой и фильтрацией головой) правый монитор. В наушниках же каналы изолированы на 100%, из‑за чего сцена схлопывается прямо внутри черепной коробки (in‑head localization).
В macOS уже много лет встроен первоклассный аппаратно‑ускоренный бинауральный движок Apple HRTFHQ (kAudioUnitSubType_SpatialMixer). Но на уровне пользовательского интерфейса Apple заблокировала системное пространственное аудио исключительно для владельцев AirPods и некоторых моделей Beats.
Платные «улучшайзеры» звука за 60 часто вешают в систему тяжелые фоновые демоны и портят АЧХ агрессивным эквалайзером или дешевым ревербератором. Поэтому я решил написать легковесную нативную Menu Bar утилиту на чистом Swift (<1% CPU, ~25 МБ RAM), которая берет системный звук macOS, пропускает его через оригинальный бинауральный фильтр Apple
HRTFHQ и выносит звуковую сцену из головы вперед — как будто перед вами на столе стоят два студийных монитора.
В этой статье расскажу, как устроен низкоуровневый аудиопайплайн в macOS и какие подводные камни CoreAudio пришлось обойти:
Рассинхронизация кварцевых генераторов между виртуальным входом и Bluetooth‑наушниками (и как написать свой Lock‑Free
AudioRingBufferс субдискретной интерполяцией).Проблема квадратичного затухания громкости (
V²) и потолок громкости Bluetooth AVRCP.Почему
AVAudioEngineнамертво зависает в дедлоке внутри CoreAudio HAL при горячем подключении наушников.
Исходный код и готовый Universal 2 бинарник (arm64 + x86_64) полностью открыты под лицензией MIT: GitHub — vladsboev‑web/SpatialAudio.
1. Архитектура аудио‑пайплайна
Чтобы перехватить системный звук macOS без платных закрытых драйверов и устаревших kext‑расширений ядра, мы используем проверенный open‑source драйвер BlackHole 2 ch в качестве системного устройства вывода по умолчанию.
Общая схема тракта выглядит так:
[ Любое приложение macOS ] (Браузер, Музыка, Кино, Игры) │ ▼ PCM Stereo (44.1 / 48 kHz) [ BlackHole 2ch ] (Виртуальный HAL loopback без потерь) │ ▼ Bus 1 Input (Zero-Copy, 0 malloc/free) [ AudioCaptureUnit ] (AUHAL kAudioUnitSubType_HALOutput) │ ▼ [ AudioRingBuffer (L & R) ] ├─ Pre-roll: 1024 сэмпла (~23 мс) ├─ Target Latency: 768 сэмплов (~16–17 мс) ├─ Субдискретная линейная интерполяция └─ Автокомпенсация дрейфа тактовых частот (±0.8%) │ ▼ Bus 0 Output Callback [ SpatialEngine (AVAudioEngine) ] ├──► [ AVAudioSourceNode (Left) ] (renderingAlgorithm = .HRTFHQ, pos: -sin θ, 0, -cos θ) ├──► [ AVAudioSourceNode (Right) ] (renderingAlgorithm = .HRTFHQ, pos: +sin θ, 0, -cos θ) │ │ │ ▼ └──► [ AVAudioEnvironmentNode ] (Listener: 0,0,0 | Room Reverb: 0.0%..3.0%) │ ▼ [ AVAudioMixerNode ] (Компенсация затухания HRTFHQ +4.0 dB / 1.58x) │ ▼ [ Apple Peak Limiter ] (Пассивная защита от межсэмпловых пиков) │ ▼ [ Физические наушники / Bluetooth / USB ЦАП ]
На этапе захвата (AudioCaptureUnit) используется низкоуровневый AudioUnit (kAudioUnitSubType_HALOutput) с включенной входной шиной (Bus 1) в формате 32-bit Float Non‑Interleaved. Все буферы (leftScratch, rightScratch, AudioBufferList) выделяются один раз при старте. Внутри real‑time колбэка AURenderCallback строго ноль вызовов malloc / free, чтобы исключить инверсию приоритетов и микрофризы звука.
2. Почему нельзя просто перекладывать сэмплы из входа в выход: Дрейф часов (Clock Drift)
Казалось бы: прочитали 512 сэмплов из BlackHole, отдали 512 сэмплов в AVAudioEngine на наушники. Но в реальном мире тактовый генератор виртуального драйвера (привязанный к системному таймеру Mac) и кварц в Bluetooth‑наушниках или внешнем USB‑ЦАП никогда не идут абсолютно синхронно. Даже если оба устройства работают на частоте 48 000 Гц, реальная скорость потребления сэмплов в Bluetooth‑кодеке может отличаться на 5–20 отсчетов в секунду.
Без коррекции происходит одно из двух:
Либо буфер постепенно опустошается (underrun), и раз в несколько секунд вы слышите сухой щелчок.
Либо буфер раздувается, и через полчаса просмотра фильма звук начинает заметно отставать от губ актеров.
Чтобы решить это без тяжелых ресэмплеров, я реализовал кольцевой буфер AudioRingBuffer емкостью 2¹⁶ = 65536 сэмплов (адресация через быструю битовую маску & (capacity - 1)), где указатель чтения readPosFrac — не целое число, а вещественный Double.
На каждом цикле чтения мы измеряем текущее отставание между записью и чтением (targetLatency = 768 сэмплов или ~16 мс), плавно подстраиваем шаг чтения в пределах ±0.8% и извлекаем сэмплы через субдискретную линейную интерполяцию между соседними отсчетами:
let currentLag = Double(writePos - Int64(readPosFrac)) let delta = currentLag - Double(targetLatency) // targetLatency = 768 let speedAdjustment = min(max(delta * 0.00003, -0.008), 0.008) let speed = 1.0 + speedAdjustment // Субдискретная линейная интерполяция между соседними отсчетами s0 и s1: let sample = s0 + Float(frac) * (s1 - s0) readPosFrac += speed
В результате задержка железно держится в районе 23–26 мс (полный сквозной тракт от приложения до ЦАП). По стандарту ITU‑R BT.1359–1 порог заметности рассинхрона видео и речи составляет 40 мс, поэтому смотреть YouTube и кино или играть можно без малейшего дискомфорта.
3. Как устроена сцена Apple HRTFHQ и почему звук в ней «проседает» на 4 дБ
Внутри AVAudioEnvironmentNode мы размещаем слушателя в начале координат (0, 0, 0), а левый и правый источники (AVAudioSourceNode) разводим по дуге единичного радиуса (d = 1.0 м) перед слушателем под углом ±θ (по умолчанию ±30° — классический равносторонний треугольник ближнего поля в студии):
Left = (-sin(θ), 0, -cos(θ)) Right = (+sin(θ), 0, -cos(θ))
Для обоих узлов включается алгоритм .HRTFHQ. Он применяет свертку с бинауральными импульсными характеристиками головы (HRTF), формируя естественные межушные задержки по времени (ITD) и спектральной громкости (ILD).
Первая ловушка: сам по себе бинауральный фильтр HRTFHQ имеет физическое ослабление энергии примерно на -4.0 дБ относительно плоского стерео, чтобы при сложении двух каналов и ранних отражений комнаты не возникало цифрового клиппинга. Из‑за этого при включении 3D‑сцены звук кажется тише и «беднее».
Мы решили это тремя шагами:
Отключили искусственное затухание от расстояния (
rolloffFactor = 0.0) и добавили на выходном микшере точную компенсирующую поправку +4.0 дБ (~1.58 x).В самом конце цепочки поставили нативный
kAudioUnitSubType_PeakLimiter. В обычном режиме он полностью прозрачен и не жмет динамику, но работает как пассивный предохранитель от межсэмпловых пиков, когда складываются фазы баса и отражений комнаты.Диапазон комнатной реверберации (
SmallRoom) ограничили сверхделикатным окном от 0.0% до 3.0% с шагом 0.1% — этого ровно достаточно, чтобы «оторвать» звук от ушей, но не превратить трек в кафельный коридор.
4. Проблема двойного затухания (V²) и аппаратный лимит Bluetooth AVRCP
Когда пользователь ставит виртуальный драйвер (BlackHole) устройством по умолчанию, системные кнопки громкости на клавиатуре Mac (F10 / F11 / F12) начинают управлять громкостью BlackHole.
В первой ревизии проекта мы считывали ползунок громкости BlackHole и… копировали это же значение в физические наушники, чтобы синхронизировать уровни. На бумаге звучало логично, но на практике привело к двум проблемам:
Эффект квадратичного затухания (
V²): демонcoreaudiodв macOS уже умножает цифровой поток внутриBlackHoleна коэффициент громкостиV. Когда мы параллельно выставляли громкостьVеще и на физическом ЦАП наушников, итоговая амплитуда превращалась вV × V = V². На 50% системного ползунка пользователь получал лишь 25% громкости!Скрытый потолок громкости Bluetooth (AVRCP): многие Bluetooth‑наушники при подключении к Mac инициализируют свой внутренний аппаратный усилитель на уровне 50–60% и ждут команд AVRCP. Если приложение не поднимет аппаратный уровень самого устройства вывода на максимум, вы никогда не получите полную мощность усилителя наушников.
Решение — архитектура Single Attenuation (Unity Gain 1:1): При старте стриминга и при каждом горячем переключении наушников наш AudioHardwareService принудительно выставляет аппаратную громкость физического устройства вывода (наушников/ЦАП) ровно в 1.0 (0 dB / Unity Gain) через свойство CoreAudio kAudioHardwareServiceDeviceProperty_VirtualMainVolume. А регулировка клавишами F11/F12 меняет громкость ровно в одной точке — на входе в BlackHole. В итоге логарифмическая кривая громкости macOS работает идеально 1-в-1, как в нативной системе.
5. Анатомия намертво зависающего дедлока CoreAudio при Hot‑Plug наушников
Самый коварный баг ждал нас при тестировании горячего подключения Bluetooth‑наушников. Сценарий: играет музыка через динамики Mac, вы достаете наушники из кейса — и приложение намертво зависает (Spinning Beachball).
В чем оказалась причина? Когда в macOS подключаются Bluetooth‑наушники, CoreAudio перестраивает граф устройств. Чтобы переключить вывод AVAudioEngine на новый AudioDeviceID, старый экземпляр движка нужно остановить и пересоздать.
Чтобы не блокировать UI‑поток при остановке старого движка, мы изначально написали классический код:
// ❌ ОПАСНО: Гарантированный дедлок внутри CoreAudio HAL! let oldEngine = self.engine DispatchQueue.global(qos: .utility).async { oldEngine.stop() oldEngine.reset() } // Тем временем на Main-потоке создаем новый движок и привязываем устройство: let newEngine = AVAudioEngine() try newEngine.outputNode.auAudioUnit.setDeviceID(newDeviceID)
Оказалось, что внутри C++ ядра CoreAudio (AudioHardware / AUHAL) существует глобальный внутренний мьютекс оборудования. Когда фоновый поток .utility вызывает oldEngine.stop(), а главный поток одновременно вызывает newEngine.outputNode.auAudioUnit.setDeviceID(...), один поток захватывает внутренний лок графа AVAudioEngine и ждет мьютекс HAL, а второй держит мьютекс HAL и ждет освобождения ресурса аудиоюнита. Классический AB‑BA Deadlock!
Более того, при каждом создании AVAudioEngine система macOS динамически порождает в списке аудиоустройств служебные виртуальные агрегаты вида CADefaultDeviceAggregate-<PID>, тут же рассылая уведомление kAudioHardwarePropertyDevices. Если ваш слушатель CoreAudio реагирует на каждое такое событие перезапуском движка — вы получаете бесконечный шторм рестартов.
Как мы это вылечили:
Полностью синхронизировали жизненный цикл
AVAudioEngineстрого в одном потоке: сначала детерминированныйstop()иreset()старого графа, и только после этого — инициализация нового.В
AudioCoordinatorзавели множество физических идентификаторов оборудования (lastKnownHardwareDeviceIDs), отфильтровывая виртуальныеCADefaultDeviceAggregate. Теперь пайплайн перезапускается исключительно тогда, когда в системе реально изменился состав физического железа.Добавили бесшовный Hot‑Plug & Fallback: достали наушники — звук сам перешел в них с сохранением текущей громкости сессии (без скачка на 100%); убрали наушники в кейс — музыка без паузы продолжила играть через встроенные динамики MacBook; переключили вывод вручную в Центре управления macOS — DSP‑движок сам встал на паузу (
0% CPU).
Как попробовать
Проект полностью бесплатный, без телеметрии, подписок и рекламы, работает на macOS 13 Ventura, 14 Sonoma, 15 Sequoia и новее (нативный Universal 2 бинарник для Apple Silicon M1–M4 и Intel). Поддерживаются русский и английский языки интерфейса.
Установите виртуальный драйвер BlackHole 2 ch (если еще не стоит):
brew install blackhole-2ch
Скачайте свежий архив
SpatialAudio.zipиз раздела Releases на GitHub и перетащитеSpatialAudio.appв/Applications.Снимите карантин Gatekeeper перед первым запуском (так как проект опенсорсный и не использует платную подписку Apple Developer ID за $99/год):
xattr -cr /Applications/SpatialAudio.app open /Applications/SpatialAudio.app
Буду рад вашим отзывам, баг‑репортам, звездам на GitHub и впечатлениям от того, как звучат ваши любимые наушники с вынесенной вперед сценой!
? Репозиторий проекта: https://github.com/vladsboev‑web/SpatialAudio
Первоначальная разработка Gemini 3.8 flash high в Antigravity, рефакторинг Gemini 4 Argon medium, статья написана Gemini 4 Argon medium.