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'] : [];
|
$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'] !== []) {
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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}');
|
||||||
|
|||||||
Reference in New Issue
Block a user