diff --git a/Sources/TinyHand/TapSound.swift b/Sources/TinyHand/TapSound.swift index ef83451..42886d0 100644 --- a/Sources/TinyHand/TapSound.swift +++ b/Sources/TinyHand/TapSound.swift @@ -16,58 +16,162 @@ enum TapSoundChoice: String, CaseIterable { } } +/// Plays the tap sound. Every AVAudioEngine call runs on `queue`, never on the main thread: +/// when another app reshapes the output device (Google Meet sharing a tab or screen with audio), +/// the engine can fail every render cycle, and calls that wait on the render thread then stall +/// for seconds. Off the main thread such a stall costs a missing tap sound, not a frozen pointer. final class TapSound { - private let engine = AVAudioEngine() - private let player = AVAudioPlayerNode() - private var buffers: [TapSoundChoice: AVAudioPCMBuffer] = [:] + private static let players = 4 + private static let maxFramesToRender: AUAudioFrameCount = 4096 + + private let queue = DispatchQueue(label: "TinyHand tap sound", qos: .userInteractive) + private let buffers: [TapSoundChoice: AVAudioPCMBuffer] + private let format: AVAudioFormat + + // Touched only on `queue`. private var choice: TapSoundChoice + private var engine: AVAudioEngine? + private var nodes: [AVAudioPlayerNode] = [] + private var next = 0 + private var generation = 0 + private var configObserver: NSObjectProtocol? private var idleStop: DispatchWorkItem? + /// Read and written on the main thread. var enabled = true init(_ choice: TapSoundChoice = Preferences.tapSound) { self.choice = choice + var buffers: [TapSoundChoice: AVAudioPCMBuffer] = [:] for c in TapSoundChoice.allCases { if let buffer = TapSound.read(c) { buffers[c] = buffer } } - engine.attach(player) - let format = buffers.values.first?.format ?? AVAudioFormat(standardFormatWithSampleRate: 44100, channels: 1) - engine.connect(player, to: engine.mainMixerNode, format: format) - engine.prepare() + self.buffers = buffers + format = buffers.values.first?.format ?? AVAudioFormat(standardFormatWithSampleRate: 44100, channels: 1)! } func load(_ choice: TapSoundChoice) { - self.choice = choice + queue.async { [weak self] in + self?.choice = choice + } } func warm() { - start() + queue.async { [weak self] in + self?.start() + } } func play() { - guard enabled, let buffer = buffers[choice] else { + guard enabled else { return } - start() - player.scheduleBuffer(buffer, at: nil, options: .interrupts) - if !player.isPlaying { - player.play() + queue.async { [weak self] in + self?.playNow() } } - private func start() { - if !engine.isRunning { - try? engine.start() + private func playNow() { + guard let buffer = buffers[choice], start(), !nodes.isEmpty else { + return + } + let node = nodes[next % nodes.count] + next += 1 + let gen = generation + var rendered = false + let lock = NSLock() + node.scheduleBuffer(buffer, at: nil, options: [], completionCallbackType: .dataRendered) { _ in + lock.withLock { rendered = true } } + if !node.isPlaying { + node.play() + } + // A broken render loop never reaches the player, so the buffer is never rendered: + // throw the engine away and build a fresh one on the next tap. + let duration = Double(buffer.frameLength) / buffer.format.sampleRate + queue.asyncAfter(deadline: .now() + duration + 1) { [weak self] in + guard let self = self, gen == self.generation, !lock.withLock({ rendered }) else { + return + } + Log.write("tap sound was not rendered; rebuilding the audio engine") + self.teardown() + } + } + + /// Makes sure a running engine exists. Builds a fresh one rather than resuming an old one, + /// so it picks up the output device's current buffer size. Returns false if it can't start. + @discardableResult + private func start() -> Bool { + scheduleIdleStop() + if let engine = engine, engine.isRunning { + return true + } + teardown() + + let engine = AVAudioEngine() + var nodes: [AVAudioPlayerNode] = [] + for _ in 0.. AVAudioPCMBuffer? {