Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions webui/native/src/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down
9 changes: 9 additions & 0 deletions webui/native/src/lib/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,15 @@ const english: Record<string, string> = {
'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.',
Expand Down
90 changes: 90 additions & 0 deletions webui/native/src/routes/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -705,6 +710,58 @@
return ['tts', 'clon', 'gen', 's2s', 'vdes'].includes(entry.task);
}

function timedRowsFromResult(result: Record<string, unknown>): { 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, unknown>) => string) =>
(Array.isArray(value) ? value : []).map((entry: Record<string, unknown>) => ({
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.
Expand Down Expand Up @@ -1297,6 +1354,8 @@
outputArtifacts = [];
outputText = '';
outputJson = '';
outputRows = [];
outputRowKind = '';
}

async function ensureLoaded() {
Expand Down Expand Up @@ -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.');
Expand Down Expand Up @@ -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'
? `<base64 data: ${value.length} chars>` : value, 2);
Expand Down Expand Up @@ -2483,6 +2544,35 @@
<div class="empty-output"><div class="wave">∿</div><p>{tr('result.empty')}</p></div>
{/if}
{#if outputText}<textarea class="transcript" readonly rows="7" value={outputText}></textarea>{/if}
{#if outputRows.length}
<div class="timed-rows">
<div class="media-actions">
<strong>{tr(`result.rows.${outputRowKind}`)}</strong>
<button type="button" on:click={() => downloadSubtitles('srt')}>{tr('result.saveSrt')}</button>
<button type="button" on:click={() => downloadSubtitles('vtt')}>{tr('result.saveVtt')}</button>
</div>
<div class="timed-rows-scroll">
<table>
<thead>
<tr>
<th>{tr('result.start')}</th>
<th>{tr('result.end')}</th>
<th>{outputRowKind === 'speaker_turns' ? tr('result.speaker') : tr('result.content')}</th>
</tr>
</thead>
<tbody>
{#each outputRows as row}
<tr>
<td>{formatTimecode(row.start, '.')}</td>
<td>{formatTimecode(row.end, '.')}</td>
<td>{[row.label, row.text].filter(Boolean).join(': ')}</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
{/if}
{#if outputJson}<pre>{outputJson}</pre>{/if}
</section>
</div>
Expand Down
Loading