'Default: best accuracy, speaker labels and Arabic support.', 'slam-1' => 'Cheaper, English-only variant.', 'nano' => 'Cheapest and fastest; rough on noisy or Arabic audio.', ]; protected int $pollInterval = 3; protected int $pollTimeout = 900; public function driver(): string { return 'assemblyai'; } public function test(AiVoiceProvider $provider): array { try { $response = Http::withHeaders(['authorization' => $this->apiKey($provider)]) ->timeout(20) ->acceptJson() ->get($this->baseUrl($provider, static::DEFAULT_BASE) . '/v2/transcript', ['limit' => 1]); 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 { $transcriptIds = []; $transcripts = []; foreach ($chunks as $chunk) { $uploadUrl = $this->upload($provider, $chunk); $transcriptId = $this->createTranscript($provider, $uploadUrl); $payload = $this->awaitTranscript($provider, $transcriptId); $transcriptIds[] = $transcriptId; $text = $this->formatTranscript($payload); if ($text !== '') { $transcripts[] = $text; } } $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.'); } return [ 'transcript' => $transcript, 'summary' => $this->summarize($provider, $transcriptIds, $context), 'meta' => [ 'transcription_model' => $this->model($provider), 'chunks' => count($chunks), 'transcript_ids' => $transcriptIds, ], ]; } protected function model(AiVoiceProvider $provider): string { $model = trim((string) $provider->transcription_model); return $model !== '' ? $model : static::DEFAULT_MODEL; } protected function upload(AiVoiceProvider $provider, string $path): string { $bytes = @file_get_contents($path); if ($bytes === false) { throw new AiProviderException($this->driver(), 'The prepared audio chunk could not be read for upload.'); } $response = Http::withHeaders(['authorization' => $this->apiKey($provider)]) ->timeout($this->timeout()) ->acceptJson() ->withBody($bytes, 'application/octet-stream') ->post($this->baseUrl($provider, static::DEFAULT_BASE) . '/v2/upload'); if (!$response->successful()) { throw new AiProviderException($this->driver(), $this->errorMessage($response)); } $url = (string) $response->json('upload_url'); if ($url === '') { throw new AiProviderException($this->driver(), 'The provider did not return an upload URL.'); } return $url; } protected function createTranscript(AiVoiceProvider $provider, string $audioUrl): string { $response = Http::withHeaders(['authorization' => $this->apiKey($provider)]) ->timeout(60) ->acceptJson() ->post($this->baseUrl($provider, static::DEFAULT_BASE) . '/v2/transcript', array_filter([ 'audio_url' => $audioUrl, 'speech_model' => $this->model($provider), 'speaker_labels' => true, 'punctuate' => true, 'format_text' => true, 'language_code' => $this->language($provider), ])); if (!$response->successful()) { throw new AiProviderException($this->driver(), $this->errorMessage($response)); } $id = (string) $response->json('id'); if ($id === '') { throw new AiProviderException($this->driver(), 'The provider did not return a transcript id.'); } return $id; } protected function awaitTranscript(AiVoiceProvider $provider, string $transcriptId): array { $deadline = time() + $this->pollTimeout; while (time() < $deadline) { $response = Http::withHeaders(['authorization' => $this->apiKey($provider)]) ->timeout(60) ->acceptJson() ->get($this->baseUrl($provider, static::DEFAULT_BASE) . '/v2/transcript/' . $transcriptId); if (!$response->successful()) { throw new AiProviderException($this->driver(), $this->errorMessage($response)); } $payload = (array) $response->json(); $status = (string) ($payload['status'] ?? ''); if ($status === 'completed') { return $payload; } if ($status === 'error') { throw new AiProviderException( $this->driver(), 'Transcription failed: ' . Str::limit((string) ($payload['error'] ?? 'unknown error'), 300) ); } sleep($this->pollInterval); } throw new AiProviderException($this->driver(), 'Timed out waiting for the transcript to finish.'); } protected function formatTranscript(array $payload): string { $utterances = $payload['utterances'] ?? null; if (is_array($utterances) && $utterances) { $lines = []; foreach ($utterances as $utterance) { $text = trim((string) ($utterance['text'] ?? '')); if ($text === '') { continue; } $lines[] = 'Speaker ' . self::speakerLabel($utterance['speaker'] ?? null) . ': ' . $text; } return trim(implode("\n", $lines)); } return trim((string) ($payload['text'] ?? '')); } /** * LeMUR summarizes the transcript server-side, so the full text has to be * sent up even though it usually fits in one request. */ protected function summarize(AiVoiceProvider $provider, array $transcriptIds, array $context): string { $response = Http::withHeaders(['authorization' => $this->apiKey($provider)]) ->timeout($this->timeout()) ->acceptJson() ->post($this->baseUrl($provider, static::DEFAULT_BASE) . '/lemur/v3/generate/summary', [ 'transcript_ids' => $transcriptIds, 'context' => $this->systemPrompt($provider) . "\n\n" . $this->contextPreamble($context), 'answer_format' => 'paragraph', ]); if (!$response->successful()) { throw new AiProviderException($this->driver(), $this->errorMessage($response)); } $summary = trim((string) $response->json('response')); if ($summary === '') { throw new AiProviderException($this->driver(), 'The provider returned an empty summary.'); } return $summary; } }