Sample-accurate chapter splitting via edit lists (follow-up to #18) - #19
Conversation
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>
911ae05 to
473d0cb
Compare
|
@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:
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:
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. |
|
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:
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. |
|
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. |
|
@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. |
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>
Problem
Two related limitations of chapter splitting:
off from the true chapter time by up to one frame — ~46 ms at 22.05 kHz, ~23 ms at 44.1 kHz.
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
stssa 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:MultipartFilterBasekeeps a bounded preroll queue of frames since the last sync frame, usingexact frame positions the chunk readers now stamp on
FrameEntry.StartSample. On a parttransition it writes the queued frames first, then reports the presentation window
(
media_time= preroll + intra-frame offset, presented duration = exact chapter length).Mp4aWriter.SetEditListwrites a single-entryelst(newEdtsBox/ElstBoxinMpeg4Lib,32/64-bit entry layouts) and sets
tkhd/mvhdto the presented duration;mdhdremains thephysical media length. A source edit list is stripped from the blank moov, so re-remuxing a
part stays correct.
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:
stssfirst entryAVAudioFilereported lengthAAC-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_timecarries the formerlydropped intra-frame offset (640, 821); no
stssemitted.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+ newElstBox/EdtsBoxround-trips incl. the 64-bit layout); Libation's testsuites 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
AudioFilelayer ignores editlists for AAC-LC and applies its fixed 2112-sample priming model (verified empirically — patching
media_timeto include the priming changes nothing at that layer). LC playback start precisionthere remains within one frame, exactly as today; ffmpeg-based players and Apple's
AVAssetlayer honor the edit precisely. USAC is exact in every layer tested.
🤖 Generated with Claude Code