pith-mp3 0.1.1__py3-none-win_amd64.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pith_mp3/__init__.py ADDED
@@ -0,0 +1,209 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2026 pith-hash
3
+ """pith-mp3 SDK: MPEG audio decoding through ctypes.
4
+
5
+ Decodes layers I/II/III (including the bit reservoir) through the Rust
6
+ cdylib, handing back the canonical byte stream the ``reference.json``
7
+ vectors are defined over: a 48-byte big-endian header followed by the
8
+ decoded PCM, interleaved ``i32`` little-endian.
9
+
10
+ The cdylib is located through the suite's discovery chain:
11
+
12
+ 1. ``PITH_CDYLIB`` — an explicit cdylib *file* path;
13
+ 2. ``PITH_CDYLIB_DIR`` — a *directory* scanned for the cdylib names
14
+ (the CD pipeline points this at ``target/release``);
15
+ 3. the package directory itself (the built wheel ships the cdylib as
16
+ package data);
17
+ 4. ``<repo root>/target/release`` — the repository working-tree layout,
18
+ so a source checkout runs against a local cargo build with no
19
+ configuration.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import ctypes
25
+ import os
26
+ from dataclasses import dataclass
27
+ from pathlib import Path
28
+
29
+ __all__ = [
30
+ "Canonical",
31
+ "FfiError",
32
+ "LibraryNotFoundError",
33
+ "find_cdylib",
34
+ "decode_canonical",
35
+ "parse_canonical",
36
+ "STATUS_OK",
37
+ "STATUS_INVALID",
38
+ "STATUS_REJECTED",
39
+ "HEADER_LEN",
40
+ ]
41
+
42
+ #: Status: success.
43
+ STATUS_OK = 0
44
+ #: Status: a caller argument is invalid (a null pointer).
45
+ STATUS_INVALID = -1
46
+ #: Status: the core decoder refused the input (malformed MPEG audio).
47
+ STATUS_REJECTED = -2
48
+
49
+ #: The canonical stream's header length in bytes.
50
+ HEADER_LEN = 48
51
+
52
+ #: Every cdylib file name cargo may drop into the build directory, per
53
+ #: platform (windows / linux / macOS).
54
+ CDYLIB_NAMES = ("pith_mp3.dll", "libpith_mp3.so", "libpith_mp3.dylib")
55
+
56
+
57
+ @dataclass(frozen=True)
58
+ class Canonical:
59
+ """A decoded MPEG audio stream, re-expressed from the canonical
60
+ byte stream.
61
+
62
+ ``pcm`` is the interleaved ``i32`` little-endian sample buffer —
63
+ exactly the bytes the ``pcm_sha256`` digest covers.
64
+ """
65
+
66
+ #: Layer of the first frame: 1, 2 or 3.
67
+ layer: int
68
+ #: Bitrate of the first frame in kbit/s (VBR streams vary after).
69
+ bitrate_kbps: int
70
+ #: Sample rate declared in the first frame header.
71
+ header_sample_rate: int
72
+ #: Channel count declared in the first frame header.
73
+ header_channels: int
74
+ #: Decoded channel count, 1 or 2.
75
+ channels: int
76
+ #: Decoded sample rate per channel.
77
+ sample_rate: int
78
+ #: Frames actually decoded.
79
+ frames: int
80
+ #: Decoded sample count, interleaved across channels.
81
+ samples: int
82
+ #: True when a Xing/Info VBR header frame was seen.
83
+ vbr: bool
84
+ #: The canonical byte stream the digest is computed over.
85
+ raw: bytes
86
+
87
+ @property
88
+ def pcm(self) -> bytes:
89
+ """The interleaved ``i32`` little-endian PCM (everything after
90
+ the 48-byte header)."""
91
+ return self.raw[HEADER_LEN:]
92
+
93
+ @property
94
+ def pcm_head_hex(self) -> str:
95
+ """The first 64 PCM bytes, lowercase hex — the same string the
96
+ vectors' ``pcm_head_hex`` records."""
97
+ return self.raw[HEADER_LEN : HEADER_LEN + 64].hex()
98
+
99
+
100
+ class LibraryNotFoundError(OSError):
101
+ """No cdylib was found through the discovery chain."""
102
+
103
+
104
+ class FfiError(Exception):
105
+ """A non-zero status code came back from the cdylib."""
106
+
107
+ def __init__(self, op: str, status: int) -> None:
108
+ detail = {
109
+ STATUS_INVALID: "invalid argument",
110
+ STATUS_REJECTED: "input rejected",
111
+ }.get(status, "unknown failure")
112
+ super().__init__(f"{op} failed: {detail} (status {status})")
113
+ #: The raw status code the FFI returned.
114
+ self.status = status
115
+
116
+
117
+ def find_cdylib() -> Path:
118
+ """Locates the cdylib through the suite's discovery chain."""
119
+ explicit = os.environ.get("PITH_CDYLIB")
120
+ if explicit:
121
+ p = Path(explicit)
122
+ if p.is_file():
123
+ return p
124
+ env_dir = os.environ.get("PITH_CDYLIB_DIR")
125
+ candidates: list[Path] = []
126
+ if env_dir:
127
+ env_dir_path = Path(env_dir)
128
+ candidates.append(env_dir_path)
129
+ if not env_dir_path.is_absolute():
130
+ # CD and local runs invoke tools from the repository root or
131
+ # from sdk/<lang>; resolve the env value against both.
132
+ candidates.append(Path.cwd() / env_dir_path)
133
+ candidates.append(Path(__file__).resolve().parents[3] / env_dir_path)
134
+ candidates.append(Path(__file__).resolve().parent) # packaged wheel
135
+ candidates.append(Path(__file__).resolve().parents[3] / "target" / "release")
136
+ for directory in candidates:
137
+ for name in CDYLIB_NAMES:
138
+ p = directory / name
139
+ if p.is_file():
140
+ return p
141
+ raise LibraryNotFoundError(
142
+ "no pith-mp3 cdylib found (searched PITH_CDYLIB, PITH_CDYLIB_DIR, "
143
+ "the package directory and <repo>/target/release); "
144
+ "run `cargo build --release` first"
145
+ )
146
+
147
+
148
+ _lib: ctypes.CDLL | None = None
149
+
150
+
151
+ def _load() -> ctypes.CDLL:
152
+ global _lib
153
+ if _lib is None:
154
+ lib = ctypes.CDLL(str(find_cdylib()))
155
+ lib.pith_mp3_decode.argtypes = [
156
+ ctypes.c_void_p, # data
157
+ ctypes.c_size_t, # len
158
+ ctypes.POINTER(ctypes.c_void_p), # out buffer
159
+ ctypes.POINTER(ctypes.c_size_t), # out length
160
+ ]
161
+ lib.pith_mp3_decode.restype = ctypes.c_int32
162
+ lib.pith_mp3_free.argtypes = [ctypes.c_void_p, ctypes.c_size_t]
163
+ lib.pith_mp3_free.restype = None
164
+ _lib = lib
165
+ return _lib
166
+
167
+
168
+ def decode_canonical(data: bytes) -> bytes:
169
+ """Decodes a complete MPEG audio stream into the canonical byte
170
+ stream the ``reference.json`` vectors are defined over.
171
+
172
+ Raises :class:`FfiError` with ``status == STATUS_REJECTED`` for any
173
+ malformed input — no sync word, bad header fields, CRC mismatch or
174
+ a truncated stream; the decoder never panics through this boundary.
175
+ """
176
+ out = ctypes.c_void_p()
177
+ out_len = ctypes.c_size_t()
178
+ status = _load().pith_mp3_decode(data, len(data), ctypes.byref(out), ctypes.byref(out_len))
179
+ if status != STATUS_OK:
180
+ raise FfiError("pith_mp3_decode", status)
181
+ try:
182
+ return ctypes.string_at(out, out_len.value)
183
+ finally:
184
+ _load().pith_mp3_free(out, out_len.value)
185
+
186
+
187
+ def parse_canonical(raw: bytes) -> Canonical:
188
+ """Re-expresses the canonical byte stream as a :class:`Canonical`."""
189
+ if len(raw) < HEADER_LEN:
190
+ raise ValueError("canonical stream is shorter than the 48-byte header")
191
+
192
+ def u32(offset: int) -> int:
193
+ return int.from_bytes(raw[offset : offset + 4], "big")
194
+
195
+ def u64(offset: int) -> int:
196
+ return int.from_bytes(raw[offset : offset + 8], "big")
197
+
198
+ return Canonical(
199
+ layer=u32(0),
200
+ bitrate_kbps=u32(4),
201
+ header_sample_rate=u32(8),
202
+ header_channels=u32(12),
203
+ channels=u32(16),
204
+ sample_rate=u32(20),
205
+ frames=u32(24),
206
+ samples=u64(28),
207
+ vbr=u32(36) == 1,
208
+ raw=raw,
209
+ )
pith_mp3/pith_mp3.dll ADDED
Binary file
@@ -0,0 +1,12 @@
1
+ Metadata-Version: 2.4
2
+ Name: pith-mp3
3
+ Version: 0.1.1
4
+ Summary: MP3 decoding SDK: layers I/II/III including the bit reservoir via the pith-mp3 Rust cdylib (ctypes, zero-dep)
5
+ License-Expression: MIT
6
+ Project-URL: Repository, https://github.com/pith-hash/pith-mp3
7
+ Project-URL: Homepage, https://github.com/pith-hash/pith-mp3
8
+ Keywords: mp3,audio,decoder,mpeg,hashing
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Programming Language :: Rust
11
+ Classifier: Topic :: Multimedia :: Sound/Audio
12
+ Requires-Python: >=3.9
@@ -0,0 +1,6 @@
1
+ pith_mp3/__init__.py,sha256=9pJd6cGfZgAHCoHmgaVHwqNNcwXh9-Q5zmjov3LiQ_Q,6904
2
+ pith_mp3/pith_mp3.dll,sha256=tzeqZIS59O67mm9cATSR-yYBWM2Tpg02LrWAjRvaFpI,221184
3
+ pith_mp3-0.1.1.dist-info/METADATA,sha256=X3IERL3JaWWKaZMBSAyCgr7Slie6UJdm4lqhZMNmxSE,531
4
+ pith_mp3-0.1.1.dist-info/WHEEL,sha256=Zx98gwb_dQKckJK3HEOVKnxxD52EfU_PXDG_usMJ2ng,98
5
+ pith_mp3-0.1.1.dist-info/top_level.txt,sha256=pMC_7NkpJ4WZY4t2MQta96jQcvKqEV64DmY0B2qUpMA,9
6
+ pith_mp3-0.1.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: false
4
+ Tag: py3-none-win_amd64
5
+
@@ -0,0 +1 @@
1
+ pith_mp3