Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 83569a27e7 | |||
| 02949e8b16 | |||
| 645d58ca21 | |||
| 905713a08d | |||
| b4cb95793b | |||
| 0e171894d5 | |||
| 40487d9634 | |||
| 827af77895 |
Generated
+12
-1
@@ -15,7 +15,9 @@
|
|||||||
"colorts": "^0.1.63",
|
"colorts": "^0.1.63",
|
||||||
"discord.js": "^14.25.1",
|
"discord.js": "^14.25.1",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"prettier": "^3.7.4"
|
"node-audio-mixer": "^2.1.0",
|
||||||
|
"prettier": "^3.7.4",
|
||||||
|
"prism-media": "^1.3.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.2",
|
"@eslint/js": "^9.39.2",
|
||||||
@@ -2666,6 +2668,15 @@
|
|||||||
"node": "^18 || ^20 || >= 21"
|
"node": "^18 || ^20 || >= 21"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/node-audio-mixer": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-audio-mixer/-/node-audio-mixer-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-ivAl1arhkdbwAmFTOQxizRs39wx1jhMedq12FgaGku6gupItFwq3pyRz3zoQ3iKnsMSNP3Ab9EM5MOu3zFoOfw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/node-fetch": {
|
"node_modules/node-fetch": {
|
||||||
"version": "2.7.0",
|
"version": "2.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
||||||
|
|||||||
+3
-1
@@ -40,6 +40,8 @@
|
|||||||
"colorts": "^0.1.63",
|
"colorts": "^0.1.63",
|
||||||
"discord.js": "^14.25.1",
|
"discord.js": "^14.25.1",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"prettier": "^3.7.4"
|
"node-audio-mixer": "^2.1.0",
|
||||||
|
"prettier": "^3.7.4",
|
||||||
|
"prism-media": "^1.3.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
import {
|
||||||
|
AudioPlayer,
|
||||||
|
createAudioPlayer,
|
||||||
|
createAudioResource,
|
||||||
|
StreamType,
|
||||||
|
VoiceConnection
|
||||||
|
} from '@discordjs/voice';
|
||||||
|
import { AudioMixer } from 'node-audio-mixer';
|
||||||
|
import { PassThrough, Readable } from 'stream';
|
||||||
|
|
||||||
|
import prism from 'prism-media';
|
||||||
|
|
||||||
|
export class StreamQueue {
|
||||||
|
private queue: Readable[] = [];
|
||||||
|
private isPlaying = false;
|
||||||
|
private mixer: MixedStream;
|
||||||
|
|
||||||
|
constructor(mixer: MixedStream) {
|
||||||
|
this.mixer = mixer;
|
||||||
|
}
|
||||||
|
|
||||||
|
public enqueue(resource: Readable) {
|
||||||
|
this.queue.push(resource);
|
||||||
|
this.processQueue();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async processQueue() {
|
||||||
|
if (this.isPlaying || this.queue.length === 0) return;
|
||||||
|
|
||||||
|
this.isPlaying = true;
|
||||||
|
const nextStream = this.queue.shift();
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (nextStream) {
|
||||||
|
await this.mixer.playStream(nextStream);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Queue error:', e);
|
||||||
|
} finally {
|
||||||
|
this.isPlaying = false;
|
||||||
|
this.processQueue();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public clear() {
|
||||||
|
this.queue = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MixedStream {
|
||||||
|
public readonly player: AudioPlayer;
|
||||||
|
private mixer: AudioMixer;
|
||||||
|
private output: PassThrough;
|
||||||
|
private silenceInterval: NodeJS.Timeout;
|
||||||
|
|
||||||
|
private queues: Map<string, StreamQueue> = new Map();
|
||||||
|
|
||||||
|
public constructor() {
|
||||||
|
this.player = createAudioPlayer();
|
||||||
|
|
||||||
|
this.mixer = new AudioMixer({
|
||||||
|
channels: 2,
|
||||||
|
bitDepth: 16,
|
||||||
|
sampleRate: 48000,
|
||||||
|
autoClose: false,
|
||||||
|
generateSilence: false // does not work :<
|
||||||
|
});
|
||||||
|
|
||||||
|
const silenceInput = this.mixer.createAudioInput({
|
||||||
|
channels: 2,
|
||||||
|
sampleRate: 48000,
|
||||||
|
bitDepth: 16,
|
||||||
|
volume: 100
|
||||||
|
});
|
||||||
|
|
||||||
|
const chunk = Buffer.alloc(3840);
|
||||||
|
this.silenceInterval = setInterval(() => {
|
||||||
|
if (silenceInput.writable && silenceInput.writableLength < 3840 * 10) {
|
||||||
|
silenceInput.write(chunk);
|
||||||
|
}
|
||||||
|
}, 20);
|
||||||
|
|
||||||
|
this.output = new PassThrough({ highWaterMark: 1024 * 16 });
|
||||||
|
this.mixer.pipe(this.output);
|
||||||
|
|
||||||
|
const resource = createAudioResource(this.output, {
|
||||||
|
inputType: StreamType.Raw
|
||||||
|
});
|
||||||
|
|
||||||
|
this.player.play(resource);
|
||||||
|
this.player.on('error', (error) => {
|
||||||
|
console.error('Error: ', error.message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public getQueue(name: string): StreamQueue {
|
||||||
|
let queue = this.queues.get(name);
|
||||||
|
if (!queue) {
|
||||||
|
queue = new StreamQueue(this);
|
||||||
|
this.queues.set(name, queue);
|
||||||
|
}
|
||||||
|
return queue;
|
||||||
|
}
|
||||||
|
|
||||||
|
public playStream(source: Readable): Promise<void> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const mixerInput = this.mixer.createAudioInput({
|
||||||
|
channels: 2,
|
||||||
|
sampleRate: 48000,
|
||||||
|
bitDepth: 16,
|
||||||
|
volume: 100
|
||||||
|
});
|
||||||
|
|
||||||
|
const transcoder = new prism.FFmpeg({
|
||||||
|
args: [
|
||||||
|
'-analyzeduration',
|
||||||
|
'0',
|
||||||
|
'-loglevel',
|
||||||
|
'0',
|
||||||
|
'-f',
|
||||||
|
's16le',
|
||||||
|
'-ar',
|
||||||
|
'48000',
|
||||||
|
'-ac',
|
||||||
|
'2'
|
||||||
|
]
|
||||||
|
});
|
||||||
|
let totalBytes = 0;
|
||||||
|
|
||||||
|
transcoder.on('data', (chunk: Buffer) => {
|
||||||
|
totalBytes += chunk.length;
|
||||||
|
});
|
||||||
|
|
||||||
|
transcoder.on('end', () => {
|
||||||
|
const durationMs = (totalBytes / 192000) * 1000;
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
source.unpipe(transcoder);
|
||||||
|
transcoder.unpipe(mixerInput);
|
||||||
|
this.mixer.removeAudioinput(mixerInput);
|
||||||
|
transcoder.destroy();
|
||||||
|
resolve();
|
||||||
|
}, durationMs);
|
||||||
|
});
|
||||||
|
|
||||||
|
transcoder.on('error', () => {
|
||||||
|
this.mixer.removeAudioinput(mixerInput);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
source.pipe(transcoder).pipe(mixerInput);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public destroy(): void {
|
||||||
|
this.player.stop();
|
||||||
|
this.output.destroy();
|
||||||
|
this.mixer.destroy();
|
||||||
|
clearInterval(this.silenceInterval);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AudioStreamManager {
|
||||||
|
private streams = new WeakMap<VoiceConnection, MixedStream>();
|
||||||
|
|
||||||
|
public getOrCreateStream(conn: VoiceConnection): MixedStream {
|
||||||
|
let stream = this.streams.get(conn);
|
||||||
|
if (stream) return stream;
|
||||||
|
|
||||||
|
stream = new MixedStream();
|
||||||
|
this.streams.set(conn, stream);
|
||||||
|
conn.subscribe(stream.player);
|
||||||
|
return stream;
|
||||||
|
}
|
||||||
|
|
||||||
|
public destroyStream(conn: VoiceConnection): void {
|
||||||
|
const stream = this.streams.get(conn);
|
||||||
|
if (stream) {
|
||||||
|
stream.destroy();
|
||||||
|
this.streams.delete(conn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
singleton logic
|
||||||
|
*/
|
||||||
|
static #instance: AudioStreamManager | null = null;
|
||||||
|
|
||||||
|
public static get get(): AudioStreamManager {
|
||||||
|
if (!AudioStreamManager.#instance)
|
||||||
|
AudioStreamManager.#instance = new AudioStreamManager();
|
||||||
|
return AudioStreamManager.#instance;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { TTSModule, TTSResponse } from '../tts';
|
||||||
|
|
||||||
|
import * as https from 'https';
|
||||||
|
|
||||||
|
import GOOGLE_TTS_VOICES from './google_voices.json';
|
||||||
|
const GOOGLE_TTS_ENDPOINT = 'translate.google.com';
|
||||||
|
const USER_AGENT =
|
||||||
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3';
|
||||||
|
|
||||||
|
const ttsGoogle: TTSModule = {
|
||||||
|
name: 'Google',
|
||||||
|
getVoices: async (): Promise<string[]> => GOOGLE_TTS_VOICES.voices,
|
||||||
|
|
||||||
|
generate: async (voice: string, text: string): Promise<TTSResponse> => {
|
||||||
|
const query = new URLSearchParams({
|
||||||
|
ie: 'UTF-8',
|
||||||
|
q: text,
|
||||||
|
tl: voice,
|
||||||
|
client: 'tw-ob'
|
||||||
|
});
|
||||||
|
|
||||||
|
const options: https.RequestOptions = {
|
||||||
|
hostname: GOOGLE_TTS_ENDPOINT,
|
||||||
|
path: `/translate_tts?${query.toString()}`,
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'User-Agent': USER_AGENT
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const req = https.get(options, (res) => {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
res.on('data', (chunk) => chunks.push(chunk));
|
||||||
|
res.on('end', () => resolve({ data: Buffer.concat(chunks) }));
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('error', (err) => resolve({ error: err.message }));
|
||||||
|
req.on('timeout', () => {
|
||||||
|
req.destroy();
|
||||||
|
resolve({ error: 'timed out' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ttsGoogle;
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
{
|
||||||
|
"voices": [
|
||||||
|
"af",
|
||||||
|
"ar",
|
||||||
|
"bn",
|
||||||
|
"bs",
|
||||||
|
"ca",
|
||||||
|
"cs",
|
||||||
|
"cy",
|
||||||
|
"da",
|
||||||
|
"de",
|
||||||
|
"el",
|
||||||
|
"en",
|
||||||
|
"en-au",
|
||||||
|
"en-ca",
|
||||||
|
"en-gb",
|
||||||
|
"en-gh",
|
||||||
|
"en-ie",
|
||||||
|
"en-in",
|
||||||
|
"en-ng",
|
||||||
|
"en-nz",
|
||||||
|
"en-ph",
|
||||||
|
"en-tz",
|
||||||
|
"en-uk",
|
||||||
|
"en-us",
|
||||||
|
"en-za",
|
||||||
|
"eo",
|
||||||
|
"es",
|
||||||
|
"es-es",
|
||||||
|
"es-us",
|
||||||
|
"et",
|
||||||
|
"fi",
|
||||||
|
"fr",
|
||||||
|
"fr-ca",
|
||||||
|
"fr-fr",
|
||||||
|
"hi",
|
||||||
|
"hr",
|
||||||
|
"hu",
|
||||||
|
"hy",
|
||||||
|
"id",
|
||||||
|
"is",
|
||||||
|
"it",
|
||||||
|
"ja",
|
||||||
|
"jw",
|
||||||
|
"km",
|
||||||
|
"ko",
|
||||||
|
"la",
|
||||||
|
"lv",
|
||||||
|
"mk",
|
||||||
|
"ml",
|
||||||
|
"mr",
|
||||||
|
"my",
|
||||||
|
"ne",
|
||||||
|
"nl",
|
||||||
|
"no",
|
||||||
|
"pl",
|
||||||
|
"pt",
|
||||||
|
"pt-br",
|
||||||
|
"pt-pt",
|
||||||
|
"ro",
|
||||||
|
"ru",
|
||||||
|
"si",
|
||||||
|
"sk",
|
||||||
|
"sq",
|
||||||
|
"sr",
|
||||||
|
"su",
|
||||||
|
"sv",
|
||||||
|
"sw",
|
||||||
|
"ta",
|
||||||
|
"te",
|
||||||
|
"th",
|
||||||
|
"tl",
|
||||||
|
"tr",
|
||||||
|
"uk",
|
||||||
|
"vi",
|
||||||
|
"zh-cn",
|
||||||
|
"zh-tw"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { TTSModule, TTSResponse } from '../tts';
|
||||||
|
|
||||||
|
const ttsNone: TTSModule = {
|
||||||
|
name: 'None',
|
||||||
|
getVoices: async (): Promise<Array<string>> => [],
|
||||||
|
generate: async (): Promise<TTSResponse> => {
|
||||||
|
return { data: Buffer.from([]) };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ttsNone;
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import path from 'path';
|
||||||
|
import fs from 'fs';
|
||||||
|
|
||||||
|
import { Logger } from '../utils/log';
|
||||||
|
import { isModule } from '../utils/misc';
|
||||||
|
|
||||||
|
export interface TTSResponse {
|
||||||
|
error?: string;
|
||||||
|
data?: Buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TTSModule {
|
||||||
|
name: string;
|
||||||
|
getVoices: () => Promise<Array<string> | undefined>;
|
||||||
|
generate: (voice: string, text: string) => Promise<TTSResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TTSManager {
|
||||||
|
private readonly modules: Array<TTSModule> = [];
|
||||||
|
private readonly log: Logger;
|
||||||
|
|
||||||
|
private constructor() {
|
||||||
|
this.log = new Logger('TTS Manager');
|
||||||
|
const modesFolder = path.resolve(__dirname, './tts-modes');
|
||||||
|
|
||||||
|
fs.readdirSync(modesFolder).map(
|
||||||
|
async (file) => await this.loadModule(path.join(modesFolder, file))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
public
|
||||||
|
*/
|
||||||
|
public getModule(name: string): TTSModule | undefined {
|
||||||
|
return this.modules.find((mod) => mod.name == name);
|
||||||
|
}
|
||||||
|
|
||||||
|
public getModules(): TTSModule[] {
|
||||||
|
return this.modules;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
internal
|
||||||
|
*/
|
||||||
|
private async loadModule(filePath: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
if (!isModule(filePath)) return;
|
||||||
|
|
||||||
|
const modRaw = await import(`file://${filePath}`);
|
||||||
|
|
||||||
|
if (!modRaw || !modRaw.default) {
|
||||||
|
this.log.warning('Invalid module format in %s', filePath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mod = modRaw.default as TTSModule;
|
||||||
|
|
||||||
|
this.log.verbose(`Loaded TTS mode: ${mod.name}`);
|
||||||
|
this.modules.push(mod);
|
||||||
|
} catch (err) {
|
||||||
|
this.log.error('Failed to load TTS Module at %s (%s)', filePath, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
singleton logic
|
||||||
|
*/
|
||||||
|
static #instance: TTSManager | null = null;
|
||||||
|
|
||||||
|
public static get get(): TTSManager {
|
||||||
|
if (!TTSManager.#instance) TTSManager.#instance = new TTSManager();
|
||||||
|
return TTSManager.#instance;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user