Getting Started
Frame-accurate, lossless MP3 splitting and duration parsing in pure Python — no ffmpeg, no subprocess, no decode step.
Cuts are made by parsing the file's own MPEG frame headers and byte-copying whole frames: output is byte-identical to the corresponding span of the source audio frames, just shorter — leading ID3v2 tags, the VBR header frame, and any trailer present in the source are not carried into split output.
Install
pip install waxcut
# or
uv add waxcut
Usage
from pathlib import Path
from waxcut import load_audio_stream, frame_index_at, slice_bytes
stream = load_audio_stream(Path("song.mp3"))
print(f"{stream.playable_duration_ms / 1000:.1f}s")
# Split at the 90-second mark
cut_at = frame_index_at(stream.frames, target_ms=90_000)
first_half = slice_bytes(stream.data, stream.frames, 0, cut_at)
second_half = slice_bytes(stream.data, stream.frames, cut_at, len(stream.frames))
Path("part1.mp3").write_bytes(first_half)
Path("part2.mp3").write_bytes(second_half)
Splitting into more than two parts
split_at collapses the loop above into one call for N cut points, and
join_frames is its inverse — concatenating the resulting segments
reproduces the original audio's frame span exactly (not the original file
bytes; tags/VBR header/trailer aren't preserved):
from pathlib import Path
from waxcut import load_audio_stream, split_at, join_frames, slice_bytes
stream = load_audio_stream(Path("mixtape.mp3"))
parts = split_at(stream, timestamps_ms=[90_000, 180_000, 270_000])
for i, part in enumerate(parts):
Path(f"part{i}.mp3").write_bytes(part)
# join_frames is the inverse of split_at: reassembling the parts reproduces
# the source's audio frame span
assert join_frames(parts) == slice_bytes(stream.data, stream.frames, 0, len(stream.frames))
Tagging split output
write_id3v2_tag writes a minimal ID3v2.3 tag (title/artist/track number)
onto untagged bytes — typically one segment of split_at's output, which
never has a leading ID3v2 tag of its own. It refuses to tag data that
already starts with an ID3v2 tag:
from pathlib import Path
from waxcut import load_audio_stream, split_at, write_id3v2_tag
stream = load_audio_stream(Path("album.mp3"))
segments = split_at(stream, timestamps_ms=[90_000, 180_000, 270_000])
titles = ["Intro", "Second Track", "Third Track", "Outro"]
for i, (segment, title) in enumerate(zip(segments, titles, strict=True), start=1):
tagged = write_id3v2_tag(segment, title=title, artist="Various Artists", track=i)
Path(f"track{i}.mp3").write_bytes(tagged)
For source files with true variable bitrate (VBR) encoding, some tag readers
and players estimate duration from the first MPEG frame's bitrate rather than
decoding the whole file. Since split output doesn't carry forward the
original Xing/VBRI VBR header, those readers may report an inaccurate
duration for split VBR tracks. This is a property of how splitting works
(frame-accurate byte copying), not a bug in write_id3v2_tag.
Splitting an album using a .cue sheet
If you already have a .cue sheet for the album (the usual companion to a
single-file rip), parse_cue_sheet turns its TRACK/INDEX 01 entries
directly into the timestamps split_at expects — no need to work out cut
points by hand:
from pathlib import Path
from waxcut import load_audio_stream, parse_cue_sheet, split_at
stream = load_audio_stream(Path("album.mp3"))
cue_text = Path("album.cue").read_text()
timestamps = parse_cue_sheet(cue_text)
tracks = split_at(stream, timestamps)
for i, track in enumerate(tracks, start=1):
Path(f"track{i:02d}.mp3").write_bytes(track)
See API Reference for the exact cue grammar this parses and the errors it raises on malformed input.
Putting it together: cue sheet, split, and tag
Combining the two sections above, a full cue-sheet-driven rip: parse the cue sheet, split on its timestamps, and tag each resulting track before writing it to disk.
Two INDEX 01 entries can land on the same MPEG frame boundary (cue sheets
are timestamped at 1/75-second CD-frame resolution, finer than an MP3
frame), and a cue sheet can also outrun the actual audio if it doesn't quite
match the file it's paired with. Both produce an empty split_at segment,
which write_id3v2_tag will happily tag into a file containing nothing but
a tag header — one waxcut itself refuses to load back in. Skip empty
segments rather than writing them:
from pathlib import Path
from waxcut import load_audio_stream, parse_cue_sheet, split_at, write_id3v2_tag
stream = load_audio_stream(Path("album.mp3"))
cue_text = Path("album.cue").read_text()
timestamps = parse_cue_sheet(cue_text)
tracks = split_at(stream, timestamps)
titles = ["Intro", "Second Track", "Third Track"]
for i, (segment, title) in enumerate(zip(tracks, titles, strict=True), start=1):
if len(segment) == 0:
continue # sub-frame-resolution or over-long cue entry -- nothing to write
tagged = write_id3v2_tag(segment, title=title, track=i)
Path(f"track{i:02d}.mp3").write_bytes(tagged)
Large files: use_mmap and split_to_files
For a multi-hour file, load_audio_stream(path, use_mmap=True) avoids
reading the whole source file into memory (see
How It Works). Pair it
with split_to_files, which writes each segment straight to its own output
path instead of returning a list[bytes] with every segment held at once:
from pathlib import Path
from waxcut import load_audio_stream, split_to_files
with load_audio_stream(Path("huge_mixtape.mp3"), use_mmap=True) as stream:
cut_points = [90_000, 180_000, 270_000] # ms
output_paths = [Path(f"part{i}.mp3") for i in range(len(cut_points) + 1)]
split_to_files(stream, cut_points, output_paths)
This avoids holding all segments in memory at once — each is written and
becomes eligible for garbage collection before the next is cut — but each
individual segment is still fully materialized as one bytes object by
slice_bytes before it's written, same as split_at. It's not a fully
streaming, byte-for-byte pipeline; it just avoids the worst of split_at's
memory profile for this common case. For full control over how each
segment is produced or written, call frame_index_at/slice_bytes
directly instead of split_to_files, the same way split_to_files itself
does internally.
See How It Works for why this approach is safe, and the API Reference for the full public surface.