@kuralle-syrinx/openai-tts 4.2.0 → 4.4.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kuralle-syrinx/openai-tts",
3
- "version": "4.2.0",
3
+ "version": "4.4.0",
4
4
  "private": false,
5
5
  "description": "OpenAI-compatible streaming TTS plugin for Syrinx",
6
6
  "keywords": [
@@ -26,11 +26,11 @@
26
26
  "main": "./src/index.ts",
27
27
  "types": "./src/index.ts",
28
28
  "dependencies": {
29
- "@kuralle-syrinx/core": "4.2.0"
29
+ "@kuralle-syrinx/core": "4.4.0"
30
30
  },
31
31
  "devDependencies": {
32
32
  "typescript": "^5.7.0",
33
- "vitest": "^2.1.0"
33
+ "vitest": "^3.2.6"
34
34
  },
35
35
  "scripts": {
36
36
  "typecheck": "tsc --noEmit",
package/src/index.ts CHANGED
@@ -85,6 +85,26 @@ export class OpenAICompatibleTTSPlugin implements VoicePlugin {
85
85
  this.bus = null;
86
86
  }
87
87
 
88
+ async prewarm(): Promise<void> {
89
+ const controller = new AbortController();
90
+ const timeout = setTimeout(() => controller.abort(), 8_000);
91
+ try {
92
+ const headers: Record<string, string> = {};
93
+ if (this.apiKey) {
94
+ headers["Authorization"] = `Bearer ${this.apiKey}`;
95
+ }
96
+ await fetch(`${this.baseUrl}/models`, {
97
+ method: "GET",
98
+ headers,
99
+ signal: controller.signal,
100
+ });
101
+ } catch {
102
+ // best-effort
103
+ } finally {
104
+ clearTimeout(timeout);
105
+ }
106
+ }
107
+
88
108
  private abortInflight(): void {
89
109
  for (const controller of this.inflight) {
90
110
  controller.abort();
@@ -183,6 +203,7 @@ export class OpenAICompatibleTTSPlugin implements VoicePlugin {
183
203
  this.emitAudio(contextId, tail);
184
204
  }
185
205
  }
206
+ this.emitUsage(contextId, text.length);
186
207
  this.emitEnd(contextId);
187
208
  } catch (err) {
188
209
  if (isAbortError(err) || controller.signal.aborted) return;
@@ -219,6 +240,19 @@ export class OpenAICompatibleTTSPlugin implements VoicePlugin {
219
240
  this.bus?.push(Route.Main, packet);
220
241
  }
221
242
 
243
+ private emitUsage(contextId: string, characters: number): void {
244
+ if (characters <= 0) return;
245
+ this.bus?.push(Route.Background, {
246
+ kind: "usage.recorded",
247
+ contextId,
248
+ timestampMs: Date.now(),
249
+ stage: "tts",
250
+ provider: "openai",
251
+ model: this.model,
252
+ characters,
253
+ });
254
+ }
255
+
222
256
  private emitEnd(contextId: string): void {
223
257
  this.bus?.push(Route.Main, {
224
258
  kind: "tts.end",
package/src/index.test.ts DELETED
@@ -1,393 +0,0 @@
1
- // SPDX-License-Identifier: MIT
2
-
3
- import { afterEach, describe, expect, it, vi } from "vitest";
4
- import {
5
- PipelineBusImpl,
6
- Route,
7
- type TextToSpeechAudioPacket,
8
- type TextToSpeechEndPacket,
9
- type TtsErrorPacket,
10
- } from "@kuralle-syrinx/core";
11
-
12
- import { OpenAICompatibleTTSPlugin } from "./index.js";
13
-
14
- function startBus(bus: PipelineBusImpl): Promise<void> {
15
- return bus.start();
16
- }
17
-
18
- async function waitForCondition(predicate: () => boolean, timeoutMs = 2000): Promise<void> {
19
- const startedAt = Date.now();
20
- while (Date.now() - startedAt < timeoutMs) {
21
- if (predicate()) return;
22
- await new Promise((resolve) => setTimeout(resolve, 10));
23
- }
24
- throw new Error("Timed out waiting for openai-tts test condition");
25
- }
26
-
27
- /** Minimal mono s16le silence: `sampleCount` samples (`sampleCount * 2` bytes). */
28
- function pcmSilence(sampleCount: number): Uint8Array {
29
- return new Uint8Array(sampleCount * 2);
30
- }
31
-
32
- function streamFromChunks(chunks: Uint8Array[]): ReadableStream<Uint8Array> {
33
- let i = 0;
34
- return new ReadableStream({
35
- pull(controller) {
36
- if (i >= chunks.length) {
37
- controller.close();
38
- return;
39
- }
40
- controller.enqueue(chunks[i]!);
41
- i += 1;
42
- },
43
- });
44
- }
45
-
46
- afterEach(() => {
47
- vi.unstubAllGlobals();
48
- vi.restoreAllMocks();
49
- });
50
-
51
- describe("OpenAICompatibleTTSPlugin", () => {
52
- it("POSTs speech body with stream, pcm, and extra_body merged", async () => {
53
- const fetchMock = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => {
54
- return new Response(streamFromChunks([pcmSilence(8)]), {
55
- status: 200,
56
- headers: { "Content-Type": "application/octet-stream" },
57
- });
58
- });
59
- vi.stubGlobal("fetch", fetchMock);
60
-
61
- const bus = new PipelineBusImpl();
62
- const started = startBus(bus);
63
- const plugin = new OpenAICompatibleTTSPlugin();
64
- const ends: TextToSpeechEndPacket[] = [];
65
- bus.on("tts.end", (pkt) => {
66
- ends.push(pkt as TextToSpeechEndPacket);
67
- });
68
-
69
- await plugin.initialize(bus, {
70
- base_url: "https://zeta.test/v1",
71
- sample_rate: 16000,
72
- extra_body: { task_type: "Base", num_steps: 8 },
73
- });
74
-
75
- bus.push(Route.Main, {
76
- kind: "tts.text",
77
- contextId: "turn-1",
78
- timestampMs: Date.now(),
79
- text: "ආයුබෝවන්",
80
- });
81
- await waitForCondition(() => ends.length >= 1);
82
-
83
- expect(fetchMock).toHaveBeenCalledTimes(1);
84
- const [url, init] = fetchMock.mock.calls[0]!;
85
- expect(String(url)).toBe("https://zeta.test/v1/audio/speech");
86
- expect(init?.method).toBe("POST");
87
- const body = JSON.parse(String(init?.body)) as Record<string, unknown>;
88
- expect(body).toEqual({
89
- model: "gpt-4o-mini-tts",
90
- input: "ආයුබෝවන්",
91
- response_format: "pcm",
92
- stream: true,
93
- task_type: "Base",
94
- num_steps: 8,
95
- });
96
- expect((init?.headers as Record<string, string>)["Content-Type"]).toBe("application/json");
97
-
98
- await plugin.close();
99
- bus.stop();
100
- await started;
101
- });
102
-
103
- it("includes voice in body only when configured", async () => {
104
- const fetchMock = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => {
105
- return new Response(streamFromChunks([pcmSilence(8)]), {
106
- status: 200,
107
- headers: { "Content-Type": "application/octet-stream" },
108
- });
109
- });
110
- vi.stubGlobal("fetch", fetchMock);
111
-
112
- const bus = new PipelineBusImpl();
113
- const started = startBus(bus);
114
- const plugin = new OpenAICompatibleTTSPlugin();
115
- const ends: TextToSpeechEndPacket[] = [];
116
- bus.on("tts.end", (pkt) => {
117
- ends.push(pkt as TextToSpeechEndPacket);
118
- });
119
-
120
- await plugin.initialize(bus, {
121
- base_url: "https://openai.test/v1",
122
- api_key: "sk-test",
123
- voice: "alloy",
124
- sample_rate: 16000,
125
- });
126
-
127
- bus.push(Route.Main, {
128
- kind: "tts.text",
129
- contextId: "turn-voice",
130
- timestampMs: Date.now(),
131
- text: "hello",
132
- });
133
- await waitForCondition(() => ends.length >= 1);
134
-
135
- const [_url, init] = fetchMock.mock.calls[0]!;
136
- const body = JSON.parse(String(init?.body)) as Record<string, unknown>;
137
- expect(body.voice).toBe("alloy");
138
- expect((init?.headers as Record<string, string>)["Authorization"]).toBe("Bearer sk-test");
139
-
140
- await plugin.close();
141
- bus.stop();
142
- await started;
143
- });
144
-
145
- it("omits voice key when voice is not configured", async () => {
146
- const fetchMock = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => {
147
- return new Response(streamFromChunks([pcmSilence(8)]), {
148
- status: 200,
149
- headers: { "Content-Type": "application/octet-stream" },
150
- });
151
- });
152
- vi.stubGlobal("fetch", fetchMock);
153
-
154
- const bus = new PipelineBusImpl();
155
- const started = startBus(bus);
156
- const plugin = new OpenAICompatibleTTSPlugin();
157
- const ends: TextToSpeechEndPacket[] = [];
158
- bus.on("tts.end", (pkt) => {
159
- ends.push(pkt as TextToSpeechEndPacket);
160
- });
161
-
162
- await plugin.initialize(bus, {
163
- base_url: "https://openai.test/v1",
164
- sample_rate: 16000,
165
- });
166
-
167
- bus.push(Route.Main, {
168
- kind: "tts.text",
169
- contextId: "turn-no-voice",
170
- timestampMs: Date.now(),
171
- text: "hi",
172
- });
173
- await waitForCondition(() => ends.length >= 1);
174
-
175
- const [_url, init] = fetchMock.mock.calls[0]!;
176
- const body = JSON.parse(String(init?.body)) as Record<string, unknown>;
177
- expect(body).not.toHaveProperty("voice");
178
-
179
- await plugin.close();
180
- bus.stop();
181
- await started;
182
- });
183
-
184
- it("streams PCM into tts.audio at engine sampleRateHz then tts.end", async () => {
185
- // Two chunks with an odd-byte split so the plugin must carry across frames.
186
- const full = pcmSilence(48); // 96 bytes
187
- const chunkA = full.subarray(0, 15); // odd
188
- const chunkB = full.subarray(15);
189
-
190
- vi.stubGlobal(
191
- "fetch",
192
- vi.fn(async () => {
193
- return new Response(streamFromChunks([chunkA, chunkB]), {
194
- status: 200,
195
- headers: { "Content-Type": "application/octet-stream" },
196
- });
197
- }),
198
- );
199
-
200
- const bus = new PipelineBusImpl();
201
- const started = startBus(bus);
202
- const plugin = new OpenAICompatibleTTSPlugin();
203
- const audio: TextToSpeechAudioPacket[] = [];
204
- const ends: TextToSpeechEndPacket[] = [];
205
- bus.on("tts.audio", (pkt) => {
206
- audio.push(pkt as TextToSpeechAudioPacket);
207
- });
208
- bus.on("tts.end", (pkt) => {
209
- ends.push(pkt as TextToSpeechEndPacket);
210
- });
211
-
212
- await plugin.initialize(bus, {
213
- base_url: "https://openai.test/v1",
214
- sample_rate: 16000,
215
- });
216
-
217
- bus.push(Route.Main, {
218
- kind: "tts.text",
219
- contextId: "turn-2",
220
- timestampMs: Date.now(),
221
- text: "hello",
222
- });
223
- await waitForCondition(() => ends.length >= 1);
224
-
225
- expect(audio.length).toBeGreaterThanOrEqual(1);
226
- for (const pkt of audio) {
227
- expect(pkt.kind).toBe("tts.audio");
228
- expect(pkt.contextId).toBe("turn-2");
229
- expect(pkt.sampleRateHz).toBe(16000);
230
- expect(pkt.audio.byteLength % 2).toBe(0);
231
- expect(pkt.audio.byteLength).toBeGreaterThan(0);
232
- }
233
- expect(ends).toEqual([expect.objectContaining({ kind: "tts.end", contextId: "turn-2" })]);
234
-
235
- await plugin.close();
236
- bus.stop();
237
- await started;
238
- });
239
-
240
- it("source_sample_rate_hz drives the resampler output length", async () => {
241
- // 96 bytes = 48 samples. Different source rates → different resampled lengths.
242
- const pcm48k = pcmSilence(48);
243
-
244
- async function collectAudioBytes(sourceRate: number): Promise<number> {
245
- vi.stubGlobal(
246
- "fetch",
247
- vi.fn(async () => {
248
- return new Response(streamFromChunks([pcm48k]), {
249
- status: 200,
250
- headers: { "Content-Type": "application/octet-stream" },
251
- });
252
- }),
253
- );
254
-
255
- const bus = new PipelineBusImpl();
256
- const started = startBus(bus);
257
- const plugin = new OpenAICompatibleTTSPlugin();
258
- let totalBytes = 0;
259
- const ends: TextToSpeechEndPacket[] = [];
260
- bus.on("tts.audio", (pkt) => {
261
- totalBytes += (pkt as TextToSpeechAudioPacket).audio.byteLength;
262
- });
263
- bus.on("tts.end", (pkt) => {
264
- ends.push(pkt as TextToSpeechEndPacket);
265
- });
266
-
267
- await plugin.initialize(bus, {
268
- base_url: "https://openai.test/v1",
269
- sample_rate: 16000,
270
- source_sample_rate_hz: sourceRate,
271
- });
272
-
273
- bus.push(Route.Main, {
274
- kind: "tts.text",
275
- contextId: `rate-${String(sourceRate)}`,
276
- timestampMs: Date.now(),
277
- text: "test",
278
- });
279
- await waitForCondition(() => ends.length >= 1);
280
-
281
- await plugin.close();
282
- bus.stop();
283
- await started;
284
- vi.unstubAllGlobals();
285
- return totalBytes;
286
- }
287
-
288
- const bytesAt48k = await collectAudioBytes(48_000);
289
- const bytesAt24k = await collectAudioBytes(24_000);
290
- expect(bytesAt48k).toBeGreaterThan(0);
291
- expect(bytesAt24k).toBeGreaterThan(bytesAt48k);
292
- });
293
-
294
- it("tempo: 0.9 produces more tts.audio bytes than tempo: 1.0 for the same PCM stream", async () => {
295
- // ~100 ms of 24 kHz silence → enough samples after resample for WSOLA to stretch.
296
- const pcm = pcmSilence(2400);
297
-
298
- async function collectAudioBytes(tempo: number): Promise<number> {
299
- vi.stubGlobal(
300
- "fetch",
301
- vi.fn(async () => {
302
- return new Response(streamFromChunks([pcm]), {
303
- status: 200,
304
- headers: { "Content-Type": "application/octet-stream" },
305
- });
306
- }),
307
- );
308
-
309
- const bus = new PipelineBusImpl();
310
- const started = startBus(bus);
311
- const plugin = new OpenAICompatibleTTSPlugin();
312
- let totalBytes = 0;
313
- const ends: TextToSpeechEndPacket[] = [];
314
- bus.on("tts.audio", (pkt) => {
315
- totalBytes += (pkt as TextToSpeechAudioPacket).audio.byteLength;
316
- });
317
- bus.on("tts.end", (pkt) => {
318
- ends.push(pkt as TextToSpeechEndPacket);
319
- });
320
-
321
- await plugin.initialize(bus, {
322
- base_url: "https://openai.test/v1",
323
- sample_rate: 16000,
324
- tempo,
325
- });
326
-
327
- bus.push(Route.Main, {
328
- kind: "tts.text",
329
- contextId: `tempo-${String(tempo)}`,
330
- timestampMs: Date.now(),
331
- text: "slow",
332
- });
333
- await waitForCondition(() => ends.length >= 1);
334
-
335
- await plugin.close();
336
- bus.stop();
337
- await started;
338
- vi.unstubAllGlobals();
339
- return totalBytes;
340
- }
341
-
342
- const bytesAt1 = await collectAudioBytes(1.0);
343
- const bytesAt09 = await collectAudioBytes(0.9);
344
- expect(bytesAt1).toBeGreaterThan(0);
345
- expect(bytesAt09).toBeGreaterThan(bytesAt1);
346
- });
347
-
348
- it("maps HTTP 503 to a recoverable tts.error", async () => {
349
- const errorLog = vi.spyOn(console, "error").mockImplementation(() => {});
350
- vi.stubGlobal(
351
- "fetch",
352
- vi.fn(async () => new Response("Service Unavailable", { status: 503 })),
353
- );
354
-
355
- const bus = new PipelineBusImpl();
356
- const started = startBus(bus);
357
- const plugin = new OpenAICompatibleTTSPlugin();
358
- const errors: TtsErrorPacket[] = [];
359
- bus.on("tts.error", (pkt) => {
360
- errors.push(pkt as TtsErrorPacket);
361
- });
362
-
363
- await plugin.initialize(bus, {
364
- base_url: "https://openai.test/v1",
365
- sample_rate: 16000,
366
- });
367
-
368
- bus.push(Route.Main, {
369
- kind: "tts.text",
370
- contextId: "turn-cold",
371
- timestampMs: Date.now(),
372
- text: "cold",
373
- });
374
- await waitForCondition(() => errors.length >= 1);
375
-
376
- expect(errors).toEqual([
377
- expect.objectContaining({
378
- kind: "tts.error",
379
- contextId: "turn-cold",
380
- component: "tts",
381
- isRecoverable: true,
382
- }),
383
- ]);
384
- expect(errorLog).toHaveBeenCalledWith(
385
- expect.stringContaining("[openai-tts] cold start"),
386
- );
387
-
388
- await plugin.close();
389
- bus.stop();
390
- await started;
391
- errorLog.mockRestore();
392
- });
393
- });
package/src/wsola.test.ts DELETED
@@ -1,116 +0,0 @@
1
- // SPDX-License-Identifier: MIT
2
-
3
- import { describe, expect, it } from "vitest";
4
- import { WsolaTimeStretch } from "./wsola.js";
5
-
6
- const SR = 24_000;
7
- const FREQ_HZ = 220;
8
- const DURATION_S = 1;
9
-
10
- function sinePcm16(sampleRateHz: number, freqHz: number, durationS: number): Int16Array {
11
- const n = Math.round(sampleRateHz * durationS);
12
- const out = new Int16Array(n);
13
- const amp = 16000;
14
- for (let i = 0; i < n; i++) {
15
- out[i] = Math.round(amp * Math.sin((2 * Math.PI * freqHz * i) / sampleRateHz));
16
- }
17
- return out;
18
- }
19
-
20
- /** Mean period (samples) between successive rising zero-crossings. */
21
- function meanRisingZeroCrossingPeriod(samples: Int16Array): number {
22
- const crossings: number[] = [];
23
- for (let i = 1; i < samples.length; i++) {
24
- const prev = samples[i - 1]!;
25
- const cur = samples[i]!;
26
- if (prev < 0 && cur >= 0) {
27
- // Linear interpolate fractional index
28
- const frac = prev === cur ? 0 : prev / (prev - cur);
29
- crossings.push(i - 1 + frac);
30
- }
31
- }
32
- if (crossings.length < 2) {
33
- throw new Error(`too few zero-crossings: ${String(crossings.length)}`);
34
- }
35
- let sum = 0;
36
- for (let i = 1; i < crossings.length; i++) {
37
- sum += crossings[i]! - crossings[i - 1]!;
38
- }
39
- return sum / (crossings.length - 1);
40
- }
41
-
42
- function stretchAll(tempo: number, input: Int16Array, sampleRateHz = SR): Int16Array {
43
- const s = new WsolaTimeStretch(tempo, sampleRateHz);
44
- const a = s.process(input);
45
- const b = s.flush();
46
- const out = new Int16Array(a.length + b.length);
47
- out.set(a, 0);
48
- out.set(b, a.length);
49
- return out;
50
- }
51
-
52
- function stretchChunked(tempo: number, input: Int16Array, chunkSize: number, sampleRateHz = SR): Int16Array {
53
- const s = new WsolaTimeStretch(tempo, sampleRateHz);
54
- const parts: Int16Array[] = [];
55
- for (let i = 0; i < input.length; i += chunkSize) {
56
- const chunk = input.subarray(i, Math.min(i + chunkSize, input.length));
57
- const out = s.process(chunk);
58
- if (out.length > 0) parts.push(out);
59
- }
60
- const tail = s.flush();
61
- if (tail.length > 0) parts.push(tail);
62
- let total = 0;
63
- for (const p of parts) total += p.length;
64
- const out = new Int16Array(total);
65
- let off = 0;
66
- for (const p of parts) {
67
- out.set(p, off);
68
- off += p.length;
69
- }
70
- return out;
71
- }
72
-
73
- describe("WsolaTimeStretch", () => {
74
- const input = sinePcm16(SR, FREQ_HZ, DURATION_S);
75
- const frame = Math.round(0.03 * SR);
76
-
77
- it("length scaling: tempo=0.9 lengthens output ≈ input/tempo (±1 frame)", () => {
78
- const out = stretchAll(0.9, input);
79
- const expected = input.length / 0.9;
80
- expect(Math.abs(out.length - expected)).toBeLessThanOrEqual(frame);
81
- });
82
-
83
- it("length scaling: tempo=1.2 shortens output ≈ input/tempo (±1 frame)", () => {
84
- const out = stretchAll(1.2, input);
85
- const expected = input.length / 1.2;
86
- expect(Math.abs(out.length - expected)).toBeLessThanOrEqual(frame);
87
- });
88
-
89
- it("pitch preserved: tempo=0.9 zero-crossing period matches input within 5%", () => {
90
- const out = stretchAll(0.9, input);
91
- // Skip a short edge region where OLA windowing is incomplete
92
- const skip = frame;
93
- const inPeriod = meanRisingZeroCrossingPeriod(input.subarray(skip, input.length - skip));
94
- const outPeriod = meanRisingZeroCrossingPeriod(out.subarray(skip, out.length - skip));
95
- const relErr = Math.abs(outPeriod - inPeriod) / inPeriod;
96
- expect(relErr).toBeLessThanOrEqual(0.05);
97
- });
98
-
99
- it("passthrough: tempo=1.0 returns input samples unchanged", () => {
100
- const s = new WsolaTimeStretch(1.0, SR);
101
- const out = s.process(input);
102
- expect(out.length).toBe(input.length);
103
- expect(out).toBe(input); // same reference — zero-cost passthrough
104
- for (let i = 0; i < input.length; i++) {
105
- expect(out[i]).toBe(input[i]);
106
- }
107
- const tail = s.flush();
108
- expect(tail.length).toBe(0);
109
- });
110
-
111
- it("streaming invariance: one chunk vs many small chunks → same total length (±1 frame)", () => {
112
- const one = stretchAll(0.9, input);
113
- const many = stretchChunked(0.9, input, 64);
114
- expect(Math.abs(one.length - many.length)).toBeLessThanOrEqual(frame);
115
- });
116
- });
package/tsconfig.json DELETED
@@ -1,21 +0,0 @@
1
- {
2
- "compilerOptions": {
3
- "target": "ES2022",
4
- "module": "NodeNext",
5
- "moduleResolution": "NodeNext",
6
- "lib": ["ES2022", "DOM"],
7
- "strict": true,
8
- "noUncheckedIndexedAccess": true,
9
- "noImplicitReturns": true,
10
- "declaration": true,
11
- "declarationMap": true,
12
- "sourceMap": true,
13
- "outDir": "./dist",
14
- "rootDir": "./src",
15
- "esModuleInterop": true,
16
- "skipLibCheck": true,
17
- "forceConsistentCasingInFileNames": true
18
- },
19
- "include": ["src/**/*.ts"],
20
- "exclude": ["node_modules", "dist"]
21
- }