From ad7dcde47c86e1b238174f2b55ca6a083ffdc0c6 Mon Sep 17 00:00:00 2001 From: baharsah Date: Wed, 13 May 2026 02:30:09 +0700 Subject: [PATCH] feat: redesign UI and overhaul audio processing for Discord Gateway v4 --- bun.lockb | Bin 101886 -> 101862 bytes package.json | 1 - public/index.html | 417 ++++++++++++++++++++++++++-------------------- src/index.ts | 2 + src/player.ts | 8 +- src/recorder.ts | 94 ++++++----- src/webserver.ts | 171 +++++++++++-------- 7 files changed, 396 insertions(+), 297 deletions(-) diff --git a/bun.lockb b/bun.lockb index 731ac5b83050cccf4bf6b82f3dcf45b14a8cd499..48c629dd4146429bc5af34663f68bc90b19101de 100755 GIT binary patch delta 10875 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 diff --git a/package.json b/package.json index 1c4b798..b7bfd2d 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,6 @@ "fluent-ffmpeg": "^2.1.3", "libsodium-wrappers": "^0.8.2", "node-crc": "^4.0.0", - "opusscript": "^0.1.1", "prism-media": "2.0.0-alpha.0", "sodium-native": "^4.3.2", "ws": "^8.20.1" diff --git a/public/index.html b/public/index.html index 11b154f..c28bc13 100644 --- a/public/index.html +++ b/public/index.html @@ -3,180 +3,160 @@ - Discord Audio Transmitter + Discord Audio Gateway v4 + -
-

Audio Transmitter

-

Transmit your microphone to Discord Voice

- -
-
- Disconnected +
+

Discord Gateway v4

+

Optimized 24kHz Mono Bridge

+ +
+
+
+ Connecting... +
+ +
- - -
-

Listen to Discord

- - -

Click button to listen

+
+ Speaker Off +
-
- +
+

Participants

