#!/usr/bin/env bun
/*
* e2e_loop_bench.mjs — Eliza-1 end-to-end mic→speech voice-loop benchmark.
*
* Drives the *real* fused runtime — the omnivoice-grafted `llama-server`
* (`/completion` + `/v1/audio/speech` + the in-process MTP speculative
* loop) and `libelizainference.{so,dylib}`'s ASR FFI — through one or more
* complete voice turns:
*
* WAV (mic) → ASR transcribe (FFI) → text generate w/ MTP spec decode
* → phrase chunker → OmniVoice TTS (HTTP /v1/audio/speech) → PCM out
*
* It measures, per turn and aggregated:
* - ASR latency + WER (vs the reference text the WAV was synthesized from)
* - first-token latency (ASR-done → first decoded text token, SSE)
* - decode tokens/sec
* - MTP acceptance rate (n_drafted_accepted / n_drafted from /metrics)
* - first-audio latency (first text token → first PCM sample of the first phrase)
* - TTS RTF (wall-seconds / audio-seconds)
* - total turn latency (mic-in → last PCM sample)
* - peak RSS (server VmHWM via /proc, when available)
* - barge-in cancel latency (in-flight TTS HTTP request abort → stop consuming)
*
* `--turns 30` runs the 30-turn endurance variant: 30 loops, asserts no
* crash / no monotone RSS leak, and asserts peak RSS stays inside the
* manifest's `ramBudgetMb.recommended`.
*
* Hard requirements (per packages/inference/AGENTS.md §4 / §8): one process,
* one llama.cpp build, one GGML pin — text + MTP + TTS all in the fused
* `llama-server`; ASR via the fused FFI library; no second model process.
*
* Honesty: a backend whose fused build is not installed, or a bundle whose
* artifacts are missing, produces `status: "needs-bundle"` / `"needs-build"`
* with a reason and null metrics — never a fabricated pass.
*
* bun packages/inference/verify/e2e_loop_bench.mjs \
* --bundle ~/.eliza/local-inference/models/eliza-1-2b.bundle \
* --tier 2b --backend cpu [--turns 1] [--wav a.wav,b.wav] [--report out.json]
*/
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { spawn } from "node:child_process";
import { fileURLToPath } from "node:url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const REPO_ROOT = path.resolve(__dirname, "..", "..", "..");
// --------------------------------------------------------------------------
// CLI
// --------------------------------------------------------------------------
function parseArgs(argv) {
const args = {
bundle: process.env.ELIZA_E2E_BUNDLE || "",
tier: process.env.ELIZA_E2E_TIER || "",
backend: process.env.ELIZA_E2E_BACKEND || "cpu", // cpu | vulkan | cuda
binDir: process.env.ELIZA_E2E_BIN_DIR || "",
wavs: (process.env.ELIZA_E2E_WAVS || "")
.split(",")
.map((s) => s.trim())
.filter(Boolean),
refs: (process.env.ELIZA_E2E_REFS || "")
.split("|")
.map((s) => s.trim())
.filter(Boolean),
turns: Number.parseInt(process.env.ELIZA_E2E_TURNS || "1", 10),
nPredict: Number.parseInt(process.env.ELIZA_E2E_N_PREDICT || "40", 10),
enduranceNPredict: Number.parseInt(process.env.ELIZA_E2E_ENDURANCE_N_PREDICT || "12", 10),
ttsAllPhrases: process.env.ELIZA_E2E_TTS_ALL_PHRASES === "1",
ttsSteps: Number.parseInt(process.env.ELIZA_E2E_TTS_STEPS || "32", 10),
micTtsSteps: Number.parseInt(process.env.ELIZA_E2E_MIC_TTS_STEPS || "32", 10),
ttsRoundtripAsr: process.env.ELIZA_E2E_TTS_ROUNDTRIP_ASR === "1",
ttsAudioDir: process.env.ELIZA_E2E_TTS_AUDIO_DIR || "",
phraseMaxTokens: Number.parseInt(process.env.ELIZA_E2E_PHRASE_MAX_TOKENS || "8", 10),
maxTtsPhrases: Number.parseInt(process.env.ELIZA_E2E_MAX_TTS_PHRASES || "3", 10),
threads: Number.parseInt(
process.env.ELIZA_E2E_THREADS || String(Math.min(os.cpus().length, 12)),
10,
),
ctx: Number.parseInt(process.env.ELIZA_E2E_CTX || "2048", 10),
ngl: process.env.ELIZA_E2E_NGL || "0",
startTimeoutS: Number.parseInt(
process.env.ELIZA_E2E_START_TIMEOUT || "180",
10,
),
turnTimeoutS: Number.parseInt(
process.env.ELIZA_E2E_TURN_TIMEOUT || "240",
10,
),
report: process.env.ELIZA_E2E_REPORT || "",
quiet: process.env.ELIZA_E2E_QUIET === "1",
};
for (let i = 0; i < argv.length; i += 1) {
const a = argv[i];
const next = () => {
i += 1;
if (i >= argv.length) throw new Error(`missing value for ${a}`);
return argv[i];
};
if (a === "--bundle" || a === "--bundle-dir") args.bundle = next();
else if (a === "--tier") args.tier = next();
else if (a === "--backend") args.backend = next();
else if (a === "--bin-dir") args.binDir = next();
else if (a === "--wav" || a === "--wavs")
args.wavs = next()
.split(",")
.map((s) => s.trim())
.filter(Boolean);
else if (a === "--ref" || a === "--refs")
args.refs = next()
.split("|")
.map((s) => s.trim())
.filter(Boolean);
else if (a === "--turns") args.turns = Number.parseInt(next(), 10);
else if (a === "--n-predict") args.nPredict = Number.parseInt(next(), 10);
else if (a === "--endurance-n-predict") args.enduranceNPredict = Number.parseInt(next(), 10);
else if (a === "--tts-all-phrases") args.ttsAllPhrases = true;
else if (a === "--tts-steps") args.ttsSteps = Number.parseInt(next(), 10);
else if (a === "--mic-tts-steps") args.micTtsSteps = Number.parseInt(next(), 10);
else if (a === "--tts-roundtrip-asr") args.ttsRoundtripAsr = true;
else if (a === "--tts-audio-dir") args.ttsAudioDir = next();
else if (a === "--phrase-max-tokens") args.phraseMaxTokens = Number.parseInt(next(), 10);
else if (a === "--max-tts-phrases") args.maxTtsPhrases = Number.parseInt(next(), 10);
else if (a === "--threads") args.threads = Number.parseInt(next(), 10);
else if (a === "--ctx") args.ctx = Number.parseInt(next(), 10);
else if (a === "--ngl") args.ngl = next();
else if (a === "--report") args.report = next();
else if (a === "--quiet") args.quiet = true;
else if (a === "--help" || a === "-h") {
console.log(
"Usage: bun packages/inference/verify/e2e_loop_bench.mjs --bundle
--tier [--backend cpu|vulkan|cuda|metal] [--turns N] [--wav a,b] [--report out.json]",
);
process.exit(0);
} else throw new Error(`unknown argument: ${a}`);
}
return args;
}
const log = (q) => (...m) => {
if (!q) console.log("[e2e-loop]", ...m);
};
// --------------------------------------------------------------------------
// Discovery
// --------------------------------------------------------------------------
function stateRoot() {
return (
process.env.ELIZA_STATE_DIR?.trim() ||
process.env.ELIZA_STATE_DIR?.trim() ||
path.join(os.homedir(), ".eliza")
);
}
function platformTag() {
const sys =
{ darwin: "darwin", linux: "linux", win32: "windows" }[process.platform] ||
process.platform;
const arch = { x64: "x64", arm64: "arm64" }[process.arch] || process.arch;
return `${sys}-${arch}`;
}
function libName() {
if (process.platform === "darwin") return "libelizainference.dylib";
if (process.platform === "win32") return "libelizainference.dll";
return "libelizainference.so";
}
/**
* Find the fused `llama-server` build dir for the requested backend. We
* require a *fused* build (`fused: true` in CAPABILITIES.json) because the
* AGENTS.md §4 contract is one fused process — a stock build can't serve
* `/v1/audio/speech`.
*/
function discoverEngine(backend, explicitBinDir) {
const root = path.join(stateRoot(), "local-inference", "bin", "mtp");
if (explicitBinDir) {
return validateEngineDir(explicitBinDir, backend);
}
if (!fs.existsSync(root)) {
return { ok: false, reason: `${root} does not exist (no managed builds)` };
}
const plat = platformTag();
const prefer = `${plat}-${backend}-fused`;
const dirs = fs
.readdirSync(root)
.filter((d) => fs.statSync(path.join(root, d)).isDirectory())
.filter((d) => d.startsWith(plat));
const anyFused = dirs.filter((d) => d.includes("-fused"));
// Honesty: a `--backend X` run must use an `X`-fused build, not silently
// fall back to a different backend's fused build (that would mislabel the
// numbers). Exact `${plat}-${backend}-fused` only, then a `-${backend}-`
// fused variant; if neither exists, this is `needs-build` for that backend.
const pick =
dirs.find((d) => d === prefer) || anyFused.find((d) => d.includes(`-${backend}-`)) || null;
if (!pick) {
return {
ok: false,
reason: `no fused ${backend} build dir for ${plat} under ${root} (have fused: ${anyFused.join(", ") || "none"}; pass --bin-dir to override)`,
};
}
return validateEngineDir(path.join(root, pick), backend);
}
function validateEngineDir(dir, backend) {
const server = path.join(dir, "llama-server");
const lib = path.join(dir, libName());
if (!fs.existsSync(server)) {
return { ok: false, reason: `${server} missing (not a built fused dir)` };
}
let caps = null;
const capsPath = path.join(dir, "CAPABILITIES.json");
if (fs.existsSync(capsPath)) {
try {
caps = JSON.parse(fs.readFileSync(capsPath, "utf8"));
} catch {
caps = null;
}
}
const fused = caps?.fused === true || (caps?.omnivoice ?? null) !== null || dir.includes("-fused");
if (!fused) {
return { ok: false, reason: `${dir} is not an omnivoice-fused build (no /v1/audio/speech route)` };
}
const actualBackend = caps?.backend || backend;
return {
ok: true,
dir,
server,
lib: fs.existsSync(lib) ? lib : null,
speculative: fs.existsSync(path.join(dir, "llama-speculative-simple"))
? path.join(dir, "llama-speculative-simple")
: null,
backend: actualBackend,
backendMismatch: actualBackend !== backend ? `requested ${backend}, build is ${actualBackend}` : null,
caps,
};
}
function firstExisting(...c) {
return c.find((p) => p && fs.existsSync(p)) || null;
}
function readJsonIfPresent(p) {
if (!p || !fs.existsSync(p)) return null;
try {
return JSON.parse(fs.readFileSync(p, "utf8"));
} catch {
return null;
}
}
function bundleFiles(bundleDir, tier) {
const text = firstExisting(
...fs
.readdirSync(path.join(bundleDir, "text"))
.filter((f) => f.endsWith(".gguf"))
.sort()
.map((f) => path.join(bundleDir, "text", f)),
);
const drafter = firstExisting(
...fs
.readdirSync(path.join(bundleDir, "mtp"))
.filter((f) => f.endsWith(".gguf"))
.map((f) => path.join(bundleDir, "mtp", f)),
);
const ttsDir = path.join(bundleDir, "tts");
const ttsGgufs = fs.existsSync(ttsDir)
? fs.readdirSync(ttsDir).filter((f) => f.endsWith(".gguf"))
: [];
const ttsTok = ttsGgufs.find((f) => /token/i.test(f));
const ttsBase = ttsGgufs.find((f) => !/token/i.test(f)) || ttsGgufs[0];
const manifestPath = path.join(bundleDir, "eliza-1.manifest.json");
const manifest = readJsonIfPresent(manifestPath);
const mtpDisabledPolicy = firstExisting(
path.join(bundleDir, "mtp", `mtp-disabled-${tier}.release-policy.json`),
path.join(bundleDir, "mtp", "mtp-disabled.release-policy.json"),
);
return {
text,
drafter,
mtpDisabledPolicy,
mtpDisabled: !!mtpDisabledPolicy,
ttsModel: ttsBase ? path.join(ttsDir, ttsBase) : null,
ttsCodec: ttsTok ? path.join(ttsDir, ttsTok) : null,
asr: firstExisting(
path.join(bundleDir, "asr", "eliza-1-asr.gguf"),
...(fs.existsSync(path.join(bundleDir, "asr"))
? fs
.readdirSync(path.join(bundleDir, "asr"))
.filter((f) => f.endsWith(".gguf") && !/mmproj/i.test(f))
.map((f) => path.join(bundleDir, "asr", f))
: []),
),
manifest,
tier: manifest?.tier || tier,
ramRecommendedMb: manifest?.ramBudgetMb?.recommended ?? null,
ramMinMb: manifest?.ramBudgetMb?.min ?? null,
};
}
function tierUsesOmniVoice(tier) {
return tier === "9b" || tier === "27b" || tier === "27b-256k";
}
function isRealGguf(p, minBytes = 1_000_000) {
if (!p || !fs.existsSync(p)) return false;
try {
if (fs.statSync(p).size < minBytes) return false;
const fd = fs.openSync(p, "r");
const buf = Buffer.alloc(4);
fs.readSync(fd, buf, 0, 4, 0);
fs.closeSync(fd);
return buf.toString("utf8") === "GGUF";
} catch {
return false;
}
}
// --------------------------------------------------------------------------
// WAV helpers (16-bit PCM read/write + simple linear resample)
// --------------------------------------------------------------------------
function readWav(file) {
const buf = fs.readFileSync(file);
if (buf.toString("ascii", 0, 4) !== "RIFF" || buf.toString("ascii", 8, 12) !== "WAVE") {
throw new Error(`${file}: not a RIFF/WAVE file`);
}
let off = 12;
let fmt = null;
let dataOff = -1;
let dataLen = 0;
while (off + 8 <= buf.length) {
const id = buf.toString("ascii", off, off + 4);
const sz = buf.readUInt32LE(off + 4);
const body = off + 8;
if (id === "fmt ") {
fmt = {
audioFormat: buf.readUInt16LE(body),
channels: buf.readUInt16LE(body + 2),
sampleRate: buf.readUInt32LE(body + 4),
bitsPerSample: buf.readUInt16LE(body + 14),
};
} else if (id === "data") {
dataOff = body;
dataLen = sz;
}
off = body + sz + (sz % 2);
}
if (!fmt || dataOff < 0) throw new Error(`${file}: missing fmt/data chunk`);
if (fmt.bitsPerSample !== 16) {
throw new Error(`${file}: only 16-bit PCM supported (got ${fmt.bitsPerSample})`);
}
const nFrames = Math.floor(dataLen / 2 / fmt.channels);
const mono = new Float32Array(nFrames);
for (let i = 0; i < nFrames; i += 1) {
let acc = 0;
for (let c = 0; c < fmt.channels; c += 1) {
acc += buf.readInt16LE(dataOff + (i * fmt.channels + c) * 2);
}
mono[i] = acc / fmt.channels / 32768;
}
return { sampleRate: fmt.sampleRate, samples: mono };
}
function resampleLinear(samples, fromHz, toHz) {
if (fromHz === toHz) return samples;
const ratio = toHz / fromHz;
const out = new Float32Array(Math.max(1, Math.round(samples.length * ratio)));
for (let i = 0; i < out.length; i += 1) {
const src = i / ratio;
const i0 = Math.floor(src);
const i1 = Math.min(samples.length - 1, i0 + 1);
const frac = src - i0;
out[i] = samples[i0] * (1 - frac) + samples[i1] * frac;
}
return out;
}
function writeWav16(file, samples, sampleRate) {
const n = samples.length;
const buf = Buffer.alloc(44 + n * 2);
buf.write("RIFF", 0, "ascii");
buf.writeUInt32LE(36 + n * 2, 4);
buf.write("WAVE", 8, "ascii");
buf.write("fmt ", 12, "ascii");
buf.writeUInt32LE(16, 16);
buf.writeUInt16LE(1, 20); // PCM
buf.writeUInt16LE(1, 22); // mono
buf.writeUInt32LE(sampleRate, 24);
buf.writeUInt32LE(sampleRate * 2, 28);
buf.writeUInt16LE(2, 32);
buf.writeUInt16LE(16, 34);
buf.write("data", 36, "ascii");
buf.writeUInt32LE(n * 2, 40);
for (let i = 0; i < n; i += 1) {
let s = Math.max(-1, Math.min(1, samples[i]));
buf.writeInt16LE(Math.round(s * 32767), 44 + i * 2);
}
fs.writeFileSync(file, buf);
}
// --------------------------------------------------------------------------
// WER (Levenshtein over normalized word lists)
// --------------------------------------------------------------------------
function normalizeWords(s) {
return (s || "")
.toLowerCase()
.replace(/[^\p{L}\p{N}\s']/gu, " ")
.replace(/\s+/g, " ")
.trim()
.split(" ")
.filter(Boolean);
}
function wordErrorRate(ref, hyp) {
const r = normalizeWords(ref);
const h = normalizeWords(hyp);
if (r.length === 0) return h.length === 0 ? 0 : 1;
const dp = Array.from({ length: r.length + 1 }, () => new Array(h.length + 1).fill(0));
for (let i = 0; i <= r.length; i += 1) dp[i][0] = i;
for (let j = 0; j <= h.length; j += 1) dp[0][j] = j;
for (let i = 1; i <= r.length; i += 1) {
for (let j = 1; j <= h.length; j += 1) {
const cost = r[i - 1] === h[j - 1] ? 0 : 1;
dp[i][j] = Math.min(dp[i - 1][j] + 1, dp[i][j - 1] + 1, dp[i - 1][j - 1] + cost);
}
}
return dp[r.length][h.length] / r.length;
}
// --------------------------------------------------------------------------
// Phrase chunker — punctuation boundary + max-token cap (mirrors the runtime
// phrase-chunker contract: punctuation OR an N-token cap).
// --------------------------------------------------------------------------
function chunkPhrases(text, maxTokens = 8) {
const out = [];
const parts = text.split(/([.!?,;:]+)/);
let cur = "";
let tokCount = 0;
const flush = () => {
const t = cur.trim();
if (t) out.push(t);
cur = "";
tokCount = 0;
};
for (let i = 0; i < parts.length; i += 1) {
const seg = parts[i];
if (!seg) continue;
if (/^[.!?,;:]+$/.test(seg)) {
cur += seg;
flush();
continue;
}
const words = seg.split(/\s+/).filter(Boolean);
for (const w of words) {
cur += (cur && !/\s$/.test(cur) ? " " : "") + w;
tokCount += 1;
if (tokCount >= maxTokens) flush();
}
}
flush();
return out.length ? out : [text.trim()].filter(Boolean);
}
// --------------------------------------------------------------------------
// FFI: libelizainference ASR + TTS-cancel (bun:ffi)
// --------------------------------------------------------------------------
async function loadFfi(libPath, libDir) {
// Bun re-exports libllama symbols through libelizainference; the SONAMEd
// deps live next to it. Set DYLD/LD path so the loader resolves them.
const sep = path.delimiter;
const cur = process.env.LD_LIBRARY_PATH || "";
process.env.LD_LIBRARY_PATH = cur ? `${libDir}${sep}${cur}` : libDir;
if (process.platform === "darwin") {
const c2 = process.env.DYLD_LIBRARY_PATH || "";
process.env.DYLD_LIBRARY_PATH = c2 ? `${libDir}${sep}${c2}` : libDir;
}
const ffi = await import("bun:ffi");
const T = ffi.FFIType;
const lib = ffi.dlopen(libPath, {
eliza_inference_abi_version: { args: [], returns: T.cstring },
eliza_inference_create: { args: [T.cstring, T.ptr], returns: T.ptr },
eliza_inference_destroy: { args: [T.ptr], returns: T.void },
eliza_inference_mmap_acquire: { args: [T.ptr, T.cstring, T.ptr], returns: T.i32 },
eliza_inference_mmap_evict: { args: [T.ptr, T.cstring, T.ptr], returns: T.i32 },
eliza_inference_asr_transcribe: {
args: [T.ptr, T.ptr, T.usize, T.i32, T.ptr, T.usize, T.ptr],
returns: T.i32,
},
eliza_inference_tts_synthesize: {
args: [T.ptr, T.cstring, T.usize, T.ptr, T.ptr, T.usize, T.ptr],
returns: T.i32,
},
eliza_inference_tts_stream_supported: { args: [], returns: T.i32 },
eliza_inference_tts_synthesize_stream: {
args: [T.ptr, T.ptr, T.usize, T.ptr, T.usize, T.usize, T.ptr],
returns: T.i32,
},
eliza_inference_cancel_tts: { args: [T.ptr, T.ptr], returns: T.i32 },
eliza_inference_free_string: { args: [T.usize], returns: T.void },
});
const s = lib.symbols;
const abi = s.eliza_inference_abi_version();
return { ffi, lib, s, abi: typeof abi === "string" ? abi : String(abi) };
}
function readErrAndFree(ffi, s, ptrBuf) {
// ptrBuf is a 8-byte buffer holding the out_error char* the C side wrote.
let p;
try {
p = ffi.read.ptr(ptrBuf, 0);
} catch {
p = 0n;
}
if (!p || p === 0n) return "(no diagnostic)";
let msg = "(unreadable diagnostic)";
try {
msg = ffi.CString(p);
} catch {
/* leave default */
}
try {
s.eliza_inference_free_string(p);
} catch {
/* best-effort */
}
return msg;
}
function asrTranscribeFloat32(ffiCtx, ffi, s, samples, sampleRate) {
const pcm16k = resampleLinear(samples, sampleRate, 16000);
const pcmBuf = Buffer.from(pcm16k.buffer, pcm16k.byteOffset, pcm16k.byteLength);
const outBytes = 4096;
const outBuf = Buffer.alloc(outBytes);
const errBuf = Buffer.alloc(8);
errBuf.fill(0);
const t0 = performance.now();
const rc = s.eliza_inference_asr_transcribe(
ffiCtx,
ffi.ptr(pcmBuf),
BigInt(pcm16k.length),
16000,
ffi.ptr(outBuf),
BigInt(outBytes),
ffi.ptr(errBuf),
);
const latencyMs = performance.now() - t0;
if (rc < 0) {
throw new Error(`asr_transcribe rc=${rc}: ${readErrAndFree(ffi, s, errBuf)}`);
}
return {
latencyMs,
transcript: outBuf.toString("utf8", 0, rc).trim(),
};
}
// --------------------------------------------------------------------------
// HTTP helpers against the fused llama-server
// --------------------------------------------------------------------------
async function httpJson(url, body, signal) {
const res = await fetch(url, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
signal,
});
const text = await res.text();
let json = null;
try {
json = JSON.parse(text);
} catch {
/* not json */
}
return { status: res.status, json, text };
}
async function waitHealthy(port, timeoutS, child, logFn) {
const deadline = Date.now() + timeoutS * 1000;
while (Date.now() < deadline) {
if (child && child.exitCode !== null) {
throw new Error(`llama-server exited (code ${child.exitCode}) before becoming healthy`);
}
try {
const r = await fetch(`http://127.0.0.1:${port}/health`, {
signal: AbortSignal.timeout(2000),
});
if (r.status === 200) {
const j = await r.json().catch(() => ({}));
if (j.status === "ok") return true;
}
} catch {
/* not up yet */
}
await new Promise((r) => setTimeout(r, 500));
}
throw new Error(`llama-server not healthy after ${timeoutS}s`);
}
async function fetchSpecCounters(port) {
try {
const r = await fetch(`http://127.0.0.1:${port}/metrics`, {
signal: AbortSignal.timeout(3000),
});
if (r.status !== 200) return { drafted: null, accepted: null };
const t = await r.text();
const m = (re) => {
const x = t.match(re);
return x ? Number(x[1]) : null;
};
return {
drafted: m(/llamacpp:n_drafted_total\s+([\d.]+)/),
accepted: m(/llamacpp:n_drafted_accepted_total\s+([\d.]+)/),
};
} catch {
return { drafted: null, accepted: null };
}
}
function peakRssMb(pid) {
if (process.platform !== "linux") return null;
try {
const status = fs.readFileSync(`/proc/${pid}/status`, "utf8");
const m = status.match(/VmHWM:\s+(\d+)\s+kB/);
return m ? Math.round(Number(m[1]) / 1024) : null;
} catch {
return null;
}
}
// --------------------------------------------------------------------------
// SSE streaming completion — measures first-token + decode tok/s
// --------------------------------------------------------------------------
async function streamCompletion(port, prompt, nPredict, turnTimeoutS) {
const t0 = performance.now();
let firstTokenMs = null;
let content = "";
let tokensSeen = 0;
let timings = null;
const ctrl = new AbortController();
const to = setTimeout(() => ctrl.abort(), turnTimeoutS * 1000);
try {
const res = await fetch(`http://127.0.0.1:${port}/completion`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
prompt,
n_predict: nPredict,
temperature: 0,
stream: true,
cache_prompt: false,
}),
signal: ctrl.signal,
});
if (!res.ok || !res.body) {
const t = await res.text().catch(() => "");
throw new Error(`/completion HTTP ${res.status}: ${t.slice(0, 200)}`);
}
const decoder = new TextDecoder();
let buf = "";
for await (const chunk of res.body) {
buf += decoder.decode(chunk, { stream: true });
let idx;
while ((idx = buf.indexOf("\n\n")) >= 0) {
const ev = buf.slice(0, idx);
buf = buf.slice(idx + 2);
for (const line of ev.split("\n")) {
const l = line.trim();
if (!l.startsWith("data:")) continue;
const payload = l.slice(5).trim();
if (payload === "[DONE]") continue;
let obj;
try {
obj = JSON.parse(payload);
} catch {
continue;
}
if (typeof obj.content === "string" && obj.content.length) {
if (firstTokenMs === null) firstTokenMs = performance.now() - t0;
content += obj.content;
tokensSeen += 1;
}
if (obj.timings) timings = obj.timings;
if (obj.stop === true || obj.stopped_eos || obj.stopped_limit) {
if (obj.timings) timings = obj.timings;
}
}
}
}
} finally {
clearTimeout(to);
}
const wallMs = performance.now() - t0;
return {
content: content.trim(),
firstTokenMs,
tokensSeen,
wallMs,
decodeTokPerSec: timings?.predicted_per_second ?? (tokensSeen > 0 ? (tokensSeen * 1000) / wallMs : null),
predictedN: timings?.predicted_n ?? tokensSeen,
promptTokPerSec: timings?.prompt_per_second ?? null,
};
}
// --------------------------------------------------------------------------
// TTS phrase synthesis via /v1/audio/speech (raw f32 PCM)
// --------------------------------------------------------------------------
function estimateTtsDurationSec(text) {
const words = (String(text).trim().match(/[A-Za-z0-9']+/g) || []).length;
if (words <= 0) return null;
// OmniVoice's default duration predictor over-stretches terse assistant
// snippets ("The meeting is to" regularly becomes ~1.8s). A conservative
// word-rate hint keeps latency sane without clipping normal short phrases.
return Math.max(0.75, Math.min(12, 0.42 + words * 0.19));
}
async function synthPhrasePcm(port, text, turnTimeoutS, ttsSteps = 32, durationSec = null) {
const t0 = performance.now();
const ctrl = new AbortController();
const to = setTimeout(() => ctrl.abort(), turnTimeoutS * 1000);
try {
const body = { input: text, response_format: "pcm", num_step: ttsSteps, interactive: false };
if (Number.isFinite(durationSec) && durationSec > 0) body.duration = durationSec;
const res = await fetch(`http://127.0.0.1:${port}/v1/audio/speech`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
signal: ctrl.signal,
});
if (!res.ok) {
const t = await res.text().catch(() => "");
throw new Error(`/v1/audio/speech HTTP ${res.status}: ${t.slice(0, 200)}`);
}
const sr = Number(res.headers.get("x-sample-rate") || "24000");
const ab = await res.arrayBuffer();
const wallMs = performance.now() - t0;
const samples = new Float32Array(ab);
const audioSec = samples.length / sr;
return { sampleRate: sr, samples, audioSec, wallMs, rtf: audioSec > 0 ? wallMs / 1000 / audioSec : null };
} finally {
clearTimeout(to);
}
}
// --------------------------------------------------------------------------
// One voice turn
// --------------------------------------------------------------------------
async function runTurn(opts, turnIdx) {
const {
port,
ffiCtx,
ffi,
s,
wav,
refText,
nPredict,
turnTimeoutS,
logFn,
ttsAllPhrases,
ttsSteps,
maxTtsPhrases,
phraseMaxTokens,
ttsRoundtripAsr,
ttsAudioDir,
} = opts;
const turnT0 = performance.now();
// 1) ASR: feed the WAV's mono PCM (resampled to 16 kHz) to the FFI.
const inputAsr = asrTranscribeFloat32(ffiCtx, ffi, s, wav.samples, wav.sampleRate);
const asrMs = inputAsr.latencyMs;
const transcript = inputAsr.transcript;
const wer = refText ? wordErrorRate(refText, transcript) : null;
logFn(`turn ${turnIdx}: ASR ${asrMs.toFixed(0)}ms -> ${JSON.stringify(transcript)} (wer=${wer == null ? "n/a" : wer.toFixed(3)})`);
// 2) Text generation with MTP spec decoding (counters reset by diffing /metrics).
const before = await fetchSpecCounters(port);
const prompt = transcript.length > 0 ? transcript : (refText || "Hello.");
const gen = await streamCompletion(port, prompt, nPredict, turnTimeoutS);
const after = await fetchSpecCounters(port);
let mtpAccept = null;
let dDrafted = null;
let dAccepted = null;
if (
before.drafted != null &&
after.drafted != null &&
before.accepted != null &&
after.accepted != null
) {
dDrafted = after.drafted - before.drafted;
dAccepted = after.accepted - before.accepted;
if (dDrafted > 0) mtpAccept = dAccepted / dDrafted;
}
logFn(
`turn ${turnIdx}: gen firstTok=${gen.firstTokenMs == null ? "n/a" : gen.firstTokenMs.toFixed(0)}ms tok/s=${gen.decodeTokPerSec == null ? "n/a" : gen.decodeTokPerSec.toFixed(1)} n=${gen.predictedN} mtp=${mtpAccept == null ? "n/a" : `${dAccepted}/${dDrafted}=${mtpAccept.toFixed(3)}`}`,
);
// 3) Phrase chunker + 4) TTS per phrase. First-audio = ASR-done → first PCM
// sample of the first phrase (the streaming-handoff latency the runtime
// optimizes); also report it relative to the first text token.
const allPhrases = chunkPhrases(gen.content || prompt, phraseMaxTokens);
// In endurance mode the contract is "the loop completes without crash /
// leak" — synthesizing every phrase of every one of 30 turns at a >1×-RTF
// CPU TTS would take ~45 min for no extra signal. So unless --tts-all-phrases
// is requested, an endurance turn synthesizes only the first phrase (still
// exercises the full TTS forward pass + the streaming handoff). A 1-turn
// run synthesizes up to `maxTtsPhrases` (default 3) — more than 3 phrases
// adds no signal over the per-phrase RTF + first-audio it already measures,
// and a noisy base-model response can chunk into 10+ phrases which would
// dominate the wall clock on a >1×-RTF CPU TTS.
const phrases = ttsAllPhrases ? allPhrases.slice(0, Math.max(1, maxTtsPhrases || 3)) : allPhrases.slice(0, 1);
const ttsRuns = [];
let firstPhrasePcm = null;
const ttsLoopT0 = performance.now();
for (let i = 0; i < phrases.length; i += 1) {
const ph = phrases[i];
const durationSec = estimateTtsDurationSec(ph);
const r = await synthPhrasePcm(port, ph, turnTimeoutS, ttsSteps, durationSec);
const run = { phrase: ph, durationHintSec: durationSec, audioSec: r.audioSec, wallMs: r.wallMs, rtf: r.rtf };
if (ttsAudioDir) {
fs.mkdirSync(ttsAudioDir, { recursive: true });
const safe = String(turnIdx).padStart(2, "0") + "-" + String(i + 1).padStart(2, "0");
const file = path.join(ttsAudioDir, `turn-${safe}-steps${ttsSteps}.wav`);
writeWav16(file, r.samples, r.sampleRate);
run.audioPath = file;
}
if (ttsRoundtripAsr) {
const rt = asrTranscribeFloat32(ffiCtx, ffi, s, r.samples, r.sampleRate);
run.roundTripAsr = {
latencyMs: round1(rt.latencyMs),
transcript: rt.transcript,
wer: round4(wordErrorRate(ph, rt.transcript)),
};
}
ttsRuns.push(run);
if (firstPhrasePcm === null) firstPhrasePcm = r;
}
const ttsTotalWallMs = ttsRuns.reduce((a, r) => a + (r.wallMs || 0), 0);
const totalAudioSec = ttsRuns.reduce((a, r) => a + r.audioSec, 0);
const ttsRtf = totalAudioSec > 0 ? ttsTotalWallMs / 1000 / totalAudioSec : null;
// first-audio relative to mic-in (ASR start) and to first text token.
const firstAudioFromMicMs = ttsLoopT0 - turnT0 + (firstPhrasePcm ? firstPhrasePcm.wallMs : 0);
// Approximate: we can't tap the mid-stream PCM-sample timestamp through
// the batch HTTP route, so first-audio is "first phrase fully synthesized"
// — the conservative end of the streaming-handoff window. Documented.
const firstAudioFromTokenMs =
gen.firstTokenMs == null ? null : firstAudioFromMicMs - asrMs - gen.firstTokenMs;
const totalTurnMs = performance.now() - turnT0;
logFn(
`turn ${turnIdx}: ${phrases.length} phrase(s) audio=${totalAudioSec.toFixed(2)}s RTF=${ttsRtf == null ? "n/a" : ttsRtf.toFixed(2)} firstAudio≈${firstAudioFromMicMs.toFixed(0)}ms total=${totalTurnMs.toFixed(0)}ms`,
);
return {
turn: turnIdx,
asr: { latencyMs: round1(asrMs), transcript, wer: wer == null ? null : round4(wer) },
gen: {
firstTokenMs: gen.firstTokenMs == null ? null : round1(gen.firstTokenMs),
decodeTokPerSec: gen.decodeTokPerSec == null ? null : round2(gen.decodeTokPerSec),
predictedN: gen.predictedN,
promptTokPerSec: gen.promptTokPerSec == null ? null : round2(gen.promptTokPerSec),
content: gen.content,
},
mtp: {
drafted: dDrafted,
accepted: dAccepted,
acceptanceRate: mtpAccept == null ? null : round4(mtpAccept),
},
tts: {
phrases: phrases.length,
totalPhrases: allPhrases.length,
audioSec: round2(totalAudioSec),
wallMs: round1(ttsTotalWallMs),
rtf: ttsRtf == null ? null : round4(ttsRtf),
roundTripAsrWerMean: round4(mean(ttsRuns.map((r) => r.roundTripAsr?.wer))),
perPhrase: ttsRuns.map((r) => ({
...r,
durationHintSec: r.durationHintSec == null ? null : round2(r.durationHintSec),
audioSec: round2(r.audioSec),
wallMs: round1(r.wallMs),
rtf: r.rtf == null ? null : round4(r.rtf),
})),
},
firstAudioFromMicMs: round1(firstAudioFromMicMs),
firstAudioFromTokenMs: firstAudioFromTokenMs == null ? null : round1(firstAudioFromTokenMs),
totalTurnMs: round1(totalTurnMs),
};
}
// --------------------------------------------------------------------------
// Barge-in cancel latency. Three probes:
// (a) audio drain: start a TTS HTTP request and abort the client fetch, which
// is the playback-side PCM drain surrogate.
// (b) native TTS: use eliza_inference_tts_synthesize_stream and have the
// chunk callback request cancellation, measuring callback-request ->
// ELIZA_ERR_CANCELLED return. This is the gateable TTS cancel evidence.
// (c) LLM/MTP: start a streaming /completion request and abort it, which
// is the assembled local server's stream-cancel path.
// --------------------------------------------------------------------------
const ELIZA_ERR_CANCELLED = -7;
async function measureBargeIn(port, ffiCtx, ffi, s) {
const audioDrainMs = await measureHttpAudioDrain(port);
let ttsStreamSupported = false;
try {
ttsStreamSupported = s.eliza_inference_tts_stream_supported() === 1;
} catch {
ttsStreamSupported = false;
}
const nativeTts = ttsStreamSupported
? measureNativeTtsStreamCancel(ffiCtx, ffi, s)
: {
ttsCancelMs: null,
nativeTtsStreamRc: null,
nativeTtsChunksBeforeCancel: 0,
nativeTtsCancelError: "eliza_inference_tts_stream_supported() returned 0",
};
const llm = await measureLlmAbort(port);
const measured = [
nativeTts.ttsCancelMs,
llm.llmCancelMs,
audioDrainMs,
].filter((v) => v != null);
const bargeInCancelMs =
ttsStreamSupported &&
nativeTts.ttsCancelMs != null &&
llm.llmCancelMs != null &&
measured.length > 0
? Math.max(...measured)
: null;
return {
bargeInCancelMs: round2(bargeInCancelMs),
ttsCancelMs: round2(nativeTts.ttsCancelMs),
llmCancelMs: round2(llm.llmCancelMs),
audioDrainMs: round2(audioDrainMs),
httpAbortMs: round2(audioDrainMs),
nativeTtsStreamRc: nativeTts.nativeTtsStreamRc,
nativeTtsChunksBeforeCancel: nativeTts.nativeTtsChunksBeforeCancel,
nativeTtsCancelError: nativeTts.nativeTtsCancelError,
llmAbortError: llm.llmAbortError,
ttsStreamSupported,
note:
bargeInCancelMs != null
? "native streaming TTS cancel and LLM/MTP stream abort were both measured; bargeInCancelMs is max(ttsCancelMs, llmCancelMs, audioDrainMs)"
: ttsStreamSupported
? "native streaming TTS is advertised, but the harness did not get a complete native TTS + LLM cancel measurement"
: "batch-only TTS build (eliza_inference_tts_stream_supported()==0); barge-in gate is not recordable until native streaming TTS cancel is available",
};
}
async function measureHttpAudioDrain(port) {
const longText = "Here is a short answer that I will keep speaking so we can interrupt it.";
const ctrl = new AbortController();
const reqP = fetch(`http://127.0.0.1:${port}/v1/audio/speech`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ input: longText, response_format: "pcm" }),
signal: ctrl.signal,
}).catch((e) => ({ aborted: true, err: String(e) }));
// Let it get going, then "barge in".
await new Promise((r) => setTimeout(r, 60));
const cancelT0 = performance.now();
ctrl.abort();
await reqP; // resolves immediately on abort
return performance.now() - cancelT0;
}
function measureNativeTtsStreamCancel(ffiCtx, ffi, s) {
const T = ffi.FFIType;
const text = "Here is a short answer that the native streaming TTS callback will cancel after the first audio chunk.";
const textBytes = Buffer.from(text, "utf8");
const textBuf = Buffer.alloc(textBytes.byteLength + 1);
textBytes.copy(textBuf);
const errBuf = Buffer.alloc(8);
errBuf.fill(0);
let cancelRequestedAtMs = null;
let chunks = 0;
const cb = new ffi.JSCallback(
((pcmPtr, nSamples, isFinal) => {
const n = Number(nSamples);
if (isFinal === 0 && n > 0 && pcmPtr !== 0n) {
chunks += 1;
if (cancelRequestedAtMs === null) {
cancelRequestedAtMs = performance.now();
return 1;
}
}
return 0;
}),
{
args: [T.ptr, T.usize, T.i32, T.ptr],
returns: T.i32,
},
);
let rc = null;
let ttsCancelMs = null;
let err = null;
try {
rc = s.eliza_inference_tts_synthesize_stream(
ffiCtx,
ffi.ptr(textBuf),
BigInt(textBytes.byteLength),
0n,
BigInt(cb.ptr),
0n,
ffi.ptr(errBuf),
);
if (cancelRequestedAtMs != null) {
ttsCancelMs = performance.now() - cancelRequestedAtMs;
}
if (rc !== ELIZA_ERR_CANCELLED) {
err = `tts_synthesize_stream returned rc=${rc}, expected ${ELIZA_ERR_CANCELLED}`;
}
} catch (e) {
err = e instanceof Error ? e.message : String(e);
} finally {
cb.close();
}
return {
ttsCancelMs,
nativeTtsStreamRc: rc,
nativeTtsChunksBeforeCancel: chunks,
nativeTtsCancelError: err,
};
}
async function measureLlmAbort(port) {
const ctrl = new AbortController();
const reqP = fetch(`http://127.0.0.1:${port}/completion`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
prompt: "Write a long paragraph about local voice cancellation.",
n_predict: 256,
temperature: 0,
stream: true,
cache_prompt: false,
}),
signal: ctrl.signal,
})
.then(async (res) => {
if (res.body) {
const reader = res.body.getReader();
await reader.read().catch(() => null);
}
})
.catch((e) => ({ aborted: true, err: String(e) }));
await new Promise((r) => setTimeout(r, 60));
const cancelT0 = performance.now();
ctrl.abort();
const result = await reqP;
return {
llmCancelMs: performance.now() - cancelT0,
llmAbortError:
result && typeof result === "object" && "err" in result ? result.err : null,
};
}
const round1 = (x) => (x == null ? null : Math.round(x * 10) / 10);
const round2 = (x) => (x == null ? null : Math.round(x * 100) / 100);
const round4 = (x) => (x == null ? null : Math.round(x * 10000) / 10000);
function median(xs) {
const v = xs.filter((x) => x != null).slice().sort((a, b) => a - b);
if (!v.length) return null;
const m = Math.floor(v.length / 2);
return v.length % 2 ? v[m] : (v[m - 1] + v[m]) / 2;
}
function mean(xs) {
const v = xs.filter((x) => x != null);
return v.length ? v.reduce((a, b) => a + b, 0) / v.length : null;
}
function maxOf(xs) {
const v = xs.filter((x) => x != null);
return v.length ? Math.max(...v) : null;
}
function parseCodecBackendLog(logText) {
const text = logText || "";
const fallback = text.match(
/\[PipelineCodec\] Metal codec fallback: requested=([^\s]+) selected=([^\s]+) reason=([^\n]+)/,
);
const loaded = text.match(/\[PipelineCodec\] Loaded codec: sr=(\d+) hop=(\d+) backend=([^\s]+)/);
return {
metalCodecFallback: !!fallback,
requestedBackend: fallback?.[1] ?? null,
selectedBackend: fallback?.[2] ?? loaded?.[3] ?? null,
reason: fallback?.[3]?.trim() ?? null,
sampleRate: loaded ? Number(loaded[1]) : null,
hopLength: loaded ? Number(loaded[2]) : null,
};
}
// --------------------------------------------------------------------------
// main
// --------------------------------------------------------------------------
function nowTag() {
return process.env.ELIZA_E2E_DATE?.trim() || new Date().toISOString().slice(0, 10);
}
async function main() {
const args = parseArgs(process.argv.slice(2));
const logFn = log(args.quiet);
if (typeof Bun === "undefined") {
throw new Error("e2e_loop_bench.mjs requires Bun (bun:ffi for the ASR/TTS-cancel calls)");
}
if (!args.bundle) throw new Error("--bundle is required");
const bundleDir = path.resolve(args.bundle);
if (!fs.existsSync(bundleDir)) throw new Error(`bundle dir not found: ${bundleDir}`);
const engine = discoverEngine(args.backend, args.binDir);
const files = bundleFiles(bundleDir, args.tier);
const tier = files.tier || args.tier || "unknown";
const baseReport = {
schemaVersion: 1,
generatedAt: new Date().toISOString(),
harness: path.relative(REPO_ROOT, __filename),
host: {
platform: process.platform,
arch: process.arch,
cpus: os.cpus().length,
cpuModel: os.cpus()[0]?.model || null,
totalMemMb: Math.round(os.totalmem() / 1024 / 1024),
},
request: {
tier,
backend: args.backend,
turns: args.turns,
nPredict: args.nPredict,
ttsSteps: args.ttsSteps,
micTtsSteps: args.micTtsSteps,
ttsRoundtripAsr: args.ttsRoundtripAsr,
phraseMaxTokens: args.phraseMaxTokens,
wavs: args.wavs.length,
refs: args.refs.length,
},
bundle: { dir: bundleDir, tier, ramBudgetMb: files.manifest?.ramBudgetMb ?? null },
engine: engine.ok ? { dir: engine.dir, backend: engine.backend, backendMismatch: engine.backendMismatch ?? null, fused: true, caps: engine.caps?.kernels ?? null } : null,
};
// Preconditions — produce honest needs-* statuses, not fake passes.
if (!engine.ok) {
const out = { ...baseReport, status: "needs-build", reason: `no fused ${args.backend} build: ${engine.reason}`, e2eLoopOk: false };
return finish(out, args, logFn);
}
if (!engine.lib) {
const out = { ...baseReport, status: "needs-build", reason: `fused build at ${engine.dir} has no ${libName()} (ASR FFI library missing)`, e2eLoopOk: false };
return finish(out, args, logFn);
}
if (!tierUsesOmniVoice(tier)) {
// Kokoro bundles use GGUF artifacts via the fork; discover by scanning tts/kokoro/
const kokoroDir = path.join(bundleDir, "tts", "kokoro");
const kokoroGgufs = fs.existsSync(kokoroDir)
? fs.readdirSync(kokoroDir).filter((f) => f.endsWith(".gguf"))
: [];
const kokoroModelPresent = kokoroGgufs.some((f) => !/token/i.test(f));
const out = {
...baseReport,
status: "needs-harness",
reason: "this tier is Kokoro-only for TTS; the fused OmniVoice+MTP e2e harness is not applicable and the app-core Kokoro voice-loop harness must drive the full ASR->text->Kokoro path",
bundleArtifacts: {
text: !!isRealGguf(files.text),
drafter: files.drafter ? !!isRealGguf(files.drafter, 10_000_000) : null,
kokoroModel: kokoroModelPresent,
ttsModel: null,
ttsCodec: null,
asr: !!isRealGguf(files.asr, 1_000_000),
},
e2eLoopOk: null,
};
return finish(out, args, logFn);
}
const drafterReady = files.mtpDisabled || isRealGguf(files.drafter, 10_000_000);
if (!isRealGguf(files.text) || !drafterReady || !isRealGguf(files.ttsModel) || !isRealGguf(files.ttsCodec) || !isRealGguf(files.asr, 1_000_000)) {
const out = {
...baseReport,
status: "needs-bundle",
reason: "bundle is missing one of text/drafter/tts-model/tts-codec/asr GGUFs (stand-in or incomplete)",
bundleArtifacts: {
text: !!isRealGguf(files.text),
drafter: files.mtpDisabled ? "disabled-by-policy" : !!isRealGguf(files.drafter, 10_000_000),
ttsModel: !!isRealGguf(files.ttsModel),
ttsCodec: !!isRealGguf(files.ttsCodec),
asr: !!isRealGguf(files.asr, 1_000_000),
},
e2eLoopOk: false,
};
return finish(out, args, logFn);
}
// --- Pick / generate the mic WAVs. If none supplied, synthesize reference
// phrases via the fused TTS route (24 kHz f32 → 16 kHz 16-bit WAV) and
// use them as the "mic" input. Reference text drives WER.
const REF_PHRASES = [
"What is the capital of France?",
"Schedule a meeting for tomorrow afternoon.",
"Tell me a short fact about the ocean.",
];
let micWavs = []; // { file, samples, sampleRate, refText }
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "eliza-e2e-mic-"));
let serverChild = null;
let port = 0;
let ffiState = null;
let ffiCtx = null;
try {
// --- start the fused llama-server (text + drafter + omnivoice) ---
port = 30000 + Math.floor(Math.random() * 20000);
const env = {
...process.env,
LD_LIBRARY_PATH: `${engine.dir}${path.delimiter}${process.env.LD_LIBRARY_PATH || ""}`,
DYLD_LIBRARY_PATH: `${engine.dir}${path.delimiter}${process.env.DYLD_LIBRARY_PATH || ""}`,
ELIZA_OMNIVOICE_MODEL: files.ttsModel,
ELIZA_OMNIVOICE_CODEC: files.ttsCodec,
ELIZA_MTP_SKIP_SERVER_STRUCTURED_OUTPUT: "1",
};
const serverArgs = [
"-m", files.text,
"--port", String(port),
"-c", String(args.ctx),
"-ngl", String(args.ngl),
"-t", String(args.threads),
"--no-warmup",
"--metrics",
];
if (!files.mtpDisabled) {
serverArgs.splice(2, 0,
"-md", files.drafter,
"--spec-type", "mtp",
"--spec-draft-n-min", "2", "--spec-draft-n-max", "6",
);
}
logFn(
`starting fused llama-server: ${path.basename(engine.dir)} port=${port} text=${path.basename(files.text)} ` +
(files.mtpDisabled ? `drafter=disabled (${path.basename(files.mtpDisabledPolicy)})` : `drafter=${path.basename(files.drafter)}`),
);
const srvLog = fs.createWriteStream(path.join(tmpDir, "server.log"));
serverChild = spawn(engine.server, serverArgs, { cwd: engine.dir, env, stdio: ["ignore", "pipe", "pipe"] });
serverChild.stdout.pipe(srvLog);
serverChild.stderr.pipe(srvLog);
await waitHealthy(port, args.startTimeoutS, serverChild, logFn);
logFn(`server healthy (pid ${serverChild.pid})`);
// --- synthesize mic WAVs if none supplied ---
if (args.wavs.length > 0) {
for (let i = 0; i < args.wavs.length; i += 1) {
const w = args.wavs[i];
const wav = readWav(w);
micWavs.push({
file: w,
samples: wav.samples,
sampleRate: wav.sampleRate,
refText: args.refs[i] || null,
});
}
} else {
for (let i = 0; i < REF_PHRASES.length; i += 1) {
const ph = REF_PHRASES[i];
const r = await synthPhrasePcm(port, ph, args.turnTimeoutS, args.micTtsSteps);
const w16 = resampleLinear(r.samples, r.sampleRate, 16000);
const file = path.join(tmpDir, `mic-${i}.wav`);
writeWav16(file, w16, 16000);
micWavs.push({ file, samples: w16, sampleRate: 16000, refText: ph });
logFn(`synthesized mic WAV ${i}: ${ph.slice(0, 40)}... (${r.audioSec.toFixed(2)}s)`);
}
}
if (micWavs.length === 0) throw new Error("no mic WAVs available (synthesis produced nothing)");
// --- FFI: create the inference context anchored at the bundle ---
ffiState = await loadFfi(engine.lib, engine.dir);
logFn(`libelizainference ABI=${ffiState.abi}`);
const { ffi, s } = ffiState;
{
const errBuf = Buffer.alloc(8);
errBuf.fill(0);
ffiCtx = s.eliza_inference_create(Buffer.from(`${bundleDir}\0`, "utf8"), ffi.ptr(errBuf));
if (!ffiCtx || (typeof ffiCtx === "bigint" && ffiCtx === 0n)) {
throw new Error(`eliza_inference_create failed: ${readErrAndFree(ffi, s, errBuf)}`);
}
}
// Acquire the ASR + TTS regions (voice-on). text/mtp are kept hot by
// the library itself; acquire is idempotent.
for (const region of ["text", "mtp", "asr", "tts"]) {
const errBuf = Buffer.alloc(8);
errBuf.fill(0);
const rc = s.eliza_inference_mmap_acquire(ffiCtx, Buffer.from(`${region}\0`, "utf8"), ffi.ptr(errBuf));
if (rc < 0) {
const msg = readErrAndFree(ffi, s, errBuf);
// text/mtp on the library are best-effort warm hints; only asr is mandatory for this bench.
if (region === "asr") throw new Error(`mmap_acquire("asr") rc=${rc}: ${msg}`);
logFn(`mmap_acquire("${region}") rc=${rc} (non-fatal): ${msg}`);
}
}
// --- run the turns ---
const turnReports = [];
let bargeIn = null;
const rssSamples = [];
const totalTurns = Math.max(1, args.turns);
const isEndurance = totalTurns >= 8;
for (let t = 0; t < totalTurns; t += 1) {
const mic = micWavs[t % micWavs.length];
// Turn 1 always runs "full" (all phrases, full nPredict) so the e2e
// metrics are complete; later turns in an endurance run are lighter.
const fullTurn = t === 0 || !isEndurance;
const turnReport = await runTurn(
{
port, ffiCtx, ffi, s, wav: mic, refText: mic.refText,
nPredict: fullTurn ? args.nPredict : args.enduranceNPredict,
turnTimeoutS: args.turnTimeoutS, logFn,
ttsAllPhrases: args.ttsAllPhrases ? true : fullTurn,
ttsSteps: args.ttsSteps,
ttsRoundtripAsr: args.ttsRoundtripAsr,
ttsAudioDir: args.ttsAudioDir,
phraseMaxTokens: args.phraseMaxTokens,
maxTtsPhrases: args.maxTtsPhrases,
},
t + 1,
);
turnReports.push(turnReport);
const rss = peakRssMb(serverChild.pid);
if (rss != null) {
rssSamples.push(rss);
turnReport.serverPeakRssMb = rss;
}
if (serverChild.exitCode !== null) {
throw new Error(`llama-server died mid-loop (exit ${serverChild.exitCode}) at turn ${t + 1}`);
}
}
// Measure barge-in after the loop. The current HTTP TTS route is batch
// only, so aborting the client fetch stops playback consumption but does
// not cancel the server-side OmniVoice forward pass; running it mid-loop
// would contaminate subsequent turn latency.
bargeIn = await measureBargeIn(port, ffiCtx, ffi, s);
const finalPeakRss = peakRssMb(serverChild.pid);
const peakRss = maxOf([...rssSamples, finalPeakRss]);
const ramRec = files.manifest?.ramBudgetMb?.recommended ?? null;
// Endurance leak heuristic: the RSS in the last quarter of the run should
// not be dramatically (>1.5×) above the RSS in the first quarter.
let leakSuspected = false;
if (totalTurns >= 8 && rssSamples.length >= 8) {
const q = Math.floor(rssSamples.length / 4);
const firstQ = mean(rssSamples.slice(0, q));
const lastQ = mean(rssSamples.slice(-q));
if (firstQ != null && lastQ != null && lastQ > firstQ * 1.5) leakSuspected = true;
}
const ramWithinBudget = ramRec == null ? null : peakRss == null ? null : peakRss <= ramRec;
const summary = {
turns: totalTurns,
asrLatencyMsMedian: round1(median(turnReports.map((r) => r.asr.latencyMs))),
asrWerMean: round4(mean(turnReports.map((r) => r.asr.wer))),
asrWerByTurn: turnReports.map((r) => r.asr.wer),
firstTokenMsMedian: round1(median(turnReports.map((r) => r.gen.firstTokenMs))),
firstTokenMsP50: round1(median(turnReports.map((r) => r.gen.firstTokenMs))),
decodeTokPerSecMedian: round2(median(turnReports.map((r) => r.gen.decodeTokPerSec))),
mtpAcceptanceRateMean: round4(mean(turnReports.map((r) => r.mtp.acceptanceRate))),
mtpDraftedTotal: turnReports.reduce((a, r) => a + (r.mtp.drafted || 0), 0),
mtpAcceptedTotal: turnReports.reduce((a, r) => a + (r.mtp.accepted || 0), 0),
firstAudioFromMicMsMedian: round1(median(turnReports.map((r) => r.firstAudioFromMicMs))),
firstAudioFromTokenMsMedian: round1(median(turnReports.map((r) => r.firstAudioFromTokenMs))),
ttsRtfMedian: round4(median(turnReports.map((r) => r.tts.rtf))),
ttsRtfMean: round4(mean(turnReports.map((r) => r.tts.rtf))),
ttsRoundTripAsrWerMean: round4(mean(turnReports.map((r) => r.tts.roundTripAsrWerMean))),
ttsRoundTripAsrWerByTurn: turnReports.map((r) => r.tts.roundTripAsrWerMean),
totalTurnMsMedian: round1(median(turnReports.map((r) => r.totalTurnMs))),
bargeInCancelMs: bargeIn ? bargeIn.bargeInCancelMs : null,
serverPeakRssMb: peakRss,
ramBudgetRecommendedMb: ramRec,
ramWithinBudget,
leakSuspected,
};
const mtpOverall =
summary.mtpDraftedTotal > 0 ? round4(summary.mtpAcceptedTotal / summary.mtpDraftedTotal) : null;
summary.mtpAcceptanceRateOverall = mtpOverall;
const flowCompletedOk = turnReports.length > 0 &&
turnReports.every((r) => r.gen.firstTokenMs != null && r.tts.audioSec != null && r.tts.audioSec > 0 && r.totalTurnMs != null);
const requiredOptimizations = {
mtpDraftingActive: files.mtpDisabled ? null : summary.mtpDraftedTotal > 0,
streamingTtsActive: bargeIn?.ttsStreamSupported === true,
};
const optimizationReadyOk = Object.values(requiredOptimizations).filter((v) => v !== null).every(Boolean);
const e2eLoopOk = flowCompletedOk;
const thirtyTurnOk = totalTurns >= 30 ? flowCompletedOk && !leakSuspected && (ramWithinBudget !== false) : null;
summary.flowCompletedOk = flowCompletedOk;
summary.optimizationReadyOk = optimizationReadyOk;
summary.requiredOptimizations = requiredOptimizations;
const fullServerLog = fs.existsSync(path.join(tmpDir, "server.log"))
? fs.readFileSync(path.join(tmpDir, "server.log"), "utf8")
: "";
summary.codecBackend = parseCodecBackendLog(fullServerLog);
const out = {
...baseReport,
status: e2eLoopOk ? (optimizationReadyOk ? "ok" : "needs-optimization") : "failed",
reason: e2eLoopOk
? optimizationReadyOk
? null
: "voice loop completed, but required Eliza-1 optimizations were not all active"
: "voice loop did not complete",
e2eLoopOk,
thirtyTurnOk,
flowCompletedOk,
optimizationReadyOk,
requiredOptimizations,
summary,
bargeIn,
turns: turnReports,
serverLog: fullServerLog ? fullServerLog.split("\n").slice(-300).join("\n") : null,
};
return finish(out, args, logFn);
} catch (err) {
const serverLogPath = path.join(tmpDir, "server.log");
const out = {
...baseReport,
status: "failed",
reason: err instanceof Error ? err.message : String(err),
e2eLoopOk: false,
thirtyTurnOk: null,
serverExitCode: serverChild?.exitCode ?? null,
serverLog: fs.existsSync(serverLogPath)
? fs.readFileSync(serverLogPath, "utf8").split("\n").slice(-80).join("\n")
: null,
};
return finish(out, args, logFn);
} finally {
// teardown FFI then server
try {
if (ffiCtx && ffiState) {
const { s } = ffiState;
try { s.eliza_inference_destroy(ffiCtx); } catch { /* best-effort */ }
}
if (ffiState?.lib?.close) ffiState.lib.close();
} catch { /* best-effort */ }
if (serverChild && serverChild.exitCode === null) {
try { serverChild.kill("SIGTERM"); } catch { /* best-effort */ }
await new Promise((r) => setTimeout(r, 300));
if (serverChild.exitCode === null) {
try { serverChild.kill("SIGKILL"); } catch { /* best-effort */ }
}
}
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* best-effort */ }
}
}
function finish(report, args, logFn) {
const reportPath =
args.report ||
path.join(__dirname, "bench_results", `e2e_loop_${nowTag()}.json`);
fs.mkdirSync(path.dirname(reportPath), { recursive: true });
// Merge if a file already exists for the day (one file, multiple
// tier/backend runs keyed by tier+backend).
let merged = report;
if (!args.report && fs.existsSync(reportPath)) {
try {
const prev = JSON.parse(fs.readFileSync(reportPath, "utf8"));
const runs = Array.isArray(prev.runs) ? prev.runs : prev.run ? [prev] : [prev];
const key = (r) => `${r?.request?.tier}|${r?.request?.backend}|${r?.request?.turns}`;
const filtered = runs.filter((r) => key(r) !== key(report));
merged = { schemaVersion: 1, generatedAt: new Date().toISOString(), runs: [...filtered, report] };
} catch {
merged = { schemaVersion: 1, generatedAt: new Date().toISOString(), runs: [report] };
}
} else if (!args.report) {
merged = { schemaVersion: 1, generatedAt: new Date().toISOString(), runs: [report] };
}
fs.writeFileSync(reportPath, `${JSON.stringify(merged, null, 2)}\n`);
logFn(`wrote ${reportPath}`);
if (report.status === "ok") {
const s = report.summary;
logFn(
`RESULT tier=${report.request.tier} backend=${report.request.backend} turns=${s.turns} ` +
`asr=${s.asrLatencyMsMedian}ms/wer${s.asrWerMean} firstTok=${s.firstTokenMsMedian}ms ` +
`tok/s=${s.decodeTokPerSecMedian} mtp=${s.mtpAcceptanceRateOverall} ` +
`firstAudio≈${s.firstAudioFromMicMsMedian}ms ttsRTF=${s.ttsRtfMedian} total=${s.totalTurnMsMedian}ms ` +
`peakRSS=${s.serverPeakRssMb}MB bargeIn=${s.bargeInCancelMs}ms e2eOk=${report.e2eLoopOk}` +
(report.thirtyTurnOk != null ? ` thirtyTurnOk=${report.thirtyTurnOk}` : ""),
);
} else {
logFn(`RESULT status=${report.status}: ${report.reason}`);
}
// The bench exits 0 whenever it produced its output (including needs-*),
// matching the eval-suite honesty contract; a non-zero exit means a harness
// crash. Callers gate on `status` / `e2eLoopOk` in the JSON.
return report;
}
main().then(
(r) => process.exit(0),
(err) => {
console.error("[e2e-loop] FATAL:", err?.stack || String(err));
process.exit(1);
},
);