Created
December 30, 2025 09:43
-
-
Save RichardKanshen/36a9be3321bce68ecb18deab65eb19f3 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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