jam-buddy / tools /community_drums.py
salgadev's picture
Sync from GitHub 6feaf31d
b2e4883 verified
Raw
History Blame Contribute Delete
8.71 kB
#!/usr/bin/env python3
"""
Community-mode v0: DI guitar -> tempo map + onset alignment -> drum MIDI.
Takes a DI guitar track and a (notated) tempo/time-map MIDI, builds a
quarter-note beat grid from the tempo curve, nudges each beat to the nearest
detected onset so the drums follow the band's real timing, places a drum
pattern on those beats, and writes a MIDI file (tempo + time sig + ch10
percussion).
Patterns:
blast kick+hat every 16th, snare on 2 & 4
dbeat kick every 16th, snare on 2 & (3-and), hat on 8ths (1 & 3)
double kick every 16th, snare on 2 & 4, hat on 8ths
groove kick 1 & 3, snare 2 & 4, hat 8ths (rock beat)
"""
import argparse
import librosa
import mido
import numpy as np
# General MIDI drum notes
KICK = 36
SNARE = 38
HIHAT = 42
CRASH = 49
# ---------------------------------------------------------------------------
# Tempo map from the MIDI time file -> absolute seconds
# ---------------------------------------------------------------------------
def tempo_map_from_midi(path):
"""Return ([(sec, bpm), ...] in order, default_bpm)."""
mid = mido.MidiFile(path)
tpb = mid.ticks_per_beat or 480
# collect (abs_tick, us) tempo changes and (abs_tick, num, den) sigs
tempo_pts = []
tsig_pts = []
running_us = 500000
abs_tick = 0
for track in mid.tracks:
for msg in track:
abs_tick += msg.time
if msg.type == "set_tempo":
running_us = msg.tempo
tempo_pts.append((abs_tick, running_us))
elif msg.type == "time_signature":
tsig_pts.append((abs_tick, msg.numerator, msg.denominator))
if not tempo_pts:
return [(0.0, 120.0)], 120.0
tempo_pts.sort()
# walk ticks -> seconds using the tempo in effect between anchors
events = []
cur_sec = 0.0
cur_tick = 0
cur_us = tempo_pts[0][1]
for tk, us in tempo_pts:
d_beat = (tk - cur_tick) / tpb
cur_sec += d_beat * (cur_us / 1e6)
cur_tick = tk
cur_us = us
events.append((cur_sec, round(60e6 / us)))
return events, round(60e6 / tempo_pts[0][1])
# ---------------------------------------------------------------------------
# Build a quarter-note grid from the tempo curve
# ---------------------------------------------------------------------------
def build_beat_grid(tempo_events, total_sec):
"""Return sorted list of quarter-beat times (sec) over [0, total_sec]."""
# linear scan: current tempo changes at each event time
beats = []
t = 0.0
idx = 0
while t < total_sec:
beats.append(t)
# tempo currently in effect
while idx < len(tempo_events) - 1 and tempo_events[idx + 1][0] <= t:
idx += 1
bpm = tempo_events[idx][1]
q = 60.0 / bpm
t += q
if q <= 0:
break
return beats
# ---------------------------------------------------------------------------
# Onset alignment
# ---------------------------------------------------------------------------
def onset_align(onset_env, sr, hop, beat_times):
"""Nudge each beat to the local onset-strength peak within +/-120ms."""
win = int(0.12 * sr / hop)
aligned = []
for sec in beat_times:
b = int(round(sec * sr / hop))
lo = max(0, b - win)
hi = min(len(onset_env), b + win + 1)
if hi <= lo:
aligned.append(b)
continue
peak = int(np.argmax(onset_env[lo:hi])) + lo
aligned.append(peak)
return librosa.frames_to_time(aligned, sr=sr, hop_length=hop)
# ---------------------------------------------------------------------------
# Pattern placement
# ---------------------------------------------------------------------------
def place_pattern(kind, beat_times, bpm_curve_at):
"""Return list of (sec, note, velocity)."""
hits = []
for i, t0 in enumerate(beat_times):
beat_in_bar = i % 4
cur_bpm = bpm_curve_at(t0)
# subdivision 16ths
for sub in range(4):
tsub = t0 + sub * (60.0 / cur_bpm) / 4
if kind in ("blast", "double_beat"):
# kick on every 16th
hits.append((tsub, KICK, 95))
# hat on every 16th
hits.append((tsub, HIHAT, 80))
elif kind == "dbeat":
hits.append((tsub, KICK, 95))
hits.append((tsub, HIHAT if sub % 2 == 0 else SNARE, 80))
else: # groove
if sub == 0:
hits.append((tsub, KICK, 95))
# snare on beats 2 & 4 (bar positions 1 and 3)
if beat_in_bar in (1, 3):
hits.append((t0, SNARE, 100))
return hits
# ---------------------------------------------------------------------------
# MIDI write
# ---------------------------------------------------------------------------
def sec_to_tick(s, tempo_events, tpb=960):
"""Absolute tick at time s, integrating the running tempo.
tempo_events = [(sec0, bpm0), (sec1, bpm1), ...] where the tempo changes
to bpm_i at sec_i. Tempo bpm_i applies over [sec_i, sec_{i+1}). The first
event is normally at sec 0.
"""
times = [sec for sec, _ in tempo_events]
bpms = [bpm for _, bpm in tempo_events]
# clamp s to the map range (never past the last change's tempo)
if not times:
return int(round(s * bpms[0] * tpb / 60)) if bpms else 0
# find active segment index i: times[i] <= s < times[i+1]
i = 0
while i < len(times) - 1 and times[i + 1] <= s:
i += 1
# sum full prior segments + partial within segment i
ticks = 0.0
for j in range(i):
ticks += (times[j + 1] - times[j]) * bpms[j] * tpb / 60
ticks += (s - times[i]) * bpms[i] * tpb / 60
return int(round(ticks))
def write_midi(out, tempo_events, tsig, hits, tpb=960):
mid = mido.MidiFile(ticks_per_beat=tpb)
# tempo track
tt = mido.MidiTrack()
mid.tracks.append(tt)
num, den = tsig
tt.append(mido.MetaMessage("time_signature", numerator=num,
denominator=den, time=0))
# tempo events: absolute ticks via integration; delta between events
prev_tick = 0
for sec, bpm in tempo_events:
us = int(round(60e6 / bpm))
ticks = sec_to_tick(sec, tempo_events, tpb)
tt.append(mido.MetaMessage("set_tempo", tempo=us,
time=max(0, ticks - prev_tick)))
prev_tick = ticks
# percussion track
pt = mido.MidiTrack()
mid.tracks.append(pt)
# group same-tick hits into chords, emit note_on with delta times
grouped = {}
for sec, note, vel in hits:
tk = sec_to_tick(sec, tempo_events, tpb)
grouped.setdefault(tk, []).append((note, vel))
prev_tick = 0
for tk in sorted(grouped):
dt = tk - prev_tick
prev_tick = tk
for j, (note, vel) in enumerate(grouped[tk]):
# first note in the chord carries the delta; rest are time=0
pt.append(mido.Message("note_on", note=note, velocity=vel,
time=dt if j == 0 else 0))
pt.append(mido.Message("note_off", note=note, velocity=0, time=0))
mid.save(out)
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--wav", required=True)
ap.add_argument("--midi", help="tempo/time-map MIDI")
ap.add_argument("--pattern", default="blast",
choices=["blast", "double_beat", "dbeat", "groove"])
ap.add_argument("--out", default="community_drums.mid")
args = ap.parse_args()
print(f"Loading {args.wav} ...")
y, sr = librosa.load(args.wav, sr=22050, mono=True)
total_sec = len(y) / sr
if args.midi:
tempo_events, default_bpm = tempo_map_from_midi(args.midi)
print(f" tempo map: {len(tempo_events)} events, first {default_bpm} BPM")
else:
tempo_events, default_bpm = [(0.0, 120.0)], 120.0
print(" no --midi; using fixed 120 BPM")
onset_env = librosa.onset.onset_strength(y=y, sr=sr)
print(" building grid ...")
grid = build_beat_grid(tempo_events, total_sec)
aligned = onset_align(onset_env, sr, 512, grid)
print(f" grid {len(grid)} beats, aligned {len(aligned)}")
# bpm curve function
def bpm_at(t):
best = tempo_events[0][1]
for sec, b in tempo_events:
if t >= sec:
best = b
return best
hits = place_pattern(args.pattern, aligned, bpm_at)
print(f" {len(hits)} drum hits")
write_midi(args.out, tempo_events, (4, 4), hits)
print(f"Wrote {args.out}")
if __name__ == "__main__":
main()