Files
openclaw/apps/macos/Tests/OpenClawIPCTests/TalkMLXSpeechSynthesizerTests.swift
T
Peter Steinberger e8524a5839 feat: add Fish Audio S2.1 and local MLX speech (#115790)
* feat: add Fish Audio speech support

* chore: remove unused speech exports

* chore: keep Fish API constant private

* test: remove stale code mode import

* style: use bracket access for Fish voice ids

* test: restore QA evidence validator import
2026-07-29 05:15:33 -04:00

485 lines
16 KiB
Swift

import Foundation
import OpenClawMLXTTSProtocol
import Testing
@testable import OpenClaw
#if arch(arm64)
@Suite(.serialized)
struct TalkMLXSpeechSynthesizerTests {
@Test
func `reuses resident helper across utterances`() async throws {
let transport = TestMLXTransport(mode: .audio)
let factory = TestMLXTransportFactory([transport])
let synthesizer = TalkMLXSpeechSynthesizer(
transportFactory: { try await factory.make() },
idleDuration: .seconds(60))
let first = try await synthesizer.synthesize(
text: "first",
modelRepo: nil,
language: nil,
voicePreset: nil)
let second = try await synthesizer.synthesize(
text: "second",
modelRepo: "repo-a",
language: "en",
voicePreset: "voice-a")
#expect(first.starts(with: Data("RIFF".utf8)))
#expect(second.starts(with: Data("RIFF".utf8)))
#expect(await factory.callCount == 1)
let requests = await transport.sent
#expect(requests.count == 2)
guard case let .synthesize(firstRequest) = requests[0],
case let .synthesize(secondRequest) = requests[1]
else {
Issue.record("expected two synthesis requests")
return
}
#expect(firstRequest.modelRepo == TalkMLXSpeechSynthesizer.defaultModelRepo)
#expect(secondRequest.modelRepo == "repo-a")
#expect(secondRequest.language == "en")
#expect(secondRequest.voice == "voice-a")
}
@Test
func `streams pcm and forwards Fish reference inputs`() async throws {
let transport = TestMLXTransport(mode: .stream)
let factory = TestMLXTransportFactory([transport])
let synthesizer = TalkMLXSpeechSynthesizer(
transportFactory: { try await factory.make() },
idleDuration: .seconds(60))
let playback = try await synthesizer.synthesizeStream(
text: "[whisper] keep this quiet",
modelRepo: "mlx-community/fish-audio-s2-pro-8bit",
language: nil,
voicePreset: nil,
referenceAudioPath: "/tmp/reference.wav",
referenceText: "reference transcript")
var received = Data()
for try await chunk in playback.chunks {
received.append(chunk)
}
#expect(playback.sampleRate == 32000)
#expect(received == Data([0x00, 0x00, 0xFF, 0x7F]))
let requests = await transport.sent
guard let firstRequest = requests.first,
case let .synthesize(request) = firstRequest
else {
Issue.record("expected synthesis request")
return
}
#expect(request.stream)
#expect(request.referenceAudioPath == "/tmp/reference.wav")
#expect(request.referenceText == "reference transcript")
#expect(request.text == "[whisper] keep this quiet")
}
@Test
func `ending stream consumption cancels the helper request`() async throws {
let transport = TestMLXTransport(mode: .streamWaitForCancel)
let factory = TestMLXTransportFactory([transport])
let synthesizer = TalkMLXSpeechSynthesizer(
transportFactory: { try await factory.make() },
idleDuration: .seconds(60))
var playback: MLXTTSPlaybackStream? = try await synthesizer.synthesizeStream(
text: "stop streaming",
modelRepo: nil,
language: nil,
voicePreset: nil,
referenceAudioPath: nil,
referenceText: nil)
#expect(playback?.sampleRate == 32000)
playback = nil
await transport.waitForCancelRequest()
#expect(await transport.closeCount == 0)
}
@Test
func `stream stall terminates the helper`() async throws {
let transport = TestMLXTransport(mode: .streamWaitForCancel)
let factory = TestMLXTransportFactory([transport])
let synthesizer = TalkMLXSpeechSynthesizer(
transportFactory: { try await factory.make() },
idleDuration: .seconds(60))
let playback = try await synthesizer.synthesizeStream(
text: "stall after first chunk boundary",
modelRepo: nil,
language: nil,
voicePreset: nil,
referenceAudioPath: nil,
referenceText: nil,
stallTimeoutSeconds: 0.01)
do {
for try await _ in playback.chunks {}
Issue.record("expected stream timeout")
} catch TalkMLXSpeechSynthesizer.SynthesizeError.timedOut {
#expect(await transport.closeCount == 1)
}
}
@Test
func `retries once after helper crash`() async throws {
let crashed = TestMLXTransport(mode: .crash)
let restarted = TestMLXTransport(mode: .audio)
let factory = TestMLXTransportFactory([crashed, restarted])
let synthesizer = TalkMLXSpeechSynthesizer(
transportFactory: { try await factory.make() },
idleDuration: .seconds(60))
let data = try await synthesizer.synthesize(
text: "retry me",
modelRepo: nil,
language: nil,
voicePreset: nil)
#expect(data.starts(with: Data("RIFF".utf8)))
#expect(await factory.callCount == 2)
#expect(await crashed.closeCount == 1)
#expect(await restarted.sent.count == 1)
}
@Test
func `cancel uses protocol without closing helper`() async throws {
let transport = TestMLXTransport(mode: .waitForCancel)
let factory = TestMLXTransportFactory([transport])
let synthesizer = TalkMLXSpeechSynthesizer(
transportFactory: { try await factory.make() },
idleDuration: .seconds(60))
let synthesis = Task {
try await synthesizer.synthesize(
text: "cancel me",
modelRepo: nil,
language: nil,
voicePreset: nil)
}
await transport.waitForSynthesisRequest()
await synthesizer.cancelCurrent()
do {
_ = try await synthesis.value
Issue.record("expected cancellation")
} catch TalkMLXSpeechSynthesizer.SynthesizeError.canceled {
#expect(await transport.closeCount == 0)
#expect(await transport.sent.contains { request in
if case .cancel = request {
return true
}
return false
})
}
}
@Test
func `late audio after cancel is discarded`() async throws {
let transport = TestMLXTransport(mode: .audioAfterCancel)
let factory = TestMLXTransportFactory([transport])
let synthesizer = TalkMLXSpeechSynthesizer(
transportFactory: { try await factory.make() },
idleDuration: .seconds(60))
let synthesis = Task {
try await synthesizer.synthesize(
text: "discard me",
modelRepo: nil,
language: nil,
voicePreset: nil)
}
await transport.waitForSynthesisRequest()
await synthesizer.cancelCurrent()
do {
_ = try await synthesis.value
Issue.record("expected cancellation")
} catch TalkMLXSpeechSynthesizer.SynthesizeError.canceled {
#expect(await transport.closeCount == 0)
}
}
@Test
func `unresponsive cancel terminates helper without retry`() async throws {
let transport = TestMLXTransport(mode: .ignoreCancel)
let factory = TestMLXTransportFactory([transport])
let synthesizer = TalkMLXSpeechSynthesizer(
transportFactory: { try await factory.make() },
idleDuration: .seconds(60),
cancelGraceDuration: .milliseconds(10))
let synthesis = Task {
try await synthesizer.synthesize(
text: "cancel me hard",
modelRepo: nil,
language: nil,
voicePreset: nil)
}
await transport.waitForSynthesisRequest()
await synthesizer.cancelCurrent()
do {
_ = try await synthesis.value
Issue.record("expected cancellation")
} catch TalkMLXSpeechSynthesizer.SynthesizeError.canceled {
#expect(await transport.closeCount == 1)
#expect(await factory.callCount == 1)
}
}
@Test
func `shutdown terminates unresponsive in-flight helper`() async throws {
let transport = TestMLXTransport(mode: .ignoreCancel)
let factory = TestMLXTransportFactory([transport])
let synthesizer = TalkMLXSpeechSynthesizer(
transportFactory: { try await factory.make() },
idleDuration: .seconds(60))
let synthesis = Task {
try await synthesizer.synthesize(
text: "stop during shutdown",
modelRepo: nil,
language: nil,
voicePreset: nil)
}
await transport.waitForSynthesisRequest()
await synthesizer.shutdown()
do {
_ = try await synthesis.value
Issue.record("expected cancellation")
} catch TalkMLXSpeechSynthesizer.SynthesizeError.canceled {
#expect(await transport.closeCount == 1)
#expect(await transport.sent.contains(.shutdown))
}
}
@Test
func `memory pressure during synthesis preserves fallback`() async throws {
let transport = TestMLXTransport(mode: .ignoreCancel)
let factory = TestMLXTransportFactory([transport])
let synthesizer = TalkMLXSpeechSynthesizer(
transportFactory: { try await factory.make() },
idleDuration: .seconds(60))
let synthesis = Task {
try await synthesizer.synthesize(
text: "fall back after pressure",
modelRepo: nil,
language: nil,
voicePreset: nil)
}
await transport.waitForSynthesisRequest()
await synthesizer.handleMemoryPressure()
do {
_ = try await synthesis.value
Issue.record("expected generation failure")
} catch TalkMLXSpeechSynthesizer.SynthesizeError.audioGenerationFailed {
#expect(await transport.closeCount == 1)
#expect(await transport.sent.contains(.shutdown))
}
}
@Test
func `cancel can terminate helper before ready`() async throws {
let transport = TestMLXTransport(mode: .startupHang)
let factory = TestMLXTransportFactory([transport])
let synthesizer = TalkMLXSpeechSynthesizer(
transportFactory: { try await factory.make() },
idleDuration: .seconds(60),
cancelGraceDuration: .milliseconds(10))
let synthesis = Task {
try await synthesizer.synthesize(
text: "never ready",
modelRepo: nil,
language: nil,
voicePreset: nil)
}
await factory.waitForCall()
await synthesizer.cancelCurrent()
do {
_ = try await synthesis.value
Issue.record("expected cancellation")
} catch TalkMLXSpeechSynthesizer.SynthesizeError.canceled {
#expect(await transport.closeCount >= 1)
#expect(await factory.callCount == 1)
}
}
@Test
func `idle timeout shuts down resident helper`() async throws {
let transport = TestMLXTransport(mode: .audio)
let factory = TestMLXTransportFactory([transport])
let synthesizer = TalkMLXSpeechSynthesizer(
transportFactory: { try await factory.make() },
idleDuration: .milliseconds(10))
_ = try await synthesizer.synthesize(
text: "brief",
modelRepo: nil,
language: nil,
voicePreset: nil)
await transport.waitForShutdown()
#expect(await transport.closeCount == 1)
}
@Test
func `pcm response becomes playable WAV`() throws {
let wav = try TalkMLXSpeechSynthesizer.makeWAV(audio: MLXTTSAudio(
id: "one",
sampleRate: 32000,
pcm: Data([0x00, 0x00, 0xFF, 0x7F])))
#expect(wav.count == 48)
#expect(wav.prefix(4) == Data("RIFF".utf8))
#expect(wav.subdata(in: 8..<12) == Data("WAVE".utf8))
#expect(wav.suffix(4) == Data([0x00, 0x00, 0xFF, 0x7F]))
}
}
private enum TestMLXTransportError: Error {
case closed
}
private actor TestMLXTransport: MLXTTSTransport {
enum Mode: Equatable, Sendable {
case audio
case audioAfterCancel
case crash
case ignoreCancel
case startupHang
case stream
case streamWaitForCancel
case waitForCancel
}
let mode: Mode
private(set) var sent: [MLXTTSRequest] = []
private(set) var closeCount = 0
private var events: [MLXTTSEvent] = [.ready]
private var closed = false
init(mode: Mode) {
self.mode = mode
if mode == .startupHang {
self.events = []
}
}
func send(_ request: MLXTTSRequest) {
self.sent.append(request)
switch request {
case let .synthesize(synthesize):
switch self.mode {
case .audio:
self.events.append(.audio(MLXTTSAudio(
id: synthesize.id,
sampleRate: 32000,
pcm: Data([0x00, 0x00, 0xFF, 0x7F]))))
case .stream:
self.events.append(.streamStarted(MLXTTSStreamStart(
id: synthesize.id,
sampleRate: 32000)))
self.events.append(.audioChunk(MLXTTSAudioChunk(
id: synthesize.id,
pcm: Data([0x00, 0x00, 0xFF, 0x7F]))))
self.events.append(.completed(id: synthesize.id))
case .streamWaitForCancel:
self.events.append(.streamStarted(MLXTTSStreamStart(
id: synthesize.id,
sampleRate: 32000)))
case .crash:
self.closed = true
case .audioAfterCancel, .ignoreCancel, .startupHang, .waitForCancel:
break
}
case let .cancel(id):
if self.mode == .audioAfterCancel {
self.events.append(.audio(MLXTTSAudio(
id: id,
sampleRate: 32000,
pcm: Data([0x00, 0x00, 0xFF, 0x7F]))))
} else if self.mode != .ignoreCancel, self.mode != .startupHang {
self.events.append(.canceled(id: id))
}
case .shutdown:
self.closed = true
}
}
func nextEvent() async throws -> MLXTTSEvent {
while self.events.isEmpty {
if self.closed {
throw TestMLXTransportError.closed
}
await Task.yield()
}
return self.events.removeFirst()
}
func close() {
self.closeCount += 1
self.closed = true
}
func waitForSynthesisRequest() async {
while !self.sent.contains(where: {
if case .synthesize = $0 {
return true
}
return false
}) {
await Task.yield()
}
}
func waitForShutdown() async {
while !self.sent.contains(.shutdown) || self.closeCount == 0 {
await Task.yield()
}
}
func waitForCancelRequest() async {
while !self.sent.contains(where: {
if case .cancel = $0 {
return true
}
return false
}) {
await Task.yield()
}
}
}
private actor TestMLXTransportFactory {
private var transports: [TestMLXTransport]
private(set) var callCount = 0
init(_ transports: [TestMLXTransport]) {
self.transports = transports
}
func make() throws -> any MLXTTSTransport {
self.callCount += 1
guard !self.transports.isEmpty else {
throw TestMLXTransportError.closed
}
return self.transports.removeFirst()
}
func waitForCall() async {
while self.callCount == 0 {
await Task.yield()
}
}
}
#endif