From 239aa5cda0e33678151fff8445083f3eb2a360bb Mon Sep 17 00:00:00 2001 From: pavzagor Date: Tue, 8 Sep 2026 14:50:11 +0300 Subject: [PATCH 1/2] feat: show AssemblyAI speaker diarization in transcripts --- .../live-transcribe-diarization.test.ts | 124 +++++++ .../integration/transcribe-workflow.test.ts | 6 + apps/web/__tests__/unit/caption-cues.test.ts | 6 +- apps/web/__tests__/unit/diarization.test.ts | 126 ++++++++ .../unit/live-transcribe-core.test.ts | 91 ------ .../__tests__/unit/slack-app-manifest.test.ts | 2 +- .../unit/transcribe-language.test.ts | 8 + .../actions/videos/translate-transcript.ts | 3 +- apps/web/app/api/v1/[...route]/route.ts | 9 +- .../s/[videoId]/_components/caption-cues.ts | 6 +- .../[videoId]/_components/tabs/Transcript.tsx | 137 ++------ .../_components/utils/transcript-utils.ts | 112 +------ .../s/[videoId]/edit/TranscriptSidebar.tsx | 5 + apps/web/lib/agent-api.ts | 30 +- apps/web/lib/assemblyai.ts | 2 + apps/web/lib/edit-transcript.ts | 6 +- apps/web/lib/live-transcribe-core.ts | 69 +--- apps/web/lib/transcribe-utils.ts | 12 +- apps/web/lib/transcribe.ts | 5 +- apps/web/lib/transcript-text.ts | 10 +- apps/web/lib/transcript-vtt.ts | 179 ++++++++++- apps/web/workflows/live-transcribe.ts | 302 ++---------------- packages/web-domain/src/Agent.ts | 1 + 23 files changed, 564 insertions(+), 687 deletions(-) create mode 100644 apps/web/__tests__/integration/live-transcribe-diarization.test.ts create mode 100644 apps/web/__tests__/unit/diarization.test.ts diff --git a/apps/web/__tests__/integration/live-transcribe-diarization.test.ts b/apps/web/__tests__/integration/live-transcribe-diarization.test.ts new file mode 100644 index 00000000000..a8f77c37abb --- /dev/null +++ b/apps/web/__tests__/integration/live-transcribe-diarization.test.ts @@ -0,0 +1,124 @@ +import { Effect, Option } from "effect"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createEmptyLiveTranscript } from "@/lib/live-transcribe-core"; + +const mocks = vi.hoisted(() => ({ + queue: vi.fn(), + objects: new Map(), + writes: [] as string[], +})); +const video = { + id: "live-video", + ownerId: "live-owner", + source: { type: "desktopSegments" }, + transcriptionStatus: null, + settings: null, +}; +vi.mock("@cap/env", () => ({ + serverEnv: () => ({ ASSEMBLY_API_KEY: "test-key" }), +})); +vi.mock("@cap/database/schema", () => ({ + videos: { + id: "video-id", + ownerId: "owner-id", + metadata: "metadata", + updatedAt: "updated-at", + }, + organizations: { id: "org-id", settings: "settings" }, + users: { id: "user-id" }, +})); +vi.mock("@cap/database", () => ({ + db: () => ({ + select: () => ({ + from: () => ({ + leftJoin: () => ({ where: async () => [{ video, orgSettings: null }] }), + where: async () => [video], + }), + }), + update: () => ({ set: () => ({ where: async () => [] }) }), + }), +})); +vi.mock("@cap/web-backend/src/Storage/index", () => ({ + Storage: { + getAccessForVideo: () => + Effect.succeed([ + { + getObject: (key: string) => + Effect.succeed(Option.fromNullable(mocks.objects.get(key))), + putObject: (key: string, value: string) => + Effect.sync(() => { + mocks.writes.push(key); + mocks.objects.set(key, value); + }), + }, + ]), + }, +})); +vi.mock("@/lib/video-storage", () => ({ decodeStorageVideo: () => ({}) })); +vi.mock("@/lib/workflow-runtime", () => ({ + runWorkflowPromise: Effect.runPromise, +})); +vi.mock("@/lib/transcribe", () => ({ transcribeVideo: mocks.queue })); +vi.mock("@/lib/ai-generation-entitlement", () => ({ + isAiGenerationEnabledForUser: () => false, +})); + +const artifactKey = "live-owner/live-video/transcription.live.json"; +beforeEach(() => { + mocks.objects.clear(); + mocks.writes.length = 0; + mocks.queue.mockResolvedValue({ success: true, message: "Queued" }); + mocks.objects.set( + artifactKey, + JSON.stringify({ + ...createEmptyLiveTranscript("2026-09-08T00:00:00.000Z"), + lastAudioSegmentIndex: 2, + transcribedDurationMs: 4000, + }), + ); + mocks.objects.set( + "live-owner/live-video/segments/manifest.json", + JSON.stringify({ + version: 5, + video_init_uploaded: true, + audio_init_uploaded: true, + video_segments: [], + audio_segments: [ + { index: 1, duration: 2 }, + { index: 2, duration: 2 }, + ], + is_complete: true, + }), + ); +}); + +describe("live recording diarization handoff", () => { + it("queues a full recording pass even when provisional chunks cover every segment", async () => { + const { liveTranscribeWorkflow } = await import( + "@/workflows/live-transcribe" + ); + await liveTranscribeWorkflow({ videoId: video.id, userId: video.ownerId }); + expect(mocks.queue).toHaveBeenCalledExactlyOnceWith( + video.id, + video.ownerId, + false, + { earlyFromSegments: true }, + ); + expect(mocks.writes).toEqual([artifactKey]); + expect(JSON.parse(mocks.objects.get(artifactKey) ?? "{}").state).toBe( + "complete", + ); + }); + it("surfaces queue failures so the durable workflow retries the final transcription", async () => { + mocks.queue.mockResolvedValue({ + success: false, + message: "Queue unavailable", + }); + const { liveTranscribeWorkflow } = await import( + "@/workflows/live-transcribe" + ); + await expect( + liveTranscribeWorkflow({ videoId: video.id, userId: video.ownerId }), + ).rejects.toThrow("Queue unavailable"); + }); +}); diff --git a/apps/web/__tests__/integration/transcribe-workflow.test.ts b/apps/web/__tests__/integration/transcribe-workflow.test.ts index bab63f58d06..c3b149be008 100644 --- a/apps/web/__tests__/integration/transcribe-workflow.test.ts +++ b/apps/web/__tests__/integration/transcribe-workflow.test.ts @@ -206,6 +206,9 @@ describe("transcribeVideoWorkflow", () => { expect(result.success).toBe(true); expect(mocks.transcribe).toHaveBeenCalledTimes(1); + expect(mocks.transcribe).toHaveBeenCalledWith( + expect.objectContaining({ speaker_labels: true }), + ); expect(mocks.transcribe.mock.calls[0]?.[0]).toMatchObject({ disfluencies: true, speech_models: ["universal-3-5-pro", "universal-2"], @@ -265,6 +268,9 @@ describe("transcribeVideoWorkflow", () => { message: "Video has no spoken audio - skipped transcription", }); expect(mocks.transcribe).toHaveBeenCalledTimes(1); + expect(mocks.transcribe).toHaveBeenCalledWith( + expect.objectContaining({ speaker_labels: true }), + ); expect(mocks.updates).toContainEqual({ transcriptionStatus: "NO_AUDIO" }); expect(mocks.updates).not.toContainEqual({ transcriptionStatus: "ERROR" }); expect(mocks.startAiGeneration).not.toHaveBeenCalled(); diff --git a/apps/web/__tests__/unit/caption-cues.test.ts b/apps/web/__tests__/unit/caption-cues.test.ts index daecbe46f2e..6afc5522ba7 100644 --- a/apps/web/__tests__/unit/caption-cues.test.ts +++ b/apps/web/__tests__/unit/caption-cues.test.ts @@ -20,9 +20,11 @@ describe("getActiveCaptionText", () => { it("uses the latest active cue when cues overlap", () => { const activeCues = createCueList([ { startTime: 0, text: "First caption" }, - { startTime: 3.199, text: "Second caption" }, + { startTime: 3.199, text: "Second & final caption" }, ]); - expect(getActiveCaptionText(activeCues)).toBe("Second caption"); + expect(getActiveCaptionText(activeCues)).toBe( + "Speaker B: Second & final caption", + ); }); }); diff --git a/apps/web/__tests__/unit/diarization.test.ts b/apps/web/__tests__/unit/diarization.test.ts new file mode 100644 index 00000000000..ada060ab265 --- /dev/null +++ b/apps/web/__tests__/unit/diarization.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it } from "vitest"; +import { formatTranscriptAsVTT } from "@/app/s/[videoId]/_components/utils/transcript-utils"; +import { + createEditTranscript, + editTranscriptWordsToCaptionVtt, + groupEditTranscriptWords, + parseEditTranscript, + remapEditTranscriptThroughSpec, + serializeEditTranscript, +} from "@/lib/edit-transcript"; +import { formatTranscriptAsParagraphs } from "@/lib/transcript-text"; +import { + formatVttCueText, + parseVTT, + parseVttCueText, + updateVttEntryText, +} from "@/lib/transcript-vtt"; + +const transcript = createEditTranscript( + { + words: [ + { text: "Hello", start: 100, end: 300, speaker: "A" }, + { text: "there", start: 300, end: 600, speaker: "A" }, + { text: "Hi", start: 650, end: 800, speaker: "B" }, + { text: "again", start: 850, end: 1000, speaker: "A" }, + { text: "unknown", start: 1100, end: 1300, speaker: null }, + ], + }, + 2000, +); + +describe("speaker diarization", () => { + it("splits captions on every speaker transition without needing punctuation or silence", () => { + const cues = parseVTT(editTranscriptWordsToCaptionVtt(transcript.words)); + expect( + cues.map(({ text, speaker, startTime, endTime }) => ({ + text, + speaker, + startTime, + endTime, + })), + ).toEqual([ + { text: "Hello there", speaker: "A", startTime: 0.1, endTime: 0.6 }, + { text: "Hi", speaker: "B", startTime: 0.65, endTime: 0.8 }, + { text: "again", speaker: "A", startTime: 0.85, endTime: 1 }, + { text: "unknown", speaker: null, startTime: 1.1, endTime: 1.3 }, + ]); + }); + + it("preserves labels through storage, video cuts, caption regeneration, and download", () => { + const stored = parseEditTranscript(serializeEditTranscript(transcript)); + expect(stored).not.toBeNull(); + if (!stored) throw new Error("Missing transcript"); + const edited = remapEditTranscriptThroughSpec(stored, { + version: 1, + sourceDuration: 2, + keepRanges: [{ start: 0.6, end: 2 }], + }); + const cues = parseVTT(editTranscriptWordsToCaptionVtt(edited.words)); + expect(cues[0]).toMatchObject({ + text: "Hi", + speaker: "B", + startTime: 0.05, + }); + expect(parseVTT(formatTranscriptAsVTT(cues))).toEqual(cues); + }); + + it("shows separate editor groups and text paragraphs for speakers and unknown speech", () => { + expect( + groupEditTranscriptWords(transcript.words).map( + ({ startIndex, endIndex }) => [startIndex, endIndex], + ), + ).toEqual([ + [0, 1], + [2, 2], + [3, 3], + [4, 4], + ]); + expect( + formatTranscriptAsParagraphs( + parseVTT(editTranscriptWordsToCaptionVtt(transcript.words)), + ), + ).toBe( + "Speaker A: Hello there\n\nSpeaker B: Hi\n\nSpeaker A: again\n\nunknown", + ); + }); + + it("preserves the voice when editing spoken text and escapes markup", () => { + const vtt = editTranscriptWordsToCaptionVtt(transcript.words); + const updated = updateVttEntryText(vtt, 2, "Yes