diff --git a/macos/README.md b/macos/README.md index b951a700..feabe9d6 100644 --- a/macos/README.md +++ b/macos/README.md @@ -142,6 +142,15 @@ supported platform, runtime boundaries, and distribution checklist. ## Audio continuity +At launch, the native helper queries CoreAudio for the nominal sample rate of +each effective SDL route. Saved speaker and microphone selections take +precedence over the Mac's separate defaults; a disconnected selection falls +back to its direction's default. Duplicate SDL device names retain their +numbered suffixes. Output and input mixer rates are set independently, with a +48 kHz fallback if a rate cannot be read. This mitigates the reported playback +speed issue at 44.1 and 48 kHz; rates stay fixed until the VM is restarted, and +other rates and live route changes still need playback validation. + The SDL backend uses a 1 ms audio timer and eight output buffers. With an obtained 512-frame SDL callback at 44.1 kHz, that gives 4096 frames (about 93 ms) of host queue capacity; other devices may negotiate different values. Capacity diff --git a/macos/Sources/OmarchyVMHelper/AudioDevices.swift b/macos/Sources/OmarchyVMHelper/AudioDevices.swift index c96e3201..bd292f9a 100644 --- a/macos/Sources/OmarchyVMHelper/AudioDevices.swift +++ b/macos/Sources/OmarchyVMHelper/AudioDevices.swift @@ -46,6 +46,20 @@ struct HostAudioDeviceCatalog: Equatable { devices(for: direction).first { $0.uid == uid } } + func effectiveDevice( + direction: HostAudioDirection, + selectedSDLName: String?, + systemDefaultUID: String? + ) -> HostAudioDevice? { + if let selectedSDLName, + let selected = devices(for: direction).first(where: { $0.sdlName == selectedSDLName }) { + return selected + } + // Match SDL's fallback when a saved device has disappeared. + guard let systemDefaultUID else { return nil } + return device(uid: systemDefaultUID, direction: direction) + } + private static func makeDevices( from descriptors: [HostAudioHardwareDescriptor], direction: HostAudioDirection @@ -100,6 +114,49 @@ protocol HostAudioDeviceProviding { } struct CoreAudioHostAudioDeviceProvider: HostAudioDeviceProviding { + func nominalSampleRate(direction: HostAudioDirection, selectedSDLName: String?) -> Int? { + var defaultAddress = AudioObjectPropertyAddress( + mSelector: direction == .output + ? kAudioHardwarePropertyDefaultOutputDevice + : kAudioHardwarePropertyDefaultInputDevice, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain + ) + var defaultID = AudioDeviceID(kAudioObjectUnknown) + var size = UInt32(MemoryLayout.size) + let defaultStatus = AudioObjectGetPropertyData( + AudioObjectID(kAudioObjectSystemObject), &defaultAddress, 0, nil, &size, &defaultID + ) + let defaultUID = defaultStatus == noErr ? stringProperty( + deviceID: defaultID, + selector: kAudioDevicePropertyDeviceUID, + scope: kAudioObjectPropertyScopeGlobal + ) : nil + guard let device = catalog().effectiveDevice( + direction: direction, + selectedSDLName: selectedSDLName, + systemDefaultUID: defaultUID + ), let deviceID = audioDeviceIdentifiers().first(where: { + stringProperty( + deviceID: $0, + selector: kAudioDevicePropertyDeviceUID, + scope: kAudioObjectPropertyScopeGlobal + ) == device.uid + }) else { return nil } + + var rateAddress = AudioObjectPropertyAddress( + mSelector: kAudioDevicePropertyNominalSampleRate, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain + ) + var rate: Float64 = 0 + size = UInt32(MemoryLayout.size) + guard AudioObjectGetPropertyData( + deviceID, &rateAddress, 0, nil, &size, &rate + ) == noErr else { return nil } + return HostAudioSampleRate.validated(rate) + } + func catalog() -> HostAudioDeviceCatalog { let descriptors: [HostAudioHardwareDescriptor] = audioDeviceIdentifiers().compactMap { deviceID in guard let uid = stringProperty( @@ -244,6 +301,14 @@ struct CoreAudioHostAudioDeviceProvider: HostAudioDeviceProviding { } } +enum HostAudioSampleRate { + static func validated(_ rate: Double) -> Int? { + guard rate.isFinite, rate >= 1_000, rate < 1_000_000, + rate.rounded() == rate else { return nil } + return Int(rate) + } +} + enum AudioRouteSelection: Equatable { case systemDefault case device(uid: String, lastKnownName: String) diff --git a/macos/Sources/OmarchyVMHelper/main.swift b/macos/Sources/OmarchyVMHelper/main.swift index ed6cb0e2..584b4799 100644 --- a/macos/Sources/OmarchyVMHelper/main.swift +++ b/macos/Sources/OmarchyVMHelper/main.swift @@ -5,7 +5,7 @@ import Foundation private var terminationSignalSources: [DispatchSourceSignal] = [] private func usage() -> Never { - fputs("Usage: omarchy-vm-helper --run-qemu [--ephemeral | --reset-storage | --reset-storage-only] [GUEST_DIR] | --host-keyboard-geometry | --host-timezone | --wait-for-qmp QEMU_PID SOCKET | --bridge-command-super QEMU_PID QMP_SOCKET | --bridge-native-audio QEMU_PID SOCKET ROUTE_DIRECTORY | --bridge-native-authentication QEMU_PID SOCKET | --bridge-native-camera QEMU_PID SOCKET | --bridge-native-battery QEMU_PID SOCKET | --bridge-native-clipboard QEMU_PID SOCKET | --bridge-native-timezone QEMU_PID SOCKET\n", stderr) + fputs("Usage: omarchy-vm-helper --run-qemu [--ephemeral | --reset-storage | --reset-storage-only] [GUEST_DIR] | --host-keyboard-geometry | --host-timezone | --host-audio-frequency output|input [SDL_NAME] | --wait-for-qmp QEMU_PID SOCKET | --bridge-command-super QEMU_PID QMP_SOCKET | --bridge-native-audio QEMU_PID SOCKET ROUTE_DIRECTORY | --bridge-native-authentication QEMU_PID SOCKET | --bridge-native-camera QEMU_PID SOCKET | --bridge-native-battery QEMU_PID SOCKET | --bridge-native-clipboard QEMU_PID SOCKET | --bridge-native-timezone QEMU_PID SOCKET\n", stderr) exit(64) } @@ -240,6 +240,17 @@ do { exit(0) } + if arguments.first == "--host-audio-frequency" { + guard (2...3).contains(arguments.count), + let direction = HostAudioDirection(rawValue: arguments[1]) else { usage() } + let selectedName = arguments.count == 3 ? arguments[2] : nil + guard let rate = CoreAudioHostAudioDeviceProvider().nominalSampleRate( + direction: direction, selectedSDLName: selectedName + ) else { exit(1) } + print(rate) + exit(0) + } + if arguments.first == "--host-keyboard-geometry" { guard arguments.count == 1 else { usage() } do { diff --git a/macos/Tests/OmarchyVMHelperTests/AudioDevicesTests.swift b/macos/Tests/OmarchyVMHelperTests/AudioDevicesTests.swift index 34c7b264..b7d8b01b 100644 --- a/macos/Tests/OmarchyVMHelperTests/AudioDevicesTests.swift +++ b/macos/Tests/OmarchyVMHelperTests/AudioDevicesTests.swift @@ -143,6 +143,55 @@ struct AudioDevicesTests { #expect(fixture.store.load() == preferences) } + @Test("sample-rate lookup follows the selected SDL route rather than the Mac default") + func sampleRateDeviceSelection() { + let catalog = HostAudioDeviceCatalog.make(from: [ + .init(uid: "built-in", outputName: "Speakers", inputName: "Microphone"), + .init(uid: "usb-a", outputName: "USB Audio", inputName: nil), + .init(uid: "usb-b", outputName: "USB Audio ", inputName: "USB Mic"), + ]) + + #expect(catalog.effectiveDevice( + direction: .output, selectedSDLName: "USB Audio (2)", systemDefaultUID: "built-in" + )?.uid == "usb-b") + #expect(catalog.effectiveDevice( + direction: .input, selectedSDLName: "USB Mic", systemDefaultUID: "built-in" + )?.uid == "usb-b") + #expect(catalog.effectiveDevice( + direction: .output, selectedSDLName: "USB Audio", systemDefaultUID: nil + )?.uid == "usb-a") + } + + @Test("default and disconnected routes resolve independently for each direction") + func sampleRateDefaultSelection() { + let catalog = HostAudioDeviceCatalog.make(from: [ + .init(uid: "speaker", outputName: "Speakers", inputName: nil), + .init(uid: "mic", outputName: nil, inputName: "Microphone"), + ]) + #expect(catalog.effectiveDevice( + direction: .output, selectedSDLName: nil, systemDefaultUID: "speaker" + )?.uid == "speaker") + #expect(catalog.effectiveDevice( + direction: .input, selectedSDLName: "Disconnected", systemDefaultUID: "mic" + )?.uid == "mic") + #expect(catalog.effectiveDevice( + direction: .output, selectedSDLName: "Microphone", systemDefaultUID: "speaker" + )?.uid == "speaker") + #expect(catalog.effectiveDevice( + direction: .input, selectedSDLName: nil, systemDefaultUID: nil + ) == nil) + } + + @Test("CoreAudio rates must be finite integral frequencies before passing them to QEMU") + func sampleRateValidation() { + for rate in [8_000.0, 16_000, 44_100, 48_000, 88_200, 96_000, 192_000] { + #expect(HostAudioSampleRate.validated(rate) == Int(rate)) + } + for rate in [0.0, -48_000, 999, 1_000_000, 44_100.5, .infinity, .nan] { + #expect(HostAudioSampleRate.validated(rate) == nil) + } + } + @Test("launch environment selects input and output independently and removes SDL override") func createsSanitizedEnvironment() { let catalog = HostAudioDeviceCatalog.make(from: [ diff --git a/macos/Tests/qemu-memory-contract.test.sh b/macos/Tests/qemu-memory-contract.test.sh index 5e8e699a..275b51da 100755 --- a/macos/Tests/qemu-memory-contract.test.sh +++ b/macos/Tests/qemu-memory-contract.test.sh @@ -84,6 +84,19 @@ if [[ ${1:-} == --host-keyboard-geometry ]]; then printf '%s\n' "${FAKE_HOST_KEYBOARD:-iso}" exit 0 fi +if [[ ${1:-} == --host-audio-frequency ]]; then + [[ $# == 3 ]] || exit 2 + [[ ${FAKE_HOST_AUDIO_FAIL:-} != "$2" ]] || exit 1 + case "$2:$3" in + 'output:External Speaker') printf '48000\n' ;; + 'output:Display Audio (2)') printf '88200\n' ;; + 'input:USB Microphone') printf '16000\n' ;; + output:*) printf '%s\n' "${FAKE_HOST_OUTPUT_RATE:-44100}" ;; + input:*) printf '%s\n' "${FAKE_HOST_INPUT_RATE:-48000}" ;; + *) exit 2 ;; + esac + exit 0 +fi if [[ ${1:-} == --host-timezone ]]; then printf '%s\n' Asia/Tokyo exit 0 @@ -397,11 +410,23 @@ done # A 16 GiB Mac defaults to 8 GiB; smaller Macs keep the 4 GiB baseline. run_scenario default 0 assert_line_pair "$test_root/default/qemu.log" -m 8192M -assert_line_pair "$test_root/default/qemu.log" -audiodev sdl,id=omarchy-audio,timer-period=1000,out.buffer-count=8 +assert_line_pair "$test_root/default/qemu.log" -audiodev sdl,id=omarchy-audio,timer-period=1000,out.buffer-count=8,out.frequency=44100,in.frequency=48000 assert_line_pair "$test_root/default/qemu.log" -device virtio-balloon-pci,free-page-reporting=on assert_contains "$(<"$test_root/default/stderr")" '8 GiB RAM' assert_keyboard_lockstep "$test_root/default/qemu.log" iso +# Saved routes and direction-specific rates reach the native query and QEMU. +run_scenario selected-audio 0 \ + OMARCHY_SDL_OUTPUT_DEVICE_NAME='External Speaker' \ + OMARCHY_SDL_INPUT_DEVICE_NAME='USB Microphone' +assert_line_pair "$test_root/selected-audio/qemu.log" -audiodev sdl,id=omarchy-audio,timer-period=1000,out.buffer-count=8,out.frequency=48000,in.frequency=16000 +run_scenario duplicate-audio 0 OMARCHY_SDL_OUTPUT_DEVICE_NAME='Display Audio (2)' +assert_line_pair "$test_root/duplicate-audio/qemu.log" -audiodev sdl,id=omarchy-audio,timer-period=1000,out.buffer-count=8,out.frequency=88200,in.frequency=48000 +run_scenario audio-query-failure 0 FAKE_HOST_AUDIO_FAIL=output FAKE_HOST_INPUT_RATE=32000 +assert_line_pair "$test_root/audio-query-failure/qemu.log" -audiodev sdl,id=omarchy-audio,timer-period=1000,out.buffer-count=8,out.frequency=48000,in.frequency=32000 +run_scenario audio-invalid-rate 0 FAKE_HOST_INPUT_RATE='48000,out.frequency=99999' +assert_line_pair "$test_root/audio-invalid-rate/qemu.log" -audiodev sdl,id=omarchy-audio,timer-period=1000,out.buffer-count=8,out.frequency=44100,in.frequency=48000 + run_scenario below-default-threshold 0 FAKE_HOST_MEMSIZE=17178820608 assert_line_pair "$test_root/below-default-threshold/qemu.log" -m 4096M run_scenario large-host-default 0 FAKE_HOST_MEMSIZE=51539607552 diff --git a/macos/run-qemu-gpu.sh b/macos/run-qemu-gpu.sh index 55457ad6..753aab46 100755 --- a/macos/run-qemu-gpu.sh +++ b/macos/run-qemu-gpu.sh @@ -131,6 +131,21 @@ qemu_netdevs=$("$qemu_bin" -machine virt -netdev help 2>&1) || { grep -qx 'user' <<<"$qemu_netdevs" || { fail "staged QEMU does not provide no-root SLIRP networking; run make runtime" } +# Match each mixer to its effective SDL route at launch. The native helper uses +# the same direction-aware SDL names as the audio picker, including duplicate +# suffixes, and falls back to that direction's default if a device disappeared. +# Rates remain fixed for this VM session; changing routes may require a restart. +host_audio_frequency() { + local audio_rate + audio_rate=$("$native_bridge" --host-audio-frequency "$1" "$2" 2>/dev/null) || return 1 + [[ $audio_rate =~ ^[1-9][0-9]{3,5}$ ]] || return 1 + printf '%s\n' "$audio_rate" +} + +# Keep optional hardware-query failures non-fatal, independently by direction. +audio_output_frequency=$(host_audio_frequency output "${OMARCHY_SDL_OUTPUT_DEVICE_NAME:-}") || audio_output_frequency=48000 +audio_input_frequency=$(host_audio_frequency input "${OMARCHY_SDL_INPUT_DEVICE_NAME:-}") || audio_input_frequency=48000 + qemu_audiodevs=$("$qemu_bin" -machine virt -audiodev help 2>&1) || { fail "cannot inspect staged QEMU audio backends" } @@ -1709,7 +1724,7 @@ qemu_args=( -action 'reboot=reset,shutdown=poweroff' -netdev "$qemu_netdev" -device "virtio-net-pci,id=omarchy-nic,netdev=omarchy-net,mac=$network_mac,romfile=" - -audiodev 'sdl,id=omarchy-audio,timer-period=1000,out.buffer-count=8' + -audiodev "sdl,id=omarchy-audio,timer-period=1000,out.buffer-count=8,out.frequency=$audio_output_frequency,in.frequency=$audio_input_frequency" -device 'intel-hda,id=omarchy-hda,romfile=' -device 'hda-micro,bus=omarchy-hda.0,audiodev=omarchy-audio' -serial none