@openclaw/fish-audio-speech 2026.10.10 → 2026.11.5-beta.1
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 +4 -4
- package/dist/.source-module-urls/8191d1ae72d16393121e038011dba25f44b3674d84c175f2d1179af18368a7d3.json +1 -0
- package/dist/speech-provider.js +241 -134
- package/package.json +4 -4
- package/dist/.source-module-urls/ab7e6d7fd26587041a62c2dc2efa176e6d088e43fce498cbc7e366803b2e7803.json +0 -1
- package/dist/tts.js +0 -150
package/README.md
CHANGED
|
@@ -13,10 +13,10 @@ remain `fish-audio`. Configure `tts.provider: "fish-audio"` and set
|
|
|
13
13
|
`FISH_API_KEY`. The provider supports buffered audio, HTTP-streamed playback,
|
|
14
14
|
native Opus voice notes, 8 kHz PCM telephony, and Fish Audio voice discovery.
|
|
15
15
|
|
|
16
|
-
Set `tts.providers.fish-audio.model: "drama-3
|
|
17
|
-
|
|
18
|
-
such as `[calm, reassuring]` and paired
|
|
19
|
-
are passed through in the spoken text.
|
|
16
|
+
Set `tts.providers.fish-audio.model: "drama-3"` to opt in to Drama 3 voice
|
|
17
|
+
direction (`"drama-3-preview"` is still accepted). The default remains
|
|
18
|
+
`s2.1-pro`. Natural-language directions such as `[calm, reassuring]` and paired
|
|
19
|
+
tags such as `<whisper>...</whisper>` are passed through in the spoken text.
|
|
20
20
|
|
|
21
21
|
See [Fish Audio](https://docs.openclaw.ai/providers/fish-audio) for setup,
|
|
22
22
|
models, voice selection, expressive tags, and local macOS MLX usage.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"entries":[{"output":"capability-catalog.js","source":"extensions/fish-audio-speech/capability-catalog.ts"},{"output":"index.js","source":"extensions/fish-audio-speech/index.ts"},{"output":"speech-provider.js","source":"extensions/fish-audio-speech/speech-provider.ts"}],"chunks":[{"output":"capability-catalog.js","sha256":"7c9a47df6e8ec5e2db37ce36f3f23369e937a6deb650b55c529f73b37380621a","facadeSource":"extensions/fish-audio-speech/capability-catalog.ts","sources":["extensions/fish-audio-speech/capability-catalog.ts"]},{"output":"index.js","sha256":"7c1ea79ef77e53df527e73de571411ed79266f9cdbffa6a988e7b40b0b0e5edc","facadeSource":"extensions/fish-audio-speech/index.ts","sources":["extensions/fish-audio-speech/index.ts"]},{"output":"speech-provider.js","sha256":"9af3f302bf900c302ce7e95ec77cf92dbf93afbcc6f5ae1cf2bfdaa2538e1fed","facadeSource":"extensions/fish-audio-speech/speech-provider.ts","sources":["extensions/fish-audio-speech/speech-provider.ts"]}],"unrenderedSources":[]}
|
package/dist/speech-provider.js
CHANGED
|
@@ -1,8 +1,76 @@
|
|
|
1
|
-
import { FISH_AUDIO_MODELS, fishAudioTts, fishAudioTtsStream, listFishAudioVoices, normalizeFishAudioBaseUrl } from "./tts.js";
|
|
2
1
|
import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/secret-input";
|
|
3
|
-
import { MAX_AUDIO_BYTES,
|
|
4
|
-
import { asBoolean, asFiniteNumberInRange, asOptionalRecord, filterStringRecord, normalizeOptionalString, parseBooleanValue } from "openclaw/plugin-sdk/string-coerce-runtime";
|
|
2
|
+
import { MAX_AUDIO_BYTES, createSpeechDirectiveParser, resolveSpeechProviderApiKey } from "openclaw/plugin-sdk/speech-provider";
|
|
3
|
+
import { asBoolean, asFiniteNumberInRange, asOptionalRecord, filterStringRecord, normalizeOptionalString, normalizeTrimmedStringList, parseBooleanValue } from "openclaw/plugin-sdk/string-coerce-runtime";
|
|
5
4
|
// extensions/fish-audio-speech/speech-provider.ts
|
|
5
|
+
const FISH_AUDIO_BASE_URL = "https://api.fish.audio";
|
|
6
|
+
const FISH_AUDIO_VOICES_MAX_BYTES = 2097152;
|
|
7
|
+
const FISH_AUDIO_VOICE_PAGE_SIZE = 100;
|
|
8
|
+
const FISH_AUDIO_MAX_OWN_VOICE_PAGES = 20;
|
|
9
|
+
const FISH_AUDIO_MODELS = [
|
|
10
|
+
"drama-3",
|
|
11
|
+
"drama-3-preview",
|
|
12
|
+
"s2.1-pro-free",
|
|
13
|
+
"s2.1-pro",
|
|
14
|
+
"s2-pro",
|
|
15
|
+
"s1"
|
|
16
|
+
];
|
|
17
|
+
function normalizeFishAudioBaseUrl(value) {
|
|
18
|
+
const trimmed = value?.trim();
|
|
19
|
+
return trimmed ? trimmed.replace(/\/+$/u, "") : FISH_AUDIO_BASE_URL;
|
|
20
|
+
}
|
|
21
|
+
async function requestFishAudioTts(params) {
|
|
22
|
+
const { fetchWithSsrFGuard, ssrfPolicyFromHttpBaseUrlAllowedHostname } = await import("openclaw/plugin-sdk/ssrf-runtime");
|
|
23
|
+
return await fetchWithSsrFGuard({
|
|
24
|
+
url: `${params.baseUrl}/v1/tts`,
|
|
25
|
+
init: {
|
|
26
|
+
method: "POST",
|
|
27
|
+
headers: {
|
|
28
|
+
Authorization: `Bearer ${params.apiKey}`,
|
|
29
|
+
"Content-Type": "application/json",
|
|
30
|
+
model: params.model
|
|
31
|
+
},
|
|
32
|
+
body: JSON.stringify({
|
|
33
|
+
text: params.text,
|
|
34
|
+
format: params.format,
|
|
35
|
+
...params.referenceId ? { reference_id: params.referenceId } : {},
|
|
36
|
+
...params.sampleRate == null ? {} : { sample_rate: params.sampleRate },
|
|
37
|
+
...params.latency == null ? {} : { latency: params.latency },
|
|
38
|
+
...params.speed == null ? {} : { prosody: { speed: params.speed } },
|
|
39
|
+
...params.temperature == null ? {} : { temperature: params.temperature },
|
|
40
|
+
...params.topP == null ? {} : { top_p: params.topP },
|
|
41
|
+
...params.normalize == null ? {} : { normalize: params.normalize }
|
|
42
|
+
})
|
|
43
|
+
},
|
|
44
|
+
timeoutMs: params.timeoutMs,
|
|
45
|
+
policy: ssrfPolicyFromHttpBaseUrlAllowedHostname(params.baseUrl),
|
|
46
|
+
auditContext: "fish-audio.tts"
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
async function fishAudioTts(params) {
|
|
50
|
+
const { assertOkOrThrowProviderError, readProviderBinaryResponse } = await import("openclaw/plugin-sdk/provider-http");
|
|
51
|
+
const { response, release } = await requestFishAudioTts(params);
|
|
52
|
+
try {
|
|
53
|
+
await assertOkOrThrowProviderError(response, "Fish Audio TTS API error", { requestHeaders: { Authorization: `Bearer ${params.apiKey}` } });
|
|
54
|
+
return await readProviderBinaryResponse(response, "Fish Audio TTS API error", "audio", { maxBytes: params.maxBytes });
|
|
55
|
+
} finally {
|
|
56
|
+
await release();
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
function parseVoiceItem(value) {
|
|
60
|
+
const item = asOptionalRecord(value);
|
|
61
|
+
const id = normalizeOptionalString(item?.["_id"]);
|
|
62
|
+
if (!id) return;
|
|
63
|
+
const languages = normalizeTrimmedStringList(item?.languages);
|
|
64
|
+
const tags = normalizeTrimmedStringList(item?.tags);
|
|
65
|
+
return {
|
|
66
|
+
id,
|
|
67
|
+
name: normalizeOptionalString(item?.title),
|
|
68
|
+
description: normalizeOptionalString(item?.description),
|
|
69
|
+
category: normalizeOptionalString(item?.visibility),
|
|
70
|
+
locale: languages[0],
|
|
71
|
+
personalities: tags.length > 0 ? tags : void 0
|
|
72
|
+
};
|
|
73
|
+
}
|
|
6
74
|
const DEFAULT_MODEL = "s2.1-pro";
|
|
7
75
|
const DEFAULT_LATENCY = "balanced";
|
|
8
76
|
const DEFAULT_TIMEOUT_MS = 24e4;
|
|
@@ -24,7 +92,7 @@ function resolveReferenceId(raw) {
|
|
|
24
92
|
}
|
|
25
93
|
function normalizeProviderConfig(rawConfig) {
|
|
26
94
|
const providers = asOptionalRecord(rawConfig.providers);
|
|
27
|
-
const raw = asOptionalRecord(providers?.["fish-audio"])
|
|
95
|
+
const raw = asOptionalRecord(providers?.["fish-audio"]);
|
|
28
96
|
return {
|
|
29
97
|
apiKey: normalizeResolvedSecretInputString({
|
|
30
98
|
value: raw?.apiKey,
|
|
@@ -50,129 +118,92 @@ function normalizeProviderConfig(rawConfig) {
|
|
|
50
118
|
};
|
|
51
119
|
}
|
|
52
120
|
function readProviderConfig(config) {
|
|
53
|
-
return normalizeProviderConfig({ "fish-audio": {
|
|
121
|
+
return normalizeProviderConfig({ providers: { "fish-audio": {
|
|
54
122
|
...config,
|
|
55
123
|
apiKey: normalizeOptionalString(config.apiKey)
|
|
56
|
-
} });
|
|
57
|
-
}
|
|
58
|
-
function readOverrides(overrides) {
|
|
59
|
-
const raw = asOptionalRecord(overrides) ?? {};
|
|
60
|
-
return {
|
|
61
|
-
model: normalizeOptionalString(raw.model ?? raw.modelId) ? normalizeModel(raw.model ?? raw.modelId) : void 0,
|
|
62
|
-
referenceId: resolveReferenceId(raw),
|
|
63
|
-
latency: normalizeOptionalString(raw.latency) ? normalizeLatency(raw.latency) : void 0,
|
|
64
|
-
speed: asFiniteNumberInRange(raw.speed, {
|
|
65
|
-
min: .5,
|
|
66
|
-
max: 2
|
|
67
|
-
}),
|
|
68
|
-
temperature: asFiniteNumberInRange(raw.temperature, {
|
|
69
|
-
min: 0,
|
|
70
|
-
max: 1
|
|
71
|
-
}),
|
|
72
|
-
topP: asFiniteNumberInRange(raw.topP ?? raw.top_p, {
|
|
73
|
-
min: 0,
|
|
74
|
-
max: 1
|
|
75
|
-
}),
|
|
76
|
-
normalize: asBoolean(raw.normalize)
|
|
77
|
-
};
|
|
124
|
+
} } });
|
|
78
125
|
}
|
|
79
126
|
function resolveApiKey(configValue) {
|
|
80
127
|
return resolveSpeechProviderApiKey(configValue, process.env.FISH_API_KEY, process.env.FISH_AUDIO_API_KEY);
|
|
81
128
|
}
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
}
|
|
150
|
-
};
|
|
151
|
-
} catch (error) {
|
|
152
|
-
return {
|
|
153
|
-
handled: true,
|
|
154
|
-
warnings: [String(error)]
|
|
155
|
-
};
|
|
156
|
-
}
|
|
157
|
-
case "normalize":
|
|
158
|
-
case "fish_normalize": {
|
|
159
|
-
if (!ctx.policy.allowNormalization) return { handled: true };
|
|
129
|
+
const parseDirectiveToken = createSpeechDirectiveParser({
|
|
130
|
+
referenceId: ["allowVoice", [
|
|
131
|
+
"voice",
|
|
132
|
+
"voiceid",
|
|
133
|
+
"voice_id",
|
|
134
|
+
"referenceid",
|
|
135
|
+
"reference_id",
|
|
136
|
+
"fish_voice",
|
|
137
|
+
"fishaudio_voice"
|
|
138
|
+
]],
|
|
139
|
+
model: [
|
|
140
|
+
"allowModelId",
|
|
141
|
+
[
|
|
142
|
+
"model",
|
|
143
|
+
"modelid",
|
|
144
|
+
"model_id",
|
|
145
|
+
"fish_model",
|
|
146
|
+
"fishaudio_model"
|
|
147
|
+
],
|
|
148
|
+
{ value: (ctx) => normalizeModel(ctx.value) }
|
|
149
|
+
],
|
|
150
|
+
speed: [
|
|
151
|
+
"allowVoiceSettings",
|
|
152
|
+
["speed", "fish_speed"],
|
|
153
|
+
{ number: {
|
|
154
|
+
range: {
|
|
155
|
+
min: .5,
|
|
156
|
+
max: 2
|
|
157
|
+
},
|
|
158
|
+
warning: (value) => `invalid Fish Audio speed "${value}"`
|
|
159
|
+
} }
|
|
160
|
+
],
|
|
161
|
+
temperature: [
|
|
162
|
+
"allowVoiceSettings",
|
|
163
|
+
["temperature", "fish_temperature"],
|
|
164
|
+
{ number: {
|
|
165
|
+
range: {
|
|
166
|
+
min: 0,
|
|
167
|
+
max: 1
|
|
168
|
+
},
|
|
169
|
+
warning: (value) => `invalid Fish Audio temperature "${value}"`
|
|
170
|
+
} }
|
|
171
|
+
],
|
|
172
|
+
topP: [
|
|
173
|
+
"allowVoiceSettings",
|
|
174
|
+
[
|
|
175
|
+
"top_p",
|
|
176
|
+
"topp",
|
|
177
|
+
"fish_top_p"
|
|
178
|
+
],
|
|
179
|
+
{ number: {
|
|
180
|
+
range: {
|
|
181
|
+
min: 0,
|
|
182
|
+
max: 1
|
|
183
|
+
},
|
|
184
|
+
warning: (value) => `invalid Fish Audio top_p "${value}"`
|
|
185
|
+
} }
|
|
186
|
+
],
|
|
187
|
+
latency: [
|
|
188
|
+
"allowVoiceSettings",
|
|
189
|
+
["latency", "fish_latency"],
|
|
190
|
+
{ value: (ctx) => normalizeLatency(ctx.value) }
|
|
191
|
+
],
|
|
192
|
+
normalize: [
|
|
193
|
+
"allowNormalization",
|
|
194
|
+
["normalize", "fish_normalize"],
|
|
195
|
+
{ parse: (ctx) => {
|
|
160
196
|
const normalize = parseBooleanValue(ctx.value);
|
|
161
|
-
|
|
197
|
+
return normalize !== void 0 ? {
|
|
162
198
|
handled: true,
|
|
163
|
-
overrides: {
|
|
164
|
-
|
|
165
|
-
normalize
|
|
166
|
-
}
|
|
167
|
-
};
|
|
168
|
-
return {
|
|
199
|
+
overrides: { normalize }
|
|
200
|
+
} : {
|
|
169
201
|
handled: true,
|
|
170
202
|
warnings: [`invalid Fish Audio normalize "${ctx.value}"`]
|
|
171
203
|
};
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
}
|
|
204
|
+
} }
|
|
205
|
+
]
|
|
206
|
+
}, { formatErrorMessage: String });
|
|
176
207
|
function resolveFormat(target) {
|
|
177
208
|
if (target === "voice-note") return {
|
|
178
209
|
format: "opus",
|
|
@@ -195,7 +226,25 @@ function resolveFormat(target) {
|
|
|
195
226
|
}
|
|
196
227
|
async function resolveSynthesisRequest(req) {
|
|
197
228
|
const config = readProviderConfig(req.providerConfig);
|
|
198
|
-
const
|
|
229
|
+
const raw = req.providerOverrides ?? {};
|
|
230
|
+
const overrides = {
|
|
231
|
+
model: normalizeOptionalString(raw.model ?? raw.modelId) ? normalizeModel(raw.model ?? raw.modelId) : void 0,
|
|
232
|
+
referenceId: resolveReferenceId(raw),
|
|
233
|
+
latency: normalizeOptionalString(raw.latency) ? normalizeLatency(raw.latency) : void 0,
|
|
234
|
+
speed: asFiniteNumberInRange(raw.speed, {
|
|
235
|
+
min: .5,
|
|
236
|
+
max: 2
|
|
237
|
+
}),
|
|
238
|
+
temperature: asFiniteNumberInRange(raw.temperature, {
|
|
239
|
+
min: 0,
|
|
240
|
+
max: 1
|
|
241
|
+
}),
|
|
242
|
+
topP: asFiniteNumberInRange(raw.topP ?? raw.top_p, {
|
|
243
|
+
min: 0,
|
|
244
|
+
max: 1
|
|
245
|
+
}),
|
|
246
|
+
normalize: asBoolean(raw.normalize)
|
|
247
|
+
};
|
|
199
248
|
const apiKey = resolveApiKey(config.apiKey);
|
|
200
249
|
if (!apiKey) throw new Error("Fish Audio API key missing");
|
|
201
250
|
const output = resolveFormat(req.target);
|
|
@@ -266,10 +315,52 @@ function buildFishAudioSpeechProvider() {
|
|
|
266
315
|
const config = readProviderConfig(req.providerConfig ?? {});
|
|
267
316
|
const apiKey = resolveApiKey(normalizeOptionalString(req.apiKey) ?? config.apiKey);
|
|
268
317
|
if (!apiKey) throw new Error("Fish Audio API key missing");
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
318
|
+
const baseUrl = normalizeFishAudioBaseUrl(normalizeOptionalString(req.baseUrl) ?? config.baseUrl);
|
|
319
|
+
const timeoutMs = req.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
320
|
+
const requestVoicePage = async (self, pageNumber) => {
|
|
321
|
+
const url = new URL(`${baseUrl}/model`);
|
|
322
|
+
url.searchParams.set("type", "tts");
|
|
323
|
+
url.searchParams.set("page_size", String(FISH_AUDIO_VOICE_PAGE_SIZE));
|
|
324
|
+
url.searchParams.set("page_number", String(pageNumber));
|
|
325
|
+
if (self) url.searchParams.set("self", "true");
|
|
326
|
+
else url.searchParams.set("sort_by", "score");
|
|
327
|
+
const { assertOkOrThrowProviderError, readProviderJsonResponse } = await import("openclaw/plugin-sdk/provider-http");
|
|
328
|
+
const { fetchWithSsrFGuard, ssrfPolicyFromHttpBaseUrlAllowedHostname } = await import("openclaw/plugin-sdk/ssrf-runtime");
|
|
329
|
+
const requestHeaders = { Authorization: `Bearer ${apiKey}` };
|
|
330
|
+
const { response, release } = await fetchWithSsrFGuard({
|
|
331
|
+
url: url.toString(),
|
|
332
|
+
init: { headers: requestHeaders },
|
|
333
|
+
timeoutMs,
|
|
334
|
+
policy: ssrfPolicyFromHttpBaseUrlAllowedHostname(baseUrl),
|
|
335
|
+
auditContext: "fish-audio.voices"
|
|
336
|
+
});
|
|
337
|
+
try {
|
|
338
|
+
await assertOkOrThrowProviderError(response, "Fish Audio voices API error", { requestHeaders });
|
|
339
|
+
return await readProviderJsonResponse(response, "Fish Audio voices", {
|
|
340
|
+
maxBytes: FISH_AUDIO_VOICES_MAX_BYTES,
|
|
341
|
+
requestHeaders
|
|
342
|
+
});
|
|
343
|
+
} finally {
|
|
344
|
+
await release();
|
|
345
|
+
}
|
|
346
|
+
};
|
|
347
|
+
const own = [];
|
|
348
|
+
for (let pageNumber = 1; pageNumber <= FISH_AUDIO_MAX_OWN_VOICE_PAGES; pageNumber += 1) {
|
|
349
|
+
const payload = await requestVoicePage(true, pageNumber);
|
|
350
|
+
const items = Array.isArray(payload.items) ? payload.items : [];
|
|
351
|
+
own.push(...items.flatMap((item) => parseVoiceItem(item) ?? []));
|
|
352
|
+
if (items.length < FISH_AUDIO_VOICE_PAGE_SIZE || own.length >= (payload.total ?? Number.MAX_SAFE_INTEGER)) break;
|
|
353
|
+
}
|
|
354
|
+
let publicVoices = [];
|
|
355
|
+
try {
|
|
356
|
+
const payload = await requestVoicePage(false, 1);
|
|
357
|
+
publicVoices = (Array.isArray(payload.items) ? payload.items : []).flatMap((item) => parseVoiceItem(item) ?? []);
|
|
358
|
+
} catch {}
|
|
359
|
+
const seen = /* @__PURE__ */ new Set();
|
|
360
|
+
return [...own, ...publicVoices].filter((voice) => {
|
|
361
|
+
if (seen.has(voice.id)) return false;
|
|
362
|
+
seen.add(voice.id);
|
|
363
|
+
return true;
|
|
273
364
|
});
|
|
274
365
|
},
|
|
275
366
|
isConfigured,
|
|
@@ -285,25 +376,41 @@ function buildFishAudioSpeechProvider() {
|
|
|
285
376
|
},
|
|
286
377
|
streamSynthesize: async (req) => {
|
|
287
378
|
const params = await resolveSynthesisRequest(req);
|
|
288
|
-
const
|
|
379
|
+
const streamParams = {
|
|
289
380
|
...params,
|
|
290
381
|
maxBytes: Math.min(params.maxBytes, MAX_AUDIO_BYTES)
|
|
291
|
-
});
|
|
292
|
-
return {
|
|
293
|
-
audioStream: stream.audioStream,
|
|
294
|
-
outputFormat: params.format,
|
|
295
|
-
fileExtension: params.fileExtension,
|
|
296
|
-
voiceCompatible: params.voiceCompatible,
|
|
297
|
-
release: stream.release
|
|
298
382
|
};
|
|
383
|
+
const { createBoundedProviderBinaryStream } = await import("openclaw/plugin-sdk/provider-binary-stream");
|
|
384
|
+
const { assertOkOrThrowProviderError, assertProviderBinaryResponseContent } = await import("openclaw/plugin-sdk/provider-http");
|
|
385
|
+
const { response, release } = await requestFishAudioTts(streamParams);
|
|
386
|
+
try {
|
|
387
|
+
await assertOkOrThrowProviderError(response, "Fish Audio TTS API error", { requestHeaders: { Authorization: `Bearer ${streamParams.apiKey}` } });
|
|
388
|
+
assertProviderBinaryResponseContent(response, "Fish Audio TTS API error", "audio");
|
|
389
|
+
if (!response.body) throw new Error("Fish Audio TTS API response missing audio stream");
|
|
390
|
+
const bounded = createBoundedProviderBinaryStream(response.body, {
|
|
391
|
+
maxBytes: streamParams.maxBytes,
|
|
392
|
+
createOverflowError: ({ maxBytes }) => /* @__PURE__ */ new Error(`Fish Audio TTS API error: audio response exceeds ${maxBytes} bytes`),
|
|
393
|
+
createReleaseError: () => /* @__PURE__ */ new Error("Fish Audio TTS stream released"),
|
|
394
|
+
cleanup: release
|
|
395
|
+
});
|
|
396
|
+
return {
|
|
397
|
+
audioStream: bounded.stream,
|
|
398
|
+
outputFormat: params.format,
|
|
399
|
+
fileExtension: params.fileExtension,
|
|
400
|
+
voiceCompatible: params.voiceCompatible,
|
|
401
|
+
release: bounded.release
|
|
402
|
+
};
|
|
403
|
+
} catch (error) {
|
|
404
|
+
await release();
|
|
405
|
+
throw error;
|
|
406
|
+
}
|
|
299
407
|
},
|
|
300
408
|
synthesizeTelephony: async (req) => {
|
|
301
|
-
const params = await resolveSynthesisRequest({
|
|
302
|
-
...req,
|
|
303
|
-
target: "telephony"
|
|
304
|
-
});
|
|
305
409
|
return {
|
|
306
|
-
audioBuffer: await fishAudioTts(
|
|
410
|
+
audioBuffer: await fishAudioTts(await resolveSynthesisRequest({
|
|
411
|
+
...req,
|
|
412
|
+
target: "telephony"
|
|
413
|
+
})),
|
|
307
414
|
outputFormat: "pcm",
|
|
308
415
|
sampleRate: 8e3
|
|
309
416
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openclaw/fish-audio-speech",
|
|
3
|
-
"version": "2026.
|
|
3
|
+
"version": "2026.11.5-beta.1",
|
|
4
4
|
"description": "OpenClaw Fish Audio speech plugin.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -18,10 +18,10 @@
|
|
|
18
18
|
"minHostVersion": ">=2026.7.2"
|
|
19
19
|
},
|
|
20
20
|
"compat": {
|
|
21
|
-
"pluginApi": ">=2026.
|
|
21
|
+
"pluginApi": ">=2026.11.5-beta.1"
|
|
22
22
|
},
|
|
23
23
|
"build": {
|
|
24
|
-
"openclawVersion": "2026.
|
|
24
|
+
"openclawVersion": "2026.11.5-beta.1",
|
|
25
25
|
"bundledDist": false
|
|
26
26
|
},
|
|
27
27
|
"release": {
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
"assets/activity.svg"
|
|
41
41
|
],
|
|
42
42
|
"peerDependencies": {
|
|
43
|
-
"openclaw": ">=2026.
|
|
43
|
+
"openclaw": ">=2026.11.5-beta.1"
|
|
44
44
|
},
|
|
45
45
|
"peerDependenciesMeta": {
|
|
46
46
|
"openclaw": {
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"entries":[{"output":"capability-catalog.js","source":"extensions/fish-audio-speech/capability-catalog.ts"},{"output":"index.js","source":"extensions/fish-audio-speech/index.ts"},{"output":"speech-provider.js","source":"extensions/fish-audio-speech/speech-provider.ts"},{"output":"tts.js","source":"extensions/fish-audio-speech/tts.ts"}],"chunks":[{"output":"capability-catalog.js","sha256":"7c9a47df6e8ec5e2db37ce36f3f23369e937a6deb650b55c529f73b37380621a","facadeSource":"extensions/fish-audio-speech/capability-catalog.ts","sources":["extensions/fish-audio-speech/capability-catalog.ts"]},{"output":"index.js","sha256":"7c1ea79ef77e53df527e73de571411ed79266f9cdbffa6a988e7b40b0b0e5edc","facadeSource":"extensions/fish-audio-speech/index.ts","sources":["extensions/fish-audio-speech/index.ts"]},{"output":"speech-provider.js","sha256":"2f404b3086043af13ac71224cdfaf0d9918fe429b17d1831934e3b2ba11c0de9","facadeSource":"extensions/fish-audio-speech/speech-provider.ts","sources":["extensions/fish-audio-speech/speech-provider.ts"]},{"output":"tts.js","sha256":"fd415647f88514968ba0e1b026410326ca20c6da40d807e1dbe76d5d39ce322f","facadeSource":"extensions/fish-audio-speech/tts.ts","sources":["extensions/fish-audio-speech/tts.ts"]}],"unrenderedSources":[]}
|
package/dist/tts.js
DELETED
|
@@ -1,150 +0,0 @@
|
|
|
1
|
-
import { asOptionalRecord, normalizeOptionalString, normalizeTrimmedStringList } from "openclaw/plugin-sdk/string-coerce-runtime";
|
|
2
|
-
// extensions/fish-audio-speech/tts.ts
|
|
3
|
-
const FISH_AUDIO_BASE_URL = "https://api.fish.audio";
|
|
4
|
-
const FISH_AUDIO_VOICES_MAX_BYTES = 2097152;
|
|
5
|
-
const FISH_AUDIO_VOICE_PAGE_SIZE = 100;
|
|
6
|
-
const FISH_AUDIO_MAX_OWN_VOICE_PAGES = 20;
|
|
7
|
-
const FISH_AUDIO_MODELS = [
|
|
8
|
-
"drama-3-preview",
|
|
9
|
-
"s2.1-pro-free",
|
|
10
|
-
"s2.1-pro",
|
|
11
|
-
"s2-pro",
|
|
12
|
-
"s1"
|
|
13
|
-
];
|
|
14
|
-
function normalizeFishAudioBaseUrl(value) {
|
|
15
|
-
const trimmed = value?.trim();
|
|
16
|
-
return trimmed ? trimmed.replace(/\/+$/u, "") : FISH_AUDIO_BASE_URL;
|
|
17
|
-
}
|
|
18
|
-
function buildFishAudioRequestBody(params) {
|
|
19
|
-
return JSON.stringify({
|
|
20
|
-
text: params.text,
|
|
21
|
-
format: params.format,
|
|
22
|
-
...params.referenceId ? { reference_id: params.referenceId } : {},
|
|
23
|
-
...params.sampleRate == null ? {} : { sample_rate: params.sampleRate },
|
|
24
|
-
...params.latency == null ? {} : { latency: params.latency },
|
|
25
|
-
...params.speed == null ? {} : { prosody: { speed: params.speed } },
|
|
26
|
-
...params.temperature == null ? {} : { temperature: params.temperature },
|
|
27
|
-
...params.topP == null ? {} : { top_p: params.topP },
|
|
28
|
-
...params.normalize == null ? {} : { normalize: params.normalize }
|
|
29
|
-
});
|
|
30
|
-
}
|
|
31
|
-
async function requestFishAudioTts(params) {
|
|
32
|
-
const baseUrl = normalizeFishAudioBaseUrl(params.baseUrl);
|
|
33
|
-
const { fetchWithSsrFGuard, ssrfPolicyFromHttpBaseUrlAllowedHostname } = await import("openclaw/plugin-sdk/ssrf-runtime");
|
|
34
|
-
return await fetchWithSsrFGuard({
|
|
35
|
-
url: `${baseUrl}/v1/tts`,
|
|
36
|
-
init: {
|
|
37
|
-
method: "POST",
|
|
38
|
-
headers: {
|
|
39
|
-
Authorization: `Bearer ${params.apiKey}`,
|
|
40
|
-
"Content-Type": "application/json",
|
|
41
|
-
model: params.model
|
|
42
|
-
},
|
|
43
|
-
body: buildFishAudioRequestBody(params)
|
|
44
|
-
},
|
|
45
|
-
timeoutMs: params.timeoutMs,
|
|
46
|
-
policy: ssrfPolicyFromHttpBaseUrlAllowedHostname(baseUrl),
|
|
47
|
-
auditContext: "fish-audio.tts"
|
|
48
|
-
});
|
|
49
|
-
}
|
|
50
|
-
async function fishAudioTts(params) {
|
|
51
|
-
const { assertOkOrThrowProviderError, readProviderBinaryResponse } = await import("openclaw/plugin-sdk/provider-http");
|
|
52
|
-
const { response, release } = await requestFishAudioTts(params);
|
|
53
|
-
try {
|
|
54
|
-
await assertOkOrThrowProviderError(response, "Fish Audio TTS API error");
|
|
55
|
-
return await readProviderBinaryResponse(response, "Fish Audio TTS API error", "audio", { maxBytes: params.maxBytes });
|
|
56
|
-
} finally {
|
|
57
|
-
await release();
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
async function fishAudioTtsStream(params) {
|
|
61
|
-
const { createBoundedProviderBinaryStream } = await import("openclaw/plugin-sdk/provider-binary-stream");
|
|
62
|
-
const { assertOkOrThrowProviderError, assertProviderBinaryResponseContent } = await import("openclaw/plugin-sdk/provider-http");
|
|
63
|
-
const { response, release } = await requestFishAudioTts(params);
|
|
64
|
-
let handedOff = false;
|
|
65
|
-
try {
|
|
66
|
-
await assertOkOrThrowProviderError(response, "Fish Audio TTS API error");
|
|
67
|
-
assertProviderBinaryResponseContent(response, "Fish Audio TTS API error", "audio");
|
|
68
|
-
if (!response.body) throw new Error("Fish Audio TTS API response missing audio stream");
|
|
69
|
-
const bounded = createBoundedProviderBinaryStream(response.body, {
|
|
70
|
-
maxBytes: params.maxBytes,
|
|
71
|
-
createOverflowError: ({ maxBytes }) => /* @__PURE__ */ new Error(`Fish Audio TTS API error: audio response exceeds ${maxBytes} bytes`),
|
|
72
|
-
createReleaseError: () => /* @__PURE__ */ new Error("Fish Audio TTS stream released"),
|
|
73
|
-
cleanup: release
|
|
74
|
-
});
|
|
75
|
-
handedOff = true;
|
|
76
|
-
return {
|
|
77
|
-
audioStream: bounded.stream,
|
|
78
|
-
release: bounded.release
|
|
79
|
-
};
|
|
80
|
-
} finally {
|
|
81
|
-
if (!handedOff) await release();
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
function parseVoiceItem(value) {
|
|
85
|
-
const item = asOptionalRecord(value);
|
|
86
|
-
const id = normalizeOptionalString(item?.["_id"]);
|
|
87
|
-
if (!id) return;
|
|
88
|
-
const languages = normalizeTrimmedStringList(item?.languages);
|
|
89
|
-
const tags = normalizeTrimmedStringList(item?.tags);
|
|
90
|
-
return {
|
|
91
|
-
id,
|
|
92
|
-
name: normalizeOptionalString(item?.title),
|
|
93
|
-
description: normalizeOptionalString(item?.description),
|
|
94
|
-
category: normalizeOptionalString(item?.visibility),
|
|
95
|
-
locale: languages[0],
|
|
96
|
-
personalities: tags.length > 0 ? tags : void 0
|
|
97
|
-
};
|
|
98
|
-
}
|
|
99
|
-
async function requestVoicePage(params) {
|
|
100
|
-
const url = new URL(`${normalizeFishAudioBaseUrl(params.baseUrl)}/model`);
|
|
101
|
-
url.searchParams.set("type", "tts");
|
|
102
|
-
url.searchParams.set("page_size", String(FISH_AUDIO_VOICE_PAGE_SIZE));
|
|
103
|
-
url.searchParams.set("page_number", String(params.pageNumber));
|
|
104
|
-
if (params.self) url.searchParams.set("self", "true");
|
|
105
|
-
else url.searchParams.set("sort_by", "score");
|
|
106
|
-
const { assertOkOrThrowProviderError, readProviderJsonResponse } = await import("openclaw/plugin-sdk/provider-http");
|
|
107
|
-
const { fetchWithSsrFGuard, ssrfPolicyFromHttpBaseUrlAllowedHostname } = await import("openclaw/plugin-sdk/ssrf-runtime");
|
|
108
|
-
const { response, release } = await fetchWithSsrFGuard({
|
|
109
|
-
url: url.toString(),
|
|
110
|
-
init: { headers: { Authorization: `Bearer ${params.apiKey}` } },
|
|
111
|
-
timeoutMs: params.timeoutMs,
|
|
112
|
-
policy: ssrfPolicyFromHttpBaseUrlAllowedHostname(params.baseUrl),
|
|
113
|
-
auditContext: "fish-audio.voices"
|
|
114
|
-
});
|
|
115
|
-
try {
|
|
116
|
-
await assertOkOrThrowProviderError(response, "Fish Audio voices API error");
|
|
117
|
-
return await readProviderJsonResponse(response, "Fish Audio voices", { maxBytes: FISH_AUDIO_VOICES_MAX_BYTES });
|
|
118
|
-
} finally {
|
|
119
|
-
await release();
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
async function listFishAudioVoices(params) {
|
|
123
|
-
const own = [];
|
|
124
|
-
for (let pageNumber = 1; pageNumber <= FISH_AUDIO_MAX_OWN_VOICE_PAGES; pageNumber += 1) {
|
|
125
|
-
const payload = await requestVoicePage({
|
|
126
|
-
...params,
|
|
127
|
-
self: true,
|
|
128
|
-
pageNumber
|
|
129
|
-
});
|
|
130
|
-
const items = Array.isArray(payload.items) ? payload.items : [];
|
|
131
|
-
own.push(...items.flatMap((item) => parseVoiceItem(item) ?? []));
|
|
132
|
-
if (items.length < FISH_AUDIO_VOICE_PAGE_SIZE || own.length >= (payload.total ?? Number.MAX_SAFE_INTEGER)) break;
|
|
133
|
-
}
|
|
134
|
-
let publicVoices = [];
|
|
135
|
-
try {
|
|
136
|
-
const payload = await requestVoicePage({
|
|
137
|
-
...params,
|
|
138
|
-
self: false,
|
|
139
|
-
pageNumber: 1
|
|
140
|
-
});
|
|
141
|
-
publicVoices = (Array.isArray(payload.items) ? payload.items : []).flatMap((item) => parseVoiceItem(item) ?? []);
|
|
142
|
-
} catch {}
|
|
143
|
-
const seen = /* @__PURE__ */ new Set();
|
|
144
|
-
return [...own, ...publicVoices].filter((voice) => {
|
|
145
|
-
if (seen.has(voice.id)) return false;
|
|
146
|
-
seen.add(voice.id);
|
|
147
|
-
return true;
|
|
148
|
-
});
|
|
149
|
-
}
|
|
150
|
-
export { FISH_AUDIO_MODELS, fishAudioTts, fishAudioTtsStream, listFishAudioVoices, normalizeFishAudioBaseUrl };
|