change bpm detect

This commit is contained in:
Nathan Rashleigh committed 2026-06-22 18:06:45 +10:00
1 parent b85cea496f
commit 096bce803a
6 files changed
+258 -359

No files matched your search

+4 -1
View File
@@ -54,7 +54,10 @@ sync args:
# write-back cache. # write-back cache.
usb dst="/media/ntr/MAPLE": usb dst="/media/ntr/MAPLE":
@test -d {{dst}} || (echo "USB not mounted at {{dst}}"; exit 1) @test -d {{dst}} || (echo "USB not mounted at {{dst}}"; exit 1)
rsync -rtvh --delete --size-only --no-perms --no-owner --no-group --modify-window=2 "{{justfile_directory()}}/data/usb/" "{{dst}}/" @# -c (checksum), NOT --size-only: maple re-exports keep the SAME file size
@# (ANLZ section lengths are deterministic) but change content (waveforms,
@# pdb), so a size-only check silently skips them and leaves stale data.
rsync -rtvh --delete -c --no-perms --no-owner --no-group --modify-window=2 "{{justfile_directory()}}/data/usb/" "{{dst}}/"
sync sync
@# Try udisksctl first (no sudo, handles the udev event); fall back to @# Try udisksctl first (no sudo, handles the udev event); fall back to
@# plain `umount` if udisks isn't available. Either way the recipe @# plain `umount` if udisks isn't available. Either way the recipe
+1 -2
View File
@@ -9,12 +9,11 @@ authors = [
requires-python = ">=3.14" requires-python = ">=3.14"
dependencies = [ dependencies = [
"aiohttp>=3.10", "aiohttp>=3.10",
"aubio>=0.4.9",
"kaitaistruct>=0.11", "kaitaistruct>=0.11",
"loguru>=0.7", "loguru>=0.7",
"mixxx-analyzer>=0.1.2",
"mutagen>=1.47.0", "mutagen>=1.47.0",
"pillow>=12.2.0", "pillow>=12.2.0",
"polars>=1.0",
"pyrekordbox>=0.4.4", "pyrekordbox>=0.4.4",
"scipy>=1.10", "scipy>=1.10",
] ]
+152 -233
View File
@@ -1,12 +1,13 @@
"""Audio analysis: analytical BPM detection + waveform envelope extraction. """Audio analysis: BPM + beat grid via mixxx-analyzer, plus waveform envelopes.
ffmpeg decodes the source to 44.1 kHz mono float32 PCM on stdout. The BPM and the beat grid come from `mixxx-analyzer` — Python bindings around
samples are then either streamed in hop-size chunks into aubio.tempo for Mixxx's own analysis (the Queen Mary tempo tracker) — which produces a
BPM detection, or fully buffered and binned to produce the per-column CDJ-grade beat grid. Its wheel bundles a native binary, so there's no extra
amplitude envelopes that PWAV / PWV3 ANLZ sections embed. system dependency. ffmpeg still decodes the source to 44.1 kHz mono float32 PCM
for the per-column waveform envelopes (PWAV / PWV3 / PWV4 / PWV5).
The decoded audio never touches disk — ffmpeg's `-f f32le` output is The decoded audio never touches disk — ffmpeg's `-f f32le` output is piped
piped straight to numpy. straight to numpy.
""" """
from __future__ import annotations from __future__ import annotations
@@ -16,90 +17,74 @@ import subprocess
from base64 import b64decode, b64encode from base64 import b64decode, b64encode
from pathlib import Path from pathlib import Path
import aubio
import numpy as np import numpy as np
import polars as pl
from loguru import logger from loguru import logger
HOP_SIZE = 512
WIN_SIZE = 1024
SAMPLE_RATE = 44100 SAMPLE_RATE = 44100
MIN_BEATS = 8
# Waveform envelope dimensions. # Waveform envelope dimensions.
# PWAV is stored as 200 col-pairs (height byte + intensity byte) = 400 bytes # PWAV is 400 columns × 1 byte, each byte = (whiteness<<5)|height (5-bit height,
# per Beat Link's WaveformPreview reader. Spec page says 400 cols × 1 byte; # 3-bit whiteness) — confirmed by byte-diffing a real export (the earlier
# Beat Link reads 200 segments × 2 bytes — Beat Link is the production # "200 col-pairs" reading was wrong: it wrote height/intensity pairs the player
# reference. PWV2 follows the same 2-bytes-per-col scheme (50 cols × 2B = 100B) # decoded as garbled half-columns with no whiteness). PWV2 = 100 columns × 1
# but with 4-bit height (0-15) per the spec. # byte, 4-bit height in the low nibble.
PWAV_COLS = 200 # paired-byte columns in PWAV (→ 400 bytes data) PWAV_COLS = 400 # PWAV: 400 single bytes (whiteness<<5 | 5-bit height)
PWV2_COLS = 50 # paired-byte columns in PWV2 (→ 100 bytes data) PWV2_COLS = 100 # PWV2: 100 single bytes (4-bit height)
SCROLL_FPS = 150 # PWV3 / PWV5 / PWV7 = ~150 cols/sec SCROLL_FPS = 150 # PWV3 / PWV5 / PWV7 = ~150 cols/sec
PWAV_HEIGHT_LEVELS = 31 # 5-bit (0-31), bits 0-4 PWAV_HEIGHT_LEVELS = 31 # 5-bit (0-31), bits 0-4
PWV2_HEIGHT_LEVELS = 15 # 4-bit (0-15), bits 0-3 PWV2_HEIGHT_LEVELS = 15 # 4-bit (0-15), bits 0-3
PWAV_LEVELS = 31 # legacy alias for callers PWAV_LEVELS = 31 # legacy alias for callers
def _stream_pcm(path: Path): def _mixxx_analyze(path: Path):
"""Yield successive hop-sized float32 numpy arrays of mono PCM. """Run mixxx-analyzer (Mixxx's BPM / beat-grid / key algorithms) on `path`.
Pads the final chunk with zeros so every yield is exactly HOP_SIZE samples Returns the `AnalysisResult` (fields incl. `bpm` and `beatgrid` — beat
(aubio.tempo requires fixed-size inputs). positions in seconds) or None on failure."""
"""
proc = subprocess.Popen(
["ffmpeg", "-nostdin", "-v", "error",
"-i", str(path),
"-f", "f32le", "-ac", "1", "-ar", str(SAMPLE_RATE), "-"],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
try: try:
assert proc.stdout is not None import mixxx_analyzer
bytes_per_chunk = HOP_SIZE * 4 # float32 except ImportError:
while True: logger.warning("mixxx-analyzer not installed; BPM/beat detection disabled")
buf = proc.stdout.read(bytes_per_chunk) return None
if not buf: try:
break return mixxx_analyzer.analyze(str(path))
arr = np.frombuffer(buf, dtype=np.float32) except Exception as e:
if len(arr) < HOP_SIZE: logger.warning("mixxx-analyzer failed for {}: {}", path.name, e)
pad = np.zeros(HOP_SIZE - len(arr), dtype=np.float32) return None
arr = np.concatenate([arr, pad])
yield arr
finally: def _beatgrid_to_pqtz(beatgrid: list[float], bpm: float) -> list[tuple[int, int, int]]:
if proc.poll() is None: """Project a continuous PQTZ grid from mixxx's tempo + phase.
proc.terminate()
proc.wait() Returns `[(beat_number 1-4, tempo×100, time_ms), ...]`. mixxx gives an
accurate BPM and beat phase, but its raw beat list can have GAPS in quiet
sections (e.g. 907 detected beats where a continuous 160 BPM grid has
~1010). rekordbox/CDJs expect a gap-free grid — beat-sync and quantise lock
to it — so we anchor on mixxx's first beat, walk back to t≥0 to cover the
intro, and emit one beat every 60/bpm seconds through to the last detected
beat. beat_number cycles 1→4 from the anchor (no downbeat info)."""
if not beatgrid or bpm <= 0:
return []
interval_ms = 60000.0 / bpm
first_ms = beatgrid[0] * 1000.0
last_ms = beatgrid[-1] * 1000.0
n_before = int(first_ms / interval_ms) # preserve phase back to t≥0
anchor = first_ms - n_before * interval_ms
tempo_x100 = round(bpm * 100)
grid: list[tuple[int, int, int]] = []
i = 0
t = anchor
while t <= last_ms + interval_ms:
grid.append(((i % 4) + 1, tempo_x100, int(round(t))))
i += 1
t = anchor + i * interval_ms
return grid
def detect_bpm(path: Path) -> float | None: def detect_bpm(path: Path) -> float | None:
"""Return the analytical BPM of `path`, or None on failure. """Return the BPM of `path` via mixxx-analyzer (Mixxx's algorithm), or None."""
res = _mixxx_analyze(path)
Uses median-of-intervals (via polars), which is robust against return float(res.bpm) if res is not None and res.bpm else None
aubio's "live" running-average swings on transient sections.
"""
if not shutil.which("ffmpeg"):
logger.warning("ffmpeg not on PATH; BPM detection disabled")
return None
tempo = aubio.tempo("default", WIN_SIZE, HOP_SIZE, SAMPLE_RATE)
beats: list[float] = []
try:
for chunk in _stream_pcm(path):
if tempo(chunk):
beats.append(tempo.get_last_s())
except Exception as e:
logger.warning("BPM detection failed for {}: {}", path.name, e)
return None
if len(beats) < MIN_BEATS:
return None
intervals = pl.Series(
[beats[i + 1] - beats[i] for i in range(len(beats) - 1)]
)
median_interval = intervals.median()
if not median_interval or median_interval <= 0:
return None
return 60.0 / median_interval
def _decode_pcm(path: Path) -> np.ndarray: def _decode_pcm(path: Path) -> np.ndarray:
@@ -123,67 +108,84 @@ def _decode_pcm(path: Path) -> np.ndarray:
return np.frombuffer(proc.stdout, dtype=np.float32) return np.frombuffer(proc.stdout, dtype=np.float32)
# Real rekordbox waveforms have a sharp noise gate and an aggressive # Height: gate near-silence to 0, then a linear map to the level range
# S-curve: silence reads as actual zero, peaks pin to max, mids cluster # (QUANT_GAMMA=1.0). Heights use per-track-normalised RMS loudness, which on the
# at the high end. Linear peak-normalisation produces mushy mid-range # reference export spreads low (scroll ~5.7/31, overview ~13/31) with no
# values that look "always loud, never silent" on the deck. These two # saturation. Colour/whiteness use a sub-linear lift (COLOR_GAMMA) so quiet
# numbers were tuned against the data/q1-rekordbox/ reference export. # bands still read vivid mid-range instead of near-zero.
SILENCE_GATE_PCT = 0.02 # < 2% of peak → 0 (matches ~13,600 silent cols on Q1) SILENCE_GATE_PCT = 0.02 # < 2% of peak → 0
QUANT_GAMMA = 0.55 # > sub-linear curve, pushes mid-values toward max QUANT_GAMMA = 1.0 # HEIGHT: linear; do NOT lift mids toward max
COLOR_GAMMA = 0.4 # COLOUR/whiteness: sub-linear lift
def _quantise(peaks: np.ndarray, levels: int) -> np.ndarray: def _quantise(peaks: np.ndarray, levels: int, peak_max: float | None = None,
"""Gate + gamma + quantise. Returns uint8 array of length len(peaks).""" gamma: float | None = None) -> np.ndarray:
peak_max = float(peaks.max()) if peaks.size else 0.0 """Gate + (optional gamma) + quantise to 0..levels uint8.
if peak_max <= 0:
`peak_max` lets callers pass a SHARED normaliser (e.g. across bands) so the
relative energy between channels is preserved; defaults to this array's own
max. `gamma` overrides QUANT_GAMMA (use COLOR_GAMMA to lift colour)."""
g = QUANT_GAMMA if gamma is None else gamma
pm = float(peaks.max()) if peak_max is None else peak_max
if pm <= 0:
return np.zeros(peaks.size, dtype=np.uint8) return np.zeros(peaks.size, dtype=np.uint8)
gated = np.where(peaks < peak_max * SILENCE_GATE_PCT, 0.0, peaks) gated = np.where(peaks < pm * SILENCE_GATE_PCT, 0.0, peaks)
normalised = gated / peak_max normalised = np.clip(gated / pm, 0.0, 1.0)
curved = np.power(normalised, QUANT_GAMMA, where=normalised > 0) curved = normalised if g == 1.0 else np.power(normalised, g)
return np.clip(np.round(curved * levels), 0, levels).astype(np.uint8) return np.clip(np.round(curved * levels), 0, levels).astype(np.uint8)
def _bin_amplitudes(samples: np.ndarray, n_bins: int, levels: int = PWAV_LEVELS) -> bytes: def _col_rms(values: np.ndarray, n_cols: int) -> np.ndarray:
"""Single-byte-per-bin amplitude (used for PWV3 detail waveform).""" """Per-column RMS over n_cols evenly-split chunks (loudness, not peak)."""
if n_bins <= 0 or len(samples) == 0: chunks = np.array_split(values.astype(np.float64), n_cols)
return bytes(n_bins) return np.array([float(np.sqrt(np.mean(c * c))) if len(c) else 0.0 for c in chunks])
chunks = np.array_split(np.abs(samples), n_bins)
peaks = np.array([float(c.max()) if len(c) else 0.0 for c in chunks])
return _quantise(peaks, levels).tobytes()
def _bin_amplitudes_paired(samples: np.ndarray, n_cols: int, height_levels: int = PWAV_HEIGHT_LEVELS) -> bytes: def _env_peaks(env: np.ndarray, n_cols: int) -> np.ndarray:
"""Pair-byte-per-column quantisation for PWAV / PWV2. Returns chunks = np.array_split(env, n_cols)
`n_cols * 2` bytes — height in even bytes (bits 0..h_bits-1), return np.array([float(c.max()) if len(c) else 0.0 for c in chunks])
intensity in odd bytes (bits 0-2).
Same gate + gamma curve as `_quantise` so silence reads as silence
and peaks pin to the channel max. def _packed_mono(samples: np.ndarray, hi_env: np.ndarray, n_cols: int,
""" height_levels: int = PWAV_HEIGHT_LEVELS) -> 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."""
if n_cols <= 0 or samples.size == 0: if n_cols <= 0 or samples.size == 0:
return bytes(n_cols * 2) return bytes(n_cols)
abs_s = np.abs(samples) height = _quantise(_col_rms(np.abs(samples), n_cols), height_levels)
chunks = np.array_split(abs_s, n_cols) white = _quantise(_env_peaks(hi_env, n_cols), 7, gamma=COLOR_GAMMA) # lifted
peaks = np.array([float(c.max()) if len(c) else 0.0 for c in chunks]) white = np.where(height > 0, white, 0).astype(np.uint8) # silence → no white
rms = np.array([float(np.sqrt(np.mean(c.astype(np.float64) ** 2))) if len(c) else 0.0 return ((white << 5) | height).astype(np.uint8).tobytes()
for c in chunks])
heights = _quantise(peaks, height_levels)
# Transients (peak > 1.5 × rms) get intensity 7. Heights already def _packed_tiny(samples: np.ndarray, n_cols: int) -> bytes:
# gated to 0 in silence — pair them: zero height → zero intensity. """PWV2: one byte per column, 4-bit height (0-15) in the low nibble."""
intensities = np.where( if n_cols <= 0 or samples.size == 0:
(heights > 0) & (peaks > 1.5 * np.maximum(rms, 1e-9)), return bytes(n_cols)
7, 0, return _quantise(_col_rms(np.abs(samples), n_cols), PWV2_HEIGHT_LEVELS).astype(np.uint8).tobytes()
).astype(np.uint8)
out = np.empty(n_cols * 2, dtype=np.uint8)
out[0::2] = heights def _bin_bands(lo_env: np.ndarray, mid_env: np.ndarray, hi_env: np.ndarray,
out[1::2] = intensities n_cols: int, shared: bool, levels: int = 255) -> tuple[bytes, bytes, bytes]:
return out.tobytes() """Bin three band envelopes (per-column PEAK) into n_cols, lifted by
COLOR_GAMMA. `shared=True` normalises all three by one SHARED peak → natural
bass→treble rolloff (PWV4 overview). `shared=False` normalises each band to
its OWN peak → vivid balanced colour where sustained bands read strong
(PWV5 colour scroll)."""
lp, mp, hp = _env_peaks(lo_env, n_cols), _env_peaks(mid_env, n_cols), _env_peaks(hi_env, n_cols)
if shared:
s = max(float(lp.max()), float(mp.max()), float(hp.max()), 1e-9)
nlo = nmd = nhi = s
else:
nlo, nmd, nhi = None, None, None
return (_quantise(lp, levels, nlo, gamma=COLOR_GAMMA).tobytes(),
_quantise(mp, levels, nmd, gamma=COLOR_GAMMA).tobytes(),
_quantise(hp, levels, nhi, gamma=COLOR_GAMMA).tobytes())
def _band_envelopes(samples: np.ndarray, sr: int) -> tuple[np.ndarray, np.ndarray, np.ndarray]: def _band_envelopes(samples: np.ndarray, sr: int) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
"""Return per-sample low/mid/high envelopes (after band-pass + abs). """Per-sample low/mid/high envelopes (4-pole Butterworth band-pass + abs)."""
Uses a 4-pole Butterworth band-pass per band; scipy.signal.sosfilt
streams in a single pass so the whole-track envelope fits in RAM."""
from scipy.signal import butter, sosfilt from scipy.signal import butter, sosfilt
def env(lo: int, hi: int) -> np.ndarray: def env(lo: int, hi: int) -> np.ndarray:
sos = butter(4, [lo, hi], btype="band", fs=sr, output="sos") sos = butter(4, [lo, hi], btype="band", fs=sr, output="sos")
@@ -191,96 +193,14 @@ def _band_envelopes(samples: np.ndarray, sr: int) -> tuple[np.ndarray, np.ndarra
return env(*BAND_LOW), env(*BAND_MID), env(*BAND_HIGH) return env(*BAND_LOW), env(*BAND_MID), env(*BAND_HIGH)
def _bin_to_bytes(values: np.ndarray, n_bins: int, levels: int = 255) -> bytes: CACHE_VERSION = 3 # mixxx-analyzer beat grid + RMS/lift waveform packing
"""Bin `values` into `n_bins`, take per-bin max, normalise to `levels`
via the shared gate+gamma curve (matches what real rekordbox writes:
silence at zero, mids pulled toward max)."""
if n_bins <= 0 or values.size == 0:
return bytes(n_bins)
chunks = np.array_split(values, n_bins)
peaks = np.array([float(c.max()) if len(c) else 0.0 for c in chunks])
return _quantise(peaks, levels).tobytes()
# 3-band waveform split: bass (kick + sub), mid (snare, vocals, chords),
def extract_waveform_envelope(path: Path) -> dict[str, bytes]: # high (hats, cymbals).
"""Return the two byte streams a PWAV + PWV3 pair needs.
Output:
{
"preview": bytes of length PREVIEW_COLS (always 400),
"scroll": bytes of length ~ duration * SCROLL_FPS,
}
On decode failure both streams are empty.
"""
samples = _decode_pcm(path)
if samples.size == 0:
return {"preview": b"", "scroll": b""}
duration = samples.size / SAMPLE_RATE
scroll_cols = max(1, int(round(duration * SCROLL_FPS)))
return {
"preview": _bin_amplitudes(samples, PREVIEW_COLS),
"scroll": _bin_amplitudes(samples, scroll_cols),
}
def _detect_beats_in_samples(samples: np.ndarray) -> list[float]:
"""Run aubio's onset detector across a pre-loaded mono PCM buffer.
Returns the timestamps (seconds) of every detected beat.
"""
tempo = aubio.tempo("default", WIN_SIZE, HOP_SIZE, SAMPLE_RATE)
beats: list[float] = []
for i in range(0, len(samples), HOP_SIZE):
chunk = samples[i:i + HOP_SIZE]
if len(chunk) < HOP_SIZE:
chunk = np.concatenate(
[chunk, np.zeros(HOP_SIZE - len(chunk), dtype=np.float32)]
)
if tempo(chunk):
beats.append(tempo.get_last_s())
return beats
def _project_beat_grid(beats: list[float], bpm: float, duration_s: float) -> list[tuple[int, int, int]]:
"""Project a rigid `bpm` beat grid using the first detected beat as anchor.
Returns `[(beat_number 1-4, tempo*100, time_ms), ...]` covering t=0 to
`duration_s`. Rigid grids are what CDJs prefer over noisy per-beat
timing — the strongest detected beat fixes the phase and we project
forward + backward at the canonical tempo.
"""
if not beats or bpm <= 0:
return []
interval = 60.0 / bpm
first = beats[0]
# Walk back from the first detected beat to t≥0 so the grid covers the intro.
n_before = int(first / interval)
anchor = first - n_before * interval
if anchor < 0:
anchor += interval
n_before -= 1
tempo_x100 = round(bpm * 100)
grid: list[tuple[int, int, int]] = []
t = anchor
i = 0
while t <= duration_s + 1.0:
time_ms = int(round(t * 1000))
beat_number = (i % 4) + 1
grid.append((beat_number, tempo_x100, time_ms))
i += 1
t = anchor + i * interval
return grid
CACHE_VERSION = 2
# 3-band waveform split. Frequencies tuned to roughly match what
# rekordbox shows on the CDJ-3000 strip: bass (kick + sub), mid (snare,
# vocals, chords), high (hats, cymbals).
BAND_LOW = (20, 200) BAND_LOW = (20, 200)
BAND_MID = (200, 2000) BAND_MID = (200, 2000)
BAND_HIGH = (2000, 15000) BAND_HIGH = (2000, 15000)
PWV6_COLS = 1200 # 3-band preview width (fixed by spec) PWV6_COLS = 1200 # 3-band / colour preview width (fixed by spec)
def _sanitise_isrc(isrc: str) -> str: def _sanitise_isrc(isrc: str) -> str:
@@ -366,42 +286,41 @@ def analyze_audio(path: Path) -> dict:
"scroll_high": bytes, "scroll_high": bytes,
} }
""" """
samples = _decode_pcm(path)
out: dict = { out: dict = {
"bpm": None, "beats": [], "bpm": None, "beats": [],
"preview": b"", "tiny": b"", "scroll": b"", "preview": b"", "tiny": b"", "scroll": b"",
"preview_low": b"", "preview_mid": b"", "preview_high": b"", "preview_low": b"", "preview_mid": b"", "preview_high": b"",
"scroll_low": b"", "scroll_mid": b"", "scroll_high": b"", "scroll_low": b"", "scroll_mid": b"", "scroll_high": b"",
} }
# BPM + beat grid from mixxx-analyzer (independent of the waveform decode,
# so a track still gets its grid even if ffmpeg waveform extraction fails).
res = _mixxx_analyze(path)
if res is not None and res.bpm and res.beatgrid:
out["bpm"] = float(res.bpm)
out["beats"] = _beatgrid_to_pqtz(list(res.beatgrid), out["bpm"])
samples = _decode_pcm(path)
if samples.size == 0: if samples.size == 0:
return out return out
beat_times = _detect_beats_in_samples(samples)
if len(beat_times) >= MIN_BEATS:
intervals = pl.Series(
[beat_times[i + 1] - beat_times[i] for i in range(len(beat_times) - 1)]
)
median_interval = intervals.median()
if median_interval and median_interval > 0:
out["bpm"] = 60.0 / median_interval
duration_s = samples.size / SAMPLE_RATE
out["beats"] = _project_beat_grid(beat_times, out["bpm"], duration_s)
duration = samples.size / SAMPLE_RATE duration = samples.size / SAMPLE_RATE
scroll_cols = max(1, int(round(duration * SCROLL_FPS))) scroll_cols = max(1, int(round(duration * SCROLL_FPS)))
# PWAV / PWV2 use paired bytes (height + intensity); PWV3 is 1B/col.
out["preview"] = _bin_amplitudes_paired(samples, PWAV_COLS, PWAV_HEIGHT_LEVELS)
out["tiny"] = _bin_amplitudes_paired(samples, PWV2_COLS, PWV2_HEIGHT_LEVELS)
out["scroll"] = _bin_amplitudes(samples, scroll_cols)
# 3-band envelopes for PWV4/5/6/7 — band-pass with scipy, then bin # Band envelopes first — the high band feeds the mono whiteness channel,
# to (a) 1200 cols for PWV4/PWV6 previews and (b) the same scroll # and all three feed the colour sections.
# column count as PWV3 for PWV5/PWV7.
lo_env, mid_env, hi_env = _band_envelopes(samples, SAMPLE_RATE) lo_env, mid_env, hi_env = _band_envelopes(samples, SAMPLE_RATE)
out["preview_low"] = _bin_to_bytes(lo_env, PWV6_COLS)
out["preview_mid"] = _bin_to_bytes(mid_env, PWV6_COLS) # Mono overview/scroll: one byte/col = (whiteness<<5)|height (PWAV/PWV3),
out["preview_high"] = _bin_to_bytes(hi_env, PWV6_COLS) # 4-bit height for the tiny preview (PWV2).
out["scroll_low"] = _bin_to_bytes(lo_env, scroll_cols) out["preview"] = _packed_mono(samples, hi_env, PWAV_COLS, PWAV_HEIGHT_LEVELS) # PWAV 400B
out["scroll_mid"] = _bin_to_bytes(mid_env, scroll_cols) out["tiny"] = _packed_tiny(samples, PWV2_COLS) # PWV2 100B
out["scroll_high"] = _bin_to_bytes(hi_env, scroll_cols) out["scroll"] = _packed_mono(samples, hi_env, scroll_cols, PWAV_HEIGHT_LEVELS) # PWV3
# 3-band colour data: SHARED-normalised preview (energy rolloff) for PWV4/
# PWV6, per-band-normalised scroll (vivid) for PWV5/PWV7.
out["preview_low"], out["preview_mid"], out["preview_high"] = _bin_bands(
lo_env, mid_env, hi_env, PWV6_COLS, shared=True)
out["scroll_low"], out["scroll_mid"], out["scroll_high"] = _bin_bands(
lo_env, mid_env, hi_env, scroll_cols, shared=False)
return out return out
+39 -58
View File
@@ -83,13 +83,12 @@ def _section_pqtz(beats: list[tuple[int, int, int]]) -> bytes:
def _section_pwav(preview: bytes) -> bytes: def _section_pwav(preview: bytes) -> bytes:
"""PWAV: WavePreviewTag — 400-byte touch-strip preview. """PWAV: WavePreviewTag — 400-byte touch-strip preview, **400 columns ×
1 byte**, each byte = (whiteness << 5) | height (3-bit whiteness, 5-bit
Per Beat Link's WaveformPreview reader, PWAV stores 200 col-pairs: height). `preview` is produced already in that form by
byte 2N : bits 0-4 = height (0-31) `maple.analyze._packed_mono`. (The earlier "200 height/intensity col-pairs"
byte 2N+1 : bits 0-2 = intensity (0-7); ≥5 renders bright cyan reading was wrong — the player decoded those as garbled half-columns with
`preview` is expected to already be in that format (`maple.analyze. no whiteness.)
_bin_amplitudes_paired`).
""" """
fourcc = b"PWAV" fourcc = b"PWAV"
len_header = 20 len_header = 20
@@ -98,8 +97,9 @@ def _section_pwav(preview: bytes) -> bytes:
def _section_pwv2(tiny: bytes) -> bytes: def _section_pwv2(tiny: bytes) -> bytes:
"""PWV2: 100-byte tiny preview. Same 2-bytes-per-col layout as PWAV """PWV2: 100-byte tiny preview — 100 columns × 1 byte, 4-bit height
but with **4-bit** height (bits 0-3, 0-15 range) per spec.""" (bits 0-3, 0-15) in the low nibble. `tiny` is produced by
`maple.analyze._packed_tiny`."""
fourcc = b"PWV2" fourcc = b"PWV2"
len_header = 20 len_header = 20
len_tag = len_header + len(tiny) len_tag = len_header + len(tiny)
@@ -149,76 +149,56 @@ def _section_pco2(cue_list_type: int) -> bytes:
def _section_pwv4(low: bytes, mid: bytes, high: bytes) -> bytes: def _section_pwv4(low: bytes, mid: bytes, high: bytes) -> bytes:
"""PWV4: WaveColorPreviewTag — fixed-width colour preview, 1200 cols × """PWV4: WaveColorPreviewTag — colour overview, 1200 cols × 6 bytes/col:
6 bytes/col. Per beatlink/deepsymmetry docs, the 6 bytes per column
interleave 6 "stripes" rendered top-to-bottom:
byte 0 : whiteness 1 (transient highlight) byte 0/1 : whiteness/brightness (= overall)
byte 1 : whiteness 2 (transient highlight) byte 2 : overall (full-spectrum) energy
byte 2 : energy lower-half of the frequency range byte 3 : bass (low band)
byte 3 : energy lower-third byte 4 : mid (mid band)
byte 4 : energy middle byte 5 : treble (high band)
byte 5 : energy top
The exact whiteness derivation isn't documented. We synthesise it as `low/mid/high` arrive SHARED-normalised (0-255); `overall = max(bands)`."""
the column's overall amplitude (peak of all bands), which gives the
same shape as the existing PWAV "intense column" highlight.
"""
fourcc = b"PWV4" fourcc = b"PWV4"
len_header = 24 len_header = 24
n = min(len(low), len(mid), len(high)) n = min(len(low), len(mid), len(high))
# Per-column max across bands → whiteness stand-in lo, md, hi = bytearray(low[:n]), bytearray(mid[:n]), bytearray(high[:n])
lo = bytearray(low[:n])
mid_arr = bytearray(mid[:n])
hi = bytearray(high[:n])
overall = bytearray(max(lo[i], mid_arr[i], hi[i]) for i in range(n))
body = bytearray(n * 6) body = bytearray(n * 6)
for i in range(n): for i in range(n):
body[i * 6 + 0] = overall[i] # whiteness 1 overall = max(lo[i], md[i], hi[i])
body[i * 6 + 1] = overall[i] # whiteness 2 (same — we don't have a separate channel) body[i * 6 + 0] = overall
body[i * 6 + 2] = lo[i] # bottom-half energy ≈ low band body[i * 6 + 1] = overall
body[i * 6 + 3] = lo[i] # bottom-third energy ≈ low band (closer to bottom) body[i * 6 + 2] = overall
body[i * 6 + 4] = mid_arr[i] # middle energy ≈ mid band body[i * 6 + 3] = lo[i] # bass
body[i * 6 + 5] = hi[i] # top energy ≈ high band body[i * 6 + 4] = md[i] # mid
len_entry_bytes = 6 body[i * 6 + 5] = hi[i] # treble
len_entries = n
len_tag = len_header + len(body) len_tag = len_header + len(body)
# Real export has `unknown = 0` here. return fourcc + pack(">IIIII", len_header, len_tag, 6, n, 0x00000000) + bytes(body)
return fourcc + pack(">IIIII", len_header, len_tag, len_entry_bytes, len_entries, 0x00000000) + bytes(body)
def _section_pwv5(low: bytes, mid: bytes, high: bytes) -> bytes: def _section_pwv5(scroll: bytes, low: bytes, mid: bytes, high: bytes) -> bytes:
"""PWV5: WaveColorScrollTag — colour scroll, 2 bytes/col packed as """PWV5: WaveColorScrollTag — colour scroll, u16 BE per col:
fedcba9876543210 fedcba9876543210
RRRGGGBBBHHHHH-- RRRGGGBBBHHHHH--
(3 bits R, 3 bits G, 3 bits B, 5 bits height, 2 unused). Colour **R=treble, G=mid, B=bass** (per spec). Bands arrive per-band
Map our (low, mid, high) band amplitudes (0-255) to (R, G, B) at normalised 0-255 → 3-bit via >>5. Height is the broadband value from the
3-bit each. Height = peak across bands at 5-bit. Result: warm reds mono `scroll` byte's low 5 bits, so colour and mono scrolls share a height.
for kick-heavy columns, blues for hat-heavy, etc. — matches how
rekordbox renders the NXS2 color waveform from low/mid/high content.
""" """
fourcc = b"PWV5" fourcc = b"PWV5"
len_header = 24 len_header = 24
n = min(len(low), len(mid), len(high)) n = min(len(scroll), len(low), len(mid), len(high))
body = bytearray(n * 2) body = bytearray(n * 2)
for i in range(n): for i in range(n):
# Map 0-255 → 0-7 (3-bit per channel). Low band → red, mid → green, r = high[i] >> 5 # red = treble
# high → blue (matches the "RGB ≈ low/mid/high" convention rekordbox g = mid[i] >> 5 # green = mid
# uses when reconstructing the NXS2 strip). b = low[i] >> 5 # blue = bass
r = low[i] >> 5 h = scroll[i] & 0x1F # broadband height (0-31), shared with PWV3
g = mid[i] >> 5
b = high[i] >> 5
h_in = max(low[i], mid[i], high[i])
h = h_in >> 3 # 0-255 → 0-31
word = (r & 0x7) << 13 | (g & 0x7) << 10 | (b & 0x7) << 7 | (h & 0x1F) << 2 word = (r & 0x7) << 13 | (g & 0x7) << 10 | (b & 0x7) << 7 | (h & 0x1F) << 2
body[i * 2] = (word >> 8) & 0xFF body[i * 2] = (word >> 8) & 0xFF
body[i * 2 + 1] = word & 0xFF body[i * 2 + 1] = word & 0xFF
len_entry_bytes = 2
len_entries = n
len_tag = len_header + len(body) len_tag = len_header + len(body)
return fourcc + pack(">IIIII", len_header, len_tag, len_entry_bytes, len_entries, 0x00960305) + bytes(body) return fourcc + pack(">IIIII", len_header, len_tag, 2, n, 0x00960305) + bytes(body)
def _section_pwv6(low: bytes, mid: bytes, high: bytes) -> bytes: def _section_pwv6(low: bytes, mid: bytes, high: bytes) -> bytes:
@@ -409,9 +389,10 @@ def build_ext(rb_path: str, analysis: dict | None = None) -> bytes:
# and (empirically) gates the CDJ's deck-side waveform/BPM display. # and (empirically) gates the CDJ's deck-side waveform/BPM display.
sections.append(_section_pqt2(analysis["beats"])) sections.append(_section_pqt2(analysis["beats"]))
if analysis: if analysis:
scroll = analysis.get("scroll") or b""
s_lo, s_md, s_hi = analysis.get("scroll_low") or b"", analysis.get("scroll_mid") or b"", analysis.get("scroll_high") or b"" s_lo, s_md, s_hi = analysis.get("scroll_low") or b"", analysis.get("scroll_mid") or b"", analysis.get("scroll_high") or b""
if s_lo and s_md and s_hi: if scroll and s_lo and s_md and s_hi:
sections.append(_section_pwv5(s_lo, s_md, s_hi)) sections.append(_section_pwv5(scroll, s_lo, s_md, s_hi))
p_lo, p_md, p_hi = analysis.get("preview_low") or b"", analysis.get("preview_mid") or b"", analysis.get("preview_high") or b"" p_lo, p_md, p_hi = analysis.get("preview_low") or b"", analysis.get("preview_mid") or b"", analysis.get("preview_high") or b""
if p_lo and p_md and p_hi: if p_lo and p_md and p_hi:
sections.append(_section_pwv4(p_lo, p_md, p_hi)) sections.append(_section_pwv4(p_lo, p_md, p_hi))
+50 -24
View File
@@ -30,6 +30,36 @@ from maple.validate import TrackIssue, log_issues, validate_track
_GENRE_SPLIT_RE = re.compile(r"\s*[;,/]\s*") _GENRE_SPLIT_RE = re.compile(r"\s*[;,/]\s*")
def usbanlz_location(usb_path: str) -> tuple[int, int]:
"""rekordbox's ANLZ folder derivation from a USB-relative track path.
Returns ``(bucket, anlz_id)`` for ``/PIONEER/USBANLZ/P{bucket:03X}/{anlz_id:08X}/``.
`usb_path` is the forward-slashed, leading-slash USB-relative path that the
pdb stores as `file_path` (e.g. ``/Contents/Artist/Album/01 Title.mp3``).
CRITICAL: the CDJ/XDJ firmware recomputes this from `file_path` and reads a
track's analysis from the resulting folder, *ignoring* the pdb's
`analyze_path` string. So maple must write its ANLZ files here and nowhere
else, or the player shows no waveform/beatgrid. Verified 1002/1002 against a
genuine rekordbox export and confirmed on XDJ-RX2 hardware.
`anlz_id` is a polynomial hash of the path's UTF-16 code units mod 0x30D43
(200003 — hence ids never exceed 0x30D42); `bucket` is a fixed bit-extraction
from that id. (Algorithm: github.com/Radexito/DjManager protocol.md §2.)
"""
h = 0
for ch in usb_path:
h = (h * 0x34F5501D + ord(ch) * 0x93B6) & 0xFFFFFFFF
anlz_id = h % 0x30D43
bucket = (
((((((((((anlz_id >> 2) & 0x4000) | (anlz_id & 0x2000)) >> 3)
| (anlz_id & 0x200)) >> 1) | (anlz_id & 0xC0)) >> 3)
| (anlz_id & 0x4)) >> 1) | (anlz_id & 0x1)
)
return bucket, anlz_id
def split_genre_tags(raw: str) -> list[str]: def split_genre_tags(raw: str) -> list[str]:
"""Tokenise a possibly multi-value genre string into normalised parts. """Tokenise a possibly multi-value genre string into normalised parts.
@@ -71,6 +101,13 @@ def build(input_dir: Path, usb_root: Path, with_playlists: bool = True) -> tuple
genre_to_tracks: dict[str, list[int]] = defaultdict(list) genre_to_tracks: dict[str, list[int]] = defaultdict(list)
track_by_src: dict[Path, int] = {} track_by_src: dict[Path, int] = {}
artwork_cache: dict[str, int] = {} artwork_cache: dict[str, int] = {}
# USBANLZ folder occupancy. Two distinct paths can hash to the same
# P{bucket}/{id} folder (a hash collision; also happens for genuine
# duplicate paths). rekordbox keeps both by bumping the filename to
# ANLZ0001.DAT, ANLZ0002.DAT, … inside the shared folder (verified: a
# real export had `Celestica` + `Soak It Up` co-located at P049/00030991).
# Mirror that so colliding tracks don't clobber each other's analysis.
anlz_folder_seen: dict[str, int] = defaultdict(int)
written: set[Path] = set() written: set[Path] = set()
audio_files = scan_audio(input_dir) audio_files = scan_audio(input_dir)
@@ -275,30 +312,19 @@ def build(input_dir: Path, usb_root: Path, with_playlists: bool = True) -> tuple
genre_id = primary_genre_id genre_id = primary_genre_id
track_id = len(db.tracks) + 1 track_id = len(db.tracks) + 1
# Reference exports always populate analyze_path even when the # The USBANLZ folder is derived from the USB-relative `file_path` by
# ANLZ files are real; tracks with an empty analyze_path appear # rekordbox's bespoke path hash (see `usbanlz_location`). The CDJ/XDJ
# to be hidden from the player's library. Synthesise a stable # firmware RE-DERIVES this same path from `file_path` and reads the
# path keyed off the track id even if we don't write the .DAT. # analysis from there — it IGNORES the pdb's `analyze_path` string —
import zlib # so the folder MUST match rekordbox exactly or the player silently
h = zlib.crc32(file_path.encode()) & 0xFFFFFFFF # shows no waveform/beatgrid. Verified 1002/1002 vs a real export and
# rekordbox writes 3-digit zero-padded buckets (e.g. P020 not P20) + # on XDJ-RX2 hardware. (Earlier crc32 scheme matched 0/984 → grids
# 8-hex-digit ids. The bucket value range observed in real exports # missing.)
# is 0..127 (P000..P07F) — wider masks here haven't shown up in any bucket_n, anlz_id = usbanlz_location(file_path)
# reference export and may push the CDJ outside whatever lookup anlz_folder = f"/PIONEER/USBANLZ/P{bucket_n:03X}/{anlz_id:08X}"
# range its firmware accepts. The 8-hex id is an opaque master.db anlz_index = anlz_folder_seen[anlz_folder] # 0, then 1, 2, … on collision
# value; we derive a stable-but-small value from crc32(path) capped anlz_folder_seen[anlz_folder] += 1
# to 20 bits so it lives in the same numeric range as real-export analyze_path = f"{anlz_folder}/ANLZ{anlz_index:04d}.DAT"
# ids (Q1's id is 0x0002E10A ≈ 190K).
bucket = f"P{(h >> 20) & 0x7F:03X}"
analyze_id = f"{h & 0xFFFFF:08X}"
# DEBUG: force Q1.1 to use the exact ANLZ path the real rekordbox
# export uses — settles whether the CDJ's path-template parser
# cares about the bucket/id pattern.
if file_path == "/Contents/Basic Channel/Q 1.1/02 Q1.1 II.mp3":
bucket = "P020"
analyze_id = "0002E10A"
logger.warning("Q1.1 path forced to real rekordbox bucket: {}/{}", bucket, analyze_id)
analyze_path = f"/PIONEER/USBANLZ/{bucket}/{analyze_id}/ANLZ0000.DAT"
anlz_abs = (usb_root / analyze_path.lstrip("/")).resolve() anlz_abs = (usb_root / analyze_path.lstrip("/")).resolve()
# `_analysis/` sits at the input collection root; ISRC-keyed cache # `_analysis/` sits at the input collection root; ISRC-keyed cache
# populated during `just collection`. write_anlz reads it before # populated during `just collection`. write_anlz reads it before
+12 -41
View File
@@ -90,15 +90,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" },
] ]
[[package]]
name = "aubio"
version = "0.4.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy" },
]
sdist = { url = "https://files.pythonhosted.org/packages/cd/80/302d89240603e5347c7f8026c8b02c59f8dfaec66c91a743d82de7c86006/aubio-0.4.9.tar.gz", hash = "sha256:df1244f6c4cf5bea382c8c2d35aa43bc31f4cf631fe325ae3992c219546a4202", size = 479008, upload-time = "2019-02-08T11:21:02.438Z" }
[[package]] [[package]]
name = "bidict" name = "bidict"
version = "0.23.1" version = "0.23.1"
@@ -252,12 +243,11 @@ version = "0.1.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "aiohttp" }, { name = "aiohttp" },
{ name = "aubio" },
{ name = "kaitaistruct" }, { name = "kaitaistruct" },
{ name = "loguru" }, { name = "loguru" },
{ name = "mixxx-analyzer" },
{ name = "mutagen" }, { name = "mutagen" },
{ name = "pillow" }, { name = "pillow" },
{ name = "polars" },
{ name = "pyrekordbox" }, { name = "pyrekordbox" },
{ name = "scipy" }, { name = "scipy" },
] ]
@@ -270,12 +260,11 @@ dev = [
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "aiohttp", specifier = ">=3.10" }, { name = "aiohttp", specifier = ">=3.10" },
{ name = "aubio", specifier = ">=0.4.9" },
{ name = "kaitaistruct", specifier = ">=0.11" }, { name = "kaitaistruct", specifier = ">=0.11" },
{ name = "loguru", specifier = ">=0.7" }, { name = "loguru", specifier = ">=0.7" },
{ name = "mixxx-analyzer", specifier = ">=0.1.2" },
{ name = "mutagen", specifier = ">=1.47.0" }, { name = "mutagen", specifier = ">=1.47.0" },
{ name = "pillow", specifier = ">=12.2.0" }, { name = "pillow", specifier = ">=12.2.0" },
{ name = "polars", specifier = ">=1.0" },
{ name = "pyrekordbox", specifier = ">=0.4.4" }, { name = "pyrekordbox", specifier = ">=0.4.4" },
{ name = "scipy", specifier = ">=1.10" }, { name = "scipy", specifier = ">=1.10" },
] ]
@@ -283,6 +272,16 @@ requires-dist = [
[package.metadata.requires-dev] [package.metadata.requires-dev]
dev = [{ name = "pytest", specifier = ">=9.0.3" }] dev = [{ name = "pytest", specifier = ">=9.0.3" }]
[[package]]
name = "mixxx-analyzer"
version = "0.1.2"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/53/b3/c4026eed48e7685a99212ede8a1ce22bd2442a7d677ec4b970a7718d5b23/mixxx_analyzer-0.1.2-py3-none-macosx_14_0_arm64.whl", hash = "sha256:cb509c06d630fad709f85dd07fbd21a029d337617de0866af74279ca0a1f596b", size = 13932797, upload-time = "2026-03-01T15:44:59.349Z" },
{ url = "https://files.pythonhosted.org/packages/a6/30/bf8afbb52d862d2976045b4f6d00456dcb6320bb5e449384a41a0ce40735/mixxx_analyzer-0.1.2-py3-none-manylinux_2_35_x86_64.whl", hash = "sha256:53d8ec329bae7a7714dfe1cd34cbcf3976ddb55fae0ed74c4872f47085a9d9cf", size = 56567723, upload-time = "2026-03-01T15:45:02.771Z" },
{ url = "https://files.pythonhosted.org/packages/2e/88/e22c9e7c3d862daec693de61c5f2843ce8e567cd4d5e866040c599b57e9d/mixxx_analyzer-0.1.2-py3-none-win_amd64.whl", hash = "sha256:cc6509acfc78f0f3e04ac9f0b1440e9cd31a3c389f888bbd91beae1f249c32b7", size = 44947103, upload-time = "2026-03-01T15:45:05.66Z" },
]
[[package]] [[package]]
name = "multidict" name = "multidict"
version = "6.7.1" version = "6.7.1"
@@ -417,34 +416,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
] ]
[[package]]
name = "polars"
version = "1.41.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "polars-runtime-32" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ff/f9/aeda46259b0669247a160315d2d51269de9504b9dd2f70acadbcb22f46b7/polars-1.41.2.tar.gz", hash = "sha256:256d6731162371b77f3f29a55eacb8c0fc740ddb1a293a01d2ef5b5393c5c708", size = 737996, upload-time = "2026-05-29T17:39:15.604Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1f/22/28f62d24f7db56ac4343588f9362d49b7b4177e55ac47a466fe696b0099b/polars-1.41.2-py3-none-any.whl", hash = "sha256:23ce9a2910b6e3e8d4258770bf44aa17170958df7af6e85feedf4458a04d8d29", size = 833445, upload-time = "2026-05-29T17:37:05.576Z" },
]
[[package]]
name = "polars-runtime-32"
version = "1.41.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/56/54e3ea0e9b64f327179049e4742241cc6b1d3e8fa414b05a057dd26df367/polars_runtime_32-1.41.2.tar.gz", hash = "sha256:7af09ec1ab053da2c9669e8d15f809a4083a29be05db57111688b8051062af56", size = 2989474, upload-time = "2026-05-29T17:39:17.257Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d6/9b/fe72a3811c0357cdb06c67bdc7695fa1623ad47948fc523195f5ac31037f/polars_runtime_32-1.41.2-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:95a08346dac337357cdb825c8076df7d36da54c4caa59a5cb41d0a30691c5edd", size = 52265283, upload-time = "2026-05-29T17:37:09.407Z" },
{ url = "https://files.pythonhosted.org/packages/0a/93/fab9da803fd80d9e83ef88c20932f637a10bc611b20415fc322eec84bc44/polars_runtime_32-1.41.2-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:dedfaeec2c7f995298da7319dd9431d662e5dd1d0ec51b1459df4a0234ceff52", size = 46571222, upload-time = "2026-05-29T17:37:13.698Z" },
{ url = "https://files.pythonhosted.org/packages/c8/2a/8843f34a8ac57acd058a39b87b03b580dd352a490e9dae0415e02033bdd4/polars_runtime_32-1.41.2-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:18eea22c5cc34e27f8a60950458ad81e6a9ea75e89363ca1367e14e7e7f781fc", size = 50409372, upload-time = "2026-05-29T17:37:17.875Z" },
{ url = "https://files.pythonhosted.org/packages/6c/c6/92b352fe88cf51bd0a19fb99e1c0cbe46aa26c14dcf7995b89869cd932ae/polars_runtime_32-1.41.2-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2630540dfdfb0f36f9b04a07c7c2e3f50bf2ad384113263c1c812007ee9141e0", size = 56405484, upload-time = "2026-05-29T17:37:22.684Z" },
{ url = "https://files.pythonhosted.org/packages/74/c4/bae3174c3b02f6b441d2e58594387abcd509f67a098f682a83b195f08966/polars_runtime_32-1.41.2-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:20e969e08f9b137e233c04cc04de73d9795f89eb77d34854e40a025965a43763", size = 50603512, upload-time = "2026-05-29T17:37:27.422Z" },
{ url = "https://files.pythonhosted.org/packages/f4/ed/f2d26ae02d92c2689056838ed59e2a626326ad23c2831d58637d25f6c82a/polars_runtime_32-1.41.2-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e7016a3deb641b64a31447abbbee0f34bd020a6a9ae34ee6b743837def15e2a4", size = 54328561, upload-time = "2026-05-29T17:37:32.587Z" },
{ url = "https://files.pythonhosted.org/packages/9b/c4/9c3831cc885dc7769e59abf8f583821a5fb4403fd0e4eba0ccc6d47a3d4b/polars_runtime_32-1.41.2-cp310-abi3-win_amd64.whl", hash = "sha256:1e5e5377c315e0dcafdfb2a31adc546abbaeb3f9cb1864e6536523d2af473265", size = 51978643, upload-time = "2026-05-29T17:37:37.443Z" },
{ url = "https://files.pythonhosted.org/packages/cd/c6/79e9f3f270270d7ed5575d92b7bfef49f01abd9275447161275b23b553a8/polars_runtime_32-1.41.2-cp310-abi3-win_arm64.whl", hash = "sha256:843d96f69d18eca53429c1198e58891db7f18111f83b9c419bb45ad9d73eaed5", size = 46006901, upload-time = "2026-05-29T17:37:42.522Z" },
]
[[package]] [[package]]
name = "propcache" name = "propcache"
version = "0.5.2" version = "0.5.2"