'Current generation: best accuracy, best on Arabic, diarisation included.', 'nova-2' => 'Previous generation; keep for pinned behaviour.', 'enhanced' => 'Older, cheaper model; noticeably weaker on Arabic.', 'base' => 'Cheapest; only for clean, quiet English calls.', ]; public function driver(): string { return 'deepgram'; } public function test(AiVoiceProvider $provider): array { try { $response = Http::withHeaders(['Authorization' => 'Token ' . $this->apiKey($provider)]) ->timeout(20) ->acceptJson() ->get($this->baseUrl($provider, static::DEFAULT_BASE) . '/auth/token'); if ($response->successful()) { return ['ok' => true, 'message' => 'Connected: the provider accepted the API key.']; } return ['ok' => false, 'message' => $this->errorMessage($response)]; } catch (\Throwable $e) { return ['ok' => false, 'message' => $e->getMessage()]; } } public function analyse(AiVoiceProvider $provider, array $chunks, array $context): array { $transcripts = []; $summaries = []; foreach ($chunks as $chunk) { $result = $this->transcribe($provider, $chunk); if ($result['transcript'] !== '') { $transcripts[] = $result['transcript']; } if ($result['summary'] !== '') { $summaries[] = $result['summary']; } } $transcript = $this->joinTranscripts($transcripts); if ($transcript === '') { throw new AiProviderException($this->driver(), 'No speech was detected in this recording (it may be silent, hold music, or too short to transcribe). Try another call.'); } // A single chunk carries one summary. Long calls are split, and each // part is summarized on its own — they are joined rather than dropped. $summary = $this->joinTranscripts($summaries); if ($summary === '') { throw new AiProviderException($this->driver(), 'The provider returned a transcript but no summary.'); } return [ 'transcript' => $transcript, 'summary' => $summary, 'meta' => [ 'transcription_model' => $this->model($provider), 'chunks' => count($chunks), ], ]; } protected function model(AiVoiceProvider $provider): string { $model = trim((string) $provider->transcription_model); return $model !== '' ? $model : static::DEFAULT_MODEL; } /** * @return array{transcript: string, summary: string} */ protected function transcribe(AiVoiceProvider $provider, string $path): array { $bytes = @file_get_contents($path); if ($bytes === false) { throw new AiProviderException($this->driver(), 'The prepared audio chunk could not be read for upload.'); } $query = array_filter([ 'model' => $this->model($provider), 'language' => $this->language($provider), 'smart_format' => 'true', 'punctuate' => 'true', 'diarize' => 'true', 'summarize' => 'v2', ]); $response = Http::withHeaders(['Authorization' => 'Token ' . $this->apiKey($provider)]) ->timeout($this->timeout()) ->acceptJson() ->withBody($bytes, 'audio/mpeg') ->post($this->baseUrl($provider, static::DEFAULT_BASE) . '/listen?' . http_build_query($query)); if (!$response->successful()) { throw new AiProviderException($this->driver(), $this->errorMessage($response)); } $data = (array) $response->json(); return [ 'transcript' => $this->formatTranscript($data), 'summary' => trim((string) data_get($data, 'results.summary.short', '')), ]; } /** * Group the diarised words into "Speaker A: ..." paragraphs. */ protected function formatTranscript(array $data): string { $words = data_get($data, 'results.channels.0.alternatives.0.words', []); if (!is_array($words) || !$words) { return trim((string) data_get($data, 'results.channels.0.alternatives.0.transcript', '')); } $lines = []; $currentSpeaker = null; $buffer = []; foreach ($words as $word) { $speaker = $word['speaker'] ?? null; $text = trim((string) ($word['punctuated_word'] ?? $word['word'] ?? '')); if ($currentSpeaker !== null && $speaker !== $currentSpeaker && $buffer) { $lines[] = 'Speaker ' . self::speakerLabel($currentSpeaker) . ': ' . trim(implode(' ', $buffer)); $buffer = []; } $currentSpeaker = $speaker; if ($text !== '') { $buffer[] = $text; } } if ($buffer) { $lines[] = 'Speaker ' . self::speakerLabel($currentSpeaker) . ': ' . trim(implode(' ', $buffer)); } return trim(implode("\n", $lines)); } }