Files
dc-recorder/src/muxer.ts

170 lines
5.2 KiB
TypeScript
Raw Normal View History

2026-05-12 19:38:23 +07:00
import fs from "fs";
import path from "path";
import { buildMuxFfmpegArgs, runFfmpeg } from "./audio/ffmpegProcess";
2026-05-12 19:38:23 +07:00
const recordingsDir = process.env.RECORDINGS_DIR ?? "./recordings";
interface EventMetadata {
2026-05-13 15:28:25 +07:00
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;
filename: string;
2026-05-12 19:38:23 +07:00
}
interface ClipInfo {
2026-05-13 15:28:25 +07:00
oggPath: string;
jsonPath: string;
meta: EventMetadata;
2026-05-12 19:38:23 +07:00
}
async function startMuxing() {
2026-05-13 15:28:25 +07:00
console.log("[muxer] Scanning recordings directory...");
if (!fs.existsSync(recordingsDir)) {
console.error("[muxer] Recordings directory not found.");
return;
}
const clips: ClipInfo[] = [];
// Scan user directories
const items = fs.readdirSync(recordingsDir);
console.log(`[muxer] Found ${items.length} directories to scan...`);
let processedDirs = 0;
for (const item of items) {
const itemPath = path.join(recordingsDir, item);
if (fs.statSync(itemPath).isDirectory()) {
const files = fs.readdirSync(itemPath);
for (const file of files) {
if (file.endsWith(".json")) {
const jsonPath = path.join(itemPath, file);
const oggPath = jsonPath.replace(/\.json$/, ".ogg");
if (fs.existsSync(oggPath)) {
try {
// Check if OGG file is valid (not empty and has reasonable size)
const oggStats = fs.statSync(oggPath);
if (oggStats.size === 0) {
console.warn(`[muxer] Skipping empty OGG file: ${oggPath}`);
continue;
}
// Skip files that are too small (less than 1KB likely corrupted)
if (oggStats.size < 1024) {
console.warn(
`[muxer] Skipping too small OGG file (${oggStats.size} bytes): ${oggPath}`,
);
continue;
}
// Check if OGG file has valid header (starts with "OggS")
const oggBuffer = fs.readFileSync(oggPath);
const oggHeader = oggBuffer.subarray(0, 4).toString();
2026-05-13 15:28:25 +07:00
if (oggHeader !== "OggS") {
console.warn(
`[muxer] Skipping invalid OGG file (bad header): ${oggPath}`,
);
continue;
}
const meta: EventMetadata = JSON.parse(
fs.readFileSync(jsonPath, "utf-8"),
);
clips.push({ oggPath, jsonPath, meta });
} catch (e) {
console.error(
`[muxer] Failed to read/parse JSON: ${jsonPath}`,
e,
);
2026-05-12 19:38:23 +07:00
}
2026-05-13 15:28:25 +07:00
}
2026-05-12 19:38:23 +07:00
}
2026-05-13 15:28:25 +07:00
}
processedDirs++;
const progress = ((processedDirs / items.length) * 100).toFixed(2);
console.log(
`[muxer] Scanning progress: ${progress}% (${processedDirs}/${items.length} directories)`,
);
2026-05-12 19:38:23 +07:00
}
2026-05-13 15:28:25 +07:00
}
if (clips.length === 0) {
console.log("[muxer] No recording clips found to mux.");
return;
}
// Sort by startTime so chronologically they are in order
clips.sort((a, b) => a.meta.startTime - b.meta.startTime);
// Find the global start time
const globalStartTime = clips[0].meta.startTime;
console.log(
`[muxer] Found ${clips.length} clips. Base timestamp: ${globalStartTime}`,
);
const filterParts: string[] = [];
console.log(`[muxer] Creating audio filters for ${clips.length} clips...`);
clips.forEach((clip, index) => {
// Calculate delay relative to the global start time
const delayMs = clip.meta.startTime - globalStartTime;
// FFmpeg filter structure: [0:a]adelay=1000|1000[a0]
// Setting adelay multiple times covers stereo channels.
// We ensure all multiple channels get delayed.
const inputSpecifier = `[${index}:a]`;
const outputSpecifier = `[pad${index}]`;
filterParts.push(
`${inputSpecifier}adelay=${delayMs}|${delayMs}${outputSpecifier}`,
);
const progress = (((index + 1) / clips.length) * 100).toFixed(2);
console.log(
`[muxer] Filter creation progress: ${progress}% (${index + 1}/${clips.length} clips)`,
);
});
// Merge them using amix
const amixInputs = clips.map((_, i) => `[pad${i}]`).join("");
// We add the amix command. dropout_transition=0 avoids volume drop when streams end.
filterParts.push(
`${amixInputs}amix=inputs=${clips.length}:dropout_transition=0[out]`,
);
const outputFilename = path.join(recordingsDir, `muxed-${Date.now()}.mp3`);
const inputs = clips.map((clip) => clip.oggPath);
2026-05-13 15:28:25 +07:00
console.log(`[muxer] Combining clips. This might take a while...`);
try {
const args = buildMuxFfmpegArgs({
inputs,
filter: filterParts.join(";"),
output: outputFilename,
codec: "libmp3lame",
2026-05-12 19:38:23 +07:00
});
await runFfmpeg(args);
console.log(
`[muxer] Successfully muxed! Output saved to: ${outputFilename}`,
);
} catch (err) {
console.error(`[muxer] FFmpeg Error:`, err);
}
2026-05-12 19:38:23 +07:00
}
startMuxing();