| |
| """ |
| 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 |
|
|
| |
| KICK = 36 |
| SNARE = 38 |
| HIHAT = 42 |
| CRASH = 49 |
|
|
|
|
| |
| |
| |
| def tempo_map_from_midi(path): |
| """Return ([(sec, bpm), ...] in order, default_bpm).""" |
| mid = mido.MidiFile(path) |
| tpb = mid.ticks_per_beat or 480 |
|
|
| |
| 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() |
| |
| 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]) |
|
|
|
|
| |
| |
| |
| def build_beat_grid(tempo_events, total_sec): |
| """Return sorted list of quarter-beat times (sec) over [0, total_sec].""" |
| |
| beats = [] |
| t = 0.0 |
| idx = 0 |
| while t < total_sec: |
| beats.append(t) |
| |
| 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 |
|
|
|
|
| |
| |
| |
| 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) |
|
|
|
|
| |
| |
| |
| 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) |
| |
| for sub in range(4): |
| tsub = t0 + sub * (60.0 / cur_bpm) / 4 |
| if kind in ("blast", "double_beat"): |
| |
| hits.append((tsub, KICK, 95)) |
| |
| 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: |
| if sub == 0: |
| hits.append((tsub, KICK, 95)) |
| |
| if beat_in_bar in (1, 3): |
| hits.append((t0, SNARE, 100)) |
| return hits |
|
|
|
|
| |
| |
| |
| 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] |
| |
| if not times: |
| return int(round(s * bpms[0] * tpb / 60)) if bpms else 0 |
| |
| i = 0 |
| while i < len(times) - 1 and times[i + 1] <= s: |
| i += 1 |
| |
| 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) |
|
|
| |
| tt = mido.MidiTrack() |
| mid.tracks.append(tt) |
| num, den = tsig |
| tt.append(mido.MetaMessage("time_signature", numerator=num, |
| denominator=den, time=0)) |
| |
| 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 |
|
|
| |
| pt = mido.MidiTrack() |
| mid.tracks.append(pt) |
|
|
| |
| 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]): |
| |
| 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)}") |
|
|
| |
| 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() |
|
|