Skip to content

feat(audio): native DSD via DoP on macOS (CoreAudio hog mode) #497

Description

@InstaZDLL

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:

  1. Resolve the devicekAudioHardwarePropertyDefaultOutputDevice (or match device_name against kAudioObjectPropertyName).
  2. Acquire hog mode — set kAudioDevicePropertyHogMode to our pid for exclusive access (no system mixing). Release it on teardown.
  3. Set the physical formatkAudioStreamPropertyPhysicalFormat 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.
  4. Feed samplesAudioDeviceCreateIOProcID + 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.
  5. TeardownAudioDeviceStop + 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.

Metadata

Metadata

Assignees

Labels

Projects

No projects

Relationships

None yet

Development

No branches or pull requests

Issue actions