@kuralle-syrinx/openai-tts 4.2.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/LICENSE ADDED
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kuralle
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
package/README.md ADDED
@@ -0,0 +1,73 @@
1
+ # @kuralle-syrinx/openai-tts
2
+
3
+ OpenAI-compatible streaming TTS plugin for Syrinx.
4
+
5
+ Supports any provider that implements the OpenAI `POST /audio/speech` endpoint with `stream: true` and returns raw mono s16le PCM. Configurable `base_url`, `api_key`, `model`, `voice`, `response_format`, `source_sample_rate_hz`, `sample_rate`, `tempo`, and `extra_body`.
6
+
7
+ Includes a built-in WSOLA time-stretcher for pitch-preserving tempo control (e.g. `tempo: 0.9` to slow speech by 10%).
8
+
9
+ ## Usage
10
+
11
+ ### OpenAI (default)
12
+
13
+ ```ts
14
+ import { OpenAICompatibleTTSPlugin } from "@kuralle-syrinx/openai-tts";
15
+
16
+ const plugin = new OpenAICompatibleTTSPlugin();
17
+ session.registerPlugin("tts", plugin);
18
+ ```
19
+
20
+ Configuration:
21
+
22
+ ```ts
23
+ {
24
+ api_key: "sk-...", // or set OPENAI_API_KEY env
25
+ model: "gpt-4o-mini-tts", // default
26
+ voice: "alloy",
27
+ response_format: "pcm", // default
28
+ sample_rate: 16000, // engine rate; default 16000
29
+ tempo: 1.0, // WSOLA stretch; default 1.0
30
+ }
31
+ ```
32
+
33
+ ### Zeta (internal Sinhala TTS)
34
+
35
+ Zeta is used purely via config — no special factory or import.
36
+
37
+ ```ts
38
+ import { OpenAICompatibleTTSPlugin } from "@kuralle-syrinx/openai-tts";
39
+
40
+ const plugin = new OpenAICompatibleTTSPlugin();
41
+ session.registerPlugin("zeta", plugin);
42
+ ```
43
+
44
+ Configuration:
45
+
46
+ ```ts
47
+ {
48
+ base_url: "https://asyncdotengineering--zeta-tts-api-zetattsapi.us-east.modal.direct/v1",
49
+ api_key: "", // Zeta is unauthenticated
50
+ model: "zeta",
51
+ source_sample_rate_hz: 48000, // Zeta outputs 48 kHz PCM
52
+ sample_rate: 24000, // engine rate
53
+ tempo: 0.9, // 10% slower — Zeta prosody rushes
54
+ extra_body: {
55
+ task_type: "Base",
56
+ num_steps: 8,
57
+ },
58
+ }
59
+ ```
60
+
61
+ ## Config reference
62
+
63
+ | Key | Type | Default | Description |
64
+ |---|---|---|---|
65
+ | `base_url` | `string` | `https://api.openai.com/v1` | Provider base URL (must include `/v1`). Falls back to `OPENAI_BASE_URL` env. |
66
+ | `api_key` | `string` | — | Bearer token. Falls back to `OPENAI_API_KEY` env. |
67
+ | `model` | `string` | `gpt-4o-mini-tts` | TTS model name. |
68
+ | `voice` | `string` | — | Voice identifier. Included in the request body only when set. |
69
+ | `response_format` | `string` | `pcm` | Audio format. |
70
+ | `source_sample_rate_hz` | `number` | `24000` | Provider's output PCM sample rate. |
71
+ | `sample_rate` | `number` | `16000` | Engine sample rate to resample to. |
72
+ | `tempo` | `number` | `1.0` | WSOLA time-stretch factor (`0.5`–`1.5`). |
73
+ | `extra_body` | `object` | — | Merged into the request JSON body last (overrides defaults). |
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@kuralle-syrinx/openai-tts",
3
+ "version": "4.2.0",
4
+ "private": false,
5
+ "description": "OpenAI-compatible streaming TTS plugin for Syrinx",
6
+ "keywords": [
7
+ "voice",
8
+ "voice-agent",
9
+ "speech",
10
+ "syrinx",
11
+ "openai",
12
+ "text-to-speech",
13
+ "tts"
14
+ ],
15
+ "license": "MIT",
16
+ "homepage": "https://github.com/kuralle/syrinx#readme",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/kuralle/syrinx.git",
20
+ "directory": "packages/openai-tts"
21
+ },
22
+ "bugs": {
23
+ "url": "https://github.com/kuralle/syrinx/issues"
24
+ },
25
+ "type": "module",
26
+ "main": "./src/index.ts",
27
+ "types": "./src/index.ts",
28
+ "dependencies": {
29
+ "@kuralle-syrinx/core": "4.2.0"
30
+ },
31
+ "devDependencies": {
32
+ "typescript": "^5.7.0",
33
+ "vitest": "^2.1.0"
34
+ },
35
+ "scripts": {
36
+ "typecheck": "tsc --noEmit",
37
+ "test": "vitest run"
38
+ }
39
+ }
@@ -0,0 +1,393 @@
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/index.ts ADDED
@@ -0,0 +1,287 @@
1
+ // SPDX-License-Identifier: MIT
2
+ //
3
+ // Syrinx Kernel v2 — OpenAI-Compatible HTTP Streaming TTS Plugin
4
+ //
5
+ // Generic plugin for any OpenAI-compatible POST /audio/speech endpoint with
6
+ // stream:true returning raw mono s16le PCM. Configurable base_url, api_key,
7
+ // model, voice, response_format, source_sample_rate_hz, sample_rate, tempo,
8
+ // and extra_body. Supports WSOLA time-stretch for tempo control.
9
+
10
+ import type { PipelineBus } from "@kuralle-syrinx/core";
11
+ import {
12
+ Route,
13
+ StreamingPcm16Resampler,
14
+ type PluginConfig,
15
+ type TextToSpeechAudioPacket,
16
+ type TextToSpeechEndPacket,
17
+ type TtsErrorPacket,
18
+ type VoicePlugin,
19
+ categorizeTtsError,
20
+ isRecoverable,
21
+ optionalStringConfig,
22
+ } from "@kuralle-syrinx/core";
23
+
24
+ import { WsolaTimeStretch } from "./wsola.js";
25
+ export { WsolaTimeStretch } from "./wsola.js";
26
+
27
+ const EMPTY = new Uint8Array(0);
28
+ const DEFAULT_BASE_URL = "https://api.openai.com/v1";
29
+ const DEFAULT_MODEL = "gpt-4o-mini-tts";
30
+ const DEFAULT_RESPONSE_FORMAT = "pcm";
31
+ const DEFAULT_SOURCE_SAMPLE_RATE_HZ = 24_000;
32
+ const DEFAULT_ENGINE_RATE_HZ = 16_000;
33
+ const DEFAULT_TEMPO = 1.0;
34
+ const TEMPO_MIN = 0.5;
35
+ const TEMPO_MAX = 1.5;
36
+
37
+ export class OpenAICompatibleTTSPlugin implements VoicePlugin {
38
+ private bus: PipelineBus | null = null;
39
+ private baseUrl = DEFAULT_BASE_URL;
40
+ private apiKey = "";
41
+ private model = DEFAULT_MODEL;
42
+ private voice: string | undefined;
43
+ private responseFormat = DEFAULT_RESPONSE_FORMAT;
44
+ private sourceSampleRateHz = DEFAULT_SOURCE_SAMPLE_RATE_HZ;
45
+ private sampleRate = DEFAULT_ENGINE_RATE_HZ;
46
+ private tempo = DEFAULT_TEMPO;
47
+ private extraBody: Record<string, unknown> | undefined;
48
+ private disposers: Array<() => void> = [];
49
+ private inflight = new Set<AbortController>();
50
+
51
+ async initialize(bus: PipelineBus, config: PluginConfig): Promise<void> {
52
+ this.bus = bus;
53
+ this.baseUrl = stripTrailingSlash(
54
+ optionalStringConfig(config, "base_url") ??
55
+ processEnv("OPENAI_BASE_URL") ??
56
+ DEFAULT_BASE_URL,
57
+ );
58
+ this.apiKey =
59
+ optionalStringConfig(config, "api_key") ?? processEnv("OPENAI_API_KEY") ?? "";
60
+ this.model = optionalStringConfig(config, "model") ?? DEFAULT_MODEL;
61
+ this.voice = optionalStringConfig(config, "voice");
62
+ this.responseFormat = optionalStringConfig(config, "response_format") ?? DEFAULT_RESPONSE_FORMAT;
63
+ this.sourceSampleRateHz = readPositiveInteger(
64
+ config["source_sample_rate_hz"],
65
+ DEFAULT_SOURCE_SAMPLE_RATE_HZ,
66
+ );
67
+ this.sampleRate = readPositiveInteger(config["sample_rate"], DEFAULT_ENGINE_RATE_HZ);
68
+ this.tempo = readClampedTempo(config["tempo"], DEFAULT_TEMPO);
69
+ this.extraBody = readPlainObject(config["extra_body"]);
70
+
71
+ this.disposers.push(
72
+ bus.on("tts.text", async (pkt: unknown) => {
73
+ const textPkt = pkt as { text: string; contextId: string };
74
+ await this.synth(textPkt.text, textPkt.contextId);
75
+ }),
76
+ bus.on("interrupt.tts", () => {
77
+ this.abortInflight();
78
+ }),
79
+ );
80
+ }
81
+
82
+ async close(): Promise<void> {
83
+ this.abortInflight();
84
+ for (const dispose of this.disposers.splice(0)) dispose();
85
+ this.bus = null;
86
+ }
87
+
88
+ private abortInflight(): void {
89
+ for (const controller of this.inflight) {
90
+ controller.abort();
91
+ }
92
+ this.inflight.clear();
93
+ }
94
+
95
+ private async synth(text: string, contextId: string): Promise<void> {
96
+ if (!text.trim()) return;
97
+
98
+ const controller = new AbortController();
99
+ this.inflight.add(controller);
100
+
101
+ const speechUrl = `${this.baseUrl}/audio/speech`;
102
+ const headers: Record<string, string> = {
103
+ "Content-Type": "application/json",
104
+ };
105
+ if (this.apiKey) {
106
+ headers["Authorization"] = `Bearer ${this.apiKey}`;
107
+ }
108
+
109
+ const body: Record<string, unknown> = {
110
+ model: this.model,
111
+ input: text,
112
+ response_format: this.responseFormat,
113
+ stream: true,
114
+ ...this.extraBody,
115
+ };
116
+ if (this.voice !== undefined) {
117
+ body.voice = this.voice;
118
+ }
119
+
120
+ try {
121
+ const response = await fetch(speechUrl, {
122
+ method: "POST",
123
+ headers,
124
+ body: JSON.stringify(body),
125
+ signal: controller.signal,
126
+ });
127
+
128
+ if (!response.ok) {
129
+ if (response.status === 503) {
130
+ console.error(
131
+ "[openai-tts] cold start (HTTP 503) — provider may be warming up; keep-warm required for production",
132
+ );
133
+ }
134
+ const err = Object.assign(
135
+ new Error(
136
+ `OpenAI-compatible TTS HTTP ${String(response.status)}: ${response.statusText || "request failed"}`,
137
+ ),
138
+ { status: response.status },
139
+ );
140
+ this.emitError(contextId, err);
141
+ return;
142
+ }
143
+
144
+ const responseBody = response.body;
145
+ if (!responseBody) {
146
+ this.emitError(contextId, new Error("OpenAI-compatible TTS response body is null"));
147
+ return;
148
+ }
149
+
150
+ const reader = responseBody.getReader();
151
+ const resampler = new StreamingPcm16Resampler(this.sourceSampleRateHz, this.sampleRate);
152
+ const stretch =
153
+ Math.abs(this.tempo - 1) >= 1e-6
154
+ ? new WsolaTimeStretch(this.tempo, this.sampleRate)
155
+ : null;
156
+ let carry: Uint8Array = EMPTY;
157
+
158
+ while (true) {
159
+ if (controller.signal.aborted) return;
160
+ const { done, value } = await reader.read();
161
+ if (done) break;
162
+ if (!value || value.byteLength === 0) continue;
163
+
164
+ const frame = value instanceof Uint8Array ? value : new Uint8Array(value);
165
+ const buf = carry.byteLength === 0 ? frame : concatBytes(carry, frame);
166
+ const evenLen = buf.byteLength - (buf.byteLength % 2);
167
+ if (evenLen > 0) {
168
+ const pcmBytes = buf.subarray(0, evenLen);
169
+ const samples = bytesToInt16LE(pcmBytes);
170
+ const resampled = resampler.process(samples);
171
+ const stretched = stretch ? stretch.process(resampled) : resampled;
172
+ if (stretched.length > 0) {
173
+ this.emitAudio(contextId, stretched);
174
+ }
175
+ }
176
+ carry = evenLen < buf.byteLength ? buf.subarray(evenLen) : EMPTY;
177
+ }
178
+
179
+ if (controller.signal.aborted) return;
180
+ if (stretch) {
181
+ const tail = stretch.flush();
182
+ if (tail.length > 0) {
183
+ this.emitAudio(contextId, tail);
184
+ }
185
+ }
186
+ this.emitEnd(contextId);
187
+ } catch (err) {
188
+ if (isAbortError(err) || controller.signal.aborted) return;
189
+ this.emitError(contextId, err instanceof Error ? err : new Error(String(err)));
190
+ } finally {
191
+ this.inflight.delete(controller);
192
+ }
193
+ }
194
+
195
+ private emitError(contextId: string, err: Error): void {
196
+ const category = categorizeTtsError(err);
197
+ const packet: TtsErrorPacket = {
198
+ kind: "tts.error",
199
+ contextId,
200
+ timestampMs: Date.now(),
201
+ component: "tts",
202
+ category,
203
+ cause: err,
204
+ isRecoverable: isRecoverable(category),
205
+ };
206
+ this.bus?.push(Route.Critical, packet);
207
+ }
208
+
209
+ private emitAudio(contextId: string, samples: Int16Array): void {
210
+ const audio = int16ToBytes(samples);
211
+ const packet: TextToSpeechAudioPacket = {
212
+ kind: "tts.audio",
213
+ contextId,
214
+ timestampMs: Date.now(),
215
+ audio,
216
+ sampleRateHz: this.sampleRate,
217
+ provider: { name: "openai", model: this.model, cancelled: false },
218
+ };
219
+ this.bus?.push(Route.Main, packet);
220
+ }
221
+
222
+ private emitEnd(contextId: string): void {
223
+ this.bus?.push(Route.Main, {
224
+ kind: "tts.end",
225
+ contextId,
226
+ timestampMs: Date.now(),
227
+ } satisfies TextToSpeechEndPacket);
228
+ }
229
+ }
230
+
231
+ function processEnv(key: string): string | undefined {
232
+ try {
233
+ const value = (globalThis as { process?: { env?: Record<string, string | undefined> } }).process
234
+ ?.env?.[key];
235
+ return typeof value === "string" && value.trim().length > 0 ? value : undefined;
236
+ } catch {
237
+ return undefined;
238
+ }
239
+ }
240
+
241
+ function stripTrailingSlash(url: string): string {
242
+ return url.endsWith("/") ? url.slice(0, -1) : url;
243
+ }
244
+
245
+ function readPositiveInteger(value: unknown, fallback: number): number {
246
+ if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
247
+ const integer = Math.floor(value);
248
+ return integer > 0 ? integer : fallback;
249
+ }
250
+
251
+ function readClampedTempo(value: unknown, fallback: number): number {
252
+ if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
253
+ if (value < TEMPO_MIN) return TEMPO_MIN;
254
+ if (value > TEMPO_MAX) return TEMPO_MAX;
255
+ return value;
256
+ }
257
+
258
+ function readPlainObject(value: unknown): Record<string, unknown> | undefined {
259
+ if (value !== null && typeof value === "object" && !Array.isArray(value)) {
260
+ return value as Record<string, unknown>;
261
+ }
262
+ return undefined;
263
+ }
264
+
265
+ function concatBytes(a: Uint8Array, b: Uint8Array): Uint8Array {
266
+ const out = new Uint8Array(a.byteLength + b.byteLength);
267
+ out.set(a, 0);
268
+ out.set(b, a.byteLength);
269
+ return out;
270
+ }
271
+
272
+ /** Copy into an owned ArrayBuffer so Int16Array is always 2-byte aligned. */
273
+ function bytesToInt16LE(bytes: Uint8Array): Int16Array {
274
+ const ab = new ArrayBuffer(bytes.byteLength);
275
+ new Uint8Array(ab).set(bytes);
276
+ return new Int16Array(ab);
277
+ }
278
+
279
+ function int16ToBytes(samples: Int16Array): Uint8Array {
280
+ return new Uint8Array(samples.buffer, samples.byteOffset, samples.byteLength);
281
+ }
282
+
283
+ function isAbortError(err: unknown): boolean {
284
+ if (!err || typeof err !== "object") return false;
285
+ const name = (err as { name?: unknown }).name;
286
+ return name === "AbortError" || name === "TimeoutError";
287
+ }
@@ -0,0 +1,116 @@
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/src/wsola.ts ADDED
@@ -0,0 +1,274 @@
1
+ // SPDX-License-Identifier: MIT
2
+ //
3
+ // Streaming WSOLA (Waveform Similarity Overlap-Add) time-stretch.
4
+ // Pitch-preserving tempo control: tempo < 1 slows, > 1 speeds, 1.0 passthrough.
5
+ // Matches the intent of ffmpeg atempo without an ffmpeg runtime dependency.
6
+
7
+ const PASSTHROUGH_EPS = 1e-6;
8
+ const FRAME_MS = 0.03;
9
+ const SEARCH_MS = 0.01;
10
+ const INT16_MIN = -32768;
11
+ const INT16_MAX = 32767;
12
+
13
+ export class WsolaTimeStretch {
14
+ private readonly passthrough: boolean;
15
+ private readonly frame: number;
16
+ private readonly hs: number;
17
+ private readonly ha: number;
18
+ private readonly searchRadius: number;
19
+ private readonly window: Float64Array;
20
+
21
+ /** Accumulated input samples (float). */
22
+ private input: Float64Array = new Float64Array(0);
23
+ private inputLen = 0;
24
+ /** Absolute sample index of input[0] in the stream. */
25
+ private inputOrigin = 0;
26
+
27
+ /** Overlap-add synthesis buffer (float). */
28
+ private ola: Float64Array = new Float64Array(0);
29
+ private olaLen = 0;
30
+ /** Absolute synthesis index of ola[0]. */
31
+ private olaOrigin = 0;
32
+ /**
33
+ * Absolute synthesis index up to which samples are final (no further OLA).
34
+ * After placing a frame at absSynth, samples in [0, absSynth) are complete.
35
+ */
36
+ private emitThrough = 0;
37
+
38
+ /** Ideal analysis position (absolute input sample index). */
39
+ private analysisPos = 0;
40
+ /** Absolute synthesis index for the next frame. */
41
+ private synthPos = 0;
42
+ private havePrev = false;
43
+ /** Natural continuation of previous frame (hs samples) for waveform matching. */
44
+ private prevContinuation: Float64Array;
45
+
46
+ constructor(tempo: number, sampleRateHz: number) {
47
+ if (!(sampleRateHz > 0) || !Number.isFinite(sampleRateHz)) {
48
+ throw new RangeError(`WsolaTimeStretch: invalid sampleRateHz ${String(sampleRateHz)}`);
49
+ }
50
+ if (!Number.isFinite(tempo) || tempo <= 0) {
51
+ throw new RangeError(`WsolaTimeStretch: invalid tempo ${String(tempo)}`);
52
+ }
53
+
54
+ this.passthrough = Math.abs(tempo - 1) < PASSTHROUGH_EPS;
55
+ this.frame = Math.max(2, Math.round(FRAME_MS * sampleRateHz));
56
+ this.hs = Math.max(1, Math.floor(this.frame / 2));
57
+ this.ha = Math.max(1, Math.round(this.hs * tempo));
58
+ this.searchRadius = Math.max(0, Math.round(SEARCH_MS * sampleRateHz));
59
+ this.window = hann(this.frame);
60
+ this.prevContinuation = new Float64Array(this.hs);
61
+ }
62
+
63
+ process(input: Int16Array): Int16Array {
64
+ if (this.passthrough) return input;
65
+ if (input.length > 0) this.appendInput(input);
66
+ return this.extractReady(false);
67
+ }
68
+
69
+ flush(): Int16Array {
70
+ if (this.passthrough) return new Int16Array(0);
71
+ return this.extractReady(true);
72
+ }
73
+
74
+ private appendInput(samples: Int16Array): void {
75
+ this.ensureInputCapacity(this.inputLen + samples.length);
76
+ for (let i = 0; i < samples.length; i++) {
77
+ this.input[this.inputLen + i] = samples[i]!;
78
+ }
79
+ this.inputLen += samples.length;
80
+ }
81
+
82
+ private extractReady(flushing: boolean): Int16Array {
83
+ while (this.tryAddFrame(flushing)) {
84
+ // keep consuming frames while input allows
85
+ }
86
+
87
+ if (flushing) {
88
+ this.emitThrough = this.olaOrigin + this.olaLen;
89
+ }
90
+
91
+ const ready = this.emitThrough - this.olaOrigin;
92
+ if (ready <= 0) return new Int16Array(0);
93
+
94
+ const out = new Int16Array(ready);
95
+ for (let i = 0; i < ready; i++) {
96
+ out[i] = clampInt16(this.ola[i]!);
97
+ }
98
+
99
+ const remaining = this.olaLen - ready;
100
+ if (remaining > 0) {
101
+ this.ola.copyWithin(0, ready, this.olaLen);
102
+ }
103
+ this.ola.fill(0, remaining, this.olaLen);
104
+ this.olaLen = remaining;
105
+ this.olaOrigin += ready;
106
+
107
+ if (flushing) {
108
+ this.inputLen = 0;
109
+ this.inputOrigin = this.analysisPos;
110
+ } else {
111
+ this.compactInput();
112
+ }
113
+
114
+ return out;
115
+ }
116
+
117
+ /**
118
+ * Place one more synthesis frame via WSOLA.
119
+ * @returns false when more input is needed or the stream is exhausted.
120
+ */
121
+ private tryAddFrame(flushing: boolean): boolean {
122
+ const frame = this.frame;
123
+ const hs = this.hs;
124
+ const search = this.searchRadius;
125
+ const inputEnd = this.inputOrigin + this.inputLen;
126
+
127
+ if (!this.havePrev) {
128
+ if (inputEnd < frame) return false;
129
+ const localStart = 0;
130
+ this.olaAddFrame(localStart, 0);
131
+ this.storeContinuation(localStart);
132
+ this.havePrev = true;
133
+ this.analysisPos = this.ha;
134
+ this.synthPos = hs;
135
+ // Samples [0, hs) still receive the next frame's OLA contribution.
136
+ this.emitThrough = 0;
137
+ return true;
138
+ }
139
+
140
+ // Need a full frame at some position in [ideal - search, ideal + search].
141
+ const ideal = this.analysisPos;
142
+ if (!flushing) {
143
+ // Require full right-side search room so offsets are unbiased while streaming.
144
+ if (inputEnd < ideal + frame + search) return false;
145
+ } else {
146
+ // On flush, accept any position that can hold a frame near the ideal.
147
+ if (inputEnd < ideal - search + frame && inputEnd < frame) return false;
148
+ if (inputEnd - this.inputOrigin < frame) return false;
149
+ // If even the earliest searchable start cannot fit a frame, stop.
150
+ const latestStart = inputEnd - frame;
151
+ if (latestStart < Math.max(this.inputOrigin, ideal - search)) return false;
152
+ }
153
+
154
+ const bestLocal = this.findBestOffset(ideal);
155
+ const absSynth = this.synthPos;
156
+ this.olaAddFrame(bestLocal, absSynth);
157
+ this.storeContinuation(bestLocal);
158
+ this.analysisPos = ideal + this.ha;
159
+ this.synthPos = absSynth + hs;
160
+ // After placing frame at absSynth, no future frame touches [0, absSynth).
161
+ this.emitThrough = absSynth;
162
+ return true;
163
+ }
164
+
165
+ private findBestOffset(idealAbs: number): number {
166
+ const frame = this.frame;
167
+ const hs = this.hs;
168
+ const search = this.searchRadius;
169
+ const inputEnd = this.inputOrigin + this.inputLen;
170
+ const earliest = Math.max(this.inputOrigin, idealAbs - search);
171
+ const latest = Math.min(inputEnd - frame, idealAbs + search);
172
+
173
+ if (latest < earliest) {
174
+ const fallbackAbs = Math.min(
175
+ Math.max(idealAbs, this.inputOrigin),
176
+ Math.max(this.inputOrigin, inputEnd - frame),
177
+ );
178
+ return fallbackAbs - this.inputOrigin;
179
+ }
180
+
181
+ let bestLocal = earliest - this.inputOrigin;
182
+ let bestScore = -Infinity;
183
+
184
+ for (let abs = earliest; abs <= latest; abs++) {
185
+ const local = abs - this.inputOrigin;
186
+ let score = 0;
187
+ for (let i = 0; i < hs; i++) {
188
+ score += this.input[local + i]! * this.prevContinuation[i]!;
189
+ }
190
+ if (score > bestScore) {
191
+ bestScore = score;
192
+ bestLocal = local;
193
+ }
194
+ }
195
+ return bestLocal;
196
+ }
197
+
198
+ private olaAddFrame(localStart: number, absSynth: number): void {
199
+ const frame = this.frame;
200
+ const localOla = absSynth - this.olaOrigin;
201
+ const needLen = localOla + frame;
202
+ this.ensureOlaCapacity(needLen);
203
+ if (needLen > this.olaLen) {
204
+ if (this.olaLen < localOla) {
205
+ this.ola.fill(0, this.olaLen, localOla);
206
+ }
207
+ this.olaLen = needLen;
208
+ }
209
+ for (let i = 0; i < frame; i++) {
210
+ const sample = localStart + i < this.inputLen ? this.input[localStart + i]! : 0;
211
+ this.ola[localOla + i]! += sample * this.window[i]!;
212
+ }
213
+ }
214
+
215
+ private storeContinuation(localStart: number): void {
216
+ const hs = this.hs;
217
+ for (let i = 0; i < hs; i++) {
218
+ const idx = localStart + hs + i;
219
+ this.prevContinuation[i] = idx < this.inputLen ? this.input[idx]! : 0;
220
+ }
221
+ }
222
+
223
+ private compactInput(): void {
224
+ const keepFromAbs = Math.max(0, this.analysisPos - this.searchRadius);
225
+ if (keepFromAbs <= this.inputOrigin) return;
226
+ const drop = keepFromAbs - this.inputOrigin;
227
+ if (drop <= 0) return;
228
+ if (drop >= this.inputLen) {
229
+ this.inputLen = 0;
230
+ this.inputOrigin = keepFromAbs;
231
+ return;
232
+ }
233
+ this.input.copyWithin(0, drop, this.inputLen);
234
+ this.inputLen -= drop;
235
+ this.inputOrigin = keepFromAbs;
236
+ }
237
+
238
+ private ensureInputCapacity(needed: number): void {
239
+ if (needed <= this.input.length) return;
240
+ const cap = Math.max(needed, this.input.length > 0 ? this.input.length * 2 : 1024);
241
+ const next = new Float64Array(cap);
242
+ next.set(this.input.subarray(0, this.inputLen));
243
+ this.input = next;
244
+ }
245
+
246
+ private ensureOlaCapacity(needed: number): void {
247
+ if (needed <= this.ola.length) return;
248
+ const cap = Math.max(needed, this.ola.length > 0 ? this.ola.length * 2 : 1024);
249
+ const next = new Float64Array(cap);
250
+ next.set(this.ola.subarray(0, this.olaLen));
251
+ this.ola = next;
252
+ }
253
+ }
254
+
255
+ /** Symmetric Hann window; 50% overlap is COLA (overlapping windows sum ≈ 1). */
256
+ function hann(n: number): Float64Array {
257
+ const w = new Float64Array(n);
258
+ if (n === 1) {
259
+ w[0] = 1;
260
+ return w;
261
+ }
262
+ const denom = n - 1;
263
+ for (let i = 0; i < n; i++) {
264
+ w[i] = 0.5 * (1 - Math.cos((2 * Math.PI * i) / denom));
265
+ }
266
+ return w;
267
+ }
268
+
269
+ function clampInt16(x: number): number {
270
+ const r = Math.round(x);
271
+ if (r < INT16_MIN) return INT16_MIN;
272
+ if (r > INT16_MAX) return INT16_MAX;
273
+ return r;
274
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,21 @@
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
+ }