@kuralle-syrinx/openai-tts 4.4.0 → 4.5.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.
@@ -0,0 +1,27 @@
1
+ import type { PipelineBus } from "@kuralle-syrinx/core";
2
+ import { type PluginConfig, type VoicePlugin } from "@kuralle-syrinx/core";
3
+ export { WsolaTimeStretch } from "./wsola.js";
4
+ export declare class OpenAICompatibleTTSPlugin implements VoicePlugin {
5
+ private bus;
6
+ private baseUrl;
7
+ private apiKey;
8
+ private model;
9
+ private voice;
10
+ private responseFormat;
11
+ private sourceSampleRateHz;
12
+ private sampleRate;
13
+ private tempo;
14
+ private extraBody;
15
+ private disposers;
16
+ private inflight;
17
+ initialize(bus: PipelineBus, config: PluginConfig): Promise<void>;
18
+ close(): Promise<void>;
19
+ prewarm(): Promise<void>;
20
+ private abortInflight;
21
+ private synth;
22
+ private emitError;
23
+ private emitAudio;
24
+ private emitUsage;
25
+ private emitEnd;
26
+ }
27
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AASA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,sBAAsB,CAAC;AACxD,OAAO,EAGL,KAAK,YAAY,EAIjB,KAAK,WAAW,EAIjB,MAAM,sBAAsB,CAAC;AAG9B,OAAO,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC;AAY9C,qBAAa,yBAA0B,YAAW,WAAW;IAC3D,OAAO,CAAC,GAAG,CAA4B;IACvC,OAAO,CAAC,OAAO,CAAoB;IACnC,OAAO,CAAC,MAAM,CAAM;IACpB,OAAO,CAAC,KAAK,CAAiB;IAC9B,OAAO,CAAC,KAAK,CAAqB;IAClC,OAAO,CAAC,cAAc,CAA2B;IACjD,OAAO,CAAC,kBAAkB,CAAiC;IAC3D,OAAO,CAAC,UAAU,CAA0B;IAC5C,OAAO,CAAC,KAAK,CAAiB;IAC9B,OAAO,CAAC,SAAS,CAAsC;IACvD,OAAO,CAAC,SAAS,CAAyB;IAC1C,OAAO,CAAC,QAAQ,CAA8B;IAExC,UAAU,CAAC,GAAG,EAAE,WAAW,EAAE,MAAM,EAAE,YAAY,GAAG,OAAO,CAAC,IAAI,CAAC;IA+BjE,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAMtB,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC;IAoB9B,OAAO,CAAC,aAAa;YAOP,KAAK;IAqGnB,OAAO,CAAC,SAAS;IAcjB,OAAO,CAAC,SAAS;IAajB,OAAO,CAAC,SAAS;IAajB,OAAO,CAAC,OAAO;CAOhB"}
package/dist/index.js ADDED
@@ -0,0 +1,279 @@
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
+ import { Route, StreamingPcm16Resampler, categorizeTtsError, isRecoverable, optionalStringConfig, } from "@kuralle-syrinx/core";
10
+ import { WsolaTimeStretch } from "./wsola.js";
11
+ export { WsolaTimeStretch } from "./wsola.js";
12
+ const EMPTY = new Uint8Array(0);
13
+ const DEFAULT_BASE_URL = "https://api.openai.com/v1";
14
+ const DEFAULT_MODEL = "gpt-4o-mini-tts";
15
+ const DEFAULT_RESPONSE_FORMAT = "pcm";
16
+ const DEFAULT_SOURCE_SAMPLE_RATE_HZ = 24_000;
17
+ const DEFAULT_ENGINE_RATE_HZ = 16_000;
18
+ const DEFAULT_TEMPO = 1.0;
19
+ const TEMPO_MIN = 0.5;
20
+ const TEMPO_MAX = 1.5;
21
+ export class OpenAICompatibleTTSPlugin {
22
+ bus = null;
23
+ baseUrl = DEFAULT_BASE_URL;
24
+ apiKey = "";
25
+ model = DEFAULT_MODEL;
26
+ voice;
27
+ responseFormat = DEFAULT_RESPONSE_FORMAT;
28
+ sourceSampleRateHz = DEFAULT_SOURCE_SAMPLE_RATE_HZ;
29
+ sampleRate = DEFAULT_ENGINE_RATE_HZ;
30
+ tempo = DEFAULT_TEMPO;
31
+ extraBody;
32
+ disposers = [];
33
+ inflight = new Set();
34
+ async initialize(bus, config) {
35
+ this.bus = bus;
36
+ this.baseUrl = stripTrailingSlash(optionalStringConfig(config, "base_url") ??
37
+ processEnv("OPENAI_BASE_URL") ??
38
+ DEFAULT_BASE_URL);
39
+ this.apiKey =
40
+ optionalStringConfig(config, "api_key") ?? processEnv("OPENAI_API_KEY") ?? "";
41
+ this.model = optionalStringConfig(config, "model") ?? DEFAULT_MODEL;
42
+ this.voice = optionalStringConfig(config, "voice");
43
+ this.responseFormat = optionalStringConfig(config, "response_format") ?? DEFAULT_RESPONSE_FORMAT;
44
+ this.sourceSampleRateHz = readPositiveInteger(config["source_sample_rate_hz"], DEFAULT_SOURCE_SAMPLE_RATE_HZ);
45
+ this.sampleRate = readPositiveInteger(config["sample_rate"], DEFAULT_ENGINE_RATE_HZ);
46
+ this.tempo = readClampedTempo(config["tempo"], DEFAULT_TEMPO);
47
+ this.extraBody = readPlainObject(config["extra_body"]);
48
+ this.disposers.push(bus.on("tts.text", async (pkt) => {
49
+ const textPkt = pkt;
50
+ await this.synth(textPkt.text, textPkt.contextId);
51
+ }), bus.on("interrupt.tts", () => {
52
+ this.abortInflight();
53
+ }));
54
+ }
55
+ async close() {
56
+ this.abortInflight();
57
+ for (const dispose of this.disposers.splice(0))
58
+ dispose();
59
+ this.bus = null;
60
+ }
61
+ async prewarm() {
62
+ const controller = new AbortController();
63
+ const timeout = setTimeout(() => controller.abort(), 8_000);
64
+ try {
65
+ const headers = {};
66
+ if (this.apiKey) {
67
+ headers["Authorization"] = `Bearer ${this.apiKey}`;
68
+ }
69
+ await fetch(`${this.baseUrl}/models`, {
70
+ method: "GET",
71
+ headers,
72
+ signal: controller.signal,
73
+ });
74
+ }
75
+ catch {
76
+ // best-effort
77
+ }
78
+ finally {
79
+ clearTimeout(timeout);
80
+ }
81
+ }
82
+ abortInflight() {
83
+ for (const controller of this.inflight) {
84
+ controller.abort();
85
+ }
86
+ this.inflight.clear();
87
+ }
88
+ async synth(text, contextId) {
89
+ if (!text.trim())
90
+ return;
91
+ const controller = new AbortController();
92
+ this.inflight.add(controller);
93
+ const speechUrl = `${this.baseUrl}/audio/speech`;
94
+ const headers = {
95
+ "Content-Type": "application/json",
96
+ };
97
+ if (this.apiKey) {
98
+ headers["Authorization"] = `Bearer ${this.apiKey}`;
99
+ }
100
+ const body = {
101
+ model: this.model,
102
+ input: text,
103
+ response_format: this.responseFormat,
104
+ stream: true,
105
+ ...this.extraBody,
106
+ };
107
+ if (this.voice !== undefined) {
108
+ body.voice = this.voice;
109
+ }
110
+ try {
111
+ const response = await fetch(speechUrl, {
112
+ method: "POST",
113
+ headers,
114
+ body: JSON.stringify(body),
115
+ signal: controller.signal,
116
+ });
117
+ if (!response.ok) {
118
+ if (response.status === 503) {
119
+ console.error("[openai-tts] cold start (HTTP 503) — provider may be warming up; keep-warm required for production");
120
+ }
121
+ const err = Object.assign(new Error(`OpenAI-compatible TTS HTTP ${String(response.status)}: ${response.statusText || "request failed"}`), { status: response.status });
122
+ this.emitError(contextId, err);
123
+ return;
124
+ }
125
+ const responseBody = response.body;
126
+ if (!responseBody) {
127
+ this.emitError(contextId, new Error("OpenAI-compatible TTS response body is null"));
128
+ return;
129
+ }
130
+ const reader = responseBody.getReader();
131
+ const resampler = new StreamingPcm16Resampler(this.sourceSampleRateHz, this.sampleRate);
132
+ const stretch = Math.abs(this.tempo - 1) >= 1e-6
133
+ ? new WsolaTimeStretch(this.tempo, this.sampleRate)
134
+ : null;
135
+ let carry = EMPTY;
136
+ while (true) {
137
+ if (controller.signal.aborted)
138
+ return;
139
+ const { done, value } = await reader.read();
140
+ if (done)
141
+ break;
142
+ if (!value || value.byteLength === 0)
143
+ continue;
144
+ const frame = value instanceof Uint8Array ? value : new Uint8Array(value);
145
+ const buf = carry.byteLength === 0 ? frame : concatBytes(carry, frame);
146
+ const evenLen = buf.byteLength - (buf.byteLength % 2);
147
+ if (evenLen > 0) {
148
+ const pcmBytes = buf.subarray(0, evenLen);
149
+ const samples = bytesToInt16LE(pcmBytes);
150
+ const resampled = resampler.process(samples);
151
+ const stretched = stretch ? stretch.process(resampled) : resampled;
152
+ if (stretched.length > 0) {
153
+ this.emitAudio(contextId, stretched);
154
+ }
155
+ }
156
+ carry = evenLen < buf.byteLength ? buf.subarray(evenLen) : EMPTY;
157
+ }
158
+ if (controller.signal.aborted)
159
+ return;
160
+ if (stretch) {
161
+ const tail = stretch.flush();
162
+ if (tail.length > 0) {
163
+ this.emitAudio(contextId, tail);
164
+ }
165
+ }
166
+ this.emitUsage(contextId, text.length);
167
+ this.emitEnd(contextId);
168
+ }
169
+ catch (err) {
170
+ if (isAbortError(err) || controller.signal.aborted)
171
+ return;
172
+ this.emitError(contextId, err instanceof Error ? err : new Error(String(err)));
173
+ }
174
+ finally {
175
+ this.inflight.delete(controller);
176
+ }
177
+ }
178
+ emitError(contextId, err) {
179
+ const category = categorizeTtsError(err);
180
+ const packet = {
181
+ kind: "tts.error",
182
+ contextId,
183
+ timestampMs: Date.now(),
184
+ component: "tts",
185
+ category,
186
+ cause: err,
187
+ isRecoverable: isRecoverable(category),
188
+ };
189
+ this.bus?.push(Route.Critical, packet);
190
+ }
191
+ emitAudio(contextId, samples) {
192
+ const audio = int16ToBytes(samples);
193
+ const packet = {
194
+ kind: "tts.audio",
195
+ contextId,
196
+ timestampMs: Date.now(),
197
+ audio,
198
+ sampleRateHz: this.sampleRate,
199
+ provider: { name: "openai", model: this.model, cancelled: false },
200
+ };
201
+ this.bus?.push(Route.Main, packet);
202
+ }
203
+ emitUsage(contextId, characters) {
204
+ if (characters <= 0)
205
+ return;
206
+ this.bus?.push(Route.Background, {
207
+ kind: "usage.recorded",
208
+ contextId,
209
+ timestampMs: Date.now(),
210
+ stage: "tts",
211
+ provider: "openai",
212
+ model: this.model,
213
+ characters,
214
+ });
215
+ }
216
+ emitEnd(contextId) {
217
+ this.bus?.push(Route.Main, {
218
+ kind: "tts.end",
219
+ contextId,
220
+ timestampMs: Date.now(),
221
+ });
222
+ }
223
+ }
224
+ function processEnv(key) {
225
+ try {
226
+ const value = globalThis.process
227
+ ?.env?.[key];
228
+ return typeof value === "string" && value.trim().length > 0 ? value : undefined;
229
+ }
230
+ catch {
231
+ return undefined;
232
+ }
233
+ }
234
+ function stripTrailingSlash(url) {
235
+ return url.endsWith("/") ? url.slice(0, -1) : url;
236
+ }
237
+ function readPositiveInteger(value, fallback) {
238
+ if (typeof value !== "number" || !Number.isFinite(value))
239
+ return fallback;
240
+ const integer = Math.floor(value);
241
+ return integer > 0 ? integer : fallback;
242
+ }
243
+ function readClampedTempo(value, fallback) {
244
+ if (typeof value !== "number" || !Number.isFinite(value))
245
+ return fallback;
246
+ if (value < TEMPO_MIN)
247
+ return TEMPO_MIN;
248
+ if (value > TEMPO_MAX)
249
+ return TEMPO_MAX;
250
+ return value;
251
+ }
252
+ function readPlainObject(value) {
253
+ if (value !== null && typeof value === "object" && !Array.isArray(value)) {
254
+ return value;
255
+ }
256
+ return undefined;
257
+ }
258
+ function concatBytes(a, b) {
259
+ const out = new Uint8Array(a.byteLength + b.byteLength);
260
+ out.set(a, 0);
261
+ out.set(b, a.byteLength);
262
+ return out;
263
+ }
264
+ /** Copy into an owned ArrayBuffer so Int16Array is always 2-byte aligned. */
265
+ function bytesToInt16LE(bytes) {
266
+ const ab = new ArrayBuffer(bytes.byteLength);
267
+ new Uint8Array(ab).set(bytes);
268
+ return new Int16Array(ab);
269
+ }
270
+ function int16ToBytes(samples) {
271
+ return new Uint8Array(samples.buffer, samples.byteOffset, samples.byteLength);
272
+ }
273
+ function isAbortError(err) {
274
+ if (!err || typeof err !== "object")
275
+ return false;
276
+ const name = err.name;
277
+ return name === "AbortError" || name === "TimeoutError";
278
+ }
279
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,47 @@
1
+ export declare class WsolaTimeStretch {
2
+ private readonly passthrough;
3
+ private readonly frame;
4
+ private readonly hs;
5
+ private readonly ha;
6
+ private readonly searchRadius;
7
+ private readonly window;
8
+ /** Accumulated input samples (float). */
9
+ private input;
10
+ private inputLen;
11
+ /** Absolute sample index of input[0] in the stream. */
12
+ private inputOrigin;
13
+ /** Overlap-add synthesis buffer (float). */
14
+ private ola;
15
+ private olaLen;
16
+ /** Absolute synthesis index of ola[0]. */
17
+ private olaOrigin;
18
+ /**
19
+ * Absolute synthesis index up to which samples are final (no further OLA).
20
+ * After placing a frame at absSynth, samples in [0, absSynth) are complete.
21
+ */
22
+ private emitThrough;
23
+ /** Ideal analysis position (absolute input sample index). */
24
+ private analysisPos;
25
+ /** Absolute synthesis index for the next frame. */
26
+ private synthPos;
27
+ private havePrev;
28
+ /** Natural continuation of previous frame (hs samples) for waveform matching. */
29
+ private prevContinuation;
30
+ constructor(tempo: number, sampleRateHz: number);
31
+ process(input: Int16Array): Int16Array;
32
+ flush(): Int16Array;
33
+ private appendInput;
34
+ private extractReady;
35
+ /**
36
+ * Place one more synthesis frame via WSOLA.
37
+ * @returns false when more input is needed or the stream is exhausted.
38
+ */
39
+ private tryAddFrame;
40
+ private findBestOffset;
41
+ private olaAddFrame;
42
+ private storeContinuation;
43
+ private compactInput;
44
+ private ensureInputCapacity;
45
+ private ensureOlaCapacity;
46
+ }
47
+ //# sourceMappingURL=wsola.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"wsola.d.ts","sourceRoot":"","sources":["../src/wsola.ts"],"names":[],"mappings":"AAYA,qBAAa,gBAAgB;IAC3B,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAU;IACtC,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAS;IAC/B,OAAO,CAAC,QAAQ,CAAC,EAAE,CAAS;IAC5B,OAAO,CAAC,QAAQ,CAAC,EAAE,CAAS;IAC5B,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAS;IACtC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAe;IAEtC,yCAAyC;IACzC,OAAO,CAAC,KAAK,CAAqC;IAClD,OAAO,CAAC,QAAQ,CAAK;IACrB,uDAAuD;IACvD,OAAO,CAAC,WAAW,CAAK;IAExB,4CAA4C;IAC5C,OAAO,CAAC,GAAG,CAAqC;IAChD,OAAO,CAAC,MAAM,CAAK;IACnB,0CAA0C;IAC1C,OAAO,CAAC,SAAS,CAAK;IACtB;;;OAGG;IACH,OAAO,CAAC,WAAW,CAAK;IAExB,6DAA6D;IAC7D,OAAO,CAAC,WAAW,CAAK;IACxB,mDAAmD;IACnD,OAAO,CAAC,QAAQ,CAAK;IACrB,OAAO,CAAC,QAAQ,CAAS;IACzB,iFAAiF;IACjF,OAAO,CAAC,gBAAgB,CAAe;gBAE3B,KAAK,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM;IAiB/C,OAAO,CAAC,KAAK,EAAE,UAAU,GAAG,UAAU;IAMtC,KAAK,IAAI,UAAU;IAKnB,OAAO,CAAC,WAAW;IAQnB,OAAO,CAAC,YAAY;IAmCpB;;;OAGG;IACH,OAAO,CAAC,WAAW;IA4CnB,OAAO,CAAC,cAAc;IAiCtB,OAAO,CAAC,WAAW;IAiBnB,OAAO,CAAC,iBAAiB;IAQzB,OAAO,CAAC,YAAY;IAepB,OAAO,CAAC,mBAAmB;IAQ3B,OAAO,CAAC,iBAAiB;CAO1B"}
package/dist/wsola.js ADDED
@@ -0,0 +1,257 @@
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
+ const PASSTHROUGH_EPS = 1e-6;
7
+ const FRAME_MS = 0.03;
8
+ const SEARCH_MS = 0.01;
9
+ const INT16_MIN = -32768;
10
+ const INT16_MAX = 32767;
11
+ export class WsolaTimeStretch {
12
+ passthrough;
13
+ frame;
14
+ hs;
15
+ ha;
16
+ searchRadius;
17
+ window;
18
+ /** Accumulated input samples (float). */
19
+ input = new Float64Array(0);
20
+ inputLen = 0;
21
+ /** Absolute sample index of input[0] in the stream. */
22
+ inputOrigin = 0;
23
+ /** Overlap-add synthesis buffer (float). */
24
+ ola = new Float64Array(0);
25
+ olaLen = 0;
26
+ /** Absolute synthesis index of ola[0]. */
27
+ olaOrigin = 0;
28
+ /**
29
+ * Absolute synthesis index up to which samples are final (no further OLA).
30
+ * After placing a frame at absSynth, samples in [0, absSynth) are complete.
31
+ */
32
+ emitThrough = 0;
33
+ /** Ideal analysis position (absolute input sample index). */
34
+ analysisPos = 0;
35
+ /** Absolute synthesis index for the next frame. */
36
+ synthPos = 0;
37
+ havePrev = false;
38
+ /** Natural continuation of previous frame (hs samples) for waveform matching. */
39
+ prevContinuation;
40
+ constructor(tempo, sampleRateHz) {
41
+ if (!(sampleRateHz > 0) || !Number.isFinite(sampleRateHz)) {
42
+ throw new RangeError(`WsolaTimeStretch: invalid sampleRateHz ${String(sampleRateHz)}`);
43
+ }
44
+ if (!Number.isFinite(tempo) || tempo <= 0) {
45
+ throw new RangeError(`WsolaTimeStretch: invalid tempo ${String(tempo)}`);
46
+ }
47
+ this.passthrough = Math.abs(tempo - 1) < PASSTHROUGH_EPS;
48
+ this.frame = Math.max(2, Math.round(FRAME_MS * sampleRateHz));
49
+ this.hs = Math.max(1, Math.floor(this.frame / 2));
50
+ this.ha = Math.max(1, Math.round(this.hs * tempo));
51
+ this.searchRadius = Math.max(0, Math.round(SEARCH_MS * sampleRateHz));
52
+ this.window = hann(this.frame);
53
+ this.prevContinuation = new Float64Array(this.hs);
54
+ }
55
+ process(input) {
56
+ if (this.passthrough)
57
+ return input;
58
+ if (input.length > 0)
59
+ this.appendInput(input);
60
+ return this.extractReady(false);
61
+ }
62
+ flush() {
63
+ if (this.passthrough)
64
+ return new Int16Array(0);
65
+ return this.extractReady(true);
66
+ }
67
+ appendInput(samples) {
68
+ this.ensureInputCapacity(this.inputLen + samples.length);
69
+ for (let i = 0; i < samples.length; i++) {
70
+ this.input[this.inputLen + i] = samples[i];
71
+ }
72
+ this.inputLen += samples.length;
73
+ }
74
+ extractReady(flushing) {
75
+ while (this.tryAddFrame(flushing)) {
76
+ // keep consuming frames while input allows
77
+ }
78
+ if (flushing) {
79
+ this.emitThrough = this.olaOrigin + this.olaLen;
80
+ }
81
+ const ready = this.emitThrough - this.olaOrigin;
82
+ if (ready <= 0)
83
+ return new Int16Array(0);
84
+ const out = new Int16Array(ready);
85
+ for (let i = 0; i < ready; i++) {
86
+ out[i] = clampInt16(this.ola[i]);
87
+ }
88
+ const remaining = this.olaLen - ready;
89
+ if (remaining > 0) {
90
+ this.ola.copyWithin(0, ready, this.olaLen);
91
+ }
92
+ this.ola.fill(0, remaining, this.olaLen);
93
+ this.olaLen = remaining;
94
+ this.olaOrigin += ready;
95
+ if (flushing) {
96
+ this.inputLen = 0;
97
+ this.inputOrigin = this.analysisPos;
98
+ }
99
+ else {
100
+ this.compactInput();
101
+ }
102
+ return out;
103
+ }
104
+ /**
105
+ * Place one more synthesis frame via WSOLA.
106
+ * @returns false when more input is needed or the stream is exhausted.
107
+ */
108
+ tryAddFrame(flushing) {
109
+ const frame = this.frame;
110
+ const hs = this.hs;
111
+ const search = this.searchRadius;
112
+ const inputEnd = this.inputOrigin + this.inputLen;
113
+ if (!this.havePrev) {
114
+ if (inputEnd < frame)
115
+ return false;
116
+ const localStart = 0;
117
+ this.olaAddFrame(localStart, 0);
118
+ this.storeContinuation(localStart);
119
+ this.havePrev = true;
120
+ this.analysisPos = this.ha;
121
+ this.synthPos = hs;
122
+ // Samples [0, hs) still receive the next frame's OLA contribution.
123
+ this.emitThrough = 0;
124
+ return true;
125
+ }
126
+ // Need a full frame at some position in [ideal - search, ideal + search].
127
+ const ideal = this.analysisPos;
128
+ if (!flushing) {
129
+ // Require full right-side search room so offsets are unbiased while streaming.
130
+ if (inputEnd < ideal + frame + search)
131
+ return false;
132
+ }
133
+ else {
134
+ // On flush, accept any position that can hold a frame near the ideal.
135
+ if (inputEnd < ideal - search + frame && inputEnd < frame)
136
+ return false;
137
+ if (inputEnd - this.inputOrigin < frame)
138
+ return false;
139
+ // If even the earliest searchable start cannot fit a frame, stop.
140
+ const latestStart = inputEnd - frame;
141
+ if (latestStart < Math.max(this.inputOrigin, ideal - search))
142
+ return false;
143
+ }
144
+ const bestLocal = this.findBestOffset(ideal);
145
+ const absSynth = this.synthPos;
146
+ this.olaAddFrame(bestLocal, absSynth);
147
+ this.storeContinuation(bestLocal);
148
+ this.analysisPos = ideal + this.ha;
149
+ this.synthPos = absSynth + hs;
150
+ // After placing frame at absSynth, no future frame touches [0, absSynth).
151
+ this.emitThrough = absSynth;
152
+ return true;
153
+ }
154
+ findBestOffset(idealAbs) {
155
+ const frame = this.frame;
156
+ const hs = this.hs;
157
+ const search = this.searchRadius;
158
+ const inputEnd = this.inputOrigin + this.inputLen;
159
+ const earliest = Math.max(this.inputOrigin, idealAbs - search);
160
+ const latest = Math.min(inputEnd - frame, idealAbs + search);
161
+ if (latest < earliest) {
162
+ const fallbackAbs = Math.min(Math.max(idealAbs, this.inputOrigin), Math.max(this.inputOrigin, inputEnd - frame));
163
+ return fallbackAbs - this.inputOrigin;
164
+ }
165
+ let bestLocal = earliest - this.inputOrigin;
166
+ let bestScore = -Infinity;
167
+ for (let abs = earliest; abs <= latest; abs++) {
168
+ const local = abs - this.inputOrigin;
169
+ let score = 0;
170
+ for (let i = 0; i < hs; i++) {
171
+ score += this.input[local + i] * this.prevContinuation[i];
172
+ }
173
+ if (score > bestScore) {
174
+ bestScore = score;
175
+ bestLocal = local;
176
+ }
177
+ }
178
+ return bestLocal;
179
+ }
180
+ olaAddFrame(localStart, absSynth) {
181
+ const frame = this.frame;
182
+ const localOla = absSynth - this.olaOrigin;
183
+ const needLen = localOla + frame;
184
+ this.ensureOlaCapacity(needLen);
185
+ if (needLen > this.olaLen) {
186
+ if (this.olaLen < localOla) {
187
+ this.ola.fill(0, this.olaLen, localOla);
188
+ }
189
+ this.olaLen = needLen;
190
+ }
191
+ for (let i = 0; i < frame; i++) {
192
+ const sample = localStart + i < this.inputLen ? this.input[localStart + i] : 0;
193
+ this.ola[localOla + i] += sample * this.window[i];
194
+ }
195
+ }
196
+ storeContinuation(localStart) {
197
+ const hs = this.hs;
198
+ for (let i = 0; i < hs; i++) {
199
+ const idx = localStart + hs + i;
200
+ this.prevContinuation[i] = idx < this.inputLen ? this.input[idx] : 0;
201
+ }
202
+ }
203
+ compactInput() {
204
+ const keepFromAbs = Math.max(0, this.analysisPos - this.searchRadius);
205
+ if (keepFromAbs <= this.inputOrigin)
206
+ return;
207
+ const drop = keepFromAbs - this.inputOrigin;
208
+ if (drop <= 0)
209
+ return;
210
+ if (drop >= this.inputLen) {
211
+ this.inputLen = 0;
212
+ this.inputOrigin = keepFromAbs;
213
+ return;
214
+ }
215
+ this.input.copyWithin(0, drop, this.inputLen);
216
+ this.inputLen -= drop;
217
+ this.inputOrigin = keepFromAbs;
218
+ }
219
+ ensureInputCapacity(needed) {
220
+ if (needed <= this.input.length)
221
+ return;
222
+ const cap = Math.max(needed, this.input.length > 0 ? this.input.length * 2 : 1024);
223
+ const next = new Float64Array(cap);
224
+ next.set(this.input.subarray(0, this.inputLen));
225
+ this.input = next;
226
+ }
227
+ ensureOlaCapacity(needed) {
228
+ if (needed <= this.ola.length)
229
+ return;
230
+ const cap = Math.max(needed, this.ola.length > 0 ? this.ola.length * 2 : 1024);
231
+ const next = new Float64Array(cap);
232
+ next.set(this.ola.subarray(0, this.olaLen));
233
+ this.ola = next;
234
+ }
235
+ }
236
+ /** Symmetric Hann window; 50% overlap is COLA (overlapping windows sum ≈ 1). */
237
+ function hann(n) {
238
+ const w = new Float64Array(n);
239
+ if (n === 1) {
240
+ w[0] = 1;
241
+ return w;
242
+ }
243
+ const denom = n - 1;
244
+ for (let i = 0; i < n; i++) {
245
+ w[i] = 0.5 * (1 - Math.cos((2 * Math.PI * i) / denom));
246
+ }
247
+ return w;
248
+ }
249
+ function clampInt16(x) {
250
+ const r = Math.round(x);
251
+ if (r < INT16_MIN)
252
+ return INT16_MIN;
253
+ if (r > INT16_MAX)
254
+ return INT16_MAX;
255
+ return r;
256
+ }
257
+ //# sourceMappingURL=wsola.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"wsola.js","sourceRoot":"","sources":["../src/wsola.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kuralle-syrinx/openai-tts",
3
- "version": "4.4.0",
3
+ "version": "4.5.0",
4
4
  "private": false,
