Files
wehub-resource-sync 426e9eeabd
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Waiting to run
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Waiting to run
Build Agent Image / build-and-push (push) Waiting to run
Chat shell gestures / Chat shell gesture + parity e2e (push) Waiting to run
ci / test (push) Waiting to run
ci / lint-and-format (push) Waiting to run
ci / build (push) Waiting to run
ci / dev-startup (push) Waiting to run
Cloud Gateway Discord / Test (push) Waiting to run
Cloud Gateway Webhook / Test (push) Waiting to run
Cloud Tests / lint-and-types (push) Waiting to run
Cloud Tests / unit-tests (push) Waiting to run
Cloud Tests / integration-tests (push) Waiting to run
Cloud Tests / e2e-tests (push) Blocked by required conditions
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
Deploy Apps Worker (Product 2) / Determine environment (push) Waiting to run
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Blocked by required conditions
Deploy Eliza Provisioning Worker / Determine environment (push) Waiting to run
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Blocked by required conditions
Dev Smoke / Classify changed paths (push) Waiting to run
Dev Smoke / bun run dev onboarding chat (push) Blocked by required conditions
Dev Smoke / Vite HMR dependency-level smoke (push) Blocked by required conditions
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Waiting to run
gitleaks / gitleaks (push) Waiting to run
Markdown Links / Relative Markdown Links (push) Waiting to run
Publish @elizaos/example-code / check_npm (push) Waiting to run
Publish @elizaos/example-code / publish_npm (push) Blocked by required conditions
Publish @elizaos/plugin-elizacloud / verify_version (push) Waiting to run
Publish @elizaos/plugin-elizacloud / publish_npm (push) Blocked by required conditions
Quality (Extended) / Homepage Build (PR smoke) (push) Waiting to run
Quality (Extended) / Comment-only diff guard (push) Waiting to run
Quality (Extended) / Format + Type Safety Ratchet (push) Waiting to run
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Waiting to run
Quality (Extended) / Develop Gate (lint) (push) Waiting to run
Sandbox Live Smoke / Sandbox live smoke (push) Waiting to run
Snap Build & Test / Build Snap (amd64) (push) Waiting to run
Snap Build & Test / Build Snap (arm64) (push) Waiting to run
supply-chain / sbom (push) Waiting to run
supply-chain / vulnerability-scan (push) Waiting to run
Build, Push & Deploy to Phala Cloud / build-and-push (push) Waiting to run
Test Packaging / Validate Packaging Configs (push) Waiting to run
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Waiting to run
Test Packaging / Pack & Test JS Tarballs (push) Waiting to run
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Waiting to run
UI Fixture E2E / ui-fixture-e2e (push) Waiting to run
UI Fixture E2E / fixture-e2e (push) Waiting to run
UI Story Gate / story-gate (push) Waiting to run
vault-ci / test (macos-latest) (push) Waiting to run
vault-ci / test (ubuntu-latest) (push) Waiting to run
vault-ci / test (windows-latest) (push) Waiting to run
vault-ci / app-core wiring tests (push) Waiting to run
verify-patches / verify patches/CHECKSUMS.sha256 (push) Waiting to run
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Waiting to run
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Waiting to run
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Waiting to run
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Waiting to run
Voice Benchmark Smoke / voice bench smoke summary (push) Blocked by required conditions
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) Waiting to run
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) Waiting to run
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) Waiting to run
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) Waiting to run
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) Waiting to run
Test Packaging / Build & Test PyPI Package (push) Waiting to run
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:43:05 +08:00

994 lines
32 KiB
JavaScript

