mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-16 16:31:36 +00:00
* 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
206 lines
7.1 KiB
Swift
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)
|
|
}
|
|
}
|