@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.
- package/package.json +3 -3
- package/skills/VERSION +1 -1
- package/skills/vos-score/SKILL.md +225 -0
- package/skills/vos-score/references/arranging.md +92 -0
- package/skills/vos-score/references/new-jack-swing-124.json +38 -0
- package/skills/vos-score/references/spec.md +79 -0
- package/skills/vos-score/scripts/analyze.py +225 -0
- package/skills/vos-score/scripts/compose.py +634 -0
- package/skills/vos-score/scripts/master.py +74 -0
|
@@ -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()
|