Update code to typescript

This commit is contained in:
2026-05-14 20:06:15 +02:00
parent fdb4b2d50f
commit f2ce38c176
68 changed files with 7647 additions and 5121 deletions

BIN
.DS_Store vendored

Binary file not shown.

3
.gitignore vendored
View File

@@ -1,6 +1,9 @@
node_modules node_modules
voice_tmp voice_tmp
data/voice_tmp
.env .env
*.db *.db
.DS_Store .DS_Store
gkey.json gkey.json
dist
*.log

9
.prettierrc.json Normal file
View File

@@ -0,0 +1,9 @@
{
"semi": true,
"singleQuote": false,
"trailingComma": "all",
"printWidth": 100,
"tabWidth": 2,
"useTabs": false,
"arrowParens": "always"
}

View File

@@ -1,31 +0,0 @@
const Voice = require("@discordjs/voice");
module.exports = class AudioQueue {
constructor(connection, api) {
this.connection = connection;
this.api = api;
this.queue = [];
this.current = undefined;
this.api.player.on(Voice.AudioPlayerStatus.Idle, this.handleStop.bind(this));
}
playNext() {
if (this.queue.length == 0) {
this.current = undefined;
return;
}
this.current = this.api.play(this.queue[0]);
}
handleStop(current) {
this.queue.shift();
this.playNext();
}
add(element) {
this.queue.push(element);
if (this.queue.length == 1) this.playNext();
}
flush() {
this.current.setVolume(0);
this.queue=[];
this.playNext();
}
}

View File

@@ -1,5 +1,13 @@
FROM node:18-alpine FROM node:22-alpine
WORKDIR /app
RUN apk add --no-cache ffmpeg espeak python3 make g++
COPY package*.json ./
RUN npm install --omit=dev
COPY . . COPY . .
RUN apk add ffmpeg
RUN npm install ENTRYPOINT ["npx", "tsx", "src/index.ts"]
ENTRYPOINT ["node", "index.js"]

View File

@@ -1,62 +0,0 @@
const { Snowflake, Client, Guild, VoiceBasedChannel, Events, Status, GatewayDispatchEvents } = require("discord.js");
const adapters = new Map();
const trackedClients = new Set();
const trackedShards = new Map();
function trackClient(client) {
if (trackedClients.has(client)) return;
trackedClients.add(client);
client.ws.on(GatewayDispatchEvents.VoiceServerUpdate, (payload) => {
adapters.get(payload.guild_id)?.onVoiceServerUpdate(payload);
});
client.ws.on(GatewayDispatchEvents.VoiceStateUpdate, (payload) => {
if (payload.guild_id && payload.session_id && payload.user_id === client.user?.id) {
adapters.get(payload.guild_id)?.onVoiceStateUpdate(payload);
}
});
client.on(Events.ShardDisconnect, (_, shardId) => {
const guilds = trackedShards.get(shardId);
if (guilds) {
for (const guildID of guilds.values()) {
adapters.get(guildID)?.destroy();
}
}
trackedShards.delete(shardId);
});
}
function trackGuild(guild) {
let guilds = trackedShards.get(guild.shardId);
if (!guilds) {
guilds = new Set();
trackedShards.set(guild.shardId, guilds);
}
guilds.add(guild.id);
}
module.exports = function (channel) {
return (methods) => {
adapters.set(channel.guild.id, methods);
trackClient(channel.client);
trackGuild(channel.guild);
return {
sendPayload(data) {
console.log(channel.guild.shard.status);
// if (channel.guild.shard.status === Status.READY) {
console.log("Sending shard data");
channel.guild.shard.send(data);
return true;
// }
console.log("Unable to send channel payload");
return false;
},
destroy() {
console.log("Destroying adapter");
return adapters.delete(channel.guild.id);
},
};
};
}

0
api.js
View File

30
eslint.config.js Normal file
View File

@@ -0,0 +1,30 @@
import tseslint from "@typescript-eslint/eslint-plugin";
import tsparser from "@typescript-eslint/parser";
import prettier from "eslint-config-prettier";
export default [
{
files: ["src/**/*.ts"],
languageOptions: {
parser: tsparser,
parserOptions: {
ecmaVersion: "latest",
sourceType: "module",
project: "./tsconfig.json",
},
},
plugins: {
"@typescript-eslint": tseslint,
},
rules: {
...tseslint.configs.recommended.rules,
"@typescript-eslint/no-unused-vars": [
"warn",
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
],
"@typescript-eslint/no-explicit-any": "warn",
"no-console": "off",
},
},
prettier,
];

View File

@@ -1,13 +1,49 @@
# Core Discord settings
TOKEN=DISCORD_BOT_TOKEN_HERE TOKEN=DISCORD_BOT_TOKEN_HERE
GUILD=GUILD_ID_HERE GUILD=GUILD_ID_HERE
CHANNEL=VOICE_CHANNEL_ID_HERE CHANNEL=VOICE_CHANNEL_ID_HERE
STRING_SET=en
VOICE_TMP_PATH=./voice_tmp/
DB_FILE=DATABASE_PATH_HERE
PREFIX=+ PREFIX=+
# Localization
STRING_SET=en
# Storage
VOICE_TMP_PATH=./data/voice_tmp/
DB_FILE=./data/tardis.db
# Announcement defaults
ANNOUNCEMENT_ENGINE=espeak ANNOUNCEMENT_ENGINE=espeak
ANNOUNCEMENT_VOICE=en ANNOUNCEMENT_VOICE=en
# Canttalk (per-message TTS) — optional
TTS_CHANNEL=CANTTALK_TEXT_CHANNEL_ID_HERE
# IBM Watson TTS — optional
watsonURL=WATSON_URL_HERE watsonURL=WATSON_URL_HERE
watsonAPIKey=WATSON_API_KEY_HERE watsonAPIKey=WATSON_API_KEY_HERE
TTS_CHANNEL=CANTTALK_TEXT_CHANNEL_ID_HERE
GOOGLE_APPLICATION_CREDENTIALS=GOOGLE_CLOUD_KEY_HERE # Azure TTS — optional
AZURE_API_KEY=AZURE_API_KEY_HERE
AZURE_REGION=AZURE_REGION_HERE
AZURE_LIST_ENDPOINT=AZURE_LIST_ENDPOINT_HERE
# ElevenLabs TTS — optional
XI_API_KEY=ELEVENLABS_API_KEY_HERE
# OpenAI (used by both TTS provider and chatgpt module) — optional
OPENAI_API_KEY=OPENAI_API_KEY_HERE
OPENAI_CHAT_MODEL=gpt-4o
# Google Cloud TTS — optional (path to service account JSON)
GOOGLE_APPLICATION_CREDENTIALS=./gkey.json
# Unreal Speech TTS — optional
UNREAL_API_KEY=UNREAL_SPEECH_API_KEY_HERE
# Mangle module — comma-separated language chain
MANGLE_LANGS=en,de,ja,ru,en
# QuoteDB module — optional
QDB_URL=QUOTE_DB_URL_HERE
QDB_USER=QUOTE_DB_USERNAME_HERE
QDB_PASS=QUOTE_DB_PASSWORD_HERE

158
index.js
View File

@@ -1,158 +0,0 @@
const Discord = require('discord.js');
const Voice = require("@discordjs/voice");
const adapterCreator = require("./adapter");
require('dotenv').config();
const fetch = require('node-fetch');
const fs = require('fs');
const sha1 = require('sha1');
const sqlite3 = require('sqlite3');
const { open } = require('sqlite')
let joinedVoiceChannels = [];
let joinedVoiceChannelConnections = new Map();
let modules = [];
let commandHandlers = new Map();
const player = Voice.createAudioPlayer();
const rest = new Discord.REST({ version: '10' }).setToken(process.env["TOKEN"]);
const bot = new Discord.Client({
intents: [
Discord.GatewayIntentBits.GuildMembers,
Discord.GatewayIntentBits.GuildMessageReactions,
Discord.GatewayIntentBits.GuildMessages,
Discord.GatewayIntentBits.GuildPresences,
Discord.GatewayIntentBits.GuildVoiceStates,
Discord.GatewayIntentBits.Guilds,
Discord.GatewayIntentBits.MessageContent
]
});
async function initDB() {
console.log(__dirname);
api.db = await open({
filename: process.env["DB_FILE"],
driver: sqlite3.Database
});
}
const api = {
player: player,
db: undefined,
queue: undefined,
strings: require('./strings/' + process.env.STRING_SET + '.json'),
ttsEngines: (() => {
let engines = {};
console.log(`Registering TTS engines...`);
const engineDirectories = fs.readdirSync('./tts');
engineDirectories.forEach((dir) => {
if (dir.startsWith('.')) return;
eng = require(`./tts/${dir}/index.js`);
engines[dir] = new eng;
console.log(`Loading ./tts/${dir}/index.js`)
})
return engines;
})(),
announcementVoice: process.env.ANNOUNCEMENT_VOICE,
announcementEngine: undefined,
play: (file) => {
return player.play(Voice.createAudioResource(file));
},
respond: (message, text, voiceText) => {
let toSend = message.member.displayName + ", " + (voiceText ? voiceText : text);
if (message.member.voice.channel) {
api.queue.add(__dirname + "/sysmsg.wav");
api.speak(message.member.voice.channel, toSend);
} else {
message.reply(text);
}
},
getActiveVoiceChannel: () => joinedVoiceChannels[0],
isInVoiceChannel: (channel) => {
return joinedVoiceChannels.includes(channel);
},
getConnectionForVoiceChannel: (channel) => {
return joinedVoiceChannelConnections.get(channel);
},
generateVoice: async (string, engine, voice, params) => {
const hash = sha1(voice + string);
const filepath = process.env.VOICE_TMP_PATH + hash + '.' + engine.fileExtension;
if (!fs.existsSync(filepath)) {
await engine.getSpeechFile(string, filepath, voice, params);
}
return filepath;
},
joinChannel: async (channel) => {
if (!api.isInVoiceChannel(channel)) {
const res = Voice.joinVoiceChannel({
channelId: channel.id,
guildId: channel.guild.id,
adapterCreator: adapterCreator(channel)
});
res.subscribe(player);
joinedVoiceChannels.push(channel);
joinedVoiceChannelConnections.set(channel, res);
}
},
leaveChannel: async (channel) => {
if (joinedVoiceChannels.includes(channel)) {
let con = joinedVoiceChannelConnections.get(channel);
joinedVoiceChannels = joinedVoiceChannels.filter((chan) => chan !== channel);
con.disconnect();
joinedVoiceChannelConnections.delete(channel);
}
},
speak: async (channel, message, engine = api.announcementEngine, voice = api.announcementVoice, params = {}) => {
const filepath = await api.generateVoice(message, engine, voice, params);
api.queue.add(filepath);
},
registerCommand: async (commandString, commandFunc) => {
commandHandlers.set(commandString, commandFunc);
}
}
function registerModules() {
console.log(`Registering modules...`);
const moduleDirectories = fs.readdirSync('./modules');
moduleDirectories.forEach((dir) => {
if (dir.startsWith('.')) return;
modules.push(require(`./modules/${dir}`));
console.log(`Loading ./modules/${dir}/index.js`)
})
modules.forEach((mod) => mod(bot, api));
}
function handleMessage(message) {
console.log(`I got message`);
if (message.content.startsWith(process.env.PREFIX)) {
const args = message.content.split(" ");
const command = args[0].substr(1, args[0].length);
const execution = commandHandlers.get(command);
if (command) {
if (execution) execution(args, message);
}
}
}
api.announcementEngine = api.ttsEngines[process.env.ANNOUNCEMENT_ENGINE];
async function start() {
await initDB();
registerModules();
}
bot.login(process.env.TOKEN);
bot.on('messageCreate', handleMessage);
start();