5
5
  "description": "OpenAI-compatible streaming TTS plugin for Syrinx",
6
6
  "keywords": [
@@ -23,17 +23,24 @@
23
23
  "url": "https://github.com/kuralle/syrinx/issues"
24
24
  },
25
25
  "type": "module",
26
- "main": "./src/index.ts",
27
- "types": "./src/index.ts",
26
+ "main": "./dist/index.js",
27
+ "types": "./dist/index.d.ts",
28
28
  "dependencies": {
29
- "@kuralle-syrinx/core": "4.4.0"
29
+ "@kuralle-syrinx/core": "4.5.0"
30
30
  },
31
31
  "devDependencies": {
32
+ "@types/node": "^22.0.0",
32
33
  "typescript": "^5.7.0",
33
34
  "vitest": "^3.2.6"
34
35
  },
36
+ "files": [
37
+ "dist",
38
+ "src",
39
+ "README.md"
40
+ ],
35
41
  "scripts": {
36
42
  "typecheck": "tsc --noEmit",
37
- "test": "vitest run"
43
+ "test": "vitest run",
44
+ "build": "tsc -p tsconfig.build.json"
38
45
  }
39
46
  }
@@ -0,0 +1,527 @@
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
+ type UsageRecordedPacket,
11
+ } from "@kuralle-syrinx/core";
12
+
13
+ import { OpenAICompatibleTTSPlugin } from "./index.js";
14
+
15
+ function startBus(bus: PipelineBusImpl): Promise<void> {
16
+ return bus.start();
17
+ }
18
+
19
+ async function waitForCondition(predicate: () => boolean, timeoutMs = 2000): Promise<void> {
20
+ const startedAt = Date.now();
21
+ while (Date.now() - startedAt < timeoutMs) {
22
+ if (predicate()) return;
23
+ await new Promise((resolve) => setTimeout(resolve, 10));
24
+ }
25
+ throw new Error("Timed out waiting for openai-tts test condition");
26
+ }
27
+
28
+ /** Minimal mono s16le silence: `sampleCount` samples (`sampleCount * 2` bytes). */
29
+ function pcmSilence(sampleCount: number): Uint8Array {
30
+ return new Uint8Array(sampleCount * 2);
31
+ }
32
+
33
+ function streamFromChunks(chunks: Uint8Array[]): ReadableStream<Uint8Array> {
34
+ let i = 0;
35
+ return new ReadableStream({
36
+ pull(controller) {
37
+ if (i >= chunks.length) {
38
+ controller.close();
39
+ return;
40
+ }
41
+ controller.enqueue(chunks[i]!);
42
+ i += 1;
43
+ },
44
+ });
45
+ }
46
+
47
+ afterEach(() => {
48
+ vi.unstubAllGlobals();
49
+ vi.restoreAllMocks();
50
+ });
51
+
52
+ describe("OpenAICompatibleTTSPlugin", () => {
53
+ it("emits usage.recorded with stage tts and characters equal to synthesized text length", async () => {
54
+ const fetchMock = vi.fn(async () => {
55
+ return new Response(streamFromChunks([pcmSilence(8)]), {
56
+ status: 200,
57
+ headers: { "Content-Type": "application/octet-stream" },
58
+ });
59
+ });
60
+ vi.stubGlobal("fetch", fetchMock);
61
+
62
+ const bus = new PipelineBusImpl();
63
+ const started = startBus(bus);
64
+ const plugin = new OpenAICompatibleTTSPlugin();
65
+ const ends: TextToSpeechEndPacket[] = [];
66
+ const usage: UsageRecordedPacket[] = [];
67
+ bus.on("tts.end", (pkt) => {
68
+ ends.push(pkt as TextToSpeechEndPacket);
69
+ });
70
+ bus.on("usage.recorded", (pkt) => {
71
+ usage.push(pkt as UsageRecordedPacket);
72
+ });
73
+
74
+ const text = "Hello usage.";
75
+ await plugin.initialize(bus, {
76
+ base_url: "https://tts.test/v1",
77
+ model: "gpt-4o-mini-tts",
78
+ sample_rate: 16000,
79
+ });
80
+ bus.push(Route.Main, {
81
+ kind: "tts.text",
82
+ contextId: "turn-usage",
83
+ timestampMs: Date.now(),
84
+ text,
85
+ });
86
+ await waitForCondition(() => ends.length >= 1 && usage.length >= 1);
87
+
88
+ expect(usage).toEqual([
89
+ expect.objectContaining({
90
+ kind: "usage.recorded",
91
+ contextId: "turn-usage",
92
+ stage: "tts",
93
+ provider: "openai",
94
+ model: "gpt-4o-mini-tts",
95
+ characters: text.length,
96
+ }),
97
+ ]);
98
+
99
+ await plugin.close();
100
+ bus.stop();
101
+ await started;
102
+ });
103
+
104
+ it("POSTs speech body with stream, pcm, and extra_body merged", async () => {
105
+ const fetchMock = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => {
106
+ return new Response(streamFromChunks([pcmSilence(8)]), {
107
+ status: 200,
108
+ headers: { "Content-Type": "application/octet-stream" },
109
+ });
110
+ });
111
+ vi.stubGlobal("fetch", fetchMock);
112
+
113
+ const bus = new PipelineBusImpl();
114
+ const started = startBus(bus);
115
+ const plugin = new OpenAICompatibleTTSPlugin();
116
+ const ends: TextToSpeechEndPacket[] = [];
117
+ bus.on("tts.end", (pkt) => {
118
+ ends.push(pkt as TextToSpeechEndPacket);
119
+ });
120
+
121
+ await plugin.initialize(bus, {
122
+ base_url: "https://zeta.test/v1",
123
+ sample_rate: 16000,
124
+ extra_body: { task_type: "Base", num_steps: 8 },
125
+ });
126
+
127
+ bus.push(Route.Main, {
128
+ kind: "tts.text",
129
+ contextId: "turn-1",
130
+ timestampMs: Date.now(),
131
+ text: "ආයුබෝවන්",
132
+ });
133
+ await waitForCondition(() => ends.length >= 1);
134
+
135
+ expect(fetchMock).toHaveBeenCalledTimes(1);
136
+ const [url, init] = fetchMock.mock.calls[0]!;
137
+ expect(String(url)).toBe("https://zeta.test/v1/audio/speech");
138
+ expect(init?.method).toBe("POST");
139
+ const body = JSON.parse(String(init?.body)) as Record<string, unknown>;
140
+ expect(body).toEqual({
141
+ model: "gpt-4o-mini-tts",
142
+ input: "ආයුබෝවන්",
143
+ response_format: "pcm",
144
+ stream: true,
145
+ task_type: "Base",
146
+ num_steps: 8,
147
+ });
148
+ expect((init?.headers as Record<string, string>)["Content-Type"]).toBe("application/json");
149
+
150
+ await plugin.close();
151
+ bus.stop();
152
+ await started;
153
+ });
154
+
155
+ it("honors response_format override and keeps stream true by default", async () => {
156
+ const fetchMock = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => {
157
+ return new Response(streamFromChunks([pcmSilence(8)]), {
158
+ status: 200,
159
+ headers: { "Content-Type": "application/octet-stream" },
160
+ });
161
+ });
162
+ vi.stubGlobal("fetch", fetchMock);
163
+
164
+ const bus = new PipelineBusImpl();
165
+ const started = startBus(bus);
166
+ const plugin = new OpenAICompatibleTTSPlugin();
167
+ const ends: TextToSpeechEndPacket[] = [];
168
+ bus.on("tts.end", (pkt) => {
169
+ ends.push(pkt as TextToSpeechEndPacket);
170
+ });
171
+
172
+ await plugin.initialize(bus, {
173
+ base_url: "https://openai.test/v1",
174
+ response_format: "wav",
175
+ sample_rate: 16000,
176
+ });
177
+
178
+ bus.push(Route.Main, {
179
+ kind: "tts.text",
180
+ contextId: "turn-fmt",
181
+ timestampMs: Date.now(),
182
+ text: "format",
183
+ });
184
+ await waitForCondition(() => ends.length >= 1);
185
+
186
+ const [_url, init] = fetchMock.mock.calls[0]!;
187
+ const body = JSON.parse(String(init?.body)) as Record<string, unknown>;
188
+ expect(body.response_format).toBe("wav");
189
+ expect(body.stream).toBe(true);
190
+
191
+ await plugin.close();
192
+ bus.stop();
193
+ await started;
194
+ });
195
+
196
+ it("includes voice in body only when configured", async () => {
197
+ const fetchMock = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => {
198
+ return new Response(streamFromChunks([pcmSilence(8)]), {
199
+ status: 200,
200
+ headers: { "Content-Type": "application/octet-stream" },
201
+ });
202
+ });
203
+ vi.stubGlobal("fetch", fetchMock);
204
+
205
+ const bus = new PipelineBusImpl();
206
+ const started = startBus(bus);
207
+ const plugin = new OpenAICompatibleTTSPlugin();
208
+ const ends: TextToSpeechEndPacket[] = [];
209
+ bus.on("tts.end", (pkt) => {
210
+ ends.push(pkt as TextToSpeechEndPacket);
211
+ });
212
+
213
+ await plugin.initialize(bus, {
214
+ base_url: "https://openai.test/v1",
215
+ api_key: "sk-test",
216
+ voice: "alloy",
217
+ sample_rate: 16000,
218
+ });
219
+
220
+ bus.push(Route.Main, {
221
+ kind: "tts.text",
222
+ contextId: "turn-voice",
223
+ timestampMs: Date.now(),
224
+ text: "hello",
225
+ });
226
+ await waitForCondition(() => ends.length >= 1);
227
+
228
+ const [_url, init] = fetchMock.mock.calls[0]!;
229
+ const body = JSON.parse(String(init?.body)) as Record<string, unknown>;
230
+ expect(body.voice).toBe("alloy");
231
+ expect((init?.headers as Record<string, string>)["Authorization"]).toBe("Bearer sk-test");
232
+
233
+ await plugin.close();
234
+ bus.stop();
235
+ await started;
236
+ });
237
+
238
+ it("omits voice key when voice is not configured", async () => {
239
+ const fetchMock = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => {
240
+ return new Response(streamFromChunks([pcmSilence(8)]), {
241
+ status: 200,
242
+ headers: { "Content-Type": "application/octet-stream" },
243
+ });
244
+ });
245
+ vi.stubGlobal("fetch", fetchMock);
246
+
247
+ const bus = new PipelineBusImpl();
248
+ const started = startBus(bus);
249
+ const plugin = new OpenAICompatibleTTSPlugin();
250
+ const ends: TextToSpeechEndPacket[] = [];
251
+ bus.on("tts.end", (pkt) => {
252
+ ends.push(pkt as TextToSpeechEndPacket);
253
+ });
254
+
255
+ await plugin.initialize(bus, {
256
+ base_url: "https://openai.test/v1",
257
+ sample_rate: 16000,
258
+ });
259
+
260
+ bus.push(Route.Main, {
261
+ kind: "tts.text",
262
+ contextId: "turn-no-voice",
263
+ timestampMs: Date.now(),
264
+ text: "hi",
265
+ });
266
+ await waitForCondition(() => ends.length >= 1);
267
+
268
+ const [_url, init] = fetchMock.mock.calls[0]!;
269
+ const body = JSON.parse(String(init?.body)) as Record<string, unknown>;
270
+ expect(body).not.toHaveProperty("voice");
271
+
272
+ await plugin.close();
273
+ bus.stop();
274
+ await started;
275
+ });
276
+
277
+ it("streams PCM into tts.audio at engine sampleRateHz then tts.end", async () => {
278
+ // Two chunks with an odd-byte split so the plugin must carry across frames.
279
+ const full = pcmSilence(48); // 96 bytes
280
+ const chunkA = full.subarray(0, 15); // odd
281
+ const chunkB = full.subarray(15);
282
+
283
+ vi.stubGlobal(
284
+ "fetch",
285
+ vi.fn(async () => {
286
+ return new Response(streamFromChunks([chunkA, chunkB]), {
287
+ status: 200,
288
+ headers: { "Content-Type": "application/octet-stream" },
289
+ });
290
+ }),
291
+ );
292
+
293
+ const bus = new PipelineBusImpl();
294
+ const started = startBus(bus);
295
+ const plugin = new OpenAICompatibleTTSPlugin();
296
+ const audio: TextToSpeechAudioPacket[] = [];
297
+ const ends: TextToSpeechEndPacket[] = [];
298
+ bus.on("tts.audio", (pkt) => {
299
+ audio.push(pkt as TextToSpeechAudioPacket);
300
+ });
301
+ bus.on("tts.end", (pkt) => {
302
+ ends.push(pkt as TextToSpeechEndPacket);
303
+ });
304
+
305
+ await plugin.initialize(bus, {
306
+ base_url: "https://openai.test/v1",
307
+ sample_rate: 16000,
308
+ });
309
+
310
+ bus.push(Route.Main, {
311
+ kind: "tts.text",
312
+ contextId: "turn-2",
313
+ timestampMs: Date.now(),
314
+ text: "hello",
315
+ });
316
+ await waitForCondition(() => ends.length >= 1);
317
+
318
+ expect(audio.length).toBeGreaterThanOrEqual(1);
319
+ for (const pkt of audio) {
320
+ expect(pkt.kind).toBe("tts.audio");
321
+ expect(pkt.contextId).toBe("turn-2");
322
+ expect(pkt.sampleRateHz).toBe(16000);
323
+ expect(pkt.audio.byteLength % 2).toBe(0);
324
+ expect(pkt.audio.byteLength).toBeGreaterThan(0);
325
+ }
326
+ expect(ends).toEqual([expect.objectContaining({ kind: "tts.end", contextId: "turn-2" })]);
327
+
328
+ await plugin.close();
329
+ bus.stop();
330
+ await started;
331
+ });
332
+
333
+ it("source_sample_rate_hz drives the resampler output length", async () => {
334
+ // 96 bytes = 48 samples. Different source rates → different resampled lengths.
335
+ const pcm48k = pcmSilence(48);
336
+
337
+ async function collectAudioBytes(sourceRate: number): Promise<number> {
338
+ vi.stubGlobal(
339
+ "fetch",
340
+ vi.fn(async () => {
341
+ return new Response(streamFromChunks([pcm48k]), {
342
+ status: 200,
343
+ headers: { "Content-Type": "application/octet-stream" },
344
+ });
345
+ }),
346
+ );
347
+
348
+ const bus = new PipelineBusImpl();
349
+ const started = startBus(bus);
350
+ const plugin = new OpenAICompatibleTTSPlugin();
351
+ let totalBytes = 0;
352
+ const ends: TextToSpeechEndPacket[] = [];
353
+ bus.on("tts.audio", (pkt) => {
354
+ totalBytes += (pkt as TextToSpeechAudioPacket).audio.byteLength;
355
+ });
356
+ bus.on("tts.end", (pkt) => {
357
+ ends.push(pkt as TextToSpeechEndPacket);
358
+ });
359
+
360
+ await plugin.initialize(bus, {
361
+ base_url: "https://openai.test/v1",
362
+ sample_rate: 16000,
363
+ source_sample_rate_hz: sourceRate,
364
+ });
365
+
366
+ bus.push(Route.Main, {
367
+ kind: "tts.text",
368
+ contextId: `rate-${String(sourceRate)}`,
369
+ timestampMs: Date.now(),
370
+ text: "test",
371
+ });
372
+ await waitForCondition(() => ends.length >= 1);
373
+
374
+ await plugin.close();
375
+ bus.stop();
376
+ await started;
377
+ vi.unstubAllGlobals();
378
+ return totalBytes;
379
+ }
380
+
381
+ const bytesAt48k = await collectAudioBytes(48_000);
382
+ const bytesAt24k = await collectAudioBytes(24_000);
383
+ expect(bytesAt48k).toBeGreaterThan(0);
384
+ expect(bytesAt24k).toBeGreaterThan(bytesAt48k);
385
+ });
386
+
387
+ it("tempo: 0.9 produces more tts.audio bytes than tempo: 1.0 for the same PCM stream", async () => {
388
+ // ~100 ms of 24 kHz silence → enough samples after resample for WSOLA to stretch.
389
+ const pcm = pcmSilence(2400);
390
+
391
+ async function collectAudioBytes(tempo: number): Promise<number> {
392
+ vi.stubGlobal(
393
+ "fetch",
394
+ vi.fn(async () => {
395
+ return new Response(streamFromChunks([pcm]), {
396
+ status: 200,
397
+ headers: { "Content-Type": "application/octet-stream" },
398
+ });
399
+ }),
400
+ );
401
+
402
+ const bus = new PipelineBusImpl();
403
+ const started = startBus(bus);
404
+ const plugin = new OpenAICompatibleTTSPlugin();
405
+ let totalBytes = 0;
406
+ const ends: TextToSpeechEndPacket[] = [];
407
+ bus.on("tts.audio", (pkt) => {
408
+ totalBytes += (pkt as TextToSpeechAudioPacket).audio.byteLength;
409
+ });
410
+ bus.on("tts.end", (pkt) => {
411
+ ends.push(pkt as TextToSpeechEndPacket);
412
+ });
413
+
414
+ await plugin.initialize(bus, {
415
+ base_url: "https://openai.test/v1",
416
+ sample_rate: 16000,
417
+ tempo,
418
+ });
419
+
420
+ bus.push(Route.Main, {
421
+ kind: "tts.text",
422
+ contextId: `tempo-${String(tempo)}`,
423
+ timestampMs: Date.now(),
424
+ text: "slow",
425
+ });
426
+ await waitForCondition(() => ends.length >= 1);
427
+
428
+ await plugin.close();
429
+ bus.stop();
430
+ await started;
431
+ vi.unstubAllGlobals();
432
+ return totalBytes;
433
+ }
434
+
435
+ const bytesAt1 = await collectAudioBytes(1.0);
436
+ const bytesAt09 = await collectAudioBytes(0.9);
437
+ expect(bytesAt1).toBeGreaterThan(0);
438
+ expect(bytesAt09).toBeGreaterThan(bytesAt1);
439
+ });
440
+
441
+ it("maps HTTP 503 to a recoverable tts.error", async () => {
442
+ const errorLog = vi.spyOn(console, "error").mockImplementation(() => {});
443
+ vi.stubGlobal(
444
+ "fetch",
445
+ vi.fn(async () => new Response("Service Unavailable", { status: 503 })),
446
+ );
447
+
448
+ const bus = new PipelineBusImpl();
449
+ const started = startBus(bus);
450
+ const plugin = new OpenAICompatibleTTSPlugin();
451
+ const errors: TtsErrorPacket[] = [];
452
+ bus.on("tts.error", (pkt) => {
453
+ errors.push(pkt as TtsErrorPacket);
454
+ });
455
+
456
+ await plugin.initialize(bus, {
457
+ base_url: "https://openai.test/v1",
458
+ sample_rate: 16000,
459
+ });
460
+
461
+ bus.push(Route.Main, {
462
+ kind: "tts.text",
463
+ contextId: "turn-cold",
464
+ timestampMs: Date.now(),
465
+ text: "cold",
466
+ });
467
+ await waitForCondition(() => errors.length >= 1);
468
+
469
+ expect(errors).toEqual([
470
+ expect.objectContaining({
471
+ kind: "tts.error",
472
+ contextId: "turn-cold",
473
+ component: "tts",
474
+ isRecoverable: true,
475
+ }),
476
+ ]);
477
+ expect(errorLog).toHaveBeenCalledWith(
478
+ expect.stringContaining("[openai-tts] cold start"),
479
+ );
480
+
481
+ await plugin.close();
482
+ bus.stop();
483
+ await started;
484
+ errorLog.mockRestore();
485
+ });
486
+
487
+ it("prewarm issues GET {baseUrl}/models with auth when api_key is set", async () => {
488
+ const fetchMock = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => {
489
+ return new Response("{}", { status: 200 });
490
+ });
491
+ vi.stubGlobal("fetch", fetchMock);
492
+
493
+ const bus = new PipelineBusImpl();
494
+ const plugin = new OpenAICompatibleTTSPlugin();
495
+ await plugin.initialize(bus, {
496
+ base_url: "https://zeta.test/v1",
497
+ api_key: "sk-test",
498
+ });
499
+
500
+ await plugin.prewarm();
501
+
502
+ expect(fetchMock).toHaveBeenCalledTimes(1);
503
+ const [url, init] = fetchMock.mock.calls[0]!;
504
+ expect(String(url)).toBe("https://zeta.test/v1/models");
505
+ expect(init?.method).toBe("GET");
506
+ expect((init?.headers as Record<string, string>)["Authorization"]).toBe("Bearer sk-test");
507
+
508
+ await plugin.close();
509
+ });
510
+
511
+ it("swallows fetch rejection in prewarm", async () => {
512
+ vi.stubGlobal(
513
+ "fetch",
514
+ vi.fn(async () => {
515
+ throw new Error("network down");
516
+ }),
517
+ );
518
+
519
+ const bus = new PipelineBusImpl();
520
+ const plugin = new OpenAICompatibleTTSPlugin();
521
+ await plugin.initialize(bus, { base_url: "https://zeta.test/v1" });
522
+
523
+ await expect(plugin.prewarm()).resolves.toBeUndefined();
524
+
525
+ await plugin.close();
526
+ });
527
+ });
@@ -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
+ });