347 lines
12 KiB
PHP
347 lines
12 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use Illuminate\Http\UploadedFile;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Illuminate\Support\Facades\Process;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Illuminate\Support\Str;
|
|
|
|
/**
|
|
* Brücke zu whisper.cpp (STT) und piper (TTS) via Docker exec.
|
|
*
|
|
* Whisper läuft im Container fox-whisper, piper ist im PHP-Container installiert (siehe Dockerfile).
|
|
*/
|
|
class SpeechService
|
|
{
|
|
public function __construct(
|
|
protected ?string $whisperContainer = null,
|
|
protected ?string $whisperModel = null,
|
|
protected ?string $piperVoice = null,
|
|
) {
|
|
$this->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);
|
|
}
|
|
}
|