@@ -96,7 +96,7 @@ public struct WaveformAnalyzer: Sendable {
9696
9797// MARK: - Private
9898
99- fileprivate extension WaveformAnalyzer {
99+ internal extension WaveformAnalyzer {
100100 func waveformSamples(
101101 track audioAssetTrack: AVAssetTrack ,
102102 reader assetReader: AVAssetReader ,
@@ -147,58 +147,73 @@ fileprivate extension WaveformAnalyzer {
147147 let samplesPerPixel = max ( 1 , totalSamples / targetSampleCount)
148148 let samplesPerFFT = 4096 // ~100ms at 44.1kHz, rounded to closest pow(2) for FFT
149149
150+ // `startReading()` throws an uncatchable ObjC exception if the reader isn't in `.unknown`
151+ // (e.g. already cancelled or failed). Normal callers always pass a fresh reader, but bail
152+ // gracefully if that contract is violated so we surface as `readerError` rather than crash.
153+ guard assetReader. status == . unknown else {
154+ return WaveformAnalysis ( amplitudes: [ ] , fft: outputFFT)
155+ }
150156 assetReader. startReading ( )
151157 while assetReader. status == . reading {
152- let trackOutput = assetReader. outputs. first!
153-
154- guard let nextSampleBuffer = trackOutput. copyNextSampleBuffer ( ) ,
155- let blockBuffer = CMSampleBufferGetDataBuffer ( nextSampleBuffer) else {
156- break
157- }
158+ // CMSampleBuffer is a Core Foundation type that lives in the autorelease pool.
159+ // Without an explicit drain per iteration, long files iterate thousands of times and
160+ // can keep gigabytes of buffer memory pinned until the loop exits.
161+ let continueReading = autoreleasepool { ( ) -> Bool in
162+ let trackOutput = assetReader. outputs. first!
163+
164+ guard let nextSampleBuffer = trackOutput. copyNextSampleBuffer ( ) ,
165+ let blockBuffer = CMSampleBufferGetDataBuffer ( nextSampleBuffer) else {
166+ return false
167+ }
158168
159- var readBufferLength = 0
160- var readBufferPointer : UnsafeMutablePointer < Int8 > ? = nil
161- CMBlockBufferGetDataPointer ( blockBuffer, atOffset: 0 , lengthAtOffsetOut: & readBufferLength, totalLengthOut: nil , dataPointerOut: & readBufferPointer)
162- sampleBuffer. append ( UnsafeBufferPointer ( start: readBufferPointer, count: readBufferLength) )
163- if fftBands != nil {
164- // don't append data to this buffer unless we're going to use it.
165- sampleBufferFFT. append ( UnsafeBufferPointer ( start: readBufferPointer, count: readBufferLength) )
166- }
167- CMSampleBufferInvalidate ( nextSampleBuffer)
169+ var readBufferLength = 0
170+ var readBufferPointer : UnsafeMutablePointer < Int8 > ? = nil
171+ CMBlockBufferGetDataPointer ( blockBuffer, atOffset: 0 , lengthAtOffsetOut: & readBufferLength, totalLengthOut: nil , dataPointerOut: & readBufferPointer)
172+ sampleBuffer. append ( UnsafeBufferPointer ( start: readBufferPointer, count: readBufferLength) )
173+ if fftBands != nil {
174+ // don't append data to this buffer unless we're going to use it.
175+ sampleBufferFFT. append ( UnsafeBufferPointer ( start: readBufferPointer, count: readBufferLength) )
176+ }
177+ CMSampleBufferInvalidate ( nextSampleBuffer)
168178
169- let result = process ( sampleBuffer, from: assetReader, downsampleTo: samplesPerPixel, channelSelection: channelSelection)
170- leftSamples += result. left
171- rightSamples += result. right
179+ let result = process ( sampleBuffer, from: assetReader, downsampleTo: samplesPerPixel, channelSelection: channelSelection)
180+ leftSamples += result. left
181+ rightSamples += result. right
172182
173- if result. bytesConsumed > 0 {
174- sampleBuffer. removeFirst ( result. bytesConsumed)
183+ if result. bytesConsumed > 0 {
184+ sampleBuffer. removeFirst ( result. bytesConsumed)
175185
176- // this takes care of a memory leak where Memory continues to increase even though it should clear after calling .removeFirst(…) above.
177- sampleBuffer = Data ( sampleBuffer)
178- }
186+ // this takes care of a memory leak where Memory continues to increase even though it should clear after calling .removeFirst(…) above.
187+ sampleBuffer = Data ( sampleBuffer)
188+ }
179189
180- if let fftBands = fftBands, sampleBufferFFT. count / MemoryLayout < Int16 > . size >= samplesPerFFT {
181- let processedFFTs = process ( sampleBufferFFT, samplesPerFFT: samplesPerFFT, fftBands: fftBands)
182- sampleBufferFFT. removeFirst ( processedFFTs. count * samplesPerFFT * MemoryLayout< Int16> . size)
183- outputFFT? += processedFFTs
190+ if let fftBands = fftBands, sampleBufferFFT. count / MemoryLayout < Int16 > . size >= samplesPerFFT {
191+ let processedFFTs = process ( sampleBufferFFT, samplesPerFFT: samplesPerFFT, fftBands: fftBands)
192+ sampleBufferFFT. removeFirst ( processedFFTs. count * samplesPerFFT * MemoryLayout< Int16> . size)
193+ outputFFT? += processedFFTs
194+ }
195+ return true
184196 }
197+ if !continueReading { break }
185198 }
186199
187- // if we don't have enough pixels yet,
188- // process leftover samples with padding (to reach multiple of samplesPerPixel for vDSP_desamp)
189- if leftSamples. count < targetSampleCount {
190- // each output sample for a single rendered "channel" consumes `samplesPerPixel * inputUnitsPerOutputSample`
191- // Int16s from the interleaved buffer.
192- let channelCount = channelInfo ( from: assetReader) ? . channelCount ?? 1
193- let inputUnitsPerOutputSample = ( channelSelection == . merged) ? 1 : channelCount
194- let missingSampleCount = ( targetSampleCount - leftSamples. count) * samplesPerPixel * inputUnitsPerOutputSample
195- let backfillPaddingSampleCount = max ( 0 , missingSampleCount - ( sampleBuffer. count / MemoryLayout < Int16 > . size) )
196- let backfillPaddingByteCount = backfillPaddingSampleCount * MemoryLayout< Int16> . size
197- let backfillPaddingSamples = [ UInt8] ( repeating: 0 , count: backfillPaddingByteCount)
198- sampleBuffer. append ( backfillPaddingSamples, count: backfillPaddingByteCount)
199- let result = process ( sampleBuffer, from: assetReader, downsampleTo: samplesPerPixel, channelSelection: channelSelection)
200- leftSamples += result. left
201- rightSamples += result. right
200+ // Pad the *output* with silence-equivalent dB values when the read produced fewer samples
201+ // than the target — e.g. a short tail or a reader that ended early (failed/cancelled after
202+ // backgrounding). These become 1.0 (silence) after `normalize`. Allocation is
203+ // O(targetSampleCount), independent of audio duration — the previous implementation padded
204+ // the *input* buffer with up to `target × samplesPerPixel × 2` bytes of zeros, which
205+ // crashed on multi-hour files (issue #93). We only pad on a clean read; a non-`.completed`
206+ // status means `waveformSamples` will throw and the result is discarded anyway, so skip the
207+ // wasted work.
208+ if assetReader. status == . completed {
209+ if leftSamples. count < targetSampleCount {
210+ let missing = targetSampleCount - leftSamples. count
211+ leftSamples. append ( contentsOf: repeatElement ( noiseFloorDecibelCutoff, count: missing) )
212+ }
213+ if isStereo, rightSamples. count < targetSampleCount {
214+ let missing = targetSampleCount - rightSamples. count
215+ rightSamples. append ( contentsOf: repeatElement ( noiseFloorDecibelCutoff, count: missing) )
216+ }
202217 }
203218
204219 let amplitudes : [ Float ]
0 commit comments