Skip to content

Commit 239aa5c

Browse files
committed
feat: show AssemblyAI speaker diarization in transcripts
1 parent 321ae61 commit 239aa5c

23 files changed

Lines changed: 564 additions & 687 deletions
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import { Effect, Option } from "effect";
2+
import { beforeEach, describe, expect, it, vi } from "vitest";
3+
import { createEmptyLiveTranscript } from "@/lib/live-transcribe-core";
4+
5+
const mocks = vi.hoisted(() => ({
6+
queue: vi.fn(),
7+
objects: new Map<string, string>(),
8+
writes: [] as string[],
9+
}));
10+
const video = {
11+
id: "live-video",
12+
ownerId: "live-owner",
13+
source: { type: "desktopSegments" },
14+
transcriptionStatus: null,
15+
settings: null,
16+
};
17+
vi.mock("@cap/env", () => ({
18+
serverEnv: () => ({ ASSEMBLY_API_KEY: "test-key" }),
19+
}));
20+
vi.mock("@cap/database/schema", () => ({
21+
videos: {
22+
id: "video-id",
23+
ownerId: "owner-id",
24+
metadata: "metadata",
25+
updatedAt: "updated-at",
26+
},
27+
organizations: { id: "org-id", settings: "settings" },
28+
users: { id: "user-id" },
29+
}));
30+
vi.mock("@cap/database", () => ({
31+
db: () => ({
32+
select: () => ({
33+
from: () => ({
34+
leftJoin: () => ({ where: async () => [{ video, orgSettings: null }] }),
35+
where: async () => [video],
36+
}),
37+
}),
38+
update: () => ({ set: () => ({ where: async () => [] }) }),
39+
}),
40+
}));
41+
vi.mock("@cap/web-backend/src/Storage/index", () => ({
42+
Storage: {
43+
getAccessForVideo: () =>
44+
Effect.succeed([
45+
{
46+
getObject: (key: string) =>
47+
Effect.succeed(Option.fromNullable(mocks.objects.get(key))),
48+
putObject: (key: string, value: string) =>
49+
Effect.sync(() => {
50+
mocks.writes.push(key);
51+
mocks.objects.set(key, value);
52+
}),
53+
},
54+
]),
55+
},
56+
}));
57+
vi.mock("@/lib/video-storage", () => ({ decodeStorageVideo: () => ({}) }));
58+
vi.mock("@/lib/workflow-runtime", () => ({
59+
runWorkflowPromise: Effect.runPromise,
60+
}));
61+
vi.mock("@/lib/transcribe", () => ({ transcribeVideo: mocks.queue }));
62+
vi.mock("@/lib/ai-generation-entitlement", () => ({
63+
isAiGenerationEnabledForUser: () => false,
64+
}));
65+
66+
const artifactKey = "live-owner/live-video/transcription.live.json";
67+
beforeEach(() => {
68+
mocks.objects.clear();
69+
mocks.writes.length = 0;
70+
mocks.queue.mockResolvedValue({ success: true, message: "Queued" });
71+
mocks.objects.set(
72+
artifactKey,
73+
JSON.stringify({
74+
...createEmptyLiveTranscript("2026-09-08T00:00:00.000Z"),
75+
lastAudioSegmentIndex: 2,
76+
transcribedDurationMs: 4000,
77+
}),
78+
);
79+
mocks.objects.set(
80+
"live-owner/live-video/segments/manifest.json",
81+
JSON.stringify({
82+
version: 5,
83+
video_init_uploaded: true,
84+
audio_init_uploaded: true,
85+
video_segments: [],
86+
audio_segments: [
87+
{ index: 1, duration: 2 },
88+
{ index: 2, duration: 2 },
89+
],
90+
is_complete: true,
91+
}),
92+
);
93+
});
94+
95+
describe("live recording diarization handoff", () => {
96+
it("queues a full recording pass even when provisional chunks cover every segment", async () => {
97+
const { liveTranscribeWorkflow } = await import(
98+
"@/workflows/live-transcribe"
99+
);
100+
await liveTranscribeWorkflow({ videoId: video.id, userId: video.ownerId });
101+
expect(mocks.queue).toHaveBeenCalledExactlyOnceWith(
102+
video.id,
103+
video.ownerId,
104+
false,
105+
{ earlyFromSegments: true },
106+
);
107+
expect(mocks.writes).toEqual([artifactKey]);
108+
expect(JSON.parse(mocks.objects.get(artifactKey) ?? "{}").state).toBe(
109+
"complete",
110+
);
111+
});
112+
it("surfaces queue failures so the durable workflow retries the final transcription", async () => {
113+
mocks.queue.mockResolvedValue({
114+
success: false,
115+
message: "Queue unavailable",
116+
});
117+
const { liveTranscribeWorkflow } = await import(
118+
"@/workflows/live-transcribe"
119+
);
120+
await expect(
121+
liveTranscribeWorkflow({ videoId: video.id, userId: video.ownerId }),
122+
).rejects.toThrow("Queue unavailable");
123+
});
124+
});

