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
+125 -20
View File
@@ -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,
};
},