Compare 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 |
+6
-1
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": false,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"printWidth": 100,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"useTabs": false,
|
||||||
|
"arrowParens": "always"
|
||||||
|
}
|
||||||
+13
@@ -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"]
|
||||||
@@ -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
@@ -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
|
||||||
@@ -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);
|
|
||||||
Vendored
BIN
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");
|
|
||||||
})
|
|
||||||
}
|
|
||||||
Generated
+5581
-1835
File diff suppressed because it is too large
Load Diff
+43
-14
@@ -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",
|
||||||
|
"main": "src/index.ts",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22"
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "echo \"Error: no test specified\" && exit 1"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
})();
|
||||||
@@ -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)), "..");
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>;
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -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));
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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!");
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -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());
|
||||||
|
};
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import type { BotContext } from "../context.js";
|
||||||
|
|
||||||
|
export type Module = (ctx: BotContext) => void | Promise<void>;
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -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, "\\$&");
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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."
|
||||||
|
}
|
||||||
@@ -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
Binary file not shown.
Binary file not shown.
@@ -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"]
|
||||||
|
}
|
||||||
Vendored
BIN
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