init
This commit is contained in:
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules
|
||||||
|
recordings
|
||||||
|
.env
|
||||||
|
|
||||||
103
README.md
Normal file
103
README.md
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
# 🎙️ Discord Voice Recorder Bot
|
||||||
|
|
||||||
|
Bot Discord yang **otomatis join ke voice channel** saat startup dan **merekam suara** semua pengguna yang bicara. File audio disimpan secara lokal dalam format `.ogg`.
|
||||||
|
|
||||||
|
Dibangun dengan **Bun** + **discord.js** + **@discordjs/voice**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 Prasyarat
|
||||||
|
|
||||||
|
- [Bun](https://bun.sh) >= 1.0
|
||||||
|
- FFmpeg (untuk encoding audio)
|
||||||
|
```bash
|
||||||
|
# Ubuntu/Debian
|
||||||
|
sudo apt install ffmpeg
|
||||||
|
|
||||||
|
# Arch
|
||||||
|
sudo pacman -S ffmpeg
|
||||||
|
```
|
||||||
|
- Discord Bot dengan permission:
|
||||||
|
- `Connect` (join voice channel)
|
||||||
|
- `Use Voice Activity`
|
||||||
|
- `Read Messages/View Channels`
|
||||||
|
- Privileged Intents: **Server Members Intent** (aktifkan di Developer Portal)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚙️ Setup
|
||||||
|
|
||||||
|
### 1. Clone & Install
|
||||||
|
```bash
|
||||||
|
cd /path/to/bot
|
||||||
|
bun install
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Konfigurasi `.env`
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Edit `.env`:
|
||||||
|
```env
|
||||||
|
DISCORD_TOKEN=your_bot_token_here
|
||||||
|
VOICE_CHANNEL_ID=your_voice_channel_id_here
|
||||||
|
GUILD_ID=your_guild_id_here
|
||||||
|
RECORDINGS_DIR=./recordings
|
||||||
|
```
|
||||||
|
|
||||||
|
**Cara mendapatkan ID:**
|
||||||
|
- Aktifkan **Developer Mode** di Discord (Settings → Advanced → Developer Mode)
|
||||||
|
- Klik kanan pada voice channel → **Copy Channel ID** → paste ke `VOICE_CHANNEL_ID`
|
||||||
|
- Klik kanan pada server/guild → **Copy Server ID** → paste ke `GUILD_ID`
|
||||||
|
- Token bot dari [Discord Developer Portal](https://discord.com/developers/applications) → Bot → Reset Token
|
||||||
|
|
||||||
|
### 3. Invite Bot ke Server
|
||||||
|
Di Developer Portal → OAuth2 → URL Generator:
|
||||||
|
- Scopes: `bot`
|
||||||
|
- Bot Permissions: `Connect`, `Use Voice Activity`, `View Channels`
|
||||||
|
|
||||||
|
Copy URL, buka di browser, pilih server.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Menjalankan Bot
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Development (auto-restart saat file berubah)
|
||||||
|
bun run dev
|
||||||
|
|
||||||
|
# Production
|
||||||
|
bun run start
|
||||||
|
```
|
||||||
|
|
||||||
|
Bot akan langsung join ke voice channel yang ditentukan dalam `.env`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📁 Struktur File Rekaman
|
||||||
|
|
||||||
|
```
|
||||||
|
recordings/
|
||||||
|
├── 123456789-1709900000000.ogg # <user-id>-<timestamp>.ogg
|
||||||
|
├── 987654321-1709900001234.ogg
|
||||||
|
└── ...
|
||||||
|
```
|
||||||
|
|
||||||
|
Setiap kali user bicara dan berhenti (>1 detik diam), satu file `.ogg` baru dibuat.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📁 Struktur Proyek
|
||||||
|
|
||||||
|
```
|
||||||
|
bot/
|
||||||
|
├── src/
|
||||||
|
│ ├── index.ts # Entry point — login & auto-join
|
||||||
|
│ └── recorder.ts # Core recording logic
|
||||||
|
├── recordings/ # File audio tersimpan (otomatis dibuat)
|
||||||
|
├── .env # Konfigurasi (buat dari .env.example)
|
||||||
|
├── .env.example
|
||||||
|
├── package.json
|
||||||
|
└── tsconfig.json
|
||||||
|
```
|
||||||
123108
index.js
Executable file
123108
index.js
Executable file
File diff suppressed because one or more lines are too long
1532
package-lock.json
generated
Normal file
1532
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
29
package.json
Normal file
29
package.json
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"name": "discord-voice-recorder",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Discord bot that joins a voice channel and records audio",
|
||||||
|
"main": "src/index.ts",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "bun --watch src/index.ts",
|
||||||
|
"start": "bun src/index.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@discordjs/opus": "^0.10.0",
|
||||||
|
"@discordjs/voice": "^0.19.1",
|
||||||
|
"@ffmpeg-installer/ffmpeg": "^1.1.0",
|
||||||
|
"@snazzah/davey": "^0.1.10",
|
||||||
|
"crc-32": "^1.2.2",
|
||||||
|
"discord.js-selfbot-v13": "^3.7.1",
|
||||||
|
"ffmpeg-static": "^5.3.0",
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bun": "latest",
|
||||||
|
"@types/fluent-ffmpeg": "^2.1.28"
|
||||||
|
}
|
||||||
|
}
|
||||||
4
src/config.ts
Normal file
4
src/config.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
// Configuration for the bot
|
||||||
|
export const config = {
|
||||||
|
verbose: process.argv.includes('-v') || process.argv.includes('--verbose'),
|
||||||
|
};
|
||||||
68
src/index.ts
Normal file
68
src/index.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { Client } from "discord.js-selfbot-v13";
|
||||||
|
import { startRecording } from "./recorder";
|
||||||
|
import { config } from "./config";
|
||||||
|
|
||||||
|
// Validasi environment variables
|
||||||
|
const token = process.env.DISCORD_TOKEN;
|
||||||
|
const voiceChannelId = process.env.VOICE_CHANNEL_ID;
|
||||||
|
const guildId = process.env.GUILD_ID;
|
||||||
|
|
||||||
|
if (!token) throw new Error("Missing DISCORD_TOKEN in .env");
|
||||||
|
if (!voiceChannelId) throw new Error("Missing VOICE_CHANNEL_ID in .env");
|
||||||
|
if (!guildId) throw new Error("Missing GUILD_ID in .env");
|
||||||
|
|
||||||
|
// Inisialisasi selfbot client (gunakan checkUpdate: false supaya tidak ada prompt update)
|
||||||
|
const client = new Client();
|
||||||
|
|
||||||
|
client.on("ready", async () => {
|
||||||
|
if (config.verbose) {
|
||||||
|
console.log(`[bot] Logged in as ${client.user!.tag}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ambil guild
|
||||||
|
const guild = client.guilds.cache.get(guildId!);
|
||||||
|
if (!guild) {
|
||||||
|
console.error(`[bot] Guild not found: ${guildId}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch channels jika belum ada di cache
|
||||||
|
const channel =
|
||||||
|
guild.channels.cache.get(voiceChannelId!) ??
|
||||||
|
(await guild.channels.fetch(voiceChannelId!).catch(() => null));
|
||||||
|
|
||||||
|
if (!channel || channel.type !== "GUILD_VOICE") {
|
||||||
|
console.error(
|
||||||
|
`[bot] Voice channel not found or wrong type: ${voiceChannelId}`
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.verbose) {
|
||||||
|
console.log(`[bot] Joining voice channel: #${channel.name} (${channel.id})`);
|
||||||
|
}
|
||||||
|
await startRecording(client, channel as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
client.on("error", (err) => {
|
||||||
|
console.error("[bot] Client error:", err);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Graceful shutdown
|
||||||
|
process.on("SIGINT", () => {
|
||||||
|
if (config.verbose) {
|
||||||
|
console.log("\n[bot] Shutting down...");
|
||||||
|
}
|
||||||
|
client.destroy();
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on("SIGTERM", () => {
|
||||||
|
if (config.verbose) {
|
||||||
|
console.log("[bot] Terminating...");
|
||||||
|
}
|
||||||
|
client.destroy();
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
client.login(token);
|
||||||
225
src/muxer-aup3.ts
Normal file
225
src/muxer-aup3.ts
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
|
import ffmpeg from "fluent-ffmpeg";
|
||||||
|
|
||||||
|
const recordingsDir = process.env.RECORDINGS_DIR ?? "./recordings";
|
||||||
|
|
||||||
|
interface EventMetadata {
|
||||||
|
userId: string;
|
||||||
|
username: string;
|
||||||
|
tag: string;
|
||||||
|
startTime: number;
|
||||||
|
endTime: number;
|
||||||
|
durationMs: number;
|
||||||
|
filename: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ClipInfo {
|
||||||
|
oggPath: string;
|
||||||
|
jsonPath: string;
|
||||||
|
meta: EventMetadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startMuxingToAup3() {
|
||||||
|
console.log("[muxer-aup3] Scanning recordings directory...");
|
||||||
|
if (!fs.existsSync(recordingsDir)) {
|
||||||
|
console.error("[muxer-aup3] Recordings directory not found.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const clips: ClipInfo[] = [];
|
||||||
|
|
||||||
|
// Scan user directories
|
||||||
|
const items = fs.readdirSync(recordingsDir);
|
||||||
|
console.log(`[muxer-aup3] Found ${items.length} directories to scan...`);
|
||||||
|
|
||||||
|
let processedDirs = 0;
|
||||||
|
for (const item of items) {
|
||||||
|
const itemPath = path.join(recordingsDir, item);
|
||||||
|
if (fs.statSync(itemPath).isDirectory()) {
|
||||||
|
const files = fs.readdirSync(itemPath);
|
||||||
|
for (const file of files) {
|
||||||
|
if (file.endsWith(".json")) {
|
||||||
|
const jsonPath = path.join(itemPath, file);
|
||||||
|
const oggPath = jsonPath.replace(/\.json$/, ".ogg");
|
||||||
|
if (fs.existsSync(oggPath)) {
|
||||||
|
try {
|
||||||
|
// Check if OGG file is valid (not empty and has reasonable size)
|
||||||
|
const oggStats = fs.statSync(oggPath);
|
||||||
|
if (oggStats.size === 0) {
|
||||||
|
console.warn(`[muxer-aup3] Skipping empty OGG file: ${oggPath}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip files that are too small (less than 1KB likely corrupted)
|
||||||
|
if (oggStats.size < 1024) {
|
||||||
|
console.warn(`[muxer-aup3] Skipping too small OGG file (${oggStats.size} bytes): ${oggPath}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if OGG file has valid header (starts with "OggS")
|
||||||
|
const oggBuffer = fs.readFileSync(oggPath);
|
||||||
|
const oggHeader = oggBuffer.slice(0, 4).toString();
|
||||||
|
if (oggHeader !== "OggS") {
|
||||||
|
console.warn(`[muxer-aup3] Skipping invalid OGG file (bad header): ${oggPath}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const meta: EventMetadata = JSON.parse(fs.readFileSync(jsonPath, "utf-8"));
|
||||||
|
clips.push({ oggPath, jsonPath, meta });
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[muxer-aup3] Failed to read/parse JSON: ${jsonPath}`, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
processedDirs++;
|
||||||
|
const progress = ((processedDirs / items.length) * 100).toFixed(2);
|
||||||
|
console.log(`[muxer-aup3] Scanning progress: ${progress}% (${processedDirs}/${items.length} directories)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (clips.length === 0) {
|
||||||
|
console.log("[muxer-aup3] No recording clips found to mux.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by startTime so chronologically they are in order
|
||||||
|
clips.sort((a, b) => a.meta.startTime - b.meta.startTime);
|
||||||
|
|
||||||
|
// Find the global start time
|
||||||
|
const globalStartTime = clips[0].meta.startTime;
|
||||||
|
console.log(`[muxer-aup3] Found ${clips.length} clips. Base timestamp: ${globalStartTime}`);
|
||||||
|
|
||||||
|
const command = ffmpeg();
|
||||||
|
const filterParts: string[] = [];
|
||||||
|
|
||||||
|
console.log(`[muxer-aup3] Creating audio filters for ${clips.length} clips...`);
|
||||||
|
clips.forEach((clip, index) => {
|
||||||
|
command.input(clip.oggPath);
|
||||||
|
|
||||||
|
// Calculate delay relative to the global start time
|
||||||
|
const delayMs = clip.meta.startTime - globalStartTime;
|
||||||
|
|
||||||
|
// FFmpeg filter structure: [0:a]adelay=1000|1000[a0]
|
||||||
|
// Setting adelay multiple times covers stereo channels.
|
||||||
|
// We ensure all multiple channels get delayed.
|
||||||
|
const inputSpecifier = `[${index}:a]`;
|
||||||
|
const outputSpecifier = `[pad${index}]`;
|
||||||
|
|
||||||
|
filterParts.push(`${inputSpecifier}adelay=${delayMs}|${delayMs}${outputSpecifier}`);
|
||||||
|
|
||||||
|
const progress = (((index + 1) / clips.length) * 100).toFixed(2);
|
||||||
|
console.log(`[muxer-aup3] Filter creation progress: ${progress}% (${index + 1}/${clips.length} clips)`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Merge them using amix
|
||||||
|
const amixInputs = clips.map((_, i) => `[pad${i}]`).join("");
|
||||||
|
// We add the amix command. dropout_transition=0 avoids volume drop when streams end.
|
||||||
|
filterParts.push(`${amixInputs}amix=inputs=${clips.length}:dropout_transition=0[out]`);
|
||||||
|
|
||||||
|
const timestamp = Date.now();
|
||||||
|
const wavFilename = path.join(recordingsDir, `muxed-${timestamp}.wav`);
|
||||||
|
const aup3Filename = path.join(recordingsDir, `muxed-${timestamp}.aup3`);
|
||||||
|
|
||||||
|
console.log(`[muxer-aup3] Combining clips to WAV. This might take a while...`);
|
||||||
|
|
||||||
|
// Using fluent-ffmpeg's complexFilter
|
||||||
|
command
|
||||||
|
.complexFilter(filterParts, "out")
|
||||||
|
.audioCodec("pcm_s16le")
|
||||||
|
.audioFrequency(44100)
|
||||||
|
.audioChannels(2)
|
||||||
|
.save(wavFilename)
|
||||||
|
.on("progress", (progress) => {
|
||||||
|
if (progress.percent) {
|
||||||
|
console.log(`[muxer-aup3] WAV Progress: ${progress.percent.toFixed(2)}%`);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.on("end", () => {
|
||||||
|
console.log(`[muxer-aup3] WAV file created: ${wavFilename}`);
|
||||||
|
console.log(`[muxer-aup3] Creating AUP3 project file...`);
|
||||||
|
createAup3Project(wavFilename, aup3Filename, clips, globalStartTime);
|
||||||
|
})
|
||||||
|
.on("error", (err) => {
|
||||||
|
console.error(`[muxer-aup3] FFmpeg Error:`, err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createAup3Project(wavFilename: string, aup3Filename: string, clips: ClipInfo[], globalStartTime: number) {
|
||||||
|
try {
|
||||||
|
console.log(`[muxer-aup3] AUP3 Progress: Reading WAV file...`);
|
||||||
|
|
||||||
|
// Read WAV file to get duration
|
||||||
|
const wavStats = fs.statSync(wavFilename);
|
||||||
|
const wavSize = wavStats.size;
|
||||||
|
|
||||||
|
// Calculate approximate duration (assuming 44.1kHz, 16-bit, stereo)
|
||||||
|
// Duration = (file_size - 44) / (44100 * 2 * 2) for WAV
|
||||||
|
const duration = (wavSize - 44) / (44100 * 4);
|
||||||
|
|
||||||
|
console.log(`[muxer-aup3] AUP3 Progress: Calculating duration... ${duration.toFixed(2)}s`);
|
||||||
|
console.log(`[muxer-aup3] AUP3 Progress: Creating XML structure...`);
|
||||||
|
|
||||||
|
// Create AUP3 project XML structure
|
||||||
|
const aup3Content = `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<audacityproject xmlns="http://audacity.sourceforge.net/xml/" projname="muxed" version="1.3.0" audacityversion="3.5.1">
|
||||||
|
<tags>
|
||||||
|
<tag name="GENRE" value=""/>
|
||||||
|
<tag name="ARTIST" value=""/>
|
||||||
|
<tag name="ALBUM" value=""/>
|
||||||
|
<tag name="TRACKNUMBER" value=""/>
|
||||||
|
<tag name="YEAR" value=""/>
|
||||||
|
<tag name="TITLE" value="Muxed Recording"/>
|
||||||
|
</tags>
|
||||||
|
<wavetrack name="Muxed Audio" channel="2" linked="0" mute="0" solo="0" height="150" minimized="0" isSelected="0" rate="44100">
|
||||||
|
<waveclip offset="0.0">
|
||||||
|
<sequence maxsamples="262144" sampleformat="262159" numsamples="${Math.floor(duration * 44100)}">
|
||||||
|
<waveblock start="0">
|
||||||
|
<simpleblockfile filename="${path.basename(wavFilename)}" len="${Math.floor(duration * 44100)}" min="-1.0" max="1.0" rms="0.1"/>
|
||||||
|
</waveblock>
|
||||||
|
</sequence>
|
||||||
|
<envelope numpoints="0"/>
|
||||||
|
</waveclip>
|
||||||
|
</wavetrack>
|
||||||
|
<timetrack name="Time Track" height="150" minimized="0" isSelected="0">
|
||||||
|
<envelope numpoints="0"/>
|
||||||
|
</timetrack>
|
||||||
|
</audacityproject>`;
|
||||||
|
|
||||||
|
console.log(`[muxer-aup3] AUP3 Progress: Writing AUP3 file...`);
|
||||||
|
|
||||||
|
// Write AUP3 file
|
||||||
|
fs.writeFileSync(aup3Filename, aup3Content, "utf-8");
|
||||||
|
|
||||||
|
console.log(`[muxer-aup3] AUP3 Progress: Creating clip info file...`);
|
||||||
|
|
||||||
|
// Create a simple info file with clip details
|
||||||
|
const infoFilename = aup3Filename.replace('.aup3', '-info.txt');
|
||||||
|
const infoContent = clips.map((clip, index) => {
|
||||||
|
const delayMs = clip.meta.startTime - globalStartTime;
|
||||||
|
return `Clip ${index + 1}:
|
||||||
|
User: ${clip.meta.username} (${clip.meta.userId})
|
||||||
|
Tag: ${clip.meta.tag}
|
||||||
|
Start Time: ${new Date(clip.meta.startTime).toISOString()}
|
||||||
|
Delay: ${delayMs}ms
|
||||||
|
Duration: ${clip.meta.durationMs}ms
|
||||||
|
File: ${path.basename(clip.oggPath)}`;
|
||||||
|
}).join('\n\n');
|
||||||
|
|
||||||
|
fs.writeFileSync(infoFilename, infoContent, "utf-8");
|
||||||
|
|
||||||
|
console.log(`[muxer-aup3] AUP3 Progress: 100% - Complete!`);
|
||||||
|
console.log(`[muxer-aup3] Successfully created AUP3 project!`);
|
||||||
|
console.log(`[muxer-aup3] WAV file: ${wavFilename}`);
|
||||||
|
console.log(`[muxer-aup3] AUP3 file: ${aup3Filename}`);
|
||||||
|
console.log(`[muxer-aup3] Clip info saved to: ${infoFilename}`);
|
||||||
|
console.log(`[muxer-aup3] Total clips processed: ${clips.length}`);
|
||||||
|
console.log(`[muxer-aup3] Duration: ${duration.toFixed(2)} seconds`);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[muxer-aup3] Error creating AUP3 project:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
startMuxingToAup3();
|
||||||
143
src/muxer.ts
Normal file
143
src/muxer.ts
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
|
import ffmpeg from "fluent-ffmpeg";
|
||||||
|
|
||||||
|
const recordingsDir = process.env.RECORDINGS_DIR ?? "./recordings";
|
||||||
|
|
||||||
|
interface EventMetadata {
|
||||||
|
userId: string;
|
||||||
|
username: string;
|
||||||
|
tag: string;
|
||||||
|
startTime: number;
|
||||||
|
endTime: number;
|
||||||
|
durationMs: number;
|
||||||
|
filename: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ClipInfo {
|
||||||
|
oggPath: string;
|
||||||
|
jsonPath: string;
|
||||||
|
meta: EventMetadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startMuxing() {
|
||||||
|
console.log("[muxer] Scanning recordings directory...");
|
||||||
|
if (!fs.existsSync(recordingsDir)) {
|
||||||
|
console.error("[muxer] Recordings directory not found.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const clips: ClipInfo[] = [];
|
||||||
|
|
||||||
|
// Scan user directories
|
||||||
|
const items = fs.readdirSync(recordingsDir);
|
||||||
|
console.log(`[muxer] Found ${items.length} directories to scan...`);
|
||||||
|
|
||||||
|
let processedDirs = 0;
|
||||||
|
for (const item of items) {
|
||||||
|
const itemPath = path.join(recordingsDir, item);
|
||||||
|
if (fs.statSync(itemPath).isDirectory()) {
|
||||||
|
const files = fs.readdirSync(itemPath);
|
||||||
|
for (const file of files) {
|
||||||
|
if (file.endsWith(".json")) {
|
||||||
|
const jsonPath = path.join(itemPath, file);
|
||||||
|
const oggPath = jsonPath.replace(/\.json$/, ".ogg");
|
||||||
|
if (fs.existsSync(oggPath)) {
|
||||||
|
try {
|
||||||
|
// Check if OGG file is valid (not empty and has reasonable size)
|
||||||
|
const oggStats = fs.statSync(oggPath);
|
||||||
|
if (oggStats.size === 0) {
|
||||||
|
console.warn(`[muxer] Skipping empty OGG file: ${oggPath}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip files that are too small (less than 1KB likely corrupted)
|
||||||
|
if (oggStats.size < 1024) {
|
||||||
|
console.warn(`[muxer] Skipping too small OGG file (${oggStats.size} bytes): ${oggPath}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if OGG file has valid header (starts with "OggS")
|
||||||
|
const oggBuffer = fs.readFileSync(oggPath);
|
||||||
|
const oggHeader = oggBuffer.slice(0, 4).toString();
|
||||||
|
if (oggHeader !== "OggS") {
|
||||||
|
console.warn(`[muxer] Skipping invalid OGG file (bad header): ${oggPath}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const meta: EventMetadata = JSON.parse(fs.readFileSync(jsonPath, "utf-8"));
|
||||||
|
clips.push({ oggPath, jsonPath, meta });
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[muxer] Failed to read/parse JSON: ${jsonPath}`, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
processedDirs++;
|
||||||
|
const progress = ((processedDirs / items.length) * 100).toFixed(2);
|
||||||
|
console.log(`[muxer] Scanning progress: ${progress}% (${processedDirs}/${items.length} directories)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (clips.length === 0) {
|
||||||
|
console.log("[muxer] No recording clips found to mux.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by startTime so chronologically they are in order
|
||||||
|
clips.sort((a, b) => a.meta.startTime - b.meta.startTime);
|
||||||
|
|
||||||
|
// Find the global start time
|
||||||
|
const globalStartTime = clips[0].meta.startTime;
|
||||||
|
console.log(`[muxer] Found ${clips.length} clips. Base timestamp: ${globalStartTime}`);
|
||||||
|
|
||||||
|
const command = ffmpeg();
|
||||||
|
const filterParts: string[] = [];
|
||||||
|
|
||||||
|
console.log(`[muxer] Creating audio filters for ${clips.length} clips...`);
|
||||||
|
clips.forEach((clip, index) => {
|
||||||
|
command.input(clip.oggPath);
|
||||||
|
|
||||||
|
// Calculate delay relative to the global start time
|
||||||
|
const delayMs = clip.meta.startTime - globalStartTime;
|
||||||
|
|
||||||
|
// FFmpeg filter structure: [0:a]adelay=1000|1000[a0]
|
||||||
|
// Setting adelay multiple times covers stereo channels.
|
||||||
|
// We ensure all multiple channels get delayed.
|
||||||
|
const inputSpecifier = `[${index}:a]`;
|
||||||
|
const outputSpecifier = `[pad${index}]`;
|
||||||
|
|
||||||
|
filterParts.push(`${inputSpecifier}adelay=${delayMs}|${delayMs}${outputSpecifier}`);
|
||||||
|
|
||||||
|
const progress = (((index + 1) / clips.length) * 100).toFixed(2);
|
||||||
|
console.log(`[muxer] Filter creation progress: ${progress}% (${index + 1}/${clips.length} clips)`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Merge them using amix
|
||||||
|
const amixInputs = clips.map((_, i) => `[pad${i}]`).join("");
|
||||||
|
// We add the amix command. dropout_transition=0 avoids volume drop when streams end.
|
||||||
|
filterParts.push(`${amixInputs}amix=inputs=${clips.length}:dropout_transition=0[out]`);
|
||||||
|
|
||||||
|
const outputFilename = path.join(recordingsDir, `muxed-${Date.now()}.mp3`);
|
||||||
|
|
||||||
|
console.log(`[muxer] Combining clips. This might take a while...`);
|
||||||
|
|
||||||
|
// Using fluent-ffmpeg's complexFilter
|
||||||
|
command
|
||||||
|
.complexFilter(filterParts, "out")
|
||||||
|
.audioCodec("libmp3lame")
|
||||||
|
.save(outputFilename)
|
||||||
|
.on("progress", (progress) => {
|
||||||
|
if (progress.percent) {
|
||||||
|
console.log(`[muxer] Progress: ${progress.percent.toFixed(2)}%`);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.on("end", () => {
|
||||||
|
console.log(`[muxer] Successfully muxed! Output saved to: ${outputFilename}`);
|
||||||
|
})
|
||||||
|
.on("error", (err) => {
|
||||||
|
console.error(`[muxer] FFmpeg Error:`, err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
startMuxing();
|
||||||
37
src/packetFilter.ts
Normal file
37
src/packetFilter.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { Transform, TransformCallback } from "stream";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transform stream untuk memfilter audio packets yang terlalu kecil
|
||||||
|
* Packet yang terlalu kecil kemungkinan gagal didekripsi oleh Discord
|
||||||
|
*/
|
||||||
|
export class PacketFilter extends Transform {
|
||||||
|
private minPacketSize: number;
|
||||||
|
private filteredCount: number = 0;
|
||||||
|
private totalCount: number = 0;
|
||||||
|
|
||||||
|
constructor(minPacketSize: number = 10) {
|
||||||
|
super();
|
||||||
|
this.minPacketSize = minPacketSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
_transform(chunk: Buffer, encoding: string, callback: TransformCallback): void {
|
||||||
|
this.totalCount++;
|
||||||
|
|
||||||
|
// Filter packet yang terlalu kecil
|
||||||
|
if (chunk.length >= this.minPacketSize) {
|
||||||
|
this.push(chunk);
|
||||||
|
} else {
|
||||||
|
this.filteredCount++;
|
||||||
|
if (this.filteredCount % 10 === 0) {
|
||||||
|
// console.log(`[packet-filter] Filtered ${this.filteredCount} small packets (size < ${this.minPacketSize} bytes)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
callback();
|
||||||
|
}
|
||||||
|
|
||||||
|
_flush(callback: TransformCallback): void {
|
||||||
|
// console.log(`[packet-filter] Total packets: ${this.totalCount}, filtered: ${this.filteredCount}, passed: ${this.totalCount - this.filteredCount}`);
|
||||||
|
callback();
|
||||||
|
}
|
||||||
|
}
|
||||||
226
src/recorder.ts
Normal file
226
src/recorder.ts
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
|
import { pipeline } from "stream/promises";
|
||||||
|
import {
|
||||||
|
EndBehaviorType,
|
||||||
|
joinVoiceChannel,
|
||||||
|
VoiceConnectionStatus,
|
||||||
|
entersState,
|
||||||
|
getVoiceConnection,
|
||||||
|
} from "@discordjs/voice";
|
||||||
|
import type { VoiceChannel, Client } from "discord.js-selfbot-v13";
|
||||||
|
import prism from "prism-media";
|
||||||
|
|
||||||
|
import { PacketFilter } from "./packetFilter";
|
||||||
|
import { config } from "./config";
|
||||||
|
const recordingsDir = process.env.RECORDINGS_DIR ?? "./recordings";
|
||||||
|
|
||||||
|
// Pastikan folder recordings ada
|
||||||
|
if (!fs.existsSync(recordingsDir)) {
|
||||||
|
fs.mkdirSync(recordingsDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Join ke voice channel dan mulai merekam semua user yang bicara.
|
||||||
|
*/
|
||||||
|
export async function startRecording(client: Client, channel: VoiceChannel): Promise<void> {
|
||||||
|
const connection = joinVoiceChannel({
|
||||||
|
channelId: channel.id,
|
||||||
|
guildId: channel.guild.id,
|
||||||
|
adapterCreator: channel.guild.voiceAdapterCreator as any,
|
||||||
|
selfDeaf: false,
|
||||||
|
selfMute: true,
|
||||||
|
debug: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (config.verbose) {
|
||||||
|
console.log(`[recorder] Joining voice channel: #${channel.name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
connection.on('debug', msg => {
|
||||||
|
if (config.verbose) {
|
||||||
|
console.log(`[voice-debug] ${msg}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
connection.on('error', err => {
|
||||||
|
console.error(`[voice-error]`, err);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tunggu sampai benar-benar terhubung
|
||||||
|
try {
|
||||||
|
await entersState(connection, VoiceConnectionStatus.Ready, 15_000);
|
||||||
|
if (config.verbose) {
|
||||||
|
console.log("[recorder] Connected to voice channel. Recording started.");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[recorder] Failed to connect:", err);
|
||||||
|
connection.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const receiver = connection.receiver;
|
||||||
|
|
||||||
|
// 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)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)) {
|
||||||
|
fs.mkdirSync(userDir, { recursive: true });
|
||||||
|
}
|
||||||
|
const filename = path.join(userDir, `${timestamp}.ogg`);
|
||||||
|
const jsonFilename = path.join(userDir, `${timestamp}.json`);
|
||||||
|
|
||||||
|
const audioStream = receiver.subscribe(userId, {
|
||||||
|
end: {
|
||||||
|
behavior: EndBehaviorType.AfterSilence,
|
||||||
|
duration: 1000, // Stop 1 detik setelah user diam
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const packetFilter = new PacketFilter(10);
|
||||||
|
// Mock node-crc to provide pure JS implementation and bypass native build issues
|
||||||
|
const CRC_TABLE = new Uint32Array(256);
|
||||||
|
for (let i = 0; i < 256; i++) {
|
||||||
|
let r = i << 24;
|
||||||
|
for (let j = 0; j < 8; j++) {
|
||||||
|
r = (r & 0x80000000) !== 0 ? ((r << 1) ^ 0x04c11db7) : (r << 1);
|
||||||
|
}
|
||||||
|
CRC_TABLE[i] = (r >>> 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const Module = require('module');
|
||||||
|
const originalRequire = Module.prototype.require;
|
||||||
|
Module.prototype.require = function (id: string) {
|
||||||
|
if (id === 'node-crc') {
|
||||||
|
return {
|
||||||
|
crc: function (width: number, reflectIn: boolean, poly: number, init: number, refOut: boolean, xorOut: number, unk1: number, unk2: number, buffer: Buffer) {
|
||||||
|
let crc = 0;
|
||||||
|
for (let i = 0; i < buffer.length; i++) {
|
||||||
|
crc = ((crc << 8) >>> 0) ^ CRC_TABLE[((crc >>> 24) ^ buffer[i]) & 0xff];
|
||||||
|
crc >>>= 0;
|
||||||
|
}
|
||||||
|
const result = Buffer.alloc(4);
|
||||||
|
result.writeUInt32BE(crc, 0);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return originalRequire.apply(this, arguments);
|
||||||
|
};
|
||||||
|
|
||||||
|
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
|
||||||
|
});
|
||||||
|
const out = fs.createWriteStream(filename);
|
||||||
|
|
||||||
|
// Pipe: audioStream -> packetFilter -> oggStream -> out
|
||||||
|
audioStream.pipe(packetFilter).pipe(oggStream).pipe(out);
|
||||||
|
|
||||||
|
if (config.verbose) {
|
||||||
|
console.log(`[recorder] Recording user ${userId} → ${filename}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
out.on('finish', async () => {
|
||||||
|
if (config.verbose) {
|
||||||
|
console.log(`[recorder] Saved: ${filename}`);
|
||||||
|
}
|
||||||
|
const endTime = Date.now();
|
||||||
|
|
||||||
|
const eventMetadata = {
|
||||||
|
userId,
|
||||||
|
username: user?.username ?? "Unknown User",
|
||||||
|
tag: user?.tag ?? "Unknown#0000",
|
||||||
|
startTime: timestamp,
|
||||||
|
endTime,
|
||||||
|
durationMs: endTime - timestamp,
|
||||||
|
filename: path.basename(filename)
|
||||||
|
};
|
||||||
|
fs.writeFileSync(jsonFilename, JSON.stringify(eventMetadata, null, 2));
|
||||||
|
if (config.verbose) {
|
||||||
|
console.log(`[recorder] Saved metadata: ${jsonFilename}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[recorder] Failed to create stream for ${userId}:`, e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle disconnect yang tidak disengaja
|
||||||
|
connection.on(VoiceConnectionStatus.Disconnected, async () => {
|
||||||
|
if (config.verbose) {
|
||||||
|
console.warn("[recorder] Disconnected from voice channel. Reconnecting...");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await Promise.race([
|
||||||
|
entersState(connection, VoiceConnectionStatus.Signalling, 5_000),
|
||||||
|
entersState(connection, VoiceConnectionStatus.Connecting, 5_000),
|
||||||
|
]);
|
||||||
|
// Berhasil reconnect
|
||||||
|
} catch {
|
||||||
|
console.error("[recorder] Could not reconnect. Destroying connection.");
|
||||||
|
connection.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
connection.on(VoiceConnectionStatus.Destroyed, () => {
|
||||||
|
if (config.verbose) {
|
||||||
|
console.log("[recorder] Voice connection destroyed.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hentikan recording dan disconnect dari voice channel.
|
||||||
|
*/
|
||||||
|
export function stopRecording(guildId: string): void {
|
||||||
|
const connection = getVoiceConnection(guildId);
|
||||||
|
if (connection) {
|
||||||
|
connection.destroy();
|
||||||
|
if (config.verbose) {
|
||||||
|
console.log("[recorder] Recording stopped and disconnected.");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn("[recorder] No active connection to stop.");
|
||||||
|
}
|
||||||
|
}
|
||||||
32
test-crc.js
Normal file
32
test-crc.js
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
const { crc } = require('node-crc');
|
||||||
|
|
||||||
|
const CRC_TABLE = new Uint32Array(256);
|
||||||
|
for (let i = 0; i < 256; i++) {
|
||||||
|
let r = i << 24;
|
||||||
|
for (let j = 0; j < 8; j++) {
|
||||||
|
r = (r & 0x80000000) !== 0 ? ((r << 1) ^ 0x04c11db7) : (r << 1);
|
||||||
|
}
|
||||||
|
CRC_TABLE[i] = (r >>> 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateOggCrc(buffer) {
|
||||||
|
let crc = 0;
|
||||||
|
for (let i = 0; i < buffer.length; i++) {
|
||||||
|
crc = ((crc << 8) >>> 0) ^ CRC_TABLE[((crc >>> 24) ^ buffer[i]) & 0xff];
|
||||||
|
crc >>>= 0;
|
||||||
|
}
|
||||||
|
return crc;
|
||||||
|
}
|
||||||
|
|
||||||
|
const testBuffer = Buffer.from("hello world this is a test page", "utf-8");
|
||||||
|
|
||||||
|
const expectedBuffer = crc(32, false, 0x04c11db7, 0, 0, 0, 0, 0, testBuffer);
|
||||||
|
const expected = expectedBuffer.readUInt32BE(0);
|
||||||
|
|
||||||
|
const actual = calculateOggCrc(testBuffer);
|
||||||
|
|
||||||
|
console.log({
|
||||||
|
expected: expected.toString(16),
|
||||||
|
actual: actual.toString(16),
|
||||||
|
match: expected === actual
|
||||||
|
});
|
||||||
18
test-ogg.js
Normal file
18
test-ogg.js
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
const files = fs.readdirSync('recordings').filter(f => f.endsWith('.ogg')).sort().reverse();
|
||||||
|
if (files.length === 0) {
|
||||||
|
console.log("No files to check");
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = 'recordings/' + files[0];
|
||||||
|
console.log("Reading " + file);
|
||||||
|
const buf = fs.readFileSync(file);
|
||||||
|
|
||||||
|
console.log("First 200 bytes:");
|
||||||
|
console.log(buf.subarray(0, 200).toString('hex').match(/.{1,32}/g).join('\n'));
|
||||||
|
|
||||||
|
// check for Opus tags
|
||||||
|
let str = buf.subarray(0, 200).toString('ascii');
|
||||||
|
console.log("ASCII:", str.replace(/[^ -~]/g, '.'));
|
||||||
19
tsconfig.json
Normal file
19
tsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ESNext",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src"
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/**/*"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules",
|
||||||
|
"dist"
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user