From 2f87d46603fca1373edfb3469fcedddd93da4bc7 Mon Sep 17 00:00:00 2001 From: Warren B Date: Thu, 3 Sep 2026 01:50:50 +0100 Subject: [PATCH] webui: show the segments, speaker turns and word timings the server returns ASR, diarization, VAD and forced alignment already returned timed detail and the UI dropped all of it on the floor: the result pane showed the flat transcript only, so word timings a user could have exported as subtitles were computed and discarded on every run. The result pane now renders whichever detail array came back -- speaker turns, word timings or segments, in that order of specificity -- as a scrollable table of start, end and content, with Save SRT and Save VTT buttons beside it. Spans arrive as sample offsets, so they are divided by the sample_rate the response carries; without that field no row is rendered rather than a wrong timestamp being shown. Timecodes are formatted once and shared by the table and both subtitle formats, which differ only in the millisecond separator and the WEBVTT header. Rows need the server to report sample_rate beside the detail arrays, which is the companion server change; a response without it renders no rows rather than a table of zeros. Validation: npx svelte-check --tsconfig ./tsconfig.json # 153 files, 0 errors The three functions were extracted and run against a real Parakeet-TDT response (12 s clip): 24 word rows, first cue 00:00:00,080 --> 00:00:00,533, SRT and VTT headers correct, and 0 rows when sample_rate is removed. --- webui/native/src/app.css | 7 +++ webui/native/src/lib/i18n.ts | 9 +++ webui/native/src/routes/+page.svelte | 90 ++++++++++++++++++++++++++++ 3 files changed, 106 insertions(+) diff --git a/webui/native/src/app.css b/webui/native/src/app.css index df994638a..72a0641d8 100644 --- a/webui/native/src/app.css +++ b/webui/native/src/app.css @@ -215,6 +215,13 @@ kbd { font: 9px ui-monospace, monospace; padding: 2px 4px; border: 1px solid rgb .audio-list a { color: var(--cyan); text-decoration: none; } audio { width: 100%; height: 38px; } .transcript { margin-top: 14px; } +.timed-rows { margin-top: 14px; } +.timed-rows-scroll { max-height: 250px; overflow: auto; border: 1px solid var(--line); border-radius: 8px; margin-top: 6px; } +.timed-rows table { width: 100%; border-collapse: collapse; font-size: 12px; } +.timed-rows th, .timed-rows td { padding: 6px 9px; text-align: left; border-bottom: 1px solid var(--line); } +.timed-rows th { position: sticky; top: 0; background: var(--card-bg); color: var(--muted); font-weight: 500; } +.timed-rows td:first-child, .timed-rows td:nth-child(2) { white-space: nowrap; font-variant-numeric: tabular-nums; color: var(--text-subtle); } +.timed-rows tr:last-child td { border-bottom: none; } pre { background: var(--code-bg); border: 1px solid var(--line); border-radius: 8px; padding: 10px; overflow: auto; max-height: 250px; color: var(--text-subtle); white-space: pre-wrap; overflow-wrap: anywhere; } .arena-hero { align-items: end; } diff --git a/webui/native/src/lib/i18n.ts b/webui/native/src/lib/i18n.ts index 5a37eef69..1a5a157f2 100644 --- a/webui/native/src/lib/i18n.ts +++ b/webui/native/src/lib/i18n.ts @@ -227,6 +227,15 @@ const english: Record = { 'result.tracks': 'tracks', 'result.saveWav': 'Save WAV', 'result.empty': 'Generated audio and structured results appear here.', + 'result.rows.segments': 'Segments', + 'result.rows.words': 'Word timings', + 'result.rows.speaker_turns': 'Speaker turns', + 'result.saveSrt': 'Save SRT', + 'result.saveVtt': 'Save VTT', + 'result.start': 'Start', + 'result.end': 'End', + 'result.speaker': 'Speaker', + 'result.content': 'Content', 'models.eyebrow': 'MODEL LIBRARY', 'models.title': 'Local packages', 'models.subtitle': 'Download and manage model packages without leaving the native interface.', diff --git a/webui/native/src/routes/+page.svelte b/webui/native/src/routes/+page.svelte index 777984830..164a5be76 100644 --- a/webui/native/src/routes/+page.svelte +++ b/webui/native/src/routes/+page.svelte @@ -91,6 +91,11 @@ let outputArtifacts: Array<{ id: string; url: string; extension: string }> = []; let outputText = ''; let outputJson = ''; + // Timed detail rows returned by ASR, diarization, VAD and forced alignment. + // Spans arrive as sample offsets alongside the rate they were counted in. + type TimedRow = { start: number; end: number; label: string; text: string; confidence: number }; + let outputRows: TimedRow[] = []; + let outputRowKind = ''; let logs: string[] = []; let aborter: AbortController | null = null; let longText = true; @@ -705,6 +710,58 @@ return ['tts', 'clon', 'gen', 's2s', 'vdes'].includes(entry.task); } + function timedRowsFromResult(result: Record): { rows: TimedRow[]; kind: string } { + // Spans are sample offsets, so without the rate they were counted in there + // is no timestamp to show. Render nothing rather than a row of zeros. + const rate = Number(result.sample_rate) || 0; + if (rate <= 0) return { rows: [], kind: '' }; + const toSeconds = (samples: unknown) => Number(samples) / rate; + const read = (value: unknown, label: (entry: Record) => string) => + (Array.isArray(value) ? value : []).map((entry: Record) => ({ + start: toSeconds(entry.start_sample), + end: toSeconds(entry.end_sample), + label: label(entry), + text: typeof entry.text === 'string' ? entry.text : '', + confidence: Number(entry.confidence) || 0 + })); + const turns = read(result.speaker_turns, (entry) => String(entry.speaker_id ?? '')); + if (turns.length) return { rows: turns, kind: 'speaker_turns' }; + const words = read(result.words, (entry) => String(entry.word ?? '')); + if (words.length) return { rows: words, kind: 'words' }; + return { rows: read(result.segments, () => ''), kind: 'segments' }; + } + + function formatTimecode(seconds: number, millisecondSeparator: string) { + if (!Number.isFinite(seconds) || seconds < 0) seconds = 0; + const whole = Math.floor(seconds); + const milliseconds = Math.round((seconds - whole) * 1000); + const pad = (value: number, width = 2) => String(value).padStart(width, '0'); + return `${pad(Math.floor(whole / 3600))}:${pad(Math.floor(whole / 60) % 60)}:${pad(whole % 60)}` + + `${millisecondSeparator}${pad(milliseconds, 3)}`; + } + + function subtitleText(rows: TimedRow[], format: 'srt' | 'vtt') { + const separator = format === 'srt' ? ',' : '.'; + const cues = rows.map((row, index) => { + const caption = [row.label, row.text].filter(Boolean).join(': ') || `#${index + 1}`; + const timing = + `${formatTimecode(row.start, separator)} --> ${formatTimecode(row.end, separator)}`; + return format === 'srt' ? `${index + 1}\n${timing}\n${caption}\n` : `${timing}\n${caption}\n`; + }); + return (format === 'vtt' ? 'WEBVTT\n\n' : '') + cues.join('\n'); + } + + function downloadSubtitles(format: 'srt' | 'vtt') { + if (!outputRows.length || !selected) return; + const blob = new Blob([subtitleText(outputRows, format)], { type: 'text/plain;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = `${selected.id}-transcript.${format}`; + anchor.click(); + URL.revokeObjectURL(url); + } + function supportsRequestOption(entry: CatalogEntry, option: string) { // Specs that publish request metadata are authoritative. Older specs // without that metadata keep the legacy UI behavior until migrated. @@ -1297,6 +1354,8 @@ outputArtifacts = []; outputText = ''; outputJson = ''; + outputRows = []; + outputRowKind = ''; } async function ensureLoaded() { @@ -1637,6 +1696,7 @@ options }, aborter.signal); outputText = String(result.text || ''); + ({ rows: outputRows, kind: outputRowKind } = timedRowsFromResult(result)); outputJson = JSON.stringify(result, null, 2); } else { if (needsSource && !audio) throw new StatusWarning('Choose a source audio file.'); @@ -1681,6 +1741,7 @@ })); } outputText = typeof result.text === 'string' ? result.text : ''; + ({ rows: outputRows, kind: outputRowKind } = timedRowsFromResult(result)); outputJson = JSON.stringify(result, (key, value) => (key === 'audio' || key === 'payload') && typeof value === 'string' ? `` : value, 2); @@ -2483,6 +2544,35 @@

{tr('result.empty')}

{/if} {#if outputText}{/if} + {#if outputRows.length} +
+
+ {tr(`result.rows.${outputRowKind}`)} + + +
+
+ + + + + + + + + + {#each outputRows as row} + + + + + + {/each} + +
{tr('result.start')}{tr('result.end')}{outputRowKind === 'speaker_turns' ? tr('result.speaker') : tr('result.content')}
{formatTimecode(row.start, '.')}{formatTimecode(row.end, '.')}{[row.label, row.text].filter(Boolean).join(': ')}
+
+
+ {/if} {#if outputJson}
{outputJson}
{/if}