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, $shouldContinue); } $report('Received transcript from local Whisper…', 85); return (string) $transcript; } /** * Call local Whisper, streaming SSE when the server supports it. * * @param Closure(string, int, ?string): 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' => 'json', 'stream' => 'true', 'without_timestamps' => 'false', ]); 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')) { return $this->extractTranscriptText($response); } return $this->consumeWhisperStream($response, $report, $message, $recording->duration_seconds, $shouldContinue); } /** * @param Closure(string, int, ?string): void $report * @param (Closure(): bool)|null $shouldContinue */ private function consumeWhisperStream( Response $response, Closure $report, string $message, ?int $durationSeconds, ?Closure $shouldContinue, ): string { $body = $response->toPsrResponse()->getBody(); $buffer = ''; $accumulated = ''; $lastPercent = 50; $eventsWithoutTimestamp = 0; $lastFlushAt = 0.0; $pending = false; try { while (! $body->eof()) { if ($shouldContinue !== null && ! $shouldContinue()) { $body->close(); break; } $chunk = $body->read(8192); if ($chunk === '') { break; } $buffer .= $chunk; foreach ($this->stream->extractPayloads($buffer) as $payload) { [$accumulated, $lastPercent, $eventsWithoutTimestamp, $pending] = $this->ingestEvent( $payload, $accumulated, $lastPercent, $eventsWithoutTimestamp, $durationSeconds, $report, $message, $lastFlushAt, $pending, ); } } foreach ($this->stream->flushBuffer($buffer) as $payload) { [$accumulated, $lastPercent, $eventsWithoutTimestamp, $pending] = $this->ingestEvent( $payload, $accumulated, $lastPercent, $eventsWithoutTimestamp, $durationSeconds, $report, $message, $lastFlushAt, $pending, ); } if ($pending && $accumulated !== '') { $report($message, $lastPercent, $accumulated); } } finally { $response->close(); } if ($accumulated === '') { throw new RuntimeException('Local transcription returned an empty transcript.'); } return $accumulated; } /** * @param Closure(string, int, ?string): void $report * @return array{0: string, 1: int, 2: int, 3: bool} */ private function ingestEvent( string $payload, string $accumulated, int $lastPercent, int $eventsWithoutTimestamp, ?int $durationSeconds, Closure $report, string $message, float &$lastFlushAt, bool $pending, ): array { $event = $this->stream->parseEvent($payload); if ($event === null) { return [$accumulated, $lastPercent, $eventsWithoutTimestamp, $pending]; } $wasEmpty = $accumulated === ''; $accumulated = $this->stream->applyEvent($accumulated, $event); if ($accumulated === '') { return [$accumulated, $lastPercent, $eventsWithoutTimestamp, $pending]; } $percent = $this->percentForEvent($event, $durationSeconds, $lastPercent, $eventsWithoutTimestamp); $lastPercent = max($lastPercent, $percent); $now = microtime(true); $shouldFlush = $wasEmpty || $event['done'] || ($now - $lastFlushAt) >= 1.0; if ($shouldFlush) { $report($message, $lastPercent, $accumulated); $lastFlushAt = $now; return [$accumulated, $lastPercent, $eventsWithoutTimestamp, false]; } return [$accumulated, $lastPercent, $eventsWithoutTimestamp, true]; } /** * @param array{append: ?string, replace: ?string, end: ?float, done: bool, legacy: bool} $event */ private function percentForEvent(array $event, ?int $durationSeconds, int $lastPercent, int &$eventsWithoutTimestamp): int { if ($event['end'] !== null && $durationSeconds !== null && $durationSeconds > 0) { return (int) min(99, max(50, round(100 * $event['end'] / $durationSeconds))); } $eventsWithoutTimestamp++; return min(84, max($lastPercent, 50 + $eventsWithoutTimestamp)); } 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); } private function extractTranscriptText(Response $response): string { $text = $response->json('text'); if (! is_string($text) || $text === '') { throw new RuntimeException('Local transcription returned an empty transcript.'); } return $text; } }