Skip to content
TutorialLaravel

Arabic Text-to-Speech with Cartesia and API Cost Control in Laravel (Part 4 of 4)

D

Dinesh Wijethunga

July 13, 2026 8 min readIntermediate 79 views
ShareX / TwitterLinkedIn
πŸŽ“

Part 4: Text-to-Speech, Cost Tracking, and Rate Limiting

The final piece of the pipeline converts Claude's plain-text response back into spoken audio. This part covers the Cartesia TTS service, how to calculate exactly what each voice request costs in USD, and a rate limiter that keeps your monthly API bill predictable.

The Cartesia Text-to-Speech Service

Place at Modules/Assistant/app/Services/TextToSpeechService.php.

<?php

namespace Modules\Assistant\Services;

use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;

class TextToSpeechService
{
    private string $apiKey;
    private string $baseUrl;
    private string $model;
    private string $apiVersion;
    private array  $outputConfig;
    private array  $voices;

    public function __construct()
    {
        $this->apiKey      = (string) config('assistant.cartesia.api_key');
        $this->baseUrl     = (string) config('assistant.cartesia.base_url');
        $this->model       = (string) config('assistant.cartesia.model');
        $this->apiVersion  = (string) config('assistant.cartesia.api_version');
        $this->outputConfig = [
            'container'   => config('assistant.cartesia.output_format.container'),
            'encoding'    => config('assistant.cartesia.output_format.encoding'),
            'sample_rate' => (int) config('assistant.cartesia.output_format.sample_rate'),
            'bit_rate'    => (int) config('assistant.cartesia.output_format.bit_rate'),
        ];
        $this->voices = (array) config('assistant.cartesia.voices');
    }

    /**
     * Convert text to speech and return base64-encoded MP3 audio.
     *
     * @return array{
     *   audio_base64: string,
     *   char_count: int,
     *   duration_sec: float,
     *   latency_ms: int
     * }
     */
    public function generateSpeech(string $text, string $language = 'ar'): array
    {
        $voiceId = $this->voices[$language] ?? $this->voices['ar'];

        $startMs = (int) (microtime(true) * 1000);

        $response = Http::withHeaders([
            'X-API-Key'        => $this->apiKey,
            'Cartesia-Version' => $this->apiVersion,
            'Content-Type'     => 'application/json',
        ])
        ->timeout(30)
        ->post($this->baseUrl . '/tts/bytes', [
            'model_id'      => $this->model,
            'transcript'    => $text,
            'voice'         => [
                'mode' => 'id',
                'id'   => $voiceId,
            ],
            'output_format' => $this->outputConfig,
            'language'      => $language === 'ar' ? 'ar' : 'en',
        ]);

        $latencyMs = (int) (microtime(true) * 1000) - $startMs;

        if ($response->failed()) {
            Log::error('Cartesia TTS error', [
                'status'   => $response->status(),
                'body'     => $response->body(),
                'language' => $language,
            ]);
            throw new \RuntimeException('TTS service error: ' . $response->status());
        }

        $audioBytes = $response->body();

        // 128kbps MP3 = 16,000 bytes/sec β€” estimate duration from raw byte count
        $durationSec = strlen($audioBytes) / 16000;

        return [
            'audio_base64' => base64_encode($audioBytes),
            'char_count'   => mb_strlen($text),
            'duration_sec' => round($durationSec, 2),
            'latency_ms'   => $latencyMs,
        ];
    }
}

Two things to notice here. First, voice routing: Cartesia assigns separate voice IDs per language. We store them in config as voices.ar and voices.en and fall back to Arabic if the language is unknown. Second, audio duration is estimated from byte count β€” 128kbps MP3 encodes at exactly 16,000 bytes per second, so a simple division gives a reliable estimate without having to decode the audio file.

The Config File

Place at Modules/Assistant/config/assistant.php.

<?php

return [

    'deepgram' => [
        'api_key'      => env('DEEPGRAM_API_KEY'),
        'base_url'     => 'https://api.deepgram.com/v1',
        'model'        => 'nova-3',
        'language'     => 'multi',
        'price_per_sec' => 0.00011944,
        'keyterms'     => [],
    ],

    'anthropic' => [
        'api_key'           => env('ANTHROPIC_API_KEY'),
        'base_url'          => 'https://api.anthropic.com/v1',
        'api_version'       => '2023-06-01',
        'model'             => 'claude-sonnet-4-6',
        'max_tokens'        => 1024,
        'temperature'       => 0.3,
        'price_input_token' => 0.000003,
        'price_output_token'=> 0.000015,
    ],

    'cartesia' => [
        'api_key'     => env('CARTESIA_API_KEY'),
        'base_url'    => 'https://api.cartesia.ai',
        'api_version' => '2024-06-10',
        'model'       => 'sonic-3',
        'price_per_char' => 0.000065,
        'output_format' => [
            'container'   => 'mp3',
            'encoding'    => 'mp3',
            'sample_rate' => 44100,
            'bit_rate'    => 128000,
        ],
        'voices' => [
            'ar'    => env('CARTESIA_VOICE_AR'),
            'en'    => env('CARTESIA_VOICE_EN'),
            'mixed' => env('CARTESIA_VOICE_AR'),
        ],
    ],

    'rate_limits' => [
        'minute' => ['requests' => 10, 'voice_requests' => 5, 'cost_usd' => 0.50],
        'hour'   => ['requests' => 60, 'voice_requests' => 30, 'cost_usd' => 5.00],
        'day'    => ['requests' => 200, 'voice_requests' => 100, 'cost_usd' => 20.00],
    ],

    'session' => [
        'expiry_minutes' => 30,
        'max_turns'      => 20,
        'max_audio_sec'  => 30,
    ],

];

