refactor: extract recorder domain types and modules
This commit is contained in:
26
src/recorder/audioStream.ts
Normal file
26
src/recorder/audioStream.ts
Normal file
@@ -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;
|
||||
}
|
||||
78
src/recorder/decoder.ts
Normal file
78
src/recorder/decoder.ts
Normal file
@@ -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();
|
||||
}
|
||||
}
|
||||
58
src/recorder/metadata.ts
Normal file
58
src/recorder/metadata.ts
Normal file
@@ -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<UserMetadata> {
|
||||
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),
|
||||
};
|
||||
}
|
||||
84
src/recorder/segment.ts
Normal file
84
src/recorder/segment.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
49
src/types.ts
Normal file
49
src/types.ts
Normal file
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user