BIN
modules/.DS_Store vendored

Binary file not shown.

View File

@@ -1,49 +0,0 @@
const printf=require('printf');
const fs = require('fs');
const path = require('path');
module.exports = async (bot, api) => {
bot.on('messageCreate', async (message) => {
if (!message.content.startsWith(process.env.PREFIX)) {
if (message.channel.id == process.env.TTS_CHANNEL) {
let chan=message.member.voice.channel;
let userRow = await api.db.get('select * from TTSPreferences where user_id=?', message.author.id);
if (!userRow) {
await api.db.run('insert into TTSPreferences (user_id,engine,voice) values (?,?,?)', [message.author.id, api.announcementEngine.shortName, api.announcementVoice]);
userRow = await api.db.get('select * from TTSPreferences where user_id=?', message.author.id);
}
if (api.ttsEngines[userRow.engine]) {
api.speak(chan,message.content, api.ttsEngines[userRow.engine], userRow.voice)
} else {
}
}
}
});
api.registerCommand('myvoice', async (args, message) => {
let userEngine, userVoice;
if (args.length > 3) {
return api.respond(message, printf(api.strings.TOO_MANY_ARGUMENTS));
}
if (api.ttsEngines[args[1]]) {
userEngine = args[1];
if (api.ttsEngines[userEngine].validateVoice(args[2].toLowerCase())) {
userVoice = args[2].toLowerCase();
api.respond(message, printf(api.strings.USER_VOICE_CHANGED, userVoice, api.ttsEngines[userEngine].longName));
} else {
userVoice = api.ttsEngines[userEngine].getDefaultVoice();
api.respond(message, printf(api.strings.INVALID_VOICE, userVoice, api.ttsEngines[userEngine].longName));
}
await api.db.run('update TTSPreferences set engine=?, voice=? where user_id=?', userEngine, userVoice, message.author.id);
} else {
api.respond(message, printf(api.strings.INVALID_ENGINE, args[1]));
}
});
api.registerCommand('random', async (args, message) => {
const files = fs.readdirSync(process.env["VOICE_TMP_PATH"]);
const rnd = files[Math.floor(Math.random()*files.length)];
console.log(rnd);
api.queue.add(__dirname + "/../../sysmsg.wav");
api.queue.add(process.env["VOICE_TMP_PATH"] + "/" + rnd);
});
}

View File

@@ -1,34 +0,0 @@
const printf = require('printf');
const AudioQueue = require('../../AudioQueue.js')
module.exports = function (bot, api) {
bot.on('voiceStateUpdate', async (oldState, newState) => {
if (newState.member.user.bot) return;
if (oldState.channel && newState.channel) return;
const channel = oldState.channel || newState.channel;
if (!channel) return;
if (channel.members.size < 2) {
api.AudioQueue.flush();
await api.leaveChannel(channel);
}
await api.joinChannel(channel);
let joined = false;
if (!oldState.channel) {
joined = true;
let conn = api.getConnectionForVoiceChannel(channel);
if (!api.queue) api.queue = new AudioQueue(conn, api);
}
let username = newState.member.displayName;
let str = "";
if (!joined) {
str = printf(api.strings.USER_LEFT, username);
} else {
str = printf(api.strings.USER_JOINED, username);
}
const filepath = await api.generateVoice(str, api.announcementEngine, api.announcementVoice);
api.queue.add(__dirname + "/sysmsg.wav");
api.queue.add(filepath);
})
}

View File

@@ -1,23 +0,0 @@
let ChatGPTAPI = null;
module.exports = function (bot, api) {
import("chatgpt").then((mod) => {
ChatGPTAPI = mod.ChatGPTAPI;
});
api.registerCommand('chat', async (args, message) => {
const response = await getChatGPTResponse(message.content.slice(6).trim());
api.respond(message, response);
});
}
async function getChatGPTResponse(prompt) {
const api = new ChatGPTAPI({
apiKey: process.env.OPENAI_API_KEY,
completionParams: {
model: 'gpt-4o'
}
})
const res = await api.sendMessage(prompt);
return res.text;
}

View File

@@ -1,29 +0,0 @@
const gtranslate = require('node-google-translate-skidz');
module.exports = function (bot, api) {
api.registerCommand('mangle', async (args, message) => {
let str = message.content.slice(8).trim();
let langs = process.env.MANGLE_LANGS.split(',');
let lang;
let i = 0;
for (let lang of langs) {
if (i >= langs.length - 1) break;
let translationResult = await translate(str, lang, langs[i + 1]);
str = translationResult.translation;
i++;
}
api.respond(message, str);
});
}
async function translate(text, fromLang, toLang) {
return new Promise((resolve, reject) => {
gtranslate({
text: text,
source: fromLang,
target: toLang
}, (res) => {
resolve(res);
})
})
}

View File

@@ -1,17 +0,0 @@
const fetch = require('node-fetch');
module.exports = function (bot, api) {
api.registerCommand('randomquote', async (args, message) => {
const data = await fetch(process.env["QDB_URL"], {
headers: {
Authorization: 'Basic ' + Buffer.from(`${process.env["QDB_USER"]}:${process.env["QDB_PASS"]}`).toString('base64')
}
});
const quotes = await data.json();
const quote = quotes[Math.floor(Math.random()*quotes.length)];
let chan=message.member.voice.channel;
// api.queue.add(__dirname + "/sysmsg.wav");
// api.speak(chan, `${quote.author}, on ${quote.medium}: ${quote.quote}`);
api.respond(message, `Here's your quote: ${quote.author}, on ${author.medium}: ${quote.quote}`);
})
}

View File

@@ -1,6 +0,0 @@
module.exports = function (bot, api) {
api.registerCommand("summon", async (args, message) => {
await api.joinChannel(message.member.voice.channel);
api.respond(message, `Hi!`);
})
}

View File

@@ -1,23 +0,0 @@
const printf=require('printf');
module.exports = function (bot, api) {
api.registerCommand('announcevoice', (args, message) => {
let channel = api.getActiveVoiceChannel();
if (args.length > 3) {
return api.respond(message, printf(api.strings.TOO_MANY_ARGUMENTS));
}
if (api.ttsEngines[args[1]]) {
api.announcementEngine = api.ttsEngines[args[1]];
if (api.announcementEngine.validateVoice(args[2])) {
api.announcementVoice = args[2];
api.respond(message, printf(api.strings.SYSTEM_VOICE_CHANGED, api.announcementVoice, api.announcementEngine.longName));
} else {
api.announcementVoice = api.announcementEngine.getDefaultVoice();
api.respond(message, printf(api.strings.INVALID_VOICE, api.announcementVoice, api.announcementEngine.longName));
}
} else {
api.respond(message, printf(api.strings.INVALID_ENGINE, args[1]));
}
});
api.registerCommand('flush',()=>api.queue.flush());
}

View File

@@ -1,14 +0,0 @@
const AudioQueue=require('../../AudioQueue.js')
module.exports = function (bot, api) {
bot.on('ready', async () => {
console.log("Bot initialized and listening");
const guild = await bot.guilds.fetch(process.env.GUILD);
const channel = await bot.channels.fetch(process.env.CHANNEL);
await api.joinChannel(channel);
let conn=api.getConnectionForVoiceChannel(channel);
if (!api.queue) api.queue=new AudioQueue(conn, api);
api.queue.add(__dirname + "/../../sysstart.wav");
api.speak(channel, api.strings.WELCOME);
})
}

View File

@@ -1,47 +0,0 @@
const printf = require('printf')
const isStringInt = require('is-string-int');
const levenshtein = require('fast-levenshtein')
module.exports = function (bot, api) {
bot.currentWBW = "";
api.registerCommand('wbw', async (args, message) => {
if (args.length == 1) {
return api.respond(message, bot.currentWBW ? printf(api.strings.CURRENT_STORY, bot.currentWBW) : printf(api.strings.NO_STORY));
}
if (args.length > 2) {
return api.respond(message, printf(api.strings.TOO_MANY_ARGUMENTS));
} else {
if (isStringInt(args[1])) {
let story = await api.db.get('select * from WBWStories where story_id=?', parseInt(args[1]))
if (!story) {
return api.respond(message, api.strings.WBW_INVALID_ID)
} else {
return api.respond(message, story.story_text)
}
} else {
let lastUser = await api.db.get('select value from BotState where key="last_wbw"');
if (message.author.id == lastUser.value && bot.currentWBW != "") {
let lastWord = (bot.currentWBW.indexOf(" ") == bot.currentWBW.lastIndexOf(" ")) ? bot.currentWBW : bot.currentWBW.slice(bot.currentWBW.slice(0,-1).lastIndexOf(' ') + 1);
console.log(args[1], lastWord, levenshtein.get(args[1], lastWord))
if (levenshtein.get(args[1], lastWord) <= 3) {
bot.currentWBW = bot.currentWBW.replace(new RegExp(lastWord + "([^" + lastWord + "]*)$"), args[1] + "$1 ");
api.respond(message, printf(api.strings.WBW_REPLACED, lastWord, args[1]))
} else {
return api.respond(message, printf(api.strings.WBW_TOO_DIFFERENT))
}
} else {
bot.currentWBW += args[1] + ' ';
api.respond(message, printf(api.strings.WBW_NEW_WORD))
let toSay = bot.currentWBW.indexOf(".") == -1 ? bot.currentWBW : bot.currentWBW.slice(bot.currentWBW.lastIndexOf('.') + 2);
api.speak(message.member.voice.channel, toSay)
await api.db.run('update BotState set value=? where key="last_wbw"', message.author.id);
}
}
}
})
api.registerCommand('newwbw', async (args, message) => {
await api.db.run('insert into WBWStories (story_text) values(?)', bot.currentWBW);
bot.currentWBW = '';
api.respond(message, printf(api.strings.WBW_RESET))
})
}

