@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,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()
|