voicekit-client 0.1.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.
Files changed (4) hide show
  1. package/README.md +128 -0
  2. package/index.d.ts +150 -0
  3. package/package.json +39 -0
  4. package/src/index.js +645 -0
package/README.md ADDED
@@ -0,0 +1,128 @@
1
+ # VoiceKit — TypeScript SDK
2
+
3
+ Official TypeScript/JavaScript wrapper for [VoiceKit](https://ttsapi.ru):
4
+ neural speech synthesis, transcription, sentiment analysis, and batch operations.
5
+
6
+ Zero runtime dependencies. Requires Node.js 18+ (global `fetch`).
7
+
8
+ ## Install
9
+
10
+ ```bash
11
+ npm install voicekit-client
12
+ ```
13
+
14
+ ## Quick start
15
+
16
+ ```js
17
+ import { VoiceKitClient, b64 } from "voicekit-client";
18
+ import { writeFile } from "node:fs/promises";
19
+
20
+ const client = new VoiceKitClient({ apiKey: "YOUR_KEY" });
21
+
22
+ // Synthesis → raw audio bytes
23
+ const audio = await client.synthesize("Привет! Это синтез русской речи.", {
24
+ voice: "preset_anna",
25
+ format: "mp3",
26
+ });
27
+ await writeFile("speech.mp3", audio);
28
+
29
+ // Streaming (Pro/Business)
30
+ for await (const chunk of client.synthesizeStream("Первое предложение. Второе.")) {
31
+ // write chunks to a file or socket
32
+ }
33
+
34
+ // Transcription (async → poll)
35
+ const job = await client.transcribe("audio.wav", { keyterms: ["диагноз"] });
36
+ let result = await client.getTranscriptionJob(job.job_id);
37
+ while (!["completed", "failed"].includes(result.status)) {
38
+ await new Promise(r => setTimeout(r, 1000));
39
+ result = await client.getTranscriptionJob(job.job_id);
40
+ }
41
+
42
+ // Short-file sync transcription
43
+ const transcript = await client.transcribeSync("audio.wav");
44
+
45
+ // Analysis (sentiment + keywords + entities)
46
+ const analysis = await client.analyzeSync("audio.wav");
47
+
48
+ // Text intelligence
49
+ const moderation = await client.moderate("Это оскорбительное сообщение.");
50
+
51
+ // Batches
52
+ const batch = await client.batchSynthesize([
53
+ { text: "Первый текст", voice: "preset_anna" },
54
+ { text: "Второй текст", voice: "dmitri" },
55
+ ]);
56
+ const status = await client.getBatch(batch.batch_id);
57
+
58
+ const analysisBatch = await client.batchAnalyze([
59
+ { audio: await b64("a.wav"), language: "ru" },
60
+ { audio: await b64("b.wav"), language: "ru" },
61
+ ]);
62
+
63
+ // Voice cloning (Pro/Business)
64
+ const clone = await client.createCloneVoice({
65
+ name: "My voice",
66
+ promptText: "Точный текст образца.",
67
+ samples: "reference.wav",
68
+ });
69
+ console.log(await client.listCloneVoices());
70
+ await client.deleteCloneVoice(clone.id);
71
+
72
+ // VAD (speech segments)
73
+ const segments = await client.vad("audio.wav");
74
+
75
+ // Account
76
+ const usage = await client.usage();
77
+ const balance = await client.billingBalance();
78
+ ```
79
+
80
+ ### Audio effects (Pro/Business)
81
+
82
+ ```js
83
+ // Inline during synthesis — the chain is applied to the synthesized audio
84
+ const fxAudio = await client.synthesize("Привет!", {
85
+ effects: '[{"type":"reverb","room_size":0.5},{"type":"pitch","semitones":2}]',
86
+ });
87
+
88
+ // Async processing of an existing file
89
+ const fxJob = await client.applyAudioEffects("voice.mp3", [{ type: "compressor", ratio: 3 }]);
90
+ let fxResult = await client.getAudioEffectsJob(fxJob.job_id);
91
+ while (!["completed", "failed"].includes(fxResult.status)) {
92
+ await new Promise(r => setTimeout(r, 1000));
93
+ fxResult = await client.getAudioEffectsJob(fxJob.job_id);
94
+ }
95
+ const fxFile = await client.downloadAudioEffects(fxJob.job_id);
96
+ await writeFile("voice_fx.mp3", fxFile);
97
+ ```
98
+
99
+ ### WebSocket streaming (Pro/Business)
100
+
101
+ ```js
102
+ const stream = await client.transcribeStream({ language: "ru", keyterms: ["диагноз"] });
103
+ await stream.sendAudio(pcm16Chunk1); // raw PCM16, 16 kHz mono (Uint8Array)
104
+ await stream.sendAudio(pcm16Chunk2);
105
+ await stream.stop(); // finalize the utterance
106
+ for await (const event of stream.events()) {
107
+ console.log(event.type, event); // session / vad / partial / final / error
108
+ }
109
+ stream.close();
110
+
111
+ const vad = await client.vadStream(); // VAD events only (speech_started/ended)
112
+ await vad.sendAudio(pcm16Chunk);
113
+ await vad.stop();
114
+ for await (const event of vad.events()) {
115
+ console.log(event.type, event);
116
+ }
117
+ vad.close();
118
+ ```
119
+
120
+ ## Configuration
121
+
122
+ | Option | Default | Description |
123
+ | --- | --- | --- |
124
+ | `apiKey` | — | API key (required) |
125
+ | `baseUrl` | `https://ttsapi.ru` | API base URL (e.g. `http://localhost:5080` for local dev) |
126
+ | `timeoutMs` | `120000` | Per-request timeout in milliseconds |
127
+
128
+ Errors reject with `VoiceKitError` (`.status`, `.code`, `.message`).
package/index.d.ts ADDED
@@ -0,0 +1,150 @@
1
+ export type AudioSource = Uint8Array | string;
2
+
3
+ export interface VoiceKitClientOptions {
4
+ apiKey: string;
5
+ baseUrl?: string;
6
+ timeoutMs?: number;
7
+ }
8
+
9
+ export class VoiceKitError extends Error {
10
+ readonly status: number;
11
+ readonly code: string;
12
+ }
13
+
14
+ export class VoiceKitClient {
15
+ constructor(options: VoiceKitClientOptions);
16
+
17
+ synthesize(text: string, opts?: SynthesisOptions): Promise<Uint8Array>;
18
+ synthesizeStream(text: string, opts?: SynthesisOptions): AsyncGenerator<Uint8Array>;
19
+ synthesizeAsync(
20
+ text: string,
21
+ opts?: SynthesisAsyncOptions,
22
+ ): Promise<Record<string, unknown>>;
23
+ getSynthesisJob(jobId: string): Promise<Record<string, unknown>>;
24
+ downloadSynthesisAudio(jobId: string): Promise<Uint8Array>;
25
+ voices(): Promise<Array<Record<string, unknown>>>;
26
+ voice(id: string): Promise<Record<string, unknown>>;
27
+
28
+ createCloneVoice(input: CloneVoiceCreateInput): Promise<Record<string, unknown>>;
29
+ listCloneVoices(): Promise<Array<Record<string, unknown>>>;
30
+ getCloneVoice(cloneId: string): Promise<Record<string, unknown>>;
31
+ deleteCloneVoice(cloneId: string): Promise<void>;
32
+
33
+ transcribe(audio: AudioSource, opts?: AudioOptions): Promise<Record<string, unknown>>;
34
+ transcribeSync(audio: AudioSource, opts?: AudioOptions): Promise<Record<string, unknown>>;
35
+ getTranscriptionJob(jobId: string): Promise<Record<string, unknown>>;
36
+ subtitles(jobId: string, format?: "vtt" | "srt"): Promise<string>;
37
+ vad(audio: AudioSource): Promise<Record<string, unknown>>;
38
+
39
+ analyze(audio: AudioSource, opts?: AudioOptions): Promise<Record<string, unknown>>;
40
+ analyzeSync(audio: AudioSource, opts?: AudioOptions): Promise<Record<string, unknown>>;
41
+ getAnalysisJob(jobId: string): Promise<Record<string, unknown>>;
42
+
43
+ detectLanguage(text: string): Promise<Record<string, unknown>>;
44
+ redact(text: string, language?: string): Promise<Record<string, unknown>>;
45
+ topics(text: string, language?: string): Promise<Record<string, unknown>>;
46
+ summarize(
47
+ text: string,
48
+ language?: string,
49
+ maxSentences?: number,
50
+ ): Promise<Record<string, unknown>>;
51
+
52
+ moderate(text: string, language?: string): Promise<Record<string, unknown>>;
53
+
54
+ applyAudioEffects(
55
+ audio: AudioSource,
56
+ effects: Array<Record<string, unknown>>,
57
+ opts?: AudioEffectsOptions,
58
+ ): Promise<Record<string, unknown>>;
59
+ getAudioEffectsJob(jobId: string): Promise<Record<string, unknown>>;
60
+ downloadAudioEffects(jobId: string): Promise<Uint8Array>;
61
+
62
+ applyVideoEffects(
63
+ video: AudioSource,
64
+ effects: Array<Record<string, unknown>>,
65
+ opts?: VideoEffectsOptions,
66
+ ): Promise<Record<string, unknown>>;
67
+ getVideoEffectsJob(jobId: string): Promise<Record<string, unknown>>;
68
+ downloadVideoEffects(jobId: string): Promise<Uint8Array>;
69
+
70
+ batchSynthesize(items: Array<Record<string, unknown>>): Promise<Record<string, unknown>>;
71
+ batchAnalyze(items: Array<Record<string, unknown>>): Promise<Record<string, unknown>>;
72
+ getBatch(batchId: string): Promise<Record<string, unknown>>;
73
+
74
+ usage(): Promise<Record<string, unknown>>;
75
+ billingBalance(): Promise<Record<string, unknown>>;
76
+
77
+ transcribeStream(opts?: TranscribeStreamOptions): Promise<WsStream>;
78
+ vadStream(): Promise<WsStream>;
79
+ }
80
+
81
+ export interface SynthesisOptions {
82
+ voice?: string;
83
+ format?: "mp3" | "wav" | "ogg";
84
+ sampleRate?: number;
85
+ speed?: number;
86
+ pitch?: number;
87
+ emotion?: string;
88
+ ssml?: boolean;
89
+ putAccent?: boolean;
90
+ putYo?: boolean;
91
+ normalize?: boolean;
92
+ model?: string;
93
+ language?: string;
94
+ effects?: string;
95
+ }
96
+
97
+ export interface AudioEffectsOptions {
98
+ outputFormat?: "wav" | "mp3" | "ogg";
99
+ webhookUrl?: string;
100
+ }
101
+
102
+ export interface VideoEffectsOptions {
103
+ mode?: "mux" | "audio";
104
+ audio?: AudioSource;
105
+ outputFormat?: string;
106
+ webhookUrl?: string;
107
+ }
108
+
109
+ export interface SynthesisAsyncOptions {
110
+ voice?: string;
111
+ format?: "wav";
112
+ sampleRate?: number;
113
+ speed?: number;
114
+ model?: string;
115
+ language?: string;
116
+ webhookUrl?: string;
117
+ }
118
+
119
+ export interface AudioOptions {
120
+ language?: string;
121
+ diarization?: boolean;
122
+ webhookUrl?: string;
123
+ keyterms?: string[];
124
+ emotions?: boolean;
125
+ keywords?: boolean;
126
+ entities?: boolean;
127
+ }
128
+
129
+ export interface CloneVoiceCreateInput {
130
+ name: string;
131
+ promptText: string;
132
+ samples: AudioSource | AudioSource[];
133
+ language?: string;
134
+ }
135
+
136
+ export interface TranscribeStreamOptions {
137
+ language?: string;
138
+ keyterms?: string[];
139
+ interim?: boolean;
140
+ }
141
+
142
+ export class WsStream {
143
+ sendAudio(pcm16: Uint8Array): Promise<void>;
144
+ sendText(payload: unknown): Promise<void>;
145
+ stop(): Promise<void>;
146
+ events(): AsyncGenerator<Record<string, unknown>>;
147
+ close(): void;
148
+ }
149
+
150
+ export function b64(audio: AudioSource): Promise<string>;
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "voicekit-client",
3
+ "version": "0.1.0",
4
+ "description": "Official TypeScript/JavaScript SDK for VoiceKit (synthesis, transcription, analysis, moderation, batches).",
5
+ "type": "module",
6
+ "main": "./src/index.js",
7
+ "types": "./index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./index.d.ts",
11
+ "import": "./src/index.js"
12
+ }
13
+ },
14
+ "files": [
15
+ "src",
16
+ "index.d.ts",
17
+ "README.md"
18
+ ],
19
+ "engines": {
20
+ "node": ">=18.0.0"
21
+ },
22
+ "dependencies": {
23
+ "ws": "^8.18.0"
24
+ },
25
+ "keywords": [
26
+ "tts",
27
+ "speech",
28
+ "synthesis",
29
+ "transcription",
30
+ "stt",
31
+ "voice",
32
+ "russian"
33
+ ],
34
+ "license": "UNLICENSED",
35
+ "repository": {
36
+ "type": "git",
37
+ "url": "https://ttsapi.ru"
38
+ }
39
+ }
package/src/index.js ADDED
@@ -0,0 +1,645 @@
1
+ /**
2
+ * Official JavaScript/TypeScript SDK for VoiceKit.
3
+ *
4
+ * Requires Node.js 18+ (global `fetch`, web streams). Zero runtime dependencies.
5
+ *
6
+ * @example
7
+ * import { VoiceKitClient } from "voicekit-client";
8
+ *
9
+ * const client = new VoiceKitClient({ apiKey: "YOUR_KEY" });
10
+ * const audio = await client.synthesize("Привет!", { voice: "preset_anna", format: "mp3" });
11
+ * await import("node:fs/promises").then(fs => fs.writeFile("speech.mp3", audio));
12
+ */
13
+
14
+ import { readFile } from "node:fs/promises";
15
+ import { basename } from "node:path";
16
+
17
+ const DEFAULT_BASE_URL = "https://ttsapi.ru";
18
+
19
+ export class VoiceKitError extends Error {
20
+ /**
21
+ * @param {number} status HTTP status code.
22
+ * @param {string} message Human-readable message.
23
+ * @param {string} [code] Machine-readable error code.
24
+ */
25
+ constructor(status, message, code = "") {
26
+ super(message);
27
+ this.name = "VoiceKitError";
28
+ this.status = status;
29
+ this.code = code;
30
+ }
31
+ }
32
+
33
+ /**
34
+ * Audio input: raw bytes or a path to a local file.
35
+ * @typedef {Uint8Array | string} AudioSource
36
+ */
37
+
38
+ /**
39
+ * Client options.
40
+ * @typedef {object} VoiceKitClientOptions
41
+ * @property {string} apiKey API key (required).
42
+ * @property {string} [baseUrl] API base URL. Defaults to `https://ttsapi.ru`.
43
+ * @property {number} [timeoutMs] Per-request timeout in milliseconds. Defaults to 120000.
44
+ */
45
+
46
+ export class VoiceKitClient {
47
+ /**
48
+ * @param {VoiceKitClientOptions} options
49
+ */
50
+ constructor({ apiKey, baseUrl = DEFAULT_BASE_URL, timeoutMs = 120_000 }) {
51
+ if (!apiKey) throw new Error("apiKey is required");
52
+ /** @private */ this._baseUrl = baseUrl.replace(/\/+$/, "");
53
+ /** @private */ this._headers = { "X-Api-Key": apiKey };
54
+ /** @private */ this._timeoutMs = timeoutMs;
55
+ }
56
+
57
+ // ──────────────────────── Synthesis ────────────────────────────────
58
+
59
+ /**
60
+ * Synthesize speech and return raw audio bytes.
61
+ * @param {string} text
62
+ * @param {object} [opts]
63
+ * @returns {Promise<Uint8Array>}
64
+ */
65
+ async synthesize(text, opts = {}) {
66
+ const body = compact({ text, ...opts });
67
+ const response = await this.#request("/v1/synthesize", { method: "POST", json: body });
68
+ return new Uint8Array(await response.arrayBuffer());
69
+ }
70
+
71
+ /**
72
+ * Synthesize and yield audio chunks as they are produced (Pro/Business).
73
+ * @param {string} text
74
+ * @param {object} [opts]
75
+ * @returns {AsyncGenerator<Uint8Array>}
76
+ */
77
+ async *synthesizeStream(text, opts = {}) {
78
+ const body = compact({ text, ...opts });
79
+ const response = await this.#request("/v1/synthesize/stream", { method: "POST", json: body });
80
+ const reader = response.body?.getReader();
81
+ if (!reader) return;
82
+ while (true) {
83
+ const { done, value } = await reader.read();
84
+ if (done) break;
85
+ if (value?.length) yield value;
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Queue a long-form (audiobook) synthesis job; poll with `getSynthesisJob()`.
91
+ * Long-form synthesis returns WAV only.
92
+ * @param {string} text
93
+ * @param {object} [opts]
94
+ * @returns {Promise<Record<string, unknown>>}
95
+ */
96
+ async synthesizeAsync(text, opts = {}) {
97
+ const { webhookUrl, ...body } = opts;
98
+ const query = webhookUrl ? `?webhookUrl=${encodeURIComponent(webhookUrl)}` : "";
99
+ const response = await this.#request(`/v1/synthesize/async${query}`, {
100
+ method: "POST",
101
+ json: compact({ text, ...body }),
102
+ });
103
+ return response.json();
104
+ }
105
+
106
+ /**
107
+ * Poll a long-form synthesis job; returns the audiobook manifest when done.
108
+ * @param {string} jobId
109
+ * @returns {Promise<Record<string, unknown>>}
110
+ */
111
+ getSynthesisJob(jobId) {
112
+ return this.#getJson(`/v1/synthesize/async/${jobId}`);
113
+ }
114
+
115
+ /**
116
+ * Download the produced WAV for a completed long-form synthesis job.
117
+ * @param {string} jobId
118
+ * @returns {Promise<Uint8Array>}
119
+ */
120
+ async downloadSynthesisAudio(jobId) {
121
+ const response = await this.#request(`/v1/synthesize/async/${jobId}/audio`);
122
+ return new Uint8Array(await response.arrayBuffer());
123
+ }
124
+
125
+ /** @returns {Promise<Array<Record<string, unknown>>>} */
126
+ voices() {
127
+ return this.#getJson("/v1/voices");
128
+ }
129
+
130
+ /**
131
+ * @param {string} id Voice id, e.g. `preset_anna`.
132
+ * @returns {Promise<Record<string, unknown>>}
133
+ */
134
+ voice(id) {
135
+ return this.#getJson(`/v1/voices/${id}`);
136
+ }
137
+
138
+ // ──────────────────────── Voice cloning ────────────────────────────
139
+
140
+ /**
141
+ * Create a cloned voice from reference audio (Pro/Business).
142
+ * @param {{ name: string, promptText: string, samples: AudioSource | AudioSource[], language?: string }} input
143
+ * @returns {Promise<Record<string, unknown>>}
144
+ */
145
+ async createCloneVoice({ name, promptText, samples, language }) {
146
+ const sampleList = Array.isArray(samples) ? samples : [samples];
147
+ const form = new FormData();
148
+ form.append("name", name);
149
+ form.append("prompt_text", promptText);
150
+ if (language) form.append("language", language);
151
+ for (const sample of sampleList) {
152
+ const { data, filename } = await audioToParts(sample);
153
+ form.append("samples", new Blob([data], { type: "audio/wav" }), filename);
154
+ }
155
+ const response = await this.#request("/v1/voices/clone", { method: "POST", form });
156
+ return response.json();
157
+ }
158
+
159
+ /** @returns {Promise<Array<Record<string, unknown>>>} */
160
+ listCloneVoices() {
161
+ return this.#getJson("/v1/voices/clone");
162
+ }
163
+
164
+ /**
165
+ * @param {string} cloneId
166
+ * @returns {Promise<Record<string, unknown>>}
167
+ */
168
+ getCloneVoice(cloneId) {
169
+ return this.#getJson(`/v1/voices/clone/${cloneId}`);
170
+ }
171
+
172
+ /**
173
+ * @param {string} cloneId
174
+ * @returns {Promise<void>}
175
+ */
176
+ async deleteCloneVoice(cloneId) {
177
+ await this.#request(`/v1/voices/clone/${cloneId}`, { method: "DELETE" });
178
+ }
179
+
180
+ // ──────────────────────── Transcription ────────────────────────────
181
+
182
+ /**
183
+ * Start an async transcription job; poll with `getTranscriptionJob()`.
184
+ * @param {AudioSource} audio
185
+ * @param {object} [opts]
186
+ * @returns {Promise<Record<string, unknown>>}
187
+ */
188
+ async transcribe(audio, opts = {}) {
189
+ return this.#postForm("/v1/transcribe", audio, opts);
190
+ }
191
+
192
+ /**
193
+ * Transcribe a short file (≤ 3 min) synchronously.
194
+ * @param {AudioSource} audio
195
+ * @param {object} [opts]
196
+ * @returns {Promise<Record<string, unknown>>}
197
+ */
198
+ async transcribeSync(audio, opts = {}) {
199
+ return this.#postForm("/v1/transcribe/sync", audio, opts);
200
+ }
201
+
202
+ /**
203
+ * @param {string} jobId
204
+ * @returns {Promise<Record<string, unknown>>}
205
+ */
206
+ getTranscriptionJob(jobId) {
207
+ return this.#getJson(`/v1/transcribe/${jobId}`);
208
+ }
209
+
210
+ /**
211
+ * Download VTT/SRT subtitles for a completed transcription job.
212
+ * @param {string} jobId
213
+ * @param {"vtt" | "srt"} [format]
214
+ * @returns {Promise<string>}
215
+ */
216
+ async subtitles(jobId, format = "vtt") {
217
+ const response = await this.#request(`/v1/transcribe/${jobId}/subtitles?format=${format}`);
218
+ return response.text();
219
+ }
220
+
221
+ /**
222
+ * Detect speech segments in an audio file (Silero VAD).
223
+ * @param {AudioSource} audio
224
+ * @returns {Promise<Record<string, unknown>>}
225
+ */
226
+ vad(audio) {
227
+ return this.#postForm("/v1/vad", audio, {});
228
+ }
229
+
230
+ // ──────────────────────── Analysis ─────────────────────────────────
231
+
232
+ /**
233
+ * Start an async analysis job; poll with `getAnalysisJob()`.
234
+ * @param {AudioSource} audio
235
+ * @param {object} [opts]
236
+ * @returns {Promise<Record<string, unknown>>}
237
+ */
238
+ async analyze(audio, opts = {}) {
239
+ return this.#postForm("/v1/analyze", audio, opts);
240
+ }
241
+
242
+ /**
243
+ * Analyze a short file (≤ 3 min) synchronously.
244
+ * @param {AudioSource} audio
245
+ * @param {object} [opts]
246
+ * @returns {Promise<Record<string, unknown>>}
247
+ */
248
+ async analyzeSync(audio, opts = {}) {
249
+ return this.#postForm("/v1/analyze/sync", audio, opts);
250
+ }
251
+
252
+ /**
253
+ * @param {string} jobId
254
+ * @returns {Promise<Record<string, unknown>>}
255
+ */
256
+ getAnalysisJob(jobId) {
257
+ return this.#getJson(`/v1/analyze/${jobId}`);
258
+ }
259
+
260
+ // ──────────────────────── Text intelligence ────────────────────────
261
+
262
+ /** @param {string} text */
263
+ detectLanguage(text) {
264
+ return this.#postJson("/v1/detect-language", { text });
265
+ }
266
+
267
+ /** @param {string} text */
268
+ redact(text, language) {
269
+ return this.#postJson("/v1/redact", compact({ text, language }));
270
+ }
271
+
272
+ /** @param {string} text */
273
+ topics(text, language) {
274
+ return this.#postJson("/v1/analyze/topics", compact({ text, language }));
275
+ }
276
+
277
+ /** @param {string} text */
278
+ summarize(text, language, maxSentences) {
279
+ return this.#postJson(
280
+ "/v1/analyze/summarize",
281
+ compact({ text, language, max_sentences: maxSentences }),
282
+ );
283
+ }
284
+
285
+ /**
286
+ * Flag profanity, insults and hate speech in raw text.
287
+ * @param {string} text
288
+ * @param {string} [language]
289
+ * @returns {Promise<Record<string, unknown>>}
290
+ */
291
+ moderate(text, language) {
292
+ return this.#postJson("/v1/moderate", compact({ text, language }));
293
+ }
294
+
295
+ // ──────────────────────── Audio / video effects ───────────────────
296
+
297
+ /**
298
+ * Start an async audio-effects job; poll with `getAudioEffectsJob()`.
299
+ * @param {AudioSource} audio
300
+ * @param {Array<Record<string, unknown>>} effects Effect descriptors.
301
+ * @param {{ outputFormat?: string, webhookUrl?: string }} [opts]
302
+ * @returns {Promise<Record<string, unknown>>}
303
+ */
304
+ async applyAudioEffects(audio, effects, opts = {}) {
305
+ const { outputFormat = "wav", webhookUrl } = opts;
306
+ const { data, filename } = await audioToParts(audio);
307
+ const form = new FormData();
308
+ form.append("audio", new Blob([data], { type: "audio/wav" }), filename);
309
+ form.append("effects", JSON.stringify(effects));
310
+ form.append("output_format", outputFormat);
311
+ const query = webhookUrl ? `?webhookUrl=${encodeURIComponent(webhookUrl)}` : "";
312
+ const response = await this.#request(`/v1/audio/effects${query}`, { method: "POST", form });
313
+ return response.json();
314
+ }
315
+
316
+ /**
317
+ * Poll an audio-effects job; returns the manifest when completed.
318
+ * @param {string} jobId
319
+ * @returns {Promise<Record<string, unknown>>}
320
+ */
321
+ getAudioEffectsJob(jobId) {
322
+ return this.#getJson(`/v1/audio/effects/${jobId}`);
323
+ }
324
+
325
+ /**
326
+ * Download the produced audio for a completed audio-effects job.
327
+ * @param {string} jobId
328
+ * @returns {Promise<Uint8Array>}
329
+ */
330
+ async downloadAudioEffects(jobId) {
331
+ const response = await this.#request(`/v1/audio/effects/${jobId}/audio`);
332
+ return new Uint8Array(await response.arrayBuffer());
333
+ }
334
+
335
+ /**
336
+ * Start an async video-effects job; poll with `getVideoEffectsJob()`.
337
+ * @param {AudioSource} video
338
+ * @param {Array<Record<string, unknown>>} effects Effect descriptors.
339
+ * @param {{ mode?: string, audio?: AudioSource, outputFormat?: string, webhookUrl?: string }} [opts]
340
+ * @returns {Promise<Record<string, unknown>>}
341
+ */
342
+ async applyVideoEffects(video, effects, opts = {}) {
343
+ const { mode = "mux", audio, outputFormat, webhookUrl } = opts;
344
+ const { data, filename } = await audioToParts(video);
345
+ const form = new FormData();
346
+ form.append("video", new Blob([data], { type: "video/mp4" }), filename);
347
+ if (audio) {
348
+ const parts = await audioToParts(audio);
349
+ form.append("audio", new Blob([parts.data], { type: "audio/wav" }), parts.filename);
350
+ }
351
+ form.append("effects", JSON.stringify(effects));
352
+ form.append("mode", mode);
353
+ if (outputFormat) form.append("output_format", outputFormat);
354
+ const query = webhookUrl ? `?webhookUrl=${encodeURIComponent(webhookUrl)}` : "";
355
+ const response = await this.#request(`/v1/video/effects${query}`, { method: "POST", form });
356
+ return response.json();
357
+ }
358
+
359
+ /**
360
+ * Poll a video-effects job; returns the manifest when completed.
361
+ * @param {string} jobId
362
+ * @returns {Promise<Record<string, unknown>>}
363
+ */
364
+ getVideoEffectsJob(jobId) {
365
+ return this.#getJson(`/v1/video/effects/${jobId}`);
366
+ }
367
+
368
+ /**
369
+ * Download the produced artifact (video or audio) for a video-effects job.
370
+ * @param {string} jobId
371
+ * @returns {Promise<Uint8Array>}
372
+ */
373
+ async downloadVideoEffects(jobId) {
374
+ const response = await this.#request(`/v1/video/effects/${jobId}/file`);
375
+ return new Uint8Array(await response.arrayBuffer());
376
+ }
377
+
378
+ // ──────────────────────── Batch ────────────────────────────────────
379
+
380
+ /**
381
+ * Queue a batch of synthesis requests; poll with `getBatch()`.
382
+ * @param {Array<Record<string, unknown>>} items
383
+ * @returns {Promise<Record<string, unknown>>}
384
+ */
385
+ batchSynthesize(items) {
386
+ return this.#postJson("/v1/batch/synthesize", { items });
387
+ }
388
+
389
+ /**
390
+ * Queue a batch of analysis requests; each item needs inline base64 `audio`.
391
+ * @param {Array<Record<string, unknown>>} items
392
+ * @returns {Promise<Record<string, unknown>>}
393
+ */
394
+ batchAnalyze(items) {
395
+ return this.#postJson("/v1/batch/analyze", { items });
396
+ }
397
+
398
+ /**
399
+ * @param {string} batchId
400
+ * @returns {Promise<Record<string, unknown>>}
401
+ */
402
+ getBatch(batchId) {
403
+ return this.#getJson(`/v1/batch/${batchId}`);
404
+ }
405
+
406
+ // ──────────────────────── Account ──────────────────────────────────
407
+
408
+ /** @returns {Promise<Record<string, unknown>>} */
409
+ usage() {
410
+ return this.#getJson("/v1/usage");
411
+ }
412
+
413
+ /** @returns {Promise<Record<string, unknown>>} */
414
+ billingBalance() {
415
+ return this.#getJson("/v1/billing/balance");
416
+ }
417
+
418
+ // ──────────────────────── Streaming (WebSocket) ────────────────────
419
+
420
+ /**
421
+ * Open a streaming transcription session (Pro/Business).
422
+ * @param {{ language?: string, keyterms?: string[], interim?: boolean }} [opts]
423
+ * @returns {Promise<WsStream>}
424
+ */
425
+ async transcribeStream({ language, keyterms, interim = true } = {}) {
426
+ const url = this.#wsUrl("/v1/transcribe/stream", {
427
+ language,
428
+ keyterms: Array.isArray(keyterms) ? keyterms.join(",") : undefined,
429
+ interim,
430
+ });
431
+ return openWsStream(url, this._headers);
432
+ }
433
+
434
+ /**
435
+ * Open a streaming turn-detection session (VAD events only).
436
+ * @returns {Promise<WsStream>}
437
+ */
438
+ async vadStream() {
439
+ const url = this.#wsUrl("/v1/vad/stream", {});
440
+ return openWsStream(url, this._headers);
441
+ }
442
+
443
+ // ──────────────────────── Transport ────────────────────────────────
444
+
445
+ /**
446
+ * @param {string} path
447
+ * @returns {Promise<Record<string, unknown>>}
448
+ */
449
+ async #getJson(path) {
450
+ const response = await this.#request(path);
451
+ return response.json();
452
+ }
453
+
454
+ /**
455
+ * @param {string} path
456
+ * @param {Record<string, unknown>} body
457
+ * @returns {Promise<Record<string, unknown>>}
458
+ */
459
+ async #postJson(path, body) {
460
+ const response = await this.#request(path, { method: "POST", json: body });
461
+ return response.json();
462
+ }
463
+
464
+ /**
465
+ * @param {string} path
466
+ * @param {AudioSource} audio
467
+ * @param {object} opts
468
+ * @returns {Promise<Record<string, unknown>>}
469
+ */
470
+ async #postForm(path, audio, opts) {
471
+ const { data, filename } = await audioToParts(audio);
472
+ const form = new FormData();
473
+ form.append("audio", new Blob([data], { type: "audio/wav" }), filename);
474
+
475
+ const { keyterms, ...rest } = opts;
476
+ for (const [key, value] of Object.entries(compact(rest))) {
477
+ form.append(key, String(value));
478
+ }
479
+ if (Array.isArray(keyterms) && keyterms.length) {
480
+ form.append("keyterms", keyterms.join(","));
481
+ }
482
+
483
+ const response = await this.#request(path, { method: "POST", form });
484
+ return response.json();
485
+ }
486
+
487
+ /**
488
+ * @param {string} path
489
+ * @param {{ method?: string, json?: unknown, form?: FormData }} [options]
490
+ * @returns {Promise<Response>}
491
+ */
492
+ async #request(path, { method = "GET", json, form } = {}) {
493
+ const headers = { ...this._headers };
494
+ const init = { method, headers, signal: AbortSignal.timeout(this._timeoutMs) };
495
+
496
+ if (json !== undefined) {
497
+ init.headers["Content-Type"] = "application/json";
498
+ init.body = JSON.stringify(json);
499
+ } else if (form !== undefined) {
500
+ init.body = form;
501
+ }
502
+
503
+ const response = await fetch(`${this._baseUrl}${path}`, init);
504
+ if (!response.ok) {
505
+ let code = "";
506
+ let detail = await response.text();
507
+ try {
508
+ const payload = JSON.parse(detail);
509
+ code = payload.code ?? "";
510
+ detail = payload.detail ?? payload.title ?? detail;
511
+ } catch {
512
+ /* not JSON */
513
+ }
514
+ throw new VoiceKitError(response.status, detail, code);
515
+ }
516
+ return response;
517
+ }
518
+
519
+ /**
520
+ * Build a `ws(s)://` URL for a streaming endpoint from the base URL.
521
+ * @param {string} path
522
+ * @param {Record<string, unknown>} params
523
+ * @returns {string}
524
+ */
525
+ #wsUrl(path, params) {
526
+ const url = new URL(this._baseUrl);
527
+ url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
528
+ url.pathname = path;
529
+ url.search = "";
530
+ for (const [key, value] of Object.entries(params)) {
531
+ if (value === undefined || value === null || value === "") continue;
532
+ url.searchParams.set(key, String(value));
533
+ }
534
+ return url.toString();
535
+ }
536
+ }
537
+
538
+ /**
539
+ * @param {AudioSource} audio
540
+ * @returns {Promise<{ data: Uint8Array, filename: string }>}
541
+ */
542
+ async function audioToParts(audio) {
543
+ if (audio instanceof Uint8Array) {
544
+ return { data: audio, filename: "audio.wav" };
545
+ }
546
+ const data = await readFile(audio);
547
+ return { data: new Uint8Array(data), filename: basename(audio) };
548
+ }
549
+
550
+ /**
551
+ * Encode audio as base64 for `batchAnalyze()`.
552
+ * @param {AudioSource} audio
553
+ * @returns {Promise<string>}
554
+ */
555
+ export async function b64(audio) {
556
+ const { data } = await audioToParts(audio);
557
+ return Buffer.from(data).toString("base64");
558
+ }
559
+
560
+ /**
561
+ * @param {Record<string, unknown>} object
562
+ * @returns {Record<string, unknown>}
563
+ */
564
+ function compact(object) {
565
+ return Object.fromEntries(
566
+ Object.entries(object).filter(([, value]) => value !== undefined && value !== null),
567
+ );
568
+ }
569
+
570
+ // ──────────────────────── WebSocket streaming ─────────────────────────
571
+
572
+ /**
573
+ * A connected streaming session (transcription or VAD).
574
+ */
575
+ export class WsStream {
576
+ /**
577
+ * @param {import("ws")} ws
578
+ */
579
+ constructor(ws) {
580
+ /** @private */ this._ws = ws;
581
+ // Prevent unhandled 'error' events from crashing the process mid-session.
582
+ ws.on("error", () => {});
583
+ }
584
+
585
+ /**
586
+ * Send raw PCM16 (16 kHz, mono, little-endian) audio.
587
+ * @param {Uint8Array} pcm16
588
+ * @returns {Promise<void>}
589
+ */
590
+ sendAudio(pcm16) {
591
+ return new Promise((resolve, reject) => {
592
+ this._ws.send(pcm16, (err) => (err ? reject(err) : resolve()));
593
+ });
594
+ }
595
+
596
+ /**
597
+ * Send a text frame (objects are JSON-encoded).
598
+ * @param {unknown} payload
599
+ * @returns {Promise<void>}
600
+ */
601
+ sendText(payload) {
602
+ const text = typeof payload === "string" ? payload : JSON.stringify(payload);
603
+ return new Promise((resolve, reject) => {
604
+ this._ws.send(text, (err) => (err ? reject(err) : resolve()));
605
+ });
606
+ }
607
+
608
+ /**
609
+ * Signal end of speech so the server finalizes the utterance.
610
+ * @returns {Promise<void>}
611
+ */
612
+ stop() {
613
+ return this.sendText({ type: "stop" });
614
+ }
615
+
616
+ /**
617
+ * Receive JSON events (`session`, `vad`, `partial`, `final`, `error`).
618
+ * @returns {AsyncGenerator<Record<string, unknown>>}
619
+ */
620
+ async *events() {
621
+ for await (const message of this._ws) {
622
+ yield typeof message === "string" ? JSON.parse(message) : JSON.parse(message.toString("utf8"));
623
+ }
624
+ }
625
+
626
+ /** Close the session. */
627
+ close() {
628
+ this._ws.close();
629
+ }
630
+ }
631
+
632
+ /**
633
+ * @param {string} url
634
+ * @param {Record<string, string>} headers
635
+ * @returns {Promise<WsStream>}
636
+ */
637
+ async function openWsStream(url, headers) {
638
+ const { default: WebSocket } = await import("ws");
639
+ const ws = new WebSocket(url, { headers });
640
+ await new Promise((resolve, reject) => {
641
+ ws.once("open", resolve);
642
+ ws.once("error", reject);
643
+ });
644
+ return new WsStream(ws);
645
+ }