264 lines
8.7 KiB
TypeScript
264 lines
8.7 KiB
TypeScript
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 startMuxingToAup3() {
|
|
console.log("[muxer-aup3] Scanning recordings directory...");
|
|
if (!fs.existsSync(recordingsDir)) {
|
|
console.error("[muxer-aup3] Recordings directory not found.");
|
|
return;
|
|
}
|
|
|
|
const clips: ClipInfo[] = [];
|
|
|
|
// Scan user directories
|
|
const items = fs.readdirSync(recordingsDir);
|
|
console.log(`[muxer-aup3] 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-aup3] Skipping empty OGG file: ${oggPath}`,
|
|
);
|
|
continue;
|
|
}
|
|
|
|
// Skip files that are too small (less than 1KB likely corrupted)
|
|
if (oggStats.size < 1024) {
|
|
console.warn(
|
|
`[muxer-aup3] 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-aup3] 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-aup3] Failed to read/parse JSON: ${jsonPath}`,
|
|
e,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
processedDirs++;
|
|
const progress = ((processedDirs / items.length) * 100).toFixed(2);
|
|
console.log(
|
|
`[muxer-aup3] Scanning progress: ${progress}% (${processedDirs}/${items.length} directories)`,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (clips.length === 0) {
|
|
console.log("[muxer-aup3] 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-aup3] Found ${clips.length} clips. Base timestamp: ${globalStartTime}`,
|
|
);
|
|
|
|
const filterParts: string[] = [];
|
|
|
|
console.log(
|
|
`[muxer-aup3] 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-aup3] 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 timestamp = Date.now();
|
|
const wavFilename = path.join(recordingsDir, `muxed-${timestamp}.wav`);
|
|
const aup3Filename = path.join(recordingsDir, `muxed-${timestamp}.aup3`);
|
|
const inputs = clips.map((clip) => clip.oggPath);
|
|
|
|
console.log(
|
|
`[muxer-aup3] Combining clips to WAV. This might take a while...`,
|
|
);
|
|
|
|
try {
|
|
const args = buildMuxFfmpegArgs({
|
|
inputs,
|
|
filter: filterParts.join(";"),
|
|
output: wavFilename,
|
|
codec: "pcm_s16le",
|
|
audioFrequency: 44100,
|
|
audioChannels: 2,
|
|
});
|
|
|
|
await runFfmpeg(args);
|
|
console.log(`[muxer-aup3] WAV file created: ${wavFilename}`);
|
|
console.log(`[muxer-aup3] Creating AUP3 project file...`);
|
|
createAup3Project(wavFilename, aup3Filename, clips, globalStartTime);
|
|
} catch (err) {
|
|
console.error(`[muxer-aup3] FFmpeg Error:`, err);
|
|
}
|
|
}
|
|
|
|
function createAup3Project(
|
|
wavFilename: string,
|
|
aup3Filename: string,
|
|
clips: ClipInfo[],
|
|
globalStartTime: number,
|
|
) {
|
|
try {
|
|
console.log(`[muxer-aup3] AUP3 Progress: Reading WAV file...`);
|
|
|
|
// Read WAV file to get duration
|
|
const wavStats = fs.statSync(wavFilename);
|
|
const wavSize = wavStats.size;
|
|
|
|
// Calculate approximate duration (assuming 44.1kHz, 16-bit, stereo)
|
|
// Duration = (file_size - 44) / (44100 * 2 * 2) for WAV
|
|
const duration = (wavSize - 44) / (44100 * 4);
|
|
|
|
console.log(
|
|
`[muxer-aup3] AUP3 Progress: Calculating duration... ${duration.toFixed(2)}s`,
|
|
);
|
|
console.log(`[muxer-aup3] AUP3 Progress: Creating XML structure...`);
|
|
|
|
// Create AUP3 project XML structure
|
|
const aup3Content = `<?xml version="1.0" encoding="UTF-8"?>
|
|
<audacityproject xmlns="http://audacity.sourceforge.net/xml/" projname="muxed" version="1.3.0" audacityversion="3.5.1">
|
|
<tags>
|
|
<tag name="GENRE" value=""/>
|
|
<tag name="ARTIST" value=""/>
|
|
<tag name="ALBUM" value=""/>
|
|
<tag name="TRACKNUMBER" value=""/>
|
|
<tag name="YEAR" value=""/>
|
|
<tag name="TITLE" value="Muxed Recording"/>
|
|
</tags>
|
|
<wavetrack name="Muxed Audio" channel="2" linked="0" mute="0" solo="0" height="150" minimized="0" isSelected="0" rate="44100">
|
|
<waveclip offset="0.0">
|
|
<sequence maxsamples="262144" sampleformat="262159" numsamples="${Math.floor(duration * 44100)}">
|
|
<waveblock start="0">
|
|
<simpleblockfile filename="${path.basename(wavFilename)}" len="${Math.floor(duration * 44100)}" min="-1.0" max="1.0" rms="0.1"/>
|
|
</waveblock>
|
|
</sequence>
|
|
<envelope numpoints="0"/>
|
|
</waveclip>
|
|
</wavetrack>
|
|
<timetrack name="Time Track" height="150" minimized="0" isSelected="0">
|
|
<envelope numpoints="0"/>
|
|
</timetrack>
|
|
</audacityproject>`;
|
|
|
|
console.log(`[muxer-aup3] AUP3 Progress: Writing AUP3 file...`);
|
|
|
|
// Write AUP3 file
|
|
fs.writeFileSync(aup3Filename, aup3Content, "utf-8");
|
|
|
|
console.log(`[muxer-aup3] AUP3 Progress: Creating clip info file...`);
|
|
|
|
// Create a simple info file with clip details
|
|
const infoFilename = aup3Filename.replace(".aup3", "-info.txt");
|
|
const infoContent = clips
|
|
.map((clip, index) => {
|
|
const delayMs = clip.meta.startTime - globalStartTime;
|
|
return `Clip ${index + 1}:
|
|
User: ${clip.meta.username} (${clip.meta.userId})
|
|
Tag: ${clip.meta.tag}
|
|
Start Time: ${new Date(clip.meta.startTime).toISOString()}
|
|
Delay: ${delayMs}ms
|
|
Duration: ${clip.meta.durationMs}ms
|
|
File: ${path.basename(clip.oggPath)}`;
|
|
})
|
|
.join("\n\n");
|
|
|
|
fs.writeFileSync(infoFilename, infoContent, "utf-8");
|
|
|
|
console.log(`[muxer-aup3] AUP3 Progress: 100% - Complete!`);
|
|
console.log(`[muxer-aup3] Successfully created AUP3 project!`);
|
|
console.log(`[muxer-aup3] WAV file: ${wavFilename}`);
|
|
console.log(`[muxer-aup3] AUP3 file: ${aup3Filename}`);
|
|
console.log(`[muxer-aup3] Clip info saved to: ${infoFilename}`);
|
|
console.log(`[muxer-aup3] Total clips processed: ${clips.length}`);
|
|
console.log(`[muxer-aup3] Duration: ${duration.toFixed(2)} seconds`);
|
|
} catch (error) {
|
|
console.error(`[muxer-aup3] Error creating AUP3 project:`, error);
|
|
}
|
|
}
|
|
|
|
startMuxingToAup3();
|