+
diff --git a/src/index.ts b/src/index.ts index adc6dfe..38990c4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,6 @@ import "./mock-crc"; +import "libsodium-wrappers"; +import "@snazzah/davey"; import { Client } from "discord.js-selfbot-v13"; import { startRecording } from "./recorder"; import { config } from "./config"; diff --git a/src/player.ts b/src/player.ts index a28856b..ad8a84c 100644 --- a/src/player.ts +++ b/src/player.ts @@ -33,13 +33,9 @@ export class DiscordPlayer { public playStream(stream: Readable) { console.log("[player] Starting new audio stream..."); - // Use WebmDemuxer to extract Opus packets from browser stream - const demuxer = new prism.opus.WebmDemuxer(); - demuxer.on('error', err => console.error("[player] Demuxer error:", err)); - - const resource = createAudioResource(stream.pipe(demuxer), { - inputType: StreamType.Opus, + const resource = createAudioResource(stream, { + inputType: StreamType.OggOpus, }); this.player.play(resource); diff --git a/src/recorder.ts b/src/recorder.ts index 0dd3f04..d21e01c 100644 --- a/src/recorder.ts +++ b/src/recorder.ts @@ -63,20 +63,22 @@ export async function startRecording(client: Client, channel: VoiceChannel): Pro // Dengarkan siapapun yang mulai bicara receiver.speaking.on("start", async (userId) => { - if (config.verbose) { - // console.log(`[recorder-debug] Speaking 'start' event triggered for userId: ${userId}. Subscriptions has? ${receiver.subscriptions.has(userId)}`); + // Coba ambil data user dari cache atau fetch dari API + const user = client.users.cache.get(userId) || await client.users.fetch(userId).catch(() => null); + const username = user?.username ?? "Unknown User"; + const avatar = user?.displayAvatarURL({ format: 'png', size: 64 }) ?? "https://cdn.discordapp.com/embed/avatars/0.png"; + + // Tampilkan format "nama user [voice activity]" + console.log(`${username} [voice activity]`); + + // Notify webserver + if ((global as any).updateActiveUser) { + (global as any).updateActiveUser(userId, { username, avatar, speaking: true }); } // Jangan record kalau sudah ada stream aktif untuk user ini if (receiver.subscriptions.has(userId)) return; - // Coba ambil data user dari cache atau fetch dari API - const user = client.users.cache.get(userId) || await client.users.fetch(userId).catch(() => null); - const username = user?.username ?? "Unknown User"; - - // Tampilkan format "nama user [voice activity]" - console.log(`${username} [voice activity]`); - const timestamp = Date.now(); const userDir = path.join(recordingsDir, userId); if (!fs.existsSync(userDir)) { @@ -88,38 +90,60 @@ export async function startRecording(client: Client, channel: VoiceChannel): Pro const audioStream = receiver.subscribe(userId, { end: { behavior: EndBehaviorType.AfterSilence, - duration: 1000, // Stop 1 detik setelah user diam + duration: 3000, // 3 seconds — avoids FFmpeg restart overhead between utterances }, }); try { - const packetFilter = new PacketFilter(10); - + // --- OGG file recording (unchanged) --- + const packetFilterForOgg = new PacketFilter(8); const oggStream = new prism.opus.OggLogicalBitstream({ - opusHead: new prism.opus.OpusHead({ - channelCount: 2, - sampleRate: 48000, - }), - pageSizeControl: { - maxPackets: 10, - }, - crc: true, // Use our mock node-crc + opusHead: new prism.opus.OpusHead({ channelCount: 2, sampleRate: 48000 }), + pageSizeControl: { maxPackets: 10 }, + crc: true, }); const out = fs.createWriteStream(filename); + audioStream.pipe(packetFilterForOgg).pipe(oggStream).pipe(out); - // Pipe: audioStream -> packetFilter -> oggStream -> out - audioStream.pipe(packetFilter).pipe(oggStream).pipe(out); + // --- Web broadcast: pure JS Opus → PCM, no FFmpeg --- + // Create a fresh decoder for each user session + const opusDecoder = new prism.opus.Decoder({ frameSize: 960, channels: 2, rate: 48000 }); - // Forward raw Opus packets to the web shared Ogg stream - packetFilter.on('data', (chunk) => { - if ((global as any).broadcastOpusToWeb) { - (global as any).broadcastOpusToWeb(chunk); + // CRITICAL: Swallow decode errors (DAVE/bad packets) without crashing + opusDecoder.on('error', () => {}); + + // Downsample 48kHz stereo → 24kHz mono (take left channel, every 2nd sample) + opusDecoder.on('data', (pcm: Buffer) => { + if (!(global as any).broadcastPcmToWeb) return; + // Input: 48kHz stereo s16le → 4 bytes per sample-pair + // Output: 24kHz mono s16le → 2 bytes per sample + const outBuf = Buffer.alloc(pcm.length / 4); + for (let i = 0; i < outBuf.length / 2; i++) { + outBuf.writeInt16LE(pcm.readInt16LE(i * 8), i * 2); + } + (global as any).broadcastPcmToWeb(outBuf, userId); + }); + + // Feed Opus packets one-by-one; catch per-packet decode errors + let packetCount = 0; + audioStream.on('data', (chunk: Buffer) => { + packetCount++; + if (packetCount <= 5) { + console.log(`[recorder] Pkt #${packetCount} from ${userId}: ${chunk.length}b | 0x${chunk.slice(0,4).toString('hex')}`); + } + if (chunk.length < 8) return; // skip tiny control packets + try { + opusDecoder.write(chunk); + } catch (_) {} // per-packet isolation — don't let one bad packet stop the stream + }); + + audioStream.on('end', () => { + opusDecoder.end(); + if ((global as any).updateActiveUser) { + (global as any).updateActiveUser(userId, { username, avatar, speaking: false }); } }); - if (config.verbose) { - console.log(`[recorder] Recording user ${userId} → ${filename}`); - } out.on('finish', async () => { if (config.verbose) { @@ -145,17 +169,9 @@ export async function startRecording(client: Client, channel: VoiceChannel): Pro audioStream.on('error', (err) => { console.error(`[recorder] Audio Stream error ${userId}:`, err.message); }); - - audioStream.on('data', (chunk) => { - if (config.verbose) { - console.log(`[recorder-debug] Received audio packet from ${userId}, size: ${chunk.length} bytes`); - } + packetFilterForOgg.on('error', (err) => { + console.error(`[recorder] PacketFilter(ogg) error ${userId}:`, err.message); }); - - packetFilter.on('error', (err) => { - console.error(`[recorder] Packet Filter error ${userId}:`, err.message); - }); - out.on('error', (err) => { console.error(`[recorder] File write error ${userId}:`, err.message); }); diff --git a/src/webserver.ts b/src/webserver.ts index 1d80289..1df9359 100644 --- a/src/webserver.ts +++ b/src/webserver.ts @@ -1,92 +1,127 @@ import express from "express"; -import { WebSocketServer } from "ws"; import http from "http"; +import { WebSocketServer } from "ws"; import path from "path"; -import { PassThrough } from "stream"; -import { discordPlayer } from "./player"; import prism from "prism-media"; +import { discordPlayer } from "./player"; + +const activeUsers = new Map(); +let wsClients = new Set(); + +// --- Upsampling: 24kHz mono s16le → 48kHz stereo s16le (pure JS, no FFmpeg) --- +// Each input sample is duplicated into 2 stereo pairs to double the sample rate. +function upsample24kMonoTo48kStereo(mono24k: Buffer): Buffer { + const out = Buffer.alloc(mono24k.length * 4); // 2x rate * 2ch = 4x bytes + for (let i = 0; i < mono24k.length / 2; i++) { + const s = mono24k.readInt16LE(i * 2); + out.writeInt16LE(s, i * 8); // t=0 L + out.writeInt16LE(s, i * 8 + 2); // t=0 R + out.writeInt16LE(s, i * 8 + 4); // t=1 L (duplicate for 2x rate) + out.writeInt16LE(s, i * 8 + 6); // t=1 R + } + return out; +} export function startWebserver(port: number = 3000) { const app = express(); const server = http.createServer(app); - const wss = new WebSocketServer({ server }); - const listeners = new Set(); - let headerChunks: Buffer[] = []; - - // Create a single, continuous Ogg stream for all web listeners - const oggStream = new prism.opus.OggLogicalBitstream({ - opusHead: new prism.opus.OpusHead({ - channelCount: 2, - sampleRate: 48000, - }), - pageSizeControl: { - maxPackets: 10, - }, - }); - - // Forward Ogg pages to all connected web listeners - oggStream.on("data", (chunk) => { - // Cache the first 2 chunks (headers) - if (headerChunks.length < 2) { - headerChunks.push(chunk); - } - listeners.forEach(res => res.write(chunk)); - }); - - // Prime the stream with a silent packet to generate headers immediately - // Silent Opus packet (1 frame, 20ms) - const silentPacket = Buffer.from([0xf8, 0xff, 0xfe]); - oggStream.write(silentPacket); + const wsPort = port + 1; + const wss = new WebSocketServer({ port: wsPort, host: "0.0.0.0" }); + console.log(`[webserver] WebSocket server listening on ws://0.0.0.0:${wsPort}`); app.use(express.static(path.join(__dirname, "../public"))); - // Endpoint for receiving (listening) audio from Discord - app.get("/listen", (req, res) => { - res.setHeader("Content-Type", "audio/ogg"); - res.setHeader("Transfer-Encoding", "chunked"); - res.setHeader("Connection", "keep-alive"); - - // Send cached headers immediately so the browser recognizes the stream - headerChunks.forEach(chunk => res.write(chunk)); - - listeners.add(res); - console.log(`[webserver] New listener connected. Total: ${listeners.size}`); - - req.on("close", () => { - listeners.delete(res); - console.log(`[webserver] Listener disconnected. Total: ${listeners.size}`); + // --- Inbound: Discord PCM → tagged chunks → browser (set in recorder.ts) --- + (global as any).broadcastPcmToWeb = (chunk: Buffer, userId: string) => { + let hash = 0; + for (let i = 0; i < userId.length; i++) { + hash = ((hash << 5) - hash) + userId.charCodeAt(i); + hash |= 0; + } + const header = Buffer.alloc(4); + header.writeInt32LE(hash, 0); + const packet = Buffer.concat([header, chunk]); + wsClients.forEach(client => { + if (client.readyState === 1) client.send(packet); }); - }); - - // Function to broadcast raw Opus packets from Discord to the shared Ogg stream - (global as any).broadcastOpusToWeb = (chunk: Buffer) => { - oggStream.write(chunk); }; + (global as any).updateActiveUser = (userId: string, data: { username: string, avatar: string, speaking: boolean }) => { + activeUsers.set(userId, data); + broadcastUserState(); + }; + + function broadcastUserState() { + const payload = JSON.stringify({ + type: "user_state", + users: Array.from(activeUsers.entries()).map(([id, data]) => ({ id, ...data })) + }); + wsClients.forEach(client => { + if (client.readyState === 1) client.send(payload); + }); + } + + // --- Outbound: browser PCM (24kHz mono) → Opus → Discord, NO FFmpeg --- + const RATE = 48000; + const CHANNELS = 2; + const FRAME_SIZE = 960; // 20ms @ 48kHz + const BYTES_PER_FRAME = FRAME_SIZE * CHANNELS * 2; // 3840 bytes + + const opusEncoder = new prism.opus.Encoder({ rate: RATE, channels: CHANNELS, frameSize: FRAME_SIZE }); + const oggBitstream = new prism.opus.OggLogicalBitstream({ + opusHead: new prism.opus.OpusHead({ channelCount: CHANNELS, sampleRate: RATE }), + pageSizeControl: { maxPackets: 10 }, + crc: true, + }); + opusEncoder.on('error', () => {}); + + opusEncoder.pipe(oggBitstream); + // Prime the encoder immediately so OGG headers are emitted before player reads + opusEncoder.write(Buffer.alloc(BYTES_PER_FRAME, 0)); + discordPlayer.playStream(oggBitstream); + + let pcmBuffer = Buffer.alloc(0); + let lastBrowserAudioTime = 0; + const SILENCE_FRAME = Buffer.alloc(BYTES_PER_FRAME, 0); + + // Keep encoder alive with silence when browser isn't sending + setInterval(() => { + if (Date.now() - lastBrowserAudioTime > 40) { + opusEncoder.write(SILENCE_FRAME); + } + }, 20); + wss.on("connection", (ws) => { - console.log("[webserver] New WebSocket connection"); + console.log("[webserver] New WebSocket connection on port " + wsPort); + wsClients.add(ws); - const audioStream = new PassThrough(); - discordPlayer.playStream(audioStream); + ws.send(JSON.stringify({ + type: "user_state", + users: Array.from(activeUsers.entries()).map(([id, data]) => ({ id, ...data })) + })); - ws.on("message", (data: Buffer) => { - // console.log(`[webserver] Received chunk: ${data.length} bytes`); - audioStream.write(data); + ws.on("message", (data: any) => { + if (!Buffer.isBuffer(data)) return; + lastBrowserAudioTime = Date.now(); + + // Upsample browser 24kHz mono → 48kHz stereo + const upsampled = upsample24kMonoTo48kStereo(data); + pcmBuffer = Buffer.concat([pcmBuffer, upsampled]); + + // Encode complete Opus frames + while (pcmBuffer.length >= BYTES_PER_FRAME) { + const frame = pcmBuffer.slice(0, BYTES_PER_FRAME); + pcmBuffer = pcmBuffer.slice(BYTES_PER_FRAME); + opusEncoder.write(frame); + } }); - ws.on("close", () => { - console.log("[webserver] WebSocket connection closed"); - audioStream.end(); - }); - - ws.on("error", (err) => { - console.error("[webserver] WebSocket error:", err); - audioStream.end(); - }); + ws.on("close", () => { wsClients.delete(ws); }); + ws.on("error", () => { wsClients.delete(ws); }); }); - server.listen(port, () => { - console.log(`[webserver] Server listening on http://localhost:${port}`); + server.listen(port, "0.0.0.0", () => { + console.log(`[webserver] Web interface listening on http://0.0.0.0:${port}`); }); }