Follow-up to #495 (native DSD via DoP). DoP now ships on Windows (WASAPI Exclusive) and Linux (raw hw: ALSA). This issue tracks the macOS backend via CoreAudio hog mode, which was deliberately deferred because it's low-level FFI that needs an actual Mac to compile + validate.
Why it's a separate task
The whole DoP stack is already platform-agnostic — the encoder (DsdToDop), the decoder path (play_dop_track), the engine (switch_output_for_track), and the shared byte/idle packing (dop_pack, including fill_dop_period_i32 / render_dop_silence_i32 for MSB-justified 32-bit samples). Only a new output backend is needed, exactly like alsa_exclusive was for Linux.
Implementation plan
Add src-tauri/crates/app/src/audio/coreaudio_exclusive.rs (#[cfg(target_os = "macos")], registered in audio/mod.rs) exposing:
pub fn spawn_coreaudio_dop_output_thread(
shared: Arc<SharedPlayback>,
app: AppHandle,
device_name: Option<String>,
dop: DopFormat,
) -> AppResult<(Producer<f32>, OutputHandle)>
mirroring spawn_alsa_dop_output_thread's contract (returns the ring producer + an OutputHandle with dop_rate: Some(dop.sample_rate), or an error → the engine falls back to DSD → PCM).
Steps inside:
- Resolve the device —
kAudioHardwarePropertyDefaultOutputDevice (or match device_name against kAudioObjectPropertyName).
- Acquire hog mode — set
kAudioDevicePropertyHogMode to our pid for exclusive access (no system mixing). Release it on teardown.
- Set the physical format —
kAudioStreamPropertyPhysicalFormat on the output stream: mSampleRate = dop.sample_rate, mFormatID = kAudioFormatLinearPCM, mFormatFlags = kAudioFormatFlagIsSignedInteger | kAudioFormatFlagIsPacked (and kAudioFormatFlagIsBigEndian cleared for LE), 32-bit. If the device won't take the exact DoP rate → error → fallback.
- Feed samples —
AudioDeviceCreateIOProcID + AudioDeviceStart; the IOProc fills the output AudioBufferList from the SPSC ring via dop_pack::fill_dop_period_i32 (MSB-justified — marker in the top byte, same as ALSA S32_LE) and render_dop_silence_i32 on pause/drain.
- Teardown —
AudioDeviceStop + AudioDeviceDestroyIOProcID + release hog mode; report a device loss through output::notify_device_lost + schedule_device_rebuild like the other backends.
Wiring (already stubbed for it)
output::spawn_output_with_mode currently routes macOS DoP into the "not supported yet" arm — swap it for coreaudio_exclusive::spawn_coreaudio_dop_output_thread.
AudioEngine::switch_output_for_track sets exclusive_available = false on macOS — flip it to true so the DoP toggle engages (the hog-mode open IS the exclusive path, same reasoning as Linux hw:).
- Settings toggle:
SettingsView.tsx dopPlatformSupported currently shows on Windows + Linux — add macOS.
- Add
coreaudio-sys (or coreaudio-rs) under [target.'cfg(target_os = "macos")'.dependencies].
Validation
Needs a Mac + a DoP-capable USB DAC. Confirm: the DAC displays "DSD", playback is bit-perfect, pause/seek don't drop DoP lock (the 0x69 idle frames), and a device that refuses the DoP rate falls back cleanly to DSD → PCM.
Notes
- The MSB-justified 32-bit convention (
dop_word_to_i32) is shared with the Linux backend and already unit-tested; reuse it rather than re-deriving.
- Playing DoP to a non-DoP DAC is white noise — keep it behind the existing opt-in / default-OFF toggle.
Follow-up to #495 (native DSD via DoP). DoP now ships on Windows (WASAPI Exclusive) and Linux (raw
hw:ALSA). This issue tracks the macOS backend via CoreAudio hog mode, which was deliberately deferred because it's low-level FFI that needs an actual Mac to compile + validate.Why it's a separate task
The whole DoP stack is already platform-agnostic — the encoder (
DsdToDop), the decoder path (play_dop_track), the engine (switch_output_for_track), and the shared byte/idle packing (dop_pack, includingfill_dop_period_i32/render_dop_silence_i32for MSB-justified 32-bit samples). Only a new output backend is needed, exactly likealsa_exclusivewas for Linux.Implementation plan
Add
src-tauri/crates/app/src/audio/coreaudio_exclusive.rs(#[cfg(target_os = "macos")], registered inaudio/mod.rs) exposing:mirroring
spawn_alsa_dop_output_thread's contract (returns the ring producer + anOutputHandlewithdop_rate: Some(dop.sample_rate), or an error → the engine falls back to DSD → PCM).Steps inside:
kAudioHardwarePropertyDefaultOutputDevice(or matchdevice_nameagainstkAudioObjectPropertyName).kAudioDevicePropertyHogModeto our pid for exclusive access (no system mixing). Release it on teardown.kAudioStreamPropertyPhysicalFormaton the output stream:mSampleRate = dop.sample_rate,mFormatID = kAudioFormatLinearPCM,mFormatFlags = kAudioFormatFlagIsSignedInteger | kAudioFormatFlagIsPacked(andkAudioFormatFlagIsBigEndiancleared for LE), 32-bit. If the device won't take the exact DoP rate → error → fallback.AudioDeviceCreateIOProcID+AudioDeviceStart; the IOProc fills the outputAudioBufferListfrom the SPSC ring viadop_pack::fill_dop_period_i32(MSB-justified — marker in the top byte, same as ALSAS32_LE) andrender_dop_silence_i32on pause/drain.AudioDeviceStop+AudioDeviceDestroyIOProcID+ release hog mode; report a device loss throughoutput::notify_device_lost+schedule_device_rebuildlike the other backends.Wiring (already stubbed for it)
output::spawn_output_with_modecurrently routes macOS DoP into the "not supported yet" arm — swap it forcoreaudio_exclusive::spawn_coreaudio_dop_output_thread.AudioEngine::switch_output_for_tracksetsexclusive_available = falseon macOS — flip it totrueso the DoP toggle engages (the hog-mode open IS the exclusive path, same reasoning as Linuxhw:).SettingsView.tsxdopPlatformSupportedcurrently shows on Windows + Linux — add macOS.coreaudio-sys(orcoreaudio-rs) under[target.'cfg(target_os = "macos")'.dependencies].Validation
Needs a Mac + a DoP-capable USB DAC. Confirm: the DAC displays "DSD", playback is bit-perfect, pause/seek don't drop DoP lock (the
0x69idle frames), and a device that refuses the DoP rate falls back cleanly to DSD → PCM.Notes
dop_word_to_i32) is shared with the Linux backend and already unit-tested; reuse it rather than re-deriving.