Stream Whisper verbose metadata live without overflowing Reverb.

Persist accumulated verbose_json on the recording and broadcast only transcript/whisper deltas so the show page can render language, segments, word timestamps, and confidence while a run is in progress.
This commit is contained in:
ben
2026-08-13 15:55:51 +02:00
parent 4c73620458
commit 187b6b5d12
16 changed files with 1646 additions and 189 deletions
@@ -1,12 +1,14 @@
<div
@if ($recording->isTranscribing())
@if ($recording->transcription_status === 'pending')
wire:poll.2s.visible
@endif
>
<div
wire:key="transcription-ui-{{ $recording->id }}-{{ $recording->transcription_status }}-{{ $recording->transcription_percent }}-{{ md5((string) $recording->transcription_progress) }}-{{ $recording->transcribed_at?->timestamp }}"
wire:ignore.self
wire:key="transcription-ui-{{ $recording->id }}-{{ $recording->transcription_status }}-{{ $recording->transcribed_at?->timestamp }}"
x-data="transcriptionMonitor(@js([
'statusUrl' => route('recordings.transcription-status', $recording),
'userId' => (int) $recording->user_id,
'initial' => $recording->transcriptionStatusPayload(),
]))"
x-init="
@@ -164,7 +166,7 @@
<flux:callout.text>
<span
class="tabular-nums"
x-show="status.status === 'processing' && status.percent != null"
x-show="status.status === 'processing' && Number(status.percent) > 0"
x-cloak
>
<span x-text="status.percent + '%'"></span>
@@ -174,22 +176,30 @@
</flux:callout.text>
</flux:callout>
<div class="mt-3" x-show="status.status === 'processing'" x-cloak>
<div class="mt-3" x-show="status.status === 'processing' && Number(status.percent) > 0" x-cloak>
<flux:progress color="amber" x-bind:value="status.percent || 0" />
</div>
<p
<div
wire:ignore
class="mt-4 whitespace-pre-wrap text-sm leading-relaxed text-zinc-800 dark:text-zinc-100"
x-show="status.transcript"
x-cloak
x-show="status.transcript && !whisper.segments.length"
x-text="status.transcript"
></p>
></div>
<ul class="mt-3 space-y-1 text-xs text-zinc-600 dark:text-zinc-400">
<li>
Engine:
<span class="font-medium" x-text="status.driver_label || '—'"></span>
</li>
<li x-show="whisper.language">
Detected language:
<span class="font-medium uppercase" x-text="whisper.language"></span>
</li>
<li x-show="whisper.duration">
Whisper duration:
<span class="font-medium" x-text="formatClock(whisper.duration)"></span>
</li>
<template x-if="status.duration_seconds">
<li>
Audio length:
@@ -219,7 +229,11 @@
</div>
<div x-show="!status.is_active && status.has_transcript" x-cloak>
<p class="mt-4 whitespace-pre-wrap text-sm leading-relaxed text-zinc-800 dark:text-zinc-100" x-text="status.transcript"></p>
<p
class="mt-4 whitespace-pre-wrap text-sm leading-relaxed text-zinc-800 dark:text-zinc-100"
x-show="!whisper.segments.length"
x-text="status.transcript"
></p>
<flux:text
class="mt-4 text-xs"
x-show="status.transcribed_at"
@@ -230,6 +244,83 @@
></flux:text>
</div>
<div
wire:ignore
class="mt-4 space-y-4"
x-show="whisper.language || whisper.segments.length || whisper.logprobs.length"
>
<div
class="flex flex-wrap gap-2 text-xs text-zinc-600 dark:text-zinc-400"
x-show="!status.is_active && (whisper.language || whisper.duration)"
>
<span x-show="whisper.language">
Language
<span class="font-medium uppercase text-zinc-800 dark:text-zinc-100" x-text="whisper.language"></span>
</span>
<span x-show="whisper.duration">
· Duration
<span class="font-medium text-zinc-800 dark:text-zinc-100" x-text="formatClock(whisper.duration)"></span>
</span>
</div>
<template x-for="(segment, index) in whisper.segments" :key="segment.id ?? (segment.start + '-' + index)">
<div class="rounded-lg border border-zinc-200 p-3 dark:border-zinc-700">
<div class="flex flex-wrap items-baseline justify-between gap-2 text-xs text-zinc-500 dark:text-zinc-400">
<span class="tabular-nums">
<span x-text="formatClock(segment.start)"></span>
<span x-text="formatClock(segment.end)"></span>
</span>
<span x-show="segment.avg_logprob != null">
avg logprob
<span class="font-medium text-zinc-700 dark:text-zinc-200" x-text="Number(segment.avg_logprob).toFixed(3)"></span>
</span>
</div>
<p class="mt-2 text-sm leading-relaxed text-zinc-800 dark:text-zinc-100" x-text="segment.text"></p>
<div class="mt-2 flex flex-wrap gap-1" x-show="segment.words && segment.words.length">
<template x-for="(word, wordIndex) in (segment.words || [])" :key="wordIndex">
<button
type="button"
class="rounded px-1.5 py-0.5 text-xs tabular-nums"
:class="wordConfidenceClass(word.probability)"
:title="(word.start != null ? formatClock(word.start) : '') + (word.probability != null ? (' · ' + Math.round(word.probability * 100) + '%') : '')"
@click="seekTo(word.start)"
x-text="word.word"
></button>
</template>
</div>
<details class="mt-2 text-xs text-zinc-500 dark:text-zinc-400" x-show="segment.tokens && segment.tokens.length">
<summary class="cursor-pointer select-none">Tokens</summary>
<p class="mt-1 break-all font-mono" x-text="(segment.tokens || []).join(' ')"></p>
</details>
<p class="mt-1 text-xs text-zinc-500 dark:text-zinc-400" x-show="segment.no_speech_prob != null">
no-speech
<span class="font-medium" x-text="Number(segment.no_speech_prob).toFixed(3)"></span>
<span x-show="segment.compression_ratio != null">
· compression
<span class="font-medium" x-text="Number(segment.compression_ratio).toFixed(2)"></span>
</span>
</p>
</div>
</template>
<details class="text-xs text-zinc-500 dark:text-zinc-400" x-show="whisper.logprobs.length">
<summary class="cursor-pointer select-none">Token logprobs</summary>
<ul class="mt-2 space-y-1 font-mono">
<template x-for="(row, index) in whisper.logprobs" :key="index">
<li>
<span x-text="row.token || '—'"></span>
<span x-show="row.logprob != null" x-text="' ' + Number(row.logprob).toFixed(3)"></span>
</li>
</template>
</ul>
</details>
</div>
<flux:text
x-show="!status.is_active && status.status !== 'failed' && status.status !== 'cancelled' && !status.has_transcript"
x-cloak