mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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
This commit is contained in:
committed by
GitHub
parent
a37a5a6575
commit
e8524a5839
@@ -502,6 +502,12 @@
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/inworld/**"
|
||||
- "docs/providers/inworld.md"
|
||||
"extensions: fish-audio":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/fish-audio/**"
|
||||
- "docs/providers/fish-audio.md"
|
||||
- "docs/tools/tts.md"
|
||||
"extensions: kilocode":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
|
||||
@@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Changes
|
||||
|
||||
- **Fish Audio speech:** add hosted S2.1 synthesis with streaming, voice notes, voice discovery, and telephony, plus local Fish S2 Pro reference-voice streaming in native macOS Talk. Thanks @Conan-Scott for the earlier community-plugin implementation.
|
||||
- **Control UI cloud workspace conflicts:** surface staged-ref guidance, bounded conflicted paths, structured transcript events, and sidebar attention for cloud worker results that kept local versions.
|
||||
- **Control UI update recovery:** the "A new version is available" Reload button now waits out the gateway restart that stranded the chunk and reloads as soon as it answers, instead of silently doing nothing and leaving a manual hard reload as the only way out.
|
||||
- **Control UI sender identity polish:** attributed user messages show the author's real avatar in an always-visible gutter on identity-resolving gateways, sender labels drop the opaque profile-UUID suffix (new and historical transcripts), and profile-id senders resolve avatars through the canonical gateway route.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"originHash" : "d542f0f8e2658883b93aaab20114007320820cb6fda34969926ca2591c2b81d8",
|
||||
"originHash" : "73589827a4186a7ccf8e3c7f89c558c34d1fbf6a7e83dd465b2c3d20cbae9b98",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "eventsource",
|
||||
@@ -15,8 +15,7 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/Blaizzy/mlx-audio-swift",
|
||||
"state" : {
|
||||
"revision" : "d302a5c6080d2bb97bae38c7418f82abb76013b6",
|
||||
"version" : "0.1.3"
|
||||
"revision" : "2de211cf80ada19a75f291e491430e2af8e4befe"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -13,13 +13,17 @@ let package = Package(
|
||||
.executable(name: "openclaw-mlx-tts", targets: ["OpenClawMLXTTSHelper"]),
|
||||
],
|
||||
dependencies: [
|
||||
.package(url: "https://github.com/Blaizzy/mlx-audio-swift", exact: "0.1.3"),
|
||||
// Progressive Fish chunks and cancellation from upstream PR #237.
|
||||
.package(
|
||||
url: "https://github.com/Blaizzy/mlx-audio-swift",
|
||||
revision: "2de211cf80ada19a75f291e491430e2af8e4befe"),
|
||||
.package(path: "../shared/OpenClawMLXTTSProtocol"),
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
name: "OpenClawMLXTTSRuntime",
|
||||
dependencies: [
|
||||
.product(name: "MLXAudioCore", package: "mlx-audio-swift"),
|
||||
.product(name: "MLXAudioTTS", package: "mlx-audio-swift"),
|
||||
.product(name: "OpenClawMLXTTSProtocol", package: "OpenClawMLXTTSProtocol"),
|
||||
],
|
||||
|
||||
@@ -1,11 +1,52 @@
|
||||
import Foundation
|
||||
@preconcurrency import MLX
|
||||
import MLXAudioCore
|
||||
import MLXAudioTTS
|
||||
import OpenClawMLXTTSProtocol
|
||||
|
||||
protocol MLXTTSSpeechModel: AnyObject, Sendable {
|
||||
var sampleRate: Int { get }
|
||||
|
||||
func generate(text: String, voice: String?, language: String?) async throws -> [Float]
|
||||
func generate(
|
||||
text: String,
|
||||
voice: String?,
|
||||
language: String?,
|
||||
referenceAudioPath: String?,
|
||||
referenceText: String?) async throws -> [Float]
|
||||
|
||||
func generateStream(
|
||||
text: String,
|
||||
voice: String?,
|
||||
language: String?,
|
||||
referenceAudioPath: String?,
|
||||
referenceText: String?) -> AsyncThrowingStream<[Float], Error>
|
||||
}
|
||||
|
||||
extension MLXTTSSpeechModel {
|
||||
func generateStream(
|
||||
text: String,
|
||||
voice: String?,
|
||||
language: String?,
|
||||
referenceAudioPath: String?,
|
||||
referenceText: String?) -> AsyncThrowingStream<[Float], Error>
|
||||
{
|
||||
AsyncThrowingStream { continuation in
|
||||
let task = Task {
|
||||
do {
|
||||
try await continuation.yield(self.generate(
|
||||
text: text,
|
||||
voice: voice,
|
||||
language: language,
|
||||
referenceAudioPath: referenceAudioPath,
|
||||
referenceText: referenceText))
|
||||
continuation.finish()
|
||||
} catch {
|
||||
continuation.finish(throwing: error)
|
||||
}
|
||||
}
|
||||
continuation.onTermination = { _ in task.cancel() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
typealias MLXTTSModelLoader = @Sendable (String) async throws -> any MLXTTSSpeechModel
|
||||
@@ -54,7 +95,7 @@ public actor MLXTTSHelperService {
|
||||
return true
|
||||
|
||||
case let .cancel(id):
|
||||
guard self.currentID == id, let task = self.currentTask else {
|
||||
guard self.currentID == id, let task = currentTask else {
|
||||
await self.emit(.canceled(id: id))
|
||||
return true
|
||||
}
|
||||
@@ -94,16 +135,45 @@ public actor MLXTTSHelperService {
|
||||
|
||||
do {
|
||||
try Task.checkCancellation()
|
||||
let samples = try await model.generate(
|
||||
text: request.text,
|
||||
voice: request.voice,
|
||||
language: request.language)
|
||||
try Task.checkCancellation()
|
||||
let audio = MLXTTSAudio(
|
||||
id: request.id,
|
||||
sampleRate: model.sampleRate,
|
||||
pcm: Self.makePCM16(samples: samples))
|
||||
await self.finish(event: .audio(audio), id: request.id)
|
||||
if request.stream {
|
||||
var started = false
|
||||
for try await samples in model.generateStream(
|
||||
text: request.text,
|
||||
voice: request.voice,
|
||||
language: request.language,
|
||||
referenceAudioPath: request.referenceAudioPath,
|
||||
referenceText: request.referenceText)
|
||||
{
|
||||
try Task.checkCancellation()
|
||||
guard !samples.isEmpty else { continue }
|
||||
if !started {
|
||||
started = true
|
||||
await self.emit(.streamStarted(MLXTTSStreamStart(
|
||||
id: request.id,
|
||||
sampleRate: model.sampleRate)))
|
||||
}
|
||||
await self.emit(.audioChunk(MLXTTSAudioChunk(
|
||||
id: request.id,
|
||||
pcm: Self.makePCM16(samples: samples))))
|
||||
}
|
||||
guard started else {
|
||||
throw AudioGenerationError.generationFailed("generation produced no audio")
|
||||
}
|
||||
await self.finish(event: .completed(id: request.id), id: request.id)
|
||||
} else {
|
||||
let samples = try await model.generate(
|
||||
text: request.text,
|
||||
voice: request.voice,
|
||||
language: request.language,
|
||||
referenceAudioPath: request.referenceAudioPath,
|
||||
referenceText: request.referenceText)
|
||||
try Task.checkCancellation()
|
||||
let audio = MLXTTSAudio(
|
||||
id: request.id,
|
||||
sampleRate: model.sampleRate,
|
||||
pcm: Self.makePCM16(samples: samples))
|
||||
await self.finish(event: .audio(audio), id: request.id)
|
||||
}
|
||||
} catch is CancellationError {
|
||||
await self.finishCanceled(id: request.id)
|
||||
} catch {
|
||||
@@ -117,15 +187,15 @@ public actor MLXTTSHelperService {
|
||||
}
|
||||
|
||||
private func model(repo: String) async throws -> any MLXTTSSpeechModel {
|
||||
if let cachedModel = self.cachedModel, cachedModel.repo == repo {
|
||||
if let cachedModel, cachedModel.repo == repo {
|
||||
return cachedModel.model
|
||||
}
|
||||
|
||||
// Only one model is retained. Dropping the previous reference before
|
||||
// loading a new repo avoids holding both sets of MLX weights at once.
|
||||
self.cachedModel = nil
|
||||
let model = try await self.loadModel(repo)
|
||||
self.cachedModel = CachedModel(repo: repo, model: model)
|
||||
cachedModel = nil
|
||||
let model = try await loadModel(repo)
|
||||
cachedModel = CachedModel(repo: repo, model: model)
|
||||
return model
|
||||
}
|
||||
|
||||
@@ -162,13 +232,61 @@ private final class UncheckedSpeechModel: MLXTTSSpeechModel, @unchecked Sendable
|
||||
self.raw.sampleRate
|
||||
}
|
||||
|
||||
func generate(text: String, voice: String?, language: String?) async throws -> [Float] {
|
||||
let generatedAudio = try await self.raw.generate(
|
||||
func generate(
|
||||
text: String,
|
||||
voice: String?,
|
||||
language: String?,
|
||||
referenceAudioPath: String?,
|
||||
referenceText: String?) async throws -> [Float]
|
||||
{
|
||||
let referenceAudio = try loadReferenceAudio(path: referenceAudioPath)
|
||||
let generatedAudio = try await raw.generate(
|
||||
text: text,
|
||||
voice: voice,
|
||||
refAudio: nil,
|
||||
refText: nil,
|
||||
refAudio: referenceAudio,
|
||||
refText: referenceText,
|
||||
language: language)
|
||||
return generatedAudio.asArray(Float.self)
|
||||
}
|
||||
|
||||
func generateStream(
|
||||
text: String,
|
||||
voice: String?,
|
||||
language: String?,
|
||||
referenceAudioPath: String?,
|
||||
referenceText: String?) -> AsyncThrowingStream<[Float], Error>
|
||||
{
|
||||
AsyncThrowingStream { continuation in
|
||||
let task = Task {
|
||||
do {
|
||||
let referenceAudio = try self.loadReferenceAudio(path: referenceAudioPath)
|
||||
for try await samples in self.raw.generateSamplesStream(
|
||||
text: text,
|
||||
voice: voice,
|
||||
refAudio: referenceAudio,
|
||||
refText: referenceText,
|
||||
language: language)
|
||||
{
|
||||
try Task.checkCancellation()
|
||||
continuation.yield(samples)
|
||||
}
|
||||
continuation.finish()
|
||||
} catch {
|
||||
continuation.finish(throwing: error)
|
||||
}
|
||||
}
|
||||
continuation.onTermination = { _ in task.cancel() }
|
||||
}
|
||||
}
|
||||
|
||||
private func loadReferenceAudio(path: String?) throws -> MLXArray? {
|
||||
guard let path = path?.trimmingCharacters(in: .whitespacesAndNewlines), !path.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
let expanded = NSString(string: path).expandingTildeInPath
|
||||
let (_, audio) = try loadAudioArray(
|
||||
from: URL(fileURLWithPath: expanded),
|
||||
sampleRate: sampleRate)
|
||||
return audio
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,6 +73,61 @@ final class MLXTTSHelperServiceTests: XCTestCase {
|
||||
let pcm = MLXTTSHelperService.makePCM16(samples: [-1, 0, 1])
|
||||
XCTAssertEqual(pcm, Data([0x01, 0x80, 0x00, 0x00, 0xFF, 0x7F]))
|
||||
}
|
||||
|
||||
func testStreamsPCMAndForwardsReferenceInputs() async {
|
||||
let state = TestState()
|
||||
let service = MLXTTSHelperService(
|
||||
loadModel: { _ in TestModel(state: state) },
|
||||
eventSink: { event in await state.emitted(event) })
|
||||
let streamRequest = MLXTTSRequest.synthesize(MLXTTSSynthesizeRequest(
|
||||
id: "stream",
|
||||
text: "hello",
|
||||
modelRepo: "repo-a",
|
||||
language: "en",
|
||||
voice: nil,
|
||||
referenceAudioPath: "/tmp/reference.wav",
|
||||
referenceText: "reference transcript",
|
||||
stream: true))
|
||||
|
||||
await service.handle(streamRequest)
|
||||
await service.waitUntilIdle()
|
||||
|
||||
let events = await state.events
|
||||
XCTAssertEqual(events, [
|
||||
.streamStarted(MLXTTSStreamStart(id: "stream", sampleRate: 32000)),
|
||||
.audioChunk(MLXTTSAudioChunk(
|
||||
id: "stream",
|
||||
pcm: Data([0x01, 0x80, 0x00, 0x00, 0xFF, 0x7F]))),
|
||||
.completed(id: "stream"),
|
||||
])
|
||||
let references = await state.references
|
||||
XCTAssertEqual(references, ["/tmp/reference.wav|reference transcript"])
|
||||
}
|
||||
|
||||
func testStreamDoesNotStartWhenGenerationProducesNoAudio() async {
|
||||
let state = TestState()
|
||||
let service = MLXTTSHelperService(
|
||||
loadModel: { _ in EmptyTestModel() },
|
||||
eventSink: { event in await state.emitted(event) })
|
||||
|
||||
await service.handle(.synthesize(MLXTTSSynthesizeRequest(
|
||||
id: "empty",
|
||||
text: "hello",
|
||||
modelRepo: "repo-a",
|
||||
language: nil,
|
||||
voice: nil,
|
||||
stream: true)))
|
||||
await service.waitUntilIdle()
|
||||
|
||||
let events = await state.events
|
||||
XCTAssertEqual(events.count, 1)
|
||||
guard case let .error(error) = events.first else {
|
||||
XCTFail("expected generation error")
|
||||
return
|
||||
}
|
||||
XCTAssertEqual(error.id, "empty")
|
||||
XCTAssertEqual(error.code, .generationFailed)
|
||||
}
|
||||
}
|
||||
|
||||
private func request(id: String, repo: String) -> MLXTTSSynthesizeRequest {
|
||||
@@ -83,6 +138,7 @@ private actor TestState {
|
||||
private(set) var loadedRepos: [String] = []
|
||||
private(set) var generatedTexts: [String] = []
|
||||
private(set) var events: [MLXTTSEvent] = []
|
||||
private(set) var references: [String] = []
|
||||
|
||||
func loaded(_ repo: String) {
|
||||
self.loadedRepos.append(repo)
|
||||
@@ -95,6 +151,10 @@ private actor TestState {
|
||||
func emitted(_ event: MLXTTSEvent) {
|
||||
self.events.append(event)
|
||||
}
|
||||
|
||||
func referenced(path: String?, text: String?) {
|
||||
self.references.append("\(path ?? "nil")|\(text ?? "nil")")
|
||||
}
|
||||
}
|
||||
|
||||
private final class TestModel: MLXTTSSpeechModel, @unchecked Sendable {
|
||||
@@ -105,8 +165,15 @@ private final class TestModel: MLXTTSSpeechModel, @unchecked Sendable {
|
||||
self.state = state
|
||||
}
|
||||
|
||||
func generate(text: String, voice _: String?, language _: String?) async throws -> [Float] {
|
||||
func generate(
|
||||
text: String,
|
||||
voice _: String?,
|
||||
language _: String?,
|
||||
referenceAudioPath: String?,
|
||||
referenceText: String?) async throws -> [Float]
|
||||
{
|
||||
await self.state.generated(text)
|
||||
await self.state.referenced(path: referenceAudioPath, text: referenceText)
|
||||
return [-1, 0, 1]
|
||||
}
|
||||
}
|
||||
@@ -114,8 +181,28 @@ private final class TestModel: MLXTTSSpeechModel, @unchecked Sendable {
|
||||
private final class SlowTestModel: MLXTTSSpeechModel, @unchecked Sendable {
|
||||
let sampleRate = 32000
|
||||
|
||||
func generate(text _: String, voice _: String?, language _: String?) async throws -> [Float] {
|
||||
func generate(
|
||||
text _: String,
|
||||
voice _: String?,
|
||||
language _: String?,
|
||||
referenceAudioPath _: String?,
|
||||
referenceText _: String?) async throws -> [Float]
|
||||
{
|
||||
try await Task.sleep(for: .seconds(30))
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
private final class EmptyTestModel: MLXTTSSpeechModel, @unchecked Sendable {
|
||||
let sampleRate = 32000
|
||||
|
||||
func generate(
|
||||
text _: String,
|
||||
voice _: String?,
|
||||
language _: String?,
|
||||
referenceAudioPath _: String?,
|
||||
referenceText _: String?) async throws -> [Float]
|
||||
{
|
||||
[]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Dispatch
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
import OpenClawMLXTTSProtocol
|
||||
import OSLog
|
||||
|
||||
@@ -11,6 +12,11 @@ protocol MLXTTSTransport: AnyObject, Sendable {
|
||||
|
||||
typealias MLXTTSTransportFactory = @Sendable () async throws -> any MLXTTSTransport
|
||||
|
||||
struct MLXTTSPlaybackStream: Sendable {
|
||||
let sampleRate: Double
|
||||
let chunks: AsyncThrowingStream<Data, Error>
|
||||
}
|
||||
|
||||
actor TalkMLXSpeechSynthesizer {
|
||||
enum SynthesizeError: Error {
|
||||
case canceled
|
||||
@@ -61,7 +67,9 @@ actor TalkMLXSpeechSynthesizer {
|
||||
text: String,
|
||||
modelRepo: String?,
|
||||
language: String?,
|
||||
voicePreset: String?) async throws -> Data
|
||||
voicePreset: String?,
|
||||
referenceAudioPath: String? = nil,
|
||||
referenceText: String? = nil) async throws -> Data
|
||||
{
|
||||
#if !arch(arm64)
|
||||
throw SynthesizeError.modelLoadFailed("MLX TTS requires Apple silicon")
|
||||
@@ -83,17 +91,19 @@ actor TalkMLXSpeechSynthesizer {
|
||||
text: trimmed,
|
||||
modelRepo: Self.resolvedModelRepo(modelRepo),
|
||||
language: language?.nilIfBlank,
|
||||
voice: voicePreset?.nilIfBlank))
|
||||
voice: voicePreset?.nilIfBlank,
|
||||
referenceAudioPath: referenceAudioPath?.nilIfBlank,
|
||||
referenceText: referenceText?.nilIfBlank))
|
||||
|
||||
for attempt in 0...1 {
|
||||
do {
|
||||
let transport = try await self.ensureTransport()
|
||||
let transport = try await ensureTransport()
|
||||
guard self.activeID == id, self.cancelRequestedID != id else {
|
||||
await self.discardTransport()
|
||||
throw SynthesizeError.canceled
|
||||
}
|
||||
try await transport.send(request)
|
||||
let audio = try await self.waitForAudio(id: id, transport: transport)
|
||||
let audio = try await waitForAudio(id: id, transport: transport)
|
||||
self.finishRequest(id: id)
|
||||
return try Self.makeWAV(audio: audio)
|
||||
} catch let error as SynthesizeError {
|
||||
@@ -139,8 +149,113 @@ actor TalkMLXSpeechSynthesizer {
|
||||
#endif
|
||||
}
|
||||
|
||||
func synthesizeStream(
|
||||
text: String,
|
||||
modelRepo: String?,
|
||||
language: String?,
|
||||
voicePreset: String?,
|
||||
referenceAudioPath: String?,
|
||||
referenceText: String?,
|
||||
stallTimeoutSeconds: Double = 90) async throws -> MLXTTSPlaybackStream
|
||||
{
|
||||
#if !arch(arm64)
|
||||
throw SynthesizeError.modelLoadFailed("MLX TTS requires Apple silicon")
|
||||
#else
|
||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else {
|
||||
return MLXTTSPlaybackStream(
|
||||
sampleRate: 1,
|
||||
chunks: AsyncThrowingStream { $0.finish() })
|
||||
}
|
||||
guard self.activeID == nil else {
|
||||
throw SynthesizeError.audioGenerationFailed
|
||||
}
|
||||
|
||||
self.ensureMemoryPressureMonitor()
|
||||
self.idleTask?.cancel()
|
||||
self.idleTask = nil
|
||||
|
||||
let id = UUID().uuidString
|
||||
self.activeID = id
|
||||
let request = MLXTTSRequest.synthesize(MLXTTSSynthesizeRequest(
|
||||
id: id,
|
||||
text: trimmed,
|
||||
modelRepo: Self.resolvedModelRepo(modelRepo),
|
||||
language: language?.nilIfBlank,
|
||||
voice: voicePreset?.nilIfBlank,
|
||||
referenceAudioPath: referenceAudioPath?.nilIfBlank,
|
||||
referenceText: referenceText?.nilIfBlank,
|
||||
stream: true))
|
||||
|
||||
for attempt in 0...1 {
|
||||
do {
|
||||
let transport = try await ensureTransport()
|
||||
guard self.activeID == id, self.cancelRequestedID != id else {
|
||||
await self.discardTransport()
|
||||
throw SynthesizeError.canceled
|
||||
}
|
||||
try await transport.send(request)
|
||||
let start = try await waitForStreamStart(id: id, transport: transport)
|
||||
switch start {
|
||||
case let .stream(info):
|
||||
return MLXTTSPlaybackStream(
|
||||
sampleRate: Double(info.sampleRate),
|
||||
chunks: self.makeAudioStream(
|
||||
id: id,
|
||||
transport: transport,
|
||||
stallTimeoutSeconds: stallTimeoutSeconds))
|
||||
case let .legacy(audio):
|
||||
self.finishRequest(id: id)
|
||||
return MLXTTSPlaybackStream(
|
||||
sampleRate: Double(audio.sampleRate),
|
||||
chunks: AsyncThrowingStream { continuation in
|
||||
continuation.yield(audio.pcm)
|
||||
continuation.finish()
|
||||
})
|
||||
}
|
||||
} catch let error as SynthesizeError {
|
||||
let requiresFallback = self.fallbackRequiredID == id
|
||||
self.finishRequest(id: id)
|
||||
if requiresFallback {
|
||||
throw SynthesizeError.audioGenerationFailed
|
||||
}
|
||||
throw error
|
||||
} catch is CancellationError {
|
||||
try? await self.transport?.send(.cancel(id: id))
|
||||
await self.discardTransport()
|
||||
self.finishRequest(id: id)
|
||||
throw SynthesizeError.canceled
|
||||
} catch {
|
||||
self.logger.error(
|
||||
"talk mlx helper stream failed attempt=\(attempt + 1, privacy: .public): " +
|
||||
"\(error.localizedDescription, privacy: .public)")
|
||||
await self.discardTransport()
|
||||
if self.fallbackRequiredID == id {
|
||||
self.finishRequest(id: id)
|
||||
throw SynthesizeError.audioGenerationFailed
|
||||
}
|
||||
if self.cancelRequestedID == id {
|
||||
self.finishRequest(id: id)
|
||||
throw SynthesizeError.canceled
|
||||
}
|
||||
guard self.activeID == id else {
|
||||
throw SynthesizeError.canceled
|
||||
}
|
||||
if attempt == 0 {
|
||||
continue
|
||||
}
|
||||
self.finishRequest(id: id)
|
||||
throw SynthesizeError.modelLoadFailed(Self.helperInvocation().displayName)
|
||||
}
|
||||
}
|
||||
|
||||
self.finishRequest(id: id)
|
||||
throw SynthesizeError.audioGenerationFailed
|
||||
#endif
|
||||
}
|
||||
|
||||
func cancelCurrent() async {
|
||||
guard let activeID = self.activeID else { return }
|
||||
guard let activeID else { return }
|
||||
self.cancelRequestedID = activeID
|
||||
do {
|
||||
try await self.transport?.send(.cancel(id: activeID))
|
||||
@@ -155,11 +270,11 @@ actor TalkMLXSpeechSynthesizer {
|
||||
self.cancelEscalationTask = nil
|
||||
self.idleTask?.cancel()
|
||||
self.idleTask = nil
|
||||
if let activeID = self.activeID {
|
||||
if let activeID {
|
||||
try? await self.transport?.send(.cancel(id: activeID))
|
||||
}
|
||||
try? await self.transport?.send(.shutdown)
|
||||
self.activeID = nil
|
||||
activeID = nil
|
||||
self.cancelRequestedID = nil
|
||||
await self.discardTransport()
|
||||
}
|
||||
@@ -169,7 +284,7 @@ actor TalkMLXSpeechSynthesizer {
|
||||
return transport
|
||||
}
|
||||
|
||||
let transport = try await self.transportFactory()
|
||||
let transport = try await transportFactory()
|
||||
// Publish the starting transport before waiting for `ready` so talk
|
||||
// cancellation and app shutdown can still terminate a wedged startup.
|
||||
self.transport = transport
|
||||
@@ -205,12 +320,135 @@ actor TalkMLXSpeechSynthesizer {
|
||||
case .busy, .generationFailed, .invalidRequest, .protocolError:
|
||||
throw SynthesizeError.audioGenerationFailed
|
||||
}
|
||||
case .ready, .audio, .error, .canceled:
|
||||
case .ready, .audio, .streamStarted, .audioChunk, .completed, .error, .canceled:
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum StreamStart {
|
||||
case stream(MLXTTSStreamStart)
|
||||
case legacy(MLXTTSAudio)
|
||||
}
|
||||
|
||||
private func waitForStreamStart(
|
||||
id: String,
|
||||
transport: any MLXTTSTransport) async throws -> StreamStart
|
||||
{
|
||||
while true {
|
||||
switch try await transport.nextEvent() {
|
||||
case let .streamStarted(start) where start.id == id:
|
||||
guard start.format == .pcmS16LE, start.sampleRate > 0, start.channels == 1 else {
|
||||
throw SynthesizeError.audioGenerationFailed
|
||||
}
|
||||
return .stream(start)
|
||||
case let .audio(audio) where audio.id == id:
|
||||
return .legacy(audio)
|
||||
case let .canceled(canceledID) where canceledID == id:
|
||||
throw SynthesizeError.canceled
|
||||
case let .error(error) where error.id == nil || error.id == id:
|
||||
throw Self.synthesizeError(error)
|
||||
case .ready, .audio, .streamStarted, .audioChunk, .completed, .error, .canceled:
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func makeAudioStream(
|
||||
id: String,
|
||||
transport: any MLXTTSTransport,
|
||||
stallTimeoutSeconds: Double) -> AsyncThrowingStream<Data, Error>
|
||||
{
|
||||
AsyncThrowingStream { continuation in
|
||||
Task { [weak self] in
|
||||
guard let self else {
|
||||
continuation.finish(throwing: SynthesizeError.audioGenerationFailed)
|
||||
return
|
||||
}
|
||||
await self.pumpAudioStream(
|
||||
id: id,
|
||||
transport: transport,
|
||||
stallTimeoutSeconds: stallTimeoutSeconds,
|
||||
continuation: continuation)
|
||||
}
|
||||
continuation.onTermination = { [weak self] _ in
|
||||
// Keep the pump alive to observe the helper's canceled event.
|
||||
// The id-scoped grace timer kills a helper that ignores cancel.
|
||||
Task { await self?.cancelStreamRequest(id: id, transport: transport) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func cancelStreamRequest(id: String, transport: any MLXTTSTransport) async {
|
||||
guard self.activeID == id else { return }
|
||||
self.cancelRequestedID = id
|
||||
do {
|
||||
try await transport.send(.cancel(id: id))
|
||||
} catch {
|
||||
await self.discardTransport()
|
||||
return
|
||||
}
|
||||
self.scheduleCancelEscalation(id: id)
|
||||
}
|
||||
|
||||
private func pumpAudioStream(
|
||||
id: String,
|
||||
transport: any MLXTTSTransport,
|
||||
stallTimeoutSeconds: Double,
|
||||
continuation: AsyncThrowingStream<Data, Error>.Continuation) async
|
||||
{
|
||||
do {
|
||||
while true {
|
||||
try Task.checkCancellation()
|
||||
let event = try await AsyncTimeout.withTimeout(
|
||||
seconds: stallTimeoutSeconds,
|
||||
onTimeout: { SynthesizeError.timedOut },
|
||||
operation: { try await transport.nextEvent() })
|
||||
switch event {
|
||||
case let .audioChunk(chunk) where chunk.id == id:
|
||||
guard self.cancelRequestedID != id else {
|
||||
throw SynthesizeError.canceled
|
||||
}
|
||||
continuation.yield(chunk.pcm)
|
||||
case let .completed(completedID) where completedID == id:
|
||||
self.finishRequest(id: id)
|
||||
continuation.finish()
|
||||
return
|
||||
case let .audio(audio) where audio.id == id:
|
||||
continuation.yield(audio.pcm)
|
||||
self.finishRequest(id: id)
|
||||
continuation.finish()
|
||||
return
|
||||
case let .canceled(canceledID) where canceledID == id:
|
||||
throw SynthesizeError.canceled
|
||||
case let .error(error) where error.id == nil || error.id == id:
|
||||
throw Self.synthesizeError(error)
|
||||
case .ready, .audio, .streamStarted, .audioChunk, .completed, .error, .canceled:
|
||||
continue
|
||||
}
|
||||
}
|
||||
} catch SynthesizeError.timedOut {
|
||||
await self.discardTransport()
|
||||
self.finishRequest(id: id)
|
||||
continuation.finish(throwing: SynthesizeError.timedOut)
|
||||
} catch {
|
||||
let requiresFallback = self.fallbackRequiredID == id
|
||||
self.finishRequest(id: id)
|
||||
continuation.finish(throwing: requiresFallback ? SynthesizeError.audioGenerationFailed : error)
|
||||
}
|
||||
}
|
||||
|
||||
private static func synthesizeError(_ error: MLXTTSErrorEvent) -> SynthesizeError {
|
||||
switch error.code {
|
||||
case .canceled:
|
||||
.canceled
|
||||
case .modelLoadFailed:
|
||||
.modelLoadFailed(error.message)
|
||||
case .busy, .generationFailed, .invalidRequest, .protocolError:
|
||||
.audioGenerationFailed
|
||||
}
|
||||
}
|
||||
|
||||
private func finishRequest(id: String) {
|
||||
if self.fallbackRequiredID == id {
|
||||
self.fallbackRequiredID = nil
|
||||
@@ -436,7 +674,7 @@ private actor ProcessMLXTTSTransport: MLXTTSTransport {
|
||||
let payload = self.pendingPayloads.removeFirst()
|
||||
return try MLXTTSFrameCodec.decode(MLXTTSEvent.self, payload: payload)
|
||||
}
|
||||
guard let chunk = await self.chunks.next() else {
|
||||
guard let chunk = await chunks.next() else {
|
||||
throw MLXTTSTransportError.closed
|
||||
}
|
||||
try self.pendingPayloads.append(contentsOf: self.decoder.append(chunk))
|
||||
@@ -486,7 +724,7 @@ private final class MLXMemoryPressureMonitor: @unchecked Sendable {
|
||||
|
||||
extension String {
|
||||
fileprivate var nilIfBlank: String? {
|
||||
let trimmed = self.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let trimmed = trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ struct TalkModeGatewayConfigState {
|
||||
let silenceTimeoutMs: Int
|
||||
let speechLocaleID: String?
|
||||
let apiKey: String?
|
||||
let referenceAudioPath: String?
|
||||
let referenceText: String?
|
||||
let seamColorHex: String?
|
||||
}
|
||||
|
||||
@@ -56,6 +58,10 @@ enum TalkModeGatewayConfigParser {
|
||||
let interrupt = talk?["interruptOnSpeech"]?.boolValue
|
||||
let speechLocaleID = TalkConfigParsing.resolvedSpeechLocaleID(talk)
|
||||
let apiKey = activeConfig?["apiKey"]?.stringValue
|
||||
let referenceAudioPath = activeConfig?["referenceAudioPath"]?.stringValue?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let referenceText = activeConfig?["referenceText"]?.stringValue?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let resolvedVoice: String? = if activeProvider == defaultProvider {
|
||||
(voice?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? voice : nil) ??
|
||||
(envVoice?.isEmpty == false ? envVoice : nil) ??
|
||||
@@ -82,6 +88,8 @@ enum TalkModeGatewayConfigParser {
|
||||
silenceTimeoutMs: silenceTimeoutMs,
|
||||
speechLocaleID: speechLocaleID,
|
||||
apiKey: resolvedApiKey,
|
||||
referenceAudioPath: referenceAudioPath?.isEmpty == false ? referenceAudioPath : nil,
|
||||
referenceText: referenceText?.isEmpty == false ? referenceText : nil,
|
||||
seamColorHex: rawSeam.isEmpty ? nil : rawSeam)
|
||||
}
|
||||
|
||||
@@ -109,6 +117,8 @@ enum TalkModeGatewayConfigParser {
|
||||
silenceTimeoutMs: defaultSilenceTimeoutMs,
|
||||
speechLocaleID: nil,
|
||||
apiKey: resolvedApiKey,
|
||||
referenceAudioPath: nil,
|
||||
referenceText: nil,
|
||||
seamColorHex: nil)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,6 +84,8 @@ actor TalkModeRuntime {
|
||||
private var voiceAliases: [String: String] = [:]
|
||||
private var lastSpokenText: String?
|
||||
private var apiKey: String?
|
||||
private var mlxReferenceAudioPath: String?
|
||||
private var mlxReferenceText: String?
|
||||
private var fallbackVoiceId: String?
|
||||
private var lastPlaybackWasPCM: Bool = false
|
||||
|
||||
@@ -145,7 +147,7 @@ actor TalkModeRuntime {
|
||||
return
|
||||
}
|
||||
self.startAudioInputObserver()
|
||||
await self.reloadConfig()
|
||||
await reloadConfig()
|
||||
guard self.isCurrent(gen) else { return }
|
||||
if self.isPaused {
|
||||
self.phase = .idle
|
||||
@@ -171,7 +173,7 @@ actor TalkModeRuntime {
|
||||
self.silenceTask = nil
|
||||
|
||||
// Stop audio before changing phase (stopSpeaking is gated on .speaking).
|
||||
await self.stopSpeaking(reason: .manual)
|
||||
await stopSpeaking(reason: .manual)
|
||||
|
||||
self.lastTranscript = ""
|
||||
self.lastHeard = nil
|
||||
@@ -323,7 +325,9 @@ actor TalkModeRuntime {
|
||||
self.rmsTask = Task { [weak self, meter] in
|
||||
while let self {
|
||||
try? await Task.sleep(nanoseconds: 50_000_000)
|
||||
if Task.isCancelled { return }
|
||||
if Task.isCancelled {
|
||||
return
|
||||
}
|
||||
await self.noteAudioLevel(rms: meter.get())
|
||||
}
|
||||
}
|
||||
@@ -339,8 +343,8 @@ actor TalkModeRuntime {
|
||||
|
||||
let trimmed = transcript.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if self.phase == .speaking, self.interruptOnSpeech {
|
||||
if await self.shouldInterrupt(transcript: trimmed, hasConfidence: update.hasConfidence) {
|
||||
await self.stopSpeaking(reason: .speech)
|
||||
if await shouldInterrupt(transcript: trimmed, hasConfidence: update.hasConfidence) {
|
||||
await stopSpeaking(reason: .speech)
|
||||
self.lastTranscript = ""
|
||||
self.lastHeard = nil
|
||||
await self.startListening()
|
||||
@@ -414,7 +418,7 @@ actor TalkModeRuntime {
|
||||
await MainActor.run { VoiceWakeChimePlayer.play(sendChime, reason: "talk.send") }
|
||||
}
|
||||
await self.stopRecognition()
|
||||
await self.sendAndSpeak(text)
|
||||
await sendAndSpeak(text)
|
||||
}
|
||||
|
||||
private func bindSelectedInputIfNeeded(
|
||||
@@ -517,7 +521,7 @@ private enum TalkAudioInputError: LocalizedError {
|
||||
extension TalkModeRuntime {
|
||||
private func sendAndSpeak(_ transcript: String) async {
|
||||
let gen = self.lifecycleGeneration
|
||||
await self.reloadConfig()
|
||||
await reloadConfig()
|
||||
guard self.isCurrent(gen) else { return }
|
||||
let prompt = self.buildPrompt(transcript: transcript)
|
||||
let activeSessionKey = await MainActor.run { WebChatManager.shared.activeSessionKey }
|
||||
@@ -627,7 +631,9 @@ extension TalkModeRuntime {
|
||||
group.addTask { [runId, sessionKey] in
|
||||
var latestText: String?
|
||||
for await push in stream {
|
||||
if Task.isCancelled { return latestText }
|
||||
if Task.isCancelled {
|
||||
return latestText
|
||||
}
|
||||
guard case let .event(evt) = push else { continue }
|
||||
guard evt.event == "chat", let payload = evt.payload else { continue }
|
||||
guard let chatEvent = try? GatewayPayloadDecoding.decode(
|
||||
@@ -672,7 +678,9 @@ extension TalkModeRuntime {
|
||||
private static func matchesSessionKey(_ incoming: String, _ current: String) -> Bool {
|
||||
let incoming = incoming.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
let current = current.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
if incoming == current { return true }
|
||||
if incoming == current {
|
||||
return true
|
||||
}
|
||||
return (incoming == "agent:main:main" && current == "main") ||
|
||||
(incoming == "main" && current == "agent:main:main")
|
||||
}
|
||||
@@ -684,7 +692,7 @@ extension TalkModeRuntime {
|
||||
{
|
||||
let deadline = Date().addingTimeInterval(TimeInterval(timeoutSeconds))
|
||||
while Date() < deadline {
|
||||
if let text = await self.latestAssistantText(sessionKey: sessionKey, since: since) {
|
||||
if let text = await latestAssistantText(sessionKey: sessionKey, since: since) {
|
||||
return text
|
||||
}
|
||||
try? await Task.sleep(nanoseconds: 300_000_000)
|
||||
@@ -717,7 +725,7 @@ extension TalkModeRuntime {
|
||||
}
|
||||
|
||||
private func playAssistant(text: String) async {
|
||||
guard let input = await self.preparePlaybackInput(text: text) else { return }
|
||||
guard let input = await preparePlaybackInput(text: text) else { return }
|
||||
|
||||
switch Self.playbackPlan(provider: input.provider, apiKey: input.apiKey, voiceId: input.voiceId) {
|
||||
case let .elevenLabsThenSystemVoice(apiKey, voiceId):
|
||||
@@ -822,6 +830,8 @@ extension TalkModeRuntime {
|
||||
let voiceId: String?
|
||||
let voicePreset: String?
|
||||
let language: String?
|
||||
let referenceAudioPath: String?
|
||||
let referenceText: String?
|
||||
let synthTimeoutSeconds: Double
|
||||
}
|
||||
|
||||
@@ -907,6 +917,8 @@ extension TalkModeRuntime {
|
||||
voiceId: voiceId,
|
||||
voicePreset: voicePreset,
|
||||
language: language,
|
||||
referenceAudioPath: self.mlxReferenceAudioPath,
|
||||
referenceText: self.mlxReferenceText,
|
||||
synthTimeoutSeconds: synthTimeoutSeconds)
|
||||
}
|
||||
|
||||
@@ -958,7 +970,7 @@ extension TalkModeRuntime {
|
||||
await MainActor.run { TalkModeController.shared.updatePhase(.speaking) }
|
||||
self.phase = .speaking
|
||||
|
||||
let result = await self.playRemoteStream(
|
||||
let result = await playRemoteStream(
|
||||
client: client,
|
||||
voiceId: voiceId,
|
||||
outputFormat: outputFormat,
|
||||
@@ -973,7 +985,7 @@ extension TalkModeRuntime {
|
||||
NSLocalizedDescriptionKey: "audio playback failed",
|
||||
])
|
||||
}
|
||||
if !result.finished, let interruptedAt = result.interruptedAt, self.phase == .speaking {
|
||||
if !result.finished, let interruptedAt = result.interruptedAt, phase == .speaking {
|
||||
if self.interruptOnSpeech {
|
||||
self.lastInterruptedAtSeconds = interruptedAt
|
||||
}
|
||||
@@ -990,7 +1002,7 @@ extension TalkModeRuntime {
|
||||
let sampleRate = TalkTTSValidation.pcmSampleRate(from: outputFormat)
|
||||
if let sampleRate {
|
||||
self.lastPlaybackWasPCM = true
|
||||
let result = await self.playPCM(stream: stream, sampleRate: sampleRate)
|
||||
let result = await playPCM(stream: stream, sampleRate: sampleRate)
|
||||
if result.finished || result.interruptedAt != nil {
|
||||
return result
|
||||
}
|
||||
@@ -1000,10 +1012,10 @@ extension TalkModeRuntime {
|
||||
let mp3Stream = client.streamSynthesize(
|
||||
voiceId: voiceId,
|
||||
request: makeRequest(mp3Format))
|
||||
return await self.playMP3(stream: mp3Stream)
|
||||
return await playMP3(stream: mp3Stream)
|
||||
}
|
||||
self.lastPlaybackWasPCM = false
|
||||
return await self.playMP3(stream: stream)
|
||||
return await playMP3(stream: stream)
|
||||
}
|
||||
|
||||
private func playGatewayTalkSpeak(input: TalkPlaybackInput) async throws {
|
||||
@@ -1022,14 +1034,14 @@ extension TalkModeRuntime {
|
||||
NSLocalizedDescriptionKey: "gateway talk.speak returned empty audio",
|
||||
])
|
||||
}
|
||||
_ = await self.stopPCM()
|
||||
_ = await self.stopMP3()
|
||||
_ = await stopPCM()
|
||||
_ = await stopMP3()
|
||||
if self.interruptOnSpeech {
|
||||
guard await self.prepareForPlayback(generation: input.generation) else { return }
|
||||
}
|
||||
await MainActor.run { TalkModeController.shared.updatePhase(.speaking) }
|
||||
self.phase = .speaking
|
||||
let playback = await self.playTalkAudio(data: audioData)
|
||||
let playback = await playTalkAudio(data: audioData)
|
||||
self.ttsLogger
|
||||
.info(
|
||||
"talk gateway audio provider=\(result.provider, privacy: .public) " +
|
||||
@@ -1067,26 +1079,34 @@ extension TalkModeRuntime {
|
||||
await MainActor.run { TalkModeController.shared.updatePhase(.speaking) }
|
||||
self.phase = .speaking
|
||||
let modelRepo = input.directive?.modelId ?? self.currentModelId
|
||||
let audioData: Data
|
||||
self.lastPlaybackWasPCM = true
|
||||
let playbackStream: MLXTTSPlaybackStream
|
||||
do {
|
||||
audioData = try await AsyncTimeout.withTimeout(
|
||||
playbackStream = try await AsyncTimeout.withTimeout(
|
||||
seconds: input.synthTimeoutSeconds,
|
||||
onTimeout: {
|
||||
TalkMLXSpeechSynthesizer.SynthesizeError.timedOut
|
||||
},
|
||||
operation: { [self] in
|
||||
try await self.synthesizeMLXVoice(
|
||||
return try await self.streamMLXVoice(
|
||||
text: input.cleanedText,
|
||||
modelRepo: modelRepo,
|
||||
language: input.language,
|
||||
voicePreset: input.voicePreset)
|
||||
voicePreset: input.voicePreset,
|
||||
referenceAudioPath: input.referenceAudioPath,
|
||||
referenceText: input.referenceText,
|
||||
stallTimeoutSeconds: input.synthTimeoutSeconds)
|
||||
})
|
||||
} catch TalkMLXSpeechSynthesizer.SynthesizeError.timedOut {
|
||||
await self.stopMLXVoice()
|
||||
_ = await stopPCM()
|
||||
await stopMLXVoice()
|
||||
throw TalkMLXSpeechSynthesizer.SynthesizeError.timedOut
|
||||
}
|
||||
let result = await self.playTalkAudio(data: audioData)
|
||||
let result = await playPCM(
|
||||
stream: playbackStream.chunks,
|
||||
sampleRate: playbackStream.sampleRate)
|
||||
if !result.finished, result.interruptedAt == nil {
|
||||
await stopMLXVoice()
|
||||
throw TalkMLXSpeechSynthesizer.SynthesizeError.audioPlaybackFailed
|
||||
}
|
||||
self.ttsLogger.info("talk mlx done")
|
||||
@@ -1100,10 +1120,14 @@ extension TalkModeRuntime {
|
||||
private func resolveVoiceId(preferred: String?, apiKey: String) async -> String? {
|
||||
let trimmed = preferred?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if !trimmed.isEmpty {
|
||||
if let resolved = self.resolveVoiceAlias(trimmed) { return resolved }
|
||||
if let resolved = resolveVoiceAlias(trimmed) {
|
||||
return resolved
|
||||
}
|
||||
self.ttsLogger.warning("talk unknown voice alias \(trimmed, privacy: .public)")
|
||||
}
|
||||
if let fallbackVoiceId { return fallbackVoiceId }
|
||||
if let fallbackVoiceId {
|
||||
return fallbackVoiceId
|
||||
}
|
||||
|
||||
do {
|
||||
let voices = try await ElevenLabsTTSClient(apiKey: apiKey).listVoices()
|
||||
@@ -1111,7 +1135,7 @@ extension TalkModeRuntime {
|
||||
self.ttsLogger.error("elevenlabs voices list empty")
|
||||
return nil
|
||||
}
|
||||
self.fallbackVoiceId = first.voiceId
|
||||
fallbackVoiceId = first.voiceId
|
||||
if self.defaultVoiceId == nil {
|
||||
self.defaultVoiceId = first.voiceId
|
||||
}
|
||||
@@ -1132,7 +1156,9 @@ extension TalkModeRuntime {
|
||||
let trimmed = (value ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
let normalized = trimmed.lowercased()
|
||||
if let mapped = self.voiceAliases[normalized] { return mapped }
|
||||
if let mapped = voiceAliases[normalized] {
|
||||
return mapped
|
||||
}
|
||||
if self.voiceAliases.values.contains(where: { $0.caseInsensitiveCompare(trimmed) == .orderedSame }) {
|
||||
return trimmed
|
||||
}
|
||||
@@ -1146,11 +1172,11 @@ extension TalkModeRuntime {
|
||||
|
||||
func stopSpeaking(reason: TalkStopReason) async {
|
||||
let usePCM = self.lastPlaybackWasPCM
|
||||
let remoteInterruptedAt = usePCM ? await self.stopPCM() : await self.stopMP3()
|
||||
_ = usePCM ? await self.stopMP3() : await self.stopPCM()
|
||||
let localInterruptedAt = await self.stopTalkAudio()
|
||||
let remoteInterruptedAt = usePCM ? await stopPCM() : await stopMP3()
|
||||
_ = usePCM ? await stopMP3() : await stopPCM()
|
||||
let localInterruptedAt = await stopTalkAudio()
|
||||
await TalkSystemSpeechSynthesizer.shared.stop()
|
||||
await self.stopMLXVoice()
|
||||
await stopMLXVoice()
|
||||
guard self.phase == .speaking else { return }
|
||||
let interruptedAt = remoteInterruptedAt ?? localInterruptedAt
|
||||
if reason == .speech, let interruptedAt {
|
||||
@@ -1259,17 +1285,23 @@ extension TalkModeRuntime {
|
||||
TalkBufferedAudioPlayer.shared.stop()
|
||||
}
|
||||
|
||||
private func synthesizeMLXVoice(
|
||||
private func streamMLXVoice(
|
||||
text: String,
|
||||
modelRepo: String?,
|
||||
language: String?,
|
||||
voicePreset: String?) async throws -> Data
|
||||
voicePreset: String?,
|
||||
referenceAudioPath: String?,
|
||||
referenceText: String?,
|
||||
stallTimeoutSeconds: Double) async throws -> MLXTTSPlaybackStream
|
||||
{
|
||||
try await TalkMLXSpeechSynthesizer.shared.synthesize(
|
||||
try await TalkMLXSpeechSynthesizer.shared.synthesizeStream(
|
||||
text: text,
|
||||
modelRepo: modelRepo,
|
||||
language: language,
|
||||
voicePreset: voicePreset)
|
||||
voicePreset: voicePreset,
|
||||
referenceAudioPath: referenceAudioPath,
|
||||
referenceText: referenceText,
|
||||
stallTimeoutSeconds: stallTimeoutSeconds)
|
||||
}
|
||||
|
||||
private func stopMLXVoice() async {
|
||||
@@ -1279,7 +1311,7 @@ extension TalkModeRuntime {
|
||||
// MARK: - Config
|
||||
|
||||
private func reloadConfig() async {
|
||||
let cfg = await self.fetchTalkConfig()
|
||||
let cfg = await fetchTalkConfig()
|
||||
self.defaultVoiceId = cfg.voiceId
|
||||
self.voiceAliases = cfg.voiceAliases
|
||||
if !self.voiceOverrideActive {
|
||||
@@ -1305,6 +1337,8 @@ extension TalkModeRuntime {
|
||||
self.silenceWindow = TimeInterval(effectiveSilenceMs) / 1000
|
||||
self.speechLocaleID = cfg.speechLocaleID
|
||||
self.apiKey = cfg.apiKey
|
||||
self.mlxReferenceAudioPath = cfg.referenceAudioPath
|
||||
self.mlxReferenceText = cfg.referenceText
|
||||
let hasApiKey = (cfg.apiKey?.isEmpty == false)
|
||||
let voiceLabel = cfg.voiceId.flatMap { $0.isEmpty ? nil : $0 } ?? "none"
|
||||
let modelLabel = cfg.modelId.flatMap { $0.isEmpty ? nil : $0 } ?? "none"
|
||||
@@ -1313,6 +1347,7 @@ extension TalkModeRuntime {
|
||||
"talk config provider=\(cfg.activeProvider, privacy: .public) " +
|
||||
"talk config voiceId=\(voiceLabel, privacy: .public) " +
|
||||
"modelId=\(modelLabel, privacy: .public) " +
|
||||
"referenceAudio=\(cfg.referenceAudioPath != nil, privacy: .public) " +
|
||||
"apiKey=\(hasApiKey, privacy: .public) " +
|
||||
"interrupt=\(cfg.interruptOnSpeech, privacy: .public) " +
|
||||
"silenceTimeoutMs=\(cfg.silenceTimeoutMs, privacy: .public) " +
|
||||
@@ -1383,11 +1418,13 @@ extension TalkModeRuntime {
|
||||
// MARK: - Audio level handling
|
||||
|
||||
private func noteAudioLevel(rms: Double) async {
|
||||
if self.phase != .listening, self.phase != .speaking { return }
|
||||
if self.phase != .listening, self.phase != .speaking {
|
||||
return
|
||||
}
|
||||
let alpha: Double = rms < self.noiseFloorRMS ? 0.08 : 0.01
|
||||
self.noiseFloorRMS = max(1e-7, self.noiseFloorRMS + (rms - self.noiseFloorRMS) * alpha)
|
||||
|
||||
let threshold = max(self.minSpeechRMS, self.noiseFloorRMS * self.speechBoostFactor)
|
||||
let threshold = max(minSpeechRMS, noiseFloorRMS * self.speechBoostFactor)
|
||||
if rms >= threshold {
|
||||
let now = Date()
|
||||
self.lastHeard = now
|
||||
@@ -1403,7 +1440,9 @@ extension TalkModeRuntime {
|
||||
private func shouldInterrupt(transcript: String, hasConfidence: Bool) async -> Bool {
|
||||
let trimmed = transcript.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard trimmed.count >= 3 else { return false }
|
||||
if self.isLikelyEcho(of: trimmed) { return false }
|
||||
if self.isLikelyEcho(of: trimmed) {
|
||||
return false
|
||||
}
|
||||
let now = Date()
|
||||
if let lastSpeechEnergyAt, now.timeIntervalSince(lastSpeechEnergyAt) > 0.35 {
|
||||
return false
|
||||
@@ -1412,7 +1451,7 @@ extension TalkModeRuntime {
|
||||
}
|
||||
|
||||
private func isLikelyEcho(of transcript: String) -> Bool {
|
||||
guard let spoken = self.lastSpokenText?.lowercased(), !spoken.isEmpty else { return false }
|
||||
guard let spoken = lastSpokenText?.lowercased(), !spoken.isEmpty else { return false }
|
||||
let probe = transcript.lowercased()
|
||||
if probe.count < 6 {
|
||||
return spoken.contains(probe)
|
||||
|
||||
@@ -42,6 +42,88 @@ struct TalkMLXSpeechSynthesizerTests {
|
||||
#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)
|
||||
@@ -87,7 +169,9 @@ struct TalkMLXSpeechSynthesizerTests {
|
||||
} catch TalkMLXSpeechSynthesizer.SynthesizeError.canceled {
|
||||
#expect(await transport.closeCount == 0)
|
||||
#expect(await transport.sent.contains { request in
|
||||
if case .cancel = request { return true }
|
||||
if case .cancel = request {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
}
|
||||
@@ -272,6 +356,8 @@ private actor TestMLXTransport: MLXTTSTransport {
|
||||
case crash
|
||||
case ignoreCancel
|
||||
case startupHang
|
||||
case stream
|
||||
case streamWaitForCancel
|
||||
case waitForCancel
|
||||
}
|
||||
|
||||
@@ -298,6 +384,18 @@ private actor TestMLXTransport: MLXTTSTransport {
|
||||
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:
|
||||
@@ -334,7 +432,9 @@ private actor TestMLXTransport: MLXTTSTransport {
|
||||
|
||||
func waitForSynthesisRequest() async {
|
||||
while !self.sent.contains(where: {
|
||||
if case .synthesize = $0 { return true }
|
||||
if case .synthesize = $0 {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}) {
|
||||
await Task.yield()
|
||||
@@ -346,6 +446,17 @@ private actor TestMLXTransport: MLXTTSTransport {
|
||||
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 {
|
||||
|
||||
@@ -16,13 +16,19 @@ struct TalkModeGatewayConfigTests {
|
||||
"provider": "mlx",
|
||||
"providers": [
|
||||
"mlx": [
|
||||
"modelId": "mlx-community/fish-audio-s2-pro-8bit",
|
||||
"voiceId": "unused-voice",
|
||||
"referenceAudioPath": "/tmp/reference.wav",
|
||||
"referenceText": "reference transcript",
|
||||
],
|
||||
],
|
||||
"resolved": [
|
||||
"provider": "mlx",
|
||||
"config": [
|
||||
"voiceId": "unused-voice",
|
||||
"modelId": "mlx-community/fish-audio-s2-pro-8bit",
|
||||
"referenceAudioPath": "/tmp/reference.wav",
|
||||
"referenceText": "reference transcript",
|
||||
],
|
||||
],
|
||||
"speechLocale": "ru-RU",
|
||||
@@ -40,9 +46,11 @@ struct TalkModeGatewayConfigTests {
|
||||
envApiKey: "env-key")
|
||||
|
||||
#expect(parsed.activeProvider == "mlx")
|
||||
#expect(parsed.modelId == nil)
|
||||
#expect(parsed.modelId == "mlx-community/fish-audio-s2-pro-8bit")
|
||||
#expect(parsed.apiKey == nil)
|
||||
#expect(parsed.voiceId == "unused-voice")
|
||||
#expect(parsed.speechLocaleID == "ru-RU")
|
||||
#expect(parsed.referenceAudioPath == "/tmp/reference.wav")
|
||||
#expect(parsed.referenceText == "reference transcript")
|
||||
}
|
||||
}
|
||||
|
||||
+103
-1
@@ -50,13 +50,63 @@ public struct MLXTTSSynthesizeRequest: Codable, Equatable, Sendable {
|
||||
public let modelRepo: String
|
||||
public let language: String?
|
||||
public let voice: String?
|
||||
public let referenceAudioPath: String?
|
||||
public let referenceText: String?
|
||||
public let stream: Bool
|
||||
|
||||
public init(id: String, text: String, modelRepo: String, language: String?, voice: String?) {
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case text
|
||||
case modelRepo
|
||||
case language
|
||||
case voice
|
||||
case referenceAudioPath
|
||||
case referenceText
|
||||
case stream
|
||||
}
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
text: String,
|
||||
modelRepo: String,
|
||||
language: String?,
|
||||
voice: String?,
|
||||
referenceAudioPath: String? = nil,
|
||||
referenceText: String? = nil,
|
||||
stream: Bool = false)
|
||||
{
|
||||
self.id = id
|
||||
self.text = text
|
||||
self.modelRepo = modelRepo
|
||||
self.language = language
|
||||
self.voice = voice
|
||||
self.referenceAudioPath = referenceAudioPath
|
||||
self.referenceText = referenceText
|
||||
self.stream = stream
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
self.id = try container.decode(String.self, forKey: .id)
|
||||
self.text = try container.decode(String.self, forKey: .text)
|
||||
self.modelRepo = try container.decode(String.self, forKey: .modelRepo)
|
||||
self.language = try container.decodeIfPresent(String.self, forKey: .language)
|
||||
self.voice = try container.decodeIfPresent(String.self, forKey: .voice)
|
||||
self.referenceAudioPath = try container.decodeIfPresent(String.self, forKey: .referenceAudioPath)
|
||||
self.referenceText = try container.decodeIfPresent(String.self, forKey: .referenceText)
|
||||
self.stream = try container.decodeIfPresent(Bool.self, forKey: .stream) ?? false
|
||||
}
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(self.id, forKey: .id)
|
||||
try container.encode(self.text, forKey: .text)
|
||||
try container.encode(self.modelRepo, forKey: .modelRepo)
|
||||
try container.encodeIfPresent(self.language, forKey: .language)
|
||||
try container.encodeIfPresent(self.voice, forKey: .voice)
|
||||
try container.encodeIfPresent(self.referenceAudioPath, forKey: .referenceAudioPath)
|
||||
try container.encodeIfPresent(self.referenceText, forKey: .referenceText)
|
||||
try container.encode(self.stream, forKey: .stream)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,6 +136,35 @@ public struct MLXTTSAudio: Codable, Equatable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct MLXTTSStreamStart: Codable, Equatable, Sendable {
|
||||
public let id: String
|
||||
public let format: MLXTTSAudioFormat
|
||||
public let sampleRate: Int
|
||||
public let channels: Int
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
format: MLXTTSAudioFormat = .pcmS16LE,
|
||||
sampleRate: Int,
|
||||
channels: Int = 1)
|
||||
{
|
||||
self.id = id
|
||||
self.format = format
|
||||
self.sampleRate = sampleRate
|
||||
self.channels = channels
|
||||
}
|
||||
}
|
||||
|
||||
public struct MLXTTSAudioChunk: Codable, Equatable, Sendable {
|
||||
public let id: String
|
||||
public let pcm: Data
|
||||
|
||||
public init(id: String, pcm: Data) {
|
||||
self.id = id
|
||||
self.pcm = pcm
|
||||
}
|
||||
}
|
||||
|
||||
public enum MLXTTSErrorCode: String, Codable, Equatable, Sendable {
|
||||
case busy
|
||||
case canceled
|
||||
@@ -110,12 +189,17 @@ public struct MLXTTSErrorEvent: Codable, Equatable, Sendable {
|
||||
public enum MLXTTSEvent: Codable, Equatable, Sendable {
|
||||
case ready
|
||||
case audio(MLXTTSAudio)
|
||||
case streamStarted(MLXTTSStreamStart)
|
||||
case audioChunk(MLXTTSAudioChunk)
|
||||
case completed(id: String)
|
||||
case error(MLXTTSErrorEvent)
|
||||
case canceled(id: String)
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case type
|
||||
case audio
|
||||
case streamStarted = "stream_started"
|
||||
case audioChunk = "audio_chunk"
|
||||
case error
|
||||
case id
|
||||
}
|
||||
@@ -123,6 +207,9 @@ public enum MLXTTSEvent: Codable, Equatable, Sendable {
|
||||
private enum EventType: String, Codable {
|
||||
case ready
|
||||
case audio
|
||||
case streamStarted = "stream_started"
|
||||
case audioChunk = "audio_chunk"
|
||||
case completed
|
||||
case error
|
||||
case canceled
|
||||
}
|
||||
@@ -134,6 +221,12 @@ public enum MLXTTSEvent: Codable, Equatable, Sendable {
|
||||
self = .ready
|
||||
case .audio:
|
||||
self = try .audio(container.decode(MLXTTSAudio.self, forKey: .audio))
|
||||
case .streamStarted:
|
||||
self = try .streamStarted(container.decode(MLXTTSStreamStart.self, forKey: .streamStarted))
|
||||
case .audioChunk:
|
||||
self = try .audioChunk(container.decode(MLXTTSAudioChunk.self, forKey: .audioChunk))
|
||||
case .completed:
|
||||
self = try .completed(id: container.decode(String.self, forKey: .id))
|
||||
case .error:
|
||||
self = try .error(container.decode(MLXTTSErrorEvent.self, forKey: .error))
|
||||
case .canceled:
|
||||
@@ -149,6 +242,15 @@ public enum MLXTTSEvent: Codable, Equatable, Sendable {
|
||||
case let .audio(audio):
|
||||
try container.encode(EventType.audio, forKey: .type)
|
||||
try container.encode(audio, forKey: .audio)
|
||||
case let .streamStarted(start):
|
||||
try container.encode(EventType.streamStarted, forKey: .type)
|
||||
try container.encode(start, forKey: .streamStarted)
|
||||
case let .audioChunk(chunk):
|
||||
try container.encode(EventType.audioChunk, forKey: .type)
|
||||
try container.encode(chunk, forKey: .audioChunk)
|
||||
case let .completed(id):
|
||||
try container.encode(EventType.completed, forKey: .type)
|
||||
try container.encode(id, forKey: .id)
|
||||
case let .error(error):
|
||||
try container.encode(EventType.error, forKey: .type)
|
||||
try container.encode(error, forKey: .error)
|
||||
|
||||
+28
-1
@@ -9,7 +9,10 @@ final class MLXTTSProtocolTests: XCTestCase {
|
||||
text: "hello",
|
||||
modelRepo: "mlx-community/Soprano-80M-bf16",
|
||||
language: "en",
|
||||
voice: nil))
|
||||
voice: nil,
|
||||
referenceAudioPath: "/tmp/reference.wav",
|
||||
referenceText: "reference transcript",
|
||||
stream: true))
|
||||
|
||||
var decoder = MLXTTSFrameDecoder()
|
||||
let payloads = try decoder.append(MLXTTSFrameCodec.encode(request))
|
||||
@@ -18,6 +21,16 @@ final class MLXTTSProtocolTests: XCTestCase {
|
||||
XCTAssertEqual(try MLXTTSFrameCodec.decode(MLXTTSRequest.self, payload: payloads[0]), request)
|
||||
}
|
||||
|
||||
func testLegacySynthesisRequestDefaultsStreamToFalse() throws {
|
||||
let payload = Data(
|
||||
#"{"id":"legacy","text":"hello","modelRepo":"repo","language":null,"voice":null}"#.utf8)
|
||||
let request = try JSONDecoder().decode(MLXTTSSynthesizeRequest.self, from: payload)
|
||||
|
||||
XCTAssertFalse(request.stream)
|
||||
XCTAssertNil(request.referenceAudioPath)
|
||||
XCTAssertNil(request.referenceText)
|
||||
}
|
||||
|
||||
func testDecoderAcceptsFragmentedAndCoalescedFrames() throws {
|
||||
let first = try MLXTTSFrameCodec.encode(MLXTTSRequest.cancel(id: "one"))
|
||||
let second = try MLXTTSFrameCodec.encode(MLXTTSRequest.shutdown)
|
||||
@@ -51,6 +64,20 @@ final class MLXTTSProtocolTests: XCTestCase {
|
||||
XCTAssertEqual(decoded, event)
|
||||
}
|
||||
|
||||
func testStreamingEventsRoundTrip() throws {
|
||||
let events: [MLXTTSEvent] = [
|
||||
.streamStarted(MLXTTSStreamStart(id: "request-3", sampleRate: 44100)),
|
||||
.audioChunk(MLXTTSAudioChunk(id: "request-3", pcm: Data([0x00, 0x00]))),
|
||||
.completed(id: "request-3"),
|
||||
]
|
||||
|
||||
for event in events {
|
||||
var decoder = MLXTTSFrameDecoder()
|
||||
let payload = try XCTUnwrap(decoder.append(MLXTTSFrameCodec.encode(event)).first)
|
||||
XCTAssertEqual(try MLXTTSFrameCodec.decode(MLXTTSEvent.self, payload: payload), event)
|
||||
}
|
||||
}
|
||||
|
||||
func testDecoderRejectsEmptyAndOversizedFrames() {
|
||||
var emptyDecoder = MLXTTSFrameDecoder()
|
||||
XCTAssertThrowsError(try emptyDecoder.append(Data(repeating: 0, count: 4))) { error in
|
||||
|
||||
@@ -1519,6 +1519,7 @@
|
||||
"providers/deepseek",
|
||||
"providers/ds4",
|
||||
"providers/elevenlabs",
|
||||
"providers/fish-audio",
|
||||
"providers/fal",
|
||||
"providers/featherless",
|
||||
"providers/fireworks",
|
||||
|
||||
@@ -6715,6 +6715,15 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
|
||||
- H2: Surface
|
||||
- H2: Related docs
|
||||
|
||||
## plugins/reference/fish-audio.md
|
||||
|
||||
- Route: /plugins/reference/fish-audio
|
||||
- Headings:
|
||||
- H1: Fish Audio plugin
|
||||
- H2: Distribution
|
||||
- H2: Surface
|
||||
- H2: Related docs
|
||||
|
||||
## plugins/reference/github-copilot.md
|
||||
|
||||
- Route: /plugins/reference/github-copilot
|
||||
@@ -8229,6 +8238,18 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
|
||||
- H2: Custom Fireworks model ids
|
||||
- H2: Related
|
||||
|
||||
## providers/fish-audio.md
|
||||
|
||||
- Route: /providers/fish-audio
|
||||
- Headings:
|
||||
- H2: Hosted S2.1
|
||||
- H3: Hosted models
|
||||
- H3: Expressive speech
|
||||
- H3: Voice selection and cloning
|
||||
- H2: Local S2 Pro on macOS
|
||||
- H3: Local reference voice
|
||||
- H2: Troubleshooting
|
||||
|
||||
## providers/github-copilot.md
|
||||
|
||||
- Route: /providers/github-copilot
|
||||
|
||||
+6
-1
@@ -71,6 +71,9 @@ Supported keys: `voice` / `voice_id` / `voiceId`, `model` / `model_id` / `modelI
|
||||
},
|
||||
mlx: {
|
||||
modelId: "mlx-community/Soprano-80M-bf16",
|
||||
// Fish S2 Pro can also use a local reference voice:
|
||||
// referenceAudioPath: "/Users/example/Voices/reference.wav",
|
||||
// referenceText: "Exact transcript of the reference clip.",
|
||||
},
|
||||
system: {},
|
||||
},
|
||||
@@ -150,6 +153,8 @@ change under OAuth.
|
||||
| `speechLocale` | device default | BCP 47 locale for Android, iOS, and macOS native speech recognition, plus the iOS system-voice fallback. Apple Speech may use network services; Android also forwards the language component to realtime input transcription. |
|
||||
| `providers.elevenlabs.modelId` | `eleven_multilingual_v2` | |
|
||||
| `providers.mlx.modelId` | `mlx-community/Soprano-80M-bf16` | |
|
||||
| `providers.mlx.referenceAudioPath` | - | Optional client-local reference recording for MLX models that support voice cloning. The path is resolved on the native macOS app host. |
|
||||
| `providers.mlx.referenceText` | - | Exact transcript of `referenceAudioPath`; Fish S2 Pro uses both values for local voice cloning. |
|
||||
| `providers.elevenlabs.apiKey` | - | Falls back to `ELEVENLABS_API_KEY` (or gateway shell profile if available). |
|
||||
| `silenceTimeoutMs` | `700` ms macOS/Android, `900` ms iOS | Pause window before Talk sends the transcript. |
|
||||
| `interruptOnSpeech` | `true` | |
|
||||
@@ -195,7 +200,7 @@ change under OAuth.
|
||||
- Requires Speech + Microphone permissions.
|
||||
- Native Talk uses the active Gateway session and only falls back to history polling when response events are unavailable.
|
||||
- The gateway resolves Talk playback through `talk.speak` using the active Talk provider. Android falls back to local system TTS only when that RPC is unavailable.
|
||||
- macOS local MLX playback uses the bundled `openclaw-mlx-tts` helper when present, or an executable on `PATH`. Set `OPENCLAW_MLX_TTS_BIN` to point at a custom helper binary during development.
|
||||
- macOS local MLX playback uses the bundled `openclaw-mlx-tts` helper when present, or an executable on `PATH`. Set `OPENCLAW_MLX_TTS_BIN` to point at a custom helper binary during development. The helper streams PCM, keeps one selected model resident, and supports Fish S2 Pro reference audio through `providers.mlx.referenceAudioPath` plus `referenceText`.
|
||||
- Voice directive value ranges (ElevenLabs): `stability`, `similarity`, and `style` accept `0..1`; `speed` accepts `0.5..2`; `latency_tier` accepts `0..4`.
|
||||
|
||||
## Related
|
||||
|
||||
@@ -197,7 +197,7 @@ Each entry lists the package, distribution route, and description.
|
||||
|
||||
## Official external packages
|
||||
|
||||
73 plugins
|
||||
74 plugins
|
||||
|
||||
- **[acpx](/plugins/reference/acpx)** (`@openclaw/acpx`) - npm; ClawHub. OpenClaw ACP runtime backend with plugin-owned session and transport management.
|
||||
|
||||
@@ -251,6 +251,8 @@ Each entry lists the package, distribution route, and description.
|
||||
|
||||
- **[fireworks](/plugins/reference/fireworks)** (`@openclaw/fireworks-provider`) - npm; ClawHub: `clawhub:@openclaw/fireworks-provider`. Adds Fireworks model provider support to OpenClaw.
|
||||
|
||||
- **[fish-audio](/plugins/reference/fish-audio)** (`@openclaw/fish-audio-speech`) - npm; ClawHub: `clawhub:@openclaw/fish-audio-speech`. Fish Audio S2.1 hosted text-to-speech with streaming, voice notes, and telephony output.
|
||||
|
||||
- **[gmi](/plugins/reference/gmi)** (`@openclaw/gmi-provider`) - npm; ClawHub: `clawhub:@openclaw/gmi-provider`. OpenClaw GMI Cloud provider plugin.
|
||||
|
||||
- **[google-meet](/plugins/reference/google-meet)** (`@openclaw/google-meet`) - npm; ClawHub. OpenClaw Google Meet participant plugin for joining calls through Chrome or Twilio transports.
|
||||
|
||||
@@ -15,5 +15,5 @@ This page is generated from `extensions/*/package.json` and
|
||||
pnpm plugins:inventory:gen
|
||||
```
|
||||
|
||||
Use [Plugin inventory](/plugins/plugin-inventory) to browse all 145
|
||||
Use [Plugin inventory](/plugins/plugin-inventory) to browse all 146
|
||||
generated plugin reference pages by distribution, package, and description.
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
---
|
||||
summary: "Fish Audio S2.1 hosted text-to-speech with streaming, voice notes, and telephony output."
|
||||
read_when:
|
||||
- You are installing, configuring, or auditing the fish-audio plugin
|
||||
title: "Fish Audio plugin"
|
||||
---
|
||||
|
||||
# Fish Audio plugin
|
||||
|
||||
Fish Audio S2.1 hosted text-to-speech with streaming, voice notes, and telephony output.
|
||||
|
||||
## Distribution
|
||||
|
||||
- Package: `@openclaw/fish-audio-speech`
|
||||
- Install route: npm; ClawHub: `clawhub:@openclaw/fish-audio-speech`
|
||||
|
||||
## Surface
|
||||
|
||||
contracts: `speechProviders`
|
||||
|
||||
## Related docs
|
||||
|
||||
- [fish-audio](/providers/fish-audio)
|
||||
@@ -0,0 +1,151 @@
|
||||
---
|
||||
summary: "Use Fish Audio S2.1 hosted TTS or local S2 Pro on Apple silicon"
|
||||
read_when:
|
||||
- You want Fish Audio text-to-speech in OpenClaw
|
||||
- You want expressive or cloned voices with Fish Audio
|
||||
- You want local Fish S2 Pro speech in macOS Talk mode
|
||||
title: "Fish Audio"
|
||||
---
|
||||
|
||||
OpenClaw supports Fish Audio in two distinct ways:
|
||||
|
||||
- **Hosted S2.1** runs through the `fish-audio` speech provider on the Gateway and works across channels, voice notes, Talk, and telephony.
|
||||
- **Local S2 Pro** runs inside the native macOS app through the existing `mlx` Talk provider. It stays on the Mac and does not require a Fish API key.
|
||||
|
||||
<Warning>
|
||||
The downloadable S2 Pro weights use the Fish Audio Research License. Personal,
|
||||
research, and non-commercial evaluation are allowed; commercial use requires a
|
||||
separate Fish Audio license. Hosted API use follows Fish Audio's service terms.
|
||||
</Warning>
|
||||
|
||||
## Hosted S2.1
|
||||
|
||||
Set an API key from the [Fish Audio API Keys](https://fish.audio/app/api-keys) page:
|
||||
|
||||
```bash
|
||||
export FISH_API_KEY="..."
|
||||
```
|
||||
|
||||
Then configure the provider:
|
||||
|
||||
```json5
|
||||
{
|
||||
tts: {
|
||||
auto: "tagged",
|
||||
provider: "fish-audio",
|
||||
providers: {
|
||||
"fish-audio": {
|
||||
apiKey: "${FISH_API_KEY}",
|
||||
model: "s2.1-pro",
|
||||
// Optional saved or public Fish Audio voice model id:
|
||||
speakerVoiceId: "802e3bc2b27e49c2995d23ef70e6ac89",
|
||||
latency: "balanced",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
`speakerVoiceId` is optional. Without it, Fish Audio uses its default voice.
|
||||
`FISH_AUDIO_API_KEY` is also accepted for compatibility with existing community
|
||||
plugins, but `FISH_API_KEY` is the canonical Fish SDK environment variable.
|
||||
|
||||
### Hosted models
|
||||
|
||||
| Model | Use |
|
||||
| --------------- | -------------------------------------------------------------------------------------------------------------- |
|
||||
| `s2.1-pro` | Default. Production S2.1 service with the hosted service guarantees attached to your plan. |
|
||||
| `s2.1-pro-free` | Promotional S2.1 access through August 31, 2026; no TTFA or DPA guarantees. Select it explicitly while active. |
|
||||
| `s2-pro` | Previous S2 generation. |
|
||||
| `s1` | Previous generation with parenthesized emotion controls. |
|
||||
|
||||
The provider requests MP3 for ordinary audio, Opus at 48 kHz for native voice
|
||||
notes, and raw PCM at 8 kHz for telephony. For Discord voice, OpenClaw consumes
|
||||
Fish Audio's chunked HTTP response as it arrives instead of waiting for the
|
||||
entire clip.
|
||||
|
||||
### Expressive speech
|
||||
|
||||
S2 and S2.1 accept inline natural-language tags. Put them in the spoken text:
|
||||
|
||||
```text
|
||||
[whisper] Keep this between us. [pause] [excited] We shipped it!
|
||||
```
|
||||
|
||||
Common tags include `[whisper]`, `[laughing]`, `[excited]`, `[sad]`, `[pause]`,
|
||||
and free-form instructions such as `[professional broadcast tone]`.
|
||||
|
||||
### Voice selection and cloning
|
||||
|
||||
Use `/tts status` to inspect the active provider and `/tts audio <text>` for a
|
||||
one-off clip. Fish voice ids can come from your own trained voices or the public
|
||||
Fish voice library. OpenClaw lists your voices first, then a bounded page of
|
||||
popular public voices.
|
||||
|
||||
The speech provider consumes existing voice ids; it does not upload recordings
|
||||
or create voice models. Voice creation is a separate consent-sensitive action
|
||||
in the Fish Audio app or API.
|
||||
|
||||
## Local S2 Pro on macOS
|
||||
|
||||
The native macOS app bundles an isolated MLX TTS helper. On Apple silicon, point
|
||||
the existing `mlx` Talk provider at the 8-bit Fish conversion:
|
||||
|
||||
```json5
|
||||
{
|
||||
talk: {
|
||||
provider: "mlx",
|
||||
providers: {
|
||||
mlx: {
|
||||
modelId: "mlx-community/fish-audio-s2-pro-8bit",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
The first utterance downloads about 6.8 GB of model and codec data. OpenClaw
|
||||
keeps one selected MLX model resident for repeated utterances, then unloads it
|
||||
after five idle minutes, app shutdown, or memory pressure.
|
||||
|
||||
### Local reference voice
|
||||
|
||||
When the Gateway and macOS app share the same filesystem, configure a clean
|
||||
10–30 second reference recording and its exact transcript:
|
||||
|
||||
```json5
|
||||
{
|
||||
talk: {
|
||||
provider: "mlx",
|
||||
providers: {
|
||||
mlx: {
|
||||
modelId: "mlx-community/fish-audio-s2-pro-8bit",
|
||||
referenceAudioPath: "/Users/example/Voices/reference.wav",
|
||||
referenceText: "The exact words spoken in the reference recording.",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
`referenceAudioPath` is resolved on the Mac running the native app, not on a
|
||||
remote Gateway. The file stays local: the app passes it only to its isolated MLX
|
||||
helper. Local Fish output is streamed as PCM into Talk playback so speech can
|
||||
start before a long generation finishes.
|
||||
|
||||
<Note>
|
||||
Local MLX currently applies only to native macOS Talk. Other channels and
|
||||
clients use the Gateway-selected hosted speech provider. iOS and Android retain
|
||||
their existing native/system and Gateway Talk paths.
|
||||
</Note>
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **`Fish Audio API key missing`**: set `FISH_API_KEY` or `tts.providers.fish-audio.apiKey`.
|
||||
- **HTTP 401**: verify the API key at Fish Audio.
|
||||
- **HTTP 402**: the selected hosted model requires available credits or plan access.
|
||||
- **Local model falls back to the system voice**: confirm Apple silicon, free disk space, and the exact Hugging Face model id.
|
||||
- **Local clone does not match**: use clean single-speaker audio and make `referenceText` match it exactly.
|
||||
|
||||
See the [Fish Audio TTS API](https://docs.fish.audio/features/text-to-speech)
|
||||
and [Fish Audio Research License](https://huggingface.co/fishaudio/s2-pro/blob/main/LICENSE.md).
|
||||
@@ -63,6 +63,7 @@ speech.
|
||||
| **Azure Speech** | `AZURE_SPEECH_KEY` + `AZURE_SPEECH_REGION` (also `AZURE_SPEECH_API_KEY`, `SPEECH_KEY`, `SPEECH_REGION`) | Native Ogg/Opus voice-note output and telephony. |
|
||||
| **DeepInfra** | `DEEPINFRA_API_KEY` | OpenAI-compatible TTS. Defaults to `hexgrad/Kokoro-82M`. |
|
||||
| **ElevenLabs** | `ELEVENLABS_API_KEY` or `XI_API_KEY` | Voice cloning, multilingual, deterministic via `seed`; streamed for Discord voice playback. |
|
||||
| **Fish Audio** | `FISH_API_KEY` or `FISH_AUDIO_API_KEY` | S2.1 hosted TTS, expressive tags, voice discovery, streaming, and telephony. |
|
||||
| **Google Gemini** | `GEMINI_API_KEY` or `GOOGLE_API_KEY` | Gemini API batch TTS; persona-aware via `promptTemplate: "audio-profile-v1"`. |
|
||||
| **Gradium** | `GRADIUM_API_KEY` | Voice-note and telephony output. |
|
||||
| **Inworld** | `INWORLD_API_KEY` | Streaming TTS API. Native Opus voice-note and PCM telephony. |
|
||||
@@ -132,6 +133,24 @@ fields shown below are canonical; each provider's own `voice`/`voiceId`/
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="Fish Audio">
|
||||
```json5
|
||||
{
|
||||
tts: {
|
||||
auto: "tagged",
|
||||
provider: "fish-audio",
|
||||
providers: {
|
||||
"fish-audio": {
|
||||
apiKey: "${FISH_API_KEY}",
|
||||
model: "s2.1-pro",
|
||||
speakerVoiceId: "802e3bc2b27e49c2995d23ef70e6ac89",
|
||||
latency: "balanced",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="Google Gemini">
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
# Fish Audio speech plugin
|
||||
|
||||
Official OpenClaw speech provider for Fish Audio's hosted S2.1 API.
|
||||
|
||||
Configure `tts.provider: "fish-audio"` and set `FISH_API_KEY`. The provider
|
||||
supports buffered audio, HTTP-streamed playback, native Opus voice notes,
|
||||
8 kHz PCM telephony, and Fish Audio voice discovery.
|
||||
|
||||
See [Fish Audio](https://docs.openclaw.ai/providers/fish-audio) for setup,
|
||||
models, voice selection, expressive tags, and local macOS MLX usage.
|
||||
@@ -0,0 +1,12 @@
|
||||
// Fish Audio plugin entrypoint registers hosted speech synthesis.
|
||||
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { buildFishAudioSpeechProvider } from "./speech-provider.js";
|
||||
|
||||
export default definePluginEntry({
|
||||
id: "fish-audio",
|
||||
name: "Fish Audio Speech",
|
||||
description: "Hosted Fish Audio S2.1 text-to-speech provider",
|
||||
register(api) {
|
||||
api.registerSpeechProvider(buildFishAudioSpeechProvider());
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"id": "fish-audio",
|
||||
"activation": {
|
||||
"onStartup": false
|
||||
},
|
||||
"name": "Fish Audio",
|
||||
"description": "Fish Audio S2.1 hosted text-to-speech with streaming, voice notes, and telephony output.",
|
||||
"setup": {
|
||||
"providers": [
|
||||
{
|
||||
"id": "fish-audio",
|
||||
"envVars": ["FISH_API_KEY", "FISH_AUDIO_API_KEY"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"contracts": {
|
||||
"speechProviders": ["fish-audio"]
|
||||
},
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "@openclaw/fish-audio-speech",
|
||||
"version": "2026.7.2",
|
||||
"description": "OpenClaw Fish Audio speech plugin.",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/openclaw/openclaw"
|
||||
},
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"@openclaw/plugin-sdk": "workspace:*"
|
||||
},
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
],
|
||||
"install": {
|
||||
"clawhubSpec": "clawhub:@openclaw/fish-audio-speech",
|
||||
"npmSpec": "@openclaw/fish-audio-speech",
|
||||
"defaultChoice": "npm",
|
||||
"minHostVersion": ">=2026.7.2"
|
||||
},
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.7.2"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.7.2",
|
||||
"bundledDist": false
|
||||
},
|
||||
"release": {
|
||||
"publishToClawHub": true,
|
||||
"publishToNpm": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
// Fish Audio tests cover config, request mapping, streaming, discovery, and target formats.
|
||||
import { afterAll, afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { buildFishAudioSpeechProvider } from "./speech-provider.js";
|
||||
|
||||
const fetchWithSsrFGuardMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({
|
||||
fetchWithSsrFGuard: async (params: { url: string; init?: RequestInit; timeoutMs?: number }) => {
|
||||
fetchWithSsrFGuardMock(params);
|
||||
return {
|
||||
response: await globalThis.fetch(params.url, params.init),
|
||||
release: vi.fn(async () => {}),
|
||||
};
|
||||
},
|
||||
ssrfPolicyFromHttpBaseUrlAllowedHostname: () => undefined,
|
||||
}));
|
||||
|
||||
function requestBody(init?: RequestInit): Record<string, unknown> {
|
||||
if (typeof init?.body !== "string") {
|
||||
throw new Error("expected Fish Audio JSON request body");
|
||||
}
|
||||
return JSON.parse(init.body) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
describe("Fish Audio speech provider", () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
afterAll(() => {
|
||||
vi.doUnmock("openclaw/plugin-sdk/ssrf-runtime");
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
fetchWithSsrFGuardMock.mockClear();
|
||||
vi.unstubAllEnvs();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("exposes S2.1 free as the default without requiring a voice id", () => {
|
||||
vi.stubEnv("FISH_API_KEY", "fish-test");
|
||||
const provider = buildFishAudioSpeechProvider();
|
||||
expect(provider.defaultModel).toBe("s2.1-pro");
|
||||
expect(provider.models).toEqual(["s2.1-pro-free", "s2.1-pro", "s2-pro", "s1"]);
|
||||
expect(provider.isConfigured({ providerConfig: {}, timeoutMs: 1_000 })).toBe(true);
|
||||
});
|
||||
|
||||
it("maps hosted synthesis and preserves Fish expression tags", async () => {
|
||||
globalThis.fetch = vi.fn(async (url: string, init?: RequestInit) => {
|
||||
expect(url).toBe("https://api.fish.audio/v1/tts");
|
||||
expect(new Headers(init?.headers).get("model")).toBe("s2.1-pro");
|
||||
expect(new Headers(init?.headers).get("authorization")).toBe("Bearer fish-test");
|
||||
expect(requestBody(init)).toEqual({
|
||||
text: "[whisper] Keep this quiet. [excited] Now celebrate!",
|
||||
format: "mp3",
|
||||
reference_id: "voice-123",
|
||||
sample_rate: 44100,
|
||||
latency: "normal",
|
||||
prosody: { speed: 1.1 },
|
||||
temperature: 0.6,
|
||||
top_p: 0.8,
|
||||
normalize: false,
|
||||
});
|
||||
return new Response(new Uint8Array([1, 2, 3]), {
|
||||
headers: { "content-type": "audio/mpeg" },
|
||||
});
|
||||
}) as unknown as typeof fetch;
|
||||
const provider = buildFishAudioSpeechProvider();
|
||||
const result = await provider.synthesize({
|
||||
text: "[whisper] Keep this quiet. [excited] Now celebrate!",
|
||||
cfg: {} as never,
|
||||
providerConfig: {
|
||||
apiKey: "fish-test",
|
||||
model: "s2.1-pro",
|
||||
speakerVoiceId: "voice-123",
|
||||
latency: "normal",
|
||||
speed: 1.1,
|
||||
temperature: 0.6,
|
||||
topP: 0.8,
|
||||
normalize: false,
|
||||
},
|
||||
target: "audio-file",
|
||||
timeoutMs: 12_345,
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
audioBuffer: Buffer.from([1, 2, 3]),
|
||||
outputFormat: "mp3",
|
||||
fileExtension: ".mp3",
|
||||
voiceCompatible: false,
|
||||
});
|
||||
expect(fetchWithSsrFGuardMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ timeoutMs: 12_345, auditContext: "fish-audio.tts" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses native Opus for streamed voice notes and releases the response", async () => {
|
||||
globalThis.fetch = vi.fn(async (_url: string, init?: RequestInit) => {
|
||||
expect(requestBody(init)).toMatchObject({ format: "opus", sample_rate: 48000 });
|
||||
return new Response(new Uint8Array([4, 5, 6]), {
|
||||
headers: { "content-type": "audio/opus" },
|
||||
});
|
||||
}) as unknown as typeof fetch;
|
||||
const provider = buildFishAudioSpeechProvider();
|
||||
const result = await provider.streamSynthesize?.({
|
||||
text: "hello",
|
||||
cfg: {} as never,
|
||||
providerConfig: { apiKey: "fish-test" },
|
||||
target: "voice-note",
|
||||
timeoutMs: 1_000,
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
outputFormat: "opus",
|
||||
fileExtension: ".opus",
|
||||
voiceCompatible: true,
|
||||
});
|
||||
const bytes = new Uint8Array(await new Response(result?.audioStream).arrayBuffer());
|
||||
expect([...bytes]).toEqual([4, 5, 6]);
|
||||
await result?.release?.();
|
||||
});
|
||||
|
||||
it("requests raw 8 kHz PCM for telephony", async () => {
|
||||
globalThis.fetch = vi.fn(async (_url: string, init?: RequestInit) => {
|
||||
expect(requestBody(init)).toMatchObject({ format: "pcm", sample_rate: 8000 });
|
||||
return new Response(new Uint8Array([7, 8]));
|
||||
}) as unknown as typeof fetch;
|
||||
const provider = buildFishAudioSpeechProvider();
|
||||
const result = await provider.synthesizeTelephony?.({
|
||||
text: "hello",
|
||||
cfg: {} as never,
|
||||
providerConfig: { apiKey: "fish-test" },
|
||||
timeoutMs: 1_000,
|
||||
});
|
||||
expect(result).toEqual({
|
||||
audioBuffer: Buffer.from([7, 8]),
|
||||
outputFormat: "pcm",
|
||||
sampleRate: 8000,
|
||||
});
|
||||
});
|
||||
|
||||
it("lists all owned pages then one public page with deduplication", async () => {
|
||||
globalThis.fetch = vi.fn(async (url: string) => {
|
||||
const parsed = new URL(url);
|
||||
const self = parsed.searchParams.get("self") === "true";
|
||||
const page = Number(parsed.searchParams.get("page_number"));
|
||||
if (self && page === 1) {
|
||||
return Response.json({
|
||||
total: 101,
|
||||
items: Array.from({ length: 100 }, (_, index) => ({
|
||||
_id: `own-${index}`,
|
||||
title: `Own ${index}`,
|
||||
})),
|
||||
});
|
||||
}
|
||||
if (self) {
|
||||
return Response.json({ total: 101, items: [{ _id: "own-100", title: "Own 100" }] });
|
||||
}
|
||||
return Response.json({
|
||||
items: [
|
||||
{ _id: "own-0", title: "Duplicate" },
|
||||
{ _id: "public-1", title: "Public", languages: ["en"], tags: ["warm"] },
|
||||
],
|
||||
});
|
||||
}) as unknown as typeof fetch;
|
||||
const provider = buildFishAudioSpeechProvider();
|
||||
const voices = await provider.listVoices?.({
|
||||
providerConfig: { apiKey: "fish-test" },
|
||||
timeoutMs: 9_000,
|
||||
});
|
||||
expect(voices).toHaveLength(102);
|
||||
expect(voices?.at(-1)).toMatchObject({ id: "public-1", locale: "en", personalities: ["warm"] });
|
||||
expect(fetchWithSsrFGuardMock).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("fails closed on blank credentials before network access", async () => {
|
||||
vi.stubEnv("FISH_API_KEY", " ");
|
||||
vi.stubEnv("FISH_AUDIO_API_KEY", " ");
|
||||
const provider = buildFishAudioSpeechProvider();
|
||||
const providerConfig = { apiKey: " " };
|
||||
expect(provider.isConfigured({ providerConfig, timeoutMs: 1_000 })).toBe(false);
|
||||
await expect(
|
||||
provider.synthesize({
|
||||
text: "hello",
|
||||
cfg: {} as never,
|
||||
providerConfig,
|
||||
target: "audio-file",
|
||||
timeoutMs: 1_000,
|
||||
}),
|
||||
).rejects.toThrow("Fish Audio API key missing");
|
||||
expect(fetchWithSsrFGuardMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,364 @@
|
||||
// Fish Audio provider maps OpenClaw speech contracts to the hosted S2.1 API.
|
||||
import { resolveGeneratedMediaMaxBytes } from "openclaw/plugin-sdk/media-generation-runtime";
|
||||
import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/secret-input";
|
||||
import type {
|
||||
SpeechDirectiveTokenParseContext,
|
||||
SpeechProviderConfig,
|
||||
SpeechProviderOverrides,
|
||||
SpeechProviderPlugin,
|
||||
SpeechSynthesisRequest,
|
||||
SpeechSynthesisTarget,
|
||||
} from "openclaw/plugin-sdk/speech";
|
||||
import {
|
||||
asBoolean,
|
||||
asFiniteNumber,
|
||||
asObject,
|
||||
parseSpeechDirectiveNumberOverride,
|
||||
resolveSpeechProviderApiKey,
|
||||
trimToUndefined,
|
||||
} from "openclaw/plugin-sdk/speech-core";
|
||||
import {
|
||||
FISH_AUDIO_STREAM_MAX_BYTES,
|
||||
type FishAudioFormat,
|
||||
type FishAudioLatency,
|
||||
type FishAudioModel,
|
||||
type FishAudioTtsRequest,
|
||||
fishAudioTts,
|
||||
fishAudioTtsStream,
|
||||
listFishAudioVoices,
|
||||
normalizeFishAudioBaseUrl,
|
||||
} from "./tts.js";
|
||||
|
||||
const FISH_AUDIO_MODELS = ["s2.1-pro-free", "s2.1-pro", "s2-pro", "s1"] as const;
|
||||
const DEFAULT_MODEL: FishAudioModel = "s2.1-pro";
|
||||
const DEFAULT_LATENCY: FishAudioLatency = "balanced";
|
||||
const DEFAULT_TIMEOUT_MS = 240_000;
|
||||
|
||||
type FishAudioProviderConfig = {
|
||||
apiKey?: string;
|
||||
baseUrl: string;
|
||||
model: FishAudioModel;
|
||||
referenceId?: string;
|
||||
latency: FishAudioLatency;
|
||||
speed?: number;
|
||||
temperature?: number;
|
||||
topP?: number;
|
||||
normalize?: boolean;
|
||||
};
|
||||
|
||||
type FishAudioOverrides = Partial<Omit<FishAudioProviderConfig, "apiKey" | "baseUrl">>;
|
||||
|
||||
function normalizeModel(value: unknown): FishAudioModel {
|
||||
const model = trimToUndefined(value);
|
||||
if (!model) {
|
||||
return DEFAULT_MODEL;
|
||||
}
|
||||
if (FISH_AUDIO_MODELS.some((candidate) => candidate === model)) {
|
||||
return model as FishAudioModel;
|
||||
}
|
||||
throw new Error(`invalid Fish Audio model "${model}"`);
|
||||
}
|
||||
|
||||
function normalizeLatency(value: unknown): FishAudioLatency {
|
||||
const latency = trimToUndefined(value)?.toLowerCase();
|
||||
if (!latency) {
|
||||
return DEFAULT_LATENCY;
|
||||
}
|
||||
if (latency === "low" || latency === "balanced" || latency === "normal") {
|
||||
return latency;
|
||||
}
|
||||
throw new Error(`invalid Fish Audio latency "${latency}"`);
|
||||
}
|
||||
|
||||
function normalizeNumber(value: unknown, min: number, max: number): number | undefined {
|
||||
const number = asFiniteNumber(value);
|
||||
return number != null && number >= min && number <= max ? number : undefined;
|
||||
}
|
||||
|
||||
function resolveReferenceId(raw: Record<string, unknown> | undefined): string | undefined {
|
||||
return trimToUndefined(raw?.speakerVoiceId ?? raw?.voiceId ?? raw?.referenceId);
|
||||
}
|
||||
|
||||
function normalizeProviderConfig(rawConfig: Record<string, unknown>): FishAudioProviderConfig {
|
||||
const providers = asObject(rawConfig.providers);
|
||||
const raw = asObject(providers?.["fish-audio"]) ?? asObject(rawConfig["fish-audio"]);
|
||||
return {
|
||||
apiKey: normalizeResolvedSecretInputString({
|
||||
value: raw?.apiKey,
|
||||
path: "tts.providers.fish-audio.apiKey",
|
||||
}),
|
||||
baseUrl: normalizeFishAudioBaseUrl(trimToUndefined(raw?.baseUrl)),
|
||||
model: normalizeModel(raw?.model ?? raw?.modelId),
|
||||
referenceId: resolveReferenceId(raw),
|
||||
latency: normalizeLatency(raw?.latency),
|
||||
speed: normalizeNumber(raw?.speed, 0.5, 2),
|
||||
temperature: normalizeNumber(raw?.temperature, 0, 1),
|
||||
topP: normalizeNumber(raw?.topP ?? raw?.top_p, 0, 1),
|
||||
normalize: asBoolean(raw?.normalize),
|
||||
};
|
||||
}
|
||||
|
||||
function readProviderConfig(config: SpeechProviderConfig): FishAudioProviderConfig {
|
||||
const defaults = normalizeProviderConfig({});
|
||||
const raw = asObject(config) ?? {};
|
||||
return {
|
||||
apiKey: trimToUndefined(raw.apiKey) ?? defaults.apiKey,
|
||||
baseUrl: normalizeFishAudioBaseUrl(trimToUndefined(raw.baseUrl) ?? defaults.baseUrl),
|
||||
model: normalizeModel(raw.model ?? raw.modelId ?? defaults.model),
|
||||
referenceId: resolveReferenceId(raw) ?? defaults.referenceId,
|
||||
latency: normalizeLatency(raw.latency ?? defaults.latency),
|
||||
speed: normalizeNumber(raw.speed, 0.5, 2) ?? defaults.speed,
|
||||
temperature: normalizeNumber(raw.temperature, 0, 1) ?? defaults.temperature,
|
||||
topP: normalizeNumber(raw.topP ?? raw.top_p, 0, 1) ?? defaults.topP,
|
||||
normalize: asBoolean(raw.normalize) ?? defaults.normalize,
|
||||
};
|
||||
}
|
||||
|
||||
function readOverrides(overrides: SpeechProviderOverrides | undefined): FishAudioOverrides {
|
||||
const raw = asObject(overrides) ?? {};
|
||||
return {
|
||||
model: trimToUndefined(raw.model ?? raw.modelId)
|
||||
? normalizeModel(raw.model ?? raw.modelId)
|
||||
: undefined,
|
||||
referenceId: resolveReferenceId(raw),
|
||||
latency: trimToUndefined(raw.latency) ? normalizeLatency(raw.latency) : undefined,
|
||||
speed: normalizeNumber(raw.speed, 0.5, 2),
|
||||
temperature: normalizeNumber(raw.temperature, 0, 1),
|
||||
topP: normalizeNumber(raw.topP ?? raw.top_p, 0, 1),
|
||||
normalize: asBoolean(raw.normalize),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveApiKey(configValue?: string): string | undefined {
|
||||
return resolveSpeechProviderApiKey(
|
||||
configValue,
|
||||
process.env.FISH_API_KEY,
|
||||
process.env.FISH_AUDIO_API_KEY,
|
||||
);
|
||||
}
|
||||
|
||||
function parseDirectiveToken(ctx: SpeechDirectiveTokenParseContext) {
|
||||
switch (ctx.key) {
|
||||
case "voice":
|
||||
case "voiceid":
|
||||
case "voice_id":
|
||||
case "referenceid":
|
||||
case "reference_id":
|
||||
case "fish_voice":
|
||||
case "fishaudio_voice":
|
||||
return ctx.policy.allowVoice
|
||||
? { handled: true, overrides: { ...ctx.currentOverrides, referenceId: ctx.value } }
|
||||
: { handled: true };
|
||||
case "model":
|
||||
case "modelid":
|
||||
case "model_id":
|
||||
case "fish_model":
|
||||
case "fishaudio_model":
|
||||
if (!ctx.policy.allowModelId) {
|
||||
return { handled: true };
|
||||
}
|
||||
try {
|
||||
return {
|
||||
handled: true,
|
||||
overrides: { ...ctx.currentOverrides, model: normalizeModel(ctx.value) },
|
||||
};
|
||||
} catch (error) {
|
||||
return { handled: true, warnings: [String(error)] };
|
||||
}
|
||||
case "speed":
|
||||
case "fish_speed":
|
||||
return parseSpeechDirectiveNumberOverride({
|
||||
ctx,
|
||||
overrideKey: "speed",
|
||||
range: { min: 0.5, max: 2 },
|
||||
warning: (value) => `invalid Fish Audio speed "${value}"`,
|
||||
});
|
||||
case "temperature":
|
||||
case "fish_temperature":
|
||||
return parseSpeechDirectiveNumberOverride({
|
||||
ctx,
|
||||
overrideKey: "temperature",
|
||||
range: { min: 0, max: 1 },
|
||||
warning: (value) => `invalid Fish Audio temperature "${value}"`,
|
||||
});
|
||||
case "top_p":
|
||||
case "topp":
|
||||
case "fish_top_p":
|
||||
return parseSpeechDirectiveNumberOverride({
|
||||
ctx,
|
||||
overrideKey: "topP",
|
||||
range: { min: 0, max: 1 },
|
||||
warning: (value) => `invalid Fish Audio top_p "${value}"`,
|
||||
});
|
||||
case "latency":
|
||||
case "fish_latency":
|
||||
if (!ctx.policy.allowVoiceSettings) {
|
||||
return { handled: true };
|
||||
}
|
||||
try {
|
||||
return {
|
||||
handled: true,
|
||||
overrides: { ...ctx.currentOverrides, latency: normalizeLatency(ctx.value) },
|
||||
};
|
||||
} catch (error) {
|
||||
return { handled: true, warnings: [String(error)] };
|
||||
}
|
||||
case "normalize":
|
||||
case "fish_normalize": {
|
||||
if (!ctx.policy.allowNormalization) {
|
||||
return { handled: true };
|
||||
}
|
||||
const value = ctx.value.trim().toLowerCase();
|
||||
if (["true", "1", "yes", "on"].includes(value)) {
|
||||
return { handled: true, overrides: { ...ctx.currentOverrides, normalize: true } };
|
||||
}
|
||||
if (["false", "0", "no", "off"].includes(value)) {
|
||||
return { handled: true, overrides: { ...ctx.currentOverrides, normalize: false } };
|
||||
}
|
||||
return { handled: true, warnings: [`invalid Fish Audio normalize "${ctx.value}"`] };
|
||||
}
|
||||
default:
|
||||
return { handled: false };
|
||||
}
|
||||
}
|
||||
|
||||
function resolveFormat(target: SpeechSynthesisTarget): {
|
||||
format: FishAudioFormat;
|
||||
sampleRate?: number;
|
||||
fileExtension: string;
|
||||
voiceCompatible: boolean;
|
||||
} {
|
||||
if (target === "voice-note") {
|
||||
return { format: "opus", sampleRate: 48_000, fileExtension: ".opus", voiceCompatible: true };
|
||||
}
|
||||
if (target === "telephony") {
|
||||
return { format: "pcm", sampleRate: 8_000, fileExtension: ".pcm", voiceCompatible: false };
|
||||
}
|
||||
return { format: "mp3", sampleRate: 44_100, fileExtension: ".mp3", voiceCompatible: false };
|
||||
}
|
||||
|
||||
function resolveSynthesisRequest(
|
||||
req: Pick<
|
||||
SpeechSynthesisRequest,
|
||||
"cfg" | "providerConfig" | "providerOverrides" | "text" | "timeoutMs" | "target"
|
||||
>,
|
||||
): FishAudioTtsRequest & { fileExtension: string; voiceCompatible: boolean } {
|
||||
const config = readProviderConfig(req.providerConfig);
|
||||
const overrides = readOverrides(req.providerOverrides);
|
||||
const apiKey = resolveApiKey(config.apiKey);
|
||||
if (!apiKey) {
|
||||
throw new Error("Fish Audio API key missing");
|
||||
}
|
||||
const output = resolveFormat(req.target);
|
||||
return {
|
||||
text: req.text,
|
||||
apiKey,
|
||||
baseUrl: config.baseUrl,
|
||||
model: overrides.model ?? config.model,
|
||||
referenceId: overrides.referenceId ?? config.referenceId,
|
||||
latency: overrides.latency ?? config.latency,
|
||||
speed: overrides.speed ?? config.speed,
|
||||
temperature: overrides.temperature ?? config.temperature,
|
||||
topP: overrides.topP ?? config.topP,
|
||||
normalize: overrides.normalize ?? config.normalize,
|
||||
timeoutMs: req.timeoutMs,
|
||||
maxBytes: resolveGeneratedMediaMaxBytes(req.cfg, "audio"),
|
||||
...output,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildFishAudioSpeechProvider(): SpeechProviderPlugin {
|
||||
return {
|
||||
id: "fish-audio",
|
||||
label: "Fish Audio",
|
||||
autoSelectOrder: 28,
|
||||
defaultTimeoutMs: DEFAULT_TIMEOUT_MS,
|
||||
defaultModel: DEFAULT_MODEL,
|
||||
models: FISH_AUDIO_MODELS,
|
||||
resolveConfig: ({ rawConfig }) => normalizeProviderConfig(rawConfig),
|
||||
parseDirectiveToken,
|
||||
resolveTalkConfig: ({ baseTtsConfig, talkProviderConfig }) => {
|
||||
const base = normalizeProviderConfig(baseTtsConfig);
|
||||
return {
|
||||
...base,
|
||||
...(talkProviderConfig.apiKey === undefined
|
||||
? {}
|
||||
: {
|
||||
apiKey: normalizeResolvedSecretInputString({
|
||||
value: talkProviderConfig.apiKey,
|
||||
path: "talk.providers.fish-audio.apiKey",
|
||||
}),
|
||||
}),
|
||||
...(trimToUndefined(talkProviderConfig.baseUrl) == null
|
||||
? {}
|
||||
: { baseUrl: normalizeFishAudioBaseUrl(trimToUndefined(talkProviderConfig.baseUrl)) }),
|
||||
...(trimToUndefined(talkProviderConfig.modelId ?? talkProviderConfig.model) == null
|
||||
? {}
|
||||
: { model: normalizeModel(talkProviderConfig.modelId ?? talkProviderConfig.model) }),
|
||||
...(resolveReferenceId(talkProviderConfig) == null
|
||||
? {}
|
||||
: { referenceId: resolveReferenceId(talkProviderConfig) }),
|
||||
...(trimToUndefined(talkProviderConfig.latency) == null
|
||||
? {}
|
||||
: { latency: normalizeLatency(talkProviderConfig.latency) }),
|
||||
...(normalizeNumber(talkProviderConfig.speed, 0.5, 2) == null
|
||||
? {}
|
||||
: { speed: normalizeNumber(talkProviderConfig.speed, 0.5, 2) }),
|
||||
};
|
||||
},
|
||||
resolveTalkOverrides: ({ params }) => ({
|
||||
...(trimToUndefined(params.modelId ?? params.model) == null
|
||||
? {}
|
||||
: { model: normalizeModel(params.modelId ?? params.model) }),
|
||||
...(resolveReferenceId(params) == null ? {} : { referenceId: resolveReferenceId(params) }),
|
||||
...(normalizeNumber(params.speed, 0.5, 2) == null
|
||||
? {}
|
||||
: { speed: normalizeNumber(params.speed, 0.5, 2) }),
|
||||
}),
|
||||
listVoices: async (req) => {
|
||||
const config = readProviderConfig(req.providerConfig ?? {});
|
||||
const apiKey = resolveApiKey(trimToUndefined(req.apiKey) ?? config.apiKey);
|
||||
if (!apiKey) {
|
||||
throw new Error("Fish Audio API key missing");
|
||||
}
|
||||
return await listFishAudioVoices({
|
||||
apiKey,
|
||||
baseUrl: normalizeFishAudioBaseUrl(trimToUndefined(req.baseUrl) ?? config.baseUrl),
|
||||
timeoutMs: req.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
||||
});
|
||||
},
|
||||
isConfigured: ({ providerConfig }) =>
|
||||
Boolean(resolveApiKey(readProviderConfig(providerConfig).apiKey)),
|
||||
synthesize: async (req) => {
|
||||
const params = resolveSynthesisRequest(req);
|
||||
return {
|
||||
audioBuffer: await fishAudioTts(params),
|
||||
outputFormat: params.format,
|
||||
fileExtension: params.fileExtension,
|
||||
voiceCompatible: params.voiceCompatible,
|
||||
};
|
||||
},
|
||||
streamSynthesize: async (req) => {
|
||||
const params = resolveSynthesisRequest(req);
|
||||
const stream = await fishAudioTtsStream({
|
||||
...params,
|
||||
maxBytes: Math.min(params.maxBytes, FISH_AUDIO_STREAM_MAX_BYTES),
|
||||
});
|
||||
return {
|
||||
audioStream: stream.audioStream,
|
||||
outputFormat: params.format,
|
||||
fileExtension: params.fileExtension,
|
||||
voiceCompatible: params.voiceCompatible,
|
||||
release: stream.release,
|
||||
};
|
||||
},
|
||||
synthesizeTelephony: async (req) => {
|
||||
const params = resolveSynthesisRequest({ ...req, target: "telephony" });
|
||||
return {
|
||||
audioBuffer: await fishAudioTts(params),
|
||||
outputFormat: "pcm",
|
||||
sampleRate: 8_000,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
// Fish Audio HTTP client for buffered and streaming TTS plus voice discovery.
|
||||
import { MAX_AUDIO_BYTES } from "openclaw/plugin-sdk/media-runtime";
|
||||
import {
|
||||
assertOkOrThrowProviderError,
|
||||
assertProviderBinaryResponseContent,
|
||||
readProviderBinaryResponse,
|
||||
readProviderJsonResponse,
|
||||
} from "openclaw/plugin-sdk/provider-http";
|
||||
import { asObject, trimToUndefined, type SpeechVoiceOption } from "openclaw/plugin-sdk/speech";
|
||||
import {
|
||||
fetchWithSsrFGuard,
|
||||
ssrfPolicyFromHttpBaseUrlAllowedHostname,
|
||||
} from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
|
||||
const FISH_AUDIO_BASE_URL = "https://api.fish.audio";
|
||||
const FISH_AUDIO_VOICES_MAX_BYTES = 2 * 1024 * 1024;
|
||||
const FISH_AUDIO_VOICE_PAGE_SIZE = 100;
|
||||
const FISH_AUDIO_MAX_OWN_VOICE_PAGES = 20;
|
||||
|
||||
export type FishAudioModel = "s2.1-pro-free" | "s2.1-pro" | "s2-pro" | "s1";
|
||||
export type FishAudioLatency = "low" | "balanced" | "normal";
|
||||
export type FishAudioFormat = "mp3" | "opus" | "wav" | "pcm";
|
||||
|
||||
export type FishAudioTtsRequest = {
|
||||
text: string;
|
||||
apiKey: string;
|
||||
baseUrl: string;
|
||||
model: FishAudioModel;
|
||||
referenceId?: string;
|
||||
format: FishAudioFormat;
|
||||
sampleRate?: number;
|
||||
latency?: FishAudioLatency;
|
||||
speed?: number;
|
||||
temperature?: number;
|
||||
topP?: number;
|
||||
normalize?: boolean;
|
||||
timeoutMs: number;
|
||||
maxBytes: number;
|
||||
};
|
||||
|
||||
export function normalizeFishAudioBaseUrl(value?: string): string {
|
||||
const trimmed = value?.trim();
|
||||
return trimmed ? trimmed.replace(/\/+$/u, "") : FISH_AUDIO_BASE_URL;
|
||||
}
|
||||
|
||||
function buildFishAudioRequestBody(params: FishAudioTtsRequest): string {
|
||||
return JSON.stringify({
|
||||
text: params.text,
|
||||
format: params.format,
|
||||
...(params.referenceId ? { reference_id: params.referenceId } : {}),
|
||||
...(params.sampleRate == null ? {} : { sample_rate: params.sampleRate }),
|
||||
...(params.latency == null ? {} : { latency: params.latency }),
|
||||
...(params.speed == null ? {} : { prosody: { speed: params.speed } }),
|
||||
...(params.temperature == null ? {} : { temperature: params.temperature }),
|
||||
...(params.topP == null ? {} : { top_p: params.topP }),
|
||||
...(params.normalize == null ? {} : { normalize: params.normalize }),
|
||||
});
|
||||
}
|
||||
|
||||
async function requestFishAudioTts(params: FishAudioTtsRequest): Promise<{
|
||||
response: Response;
|
||||
release: () => Promise<void>;
|
||||
}> {
|
||||
const baseUrl = normalizeFishAudioBaseUrl(params.baseUrl);
|
||||
return await fetchWithSsrFGuard({
|
||||
url: `${baseUrl}/v1/tts`,
|
||||
init: {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${params.apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
model: params.model,
|
||||
},
|
||||
body: buildFishAudioRequestBody(params),
|
||||
},
|
||||
timeoutMs: params.timeoutMs,
|
||||
policy: ssrfPolicyFromHttpBaseUrlAllowedHostname(baseUrl),
|
||||
auditContext: "fish-audio.tts",
|
||||
});
|
||||
}
|
||||
|
||||
export async function fishAudioTts(params: FishAudioTtsRequest): Promise<Buffer> {
|
||||
const { response, release } = await requestFishAudioTts(params);
|
||||
try {
|
||||
await assertOkOrThrowProviderError(response, "Fish Audio TTS API error");
|
||||
return Buffer.from(
|
||||
await readProviderBinaryResponse(response, "Fish Audio TTS API error", "audio", {
|
||||
maxBytes: params.maxBytes,
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
}
|
||||
|
||||
function createBoundedFishAudioStream(
|
||||
stream: ReadableStream<Uint8Array>,
|
||||
maxBytes: number,
|
||||
): { audioStream: ReadableStream<Uint8Array>; release: () => Promise<void> } {
|
||||
let reader: ReadableStreamDefaultReader<Uint8Array> | undefined;
|
||||
let totalBytes = 0;
|
||||
|
||||
const releaseReader = (activeReader: ReadableStreamDefaultReader<Uint8Array>) => {
|
||||
if (reader === activeReader) {
|
||||
reader = undefined;
|
||||
activeReader.releaseLock();
|
||||
}
|
||||
};
|
||||
const cancelReader = async (reason?: unknown) => {
|
||||
const activeReader = reader;
|
||||
if (!activeReader) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await activeReader.cancel(reason).catch(() => undefined);
|
||||
} finally {
|
||||
releaseReader(activeReader);
|
||||
}
|
||||
};
|
||||
|
||||
const audioStream = new ReadableStream<Uint8Array>({
|
||||
start() {
|
||||
reader = stream.getReader();
|
||||
},
|
||||
async pull(controller) {
|
||||
const activeReader = reader;
|
||||
if (!activeReader) {
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const chunk = await activeReader.read();
|
||||
if (chunk.done) {
|
||||
releaseReader(activeReader);
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
const remaining = maxBytes - totalBytes;
|
||||
if (chunk.value.byteLength > remaining) {
|
||||
if (remaining > 0) {
|
||||
controller.enqueue(chunk.value.subarray(0, remaining));
|
||||
}
|
||||
const error = new Error(
|
||||
`Fish Audio TTS API error: audio response exceeds ${maxBytes} bytes`,
|
||||
);
|
||||
await activeReader.cancel(error).catch(() => undefined);
|
||||
releaseReader(activeReader);
|
||||
controller.error(error);
|
||||
return;
|
||||
}
|
||||
totalBytes += chunk.value.byteLength;
|
||||
controller.enqueue(chunk.value);
|
||||
} catch (error) {
|
||||
releaseReader(activeReader);
|
||||
controller.error(error);
|
||||
}
|
||||
},
|
||||
async cancel(reason) {
|
||||
await cancelReader(reason);
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
audioStream,
|
||||
release: () => cancelReader(new Error("Fish Audio TTS stream released")),
|
||||
};
|
||||
}
|
||||
|
||||
export async function fishAudioTtsStream(params: FishAudioTtsRequest): Promise<{
|
||||
audioStream: ReadableStream<Uint8Array>;
|
||||
release: () => Promise<void>;
|
||||
}> {
|
||||
const { response, release } = await requestFishAudioTts(params);
|
||||
let handedOff = false;
|
||||
try {
|
||||
await assertOkOrThrowProviderError(response, "Fish Audio TTS API error");
|
||||
assertProviderBinaryResponseContent(response, "Fish Audio TTS API error", "audio");
|
||||
if (!response.body) {
|
||||
throw new Error("Fish Audio TTS API response missing audio stream");
|
||||
}
|
||||
const bounded = createBoundedFishAudioStream(response.body, params.maxBytes);
|
||||
let releasePromise: Promise<void> | undefined;
|
||||
const releaseAll = () => {
|
||||
releasePromise ??= (async () => {
|
||||
try {
|
||||
await bounded.release();
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
})();
|
||||
return releasePromise;
|
||||
};
|
||||
handedOff = true;
|
||||
return { audioStream: bounded.audioStream, release: releaseAll };
|
||||
} finally {
|
||||
if (!handedOff) {
|
||||
await release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type FishAudioVoicePayload = {
|
||||
total?: number;
|
||||
items?: unknown[];
|
||||
};
|
||||
|
||||
function parseVoiceItem(value: unknown): SpeechVoiceOption | undefined {
|
||||
const item = asObject(value);
|
||||
const id = trimToUndefined(item?.["_id"]);
|
||||
if (!id) {
|
||||
return undefined;
|
||||
}
|
||||
const languages = Array.isArray(item?.languages)
|
||||
? item.languages.flatMap((entry) =>
|
||||
typeof entry === "string" && entry.trim() ? [entry.trim()] : [],
|
||||
)
|
||||
: [];
|
||||
const tags = Array.isArray(item?.tags)
|
||||
? item.tags.flatMap((entry) =>
|
||||
typeof entry === "string" && entry.trim() ? [entry.trim()] : [],
|
||||
)
|
||||
: [];
|
||||
return {
|
||||
id,
|
||||
name: trimToUndefined(item?.title),
|
||||
description: trimToUndefined(item?.description),
|
||||
category: trimToUndefined(item?.visibility),
|
||||
locale: languages[0],
|
||||
personalities: tags.length > 0 ? tags : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async function requestVoicePage(params: {
|
||||
apiKey: string;
|
||||
baseUrl: string;
|
||||
timeoutMs: number;
|
||||
self: boolean;
|
||||
pageNumber: number;
|
||||
}): Promise<FishAudioVoicePayload> {
|
||||
const url = new URL(`${normalizeFishAudioBaseUrl(params.baseUrl)}/model`);
|
||||
url.searchParams.set("type", "tts");
|
||||
url.searchParams.set("page_size", String(FISH_AUDIO_VOICE_PAGE_SIZE));
|
||||
url.searchParams.set("page_number", String(params.pageNumber));
|
||||
if (params.self) {
|
||||
url.searchParams.set("self", "true");
|
||||
} else {
|
||||
url.searchParams.set("sort_by", "score");
|
||||
}
|
||||
const { response, release } = await fetchWithSsrFGuard({
|
||||
url: url.toString(),
|
||||
init: { headers: { Authorization: `Bearer ${params.apiKey}` } },
|
||||
timeoutMs: params.timeoutMs,
|
||||
policy: ssrfPolicyFromHttpBaseUrlAllowedHostname(params.baseUrl),
|
||||
auditContext: "fish-audio.voices",
|
||||
});
|
||||
try {
|
||||
await assertOkOrThrowProviderError(response, "Fish Audio voices API error");
|
||||
return await readProviderJsonResponse<FishAudioVoicePayload>(response, "Fish Audio voices", {
|
||||
maxBytes: FISH_AUDIO_VOICES_MAX_BYTES,
|
||||
});
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
}
|
||||
|
||||
export async function listFishAudioVoices(params: {
|
||||
apiKey: string;
|
||||
baseUrl: string;
|
||||
timeoutMs: number;
|
||||
}): Promise<SpeechVoiceOption[]> {
|
||||
const own: SpeechVoiceOption[] = [];
|
||||
for (let pageNumber = 1; pageNumber <= FISH_AUDIO_MAX_OWN_VOICE_PAGES; pageNumber += 1) {
|
||||
const payload = await requestVoicePage({ ...params, self: true, pageNumber });
|
||||
const items = Array.isArray(payload.items) ? payload.items : [];
|
||||
own.push(...items.flatMap((item) => parseVoiceItem(item) ?? []));
|
||||
if (
|
||||
items.length < FISH_AUDIO_VOICE_PAGE_SIZE ||
|
||||
own.length >= (payload.total ?? Number.MAX_SAFE_INTEGER)
|
||||
) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let publicVoices: SpeechVoiceOption[] = [];
|
||||
try {
|
||||
const payload = await requestVoicePage({ ...params, self: false, pageNumber: 1 });
|
||||
publicVoices = (Array.isArray(payload.items) ? payload.items : []).flatMap(
|
||||
(item) => parseVoiceItem(item) ?? [],
|
||||
);
|
||||
} catch {
|
||||
// Own voices remain useful when the public catalog is temporarily unavailable.
|
||||
}
|
||||
|
||||
const seen = new Set<string>();
|
||||
return [...own, ...publicVoices].filter((voice) => {
|
||||
if (seen.has(voice.id)) {
|
||||
return false;
|
||||
}
|
||||
seen.add(voice.id);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export const FISH_AUDIO_STREAM_MAX_BYTES = MAX_AUDIO_BYTES;
|
||||
@@ -266,6 +266,7 @@
|
||||
"!dist/extensions/featherless/**",
|
||||
"!dist/extensions/firecrawl/**",
|
||||
"!dist/extensions/fireworks/**",
|
||||
"!dist/extensions/fish-audio/**",
|
||||
"!dist/extensions/google-meet/**",
|
||||
"!dist/extensions/googlechat/**",
|
||||
"!dist/extensions/gmi/**",
|
||||
|
||||
Generated
+6
@@ -928,6 +928,12 @@ importers:
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/plugin-sdk
|
||||
|
||||
extensions/fish-audio:
|
||||
devDependencies:
|
||||
'@openclaw/plugin-sdk':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/plugin-sdk
|
||||
|
||||
extensions/github-copilot:
|
||||
dependencies:
|
||||
'@clack/prompts':
|
||||
|
||||
Reference in New Issue
Block a user