diff --git a/.vscode/settings.json b/.vscode/settings.json index cf5e3b7..b96d0c1 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -35,6 +35,7 @@ "Deutsch", "ehthumbs", "Eiwc", + "ENXIO", "filecontent", "gcast", "GCAST", @@ -69,6 +70,7 @@ "maxrss", "MDNS", "miniplus", + "mkfifo", "multizone", "MUSICTRACK", "mycast", @@ -80,6 +82,7 @@ "newdevice", "newone", "newttscast", + "NONBLOCK", "OLDDIR", "Ondashboard", "Onmobile", @@ -93,6 +96,7 @@ "setvolume", "signum", "ssml", + "streamingdefault", "stype", "subdevices", "ttsaiapikey", @@ -119,6 +123,7 @@ "volumeset", "volumeup", "Wavenet", + "WRONLY", "youtube", "ZCONF" ] diff --git a/core/class/ttscast.class.php b/core/class/ttscast.class.php index e72ee0e..7339c3d 100644 --- a/core/class/ttscast.class.php +++ b/core/class/ttscast.class.php @@ -193,6 +193,7 @@ public static function deamon_start() { $cmd .= ' --geminittsmodel ' . config::byKey('geminiTTSModel', __CLASS__, 'noModel'); $cmd .= ' --geminittsdefault ' . config::byKey('geminiTTSDefault', __CLASS__, '0'); $cmd .= ' --geminittsstyle "' . config::byKey('geminiTTSStyle', __CLASS__, '') . '"'; + $cmd .= ' --streamingdefault ' . config::byKey('streamingDefault', __CLASS__, '0'); $cmd .= ' --pid ' . jeedom::getTmpFolder(__CLASS__) . '/deamon.pid'; // ne PAS modifier # log::add(__CLASS__, 'debug', 'Lancement du démon :: ' . $cmd); @@ -328,7 +329,8 @@ public static function playTestTTS() { $ttsTestGemini = config::byKey('ttsTestGemini', 'ttscast', '0'); $ttsGeminiVoiceName = config::byKey('geminiTTSVoice', 'ttscast', 'Aoede'); $ttsGeminiStyle = config::byKey('ttsTestGeminiStyle', 'ttscast', ''); - $value = array('cmd' => 'action', 'cmd_action' => 'ttstest', 'ttsEngine' => $ttsEngine, 'ttsLang' => $ttsLang, 'ttsSpeed' => $ttsSpeed, 'ttsText' => $ttsText, 'ttsGoogleName' => $ttsGoogleName, 'ttsVoiceName' => $ttsVoiceName, 'ttsRSSVoiceName' => $ttsRSSVoiceName, 'ttsGeminiVoiceName' => $ttsGeminiVoiceName, 'ttsGeminiStyle' => $ttsGeminiStyle, 'ttsRSSSpeed' => $ttsRSSSpeed, 'ttsSSML' => $ttsSSML, 'ttsAI' => $ttsAI, 'ttsGemini' => $ttsTestGemini); + $ttsTestStreaming = ($ttsTestGemini == '1') ? config::byKey('ttsTestStreaming', 'ttscast', '0') : '0'; + $value = array('cmd' => 'action', 'cmd_action' => 'ttstest', 'ttsEngine' => $ttsEngine, 'ttsLang' => $ttsLang, 'ttsSpeed' => $ttsSpeed, 'ttsText' => $ttsText, 'ttsGoogleName' => $ttsGoogleName, 'ttsVoiceName' => $ttsVoiceName, 'ttsRSSVoiceName' => $ttsRSSVoiceName, 'ttsGeminiVoiceName' => $ttsGeminiVoiceName, 'ttsGeminiStyle' => $ttsGeminiStyle, 'ttsRSSSpeed' => $ttsRSSSpeed, 'ttsSSML' => $ttsSSML, 'ttsAI' => $ttsAI, 'ttsGemini' => $ttsTestGemini, 'ttsStreaming' => $ttsTestStreaming); self::sendToDaemon($value); } @@ -427,7 +429,7 @@ public static function customCmdDecoder($customCmd=null) { $optionKeys = [ 'force', 'reload_seconds', 'quit_app', 'playlist', 'enqueue', 'volume', 'ding', 'wait', 'type', 'ssml', 'markup', 'style', 'genai', 'before', 'voice', 'aitone', 'aisysprompt', 'aitemp', - 'engine' + 'engine', 'streaming' ]; foreach ($optionKeys as $key) { if (array_key_exists($key, $data)) { diff --git a/core/php/ttscast.audio.proxy.php b/core/php/ttscast.audio.proxy.php index 0814bfb..78422d0 100644 --- a/core/php/ttscast.audio.proxy.php +++ b/core/php/ttscast.audio.proxy.php @@ -15,15 +15,10 @@ * along with Jeedom. If not, see . */ -try { - // Restriction réseau local uniquement (le Chromecast ne vient jamais d'une IP publique) - // REMOTE_ADDR est l'IP TCP réelle — ne pas utiliser X-Forwarded-For (falsifiable) - $clientIp = $_SERVER['REMOTE_ADDR'] ?? ''; - if (filter_var($clientIp, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) !== false) { - http_response_code(403); - die(); - } +require_once dirname(__FILE__) . '/../../../../core/php/core.inc.php'; +try { + // Protection par validation stricte des noms de fichiers (regex + identifiants imprévisibles). $mimeTypes = [ 'mp3' => 'audio/mp3', 'wav' => 'audio/wav', @@ -38,16 +33,74 @@ if ($type === 'tts') { // Validation stricte : MD5 hex (32 car.) + extension audio autorisée if (!preg_match('/^([a-f0-9]{32})\.(mp3|wav|ogg|opus|flac)$/', $file, $matches)) { + log::add('ttscast', 'warning', '[PROXY][TTS] Paramètre invalide :: file=' . $file); http_response_code(400); die(); } $mime = $mimeTypes[$matches[2]]; $filePath = dirname(dirname(__DIR__)) . '/data/cache/' . $file; + } elseif ($type === 'stream') { + // Named pipe (mkfifo) — streaming PCM L16 Gemini TTS + // Validation : UUID v4 + extension l16 uniquement + $safeFile = basename($file); + if (!preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\.l16$/', $safeFile)) { + log::add('ttscast', 'warning', '[PROXY][Stream] Paramètre invalide :: file=' . $file); + http_response_code(400); + die(); + } + $pipePath = '/tmp/jeedom/ttscast_cache/stream/' . $safeFile; + if (!file_exists($pipePath)) { + log::add('ttscast', 'warning', '[PROXY][Stream] Pipe introuvable :: ' . $safeFile); + http_response_code(404); + die(); + } + $streamRate = in_array((int)($_GET['rate'] ?? 0), [8000, 16000, 22050, 24000, 44100, 48000], true) ? (int)$_GET['rate'] : 24000; + $streamChannels = in_array((int)($_GET['channels'] ?? 0), [1, 2], true) ? (int)$_GET['channels'] : 1; + + // Envoyer les headers HTTP + flusher AVANT d'ouvrir le pipe + // Le Chromecast reçoit « HTTP/1.1 200 OK » immédiatement et attend les données, + // au lieu de timeout pendant que PHP bloque sur fopen() du FIFO. + header('Content-Type: audio/wav'); // WAV RIFF avec header dans le stream (PCM LE 16-bit) + header('Cache-Control: no-cache, no-store'); + header('Content-Encoding: identity'); // Désactiver mod_deflate — le PCM brut ne doit pas être compressé + set_time_limit(0); + while (ob_get_level() > 0) { + ob_end_clean(); + } + ob_implicit_flush(true); // Flush automatique à chaque echo + flush(); // Envoyer les headers HTTP maintenant (avant que le pipe soit ouvert) + + log::add('ttscast', 'debug', '[PROXY][Stream] Ouverture du pipe (attente writer) :: ' . $safeFile); + $fh = fopen($pipePath, 'rb'); + if ($fh === false) { + log::add('ttscast', 'error', '[PROXY][Stream] Échec ouverture pipe :: ' . $safeFile); + die(); + } + log::add('ttscast', 'debug', '[PROXY][Stream] Pipe ouvert, streaming démarré :: rate=' . $streamRate . ' | channels=' . $streamChannels); + + // Lire le pipe et envoyer immédiatement au client (Chromecast) + $bytesSent = 0; + while (!feof($fh)) { + $chunk = fread($fh, 8192); + if ($chunk !== false && $chunk !== '') { + echo $chunk; + $bytesSent += strlen($chunk); + } + } + fclose($fh); + log::add('ttscast', 'debug', '[PROXY][Stream] Terminé :: ' . $safeFile . ' | ' . $bytesSent . ' bytes envoyés'); + // Nettoyage du pipe (déjà supprimé par le démon Python mais au cas où) + if (file_exists($pipePath)) { + @unlink($pipePath); + } + die(); + } elseif ($type === 'sounds' || $type === 'customsounds') { // Validation : nom de fichier sûr (pas de séparateur de répertoire, extension autorisée) $safeFile = basename($file); if (!preg_match('/^([a-zA-Z0-9._-]+)\.(mp3|wav|ogg|opus|flac)$/', $safeFile, $matches)) { + log::add('ttscast', 'warning', '[PROXY][Sounds] Paramètre invalide :: file=' . $file); http_response_code(400); die(); } @@ -56,11 +109,13 @@ $filePath = dirname(dirname(__DIR__)) . '/data/media/' . $subDir . $safeFile; } else { + log::add('ttscast', 'warning', '[PROXY] Type inconnu :: type=' . $type); http_response_code(400); die(); } if (!file_exists($filePath) || !is_file($filePath)) { + log::add('ttscast', 'warning', '[PROXY] Fichier non trouvé :: ' . basename($filePath)); http_response_code(404); die(); } @@ -74,6 +129,7 @@ die(); } catch (Exception $e) { + log::add('ttscast', 'error', '[PROXY] Exception :: ' . $e->getMessage()); http_response_code(500); die(); } diff --git a/plugin_info/configuration.php b/plugin_info/configuration.php index 65113a1..63ab807 100644 --- a/plugin_info/configuration.php +++ b/plugin_info/configuration.php @@ -174,8 +174,8 @@
@@ -775,7 +775,7 @@
@@ -783,6 +783,7 @@
+
+ +
+ +
+
+