audiowaveform 0.4.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 31dd551b71c1465095aca4ead96165c3c1920fc2ef2825092def2eee860a9f13
4
- data.tar.gz: 3bdef833753c357a394c0d521954781b641c061037b86478c6cf8ec7eb1762d2
3
+ metadata.gz: eb87df06de7ce7961d87d2767b4c95aae8adbaec3f8b1db21bebf25a9ab4a9da
4
+ data.tar.gz: 223edeea93936a8d61b59bd55fa3bd7950eb41b76d1a9d1410ea2bdf201e045b
5
5
  SHA512:
6
- metadata.gz: 240d389339b46118b7ff7e1868ebee7c3dd35feb65361711b5b4832fc96af79450f283d979c9eb3d47eabe83a4b1f1503cdb227836374c981526f889aabdf210
7
- data.tar.gz: 35b3317544ecc55b8d8d83b6b286466776f93afa2c775d4dcd2e3d725239647e033a9bd2dad42b9f8cc2c72e25aff95b9f5614dbbd135182fb6d2358a7aaf57a
6
+ metadata.gz: 9c9323f816013d497dab718a9232d46dd830cd03ae78c6cb3d6d5e8ff5c407ffc325119e23de75da454c0e409d0e8f466726928284302a88a4ec6559b7b55b5c
7
+ data.tar.gz: 855a327090b96e12dd25620817e886da8086c0acf265a923279f3a25695970086156a104a3cdbc68a5d31c31e9087c75add7934ddd652f4103a424f510f4cf7f
data/PROVENANCE.md CHANGED
@@ -91,3 +91,20 @@ repository's synthetic tone fixtures and their known source length, with no
91
91
  copied legacy golden arrays. Raw PCM conversion/streaming was implemented from
92
92
  standard signed PCM scaling, IEEE float encodings, and Rust byte-conversion APIs;
93
93
  no legacy raw-input implementation was used or copied.
