From bbf946af5d88bebbf0e9cf669fd56db4e69a0acb Mon Sep 17 00:00:00 2001 From: James Date: Tue, 17 Feb 2026 20:10:03 -0500 Subject: [PATCH] feat: voice meeting announcements via Fish Audio TTS + Fully Kiosk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add Fish Audio TTS integration (POST /v1/tts, Adrian voice) - Pre-generate meeting audio at push time (instant playback at T-5) - Schedule T-5 minute announcement timer on POST /api/meeting - Play audio on office Fully tablet via REST API (screenOn + playSound) - Critical alerts also trigger voice playback - Serve mp3s from /tts/ static route - Auto-cleanup TTS files older than 1 hour - Night hours guard (11pm–8am ET, no playback) - Fish API key loaded from /home/johan/.config/fish-audio.env or env var - Inline require('https') → top-level import throughout --- server.js | 342 +++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 260 insertions(+), 82 deletions(-) diff --git a/server.js b/server.js index d00063f..d7a60e2 100644 --- a/server.js +++ b/server.js @@ -1,6 +1,9 @@ const express = require('express'); const fs = require('fs'); const path = require('path'); +const http = require('http'); +const https = require('https'); +const crypto = require('crypto'); const app = express(); const PORT = 9202; @@ -8,7 +11,26 @@ const ALERTS_FILE = path.join(__dirname, 'alerts.json'); app.use(express.json()); -// Load/save alerts +// ─── Fish Audio TTS Config ─────────────────────────────────────────────────── +const FISH_API_KEY = (() => { + try { + const envFile = fs.readFileSync('/home/johan/.config/fish-audio.env', 'utf8'); + const match = envFile.match(/FISH_API_KEY\s*=\s*(.+)/); + if (match) return match[1].trim(); + } catch (e) {} + return process.env.FISH_API_KEY || 'd50ba0bcfce34d918f875266272325c7'; +})(); + +const FISH_VOICE_REF = 'bf322df2096a46f18c579d0baa36f41d'; +const FULLY_BASE = 'http://192.168.2.243:2323'; +const FULLY_PWD = '3005'; +const SERVER_IP = '192.168.1.16'; // forge — reachable from tablet + +// ─── TTS Directory ─────────────────────────────────────────────────────────── +const TTS_DIR = path.join(__dirname, 'tts'); +if (!fs.existsSync(TTS_DIR)) fs.mkdirSync(TTS_DIR, { recursive: true }); + +// ─── Load/save alerts ──────────────────────────────────────────────────────── function loadAlerts() { try { return JSON.parse(fs.readFileSync(ALERTS_FILE, 'utf8')); } catch { return []; } @@ -17,30 +39,141 @@ function saveAlerts(alerts) { fs.writeFileSync(ALERTS_FILE, JSON.stringify(alerts, null, 2)); } -const http = require('http'); - // Camera proxy config -const HA_URL = 'http://192.168.1.252:8123'; +const HA_URL = 'http://192.168.1.252:8123'; const HA_TOKEN = process.env.HA_TOKEN || ''; // SSE clients const sseClients = new Set(); -// Serve dashboard +// ─── Night hours check (11pm – 8am ET) ────────────────────────────────────── +function isNightHours() { + const hour = parseInt( + new Intl.DateTimeFormat('en-US', { + hour: 'numeric', + hour12: false, + timeZone: 'America/New_York' + }).format(new Date()) + ); + return hour >= 23 || hour < 8; +} + +// ─── Send a command to Fully Kiosk ─────────────────────────────────────────── +function fullyCmd(params) { + return new Promise((resolve) => { + const qs = new URLSearchParams({ ...params, password: FULLY_PWD }); + const url = `${FULLY_BASE}/?${qs.toString()}`; + const req = http.get(url, (res) => { + res.resume(); + res.on('end', resolve); + }); + req.on('error', (e) => { + console.error('[Fully] Error:', e.message); + resolve(); // non-critical — don't crash + }); + req.setTimeout(5000, () => { req.destroy(); resolve(); }); + }); +} + +// ─── Generate TTS via Fish Audio, save to /tts/.mp3 ─────────────────── +function generateTTS(text) { + return new Promise((resolve, reject) => { + const body = JSON.stringify({ + text, + reference_id: FISH_VOICE_REF, + format: 'mp3', + mp3_bitrate: 128, + normalize: true, + latency: 'normal' + }); + + const options = { + hostname: 'api.fish.audio', + path: '/v1/tts', + method: 'POST', + headers: { + 'Authorization': `Bearer ${FISH_API_KEY}`, + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(body) + } + }; + + const filename = `${crypto.randomUUID()}.mp3`; + const filepath = path.join(TTS_DIR, filename); + const fileStream = fs.createWriteStream(filepath); + + const req = https.request(options, (res) => { + if (res.statusCode !== 200) { + let errBody = ''; + res.on('data', c => errBody += c); + res.on('end', () => { + fs.unlink(filepath, () => {}); + reject(new Error(`Fish API ${res.statusCode}: ${errBody}`)); + }); + return; + } + res.pipe(fileStream); + fileStream.on('finish', () => { + console.log(`[TTS] Generated: ${filename} (${text.slice(0, 60)}…)`); + resolve(filename); + }); + fileStream.on('error', reject); + }); + + req.on('error', reject); + req.setTimeout(30000, () => { req.destroy(); reject(new Error('Fish API timeout')); }); + req.write(body); + req.end(); + }); +} + +// ─── Play an mp3 file on the office Fully tablet ──────────────────────────── +async function playOnTablet(filename) { + if (isNightHours()) { + console.log('[TTS] Night hours — skipping playback'); + return; + } + const mp3Url = `http://${SERVER_IP}:${PORT}/tts/${filename}`; + console.log('[TTS] Playing on tablet:', mp3Url); + await fullyCmd({ cmd: 'screenOn' }); + await new Promise(r => setTimeout(r, 400)); // brief gap for screen + await fullyCmd({ cmd: 'playSound', url: mp3Url }); +} + +// ─── Cleanup TTS files older than 1 hour ───────────────────────────────────── +setInterval(() => { + try { + const cutoff = Date.now() - 3600000; + for (const f of fs.readdirSync(TTS_DIR)) { + if (!f.endsWith('.mp3')) continue; + const stat = fs.statSync(path.join(TTS_DIR, f)); + if (stat.mtimeMs < cutoff) { + fs.unlinkSync(path.join(TTS_DIR, f)); + console.log('[TTS] Cleaned up:', f); + } + } + } catch (e) {} +}, 600_000); // every 10 min + +// ─── Static route for TTS files ────────────────────────────────────────────── +app.use('/tts', express.static(TTS_DIR)); + +// ─── Serve dashboard ───────────────────────────────────────────────────────── app.get('/', (req, res) => { res.set('Cache-Control', 'no-store'); res.sendFile(path.join(__dirname, 'index.html')); }); -// List alerts +// ─── List alerts ───────────────────────────────────────────────────────────── app.get('/api/alerts', (req, res) => { res.json(loadAlerts().slice(-50).reverse()); }); -// Push alert +// ─── Push alert (with TTS for critical) ────────────────────────────────────── app.post('/api/alerts', (req, res) => { const { message, priority = 'info', group } = req.body; if (!message) return res.status(400).json({ error: 'message required' }); + const alert = { id: Date.now().toString(36) + Math.random().toString(36).slice(2, 6), message, @@ -48,29 +181,42 @@ app.post('/api/alerts', (req, res) => { group: group || null, timestamp: new Date().toISOString() }; + const alerts = loadAlerts(); alerts.push(alert); - // Keep last 100 if (alerts.length > 100) alerts.splice(0, alerts.length - 100); saveAlerts(alerts); - // Notify SSE clients + for (const client of sseClients) { client.write(`data: ${JSON.stringify(alert)}\n\n`); } + + // Critical alert → voice announcement + if (priority === 'critical') { + (async () => { + try { + console.log('[Alert TTS] Critical alert — generating voice'); + const filename = await generateTTS(message); + await playOnTablet(filename); + } catch (e) { + console.error('[Alert TTS] Failed:', e.message); + } + })(); + } + res.status(201).json(alert); }); -// Bedroom 1 sensors proxy from HA +// ─── Bedroom 1 sensors proxy from HA ───────────────────────────────────────── const SENSOR_ENTITIES = { temperature: 'sensor.athom_tem_hum_sensor_b3882c_temperature', - humidity: 'sensor.athom_tem_hum_sensor_b3882c_humidity', - co2: 'sensor.athom_co2_sensor_b34780_co2' + humidity: 'sensor.athom_tem_hum_sensor_b3882c_humidity', + co2: 'sensor.athom_co2_sensor_b34780_co2' }; let sensorCache = { data: null, ts: 0 }; app.get('/api/sensors/bed1', async (req, res) => { - // Cache for 15s to avoid hammering HA if (sensorCache.data && Date.now() - sensorCache.ts < 4000) { return res.json(sensorCache.data); } @@ -97,18 +243,16 @@ app.get('/api/sensors/bed1', async (req, res) => { } }); -// Weather: temp range + severe alerts +// ─── Weather ───────────────────────────────────────────────────────────────── let weatherCache = { data: null, ts: 0 }; const SEVERE_EVENTS = ['hurricane', 'tropical storm', 'tornado', 'severe thunderstorm', 'flash flood', 'storm surge', 'tsunami', 'extreme wind']; app.get('/api/weather', async (req, res) => { - // Refresh current temp from NWS every request, forecast/NHC daily const forecastFresh = weatherCache.data && Date.now() - weatherCache.ts < 86400000; if (forecastFresh) { - // Just update current temp from NWS try { const nwsData = await new Promise((resolve, reject) => { - const r = require('https').get('https://api.weather.gov/stations/KSPG/observations/latest', { + const r = https.get('https://api.weather.gov/stations/KSPG/observations/latest', { headers: { 'User-Agent': 'james-dashboard' } }, (resp) => { let body = ''; @@ -118,10 +262,9 @@ app.get('/api/weather', async (req, res) => { r.on('error', reject); r.setTimeout(5000, () => { r.destroy(); reject(new Error('timeout')); }); }); - // Convert Celsius to Fahrenheit const tempC = nwsData.properties.temperature.value; if (tempC !== null) { - weatherCache.data.current = Math.round(tempC * 9/5 + 32); + weatherCache.data.current = Math.round(tempC * 9/5 + 32); weatherCache.data.currentC = Math.round(tempC); } } catch(e) {} @@ -130,10 +273,9 @@ app.get('/api/weather', async (req, res) => { try { const result = { high: null, low: null, current: null, currentC: null, alerts: [] }; - // Current outside temp from NWS try { const nwsData = await new Promise((resolve, reject) => { - const r = require('https').get('https://api.weather.gov/stations/KSPG/observations/latest', { + const r = https.get('https://api.weather.gov/stations/KSPG/observations/latest', { headers: { 'User-Agent': 'james-dashboard' } }, (resp) => { let body = ''; @@ -143,17 +285,15 @@ app.get('/api/weather', async (req, res) => { r.on('error', reject); r.setTimeout(5000, () => { r.destroy(); reject(new Error('timeout')); }); }); - // Convert Celsius to Fahrenheit const tempC = nwsData.properties.temperature.value; if (tempC !== null) { - result.current = Math.round(tempC * 9/5 + 32); + result.current = Math.round(tempC * 9/5 + 32); result.currentC = Math.round(tempC); } } catch(e) {} - // Temp range from wttr.in const wttr = await new Promise((resolve, reject) => { - const r = require('https').get('https://wttr.in/St+Petersburg+FL?format=j1', { + const r = https.get('https://wttr.in/St+Petersburg+FL?format=j1', { headers: { 'User-Agent': 'james-dashboard' } }, (resp) => { let body = ''; @@ -165,12 +305,11 @@ app.get('/api/weather', async (req, res) => { }); const today = wttr.weather[0]; result.high = parseInt(today.maxtempF); - result.low = parseInt(today.mintempF); + result.low = parseInt(today.mintempF); - // NWS severe alerts (imminent) try { const nws = await new Promise((resolve, reject) => { - const r = require('https').get('https://api.weather.gov/alerts/active?point=27.7676,-82.6403', { + const r = https.get('https://api.weather.gov/alerts/active?point=27.7676,-82.6403', { headers: { 'User-Agent': 'james-dashboard' } }, (resp) => { let body = ''; @@ -188,10 +327,9 @@ app.get('/api/weather', async (req, res) => { } } catch(e) {} - // NHC Tropical Weather Outlook (7-day formation, hurricane season) try { const twoHtml = await new Promise((resolve, reject) => { - const r = require('https').get('https://www.nhc.noaa.gov/text/MIATWOAT.shtml', { + const r = https.get('https://www.nhc.noaa.gov/text/MIATWOAT.shtml', { headers: { 'User-Agent': 'james-dashboard' } }, (resp) => { let body = ''; @@ -204,15 +342,12 @@ app.get('/api/weather', async (req, res) => { const preMatch = twoHtml.match(/
([\s\S]*?)<\/pre>/);
       if (preMatch) {
         const text = preMatch[1].replace(/<[^>]+>/g, '');
-        // Skip if off-season / "not expected"
         if (!text.includes('formation is not expected')) {
-          // Extract formation probabilities
           const lines = text.split('\n');
           const formations = [];
           for (let i = 0; i < lines.length; i++) {
             const pctMatch = lines[i].match(/(\d+)\s*percent/i);
             if (pctMatch && parseInt(pctMatch[1]) >= 20) {
-              // Find nearby context about Gulf/Caribbean
               const context = lines.slice(Math.max(0, i-3), i+2).join(' ');
               const loc = context.match(/Gulf|Caribbean|Atlantic|Florida|Bahamas/i);
               formations.push({
@@ -238,7 +373,7 @@ app.get('/api/weather', async (req, res) => {
   }
 });
 
-// Camera proxy - snapshot from HA
+// ─── Camera proxy ─────────────────────────────────────────────────────────────
 app.get('/api/cam/pulse-ox', (req, res) => {
   const haReq = http.get(`${HA_URL}/api/camera_proxy/camera.pulse_ox_live_view`, {
     headers: { 'Authorization': `Bearer ${HA_TOKEN}` }
@@ -251,7 +386,6 @@ app.get('/api/cam/pulse-ox', (req, res) => {
   haReq.setTimeout(5000, () => { haReq.destroy(); res.status(504).end(); });
 });
 
-// Camera proxy - MJPEG stream from HA
 app.get('/api/cam/pulse-ox/stream', (req, res) => {
   const haReq = http.get(`${HA_URL}/api/camera_proxy_stream/camera.pulse_ox_live_view`, {
     headers: { 'Authorization': `Bearer ${HA_TOKEN}` }
@@ -264,7 +398,7 @@ app.get('/api/cam/pulse-ox/stream', (req, res) => {
   haReq.on('error', () => res.status(502).end());
 });
 
-// Toggle done
+// ─── Toggle done ──────────────────────────────────────────────────────────────
 app.patch('/api/alerts/:id/done', (req, res) => {
   const alerts = loadAlerts();
   const alert = alerts.find(a => a.id === req.params.id);
@@ -274,21 +408,20 @@ app.patch('/api/alerts/:id/done', (req, res) => {
   res.json(alert);
 });
 
-// Delete alert
+// ─── Delete alert ─────────────────────────────────────────────────────────────
 app.delete('/api/alerts/:id', (req, res) => {
   const alerts = loadAlerts();
   const idx = alerts.findIndex(a => a.id === req.params.id);
   if (idx === -1) return res.status(404).json({ error: 'not found' });
   alerts.splice(idx, 1);
   saveAlerts(alerts);
-  // Notify SSE clients of removal
   for (const client of sseClients) {
     client.write(`data: ${JSON.stringify({ type: 'remove', id: req.params.id })}\n\n`);
   }
   res.json({ status: 'deleted' });
 });
 
-// SSE stream
+// ─── SSE stream ───────────────────────────────────────────────────────────────
 app.get('/api/alerts/stream', (req, res) => {
   res.writeHead(200, {
     'Content-Type': 'text/event-stream',
@@ -300,7 +433,7 @@ app.get('/api/alerts/stream', (req, res) => {
   req.on('close', () => sseClients.delete(res));
 });
 
-// Auto-purge done items older than 2 hours
+// ─── Auto-purge done items older than 2 hours ─────────────────────────────────
 setInterval(() => {
   const alerts = loadAlerts();
   const cutoff = Date.now() - 2 * 3600000;
@@ -311,12 +444,9 @@ setInterval(() => {
       client.write(`data: ${JSON.stringify({ type: 'refresh' })}\n\n`);
     }
   }
-}, 300000); // Check every 5 min
+}, 300_000);
 
-// Meeting storage
-let nextMeeting = null;
-
-// Claude usage endpoint
+// ─── Claude usage endpoint ────────────────────────────────────────────────────
 app.get('/api/claude-usage', async (req, res) => {
   try {
     const jamesData = await new Promise((resolve, reject) => {
@@ -328,72 +458,120 @@ app.get('/api/claude-usage', async (req, res) => {
       r.on('error', reject);
       r.setTimeout(3000, () => { r.destroy(); reject(new Error('timeout')); });
     });
-    
-    // James dashboard returns {status: {key: {value: "..."}, ...}}
-    const statuses = jamesData.status || jamesData;
-    const usage = statuses['claude-usage'] || statuses['claude'];
-    
-    // Extract usage percentage from value like "📊 Weekly: 46% used" or "46% used · 2:00 PM"
-    let usagePct = null;
+
+    const statuses  = jamesData.status || jamesData;
+    const usage     = statuses['claude-usage'] || statuses['claude'];
+    let usagePct    = null;
     if (usage && usage.value) {
       const m = usage.value.match(/(\d+)%/);
       if (m) usagePct = parseInt(m[1]);
     }
-    
-    // Calculate time% into budget week (Sat 2pm - Sat 2pm ET)
+
     const now = new Date();
-    // Find most recent Saturday 2pm ET
-    const et = new Date(now.toLocaleString('en-US', { timeZone: 'America/New_York' }));
-    const day = et.getDay(); // 0=Sun
-    // Days since last Saturday
+    const et  = new Date(now.toLocaleString('en-US', { timeZone: 'America/New_York' }));
+    const day = et.getDay();
     const daysSinceSat = day === 6 ? 0 : day + 1;
     const lastSat = new Date(et);
     lastSat.setDate(lastSat.getDate() - daysSinceSat);
-    lastSat.setHours(14, 0, 0, 0); // 2pm
-    // If we're before 2pm on Saturday, go back one more week
-    if (et < lastSat) {
-      lastSat.setDate(lastSat.getDate() - 7);
-    }
-    const weekMs = 7 * 24 * 60 * 60 * 1000;
+    lastSat.setHours(14, 0, 0, 0);
+    if (et < lastSat) lastSat.setDate(lastSat.getDate() - 7);
+    const weekMs  = 7 * 24 * 60 * 60 * 1000;
     const elapsed = et - lastSat;
     const timePct = Math.min(100, Math.max(0, (elapsed / weekMs) * 100));
-    
-    // Burn rate = usage% / time% — >100 means overspending
-    let pace = null;
-    if (timePct > 0) {
-      pace = Math.round((usagePct / timePct) * 100);
-    }
-    
+    const pace    = timePct > 0 ? Math.round((usagePct / timePct) * 100) : null;
+
     res.json({ usage: usagePct, timePct: Math.round(timePct), pace });
   } catch(e) {
     res.status(502).json({ error: 'james dashboard unavailable' });
   }
 });
 
-// Meeting endpoints
+// ─── Meeting state ────────────────────────────────────────────────────────────
+let nextMeeting    = null;
+let meetingTimer   = null;
+let meetingTTSFile = null;
+
+// Build the voice message text for a meeting
+function buildMeetingTTSText(meeting) {
+  const { title, organizer, topic } = meeting;
+  let text = `You have ${title} in 5 minutes`;
+  if (organizer) text += `, organized by ${organizer}`;
+  else if (topic)  text += `. Topic: ${topic}`;
+  text += '.';
+  return text;
+}
+
+// Schedule pre-generation and playback timer
+async function scheduleMeetingAnnouncement(meeting) {
+  const meetingTime     = new Date(meeting.time);
+  const msUntilMeeting  = meetingTime - Date.now();
+  const msUntilAnnounce = msUntilMeeting - 5 * 60 * 1000; // T-5 min
+
+  const ttsText = buildMeetingTTSText(meeting);
+
+  // Pre-generate audio now, so playback is instant
+  console.log('[Meeting] Pre-generating TTS for:', meeting.title);
+  try {
+    meetingTTSFile = await generateTTS(ttsText);
+    console.log('[Meeting] TTS ready:', meetingTTSFile);
+  } catch (e) {
+    console.error('[Meeting] TTS generation failed:', e.message);
+    meetingTTSFile = null;
+  }
+
+  const announce = async () => {
+    console.log('[Meeting] T-5 announcement for:', meeting.title);
+    if (meetingTTSFile) {
+      try { await playOnTablet(meetingTTSFile); }
+      catch (e) { console.error('[Meeting] Playback failed:', e.message); }
+    }
+  };
+
+  if (msUntilAnnounce > 0) {
+    meetingTimer = setTimeout(announce, msUntilAnnounce);
+    console.log(`[Meeting] Timer set — ${Math.round(msUntilAnnounce / 1000)}s until announcement`);
+  } else if (msUntilMeeting > 0) {
+    // Already within 5 min window — announce immediately
+    console.log('[Meeting] Already within 5-min window — announcing now');
+    await announce();
+  } else {
+    console.log('[Meeting] Meeting is in the past — skipping announcement');
+  }
+}
+
+// ─── Get current meeting ──────────────────────────────────────────────────────
 app.get('/api/meeting', (req, res) => {
   if (!nextMeeting) return res.json(null);
-  
-  const now = new Date();
-  const meetingTime = new Date(nextMeeting.time);
-  
-  // Remove if in the past
-  if (meetingTime < now) {
+  if (new Date(nextMeeting.time) < new Date()) {
     nextMeeting = null;
     return res.json(null);
   }
-  
   res.json(nextMeeting);
 });
 
+// ─── Set next meeting (with voice announcement scheduling) ───────────────────
 app.post('/api/meeting', (req, res) => {
-  const { title, time, id } = req.body;
+  const { title, time, id, organizer, topic } = req.body;
   if (!title || !time) return res.status(400).json({ error: 'title and time required' });
-  
-  nextMeeting = { title, time, id: id || Date.now().toString(36) };
+
+  // Cancel any existing timer
+  if (meetingTimer) {
+    clearTimeout(meetingTimer);
+    meetingTimer   = null;
+    meetingTTSFile = null;
+  }
+
+  nextMeeting = { title, time, id: id || Date.now().toString(36), organizer, topic };
   res.status(201).json(nextMeeting);
+
+  // Kick off async pre-gen + scheduling (don't await — respond immediately)
+  scheduleMeetingAnnouncement(nextMeeting).catch(e =>
+    console.error('[Meeting] Scheduler error:', e.message)
+  );
 });
 
+// ─── Start server ─────────────────────────────────────────────────────────────
 app.listen(PORT, '0.0.0.0', () => {
   console.log(`Alert dashboard running on http://0.0.0.0:${PORT}`);
+  console.log(`Fish TTS: key=${FISH_API_KEY.slice(0, 8)}… voice=${FISH_VOICE_REF.slice(0, 8)}…`);
 });