4419
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,37 +1,50 @@
{ {
"name": "tardis-bot", "name": "tardis-bot",
"version": "1.0.0", "version": "2.0.0",
"description": "", "description": "Discord bot with TTS announcements and assorted modules",
"main": "index.js", "type": "module",
"scripts": { "main": "src/index.ts",
"test": "echo \"Error: no test specified\" && exit 1", "engines": {
"start": "node index.js" "node": ">=22"
},
"scripts": {
"start": "tsx src/index.ts",
"dev": "tsx watch src/index.ts",
"typecheck": "tsc --noEmit",
"lint": "eslint \"src/**/*.ts\"",
"lint:fix": "eslint \"src/**/*.ts\" --fix",
"format": "prettier -w \"src/**/*.ts\""
}, },
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": { "dependencies": {
"@discordjs/opus": "^0.10.0",
"@discordjs/voice": "^0.19.0", "@discordjs/voice": "^0.19.0",
"@google-cloud/text-to-speech": "^3.1.3", "@google-cloud/text-to-speech": "^6.4.1",
"@noble/ciphers": "^1.3.0", "@noble/ciphers": "^1.3.0",
"@sefinek/google-tts-api": "^2.1.11",
"@snazzah/davey": "^0.1.6", "@snazzah/davey": "^0.1.6",
"@stablelib/xchacha20poly1305": "^2.0.1", "@stablelib/xchacha20poly1305": "^2.0.1",
"chatgpt": "^5.1.2", "discord.js": "^14.26.4",
"discord.js": "^14.8.0", "dotenv": "^16.4.7",
"dotenv": "^8.2.0",
"fast-levenshtein": "^3.0.0", "fast-levenshtein": "^3.0.0",
"google-tts-api": "^2.0.2", "microsoft-cognitiveservices-speech-sdk": "^1.49.0",
"is-string-int": "^1.0.1", "openai": "^6.4.0",
"microsoft-cognitiveservices-speech-sdk": "^1.16.0",
"node-fetch": "^2.6.1",
"node-google-translate-skidz": "^1.1.2",
"opusscript": "^0.0.8",
"printf": "^0.6.1", "printf": "^0.6.1",
"sam-js": "^0.1.2", "sam-js": "^0.3.1",
"sha1": "^1.1.1", "sodium-native": "^5.1.0",
"sodium-native": "^3.4.1", "sqlite": "^5.1.1",
"sqlite": "^4.0.21", "sqlite3": "^5.1.7",
"sqlite3": "^5.0.2", "tsx": "^4.19.2",
"wavefile": "^11.0.0" "wavefile": "^11.0.0",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/fast-levenshtein": "^0.0.4",
"@types/node": "^22.10.5",
"@typescript-eslint/eslint-plugin": "^8.20.0",
"@typescript-eslint/parser": "^8.20.0",
"eslint": "^9.18.0",
"eslint-config-prettier": "^9.1.0",
"prettier": "^3.4.2",
"typescript": "^5.7.3"
} }
} }

66
src/adapter.ts Normal file
View File

@@ -0,0 +1,66 @@
import {
Events,
GatewayDispatchEvents,
type Client,
type Guild,
type VoiceBasedChannel,
} from "discord.js";
import type {
DiscordGatewayAdapterCreator,
DiscordGatewayAdapterLibraryMethods,
} from "@discordjs/voice";
const adapters = new Map<string, DiscordGatewayAdapterLibraryMethods>();
const trackedClients = new Set<Client>();
const trackedShards = new Map<number, Set<string>>();
function trackClient(client: Client): void {
if (trackedClients.has(client)) return;
trackedClients.add(client);
client.ws.on(GatewayDispatchEvents.VoiceServerUpdate, (payload) => {
adapters.get(payload.guild_id)?.onVoiceServerUpdate(payload);
});
client.ws.on(GatewayDispatchEvents.VoiceStateUpdate, (payload) => {
if (payload.guild_id && payload.session_id && payload.user_id === client.user?.id) {
adapters.get(payload.guild_id)?.onVoiceStateUpdate(payload);
}
});
client.on(Events.ShardDisconnect, (_event, shardId) => {
const guilds = trackedShards.get(shardId);
if (guilds) {
for (const guildID of guilds.values()) {
adapters.get(guildID)?.destroy();
}
}
trackedShards.delete(shardId);
});
}
function trackGuild(guild: Guild): void {
let guilds = trackedShards.get(guild.shardId);
if (!guilds) {
guilds = new Set();
trackedShards.set(guild.shardId, guilds);
}
guilds.add(guild.id);
}
export function createAdapter(channel: VoiceBasedChannel): DiscordGatewayAdapterCreator {
return (methods) => {
adapters.set(channel.guild.id, methods);
trackClient(channel.client);
trackGuild(channel.guild);
return {
sendPayload(data) {
channel.guild.shard.send(data);
return true;
},
destroy() {
return void adapters.delete(channel.guild.id);
},
};
};
}

42
src/audio/AudioQueue.ts Normal file
View File

@@ -0,0 +1,42 @@
import {
AudioPlayerStatus,
createAudioResource,
type AudioPlayer,
type AudioResource,
} from "@discordjs/voice";
export class AudioQueue {
private queue: string[] = [];
private current: AudioResource | undefined;
constructor(private readonly player: AudioPlayer) {
this.player.on(AudioPlayerStatus.Idle, () => this.handleStop());
}
add(filepath: string): void {
this.queue.push(filepath);
if (this.queue.length === 1) this.playNext();
}
flush(): void {
this.current?.volume?.setVolume(0);
this.queue = [];
this.playNext();
}
private playNext(): void {
const next = this.queue[0];
if (next === undefined) {
this.current = undefined;
return;
}
const resource = createAudioResource(next, { inlineVolume: true });
this.player.play(resource);
this.current = resource;
}
private handleStop(): void {
this.queue.shift();
this.playNext();
}
}

116
src/audio/AudioService.ts Normal file
View File

@@ -0,0 +1,116 @@
import { createHash } from "node:crypto";
import { existsSync } from "node:fs";
import { join } from "node:path";
import {
createAudioPlayer,
joinVoiceChannel,
type AudioPlayer,
type VoiceConnection,
} from "@discordjs/voice";
import type { Message, VoiceBasedChannel } from "discord.js";
import { createAdapter } from "../adapter.js";
import type { BaseEngine, VoiceParams } from "../tts/BaseEngine.js";
import type { TTSRegistry } from "../tts/registry.js";
import { AudioQueue } from "./AudioQueue.js";
export interface AudioServiceOptions {
voiceTmpPath: string;
tts: TTSRegistry;
}
export class AudioService {
readonly player: AudioPlayer = createAudioPlayer();
readonly queue: AudioQueue = new AudioQueue(this.player);
private joinedChannels: VoiceBasedChannel[] = [];
private connections = new Map<VoiceBasedChannel, VoiceConnection>();
constructor(private readonly opts: AudioServiceOptions) {}
getActiveVoiceChannel(): VoiceBasedChannel | undefined {
return this.joinedChannels[0];
}
isInVoiceChannel(channel: VoiceBasedChannel): boolean {
return this.joinedChannels.includes(channel);
}
getConnectionForVoiceChannel(channel: VoiceBasedChannel): VoiceConnection | undefined {
return this.connections.get(channel);
}
async joinChannel(channel: VoiceBasedChannel): Promise<void> {
if (this.isInVoiceChannel(channel)) return;
const connection = joinVoiceChannel({
channelId: channel.id,
guildId: channel.guild.id,
adapterCreator: createAdapter(channel),
});
connection.subscribe(this.player);
this.joinedChannels.push(channel);
this.connections.set(channel, connection);
}
async leaveChannel(channel: VoiceBasedChannel): Promise<void> {
if (!this.isInVoiceChannel(channel)) return;
const conn = this.connections.get(channel);
this.joinedChannels = this.joinedChannels.filter((c) => c !== channel);
conn?.disconnect();
this.connections.delete(channel);
}
/**
* Generates an audio file for `text` using the given engine+voice, caching by
* SHA1(voice + text). Returns the path of the generated file.
*/
async generateVoice(
text: string,
engine: BaseEngine,
voice: string,
params: VoiceParams = {},
): Promise<string> {
const hash = createHash("sha1").update(voice + text).digest("hex");
const filepath = join(this.opts.voiceTmpPath, `${hash}.${engine.fileExtension}`);
if (!existsSync(filepath)) {
await engine.getSpeechFile(text, filepath, voice, params);
}
return filepath;
}
/**
* Synthesizes `message` with the given engine/voice (defaults to the
* configured announcement engine/voice) and queues it for playback.
*/
async speak(
_channel: VoiceBasedChannel,
message: string,
engine: BaseEngine = this.opts.tts.announcement,
voice: string = this.opts.tts.announcementVoice,
params: VoiceParams = {},
): Promise<void> {
const filepath = await this.generateVoice(message, engine, voice, params);
this.queue.add(filepath);
}
}
/**
* If the author is in voice, speaks the response and adds a sysmsg ping;
* otherwise replies to the message.
*/
export function respond(
audio: AudioService,
sysmsgPath: string,
message: Message,
text: string,
voiceText?: string,
): void {
const displayName = message.member?.displayName ?? message.author.username;
const toSend = `${displayName}, ${voiceText ?? text}`;
const voiceChannel = message.member?.voice.channel;
if (voiceChannel) {
audio.queue.add(sysmsgPath);
void audio.speak(voiceChannel, toSend);
} else {
void message.reply(text);
}
}

