Auto-queue transcription on upload and clarify pending status.

Batch uploads were only storing files as pending without dispatching Whisper jobs; queue them immediately, add a bulk pending action, and show human-readable status labels.
This commit is contained in:
ben
2026-08-12 16:26:36 +02:00
parent 3498861184
commit 1dcdfc0ed0
12 changed files with 525 additions and 95 deletions
+19 -4
View File
@@ -14,6 +14,8 @@ Built with Laravel 13, Blade, Tailwind CSS 4, and [Laravel AI](https://github.co
- Stop or restart a run anytime - Stop or restart a run anytime
- Copy finished transcripts from the recording detail page - Copy finished transcripts from the recording detail page
## Requirements ## Requirements
- PHP 8.3+ (8.5 recommended) - PHP 8.3+ (8.5 recommended)
@@ -22,6 +24,8 @@ Built with Laravel 13, Blade, Tailwind CSS 4, and [Laravel AI](https://github.co
- SQLite (default) or another supported database - SQLite (default) or another supported database
- [Docker](https://docs.docker.com/get-docker/) for the Whisper container - [Docker](https://docs.docker.com/get-docker/) for the Whisper container
## Setup ## Setup
```bash ```bash
@@ -42,6 +46,8 @@ npm install
npm run build npm run build
``` ```
## Local Whisper (Docker) ## Local Whisper (Docker)
Transcription calls an OpenAI-compatible HTTP API. This project ships Compose for that: Transcription calls an OpenAI-compatible HTTP API. This project ships Compose for that:
@@ -70,12 +76,15 @@ Stop:
docker compose down docker compose down
``` ```
## Configuration ## Configuration
Copy values from `.env.example`. The transcription-related settings are: Copy values from `.env.example`. The transcription-related settings are:
| Variable | Purpose | | Variable | Purpose |
| --- | --- | | ----------------------- | -------------------------------------------------------------------------------- |
| `LOCAL_WHISPER_URL` | Local faster-whisper base URL (default `http://127.0.0.1:8090/v1`) | | `LOCAL_WHISPER_URL` | Local faster-whisper base URL (default `http://127.0.0.1:8090/v1`) |
| `LOCAL_WHISPER_API_KEY` | API key for local server (often unused) | | `LOCAL_WHISPER_API_KEY` | API key for local server (often unused) |
| `LOCAL_WHISPER_MODEL` | Model name for local transcription | | `LOCAL_WHISPER_MODEL` | Model name for local transcription |
@@ -84,6 +93,7 @@ Copy values from `.env.example`. The transcription-related settings are:
| `DB_QUEUE_RETRY_AFTER` | Database queue retry window; must exceed `TRANSCRIPTION_TIMEOUT` (default `660`) | | `DB_QUEUE_RETRY_AFTER` | Database queue retry window; must exceed `TRANSCRIPTION_TIMEOUT` (default `660`) |
| `QUEUE_CONNECTION` | Use `database` (default) so transcription runs in the background | | `QUEUE_CONNECTION` | Use `database` (default) so transcription runs in the background |
Finished transcripts are stored on the recording (`transcript` column) and are included in the recordings search box (title, artist, album, filename, and transcript). Finished transcripts are stored on the recording (`transcript` column) and are included in the recordings search box (title, artist, album, filename, and transcript).
Ensure `APP_URL` matches how you access the app (default `http://localhost:8000`). Ensure `APP_URL` matches how you access the app (default `http://localhost:8000`).
@@ -112,10 +122,13 @@ Transcription jobs are queued — keep a queue worker running or jobs will stay
## Usage ## Usage
1. **Upload** audio from Recordings → Upload (optional title override). 1. **Upload** audio from Recordings → Upload (single file or batch dropzone).
2. Open the recording and start transcription. 2. Transcription queues automatically — keep `php artisan queue:work` running.
3. Watch live progress until the transcript appears (or stop and restart). 3. Watch live progress on the recording page (or stop and restart).
4. Search the list by title, artist, or transcript text. 4. Search the list by title, artist, or transcript text.
5. If older uploads still show **Queued** with no progress, use **Queue pending transcriptions** on the recordings list.
## Tests ## Tests
@@ -125,6 +138,8 @@ composer test
php artisan test php artisan test
``` ```
## License ## License
MIT MIT
+64 -20
View File
@@ -7,6 +7,7 @@ use App\Models\Recording;
use App\Services\Mp3MetadataService; use App\Services\Mp3MetadataService;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Illuminate\View\View; use Illuminate\View\View;
@@ -30,9 +31,16 @@ class RecordingController extends Controller
$recordings = $query->paginate(20)->withQueryString(); $recordings = $query->paginate(20)->withQueryString();
$pendingCount = Recording::query()
->whereIn('transcription_status', ['pending', 'failed', 'cancelled'])
->get()
->filter(fn (Recording $recording) => ! $recording->hasActiveTranscriptionJob())
->count();
return view('recordings.index', [ return view('recordings.index', [
'recordings' => $recordings, 'recordings' => $recordings,
'search' => $search ?? '', 'search' => $search ?? '',
'pendingCount' => $pendingCount,
]); ]);
} }
@@ -45,33 +53,36 @@ class RecordingController extends Controller
} }
/** /**
* Store a newly uploaded recording. * Store one or more uploaded recordings.
*/ */
public function store(StoreRecordingRequest $request, Mp3MetadataService $metadata): RedirectResponse public function store(StoreRecordingRequest $request, Mp3MetadataService $metadata): RedirectResponse
{ {
$file = $request->file('audio'); /** @var list<UploadedFile> $files */
$path = $file->store('recordings', 'local'); $files = array_values(array_filter(
$absolutePath = Storage::disk('local')->path($path); $request->file('audio', []),
$tags = $metadata->extract($absolutePath); fn ($file) => $file instanceof UploadedFile,
));
$title = $request->string('title')->trim()->toString() $titleOverride = $request->string('title')->trim()->toString();
?: ($tags['title'] ?? pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME)); $recordings = [];
$recording = Recording::create([ foreach ($files as $file) {
'title' => $title, $title = count($files) === 1 && $titleOverride !== ''
'original_filename' => $file->getClientOriginalName(), ? $titleOverride
'file_path' => $path, : null;
'duration_seconds' => $tags['duration_seconds'],
'recorded_at' => $tags['recorded_at'], $recordings[] = $this->storeUploadedRecording($file, $metadata, $title);
'artist' => $tags['artist'], }
'album' => $tags['album'],
'file_size_bytes' => $file->getSize() ?: 0, if (count($recordings) === 1) {
'transcription_status' => 'pending', return redirect()
]); ->route('recordings.show', $recordings[0])
->with('success', 'Recording uploaded — transcription queued.');
}
return redirect() return redirect()
->route('recordings.show', $recording) ->route('recordings.index')
->with('success', 'Recording uploaded successfully.'); ->with('success', count($recordings).' recordings uploaded — transcription queued.');
} }
/** /**
@@ -97,4 +108,37 @@ class RecordingController extends Controller
->route('recordings.index') ->route('recordings.index')
->with('success', 'Recording deleted.'); ->with('success', 'Recording deleted.');
} }
/**
* Persist a single uploaded audio file as a recording.
*/
private function storeUploadedRecording(
UploadedFile $file,
Mp3MetadataService $metadata,
?string $titleOverride = null,
): Recording {
$path = $file->store('recordings', 'local');
$absolutePath = Storage::disk('local')->path($path);
$tags = $metadata->extract($absolutePath);
$title = $titleOverride
?: ($tags['title'] ?? pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME));
$recording = Recording::create([
'title' => $title,
'original_filename' => $file->getClientOriginalName(),
'file_path' => $path,
'duration_seconds' => $tags['duration_seconds'],
'recorded_at' => $tags['recorded_at'],
'artist' => $tags['artist'],
'album' => $tags['album'],
'file_size_bytes' => $file->getSize() ?: 0,
'transcription_status' => 'pending',
'transcription_driver' => 'local',
]);
$recording->queueLocalTranscription();
return $recording->fresh();
}
} }
+1 -19
View File
@@ -3,7 +3,6 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Http\Requests\TranscribeRecordingRequest; use App\Http\Requests\TranscribeRecordingRequest;
use App\Jobs\TranscribeRecording;
use App\Models\Recording; use App\Models\Recording;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
@@ -16,24 +15,7 @@ class TranscribeController extends Controller
*/ */
public function __invoke(TranscribeRecordingRequest $request, Recording $recording): RedirectResponse public function __invoke(TranscribeRecordingRequest $request, Recording $recording): RedirectResponse
{ {
if ($recording->isTranscribing() || $recording->hasActiveTranscriptionJob()) { $recording->queueLocalTranscription();
$recording->cancelTranscription(silent: true);
$recording->refresh();
}
$recording->update([
'transcription_driver' => 'local',
'ollama_url' => null,
'transcription_status' => 'pending',
'transcription_progress' => 'Queued — waiting to start…',
'transcription_percent' => 5,
'transcription_started_at' => now(),
'transcription_error' => null,
// Keep the previous transcript until a new run succeeds.
'transcribed_at' => $recording->transcribed_at,
]);
TranscribeRecording::dispatch($recording->fresh());
return back()->with('success', 'Transcription started. Progress updates below.'); return back()->with('success', 'Transcription started. Progress updates below.');
} }
@@ -0,0 +1,40 @@
<?php
namespace App\Http\Controllers;
use App\Models\Recording;
use Illuminate\Http\RedirectResponse;
class TranscribePendingController extends Controller
{
/**
* Queue local transcription for recordings that still need a transcript.
*/
public function __invoke(): RedirectResponse
{
$queued = 0;
Recording::query()
->whereIn('transcription_status', ['pending', 'failed', 'cancelled'])
->orderBy('id')
->each(function (Recording $recording) use (&$queued): void {
if ($recording->hasActiveTranscriptionJob()) {
return;
}
$recording->queueLocalTranscription();
$queued++;
});
if ($queued === 0) {
return back()->with('error', 'No recordings need transcription right now.');
}
return back()->with(
'success',
$queued === 1
? 'Queued 1 recording for transcription.'
: "Queued {$queued} recordings for transcription.",
);
}
}
+19 -4
View File
@@ -3,6 +3,7 @@
namespace App\Http\Requests; namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Http\UploadedFile;
use Illuminate\Validation\Rules\File; use Illuminate\Validation\Rules\File;
class StoreRecordingRequest extends FormRequest class StoreRecordingRequest extends FormRequest
@@ -34,13 +35,24 @@ class StoreRecordingRequest extends FormRequest
return true; return true;
} }
/**
* Normalize a single file upload into an array for batch handling.
*/
protected function prepareForValidation(): void
{
if ($this->hasFile('audio') && $this->file('audio') instanceof UploadedFile) {
$this->files->set('audio', [$this->file('audio')]);
}
}
/** /**
* @return array<string, mixed> * @return array<string, mixed>
*/ */
public function rules(): array public function rules(): array
{ {
return [ return [
'audio' => [ 'audio' => ['required', 'array', 'min:1', 'max:50'],
'audio.*' => [
'required', 'required',
File::types(self::AUDIO_EXTENSIONS)->max(102400), File::types(self::AUDIO_EXTENSIONS)->max(102400),
], ],
@@ -54,9 +66,12 @@ class StoreRecordingRequest extends FormRequest
public function messages(): array public function messages(): array
{ {
return [ return [
'audio.required' => 'Please choose an audio file to upload.', 'audio.required' => 'Please choose at least one audio file to upload.',
'audio' => 'Unsupported audio type. Use MP3, WAV, OGG, FLAC, M4A, AAC, WebM, WMA, or AIFF.', 'audio.min' => 'Please choose at least one audio file to upload.',
'audio.max' => 'The audio file may not be larger than 100 MB.', 'audio.max' => 'You can upload at most 50 files at once.',
'audio.*.required' => 'Please choose an audio file to upload.',
'audio.*' => 'Unsupported audio type. Use MP3, WAV, OGG, FLAC, M4A, AAC, WebM, WMA, or AIFF.',
'audio.*.max' => 'Each audio file may not be larger than 100 MB.',
]; ];
} }
} }
+56
View File
@@ -71,6 +71,20 @@ class Recording extends Model
}); });
} }
/**
* Word count of the stored transcript (0 when empty).
*/
protected function wordCount(): Attribute
{
return Attribute::get(function (): int {
if (! filled($this->transcript)) {
return 0;
}
return count(preg_split('/\s+/u', trim($this->transcript), -1, PREG_SPLIT_NO_EMPTY) ?: []);
});
}
/** /**
* Friendly label for the selected transcription engine. * Friendly label for the selected transcription engine.
*/ */
@@ -92,6 +106,47 @@ class Recording extends Model
return in_array($this->transcription_status, ['pending', 'processing'], true); return in_array($this->transcription_status, ['pending', 'processing'], true);
} }
/**
* Queue a new local faster-whisper transcription run.
*/
public function queueLocalTranscription(): void
{
// Only stop a real in-flight/queued run — bare "pending" uploads have no job yet.
if ($this->transcription_status === 'processing' || $this->hasActiveTranscriptionJob()) {
$this->cancelTranscription(silent: true);
$this->refresh();
}
$this->update([
'transcription_driver' => 'local',
'ollama_url' => null,
'transcription_status' => 'pending',
'transcription_progress' => 'Queued — waiting to start…',
'transcription_percent' => 5,
'transcription_started_at' => now(),
'transcription_error' => null,
// Keep the previous transcript until a new run succeeds.
'transcribed_at' => $this->transcribed_at,
]);
TranscribeRecording::dispatch($this->fresh());
}
/**
* Human-readable transcription status for badges.
*/
public function transcriptionStatusLabel(): string
{
return match ($this->transcription_status) {
'pending' => 'Queued',
'processing' => 'Transcribing',
'done' => 'Done',
'failed' => 'Failed',
'cancelled' => 'Cancelled',
default => (string) $this->transcription_status,
};
}
/** /**
* Search title, metadata, and stored transcript text. * Search title, metadata, and stored transcript text.
*/ */
@@ -383,6 +438,7 @@ class Recording extends Model
return [ return [
'id' => $this->id, 'id' => $this->id,
'status' => $this->transcription_status, 'status' => $this->transcription_status,
'status_label' => $this->transcriptionStatusLabel(),
'progress' => $this->transcription_progress, 'progress' => $this->transcription_progress,
'percent' => $this->transcription_percent, 'percent' => $this->transcription_percent,
'driver' => $this->transcription_driver, 'driver' => $this->transcription_driver,
+184 -15
View File
@@ -4,32 +4,82 @@
@section('content') @section('content')
<div class="mb-8"> <div class="mb-8">
<h1 class="text-2xl font-semibold tracking-tight">Upload recording</h1> <h1 class="text-2xl font-semibold tracking-tight">Upload recordings</h1>
<p class="mt-1 text-sm text-stone-600">Upload audio from your pocket recorder. Embedded metadata is extracted when available.</p> <p class="mt-1 text-sm text-stone-600">
Drop one or many pocket-recorder files. Embedded metadata is extracted when available.
</p>
</div> </div>
<form <form
method="POST" method="POST"
action="{{ route('recordings.store') }}" action="{{ route('recordings.store') }}"
enctype="multipart/form-data" enctype="multipart/form-data"
class="max-w-xl space-y-6 rounded border border-stone-200 bg-white p-6 shadow-sm" x-data="uploadDropzone()"
@submit="ensureFilesSelected($event)"
class="max-w-2xl space-y-6 rounded border border-stone-200 bg-white p-6 shadow-sm"
> >
@csrf @csrf
<div> <div>
<label for="audio" class="block text-sm font-medium text-stone-700">Audio file</label> <label class="block text-sm font-medium text-stone-700">Audio files</label>
<input
id="audio" <div
type="file" @dragenter.prevent="dragging = true"
name="audio" @dragover.prevent="dragging = true"
accept=".mp3,.wav,.ogg,.oga,.flac,.m4a,.mp4,.aac,.webm,.wma,.aiff,.aif,audio/*" @dragleave.prevent="dragging = false"
required @drop.prevent="onDrop($event)"
class="mt-2 block w-full text-sm text-stone-600 file:mr-4 file:rounded file:border-0 file:bg-teal-50 file:px-3 file:py-2 file:text-sm file:font-medium file:text-teal-800 hover:file:bg-teal-100" @click="$refs.fileInput.click()"
:class="dragging ? 'border-teal-600 bg-teal-50' : 'border-stone-300 bg-stone-50 hover:border-teal-500 hover:bg-teal-50/40'"
class="mt-2 flex cursor-pointer flex-col items-center justify-center rounded border-2 border-dashed px-6 py-12 text-center transition-colors"
> >
<p class="mt-2 text-xs text-stone-500">MP3, WAV, OGG, FLAC, M4A, AAC, WebM, WMA, or AIFF (max 100 MB).</p> <p class="text-sm font-medium text-stone-800">Drop audio files here</p>
<p class="mt-1 text-sm text-stone-600">or click to browse</p>
<p class="mt-3 text-xs text-stone-500">MP3, WAV, OGG, FLAC, M4A, AAC, WebM, WMA, or AIFF · max 100 MB each · up to 50 files</p>
</div> </div>
<div> <input
x-ref="fileInput"
id="audio"
type="file"
name="audio[]"
accept=".mp3,.wav,.ogg,.oga,.flac,.m4a,.mp4,.aac,.webm,.wma,.aiff,.aif,audio/*"
multiple
class="sr-only"
@change="onBrowse($event)"
>
</div>
<div x-show="files.length > 0" x-cloak class="space-y-2">
<div class="flex items-center justify-between gap-3">
<p class="text-sm font-medium text-stone-700">
<span x-text="files.length"></span>
<span x-text="files.length === 1 ? 'file selected' : 'files selected'"></span>
</p>
<button type="button" @click="clearFiles()" class="text-sm text-stone-600 hover:underline">
Clear all
</button>
</div>
<ul class="divide-y divide-stone-100 rounded border border-stone-200">
<template x-for="(file, index) in files" :key="fileKey(file, index)">
<li class="flex items-center justify-between gap-3 px-3 py-2 text-sm">
<div class="min-w-0">
<p class="truncate font-medium text-stone-800" x-text="file.name"></p>
<p class="text-xs text-stone-500" x-text="formatSize(file.size)"></p>
</div>
<button
type="button"
@click="removeFile(index)"
class="shrink-0 text-stone-500 hover:text-red-700"
>
Remove
</button>
</li>
</template>
</ul>
</div>
<div x-show="files.length === 1" x-cloak>
<label for="title" class="block text-sm font-medium text-stone-700">Title (optional)</label> <label for="title" class="block text-sm font-medium text-stone-700">Title (optional)</label>
<input <input
id="title" id="title"
@@ -41,11 +91,130 @@
> >
</div> </div>
<p x-show="error" x-cloak class="text-sm text-red-700" x-text="error"></p>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<button type="submit" class="rounded bg-teal-700 px-4 py-2 text-sm font-medium text-white hover:bg-teal-800"> <button
Upload type="submit"
:disabled="files.length === 0 || uploading"
class="rounded bg-teal-700 px-4 py-2 text-sm font-medium text-white hover:bg-teal-800 disabled:cursor-not-allowed disabled:opacity-50"
>
<span x-text="uploadLabel"></span>
</button> </button>
<a href="{{ route('recordings.index') }}" class="text-sm text-stone-600 hover:underline">Cancel</a> <a href="{{ route('recordings.index') }}" class="text-sm text-stone-600 hover:underline">Cancel</a>
</div> </div>
</form> </form>
<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 uploadDropzone() {
const acceptExt = ['.mp3', '.wav', '.ogg', '.oga', '.flac', '.m4a', '.mp4', '.aac', '.webm', '.wma', '.aiff', '.aif'];
return {
files: [],
dragging: false,
uploading: false,
error: null,
get uploadLabel() {
if (this.uploading) return 'Uploading…';
if (this.files.length <= 1) return 'Upload';
return 'Upload ' + this.files.length + ' files';
},
onBrowse(event) {
this.addFiles(Array.from(event.target.files || []));
},
onDrop(event) {
this.dragging = false;
this.addFiles(Array.from(event.dataTransfer?.files || []));
},
addFiles(incoming) {
this.error = null;
const accepted = [];
for (const file of incoming) {
if (! this.isAccepted(file)) {
this.error = 'Skipped unsupported file type. Use common audio formats only.';
continue;
}
if (file.size > 100 * 1024 * 1024) {
this.error = 'Skipped a file larger than 100 MB.';
continue;
}
accepted.push(file);
}
const merged = [...this.files, ...accepted];
const unique = [];
const seen = new Set();
for (const file of merged) {
const key = this.fileKey(file);
if (seen.has(key)) continue;
seen.add(key);
unique.push(file);
}
if (unique.length > 50) {
this.error = 'You can upload at most 50 files at once.';
this.files = unique.slice(0, 50);
} else {
this.files = unique;
}
this.syncInput();
},
isAccepted(file) {
const name = (file.name || '').toLowerCase();
if (acceptExt.some((ext) => name.endsWith(ext))) return true;
return (file.type || '').startsWith('audio/');
},
fileKey(file, index = 0) {
return [file.name, file.size, file.lastModified, index].join(':');
},
removeFile(index) {
this.files.splice(index, 1);
this.syncInput();
},
clearFiles() {
this.files = [];
this.syncInput();
},
syncInput() {
const input = this.$refs.fileInput;
if (! input) return;
const transfer = new DataTransfer();
this.files.forEach((file) => transfer.items.add(file));
input.files = transfer.files;
},
ensureFilesSelected(event) {
if (this.files.length === 0) {
event.preventDefault();
this.error = 'Drop or choose at least one audio file.';
return;
}
this.uploading = true;
this.error = null;
},
formatSize(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
},
};
}
</script>
@endsection @endsection
+18 -1
View File
@@ -8,6 +8,7 @@
<h1 class="text-2xl font-semibold tracking-tight">Recordings</h1> <h1 class="text-2xl font-semibold tracking-tight">Recordings</h1>
<p class="mt-1 text-sm text-stone-600">Manage pocket-recorder audio and transcripts.</p> <p class="mt-1 text-sm text-stone-600">Manage pocket-recorder audio and transcripts.</p>
</div> </div>
<div class="flex flex-col gap-2 sm:items-end">
<form method="GET" action="{{ route('recordings.index') }}" class="flex gap-2"> <form method="GET" action="{{ route('recordings.index') }}" class="flex gap-2">
<input <input
type="search" type="search"
@@ -20,6 +21,15 @@
Search Search
</button> </button>
</form> </form>
@if (($pendingCount ?? 0) > 0)
<form method="POST" action="{{ route('recordings.transcribe-pending') }}">
@csrf
<button type="submit" class="text-sm font-medium text-teal-700 hover:underline">
Queue {{ $pendingCount }} pending {{ \Illuminate\Support\Str::plural('transcription', $pendingCount) }}
</button>
</form>
@endif
</div>
</div> </div>
@if ($recordings->isEmpty()) @if ($recordings->isEmpty())
@@ -36,6 +46,7 @@
<tr> <tr>
<th class="px-4 py-3">Title</th> <th class="px-4 py-3">Title</th>
<th class="px-4 py-3">Duration</th> <th class="px-4 py-3">Duration</th>
<th class="px-4 py-3">Words</th>
<th class="px-4 py-3">Status</th> <th class="px-4 py-3">Status</th>
<th class="px-4 py-3">Uploaded</th> <th class="px-4 py-3">Uploaded</th>
</tr> </tr>
@@ -57,8 +68,14 @@
@endif @endif
</td> </td>
<td class="px-4 py-3 text-stone-600">{{ $recording->duration_formatted }}</td> <td class="px-4 py-3 text-stone-600">{{ $recording->duration_formatted }}</td>
<td class="px-4 py-3 tabular-nums text-stone-600">
{{ $recording->word_count > 0 ? number_format($recording->word_count) : '—' }}
</td>
<td class="px-4 py-3"> <td class="px-4 py-3">
@include('recordings.partials.status-badge', ['status' => $recording->transcription_status]) @include('recordings.partials.status-badge', [
'status' => $recording->transcription_status,
'label' => $recording->transcriptionStatusLabel(),
])
@if ($recording->isTranscribing() && $recording->transcription_progress) @if ($recording->isTranscribing() && $recording->transcription_progress)
<div class="mt-1 max-w-[14rem] truncate text-xs text-amber-700" title="{{ $recording->transcription_progress }}"> <div class="mt-1 max-w-[14rem] truncate text-xs text-amber-700" title="{{ $recording->transcription_progress }}">
{{ $recording->transcription_percent ? $recording->transcription_percent.'% · ' : '' }}{{ $recording->transcription_progress }} {{ $recording->transcription_percent ? $recording->transcription_percent.'% · ' : '' }}{{ $recording->transcription_progress }}
@@ -1,4 +1,12 @@
@php @php
$label = $label ?? match ($status) {
'pending' => 'Queued',
'processing' => 'Transcribing',
'done' => 'Done',
'failed' => 'Failed',
'cancelled' => 'Cancelled',
default => (string) $status,
};
$classes = match ($status) { $classes = match ($status) {
'done' => 'bg-teal-50 text-teal-800 ring-teal-600/20', 'done' => 'bg-teal-50 text-teal-800 ring-teal-600/20',
'processing' => 'bg-amber-50 text-amber-800 ring-amber-600/20', 'processing' => 'bg-amber-50 text-amber-800 ring-amber-600/20',
@@ -9,5 +17,5 @@
}; };
@endphp @endphp
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ring-1 ring-inset {{ $classes }}"> <span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ring-1 ring-inset {{ $classes }}">
{{ $status }} {{ $label }}
</span> </span>
+2 -8
View File
@@ -18,7 +18,7 @@
<span <span
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ring-1 ring-inset" class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ring-1 ring-inset"
:class="badgeClass" :class="badgeClass"
x-text="status.status" x-text="status.status_label || status.status"
></span> ></span>
<template x-if="status.driver_label"> <template x-if="status.driver_label">
<span class="text-xs text-stone-500" x-text="status.driver_label"></span> <span class="text-xs text-stone-500" x-text="status.driver_label"></span>
@@ -71,12 +71,6 @@
<section class="rounded border border-stone-200 bg-white p-6 shadow-sm"> <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> <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"> <form method="POST" action="{{ route('recordings.transcribe', $recording) }}" class="mt-4">
@csrf @csrf
<button <button
@@ -175,7 +169,7 @@
x-cloak x-cloak
class="mt-4 text-sm text-stone-500" class="mt-4 text-sm text-stone-500"
> >
No transcript yet. Choose an engine above to start. No transcript yet. Transcription starts automatically after upload, or use the button above.
</p> </p>
</section> </section>
</div> </div>
+3
View File
@@ -3,12 +3,15 @@
use App\Http\Controllers\CancelTranscriptionController; use App\Http\Controllers\CancelTranscriptionController;
use App\Http\Controllers\RecordingController; use App\Http\Controllers\RecordingController;
use App\Http\Controllers\TranscribeController; use App\Http\Controllers\TranscribeController;
use App\Http\Controllers\TranscribePendingController;
use App\Http\Controllers\TranscriptionStatusController; use App\Http\Controllers\TranscriptionStatusController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
Route::redirect('/', '/recordings'); Route::redirect('/', '/recordings');
Route::resource('recordings', RecordingController::class)->except(['edit', 'update']); Route::resource('recordings', RecordingController::class)->except(['edit', 'update']);
Route::post('recordings/transcribe-pending', TranscribePendingController::class)
->name('recordings.transcribe-pending');
Route::post('recordings/{recording}/transcribe', TranscribeController::class)->name('recordings.transcribe'); Route::post('recordings/{recording}/transcribe', TranscribeController::class)->name('recordings.transcribe');
Route::post('recordings/{recording}/transcribe/cancel', CancelTranscriptionController::class) Route::post('recordings/{recording}/transcribe/cancel', CancelTranscriptionController::class)
->name('recordings.transcribe.cancel'); ->name('recordings.transcribe.cancel');
+93 -6
View File
@@ -23,22 +23,26 @@ class RecordingUploadTest extends TestCase
'original_filename' => 'note.mp3', 'original_filename' => 'note.mp3',
'file_path' => 'recordings/note.mp3', 'file_path' => 'recordings/note.mp3',
'file_size_bytes' => 1024, 'file_size_bytes' => 1024,
'transcription_status' => 'pending', 'transcription_status' => 'done',
'transcript' => 'One two three four five',
]); ]);
$this->get(route('recordings.index')) $this->get(route('recordings.index'))
->assertOk() ->assertOk()
->assertSee('Pocket note'); ->assertSee('Pocket note')
->assertSee('Words')
->assertSee('5');
} }
public function test_user_can_upload_an_mp3(): void public function test_user_can_upload_an_mp3(): void
{ {
Storage::fake('local'); Storage::fake('local');
Bus::fake();
$file = UploadedFile::fake()->create('meeting.mp3', 500, 'audio/mpeg'); $file = UploadedFile::fake()->create('meeting.mp3', 500, 'audio/mpeg');
$response = $this->post(route('recordings.store'), [ $response = $this->post(route('recordings.store'), [
'audio' => $file, 'audio' => [$file],
'title' => 'Team meeting', 'title' => 'Team meeting',
]); ]);
@@ -48,12 +52,48 @@ class RecordingUploadTest extends TestCase
$response->assertRedirect(route('recordings.show', $recording)); $response->assertRedirect(route('recordings.show', $recording));
$this->assertSame('Team meeting', $recording->title); $this->assertSame('Team meeting', $recording->title);
$this->assertSame('pending', $recording->transcription_status); $this->assertSame('pending', $recording->transcription_status);
$this->assertSame('local', $recording->transcription_driver);
$this->assertNotNull($recording->transcription_started_at);
Storage::disk('local')->assertExists($recording->file_path); Storage::disk('local')->assertExists($recording->file_path);
Bus::assertDispatched(TranscribeRecording::class);
}
public function test_user_can_batch_upload_multiple_audio_files(): void
{
Storage::fake('local');
Bus::fake();
$response = $this->post(route('recordings.store'), [
'audio' => [
UploadedFile::fake()->create('one.mp3', 400, 'audio/mpeg'),
UploadedFile::fake()->create('two.wav', 400, 'audio/wav'),
UploadedFile::fake()->create('three.ogg', 400, 'audio/ogg'),
],
]);
$response->assertRedirect(route('recordings.index'));
$response->assertSessionHas('success');
$this->assertSame(3, Recording::query()->count());
Bus::assertDispatched(TranscribeRecording::class, 3);
foreach (Recording::query()->get() as $recording) {
Storage::disk('local')->assertExists($recording->file_path);
$this->assertSame('pending', $recording->transcription_status);
}
}
public function test_upload_page_includes_dropzone(): void
{
$this->get(route('recordings.create'))
->assertOk()
->assertSee('Drop audio files here')
->assertSee('name="audio[]"', false);
} }
public function test_user_can_upload_wav_and_ogg(): void public function test_user_can_upload_wav_and_ogg(): void
{ {
Storage::fake('local'); Storage::fake('local');
Bus::fake();
foreach ([ foreach ([
['memo.wav', 'audio/wav'], ['memo.wav', 'audio/wav'],
@@ -61,7 +101,7 @@ class RecordingUploadTest extends TestCase
['talk.m4a', 'audio/mp4'], ['talk.m4a', 'audio/mp4'],
] as [$name, $mime]) { ] as [$name, $mime]) {
$response = $this->post(route('recordings.store'), [ $response = $this->post(route('recordings.store'), [
'audio' => UploadedFile::fake()->create($name, 400, $mime), 'audio' => [UploadedFile::fake()->create($name, 400, $mime)],
'title' => $name, 'title' => $name,
]); ]);
@@ -71,6 +111,8 @@ class RecordingUploadTest extends TestCase
$response->assertRedirect(route('recordings.show', $recording)); $response->assertRedirect(route('recordings.show', $recording));
Storage::disk('local')->assertExists($recording->file_path); Storage::disk('local')->assertExists($recording->file_path);
} }
Bus::assertDispatched(TranscribeRecording::class, 3);
} }
public function test_unsupported_audio_type_is_rejected(): void public function test_unsupported_audio_type_is_rejected(): void
@@ -79,9 +121,9 @@ class RecordingUploadTest extends TestCase
$this->from(route('recordings.create')) $this->from(route('recordings.create'))
->post(route('recordings.store'), [ ->post(route('recordings.store'), [
'audio' => UploadedFile::fake()->create('notes.txt', 10, 'text/plain'), 'audio' => [UploadedFile::fake()->create('notes.txt', 10, 'text/plain')],
]) ])
->assertSessionHasErrors('audio'); ->assertSessionHasErrors(['audio.0']);
} }
public function test_user_can_queue_local_transcription(): void public function test_user_can_queue_local_transcription(): void
@@ -385,4 +427,49 @@ class RecordingUploadTest extends TestCase
$this->assertSame('pending', $recording->transcription_status); $this->assertSame('pending', $recording->transcription_status);
Bus::assertDispatched(TranscribeRecording::class); Bus::assertDispatched(TranscribeRecording::class);
} }
public function test_user_can_queue_all_pending_transcriptions(): void
{
Bus::fake();
Recording::query()->create([
'title' => 'Needs work',
'original_filename' => 'needs.mp3',
'file_path' => 'recordings/needs.mp3',
'file_size_bytes' => 100,
'transcription_status' => 'pending',
]);
Recording::query()->create([
'title' => 'Already done',
'original_filename' => 'done.mp3',
'file_path' => 'recordings/done.mp3',
'file_size_bytes' => 100,
'transcription_status' => 'done',
'transcript' => 'Finished text',
]);
$this->from(route('recordings.index'))
->post(route('recordings.transcribe-pending'))
->assertRedirect(route('recordings.index'))
->assertSessionHas('success');
Bus::assertDispatched(TranscribeRecording::class, 1);
}
public function test_index_shows_human_status_labels(): void
{
Recording::query()->create([
'title' => 'Label check',
'original_filename' => 'label.mp3',
'file_path' => 'recordings/label.mp3',
'file_size_bytes' => 100,
'transcription_status' => 'pending',
]);
$this->get(route('recordings.index'))
->assertOk()
->assertSee('Queued')
->assertDontSee('>pending<', false);
}
} }