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 bit-identical to the source, just shorter.
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/join_frames collapse the loop above into one call for N cut
points:
from waxcut import load_audio_stream, split_at
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)
Tagging split output
write_id3v2_tag writes a minimal ID3v2.3 tag (title/artist/track number)
onto any bytes — typically one segment of split_at's output:
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)
Streaming large files
split_at returns a list[bytes] with every segment fully materialized at
once, so even with use_mmap=True (which avoids loading the whole source
file into memory), a full split pipeline still peaks at roughly the size of
all segments held simultaneously. For a very large file, loop
frame_index_at/slice_bytes directly and write each segment as it's
produced instead of collecting them all via split_at first -- only one
segment is ever in memory at a time:
from pathlib import Path
from waxcut import load_audio_stream, frame_index_at, slice_bytes
with load_audio_stream(Path("huge_mixtape.mp3"), use_mmap=True) as stream:
cut_points = [90_000, 180_000, 270_000] # ms
indices = [0, *(frame_index_at(stream.frames, t) for t in cut_points), len(stream.frames)]
for i, (start, end) in enumerate(zip(indices, indices[1:])):
segment = slice_bytes(stream.data, stream.frames, start, end)
Path(f"part{i}.mp3").write_bytes(segment)
# segment goes out of scope here -- only one segment in memory at a time
See How It Works for why this approach is safe, and the API Reference for the full public surface.