View File

@@ -0,0 +1,28 @@
import type { Message } from "discord.js";
export type CommandHandler = (args: string[], message: Message) => void | Promise<void>;
export class CommandRegistry {
private handlers = new Map<string, CommandHandler>();
constructor(private readonly prefix: string) {}
register(name: string, handler: CommandHandler): void {
this.handlers.set(name, handler);
}
async handleMessage(message: Message): Promise<void> {
if (!message.content.startsWith(this.prefix)) return;
const args = message.content.split(" ");
const head = args[0];
if (!head) return;
const command = head.slice(this.prefix.length);
const handler = this.handlers.get(command);
if (!handler) return;
try {
await handler(args, message);
} catch (err) {
console.error(`Command "${command}" failed:`, err);
}
}
}

47
src/config.ts Normal file
View File

@@ -0,0 +1,47 @@
import "dotenv/config";
import { z } from "zod";
const schema = z.object({
TOKEN: z.string().min(1),
GUILD: z.string().min(1),
CHANNEL: z.string().min(1),
STRING_SET: z.string().default("en"),
VOICE_TMP_PATH: z.string().min(1),
DB_FILE: z.string().min(1),
PREFIX: z.string().min(1).default("+"),
ANNOUNCEMENT_ENGINE: z.string().min(1),
ANNOUNCEMENT_VOICE: z.string().min(1),
TTS_CHANNEL: z.string().optional(),
// Optional per-provider credentials
AZURE_API_KEY: z.string().optional(),
AZURE_REGION: z.string().optional(),
AZURE_LIST_ENDPOINT: z.string().optional(),
XI_API_KEY: z.string().optional(),
watsonURL: z.string().optional(),
watsonAPIKey: z.string().optional(),
GOOGLE_APPLICATION_CREDENTIALS: z.string().optional(),
OPENAI_API_KEY: z.string().optional(),
OPENAI_CHAT_MODEL: z.string().default("gpt-4o"),
UNREAL_API_KEY: z.string().optional(),
// Module-specific
MANGLE_LANGS: z.string().optional(),
QDB_URL: z.string().optional(),
QDB_USER: z.string().optional(),
QDB_PASS: z.string().optional(),
});
export type Config = z.infer<typeof schema>;
export const config: Config = (() => {
const parsed = schema.safeParse(process.env);
if (!parsed.success) {
console.error("Invalid configuration:");
for (const issue of parsed.error.issues) {
console.error(` ${issue.path.join(".")}: ${issue.message}`);
}
process.exit(1);
}
return parsed.data;
})();

24
src/context.ts Normal file
View File

@@ -0,0 +1,24 @@
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
import type { Client } from "discord.js";
import type { Config } from "./config.js";
import type { AudioService } from "./audio/AudioService.js";
import type { CommandRegistry } from "./commands/CommandRegistry.js";
import type { TTSRegistry } from "./tts/registry.js";
import type { AppDatabase } from "./db/db.js";
import type { TFn, Strings } from "./i18n/strings.js";
export interface BotContext {
client: Client;
config: Config;
audio: AudioService;
commands: CommandRegistry;
tts: TTSRegistry;
db: AppDatabase;
strings: Strings;
t: TFn;
/** Absolute path to the repo root (used for sysmsg.wav, sysstart.wav lookup). */
rootDir: string;
}
export const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");

8
src/db/db.ts Normal file
View File

@@ -0,0 +1,8 @@
import sqlite3 from "sqlite3";
import { open, type Database } from "sqlite";
export type AppDatabase = Database<sqlite3.Database, sqlite3.Statement>;
export async function openDatabase(filename: string): Promise<AppDatabase> {
return open({ filename, driver: sqlite3.Database });
}

15
src/db/schema.ts Normal file
View File

@@ -0,0 +1,15 @@
export interface TTSPreferencesRow {
user_id: string;
engine: string;
voice: string;
}
export interface WBWStoryRow {
story_id: number;
story_text: string;
}
export interface BotStateRow {
key: string;
value: string;
}

43
src/i18n/strings.ts Normal file
View File

@@ -0,0 +1,43 @@
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
import printf from "printf";
export type StringKey =
| "WELCOME"
| "USER_JOINED"
| "USER_LEFT"
| "SYSTEM_VOICE_CHANGED"
| "USER_VOICE_CHANGED"
| "INVALID_ENGINE"
| "INVALID_VOICE"
| "TOO_MANY_ARGUMENTS"
| "CURRENT_STORY"
| "NO_STORY"
| "WBW_REPLACED"
| "WBW_TOO_DIFFERENT"
| "WBW_NEW_WORD"
| "WBW_RESET"
| "WBW_INVALID_ID";
export type Strings = Record<StringKey, string>;
const stringsDir = resolve(dirname(fileURLToPath(import.meta.url)), "../../strings");
function loadStringSet(name: string): Partial<Strings> {
const filepath = resolve(stringsDir, `${name}.json`);
return JSON.parse(readFileSync(filepath, "utf8")) as Partial<Strings>;
}
export function loadStrings(stringSet: string): Strings {
const fallback = loadStringSet("en") as Strings;
if (stringSet === "en") return fallback;
const localized = loadStringSet(stringSet);
return { ...fallback, ...localized };
}
export function makeT(strings: Strings) {
return (key: StringKey, ...args: unknown[]): string => printf(strings[key], ...args);
}
export type TFn = ReturnType<typeof makeT>;

57
src/index.ts Normal file
View File

@@ -0,0 +1,57 @@
import { Client, Events, GatewayIntentBits } from "discord.js";
import { config } from "./config.js";
process.on("unhandledRejection", (reason) => {
console.error("Unhandled promise rejection:", reason);
});
process.on("uncaughtException", (err) => {
console.error("Uncaught exception:", err);
});
import { openDatabase } from "./db/db.js";
import { loadStrings, makeT } from "./i18n/strings.js";
import { TTSRegistry } from "./tts/registry.js";
import { AudioService } from "./audio/AudioService.js";
import { CommandRegistry } from "./commands/CommandRegistry.js";
import { registerModules } from "./modules/registry.js";
import { repoRoot, type BotContext } from "./context.js";
const client = new Client({
intents: [
GatewayIntentBits.GuildMembers,
GatewayIntentBits.GuildMessageReactions,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.GuildPresences,
GatewayIntentBits.GuildVoiceStates,
GatewayIntentBits.Guilds,
GatewayIntentBits.MessageContent,
],
});
client.on(Events.Error, (err) => console.error("Discord client error:", err));
client.on(Events.Warn, (msg) => console.warn("Discord client warning:", msg));
const db = await openDatabase(config.DB_FILE);
const tts = new TTSRegistry(config.ANNOUNCEMENT_ENGINE, config.ANNOUNCEMENT_VOICE);
const audio = new AudioService({ voiceTmpPath: config.VOICE_TMP_PATH, tts });
const commands = new CommandRegistry(config.PREFIX);
const strings = loadStrings(config.STRING_SET);
const ctx: BotContext = {
client,
config,
audio,
commands,
tts,
db,
strings,
t: makeT(strings),
rootDir: repoRoot,
};
await registerModules(ctx);
client.on(Events.MessageCreate, (message) => {
void commands.handleMessage(message);
});
await client.login(config.TOKEN);

26
src/modules/announcer.ts Normal file
View File

@@ -0,0 +1,26 @@
import { join } from "node:path";
import type { Module } from "./types.js";
export const announcer: Module = ({ client, audio, tts, t, rootDir }) => {
const sysmsg = join(rootDir, "sysmsg.wav");
client.on("voiceStateUpdate", async (oldState, newState) => {
if (newState.member?.user.bot) return;
if (oldState.channel && newState.channel) return;
const channel = oldState.channel ?? newState.channel;
if (!channel) return;
if (channel.members.size < 2) {
audio.queue.flush();
await audio.leaveChannel(channel);
}
await audio.joinChannel(channel);
const joined = !oldState.channel;
const username = newState.member?.displayName ?? oldState.member?.displayName ?? "someone";
const str = joined ? t("USER_JOINED", username) : t("USER_LEFT", username);
const filepath = await audio.generateVoice(str, tts.announcement, tts.announcementVoice);
audio.queue.add(sysmsg);
audio.queue.add(filepath);
});
};

78
src/modules/canttalk.ts Normal file
View File

@@ -0,0 +1,78 @@
import { readdirSync } from "node:fs";
import { join } from "node:path";
import { respond } from "../audio/AudioService.js";
import type { TTSPreferencesRow } from "../db/schema.js";
import type { Module } from "./types.js";
export const canttalk: Module = ({ client, audio, commands, tts, db, t, config, rootDir }) => {
const sysmsg = join(rootDir, "sysmsg.wav");
client.on("messageCreate", async (message) => {
if (message.author.bot) return;
if (message.content.startsWith(config.PREFIX)) return;
if (!config.TTS_CHANNEL || message.channel.id !== config.TTS_CHANNEL) return;
const voiceChannel = message.member?.voice.channel;
if (!voiceChannel) return;
let userRow = await db.get<TTSPreferencesRow>(
"select * from TTSPreferences where user_id=?",
message.author.id,
);
if (!userRow) {
await db.run("insert into TTSPreferences (user_id,engine,voice) values (?,?,?)", [
message.author.id,
tts.announcement.shortName,
tts.announcementVoice,
]);
userRow = await db.get<TTSPreferencesRow>(
"select * from TTSPreferences where user_id=?",
message.author.id,
);
}
if (!userRow) return;
const engine = tts.get(userRow.engine);
if (engine) {
await audio.speak(voiceChannel, message.content, engine, userRow.voice);
}
});
commands.register("myvoice", async (args, message) => {
if (args.length > 3) {
return respond(audio, sysmsg, message, t("TOO_MANY_ARGUMENTS"));
}
const engineName = args[1];
const voiceArg = args[2];
if (!engineName || !voiceArg) {
return respond(audio, sysmsg, message, t("TOO_MANY_ARGUMENTS"));
}
const engine = tts.get(engineName);
if (!engine) {
return respond(audio, sysmsg, message, t("INVALID_ENGINE", engineName));
}
const userVoice = voiceArg.toLowerCase();
let chosenVoice: string;
if (engine.validateVoice(userVoice)) {
chosenVoice = userVoice;
respond(audio, sysmsg, message, t("USER_VOICE_CHANGED", chosenVoice, engine.longName));
} else {
chosenVoice = engine.getDefaultVoice();
respond(audio, sysmsg, message, t("INVALID_VOICE", chosenVoice, engine.longName));
}
await db.run("update TTSPreferences set engine=?, voice=? where user_id=?", [
engineName,
chosenVoice,
message.author.id,
]);
});
commands.register("random", async (_args, _message) => {
const tmpPath = config.VOICE_TMP_PATH;
const files = readdirSync(tmpPath);
if (files.length === 0) return;
const rnd = files[Math.floor(Math.random() * files.length)];
if (!rnd) return;
audio.queue.add(sysmsg);
audio.queue.add(join(tmpPath, rnd));
});
};

