feat(mac,ios,playback,store): initial macos implementation

This commit is contained in:
hh committed 2026-09-23 04:56:48 +02:00
1 parent e5bb559d7f
commit d2d7166cf0
72 files changed
+4452 -318

No files matched your search

@@ -0,0 +1,127 @@
#if os(macOS)
import CoreAudio
import Foundation
public enum OutputDeviceError: Error {
case noDevice, unsupportedRate, writeFailed, timedOut
}
public enum OutputDevice {
private static let pollStep: Duration = .milliseconds(20)
private static let pollLimit = 50
private static let settle: Duration = .milliseconds(100)
public static func current() -> OutputDeviceInfo? {
guard let id = defaultDevice() else { return nil }
return OutputDeviceInfo(
id: id,
name: read(kAudioObjectPropertyName, from: id, as: CFString.self).map { $0 as String } ?? "",
nominalRate: read(kAudioDevicePropertyNominalSampleRate, from: id, as: Float64.self) ?? 0,
availableRates: availableRates(of: id)
)
}
public static func setNominalRate(_ rate: Double) async throws {
guard let device = current() else { throw OutputDeviceError.noDevice }
guard device.nominalRate != rate else { return }
guard device.availableRates.contains(rate) else { throw OutputDeviceError.unsupportedRate }
var address = address(kAudioDevicePropertyNominalSampleRate)
var value = rate
let size = UInt32(MemoryLayout<Float64>.size)
guard AudioObjectSetPropertyData(device.id, &address, 0, nil, size, &value) == noErr else {
throw OutputDeviceError.writeFailed
}
for _ in 0 ..< pollLimit {
if read(kAudioDevicePropertyNominalSampleRate, from: device.id, as: Float64.self) == rate {
try await Task.sleep(for: settle)
return
}
try await Task.sleep(for: pollStep)
}
throw OutputDeviceError.timedOut
}
public static func changes() -> AsyncStream<OutputDeviceInfo?> {
AsyncStream { continuation in
continuation.yield(current())
let listener = Listener { continuation.yield(current()) }
continuation.onTermination = { _ in listener.stop() }
}
}
fileprivate static func defaultDevice() -> AudioDeviceID? {
let system = AudioObjectID(kAudioObjectSystemObject)
let id = read(kAudioHardwarePropertyDefaultOutputDevice, from: system, as: AudioDeviceID.self)
return id.flatMap { $0 == kAudioObjectUnknown ? nil : $0 }
}
fileprivate static func address(_ selector: AudioObjectPropertySelector) -> AudioObjectPropertyAddress {
AudioObjectPropertyAddress(
mSelector: selector,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain
)
}
private static func read<T>(_ selector: AudioObjectPropertySelector, from object: AudioObjectID, as _: T.Type) -> T? {
var address = address(selector)
var size = UInt32(MemoryLayout<T>.size)
let buffer = UnsafeMutablePointer<T>.allocate(capacity: 1)
defer { buffer.deallocate() }
guard AudioObjectGetPropertyData(object, &address, 0, nil, &size, buffer) == noErr else { return nil }
return buffer.move()
}
private static func availableRates(of device: AudioDeviceID) -> [Double] {
var address = address(kAudioDevicePropertyAvailableNominalSampleRates)
var size: UInt32 = 0
guard AudioObjectGetPropertyDataSize(device, &address, 0, nil, &size) == noErr else { return [] }
let count = Int(size) / MemoryLayout<AudioValueRange>.stride
var ranges = [AudioValueRange](repeating: AudioValueRange(), count: count)
guard AudioObjectGetPropertyData(device, &address, 0, nil, &size, &ranges) == noErr else { return [] }
return Array(Set(ranges.flatMap { [$0.mMinimum, $0.mMaximum] })).sorted()
}
}
private final class Listener: @unchecked Sendable {
private let queue = DispatchQueue(label: "Amphlify.OutputDevice")
private let system = AudioObjectID(kAudioObjectSystemObject)
private var device: AudioDeviceID = kAudioObjectUnknown
private var systemAddress = OutputDevice.address(kAudioHardwarePropertyDefaultOutputDevice)
private var rateAddress = OutputDevice.address(kAudioDevicePropertyNominalSampleRate)
private var onSystemChange: AudioObjectPropertyListenerBlock = { _, _ in }
private var onRateChange: AudioObjectPropertyListenerBlock = { _, _ in }
init(onChange: @escaping @Sendable () -> Void) {
onRateChange = { _, _ in onChange() }
onSystemChange = { [weak self] _, _ in
self?.follow()
onChange()
}
queue.sync {
AudioObjectAddPropertyListenerBlock(system, &systemAddress, queue, onSystemChange)
follow()
}
}
func stop() {
queue.sync {
AudioObjectRemovePropertyListenerBlock(system, &systemAddress, queue, onSystemChange)
unfollow()
}
}
private func follow() {
unfollow()
device = OutputDevice.defaultDevice() ?? kAudioObjectUnknown
guard device != kAudioObjectUnknown else { return }
AudioObjectAddPropertyListenerBlock(device, &rateAddress, queue, onRateChange)
}
private func unfollow() {
guard device != kAudioObjectUnknown else { return }
AudioObjectRemovePropertyListenerBlock(device, &rateAddress, queue, onRateChange)
device = kAudioObjectUnknown
}
}
#endif