- )}
-
-
-
-
-
- {(log) => }
-
+
{(call) => {
diff --git a/src/components/voice/call.test.ts b/src/components/voice/call.test.ts
index 288a696..7039cd6 100644
--- a/src/components/voice/call.test.ts
+++ b/src/components/voice/call.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test";
-import { CallLog } from "./call";
+import { align, CallLog } from "./call";
// Millisecond ticks, so media times read as written.
const arrival = (capture: number, arrive: number) => ({
@@ -107,7 +107,7 @@ describe("publish", () => {
expect(old?.discarded).toBe(true);
expect(claimed?.track).toBe("bot-audio-1");
expect(claimed?.discarded).toBeFalsy();
- expect(claimed?.play).toBeUndefined();
+ expect(claimed?.played).toBeFalsy();
});
test("a frame of a track retired before it arrived never plays", () => {
@@ -125,10 +125,9 @@ describe("publish", () => {
const late = log.reply.find((f) => f.media === 3000);
expect(late?.track).toBe("bot-audio");
expect(late?.discarded).toBe(true);
- expect(late?.play).toBeDefined();
});
- test("a frame of a track retired before any reply arrived is placed at the retirement", () => {
+ test("a frame of a track retired before any reply arrived never plays", () => {
const log = new CallLog();
log.agent(arrival(0, 1010));
log.agent(publish("bot-audio", 0, 5000, 1100));
@@ -144,7 +143,6 @@ describe("publish", () => {
const frame = log.reply.find((f) => f.media === 0);
expect(frame?.discarded).toBe(true);
- expect(frame?.play).toBeDefined();
});
test("an older publish still holds media past a newer, shorter one", () => {
@@ -156,63 +154,94 @@ describe("publish", () => {
});
});
-const record = (type: string, at: number, fields: object) => ({
+const word = (speaker: string, text: string, start: number, end: number, track = "mic") => ({
version: 2,
- type,
+ type: "word",
+ speaker,
+ track,
+ text,
timescale: 1000,
- at_ns: at * 1e6,
- ...fields,
+ start_ticks: start,
+ end_ticks: end,
});
-describe("statement", () => {
- test("runs from your speech until you stop", () => {
+describe("sent", () => {
+ test("marks a frame that reached the agent after a later one", () => {
+ const log = new CallLog();
+ log.agent(arrival(0, 1010));
+ log.agent(arrival(40, 1050));
+ // Retransmitted: captured before the frame that beat it.
+ log.agent(arrival(20, 1200));
+
+ expect(log.sent({ text: "hi", start: 0, end: 60 })).toEqual(["delivered", "reordered", "delivered"]);
+ });
+
+ test("a frame the agent has not heard yet is in flight", () => {
+ const log = new CallLog();
+ log.agent(arrival(0, 1010));
+ log.agent(arrival(40, 1050));
+
+ expect(log.sent({ text: "hi", start: 0, end: 60 })).toEqual(["delivered", "flight", "delivered"]);
+ });
+});
+
+describe("heard", () => {
+ test("buffered ahead of the playhead, delivered once played", () => {
const log = synced();
- log.agent(record("vad", 6210, { speaking: true, media_ticks: 5200, confirm_ticks: 200 }));
- log.agent(record("turn", 6250, { speaking: true, media_ticks: 5240 }));
- expect(log.statement(5500)).toEqual({ start: 5000, end: 5500 });
- log.agent(record("vad", 7210, { speaking: false, media_ticks: 6200, confirm_ticks: 200 }));
- log.agent(record("turn", 7300, { speaking: false, media_ticks: 6290 }));
- expect(log.statement(9000)).toEqual({ start: 5000, end: 6000 });
+ log.received(20, 120, false);
+ log.buffer([{ start: 40, end: 80 }]);
+ log.playhead(0);
+
+ expect(log.heard({ text: "hi", start: 0, end: 100, track: "bot-audio" })).toEqual([
+ "delivered",
+ "buffered",
+ "buffered",
+ "buffered",
+ "flight",
+ ]);
});
- test("a reply to words the VAD missed starts a statement of its own", () => {
+ test("a retired track's word is interrupted, arrived or not", () => {
const log = synced();
- log.agent(record("vad", 6210, { speaking: true, media_ticks: 5200, confirm_ticks: 200 }));
- log.agent(record("turn", 6250, { speaking: true, media_ticks: 5240 }));
- log.agent(record("vad", 7210, { speaking: false, media_ticks: 6200, confirm_ticks: 200 }));
- log.agent(record("turn", 7300, { speaking: false, media_ticks: 6290 }));
- // A short "okay" after the agent's reply: STT hears it, the VAD does not.
- const word = { speaker: "user", track: "mic", text: "okay", start_ticks: 12_000, end_ticks: 12_300 };
- log.agent(record("word", 13_350, word));
- log.agent(record("turn", 13_400, { speaking: true, media_ticks: 12_390 }));
- expect(log.statement(12_500)).toEqual({ start: 12_000, end: 12_300 });
- });
-
- test("speech after the agent's turn is the next statement", () => {
+ log.received(20, 120, false);
+ log.agent({
+ version: 2,
+ type: "retire",
+ track: "bot-audio",
+ timescale: 1000,
+ unplayed_ticks: 5000,
+ at_ns: 1200 * 1e6,
+ });
+
+ expect(log.heard({ text: "hi", start: 20, end: 60, track: "bot-audio" })).toEqual(["interrupted", "interrupted"]);
+ });
+
+ test("ignores another track's frame at the same media time", () => {
const log = synced();
- log.agent(record("vad", 6210, { speaking: true, media_ticks: 5200, confirm_ticks: 200 }));
- log.agent(record("turn", 6250, { speaking: true, media_ticks: 5240 }));
- log.agent(record("vad", 7210, { speaking: false, media_ticks: 6200, confirm_ticks: 200 }));
- log.agent(record("turn", 7300, { speaking: false, media_ticks: 6290 }));
- log.agent(record("vad", 9210, { speaking: true, media_ticks: 8200, confirm_ticks: 200 }));
- expect(log.statement(8500)).toEqual({ start: 8000, end: 8500 });
+ log.received(2000, 2500, false);
+ log.agent(publish("bot-audio-1", 2000, 3000, 4000));
+
+ expect(log.heard({ text: "hi", start: 2000, end: 2020, track: "bot-audio-1" })).toEqual(["flight"]);
});
});
-describe("answer", () => {
- test("spans the newest track's publishes since the request that led to them", () => {
- const log = synced();
- // The first reply rides bot-audio from 0. The next, on the same track, starts at 5000.
- log.agent(record("llm", 7000, { event: "request" }));
- log.agent(publish("bot-audio", 5000, 6000, 7100));
- log.agent(publish("bot-audio", 6000, 8000, 7200));
- expect(log.answer()).toEqual({ track: "bot-audio", start: 5000, end: 8000 });
+describe("align", () => {
+ test("matches across case and punctuation, and skips words either side left out", () => {
+ const log = new CallLog();
+ for (const [i, text] of ["hello", "there", "how", "are", "you"].entries()) {
+ log.agent(word("user", text, i * 100, i * 100 + 80));
+ }
+ // The transcript has "um", which was never timed; `voice.json` has "there", which the transcript lost.
+ const matched = align(["Hello,", "um", "how", "are", "you?"], log.userWords);
+
+ expect(matched.map((w) => w?.text)).toEqual(["hello", undefined, "how", "are", "you"]);
});
- test("a barge-in's track replaces the interrupted reply", () => {
- const log = synced();
- log.agent(record("llm", 7000, { event: "request" }));
- log.agent(publish("bot-audio-1", 2000, 3000, 7100));
- expect(log.answer()).toEqual({ track: "bot-audio-1", start: 2000, end: 3000 });
+ test("a restated window does not repeat a word", () => {
+ const log = new CallLog();
+ log.agent(word("user", "hello", 0, 80));
+ log.agent(word("user", "hello", 0, 80));
+
+ expect(log.userWords).toHaveLength(1);
});
});
diff --git a/src/components/voice/call.ts b/src/components/voice/call.ts
index 2747ac2..f3a9296 100644
--- a/src/components/voice/call.ts
+++ b/src/components/voice/call.ts
@@ -1,4 +1,4 @@
-// Everything measured about one live call, on this page's clock (`performance.now()` milliseconds).
+// Everything measured about one live call's audio, on this page's clock (`performance.now()` ms).
//
// Three sources feed it:
// - Your mic frames: `@moq/publish` stamps each with its capture time on this clock, so the media
@@ -10,15 +10,22 @@
// The agent's clock is placed on ours NTP-style, from the fastest transit each way: request frames
// bound the offset from one side and reply frames from the other, so it needs no clock agreement.
-// The agent's Opus frames, and the duration drawn for a reply frame.
+// The agent's Opus frames, and the span each packet of a word covers.
export const FRAME_MS = 20;
/** A `voice.json` record. Unknown types are skipped; see moq.pro agent/VOICE.md. */
export type AgentRecord = { version?: number; type?: string; [key: string]: unknown };
+/**
+ * One frame of a word, as far as it has travelled. `delivered` is at the agent for your words and
+ * played for its own; `buffered` is a reply frame here ahead of the playhead.
+ */
+export type Packet = "delivered" | "buffered" | "reordered" | "flight" | "interrupted" | "lost";
+
export interface MicFrame {
capture: number; // local
arrive: number; // agent
+ reordered: boolean; // reached the agent after a frame captured later: retransmitted or reordered
}
export interface ReplyFrame {
@@ -29,32 +36,17 @@ export interface ReplyFrame {
// playhead was, so two tracks can hold the same media time.
track?: string;
published?: number; // agent
- play?: number; // local, frozen once it played or was interrupted
+ played?: boolean;
discarded?: boolean;
}
export interface Word {
text: string;
- start: number;
+ start: number; // local capture time for yours, reply media time for the agent's
end: number;
- at?: number; // agent, when STT settled on it
track?: string;
}
-export interface Mark {
- at: number; // agent
- kind: "vad" | "turn" | "llm" | "tts" | "retire";
- label: string;
- media?: number; // local, for user-side marks: what the agent had heard
-}
-
-export interface Span {
- start: number; // agent
- end?: number; // agent, open while running
- kind: "llm" | "tts";
- first?: number; // agent: first text or audio
-}
-
interface Publish {
track: string;
start: number; // reply media
@@ -76,10 +68,30 @@ function agentMs(record: AgentRecord, field: string): number | undefined {
}
// Insert keeping `list` sorted by `key`. Records arrive in about time order, so scan from the back.
-function insert(list: T[], item: T, key: (t: T) => number) {
+// Returns where it went.
+function insert(list: T[], item: T, key: (t: T) => number): number {
let i = list.length;
while (i > 0 && key(list[i - 1]) > key(item)) i--;
list.splice(i, 0, item);
+ return i;
+}
+
+// The first index whose key is at least `value`, in a list sorted by `key`.
+function lowerBound(list: T[], value: number, key: (t: T) => number): number {
+ let lo = 0;
+ let hi = list.length;
+ while (lo < hi) {
+ const mid = (lo + hi) >> 1;
+ if (key(list[mid]) < value) lo = mid + 1;
+ else hi = mid;
+ }
+ return lo;
+}
+
+// Each FRAME_MS of a word, the middle of the frame it should fall in.
+function slots(word: Word): number[] {
+ const count = Math.max(1, Math.round((word.end - word.start) / FRAME_MS));
+ return Array.from({ length: count }, (_, i) => word.start + (i + 0.5) * FRAME_MS);
}
export class CallLog {
@@ -88,30 +100,23 @@ export class CallLog {
gaps: { media: number; duration: number }[] = [];
// Reply media the player holds but has not played: received, decoded or not.
buffered: { start: number; end: number }[] = [];
+ // In the order the agent settled on them, which is the order they were said.
userWords: Word[] = [];
- interims: Word[] = [];
agentWords: Word[] = [];
- marks: Mark[] = [];
- spans: Span[] = [];
- // User speech as the VAD decided it, in local capture time.
- speech: { start: number; end?: number }[] = [];
publishes: Publish[] = [];
// The longest publish, which bounds how far back one can still hold a media time.
#longest = 0;
- // Retired tracks and when they retired, on the local clock: their later arrivals never play.
- #retired = new Map();
+ // Tracks a barge-in retired: their later arrivals never play.
+ #retired = new Set();
+ // A reconnect restates the window, so a word can come twice.
+ #seen = new Set();
+ // The reply's media time only moves forward, across track changes too.
+ #head?: number;
// The fastest transit each way, each including the clock offset.
minUp?: number; // agent - local
minDown?: number; // local - agent
- // Where the reply's playhead was, and when.
- #anchor?: { local: number; media: number };
- #playing = false;
- #head?: { now: number; media: number | undefined };
-
- ended?: number; // local
-
/** Agent time minus local time, once both directions have been measured. */
get offset(): number | undefined {
if (this.minUp === undefined || this.minDown === undefined) return undefined;
@@ -124,12 +129,6 @@ export class CallLog {
return (this.minUp + this.minDown) / 2;
}
- /** An agent time on the local clock, once the offset is known. */
- local(agent: number): number | undefined {
- const offset = this.offset;
- return offset === undefined ? undefined : agent - offset;
- }
-
/** Ingest one `voice.json` record. */
agent(record: AgentRecord) {
if (record.version !== 2) return;
@@ -138,58 +137,26 @@ export class CallLog {
const capture = ms(record, "media_ticks");
const arrive = agentMs(record, "arrival_ns");
if (capture === undefined || arrive === undefined) return;
- insert(this.mic, { capture, arrive }, (f) => f.capture);
+ const frame = { capture, arrive, reordered: false };
+ const at = insert(this.mic, frame, (f) => f.capture);
+ for (let i = at + 1; i < this.mic.length && !frame.reordered; i++) {
+ frame.reordered = this.mic[i].arrive < arrive;
+ }
const up = arrive - capture;
if (this.minUp === undefined || up < this.minUp) this.minUp = up;
return;
}
case "word": {
- const word = this.#word(record);
- if (!word) return;
- if (record.speaker === "user") {
- insert(this.userWords, word, (w) => w.start);
- // The final word supersedes what the interims heard there.
- this.interims = this.interims.filter((i) => i.end <= word.start || i.start >= word.end);
- } else if (record.speaker === "agent") {
- insert(this.agentWords, word, (w) => w.start);
- }
- return;
- }
- case "interim": {
- const word = this.#word(record);
- if (!word) return;
- // A later interim revises the ones it overlaps.
- this.interims = this.interims.filter((i) => i.end <= word.start || i.start >= word.end);
- if (!this.userWords.some((w) => w.start < word.end && w.end > word.start)) {
- insert(this.interims, word, (w) => w.start);
- }
- return;
- }
- case "vad": {
- const at = agentMs(record, "at_ns");
- const media = ms(record, "media_ticks");
- const confirm = ms(record, "confirm_ticks") ?? 0;
- if (at === undefined || media === undefined) return;
- const speaking = record.speaking === true;
- const change = media - confirm;
- const open = this.speech.at(-1);
- if (speaking && (!open || open.end !== undefined)) this.speech.push({ start: change });
- if (!speaking && open && open.end === undefined) open.end = change;
- this.marks.push({ at, kind: "vad", label: speaking ? "speech" : "silence", media });
- return;
- }
- case "turn": {
- const at = agentMs(record, "at_ns");
- if (at === undefined) return;
- const media = ms(record, "media_ticks");
- this.marks.push({ at, kind: "turn", label: record.speaking === true ? "your turn" : "my turn", media });
- return;
- }
- case "llm":
- case "tts": {
- const at = agentMs(record, "at_ns");
- if (at === undefined) return;
- this.#span(record.type, record.event, at);
+ const start = ms(record, "start_ticks");
+ const end = ms(record, "end_ticks");
+ if (typeof record.text !== "string" || start === undefined || end === undefined) return;
+ const track = typeof record.track === "string" ? record.track : undefined;
+ const key = `${record.speaker}:${track}:${start}:${record.text}`;
+ if (this.#seen.has(key)) return;
+ this.#seen.add(key);
+ const word = { text: record.text, start, end, track };
+ if (record.speaker === "user") this.userWords.push(word);
+ else if (record.speaker === "agent") this.agentWords.push(word);
return;
}
case "publish": {
@@ -211,45 +178,16 @@ export class CallLog {
return;
}
case "retire": {
- const at = agentMs(record, "at_ns");
- const unplayed = ms(record, "unplayed_ticks");
- if (at === undefined) return;
- const label = unplayed ? `barge-in, dropped ${(unplayed / 1000).toFixed(1)}s` : "barge-in";
- this.marks.push({ at, kind: "retire", label });
- // What the retired track still held never plays; draw it where it would have.
if (typeof record.track !== "string") return;
- const now = this.local(at) ?? performance.now();
- this.#retired.set(record.track, now);
+ this.#retired.add(record.track);
for (const frame of this.reply) {
- if (frame.track === record.track) this.#discard(frame, now);
+ if (frame.track === record.track) this.#discard(frame);
}
return;
}
}
}
- #word(record: AgentRecord): Word | undefined {
- const start = ms(record, "start_ticks");
- const end = ms(record, "end_ticks");
- if (typeof record.text !== "string" || start === undefined || end === undefined) return undefined;
- const track = typeof record.track === "string" ? record.track : undefined;
- return { text: record.text, start, end, at: agentMs(record, "at_ns"), track };
- }
-
- #span(kind: "llm" | "tts", event: unknown, at: number) {
- const open = this.spans.findLast((s) => s.kind === kind && s.end === undefined);
- if (event === "request") {
- // TTS requests each sentence; one span covers the whole reply until it ends. Each LLM request
- // is a new statement, and supersedes one an interruption left without an end.
- if (open && kind === "llm") open.end = at;
- if (!open || kind === "llm") this.spans.push({ kind, start: at });
- } else if (event === "first_text" || event === "first_audio") {
- if (open && open.first === undefined) open.first = at;
- } else if (event === "end") {
- if (open) open.end = at;
- }
- }
-
/** Whether a publish at agent time `at` can have been sent before local time `arrive`. */
#sentBefore(at: number, arrive: number): boolean {
// Trusting the offset's midpoint would turn away the faster frames that would refine it.
@@ -260,10 +198,7 @@ export class CallLog {
#published(frame: ReplyFrame, publish: Publish) {
// A frame claimed from a retired track is not what that retirement discarded.
- if (frame.track !== undefined && frame.track !== publish.track && frame.discarded) {
- frame.discarded = false;
- frame.play = undefined;
- }
+ if (frame.track !== undefined && frame.track !== publish.track) frame.discarded = false;
frame.track = publish.track;
frame.published = publish.at;
// Between overlapping publishes the pick is only as good as the clock, and the wrong one could
@@ -271,16 +206,12 @@ export class CallLog {
const down = frame.arrive - publish.at;
const holders = this.publishes.filter((p) => frame.media >= p.start && frame.media < p.end).length;
if (holders === 1 && (this.minDown === undefined || down < this.minDown)) this.minDown = down;
- const retired = this.#retired.get(publish.track);
- if (retired !== undefined) this.#discard(frame, retired);
+ if (this.#retired.has(publish.track)) this.#discard(frame);
}
- /** What a retired track still held never plays; draw it where it would have. */
- #discard(frame: ReplyFrame, retired: number) {
- if (frame.play !== undefined) return;
- // Before any playhead there is nowhere it would have played, so it ends where it was retired.
- frame.play = this.playAt(frame, retired) ?? retired;
- frame.discarded = true;
+ /** What a retired track still held never plays. */
+ #discard(frame: ReplyFrame) {
+ if (!frame.played) frame.discarded = true;
}
/** A reply frame as `@moq/watch` received it. */
@@ -311,91 +242,73 @@ export class CallLog {
this.buffered = ranges.map(({ start, end }) => ({ start, end }));
}
- /** The reply's playhead: media `media` is playing at local time `now`, or nothing is. */
- playhead(now: number, media: number | undefined, playing: boolean) {
- // The reply's media time only moves forward, across track changes too; an idle player
- // reports zero.
- if (media === undefined || (this.#anchor && media < this.#anchor.media)) {
- this.#playing = false;
- return;
- }
- this.#playing = playing;
- this.#anchor = { local: now, media };
+ /** The reply's playhead is at `media`, or nothing is playing. */
+ playhead(media: number | undefined) {
+ // An idle player reports zero.
+ if (media === undefined || (this.#head !== undefined && media < this.#head)) return;
+ this.#head = media;
for (const frame of this.reply) {
- if (frame.play === undefined && frame.media <= media) frame.play = now - (media - frame.media);
+ if (!frame.played && !frame.discarded && frame.media <= media) frame.played = true;
}
}
- /** When a reply frame plays (or played) on the local clock, if it can be placed. */
- playAt(frame: ReplyFrame, now: number): number | undefined {
- if (frame.play !== undefined) return frame.play;
- const head = this.mediaNow(now);
- return head === undefined ? undefined : now + Math.max(0, frame.media - head);
+ /** Your word's frames, one per FRAME_MS, as the agent received them. */
+ sent(word: Word): Packet[] {
+ return slots(word).map((t) => {
+ // The frame whose 20 ms holds this slot's middle.
+ const i = lowerBound(this.mic, t - FRAME_MS, (f) => f.capture);
+ const frame = this.mic[i];
+ if (!frame || frame.capture > t) return "flight";
+ return frame.reordered ? "reordered" : "delivered";
+ });
}
- /** Media time of the reply's playhead now, extrapolated between updates. */
- mediaNow(now: number): number | undefined {
- if (this.#head?.now === now) return this.#head.media;
- const anchor = this.#anchor;
- let media: number | undefined;
- if (anchor && this.#playing) {
- media = anchor.media + (now - anchor.local);
- } else {
- // A stalled player starts the next utterance from its own first frame, about now.
- const next = this.reply.find((f) => f.play === undefined && !f.discarded);
- media = next ? Math.max(next.media, anchor?.media ?? 0) : anchor?.media;
- }
- this.#head = { now, media };
- return media;
+ /** The agent's word's frames, one per FRAME_MS, as they reached you. */
+ heard(word: Word): Packet[] {
+ return slots(word).map((t) => {
+ for (let i = lowerBound(this.reply, t - FRAME_MS, (f) => f.media); i < this.reply.length; i++) {
+ const frame = this.reply[i];
+ if (frame.media > t) break;
+ // A frame can beat the record of the track that carries it.
+ if (frame.track !== undefined && word.track !== undefined && frame.track !== word.track) continue;
+ if (frame.discarded) return "interrupted";
+ if (frame.late) return "lost";
+ return frame.played ? "delivered" : "buffered";
+ }
+ if (word.track !== undefined && this.#retired.has(word.track)) return "interrupted";
+ if (this.gaps.some((g) => t >= g.media && t < g.media + g.duration)) return "lost";
+ // The decoder reports a frame only once playback nears it; the player holds it before that.
+ if (this.buffered.some((r) => t >= r.start && t < r.end)) return "buffered";
+ return "flight";
+ });
}
+}
- /**
- * Your latest statement on the local clock: what you said since the agent's previous turn, until
- * you stopped or it took its next one.
- */
- statement(now: number): { start: number; end: number } | undefined {
- const latest = this.speech.at(-1);
- const yours = this.marks.findLast((m) => m.kind === "turn" && m.label === "your turn");
- if (!yours) return latest && { start: latest.start, end: latest.end ?? now };
- const mine = (m: Mark) => m.kind === "turn" && m.label === "my turn";
- const before = this.marks.findLast((m) => mine(m) && m.at < yours.at);
- const after = this.marks.find((m) => mine(m) && m.at > yours.at);
- const from = (before && this.local(before.at)) ?? Number.NEGATIVE_INFINITY;
- const until = after && this.local(after.at);
- // Speech after the agent took its turn is the next statement.
- if (latest && until !== undefined && latest.start > until) return { start: latest.start, end: latest.end ?? now };
-
- // Words count as well as speech: the agent can answer one too short for the VAD to hear.
- const to = until ?? Number.POSITIVE_INFINITY;
- let start = Number.POSITIVE_INFINITY;
- let end = Number.NEGATIVE_INFINITY;
- for (const s of [...this.speech, ...this.userWords, ...this.interims]) {
- const stop = s.end ?? now;
- if (stop <= from || s.start >= to) continue;
- start = Math.min(start, s.start);
- end = Math.max(end, stop);
- }
- if (start <= end) return { start, end };
- const heard = yours.media;
- return heard === undefined ? undefined : { start: heard, end: Math.max(heard, until ?? now) };
- }
+// Words as compared across the transcript and `voice.json`: case and punctuation aside.
+function norm(text: string): string {
+ return text
+ .normalize("NFKD")
+ .toLowerCase()
+ .replace(/[^\p{L}\p{N}]/gu, "");
+}
- /**
- * The agent's latest reply in media time, as far as it has published: its newest track, from the
- * first publish since the request that led to it. A track carries every reply until a barge-in.
- */
- answer(): { track: string; start: number; end: number } | undefined {
- let latest: Publish | undefined;
- for (const p of this.publishes) if (!latest || p.at > latest.at) latest = p;
- if (!latest) return undefined;
- const newest = latest;
- const asked = this.spans.findLast((s) => s.kind === "llm" && s.start <= newest.at)?.start;
- let { start, end } = newest;
- for (const p of this.publishes) {
- if (p.track !== newest.track || (asked !== undefined && p.at < asked)) continue;
- start = Math.min(start, p.start);
- end = Math.max(end, p.end);
+// How far either side may run ahead: the transcript and `voice.json` each leave words out.
+const SKIP = 4;
+
+/**
+ * Pair each transcript word with the timed word it is, in order. The transcript shows what was
+ * said; `voice.json` leaves out any word it could not time, so a word without a match is undefined.
+ */
+export function align(texts: string[], words: Word[]): (Word | undefined)[] {
+ let next = 0;
+ return texts.map((text) => {
+ const key = norm(text);
+ if (!key) return undefined;
+ for (let i = next; i < Math.min(words.length, next + SKIP); i++) {
+ if (norm(words[i].text) !== key) continue;
+ next = i + 1;
+ return words[i];
}
- return { track: newest.track, start, end };
- }
+ return undefined;
+ });
}
diff --git a/src/components/voice/timeline.tsx b/src/components/voice/timeline.tsx
deleted file mode 100644
index 87bcbb2..0000000
--- a/src/components/voice/timeline.tsx
+++ /dev/null
@@ -1,328 +0,0 @@
-// The call's two halves as buffers, each fitted to its latest utterance and replaced when the next
-// starts: your statement filling in at the agent (STT), retransmissions included, and the agent's
-// reply landing in your buffer (TTS), cached well ahead of the playhead.
-// Canvas, not SVG: an utterance is hundreds of 20 ms frames, redrawn every animation frame.
-import { createSignal, onCleanup, onMount } from "solid-js";
-import { type CallLog, FRAME_MS, type Word } from "./call";
-import { COLOR } from "./diagram";
-
-const WORDS_H = 24;
-// Words that would overprint each other stack into more rows above the bar, up to MAX_LANES.
-const LANE_H = 18;
-const MAX_LANES = 3;
-const BAR_H = 22;
-const AXIS_H = 16;
-const BAR_Y = WORDS_H;
-const AXIS_Y = BAR_Y + BAR_H;
-const H = AXIS_Y + AXIS_H;
-// The narrowest span drawn, so a short utterance's frames don't stretch into slabs.
-const MIN_SPAN = 2000;
-
-const PALETTE = {
- ...COLOR,
- flight: "rgba(245, 158, 11, 0.35)",
- recovered: COLOR.missing,
- discarded: "rgba(148, 163, 184, 0.35)",
-};
-
-const FONT = "14px 'JetBrains Mono', ui-monospace, monospace";
-const AXIS_FONT = "11px 'JetBrains Mono', ui-monospace, monospace";
-
-// Draws at the given lane count and returns the caption and the lanes its words need.
-type Draw = (
- ctx: CanvasRenderingContext2D,
- width: number,
- call: CallLog,
- now: number,
- lanes: number,
-) => { caption: string; lanes: number };
-
-const height = (lanes: number) => H + (lanes - 1) * LANE_H;
-
-export default function Timeline(props: { call: CallLog }) {
- const [heard, setHeard] = createSignal("");
- const [cached, setCached] = createSignal("");
- let stt!: HTMLCanvasElement;
- let tts!: HTMLCanvasElement;
-
- onMount(() => {
- let frame = 0;
- const charts: { canvas: HTMLCanvasElement; draw: Draw; caption: (s: string) => void; lanes: number }[] = [
- { canvas: stt, draw: drawStatement, caption: setHeard, lanes: 1 },
- { canvas: tts, draw: drawReply, caption: setCached, lanes: 1 },
- ];
- const fit = (chart: (typeof charts)[number]) => {
- const dpr = window.devicePixelRatio || 1;
- const { canvas } = chart;
- canvas.style.height = `${height(chart.lanes)}px`;
- canvas.width = Math.round(canvas.clientWidth * dpr);
- canvas.height = Math.round(height(chart.lanes) * dpr);
- canvas.getContext("2d")?.setTransform(dpr, 0, 0, dpr, 0, 0);
- };
- const render = () => {
- const call = props.call;
- const now = call.ended ?? performance.now();
- for (const chart of charts) {
- const ctx = chart.canvas.getContext("2d");
- if (!ctx) continue;
- const width = chart.canvas.clientWidth;
- ctx.clearRect(0, 0, width, height(chart.lanes));
- // The extra lanes sit above the single-lane layout.
- ctx.save();
- ctx.translate(0, (chart.lanes - 1) * LANE_H);
- const { caption, lanes } = chart.draw(ctx, width, call, now, chart.lanes);
- ctx.restore();
- chart.caption(caption);
- if (lanes !== chart.lanes) {
- // The resize observer redraws a finished call at the new height.
- chart.lanes = lanes;
- fit(chart);
- }
- }
- };
- const resize = () => {
- for (const chart of charts) fit(chart);
- // Resizing clears the canvas, so a finished call whose loop has stopped draws again.
- if (frame === 0) render();
- };
- const observer = new ResizeObserver(resize);
- for (const { canvas } of charts) observer.observe(canvas);
- resize();
-
- const tick = () => {
- const call = props.call;
- render();
- frame = call.ended === undefined || performance.now() - call.ended < 2000 ? requestAnimationFrame(tick) : 0;
- };
- frame = requestAnimationFrame(tick);
- onCleanup(() => {
- cancelAnimationFrame(frame);
- observer.disconnect();
- });
- });
-
- return (
-
-
-
-
-
-
- arrived
- arrived out of order
- in flight
- cached
- interrupted
- missed playout
-
+
+
+ );
+}
From fa4ccc06148b5f3c821c82c600caf772b69241b1 Mon Sep 17 00:00:00 2001
From: Luke Curley
Date: Mon, 26 Oct 2026 21:37:44 -0700
Subject: [PATCH 08/11] feat(voice): grow a bar for your speech before it
becomes words
The agent's VAD, turn, and interim records bound the speech the STT has
not recognized yet; a bar of its packets trails your words and gives way
as interim and final words land. Interim words get underlines too, and
the AI's text is grey so green only means delivered packets.
Co-Authored-By: Claude Opus 5.5
---
src/components/voice/call.test.ts | 62 +++++++++++++++++++++++++++++
src/components/voice/call.ts | 56 +++++++++++++++++++++++++-
src/components/voice/transcript.tsx | 60 +++++++++++++++++++++++-----
3 files changed, 165 insertions(+), 13 deletions(-)
diff --git a/src/components/voice/call.test.ts b/src/components/voice/call.test.ts
index 7039cd6..90437e1 100644
--- a/src/components/voice/call.test.ts
+++ b/src/components/voice/call.test.ts
@@ -225,6 +225,68 @@ describe("heard", () => {
});
});
+describe("pending", () => {
+ const vad = (speaking: boolean, heard: number) => ({
+ version: 2,
+ type: "vad",
+ timescale: 1000,
+ speaking,
+ media_ticks: heard,
+ confirm_ticks: 200,
+ });
+ const word = (type: string, text: string, start: number, end: number, at = 0) => ({
+ version: 2,
+ type,
+ speaker: "user",
+ timescale: 1000,
+ text,
+ start_ticks: start,
+ end_ticks: end,
+ at_ns: at * 1e6,
+ });
+
+ // You started talking at 1000; the VAD was sure by 1200.
+ function talking(): CallLog {
+ const log = new CallLog();
+ log.agent(vad(true, 1200));
+ for (let capture = 1000; capture < 1200; capture += 20) log.agent(arrival(capture, capture + 10));
+ return log;
+ }
+
+ test("runs from the start of your speech to now, delivered then in flight", () => {
+ const packets = talking().pending(1300);
+ expect(packets?.length).toBe(15);
+ expect(packets?.slice(0, 10).every((p) => p === "delivered")).toBe(true);
+ expect(packets?.slice(10).every((p) => p === "flight")).toBe(true);
+ });
+
+ test("gives way to words, interim or final, and stops where the VAD heard you stop", () => {
+ const log = talking();
+ log.agent(word("interim", "hel", 1000, 1100, 1));
+ expect(log.pending(1300)?.length).toBe(10);
+ log.agent(word("word", "hello", 1000, 1160));
+ expect(log.interims).toEqual([]);
+ expect(log.pending(1300)?.length).toBe(7);
+ log.agent(vad(false, 1400));
+ expect(log.pending(5000)?.length).toBe(2);
+ });
+
+ test("a newer interim result replaces the last one", () => {
+ const log = talking();
+ log.agent(word("interim", "hel", 1000, 1100, 1));
+ log.agent(word("interim", "hello", 1000, 1150, 2));
+ expect(log.interims.map((w) => w.text)).toEqual(["hello"]);
+ });
+
+ test("ends with your turn, and comes back when you speak again", () => {
+ const log = talking();
+ log.agent({ version: 2, type: "turn", timescale: 1000, speaking: false, media_ticks: 1500 });
+ expect(log.pending(1600)).toBeUndefined();
+ log.agent(vad(true, 2200));
+ expect(log.pending(2100)?.length).toBe(5);
+ });
+});
+
describe("align", () => {
test("matches across case and punctuation, and skips words either side left out", () => {
const log = new CallLog();
diff --git a/src/components/voice/call.ts b/src/components/voice/call.ts
index f3a9296..b08b013 100644
--- a/src/components/voice/call.ts
+++ b/src/components/voice/call.ts
@@ -103,6 +103,13 @@ export class CallLog {
// In the order the agent settled on them, which is the order they were said.
userWords: Word[] = [];
agentWords: Word[] = [];
+ // The STT's latest interim result for your words since its last final ones.
+ interims: Word[] = [];
+ #interimAt?: number;
+ // Your latest stretch of speech as the agent's VAD heard it, on your capture clock.
+ #speech?: { start: number; end?: number };
+ // The request audio heard when your last turn ended.
+ #turnEnd?: number;
publishes: Publish[] = [];
// The longest publish, which bounds how far back one can still hold a media time.
#longest = 0;
@@ -155,8 +162,39 @@ export class CallLog {
if (this.#seen.has(key)) return;
this.#seen.add(key);
const word = { text: record.text, start, end, track };
- if (record.speaker === "user") this.userWords.push(word);
- else if (record.speaker === "agent") this.agentWords.push(word);
+ if (record.speaker === "user") {
+ this.userWords.push(word);
+ // A final result settles the interims it covers.
+ this.interims = this.interims.filter((w) => w.start >= end);
+ } else if (record.speaker === "agent") this.agentWords.push(word);
+ return;
+ }
+ case "interim": {
+ const start = ms(record, "start_ticks");
+ const end = ms(record, "end_ticks");
+ const at = agentMs(record, "at_ns");
+ if (typeof record.text !== "string" || start === undefined || end === undefined || at === undefined) return;
+ // One result's words share its time, and each result restates the words since the last final.
+ if (at !== this.#interimAt) {
+ this.#interimAt = at;
+ this.interims = [];
+ }
+ this.interims.push({ text: record.text, start, end });
+ return;
+ }
+ case "vad": {
+ const heard = ms(record, "media_ticks");
+ const confirm = ms(record, "confirm_ticks");
+ if (typeof record.speaking !== "boolean" || heard === undefined || confirm === undefined) return;
+ // The VAD waited out `confirm` before deciding, so the change came that much earlier.
+ const at = heard - confirm;
+ if (record.speaking) this.#speech = { start: at };
+ else if (this.#speech) this.#speech.end = at;
+ return;
+ }
+ case "turn": {
+ const heard = ms(record, "media_ticks");
+ if (record.speaking === false && heard !== undefined) this.#turnEnd = heard;
return;
}
case "publish": {
@@ -263,6 +301,20 @@ export class CallLog {
});
}
+ /**
+ * Your speech the STT has not made words of yet, up to `now` while you are still talking: its
+ * frames, as far as they have got. Nothing once your turn has ended.
+ */
+ pending(now: number): Packet[] | undefined {
+ const speech = this.#speech;
+ if (!speech || (this.#turnEnd !== undefined && this.#turnEnd >= speech.start)) return undefined;
+ let start = Math.max(speech.start, this.userWords.at(-1)?.end ?? Number.NEGATIVE_INFINITY);
+ for (const word of this.interims) start = Math.max(start, word.end);
+ const end = speech.end ?? now;
+ if (end - start < FRAME_MS) return undefined;
+ return this.sent({ text: "", start, end });
+ }
+
/** The agent's word's frames, one per FRAME_MS, as they reached you. */
heard(word: Word): Packet[] {
return slots(word).map((t) => {
diff --git a/src/components/voice/transcript.tsx b/src/components/voice/transcript.tsx
index 58b1598..262f099 100644
--- a/src/components/voice/transcript.tsx
+++ b/src/components/voice/transcript.tsx
@@ -1,8 +1,9 @@
// Both sides' words, each underlined by its own audio: one segment per 20 ms packet, colored by how
-// far it got. Your words fill in as the agent receives them; its words land in your buffer well
-// ahead of the playhead and turn solid as you hear them.
+// far it got. Your words fill in as the agent receives them, behind a bar of the speech it has not
+// recognized yet; its words land in your buffer well ahead of the playhead and turn solid as you
+// hear them.
import { createEffect, createMemo, createSignal, For, Index, type JSX, onCleanup, Show } from "solid-js";
-import { align, type CallLog, type Packet } from "./call";
+import { align, type CallLog, FRAME_MS, type Packet } from "./call";
// A run of speech by one side. `interim` is your words the recognizer may still revise.
export type Turn = { who: "you" | "ai"; text: string; interim: string };
@@ -27,6 +28,9 @@ const LEGEND: [Packet, string][] = [
const REDRAW_MS = 100;
+// About the width the words take to say, so the bar gives way to them without a jump.
+const PX_PER_MS = 0.12;
+
function words(text: string): string[] {
return text.split(/\s+/).filter(Boolean);
}
@@ -48,7 +52,7 @@ function underline(packets: Packet[]): JSX.CSSProperties {
"background-repeat": "no-repeat",
"padding-bottom": "3px",
// A word a barge-in cut off entirely was never said. Only the agent's words are cut off.
- color: packets.every((p) => p === "interrupted") ? "rgba(74, 222, 128, 0.4)" : undefined,
+ color: packets.every((p) => p === "interrupted") ? "rgba(203, 213, 225, 0.4)" : undefined,
};
}
@@ -74,25 +78,44 @@ export default function Transcript(props: { turns: Turn[]; log?: CallLog; live:
const spoken = (who: Turn["who"]) => turns.filter((t) => t.who === who).flatMap((t) => words(t.text));
const matched = { you: align(spoken("you"), log.userWords), ai: align(spoken("ai"), log.agentWords) };
const next = { you: 0, ai: 0 };
- return turns.map((turn) =>
- words(turn.text).map(() => {
+ return turns.map((turn) => ({
+ text: words(turn.text).map(() => {
const word = matched[turn.who][next[turn.who]++];
if (!word) return undefined;
return underline(turn.who === "you" ? log.sent(word) : log.heard(word));
}),
- );
+ interim: align(words(turn.interim), log.interims).map((word) => word && underline(log.sent(word))),
+ }));
});
+ // Your speech the agent has heard but not made words of, while the call is up.
+ const pending = createMemo(() => {
+ tick();
+ return props.live ? props.log?.pending(performance.now()) : undefined;
+ });
+ const bar = (packets: Packet[]) => (
+
+ {/* A line of height, so it sits under the words even alone. */}
+ {"\u200b"}
+
+ );
+ // The bar trails your current turn, or starts one before its first word.
+ const opening = () => props.turns.at(-1)?.who !== "you";
+
// The transcript follows new words unless the reader has scrolled up to reread.
let list: HTMLOListElement | undefined;
let following = true;
createEffect(() => {
void props.turns;
+ void pending();
if (list && following) list.scrollTop = list.scrollHeight;
});
return (
- 0}>
+ 0 || pending()}>
{
list = el;
@@ -115,18 +138,33 @@ export default function Transcript(props: { turns: Turn[]; log?: CallLog; live:
{(word, i) => (
{word()}
)}
- {(word) => {word()}}
+
+ {(word, i) => (
+
+ {word()}
+
+ )}
+
+ {(p) => bar(p())}
)}
+
+ {(p) => (
+
+ you
+
{bar(p())}
+
+ )}
+
Date: Mon, 26 Oct 2026 22:26:18 -0700
Subject: [PATCH 09/11] feat(voice): grow the speech bar until the reply plays,
not by VAD
The VAD froze the bar at every pause. It now runs from your last
recognized word, or from when the reply last stopped playing, up to now,
and hides while the reply plays. The player's stall is the reply signal,
so the vad and turn records are no longer read.
Co-Authored-By: Claude Opus 5.5
---
src/components/voice-demo.tsx | 4 +++
src/components/voice/call.test.ts | 28 +++++++-------------
src/components/voice/call.ts | 44 ++++++++++++-------------------
3 files changed, 30 insertions(+), 46 deletions(-)
diff --git a/src/components/voice-demo.tsx b/src/components/voice-demo.tsx
index a8c0ee8..ab45352 100644
--- a/src/components/voice-demo.tsx
+++ b/src/components/voice-demo.tsx
@@ -439,6 +439,10 @@ export default function VoiceDemo() {
effects.run((effect) => {
call.log.buffer(effect.get(el.audio.out.buffered));
});
+ // The ring stalls before the first reply, when one runs dry, and on a barge-in's reset.
+ effects.run((effect) => {
+ call.log.replying(!effect.get(el.audio.out.stalled), performance.now());
+ });
effects.spawn(async () => {
const arrivals = el.audio.arrivals();
effects.cleanup(() => arrivals.close());
diff --git a/src/components/voice/call.test.ts b/src/components/voice/call.test.ts
index 90437e1..ca4202d 100644
--- a/src/components/voice/call.test.ts
+++ b/src/components/voice/call.test.ts
@@ -226,14 +226,6 @@ describe("heard", () => {
});
describe("pending", () => {
- const vad = (speaking: boolean, heard: number) => ({
- version: 2,
- type: "vad",
- timescale: 1000,
- speaking,
- media_ticks: heard,
- confirm_ticks: 200,
- });
const word = (type: string, text: string, start: number, end: number, at = 0) => ({
version: 2,
type,
@@ -245,30 +237,27 @@ describe("pending", () => {
at_ns: at * 1e6,
});
- // You started talking at 1000; the VAD was sure by 1200.
+ // Your mic from 1000, the agent hearing each frame 10 ms after it was captured, up to 1200.
function talking(): CallLog {
const log = new CallLog();
- log.agent(vad(true, 1200));
for (let capture = 1000; capture < 1200; capture += 20) log.agent(arrival(capture, capture + 10));
return log;
}
- test("runs from the start of your speech to now, delivered then in flight", () => {
+ test("runs from your first packet to now, delivered then in flight", () => {
const packets = talking().pending(1300);
expect(packets?.length).toBe(15);
expect(packets?.slice(0, 10).every((p) => p === "delivered")).toBe(true);
expect(packets?.slice(10).every((p) => p === "flight")).toBe(true);
});
- test("gives way to words, interim or final, and stops where the VAD heard you stop", () => {
+ test("gives way to words, interim or final", () => {
const log = talking();
log.agent(word("interim", "hel", 1000, 1100, 1));
expect(log.pending(1300)?.length).toBe(10);
log.agent(word("word", "hello", 1000, 1160));
expect(log.interims).toEqual([]);
expect(log.pending(1300)?.length).toBe(7);
- log.agent(vad(false, 1400));
- expect(log.pending(5000)?.length).toBe(2);
});
test("a newer interim result replaces the last one", () => {
@@ -278,12 +267,13 @@ describe("pending", () => {
expect(log.interims.map((w) => w.text)).toEqual(["hello"]);
});
- test("ends with your turn, and comes back when you speak again", () => {
+ test("hides while the reply plays, and starts over when it stops", () => {
const log = talking();
- log.agent({ version: 2, type: "turn", timescale: 1000, speaking: false, media_ticks: 1500 });
- expect(log.pending(1600)).toBeUndefined();
- log.agent(vad(true, 2200));
- expect(log.pending(2100)?.length).toBe(5);
+ log.agent(word("word", "hello", 1000, 1160));
+ log.replying(true, 1500);
+ expect(log.pending(2000)).toBeUndefined();
+ log.replying(false, 3000);
+ expect(log.pending(3200)?.length).toBe(10);
});
});
diff --git a/src/components/voice/call.ts b/src/components/voice/call.ts
index b08b013..feffb38 100644
--- a/src/components/voice/call.ts
+++ b/src/components/voice/call.ts
@@ -106,10 +106,9 @@ export class CallLog {
// The STT's latest interim result for your words since its last final ones.
interims: Word[] = [];
#interimAt?: number;
- // Your latest stretch of speech as the agent's VAD heard it, on your capture clock.
- #speech?: { start: number; end?: number };
- // The request audio heard when your last turn ended.
- #turnEnd?: number;
+ // Whether the reply is playing, and when it last stopped.
+ #replying = false;
+ #replyStopped?: number;
publishes: Publish[] = [];
// The longest publish, which bounds how far back one can still hold a media time.
#longest = 0;
@@ -182,21 +181,6 @@ export class CallLog {
this.interims.push({ text: record.text, start, end });
return;
}
- case "vad": {
- const heard = ms(record, "media_ticks");
- const confirm = ms(record, "confirm_ticks");
- if (typeof record.speaking !== "boolean" || heard === undefined || confirm === undefined) return;
- // The VAD waited out `confirm` before deciding, so the change came that much earlier.
- const at = heard - confirm;
- if (record.speaking) this.#speech = { start: at };
- else if (this.#speech) this.#speech.end = at;
- return;
- }
- case "turn": {
- const heard = ms(record, "media_ticks");
- if (record.speaking === false && heard !== undefined) this.#turnEnd = heard;
- return;
- }
case "publish": {
const start = ms(record, "start_ticks");
const end = ms(record, "end_ticks");
@@ -301,18 +285,24 @@ export class CallLog {
});
}
+ /** The reply started or stopped playing at local time `at`. */
+ replying(playing: boolean, at: number) {
+ if (this.#replying && !playing) this.#replyStopped = at;
+ this.#replying = playing;
+ }
+
/**
- * Your speech the STT has not made words of yet, up to `now` while you are still talking: its
- * frames, as far as they have got. Nothing once your turn has ended.
+ * Your audio the STT has not made words of, up to `now`: its frames, as far as they have got.
+ * Nothing while the reply plays, and it starts over once it stops.
*/
pending(now: number): Packet[] | undefined {
- const speech = this.#speech;
- if (!speech || (this.#turnEnd !== undefined && this.#turnEnd >= speech.start)) return undefined;
- let start = Math.max(speech.start, this.userWords.at(-1)?.end ?? Number.NEGATIVE_INFINITY);
+ const first = this.mic[0];
+ if (this.#replying || !first) return undefined;
+ const none = Number.NEGATIVE_INFINITY;
+ let start = Math.max(first.capture, this.#replyStopped ?? none, this.userWords.at(-1)?.end ?? none);
for (const word of this.interims) start = Math.max(start, word.end);
- const end = speech.end ?? now;
- if (end - start < FRAME_MS) return undefined;
- return this.sent({ text: "", start, end });
+ if (now - start < FRAME_MS) return undefined;
+ return this.sent({ text: "", start, end: now });
}
/** The agent's word's frames, one per FRAME_MS, as they reached you. */
From ab2305354a9cc7f06fc5ba986eea0ea56e59f206 Mon Sep 17 00:00:00 2001
From: Luke Curley
Date: Mon, 26 Oct 2026 23:02:15 -0700
Subject: [PATCH 10/11] fix(voice): realign past long omissions, exact frame
bounds, per-track gaps
- align() could not get past four or more timed words the transcript
left out; further ahead it now accepts a match when the next word
matches too.
- A frame ending exactly at a slot no longer holds that slot.
- A gap belongs to the reply track that held its media, so a barge-in's
replacement track does not inherit the retired track's losses.
Co-Authored-By: Claude Opus 5.5
---
src/components/voice-demo.tsx | 2 +-
src/components/voice/call.test.ts | 37 ++++++++++++++++++++
src/components/voice/call.ts | 57 ++++++++++++++++++++-----------
3 files changed, 76 insertions(+), 20 deletions(-)
diff --git a/src/components/voice-demo.tsx b/src/components/voice-demo.tsx
index ab45352..990cbbb 100644
--- a/src/components/voice-demo.tsx
+++ b/src/components/voice-demo.tsx
@@ -450,7 +450,7 @@ export default function VoiceDemo() {
const next = await arrivals.next();
if (!next) return;
if (next.kind === "frame") call.log.received(next.timestamp, next.arrival, next.late);
- else call.log.gap(next.timestamp, next.duration);
+ else call.log.gap(next.timestamp, next.duration, performance.now());
}
});
effects.run((effect) => {
diff --git a/src/components/voice/call.test.ts b/src/components/voice/call.test.ts
index ca4202d..6ff80f0 100644
--- a/src/components/voice/call.test.ts
+++ b/src/components/voice/call.test.ts
@@ -183,6 +183,15 @@ describe("sent", () => {
expect(log.sent({ text: "hi", start: 0, end: 60 })).toEqual(["delivered", "flight", "delivered"]);
});
+
+ test("a frame that ends where a slot falls does not hold it", () => {
+ const log = new CallLog();
+ log.agent(arrival(0, 1010));
+ log.agent(arrival(40, 1050));
+
+ // The slots fall at 20 and 40: the missing frame and the one at 40.
+ expect(log.sent({ text: "hi", start: 10, end: 50 })).toEqual(["flight", "delivered"]);
+ });
});
describe("heard", () => {
@@ -223,6 +232,24 @@ describe("heard", () => {
expect(log.heard({ text: "hi", start: 2000, end: 2020, track: "bot-audio-1" })).toEqual(["flight"]);
});
+
+ test("a retired track's gap is not the replacement's loss", () => {
+ const log = synced();
+ log.gap(200, 100, 150);
+ log.agent({
+ version: 2,
+ type: "retire",
+ track: "bot-audio",
+ timescale: 1000,
+ unplayed_ticks: 4800,
+ at_ns: 1200 * 1e6,
+ });
+ log.agent(publish("bot-audio-1", 200, 1000, 1300));
+ log.buffer([{ start: 200, end: 1000 }]);
+
+ expect(log.heard({ text: "hi", start: 200, end: 240, track: "bot-audio-1" })).toEqual(["buffered", "buffered"]);
+ expect(log.heard({ text: "hi", start: 200, end: 240, track: "bot-audio" })).toEqual(["interrupted", "interrupted"]);
+ });
});
describe("pending", () => {
@@ -289,6 +316,16 @@ describe("align", () => {
expect(matched.map((w) => w?.text)).toEqual(["hello", undefined, "how", "are", "you"]);
});
+ test("meets up again past a longer stretch only on two words in a row", () => {
+ const log = new CallLog();
+ for (const [i, text] of ["one", "two", "three", "four", "the", "dog", "hello", "world"].entries()) {
+ log.agent(word("user", text, i * 100, i * 100 + 80));
+ }
+ const matched = align(["the", "cat", "hello", "world"], log.userWords);
+
+ expect(matched.map((w) => w?.text)).toEqual([undefined, undefined, "hello", "world"]);
+ });
+
test("a restated window does not repeat a word", () => {
const log = new CallLog();
log.agent(word("user", "hello", 0, 80));
diff --git a/src/components/voice/call.ts b/src/components/voice/call.ts
index feffb38..e484e60 100644
--- a/src/components/voice/call.ts
+++ b/src/components/voice/call.ts
@@ -76,13 +76,13 @@ function insert(list: T[], item: T, key: (t: T) => number): number {
return i;
}
-// The first index whose key is at least `value`, in a list sorted by `key`.
-function lowerBound(list: T[], value: number, key: (t: T) => number): number {
+// The first index whose key is above `value`, in a list sorted by `key`.
+function above(list: T[], value: number, key: (t: T) => number): number {
let lo = 0;
let hi = list.length;
while (lo < hi) {
const mid = (lo + hi) >> 1;
- if (key(list[mid]) < value) lo = mid + 1;
+ if (key(list[mid]) <= value) lo = mid + 1;
else hi = mid;
}
return lo;
@@ -97,7 +97,7 @@ function slots(word: Word): number[] {
export class CallLog {
mic: MicFrame[] = [];
reply: ReplyFrame[] = [];
- gaps: { media: number; duration: number }[] = [];
+ gaps: { media: number; duration: number; track?: string }[] = [];
// Reply media the player holds but has not played: received, decoded or not.
buffered: { start: number; end: number }[] = [];
// In the order the agent settled on them, which is the order they were said.
@@ -239,8 +239,16 @@ export class CallLog {
/** A reply frame as `@moq/watch` received it. */
received(media: number, arrive: number, late: boolean) {
const frame: ReplyFrame = { media, arrive, late };
- // Of the publishes holding this media time, the newest one sent before it arrived. Without a
- // clock offset yet there has been no barge-in, so there is only one.
+ const publish = this.#holder(media, arrive);
+ if (publish) this.#published(frame, publish);
+ insert(this.reply, frame, (f) => f.media);
+ }
+
+ /**
+ * Of the publishes holding this media time, the newest one sent before local time `arrive`.
+ * Without a clock offset yet there has been no barge-in, so there is only one.
+ */
+ #holder(media: number, arrive: number): Publish | undefined {
let best: Publish | undefined;
for (let i = this.publishes.length - 1; i >= 0; i--) {
const p = this.publishes[i];
@@ -250,13 +258,13 @@ export class CallLog {
if (!this.#sentBefore(p.at, arrive)) continue;
if (!best || p.at > best.at) best = p;
}
- if (best) this.#published(frame, best);
- insert(this.reply, frame, (f) => f.media);
+ return best;
}
- /** Reply media that will never arrive. */
- gap(media: number, duration: number) {
- this.gaps.push({ media, duration });
+ /** Reply media that will never arrive, as found at local time `at`. */
+ gap(media: number, duration: number, at: number) {
+ // A barge-in's track can reuse the media times of the one it replaced, which lost nothing.
+ this.gaps.push({ media, duration, track: this.#holder(media, at)?.track });
}
/** The reply media the player holds, which runs ahead of the arrivals its decoder has reached. */
@@ -278,7 +286,7 @@ export class CallLog {
sent(word: Word): Packet[] {
return slots(word).map((t) => {
// The frame whose 20 ms holds this slot's middle.
- const i = lowerBound(this.mic, t - FRAME_MS, (f) => f.capture);
+ const i = above(this.mic, t - FRAME_MS, (f) => f.capture);
const frame = this.mic[i];
if (!frame || frame.capture > t) return "flight";
return frame.reordered ? "reordered" : "delivered";
@@ -308,7 +316,7 @@ export class CallLog {
/** The agent's word's frames, one per FRAME_MS, as they reached you. */
heard(word: Word): Packet[] {
return slots(word).map((t) => {
- for (let i = lowerBound(this.reply, t - FRAME_MS, (f) => f.media); i < this.reply.length; i++) {
+ for (let i = above(this.reply, t - FRAME_MS, (f) => f.media); i < this.reply.length; i++) {
const frame = this.reply[i];
if (frame.media > t) break;
// A frame can beat the record of the track that carries it.
@@ -318,7 +326,13 @@ export class CallLog {
return frame.played ? "delivered" : "buffered";
}
if (word.track !== undefined && this.#retired.has(word.track)) return "interrupted";
- if (this.gaps.some((g) => t >= g.media && t < g.media + g.duration)) return "lost";
+ const lost = this.gaps.some(
+ (g) =>
+ t >= g.media &&
+ t < g.media + g.duration &&
+ (g.track === undefined || word.track === undefined || g.track === word.track),
+ );
+ if (lost) return "lost";
// The decoder reports a frame only once playback nears it; the player holds it before that.
if (this.buffered.some((r) => t >= r.start && t < r.end)) return "buffered";
return "flight";
@@ -334,7 +348,8 @@ function norm(text: string): string {
.replace(/[^\p{L}\p{N}]/gu, "");
}
-// How far either side may run ahead: the transcript and `voice.json` each leave words out.
+// How far either side may run ahead on one matching word: the transcript and `voice.json` each
+// leave words out.
const SKIP = 4;
/**
@@ -342,12 +357,16 @@ const SKIP = 4;
* said; `voice.json` leaves out any word it could not time, so a word without a match is undefined.
*/
export function align(texts: string[], words: Word[]): (Word | undefined)[] {
+ const keys = texts.map(norm);
+ const timed = words.map((w) => norm(w.text));
let next = 0;
- return texts.map((text) => {
- const key = norm(text);
+ return keys.map((key, k) => {
if (!key) return undefined;
- for (let i = next; i < Math.min(words.length, next + SKIP); i++) {
- if (norm(words[i].text) !== key) continue;
+ for (let i = next; i < timed.length; i++) {
+ if (timed[i] !== key) continue;
+ // Further ahead, a common word alone could skip a stretch; the next word matching too means
+ // the two sides have met again.
+ if (i >= next + SKIP && (k + 1 >= keys.length || timed[i + 1] !== keys[k + 1])) continue;
next = i + 1;
return words[i];
}
From 05ea4136ec9816b1ce0756b486f52e90163f1b42 Mon Sep 17 00:00:00 2001
From: Luke Curley
Date: Mon, 26 Oct 2026 23:09:50 -0700
Subject: [PATCH 11/11] fix(voice): keep the trailing speech bar growing
between transcript updates
A non-keyed Show ran bar() once with a snapshot of the packets, so the
bar behind your words froze until the transcript changed. The bar now
reads the accessor inside its style.
Co-Authored-By: Claude Opus 5.5
---
src/components/voice/transcript.tsx | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/src/components/voice/transcript.tsx b/src/components/voice/transcript.tsx
index 262f099..555c041 100644
--- a/src/components/voice/transcript.tsx
+++ b/src/components/voice/transcript.tsx
@@ -93,10 +93,10 @@ export default function Transcript(props: { turns: Turn[]; log?: CallLog; live:
tick();
return props.live ? props.log?.pending(performance.now()) : undefined;
});
- const bar = (packets: Packet[]) => (
+ const bar = (packets: () => Packet[]) => (
{/* A line of height, so it sits under the words even alone. */}
{"\u200b"}
@@ -152,7 +152,7 @@ export default function Transcript(props: { turns: Turn[]; log?: CallLog; live:
)}
- {(p) => bar(p())}
+ {(p) => bar(p)}
)}
@@ -161,7 +161,7 @@ export default function Transcript(props: { turns: Turn[]; log?: CallLog; live:
{(p) => (