Per-Request Cost Tracking

Every voice request touches three paid APIs. After the pipeline completes, calculate the exact cost and log it β€” then store it against the session so you can build a usage dashboard later.

private function calculateCost(
    float $audioDurationSec,
    int   $inputTokens,
    int   $outputTokens,
    int   $ttsCharCount
): array {
    $sttCost = $audioDurationSec * (float) config('assistant.deepgram.price_per_sec');
    $llmCost = ($inputTokens * (float) config('assistant.anthropic.price_input_token'))
             + ($outputTokens * (float) config('assistant.anthropic.price_output_token'));
    $ttsCost = $ttsCharCount * (float) config('assistant.cartesia.price_per_char');

    return [
        'stt_usd'   => round($sttCost, 6),
        'llm_usd'   => round($llmCost, 6),
        'tts_usd'   => round($ttsCost, 6),
        'total_usd' => round($sttCost + $llmCost + $ttsCost, 6),
    ];
}

A typical voice exchange in Arabic β€” 5 seconds of audio, 200 input tokens, 80 output tokens, 120 TTS characters β€” costs roughly $0.0024. At 100 voice requests per day per active user that's around $0.24/day per user. Track this early so you can set pricing before you scale.

The Rate Limiter

Rate limits run on three windows simultaneously. Implement the check as a method on the controller before the pipeline runs:

private function enforceRateLimit(int $userId, bool $isVoice): void
{
    $limits = [
        'minute' => [60,   config('assistant.rate_limits.minute')],
        'hour'   => [3600, config('assistant.rate_limits.hour')],
        'day'    => [86400, config('assistant.rate_limits.day')],
    ];

    foreach ($limits as $window => [$ttl, $cfg]) {
        // Request count
        $reqKey   = "assistant:rl:req:{$window}:{$userId}";
        $reqCount = (int) Cache::get($reqKey, 0);

        if ($reqCount >= $cfg['requests']) {
            throw new \Illuminate\Http\Exceptions\ThrottleRequestsException(
                "Rate limit exceeded β€” too many requests this {$window}."
            );
        }

        // Voice-specific count
        if ($isVoice) {
            $voiceKey   = "assistant:rl:voice:{$window}:{$userId}";
            $voiceCount = (int) Cache::get($voiceKey, 0);

            if ($voiceCount >= $cfg['voice_requests']) {
                throw new \Illuminate\Http\Exceptions\ThrottleRequestsException(
                    "Rate limit exceeded β€” too many voice requests this {$window}."
                );
            }
        }

        // Cost guard β€” checked after the request finishes (see below)
        // Store the running cost per window in a separate cache key
    }
}

private function incrementRateLimitCounters(int $userId, bool $isVoice, float $totalCostUsd): void
{
    $windows = [
        'minute' => 60,
        'hour'   => 3600,
        'day'    => 86400,
    ];

    foreach ($windows as $window => $ttl) {
        $reqKey = "assistant:rl:req:{$window}:{$userId}";
        Cache::add($reqKey, 0, $ttl);
        Cache::increment($reqKey);

        if ($isVoice) {
            $voiceKey = "assistant:rl:voice:{$window}:{$userId}";
            Cache::add($voiceKey, 0.0, $ttl);
            Cache::increment($voiceKey);
        }

        // Accumulate cost β€” stored as micro-dollars to avoid float precision loss
        $costKey = "assistant:rl:cost:{$window}:{$userId}";
        Cache::add($costKey, 0, $ttl);
        Cache::increment($costKey, (int) round($totalCostUsd * 1_000_000));
    }
}

private function enforceCostLimit(int $userId): void
{
    $limits = [
        'minute' => config('assistant.rate_limits.minute.cost_usd'),
        'hour'   => config('assistant.rate_limits.hour.cost_usd'),
        'day'    => config('assistant.rate_limits.day.cost_usd'),
    ];

    foreach ($limits as $window => $limitUsd) {
        $costKey     = "assistant:rl:cost:{$window}:{$userId}";
        $spentMicro  = (int) Cache::get($costKey, 0);
        $spentUsd    = $spentMicro / 1_000_000;

        if ($spentUsd >= $limitUsd) {
            throw new \Illuminate\Http\Exceptions\ThrottleRequestsException(
                "Cost limit reached for this {$window}."
            );
        }
    }
}

