Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4b8bae96c2 | |||
| bccfc20403 | |||
| 69dc0d631c | |||
| 4d9bb98aed | |||
| 31368a54c1 | |||
| d3b563c7cd | |||
| a416cc8e92 | |||
| f36ca68faa | |||
| 2f3c7912e8 | |||
| 548d599628 | |||
| 17f532556a | |||
| 00bd6cd81b | |||
| ce2b88ac03 | |||
| 624e575980 | |||
| b89d3d953f | |||
| 4951b54ede | |||
| 2337cd3844 | |||
| f8f836125d | |||
| d24bca48a2 | |||
| 1916a0628a | |||
| 579fd26846 | |||
| ed8352ff68 | |||
| 933a22bb72 | |||
| 47d3e5bea5 | |||
| fde33feeef |
@@ -7,6 +7,7 @@
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
42BE42F52C9322AA00C0E448 /* CustomStreamSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42BE42F42C9322AA00C0E448 /* CustomStreamSource.swift */; };
|
||||
9806E8182BC5D12500757370 /* App.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9806E8172BC5D12500757370 /* App.swift */; };
|
||||
9806E81A2BC5D12500757370 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9806E8192BC5D12500757370 /* ContentView.swift */; };
|
||||
9806E81C2BC5D12700757370 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 9806E81B2BC5D12700757370 /* Assets.xcassets */; };
|
||||
@@ -24,6 +25,7 @@
|
||||
9816A8BB2BC87BC200AD1299 /* AudioPlayerService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9816A8BA2BC87BC200AD1299 /* AudioPlayerService.swift */; };
|
||||
984DE9552BDAE59C004B427A /* Notifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 984DE9542BDAE59C004B427A /* Notifier.swift */; };
|
||||
984DE9572BDAFC7E004B427A /* AudioPlayerControlsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 984DE9562BDAFC7E004B427A /* AudioPlayerControlsView.swift */; };
|
||||
989E08E72BF7A4E300599F17 /* PrefersTabNavigationEnvironmentKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = 989E08E62BF7A4E300599F17 /* PrefersTabNavigationEnvironmentKey.swift */; };
|
||||
98BFB41A2BC97AF800E812C0 /* DisplayLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98BFB4192BC97AF800E812C0 /* DisplayLink.swift */; };
|
||||
98BFB41D2BCD7BB800E812C0 /* EqualizerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98BFB41C2BCD7BB800E812C0 /* EqualizerView.swift */; };
|
||||
98BFB41F2BCD814000E812C0 /* EqualizerService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98BFB41E2BCD814000E812C0 /* EqualizerService.swift */; };
|
||||
@@ -46,6 +48,7 @@
|
||||
/* End PBXCopyFilesBuildPhase section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
42BE42F42C9322AA00C0E448 /* CustomStreamSource.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CustomStreamSource.swift; sourceTree = "<group>"; };
|
||||
9806E8142BC5D12500757370 /* AudioPlayer.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = AudioPlayer.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
9806E8172BC5D12500757370 /* App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App.swift; sourceTree = "<group>"; };
|
||||
9806E8192BC5D12500757370 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
|
||||
@@ -63,6 +66,7 @@
|
||||
9816A8BA2BC87BC200AD1299 /* AudioPlayerService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlayerService.swift; sourceTree = "<group>"; };
|
||||
984DE9542BDAE59C004B427A /* Notifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Notifier.swift; sourceTree = "<group>"; };
|
||||
984DE9562BDAFC7E004B427A /* AudioPlayerControlsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlayerControlsView.swift; sourceTree = "<group>"; };
|
||||
989E08E62BF7A4E300599F17 /* PrefersTabNavigationEnvironmentKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrefersTabNavigationEnvironmentKey.swift; sourceTree = "<group>"; };
|
||||
98BFB4192BC97AF800E812C0 /* DisplayLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisplayLink.swift; sourceTree = "<group>"; };
|
||||
98BFB41B2BCAAD8A00E812C0 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
|
||||
98BFB41C2BCD7BB800E812C0 /* EqualizerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EqualizerView.swift; sourceTree = "<group>"; };
|
||||
@@ -176,6 +180,7 @@
|
||||
children = (
|
||||
98BFB4192BC97AF800E812C0 /* DisplayLink.swift */,
|
||||
984DE9542BDAE59C004B427A /* Notifier.swift */,
|
||||
989E08E62BF7A4E300599F17 /* PrefersTabNavigationEnvironmentKey.swift */,
|
||||
);
|
||||
path = Helpers;
|
||||
sourceTree = "<group>";
|
||||
@@ -192,6 +197,7 @@
|
||||
98E3921C2BD845E100B586E9 /* AudioPlayer */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
42BE42F42C9322AA00C0E448 /* CustomStreamSource.swift */,
|
||||
9806E8302BC6927D00757370 /* AudioPlayerModel.swift */,
|
||||
9806E8292BC68F8700757370 /* AudioPlayerView.swift */,
|
||||
98BFB41C2BCD7BB800E812C0 /* EqualizerView.swift */,
|
||||
@@ -289,6 +295,8 @@
|
||||
9816A8BB2BC87BC200AD1299 /* AudioPlayerService.swift in Sources */,
|
||||
984DE9572BDAFC7E004B427A /* AudioPlayerControlsView.swift in Sources */,
|
||||
9806E8182BC5D12500757370 /* App.swift in Sources */,
|
||||
42BE42F52C9322AA00C0E448 /* CustomStreamSource.swift in Sources */,
|
||||
989E08E72BF7A4E300599F17 /* PrefersTabNavigationEnvironmentKey.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -439,6 +447,8 @@
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.decimal.AudioPlayer;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx";
|
||||
SUPPORTS_MACCATALYST = NO;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
@@ -470,6 +480,8 @@
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.decimal.AudioPlayer;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx";
|
||||
SUPPORTS_MACCATALYST = NO;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
enableThreadSanitizer = "YES"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
|
||||
@@ -41,7 +41,7 @@ func provideEqualizerService(playerService: AudioPlayerService) -> EqualizerServ
|
||||
|
||||
func provideAudioPlayerService() -> AudioPlayerService {
|
||||
AudioPlayerService(
|
||||
audioPlayer: provideDefaultAudioPlayer()
|
||||
audioPlayerProvider: provideDefaultAudioPlayer
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -25,7 +25,9 @@ struct AddNewAudioURLView: View {
|
||||
TextField(value: $audioUrl, format: urlStyle, prompt: nil, label: {
|
||||
Text("Insert URL")
|
||||
})
|
||||
#if os(iOS)
|
||||
.keyboardType(.URL)
|
||||
#endif
|
||||
.autocorrectionDisabled()
|
||||
.textFieldStyle(RoundedBorderTextFieldStyle())
|
||||
.onSubmit {
|
||||
@@ -48,6 +50,7 @@ struct AddNewAudioURLView: View {
|
||||
}
|
||||
.foregroundStyle(Color.white)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(audioUrl == nil)
|
||||
.opacity(audioUrl == nil ? 0.5 : 1.0)
|
||||
.padding(.horizontal, 16)
|
||||
@@ -56,8 +59,11 @@ struct AddNewAudioURLView: View {
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||
}
|
||||
.navigationTitle("Add Audio URL")
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
#endif
|
||||
.toolbar {
|
||||
#if os(iOS)
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button {
|
||||
dismiss()
|
||||
@@ -65,7 +71,13 @@ struct AddNewAudioURLView: View {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundStyle(Color.gray)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
#else
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Done", action: dismiss.callAsFunction)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ enum AudioContent {
|
||||
case remoteWave
|
||||
case local
|
||||
case localWave
|
||||
case loopBeatFlac
|
||||
case custom(String)
|
||||
|
||||
var title: String {
|
||||
@@ -49,6 +50,8 @@ enum AudioContent {
|
||||
return "Jazzy Frenchy"
|
||||
case .nonOptimized:
|
||||
return "Jazzy Frenchy"
|
||||
case .loopBeatFlac:
|
||||
return "Beat loop"
|
||||
case .custom(let url):
|
||||
return url
|
||||
}
|
||||
@@ -82,6 +85,8 @@ enum AudioContent {
|
||||
return "Music by: bensound.com - m4a optimized"
|
||||
case .nonOptimized:
|
||||
return "Music by: bensound.com - m4a non-optimized"
|
||||
case .loopBeatFlac:
|
||||
return "Remote flac"
|
||||
case .custom:
|
||||
return ""
|
||||
}
|
||||
@@ -117,6 +122,8 @@ enum AudioContent {
|
||||
return URL(fileURLWithPath: path)
|
||||
case .remoteWave:
|
||||
return URL(string: "https://github.com/dimitris-c/sample-audio/raw/main/5-MB-WAV.wav")!
|
||||
case .loopBeatFlac:
|
||||
return URL(string: "https://github.com/dimitris-c/sample-audio/raw/main/drumbeat-loop.flac")!
|
||||
case .custom(let url):
|
||||
return URL(string: url)!
|
||||
}
|
||||
|
||||
@@ -95,6 +95,8 @@ struct AudioTrackView: View {
|
||||
} else if track.status == .buffering {
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
.frame(alignment: .center)
|
||||
.scaleEffect(0.7)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
// Created by Dimitris Chatzieleftheriou on 26/04/2024.
|
||||
//
|
||||
|
||||
import AVFoundation
|
||||
import SwiftUI
|
||||
import AudioStreaming
|
||||
|
||||
struct AudioPlayerControls: View {
|
||||
@State var model: Model
|
||||
@@ -17,28 +19,41 @@ struct AudioPlayerControls: View {
|
||||
VStack(alignment: .leading) {
|
||||
HStack {
|
||||
Button(action: { model.playPause() }) {
|
||||
Image(systemName: model.isPlaying ? "pause.fill" : "play.fill")
|
||||
Image(systemName: model.isPlaying ? "pause" : "play")
|
||||
.symbolVariant(.fill)
|
||||
.font(.title)
|
||||
.imageScale(.small)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.contentTransition(.symbolEffect(.replace))
|
||||
Button(action: {
|
||||
model.stop()
|
||||
currentTrack = nil
|
||||
}) {
|
||||
Image(systemName: "stop.fill")
|
||||
Image(systemName: "stop")
|
||||
.symbolVariant(.fill)
|
||||
.font(.title)
|
||||
.imageScale(.small)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.leading, 8)
|
||||
Spacer()
|
||||
Button(action: { model.mute() }) {
|
||||
Image(systemName: model.isMuted ? "speaker.slash.fill" : "speaker.fill")
|
||||
.font(.title)
|
||||
.imageScale(.small)
|
||||
HStack {
|
||||
Slider(value: $model.volume)
|
||||
.frame(width: 80)
|
||||
.onChange(of: model.volume) { _, newValue in
|
||||
model.update(volume: newValue)
|
||||
}
|
||||
Button(action: { model.mute() }) {
|
||||
Image(systemName: model.iconForVolume)
|
||||
.symbolVariant(model.isMuted || model.volume == 0 ? .slash.fill : .fill)
|
||||
.foregroundStyle(.teal, .gray)
|
||||
.font(.title.monospaced())
|
||||
.imageScale(.small)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.frame(width: 20, height: 20)
|
||||
}
|
||||
.frame(width: 20, height: 20)
|
||||
.contentTransition(.symbolEffect(.replace))
|
||||
}
|
||||
.tint(.mint)
|
||||
.padding(16)
|
||||
@@ -118,6 +133,8 @@ extension AudioPlayerControls {
|
||||
var isPlaying: Bool = false
|
||||
var isMuted: Bool = false
|
||||
|
||||
var volume: Float = 0.5
|
||||
|
||||
var playbackRate: Double = 0.0
|
||||
|
||||
var currentTime: Double = 0
|
||||
@@ -130,6 +147,19 @@ extension AudioPlayerControls {
|
||||
|
||||
var currentTrack: AudioTrack?
|
||||
|
||||
var iconForVolume: String {
|
||||
if isMuted || volume == 0 {
|
||||
return "speaker"
|
||||
}
|
||||
if volume < 0.4 {
|
||||
return "speaker.wave.1"
|
||||
} else if volume < 0.8 {
|
||||
return "speaker.wave.2"
|
||||
} else {
|
||||
return "speaker.wave.3"
|
||||
}
|
||||
}
|
||||
|
||||
init(audioPlayerService: AudioPlayerService) {
|
||||
self.audioPlayerService = audioPlayerService
|
||||
|
||||
@@ -204,6 +234,10 @@ extension AudioPlayerControls {
|
||||
audioPlayerService.update(rate: rate)
|
||||
}
|
||||
|
||||
func update(volume: Float) {
|
||||
audioPlayerService.update(volume: volume)
|
||||
}
|
||||
|
||||
func stop() {
|
||||
isPlaying = false
|
||||
audioPlayerService.stop()
|
||||
@@ -214,11 +248,23 @@ extension AudioPlayerControls {
|
||||
func play(_ track: AudioTrack) {
|
||||
if track != currentTrack {
|
||||
currentTrack?.status = .idle
|
||||
audioPlayerService.play(url: track.url)
|
||||
currentTrack = track
|
||||
if track.url.scheme == "custom" {
|
||||
let source = createStreamSource()
|
||||
let audioFormat = AVAudioFormat(
|
||||
commonFormat: .pcmFormatFloat32, sampleRate: 44100, channels: 2, interleaved: false
|
||||
)!
|
||||
audioPlayerService.play(source: source, entryId: track.url.absoluteString, format: audioFormat)
|
||||
currentTrack = track
|
||||
} else {
|
||||
audioPlayerService.play(url: track.url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func createStreamSource() -> CoreAudioStreamSource {
|
||||
return CustomStreamAudioSource(underlyingQueue: audioPlayerService.player.sourceQueue)
|
||||
}
|
||||
|
||||
func onTick() {
|
||||
let duration = audioPlayerService.duration
|
||||
let progress = audioPlayerService.progress
|
||||
|
||||
@@ -3,7 +3,12 @@
|
||||
// Copyright © 2024 Decimal. All rights reserved.
|
||||
//
|
||||
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
#else
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
import Foundation
|
||||
import AudioStreaming
|
||||
|
||||
@@ -53,12 +58,14 @@ public class AudioPlayerModel {
|
||||
}
|
||||
|
||||
private let radioTracks: [AudioContent] = [.offradio, .enlefko, .pepper966, .kosmos, .kosmosJazz, .radiox]
|
||||
private let audioTracks: [AudioContent] = [.khruangbin, .piano, .optimized, .nonOptimized, .remoteWave, .local, .localWave]
|
||||
private let audioTracks: [AudioContent] = [.khruangbin, .piano, .optimized, .nonOptimized, .remoteWave, .local, .localWave, .loopBeatFlac]
|
||||
private let customStreams: [AudioContent] = [.custom("custom://sinwave")]
|
||||
|
||||
func audioTracksProvider() -> [AudioPlaylist] {
|
||||
[
|
||||
AudioPlaylist(title: "Radio", tracks: radioTracks.map { AudioTrack.init(from: $0) }),
|
||||
AudioPlaylist(title: "Tracks", tracks: audioTracks.map { AudioTrack.init(from:$0) })
|
||||
AudioPlaylist(title: "Tracks", tracks: audioTracks.map { AudioTrack.init(from:$0) }),
|
||||
AudioPlaylist(title: "Generated", tracks: customStreams.map { AudioTrack.init(from:$0) })
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -51,24 +51,37 @@ struct AudioPlayerView: View {
|
||||
}
|
||||
.ignoresSafeArea(.keyboard, edges: .bottom)
|
||||
.navigationTitle("Audio Player")
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
#endif
|
||||
.toolbar {
|
||||
ToolbarItemGroup(placement: .topBarTrailing) {
|
||||
#if os(iOS)
|
||||
let placement: ToolbarItemPlacement = .topBarTrailing
|
||||
#else
|
||||
let placement: ToolbarItemPlacement = .automatic
|
||||
#endif
|
||||
ToolbarItemGroup(placement: placement) {
|
||||
Button {
|
||||
eqSheetIsShown.toggle()
|
||||
} label: {
|
||||
Image(systemName: "slider.horizontal.3")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
Button {
|
||||
addNewAudioIsShown.toggle()
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $eqSheetIsShown) {
|
||||
EqualizerView(appModel: appModel)
|
||||
#if os(iOS)
|
||||
.presentationDetents([.medium])
|
||||
#elseif os(macOS)
|
||||
.frame(minWidth: 520, maxWidth: .infinity, minHeight: 400, maxHeight: .infinity)
|
||||
#endif
|
||||
}
|
||||
.sheet(isPresented: $addNewAudioIsShown) {
|
||||
AddNewAudioURLView(
|
||||
@@ -76,7 +89,11 @@ struct AudioPlayerView: View {
|
||||
model.addNewAudioTrack(url: url)
|
||||
}
|
||||
)
|
||||
#if os(iOS)
|
||||
.presentationDetents([.height(150)])
|
||||
#elseif os(macOS)
|
||||
.frame(minWidth: 320, maxWidth: .infinity, minHeight: 140, maxHeight: .infinity)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
//
|
||||
// CustomStreamSource.swift
|
||||
// AudioPlayer
|
||||
//
|
||||
// Created by Jackson Harper on 12/9/24.
|
||||
//
|
||||
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
|
||||
import AudioStreaming
|
||||
|
||||
// This is a basic example of playing a custom audio stream. We generate
|
||||
// a small audio data on load and then pass it off to AudioStreaming.
|
||||
final class CustomStreamAudioSource: NSObject, CoreAudioStreamSource {
|
||||
weak var delegate: AudioStreamSourceDelegate?
|
||||
|
||||
var underlyingQueue: DispatchQueue
|
||||
|
||||
var position = 0
|
||||
var length = 0
|
||||
|
||||
var audioFileHint: AudioFileTypeID {
|
||||
kAudioFileWAVEType
|
||||
}
|
||||
|
||||
init(underlyingQueue: DispatchQueue) {
|
||||
self.underlyingQueue = underlyingQueue
|
||||
}
|
||||
|
||||
// no-op
|
||||
func close() {}
|
||||
|
||||
// no-op
|
||||
func suspend() {}
|
||||
|
||||
func resume() {}
|
||||
|
||||
func seek(at _: Int) {
|
||||
// The streaming process is started by a seek(0) call from AudioStreaming
|
||||
generateData()
|
||||
}
|
||||
|
||||
private func generateData() {
|
||||
let frequency = 440.0
|
||||
let sampleRate = 44100
|
||||
let duration = 20.0
|
||||
|
||||
let lpcmData = generateSineWave(frequency: frequency, sampleRate: sampleRate, duration: duration)
|
||||
let waveFile = createWavFile(using: lpcmData)
|
||||
|
||||
// We enqueue this because during startup the seek call will be made, but the player
|
||||
// is not completely setup and ready to handle data yet, as its expected to be
|
||||
// generated asyncronously.
|
||||
underlyingQueue.asyncAfter(deadline: .now().advanced(by: .milliseconds(100))) {
|
||||
self.delegate?.dataAvailable(source: self, data: waveFile)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Functions for generating some sample data
|
||||
|
||||
// Function to generate a sine wave as Data
|
||||
func generateSineWave(frequency: Double, sampleRate: Int, duration: Double, amplitude: Double = 0.5) -> Data {
|
||||
let numberOfSamples = Int(Double(sampleRate) * duration)
|
||||
let twoPi = 2.0 * Double.pi
|
||||
var lpcmData = Data()
|
||||
|
||||
for sampleIndex in 0 ..< numberOfSamples {
|
||||
let time = Double(sampleIndex) / Double(sampleRate)
|
||||
let sampleValue = amplitude * sin(twoPi * frequency * time)
|
||||
|
||||
let pcmValue = Int16(sampleValue * Double(Int16.max))
|
||||
withUnsafeBytes(of: pcmValue.littleEndian) { lpcmData.append(contentsOf: $0) }
|
||||
}
|
||||
|
||||
return lpcmData
|
||||
}
|
||||
|
||||
func createWavFile(using rawData: Data) -> Data {
|
||||
let waveHeaderFormate = createWaveHeader(data: rawData) as Data
|
||||
let waveFileData = waveHeaderFormate + rawData
|
||||
return waveFileData
|
||||
}
|
||||
|
||||
// from: https://stackoverflow.com/questions/49399823/in-ios-how-to-create-audio-file-wav-mp3-file-from-data
|
||||
private func createWaveHeader(data: Data) -> NSData {
|
||||
let sampleRate: Int32 = 44100
|
||||
let chunkSize: Int32 = 36 + Int32(data.count)
|
||||
let subChunkSize: Int32 = 16
|
||||
let format: Int16 = 1
|
||||
let channels: Int16 = 2
|
||||
let bitsPerSample: Int16 = 16
|
||||
let byteRate: Int32 = sampleRate * Int32(channels * bitsPerSample / 8)
|
||||
let blockAlign: Int16 = channels * bitsPerSample / 8
|
||||
let dataSize = Int32(data.count)
|
||||
|
||||
let header = NSMutableData()
|
||||
|
||||
header.append([UInt8]("RIFF".utf8), length: 4)
|
||||
header.append(intToByteArray(chunkSize), length: 4)
|
||||
|
||||
// WAVE
|
||||
header.append([UInt8]("WAVE".utf8), length: 4)
|
||||
|
||||
// FMT
|
||||
header.append([UInt8]("fmt ".utf8), length: 4)
|
||||
|
||||
header.append(intToByteArray(subChunkSize), length: 4)
|
||||
header.append(shortToByteArray(format), length: 2)
|
||||
header.append(shortToByteArray(channels), length: 2)
|
||||
header.append(intToByteArray(sampleRate), length: 4)
|
||||
header.append(intToByteArray(byteRate), length: 4)
|
||||
header.append(shortToByteArray(blockAlign), length: 2)
|
||||
header.append(shortToByteArray(bitsPerSample), length: 2)
|
||||
|
||||
header.append([UInt8]("data".utf8), length: 4)
|
||||
header.append(intToByteArray(dataSize), length: 4)
|
||||
|
||||
return header
|
||||
}
|
||||
|
||||
private func intToByteArray(_ i: Int32) -> [UInt8] {
|
||||
return [
|
||||
// little endian
|
||||
UInt8(truncatingIfNeeded: i & 0xFF),
|
||||
UInt8(truncatingIfNeeded: (i >> 8) & 0xFF),
|
||||
UInt8(truncatingIfNeeded: (i >> 16) & 0xFF),
|
||||
UInt8(truncatingIfNeeded: (i >> 24) & 0xFF),
|
||||
]
|
||||
}
|
||||
|
||||
private func shortToByteArray(_ i: Int16) -> [UInt8] {
|
||||
return [
|
||||
// little endian
|
||||
UInt8(truncatingIfNeeded: i & 0xFF),
|
||||
UInt8(truncatingIfNeeded: (i >> 8) & 0xFF),
|
||||
]
|
||||
}
|
||||
@@ -85,8 +85,11 @@ struct EqualizerView: View {
|
||||
}
|
||||
}
|
||||
.navigationTitle("Equalizer")
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
#endif
|
||||
.toolbar {
|
||||
#if os(iOS)
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button {
|
||||
dismiss()
|
||||
@@ -95,6 +98,11 @@ struct EqualizerView: View {
|
||||
.foregroundStyle(Color.gray)
|
||||
}
|
||||
}
|
||||
#else
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Done", action: dismiss.callAsFunction)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ protocol AudioPlayerServiceDelegate: AnyObject {
|
||||
final class AudioPlayerService {
|
||||
weak var delegate: AudioPlayerServiceDelegate?
|
||||
|
||||
private var player: AudioPlayer
|
||||
var player: AudioPlayer
|
||||
private var audioSystemResetObserver: Any?
|
||||
|
||||
var duration: Double {
|
||||
@@ -44,8 +44,11 @@ final class AudioPlayerService {
|
||||
var metadataReceivedNotifier = Notifier<[String: String]>()
|
||||
var playingStartedStopped = Notifier<(started: Bool, AudioEntryId, AudioPlayerStopReason?)>()
|
||||
|
||||
init(audioPlayer: AudioPlayer) {
|
||||
player = audioPlayer
|
||||
private let audioPlayerProvider: () -> AudioPlayer
|
||||
|
||||
init(audioPlayerProvider: @escaping () -> AudioPlayer) {
|
||||
self.audioPlayerProvider = audioPlayerProvider
|
||||
player = audioPlayerProvider()
|
||||
player.delegate = self
|
||||
|
||||
configureAudioSession()
|
||||
@@ -57,6 +60,11 @@ final class AudioPlayerService {
|
||||
player.play(url: url)
|
||||
}
|
||||
|
||||
func play(source: CoreAudioStreamSource, entryId: String, format: AVAudioFormat) {
|
||||
activateAudioSession()
|
||||
player.play(source: source, entryId: entryId, format: format)
|
||||
}
|
||||
|
||||
func queue(url: URL) {
|
||||
activateAudioSession()
|
||||
player.queue(url: url)
|
||||
@@ -83,6 +91,10 @@ final class AudioPlayerService {
|
||||
player.rate = rate
|
||||
}
|
||||
|
||||
func update(volume: Float) {
|
||||
player.volume = volume
|
||||
}
|
||||
|
||||
func add(_ node: AVAudioNode) {
|
||||
player.attach(node: node)
|
||||
}
|
||||
@@ -104,12 +116,13 @@ final class AudioPlayerService {
|
||||
}
|
||||
|
||||
private func recreatePlayer() {
|
||||
player = AudioPlayer(configuration: .init(enableLogs: true))
|
||||
player = audioPlayerProvider()
|
||||
player.delegate = self
|
||||
}
|
||||
|
||||
private func registerSessionEvents() {
|
||||
// Note that a real app might need to observer other AVAudioSession notifications as well
|
||||
#if os(iOS)
|
||||
audioSystemResetObserver = NotificationCenter.default.addObserver(
|
||||
forName: AVAudioSession.mediaServicesWereResetNotification,
|
||||
object: nil,
|
||||
@@ -118,9 +131,11 @@ final class AudioPlayerService {
|
||||
self.configureAudioSession()
|
||||
self.recreatePlayer()
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private func configureAudioSession() {
|
||||
#if os(iOS)
|
||||
do {
|
||||
print("AudioSession category is AVAudioSessionCategoryPlayback")
|
||||
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, policy: .longFormAudio, options: [])
|
||||
@@ -128,9 +143,11 @@ final class AudioPlayerService {
|
||||
} catch let error as NSError {
|
||||
print("Couldn't setup audio session category to Playback \(error.localizedDescription)")
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private func activateAudioSession() {
|
||||
#if os(iOS)
|
||||
do {
|
||||
print("AudioSession is active")
|
||||
try AVAudioSession.sharedInstance().setActive(true, options: [])
|
||||
@@ -138,15 +155,18 @@ final class AudioPlayerService {
|
||||
} catch let error as NSError {
|
||||
print("Couldn't set audio session to active: \(error.localizedDescription)")
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private func deactivateAudioSession() {
|
||||
#if os(iOS)
|
||||
do {
|
||||
print("AudioSession is deactivated")
|
||||
try AVAudioSession.sharedInstance().setActive(false)
|
||||
} catch let error as NSError {
|
||||
print("Couldn't deactivate audio session: \(error.localizedDescription)")
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,4 +207,3 @@ extension AudioPlayerService: AudioPlayerDelegate {
|
||||
delegate?.metadataReceived(metadata: metadata)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
//
|
||||
// Created by Dimitris C.
|
||||
// Copyright © 2024 Decimal. All rights reserved.
|
||||
//
|
||||
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
#else
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
final class DisplayLink {
|
||||
|
||||
private var displayLink: CADisplayLink?
|
||||
private var target = DisplayLinkTarget()
|
||||
private var displayLink: DisplayLinkPlatform?
|
||||
|
||||
var isPaused: Bool = true {
|
||||
didSet {
|
||||
@@ -16,34 +15,124 @@ final class DisplayLink {
|
||||
}
|
||||
}
|
||||
|
||||
init(onTick: @escaping (CADisplayLink) -> Void) {
|
||||
target.onTick = onTick
|
||||
init(onTick: @escaping (DisplayLinkFrame) -> Void) {
|
||||
displayLink = DisplayLinkPlatform()
|
||||
|
||||
displayLink?.onTick = onTick
|
||||
}
|
||||
|
||||
deinit {
|
||||
deactivate()
|
||||
}
|
||||
|
||||
func activate() {
|
||||
displayLink?.activate()
|
||||
self.isPaused = false
|
||||
}
|
||||
|
||||
func deactivate() {
|
||||
displayLink?.deactivate()
|
||||
isPaused = true
|
||||
}
|
||||
}
|
||||
|
||||
struct DisplayLinkFrame {
|
||||
var timestamp: TimeInterval
|
||||
var duration: TimeInterval
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
final class DisplayLinkPlatform {
|
||||
private final class DisplayLinkTarget {
|
||||
var onTick: ((DisplayLinkFrame) -> Void)?
|
||||
|
||||
@objc func tick(_ link: CADisplayLink) {
|
||||
onTick?(DisplayLinkFrame(timestamp: link.timestamp, duration: link.duration))
|
||||
}
|
||||
}
|
||||
|
||||
var onTick: ((DisplayLinkFrame) -> Void)?
|
||||
private var target = DisplayLinkTarget()
|
||||
var displayLink: CADisplayLink?
|
||||
|
||||
var isPaused: Bool {
|
||||
get { displayLink?.isPaused ?? false }
|
||||
set { displayLink?.isPaused = newValue }
|
||||
}
|
||||
|
||||
init() {
|
||||
displayLink = CADisplayLink(target: target, selector: #selector(DisplayLinkTarget.tick(_:)))
|
||||
target.onTick = { [weak self] value in
|
||||
self?.onTick?(value)
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
displayLink?.invalidate()
|
||||
}
|
||||
|
||||
func activate() {
|
||||
displayLink?.invalidate()
|
||||
displayLink = nil
|
||||
displayLink = CADisplayLink(target: target, selector: #selector(DisplayLinkTarget.tick(_:)))
|
||||
displayLink?.preferredFrameRateRange = .init(minimum: 6, maximum: 10)
|
||||
displayLink?.add(to: .current, forMode: .common)
|
||||
self.isPaused = false
|
||||
}
|
||||
|
||||
func deactivate() {
|
||||
isPaused = true
|
||||
displayLink?.invalidate()
|
||||
displayLink = nil
|
||||
}
|
||||
}
|
||||
#else
|
||||
final class DisplayLinkPlatform {
|
||||
|
||||
private final class DisplayLinkTarget {
|
||||
var onTick: ((CADisplayLink) -> Void)?
|
||||
var onTick: ((DisplayLinkFrame) -> Void)?
|
||||
var isPaused: Bool = true {
|
||||
didSet {
|
||||
guard isPaused != oldValue else { return }
|
||||
if isPaused == true {
|
||||
CVDisplayLinkStop(self.displayLink)
|
||||
} else {
|
||||
CVDisplayLinkStart(self.displayLink)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc func tick(_ link: CADisplayLink) {
|
||||
onTick?(link)
|
||||
/// The CVDisplayLink that powers this DisplayLink instance.
|
||||
var displayLink: CVDisplayLink = {
|
||||
var dl: CVDisplayLink? = nil
|
||||
CVDisplayLinkCreateWithActiveCGDisplays(&dl)
|
||||
return dl!
|
||||
}()
|
||||
|
||||
init() {
|
||||
CVDisplayLinkSetOutputHandler(self.displayLink, { [weak self] (displayLink, inNow, inOutputTime, flageIn, flagsOut) -> CVReturn in
|
||||
let frame = DisplayLinkFrame(
|
||||
timestamp: inNow.pointee.timeInterval,
|
||||
duration: inOutputTime.pointee.timeInterval - inNow.pointee.timeInterval)
|
||||
|
||||
DispatchQueue.main.async {
|
||||
guard self?.isPaused == false else { return }
|
||||
self?.onTick?(frame)
|
||||
}
|
||||
|
||||
return kCVReturnSuccess
|
||||
})
|
||||
}
|
||||
|
||||
func activate() {
|
||||
isPaused = true
|
||||
}
|
||||
|
||||
func deactivate() {
|
||||
isPaused = false
|
||||
}
|
||||
}
|
||||
|
||||
extension CVTimeStamp {
|
||||
fileprivate var timeInterval: TimeInterval {
|
||||
return TimeInterval(videoTime) / TimeInterval(self.videoTimeScale)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import SwiftUI
|
||||
|
||||
struct PrefersStackNavigationEnvironmentKey: EnvironmentKey {
|
||||
static var defaultValue: Bool = false
|
||||
}
|
||||
|
||||
extension EnvironmentValues {
|
||||
var prefersStackNavigation: Bool {
|
||||
get { self[PrefersStackNavigationEnvironmentKey.self] }
|
||||
set { self[PrefersStackNavigationEnvironmentKey.self] = newValue }
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
extension PrefersStackNavigationEnvironmentKey: UITraitBridgedEnvironmentKey {
|
||||
static func read(from traitCollection: UITraitCollection) -> Bool {
|
||||
return traitCollection.userInterfaceIdiom == .phone || traitCollection.userInterfaceIdiom == .tv
|
||||
}
|
||||
|
||||
static func write(to mutableTraits: inout UIMutableTraits, value: Bool) {
|
||||
// Do not write.
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -1,4 +1,4 @@
|
||||
//
|
||||
//
|
||||
// Created by Dimitris C.
|
||||
// Copyright © 2024 Decimal. All rights reserved.
|
||||
//
|
||||
@@ -8,28 +8,28 @@ import SwiftUI
|
||||
struct ContentView: View {
|
||||
|
||||
@Environment(AppModel.self) var appModel
|
||||
@Environment(\.prefersStackNavigation) private var prefersStackNavigation
|
||||
|
||||
@State private var selection: NavigationContent?
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
NavigationLink(value: NavigationContent.audioPlayer) {
|
||||
Label("Audio Player", systemImage: "play")
|
||||
}
|
||||
|
||||
NavigationLink(value: NavigationContent.audioQueue) {
|
||||
Label("Audio Queue", systemImage: "play.square.stack")
|
||||
if prefersStackNavigation {
|
||||
NavigationStack {
|
||||
ContentSidebar(selection: $selection)
|
||||
.navigationTitle("Home")
|
||||
}
|
||||
} else {
|
||||
NavigationSplitView {
|
||||
ContentSidebar(selection: $selection)
|
||||
.navigationTitle("Home")
|
||||
} detail: {
|
||||
if let selection {
|
||||
DetailView(selection: selection)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Home")
|
||||
.navigationDestination(for: NavigationContent.self) { content in
|
||||
DetailView(selection: content)
|
||||
.onAppear {
|
||||
selection = .audioPlayer
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ContentView()
|
||||
}
|
||||
|
||||
@@ -24,6 +24,9 @@ struct ContentSidebar: View {
|
||||
}
|
||||
}
|
||||
.navigationTitle("Home")
|
||||
.navigationDestination(item: $selection, destination: { selection in
|
||||
DetailView(selection: selection)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
Pod::Spec.new do |s|
|
||||
s.name = 'AudioStreaming'
|
||||
s.version = '1.2.1'
|
||||
s.license = 'MIT'
|
||||
s.summary = 'An AudioPlayer/Streaming library for iOS written in Swift using AVAudioEngine.'
|
||||
s.homepage = 'https://github.com/dimitris-c/AudioStreaming'
|
||||
s.authors = { 'Dimitris C.' => 'dimmdesign@gmail.com' }
|
||||
s.source = { :git => 'https://github.com/dimitris-c/AudioStreaming.git', :tag => s.version }
|
||||
|
||||
s.ios.deployment_target = '13.0'
|
||||
|
||||
s.swift_versions = ['5.1', '5.2', '5.3']
|
||||
|
||||
s.source_files = 'AudioStreaming/**/*.swift'
|
||||
|
||||
s.pod_target_xcconfig = {
|
||||
'SWIFT_INSTALL_OBJC_HEADER' => 'NO'
|
||||
}
|
||||
end
|
||||
@@ -3,7 +3,7 @@
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 52;
|
||||
objectVersion = 55;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
@@ -54,7 +54,7 @@
|
||||
B59D0B6F255C904900D6CCE5 /* FileAudioSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = B59D0B6E255C904900D6CCE5 /* FileAudioSource.swift */; };
|
||||
B59DF10424916FD50043C498 /* DispatchQueue+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = B59DF10324916FD50043C498 /* DispatchQueue+Helpers.swift */; };
|
||||
B59DF1A32493E90C0043C498 /* AudioFileStream+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = B59DF1A22493E90C0043C498 /* AudioFileStream+Helpers.swift */; };
|
||||
B5AEDBB824744153007D8101 /* AudioStreaming.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B5AEDBAE24744153007D8101 /* AudioStreaming.framework */; };
|
||||
B5AEDBB824744153007D8101 /* AudioStreaming.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B5AEDBAE24744153007D8101 /* AudioStreaming.framework */; platformFilters = (ios, tvos, ); };
|
||||
B5AEDBBF24744153007D8101 /* AudioStreaming.h in Headers */ = {isa = PBXBuildFile; fileRef = B5AEDBB124744153007D8101 /* AudioStreaming.h */; settings = {ATTRIBUTES = (Public, ); }; };
|
||||
B5B36E432655A32200DC96F5 /* FrameFilterProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5B36E422655A32200DC96F5 /* FrameFilterProcessor.swift */; };
|
||||
B5B3B7CC248647ED00656828 /* AudioPlayerState.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5B3B7CB248647ED00656828 /* AudioPlayerState.swift */; };
|
||||
@@ -528,8 +528,9 @@
|
||||
B5AEDBA524744153007D8101 /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = YES;
|
||||
LastSwiftUpdateCheck = 1140;
|
||||
LastUpgradeCheck = 1200;
|
||||
LastUpgradeCheck = 1620;
|
||||
ORGANIZATIONNAME = Decimal;
|
||||
TargetAttributes = {
|
||||
B5AEDBAD24744153007D8101 = {
|
||||
@@ -587,6 +588,7 @@
|
||||
/* Begin PBXShellScriptBuildPhase section */
|
||||
B583864B2545858E0087A712 /* SwiftLint */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
alwaysOutOfDate = 1;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
@@ -683,6 +685,10 @@
|
||||
/* Begin PBXTargetDependency section */
|
||||
B5AEDBBA24744153007D8101 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
platformFilters = (
|
||||
ios,
|
||||
tvos,
|
||||
);
|
||||
target = B5AEDBAD24744153007D8101 /* AudioStreaming */;
|
||||
targetProxy = B5AEDBB924744153007D8101 /* PBXContainerItemProxy */;
|
||||
};
|
||||
@@ -727,6 +733,7 @@
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
GCC_DYNAMIC_NO_PIC = NO;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
@@ -742,7 +749,8 @@
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
MARKETING_VERSION = 1.1.0;
|
||||
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
||||
MARKETING_VERSION = 1.2.8;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
@@ -792,6 +800,7 @@
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
@@ -801,7 +810,8 @@
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
MARKETING_VERSION = 1.1.0;
|
||||
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
||||
MARKETING_VERSION = 1.2.8;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
MTL_FAST_MATH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
@@ -817,12 +827,14 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_IDENTITY = "";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 2;
|
||||
DEFINES_MODULE = YES;
|
||||
DYLIB_COMPATIBILITY_VERSION = 1;
|
||||
DYLIB_CURRENT_VERSION = 1;
|
||||
DYLIB_INSTALL_NAME_BASE = "@rpath";
|
||||
ENABLE_MODULE_VERIFIER = YES;
|
||||
INFOPLIST_FILE = AudioStreaming/Info.plist;
|
||||
INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
@@ -831,16 +843,18 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@loader_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.2.1;
|
||||
MARKETING_VERSION = 1.2.8;
|
||||
MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu11 gnu++14";
|
||||
OTHER_LDFLAGS = "-ObjC";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.decimal.AudioStreaming;
|
||||
PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)";
|
||||
SKIP_INSTALL = YES;
|
||||
SUPPORTED_PLATFORMS = "appletvos appletvsimulator iphoneos iphonesimulator macosx";
|
||||
SUPPORTS_MACCATALYST = NO;
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
TARGETED_DEVICE_FAMILY = "1,2,3";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
@@ -848,12 +862,14 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_IDENTITY = "";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 2;
|
||||
DEFINES_MODULE = YES;
|
||||
DYLIB_COMPATIBILITY_VERSION = 1;
|
||||
DYLIB_CURRENT_VERSION = 1;
|
||||
DYLIB_INSTALL_NAME_BASE = "@rpath";
|
||||
ENABLE_MODULE_VERIFIER = YES;
|
||||
INFOPLIST_FILE = AudioStreaming/Info.plist;
|
||||
INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
@@ -862,22 +878,23 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@loader_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.2.1;
|
||||
MARKETING_VERSION = 1.2.8;
|
||||
MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu11 gnu++14";
|
||||
OTHER_LDFLAGS = "-ObjC";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.decimal.AudioStreaming;
|
||||
PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)";
|
||||
SKIP_INSTALL = YES;
|
||||
SUPPORTED_PLATFORMS = "appletvos appletvsimulator iphoneos iphonesimulator macosx";
|
||||
SUPPORTS_MACCATALYST = NO;
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
TARGETED_DEVICE_FAMILY = "1,2,3";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
B5AEDBC624744153007D8101 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
INFOPLIST_FILE = AudioStreamingTests/Info.plist;
|
||||
@@ -889,16 +906,17 @@
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.decimal.AudioStreamingTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SUPPORTED_PLATFORMS = "appletvos appletvsimulator iphoneos iphonesimulator";
|
||||
SUPPORTS_MACCATALYST = YES;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
TARGETED_DEVICE_FAMILY = "1,2,3";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
B5AEDBC724744153007D8101 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
INFOPLIST_FILE = AudioStreamingTests/Info.plist;
|
||||
@@ -910,8 +928,10 @@
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.decimal.AudioStreamingTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SUPPORTED_PLATFORMS = "appletvos appletvsimulator iphoneos iphonesimulator";
|
||||
SUPPORTS_MACCATALYST = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
TARGETED_DEVICE_FAMILY = "1,2,3";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1200"
|
||||
LastUpgradeVersion = "1620"
|
||||
version = "1.3">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
protocol Lock {
|
||||
func lock()
|
||||
@@ -14,24 +15,96 @@ protocol Lock {
|
||||
|
||||
// Execute a closure while acquiring a lock
|
||||
func withLock(body: () -> Void)
|
||||
|
||||
func deallocate()
|
||||
}
|
||||
|
||||
/// A wrapper for `os_unfair_lock`
|
||||
/// - Tag: UnfairLock
|
||||
final class UnfairLock: Lock {
|
||||
@usableFromInline let unfairLock: UnsafeMutablePointer<os_unfair_lock>
|
||||
|
||||
var unfairLock: Lock
|
||||
|
||||
init() {
|
||||
if #available(iOS 16.0, *), #available(macOS 13.0, *) {
|
||||
unfairLock = OSStorageLock()
|
||||
} else {
|
||||
unfairLock = UnfairStorageLock()
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
deallocate()
|
||||
}
|
||||
|
||||
func deallocate() {
|
||||
unfairLock.deallocate()
|
||||
}
|
||||
|
||||
@inlinable
|
||||
func withLock<Result>(body: () throws -> Result) rethrows -> Result {
|
||||
try unfairLock.withLock(body: body)
|
||||
}
|
||||
|
||||
@inlinable
|
||||
func withLock(body: () -> Void) {
|
||||
unfairLock.withLock(body: body)
|
||||
}
|
||||
|
||||
@inlinable
|
||||
func lock() {
|
||||
unfairLock.lock()
|
||||
}
|
||||
|
||||
@inlinable
|
||||
func unlock() {
|
||||
unfairLock.unlock()
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 16.0, *)
|
||||
@available(macOS 13, *)
|
||||
private class OSStorageLock: Lock {
|
||||
@usableFromInline
|
||||
let osLock = OSAllocatedUnfairLock()
|
||||
|
||||
@inlinable
|
||||
func lock() {
|
||||
osLock.lock()
|
||||
}
|
||||
|
||||
@inlinable
|
||||
func unlock() {
|
||||
osLock.unlock()
|
||||
}
|
||||
|
||||
func withLock<Result>(body: () throws -> Result) rethrows -> Result {
|
||||
try osLock.withLockUnchecked(body)
|
||||
}
|
||||
|
||||
func withLock(body: () -> Void) {
|
||||
osLock.withLockUnchecked(body)
|
||||
}
|
||||
|
||||
func deallocate() {} // no-op
|
||||
}
|
||||
|
||||
private class UnfairStorageLock: Lock {
|
||||
|
||||
@usableFromInline
|
||||
let unfairLock: UnsafeMutablePointer<os_unfair_lock>
|
||||
|
||||
init() {
|
||||
unfairLock = .allocate(capacity: 1)
|
||||
unfairLock.initialize(to: os_unfair_lock())
|
||||
}
|
||||
|
||||
deinit {
|
||||
func deallocate() {
|
||||
unfairLock.deinitialize(count: 1)
|
||||
unfairLock.deallocate()
|
||||
}
|
||||
|
||||
@inlinable
|
||||
@inline(__always)
|
||||
func withLock<Result>(body: () throws -> Result) rethrows -> Result {
|
||||
os_unfair_lock_lock(unfairLock)
|
||||
defer { os_unfair_lock_unlock(unfairLock) }
|
||||
@@ -39,7 +112,6 @@ final class UnfairLock: Lock {
|
||||
}
|
||||
|
||||
@inlinable
|
||||
@inline(__always)
|
||||
func withLock(body: () -> Void) {
|
||||
os_unfair_lock_lock(unfairLock)
|
||||
defer { os_unfair_lock_unlock(unfairLock) }
|
||||
@@ -47,13 +119,11 @@ final class UnfairLock: Lock {
|
||||
}
|
||||
|
||||
@inlinable
|
||||
@inline(__always)
|
||||
func lock() {
|
||||
os_unfair_lock_lock(unfairLock)
|
||||
}
|
||||
|
||||
@inlinable
|
||||
@inline(__always)
|
||||
func unlock() {
|
||||
os_unfair_lock_unlock(unfairLock)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -121,15 +129,21 @@ class AudioEntry {
|
||||
}
|
||||
|
||||
func duration() -> Double {
|
||||
guard sampleRate > 0 else { return 0 }
|
||||
lock.lock()
|
||||
guard sampleRate > 0 else {
|
||||
lock.unlock()
|
||||
return 0
|
||||
}
|
||||
|
||||
if let audioDataPacketOffset = audioStreamState.dataPacketOffset {
|
||||
let framesPerPacket = UInt64(audioStreamFormat.mFramesPerPacket)
|
||||
if audioDataPacketOffset > 0, framesPerPacket > 0 {
|
||||
return Double(audioDataPacketOffset * framesPerPacket) / audioStreamFormat.mSampleRate
|
||||
let duration = Double(audioDataPacketOffset * framesPerPacket) / audioStreamFormat.mSampleRate
|
||||
lock.unlock()
|
||||
return duration
|
||||
}
|
||||
}
|
||||
|
||||
lock.unlock()
|
||||
let calculatedBitrate = self.calculatedBitrate()
|
||||
if calculatedBitrate < 1.0 || source.length == 0 {
|
||||
return 0
|
||||
|
||||
@@ -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?
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import AudioToolbox
|
||||
import Foundation
|
||||
|
||||
protocol AudioStreamSourceDelegate: AnyObject {
|
||||
public protocol AudioStreamSourceDelegate: AnyObject {
|
||||
/// Indicates that there's data available
|
||||
func dataAvailable(source: CoreAudioStreamSource, data: Data)
|
||||
/// Indicates an error occurred
|
||||
@@ -17,7 +17,7 @@ protocol AudioStreamSourceDelegate: AnyObject {
|
||||
func metadataReceived(data: [String: String])
|
||||
}
|
||||
|
||||
protocol CoreAudioStreamSource: AnyObject {
|
||||
public protocol CoreAudioStreamSource: AnyObject {
|
||||
/// An `Int` that represents the position of the audio
|
||||
var position: Int { get }
|
||||
/// The length of the audio in bytes
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -13,18 +13,20 @@ enum RemoteAudioSourceError: Error {
|
||||
}
|
||||
|
||||
public class RemoteAudioSource: AudioStreamSource {
|
||||
weak var delegate: AudioStreamSourceDelegate?
|
||||
public weak var delegate: AudioStreamSourceDelegate?
|
||||
|
||||
var position: Int {
|
||||
public var position: Int {
|
||||
return seekOffset + relativePosition
|
||||
}
|
||||
|
||||
var length: Int {
|
||||
public var length: Int {
|
||||
guard let parsedHeader = parsedHeaderOutput else { return 0 }
|
||||
return parsedHeader.fileLength
|
||||
}
|
||||
|
||||
private let url: URL
|
||||
private let httpMethod: String?
|
||||
private let httpBody: Data?
|
||||
private let networkingClient: NetworkingClient
|
||||
private var streamRequest: NetworkDataStream?
|
||||
|
||||
@@ -40,7 +42,7 @@ public class RemoteAudioSource: AudioStreamSource {
|
||||
private var shouldTryParsingIcycastHeaders: Bool = false
|
||||
private let icycastHeadersProcessor: IcycastHeadersProcessor
|
||||
|
||||
var audioFileHint: AudioFileTypeID {
|
||||
public var audioFileHint: AudioFileTypeID {
|
||||
guard let output = parsedHeaderOutput, output.typeId != 0 else {
|
||||
return audioFileType(fileExtension: url.pathExtension)
|
||||
}
|
||||
@@ -49,7 +51,7 @@ public class RemoteAudioSource: AudioStreamSource {
|
||||
|
||||
private let mp4Restructure: RemoteMp4Restructure
|
||||
|
||||
let underlyingQueue: DispatchQueue
|
||||
public let underlyingQueue: DispatchQueue
|
||||
let streamOperationQueue: OperationQueue
|
||||
let netStatusService: NetStatusProvider
|
||||
var waitingForNetwork = false
|
||||
@@ -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)
|
||||
}
|
||||
@@ -114,7 +137,7 @@ public class RemoteAudioSource: AudioStreamSource {
|
||||
httpHeaders: [:])
|
||||
}
|
||||
|
||||
func close() {
|
||||
public func close() {
|
||||
retrierTimeout.cancel()
|
||||
streamOperationQueue.isSuspended = false
|
||||
streamOperationQueue.cancelAllOperations()
|
||||
@@ -125,7 +148,7 @@ public class RemoteAudioSource: AudioStreamSource {
|
||||
streamRequest = nil
|
||||
}
|
||||
|
||||
func seek(at offset: Int) {
|
||||
public func seek(at offset: Int) {
|
||||
close()
|
||||
|
||||
relativePosition = 0
|
||||
@@ -144,11 +167,11 @@ public class RemoteAudioSource: AudioStreamSource {
|
||||
performOpen(seek: offset)
|
||||
}
|
||||
|
||||
func suspend() {
|
||||
public func suspend() {
|
||||
streamOperationQueue.isSuspended = true
|
||||
}
|
||||
|
||||
func resume() {
|
||||
public func resume() {
|
||||
streamOperationQueue.isSuspended = false
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
@@ -124,7 +134,7 @@ open class AudioPlayer {
|
||||
private let frameFilterProcessor: FrameFilterProcessor
|
||||
|
||||
private let serializationQueue: DispatchQueue
|
||||
private let sourceQueue: DispatchQueue
|
||||
public let sourceQueue: DispatchQueue
|
||||
|
||||
private let entryProvider: AudioEntryProviding
|
||||
|
||||
@@ -190,6 +200,31 @@ open class AudioPlayer {
|
||||
/// - parameter headers: A `Dictionary` specifying any additional headers to be pass to the network request.
|
||||
public func play(url: URL, headers: [String: String]) {
|
||||
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
|
||||
///
|
||||
/// - parameter source: A `CoreAudioStreamSource` that will providing streaming data
|
||||
/// - parameter entryId: A `String` that provides a unique id for this item
|
||||
/// - parameter format: An `AVAudioFormat` the format of this audio source
|
||||
public func play(source: CoreAudioStreamSource, entryId: String, format: AVAudioFormat) {
|
||||
let audioEntry = AudioEntry(source: source, entryId: AudioEntryId(id: entryId), outputAudioFormat: format)
|
||||
play(audioEntry: audioEntry)
|
||||
}
|
||||
|
||||
private func play(audioEntry: AudioEntry) {
|
||||
audioEntry.delegate = self
|
||||
|
||||
checkRenderWaitingAndNotifyIfNeeded()
|
||||
@@ -247,6 +282,16 @@ open class AudioPlayer {
|
||||
queue(url: url, headers: [:], after: afterUrl)
|
||||
}
|
||||
|
||||
/// Queues the specified audio stream
|
||||
///
|
||||
/// - parameter source: A `CoreAudioStreamSource` that will providing streaming data
|
||||
/// - parameter entryId: A `String` that provides a unique id for this item
|
||||
/// - parameter format: An `AVAudioFormat` the format of this audio source
|
||||
public func queue(source: CoreAudioStreamSource, entryId: String, format: AVAudioFormat) {
|
||||
let audioEntry = AudioEntry(source: source, entryId: AudioEntryId(id: entryId), outputAudioFormat: format)
|
||||
queue(audioEntry: audioEntry)
|
||||
}
|
||||
|
||||
public func removeFromQueue(url: URL) {
|
||||
serializationQueue.sync {
|
||||
if let item = entriesQueue.items(type: .upcoming).first(where: { $0.id.id == url.absoluteString }) {
|
||||
@@ -268,21 +313,8 @@ open class AudioPlayer {
|
||||
/// - Parameter url: A `URL` specifying the audio content to be played.
|
||||
/// - parameter headers: A `Dictionary` specifying any additional headers to be pass to the network request.
|
||||
public func queue(url: URL, headers: [String: String], after afterUrl: URL? = nil) {
|
||||
serializationQueue.sync {
|
||||
let audioEntry = entryProvider.provideAudioEntry(url: url, headers: headers)
|
||||
audioEntry.delegate = self
|
||||
if let afterUrl = afterUrl {
|
||||
if let afterUrlEntry = entriesQueue.items(type: .upcoming).first(where: { $0.id.id == afterUrl.absoluteString }) {
|
||||
entriesQueue.insert(item: audioEntry, type: .upcoming, after: afterUrlEntry)
|
||||
}
|
||||
} else {
|
||||
entriesQueue.enqueue(item: audioEntry, type: .upcoming)
|
||||
}
|
||||
}
|
||||
checkRenderWaitingAndNotifyIfNeeded()
|
||||
sourceQueue.async { [weak self] in
|
||||
self?.processSource()
|
||||
}
|
||||
let audioEntry = entryProvider.provideAudioEntry(url: url, headers: headers)
|
||||
queue(audioEntry: audioEntry, after: afterUrl)
|
||||
}
|
||||
|
||||
/// Queues the specified URLs
|
||||
@@ -303,6 +335,23 @@ open class AudioPlayer {
|
||||
}
|
||||
}
|
||||
|
||||
private func queue(audioEntry: AudioEntry, after afterUrl: URL? = nil) {
|
||||
serializationQueue.sync {
|
||||
audioEntry.delegate = self
|
||||
if let afterUrl = afterUrl {
|
||||
if let afterUrlEntry = entriesQueue.items(type: .upcoming).first(where: { $0.id.id == afterUrl.absoluteString }) {
|
||||
entriesQueue.insert(item: audioEntry, type: .upcoming, after: afterUrlEntry)
|
||||
}
|
||||
} else {
|
||||
entriesQueue.enqueue(item: audioEntry, type: .upcoming)
|
||||
}
|
||||
}
|
||||
checkRenderWaitingAndNotifyIfNeeded()
|
||||
sourceQueue.async { [weak self] in
|
||||
self?.processSource()
|
||||
}
|
||||
}
|
||||
|
||||
/// Stops the audio playback
|
||||
public func stop(clearQueue: Bool = true) {
|
||||
guard playerContext.internalState != .stopped else { return }
|
||||
@@ -602,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()
|
||||
}
|
||||
@@ -628,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
|
||||
{
|
||||
@@ -805,7 +858,7 @@ open class AudioPlayer {
|
||||
}
|
||||
|
||||
extension AudioPlayer: AudioStreamSourceDelegate {
|
||||
func dataAvailable(source: CoreAudioStreamSource, data: Data) {
|
||||
public func dataAvailable(source: CoreAudioStreamSource, data: Data) {
|
||||
guard let readingEntry = playerContext.audioReadingEntry, readingEntry.has(same: source) else {
|
||||
return
|
||||
}
|
||||
@@ -835,12 +888,12 @@ extension AudioPlayer: AudioStreamSourceDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
func errorOccurred(source: CoreAudioStreamSource, error: Error) {
|
||||
public func errorOccurred(source: CoreAudioStreamSource, error: Error) {
|
||||
guard let entry = playerContext.audioReadingEntry, entry.has(same: source) else { return }
|
||||
raiseUnexpected(error: .networkError(.failure(error)))
|
||||
}
|
||||
|
||||
func endOfFileOccurred(source: CoreAudioStreamSource) {
|
||||
public func endOfFileOccurred(source: CoreAudioStreamSource) {
|
||||
let hasSameSource = playerContext.audioReadingEntry?.has(same: source) ?? false
|
||||
guard playerContext.audioReadingEntry == nil || hasSameSource else {
|
||||
source.delegate = nil
|
||||
@@ -877,7 +930,7 @@ extension AudioPlayer: AudioStreamSourceDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
func metadataReceived(data: [String: String]) {
|
||||
public func metadataReceived(data: [String: String]) {
|
||||
asyncOnMain { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.delegate?.audioPlayerDidReadMetadata(player: self, metadata: data)
|
||||
|
||||
@@ -26,7 +26,7 @@ public struct AudioPlayerConfiguration: Equatable {
|
||||
bufferSizeInSeconds: 10,
|
||||
secondsRequiredToStartPlaying: 1,
|
||||
gracePeriodAfterSeekInSeconds: 0.5,
|
||||
secondsRequiredToStartPlayingAfterBufferUnderrun: 1,
|
||||
secondsRequiredToStartPlayingAfterBufferUnderrun: 7,
|
||||
enableLogs: false)
|
||||
/// Initializes the configuration for the `AudioPlayer`
|
||||
///
|
||||
|
||||
@@ -13,11 +13,11 @@ extension AudioPlayer {
|
||||
|
||||
static let initial = InternalState([])
|
||||
static let running = InternalState(rawValue: 1)
|
||||
static let playing = InternalState(rawValue: 1 << 1 | InternalState.running.rawValue)
|
||||
static let rebuffering = InternalState(rawValue: 1 << 2 | InternalState.running.rawValue)
|
||||
static let waitingForData = InternalState(rawValue: 1 << 3 | InternalState.running.rawValue)
|
||||
static let waitingForDataAfterSeek = InternalState(rawValue: 1 << 4 | InternalState.running.rawValue)
|
||||
static let paused = InternalState(rawValue: 1 << 5 | InternalState.running.rawValue)
|
||||
static let playing = InternalState(rawValue: (1 << 1) | InternalState.running.rawValue)
|
||||
static let rebuffering = InternalState(rawValue: (1 << 2) | InternalState.running.rawValue)
|
||||
static let waitingForData = InternalState(rawValue: (1 << 3) | InternalState.running.rawValue)
|
||||
static let waitingForDataAfterSeek = InternalState(rawValue: (1 << 4) | InternalState.running.rawValue)
|
||||
static let paused = InternalState(rawValue: (1 << 5) | InternalState.running.rawValue)
|
||||
static let stopped = InternalState(rawValue: 1 << 9)
|
||||
static let pendingNext = InternalState(rawValue: 1 << 10)
|
||||
static let disposed = InternalState(rawValue: 1 << 30)
|
||||
@@ -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
|
||||
|
||||
@@ -9,22 +9,22 @@ import CoreAudio
|
||||
var maxFramesPerSlice: AVAudioFrameCount = 8192
|
||||
|
||||
final class AudioRendererContext {
|
||||
var waiting = Atomic<Bool>(false)
|
||||
let waiting = Atomic<Bool>(false)
|
||||
|
||||
let lock = UnfairLock()
|
||||
|
||||
let bufferContext: BufferContext
|
||||
|
||||
var audioBuffer: AudioBuffer
|
||||
var inOutAudioBufferList: UnsafeMutablePointer<AudioBufferList>
|
||||
let audioBuffer: AudioBuffer
|
||||
let inOutAudioBufferList: UnsafeMutablePointer<AudioBufferList>
|
||||
|
||||
let packetsSemaphore = DispatchSemaphore(value: 0)
|
||||
|
||||
let framesRequiredToStartPlaying: UInt32
|
||||
let framesRequiredAfterRebuffering: UInt32
|
||||
let framesRequiredForDataAfterSeekPlaying: UInt32
|
||||
let framesRequiredToStartPlaying: Double
|
||||
let framesRequiredAfterRebuffering: Double
|
||||
let framesRequiredForDataAfterSeekPlaying: Double
|
||||
|
||||
var waitingForDataAfterSeekFrameCount = Atomic<Int32>(0)
|
||||
let waitingForDataAfterSeekFrameCount = Atomic<Int32>(0)
|
||||
|
||||
private let configuration: AudioPlayerConfiguration
|
||||
|
||||
@@ -33,9 +33,9 @@ final class AudioRendererContext {
|
||||
|
||||
let canonicalStream = outputAudioFormat.basicStreamDescription
|
||||
|
||||
framesRequiredToStartPlaying = UInt32(canonicalStream.mSampleRate) * UInt32(configuration.secondsRequiredToStartPlaying)
|
||||
framesRequiredAfterRebuffering = UInt32(canonicalStream.mSampleRate) * UInt32(configuration.secondsRequiredToStartPlayingAfterBufferUnderrun)
|
||||
framesRequiredForDataAfterSeekPlaying = UInt32(canonicalStream.mSampleRate) * UInt32(configuration.gracePeriodAfterSeekInSeconds)
|
||||
framesRequiredToStartPlaying = Double(canonicalStream.mSampleRate) * Double(configuration.secondsRequiredToStartPlaying)
|
||||
framesRequiredAfterRebuffering = Double(canonicalStream.mSampleRate) * Double(configuration.secondsRequiredToStartPlayingAfterBufferUnderrun)
|
||||
framesRequiredForDataAfterSeekPlaying = Double(canonicalStream.mSampleRate) * Double(configuration.gracePeriodAfterSeekInSeconds)
|
||||
|
||||
let dataByteSize = Int(canonicalStream.mSampleRate * configuration.bufferSizeInSeconds) * Int(canonicalStream.mBytesPerFrame)
|
||||
inOutAudioBufferList = allocateBufferList(dataByteSize: dataByteSize)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -214,23 +217,29 @@ final class AudioFileStreamProcessor {
|
||||
propertyId: AudioFileStreamPropertyID,
|
||||
flags _: UnsafeMutablePointer<AudioFileStreamPropertyFlags>)
|
||||
{
|
||||
guard let entry = playerContext.audioReadingEntry else { return }
|
||||
switch propertyId {
|
||||
case kAudioFileStreamProperty_DataOffset:
|
||||
processDataOffset(fileStream: fileStream)
|
||||
processDataOffset(entry: entry, fileStream: fileStream)
|
||||
case kAudioFileStreamProperty_FileFormat:
|
||||
processFileFormat(fileStream: fileStream)
|
||||
case kAudioFileStreamProperty_DataFormat:
|
||||
processDataFormat(fileStream: fileStream)
|
||||
processDataFormat(entry: entry, fileStream: fileStream)
|
||||
case kAudioFileStreamProperty_AudioDataByteCount:
|
||||
processDataByteCount(fileStream: fileStream)
|
||||
processDataByteCount(entry: entry, fileStream: fileStream)
|
||||
case kAudioFileStreamProperty_AudioDataPacketCount:
|
||||
processAudioDataPacketCount(fileStream: fileStream)
|
||||
processAudioDataPacketCount(entry: entry, fileStream: fileStream)
|
||||
case kAudioFileStreamProperty_BitRate:
|
||||
processBitRate(entry: entry, fileStream: fileStream)
|
||||
case kAudioFileStreamProperty_ReadyToProducePackets:
|
||||
// check converter for discontinuous stream
|
||||
processReadyToProducePackets(fileStream: fileStream)
|
||||
processPacketUpperBoundAndMaxPacketSize(fileStream: fileStream)
|
||||
assignMagicCookieToConverterIfNeeded()
|
||||
processPacketUpperBoundAndMaxPacketSize(entry: entry, fileStream: fileStream)
|
||||
processReadyToProducePackets(entry: entry, fileStream: fileStream)
|
||||
case kAudioFileStreamProperty_FormatList:
|
||||
processFormatList(fileStream: fileStream)
|
||||
processFormatList(entry: entry, fileStream: fileStream)
|
||||
case kAudioFileStreamProperty_PacketTableInfo:
|
||||
processPacketTableInfo(entry: entry, fileStream: fileStream)
|
||||
default:
|
||||
break
|
||||
}
|
||||
@@ -238,19 +247,23 @@ final class AudioFileStreamProcessor {
|
||||
|
||||
// MARK: AudioFileStream properties Processing
|
||||
|
||||
private func processDataOffset(fileStream: AudioFileStreamID) {
|
||||
private func processDataOffset(entry: AudioEntry, fileStream: AudioFileStreamID) {
|
||||
var offset: UInt64 = 0
|
||||
fileStreamGetProperty(value: &offset, fileStream: fileStream, propertyId: kAudioFileStreamProperty_DataOffset)
|
||||
playerContext.audioReadingEntry?.audioStreamState.processedDataFormat = true
|
||||
playerContext.audioReadingEntry?.audioStreamState.dataOffset = offset
|
||||
entry.lock.lock(); defer { entry.lock.unlock() }
|
||||
entry.audioStreamState.processedDataFormat = true
|
||||
entry.audioStreamState.dataOffset = offset
|
||||
}
|
||||
|
||||
private func processReadyToProducePackets(fileStream: AudioFileStreamID) {
|
||||
private func processReadyToProducePackets(entry: AudioEntry, fileStream: AudioFileStreamID) {
|
||||
var packetCount: UInt64 = 0
|
||||
var packetCountSize = UInt32(MemoryLayout.size(ofValue: packetCount))
|
||||
AudioFileStreamGetProperty(fileStream, kAudioFileStreamProperty_AudioDataPacketCount, &packetCountSize, &packetCount)
|
||||
playerContext.audioPlayingEntry?.audioStreamState.dataPacketCount = Double(packetCount)
|
||||
if playerContext.audioPlayingEntry?.audioStreamFormat.mFormatID != kAudioFormatLinearPCM {
|
||||
entry.lock.lock(); defer { entry.lock.unlock() }
|
||||
entry.audioStreamState.dataPacketCount = Double(packetCount)
|
||||
let entryFormatID = entry.audioStreamFormat.mFormatID
|
||||
let isFLAC = entryFormatID == kAudioFormatFLAC
|
||||
if entryFormatID != kAudioFormatLinearPCM && !isFLAC {
|
||||
discontinuous = true
|
||||
}
|
||||
}
|
||||
@@ -264,9 +277,9 @@ final class AudioFileStreamProcessor {
|
||||
}
|
||||
}
|
||||
|
||||
private func processDataFormat(fileStream: AudioFileStreamID) {
|
||||
private func processDataFormat(entry: AudioEntry, fileStream: AudioFileStreamID) {
|
||||
var audioStreamFormat = AudioStreamBasicDescription()
|
||||
guard let entry = playerContext.audioReadingEntry else { return }
|
||||
entry.lock.lock(); defer { entry.lock.unlock() }
|
||||
if !entry.audioStreamState.processedDataFormat {
|
||||
fileStreamGetProperty(value: &audioStreamFormat, fileStream: fileStream, propertyId: kAudioFileStreamProperty_DataFormat)
|
||||
|
||||
@@ -289,9 +302,8 @@ final class AudioFileStreamProcessor {
|
||||
packetBufferSize = 2048 // default value
|
||||
}
|
||||
}
|
||||
entry.lock.withLock {
|
||||
entry.processedPacketsState.bufferSize = packetBufferSize
|
||||
}
|
||||
|
||||
entry.processedPacketsState.bufferSize = packetBufferSize
|
||||
|
||||
if !fileFormatsForDelayedConverterCreation.contains(currentFileFormat) {
|
||||
createAudioConverter(from: entry.audioStreamFormat, to: outputAudioFormat)
|
||||
@@ -299,8 +311,7 @@ final class AudioFileStreamProcessor {
|
||||
}
|
||||
}
|
||||
|
||||
private func processPacketUpperBoundAndMaxPacketSize(fileStream: AudioFileStreamID) {
|
||||
guard let entry = playerContext.audioReadingEntry else { return }
|
||||
private func processPacketUpperBoundAndMaxPacketSize(entry: AudioEntry, fileStream: AudioFileStreamID) {
|
||||
var packetBufferSize: UInt32 = 0
|
||||
var status = fileStreamGetProperty(value: &packetBufferSize,
|
||||
fileStream: fileStream,
|
||||
@@ -318,55 +329,90 @@ final class AudioFileStreamProcessor {
|
||||
}
|
||||
}
|
||||
|
||||
private func processDataByteCount(fileStream: AudioFileStreamID) {
|
||||
guard let entry = playerContext.audioReadingEntry else { return }
|
||||
private func processDataByteCount(entry: AudioEntry, fileStream: AudioFileStreamID) {
|
||||
var audioDataByteCount: UInt64 = 0
|
||||
fileStreamGetProperty(value: &audioDataByteCount, fileStream: fileStream, propertyId: kAudioFileStreamProperty_AudioDataByteCount)
|
||||
entry.lock.lock(); defer { entry.lock.unlock() }
|
||||
entry.audioStreamState.dataByteCount = audioDataByteCount
|
||||
}
|
||||
|
||||
private func processAudioDataPacketCount(fileStream: AudioFileStreamID) {
|
||||
guard let entry = playerContext.audioReadingEntry else { return }
|
||||
private func processAudioDataPacketCount(entry: AudioEntry, fileStream: AudioFileStreamID) {
|
||||
var audioDataPacketCount: UInt64 = 0
|
||||
fileStreamGetProperty(value: &audioDataPacketCount, fileStream: fileStream, propertyId: kAudioFileStreamProperty_AudioDataPacketCount)
|
||||
entry.lock.lock(); defer { entry.lock.unlock() }
|
||||
entry.audioStreamState.dataPacketOffset = audioDataPacketCount
|
||||
}
|
||||
|
||||
private func processFormatList(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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Packets Proc
|
||||
|
||||
func propertyPacketsProc(inNumberBytes: UInt32,
|
||||
inNumberPackets: UInt32,
|
||||
inInputData: UnsafeRawPointer,
|
||||
inPacketDescriptions: UnsafeMutablePointer<AudioStreamPacketDescription>?)
|
||||
{
|
||||
func propertyPacketsProc(
|
||||
inNumberBytes: UInt32,
|
||||
inNumberPackets: UInt32,
|
||||
inInputData: UnsafeRawPointer,
|
||||
inPacketDescriptions: UnsafeMutablePointer<AudioStreamPacketDescription>?
|
||||
) {
|
||||
guard let entry = playerContext.audioReadingEntry else { return }
|
||||
guard entry.audioStreamState.processedDataFormat else { return }
|
||||
|
||||
guard let converter = audioConverter else {
|
||||
Logger.error("Couldn't find audio converter", category: .audioRendering)
|
||||
return
|
||||
}
|
||||
|
||||
if let playingEntry = playerContext.audioPlayingEntry,
|
||||
playingEntry.seekRequest.requested, playingEntry.calculatedBitrate() > 0
|
||||
{
|
||||
@@ -377,25 +423,24 @@ final class AudioFileStreamProcessor {
|
||||
return
|
||||
}
|
||||
|
||||
guard let converter = audioConverter else {
|
||||
Logger.error("Couldn't find audio converter", category: .audioRendering)
|
||||
return
|
||||
}
|
||||
|
||||
// reset discontinuity
|
||||
discontinuous = false
|
||||
|
||||
var convertInfo = AudioConvertInfo(done: false,
|
||||
numberOfPackets: inNumberPackets,
|
||||
packDescription: inPacketDescriptions)
|
||||
var convertInfo = AudioConvertInfo(
|
||||
done: false,
|
||||
numberOfPackets: inNumberPackets,
|
||||
packDescription: inPacketDescriptions
|
||||
)
|
||||
convertInfo.audioBuffer.mData = UnsafeMutableRawPointer(mutating: inInputData)
|
||||
convertInfo.audioBuffer.mDataByteSize = inNumberBytes
|
||||
if let playingAudioStreamFormat = playerContext.audioPlayingEntry?.audioStreamFormat {
|
||||
convertInfo.audioBuffer.mNumberChannels = playingAudioStreamFormat.mChannelsPerFrame
|
||||
}
|
||||
|
||||
updateProcessedPackets(inPacketDescriptions: inPacketDescriptions,
|
||||
inNumberPackets: inNumberPackets)
|
||||
updateProcessedPackets(
|
||||
inPacketDescriptions: inPacketDescriptions,
|
||||
inNumberPackets: inNumberPackets
|
||||
)
|
||||
|
||||
var status: OSStatus = noErr
|
||||
packetProcess: while status == noErr {
|
||||
@@ -403,7 +448,7 @@ final class AudioFileStreamProcessor {
|
||||
let bufferContext = rendererContext.bufferContext
|
||||
var used = bufferContext.frameUsedCount
|
||||
var start = bufferContext.frameStartIndex
|
||||
var end = bufferContext.end
|
||||
var end = (bufferContext.frameStartIndex + bufferContext.frameUsedCount) % bufferContext.totalFrameCount
|
||||
|
||||
var framesLeftInBuffer = bufferContext.totalFrameCount - used
|
||||
rendererContext.lock.unlock()
|
||||
@@ -451,16 +496,20 @@ final class AudioFileStreamProcessor {
|
||||
var framesToDecode: UInt32 = rendererContext.bufferContext.totalFrameCount - end
|
||||
|
||||
let offset = Int(end * rendererContext.bufferContext.sizeInBytes)
|
||||
prefillLocalBufferList(bufferList: localBufferList,
|
||||
dataOffset: offset,
|
||||
framesToDecode: framesToDecode)
|
||||
prefillLocalBufferList(
|
||||
bufferList: localBufferList,
|
||||
dataOffset: offset,
|
||||
framesToDecode: framesToDecode
|
||||
)
|
||||
|
||||
status = AudioConverterFillComplexBuffer(converter,
|
||||
_converterCallback,
|
||||
&convertInfo,
|
||||
&framesToDecode,
|
||||
localBufferList.unsafeMutablePointer,
|
||||
nil)
|
||||
status = AudioConverterFillComplexBuffer(
|
||||
converter,
|
||||
_converterCallback,
|
||||
&convertInfo,
|
||||
&framesToDecode,
|
||||
localBufferList.unsafeMutablePointer,
|
||||
nil
|
||||
)
|
||||
|
||||
framesAdded = framesToDecode
|
||||
|
||||
@@ -477,16 +526,20 @@ final class AudioFileStreamProcessor {
|
||||
fillUsedFrames(framesCount: framesAdded)
|
||||
continue packetProcess
|
||||
}
|
||||
prefillLocalBufferList(bufferList: localBufferList,
|
||||
dataOffset: 0,
|
||||
framesToDecode: framesToDecode)
|
||||
prefillLocalBufferList(
|
||||
bufferList: localBufferList,
|
||||
dataOffset: 0,
|
||||
framesToDecode: framesToDecode
|
||||
)
|
||||
|
||||
status = AudioConverterFillComplexBuffer(converter,
|
||||
_converterCallback,
|
||||
&convertInfo,
|
||||
&framesToDecode,
|
||||
localBufferList.unsafeMutablePointer,
|
||||
nil)
|
||||
status = AudioConverterFillComplexBuffer(
|
||||
converter,
|
||||
_converterCallback,
|
||||
&convertInfo,
|
||||
&framesToDecode,
|
||||
localBufferList.unsafeMutablePointer,
|
||||
nil
|
||||
)
|
||||
|
||||
framesAdded += framesToDecode
|
||||
|
||||
@@ -505,16 +558,20 @@ final class AudioFileStreamProcessor {
|
||||
var framesToDecode: UInt32 = start - end
|
||||
|
||||
let offset = Int(end * rendererContext.bufferContext.sizeInBytes)
|
||||
prefillLocalBufferList(bufferList: localBufferList,
|
||||
dataOffset: offset,
|
||||
framesToDecode: framesToDecode)
|
||||
prefillLocalBufferList(
|
||||
bufferList: localBufferList,
|
||||
dataOffset: offset,
|
||||
framesToDecode: framesToDecode
|
||||
)
|
||||
|
||||
status = AudioConverterFillComplexBuffer(converter,
|
||||
_converterCallback,
|
||||
&convertInfo,
|
||||
&framesToDecode,
|
||||
localBufferList.unsafeMutablePointer,
|
||||
nil)
|
||||
status = AudioConverterFillComplexBuffer(
|
||||
converter,
|
||||
_converterCallback,
|
||||
&convertInfo,
|
||||
&framesToDecode,
|
||||
localBufferList.unsafeMutablePointer,
|
||||
nil
|
||||
)
|
||||
|
||||
framesAdded = framesToDecode
|
||||
if status == AudioConvertStatus.done.rawValue {
|
||||
@@ -537,10 +594,11 @@ final class AudioFileStreamProcessor {
|
||||
/// - parameter dataOffset: An `Int` value indicating any offset to be applied to the buffer data
|
||||
/// - parameter framesToDecode: An `UInt32` value indicating the frames to be decoded, used in calculating the data size of the buffer.
|
||||
@inline(__always)
|
||||
private func prefillLocalBufferList(bufferList: UnsafeMutableAudioBufferListPointer,
|
||||
dataOffset: Int,
|
||||
framesToDecode: UInt32)
|
||||
{
|
||||
private func prefillLocalBufferList(
|
||||
bufferList: UnsafeMutableAudioBufferListPointer,
|
||||
dataOffset: Int,
|
||||
framesToDecode: UInt32
|
||||
) {
|
||||
if let mData = rendererContext.audioBuffer.mData {
|
||||
bufferList[0].mData = dataOffset > 0 ? mData + dataOffset : mData
|
||||
}
|
||||
@@ -563,9 +621,10 @@ final class AudioFileStreamProcessor {
|
||||
}
|
||||
|
||||
@inline(__always)
|
||||
private func updateProcessedPackets(inPacketDescriptions: UnsafeMutablePointer<AudioStreamPacketDescription>?,
|
||||
inNumberPackets: UInt32)
|
||||
{
|
||||
private func updateProcessedPackets(
|
||||
inPacketDescriptions: UnsafeMutablePointer<AudioStreamPacketDescription>?,
|
||||
inNumberPackets: UInt32
|
||||
) {
|
||||
guard let inPacketDescriptions = inPacketDescriptions else { return }
|
||||
guard let readingEntry = playerContext.audioReadingEntry else { return }
|
||||
let processedPackCount = readingEntry.processedPacketsState.count
|
||||
@@ -585,23 +644,25 @@ final class AudioFileStreamProcessor {
|
||||
|
||||
// MARK: - AudioFileStream proc method
|
||||
|
||||
private func _propertyListenerProc(clientData: UnsafeMutableRawPointer,
|
||||
fileStream: AudioFileStreamID,
|
||||
propertyId: AudioFileStreamPropertyID,
|
||||
flags: UnsafeMutablePointer<AudioFileStreamPropertyFlags>)
|
||||
{
|
||||
private func _propertyListenerProc(
|
||||
clientData: UnsafeMutableRawPointer,
|
||||
fileStream: AudioFileStreamID,
|
||||
propertyId: AudioFileStreamPropertyID,
|
||||
flags: UnsafeMutablePointer<AudioFileStreamPropertyFlags>
|
||||
) {
|
||||
let processor = clientData.to(type: AudioFileStreamProcessor.self)
|
||||
processor.propertyListenerProc(fileStream: fileStream,
|
||||
propertyId: propertyId,
|
||||
flags: flags)
|
||||
}
|
||||
|
||||
private func _propertyPacketsProc(clientData: UnsafeMutableRawPointer,
|
||||
inNumberBytes: UInt32,
|
||||
inNumberPackets: UInt32,
|
||||
inInputData: UnsafeRawPointer,
|
||||
inPacketDescriptions: UnsafeMutablePointer<AudioStreamPacketDescription>?)
|
||||
{
|
||||
private func _propertyPacketsProc(
|
||||
clientData: UnsafeMutableRawPointer,
|
||||
inNumberBytes: UInt32,
|
||||
inNumberPackets: UInt32,
|
||||
inInputData: UnsafeRawPointer,
|
||||
inPacketDescriptions: UnsafeMutablePointer<AudioStreamPacketDescription>?
|
||||
) {
|
||||
let processor = clientData.to(type: AudioFileStreamProcessor.self)
|
||||
processor.propertyPacketsProc(inNumberBytes: inNumberBytes,
|
||||
inNumberPackets: inNumberPackets,
|
||||
@@ -611,12 +672,13 @@ private func _propertyPacketsProc(clientData: UnsafeMutableRawPointer,
|
||||
|
||||
// MARK: - AudioConverterFillComplexBuffer callback method
|
||||
|
||||
private func _converterCallback(inAudioConverter _: AudioConverterRef,
|
||||
ioNumberDataPackets: UnsafeMutablePointer<UInt32>,
|
||||
ioData: UnsafeMutablePointer<AudioBufferList>,
|
||||
outDataPacketDescription: UnsafeMutablePointer<UnsafeMutablePointer<AudioStreamPacketDescription>?>?,
|
||||
inUserData: UnsafeMutableRawPointer?) -> OSStatus
|
||||
{
|
||||
private func _converterCallback(
|
||||
inAudioConverter _: AudioConverterRef,
|
||||
ioNumberDataPackets: UnsafeMutablePointer<UInt32>,
|
||||
ioData: UnsafeMutablePointer<AudioBufferList>,
|
||||
outDataPacketDescription: UnsafeMutablePointer<UnsafeMutablePointer<AudioStreamPacketDescription>?>?,
|
||||
inUserData: UnsafeMutableRawPointer?
|
||||
) -> OSStatus {
|
||||
guard let convertInfo = inUserData?.assumingMemoryBound(to: AudioConvertInfo.self) else { return 0 }
|
||||
|
||||
// we need to tell the converter to stop converting after it should stop converting
|
||||
|
||||
@@ -64,29 +64,30 @@ final class AudioPlayerRenderProcessor: NSObject {
|
||||
let frameSizeInBytes = bufferContext.sizeInBytes
|
||||
let used = bufferContext.frameUsedCount
|
||||
let start = bufferContext.frameStartIndex
|
||||
let end = bufferContext.end
|
||||
let end = (bufferContext.frameStartIndex + bufferContext.frameUsedCount) % bufferContext.totalFrameCount
|
||||
let signal = rendererContext.waiting.value && used < bufferContext.totalFrameCount / 2
|
||||
|
||||
if let playingEntry = playingEntry {
|
||||
playingEntry.lock.lock()
|
||||
let framesState = playingEntry.framesState
|
||||
playingEntry.lock.unlock()
|
||||
|
||||
if state == .waitingForData {
|
||||
var requiredFramesToStart = rendererContext.framesRequiredToStartPlaying
|
||||
if framesState.lastFrameQueued >= 0 {
|
||||
requiredFramesToStart = min(requiredFramesToStart, UInt32(playingEntry.framesState.lastFrameQueued))
|
||||
requiredFramesToStart = min(requiredFramesToStart, Double(playingEntry.framesState.lastFrameQueued))
|
||||
}
|
||||
if let readingEntry = readingEntry, readingEntry === playingEntry,
|
||||
framesState.queued < requiredFramesToStart
|
||||
|
||||
if readingEntry === playingEntry, framesState.queued < Int(requiredFramesToStart)
|
||||
{
|
||||
waitForBuffer = true
|
||||
}
|
||||
} else if state == .rebuffering {
|
||||
var requiredFramesToStart = rendererContext.framesRequiredAfterRebuffering
|
||||
if framesState.lastFrameQueued >= 0 {
|
||||
requiredFramesToStart = min(requiredFramesToStart, UInt32(framesState.lastFrameQueued - framesState.queued))
|
||||
requiredFramesToStart = min(requiredFramesToStart, Double(framesState.lastFrameQueued - framesState.queued))
|
||||
}
|
||||
if used < requiredFramesToStart {
|
||||
if used < Int(requiredFramesToStart) {
|
||||
waitForBuffer = true
|
||||
}
|
||||
} else if state == .waitingForDataAfterSeek {
|
||||
@@ -102,7 +103,7 @@ final class AudioPlayerRenderProcessor: NSObject {
|
||||
rendererContext.lock.unlock()
|
||||
|
||||
var totalFramesCopied: UInt32 = 0
|
||||
if used > 0 && !waitForBuffer && state.contains(.running) && state != .paused {
|
||||
if used > 0 && !waitForBuffer && playingEntry != nil && state.contains(.running) && state != .paused {
|
||||
if end > start {
|
||||
let framesToCopy = min(inNumberFrames, used)
|
||||
bufferList.mBuffers.mNumberChannels = 2
|
||||
@@ -114,9 +115,7 @@ final class AudioPlayerRenderProcessor: NSObject {
|
||||
}
|
||||
} else {
|
||||
if let mDataBuffer = audioBuffer.mData {
|
||||
memcpy(bufferList.mBuffers.mData,
|
||||
mDataBuffer + Int(start * frameSizeInBytes),
|
||||
Int(bufferList.mBuffers.mDataByteSize))
|
||||
memcpy(bufferList.mBuffers.mData, mDataBuffer + Int(start * frameSizeInBytes), Int(bufferList.mBuffers.mDataByteSize))
|
||||
}
|
||||
}
|
||||
totalFramesCopied = framesToCopy
|
||||
@@ -137,9 +136,7 @@ final class AudioPlayerRenderProcessor: NSObject {
|
||||
}
|
||||
} else {
|
||||
if let mDataBuffer = audioBuffer.mData {
|
||||
memcpy(bufferList.mBuffers.mData,
|
||||
mDataBuffer + Int(start * frameSizeInBytes),
|
||||
Int(bufferList.mBuffers.mDataByteSize))
|
||||
memcpy(bufferList.mBuffers.mData, mDataBuffer + Int(start * frameSizeInBytes), Int(bufferList.mBuffers.mDataByteSize))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,9 +151,7 @@ final class AudioPlayerRenderProcessor: NSObject {
|
||||
memset(ioBufferData + Int(frameToCopy * frameSizeInBytes), 0, Int(frameSizeInBytes * moreFramesToCopy))
|
||||
} else {
|
||||
if let mDataBuffer = audioBuffer.mData {
|
||||
memcpy(ioBufferData + Int(frameToCopy * frameSizeInBytes),
|
||||
mDataBuffer,
|
||||
Int(frameSizeInBytes * moreFramesToCopy))
|
||||
memcpy(ioBufferData + Int(frameToCopy * frameSizeInBytes), mDataBuffer, Int(frameSizeInBytes * moreFramesToCopy))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -168,6 +163,7 @@ final class AudioPlayerRenderProcessor: NSObject {
|
||||
bufferContext.frameUsedCount -= totalFramesCopied
|
||||
rendererContext.lock.unlock()
|
||||
}
|
||||
|
||||
if playerContext.internalState != .playing {
|
||||
playerContext.setInternalState(to: .playing, when: { state -> Bool in
|
||||
state.contains(.running) && state != .paused
|
||||
@@ -181,7 +177,7 @@ final class AudioPlayerRenderProcessor: NSObject {
|
||||
memset(mData + Int(totalFramesCopied * frameSizeInBytes), 0, Int(delta * frameSizeInBytes))
|
||||
}
|
||||
|
||||
if playingEntry != nil || AudioPlayer.InternalState.waiting.contains(state) {
|
||||
if !(playingEntry == nil || state == .waitingForDataAfterSeek || state == .waitingForData || state == .rebuffering) {
|
||||
if playerContext.internalState != .rebuffering {
|
||||
playerContext.setInternalState(to: .rebuffering, when: { state -> Bool in
|
||||
state.contains(.running) && state != .paused
|
||||
@@ -190,7 +186,7 @@ final class AudioPlayerRenderProcessor: NSObject {
|
||||
} else if state == .waitingForDataAfterSeek {
|
||||
if totalFramesCopied == 0 {
|
||||
rendererContext.waitingForDataAfterSeekFrameCount.write { $0 += Int32(inNumberFrames - totalFramesCopied) }
|
||||
if rendererContext.waitingForDataAfterSeekFrameCount.value > rendererContext.framesRequiredForDataAfterSeekPlaying {
|
||||
if rendererContext.waitingForDataAfterSeekFrameCount.value > Int(rendererContext.framesRequiredForDataAfterSeekPlaying) {
|
||||
if playerContext.internalState != .playing {
|
||||
playerContext.setInternalState(to: .playing) { state -> Bool in
|
||||
state.contains(.running) && state != .playing
|
||||
@@ -270,10 +266,11 @@ final class AudioPlayerRenderProcessor: NSObject {
|
||||
return UnsafePointer(rendererContext.inOutAudioBufferList)
|
||||
}
|
||||
|
||||
func render(inNumberFrames: UInt32,
|
||||
ioData: UnsafeMutablePointer<AudioBufferList>,
|
||||
flags _: UnsafeMutablePointer<AudioUnitRenderActionFlags>) -> OSStatus
|
||||
{
|
||||
func render(
|
||||
inNumberFrames: UInt32,
|
||||
ioData: UnsafeMutablePointer<AudioBufferList>,
|
||||
flags _: UnsafeMutablePointer<AudioUnitRenderActionFlags>
|
||||
) -> OSStatus {
|
||||
var status = noErr
|
||||
|
||||
rendererContext.inOutAudioBufferList[0].mBuffers.mData = ioData.pointee.mBuffers.mData
|
||||
@@ -308,13 +305,18 @@ final class AudioPlayerRenderProcessor: NSObject {
|
||||
return status
|
||||
}
|
||||
|
||||
func renderProvider(flags: UnsafeMutablePointer<AudioUnitRenderActionFlags>,
|
||||
timeStamp _: UnsafePointer<AudioTimeStamp>,
|
||||
inNumberFrames: AUAudioFrameCount,
|
||||
inputBusNumber: Int,
|
||||
inputData: UnsafeMutablePointer<AudioBufferList>) -> AUAudioUnitStatus
|
||||
{
|
||||
func renderProvider(
|
||||
flags: UnsafeMutablePointer<AudioUnitRenderActionFlags>,
|
||||
timeStamp _: UnsafePointer<AudioTimeStamp>,
|
||||
inNumberFrames: AUAudioFrameCount,
|
||||
inputBusNumber: Int,
|
||||
inputData: UnsafeMutablePointer<AudioBufferList>
|
||||
) -> AUAudioUnitStatus {
|
||||
guard inputBusNumber == 0 else { return noErr }
|
||||
return render(inNumberFrames: inNumberFrames, ioData: inputData, flags: flags)
|
||||
return render(
|
||||
inNumberFrames: inNumberFrames,
|
||||
ioData: inputData,
|
||||
flags: flags
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,13 +5,11 @@
|
||||
|
||||
import AVFoundation
|
||||
|
||||
private let outputChannels: UInt32 = 2
|
||||
|
||||
enum UnitDescriptions {
|
||||
static var output: AudioComponentDescription = {
|
||||
static let output: AudioComponentDescription = {
|
||||
var desc = AudioComponentDescription()
|
||||
desc.componentType = kAudioUnitType_Output
|
||||
#if os(iOS)
|
||||
#if os(iOS) || os(tvOS)
|
||||
desc.componentSubType = kAudioUnitSubType_RemoteIO
|
||||
#else
|
||||
desc.componentSubType = kAudioUnitSubType_DefaultOutput
|
||||
|
||||
@@ -33,6 +33,7 @@ let fileTypesFromMimeType: [String: AudioFileTypeID] =
|
||||
"video/3gpp": kAudioFile3GPType,
|
||||
"audio/3gp2": kAudioFile3GP2Type,
|
||||
"video/3gp2": kAudioFile3GP2Type,
|
||||
"audio/flac": kAudioFileFLACType
|
||||
]
|
||||
|
||||
/// Method that converts mime type to AudioFileTypeID
|
||||
|
||||
@@ -6,6 +6,8 @@ let package = Package(
|
||||
name: "AudioStreaming",
|
||||
platforms: [
|
||||
.iOS(.v12),
|
||||
.macOS(.v13),
|
||||
.tvOS(.v16)
|
||||
],
|
||||
products: [
|
||||
.library(
|
||||
|
||||
@@ -8,7 +8,7 @@ Under the hood `AudioStreaming` uses `AVAudioEngine` and `CoreAudio` for playbac
|
||||
#### Supported audio
|
||||
- Online streaming (Shoutcast/ICY streams) with metadata parsing
|
||||
- AIFF, AIFC, WAVE, CAF, NeXT, ADTS, MPEG Audio Layer 3, AAC audio formats
|
||||
- M4A (_Optimized files only_)
|
||||
- M4A
|
||||
|
||||
As of 1.2.0 version, there's support for non-optimized M4A, please report any issues
|
||||
|
||||
@@ -18,6 +18,8 @@ Known limitations:
|
||||
|
||||
# Requirements
|
||||
- iOS 13.0+
|
||||
- macOS 13.0+
|
||||
- tvOS 16.0+
|
||||
- Swift 5.x
|
||||
|
||||
# Using AudioStreaming
|
||||
@@ -163,39 +165,11 @@ Under the hood the concrete class for frame filters, `FrameFilterProcessor` inst
|
||||
|
||||
# Installation
|
||||
|
||||
### Cocoapods
|
||||
|
||||
[Cocoapods](https://cocoapods.org/) is a dependency manager for Cocoa projects. You can install it with the following command:
|
||||
```
|
||||
$ gem install cocoapods
|
||||
```
|
||||
|
||||
To intergrate AudioStreaming with [Cocoapods](https://cocoapods.org/) to your Xcode project add the following to your `Podfile`:
|
||||
```
|
||||
pod 'AudioStreaming'
|
||||
```
|
||||
|
||||
### Swift Package Manager
|
||||
|
||||
On Xcode 11.0+ you can add a new dependency by going to **File / Swift Packages / Add Package Dependency...**
|
||||
and enter package repository URL https://github.com/dimitris-c/AudioStreaming.git, then follow the instructions.
|
||||
|
||||
### Carthage
|
||||
|
||||
[Carthage](https://github.com/Carthage/Carthage) is a decentralized dependency manager that builds your dependencies and provides you with frameworks.
|
||||
|
||||
You can install Carthage with Homebrew using the following command:
|
||||
```
|
||||
$ brew update
|
||||
$ brew install carthage
|
||||
```
|
||||
|
||||
To integrate AudioStreaming into your Xcode project using Carthage, add the following to your `Cartfile`:
|
||||
```
|
||||
github "dimitris-c/AudioStreaming"
|
||||
```
|
||||
Visit [installation instructions](https://github.com/Carthage/Carthage#adding-frameworks-to-an-application) on Carthage to install the framework
|
||||
|
||||
# Licence
|
||||
|
||||
AudioStreaming is available under the MIT license. See the LICENSE file for more info.
|
||||
|
||||
Reference in New Issue
Block a user