null; $model = config('ai.local_whisper_model', 'Systran/faster-whisper-base'); $report("Transcribing locally with {$model} (audio stays on this machine)…", 0); 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, $shouldContinue); } return (string) $transcript; } /** * Call local Whisper, streaming SSE when the server supports it. * * @param Closure(string, int, ?string, ?array): void $report * @param (Closure(): bool)|null $shouldContinue */ private function transcribeViaLocalWhisper(Recording $recording, Closure $report, string $model, ?Closure $shouldContinue): 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); $response = $this->localWhisperRequest($filename, $path) ->withOptions(['stream' => true]) ->post('audio/transcriptions', [ 'model' => $model, 'response_format' => 'verbose_json', 'stream' => 'true', 'without_timestamps' => 'false', 'timestamp_granularities[]' => 'word', ]); if (! $response->successful()) { throw new RuntimeException( 'Local transcription failed (HTTP '.$response->status().'): '.$response->body() ); } $contentType = strtolower((string) $response->header('Content-Type')); if (! str_contains($contentType, 'text/event-stream')) { Log::warning('Whisper did not stream SSE; falling back to a single JSON body', [ 'recording_id' => $recording->id, 'content_type' => $contentType, ]); return $this->extractTranscriptText($response, $report, $message); } return $this->consumeWhisperStream($response, $report, $message, $recording, $shouldContinue); } /** * @param Closure(string, int, ?string, ?array): void $report * @param (Closure(): bool)|null $shouldContinue */ private function consumeWhisperStream( Response $response, Closure $report, string $message, Recording $recording, ?Closure $shouldContinue, ): string { $body = $response->toPsrResponse()->getBody(); $buffer = ''; $accumulated = ''; $lastPercent = 0; $idleReads = 0; try { while (! $body->eof()) { if ($shouldContinue !== null && ! $shouldContinue()) { $body->close(); break; } $chunk = $body->read(8192); if ($chunk === '') { $idleReads++; if ($idleReads >= 40) { break; } usleep(50_000); continue; } $idleReads = 0; $buffer .= $chunk; foreach ($this->stream->extractPayloads($buffer) as $payload) { [$accumulated, $lastPercent] = $this->ingestEvent( $payload, $accumulated, $lastPercent, $recording, $report, $message, ); } } foreach ($this->stream->flushBuffer($buffer) as $payload) { [$accumulated, $lastPercent] = $this->ingestEvent( $payload, $accumulated, $lastPercent, $recording, $report, $message, ); } } finally { $response->close(); } if ($accumulated === '') { throw new RuntimeException('Local transcription returned an empty transcript.'); } return $accumulated; } /** * @param Closure(string, int, ?string, ?array): void $report * @return array{0: string, 1: int} */ private function ingestEvent( string $payload, string $accumulated, int $lastPercent, Recording $recording, Closure $report, string $message, ): array { $event = $this->stream->parseEvent($payload); if ($event === null) { return [$accumulated, $lastPercent]; } $accumulated = $this->stream->applyEvent($accumulated, $event); $whisper = $event['whisper'] ?? []; if ($accumulated === '' && $whisper === []) { return [$accumulated, $lastPercent]; } $lastPercent = $this->percentForEvent($event, $recording, $lastPercent); $report( $message, $lastPercent, $accumulated === '' ? null : $accumulated, $whisper === [] ? null : $whisper, ); return [$accumulated, $lastPercent]; } /** * @param array{append: ?string, replace: ?string, end: ?float, done: bool, legacy: bool, whisper?: array} $event */ private function percentForEvent(array $event, Recording $recording, int $lastPercent): int { $duration = $recording->duration_seconds; $end = $event['end'] ?? null; if ($end === null || $duration === null || $duration <= 0) { return $lastPercent; } return (int) min(99, max($lastPercent, round(100 * $end / $duration))); } 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) ->connectTimeout(10) ->attach('file', fopen($path, 'r'), $filename); } /** * @param Closure(string, int, ?string, ?array): void $report */ private function extractTranscriptText(Response $response, ?Closure $report = null, string $message = ''): string { $json = $response->json(); if (! is_array($json)) { throw new RuntimeException('Local transcription returned an empty transcript.'); } $text = $json['text'] ?? null; if (! is_string($text) || $text === '') { throw new RuntimeException('Local transcription returned an empty transcript.'); } $whisper = $this->stream->extractWhisperMeta($json); if ($report !== null && $whisper !== []) { $report($message, 99, $text, $whisper); } return $text; } }