From 4773d3402cfb04f5fdaf4e5e957cc55b07708f60 Mon Sep 17 00:00:00 2001 From: Chrizz Gelbach Date: Mon, 19 Oct 2026 15:15:11 +0200 Subject: [PATCH] Ignore parse errors on trailing data after the mdat atom of MP4 streams Some M4A files carry trailing padding after mdat (e.g. zero bytes left by tag editors). AudioFileStreamParseBytes rejects it with kAudioFileStreamError_InvalidFile once the stream reaches it, although all audio packets have been parsed already, which raised streamParseBytesFailure shortly before the end of the song. Track the stream byte offset passed to the parser. When parsing fails after dataOffset + audioDataByteCount of an MP4 stream, ignore the error and the rest of the stream. All bytes are still parsed as before, so no audio can be dropped, and errors inside the audio data are reported as before. --- .../Processors/AudioFileStreamProcessor.swift | 47 ++++++++++- .../Mp4TrailingDataTests.swift | 79 ++++++++++++++++++ .../trailing-zero-padding.m4a | Bin 0 -> 9375 bytes Package.swift | 3 +- 4 files changed, 126 insertions(+), 3 deletions(-) create mode 100644 AudioStreamingTests/Streaming/Mp4 Trailing Data/Mp4TrailingDataTests.swift create mode 100644 AudioStreamingTests/Streaming/Mp4 Trailing Data/trailing-zero-padding.m4a diff --git a/AudioStreaming/Streaming/AudioPlayer/Processors/AudioFileStreamProcessor.swift b/AudioStreaming/Streaming/AudioPlayer/Processors/AudioFileStreamProcessor.swift index 77d560e..1b03781 100644 --- a/AudioStreaming/Streaming/AudioPlayer/Processors/AudioFileStreamProcessor.swift +++ b/AudioStreaming/Streaming/AudioPlayer/Processors/AudioFileStreamProcessor.swift @@ -54,6 +54,12 @@ final class AudioFileStreamProcessor { // Track if we're processing Ogg Vorbis private var isProcessingOggVorbis: Bool = false + private var fileHint: AudioFileTypeID = 0 + /// The stream byte offset of the next data passed to the parser + private var parserByteOffset: Int64 = 0 + /// Set once the parser rejected the trailing data after the audio of an MP4 stream + private var ignoresTrailingData: Bool = false + var isFileStreamOpen: Bool { audioFileStream != nil || isProcessingOggVorbis } @@ -85,6 +91,9 @@ final class AudioFileStreamProcessor { return noErr } else { isProcessingOggVorbis = false + self.fileHint = fileHint + parserByteOffset = 0 + ignoresTrailingData = false let data = UnsafeMutableRawPointer.from(object: self) return AudioFileStreamOpen(data, _propertyListenerProc, _propertyPacketsProc, fileHint, &audioFileStream) } @@ -120,10 +129,42 @@ final class AudioFileStreamProcessor { } guard let stream = audioFileStream else { return 0 } + guard !ignoresTrailingData else { return noErr } + parserByteOffset += Int64(data.count) let flags: AudioFileStreamParseFlags = discontinuous ? .discontinuity : .init() - return data.withUnsafeBytes { buffer -> OSStatus in + let status = data.withUnsafeBytes { buffer -> OSStatus in AudioFileStreamParseBytes(stream, UInt32(buffer.count), buffer.baseAddress, flags) } + if status != noErr, isParsedDataPastMp4AudioData() { + // Some files carry trailing padding after the mdat atom (e.g. zero bytes left by tag editors), + // which the parser rejects although all audio packets have been parsed already. + Logger.debug("Ignoring data after the end of the mp4 audio data: \(status)", category: .generic) + ignoresTrailingData = true + return noErr + } + return status + } + + private func isParsedDataPastMp4AudioData() -> Bool { + guard fileHint == kAudioFileM4AType || fileHint == kAudioFileMPEG4Type, + let readingEntry = playerContext.audioReadingEntry + else { return false } + readingEntry.lock.lock() + let streamState = readingEntry.audioStreamState + let dataOffset = streamState.dataOffset + let dataByteCount = streamState.dataByteCount + readingEntry.lock.unlock() + return Self.isPastMp4AudioData(byteOffset: parserByteOffset, dataOffset: dataOffset, dataByteCount: dataByteCount) + } + + /// Whether the stream reached the end of the audio data of an MP4 stream + /// + /// - parameter byteOffset: The stream byte offset up to which data has been passed to the parser + /// - parameter dataOffset: The stream byte offset of the audio data + /// - parameter dataByteCount: The size of the audio data, if known + static func isPastMp4AudioData(byteOffset: Int64, dataOffset: UInt64, dataByteCount: UInt64?) -> Bool { + guard dataOffset > 0, let dataByteCount, dataByteCount > 0 else { return false } + return byteOffset > Int64(dataOffset + dataByteCount) } /// Flushes any remaining packets from the AudioFileStream parser. @@ -138,7 +179,7 @@ final class AudioFileStreamProcessor { /// - Returns: An `OSStatus` value indicating if an error occurred or not. func flushRemainingPackets() -> OSStatus { // Ogg Vorbis doesn't need flushing (handled internally) - if isProcessingOggVorbis { + if isProcessingOggVorbis || ignoresTrailingData { return noErr } @@ -211,6 +252,8 @@ final class AudioFileStreamProcessor { readingEntry.reset() readingEntry.seek(at: Int(seekByteOffset)) + parserByteOffset = seekByteOffset + ignoresTrailingData = false rendererContext.waitingForDataAfterSeekFrameCount.write { $0 = 0 } playerContext.setInternalState(to: .waitingForDataAfterSeek) rendererContext.resetBuffers() diff --git a/AudioStreamingTests/Streaming/Mp4 Trailing Data/Mp4TrailingDataTests.swift b/AudioStreamingTests/Streaming/Mp4 Trailing Data/Mp4TrailingDataTests.swift new file mode 100644 index 0000000..c4adfaf --- /dev/null +++ b/AudioStreamingTests/Streaming/Mp4 Trailing Data/Mp4TrailingDataTests.swift @@ -0,0 +1,79 @@ +// +// Mp4TrailingDataTests.swift +// AudioStreamingTests +// + +import AVFoundation +import XCTest +@testable import AudioStreaming + +class Mp4TrailingDataTests: XCTestCase { + func test_Offset_Inside_Audio_Data_Is_Not_Past() { + XCTAssertFalse(AudioFileStreamProcessor.isPastMp4AudioData(byteOffset: 900, dataOffset: 100, dataByteCount: 1000)) + } + + func test_Offset_At_End_Of_Audio_Data_Is_Not_Past() { + XCTAssertFalse(AudioFileStreamProcessor.isPastMp4AudioData(byteOffset: 1100, dataOffset: 100, dataByteCount: 1000)) + } + + func test_Offset_After_End_Of_Audio_Data_Is_Past() { + XCTAssertTrue(AudioFileStreamProcessor.isPastMp4AudioData(byteOffset: 1101, dataOffset: 100, dataByteCount: 1000)) + } + + func test_Unknown_Audio_Data_Is_Never_Past() { + XCTAssertFalse(AudioFileStreamProcessor.isPastMp4AudioData(byteOffset: 5000, dataOffset: 100, dataByteCount: nil)) + XCTAssertFalse(AudioFileStreamProcessor.isPastMp4AudioData(byteOffset: 5000, dataOffset: 100, dataByteCount: 0)) + XCTAssertFalse(AudioFileStreamProcessor.isPastMp4AudioData(byteOffset: 5000, dataOffset: 0, dataByteCount: 1000)) + } + + func test_Trailing_Zero_Padding_After_Mdat_Is_Parsed_Without_Error() throws { + // AAC m4a with 4096 zero bytes after the mdat atom, which AudioFileStream rejects as an invalid file + let url = try XCTUnwrap(Bundle.module.url(forResource: "trailing-zero-padding", withExtension: "m4a")) + let data = try Data(contentsOf: url) + + let outputFormat = AVAudioFormat(commonFormat: .pcmFormatFloat32, sampleRate: 44100, channels: 2, interleaved: true)! + let playerContext = AudioPlayerContext() + let rendererContext = AudioRendererContext(configuration: .default, outputAudioFormat: outputFormat) + defer { rendererContext.clean() } + let processor = AudioFileStreamProcessor(playerContext: playerContext, + rendererContext: rendererContext, + outputAudioFormat: outputFormat.basicStreamDescription) + var raisedErrors: [AudioPlayerError] = [] + processor.fileStreamCallback = { effect in + if case let .raiseError(error) = effect { raisedErrors.append(error) } + } + let entry = AudioEntry(source: Mp4Source(length: data.count), + entryId: AudioEntryId(id: "entry"), + outputAudioFormat: outputFormat) + playerContext.audioReadingEntry = entry + + XCTAssertEqual(processor.openFileStream(with: kAudioFileM4AType), noErr) + defer { processor.closeFileStreamIfNeeded() } + var offset = 0 + while offset < data.count { + let chunk = data[offset ..< min(offset + 1024, data.count)] + XCTAssertEqual(processor.parseFileStreamBytes(data: chunk), noErr, "chunk at \(offset)") + offset += chunk.count + } + XCTAssertEqual(processor.flushRemainingPackets(), noErr) + + XCTAssertTrue(raisedErrors.isEmpty) + XCTAssertEqual(entry.audioStreamState.dataOffset, 4096) + XCTAssertEqual(entry.audioStreamState.dataByteCount, 1183) + } +} + +private final class Mp4Source: CoreAudioStreamSource { + let position = 0 + let length: Int + weak var delegate: AudioStreamSourceDelegate? + let audioFileHint: AudioFileTypeID = kAudioFileM4AType + let underlyingQueue = DispatchQueue(label: "mp4-trailing-data-tests") + + init(length: Int) { self.length = length } + + func close() {} + func suspend() {} + func resume() {} + func seek(at _: Int) {} +} diff --git a/AudioStreamingTests/Streaming/Mp4 Trailing Data/trailing-zero-padding.m4a b/AudioStreamingTests/Streaming/Mp4 Trailing Data/trailing-zero-padding.m4a new file mode 100644 index 0000000000000000000000000000000000000000..912c5191df945cf4b6af047be5e8af0be7cd04a6 GIT binary patch literal 9375 zcmeI0e^gUt7{}jx*``FX(S$;)UWr1~>pXOQ(^ngq0<_m6kp2p6`ivLxTJMMV92>_8FZyI*k|& zEFRmbl%+{v$WzMxr7!DzZ-p#fGy14jm6cZ@Pnt3q5uj# zhH7I*JQ|PYV&S~ia6Bsbyk&v)&?iGnfVLRgB4|m_=0J;whVrPs2HJjT@ElSV^hj`R z)T9gI{5VslLlg#;a*Kx{c;PLbK&T zX$vwlD z7fDUk$SvnWXQM?Epb5|fXaY0=ngC6JCO{LQ3D5-oD*?u*O8BY+&JcE3knuPE^riE- zJAZnTdvIxvNxb3nQcqJ4(Igqp<`VpeXL#AU&I~FoSUEoA_+0+wwj)`$J03@UJfXb! zQB;Wwhf3^|P8-KJp9wecb(tnc}`<0c1{3i zyS~aeRmlS#dxbWlS}}>Iq=+BGW}NHaQ zmm79hy!30Y(k5C5+I}!FW9-E@8e1~NOHY~IJ*={`%z5L(ZRTY58l2hu~035nd2(YBddqUzlj>Zem>QcUsqzg@4&u>j*c@9CR=009AU)0w!w0vr%&;jqV_dKLU&(a-*nrb z;9&X%wQQ9R&lH4ePEON z=XOoJE400$tGsWHa)E12DieMe62#8l zznU@!kmYt*fbo+@S(+_xA6++SHr^Qs{vs{&aN=<0L63Qj>g^HPT)=;*<_%=L=iSp% zy>t?mTp0JYCc78!(Nyi2=r-_aU59yJ4oz=OkB4{f7BZqjL4QO?%GTpBf&Rd7gVIGygvDq`Ih22Rx~AY|ymo z_J>V&LD*(7>*>spT^;3RDzUrqmpp#-#z@;)f+pA