null; return match ($recording->transcription_driver) { 'cloud' => $this->viaCloud($recording, $report), 'local' => $this->viaLocal($recording, $report), 'ollama' => $this->viaRemoteCompatible($recording, $report), default => throw new RuntimeException('Unknown transcription driver: '.$recording->transcription_driver), }; } /** * @param Closure(string, int): void $report */ private function viaCloud(Recording $recording, Closure $report): string { $report('Sending audio to OpenAI Whisper…', 35); $report('Waiting for cloud transcript (this can take a while for long recordings)…', 55); $transcript = Transcription::fromStorage($recording->file_path) ->timeout((int) config('ai.transcription_timeout', 600)) ->generate('openai', 'whisper-1'); $report('Received transcript from OpenAI…', 85); return (string) $transcript; } /** * @param Closure(string, int): void $report */ private function viaLocal(Recording $recording, Closure $report): string { $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); $transcript = Transcription::fromStorage($recording->file_path) ->timeout((int) config('ai.transcription_timeout', 600)) ->generate('local-whisper', $model); $report('Received transcript from local Whisper…', 85); return (string) $transcript; } /** * Call an OpenAI-compatible /v1/audio/transcriptions endpoint at a user-supplied host URL. * * @param Closure(string, int): void $report */ private function viaRemoteCompatible(Recording $recording, Closure $report): string { if (! filled($recording->ollama_url)) { throw new RuntimeException('Ollama host URL is required for remote transcription.'); } $base = $this->normalizeBaseUrl($recording->ollama_url); $model = config('ai.remote_whisper_model', config('ai.local_whisper_model', 'Systran/faster-whisper-base')); $path = $recording->absolutePath(); if (! is_readable($path)) { throw new RuntimeException('Recording audio file is not readable.'); } $report('Connecting to remote host '.$recording->ollama_url.'…', 30); $report("Uploading audio and waiting for transcript ({$model})…", 50); $response = Http::timeout((int) config('ai.transcription_timeout', 600)) ->attach( 'file', fopen($path, 'r'), $recording->original_filename ?: basename($path), ) ->post($base.'/audio/transcriptions', [ 'model' => $model, 'response_format' => 'json', ]); if (! $response->successful()) { throw new RuntimeException( 'Remote transcription failed (HTTP '.$response->status().'): '.$response->body() ); } $text = $response->json('text'); if (! is_string($text) || $text === '') { throw new RuntimeException('Remote transcription returned an empty transcript. Ensure the host exposes OpenAI-compatible /v1/audio/transcriptions.'); } $report('Received transcript from remote host…', 85); return $text; } /** * Normalize a user URL to an OpenAI-style base ending in /v1. */ private function normalizeBaseUrl(string $url): string { $url = rtrim(trim($url), '/'); if (Str::endsWith($url, '/v1')) { return $url; } return $url.'/v1'; } }