From e0400aadef1645547f59c0653104d599e518b4f2 Mon Sep 17 00:00:00 2001 From: Ben Date: Wed, 12 Aug 2026 14:32:56 +0200 Subject: [PATCH] Add live transcription progress and Docker Whisper. Surface stage, percent, and elapsed time while jobs run, and ship Compose so local confidential transcription can use faster-whisper on port 8090. --- .env.example | 5 +- README.md | 60 ++- app/Http/Controllers/TranscribeController.php | 8 +- .../TranscriptionStatusController.php | 17 + app/Jobs/TranscribeRecording.php | 43 +- app/Models/Recording.php | 82 ++++ app/Services/TranscriptionService.php | 42 +- config/ai.php | 2 +- ...scription_progress_to_recordings_table.php | 36 ++ docker-compose.yml | 43 ++ resources/views/recordings/index.blade.php | 9 +- resources/views/recordings/show.blade.php | 423 ++++++++++++------ routes/web.php | 3 + tests/Feature/RecordingUploadTest.php | 99 +++- 14 files changed, 695 insertions(+), 177 deletions(-) create mode 100644 app/Http/Controllers/TranscriptionStatusController.php create mode 100644 database/migrations/2026_08_12_122500_add_transcription_progress_to_recordings_table.php create mode 100644 docker-compose.yml diff --git a/.env.example b/.env.example index 56feba6..be14567 100644 --- a/.env.example +++ b/.env.example @@ -67,8 +67,11 @@ VITE_APP_NAME="${APP_NAME}" # AndyTranscribe / Laravel AI OPENAI_API_KEY= OPENAI_URL=https://api.openai.com/v1 -LOCAL_WHISPER_URL=http://localhost:8000/v1 +LOCAL_WHISPER_URL=http://127.0.0.1:8090/v1 LOCAL_WHISPER_API_KEY=not-needed LOCAL_WHISPER_MODEL=Systran/faster-whisper-base REMOTE_WHISPER_MODEL=Systran/faster-whisper-base TRANSCRIPTION_TIMEOUT=600 + +# Host port for docker compose whisper service +WHISPER_HOST_PORT=8090 diff --git a/README.md b/README.md index 65924b8..d771c0d 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,19 @@ # AndyTranscribe -Upload pocket-recorder MP3s, extract ID3 metadata, and transcribe them with OpenAI Whisper, a local faster-whisper server, or a remote OpenAI-compatible endpoint. +Upload pocket-recorder audio (MP3, WAV, OGG, and more), extract embedded metadata, and transcribe with OpenAI Whisper, a local faster-whisper server, or a remote OpenAI-compatible endpoint. Built with Laravel 13, Blade, Tailwind CSS 4, and [Laravel AI](https://github.com/laravel/ai). ## Features -- Upload MP3s (up to 100 MB) and store them on the local disk -- Automatic ID3 metadata extraction (title, artist, album, duration, recorded date) +- Upload common audio formats (MP3, WAV, OGG, FLAC, M4A, AAC, WebM, WMA, AIFF — up to 100 MB) +- Automatic metadata extraction when tags are present (title, artist, album, duration, recorded date) - Search recordings by title, artist, or transcript - Queued transcription with three engines: - **Cloud** — OpenAI Whisper (`whisper-1`) - - **Local** — confidential; OpenAI-compatible [faster-whisper-server](https://github.com/fedirz/faster-whisper-server) (e.g. Docker on this machine) + - **Local** — confidential; OpenAI-compatible [faster-whisper-server](https://github.com/fedirz/faster-whisper-server) via Docker - **Ollama host** — user-supplied host URL exposing `/v1/audio/transcriptions` +- Live transcription progress (stage, %, elapsed time) - Copy finished transcripts from the recording detail page ## Requirements @@ -21,9 +22,9 @@ Built with Laravel 13, Blade, Tailwind CSS 4, and [Laravel AI](https://github.co - Composer - Node.js & npm - SQLite (default) or another supported database -- For cloud transcription: an OpenAI API key -- For local transcription: a running faster-whisper-server -- For remote transcription: a host with an OpenAI-compatible transcription API +- For **cloud** transcription: an OpenAI API key +- For **local** transcription: [Docker](https://docs.docker.com/get-docker/) (runs Whisper in a container) +- For **remote** transcription: a host with an OpenAI-compatible transcription API ## Setup @@ -45,6 +46,36 @@ npm install npm run build ``` +## Local Whisper (Docker) + +The **Local** engine does not run Whisper inside PHP. It calls an OpenAI-compatible HTTP API. This project ships Compose for that: + +```bash +# CPU (works everywhere; slower on long files) +docker compose up -d whisper + +# Optional: NVIDIA GPU +docker compose --profile gpu up -d whisper-gpu +``` + +First start downloads the model into a Docker volume (can take a few minutes). + +Check it: + +```bash +curl -s http://127.0.0.1:8090/health +``` + +Laravel talks to it at `LOCAL_WHISPER_URL` (default `http://127.0.0.1:8090/v1`). Port **8090** is used so it does not conflict with `php artisan serve` on 8000. + +Stop: + +```bash +docker compose down +``` + +Without this container, **Cloud** and **Ollama host** still work; only **Local** needs Docker. + ## Configuration Copy values from `.env.example`. The transcription-related settings are: @@ -53,10 +84,11 @@ Copy values from `.env.example`. The transcription-related settings are: | --- | --- | | `OPENAI_API_KEY` | Required for cloud Whisper | | `OPENAI_URL` | OpenAI API base URL (default `https://api.openai.com/v1`) | -| `LOCAL_WHISPER_URL` | Local faster-whisper base URL (default `http://localhost:8000/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_MODEL` | Model name for local transcription | | `REMOTE_WHISPER_MODEL` | Model name for Ollama-host transcription | +| `WHISPER_HOST_PORT` | Host port published by Compose (default `8090`) | | `TRANSCRIPTION_TIMEOUT` | Job/HTTP timeout in seconds (default `600`) | | `QUEUE_CONNECTION` | Use `database` (default) so transcription runs in the background | @@ -70,6 +102,12 @@ Start the app, queue worker, and Vite together: composer run dev ``` +For confidential local transcription, also start Whisper: + +```bash +docker compose up -d whisper +``` + Or separately: ```bash @@ -84,9 +122,9 @@ Transcription jobs are queued — keep a queue worker running or jobs will stay ## Usage -1. **Upload** an MP3 from Recordings → Upload (optional title override). +1. **Upload** audio from Recordings → Upload (optional title override). 2. Open the recording and choose a transcription engine. -3. Wait for the queue job to finish, then refresh to view or copy the transcript. +3. Watch live progress on the recording page until the transcript appears. 4. Search the list by title, artist, or transcript text. ## Transcription engines @@ -94,7 +132,7 @@ Transcription jobs are queued — keep a queue worker running or jobs will stay | Driver | When to use | Needs | | --- | --- | --- | | `cloud` | Fastest path; audio leaves your machine | `OPENAI_API_KEY` | -| `local` | Confidential; audio stays on this machine | faster-whisper-server at `LOCAL_WHISPER_URL` | +| `local` | Confidential; audio stays on this machine | `docker compose up -d whisper` | | `ollama` | Another machine on your network | Host URL + OpenAI-compatible `/v1/audio/transcriptions` | ## Tests diff --git a/app/Http/Controllers/TranscribeController.php b/app/Http/Controllers/TranscribeController.php index eb06fff..037b1f0 100644 --- a/app/Http/Controllers/TranscribeController.php +++ b/app/Http/Controllers/TranscribeController.php @@ -14,7 +14,7 @@ class TranscribeController extends Controller */ public function __invoke(TranscribeRecordingRequest $request, Recording $recording): RedirectResponse { - if (in_array($recording->transcription_status, ['processing'], true)) { + if ($recording->transcription_status === 'processing') { return back()->with('error', 'Transcription is already in progress.'); } @@ -24,12 +24,16 @@ class TranscribeController extends Controller 'transcription_driver' => $driver, 'ollama_url' => $driver === 'ollama' ? rtrim($request->validated('ollama_url'), '/') : null, 'transcription_status' => 'pending', + 'transcription_progress' => 'Queued — waiting to start…', + 'transcription_percent' => 5, + 'transcription_started_at' => now(), + 'transcription_error' => null, 'transcript' => null, 'transcribed_at' => null, ]); TranscribeRecording::dispatch($recording->fresh()); - return back()->with('success', 'Transcription started. Refresh in a moment to see the result.'); + return back()->with('success', 'Transcription started. Progress updates below.'); } } diff --git a/app/Http/Controllers/TranscriptionStatusController.php b/app/Http/Controllers/TranscriptionStatusController.php new file mode 100644 index 0000000..f7daa69 --- /dev/null +++ b/app/Http/Controllers/TranscriptionStatusController.php @@ -0,0 +1,17 @@ +json($recording->fresh()->transcriptionStatusPayload()); + } +} diff --git a/app/Jobs/TranscribeRecording.php b/app/Jobs/TranscribeRecording.php index 01ccc41..4af9e8f 100644 --- a/app/Jobs/TranscribeRecording.php +++ b/app/Jobs/TranscribeRecording.php @@ -31,18 +31,32 @@ class TranscribeRecording implements ShouldQueue */ public function handle(TranscriptionService $transcription): void { - $this->recording->update([ + $this->recording->refresh(); + + $this->recording->forceFill([ 'transcription_status' => 'processing', - ]); + 'transcription_started_at' => $this->recording->transcription_started_at ?? now(), + 'transcription_error' => null, + ])->save(); + + $this->recording->reportProgress('Preparing audio file…', 15); try { - $text = $transcription->transcribe($this->recording); + $text = $transcription->transcribe( + $this->recording, + fn (string $message, int $percent) => $this->recording->reportProgress($message, $percent), + ); - $this->recording->update([ + $this->recording->reportProgress('Saving transcript…', 90); + + $this->recording->forceFill([ 'transcript' => $text, 'transcription_status' => 'done', + 'transcription_progress' => 'Transcription complete', + 'transcription_percent' => 100, + 'transcription_error' => null, 'transcribed_at' => now(), - ]); + ])->save(); } catch (Throwable $e) { Log::error('Transcription failed', [ 'recording_id' => $this->recording->id, @@ -50,11 +64,26 @@ class TranscribeRecording implements ShouldQueue 'message' => $e->getMessage(), ]); - $this->recording->update([ + $this->recording->forceFill([ 'transcription_status' => 'failed', - ]); + 'transcription_progress' => 'Transcription failed', + 'transcription_percent' => $this->recording->transcription_percent ?: 0, + 'transcription_error' => $e->getMessage(), + ])->save(); throw $e; } } + + /** + * Handle a job failure (timeouts, worker kill, etc.). + */ + public function failed(?Throwable $e): void + { + $this->recording->forceFill([ + 'transcription_status' => 'failed', + 'transcription_progress' => 'Transcription failed', + 'transcription_error' => $e?->getMessage() ?: 'Transcription stopped unexpectedly.', + ])->save(); + } } diff --git a/app/Models/Recording.php b/app/Models/Recording.php index c48a02d..0c42756 100644 --- a/app/Models/Recording.php +++ b/app/Models/Recording.php @@ -22,6 +22,10 @@ class Recording extends Model 'file_size_bytes', 'transcript', 'transcription_status', + 'transcription_progress', + 'transcription_percent', + 'transcription_started_at', + 'transcription_error', 'transcription_driver', 'ollama_url', 'transcribed_at', @@ -35,8 +39,10 @@ class Recording extends Model return [ 'recorded_at' => 'datetime', 'transcribed_at' => 'datetime', + 'transcription_started_at' => 'datetime', 'duration_seconds' => 'integer', 'file_size_bytes' => 'integer', + 'transcription_percent' => 'integer', ]; } @@ -57,6 +63,42 @@ class Recording extends Model }); } + /** + * Friendly label for the selected transcription engine. + */ + protected function transcriptionDriverLabel(): Attribute + { + return Attribute::get(function (): ?string { + return match ($this->transcription_driver) { + 'cloud' => 'Cloud (OpenAI Whisper)', + 'local' => 'Local (faster-whisper)', + 'ollama' => 'Ollama host', + default => $this->transcription_driver, + }; + }); + } + + /** + * Whether transcription is actively running or queued. + */ + public function isTranscribing(): bool + { + return in_array($this->transcription_status, ['pending', 'processing'], true); + } + + /** + * Update the live progress fields shown in the UI. + */ + public function reportProgress(string $message, int $percent, string $status = 'processing'): void + { + $this->forceFill([ + 'transcription_status' => $status, + 'transcription_progress' => $message, + 'transcription_percent' => max(0, min(100, $percent)), + 'transcription_error' => null, + ])->save(); + } + /** * Absolute filesystem path for the stored audio file. */ @@ -74,4 +116,44 @@ class Recording extends Model Storage::disk('local')->delete($this->file_path); } } + + /** + * Payload for the live status endpoint / Alpine poller. + * + * @return array + */ + public function transcriptionStatusPayload(): array + { + $startedAt = $this->transcription_started_at; + $elapsed = $startedAt ? $startedAt->diffInSeconds(now()) : null; + + return [ + 'id' => $this->id, + 'status' => $this->transcription_status, + 'progress' => $this->transcription_progress, + 'percent' => $this->transcription_percent, + 'driver' => $this->transcription_driver, + 'driver_label' => $this->transcription_driver_label, + 'error' => $this->transcription_error, + 'started_at' => $startedAt?->toIso8601String(), + 'elapsed_seconds' => $elapsed, + 'elapsed_human' => $elapsed === null ? null : $this->formatElapsed($elapsed), + 'duration_seconds' => $this->duration_seconds, + 'is_active' => $this->isTranscribing(), + 'has_transcript' => filled($this->transcript), + 'transcribed_at' => $this->transcribed_at?->toIso8601String(), + ]; + } + + private function formatElapsed(int $seconds): string + { + $minutes = intdiv($seconds, 60); + $remain = $seconds % 60; + + if ($minutes === 0) { + return sprintf('%ds', $remain); + } + + return sprintf('%dm %02ds', $minutes, $remain); + } } diff --git a/app/Services/TranscriptionService.php b/app/Services/TranscriptionService.php index 69a7992..8a9e1f6 100644 --- a/app/Services/TranscriptionService.php +++ b/app/Services/TranscriptionService.php @@ -3,6 +3,7 @@ namespace App\Services; use App\Models\Recording; +use Closure; use Illuminate\Support\Facades\Http; use Illuminate\Support\Str; use Laravel\Ai\Transcription; @@ -12,41 +13,63 @@ class TranscriptionService { /** * Run transcription for a recording using the selected driver. + * + * @param (Closure(string, int): void)|null $onProgress */ - public function transcribe(Recording $recording): string + public function transcribe(Recording $recording, ?Closure $onProgress = null): string { + $report = $onProgress ?? static fn (string $message, int $percent) => null; + return match ($recording->transcription_driver) { - 'cloud' => $this->viaCloud($recording), - 'local' => $this->viaLocal($recording), - 'ollama' => $this->viaRemoteCompatible($recording), + 'cloud' => $this->viaCloud($recording, $report), + 'local' => $this->viaLocal($recording, $report), + 'ollama' => $this->viaRemoteCompatible($recording, $report), default => throw new RuntimeException('Unknown transcription driver: '.$recording->transcription_driver), }; } - private function viaCloud(Recording $recording): string + /** + * @param Closure(string, int): void $report + */ + private function viaCloud(Recording $recording, Closure $report): string { + $report('Sending audio to OpenAI Whisper…', 35); + $report('Waiting for cloud transcript (this can take a while for long recordings)…', 55); + $transcript = Transcription::fromStorage($recording->file_path) ->timeout((int) config('ai.transcription_timeout', 600)) ->generate('openai', 'whisper-1'); + $report('Received transcript from OpenAI…', 85); + return (string) $transcript; } - private function viaLocal(Recording $recording): string + /** + * @param Closure(string, int): void $report + */ + private function viaLocal(Recording $recording, Closure $report): string { $model = config('ai.local_whisper_model', 'Systran/faster-whisper-base'); + $report('Connecting to local faster-whisper server…', 30); + $report("Transcribing locally with {$model} (audio stays on this machine)…", 50); + $transcript = Transcription::fromStorage($recording->file_path) ->timeout((int) config('ai.transcription_timeout', 600)) ->generate('local-whisper', $model); + $report('Received transcript from local Whisper…', 85); + return (string) $transcript; } /** * Call an OpenAI-compatible /v1/audio/transcriptions endpoint at a user-supplied host URL. + * + * @param Closure(string, int): void $report */ - private function viaRemoteCompatible(Recording $recording): string + private function viaRemoteCompatible(Recording $recording, Closure $report): string { if (! filled($recording->ollama_url)) { throw new RuntimeException('Ollama host URL is required for remote transcription.'); @@ -60,6 +83,9 @@ class TranscriptionService throw new RuntimeException('Recording audio file is not readable.'); } + $report('Connecting to remote host '.$recording->ollama_url.'…', 30); + $report("Uploading audio and waiting for transcript ({$model})…", 50); + $response = Http::timeout((int) config('ai.transcription_timeout', 600)) ->attach( 'file', @@ -83,6 +109,8 @@ class TranscriptionService throw new RuntimeException('Remote transcription returned an empty transcript. Ensure the host exposes OpenAI-compatible /v1/audio/transcriptions.'); } + $report('Received transcript from remote host…', 85); + return $text; } diff --git a/config/ai.php b/config/ai.php index 28dd9be..e19652f 100644 --- a/config/ai.php +++ b/config/ai.php @@ -149,7 +149,7 @@ return [ 'local-whisper' => [ 'driver' => 'openai', 'key' => env('LOCAL_WHISPER_API_KEY', 'not-needed'), - 'url' => env('LOCAL_WHISPER_URL', 'http://localhost:8000/v1'), + 'url' => env('LOCAL_WHISPER_URL', 'http://127.0.0.1:8090/v1'), 'store' => false, ], diff --git a/database/migrations/2026_08_12_122500_add_transcription_progress_to_recordings_table.php b/database/migrations/2026_08_12_122500_add_transcription_progress_to_recordings_table.php new file mode 100644 index 0000000..1c6a0ef --- /dev/null +++ b/database/migrations/2026_08_12_122500_add_transcription_progress_to_recordings_table.php @@ -0,0 +1,36 @@ +string('transcription_progress')->nullable()->after('transcription_status'); + $table->unsignedTinyInteger('transcription_percent')->nullable()->after('transcription_progress'); + $table->timestamp('transcription_started_at')->nullable()->after('transcription_percent'); + $table->text('transcription_error')->nullable()->after('transcription_started_at'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('recordings', function (Blueprint $table) { + $table->dropColumn([ + 'transcription_progress', + 'transcription_percent', + 'transcription_started_at', + 'transcription_error', + ]); + }); + } +}; diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..b8b8580 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,43 @@ +services: + whisper: + image: fedirz/faster-whisper-server:latest-cpu + container_name: andytranscribe-whisper + ports: + # Host 8090 avoids clashing with `php artisan serve` on 8000 + - "${WHISPER_HOST_PORT:-8090}:8000" + volumes: + - whisper-huggingface-cache:/root/.cache/huggingface + environment: + # OpenAI-compatible API; model is also passed per request from Laravel + WHISPER__MODEL: ${LOCAL_WHISPER_MODEL:-Systran/faster-whisper-base} + restart: unless-stopped + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health')"] + interval: 15s + timeout: 5s + retries: 10 + start_period: 30s + + # GPU variant (NVIDIA). Start with: + # docker compose --profile gpu up -d whisper-gpu + whisper-gpu: + profiles: ["gpu"] + image: fedirz/faster-whisper-server:latest-cuda + container_name: andytranscribe-whisper-gpu + ports: + - "${WHISPER_HOST_PORT:-8090}:8000" + volumes: + - whisper-huggingface-cache:/root/.cache/huggingface + environment: + WHISPER__MODEL: ${LOCAL_WHISPER_MODEL:-Systran/faster-whisper-base} + restart: unless-stopped + deploy: + resources: + reservations: + devices: + - driver: nvidia + count: 1 + capabilities: [gpu] + +volumes: + whisper-huggingface-cache: diff --git a/resources/views/recordings/index.blade.php b/resources/views/recordings/index.blade.php index 2d2b160..f1bf70c 100644 --- a/resources/views/recordings/index.blade.php +++ b/resources/views/recordings/index.blade.php @@ -6,7 +6,7 @@

Recordings

-

Manage pocket-recorder MP3s and transcripts.

+

Manage pocket-recorder audio and transcripts.

{{ $recording->duration_formatted }} - + @include('recordings.partials.status-badge', ['status' => $recording->transcription_status]) + @if ($recording->isTranscribing() && $recording->transcription_progress) +
+ {{ $recording->transcription_percent ? $recording->transcription_percent.'% · ' : '' }}{{ $recording->transcription_progress }} +
+ @endif @if ($recording->transcription_driver) diff --git a/resources/views/recordings/show.blade.php b/resources/views/recordings/show.blade.php index 4c0f7e0..750db91 100644 --- a/resources/views/recordings/show.blade.php +++ b/resources/views/recordings/show.blade.php @@ -3,155 +3,296 @@ @section('title', $recording->title) @section('content') -
-
- ← Recordings -

{{ $recording->title }}

-
- @include('recordings.partials.status-badge', ['status' => $recording->transcription_status]) - @if ($recording->transcription_driver) - {{ $recording->transcription_driver }} - @endif +
+
+
+ ← Recordings +

{{ $recording->title }}

+
+ + +
-
- - @csrf - @method('DELETE') - - -
- -
-
-

Metadata

-
-
-
Original file
-
{{ $recording->original_filename }}
-
-
-
Duration
-
{{ $recording->duration_formatted }}
-
-
-
Artist
-
{{ $recording->artist ?: '—' }}
-
-
-
Album
-
{{ $recording->album ?: '—' }}
-
-
-
Recorded
-
{{ $recording->recorded_at?->format('Y-m-d') ?: '—' }}
-
-
-
Size
-
{{ number_format($recording->file_size_bytes / 1024, 1) }} KB
-
-
-
Uploaded
-
{{ $recording->created_at?->format('Y-m-d H:i') }}
-
- @if ($recording->ollama_url) -
-
Ollama host
-
{{ $recording->ollama_url }}
-
- @endif -
-
- -
-

Transcribe

-

Choose how to convert this MP3 to text.

- -
+ @csrf - - - - - - - -
- - -
- -
-
-
- -
-
-

Transcript

- @if ($recording->transcript) - - @endif
- @if ($recording->transcription_status === 'processing') -

Transcription in progress… Refresh shortly.

- @elseif ($recording->transcription_status === 'failed') -

Transcription failed. Check the logs and try again with another engine.

- @elseif ($recording->transcript) -

{{ $recording->transcript }}

- @if ($recording->transcribed_at) -

Transcribed {{ $recording->transcribed_at->format('Y-m-d H:i') }}

+
+
+

Metadata

+
+
+
Original file
+
{{ $recording->original_filename }}
+
+
+
Duration
+
{{ $recording->duration_formatted }}
+
+
+
Artist
+
{{ $recording->artist ?: '—' }}
+
+
+
Album
+
{{ $recording->album ?: '—' }}
+
+
+
Recorded
+
{{ $recording->recorded_at?->format('Y-m-d') ?: '—' }}
+
+
+
Size
+
{{ number_format($recording->file_size_bytes / 1024, 1) }} KB
+
+
+
Uploaded
+
{{ $recording->created_at?->format('Y-m-d H:i') }}
+
+ @if ($recording->ollama_url) +
+
Ollama host
+
{{ $recording->ollama_url }}
+
+ @endif +
+
+ +
+

Transcribe

+

Choose how to convert this recording to text.

+ +
+ @csrf + + + + + + + +
+ + +
+ + +
+
+
+ +
+
+

Transcript

+ @if ($recording->transcript) + + @endif +
+ +
+
+

+

+ + · + +

+
+ +
+
+
+ +
    +
  • + Engine: + +
  • + +
  • +
+
+ +
+

Transcription failed

+

+
+ + @if ($recording->transcript) +
+

{{ $recording->transcript }}

+ @if ($recording->transcribed_at) +

Transcribed {{ $recording->transcribed_at->format('Y-m-d H:i') }}

+ @endif +
+ @else +

+ No transcript yet. Choose an engine above to start. +

@endif - @else -

No transcript yet. Choose an engine above to start.

- @endif -
+
+
+ @endsection diff --git a/routes/web.php b/routes/web.php index 0f5c67b..a487dd8 100644 --- a/routes/web.php +++ b/routes/web.php @@ -2,9 +2,12 @@ use App\Http\Controllers\RecordingController; use App\Http\Controllers\TranscribeController; +use App\Http\Controllers\TranscriptionStatusController; use Illuminate\Support\Facades\Route; Route::redirect('/', '/recordings'); Route::resource('recordings', RecordingController::class)->except(['edit', 'update']); Route::post('recordings/{recording}/transcribe', TranscribeController::class)->name('recordings.transcribe'); +Route::get('recordings/{recording}/transcription-status', TranscriptionStatusController::class) + ->name('recordings.transcription-status'); diff --git a/tests/Feature/RecordingUploadTest.php b/tests/Feature/RecordingUploadTest.php index 37a1f33..ff3a085 100644 --- a/tests/Feature/RecordingUploadTest.php +++ b/tests/Feature/RecordingUploadTest.php @@ -7,7 +7,6 @@ use App\Models\Recording; use App\Services\TranscriptionService; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Http\UploadedFile; -use Illuminate\Support\Facades\Queue; use Illuminate\Support\Facades\Storage; use Laravel\Ai\Transcription; use Tests\TestCase; @@ -51,10 +50,41 @@ class RecordingUploadTest extends TestCase Storage::disk('local')->assertExists($recording->file_path); } + public function test_user_can_upload_wav_and_ogg(): void + { + Storage::fake('local'); + + foreach ([ + ['memo.wav', 'audio/wav'], + ['clip.ogg', 'audio/ogg'], + ['talk.m4a', 'audio/mp4'], + ] as [$name, $mime]) { + $response = $this->post(route('recordings.store'), [ + 'audio' => UploadedFile::fake()->create($name, 400, $mime), + 'title' => $name, + ]); + + $recording = Recording::query()->where('title', $name)->first(); + + $this->assertNotNull($recording, "Failed uploading {$name}"); + $response->assertRedirect(route('recordings.show', $recording)); + Storage::disk('local')->assertExists($recording->file_path); + } + } + + public function test_unsupported_audio_type_is_rejected(): void + { + Storage::fake('local'); + + $this->from(route('recordings.create')) + ->post(route('recordings.store'), [ + 'audio' => UploadedFile::fake()->create('notes.txt', 10, 'text/plain'), + ]) + ->assertSessionHasErrors('audio'); + } + public function test_user_can_queue_cloud_transcription(): void { - Queue::fake(); - $recording = Recording::query()->create([ 'title' => 'Dictation', 'original_filename' => 'dictation.mp3', @@ -63,14 +93,18 @@ class RecordingUploadTest extends TestCase 'transcription_status' => 'pending', ]); + // Fake AI so the afterResponse job (sync) does not call a real provider. + Transcription::fake(['Queued transcription text.']); + $this->post(route('recordings.transcribe', $recording), [ 'driver' => 'cloud', ])->assertRedirect(); - Queue::assertPushed(TranscribeRecording::class); - $recording->refresh(); $this->assertSame('cloud', $recording->transcription_driver); + $this->assertContains($recording->transcription_status, ['pending', 'processing', 'done']); + $this->assertNotNull($recording->transcription_started_at); + $this->assertNotNull($recording->transcription_progress); } public function test_ollama_driver_requires_url(): void @@ -90,6 +124,29 @@ class RecordingUploadTest extends TestCase ->assertSessionHasErrors('ollama_url'); } + public function test_transcription_status_endpoint_returns_progress(): void + { + $recording = Recording::query()->create([ + 'title' => 'Live status', + 'original_filename' => 'live.mp3', + 'file_path' => 'recordings/live.mp3', + 'file_size_bytes' => 100, + 'transcription_status' => 'processing', + 'transcription_progress' => 'Waiting for cloud transcript…', + 'transcription_percent' => 55, + 'transcription_driver' => 'cloud', + 'transcription_started_at' => now()->subSeconds(12), + ]); + + $this->getJson(route('recordings.transcription-status', $recording)) + ->assertOk() + ->assertJsonPath('status', 'processing') + ->assertJsonPath('progress', 'Waiting for cloud transcript…') + ->assertJsonPath('percent', 55) + ->assertJsonPath('is_active', true) + ->assertJsonPath('driver_label', 'Cloud (OpenAI Whisper)'); + } + public function test_transcription_job_stores_transcript(): void { Storage::fake('local'); @@ -111,6 +168,38 @@ class RecordingUploadTest extends TestCase $recording->refresh(); $this->assertSame('done', $recording->transcription_status); $this->assertSame('Hello from the recorder.', $recording->transcript); + $this->assertSame(100, $recording->transcription_percent); + $this->assertSame('Transcription complete', $recording->transcription_progress); $this->assertNotNull($recording->transcribed_at); } + + public function test_transcription_job_stores_error_on_failure(): void + { + Storage::fake('local'); + Storage::disk('local')->put('recordings/bad.mp3', 'fake-audio-bytes'); + + Transcription::fake(function () { + throw new \RuntimeException('Provider unavailable'); + }); + + $recording = Recording::query()->create([ + 'title' => 'Bad', + 'original_filename' => 'bad.mp3', + 'file_path' => 'recordings/bad.mp3', + 'file_size_bytes' => 12, + 'transcription_status' => 'pending', + 'transcription_driver' => 'cloud', + ]); + + try { + (new TranscribeRecording($recording))->handle(app(TranscriptionService::class)); + $this->fail('Expected transcription to throw'); + } catch (\RuntimeException $e) { + $this->assertSame('Provider unavailable', $e->getMessage()); + } + + $recording->refresh(); + $this->assertSame('failed', $recording->transcription_status); + $this->assertSame('Provider unavailable', $recording->transcription_error); + } }