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)); + } +}