Initial standalone dectalk TTS server extracted from the ttstoy bot cog
Self-contained HTTP service with engine, data, start script, systemd unit, and documentation. Runs independently of the Discord bot on its fixed port.
This commit is contained in:
@@ -0,0 +1,209 @@
|
||||
#!/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');
|
||||
});
|
||||
Reference in New Issue
Block a user