Ctrl K
ComunidadGuías…

scripts/testing/voice-latency-harness.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// Voice-latency measurement harness. Drives the real app — renderer store
// speak path, the Speaker, the server TTS route and the provider call —
// against the committed corpus fixture, through the same disposable
// verification fixture `control-omb ui` uses. The record protocol is the
// deterministic stub: no live engine call, no engine key. See
// fixtures/voice-latency/README.md for the protocol.
import { execFileSync } from "node:child_process";
import { createHash } from "node:crypto";
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
import { cpus, tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { runControlOmb, launchVerificationServer, type VerificationServer } from "../control-omb.ts";
import { agentBrowser, ensureUiBrowser, sessionEnv } from "./control-omb-ui.ts";
import { mountPreview, type MountedPreview } from "./preview-fixture.ts";
import { CADENCE, corpusFixtureId, readLfText, SYNTHESIS } from "./voice-latency-corpus.ts";
import { launchVoiceLatencyStub, STUB_KEY, STUB_MODEL, STUB_VOICE } from "./voice-latency-stub.ts";
import {
  summarizeVoiceTurn,
  summarizeVoiceRun,
  type VoiceProbeSample,
} from "../../shared/voice-latency-probe.ts";

const HERE = dirname(fileURLToPath(import.meta.url));
const FIXTURE_DIR = join(HERE, "fixtures", "voice-latency");
const CORPUS_PATH = join(FIXTURE_DIR, "corpus.json");
const MANIFEST_PATH = join(FIXTURE_DIR, "manifest.json");
const LOGS_DIR = join(FIXTURE_DIR, "logs");
const BASE_COMMIT = execFileSync("git", ["rev-parse", "--short=9", "HEAD"], { cwd: join(HERE, "..", "..") }).toString().trim();

const WARMUP_REPLY = "Warm. One throwaway reply to prime the voice path.";

interface CorpusReply {
  id: string;
  shape: string;
  text: string;
}

interface ManifestReply {
  id: string;
  blocks: number;
}

interface Manifest {
  corpusFixtureId: string;
  replies: ManifestReply[];
}

interface ReplyRow {
  replyId: string;
  shape: string;
  blocksExpected: number;
  blocksMeasured: number | null;
  t0ToFirstAudioMs: number | null;
  t0ToMessageFrameMs: number | null;
  messageFrameToFirstAudioMs: number | null;
  gapCount: number;
  totalGapMs: number;
  longestGapMs: number;
  measured: boolean;
  modeled: boolean;
}

const sha256 = (text: string): string => createHash("sha256").update(text).digest("hex");

const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
function readJson(path: string): unknown {
  return JSON.parse(readFileSync(path, "utf8"));
}

function readCorpus(raw: unknown): CorpusReply[] {
  if (!raw || typeof raw !== "object" || !("replies" in raw) || !Array.isArray((raw as { replies: unknown }).replies)) {
    throw new Error("corpus fixture has no replies array");
  }
  return (raw as { replies: unknown[] }).replies.map((reply): CorpusReply => {
    if (!reply || typeof reply !== "object") throw new Error("corpus reply is not an object");
    const held = reply as Record<string, unknown>;
    if (typeof held.id !== "string" || typeof held.shape !== "string" || typeof held.text !== "string") {
      throw new Error("corpus reply missing id/shape/text");
    }
    return { id: held.id, shape: held.shape, text: held.text };
  });
}

function readManifest(raw: unknown): Manifest {
  if (!raw || typeof raw !== "object") throw new Error("manifest is not an object");
  const held = raw as Record<string, unknown>;
  if (typeof held.corpusFixtureId !== "string" || !Array.isArray(held.replies)) throw new Error("manifest missing corpusFixtureId/replies");
  const replies = held.replies.map((reply): ManifestReply => {
    if (!reply || typeof reply !== "object") throw new Error("manifest reply is not an object");
    const row = reply as Record<string, unknown>;
    if (typeof row.id !== "string" || typeof row.blocks !== "number") throw new Error("manifest reply missing id/blocks");
    return { id: row.id, blocks: row.blocks };
  });
  return { corpusFixtureId: held.corpusFixtureId, replies };
}

function usage(): never {
  console.error("usage: node --experimental-strip-types scripts/testing/voice-latency-harness.ts [--limit N] [--label before|after]");
  process.exit(2);
}

function parseArgs(argv: string[]): { limit: number | null; label: string } {
  let limit: number | null = null;
  let label = "before";
  for (let i = 0; i < argv.length; i += 1) {
    if (argv[i] === "--limit") {
      limit = Number(argv[i + 1]);
      if (!Number.isSafeInteger(limit) || limit < 1) usage();
      i += 1;
    } else if (argv[i] === "--label") {
      label = String(argv[i + 1] ?? "");
      if (label !== "before" && label !== "after") usage();
      i += 1;
    } else {
      usage();
    }
  }
  return { limit, label };
}

async function evalInPage(binary: string, env: NodeJS.ProcessEnv, js: string): Promise<unknown> {
  const data = await agentBrowser(binary, env, ["eval", js]);
  if ("result" in data) return data.result;
  if ("value" in data) return data.value;
  return data;
}

const sampleComplete = (sample: VoiceProbeSample): boolean =>
  typeof sample.blockCount === "number"
  && sample.blocks.length === sample.blockCount
  && sample.blocks.every((block) => typeof block.endAt === "number");

async function main(): Promise<void> {
  const { limit, label } = parseArgs(process.argv.slice(2));
  const corpusText = readLfText(CORPUS_PATH);
  const corpusSha = corpusFixtureId(corpusText);
  const manifest = readManifest(readJson(MANIFEST_PATH));
  if (manifest.corpusFixtureId !== corpusSha) {
    throw new Error(`corpus fixture drift: manifest ${manifest.corpusFixtureId} != file ${corpusSha}`);
  }
  const corpus = readCorpus(JSON.parse(corpusText));
  const selected = limit ? corpus.slice(0, limit) : corpus;

  const { binary, chrome } = await ensureUiBrowser(process.env, (line) => console.error(line));

  // The record protocol's synthesis endpoint: loopback, keyless, modeled
  // timing at the manifest's pinned RTF. Launched before the fixture so the
  // provider override can point at it.
  const stub = await launchVoiceLatencyStub();

  // Marker routing, not ordered slots: the driver replays a user message over
  // several CLI launches, and slot order would misalign replies across them.
  const WARMUP_MARKER = "omb voice probe warmup";
  const markerPath = join(mkdtempSync(join(tmpdir(), "omb-voice-latency-")), "marker-replies.json");
  const markerReplies: Record<string, string> = { [WARMUP_MARKER]: WARMUP_REPLY };
  for (const reply of selected) markerReplies[`omb voice probe ${reply.id}`] = reply.text;
  writeFileSync(markerPath, JSON.stringify(markerReplies));
  const fakeEnv: NodeJS.ProcessEnv = {
    FAKE_CLAUDE_MARKER_REPLIES: markerPath,
    FAKE_CLAUDE_TOOL_CALLS: "[]",
    FAKE_CLAUDE_STREAM_CADENCE: `${CADENCE.chunkChars}:${CADENCE.intervalMs}`,
    OMB_ELEVENLABS_API: stub.url,
  };

  let fixture: VerificationServer | undefined;
  let preview: MountedPreview | undefined;
  let browserEnv: NodeJS.ProcessEnv | undefined;
  const rows: ReplyRow[] = [];
  try {
    const stop = new AbortController();
    process.once("SIGINT", () => stop.abort());
    process.once("SIGTERM", () => stop.abort());
    // The signal feeds launchVerificationServer only; polling it here is what
    // unwinds an interrupt to the finally teardown.
    const throwIfAborted = (): void => stop.signal.throwIfAborted();
    const serverEnv: NodeJS.ProcessEnv = { ...process.env, ...fakeEnv };
    // Keyless record run: an exported live key would otherwise ride the
    // parent env into measurement, even though nothing reads it here.
    delete serverEnv.OMB_TTS_KEY;
    fixture = await launchVerificationServer(serverEnv, stop.signal, undefined,
      { binaryPath: binary, executablePath: chrome ?? "" });

    const api = async (method: string, path: string, body?: unknown): Promise<Record<string, unknown>> => {
      const res = await fetch(`${fixture!.info.url}${path}`, {
        method,
        headers: { "content-type": "application/json" },
        body: body === undefined ? undefined : JSON.stringify(body),
      });
      const parsed = await res.json().catch(() => ({}));
      if (!res.ok) throw new Error(`${method} ${path} -> ${res.status}: ${JSON.stringify(parsed)}`);
      return parsed as Record<string, unknown>;
    };

    await api("PATCH", "/api/config", { tts: { provider: "elevenlabs", key: STUB_KEY, model: STUB_MODEL, voice: STUB_VOICE } });
    const created = await runControlOmb(["new-bot", "--name", "VoiceProbe", "--url", fixture.info.url]) as { bot: { id: string } };
    const botId = created.bot.id;
    await api("PATCH", `/api/bots/${botId}`, { speakReplies: true, voice: STUB_VOICE });
    console.error(`tts provider=elevenlabs(stub) model=${STUB_MODEL} voice=${STUB_VOICE} synthesis=modeled rtf=${SYNTHESIS.rtf} audioMsPerChar=${SYNTHESIS.audioMsPerChar}`);

    preview = await mountPreview(fixture, {
      entry: "/scripts/testing/threads-preview.tsx",
      route: "/__threads.html",
      title: "Isolated OpenMaus Chat",
      logLevel: "warn",
    });
    const session = { home: fixture.info.dataDir, session: `omb-voice-${new URL(fixture.info.url).port}`, chrome };
    browserEnv = sessionEnv(session, process.env);
    // Headless Chrome otherwise refuses media playback without a user gesture.
    browserEnv.AGENT_BROWSER_ARGS = [browserEnv.AGENT_BROWSER_ARGS ?? "", "--autoplay-policy=no-user-gesture-required"].filter(Boolean).join("\n");
    await agentBrowser(binary, browserEnv, ["open", `${preview.previewUrl}?ombVoiceProbe=1`], 120_000);

    // The `open` verb drops the query string, so the sink is installed by
    // eval; the preview entry's ?ombVoiceProbe hook stays as documentation.
    await evalInPage(binary, browserEnv, "window.__ombVoiceProbe = window.__ombVoiceProbe ?? []");
    if (await evalInPage(binary, browserEnv, "Array.isArray(window.__ombVoiceProbe)") !== true) {
      throw new Error("voice probe sink failed to install in the page");
    }
    // The app must render (and hydrate its event stream) before the first
    // turn, or the warmup's frames never reach the page's recorder.
    for (let i = 0; i < 60; i += 1) {
      throwIfAborted();
      const booted = await evalInPage(binary, browserEnv, "(document.getElementById('root')?.children.length ?? 0) > 0");
      if (booted === true) break;
      if (i === 59) throw new Error("app never rendered: page stayed empty for 30s");
      await sleep(500);
    }
    const seen = new Set<string>();

    const pullSamples = async (): Promise<VoiceProbeSample[]> => {
      const raw = await evalInPage(binary, browserEnv!, "JSON.stringify(window.__ombVoiceProbe)");
      if (typeof raw !== "string") return [];
      return JSON.parse(raw) as VoiceProbeSample[];
    };

    // A streaming turn emits one message frame per delta chunk, each speaking;
    // a turn is settled when no new frame has appeared for a quiet window and
    // the final frame finished playing.
    const QUIET_POLLS = 4;
    // Settle only after the turn's OWN reply fully streamed: replays of the
    // previous marker can still land early in this window and would otherwise
    // settle the turn before its first frame exists.
    const settleTurn = async (scope: string, timeoutMs: number, expectedChars: number): Promise<VoiceProbeSample[] | null> => {
      const deadline = Date.now() + timeoutMs;
      let quiet = 0;
      let lastCount = -1;
      for (;;) {
        throwIfAborted();
        const samples = await pullSamples();
        const fresh = samples.filter((sample) => !seen.has(sample.messageId));
        if (samples.length !== lastCount) {
          lastCount = samples.length;
          quiet = 0;
        } else {
          quiet += 1;
        }
        const own = fresh.filter((frame) => (frame.textChars ?? -1) === expectedChars && sampleComplete(frame));
        const settled = quiet >= QUIET_POLLS && own.length > 0;
        if (settled) {
          console.error(`${scope}: settled frames ${fresh.map((frame) => `${frame.textChars ?? "?"}c${sampleComplete(frame) ? "" : "~"}`).join(" ")}`);
          for (const sample of fresh) seen.add(sample.messageId);
          return fresh;
        }
        if (Date.now() > deadline) {
          const href = await evalInPage(binary, browserEnv!, "location.href").catch(() => "?");
          console.error(`${scope} timeout: samples=${samples.length} fresh=${fresh.length} quiet=${quiet} own=${own.length} expected=${expectedChars}c href=${JSON.stringify(href)}`);
          try {
            const tail = readFileSync(fixture!.info.logPath, "utf8").split("\n").filter((line) =>
              line.includes("[omb-turn]") || line.includes("[omb-error]") || line.includes("tts")).slice(-10);
            for (const line of tail) console.error(`  srv ${line.slice(0, 240)}`);
          } catch {}
          return null;
        }
        await sleep(500);
      }
    };

    // Frames going quiet does not mean the server finished: the driver
    // legitimately replays a user message over several CLI launches, and the
    // next POST's turn would queue behind that chain. Gate on the task's own
    // busy flag before handing the turn slot to the next reply.
    const waitServerIdle = async (timeoutMs: number): Promise<void> => {
      const deadline = Date.now() + timeoutMs;
      let idleStreak = 0;
      for (;;) {
        throwIfAborted();
        const raw = await api("GET", "/api/bots");
        const bots = Array.isArray(raw.bots) ? raw.bots as Array<{ id?: string; tasks?: Array<{ busy?: boolean }> }> : [];
        const mine = bots.find((bot) => bot.id === botId);
        const busyCount = (mine?.tasks ?? []).filter((entry) => entry.busy === true).length;
        idleStreak = mine && busyCount === 0 ? idleStreak + 1 : 0;
        if (idleStreak >= 2) return;
        if (Date.now() > deadline) {
          console.error(`idle debug: bots=${bots.length} mine=${mine?.id ?? "none"} busyTasks=${busyCount}`);
          throw new Error(`server turn never went idle`);
        }
        await sleep(500);
      }
    };

    const driveTurn = async (scope: string, prompt: string, promptChars: number, timeoutMs: number): Promise<VoiceProbeSample[] | null> => {
      await api("POST", `/api/bots/${botId}/messages`, { text: prompt });
      const frames = await settleTurn(scope, timeoutMs, promptChars);
      await waitServerIdle(120_000);
      return frames;
    };

    // Warmup: one spoken reply through the whole path, discarded.
    const warmup = await driveTurn("warmup", WARMUP_MARKER, WARMUP_REPLY.length, 60_000);
    if (!warmup) throw new Error("warmup reply never finished; aborting before the corpus");
    console.error("warmup complete=true");

    for (const reply of selected) {
      throwIfAborted();
      const expected = manifest.replies.find((row) => row.id === reply.id)?.blocks;
      if (!expected) throw new Error(`manifest has no block count for ${reply.id}`);
      const timeout = 45_000 + expected * 8_000;
      const frames = await driveTurn(`reply ${reply.id}`, `omb voice probe ${reply.id}`, reply.text.length, timeout);
      // The turn's other frames carry chunk-sized prefixes or trailing
      // auxiliary messages; the corpus reply is identified by its length.
      const audioFrame = frames?.filter((frame) => frame.textChars === reply.text.length)
        .find((frame) => sampleComplete(frame));
      const summary = frames && audioFrame ? summarizeVoiceTurn(frames, audioFrame) : null;
      if (frames && !audioFrame) console.error(`reply ${reply.id}: frames ${frames.map((frame) => `${frame.textChars ?? "?"}c/${frame.blocks.length}b${sampleComplete(frame) ? "" : "~"}`).join(" ")}`);
      rows.push({
        replyId: reply.id,
        shape: reply.shape,
        blocksExpected: expected,
        blocksMeasured: summary?.blocksMeasured ?? null,
        t0ToFirstAudioMs: summary?.t0ToFirstAudioMs ?? null,
        t0ToMessageFrameMs: summary?.t0ToMessageFrameMs ?? null,
        messageFrameToFirstAudioMs: summary?.messageFrameToFirstAudioMs ?? null,
        gapCount: summary?.gapCount ?? 0,
        totalGapMs: summary?.totalGapMs ?? 0,
        longestGapMs: summary?.longestGapMs ?? 0,
        measured: summary !== null,
        modeled: true,
      });
      console.error(`${reply.id} blocks=${summary?.blocksMeasured ?? "?"}/${expected} first=${summary?.t0ToFirstAudioMs ?? "?"}ms gaps=${summary?.gapCount ?? "?"} longest=${summary?.longestGapMs ?? "?"}ms frames=${frames?.length ?? 0}`);
    }

    const totals = summarizeVoiceRun(rows.filter((row) => row.measured).map((row) => ({
      t0ToFirstAudioMs: row.t0ToFirstAudioMs,
      gapCount: row.gapCount,
      totalGapMs: row.totalGapMs,
      longestGapMs: row.longestGapMs,
    })));
    const userAgent = await evalInPage(binary, browserEnv, "navigator.userAgent").catch(() => null);
    const log = {
      kind: "voice-latency-run",
      label,
      corpusFixtureId: corpusSha,
      baseCommit: BASE_COMMIT,
      measuredBuildCommit: process.env.OMB_MEASURED_COMMIT ?? "(unset: worktree HEAD)",
      date: new Date().toISOString(),
      machine: { platform: process.platform, arch: process.arch, cpuModel: cpus()[0]?.model ?? null },
      configSummary: { provider: "elevenlabs (record stub)", model: STUB_MODEL, voice: STUB_VOICE },
      synthesis: {
        protocol: "elevenlabs-wire stub on loopback; no live engine call, no engine key",
        modeled: true,
        rtf: SYNTHESIS.rtf,
        audioMsPerChar: SYNTHESIS.audioMsPerChar,
        synthesisMsPerChar: SYNTHESIS.rtf * SYNTHESIS.audioMsPerChar,
      },
      cadence: CADENCE,
      limit: limit ?? null,
      userAgent,
      totals: { ...totals, modeled: true },
      replies: rows,
    };
    mkdirSync(LOGS_DIR, { recursive: true });
    const logPath = join(LOGS_DIR, `${label}-${corpusSha.slice(0, 12)}.json`);
    const body = `${JSON.stringify(log, null, 2)}\n`;
    writeFileSync(logPath, body);
    process.stdout.write(`${JSON.stringify({
      ok: true,
      logPath,
      logFixtureId: sha256(body),
      corpusFixtureId: corpusSha,
      synthesis: log.synthesis,
      totals: log.totals,
    }, null, 2)}\n`);
  } finally {
    if (browserEnv) await agentBrowser(binary, browserEnv, ["close"], 15_000).catch(() => {});
    await preview?.close();
    await fixture?.close();
    await stub.close();
  }
}

if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
  await main();
}