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