Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions macos/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
65 changes: 65 additions & 0 deletions macos/Sources/OmarchyVMHelper/AudioDevices.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<AudioDeviceID>.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<Float64>.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(
Expand Down Expand Up @@ -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)
Expand Down
13 changes: 12 additions & 1 deletion macos/Sources/OmarchyVMHelper/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down Expand Up @@ -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 {
Expand Down
49 changes: 49 additions & 0 deletions macos/Tests/OmarchyVMHelperTests/AudioDevicesTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down
27 changes: 26 additions & 1 deletion macos/Tests/qemu-memory-contract.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
17 changes: 16 additions & 1 deletion macos/run-qemu-gpu.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down Expand Up @@ -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
Expand Down
Loading