forked from Talon/tardis-bot
Compare commits
61 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4853da05a9 | |||
| f56e6079e6 | |||
| ffb229b513 | |||
| c8d4c4c02f | |||
| afeb05447d | |||
| 456ec8c83f | |||
| f2ce38c176 | |||
| fdb4b2d50f | |||
| 6a024f8cb6 | |||
| 591eb227b3 | |||
| 17ba5e2048 | |||
| 4a371cf4d6 | |||
| 118a1a72e3 | |||
| f709282ca1 | |||
| 0ed2b4d091 | |||
| a0c4630bed | |||
| b964f4acfd | |||
| c92eacb7fd | |||
| b6008e0ad3 | |||
| 136173f4de | |||
| 9d25ed6e7b | |||
| 1f06ae0301 | |||
| e1f5f81338 | |||
| 6f9113a463 | |||
| 604fe3fd10 | |||
| 9ba6185335 | |||
| b8b2906fe3 | |||
| dd618bf785 | |||
| 78e2c77bc7 | |||
| cd0ae8aadf | |||
| ba76ae1023 | |||
| 9b83cdd21a | |||
| ca8f3a4be2 | |||
| fc43c43d59 | |||
| af02d2d279 | |||
| c33d35689b | |||
| 82d5cbc758 | |||
| ee88a48fa5 | |||
| 57d37a7b0b | |||
| 43d7f60a7a | |||
| a27d68ec92 | |||
| 5882be1e43 | |||
| a74cf74574 | |||
| 83624684e6 | |||
| b47437907b | |||
| cd51b092fc | |||
| cbb4a6898b | |||
| 6b43761ca7 | |||
| f0c71d75dc | |||
| 40e50b6546 | |||
| c6c370d22b | |||
| 4a37af0795 | |||
| 9ec6ee3922 | |||
| fdfc431107 | |||
| 75dc0c2da2 | |||
| 1fe978eec6 | |||
| 3b1a419db4 | |||
| 70686c2ce0 | |||
| 0b2b443efe | |||
| 12a9f8fc53 | |||
| 158ed0372f |
7
.gitignore
vendored
7
.gitignore
vendored
@@ -1,4 +1,9 @@
|
|||||||
node_modules
|
node_modules
|
||||||
voice_tmp
|
voice_tmp
|
||||||
|
data/voice_tmp
|
||||||
.env
|
.env
|
||||||
*.db
|
*.db
|
||||||
|
.DS_Store
|
||||||
|
gkey.json
|
||||||
|
dist
|
||||||
|
*.log
|
||||||
|
|||||||
9
.prettierrc.json
Normal file
9
.prettierrc.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": false,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"printWidth": 100,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"useTabs": false,
|
||||||
|
"arrowParens": "always"
|
||||||
|
}
|
||||||
13
Dockerfile
Normal file
13
Dockerfile
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
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 . .
|
||||||
|
|
||||||
|
ENTRYPOINT ["npx", "tsx", "src/index.ts"]
|
||||||
30
eslint.config.js
Normal file
30
eslint.config.js
Normal 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,
|
||||||
|
];
|
||||||
49
example.env
Normal file
49
example.env
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
# Core Discord settings
|
||||||
|
TOKEN=DISCORD_BOT_TOKEN_HERE
|
||||||
|
GUILD=GUILD_ID_HERE
|
||||||
|
CHANNEL=VOICE_CHANNEL_ID_HERE
|
||||||
|
PREFIX=+
|
||||||
|
|
||||||
|
# Localization
|
||||||
|
STRING_SET=en
|
||||||
|
|
||||||
|
# Storage
|
||||||
|
VOICE_TMP_PATH=./data/voice_tmp/
|
||||||
|
DB_FILE=./data/tardis.db
|
||||||
|
|
||||||
|
# Announcement defaults
|
||||||
|
ANNOUNCEMENT_ENGINE=espeak
|
||||||
|
ANNOUNCEMENT_VOICE=en
|
||||||
|
|
||||||
|
# Canttalk (per-message TTS) — optional
|
||||||
|
TTS_CHANNEL=CANTTALK_TEXT_CHANNEL_ID_HERE
|
||||||
|
|
||||||
|
# IBM Watson TTS — optional
|
||||||
|
watsonURL=WATSON_URL_HERE
|
||||||
|
watsonAPIKey=WATSON_API_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
|
||||||
97
index.js
97
index.js
@@ -1,97 +0,0 @@
|
|||||||
const Discord = require('discord.js');
|
|
||||||
require('dotenv').config();
|
|
||||||
const fetch = require('node-fetch');
|
|
||||||
const fs = require('fs');
|
|
||||||
const sha1 = require('sha1');
|
|
||||||
const sqlite = require('sqlite3');
|
|
||||||
|
|
||||||
let joinedVoiceChannels = [];
|
|
||||||
let modules = [];
|
|
||||||
let commandHandlers = new Map();
|
|
||||||
|
|
||||||
const bot = new Discord.Client();
|
|
||||||
|
|
||||||
const db = new sqlite.Database(process.env.DB_FILE);
|
|
||||||
|
|
||||||
const api = {
|
|
||||||
db: db,
|
|
||||||
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;
|
|
||||||
})(),
|
|
||||||
|
|
||||||
isInVoiceChannel: (channel) => {
|
|
||||||
return joinedVoiceChannels.includes(channel);
|
|
||||||
},
|
|
||||||
|
|
||||||
getConnectionForVoiceChannel: (channel) => {
|
|
||||||
return bot.voice.connections.find((conn) => conn.channel === 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 = await channel.join();
|
|
||||||
joinedVoiceChannels.push(channel);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
leaveChannel: async (channel) => {
|
|
||||||
if (joinedVoiceChannels.includes(channel)) {
|
|
||||||
joinedVoiceChannels = joinedVoiceChannels.filter((chan) => chan !== channel);
|
|
||||||
await channel.leave();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
speak: async (channel, message, engine=api.ttsEngines.gtranslate, voice='en-us', params={}) => {
|
|
||||||
const conn = api.getConnectionForVoiceChannel(channel);
|
|
||||||
const filepath = await api.generateVoice(message, engine, voice, params);
|
|
||||||
if (conn) conn.play(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}/index.js`));
|
|
||||||
console.log(`Loading ./modules/${dir}/index.js`)
|
|
||||||
})
|
|
||||||
modules.forEach((mod) => mod(bot, api));
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleMessage(message) {
|
|
||||||
if (message.content.startsWith(process.env.PREFIX)) {
|
|
||||||
const args = message.contents.split(" ");
|
|
||||||
const command = args[0].substr(1, args[0].length);
|
|
||||||
const execution = commandHandlers.get(command);
|
|
||||||
if (command) {
|
|
||||||
command(args, message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
registerModules();
|
|
||||||
bot.login(process.env.TOKEN);
|
|
||||||
bot.on('message', handleMessage);
|
|
||||||
BIN
modules/.DS_Store
vendored
BIN
modules/.DS_Store
vendored
Binary file not shown.
@@ -1,25 +0,0 @@
|
|||||||
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.array().length < 2) {
|
|
||||||
return await api.leaveChannel(channel);
|
|
||||||
}
|
|
||||||
await api.joinChannel(channel);
|
|
||||||
let joined = false;
|
|
||||||
if (!oldState.channel) {
|
|
||||||
joined = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
let username = newState.member.displayName;
|
|
||||||
let str = "";
|
|
||||||
if (!joined) {
|
|
||||||
str = username + " left the channel";
|
|
||||||
} else {
|
|
||||||
str = username + " joined the channel";
|
|
||||||
}
|
|
||||||
api.speak(channel, str);
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
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);
|
|
||||||
|
|
||||||
api.speak(channel, `Hi! I'm alive. It is now ${new Date().toLocaleTimeString()} on ${new Date().toLocaleDateString()}`,api.ttsEngines.espeak, "en");
|
|
||||||
})
|
|
||||||
}
|
|
||||||
7416
package-lock.json
generated
7416
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
59
package.json
59
package.json
@@ -1,21 +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": {
|
||||||
|
"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": {
|
||||||
"discord.js": "^12.5.3",
|
"@discordjs/opus": "^0.10.0",
|
||||||
"dotenv": "^8.2.0",
|
"@discordjs/voice": "^0.19.0",
|
||||||
"google-tts-api": "^2.0.2",
|
"@google-cloud/text-to-speech": "^6.4.1",
|
||||||
"node-fetch": "^2.6.1",
|
"@noble/ciphers": "^1.3.0",
|
||||||
"opusscript": "^0.0.8",
|
"@sefinek/google-tts-api": "^2.1.11",
|
||||||
"sha1": "^1.1.1",
|
"@snazzah/davey": "^0.1.6",
|
||||||
"sqlite3": "^5.0.2"
|
"@stablelib/xchacha20poly1305": "^2.0.1",
|
||||||
|
"discord.js": "^14.26.4",
|
||||||
|
"dotenv": "^16.4.7",
|
||||||
|
"fast-levenshtein": "^3.0.0",
|
||||||
|
"microsoft-cognitiveservices-speech-sdk": "^1.49.0",
|
||||||
|
"openai": "^6.4.0",
|
||||||
|
"printf": "^0.6.1",
|
||||||
|
"sam-js": "^0.3.1",
|
||||||
|
"sodium-native": "^5.1.0",
|
||||||
|
"sqlite": "^5.1.1",
|
||||||
|
"sqlite3": "^5.1.7",
|
||||||
|
"tsx": "^4.19.2",
|
||||||
|
"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
66
src/adapter.ts
Normal 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
42
src/audio/AudioQueue.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
135
src/audio/AudioService.ts
Normal file
135
src/audio/AudioService.ts
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SUMMON_LOCK_MS = 60_000;
|
||||||
|
|
||||||
|
export interface MoveOptions {
|
||||||
|
summon?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AudioService {
|
||||||
|
readonly player: AudioPlayer = createAudioPlayer();
|
||||||
|
readonly queue: AudioQueue = new AudioQueue(this.player);
|
||||||
|
|
||||||
|
private currentChannel: VoiceBasedChannel | undefined;
|
||||||
|
private currentConnection: VoiceConnection | undefined;
|
||||||
|
private summonLockUntil = 0;
|
||||||
|
|
||||||
|
constructor(private readonly opts: AudioServiceOptions) {}
|
||||||
|
|
||||||
|
getCurrentChannel(): VoiceBasedChannel | undefined {
|
||||||
|
return this.currentChannel;
|
||||||
|
}
|
||||||
|
|
||||||
|
getCurrentConnection(): VoiceConnection | undefined {
|
||||||
|
return this.currentConnection;
|
||||||
|
}
|
||||||
|
|
||||||
|
isInChannel(channel: VoiceBasedChannel): boolean {
|
||||||
|
return this.currentChannel?.id === channel.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
isSummonLocked(): boolean {
|
||||||
|
if (this.summonLockUntil <= Date.now()) return false;
|
||||||
|
// Release the lock early if the summon channel has only the bot left.
|
||||||
|
if ((this.currentChannel?.members.size ?? 0) < 2) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async moveTo(channel: VoiceBasedChannel, opts: MoveOptions = {}): Promise<void> {
|
||||||
|
if (opts.summon) this.summonLockUntil = Date.now() + SUMMON_LOCK_MS;
|
||||||
|
if (this.currentChannel?.id === channel.id) return;
|
||||||
|
|
||||||
|
const connection = joinVoiceChannel({
|
||||||
|
channelId: channel.id,
|
||||||
|
guildId: channel.guild.id,
|
||||||
|
adapterCreator: createAdapter(channel),
|
||||||
|
});
|
||||||
|
connection.subscribe(this.player);
|
||||||
|
|
||||||
|
this.currentConnection?.destroy();
|
||||||
|
this.currentChannel = channel;
|
||||||
|
this.currentConnection = connection;
|
||||||
|
}
|
||||||
|
|
||||||
|
async leave(): Promise<void> {
|
||||||
|
if (!this.currentConnection) return;
|
||||||
|
this.queue.flush();
|
||||||
|
this.currentConnection.destroy();
|
||||||
|
this.currentConnection = undefined;
|
||||||
|
this.currentChannel = undefined;
|
||||||
|
this.summonLockUntil = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
28
src/commands/CommandRegistry.ts
Normal file
28
src/commands/CommandRegistry.ts
Normal 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
47
src/config.ts
Normal 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
24
src/context.ts
Normal 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
8
src/db/db.ts
Normal 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 });
|
||||||
|
}
|
||||||
19
src/db/init.ts
Normal file
19
src/db/init.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import type { AppDatabase } from "./db.js";
|
||||||
|
|
||||||
|
export async function initializeSchema(db: AppDatabase): Promise<void> {
|
||||||
|
await db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS BotState (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS TTSPreferences (
|
||||||
|
user_id TEXT PRIMARY KEY,
|
||||||
|
engine TEXT NOT NULL,
|
||||||
|
voice TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS WBWStories (
|
||||||
|
story_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
story_text TEXT NOT NULL
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
}
|
||||||
15
src/db/schema.ts
Normal file
15
src/db/schema.ts
Normal 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;
|
||||||
|
}
|
||||||
44
src/i18n/strings.ts
Normal file
44
src/i18n/strings.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
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"
|
||||||
|
| "AMBIGUOUS_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>;
|
||||||
69
src/index.ts
Normal file
69
src/index.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
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 { initializeSchema } from "./db/init.js";
|
||||||
|
import type { BotStateRow } from "./db/schema.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);
|
||||||
|
await initializeSchema(db);
|
||||||
|
const savedEngine = await db.get<BotStateRow>(
|
||||||
|
"select value from BotState where key='announcement_engine'",
|
||||||
|
);
|
||||||
|
const savedVoice = await db.get<BotStateRow>(
|
||||||
|
"select value from BotState where key='announcement_voice'",
|
||||||
|
);
|
||||||
|
const tts = new TTSRegistry(
|
||||||
|
savedEngine?.value ?? config.ANNOUNCEMENT_ENGINE,
|
||||||
|
savedVoice?.value ?? 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);
|
||||||
71
src/modules/announcer.ts
Normal file
71
src/modules/announcer.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import type { Guild, VoiceBasedChannel } from "discord.js";
|
||||||
|
import { ChannelType } from "discord.js";
|
||||||
|
import type { Module } from "./types.js";
|
||||||
|
|
||||||
|
function chooseTargetChannel(
|
||||||
|
guild: Guild,
|
||||||
|
recentEventChannel: VoiceBasedChannel | null | undefined,
|
||||||
|
currentChannelId: string | undefined,
|
||||||
|
): VoiceBasedChannel | undefined {
|
||||||
|
let best: VoiceBasedChannel | undefined;
|
||||||
|
let bestCount = 0;
|
||||||
|
|
||||||
|
for (const ch of guild.channels.cache.values()) {
|
||||||
|
if (ch.type !== ChannelType.GuildVoice && ch.type !== ChannelType.GuildStageVoice) continue;
|
||||||
|
const voice = ch as VoiceBasedChannel;
|
||||||
|
const humans = voice.members.filter((m) => !m.user.bot).size;
|
||||||
|
if (humans === 0) continue;
|
||||||
|
|
||||||
|
if (humans > bestCount) {
|
||||||
|
best = voice;
|
||||||
|
bestCount = humans;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (humans === bestCount && best) {
|
||||||
|
// Tie-break: prefer the channel that just had activity, then the bot's current channel.
|
||||||
|
if (recentEventChannel && voice.id === recentEventChannel.id) {
|
||||||
|
best = voice;
|
||||||
|
} else if (
|
||||||
|
currentChannelId &&
|
||||||
|
voice.id === currentChannelId &&
|
||||||
|
best.id !== recentEventChannel?.id
|
||||||
|
) {
|
||||||
|
best = voice;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const announcer: Module = ({ client, audio, tts, t }) => {
|
||||||
|
client.on("voiceStateUpdate", async (oldState, newState) => {
|
||||||
|
if (newState.member?.user.bot) return;
|
||||||
|
|
||||||
|
const guild = newState.guild ?? oldState.guild;
|
||||||
|
const recentChannel = newState.channel ?? oldState.channel;
|
||||||
|
|
||||||
|
if (!audio.isSummonLocked()) {
|
||||||
|
const target = chooseTargetChannel(guild, recentChannel, audio.getCurrentChannel()?.id);
|
||||||
|
const current = audio.getCurrentChannel();
|
||||||
|
if (!target && current) {
|
||||||
|
await audio.leave();
|
||||||
|
} else if (target && target.id !== current?.id) {
|
||||||
|
await audio.moveTo(target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const joined = !oldState.channel && !!newState.channel;
|
||||||
|
const left = !!oldState.channel && !newState.channel;
|
||||||
|
if (!joined && !left) return;
|
||||||
|
|
||||||
|
const eventChannel = newState.channel ?? oldState.channel;
|
||||||
|
const current = audio.getCurrentChannel();
|
||||||
|
if (!eventChannel || !current || eventChannel.id !== current.id) return;
|
||||||
|
|
||||||
|
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(filepath);
|
||||||
|
});
|
||||||
|
};
|
||||||
83
src/modules/canttalk.ts
Normal file
83
src/modules/canttalk.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { readdirSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { respond } from "../audio/AudioService.js";
|
||||||
|
import type { TTSPreferencesRow } from "../db/schema.js";
|
||||||
|
import { formatCandidates } from "../tts/BaseEngine.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) => {
|
||||||
|
const engineName = args[1];
|
||||||
|
if (!engineName || args.length < 3) {
|
||||||
|
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 voiceInput = args.slice(2).join(" ");
|
||||||
|
const res = engine.resolveVoice(voiceInput);
|
||||||
|
let chosenVoice: string;
|
||||||
|
if (res.kind === "exact" || res.kind === "fuzzy") {
|
||||||
|
chosenVoice = res.voice;
|
||||||
|
respond(audio, sysmsg, message, t("USER_VOICE_CHANGED", chosenVoice, engine.longName));
|
||||||
|
} else if (res.kind === "ambiguous") {
|
||||||
|
return respond(
|
||||||
|
audio,
|
||||||
|
sysmsg,
|
||||||
|
message,
|
||||||
|
t("AMBIGUOUS_VOICE", voiceInput, formatCandidates(res.candidates)),
|
||||||
|
);
|
||||||
|
} 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
25
src/modules/chatgpt.ts
Normal 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
42
src/modules/mangle.ts
Normal 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
38
src/modules/quotedb.ts
Normal 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
30
src/modules/registry.ts
Normal 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
14
src/modules/summon.ts
Normal 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.moveTo(channel, { summon: true });
|
||||||
|
respond(audio, sysmsg, message, "Hi!");
|
||||||
|
});
|
||||||
|
};
|
||||||
86
src/modules/ttsSettings.ts
Normal file
86
src/modules/ttsSettings.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { join } from "node:path";
|
||||||
|
import { AttachmentBuilder } from "discord.js";
|
||||||
|
import { respond } from "../audio/AudioService.js";
|
||||||
|
import { formatCandidates } from "../tts/BaseEngine.js";
|
||||||
|
import type { Module } from "./types.js";
|
||||||
|
|
||||||
|
export const ttsSettings: Module = ({ audio, commands, tts, db, t, rootDir }) => {
|
||||||
|
const sysmsg = join(rootDir, "sysmsg.wav");
|
||||||
|
|
||||||
|
const persistAnnouncement = async () => {
|
||||||
|
await db.run("insert or replace into BotState (key, value) values (?, ?)", [
|
||||||
|
"announcement_engine",
|
||||||
|
tts.announcement.shortName,
|
||||||
|
]);
|
||||||
|
await db.run("insert or replace into BotState (key, value) values (?, ?)", [
|
||||||
|
"announcement_voice",
|
||||||
|
tts.announcementVoice,
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
commands.register("announcevoice", async (args, message) => {
|
||||||
|
const engineName = args[1];
|
||||||
|
if (!engineName || args.length < 3) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
const voiceInput = args.slice(2).join(" ");
|
||||||
|
const res = engine.resolveVoice(voiceInput);
|
||||||
|
tts.announcement = engine;
|
||||||
|
if (res.kind === "exact" || res.kind === "fuzzy") {
|
||||||
|
tts.announcementVoice = res.voice;
|
||||||
|
await persistAnnouncement();
|
||||||
|
respond(audio, sysmsg, message, t("SYSTEM_VOICE_CHANGED", res.voice, engine.longName));
|
||||||
|
} else if (res.kind === "ambiguous") {
|
||||||
|
respond(
|
||||||
|
audio,
|
||||||
|
sysmsg,
|
||||||
|
message,
|
||||||
|
t("AMBIGUOUS_VOICE", voiceInput, formatCandidates(res.candidates)),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
tts.announcementVoice = engine.getDefaultVoice();
|
||||||
|
await persistAnnouncement();
|
||||||
|
respond(
|
||||||
|
audio,
|
||||||
|
sysmsg,
|
||||||
|
message,
|
||||||
|
t("INVALID_VOICE", tts.announcementVoice, engine.longName),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
commands.register("lsvoices", (args, message) => {
|
||||||
|
if (args.length > 1) {
|
||||||
|
respond(audio, sysmsg, message, t("TOO_MANY_ARGUMENTS"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const sections: string[] = [];
|
||||||
|
for (const name of tts.list()) {
|
||||||
|
const engine = tts.get(name);
|
||||||
|
if (!engine) continue;
|
||||||
|
const lines = [`=== ${name} — ${engine.longName} ===`, `default: ${engine.getDefaultVoice()}`];
|
||||||
|
if (engine.isFreeformVoice()) {
|
||||||
|
lines.push(`(accepts any voice string — see ${engine.longName} documentation)`);
|
||||||
|
} else {
|
||||||
|
const voices = engine.listVoices();
|
||||||
|
lines.push(`voices (${voices.length}):`);
|
||||||
|
for (const v of voices) lines.push(` ${v}`);
|
||||||
|
}
|
||||||
|
sections.push(lines.join("\n"));
|
||||||
|
}
|
||||||
|
const body = sections.join("\n\n") + "\n";
|
||||||
|
const file = new AttachmentBuilder(Buffer.from(body, "utf8"), { name: "voices.txt" });
|
||||||
|
void message.reply({
|
||||||
|
content: `Available TTS engines and voices (${tts.list().length} engines):`,
|
||||||
|
files: [file],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
commands.register("flush", () => audio.queue.flush());
|
||||||
|
};
|
||||||
3
src/modules/types.ts
Normal file
3
src/modules/types.ts
Normal 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
21
src/modules/welcomer.ts
Normal 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.moveTo(voiceChannel, { summon: true });
|
||||||
|
audio.queue.add(sysstart);
|
||||||
|
await audio.speak(voiceChannel, strings.WELCOME);
|
||||||
|
});
|
||||||
|
};
|
||||||
78
src/modules/wordbyword.ts
Normal file
78
src/modules/wordbyword.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
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("insert or replace into BotState (key, value) values (?, ?)", [
|
||||||
|
"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, "\\$&");
|
||||||
|
}
|
||||||
100
src/tts/BaseEngine.ts
Normal file
100
src/tts/BaseEngine.ts
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import { writeFile } from "node:fs/promises";
|
||||||
|
|
||||||
|
export type VoiceParams = Record<string, unknown>;
|
||||||
|
|
||||||
|
export type VoiceResolution =
|
||||||
|
| { kind: "exact"; voice: string }
|
||||||
|
| { kind: "fuzzy"; voice: string }
|
||||||
|
| { kind: "ambiguous"; candidates: string[] }
|
||||||
|
| { kind: "none" };
|
||||||
|
|
||||||
|
/** Joins up to `max` candidate voice names for a user-facing ambiguity message, summarizing the rest as "(+N more)". */
|
||||||
|
export function formatCandidates(candidates: string[], max = 5): string {
|
||||||
|
const shown = candidates.slice(0, max).join(", ");
|
||||||
|
const extra = candidates.length - max;
|
||||||
|
return extra > 0 ? `${shown} (+${extra} more)` : shown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns sorted lowercase voice names known to this engine. Empty if the engine accepts any voice. */
|
||||||
|
listVoices(): string[] {
|
||||||
|
return Object.keys(this.voices).sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when this engine has no static voice table and accepts any voice string. */
|
||||||
|
isFreeformVoice(): boolean {
|
||||||
|
return Object.keys(this.voices).length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves a user-typed voice string against this engine's voice table using
|
||||||
|
* exact match first, then token-prefix matching: each whitespace-separated
|
||||||
|
* input token must be a prefix of some alphanumeric token of a key.
|
||||||
|
* Freeform engines always succeed with the normalized input.
|
||||||
|
*/
|
||||||
|
resolveVoice(input: string): VoiceResolution {
|
||||||
|
const norm = input.trim().toLowerCase();
|
||||||
|
if (this.isFreeformVoice()) return { kind: "exact", voice: norm };
|
||||||
|
if (this.voices[norm] != null) return { kind: "exact", voice: norm };
|
||||||
|
const inputTokens = norm.split(/\s+/).filter(Boolean);
|
||||||
|
if (inputTokens.length === 0) return { kind: "none" };
|
||||||
|
const matches = Object.keys(this.voices).filter((key) => {
|
||||||
|
const keyTokens = key.split(/[^a-z0-9]+/i).filter(Boolean);
|
||||||
|
return inputTokens.every((it) => keyTokens.some((kt) => kt.startsWith(it)));
|
||||||
|
});
|
||||||
|
if (matches.length === 1) return { kind: "fuzzy", voice: matches[0]! };
|
||||||
|
if (matches.length > 1) return { kind: "ambiguous", candidates: matches.sort() };
|
||||||
|
return { kind: "none" };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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
77
src/tts/azure.ts
Normal 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
63
src/tts/eleven.ts
Normal 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
33
src/tts/espeak.ts
Normal 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
78
src/tts/google.ts
Normal 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
25
src/tts/gtranslate.ts
Normal 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
40
src/tts/openai.ts
Normal 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
69
src/tts/registry.ts
Normal 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
41
src/tts/sam.ts
Normal 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
52
src/tts/unreal.ts
Normal 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
64
src/tts/watson.ts
Normal 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 }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
18
strings/en.json
Normal file
18
strings/en.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"WELCOME": "Beep boop. I'm a bot. Hi.",
|
||||||
|
"USER_JOINED": "%s joined the channel.",
|
||||||
|
"USER_LEFT": "%s left the channel.",
|
||||||
|
"SYSTEM_VOICE_CHANGED": "My new voice is %s from %s",
|
||||||
|
"USER_VOICE_CHANGED": "Your new voice is %s from %s",
|
||||||
|
"INVALID_ENGINE": "%s is not a valid engine name.",
|
||||||
|
"INVALID_VOICE": "invalid voice name. Using default voice %s for %s instead.",
|
||||||
|
"AMBIGUOUS_VOICE": "voice name \"%s\" is ambiguous. Candidates: %s",
|
||||||
|
"TOO_MANY_ARGUMENTS": "too many arguments for command.",
|
||||||
|
"CURRENT_STORY": "Here's the current story: %s",
|
||||||
|
"NO_STORY": "No story in progress at the moment.",
|
||||||
|
"WBW_REPLACED": "Replaced %s with %s",
|
||||||
|
"WBW_TOO_DIFFERENT": "This word is too different from the last word.",
|
||||||
|
"WBW_NEW_WORD": "Added to story.",
|
||||||
|
"WBW_RESET": "The story has been reset.",
|
||||||
|
"WBW_INVALID_ID": "No story with that ID."
|
||||||
|
}
|
||||||
10
strings/es.json
Normal file
10
strings/es.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"WELCOME": "Hola hola, soy un bot.",
|
||||||
|
"USER_JOINED": "%s se ha unido al canal.",
|
||||||
|
"USER_LEFT": "%s ha salido del canal.",
|
||||||
|
"SYSTEM_VOICE_CHANGED": "Mi nueva voz es %s de %s",
|
||||||
|
"USER_VOICE_CHANGED": "Tu nueva voz es %s de %s",
|
||||||
|
"INVALID_ENGINE": "%s no es un nombre de motor válido.",
|
||||||
|
"INVALID_VOICE": "Nombre de voz no válido. Usando voz por defecto %s para %s.",
|
||||||
|
"TOO_MANY_ARGUMENTS": "Demasiados argumentos para el comando."
|
||||||
|
}
|
||||||
BIN
sysmsg.wav
Normal file
BIN
sysmsg.wav
Normal file
Binary file not shown.
BIN
sysstart.wav
Normal file
BIN
sysstart.wav
Normal file
Binary file not shown.
21
tsconfig.json
Normal file
21
tsconfig.json
Normal 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
BIN
tts/.DS_Store
vendored
Binary file not shown.
@@ -1,16 +0,0 @@
|
|||||||
const fs=require('fs');
|
|
||||||
|
|
||||||
module.exports=class {
|
|
||||||
constructor(longName, fileExtension, supportedParameters=[]) {
|
|
||||||
this.longName=longName;
|
|
||||||
this.fileExtension=fileExtension;
|
|
||||||
}
|
|
||||||
async getSpeech(text, voice, 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
const BaseEngine=require('../BaseEngine')
|
|
||||||
const {spawn} = require('child_process')
|
|
||||||
|
|
||||||
module.exports=class extends BaseEngine {
|
|
||||||
constructor() {
|
|
||||||
super('ESpeak','wav')
|
|
||||||
}
|
|
||||||
async getSpeechFile(text, filepath, voice='en', params={}) {
|
|
||||||
let proc=await spawn('espeak', ['-v', voice, '-w',filepath, '--stdin']);
|
|
||||||
proc.stdin.end(text);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
const BaseEngine=require('../BaseEngine');
|
|
||||||
const fetch = require('node-fetch');
|
|
||||||
const tts = require('google-tts-api');
|
|
||||||
|
|
||||||
module.exports= class extends BaseEngine {
|
|
||||||
constructor() {
|
|
||||||
super("Google Translate TTS","mp3");
|
|
||||||
}
|
|
||||||
async getSpeech(text, voice='en-us', params={}) {
|
|
||||||
const url = tts.getAudioUrl(text, {lang: voice});
|
|
||||||
return fetch(url);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
const BaseEngine=require('../BaseEngine');
|
|
||||||
const fetch = require('node-fetch');
|
|
||||||
const querystring = require('querystring');
|
|
||||||
|
|
||||||
module.exports= class extends BaseEngine {
|
|
||||||
constructor() {
|
|
||||||
super("IBM Watson TTS","ogg");
|
|
||||||
}
|
|
||||||
async getSpeech(text, voice='en-us', params={}) {
|
|
||||||
const url = process.env.watsonURL+"/v1/synthesize";
|
|
||||||
let buff=new Buffer('apikey:'+process.env.watsonAPIKey);
|
|
||||||
let b64auth=buff.toString('base64');
|
|
||||||
const authorization='Basic '+b64auth;
|
|
||||||
const opts={
|
|
||||||
method: "post",
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Authorization': authorization
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
text: text
|
|
||||||
})
|
|
||||||
};
|
|
||||||
return fetch(url,opts);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -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();
|
|
||||||
Reference in New Issue
Block a user