File size: 5,655 Bytes
37aeb1f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
"""Section A tabular features for the drum-velocity model (design §4).

STRUCTURAL ONLY — no note's velocity is ever used as a feature (design §1.1).
"""

from __future__ import annotations

import numpy as np

SIMULTANEITY_TOL_BEATS = 0.02      # Phase 0: fixed (no near-zero valley)
TIME_DELTA_CLIP_BEATS = 8.0        # clip inter-onset deltas before log1p
N_PHASE_BINS = 16                  # phase_beat bins for the lookup-table baseline

# candidate subdivision grids: name -> divisions per beat
SUBDIVISIONS = {
    "8th": 2,
    "16th": 4,
    "32nd": 8,
    "8th-triplet": 3,
    "quintuplet": 5,
}


def beats_per_bar(time_signature: str) -> int:
    """Beats per bar from an E-GMD time-signature string like '4-4' -> 4."""
    return int(str(time_signature).split("-")[0])


def metrical_phase(onset_sec: np.ndarray, bpm: float, bpb: int):
    """Continuous metrical phase within the beat and within the bar, each in [0, 1)."""
    onset_sec = np.asarray(onset_sec, dtype=float)
    beat_dur = 60.0 / float(bpm)
    bar_dur = beat_dur * bpb
    phase_beat = np.mod(onset_sec, beat_dur) / beat_dur
    phase_bar = np.mod(onset_sec, bar_dur) / bar_dur
    return phase_beat, phase_bar


def swing_ratio(phase_beat: np.ndarray) -> np.ndarray:
    """How far an offbeat is pushed toward the triplet position.

    0 at the straight 8th (phase 0.5), 1 at the 8th-note-triplet (phase 2/3).
    Defined only in the offbeat region [0.4, 0.8]; 0 elsewhere (onbeats etc.).
    """
    phase_beat = np.asarray(phase_beat, dtype=float)
    out = np.zeros_like(phase_beat)
    region = (phase_beat >= 0.4) & (phase_beat <= 0.8)
    out[region] = (phase_beat[region] - 0.5) / (2.0 / 3.0 - 0.5)
    return out


def nearest_subdivision(phase_beat: np.ndarray) -> np.ndarray:
    """For each onset, the candidate grid whose nearest gridline it is closest to."""
    phase_beat = np.asarray(phase_beat, dtype=float)
    names = list(SUBDIVISIONS)
    # distance to nearest gridline for each grid (phase is circular on [0,1))
    dists = np.empty((len(names), phase_beat.size))
    for i, name in enumerate(names):
        d = SUBDIVISIONS[name]
        scaled = phase_beat * d
        dists[i] = np.abs(scaled - np.round(scaled)) / d
    return np.array(names, dtype=object)[np.argmin(dists, axis=0)]


import pandas as pd

from ..core.voicemap import CANONICAL_VOICES, voice_of


def _log_clip_beats(delta_beats: np.ndarray) -> np.ndarray:
    return np.log1p(np.clip(delta_beats, 0.0, TIME_DELTA_CLIP_BEATS))


def build_note_features(note_array, meta) -> pd.DataFrame:
    """One structural feature row per note (design §4). No velocity leakage."""
    order = np.argsort(note_array["onset_sec"], kind="stable")
    na = note_array[order]
    onset = na["onset_sec"].astype(float)
    pitch = na["pitch"].astype(int)
    n = len(na)

    bpm = float(meta["bpm"])
    beat_dur = 60.0 / bpm
    bpb = beats_per_bar(meta["time_signature"])
    onset_beats = onset / beat_dur

    phase_beat, phase_bar = metrical_phase(onset, bpm, bpb)
    voices = np.array([voice_of(p) for p in pitch], dtype=object)

    # global consecutive deltas (any voice), in beats
    to_prev = np.full(n, TIME_DELTA_CLIP_BEATS)
    to_next = np.full(n, TIME_DELTA_CLIP_BEATS)
    if n > 1:
        d = np.diff(onset_beats)
        to_prev[1:] = d
        to_next[:-1] = d

    # same-voice consecutive deltas, in beats
    sv_prev = np.full(n, TIME_DELTA_CLIP_BEATS)
    sv_next = np.full(n, TIME_DELTA_CLIP_BEATS)
    for v in set(voices):
        idx = np.where(voices == v)[0]
        if idx.size > 1:
            dv = np.diff(onset_beats[idx])
            sv_prev[idx[1:]] = dv
            sv_next[idx[:-1]] = dv

    # simultaneity multi-hot + count, and ±1-beat density (vectorized via searchsorted)
    lo = np.searchsorted(onset_beats, onset_beats - SIMULTANEITY_TOL_BEATS, side="left")
    hi = np.searchsorted(onset_beats, onset_beats + SIMULTANEITY_TOL_BEATS, side="right")
    dlo = np.searchsorted(onset_beats, onset_beats - 1.0, side="left")
    dhi = np.searchsorted(onset_beats, onset_beats + 1.0, side="right")
    simult_count = hi - lo
    density = dhi - dlo
    multihot = {f"simult_{v}": np.zeros(n, dtype=np.int8) for v in CANONICAL_VOICES}
    for i in range(n):
        for j in range(lo[i], hi[i]):
            multihot[f"simult_{voices[j]}"][i] = 1

    style = str(meta["style"])
    out = pd.DataFrame({
        "file_id": str(meta["id"]),
        "drummer": str(meta["drummer"]),
        "split": str(meta["split"]),
        "onset_sec": onset,
        "bar_index": np.floor(onset / (beat_dur * bpb)).astype(int),
        "velocity": na["velocity"].astype(int),
        "voice": voices,
        "genre": style.split("/")[0],
        "style": style,
        "time_signature": str(meta["time_signature"]),
        "beat_type": str(meta["beat_type"]),
        "nearest_subdiv": nearest_subdivision(phase_beat),
        "phase_beat": phase_beat,
        "phase_bar": phase_bar,
        "sin_beat": np.sin(2 * np.pi * phase_beat),
        "cos_beat": np.cos(2 * np.pi * phase_beat),
        "sin_bar": np.sin(2 * np.pi * phase_bar),
        "cos_bar": np.cos(2 * np.pi * phase_bar),
        "swing_ratio": swing_ratio(phase_beat),
        "log_time_to_prev": _log_clip_beats(to_prev),
        "log_time_to_next": _log_clip_beats(to_next),
        "log_same_voice_prev": _log_clip_beats(sv_prev),
        "log_same_voice_next": _log_clip_beats(sv_next),
        "simult_count": simult_count.astype(int),
        "density_1beat": density.astype(int),
        "bpm": bpm,
    })
    for name, col in multihot.items():
        out[name] = col
    return out