Compare commits

...

9 Commits

Author SHA1 Message Date
dimitris-c 07b4ecd39a nit 2024-05-15 00:10:55 +03:00
dimitris-c 539750c0e7 improvements on seeking 2024-05-14 23:22:58 +03:00
dimitris-c b1b2effbe4 improvements on seeking on local files 2024-05-14 20:27:16 +03:00
dimitris-c 89f2e8deb4 small improvement in render processor 2024-05-14 15:35:43 +03:00
dimitris-c a144d48f22 seek improvements 2024-05-13 19:06:30 +03:00
dimitris-c 3e5602ec14 adds handling for non-optimized m4a for local files 2024-05-11 16:42:55 +03:00
Dimitris C a8865bb4d8 Mp4 Restructure account for large mdat box size (#78) 2024-05-09 14:45:02 +03:00
Dimitris C dd2e790ca6 Update README.md 2024-04-01 17:51:23 +03:00
Dimitris C c5bdbdd692 update readme.md (#71) 2024-04-01 16:46:23 +03:00
9 changed files with 152 additions and 41 deletions
@@ -101,7 +101,7 @@ enum AudioContent: Int, CaseIterable {
case .nonOptimized:
return URL(string: "https://github.com/dimitris-c/sample-audio/raw/main/bensound-jazzyfrenchy.m4a")!
case .local:
let path = Bundle.main.path(forResource: "bensound-jazzyfrenchy", ofType: "mp3")!
let path = Bundle.main.path(forResource: "bensound-jazzyfrenchy", ofType: "m4a")!
return URL(fileURLWithPath: path)
case .localWave:
let path = Bundle.main.path(forResource: "hipjazz", ofType: "wav")!
@@ -92,7 +92,9 @@ class AudioEntry {
func reset() {
lock.lock(); defer { lock.unlock() }
framesState = EntryFramesState()
framesState.played = 0
framesState.queued = 0
framesState.lastFrameQueued = -1
}
func has(same source: CoreAudioStreamSource) -> Bool {
@@ -3,6 +3,7 @@
// Copyright © 2020 Decimal. All rights reserved.
//
import Foundation
import AVFoundation
final class FileAudioSource: NSObject, CoreAudioStreamSource {
@@ -17,6 +18,12 @@ final class FileAudioSource: NSObject, CoreAudioStreamSource {
audioFileType(fileExtension: url.pathExtension)
}
private var isMp4: Bool {
audioFileHint == kAudioFileM4AType || audioFileHint == kAudioFileMPEG4Type
}
private var mp4IsAlreadyOptimized: Bool = false
private var seekOffset: Int
private let url: URL
@@ -26,6 +33,8 @@ final class FileAudioSource: NSObject, CoreAudioStreamSource {
private var buffer: UnsafeMutablePointer<UInt8>
private var inputStream: InputStream?
private var mp4Restructure: Mp4Restructure
init(url: URL,
fileManager: FileManager = .default,
underlyingQueue: DispatchQueue,
@@ -35,6 +44,7 @@ final class FileAudioSource: NSObject, CoreAudioStreamSource {
self.underlyingQueue = underlyingQueue
self.fileManager = fileManager
self.readSize = readSize
self.mp4Restructure = Mp4Restructure()
buffer = UnsafeMutablePointer.uint8pointer(of: readSize)
seekOffset = 0
position = 0
@@ -43,6 +53,7 @@ final class FileAudioSource: NSObject, CoreAudioStreamSource {
deinit {
buffer.deallocate()
mp4Restructure.clear()
}
func close() {
@@ -73,18 +84,32 @@ final class FileAudioSource: NSObject, CoreAudioStreamSource {
}
private func performOpen(seek seekOffset: Int) throws {
close()
try open()
guard let inputStream = inputStream else {
return
var reopened = false
let status = inputStream?.streamStatus ?? .closed
if status == .atEnd || status == .closed || status == .error {
reopened = true
close()
try open()
}
if inputStream.setProperty(seekOffset, forKey: .fileCurrentOffsetKey) {
position = seekOffset
var offset = seekOffset
if isMp4, mp4Restructure.dataOptimized {
offset = mp4Restructure.seekAdjusted(offset: seekOffset)
}
if inputStream?.setProperty(offset, forKey: .fileCurrentOffsetKey) == true {
position = offset
} else {
position = 0
}
if !reopened {
underlyingQueue.async { [weak self] in
if self?.inputStream?.hasBytesAvailable == true {
self?.dataAvailable()
}
}
}
}
private func dataAvailable() {
@@ -92,13 +117,51 @@ final class FileAudioSource: NSObject, CoreAudioStreamSource {
let read = inputStream.read(buffer, maxLength: readSize)
if read > 0 {
let data = Data(bytes: buffer, count: read)
delegate?.dataAvailable(source: self, data: data)
if isMp4, !mp4IsAlreadyOptimized {
if !mp4Restructure.dataOptimized {
do {
if let mp4OptimizeInfo = try mp4Restructure.checkIsOptimized(data: data) {
try performMp4Restructure(inputStream: inputStream, mp4OptimizeInfo: mp4OptimizeInfo)
} else {
mp4IsAlreadyOptimized = true
delegate?.dataAvailable(source: self, data: data)
}
} catch {
delegate?.errorOccurred(source: self, error: error)
}
} else {
delegate?.dataAvailable(source: self, data: data)
}
} else {
delegate?.dataAvailable(source: self, data: data)
}
position += read
} else {
position += getCurrentOffsetFromStream()
}
}
func performMp4Restructure(inputStream: InputStream, mp4OptimizeInfo: Mp4OptimizeInfo) throws {
let offsetAccepted = inputStream.setProperty(mp4OptimizeInfo.moovOffset, forKey: .fileCurrentOffsetKey)
if offsetAccepted {
let moovDataBuffer = UnsafeMutablePointer.uint8pointer(of: mp4OptimizeInfo.moovSize)
defer { moovDataBuffer.deallocate() }
let moovRead = inputStream.read(moovDataBuffer, maxLength: mp4OptimizeInfo.moovSize)
if moovRead > 0 {
let data = Data(bytes: moovDataBuffer, count: moovRead)
let moovData = try mp4Restructure.restructureMoov(data: data)
delegate?.dataAvailable(source: self, data: moovData.initialData)
if !inputStream.setProperty(moovData.mdatOffset, forKey: .fileCurrentOffsetKey) {
delegate?.errorOccurred(source: self, error: AudioSystemError.playerStartError)
}
} else {
delegate?.errorOccurred(source: self, error: AudioSystemError.playerStartError)
}
} else {
delegate?.errorOccurred(source: self, error: inputStream.streamError ?? AudioSystemError.playerStartError)
}
}
private func open() throws {
guard let inputStream = InputStream(url: url) else {
throw AudioSystemError.playerStartError
@@ -11,17 +11,28 @@ struct MP4Atom: Equatable, CustomDebugStringConvertible {
let offset: Int
var data: Data?
var isFreeSpaceAtom: Bool {
type == Atoms.free || type == Atoms.skip || type == Atoms.wide
}
var debugDescription: String {
"[Atom][size: \(size))][type: \(Atoms.integerToFourCC(type) ?? "")][offset: \(offset)]"
}
}
struct Mp4OptimizeInfo: Equatable {
let moovOffset: Int
let moovSize: Int
}
/// These are some atoms, helpful for audio mp4
enum Atoms {
static var ftyp: Int { fourCcToInt("ftyp") }
static var moov: Int { fourCcToInt("moov") }
static var mdat: Int { fourCcToInt("mdat") }
static var free: Int { fourCcToInt("free") }
static var skip: Int { fourCcToInt("skip") }
static var wide: Int { fourCcToInt("wide") }
static var cmov: Int { fourCcToInt("cmov") }
static var stco: Int { fourCcToInt("stco") }
@@ -104,14 +115,25 @@ final class Mp4Restructure {
partialResult.append(data)
}
let initialData = accumulatedInitialData + atomData
let mdatOffset = mdatAtom.offset - Atoms.atomPreampleSize
let mdatOffset: Int
if let ftyp = ftyp {
mdatOffset = ftyp.offset + ftyp.size
} else {
let freeSpaceAtoms = atoms.filter(\.isFreeSpaceAtom)
let freeSpaceSize = freeSpaceAtoms.reduce(into: 0) { partialResult, atom in
partialResult += atom.size
}
mdatOffset = mdatAtom.offset - freeSpaceSize
}
dataOptimized = true
return (initialData, mdatOffset)
}
func checkIsOptimized(data: Data) -> (optimized: Bool, offset: Int?)? {
/// Returns `nil` if the data is optimized otherwise `Mp4OptimizeInfo`
func checkIsOptimized(data: Data) throws -> Mp4OptimizeInfo? {
while atomOffset < UInt64(data.count) {
let atomSize = Int(readUInt32FromData(data: data, offset: atomOffset))
let atomType = Int(readUInt32FromData(data: data, offset: atomOffset + 4))
var atomSize = try Int(getInteger(data: data, offset: atomOffset) as UInt32)
let atomType = try Int(getInteger(data: data, offset: atomOffset + 4) as UInt32)
switch atomType {
case Atoms.ftyp:
let ftypData = data[Int(atomOffset) ..< atomSize]
@@ -119,6 +141,12 @@ final class Mp4Restructure {
self.ftyp = ftyp
atoms.append(ftyp)
case Atoms.mdat:
// ref: https://developer.apple.com/documentation/quicktime-file-format/movie_data_atom
// This atom can be quite large, and may exceed 2^32 bytes, in which case the size field will be set to 1,
// and the header will contain a 64-bit extended size field.
if atomSize == 1 {
atomSize = Int(try getInteger(data: data, offset: atomOffset + 8) as UInt64)
}
let mdat = MP4Atom(type: atomType, size: atomSize, offset: atomOffset)
atoms.append(mdat)
foundMdat = true
@@ -133,11 +161,11 @@ final class Mp4Restructure {
if ftyp != nil {
if foundMoov && !foundMdat {
Logger.debug("🕵️ detected an optimized mp4", category: .generic)
return (true, nil)
return nil
} else if !foundMoov && foundMdat {
Logger.debug("🕵️ detected an non-optimized mp4", category: .generic)
let possibleMoovOffset = Int(atomOffset) + atomSize
return (false, possibleMoovOffset)
return Mp4OptimizeInfo(moovOffset: possibleMoovOffset, moovSize: atomSize)
}
}
atomOffset += atomSize
@@ -241,8 +269,12 @@ final class Mp4Restructure {
return (moovAtom.storage, moovAtomSize)
}
private func readUInt32FromData(data: Data, offset: Int) -> UInt32 {
let valueData = data.subdata(in: offset ..< offset + 4)
return UInt32(bigEndian: valueData.withUnsafeBytes { $0.load(as: UInt32.self) })
func getInteger<T: FixedWidthInteger>(data: Data, offset: Int) throws -> T {
let sizeOfInteger = MemoryLayout<T>.size
guard sizeOfInteger <= data.count else {
throw ByteBuffer.Error.eof
}
let _offset = offset + sizeOfInteger
return T(data: data[_offset - sizeOfInteger ..< _offset]).bigEndian
}
}
@@ -74,9 +74,9 @@ final class RemoteMp4Restructure {
return
}
self.audioData.append(data)
let value = self.mp4Restructure.checkIsOptimized(data: self.audioData)
if let value {
if let offset = value.offset, !value.optimized {
do {
let value = try self.mp4Restructure.checkIsOptimized(data: self.audioData)
if let value {
guard response.response?.statusCode == 206 else {
Logger.error("⛔️ mp4 error: no moov before mdat and the stream is not seekable", category: .networking)
completion(.failure(Mp4RestructureError.nonOptimizedMp4AndServerCannotSeek))
@@ -86,7 +86,7 @@ final class RemoteMp4Restructure {
self.audioData = Data()
self.task?.cancel()
self.task = nil
self.fetchAndRestructureMoovAtom(offset: offset) { result in
self.fetchAndRestructureMoovAtom(offset: value.moovOffset) { result in
switch result {
case let .success(value):
let data = value.data
@@ -103,6 +103,8 @@ final class RemoteMp4Restructure {
self.task = nil
completion(.success(nil))
}
} catch {
completion(.failure(Mp4RestructureError.invalidAtomSize))
}
case let .stream(.failure(error)):
completion(.failure(Mp4RestructureError.networkError(error)))
@@ -749,7 +749,6 @@ open class AudioPlayer {
private func raiseUnexpected(error: AudioPlayerError) {
playerContext.setInternalState(to: .error)
// todo raise on main thread from playback thread
asyncOnMain { [weak self] in
guard let self = self else { return }
self.delegate?.audioPlayerUnexpectedError(player: self, error: error)
@@ -122,20 +122,13 @@ final class AudioFileStreamProcessor {
let seekPacket = Int64(floor(readingEntry.seekRequest.time / readingEntry.packetDuration))
let seekStatus = AudioFileStreamSeek(stream, seekPacket, &packetsAlignedByteOffset, &ioFlags)
guard seekStatus == noErr else {
let streamError = AudioFileStreamError(status: seekStatus)
Logger.error("seek failed %@", category: .generic, args: streamError.debugDescription)
return
}
let dataOffset = Int64(readingEntry.audioStreamState.dataOffset)
if !ioFlags.contains(.offsetIsEstimated) {
seekByteOffset = packetsAlignedByteOffset + dataOffset
let delta = Double((seekByteOffset - dataOffset) - packetsAlignedByteOffset) / bitrate * 8
if seekStatus == noErr, !ioFlags.contains(.offsetIsEstimated) {
let delta = Double((seekByteOffset - dataOffset) - packetsAlignedByteOffset) / (bitrate * 8)
readingEntry.lock.lock()
readingEntry.seekTime -= delta
readingEntry.lock.unlock()
seekByteOffset = packetsAlignedByteOffset + dataOffset
}
}
@@ -235,6 +228,7 @@ final class AudioFileStreamProcessor {
case kAudioFileStreamProperty_ReadyToProducePackets:
// check converter for discontinuous stream
processReadyToProducePackets(fileStream: fileStream)
processPacketUpperBoundAndMaxPacketSize(fileStream: fileStream)
case kAudioFileStreamProperty_FormatList:
processFormatList(fileStream: fileStream)
default:
@@ -305,6 +299,25 @@ final class AudioFileStreamProcessor {
}
}
private func processPacketUpperBoundAndMaxPacketSize(fileStream: AudioFileStreamID) {
guard let entry = playerContext.audioReadingEntry else { return }
var packetBufferSize: UInt32 = 0
var status = fileStreamGetProperty(value: &packetBufferSize,
fileStream: fileStream,
propertyId: kAudioFileStreamProperty_PacketSizeUpperBound)
if status != 0 || packetBufferSize == 0 {
status = fileStreamGetProperty(value: &packetBufferSize,
fileStream: fileStream,
propertyId: kAudioFileStreamProperty_MaximumPacketSize)
if status != 0 || packetBufferSize == 0 {
packetBufferSize = 2048 // default value
}
}
entry.lock.withLock {
entry.processedPacketsState.bufferSize = packetBufferSize
}
}
private func processDataByteCount(fileStream: AudioFileStreamID) {
guard let entry = playerContext.audioReadingEntry else { return }
var audioDataByteCount: UInt64 = 0
@@ -179,9 +179,9 @@ final class AudioPlayerRenderProcessor: NSObject {
if totalFramesCopied < inNumberFrames {
let delta = inNumberFrames - totalFramesCopied
writeSilence(outputBuffer: &bufferList.mBuffers,
outputBufferSize: Int(delta * frameSizeInBytes),
offset: Int(totalFramesCopied * frameSizeInBytes))
if let mData = bufferList.mBuffers.mData {
memset(mData + Int(totalFramesCopied * frameSizeInBytes), 0, Int(delta * frameSizeInBytes))
}
if playingEntry != nil || AudioPlayer.InternalState.waiting.contains(state) {
if playerContext.internalState != .rebuffering {
@@ -327,7 +327,5 @@ final class AudioPlayerRenderProcessor: NSObject {
{
guard let mData = outputBuffer.mData else { return }
memset(mData + offset, 0, outputBufferSize)
outputBuffer.mDataByteSize = UInt32(outputBufferSize)
outputBuffer.mNumberChannels = outputAudioFormat.mChannelsPerFrame
}
}
+4 -2
View File
@@ -10,12 +10,14 @@ Under the hood `AudioStreaming` uses `AVAudioEngine` and `CoreAudio` for playbac
- AIFF, AIFC, WAVE, CAF, NeXT, ADTS, MPEG Audio Layer 3, AAC audio formats
- M4A (_Optimized files only_)
As of 1.2.0 version, there's support for non-optimized remote M4A, please report any issues
Known limitations:
- As described above non-optimised M4A files are not supported this is a limitation of [AudioFileStream Services](https://developer.apple.com/documentation/audiotoolbox/audio_file_stream_services?language=swift)
~~- As described above non-optimised M4A files are not supported this is a limitation of [AudioFileStream Services](https://developer.apple.com/documentation/audiotoolbox/audio_file_stream_services?language=swift)~~
# Requirements
- iOS 12.0+
- iOS 13.0+
- Swift 5.x
# Using AudioStreaming