From 5cea5192c23403718225174ff0d7f7730c197d6a Mon Sep 17 00:00:00 2001 From: Ben Date: Thu, 13 Aug 2026 12:12:10 +0200 Subject: [PATCH] Add transcription progress handling and local Whisper integration. Implement a new method for calculating elapsed transcription time in the Recording model. Enhance the TranscriptionService to handle local Whisper requests, including asynchronous processing and progress reporting. Update the recordings index view to display transcription progress percentage. Add unit tests for the new transcription service functionality. --- app/Models/Recording.php | 14 +- app/Services/TranscriptionService.php | 123 +++++++++++++++++- .../views/livewire/recordings/index.blade.php | 19 ++- tests/Feature/RecordingUploadTest.php | 29 +++++ tests/Feature/Recordings/IndexTest.php | 1 + tests/Unit/TranscriptionServiceTest.php | 63 +++++++++ 6 files changed, 239 insertions(+), 10 deletions(-) create mode 100644 tests/Unit/TranscriptionServiceTest.php diff --git a/app/Models/Recording.php b/app/Models/Recording.php index 1d514de..d753624 100644 --- a/app/Models/Recording.php +++ b/app/Models/Recording.php @@ -165,6 +165,18 @@ class Recording extends Model }; } + /** + * Seconds since the current transcription run started. + */ + public function transcriptionElapsedSeconds(): ?int + { + if ($this->transcription_started_at === null) { + return null; + } + + return max(0, now()->getTimestamp() - $this->transcription_started_at->getTimestamp()); + } + /** * Search title, metadata, and stored transcript text. */ @@ -556,7 +568,7 @@ class Recording extends Model public function transcriptionStatusPayload(): array { $startedAt = $this->transcription_started_at; - $elapsed = $startedAt ? (int) round($startedAt->diffInSeconds(now())) : null; + $elapsed = $this->transcriptionElapsedSeconds(); return [ 'id' => $this->id, diff --git a/app/Services/TranscriptionService.php b/app/Services/TranscriptionService.php index 19ca952..9e183b5 100644 --- a/app/Services/TranscriptionService.php +++ b/app/Services/TranscriptionService.php @@ -4,7 +4,13 @@ namespace App\Services; use App\Models\Recording; use Closure; +use GuzzleHttp\Promise\PromiseInterface; +use Illuminate\Http\Client\PendingRequest; +use Illuminate\Http\Client\Promises\LazyPromise; +use Illuminate\Http\Client\Response; +use Illuminate\Support\Facades\Http; use Laravel\Ai\Transcription; +use RuntimeException; class TranscriptionService { @@ -21,12 +27,123 @@ class TranscriptionService $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); + if (Transcription::isFaked()) { + $transcript = Transcription::fromStorage($recording->file_path) + ->timeout((int) config('ai.transcription_timeout', 600)) + ->generate('local-whisper', $model); + } else { + $transcript = $this->transcribeViaLocalWhisper($recording, $report, $model); + } $report('Received transcript from local Whisper…', 85); return (string) $transcript; } + + /** + * Call local Whisper and pulse progress while waiting. + * + * @param Closure(string, int): void $report + */ + private function transcribeViaLocalWhisper(Recording $recording, Closure $report, string $model): string + { + $path = $recording->absolutePath(); + + if (! is_readable($path)) { + throw new RuntimeException('Recording audio file is not readable.'); + } + + $message = "Transcribing locally with {$model} (audio stays on this machine)…"; + $filename = $recording->original_filename ?: basename($path); + $request = $this->localWhisperRequest($filename, $path); + + if ($this->shouldTranscribeSynchronously()) { + return $this->extractTranscriptText($request->post('audio/transcriptions', [ + 'model' => $model, + 'response_format' => 'json', + ])); + } + + /** @var LazyPromise $promise */ + $promise = $request + ->async() + ->post('audio/transcriptions', [ + 'model' => $model, + 'response_format' => 'json', + ]); + + $promise->buildPromise(); + + $this->pulseProgressWhilePending($promise, $report, $message); + + /** @var Response $response */ + $response = $promise->wait(); + + return $this->extractTranscriptText($response); + } + + private function localWhisperRequest(string $filename, string $path): PendingRequest + { + $config = config('ai.providers.local-whisper'); + $baseUrl = rtrim((string) ($config['url'] ?? 'http://127.0.0.1:8090/v1'), '/'); + $timeout = (int) config('ai.transcription_timeout', 600); + + return Http::baseUrl($baseUrl) + ->withHeaders(['Authorization' => 'Bearer '.($config['key'] ?? 'not-needed')]) + ->timeout($timeout) + ->attach('file', fopen($path, 'r'), $filename); + } + + /** + * Laravel's HTTP fake does not resolve async promises; use sync in tests. + */ + private function shouldTranscribeSynchronously(): bool + { + return app()->runningUnitTests(); + } + + private function extractTranscriptText(Response $response): string + { + if (! $response->successful()) { + throw new RuntimeException( + 'Local transcription failed (HTTP '.$response->status().'): '.$response->body() + ); + } + + $text = $response->json('text'); + + if (! is_string($text) || $text === '') { + throw new RuntimeException('Local transcription returned an empty transcript.'); + } + + return $text; + } + + /** + * Broadcast incremental progress while Whisper is working. + * + * @param Closure(string, int): void $report + */ + private function pulseProgressWhilePending(LazyPromise $promise, Closure $report, string $message, int $floor = 50, int $ceiling = 84): void + { + $lastPercent = $floor; + $lastPulseAt = microtime(true); + $startedAt = microtime(true); + + while ($promise->getState() === PromiseInterface::PENDING) { + $now = microtime(true); + + if ($now - $lastPulseAt >= 2.0) { + $elapsed = $now - $startedAt; + $percent = $floor + (int) floor(($ceiling - $floor) * (1 - exp(-$elapsed / 90))); + $percent = max($lastPercent + 1, min($ceiling, $percent)); + + $report($message, $percent); + $lastPercent = $percent; + $lastPulseAt = $now; + } + + usleep(250_000); + } + } } diff --git a/resources/views/livewire/recordings/index.blade.php b/resources/views/livewire/recordings/index.blade.php index a625125..90bdd2d 100644 --- a/resources/views/livewire/recordings/index.blade.php +++ b/resources/views/livewire/recordings/index.blade.php @@ -117,7 +117,7 @@ Words word_count > 0 ? number_format($recording->word_count) : '—' }} - - + + + + @if ($recording->transcription_status === 'processing' && $recording->transcription_percent !== null) + + {{ $recording->transcription_percent }}% + + @endif + {{ $recording->created_at?->format('Y-m-d H:i') }} diff --git a/tests/Feature/RecordingUploadTest.php b/tests/Feature/RecordingUploadTest.php index 1831ced..a8a9d03 100644 --- a/tests/Feature/RecordingUploadTest.php +++ b/tests/Feature/RecordingUploadTest.php @@ -14,6 +14,7 @@ use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\Bus; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Storage; use Laravel\Ai\Transcription; use Livewire\Livewire; @@ -203,6 +204,34 @@ class RecordingUploadTest extends TestCase ->assertJsonPath('driver_label', 'Local (faster-whisper)'); } + public function test_transcription_service_calls_local_whisper_http(): void + { + Storage::fake('local'); + Storage::disk('local')->put('recordings/long.mp3', 'fake-audio-bytes'); + + Http::fake([ + '*' => Http::response(['text' => 'Hello from whisper.']), + ]); + + $recording = Recording::query()->create([ + 'user_id' => $this->user->id, + 'title' => 'Long whisper', + 'original_filename' => 'long.mp3', + 'file_path' => 'recordings/long.mp3', + 'file_size_bytes' => 100, + 'transcription_status' => 'processing', + 'transcription_driver' => 'local', + ]); + + $text = app(TranscriptionService::class)->transcribe($recording); + + $this->assertSame('Hello from whisper.', $text); + + Http::assertSent(function ($request): bool { + return str_contains($request->url(), '/audio/transcriptions'); + }); + } + public function test_transcription_job_stores_transcript(): void { Storage::fake('local'); diff --git a/tests/Feature/Recordings/IndexTest.php b/tests/Feature/Recordings/IndexTest.php index 30a740b..8d796fc 100644 --- a/tests/Feature/Recordings/IndexTest.php +++ b/tests/Feature/Recordings/IndexTest.php @@ -126,6 +126,7 @@ class IndexTest extends TestCase Livewire::test(Index::class) ->assertSee('wire:poll', false) ->assertSee('Transcribing') + ->assertSee('40%') ->assertDontSee('Transcribing locally…') ->assertSeeHtml('bg-amber-400'); } diff --git a/tests/Unit/TranscriptionServiceTest.php b/tests/Unit/TranscriptionServiceTest.php new file mode 100644 index 0000000..bb7c62f --- /dev/null +++ b/tests/Unit/TranscriptionServiceTest.php @@ -0,0 +1,63 @@ +checks < 12 ? self::PENDING : self::FULFILLED; + } + + public function resolve($value): void {} + + public function reject($reason): void {} + + public function cancel(): void {} + + public function wait(bool $unwrap = true): mixed + { + return null; + } + }; + }); + + $promise->buildPromise(); + + $percents = []; + $method = new ReflectionMethod(TranscriptionService::class, 'pulseProgressWhilePending'); + $method->invoke($service, $promise, function (string $message, int $percent) use (&$percents): void { + $percents[] = $percent; + }, 'Transcribing…'); + + $this->assertNotEmpty($percents); + $this->assertGreaterThan(50, max($percents)); + $this->assertLessThanOrEqual(84, max($percents)); + } +}