fix(macos): prevent Talk Mode audio hang
This commit is contained in:
@@ -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<TalkPlaybackResult, Never>?
|
||||
private var playback: Playback?
|
||||
|
||||
private final class Playback: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var finished = false
|
||||
private var continuation: CheckedContinuation<TalkPlaybackResult, Never>?
|
||||
private var watchdog: Task<Void, Never>?
|
||||
|
||||
func setContinuation(_ continuation: CheckedContinuation<TalkPlaybackResult, Never>) {
|
||||
self.lock.lock()
|
||||
defer { self.lock.unlock() }
|
||||
self.continuation = continuation
|
||||
}
|
||||
|
||||
func setWatchdog(_ task: Task<Void, Never>?) {
|
||||
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<TalkPlaybackResult, Never>?
|
||||
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))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
79
apps/macos/Tests/ClawdisIPCTests/TalkAudioPlayerTests.swift
Normal file
79
apps/macos/Tests/ClawdisIPCTests/TalkAudioPlayerTests.swift
Normal file
@@ -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<T: Sendable>(
|
||||
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) }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user