apps/web/__tests__/integration/transcribe-workflow.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,9 @@ describe("transcribeVideoWorkflow", () => {
206206

207207
expect(result.success).toBe(true);
208208
expect(mocks.transcribe).toHaveBeenCalledTimes(1);
209+
expect(mocks.transcribe).toHaveBeenCalledWith(
210+
expect.objectContaining({ speaker_labels: true }),
211+
);
209212
expect(mocks.transcribe.mock.calls[0]?.[0]).toMatchObject({
210213
disfluencies: true,
211214
speech_models: ["universal-3-5-pro", "universal-2"],
@@ -265,6 +268,9 @@ describe("transcribeVideoWorkflow", () => {
265268
message: "Video has no spoken audio - skipped transcription",
266269
});
267270
expect(mocks.transcribe).toHaveBeenCalledTimes(1);
271+
expect(mocks.transcribe).toHaveBeenCalledWith(
272+
expect.objectContaining({ speaker_labels: true }),
273+
);
268274
expect(mocks.updates).toContainEqual({ transcriptionStatus: "NO_AUDIO" });
269275
expect(mocks.updates).not.toContainEqual({ transcriptionStatus: "ERROR" });
270276
expect(mocks.startAiGeneration).not.toHaveBeenCalled();

apps/web/__tests__/unit/caption-cues.test.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,11 @@ describe("getActiveCaptionText", () => {
2020
it("uses the latest active cue when cues overlap", () => {
2121
const activeCues = createCueList([
2222
{ startTime: 0, text: "First caption" },
23-
{ startTime: 3.199, text: "<v Speaker>Second caption</v>" },
23+
{ startTime: 3.199, text: "<v Speaker B>Second &amp; final caption</v>" },
2424
]);
2525

26-
expect(getActiveCaptionText(activeCues)).toBe("Second caption");
26+
expect(getActiveCaptionText(activeCues)).toBe(
27+
"Speaker B: Second & final caption",
28+
);
2729
});
2830
});
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import { describe, expect, it } from "vitest";
2+
import { formatTranscriptAsVTT } from "@/app/s/[videoId]/_components/utils/transcript-utils";
3+
import {
4+
createEditTranscript,
5+
editTranscriptWordsToCaptionVtt,
6+
groupEditTranscriptWords,
7+
parseEditTranscript,
8+
remapEditTranscriptThroughSpec,
9+
serializeEditTranscript,
10+
} from "@/lib/edit-transcript";
11+
import { formatTranscriptAsParagraphs } from "@/lib/transcript-text";
12+
import {
13+
formatVttCueText,
14+
parseVTT,
15+
parseVttCueText,
16+
updateVttEntryText,
17+
} from "@/lib/transcript-vtt";
18+
19+
const transcript = createEditTranscript(
20+
{
21+
words: [
22+
{ text: "Hello", start: 100, end: 300, speaker: "A" },
23+
{ text: "there", start: 300, end: 600, speaker: "A" },
24+
{ text: "Hi", start: 650, end: 800, speaker: "B" },
25+
{ text: "again", start: 850, end: 1000, speaker: "A" },
26+
{ text: "unknown", start: 1100, end: 1300, speaker: null },
27+
],
28+
},
29+
2000,
30+
);
31+
32+
describe("speaker diarization", () => {
33+
it("splits captions on every speaker transition without needing punctuation or silence", () => {
34+
const cues = parseVTT(editTranscriptWordsToCaptionVtt(transcript.words));
35+
expect(
36+
cues.map(({ text, speaker, startTime, endTime }) => ({
37+
text,
38+
speaker,
39+
startTime,
40+
endTime,
41+
})),
42+
).toEqual([
43+
{ text: "Hello there", speaker: "A", startTime: 0.1, endTime: 0.6 },
44+
{ text: "Hi", speaker: "B", startTime: 0.65, endTime: 0.8 },
45+
{ text: "again", speaker: "A", startTime: 0.85, endTime: 1 },
46+
{ text: "unknown", speaker: null, startTime: 1.1, endTime: 1.3 },
47+
]);
48+
});
49+
50+
it("preserves labels through storage, video cuts, caption regeneration, and download", () => {
51+
const stored = parseEditTranscript(serializeEditTranscript(transcript));
52+
expect(stored).not.toBeNull();
53+
if (!stored) throw new Error("Missing transcript");
54+
const edited = remapEditTranscriptThroughSpec(stored, {
55+
version: 1,
56+
sourceDuration: 2,
57+
keepRanges: [{ start: 0.6, end: 2 }],
58+
});
59+
const cues = parseVTT(editTranscriptWordsToCaptionVtt(edited.words));
60+
expect(cues[0]).toMatchObject({
61+
text: "Hi",
62+
speaker: "B",
63+
startTime: 0.05,
64+
});
65+
expect(parseVTT(formatTranscriptAsVTT(cues))).toEqual(cues);
66+
});
67+
68+
it("shows separate editor groups and text paragraphs for speakers and unknown speech", () => {
69+
expect(
70+
groupEditTranscriptWords(transcript.words).map(
71+
({ startIndex, endIndex }) => [startIndex, endIndex],
72+
),
73+
).toEqual([
74+
[0, 1],
75+
[2, 2],
76+
[3, 3],
77+
[4, 4],
78+
]);
79+
expect(
80+
formatTranscriptAsParagraphs(
81+
parseVTT(editTranscriptWordsToCaptionVtt(transcript.words)),
82+
),
83+
).toBe(
84+
"Speaker A: Hello there\n\nSpeaker B: Hi\n\nSpeaker A: again\n\nunknown",
85+
);
86+
});
87+
88+
it("preserves the voice when editing spoken text and escapes markup", () => {
89+
const vtt = editTranscriptWordsToCaptionVtt(transcript.words);
90+
const updated = updateVttEntryText(vtt, 2, "Yes <script> & no");
91+
expect(updated.updated).toBe(true);
92+
expect(updated.content).toContain(
93+
"<v Speaker B>Yes &lt;script&gt; &amp; no</v>",
94+
);
95+
expect(parseVTT(updated.content)[1]).toMatchObject({
96+
speaker: "B",
97+
text: "Yes <script> & no",
98+
startTime: 0.65,
99+
endTime: 0.8,
100+
});
101+
});
102+
103+
it("handles multiline voice cues, legacy plain cues, and escaped labels", () => {
104+
const cues = parseVTT(
105+
"WEBVTT\r\n\r\n1\r\n00:00:00.125 --> 00:00:01.500\r\n<v Speaker A>Hello\r\nthere</v>\r\n\r\n2\r\n00:00:01.500 --> 00:00:02.000\r\nLegacy text\r\n",
106+
);
107+
expect(cues[0]).toMatchObject({
108+
text: "Hello there",
109+
speaker: "A",
110+
startTime: 0.125,
111+
endTime: 1.5,
112+
});
113+
expect(cues[1]).toMatchObject({ text: "Legacy text", speaker: null });
114+
expect(parseVttCueText(formatVttCueText("2 < 3 & 4 > 1", "A & B"))).toEqual(
115+
{ text: "2 < 3 & 4 > 1", speaker: "A & B" },
116+
);
117+
});
118+
});
119+
120+
it("keeps speaker metadata and literal text through the agent transcript API", async () => {
121+
const { parseAgentVtt, renderAgentVtt } = await import("@/lib/agent-api");
122+
const cues = [
123+
{ startMs: 125, endMs: 500, text: "R&D < planning", speaker: "B" },
124+
];
125+
expect(parseAgentVtt(renderAgentVtt(cues))).toEqual(cues);
126+
});

apps/web/__tests__/unit/live-transcribe-core.test.ts

Lines changed: 0 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,8 @@
11
import { describe, expect, it } from "vitest";
22
import {
33
applyChunkToLiveTranscript,
4-
canPromoteLiveTranscript,
54
createEmptyLiveTranscript,
65
isNoSpokenAudioError,
7-
liveTranscriptToEditTranscript,
86
offsetChunkWords,
97
parseLiveTranscript,
108
planNextLiveChunk,
@@ -248,95 +246,6 @@ describe("live transcript artifact", () => {
248246
});
249247
});
250248

251-
describe("canPromoteLiveTranscript", () => {
252-
const fullCoverage = (overrides = {}) => ({
253-
...createEmptyLiveTranscript("2026-08-03T00:00:00.000Z"),
254-
lastAudioSegmentIndex: 3,
255-
transcribedDurationMs: 6000,
256-
...overrides,
257-
});
258-
const completeManifest = {
259-
...baseManifest,
260-
audio_segments: [seg(1), seg(2), seg(3)],
261-
is_complete: true,
262-
};
263-
264-
it("promotes only full gap-free coverage", () => {
265-
expect(canPromoteLiveTranscript(fullCoverage(), completeManifest)).toEqual({
266-
ok: true,
267-
});
268-
});
269-
270-
it("declines incomplete manifests, partial coverage, and skipped chunks", () => {
271-
expect(
272-
canPromoteLiveTranscript(fullCoverage(), {
273-
...completeManifest,
274-
is_complete: false,
275-
}).ok,
276-
).toBe(false);
277-
expect(
278-
canPromoteLiveTranscript(
279-
fullCoverage({ lastAudioSegmentIndex: 2 }),
280-
completeManifest,
281-
).ok,
282-
).toBe(false);
283-
expect(
284-
canPromoteLiveTranscript(
285-
fullCoverage({ hasGaps: true }),
286-
completeManifest,
287-
).ok,
288-
).toBe(false);
289-
});
290-
291-
it("declines manifests with segment index gaps", () => {
292-
expect(
293-
canPromoteLiveTranscript(fullCoverage({ lastAudioSegmentIndex: 4 }), {
294-
...completeManifest,
295-
audio_segments: [seg(1), seg(2), seg(4)],
296-
}).ok,
297-
).toBe(false);
298-
});
299-
300-
it("declines recordings with no audio", () => {
301-
expect(
302-
canPromoteLiveTranscript(fullCoverage(), {
303-
...completeManifest,
304-
audio_segments: [],
305-
}).ok,
306-
).toBe(false);
307-
});
308-
});
309-
310-
describe("liveTranscriptToEditTranscript", () => {
311-
it("shapes accumulated words as a canonical v3 edit transcript", () => {
312-
const artifact = applyChunkToLiveTranscript(
313-
createEmptyLiveTranscript("2026-08-03T00:00:00.000Z"),
314-
{
315-
startMs: 0,
316-
durationMs: 4000,
317-
lastAudioSegmentIndex: 2,
318-
words: offsetChunkWords(
319-
[{ text: "Hello", start: 10, end: 500 }],
320-
0,
321-
4000,
322-
),
323-
languageCode: "en",
324-
nowIso: "2026-08-03T00:00:05.000Z",
325-
},
326-
);
327-
328-
const edit = liveTranscriptToEditTranscript(artifact, "universal-3-5-pro");
329-
expect(edit).toMatchObject({
330-
version: 3,
331-
speechModelUsed: "universal-3-5-pro",
332-
durationMs: 4000,
333-
languageCode: "en",
334-
});
335-
expect(edit.words).toHaveLength(1);
336-
expect(edit.words[0]).toMatchObject({ text: "Hello", startMs: 10 });
337-
});
338-
});
339-
340249
describe("isNoSpokenAudioError", () => {
341250
it("recognizes speech-free chunks as valid empties, not failures", () => {
342251
// exact message observed from the real API on a silent recording

0 commit comments

Comments
 (0)