426e9eeabd
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
Chat shell gestures / Chat shell gesture + parity e2e (push) Has been cancelled
Cloud Gateway Discord / Test (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Has been cancelled
Build Agent Image / build-and-push (push) Has been cancelled
Dev Smoke / bun run dev onboarding chat (push) Has been cancelled
Dev Smoke / Vite HMR dependency-level smoke (push) Has been cancelled
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Has been cancelled
Publish @elizaos/example-code / check_npm (push) Has been cancelled
Publish @elizaos/example-code / publish_npm (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / verify_version (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / publish_npm (push) Has been cancelled
Sandbox Live Smoke / Sandbox live smoke (push) Has been cancelled
Snap Build & Test / Build Snap (amd64) (push) Has been cancelled
Snap Build & Test / Build Snap (arm64) (push) Has been cancelled
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Has been cancelled
Cloud Gateway Webhook / Test (push) Has been cancelled
Cloud Tests / lint-and-types (push) Has been cancelled
Cloud Tests / unit-tests (push) Has been cancelled
Cloud Tests / integration-tests (push) Has been cancelled
Cloud Tests / e2e-tests (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Apps Worker (Product 2) / Determine environment (push) Has been cancelled
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Has been cancelled
Deploy Eliza Provisioning Worker / Determine environment (push) Has been cancelled
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Has been cancelled
Dev Smoke / Classify changed paths (push) Has been cancelled
supply-chain / sbom (push) Has been cancelled
supply-chain / vulnerability-scan (push) Has been cancelled
Build, Push & Deploy to Phala Cloud / build-and-push (push) Has been cancelled
Test Packaging / Validate Packaging Configs (push) Has been cancelled
Test Packaging / Build & Test PyPI Package (push) Has been cancelled
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Has been cancelled
Test Packaging / Pack & Test JS Tarballs (push) Has been cancelled
UI Fixture E2E / ui-fixture-e2e (push) Has been cancelled
UI Fixture E2E / fixture-e2e (push) Has been cancelled
UI Story Gate / story-gate (push) Has been cancelled
vault-ci / test (macos-latest) (push) Has been cancelled
vault-ci / test (ubuntu-latest) (push) Has been cancelled
vault-ci / test (windows-latest) (push) Has been cancelled
vault-ci / app-core wiring tests (push) Has been cancelled
verify-patches / verify patches/CHECKSUMS.sha256 (push) Has been cancelled
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Has been cancelled
Voice Benchmark Smoke / voice bench smoke summary (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Has been cancelled
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Has been cancelled
268 lines
8.7 KiB
TypeScript
268 lines
8.7 KiB
TypeScript
/**
|
|
* Shared plumbing for the #10726 real voice benchmark lanes. Bun-only (bun:ffi).
|
|
*
|
|
* Conventions (matches asr-real-smoke / kokoro-real-smoke):
|
|
* exit 0 = pass, exit 1 = real failure, exit 2 = honest skip (assets absent).
|
|
* A lane-specific `*_REQUIRE=1` env flips every skip into a hard failure so a
|
|
* CI job that STAGED the assets goes RED when they are missing.
|
|
*
|
|
* Corpus: fixed-transcript utterances synthesized ONCE with the shipped Kokoro
|
|
* TTS (real weights; #11238 raw-text path + espeak-linked lib required for
|
|
* intelligible audio) and cached under
|
|
* `<stateDir>/local-inference/voice-bench-corpus/<name>-v1/` as 16 kHz mono
|
|
* PCM16 WAVs + manifest. Reruns hit the cache and need no TTS at all.
|
|
*/
|
|
|
|
import { spawn } from "node:child_process";
|
|
import {
|
|
existsSync,
|
|
mkdirSync,
|
|
mkdtempSync,
|
|
readFileSync,
|
|
rmSync,
|
|
symlinkSync,
|
|
writeFileSync,
|
|
} from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { resolveFusedLibraryPath } from "../src/services/desktop-fused-ffi-backend-runtime";
|
|
import type { BenchCorpusEntry } from "../src/services/voice/bench-utils";
|
|
import { decodeMonoPcm16Wav } from "../src/services/voice/engine-bridge";
|
|
import {
|
|
type ElizaInferenceFfi,
|
|
loadElizaInferenceFfi,
|
|
} from "../src/services/voice/ffi-bindings";
|
|
import { resolveKokoroEngineConfig } from "../src/services/voice/kokoro/kokoro-engine-discovery";
|
|
import { localInferenceRoot } from "../src/services/paths";
|
|
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
|
|
export const BENCH_SAMPLE_RATE = 16_000;
|
|
const CORPUS_SCHEMA = 1;
|
|
|
|
export interface BenchGates {
|
|
skip(msg: string): never;
|
|
fail(msg: string): never;
|
|
required: boolean;
|
|
}
|
|
|
|
/** skip()/fail() pair with the repo's exit-code + REQUIRE-env contract. */
|
|
export function makeBenchGates(tag: string, requireEnvName: string): BenchGates {
|
|
const v = process.env[requireEnvName]?.trim().toLowerCase();
|
|
const required = v === "1" || v === "true" || v === "yes";
|
|
return {
|
|
required,
|
|
skip(msg: string): never {
|
|
if (required) {
|
|
console.error(`[${tag}] FAIL (${requireEnvName} set): ${msg}`);
|
|
process.exit(1);
|
|
}
|
|
console.log(`[${tag}] SKIP: ${msg}`);
|
|
process.exit(2);
|
|
},
|
|
fail(msg: string): never {
|
|
console.error(`[${tag}] FAIL: ${msg}`);
|
|
process.exit(1);
|
|
},
|
|
};
|
|
}
|
|
|
|
/** Load the fused lib (ABI v12) or skip. */
|
|
export function bootFusedFfi(gates: BenchGates): {
|
|
ffi: ElizaInferenceFfi;
|
|
libPath: string;
|
|
} {
|
|
if (typeof (globalThis as { Bun?: unknown }).Bun === "undefined") {
|
|
gates.skip("not running under bun (bun:ffi required) — invoke with `bun`");
|
|
}
|
|
const libPath = resolveFusedLibraryPath(null, process.env);
|
|
if (!libPath) {
|
|
gates.skip(
|
|
"fused lib not found (set ELIZA_INFERENCE_LIBRARY / ELIZA_INFERENCE_LIB_DIR, " +
|
|
"or run `bun run build:fused-desktop` in packages/app-core)",
|
|
);
|
|
}
|
|
const ffi = loadElizaInferenceFfi(libPath);
|
|
if (ffi.libraryAbiVersion !== "12") {
|
|
gates.fail(`expected fused-lib ABI v12, got v${ffi.libraryAbiVersion}`);
|
|
}
|
|
return { ffi, libPath };
|
|
}
|
|
|
|
export interface CorpusItem extends BenchCorpusEntry {
|
|
wavPath: string;
|
|
pcm: Float32Array;
|
|
seconds: number;
|
|
}
|
|
|
|
function corpusRoot(): string {
|
|
return (
|
|
process.env.ELIZA_VOICE_BENCH_CORPUS_DIR?.trim() ||
|
|
path.join(localInferenceRoot(), "voice-bench-corpus")
|
|
);
|
|
}
|
|
|
|
interface CorpusManifest {
|
|
schemaVersion: number;
|
|
entries: Array<{ id: string; voiceId: string; text: string }>;
|
|
}
|
|
|
|
/**
|
|
* Return the cached corpus, synthesizing any missing utterances with the real
|
|
* Kokoro TTS first (parallel worker subprocesses — the Kokoro forward pass is
|
|
* effectively single-threaded on desktop CPU). Skips when the cache is
|
|
* incomplete AND no Kokoro model is staged.
|
|
*/
|
|
export async function ensureKokoroCorpus(
|
|
name: string,
|
|
entries: readonly BenchCorpusEntry[],
|
|
gates: BenchGates,
|
|
log: (msg: string) => void,
|
|
): Promise<CorpusItem[]> {
|
|
const dir = path.join(corpusRoot(), `${name}-v${CORPUS_SCHEMA}`);
|
|
mkdirSync(dir, { recursive: true });
|
|
const manifestPath = path.join(dir, "manifest.json");
|
|
const manifest: CorpusManifest = {
|
|
schemaVersion: CORPUS_SCHEMA,
|
|
entries: entries.map((e) => ({ id: e.id, voiceId: e.voiceId, text: e.text })),
|
|
};
|
|
// A stale manifest (different texts/voices) invalidates the whole cache.
|
|
if (existsSync(manifestPath)) {
|
|
const existing = readFileSync(manifestPath, "utf8");
|
|
if (existing !== JSON.stringify(manifest, null, "\t")) {
|
|
log(`corpus ${name}: manifest changed — regenerating all utterances`);
|
|
for (const e of entries) rmSync(path.join(dir, `${e.id}.wav`), { force: true });
|
|
}
|
|
}
|
|
|
|
const missing = entries.filter((e) => !existsSync(path.join(dir, `${e.id}.wav`)));
|
|
if (missing.length > 0) {
|
|
const kokoro = resolveKokoroEngineConfig();
|
|
if (!kokoro) {
|
|
gates.skip(
|
|
`corpus ${name}: ${missing.length}/${entries.length} utterances missing and no Kokoro model staged ` +
|
|
"(set ELIZA_KOKORO_MODEL_DIR to a dir with kokoro-82m-v1_0*.gguf + voices/<v>.bin)",
|
|
);
|
|
}
|
|
log(
|
|
`corpus ${name}: synthesizing ${missing.length} utterance(s) with Kokoro ` +
|
|
`(${kokoro.layout.modelFile}) — one-time, cached in ${dir}`,
|
|
);
|
|
const workers = Math.max(
|
|
1,
|
|
Math.min(
|
|
Number(process.env.ELIZA_VOICE_BENCH_WORKERS ?? "6") || 6,
|
|
missing.length,
|
|
),
|
|
);
|
|
const queue = [...missing];
|
|
const failures: string[] = [];
|
|
await Promise.all(
|
|
Array.from({ length: workers }, async () => {
|
|
for (;;) {
|
|
const entry = queue.shift();
|
|
if (!entry) return;
|
|
const out = path.join(dir, `${entry.id}.wav`);
|
|
const started = Date.now();
|
|
const code = await runSynthWorker(entry, out);
|
|
if (code !== 0) {
|
|
failures.push(`${entry.id} (exit ${code})`);
|
|
return;
|
|
}
|
|
log(
|
|
`corpus ${name}: ${entry.id} [${entry.voiceId}] synthesized in ${((Date.now() - started) / 1000).toFixed(1)}s`,
|
|
);
|
|
}
|
|
}),
|
|
);
|
|
if (failures.length > 0) {
|
|
gates.fail(`corpus ${name}: Kokoro synthesis failed for ${failures.join(", ")}`);
|
|
}
|
|
writeFileSync(manifestPath, JSON.stringify(manifest, null, "\t"));
|
|
} else if (!existsSync(manifestPath)) {
|
|
writeFileSync(manifestPath, JSON.stringify(manifest, null, "\t"));
|
|
}
|
|
|
|
return entries.map((e) => {
|
|
const wavPath = path.join(dir, `${e.id}.wav`);
|
|
const { pcm, sampleRate } = decodeMonoPcm16Wav(
|
|
new Uint8Array(readFileSync(wavPath)),
|
|
);
|
|
if (sampleRate !== BENCH_SAMPLE_RATE) {
|
|
gates.fail(
|
|
`corpus ${name}: ${e.id}.wav is ${sampleRate} Hz, expected ${BENCH_SAMPLE_RATE} (delete ${dir} to regenerate)`,
|
|
);
|
|
}
|
|
return { ...e, wavPath, pcm, seconds: pcm.length / sampleRate };
|
|
});
|
|
}
|
|
|
|
function runSynthWorker(entry: BenchCorpusEntry, outPath: string): Promise<number> {
|
|
return new Promise((resolve) => {
|
|
const child = spawn(
|
|
process.execPath, // bun
|
|
[
|
|
path.join(__dirname, "voice-bench-synth-worker.ts"),
|
|
"--voice",
|
|
entry.voiceId,
|
|
"--out",
|
|
outPath,
|
|
"--text-b64",
|
|
Buffer.from(entry.text, "utf8").toString("base64"),
|
|
],
|
|
{ stdio: ["ignore", "inherit", "inherit"], env: process.env },
|
|
);
|
|
child.on("close", (code) => resolve(code ?? 1));
|
|
child.on("error", () => resolve(1));
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Stage one ASR quant as a bundle dir the fused lib understands
|
|
* (`asr/eliza-1-asr.gguf` + `asr/eliza-1-asr-mmproj.gguf` symlinks).
|
|
*/
|
|
export function makeQuantBundle(
|
|
quantGgufPath: string,
|
|
mmprojPath: string,
|
|
): { dir: string; cleanup: () => void } {
|
|
const dir = mkdtempSync(path.join(os.tmpdir(), "stt-quant-bundle-"));
|
|
mkdirSync(path.join(dir, "asr"));
|
|
symlinkSync(quantGgufPath, path.join(dir, "asr", "eliza-1-asr.gguf"));
|
|
symlinkSync(mmprojPath, path.join(dir, "asr", "eliza-1-asr-mmproj.gguf"));
|
|
return { dir, cleanup: () => rmSync(dir, { recursive: true, force: true }) };
|
|
}
|
|
|
|
/** Write the JSON + Markdown report pair and return their paths. */
|
|
export function writeBenchReport(
|
|
outDir: string,
|
|
baseName: string,
|
|
payload: object,
|
|
markdown: string,
|
|
): { jsonPath: string; mdPath: string } {
|
|
mkdirSync(outDir, { recursive: true });
|
|
const jsonPath = path.join(outDir, `${baseName}.json`);
|
|
const mdPath = path.join(outDir, `${baseName}.md`);
|
|
writeFileSync(jsonPath, JSON.stringify(payload, null, "\t"));
|
|
writeFileSync(mdPath, markdown);
|
|
return { jsonPath, mdPath };
|
|
}
|
|
|
|
/** Default report dir (gitignored): `<plugin>/voice-bench-output/`. */
|
|
export function defaultReportDir(): string {
|
|
return (
|
|
process.env.ELIZA_VOICE_BENCH_OUT?.trim() ||
|
|
path.resolve(__dirname, "..", "voice-bench-output")
|
|
);
|
|
}
|
|
|
|
export const mean = (xs: readonly number[]): number =>
|
|
xs.length === 0 ? 0 : xs.reduce((a, b) => a + b, 0) / xs.length;
|
|
|
|
export const median = (xs: readonly number[]): number => {
|
|
if (xs.length === 0) return 0;
|
|
const s = [...xs].sort((a, b) => a - b);
|
|
const mid = Math.floor(s.length / 2);
|
|
return s.length % 2 === 1 ? s[mid] : (s[mid - 1] + s[mid]) / 2;
|
|
};
|