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:
2026-09-14 14:31:15 -05:00
commit b29d04ac2c
10 changed files with 1591 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
# Port the DECTalk TTS server listens on
PORT=33001
# Directory for cached mp3 output
OUTPUT_DIR=./output
+4
View File
@@ -0,0 +1,4 @@
node_modules/
output/
*.log
.env
+43
View File
@@ -0,0 +1,43 @@
# Installing the DECTalk TTS Server as a system service
These steps install the server as a systemd service that starts on boot and
restarts automatically if it crashes. Commands that need root are shown with
`sudo`; run them in your own terminal.
## 1. Install prerequisites
```bash
sudo apt install ffmpeg nodejs npm
```
## 2. Install node dependencies
```bash
cd /home/owen/dectalk-tts-server
npm install
```
## 3. Install the systemd unit
```bash
sudo cp /home/owen/dectalk-tts-server/dectalk-tts-server.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now dectalk-tts-server
```
## 4. Verify
```bash
systemctl status dectalk-tts-server
curl -s http://127.0.0.1:33001/health
```
The health endpoint should return `{"status":"ok"}`.
## Managing the service
```bash
sudo systemctl restart dectalk-tts-server
sudo systemctl stop dectalk-tts-server
journalctl -u dectalk-tts-server -f
```
+102
View File
@@ -0,0 +1,102 @@
# DECTalk TTS Server
A standalone HTTP server that turns text into speech using authentic DECTalk
(the Moonbase Alpha voice). It exposes both a simple GET endpoint and a
MiniMax-compatible POST endpoint, so it can be used directly or as a drop-in
TTS backend.
This service was extracted from the ttstoy Discord bot cog so it can run on its
own as a system service, independent of the bot.
## Requirements
- Node.js (v16 or newer recommended)
- ffmpeg (used to encode output to mp3)
- npm dependencies: `express`, `dectalk` (installed via `npm install`)
Install ffmpeg on Debian/Ubuntu:
```bash
sudo apt install ffmpeg
```
## Running
```bash
./start.sh
```
`start.sh` runs `npm install` on first launch and then starts the server. The
listening port defaults to `33001` and can be overridden with the `PORT`
environment variable.
You can also run it directly:
```bash
npm install
PORT=33001 node server.js
```
## API
### GET /say
Generate speech and return an mp3.
```
GET /say?text=Hello%20world
```
Response: `audio/mpeg` (mp3 bytes).
Voice is controlled inline using DECTalk voice commands (see below).
### POST /v1/t2a_v2
MiniMax-compatible endpoint. Accepts JSON `{ "text": "..." }` and returns a
JSON body with the audio encoded as hex under `data.audio`.
```bash
curl -X POST http://127.0.0.1:33001/v1/t2a_v2 \
-H 'Content-Type: application/json' \
-d '{"text":"Hello from DECTalk"}'
```
### GET /voices
Returns the list of available DECTalk voices and their inline command codes.
### GET /engine
Returns engine type and version metadata.
### GET /health
Returns `{ "status": "ok" }`.
## Voice commands
Voices are selected inline by prefixing the text with a command code:
| Voice | Command | Description |
|--------|---------|-------------------------|
| Paul | `[:np]` | Perfect Paul (default) |
| Betty | `[:nb]` | Beautiful Betty |
| Harry | `[:nh]` | Huge Harry (deep male) |
| Frank | `[:nf]` | Frail Frank (elderly) |
| Dennis | `[:nd]` | Doctor Dennis |
| Kit | `[:nk]` | Kit the Kid (child) |
| Ursula | `[:nu]` | Uppity Ursula |
| Rita | `[:nr]` | Rough Rita |
| Wendy | `[:nw]` | Whispering Wendy (soft) |
Example:
```
GET /say?text=[:nh]This is Huge Harry speaking.
```
## Running as a system service
A systemd unit file is provided (`dectalk-tts-server.service`). See INSTALL.md
for setup steps.
+16
View File
@@ -0,0 +1,16 @@
[Unit]
Description=DECTalk TTS Server
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=owen
WorkingDirectory=/home/owen/dectalk-tts-server
Environment=PORT=33001
ExecStart=/home/owen/dectalk-tts-server/start.sh
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target
+1125
View File
File diff suppressed because it is too large Load Diff
+20
View File
@@ -0,0 +1,20 @@
{
"name": "dectalk-server",
"version": "1.0.0",
"description": "DECTalk TTS server compatible with RedBot TTSTOY cog",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js"
},
"keywords": ["dectalk", "tts", "text-to-speech", "redbot", "moonbase-alpha"],
"author": "",
"license": "MIT",
"dependencies": {
"express": "^4.18.2",
"dectalk": "^1.0.0"
},
"devDependencies": {
"nodemon": "^3.0.1"
}
}
+209
View File
@@ -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');
});
Executable
+20
View File
@@ -0,0 +1,20 @@
#!/bin/bash
# Start script for the DECTalk TTS server.
# Installs node dependencies on first run, then runs the server.
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
cd "$SCRIPT_DIR"
if ! command -v ffmpeg >/dev/null 2>&1; then
echo "Error: ffmpeg is not installed. Install it with: sudo apt install ffmpeg"
exit 1
fi
if [ ! -d node_modules ]; then
echo "Installing node dependencies..."
npm install
fi
export PORT="${PORT:-33001}"
exec node server.js
Executable
+48
View File
@@ -0,0 +1,48 @@
#!/bin/bash
# Test script for DECTalk server API
BASE_URL="${1:-http://127.0.0.1:3000}"
echo "🧪 Testing DECTalk Server at $BASE_URL"
echo ""
# Test 1: Health check
echo "1️⃣ Testing /health endpoint..."
curl -s "$BASE_URL/health" | jq '.' || echo "❌ Health check failed"
echo ""
# Test 2: Engine info
echo "2️⃣ Testing /engine endpoint..."
curl -s "$BASE_URL/engine" | jq '.' || echo "❌ Engine info failed"
echo ""
# Test 3: Simple GET endpoint
echo "3️⃣ Testing /say endpoint (saving to test_say.mp3)..."
curl -s "$BASE_URL/say?text=Hello%20world" -o test_say.mp3
if [ -f test_say.mp3 ] && [ -s test_say.mp3 ]; then
echo "✅ Audio saved to test_say.mp3 ($(stat -f%z test_say.mp3 2>/dev/null || stat -c%s test_say.mp3) bytes)"
else
echo "❌ Failed to generate audio"
fi
echo ""
# Test 4: MiniMax-compatible endpoint
echo "4️⃣ Testing /v1/t2a_v2 endpoint (MiniMax-compatible)..."
curl -s -X POST "$BASE_URL/v1/t2a_v2" \
-H "Content-Type: application/json" \
-d '{
"text": "Testing MiniMax compatibility",
"voice_setting": {
"voice_id": "default",
"speed": 1.0
}
}' | jq '.base_resp, .data.audio_id' || echo "❌ MiniMax endpoint failed"
echo ""
echo "✅ All tests complete!"
echo ""
echo "To test audio playback:"
echo " ffplay test_say.mp3"
echo " # or"
echo " mpv test_say.mp3"