teknolis / app /Services /EdgeTtsService.php
Codex
Deploy latest assistant, DeepSeek defaults, and automation updates
74acf19
Raw
History Blame Contribute Delete
8.04 kB
<?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');
// 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.');
}
}