25
src/modules/chatgpt.ts Normal file
View File

@@ -0,0 +1,25 @@
import { join } from "node:path";
import OpenAI from "openai";
import { respond } from "../audio/AudioService.js";
import type { Module } from "./types.js";
export const chatgptModule: Module = ({ audio, commands, config, rootDir }) => {
if (!config.OPENAI_API_KEY) {
console.warn("chatgpt module: OPENAI_API_KEY not set — `chat` command disabled");
return;
}
const client = new OpenAI({ apiKey: config.OPENAI_API_KEY });
const sysmsg = join(rootDir, "sysmsg.wav");
const model = config.OPENAI_CHAT_MODEL;
commands.register("chat", async (_args, message) => {
const prompt = message.content.slice(config.PREFIX.length + "chat".length).trim();
if (!prompt) return;
const completion = await client.chat.completions.create({
model,
messages: [{ role: "user", content: prompt }],
});
const text = completion.choices[0]?.message.content?.trim() ?? "";
if (text) respond(audio, sysmsg, message, text);
});
};

42
src/modules/mangle.ts Normal file
View File

@@ -0,0 +1,42 @@
import { join } from "node:path";
import { respond } from "../audio/AudioService.js";
import type { Module } from "./types.js";
/**
* Calls Google Translate's unofficial endpoint. No SDK — the maintained
* `node-google-translate-skidz` wrapper was abandoned, so we hit the same
* single_translate endpoint directly with a fetch.
*/
async function translate(text: string, fromLang: string, toLang: string): Promise<string> {
const url = new URL("https://translate.googleapis.com/translate_a/single");
url.searchParams.set("client", "gtx");
url.searchParams.set("sl", fromLang);
url.searchParams.set("tl", toLang);
url.searchParams.set("dt", "t");
url.searchParams.set("q", text);
const res = await fetch(url.toString());
if (!res.ok) throw new Error(`translate ${fromLang}->${toLang} failed: ${res.status}`);
const json = (await res.json()) as unknown[];
const segments = json[0];
if (!Array.isArray(segments)) throw new Error("translate: unexpected response shape");
return segments
.map((seg) => (Array.isArray(seg) && typeof seg[0] === "string" ? seg[0] : ""))
.join("");
}
export const mangle: Module = ({ audio, commands, config, rootDir }) => {
const sysmsg = join(rootDir, "sysmsg.wav");
const langs = (config.MANGLE_LANGS ?? "en,de,ja,ru,en").split(",").map((l) => l.trim());
commands.register("mangle", async (_args, message) => {
let str = message.content.slice(config.PREFIX.length + "mangle".length).trim();
if (!str) return;
for (let i = 0; i < langs.length - 1; i++) {
const from = langs[i];
const to = langs[i + 1];
if (!from || !to) break;
str = await translate(str, from, to);
}
respond(audio, sysmsg, message, str);
});
};

38
src/modules/quotedb.ts Normal file
View File

@@ -0,0 +1,38 @@
import { join } from "node:path";
import { respond } from "../audio/AudioService.js";
import type { Module } from "./types.js";
interface Quote {
author: string;
medium: string;
quote: string;
}
export const quotedb: Module = ({ audio, commands, config, rootDir }) => {
const sysmsg = join(rootDir, "sysmsg.wav");
commands.register("randomquote", async (_args, message) => {
if (!config.QDB_URL || !config.QDB_USER || !config.QDB_PASS) {
console.warn("quotedb: QDB_URL/QDB_USER/QDB_PASS not configured");
return;
}
try {
const auth = Buffer.from(`${config.QDB_USER}:${config.QDB_PASS}`).toString("base64");
const res = await fetch(config.QDB_URL, {
headers: { Authorization: `Basic ${auth}` },
});
const quotes = (await res.json()) as Quote[];
if (quotes.length === 0) return;
const quote = quotes[Math.floor(Math.random() * quotes.length)];
if (!quote) return;
respond(
audio,
sysmsg,
message,
`Here's your quote: ${quote.author}, on ${quote.medium}: ${quote.quote}`,
);
} catch (err) {
console.error("randomquote failed:", err);
}
});
};

30
src/modules/registry.ts Normal file
View File

@@ -0,0 +1,30 @@
import type { BotContext } from "../context.js";
import { announcer } from "./announcer.js";
import { canttalk } from "./canttalk.js";
import { chatgptModule } from "./chatgpt.js";
import { mangle } from "./mangle.js";
import { quotedb } from "./quotedb.js";
import { summon } from "./summon.js";
import { ttsSettings } from "./ttsSettings.js";
import { welcomer } from "./welcomer.js";
import { wordbyword } from "./wordbyword.js";
import type { Module } from "./types.js";
const modules: Record<string, Module> = {
announcer,
canttalk,
chatgpt: chatgptModule,
mangle,
quotedb,
summon,
ttsSettings,
welcomer,
wordbyword,
};
export async function registerModules(ctx: BotContext): Promise<void> {
for (const [name, mod] of Object.entries(modules)) {
console.log(`Loading module: ${name}`);
await mod(ctx);
}
}

14
src/modules/summon.ts Normal file
View File

@@ -0,0 +1,14 @@
import { join } from "node:path";
import { respond } from "../audio/AudioService.js";
import type { Module } from "./types.js";
export const summon: Module = ({ audio, commands, rootDir }) => {
const sysmsg = join(rootDir, "sysmsg.wav");
commands.register("summon", async (_args, message) => {
const channel = message.member?.voice.channel;
if (!channel) return;
await audio.joinChannel(channel);
respond(audio, sysmsg, message, "Hi!");
});
};

View File

@@ -0,0 +1,40 @@
import { join } from "node:path";
import { respond } from "../audio/AudioService.js";
import type { Module } from "./types.js";
export const ttsSettings: Module = ({ audio, commands, tts, t, rootDir }) => {
const sysmsg = join(rootDir, "sysmsg.wav");
commands.register("announcevoice", (args, message) => {
if (args.length > 3) {
respond(audio, sysmsg, message, t("TOO_MANY_ARGUMENTS"));
return;
}
const engineName = args[1];
const voiceArg = args[2];
if (!engineName || !voiceArg) {
respond(audio, sysmsg, message, t("TOO_MANY_ARGUMENTS"));
return;
}
const engine = tts.get(engineName);
if (!engine) {
respond(audio, sysmsg, message, t("INVALID_ENGINE", engineName));
return;
}
tts.announcement = engine;
if (engine.validateVoice(voiceArg)) {
tts.announcementVoice = voiceArg;
respond(audio, sysmsg, message, t("SYSTEM_VOICE_CHANGED", voiceArg, engine.longName));
} else {
tts.announcementVoice = engine.getDefaultVoice();
respond(
audio,
sysmsg,
message,
t("INVALID_VOICE", tts.announcementVoice, engine.longName),
);
}
});
commands.register("flush", () => audio.queue.flush());
};

3
src/modules/types.ts Normal file
View File

@@ -0,0 +1,3 @@
import type { BotContext } from "../context.js";
export type Module = (ctx: BotContext) => void | Promise<void>;

21
src/modules/welcomer.ts Normal file
View File

@@ -0,0 +1,21 @@
import { join } from "node:path";
import { Events, type VoiceBasedChannel } from "discord.js";
import type { Module } from "./types.js";
export const welcomer: Module = ({ client, audio, config, strings, rootDir }) => {
const sysstart = join(rootDir, "sysstart.wav");
client.once(Events.ClientReady, async () => {
console.log("Bot initialized and listening");
await client.guilds.fetch(config.GUILD);
const channel = await client.channels.fetch(config.CHANNEL);
if (!channel?.isVoiceBased()) {
console.warn(`Channel ${config.CHANNEL} is not a voice channel; skipping welcome`);
return;
}
const voiceChannel = channel as VoiceBasedChannel;
await audio.joinChannel(voiceChannel);
audio.queue.add(sysstart);
await audio.speak(voiceChannel, strings.WELCOME);
});
};

75
src/modules/wordbyword.ts Normal file
View File

