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.
334 lines
15 KiB
PHP
334 lines
15 KiB
PHP
<div
|
||
@if ($recording->transcription_status === 'pending')
|
||
wire:poll.2s.visible
|
||
@endif
|
||
>
|
||
<div
|
||
wire:ignore.self
|
||
wire:key="transcription-ui-{{ $recording->id }}-{{ $recording->transcription_status }}-{{ $recording->transcribed_at?->timestamp }}"
|
||
x-data="transcriptionMonitor(@js([
|
||
'statusUrl' => route('recordings.transcription-status', $recording),
|
||
'userId' => (int) $recording->user_id,
|
||
'initial' => $recording->transcriptionStatusPayload(),
|
||
]))"
|
||
x-init="
|
||
start();
|
||
return () => destroy();
|
||
"
|
||
>
|
||
<div class="mb-6 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||
<div>
|
||
<flux:link href="{{ route('recordings.index') }}" wire:navigate class="text-sm">← Recordings</flux:link>
|
||
<flux:heading size="xl" class="mt-2">{{ $recording->title }}</flux:heading>
|
||
<div class="mt-2 flex flex-wrap items-center gap-2">
|
||
<x-transcription-status-badge
|
||
:status="$recording->transcription_status"
|
||
:label="$recording->transcriptionStatusLabel()"
|
||
alpine-row="status"
|
||
/>
|
||
<flux:text
|
||
class="text-xs"
|
||
x-show="status.driver_label"
|
||
x-cloak
|
||
x-text="status.driver_label"
|
||
></flux:text>
|
||
</div>
|
||
</div>
|
||
<div class="flex flex-wrap items-center gap-2">
|
||
<flux:button
|
||
type="button"
|
||
variant="primary"
|
||
icon="play"
|
||
@click="$refs.player.paused ? $refs.player.play() : $refs.player.pause()"
|
||
>
|
||
Play
|
||
</flux:button>
|
||
|
||
<flux:modal.trigger name="delete-recording">
|
||
<flux:button type="button" variant="danger">Delete</flux:button>
|
||
</flux:modal.trigger>
|
||
|
||
<flux:modal name="delete-recording" class="max-w-md">
|
||
<div class="space-y-6">
|
||
<div>
|
||
<flux:heading size="lg">Delete recording?</flux:heading>
|
||
<flux:text class="mt-2">
|
||
This permanently removes the recording and its audio file.
|
||
</flux:text>
|
||
</div>
|
||
<div class="flex justify-end gap-2">
|
||
<flux:modal.close>
|
||
<flux:button variant="ghost">Cancel</flux:button>
|
||
</flux:modal.close>
|
||
<flux:button type="button" variant="danger" wire:click="delete">Delete</flux:button>
|
||
</div>
|
||
</div>
|
||
</flux:modal>
|
||
</div>
|
||
</div>
|
||
|
||
<flux:card class="mb-6">
|
||
<flux:heading size="sm" class="uppercase tracking-wide text-zinc-500 dark:text-zinc-400">Audio</flux:heading>
|
||
<audio
|
||
x-ref="player"
|
||
class="mt-4 w-full"
|
||
controls
|
||
preload="metadata"
|
||
src="{{ route('recordings.audio', $recording) }}"
|
||
>
|
||
Your browser does not support audio playback.
|
||
</audio>
|
||
</flux:card>
|
||
|
||
<div class="grid gap-6 lg:grid-cols-2">
|
||
<flux:card>
|
||
<flux:heading size="sm" class="uppercase tracking-wide text-zinc-500 dark:text-zinc-400">Metadata</flux:heading>
|
||
<dl class="mt-4 space-y-3 text-sm">
|
||
<div class="flex justify-between gap-4">
|
||
<dt><flux:text>Original file</flux:text></dt>
|
||
<dd class="text-right font-medium">{{ $recording->original_filename }}</dd>
|
||
</div>
|
||
<div class="flex justify-between gap-4">
|
||
<dt><flux:text>Duration</flux:text></dt>
|
||
<dd class="font-medium">{{ $recording->duration_formatted }}</dd>
|
||
</div>
|
||
<div class="flex justify-between gap-4">
|
||
<dt><flux:text>Artist</flux:text></dt>
|
||
<dd class="font-medium">{{ $recording->artist ?: '—' }}</dd>
|
||
</div>
|
||
<div class="flex justify-between gap-4">
|
||
<dt><flux:text>Album</flux:text></dt>
|
||
<dd class="font-medium">{{ $recording->album ?: '—' }}</dd>
|
||
</div>
|
||
<div class="flex justify-between gap-4">
|
||
<dt><flux:text>Recorded</flux:text></dt>
|
||
<dd class="font-medium">{{ $recording->recorded_at?->format('Y-m-d') ?: '—' }}</dd>
|
||
</div>
|
||
<div class="flex justify-between gap-4">
|
||
<dt><flux:text>Size</flux:text></dt>
|
||
<dd class="font-medium">{{ number_format($recording->file_size_bytes / 1024, 1) }} KB</dd>
|
||
</div>
|
||
<div class="flex justify-between gap-4">
|
||
<dt><flux:text>Uploaded</flux:text></dt>
|
||
<dd class="font-medium">{{ $recording->created_at?->format('Y-m-d H:i') }}</dd>
|
||
</div>
|
||
</dl>
|
||
</flux:card>
|
||
|
||
<flux:card>
|
||
<flux:heading size="sm" class="uppercase tracking-wide text-zinc-500 dark:text-zinc-400">Transcribe</flux:heading>
|
||
<div class="mt-4">
|
||
<flux:button type="button" variant="primary" wire:click="startTranscription">
|
||
<span x-text="startButtonLabel"></span>
|
||
</flux:button>
|
||
</div>
|
||
|
||
<div
|
||
x-show="status.is_active"
|
||
x-cloak
|
||
class="mt-3"
|
||
>
|
||
<flux:button type="button" variant="outline" wire:click="cancelTranscription">
|
||
Stop transcription
|
||
</flux:button>
|
||
</div>
|
||
</flux:card>
|
||
</div>
|
||
|
||
<flux:card class="mt-6">
|
||
<div class="flex items-center justify-between gap-4">
|
||
<flux:heading size="sm" class="uppercase tracking-wide text-zinc-500 dark:text-zinc-400">Transcript</flux:heading>
|
||
<flux:button
|
||
type="button"
|
||
variant="ghost"
|
||
size="sm"
|
||
x-show="!status.is_active && status.has_transcript"
|
||
x-cloak
|
||
@click="navigator.clipboard.writeText(status.transcript || '')"
|
||
>
|
||
Copy
|
||
</flux:button>
|
||
</div>
|
||
|
||
<div x-show="status.is_active" x-cloak class="mt-4">
|
||
<flux:callout variant="warning" icon="arrow-path">
|
||
<flux:callout.heading>
|
||
<span class="inline-flex items-center gap-2">
|
||
<flux:icon.loading
|
||
variant="micro"
|
||
class="size-4"
|
||
x-show="status.status === 'processing'"
|
||
x-cloak
|
||
/>
|
||
<span x-text="status.progress || 'Working…'"></span>
|
||
</span>
|
||
</flux:callout.heading>
|
||
<flux:callout.text>
|
||
<span
|
||
class="tabular-nums"
|
||
x-show="status.status === 'processing' && Number(status.percent) > 0"
|
||
x-cloak
|
||
>
|
||
<span x-text="status.percent + '%'"></span>
|
||
·
|
||
</span>
|
||
Elapsed <span x-text="status.elapsed_human || '0s'"></span>
|
||
</flux:callout.text>
|
||
</flux:callout>
|
||
|
||
<div class="mt-3" x-show="status.status === 'processing' && Number(status.percent) > 0" x-cloak>
|
||
<flux:progress color="amber" x-bind:value="status.percent || 0" />
|
||
</div>
|
||
|
||
<div
|
||
wire:ignore
|
||
class="mt-4 whitespace-pre-wrap text-sm leading-relaxed text-zinc-800 dark:text-zinc-100"
|
||
x-show="status.transcript && !whisper.segments.length"
|
||
x-text="status.transcript"
|
||
></div>
|
||
|
||
<ul class="mt-3 space-y-1 text-xs text-zinc-600 dark:text-zinc-400">
|
||
<li>
|
||
Engine:
|
||
<span class="font-medium" x-text="status.driver_label || '—'"></span>
|
||
</li>
|
||
<li x-show="whisper.language">
|
||
Detected language:
|
||
<span class="font-medium uppercase" x-text="whisper.language"></span>
|
||
</li>
|
||
<li x-show="whisper.duration">
|
||
Whisper duration:
|
||
<span class="font-medium" x-text="formatClock(whisper.duration)"></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-zinc-500">(longer files take longer)</span>
|
||
</li>
|
||
</template>
|
||
<li x-show="pollError" class="text-red-600 dark:text-red-400" x-text="pollError"></li>
|
||
</ul>
|
||
</div>
|
||
|
||
<div x-show="!status.is_active && status.status === 'cancelled'" x-cloak class="mt-4">
|
||
<flux:callout icon="stop-circle">
|
||
<flux:callout.text>
|
||
Transcription stopped. Use the button above to start again.
|
||
</flux:callout.text>
|
||
</flux:callout>
|
||
</div>
|
||
|
||
<div x-show="!status.is_active && status.status === 'failed'" x-cloak class="mt-4">
|
||
<flux:callout variant="danger" icon="exclamation-triangle">
|
||
<flux:callout.heading>Transcription failed</flux:callout.heading>
|
||
<flux:callout.text>
|
||
<span x-text="status.error || 'Check the logs and try again.'"></span>
|
||
</flux:callout.text>
|
||
</flux:callout>
|
||
</div>
|
||
|
||
<div x-show="!status.is_active && status.has_transcript" x-cloak>
|
||
<p
|
||
class="mt-4 whitespace-pre-wrap text-sm leading-relaxed text-zinc-800 dark:text-zinc-100"
|
||
x-show="!whisper.segments.length"
|
||
x-text="status.transcript"
|
||
></p>
|
||
<flux:text
|
||
class="mt-4 text-xs"
|
||
x-show="status.transcribed_at"
|
||
x-text="status.transcribed_at
|
||
? ('Transcribed ' + formatTimestamp(status.transcribed_at)
|
||
+ (status.transcription_duration_human ? (' · took ' + status.transcription_duration_human) : ''))
|
||
: ''"
|
||
></flux:text>
|
||
</div>
|
||
|
||
<div
|
||
wire:ignore
|
||
class="mt-4 space-y-4"
|
||
x-show="whisper.language || whisper.segments.length || whisper.logprobs.length"
|
||
>
|
||
<div
|
||
class="flex flex-wrap gap-2 text-xs text-zinc-600 dark:text-zinc-400"
|
||
x-show="!status.is_active && (whisper.language || whisper.duration)"
|
||
>
|
||
<span x-show="whisper.language">
|
||
Language
|
||
<span class="font-medium uppercase text-zinc-800 dark:text-zinc-100" x-text="whisper.language"></span>
|
||
</span>
|
||
<span x-show="whisper.duration">
|
||
· Duration
|
||
<span class="font-medium text-zinc-800 dark:text-zinc-100" x-text="formatClock(whisper.duration)"></span>
|
||
</span>
|
||
</div>
|
||
|
||
<template x-for="(segment, index) in whisper.segments" :key="segment.id ?? (segment.start + '-' + index)">
|
||
<div class="rounded-lg border border-zinc-200 p-3 dark:border-zinc-700">
|
||
<div class="flex flex-wrap items-baseline justify-between gap-2 text-xs text-zinc-500 dark:text-zinc-400">
|
||
<span class="tabular-nums">
|
||
<span x-text="formatClock(segment.start)"></span>
|
||
–
|
||
<span x-text="formatClock(segment.end)"></span>
|
||
</span>
|
||
<span x-show="segment.avg_logprob != null">
|
||
avg logprob
|
||
<span class="font-medium text-zinc-700 dark:text-zinc-200" x-text="Number(segment.avg_logprob).toFixed(3)"></span>
|
||
</span>
|
||
</div>
|
||
|
||
<p class="mt-2 text-sm leading-relaxed text-zinc-800 dark:text-zinc-100" x-text="segment.text"></p>
|
||
|
||
<div class="mt-2 flex flex-wrap gap-1" x-show="segment.words && segment.words.length">
|
||
<template x-for="(word, wordIndex) in (segment.words || [])" :key="wordIndex">
|
||
<button
|
||
type="button"
|
||
class="rounded px-1.5 py-0.5 text-xs tabular-nums"
|
||
:class="wordConfidenceClass(word.probability)"
|
||
:title="(word.start != null ? formatClock(word.start) : '') + (word.probability != null ? (' · ' + Math.round(word.probability * 100) + '%') : '')"
|
||
@click="seekTo(word.start)"
|
||
x-text="word.word"
|
||
></button>
|
||
</template>
|
||
</div>
|
||
|
||
<details class="mt-2 text-xs text-zinc-500 dark:text-zinc-400" x-show="segment.tokens && segment.tokens.length">
|
||
<summary class="cursor-pointer select-none">Tokens</summary>
|
||
<p class="mt-1 break-all font-mono" x-text="(segment.tokens || []).join(' ')"></p>
|
||
</details>
|
||
|
||
<p class="mt-1 text-xs text-zinc-500 dark:text-zinc-400" x-show="segment.no_speech_prob != null">
|
||
no-speech
|
||
<span class="font-medium" x-text="Number(segment.no_speech_prob).toFixed(3)"></span>
|
||
<span x-show="segment.compression_ratio != null">
|
||
· compression
|
||
<span class="font-medium" x-text="Number(segment.compression_ratio).toFixed(2)"></span>
|
||
</span>
|
||
</p>
|
||
</div>
|
||
</template>
|
||
|
||
<details class="text-xs text-zinc-500 dark:text-zinc-400" x-show="whisper.logprobs.length">
|
||
<summary class="cursor-pointer select-none">Token logprobs</summary>
|
||
<ul class="mt-2 space-y-1 font-mono">
|
||
<template x-for="(row, index) in whisper.logprobs" :key="index">
|
||
<li>
|
||
<span x-text="row.token || '—'"></span>
|
||
<span x-show="row.logprob != null" x-text="' ' + Number(row.logprob).toFixed(3)"></span>
|
||
</li>
|
||
</template>
|
||
</ul>
|
||
</details>
|
||
</div>
|
||
|
||
<flux:text
|
||
x-show="!status.is_active && status.status !== 'failed' && status.status !== 'cancelled' && !status.has_transcript"
|
||
x-cloak
|
||
class="mt-4"
|
||
>
|
||
No transcript yet. Transcription starts automatically after upload, or use the button above.
|
||
</flux:text>
|
||
</flux:card>
|
||
</div>
|
||
</div>
|