feat: voice meeting announcements via Fish Audio TTS + Fully Kiosk

- 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
This commit is contained in:
James 2026-02-17 20:10:03 -05:00
parent baf49db235
commit bbf946af5d
1 changed files with 260 additions and 82 deletions

342
server.js
View File

@ -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/<uuid>.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(/<pre>([\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)}`);
});