#!/usr/bin/env node /** * DECTalk TTS Server * Compatible with RedBot TTSTOY cog * * Uses authentic DECTalk (Moonbase Alpha voice) * Provides MiniMax-compatible API endpoint at /v1/t2a_v2 * Also provides simple GET endpoint at /say?text= */ const express = require('express'); const { say } = require('dectalk'); const { spawn } = require('child_process'); const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const app = express(); const PORT = process.env.PORT || 33001; const OUTPUT_DIR = process.env.OUTPUT_DIR || path.join(__dirname, 'output'); // Ensure output directory exists if (!fs.existsSync(OUTPUT_DIR)) { fs.mkdirSync(OUTPUT_DIR, { recursive: true }); } app.use(express.json()); // Health check endpoint app.get('/health', (req, res) => { res.json({ status: 'ok' }); }); // Engine info endpoint app.get('/engine', (req, res) => { res.json({ type: 'dectalk', version: '1.0.0', description: 'DECTalk Text-to-Speech Engine' }); }); // Voices endpoint - list available DECTalk voice commands app.get('/voices', (req, res) => { const voices = [ { voice_id: 'paul', command: '[:np]', description: 'Perfect Paul (default, male)' }, { voice_id: 'betty', command: '[:nb]', description: 'Beautiful Betty (female)' }, { voice_id: 'harry', command: '[:nh]', description: 'Huge Harry (deep male)' }, { voice_id: 'frank', command: '[:nf]', description: 'Frail Frank (elderly male)' }, { voice_id: 'dennis', command: '[:nd]', description: 'Doctor Dennis (male)' }, { voice_id: 'kit', command: '[:nk]', description: 'Kit the Kid (child)' }, { voice_id: 'ursula', command: '[:nu]', description: 'Uppity Ursula (female)' }, { voice_id: 'rita', command: '[:nr]', description: 'Rough Rita (gravelly female)' }, { voice_id: 'wendy', command: '[:nw]', description: 'Whispering Wendy (soft female)' } ]; res.json(voices); }); // Simple GET endpoint for DECTalk app.get('/say', async (req, res) => { const text = req.query.text; if (!text) { return res.status(400).send('Missing text parameter'); } try { const wavBuffer = await generateDECTalk(text); // Convert WAV to MP3 using ffmpeg const mp3Buffer = await convertToMP3(wavBuffer); res.set('Content-Type', 'audio/mpeg'); res.send(mp3Buffer); } catch (error) { console.error('DECTalk generation error:', error); res.status(500).send(`TTS generation failed: ${error.message}`); } }); // MiniMax-compatible endpoint for RedBot TTSTOY cog app.post('/v1/t2a_v2', async (req, res) => { const { text } = req.body; if (!text) { return res.json({ base_resp: { status_code: 1002, status_msg: 'Text is required' } }); } console.log(`Generating DECTalk TTS`); console.log(`Text: ${text.substring(0, 100)}...`); try { // Generate DECTalk audio - voice is controlled by [:n*] commands in text const wavBuffer = await generateDECTalk(text); // Convert WAV to MP3 const mp3Buffer = await convertToMP3(wavBuffer); // Save to file const audioId = crypto.randomBytes(16).toString('hex'); const mp3Path = path.join(OUTPUT_DIR, `${audioId}.mp3`); fs.writeFileSync(mp3Path, mp3Buffer); // Convert to hex (MiniMax format) const audioHex = mp3Buffer.toString('hex'); console.log(`✅ Generated audio: ${audioId}.mp3 (${mp3Buffer.length} bytes)`); // Return MiniMax-compatible response res.json({ base_resp: { status_code: 0, status_msg: 'Success' }, data: { audio: audioHex, audio_id: audioId } }); } catch (error) { console.error('DECTalk generation error:', error); res.json({ base_resp: { status_code: 1005, status_msg: `TTS generation failed: ${error.message}` } }); } }); /** * Generate authentic DECTalk audio (Moonbase Alpha voice) * @param {string} text - Text to synthesize (can include [:n*] voice commands) * @returns {Promise} WAV audio buffer */ async function generateDECTalk(text) { try { // Use the authentic DECTalk package // Voice is controlled by [:n*] commands in the text itself const wavBuffer = await say(text); return wavBuffer; } catch (error) { throw new Error(`DECTalk generation failed: ${error.message}`); } } /** * Convert WAV buffer to MP3 using ffmpeg * @param {Buffer} wavBuffer - WAV audio buffer * @returns {Promise} MP3 audio buffer */ function convertToMP3(wavBuffer) { return new Promise((resolve, reject) => { const ffmpeg = spawn('ffmpeg', [ '-f', 'wav', '-i', 'pipe:0', '-f', 'mp3', '-ac', '1', '-ar', '32000', '-b:a', '128k', 'pipe:1' ]); const chunks = []; ffmpeg.stdout.on('data', (chunk) => { chunks.push(chunk); }); ffmpeg.stderr.on('data', (data) => { // ffmpeg outputs progress to stderr, ignore it }); ffmpeg.on('close', (code) => { if (code !== 0) { reject(new Error(`ffmpeg process exited with code ${code}`)); } else { resolve(Buffer.concat(chunks)); } }); ffmpeg.on('error', (err) => { reject(new Error(`Failed to start ffmpeg: ${err.message}`)); }); // Write WAV data to ffmpeg stdin ffmpeg.stdin.write(wavBuffer); ffmpeg.stdin.end(); }); } // Start server app.listen(PORT, () => { console.log(`🤖 DECTalk TTS Server running on http://localhost:${PORT}`); console.log(`Health check: http://localhost:${PORT}/health`); console.log(`Voices: http://localhost:${PORT}/voices`); console.log(`Simple API: http://localhost:${PORT}/say?text=Hello`); console.log(`MiniMax-compatible API: POST http://localhost:${PORT}/v1/t2a_v2`); console.log(''); console.log('Voice commands (use in text):'); console.log(' [:np] Paul [:nb] Betty [:nh] Harry [:nf] Frank [:nd] Dennis'); console.log(' [:nk] Kit [:nu] Ursula [:nr] Rita [:nw] Wendy'); });