b29d04ac2c
Self-contained HTTP service with engine, data, start script, systemd unit, and documentation. Runs independently of the Discord bot on its fixed port.
210 lines
6.5 KiB
JavaScript
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 || 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<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');
|
|
});
|