diff --git a/clients/apple/Punktfunk.xcodeproj/project.pbxproj b/clients/apple/Punktfunk.xcodeproj/project.pbxproj index 78d594f..d7addd1 100644 --- a/clients/apple/Punktfunk.xcodeproj/project.pbxproj +++ b/clients/apple/Punktfunk.xcodeproj/project.pbxproj @@ -257,6 +257,7 @@ INFOPLIST_KEY_CFBundleDisplayName = "Punktfunkempfänger"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INFOPLIST_KEY_NSMicrophoneUsageDescription = "Your microphone is streamed to the connected punktfunk host, where it appears as a virtual microphone."; INFOPLIST_KEY_NSPrincipalClass = NSApplication; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -283,6 +284,7 @@ INFOPLIST_KEY_CFBundleDisplayName = "Punktfunkempfänger"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INFOPLIST_KEY_NSMicrophoneUsageDescription = "Your microphone is streamed to the connected punktfunk host, where it appears as a virtual microphone."; INFOPLIST_KEY_NSPrincipalClass = NSApplication; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", diff --git a/clients/apple/README.md b/clients/apple/README.md index 5e346a2..ec43e7e 100644 --- a/clients/apple/README.md +++ b/clients/apple/README.md @@ -117,10 +117,18 @@ signing, bundle id `io.unom.punktfunk`. Notes: control (ProMotion/120 Hz), glass-to-glass measurement via `tools/latency-probe` (the host stamps `pts_ns` with its capture wall clock; across machines you need a clock offset estimate from the QUIC RTT). -5. **Audio**: `nextAudio()` yields raw Opus packets (48 kHz stereo, one 5 ms frame each, - sequence-numbered). The inverse direction exists too: `sendMic(_:seq:ptsNs:)` uplinks - the client's mic as Opus frames into a virtual PipeWire source on the host (wire it - to AVAudioEngine input + an Opus encoder alongside playback). Decode with libopus or `AVAudioConverter`/`kAudioFormatOpus` into an +5. **Audio — wired, both directions.** Playback: `SessionAudio` drains `nextAudio()` + on its own thread, decodes through CoreAudio's built-in Opus codec (`OpusCodec.swift` + — kAudioFormatOpus, no bundled libopus; round-trip unit-tested) into a priming + jitter ring feeding an `AVAudioSourceNode`. Mic: a second engine taps the input + device, resamples to 48 kHz stereo, Opus-encodes 20 ms chunks and `sendMic()`s them + (the host's virtual PipeWire source accepts any frame size ≤ 120 ms). Speaker/mic + are chosen in Settings (`AudioDevices.swift` — persisted by UID; "System default" + leaves the engines unpinned so they follow macOS device changes), mic on/off toggle + included; the app asks for mic permission on first use + (NSMicrophoneUsageDescription is in the Xcode target). A/V sync and packet-loss + concealment beyond silence-fill are still open (AudioPacket.seq/ptsNs carry what's + needed). Decode with libopus or `AVAudioConverter`/`kAudioFormatOpus` into an `AVAudioEngine` source node; conceal gaps (drop/dup) rather than blocking — the Rust side buffers 320 ms and drops the newest packet when the puller lags. Wall-clock `ptsNs` shares the host clock with video AUs for A/V sync. Wiring this into diff --git a/clients/apple/Sources/PunktfunkClient/SessionModel.swift b/clients/apple/Sources/PunktfunkClient/SessionModel.swift index d5ac397..554677d 100644 --- a/clients/apple/Sources/PunktfunkClient/SessionModel.swift +++ b/clients/apple/Sources/PunktfunkClient/SessionModel.swift @@ -59,6 +59,7 @@ final class SessionModel: ObservableObject { let meter = FrameMeter() private var statsTimer: Timer? + private var audio: SessionAudio? var isBusy: Bool { phase != .idle } @@ -82,6 +83,15 @@ final class SessionModel: ObservableObject { pinSHA256: pin, identity: identity, compositor: compositor) } await MainActor.run { [weak self] in guard let self else { return } + // The user may have abandoned this attempt (window closed, another host + // clicked) while the handshake was in flight — don't resurrect a session + // for a dead window, and especially don't start its mic uplink. + guard self.phase == .connecting, self.activeHost?.id == host.id else { + if case .success(let conn) = result { + Task.detached { conn.close() } // joins Rust threads — off-main + } + return + } switch result { case .success(let conn): self.connection = conn @@ -123,10 +133,18 @@ final class SessionModel: ObservableObject { func disconnect() { statsTimer?.invalidate() statsTimer = nil + let audio = self.audio + self.audio = nil if let conn = connection { - // close() waits out an in-flight poll (≤100 ms) and joins the Rust worker - // threads — keep that off the main actor. - Task.detached { conn.close() } + // Audio teardown waits its drain thread out and close() waits out in-flight + // polls + joins the Rust worker threads — keep both off the main actor, in + // this order (no audio poll left when the handle is freed). + Task.detached { + audio?.stop() + conn.close() + } + } else { + Task.detached { audio?.stop() } } connection = nil activeHost = nil @@ -145,10 +163,20 @@ final class SessionModel: ObservableObject { } private func beginStreaming() { - guard connection != nil else { return } + guard let conn = connection else { return } // Input capture itself is owned by StreamView (engaged by the captureEnabled // flip this phase change causes, released/re-engaged by the user from there). phase = .streaming + // Audio starts with streaming, not during the trust prompt — no host sound (or + // mic uplink!) before the user trusted the host. Devices come from Settings; + // "" = system default. + let defaults = UserDefaults.standard + let audio = SessionAudio(connection: conn) + audio.start( + speakerUID: defaults.string(forKey: "punktfunk.speakerUID") ?? "", + micUID: defaults.string(forKey: "punktfunk.micUID") ?? "", + micEnabled: defaults.object(forKey: "punktfunk.micEnabled") as? Bool ?? true) + self.audio = audio } private func startStatsTimer() { diff --git a/clients/apple/Sources/PunktfunkClient/SettingsView.swift b/clients/apple/Sources/PunktfunkClient/SettingsView.swift index f689fd8..2891390 100644 --- a/clients/apple/Sources/PunktfunkClient/SettingsView.swift +++ b/clients/apple/Sources/PunktfunkClient/SettingsView.swift @@ -11,6 +11,11 @@ struct SettingsView: View { @AppStorage("punktfunk.height") private var height = 1080 @AppStorage("punktfunk.hz") private var hz = 60 @AppStorage("punktfunk.compositor") private var compositor = 0 + @AppStorage("punktfunk.speakerUID") private var speakerUID = "" + @AppStorage("punktfunk.micUID") private var micUID = "" + @AppStorage("punktfunk.micEnabled") private var micEnabled = true + @State private var outputDevices: [AudioDevice] = [] + @State private var inputDevices: [AudioDevice] = [] var body: some View { Form { @@ -33,6 +38,38 @@ struct SettingsView: View { .font(.caption) .foregroundStyle(.secondary) } + Section { + Picker("Speaker", selection: $speakerUID) { + Text("System default").tag("") + ForEach(outputDevices) { device in + Text(device.name).tag(device.uid) + } + if !speakerUID.isEmpty, + !outputDevices.contains(where: { $0.uid == speakerUID }) { + Text("Unavailable device").tag(speakerUID) + } + } + Toggle("Send microphone to the host", isOn: $micEnabled) + Picker("Microphone", selection: $micUID) { + Text("System default").tag("") + ForEach(inputDevices) { device in + Text(device.name).tag(device.uid) + } + if !micUID.isEmpty, + !inputDevices.contains(where: { $0.uid == micUID }) { + Text("Unavailable device").tag(micUID) + } + } + .disabled(!micEnabled) + } header: { + Text("Audio") + } footer: { + Text("Host audio plays through the speaker; the microphone feeds the " + + "host's virtual mic. System default follows macOS device changes. " + + "Applies from the next session.") + .font(.caption) + .foregroundStyle(.secondary) + } Section { Picker("Compositor", selection: $compositor) { Text("Automatic").tag(0) @@ -54,6 +91,10 @@ struct SettingsView: View { .formStyle(.grouped) .frame(width: 380) .fixedSize() + .onAppear { + outputDevices = AudioDevices.outputs() + inputDevices = AudioDevices.inputs() + } } private func fillFromMainScreen() { diff --git a/clients/apple/Sources/PunktfunkKit/AudioDevices.swift b/clients/apple/Sources/PunktfunkKit/AudioDevices.swift new file mode 100644 index 0000000..f2113af --- /dev/null +++ b/clients/apple/Sources/PunktfunkKit/AudioDevices.swift @@ -0,0 +1,88 @@ +// 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.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.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 diff --git a/clients/apple/Sources/PunktfunkKit/OpusCodec.swift b/clients/apple/Sources/PunktfunkKit/OpusCodec.swift new file mode 100644 index 0000000..7261cd3 --- /dev/null +++ b/clients/apple/Sources/PunktfunkKit/OpusCodec.swift @@ -0,0 +1,140 @@ +// Opus ⇄ PCM through CoreAudio's built-in codec (kAudioFormatOpus, macOS 10.13+ / iOS +// 11+) — no bundled libopus. The host's audio plane is raw Opus packets (48 kHz stereo, +// one frame per packet); AVAudioConverter handles them as single-packet +// AVAudioCompressedBuffers with explicit packet descriptions. +// +// Both classes are single-threaded by contract (one per direction, owned by their +// drain/capture pipelines). + +import AVFoundation + +enum OpusCodecError: Error { + /// CoreAudio rejected the Opus stream format or had no converter for it. + case unavailable + case convertFailed(String) +} + +/// 48 kHz stereo float32 interleaved — the PCM side of both converters and the layout +/// of the playback ring buffer. +func opusPCMFormat() -> AVAudioFormat? { + AVAudioFormat( + commonFormat: .pcmFormatFloat32, sampleRate: 48_000, channels: 2, interleaved: true) +} + +/// The compressed side: raw Opus, `framesPerPacket` nominal samples per packet at 48 kHz +/// (240 = the host's 5 ms audio plane; 960 = the 20 ms packets the encoder emits). +private func opusFormat(framesPerPacket: UInt32) -> AVAudioFormat? { + var desc = AudioStreamBasicDescription( + mSampleRate: 48_000, + mFormatID: kAudioFormatOpus, + mFormatFlags: 0, + mBytesPerPacket: 0, + mFramesPerPacket: framesPerPacket, + mBytesPerFrame: 0, + mChannelsPerFrame: 2, + mBitsPerChannel: 0, + mReserved: 0) + return AVAudioFormat(streamDescription: &desc) +} + +final class OpusDecoder { + private let converter: AVAudioConverter + private let inBuf: AVAudioCompressedBuffer + private let opus: AVAudioFormat + let pcmFormat: AVAudioFormat + + /// `framesPerPacket`: the sender's packet duration in samples (host audio = 240). + init(framesPerPacket: UInt32) throws { + guard let pcm = opusPCMFormat(), let opus = opusFormat(framesPerPacket: framesPerPacket), + let converter = AVAudioConverter(from: opus, to: pcm) + else { throw OpusCodecError.unavailable } + self.converter = converter + self.opus = opus + self.pcmFormat = pcm + inBuf = AVAudioCompressedBuffer( + format: opus, packetCapacity: 1, maximumPacketSize: 1500) + } + + /// Decode one Opus packet into `out` (whose format must be `pcmFormat`); returns the + /// number of frames written. Empty packets (DTX) decode to 0 frames. + func decode(_ packet: Data, into out: AVAudioPCMBuffer) throws -> AVAudioFrameCount { + guard !packet.isEmpty else { return 0 } + guard packet.count <= Int(inBuf.maximumPacketSize) else { + throw OpusCodecError.convertFailed("packet larger than maximumPacketSize") + } + packet.withUnsafeBytes { raw in + inBuf.data.copyMemory(from: raw.baseAddress!, byteCount: raw.count) + } + inBuf.byteLength = UInt32(packet.count) + inBuf.packetCount = 1 + inBuf.packetDescriptions![0] = AudioStreamPacketDescription( + mStartOffset: 0, mVariableFramesInPacket: 0, mDataByteSize: UInt32(packet.count)) + + out.frameLength = 0 + var fed = false + var convError: NSError? + let status = converter.convert(to: out, error: &convError) { [inBuf] _, outStatus in + if fed { + outStatus.pointee = .noDataNow + return nil + } + fed = true + outStatus.pointee = .haveData + return inBuf + } + if status == .error { + throw OpusCodecError.convertFailed(convError?.localizedDescription ?? "decode") + } + return out.frameLength + } +} + +final class OpusEncoder { + /// The encoder's packet duration: 960 samples = 20 ms, CoreAudio's default Opus + /// framing. The host's mic service decodes any Opus frame size up to 120 ms. + static let framesPerPacket: AVAudioFrameCount = 960 + + private let converter: AVAudioConverter + private let outBuf: AVAudioCompressedBuffer + let pcmFormat: AVAudioFormat + + init() throws { + guard let pcm = opusPCMFormat(), + let opus = opusFormat(framesPerPacket: UInt32(Self.framesPerPacket)), + let converter = AVAudioConverter(from: pcm, to: opus) + else { throw OpusCodecError.unavailable } + converter.bitRate = 96_000 + self.converter = converter + self.pcmFormat = pcm + outBuf = AVAudioCompressedBuffer( + format: opus, packetCapacity: 4, maximumPacketSize: 1500) + } + + /// Encode exactly `framesPerPacket` frames of `pcmFormat` audio; returns the encoded + /// packets (normally one). + func encode(_ pcm: AVAudioPCMBuffer) throws -> [Data] { + outBuf.byteLength = 0 + outBuf.packetCount = 0 + var fed = false + var convError: NSError? + let status = converter.convert(to: outBuf, error: &convError) { _, outStatus in + if fed { + outStatus.pointee = .noDataNow + return nil + } + fed = true + outStatus.pointee = .haveData + return pcm + } + if status == .error { + throw OpusCodecError.convertFailed(convError?.localizedDescription ?? "encode") + } + guard let descs = outBuf.packetDescriptions else { return [] } + return (0.., count: Int) { + lock.lock() + defer { lock.unlock() } + let capacity = buf.count + if writeIdx + count - readIdx > capacity { + readIdx = writeIdx + count - capacity // overflow: drop oldest + } + for i in 0.. highWater { + readIdx = writeIdx - prefill * 2 + } + } + + /// Fills `out` completely (silence beyond what's buffered). + func read(into out: UnsafeMutablePointer, count: Int) { + lock.lock() + defer { lock.unlock() } + renderQuantum = max(renderQuantum, count) + let available = writeIdx - readIdx + if !primed { + // 480 samples = one 5 ms host packet of slack beyond the device's demand. + if available >= max(prefill, renderQuantum + 480) { + primed = true + } else { + for i in 0...allocate(capacity: 8192 * 2) + deinit { ptr.deallocate() } +} + +public final class SessionAudio { + private let connection: PunktfunkConnection + private let flag = StopFlag() + private let drainDone = DispatchSemaphore(value: 0) + /// Owns the engine handles + drainStarted, paired with `flag`: stop() sets the flag + /// BEFORE taking the engines, every publisher re-checks the flag under this lock + /// after publishing-side work — so a startCapture racing stop() (the mic-permission + /// callback arrives whenever the user clicks the prompt) can never leave a hot + /// microphone with no owner. + private let stateLock = NSLock() + private var playbackEngine: AVAudioEngine? + private var captureEngine: AVAudioEngine? + private var drainStarted = false + + public init(connection: PunktfunkConnection) { + self.connection = connection + } + + /// Backstop for an owner dropping us without stop() — unblocks the drain thread + /// (which captures the connection strongly, NOT self) within one poll timeout. + /// Engine teardown still belongs to stop(). + deinit { + flag.stop() + } + + /// Start playback (and, if enabled+authorized, the mic uplink). Empty UIDs = system + /// default device. Main thread (engine setup); returns after the engines start — + /// the mic may start slightly later if the permission prompt is pending. + public func start(speakerUID: String, micUID: String, micEnabled: Bool) { + startPlayback(speakerUID: speakerUID) + guard micEnabled else { return } + switch AVCaptureDevice.authorizationStatus(for: .audio) { + case .authorized: + startCapture(micUID: micUID) + case .notDetermined: + AVCaptureDevice.requestAccess(for: .audio) { [weak self] granted in + DispatchQueue.main.async { + guard let self, granted, !self.flag.isStopped else { return } + self.startCapture(micUID: micUID) + } + } + default: + log.warning("microphone access denied — mic uplink disabled (System Settings → Privacy)") + } + } + + /// Stop both directions. Safe from any thread; waits the drain thread out (≤ its + /// poll timeout) so the caller can close the connection right after. + public func stop() { + flag.stop() // before taking the engines — see stateLock's comment + stateLock.lock() + let capture = captureEngine + captureEngine = nil + let playback = playbackEngine + playbackEngine = nil + let wasDraining = drainStarted + drainStarted = false + stateLock.unlock() + if let capture { + capture.inputNode.removeTap(onBus: 0) + capture.stop() + } + playback?.stop() + if wasDraining { + _ = drainDone.wait(timeout: .now() + .milliseconds(400)) + } + } + + // MARK: - Playback (host → speaker) + + private func startPlayback(speakerUID: String) { + // 1 s of interleaved stereo capacity, ~20 ms prefill: four 5 ms host packets of + // jitter absorption before the first sample plays. + let ring = AudioRing(capacity: 96_000, prefill: 1920) + + let engine = AVAudioEngine() + if !speakerUID.isEmpty { + if let dev = AudioDevices.deviceID(forUID: speakerUID), + let unit = engine.outputNode.audioUnit { + if !Self.setDevice(dev, on: unit) { + log.error("could not select speaker \(speakerUID) — using default") + } + } else { + log.warning("speaker \(speakerUID) not present — using default") + } + } + + // Engine-native deinterleaved float; the render block deinterleaves from the ring. + guard let format = AVAudioFormat(standardFormatWithSampleRate: 48_000, channels: 2) + else { return } + let scratch = ScratchBuffer() // block-owned; freed with the closure + let source = AVAudioSourceNode(format: format) { _, _, frameCount, abl -> OSStatus in + let frames = Int(frameCount) + guard frames <= 8192 else { return kAudioUnitErr_TooManyFramesToProcess } + ring.read(into: scratch.ptr, count: frames * 2) + let buffers = UnsafeMutableAudioBufferListPointer(abl) + if buffers.count >= 2, + let left = buffers[0].mData?.assumingMemoryBound(to: Float.self), + let right = buffers[1].mData?.assumingMemoryBound(to: Float.self) { + for f in 0.. 0, let p = pcm.floatChannelData?[0] { + ring.write(p, count: Int(frames) * 2) + } + } catch { + // One corrupt packet ≠ a dead stream; skip it. + log.warning("audio decode failed: \(error.localizedDescription)") + } + } + } + thread.name = "punktfunk-audio" + thread.qualityOfService = .userInteractive + thread.start() + } + + // MARK: - Mic (mic → host) + + private func startCapture(micUID: String) { + let engine = AVAudioEngine() + let input = engine.inputNode + if !micUID.isEmpty { + if let dev = AudioDevices.deviceID(forUID: micUID), let unit = input.audioUnit { + if !Self.setDevice(dev, on: unit) { + log.error("could not select microphone \(micUID) — using default") + } + } else { + log.warning("microphone \(micUID) not present — using default") + } + } + + let inFormat = input.outputFormat(forBus: 0) + guard inFormat.sampleRate > 0, inFormat.channelCount > 0 else { + log.error("no usable input device — mic uplink disabled") + return + } + guard let encoder = try? OpusEncoder(), + let resampler = AVAudioConverter(from: inFormat, to: encoder.pcmFormat), + let chunk = AVAudioPCMBuffer( + pcmFormat: encoder.pcmFormat, frameCapacity: OpusEncoder.framesPerPacket) + else { + log.error("Opus encoder unavailable — mic uplink disabled") + return + } + + // Tap-thread-confined state: resample into `staging`, accumulate in `fifo`, + // slice 960-frame chunks for the encoder. + var fifo: [Float] = [] + fifo.reserveCapacity(48_000) + var seq: UInt32 = 0 + let connection = connection + let flag = flag + + input.installTap(onBus: 0, bufferSize: 2048, format: inFormat) { buffer, _ in + if flag.isStopped { return } + let ratio = 48_000 / inFormat.sampleRate + let outCapacity = AVAudioFrameCount( + (Double(buffer.frameLength) * ratio).rounded(.up) + 64) + guard let staging = AVAudioPCMBuffer( + pcmFormat: encoder.pcmFormat, frameCapacity: outCapacity) + else { return } + var fed = false + var convError: NSError? + let status = resampler.convert(to: staging, error: &convError) { _, outStatus in + if fed { + outStatus.pointee = .noDataNow + return nil + } + fed = true + outStatus.pointee = .haveData + return buffer + } + guard status != .error, let p = staging.floatChannelData?[0] else { return } + fifo.append(contentsOf: UnsafeBufferPointer( + start: p, count: Int(staging.frameLength) * 2)) + + let samplesPerChunk = Int(OpusEncoder.framesPerPacket) * 2 + while fifo.count >= samplesPerChunk { + chunk.frameLength = OpusEncoder.framesPerPacket + fifo.withUnsafeBufferPointer { src in + chunk.floatChannelData![0].update( + from: src.baseAddress!, count: samplesPerChunk) + } + fifo.removeFirst(samplesPerChunk) + guard let packets = try? encoder.encode(chunk) else { continue } + for packet in packets { + connection.sendMic( + packet, seq: seq, ptsNs: DispatchTime.now().uptimeNanoseconds) + seq &+= 1 + } + } + } + + engine.prepare() + do { + try engine.start() + } catch { + log.error("capture engine failed to start: \(error.localizedDescription)") + input.removeTap(onBus: 0) + return + } + stateLock.lock() + if flag.isStopped { + // stop() ran while we were starting (the permission prompt resolves at the + // user's leisure) — tear the engine down ourselves, nobody else owns it now. + stateLock.unlock() + input.removeTap(onBus: 0) + engine.stop() + return + } + captureEngine = engine + stateLock.unlock() + log.info("mic uplink started (\(micUID.isEmpty ? "default input" : micUID))") + } + + private static func setDevice(_ id: AudioDeviceID, on unit: AudioUnit) -> Bool { + var dev = id + return AudioUnitSetProperty( + unit, kAudioOutputUnitProperty_CurrentDevice, kAudioUnitScope_Global, 0, + &dev, UInt32(MemoryLayout.size)) == noErr + } +} +#endif diff --git a/clients/apple/Tests/PunktfunkKitTests/OpusCodecTests.swift b/clients/apple/Tests/PunktfunkKitTests/OpusCodecTests.swift new file mode 100644 index 0000000..3969441 --- /dev/null +++ b/clients/apple/Tests/PunktfunkKitTests/OpusCodecTests.swift @@ -0,0 +1,83 @@ +// The Opus codec through CoreAudio (kAudioFormatOpus): a real encode → decode round +// trip. This is the load-bearing assumption of the whole audio feature (no bundled +// libopus) — if AVAudioConverter can't handle raw Opus packets, fail HERE, not in the +// app. + +import AVFoundation +import XCTest + +@testable import PunktfunkKit + +final class OpusCodecTests: XCTestCase { + /// Encode a 440 Hz stereo tone, decode it back, and require the result to be + /// recognizably the same signal (Opus is lossy — check correlation, not bytes). + func testEncodeDecodeRoundTripPreservesTone() throws { + let encoder = try OpusEncoder() + let decoder = try OpusDecoder(framesPerPacket: UInt32(OpusEncoder.framesPerPacket)) + let pcmFormat = encoder.pcmFormat + + let frames = OpusEncoder.framesPerPacket + var packets: [Data] = [] + var phase: Float = 0 + let step = 2 * Float.pi * 440 / 48_000 + + // 50 packets = 1 s of tone. + for _ in 0..<50 { + let buf = AVAudioPCMBuffer(pcmFormat: pcmFormat, frameCapacity: frames)! + buf.frameLength = frames + let p = buf.floatChannelData![0] // interleaved: one plane, L R L R … + for f in 0..