@vosjs/cli 0.72.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,225 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Verify a score by numbers, because an agent cannot listen.
4
+
5
+ python analyze.py score.mp3 --spec spec.json [--lufs -16]
6
+ python analyze.py score.mp3 --bpm 124 --bars 30
7
+
8
+ Prints the tempo and first-beat fit (kick-band onset flux), the length, the
9
+ integrated loudness, per-bar loudness (all bands and the 30 to 150 Hz low
10
+ band), and, with a spec, per-section loudness against the reference section
11
+ and the checks below. Exits 1 when a check fails. Needs ffmpeg, numpy, scipy.
12
+
13
+ The checks (with --spec):
14
+ grid the tempo fit is the spec's BPM within 0.05, beat 1 within 0.02 s of t = 0
15
+ length the file is the program's length within 0.05 s
16
+ order a section with more energy is at least 0.5 dB louder than one with less
17
+ biggest the highest-energy section is at least 1 dB louder than every other
18
+ holes no bar falls more than 10 dB under the bar before it (a fill is not a hole)
19
+ ring the bar after the last hit is quieter than the hit's bar (the ending rings out)
20
+ lufs integrated loudness within 1 LU of --lufs, when given
21
+ """
22
+ import argparse
23
+ import json
24
+ import re
25
+ import subprocess
26
+ import sys
27
+
28
+ import numpy as np
29
+ from scipy import signal
30
+
31
+ SR = 11025
32
+ # The energy each groove texture plays at by default (compose.py's TEXTURE_ENERGY).
33
+ TEXTURE_ENERGY = {'build': 0.65, 'groove': 0.72, 'stop': 0.75, 'pump': 1.0, 'drop': 1.05, 'dark': 0.8, 'chorus': 1.25}
34
+ GROOVES = tuple(TEXTURE_ENERGY)
35
+ ORDER_GAP = 0.03 # energies closer than this are not ordered
36
+ ORDER_STEP = 0.5 # dB a higher-energy section must clear
37
+ BIGGEST_STEP = 1.0 # dB the biggest moment must clear
38
+ HOLE = 10.0 # dB a bar may fall under the bar before it
39
+
40
+
41
+ def decode(path):
42
+ out = subprocess.run(
43
+ ['ffmpeg', '-v', 'error', '-i', path, '-ac', '1', '-ar', str(SR), '-f', 'f32le', '-'],
44
+ capture_output=True,
45
+ )
46
+ if out.returncode != 0:
47
+ sys.exit(f'ffmpeg could not read {path}: {out.stderr.decode().strip()}')
48
+ return np.frombuffer(out.stdout, dtype=np.float32).astype(np.float64)
49
+
50
+
51
+ def integrated_lufs(path):
52
+ out = subprocess.run(
53
+ ['ffmpeg', '-v', 'info', '-i', path, '-af', 'loudnorm=print_format=json', '-f', 'null', '-'],
54
+ capture_output=True, text=True,
55
+ )
56
+ m = re.search(r'"input_i"\s*:\s*"(-?[\d.]+|-inf)"', out.stderr)
57
+ return float(m.group(1)) if m and m.group(1) != '-inf' else None
58
+
59
+
60
+ def tempo_fit(flux, fps, lo, hi, step):
61
+ """The BPM and first-beat phase whose beat grid lands on the most kick onsets."""
62
+ dur = len(flux) / fps
63
+ best = (-1.0, None, None)
64
+ for bpm in np.arange(lo, hi + step / 2, step):
65
+ P = 60.0 / bpm
66
+ offs = np.arange(0, P, 0.002)
67
+ beats = np.arange(0, dur, P)
68
+ idx = ((offs[:, None] + beats[None, :]) * fps).astype(int)
69
+ valid = idx < len(flux)
70
+ scores = np.where(valid, flux[np.minimum(idx, len(flux) - 1)], 0).sum(axis=1)
71
+ k = int(np.argmax(scores))
72
+ if scores[k] > best[0]:
73
+ best = (scores[k], bpm, offs[k])
74
+ return best[1], best[2]
75
+
76
+
77
+ def db(x):
78
+ return 20 * np.log10(np.sqrt(np.mean(x ** 2)) + 1e-9) if len(x) else -180.0
79
+
80
+
81
+ def main():
82
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
83
+ ap.add_argument('audio')
84
+ ap.add_argument('--spec', help='the compose spec: BPM, bars and the sections to check')
85
+ ap.add_argument('--bpm', type=float, help='the grid tempo, without a spec')
86
+ ap.add_argument('--bars', type=int, help='the number of bars, without a spec')
87
+ ap.add_argument('--lufs', type=float, help='check integrated loudness against this target (the catalog is -16)')
88
+ a = ap.parse_args()
89
+
90
+ spec = None
91
+ if a.spec:
92
+ with open(a.spec) as f:
93
+ spec = json.load(f)
94
+ bpm = float(spec['bpm']) if spec else a.bpm
95
+ bars = int(spec['bars']) if spec else a.bars
96
+
97
+ x = decode(a.audio)
98
+ sos = signal.butter(4, [30, 150], 'bandpass', fs=SR, output='sos')
99
+ low = signal.sosfilt(sos, x)
100
+ hop = 64
101
+ fps = SR / hop
102
+ n = (len(low) - hop) // hop
103
+ env = np.sqrt(np.mean(low[: n * hop].reshape(n, hop) ** 2, axis=1))
104
+ flux = np.maximum(0, np.diff(env, prepend=env[0]))
105
+
106
+ if bpm:
107
+ fit_bpm, phase = tempo_fit(flux, fps, bpm - 3, bpm + 3, 0.01)
108
+ else:
109
+ coarse, _ = tempo_fit(flux, fps, 70, 180, 0.1)
110
+ fit_bpm, phase = tempo_fit(flux, fps, coarse - 0.3, coarse + 0.3, 0.01)
111
+ P = 60.0 / fit_bpm
112
+ off = min(phase, P - phase) # distance of the beat grid from t = 0
113
+ print(f'tempo fit: {fit_bpm:.2f} BPM, first beat at {phase:.3f} s')
114
+ length = len(x) / SR
115
+ print(f'length: {length:.3f} s')
116
+ lufs = integrated_lufs(a.audio)
117
+ if lufs is not None:
118
+ print(f'integrated loudness: {lufs:.1f} LUFS')
119
+
120
+ checks = []
121
+
122
+ def check(name, ok, line):
123
+ checks.append(ok)
124
+ print(f' {"pass" if ok else "FAIL"} {name:<8} {line}')
125
+
126
+ if not bpm:
127
+ print('(no --spec or --bpm: the fit is a search from 70 to 180 BPM; half and double tempo are possible)')
128
+ return
129
+
130
+ BAR = 4 * 60.0 / bpm
131
+ bars = bars or int(round(length / BAR))
132
+ per_bar = []
133
+ for b in range(bars):
134
+ i0, i1 = int(b * BAR * SR), int((b + 1) * BAR * SR)
135
+ per_bar.append((db(x[i0:i1]), db(low[i0:i1])))
136
+ print('\nbar start s all dB low dB')
137
+ for b, (al, lo) in enumerate(per_bar):
138
+ print(f'{b:3d} {b * BAR:8.3f} {al:6.1f} {lo:6.1f}')
139
+
140
+ print('\nchecks:')
141
+ check('grid', abs(fit_bpm - bpm) <= 0.05 and off <= 0.02,
142
+ f'{fit_bpm:.2f} BPM against {bpm:g}, beat 1 {off:.3f} s from t = 0 (limits 0.05 BPM, 0.02 s)')
143
+ expect_len = float(spec.get('length', bars * BAR)) if spec else bars * BAR
144
+ check('length', abs(length - expect_len) <= 0.05, f'{length:.3f} s against {expect_len:.3f} s')
145
+ if a.lufs is not None:
146
+ ok = lufs is not None and abs(lufs - a.lufs) <= 1.0
147
+ check('lufs', ok, f'{lufs if lufs is not None else "unreadable"} against {a.lufs:g} (within 1 LU)')
148
+
149
+ if spec:
150
+ sections = spec['sections']
151
+ loud = {}
152
+ print('\nsection bars texture energy all dB low dB vs ref')
153
+ ref = next((s for s in sections if s['texture'] == 'groove'), None) or \
154
+ next((s for s in sections if s['texture'] in GROOVES), None)
155
+ for s in sections:
156
+ b0, b1 = s['bars']
157
+ i0, i1 = int(b0 * BAR * SR), int(b1 * BAR * SR)
158
+ loud[s['name']] = (db(x[i0:i1]), db(low[i0:i1]))
159
+ ref_db = loud[ref['name']][0] if ref else 0.0
160
+ for s in sections:
161
+ al, lo = loud[s['name']]
162
+ if s['texture'] in GROOVES:
163
+ s.setdefault('energy', TEXTURE_ENERGY[s['texture']])
164
+ e = s.get('energy')
165
+ es = f'{e:6.2f}' if e is not None else ' '
166
+ mark = ' (ref)' if ref is s else ''
167
+ print(f'{s["name"][:22]:<22} {s["bars"][0]:>4g}-{s["bars"][1]:<4g} {s["texture"]:<7} {es} '
168
+ f'{al:6.1f} {lo:6.1f} {al - ref_db:+5.1f}{mark}')
169
+
170
+ print('\nshape:')
171
+ grooves = [s for s in sections if s['texture'] in GROOVES]
172
+ pairs, closest, order_failed = 0, None, False
173
+ for i, s in enumerate(grooves):
174
+ for t in grooves[i + 1:]:
175
+ hi_s, lo_s = (s, t) if s['energy'] > t['energy'] else (t, s)
176
+ if abs(s['energy'] - t['energy']) < ORDER_GAP:
177
+ continue
178
+ pairs += 1
179
+ step = loud[hi_s['name']][0] - loud[lo_s['name']][0]
180
+ line = (f'{hi_s["name"]} (energy {hi_s["energy"]:g}) is {step:+.1f} dB over {lo_s["name"]} '
181
+ f'(energy {lo_s["energy"]:g}), needs +{ORDER_STEP:g}')
182
+ if step < ORDER_STEP:
183
+ order_failed = True
184
+ check('order', False, line)
185
+ elif closest is None or step < closest[0]:
186
+ closest = (step, line)
187
+ if closest and not order_failed:
188
+ check('order', True, f'{pairs} pairs in energy order; the closest: {closest[1]}')
189
+ if grooves:
190
+ top = max(grooves, key=lambda s: s['energy'])
191
+ others = [s for s in sections if s is not top]
192
+ if others:
193
+ runner = max(others, key=lambda s: loud[s['name']][0])
194
+ step = loud[top['name']][0] - loud[runner['name']][0]
195
+ check('biggest', step >= BIGGEST_STEP,
196
+ f'{top["name"]} is {step:+.1f} dB over the next loudest, {runner["name"]} (needs +{BIGGEST_STEP:g})')
197
+ silent = set()
198
+ for s in sections:
199
+ if s['texture'] == 'silence':
200
+ silent.update(range(int(s['bars'][0]), int(np.ceil(s['bars'][1]))))
201
+ worst = (0.0, None)
202
+ for b in range(1, bars):
203
+ if b in silent:
204
+ continue
205
+ fall = per_bar[b - 1][0] - per_bar[b][0]
206
+ if fall > worst[0]:
207
+ worst = (fall, b)
208
+ check('holes', worst[0] <= HOLE,
209
+ f'the deepest fall is {worst[0]:.1f} dB, into bar {worst[1]} (limit {HOLE:g} dB)' if worst[1] is not None
210
+ else 'no bar falls under the bar before it')
211
+ ending = next((s for s in sections if s['texture'] == 'ending'), None)
212
+ if ending and ending.get('hits'):
213
+ hb = int(np.floor(max(h['at'] for h in ending['hits'])))
214
+ if hb + 1 < bars:
215
+ check('ring', per_bar[hb + 1][0] < per_bar[hb][0],
216
+ f'bar {hb + 1} is {per_bar[hb + 1][0] - per_bar[hb][0]:+.1f} dB against the last hit\'s bar {hb}')
217
+
218
+ failed = checks.count(False)
219
+ print(f'\n{len(checks) - failed} of {len(checks)} checks pass' + ('' if not failed else f', {failed} FAIL'))
220
+ if failed:
221
+ sys.exit(1)
222
+
223
+
224
+ if __name__ == '__main__':
225
+ main()