Skip to content
Source
Sign inSign up

native.test.ts

318 lines11.1 KB

1// @effect-diagnostics nodeBuiltinImport:off - verifies isolated native worker processes and their exit events.
2import { expect, it } from "vite-plus/test";
3​
4import { loadNativeSpeechModel } from "./native.ts";
5​
6const fixture = (transcribe: string, maxAudioMs = 0, supportsVocabulary = false) =>
7 `data:text/javascript,${encodeURIComponent(`export const TranscribeModel = { load: async () => ({ capabilities: { supportsStreaming: false, maxAudioMs: ${maxAudioMs}, languages: ["en-US", "de-DE"] }, supports: (feature) => feature === "vocabulary" && ${supportsVocabulary}, transcribe: ${transcribe} }) };`)}`;
8​
9const fixtureWithUnrelatedIpcMessage = () =>
10 `data:text/javascript,${encodeURIComponent(`
11 process.send?.({ type: "unrelated-control-message" });
12 export const TranscribeModel = {
13 load: async () => ({ capabilities: { supportsStreaming: false }, supports: () => false, transcribe: async () => ({ text: "hello" }) }),
14 };
15 `)}`;
16​
17const streamingFixture = (feed: string, supportsVocabulary = false) =>
18 `data:text/javascript,${encodeURIComponent(`
19 export const TranscribeModel = { load: async () => ({
20 capabilities: { supportsStreaming: true },
21 supports: (feature) => feature === "vocabulary" && ${supportsVocabulary},
22 createSession: () => ({
23 dispose() {},
24 stream: async (opts) => ({
25 feed: ${feed},
26 finalize: async () => {},
27 text: { full: opts.vocabulary?.join(" / ") ?? opts.language ?? "hello world", committed: "hello ", tentative: "world" },
28 reset() {},
29 }),
30 }),
31 }) };
32`)}`;
33​
34it("returns transcription from an isolated process and disposes it", async () => {
35 const model = await loadNativeSpeechModel(
36 "unused.gguf",
37 new AbortController().signal,
38 fixture("async (pcm) => ({ text: String(pcm[0]) })"),
39 );
40 try {
41 expect(model.languages).toEqual(["en-US", "de-DE"]);
42 expect(
43 await model.transcribe(
44 new Float32Array([0.25]),
45 { timestamps: "none", language: "en" },
46 false,
47 ),
48 ).toEqual({ text: "0.25" });
49 } finally {
50 await model.dispose();
51 }
52});
53​
54it("probes generic vocabulary support and delivers ordered terms to offline inference", async () => {
55 for (const supported of [false, true]) {
56 const model = await loadNativeSpeechModel(
57 "unused.gguf",
58 new AbortController().signal,
59 fixture("async (_pcm, options) => ({ text: JSON.stringify(options) })", 0, supported),
60 );
61 try {
62 expect(model.supportsVocabulary).toBe(supported);
63 const options = {
64 timestamps: "none" as const,
65 language: "en",
66 ...(supported ? { vocabulary: ["T3 Code", "GitHub"] } : {}),
67 };
68 const result = await model.transcribe(new Float32Array([0.25]), options, false);
69 expect(JSON.parse(result.text)).toEqual(options);
70 } finally {
71 await model.dispose();
72 }
73 }
74});
75​
76it("ignores IPC messages that do not belong to the speech protocol", async () => {
77 const model = await loadNativeSpeechModel(
78 "unused.gguf",
79 new AbortController().signal,
80 fixtureWithUnrelatedIpcMessage(),
81 );
82 try {
83 await expect(
84 model.transcribe(new Float32Array([0.25]), { timestamps: "none", language: "en" }, false),
85 ).resolves.toEqual({ text: "hello" });
86 } finally {
87 await model.dispose();
88 }
89});
90​
91it("terminates hung native inference when the service shuts down", async () => {
92 const controller = new AbortController();
93 const model = await loadNativeSpeechModel(
94 "unused.gguf",
95 controller.signal,
96 fixture("async () => { while (true) {} }"),
97 );
98 const pending = model.transcribe(
99 new Float32Array([0.25]),
100 {
101 timestamps: "none",
102 language: "en",
103 },
104 false,
105 );
106 const rejected = expect(pending).rejects.toThrow();
107 controller.abort();
108 await model.dispose();
109 await rejected;
110});
111​
112it("returns streaming previews and final text from an isolated process", async () => {
113 const model = await loadNativeSpeechModel(
114 "unused.gguf",
115 new AbortController().signal,
116 streamingFixture(
117 "async () => ({ revision: 1, committedChanged: true, tentativeChanged: true })",
118 true,
119 ),
120 );
121 try {
122 expect(model.supportsStreaming).toBe(true);
123 await model.begin({ timestamps: "none" }, false);
124 expect(await model.feed(new Float32Array(512).fill(0.25))).toEqual({
125 revision: 1,
126 text: { committed: "hello ", tentative: "world" },
127 });
128 expect(await model.finish()).toBe("hello world");
129 await model.begin({ timestamps: "none", language: "fr" }, false);
130 await model.reset();
131 await model.begin({ timestamps: "none", language: "fr" }, false);
132 await model.feed(new Float32Array(512));
133 expect(await model.finish()).toBe("fr");
134 await model.begin(
135 { timestamps: "none", language: "fr", vocabulary: ["T3 Code", "GitHub"] },
136 false,
137 );
138 await model.feed(new Float32Array(512).fill(0.25));
139 expect(await model.finish()).toBe("T3 Code / GitHub");
140 } finally {
141 await model.dispose();
142 }
143});
144​
145it("kills a hung streaming feed without waiting for native cleanup", async () => {
146 const controller = new AbortController();
147 const model = await loadNativeSpeechModel(
148 "unused.gguf",
149 controller.signal,
150 streamingFixture("async () => { while (true) {} }"),
151 );
152 await model.begin({ timestamps: "none" }, false);
153 const rejected = expect(model.feed(new Float32Array(512).fill(0.25))).rejects.toThrow();
154 controller.abort();
155 await model.dispose();
156 await rejected;
157});
158const vadFixture = ({
159 streaming = false,
160 failVad = false,
161 failVadLoad = false,
162 failAsr = false,
163 maxAudioMs = 4000,
164} = {}) =>
165 `data:text/javascript,${encodeURIComponent(`
166 const summarize = pcm => String(pcm.length) + ":" + pcm[0].toFixed(2) + ":" + pcm[pcm.length - 1].toFixed(2);
167 export const TranscribeModel = { load: async (path, options) => {
168 if (path === "vad.gguf") {
169 if (${failVadLoad}) throw new Error("VAD unavailable");
170 if (options.backend !== "cpu") throw new Error("VAD must use CPU");
171 return { vadInfo: { sampleRate: 16000, frameSamples: 512 }, dispose() {},
172 createVadSession: ({ nThreads }) => {
173 if (nThreads !== 1) throw new Error("VAD thread count");
174 let pending = []; let at = 0;
175 const score = final => {
176 const firstFrame = at;
177 const probs = [];
178 while (pending.length >= 512 || final && pending.length) {
179 probs.push(pending[0] > 0.5 ? 0.9 : 0);
180 pending.splice(0, 512); at++;
181 }
182 return { firstFrame, probs };
183 };
184 return { dispose() {}, streamReset: async () => { pending = []; at = 0; },
185 streamFeed: async pcm => {
186 if (${failVad} && pcm[0] < 0) throw new Error("VAD fixture failed");
187 pending.push(...pcm); return score(false);
188 },
189 streamFlush: async () => score(true),
190 };
191 },
192 };
193 }
194 return { backend: "CPU", supports: () => false,
195 capabilities: { supportsStreaming: ${streaming}, maxAudioMs: ${maxAudioMs} },
196 transcribe: async pcm => { if (${failAsr}) throw new Error("ASR fixture failed"); return { text: summarize(pcm) }; },
197 createSession: () => {
198 let all = [];
199 return { dispose() {}, stream: async () => ({
200 feed: async pcm => { all.push(...pcm); return { committedChanged: true }; },
201 finalize: async () => {}, reset() { all = []; },
202 get text() { const full = summarize(all); return { committed: full, tentative: "", full }; },
203 }) };
204 },
205 };
206 }};
207 `)}`;
208​
209it("filters silence and preserves partial speech through the production worker and resets between recordings", async () => {
210 for (const streaming of [false, true]) {
211 const model = await loadNativeSpeechModel(
212 "asr.gguf",
213 new AbortController().signal,
214 vadFixture({ streaming }),
215 "cpu",
216 );
217 try {
218 expect(
219 await model.transcribe(new Float32Array(2000), { timestamps: "none" }, true, "vad.gguf"),
220 ).toEqual({ text: "" });
221 expect(
222 await model.transcribe(
223 new Float32Array([0.8, 0.6]),
224 { timestamps: "none" },
225 true,
226 "vad.gguf",
227 ),
228 ).toEqual({ text: "2:0.80:0.60" });
229 expect(
230 await model.transcribe(new Float32Array(2000), { timestamps: "none" }, false, "vad.gguf"),
231 ).toEqual({ text: "2000:0.00:0.00" });
232 for (const cancel of [true, false]) {
233 await model.begin({ timestamps: "none" }, true, "vad.gguf");
234 await model.feed(new Float32Array(600));
235 expect(await model.finish()).toBe("");
236 await model.begin({ timestamps: "none" }, true, "vad.gguf");
237 await model.feed(new Float32Array(511).fill(0.8));
238 if (cancel) await model.reset();
239 else expect(await model.finish()).toBe("511:0.80:0.80");
240 }
241 } finally {
242 await model.dispose();
243 }
244 }
245});
246​
247it("decodes complete stable chunks before Stop and returns the ordered final tail", async () => {
248 const model = await loadNativeSpeechModel(
249 "asr.gguf",
250 new AbortController().signal,
251 vadFixture(),
252 "cpu",
253 );
254 try {
255 await model.begin({ timestamps: "none" }, false, "vad.gguf");
256 await model.feed(new Float32Array(16000).fill(0.1));
257 await model.feed(new Float32Array(16000).fill(0.2));
258 const update = await model.feed(new Float32Array(16000).fill(0.3));
259 expect(update.text).toEqual({ committed: "32000:0.10:0.20", tentative: "" });
260 await model.feed(new Float32Array([0.4, 0.5]));
261 expect(await model.finish()).toBe("32000:0.10:0.20 16002:0.30:0.50");
262 } finally {
263 await model.dispose();
264 }
265});
266​
267it("preserves withheld audio on VAD failure while keeping ASR failures distinct", async () => {
268 for (const streaming of [false, true]) {
269 const model = await loadNativeSpeechModel(
270 "asr.gguf",
271 new AbortController().signal,
272 vadFixture({ streaming, failVad: true }),
273 "cpu",
274 );
275 try {
276 await model.begin({ timestamps: "none" }, true, "vad.gguf");
277 await model.feed(new Float32Array(512).fill(0.1));
278 await model.feed(new Float32Array(512).fill(-0.2));
279 expect(await model.finish()).toBe("1024:0.10:-0.20");
280 await model.begin({ timestamps: "none" }, true, "vad.gguf");
281 await model.feed(new Float32Array(512));
282 expect(await model.finish()).toBe("");
283 } finally {
284 await model.dispose();
285 }
286 }
287 const unavailable = await loadNativeSpeechModel(
288 "asr.gguf",
289 new AbortController().signal,
290 vadFixture({ failVadLoad: true }),
291 "cpu",
292 );
293 try {
294 expect(
295 await unavailable.transcribe(
296 new Float32Array(2000).fill(0.1),
297 { timestamps: "none" },
298 true,
299 "vad.gguf",
300 ),
301 ).toEqual({ text: "2000:0.10:0.10" });
302 } finally {
303 await unavailable.dispose();
304 }
305 const model = await loadNativeSpeechModel(
306 "asr.gguf",
307 new AbortController().signal,
308 vadFixture({ failAsr: true }),
309 "cpu",
310 );
311 try {
312 await expect(
313 model.transcribe(new Float32Array(512).fill(0.8), { timestamps: "none" }, true, "vad.gguf"),
314 ).rejects.toThrow("ASR fixture failed");
315 } finally {
316 await model.dispose();
317 }
318});