Storing cost as micro-dollars (multiply by 1,000,000) lets you use Redis's atomic integer increment instead of read-modify-write on a float β€” no race conditions under parallel requests.

Putting the Pipeline Together

Here's the full controller flow with all parts connected:

public function chat(AssistantChatRequest $request): JsonResponse
{
    $dto  = AssistantMessageDTO::fromRequest($request);
    $user = auth()->user();

    // Step 1 β€” Rate limit check (before any API call)
    $this->enforceRateLimit($user->id, $dto->inputType === 'voice');
    $this->enforceCostLimit($user->id);

    // Step 2 β€” Load or create session
    $session = $this->loadOrCreateSession($dto, $user);

    // Step 3 β€” STT (voice path only)
    $transcriptText = $dto->text;
    $sttDurationSec = 0.0;

    if ($dto->inputType === 'voice') {
        $sttResult      = $this->stt->processAudio($dto->audioBase64);
        $transcriptText = $sttResult['transcript'];
        $sttDurationSec = $sttResult['duration_sec'];
    }

    // Step 4 β€” Language detection
    $languageDetected = $this->identifyLanguage($transcriptText, $dto->language);

    // Step 5 β€” Build conversation history
    $history = $this->buildHistory($session, $dto, $transcriptText);

    // Step 6 β€” Assemble user context
    $userContext = $this->assembleUserContext($user, $session);

    // Step 7 β€” LLM (with optional tool use turn)
    $llmResult = $this->llm->complete($history, $userContext, $languageDetected->value, $dto->moduleContext);

    $inputTokens  = $llmResult['input_tokens'];
    $outputTokens = $llmResult['output_tokens'];
    $responseText = $llmResult['response_text'];

    if ($llmResult['tool_name']) {
        [$toolResult] = $this->dispatchToolAction(
            $llmResult['tool_name'],
            $llmResult['tool_input'],
            $user,
            $session
        );

        $history[] = ['role' => 'assistant', 'content' => $llmResult['raw_content']];

        $finalResult   = $this->llm->resumeWithToolResult(
            $history, $llmResult['tool_use_id'],
            $toolResult ?? [],
            $userContext, $languageDetected->value, $dto->moduleContext
        );

        $responseText  = $finalResult['response_text'];
        $inputTokens  += $finalResult['input_tokens'];
        $outputTokens += $finalResult['output_tokens'];
    }

    // Step 8 β€” TTS (voice path only, non-fatal)
    $audioBase64  = null;
    $ttsCharCount = 0;

    if ($dto->inputType === 'voice') {
        try {
            $ttsResult    = $this->tts->generateSpeech($responseText, $languageDetected->value);
            $audioBase64  = $ttsResult['audio_base64'];
            $ttsCharCount = $ttsResult['char_count'];
        } catch (\Throwable $e) {
            Log::warning('TTS failed β€” returning text only', ['error' => $e->getMessage()]);
        }
    }

    // Step 9 β€” Cost calculation and counter increment
    $cost = $this->calculateCost($sttDurationSec, $inputTokens, $outputTokens, $ttsCharCount);
    $this->incrementRateLimitCounters($user->id, $dto->inputType === 'voice', $cost['total_usd']);

    // Step 10 β€” Persist session
    $this->persistSession($session, $history, $transcriptText, $responseText, $cost);

    return response()->json([
        'response'   => $responseText,
        'audio'      => $audioBase64,
        'language'   => $languageDetected->value,
        'session_id' => $session->session_id,
        'cost'       => $cost,
    ]);
}

What This Series Covered

Across four parts you built a production voice assistant for a UAE ride-booking platform:

  • Part 1 β€” Architecture and Deepgram STT. Multi-language Arabic Gulf transcription with keyterm boosting.
  • Part 2 β€” Session management, language detection, and the AssistantMessageDTO.
  • Part 3 β€” Claude Sonnet tool use. The two-turn flow, system prompt design, and fare estimate memory.
  • Part 4 β€” Cartesia TTS, per-call cost tracking, and multi-window rate limiting.

The total cost per voice conversation sits around $0.002–$0.005 depending on message length. At scale that's predictable enough to build a freemium tier around β€” give users 20 free voice requests per day and charge above that.

Infrastructure You'll Need

To run this in production you need reliable transactional email for booking confirmations alongside the voice flow. Mailgun handles the MENA region well β€” dedicated IPs, Arabic-locale templates, and a generous free tier to start. Pair it with Hostinger VPS for the PHP 8.2 + Redis stack if you're not yet on a managed cloud.

D
Dinesh Wijethunga

Senior Full Stack Developer Β· Building SaaS products & teaching Laravel/React Β· 10+ years experience Β· Founder of Orion360 Β· Based in Dubai, UAE.

Was this post helpful?

Reviews & Ratings

Sign in to leave a review.

Comments(0)

Guest comments are held for moderation.

You might also like