feat: enhance EventMetadata interface and improve user data handling in recording process
This commit is contained in:
@@ -8,6 +8,16 @@ interface EventMetadata {
|
|||||||
userId: string;
|
userId: string;
|
||||||
username: string;
|
username: string;
|
||||||
tag: 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;
|
startTime: number;
|
||||||
endTime: number;
|
endTime: number;
|
||||||
durationMs: number;
|
durationMs: number;
|
||||||
|
|||||||
10
src/muxer.ts
10
src/muxer.ts
@@ -8,6 +8,16 @@ interface EventMetadata {
|
|||||||
userId: string;
|
userId: string;
|
||||||
username: string;
|
username: string;
|
||||||
tag: 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;
|
startTime: number;
|
||||||
endTime: number;
|
endTime: number;
|
||||||
durationMs: number;
|
durationMs: number;
|
||||||
|
|||||||
224
src/recorder.ts
224
src/recorder.ts
@@ -65,27 +65,39 @@ export async function startRecording(client: Client, channel: VoiceChannel): Pro
|
|||||||
receiver.speaking.on("start", async (userId) => {
|
receiver.speaking.on("start", async (userId) => {
|
||||||
// Coba ambil data user dari cache atau fetch dari API
|
// 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 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 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]"
|
// Tampilkan format "nama user [voice activity]"
|
||||||
console.log(`${username} [voice activity]`);
|
console.log(`${username} [voice activity]`);
|
||||||
|
|
||||||
// Notify webserver
|
// Notify webserver
|
||||||
if ((global as any).updateActiveUser) {
|
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
|
// Jangan record kalau sudah ada stream aktif untuk user ini
|
||||||
if (receiver.subscriptions.has(userId)) return;
|
if (receiver.subscriptions.has(userId)) return;
|
||||||
|
|
||||||
const timestamp = Date.now();
|
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);
|
const userDir = path.join(recordingsDir, userId);
|
||||||
if (!fs.existsSync(userDir)) {
|
if (!fs.existsSync(userDir)) {
|
||||||
fs.mkdirSync(userDir, { recursive: true });
|
fs.mkdirSync(userDir, { recursive: true });
|
||||||
}
|
}
|
||||||
const filename = path.join(userDir, `${timestamp}.ogg`);
|
|
||||||
const jsonFilename = path.join(userDir, `${timestamp}.json`);
|
|
||||||
|
|
||||||
const audioStream = receiver.subscribe(userId, {
|
const audioStream = receiver.subscribe(userId, {
|
||||||
end: {
|
end: {
|
||||||
@@ -95,29 +107,107 @@ export async function startRecording(client: Client, channel: VoiceChannel): Pro
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// --- OGG file recording (unchanged) ---
|
// --- OGG file recording with segment rotation ---
|
||||||
const packetFilterForOgg = new PacketFilter(8);
|
const packetFilterForOgg = new PacketFilter(8);
|
||||||
const oggStream = new prism.opus.OggLogicalBitstream({
|
const oggPacketStream = audioStream.pipe(packetFilterForOgg);
|
||||||
opusHead: new prism.opus.OpusHead({ channelCount: 2, sampleRate: 48000 }),
|
let segmentIndex = 0;
|
||||||
pageSizeControl: { maxPackets: 10 },
|
let currentSegment: {
|
||||||
crc: true,
|
index: number;
|
||||||
});
|
startTime: number;
|
||||||
const out = fs.createWriteStream(filename);
|
endTime: number | null;
|
||||||
audioStream.pipe(packetFilterForOgg).pipe(oggStream).pipe(out);
|
filename: string;
|
||||||
|
jsonFilename: string;
|
||||||
|
oggStream: any;
|
||||||
|
out: fs.WriteStream;
|
||||||
|
} | null = null;
|
||||||
|
|
||||||
// --- Web broadcast: prism decoder with auto-recreate on error ---
|
const openSegment = () => {
|
||||||
// Prism's Transform stream enters a dead error state after first bad packet.
|
const index = segmentIndex++;
|
||||||
// We recreate the decoder instance when this happens, so subsequent packets
|
const startTime = Date.now();
|
||||||
// are decoded normally. Each packet failure is fully isolated.
|
const segmentFilename = path.join(userDir, `${startTime}.ogg`);
|
||||||
function makePcmListener(onPcm: (pcm: Buffer) => void) {
|
const segmentJsonFilename = path.join(userDir, `${startTime}.json`);
|
||||||
const d = new prism.opus.Decoder({ frameSize: 960, channels: 2, rate: 48000 });
|
const oggStream = new prism.opus.OggLogicalBitstream({
|
||||||
d.on('data', onPcm);
|
opusHead: new prism.opus.OpusHead({ channelCount: 2, sampleRate: 48000 }),
|
||||||
d.on('error', () => {
|
pageSizeControl: { maxPackets: 10 },
|
||||||
// Decoder is dead — swap to a fresh one
|
crc: true,
|
||||||
currentDecoder = makePcmListener(onPcm);
|
|
||||||
});
|
});
|
||||||
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) => {
|
const handlePcm = (pcm: Buffer) => {
|
||||||
if (!(global as any).broadcastPcmToWeb) return;
|
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);
|
(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
|
// Feed Opus packets one-by-one
|
||||||
let packetCount = 0;
|
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')}`);
|
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
|
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 {
|
try {
|
||||||
currentDecoder.write(chunk);
|
decoder.write(chunk);
|
||||||
} catch (_) {
|
} catch (err) {
|
||||||
currentDecoder = makePcmListener(handlePcm);
|
console.warn("[recorder] Opus decoder write failed, cooling down:", err);
|
||||||
|
decoderDisabledUntil = Date.now() + decoderCooldownMs;
|
||||||
|
destroyDecoder();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
audioStream.on('end', () => {
|
audioStream.on('end', () => {
|
||||||
|
closeSegment();
|
||||||
|
destroyDecoder();
|
||||||
if ((global as any).updateActiveUser) {
|
if ((global as any).updateActiveUser) {
|
||||||
(global as any).updateActiveUser(userId, { username, avatar, speaking: false });
|
(global as any).updateActiveUser(userId, { username, avatar: avatarUrl, 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}`);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
audioStream.on('error', (err) => {
|
audioStream.on('error', (err) => {
|
||||||
|
closeSegment();
|
||||||
|
destroyDecoder();
|
||||||
console.error(`[recorder] Audio Stream error ${userId}:`, err.message);
|
console.error(`[recorder] Audio Stream error ${userId}:`, err.message);
|
||||||
});
|
});
|
||||||
packetFilterForOgg.on('error', (err) => {
|
packetFilterForOgg.on('error', (err) => {
|
||||||
|
closeSegment();
|
||||||
console.error(`[recorder] PacketFilter(ogg) error ${userId}:`, err.message);
|
console.error(`[recorder] PacketFilter(ogg) error ${userId}:`, err.message);
|
||||||
});
|
});
|
||||||
out.on('error', (err) => {
|
|
||||||
console.error(`[recorder] File write error ${userId}:`, err.message);
|
|
||||||
});
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`[recorder] Failed to create stream for ${userId}:`, e);
|
console.error(`[recorder] Failed to create stream for ${userId}:`, e);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user