import fs from "fs"; import path from "path"; import { buildMuxFfmpegArgs, runFfmpeg } from "./audio/ffmpegProcess"; const recordingsDir = process.env.RECORDINGS_DIR ?? "./recordings"; 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; filename: string; } interface ClipInfo { oggPath: string; jsonPath: string; meta: EventMetadata; } async function startMuxing() { 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(); 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, ); } } } } processedDirs++; const progress = ((processedDirs / items.length) * 100).toFixed(2); console.log( `[muxer] Scanning progress: ${progress}% (${processedDirs}/${items.length} directories)`, ); } } 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); console.log(`[muxer] Combining clips. This might take a while...`); try { const args = buildMuxFfmpegArgs({ inputs, filter: filterParts.join(";"), output: outputFilename, codec: "libmp3lame", }); await runFfmpeg(args); console.log( `[muxer] Successfully muxed! Output saved to: ${outputFilename}`, ); } catch (err) { console.error(`[muxer] FFmpeg Error:`, err); } } startMuxing();