null; $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); 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); } } }