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
+251 -38
View File
@@ -55,39 +55,105 @@ export function formatTimestamp(value) {
+ ':' + pad(date.getMinutes());
}
function subscribeToRecording(recordingId, handler) {
if (!window.Echo) {
function unwrapBroadcast(event) {
if (! event || typeof event !== 'object') {
return {};
}
if (
event.transcript_delta === undefined
&& event.transcriptDelta === undefined
&& event.data
&& typeof event.data === 'object'
) {
return event.data;
}
return event;
}
function subscribeToRecording({ recordingId, userId }, handler) {
if (! window.Echo) {
return () => {};
}
const channelName = 'recording.' + recordingId;
const channel = window.Echo.private(channelName);
const channels = [window.Echo.private('recording.' + recordingId)];
channel.listen('.RecordingTranscriptionUpdated', handler);
if (userId) {
channels.push(window.Echo.private('user.' + userId + '.recordings'));
}
channels.forEach((channel) => {
channel.listen('.RecordingTranscriptionUpdated', handler);
});
return () => {
// Prefer stopListening over leave() so a remount does not drop other subscribers.
if (typeof channel.stopListening === 'function') {
channel.stopListening('.RecordingTranscriptionUpdated');
}
channels.forEach((channel) => {
if (typeof channel.stopListening === 'function') {
channel.stopListening('.RecordingTranscriptionUpdated');
}
});
};
}
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 : [],
};
}
function segmentKey(segment) {
return [segment?.id ?? '', segment?.start ?? '', segment?.end ?? '', segment?.text ?? ''].join('|');
}
export function formatClock(seconds) {
if (seconds == null || Number.isNaN(Number(seconds))) {
return '';
}
const value = Math.max(0, Number(seconds));
const minutes = Math.floor(value / 60);
const rest = value - minutes * 60;
return minutes + ':' + rest.toFixed(1).padStart(4, '0');
}
export function wordConfidenceClass(probability) {
if (probability == null) {
return 'bg-zinc-400/20 text-zinc-700 dark:text-zinc-200';
}
if (probability >= 0.85) {
return 'bg-teal-400/25 text-teal-800 dark:text-teal-200';
}
if (probability >= 0.6) {
return 'bg-amber-400/25 text-amber-800 dark:text-amber-200';
}
return 'bg-red-400/20 text-red-700 dark:text-red-300';
}
/**
* Show-page Alpine component: Echo push + local elapsed tick + optional status hydrate.
* Livewire also listens on the user recordings channel and polls while active.
* Hydrate once at start and again when the run finishes; do not poll the full transcript.
*/
export function transcriptionMonitor({ statusUrl, initial }) {
export function transcriptionMonitor({ statusUrl, initial, userId }) {
return {
statusUrl,
userId,
status: {
...initial,
badge_color: badgeColorFor(initial.status),
},
pollError: null,
tickTimer: null,
hydrateTimer: null,
leaveChannel: null,
liveFromEcho: false,
lastDeltaStamp: null,
whisper: whisperSnapshot(initial?.whisper),
get badgeColor() {
return badgeColorFor(this.status.status);
@@ -102,24 +168,27 @@ export function transcriptionMonitor({ statusUrl, initial }) {
},
start() {
this.leaveChannel = subscribeToRecording(this.status.id, (event) => {
if (Number(event.id) !== Number(this.status.id)) {
this.leaveChannel = subscribeToRecording({
recordingId: this.status.id,
userId: this.userId,
}, (event) => {
const payload = unwrapBroadcast(event);
if (Number(payload.id) !== Number(this.status.id)) {
return;
}
this.applyPayload(event);
this.applyPayload(payload, { fromEcho: true });
});
if (this.status.is_active) {
this.beginTick();
this.hydrateOnce();
this.beginHydratePoll();
}
},
destroy() {
this.stopTick();
this.stopHydratePoll();
if (this.leaveChannel) {
this.leaveChannel();
@@ -127,21 +196,45 @@ export function transcriptionMonitor({ statusUrl, initial }) {
}
},
applyPayload(payload) {
applyPayload(payload, { fromEcho = false } = {}) {
const wasActive = this.status.is_active;
const transcript = this.mergeTranscript(payload, fromEcho);
const nextStatus = payload.status ?? this.status.status;
this.status = {
...this.status,
...payload,
badge_color: badgeColorFor(payload.status ?? this.status.status),
transcript,
has_transcript: Boolean(transcript),
percent: this.mergePercent(payload, nextStatus),
badge_color: badgeColorFor(nextStatus),
};
if (
payload.transcript_replace
|| (
fromEcho
&& payload.status === 'processing'
&& payload.percent === 0
&& ! payload.whisper_delta
&& ! payload.transcript_delta
)
) {
this.whisper = whisperSnapshot(null);
}
this.whisper = this.mergeWhisper(payload, fromEcho);
this.pollError = null;
if (this.status.is_active) {
this.beginTick();
this.beginHydratePoll();
} else {
this.stopTick();
this.stopHydratePoll();
if (wasActive) {
this.hydrateOnce();
this.refreshLivewire();
}
}
if (wasActive && ! this.status.is_active
@@ -152,6 +245,139 @@ export function transcriptionMonitor({ statusUrl, initial }) {
}
},
refreshLivewire() {
if (typeof this.$wire?.$refresh === 'function') {
this.$wire.$refresh();
}
},
mergeTranscript(payload, fromEcho = false) {
const delta = payload.transcript_delta || payload.transcriptDelta;
const replace = payload.transcript_replace ?? payload.transcriptReplace ?? false;
if (fromEcho && delta) {
const stamp = String(payload.percent ?? '') + ':' + delta;
if (this.lastDeltaStamp === stamp) {
return this.status.transcript;
}
this.lastDeltaStamp = stamp;
this.liveFromEcho = true;
}
if (replace) {
return delta || '';
}
if (delta) {
this.liveFromEcho = this.liveFromEcho || fromEcho;
return (this.status.transcript || '') + delta;
}
if (this.liveFromEcho && (payload.status ?? this.status.status) === 'processing') {
return this.status.transcript;
}
if (typeof payload.transcript === 'string') {
const current = this.status.transcript || '';
if (payload.transcript.length < current.length) {
return current;
}
return payload.transcript;
}
return this.status.transcript;
},
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)) {
return this.whisper;
}
return whisperSnapshot(snapshot);
}
if (! delta) {
return this.whisper;
}
if (fromEcho) {
this.liveFromEcho = true;
}
const next = whisperSnapshot(this.whisper);
if (delta.language) {
next.language = delta.language;
}
if (delta.duration != null) {
next.duration = delta.duration;
}
if (Array.isArray(delta.segments) && delta.segments.length) {
next.segments = delta.segments;
} else if (delta.segment) {
next.segments = this.appendSegment(next.segments, delta.segment);
}
if (Array.isArray(delta.logprobs) && delta.logprobs.length) {
next.logprobs = next.logprobs.concat(delta.logprobs);
}
if (Array.isArray(delta.words) && delta.words.length && next.segments.length) {
const last = { ...next.segments[next.segments.length - 1] };
last.words = (last.words || []).concat(delta.words);
next.segments = next.segments.slice(0, -1).concat([last]);
}
return next;
},
appendSegment(segments, segment) {
const key = segmentKey(segment);
if (segments.some((row) => segmentKey(row) === key)) {
return segments;
}
return segments.concat([segment]);
},
seekTo(seconds) {
const player = this.$refs.player;
if (! player || seconds == null) {
return;
}
player.currentTime = Number(seconds);
player.play().catch(() => {});
},
mergePercent(payload, status) {
if (status !== 'processing') {
return payload.percent !== undefined ? payload.percent : this.status.percent;
}
const incoming = payload.percent;
const current = Number(this.status.percent) || 0;
if (incoming == null) {
return this.status.percent;
}
return Math.max(current, Number(incoming) || 0);
},
beginTick() {
if (this.tickTimer) {
return;
@@ -167,21 +393,6 @@ export function transcriptionMonitor({ statusUrl, initial }) {
}
},
beginHydratePoll() {
if (this.hydrateTimer || ! this.statusUrl) {
return;
}
this.hydrateTimer = setInterval(() => this.hydrateOnce(), 2000);
},
stopHydratePoll() {
if (this.hydrateTimer) {
clearInterval(this.hydrateTimer);
this.hydrateTimer = null;
}
},
tickElapsed() {
if (! this.status.is_active || this.status.elapsed_seconds == null) {
return;
@@ -220,12 +431,14 @@ export function transcriptionMonitor({ statusUrl, initial }) {
formatElapsed,
formatDuration,
formatTimestamp,
formatClock,
wordConfidenceClass,
};
}
/**
* Index-page Alpine component: inline audio player only.
* Status/progress refresh via Livewire Echo + wire:poll.
* Status/progress refresh via wire:poll. Live transcript on the show page uses Echo.
*/
export function recordingsIndex() {
return {