Skip to main content

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.