mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
e8524a5839
* 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
485 lines
16 KiB
Swift
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
|