94
+
95
+ ## AAC/MP4 timing follow-up
96
+
97
+ The focused MP4 timing reader was written for this project using Apple's
98
+ [AAC priming explanation](https://developer.apple.com/documentation/quicktime-file-format/background_aac_encoding),
99
+ [edit-list format](https://developer.apple.com/documentation/quicktime-file-format/edit_list_atom),
100
+ and time-to-sample/media/movie-header field definitions. Symphonia 0.6.1's
101
+ registry source was consulted for track IDs, packet timestamps, and its current
102
+ edit-list limitation; no parser implementation was copied. No legacy core code
103
+ was read or reused for this change. New arithmetic tone/silence fixtures and
104
+ encoding commands are recorded in `tests/build_aac_fixtures.py` and
105
+ `tests/fixtures/aac/manifest.json`. Peak expectations use independent batch
106
+ arithmetic over untrimmed Symphonia output sliced at known fixture boundaries.
107
+ The compatibility follow-up adds an FFmpeg start-offset remux and newly
108
+ constructed millisecond timing tables. Expected leading silence follows Apple's
109
+ [empty-edit definition](https://developer.apple.com/documentation/quicktime-file-format/edit_list_atom/edit_list_table);
110
+ timing uses the documented [media timescale](https://developer.apple.com/documentation/quicktime-file-format/media_header_atom/time_scale).
data/README.md CHANGED
@@ -64,17 +64,18 @@ by Ruby and never run inside an unprotected native callback. See the
64
64
  ## Memory and cancellation
65
65
 
66
66
  Fixed resolution decodes once. Exact point counts also decode once for PCM/float
67
- WAV and native FLAC with an exact header frame count. The count and signal
68
- metadata are checked against the actual decoded audio. Missing/unsupported
69
- metadata uses a counting pass and replay of the same open file. If a header
70
- count disagrees, provisional peaks are discarded and the completed first pass
71
- supplies the actual count for replay: at most two decoding passes. Decoder
72
- errors, checksum failures, and truncation remain errors.
73
-
74
- This optimization never uses duration estimates and preserves the same peak
75
- boundaries and values. AAC/M4A, MP3, Ogg/WebM and other formats still use the
76
- two-pass path for exact points. Empty audio does not allocate the requested
77
- point count. Keep input files unchanged during generation; replay mismatches
67
+ WAV and native FLAC with an exact header frame count, and for nonfragmented
68
+ AAC/MP4 with parsed playback bounds. The count and signal metadata are checked
69
+ against the actual playback frames. Missing/unsupported metadata uses a counting
70
+ pass and replay of the same open file. If a count disagrees (including an AAC
71
+ media end rounded past decoded EOF), provisional peaks are discarded and the
72
+ completed first pass supplies the actual count for replay: at most two decoding
73
+ passes. Decoder errors, checksum failures, and truncation remain errors.
74
+
75
+ This optimization preserves the same peak boundaries and values. Raw ADTS AAC,
76
+ fragmented MP4, MP3, Ogg/WebM and other formats still use the two-pass path for
77
+ exact points. Empty audio does not allocate the requested point count. Keep
78
+ input files unchanged during generation; replay mismatches
78
79
  in frame count, track, rate, or channel count are errors.
79
80
 
80
81
  A decoded-block scratch buffer and channel extrema are reused. Exact output
@@ -104,8 +105,22 @@ AAC-LC decoding targets mono/stereo. Multichannel AAC, HE-AAC, Opus, and Wave64
104
105
  are outside this revision. Codec availability is separate from container
105
106
  recognition. Decoder gapless trimming is enabled, removing delay/padding where
106
107
  Symphonia supplies it (including MP3 and Vorbis priming). Duration counts the
107
- frames actually delivered after trimming. AAC/MP4 edit-list trimming is still
108
- limited by the decoder, so AAC duration can differ from playback.
108
+ playback frames retained after trimming, including silence from supported
109
+ leading empty MP4 edits.
110
+
111
+ For nonfragmented AAC/MP4, the core reads the selected track's edit list and
112
+ sample timing table. It decodes priming packets normally, then excludes frames
113
+ outside the playback range before counting or aggregating peaks in either pass.
114
+ This supports leading empty edits followed by a single contiguous media edit
115
+ at normal speed. Empty edits contribute silence on the playback timeline;
116
+ genuine recorded silence is also preserved. Edit duration uses the movie
117
+ timescale, and packet timestamps may be rounded by less than one media-clock
118
+ tick. The sample table and available decoded frames cap the end. Coarse
119
+ metadata can still round the reported playback duration.
120
+ No edit means no assumed leading delay. Fragmented MP4 and iTunSMPB-only delay
121
+ metadata are not covered; their duration can still differ from playback.
122
+ Multiple media edits, empty edits after media, and non-unit playback rates
123
+ return an error; decode those inputs to PCM externally and use `generate_pcm`.
109
124
 
110
125
  Track selection uses explicit default-audio flags exposed by Symphonia, then
111
126
  the first reported audio track. Unsupported selected audio is an error.
data/VALIDATION.md CHANGED
@@ -199,3 +199,83 @@ The Ruby 0.4.0 version bump passes 72 Ruby tests / 1,393 assertions and RBS
199
199
  validation. The built `audiowaveform-0.4.0.gem` installs into an isolated gem
200
200
  directory and passes the contract vectors, codec matrix, PCM streaming, and
201
201
  MP3 gapless checks. Both unpublished Rust packages remain at 0.1.0.
202
+
203
+ ## AAC/MP4 playback trimming (unreleased)
204
+
205
+ Local all-feature, no-default-feature, and M4A-only Rust tests pass, along with
206
+ formatting and strict all-target Clippy for both workspaces. Ten parser tests
207
+ cover edit-list/header versions 0/1, different movie/media/sample clocks, selected
208
+ track IDs, partial packet boundaries, malformed/truncated atoms and timing
209
+ tables, unsupported edits, overflow, cancellation, and shared-file cursor
210
+ restoration. Nine integration tests decode newly generated AAC fixtures and
211
+ compare peaks with independent batch arithmetic over the known playback slice.
212
+
213
+ The 44.1 kHz 50 ms fixture decodes to 4,096 raw frames but retains exactly 2,205
214
+ frames and 110 points. Other cases cover 32/48/88.2/96 kHz, one-frame and odd-length
215
+ clips, mono/stereo, fixed/normalized gain, direct 8-bit output, true silence and
216
+ silent edges, changed edit offsets (including 2,112), empty playback, absent
217
+ edits, coarse movie-clock rounding, video before multiple audio tracks,
218
+ impossible sample-table lengths, and the documented fragmented-MP4 fallback.
219
+ Exact points and fixed-size buckets both use the same trimmed frame range.
220
+
221
+ The metadata reader uses fixed-size stack buffers and seeks past media; it
222
+ retains neither packet tables nor decoded audio. A manual comparison of 50 ms
223
+ and 30-second mono AAC at 44.1 kHz, each producing 110 points, reports identical
224
+ application capacities: 8,192 bytes of sample scratch and 456 bytes of
225
+ peak/current storage. Decoder/container allocations are outside these figures.
226
+
227
+ Ruby 4.0.5 passes 73 tests / 1,689 assertions and RBS validation. An isolated
228
+ source-gem installation passes the numeric/codec/PCM checks plus an AAC check
229
+ requiring a 50 ms duration and 110 points at 44.1, 88.2, and 96 kHz. This is a
230
+ local development build of the unchanged 0.4.0 package version, not a published
231
+ release.
232
+
233
+ The compatibility follow-up covers leading empty edits (including versions 0/1
234
+ and summed movie-clock durations), coarse media timestamps with and without an
235
+ edit, and a final media timestamp rounded beyond the decoded sample count.
236
+ An FFmpeg start-offset remux now yields a 150 ms waveform including its leading
237
+ silence. Both passes use that same timeline. Millisecond-clock fixtures retain
238
+ the frames inside their declared ranges without failing exact-time equality;
239
+ rounding past decoded EOF is clamped, while full-tick jumps, cumulative drift,
240
+ and larger shortages still fail. Source-gem smoke tests exercise both fixes.
241
+
242
+ Increasing a leading empty edit to ten seconds preserves application buffer
243
+ capacities with fixed and normalized gain at 110 points. A much larger empty
244
+ edit is cancelled during generation in both resolution modes, without building
245
+ a whole-gap sample buffer. The original 50 ms regression remains covered.
246
+
247
+ Playback bounds use the initialized AAC decoder's sample rate from
248
+ AudioSpecificConfig, which can differ from the container sample-entry rate.
249
+ The 88.2 and 96 kHz fixtures failed with `AAC/MP4 sample rate changed` before
250
+ this correction. They now retain exactly 4,410 and 4,800 frames, respectively,
251
+ with peaks checked in both resolution modes and installed-gem coverage.
252
+
253
+ Exact points now use those parsed bounds as a verified frame-count hint. The
254
+ AAC fixture matrix checks one decoding pass, including 88.2/96 kHz, leading
255
+ gaps, empty playback, and selected tracks following video, with unchanged peaks.
256
+ The rounded-media-end fixture predicts 4,102 frames but actually supplies 4,096;
257
+ it still uses two passes and matches the independent oracle with fixed and
258
+ normalized gain. Fragmented MP4 also retains two passes. A truncated final AAC
259
+ packet fails even when all requested playback frames (or an empty range) were
260
+ already delivered. Existing buffer-capacity and cancellation checks still pass.
261
+
262
+ Release-build comparison against `193f789` on an arm64 Mac, 48 kHz AAC at
263
+ 128 kbit/s and 110 exact points, with warm caches and alternating before/after
264
+ processes. Values are median library wall times from five paired trials (three
265
+ for 30 minutes), excluding a warmup generation in each process:
266
+
267
+ | Input | Two passes | Verified one pass |
268
+ | --- | ---: | ---: |
269
+ | 50 ms mono | 0.419 ms | 0.209 ms |
270
+ | 30 seconds mono | 28.69 ms | 17.79 ms |
271
+ | 5 minutes mono | 309.93 ms | 173.28 ms |
272
+ | 30 minutes mono | 1,741.00 ms | 1,069.14 ms |
273
+ | 5 minutes stereo, mixed to mono | 413.75 ms | 242.39 ms |
274
+ | 5 minutes mono, normalized | 283.62 ms | 173.16 ms |
275
+
276
+ Longer inputs took 38–44% less time in these local measurements. Fixed buckets
277
+ remain one-pass and measured 176.43 vs. 174.75 ms for five minutes. Mono scratch
278
+ and peak/current capacities remain 8,192 and 456 bytes; normalized peaks use
279
+ 2,216 bytes and stereo scratch uses 16,384 bytes. Maximum measured process RSS
280
+ was about 3.9 MiB. These timings are host-specific, not isolated-system results
281
+ or a constant-memory guarantee for decoder/container internals.
@@ -1,5 +1,23 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.1 (2026-09-22)
4
+
5
+ - Generate exact `points:` for nonfragmented AAC/MP4 in one decoding pass using
6
+ parsed playback bounds and the decoder's resolved signal parameters. Verify
7
+ the actual count before returning peaks; rounded endpoints that overstate it
8
+ replay with the observed count. Fragmented MP4 and raw ADTS retain two passes.
9
+ - Trim AAC/MP4 priming and padding from the selected track's edit list and
10
+ sample timing before counting frames or generating peaks. A 50 ms clip now
11
+ produces a 50 ms waveform with either exact points or fixed-size buckets.
12
+ Use the decoder's resolved sample rate, including 88.2 and 96 kHz AAC.
13
+ Keep streaming buffers and genuine silence; do not guess encoder delay.
14
+ - Support leading empty edits followed by one contiguous, normal-speed media
15
+ edit in nonfragmented MP4. Render the leading gap as silence with bounded
16
+ buffers. Accept media-clock timestamp rounding without tolerating cumulative
17
+ drift or padding past the available decoded samples. Other complex edits
18
+ return an error; fragmented MP4 and iTunSMPB-only gapless metadata remain
19
+ outside this support.
20
+
3
21
  ## 0.4.0 (2026-09-21)
4
22
 
5
23
  - Generate exact `points:` in one decoding pass for PCM/float WAV and native
@@ -27,10 +27,12 @@ waveform.duration # actual decoded seconds
27
27
 
28
28
  At most one resolution option may be non-nil. Each resolution is an Integer no
29
29
  larger than 4,294,967,295. Exact counts use one decoding pass for PCM/float WAV
30
- and native FLAC with an exact header frame count; the decoded count is verified.
31
- Other inputs use two passes on the same open file without requiring duration
32
- metadata. A mismatched header count discards provisional peaks and replays once
33
- using the count from that first pass. Decoding/corruption errors still fail.
30
+ and native FLAC with an exact header frame count, and nonfragmented AAC/MP4
31
+ with parsed playback bounds. The actual playback count is verified. Other inputs
32
+ use two passes on the same open file without requiring duration metadata.
33
+ A mismatched count, including AAC timestamp rounding beyond decoded EOF,
34
+ discards provisional peaks and replays once using the count from that first
35
+ pass. Decoding/corruption errors still fail.
34
36
  Results are unchanged, and no new keyword is required. Keep the file unchanged
35
37
  during the operation. This method accepts regular seekable files. Use `generate_pcm` below
36
38
  for raw PCM streams. Neither method fetches URLs.
@@ -104,7 +106,8 @@ at EOF and nonfinite float samples raise `AudioWaveform::Error`.
104
106
  - `sample_rate`, `channels`, `length` (`size`), `empty?`
105
107
  - `samples_per_pixel`: nominal frames per point. For exact-count timing use
106
108
  `duration / length`; bucket widths may differ by one source frame.
107
- - `duration` (`duration_seconds`): decoded frames divided by sample rate.
109
+ - `duration` (`duration_seconds`): playback frames divided by sample rate,
110
+ including silence from supported leading empty MP4 edits.
108
111
  - `storage_bits` (`bits`): always 16.
109
112
  - `data(bits: 16)`: a new Array of signed integers, ordered by point, channel,
110
113
  then minimum/maximum. `bits: 8` converts with signed truncation by 256.
@@ -123,9 +126,15 @@ AAC/M4A, ALAC, MP1/MP2/MP3, AIFF, CAF, and Matroska/WebM audio codecs. Container
123
126
  recognition does not imply every codec is supported. Multichannel AAC, HE-AAC,
124
127
  Opus, and Wave64 are unsupported by file decoding; a caller-managed decoder can
125
128
  feed their PCM into `generate_pcm`. Decoder gapless trimming removes reported
126
- delay/padding, including MP3 and Vorbis priming. AAC/MP4 trimming remains limited
127
- by Symphonia, so decoded duration can differ from playback. See the root README
128
- for track-selection limits.
129
+ delay/padding, including MP3 and Vorbis priming. Nonfragmented AAC/MP4 also trims
130
+ to the selected track's single, normal-speed media edit and sample timing range
131
+ in both passes. Leading empty edits contribute silence before the audio;
132
+ intentional recorded silence is preserved. Rounded media timestamps are
133
+ supported, though coarse metadata may still round duration slightly.
134
+ Fragmented MP4 and iTunSMPB-only delay metadata remain unsupported for gapless
135
+ trimming. Multiple media edits, empty edits after media, or non-unit playback
136
+ rates raise `AudioWaveform::Error`; use external decoding and `generate_pcm`
137
+ for those inputs. See the root README for track-selection limits.
129
138
 
130
139
  Version 0.3 removes exports and tightens argument coercion. PCM quantization and
131
140
  fixed-resolution duration also change; do not expect byte-identical 0.2 peaks.
@@ -145,7 +154,7 @@ ruby bindings/ruby/script/test_native_gem.rb PATH_TO_NATIVE_GEM
145
154
 
146
155
  The normal rake task compiles, runs contract/codec/GC/cancellation tests, and
147
156
  validates RBS. Source installation is checked with
148
- `ruby bindings/ruby/script/test_source_gem.rb pkg/audiowaveform-0.4.0.gem`.
157
+ `ruby bindings/ruby/script/test_source_gem.rb pkg/audiowaveform-0.4.1.gem`.
149
158
  Native cross-builds and ABI checks are configured in `.github/workflows`.
150
159
 
151
160
  Release tags use `ruby-vX.Y.Z`. The release workflow verifies the tag against
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AudioWaveform
4
- VERSION = "0.4.0"
4
+ VERSION = "0.4.1"
5
5
  end
data/contract/SPEC.md CHANGED
@@ -1,6 +1,6 @@
1
- # Audio peak generation contract, revision 2
1
+ # Audio peak generation contract, revision 3
2
2
 
3
- Updated for Ruby 0.4.0 with gapless trimming, raw PCM, and exact-count metadata. Normative
3
+ Updated after Ruby 0.4.0 with AAC/MP4 playback trimming. Normative
4
4
  requirements below apply to the library. No BBC command-line, file-format,
5
5
  rendering, or byte-for-byte implementation compatibility is required.
6
6
 
@@ -11,8 +11,9 @@ PCM stream and returns waveform peaks in memory. A frame is one time instant con
11
11
  channel. A point is a minimum/maximum pair for each output channel. Resolution
12
12
  arguments count **frames**, despite the historical `samples_per_pixel` name.
13
13
 
14
- Let `N` be the actual number of decoded frames, `F` the positive sample rate,
15
- and `C` the output channel count. Container duration estimates must not determine
14
+ Let `N` be the number of playback frames (retained decoded frames plus silence
15
+ from supported leading empty edits), `F` the positive sample rate,
16
+ and `C` the output channel count. General container duration estimates must not determine
16
17
  `N`, point boundaries, or the returned duration. Channel count and sample rate
17
18
  must remain constant throughout the selected track; otherwise return an error.
18
19
 
@@ -28,12 +29,34 @@ This is an acceptance target, not a claim of completed decoder coverage.
28
29
  Codec availability is distinct from container recognition. Multichannel AAC,
29
30
  HE-AAC, Opus, and Wave64 are not required for the first replacement.
30
31
 
31
- Enable decoder-provided gapless trimming. `N` counts frames actually delivered
32
- by the decoder after trimming; count and aggregation passes use identical
32
+ Enable decoder-provided gapless trimming. Count and aggregation passes use identical
33
33
  settings. Do not manually subtract delay a second time. MP3 delay/padding and
34
34
  Vorbis priming must not create extra leading buckets. Available trimming depends
35
- on the codec/container metadata exposed by Symphonia; AAC/MP4 edit-list handling
36
- remains a documented limitation. Raw PCM receives no automatic trimming.
35
+ on the codec/container metadata exposed by Symphonia, with this additional rule:
36
+ nonfragmented AAC/MP4 uses the selected track's media timescale, time-to-sample
37
+ table, and optional single unit-rate media edit, preceded by zero or more empty
38
+ edits. Decode all packets, including priming,
39
+ but retain only frames whose timestamps lie in that half-open playback range,
40
+ capped at the sample table's end. Apply the same range in each pass. Never infer
41
+ delay from silence or a fixed encoder-specific constant. Missing edits mean no
42
+ assumed encoder delay. Sum leading empty-edit durations in movie ticks before
43
+ converting to frames (rounding up), and prepend that many zero frames using
44
+ bounded buffers. Those frames are part of `N`, duration, and bucket placement.
45
+ Allow absolute packet timestamps to differ from decoded time by less than one
46
+ media-clock tick, without accumulating that allowance per packet. A media end
47
+ rounded beyond decoded audio by less than one tick is clamped to the actual
48
+ end; larger shortages remain errors. Sample-rate clocks retain exact checks.
49
+ Reject multiple media edits, empty edits after media, dwell/non-unit rates, and
50
+ inconsistent packet timing. Fragmented MP4 retains the decoder timeline; iTunSMPB-only delay
51
+ metadata is not interpreted. Raw PCM receives no automatic trimming.
52
+
53
+ For exact points, use parsed nonfragmented AAC/MP4 bounds to generate provisional
54
+ peaks in one pass with `N = end - start + leading_frames`. Use the initialized
55
+ decoder's sample rate and channel layout. Verify the observed playback count
56
+ before returning those peaks. If a rounded endpoint overstates the count,
57
+ discard provisional peaks and replay using the observed count; retain all
58
+ decoding and timing checks, including after the playback range ends. Fragmented
59
+ MP4 and raw ADTS AAC keep the two-pass path.
37
60
 
38
61
  ## 2. Ruby generation API
39
62
 
data/src/decode.rs CHANGED
@@ -8,8 +8,8 @@ use symphonia::core::{
8
8
  codecs::audio::{
9
9
  AudioDecoder, AudioDecoderOptions,
10
10
  well_known::{
11
- CODEC_ID_FLAC, CODEC_ID_PCM_F32LE, CODEC_ID_PCM_F64LE, CODEC_ID_PCM_S16LE,
12
- CODEC_ID_PCM_S24LE, CODEC_ID_PCM_S32LE, CODEC_ID_PCM_U8,
11
+ CODEC_ID_AAC, CODEC_ID_FLAC, CODEC_ID_PCM_F32LE, CODEC_ID_PCM_F64LE,
12
+ CODEC_ID_PCM_S16LE, CODEC_ID_PCM_S24LE, CODEC_ID_PCM_S32LE, CODEC_ID_PCM_U8,
13
13
  },
14
14
  },
15
15
  formats::{FormatReader, TrackFlags, TrackType, probe::Hint},
@@ -52,16 +52,18 @@ fn generate_inner(
52
52
  #[cfg(feature = "wav")]
53
53
  crate::wave_header::validate(&mut file, &mut cancelled)?;
54
54
  file.seek(SeekFrom::Start(0))?;
55
+ let metadata = file.try_clone()?;
55
56
  let stream = MediaSourceStream::new(Box::new(file), Default::default());
56
57
  let mut hint = Hint::new();
57
58
  if let Some(extension) = path.extension().and_then(|ext| ext.to_str()) {
58
59
  hint.with_extension(extension);
59
60
  }
60
- let session = Session::open(stream, &hint)?;
61
+ let mut source = Source { hint, metadata };
62
+ let session = Session::open(stream, &mut source, &mut cancelled)?;
61
63
  let mut scratch = Vec::new();
62
64
  let (reducer, decode_passes) = match options.resolution {
63
65
  Resolution::Points(_) => {
64
- reduce_points(session, options, &hint, &mut scratch, &mut cancelled)?
66
+ reduce_points(session, options, &mut source, &mut scratch, &mut cancelled)?
65
67
  }
66
68
  _ => (
67
69
  reduce_fixed(session, options, &mut scratch, &mut cancelled)?,
@@ -97,12 +99,12 @@ fn reduce_fixed(
97
99
  fn reduce_points(
98
100
  session: Session,
99
101
  options: Options,
100
- hint: &Hint,
102
+ source: &mut Source,
101
103
  scratch: &mut Vec<f64>,
102
104
  cancelled: &mut impl FnMut() -> bool,
103
105
  ) -> Result<(Reducer, u8), Error> {
104
106
  let mut provisional = session
105
- .exact_frame_count()
107
+ .frame_count_hint()
106
108
  .map(|expected| ProvisionalPoints {
107
109
  expected,
108
110
  reducer: None,
@@ -133,7 +135,7 @@ fn reduce_points(
133
135
  if counted.frames == 0 {
134
136
  return Ok((reducer, 1));
135
137
  }
136
- replay(stream, hint, counted, &mut reducer, scratch, cancelled)?;
138
+ replay(stream, source, counted, &mut reducer, scratch, cancelled)?;
137
139
  Ok((reducer, 2))
138
140
  }
139
141
 
@@ -164,7 +166,7 @@ impl ProvisionalPoints {
164
166
 
165
167
  fn replay(
166
168
  mut stream: MediaSourceStream<'static>,
167
- hint: &Hint,
169
+ source: &mut Source,
168
170
  counted: Scan,
169
171
  reducer: &mut Reducer,
170
172
  scratch: &mut Vec<f64>,
@@ -172,7 +174,7 @@ fn replay(
172
174
  ) -> Result<(), Error> {
173
175
  poll(cancelled)?;
174
176
  stream.seek(SeekFrom::Start(0))?;
175
- let session = Session::open(stream, hint)?;
177
+ let session = Session::open(stream, source, cancelled)?;
176
178
  let (_, generated) = session.scan(scratch, cancelled, |progress, samples, cancel| {
177
179
  if progress.signal != counted.signal {
178
180
  return Err(Error::InputChanged);
@@ -192,16 +194,61 @@ struct Scan {
192
194
  track: u32,
193
195
  }
194
196
 
197
+ fn resize_scratch(scratch: &mut Vec<f64>, size: usize) -> Result<(), Error> {
198
+ if size > scratch.len() {
199
+ scratch.try_reserve(size - scratch.len())?;
200
+ }
201
+ scratch.resize(size, 0.0);
202
+ Ok(())
203
+ }
204
+
205
+ /// Advance the playback timeline through empty edits using the same bounded
206
+ /// buffer as decoded audio, including during the counting pass and replay.
207
+ fn consume_silence<C: FnMut() -> bool>(
208
+ mut progress: Scan,
209
+ remaining: &mut u64,
210
+ scratch: &mut Vec<f64>,
211
+ cancelled: &mut C,
212
+ consume: &mut impl FnMut(Scan, &[f64], &mut C) -> Result<(), Error>,
213
+ ) -> Result<u64, Error> {
214
+ while *remaining > 0 {
215
+ poll(cancelled)?;
216
+ let count = (*remaining).min(1024) as usize;
217
+ let size = count
218
+ .checked_mul(progress.signal.channels)
219
+ .ok_or(Error::SizeOverflow)?;
220
+ resize_scratch(scratch, size)?;
221
+ scratch.fill(0.0);
222
+ progress.frames = progress
223
+ .frames
224
+ .checked_add(count as u64)
225
+ .ok_or(Error::SizeOverflow)?;
226
+ consume(progress, scratch, cancelled)?;
227
+ *remaining -= count as u64;
228
+ }
229
+ Ok(progress.frames)
230
+ }
231
+
232
+ struct Source {
233
+ hint: Hint,
234
+ metadata: File,
235
+ }
236
+
195
237
  struct Session {
238
+ playback: Option<crate::mp4::Playback>,
196
239
  format: Box<dyn FormatReader>,
197
240
  decoder: Box<dyn AudioDecoder>,
198
241
  track: u32,
199
242
  }
200
243
 
201
244
  impl Session {
202
- fn open(stream: MediaSourceStream<'static>, hint: &Hint) -> Result<Self, Error> {
245
+ fn open(
246
+ stream: MediaSourceStream<'static>,
247
+ source: &mut Source,
248
+ cancelled: &mut impl FnMut() -> bool,
249
+ ) -> Result<Self, Error> {
203
250
  let format = symphonia::default::get_probe().probe(
204
- hint,
251
+ &source.hint,
205
252
  stream,
206
253
  Default::default(),
207
254
  Default::default(),
@@ -227,23 +274,57 @@ impl Session {
227
274
  .as_ref()
228
275
  .and_then(|params| params.audio())
229
276
  .ok_or(Error::InvalidAudio("missing audio codec parameters"))?;
277
+ let aac_mp4 = format.format_info().short_name == "isomp4" && params.codec == CODEC_ID_AAC;
230
278
  let decoder = symphonia::default::get_codecs().make_audio_decoder(
231
279
  params,
232
- &AudioDecoderOptions::default().gapless(true).verify(true),
280
+ // This layer owns AAC/MP4 trimming, including the untrimmed fallback
281
+ // for fragmented files. Other codecs keep the decoder's gapless mode.
282
+ &AudioDecoderOptions::default()
283
+ .gapless(!aac_mp4)
284
+ .verify(true),
233
285
  )?;
286
+ let playback = if aac_mp4 {
287
+ crate::mp4::playback(
288
+ &mut source.metadata,
289
+ track.id,
290
+ // AudioSpecificConfig can override the container sample-entry
291
+ // rate, notably above the 16.16 field's 65,535 Hz limit.
292
+ decoder
293
+ .codec_params()
294
+ .sample_rate
295
+ .filter(|rate| *rate > 0)
296
+ .ok_or(Error::InvalidAudio("AAC/MP4 has no sample rate"))?,
297
+ cancelled,
298
+ )?
299
+ } else {
300
+ None
301
+ };
234
302
  let track = track.id;
235
303
  Ok(Self {
304
+ playback,
236
305
  format,
237
306
  decoder,
238
307
  track,
239
308
  })
240
309
  }
241
310
 
242
- /// Only native FLAC STREAMINFO and WAV PCM data extents have been audited
243
- /// as exact frame counts here. Container durations, ADPCM block counts,
244
- /// and counts affected by lossy codec delay/padding are not trusted.
245
- /// Even these hints are checked against all frames actually decoded.
246
- fn exact_frame_count(&self) -> Option<Scan> {
311
+ /// Audited counts from WAV PCM extents, native FLAC STREAMINFO, or parsed
312
+ /// AAC/MP4 playback bounds. Every hint is checked against actual playback
313
+ /// frames before returning peaks; other container durations are not used.
314
+ fn frame_count_hint(&self) -> Option<Scan> {
315
+ if let Some(playback) = self.playback {
316
+ // Use the decoder's resolved channel layout, just as playback
317
+ // bounds use its sample rate instead of the container entry's.
318
+ let channels = self.decoder.codec_params().channels.as_ref()?.count();
319
+ return (channels > 0).then_some(Scan {
320
+ signal: Signal {
321
+ rate: playback.rate,
322
+ channels,
323
+ },
324
+ frames: playback.frame_count(),
325
+ track: self.track,
326
+ });
327
+ }
247
328
  let track = self
248
329
  .format
249
330
  .tracks()
@@ -285,6 +366,8 @@ impl Session {
285
366
  ) -> Result<(MediaSourceStream<'static>, Scan), Error> {
286
367
  let mut observed = None;
287
368
  let mut frames = 0_u64;
369
+ let mut decoded_frames = 0_u64;
370
+ let mut leading = self.playback.map_or(0, |playback| playback.leading_frames);
288
371
  let mut demux_error = None;
289
372
  loop {
290
373
  poll(cancelled)?;
@@ -314,17 +397,40 @@ impl Session {
314
397
  return Err(Error::InvalidAudio("sample rate or channel count changed"));
315
398
  }
316
399
  observed = Some(signal);
400
+ let range = match self.playback {
401
+ Some(playback) => {
402
+ if signal.rate != playback.rate {
403
+ return Err(Error::InvalidAudio("AAC/MP4 sample rate changed"));
404
+ }
405
+ playback.slice(decoded_frames, decoded.frames(), packet.pts.get())?
406
+ }
407
+ None => 0..decoded.frames(),
408
+ };
409
+ decoded_frames = decoded_frames
410
+ .checked_add(u64::try_from(decoded.frames()).map_err(|_| Error::SizeOverflow)?)
411
+ .ok_or(Error::SizeOverflow)?;
412
+ frames = consume_silence(
413
+ Scan {
414
+ signal,
415
+ frames,
416
+ track: self.track,
417
+ },
418
+ &mut leading,
419
+ scratch,
420
+ cancelled,
421
+ &mut consume,
422
+ )?;
423
+ if range.is_empty() {
424
+ continue;
425
+ }
317
426
  let size = decoded
318
427
  .frames()
319
428
  .checked_mul(signal.channels)
320
429
  .ok_or(Error::SizeOverflow)?;
321
- if size > scratch.len() {
322
- scratch.try_reserve(size - scratch.len())?;
323
- }
324
- scratch.resize(size, 0.0);
430
+ resize_scratch(scratch, size)?;
325
431
  decoded.copy_to_slice_interleaved(scratch.as_mut_slice());
326
432
  frames = frames
327
- .checked_add(u64::try_from(decoded.frames()).map_err(|_| Error::SizeOverflow)?)
433
+ .checked_add(u64::try_from(range.len()).map_err(|_| Error::SizeOverflow)?)
328
434
  .ok_or(Error::SizeOverflow)?;
329
435
  consume(
330
436
  Scan {
@@ -332,7 +438,7 @@ impl Session {
332
438
  frames,
333
439
  track: self.track,
334
440
  },
335
- scratch,
441
+ &scratch[range.start * signal.channels..range.end * signal.channels],
336
442
  cancelled,
337
443
  )?;
338
444
  }
@@ -374,6 +480,22 @@ impl Session {
374
480
  Signal { rate, channels }
375
481
  }
376
482
  };
483
+ // Empty media may still have a declared leading gap and valid signal
484
+ // metadata; emit that timeline even when no decoder block was returned.
485
+ frames = consume_silence(
486
+ Scan {
487
+ signal,
488
+ frames,
489
+ track: self.track,
490
+ },
491
+ &mut leading,
492
+ scratch,
493
+ cancelled,
494
+ &mut consume,
495
+ )?;
496
+ if let Some(playback) = self.playback {
497
+ playback.verify(decoded_frames, frames)?;
498
+ }
377
499
  Ok((
378
500
  stream,
379
501
  Scan {
data/src/lib.rs CHANGED
@@ -17,6 +17,7 @@ mod decode;
17
17
  mod error;
18
18
  #[cfg(feature = "mkv")]
19
19
  mod matroska;
20
+ mod mp4;
20
21
  mod options;
21
22
  mod pcm;
22
23
  mod reduce;
data/src/mp4/tests.rs ADDED
@@ -0,0 +1,311 @@
1
+ use super::*;
2
+ use std::io::{Cursor, Write};
3
+
4
+ fn atom(kind: &[u8; 4], data: &[u8]) -> Vec<u8> {
5
+ let mut result = ((data.len() + 8) as u32).to_be_bytes().to_vec();
6
+ result.extend(kind);
7
+ result.extend(data);
8
+ result
9
+ }
10
+
11
+ fn header(kind: &[u8; 4], version: u8, value: u32) -> Vec<u8> {
12
+ let mut data = vec![0; if version == 0 { 12 } else { 20 }];
13
+ data[0] = version;
14
+ data.extend(value.to_be_bytes());
15
+ atom(kind, &data)
16
+ }
17
+
18
+ fn edit(version: u8, entries: &[(u64, i64, u32)]) -> Vec<u8> {
19
+ let mut data = vec![version, 0, 0, 0];
20
+ data.extend((entries.len() as u32).to_be_bytes());
21
+ for &(duration, start, rate) in entries {
22
+ if version == 0 {
23
+ data.extend((duration as u32).to_be_bytes());
24
+ data.extend((start as i32).to_be_bytes());
25
+ } else {
26
+ data.extend(duration.to_be_bytes());
27
+ data.extend(start.to_be_bytes());
28
+ }
29
+ data.extend(rate.to_be_bytes());
30
+ }
31
+ atom(b"edts", &atom(b"elst", &data))
32
+ }
33
+
34
+ fn track(id: u32, version: u8, scale: u32, timing: &[(u32, u32)], edits: &[u8]) -> Vec<u8> {
35
+ let mut times = vec![0; 4];
36
+ times.extend((timing.len() as u32).to_be_bytes());
37
+ for (count, delta) in timing {
38
+ times.extend(count.to_be_bytes());
39
+ times.extend(delta.to_be_bytes());
40
+ }
41
+ let mut media = header(b"mdhd", version, scale);
42
+ media.extend(atom(b"minf", &atom(b"stbl", &atom(b"stts", &times))));
43
+ let mut data = header(b"tkhd", version, id);
44
+ data.extend(atom(b"mdia", &media));
45
+ data.extend(edits);
46
+ atom(b"trak", &data)
47
+ }
48
+
49
+ fn movie(version: u8, scale: u32, tracks: &[u8]) -> Vec<u8> {
50
+ let mut data = header(b"mvhd", version, scale);
51
+ data.extend(tracks);
52
+ atom(b"moov", &data)
53
+ }
54
+
55
+ fn parse(bytes: &[u8], id: u32, rate: u32) -> Result<Playback, Error> {
56
+ Parser {
57
+ input: &mut Cursor::new(bytes),
58
+ cancelled: &mut || false,
59
+ }
60
+ .playback(bytes.len() as u64, id, rate)
61
+ .map(Option::unwrap)
62
+ }
63
+
64
+ #[test]
65
+ fn versions_clocks_and_selected_track() {
66
+ for version in [0, 1] {
67
+ // An unrelated track has an unsupported edit. Only id 7 applies.
68
+ let mut tracks = track(
69
+ 3,
70
+ version,
71
+ 48000,
72
+ &[(1, 500)],
73
+ &edit(version, &[(20, -1, 65536)]),
74
+ );
75
+ tracks.extend(track(
76
+ 7,
77
+ version,
78
+ 88200,
79
+ &[(3, 2048), (1, 314)],
80
+ &edit(version, &[(51, 2048, 65536)]),
81
+ ));
82
+ let bounds = parse(&movie(version, 1000, &tracks), 7, 44100).unwrap();
83
+ assert_eq!(
84
+ (bounds.start, bounds.end, bounds.minimum_decoded_frames),
85
+ (1024, 3229, 3229)
86
+ );
87
+ assert_eq!(bounds.slice(0, 1024, 0).unwrap(), 1024..1024);
88
+ assert_eq!(bounds.slice(1024, 1024, 2048).unwrap(), 0..1024);
89
+ assert_eq!(bounds.slice(3072, 1024, 6144).unwrap(), 0..157);
90
+ assert_eq!(bounds.slice(4096, 1024, 8192).unwrap(), 0..0);
91
+ assert!(bounds.slice(1024, 1024, 1024).is_err());
92
+ assert!(bounds.slice(0, 1024, -1).is_err());
93
+ assert!(bounds.verify(4096, 2205).is_ok());
94
+ assert!(bounds.verify(3072, 2205).is_err());
95
+ assert!(bounds.verify(4096, 2204).is_err());
96
+ }
97
+ }
98
+
99
+ #[test]
100
+ fn edit_end_is_added_before_rounding_and_can_cut_within_a_packet() {
101
+ let bytes = movie(
102
+ 0,
103
+ 10,
104
+ &track(1, 0, 3, &[(3, 3)], &edit(0, &[(1, 1, 65536)])),
105
+ );
106
+ let bounds = parse(&bytes, 1, 10).unwrap();
107
+ assert_eq!((bounds.start, bounds.end), (4, 5)); // ceil(10/3), ceil(10/3 + 1)
108
+ assert_eq!(bounds.slice(0, 10, 0).unwrap(), 4..5);
109
+ }
110
+
111
+ #[test]
112
+ fn absent_and_empty_edit_use_sample_table_duration() {
113
+ for edits in [vec![], edit(0, &[])] {
114
+ let bytes = movie(
115
+ 0,
116
+ 44100,
117
+ &track(1, 0, 44100, &[(3, 1024), (1, 157)], &edits),
118
+ );
119
+ let bounds = parse(&bytes, 1, 44100).unwrap();
120
+ assert_eq!((bounds.start, bounds.end), (0, 3229));
121
+ }
122
+ }
123
+
124
+ #[test]
125
+ fn complex_edits_and_ranges_beyond_media_are_rejected() {
126
+ for version in [0, 1] {
127
+ for entries in [
128
+ vec![(50, -1, 65536)],
129
+ vec![(50, 1024, 0)],
130
+ vec![(50, 1024, 131072)],
131
+ vec![(50, 0, 65536), (50, 1024, 65536)],
132
+ vec![(50, 1024, 65536), (50, -1, 65536)],
133
+ vec![(50, -2, 65536), (50, 1024, 65536)],
134
+ vec![(50, 5000, 65536)],
135
+ ] {
136
+ let bytes = movie(
137
+ version,
138
+ 1000,
139
+ &track(1, version, 44100, &[(4, 1024)], &edit(version, &entries)),
140
+ );
141
+ assert!(matches!(
142
+ parse(&bytes, 1, 44100),
143
+ Err(Error::InvalidAudio(_))
144
+ ));
145
+ }
146
+ }
147
+ }
148
+
149
+ #[test]
150
+ fn leading_empty_edits_advance_movie_time_without_changing_media_range() {
151
+ for version in [0, 1] {
152
+ let edits = edit(
153
+ version,
154
+ &[(33, -1, 65536), (67, -1, 65536), (50, 1024, 65536)],
155
+ );
156
+ let bytes = movie(
157
+ version,
158
+ 1000,
159
+ &track(1, version, 44100, &[(3, 1024), (1, 157)], &edits),
160
+ );
161
+ let bounds = parse(&bytes, 1, 44100).unwrap();
162
+ assert_eq!(
163
+ (bounds.start, bounds.end, bounds.leading_frames),
164
+ (1024, 3229, 4410)
165
+ );
166
+ assert!(bounds.verify(4096, 6615).is_ok());
167
+ assert!(bounds.verify(4096, 2205).is_err());
168
+ }
169
+ // Sum movie ticks before converting so several short gaps do not each add
170
+ // another rounded frame. Metadata remains constant-size for many edits.
171
+ let bytes = movie(
172
+ 0,
173
+ 3,
174
+ &track(
175
+ 1,
176
+ 0,
177
+ 10,
178
+ &[(1, 10)],
179
+ &edit(0, &[(1, -1, 65536), (1, -1, 65536), (1, 0, 65536)]),
180
+ ),
181
+ );
182
+ assert_eq!(parse(&bytes, 1, 10).unwrap().leading_frames, 7);
183
+
184
+ let huge = movie(
185
+ 1,
186
+ 1,
187
+ &track(
188
+ 1,
189
+ 1,
190
+ 44100,
191
+ &[(1, 1024)],
192
+ &edit(1, &[(u64::MAX, -1, 65536), (1, -1, 65536), (1, 0, 65536)]),
193
+ ),
194
+ );
195
+ assert!(matches!(parse(&huge, 1, 44100), Err(Error::SizeOverflow)));
196
+ }
197
+
198
+ #[test]
199
+ fn rounded_media_timestamps_allow_less_than_one_tick_without_accumulating_drift() {
200
+ let bytes = movie(
201
+ 0,
202
+ 1000,
203
+ &track(1, 0, 1000, &[(2, 23), (1, 24), (1, 3)], &[]),
204
+ );
205
+ let bounds = parse(&bytes, 1, 44100).unwrap();
206
+ for (position, timestamp) in [(0, 0), (1024, 23), (2048, 46), (3072, 70)] {
207
+ assert!(bounds.slice(position, 1024, timestamp).is_ok());
208
+ }
209
+ assert!(bounds.slice(1024, 1024, 24).is_ok()); // rounded upward
210
+ assert!(bounds.slice(1024, 1024, 22).is_err());
211
+ assert!(bounds.slice(1024, 1024, 25).is_err());
212
+ assert!(bounds.slice(44100, 1024, 1001).is_err()); // exactly one tick
213
+ assert!(bounds.slice(3072, 1024, 69).is_ok());
214
+ assert!(bounds.slice(4096, 1024, 92).is_ok());
215
+ assert!(bounds.slice(5120, 1024, 115).is_err()); // repeated 23-tick drift
216
+ assert_eq!(bounds.end, 3220);
217
+ assert!(bounds.verify(4096, 3220).is_ok());
218
+ }
219
+
220
+ #[test]
221
+ fn rounded_media_end_is_clamped_to_decoded_frames_but_truncation_is_rejected() {
222
+ let bytes = movie(
223
+ 0,
224
+ 1000,
225
+ &track(1, 0, 1000, &[(2, 23), (1, 24), (1, 23)], &[]),
226
+ );
227
+ let bounds = parse(&bytes, 1, 44100).unwrap();
228
+ assert_eq!(bounds.end, 4102); // 93 ms, rounded beyond 4,096 decoded frames
229
+ assert!(bounds.verify(4096, 4096).is_ok());
230
+ assert!(bounds.verify(3072, 3072).is_err());
231
+ assert!(bounds.verify(4096, 4102).is_err());
232
+ let bytes = movie(0, 1000, &track(1, 0, 1000, &[(4, 24)], &[]));
233
+ assert!(parse(&bytes, 1, 44100).unwrap().verify(4096, 4096).is_err());
234
+ let exact = movie(0, 44100, &track(1, 0, 44100, &[(4, 1024)], &[]));
235
+ assert!(parse(&exact, 1, 44100).unwrap().verify(4095, 4095).is_err());
236
+ }
237
+
238
+ #[test]
239
+ fn extended_and_open_ended_atoms_and_parent_bounds() {
240
+ let valid = movie(0, 44100, &track(1, 0, 44100, &[(2, 1024)], &[]));
241
+ let mut extended = 1_u32.to_be_bytes().to_vec();
242
+ extended.extend(b"moov");
243
+ extended.extend((valid.len() as u64 + 8).to_be_bytes());
244
+ extended.extend(&valid[8..]);
245
+ assert_eq!(parse(&extended, 1, 44100).unwrap().end, 2048);
246
+ let mut open = valid.clone();
247
+ open[..4].copy_from_slice(&0_u32.to_be_bytes());
248
+ assert_eq!(parse(&open, 1, 44100).unwrap().end, 2048);
249
+ for length in 0..valid.len() {
250
+ assert!(
251
+ parse(&valid[..length], 1, 44100).is_err(),
252
+ "length={length}"
253
+ );
254
+ }
255
+ for size in [4_u32, 7, u32::MAX] {
256
+ let mut bad = valid.clone();
257
+ bad[8..12].copy_from_slice(&size.to_be_bytes());
258
+ assert!(parse(&bad, 1, 44100).is_err());
259
+ }
260
+ let duplicate = [valid.clone(), valid].concat();
261
+ assert!(parse(&duplicate, 1, 44100).is_err());
262
+ }
263
+
264
+ #[test]
265
+ fn invalid_timing_tables_and_overflow_are_errors() {
266
+ for scale in [0, 44100] {
267
+ let bytes = movie(0, scale, &track(1, 0, 0, &[], &[]));
268
+ assert!(parse(&bytes, 1, 44100).is_err());
269
+ }
270
+ let overflow = movie(
271
+ 0,
272
+ 44100,
273
+ &track(1, 0, 44100, &[(u32::MAX, u32::MAX); 3], &[]),
274
+ );
275
+ assert!(matches!(
276
+ parse(&overflow, 1, 44100),
277
+ Err(Error::SizeOverflow)
278
+ ));
279
+ let mut bad = movie(0, 44100, &track(1, 0, 44100, &[(2, 1024)], &[]));
280
+ let position = bad.windows(4).position(|x| x == b"stts").unwrap();
281
+ bad[position + 8..position + 12].copy_from_slice(&u32::MAX.to_be_bytes());
282
+ assert!(parse(&bad, 1, 44100).is_err());
283
+ let duplicate = movie(
284
+ 0,
285
+ 44100,
286
+ &[track(1, 0, 44100, &[], &[]), track(1, 0, 44100, &[], &[])].concat(),
287
+ );
288
+ assert!(parse(&duplicate, 1, 44100).is_err());
289
+ }
290
+
291
+ #[test]
292
+ fn metadata_scan_restores_the_shared_file_position_on_success_error_and_cancellation() {
293
+ let bytes = movie(0, 44100, &track(1, 0, 44100, &[(4, 1024)], &[]));
294
+ let mut file = tempfile::tempfile().unwrap();
295
+ file.write_all(&bytes).unwrap();
296
+ file.seek(SeekFrom::Start(11)).unwrap();
297
+ let mut clone = file.try_clone().unwrap();
298
+ assert!(playback(&mut clone, 1, 44100, &mut || false).is_ok());
299
+ assert_eq!(file.stream_position().unwrap(), 11);
300
+ assert!(playback(&mut clone, 9, 44100, &mut || false).is_err());
301
+ assert_eq!(file.stream_position().unwrap(), 11);
302
+ let mut calls = 0;
303
+ assert!(matches!(
304
+ playback(&mut clone, 1, 44100, &mut || {
305
+ calls += 1;
306
+ calls == 5
307
+ }),
308
+ Err(Error::Cancelled)
309
+ ));
310
+ assert_eq!(file.stream_position().unwrap(), 11);
311
+ }
data/src/mp4.rs ADDED
@@ -0,0 +1,376 @@
1
+ //! AAC playback bounds from ISO BMFF timing, without loading media or tables.
2
+ //! Supports leading empty edits and one contiguous, unit-rate media segment.
3
+ //! References:
4
+ //! <https://developer.apple.com/documentation/quicktime-file-format/edit_list_atom>
5
+ //! <https://developer.apple.com/documentation/quicktime-file-format/time-to-sample_atom>
6
+
7
+ use std::{
8
+ fs::File,
9
+ io::{Read, Seek, SeekFrom},
10
+ ops::Range,
11
+ };
12
+
13
+ use crate::{Error, error::poll};
14
+
15
+ const INVALID: Error = Error::InvalidAudio("invalid MP4 playback timing");
16
+ const UNSUPPORTED_EDIT: Error = Error::InvalidAudio(
17
+ "AAC/MP4 requires a single unit-rate media edit; decode complex edits to PCM",
18
+ );
19
+
20
+ #[derive(Clone, Copy, Debug, PartialEq, Eq)]
21
+ pub(crate) struct Playback {
22
+ pub rate: u32,
23
+ timescale: u32,
24
+ start: u64,
25
+ end: u64,
26
+ minimum_decoded_frames: u64,
27
+ pub leading_frames: u64,
28
+ }
29
+
30
+ impl Playback {
31
+ /// Expected playback length, verified against actual frames at EOF. A
32
+ /// rounded media end can overstate this count and require peak generation
33
+ /// to replay using the smaller observed count.
34
+ pub fn frame_count(self) -> u64 {
35
+ // Parsing already checked the ordering and this sum for overflow.
36
+ self.end - self.start + self.leading_frames
37
+ }
38
+
39
+ pub fn slice(self, position: u64, frames: usize, pts: i64) -> Result<Range<usize>, Error> {
40
+ let timestamp = u64::try_from(pts).map_err(|_| INVALID)?;
41
+ // Packet boundaries may be rounded to media-clock ticks. Compare each
42
+ // absolute timestamp with decoded time, allowing less than one tick;
43
+ // do not accumulate a per-packet allowance that could hide real drift.
44
+ if (u128::from(timestamp) * u128::from(self.rate))
45
+ .abs_diff(u128::from(position) * u128::from(self.timescale))
46
+ >= u128::from(self.rate)
47
+ {
48
+ return Err(Error::InvalidAudio("non-contiguous AAC/MP4 sample timing"));
49
+ }
50
+ let length = u64::try_from(frames).map_err(|_| Error::SizeOverflow)?;
51
+ Ok(self.start.saturating_sub(position).min(length) as usize
52
+ ..self.end.saturating_sub(position).min(length) as usize)
53
+ }
54
+
55
+ pub fn verify(self, decoded: u64, retained: u64) -> Result<(), Error> {
56
+ // A coarse final timestamp can round slightly beyond the decoded end.
57
+ // Clamp to the frames actually available, never synthesize AAC padding.
58
+ let expected = self
59
+ .end
60
+ .min(decoded)
61
+ .saturating_sub(self.start)
62
+ .checked_add(self.leading_frames)
63
+ .ok_or(Error::SizeOverflow)?;
64
+ if decoded < self.minimum_decoded_frames || retained != expected {
65
+ return Err(Error::InvalidAudio("truncated AAC/MP4 playback range"));
66
+ }
67
+ Ok(())
68
+ }
69
+ }
70
+
71
+ /// `file` is a duplicate of the decoder's open file description. Restore its
72
+ /// physical cursor before decoding resumes, including when metadata is invalid.
73
+ /// No pathname is reopened, so both decoding passes keep using the same file.
74
+ pub(crate) fn playback(
75
+ file: &mut File,
76
+ track: u32,
77
+ rate: u32,
78
+ cancelled: &mut impl FnMut() -> bool,
79
+ ) -> Result<Option<Playback>, Error> {
80
+ let saved = file.stream_position()?;
81
+ let length = file.metadata()?.len();
82
+ let result = Parser {
83
+ input: &mut *file,
84
+ cancelled,
85
+ }
86
+ .playback(length, track, rate);
87
+ let restored = file.seek(SeekFrom::Start(saved));
88
+ let result = result?;
89
+ restored?;
90
+ Ok(result)
91
+ }
92
+
93
+ #[derive(Clone, Copy)]
94
+ struct Atom {
95
+ kind: [u8; 4],
96
+ data: u64,
97
+ end: u64,
98
+ }
99
+
100
+ struct Edit {
101
+ start: u64,
102
+ duration: u64,
103
+ leading: u64,
104
+ }
105
+
106
+ struct Parser<'a, R, C> {
107
+ input: &'a mut R,
108
+ cancelled: &'a mut C,
109
+ }
110
+
111
+ impl<R: Read + Seek, C: FnMut() -> bool> Parser<'_, R, C> {
112
+ fn playback(&mut self, length: u64, track: u32, rate: u32) -> Result<Option<Playback>, Error> {
113
+ let root = Atom {
114
+ kind: *b"root",
115
+ data: 0,
116
+ end: length,
117
+ };
118
+ let moov = self.required(root, *b"moov")?;
119
+ // Fragment sample timing lives in moof/trun, not this initial stts.
120
+ // Leave fragmented inputs on the existing decoded-frame timeline.
121
+ if self.find(moov, *b"mvex")?.is_some() {
122
+ return Ok(None);
123
+ }
124
+ let movie = self.required(moov, *b"mvhd")?;
125
+ let movie_scale = self.timescale(movie)?;
126
+ let mut selected = None;
127
+ let mut position = moov.data;
128
+ while position < moov.end {
129
+ let atom = self.atom(position, moov.end)?;
130
+ if atom.kind == *b"trak" {
131
+ let header = self.required(atom, *b"tkhd")?;
132
+ let offset = match self.version(header)? {
133
+ 0 => 12,
134
+ 1 => 20,
135
+ _ => return Err(INVALID),
136
+ };
137
+ if u32::from_be_bytes(self.bytes(header, offset)?) == track
138
+ && selected.replace(atom).is_some()
139
+ {
140
+ return Err(INVALID);
141
+ }
142
+ }
143
+ position = atom.end;
144
+ }
145
+ let selected = selected.ok_or(INVALID)?;
146
+ let media = self.required(selected, *b"mdia")?;
147
+ let header = self.required(media, *b"mdhd")?;
148
+ let media_scale = self.timescale(header)?;
149
+ let info = self.required(media, *b"minf")?;
150
+ let table = self.required(info, *b"stbl")?;
151
+ let stts = self.required(table, *b"stts")?;
152
+ let media_duration = self.duration(stts)?;
153
+ let media_frames = frames(u128::from(media_duration), media_scale, rate)?;
154
+ let edit = match self.find(selected, *b"edts")? {
155
+ Some(edts) => match self.find(edts, *b"elst")? {
156
+ Some(elst) => self.edit(elst)?,
157
+ None => None,
158
+ },
159
+ None => None,
160
+ };
161
+ let (start, end, leading_frames) = match edit {
162
+ Some(Edit {
163
+ start,
164
+ duration,
165
+ leading,
166
+ }) => {
167
+ let start_frame = frames(u128::from(start), media_scale, rate)?;
168
+ // Add in rational time before rounding, and clamp rounded movie
169
+ // duration to the sample table's precise end of media.
170
+ let end_time = u128::from(start) * u128::from(movie_scale)
171
+ + u128::from(duration) * u128::from(media_scale);
172
+ let numerator = end_time
173
+ .checked_mul(u128::from(rate))
174
+ .ok_or(Error::SizeOverflow)?;
175
+ let denominator = u128::from(movie_scale) * u128::from(media_scale);
176
+ let end_frame = u64::try_from(numerator.div_ceil(denominator))
177
+ .map_err(|_| Error::SizeOverflow)?
178
+ .min(media_frames);
179
+ (
180
+ start_frame,
181
+ end_frame,
182
+ frames(u128::from(leading), movie_scale, rate)?,
183
+ )
184
+ }
185
+ None => (0, media_frames, 0),
186
+ };
187
+ if start > end {
188
+ return Err(INVALID);
189
+ }
190
+ // Require decoded time to lie strictly after the preceding media tick.
191
+ // With a sample-rate clock this still requires every declared frame.
192
+ let minimum_decoded_frames = if media_duration == 0 {
193
+ 0
194
+ } else {
195
+ u64::try_from(
196
+ u128::from(media_duration - 1) * u128::from(rate) / u128::from(media_scale),
197
+ )
198
+ .map_err(|_| Error::SizeOverflow)?
199
+ .checked_add(1)
200
+ .ok_or(Error::SizeOverflow)?
201
+ };
202
+ leading_frames
203
+ .checked_add(end - start)
204
+ .ok_or(Error::SizeOverflow)?;
205
+ Ok(Some(Playback {
206
+ rate,
207
+ timescale: media_scale,
208
+ start,
209
+ end,
210
+ minimum_decoded_frames,
211
+ leading_frames,
212
+ }))
213
+ }
214
+
215
+ fn atom(&mut self, position: u64, limit: u64) -> Result<Atom, Error> {
216
+ poll(self.cancelled)?;
217
+ let remaining = limit.checked_sub(position).ok_or(INVALID)?;
218
+ if remaining < 8 {
219
+ return Err(INVALID);
220
+ }
221
+ self.input.seek(SeekFrom::Start(position))?;
222
+ let mut header = [0; 8];
223
+ self.input.read_exact(&mut header)?;
224
+ let short = u32::from_be_bytes(header[..4].try_into().unwrap());
225
+ let (size, width) = match short {
226
+ 0 => (remaining, 8),
227
+ 1 => {
228
+ if remaining < 16 {
229
+ return Err(INVALID);
230
+ }
231
+ let mut size = [0; 8];
232
+ self.input.read_exact(&mut size)?;
233
+ (u64::from_be_bytes(size), 16)
234
+ }
235
+ n => (u64::from(n), 8),
236
+ };
237
+ if size < width || size > remaining {
238
+ return Err(INVALID);
239
+ }
240
+ Ok(Atom {
241
+ kind: header[4..].try_into().unwrap(),
242
+ data: position + width,
243
+ end: position + size,
244
+ })
245
+ }
246
+
247
+ fn find(&mut self, parent: Atom, kind: [u8; 4]) -> Result<Option<Atom>, Error> {
248
+ let mut found = None;
249
+ let mut position = parent.data;
250
+ while position < parent.end {
251
+ let atom = self.atom(position, parent.end)?;
252
+ if atom.kind == kind && found.replace(atom).is_some() {
253
+ return Err(INVALID);
254
+ }
255
+ position = atom.end;
256
+ }
257
+ Ok(found)
258
+ }
259
+
260
+ fn required(&mut self, parent: Atom, kind: [u8; 4]) -> Result<Atom, Error> {
261
+ self.find(parent, kind)?.ok_or(INVALID)
262
+ }
263
+
264
+ fn bytes<const N: usize>(&mut self, atom: Atom, offset: u64) -> Result<[u8; N], Error> {
265
+ let position = atom.data.checked_add(offset).ok_or(INVALID)?;
266
+ if position
267
+ .checked_add(N as u64)
268
+ .is_none_or(|end| end > atom.end)
269
+ {
270
+ return Err(INVALID);
271
+ }
272
+ self.input.seek(SeekFrom::Start(position))?;
273
+ let mut bytes = [0; N];
274
+ self.input.read_exact(&mut bytes)?;
275
+ Ok(bytes)
276
+ }
277
+
278
+ fn version(&mut self, atom: Atom) -> Result<u8, Error> {
279
+ let header = self.bytes::<4>(atom, 0)?;
280
+ if header[1..] != [0, 0, 0] && atom.kind != *b"tkhd" {
281
+ return Err(INVALID);
282
+ }
283
+ Ok(header[0])
284
+ }
285
+
286
+ fn timescale(&mut self, atom: Atom) -> Result<u32, Error> {
287
+ let offset = match self.version(atom)? {
288
+ 0 => 12,
289
+ 1 => 20,
290
+ _ => return Err(INVALID),
291
+ };
292
+ let value = u32::from_be_bytes(self.bytes(atom, offset)?);
293
+ if value == 0 {
294
+ return Err(INVALID);
295
+ }
296
+ Ok(value)
297
+ }
298
+
299
+ fn duration(&mut self, atom: Atom) -> Result<u64, Error> {
300
+ if self.version(atom)? != 0 {
301
+ return Err(INVALID);
302
+ }
303
+ let entries = u32::from_be_bytes(self.bytes(atom, 4)?);
304
+ if atom.end - atom.data != 8 + u64::from(entries) * 8 {
305
+ return Err(INVALID);
306
+ }
307
+ let mut duration = 0_u64;
308
+ for index in 0..entries {
309
+ poll(self.cancelled)?;
310
+ let entry = self.bytes::<8>(atom, 8 + u64::from(index) * 8)?;
311
+ let count = u32::from_be_bytes(entry[..4].try_into().unwrap());
312
+ let delta = u32::from_be_bytes(entry[4..].try_into().unwrap());
313
+ duration = duration
314
+ .checked_add(u64::from(count) * u64::from(delta))
315
+ .ok_or(Error::SizeOverflow)?;
316
+ }
317
+ Ok(duration)
318
+ }
319
+
320
+ fn edit(&mut self, atom: Atom) -> Result<Option<Edit>, Error> {
321
+ let version = self.version(atom)?;
322
+ let entries = u32::from_be_bytes(self.bytes(atom, 4)?);
323
+ let width = match version {
324
+ 0 => 12,
325
+ 1 => 20,
326
+ _ => return Err(INVALID),
327
+ };
328
+ if atom.end - atom.data != 8 + u64::from(entries) * width {
329
+ return Err(INVALID);
330
+ }
331
+ if entries == 0 {
332
+ return Ok(None);
333
+ }
334
+ let mut leading = 0_u64;
335
+ for index in 0..entries {
336
+ poll(self.cancelled)?;
337
+ let offset = 8 + u64::from(index) * width;
338
+ let (duration, start, rate) = match version {
339
+ 0 => (
340
+ u64::from(u32::from_be_bytes(self.bytes(atom, offset)?)),
341
+ i64::from(i32::from_be_bytes(self.bytes(atom, offset + 4)?)),
342
+ u32::from_be_bytes(self.bytes(atom, offset + 8)?),
343
+ ),
344
+ _ => (
345
+ u64::from_be_bytes(self.bytes(atom, offset)?),
346
+ i64::from_be_bytes(self.bytes(atom, offset + 8)?),
347
+ u32::from_be_bytes(self.bytes(atom, offset + 16)?),
348
+ ),
349
+ };
350
+ if rate != 0x0001_0000 {
351
+ return Err(UNSUPPORTED_EDIT);
352
+ }
353
+ if start == -1 {
354
+ leading = leading.checked_add(duration).ok_or(Error::SizeOverflow)?;
355
+ } else if start >= 0 && index == entries - 1 {
356
+ return Ok(Some(Edit {
357
+ start: start as u64,
358
+ duration,
359
+ leading,
360
+ }));
361
+ } else {
362
+ return Err(UNSUPPORTED_EDIT);
363
+ }
364
+ }
365
+ // A sequence of empty edits with no media segment has no audio range.
366
+ Err(UNSUPPORTED_EDIT)
367
+ }
368
+ }
369
+
370
+ fn frames(time: u128, scale: u32, rate: u32) -> Result<u64, Error> {
371
+ u64::try_from((time * u128::from(rate)).div_ceil(u128::from(scale)))
372
+ .map_err(|_| Error::SizeOverflow)
373
+ }
374
+
375
+ #[cfg(test)]
376
+ mod tests;
data/src/waveform.rs CHANGED
@@ -25,6 +25,7 @@ impl Waveform {
25
25
  pub fn channels(&self) -> usize {
26
26
  self.channels
27
27
  }
28
+ /// Playback frames after trimming, including supported MP4 leading silence.
28
29
  pub fn source_frames(&self) -> u64 {
29
30
  self.source_frames
30
31
  }
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: audiowaveform
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.4.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrii Dmytrenko
@@ -57,6 +57,8 @@ files:
57
57
  - src/error.rs
58
58
  - src/lib.rs
59
59
  - src/matroska.rs
60
+ - src/mp4.rs
61
+ - src/mp4/tests.rs
60
62
  - src/options.rs
61
63
  - src/pcm.rs
62
64
  - src/reduce.rs