@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 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
+ }