Skip to content

Commit 0bde592

Browse files
authored
add local-friendly script for nomalization parity (#1020)
2 parents 60ebfea + 9ced868 commit 0bde592

File tree

1 file changed

+228
-0
lines changed

1 file changed

+228
-0
lines changed
Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
#!/usr/bin/env ts-node
2+
3+
import fs from 'fs';
4+
import path from 'path';
5+
import { promisify } from 'util';
6+
import { exec as execCallback } from 'child_process';
7+
const exec = promisify(execCallback);
8+
9+
import FFMPEGstatic from 'ffmpeg-static';
10+
11+
if (!FFMPEGstatic) {
12+
error('FFMPEGstatic executable not found');
13+
process.exit(1);
14+
}
15+
16+
// string | null here - but we know it's a string from the above check
17+
const FFMPEG = FFMPEGstatic as unknown as string;
18+
19+
function log(s: string) {
20+
// eslint-disable-next-line no-console
21+
console.log(s);
22+
}
23+
function error(s: string) {
24+
// eslint-disable-next-line no-console
25+
console.error(s);
26+
}
27+
28+
function showUsage() {
29+
log(`
30+
Usage: tsx normalize-local.ts <input-directory> [-o <output-directory>]
31+
32+
Options:
33+
-o <output-directory> Directory to write normalized files (created if needed)
34+
Default: same as input directory
35+
36+
Examples:
37+
tsx normalize-local.ts .
38+
tsx normalize-local.ts /path/to/wavs -o /path/to/output
39+
tsx normalize-local.ts . -o ./normalized
40+
`);
41+
}
42+
43+
log(`FFMPEG path: ${FFMPEG}`);
44+
45+
/**
46+
* From FFMPEG's loudnorm output - loudness data on a media file
47+
*/
48+
interface LoudnessData {
49+
input_i: string;
50+
input_tp: string;
51+
input_lra: string;
52+
input_thresh: string;
53+
output_i: string;
54+
output_tp: string;
55+
output_lra: string;
56+
output_thresh: string;
57+
normalization_type: string;
58+
target_offset: string;
59+
}
60+
61+
/**
62+
* Normalizes a single wav file to mp3 with loudnorm
63+
* Same spec as the attachment preprocessing: I=-16:TP=-1.5:LRA=11
64+
*/
65+
async function normalizeFile(
66+
inputPath: string,
67+
outputDir?: string
68+
): Promise<void> {
69+
const tmpDir = fs.mkdtempSync(`audioNormalize-local-`);
70+
const baseName = path.basename(inputPath, '.wav');
71+
const targetDir = outputDir || path.dirname(inputPath);
72+
const outputPath = path.join(targetDir, `${baseName}.normalized.mp3`);
73+
74+
const PADDED = path.join(tmpDir, 'padded.wav');
75+
const PADDED_NORMALIZED = path.join(tmpDir, 'paddedNormalized.wav');
76+
const NORMALIZED = path.join(tmpDir, 'normalized.mp3');
77+
78+
try {
79+
log(`[${baseName}] Processing...`);
80+
81+
// Pad with 10s of silence
82+
log(`[${baseName}] Padding with silence...`);
83+
await exec(
84+
`"${FFMPEG}" -i "${inputPath}" -af "adelay=10000|10000" "${PADDED}"`
85+
);
86+
87+
// Analyze loudness
88+
log(`[${baseName}] Analyzing loudness...`);
89+
const info = await exec(
90+
`"${FFMPEG}" -i "${PADDED}" -af loudnorm=I=-16:TP=-1.5:LRA=11:print_format=json -f null -`
91+
);
92+
93+
const data: LoudnessData = JSON.parse(
94+
info.stderr.substring(info.stderr.indexOf('{'))
95+
);
96+
97+
log(
98+
`[${baseName}] Input loudness: I=${data.input_i} LUFS, TP=${data.input_tp} dBTP, LRA=${data.input_lra} LU`
99+
);
100+
101+
// Normalize the padded file
102+
log(`[${baseName}] Normalizing...`);
103+
await exec(
104+
`"${FFMPEG}" -i "${PADDED}" -af ` +
105+
`loudnorm=I=-16:TP=-1.5:LRA=11:measured_I=${data.input_i}:` +
106+
`measured_LRA=${data.input_lra}:measured_TP=${data.input_tp}:` +
107+
`measured_thresh=${data.input_thresh}:offset=${data.target_offset}:linear=true:` +
108+
`print_format=summary -ar 48k "${PADDED_NORMALIZED}"`
109+
);
110+
111+
// Cut off the padded part and convert to mp3
112+
log(`[${baseName}] Cutting padding and encoding to mp3...`);
113+
await exec(
114+
`"${FFMPEG}" -i "${PADDED_NORMALIZED}" -ss 00:00:10.000 -acodec libmp3lame -b:a 192k "${NORMALIZED}"`
115+
);
116+
117+
// Copy to output location
118+
fs.copyFileSync(NORMALIZED, outputPath);
119+
log(`[${baseName}] ✓ Saved to: ${outputPath}`);
120+
} catch (e) {
121+
error(`[${baseName}] Error: ${e}`);
122+
throw e;
123+
} finally {
124+
// Cleanup temp directory
125+
const files = fs.readdirSync(tmpDir);
126+
files.forEach((file) => {
127+
fs.unlinkSync(path.join(tmpDir, file));
128+
});
129+
fs.rmdirSync(tmpDir);
130+
}
131+
}
132+
133+
async function main() {
134+
// Check FFMPEG availability
135+
try {
136+
if (!fs.existsSync(FFMPEG)) {
137+
error(`FFMPEG executable not found at path: ${FFMPEG}`);
138+
process.exit(1);
139+
}
140+
141+
const result = await exec(`"${FFMPEG}" -version`);
142+
const version = result.stdout.split('\n')[0];
143+
log(`FFMPEG version: ${version}`);
144+
145+
// Verify loudnorm filter availability
146+
const filters = await exec(`"${FFMPEG}" -filters | grep loudnorm`);
147+
if (!filters.stdout.includes('loudnorm')) {
148+
throw new Error('loudnorm filter not available');
149+
}
150+
log('loudnorm filter: available\n');
151+
} catch (e) {
152+
error(`FFMPEG check failed: ${e}`);
153+
process.exit(1);
154+
}
155+
156+
// Parse command line arguments
157+
const args = process.argv.slice(2);
158+
let targetDir = '.';
159+
let outputDir: string | undefined;
160+
161+
// Check for help flag
162+
if (args.includes('-h') || args.includes('--help')) {
163+
showUsage();
164+
process.exit(0);
165+
}
166+
167+
for (let i = 0; i < args.length; i++) {
168+
if (args[i] === '-o' && args[i + 1]) {
169+
outputDir = args[i + 1];
170+
i++; // Skip next arg
171+
} else if (!args[i].startsWith('-')) {
172+
targetDir = args[i];
173+
}
174+
}
175+
176+
const absoluteDir = path.resolve(targetDir);
177+
178+
if (!fs.existsSync(absoluteDir)) {
179+
error(`Directory not found: ${absoluteDir}`);
180+
process.exit(1);
181+
}
182+
183+
if (!fs.statSync(absoluteDir).isDirectory()) {
184+
error(`Not a directory: ${absoluteDir}`);
185+
process.exit(1);
186+
}
187+
188+
// Create output directory if specified
189+
let absoluteOutputDir: string | undefined;
190+
if (outputDir) {
191+
absoluteOutputDir = path.resolve(outputDir);
192+
if (!fs.existsSync(absoluteOutputDir)) {
193+
fs.mkdirSync(absoluteOutputDir, { recursive: true });
194+
log(`Created output directory: ${absoluteOutputDir}`);
195+
} else if (!fs.statSync(absoluteOutputDir).isDirectory()) {
196+
error(`Output path exists but is not a directory: ${absoluteOutputDir}`);
197+
process.exit(1);
198+
}
199+
log(`Output directory: ${absoluteOutputDir}\n`);
200+
}
201+
202+
log(`Scanning directory: ${absoluteDir}\n`);
203+
204+
// Find all .wav files
205+
const files = fs.readdirSync(absoluteDir);
206+
const wavFiles = files.filter((f) => f.toLowerCase().endsWith('.wav'));
207+
208+
if (wavFiles.length === 0) {
209+
log('No .wav files found in directory');
210+
process.exit(0);
211+
}
212+
213+
log(`Found ${wavFiles.length} .wav file(s)\n`);
214+
215+
// Process each file
216+
for (const wavFile of wavFiles) {
217+
const fullPath = path.join(absoluteDir, wavFile);
218+
await normalizeFile(fullPath, absoluteOutputDir);
219+
log(''); // blank line between files
220+
}
221+
222+
log(`\nComplete! Processed ${wavFiles.length} file(s)`);
223+
}
224+
225+
main().catch((error) => {
226+
error('Fatal error:', error);
227+
process.exit(1);
228+
});

0 commit comments

Comments
 (0)