From 6e67da21924480ed341e948906046f530e13d7b7 Mon Sep 17 00:00:00 2001 From: MythEclipse Date: Wed, 13 May 2026 04:06:37 +0700 Subject: [PATCH] feat: enhance EventMetadata interface and improve user data handling in recording process --- src/muxer-aup3.ts | 10 +++ src/muxer.ts | 10 +++ src/recorder.ts | 224 +++++++++++++++++++++++++++++++++++----------- 3 files changed, 190 insertions(+), 54 deletions(-) diff --git a/src/muxer-aup3.ts b/src/muxer-aup3.ts index 78f91a4..0827985 100644 --- a/src/muxer-aup3.ts +++ b/src/muxer-aup3.ts @@ -8,6 +8,16 @@ interface EventMetadata { userId: string; username: string; tag: string; + displayName?: string; + avatarUrl?: string; + bot?: boolean; + roles?: Array<{ id: string; name: string; position: number }>; + highestRole?: { id: string; name: string; position: number } | null; + joinedTimestamp?: number | null; + sessionId?: string; + sessionStartTime?: number; + segmentIndex?: number; + segmentMs?: number; startTime: number; endTime: number; durationMs: number; diff --git a/src/muxer.ts b/src/muxer.ts index fb8bd80..599f8b4 100644 --- a/src/muxer.ts +++ b/src/muxer.ts @@ -8,6 +8,16 @@ interface EventMetadata { userId: string; username: string; tag: string; + displayName?: string; + avatarUrl?: string; + bot?: boolean; + roles?: Array<{ id: string; name: string; position: number }>; + highestRole?: { id: string; name: string; position: number } | null; + joinedTimestamp?: number | null; + sessionId?: string; + sessionStartTime?: number; + segmentIndex?: number; + segmentMs?: number; startTime: number; endTime: number; durationMs: number; diff --git a/src/recorder.ts b/src/recorder.ts index fbb9d8c..3d6f0e9 100644 --- a/src/recorder.ts +++ b/src/recorder.ts @@ -65,27 +65,39 @@ export async function startRecording(client: Client, channel: VoiceChannel): Pro receiver.speaking.on("start", async (userId) => { // 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 member = channel.guild.members.cache.get(userId) || await channel.guild.members.fetch(userId).catch(() => null); const username = user?.username ?? "Unknown User"; - const avatar = user?.displayAvatarURL({ format: 'png', size: 64 }) ?? "https://cdn.discordapp.com/embed/avatars/0.png"; + const avatarUrl = user?.displayAvatarURL({ format: "png", size: 64 }) ?? "https://cdn.discordapp.com/embed/avatars/0.png"; + const displayName = member?.displayName ?? username; + 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 })) ?? []; + const highestRole = roles.length > 0 ? roles[0] : null; + const joinedTimestamp = member?.joinedTimestamp ?? null; // Tampilkan format "nama user [voice activity]" console.log(`${username} [voice activity]`); // Notify webserver if ((global as any).updateActiveUser) { - (global as any).updateActiveUser(userId, { username, avatar, speaking: true }); + (global as any).updateActiveUser(userId, { username, avatar: avatarUrl, speaking: true }); } // 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 recordingSegmentMsRaw = Number(process.env.RECORDING_SEGMENT_MS ?? 5_000); + const recordingSegmentMs = Number.isFinite(recordingSegmentMsRaw) && recordingSegmentMsRaw > 0 + ? recordingSegmentMsRaw + : 0; 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: { @@ -95,29 +107,107 @@ export async function startRecording(client: Client, channel: VoiceChannel): Pro }); try { - // --- OGG file recording (unchanged) --- + // --- OGG file recording with segment rotation --- const packetFilterForOgg = new PacketFilter(8); - 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); - audioStream.pipe(packetFilterForOgg).pipe(oggStream).pipe(out); + const oggPacketStream = audioStream.pipe(packetFilterForOgg); + let segmentIndex = 0; + let currentSegment: { + index: number; + startTime: number; + endTime: number | null; + filename: string; + jsonFilename: string; + oggStream: any; + out: fs.WriteStream; + } | null = null; - // --- Web broadcast: prism decoder with auto-recreate on error --- - // Prism's Transform stream enters a dead error state after first bad packet. - // We recreate the decoder instance when this happens, so subsequent packets - // are decoded normally. Each packet failure is fully isolated. - function makePcmListener(onPcm: (pcm: Buffer) => void) { - const d = new prism.opus.Decoder({ frameSize: 960, channels: 2, rate: 48000 }); - d.on('data', onPcm); - d.on('error', () => { - // Decoder is dead — swap to a fresh one - currentDecoder = makePcmListener(onPcm); + const openSegment = () => { + const index = segmentIndex++; + const startTime = Date.now(); + const segmentFilename = path.join(userDir, `${startTime}.ogg`); + const segmentJsonFilename = path.join(userDir, `${startTime}.json`); + const oggStream = new prism.opus.OggLogicalBitstream({ + opusHead: new prism.opus.OpusHead({ channelCount: 2, sampleRate: 48000 }), + pageSizeControl: { maxPackets: 10 }, + crc: true, }); - return d; - } + const out = fs.createWriteStream(segmentFilename); + oggPacketStream.pipe(oggStream).pipe(out); + + const segment = { + index, + startTime, + endTime: null as number | null, + filename: segmentFilename, + jsonFilename: segmentJsonFilename, + oggStream, + out, + }; + + out.on("finish", () => { + if (config.verbose) { + console.log(`[recorder] Saved: ${segment.filename}`); + } + const endTime = segment.endTime ?? Date.now(); + + const eventMetadata = { + userId, + username, + tag: user?.tag ?? "Unknown#0000", + displayName, + avatarUrl, + bot: user?.bot ?? false, + roles, + highestRole, + joinedTimestamp, + sessionId, + sessionStartTime, + segmentIndex: segment.index, + segmentMs: recordingSegmentMs, + startTime: segment.startTime, + endTime, + durationMs: endTime - segment.startTime, + filename: path.basename(segment.filename) + }; + fs.writeFileSync(segment.jsonFilename, JSON.stringify(eventMetadata, null, 2)); + if (config.verbose) { + console.log(`[recorder] Saved metadata: ${segment.jsonFilename}`); + } + }); + + out.on("error", (err) => { + console.error(`[recorder] File write error ${userId}:`, err.message); + }); + + return segment; + }; + + const closeSegment = () => { + if (!currentSegment) return; + currentSegment.endTime = Date.now(); + oggPacketStream.unpipe(currentSegment.oggStream); + currentSegment.oggStream.end(); + currentSegment = null; + }; + + const rotateSegmentIfNeeded = () => { + if (!currentSegment) return; + if (recordingSegmentMs <= 0) return; + if (Date.now() - currentSegment.startTime < recordingSegmentMs) return; + closeSegment(); + currentSegment = openSegment(); + }; + + currentSegment = openSegment(); + + // --- Web broadcast: prism decoder with safe restart and cooldown --- + // OpusScript can crash on long/invalid streams; avoid taking down the process. + const decoderConfig = { frameSize: 960, channels: 2, rate: 48000 }; + const decoderCooldownMs = 30_000; + const decoderRotateMs = Number(process.env.DECODER_ROTATE_MS ?? 5_000); + let currentDecoder: prism.opus.Decoder | null = null; + let decoderDisabledUntil = 0; + let decoderCreatedAt = 0; const handlePcm = (pcm: Buffer) => { if (!(global as any).broadcastPcmToWeb) return; @@ -129,7 +219,46 @@ export async function startRecording(client: Client, channel: VoiceChannel): Pro (global as any).broadcastPcmToWeb(outBuf, userId); }; - let currentDecoder = makePcmListener(handlePcm); + const destroyDecoder = () => { + if (!currentDecoder) return; + currentDecoder.removeAllListeners(); + currentDecoder.destroy(); + currentDecoder = null; + decoderCreatedAt = 0; + }; + + const createDecoder = () => { + if (Date.now() < decoderDisabledUntil) return null; + try { + const d = new prism.opus.Decoder(decoderConfig); + d.on('data', handlePcm); + d.on('error', (err) => { + console.warn("[recorder] Opus decoder error, cooling down:", err); + decoderDisabledUntil = Date.now() + decoderCooldownMs; + destroyDecoder(); + }); + decoderCreatedAt = Date.now(); + return d; + } catch (err) { + console.warn("[recorder] Opus decoder init failed, cooling down:", err); + decoderDisabledUntil = Date.now() + decoderCooldownMs; + return null; + } + }; + + const rotateDecoderIfNeeded = () => { + if (!currentDecoder || decoderRotateMs <= 0) return; + if (Date.now() - decoderCreatedAt < decoderRotateMs) return; + destroyDecoder(); + currentDecoder = createDecoder(); + }; + + const ensureDecoder = () => { + if (!currentDecoder) { + currentDecoder = createDecoder(); + } + return currentDecoder; + }; // Feed Opus packets one-by-one let packetCount = 0; @@ -139,50 +268,37 @@ export async function startRecording(client: Client, channel: VoiceChannel): Pro console.log(`[recorder] Pkt #${packetCount} from ${userId}: ${chunk.length}b | 0x${chunk.slice(0,4).toString('hex')}`); } if (chunk.length < 8) return; // skip tiny control/DTX packets + rotateSegmentIfNeeded(); + if (!(global as any).broadcastPcmToWeb) return; + rotateDecoderIfNeeded(); + const decoder = ensureDecoder(); + if (!decoder) return; try { - currentDecoder.write(chunk); - } catch (_) { - currentDecoder = makePcmListener(handlePcm); + decoder.write(chunk); + } catch (err) { + console.warn("[recorder] Opus decoder write failed, cooling down:", err); + decoderDisabledUntil = Date.now() + decoderCooldownMs; + destroyDecoder(); } }); audioStream.on('end', () => { + closeSegment(); + destroyDecoder(); if ((global as any).updateActiveUser) { - (global as any).updateActiveUser(userId, { username, avatar, speaking: false }); - } - }); - - - 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}`); + (global as any).updateActiveUser(userId, { username, avatar: avatarUrl, speaking: false }); } }); audioStream.on('error', (err) => { + closeSegment(); + destroyDecoder(); console.error(`[recorder] Audio Stream error ${userId}:`, err.message); }); packetFilterForOgg.on('error', (err) => { + closeSegment(); console.error(`[recorder] PacketFilter(ogg) 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); }