Skip to content

Instantly share code, notes, and snippets.

@RichardKanshen
Created December 30, 2025 09:43
Show Gist options
  • Select an option

  • Save RichardKanshen/36a9be3321bce68ecb18deab65eb19f3 to your computer and use it in GitHub Desktop.

Select an option

Save RichardKanshen/36a9be3321bce68ecb18deab65eb19f3 to your computer and use it in GitHub Desktop.
#!/usr/bin/env node
import {readFileSync} from "fs";
import fs from "fs/promises";
import path from "path";
import { spawn } from "child_process";
import readline from "readline";
import YAML from "yaml";
function parseUSTX(ustxPath) {
const raw = readFileSync(ustxPath, "utf-8");
const data = YAML.parse(raw);
console.log(data)
if (!Array.isArray(data.tracks)) {
throw new Error("Invalid USTX: missing tracks array");
}
return data.tracks
.filter(track => !track.mute)
.map(track => ({
name: track.track_name,
volume: track.volume ?? 0,
pan: (track.pan ?? 0) / 100
}));
}
function prompt(question) {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
return new Promise(resolve =>
rl.question(question, answer => {
rl.close();
resolve(answer);
})
);
}
function sanitizeFFmpegLabel(name) {
return name.replace(/[^a-zA-Z0-9_]/g, "_");
}
const targetDir = process.argv[2];
if (!targetDir) {
console.error("Usage: node openutau-render <directory>");
process.exit(1);
}
const absDir = path.resolve(targetDir);
const files = await fs.readdir(absDir);
const ustxFiles = files.filter(f => f.toLowerCase().endsWith(".ustx"));
if (ustxFiles.length === 0) {
console.error("No USTX files found.");
process.exit(1);
}
let ustxFile;
if (ustxFiles.length === 1) {
ustxFile = ustxFiles[0];
} else {
console.log("Multiple USTX files found:");
ustxFiles.forEach((f, i) => console.log(` [${i}] ${f}`));
const idx = Number(await prompt("Select one: "));
ustxFile = ustxFiles[idx];
}
const ustxPath = path.join(absDir, ustxFile);
const baseName = path.parse(ustxFile).name;
const tracks = parseUSTX(ustxPath);
if (!tracks.length) {
console.error("No tracks found in USTX.");
process.exit(1);
}
const resolvedTracks = [];
for (const track of tracks) {
const wav = path.join(absDir, `${baseName}_${track.name}.wav`);
const mp3 = path.join(absDir, `${baseName}_${track.name}.mp3`);
let filePath;
try {
await fs.access(wav);
filePath = wav;
} catch {
try {
await fs.access(mp3);
filePath = mp3;
} catch {
console.error(`Missing audio for track: ${track.name}; Make sure each track has a unique name, and then export all tracks via File -> Export Audio -> Export Wav Files To..., and choose the same directory as your project files`);
process.exit(1);
}
}
resolvedTracks.push({ ...track, filePath });
}
const ffmpegArgs = [];
const filterParts = [];
const mixInputs = [];
resolvedTracks.forEach((track, i) => {
ffmpegArgs.push("-i", track.filePath);
const label = `a${i}`;
const pan = track.pan;
const volume = track.volume;
const left = (1 - pan) / 2;
const right = (1 + pan) / 2;
filterParts.push(
`[${i}:a]` +
`volume=${volume}dB,` +
`pan=stereo|c0=${left}*c0+${left}*c1|c1=${right}*c0+${right}*c1` +
`[${label}]`
);
mixInputs.push(`[${label}]`);
});
filterParts.push(
`${mixInputs.join("")}amix=inputs=${mixInputs.length}:normalize=0`
);
const outputPath = path.join(absDir, `${baseName}_mixdown.wav`);
ffmpegArgs.push(
"-filter_complex", filterParts.join(";"),
outputPath
);
console.log("Running ffmpeg...");
console.log(ffmpegArgs)
const ffmpeg = spawn("ffmpeg", ffmpegArgs, { stdio: "inherit" });
ffmpeg.on("exit", code => {
if (code === 0) {
console.log("Mixdown complete:", outputPath);
} else {
console.error("ffmpeg failed.");
}
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment