fox/app/Services/SpeechService.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);
}
}