@happyvertical/speech 0.78.0
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/AGENT.md +51 -0
- package/LICENSE +7 -0
- package/README.md +85 -0
- package/dist/adapters/openai-compatible.d.ts +11 -0
- package/dist/adapters/openai-compatible.d.ts.map +1 -0
- package/dist/adapters/qwen3.d.ts +11 -0
- package/dist/adapters/qwen3.d.ts.map +1 -0
- package/dist/adapters/studio-server.d.ts +16 -0
- package/dist/adapters/studio-server.d.ts.map +1 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +694 -0
- package/dist/index.js.map +1 -0
- package/dist/shared/errors.d.ts +21 -0
- package/dist/shared/errors.d.ts.map +1 -0
- package/dist/shared/factory.d.ts +22 -0
- package/dist/shared/factory.d.ts.map +1 -0
- package/dist/shared/http.d.ts +31 -0
- package/dist/shared/http.d.ts.map +1 -0
- package/dist/shared/types.d.ts +143 -0
- package/dist/shared/types.d.ts.map +1 -0
- package/metadata.json +31 -0
- package/package.json +58 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,694 @@
|
|
|
1
|
+
class SpeechError extends Error {
|
|
2
|
+
constructor(message, code, adapter) {
|
|
3
|
+
super(message);
|
|
4
|
+
this.code = code;
|
|
5
|
+
this.adapter = adapter;
|
|
6
|
+
this.name = "SpeechError";
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
class SpeechConfigurationError extends SpeechError {
|
|
10
|
+
constructor(message, adapter) {
|
|
11
|
+
super(message, "SPEECH_CONFIGURATION_ERROR", adapter);
|
|
12
|
+
this.name = "SpeechConfigurationError";
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
class InvalidSpeechAdapterError extends SpeechError {
|
|
16
|
+
constructor(type, kind) {
|
|
17
|
+
super(`Invalid ${kind} speech adapter type: ${type}`, "INVALID_ADAPTER");
|
|
18
|
+
this.name = "InvalidSpeechAdapterError";
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
class SpeechProviderError extends SpeechError {
|
|
22
|
+
status;
|
|
23
|
+
responseBody;
|
|
24
|
+
constructor(adapter, message, options = {}) {
|
|
25
|
+
super(message, "SPEECH_PROVIDER_ERROR", adapter);
|
|
26
|
+
this.name = "SpeechProviderError";
|
|
27
|
+
this.status = options.status;
|
|
28
|
+
this.responseBody = options.responseBody;
|
|
29
|
+
if (options.cause !== void 0) {
|
|
30
|
+
this.cause = options.cause;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
function normalizeBaseUrl(baseUrl) {
|
|
35
|
+
if (!baseUrl.trim()) {
|
|
36
|
+
throw new SpeechConfigurationError("Speech adapter baseUrl is required");
|
|
37
|
+
}
|
|
38
|
+
return baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
|
|
39
|
+
}
|
|
40
|
+
function resolveSpeechUrl(baseUrl, path) {
|
|
41
|
+
return new URL(path.replace(/^\//, ""), normalizeBaseUrl(baseUrl)).toString();
|
|
42
|
+
}
|
|
43
|
+
function resolveFetch(fetchOverride) {
|
|
44
|
+
if (fetchOverride) {
|
|
45
|
+
return fetchOverride;
|
|
46
|
+
}
|
|
47
|
+
const globalFetch = globalThis.fetch;
|
|
48
|
+
if (!globalFetch) {
|
|
49
|
+
throw new SpeechConfigurationError(
|
|
50
|
+
"No fetch implementation available for speech adapter"
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
return globalFetch.bind(globalThis);
|
|
54
|
+
}
|
|
55
|
+
function mergeHeaders(options, extra) {
|
|
56
|
+
const headers = new Headers(options.headers);
|
|
57
|
+
if (options.apiKey && !headers.has("authorization")) {
|
|
58
|
+
headers.set("authorization", `Bearer ${options.apiKey}`);
|
|
59
|
+
}
|
|
60
|
+
if (extra) {
|
|
61
|
+
new Headers(extra).forEach((value, key) => {
|
|
62
|
+
headers.set(key, value);
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
return headers;
|
|
66
|
+
}
|
|
67
|
+
class HttpSpeechAdapter {
|
|
68
|
+
baseUrl;
|
|
69
|
+
fetchImpl;
|
|
70
|
+
apiKey;
|
|
71
|
+
headers;
|
|
72
|
+
timeoutMs;
|
|
73
|
+
constructor(options) {
|
|
74
|
+
this.baseUrl = options.baseUrl;
|
|
75
|
+
this.fetchImpl = resolveFetch(options.fetch);
|
|
76
|
+
this.apiKey = options.apiKey;
|
|
77
|
+
this.headers = options.headers;
|
|
78
|
+
this.timeoutMs = options.timeoutMs;
|
|
79
|
+
}
|
|
80
|
+
async post(adapterName, path, init, readResponse) {
|
|
81
|
+
const timeoutController = this.timeoutMs && this.timeoutMs > 0 ? new AbortController() : void 0;
|
|
82
|
+
const timeout = timeoutController && this.timeoutMs ? setTimeout(() => timeoutController.abort(), this.timeoutMs) : void 0;
|
|
83
|
+
const requestSignal = composeAbortSignals(
|
|
84
|
+
init.signal,
|
|
85
|
+
timeoutController?.signal
|
|
86
|
+
);
|
|
87
|
+
try {
|
|
88
|
+
const response = await this.fetchImpl(
|
|
89
|
+
resolveSpeechUrl(this.baseUrl, path),
|
|
90
|
+
{
|
|
91
|
+
...init,
|
|
92
|
+
method: "POST",
|
|
93
|
+
headers: mergeHeaders(
|
|
94
|
+
{ apiKey: this.apiKey, headers: this.headers },
|
|
95
|
+
init.headers
|
|
96
|
+
),
|
|
97
|
+
signal: requestSignal.signal
|
|
98
|
+
}
|
|
99
|
+
);
|
|
100
|
+
await assertOk(response, adapterName);
|
|
101
|
+
return await readResponse(response);
|
|
102
|
+
} finally {
|
|
103
|
+
if (timeout) {
|
|
104
|
+
clearTimeout(timeout);
|
|
105
|
+
}
|
|
106
|
+
requestSignal.cleanup();
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
function composeAbortSignals(...signals) {
|
|
111
|
+
const activeSignals = signals.filter(
|
|
112
|
+
(signal) => Boolean(signal)
|
|
113
|
+
);
|
|
114
|
+
if (activeSignals.length === 0) {
|
|
115
|
+
return { cleanup: () => void 0 };
|
|
116
|
+
}
|
|
117
|
+
if (activeSignals.length === 1) {
|
|
118
|
+
return { signal: activeSignals[0], cleanup: () => void 0 };
|
|
119
|
+
}
|
|
120
|
+
const controller = new AbortController();
|
|
121
|
+
let cleanup = () => void 0;
|
|
122
|
+
const abortFrom = (signal) => {
|
|
123
|
+
if (!controller.signal.aborted) {
|
|
124
|
+
controller.abort(signal.reason);
|
|
125
|
+
}
|
|
126
|
+
cleanup();
|
|
127
|
+
};
|
|
128
|
+
const onAbort = (event) => {
|
|
129
|
+
abortFrom(event.target);
|
|
130
|
+
};
|
|
131
|
+
cleanup = () => {
|
|
132
|
+
for (const signal of activeSignals) {
|
|
133
|
+
signal.removeEventListener("abort", onAbort);
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
for (const signal of activeSignals) {
|
|
137
|
+
if (signal.aborted) {
|
|
138
|
+
abortFrom(signal);
|
|
139
|
+
return { signal: controller.signal, cleanup: () => void 0 };
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
for (const signal of activeSignals) {
|
|
143
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
144
|
+
}
|
|
145
|
+
return { signal: controller.signal, cleanup };
|
|
146
|
+
}
|
|
147
|
+
async function assertOk(response, adapterName) {
|
|
148
|
+
if (response.ok) {
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
const responseBody = await response.text().catch(() => void 0);
|
|
152
|
+
throw new SpeechProviderError(
|
|
153
|
+
adapterName,
|
|
154
|
+
`${adapterName} speech request failed with HTTP ${response.status}`,
|
|
155
|
+
{
|
|
156
|
+
status: response.status,
|
|
157
|
+
responseBody
|
|
158
|
+
}
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
function appendAudioInput(form, audio, fieldName = "audio") {
|
|
162
|
+
const filename = audio.filename ?? "audio";
|
|
163
|
+
const data = audio.data;
|
|
164
|
+
if (typeof Blob !== "undefined" && data instanceof Blob) {
|
|
165
|
+
const contentType2 = audio.contentType ?? (data.type || "application/octet-stream");
|
|
166
|
+
const blob = data.type === contentType2 ? data : new Blob([data], { type: contentType2 });
|
|
167
|
+
form.append(fieldName, blob, filename);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
const contentType = audio.contentType ?? "application/octet-stream";
|
|
171
|
+
const blobPart = data instanceof Uint8Array ? arrayBufferFromBytes(data) : data;
|
|
172
|
+
form.append(fieldName, new Blob([blobPart], { type: contentType }), filename);
|
|
173
|
+
}
|
|
174
|
+
function appendOptionalFormValue(form, name, value) {
|
|
175
|
+
if (value === void 0 || value === null) {
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
form.append(name, String(value));
|
|
179
|
+
}
|
|
180
|
+
function createHappyVerticalSynthesisForm(request, defaultVoice) {
|
|
181
|
+
const form = new FormData();
|
|
182
|
+
const voice = request.voice && typeof request.voice !== "string" ? request.voice : void 0;
|
|
183
|
+
form.set("text", request.text);
|
|
184
|
+
appendOptionalFormValue(
|
|
185
|
+
form,
|
|
186
|
+
"language",
|
|
187
|
+
request.language ?? voice?.language
|
|
188
|
+
);
|
|
189
|
+
appendOptionalFormValue(
|
|
190
|
+
form,
|
|
191
|
+
"speaker",
|
|
192
|
+
voiceToString(request.voice, defaultVoice)
|
|
193
|
+
);
|
|
194
|
+
appendOptionalFormValue(form, "speed", request.speed);
|
|
195
|
+
appendOptionalFormValue(form, "voice_prompt", voice?.prompt);
|
|
196
|
+
return form;
|
|
197
|
+
}
|
|
198
|
+
function voiceToString(voice, fallback) {
|
|
199
|
+
if (!voice) {
|
|
200
|
+
return fallback;
|
|
201
|
+
}
|
|
202
|
+
if (typeof voice === "string") {
|
|
203
|
+
return voice;
|
|
204
|
+
}
|
|
205
|
+
return voice.id ?? voice.name ?? voice.speakerId ?? fallback;
|
|
206
|
+
}
|
|
207
|
+
function compactJson(value) {
|
|
208
|
+
return Object.fromEntries(
|
|
209
|
+
Object.entries(value).filter(([, entryValue]) => entryValue !== void 0)
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
function contentTypeToFormat(contentType) {
|
|
213
|
+
const match = /^audio\/([^;]+)/.exec(contentType);
|
|
214
|
+
return match?.[1];
|
|
215
|
+
}
|
|
216
|
+
function arrayBufferFromBytes(bytes) {
|
|
217
|
+
const copy = new Uint8Array(bytes.byteLength);
|
|
218
|
+
copy.set(bytes);
|
|
219
|
+
return copy.buffer;
|
|
220
|
+
}
|
|
221
|
+
function decodeBase64(base64) {
|
|
222
|
+
if (typeof Buffer !== "undefined") {
|
|
223
|
+
return arrayBufferFromBytes(Buffer.from(base64, "base64"));
|
|
224
|
+
}
|
|
225
|
+
const binary = atob(base64);
|
|
226
|
+
const bytes = new Uint8Array(binary.length);
|
|
227
|
+
for (let index = 0; index < binary.length; index += 1) {
|
|
228
|
+
bytes[index] = binary.charCodeAt(index);
|
|
229
|
+
}
|
|
230
|
+
return bytes.buffer;
|
|
231
|
+
}
|
|
232
|
+
function getNumber(value, ...keys) {
|
|
233
|
+
for (const key of keys) {
|
|
234
|
+
const candidate = value[key];
|
|
235
|
+
if (typeof candidate === "number") {
|
|
236
|
+
return candidate;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return void 0;
|
|
240
|
+
}
|
|
241
|
+
function getString(value, ...keys) {
|
|
242
|
+
for (const key of keys) {
|
|
243
|
+
const candidate = value[key];
|
|
244
|
+
if (typeof candidate === "string") {
|
|
245
|
+
return candidate;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return void 0;
|
|
249
|
+
}
|
|
250
|
+
function normalizeWordTimings(value) {
|
|
251
|
+
if (!Array.isArray(value)) {
|
|
252
|
+
return void 0;
|
|
253
|
+
}
|
|
254
|
+
return value.flatMap((entry) => {
|
|
255
|
+
if (!entry || typeof entry !== "object") {
|
|
256
|
+
return [];
|
|
257
|
+
}
|
|
258
|
+
const record = entry;
|
|
259
|
+
const word = getString(record, "word", "text", "token");
|
|
260
|
+
const startSeconds = getNumber(
|
|
261
|
+
record,
|
|
262
|
+
"startSeconds",
|
|
263
|
+
"start",
|
|
264
|
+
"start_time"
|
|
265
|
+
);
|
|
266
|
+
const endSeconds = getNumber(record, "endSeconds", "end", "end_time");
|
|
267
|
+
if (!word || startSeconds === void 0 || endSeconds === void 0) {
|
|
268
|
+
return [];
|
|
269
|
+
}
|
|
270
|
+
return [
|
|
271
|
+
{
|
|
272
|
+
word,
|
|
273
|
+
startSeconds,
|
|
274
|
+
endSeconds,
|
|
275
|
+
confidence: getNumber(record, "confidence"),
|
|
276
|
+
speakerId: getString(record, "speakerId", "speaker", "speaker_id")
|
|
277
|
+
}
|
|
278
|
+
];
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
function normalizeTranscriptSegments(value) {
|
|
282
|
+
if (!Array.isArray(value)) {
|
|
283
|
+
return void 0;
|
|
284
|
+
}
|
|
285
|
+
return value.flatMap((entry) => {
|
|
286
|
+
if (!entry || typeof entry !== "object") {
|
|
287
|
+
return [];
|
|
288
|
+
}
|
|
289
|
+
const record = entry;
|
|
290
|
+
const text = getString(record, "text", "transcript");
|
|
291
|
+
if (!text) {
|
|
292
|
+
return [];
|
|
293
|
+
}
|
|
294
|
+
return [
|
|
295
|
+
{
|
|
296
|
+
text,
|
|
297
|
+
startSeconds: getNumber(record, "startSeconds", "start", "start_time"),
|
|
298
|
+
endSeconds: getNumber(record, "endSeconds", "end", "end_time"),
|
|
299
|
+
confidence: getNumber(record, "confidence"),
|
|
300
|
+
speakerId: getString(record, "speakerId", "speaker", "speaker_id")
|
|
301
|
+
}
|
|
302
|
+
];
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
async function readTranscriptResponse(response, adapterName) {
|
|
306
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
307
|
+
if (!contentType.includes("application/json")) {
|
|
308
|
+
const text2 = await response.text();
|
|
309
|
+
return {
|
|
310
|
+
text: text2,
|
|
311
|
+
provider: adapterName
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
const json = await response.json();
|
|
315
|
+
const text = getString(json, "text", "transcript", "transcription") ?? "";
|
|
316
|
+
return {
|
|
317
|
+
text,
|
|
318
|
+
language: getString(json, "language", "lang"),
|
|
319
|
+
durationSeconds: getNumber(json, "durationSeconds", "duration"),
|
|
320
|
+
words: normalizeWordTimings(
|
|
321
|
+
json.words ?? json.wordTimings ?? json.word_timings
|
|
322
|
+
),
|
|
323
|
+
segments: normalizeTranscriptSegments(json.segments),
|
|
324
|
+
provider: getString(json, "provider") ?? adapterName,
|
|
325
|
+
model: getString(json, "model"),
|
|
326
|
+
raw: json
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
async function readSynthesizedSpeechResponse(response, adapterName) {
|
|
330
|
+
const responseContentType = response.headers.get("content-type") ?? "application/octet-stream";
|
|
331
|
+
if (!responseContentType.includes("application/json")) {
|
|
332
|
+
const audio = await response.arrayBuffer();
|
|
333
|
+
const sampleRate = parseHeaderNumber(response, "x-sample-rate");
|
|
334
|
+
return {
|
|
335
|
+
audio,
|
|
336
|
+
contentType: responseContentType,
|
|
337
|
+
format: contentTypeToFormat(responseContentType),
|
|
338
|
+
sampleRate,
|
|
339
|
+
provider: adapterName
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
const json = await response.json();
|
|
343
|
+
const contentType = getString(json, "contentType", "content_type", "mimeType", "mime_type") ?? "application/octet-stream";
|
|
344
|
+
const encodedAudio = getString(
|
|
345
|
+
json,
|
|
346
|
+
"audio",
|
|
347
|
+
"audioContent",
|
|
348
|
+
"audio_content"
|
|
349
|
+
);
|
|
350
|
+
if (!encodedAudio) {
|
|
351
|
+
throw new SpeechProviderError(
|
|
352
|
+
adapterName,
|
|
353
|
+
`${adapterName} JSON speech response did not include base64 audio`
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
return {
|
|
357
|
+
audio: decodeBase64(encodedAudio),
|
|
358
|
+
contentType,
|
|
359
|
+
format: getString(json, "format", "response_format") ?? contentTypeToFormat(contentType),
|
|
360
|
+
sampleRate: getNumber(json, "sampleRate", "sample_rate"),
|
|
361
|
+
channels: getNumber(json, "channels"),
|
|
362
|
+
durationSeconds: getNumber(json, "durationSeconds", "duration"),
|
|
363
|
+
words: normalizeWordTimings(
|
|
364
|
+
json.words ?? json.wordTimings ?? json.word_timings
|
|
365
|
+
),
|
|
366
|
+
provider: getString(json, "provider") ?? adapterName,
|
|
367
|
+
model: getString(json, "model"),
|
|
368
|
+
raw: json
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
function parseHeaderNumber(response, headerName) {
|
|
372
|
+
const value = response.headers.get(headerName);
|
|
373
|
+
if (!value) {
|
|
374
|
+
return void 0;
|
|
375
|
+
}
|
|
376
|
+
const parsed = Number(value);
|
|
377
|
+
return Number.isFinite(parsed) ? parsed : void 0;
|
|
378
|
+
}
|
|
379
|
+
class OpenAICompatibleSpeechSynthesizer extends HttpSpeechAdapter {
|
|
380
|
+
type = "openai-compatible";
|
|
381
|
+
speechPath;
|
|
382
|
+
defaultModel;
|
|
383
|
+
defaultVoice;
|
|
384
|
+
constructor(options) {
|
|
385
|
+
super(options);
|
|
386
|
+
this.speechPath = options.speechPath ?? "/v1/audio/speech";
|
|
387
|
+
this.defaultModel = options.defaultModel ?? "tts-1";
|
|
388
|
+
this.defaultVoice = options.defaultVoice ?? "alloy";
|
|
389
|
+
}
|
|
390
|
+
async synthesize(request) {
|
|
391
|
+
const outputFormat = request.outputFormat ?? "mp3";
|
|
392
|
+
const payload = compactJson({
|
|
393
|
+
model: request.model ?? this.defaultModel,
|
|
394
|
+
input: request.text,
|
|
395
|
+
voice: voiceToString(request.voice, this.defaultVoice),
|
|
396
|
+
// biome-ignore lint/style/useNamingConvention: OpenAI-compatible API uses snake_case.
|
|
397
|
+
response_format: outputFormat,
|
|
398
|
+
speed: request.speed
|
|
399
|
+
});
|
|
400
|
+
const speech = await this.post(
|
|
401
|
+
this.type,
|
|
402
|
+
this.speechPath,
|
|
403
|
+
{
|
|
404
|
+
headers: {
|
|
405
|
+
"content-type": "application/json"
|
|
406
|
+
},
|
|
407
|
+
body: JSON.stringify(payload),
|
|
408
|
+
signal: request.signal
|
|
409
|
+
},
|
|
410
|
+
(response) => readSynthesizedSpeechResponse(response, this.type)
|
|
411
|
+
);
|
|
412
|
+
return {
|
|
413
|
+
...speech,
|
|
414
|
+
format: speech.format ?? outputFormat,
|
|
415
|
+
model: speech.model ?? String(payload.model)
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
class Qwen3SpeechSynthesizer extends HttpSpeechAdapter {
|
|
420
|
+
type = "qwen3-tts";
|
|
421
|
+
speechPath;
|
|
422
|
+
defaultModel;
|
|
423
|
+
defaultVoice;
|
|
424
|
+
constructor(options) {
|
|
425
|
+
super(options);
|
|
426
|
+
this.speechPath = options.speechPath ?? "/v1/audio/speech";
|
|
427
|
+
this.defaultModel = options.defaultModel ?? "qwen3-tts";
|
|
428
|
+
this.defaultVoice = options.defaultVoice;
|
|
429
|
+
}
|
|
430
|
+
async synthesize(request) {
|
|
431
|
+
const form = createHappyVerticalSynthesisForm(request, this.defaultVoice);
|
|
432
|
+
const speech = await this.post(
|
|
433
|
+
this.type,
|
|
434
|
+
this.speechPath,
|
|
435
|
+
{
|
|
436
|
+
body: form,
|
|
437
|
+
signal: request.signal
|
|
438
|
+
},
|
|
439
|
+
(response) => readSynthesizedSpeechResponse(response, this.type)
|
|
440
|
+
);
|
|
441
|
+
return {
|
|
442
|
+
...speech,
|
|
443
|
+
format: speech.format ?? request.outputFormat,
|
|
444
|
+
model: speech.model ?? request.model ?? this.defaultModel
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
class StudioServerTranscriber extends HttpSpeechAdapter {
|
|
449
|
+
type = "studio-server";
|
|
450
|
+
transcribePath;
|
|
451
|
+
constructor(options) {
|
|
452
|
+
super(options);
|
|
453
|
+
this.transcribePath = options.transcribePath ?? "/v1/transcribe";
|
|
454
|
+
}
|
|
455
|
+
async transcribe(request) {
|
|
456
|
+
const form = new FormData();
|
|
457
|
+
appendAudioInput(form, request.audio);
|
|
458
|
+
appendOptionalFormValue(form, "language", request.language);
|
|
459
|
+
return this.post(
|
|
460
|
+
this.type,
|
|
461
|
+
this.transcribePath,
|
|
462
|
+
{
|
|
463
|
+
body: form,
|
|
464
|
+
signal: request.signal
|
|
465
|
+
},
|
|
466
|
+
(response) => readTranscriptResponse(response, this.type)
|
|
467
|
+
);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
class StudioServerSpeechSynthesizer extends HttpSpeechAdapter {
|
|
471
|
+
type = "studio-server";
|
|
472
|
+
synthesizePath;
|
|
473
|
+
defaultVoice;
|
|
474
|
+
constructor(options) {
|
|
475
|
+
super(options);
|
|
476
|
+
this.synthesizePath = options.synthesizePath ?? "/v1/tts/synthesize";
|
|
477
|
+
this.defaultVoice = options.defaultVoice;
|
|
478
|
+
}
|
|
479
|
+
async synthesize(request) {
|
|
480
|
+
const form = createHappyVerticalSynthesisForm(request, this.defaultVoice);
|
|
481
|
+
return this.post(
|
|
482
|
+
this.type,
|
|
483
|
+
this.synthesizePath,
|
|
484
|
+
{
|
|
485
|
+
body: form,
|
|
486
|
+
signal: request.signal
|
|
487
|
+
},
|
|
488
|
+
(response) => readSynthesizedSpeechResponse(response, this.type)
|
|
489
|
+
);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
async function getSpeech(options = {}, context = {}) {
|
|
493
|
+
const transcriber = options.transcriber === false ? void 0 : await getOptionalTranscriber(options.transcriber, context);
|
|
494
|
+
const synthesizer = options.synthesizer === false ? void 0 : await getOptionalSpeechSynthesizer(options.synthesizer, context);
|
|
495
|
+
return {
|
|
496
|
+
transcriber,
|
|
497
|
+
synthesizer,
|
|
498
|
+
async transcribe(request) {
|
|
499
|
+
if (!transcriber) {
|
|
500
|
+
throw new SpeechConfigurationError("No STT provider configured");
|
|
501
|
+
}
|
|
502
|
+
return transcriber.transcribe(request);
|
|
503
|
+
},
|
|
504
|
+
async synthesize(request) {
|
|
505
|
+
if (!synthesizer) {
|
|
506
|
+
throw new SpeechConfigurationError("No TTS provider configured");
|
|
507
|
+
}
|
|
508
|
+
return synthesizer.synthesize(request);
|
|
509
|
+
}
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
async function getTranscriber(options = {}, context = {}) {
|
|
513
|
+
const resolved = normalizeTranscriberOptions(options, context);
|
|
514
|
+
switch (resolved.type) {
|
|
515
|
+
case "studio-server":
|
|
516
|
+
return new StudioServerTranscriber(resolved);
|
|
517
|
+
default:
|
|
518
|
+
throw new InvalidSpeechAdapterError(
|
|
519
|
+
resolved.type ?? "unknown",
|
|
520
|
+
"STT"
|
|
521
|
+
);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
async function getSpeechSynthesizer(options, context = {}) {
|
|
525
|
+
const resolved = normalizeSpeechSynthesizerOptions(options, context);
|
|
526
|
+
switch (resolved.type) {
|
|
527
|
+
case "studio-server":
|
|
528
|
+
return new StudioServerSpeechSynthesizer(resolved);
|
|
529
|
+
case "qwen3-tts":
|
|
530
|
+
return new Qwen3SpeechSynthesizer(resolved);
|
|
531
|
+
case "openai-compatible":
|
|
532
|
+
return new OpenAICompatibleSpeechSynthesizer(resolved);
|
|
533
|
+
default:
|
|
534
|
+
throw new InvalidSpeechAdapterError(
|
|
535
|
+
resolved.type ?? "unknown",
|
|
536
|
+
"TTS"
|
|
537
|
+
);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
function getAvailableSpeechAdapters() {
|
|
541
|
+
return {
|
|
542
|
+
transcribers: ["studio-server"],
|
|
543
|
+
synthesizers: ["studio-server", "qwen3-tts", "openai-compatible"]
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
async function getOptionalTranscriber(options, context) {
|
|
547
|
+
if (!options && !hasTranscriberEnv(context.env ?? defaultEnv())) {
|
|
548
|
+
return void 0;
|
|
549
|
+
}
|
|
550
|
+
return getTranscriber(options, context);
|
|
551
|
+
}
|
|
552
|
+
async function getOptionalSpeechSynthesizer(options, context) {
|
|
553
|
+
if (!options && !hasSynthesizerEnv(context.env ?? defaultEnv())) {
|
|
554
|
+
return void 0;
|
|
555
|
+
}
|
|
556
|
+
return getSpeechSynthesizer(options, context);
|
|
557
|
+
}
|
|
558
|
+
function normalizeTranscriberOptions(options = {}, context) {
|
|
559
|
+
const env = context.env ?? defaultEnv();
|
|
560
|
+
const type = options.type ?? readEnv(
|
|
561
|
+
env,
|
|
562
|
+
"HAVE_SPEECH_STT_TYPE",
|
|
563
|
+
"HAVE_SPEECH_STT_ADAPTER",
|
|
564
|
+
"STT_ADAPTER"
|
|
565
|
+
) ?? "studio-server";
|
|
566
|
+
const baseUrl = options.baseUrl ?? readEnv(env, "HAVE_SPEECH_STT_BASE_URL", "STT_BASE_URL");
|
|
567
|
+
if (type !== "studio-server") {
|
|
568
|
+
throw new InvalidSpeechAdapterError(type, "STT");
|
|
569
|
+
}
|
|
570
|
+
if (!baseUrl?.trim()) {
|
|
571
|
+
throw new SpeechConfigurationError("STT baseUrl is required", type);
|
|
572
|
+
}
|
|
573
|
+
return {
|
|
574
|
+
...options,
|
|
575
|
+
type,
|
|
576
|
+
baseUrl: baseUrl.trim(),
|
|
577
|
+
fetch: options.fetch ?? context.fetch,
|
|
578
|
+
headers: options.headers ?? context.headers,
|
|
579
|
+
apiKey: options.apiKey ?? readEnv(env, "HAVE_SPEECH_STT_API_KEY", "STT_API_KEY", "SPEECH_API_KEY"),
|
|
580
|
+
timeoutMs: options.timeoutMs ?? parseOptionalInteger(
|
|
581
|
+
readEnv(env, "HAVE_SPEECH_STT_TIMEOUT_MS", "STT_TIMEOUT_MS")
|
|
582
|
+
),
|
|
583
|
+
transcribePath: options.transcribePath ?? readEnv(env, "HAVE_SPEECH_STT_PATH", "STT_PATH")
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
function normalizeSpeechSynthesizerOptions(options, context) {
|
|
587
|
+
const env = context.env ?? defaultEnv();
|
|
588
|
+
const type = options?.type ?? readEnv(
|
|
589
|
+
env,
|
|
590
|
+
"HAVE_SPEECH_TTS_TYPE",
|
|
591
|
+
"HAVE_SPEECH_TTS_ADAPTER",
|
|
592
|
+
"TTS_ADAPTER"
|
|
593
|
+
);
|
|
594
|
+
const baseUrl = options?.baseUrl ?? readEnv(env, "HAVE_SPEECH_TTS_BASE_URL", "TTS_BASE_URL");
|
|
595
|
+
if (!type) {
|
|
596
|
+
throw new SpeechConfigurationError("TTS provider type is required");
|
|
597
|
+
}
|
|
598
|
+
if (type !== "studio-server" && type !== "qwen3-tts" && type !== "openai-compatible") {
|
|
599
|
+
throw new InvalidSpeechAdapterError(type, "TTS");
|
|
600
|
+
}
|
|
601
|
+
if (!baseUrl?.trim()) {
|
|
602
|
+
throw new SpeechConfigurationError("TTS baseUrl is required", type);
|
|
603
|
+
}
|
|
604
|
+
const shared = {
|
|
605
|
+
type,
|
|
606
|
+
baseUrl: baseUrl.trim(),
|
|
607
|
+
fetch: options?.fetch ?? context.fetch,
|
|
608
|
+
headers: options?.headers ?? context.headers,
|
|
609
|
+
apiKey: options?.apiKey ?? readEnv(env, "HAVE_SPEECH_TTS_API_KEY", "TTS_API_KEY", "SPEECH_API_KEY"),
|
|
610
|
+
timeoutMs: options?.timeoutMs ?? parseOptionalInteger(
|
|
611
|
+
readEnv(env, "HAVE_SPEECH_TTS_TIMEOUT_MS", "TTS_TIMEOUT_MS")
|
|
612
|
+
)
|
|
613
|
+
};
|
|
614
|
+
if (type === "studio-server") {
|
|
615
|
+
return {
|
|
616
|
+
...shared,
|
|
617
|
+
type,
|
|
618
|
+
synthesizePath: options?.synthesizePath ?? readEnv(env, "HAVE_SPEECH_TTS_PATH", "TTS_PATH"),
|
|
619
|
+
defaultVoice: options?.defaultVoice ?? readEnv(env, "HAVE_SPEECH_TTS_VOICE", "TTS_VOICE")
|
|
620
|
+
};
|
|
621
|
+
}
|
|
622
|
+
if (type === "qwen3-tts") {
|
|
623
|
+
return {
|
|
624
|
+
...shared,
|
|
625
|
+
type,
|
|
626
|
+
speechPath: options?.speechPath ?? readEnv(env, "HAVE_SPEECH_TTS_PATH", "TTS_PATH"),
|
|
627
|
+
defaultModel: options?.defaultModel ?? readEnv(env, "HAVE_SPEECH_TTS_MODEL", "TTS_MODEL"),
|
|
628
|
+
defaultVoice: options?.defaultVoice ?? readEnv(env, "HAVE_SPEECH_TTS_VOICE", "TTS_VOICE")
|
|
629
|
+
};
|
|
630
|
+
}
|
|
631
|
+
if (type === "openai-compatible") {
|
|
632
|
+
return {
|
|
633
|
+
...shared,
|
|
634
|
+
type,
|
|
635
|
+
speechPath: options?.speechPath ?? readEnv(env, "HAVE_SPEECH_TTS_PATH", "TTS_PATH"),
|
|
636
|
+
defaultModel: options?.defaultModel ?? readEnv(env, "HAVE_SPEECH_TTS_MODEL", "TTS_MODEL"),
|
|
637
|
+
defaultVoice: options?.defaultVoice ?? readEnv(env, "HAVE_SPEECH_TTS_VOICE", "TTS_VOICE")
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
throw new InvalidSpeechAdapterError(type, "TTS");
|
|
641
|
+
}
|
|
642
|
+
function hasTranscriberEnv(env) {
|
|
643
|
+
return hasAnyEnv(
|
|
644
|
+
env,
|
|
645
|
+
"HAVE_SPEECH_STT_TYPE",
|
|
646
|
+
"HAVE_SPEECH_STT_ADAPTER",
|
|
647
|
+
"HAVE_SPEECH_STT_BASE_URL",
|
|
648
|
+
"STT_ADAPTER",
|
|
649
|
+
"STT_BASE_URL"
|
|
650
|
+
);
|
|
651
|
+
}
|
|
652
|
+
function hasSynthesizerEnv(env) {
|
|
653
|
+
return hasAnyEnv(
|
|
654
|
+
env,
|
|
655
|
+
"HAVE_SPEECH_TTS_TYPE",
|
|
656
|
+
"HAVE_SPEECH_TTS_ADAPTER",
|
|
657
|
+
"HAVE_SPEECH_TTS_BASE_URL",
|
|
658
|
+
"TTS_ADAPTER",
|
|
659
|
+
"TTS_BASE_URL"
|
|
660
|
+
);
|
|
661
|
+
}
|
|
662
|
+
function readEnv(env, ...keys) {
|
|
663
|
+
for (const key of keys) {
|
|
664
|
+
const value = env[key];
|
|
665
|
+
if (value?.trim()) {
|
|
666
|
+
return value.trim();
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
return void 0;
|
|
670
|
+
}
|
|
671
|
+
function hasAnyEnv(env, ...keys) {
|
|
672
|
+
return keys.some((key) => Boolean(env[key]?.trim()));
|
|
673
|
+
}
|
|
674
|
+
function parseOptionalInteger(value) {
|
|
675
|
+
if (!value) {
|
|
676
|
+
return void 0;
|
|
677
|
+
}
|
|
678
|
+
const parsed = Number.parseInt(value, 10);
|
|
679
|
+
return Number.isFinite(parsed) ? parsed : void 0;
|
|
680
|
+
}
|
|
681
|
+
function defaultEnv() {
|
|
682
|
+
return globalThis.process?.env ?? {};
|
|
683
|
+
}
|
|
684
|
+
export {
|
|
685
|
+
InvalidSpeechAdapterError,
|
|
686
|
+
SpeechConfigurationError,
|
|
687
|
+
SpeechError,
|
|
688
|
+
SpeechProviderError,
|
|
689
|
+
getAvailableSpeechAdapters,
|
|
690
|
+
getSpeech,
|
|
691
|
+
getSpeechSynthesizer,
|
|
692
|
+
getTranscriber
|
|
693
|
+
};
|
|
694
|
+
//# sourceMappingURL=index.js.map
|