2026-05-13 00:32:27 +07:00
|
|
|
import express from "express";
|
|
|
|
|
import http from "http";
|
2026-05-13 02:30:09 +07:00
|
|
|
import { WebSocketServer } from "ws";
|
2026-05-13 00:32:27 +07:00
|
|
|
import path from "path";
|
2026-05-13 01:12:11 +07:00
|
|
|
import prism from "prism-media";
|
2026-05-13 02:30:09 +07:00
|
|
|
import { discordPlayer } from "./player";
|
|
|
|
|
|
|
|
|
|
const activeUsers = new Map<string, { username: string, avatar: string, speaking: boolean }>();
|
|
|
|
|
let wsClients = new Set<any>();
|
|
|
|
|
|
|
|
|
|
// --- 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;
|
|
|
|
|
}
|
2026-05-13 00:32:27 +07:00
|
|
|
|
|
|
|
|
export function startWebserver(port: number = 3000) {
|
|
|
|
|
const app = express();
|
|
|
|
|
const server = http.createServer(app);
|
|
|
|
|
|
2026-05-13 02:30:09 +07:00
|
|
|
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}`);
|
2026-05-13 00:32:27 +07:00
|
|
|
|
|
|
|
|
app.use(express.static(path.join(__dirname, "../public")));
|
|
|
|
|
|
2026-05-13 02:30:09 +07:00
|
|
|
// --- 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);
|
2026-05-13 00:32:27 +07:00
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-13 02:30:09 +07:00
|
|
|
(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);
|
|
|
|
|
|
2026-05-13 00:32:27 +07:00
|
|
|
wss.on("connection", (ws) => {
|
2026-05-13 02:30:09 +07:00
|
|
|
console.log("[webserver] New WebSocket connection on port " + wsPort);
|
|
|
|
|
wsClients.add(ws);
|
2026-05-13 00:32:27 +07:00
|
|
|
|
2026-05-13 02:30:09 +07:00
|
|
|
ws.send(JSON.stringify({
|
|
|
|
|
type: "user_state",
|
|
|
|
|
users: Array.from(activeUsers.entries()).map(([id, data]) => ({ id, ...data }))
|
|
|
|
|
}));
|
2026-05-13 00:32:27 +07:00
|
|
|
|
2026-05-13 02:30:09 +07:00
|
|
|
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);
|
|
|
|
|
}
|
2026-05-13 00:32:27 +07:00
|
|
|
});
|
|
|
|
|
|
2026-05-13 02:30:09 +07:00
|
|
|
ws.on("close", () => { wsClients.delete(ws); });
|
|
|
|
|
ws.on("error", () => { wsClients.delete(ws); });
|
2026-05-13 00:32:27 +07:00
|
|
|
});
|
|
|
|
|
|
2026-05-13 02:30:09 +07:00
|
|
|
server.listen(port, "0.0.0.0", () => {
|
|
|
|
|
console.log(`[webserver] Web interface listening on http://0.0.0.0:${port}`);
|
2026-05-13 00:32:27 +07:00
|
|
|
});
|
|
|
|
|
}
|