"""Section B event-sequence tensors for the velocity Transformer (design §5). Turns the cached Section A parquet (per-note rows) into padded, windowed token tensors. STRUCTURAL ONLY — velocity is the target, never a feature (design §1.1). """ from __future__ import annotations import numpy as np import pandas as pd import torch from ..core.voicemap import CANONICAL_VOICES _VOICE_IDX = {v: i for i, v in enumerate(CANONICAL_VOICES)} # fixed tensor-column order for the numeric feature block NUMERIC_FEATURES = [ "sin_beat", "cos_beat", "sin_bar", "cos_bar", "bpm_z", "log_time_to_prev", "same_onset", ] MAX_LEN = 512 # tokens per window _DELTA_CLIP_BEATS = 8.0 # matches Plan A TIME_DELTA_CLIP_BEATS def build_genre_vocab(train_df: pd.DataFrame) -> dict: """Sorted train genres -> 1..G; index 0 is reserved for .""" genres = sorted(train_df["genre"].astype(str).unique()) return {g: i + 1 for i, g in enumerate(genres)} def bpm_stats(train_df: pd.DataFrame) -> tuple: """Train-split bpm mean/std; std floored at 1.0.""" mean = float(train_df["bpm"].mean()) std = float(train_df["bpm"].std()) return mean, (std if std > 1e-6 else 1.0) def build_split_tensors(df, genre_vocab, bpm_mean, bpm_std, max_len=MAX_LEN): """Per-note rows -> padded windowed token tensors (design §5). No leakage.""" df = df.reset_index(drop=True) row_all = np.arange(len(df)) voice_all = df["voice"].map(_VOICE_IDX).to_numpy() genre_all = df["genre"].astype(str).map(lambda g: genre_vocab.get(g, 0)).to_numpy() bpm_z_all = ((df["bpm"].to_numpy() - bpm_mean) / bpm_std) onset_all = df["onset_sec"].to_numpy(dtype=float) bpm_all = df["bpm"].to_numpy(dtype=float) vel_all = df["velocity"].to_numpy(dtype=float) sin_beat = df["sin_beat"].to_numpy(dtype=float) cos_beat = df["cos_beat"].to_numpy(dtype=float) sin_bar = df["sin_bar"].to_numpy(dtype=float) cos_bar = df["cos_bar"].to_numpy(dtype=float) windows = [] # list of (positions[np.int64], log_time_to_prev[np.float32], same_onset[np.float32]) for _, idx in df.groupby("file_id", sort=False).groups.items(): pos = np.asarray(idx, dtype=np.int64) order = np.lexsort((voice_all[pos], onset_all[pos])) # sort by onset, then voice pos = pos[order] onset_beats = onset_all[pos] / (60.0 / bpm_all[pos]) delta = np.empty(len(pos), dtype=float) delta[0] = _DELTA_CLIP_BEATS if len(pos) > 1: delta[1:] = onset_beats[1:] - onset_beats[:-1] ltp = np.log1p(np.clip(delta, 0.0, _DELTA_CLIP_BEATS)).astype(np.float32) same_onset = np.zeros(len(pos), dtype=np.float32) if len(pos) > 1: same_onset[1:] = (delta[1:] == 0.0).astype(np.float32) for s in range(0, len(pos), max_len): sl = slice(s, s + max_len) windows.append((pos[sl], ltp[sl], same_onset[sl])) n = len(windows) voice_t = np.zeros((n, max_len), dtype=np.int64) genre_t = np.zeros((n, max_len), dtype=np.int64) num_t = np.zeros((n, max_len, len(NUMERIC_FEATURES)), dtype=np.float32) target_t = np.zeros((n, max_len), dtype=np.float32) pad_t = np.ones((n, max_len), dtype=bool) row_t = np.full((n, max_len), -1, dtype=np.int64) for i, (pos, ltp, so) in enumerate(windows): L = len(pos) voice_t[i, :L] = voice_all[pos] genre_t[i, :L] = genre_all[pos] num_t[i, :L, 0] = sin_beat[pos] num_t[i, :L, 1] = cos_beat[pos] num_t[i, :L, 2] = sin_bar[pos] num_t[i, :L, 3] = cos_bar[pos] num_t[i, :L, 4] = bpm_z_all[pos] num_t[i, :L, 5] = ltp num_t[i, :L, 6] = so target_t[i, :L] = vel_all[pos] pad_t[i, :L] = False row_t[i, :L] = row_all[pos] return { "voice_idx": torch.from_numpy(voice_t), "genre_idx": torch.from_numpy(genre_t), "num_feats": torch.from_numpy(num_t), "target": torch.from_numpy(target_t), "pad_mask": torch.from_numpy(pad_t), "row_idx": torch.from_numpy(row_t), } def scatter_predictions(row_idx, preds, pad_mask, n_rows) -> np.ndarray: """Place each non-pad token's prediction at its original row position.""" out = np.zeros(n_rows, dtype=np.float64) ri = row_idx.reshape(-1).cpu().numpy() pr = preds.reshape(-1).detach().cpu().numpy().astype(np.float64) keep = ri >= 0 out[ri[keep]] = pr[keep] return out