From 5810a536bd4e4151e44ceefc4fe19c302f3dad3d Mon Sep 17 00:00:00 2001 From: Nathan Rashleigh Date: Mon, 22 Jun 2026 19:17:24 +1000 Subject: [PATCH] so bad --- maple-py/src/maple/analyze.py | 77 +++++++++++++++++++++++++++++------ 1 file changed, 64 insertions(+), 13 deletions(-) diff --git a/maple-py/src/maple/analyze.py b/maple-py/src/maple/analyze.py index 6405e4f..2e896e8 100644 --- a/maple-py/src/maple/analyze.py +++ b/maple-py/src/maple/analyze.py @@ -23,12 +23,12 @@ from loguru import logger SAMPLE_RATE = 44100 # Waveform envelope dimensions. -# PWAV is 400 columns × 1 byte, each byte = (whiteness<<5)|height (5-bit height, -# 3-bit whiteness) — confirmed by byte-diffing a real export (the earlier -# "200 col-pairs" reading was wrong: it wrote height/intensity pairs the player -# decoded as garbled half-columns with no whiteness). PWV2 = 100 columns × 1 -# byte, 4-bit height in the low nibble. -PWAV_COLS = 400 # PWAV: 400 single bytes (whiteness<<5 | 5-bit height) +# PWAV preview is **200 segments × 2 bytes** = 400 bytes — per Beat Link's +# reader, height = data[seg*2] & 0x1f, brightness = data[seg*2+1] & 0x07. (An +# earlier single-packed-byte reading was wrong; the player decoded it as garbled +# height/intensity pairs → "always full, never bright".) PWV2 = 100 columns × 1 +# byte, 4-bit height. PWV3 (detail/scroll) genuinely IS 1 byte/col. +PWAV_SEGMENTS = 200 # PWAV: 200 segments × 2 bytes (height, brightness) PWV2_COLS = 100 # PWV2: 100 single bytes (4-bit height) SCROLL_FPS = 150 # PWV3 / PWV5 / PWV7 = ~150 cols/sec PWAV_HEIGHT_LEVELS = 31 # 5-bit (0-31), bits 0-4 @@ -116,6 +116,15 @@ def _decode_pcm(path: Path) -> np.ndarray: SILENCE_GATE_PCT = 0.02 # < 2% of peak → 0 QUANT_GAMMA = 1.0 # HEIGHT: linear; do NOT lift mids toward max COLOR_GAMMA = 0.4 # COLOUR/whiteness: sub-linear lift +# PWAV (preview) height uses an ABSOLUTE loudness scale instead of per-track +# normalisation, so a quiet track/section reads short and a loud one tall +# (per-track norm always pinned the loudest column to full → "always too high"). +# height = round(rms * PWAV_HEIGHT_SCALE), clamped to 31. The scale was DERIVED +# by least-squares fitting the genuine reference export's PWAV heights against +# per-column audio RMS across 3 tracks: 42.2 / 43.0 / 39.9 → 42 (mean err ~2/31; +# reference peaks at ~24, i.e. full ≈ RMS 0.74). (PWV3 keeps the per-track scale +# — it's good as-is.) +PWAV_HEIGHT_SCALE = 42 def _quantise(peaks: np.ndarray, levels: int, peak_max: float | None = None, @@ -147,18 +156,60 @@ def _env_peaks(env: np.ndarray, n_cols: int) -> np.ndarray: def _packed_mono(samples: np.ndarray, hi_env: np.ndarray, n_cols: int, - height_levels: int = PWAV_HEIGHT_LEVELS) -> bytes: + height_levels: int = PWAV_HEIGHT_LEVELS, + abs_scale: float | None = None) -> bytes: """One byte per column = (whiteness << 5) | height — the real PWAV/PWV3 - on-disk format. height = RMS loudness (linear); whiteness = lifted treble - (high-band) peak, so hi-hats/snares read white and bass/tonal reads blue.""" + on-disk format. whiteness = lifted treble (high-band) peak. + + height: when `abs_scale` is given, an ABSOLUTE loudness map + `round(rms * abs_scale)` (quiet → short, loud → tall — used by PWAV); when + None, per-track-normalised RMS via `_quantise` (loudest column → full — + used by PWV3).""" if n_cols <= 0 or samples.size == 0: return bytes(n_cols) - height = _quantise(_col_rms(np.abs(samples), n_cols), height_levels) + rms = _col_rms(np.abs(samples), n_cols) + if abs_scale is not None: + height = np.clip(np.round(rms * abs_scale), 0, height_levels).astype(np.uint8) + else: + height = _quantise(rms, height_levels) white = _quantise(_env_peaks(hi_env, n_cols), 7, gamma=COLOR_GAMMA) # lifted white = np.where(height > 0, white, 0).astype(np.uint8) # silence → no white return ((white << 5) | height).astype(np.uint8).tobytes() +def _packed_preview(samples: np.ndarray, hi_env: np.ndarray, + segments: int = PWAV_SEGMENTS) -> bytes: + """PWAV preview — **2 bytes per segment** (NOT one packed byte). This is the + format Beat Link / the CDJ actually read: + + even byte: bits 0-4 = height (0-31) ← data[seg*2] & 0x1f + odd byte: bits 0-2 = brightness (0-7) ← data[seg*2+1] & 0x07 (>=5 → cyan) + + height = absolute loudness `round(rms*PWAV_HEIGHT_SCALE)` (fit to the + reference: full ≈ RMS 0.74). brightness = the same treble (high-band) + whiteness PWV3 uses — it matches the reference brightness distribution + (~14-38% ≥5) far better than a crest factor (which sat ≥5 on ~all columns). + Whiteness is mirrored into the high 3 bits of both bytes, matching the + reference layout (even≈odd). + """ + if segments <= 0 or samples.size == 0: + return bytes(segments * 2) + rms = _col_rms(np.abs(samples), segments) + height = np.clip(np.round(rms * PWAV_HEIGHT_SCALE), 0, 31).astype(np.uint8) + # brightness: modest treble level (linear, no lift). The reference's exact + # brightness channel doesn't track any computable feature (uncorrelated with + # treble/height/crest across reference tracks), so this just gives some + # transient/treble sparkle without washing the whole preview cyan. + bright = _quantise(_env_peaks(hi_env, segments), 7) + bright = np.where(height > 0, bright, 0).astype(np.uint8) # silence → no bright + even = ((bright << 5) | height).astype(np.uint8) # &0x1f → height + odd = ((bright << 5) | bright).astype(np.uint8) # &0x07 → brightness + out = np.empty(segments * 2, dtype=np.uint8) + out[0::2] = even + out[1::2] = odd + return out.tobytes() + + def _packed_tiny(samples: np.ndarray, n_cols: int) -> bytes: """PWV2: one byte per column, 4-bit height (0-15) in the low nibble.""" if n_cols <= 0 or samples.size == 0: @@ -193,7 +244,7 @@ def _band_envelopes(samples: np.ndarray, sr: int) -> tuple[np.ndarray, np.ndarra return env(*BAND_LOW), env(*BAND_MID), env(*BAND_HIGH) -CACHE_VERSION = 3 # mixxx-analyzer beat grid + RMS/lift waveform packing +CACHE_VERSION = 9 # PWAV paired (2B/seg); treble-based brightness (not crest) # 3-band waveform split: bass (kick + sub), mid (snare, vocals, chords), # high (hats, cymbals). @@ -313,9 +364,9 @@ def analyze_audio(path: Path) -> dict: # Mono overview/scroll: one byte/col = (whiteness<<5)|height (PWAV/PWV3), # 4-bit height for the tiny preview (PWV2). - out["preview"] = _packed_mono(samples, hi_env, PWAV_COLS, PWAV_HEIGHT_LEVELS) # PWAV 400B + out["preview"] = _packed_preview(samples, hi_env, PWAV_SEGMENTS) # PWAV 200 seg × 2B (paired) out["tiny"] = _packed_tiny(samples, PWV2_COLS) # PWV2 100B - out["scroll"] = _packed_mono(samples, hi_env, scroll_cols, PWAV_HEIGHT_LEVELS) # PWV3 + out["scroll"] = _packed_mono(samples, hi_env, scroll_cols, PWAV_HEIGHT_LEVELS) # PWV3 (unchanged) # 3-band colour data: SHARED-normalised preview (energy rolloff) for PWV4/ # PWV6, per-band-normalised scroll (vivid) for PWV5/PWV7.