Drop cloud and Ollama engine choices so audio stays on-machine via Docker Whisper, and tighten orphan detection plus UI around a single local flow.
299 lines
14 KiB
PHP
299 lines
14 KiB
PHP
@extends('layouts.app')
|
|
|
|
@section('title', $recording->title)
|
|
|
|
@section('content')
|
|
<div
|
|
x-data="transcriptionMonitor(@js([
|
|
'statusUrl' => route('recordings.transcription-status', $recording),
|
|
'initial' => $recording->transcriptionStatusPayload(),
|
|
]))"
|
|
x-init="start()"
|
|
>
|
|
<div class="mb-6 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
|
<div>
|
|
<a href="{{ route('recordings.index') }}" class="text-sm text-stone-500 hover:text-stone-800">← Recordings</a>
|
|
<h1 class="mt-2 text-2xl font-semibold tracking-tight">{{ $recording->title }}</h1>
|
|
<div class="mt-2 flex flex-wrap items-center gap-2 text-sm text-stone-600">
|
|
<span
|
|
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ring-1 ring-inset"
|
|
:class="badgeClass"
|
|
x-text="status.status"
|
|
></span>
|
|
<template x-if="status.driver_label">
|
|
<span class="text-xs text-stone-500" x-text="status.driver_label"></span>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
<form method="POST" action="{{ route('recordings.destroy', $recording) }}" onsubmit="return confirm('Delete this recording and its file?')">
|
|
@csrf
|
|
@method('DELETE')
|
|
<button type="submit" class="rounded border border-red-200 bg-white px-3 py-1.5 text-sm text-red-700 hover:bg-red-50">
|
|
Delete
|
|
</button>
|
|
</form>
|
|
</div>
|
|
|
|
<div class="grid gap-6 lg:grid-cols-2">
|
|
<section class="rounded border border-stone-200 bg-white p-6 shadow-sm">
|
|
<h2 class="text-sm font-semibold uppercase tracking-wide text-stone-500">Metadata</h2>
|
|
<dl class="mt-4 space-y-3 text-sm">
|
|
<div class="flex justify-between gap-4">
|
|
<dt class="text-stone-500">Original file</dt>
|
|
<dd class="text-right font-medium">{{ $recording->original_filename }}</dd>
|
|
</div>
|
|
<div class="flex justify-between gap-4">
|
|
<dt class="text-stone-500">Duration</dt>
|
|
<dd class="font-medium">{{ $recording->duration_formatted }}</dd>
|
|
</div>
|
|
<div class="flex justify-between gap-4">
|
|
<dt class="text-stone-500">Artist</dt>
|
|
<dd class="font-medium">{{ $recording->artist ?: '—' }}</dd>
|
|
</div>
|
|
<div class="flex justify-between gap-4">
|
|
<dt class="text-stone-500">Album</dt>
|
|
<dd class="font-medium">{{ $recording->album ?: '—' }}</dd>
|
|
</div>
|
|
<div class="flex justify-between gap-4">
|
|
<dt class="text-stone-500">Recorded</dt>
|
|
<dd class="font-medium">{{ $recording->recorded_at?->format('Y-m-d') ?: '—' }}</dd>
|
|
</div>
|
|
<div class="flex justify-between gap-4">
|
|
<dt class="text-stone-500">Size</dt>
|
|
<dd class="font-medium">{{ number_format($recording->file_size_bytes / 1024, 1) }} KB</dd>
|
|
</div>
|
|
<div class="flex justify-between gap-4">
|
|
<dt class="text-stone-500">Uploaded</dt>
|
|
<dd class="font-medium">{{ $recording->created_at?->format('Y-m-d H:i') }}</dd>
|
|
</div>
|
|
</dl>
|
|
</section>
|
|
|
|
<section class="rounded border border-stone-200 bg-white p-6 shadow-sm">
|
|
<h2 class="text-sm font-semibold uppercase tracking-wide text-stone-500">Transcribe</h2>
|
|
<p class="mt-2 text-sm text-stone-600">
|
|
Audio stays on this machine. Requires
|
|
<code class="text-[11px]">docker compose up -d whisper</code>
|
|
(port 8090).
|
|
</p>
|
|
|
|
<form method="POST" action="{{ route('recordings.transcribe', $recording) }}" class="mt-4">
|
|
@csrf
|
|
<button
|
|
type="submit"
|
|
class="rounded bg-teal-700 px-4 py-2 text-sm font-medium text-white hover:bg-teal-800"
|
|
>
|
|
<span x-text="startButtonLabel"></span>
|
|
</button>
|
|
</form>
|
|
|
|
<form
|
|
method="POST"
|
|
action="{{ route('recordings.transcribe.cancel', $recording) }}"
|
|
x-show="status.is_active"
|
|
x-cloak
|
|
class="mt-3"
|
|
>
|
|
@csrf
|
|
<button
|
|
type="submit"
|
|
class="rounded border border-stone-300 bg-white px-4 py-2 text-sm font-medium text-stone-800 hover:bg-stone-50"
|
|
>
|
|
Stop transcription
|
|
</button>
|
|
</form>
|
|
</section>
|
|
</div>
|
|
|
|
<section class="mt-6 rounded border border-stone-200 bg-white p-6 shadow-sm">
|
|
<div class="flex items-center justify-between gap-4">
|
|
<h2 class="text-sm font-semibold uppercase tracking-wide text-stone-500">Transcript</h2>
|
|
<button
|
|
type="button"
|
|
x-show="!status.is_active && status.has_transcript"
|
|
x-cloak
|
|
@click="navigator.clipboard.writeText(status.transcript || '')"
|
|
class="text-sm text-teal-700 hover:underline"
|
|
>
|
|
Copy
|
|
</button>
|
|
</div>
|
|
|
|
<div x-show="status.is_active" x-cloak class="mt-4 space-y-3 rounded border border-amber-200 bg-amber-50 p-4">
|
|
<div class="flex flex-wrap items-center justify-between gap-2 text-sm">
|
|
<p class="font-medium text-amber-900" x-text="status.progress || 'Working…'"></p>
|
|
<p class="text-amber-800 tabular-nums">
|
|
<span x-text="(status.percent ?? 0) + '%'"></span>
|
|
<span class="mx-1 text-amber-600">·</span>
|
|
<span x-text="'Elapsed ' + (status.elapsed_human || '0s')"></span>
|
|
</p>
|
|
</div>
|
|
|
|
<div class="h-2 overflow-hidden rounded-full bg-amber-100">
|
|
<div
|
|
class="h-full rounded-full bg-amber-500 transition-all duration-500"
|
|
:style="`width: ${Math.max(status.percent || 5, 5)}%`"
|
|
></div>
|
|
</div>
|
|
|
|
<ul class="space-y-1 text-xs text-amber-900/80">
|
|
<li>
|
|
Engine:
|
|
<span class="font-medium" x-text="status.driver_label || '—'"></span>
|
|
</li>
|
|
<template x-if="status.duration_seconds">
|
|
<li>
|
|
Audio length:
|
|
<span class="font-medium" x-text="formatDuration(status.duration_seconds)"></span>
|
|
<span class="text-amber-700">(longer files take longer)</span>
|
|
</li>
|
|
</template>
|
|
<li x-show="pollError" class="text-red-700" x-text="pollError"></li>
|
|
</ul>
|
|
</div>
|
|
|
|
<div x-show="!status.is_active && status.status === 'cancelled'" x-cloak class="mt-4 rounded border border-stone-200 bg-stone-50 p-4 text-sm text-stone-700">
|
|
Transcription stopped. Choose an engine above to start again.
|
|
</div>
|
|
|
|
<div x-show="!status.is_active && status.status === 'failed'" x-cloak class="mt-4 space-y-2 rounded border border-red-200 bg-red-50 p-4 text-sm text-red-800">
|
|
<p class="font-medium">Transcription failed</p>
|
|
<p x-text="status.error || 'Check the logs and try again with another engine.'"></p>
|
|
</div>
|
|
|
|
<div x-show="!status.is_active && status.has_transcript" x-cloak>
|
|
<p class="mt-4 whitespace-pre-wrap text-sm leading-relaxed text-stone-800" x-text="status.transcript"></p>
|
|
<p
|
|
class="mt-4 text-xs text-stone-500"
|
|
x-show="status.transcribed_at"
|
|
x-text="status.transcribed_at ? ('Transcribed ' + formatTimestamp(status.transcribed_at)) : ''"
|
|
></p>
|
|
</div>
|
|
|
|
<p
|
|
x-show="!status.is_active && status.status !== 'failed' && status.status !== 'cancelled' && !status.has_transcript"
|
|
x-cloak
|
|
class="mt-4 text-sm text-stone-500"
|
|
>
|
|
No transcript yet. Choose an engine above to start.
|
|
</p>
|
|
</section>
|
|
</div>
|
|
|
|
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
|
<style>[x-cloak]{display:none!important}</style>
|
|
<script>
|
|
function transcriptionMonitor({ statusUrl, initial }) {
|
|
return {
|
|
statusUrl,
|
|
status: initial,
|
|
pollError: null,
|
|
timer: null,
|
|
tickTimer: null,
|
|
|
|
get badgeClass() {
|
|
const map = {
|
|
done: 'bg-teal-50 text-teal-800 ring-teal-600/20',
|
|
processing: 'bg-amber-50 text-amber-800 ring-amber-600/20',
|
|
pending: 'bg-amber-50 text-amber-800 ring-amber-600/20',
|
|
failed: 'bg-red-50 text-red-800 ring-red-600/20',
|
|
cancelled: 'bg-stone-100 text-stone-700 ring-stone-500/20',
|
|
};
|
|
|
|
return map[this.status.status] || 'bg-stone-100 text-stone-700 ring-stone-500/20';
|
|
},
|
|
|
|
get startButtonLabel() {
|
|
if (this.status.is_active) {
|
|
return 'Restart transcription';
|
|
}
|
|
|
|
return this.status.has_transcript ? 'Re-transcribe' : 'Start transcription';
|
|
},
|
|
|
|
start() {
|
|
if (this.status.is_active) {
|
|
this.beginPolling();
|
|
}
|
|
},
|
|
|
|
beginPolling() {
|
|
this.stopPolling();
|
|
this.poll();
|
|
this.timer = setInterval(() => this.poll(), 1500);
|
|
this.tickTimer = setInterval(() => this.tickElapsed(), 1000);
|
|
},
|
|
|
|
stopPolling() {
|
|
if (this.timer) clearInterval(this.timer);
|
|
if (this.tickTimer) clearInterval(this.tickTimer);
|
|
this.timer = null;
|
|
this.tickTimer = null;
|
|
},
|
|
|
|
tickElapsed() {
|
|
if (!this.status.is_active || this.status.elapsed_seconds == null) return;
|
|
this.status.elapsed_seconds += 1;
|
|
this.status.elapsed_human = this.formatElapsed(this.status.elapsed_seconds);
|
|
},
|
|
|
|
async poll() {
|
|
try {
|
|
const response = await fetch(this.statusUrl, {
|
|
headers: { Accept: 'application/json' },
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error('Status request failed (' + response.status + ')');
|
|
}
|
|
|
|
const wasActive = this.status.is_active;
|
|
this.status = await response.json();
|
|
this.pollError = null;
|
|
|
|
if (this.status.is_active && !this.timer) {
|
|
this.beginPolling();
|
|
}
|
|
|
|
if (wasActive && !this.status.is_active) {
|
|
this.stopPolling();
|
|
if (this.status.has_transcript || this.status.status === 'failed' || this.status.status === 'cancelled') {
|
|
return;
|
|
}
|
|
window.location.reload();
|
|
}
|
|
} catch (error) {
|
|
this.pollError = error.message || 'Could not refresh progress.';
|
|
}
|
|
},
|
|
|
|
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';
|
|
},
|
|
|
|
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');
|
|
},
|
|
|
|
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());
|
|
},
|
|
};
|
|
}
|
|
</script>
|
|
@endsection
|