Files
dc-recorder/src/recorder.ts

260 lines
7.5 KiB
TypeScript
Raw Normal View History

import fs from "node:fs";
import path from "node:path";
2026-05-12 19:38:23 +07:00
import {
2026-05-13 15:28:25 +07:00
EndBehaviorType,
entersState,
getVoiceConnection,
joinVoiceChannel,
VoiceConnectionStatus,
2026-05-12 19:38:23 +07:00
} from "@discordjs/voice";
2026-05-13 15:28:25 +07:00
import type { Client, VoiceChannel } from "discord.js-selfbot-v13";
2026-05-12 19:38:23 +07:00
import { config } from "./config";
2026-05-13 15:28:25 +07:00
import { PacketFilter } from "./packetFilter";
import { subscribeToAudioStream } from "./recorder/audioStream";
import { OpusDecoder } from "./recorder/decoder";
import {
collectUserMetadata,
createSegmentMetadata,
} from "./recorder/metadata";
import { SegmentManager } from "./recorder/segment";
import type { PcmBroadcaster } from "./types";
import { createChildLogger } from "./logger";
import { retryWithBackoff } from "./retry";
const logger = createChildLogger("recorder");
2026-05-13 15:28:25 +07:00
const recordingsDir = config.recordingsDir;
2026-05-12 19:38:23 +07:00
// Pastikan folder recordings ada
if (!fs.existsSync(recordingsDir)) {
2026-05-13 15:28:25 +07:00
fs.mkdirSync(recordingsDir, { recursive: true });
2026-05-12 19:38:23 +07:00
}
/**
* Join ke voice channel dan mulai merekam semua user yang bicara.
*/
2026-05-13 15:28:25 +07:00
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: false,
debug: true,
});
logger.info({ channelName: channel.name }, "Joining voice channel");
2026-05-13 15:28:25 +07:00
connection.on("debug", (msg) => {
2026-05-12 19:38:23 +07:00
if (config.verbose) {
logger.debug({ message: msg }, "Voice debug");
2026-05-12 19:38:23 +07:00
}
2026-05-13 15:28:25 +07:00
});
2026-05-12 19:38:23 +07:00
2026-05-13 15:28:25 +07:00
connection.on("error", (err) => {
logger.error({ error: err }, "Voice connection error");
2026-05-13 15:28:25 +07:00
});
2026-05-12 19:38:23 +07:00
// Tunggu sampai benar-benar terhubung dengan retry logic
2026-05-13 15:28:25 +07:00
try {
await retryWithBackoff(
() =>
entersState(
connection,
VoiceConnectionStatus.Ready,
config.voiceConnectionTimeoutMs,
),
{
retries: 3,
minTimeout: 1000,
maxTimeout: 5000,
logger,
},
);
logger.info("Connected to voice channel. Recording started");
2026-05-13 15:28:25 +07:00
} catch (err) {
logger.error({ error: err }, "Failed to connect to voice channel");
2026-05-13 15:28:25 +07:00
connection.destroy();
return;
}
const receiver = connection.receiver;
const broadcaster = globalThis as typeof globalThis & PcmBroadcaster;
2026-05-13 15:28:25 +07:00
// Dengarkan siapapun yang mulai bicara
receiver.speaking.on("start", async (userId) => {
const userMetadata = await collectUserMetadata(client, userId, channel);
logger.info({ userId, username: userMetadata.username }, "Voice activity detected");
2026-05-13 15:28:25 +07:00
// Notify webserver
broadcaster.updateActiveUser?.(userId, {
username: userMetadata.username,
avatar: userMetadata.avatarUrl,
speaking: true,
});
2026-05-12 19:38:23 +07:00
2026-05-13 15:28:25 +07:00
// Jangan record kalau sudah ada stream aktif untuk user ini
if (receiver.subscriptions.has(userId)) return;
const timestamp = Date.now();
const sessionStartTime = timestamp;
const sessionId = `${userId}-${sessionStartTime}`;
const userDir = path.join(recordingsDir, userId);
if (!fs.existsSync(userDir)) {
fs.mkdirSync(userDir, { recursive: true });
2026-05-12 19:38:23 +07:00
}
2026-05-13 15:28:25 +07:00
try {
// --- OGG file recording with segment rotation ---
const packetFilterForOgg = new PacketFilter(config.packetFilterMinSize);
const audioStream = receiver.subscribe(userId, {
end: {
behavior: EndBehaviorType.AfterSilence,
duration: 3000,
},
});
2026-05-13 15:28:25 +07:00
const oggPacketStream = audioStream.pipe(packetFilterForOgg);
const segmentManager = new SegmentManager(
userDir,
config.recordingSegmentMs,
);
2026-05-13 15:28:25 +07:00
// --- Web broadcast: prism decoder with safe restart and cooldown ---
const decoder = new OpusDecoder({
cooldownMs: config.decoderCooldownMs,
rotateMs: config.decoderRotateMs,
onData: (pcm) => {
if (!broadcaster.broadcastPcmToWeb) return;
// Downsample 48kHz stereo → 24kHz mono (left channel, every 2nd 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);
}
broadcaster.broadcastPcmToWeb(outBuf, userId);
},
});
2026-05-13 15:28:25 +07:00
let currentSegment = segmentManager.open(oggPacketStream);
currentSegment.out.on("finish", () => {
if (config.verbose) {
logger.info({ filename: currentSegment.filename }, "Segment saved");
2026-05-13 15:28:25 +07:00
}
const metadata = createSegmentMetadata(
userMetadata,
currentSegment,
sessionId,
sessionStartTime,
config.recordingSegmentMs,
);
fs.writeFileSync(
currentSegment.jsonFilename,
JSON.stringify(metadata, null, 2),
);
if (config.verbose) {
logger.info(
{ jsonFile: currentSegment.jsonFilename },
"Metadata saved",
2026-05-13 15:28:25 +07:00
);
2026-05-12 19:38:23 +07:00
}
});
2026-05-13 15:28:25 +07:00
currentSegment.out.on("error", (err) => {
logger.error(
{ userId, error: err.message },
"File write error",
);
});
2026-05-13 15:28:25 +07:00
// Feed Opus packets one-by-one
subscribeToAudioStream(receiver, userId, {
onPacket: (chunk) => {
if (chunk.length < 8) return;
segmentManager.rotateIfNeeded(oggPacketStream);
if (!broadcaster.broadcastPcmToWeb) return;
decoder.rotateIfNeeded();
2026-05-13 15:28:25 +07:00
decoder.write(chunk);
},
onEnd: () => {
segmentManager.close(oggPacketStream);
decoder.destroy();
broadcaster.updateActiveUser?.(userId, {
username: userMetadata.username,
avatar: userMetadata.avatarUrl,
2026-05-13 15:28:25 +07:00
speaking: false,
});
},
onError: (error) => {
segmentManager.close(oggPacketStream);
decoder.destroy();
logger.error(
{ userId, error: error.message },
"Audio stream error",
);
},
2026-05-13 15:28:25 +07:00
});
packetFilterForOgg.on("error", (err) => {
segmentManager.close(oggPacketStream);
logger.error(
{ userId, error: err.message },
"PacketFilter error",
2026-05-13 15:28:25 +07:00
);
});
} catch (e) {
logger.error(
{ userId, error: e instanceof Error ? e.message : String(e) },
"Failed to create stream",
);
2026-05-13 15:28:25 +07:00
}
});
// Handle disconnect yang tidak disengaja
connection.on(VoiceConnectionStatus.Disconnected, async () => {
if (config.verbose) {
logger.warn("Disconnected from voice channel. Reconnecting...");
2026-05-13 15:28:25 +07:00
}
try {
await Promise.race([
entersState(
connection,
VoiceConnectionStatus.Signalling,
config.reconnectTimeoutMs,
),
entersState(
connection,
VoiceConnectionStatus.Connecting,
config.reconnectTimeoutMs,
),
2026-05-13 15:28:25 +07:00
]);
// Berhasil reconnect
} catch {
logger.error("Could not reconnect. Destroying connection");
2026-05-13 15:28:25 +07:00
connection.destroy();
}
});
connection.on(VoiceConnectionStatus.Destroyed, () => {
if (config.verbose) {
logger.info("Voice connection destroyed");
2026-05-13 15:28:25 +07:00
}
});
2026-05-12 19:38:23 +07:00
}
/**
* Hentikan recording dan disconnect dari voice channel.
*/
export function stopRecording(guildId: string): void {
2026-05-13 15:28:25 +07:00
const connection = getVoiceConnection(guildId);
if (connection) {
connection.destroy();
if (config.verbose) {
logger.info("Recording stopped and disconnected");
2026-05-12 19:38:23 +07:00
}
2026-05-13 15:28:25 +07:00
} else {
logger.warn("No active connection to stop");
2026-05-13 15:28:25 +07:00
}
2026-05-12 19:38:23 +07:00
}