whisperContainer = $whisperContainer ?? config('services.whisper.container'); $this->whisperModel = $whisperModel ?? config('services.whisper.model'); $this->piperVoice = $piperVoice ?? config('services.piper.voice'); } /** * Transkribiert eine Audio-Datei via whisper.cpp. */ public function transcribe(UploadedFile|string $file, string $language = 'de'): ?string { $filename = 'in_'.Str::random(12).'.wav'; $path = Storage::disk('audio')->path($filename); if ($file instanceof UploadedFile) { $file->move(dirname($path), basename($path)); } else { copy($file, $path); } // whisper.cpp im Sidecar-Container $output = Process::timeout(120) ->run(sprintf( 'docker exec %s /app/build/bin/whisper-cli -m %s -f /audio/%s -l %s -nt -otxt', escapeshellarg($this->whisperContainer), escapeshellarg($this->whisperModel), escapeshellarg($filename), escapeshellarg($language), )); if (! $output->successful()) { Log::warning('Whisper-Transkription fehlgeschlagen', [ 'stderr' => $output->errorOutput(), ]); @unlink($path); return null; } $textFile = Storage::disk('audio')->path($filename.'.txt'); $text = is_file($textFile) ? trim(file_get_contents($textFile)) : trim($output->output()); @unlink($path); @unlink($textFile); return $text !== '' ? $text : null; } /** * Synthetisiert Text zu Audio. Engine wählbar: elevenlabs | xtts | piper. * Bei Fehler automatischer Fallback auf nächste verfügbare Engine. * Gibt eine öffentliche URL (zum WAV/MP3) zurück oder null. */ public function speak(string $text): ?string { $clean = $this->normalizeForTts($text); if ($clean === '') { return null; } $engine = (string) config('services.tts.engine', 'openai'); // Fallback-Reihenfolge nach gewählter Engine $chain = match ($engine) { 'openai' => ['openai', 'elevenlabs', 'xtts', 'piper'], 'elevenlabs' => ['elevenlabs', 'xtts', 'piper'], 'xtts' => ['xtts', 'piper'], default => ['piper'], }; foreach ($chain as $try) { $url = match ($try) { 'openai' => $this->speakOpenAi($clean), 'elevenlabs' => $this->speakElevenLabs($clean), 'xtts' => $this->speakXtts($clean), 'piper' => $this->speakPiper($clean), default => null, }; if ($url !== null) { return $url; } Log::info('TTS-Engine '.$try.' fehlgeschlagen, versuche nächste'); } return null; } /** * OpenAI Audio API: tts-1 (~300ms), multilingual, Stimmen onyx/echo/alloy/fable/nova/shimmer. Gibt MP3 zurück. */ protected function speakOpenAi(string $text): ?string { $cfg = config('services.openai'); $key = $cfg['key'] ?? null; if (empty($key)) { return null; } try { $response = \Illuminate\Support\Facades\Http::timeout((int) ($cfg['timeout'] ?? 15)) ->withHeaders([ 'Authorization' => 'Bearer '.$key, 'Content-Type' => 'application/json', ]) ->post('https://api.openai.com/v1/audio/speech', [ 'model' => $cfg['tts_model'] ?? 'tts-1', 'voice' => $cfg['tts_voice'] ?? 'onyx', 'input' => $text, 'response_format' => 'mp3', 'speed' => (float) ($cfg['tts_speed'] ?? 1.0), ]); if (! $response->successful()) { Log::warning('OpenAI TTS Fehler', [ 'status' => $response->status(), 'body' => mb_substr($response->body(), 0, 200), ]); return null; } $body = $response->body(); if (strlen($body) < 256) { Log::warning('OpenAI TTS: Response zu klein für Audio', ['len' => strlen($body)]); return null; } $filename = 'fox_'.Str::random(12).'.mp3'; $path = Storage::disk('audio')->path($filename); file_put_contents($path, $body); return rtrim(config('app.url'), '/').'/audio/'.$filename; } catch (\Throwable $e) { Log::warning('OpenAI TTS exception', ['e' => $e->getMessage()]); return null; } } /** * ElevenLabs API: Cloud-TTS, schnell, multilingual. Gibt MP3 zurück. */ protected function speakElevenLabs(string $text): ?string { $cfg = config('services.elevenlabs'); $key = $cfg['key'] ?? null; if (empty($key)) { return null; // ohne Key keinen Versuch } $voiceId = $cfg['voice_id'] ?? 'KDqku3FJfbImX6HKQdWA'; try { $response = \Illuminate\Support\Facades\Http::timeout((int) ($cfg['timeout'] ?? 10)) ->withHeaders([ 'xi-api-key' => $key, 'Content-Type' => 'application/json', 'Accept' => 'audio/mpeg', ]) ->post("https://api.elevenlabs.io/v1/text-to-speech/{$voiceId}", [ 'text' => $text, 'model_id' => $cfg['model'] ?? 'eleven_multilingual_v2', 'voice_settings' => [ 'stability' => (float) ($cfg['stability'] ?? 0.5), 'similarity_boost' => (float) ($cfg['similarity_boost'] ?? 0.75), 'style' => (float) ($cfg['style'] ?? 0.3), 'use_speaker_boost' => (bool) ($cfg['speaker_boost'] ?? true), ], ]); if (! $response->successful()) { Log::warning('ElevenLabs Fehler', [ 'status' => $response->status(), 'body' => mb_substr($response->body(), 0, 200), ]); return null; } $body = $response->body(); if (strlen($body) < 256) { Log::warning('ElevenLabs: Response zu klein für Audio', ['len' => strlen($body)]); return null; } $filename = 'fox_'.Str::random(12).'.mp3'; $path = Storage::disk('audio')->path($filename); file_put_contents($path, $body); return rtrim(config('app.url'), '/').'/audio/'.$filename; } catch (\Throwable $e) { Log::warning('ElevenLabs exception', ['e' => $e->getMessage()]); return null; } } /** * XTTS v2: HTTP-Call an Mac-Service, WAV-Response lokal speichern. */ protected function speakXtts(string $text): ?string { $cfg = config('services.xtts'); $url = rtrim((string) $cfg['url'], '/').'/speak'; try { $response = \Illuminate\Support\Facades\Http::timeout((int) ($cfg['timeout'] ?? 30)) ->acceptJson() ->withOptions(['stream' => false]) ->asJson() ->post($url, [ 'text' => $text, 'language' => config('services.tts.language', 'de'), 'speaker' => $cfg['speaker'] ?? 'Ana Florence', ]); if (! $response->successful()) { Log::warning('XTTS Fehler', [ 'status' => $response->status(), 'body' => mb_substr($response->body(), 0, 200), ]); return null; } $body = $response->body(); if (strlen($body) < 1024 || str_starts_with($body, '{')) { Log::warning('XTTS Response sieht nicht nach WAV aus', [ 'len' => strlen($body), 'head' => mb_substr($body, 0, 80), ]); return null; } $filename = 'fox_'.Str::random(12).'.wav'; $path = Storage::disk('audio')->path($filename); file_put_contents($path, $body); return rtrim(config('app.url'), '/').'/audio/'.$filename; } catch (\Throwable $e) { Log::warning('XTTS exception', ['e' => $e->getMessage()]); return null; } } /** * Piper: lokales binary im Container. */ protected function speakPiper(string $text): ?string { $filename = 'fox_'.Str::random(12).'.wav'; $path = Storage::disk('audio')->path($filename); $voice = $this->piperVoice; $lengthScale = (float) config('services.piper.length_scale', 1.05); $sentenceSilence = (float) config('services.piper.sentence_silence', 0.3); $noiseScale = (float) config('services.piper.noise_scale', 0.667); $noiseW = (float) config('services.piper.noise_w', 0.8); $cmd = sprintf( 'echo %s | piper --model %s --output_file %s '. '--length-scale %s --sentence-silence %s '. '--noise-scale %s --noise-w %s 2>&1', escapeshellarg($text), escapeshellarg($this->resolveVoicePath($voice)), escapeshellarg($path), escapeshellarg((string) $lengthScale), escapeshellarg((string) $sentenceSilence), escapeshellarg((string) $noiseScale), escapeshellarg((string) $noiseW), ); $result = Process::timeout(60)->run($cmd); if (! $result->successful() || ! is_file($path)) { Log::warning('Piper-TTS fehlgeschlagen', [ 'stderr' => $result->errorOutput(), 'output' => $result->output(), ]); return null; } return rtrim(config('app.url'), '/').'/audio/'.$filename; } protected function resolveVoicePath(string $voice): string { $base = rtrim(config('services.piper.voices_path'), '/'); return "{$base}/{$voice}.onnx"; } /** * Kleine Vorbereitung für piper: Markdown raus, Mehrfach-Whitespace * normalisieren, ein paar Abkürzungen ausschreiben. */ protected function normalizeForTts(string $text): string { $text = preg_replace('/```.*?```/s', '', $text); // Code-Blöcke $text = preg_replace('/`([^`]+)`/', '$1', $text); // Inline-Code $text = preg_replace('/\*\*?(.*?)\*\*?/', '$1', $text); // bold/italic $text = preg_replace('/\[([^\]]+)\]\([^)]+\)/', '$1', $text); // markdown links $text = strtr($text, [ 'z.B.' => 'zum Beispiel', 'z. B.' => 'zum Beispiel', 'd.h.' => 'das heißt', 'd. h.' => 'das heißt', 'usw.' => 'und so weiter', 'bzw.' => 'beziehungsweise', 'ca.' => 'circa', 'etc.' => 'et cetera', 'evtl.' => 'eventuell', 'ggf.' => 'gegebenenfalls', ]); $text = preg_replace('/\s+/', ' ', $text); return trim($text); } }