format('Ymd_His').'_'.Str::lower(Str::random(8)).'.mp3'; $absolutePath = $directory.DIRECTORY_SEPARATOR.$filename; if ($engine === 'kokoro') { return $this->synthesizeHuggingFace($text, $language, $gender, $absolutePath, $filename, $timeoutSec); } return $this->synthesizeEdge($text, $language, $gender, $absolutePath, $filename, $timeoutSec); } private function synthesizeEdge(string $text, ?string $language, ?string $gender, string $absolutePath, string $filename, int $timeoutSec): string { $voice = $this->resolveVoice($language, $gender); $binary = $this->resolveBinary(); $result = Process::timeout($timeoutSec)->run([ $binary, '--voice', $voice, '--text', $text, '--write-media', $absolutePath, ]); if (! $result->successful()) { $error = trim($result->errorOutput() ?: $result->output()); Log::error('Edge-TTS error: ' . $error); throw new RuntimeException('Edge-TTS gagal: ' . $error); } chmod($absolutePath, 0666); return Storage::url('tts/'.$filename); } private function synthesizeHuggingFace(string $text, ?string $language, ?string $gender, string $absolutePath, string $filename, int $timeoutSec): string { $token = config('services.huggingface.api_token'); // Model candidates: Kokoro (multilingual, high quality) → MMS language-specific fallback $models = ($language === 'id') ? ['hexgrad/Kokoro-82M', 'facebook/mms-tts-ind'] : ['hexgrad/Kokoro-82M', 'facebook/mms-tts-eng']; // Kokoro needs a voice hint via the inputs array; other models just need plain text $payloads = [ 'hexgrad/Kokoro-82M' => [ 'inputs' => $text, 'parameters' => [ 'voice' => ($language === 'id') ? (($gender === 'male') ? 'im' : 'if') : (($gender === 'male') ? 'am_michael' : 'af_heart'), ], ], 'facebook/mms-tts-ind' => ['inputs' => $text], 'facebook/mms-tts-eng' => ['inputs' => $text], ]; $deadline = time() + $timeoutSec; $lastError = 'Unknown HF-TTS error'; foreach ($models as $model) { $url = "https://router.huggingface.co/hf-inference/models/{$model}"; $payload = $payloads[$model] ?? ['inputs' => $text]; while (time() < $deadline) { $response = Http::withToken($token) ->timeout(min(60, $deadline - time())) ->accept('audio/flac, audio/wav, audio/mpeg, application/octet-stream') ->post($url, $payload); $status = $response->status(); // Binary audio returned directly if ($status === 200) { $body = $response->body(); if (strlen($body) > 1024) { // Persist; extension stays .mp3 but content may be flac/wav — player handles it file_put_contents($absolutePath, $body); chmod($absolutePath, 0666); return Storage::url('tts/' . $filename); } // Tiny body = likely JSON error wrapped in 200 $lastError = $response->json('error') ?? 'Empty audio body'; Log::warning("HF-TTS [{$model}] 200 but empty body: {$lastError}"); break; // try next model } // Model still loading — wait and retry if ($status === 503) { $wait = (int) ($response->json('estimated_time') ?? 5); $wait = max(3, min($wait, 20)); Log::info("HF-TTS [{$model}] loading, retrying in {$wait}s…"); sleep($wait); continue; } // Rate limit — short back-off then retry current model if ($status === 429) { Log::warning("HF-TTS [{$model}] rate limited, waiting 10s…"); sleep(10); continue; } // Any other error: log and try next model $lastError = $response->json('error') ?? $response->body(); Log::warning("HF-TTS [{$model}] HTTP {$status}: {$lastError}"); break; } } // All HF models failed — fall back to Edge TTS Log::error("HF-TTS all models failed: {$lastError} — falling back to Edge TTS"); return $this->synthesizeEdge($text, $language, $gender, $absolutePath, $filename, $timeoutSec); } public function languageOptions(): array { return [ 'id' => 'Indonesia', 'en' => 'English (US)', 'ja' => 'Japanese', 'ko' => 'Korean', 'es' => 'Spanish', ]; } public function genderOptions(): array { return [ 'female' => 'Female', 'male' => 'Male', ]; } private function resolveVoice(?string $language, ?string $gender): string { $language = $language ?: 'id'; $gender = $gender ?: 'female'; $catalog = $this->voiceCatalog(); if (isset($catalog[$language][$gender])) { return $catalog[$language][$gender]; } return (string) config('services.edge_tts.voice', 'id-ID-GadisNeural'); } private function voiceCatalog(): array { return [ 'id' => [ 'female' => 'id-ID-GadisNeural', 'male' => 'id-ID-ArdiNeural', ], 'en' => [ 'female' => 'en-US-JennyNeural', 'male' => 'en-US-GuyNeural', ], 'ja' => [ 'female' => 'ja-JP-NanamiNeural', 'male' => 'ja-JP-KeitaNeural', ], 'ko' => [ 'female' => 'ko-KR-SunHiNeural', 'male' => 'ko-KR-InJoonNeural', ], 'es' => [ 'female' => 'es-ES-ElviraNeural', 'male' => 'es-ES-AlvaroNeural', ], ]; } private function resolveBinary(): string { $configuredBinary = trim((string) config('services.edge_tts.binary', '')); $candidates = [ $configuredBinary, 'edge-tts', base_path('.venv-edge-tts/bin/edge-tts'), '/usr/local/bin/edge-tts', '/usr/bin/edge-tts', '/home/www-data/.local/bin/edge-tts', ]; foreach ($candidates as $candidate) { if ($candidate === '') { continue; } $check = Process::timeout(5)->run([$candidate, '--version']); if ($check->successful()) { return $candidate; } } $find = Process::timeout(5)->run(['which', 'edge-tts']); if ($find->successful() && filled($find->output())) { return trim($find->output()); } throw new RuntimeException('edge-tts belum terinstall atau tidak ditemukan di PATH.'); } }