Setting the file. One moment.
Analyze Beatgrid · Music To Video · heygen-com/hyperframes · Skills Docs
ContentsBack to the top of the page Gsap Min
— line 293
This file
Number 29.96
Position 96 of 101
Type Python
Size 24 KB
Lines 542 scripts/ analyze-beatgrid.py
Python · 542 lines · 24 KB
Output is the canonical `audiomap.json` documented in the skill.
16
17 Usage:
18 python3 analyze-beatgrid.py track.mp3 -o audiomap.json
19 python3 analyze-beatgrid.py track.mp3 --print # also print a readable brief
20
21 Deps: ffmpeg/ffprobe on PATH + librosa, numpy, soundfile (band-split heuristics,
22 no learned models / no madmom).
23 """
24
25 from __future__ import annotations
26
27 import argparse
28 import json
29 import subprocess
30 import sys
31 import tempfile
32 from pathlib import Path
33
34 import librosa
35 import numpy as np
36 import soundfile as sf
37
38 # Windows sizes stdio to the ANSI code page (cp1252), which cannot encode the glyphs
39 # the brief prints (Δ, →) — every `--print` run died with UnicodeEncodeError. These
40 # scripts emit UTF-8 on every platform; say so instead of trading the glyphs away.
41 # Carry `errors` across: reconfigure() resets it to "strict", and CPython deliberately
42 # gives stderr "backslashreplace" so the diagnostic path can never itself raise.
43 for _stream in (sys.stdout, sys.stderr):
44 if hasattr (_stream, "reconfigure" ):
45 _stream.reconfigure( encoding = "utf-8" , errors = _stream.errors)
46
47 SR = 22050
48 HOP = 512 # ~23 ms frames
49 AUDIOMAP_VERSION = 2
50
51
52 # ── decode ────────────────────────────────────────────────────────────────
53 def load_audio (path: str ) -> tuple[np.ndarray, int , float ]:
54 """Decode any ffmpeg-readable file to mono float32 @ SR via a temp wav."""
55 with tempfile.NamedTemporaryFile( suffix = ".wav" , delete = False ) as tmp:
56 wav = tmp.name
57 subprocess.run(
58 [ "ffmpeg" , "-y" , "-i" , path, "-ac" , "1" , "-ar" , str ( SR ), wav],
59 capture_output = True , check = True ,
60 )
61 y, sr = sf.read(wav, dtype = "float32" )
62 Path(wav).unlink( missing_ok = True )
63 if y.ndim > 1 :
64 y = y.mean( axis = 1 )
65 return y, sr, len (y) / sr
66
67
68 # ── tempo + beat grid + downbeat phase ──────────────────────────────────────
69 def beat_grid (y: np.ndarray, sr: int ) -> dict :
70 tempo, beat_frames = librosa.beat.beat_track( y = y, sr = sr, hop_length = HOP , units = "frames" )
71 beats = librosa.frames_to_time(beat_frames, sr = sr, hop_length = HOP )
72 return { "bpm" : float (np.atleast_1d(tempo)[ 0 ]), "beats" : beats, "beat_frames" : beat_frames}
73
74
75 def _norm_flux (band: np.ndarray) -> np.ndarray:
76 """Positive first-difference (onset flux) of a band-energy curve, normalized."""
77 flux = np.maximum( 0.0 , np.diff(np.sqrt(band), prepend = band[: 1 ]))
78 return flux / (flux.max() + 1e-9 )
79
80
81 def band_energy_curves (y: np.ndarray, sr: int ) -> dict :
82 """Per-frame band energy + per-band normalized onset flux (for drum typing)."""
83 S = np.abs(librosa.stft(y, hop_length = HOP )) ** 2
84 freqs = librosa.fft_frequencies( sr = sr)
85 # Tight drum bands: kick fundamental, snare body, hihat sizzle. Narrow bands
86 # keep one drum's transient from leaking into another's flux on a full mix.
87 low = (freqs < 150 ) # kick
88 mid = (freqs >= 150 ) & (freqs < 900 ) # snare body
89 high = (freqs >= 5000 ) # hihat / cymbal
90 e_low, e_mid, e_high = S[low].sum( axis = 0 ), S[mid].sum( axis = 0 ), S[high].sum( axis = 0 )
91 return {
92 "S" : S,
93 "low" : e_low, "mid" : e_mid, "high" : e_high,
94 "total" : S.sum( axis = 0 ) + 1e-9 ,
95 "flux_low" : _norm_flux(e_low), "flux_mid" : _norm_flux(e_mid), "flux_high" : _norm_flux(e_high),
96 "flatness" : librosa.feature.spectral_flatness( S = np.sqrt(S))[ 0 ],
97 "centroid" : librosa.feature.spectral_centroid( S = np.sqrt(S), sr = sr)[ 0 ],
98 "n" : S.shape[ 1 ],
99 }
100
101
102 def downbeat_phase (beat_frames: np.ndarray, bc: dict , beats_per_bar: int = 4 ) -> int :
103 """Pick the bar phase whose beats carry the most KICK (low-band) energy."""
104 kick = bc[ "low" ] / bc[ "total" ]
105 best_p, best_score = 0 , - 1.0
106 for p in range (beats_per_bar):
107 idx = [bf for i, bf in enumerate (beat_frames) if (i - p) % beats_per_bar == 0 ]
108 idx = [ min (f, bc[ "n" ] - 1 ) for f in idx]
109 score = float (np.sum([kick[f] for f in idx])) if idx else 0.0
110 if score > best_score:
111 best_p, best_score = p, score
112 return best_p
113
114
115 # ── metrical position: strong / weak / syncopated / off-grid ─────────────────
116 # 16-step bar grid (4 beats x 4 sixteenths). Strength by metrical weight.
117 GRID_CLASS = { 0 : "strong" , 8 : "strong" , 4 : "weak" , 12 : "weak" ,
118 2 : "weak" , 6 : "weak" , 10 : "weak" , 14 : "weak" } # else (odd 16ths) -> syncopated
119
120
121 def classify_metric (t: float , beats: np.ndarray, phase: int , bpb: int = 4 ) -> tuple :
122 """Return (grid_class, bar, beat_in_bar, step16) for a time t."""
123 if len (beats) < 2 :
124 return "off-grid" , - 1 , - 1 , - 1
125 i = int (np.searchsorted(beats, t) - 1 )
126 i = max ( 0 , min (i, len (beats) - 2 ))
127 beat_dur = beats[i + 1 ] - beats[i]
128 frac = (t - beats[i]) / max (beat_dur, 1e-6 ) # 0..1 within the beat
129 sixteenth = int ( round (frac * 4 )) % 4 # nearest 16th in beat
130 carry = 1 if round (frac * 4 ) >= 4 else 0
131 beat_idx = i + carry
132 beat_in_bar = (beat_idx - phase) % bpb # 0..3
133 bar = (beat_idx - phase) // bpb
134 step16 = beat_in_bar * 4 + sixteenth # 0..15
135 # distance to the nearest 16th line (in seconds) → off-grid test
136 nearest = beats[i] + ( round (frac * 4 ) / 4 ) * beat_dur
137 if abs (t - nearest) > 0.5 * (beat_dur / 4 ):
138 return "off-grid" , bar, beat_in_bar + 1 , step16
139 return GRID_CLASS .get(step16, "syncopated" ), bar, beat_in_bar + 1 , step16
140
141
142 # ── drum classification (band-split heuristic) ──────────────────────────────
143 def frame_at (t: float , sr: int , n: int ) -> int :
144 return min ( int ( round (t * sr / HOP )), n - 1 )
145
146
147 def classify_drum (t: float , bc: dict , sr: int ) -> tuple :
148 """(drum_type, energy_norm, feel): which band's onset TRANSIENT dominates.
149
150 Uses per-band normalized flux (relative transient strength), the standard
151 way to separate kick (low) / snare (mid+noise) / hihat (high). Falls back to
152 glitch for noisy non-harmonic bursts and perc when no band clearly leads.
153 """
154 f = frame_at(t, sr, bc[ "n" ])
155 win = slice ( max ( 0 , f - 1 ), min (bc[ "n" ], f + 2 ))
156 fl = float (bc[ "flux_low" ][win].max())
157 fm = float (bc[ "flux_mid" ][win].max())
158 fh = float (bc[ "flux_high" ][win].max())
159 lo = float (bc[ "low" ][win].mean()); md = float (bc[ "mid" ][win].mean())
160 hi = float (bc[ "high" ][win].mean()); tot = float (bc[ "total" ][win].mean())
161 flat = float (bc[ "flatness" ][win].mean())
162 lr, mr, hr = lo / tot, md / tot, hi / tot
163
164 fluxes = { "kick" : fl, "snare" : fm, "hihat" : fh}
165 lead = max (fluxes, key = fluxes.get)
166 lead_val = fluxes[lead]
167 if lead_val < 0.06 : # no real transient → texture/perc
168 drum = "glitch" if flat > 0.30 else "perc"
169 elif lead == "snare" and flat > 0.30 and mr < 0.30 :
170 drum = "glitch" # mid-band but noisy & thin → scratch/glitch
171 else :
172 drum = lead
173 # feel (frequency character)
174 has_bot, has_top, has_mid = lr > 0.30 , hr > 0.20 , mr > 0.30
175 feel = ( "full" if has_bot and has_top and has_mid else
176 "heavy" if has_bot and not has_top else
177 "bright" if has_top and not has_bot else
178 "intimate" if has_mid else "sparse" )
179 return drum, tot, feel
180
181
182 # ── energy structure (RMS @1s) + sections + key moments + builds ────────────
183 def energy_structure (y: np.ndarray, sr: int , dur: float , first_onset: float = 0.0 ) -> dict :
184 rms = librosa.feature.rms( y = y, hop_length = sr)[ 0 ] # ~1s frames
185 rms = rms / (rms.max() + 1e-9 )
186 norms = rms.tolist()
187
188 def lvl (n):
189 return "VOID" if n < 0.2 else "LOW" if n < 0.4 else "MEDIUM" if n < 0.65 else "HIGH"
190
191 phases, cur, cs = [], None , 0
192 for i, n in enumerate (norms):
193 l = lvl(n)
194 if l != cur:
195 if cur:
196 phases.append({ "s" : cs, "e" : i, "lvl" : cur})
197 cur, cs = l, i
198 if cur:
199 phases.append({ "s" : cs, "e" : len (norms), "lvl" : cur})
200
201 moments = []
202 for i in range ( 1 , len (norms)):
203 d = norms[i] - norms[i - 1 ]
204 if abs (d) > 0.12 :
205 moments.append({ "t" : i, "kind" : "DROP" if d < 0 else "SURGE" , "delta" : round (d, 2 )})
206 moments.sort( key =lambda m: abs (m[ "delta" ]), reverse = True )
207
208 # hard stop: a HIGH→low cliff (a sudden stop) in the back third
209 hard_stops = [m for m in moments if m[ "kind" ] == "DROP" and m[ "t" ] > dur * 0.6 and m[ "delta" ] < - 0.25 ]
210
211 # NO forced Intro/Build/Drop/Outro template. The energy phases (audio-driven runs of
212 # one energy level, variable count) are the raw structural blocks. The Music Reader
213 # (LLM) decides the actual sections — count, boundaries, and free-form names — from
214 # these phases + key_moments + rolls + hard_stops + phrases. Sections are
215 # interpretation; only the timing they snap to is fact.
216 phases_sec = []
217 for p in phases:
218 seg = norms[p[ "s" ]: max (p[ "s" ] + 1 , p[ "e" ])]
219 phases_sec.append({
220 "start" : float (p[ "s" ]),
221 "end" : float ( min (p[ "e" ], round (dur, 1 ))),
222 "level" : p[ "lvl" ],
223 "energy" : round ( float (np.mean(seg)) if seg else 0.0 , 2 ),
224 })
225
226 return { "norms" : [ round (n, 2 ) for n in norms], "phases" : phases_sec,
227 "moments" : moments[: 8 ], "hard_stops" : hard_stops}
228
229
230 # ── rolls / fills (localized rapid-onset runs) ───────────────────────────────
231 # A roll is where choreography should switch from discrete hits to a continuous /
232 # cascading visual (per-letter cascade, stagger). Derived straight from the onset
233 # stream — runs never overlap, so no dedup is needed (unlike a band-energy detector).
234 ROLL_MIN_HITS = 4
235 ROLL_CONT = 0.55 # × beat_dur: gap up to ~half a beat still keeps a run alive
236 ROLL_ACCEPT = 0.42 # × beat_dur: mean spacing denser than an 8th note counts
237 ROLL_DEDUP = 0.08 # seconds: merge onsets closer than a 32nd (double-trigger)
238
239
240 def detect_rolls (events: list , beat_dur: float ) -> list :
241 """Runs of >=4 onsets whose MEAN spacing is denser than an 8th note. Tuned so a
242 full hihat/snare roll is captured as ONE span (not fragmented down to its tail),
243 while a sparse groove stays out — validated against the golden 7.5-9.5s roll.
244 The linear scan means runs never overlap (no LEGACY-style double-counting)."""
245 cont = beat_dur * ROLL_CONT # max gap that keeps a run alive
246 accept = beat_dur * ROLL_ACCEPT # max MEAN gap for a run to count
247 # collapse onset double-triggers (two onsets < a 32nd apart = one hit) so a
248 # held/sparse passage can't masquerade as a roll on a duplicated transient.
249 ev = []
250 for e in events:
251 if ev and e[ "t" ] - ev[ - 1 ][ "t" ] <= ROLL_DEDUP :
252 if e.get( "energy" , 0 ) > ev[ - 1 ].get( "energy" , 0 ):
253 ev[ - 1 ] = e
254 continue
255 ev.append(e)
256 times = [e[ "t" ] for e in ev]
257 n = len (times)
258 rolls, i = [], 0
259 while i < n - 1 :
260 j = i
261 while j + 1 < n and (times[j + 1 ] - times[j]) <= cont:
262 j += 1
263 if j - i + 1 >= ROLL_MIN_HITS :
264 gaps = [times[k + 1 ] - times[k] for k in range (i, j)]
265 if sum (gaps) / len (gaps) <= accept:
266 t0, t1 = times[i], times[j]
267 half = len (gaps) // 2
268 accel = (half >= 1 and
269 sum (gaps[half:]) / ( len (gaps) - half) <
270 sum (gaps[:half]) / half * 0.85 )
271 dcount: dict[ str , int ] = {}
272 for e in ev[i:j + 1 ]:
273 dcount[e[ "drum" ]] = dcount.get(e[ "drum" ], 0 ) + 1
274 rolls.append({
275 "start" : round (t0, 3 ), "end" : round (t1, 3 ),
276 "dur_sec" : round (t1 - t0, 3 ),
277 "hits" : j - i + 1 ,
278 "rate_per_min" : round ((j - i) / max (t1 - t0, 1e-6 ) * 60 ),
279 "kind" : "accel-roll" if accel else ( "sustained-fill" if t1 - t0 > 1.2 else "fill" ),
280 "drum" : max (dcount, key = dcount.get),
281 })
282 i = j + 1
283 return rolls
284
285
286 # ── per-section spectral character (sustained "feel") ────────────────────────
287 # Coarse, reliable bands for how a SECTION sounds (not the noisy per-second dump).
288 # Distinct from a per-event `feel`, which is the transient color of a single hit.
289 FEEL_BANDS = [( "sub" , 0 , 60 ), ( "bass" , 60 , 250 ), ( "low_mid" , 250 , 800 ),
290 ( "mid" , 800 , 2500 ), ( "presence" , 2500 , 6000 ), ( "air" , 6000 , 1e9 )]
291
292
293 def annotate_section_feel (bc: dict , sr: int , sections: list ) -> None :
294 """Attach {character, bands} to each energy phase from its sustained band balance."""
295 S, n = bc[ "S" ], bc[ "n" ]
296 freqs = librosa.fft_frequencies( sr = sr)
297 fps = sr / HOP
298 masks = [(name, (freqs >= lo) & (freqs < hi)) for name, lo, hi in FEEL_BANDS ]
299 for s in sections:
300 f0 = int (s[ "start" ] * fps)
301 f1 = min ( max (f0 + 1 , int (s[ "end" ] * fps)), n)
302 seg = S[:, f0:f1]
303 if seg.shape[ 1 ] == 0 or s.get( "energy" , 0 ) < 0.15 :
304 s[ "feel" ] = { "character" : "sparse" , "bands" : []}
305 continue
306 en = {name: float (seg[m].sum()) for name, m in masks}
307 tot = sum (en.values()) + 1e-9
308 ratios = {name: en[name] / tot for name in en}
309 present = sorted ([bn for bn, r in ratios.items() if r > 0.12 ],
310 key =lambda bn: - ratios[bn])
311 lo = ratios[ "sub" ] + ratios[ "bass" ]
312 hi = ratios[ "presence" ] + ratios[ "air" ]
313 mid = ratios[ "low_mid" ] + ratios[ "mid" ]
314 char = ( "heavy" if lo > 0.5 else "bright" if hi > 0.45 else
315 "full" if lo > 0.25 and hi > 0.25 else
316 "warm" if mid > 0.5 else "sparse" )
317 s[ "feel" ] = { "character" : char, "bands" : present}
318
319
320 # ── audiomap enrichment: phrase layer + section density budgets ─────────────
321 def round3 (n: float ) -> float :
322 return round ( float (n), 3 )
323
324
325 def derive_phrases (downbeats: list[ float ], phrase_bars: int , duration_sec: float ) -> list :
326 """Group downbeats into phrase spans of `phrase_bars` bars."""
327 phrases = []
328 if not downbeats:
329 return phrases
330 index = 0
331 for i in range ( 0 , len (downbeats), phrase_bars):
332 start = downbeats[i]
333 next_idx = i + phrase_bars
334 end = downbeats[next_idx] if next_idx < len (downbeats) else duration_sec
335 phrases.append({
336 "index" : index,
337 "start" : round3(start),
338 "end" : round3(end),
339 "bars" : min (phrase_bars, len (downbeats) - i),
340 })
341 index += 1
342 return phrases
343
344
345 def count_in (times: list[ float ], start: float , end: float ) -> int :
346 return sum ( 1 for t in times if t >= start - 1e-6 and t < end - 1e-6 )
347
348
349 def derive_phase_budgets (timeline: dict ) -> list :
350 """Attach an objective density read to each energy phase.
351
352 Density is a fact (onsets-per-second + rolls). It is a hint for how much visual
353 content a span can hold; it does not set timing or sections. The Music Reader uses
354 these phases to decide the actual sections.
355 """
356 phases = timeline.get( "energy_phases" , [])
357 onset_times = [e[ "t" ] for e in timeline.get( "events" , [])]
358 rolls = timeline.get( "rolls" , [])
359 hard_stops = timeline.get( "hard_stops" , [])
360
361 out = []
362 for s in phases:
363 span = max ( 1e-6 , float (s.get( "end" , 0 )) - float (s.get( "start" , 0 )))
364 onsets = count_in(onset_times, s[ "start" ], s[ "end" ])
365 ph_rolls = [
366 { "start" : r[ "start" ], "end" : r[ "end" ], "kind" : r[ "kind" ], "drum" : r[ "drum" ]}
367 for r in rolls
368 if r[ "start" ] < s[ "end" ] - 1e-6 and r[ "end" ] > s[ "start" ] + 1e-6
369 ]
370 ph_stops = [
371 h[ "t" ]
372 for h in hard_stops
373 if h[ "t" ] >= s[ "start" ] - 1e-6 and h[ "t" ] < s[ "end" ] + 1e-6
374 ]
375
376 if s.get( "energy" , 0 ) < 0.2 or onsets < 6 :
377 density = "sparse"
378 elif onsets >= 18 or ph_rolls:
379 density = "dense"
380 else :
381 density = "medium"
382
383 enriched = dict (s)
384 enriched[ "onsets" ] = onsets
385 enriched[ "onsetRate" ] = round (onsets / span, 1 )
386 enriched[ "rolls" ] = ph_rolls
387 enriched[ "hardStops" ] = ph_stops
388 enriched[ "density" ] = density
389 out.append(enriched)
390 return out
391
392
393 def finalize_audiomap (timeline: dict , phrase_bars: int = 4 ) -> dict :
394 downbeats = timeline.get( "grid" , {}).get( "downbeats_sec" , [])
395 duration_sec = timeline.get( "audio" , {}).get( "duration_sec" , 0 )
396 energy_phases = derive_phase_budgets(timeline)
397 phrases = derive_phrases(downbeats, phrase_bars, duration_sec)
398 return {
399 "version" : AUDIOMAP_VERSION ,
400 "phraseBars" : phrase_bars,
401 ** timeline,
402 "energy_phases" : energy_phases,
403 "phrases" : phrases,
404 }
405
406
407 # ── main ────────────────────────────────────────────────────────────────────
408 def analyze (path: str , phrase_bars: int = 4 ) -> dict :
409 y, sr, dur = load_audio(path)
410 bg = beat_grid(y, sr)
411 bc = band_energy_curves(y, sr)
412 phase = downbeat_phase(bg[ "beat_frames" ], bc)
413 beats = bg[ "beats" ]
414 downbeats = [ float (beats[i]) for i in range ( len (beats)) if (i - phase) % 4 == 0 ]
415
416 # onsets → events
417 onset_t = librosa.onset.onset_detect(
418 y = y, sr = sr, hop_length = HOP , units = "time" , backtrack = True
419 )
420 en_at = bc[ "total" ]
421 en_max = float (en_at.max()) + 1e-9
422 events = []
423 for t in onset_t:
424 gclass, bar, bib, step16 = classify_metric( float (t), beats, phase)
425 drum, energy, feel = classify_drum( float (t), bc, sr)
426 f = frame_at( float (t), sr, bc[ "n" ])
427 events.append({
428 "t" : round ( float (t), 3 ),
429 "bar" : int (bar), "beat_in_bar" : int (bib), "step16" : int (step16),
430 "grid" : gclass, "drum" : drum,
431 "energy" : round ( float (en_at[f]) / en_max, 2 ), "feel" : feel,
432 "special" : None ,
433 })
434
435 first_onset = next (( float (t) for t in onset_t if t >= 2.0 ), 0.0 )
436 es = energy_structure(y, sr, dur, first_onset)
437
438 # tag specials onto nearby events
439 for hs in es[ "hard_stops" ]:
440 for e in events:
441 if abs (e[ "t" ] - hs[ "t" ]) < 0.6 :
442 e[ "special" ] = "hard_stop"
443 # riser: events inside a 1.5s+ rising-energy run that precedes a SURGE
444 surges = [m[ "t" ] for m in es[ "moments" ] if m[ "kind" ] == "SURGE" ]
445 for st in surges:
446 for e in events:
447 if st - 2.0 <= e[ "t" ] < st and e[ "special" ] is None and e[ "drum" ] in ( "perc" , "glitch" ):
448 e[ "special" ] = "riser"
449
450 # rolls / fills + whether each leads straight into a surge/drop (cascade cue)
451 beat_dur = float (np.median(np.diff(beats))) if len (beats) > 1 else 60.0 / max (bg[ "bpm" ], 1e-6 )
452 rolls = detect_rolls(events, beat_dur)
453 for r in rolls:
454 r[ "leads_to" ] = next ((m[ "kind" ] for m in es[ "moments" ]
455 if 0 <= m[ "t" ] - r[ "end" ] <= 1.2 ), None )
456 # near-silent windows (= VOID energy phases) — convenience for "hold / breathe"
457 silences = [{ "start" : p[ "start" ], "end" : p[ "end" ]}
458 for p in es[ "phases" ] if p[ "level" ] == "VOID" ]
459 # per-phase sustained spectral character (how each energy block FEELS)
460 annotate_section_feel(bc, sr, es[ "phases" ])
461
462 n_drum = {}
463 for e in events:
464 n_drum[e[ "drum" ]] = n_drum.get(e[ "drum" ], 0 ) + 1
465 n_grid = {}
466 for e in events:
467 n_grid[e[ "grid" ]] = n_grid.get(e[ "grid" ], 0 ) + 1
468
469 summary = ( f " { bg[ 'bpm' ] :.0f} BPM · { len (beats) } beats / { len (downbeats) } bars · "
470 f " { len (events) } events ( { n_drum } ) · { len (rolls) } rolls · "
471 f " { len (es[ 'phases' ]) } energy phases · { dur :.1f} s" )
472
473 timeline = {
474 "summary" : summary,
475 "audio" : { "path" : path, "duration_sec" : round (dur, 3 ), "sr" : sr},
476 "tempo" : { "bpm" : round (bg[ "bpm" ], 1 ), "beats_per_bar" : 4 ,
477 "downbeat_phase" : phase, "n_beats" : len (beats), "n_bars" : len (downbeats)},
478 "grid" : { "beats_sec" : [ round ( float (b), 3 ) for b in beats],
479 "downbeats_sec" : [ round (b, 3 ) for b in downbeats]},
480 "energy_phases" : es[ "phases" ],
481 "key_moments" : es[ "moments" ],
482 "hard_stops" : es[ "hard_stops" ],
483 "rolls" : rolls,
484 "silences" : silences,
485 "stats" : { "drum_counts" : n_drum, "grid_counts" : n_grid},
486 "events" : events,
487 }
488 return finalize_audiomap(timeline, phrase_bars)
489
490
491 def print_brief (d: dict ) -> None :
492 print ( f " \n{ d[ 'summary' ] }\n{ '=' * 70 } " )
493 print ( "ENERGY PHASES (audio-driven blocks; the Music Reader names the sections)" )
494 for s in d.get( "energy_phases" , []):
495 feel = s.get( "feel" , {})
496 bands = "," .join(feel.get( "bands" , []))
497 print ( f " { s[ 'start' ] :5.1f} - { s[ 'end' ] :5.1f} s { s.get( 'level' , '' ) :6s} energy= { s.get( 'energy' ) } "
498 f " { feel.get( 'character' , '' ) :6s} [ { bands } ] { s.get( 'density' , '?' ) } " )
499 print ( "PHRASES" )
500 for p in d.get( "phrases" , []):
501 print ( f " # { p[ 'index' ] } { p[ 'start' ] :5.2f} - { p[ 'end' ] :5.2f} s bars= { p[ 'bars' ] } " )
502 print ( "KEY MOMENTS" )
503 for m in d[ "key_moments" ]:
504 print ( f " { m[ 't' ] :3d} s { m[ 'kind' ] :5s} Δ { m[ 'delta' ] :+.2f} " )
505 print ( f "HARD STOPS: { [h[ 't' ] for h in d[ 'hard_stops' ]] } " )
506 print ( "ROLLS / FILLS" )
507 for r in d.get( "rolls" , []):
508 lead = f " → { r[ 'leads_to' ] } " if r.get( "leads_to" ) else ""
509 print ( f " { r[ 'start' ] :6.2f} - { r[ 'end' ] :5.2f} s { r[ 'hits' ] :2d} hits @ { r[ 'rate_per_min' ] :4d} /min "
510 f " { r[ 'kind' ] :10s} ( { r[ 'drum' ] } ) { lead } " )
511 print ( f "SILENCES: { [(s[ 'start' ], s[ 'end' ]) for s in d.get( 'silences' , [])] } " )
512 print ( f "DRUM COUNTS: { d[ 'stats' ][ 'drum_counts' ] } GRID: { d[ 'stats' ][ 'grid_counts' ] } " )
513 print ( f " \n EVENTS ( { len (d[ 'events' ]) } ) [t · bar:beat · grid · drum · energy · special]" )
514 for e in d[ "events" ]:
515 sp = f " < { e[ 'special' ] } >" if e[ "special" ] else ""
516 print ( f " { e[ 't' ] :6.2f} s b { e[ 'bar' ] } : { e[ 'beat_in_bar' ] } { e[ 'grid' ] :3s} "
517 f " { e[ 'drum' ] :6s} e= { e[ 'energy' ] :.2f} { e[ 'feel' ] :8s}{ sp } " )
518
519
520 def main () -> None :
521 ap = argparse.ArgumentParser()
522 ap.add_argument( "audio" )
523 ap.add_argument( "-o" , "--out" , default = None )
524 ap.add_argument( "--phrase-bars" , type = int , default = 4 )
525 ap.add_argument( "--print" , action = "store_true" , dest = "do_print" )
526 a = ap.parse_args()
527 d = analyze(a.audio, phrase_bars = a.phrase_bars)
528 if a.out:
529 # ensure_ascii=False means the payload can carry non-ASCII, so the file
530 # encoding cannot be left to the platform default (cp1252 on Windows).
531 Path(a.out).write_text(json.dumps(d, ensure_ascii = False , indent = 2 ), encoding = "utf-8" )
532 dens = " " .join( f " { s.get( 'level' , '?' ) } : { s.get( 'density' , '?' ) } " for s in d.get( "energy_phases" , []))
533 print (
534 f "[analyze-beatgrid] wrote audiomap { a.out } · { len (d.get( 'energy_phases' , [])) } phases · density [ { dens } ]" ,
535 file = sys.stderr,
536 )
537 if a.do_print or not a.out:
538 print_brief(d)
539
540
541 if __name__ == "__main__" :
542 main()