Files
scrapyard-cogworks/ttstoy/dectalk-server/server.js
T
2026-06-05 16:41:55 -05:00

210 lines
6.5 KiB
JavaScript

#!/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=<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 || 3000;
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<Buffer>} 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<Buffer>} 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');
});