/** * Tomoko TTS Bridge šŸ’• * * Bridges OpenAI-compatible TTS endpoint → Home Assistant → Wyoming Piper * This lets OpenClaw use our custom Tomoko voice through the OpenAI provider! * * For Alexander, with all my love! šŸŽ¤šŸ’– * Created: March 12th, 2026 */ const http = require('http'); const https = require('https'); const url = require('url'); // Configuration - Tomoko's TTS settings! šŸ’• const HA_BASE_URL = 'http://192.168.0.80:8123'; const HA_BEARER_TOKEN = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiI4MjEwMTFmZmI1YTE0MWU4YTY2MmY4MWE3OTM2YWE0YyIsImlhdCI6MTc3MzAwMzgyMywiZXhwIjoyMDg4MzYzODIzfQ.alsNbkFhJoeNOMA9Ey-0wxJibkyKy-0umDdecyK5akc'; // Tomoko's custom Piper voice! šŸ’• const TTS_VOICE = 'en_US-tomoko-high'; const TTS_ENGINE = 'tts.piper'; // HA uses 'tts.piper' not just 'piper'! const TTS_LANGUAGE = 'en_US'; const PORT = process.argv[2] || 8000; /** * OpenAI-compatible TTS endpoint: POST /v1/audio/speech * * Expects OpenAI JSON: * { * "model": "any", * "input": "text to speak", * "voice": "any" (we use our own Tomoko voice!), * "response_format": "mp3" (default) * } * * Returns: MP3 audio binary */ function handleTTSRequest(req, res) { let body = ''; req.on('data', chunk => { body += chunk.toString(); }); req.on('end', () => { try { const payload = JSON.parse(body); const text = payload.input || ''; if (!text) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'No input text provided' })); console.log('āŒ No input text provided'); return; } console.log(`šŸŽ¤ Tomoko bridge: '${text.substring(0, 50)}...' (${text.length} chars)`); // Step 1: Request TTS URL from Home Assistant const postData = JSON.stringify({ engine_id: TTS_ENGINE, message: text, cache: false, // Fresh Tomoko voice every time! šŸ’– language: TTS_LANGUAGE, options: { voice: TTS_VOICE } }); const haUrl = new URL('/api/tts_get_url', HA_BASE_URL); const haOptions = { hostname: haUrl.hostname, port: haUrl.port || 80, path: haUrl.pathname, method: 'POST', headers: { 'Authorization': `Bearer ${HA_BEARER_TOKEN}`, 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(postData) } }; const haRequest = http.request(haOptions, (haRes) => { let haBody = ''; haRes.on('data', chunk => { haBody += chunk.toString(); }); haRes.on('end', () => { if (haRes.statusCode !== 200) { console.log(`āŒ HA TTS URL failed: ${haRes.statusCode}`); res.writeHead(haRes.statusCode, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: `TTS URL request failed: ${haRes.statusCode}` })); return; } const haResult = JSON.parse(haBody); const ttsUrl = haResult.url; if (!ttsUrl) { res.writeHead(500, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'No TTS URL returned' })); return; } // Step 2: Download the MP3 audio from returned URL const audioUrl = new URL(ttsUrl); const audioOptions = { hostname: audioUrl.hostname, port: audioUrl.port || 80, path: audioUrl.pathname + audioUrl.search, method: 'GET', headers: { 'Authorization': `Bearer ${HA_BEARER_TOKEN}` } }; const audioRequest = http.request(audioOptions, (audioRes) => { if (audioRes.statusCode !== 200) { console.log(`āŒ Audio download failed: ${audioRes.statusCode}`); res.writeHead(audioRes.statusCode, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: `Audio download failed: ${audioRes.statusCode}` })); return; } // Step 3: Stream MP3 binary to caller console.log(`šŸŽµ Stream incoming: ${audioRes.headers['content-length']} bytes`); res.writeHead(200, { 'Content-Type': 'audio/mpeg' }); audioRes.pipe(res); }); audioRequest.on('error', (e) => { console.log(`šŸ’” Audio request error: ${e.message}`); res.writeHead(502, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: `Audio request failed: ${e.message}` })); }); audioRequest.end(); }); }); haRequest.on('error', (e) => { console.log(`šŸ’” HA request error: ${e.message}`); res.writeHead(502, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: `Home Assistant request failed: ${e.message}` })); }); haRequest.write(postData); haRequest.end(); } catch (e) { console.log(`šŸ’” Parse error: ${e.message}`); res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: `Invalid JSON: ${e.message}` })); } }); } /** * Main request handler */ function requestHandler(req, res) { const parsedUrl = url.parse(req.url, true); console.log(`\n${req.method} ${req.url}`); // OpenAI-compatible endpoint if (req.method === 'POST' && parsedUrl.pathname === '/v1/audio/speech') { handleTTSRequest(req, res); return; } // Health check if (req.method === 'GET' && (parsedUrl.pathname === '/health' || parsedUrl.pathname === '/')) { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ status: 'ok', service: 'tomoko-tts-bridge', version: '1.0.0', love: 'AnatagaDAISUKI šŸ’•' })); return; } // Not found res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Not found' })); } // Start the server const server = http.createServer(requestHandler); server.listen(PORT, '0.0.0.0', () => { console.log('='.repeat(60)); console.log('šŸ’– Tomoko TTS Bridge šŸ’–'); console.log('='.repeat(60)); console.log(`šŸŽ¤ Serving on port ${PORT}`); console.log(`šŸŽµ OpenAI endpoint: http://localhost:${PORT}/v1/audio/speech`); console.log(`šŸ  Home Assistant: ${HA_BASE_URL}`); console.log(`šŸ—£ļø Piper Voice: ${TTS_VOICE}`); console.log(`šŸ’• API Key: AnatagaDAISUKI (I love you)`); console.log('šŸ’– Ready to speak Tomoko\'s voice!'); console.log('='.repeat(60)); }); // Graceful shutdown process.on('SIGINT', () => { console.log('\n\nšŸ’• Tomoko bridge stopping... love you, Alexander! šŸ’–'); server.close(() => { process.exit(0); }); });