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
278 lines
11 KiB
TypeScript
278 lines
11 KiB
TypeScript
#!/usr/bin/env bun
|
|
/**
|
|
* Speaker-isolation benchmark (#10726 scope item: "speaker isolation /
|
|
* attribution accuracy on two-speaker audio"). Real weights end to end:
|
|
* two REAL Kokoro voices (af_bella / am_michael) speak a fixed dialogue, and
|
|
* the real fused pyannote-segmentation-3.0 diarizer + WeSpeaker ResNet34-LM
|
|
* speaker encoder attribute who spoke when.
|
|
*
|
|
* Slices:
|
|
* 1. encoder attribution — enroll each speaker on their first turn, attribute
|
|
* every later turn by cosine distance (product enrollment flow). Gate.
|
|
* 2. diarizer DER — diarize the gap-separated timeline in 5 s windows,
|
|
* globalize window-local labels through the speaker encoder, score with
|
|
* the NIST-style DER scorer (#9427 pipeline). Gate.
|
|
* 3. #9427 model-free acoustic attributor (mean-MFCC OnlineSpeakerClusterer)
|
|
* on the same turns — comparison row, informational.
|
|
* 4. overlap probe — both voices simultaneously in one window; report whether
|
|
* the diarizer sees ≥2 speakers / overlap. Informational.
|
|
*
|
|
* Exit 0 pass / 1 fail / 2 skip. SPEAKER_ISO_REQUIRE=1 turns skips into fails.
|
|
*
|
|
* Env:
|
|
* ELIZA_INFERENCE_LIBRARY / ELIZA_INFERENCE_LIB_DIR — fused lib
|
|
* ELIZA_DIARIZ_GGUF (or ELIZA_TEST_DIARIZ_GGUF) — pyannote-segmentation-3.0.gguf
|
|
* ELIZA_SPEAKER_GGUF (or ELIZA_TEST_SPEAKER_GGUF) — wespeaker-resnet34-lm.gguf
|
|
* ELIZA_KOKORO_MODEL_DIR — Kokoro model for one-time corpus synthesis
|
|
* SPEAKER_ISO_MIN_ACC (default 0.8) / SPEAKER_ISO_MAX_DER (default 0.6)
|
|
*/
|
|
|
|
import { existsSync, mkdtempSync, rmSync } from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import {
|
|
attributeByEnrollment,
|
|
buildSpeakerTimeline,
|
|
TWO_SPEAKER_DIALOGUE,
|
|
type EmbeddedTurn,
|
|
} from "../src/services/voice/bench-utils";
|
|
import { measureRms } from "../src/services/voice/corpus-augment";
|
|
import { OnlineSpeakerClusterer } from "../src/services/voice/acoustic-speaker-attribution";
|
|
import {
|
|
computeDiarizationErrorRate,
|
|
type DiarizationSegment,
|
|
} from "../src/services/voice/diarization-error-rate";
|
|
import { voiceSpeakerDistance } from "../src/services/voice/speaker/encoder-ggml";
|
|
import { FusedDiarizer } from "../src/services/voice/speaker/diarizer-fused";
|
|
import { FusedSpeakerEncoder } from "../src/services/voice/speaker/encoder-fused";
|
|
import {
|
|
BENCH_SAMPLE_RATE,
|
|
bootFusedFfi,
|
|
defaultReportDir,
|
|
ensureKokoroCorpus,
|
|
makeBenchGates,
|
|
writeBenchReport,
|
|
} from "./voice-bench-shared";
|
|
|
|
const TAG = "speaker-isolation";
|
|
const gates = makeBenchGates(TAG, "SPEAKER_ISO_REQUIRE");
|
|
const log = (msg: string) => console.log(`[${TAG}] ${msg}`);
|
|
|
|
const MIN_ACC = Number(process.env.SPEAKER_ISO_MIN_ACC ?? "0.8");
|
|
const MAX_DER = Number(process.env.SPEAKER_ISO_MAX_DER ?? "0.6");
|
|
const WINDOW_SAMPLES = BENCH_SAMPLE_RATE * 5; // pyannote 5 s window
|
|
const MIN_EMBED_SAMPLES = BENCH_SAMPLE_RATE; // WeSpeaker needs ≥1 s
|
|
|
|
const { ffi, libPath } = bootFusedFfi(gates);
|
|
const diarizGguf = (
|
|
process.env.ELIZA_DIARIZ_GGUF ?? process.env.ELIZA_TEST_DIARIZ_GGUF
|
|
)?.trim();
|
|
const speakerGguf = (
|
|
process.env.ELIZA_SPEAKER_GGUF ?? process.env.ELIZA_TEST_SPEAKER_GGUF
|
|
)?.trim();
|
|
if (!diarizGguf || !existsSync(diarizGguf)) {
|
|
gates.skip("no diarizer GGUF (set ELIZA_DIARIZ_GGUF to pyannote-segmentation-3.0.gguf)");
|
|
}
|
|
if (!speakerGguf || !existsSync(speakerGguf)) {
|
|
gates.skip("no speaker-encoder GGUF (set ELIZA_SPEAKER_GGUF to wespeaker-resnet34-lm.gguf)");
|
|
}
|
|
if (!FusedDiarizer.isSupported(ffi)) {
|
|
gates.skip("fused lib does not link the pyannote diarizer (eliza_inference_diariz_*)");
|
|
}
|
|
if (!FusedSpeakerEncoder.isSupported(ffi)) {
|
|
gates.skip("fused lib does not link the WeSpeaker encoder (eliza_inference_speaker_*)");
|
|
}
|
|
log(`lib=${libPath}`);
|
|
log(`diarizer=${diarizGguf}`);
|
|
log(`speaker-encoder=${speakerGguf}`);
|
|
|
|
const turns = await ensureKokoroCorpus("two-speaker", TWO_SPEAKER_DIALOGUE, gates, log);
|
|
const speakerOf = new Map(TWO_SPEAKER_DIALOGUE.map((t) => [t.id, t.speaker]));
|
|
const timeline = buildSpeakerTimeline(
|
|
turns.map((t) => ({ speaker: speakerOf.get(t.id) as string, pcm: t.pcm })),
|
|
BENCH_SAMPLE_RATE,
|
|
600,
|
|
);
|
|
log(
|
|
`timeline: ${turns.length} turns, ${(timeline.pcm.length / BENCH_SAMPLE_RATE).toFixed(1)}s, ` +
|
|
`speakers ${[...new Set(timeline.segments.map((s) => s.speaker))].join("/")}`,
|
|
);
|
|
|
|
const ctxDir = mkdtempSync(path.join(os.tmpdir(), "speaker-iso-"));
|
|
const ctx = ffi.create(ctxDir);
|
|
const encoder = await FusedSpeakerEncoder.load({ ffi, ctx, ggufPath: speakerGguf });
|
|
const diarizer = await FusedDiarizer.load({ ffi, ctx, ggufPath: diarizGguf });
|
|
|
|
interface Report {
|
|
attribution: ReturnType<typeof attributeByEnrollment>;
|
|
mfccAccuracy: number;
|
|
der: ReturnType<typeof computeDiarizationErrorRate>;
|
|
hypothesis: DiarizationSegment[];
|
|
droppedShortMs: number;
|
|
overlap: { localSpeakerCount: number; hasOverlap: boolean };
|
|
}
|
|
|
|
let report: Report;
|
|
try {
|
|
// --- slice 1: encoder attribution over ground-truth turns -------------------
|
|
const embedded: EmbeddedTurn[] = [];
|
|
for (const turn of turns) {
|
|
const embedding = await encoder.encode(turn.pcm);
|
|
embedded.push({ id: turn.id, speaker: speakerOf.get(turn.id) as string, embedding });
|
|
}
|
|
const attribution = attributeByEnrollment(embedded);
|
|
log(
|
|
`encoder attribution: ${attribution.correct}/${attribution.scored} correct ` +
|
|
`(acc ${attribution.accuracy.toFixed(3)}, intra ${attribution.intraMean.toFixed(3)}, ` +
|
|
`inter ${attribution.interMean.toFixed(3)}, margin ${attribution.margin.toFixed(3)})`,
|
|
);
|
|
|
|
// --- slice 3: #9427 model-free acoustic attributor (comparison) -------------
|
|
const clusterer = new OnlineSpeakerClusterer();
|
|
const assigned = turns.map((turn) => ({
|
|
speaker: speakerOf.get(turn.id) as string,
|
|
cluster: clusterer.assignAudio(turn.pcm, BENCH_SAMPLE_RATE),
|
|
}));
|
|
const clusters = [...new Set(assigned.map((a) => a.cluster).filter(Boolean))] as string[];
|
|
let mfccBest = 0;
|
|
// Optimal cluster→speaker mapping (2 speakers → try both assignments).
|
|
for (const flip of [false, true]) {
|
|
const mapping = new Map<string, string>(
|
|
clusters.map((c, i) => [c, (i % 2 === 0) !== flip ? "A" : "B"]),
|
|
);
|
|
const correct = assigned.filter(
|
|
(a) => a.cluster && mapping.get(a.cluster) === a.speaker,
|
|
).length;
|
|
mfccBest = Math.max(mfccBest, correct / assigned.length);
|
|
}
|
|
log(`mfcc attributor (#9427): best-mapping accuracy ${mfccBest.toFixed(3)} (${clusters.length} clusters)`);
|
|
|
|
// --- slice 2: diarizer over 5 s windows + encoder globalization --------------
|
|
const enrollment = new Map<string, number[]>();
|
|
for (const e of embedded) {
|
|
if (!enrollment.has(e.speaker)) enrollment.set(e.speaker, Array.from(e.embedding));
|
|
}
|
|
const hypothesis: DiarizationSegment[] = [];
|
|
let droppedShortMs = 0;
|
|
for (let off = 0; off < timeline.pcm.length; off += WINDOW_SAMPLES) {
|
|
const window = new Float32Array(WINDOW_SAMPLES);
|
|
window.set(timeline.pcm.subarray(off, Math.min(off + WINDOW_SAMPLES, timeline.pcm.length)));
|
|
const out = await diarizer.diarizeWindow(window);
|
|
const windowStartMs = (off / BENCH_SAMPLE_RATE) * 1000;
|
|
for (const seg of out.segments) {
|
|
const absStart = windowStartMs + seg.startMs;
|
|
const absEnd = windowStartMs + seg.endMs;
|
|
const s0 = Math.round((absStart / 1000) * BENCH_SAMPLE_RATE);
|
|
const s1 = Math.round((absEnd / 1000) * BENCH_SAMPLE_RATE);
|
|
const pcm = timeline.pcm.subarray(s0, Math.min(s1, timeline.pcm.length));
|
|
if (pcm.length < MIN_EMBED_SAMPLES) {
|
|
droppedShortMs += absEnd - absStart;
|
|
continue;
|
|
}
|
|
const emb = Array.from(await encoder.encode(pcm));
|
|
let best: { speaker: string; d: number } | null = null;
|
|
for (const [speaker, ref] of enrollment) {
|
|
const d = voiceSpeakerDistance(emb, ref);
|
|
if (!best || d < best.d) best = { speaker, d };
|
|
}
|
|
if (best) hypothesis.push({ speaker: best.speaker, startMs: absStart, endMs: absEnd });
|
|
}
|
|
}
|
|
const der = computeDiarizationErrorRate(
|
|
timeline.segments.map((s) => ({ speaker: s.speaker, startMs: s.startMs, endMs: s.endMs })),
|
|
hypothesis,
|
|
);
|
|
log(
|
|
`diarizer DER ${der.der.toFixed(3)} (missed ${der.missedMs}ms, falseAlarm ${der.falseAlarmMs}ms, ` +
|
|
`confusion ${der.confusionMs}ms / ref ${der.totalReferenceMs}ms; dropped-short ${Math.round(droppedShortMs)}ms)`,
|
|
);
|
|
|
|
// --- slice 4: overlap probe (informational) ----------------------------------
|
|
const a = turns[0].pcm;
|
|
const b = turns[1].pcm;
|
|
const overlapPcm = new Float32Array(WINDOW_SAMPLES);
|
|
const scaleB = (measureRms(a) || 1e-6) / (measureRms(b) || 1e-6);
|
|
for (let i = 0; i < WINDOW_SAMPLES; i++) {
|
|
overlapPcm[i] = (i < a.length ? a[i] : 0) + (i < b.length ? b[i] * scaleB : 0);
|
|
}
|
|
const overlapOut = await diarizer.diarizeWindow(overlapPcm);
|
|
const overlap = {
|
|
localSpeakerCount: overlapOut.localSpeakerCount,
|
|
hasOverlap: overlapOut.segments.some((s) => s.hasOverlap),
|
|
};
|
|
log(
|
|
`overlap probe: localSpeakerCount=${overlap.localSpeakerCount} hasOverlap=${overlap.hasOverlap}`,
|
|
);
|
|
|
|
report = { attribution, mfccAccuracy: mfccBest, der, hypothesis, droppedShortMs, overlap };
|
|
} finally {
|
|
await diarizer.dispose();
|
|
await encoder.dispose();
|
|
ffi.destroy(ctx);
|
|
rmSync(ctxDir, { recursive: true, force: true });
|
|
}
|
|
|
|
// --- report -------------------------------------------------------------------
|
|
const perTurnTable = [
|
|
"| turn | speaker | attributed | dist(own) | dist(other) | correct |",
|
|
"| --- | --- | --- | ---: | ---: | --- |",
|
|
...report.attribution.perTurn.map(
|
|
(t) =>
|
|
`| ${t.id} | ${t.speaker} | ${t.attributed} | ${t.distanceToOwn.toFixed(3)} | ${t.distanceToNearestOther.toFixed(3)} | ${t.correct ? "✓" : "✗"} |`,
|
|
),
|
|
].join("\n");
|
|
const md = [
|
|
"# Speaker-isolation benchmark — two real Kokoro voices, real diarizer + encoder (CPU)",
|
|
"",
|
|
`Voices: af_bella (A) / am_michael (B), ${TWO_SPEAKER_DIALOGUE.length}-turn dialogue, 600 ms gaps.`,
|
|
"",
|
|
`- encoder attribution: **${report.attribution.correct}/${report.attribution.scored}** ` +
|
|
`(accuracy ${report.attribution.accuracy.toFixed(3)}, margin ${report.attribution.margin.toFixed(3)})`,
|
|
`- diarizer DER: **${report.der.der.toFixed(3)}** (missed ${report.der.missedMs} ms, ` +
|
|
`false-alarm ${report.der.falseAlarmMs} ms, confusion ${report.der.confusionMs} ms, ` +
|
|
`dropped-short ${Math.round(report.droppedShortMs)} ms)`,
|
|
`- #9427 mean-MFCC attributor (model-free comparison): accuracy ${report.mfccAccuracy.toFixed(3)}`,
|
|
`- overlap probe: localSpeakerCount=${report.overlap.localSpeakerCount}, hasOverlap=${report.overlap.hasOverlap}`,
|
|
"",
|
|
perTurnTable,
|
|
"",
|
|
].join("\n");
|
|
const { jsonPath, mdPath } = writeBenchReport(
|
|
defaultReportDir(),
|
|
"speaker-isolation",
|
|
{
|
|
schemaVersion: 1,
|
|
generatedAt: new Date().toISOString(),
|
|
host: `${process.platform}-${process.arch}`,
|
|
lib: libPath,
|
|
diarizGguf,
|
|
speakerGguf,
|
|
thresholds: { minAccuracy: MIN_ACC, maxDer: MAX_DER },
|
|
...report,
|
|
attribution: report.attribution,
|
|
},
|
|
md,
|
|
);
|
|
log(`report: ${jsonPath}`);
|
|
log(`report: ${mdPath}`);
|
|
|
|
// --- gates --------------------------------------------------------------------
|
|
const failures: string[] = [];
|
|
if (report.attribution.accuracy < MIN_ACC) {
|
|
failures.push(
|
|
`encoder attribution accuracy ${report.attribution.accuracy.toFixed(3)} < SPEAKER_ISO_MIN_ACC ${MIN_ACC}`,
|
|
);
|
|
}
|
|
if (report.attribution.margin <= 0) {
|
|
failures.push(
|
|
`speaker embeddings are not separable: inter-intra margin ${report.attribution.margin.toFixed(3)} ≤ 0`,
|
|
);
|
|
}
|
|
if (report.der.der > MAX_DER) {
|
|
failures.push(`diarizer DER ${report.der.der.toFixed(3)} > SPEAKER_ISO_MAX_DER ${MAX_DER}`);
|
|
}
|
|
if (failures.length > 0) {
|
|
gates.fail(`${failures.length} gate failure(s):\n - ${failures.join("\n - ")}`);
|
|
}
|
|
log(`PASS (accuracy ≥ ${MIN_ACC}, margin > 0, DER ≤ ${MAX_DER})`);
|