@vosjs/cli 0.73.0 → 0.74.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,74 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Bring a composed WAV to a loudness target with ONE constant gain, then
4
+ encode it to a 192k MP3.
5
+
6
+ python master.py score.wav score.mp3 [--lufs -16] [--tp -1.5]
7
+
8
+ A constant gain keeps the arrangement's section contrast exactly as composed.
9
+ Single-pass ffmpeg `loudnorm` does not: it rides the gain over a few seconds,
10
+ which flattens the build into the groove and the chorus into the drop, so
11
+ the shape analyze.py checked is no longer the shape that ships. This runs
12
+ loudnorm twice (measure, then `linear=true`). If ffmpeg cannot stay linear
13
+ (the gain would push the true peak over --tp), it applies a plain gain capped
14
+ by the peak instead and says how far under the target that leaves the file.
15
+ Needs ffmpeg with libmp3lame.
16
+ """
17
+ import argparse
18
+ import json
19
+ import re
20
+ import subprocess
21
+ import sys
22
+
23
+
24
+ def loudnorm_json(stderr):
25
+ m = re.findall(r'\{[^{}]*"input_i"[^{}]*\}', stderr)
26
+ if not m:
27
+ sys.exit('ffmpeg printed no loudnorm measurement:\n' + stderr[-2000:])
28
+ return json.loads(m[-1])
29
+
30
+
31
+ def run(cmd):
32
+ out = subprocess.run(cmd, capture_output=True, text=True)
33
+ if out.returncode != 0:
34
+ sys.exit(f'{" ".join(cmd[:3])}... failed:\n{out.stderr[-2000:]}')
35
+ return out.stderr
36
+
37
+
38
+ def main():
39
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
40
+ ap.add_argument('wav')
41
+ ap.add_argument('mp3')
42
+ ap.add_argument('--lufs', type=float, default=-16.0, help='integrated loudness target (default -16)')
43
+ ap.add_argument('--tp', type=float, default=-1.5, help='true-peak ceiling, dBTP (default -1.5)')
44
+ ap.add_argument('--lra', type=float, default=20.0, help='loudness range allowed before ffmpeg gives up linear mode')
45
+ a = ap.parse_args()
46
+
47
+ target = f'I={a.lufs}:TP={a.tp}:LRA={a.lra}'
48
+ m = loudnorm_json(run(['ffmpeg', '-hide_banner', '-i', a.wav, '-af',
49
+ f'loudnorm={target}:print_format=json', '-f', 'null', '-']))
50
+ print(f'measured: {float(m["input_i"]):.1f} LUFS, true peak {float(m["input_tp"]):.1f} dBTP, '
51
+ f'range {float(m["input_lra"]):.1f} LU')
52
+
53
+ second = (f'loudnorm={target}:linear=true:print_format=json:measured_I={m["input_i"]}:'
54
+ f'measured_TP={m["input_tp"]}:measured_LRA={m["input_lra"]}:'
55
+ f'measured_thresh={m["input_thresh"]}:offset={m["target_offset"]}')
56
+ r = loudnorm_json(run(['ffmpeg', '-hide_banner', '-y', '-i', a.wav, '-af', second, '-ar', '48000',
57
+ '-c:a', 'libmp3lame', '-b:a', '192k', a.mp3]))
58
+ kind = r.get('normalization_type', '?')
59
+ if kind == 'linear':
60
+ print(f'wrote {a.mp3}: one gain of {a.lufs - float(m["input_i"]):+.2f} dB, '
61
+ f'{float(r["output_i"]):.1f} LUFS, true peak {float(r["output_tp"]):.1f} dBTP')
62
+ return
63
+
64
+ # ffmpeg fell back to dynamic: never ship that, apply a plain capped gain.
65
+ gain = min(a.lufs - float(m['input_i']), a.tp - float(m['input_tp']))
66
+ run(['ffmpeg', '-hide_banner', '-y', '-i', a.wav, '-af', f'volume={gain:.3f}dB', '-ar', '48000',
67
+ '-c:a', 'libmp3lame', '-b:a', '192k', a.mp3])
68
+ level = float(m['input_i']) + gain
69
+ print(f'loudnorm could not stay linear ({kind}); wrote {a.mp3} with one gain of {gain:+.2f} dB: '
70
+ f'about {level:.1f} LUFS, {a.lufs - level:.1f} LU under the target, so the true peak stays under {a.tp:g} dBTP')
71
+
72
+
73
+ if __name__ == '__main__':
74
+ main()