144 lines
5.5 KiB
TypeScript
144 lines
5.5 KiB
TypeScript
|
|
import fs from "fs";
|
||
|
|
import path from "path";
|
||
|
|
import ffmpeg from "fluent-ffmpeg";
|
||
|
|
|
||
|
|
const recordingsDir = process.env.RECORDINGS_DIR ?? "./recordings";
|
||
|
|
|
||
|
|
interface EventMetadata {
|
||
|
|
userId: string;
|
||
|
|
username: string;
|
||
|
|
tag: string;
|
||
|
|
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.slice(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 command = ffmpeg();
|
||
|
|
const filterParts: string[] = [];
|
||
|
|
|
||
|
|
console.log(`[muxer] Creating audio filters for ${clips.length} clips...`);
|
||
|
|
clips.forEach((clip, index) => {
|
||
|
|
command.input(clip.oggPath);
|
||
|
|
|
||
|
|
// 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`);
|
||
|
|
|
||
|
|
console.log(`[muxer] Combining clips. This might take a while...`);
|
||
|
|
|
||
|
|
// Using fluent-ffmpeg's complexFilter
|
||
|
|
command
|
||
|
|
.complexFilter(filterParts, "out")
|
||
|
|
.audioCodec("libmp3lame")
|
||
|
|
.save(outputFilename)
|
||
|
|
.on("progress", (progress) => {
|
||
|
|
if (progress.percent) {
|
||
|
|
console.log(`[muxer] Progress: ${progress.percent.toFixed(2)}%`);
|
||
|
|
}
|
||
|
|
})
|
||
|
|
.on("end", () => {
|
||
|
|
console.log(`[muxer] Successfully muxed! Output saved to: ${outputFilename}`);
|
||
|
|
})
|
||
|
|
.on("error", (err) => {
|
||
|
|
console.error(`[muxer] FFmpeg Error:`, err);
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
startMuxing();
|