@@ -0,0 +1,75 @@
import { join } from "node:path";
import levenshtein from "fast-levenshtein";
import { respond } from "../audio/AudioService.js";
const isStringInt = (s: string): boolean => /^-?\d+$/.test(s);
import type { BotStateRow, WBWStoryRow } from "../db/schema.js";
import type { Module } from "./types.js";
export const wordbyword: Module = ({ audio, commands, db, t, rootDir }) => {
const sysmsg = join(rootDir, "sysmsg.wav");
let currentWBW = "";
commands.register("wbw", async (args, message) => {
if (args.length === 1) {
return respond(
audio,
sysmsg,
message,
currentWBW ? t("CURRENT_STORY", currentWBW) : t("NO_STORY"),
);
}
if (args.length > 2) {
return respond(audio, sysmsg, message, t("TOO_MANY_ARGUMENTS"));
}
const arg1 = args[1];
if (arg1 === undefined) return;
if (isStringInt(arg1)) {
const story = await db.get<WBWStoryRow>(
"select * from WBWStories where story_id=?",
parseInt(arg1, 10),
);
if (!story) return respond(audio, sysmsg, message, t("WBW_INVALID_ID"));
return respond(audio, sysmsg, message, story.story_text);
}
const lastUser = await db.get<BotStateRow>(
"select value from BotState where key='last_wbw'",
);
if (lastUser && message.author.id === lastUser.value && currentWBW !== "") {
const lastWord =
currentWBW.indexOf(" ") === currentWBW.lastIndexOf(" ")
? currentWBW.trim()
: currentWBW.slice(currentWBW.slice(0, -1).lastIndexOf(" ") + 1).trim();
if (levenshtein.get(arg1, lastWord) <= 3) {
currentWBW = currentWBW.replace(
new RegExp(`${escapeRegExp(lastWord)}([^${escapeRegExp(lastWord)}]*)$`),
`${arg1}$1 `,
);
respond(audio, sysmsg, message, t("WBW_REPLACED", lastWord, arg1));
} else {
return respond(audio, sysmsg, message, t("WBW_TOO_DIFFERENT"));
}
} else {
currentWBW += `${arg1} `;
respond(audio, sysmsg, message, t("WBW_NEW_WORD"));
const toSay =
currentWBW.indexOf(".") === -1 ? currentWBW : currentWBW.slice(currentWBW.lastIndexOf(".") + 2);
const voiceChannel = message.member?.voice.channel;
if (voiceChannel) await audio.speak(voiceChannel, toSay);
await db.run("update BotState set value=? where key='last_wbw'", [message.author.id]);
}
});
commands.register("newwbw", async (_args, message) => {
await db.run("insert into WBWStories (story_text) values(?)", [currentWBW]);
currentWBW = "";
respond(audio, sysmsg, message, t("WBW_RESET"));
});
};
function escapeRegExp(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

56
src/tts/BaseEngine.ts Normal file
View File

@@ -0,0 +1,56 @@
import { writeFile } from "node:fs/promises";
export type VoiceParams = Record<string, unknown>;
/**
* Common contract for every TTS provider. Subclasses override either
* `getSpeech` (returning a fetch-like Response) or `getSpeechFile` (writing
* directly to disk) — the default `getSpeechFile` pipes `getSpeech` to a file.
*/
export abstract class BaseEngine {
/** Short ID used in env / commands (e.g. "azure"). */
readonly shortName: string;
/** Human-readable name shown in messages. */
readonly longName: string;
/** Output file extension without the dot (e.g. "mp3"). */
readonly fileExtension: string;
protected voices: Record<string, string | { name: string; lang: string }> = {};
constructor(shortName: string, longName: string, fileExtension: string) {
this.shortName = shortName;
this.longName = longName;
this.fileExtension = fileExtension;
}
/** Maps a user-friendly voice name to the provider's internal identifier. */
getInternalVoiceName(str: string): string {
const v = this.voices[str];
if (v == null) return str;
return typeof v === "string" ? v : v.name;
}
abstract getDefaultVoice(): string;
validateVoice(voice: string): boolean {
if (Object.keys(this.voices).length === 0) return true;
return this.voices[voice] != null;
}
/** Default implementation: subclass should override either this or getSpeechFile. */
async getSpeech(_text: string, _voice?: string, _params?: VoiceParams): Promise<Response> {
throw new Error(`${this.shortName}: getSpeech not implemented`);
}
async getSpeechFile(
text: string,
filepath: string,
voice: string = this.getDefaultVoice(),
params: VoiceParams = {},
): Promise<string> {
const data = await this.getSpeech(text, voice, params);
const buf = Buffer.from(await data.arrayBuffer());
await writeFile(filepath, buf);
return filepath;
}
}

77
src/tts/azure.ts Normal file
View File

@@ -0,0 +1,77 @@
import * as sdk from "microsoft-cognitiveservices-speech-sdk";
import { config } from "../config.js";
import { BaseEngine, type VoiceParams } from "./BaseEngine.js";
interface AzureVoiceMeta {
DisplayName: string;
ShortName: string;
Name: string;
}
export class AzureEngine extends BaseEngine {
protected override voices: Record<string, string> = {};
constructor() {
super("azure", "Microsoft Azure TTS", "wav");
void this.populateVoiceList();
}
override getDefaultVoice(): string {
return "aria";
}
override getSpeechFile(
text: string,
filepath: string,
voice: string = this.getDefaultVoice(),
_params: VoiceParams = {},
): Promise<string> {
return new Promise((resolve, reject) => {
if (!config.AZURE_API_KEY || !config.AZURE_REGION) {
reject(new Error("AZURE_API_KEY and AZURE_REGION must be set"));
return;
}
const speechConfig = sdk.SpeechConfig.fromSubscription(
config.AZURE_API_KEY,
config.AZURE_REGION,
);
speechConfig.speechSynthesisOutputFormat = sdk.SpeechSynthesisOutputFormat.Riff24Khz16BitMonoPcm;
const internal = this.voices[voice] ?? this.voices[this.getDefaultVoice()];
if (internal) speechConfig.speechSynthesisVoiceName = internal;
const audioConfig = sdk.AudioConfig.fromAudioFileOutput(filepath);
const synthesizer = new sdk.SpeechSynthesizer(speechConfig, audioConfig);
synthesizer.speakTextAsync(
text,
(result) => {
synthesizer.close();
if (result) resolve(filepath);
else reject(new Error("Azure TTS returned no result"));
},
(error: unknown) => {
synthesizer.close();
reject(error instanceof Error ? error : new Error(String(error)));
},
);
});
}
private async populateVoiceList(): Promise<void> {
if (!config.AZURE_LIST_ENDPOINT || !config.AZURE_API_KEY) return;
try {
const res = await fetch(config.AZURE_LIST_ENDPOINT, {
headers: { "Ocp-Apim-Subscription-Key": config.AZURE_API_KEY },
});
const json = (await res.json()) as AzureVoiceMeta[];
for (const voice of json) {
const key = voice.DisplayName.toLowerCase();
if (this.voices[key]) {
if (voice.Name.includes("Neural")) this.voices[key] = voice.ShortName;
} else {
this.voices[key] = voice.ShortName;
}
}
} catch (err) {
console.error("Azure: failed to populate voice list:", err);
}
}
}

63
src/tts/eleven.ts Normal file
View File

@@ -0,0 +1,63 @@
import { config } from "../config.js";
import { BaseEngine, type VoiceParams } from "./BaseEngine.js";
interface ElevenVoice {
name: string;
voice_id: string;
}
interface ElevenVoicesResponse {
voices: ElevenVoice[];
}
export class ElevenEngine extends BaseEngine {
protected override voices: Record<string, string> = {};
constructor() {
super("eleven", "Eleven Labs TTS", "mp3");
void this.populateVoiceList();
}
override getDefaultVoice(): string {
return "guillem";
}
private async populateVoiceList(): Promise<void> {
if (!config.XI_API_KEY) return;
try {
const res = await fetch("https://api.elevenlabs.io/v1/voices", {
method: "GET",
headers: { "xi-api-key": config.XI_API_KEY },
});
const json = (await res.json()) as ElevenVoicesResponse;
for (const v of json.voices) {
this.voices[v.name.toLowerCase()] = v.voice_id;
}
} catch (err) {
console.error("Eleven: failed to populate voice list:", err);
}
}
override async getSpeech(
text: string,
voice: string = this.getDefaultVoice(),
_params: VoiceParams = {},
): Promise<Response> {
if (!config.XI_API_KEY) {
throw new Error("XI_API_KEY must be set");
}
const voiceId = this.getInternalVoiceName(voice);
const url = `https://api.elevenlabs.io/v1/text-to-speech/${voiceId}`;
return fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"xi-api-key": config.XI_API_KEY,
},
body: JSON.stringify({
model_id: "eleven_multilingual_v2",
text,
}),
});
}
}

33
src/tts/espeak.ts Normal file
View File

@@ -0,0 +1,33 @@
import { spawn } from "node:child_process";
import { BaseEngine, type VoiceParams } from "./BaseEngine.js";
export class EspeakEngine extends BaseEngine {
constructor() {
super("espeak", "ESpeak", "wav");
}
override getDefaultVoice(): string {
return "en";
}
override validateVoice(_voice: string): boolean {
return true;
}
override async getSpeechFile(
text: string,
filepath: string,
voice: string = this.getDefaultVoice(),
_params: VoiceParams = {},
): Promise<string> {
return new Promise((resolve, reject) => {
const proc = spawn("espeak", ["-v", voice, "-w", filepath, "--stdin"]);
proc.on("error", reject);
proc.on("close", (code) => {
if (code === 0) resolve(filepath);
else reject(new Error(`espeak exited with code ${code}`));
});
proc.stdin.end(text);
});
}
}

78
src/tts/google.ts Normal file
View File

@@ -0,0 +1,78 @@
import { existsSync } from "node:fs";
import { writeFile } from "node:fs/promises";
import textToSpeech from "@google-cloud/text-to-speech";
import type { TextToSpeechClient } from "@google-cloud/text-to-speech";
import { config } from "../config.js";
import { BaseEngine, type VoiceParams } from "./BaseEngine.js";
interface GoogleVoiceMeta {
name: string;
lang: string;
}
export class GoogleEngine extends BaseEngine {
private client: TextToSpeechClient | undefined;
protected override voices: Record<string, GoogleVoiceMeta> = {};
constructor() {
super("google", "Google Cloud TTS", "wav");
void this.populateVoiceList();
}
override getDefaultVoice(): string {
return "en-us-wavenet-a";
}
private credentialsAvailable(): boolean {
return (
!!config.GOOGLE_APPLICATION_CREDENTIALS &&
existsSync(config.GOOGLE_APPLICATION_CREDENTIALS)
);
}
private getClient(): TextToSpeechClient {
if (!this.credentialsAvailable()) {
throw new Error(
"Google Cloud TTS unavailable: GOOGLE_APPLICATION_CREDENTIALS must point to a readable file",
);
}
this.client ??= new textToSpeech.TextToSpeechClient();
return this.client;
}
private async populateVoiceList(): Promise<void> {
if (!this.credentialsAvailable()) return;
try {
const [result] = await this.getClient().listVoices({});
for (const voice of result.voices ?? []) {
if (!voice.name || !voice.languageCodes?.[0]) continue;
this.voices[voice.name.toLowerCase()] = {
name: voice.name,
lang: voice.languageCodes[0],
};
}
} catch (err) {
console.error("Google Cloud TTS: failed to populate voice list:", err);
}
}
override async getSpeechFile(
text: string,
filepath: string,
voice: string = this.getDefaultVoice(),
_params: VoiceParams = {},
): Promise<string> {
const meta = this.voices[voice];
if (!meta) throw new Error(`Google: unknown voice "${voice}"`);
const [response] = await this.getClient().synthesizeSpeech({
input: { text },
voice: { name: meta.name, languageCode: meta.lang },
audioConfig: { audioEncoding: "LINEAR16" },
});
if (!response.audioContent) {
throw new Error("Google: synthesizeSpeech returned no audioContent");
}
await writeFile(filepath, response.audioContent);
return filepath;
}
}

