Compare commits

...

8 Commits

Author SHA1 Message Date
dimitris-c 4b8bae96c2 bump version number 2025-10-13 18:33:32 +03:00
Maximilian Bauer bccfc20403 fix seek crash: Double value gets infinit and can't be converted to Int64 (#115)
* fix seek crash: Double value get infinit and can't be converted to Int64

* add CoreAudio import to be able to build for macOS Catalyst
2025-10-13 18:32:34 +03:00
Dimitris C. 69dc0d631c fix(MP4): MP4 restructure improvements and some other fixes (#120)
* Adds mp4 restructure improvements

* fixes data race

* fix incorrect parsing of formatList

* adds more handling on propertyListenerProc
2025-10-13 18:32:09 +03:00
Tiger W 4d9bb98aed Support customizing HTTP method and HTTP body (#108) 2025-05-30 14:38:29 +03:00
Dimitris C. 31368a54c1 Revert "Revert "Expose the framesPlayed attribute so progress can be tracked …" (#111)
This reverts commit d3b563c7cd.
2025-05-30 10:13:46 +03:00
Dimitris C. d3b563c7cd Revert "Expose the framesPlayed attribute so progress can be tracked based on…" (#110)
This reverts commit a416cc8e92.
2025-05-29 17:50:50 +03:00
Jackson Harper a416cc8e92 Expose the framesPlayed attribute so progress can be tracked based on frames instead of time (#109) 2025-05-29 17:45:31 +03:00
Stuart A. Malone f36ca68faa Mark state and error types as Sendable so clients can pass them (#105)
across isolation boundaries.
2025-02-26 17:20:33 +02:00
13 changed files with 271 additions and 90 deletions
+4 -4
View File
@@ -750,7 +750,7 @@
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MACOSX_DEPLOYMENT_TARGET = 13.0;
MARKETING_VERSION = 1.2.7;
MARKETING_VERSION = 1.2.8;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
@@ -811,7 +811,7 @@
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MACOSX_DEPLOYMENT_TARGET = 13.0;
MARKETING_VERSION = 1.2.7;
MARKETING_VERSION = 1.2.8;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SDKROOT = iphoneos;
@@ -843,7 +843,7 @@
"@executable_path/Frameworks",
"@loader_path/Frameworks",
);
MARKETING_VERSION = 1.2.7;
MARKETING_VERSION = 1.2.8;
MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu11 gnu++14";
OTHER_LDFLAGS = "-ObjC";
PRODUCT_BUNDLE_IDENTIFIER = com.decimal.AudioStreaming;
@@ -878,7 +878,7 @@
"@executable_path/Frameworks",
"@loader_path/Frameworks",
);
MARKETING_VERSION = 1.2.7;
MARKETING_VERSION = 1.2.8;
MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu11 gnu++14";
OTHER_LDFLAGS = "-ObjC";
PRODUCT_BUNDLE_IDENTIFIER = com.decimal.AudioStreaming;
@@ -5,7 +5,7 @@
import AVFoundation
public enum AudioConverterError: CustomDebugStringConvertible {
public enum AudioConverterError: CustomDebugStringConvertible, Sendable {
case badPropertySizeError
case formatNotSupported
case inputSampleRateOutOfRange
@@ -29,7 +29,7 @@ func fileStreamGetPropertyInfo(fileStream streamId: AudioFileStreamID, propertyI
///
/// Reference:
/// [Audio File Stream Errors](https://developer.apple.com/documentation/audiotoolbox/1391572-audio_file_stream_errors?language=objc)
public enum AudioFileStreamError: CustomDebugStringConvertible {
public enum AudioFileStreamError: CustomDebugStringConvertible, Sendable {
case badPropertySize
case dataUnavailable
case discontinuityCantRecover
@@ -37,6 +37,11 @@ class AudioEntry {
return seekTime + (Double(framesState.played) / outputAudioFormat.sampleRate)
}
var framesPlayed: Int {
lock.lock(); defer { lock.unlock() }
return framesState.played
}
var audioStreamFormat = AudioStreamBasicDescription()
/// Hold the seek time, if a seek was requested
@@ -103,6 +108,9 @@ class AudioEntry {
func calculatedBitrate() -> Double {
lock.lock(); defer { lock.unlock() }
if let explicitBitRate = audioStreamState.bitRate, explicitBitRate > 0 {
return explicitBitRate
}
let packets = processedPacketsState
if packetDuration > 0 {
let packetsCount = packets.count
@@ -6,6 +6,7 @@
import AVFoundation
protocol AudioEntryProviding {
func provideAudioEntry(url: URL, httpMethod: String?, httpBody: Data?, headers: [String: String]) -> AudioEntry
func provideAudioEntry(url: URL, headers: [String: String]) -> AudioEntry
func provideAudioEntry(url: URL) -> AudioEntry
}
@@ -25,7 +26,14 @@ final class AudioEntryProvider: AudioEntryProviding {
}
func provideAudioEntry(url: URL, headers: [String: String]) -> AudioEntry {
let source = self.source(for: url, headers: headers)
let source = self.source(for: url, httpMethod: nil, httpBody: nil, headers: headers)
return AudioEntry(source: source,
entryId: AudioEntryId(id: url.absoluteString),
outputAudioFormat: outputAudioFormat)
}
func provideAudioEntry(url: URL, httpMethod: String?, httpBody: Data?, headers: [String: String]) -> AudioEntry {
let source = self.source(for: url, httpMethod: httpMethod, httpBody: httpBody, headers: headers)
return AudioEntry(source: source,
entryId: AudioEntryId(id: url.absoluteString),
outputAudioFormat: outputAudioFormat)
@@ -34,10 +42,12 @@ final class AudioEntryProvider: AudioEntryProviding {
func provideAudioEntry(url: URL) -> AudioEntry {
provideAudioEntry(url: url, headers: [:])
}
func provideAudioSource(url: URL, headers: [String: String]) -> AudioStreamSource {
func provideAudioSource(url: URL, httpMethod: String?, httpBody: Data?, headers: [String: String]) -> AudioStreamSource {
RemoteAudioSource(networking: networkingClient,
url: url,
httpMethod: httpMethod,
httpBody: httpBody,
underlyingQueue: underlyingQueue,
httpHeaders: headers)
}
@@ -46,10 +56,10 @@ final class AudioEntryProvider: AudioEntryProviding {
FileAudioSource(url: url, underlyingQueue: underlyingQueue)
}
func source(for url: URL, headers: [String: String]) -> CoreAudioStreamSource {
func source(for url: URL, httpMethod: String?, httpBody: Data?, headers: [String: String]) -> CoreAudioStreamSource {
guard !url.isFileURL else {
return provideFileAudioSource(url: url)
}
return provideAudioSource(url: url, headers: headers)
return provideAudioSource(url: url, httpMethod: httpMethod, httpBody: httpBody, headers: headers)
}
}
@@ -12,4 +12,5 @@ final class AudioStreamState {
var dataPacketOffset: UInt64?
var dataPacketCount: Double = 0
var streamFormat = AudioStreamBasicDescription()
var bitRate: Double?
}
@@ -120,11 +120,15 @@ final class FileAudioSource: NSObject, CoreAudioStreamSource {
if isMp4, !mp4IsAlreadyOptimized {
if !mp4Restructure.dataOptimized {
do {
if let mp4OptimizeInfo = try mp4Restructure.checkIsOptimized(data: data) {
try performMp4Restructure(inputStream: inputStream, mp4OptimizeInfo: mp4OptimizeInfo)
} else {
switch try mp4Restructure.checkIsOptimized(data: data) {
case .undetermined:
// Not enough bytes yet; wait for more data before deciding
break
case .optimized:
mp4IsAlreadyOptimized = true
delegate?.dataAvailable(source: self, data: data)
case let .needsRestructure(moovOffset):
try performMp4Restructure(inputStream: inputStream, moovOffset: moovOffset)
}
} catch {
delegate?.errorOccurred(source: self, error: error)
@@ -141,24 +145,71 @@ final class FileAudioSource: NSObject, CoreAudioStreamSource {
}
}
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 {
func performMp4Restructure(inputStream: InputStream, moovOffset: Int) throws {
let offsetAccepted = inputStream.setProperty(moovOffset, forKey: .fileCurrentOffsetKey)
if !offsetAccepted {
delegate?.errorOccurred(source: self, error: inputStream.streamError ?? AudioSystemError.playerStartError)
return
}
// Read moov header (8 bytes)
var header = [UInt8](repeating: 0, count: 8)
let headerRead = inputStream.read(&header, maxLength: 8)
guard headerRead == 8 else {
delegate?.errorOccurred(source: self, error: AudioSystemError.playerStartError)
return
}
// Parse size and type (big endian)
let size32 = Data(header[0 ..< 4]).withUnsafeBytes { $0.load(as: UInt32.self) }.bigEndian
let type32 = Data(header[4 ..< 8]).withUnsafeBytes { $0.load(as: UInt32.self) }.bigEndian
guard Int(type32) == Atoms.moov else {
delegate?.errorOccurred(source: self, error: Mp4RestructureError.missingMoovAtom)
return
}
var moovSize = Int(size32)
var moovData = Data(header)
// Extended size (64-bit)
if moovSize == 1 {
var ext = [UInt8](repeating: 0, count: 8)
let extRead = inputStream.read(&ext, maxLength: 8)
guard extRead == 8 else {
delegate?.errorOccurred(source: self, error: AudioSystemError.playerStartError)
return
}
let ext64 = Data(ext).withUnsafeBytes { $0.load(as: UInt64.self) }.bigEndian
moovSize = Int(ext64)
moovData.append(contentsOf: ext)
}
let remaining = moovSize - moovData.count
if remaining < 0 {
delegate?.errorOccurred(source: self, error: AudioSystemError.playerStartError)
return
}
if remaining > 0 {
var buffer = [UInt8](repeating: 0, count: remaining)
var total = 0
while total < remaining {
let readBytes = buffer.withUnsafeMutableBytes { ptr -> Int in
let base = ptr.baseAddress!.assumingMemoryBound(to: UInt8.self).advanced(by: total)
return inputStream.read(base, maxLength: remaining - total)
}
guard readBytes > 0 else {
delegate?.errorOccurred(source: self, error: AudioSystemError.playerStartError)
return
}
total += readBytes
}
moovData.append(contentsOf: buffer)
}
let moovResult = try mp4Restructure.restructureMoov(data: moovData)
delegate?.dataAvailable(source: self, data: moovResult.initialData)
if !inputStream.setProperty(moovResult.mdatOffset, forKey: .fileCurrentOffsetKey) {
delegate?.errorOccurred(source: self, error: AudioSystemError.playerStartError)
}
}
@@ -36,7 +36,7 @@ enum Atoms {
static var cmov: Int { fourCcToInt("cmov") }
static var stco: Int { fourCcToInt("stco") }
static var co64: Int { fourCcToInt("c064") }
static var co64: Int { fourCcToInt("co64") }
static var atomPreampleSize: Int = 8
@@ -75,6 +75,12 @@ enum Mp4RestructureError: Error {
case networkError(Error)
}
enum OptimizeCheckResult: Equatable {
case optimized
case needsRestructure(moovOffset: Int)
case undetermined
}
final class Mp4Restructure {
private var atomOffset: Int = 0
@@ -129,24 +135,36 @@ final class Mp4Restructure {
return (initialData, mdatOffset)
}
/// Returns `nil` if the data is optimized otherwise `Mp4OptimizeInfo`
func checkIsOptimized(data: Data) throws -> Mp4OptimizeInfo? {
while atomOffset < UInt64(data.count) {
var atomSize = try Int(getInteger(data: data, offset: atomOffset) as UInt32)
let atomType = try Int(getInteger(data: data, offset: atomOffset + 4) as UInt32)
/// Incrementally checks if the MP4 is optimized. Returns tri-state result.
func checkIsOptimized(data: Data) throws -> OptimizeCheckResult {
while atomOffset + 8 <= data.count {
var atomSize: Int = try Int(getInteger(data: data, offset: atomOffset) as UInt32)
let atomType: Int = try Int(getInteger(data: data, offset: atomOffset + 4) as UInt32)
var headerSize = 8
// Handle extended size (64-bit)
if atomSize == 1 {
if atomOffset + 16 > data.count { break }
let ext: UInt64 = try getInteger(data: data, offset: atomOffset + 8)
atomSize = Int(ext)
headerSize = 16
} else if atomSize == 0 {
// Size extends to EOF; with partial data we can't determine full box
break
}
// Bounds and sanity checks
if atomSize < headerSize || atomOffset + atomSize > data.count { break }
switch atomType {
case Atoms.ftyp:
let ftypData = data[Int(atomOffset) ..< atomSize]
let start = atomOffset
let end = atomOffset + atomSize
let ftypData = data[start ..< end]
let ftyp = MP4Atom(type: atomType, size: atomSize, offset: atomOffset, data: ftypData)
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
@@ -158,19 +176,21 @@ final class Mp4Restructure {
let atom = MP4Atom(type: atomType, size: atomSize, offset: atomOffset)
atoms.append(atom)
}
if ftyp != nil {
if foundMoov && !foundMdat {
Logger.debug("🕵️ detected an optimized mp4", category: .generic)
return nil
return .optimized
} else if !foundMoov && foundMdat {
Logger.debug("🕵️ detected an non-optimized mp4", category: .generic)
let possibleMoovOffset = Int(atomOffset) + atomSize
return Mp4OptimizeInfo(moovOffset: possibleMoovOffset, moovSize: atomSize)
Logger.debug("🕵️ detected a non-optimized mp4", category: .generic)
let possibleMoovOffset = atomOffset + atomSize
return .needsRestructure(moovOffset: possibleMoovOffset)
}
}
atomOffset += atomSize
}
return nil
return .undetermined
}
/// logic taken from qt-faststart.c over at ffmpeg
@@ -236,6 +256,8 @@ final class Mp4Restructure {
// the next integer determines the `Number of entries`
// https://developer.apple.com/documentation/quicktime-file-format/chunk_offset_atom/number_of_entries
let numberOfOffsetEntries = try Int(moovAtom.getInteger() as UInt32)
// Adjust by moov size
let adjustDelta = moovAtomSize
if atomType == Atoms.stco {
Logger.debug("🏗️ patching stco atom...", category: .generic)
if moovAtom.bytesAvailable < numberOfOffsetEntries * 4 {
@@ -246,7 +268,7 @@ final class Mp4Restructure {
for _ in 0 ..< numberOfOffsetEntries {
let currentOffset = try Int(moovAtom.getInteger(moovAtom.offset) as UInt32)
// adjust the offset by adding the size of moov atom
let adjustOffset = currentOffset + moovAtomSize
let adjustOffset = currentOffset + adjustDelta
if currentOffset < 0, adjustOffset >= 0 {
throw Mp4RestructureError.unableToRestructureData
@@ -261,8 +283,8 @@ final class Mp4Restructure {
}
for _ in 0 ..< numberOfOffsetEntries {
let currentOffset: Int = try moovAtom.getInteger(moovAtom.offset)
// adjust the offset by adding the size of moov atom
moovAtom.put(currentOffset + moovAtomSize)
// adjust the offset by adding the size of moov atom (write as big-endian 64-bit)
moovAtom.put(UInt64(currentOffset + adjustDelta).bigEndian)
}
}
}
@@ -271,10 +293,10 @@ final class Mp4Restructure {
func getInteger<T: FixedWidthInteger>(data: Data, offset: Int) throws -> T {
let sizeOfInteger = MemoryLayout<T>.size
guard sizeOfInteger <= data.count else {
guard offset >= 0, offset + sizeOfInteger <= data.count else {
throw ByteBuffer.Error.eof
}
let _offset = offset + sizeOfInteger
return T(data: data[_offset - sizeOfInteger ..< _offset]).bigEndian
let end = offset + sizeOfInteger
return T(data: data[offset ..< end]).bigEndian
}
}
@@ -75,8 +75,15 @@ final class RemoteMp4Restructure {
}
self.audioData.append(data)
do {
let value = try self.mp4Restructure.checkIsOptimized(data: self.audioData)
if let value {
switch try self.mp4Restructure.checkIsOptimized(data: self.audioData) {
case .undetermined:
break // keep streaming until decision can be made
case .optimized:
self.audioData = Data()
self.task?.cancel()
self.task = nil
completion(.success(nil))
case let .needsRestructure(moovOffset):
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,22 +93,15 @@ final class RemoteMp4Restructure {
self.audioData = Data()
self.task?.cancel()
self.task = nil
self.fetchAndRestructureMoovAtom(offset: value.moovOffset) { result in
self.fetchAndRestructureMoovAtom(offset: moovOffset) { result in
switch result {
case let .success(value):
let data = value.data
let offset = value.offset
self.dataOptimized = true
completion(.success(RestructuredData(initialData: data, mdatOffset: offset)))
completion(.success(RestructuredData(initialData: value.data, mdatOffset: value.offset)))
case let .failure(error):
completion(.failure(Mp4RestructureError.networkError(error)))
}
}
} else {
self.audioData = Data()
self.task?.cancel()
self.task = nil
completion(.success(nil))
}
} catch {
completion(.failure(Mp4RestructureError.invalidAtomSize))
@@ -132,6 +132,8 @@ final class RemoteMp4Restructure {
}
}
// removed warmup range helper
private func urlForPartialContent(with url: URL, offset: Int) -> URLRequest {
var urlRequest = URLRequest(url: url)
urlRequest.networkServiceType = .avStreaming
@@ -25,6 +25,8 @@ public class RemoteAudioSource: AudioStreamSource {
}
private let url: URL
private let httpMethod: String?
private let httpBody: Data?
private let networkingClient: NetworkingClient
private var streamRequest: NetworkDataStream?
@@ -61,12 +63,16 @@ public class RemoteAudioSource: AudioStreamSource {
netStatusProvider: NetStatusProvider,
retrier: Retrier,
url: URL,
httpMethod: String?,
httpBody: Data?,
underlyingQueue: DispatchQueue,
httpHeaders: [String: String])
{
networkingClient = networking
metadataStreamProcessor = metadataStreamSource
self.url = url
self.httpMethod = httpMethod
self.httpBody = httpBody
additionalRequestHeaders = httpHeaders
relativePosition = 0
seekOffset = 0
@@ -83,9 +89,11 @@ public class RemoteAudioSource: AudioStreamSource {
mp4Restructure = RemoteMp4Restructure(url: url, networking: networkingClient)
startNetworkService()
}
convenience init(networking: NetworkingClient,
url: URL,
httpMethod: String?,
httpBody: Data?,
underlyingQueue: DispatchQueue,
httpHeaders: [String: String])
{
@@ -100,6 +108,21 @@ public class RemoteAudioSource: AudioStreamSource {
netStatusProvider: netStatusProvider,
retrier: retrierTimeout,
url: url,
httpMethod: httpMethod,
httpBody: httpBody,
underlyingQueue: underlyingQueue,
httpHeaders: httpHeaders)
}
convenience init(networking: NetworkingClient,
url: URL,
underlyingQueue: DispatchQueue,
httpHeaders: [String: String])
{
self.init(networking: networking,
url: url,
httpMethod: nil,
httpBody: nil,
underlyingQueue: underlyingQueue,
httpHeaders: httpHeaders)
}
@@ -347,6 +370,8 @@ public class RemoteAudioSource: AudioStreamSource {
urlRequest.networkServiceType = .avStreaming
urlRequest.cachePolicy = .reloadIgnoringLocalCacheData
urlRequest.timeoutInterval = 60
urlRequest.httpMethod = httpMethod
urlRequest.httpBody = httpBody
for header in additionalRequestHeaders {
urlRequest.addValue(header.value, forHTTPHeaderField: header.key)
@@ -366,6 +391,8 @@ public class RemoteAudioSource: AudioStreamSource {
urlRequest.networkServiceType = .avStreaming
urlRequest.cachePolicy = .reloadIgnoringLocalCacheData
urlRequest.timeoutInterval = 60
urlRequest.httpMethod = httpMethod
urlRequest.httpBody = httpBody
for header in additionalRequestHeaders {
urlRequest.addValue(header.value, forHTTPHeaderField: header.key)
@@ -81,6 +81,16 @@ open class AudioPlayer {
return entry.progress
}
/// The number of audio frames that have been played
public var framesPlayed: Int {
guard playerContext.internalState != .pendingNext else { return 0 }
playerContext.entriesLock.lock()
let playingEntry = playerContext.audioPlayingEntry
playerContext.entriesLock.unlock()
guard let entry = playingEntry else { return 0 }
return entry.framesPlayed
}
public private(set) var customAttachedNodes = [AVAudioNode]()
/// The current configuration of the player.
@@ -192,6 +202,17 @@ open class AudioPlayer {
let audioEntry = entryProvider.provideAudioEntry(url: url, headers: headers)
play(audioEntry: audioEntry)
}
/// Starts the audio playback for the given URL
///
/// - parameter url: A `URL` specifying the audio context to be played.
/// - parameter httpMethod: A `String` specifying the HTTP method to use (e.g. "GET", "POST").
/// - parameter httpBody: A "Data" specifying the HTTP request body, if any.
/// - parameter headers: A `Dictionary` specifying any additional headers to be pass to the network request.
public func play(url: URL, httpMethod: String?, httpBody: Data?, headers: [String: String]) {
let audioEntry = entryProvider.provideAudioEntry(url: url, httpMethod: httpMethod, httpBody: httpBody, headers: headers)
play(audioEntry: audioEntry)
}
/// Starts the audio playback for the supplied stream
///
@@ -630,18 +651,22 @@ open class AudioPlayer {
guard playerContext.internalState != .paused else { return }
let snapshot = playerContext.entriesLock.withLock {
(reading: playerContext.audioReadingEntry, playing: playerContext.audioPlayingEntry)
}
if playerContext.internalState == .pendingNext {
let entry = entriesQueue.dequeue(type: .upcoming)
playerContext.setInternalState(to: .waitingForData)
setCurrentReading(entry: entry, startPlaying: true, shouldClearQueue: true)
rendererContext.resetBuffers()
} else if let playingEntry = playerContext.audioPlayingEntry,
} else if let playingEntry = snapshot.playing,
playingEntry.seekRequest.requested,
playingEntry != playerContext.audioReadingEntry
playingEntry != snapshot.reading
{
playingEntry.audioStreamState.processedDataFormat = false
playingEntry.reset()
if let readingEntry = playerContext.audioReadingEntry {
if let readingEntry = snapshot.reading {
readingEntry.delegate = nil
readingEntry.close()
}
@@ -656,20 +681,20 @@ open class AudioPlayer {
setCurrentReading(entry: playingEntry, startPlaying: true, shouldClearQueue: false)
}
} else if playerContext.audioReadingEntry == nil {
} else if snapshot.reading == nil {
if entriesQueue.count(for: .upcoming) > 0 {
let entry = entriesQueue.dequeue(type: .upcoming)
let shouldStartPlaying = playerContext.audioPlayingEntry == nil
let shouldStartPlaying = snapshot.playing == nil
playerContext.setInternalState(to: .waitingForData)
setCurrentReading(entry: entry, startPlaying: shouldStartPlaying, shouldClearQueue: false)
} else if playerContext.audioPlayingEntry == nil {
} else if snapshot.playing == nil {
if playerContext.internalState != .stopped {
stopEngine(reason: .eof)
}
}
}
if let playingEntry = playerContext.audioPlayingEntry,
if let playingEntry = snapshot.playing,
playingEntry.audioStreamState.processedDataFormat,
playingEntry.calculatedBitrate() > 0.0
{
@@ -55,7 +55,7 @@ func playerStateAndStopReason(
// MARK: Public States
public enum AudioPlayerState: Equatable {
public enum AudioPlayerState: Equatable, Sendable {
case ready
case running
case playing
@@ -66,7 +66,7 @@ public enum AudioPlayerState: Equatable {
case disposed
}
public enum AudioPlayerStopReason: Equatable {
public enum AudioPlayerStopReason: Equatable, Sendable {
case none
case eof
case userAction
@@ -74,7 +74,7 @@ public enum AudioPlayerStopReason: Equatable {
case disposed
}
public enum AudioPlayerError: LocalizedError, Equatable {
public enum AudioPlayerError: LocalizedError, Equatable, Sendable {
case streamParseBytesFailure(AudioFileStreamError)
case audioSystemError(AudioSystemError)
case codecError
@@ -100,7 +100,7 @@ public enum AudioPlayerError: LocalizedError, Equatable {
}
}
public enum AudioSystemError: LocalizedError, Equatable {
public enum AudioSystemError: LocalizedError, Equatable, Sendable {
case engineFailure
case playerNotFound
case playerStartError
@@ -6,6 +6,7 @@
//
import AVFoundation
import CoreAudio
enum AudioConvertStatus: Int32 {
case done = 100
@@ -104,6 +105,8 @@ final class AudioFileStreamProcessor {
let dataLengthInBytes = Double(readingEntry.audioDataLengthBytes())
let entryDuration = readingEntry.duration()
let duration = entryDuration < readingEntry.progress && entryDuration > 0 ? readingEntry.progress : entryDuration
guard duration > 0.0 else { return }
var seekByteOffset = Int64(dataOffset + (readingEntry.seekRequest.time / duration) * dataLengthInBytes)
@@ -226,6 +229,8 @@ final class AudioFileStreamProcessor {
processDataByteCount(entry: entry, fileStream: fileStream)
case kAudioFileStreamProperty_AudioDataPacketCount:
processAudioDataPacketCount(entry: entry, fileStream: fileStream)
case kAudioFileStreamProperty_BitRate:
processBitRate(entry: entry, fileStream: fileStream)
case kAudioFileStreamProperty_ReadyToProducePackets:
// check converter for discontinuous stream
assignMagicCookieToConverterIfNeeded()
@@ -233,6 +238,8 @@ final class AudioFileStreamProcessor {
processReadyToProducePackets(entry: entry, fileStream: fileStream)
case kAudioFileStreamProperty_FormatList:
processFormatList(entry: entry, fileStream: fileStream)
case kAudioFileStreamProperty_PacketTableInfo:
processPacketTableInfo(entry: entry, fileStream: fileStream)
default:
break
}
@@ -336,28 +343,56 @@ final class AudioFileStreamProcessor {
entry.audioStreamState.dataPacketOffset = audioDataPacketCount
}
private func processFormatList(entry: AudioEntry, fileStream: AudioFileStreamID) {
private func processBitRate(entry: AudioEntry, fileStream: AudioFileStreamID) {
var bitRate: UInt32 = 0
let status = fileStreamGetProperty(value: &bitRate, fileStream: fileStream, propertyId: kAudioFileStreamProperty_BitRate)
guard status == noErr else { return }
entry.lock.lock(); defer { entry.lock.unlock() }
entry.audioStreamState.bitRate = Double(bitRate)
}
private func processPacketTableInfo(entry: AudioEntry, fileStream: AudioFileStreamID) {
var pti = AudioFilePacketTableInfo(mNumberValidFrames: 0,
mPrimingFrames: 0,
mRemainderFrames: 0)
let status = fileStreamGetProperty(value: &pti, fileStream: fileStream, propertyId: kAudioFileStreamProperty_PacketTableInfo)
guard status == noErr else { return }
// Use valid frames to refine duration if present
entry.lock.lock(); defer { entry.lock.unlock() }
if pti.mNumberValidFrames > 0 {
entry.audioStreamState.dataPacketCount = Double(pti.mNumberValidFrames) / Double(max(1, entry.audioStreamFormat.mFramesPerPacket))
}
}
private func processFormatList(entry: AudioEntry, fileStream: AudioFileStreamID) {
let info = fileStreamGetPropertyInfo(fileStream: fileStream, propertyId: kAudioFileStreamProperty_FormatList)
guard info.status == noErr else { return }
var list: [AudioFormatListItem] = Array(repeating: AudioFormatListItem(), count: Int(info.size))
var size = UInt32(info.size)
guard info.status == noErr, info.size > 0 else { return }
let itemStride = MemoryLayout<AudioFormatListItem>.stride
let itemCount = Int(info.size) / itemStride
guard itemCount > 0 else { return }
var list = [AudioFormatListItem](repeating: AudioFormatListItem(), count: itemCount)
var size = UInt32(itemCount * itemStride)
AudioFileStreamGetProperty(fileStream, kAudioFileStreamProperty_FormatList, &size, &list)
let step = MemoryLayout<AudioFormatListItem>.size
var i = 0
while i * step < size {
var chosenASBD: AudioStreamBasicDescription?
for i in 0..<itemCount {
let asbd = list[i].mASBD
let formatId = asbd.mFormatID
if formatId == kAudioFormatMPEG4AAC_HE || formatId == kAudioFormatMPEG4AAC_HE_V2 {
playerContext.audioReadingEntry?.audioStreamFormat = asbd
chosenASBD = asbd
break
}
i += step
if chosenASBD == nil {
chosenASBD = asbd
}
}
if fileFormatsForDelayedConverterCreation.contains(currentFileFormat) {
if let inputStreamFormat = playerContext.audioReadingEntry?.audioStreamFormat {
createAudioConverter(from: inputStreamFormat, to: outputAudioFormat)
if let asbd = chosenASBD {
entry.lock.withLock { entry.audioStreamFormat = asbd }
if fileFormatsForDelayedConverterCreation.contains(currentFileFormat) {
createAudioConverter(from: asbd, to: outputAudioFormat)
}
}
}