diff --git a/README.md b/README.md index 569f23a..5f7685a 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,8 @@ Built with Laravel 13, Blade, Tailwind CSS 4, and [Laravel AI](https://github.co - Stop or restart a run anytime - Copy finished transcripts from the recording detail page + + ## Requirements - 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 - [Docker](https://docs.docker.com/get-docker/) for the Whisper container + + ## Setup ```bash @@ -42,6 +46,8 @@ npm install npm run build ``` + + ## Local Whisper (Docker) Transcription calls an OpenAI-compatible HTTP API. This project ships Compose for that: @@ -70,19 +76,23 @@ Stop: docker compose down ``` + + ## Configuration Copy values from `.env.example`. The transcription-related settings are: -| Variable | Purpose | -| --- | --- | -| `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_MODEL` | Model name for local transcription | -| `WHISPER_HOST_PORT` | Host port published by Compose (default `8090`) | -| `TRANSCRIPTION_TIMEOUT` | Job/HTTP timeout in seconds (default `600`) | -| `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 | + +| Variable | Purpose | +| ----------------------- | -------------------------------------------------------------------------------- | +| `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_MODEL` | Model name for local transcription | +| `WHISPER_HOST_PORT` | Host port published by Compose (default `8090`) | +| `TRANSCRIPTION_TIMEOUT` | Job/HTTP timeout in seconds (default `600`) | +| `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 | + Finished transcripts are stored on the recording (`transcript` column) and are included in the recordings search box (title, artist, album, filename, and transcript). @@ -112,10 +122,13 @@ Transcription jobs are queued — keep a queue worker running or jobs will stay ## Usage -1. **Upload** audio from Recordings → Upload (optional title override). -2. Open the recording and start transcription. -3. Watch live progress until the transcript appears (or stop and restart). +1. **Upload** audio from Recordings → Upload (single file or batch dropzone). +2. Transcription queues automatically — keep `php artisan queue:work` running. +3. Watch live progress on the recording page (or stop and restart). 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 @@ -125,6 +138,8 @@ composer test php artisan test ``` + + ## License -MIT +MIT \ No newline at end of file diff --git a/app/Http/Controllers/RecordingController.php b/app/Http/Controllers/RecordingController.php index 68b2b1e..8f52478 100644 --- a/app/Http/Controllers/RecordingController.php +++ b/app/Http/Controllers/RecordingController.php @@ -7,6 +7,7 @@ use App\Models\Recording; use App\Services\Mp3MetadataService; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; +use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\Storage; use Illuminate\View\View; @@ -30,9 +31,16 @@ class RecordingController extends Controller $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', [ 'recordings' => $recordings, '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 { - $file = $request->file('audio'); - $path = $file->store('recordings', 'local'); - $absolutePath = Storage::disk('local')->path($path); - $tags = $metadata->extract($absolutePath); + /** @var list $files */ + $files = array_values(array_filter( + $request->file('audio', []), + fn ($file) => $file instanceof UploadedFile, + )); - $title = $request->string('title')->trim()->toString() - ?: ($tags['title'] ?? pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME)); + $titleOverride = $request->string('title')->trim()->toString(); + $recordings = []; - $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', - ]); + foreach ($files as $file) { + $title = count($files) === 1 && $titleOverride !== '' + ? $titleOverride + : null; + + $recordings[] = $this->storeUploadedRecording($file, $metadata, $title); + } + + if (count($recordings) === 1) { + return redirect() + ->route('recordings.show', $recordings[0]) + ->with('success', 'Recording uploaded — transcription queued.'); + } return redirect() - ->route('recordings.show', $recording) - ->with('success', 'Recording uploaded successfully.'); + ->route('recordings.index') + ->with('success', count($recordings).' recordings uploaded — transcription queued.'); } /** @@ -97,4 +108,37 @@ class RecordingController extends Controller ->route('recordings.index') ->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(); + } } diff --git a/app/Http/Controllers/TranscribeController.php b/app/Http/Controllers/TranscribeController.php index 254bd74..ee75190 100644 --- a/app/Http/Controllers/TranscribeController.php +++ b/app/Http/Controllers/TranscribeController.php @@ -3,7 +3,6 @@ namespace App\Http\Controllers; use App\Http\Requests\TranscribeRecordingRequest; -use App\Jobs\TranscribeRecording; use App\Models\Recording; use Illuminate\Http\RedirectResponse; @@ -16,24 +15,7 @@ class TranscribeController extends Controller */ public function __invoke(TranscribeRecordingRequest $request, Recording $recording): RedirectResponse { - if ($recording->isTranscribing() || $recording->hasActiveTranscriptionJob()) { - $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()); + $recording->queueLocalTranscription(); return back()->with('success', 'Transcription started. Progress updates below.'); } diff --git a/app/Http/Controllers/TranscribePendingController.php b/app/Http/Controllers/TranscribePendingController.php new file mode 100644 index 0000000..6c997a0 --- /dev/null +++ b/app/Http/Controllers/TranscribePendingController.php @@ -0,0 +1,40 @@ +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.", + ); + } +} diff --git a/app/Http/Requests/StoreRecordingRequest.php b/app/Http/Requests/StoreRecordingRequest.php index 093fecc..59b9c7b 100644 --- a/app/Http/Requests/StoreRecordingRequest.php +++ b/app/Http/Requests/StoreRecordingRequest.php @@ -3,6 +3,7 @@ namespace App\Http\Requests; use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Http\UploadedFile; use Illuminate\Validation\Rules\File; class StoreRecordingRequest extends FormRequest @@ -34,13 +35,24 @@ class StoreRecordingRequest extends FormRequest 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 */ public function rules(): array { return [ - 'audio' => [ + 'audio' => ['required', 'array', 'min:1', 'max:50'], + 'audio.*' => [ 'required', File::types(self::AUDIO_EXTENSIONS)->max(102400), ], @@ -54,9 +66,12 @@ class StoreRecordingRequest extends FormRequest public function messages(): array { return [ - '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' => 'The audio file may not be larger than 100 MB.', + 'audio.required' => 'Please choose at least one audio file to upload.', + 'audio.min' => 'Please choose at least one audio file to upload.', + '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.', ]; } } diff --git a/app/Models/Recording.php b/app/Models/Recording.php index a2fa715..e68347d 100644 --- a/app/Models/Recording.php +++ b/app/Models/Recording.php @@ -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. */ @@ -92,6 +106,47 @@ class Recording extends Model 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. */ @@ -383,6 +438,7 @@ class Recording extends Model return [ 'id' => $this->id, 'status' => $this->transcription_status, + 'status_label' => $this->transcriptionStatusLabel(), 'progress' => $this->transcription_progress, 'percent' => $this->transcription_percent, 'driver' => $this->transcription_driver, diff --git a/resources/views/recordings/create.blade.php b/resources/views/recordings/create.blade.php index f782c48..b73d164 100644 --- a/resources/views/recordings/create.blade.php +++ b/resources/views/recordings/create.blade.php @@ -4,32 +4,82 @@ @section('content')
-

Upload recording

-

Upload audio from your pocket recorder. Embedded metadata is extracted when available.

+

Upload recordings

+

+ Drop one or many pocket-recorder files. Embedded metadata is extracted when available. +

@csrf
- + + +
+

Drop audio files here

+

or click to browse

+

MP3, WAV, OGG, FLAC, M4A, AAC, WebM, WMA, or AIFF · max 100 MB each · up to 50 files

+
+ -

MP3, WAV, OGG, FLAC, M4A, AAC, WebM, WMA, or AIFF (max 100 MB).

-
+
+
+

+ + +

+ +
+ +
    + +
+
+ +
+

+
- Cancel
+ + + + @endsection diff --git a/resources/views/recordings/index.blade.php b/resources/views/recordings/index.blade.php index 635aa12..d2c9ba6 100644 --- a/resources/views/recordings/index.blade.php +++ b/resources/views/recordings/index.blade.php @@ -8,18 +8,28 @@

Recordings

Manage pocket-recorder audio and transcripts.

-
- - -
+
+
+ + +
+ @if (($pendingCount ?? 0) > 0) +
+ @csrf + +
+ @endif +
@if ($recordings->isEmpty()) @@ -36,6 +46,7 @@ Title Duration + Words Status Uploaded @@ -57,8 +68,14 @@ @endif {{ $recording->duration_formatted }} + + {{ $recording->word_count > 0 ? number_format($recording->word_count) : '—' }} + - @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)
{{ $recording->transcription_percent ? $recording->transcription_percent.'% · ' : '' }}{{ $recording->transcription_progress }} diff --git a/resources/views/recordings/partials/status-badge.blade.php b/resources/views/recordings/partials/status-badge.blade.php index ebd5ba5..1cce875 100644 --- a/resources/views/recordings/partials/status-badge.blade.php +++ b/resources/views/recordings/partials/status-badge.blade.php @@ -1,4 +1,12 @@ @php + $label = $label ?? match ($status) { + 'pending' => 'Queued', + 'processing' => 'Transcribing', + 'done' => 'Done', + 'failed' => 'Failed', + 'cancelled' => 'Cancelled', + default => (string) $status, + }; $classes = match ($status) { 'done' => 'bg-teal-50 text-teal-800 ring-teal-600/20', 'processing' => 'bg-amber-50 text-amber-800 ring-amber-600/20', @@ -9,5 +17,5 @@ }; @endphp - {{ $status }} + {{ $label }} diff --git a/resources/views/recordings/show.blade.php b/resources/views/recordings/show.blade.php index 2297ef6..f6b2e25 100644 --- a/resources/views/recordings/show.blade.php +++ b/resources/views/recordings/show.blade.php @@ -18,7 +18,7 @@