Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
"Deutsch",
"ehthumbs",
"Eiwc",
"ENXIO",
"filecontent",
"gcast",
"GCAST",
Expand Down Expand Up @@ -69,6 +70,7 @@
"maxrss",
"MDNS",
"miniplus",
"mkfifo",
"multizone",
"MUSICTRACK",
"mycast",
Expand All @@ -80,6 +82,7 @@
"newdevice",
"newone",
"newttscast",
"NONBLOCK",
"OLDDIR",
"Ondashboard",
"Onmobile",
Expand All @@ -93,6 +96,7 @@
"setvolume",
"signum",
"ssml",
"streamingdefault",
"stype",
"subdevices",
"ttsaiapikey",
Expand All @@ -119,6 +123,7 @@
"volumeset",
"volumeup",
"Wavenet",
"WRONLY",
"youtube",
"ZCONF"
]
Expand Down
6 changes: 4 additions & 2 deletions core/class/ttscast.class.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -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)) {
Expand Down
72 changes: 64 additions & 8 deletions core/php/ttscast.audio.proxy.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,10 @@
* along with Jeedom. If not, see <http://www.gnu.org/licenses/>.
*/

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',
Expand All @@ -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();
}
Expand All @@ -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();
}
Expand All @@ -74,6 +129,7 @@
die();

} catch (Exception $e) {
log::add('ttscast', 'error', '[PROXY] Exception :: ' . $e->getMessage());
http_response_code(500);
die();
}
24 changes: 21 additions & 3 deletions plugin_info/configuration.php
Original file line number Diff line number Diff line change
Expand Up @@ -174,8 +174,8 @@
<div class="col-lg-3">
<select class="configKey form-control" data-l1key="gCloudAudioEncoding">
<option value="MP3" selected>{{MP3 (Défaut)}}</option>
<option value="LINEAR16_24K" disabled>{{LINEAR16 (WAV - 24kHz)}}</option>
<option value="LINEAR16_48K" disabled>{{LINEAR16 (WAV - 48kHz)}}</option>
<option value="LINEAR16_24K">{{LINEAR16 (WAV - 24kHz)}}</option>
<option value="LINEAR16_48K">{{LINEAR16 (WAV - 48kHz)}}</option>
</select>
</div>
</div>
Expand Down Expand Up @@ -775,14 +775,15 @@
<br>
<div class="form-group">
<label class="col-lg-3 control-label">{{Modèle IA (Reformulation)}}
<sup><i class="fas fa-dollar-sign tooltips" style="color:var(--al-danger-color)!important;" title="<b>Tarifs par Million de tokens :</b><br/><br/><b>Modèles Stables (Recommandés) :</b><br/>• Gemini 3.1 Flash-Lite : In $0.25 | Out $1.50<br/>• Gemini 2.5 Flash Lite : In $0.10 | Out $0.40<br/>• Gemini 2.5 Flash : In $0.30 | Out $2.50<br/>• Gemini 2.5 Pro : In $1.25 | Out $10.00<br/><br/><b>Versions Latest (Mise à jour auto) :</b><br/>⚠️ Prix variable selon le modèle résolu.<br/><br/><b>Versions Preview (Beta) :</b><br/>• Gemini 3.1 Flash-Lite Preview : In $0.25 | Out $1.50<br/>• Gemini 3.1 Pro Preview : In $2.00 | Out $12.00<br/>• Gemini 3 Flash Preview : In $0.50 | Out $3.00"></i></sup>
<sup><i class="fas fa-dollar-sign tooltips" style="color:var(--al-danger-color)!important;" title="<b>Tarifs par Million de tokens :</b><br/><br/><b>Modèles Stables (Recommandés) :</b><br/>• Gemini 3.5 Flash : In $1.50 | Out $9.00<br/>• Gemini 3.1 Flash-Lite : In $0.25 | Out $1.50<br/>• Gemini 2.5 Flash Lite : In $0.10 | Out $0.40<br/>• Gemini 2.5 Flash : In $0.30 | Out $2.50<br/>• Gemini 2.5 Pro : In $1.25 | Out $10.00<br/><br/><b>Versions Latest (Mise à jour auto) :</b><br/>⚠️ Prix variable selon le modèle résolu.<br/><br/><b>Versions Preview (Beta) :</b><br/>• Gemini 3.1 Flash-Lite Preview : In $0.25 | Out $1.50<br/>• Gemini 3.1 Pro Preview : In $2.00 | Out $12.00<br/>• Gemini 3 Flash Preview : In $0.50 | Out $3.00"></i></sup>
<sup><i class="fas fa-exclamation-triangle tooltips" style="color:var(--al-warning-color)!important;" title="{{Le démon devra être redémarré après la modification de ce paramètre}}"></i></sup>
<sup><i class="fas fa-question-circle tooltips" title="{{Sélectionnez le modèle d'IA à utiliser pour la reformulation des réponses.}}"></i></sup>
</label>
<div class="col-lg-2">
<select class="configKey form-control" data-l1key="ttsAIModel">
<!-- Modèles Recommandés (Stables) -->
<option disabled>--- {{Modèles Stables (Recommandés)}} ---</option>
<option value="gemini-3.5-flash">Gemini 3.5 Flash</option>
<option value="gemini-3.1-flash-lite" selected>Gemini 3.1 Flash-Lite</option>
<option value="gemini-2.5-flash-lite">Gemini 2.5 Flash Lite</option>
<option value="gemini-2.5-flash">Gemini 2.5 Flash</option>
Expand Down Expand Up @@ -913,6 +914,15 @@
<input type="checkbox" class="configKey" data-l1key="geminiTTSDefault" />
</div>
</div>
<div class="form-group customform-geminiTTS">
<label class="col-lg-3 control-label">{{Streaming Gemini TTS par défaut}}
<sup><i class="fas fa-exclamation-triangle tooltips" style="color:var(--al-warning-color)!important;" title="{{Le démon devra être redémarré après la modification de ce paramètre}}"></i></sup>
<sup><i class="fas fa-question-circle tooltips" title="{{Si activé, la lecture audio démarre dès les premières syllabes générées, sans attendre la fin de la synthèse vocale. Réduit la latence perçue.}}"></i></sup>
</label>
<div class="col-lg-1">
<input type="checkbox" class="configKey" data-l1key="streamingDefault" />
</div>
</div>
<div class="form-group customform-geminiTTS">
<label class="col-lg-3 control-label">{{Style par défaut (Gemini TTS)}}
<sup><i class="fas fa-exclamation-triangle tooltips" style="color:var(--al-warning-color)!important;" title="{{Le démon devra être redémarré après la modification de ce paramètre}}"></i></sup>
Expand Down Expand Up @@ -947,6 +957,14 @@
<input type="checkbox" class="configKey" data-l1key="ttsTestGemini" />
</div>
</div>
<div class="form-group customform-testGeminiStyle" style="display:none;">
<label class="col-lg-3 control-label">{{Tester avec le mode streaming}}
<sup><i class="fas fa-question-circle tooltips" title="{{Si coché, le test utilise le mode streaming Gemini TTS (lecture directe sans fichier intermédiaire). Nécessite que Gemini TTS soit activé et que le paramètre 'Tester avec Gemini TTS' soit coché.}}"></i></sup>
</label>
<div class="col-lg-1">
<input type="checkbox" class="configKey" data-l1key="ttsTestStreaming" />
</div>
</div>
<div class="form-group customform-testGeminiStyle" style="display:none;">
<label class="col-lg-3 control-label">{{Style de voix (Gemini TTS)}}
<sup><i class="fas fa-question-circle tooltips" title="{{Instruction de style transmise au modèle Gemini TTS (ex: Parle d'une voix chaleureure et rassurante). Laissez vide pour le style par défaut.}}"></i></sup>
Expand Down
2 changes: 1 addition & 1 deletion plugin_info/info.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"id": "ttscast",
"name": "TTS Cast",
"pluginVersion": "1.9.1",
"pluginVersion": "1.9.2",
"description": {
"fr_FR": "Plugin pour gérer ses équipements Google, type Google Home, Nest Mini, Nest Hub (Max), Chromecast. Il permet de générer des notifications TTS (Text To Speech) et de les diffuser sur les équipements Google. Il permet également de diffuser des sons (mp3), des vidéos YouTube, une page Web, ou encore une radio en streaming sur ces mêmes équipements.",
"en_US": "Plugin to manage Google equipment, such as Google Home, Nest Mini, Nest Hub (Max), Chromecast. It allows you to generate TTS (Text To Speech) notifications and broadcast them to Google devices. It also allows you to broadcast sounds (mp3), YouTube videos, a web page, or even streaming radio on the same equipment.",
Expand Down
6 changes: 6 additions & 0 deletions plugin_info/install.php
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,9 @@ function ttscast_install() {
if (config::byKey('ttsAIDefault', 'ttscast') == '') {
config::save('ttsAIDefault', '0', 'ttscast');
}
if (config::byKey('streamingDefault', 'ttscast') == '') {
config::save('streamingDefault', '0', 'ttscast');
}
if (config::byKey('disableUpdateMsg', 'ttscast') == '') {
config::save('disableUpdateMsg', '0', 'ttscast');
}
Expand Down Expand Up @@ -171,6 +174,9 @@ function ttscast_update() {
if (config::byKey('ttsAIDefault', 'ttscast') == '') {
config::save('ttsAIDefault', '0', 'ttscast');
}
if (config::byKey('streamingDefault', 'ttscast') == '') {
config::save('streamingDefault', '0', 'ttscast');
}
if (config::byKey('disableUpdateMsg', 'ttscast') == '') {
config::save('disableUpdateMsg', '0', 'ttscast');
}
Expand Down
2 changes: 1 addition & 1 deletion resources/requirements.txt
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
PyChromecast==14.0.10
google-cloud-texttospeech==2.36.0
gTTS==2.5.4
google-genai==2.3.0
google-genai==2.4.0
Markdown==3.10.2
beautifulsoup4==4.14.3
Loading