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}