Keep click-to-seek and hover percentages, but drop the boxed pill styling so the transcript reads as normal flowing text.
628 lines
18 KiB
JavaScript
628 lines
18 KiB
JavaScript
/**
|
|
* Shared helpers and Alpine components for live transcription updates via Reverb.
|
|
*/
|
|
|
|
const BADGE_COLORS = {
|
|
done: 'teal',
|
|
processing: 'amber',
|
|
pending: 'zinc',
|
|
failed: 'red',
|
|
cancelled: 'zinc',
|
|
};
|
|
|
|
export function badgeColorFor(status) {
|
|
return BADGE_COLORS[status] || 'zinc';
|
|
}
|
|
|
|
/** @deprecated Use badgeColorFor — kept for any leftover callers */
|
|
export function badgeClassFor(status) {
|
|
return badgeColorFor(status);
|
|
}
|
|
|
|
export function formatElapsed(seconds) {
|
|
const total = Math.max(0, Math.round(Number(seconds) || 0));
|
|
const minutes = Math.floor(total / 60);
|
|
const remain = total % 60;
|
|
|
|
if (minutes === 0) {
|
|
return remain + 's';
|
|
}
|
|
|
|
return minutes + 'm ' + String(remain).padStart(2, '0') + 's';
|
|
}
|
|
|
|
export function formatDuration(seconds) {
|
|
const total = Math.max(0, Math.round(Number(seconds) || 0));
|
|
const minutes = Math.floor(total / 60);
|
|
const remain = total % 60;
|
|
|
|
return minutes + ':' + String(remain).padStart(2, '0');
|
|
}
|
|
|
|
export function formatTimestamp(value) {
|
|
const date = new Date(value);
|
|
|
|
if (Number.isNaN(date.getTime())) {
|
|
return value;
|
|
}
|
|
|
|
const pad = (n) => String(n).padStart(2, '0');
|
|
|
|
return date.getFullYear()
|
|
+ '-' + pad(date.getMonth() + 1)
|
|
+ '-' + pad(date.getDate())
|
|
+ ' ' + pad(date.getHours())
|
|
+ ':' + pad(date.getMinutes());
|
|
}
|
|
|
|
function unwrapBroadcast(event) {
|
|
if (! event || typeof event !== 'object') {
|
|
return {};
|
|
}
|
|
|
|
if (
|
|
event.transcript_delta === undefined
|
|
&& event.transcriptDelta === undefined
|
|
&& event.whisper_delta === undefined
|
|
&& event.whisperDelta === undefined
|
|
&& event.data
|
|
&& typeof event.data === 'object'
|
|
) {
|
|
return event.data;
|
|
}
|
|
|
|
return event;
|
|
}
|
|
|
|
function subscribeToRecording({ recordingId, userId }, handler) {
|
|
if (! window.Echo) {
|
|
return () => {};
|
|
}
|
|
|
|
const channels = [window.Echo.private('recording.' + recordingId)];
|
|
|
|
if (userId) {
|
|
channels.push(window.Echo.private('user.' + userId + '.recordings'));
|
|
}
|
|
|
|
channels.forEach((channel) => {
|
|
channel.listen('.RecordingTranscriptionUpdated', handler);
|
|
});
|
|
|
|
return () => {
|
|
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.slice() : [],
|
|
logprobs: Array.isArray(snapshot?.logprobs) ? snapshot.logprobs.slice() : [],
|
|
};
|
|
}
|
|
|
|
function segmentKey(segment) {
|
|
return [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 'text-zinc-700 dark:text-zinc-200';
|
|
}
|
|
|
|
if (probability >= 0.85) {
|
|
return 'text-teal-700 dark:text-teal-300';
|
|
}
|
|
|
|
if (probability >= 0.6) {
|
|
return 'text-amber-700 dark:text-amber-300';
|
|
}
|
|
|
|
return 'text-red-700 dark:text-red-300';
|
|
}
|
|
|
|
/**
|
|
* 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 {
|
|
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);
|
|
},
|
|
|
|
get startButtonLabel() {
|
|
if (this.status.is_active) {
|
|
return 'Restart transcription';
|
|
}
|
|
|
|
return this.status.has_transcript ? 'Re-transcribe' : 'Start transcription';
|
|
},
|
|
|
|
get whisperWords() {
|
|
return this.whisper.segments.flatMap((segment, segmentIndex) => {
|
|
if (Array.isArray(segment.words) && segment.words.length) {
|
|
return segment.words.map((word, wordIndex) => ({
|
|
word: word.word,
|
|
start: word.start,
|
|
probability: word.probability,
|
|
key: [segmentIndex, wordIndex, word.start ?? '', word.word ?? ''].join(':'),
|
|
}));
|
|
}
|
|
|
|
const text = typeof segment.text === 'string' ? segment.text.trim() : '';
|
|
|
|
if (text === '') {
|
|
return [];
|
|
}
|
|
|
|
return [{
|
|
word: text,
|
|
start: segment.start,
|
|
probability: null,
|
|
key: segmentIndex + ':text',
|
|
}];
|
|
});
|
|
},
|
|
|
|
start() {
|
|
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(payload, { fromEcho: true });
|
|
});
|
|
|
|
if (this.status.is_active) {
|
|
this.beginTick();
|
|
this.beginHydratePoll();
|
|
this.hydrateOnce();
|
|
}
|
|
},
|
|
|
|
destroy() {
|
|
this.stopTick();
|
|
this.stopHydratePoll();
|
|
|
|
if (this.leaveChannel) {
|
|
this.leaveChannel();
|
|
this.leaveChannel = null;
|
|
}
|
|
},
|
|
|
|
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,
|
|
transcript,
|
|
has_transcript: Boolean(transcript),
|
|
percent: this.mergePercent(payload, nextStatus),
|
|
badge_color: badgeColorFor(nextStatus),
|
|
};
|
|
|
|
if (this.shouldResetWhisper(payload, fromEcho)) {
|
|
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
|
|
&& ! this.status.has_transcript
|
|
&& this.status.status !== 'failed'
|
|
&& this.status.status !== 'cancelled') {
|
|
window.location.reload();
|
|
}
|
|
},
|
|
|
|
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') {
|
|
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') {
|
|
const current = this.status.transcript || '';
|
|
|
|
if (payload.transcript.length < current.length) {
|
|
return current;
|
|
}
|
|
|
|
return payload.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) {
|
|
const snapshot = payload.whisper;
|
|
const delta = payload.whisper_delta || payload.whisperDelta;
|
|
|
|
if (snapshot && Array.isArray(snapshot.segments) && ! delta) {
|
|
if (fromEcho) {
|
|
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);
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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 (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);
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
this.tickTimer = setInterval(() => this.tickElapsed(), 1000);
|
|
},
|
|
|
|
stopTick() {
|
|
if (this.tickTimer) {
|
|
clearInterval(this.tickTimer);
|
|
this.tickTimer = null;
|
|
}
|
|
},
|
|
|
|
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,
|
|
};
|
|
},
|
|
|
|
async hydrateOnce() {
|
|
if (! this.statusUrl) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(this.statusUrl, {
|
|
headers: { Accept: 'application/json' },
|
|
credentials: 'same-origin',
|
|
});
|
|
|
|
if (! response.ok) {
|
|
throw new Error('Status request failed (' + response.status + ')');
|
|
}
|
|
|
|
this.applyPayload(await response.json());
|
|
} catch (error) {
|
|
this.pollError = error.message || 'Could not refresh progress.';
|
|
}
|
|
},
|
|
|
|
formatElapsed,
|
|
formatDuration,
|
|
formatTimestamp,
|
|
formatClock,
|
|
wordConfidenceClass,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Index-page Alpine component: inline audio player only.
|
|
* Status/progress refresh via wire:poll. Live transcript on the show page uses Echo.
|
|
*/
|
|
export function recordingsIndex() {
|
|
return {
|
|
playingId: null,
|
|
isPlaying: false,
|
|
|
|
start() {
|
|
//
|
|
},
|
|
|
|
destroy() {
|
|
const player = this.$refs.player;
|
|
|
|
if (player) {
|
|
player.pause();
|
|
player.removeAttribute('src');
|
|
player.load();
|
|
}
|
|
},
|
|
|
|
syncPlayer() {
|
|
const player = this.$refs.player;
|
|
|
|
this.isPlaying = Boolean(player && !player.paused && !player.ended);
|
|
|
|
if (player?.ended) {
|
|
this.playingId = null;
|
|
}
|
|
},
|
|
|
|
isPlayingRow(id) {
|
|
return this.playingId === id && this.isPlaying;
|
|
},
|
|
|
|
togglePlay(id, url) {
|
|
const player = this.$refs.player;
|
|
|
|
if (!player) {
|
|
return;
|
|
}
|
|
|
|
if (this.playingId === id && this.isPlaying) {
|
|
player.pause();
|
|
|
|
return;
|
|
}
|
|
|
|
if (this.playingId !== id) {
|
|
player.src = url;
|
|
this.playingId = id;
|
|
}
|
|
|
|
player.play().catch(() => {
|
|
this.playingId = null;
|
|
this.isPlaying = false;
|
|
});
|
|
},
|
|
};
|
|
}
|