Skip to content

Sample-accurate chapter splitting via edit lists (follow-up to #18) - #19

Merged
Mbucari merged 5 commits into
Mbucari:masterfrom
cclements:feat/sample-accurate-splits
Jul 31, 2026
Merged

Sample-accurate chapter splitting via edit lists (follow-up to #18)#19
Mbucari merged 5 commits into
Mbucari:masterfrom
cclements:feat/sample-accurate-splits

Conversation

@cclements

Copy link
Copy Markdown
Contributor

Follow-up to #18, as discussed there — built on top of that branch, so this PR should be
reviewed/merged after it (the diff below includes #18's commits until it lands).

Problem

Two related limitations of chapter splitting:

  1. Splits are frame-quantized. The cut lands on a frame boundary, so each part's start is
    off from the true chapter time by up to one frame — ~46 ms at 22.05 kHz, ~23 ms at 44.1 kHz.
  2. USAC parts start on undecodable frames. The chapter mark almost never falls on a sync
    frame, so each xHE-AAC part opens with up to ~1 s of frames whose decoder state stayed in the
    previous part. With Write sync sample tables: derive stss for USAC (ISO/IEC 23003-3 §H.1), propagate source sync info — fixes seeking in Apple players #18's honest stss a strict decoder refuses those head seeks; before it,
    Apple players couldn't seek such parts at all.

Fix

Each part now begins at the most recent sync frame at or before the chapter boundary, and an
edit list (edts/elst) trims playback to the exact chapter window:

  • MultipartFilterBase keeps a bounded preroll queue of frames since the last sync frame, using
    exact frame positions the chunk readers now stamp on FrameEntry.StartSample. On a part
    transition it writes the queued frames first, then reports the presentation window
    (media_time = preroll + intra-frame offset, presented duration = exact chapter length).
  • Mp4aWriter.SetEditList writes a single-entry elst (new EdtsBox/ElstBox in Mpeg4Lib,
    32/64-bit entry layouts) and sets tkhd/mvhd to the presented duration; mdhd remains the
    physical media length. A source edit list is stripped from the blank moov, so re-remuxing a
    part stays correct.
  • Applies to all codecs (per the discussion in Write sync sample tables: derive stss for USAC (ISO/IEC 23003-3 §H.1), propagate source sync info — fixes seeking in Apple players #18: LC splits weren't sample-accurate
    either). For codecs where every frame is a sync frame the preroll queue holds only the
    boundary frame, so the only change is the sample-exact trim. Subclasses that don't opt in
    (e.g. mp3 output, which has no edit lists) keep today's behavior exactly.

Verification (real Audible titles, Apple decoder)

USAC, 39 h title split at its first 3 chapters:

Check Result
Part stss first entry 1 — every part now starts on a sync frame
Part-head seeks (0.0/0.3/0.7/5/60/300 s) 0 failures (was: every probe failing pre-#18; head probes failing with #18 alone)
AVAudioFile reported length exactly the presented chapter duration (edit list honored)
Presented durations chapter durations to the sample (e.g. 35,077,051 = 795.397982 s × 44100)
Cross-boundary content bit-identical to the full file (cross-correlation residual 0.0)¹

AAC-LC, 13 h title split at its first 3 chapters: segment durations equal chapter durations
to the sample (410,240 / 276,661 / 61,475 @ 22.05 kHz); media_time carries the formerly
dropped intra-frame offset (640, 821); no stss emitted.

Regression: full-file remux output is byte-for-byte unaffected (edit lists are only written for
splits); re-remuxing a part strips its edit list and regenerates a full-length file; unit tests
8/8 (StssBox + new ElstBox/EdtsBox round-trips incl. the 64-bit layout); Libation's test
suites pass against the branch.

¹ Alignment shows a constant 37-sample (0.84 ms) offset traceable to the source's irregular
stts (one 1061-tick frame amid 1024-tick frames) interacting with Apple's uniform packet-position
mapping — present in the unsplit source, inaudible, and unchanged by this PR.

Known quirk (documented, not a regression): Apple's low-level AudioFile layer ignores edit
lists for AAC-LC and applies its fixed 2112-sample priming model (verified empirically — patching
media_time to include the priming changes nothing at that layer). LC playback start precision
there remains within one frame, exactly as today; ffmpeg-based players and Apple's AVAsset
layer honor the edit precisely. USAC is exact in every layer tested.

🤖 Generated with Claude Code

cclements and others added 5 commits July 29, 2026 10:18
USAC (xHE-AAC) has only sparse independently decodable frames, so the USAC
storage spec requires a sync sample box enumerating them. Without it the
14496-12 default ("every sample is a sync sample") applies, and demuxers
that trust the sample table — notably Apple's AVAudioFile/ExtAudioFile on
iOS/macOS, per an Apple engineering response to a Feedback report — seek to
non-entry-point frames and fail with error 'bada' or silently decode audio
from the wrong position.

Mp4aWriter now records which audio samples begin with usacIndependencyFlag
set (the first bit of every USAC access unit) and, for AudioObjectType 42
sources, writes them to a new StssBox on Close. Sample 1 is always included;
re-encoded (non-USAC) outputs and AAC-LC passthrough are unchanged; a stale
source stss is stripped from the blank moov so repairing an existing file
stays idempotent.

Verified against a real Audible xHE-AAC title: Apple's decoder failed 23/31
seek probes on the unpatched output and 0/31 after the remux, with the audio
payload MD5-identical and AAC-LC output gaining no stss.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review follow-up to the USAC stss change, addressing three gaps:

1. Non-USAC sources (e.g. AC-4, whose storage spec ETSI TS 103 190-2
   Annex E also requires stss) lost their sync information: MakeBlankMoov
   stripped the source stss but Close() only rebuilt it for USAC. Sync
   status now travels with every frame: ChunkEntryList maps a source stss
   to per-chunk SyncFlags, DashChunkEntryies derives them from fragment
   sample flags (trun/tfhd, sample_is_non_sync_sample), ChunkReader stamps
   FrameEntry.IsSyncSample, and Mp4aWriter renumbers into the output's
   sample numbering - so trimmed and split outputs stay correct. For USAC
   the bitstream's usacIndependencyFlag remains the ground truth and
   overrides source tables (which are absent or wrong in the files this
   repairs).

2. The unconditional "sample 1 is sync" entry was a false claim when the
   first frame is not independent (usacIndependencyFlag clear), which is
   the normal case for chapter-split output. Measured against Apple's
   decoder, the forced entry also bought nothing: a split part whose head
   frames are dependent fails those seeks with or without it, while seeks
   at or before an unlisted region of a well-formed file succeed with
   bit-identical PCM. The entry is gone; only genuinely independent
   frames are listed.

3. Added fixture-free unit tests (stss parse/render round-trips,
   CreateBlank, and ChunkEntryList stss-to-SyncFlags mapping over a
   synthesized trak) and added the previously-orphaned Mpeg4Lib.Test
   project to the solution.

Split-part measurements (USAC, Apple decoder): without stss a split part
failed every seek probe including position 0; with the honest stss only
the sub-second dependent head fails and the body seeks cleanly. Aligning
split points to IPFs would fix the head and is left as a follow-up.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Fragmented sources can mark all frames independent (normal for AAC-LC),
which propagated into a full-enumeration stss - spec-valid but pure bloat
(~4 MB on a 13-hour book), since an absent stss already means "all samples
are sync" per ISO/IEC 14496-12. Close() now writes the box only when the
sync set is a proper subset of the samples.

Verified: a real 1,019,177-sample AAC-LC file whose source stss enumerates
every sample remuxes to output with no stss; a USAC file's sparse derived
stss (5% of samples) is unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ilter-level USAC derivation

- GetSyncFlags now follows ISO/IEC 14496-12 § 8.8.8 branch structure:
  per-sample flags (which never legally coexist with first-sample-flags),
  then first-sample-flags with the fragment default for the remainder
  (non-sync when no default is present), then the fragment default alone,
  else null with a TODO for trex support. sample_flags_present is public.
- StssBox reads its entries with the collection marshaller, matching
  SttsBox.
- The usacIndependencyFlag derivation moved out of Mp4aWriter into
  AacValidateFilter: the writer is now codec-unaware and trusts
  FrameEntry.IsSyncSample, which the chunk readers seed from source
  metadata and the audio filters correct for USAC. The filter is the
  earliest point the derivation can live: ChunkEntry is built from table
  metadata with no frame bytes, and at the chunk readers AAX/AAXC/DASH
  payloads are still encrypted - the first bit of the cleartext access
  unit only exists after AavdFilter/DashFilter decrypt, and both feed
  AacValidateFilter.

Re-verified: USAC remux emits the identical 84,446-entry stss via the
filter path (idempotent; source stss overridden by bitstream truth),
Apple's decoder passes 31/31 seek probes, all-sync AAC-LC suppression
unchanged, unit tests 5/5.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Chapter splits were quantized to frame boundaries (off by up to one frame,
~46 ms at 22.05 kHz), and for USAC each part opened with frames that are
not decodable entry points.

Each part now begins at the most recent sync frame at or before the
chapter boundary (MultipartFilterBase keeps a bounded preroll queue of
frames since the last sync frame, using exact frame positions provided by
the chunk readers via FrameEntry.StartSample), and Mp4aWriter writes an
edts/elst presenting exactly the chapter's samples: media_time skips the
preroll and any intra-frame offset, segment_duration is the chapter's
exact duration, and tkhd/mvhd carry the presented duration. A source
edit list is stripped from the blank moov so re-remuxing a part stays
correct. Applies to all codecs per review feedback; the default hooks
leave subclasses without an Mp4a writer (e.g. mp3 output) unchanged.

Measured (Apple decoder, real Audible titles): USAC part heads went from
failing every seek probe at/near zero to 0 failures, with AVAudioFile
reporting exactly the presented chapter length and cross-correlation
showing bit-identical content across the split boundary. AAC-LC part
durations are now sample-exact (segment durations equal chapter durations
to the sample; media_time carries the formerly-dropped intra-frame
offset). Known quirk, unchanged by this commit: Apple's low-level
AudioFile layer ignores edit lists for AAC-LC (it applies its fixed
2112-sample priming model), so LC playback start precision there remains
within one frame as before; ffmpeg-based players and AVAsset honor the
edit exactly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@cclements
cclements force-pushed the feat/sample-accurate-splits branch from 911ae05 to 473d0cb Compare July 30, 2026 18:31
@Mbucari

Mbucari commented Jul 30, 2026

Copy link
Copy Markdown
Owner

@cclements Can you please rebase to master? Nevermind, you already updated it.

@Mbucari

Mbucari commented Jul 31, 2026

Copy link
Copy Markdown
Owner

@cclements

This is great for multipart split file, but it doesn't handle cases where the output doesn't begin at the source's beginning. Within Libation this can happen in 2 ways:

  1. If the "Strip Audible Branding from the start and end of audiobooks" option is enabled, frames in the beginning if the source are discarded. This system needs to be changed so that the first frame dispatched to the FrameFilter is a sync frame.
  2. After this change is implemented, if a user has a multipart lossless audiobook and then converts it to MP3, the USAC multipart files will begin after the first sample (as defined in the edit list). The Mp4File input needs to read the elist and tell the chunk reader if the start time is no 0.

I've been working on a design for handling these cases, but it's going to require a lot of changes. Basically, every FrameFilter will need to know:

  1. the first sample from the source that should be included in the output, and
  2. what to do with frames before that first sample.

I'm tempted to merge this PR and then try to solve this problem later, unless you have any ideas and/or want to try tackling it.

@cclements

Copy link
Copy Markdown
Contributor Author

Hmm you're right on both, and the second one is actually an edge this PR created. I have a test showing it: re-remuxing a split part strips the stale elst (correctly) but doesn't carry the presentation window forward, so the preroll becomes audible again. Case 1 predates the PR (trimmed starts were just as undecodable before, they only fail honestly now), but it's the same missing concept either way: the source's presentation window isn't a first-class thing yet.

Unless you have a different idea I'd suggest merging this as-is. It strictly improves everything currently shipping, and the case-2 edge only bites when re-converting multipart output. If you want I can take on the follow-up, because I think it's smaller than it looks now that the primitives exist. Sketch:

  1. Mp4File reads the input's elst (presentation offset + presented duration) and maps requested times from presentation space to media space when it creates the chunk reader. That alone fixes the "convert multipart to MP3" case reading the preroll.
  2. The reader starts dispatching at the last sync frame at or before the requested start directly when sync metadata exists, or via a bounded lookback with the post-decrypt filter dropping the excess (same encryption constraint as before: the bitstream truth only exists after decrypt).
  3. Final filters apply one of two policies for pre-window frames, which I think is the concrete version of your "every FrameFilter needs to know": lossless keeps them and writes an elst (the multipart preroll queue from this PR, hoisted so the single-file path uses it too, that's the branding-strip fix); decoders (MP3/PCM) decode from the sync frame and discard output before the window, which incidentally makes MP3 trims sample-exact as well.

With 1+3, re-remuxing a part round-trips cleanly (same preroll, same window), and stripped-branding xHE output starts on a decodable frame with exact timing. The Codecs side of policy-2 would be a companion PR over there. Happy to build this as another PR if it looks sane to you or adjust whatever doesn't.

@Mbucari

Mbucari commented Jul 31, 2026

Copy link
Copy Markdown
Owner

I like your solution sketch and would love for you to continue working on this. The only caveat I want to mention is that you'll need to take into account the time text track (chapter markers). It will also need an edit list to match with the audio track.

I'll go ahead and merge this PR and make a new release.

@Mbucari
Mbucari merged commit 44f80fb into Mbucari:master Jul 31, 2026
1 check passed
@Mbucari

Mbucari commented Jul 31, 2026

Copy link
Copy Markdown
Owner

@cclements AAXClean.Codecs v3.1.0 is live (Libation uses AAXClean transitively through AAXClean.Codecs). You can go ahead and make a Libation PR.

@rmcrackan, it's good. It still has a known bug, but as @cclements said, it strictly improves everything currently shipping. I just want @cclements to make the PR for traceability and so they can better explain the changes.

rmcrackan pushed a commit to rmcrackan/Libation that referenced this pull request Aug 1, 2026
Fixes xHE-AAC (USAC) seeking in Apple players: AAXClean now writes the
sync sample table (stss) required by ISO/IEC 23003-3 for USAC output,
and chapter-split files are sample-accurate via edit lists
(Mbucari/AAXClean#18, Mbucari/AAXClean#19).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants