From 21b17c7657b5578093c8d94a11db36998b0a6973 Mon Sep 17 00:00:00 2001 From: Ben Date: Wed, 12 Aug 2026 15:34:16 +0200 Subject: [PATCH] Use local faster-whisper only for transcription. Drop cloud and Ollama engine choices so audio stays on-machine via Docker Whisper, and tighten orphan detection plus UI around a single local flow. --- .env.example | 5 +- README.md | 41 ++----- app/Http/Controllers/TranscribeController.php | 10 +- .../Requests/TranscribeRecordingRequest.php | 24 +---- app/Models/Recording.php | 44 +++++--- app/Services/TranscriptionService.php | 100 +----------------- config/ai.php | 1 - resources/views/recordings/index.blade.php | 10 +- resources/views/recordings/show.blade.php | 83 ++++----------- tests/Feature/RecordingUploadTest.php | 94 ++++++++-------- 10 files changed, 117 insertions(+), 295 deletions(-) diff --git a/.env.example b/.env.example index 11ebab2..811a432 100644 --- a/.env.example +++ b/.env.example @@ -64,13 +64,10 @@ AWS_USE_PATH_STYLE_ENDPOINT=false VITE_APP_NAME="${APP_NAME}" -# AndyTranscribe / Laravel AI -OPENAI_API_KEY= -OPENAI_URL=https://api.openai.com/v1 +# AndyTranscribe / local faster-whisper 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 # Must be greater than TRANSCRIPTION_TIMEOUT so long Whisper jobs are not re-queued mid-run DB_QUEUE_RETRY_AFTER=660 diff --git a/README.md b/README.md index 00621da..569f23a 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # AndyTranscribe -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. +Upload pocket-recorder audio (MP3, WAV, OGG, and more), extract embedded metadata, and transcribe locally with [faster-whisper-server](https://github.com/fedirz/faster-whisper-server) via Docker. Audio never leaves your machine. Built with Laravel 13, Blade, Tailwind CSS 4, and [Laravel AI](https://github.com/laravel/ai). @@ -9,11 +9,9 @@ Built with Laravel 13, Blade, Tailwind CSS 4, and [Laravel AI](https://github.co - 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) via Docker - - **Ollama host** — user-supplied host URL exposing `/v1/audio/transcriptions` +- Queued local transcription (faster-whisper in Docker) - Live transcription progress (stage, %, elapsed time) +- Stop or restart a run anytime - Copy finished transcripts from the recording detail page ## Requirements @@ -22,9 +20,7 @@ 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: [Docker](https://docs.docker.com/get-docker/) (runs Whisper in a container) -- For **remote** transcription: a host with an OpenAI-compatible transcription API +- [Docker](https://docs.docker.com/get-docker/) for the Whisper container ## Setup @@ -48,7 +44,7 @@ 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: +Transcription calls an OpenAI-compatible HTTP API. This project ships Compose for that: ```bash # CPU (works everywhere; slower on long files) @@ -74,20 +70,15 @@ Stop: 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: | Variable | Purpose | | --- | --- | -| `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://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`) | | `DB_QUEUE_RETRY_AFTER` | Database queue retry window; must exceed `TRANSCRIPTION_TIMEOUT` (default `660`) | @@ -99,21 +90,17 @@ Ensure `APP_URL` matches how you access the app (default `http://localhost:8000` ## Running locally -Start the app, queue worker, and Vite together: - -```bash -composer run dev -``` - -For confidential local transcription, also start Whisper: +Start Whisper, then the app stack: ```bash docker compose up -d whisper +composer run dev ``` Or separately: ```bash +docker compose up -d whisper php artisan serve php artisan queue:work npm run dev @@ -126,18 +113,10 @@ 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 choose a transcription engine. -3. Watch live progress on the recording page until the transcript appears. +2. Open the recording and start transcription. +3. Watch live progress until the transcript appears (or stop and restart). 4. Search the list by title, artist, or transcript text. -## Transcription engines - -| Driver | When to use | Needs | -| --- | --- | --- | -| `cloud` | Fastest path; audio leaves your machine | `OPENAI_API_KEY` | -| `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 ```bash diff --git a/app/Http/Controllers/TranscribeController.php b/app/Http/Controllers/TranscribeController.php index d6a4ba4..254bd74 100644 --- a/app/Http/Controllers/TranscribeController.php +++ b/app/Http/Controllers/TranscribeController.php @@ -10,9 +10,9 @@ use Illuminate\Http\RedirectResponse; class TranscribeController extends Controller { /** - * Queue transcription for the recording with the chosen engine. + * Queue local faster-whisper transcription for the recording. * - * Always allowed: stops any current run first, then starts the new engine. + * Always allowed: stops any current run first, then starts a new one. */ public function __invoke(TranscribeRecordingRequest $request, Recording $recording): RedirectResponse { @@ -21,11 +21,9 @@ class TranscribeController extends Controller $recording->refresh(); } - $driver = $request->validated('driver'); - $recording->update([ - 'transcription_driver' => $driver, - 'ollama_url' => $driver === 'ollama' ? rtrim($request->validated('ollama_url'), '/') : null, + 'transcription_driver' => 'local', + 'ollama_url' => null, 'transcription_status' => 'pending', 'transcription_progress' => 'Queued — waiting to start…', 'transcription_percent' => 5, diff --git a/app/Http/Requests/TranscribeRecordingRequest.php b/app/Http/Requests/TranscribeRecordingRequest.php index e5bb1c0..1bae41d 100644 --- a/app/Http/Requests/TranscribeRecordingRequest.php +++ b/app/Http/Requests/TranscribeRecordingRequest.php @@ -3,7 +3,6 @@ namespace App\Http\Requests; use Illuminate\Foundation\Http\FormRequest; -use Illuminate\Validation\Rule; class TranscribeRecordingRequest extends FormRequest { @@ -17,27 +16,6 @@ class TranscribeRecordingRequest extends FormRequest */ public function rules(): array { - return [ - 'driver' => ['required', Rule::in(['cloud', 'local', 'ollama'])], - 'ollama_url' => [ - Rule::requiredIf(fn () => $this->input('driver') === 'ollama'), - 'nullable', - 'url', - 'regex:/^https?:\/\//i', - ], - ]; - } - - /** - * @return array - */ - public function messages(): array - { - return [ - 'driver.required' => 'Choose a transcription engine.', - 'ollama_url.required' => 'Enter the URL of the Ollama host (OpenAI-compatible Whisper endpoint).', - 'ollama_url.url' => 'Enter a valid URL, e.g. http://192.168.1.50:8000', - 'ollama_url.regex' => 'The host URL must start with http:// or https://', - ]; + return []; } } diff --git a/app/Models/Recording.php b/app/Models/Recording.php index ae949f6..a2fa715 100644 --- a/app/Models/Recording.php +++ b/app/Models/Recording.php @@ -78,10 +78,8 @@ class Recording extends Model { 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, + default => $this->transcription_driver ?: 'Local (faster-whisper)', }; }); } @@ -160,7 +158,7 @@ class Recording extends Model try { $job = unserialize($command); } catch (Throwable) { - return (bool) preg_match('/id";i:'.$this->id.';/', $payload); + return $this->payloadMentionsRecording($payload); } return $job instanceof TranscribeRecording @@ -168,6 +166,15 @@ class Recording extends Model }); } + /** + * Fallback payload match when unserialize is unavailable. + */ + private function payloadMentionsRecording(string $payload): bool + { + return (bool) preg_match('/id";i:'.$this->id.';/', $payload) + || str_contains($payload, 'id";s:'.strlen((string) $this->id).':"'.$this->id.'"'); + } + /** * Processing/pending with no worker job left (crashed worker, bad retry_after, etc.). */ @@ -183,12 +190,27 @@ class Recording extends Model $reference = $this->transcription_started_at ?? $this->updated_at; - // Allow a short window after dispatch before the row appears / worker claims it. - if ($reference !== null && $reference->gt(now()->subSeconds(15))) { + if ($reference === null) { + return true; + } + + // Only treat as orphaned after the job could not possibly still be running. + // (A short grace caused false failures while Whisper was still working.) + $orphanAfterSeconds = max(120, (int) config('ai.transcription_timeout', 600) + 60); + + return $reference->lte(now()->subSeconds($orphanAfterSeconds)); + } + + /** + * Whether a payload/job belongs to this recording's transcription run start time. + */ + public function matchesTranscriptionRun(?string $runStartedAt): bool + { + if ($runStartedAt === null || $this->transcription_started_at === null) { return false; } - return true; + return $this->transcription_started_at->getTimestamp() === Carbon::parse($runStartedAt)->getTimestamp(); } /** @@ -202,11 +224,7 @@ class Recording extends Model return false; } - if ($runStartedAt === null || $this->transcription_started_at === null) { - return false; - } - - return $this->transcription_started_at->getTimestamp() === Carbon::parse($runStartedAt)->getTimestamp(); + return $this->matchesTranscriptionRun($runStartedAt); } /** @@ -360,7 +378,7 @@ class Recording extends Model public function transcriptionStatusPayload(): array { $startedAt = $this->transcription_started_at; - $elapsed = $startedAt ? $startedAt->diffInSeconds(now()) : null; + $elapsed = $startedAt ? (int) round($startedAt->diffInSeconds(now())) : null; return [ 'id' => $this->id, diff --git a/app/Services/TranscriptionService.php b/app/Services/TranscriptionService.php index 8a9e1f6..19ca952 100644 --- a/app/Services/TranscriptionService.php +++ b/app/Services/TranscriptionService.php @@ -4,52 +4,18 @@ namespace App\Services; use App\Models\Recording; use Closure; -use Illuminate\Support\Facades\Http; -use Illuminate\Support\Str; use Laravel\Ai\Transcription; -use RuntimeException; class TranscriptionService { /** - * Run transcription for a recording using the selected driver. + * Transcribe a recording with the local faster-whisper server. * * @param (Closure(string, int): void)|null $onProgress */ 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, $report), - 'local' => $this->viaLocal($recording, $report), - 'ollama' => $this->viaRemoteCompatible($recording, $report), - default => throw new RuntimeException('Unknown transcription driver: '.$recording->transcription_driver), - }; - } - - /** - * @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; - } - - /** - * @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); @@ -63,68 +29,4 @@ class TranscriptionService 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, Closure $report): string - { - if (! filled($recording->ollama_url)) { - throw new RuntimeException('Ollama host URL is required for remote transcription.'); - } - - $base = $this->normalizeBaseUrl($recording->ollama_url); - $model = config('ai.remote_whisper_model', config('ai.local_whisper_model', 'Systran/faster-whisper-base')); - $path = $recording->absolutePath(); - - if (! is_readable($path)) { - 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', - fopen($path, 'r'), - $recording->original_filename ?: basename($path), - ) - ->post($base.'/audio/transcriptions', [ - 'model' => $model, - 'response_format' => 'json', - ]); - - if (! $response->successful()) { - throw new RuntimeException( - 'Remote transcription failed (HTTP '.$response->status().'): '.$response->body() - ); - } - - $text = $response->json('text'); - - if (! is_string($text) || $text === '') { - 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; - } - - /** - * Normalize a user URL to an OpenAI-style base ending in /v1. - */ - private function normalizeBaseUrl(string $url): string - { - $url = rtrim(trim($url), '/'); - - if (Str::endsWith($url, '/v1')) { - return $url; - } - - return $url.'/v1'; - } } diff --git a/config/ai.php b/config/ai.php index e19652f..e2c9557 100644 --- a/config/ai.php +++ b/config/ai.php @@ -28,7 +28,6 @@ return [ 'transcription_timeout' => (int) env('TRANSCRIPTION_TIMEOUT', 600), 'local_whisper_model' => env('LOCAL_WHISPER_MODEL', 'Systran/faster-whisper-base'), - 'remote_whisper_model' => env('REMOTE_WHISPER_MODEL', env('LOCAL_WHISPER_MODEL', 'Systran/faster-whisper-base')), /* |-------------------------------------------------------------------------- diff --git a/resources/views/recordings/index.blade.php b/resources/views/recordings/index.blade.php index 11ee11f..635aa12 100644 --- a/resources/views/recordings/index.blade.php +++ b/resources/views/recordings/index.blade.php @@ -37,7 +37,6 @@ Title Duration Status - Engine Uploaded @@ -58,7 +57,7 @@ @endif {{ $recording->duration_formatted }} - + @include('recordings.partials.status-badge', ['status' => $recording->transcription_status]) @if ($recording->isTranscribing() && $recording->transcription_progress)
@@ -66,13 +65,6 @@
@endif - - @if ($recording->transcription_driver) - {{ $recording->transcription_driver }} - @else - — - @endif - {{ $recording->created_at?->format('Y-m-d H:i') }} @endforeach diff --git a/resources/views/recordings/show.blade.php b/resources/views/recordings/show.blade.php index db05a84..2297ef6 100644 --- a/resources/views/recordings/show.blade.php +++ b/resources/views/recordings/show.blade.php @@ -7,7 +7,6 @@ x-data="transcriptionMonitor(@js([ 'statusUrl' => route('recordings.transcription-status', $recording), 'initial' => $recording->transcriptionStatusPayload(), - 'driver' => old('driver', $recording->transcription_driver ?: 'cloud'), ]))" x-init="start()" > @@ -67,68 +66,25 @@
Uploaded
{{ $recording->created_at?->format('Y-m-d H:i') }}
- @if ($recording->ollama_url) -
-
Ollama host
-
{{ $recording->ollama_url }}
-
- @endif

Transcribe

-

Choose an engine anytime — starting a new run stops the current one.

+

+ Audio stays on this machine. Requires + docker compose up -d whisper + (port 8090). +

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