From e3d8d5f3000a61df4cb0139288e40d1929c8bb96 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 30 Dec 2025 04:14:16 +0100 Subject: [PATCH] fix(macos): prevent Talk Mode audio hang --- .../Sources/Clawdis/TalkAudioPlayer.swift | 139 +++++++++++++++--- .../TalkAudioPlayerTests.swift | 79 ++++++++++ 2 files changed, 198 insertions(+), 20 deletions(-) create mode 100644 apps/macos/Tests/ClawdisIPCTests/TalkAudioPlayerTests.swift diff --git a/apps/macos/Sources/Clawdis/TalkAudioPlayer.swift b/apps/macos/Sources/Clawdis/TalkAudioPlayer.swift index b1df3886b..713ede40d 100644 --- a/apps/macos/Sources/Clawdis/TalkAudioPlayer.swift +++ b/apps/macos/Sources/Clawdis/TalkAudioPlayer.swift @@ -8,43 +8,142 @@ final class TalkAudioPlayer: NSObject, @preconcurrency AVAudioPlayerDelegate { private let logger = Logger(subsystem: "com.steipete.clawdis", category: "talk.tts") private var player: AVAudioPlayer? - private var continuation: CheckedContinuation? + private var playback: Playback? + + private final class Playback: @unchecked Sendable { + private let lock = NSLock() + private var finished = false + private var continuation: CheckedContinuation? + private var watchdog: Task? + + func setContinuation(_ continuation: CheckedContinuation) { + self.lock.lock() + defer { self.lock.unlock() } + self.continuation = continuation + } + + func setWatchdog(_ task: Task?) { + self.lock.lock() + let old = self.watchdog + self.watchdog = task + self.lock.unlock() + old?.cancel() + } + + func cancelWatchdog() { + self.setWatchdog(nil) + } + + func finish(_ result: TalkPlaybackResult) { + let continuation: CheckedContinuation? + self.lock.lock() + if self.finished { + continuation = nil + } else { + self.finished = true + continuation = self.continuation + self.continuation = nil + } + self.lock.unlock() + continuation?.resume(returning: result) + } + } func play(data: Data) async -> TalkPlaybackResult { - self.stopInternal(interrupted: true) - do { - let player = try AVAudioPlayer(data: data) - self.player = player - player.delegate = self - player.prepareToPlay() - player.play() - return await withCheckedContinuation { continuation in - self.continuation = continuation + self.stopInternal() + + let playback = Playback() + self.playback = playback + + return await withCheckedContinuation { continuation in + playback.setContinuation(continuation) + do { + let player = try AVAudioPlayer(data: data) + self.player = player + + player.delegate = self + player.prepareToPlay() + + self.armWatchdog(playback: playback) + + let ok = player.play() + if !ok { + self.logger.error("talk audio player refused to play") + self.finish(playback: playback, result: TalkPlaybackResult(finished: false, interruptedAt: nil)) + } + } catch { + self.logger.error("talk audio player failed: \(error.localizedDescription, privacy: .public)") + self.finish(playback: playback, result: TalkPlaybackResult(finished: false, interruptedAt: nil)) } - } catch { - self.logger.error("talk audio player failed: \(error.localizedDescription, privacy: .public)") - return TalkPlaybackResult(finished: false, interruptedAt: nil) } } func stop() -> Double? { guard let player else { return nil } let time = player.currentTime - self.stopInternal(interrupted: true, interruptedAt: time) + self.stopInternal(interruptedAt: time) return time } func audioPlayerDidFinishPlaying(_: AVAudioPlayer, successfully flag: Bool) { - self.stopInternal(interrupted: !flag) + self.stopInternal(finished: flag) } - private func stopInternal(interrupted: Bool, interruptedAt: Double? = nil) { + private func stopInternal(finished: Bool = false, interruptedAt: Double? = nil) { + guard let playback else { return } + let result = TalkPlaybackResult(finished: finished, interruptedAt: interruptedAt) + self.finish(playback: playback, result: result) + } + + private func finish(playback: Playback, result: TalkPlaybackResult) { + playback.cancelWatchdog() + playback.finish(result) + + guard self.playback === playback else { return } + self.playback = nil self.player?.stop() self.player = nil - if let continuation { - self.continuation = nil - continuation.resume(returning: TalkPlaybackResult(finished: !interrupted, interruptedAt: interruptedAt)) - } + } + + private func stopInternal() { + self.playback?.cancelWatchdog() + self.playback = nil + self.player?.stop() + self.player = nil + } + + private func armWatchdog(playback: Playback) { + playback.setWatchdog(Task { @MainActor [weak self] in + guard let self else { return } + + do { + try await Task.sleep(nanoseconds: 650_000_000) + } catch { + return + } + if Task.isCancelled { return } + + guard self.playback === playback else { return } + if self.player?.isPlaying != true { + self.logger.error("talk audio player did not start playing") + self.finish(playback: playback, result: TalkPlaybackResult(finished: false, interruptedAt: nil)) + return + } + + let duration = self.player?.duration ?? 0 + let timeoutSeconds = min(max(2.0, duration + 2.0), 5 * 60.0) + do { + try await Task.sleep(nanoseconds: UInt64(timeoutSeconds * 1_000_000_000)) + } catch { + return + } + if Task.isCancelled { return } + + guard self.playback === playback else { return } + guard self.player?.isPlaying == true else { return } + self.logger.error("talk audio player watchdog fired") + self.finish(playback: playback, result: TalkPlaybackResult(finished: false, interruptedAt: nil)) + }) } } diff --git a/apps/macos/Tests/ClawdisIPCTests/TalkAudioPlayerTests.swift b/apps/macos/Tests/ClawdisIPCTests/TalkAudioPlayerTests.swift new file mode 100644 index 000000000..e0278e4bb --- /dev/null +++ b/apps/macos/Tests/ClawdisIPCTests/TalkAudioPlayerTests.swift @@ -0,0 +1,79 @@ +import Foundation +import Testing +@testable import Clawdis + +@Suite(.serialized) struct TalkAudioPlayerTests { + @MainActor + @Test func playDoesNotHangWhenPlaybackEndsOrFails() async throws { + let wav = makeWav16Mono(sampleRate: 8000, samples: 80) + defer { _ = TalkAudioPlayer.shared.stop() } + + _ = try await withTimeout(seconds: 2.0) { + await TalkAudioPlayer.shared.play(data: wav) + } + + #expect(true) + } +} + +private struct TimeoutError: Error {} + +private func withTimeout( + seconds: Double, + _ work: @escaping @Sendable () async throws -> T) async throws -> T +{ + try await withThrowingTaskGroup(of: T.self) { group in + group.addTask { + try await work() + } + group.addTask { + try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) + throw TimeoutError() + } + let result = try await group.next() + group.cancelAll() + guard let result else { throw TimeoutError() } + return result + } +} + +private func makeWav16Mono(sampleRate: UInt32, samples: Int) -> Data { + let channels: UInt16 = 1 + let bitsPerSample: UInt16 = 16 + let blockAlign = channels * (bitsPerSample / 8) + let byteRate = sampleRate * UInt32(blockAlign) + let dataSize = UInt32(samples) * UInt32(blockAlign) + + var data = Data() + data.append(contentsOf: [0x52, 0x49, 0x46, 0x46]) // RIFF + data.appendLEUInt32(36 + dataSize) + data.append(contentsOf: [0x57, 0x41, 0x56, 0x45]) // WAVE + + data.append(contentsOf: [0x66, 0x6D, 0x74, 0x20]) // fmt + data.appendLEUInt32(16) // PCM + data.appendLEUInt16(1) // audioFormat + data.appendLEUInt16(channels) + data.appendLEUInt32(sampleRate) + data.appendLEUInt32(byteRate) + data.appendLEUInt16(blockAlign) + data.appendLEUInt16(bitsPerSample) + + data.append(contentsOf: [0x64, 0x61, 0x74, 0x61]) // data + data.appendLEUInt32(dataSize) + + // Silence samples. + data.append(Data(repeating: 0, count: Int(dataSize))) + return data +} + +private extension Data { + mutating func appendLEUInt16(_ value: UInt16) { + var v = value.littleEndian + Swift.withUnsafeBytes(of: &v) { append(contentsOf: $0) } + } + + mutating func appendLEUInt32(_ value: UInt32) { + var v = value.littleEndian + Swift.withUnsafeBytes(of: &v) { append(contentsOf: $0) } + } +}