Skip to content

Commit 142d4ed

Browse files
author
Peter Steinberger
committed
fix(ios): ignore stale audio callbacks
1 parent f95d836 commit 142d4ed

2 files changed

Lines changed: 75 additions & 14 deletions

File tree

apps/ios/Sources/Voice/TalkGatewaySpeechClient.swift

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -197,20 +197,27 @@ final class TalkBufferedAudioPlayer: NSObject, TalkBufferedAudioPlaying, @precon
197197
return interruptedAt
198198
}
199199

200-
func audioPlayerDidFinishPlaying(_: AVAudioPlayer, successfully flag: Bool) {
200+
func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) {
201201
self.finish(
202-
playback: self.playback,
202+
playback: self.activePlayback(for: player),
203203
result: .init(finished: flag, interruptedAt: nil))
204204
}
205205

206-
func audioPlayerDecodeErrorDidOccur(_: AVAudioPlayer, error: (any Error)?) {
206+
func audioPlayerDecodeErrorDidOccur(_ player: AVAudioPlayer, error: (any Error)?) {
207207
let message = error?.localizedDescription ?? "unknown decode error"
208208
self.logger.error("talk buffered audio decode failed: \(message, privacy: .public)")
209209
self.finish(
210-
playback: self.playback,
210+
playback: self.activePlayback(for: player),
211211
result: .init(finished: false, interruptedAt: nil))
212212
}
213213

214+
private func activePlayback(for player: AVAudioPlayer) -> Playback? {
215+
// AVAudioPlayer can deliver callbacks after stop/replacement. Keep a stale
216+
// player from completing the current reply's continuation.
217+
guard self.player === player else { return nil }
218+
return self.playback
219+
}
220+
214221
private func stopInternal() {
215222
if let player, let playback {
216223
self.finish(

apps/ios/Tests/TalkGatewaySpeechClientTests.swift

Lines changed: 64 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import AVFoundation
12
import Foundation
23
import OpenClawKit
34
import OpenClawProtocol
@@ -80,8 +81,8 @@ private final class InterruptibleBufferedAudioPlayer: TalkBufferedAudioPlaying {
8081
}
8182

8283
@MainActor
83-
@Suite struct TalkGatewaySpeechClientTests {
84-
@Test func forwardsTalkDirectivesAndDecodesAudio() async throws {
84+
struct TalkGatewaySpeechClientTests {
85+
@Test func `forwards talk directives and decodes audio`() async throws {
8586
let expectedAudio = Data([1, 2, 3])
8687
var requestedMethod: String?
8788
var requestedParams: TalkSpeakParams?
@@ -127,7 +128,7 @@ private final class InterruptibleBufferedAudioPlayer: TalkBufferedAudioPlaying {
127128
#expect(audio.playbackMode == .buffered)
128129
}
129130

130-
@Test func resolvesGatewayAudioPlaybackMetadata() {
131+
@Test func `resolves gateway audio playback metadata`() {
131132
let pcm = TalkGatewaySpeechAudio(
132133
data: Data([0, 1]),
133134
provider: "elevenlabs",
@@ -166,7 +167,7 @@ private final class InterruptibleBufferedAudioPlayer: TalkBufferedAudioPlaying {
166167
#expect(microsoftRIFF.playbackMode == .buffered)
167168
}
168169

169-
@Test func gatewaySpeechProviderStaysNativeAndUsesTalkSpeak() async {
170+
@Test func `gateway speech provider stays native and uses talk speak`() async {
170171
let parsed = Self.parseSpeechProvider("xiaomi")
171172
let routing = TalkModeRoutingResolver.resolve(
172173
parsed: parsed,
@@ -200,7 +201,7 @@ private final class InterruptibleBufferedAudioPlayer: TalkBufferedAudioPlaying {
200201
#expect(audioPlayer.payloads == [expectedAudio])
201202
}
202203

203-
@Test func persistedVoiceAndModelOverridesReachLaterGatewayRequests() async {
204+
@Test func `persisted voice and model overrides reach later gateway requests`() async {
204205
let parsed = Self.parseSpeechProvider("xiaomi")
205206
let synthesizer = RecordingGatewaySpeechSynthesizer(audio: TalkGatewaySpeechAudio(
206207
data: Data([4, 5, 6]),
@@ -220,7 +221,7 @@ private final class InterruptibleBufferedAudioPlayer: TalkBufferedAudioPlaying {
220221
#expect(synthesizer.requests[1].modelId == "expressive")
221222
}
222223

223-
@Test func omittedGatewayModelDoesNotSendElevenLabsFallback() async {
224+
@Test func `omitted gateway model does not send eleven labs fallback`() async {
224225
let parsed = Self.parseSpeechProvider("openai", model: nil)
225226
let synthesizer = RecordingGatewaySpeechSynthesizer(audio: TalkGatewaySpeechAudio(
226227
data: Data([4, 5, 6]),
@@ -239,7 +240,7 @@ private final class InterruptibleBufferedAudioPlayer: TalkBufferedAudioPlaying {
239240
#expect(manager.gatewayTalkDefaultModelId == nil)
240241
}
241242

242-
@Test func stoppedTalkDoesNotPlayCompletedGatewaySynthesis() async {
243+
@Test func `stopped talk does not play completed gateway synthesis`() async {
243244
let parsed = Self.parseSpeechProvider("xiaomi")
244245
let synthesizer = SuspendedGatewaySpeechSynthesizer()
245246
let audioPlayer = RecordingBufferedAudioPlayer()
@@ -260,7 +261,23 @@ private final class InterruptibleBufferedAudioPlayer: TalkBufferedAudioPlaying {
260261
#expect(audioPlayer.payloads.isEmpty)
261262
}
262263

263-
@Test func interruptedGatewayPlaybackStopsSpeechRecognition() async {
264+
@Test func `stale buffered player callback does not finish replacement`() async throws {
265+
let player = TalkBufferedAudioPlayer()
266+
let wav = makeWav16Mono(sampleRate: 8000, samples: 8000)
267+
let stalePlayer = try AVAudioPlayer(data: wav)
268+
let stopAfterStaleCallback = Task { @MainActor in
269+
player.audioPlayerDidFinishPlaying(stalePlayer, successfully: true)
270+
return player.stop()
271+
}
272+
273+
let result = await player.play(data: wav)
274+
let interruptedAt = await stopAfterStaleCallback.value
275+
276+
#expect(interruptedAt != nil)
277+
#expect(!result.finished)
278+
}
279+
280+
@Test func `interrupted gateway playback stops speech recognition`() async {
264281
let parsed = Self.parseSpeechProvider("xiaomi", interruptOnSpeech: true)
265282
let synthesizer = RecordingGatewaySpeechSynthesizer(audio: TalkGatewaySpeechAudio(
266283
data: Data([4, 5, 6]),
@@ -286,7 +303,7 @@ private final class InterruptibleBufferedAudioPlayer: TalkBufferedAudioPlaying {
286303
#expect(manager._test_lastInterruptedAtSeconds() == nil)
287304
}
288305

289-
@Test func openAISpeechProviderWithoutRealtimeConfigUsesTalkSpeak() {
306+
@Test func `open AI speech provider without realtime config uses talk speak`() {
290307
let parsed = Self.parseSpeechProvider("openai")
291308

292309
let routing = TalkModeRoutingResolver.resolve(
@@ -300,7 +317,7 @@ private final class InterruptibleBufferedAudioPlayer: TalkBufferedAudioPlaying {
300317
#expect(routing.route == .gatewayTalkSpeak)
301318
}
302319

303-
@Test func explicitRealtimeConfigKeepsRealtimeRelay() {
320+
@Test func `explicit realtime config keeps realtime relay`() {
304321
let parsed = TalkModeGatewayConfigParser.parse(
305322
config: [
306323
"talk": [
@@ -366,3 +383,40 @@ private final class InterruptibleBufferedAudioPlayer: TalkBufferedAudioPlaying {
366383
defaultSilenceTimeoutMs: 900)
367384
}
368385
}
386+
387+
private func makeWav16Mono(sampleRate: UInt32, samples: Int) -> Data {
388+
let channels: UInt16 = 1
389+
let bitsPerSample: UInt16 = 16
390+
let blockAlign = channels * (bitsPerSample / 8)
391+
let byteRate = sampleRate * UInt32(blockAlign)
392+
let dataSize = UInt32(samples) * UInt32(blockAlign)
393+
394+
var data = Data()
395+
data.append(contentsOf: [0x52, 0x49, 0x46, 0x46])
396+
data.appendTestLEUInt32(36 + dataSize)
397+
data.append(contentsOf: [0x57, 0x41, 0x56, 0x45])
398+
data.append(contentsOf: [0x66, 0x6D, 0x74, 0x20])
399+
data.appendTestLEUInt32(16)
400+
data.appendTestLEUInt16(1)
401+
data.appendTestLEUInt16(channels)
402+
data.appendTestLEUInt32(sampleRate)
403+
data.appendTestLEUInt32(byteRate)
404+
data.appendTestLEUInt16(blockAlign)
405+
data.appendTestLEUInt16(bitsPerSample)
406+
data.append(contentsOf: [0x64, 0x61, 0x74, 0x61])
407+
data.appendTestLEUInt32(dataSize)
408+
data.append(Data(repeating: 0, count: Int(dataSize)))
409+
return data
410+
}
411+
412+
extension Data {
413+
fileprivate mutating func appendTestLEUInt16(_ value: UInt16) {
414+
var value = value.littleEndian
415+
Swift.withUnsafeBytes(of: &value) { self.append(contentsOf: $0) }
416+
}
417+
418+
fileprivate mutating func appendTestLEUInt32(_ value: UInt32) {
419+
var value = value.littleEndian
420+
Swift.withUnsafeBytes(of: &value) { self.append(contentsOf: $0) }
421+
}
422+
}

0 commit comments

Comments
 (0)