25
src/tts/gtranslate.ts Normal file
View File

@@ -0,0 +1,25 @@
import { getAudioUrl } from "@sefinek/google-tts-api";
import { BaseEngine, type VoiceParams } from "./BaseEngine.js";
export class GtranslateEngine extends BaseEngine {
constructor() {
super("gtranslate", "Google Translate TTS", "mp3");
}
override getDefaultVoice(): string {
return "en-us";
}
override validateVoice(_voice: string): boolean {
return true;
}
override async getSpeech(
text: string,
voice: string = this.getDefaultVoice(),
_params: VoiceParams = {},
): Promise<Response> {
const url = getAudioUrl(text, { lang: voice });
return fetch(url);
}
}

40
src/tts/openai.ts Normal file
View File

@@ -0,0 +1,40 @@
import { config } from "../config.js";
import { BaseEngine, type VoiceParams } from "./BaseEngine.js";
const OPENAI_VOICES = ["alloy", "echo", "fable", "onyx", "nova", "shimmer"] as const;
export class OpenAIEngine extends BaseEngine {
protected override voices: Record<string, string> = Object.fromEntries(
OPENAI_VOICES.map((v) => [v, v]),
);
constructor() {
super("openai", "OpenAI TTS", "mp3");
}
override getDefaultVoice(): string {
return "alloy";
}
override async getSpeech(
text: string,
voice: string = this.getDefaultVoice(),
_params: VoiceParams = {},
): Promise<Response> {
if (!config.OPENAI_API_KEY) {
throw new Error("OPENAI_API_KEY must be set");
}
return fetch("https://api.openai.com/v1/audio/speech", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${config.OPENAI_API_KEY}`,
},
body: JSON.stringify({
model: "tts-1-hd",
input: text,
voice,
}),
});
}
}

69
src/tts/registry.ts Normal file
View File

@@ -0,0 +1,69 @@
import type { BaseEngine } from "./BaseEngine.js";
import { AzureEngine } from "./azure.js";
import { ElevenEngine } from "./eleven.js";
import { EspeakEngine } from "./espeak.js";
import { GoogleEngine } from "./google.js";
import { GtranslateEngine } from "./gtranslate.js";
import { OpenAIEngine } from "./openai.js";
import { SamEngine } from "./sam.js";
import { UnrealEngine } from "./unreal.js";
import { WatsonEngine } from "./watson.js";
export class TTSRegistry {
private engines: Record<string, BaseEngine>;
private _announcement: BaseEngine;
private _announcementVoice: string;
constructor(initialAnnouncementEngineName: string, initialAnnouncementVoice: string) {
this.engines = {
azure: new AzureEngine(),
eleven: new ElevenEngine(),
espeak: new EspeakEngine(),
google: new GoogleEngine(),
gtranslate: new GtranslateEngine(),
openai: new OpenAIEngine(),
sam: new SamEngine(),
unreal: new UnrealEngine(),
watson: new WatsonEngine(),
};
for (const name of Object.keys(this.engines)) {
console.log(`Loaded TTS engine: ${name}`);
}
const ann = this.engines[initialAnnouncementEngineName];
if (!ann) {
throw new Error(
`ANNOUNCEMENT_ENGINE "${initialAnnouncementEngineName}" is not a registered TTS engine`,
);
}
this._announcement = ann;
this._announcementVoice = initialAnnouncementVoice;
}
get(name: string): BaseEngine | undefined {
return this.engines[name];
}
has(name: string): boolean {
return name in this.engines;
}
list(): string[] {
return Object.keys(this.engines);
}
get announcement(): BaseEngine {
return this._announcement;
}
set announcement(engine: BaseEngine) {
this._announcement = engine;
}
get announcementVoice(): string {
return this._announcementVoice;
}
set announcementVoice(voice: string) {
this._announcementVoice = voice;
}
}

41
src/tts/sam.ts Normal file
View File

@@ -0,0 +1,41 @@
import { writeFile } from "node:fs/promises";
import Sam from "sam-js";
import pkg from "wavefile";
import { BaseEngine, type VoiceParams } from "./BaseEngine.js";
const { WaveFile } = pkg;
export class SamEngine extends BaseEngine {
constructor() {
super("sam", "Software Automatic Mouth", "wav");
}
override getDefaultVoice(): string {
return "sam";
}
override validateVoice(_voice: string): boolean {
return true;
}
override async getSpeechFile(
text: string,
filepath: string,
_voice: string = this.getDefaultVoice(),
_params: VoiceParams = {},
): Promise<string> {
const sam = new Sam();
let phonetic = false;
let input = text;
if (input.startsWith("$")) {
input = input.slice(1);
phonetic = true;
}
const buf = sam.buf8(input, phonetic);
if (!(buf instanceof Uint8Array)) throw new Error("SAM produced no audio");
const wave = new WaveFile();
wave.fromScratch(1, 22050, "8", buf);
await writeFile(filepath, wave.toBuffer());
return filepath;
}
}

52
src/tts/unreal.ts Normal file
View File

@@ -0,0 +1,52 @@
import { writeFile } from "node:fs/promises";
import { config } from "../config.js";
import { BaseEngine, type VoiceParams } from "./BaseEngine.js";
interface UnrealResponse {
OutputUri: string;
}
export class UnrealEngine extends BaseEngine {
protected override voices: Record<string, string> = {
scarlett: "Scarlett",
liv: "Liv",
dan: "Dan",
will: "Will",
amy: "Amy",
};
constructor() {
super("unreal", "Unreal Speech TTS", "mp3");
}
override getDefaultVoice(): string {
return "liv";
}
override async getSpeechFile(
text: string,
filepath: string,
voice: string = this.getDefaultVoice(),
_params: VoiceParams = {},
): Promise<string> {
if (!config.UNREAL_API_KEY) throw new Error("UNREAL_API_KEY must be set");
const res = await fetch("https://api.v6.unrealspeech.com/speech", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${config.UNREAL_API_KEY}`,
},
body: JSON.stringify({
Bitrate: "320k",
Temperature: 0.1,
VoiceId: this.getInternalVoiceName(voice),
Text: text,
AudioFormat: "mp3",
}),
});
const json = (await res.json()) as UnrealResponse;
const audio = await fetch(json.OutputUri);
await writeFile(filepath, Buffer.from(await audio.arrayBuffer()));
return filepath;
}
}

64
src/tts/watson.ts Normal file
View File

@@ -0,0 +1,64 @@
import { config } from "../config.js";
import { BaseEngine, type VoiceParams } from "./BaseEngine.js";
interface WatsonVoice {
name: string;
description: string;
}
interface WatsonVoicesResponse {
voices: WatsonVoice[];
}
export class WatsonEngine extends BaseEngine {
protected override voices: Record<string, string> = {};
constructor() {
super("watson", "IBM Watson TTS", "ogg");
void this.populateVoiceList();
}
override getDefaultVoice(): string {
return "michael";
}
private authString(): string {
if (!config.watsonAPIKey) throw new Error("watsonAPIKey must be set");
const b64 = Buffer.from(`apikey:${config.watsonAPIKey}`).toString("base64");
return `Basic ${b64}`;
}
private async populateVoiceList(): Promise<void> {
if (!config.watsonURL || !config.watsonAPIKey) return;
try {
const res = await fetch(`${config.watsonURL}/v1/voices`, {
method: "GET",
headers: { Authorization: this.authString() },
});
const json = (await res.json()) as WatsonVoicesResponse;
for (const v of json.voices) {
const key = v.description.substring(0, v.description.indexOf(":")).toLowerCase();
this.voices[key] = v.name;
}
} catch (err) {
console.error("Watson: failed to populate voice list:", err);
}
}
override async getSpeech(
text: string,
voice: string = this.getDefaultVoice(),
_params: VoiceParams = {},
): Promise<Response> {
if (!config.watsonURL) throw new Error("watsonURL must be set");
const url = `${config.watsonURL}/v1/synthesize?voice=${this.getInternalVoiceName(voice)}`;
return fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: this.authString(),
},
body: JSON.stringify({ text }),
});
}
}

9
t.js
View File

@@ -1,9 +0,0 @@
const t=require('node-google-translate-skidz')
t({
text: 'this is a test of the meow',
'source': 'en',
target: 'ta'
}, (res) => {
console.log(res);
});

21
tsconfig.json Normal file
View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2023",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2023"],
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"noFallthroughCasesInSwitch": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"verbatimModuleSyntax": true,
"skipLibCheck": true,
"allowSyntheticDefaultImports": true,
"noEmit": true,
"isolatedModules": true
},
"include": ["src/**/*.ts", "src/**/*.json"]
}

BIN
tts/.DS_Store vendored

Binary file not shown.

View File

@@ -1,22 +0,0 @@
const fs = require('fs');
module.exports = class {
constructor(shortName, longName, fileExtension, supportedParameters = []) {
this.shortName = shortName;
this.longName = longName;
this.fileExtension = fileExtension;
}
getInternalVoiceName(str) {
return this.voices ? this.voices[str] : str;
}
getDefaultVoice() { }
validateVoice(voice) { return this.voices ? this.voices[voice] : true; }
async getSpeech(text, voice = this.getDefaultVoice(), params) { }
async getSpeechFile(text, filepath, voice, params) {
const data = await this.getSpeech(text, voice, params);
const contents = await data.arrayBuffer();
const buf = Buffer.from(contents);
fs.writeFileSync(filepath, buf);
return filepath;
}
}

