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 0000000..912c519 Binary files /dev/null and b/AudioStreamingTests/Streaming/Mp4 Trailing Data/trailing-zero-padding.m4a differ diff --git a/Package.swift b/Package.swift index 5249157..2a0ee0a 100644 --- a/Package.swift +++ b/Package.swift @@ -60,7 +60,8 @@ let package = Package( exclude: ["Info.plist", "Streaming/output"], resources: [ // Test resources for metadata stream processor tests - .copy("Streaming/Metadata Stream Processor/raw-audio-streams") + .copy("Streaming/Metadata Stream Processor/raw-audio-streams"), + .copy("Streaming/Mp4 Trailing Data/trailing-zero-padding.m4a") ] ) ]