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:
@@ -595,6 +595,10 @@ class Recording extends Model
|
||||
|
||||
$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'])) {
|
||||
$incoming = [];
|
||||
|
||||
@@ -605,10 +609,14 @@ class Recording extends Model
|
||||
}
|
||||
|
||||
if ($incoming !== []) {
|
||||
$segments = $incoming;
|
||||
if (isset($delta['segment'])) {
|
||||
foreach ($incoming as $segment) {
|
||||
$segments = $this->appendVerboseSegment($segments, $segment);
|
||||
}
|
||||
} else {
|
||||
$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'] !== []) {
|
||||
|
||||
@@ -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'])) {
|
||||
$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 !== []) {
|
||||
return [
|
||||
'append' => null,
|
||||
@@ -217,7 +212,7 @@ class WhisperTranscriptionStream
|
||||
}
|
||||
}
|
||||
|
||||
if ($segments !== []) {
|
||||
if ($segments !== [] && ! isset($meta['segment'])) {
|
||||
$meta['segments'] = $segments;
|
||||
}
|
||||
}
|
||||
@@ -274,6 +269,34 @@ class WhisperTranscriptionStream
|
||||
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>
|
||||
*/
|
||||
|
||||
+125
-20
@@ -63,6 +63,8 @@ function unwrapBroadcast(event) {
|
||||
if (
|
||||
event.transcript_delta === undefined
|
||||
&& event.transcriptDelta === undefined
|
||||
&& event.whisper_delta === undefined
|
||||
&& event.whisperDelta === undefined
|
||||
&& event.data
|
||||
&& typeof event.data === 'object'
|
||||
) {
|
||||
@@ -100,13 +102,13 @@ function whisperSnapshot(snapshot) {
|
||||
return {
|
||||
language: snapshot?.language ?? null,
|
||||
duration: snapshot?.duration ?? null,
|
||||
segments: Array.isArray(snapshot?.segments) ? snapshot.segments : [],
|
||||
logprobs: Array.isArray(snapshot?.logprobs) ? snapshot.logprobs : [],
|
||||
segments: Array.isArray(snapshot?.segments) ? snapshot.segments.slice() : [],
|
||||
logprobs: Array.isArray(snapshot?.logprobs) ? snapshot.logprobs.slice() : [],
|
||||
};
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -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 }) {
|
||||
return {
|
||||
@@ -150,6 +153,7 @@ export function transcriptionMonitor({ statusUrl, initial, userId }) {
|
||||
},
|
||||
pollError: null,
|
||||
tickTimer: null,
|
||||
hydrateTimer: null,
|
||||
leaveChannel: null,
|
||||
liveFromEcho: false,
|
||||
lastDeltaStamp: null,
|
||||
@@ -183,12 +187,14 @@ export function transcriptionMonitor({ statusUrl, initial, userId }) {
|
||||
|
||||
if (this.status.is_active) {
|
||||
this.beginTick();
|
||||
this.beginHydratePoll();
|
||||
this.hydrateOnce();
|
||||
}
|
||||
},
|
||||
|
||||
destroy() {
|
||||
this.stopTick();
|
||||
this.stopHydratePoll();
|
||||
|
||||
if (this.leaveChannel) {
|
||||
this.leaveChannel();
|
||||
@@ -210,16 +216,7 @@ export function transcriptionMonitor({ statusUrl, initial, userId }) {
|
||||
badge_color: badgeColorFor(nextStatus),
|
||||
};
|
||||
|
||||
if (
|
||||
payload.transcript_replace
|
||||
|| (
|
||||
fromEcho
|
||||
&& payload.status === 'processing'
|
||||
&& payload.percent === 0
|
||||
&& ! payload.whisper_delta
|
||||
&& ! payload.transcript_delta
|
||||
)
|
||||
) {
|
||||
if (this.shouldResetWhisper(payload, fromEcho)) {
|
||||
this.whisper = whisperSnapshot(null);
|
||||
}
|
||||
|
||||
@@ -228,8 +225,10 @@ export function transcriptionMonitor({ statusUrl, initial, userId }) {
|
||||
|
||||
if (this.status.is_active) {
|
||||
this.beginTick();
|
||||
this.beginHydratePoll();
|
||||
} else {
|
||||
this.stopTick();
|
||||
this.stopHydratePoll();
|
||||
|
||||
if (wasActive) {
|
||||
this.hydrateOnce();
|
||||
@@ -277,7 +276,14 @@ export function transcriptionMonitor({ statusUrl, initial, userId }) {
|
||||
}
|
||||
|
||||
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') {
|
||||
@@ -293,19 +299,74 @@ export function transcriptionMonitor({ statusUrl, initial, userId }) {
|
||||
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) {
|
||||
const snapshot = payload.whisper;
|
||||
const delta = payload.whisper_delta || payload.whisperDelta;
|
||||
|
||||
if (snapshot && Array.isArray(snapshot.segments) && ! delta) {
|
||||
if (fromEcho || (this.liveFromEcho && this.whisper.segments.length > snapshot.segments.length)) {
|
||||
if (fromEcho) {
|
||||
return this.whisper;
|
||||
}
|
||||
|
||||
return whisperSnapshot(snapshot);
|
||||
// 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);
|
||||
}
|
||||
|
||||
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 (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;
|
||||
}
|
||||
|
||||
@@ -323,12 +384,20 @@ export function transcriptionMonitor({ statusUrl, initial, userId }) {
|
||||
next.duration = delta.duration;
|
||||
}
|
||||
|
||||
if (Array.isArray(delta.segments) && delta.segments.length) {
|
||||
next.segments = delta.segments;
|
||||
} else if (delta.segment) {
|
||||
if (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) {
|
||||
next.logprobs = next.logprobs.concat(delta.logprobs);
|
||||
}
|
||||
@@ -352,6 +421,10 @@ export function transcriptionMonitor({ statusUrl, initial, userId }) {
|
||||
return segments.concat([segment]);
|
||||
},
|
||||
|
||||
segmentDomKey(segment, index) {
|
||||
return segmentKey(segment) + '#' + index;
|
||||
},
|
||||
|
||||
seekTo(seconds) {
|
||||
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() {
|
||||
if (! this.status.is_active || this.status.elapsed_seconds == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
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,
|
||||
elapsed_seconds: next,
|
||||
elapsed_human: formatElapsed(next),
|
||||
percent,
|
||||
};
|
||||
},
|
||||
|
||||
|
||||
@@ -1,21 +1,20 @@
|
||||
{{--
|
||||
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
|
||||
@if ($recording->transcription_status === 'pending')
|
||||
wire:poll.2s.visible
|
||||
@endif
|
||||
wire:ignore
|
||||
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="
|
||||
start();
|
||||
return () => destroy();
|
||||
"
|
||||
>
|
||||
<div
|
||||
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="
|
||||
start();
|
||||
return () => destroy();
|
||||
"
|
||||
>
|
||||
<div class="mb-6 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<flux:link href="{{ route('recordings.index') }}" wire:navigate class="text-sm">← Recordings</flux:link>
|
||||
@@ -181,7 +180,6 @@
|
||||
</div>
|
||||
|
||||
<div
|
||||
wire:ignore
|
||||
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-text="status.transcript"
|
||||
@@ -245,7 +243,6 @@
|
||||
</div>
|
||||
|
||||
<div
|
||||
wire:ignore
|
||||
class="mt-4 space-y-4"
|
||||
x-show="whisper.language || whisper.segments.length || whisper.logprobs.length"
|
||||
>
|
||||
@@ -263,7 +260,7 @@
|
||||
</span>
|
||||
</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="flex flex-wrap items-baseline justify-between gap-2 text-xs text-zinc-500 dark:text-zinc-400">
|
||||
<span class="tabular-nums">
|
||||
@@ -329,5 +326,4 @@
|
||||
No transcript yet. Transcription starts automatically after upload, or use the button above.
|
||||
</flux:text>
|
||||
</flux:card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -60,7 +60,7 @@ class ShowTest extends TestCase
|
||||
$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();
|
||||
$this->actingAs($user);
|
||||
@@ -79,12 +79,13 @@ class ShowTest extends TestCase
|
||||
]);
|
||||
|
||||
Livewire::test(Show::class, ['recording' => $recording])
|
||||
->assertSee('wire:poll', false)
|
||||
->assertDontSee('echo-private', false)
|
||||
->assertDontSee('wire:poll', false)
|
||||
->assertSee('wire:ignore', false)
|
||||
->assertSee('transcriptionMonitor', false)
|
||||
->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();
|
||||
$this->actingAs($user);
|
||||
@@ -104,6 +105,7 @@ class ShowTest extends TestCase
|
||||
|
||||
Livewire::test(Show::class, ['recording' => $recording])
|
||||
->assertDontSee('wire:poll', false)
|
||||
->assertSee('wire:ignore', false)
|
||||
->assertSee('userId', false);
|
||||
}
|
||||
|
||||
@@ -166,6 +168,7 @@ class ShowTest extends TestCase
|
||||
Livewire::test(Show::class, ['recording' => $recording])
|
||||
->assertSee('whisper.segments', false)
|
||||
->assertSee('whisper.language', false)
|
||||
->assertSee('segmentDomKey', false)
|
||||
->assertSee('Hello');
|
||||
}
|
||||
|
||||
@@ -274,7 +277,7 @@ class ShowTest extends TestCase
|
||||
]);
|
||||
|
||||
Livewire::test(Show::class, ['recording' => $recording])
|
||||
->assertSee('wire:ignore.self', false)
|
||||
->assertSee('wire:ignore', false)
|
||||
->assertSee('transcription-ui-'.$recording->id.'-processing-', 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']);
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
$recording = Recording::query()->create([
|
||||
|
||||
@@ -155,6 +155,42 @@ class WhisperTranscriptionStreamTest extends TestCase
|
||||
$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
|
||||
{
|
||||
$first = $this->stream->parseEvent('{"text":"Hello from","end":10}');
|
||||
|
||||
Reference in New Issue
Block a user