Compare commits

...

8 Commits

Author SHA1 Message Date
neru 83569a27e7 style: run format:apply 2026-01-13 17:50:33 -03:00
neru 02949e8b16 style: remove unused variable 2026-01-13 17:49:56 -03:00
neru 645d58ca21 chore: re-add prettier 2026-01-13 17:49:10 -03:00
neru 905713a08d feat: add audio streams, queue and manager 2026-01-13 17:49:01 -03:00
neru b4cb95793b feat: add empty tts module 2026-01-13 17:48:48 -03:00
neru 0e171894d5 feat: add Google TTS module 2026-01-13 17:48:42 -03:00
neru 40487d9634 feat: add TTS module 2026-01-13 17:48:33 -03:00
neru 827af77895 chore: add node-audio-mixer and prism-media 2026-01-13 17:48:21 -03:00
7 changed files with 420 additions and 2 deletions
+12 -1
View File
@@ -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
View File
@@ -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"
} }
} }
+194
View File
@@ -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;
}
}
+47
View File
@@ -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;
+79
View File
@@ -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"
]
}
+11
View File
@@ -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;
+74
View File
@@ -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;
}
}