Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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.
Expand All @@ -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
}

Expand Down Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
@@ -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) {}
}
Binary file not shown.
3 changes: 2 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
]
)
]
Expand Down