diff --git a/slab/macos-audio/Sources/ACMacAudio/ProcessAudioTap.swift b/slab/macos-audio/Sources/ACMacAudio/ProcessAudioTap.swift index 703f4a9792..0ecbbf3413 100644 --- a/slab/macos-audio/Sources/ACMacAudio/ProcessAudioTap.swift +++ b/slab/macos-audio/Sources/ACMacAudio/ProcessAudioTap.swift @@ -77,6 +77,24 @@ public final class ACProcessAudioTap: @unchecked Sendable { ] status = AudioHardwareCreateAggregateDevice(aggregateDescription as CFDictionary, &aggregateID) guard status == noErr else { throw ACAudioRoomError.coreAudio("Create tap aggregate", status) } + // The IOProc runs on the AGGREGATE's clock (its main subdevice is + // the current output device) and drift compensation delivers the + // tap's samples AT THAT RATE. On a 96 kHz interface, a tap that + // claims 48 kHz would otherwise have its buffers wrapped and + // paced as 48 kHz: audio at the wrong pitch and a presentation + // clock racing 2× real time, so receivers chase chunks scheduled + // for a future that never arrives. Trust the aggregate's rate. + if let aggregateRate = try? Self.read(aggregateID, + selector: kAudioDevicePropertyNominalSampleRate, + defaultValue: Double(0)), + aggregateRate > 0, abs(aggregateRate - format.sampleRate) > 1 { + var corrected = stream + corrected.mSampleRate = aggregateRate + if let correctedFormat = AVAudioFormat(streamDescription: &corrected) { + self.format = correctedFormat + onLog?("tap delivers at aggregate rate \(Int(aggregateRate)) Hz (tap claimed \(Int(format.sampleRate)))") + } + } self.handler = handler status = AudioDeviceCreateIOProcIDWithBlock(&ioProcID, aggregateID, queue) { [weak self] _, inputData, _, _, _ in diff --git a/slab/macos-audio/Sources/ACMacAudio/RoomSender.swift b/slab/macos-audio/Sources/ACMacAudio/RoomSender.swift index bfd15bdf1b..e211bffadb 100644 --- a/slab/macos-audio/Sources/ACMacAudio/RoomSender.swift +++ b/slab/macos-audio/Sources/ACMacAudio/RoomSender.swift @@ -173,6 +173,19 @@ public final class ACAudioRoomSender: @unchecked Sendable { } lastAudioNanos = now + // Insurance against pacing bugs (a wrong input rate, device quirks): + // if the presentation clock drifts more than a second from + // wall-clock + latency, resync with a fresh session rather than + // keep scheduling audio for a time that never comes. + let target = now + configuration.latencyMilliseconds * 1_000_000 + let drift = Int64(bitPattern: nextPresentationNanos &- target) + if abs(drift) > 1_000_000_000 { + log(String(format: "presentation clock drifted %+.1f s — resyncing", Double(drift) / 1e9)) + session = UInt32.random(in: 1...UInt32.max) + sequence = 0 + nextPresentationNanos = target + } + var offset = 0 let total = Int(output.frameLength) while offset < total {