API Reference
The full public surface of waxcut — every function and class in
waxcut.__all__ (the package version string, waxcut.__version__, is the
one non-callable entry and isn't covered here). All names are importable
directly from the top-level waxcut package.
load_audio_stream
def load_audio_stream(path: Path | str, *, use_mmap: bool = False) -> AudioStream
Loads an MP3 file and parses it into an AudioStream ready
for frame-accurate splitting.
Reads the whole file into memory, scans it for MPEG Layer III frames (see
scan_frames, which also skips any leading ID3v2 tag), and
checks whether the first frame is a Xing/Info/VBRI VBR header rather than
real audio. If it is, that frame is excluded from the returned frames
list, every remaining frame's start_ms is rebased so the first real audio
frame starts at 0, and — if the header carries a LAME gapless extension —
encoder_delay_samples/encoder_padding_samples are extracted from it.
Pass use_mmap=True to memory-map the file instead of reading it into a
bytes object — the file's bytes are never fully materialized in memory,
which matters for a multi-hour file. AudioStream.data is then an
mmap.mmap rather than bytes (every function here that accepts data
works identically with either), and the file is kept open for the
AudioStream's whole lifetime — call AudioStream.close() (or use it as a
context manager) when done with it. use_mmap=True is governed by its own,
larger 2 GB size cap rather than the 250 MB default. See
Security for the rationale behind both
limits.
Args
path(Path | str) — path to an MP3 file on disk.use_mmap(bool, keyword-only, defaultFalse) — ifTrue, memory-map the file instead of reading it into abytesobject; see above.
Returns
AudioStream
Raises
UnsupportedMp3Error— no valid MPEG Layer III frame was found anywhere in the file (propagated fromscan_frames), or the file consists of only a VBR header frame with no audio frames after it.FileTooLargeError— the file exceeds the applicable size limit: 250 MB by default, or 2 GB withuse_mmap=True. Checked against the file's size on disk before opening it, so an oversized file never gets read or mapped in the first place. See Security.FileNotFoundError(and other OS-level errors) — propagated from reading the file ifpathdoesn't exist or can't be opened.
AudioStream
@dataclass(frozen=True, eq=False)
class AudioStream:
data: bytes | mmap.mmap
frames: Frames
encoder_delay_samples: int
encoder_padding_samples: int
sample_rate: int
A parsed MP3 stream with located frames and gapless metadata. Normally
constructed via load_audio_stream rather than
directly.
Equality and hashing are identity-based (object's default) — two
AudioStreams parsed from the same file are not ==, regardless of
use_mmap. eq=False opts out of the field-wise __eq__/__hash__ a
frozen dataclass generates by default, which would otherwise compare (and
hash) the full data field — reading the entire file on every equality
check or hash() call, while still reporting two independently-parsed
streams as equal since data is the only field capable of comparing equal
by value in the first place.
Fields
data(bytes | mmap.mmap) — the complete file bytes this stream was parsed from, or (if loaded withuse_mmap=True) anmmap.mmapview over them. Every function in this module that acceptsdata(scan_frames,slice_bytes, etc.) works identically with either.frames(Frames) — the located frames, in file order (seeFramesfor exactly whichlist[Frame]-like operations it supports). If the source file had a Xing/Info/VBRI VBR header frame, it has already been excluded here, and the remaining frames rebased so the first one hasstart_ms == 0.encoder_delay_samples(int) — samples of encoder padding at the start of the audio, read from a LAME gapless tag if one was present;0otherwise. Informational only: it does not affect frame boundaries or where splits can land — real players skip this many samples at the start, but split output is fresh audio starting exactly at a frame boundary with no delay semantics of its own to carry over.encoder_padding_samples(int) — samples of encoder padding at the end of the audio, from the same source;0if absent. Same caveat as above (real players stop this many samples early; split output has no padding semantics of its own).sample_rate(int) — audio sample rate in Hz (e.g.44100,48000).
Properties
duration_ms -> float— total playback duration spanned byframes(equivalent tototal_duration_ms(self.frames)).playable_duration_ms -> float— the duration a real player would report:duration_msminus the gapless delay/padding trim (converted from samples to milliseconds viasample_rate), clamped to a minimum of0.0.
Methods
close() -> None— releases the mmap and file handle backingdata, if any. A no-op when thisAudioStreamwas loaded withoutuse_mmap=True(datais a plain, already-materializedbytesobject with no open file handle to release). Safe to call more than once.AudioStreamalso supports use as a context manager (with load_audio_stream(path, use_mmap=True) as stream:), which callsclose()automatically on exit.
Frame
@dataclass(frozen=True)
class Frame:
offset: int
length: int
start_ms: float
duration_ms: float
One located MPEG Layer III frame, as produced by
scan_frames or found in AudioStream.frames.
Fields
offset(int) — byte offset of this frame's header within the source data.length(int) — total length of this frame in bytes (header + side info + audio data) — how far to advance fromoffsetto reach the next frame.start_ms(float) — playback position of this frame's start, in milliseconds. Frames returned directly byscan_framesare timed from the very first frame found in the data; frames onAudioStream.framesare timed relative to the first real audio frame, i.e. after any VBR header frame has been excluded byload_audio_stream.duration_ms(float) — this frame's own playback duration, in milliseconds.
Frames
class Frames(Sequence[Frame])
The type returned by scan_frames and found on
AudioStream.frames. Backed by four packed array.array buffers rather
than one Python object per frame: indexing/iterating constructs a
Frame on demand instead of every frame being pre-allocated up
front. Measured at ~24 bytes/frame vs. ~128 bytes/frame for an equivalent
list[Frame] — see Security for why this
matters and the real numbers behind it.
Supports a specific subset of list[Frame] operations, not the full
interface:
len(), positive and negative indexing (frames[3],frames[-1]), and iteration all work the same way aslist[Frame].- Slicing (
frames[2:5]) works, but only with a step of1— a stepped slice (frames[::2]) raisesTypeError, since real step support for this array-backed view isn't implemented (no caller in this codebase needs it). A slice returns anotherFramessharing the same backing arrays — it never copies. - Equality is identity-based, not element-wise:
Framesdoesn't define__eq__, so twoFramesviews over the same or equal underlying data are only==if they're the same object. This differs fromlist[Frame], where two lists with equal elements compare equal.
Not constructed directly by callers.
frame_index_at
def frame_index_at(frames: Sequence[Frame], target_ms: float) -> int
Returns the index of the last frame in frames that starts at or before
target_ms. This is how a "split at N milliseconds" request becomes a
frame boundary for slice_bytes: a lossless split can only
land on a frame's own start, so this snaps to the nearest one at or before
the requested time.
Args
frames(Sequence[Frame]) — e.g. aFramesfromscan_framesorAudioStream.frames, or a plainlist[Frame].target_ms(float) — desired split point in milliseconds.
Returns
int— an index intoframes. Atarget_msbefore the first frame's start clamps to0; atarget_msat or beyond the last frame's start clamps to the last index.
On an empty frames list, or a NaN target_ms: raises ValueError
immediately, rather than returning a meaningless index. (NaN comparisons are
always false, so without this guard a NaN target would silently walk to the
last frame index instead of erroring.)
slice_bytes
def slice_bytes(data: bytes | mmap.mmap, frames: Sequence[Frame], start_idx: int, end_idx: int) -> bytes
Returns the raw bytes covering frames[start_idx:end_idx] as one
contiguous range copied directly out of data — this is a byte-copy, not a
re-parse. Frames are assumed contiguous, which holds for any list produced
by scan_frames from the same data. The result is itself a decodable,
standalone MP3 stream (no container/ID3 wrapper), byte-identical to the
corresponding span of the original file.
Args
data(bytes | mmap.mmap) — the same bytesframeswas derived from.frames(Sequence[Frame]) — fromscan_framesorAudioStream.frames.start_idx(int) — first frame index to include (inclusive).end_idx(int) — one past the last frame index to include (exclusive) — standard Python slice semantics.
Returns
bytes—b""ifstart_idx >= end_idx; otherwise the byte span fromframes[start_idx].offsetthrough the end offrames[end_idx - 1].
On an empty frames list: raises ValueError. On a negative
start_idx/end_idx: raises IndexError explicitly, rather than
silently wrapping to an unintended frame the way Python's own negative
indexing would. Positive out-of-range indices surface as a normal Python
IndexError from indexing frames[start_idx] or frames[end_idx - 1].
split_at
def split_at(stream: AudioStream, timestamps_ms: Sequence[float]) -> list[bytes]
Convenience wrapper around frame_index_at +
slice_bytes for the common case of cutting at several
timestamps in one call, instead of looping manually.
Args
stream(AudioStream) — fromload_audio_stream.timestamps_ms(Sequence[float]) — desired cut points, in milliseconds. Need not be sorted or in range — each is clamped byframe_index_at, and the resulting frame indices are then sorted, so unsorted input is normalized to ascending cut points rather than raising. A duplicate timestamp, or two timestamps landing on the same frame, still produces an empty segment between them.
Returns
list[bytes]—len(timestamps_ms) + 1segments, in ascending time order. The count only depends on how many timestamps were passed — sorting reorders where the cuts land, never how many segments come back — but the segments follow position in the stream, not the order the timestamps were given in. Each is a standalone, decodable MP3 stream. Concatenating all of them (seejoin_frames) reproduces the original audio exactly, for any input order.
split_to_files
def split_to_files(stream: AudioStream, timestamps_ms: Sequence[float], output_paths: Sequence[Path]) -> None
Same cut-point semantics as split_at, but writes each segment
straight to its own output path via Path.write_bytes instead of returning
them all as one list[bytes]. For a stream loaded with use_mmap=True,
this avoids split_at's failure mode of holding every segment (and
therefore the whole file) in the Python heap at once — each segment is
written and then eligible for garbage collection before the next one is
sliced. This is about not accumulating all segments at once, not about
streaming a single segment: each individual segment is still fully
materialized as one bytes object by slice_bytes before being written,
same as split_at.
Args
stream(AudioStream) — fromload_audio_stream.timestamps_ms(Sequence[float]) — desired cut points, in milliseconds. Same sorting/clamping/duplicate-timestamp semantics assplit_at.output_paths(Sequence[Path]) — one path per output segment, in ascending stream order (not the ordertimestamps_mswas given in — same reorderingsplit_atapplies). Must have exactlylen(timestamps_ms) + 1entries, one per segmentsplit_atwould have returned. Existing files at these paths are overwritten.
Returns
None
Raises
ValueError—len(output_paths) != len(timestamps_ms) + 1.
join_frames
def join_frames(segments: Sequence[bytes]) -> bytes
Concatenates frame-aligned MP3 byte segments back into one stream. Safe because MPEG Layer III frames are self-delimited — each carries its own length in its header — so concatenation always reproduces the joined audio frame span exactly, with no re-parsing or re-alignment needed. Not the original file bytes, though: leading ID3v2 tags, the VBR header frame, and any trailer aren't carried into split output, so they're absent from a rejoin too.
Args
segments(Sequence[bytes]) — byte segments to join, in order, as produced byslice_bytesorsplit_at.
Returns
bytes— the concatenated result.
write_id3v2_tag
def write_id3v2_tag(
data: bytes,
*,
title: str | None = None,
artist: str | None = None,
track: int | None = None,
) -> bytes
Prepends a fresh, minimal ID3v2.3 tag onto data, writing TIT2 (title),
TPE1 (artist), and TRCK (track number) frames for whichever fields are
given. No padding, no footer. Intended for data that has no leading
ID3v2 tag of its own, which is always true of
slice_bytes/split_at output: this function
detects a pre-existing leading ID3v2 tag and refuses to tag over it (see
Raises below) rather than stacking a second tag on top of it.
Text is encoded per-frame: Latin-1 (ID3v2 encoding byte 0x00) where the
text allows it, UTF-16 with an explicit little-endian BOM (encoding byte
0x01) otherwise — UTF-8 is a v2.4-only encoding and would be invalid in
this v2.3 tag.
Args
data(bytes) — bytes to tag; coerced viabytes(data)so amemoryviewor similar is also accepted.title(str | None) — track title, written as aTIT2frame if given.artist(str | None) — track artist, written as aTPE1frame if given.track(int | None) — track number, written as aTRCKframe (str(track), no"N/total"support yet) if given.
Returns
bytes— the ID3v2.3 tag followed immediately bydata.
Raises
ValueError—trackis given and is less than1;title/artistcontains NUL, CR, or LF (a NUL truncates the field for readers that treat it as a C string terminator, and CR/LF can make stored content differ from what's displayed — rejected rather than silently stripped);dataalready starts with an ID3v2 tag (stacking a second tag on top would corrupt frame scanning, sincescan_frames/id3v2_sizeonly ever skip the outermost tag); or the combined frame payload doesn't fit in a 4-byte ID3v2 syncsafe integer.
total_duration_ms
def total_duration_ms(frames: Sequence[Frame]) -> float
Total playback duration spanned by frames, in milliseconds — the last
frame's start_ms plus its duration_ms.
Args
frames(Sequence[Frame]) — non-empty, as returned byscan_frames.
Returns
float
Raises
IndexError—framesis empty (frames[-1]on an empty sequence).scan_framesitself never returns an emptyFrames— it raisesUnsupportedMp3Errorinstead — so this only happens if you pass in an empty sequence you constructed or filtered yourself.
parse_cue_sheet
def parse_cue_sheet(text: str) -> list[float]
Parses CUE-sheet text into cut-point timestamps, in milliseconds.
Extracts each AUDIO TRACK's INDEX 01 timestamp from a single-FILE CUE sheet
(the standard shape for a ripped album: one audio file, several TRACK/INDEX
01 entries marking where each track starts). The first track's INDEX 01 is
almost always 00:00:00 and is dropped from the output — it isn't a real
cut point, the stream already starts there; feeding a leading 0.0 into
split_at would otherwise produce a spurious empty first segment. Any other
collected timestamp, including a genuinely nonzero first one, is kept.
Recognized but ignored: REM comments, TITLE/PERFORMER/SONGWRITER
(disc- and track-level), CATALOG, CDTEXTFILE, ISRC, FLAGS,
PREGAP/POSTGAP, INDEX 00 and INDEX 02+, and any TRACK whose type
isn't AUDIO (its INDEX lines are skipped, not treated as errors).
MM:SS:FF is CD Red Book timecode — minutes, seconds, and CD "frames" (0-74
at 75 frames/second) — unrelated to and not to be confused with an MPEG
audio Frame elsewhere on this page; it's a fixed 1/75-second CD
unit, not a frame of audio data.
Args
text(str) — the full contents of a.cuefile, already decoded tostr.
Returns
list[float]— cut-point timestamps in milliseconds, in the order tracks appear, ready to pass directly assplit_at'stimestamps_msargument. Empty if the cue sheet describes only a single track.
Raises
CueSheetError—textcontains no AUDIO TRACK with an INDEX 01 entry; contains more than one FILE line (multi-FILE cue sheets, where each TRACK's audio lives in a different file, aren't supported — their timestamps aren't comparable without knowing per-file boundaries); an INDEX 01 timestamp isn't valid MM:SS:FF (wrong field count, non-numeric fields, seconds outside 0-59, or the CD frame field outside 0-74 at 75 frames/second); an AUDIO track's block ends without ever recording its own INDEX 01; or a later INDEX 01 timestamp is strictly less than the one before it (equal, i.e. duplicate, timestamps are allowed —split_atalready documents that a duplicate timestamp simply yields an empty segment).
Example
Given this single-FILE, 3-track cue sheet:
REM GENRE Reggae
REM DATE 1978
PERFORMER "The Wailers"
TITLE "Kaya"
FILE "kaya.mp3" MP3
TRACK 01 AUDIO
TITLE "Easy Skanking"
PERFORMER "The Wailers"
INDEX 01 00:00:00
TRACK 02 AUDIO
TITLE "Kaya"
PERFORMER "The Wailers"
INDEX 00 03:22:18
INDEX 01 03:25:37
TRACK 03 AUDIO
TITLE "Sun Is Shining"
PERFORMER "The Wailers"
INDEX 01 07:00:00
parse_cue_sheet(text) returns:
[205493.33333333334, 420000.0]
Track 1's INDEX 01 00:00:00 is dropped (the stream already starts there),
track 2's INDEX 00 (pregap) is ignored, and track 2's and track 3's
INDEX 01 entries become the two cut points — feeding this list directly
into split_at(stream, timestamps) produces exactly 3 segments, one per
track.
scan_frames
def scan_frames(data: bytes | mmap.mmap, *, max_size: int | None = None) -> Frames
Scans data for MPEG Layer III audio frames, skipping any leading ID3v2
tag (via id3v2_size). At each position it tries to parse a
valid frame header; if the header doesn't check out (bad sync word,
unsupported layer, a reserved bitrate/sample-rate index, or a computed
frame length that would run past the end of data), the scan advances one
byte and keeps looking — this is what lets it skip past a trailing
ID3v1/APE tag or other non-frame bytes without getting stuck. Every frame
found is recorded with its own start_ms/duration_ms, timed
cumulatively from the first frame found in data.
Note that this returns every parsed frame, including a leading
Xing/Info/VBRI VBR header frame if the file has one — excluding that frame
from playback/duration is load_audio_stream's job,
not scan_frames's.
Args
data(bytes | mmap.mmap) — raw file bytes.max_size(int | None, keyword-only, defaultNone) — maximum allowed size in bytes;Nonemeans the default 250 MB cap.load_audio_streamuses this internally to apply its own larger 2 GB cap when called withuse_mmap=True. Most callers should leave this at the default.
Returns
Frames— never empty.
Raises
UnsupportedMp3Error— no valid MPEG Layer III frame was found anywhere indata. This covers both non-MP3 input and files containing only Layer I/II frames, which this parser doesn't recognize (see How It Works). Also raised if 2,000,000 consecutive candidate sync bytes each fail header validation with zero frames located yet — input that looks nothing like an MP3 from the start is rejected quickly instead of scanning all the way tomax_size. If frames were already found before such a streak began, this bound doesn't discard them:scan_framesreturns those frames instead of raising, the same outcome as data simply running out.FileTooLargeError—dataexceedsmax_size(250 MB by default). See Security.
id3v2_size
def id3v2_size(data: bytes | mmap.mmap) -> int
Returns the byte length of a leading ID3v2 tag at the start of data, or
0 if data doesn't start with one. The tag's size is read from ID3v2's
syncsafe 4-byte size field and added to the fixed 10-byte header size.
Args
data(bytes | mmap.mmap) — raw file bytes.
Returns
int—0if no ID3v2 tag is present, otherwise the tag's total size in bytes, including its 10-byte header.
WaxcutError
class WaxcutError(ValueError)
Common base for waxcut's parse/format errors. Catch WaxcutError to
handle any waxcut-specific parse/format failure in one place, instead of
needing to know about UnsupportedMp3Error's and
CueSheetError's trees separately. Subclasses ValueError,
so an except ValueError handler written before this base class existed
keeps working unchanged.
Caller-misuse errors -- invalid arguments to
write_id3v2_tag, frame_index_at,
slice_bytes, split_to_files, or a
stepped Frames slice -- are deliberately plain
ValueError/TypeError, not WaxcutError.
UnsupportedMp3Error
class UnsupportedMp3Error(WaxcutError)
Raised when frame parsing can't make sense of the input as an MP3. In the current implementation this covers two cases:
scan_framesfinds no valid MPEG Layer III frame anywhere in the data — this includes files that aren't MP3s at all, and files that contain only Layer I/II frames, which this parser doesn't recognize.load_audio_streamfinds a file consisting of only a VBR header frame (Xing/Info/VBRI) with no real audio frames after it.
Subclasses WaxcutError (and therefore ValueError).
CueSheetError
class CueSheetError(WaxcutError)
Raised when cue-sheet text can't be parsed into cut-point timestamps.
Covers malformed MM:SS:FF timestamps, a TRACK with no INDEX 01, cue text
with no audio tracks at all, out-of-order INDEX 01 timestamps, and
multi-FILE cue sheets (unsupported — see
parse_cue_sheet). Always raised with a message naming
the offending line.
Subclasses WaxcutError (and therefore ValueError).
FileTooLargeError
class FileTooLargeError(UnsupportedMp3Error)
Raised by scan_frames/load_audio_stream
when input exceeds the applicable size limit: 250 MB by default, or 2 GB
when load_audio_stream is called with use_mmap=True — see
Security for why both limits exist and
which one a given call is subject to. Subclasses UnsupportedMp3Error (and
therefore ValueError), so an existing except UnsupportedMp3Error handler
still catches it. It's a distinct class so callers who want to tell "too
large" apart from "not a valid MP3" can catch it specifically.