tests/voiceEngine.test.ts
Rama main · réplica de lectura del repositorio original.
// Voice notes without a GPU: the backend choice, the processor's thread
// count, the fallback from a GPU that fails to the processor, and the status
// line that fallback leaves behind (server/voiceEngine.ts,
// server/voiceWorker.ts, client/components/settings/voiceStatus.ts). The
// default's migration is pinned in tests/settings.test.ts beside the other
// settings.voice rules; speechWindows in tests/voice.test.ts.
import assert from "node:assert/strict";
import { after, before, beforeEach, describe, it } from "node:test";
import { closeSync, ftruncateSync, mkdirSync, openSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { VOICE_MAX_THREADS, voiceModelBytes, voiceModelInfo, voiceThreads, type VoiceEngineState } from "../shared/voice.ts";
import {
engineBackend,
engineThreads,
knownEngine,
modelPath,
modelReady,
onnxDir,
physicalCores,
probeGpu,
resetEngine,
transcribeInChild,
} from "../server/voiceEngine.ts";
import { voiceRoutes } from "../server/voice.ts";
import { initSite } from "../server/site.ts";
import { makeDir, removeVault } from "./helpers/vault.ts";
const here = path.dirname(fileURLToPath(import.meta.url));
const FAKE = path.join(here, "helpers", "fakeVoiceWorker.ts");
describe("the processor's thread count", () => {
it("is the physical cores, capped at eight", () => {
assert.equal(voiceThreads(16, 8), 8, "a 16-thread, 8-core machine: its cores, not its threads");
assert.equal(voiceThreads(32, 16), VOICE_MAX_THREADS, "past eight the encoder stops getting faster");
assert.equal(voiceThreads(8, 4), 4);
});
it("never exceeds what the process may run on (taskset, a container's quota)", () => {
assert.equal(voiceThreads(2, 8), 2, "`taskset -c 0-1` on an 8-core machine: two");
assert.equal(voiceThreads(1, 8), 1);
});
it("halves the logical count where the platform does not say, and is never zero", () => {
assert.equal(voiceThreads(12, null), 6);
assert.equal(voiceThreads(1, null), 1);
assert.equal(voiceThreads(0, null), 1);
assert.equal(voiceThreads(4, 0), 2);
});
it("reads physical cores from /proc/cpuinfo's (physical id, core id) pairs", () => {
const cpu = (phys: number, core: number): string => `processor\t: x\nphysical id\t: ${phys}\ncore id\t\t: ${core}\n\n`;
// Four cores, each listed twice (SMT).
const smt = [0, 1, 2, 3, 0, 1, 2, 3].map((c) => cpu(0, c)).join("");
assert.equal(physicalCores(smt), 4);
// Two sockets whose core ids repeat.
assert.equal(physicalCores([0, 1, 0, 1].map((c, i) => cpu(i < 2 ? 0 : 1, c)).join("")), 4);
assert.equal(physicalCores("processor\t: 0\nmodel name\t: ARMv8\n"), null, "no core ids: unknown, not one");
assert.ok(engineThreads() >= 1 && engineThreads() <= VOICE_MAX_THREADS);
});
});
describe("the model catalogue", () => {
it("names both forms of every model, and each engine's download is its own", () => {
for (const id of ["base-q5_1", "small-q5_1", "large-v3-turbo-q5_0", "large-v3-turbo"] as const) {
const info = voiceModelInfo(id);
assert.equal(info.id, id);
assert.equal(voiceModelBytes(id, "gpu"), info.bytes);
assert.equal(info.onnx.files.length, 3);
assert.equal(voiceModelBytes(id, "cpu"), info.onnx.files.reduce((s, f) => s + f.bytes, 0));
assert.ok(info.cpuSecondsPerMinute > 0);
}
assert.equal(voiceModelInfo("large-v3-turbo").onnx.size, voiceModelInfo("large-v3-turbo-q5_0").onnx.size, "both turbo files are one export on the processor");
});
});
/** A model "on disk" without a download: files of the catalogue's exact
* sizes, sparse, so the test writes nothing but inodes. */
function fakeModel(id: "small-q5_1"): void {
const touch = (file: string, bytes: number): void => {
mkdirSync(path.dirname(file), { recursive: true });
const fd = openSync(file, "w");
ftruncateSync(fd, bytes);
closeSync(fd);
};
const info = voiceModelInfo(id);
touch(modelPath(id), info.bytes);
for (const f of info.onnx.files) touch(path.join(onnxDir(id), f.file), f.bytes);
}
async function engineState(): Promise<VoiceEngineState> {
const res = await voiceRoutes.request("/voice/engine");
assert.equal(res.status, 200);
return (await res.json()) as VoiceEngineState;
}
describe("the backend, and the fallback from a GPU that fails", () => {
const data = makeDir();
const env = { ...process.env };
before(() => {
initSite({ ASTROLABE_DATA: data });
fakeModel("small-q5_1");
assert.ok(modelReady("small-q5_1", "gpu") && modelReady("small-q5_1", "cpu"));
});
beforeEach(() => {
resetEngine();
process.env.ASTROLABE_VOICE_WORKER = FAKE;
delete process.env.FAKE_VOICE_GPU;
delete process.env.FAKE_VOICE_PROBE;
delete process.env.ASTROLABE_WHISPER_FAIL_GPU;
});
after(() => {
resetEngine();
for (const k of ["ASTROLABE_VOICE_WORKER", "FAKE_VOICE_GPU", "FAKE_VOICE_PROBE", "ASTROLABE_WHISPER_FAIL_GPU"]) {
if (env[k] === undefined) delete process.env[k];
else process.env[k] = env[k];
}
removeVault(data);
});
const audio = new Uint8Array([1, 2, 3]);
it("cpu never asks about a GPU: the job goes to the processor with the thread rule's count", async () => {
const out = await transcribeInChild(audio, "wav", "small-q5_1", "auto", "cpu");
assert.equal(out.text, `cpu small ${engineThreads()}`);
assert.equal(engineBackend(), "cpu");
assert.equal(knownEngine("auto"), null, "the GPU builds were never probed");
});
it("auto on a machine whose GPU build loads but finds no GPU: silently the processor", async () => {
const out = await transcribeInChild(audio, "wav", "small-q5_1", "en", "auto");
assert.equal(out.text, `cpu small ${engineThreads()}`, "the same recording, heard again on the processor");
assert.equal(engineBackend(), "cpu");
assert.equal(knownEngine("auto"), "cpu", "and the GPU is not tried again this run");
});
it("auto when the GPU build takes the transcriber down mid-job: the processor, in a new child", async () => {
process.env.FAKE_VOICE_GPU = "crash";
const out = await transcribeInChild(audio, "wav", "small-q5_1", "auto", "auto");
assert.equal(out.text, `cpu small ${engineThreads()}`);
assert.equal(engineBackend(), "cpu");
});
it("auto when no GPU build loads at all: the processor from the first job", async () => {
process.env.FAKE_VOICE_PROBE = "cpu";
assert.equal(await probeGpu(), "cpu");
assert.equal(knownEngine("auto"), "cpu");
const out = await transcribeInChild(audio, "wav", "small-q5_1", "auto", "auto");
assert.equal(out.text, `cpu small ${engineThreads()}`);
});
it("a processor engine that will not load falls to whisper.cpp's own CPU build, and still lands words", async () => {
process.env.FAKE_VOICE_CPU = "missing";
try {
const out = await transcribeInChild(audio, "wav", "small-q5_1", "auto", "cpu");
assert.equal(out.text, `floor small ${engineThreads()}`);
assert.equal(engineBackend(), "cpu");
} finally {
delete process.env.FAKE_VOICE_CPU;
}
});
it("the real transcriber, with every GPU build forced to refuse, answers the probe with the processor", async () => {
delete process.env.ASTROLABE_VOICE_WORKER;
process.env.ASTROLABE_WHISPER_FAIL_GPU = "1";
assert.equal(await probeGpu(), "cpu");
assert.equal(knownEngine("auto"), "cpu");
});
it("the status line then says the model ran on the processor", async () => {
await transcribeInChild(audio, "wav", "small-q5_1", "auto", "auto");
const state = await engineState();
assert.equal(state.model, "small-q5_1");
assert.equal(state.choice, "auto");
assert.equal(state.engine, "cpu");
assert.equal(state.backend, "cpu");
assert.equal(state.bytes, voiceModelBytes("small-q5_1", "cpu"), "the size is the processor's files");
assert.equal(state.downloaded, state.bytes);
const { installDictionary, setLang } = await import("../client/i18n.ts");
const en = (await import("../client/i18n/en.ts")).default;
const ar = (await import("../client/i18n/ar.ts")).default;
const { voiceStatusLine } = await import("../client/components/settings/voiceStatus.ts");
installDictionary("en", en);
installDictionary("ar", ar);
setLang("en");
// tf() isolates each filled-in word (FSI … PDI); the reader sees the words.
const plain = (s: string | null): string => (s ?? "").replace(/[\u2066-\u2069]/g, "");
const line = plain(voiceStatusLine("small-q5_1", "auto", "small-q5_1|auto", state));
assert.match(line, /on the processor/);
assert.match(line, new RegExp(en.voiceModelSmall.replace(/[()]/g, "\\$&")), "and names the model in use");
setLang("ar");
assert.match(plain(voiceStatusLine("small-q5_1", "auto", "small-q5_1|auto", state)), /على المعالج/);
setLang("en");
});
it("before any job, a cpu choice already says where it will run", async () => {
const state: VoiceEngineState = { ...(await engineState()), choice: "cpu", engine: "cpu", backend: null };
const { voiceStatusLine } = await import("../client/components/settings/voiceStatus.ts");
const line = (voiceStatusLine("small-q5_1", "cpu", "small-q5_1|cpu", state) ?? "").replace(/[\u2066-\u2069]/g, "");
assert.match(line, /runs on the processor/);
});
});