JacobLinCool commited on
Commit
0857ab8
·
verified ·
1 Parent(s): 25a5758

Precise beat grid: global (period,phase) fit (app.py)

Browse files
Files changed (1) hide show
  1. app.py +58 -36
app.py CHANGED
@@ -15,9 +15,10 @@ import numpy as np
15
  import torch
16
 
17
  from softchart.generate import generate_song, load_hf
 
18
  from softchart.hf import SoftChartPlanner
19
  from softchart.rhythm import snap_chart
20
- from softchart.vocab import FPS, HOP, N_FFT, N_MELS, SR, WINDOW
21
 
22
  GEN_REPO = os.environ.get("SC_GEN", "JacobLinCool/softchart-generator")
23
  BEAT_REPO = os.environ.get("SC_BEAT", "JacobLinCool/softchart-beat")
@@ -61,27 +62,6 @@ def load_logmel(path):
61
  return mel, wav
62
 
63
 
64
- def predict_downbeats(beat_model, mel):
65
- from scipy.signal import find_peaks
66
- from softchart.generate import _autocast
67
-
68
- T = mel.shape[1]
69
- L = WINDOW // 4
70
- acc = np.zeros(T // 4 + L)
71
- cnt = np.zeros(T // 4 + L)
72
- for st in range(0, max(T - 1, 1), WINDOW):
73
- w = torch.from_numpy(mel[:, st:st + WINDOW].astype(np.float32))
74
- if w.shape[1] < WINDOW:
75
- w = torch.nn.functional.pad(w, (0, WINDOW - w.shape[1]), value=float(np.log(1e-5)))
76
- with torch.no_grad(), _autocast(DEVICE):
77
- mem = beat_model.encode(w[None].to(DEVICE))
78
- pr = torch.sigmoid(beat_model.beat(mem[:, -L:]).float())[0, :, 1].cpu().numpy()
79
- acc[st // 4: st // 4 + L] += pr
80
- cnt[st // 4: st // 4 + L] += 1
81
- acc /= np.maximum(cnt, 1)
82
- return find_peaks(acc, height=0.4, distance=int(0.8 * FPS / 4))[0] * 4 / FPS
83
-
84
-
85
  def auto_plan(mel, bpm, downbeats=None):
86
  T = mel.shape[1]
87
  dur = T / FPS
@@ -154,20 +134,37 @@ def group_quantize(times, phase, grid, min_run=3):
154
  return slots
155
 
156
 
157
- def write_tja(gen, bpm, title, course, level, downbeats=None):
158
  hits = sorted((h["t"], CHAR[h["type"]]) for h in gen["hits"])
159
- times = np.array([t for t, _ in hits]) if hits else np.array([0.0])
160
  beat = 60.0 / bpm
161
  grid = beat / (SUB / 4)
162
- cands = np.arange(0, beat, grid / 4)
163
- phase = float(cands[int(np.argmin([np.mean(np.abs(((times - o) / grid) - np.round((times - o) / grid))) for o in cands]))])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
164
  slots = {}
165
- for idx, (t, ch) in zip(group_quantize([t for t, _ in hits], phase, grid), hits):
166
  if idx >= 0 and idx not in slots:
167
  slots[idx] = ch
168
  for sp in gen["spans"]:
169
- i0 = int(round((sp["t0"] - phase) / grid))
170
- i1 = int(round((sp["t1"] - phase) / grid))
171
  while i0 in slots:
172
  i0 += 1
173
  while i1 in slots or i1 <= i0:
@@ -175,7 +172,9 @@ def write_tja(gen, bpm, title, course, level, downbeats=None):
175
  if i0 >= 0:
176
  slots[i0] = CHAR[sp["type"]]
177
  slots[i1] = "8"
178
- if slots:
 
 
179
  first_t = min(slots) * grid + phase
180
  anchor_t = None
181
  if downbeats is not None and len(downbeats):
@@ -237,17 +236,34 @@ def generate(audio, course, level, bpm_override, auto_plan_on, use_beat, use_pla
237
  raise gr.Error("Please upload an audio file.")
238
  M = get_models()
239
  mel, wav = load_logmel(audio)
240
- dbs = None
241
  if use_beat and M["beat"] is not None:
242
- dbs = predict_downbeats(M["beat"], mel)
 
 
 
 
 
 
 
 
243
  if bpm_override and bpm_override > 0:
244
  bpm = float(bpm_override)
 
 
 
 
245
  elif dbs is not None and len(dbs) > 4:
246
- bpm = 60.0 / float(np.median(np.diff(dbs)))
 
 
 
 
 
247
  else:
248
  import librosa
249
  bpm = float(np.atleast_1d(librosa.beat.beat_track(y=wav, sr=SR)[0])[0])
250
- if abs(bpm - round(bpm)) < 0.06:
251
  bpm = float(round(bpm))
252
 
253
  plan = None
@@ -262,12 +278,18 @@ def generate(audio, course, level, bpm_override, auto_plan_on, use_beat, use_pla
262
  seed=0, device=DEVICE, plan=plan)
263
  g = snap_chart(g, bpm)
264
  title = os.path.splitext(os.path.basename(audio))[0]
265
- tja = write_tja(g, bpm, title, course, int(level), dbs)
266
  tja_path = tempfile.mktemp(suffix=f"_{course}.tja")
267
  with open(tja_path, "w") as f:
268
  f.write(tja)
269
  img = render(mel, g, title, course)
270
- info = (f"BPM {bpm:.1f} · {len(g['hits'])} notes · {len(g['spans'])} spans"
 
 
 
 
 
 
271
  + (f" · plan: {sum(1 for b in plan if b[3]==1)} gaps, {sum(1 for b in plan if b[3]==2)} climax" if plan else ""))
272
  return img, tja_path, info
273
 
 
15
  import torch
16
 
17
  from softchart.generate import generate_song, load_hf
18
+ from softchart.grid import debias_to_grid, fit_grid
19
  from softchart.hf import SoftChartPlanner
20
  from softchart.rhythm import snap_chart
21
+ from softchart.vocab import FPS, HOP, N_FFT, N_MELS, SR
22
 
23
  GEN_REPO = os.environ.get("SC_GEN", "JacobLinCool/softchart-generator")
24
  BEAT_REPO = os.environ.get("SC_BEAT", "JacobLinCool/softchart-beat")
 
62
  return mel, wav
63
 
64
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
65
  def auto_plan(mel, bpm, downbeats=None):
66
  T = mel.shape[1]
67
  dur = T / FPS
 
134
  return slots
135
 
136
 
137
+ def write_tja(gen, bpm, title, course, level, downbeats=None, grid_fit=None):
138
  hits = sorted((h["t"], CHAR[h["type"]]) for h in gen["hits"])
 
139
  beat = 60.0 / bpm
140
  grid = beat / (SUB / 4)
141
+ bias = 0.0
142
+ if grid_fit is not None and hits:
143
+ # authoritative fitted grid: barlines ARE the fitted downbeats.
144
+ # De-bias the generator's systematic latency (global shift only),
145
+ # then anchor slot 0 on the last fitted barline at/before the first note.
146
+ times, bias = debias_to_grid([t for t, _ in hits], grid_fit["phase"], grid)
147
+ phase = grid_fit["phase"] + float(np.floor((times[0] - grid_fit["phase"]) / (4 * beat))) * 4 * beat
148
+ q_times = list(times)
149
+ else:
150
+ times = np.array([t for t, _ in hits]) if hits else np.array([0.0])
151
+ cands = np.arange(0, beat, grid / 4)
152
+ phase = float(cands[int(np.argmin([np.mean(np.abs(((times - o) / grid) - np.round((times - o) / grid))) for o in cands]))])
153
+ q_times = [t for t, _ in hits]
154
+ slot_idx = group_quantize(q_times, phase, grid)
155
+ if slot_idx and min(slot_idx) < 0:
156
+ # note quantized just before the anchor barline: pull back whole bars
157
+ # so nothing is dropped (barline alignment is preserved mod SUB)
158
+ nb = int(np.ceil(-min(slot_idx) / SUB))
159
+ slot_idx = [s + nb * SUB for s in slot_idx]
160
+ phase -= nb * SUB * grid
161
  slots = {}
162
+ for idx, (t, ch) in zip(slot_idx, hits):
163
  if idx >= 0 and idx not in slots:
164
  slots[idx] = ch
165
  for sp in gen["spans"]:
166
+ i0 = int(round((sp["t0"] - bias - phase) / grid))
167
+ i1 = int(round((sp["t1"] - bias - phase) / grid))
168
  while i0 in slots:
169
  i0 += 1
170
  while i1 in slots or i1 <= i0:
 
172
  if i0 >= 0:
173
  slots[i0] = CHAR[sp["type"]]
174
  slots[i1] = "8"
175
+ if slots and grid_fit is None:
176
+ # legacy anchoring (no trusted grid): shift so the first note sits on a
177
+ # detected downbeat if one is nearby, else on the first barline
178
  first_t = min(slots) * grid + phase
179
  anchor_t = None
180
  if downbeats is not None and len(downbeats):
 
236
  raise gr.Error("Please upload an audio file.")
237
  M = get_models()
238
  mel, wav = load_logmel(audio)
239
+ grid = dbs = None
240
  if use_beat and M["beat"] is not None:
241
+ # global robust (period, phase) fit over the whole song — much more
242
+ # precise than per-peak use (each raw peak carries ~±23 ms bin noise)
243
+ grid = fit_grid(M["beat"], mel, device=DEVICE)
244
+ if grid is not None:
245
+ # rigid synthesized barlines when the fit is trustworthy; raw peaks
246
+ # (plan-block edges only, no anchoring) when it is not
247
+ dbs = grid["downbeats"] if grid["ok"] else grid["db_peaks"]
248
+ if not grid["ok"]:
249
+ grid = None
250
  if bpm_override and bpm_override > 0:
251
  bpm = float(bpm_override)
252
+ if grid is not None and abs(grid["bpm"] - bpm) > 0.5:
253
+ grid = None # user disagrees with the fit: don't anchor to it
254
+ elif grid is not None:
255
+ bpm = grid["bpm"] # already integer-snapped when the residual allows
256
  elif dbs is not None and len(dbs) > 4:
257
+ period = float(np.median(np.diff(dbs))) # downbeat gap = one 4/4 bar
258
+ bpm = 240.0 / period if period > 0 else 0.0
259
+ while bpm >= 210: # octave guard
260
+ bpm /= 2.0
261
+ while 0 < bpm < 70:
262
+ bpm *= 2.0
263
  else:
264
  import librosa
265
  bpm = float(np.atleast_1d(librosa.beat.beat_track(y=wav, sr=SR)[0])[0])
266
+ if grid is None and abs(bpm - round(bpm)) < 0.06:
267
  bpm = float(round(bpm))
268
 
269
  plan = None
 
278
  seed=0, device=DEVICE, plan=plan)
279
  g = snap_chart(g, bpm)
280
  title = os.path.splitext(os.path.basename(audio))[0]
281
+ tja = write_tja(g, bpm, title, course, int(level), dbs, grid_fit=grid)
282
  tja_path = tempfile.mktemp(suffix=f"_{course}.tja")
283
  with open(tja_path, "w") as f:
284
  f.write(tja)
285
  img = render(mel, g, title, course)
286
+ if grid is not None:
287
+ grid_info = f" · grid: rms {grid['rms_ms']:.1f}ms ({grid['inlier_frac']:.0%} inlier)"
288
+ elif use_beat and M["beat"] is not None:
289
+ grid_info = " · grid: unreliable, barline anchoring off"
290
+ else:
291
+ grid_info = ""
292
+ info = (f"BPM {bpm:.1f} · {len(g['hits'])} notes · {len(g['spans'])} spans" + grid_info
293
  + (f" · plan: {sum(1 for b in plan if b[3]==1)} gaps, {sum(1 for b in plan if b[3]==2)} climax" if plan else ""))
294
  return img, tja_path, info
295