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 @@
+
+