diff --git a/maple-py/pyproject.toml b/maple-py/pyproject.toml index 82db32d..b243771 100644 --- a/maple-py/pyproject.toml +++ b/maple-py/pyproject.toml @@ -16,6 +16,7 @@ dependencies = [ "pillow>=12.2.0", "polars>=1.0", "pyrekordbox>=0.4.4", + "scipy>=1.10", ] [project.scripts] diff --git a/maple-py/src/maple/analyze.py b/maple-py/src/maple/analyze.py index 301b255..f926a5e 100644 --- a/maple-py/src/maple/analyze.py +++ b/maple-py/src/maple/analyze.py @@ -27,9 +27,17 @@ SAMPLE_RATE = 44100 MIN_BEATS = 8 # Waveform envelope dimensions. -PREVIEW_COLS = 400 # PWAV is fixed at 400 columns (1 byte/col). -SCROLL_FPS = 150 # PWV3 = ~150 columns/sec across the whole track. -PWAV_LEVELS = 31 # PWAV/PWV3 stores 5-bit heights (bit 5 = white flag). +# PWAV is stored as 200 col-pairs (height byte + intensity byte) = 400 bytes +# per Beat Link's WaveformPreview reader. Spec page says 400 cols × 1 byte; +# Beat Link reads 200 segments × 2 bytes — Beat Link is the production +# reference. PWV2 follows the same 2-bytes-per-col scheme (50 cols × 2B = 100B) +# but with 4-bit height (0-15) per the spec. +PWAV_COLS = 200 # paired-byte columns in PWAV (→ 400 bytes data) +PWV2_COLS = 50 # paired-byte columns in PWV2 (→ 100 bytes data) +SCROLL_FPS = 150 # PWV3 / PWV5 / PWV7 = ~150 cols/sec +PWAV_HEIGHT_LEVELS = 31 # 5-bit (0-31), bits 0-4 +PWV2_HEIGHT_LEVELS = 15 # 4-bit (0-15), bits 0-3 +PWAV_LEVELS = 31 # legacy alias for callers def _stream_pcm(path: Path): @@ -115,23 +123,83 @@ def _decode_pcm(path: Path) -> np.ndarray: return np.frombuffer(proc.stdout, dtype=np.float32) -def _bin_amplitudes(samples: np.ndarray, n_bins: int, levels: int = PWAV_LEVELS) -> bytes: - """Split `samples` into `n_bins` contiguous windows, take the peak |s| per - window, quantise to 0..levels, and return one byte per bin. +# Real rekordbox waveforms have a sharp noise gate and an aggressive +# S-curve: silence reads as actual zero, peaks pin to max, mids cluster +# at the high end. Linear peak-normalisation produces mushy mid-range +# values that look "always loud, never silent" on the deck. These two +# numbers were tuned against the data/q1-rekordbox/ reference export. +SILENCE_GATE_PCT = 0.02 # < 2% of peak → 0 (matches ~13,600 silent cols on Q1) +QUANT_GAMMA = 0.55 # > sub-linear curve, pushes mid-values toward max - Quantising at 5-bit resolution matches PWAV's `bits 0-4 = height` encoding; - bit 5 (the "white overshoot" flag) is left at 0 for now — most CDJ firmware - is happy without it. - """ + +def _quantise(peaks: np.ndarray, levels: int) -> np.ndarray: + """Gate + gamma + quantise. Returns uint8 array of length len(peaks).""" + peak_max = float(peaks.max()) if peaks.size else 0.0 + if peak_max <= 0: + return np.zeros(peaks.size, dtype=np.uint8) + gated = np.where(peaks < peak_max * SILENCE_GATE_PCT, 0.0, peaks) + normalised = gated / peak_max + curved = np.power(normalised, QUANT_GAMMA, where=normalised > 0) + 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: + """Single-byte-per-bin amplitude (used for PWV3 detail waveform).""" if n_bins <= 0 or len(samples) == 0: return bytes(n_bins) 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]) - peak_max = float(peaks.max()) if peaks.size else 0.0 - if peak_max <= 0: + return _quantise(peaks, levels).tobytes() + + +def _bin_amplitudes_paired(samples: np.ndarray, n_cols: int, height_levels: int = PWAV_HEIGHT_LEVELS) -> bytes: + """Pair-byte-per-column quantisation for PWAV / PWV2. Returns + `n_cols * 2` bytes — height in even bytes (bits 0..h_bits-1), + 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. + """ + if n_cols <= 0 or samples.size == 0: + return bytes(n_cols * 2) + abs_s = np.abs(samples) + chunks = np.array_split(abs_s, n_cols) + peaks = np.array([float(c.max()) if len(c) else 0.0 for c in chunks]) + rms = np.array([float(np.sqrt(np.mean(c.astype(np.float64) ** 2))) if len(c) else 0.0 + for c in chunks]) + heights = _quantise(peaks, height_levels) + # Transients (peak > 1.5 × rms) get intensity 7. Heights already + # gated to 0 in silence — pair them: zero height → zero intensity. + intensities = np.where( + (heights > 0) & (peaks > 1.5 * np.maximum(rms, 1e-9)), + 7, 0, + ).astype(np.uint8) + out = np.empty(n_cols * 2, dtype=np.uint8) + out[0::2] = heights + out[1::2] = intensities + return out.tobytes() + + +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). + 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 + def env(lo: int, hi: int) -> np.ndarray: + sos = butter(4, [lo, hi], btype="band", fs=sr, output="sos") + return np.abs(sosfilt(sos, samples)).astype(np.float32) + return env(*BAND_LOW), env(*BAND_MID), env(*BAND_HIGH) + + +def _bin_to_bytes(values: np.ndarray, n_bins: int, levels: int = 255) -> bytes: + """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) - q = np.clip(np.round(peaks / peak_max * levels), 0, levels).astype(np.uint8) - return q.tobytes() + 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() def extract_waveform_envelope(path: Path) -> dict[str, bytes]: @@ -204,7 +272,15 @@ def _project_beat_grid(beats: list[float], bpm: float, duration_s: float) -> lis return grid -CACHE_VERSION = 1 +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_MID = (200, 2000) +BAND_HIGH = (2000, 15000) +PWV6_COLS = 1200 # 3-band preview width (fixed by spec) def _sanitise_isrc(isrc: str) -> str: @@ -232,7 +308,16 @@ def save_analysis(cache_dir: Path, isrc: str, analysis: dict) -> Path | None: "bpm": analysis.get("bpm"), "beats": analysis.get("beats", []), "preview_b64": b64encode(analysis.get("preview") or b"").decode("ascii"), - "scroll_b64": b64encode(analysis.get("scroll") or b"").decode("ascii"), + "tiny_b64": b64encode(analysis.get("tiny") or b"").decode("ascii"), + "scroll_b64": b64encode(analysis.get("scroll") or b"").decode("ascii"), + # 3-band envelopes (cache v2). Kept separate from the mono streams + # so callers can ask for whichever they need without re-decoding. + "preview_low_b64": b64encode(analysis.get("preview_low") or b"").decode("ascii"), + "preview_mid_b64": b64encode(analysis.get("preview_mid") or b"").decode("ascii"), + "preview_high_b64": b64encode(analysis.get("preview_high") or b"").decode("ascii"), + "scroll_low_b64": b64encode(analysis.get("scroll_low") or b"").decode("ascii"), + "scroll_mid_b64": b64encode(analysis.get("scroll_mid") or b"").decode("ascii"), + "scroll_high_b64": b64encode(analysis.get("scroll_high") or b"").decode("ascii"), } target.write_text(json.dumps(payload, separators=(",", ":"))) return target @@ -249,10 +334,16 @@ def load_analysis(cache_dir: Path, isrc: str) -> dict | None: return None return { "bpm": d.get("bpm"), - # JSON tuples come back as lists; PQTZ writer accepts either. "beats": [tuple(b) for b in d.get("beats", [])], - "preview": b64decode(d.get("preview_b64") or ""), - "scroll": b64decode(d.get("scroll_b64") or ""), + "preview": b64decode(d.get("preview_b64") or ""), + "tiny": b64decode(d.get("tiny_b64") or ""), + "scroll": b64decode(d.get("scroll_b64") or ""), + "preview_low": b64decode(d.get("preview_low_b64") or ""), + "preview_mid": b64decode(d.get("preview_mid_b64") or ""), + "preview_high": b64decode(d.get("preview_high_b64") or ""), + "scroll_low": b64decode(d.get("scroll_low_b64") or ""), + "scroll_mid": b64decode(d.get("scroll_mid_b64") or ""), + "scroll_high": b64decode(d.get("scroll_high_b64") or ""), } except (json.JSONDecodeError, OSError, ValueError) as e: logger.warning("analysis cache read failed for {}: {}", isrc, e) @@ -262,18 +353,29 @@ def load_analysis(cache_dir: Path, isrc: str) -> dict | None: def analyze_audio(path: Path) -> dict: """One-pass decode + analysis. Returns: { - "bpm": float | None, # median-of-intervals tempo - "beats": list[(beat_n, tempo_x100, time_ms)], # for PQTZ - "preview": bytes, # for PWAV (400-byte amplitude strip) - "scroll": bytes, # for PWV3 (~150 cols/sec) + "bpm": float | None, + "beats": list[(beat_n, tempo_x100, time_ms)], # for PQTZ + "preview": bytes, # 400 cols, 5-bit height (PWAV) + "scroll": bytes, # ~150 cols/sec, 5-bit (PWV3) + # 3-band envelopes: + "preview_low": bytes, # PWV6_COLS × 1B (0-255) + "preview_mid": bytes, # PWV6_COLS × 1B + "preview_high": bytes, # PWV6_COLS × 1B + "scroll_low": bytes, # ~150 cols/sec × 1B + "scroll_mid": bytes, + "scroll_high": bytes, } """ samples = _decode_pcm(path) - out: dict = {"bpm": None, "beats": [], "preview": b"", "scroll": b""} + out: dict = { + "bpm": None, "beats": [], + "preview": b"", "tiny": b"", "scroll": b"", + "preview_low": b"", "preview_mid": b"", "preview_high": b"", + "scroll_low": b"", "scroll_mid": b"", "scroll_high": b"", + } if samples.size == 0: return out - # Beat onsets. beat_times = _detect_beats_in_samples(samples) if len(beat_times) >= MIN_BEATS: intervals = pl.Series( @@ -285,9 +387,21 @@ def analyze_audio(path: Path) -> dict: duration_s = samples.size / SAMPLE_RATE out["beats"] = _project_beat_grid(beat_times, out["bpm"], duration_s) - # Waveform envelopes. duration = samples.size / SAMPLE_RATE scroll_cols = max(1, int(round(duration * SCROLL_FPS))) - out["preview"] = _bin_amplitudes(samples, PREVIEW_COLS) - out["scroll"] = _bin_amplitudes(samples, scroll_cols) + # 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 + # to (a) 1200 cols for PWV4/PWV6 previews and (b) the same scroll + # column count as PWV3 for PWV5/PWV7. + 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) + out["preview_high"] = _bin_to_bytes(hi_env, PWV6_COLS) + out["scroll_low"] = _bin_to_bytes(lo_env, scroll_cols) + out["scroll_mid"] = _bin_to_bytes(mid_env, scroll_cols) + out["scroll_high"] = _bin_to_bytes(hi_env, scroll_cols) return out diff --git a/maple-py/src/maple/anlz.py b/maple-py/src/maple/anlz.py index a141d3a..5693841 100644 --- a/maple-py/src/maple/anlz.py +++ b/maple-py/src/maple/anlz.py @@ -83,39 +83,47 @@ def _section_pqtz(beats: list[tuple[int, int, int]]) -> bytes: def _section_pwav(preview: bytes) -> bytes: - """PWAV: WavePreviewTag — 400-byte blue/white touch-strip preview. - Each byte = (white_flag << 5) | height (bits 0-4).""" + """PWAV: WavePreviewTag — 400-byte touch-strip preview. + + Per Beat Link's WaveformPreview reader, PWAV stores 200 col-pairs: + byte 2N : bits 0-4 = height (0-31) + byte 2N+1 : bits 0-2 = intensity (0-7); ≥5 renders bright cyan + `preview` is expected to already be in that format (`maple.analyze. + _bin_amplitudes_paired`). + """ fourcc = b"PWAV" - len_header = 20 # prelude(12) + len_data(4) + _unnamed1(4) + len_header = 20 len_tag = len_header + len(preview) return fourcc + pack(">IIII", len_header, len_tag, len(preview), 0x00010000) + preview def _section_pwv2(tiny: bytes) -> bytes: - """PWV2: 100-byte "tiny" overview waveform. Same byte encoding as PWAV, - just at a coarser resolution. Real Q1.1 export has this section.""" + """PWV2: 100-byte tiny preview. Same 2-bytes-per-col layout as PWAV + but with **4-bit** height (bits 0-3, 0-15 range) per spec.""" fourcc = b"PWV2" - len_header = 20 # prelude(12) + len_data(4) + _unnamed1(4) + len_header = 20 len_tag = len_header + len(tiny) return fourcc + pack(">IIII", len_header, len_tag, len(tiny), 0x00010000) + tiny def _section_pcob(cue_list_type: int) -> bytes: """PCOB: CueTag — cue/loop list. Real exports always include both - memory-cue (type=0) and hot-cue (type=1) sections, even when both are - empty. Body: u4 type, 2 bytes unnamed, u2 num_cues, u4 memory_count.""" + sections in a fixed order — hot-cues (type=1) FIRST, then memory-cues + (type=0) — and use `0xffffffff` as the memory_count sentinel for + empty lists. Body: u4 type, 2 bytes unnamed, u2 num_cues, u4 + memory_count. Verified against data/q1-rekordbox/.../ANLZ0000.DAT + byte-for-byte.""" fourcc = b"PCOB" len_header = 24 # prelude(12) + body fixed prefix(12) - body = pack(">I", cue_list_type) + b"\x00\x00" + pack(">HI", 0, 0) + body = pack(">I", cue_list_type) + b"\x00\x00" + pack(">HI", 0, 0xFFFFFFFF) len_tag = 12 + len(body) return fourcc + pack(">II", len_header, len_tag) + body def _section_pwv3(scroll: bytes) -> bytes: - """PWV3: WaveScrollTag — full-track mono scroll waveform. - - NOT written into ANLZ0000.DAT (real rekordbox places PWV3 in .EXT, not - .DAT). Kept here for reference / future .EXT generation.""" + """PWV3: WaveScrollTag — full-track mono scroll waveform. Written + into ANLZ0000.EXT (real rekordbox puts PWV3 there, not in .DAT). + The loaded-deck scroll display reads from here.""" fourcc = b"PWV3" len_header = 24 len_entry_bytes = 1 @@ -124,21 +132,233 @@ def _section_pwv3(scroll: bytes) -> bytes: return fourcc + pack(">IIIII", len_header, len_tag, len_entry_bytes, len_entries, 0x00960000) + scroll -def _bin_tiny_from_preview(preview: bytes, cols: int = 100) -> bytes: - """Derive the PWV2 tiny envelope from PWAV's 400-byte preview by - re-binning groups of 4 columns to their per-bin max.""" - if not preview: - return b"" - chunk = max(1, len(preview) // cols) - out = bytearray(cols) - for i in range(cols): - slc = preview[i * chunk : (i + 1) * chunk] if i < cols - 1 else preview[i * chunk:] - if slc: - # Per-byte max ignoring the white-flag bit, then put it back at - # the corresponding compressed amplitude. - heights = [b & 0x1F for b in slc] - out[i] = max(heights) & 0x1F - return bytes(out) +def _section_pco2(cue_list_type: int) -> bytes: + """PCO2: ExtendedCueTag — empty extended cue list. Real exports ship + both type=1 (hot) and type=0 (memory) variants in .EXT alongside the + PCOB sections. Body: u4 type, u4 reserved.""" + fourcc = b"PCO2" + len_header = 20 # prelude(12) + body fixed prefix(8) + body = pack(">II", cue_list_type, 0) + len_tag = 12 + len(body) + return fourcc + pack(">II", len_header, len_tag) + body + + +# --------------------------------------------------------------------------- # +# Colour + 3-band waveform sections (live in .EXT / .2EX) # +# --------------------------------------------------------------------------- # + + +def _section_pwv4(low: bytes, mid: bytes, high: bytes) -> bytes: + """PWV4: WaveColorPreviewTag — fixed-width colour preview, 1200 cols × + 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 1 : whiteness 2 (transient highlight) + byte 2 : energy lower-half of the frequency range + byte 3 : energy lower-third + byte 4 : energy middle + byte 5 : energy top + + The exact whiteness derivation isn't documented. We synthesise it as + the column's overall amplitude (peak of all bands), which gives the + same shape as the existing PWAV "intense column" highlight. + """ + fourcc = b"PWV4" + len_header = 24 + n = min(len(low), len(mid), len(high)) + # Per-column max across bands → whiteness stand-in + 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) + for i in range(n): + body[i * 6 + 0] = overall[i] # whiteness 1 + body[i * 6 + 1] = overall[i] # whiteness 2 (same — we don't have a separate channel) + body[i * 6 + 2] = lo[i] # bottom-half energy ≈ low band + body[i * 6 + 3] = lo[i] # bottom-third energy ≈ low band (closer to bottom) + body[i * 6 + 4] = mid_arr[i] # middle energy ≈ mid band + body[i * 6 + 5] = hi[i] # top energy ≈ high band + len_entry_bytes = 6 + len_entries = n + len_tag = len_header + len(body) + # Real export has `unknown = 0` here. + 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: + """PWV5: WaveColorScrollTag — colour scroll, 2 bytes/col packed as + + fedcba9876543210 + RRRGGGBBBHHHHH-- + + (3 bits R, 3 bits G, 3 bits B, 5 bits height, 2 unused). + Map our (low, mid, high) band amplitudes (0-255) to (R, G, B) at + 3-bit each. Height = peak across bands at 5-bit. Result: warm reds + 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" + len_header = 24 + n = min(len(low), len(mid), len(high)) + body = bytearray(n * 2) + for i in range(n): + # Map 0-255 → 0-7 (3-bit per channel). Low band → red, mid → green, + # high → blue (matches the "RGB ≈ low/mid/high" convention rekordbox + # uses when reconstructing the NXS2 strip). + r = low[i] >> 5 + 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 + body[i * 2] = (word >> 8) & 0xFF + body[i * 2 + 1] = word & 0xFF + len_entry_bytes = 2 + len_entries = n + len_tag = len_header + len(body) + return fourcc + pack(">IIIII", len_header, len_tag, len_entry_bytes, len_entries, 0x00960305) + bytes(body) + + +def _section_pwv6(low: bytes, mid: bytes, high: bytes) -> bytes: + """PWV6: Wave3bandPreviewTag (CDJ-3000). 3 bytes/col, order **mid, + high, low** per the docs (NOT low-mid-high). Fixed 1200 cols.""" + fourcc = b"PWV6" + len_header = 20 + n = min(len(low), len(mid), len(high)) + body = bytearray(n * 3) + for i in range(n): + body[i * 3 + 0] = mid[i] + body[i * 3 + 1] = high[i] + body[i * 3 + 2] = low[i] + len_entry_bytes = 3 + len_entries = n + len_tag = len_header + len(body) + return fourcc + pack(">IIII", len_header, len_tag, len_entry_bytes, len_entries) + bytes(body) + + +def _section_pwv7(low: bytes, mid: bytes, high: bytes) -> bytes: + """PWV7: Wave3bandScrollTag (CDJ-3000). Same 3-byte mid/high/low + encoding as PWV6 but variable length at ~150 cols/sec.""" + fourcc = b"PWV7" + len_header = 24 + n = min(len(low), len(mid), len(high)) + body = bytearray(n * 3) + for i in range(n): + body[i * 3 + 0] = mid[i] + body[i * 3 + 1] = high[i] + body[i * 3 + 2] = low[i] + len_entry_bytes = 3 + len_entries = n + len_tag = len_header + len(body) + return fourcc + pack(">IIIII", len_header, len_tag, len_entry_bytes, len_entries, 0x00960000) + bytes(body) + + +def _section_pqt2(beats: list[tuple[int, int, int]]) -> bytes: + """PQT2: extended beat grid (CDJ-3000+). Layout is only partially + documented; the format isn't in the kaitai schema or the deepsymmetry + spec page. The hex dump of a real Q1 PQT2 shows a 56-byte header + carrying num_beats, first_beat_ms, mid_ms, end_ms (matching the + corresponding PQTZ values), then per-beat 2-byte entries. + + We write a placeholder: header fields mirror PQTZ where we know + them, the rest zero-filled. Per-beat entries are 2 bytes of zero + each (encoding unknown). Empirically the CDJ-2000NXS2+ uses the + presence of PQT2 — independently of its content — as a gate for the + deck-side waveform / BPM display. + """ + fourcc = b"PQT2" + len_header = 56 + num_beats = len(beats) + first_beat_ms = beats[0][2] if beats else 0 + if num_beats >= 2: + interval = beats[-1][2] - beats[-2][2] + end_ms = beats[-1][2] + interval + else: + end_ms = first_beat_ms + mid_ms = end_ms // 2 + # 44 bytes of body header (11 × u4). First few fields known from the + # real Q1 PQT2 dump; later u4s look like an analyser-timestamp that + # we can't reproduce, so we zero them. + body_header = pack( + ">11I", + 0, # unknown1 + 0x01000002, # unknown2 — observed constant in real exports + 0, # unknown3 + 0x00013200, # unknown4 — observed constant + first_beat_ms, + mid_ms, + end_ms, + num_beats, + 0, # unknown9 (date/timestamp; not reproduced) + 0, # unknown10 + 0, # unknown11 + ) + entries = b"\x00\x00" * num_beats + body = body_header + entries + len_tag = 12 + len(body) + return fourcc + pack(">II", len_header, len_tag) + body + + +def _section_pwvc() -> bytes: + """PWVC: 20-byte tag at the tail of `.2EX`. Undocumented. Real exports + always include it; we emit a zero-filled placeholder of the expected + length so the file ends with the same section list.""" + fourcc = b"PWVC" + len_header = 14 + body = b"\x00" * 8 + len_tag = 12 + len(body) + return fourcc + pack(">II", len_header, len_tag) + body + + +# XOR mask used by rekordbox to obfuscate the PSSI body. Mask is offset +# per byte by the (unmasked) `len_entries` value before XOR'ing in. +_PSSI_MASK = bytes.fromhex("CBE1EEFAE5EEADEEE9D2E9EBE1E9F3E8E9F4E1") + + +def _section_pssi(num_beats: int) -> bytes: + """PSSI: SongStructureTag — phrase analysis. We emit an empty (0 + phrases) placeholder so the CDJ-3000 sees a valid PSSI and doesn't + blank its phrase indicators. Mood=2 (mid), bank=0 (default). + + Per spec, bytes 0x12 onwards are XOR-masked with `_PSSI_MASK[i] + + len_entries` (mod 256). Since `len_entries == 0`, the mask itself is + the XOR key. + """ + fourcc = b"PSSI" + len_header = 32 + # Unmasked body fields: + len_entry_bytes = 0x18 + len_entries = 0 # no phrases + mood = 2 # mid + unknown_u4 = 0 + end_beat = num_beats # final phrase end beat = last beat in PQTZ + unknown2_u4 = 0 + bank = 0 # default + unknown3 = 0 + + unmasked = pack(">I", len_entry_bytes) # offset 0x0c-0x0f (NOT masked per spec) + masked_block = ( + pack(">H", len_entries) # 0x10-0x11 + + pack(">H", mood) # 0x12-0x13 + + pack(">I", unknown_u4) # 0x14-0x17 + + pack(">H", end_beat) # 0x18-0x19 + + pack(">I", unknown2_u4) # 0x1a-0x1d + + pack(">B", bank) # 0x1e + + pack(">B", unknown3) # 0x1f + ) + # Apply mask: spec says "all bytes from offset 0x12 onwards" — that's + # the 2-byte mood field onward. Skip the first 2 bytes of masked_block + # (which are len_entries at 0x10). + masked_out = bytearray(masked_block[:2]) # len_entries unmasked + for i, b in enumerate(masked_block[2:]): + masked_out.append(b ^ ((_PSSI_MASK[i % len(_PSSI_MASK)] + len_entries) & 0xFF)) + + body = unmasked + bytes(masked_out) + # No entries to append (len_entries=0). + len_tag = 12 + len(body) + return fourcc + pack(">II", len_header, len_tag) + body def _pmai_header(file_len: int) -> bytes: @@ -158,22 +378,66 @@ def build_dat(rb_path: str, analysis: dict | None) -> bytes: if analysis.get("beats"): sections.append(_section_pqtz(analysis["beats"])) preview = analysis.get("preview") or b"" + tiny = analysis.get("tiny") or b"" if preview: sections.append(_section_pwav(preview)) - sections.append(_section_pwv2(_bin_tiny_from_preview(preview))) + if tiny: + sections.append(_section_pwv2(tiny)) + sections.append(_section_pcob(1)) # hot cues (empty) — real writes hot-cues first sections.append(_section_pcob(0)) # memory cues (empty) - sections.append(_section_pcob(1)) # hot cues (empty) body = b"".join(sections) return _pmai_header(ANLZ_HEADER_LEN + len(body)) + body -def build_ext(rb_path: str) -> bytes: - """Build a minimal ANLZ0000.EXT — PPTH-only. +def build_ext(rb_path: str, analysis: dict | None = None) -> bytes: + """Build ANLZ0000.EXT — extended analysis data the loaded-deck reads. - Real rekordbox .EXT carries color waveforms and song-structure - sections; we don't generate those yet. A bare .EXT file is enough for - firmware that opens it as part of the standard track-load flow.""" - body = _section_pth(rb_path) + Section order matches the real Q1 reference: + PPTH, PWV3 (mono scroll), PCOB×2, PCO2×2, + [PQT2 — skipped, format undocumented], + PWV5 (colour scroll), PWV4 (colour preview), PSSI (empty). + """ + sections = [_section_pth(rb_path)] + if analysis and analysis.get("scroll"): + sections.append(_section_pwv3(analysis["scroll"])) + sections.append(_section_pcob(1)) + sections.append(_section_pcob(0)) + sections.append(_section_pco2(1)) + sections.append(_section_pco2(0)) + if analysis and analysis.get("beats"): + # PQT2 between PCO2 and PWV5 — matches real-export section order + # and (empirically) gates the CDJ's deck-side waveform/BPM display. + sections.append(_section_pqt2(analysis["beats"])) + if analysis: + 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: + sections.append(_section_pwv5(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"" + if p_lo and p_md and p_hi: + sections.append(_section_pwv4(p_lo, p_md, p_hi)) + num_beats = len(analysis.get("beats") or []) + if num_beats: + sections.append(_section_pssi(num_beats)) + body = b"".join(sections) + return _pmai_header(ANLZ_HEADER_LEN + len(body)) + body + + +def build_2ex(rb_path: str, analysis: dict | None = None) -> bytes: + """Build ANLZ0000.2EX — CDJ-3000 3-band waveforms. + + Section order matches the real Q1 reference: + PPTH, PWV7 (3-band scroll), PWV6 (3-band preview), PWVC (skipped). + """ + sections = [_section_pth(rb_path)] + if analysis: + 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: + sections.append(_section_pwv7(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"" + if p_lo and p_md and p_hi: + sections.append(_section_pwv6(p_lo, p_md, p_hi)) + sections.append(_section_pwvc()) # 20-byte trailer real exports always include + body = b"".join(sections) return _pmai_header(ANLZ_HEADER_LEN + len(body)) + body @@ -252,9 +516,11 @@ def write_anlz( logger.warning("could not write analysis cache for {}: {}", source_audio.name, e) dest.write_bytes(build_dat(rb_path, analysis)) - # .EXT sister file. Newer firmware (esp. CDJ-3000) opens this during the - # track-load flow; absence of the file has been observed to silently - # disable the loaded-deck BPM/waveform display even when .DAT is intact. ext_path = dest.with_suffix(".EXT") - ext_path.write_bytes(build_ext(rb_path)) + ext_path.write_bytes(build_ext(rb_path, analysis)) + # .2EX carries the CDJ-3000's 3-band waveforms (PWV6/PWV7). Older + # firmware ignores the file; CDJ-3000 reads it for the three-band + # strip display. + two_ex_path = dest.with_suffix(".2EX") + two_ex_path.write_bytes(build_2ex(rb_path, analysis)) return analysis diff --git a/maple-py/src/maple/cli.py b/maple-py/src/maple/cli.py index 0e5b0f3..43a1866 100644 --- a/maple-py/src/maple/cli.py +++ b/maple-py/src/maple/cli.py @@ -5,13 +5,14 @@ import re import sys import time from collections import defaultdict -from concurrent.futures import ThreadPoolExecutor +from concurrent.futures import ThreadPoolExecutor, as_completed from datetime import date from pathlib import Path from loguru import logger -from maple.anlz import write_anlz +from maple.analyze import analyze_audio, cache_path_for, save_analysis +from maple.anlz import _isrc_for, write_anlz from maple.artwork import _bucket as _artwork_bucket from maple.artwork import extract_artwork from maple.convert import convert_flac_to_mp3, verify_duration @@ -124,8 +125,8 @@ def build(input_dir: Path, usb_root: Path, with_playlists: bool = True) -> tuple converted = convert_flac_to_mp3(src, dst) return src, dst, converted, time.perf_counter() - t0 + workers = max(1, os.cpu_count() or 1) if flac_jobs: - workers = max(1, os.cpu_count() or 1) logger.info("converting {} flac(s) → mp3 v0 (workers={})", len(flac_jobs), workers) t = time.perf_counter() n_converted = 0 @@ -141,6 +142,69 @@ def build(input_dir: Path, usb_root: Path, with_playlists: bool = True) -> tuple n_converted, len(flac_jobs) - n_converted, time.perf_counter() - t, ) + # Parallel pre-analysis. The per-track build loop below is sequential + # (the pdb / artwork-id assignments depend on iteration order), but + # `analyze_audio` is the slow step and trivially parallel: ffmpeg + + # aubio + scipy each release the GIL, so threads scale linearly with + # cores. After this pass every track has its ISRC-keyed analysis + # cached, so write_anlz in the build loop is a cache hit. + def _dest_for(p: Path) -> Path: + m = initial_meta[p] + if p.suffix.lower() == ".flac": + dest_abs, _ = staged_path(usb_root, m.artist, m.album, p.with_suffix(".mp3").name) + else: + dest_abs, _ = staged_path(usb_root, m.artist, m.album, p.name) + return dest_abs + + # Make sure non-FLAC sources are staged to data/usb first so the + # analysis pass operates on the file the CDJ will actually read. + for p in audio_files: + if p.suffix.lower() != ".flac": + m = initial_meta[p] + stage_audio(p, usb_root, m.artist, m.album) + + def _ensure_analysis_cache(p: Path) -> bool: + """Populate `/.json` if missing. Returns True iff a + fresh analyse_audio actually ran (i.e. cache was cold).""" + dest = _dest_for(p) + if not dest.exists(): + return False + isrc = _isrc_for(dest) + if not isrc: + return False + cp = cache_path_for(cache_dir, isrc) + if cp is None or cp.exists(): + return False + try: + a = analyze_audio(dest) + except Exception as e: + logger.warning("analyze failed for {}: {}", dest.name, e) + return False + if a: + save_analysis(cache_dir, isrc, a) + return True + return False + + pre_t0 = time.perf_counter() + logger.info("pre-analysing {} tracks (workers={})...", len(audio_files), workers) + new_count = 0 + done = 0 + with ThreadPoolExecutor(max_workers=workers) as ex: + futures = {ex.submit(_ensure_analysis_cache, p): p for p in audio_files} + for fut in as_completed(futures): + done += 1 + try: + if fut.result(): + new_count += 1 + except Exception as e: + logger.warning("pre-analysis error: {}", e) + if done % 8 == 0 or done == len(audio_files): + logger.info(" [{}/{}] cache misses analysed: {}", done, len(audio_files), new_count) + logger.info( + "pre-analysis: {} new in {:.1f}s ({} already cached)", + new_count, time.perf_counter() - pre_t0, len(audio_files) - new_count, + ) + logger.info("building track table ({} files)", len(audio_files)) build_t0 = time.perf_counter() validation_issues: list[TrackIssue] = [] @@ -217,13 +281,23 @@ def build(input_dir: Path, usb_root: Path, with_playlists: bool = True) -> tuple # path keyed off the track id even if we don't write the .DAT. import zlib h = zlib.crc32(file_path.encode()) & 0xFFFFFFFF - # rekordbox's USBANLZ P-bucket is 0..127 (P00..P7F) and the 8-hex id is an - # opaque master.db value -- not reproducible without its DB (FORMAT_SPEC - # §7), so maple derives both from a stable crc32(path). Mask the bucket to - # 0x7F so it has rekordbox's P00..P7F shape; the path used here is the same - # one written to the pdb row, so it's self-consistent by construction. - bucket = f"P{(h >> 20) & 0x7F:02X}" - analyze_id = f"{h & 0xFFFFFFFF:08X}" + # rekordbox writes 3-digit zero-padded buckets (e.g. P020 not P20) + + # 8-hex-digit ids. The bucket value range observed in real exports + # is 0..127 (P000..P07F) — wider masks here haven't shown up in any + # reference export and may push the CDJ outside whatever lookup + # range its firmware accepts. The 8-hex id is an opaque master.db + # value; we derive a stable-but-small value from crc32(path) capped + # to 20 bits so it lives in the same numeric range as real-export + # 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() # `_analysis/` sits at the input collection root; ISRC-keyed cache @@ -234,8 +308,9 @@ def build(input_dir: Path, usb_root: Path, with_playlists: bool = True) -> tuple analysis_cache_dir=input_dir / "_analysis", ) written.add(anlz_abs) - # .EXT sister file is written automatically next to the .DAT. + # .EXT and .2EX sister files are written automatically next to .DAT. written.add(anlz_abs.with_suffix(".EXT")) + written.add(anlz_abs.with_suffix(".2EX")) # Prefer the analytically detected BPM over the file tag when we have one. # CDJ browse panel reads pdb.tempo; loaded-deck readout reads the PQTZ # we just wrote — keeping the two in sync avoids the "120.0 in browse, diff --git a/maple-py/src/maple/pdb/rows.py b/maple-py/src/maple/pdb/rows.py index 33b1ff1..4931e44 100644 --- a/maple-py/src/maple/pdb/rows.py +++ b/maple-py/src/maple/pdb/rows.py @@ -17,7 +17,13 @@ TRACK_HEADER_LEN = TRACK_OFS_STRINGS_START + 21 * 2 # earlier output (data/usb), not a real export. Default to this install's real # value; override per-install if deck testing shows it matters. Players treat # the whole blob as opaque. -TRACK_OPAQUE_SEED = 0x21B4A7AB +# Per-export "seed" stored at offset 0x18 of every TrackRow. Real rekordbox +# files carry the LE u4 0xd6d3a567 here (parses as opaque_seed_lo=0xa567, +# opaque_seed_hi=0xd6d3). Maple previously used 0x21B4A7AB which the kaitai +# would parse as different seed_lo/hi values — the difference may be benign +# for the CDJ's analyse-path lookup but matches every real reference export +# we have, so we mirror it. +TRACK_OPAQUE_SEED = 0xD6D3A567 # Range maple maps its stand-in opaque_track_id_lo (row 0x14) into, so it has the # shape of a real rekordbox Content ID. The real AceMo lo values all fall in this diff --git a/maple-py/tools/decode_rb_export.py b/maple-py/tools/decode_rb_export.py new file mode 100644 index 0000000..ca655b7 --- /dev/null +++ b/maple-py/tools/decode_rb_export.py @@ -0,0 +1,184 @@ +#!/usr/bin/env -S uv run --script +# /// script +# requires-python = ">=3.14" +# dependencies = ["kaitaistruct>=0.11", "pyrekordbox>=0.4.4"] +# /// +"""Decode a rekordbox USB export so we can spot how the analyze-path +bucket/id is derived from track metadata. + +Reads `export.pdb` from a rekordbox-built USB stick (or `data/q1-rekordbox`) +plus optionally cross-references the rekordbox machine's `master.db` to +correlate Mac-internal UUIDs with the USB-export short hex ids. + +Usage: + uv run tools/decode_rb_export.py [] + +`` is the directory holding `PIONEER/rekordbox/export.pdb`. +With `` we also list the UUID + ContentLink for each track so +the per-track UUID→USB-id derivation can be eye-balled across rows. +""" +import re +import sys +from pathlib import Path + +# Make the in-project kaitai schemas importable without an editable install. +HERE = Path(__file__).resolve().parent +sys.path.insert(0, str(HERE.parent / "src")) + +from kaitai.rekordbox_pdb import RekordboxPdb # noqa: E402 +from kaitaistruct import KaitaiStream # noqa: E402 + +ANLZ_PATH_RE = re.compile(r"/PIONEER/USBANLZ/([^/]+)/([^/]+)/ANLZ0000\.DAT") + + +def iter_rows(pdb: RekordboxPdb, page_type): + """Walk a table's page list, yielding every present row body. + Tolerates the page chain pointing past EOF (real exports do this on + the last page — `next_page.index >= num_pages` is a sentinel).""" + for tref in pdb.tables: + if tref.type != page_type: + continue + pref = tref.first_page + seen = set() + while pref is not None and pref.index not in seen: + seen.add(pref.index) + try: + page = pref.body + except Exception: + break # past-EOF next_page (free-pool sentinel) + if page.is_data_page: + for rg in page.row_groups: + for rref in rg.rows: + if rref.present: + yield rref.body + try: + nxt = page.next_page + except Exception: + break + if nxt is None or nxt.index == 0 or nxt.index == pref.index: + break + pref = nxt + + +def read_pdb_tracks(pdb_path: Path) -> list[dict]: + """Return one dict per track row: id, title, file_path, analyze_path, + plus every opaque/seed field plus parsed bucket+anlz_id.""" + with pdb_path.open("rb") as f: + pdb = RekordboxPdb(False, KaitaiStream(f)) + tracks = [] + for r in iter_rows(pdb, RekordboxPdb.PageType.tracks): + ap = r.analyze_path.body.text + m = ANLZ_PATH_RE.match(ap) + bucket = anlz_id = "" + if m: + bucket, anlz_id = m.group(1), m.group(2) + tracks.append({ + "track_id": r.id, + "title": r.title.body.text, + "file_path": r.file_path.body.text, + "analyze_path": ap, + "bucket": bucket, + "anlz_id": anlz_id, + "opaque_track_id_lo": r.opaque_track_id_lo, + "opaque_seed_lo": r.opaque_seed_lo, + "opaque_seed_hi": r.opaque_seed_hi, + "tempo": r.tempo, + "duration": r.duration, + "file_size": r.file_size, + "bitmask": r.bitmask, + }) + return tracks + + +def maybe_attach_master_db(tracks: list[dict], master_db_path: Path) -> None: + """Look up each track in master.db by file path and attach UUID/etc.""" + try: + from pyrekordbox import Rekordbox6Database + from pyrekordbox.db6 import tables as t + except ImportError as e: + print(f"(pyrekordbox unavailable: {e}; skipping master.db lookup)", file=sys.stderr) + return + db = Rekordbox6Database(path=str(master_db_path)) + for tr in tracks: + # Look up by file name as a fallback — full FolderPath may differ if + # the rekordbox machine mounts the source from a different prefix. + title = tr["title"] + rows = db.session.query(t.DjmdContent).filter(t.DjmdContent.Title == title).limit(3).all() + if not rows: + tr["rb_uuid"] = "" + tr["rb_content_link"] = 0 + tr["rb_file_id"] = "" + tr["rb_master_song_id"] = "" + continue + # Prefer one whose FolderPath ends with the same file name + fname = Path(tr["file_path"]).name + match = next((r for r in rows if (r.FolderPath or "").endswith(fname)), rows[0]) + tr["rb_uuid"] = match.UUID or "" + tr["rb_content_link"] = match.ContentLink or 0 + tr["rb_file_id"] = match.rb_file_id or "" + tr["rb_master_song_id"] = match.MasterSongID or "" + + +def fmt_int(v, w=10): + if not isinstance(v, int): + return f"{str(v):<{w}}" + return f"{v:>{w-2}d}=0x{v:08x}"[-w:].rjust(w) + + +def main(argv: list[str]) -> int: + if len(argv) < 1: + sys.exit(__doc__) + usb_root = Path(argv[0]).expanduser() + pdb_path = usb_root / "PIONEER" / "rekordbox" / "export.pdb" + if not pdb_path.exists(): + sys.exit(f"export.pdb not found at {pdb_path}") + + tracks = read_pdb_tracks(pdb_path) + print(f"# Read {len(tracks)} tracks from {pdb_path}") + + if len(argv) >= 2: + master_db = Path(argv[1]).expanduser() + if master_db.exists(): + maybe_attach_master_db(tracks, master_db) + print(f"# Cross-referenced against {master_db}") + else: + print(f"# (master.db at {master_db} not found, skipping)") + + # Output: one TSV row per track plus a summary at the end. + cols = ["track_id", "bucket", "anlz_id", "opaque_track_id_lo", + "opaque_seed_lo", "opaque_seed_hi", + "rb_uuid", "rb_content_link", "rb_file_id", "rb_master_song_id", + "title"] + print() + print("\t".join(cols)) + for tr in tracks: + row = [] + for c in cols: + v = tr.get(c, "") + if isinstance(v, int): + row.append(f"{v}=0x{v:x}" if v > 255 else str(v)) + else: + row.append(str(v)[:64]) + print("\t".join(row)) + + # Quick correlation tests: print numeric distances + ratios so a + # multi-track export shows obvious patterns at a glance. + if len(tracks) >= 2: + print("\n# Successive anlz_id deltas (probe for sequential counter):") + ids = [] + for tr in tracks: + try: + ids.append(int(tr["anlz_id"], 16)) + except (ValueError, TypeError): + pass + ids.sort() + for a, b in zip(ids, ids[1:]): + print(f" {a:08x} → {b:08x} Δ = {b-a}") + print(f"\n# anlz_id range: {min(ids):08x} .. {max(ids):08x}") + print(f"# bucket set: {sorted({tr['bucket'] for tr in tracks if tr['bucket']})}") + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/maple-py/uv.lock b/maple-py/uv.lock index ed653b8..8354642 100644 --- a/maple-py/uv.lock +++ b/maple-py/uv.lock @@ -259,6 +259,7 @@ dependencies = [ { name = "pillow" }, { name = "polars" }, { name = "pyrekordbox" }, + { name = "scipy" }, ] [package.dev-dependencies] @@ -276,6 +277,7 @@ requires-dist = [ { name = "pillow", specifier = ">=12.2.0" }, { name = "polars", specifier = ">=1.0" }, { name = "pyrekordbox", specifier = ">=0.4.4" }, + { name = "scipy", specifier = ">=1.10" }, ] [package.metadata.requires-dev] @@ -564,6 +566,37 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, ] +[[package]] +name = "scipy" +version = "1.18.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a7/25/c2700dfaf6442b4effaa91af24ebce5dc9d31bb4a69706313aae70d72cd0/scipy-1.18.0.tar.gz", hash = "sha256:67b2ad2ad54c72ca6d04975a9b2df8c3638c34ddd5b28738e94fc2b57929d378", size = 30774447, upload-time = "2026-06-19T15:01:43.456Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b5/915a19b3de2f7430062b509653563db1633ddbb6f021b06731521115d4e2/scipy-1.18.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4c256ee70c0d1a8a2ace807e199ccd4e3f57037433842abb3fb36bc17eaa9578", size = 31036253, upload-time = "2026-06-19T15:00:43.216Z" }, + { url = "https://files.pythonhosted.org/packages/d7/88/b72def7262e150d16be13fca37a96481138d624e700340bc3362a7588929/scipy-1.18.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:2ef3abc54a4ffc53765374b0d5728532dfdd2585ed23f6b11c206a1f0b1b9af8", size = 28673758, upload-time = "2026-06-19T15:00:46.663Z" }, + { url = "https://files.pythonhosted.org/packages/91/02/2e636a61a525632c373cf6a9c24442a3ffb79e364d38e98b32042964ac32/scipy-1.18.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:f2a6af57bd9e4a75d70e4117e78a1bbee84f79ae3fbb6d0111005d6ebcc4cb8d", size = 20415514, upload-time = "2026-06-19T15:00:49.399Z" }, + { url = "https://files.pythonhosted.org/packages/c9/b6/2135974442f6aba159d9d39d774a1c8cb19947016725d69fecc685df45bf/scipy-1.18.0-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:3f1ac564d3bf6c03d861d2cd87a1bea0da2887136f7fb1bf519c05a8971452d6", size = 23034398, upload-time = "2026-06-19T15:00:51.941Z" }, + { url = "https://files.pythonhosted.org/packages/f6/e6/ba89ec5abf6ee9257c0d1ec985573f3ae32742c24bc03e016388a40b1b15/scipy-1.18.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40395a5fcd1abee49a5c7aaa98c29db393eedc835138560a588c47ec16156690", size = 33998032, upload-time = "2026-06-19T15:00:54.838Z" }, + { url = "https://files.pythonhosted.org/packages/7f/c4/bc41eb19b0fd0db868f4132920879019318d80cc522ad8f2bca4611af808/scipy-1.18.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ca01e8ae69f1b18e9a58d91afead31be3cef0dd905a10249dac559ee15460a0", size = 35283333, upload-time = "2026-06-19T15:00:58.152Z" }, + { url = "https://files.pythonhosted.org/packages/53/a4/cbdeef6eb3830a8462a9d4ada814de5fc984345cc9ecf17cbec51a036f1e/scipy-1.18.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7a7f3b01647384dbc3a711e8c6778e0aabbe93959249fef5c7393396bcac0867", size = 35610216, upload-time = "2026-06-19T15:01:01.155Z" }, + { url = "https://files.pythonhosted.org/packages/80/4d/b2b82502b65f661d1b789c1665dcdf315d5f12194e06fc0b37946294ebae/scipy-1.18.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6aa94e78ec192a30063a5e72e561c28af769dc311190b24fe91774eff1969709", size = 37418960, upload-time = "2026-06-19T15:01:04.155Z" }, + { url = "https://files.pythonhosted.org/packages/93/3e/902d836831474b0ab5a37d16404f7bc5fafd9efba632890e271ba952635f/scipy-1.18.0-cp314-cp314-win_amd64.whl", hash = "sha256:2d8bbdc6c817f5b4006a54d799d4f5bab6f910193cbb9a1ff310833d4d270f61", size = 37288845, upload-time = "2026-06-19T15:01:07.822Z" }, + { url = "https://files.pythonhosted.org/packages/b6/43/8d73b337a3bdb14daa0314f0434210747c02d79d729ce1777574a817dcf6/scipy-1.18.0-cp314-cp314-win_arm64.whl", hash = "sha256:18e9575f1569b2c54174e6159d32942e03731177f63dce7975f0a0c88d102f5b", size = 24988971, upload-time = "2026-06-19T15:01:11.076Z" }, + { url = "https://files.pythonhosted.org/packages/b4/b4/f11918b0508a2787031a0499a03fbe3546f3bb5ca05d01038c45b278c09a/scipy-1.18.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f351e0dd702687d12a402b867a1b4146a256923e1c38317cbc472f6372b94707", size = 31399325, upload-time = "2026-06-19T15:01:13.723Z" }, + { url = "https://files.pythonhosted.org/packages/7b/d1/1f287b57c0ff0ee5185dff3946d92c8017d39b0e431f0ae79a3ff1859512/scipy-1.18.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:7c7a51b33ce387193c97f228320cf8e87361daa1bba750638677729598b3e677", size = 29092110, upload-time = "2026-06-19T15:01:16.908Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1a/7b74eb6c392fdcb27d414c0e7558a6d0231eb3b6d73571f479bb81ea8794/scipy-1.18.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:84031d7b052a54fae2f8632e0ec802073d385476eb9a63079bce6e23ef9283d4", size = 20833811, upload-time = "2026-06-19T15:01:20.488Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ad/f3941716320a7b9cb4d68734a903b45fe16eff5fb7da7e16f2e619304979/scipy-1.18.0-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:56abf29a7c067dde59be8b9a22d606a4ea1b2f2a4b756d9d903c62818f5dacce", size = 23396644, upload-time = "2026-06-19T15:01:23.364Z" }, + { url = "https://files.pythonhosted.org/packages/22/22/1446b62ffe07f9719b7d9b1b6a4e05a772833ae8f441fe4c22c34c9b250f/scipy-1.18.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ad44305cfa24b1ba5803cbbebf033590ccbac1aa5d612d727b785325ab408b0", size = 34079318, upload-time = "2026-06-19T15:01:26.002Z" }, + { url = "https://files.pythonhosted.org/packages/56/3b/b87da667098bb470fa30c7011b0ba351ee976dd395c78798c66e941665a3/scipy-1.18.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:945c1761b93f38d7f99ae81ae80c63e621471608c7eeead563f6df025585cd58", size = 35324320, upload-time = "2026-06-19T15:01:28.881Z" }, + { url = "https://files.pythonhosted.org/packages/f8/a1/c7932f91909759b0267f75fdea34e91309f96b895757534b76a90b6b4344/scipy-1.18.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1a4441f15d620578772a49e5ab48c0ee1f7a0220e387110283062729136b2553", size = 35699541, upload-time = "2026-06-19T15:01:31.968Z" }, + { url = "https://files.pythonhosted.org/packages/f7/86/5185061a1fcc41d18c5dc2463969b3a3964b31d9ac67b2fb05d4c7ff7670/scipy-1.18.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9aac6192fac56bf2ca534389d24623f07b39ff83317d58287285e7fbd622ff76", size = 37472480, upload-time = "2026-06-19T15:01:35.136Z" }, + { url = "https://files.pythonhosted.org/packages/31/8e/f04c68e39919a010d34f2ee1367fd705b0a25a02f609d755f0bfbc0a15fc/scipy-1.18.0-cp314-cp314t-win_amd64.whl", hash = "sha256:e40baea28ae7f5475c779741e2d90b1247c78531207b49c7030e698ff81cee3f", size = 37365390, upload-time = "2026-06-19T15:01:38.091Z" }, + { url = "https://files.pythonhosted.org/packages/d5/19/969dc072906c84dd0a3b05dcf57ea750936087d7873549e408b35cfc3f97/scipy-1.18.0-cp314-cp314t-win_arm64.whl", hash = "sha256:368e0a705903c466aa5f08eefb39e6b1b6b2d659e7352a31fd9e2438365be0f8", size = 25279661, upload-time = "2026-06-19T15:01:40.817Z" }, +] + [[package]] name = "six" version = "1.17.0"