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); if ($this->usesHttpTransport()) { return $this->transcribeViaHttp($recording, $report, $message, $model, $filename, $path, $shouldContinue); } return $this->transcribeViaCurl($recording, $report, $message, $model, $filename, $path, $shouldContinue); } /** * Laravel Http client path (used in tests via Http::fake). * * Does not set Guzzle stream => true, which would string-cast the multipart upload. * * @param Closure(string, int, ?string, ?array): void $report * @param (Closure(): bool)|null $shouldContinue */ private function transcribeViaHttp( Recording $recording, Closure $report, string $message, string $model, string $filename, string $path, ?Closure $shouldContinue, ): string { $response = $this->localWhisperRequest($filename, $path) ->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().'): '.$this->truncateBody($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, 'transport' => 'http', ]); return $this->extractTranscriptText($response->json(), $report, $message); } return $this->consumeSseBody($response->body(), $report, $message, $recording, $shouldContinue); } /** * Curl path for production: streams the file upload and SSE response chunks. * * @param Closure(string, int, ?string, ?array): void $report * @param (Closure(): bool)|null $shouldContinue */ private function transcribeViaCurl( Recording $recording, Closure $report, string $message, string $model, string $filename, string $path, ?Closure $shouldContinue, ): string { $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); $apiKey = (string) ($config['key'] ?? 'not-needed'); $buffer = ''; $rawBody = ''; $accumulated = ''; $lastPercent = 0; $isSse = null; $result = $this->curl->streamTranscription( $baseUrl.'/audio/transcriptions', $apiKey, $path, $filename, $recording->audioMimeType(), $model, $timeout, function (string $chunk) use ( &$buffer, &$rawBody, &$accumulated, &$lastPercent, &$isSse, $report, $message, $recording, $shouldContinue, ): bool { if ($shouldContinue !== null && ! $shouldContinue()) { return false; } if ($isSse === null) { $rawBody .= $chunk; return true; } if ($isSse === false) { $rawBody .= $chunk; return true; } if ($rawBody !== '') { $buffer .= $rawBody; $rawBody = ''; foreach ($this->stream->extractPayloads($buffer) as $payload) { [$accumulated, $lastPercent] = $this->ingestEvent( $payload, $accumulated, $lastPercent, $recording, $report, $message, ); } } $buffer .= $chunk; foreach ($this->stream->extractPayloads($buffer) as $payload) { [$accumulated, $lastPercent] = $this->ingestEvent( $payload, $accumulated, $lastPercent, $recording, $report, $message, ); } return true; }, function (string $contentType) use (&$isSse): void { $isSse = str_contains(strtolower($contentType), 'text/event-stream'); }, ); if ($result['status'] >= 400 || $result['status'] === 0) { throw new RuntimeException( 'Local transcription failed (HTTP '.$result['status'].'): '.$this->truncateBody($rawBody) ); } $contentType = strtolower($result['content_type']); $isSse ??= str_contains($contentType, 'text/event-stream'); if (! $isSse) { Log::warning('Whisper did not stream SSE; falling back to a single JSON body', [ 'recording_id' => $recording->id, 'content_type' => $contentType, 'transport' => 'curl', ]); $json = json_decode($rawBody, true); return $this->extractTranscriptText(is_array($json) ? $json : null, $report, $message); } if ($rawBody !== '') { $buffer .= $rawBody; $rawBody = ''; } 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, ); } if ($accumulated === '') { throw new RuntimeException('Local transcription returned an empty transcript.'); } return $accumulated; } private function usesHttpTransport(): bool { return config('ai.local_whisper_transport', 'curl') === 'http'; } /** * @param Closure(string, int, ?string, ?array): void $report * @param (Closure(): bool)|null $shouldContinue */ private function consumeSseBody( string $body, Closure $report, string $message, Recording $recording, ?Closure $shouldContinue, ): string { if ($shouldContinue !== null && ! $shouldContinue()) { throw new RuntimeException('Local transcription returned an empty transcript.'); } $buffer = $body; $accumulated = ''; $lastPercent = 0; 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, ); } 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 array|null $json * @param Closure(string, int, ?string, ?array): void $report */ private function extractTranscriptText(?array $json, ?Closure $report = null, string $message = ''): string { 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; } private function truncateBody(string $body): string { $body = trim($body); if (strlen($body) <= 2000) { return $body; } return substr($body, 0, 2000).'…'; } }