/** * 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; }); }, }; }