| <?php |
|
|
| namespace App\Services; |
|
|
| use Illuminate\Support\Facades\Http; |
| use Illuminate\Support\Facades\Log; |
| use Illuminate\Support\Facades\Process; |
| use Illuminate\Support\Facades\Storage; |
| use Illuminate\Support\Str; |
| use RuntimeException; |
|
|
| class EdgeTtsService |
| { |
| public function synthesize(string $text, ?string $language = null, ?string $gender = null, string $engine = 'edge'): string |
| { |
| $timeoutSec = max(30, (int) config('services.tts.timeout', 240)); |
| |
| $directory = storage_path('app/public/tts'); |
| if (! is_dir($directory)) { |
| mkdir($directory, 0777, true); |
| } |
|
|
| $filename = 'tts_'.now()->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'); |
|
|
| |
| $models = ($language === 'id') |
| ? ['hexgrad/Kokoro-82M', 'facebook/mms-tts-ind'] |
| : ['hexgrad/Kokoro-82M', 'facebook/mms-tts-eng']; |
|
|
| |
| $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(); |
|
|
| |
| if ($status === 200) { |
| $body = $response->body(); |
| if (strlen($body) > 1024) { |
| |
| file_put_contents($absolutePath, $body); |
| chmod($absolutePath, 0666); |
| return Storage::url('tts/' . $filename); |
| } |
| |
| $lastError = $response->json('error') ?? 'Empty audio body'; |
| Log::warning("HF-TTS [{$model}] 200 but empty body: {$lastError}"); |
| break; |
| } |
|
|
| |
| 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; |
| } |
|
|
| |
| if ($status === 429) { |
| Log::warning("HF-TTS [{$model}] rate limited, waiting 10s…"); |
| sleep(10); |
| continue; |
| } |
|
|
| |
| $lastError = $response->json('error') ?? $response->body(); |
| Log::warning("HF-TTS [{$model}] HTTP {$status}: {$lastError}"); |
| break; |
| } |
| } |
|
|
| |
| 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.'); |
| } |
| } |
|
|