Files
punktfunk/clients/apple/Sources/PunktfunkKit/AudioDevices.swift
T
enricobuehler b26f138699
ci / rust (push) Has been cancelled
feat(apple): session audio — host playback + mic uplink, device pickers in Settings
Both directions of the audio plane, on CoreAudio's built-in Opus codec
(kAudioFormatOpus — no bundled libopus; OpusCodec.swift, round trip unit-tested):

- Playback: a drain thread pulls nextAudio() packets, decodes, and writes a priming
  jitter ring feeding an AVAudioSourceNode (~20 ms prefill, adaptive to the device's
  render quantum so large-buffer devices don't oscillate prime/dropout; a high-water
  clamp sheds stall backlog so one network hiccup can't permanently lag audio behind
  video; underrun re-primes — one dip, not sustained crackle).
- Mic: a second engine taps the input device, resamples to 48 kHz stereo, Opus-encodes
  20 ms chunks and sendMic()s them into the host's virtual PipeWire source. Permission
  via AVCaptureDevice (NSMicrophoneUsageDescription added to the Xcode target).
- Settings: Speaker + Microphone pickers (CoreAudio HAL enumeration, persisted by
  device UID — "System default" leaves the engine unpinned so it follows macOS device
  changes) and a "Send microphone" toggle (default on). Applies from the next session.
- Audio starts with streaming, never during the trust prompt (no host sound — and no
  mic uplink — before the user trusted the host); teardown stops audio before close().

Adversarial-review fixes baked in: stop() and the dangling mic-permission callback
share one lock+flag protocol (no hot mic with no owner), the connect-success handler
bails when the attempt was abandoned mid-handshake (no session/mic for a dead window),
SessionAudio gets a deinit backstop (a dropped instance can't pin the connection via
its drain thread), and the render scratch buffer is block-owned (was leaked per
session).

Verified live against the box: remote test decodes 100 host Opus packets to PCM and
the host opens its virtual mic on the first uplinked frame ("punktfunk/1 virtual mic
ready"); on-glass session runs with both engines up.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:39:15 +02:00

89 lines
3.4 KiB
Swift

// CoreAudio HAL device enumeration for the Settings pickers. Devices are persisted by
// UID (stable across reboots/replugs AudioDeviceIDs are not); the empty UID means
// "system default", which additionally tracks default-device changes because we then
// never pin the engine to a concrete device.
#if os(macOS)
import CoreAudio
import Foundation
public struct AudioDevice: Hashable, Identifiable, Sendable {
public let uid: String
public let name: String
public var id: String { uid }
}
public enum AudioDevices {
/// Output-capable devices (speakers, headphones, multi-output).
public static func outputs() -> [AudioDevice] {
all().filter { hasStreams($0, scope: kAudioObjectPropertyScopeOutput) }
.compactMap(describe)
}
/// Input-capable devices (microphones, interfaces).
public static func inputs() -> [AudioDevice] {
all().filter { hasStreams($0, scope: kAudioObjectPropertyScopeInput) }
.compactMap(describe)
}
/// Resolve a persisted UID to the current AudioDeviceID nil when unplugged.
static func deviceID(forUID uid: String) -> AudioDeviceID? {
all().first { id in
stringProperty(id, kAudioDevicePropertyDeviceUID) == uid
}
}
private static func all() -> [AudioDeviceID] {
var address = AudioObjectPropertyAddress(
mSelector: kAudioHardwarePropertyDevices,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain)
var size: UInt32 = 0
guard AudioObjectGetPropertyDataSize(
AudioObjectID(kAudioObjectSystemObject), &address, 0, nil, &size) == noErr,
size > 0
else { return [] }
var ids = [AudioDeviceID](
repeating: 0, count: Int(size) / MemoryLayout<AudioDeviceID>.size)
guard AudioObjectGetPropertyData(
AudioObjectID(kAudioObjectSystemObject), &address, 0, nil, &size, &ids) == noErr
else { return [] }
return ids
}
private static func hasStreams(
_ id: AudioDeviceID, scope: AudioObjectPropertyScope
) -> Bool {
var address = AudioObjectPropertyAddress(
mSelector: kAudioDevicePropertyStreams,
mScope: scope,
mElement: kAudioObjectPropertyElementMain)
var size: UInt32 = 0
return AudioObjectGetPropertyDataSize(id, &address, 0, nil, &size) == noErr && size > 0
}
private static func describe(_ id: AudioDeviceID) -> AudioDevice? {
guard let uid = stringProperty(id, kAudioDevicePropertyDeviceUID),
let name = stringProperty(id, kAudioObjectPropertyName)
else { return nil }
return AudioDevice(uid: uid, name: name)
}
private static func stringProperty(
_ id: AudioDeviceID, _ selector: AudioObjectPropertySelector
) -> String? {
var address = AudioObjectPropertyAddress(
mSelector: selector,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain)
var ref: CFString?
var size = UInt32(MemoryLayout<CFString?>.size)
let status = withUnsafeMutablePointer(to: &ref) { p in
AudioObjectGetPropertyData(id, &address, 0, nil, &size, p)
}
guard status == noErr, let ref else { return nil }
return ref as String
}
}
#endif