Files
openclaw/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatSpeechControllerTests.swift
Peter Steinberger 60ce506712 feat(ios): add Listen action speaking assistant messages via gateway TTS (#100771)
* test(gateway): shift advertised-method window for tts.speak

* feat(ios): add Listen action speaking assistant messages via gateway TTS

* docs(ios): document the chat Listen action

* style(ios): keep outbox label doc comment attached and give speech chip its own

* fix(ios): harden chat Listen playback

* docs(changelog): note iOS chat Listen

* fix(ios): unwrap speech MIME before classification

* docs(changelog): keep Listen note app-scoped
2026-07-06 19:32:54 +01:00

206 lines
7.1 KiB
Swift

import Foundation
import Testing
@testable import OpenClawChatUI
@MainActor
private final class GatedClipPlayer: ChatSpeechClipPlaying {
var playedClips: [OpenClawChatSpeechClip] = []
var stopCount = 0
private var pending: CheckedContinuation<Bool, Never>?
func play(clip: OpenClawChatSpeechClip) async -> Bool {
self.playedClips.append(clip)
return await withCheckedContinuation { continuation in
self.pending = continuation
}
}
func stop() {
self.stopCount += 1
self.resolve(false)
}
func resolve(_ finished: Bool) {
let pending = self.pending
self.pending = nil
pending?.resume(returning: finished)
}
}
@MainActor
private final class RecordingLocalSpeaker: ChatSpeechLocalSpeaking {
var spokenTexts: [String] = []
var stopCount = 0
func speak(text: String) async -> Bool {
self.spokenTexts.append(text)
return true
}
func stop() {
self.stopCount += 1
}
}
@MainActor
private struct SpeechHarness {
let controller: OpenClawChatSpeechController
let clipPlayer: GatedClipPlayer
let localSpeaker: RecordingLocalSpeaker
init(synthesize: @escaping OpenClawChatSpeechSynthesis) {
let clipPlayer = GatedClipPlayer()
let localSpeaker = RecordingLocalSpeaker()
self.clipPlayer = clipPlayer
self.localSpeaker = localSpeaker
self.controller = OpenClawChatSpeechController(
synthesize: synthesize,
clipPlayer: clipPlayer,
localSpeech: localSpeaker)
}
}
/// Polls until the controller reaches the expected phase; playback hops
/// through an unstructured task, so tests must yield to it.
@MainActor
private func waitForPhase(
_ controller: OpenClawChatSpeechController,
_ expected: OpenClawChatSpeechController.Phase) async -> Bool
{
for _ in 0..<200 {
if controller.phase == expected { return true }
await Task.yield()
}
return controller.phase == expected
}
@MainActor
@Suite("OpenClawChatSpeechController")
struct ChatSpeechControllerTests {
@Test func `plays gateway clip and returns to idle`() async {
let clip = OpenClawChatSpeechClip(
data: Data([9, 9, 9]),
outputFormat: "mp3",
mimeType: "audio/mpeg",
fileExtension: ".mp3")
let harness = SpeechHarness { _ in clip }
let messageID = UUID()
harness.controller.toggle(messageID: messageID, text: "Hello there.")
#expect(harness.controller.phase == .preparing(messageID))
#expect(await waitForPhase(harness.controller, .speaking(messageID)))
#expect(harness.clipPlayer.playedClips == [clip])
harness.clipPlayer.resolve(true)
#expect(await waitForPhase(harness.controller, .idle))
#expect(harness.localSpeaker.spokenTexts.isEmpty)
}
@Test func `falls back to local speech when synthesis fails`() async {
struct SynthesisFailed: Error {}
let harness = SpeechHarness { _ in throw SynthesisFailed() }
let messageID = UUID()
harness.controller.toggle(messageID: messageID, text: "Read me aloud")
#expect(await waitForPhase(harness.controller, .idle))
#expect(harness.localSpeaker.spokenTexts == ["Read me aloud"])
#expect(harness.clipPlayer.playedClips.isEmpty)
}
@Test func `falls back to local speech when clip is unplayable`() async {
let clip = OpenClawChatSpeechClip(data: Data([1]), mimeType: nil)
let harness = SpeechHarness { _ in clip }
let messageID = UUID()
harness.controller.toggle(messageID: messageID, text: "Broken clip")
#expect(await waitForPhase(harness.controller, .speaking(messageID)))
// Unplayable clip resolves false without a user stop.
harness.clipPlayer.resolve(false)
#expect(await waitForPhase(harness.controller, .idle))
#expect(harness.localSpeaker.spokenTexts == ["Broken clip"])
}
@Test func `toggle while active stops without fallback`() async {
let clip = OpenClawChatSpeechClip(data: Data([5]), mimeType: nil)
let harness = SpeechHarness { _ in clip }
let messageID = UUID()
harness.controller.toggle(messageID: messageID, text: "Long reply")
#expect(await waitForPhase(harness.controller, .speaking(messageID)))
harness.controller.toggle(messageID: messageID, text: "Long reply")
#expect(harness.controller.phase == .idle)
#expect(harness.clipPlayer.stopCount > 0)
// The interrupted clip resolves false, but the bumped generation must
// keep the stop from cascading into the on-device voice.
for _ in 0..<50 {
await Task.yield()
}
#expect(harness.localSpeaker.spokenTexts.isEmpty)
}
@Test func `starting another message supersedes the first`() async {
let clip = OpenClawChatSpeechClip(data: Data([7]), mimeType: nil)
let harness = SpeechHarness { _ in clip }
let first = UUID()
let second = UUID()
harness.controller.toggle(messageID: first, text: "First message")
#expect(await waitForPhase(harness.controller, .speaking(first)))
harness.controller.toggle(messageID: second, text: "Second message")
#expect(await waitForPhase(harness.controller, .speaking(second)))
#expect(harness.controller.isActive(second))
#expect(!harness.controller.isActive(first))
harness.clipPlayer.resolve(true)
#expect(await waitForPhase(harness.controller, .idle))
}
@Test func `blank text stays idle`() async {
let harness = SpeechHarness { _ in
OpenClawChatSpeechClip(data: Data([1]), mimeType: nil)
}
harness.controller.toggle(messageID: UUID(), text: " \n ")
#expect(harness.controller.phase == .idle)
for _ in 0..<50 {
await Task.yield()
}
#expect(harness.clipPlayer.playedClips.isEmpty)
#expect(harness.localSpeaker.spokenTexts.isEmpty)
}
@Test func `identifies container hints and headerless formats`() {
let mp3 = OpenClawChatSpeechClip(
data: Data([1]),
outputFormat: "mp3",
mimeType: "audio/mpeg",
fileExtension: ".mp3")
let pcm = OpenClawChatSpeechClip(
data: Data([1]),
outputFormat: "pcm",
mimeType: "audio/pcm",
fileExtension: ".pcm")
let azureRaw = OpenClawChatSpeechClip(
data: Data([1]),
outputFormat: "raw-8khz-8bit-mono-mulaw",
fileExtension: ".pcm")
let sampledPCM = OpenClawChatSpeechClip(
data: Data([1]),
outputFormat: "pcm_24000",
fileExtension: ".pcm")
let rawULaw = OpenClawChatSpeechClip(
data: Data([1]),
outputFormat: "ulaw_8000")
#expect(!mp3.isHeaderlessAudio)
#expect(mp3.fileTypeHint != nil)
#expect(pcm.isHeaderlessAudio)
#expect(azureRaw.isHeaderlessAudio)
#expect(sampledPCM.isHeaderlessAudio)
#expect(rawULaw.isHeaderlessAudio)
}
}