View File

@@ -1,57 +0,0 @@
const BaseEngine = require('../BaseEngine');
const sdk = require("microsoft-cognitiveservices-speech-sdk");
const fetch = require('node-fetch');
module.exports = class AzureTTS extends BaseEngine {
constructor() {
super("azure", "Microsoft Azure TTS", "wav");
this.voices = {};
this.populateVoiceList();
}
getDefaultVoice() {
return "Aria";
}
getSpeechFile(text, filepath, voice = this.getDefaultVoice(), params = {}) {
return new Promise((resolve, reject) => {
const speechConfig = sdk.SpeechConfig.fromSubscription(process.env.AZURE_API_KEY, process.env.AZURE_REGION);
speechConfig.speechSynthesisOutputFormat = sdk.SpeechSynthesisOutputFormat.Riff24Khz16BitMonoPcm;
speechConfig.speechSynthesisVoiceName = this.voices[voice];
const audioConfig = sdk.AudioConfig.fromAudioFileOutput(filepath);
const synthesizer = new sdk.SpeechSynthesizer(speechConfig, audioConfig);
synthesizer.speakTextAsync(text,
result => {
synthesizer.close();
if (result) {
// return result as stream
resolve(filepath);
}
},
error => {
console.log(error);
synthesizer.close();
reject(error);
});
})
}
async populateVoiceList() {
const opts = {
headers: {
'Ocp-Apim-Subscription-Key': process.env.AZURE_API_KEY
}
}
const res = await fetch(process.env.AZURE_LIST_ENDPOINT, opts);
const json = await res.json();
json.forEach((voice) => {
if (this.voices[voice.DisplayName.toLowerCase()]) {
if (voice.Name.includes('Neural')) {
this.voices[voice.DisplayName.toLowerCase()] = voice.ShortName;
}
} else {
this.voices[voice.DisplayName.toLowerCase()] = voice.ShortName;
}
});
}
}

View File

@@ -1,47 +0,0 @@
const BaseEngine = require('../BaseEngine');
const fetch = require('node-fetch');
const querystring = require('querystring');
module.exports = class extends BaseEngine {
constructor() {
super('eleven',"Eleven Labs TTS", "mp3");
this.voices = {};
this.populateVoiceList();
}
async populateVoiceList() {
const url = "https://api.elevenlabs.io/v1/voices";
const authorization = process.env.XI_API_KEY;
const opts = {
method: "get",
headers: {
'xi-api-key': authorization
},
}
const res = await fetch(url, opts);
const voices = await res.json();
voices.voices.forEach((i) => {
let voiceName = i.name.toLowerCase();
this.voices[voiceName] = i.voice_id;
});
}
getDefaultVoice() {
return 'Guillem';
}
async getSpeech(text, voice = this.getSpeechVoice(), params = {}) {
const url = "https://api.elevenlabs.io/v1/text-to-speech/" + this.getInternalVoiceName(voice);
const authorization = process.env.XI_API_KEY;
const opts = {
method: "post",
headers: {
'Content-Type': 'application/json',
'voice_id': this.getInternalVoiceName(voice),
'xi-api-key': authorization
},
body: JSON.stringify({
model_id: 'eleven_multilingual_v2',
text: text
})
};
return fetch(url, opts);
}
};

View File

@@ -1,15 +0,0 @@
const BaseEngine = require('../BaseEngine')
const { spawn } = require('child_process')
module.exports = class extends BaseEngine {
constructor() {
super('espeak','ESpeak', 'wav')
}
getDefaultVoice() {
return 'en';
}
async getSpeechFile(text, filepath, voice = this.getDefaultVoice(), params = {}) {
let proc = await spawn('espeak', ['-v', voice, '-w', filepath, '--stdin']);
proc.stdin.end(text);
}
}

View File

@@ -1,36 +0,0 @@
const BaseEngine = require('../BaseEngine')
const sdk = require('@google-cloud/text-to-speech');
const fs = require('fs');
const util = require('util');
module.exports = class GoogleCloudTTS extends BaseEngine {
constructor() {
super('google', 'Google Cloud TTS', 'wav');
this.client = new sdk.TextToSpeechClient();
this.client.initialize();
this.voices = {};
this.populateVoiceList();
}
async populateVoiceList() {
const [result] = await this.client.listVoices({});
const voiceList = result.voices;
voiceList.forEach((voice) => {
this.voices[voice.name.toLowerCase()] = { name: voice.name, lang: voice.languageCodes[0] };
});
}
getDefaultVoice() {
return 'en-US-Wavenet-A';
}
async getSpeechFile(text, filepath, voice = this.getDefaultVoice(), params = {}) {
const request = {
input: { text: text },
voice: { name: this.voices[voice].name, languageCode: this.voices[voice].lang },
audioConfig: { audioEncoding: 'LINEAR16' },
};
let [response] = await this.client.synthesizeSpeech(request);
const writeFile = util.promisify(fs.writeFile);
await writeFile(filepath, response.audioContent, 'binary');
return filepath;
}
}

View File

@@ -1,16 +0,0 @@
const BaseEngine = require('../BaseEngine');
const fetch = require('node-fetch');
const tts = require('google-tts-api');
module.exports = class extends BaseEngine {
constructor() {
super('gtranslate', "Google Translate TTS", "mp3");
}
getDefaultVoice() {
return 'en-us';
}
async getSpeech(text, voice = this.getDefaultVoice(), params = {}) {
const url = tts.getAudioUrl(text, { lang: voice });
return fetch(url);
}
};

View File

@@ -1,37 +0,0 @@
const BaseEngine = require('../BaseEngine');
const fetch = require('node-fetch');
const tts = require('google-tts-api');
module.exports = class extends BaseEngine {
constructor() {
super('openai', "OpenAI TTS", "mp3");
this.voices = {
alloy: "alloy",
echo: "echo",
fable: "fable",
onyx: "onyx",
nova: "nova",
shimmer: "shimmer"
}
}
getDefaultVoice() {
return 'Alloy';
}
async getSpeech(text, voice = this.getDefaultVoice(), params = {}) {
const url = `https://api.openai.com/v1/audio/speech`;
const opts = {
method: "post",
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${process.env["OPENAI_API_KEY"]}`
},
body: JSON.stringify({
model: "tts-1-hd",
input: text,
voice: voice,
})
}
console.log(opts);
return fetch(url, opts);
}
};

View File

@@ -1,25 +0,0 @@
const BaseEngine = require('../BaseEngine')
const Sam = require('sam-js');
const wavefile = require('wavefile');
const fs = require('fs');
module.exports = class extends BaseEngine {
constructor() {
super('sam', 'Software Automatic Mouth', 'wav')
}
getDefaultVoice() {
return 'sam';
}
async getSpeechFile(text, filepath, voice = this.getDefaultVoice(), params = {}) {
let sam = new Sam();
let phonetic = false;
if (text[0] == "$") {
text = text.slice(1);
phonetic = true;
}
const buf = sam.buf8(text, phonetic);
const file = new wavefile.WaveFile();
file.fromScratch(1, 22050, 8, buf);
fs.writeFileSync(filepath, file.toBuffer());
}
}

View File

@@ -1,47 +0,0 @@
const BaseEngine = require('../BaseEngine');
const fetch = require('node-fetch');
const querystring = require('querystring');
const fs = require("fs");
module.exports = class extends BaseEngine {
constructor() {
super('unreal', "Unreal Speech TTS", "mp3");
this.voices = {
scarlett: 'Scarlett',
liv: 'Liv',
dan: 'Dan',
will: 'Will',
amy: 'Amy'
};
}
getDefaultVoice() {
return 'Liv';
}
async getSpeechFile(text, filepath, voice = this.getDefaultVoice(), params = {}) {
const url = "https://api.v6.unrealspeech.com/speech";
const authorization = process.env.UNREAL_API_KEY;
const opts = {
method: "post",
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${authorization}`
},
body: JSON.stringify({
Bitrate: "320k",
Temperature: 0.1,
VoiceId: this.getInternalVoiceName(voice),
Text: text,
AudioFormat: "mp3"
})
};
const res = await fetch(url, opts);
const json = await res.json();
const data = await fetch(json.OutputUri);
const contents = await data.arrayBuffer();
const buf = Buffer.from(contents);
fs.writeFileSync(filepath, buf);
return filepath;
}
};

View File

@@ -1,50 +0,0 @@
const BaseEngine = require('../BaseEngine');
const fetch = require('node-fetch');
const querystring = require('querystring');
module.exports = class extends BaseEngine {
constructor() {
super('watson',"IBM Watson TTS", "ogg");
this.voices = {};
this.populateVoiceList();
}
async populateVoiceList() {
const url = process.env.watsonURL + "/v1/voices";
const authorization = this.IBMAuthString();
const opts = {
method: "get",
headers: {
'Authorization': authorization
},
}
const res = await fetch(url, opts);
const voices = await res.json();
voices.voices.forEach((i) => {
let voiceName = i.description.substring(0, i.description.indexOf(':')).toLowerCase();
this.voices[voiceName] = i.name;
});
}
getDefaultVoice() {
return 'Michael';
}
IBMAuthString() {
let buff = new Buffer('apikey:' + process.env.watsonAPIKey);
let b64auth = buff.toString('base64');
return 'Basic ' + b64auth;
}
async getSpeech(text, voice = this.getSpeechVoice(), params = {}) {
const url = process.env.watsonURL + "/v1/synthesize?voice=" + this.getInternalVoiceName(voice);
const authorization = this.IBMAuthString();
const opts = {
method: "post",
headers: {
'Content-Type': 'application/json',
'Authorization': authorization
},
body: JSON.stringify({
text: text
})
};
return fetch(url, opts);
}
};

View File

@@ -1,19 +0,0 @@
const tts = require('google-tts-api');
const fetch = require('node-fetch');
const fs = require('fs');
const buffer = require('buffer');
async function generateVoice(string) {
const url = tts.getAudioUrl(string, {lang: "en-us"});
console.log("Generated url: " + url);
const data = await fetch(url);
const contents = await data.arrayBuffer();
const buf = Buffer.from(contents);
fs.writeFileSync('speak.mp3', buf);
}
async function test() {
const url = await generateVoice("Hi there bud");
}
test();