Keep live Whisper segments on screen without a page refresh.

Treat streamed chunks as appends, stop Livewire from remorphing the Alpine tree, and hydrate from HTTP while a run is active so segment cards appear as they arrive.
This commit is contained in:
ben
2026-08-13 16:35:33 +02:00
parent 187b6b5d12
commit 3a9cf50529
7 changed files with 265 additions and 65 deletions
+10 -2
View File
@@ -595,6 +595,10 @@ class Recording extends Model
$segments = is_array($verbose['segments'] ?? null) ? $verbose['segments'] : []; $segments = is_array($verbose['segments'] ?? null) ? $verbose['segments'] : [];
if (isset($delta['segment']) && is_array($delta['segment'])) {
$segments = $this->appendVerboseSegment($segments, $delta['segment']);
}
if (isset($delta['segments']) && is_array($delta['segments'])) { if (isset($delta['segments']) && is_array($delta['segments'])) {
$incoming = []; $incoming = [];
@@ -605,10 +609,14 @@ class Recording extends Model
} }
if ($incoming !== []) { if ($incoming !== []) {
if (isset($delta['segment'])) {
foreach ($incoming as $segment) {
$segments = $this->appendVerboseSegment($segments, $segment);
}
} else {
$segments = $incoming; $segments = $incoming;
} }
} elseif (isset($delta['segment']) && is_array($delta['segment'])) { }
$segments = $this->appendVerboseSegment($segments, $delta['segment']);
} }
if (isset($delta['words']) && is_array($delta['words']) && $delta['words'] !== []) { if (isset($delta['words']) && is_array($delta['words']) && $delta['words'] !== []) {
+40 -17
View File
@@ -95,6 +95,17 @@ class WhisperTranscriptionStream
]; ];
} }
if ($this->isLiveSegmentEvent($type, $data)) {
return [
'append' => $data['text'],
'replace' => null,
'end' => $end,
'done' => false,
'legacy' => true,
'whisper' => $whisper,
];
}
if (isset($data['segments']) && is_array($data['segments'])) { if (isset($data['segments']) && is_array($data['segments'])) {
$text = $data['text'] ?? ''; $text = $data['text'] ?? '';
@@ -112,22 +123,6 @@ class WhisperTranscriptionStream
]; ];
} }
if (
($type === null || $type === 'segment')
&& isset($data['text'])
&& is_string($data['text'])
&& $data['text'] !== ''
) {
return [
'append' => $data['text'],
'replace' => null,
'end' => $end,
'done' => false,
'legacy' => true,
'whisper' => $whisper,
];
}
if ($whisper !== []) { if ($whisper !== []) {
return [ return [
'append' => null, 'append' => null,
@@ -217,7 +212,7 @@ class WhisperTranscriptionStream
} }
} }
if ($segments !== []) { if ($segments !== [] && ! isset($meta['segment'])) {
$meta['segments'] = $segments; $meta['segments'] = $segments;
} }
} }
@@ -274,6 +269,34 @@ class WhisperTranscriptionStream
return array_filter($segment, fn (mixed $value): bool => $value !== null && $value !== []); return array_filter($segment, fn (mixed $value): bool => $value !== null && $value !== []);
} }
/**
* One streamed Whisper chunk (not a finished verbose_json document).
*
* @param array<string, mixed> $data
*/
private function isLiveSegmentEvent(mixed $type, array $data): bool
{
$text = $data['text'] ?? null;
if (! is_string($text) || $text === '') {
return false;
}
if ($type === 'segment') {
return true;
}
if ($type !== null && $type !== '') {
return false;
}
if (isset($data['start']) || isset($data['end'])) {
return true;
}
return ! (isset($data['segments']) && is_array($data['segments']));
}
/** /**
* @return list<string> * @return list<string>
*/ */
+124 -19
View File
@@ -63,6 +63,8 @@ function unwrapBroadcast(event) {
if ( if (
event.transcript_delta === undefined event.transcript_delta === undefined
&& event.transcriptDelta === undefined && event.transcriptDelta === undefined
&& event.whisper_delta === undefined
&& event.whisperDelta === undefined
&& event.data && event.data
&& typeof event.data === 'object' && typeof event.data === 'object'
) { ) {
@@ -100,13 +102,13 @@ function whisperSnapshot(snapshot) {
return { return {
language: snapshot?.language ?? null, language: snapshot?.language ?? null,
duration: snapshot?.duration ?? null, duration: snapshot?.duration ?? null,
segments: Array.isArray(snapshot?.segments) ? snapshot.segments : [], segments: Array.isArray(snapshot?.segments) ? snapshot.segments.slice() : [],
logprobs: Array.isArray(snapshot?.logprobs) ? snapshot.logprobs : [], logprobs: Array.isArray(snapshot?.logprobs) ? snapshot.logprobs.slice() : [],
}; };
} }
function segmentKey(segment) { function segmentKey(segment) {
return [segment?.id ?? '', segment?.start ?? '', segment?.end ?? '', segment?.text ?? ''].join('|'); return [segment?.start ?? '', segment?.end ?? '', segment?.text ?? ''].join('|');
} }
export function formatClock(seconds) { export function formatClock(seconds) {
@@ -138,7 +140,8 @@ export function wordConfidenceClass(probability) {
} }
/** /**
* Hydrate once at start and again when the run finishes; do not poll the full transcript. * Live updates come from Echo. While a run is active we also hydrate from HTTP
* so segment cards keep appearing even if Livewire remorphs or an Echo frame is missed.
*/ */
export function transcriptionMonitor({ statusUrl, initial, userId }) { export function transcriptionMonitor({ statusUrl, initial, userId }) {
return { return {
@@ -150,6 +153,7 @@ export function transcriptionMonitor({ statusUrl, initial, userId }) {
}, },
pollError: null, pollError: null,
tickTimer: null, tickTimer: null,
hydrateTimer: null,
leaveChannel: null, leaveChannel: null,
liveFromEcho: false, liveFromEcho: false,
lastDeltaStamp: null, lastDeltaStamp: null,
@@ -183,12 +187,14 @@ export function transcriptionMonitor({ statusUrl, initial, userId }) {
if (this.status.is_active) { if (this.status.is_active) {
this.beginTick(); this.beginTick();
this.beginHydratePoll();
this.hydrateOnce(); this.hydrateOnce();
} }
}, },
destroy() { destroy() {
this.stopTick(); this.stopTick();
this.stopHydratePoll();
if (this.leaveChannel) { if (this.leaveChannel) {
this.leaveChannel(); this.leaveChannel();
@@ -210,16 +216,7 @@ export function transcriptionMonitor({ statusUrl, initial, userId }) {
badge_color: badgeColorFor(nextStatus), badge_color: badgeColorFor(nextStatus),
}; };
if ( if (this.shouldResetWhisper(payload, fromEcho)) {
payload.transcript_replace
|| (
fromEcho
&& payload.status === 'processing'
&& payload.percent === 0
&& ! payload.whisper_delta
&& ! payload.transcript_delta
)
) {
this.whisper = whisperSnapshot(null); this.whisper = whisperSnapshot(null);
} }
@@ -228,8 +225,10 @@ export function transcriptionMonitor({ statusUrl, initial, userId }) {
if (this.status.is_active) { if (this.status.is_active) {
this.beginTick(); this.beginTick();
this.beginHydratePoll();
} else { } else {
this.stopTick(); this.stopTick();
this.stopHydratePoll();
if (wasActive) { if (wasActive) {
this.hydrateOnce(); this.hydrateOnce();
@@ -277,7 +276,14 @@ export function transcriptionMonitor({ statusUrl, initial, userId }) {
} }
if (this.liveFromEcho && (payload.status ?? this.status.status) === 'processing') { if (this.liveFromEcho && (payload.status ?? this.status.status) === 'processing') {
return this.status.transcript; const incoming = typeof payload.transcript === 'string' ? payload.transcript : '';
const current = this.status.transcript || '';
if (incoming.length > current.length) {
return incoming;
}
return current;
} }
if (typeof payload.transcript === 'string') { if (typeof payload.transcript === 'string') {
@@ -293,19 +299,74 @@ export function transcriptionMonitor({ statusUrl, initial, userId }) {
return this.status.transcript; return this.status.transcript;
}, },
shouldResetWhisper(payload, fromEcho = false) {
const delta = payload.whisper_delta || payload.whisperDelta;
const segment = delta?.segment;
if (
fromEcho
&& payload.status === 'processing'
&& payload.percent === 0
&& ! delta
&& ! payload.transcript_delta
&& ! payload.transcriptDelta
) {
return true;
}
if (segment && this.whisper.segments.length) {
const last = this.whisper.segments[this.whisper.segments.length - 1];
if (segment.start != null && last.start != null && Number(segment.start) + 0.05 < Number(last.start)) {
return true;
}
}
return false;
},
mergeWhisper(payload, fromEcho = false) { mergeWhisper(payload, fromEcho = false) {
const snapshot = payload.whisper; const snapshot = payload.whisper;
const delta = payload.whisper_delta || payload.whisperDelta; const delta = payload.whisper_delta || payload.whisperDelta;
if (snapshot && Array.isArray(snapshot.segments) && ! delta) { if (snapshot && Array.isArray(snapshot.segments) && ! delta) {
if (fromEcho || (this.liveFromEcho && this.whisper.segments.length > snapshot.segments.length)) { if (fromEcho) {
return this.whisper; return this.whisper;
} }
// HTTP hydrate is source of truth once it is ahead of (or equal to) local Echo state.
if (snapshot.segments.length >= this.whisper.segments.length) {
return whisperSnapshot(snapshot); return whisperSnapshot(snapshot);
} }
const next = whisperSnapshot(this.whisper);
if (! next.language && snapshot.language) {
next.language = snapshot.language;
}
if (next.duration == null && snapshot.duration != null) {
next.duration = snapshot.duration;
}
return next;
}
if (! delta) { if (! delta) {
if (snapshot) {
const next = whisperSnapshot(this.whisper);
if (snapshot.language) {
next.language = snapshot.language;
}
if (snapshot.duration != null) {
next.duration = snapshot.duration;
}
return next;
}
return this.whisper; return this.whisper;
} }
@@ -323,12 +384,20 @@ export function transcriptionMonitor({ statusUrl, initial, userId }) {
next.duration = delta.duration; next.duration = delta.duration;
} }
if (Array.isArray(delta.segments) && delta.segments.length) { if (delta.segment) {
next.segments = delta.segments;
} else if (delta.segment) {
next.segments = this.appendSegment(next.segments, delta.segment); next.segments = this.appendSegment(next.segments, delta.segment);
} }
if (Array.isArray(delta.segments) && delta.segments.length) {
if (! delta.segment) {
next.segments = delta.segments.slice();
} else {
delta.segments.forEach((row) => {
next.segments = this.appendSegment(next.segments, row);
});
}
}
if (Array.isArray(delta.logprobs) && delta.logprobs.length) { if (Array.isArray(delta.logprobs) && delta.logprobs.length) {
next.logprobs = next.logprobs.concat(delta.logprobs); next.logprobs = next.logprobs.concat(delta.logprobs);
} }
@@ -352,6 +421,10 @@ export function transcriptionMonitor({ statusUrl, initial, userId }) {
return segments.concat([segment]); return segments.concat([segment]);
}, },
segmentDomKey(segment, index) {
return segmentKey(segment) + '#' + index;
},
seekTo(seconds) { seekTo(seconds) {
const player = this.$refs.player; const player = this.$refs.player;
@@ -393,17 +466,49 @@ export function transcriptionMonitor({ statusUrl, initial, userId }) {
} }
}, },
beginHydratePoll() {
if (this.hydrateTimer || ! this.statusUrl) {
return;
}
this.hydrateTimer = setInterval(() => {
if (! this.status.is_active) {
this.stopHydratePoll();
return;
}
this.hydrateOnce();
}, 1000);
},
stopHydratePoll() {
if (this.hydrateTimer) {
clearInterval(this.hydrateTimer);
this.hydrateTimer = null;
}
},
tickElapsed() { tickElapsed() {
if (! this.status.is_active || this.status.elapsed_seconds == null) { if (! this.status.is_active || this.status.elapsed_seconds == null) {
return; return;
} }
const next = this.status.elapsed_seconds + 1; const next = this.status.elapsed_seconds + 1;
const duration = Number(this.status.duration_seconds) || 0;
let percent = this.status.percent;
if (this.status.status === 'processing' && duration > 0) {
const elapsedPercent = Math.min(99, Math.round((100 * next) / duration));
percent = Math.max(Number(percent) || 0, elapsedPercent);
}
this.status = { this.status = {
...this.status, ...this.status,
elapsed_seconds: next, elapsed_seconds: next,
elapsed_human: formatElapsed(next), elapsed_human: formatElapsed(next),
percent,
}; };
}, },
@@ -1,10 +1,9 @@
{{--
Full wire:ignore: Livewire must not remorph this tree while Echo/Alpine own the live UI.
Status + whisper segments hydrate over HTTP while active (no wire:poll here).
--}}
<div <div
@if ($recording->transcription_status === 'pending') wire:ignore
wire:poll.2s.visible
@endif
>
<div
wire:ignore.self
wire:key="transcription-ui-{{ $recording->id }}-{{ $recording->transcription_status }}-{{ $recording->transcribed_at?->timestamp }}" wire:key="transcription-ui-{{ $recording->id }}-{{ $recording->transcription_status }}-{{ $recording->transcribed_at?->timestamp }}"
x-data="transcriptionMonitor(@js([ x-data="transcriptionMonitor(@js([
'statusUrl' => route('recordings.transcription-status', $recording), 'statusUrl' => route('recordings.transcription-status', $recording),
@@ -15,7 +14,7 @@
start(); start();
return () => destroy(); return () => destroy();
" "
> >
<div class="mb-6 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"> <div class="mb-6 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div> <div>
<flux:link href="{{ route('recordings.index') }}" wire:navigate class="text-sm"> Recordings</flux:link> <flux:link href="{{ route('recordings.index') }}" wire:navigate class="text-sm"> Recordings</flux:link>
@@ -181,7 +180,6 @@
</div> </div>
<div <div
wire:ignore
class="mt-4 whitespace-pre-wrap text-sm leading-relaxed text-zinc-800 dark:text-zinc-100" class="mt-4 whitespace-pre-wrap text-sm leading-relaxed text-zinc-800 dark:text-zinc-100"
x-show="status.transcript && !whisper.segments.length" x-show="status.transcript && !whisper.segments.length"
x-text="status.transcript" x-text="status.transcript"
@@ -245,7 +243,6 @@
</div> </div>
<div <div
wire:ignore
class="mt-4 space-y-4" class="mt-4 space-y-4"
x-show="whisper.language || whisper.segments.length || whisper.logprobs.length" x-show="whisper.language || whisper.segments.length || whisper.logprobs.length"
> >
@@ -263,7 +260,7 @@
</span> </span>
</div> </div>
<template x-for="(segment, index) in whisper.segments" :key="segment.id ?? (segment.start + '-' + index)"> <template x-for="(segment, index) in whisper.segments" :key="segmentDomKey(segment, index)">
<div class="rounded-lg border border-zinc-200 p-3 dark:border-zinc-700"> <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"> <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 class="tabular-nums">
@@ -329,5 +326,4 @@
No transcript yet. Transcription starts automatically after upload, or use the button above. No transcript yet. Transcription starts automatically after upload, or use the button above.
</flux:text> </flux:text>
</flux:card> </flux:card>
</div>
</div> </div>
+8 -5
View File
@@ -60,7 +60,7 @@ class ShowTest extends TestCase
$this->assertDatabaseMissing('recordings', ['id' => $recording->id]); $this->assertDatabaseMissing('recordings', ['id' => $recording->id]);
} }
public function test_show_polls_while_transcription_is_queued(): void public function test_show_uses_alpine_hydrate_while_transcription_is_queued(): void
{ {
$user = User::factory()->create(); $user = User::factory()->create();
$this->actingAs($user); $this->actingAs($user);
@@ -79,12 +79,13 @@ class ShowTest extends TestCase
]); ]);
Livewire::test(Show::class, ['recording' => $recording]) Livewire::test(Show::class, ['recording' => $recording])
->assertSee('wire:poll', false) ->assertDontSee('wire:poll', false)
->assertDontSee('echo-private', false) ->assertSee('wire:ignore', false)
->assertSee('transcriptionMonitor', false)
->assertSee('Queued — waiting to start…'); ->assertSee('Queued — waiting to start…');
} }
public function test_show_does_not_poll_while_transcription_is_processing(): void public function test_show_does_not_use_livewire_poll_while_transcription_is_processing(): void
{ {
$user = User::factory()->create(); $user = User::factory()->create();
$this->actingAs($user); $this->actingAs($user);
@@ -104,6 +105,7 @@ class ShowTest extends TestCase
Livewire::test(Show::class, ['recording' => $recording]) Livewire::test(Show::class, ['recording' => $recording])
->assertDontSee('wire:poll', false) ->assertDontSee('wire:poll', false)
->assertSee('wire:ignore', false)
->assertSee('userId', false); ->assertSee('userId', false);
} }
@@ -166,6 +168,7 @@ class ShowTest extends TestCase
Livewire::test(Show::class, ['recording' => $recording]) Livewire::test(Show::class, ['recording' => $recording])
->assertSee('whisper.segments', false) ->assertSee('whisper.segments', false)
->assertSee('whisper.language', false) ->assertSee('whisper.language', false)
->assertSee('segmentDomKey', false)
->assertSee('Hello'); ->assertSee('Hello');
} }
@@ -274,7 +277,7 @@ class ShowTest extends TestCase
]); ]);
Livewire::test(Show::class, ['recording' => $recording]) Livewire::test(Show::class, ['recording' => $recording])
->assertSee('wire:ignore.self', false) ->assertSee('wire:ignore', false)
->assertSee('transcription-ui-'.$recording->id.'-processing-', false) ->assertSee('transcription-ui-'.$recording->id.'-processing-', false)
->assertDontSee('transcription-ui-'.$recording->id.'-processing-62', false); ->assertDontSee('transcription-ui-'.$recording->id.'-processing-62', false);
} }
@@ -330,6 +330,35 @@ class TranscriptionBroadcastTest extends TestCase
$this->assertSame(-0.1, $recording->transcription_verbose['segments'][0]['avg_logprob']); $this->assertSame(-0.1, $recording->transcription_verbose['segments'][0]['avg_logprob']);
} }
public function test_consecutive_live_whisper_segments_are_appended(): void
{
Event::fake([RecordingTranscriptionUpdated::class]);
$recording = Recording::query()->create([
'user_id' => $this->user->id,
'title' => 'Append live',
'original_filename' => 'append.mp3',
'file_path' => 'recordings/append.mp3',
'file_size_bytes' => 100,
'transcription_status' => 'processing',
'transcription_driver' => 'local',
'transcription_started_at' => now(),
]);
$recording->reportProgress('Transcribing locally…', 40, partialTranscript: "didn't I wasn't aware", whisperDelta: [
'language' => 'en',
'segment' => ['id' => 0, 'text' => "didn't I wasn't aware", 'start' => 254.5, 'end' => 260.8],
]);
$recording->refresh()->reportProgress('Transcribing locally…', 45, partialTranscript: "didn't I wasn't aware other people", whisperDelta: [
'language' => 'en',
'segment' => ['id' => 0, 'text' => 'other people', 'start' => 260.8, 'end' => 267.4],
]);
$recording->refresh();
$this->assertCount(2, $recording->transcription_verbose['segments']);
$this->assertSame('other people', $recording->transcription_verbose['segments'][1]['text']);
}
public function test_oversized_transcript_delta_is_not_broadcast(): void public function test_oversized_transcript_delta_is_not_broadcast(): void
{ {
$recording = Recording::query()->create([ $recording = Recording::query()->create([
@@ -155,6 +155,42 @@ class WhisperTranscriptionStreamTest extends TestCase
$this->assertSame(-0.3, $event['whisper']['segments'][0]['avg_logprob']); $this->assertSame(-0.3, $event['whisper']['segments'][0]['avg_logprob']);
} }
public function test_live_segment_with_nested_segments_array_appends_instead_of_replacing(): void
{
$first = $this->stream->parseEvent(json_encode([
'language' => 'en',
'duration' => 6.3,
'id' => 0,
'start' => 254.5,
'end' => 260.8,
'text' => "didn't I wasn't aware",
'segments' => [
['id' => 0, 'start' => 254.5, 'end' => 260.8, 'text' => "didn't I wasn't aware"],
],
]));
$second = $this->stream->parseEvent(json_encode([
'language' => 'en',
'duration' => 6.96,
'id' => 0,
'start' => 260.8,
'end' => 267.4,
'text' => ' other people so I am trying',
'segments' => [
['id' => 0, 'start' => 260.8, 'end' => 267.4, 'text' => ' other people so I am trying'],
],
]));
$this->assertTrue($first['legacy']);
$this->assertNull($first['replace']);
$this->assertArrayNotHasKey('segments', $first['whisper']);
$this->assertSame("didn't I wasn't aware", $first['whisper']['segment']['text']);
$text = $this->stream->applyEvent('', $first);
$text = $this->stream->applyEvent($text, $second);
$this->assertSame("didn't I wasn't aware other people so I am trying", $text);
}
public function test_joins_legacy_segments_with_spaces(): void public function test_joins_legacy_segments_with_spaces(): void
{ {
$first = $this->stream->parseEvent('{"text":"Hello from","end":10}'); $first = $this->stream->parseEvent('{"text":"Hello from","end":10}');