Connect recording detail transcript data

This commit is contained in:
rishikanthc
2026-04-27 01:58:13 -07:00
parent f90c13cfe0
commit 2c265bbfe9
5 changed files with 378 additions and 47 deletions

View File

@@ -12,6 +12,28 @@ export type Transcription = {
updated_at: string;
};
export type TranscriptSegment = {
id?: string;
start: number;
end: number;
speaker?: string;
text: string;
};
export type TranscriptWord = {
start: number;
end: number;
word: string;
speaker?: string;
};
export type TranscriptionTranscript = {
transcription_id: string;
text: string;
segments: TranscriptSegment[];
words: TranscriptWord[];
};
export type TranscriptionsResponse = {
items: Transcription[];
next_cursor: string | null;
@@ -31,6 +53,17 @@ export async function listTranscriptions(headers: Record<string, string>): Promi
return response.json() as Promise<TranscriptionsResponse>;
}
export async function getTranscriptionTranscript(
transcriptionId: string,
headers: Record<string, string>
): Promise<TranscriptionTranscript> {
const response = await fetch(`/api/v1/transcriptions/${transcriptionId}/transcript`, {
headers,
});
if (!response.ok) throw new Error(await readError(response));
return response.json() as Promise<TranscriptionTranscript>;
}
export async function createTranscription(
payload: CreateTranscriptionPayload,
headers: Record<string, string>

View File

@@ -4,44 +4,14 @@ import { CalendarDays, Clock3, MoreHorizontal, Pause, Pencil, Play } from "lucid
import { Sidebar } from "@/features/home/components/HomePage";
import { useAuth } from "@/features/auth/hooks/useAuth";
import { useFile, useUpdateFile } from "@/features/files/hooks/useFiles";
import type { FileStatus } from "@/features/files/api/filesApi";
import type { TranscriptSegment, TranscriptWord, Transcription, TranscriptionTranscript } from "@/features/transcription/api/transcriptionsApi";
import { useTranscriptionDetailEvents } from "@/features/transcription/hooks/useTranscriptionDetailEvents";
import { useTranscriptionListEvents } from "@/features/transcription/hooks/useTranscriptionListEvents";
import { useTranscriptionTranscript, useTranscriptions } from "@/features/transcription/hooks/useTranscriptions";
type DetailTab = "summary" | "transcript";
type MockSegment = {
id: string;
start: number;
text: string;
speaker?: string;
};
const mockSegments: MockSegment[] = [
{
id: "seg-1",
start: 1,
text: "About adding event listeners",
},
{
id: "seg-2",
start: 3,
text: "On your element. We are going to dive deeper and explain the things you need to know about adding event listeners on your elements in your app component.",
},
{
id: "seg-3",
start: 17,
text: "So let's take a look at the repo.",
},
{
id: "seg-4",
start: 18,
text: "Over here, I have a heading and if I want to add event listeners, this is how I would have done it. If I write vanilla JavaScript, I would have a reference to the element and I will say add event listener. Then I will pass in the event name and the event handler. There are two ways to handle this: create a function inline, or create the function ahead of time and pass the reference to this event listener method.",
},
{
id: "seg-5",
start: 93,
text: "So that's how you say this.",
},
];
export function AudioDetailView() {
const { audioId = "" } = useParams<{ audioId: string }>();
const [activeTab, setActiveTab] = useState<DetailTab>("transcript");
@@ -50,10 +20,20 @@ export function AudioDetailView() {
const [draftTitle, setDraftTitle] = useState("");
const fileQuery = useFile(audioId);
const updateFileMutation = useUpdateFile(audioId);
const transcriptionsQuery = useTranscriptions();
const file = fileQuery.data;
const title = file?.title?.trim() || "Untitled recording";
const visibleDuration = file?.duration_seconds ?? audioDuration;
const latestTranscription = useMemo(() => {
if (!file) return undefined;
return latestTranscriptionForFile(transcriptionsQuery.data?.items || [], file.id);
}, [file, transcriptionsQuery.data?.items]);
const isActiveTranscription = latestTranscription?.status === "queued" || latestTranscription?.status === "processing";
const transcriptQuery = useTranscriptionTranscript(latestTranscription?.id, latestTranscription?.status === "completed");
useTranscriptionListEvents();
useTranscriptionDetailEvents(isActiveTranscription ? latestTranscription?.id : undefined);
const meta = useMemo(() => {
return {
@@ -179,7 +159,17 @@ export function AudioDetailView() {
</button>
</div>
{activeTab === "summary" ? <MockSummary /> : <TranscriptMock segments={mockSegments} />}
{activeTab === "summary" ? (
<SummaryPanel transcription={latestTranscription} />
) : (
<TranscriptPanel
fileStatus={file.status}
transcription={latestTranscription}
transcript={transcriptQuery.data}
isLoading={transcriptionsQuery.isLoading || transcriptQuery.isLoading}
isError={transcriptionsQuery.isError || transcriptQuery.isError}
/>
)}
</article>
<StreamingAudioPlayer
fileId={file.id}
@@ -193,26 +183,75 @@ export function AudioDetailView() {
);
}
function MockSummary() {
function SummaryPanel({ transcription }: { transcription?: Transcription }) {
if (!transcription) {
return (
<section className="scr-audio-summary" aria-label="Summary">
<p>No transcription has been queued for this recording yet.</p>
</section>
);
}
return (
<section className="scr-audio-summary" aria-label="Summary">
<p>
This recording walks through adding event listeners, how event names and handlers are passed, and the difference
between inline callbacks and pre-defined handler functions.
</p>
<p>
The key workflow is to identify the target element, choose the relevant event, and pass a stable function reference
when the handler will be reused or benefits from being named.
</p>
<p>Summary is not available yet.</p>
</section>
);
}
function TranscriptMock({ segments }: { segments: MockSegment[] }) {
type TranscriptPanelProps = {
fileStatus: FileStatus;
transcription?: Transcription;
transcript?: TranscriptionTranscript;
isLoading: boolean;
isError: boolean;
};
function TranscriptPanel({ fileStatus, transcription, transcript, isLoading, isError }: TranscriptPanelProps) {
if (isLoading) {
return <TranscriptPlaceholder title="Loading transcript" description="Reading the latest transcription state." />;
}
if (isError) {
return <TranscriptPlaceholder title="Transcript unavailable" description="Could not load transcript data." />;
}
if (!transcription) {
if (fileStatus === "processing") {
return <TranscriptPlaceholder title="Audio is processing" description="Transcript actions will be available when the imported audio is ready." />;
}
if (fileStatus === "failed") {
return <TranscriptPlaceholder title="Audio import failed" description="This recording cannot be transcribed until the file import succeeds." />;
}
return <TranscriptPlaceholder title="Not transcribed yet" description="Queue a transcription from Home to generate transcript segments for this recording." />;
}
if (transcription.status === "queued" || transcription.status === "processing") {
return (
<TranscriptPlaceholder
title={transcription.status === "queued" ? "Transcription queued" : "Transcription processing"}
description={formatTranscriptionProgress(transcription)}
/>
);
}
if (transcription.status === "failed") {
return <TranscriptPlaceholder title="Transcription failed" description="Start another transcription from Home when you are ready to retry." />;
}
if (transcription.status === "canceled") {
return <TranscriptPlaceholder title="Transcription canceled" description="Start another transcription from Home to generate transcript text." />;
}
const segments = normalizeTranscriptSegments(transcript);
if (!segments.length) {
return <TranscriptPlaceholder title="Transcript is empty" description="The completed transcription did not return any text." />;
}
return (
<section className="scr-transcript" aria-label="Transcript">
{segments.map((segment) => (
<article className="scr-transcript-segment" key={segment.id}>
<article className="scr-transcript-segment" key={segment.id || `${segment.start}-${segment.end}`}>
{segment.speaker ? <span className="scr-transcript-speaker">{segment.speaker}</span> : null}
<time className="scr-transcript-time">{formatSegmentTime(segment.start)}</time>
<p className="scr-transcript-text">{segment.text}</p>
@@ -222,6 +261,15 @@ function TranscriptMock({ segments }: { segments: MockSegment[] }) {
);
}
function TranscriptPlaceholder({ title, description }: { title: string; description: string }) {
return (
<section className="scr-transcript-placeholder" aria-label="Transcript status">
<h2>{title}</h2>
<p>{description}</p>
</section>
);
}
type StreamingAudioPlayerProps = {
fileId: string;
durationSeconds: number | null;
@@ -309,6 +357,82 @@ function StreamingAudioPlayer({ fileId, durationSeconds, title, onDurationChange
);
}
function latestTranscriptionForFile(transcriptions: Transcription[], fileId: string) {
return transcriptions
.filter((transcription) => transcription.file_id === fileId)
.sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime())[0];
}
function normalizeTranscriptSegments(transcript?: TranscriptionTranscript): TranscriptSegment[] {
if (!transcript) return [];
if (transcript.segments.length > 1) return transcript.segments.filter((segment) => segment.text.trim());
if (transcript.words.length > 0) return chunkWordsIntoDisplaySegments(transcript.words);
if (transcript.segments.length === 1 && transcript.segments[0].text.trim()) return transcript.segments;
const text = transcript.text.trim();
if (!text) return [];
return [{
id: "full-transcript",
start: 0,
end: 0,
text,
}];
}
function chunkWordsIntoDisplaySegments(words: TranscriptWord[]): TranscriptSegment[] {
const segments: TranscriptSegment[] = [];
let current: TranscriptWord[] = [];
const flush = () => {
if (!current.length) return;
const first = current[0];
const last = current[current.length - 1];
const text = current.map((word) => word.word.trim()).filter(Boolean).join(" ");
if (text) {
segments.push({
id: `word-segment-${segments.length}`,
start: first.start,
end: last.end,
speaker: first.speaker,
text,
});
}
current = [];
};
for (const word of words) {
current.push(word);
const cleaned = word.word.trim();
const closesSentence = /[.!?]$/.test(cleaned);
if ((closesSentence && current.length >= 10) || current.length >= 32) {
flush();
}
}
flush();
return segments;
}
function formatTranscriptionProgress(transcription: Transcription) {
const progress = normalizeProgress(transcription.progress);
const stage = transcription.progress_stage && transcription.progress_stage !== "queued"
? transcription.progress_stage
: transcription.status;
if (progress === null) return sentenceCase(stage);
return `${sentenceCase(stage)} · ${progress}%`;
}
function normalizeProgress(value: number) {
if (!Number.isFinite(value) || value <= 0) return null;
const percent = value <= 1 ? value * 100 : value;
return Math.min(100, Math.max(1, Math.round(percent)));
}
function sentenceCase(value: string) {
const normalized = value.replace(/[_-]+/g, " ").trim();
if (!normalized) return "";
return normalized.charAt(0).toUpperCase() + normalized.slice(1);
}
function formatCreatedDate(value: string) {
return new Date(value).toLocaleString("en-US", {
month: "short",

View File

@@ -0,0 +1,140 @@
import { useEffect } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { useAuth } from "@/features/auth/hooks/useAuth";
import type { TranscriptionStatus, TranscriptionsResponse } from "@/features/transcription/api/transcriptionsApi";
import { transcriptionTranscriptQueryKey, transcriptionsQueryKey } from "@/features/transcription/hooks/useTranscriptions";
type TranscriptionEvent = {
name: string;
data: {
id?: string;
status?: string;
progress?: number;
stage?: string;
};
};
export function useTranscriptionDetailEvents(transcriptionId: string | undefined) {
const { token } = useAuth();
const queryClient = useQueryClient();
useEffect(() => {
if (!token || !transcriptionId) return;
const abortController = new AbortController();
let reconnectTimer: number | undefined;
const invalidateDetail = () => {
queryClient.invalidateQueries({ queryKey: transcriptionsQueryKey });
queryClient.invalidateQueries({ queryKey: transcriptionTranscriptQueryKey(transcriptionId) });
};
const scheduleReconnect = () => {
if (abortController.signal.aborted) return;
reconnectTimer = window.setTimeout(() => {
void connect();
}, 1500);
};
const connect = async () => {
try {
const response = await fetch(`/api/v1/transcriptions/${transcriptionId}/events`, {
headers: { Authorization: `Bearer ${token}` },
signal: abortController.signal,
});
if (!response.ok || !response.body) {
scheduleReconnect();
return;
}
invalidateDetail();
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (!abortController.signal.aborted) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const chunks = buffer.split("\n\n");
buffer = chunks.pop() || "";
for (const chunk of chunks) {
const parsed = parseSSEChunk(chunk);
if (!parsed || parsed.data.id !== transcriptionId) continue;
queryClient.setQueryData<TranscriptionsResponse>(transcriptionsQueryKey, (current) => {
if (!current) return current;
return {
...current,
items: current.items.map((transcription) => {
if (transcription.id !== transcriptionId) return transcription;
return {
...transcription,
status: normalizeEventStatus(parsed.data.status) || transcription.status,
progress: parsed.data.progress ?? transcription.progress,
progress_stage: parsed.data.stage || transcription.progress_stage,
updated_at: new Date().toISOString(),
};
}),
};
});
if (parsed.name === "transcription.completed" || parsed.data.status === "completed") {
queryClient.invalidateQueries({ queryKey: transcriptionTranscriptQueryKey(transcriptionId) });
}
}
}
scheduleReconnect();
} catch (error) {
if ((error as Error).name === "AbortError") return;
scheduleReconnect();
}
};
void connect();
return () => {
abortController.abort();
if (reconnectTimer) window.clearTimeout(reconnectTimer);
};
}, [token, transcriptionId, queryClient]);
}
function parseSSEChunk(chunk: string): TranscriptionEvent | null {
let name = "";
let data = "";
for (const line of chunk.split("\n")) {
if (line.startsWith("event:")) {
name = line.slice("event:".length).trim();
} else if (line.startsWith("data:")) {
data += line.slice("data:".length).trim();
}
}
if (!name || !data) return null;
try {
return { name, data: JSON.parse(data) };
} catch {
return null;
}
}
function normalizeEventStatus(status?: string): TranscriptionStatus | undefined {
switch (status) {
case "queued":
case "processing":
case "completed":
case "failed":
case "canceled":
return status;
default:
return undefined;
}
}

View File

@@ -2,12 +2,14 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useAuth } from "@/features/auth/hooks/useAuth";
import {
createTranscription,
getTranscriptionTranscript,
listTranscriptions,
type CreateTranscriptionPayload,
type TranscriptionsResponse,
} from "@/features/transcription/api/transcriptionsApi";
export const transcriptionsQueryKey = ["transcriptions"] as const;
export const transcriptionTranscriptQueryKey = (transcriptionId: string) => ["transcription-transcript", transcriptionId] as const;
export function useTranscriptions() {
const { getAuthHeaders, isAuthenticated } = useAuth();
@@ -25,6 +27,17 @@ export function useTranscriptions() {
});
}
export function useTranscriptionTranscript(transcriptionId: string | undefined, enabled: boolean) {
const { getAuthHeaders, isAuthenticated } = useAuth();
return useQuery({
queryKey: transcriptionId ? transcriptionTranscriptQueryKey(transcriptionId) : ["transcription-transcript", "missing"],
queryFn: () => getTranscriptionTranscript(transcriptionId || "", getAuthHeaders()),
enabled: isAuthenticated && Boolean(transcriptionId) && enabled,
staleTime: 30_000,
});
}
export function useCreateTranscription() {
const { getAuthHeaders } = useAuth();
const queryClient = useQueryClient();

View File

@@ -170,6 +170,27 @@
max-width: 900px;
}
.scr-transcript-placeholder {
display: grid;
gap: var(--scr-space-2);
max-width: 560px;
padding-top: var(--scr-space-2);
}
.scr-transcript-placeholder h2 {
color: var(--scr-text-primary);
font-size: 1rem;
font-weight: 600;
line-height: var(--scr-line-tight);
}
.scr-transcript-placeholder p {
color: var(--scr-text-secondary);
font-size: 0.9375rem;
font-weight: 500;
line-height: var(--scr-line-base);
}
.scr-transcript-segment {
--scr-transcript-row-line: 1.68;
display: grid;