#!/usr/bin/env bun
/**
* three-voice-scenario.mjs — Three-voice multi-user diarization scenario.
*
* Three voices participate in a sequential conversation:
* VOICE_A ("alice"): female human, f0 ≈ 200 Hz carrier
* VOICE_B ("bob"): male human, f0 ≈ 120 Hz carrier
* AGENT_VOICE ("eliza"): agent, f0 ≈ 160 Hz carrier
*
* Each turn generates synthetic PCM audio using the formant-resonator speech
* synthesis from plugin-local-inference's test helpers. All turns are
* concatenated into a mixed stream and run through diarization.
*
* Should-respond detection: turns containing "Eliza" in their text are
* addressed to the agent. Turn 5 (Bob→Alice, no "Eliza") should NOT trigger
* a response. Turn 6 (Alice→Eliza) SHOULD trigger a response.
*
* Entity/Relationship tracking: after diarization, creates a VoiceProfile
* per detected speaker and maps them to named entities.
*
* Usage:
* bun packages/benchmarks/voice/three-voice-scenario.mjs [--bundle <path>]
*/
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const REPO_ROOT = path.resolve(__dirname, "..", "..", "..");
const REPORTS_DIR = path.join(__dirname, "reports");
const DEFAULT_BUNDLE = path.join(
os.homedir(),
".eliza",
"local-inference",
"models",
"eliza-1-0_8b.bundle",
);
function parseArgs(argv) {
const args = { bundle: DEFAULT_BUNDLE, json: false };
for (let i = 0; i < argv.length; i++) {
if (argv[i] === "--bundle" && argv[i + 1]) args.bundle = argv[++i];
if (argv[i] === "--json") args.json = true;
}
return args;
}
function timestamp() {
return new Date()
.toISOString()
.replace(/[-:]/g, "")
.replace(/\.\d{3}Z$/, "Z");
}
// ---------------------------------------------------------------------------
// Voice definitions
// ---------------------------------------------------------------------------
const VOICES = {
alice: { label: "alice", f0: 200, seed: 0xa1ce },
bob: { label: "bob", f0: 120, seed: 0xb0b0 },
eliza: { label: "eliza", f0: 160, seed: 0xe17a },
};
// ---------------------------------------------------------------------------
// Scenario script (7 turns)
// ---------------------------------------------------------------------------
const SCENARIO = [
{
turn: 1,
speaker: "alice",
text: "Hey Eliza, what's the weather like today?",
addressedTo: ["eliza"],
agentShouldRespond: true,
note: "Alice asks Eliza about weather",
},
{
turn: 2,
speaker: "bob",
text: "Yeah I've been wondering too, it's supposed to rain.",
addressedTo: ["eliza", "alice"], // mentions context but addressed to group
agentShouldRespond: false, // no direct "Eliza" trigger in Bob's turn
note: "Bob comments to group, no direct agent trigger",
},
{
turn: 3,
speaker: "eliza", // agent responds to Alice's question
text: "It looks like it'll be sunny in the morning, Bob, with a chance of rain in the afternoon.",
addressedTo: ["alice", "bob"],
agentShouldRespond: null, // this IS the agent speaking
note: "Agent responds to Alice's weather question, mentions Bob",
},
{
turn: 4,
speaker: "alice",
text: "Thanks Eliza! Bob, should we reschedule?",
addressedTo: ["eliza", "bob"],
agentShouldRespond: true, // "Eliza" mentioned
note: "Alice thanks Eliza and asks Bob about rescheduling",
},
{
turn: 5,
speaker: "bob",
text: "Yeah probably a good idea.",
addressedTo: ["alice"], // addressed to Alice, not Eliza
agentShouldRespond: false, // no "Eliza" trigger — agent should NOT respond
note: "Bob responds to Alice only — agent should NOT respond",
},
{
turn: 6,
speaker: "alice",
text: "Eliza, what time is it?",
addressedTo: ["eliza"],
agentShouldRespond: true, // agent SHOULD respond
note: "Alice addresses Eliza directly — agent SHOULD respond",
},
{
turn: 7,
speaker: "eliza", // agent responds
text: "It's two fifteen in the afternoon.",
addressedTo: ["alice"],
agentShouldRespond: null, // this IS the agent speaking
note: "Agent responds to Alice's time question",
},
];
// ---------------------------------------------------------------------------
// Synthetic speech synthesis
// ---------------------------------------------------------------------------
function mulberry32(seed) {
let a = seed >>> 0;
return () => {
a |= 0;
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
class FormantBank {
constructor(sampleRate, formants) {
this.r = [];
this.a1 = [];
this.a2 = [];
this.z1 = [];
this.z2 = [];
for (const [fc, bw] of formants) {
const r = Math.exp((-Math.PI * bw) / sampleRate);
const theta = (2 * Math.PI * fc) / sampleRate;
this.r.push(r);
this.a1.push(-2 * r * Math.cos(theta));
this.a2.push(r * r);
this.z1.push(0);
this.z2.push(0);
}
}
step(excitation) {
let v = 0;
for (let k = 0; k < this.r.length; k++) {
const y = excitation - this.a1[k] * this.z1[k] - this.a2[k] * this.z2[k];
this.z2[k] = this.z1[k];
this.z1[k] = y;
v += y * (1 - k * 0.25);
}
return v;
}
}
const DEFAULT_FORMANTS = [
[700, 80],
[1220, 90],
[2600, 120],
];
/**
* Generate speech PCM for a given voice and text.
* Duration is proportional to word count (~150ms per word, min 0.8s, max 3.0s).
* The formant bank is seeded with voice.seed so each speaker sounds distinct.
*/
function generateVoicePcm(voice, text, sampleRate = 16_000) {
const words = text.trim().split(/\s+/).length;
const speechSec = Math.max(0.8, Math.min(3.0, words * 0.15));
const leadSilenceSec = 0.2;
const tailSilenceSec = 0.2;
const totalSec = leadSilenceSec + speechSec + tailSilenceSec;
const n = Math.floor(totalSec * sampleRate);
const pcm = new Float32Array(n);
const speechStartSample = Math.floor(leadSilenceSec * sampleRate);
const speechEndSample =
speechStartSample + Math.floor(speechSec * sampleRate);
const rng = mulberry32(voice.seed ^ (text.length * 0x1337));
const bank = new FormantBank(sampleRate, DEFAULT_FORMANTS);
let phase = 0;
for (let i = speechStartSample; i < speechEndSample; i++) {
const tInSpeech = (i - speechStartSample) / sampleRate;
const f0 =
voice.f0 + 20 * Math.sin(2 * Math.PI * 4 * tInSpeech) + (rng() - 0.5) * 3;
phase += f0 / sampleRate;
let excitation = 0;
if (phase >= 1) {
phase -= 1;
excitation = 1;
}
const amp = Math.max(
0,
0.6 * (1 + Math.sin(2 * Math.PI * 4 * tInSpeech - Math.PI / 2)),
);
excitation *= amp;
pcm[i] = bank.step(excitation) * 0.15;
}
return {
pcm,
sampleRate,
speechStartSample,
speechEndSample,
durationMs: Math.round(totalSec * 1000),
};
}
// ---------------------------------------------------------------------------
// AudioBus (inline, minimal version for this harness)
// Uses the same format contract as packages/benchmarks/three-agent-dialogue/runner/audio-bus.ts
// ---------------------------------------------------------------------------
function buildWavHeader(
dataLen,
sampleRate = 16_000,
numChannels = 1,
bitsPerSample = 16,
) {
const byteRate = (sampleRate * numChannels * bitsPerSample) / 8;
const blockAlign = (numChannels * bitsPerSample) / 8;
const header = new DataView(new ArrayBuffer(44));
const enc = new TextEncoder();
new Uint8Array(header.buffer).set(enc.encode("RIFF"), 0);
header.setUint32(4, 36 + dataLen, true);
new Uint8Array(header.buffer).set(enc.encode("WAVE"), 8);
new Uint8Array(header.buffer).set(enc.encode("fmt "), 12);
header.setUint32(16, 16, true);
header.setUint16(20, 1, true);
header.setUint16(22, numChannels, true);
header.setUint32(24, sampleRate, true);
header.setUint32(28, byteRate, true);
header.setUint16(32, blockAlign, true);
header.setUint16(34, bitsPerSample, true);
new Uint8Array(header.buffer).set(enc.encode("data"), 36);
header.setUint32(40, dataLen, true);
return new Uint8Array(header.buffer);
}
function pcmToWav(pcm, sampleRate = 16_000) {
const dataBytes = pcm.length * 2;
const header = buildWavHeader(dataBytes, sampleRate);
const wav = new Uint8Array(header.length + dataBytes);
wav.set(header, 0);
const view = new DataView(wav.buffer, header.length);
for (let i = 0; i < pcm.length; i++) {
const clamped = Math.max(-1, Math.min(1, pcm[i]));
view.setInt16(
i * 2,
Math.round(clamped < 0 ? clamped * 0x8000 : clamped * 0x7fff),
true,
);
}
return wav;
}
// ---------------------------------------------------------------------------
// Diarization (pure-JS synthetic labels, same logic as test-diarizer.mjs)
// ---------------------------------------------------------------------------
const PYANNOTE_CLASS_TO_SPEAKERS = [[], [0], [1], [2], [0, 1], [0, 2], [1, 2]];
const PYANNOTE_FRAME_STRIDE_MS = (1_000 * 5) / 293;
const PYANNOTE_CLASS_COUNT = 7;
function softmax(row) {
let max = -Infinity;
for (let i = 0; i < row.length; i++) if (row[i] > max) max = row[i];
const out = new Float32Array(row.length);
let sum = 0;
for (let i = 0; i < row.length; i++) {
out[i] = Math.exp(row[i] - max);
sum += out[i];
}
if (sum === 0) return out;
for (let i = 0; i < row.length; i++) out[i] /= sum;
return out;
}
function classifyFramesToSegments(
classProbs,
frames,
classCount,
startMs,
frameStrideMs,
) {
const open = new Map();
const closed = [];
let speechFrames = 0;
for (let f = 0; f < frames; f++) {
const offset = f * classCount;
const row = classProbs.subarray(offset, offset + classCount);
const probs = softmax(row);
let winner = 0;
let winnerProb = probs[0];
for (let c = 1; c < classCount; c++)
if (probs[c] > winnerProb) {
winner = c;
winnerProb = probs[c];
}
const activeSpeakers = PYANNOTE_CLASS_TO_SPEAKERS[winner] ?? [];
const isOverlap = activeSpeakers.length > 1;
if (activeSpeakers.length > 0) speechFrames++;
for (const [sid, run] of open.entries()) {
if (!activeSpeakers.includes(sid)) {
closed.push({ ...run, speakerId: sid });
open.delete(sid);
}
}
for (const sid of activeSpeakers) {
const ex = open.get(sid);
if (ex) {
ex.endFrame = f + 1;
ex.confSum += winnerProb;
ex.count++;
ex.hasOverlap = ex.hasOverlap || isOverlap;
} else
open.set(sid, {
startFrame: f,
endFrame: f + 1,
confSum: winnerProb,
count: 1,
hasOverlap: isOverlap,
});
}
}
for (const [sid, run] of open.entries())
closed.push({ ...run, speakerId: sid });
const segments = closed
.map((run) => ({
startMs: Math.round(startMs + run.startFrame * frameStrideMs),
endMs: Math.round(startMs + run.endFrame * frameStrideMs),
localSpeakerId: run.speakerId,
confidence: run.count > 0 ? run.confSum / run.count : 0,
hasOverlap: run.hasOverlap,
}))
.sort((a, b) =>
a.startMs !== b.startMs ? a.startMs - b.startMs : a.endMs - b.endMs,
);
return {
segments,
localSpeakerCount: new Set(segments.map((s) => s.localSpeakerId)).size,
speechMs: Math.round(speechFrames * frameStrideMs),
};
}
/**
* Build synthetic label tensor for the mixed stream.
*
* The mixed stream is a sequential concatenation of turns. We know exactly
* which speaker is active at each sample, so we can construct the label tensor
* directly to verify that classifyFramesToSegments works correctly.
*
* Speaker index mapping (local, window-relative):
* 0 → alice
* 1 → bob
* 2 → eliza (agent)
*
* These are window-LOCAL — in a real system the profile store re-clusters
* them across windows.
*/
function buildLabelTensorForWindow(
speakerRanges, // [{speakerIdx, startSample, endSample}]
windowStartSample,
windowSamples,
windowFrames,
) {
const classCount = PYANNOTE_CLASS_COUNT;
const probs = new Float32Array(windowFrames * classCount);
for (let f = 0; f < windowFrames; f++) {
const centerSample =
windowStartSample + Math.floor((f / windowFrames) * windowSamples);
let speakerIdx = -1; // silence
for (const range of speakerRanges) {
if (centerSample >= range.startSample && centerSample < range.endSample) {
speakerIdx = range.speakerIdx;
break;
}
}
let label = 0; // silence
if (speakerIdx === 0)
label = 1; // speaker A (alice)
else if (speakerIdx === 1)
label = 2; // speaker B (bob)
else if (speakerIdx === 2) label = 3; // speaker C (eliza)
probs[f * classCount + label] = 10.0; // strong logit
}
return probs;
}
// ---------------------------------------------------------------------------
// Should-respond detection
// ---------------------------------------------------------------------------
const AGENT_NAME = "Eliza";
function agentShouldRespond(turn) {
if (turn.speaker === "eliza") return null; // agent is speaking, not responding
// Simple name-trigger: does the text mention the agent's name?
const lower = turn.text.toLowerCase();
return lower.includes(AGENT_NAME.toLowerCase());
}
// ---------------------------------------------------------------------------
// VoiceProfile and entity tracking
// ---------------------------------------------------------------------------
const SPEAKER_INDEX_MAP = { alice: 0, bob: 1, eliza: 2 };
const ENTITY_MAP = {
0: {
entityId: "entity-alice",
label: "alice",
displayName: "Alice",
role: "human",
},
1: {
entityId: "entity-bob",
label: "bob",
displayName: "Bob",
role: "human",
},
2: {
entityId: "entity-eliza",
label: "eliza",
displayName: "Eliza",
role: "agent",
},
};
/**
* Build VoiceProfiles for each detected speaker cluster.
* In this synthetic scenario we know the ground-truth mapping.
* A real system would use WeSpeaker embeddings + cosine clustering.
*/
function buildVoiceProfiles(detectedSpeakers) {
return detectedSpeakers.map((localId) => {
const entity = ENTITY_MAP[localId];
if (!entity) {
return {
id: `cluster-unknown-${localId}`,
displayName: `Unknown speaker ${localId}`,
owner: false,
embeddingModel: "synthetic-no-embedding",
embeddings: [],
quality: {
samples: 0,
seconds: 0,
noiseFloor: 0,
lastUpdatedAt: Date.now(),
},
consent: "unknown",
localSpeakerId: localId,
};
}
return {
id: `cluster-${entity.label}`,
displayName: entity.displayName,
owner: entity.role === "agent",
embeddingModel: "synthetic-no-embedding",
embeddings: [
{
vectorPreview: Array.from({ length: 8 }, (_, i) =>
i === localId ? 1 : 0,
),
modelId: "wespeaker-resnet34-lm-fp32",
createdAt: Date.now(),
},
],
quality: {
samples: 1,
seconds: 1.0,
noiseFloor: 0.01,
lastUpdatedAt: Date.now(),
},
consent: entity.role === "agent" ? "explicit" : "implicit-household",
entityId: entity.entityId,
entityLabel: entity.label,
entityRole: entity.role,
localSpeakerId: localId,
};
});
}
/**
* Build a relationship graph from the scenario: who spoke to whom.
*/
function buildRelationships(turns, localIdToSpeaker) {
const relationships = [];
for (const turn of turns) {
const speakerLocalId = SPEAKER_INDEX_MAP[turn.speaker];
if (speakerLocalId === undefined) continue;
for (const target of turn.addressedTo ?? []) {
const targetLocalId = SPEAKER_INDEX_MAP[target];
if (targetLocalId === undefined) continue;
relationships.push({
from: { localSpeakerId: speakerLocalId, label: turn.speaker },
to: { localSpeakerId: targetLocalId, label: target },
turn: turn.turn,
text: turn.text,
type:
turn.turn === 1
? "question-weather"
: turn.turn === 4
? "thanks-and-question"
: turn.turn === 6
? "question-time"
: "statement",
});
}
}
return relationships;
}
// ---------------------------------------------------------------------------
// DER calculation (Diarization Error Rate)
// ---------------------------------------------------------------------------
/**
* Calculate a simplified DER on the mixed stream.
*
* DER = (false_alarm + missed_speech + speaker_error) / total_speech_reference
*
* Since we use synthetic labels that exactly encode ground truth, the DER on
* synthetic labels is 0 (or very near 0 due to frame stride quantization).
* This verifies the pipeline correctness.
*
* For production audio with real models, DER is reported from the actual model output.
*/
function calculateDer(
groundTruthTurns,
diarizedSegments,
totalSamples,
sampleRate,
) {
// Build reference spans using absolute sample positions (already stored in turnData).
// speechStartSample and speechEndSample are absolute positions in the mixed stream.
const refSpans = [];
for (const t of groundTruthTurns) {
const speakerLocalId = SPEAKER_INDEX_MAP[t.speaker];
if (speakerLocalId === undefined) continue;
const startMs = (t.speechStartSample / sampleRate) * 1000;
const endMs = (t.speechEndSample / sampleRate) * 1000;
if (endMs > startMs) {
refSpans.push({ speakerLocalId, startMs, endMs });
}
}
const totalRefMs = refSpans.reduce((s, r) => s + (r.endMs - r.startMs), 0);
// For synthetic labels: the diarized segments exactly match reference → DER = 0
// Count speaker errors: segments where the detected speaker != reference speaker at that time
let speakerErrorMs = 0;
let falsAlarmMs = 0;
let missedMs = 0;
for (const ref of refSpans) {
const overlapping = diarizedSegments.filter(
(seg) => seg.endMs > ref.startMs && seg.startMs < ref.endMs,
);
if (overlapping.length === 0) {
missedMs += ref.endMs - ref.startMs;
continue;
}
for (const seg of overlapping) {
const overlapStart = Math.max(seg.startMs, ref.startMs);
const overlapEnd = Math.min(seg.endMs, ref.endMs);
const overlapMs = overlapEnd - overlapStart;
if (overlapMs <= 0) continue;
if (seg.localSpeakerId !== ref.speakerLocalId)
speakerErrorMs += overlapMs;
}
}
// False alarm: diarized speech outside any reference span
for (const seg of diarizedSegments) {
const refAtTime = refSpans.find(
(r) => r.startMs <= seg.startMs && r.endMs >= seg.endMs,
);
if (!refAtTime) falsAlarmMs += seg.endMs - seg.startMs;
}
const der =
totalRefMs > 0
? (missedMs + falsAlarmMs + speakerErrorMs) / totalRefMs
: null;
return {
totalRefMs: Math.round(totalRefMs),
missedMs: Math.round(missedMs),
falsAlarmMs: Math.round(falsAlarmMs),
speakerErrorMs: Math.round(speakerErrorMs),
der: der !== null ? Math.round(der * 10000) / 10000 : null,
note: "DER computed on synthetic labels; ground truth equals model output → DER≈0 on synthetic fixtures. Real-audio DER requires native forward pass.",
};
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main() {
const args = parseArgs(process.argv.slice(2));
fs.mkdirSync(REPORTS_DIR, { recursive: true });
console.log(
`[three-voice] Starting three-voice multi-user diarization scenario`,
);
console.log(`[three-voice] bundle: ${args.bundle}`);
console.log(
`[three-voice] voices: alice(f0=200Hz) bob(f0=120Hz) eliza(f0=160Hz)`,
);
const SAMPLE_RATE = 16_000;
const WINDOW_SAMPLES = SAMPLE_RATE * 5;
const FRAMES_PER_WINDOW = 293;
// ---------------------------------------------------------------------------
// Step 1: Generate PCM for each turn
// ---------------------------------------------------------------------------
console.log(
`\n[three-voice] Generating synthetic speech for ${SCENARIO.length} turns...`,
);
const turnData = [];
let totalSamples = 0;
const speakerRanges = []; // for label tensor construction
for (const turn of SCENARIO) {
const voice = VOICES[turn.speaker];
if (!voice) throw new Error(`Unknown speaker: ${turn.speaker}`);
const pcmInfo = generateVoicePcm(voice, turn.text, SAMPLE_RATE);
const startSample = totalSamples;
const speechStartSample = startSample + pcmInfo.speechStartSample;
const speechEndSample = startSample + pcmInfo.speechEndSample;
const speakerLocalId = SPEAKER_INDEX_MAP[turn.speaker];
speakerRanges.push({
speakerIdx: speakerLocalId,
startSample: speechStartSample,
endSample: speechEndSample,
});
const shouldRespond = agentShouldRespond(turn);
const predictedShouldRespond = shouldRespond;
const correct = turn.agentShouldRespond === predictedShouldRespond;
console.log(
` turn ${turn.turn} [${turn.speaker}] "${turn.text.slice(0, 50)}" — ` +
`${pcmInfo.durationMs}ms | agentRespond=${String(turn.agentShouldRespond)} ` +
`predicted=${String(predictedShouldRespond)} ${correct ? "OK" : "MISMATCH"}`,
);
turnData.push({
...turn,
pcmInfo,
startSample,
speechStartSample,
speechEndSample,
speakerLocalId,
shouldRespond,
shouldRespondCorrect: correct,
});
totalSamples += pcmInfo.pcm.length;
}
// ---------------------------------------------------------------------------
// Step 2: Concatenate into mixed stream
// ---------------------------------------------------------------------------
console.log(
`\n[three-voice] Building mixed stream (${totalSamples} samples, ${(totalSamples / SAMPLE_RATE).toFixed(2)}s)`,
);
const mixedPcm = new Float32Array(totalSamples);
let offset = 0;
for (const td of turnData) {
mixedPcm.set(td.pcmInfo.pcm, offset);
offset += td.pcmInfo.pcm.length;
}
// ---------------------------------------------------------------------------
// Step 3: Use AudioBus to track per-speaker audio
// ---------------------------------------------------------------------------
// Build per-speaker buffers (matching AudioBus interface)
const speakerBuffers = { alice: [], bob: [], eliza: [] };
for (const td of turnData) {
speakerBuffers[td.speaker].push(td.pcmInfo.pcm);
}
const audioStats = {};
for (const [speaker, buffers] of Object.entries(speakerBuffers)) {
const totalPcmSamples = buffers.reduce((s, b) => s + b.length, 0);
audioStats[speaker] = {
turns: buffers.length,
totalSamples: totalPcmSamples,
durationMs: Math.round((totalPcmSamples / SAMPLE_RATE) * 1000),
};
}
console.log(`[three-voice] Per-speaker audio:`);
for (const [speaker, stats] of Object.entries(audioStats)) {
console.log(` ${speaker}: ${stats.turns} turns, ${stats.durationMs}ms`);
}
// ---------------------------------------------------------------------------
// Step 4: Diarize the mixed stream (window-by-window)
// ---------------------------------------------------------------------------
console.log(`\n[three-voice] Running diarization on mixed stream...`);
const allSegments = [];
const windowResults = [];
let windowStart = 0;
let windowIndex = 0;
while (windowStart < totalSamples) {
const windowEnd = Math.min(windowStart + WINDOW_SAMPLES, totalSamples);
const windowLen = windowEnd - windowStart;
if (windowLen < SAMPLE_RATE) break; // too short for pyannote
const startMs = (windowStart / SAMPLE_RATE) * 1000;
const labelTensor = buildLabelTensorForWindow(
speakerRanges,
windowStart,
windowLen,
FRAMES_PER_WINDOW,
);
const result = classifyFramesToSegments(
labelTensor,
FRAMES_PER_WINDOW,
PYANNOTE_CLASS_COUNT,
startMs,
PYANNOTE_FRAME_STRIDE_MS,
);
allSegments.push(...result.segments);
windowResults.push({
windowIndex,
startMs,
endMs: startMs + (windowLen / SAMPLE_RATE) * 1000,
windowSamples: windowLen,
...result,
});
console.log(
` window ${windowIndex}: ${startMs.toFixed(0)}-${(startMs + (windowLen / SAMPLE_RATE) * 1000).toFixed(0)}ms | ` +
`${result.segments.length} segments, ${result.localSpeakerCount} local speakers`,
);
windowStart += WINDOW_SAMPLES;
windowIndex++;
}
const detectedSpeakerIds = [
...new Set(allSegments.map((s) => s.localSpeakerId)),
].sort();
console.log(`\n[three-voice] Diarization complete:`);
console.log(` total segments: ${allSegments.length}`);
console.log(
` detected local speaker IDs: [${detectedSpeakerIds.join(", ")}]`,
);
for (const seg of allSegments) {
const speakerLabel =
ENTITY_MAP[seg.localSpeakerId]?.label ?? `unknown-${seg.localSpeakerId}`;
console.log(
` [${seg.startMs.toFixed(0)}-${seg.endMs.toFixed(0)}ms] localId=${seg.localSpeakerId} (${speakerLabel}) ` +
`conf=${seg.confidence.toFixed(3)} overlap=${seg.hasOverlap}`,
);
}
// ---------------------------------------------------------------------------
// Step 5: Verify should-respond detection
// ---------------------------------------------------------------------------
console.log(`\n[three-voice] Should-respond detection verification:`);
const shouldRespondResults = turnData
.filter((td) => td.agentShouldRespond !== null)
.map((td) => ({
turn: td.turn,
speaker: td.speaker,
text: td.text,
expectedShouldRespond: td.agentShouldRespond,
predictedShouldRespond: td.shouldRespond,
correct: td.shouldRespondCorrect,
note: td.note,
}));
const turn5 = shouldRespondResults.find((r) => r.turn === 5);
const turn6 = shouldRespondResults.find((r) => r.turn === 6);
for (const r of shouldRespondResults) {
console.log(
` turn ${r.turn} [${r.speaker}]: expected=${r.expectedShouldRespond} predicted=${r.predictedShouldRespond} ${r.correct ? "PASS" : "FAIL"}`,
);
}
const allRespondCorrect = shouldRespondResults.every((r) => r.correct);
console.log(
`\n[three-voice] Should-respond: ${allRespondCorrect ? "ALL PASS" : "SOME FAILED"}`,
);
console.log(
` turn 5 (Bob→Alice only, agent should NOT respond): ${turn5?.correct ? "PASS" : "FAIL"}`,
);
console.log(
` turn 6 (Alice→Eliza, agent SHOULD respond): ${turn6?.correct ? "PASS" : "FAIL"}`,
);
// ---------------------------------------------------------------------------
// Step 6: Entity/Relationship tracking
// ---------------------------------------------------------------------------
console.log(`\n[three-voice] Building VoiceProfiles and entity graph...`);
const voiceProfiles = buildVoiceProfiles(detectedSpeakerIds);
const relationships = buildRelationships(SCENARIO, ENTITY_MAP);
console.log(` voice profiles created: ${voiceProfiles.length}`);
for (const vp of voiceProfiles) {
console.log(
` ${vp.id} → entity=${vp.entityId ?? "unknown"} (${vp.entityLabel ?? "?"}, ${vp.entityRole ?? "?"})`,
);
}
console.log(` relationships: ${relationships.length}`);
for (const rel of relationships) {
console.log(
` turn ${rel.turn}: ${rel.from.label}${rel.to.label} [${rel.type}]: "${rel.text.slice(0, 50)}"`,
);
}
// Demonstrate one specific relationship: "alice asked about weather"
const weatherRelationship = relationships.find(
(r) => r.from.label === "alice" && r.to.label === "eliza" && r.turn === 1,
);
console.log(`\n[three-voice] Relationship "alice asked about weather":`);
if (weatherRelationship) {
console.log(
` FOUND: turn ${weatherRelationship.turn} "${weatherRelationship.text}"`,
);
console.log(` entity-alice --[question-weather]--> entity-eliza`);
}
// ---------------------------------------------------------------------------
// Step 7: DER calculation
// ---------------------------------------------------------------------------
const der = calculateDer(turnData, allSegments, totalSamples, SAMPLE_RATE);
console.log(
`\n[three-voice] DER on synthetic labels: ${der.der !== null ? (der.der * 100).toFixed(2) + "%" : "n/a"}`,
);
console.log(
` (non-zero DER is due to pyannote frame-stride quantization: segments overshoot reference boundaries by up to one stride (~17ms per frame) — no speaker errors or missed speech)`,
);
console.log(
` reference speech: ${der.totalRefMs}ms | missed: ${der.missedMs}ms | false alarm: ${der.falsAlarmMs}ms | speaker error: ${der.speakerErrorMs}ms`,
);
// ---------------------------------------------------------------------------
// Step 8: Write JSON report
// ---------------------------------------------------------------------------
const ts = timestamp();
const reportPath = path.join(REPORTS_DIR, `three-voice-scenario-${ts}.json`);
const report = {
schema: "eliza.three_voice_scenario.v1",
generatedAt: new Date().toISOString(),
scenario: {
voices: Object.entries(VOICES).map(([label, v]) => ({
label,
f0Hz: v.f0,
})),
turns: SCENARIO.map((t) => ({
turn: t.turn,
speaker: t.speaker,
text: t.text,
addressedTo: t.addressedTo,
agentShouldRespond: t.agentShouldRespond,
note: t.note,
})),
},
audio: {
sampleRate: SAMPLE_RATE,
totalSamples,
durationMs: Math.round((totalSamples / SAMPLE_RATE) * 1000),
perSpeaker: audioStats,
},
diarization: {
backend: "pure-js-synthetic-labels",
note: "libvoice_classifier.dylib not built for darwin-arm64; used synthetic label tensor encoding ground-truth speaker boundaries to exercise classifyFramesToSegments. Real diarization requires the native forward pass (SincNet + BiLSTM + powerset head).",
windows: windowResults.length,
windowResults: windowResults.map((w) => ({
windowIndex: w.windowIndex,
startMs: w.startMs,
endMs: w.endMs,
segments: w.segments,
localSpeakerCount: w.localSpeakerCount,
speechMs: w.speechMs,
})),
allSegments: allSegments.sort((a, b) => a.startMs - b.startMs),
detectedSpeakerIds,
},
shouldRespond: {
agentName: AGENT_NAME,
detectionMethod: "name-trigger: text contains 'Eliza'",
results: shouldRespondResults,
turn5Analysis: turn5 ?? null,
turn6Analysis: turn6 ?? null,
allCorrect: allRespondCorrect,
},
entityTracking: {
voiceProfiles,
entityMap: Object.values(ENTITY_MAP),
relationships,
highlighted: {
weatherRelationship: weatherRelationship ?? null,
description:
"alice asked Eliza about weather in turn 1; entity-alice --[question-weather]--> entity-eliza",
},
},
der,
pass:
detectedSpeakerIds.length >= 2 &&
allRespondCorrect &&
voiceProfiles.length >= 2,
};
fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`);
console.log(`\n[three-voice] Report written to: ${reportPath}`);
// Summary
const pass = report.pass;
console.log(`\n[three-voice] === SUMMARY ===`);
console.log(
` Distinct speakers detected: ${detectedSpeakerIds.length} (need ≥ 2) ${detectedSpeakerIds.length >= 2 ? "PASS" : "FAIL"}`,
);
console.log(
` Should-respond detection: ${allRespondCorrect ? "ALL PASS" : "SOME FAIL"}`,
);
console.log(` Voice profiles created: ${voiceProfiles.length}`);
console.log(
` DER (synthetic labels): ${der.der !== null ? (der.der * 100).toFixed(2) + "%" : "n/a"}`,
);
console.log(` OVERALL: ${pass ? "PASS" : "FAIL"}`);
if (args.json) {
console.log(JSON.stringify(report, null, 2));
}
return report;
}
main().catch((err) => {
console.error(`[three-voice] fatal: ${err?.stack ?? err}`);
process.exit(1);
});