@openclaw/fish-audio-speech 2026.7.2-beta.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -0
- package/dist/index.js +13 -0
- package/dist/speech-provider.js +318 -0
- package/dist/tts.js +207 -0
- package/openclaw.plugin.json +24 -0
- package/package.json +49 -0
package/README.md
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Fish Audio speech plugin
|
|
2
|
+
|
|
3
|
+
Official OpenClaw speech provider for Fish Audio's hosted S2.1 API.
|
|
4
|
+
|
|
5
|
+
Configure `tts.provider: "fish-audio"` and set `FISH_API_KEY`. The provider
|
|
6
|
+
supports buffered audio, HTTP-streamed playback, native Opus voice notes,
|
|
7
|
+
8 kHz PCM telephony, and Fish Audio voice discovery.
|
|
8
|
+
|
|
9
|
+
See [Fish Audio](https://docs.openclaw.ai/providers/fish-audio) for setup,
|
|
10
|
+
models, voice selection, expressive tags, and local macOS MLX usage.
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { buildFishAudioSpeechProvider } from "./speech-provider.js";
|
|
2
|
+
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
|
|
3
|
+
//#region extensions/fish-audio/index.ts
|
|
4
|
+
var fish_audio_default = definePluginEntry({
|
|
5
|
+
id: "fish-audio",
|
|
6
|
+
name: "Fish Audio Speech",
|
|
7
|
+
description: "Hosted Fish Audio S2.1 text-to-speech provider",
|
|
8
|
+
register(api) {
|
|
9
|
+
api.registerSpeechProvider(buildFishAudioSpeechProvider());
|
|
10
|
+
}
|
|
11
|
+
});
|
|
12
|
+
//#endregion
|
|
13
|
+
export { fish_audio_default as default };
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
import { FISH_AUDIO_STREAM_MAX_BYTES, fishAudioTts, fishAudioTtsStream, listFishAudioVoices, normalizeFishAudioBaseUrl } from "./tts.js";
|
|
2
|
+
import { resolveGeneratedMediaMaxBytes } from "openclaw/plugin-sdk/media-generation-runtime";
|
|
3
|
+
import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/secret-input";
|
|
4
|
+
import { asBoolean, asFiniteNumber, asObject, parseSpeechDirectiveNumberOverride, resolveSpeechProviderApiKey, trimToUndefined } from "openclaw/plugin-sdk/speech-core";
|
|
5
|
+
//#region extensions/fish-audio/speech-provider.ts
|
|
6
|
+
const FISH_AUDIO_MODELS = [
|
|
7
|
+
"s2.1-pro-free",
|
|
8
|
+
"s2.1-pro",
|
|
9
|
+
"s2-pro",
|
|
10
|
+
"s1"
|
|
11
|
+
];
|
|
12
|
+
const DEFAULT_MODEL = "s2.1-pro";
|
|
13
|
+
const DEFAULT_LATENCY = "balanced";
|
|
14
|
+
const DEFAULT_TIMEOUT_MS = 24e4;
|
|
15
|
+
function normalizeModel(value) {
|
|
16
|
+
const model = trimToUndefined(value);
|
|
17
|
+
if (!model) return DEFAULT_MODEL;
|
|
18
|
+
if (FISH_AUDIO_MODELS.some((candidate) => candidate === model)) return model;
|
|
19
|
+
throw new Error(`invalid Fish Audio model "${model}"`);
|
|
20
|
+
}
|
|
21
|
+
function normalizeLatency(value) {
|
|
22
|
+
const latency = trimToUndefined(value)?.toLowerCase();
|
|
23
|
+
if (!latency) return DEFAULT_LATENCY;
|
|
24
|
+
if (latency === "low" || latency === "balanced" || latency === "normal") return latency;
|
|
25
|
+
throw new Error(`invalid Fish Audio latency "${latency}"`);
|
|
26
|
+
}
|
|
27
|
+
function normalizeNumber(value, min, max) {
|
|
28
|
+
const number = asFiniteNumber(value);
|
|
29
|
+
return number != null && number >= min && number <= max ? number : void 0;
|
|
30
|
+
}
|
|
31
|
+
function resolveReferenceId(raw) {
|
|
32
|
+
return trimToUndefined(raw?.speakerVoiceId ?? raw?.voiceId ?? raw?.referenceId);
|
|
33
|
+
}
|
|
34
|
+
function normalizeProviderConfig(rawConfig) {
|
|
35
|
+
const raw = asObject(asObject(rawConfig.providers)?.["fish-audio"]) ?? asObject(rawConfig["fish-audio"]);
|
|
36
|
+
return {
|
|
37
|
+
apiKey: normalizeResolvedSecretInputString({
|
|
38
|
+
value: raw?.apiKey,
|
|
39
|
+
path: "tts.providers.fish-audio.apiKey"
|
|
40
|
+
}),
|
|
41
|
+
baseUrl: normalizeFishAudioBaseUrl(trimToUndefined(raw?.baseUrl)),
|
|
42
|
+
model: normalizeModel(raw?.model ?? raw?.modelId),
|
|
43
|
+
referenceId: resolveReferenceId(raw),
|
|
44
|
+
latency: normalizeLatency(raw?.latency),
|
|
45
|
+
speed: normalizeNumber(raw?.speed, .5, 2),
|
|
46
|
+
temperature: normalizeNumber(raw?.temperature, 0, 1),
|
|
47
|
+
topP: normalizeNumber(raw?.topP ?? raw?.top_p, 0, 1),
|
|
48
|
+
normalize: asBoolean(raw?.normalize)
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
function readProviderConfig(config) {
|
|
52
|
+
const defaults = normalizeProviderConfig({});
|
|
53
|
+
const raw = asObject(config) ?? {};
|
|
54
|
+
return {
|
|
55
|
+
apiKey: trimToUndefined(raw.apiKey) ?? defaults.apiKey,
|
|
56
|
+
baseUrl: normalizeFishAudioBaseUrl(trimToUndefined(raw.baseUrl) ?? defaults.baseUrl),
|
|
57
|
+
model: normalizeModel(raw.model ?? raw.modelId ?? defaults.model),
|
|
58
|
+
referenceId: resolveReferenceId(raw) ?? defaults.referenceId,
|
|
59
|
+
latency: normalizeLatency(raw.latency ?? defaults.latency),
|
|
60
|
+
speed: normalizeNumber(raw.speed, .5, 2) ?? defaults.speed,
|
|
61
|
+
temperature: normalizeNumber(raw.temperature, 0, 1) ?? defaults.temperature,
|
|
62
|
+
topP: normalizeNumber(raw.topP ?? raw.top_p, 0, 1) ?? defaults.topP,
|
|
63
|
+
normalize: asBoolean(raw.normalize) ?? defaults.normalize
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
function readOverrides(overrides) {
|
|
67
|
+
const raw = asObject(overrides) ?? {};
|
|
68
|
+
return {
|
|
69
|
+
model: trimToUndefined(raw.model ?? raw.modelId) ? normalizeModel(raw.model ?? raw.modelId) : void 0,
|
|
70
|
+
referenceId: resolveReferenceId(raw),
|
|
71
|
+
latency: trimToUndefined(raw.latency) ? normalizeLatency(raw.latency) : void 0,
|
|
72
|
+
speed: normalizeNumber(raw.speed, .5, 2),
|
|
73
|
+
temperature: normalizeNumber(raw.temperature, 0, 1),
|
|
74
|
+
topP: normalizeNumber(raw.topP ?? raw.top_p, 0, 1),
|
|
75
|
+
normalize: asBoolean(raw.normalize)
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
function resolveApiKey(configValue) {
|
|
79
|
+
return resolveSpeechProviderApiKey(configValue, process.env.FISH_API_KEY, process.env.FISH_AUDIO_API_KEY);
|
|
80
|
+
}
|
|
81
|
+
function parseDirectiveToken(ctx) {
|
|
82
|
+
switch (ctx.key) {
|
|
83
|
+
case "voice":
|
|
84
|
+
case "voiceid":
|
|
85
|
+
case "voice_id":
|
|
86
|
+
case "referenceid":
|
|
87
|
+
case "reference_id":
|
|
88
|
+
case "fish_voice":
|
|
89
|
+
case "fishaudio_voice": return ctx.policy.allowVoice ? {
|
|
90
|
+
handled: true,
|
|
91
|
+
overrides: {
|
|
92
|
+
...ctx.currentOverrides,
|
|
93
|
+
referenceId: ctx.value
|
|
94
|
+
}
|
|
95
|
+
} : { handled: true };
|
|
96
|
+
case "model":
|
|
97
|
+
case "modelid":
|
|
98
|
+
case "model_id":
|
|
99
|
+
case "fish_model":
|
|
100
|
+
case "fishaudio_model":
|
|
101
|
+
if (!ctx.policy.allowModelId) return { handled: true };
|
|
102
|
+
try {
|
|
103
|
+
return {
|
|
104
|
+
handled: true,
|
|
105
|
+
overrides: {
|
|
106
|
+
...ctx.currentOverrides,
|
|
107
|
+
model: normalizeModel(ctx.value)
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
} catch (error) {
|
|
111
|
+
return {
|
|
112
|
+
handled: true,
|
|
113
|
+
warnings: [String(error)]
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
case "speed":
|
|
117
|
+
case "fish_speed": return parseSpeechDirectiveNumberOverride({
|
|
118
|
+
ctx,
|
|
119
|
+
overrideKey: "speed",
|
|
120
|
+
range: {
|
|
121
|
+
min: .5,
|
|
122
|
+
max: 2
|
|
123
|
+
},
|
|
124
|
+
warning: (value) => `invalid Fish Audio speed "${value}"`
|
|
125
|
+
});
|
|
126
|
+
case "temperature":
|
|
127
|
+
case "fish_temperature": return parseSpeechDirectiveNumberOverride({
|
|
128
|
+
ctx,
|
|
129
|
+
overrideKey: "temperature",
|
|
130
|
+
range: {
|
|
131
|
+
min: 0,
|
|
132
|
+
max: 1
|
|
133
|
+
},
|
|
134
|
+
warning: (value) => `invalid Fish Audio temperature "${value}"`
|
|
135
|
+
});
|
|
136
|
+
case "top_p":
|
|
137
|
+
case "topp":
|
|
138
|
+
case "fish_top_p": return parseSpeechDirectiveNumberOverride({
|
|
139
|
+
ctx,
|
|
140
|
+
overrideKey: "topP",
|
|
141
|
+
range: {
|
|
142
|
+
min: 0,
|
|
143
|
+
max: 1
|
|
144
|
+
},
|
|
145
|
+
warning: (value) => `invalid Fish Audio top_p "${value}"`
|
|
146
|
+
});
|
|
147
|
+
case "latency":
|
|
148
|
+
case "fish_latency":
|
|
149
|
+
if (!ctx.policy.allowVoiceSettings) return { handled: true };
|
|
150
|
+
try {
|
|
151
|
+
return {
|
|
152
|
+
handled: true,
|
|
153
|
+
overrides: {
|
|
154
|
+
...ctx.currentOverrides,
|
|
155
|
+
latency: normalizeLatency(ctx.value)
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
} catch (error) {
|
|
159
|
+
return {
|
|
160
|
+
handled: true,
|
|
161
|
+
warnings: [String(error)]
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
case "normalize":
|
|
165
|
+
case "fish_normalize": {
|
|
166
|
+
if (!ctx.policy.allowNormalization) return { handled: true };
|
|
167
|
+
const value = ctx.value.trim().toLowerCase();
|
|
168
|
+
if ([
|
|
169
|
+
"true",
|
|
170
|
+
"1",
|
|
171
|
+
"yes",
|
|
172
|
+
"on"
|
|
173
|
+
].includes(value)) return {
|
|
174
|
+
handled: true,
|
|
175
|
+
overrides: {
|
|
176
|
+
...ctx.currentOverrides,
|
|
177
|
+
normalize: true
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
if ([
|
|
181
|
+
"false",
|
|
182
|
+
"0",
|
|
183
|
+
"no",
|
|
184
|
+
"off"
|
|
185
|
+
].includes(value)) return {
|
|
186
|
+
handled: true,
|
|
187
|
+
overrides: {
|
|
188
|
+
...ctx.currentOverrides,
|
|
189
|
+
normalize: false
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
return {
|
|
193
|
+
handled: true,
|
|
194
|
+
warnings: [`invalid Fish Audio normalize "${ctx.value}"`]
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
default: return { handled: false };
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
function resolveFormat(target) {
|
|
201
|
+
if (target === "voice-note") return {
|
|
202
|
+
format: "opus",
|
|
203
|
+
sampleRate: 48e3,
|
|
204
|
+
fileExtension: ".opus",
|
|
205
|
+
voiceCompatible: true
|
|
206
|
+
};
|
|
207
|
+
if (target === "telephony") return {
|
|
208
|
+
format: "pcm",
|
|
209
|
+
sampleRate: 8e3,
|
|
210
|
+
fileExtension: ".pcm",
|
|
211
|
+
voiceCompatible: false
|
|
212
|
+
};
|
|
213
|
+
return {
|
|
214
|
+
format: "mp3",
|
|
215
|
+
sampleRate: 44100,
|
|
216
|
+
fileExtension: ".mp3",
|
|
217
|
+
voiceCompatible: false
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
function resolveSynthesisRequest(req) {
|
|
221
|
+
const config = readProviderConfig(req.providerConfig);
|
|
222
|
+
const overrides = readOverrides(req.providerOverrides);
|
|
223
|
+
const apiKey = resolveApiKey(config.apiKey);
|
|
224
|
+
if (!apiKey) throw new Error("Fish Audio API key missing");
|
|
225
|
+
const output = resolveFormat(req.target);
|
|
226
|
+
return {
|
|
227
|
+
text: req.text,
|
|
228
|
+
apiKey,
|
|
229
|
+
baseUrl: config.baseUrl,
|
|
230
|
+
model: overrides.model ?? config.model,
|
|
231
|
+
referenceId: overrides.referenceId ?? config.referenceId,
|
|
232
|
+
latency: overrides.latency ?? config.latency,
|
|
233
|
+
speed: overrides.speed ?? config.speed,
|
|
234
|
+
temperature: overrides.temperature ?? config.temperature,
|
|
235
|
+
topP: overrides.topP ?? config.topP,
|
|
236
|
+
normalize: overrides.normalize ?? config.normalize,
|
|
237
|
+
timeoutMs: req.timeoutMs,
|
|
238
|
+
maxBytes: resolveGeneratedMediaMaxBytes(req.cfg, "audio"),
|
|
239
|
+
...output
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
function buildFishAudioSpeechProvider() {
|
|
243
|
+
return {
|
|
244
|
+
id: "fish-audio",
|
|
245
|
+
label: "Fish Audio",
|
|
246
|
+
autoSelectOrder: 28,
|
|
247
|
+
defaultTimeoutMs: DEFAULT_TIMEOUT_MS,
|
|
248
|
+
defaultModel: DEFAULT_MODEL,
|
|
249
|
+
models: FISH_AUDIO_MODELS,
|
|
250
|
+
resolveConfig: ({ rawConfig }) => normalizeProviderConfig(rawConfig),
|
|
251
|
+
parseDirectiveToken,
|
|
252
|
+
resolveTalkConfig: ({ baseTtsConfig, talkProviderConfig }) => {
|
|
253
|
+
return {
|
|
254
|
+
...normalizeProviderConfig(baseTtsConfig),
|
|
255
|
+
...talkProviderConfig.apiKey === void 0 ? {} : { apiKey: normalizeResolvedSecretInputString({
|
|
256
|
+
value: talkProviderConfig.apiKey,
|
|
257
|
+
path: "talk.providers.fish-audio.apiKey"
|
|
258
|
+
}) },
|
|
259
|
+
...trimToUndefined(talkProviderConfig.baseUrl) == null ? {} : { baseUrl: normalizeFishAudioBaseUrl(trimToUndefined(talkProviderConfig.baseUrl)) },
|
|
260
|
+
...trimToUndefined(talkProviderConfig.modelId ?? talkProviderConfig.model) == null ? {} : { model: normalizeModel(talkProviderConfig.modelId ?? talkProviderConfig.model) },
|
|
261
|
+
...resolveReferenceId(talkProviderConfig) == null ? {} : { referenceId: resolveReferenceId(talkProviderConfig) },
|
|
262
|
+
...trimToUndefined(talkProviderConfig.latency) == null ? {} : { latency: normalizeLatency(talkProviderConfig.latency) },
|
|
263
|
+
...normalizeNumber(talkProviderConfig.speed, .5, 2) == null ? {} : { speed: normalizeNumber(talkProviderConfig.speed, .5, 2) }
|
|
264
|
+
};
|
|
265
|
+
},
|
|
266
|
+
resolveTalkOverrides: ({ params }) => ({
|
|
267
|
+
...trimToUndefined(params.modelId ?? params.model) == null ? {} : { model: normalizeModel(params.modelId ?? params.model) },
|
|
268
|
+
...resolveReferenceId(params) == null ? {} : { referenceId: resolveReferenceId(params) },
|
|
269
|
+
...normalizeNumber(params.speed, .5, 2) == null ? {} : { speed: normalizeNumber(params.speed, .5, 2) }
|
|
270
|
+
}),
|
|
271
|
+
listVoices: async (req) => {
|
|
272
|
+
const config = readProviderConfig(req.providerConfig ?? {});
|
|
273
|
+
const apiKey = resolveApiKey(trimToUndefined(req.apiKey) ?? config.apiKey);
|
|
274
|
+
if (!apiKey) throw new Error("Fish Audio API key missing");
|
|
275
|
+
return await listFishAudioVoices({
|
|
276
|
+
apiKey,
|
|
277
|
+
baseUrl: normalizeFishAudioBaseUrl(trimToUndefined(req.baseUrl) ?? config.baseUrl),
|
|
278
|
+
timeoutMs: req.timeoutMs ?? DEFAULT_TIMEOUT_MS
|
|
279
|
+
});
|
|
280
|
+
},
|
|
281
|
+
isConfigured: ({ providerConfig }) => Boolean(resolveApiKey(readProviderConfig(providerConfig).apiKey)),
|
|
282
|
+
synthesize: async (req) => {
|
|
283
|
+
const params = resolveSynthesisRequest(req);
|
|
284
|
+
return {
|
|
285
|
+
audioBuffer: await fishAudioTts(params),
|
|
286
|
+
outputFormat: params.format,
|
|
287
|
+
fileExtension: params.fileExtension,
|
|
288
|
+
voiceCompatible: params.voiceCompatible
|
|
289
|
+
};
|
|
290
|
+
},
|
|
291
|
+
streamSynthesize: async (req) => {
|
|
292
|
+
const params = resolveSynthesisRequest(req);
|
|
293
|
+
const stream = await fishAudioTtsStream({
|
|
294
|
+
...params,
|
|
295
|
+
maxBytes: Math.min(params.maxBytes, FISH_AUDIO_STREAM_MAX_BYTES)
|
|
296
|
+
});
|
|
297
|
+
return {
|
|
298
|
+
audioStream: stream.audioStream,
|
|
299
|
+
outputFormat: params.format,
|
|
300
|
+
fileExtension: params.fileExtension,
|
|
301
|
+
voiceCompatible: params.voiceCompatible,
|
|
302
|
+
release: stream.release
|
|
303
|
+
};
|
|
304
|
+
},
|
|
305
|
+
synthesizeTelephony: async (req) => {
|
|
306
|
+
return {
|
|
307
|
+
audioBuffer: await fishAudioTts(resolveSynthesisRequest({
|
|
308
|
+
...req,
|
|
309
|
+
target: "telephony"
|
|
310
|
+
})),
|
|
311
|
+
outputFormat: "pcm",
|
|
312
|
+
sampleRate: 8e3
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
//#endregion
|
|
318
|
+
export { buildFishAudioSpeechProvider };
|
package/dist/tts.js
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { MAX_AUDIO_BYTES } from "openclaw/plugin-sdk/media-runtime";
|
|
2
|
+
import { assertOkOrThrowProviderError, assertProviderBinaryResponseContent, readProviderBinaryResponse, readProviderJsonResponse } from "openclaw/plugin-sdk/provider-http";
|
|
3
|
+
import { asObject, trimToUndefined } from "openclaw/plugin-sdk/speech";
|
|
4
|
+
import { fetchWithSsrFGuard, ssrfPolicyFromHttpBaseUrlAllowedHostname } from "openclaw/plugin-sdk/ssrf-runtime";
|
|
5
|
+
//#region extensions/fish-audio/tts.ts
|
|
6
|
+
const FISH_AUDIO_BASE_URL = "https://api.fish.audio";
|
|
7
|
+
const FISH_AUDIO_VOICES_MAX_BYTES = 2 * 1024 * 1024;
|
|
8
|
+
const FISH_AUDIO_VOICE_PAGE_SIZE = 100;
|
|
9
|
+
const FISH_AUDIO_MAX_OWN_VOICE_PAGES = 20;
|
|
10
|
+
function normalizeFishAudioBaseUrl(value) {
|
|
11
|
+
const trimmed = value?.trim();
|
|
12
|
+
return trimmed ? trimmed.replace(/\/+$/u, "") : FISH_AUDIO_BASE_URL;
|
|
13
|
+
}
|
|
14
|
+
function buildFishAudioRequestBody(params) {
|
|
15
|
+
return JSON.stringify({
|
|
16
|
+
text: params.text,
|
|
17
|
+
format: params.format,
|
|
18
|
+
...params.referenceId ? { reference_id: params.referenceId } : {},
|
|
19
|
+
...params.sampleRate == null ? {} : { sample_rate: params.sampleRate },
|
|
20
|
+
...params.latency == null ? {} : { latency: params.latency },
|
|
21
|
+
...params.speed == null ? {} : { prosody: { speed: params.speed } },
|
|
22
|
+
...params.temperature == null ? {} : { temperature: params.temperature },
|
|
23
|
+
...params.topP == null ? {} : { top_p: params.topP },
|
|
24
|
+
...params.normalize == null ? {} : { normalize: params.normalize }
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
async function requestFishAudioTts(params) {
|
|
28
|
+
const baseUrl = normalizeFishAudioBaseUrl(params.baseUrl);
|
|
29
|
+
return await fetchWithSsrFGuard({
|
|
30
|
+
url: `${baseUrl}/v1/tts`,
|
|
31
|
+
init: {
|
|
32
|
+
method: "POST",
|
|
33
|
+
headers: {
|
|
34
|
+
Authorization: `Bearer ${params.apiKey}`,
|
|
35
|
+
"Content-Type": "application/json",
|
|
36
|
+
model: params.model
|
|
37
|
+
},
|
|
38
|
+
body: buildFishAudioRequestBody(params)
|
|
39
|
+
},
|
|
40
|
+
timeoutMs: params.timeoutMs,
|
|
41
|
+
policy: ssrfPolicyFromHttpBaseUrlAllowedHostname(baseUrl),
|
|
42
|
+
auditContext: "fish-audio.tts"
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
async function fishAudioTts(params) {
|
|
46
|
+
const { response, release } = await requestFishAudioTts(params);
|
|
47
|
+
try {
|
|
48
|
+
await assertOkOrThrowProviderError(response, "Fish Audio TTS API error");
|
|
49
|
+
return Buffer.from(await readProviderBinaryResponse(response, "Fish Audio TTS API error", "audio", { maxBytes: params.maxBytes }));
|
|
50
|
+
} finally {
|
|
51
|
+
await release();
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function createBoundedFishAudioStream(stream, maxBytes) {
|
|
55
|
+
let reader;
|
|
56
|
+
let totalBytes = 0;
|
|
57
|
+
const releaseReader = (activeReader) => {
|
|
58
|
+
if (reader === activeReader) {
|
|
59
|
+
reader = void 0;
|
|
60
|
+
activeReader.releaseLock();
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
const cancelReader = async (reason) => {
|
|
64
|
+
const activeReader = reader;
|
|
65
|
+
if (!activeReader) return;
|
|
66
|
+
try {
|
|
67
|
+
await activeReader.cancel(reason).catch(() => void 0);
|
|
68
|
+
} finally {
|
|
69
|
+
releaseReader(activeReader);
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
return {
|
|
73
|
+
audioStream: new ReadableStream({
|
|
74
|
+
start() {
|
|
75
|
+
reader = stream.getReader();
|
|
76
|
+
},
|
|
77
|
+
async pull(controller) {
|
|
78
|
+
const activeReader = reader;
|
|
79
|
+
if (!activeReader) {
|
|
80
|
+
controller.close();
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
try {
|
|
84
|
+
const chunk = await activeReader.read();
|
|
85
|
+
if (chunk.done) {
|
|
86
|
+
releaseReader(activeReader);
|
|
87
|
+
controller.close();
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
const remaining = maxBytes - totalBytes;
|
|
91
|
+
if (chunk.value.byteLength > remaining) {
|
|
92
|
+
if (remaining > 0) controller.enqueue(chunk.value.subarray(0, remaining));
|
|
93
|
+
const error = /* @__PURE__ */ new Error(`Fish Audio TTS API error: audio response exceeds ${maxBytes} bytes`);
|
|
94
|
+
await activeReader.cancel(error).catch(() => void 0);
|
|
95
|
+
releaseReader(activeReader);
|
|
96
|
+
controller.error(error);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
totalBytes += chunk.value.byteLength;
|
|
100
|
+
controller.enqueue(chunk.value);
|
|
101
|
+
} catch (error) {
|
|
102
|
+
releaseReader(activeReader);
|
|
103
|
+
controller.error(error);
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
async cancel(reason) {
|
|
107
|
+
await cancelReader(reason);
|
|
108
|
+
}
|
|
109
|
+
}),
|
|
110
|
+
release: () => cancelReader(/* @__PURE__ */ new Error("Fish Audio TTS stream released"))
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
async function fishAudioTtsStream(params) {
|
|
114
|
+
const { response, release } = await requestFishAudioTts(params);
|
|
115
|
+
let handedOff = false;
|
|
116
|
+
try {
|
|
117
|
+
await assertOkOrThrowProviderError(response, "Fish Audio TTS API error");
|
|
118
|
+
assertProviderBinaryResponseContent(response, "Fish Audio TTS API error", "audio");
|
|
119
|
+
if (!response.body) throw new Error("Fish Audio TTS API response missing audio stream");
|
|
120
|
+
const bounded = createBoundedFishAudioStream(response.body, params.maxBytes);
|
|
121
|
+
let releasePromise;
|
|
122
|
+
const releaseAll = () => {
|
|
123
|
+
releasePromise ??= (async () => {
|
|
124
|
+
try {
|
|
125
|
+
await bounded.release();
|
|
126
|
+
} finally {
|
|
127
|
+
await release();
|
|
128
|
+
}
|
|
129
|
+
})();
|
|
130
|
+
return releasePromise;
|
|
131
|
+
};
|
|
132
|
+
handedOff = true;
|
|
133
|
+
return {
|
|
134
|
+
audioStream: bounded.audioStream,
|
|
135
|
+
release: releaseAll
|
|
136
|
+
};
|
|
137
|
+
} finally {
|
|
138
|
+
if (!handedOff) await release();
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
function parseVoiceItem(value) {
|
|
142
|
+
const item = asObject(value);
|
|
143
|
+
const id = trimToUndefined(item?.["_id"]);
|
|
144
|
+
if (!id) return;
|
|
145
|
+
const languages = Array.isArray(item?.languages) ? item.languages.flatMap((entry) => typeof entry === "string" && entry.trim() ? [entry.trim()] : []) : [];
|
|
146
|
+
const tags = Array.isArray(item?.tags) ? item.tags.flatMap((entry) => typeof entry === "string" && entry.trim() ? [entry.trim()] : []) : [];
|
|
147
|
+
return {
|
|
148
|
+
id,
|
|
149
|
+
name: trimToUndefined(item?.title),
|
|
150
|
+
description: trimToUndefined(item?.description),
|
|
151
|
+
category: trimToUndefined(item?.visibility),
|
|
152
|
+
locale: languages[0],
|
|
153
|
+
personalities: tags.length > 0 ? tags : void 0
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
async function requestVoicePage(params) {
|
|
157
|
+
const url = new URL(`${normalizeFishAudioBaseUrl(params.baseUrl)}/model`);
|
|
158
|
+
url.searchParams.set("type", "tts");
|
|
159
|
+
url.searchParams.set("page_size", String(FISH_AUDIO_VOICE_PAGE_SIZE));
|
|
160
|
+
url.searchParams.set("page_number", String(params.pageNumber));
|
|
161
|
+
if (params.self) url.searchParams.set("self", "true");
|
|
162
|
+
else url.searchParams.set("sort_by", "score");
|
|
163
|
+
const { response, release } = await fetchWithSsrFGuard({
|
|
164
|
+
url: url.toString(),
|
|
165
|
+
init: { headers: { Authorization: `Bearer ${params.apiKey}` } },
|
|
166
|
+
timeoutMs: params.timeoutMs,
|
|
167
|
+
policy: ssrfPolicyFromHttpBaseUrlAllowedHostname(params.baseUrl),
|
|
168
|
+
auditContext: "fish-audio.voices"
|
|
169
|
+
});
|
|
170
|
+
try {
|
|
171
|
+
await assertOkOrThrowProviderError(response, "Fish Audio voices API error");
|
|
172
|
+
return await readProviderJsonResponse(response, "Fish Audio voices", { maxBytes: FISH_AUDIO_VOICES_MAX_BYTES });
|
|
173
|
+
} finally {
|
|
174
|
+
await release();
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
async function listFishAudioVoices(params) {
|
|
178
|
+
const own = [];
|
|
179
|
+
for (let pageNumber = 1; pageNumber <= FISH_AUDIO_MAX_OWN_VOICE_PAGES; pageNumber += 1) {
|
|
180
|
+
const payload = await requestVoicePage({
|
|
181
|
+
...params,
|
|
182
|
+
self: true,
|
|
183
|
+
pageNumber
|
|
184
|
+
});
|
|
185
|
+
const items = Array.isArray(payload.items) ? payload.items : [];
|
|
186
|
+
own.push(...items.flatMap((item) => parseVoiceItem(item) ?? []));
|
|
187
|
+
if (items.length < FISH_AUDIO_VOICE_PAGE_SIZE || own.length >= (payload.total ?? Number.MAX_SAFE_INTEGER)) break;
|
|
188
|
+
}
|
|
189
|
+
let publicVoices = [];
|
|
190
|
+
try {
|
|
191
|
+
const payload = await requestVoicePage({
|
|
192
|
+
...params,
|
|
193
|
+
self: false,
|
|
194
|
+
pageNumber: 1
|
|
195
|
+
});
|
|
196
|
+
publicVoices = (Array.isArray(payload.items) ? payload.items : []).flatMap((item) => parseVoiceItem(item) ?? []);
|
|
197
|
+
} catch {}
|
|
198
|
+
const seen = /* @__PURE__ */ new Set();
|
|
199
|
+
return [...own, ...publicVoices].filter((voice) => {
|
|
200
|
+
if (seen.has(voice.id)) return false;
|
|
201
|
+
seen.add(voice.id);
|
|
202
|
+
return true;
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
const FISH_AUDIO_STREAM_MAX_BYTES = MAX_AUDIO_BYTES;
|
|
206
|
+
//#endregion
|
|
207
|
+
export { FISH_AUDIO_STREAM_MAX_BYTES, fishAudioTts, fishAudioTtsStream, listFishAudioVoices, normalizeFishAudioBaseUrl };
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "fish-audio",
|
|
3
|
+
"activation": {
|
|
4
|
+
"onStartup": false
|
|
5
|
+
},
|
|
6
|
+
"name": "Fish Audio",
|
|
7
|
+
"description": "Fish Audio S2.1 hosted text-to-speech with streaming, voice notes, and telephony output.",
|
|
8
|
+
"setup": {
|
|
9
|
+
"providers": [
|
|
10
|
+
{
|
|
11
|
+
"id": "fish-audio",
|
|
12
|
+
"envVars": ["FISH_API_KEY", "FISH_AUDIO_API_KEY"]
|
|
13
|
+
}
|
|
14
|
+
]
|
|
15
|
+
},
|
|
16
|
+
"contracts": {
|
|
17
|
+
"speechProviders": ["fish-audio"]
|
|
18
|
+
},
|
|
19
|
+
"configSchema": {
|
|
20
|
+
"type": "object",
|
|
21
|
+
"additionalProperties": false,
|
|
22
|
+
"properties": {}
|
|
23
|
+
}
|
|
24
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@openclaw/fish-audio-speech",
|
|
3
|
+
"version": "2026.7.2-beta.7",
|
|
4
|
+
"description": "OpenClaw Fish Audio speech plugin.",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "https://github.com/openclaw/openclaw"
|
|
8
|
+
},
|
|
9
|
+
"type": "module",
|
|
10
|
+
"openclaw": {
|
|
11
|
+
"extensions": [
|
|
12
|
+
"./index.ts"
|
|
13
|
+
],
|
|
14
|
+
"install": {
|
|
15
|
+
"clawhubSpec": "clawhub:@openclaw/fish-audio-speech",
|
|
16
|
+
"npmSpec": "@openclaw/fish-audio-speech",
|
|
17
|
+
"defaultChoice": "npm",
|
|
18
|
+
"minHostVersion": ">=2026.7.2"
|
|
19
|
+
},
|
|
20
|
+
"compat": {
|
|
21
|
+
"pluginApi": ">=2026.7.2-beta.7"
|
|
22
|
+
},
|
|
23
|
+
"build": {
|
|
24
|
+
"openclawVersion": "2026.7.2-beta.7",
|
|
25
|
+
"bundledDist": false
|
|
26
|
+
},
|
|
27
|
+
"release": {
|
|
28
|
+
"publishToClawHub": true,
|
|
29
|
+
"publishToNpm": true
|
|
30
|
+
},
|
|
31
|
+
"runtimeExtensions": [
|
|
32
|
+
"./dist/index.js"
|
|
33
|
+
]
|
|
34
|
+
},
|
|
35
|
+
"files": [
|
|
36
|
+
"dist/**",
|
|
37
|
+
"openclaw.plugin.json",
|
|
38
|
+
"README.md"
|
|
39
|
+
],
|
|
40
|
+
"peerDependencies": {
|
|
41
|
+
"openclaw": ">=2026.7.2-beta.7"
|
|
42
|
+
},
|
|
43
|
+
"peerDependenciesMeta": {
|
|
44
|
+
"openclaw": {
|
|
45
|
+
"optional": true
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
"bundledDependencies": []
|
|
49
|
+
}
|