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 let logger = Logger(subsystem: "com.steipete.clawdis", category: "talk.tts")
|
||||||
private var player: AVAudioPlayer?
|
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 {
|
func play(data: Data) async -> TalkPlaybackResult {
|
||||||
self.stopInternal(interrupted: true)
|
self.stopInternal()
|
||||||
do {
|
|
||||||
let player = try AVAudioPlayer(data: data)
|
let playback = Playback()
|
||||||
self.player = player
|
self.playback = playback
|
||||||
player.delegate = self
|
|
||||||
player.prepareToPlay()
|
return await withCheckedContinuation { continuation in
|
||||||
player.play()
|
playback.setContinuation(continuation)
|
||||||
return await withCheckedContinuation { continuation in
|
do {
|
||||||
self.continuation = continuation
|
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? {
|
func stop() -> Double? {
|
||||||
guard let player else { return nil }
|
guard let player else { return nil }
|
||||||
let time = player.currentTime
|
let time = player.currentTime
|
||||||
self.stopInternal(interrupted: true, interruptedAt: time)
|
self.stopInternal(interruptedAt: time)
|
||||||
return time
|
return time
|
||||||
}
|
}
|
||||||
|
|
||||||
func audioPlayerDidFinishPlaying(_: AVAudioPlayer, successfully flag: Bool) {
|
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?.stop()
|
||||||
self.player = nil
|
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