From dbe593c6fdc50f9da5771231eee4ef2b7117711a Mon Sep 17 00:00:00 2001 From: MythEclipse Date: Wed, 13 May 2026 15:56:42 +0700 Subject: [PATCH] refactor: extract recorder domain types and modules --- src/recorder/audioStream.ts | 26 ++++++++++++ src/recorder/decoder.ts | 78 ++++++++++++++++++++++++++++++++++ src/recorder/metadata.ts | 58 +++++++++++++++++++++++++ src/recorder/segment.ts | 84 +++++++++++++++++++++++++++++++++++++ src/types.ts | 49 ++++++++++++++++++++++ 5 files changed, 295 insertions(+) create mode 100644 src/recorder/audioStream.ts create mode 100644 src/recorder/decoder.ts create mode 100644 src/recorder/metadata.ts create mode 100644 src/recorder/segment.ts create mode 100644 src/types.ts diff --git a/src/recorder/audioStream.ts b/src/recorder/audioStream.ts new file mode 100644 index 0000000..036174a --- /dev/null +++ b/src/recorder/audioStream.ts @@ -0,0 +1,26 @@ +import { EndBehaviorType, type VoiceReceiver } from "@discordjs/voice"; + +export interface AudioStreamHandlers { + onPacket: (chunk: Buffer) => void; + onEnd: () => void; + onError: (error: Error) => void; +} + +export function subscribeToAudioStream( + receiver: VoiceReceiver, + userId: string, + handlers: AudioStreamHandlers, +): NodeJS.ReadableStream { + const audioStream = receiver.subscribe(userId, { + end: { + behavior: EndBehaviorType.AfterSilence, + duration: 3000, + }, + }); + + audioStream.on("data", handlers.onPacket); + audioStream.on("end", handlers.onEnd); + audioStream.on("error", handlers.onError); + + return audioStream; +} diff --git a/src/recorder/decoder.ts b/src/recorder/decoder.ts new file mode 100644 index 0000000..b1efd8e --- /dev/null +++ b/src/recorder/decoder.ts @@ -0,0 +1,78 @@ +import prism from "prism-media"; + +export interface OpusDecoderOptions { + cooldownMs: number; + rotateMs: number; + createDecoder?: () => prism.opus.Decoder; + onData: (pcm: Buffer) => void; +} + +export class OpusDecoder { + private decoder: prism.opus.Decoder | null = null; + private disabledUntil = 0; + private createdAt = 0; + private readonly cooldownMs: number; + private readonly rotateMs: number; + private readonly createDecoderFn: () => prism.opus.Decoder; + private readonly onData: (pcm: Buffer) => void; + + constructor(options: OpusDecoderOptions) { + this.cooldownMs = options.cooldownMs; + this.rotateMs = options.rotateMs; + this.onData = options.onData; + this.createDecoderFn = + options.createDecoder ?? + (() => new prism.opus.Decoder({ frameSize: 960, channels: 2, rate: 48000 })); + } + + rotateIfNeeded(): void { + if (!this.decoder || this.rotateMs <= 0) return; + if (Date.now() - this.createdAt < this.rotateMs) return; + this.destroy(); + this.ensureDecoder(); + } + + write(chunk: Buffer): void { + const decoder = this.ensureDecoder(); + if (!decoder) return; + try { + decoder.write(chunk); + } catch (error) { + console.warn("[recorder] Opus decoder write failed, cooling down:", error); + this.coolDown(); + } + } + + destroy(): void { + if (!this.decoder) return; + this.decoder.removeAllListeners(); + this.decoder.destroy(); + this.decoder = null; + this.createdAt = 0; + } + + private ensureDecoder(): prism.opus.Decoder | null { + if (this.decoder) return this.decoder; + if (Date.now() < this.disabledUntil) return null; + try { + const decoder = this.createDecoderFn(); + decoder.on("data", this.onData); + decoder.on("error", (error) => { + console.warn("[recorder] Opus decoder error, cooling down:", error); + this.coolDown(); + }); + this.decoder = decoder; + this.createdAt = Date.now(); + return decoder; + } catch (error) { + console.warn("[recorder] Opus decoder init failed, cooling down:", error); + this.disabledUntil = Date.now() + this.cooldownMs; + return null; + } + } + + private coolDown(): void { + this.disabledUntil = Date.now() + this.cooldownMs; + this.destroy(); + } +} diff --git a/src/recorder/metadata.ts b/src/recorder/metadata.ts new file mode 100644 index 0000000..bba2175 --- /dev/null +++ b/src/recorder/metadata.ts @@ -0,0 +1,58 @@ +import path from "node:path"; +import type { Client, VoiceChannel } from "discord.js-selfbot-v13"; +import type { SegmentMetadata, SegmentState, UserMetadata } from "../types"; + +export async function collectUserMetadata( + client: Client, + userId: string, + channel: VoiceChannel, +): Promise { + const user = + client.users.cache.get(userId) || + (await client.users.fetch(userId).catch(() => null)); + const member = + channel.guild.members.cache.get(userId) || + (await channel.guild.members.fetch(userId).catch(() => null)); + const username = user?.username ?? "Unknown User"; + const roles = + member?.roles.cache + .filter((role) => role.id !== channel.guild.id) + .sort((a, b) => b.position - a.position) + .map((role) => ({ id: role.id, name: role.name, position: role.position })) ?? + []; + + return { + userId, + username, + tag: user?.tag ?? "Unknown#0000", + displayName: member?.displayName ?? username, + avatarUrl: + user?.displayAvatarURL({ format: "png", size: 64 }) ?? + "https://cdn.discordapp.com/embed/avatars/0.png", + bot: user?.bot ?? false, + roles, + highestRole: roles[0] ?? null, + joinedTimestamp: member?.joinedTimestamp ?? null, + }; +} + +export function createSegmentMetadata( + user: UserMetadata, + segment: SegmentState, + sessionId: string, + sessionStartTime: number, + recordingSegmentMs: number, +): SegmentMetadata { + const endTime = segment.endTime ?? Date.now(); + return { + ...user, + sessionId, + sessionStartTime, + segmentIndex: segment.index, + segmentMs: recordingSegmentMs, + startTime: segment.startTime, + endTime, + durationMs: endTime - segment.startTime, + filename: path.basename(segment.filename), + }; +} diff --git a/src/recorder/segment.ts b/src/recorder/segment.ts new file mode 100644 index 0000000..056d3b8 --- /dev/null +++ b/src/recorder/segment.ts @@ -0,0 +1,84 @@ +import fs from "node:fs"; +import path from "node:path"; +import prism from "prism-media"; +import type { SegmentState } from "../types"; + +export function buildSegmentPaths( + userDir: string, + startTime: number, +): { filename: string; jsonFilename: string } { + return { + filename: path.join(userDir, `${startTime}.ogg`), + jsonFilename: path.join(userDir, `${startTime}.json`), + }; +} + +export function shouldRotateSegment( + startTime: number, + now: number, + recordingSegmentMs: number, +): boolean { + return recordingSegmentMs > 0 && now - startTime >= recordingSegmentMs; +} + +export class SegmentManager { + private currentSegment: SegmentState | null = null; + private segmentIndex = 0; + + constructor( + private readonly userDir: string, + private readonly recordingSegmentMs: number, + ) {} + + open(oggPacketStream: NodeJS.ReadableStream): SegmentState { + const index = this.segmentIndex++; + const startTime = Date.now(); + const { filename, jsonFilename } = buildSegmentPaths(this.userDir, startTime); + const oggStream = new prism.opus.OggLogicalBitstream({ + opusHead: new prism.opus.OpusHead({ channelCount: 2, sampleRate: 48000 }), + pageSizeControl: { maxPackets: 10 }, + crc: true, + }); + const out = fs.createWriteStream(filename); + oggPacketStream.pipe(oggStream).pipe(out); + + this.currentSegment = { + index, + startTime, + endTime: null, + filename, + jsonFilename, + oggStream, + out, + }; + return this.currentSegment; + } + + close(oggPacketStream: NodeJS.ReadableStream): SegmentState | null { + if (!this.currentSegment) return null; + const segment = this.currentSegment; + segment.endTime = Date.now(); + oggPacketStream.unpipe(segment.oggStream); + segment.oggStream.end(); + this.currentSegment = null; + return segment; + } + + rotateIfNeeded(oggPacketStream: NodeJS.ReadableStream): SegmentState | null { + if (!this.currentSegment) return null; + if ( + !shouldRotateSegment( + this.currentSegment.startTime, + Date.now(), + this.recordingSegmentMs, + ) + ) + return null; + this.close(oggPacketStream); + return this.open(oggPacketStream); + } + + getCurrent(): SegmentState | null { + return this.currentSegment; + } +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..4c8ff95 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,49 @@ +import type fs from "node:fs"; +import type prism from "prism-media"; + +export interface RoleMetadata { + id: string; + name: string; + position: number; +} + +export interface UserMetadata { + userId: string; + username: string; + tag: string; + displayName: string; + avatarUrl: string; + bot: boolean; + roles: RoleMetadata[]; + highestRole: RoleMetadata | null; + joinedTimestamp: number | null; +} + +export interface SegmentState { + index: number; + startTime: number; + endTime: number | null; + filename: string; + jsonFilename: string; + oggStream: prism.opus.OggLogicalBitstream; + out: fs.WriteStream; +} + +export interface SegmentMetadata extends UserMetadata { + sessionId: string; + sessionStartTime: number; + segmentIndex: number; + segmentMs: number; + startTime: number; + endTime: number; + durationMs: number; + filename: string; +} + +export interface PcmBroadcaster { + broadcastPcmToWeb?: (chunk: Buffer, userId: string) => void; + updateActiveUser?: ( + userId: string, + data: { username: string; avatar: string; speaking: boolean }, + ) => void; +}