Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7e770197e6 | |||
| 6f552e60c0 | |||
| 0f2a1f7b8a | |||
| 0c2c7ba685 | |||
| 50174a7f4a | |||
| cc82e79d50 | |||
| 578bbcdbe8 | |||
| 56c6483fc0 | |||
| fca0930b01 | |||
| 2f08ea4131 | |||
| 5ac825ed7a | |||
| 4856a30bb6 | |||
| f15f0f6eae | |||
| da19dd9488 | |||
| e57c6aabe5 | |||
| f6f9554b25 | |||
| e9bace4447 | |||
| 40b9d03ea8 | |||
| 3247b54c86 | |||
| d78de29daf | |||
| 0758c14909 | |||
| 03c6a7692c | |||
| 02a3606185 | |||
| 7e45a7b2f5 | |||
| 30b4189778 | |||
| 8bdc2a64f7 | |||
| 65de9d90c0 | |||
| 217a88f171 | |||
| 566dc86f3f | |||
| d8aa58525c | |||
| 8197db0016 |
@@ -14,10 +14,10 @@ jobs:
|
||||
name: Test iOS
|
||||
runs-on: macOS-latest
|
||||
env:
|
||||
DEVELOPER_DIR: /Applications/Xcode_12.app/Contents/Developer
|
||||
DEVELOPER_DIR: /Applications/Xcode.app/Contents/Developer
|
||||
strategy:
|
||||
matrix:
|
||||
destination: ["OS=14.0,name=iPhone 11 Pro"] #, "OS=12.4,name=iPhone XS", "OS=11.4,name=iPhone X", "OS=10.3.1,name=iPhone SE"]
|
||||
destination: ["OS=latest,name=iPhone 13 Pro"]
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: iOS - ${{ matrix.destination }}
|
||||
|
||||
Vendored
BIN
Binary file not shown.
@@ -7,6 +7,7 @@
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
984808A028C0F549001160E6 /* hipjazz.wav in Resources */ = {isa = PBXBuildFile; fileRef = 9848089F28C0F549001160E6 /* hipjazz.wav */; };
|
||||
B5220836256051830086FB3A /* AudioPlayerService.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5220835256051830086FB3A /* AudioPlayerService.swift */; };
|
||||
B5220948256074910086FB3A /* MulticastDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5220947256074910086FB3A /* MulticastDelegate.swift */; };
|
||||
B52209502561883E0086FB3A /* EqualizerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B522094F2561883E0086FB3A /* EqualizerViewController.swift */; };
|
||||
@@ -42,6 +43,7 @@
|
||||
/* End PBXCopyFilesBuildPhase section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
9848089F28C0F549001160E6 /* hipjazz.wav */ = {isa = PBXFileReference; lastKnownFileType = audio.wav; path = hipjazz.wav; sourceTree = "<group>"; };
|
||||
B5220835256051830086FB3A /* AudioPlayerService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlayerService.swift; sourceTree = "<group>"; };
|
||||
B5220947256074910086FB3A /* MulticastDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MulticastDelegate.swift; sourceTree = "<group>"; };
|
||||
B522094F2561883E0086FB3A /* EqualizerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EqualizerViewController.swift; sourceTree = "<group>"; };
|
||||
@@ -78,6 +80,7 @@
|
||||
B524D59D2560177C00F5A88F /* Resources */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
9848089F28C0F549001160E6 /* hipjazz.wav */,
|
||||
B524D59B2560176C00F5A88F /* bensound-jazzyfrenchy.mp3 */,
|
||||
B5AEDBDD2475274D007D8101 /* Assets.xcassets */,
|
||||
B5AEDBDF2475274D007D8101 /* LaunchScreen.storyboard */,
|
||||
@@ -211,6 +214,7 @@
|
||||
B524D59C2560176C00F5A88F /* bensound-jazzyfrenchy.mp3 in Resources */,
|
||||
B5AEDBE12475274D007D8101 /* LaunchScreen.storyboard in Resources */,
|
||||
B5AEDBDE2475274D007D8101 /* Assets.xcassets in Resources */,
|
||||
984808A028C0F549001160E6 /* hipjazz.wav in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -371,8 +375,8 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
DEVELOPMENT_TEAM = 5Y92JCRVR7;
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
DEVELOPMENT_TEAM = "";
|
||||
INFOPLIST_FILE = AudioExample/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
@@ -381,6 +385,7 @@
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.decimal.AudioExample;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
@@ -390,8 +395,8 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
DEVELOPMENT_TEAM = 5Y92JCRVR7;
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
DEVELOPMENT_TEAM = "";
|
||||
INFOPLIST_FILE = AudioExample/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
@@ -400,6 +405,7 @@
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.decimal.AudioExample;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
|
||||
@@ -85,18 +85,6 @@
|
||||
isEnabled = "NO">
|
||||
</EnvironmentVariable>
|
||||
</EnvironmentVariables>
|
||||
<AdditionalOptions>
|
||||
<AdditionalOption
|
||||
key = "MallocStackLogging"
|
||||
value = ""
|
||||
isEnabled = "YES">
|
||||
</AdditionalOption>
|
||||
<AdditionalOption
|
||||
key = "PrefersMallocStackLoggingLite"
|
||||
value = ""
|
||||
isEnabled = "YES">
|
||||
</AdditionalOption>
|
||||
</AdditionalOptions>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
|
||||
Vendored
BIN
Binary file not shown.
@@ -9,7 +9,6 @@
|
||||
import UIKit
|
||||
|
||||
final class AppCoordinator {
|
||||
|
||||
enum Route {
|
||||
case equalizer
|
||||
}
|
||||
@@ -44,8 +43,8 @@ final class AppCoordinator {
|
||||
|
||||
private func routeTo(_ route: AppCoordinator.Route) {
|
||||
switch route {
|
||||
case .equalizer:
|
||||
showEqualizerControls()
|
||||
case .equalizer:
|
||||
showEqualizerControls()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
import UIKit
|
||||
|
||||
class EqualizerViewController: UIViewController {
|
||||
|
||||
private lazy var enableTextLabel = UILabel()
|
||||
private lazy var enableButton = UISwitch()
|
||||
|
||||
@@ -22,7 +21,8 @@ class EqualizerViewController: UIViewController {
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
@available(*, unavailable)
|
||||
required init?(coder _: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ class EqualizerViewController: UIViewController {
|
||||
stackView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
|
||||
stackView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
stackView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
stackView.heightAnchor.constraint(equalTo: view.safeAreaLayoutGuide.heightAnchor, multiplier: 0.8)
|
||||
stackView.heightAnchor.constraint(equalTo: view.safeAreaLayoutGuide.heightAnchor, multiplier: 0.8),
|
||||
]
|
||||
)
|
||||
}
|
||||
@@ -86,7 +86,7 @@ class EqualizerViewController: UIViewController {
|
||||
|
||||
private func buildSliders() -> [UIView] {
|
||||
var sliders = [UIView]()
|
||||
for index in 0..<viewModel.numberOfBands() {
|
||||
for index in 0 ..< viewModel.numberOfBands() {
|
||||
guard let item = viewModel.band(at: index) else { continue }
|
||||
let slider = buildSlider(item: item, index: index)
|
||||
sliders.append(slider)
|
||||
|
||||
@@ -16,7 +16,6 @@ struct EQBand {
|
||||
}
|
||||
|
||||
final class EqualzerViewModel {
|
||||
|
||||
private var bands: [EQBand] = []
|
||||
|
||||
private let equalizerService: EqualizerService
|
||||
@@ -31,7 +30,7 @@ final class EqualzerViewModel {
|
||||
bands = equalizerService.bands.map { item in
|
||||
var measurement = item.frequency
|
||||
var frequency = String(Int(measurement))
|
||||
if item.frequency >= 1_000 {
|
||||
if item.frequency >= 1000 {
|
||||
measurement = item.frequency / 1000
|
||||
frequency = "\(String(Int(measurement)))K"
|
||||
}
|
||||
@@ -43,7 +42,7 @@ final class EqualzerViewModel {
|
||||
if enable {
|
||||
equalizerService.activate()
|
||||
} else {
|
||||
equalizerService.deactive()
|
||||
equalizerService.deactivate()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -153,7 +153,7 @@ extension PlayerControlsViewModel: AudioPlayerServiceDelegate {
|
||||
updateContent?(.updateMetadata(""))
|
||||
}
|
||||
|
||||
func errorOccured(error _: AudioPlayerError) {}
|
||||
func errorOccurred(error _: AudioPlayerError) {}
|
||||
|
||||
func metadataReceived(metadata: [String: String]) {
|
||||
guard !metadata.isEmpty else { return }
|
||||
|
||||
@@ -51,7 +51,7 @@ class PlayerViewController: UIViewController {
|
||||
style: .plain,
|
||||
target: self,
|
||||
action: #selector(showEqualizer))
|
||||
|
||||
|
||||
tableView.translatesAutoresizingMaskIntoConstraints = false
|
||||
tableView.delegate = self
|
||||
tableView.dataSource = self
|
||||
@@ -92,12 +92,13 @@ class PlayerViewController: UIViewController {
|
||||
|
||||
@objc private func addNowPlaylistItem() {
|
||||
let controller = UIAlertController(title: "Add new item", message: "", preferredStyle: .alert)
|
||||
controller.addTextField { (textField) in
|
||||
controller.addTextField { textField in
|
||||
textField.placeholder = "Insert url here"
|
||||
}
|
||||
let saveAction = UIAlertAction(title: "Save", style: .default) { [viewModel] action in
|
||||
let saveAction = UIAlertAction(title: "Save", style: .default) { [viewModel] _ in
|
||||
if let textfield = controller.textFields?.first,
|
||||
let text = textfield.text {
|
||||
let text = textfield.text
|
||||
{
|
||||
viewModel.add(urlString: text)
|
||||
}
|
||||
}
|
||||
@@ -105,7 +106,7 @@ class PlayerViewController: UIViewController {
|
||||
|
||||
controller.addAction(saveAction)
|
||||
controller.addAction(cancelAction)
|
||||
self.present(controller, animated: true, completion: nil)
|
||||
present(controller, animated: true, completion: nil)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,7 +121,8 @@ extension PlayerViewController: UITableViewDataSource {
|
||||
return cell
|
||||
}
|
||||
cell.textLabel?.text = item.name
|
||||
cell.detailTextLabel?.text = item.queues ? "Queue item" : nil
|
||||
let queuedItem = item.queues ? "Queue item" : nil
|
||||
cell.detailTextLabel?.text = queuedItem ?? item.subtitle
|
||||
update(status: item.status, of: cell)
|
||||
return cell
|
||||
}
|
||||
@@ -149,14 +151,13 @@ extension PlayerViewController: UITableViewDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
final class PlaylistTableViewCell: UITableViewCell {
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
override init(style _: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: .subtitle, reuseIdentifier: reuseIdentifier)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
required init?(coder _: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,14 +18,15 @@ final class PlayerViewModel {
|
||||
private let playerService: AudioPlayerService
|
||||
private let playlistItemsService: PlaylistItemsService
|
||||
|
||||
private let routeTo: ((AppCoordinator.Route) -> Void)
|
||||
private let routeTo: (AppCoordinator.Route) -> Void
|
||||
private var currentPlayingItemIndex: Int?
|
||||
|
||||
var reloadContent: ((ReloadAction) -> Void)?
|
||||
|
||||
init(playlistItemsService: PlaylistItemsService,
|
||||
playerService: AudioPlayerService,
|
||||
routeTo: @escaping (AppCoordinator.Route) -> Void) {
|
||||
routeTo: @escaping (AppCoordinator.Route) -> Void)
|
||||
{
|
||||
self.playlistItemsService = playlistItemsService
|
||||
self.playerService = playerService
|
||||
self.routeTo = routeTo
|
||||
@@ -47,7 +48,7 @@ final class PlayerViewModel {
|
||||
print("malformed url error")
|
||||
return
|
||||
}
|
||||
playlistItemsService.add(item: PlaylistItem(url: url, name: urlString, status: .stopped, queues: false))
|
||||
playlistItemsService.add(item: PlaylistItem(url: url, name: urlString, subtitle: nil, status: .stopped, queues: false))
|
||||
reloadContent?(.all)
|
||||
}
|
||||
|
||||
@@ -96,7 +97,7 @@ extension PlayerViewModel: AudioPlayerServiceDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
func errorOccured(error _: AudioPlayerError) {
|
||||
func errorOccurred(error _: AudioPlayerError) {
|
||||
currentPlayingItemIndex = nil
|
||||
}
|
||||
|
||||
|
||||
Binary file not shown.
@@ -16,8 +16,9 @@ enum AudioContent: Int, CaseIterable {
|
||||
case radiox
|
||||
case khruangbin
|
||||
case piano
|
||||
case remoteWave
|
||||
case local
|
||||
case podcast
|
||||
case localWave
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
@@ -35,10 +36,37 @@ enum AudioContent: Int, CaseIterable {
|
||||
return "Khruangbin (mp3 preview)"
|
||||
case .piano:
|
||||
return "Piano (mp3)"
|
||||
case .remoteWave:
|
||||
return "Sample remote (wave)"
|
||||
case .local:
|
||||
return "Local file (mp3)"
|
||||
case .podcast:
|
||||
return "Swift by Sundell. Ep. 50 (mp3)"
|
||||
return "Jazzy Frenchy (local mp3)"
|
||||
case .localWave:
|
||||
return "Local file (local wave)"
|
||||
}
|
||||
}
|
||||
|
||||
var subtitle: String? {
|
||||
switch self {
|
||||
case .offradio:
|
||||
return nil
|
||||
case .enlefko:
|
||||
return nil
|
||||
case .pepper966:
|
||||
return nil
|
||||
case .kosmos:
|
||||
return nil
|
||||
case .radiox:
|
||||
return nil
|
||||
case .khruangbin:
|
||||
return nil
|
||||
case .piano:
|
||||
return nil
|
||||
case .remoteWave:
|
||||
return nil
|
||||
case .local:
|
||||
return "Music by: bensound.com"
|
||||
case .localWave:
|
||||
return "Music by: bensound.com"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,7 +77,7 @@ enum AudioContent: Int, CaseIterable {
|
||||
case .offradio:
|
||||
return URL(string: "https://s3.yesstreaming.net:17062/stream")!
|
||||
case .pepper966:
|
||||
return URL(string: "https://ample-09.radiojar.com/pepper.m4a?1593699983=&rj-tok=AAABcw_1KyMAIViq2XpI098ZSQ&rj-ttl=5")!
|
||||
return URL(string: "https://n04.radiojar.com/pepper.m4a?1662039818=&rj-tok=AAABgvlUaioALhdOXDt0mgajoA&rj-ttl=5")!
|
||||
case .kosmos:
|
||||
return URL(string: "https://radiostreaming.ert.gr/ert-kosmos")!
|
||||
case .radiox:
|
||||
@@ -61,8 +89,11 @@ enum AudioContent: Int, CaseIterable {
|
||||
case .local:
|
||||
let path = Bundle.main.path(forResource: "bensound-jazzyfrenchy", ofType: "mp3")!
|
||||
return URL(fileURLWithPath: path)
|
||||
case .podcast:
|
||||
return URL(string: "https://hwcdn.libsyn.com/p/f/6/e/f6e7cb785cf0f71f/SwiftBySundell50.mp3?c_id=45232967&cs_id=45232967&expiration=1605613140&hwt=f9ff0b2f758c3286cd75322e14ef7a23")!
|
||||
case .localWave:
|
||||
let path = Bundle.main.path(forResource: "hipjazz", ofType: "wav")!
|
||||
return URL(fileURLWithPath: path)
|
||||
case .remoteWave:
|
||||
return URL(string: "https://file-examples.com/storage/fe183d9197630fb5c969255/2017/11/file_example_WAV_5MG.wav")!
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ protocol AudioPlayerServiceDelegate: AnyObject {
|
||||
func didStartPlaying()
|
||||
func didStopPlaying()
|
||||
func statusChanged(status: AudioPlayerState)
|
||||
func errorOccured(error: AudioPlayerError)
|
||||
func errorOccurred(error: AudioPlayerError)
|
||||
func metadataReceived(metadata: [String: String])
|
||||
}
|
||||
|
||||
@@ -168,7 +168,7 @@ extension AudioPlayerService: AudioPlayerDelegate {
|
||||
}
|
||||
|
||||
func audioPlayerUnexpectedError(player _: AudioPlayer, error: AudioPlayerError) {
|
||||
delegate.invoke(invocation: { $0.errorOccured(error: error) })
|
||||
delegate.invoke(invocation: { $0.errorOccurred(error: error) })
|
||||
}
|
||||
|
||||
func audioPlayerDidCancel(player _: AudioPlayer, queuedItems _: [AudioEntryId]) {}
|
||||
|
||||
@@ -10,7 +10,7 @@ import AVFoundation
|
||||
|
||||
final class EqualizerService {
|
||||
private let playerService: AudioPlayerService
|
||||
private let _freqs = [32, 64, 128, 250, 500, 1_000, 2_000, 4_000, 8_000, 16_000]
|
||||
private let _freqs = [32, 64, 128, 250, 500, 1000, 2000, 4000, 8000, 16000]
|
||||
private let eqUnit: AVAudioUnitEQ
|
||||
|
||||
var bands: [AVAudioUnitEQFilterParameters] {
|
||||
@@ -23,7 +23,7 @@ final class EqualizerService {
|
||||
self.playerService = playerService
|
||||
|
||||
eqUnit = AVAudioUnitEQ(numberOfBands: _freqs.count)
|
||||
for i in 0..<_freqs.count {
|
||||
for i in 0 ..< _freqs.count {
|
||||
eqUnit.bands[i].bypass = false
|
||||
eqUnit.bands[i].filterType = .parametric
|
||||
eqUnit.bands[i].frequency = Float(_freqs[i])
|
||||
@@ -45,7 +45,7 @@ final class EqualizerService {
|
||||
playerService.add(eqUnit)
|
||||
}
|
||||
|
||||
func deactive() {
|
||||
func deactivate() {
|
||||
isActivated = false
|
||||
playerService.remove(eqUnit)
|
||||
}
|
||||
|
||||
@@ -9,10 +9,9 @@
|
||||
import MediaPlayer
|
||||
|
||||
final class NowPlayingCenter {
|
||||
|
||||
private let infoCenter: MPNowPlayingInfoCenter
|
||||
|
||||
init(infoCenter: MPNowPlayingInfoCenter = .default()){
|
||||
init(infoCenter: MPNowPlayingInfoCenter = .default()) {
|
||||
self.infoCenter = infoCenter
|
||||
}
|
||||
|
||||
|
||||
@@ -18,19 +18,22 @@ struct PlaylistItem: Equatable {
|
||||
|
||||
let url: URL
|
||||
let name: String
|
||||
let subtitle: String?
|
||||
let status: Status
|
||||
let queues: Bool
|
||||
|
||||
init(content: AudioContent, queues: Bool) {
|
||||
name = content.title
|
||||
subtitle = content.subtitle
|
||||
url = content.streamUrl
|
||||
status = .stopped
|
||||
self.queues = queues
|
||||
}
|
||||
|
||||
init(url: URL, name: String, status: Status, queues: Bool) {
|
||||
init(url: URL, name: String, subtitle: String?, status: Status, queues: Bool) {
|
||||
self.url = url
|
||||
self.name = name
|
||||
self.subtitle = subtitle
|
||||
self.status = status
|
||||
self.queues = queues
|
||||
}
|
||||
@@ -73,14 +76,20 @@ final class PlaylistItemsService {
|
||||
guard let item = item(at: index) else {
|
||||
return
|
||||
}
|
||||
items[index] = PlaylistItem(url: item.url, name: item.name, status: status, queues: item.queues)
|
||||
items[index] = PlaylistItem(
|
||||
url: item.url,
|
||||
name: item.name,
|
||||
subtitle: item.subtitle,
|
||||
status: status,
|
||||
queues: item.queues
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func provideInitialPlaylistItems() -> [PlaylistItem] {
|
||||
let allCases = AudioContent.allCases
|
||||
let casesForQueueing: [AudioContent] = [.piano, .local, .khruangbin]
|
||||
let allItems = allCases.map { PlaylistItem.init(content: $0 , queues: false) }
|
||||
let casesForQueuingItems = casesForQueueing.map { PlaylistItem.init(content: $0 , queues: true) }
|
||||
let allItems = allCases.map { PlaylistItem(content: $0, queues: false) }
|
||||
let casesForQueuingItems = casesForQueueing.map { PlaylistItem(content: $0, queues: true) }
|
||||
return allItems + casesForQueuingItems
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
Pod::Spec.new do |s|
|
||||
s.name = 'AudioStreaming'
|
||||
s.version = '0.5.0'
|
||||
s.version = '1.1.0'
|
||||
s.license = 'MIT'
|
||||
s.summary = 'An AudioPlayer/Streaming library for iOS written in Swift using AVAudioEngine.'
|
||||
s.homepage = 'https://github.com/dimitris-c/AudioStreaming'
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
98CC396E28BD651E006C9FF9 /* Atomic.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98CC396D28BD651E006C9FF9 /* Atomic.swift */; };
|
||||
B500732024D00BAC00BB4475 /* Logger.swift in Sources */ = {isa = PBXBuildFile; fileRef = B500731F24D00BAC00BB4475 /* Logger.swift */; };
|
||||
B514657F248E3884005C03F7 /* DispatchTimerSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = B514657E248E3884005C03F7 /* DispatchTimerSource.swift */; };
|
||||
B51B9F9A24DBE5BF00BDEAA2 /* AVAudioFormat+Convenience.swift in Sources */ = {isa = PBXBuildFile; fileRef = B51B9F9924DBE5BF00BDEAA2 /* AVAudioFormat+Convenience.swift */; };
|
||||
@@ -52,6 +53,7 @@
|
||||
B59DF1A32493E90C0043C498 /* AudioFileStream+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = B59DF1A22493E90C0043C498 /* AudioFileStream+Helpers.swift */; };
|
||||
B5AEDBB824744153007D8101 /* AudioStreaming.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B5AEDBAE24744153007D8101 /* AudioStreaming.framework */; };
|
||||
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 */; };
|
||||
B5D4A40925D9321400E1450C /* IcycastHeaderParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5D4A40825D9321400E1450C /* IcycastHeaderParser.swift */; };
|
||||
B5D4A41025D948EF00E1450C /* IcycastHeadersProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5D4A40B25D9445600E1450C /* IcycastHeadersProcessor.swift */; };
|
||||
@@ -63,8 +65,7 @@
|
||||
B5EF9557247E9439003E8FF8 /* AudioStreamSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5EF9556247E9439003E8FF8 /* AudioStreamSource.swift */; };
|
||||
B5EF955B247EBCB3003E8FF8 /* AudioFileType.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5EF955A247EBCB3003E8FF8 /* AudioFileType.swift */; };
|
||||
B5EF955D247ECBB1003E8FF8 /* RemoteAudioSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5EF955C247ECBB1003E8FF8 /* RemoteAudioSource.swift */; };
|
||||
B5F883B62476DADB00D277C1 /* Protected.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5F883B52476DADB00D277C1 /* Protected.swift */; };
|
||||
B5F883BA2477CEFC00D277C1 /* ProtectedTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5F883B82477CBF600D277C1 /* ProtectedTests.swift */; };
|
||||
B5F883BA2477CEFC00D277C1 /* AtomicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5F883B82477CBF600D277C1 /* AtomicTests.swift */; };
|
||||
B5F883C32477DC4400D277C1 /* NetworkDataStream.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5F883C22477DC4400D277C1 /* NetworkDataStream.swift */; };
|
||||
B5FB6C0525516507002C0A37 /* AudioConverter+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5FB6C0425516507002C0A37 /* AudioConverter+Helpers.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
@@ -93,6 +94,7 @@
|
||||
/* End PBXCopyFilesBuildPhase section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
98CC396D28BD651E006C9FF9 /* Atomic.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Atomic.swift; sourceTree = "<group>"; };
|
||||
B500731F24D00BAC00BB4475 /* Logger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Logger.swift; sourceTree = "<group>"; };
|
||||
B514657E248E3884005C03F7 /* DispatchTimerSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DispatchTimerSource.swift; sourceTree = "<group>"; };
|
||||
B51B9F9924DBE5BF00BDEAA2 /* AVAudioFormat+Convenience.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AVAudioFormat+Convenience.swift"; sourceTree = "<group>"; };
|
||||
@@ -144,6 +146,7 @@
|
||||
B5AEDBB224744153007D8101 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
B5AEDBB724744153007D8101 /* AudioStreamingTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = AudioStreamingTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
B5AEDBBE24744153007D8101 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
B5B36E422655A32200DC96F5 /* FrameFilterProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FrameFilterProcessor.swift; sourceTree = "<group>"; };
|
||||
B5B3B7CB248647ED00656828 /* AudioPlayerState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlayerState.swift; sourceTree = "<group>"; };
|
||||
B5D4A40825D9321400E1450C /* IcycastHeaderParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IcycastHeaderParser.swift; sourceTree = "<group>"; };
|
||||
B5D4A40B25D9445600E1450C /* IcycastHeadersProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IcycastHeadersProcessor.swift; sourceTree = "<group>"; };
|
||||
@@ -156,8 +159,7 @@
|
||||
B5EF9556247E9439003E8FF8 /* AudioStreamSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioStreamSource.swift; sourceTree = "<group>"; };
|
||||
B5EF955A247EBCB3003E8FF8 /* AudioFileType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioFileType.swift; sourceTree = "<group>"; };
|
||||
B5EF955C247ECBB1003E8FF8 /* RemoteAudioSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteAudioSource.swift; sourceTree = "<group>"; };
|
||||
B5F883B52476DADB00D277C1 /* Protected.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Protected.swift; sourceTree = "<group>"; };
|
||||
B5F883B82477CBF600D277C1 /* ProtectedTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProtectedTests.swift; sourceTree = "<group>"; };
|
||||
B5F883B82477CBF600D277C1 /* AtomicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AtomicTests.swift; sourceTree = "<group>"; };
|
||||
B5F883C22477DC4400D277C1 /* NetworkDataStream.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkDataStream.swift; sourceTree = "<group>"; };
|
||||
B5FB6C0425516507002C0A37 /* AudioConverter+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AudioConverter+Helpers.swift"; sourceTree = "<group>"; };
|
||||
B5FFF5FD2549FA02006BBB7C /* AudioExample.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = AudioExample.xctestplan; sourceTree = "<group>"; };
|
||||
@@ -259,6 +261,7 @@
|
||||
B55CEAC024855AA20001C498 /* Processors */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
B5B36E422655A32200DC96F5 /* FrameFilterProcessor.swift */,
|
||||
B5667A8F2499018D00D93F85 /* AudioFileStreamProcessor.swift */,
|
||||
B5667B3D249BC43000D93F85 /* AudioPlayerRenderProcessor.swift */,
|
||||
B55CE97024810DE20001C498 /* MetadataStreamProcessor.swift */,
|
||||
@@ -318,11 +321,11 @@
|
||||
B592E13025460883008866FB /* Helpers */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
98CC396D28BD651E006C9FF9 /* Atomic.swift */,
|
||||
B573733F254DE43E003DFBEC /* measure.swift */,
|
||||
B514657E248E3884005C03F7 /* DispatchTimerSource.swift */,
|
||||
B57829CE2548B32B00C78D36 /* Lock.swift */,
|
||||
B500731F24D00BAC00BB4475 /* Logger.swift */,
|
||||
B5F883B52476DADB00D277C1 /* Protected.swift */,
|
||||
B54C3E55255F286D00B356F2 /* Retrier.swift */,
|
||||
);
|
||||
path = Helpers;
|
||||
@@ -440,7 +443,7 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
B5EF954A247DA450003E8FF8 /* Network */,
|
||||
B5F883B82477CBF600D277C1 /* ProtectedTests.swift */,
|
||||
B5F883B82477CBF600D277C1 /* AtomicTests.swift */,
|
||||
B51FE0C12488F96A00F2A4D2 /* QueueTests.swift */,
|
||||
B592E12825460146008866FB /* BiMapTests.swift */,
|
||||
B592E133254608B4008866FB /* DispatchTimerSourceTests.swift */,
|
||||
@@ -596,6 +599,7 @@
|
||||
B5838640254584A50087A712 /* ProcessedPackets.swift in Sources */,
|
||||
B54C3E56255F286D00B356F2 /* Retrier.swift in Sources */,
|
||||
B59DF10424916FD50043C498 /* DispatchQueue+Helpers.swift in Sources */,
|
||||
98CC396E28BD651E006C9FF9 /* Atomic.swift in Sources */,
|
||||
B5B3B7CC248647ED00656828 /* AudioPlayerState.swift in Sources */,
|
||||
B51B9F9A24DBE5BF00BDEAA2 /* AVAudioFormat+Convenience.swift in Sources */,
|
||||
B51FE0C624890CCB00F2A4D2 /* PlayerQueueEntries.swift in Sources */,
|
||||
@@ -615,6 +619,7 @@
|
||||
B5667A902499018D00D93F85 /* AudioFileStreamProcessor.swift in Sources */,
|
||||
B59D0B6F255C904900D6CCE5 /* FileAudioSource.swift in Sources */,
|
||||
B5EF9555247E9393003E8FF8 /* AudioEntry.swift in Sources */,
|
||||
B5B36E432655A32200DC96F5 /* FrameFilterProcessor.swift in Sources */,
|
||||
B51FE0C02488F67C00F2A4D2 /* Queue.swift in Sources */,
|
||||
B5667A922499063D00D93F85 /* AudioPlayerContext.swift in Sources */,
|
||||
B55CE97124810DE20001C498 /* MetadataStreamProcessor.swift in Sources */,
|
||||
@@ -634,7 +639,6 @@
|
||||
B5838648254584D90087A712 /* SeekRequest.swift in Sources */,
|
||||
B5D82E65255DD562009EDAA4 /* NetStatusService.swift in Sources */,
|
||||
B55CE97824813BCA0001C498 /* UnsafeMutablePointer+Helpers.swift in Sources */,
|
||||
B5F883B62476DADB00D277C1 /* Protected.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -647,7 +651,7 @@
|
||||
B51FE0C824892D1600F2A4D2 /* PlayerQueueEntriesTest.swift in Sources */,
|
||||
B55CEABA248530C00001C498 /* MetadataParser.swift in Sources */,
|
||||
B51FE0C22488F96A00F2A4D2 /* QueueTests.swift in Sources */,
|
||||
B5F883BA2477CEFC00D277C1 /* ProtectedTests.swift in Sources */,
|
||||
B5F883BA2477CEFC00D277C1 /* AtomicTests.swift in Sources */,
|
||||
B592E134254608B4008866FB /* DispatchTimerSourceTests.swift in Sources */,
|
||||
B55CEAB82485172D0001C498 /* HTTPHeaderParserTests.swift in Sources */,
|
||||
B592E12925460146008866FB /* BiMapTests.swift in Sources */,
|
||||
@@ -718,7 +722,7 @@
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
|
||||
MARKETING_VERSION = 0.1.0;
|
||||
MARKETING_VERSION = 1.1.0;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
@@ -777,7 +781,7 @@
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
|
||||
MARKETING_VERSION = 0.1.0;
|
||||
MARKETING_VERSION = 1.1.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
MTL_FAST_MATH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
@@ -794,6 +798,7 @@
|
||||
buildSettings = {
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 2;
|
||||
DEFINES_MODULE = YES;
|
||||
DYLIB_COMPATIBILITY_VERSION = 1;
|
||||
DYLIB_CURRENT_VERSION = 1;
|
||||
@@ -806,7 +811,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@loader_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.5.0;
|
||||
MARKETING_VERSION = 1.1.0;
|
||||
OTHER_LDFLAGS = "-ObjC";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.decimal.AudioStreaming;
|
||||
PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)";
|
||||
@@ -824,6 +829,7 @@
|
||||
buildSettings = {
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 2;
|
||||
DEFINES_MODULE = YES;
|
||||
DYLIB_COMPATIBILITY_VERSION = 1;
|
||||
DYLIB_CURRENT_VERSION = 1;
|
||||
@@ -836,7 +842,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@loader_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.5.0;
|
||||
MARKETING_VERSION = 1.1.0;
|
||||
OTHER_LDFLAGS = "-ObjC";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.decimal.AudioStreaming;
|
||||
PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)";
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
import AVFoundation
|
||||
|
||||
extension AVAudioFormat {
|
||||
public extension AVAudioFormat {
|
||||
/// The underlying audio stream description.
|
||||
///
|
||||
/// This exposes the `pointee` value of the `UsafePointer<AudioStreamBasicDescription>`
|
||||
|
||||
@@ -13,10 +13,10 @@ final class Atomic<Value> {
|
||||
_value = value
|
||||
}
|
||||
|
||||
var value: Value { lock.around { _value } }
|
||||
var value: Value { lock.withLock { _value } }
|
||||
|
||||
func write(_ transform: (inout Value) -> Void) {
|
||||
lock.around { transform(&self._value) }
|
||||
lock.withLock { transform(&self._value) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,28 +8,18 @@ import Foundation
|
||||
protocol Lock {
|
||||
func lock()
|
||||
func unlock()
|
||||
}
|
||||
|
||||
extension Lock {
|
||||
// Execute a closure while acquiring a lock and returns the closure value
|
||||
@inline(__always)
|
||||
func around<Value>(_ closure: () -> Value) -> Value {
|
||||
lock(); defer { unlock() }
|
||||
return closure()
|
||||
}
|
||||
func withLock<Result>(body: () throws -> Result) rethrows -> Result
|
||||
|
||||
// Execute a closure while acquiring a lock
|
||||
@inline(__always)
|
||||
func around(_ closure: () -> Void) {
|
||||
lock(); defer { unlock() }
|
||||
closure()
|
||||
}
|
||||
func withLock(body: () -> Void)
|
||||
}
|
||||
|
||||
/// A wrapper for `os_unfair_lock`
|
||||
/// - Tag: UnfairLock
|
||||
final class UnfairLock: Lock {
|
||||
private let unfairLock: os_unfair_lock_t
|
||||
@usableFromInline let unfairLock: UnsafeMutablePointer<os_unfair_lock>
|
||||
|
||||
internal init() {
|
||||
unfairLock = .allocate(capacity: 1)
|
||||
@@ -37,15 +27,32 @@ final class UnfairLock: Lock {
|
||||
}
|
||||
|
||||
deinit {
|
||||
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) }
|
||||
return try body()
|
||||
}
|
||||
|
||||
@inlinable
|
||||
@inline(__always)
|
||||
func withLock(body: () -> Void) {
|
||||
os_unfair_lock_lock(unfairLock)
|
||||
defer { os_unfair_lock_unlock(unfairLock) }
|
||||
body()
|
||||
}
|
||||
|
||||
@inlinable
|
||||
@inline(__always)
|
||||
internal func lock() {
|
||||
os_unfair_lock_lock(unfairLock)
|
||||
}
|
||||
|
||||
@inlinable
|
||||
@inline(__always)
|
||||
internal func unlock() {
|
||||
os_unfair_lock_unlock(unfairLock)
|
||||
|
||||
@@ -31,7 +31,7 @@ internal enum Logger {
|
||||
}
|
||||
|
||||
static func error(_ message: StaticString, category: Category, args: CVarArg...) {
|
||||
proccess(message, category: category, type: .error, args: args)
|
||||
process(message, category: category, type: .error, args: args)
|
||||
}
|
||||
|
||||
static func error(_ message: StaticString, category: Category) {
|
||||
@@ -39,14 +39,14 @@ internal enum Logger {
|
||||
}
|
||||
|
||||
static func debug(_ message: StaticString, category: Category, args: CVarArg...) {
|
||||
proccess(message, category: category, type: .debug, args: args)
|
||||
process(message, category: category, type: .debug, args: args)
|
||||
}
|
||||
|
||||
static func debug(_ message: StaticString, category: Category) {
|
||||
debug(message, category: category, args: [])
|
||||
}
|
||||
|
||||
private static func proccess(_ message: StaticString, category: Category, type: OSLogType, args: CVarArg...) {
|
||||
private static func process(_ message: StaticString, category: Category, type: OSLogType, args: CVarArg...) {
|
||||
guard isEnabled else { return }
|
||||
os_log(message, log: category.toOSLog(), type: type, args)
|
||||
}
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
//
|
||||
// Created by Dimitrios Chatzieleftheriou on 21/05/2020.
|
||||
// Copyright © 2020 Decimal. All rights reserved.
|
||||
//
|
||||
|
||||
internal final class Protected<Value> {
|
||||
var value: Value { lock.around { _value } }
|
||||
|
||||
private let lock = UnfairLock()
|
||||
private var _value: Value
|
||||
|
||||
init(_ value: Value) {
|
||||
_value = value
|
||||
}
|
||||
|
||||
func read<Element>(_ closure: (Value) -> Element) -> Element {
|
||||
lock.around { closure(self._value) }
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func write<Element>(_ closure: (inout Value) -> Element) -> Element {
|
||||
lock.around { closure(&self._value) }
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ final class Retrier {
|
||||
private let maxInterval: Int
|
||||
private let timeoutTimer: DispatchTimerSource
|
||||
|
||||
/// Initiliazes a new object with the given parameters
|
||||
/// Initializes a new object with the given parameters
|
||||
/// - Parameters:
|
||||
/// - interval: The Mach absolute time at which to execute the dispatch source's event handler.
|
||||
/// - maxInterval: The maximum interval in which the internal timer will retry the callback.
|
||||
@@ -38,6 +38,7 @@ final class Retrier {
|
||||
|
||||
/// Cancels retrying
|
||||
func cancel() {
|
||||
interval = .seconds(1)
|
||||
timeoutTimer.removeHandler()
|
||||
timeoutTimer.suspend()
|
||||
}
|
||||
|
||||
@@ -9,12 +9,14 @@ import Network
|
||||
enum NetConnectionType: Equatable {
|
||||
case cellular(connected: Bool)
|
||||
case wifi(connected: Bool)
|
||||
case other(connected: Bool)
|
||||
case undetermined
|
||||
|
||||
var isConnected: Bool {
|
||||
switch self {
|
||||
case let .cellular(connected),
|
||||
let .wifi(connected):
|
||||
let .wifi(connected),
|
||||
let .other(connected):
|
||||
return connected
|
||||
default:
|
||||
return false
|
||||
@@ -39,15 +41,13 @@ final class NetStatusService: NetStatusProvider {
|
||||
network.currentPath.toNetConnectionType()
|
||||
}
|
||||
|
||||
private var currentConnectionType: NetConnectionType = .undetermined
|
||||
|
||||
private let network: NWPathMonitor
|
||||
|
||||
private let monitorQueue: DispatchQueue
|
||||
|
||||
init(network: NWPathMonitor) {
|
||||
self.network = network
|
||||
monitorQueue = DispatchQueue(label: "net.path.queue", qos: .background)
|
||||
monitorQueue = DispatchQueue(label: "net.path.queue", qos: .utility)
|
||||
}
|
||||
|
||||
deinit {
|
||||
@@ -59,20 +59,15 @@ final class NetStatusService: NetStatusProvider {
|
||||
/// - parameter connectionChange: A callback block to listen to changes of the network type, this skips duplicates.
|
||||
/// - Note: The callback will be executed on the main thread.
|
||||
func start(connectionChange: @escaping (NetConnectionType) -> Void) {
|
||||
network.pathUpdateHandler = { [weak self] path in
|
||||
guard let self = self else { return }
|
||||
let connecionType = path.toNetConnectionType()
|
||||
if self.currentConnectionType != connecionType {
|
||||
connectionChange(self.connectionType)
|
||||
self.currentConnectionType = self.connectionType
|
||||
}
|
||||
network.pathUpdateHandler = { path in
|
||||
let connectionType = path.toNetConnectionType()
|
||||
connectionChange(connectionType)
|
||||
}
|
||||
startIfNeeded()
|
||||
}
|
||||
|
||||
func stop() {
|
||||
network.cancel()
|
||||
network.pathUpdateHandler = nil
|
||||
}
|
||||
|
||||
func startIfNeeded() {
|
||||
@@ -85,12 +80,17 @@ extension NWPath {
|
||||
func toNetConnectionType() -> NetConnectionType {
|
||||
let isCellular = usesInterfaceType(.cellular)
|
||||
let isWifi = usesInterfaceType(.wifi)
|
||||
let isOther = usesInterfaceType(.loopback)
|
||||
|| usesInterfaceType(.other)
|
||||
|| usesInterfaceType(.wiredEthernet)
|
||||
let isConnected = status == .satisfied
|
||||
|
||||
if isCellular {
|
||||
return .cellular(connected: isConnected)
|
||||
} else if isWifi {
|
||||
return .wifi(connected: isConnected)
|
||||
} else if isOther {
|
||||
return .other(connected: isConnected)
|
||||
}
|
||||
|
||||
return .undetermined
|
||||
|
||||
@@ -13,12 +13,15 @@ enum DataStreamError: Error {
|
||||
public enum NetworkError: Error, Equatable {
|
||||
case failure(Error)
|
||||
case serverError
|
||||
case missingData
|
||||
public static func == (lhs: NetworkError, rhs: NetworkError) -> Bool {
|
||||
switch (lhs, rhs) {
|
||||
case (.failure, failure):
|
||||
return true
|
||||
case (.serverError, .serverError):
|
||||
return true
|
||||
case (.missingData, .missingData):
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
@@ -74,15 +77,16 @@ internal final class NetworkingClient {
|
||||
}
|
||||
|
||||
internal func remove(task: NetworkDataStream) {
|
||||
tasksLock.lock(); defer { tasksLock.unlock() }
|
||||
if !tasks.isEmpty {
|
||||
tasks[task] = nil
|
||||
tasksLock.withLock {
|
||||
if !tasks.isEmpty {
|
||||
tasks[task] = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Private
|
||||
|
||||
/// Schedules the given `NetworkDataStream` to be performed immediatelly
|
||||
/// Schedules the given `NetworkDataStream` to be performed immediately
|
||||
/// - parameter stream: The `NetworkDataStream` object to be performed
|
||||
/// - parameter request: The `URLRequest` for the `stream`
|
||||
private func setupRequest(_ stream: NetworkDataStream, request: URLRequest) {
|
||||
@@ -97,13 +101,15 @@ internal final class NetworkingClient {
|
||||
|
||||
extension NetworkingClient: StreamTaskProvider {
|
||||
internal func dataStream(for request: URLSessionTask) -> NetworkDataStream? {
|
||||
tasksLock.lock(); defer { tasksLock.unlock() }
|
||||
return tasks[request] ?? nil
|
||||
tasksLock.withLock {
|
||||
tasks[request] ?? nil
|
||||
}
|
||||
}
|
||||
|
||||
internal func sessionTask(for stream: NetworkDataStream) -> URLSessionTask? {
|
||||
tasksLock.lock(); defer { tasksLock.unlock() }
|
||||
return tasks[stream] ?? nil
|
||||
tasksLock.withLock {
|
||||
tasks[stream] ?? nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
/// A convenient type that holds tasks in a two-way manner, such as `URLSessionTask` to `NetworkDataStream` and reverved
|
||||
/// A convenient type that holds tasks in a two-way manner, such as `URLSessionTask` to `NetworkDataStream` and reversed
|
||||
struct BiMap<Left, Right> where Left: Hashable, Right: Hashable {
|
||||
private var leftToRight: [Left: Right] = [:]
|
||||
private var rightToLeft: [Right: Left] = [:]
|
||||
|
||||
@@ -22,9 +22,7 @@ internal class AudioEntry {
|
||||
let id: AudioEntryId
|
||||
|
||||
/// The sample rate from the `audioStreamFormat`
|
||||
var sampleRate: Float {
|
||||
Float(audioStreamFormat.mSampleRate)
|
||||
}
|
||||
var sampleRate: Float
|
||||
|
||||
var audioFileHint: AudioFileTypeID {
|
||||
source.audioFileHint
|
||||
@@ -49,11 +47,9 @@ internal class AudioEntry {
|
||||
private(set) var framesState: EntryFramesState
|
||||
private(set) var processedPacketsState: ProcessedPacketsState
|
||||
|
||||
var packetDuration: Double {
|
||||
return Double(audioStreamFormat.mFramesPerPacket) / Double(sampleRate)
|
||||
}
|
||||
var packetDuration: Double
|
||||
|
||||
private var avaragePacketByteSize: Double {
|
||||
private var averagePacketByteSize: Double {
|
||||
let packets = processedPacketsState
|
||||
guard !packets.isEmpty else { return 0 }
|
||||
return Double(packets.sizeTotal / packets.count)
|
||||
@@ -72,6 +68,8 @@ internal class AudioEntry {
|
||||
processedPacketsState = ProcessedPacketsState()
|
||||
framesState = EntryFramesState()
|
||||
audioStreamState = AudioStreamState()
|
||||
sampleRate = 0
|
||||
packetDuration = 0
|
||||
}
|
||||
|
||||
func close() {
|
||||
@@ -109,7 +107,7 @@ internal class AudioEntry {
|
||||
if packetsCount > estimationMinPacketsPreferred ||
|
||||
(audioStreamFormat.mBytesPerFrame == 0 && packetsCount > estimationMinPackets)
|
||||
{
|
||||
return avaragePacketByteSize / packetDuration * 8
|
||||
return averagePacketByteSize / packetDuration * 8
|
||||
}
|
||||
}
|
||||
return (Double(audioStreamFormat.mBytesPerFrame) * audioStreamFormat.mSampleRate) * 8
|
||||
@@ -151,12 +149,12 @@ extension AudioEntry: AudioStreamSourceDelegate {
|
||||
delegate?.dataAvailable(source: source, data: data)
|
||||
}
|
||||
|
||||
func errorOccured(source: CoreAudioStreamSource, error: Error) {
|
||||
delegate?.errorOccured(source: source, error: error)
|
||||
func errorOccurred(source: CoreAudioStreamSource, error: Error) {
|
||||
delegate?.errorOccurred(source: source, error: error)
|
||||
}
|
||||
|
||||
func endOfFileOccured(source: CoreAudioStreamSource) {
|
||||
delegate?.endOfFileOccured(source: source)
|
||||
func endOfFileOccurred(source: CoreAudioStreamSource) {
|
||||
delegate?.endOfFileOccurred(source: source)
|
||||
}
|
||||
|
||||
func metadataReceived(data: [String: String]) {
|
||||
|
||||
@@ -8,6 +8,6 @@ import Foundation
|
||||
final class SeekRequest {
|
||||
let lock = UnfairLock()
|
||||
var requested: Bool = false
|
||||
var version = Protected<Int>(0)
|
||||
var version = Atomic<Int>(0)
|
||||
var time: Double = 0
|
||||
}
|
||||
|
||||
@@ -10,9 +10,9 @@ protocol AudioStreamSourceDelegate: AnyObject {
|
||||
/// Indicates that there's data available
|
||||
func dataAvailable(source: CoreAudioStreamSource, data: Data)
|
||||
/// Indicates an error occurred
|
||||
func errorOccured(source: CoreAudioStreamSource, error: Error)
|
||||
func errorOccurred(source: CoreAudioStreamSource, error: Error)
|
||||
/// Indicates end of file has occurred
|
||||
func endOfFileOccured(source: CoreAudioStreamSource)
|
||||
func endOfFileOccurred(source: CoreAudioStreamSource)
|
||||
/// Indicates metadata read from stream
|
||||
func metadataReceived(data: [String: String])
|
||||
}
|
||||
|
||||
@@ -54,12 +54,8 @@ final class FileAudioSource: NSObject, CoreAudioStreamSource {
|
||||
inputStream.delegate = nil
|
||||
}
|
||||
|
||||
func suspend() {
|
||||
guard let inputStream = inputStream else {
|
||||
return
|
||||
}
|
||||
CFReadStreamSetDispatchQueue(inputStream, nil)
|
||||
}
|
||||
// no-op
|
||||
func suspend() { }
|
||||
|
||||
func resume() {
|
||||
guard let inputStream = inputStream else {
|
||||
@@ -69,31 +65,26 @@ final class FileAudioSource: NSObject, CoreAudioStreamSource {
|
||||
}
|
||||
|
||||
func seek(at offset: Int) {
|
||||
close()
|
||||
|
||||
do {
|
||||
try performOpen(seek: offset)
|
||||
} catch {
|
||||
delegate?.errorOccured(source: self, error: error)
|
||||
delegate?.errorOccurred(source: self, error: error)
|
||||
}
|
||||
}
|
||||
|
||||
private func performOpen(seek seekOffset: Int) throws {
|
||||
guard let inputStream = InputStream(url: url) else {
|
||||
throw AudioSystemError.playerStartError
|
||||
}
|
||||
self.inputStream = inputStream
|
||||
|
||||
var reopened = false
|
||||
let streamStatus = inputStream.streamStatus
|
||||
if streamStatus == .notOpen || streamStatus == .error {
|
||||
let streamStatus = inputStream?.streamStatus ?? .closed
|
||||
if streamStatus == .notOpen || streamStatus == .closed || streamStatus == .error || streamStatus == .atEnd {
|
||||
reopened = true
|
||||
close()
|
||||
open(inputStream: inputStream)
|
||||
try open()
|
||||
}
|
||||
|
||||
let attributes = try fileManager.attributesOfItem(atPath: url.path)
|
||||
length = (attributes[.size] as? Int) ?? 0
|
||||
guard let inputStream = inputStream else {
|
||||
return
|
||||
}
|
||||
|
||||
if inputStream.setProperty(seekOffset, forKey: .fileCurrentOffsetKey) {
|
||||
position = seekOffset
|
||||
@@ -119,10 +110,17 @@ final class FileAudioSource: NSObject, CoreAudioStreamSource {
|
||||
}
|
||||
}
|
||||
|
||||
private func open(inputStream: InputStream) {
|
||||
private func open() throws {
|
||||
guard let inputStream = InputStream(url: url) else {
|
||||
throw AudioSystemError.playerStartError
|
||||
}
|
||||
self.inputStream = inputStream
|
||||
CFReadStreamSetDispatchQueue(inputStream, underlyingQueue)
|
||||
inputStream.delegate = self
|
||||
inputStream.open()
|
||||
|
||||
let attributes = try fileManager.attributesOfItem(atPath: url.path)
|
||||
length = (attributes[.size] as? Int) ?? 0
|
||||
}
|
||||
|
||||
private func getCurrentOffsetFromStream() -> Int {
|
||||
@@ -139,11 +137,9 @@ extension FileAudioSource: StreamDelegate {
|
||||
case .hasBytesAvailable:
|
||||
dataAvailable()
|
||||
case .endEncountered:
|
||||
delegate?.endOfFileOccured(source: self)
|
||||
delegate?.endOfFileOccurred(source: self)
|
||||
case .errorOccurred:
|
||||
delegate?.errorOccured(source: self, error: AudioPlayerError.codecError)
|
||||
case .endEncountered:
|
||||
delegate?.endOfFileOccured(source: self)
|
||||
delegate?.errorOccurred(source: self, error: AudioPlayerError.codecError)
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
@@ -86,12 +86,12 @@ public class RemoteAudioSource: AudioStreamSource {
|
||||
let metadataProcessor = MetadataStreamProcessor(parser: metadataParser.eraseToAnyParser())
|
||||
let netStatusProvider = NetStatusService(network: NWPathMonitor())
|
||||
let icyheaderProcessor = IcycastHeadersProcessor()
|
||||
let retrierTimout = Retrier(interval: .seconds(1), maxInterval: 5, underlyingQueue: nil)
|
||||
let retrierTimeout = Retrier(interval: .seconds(1), maxInterval: 5, underlyingQueue: nil)
|
||||
self.init(networking: networking,
|
||||
metadataStreamSource: metadataProcessor,
|
||||
icycastHeadersProcessor: icyheaderProcessor,
|
||||
netStatusProvider: netStatusProvider,
|
||||
retrier: retrierTimout,
|
||||
retrier: retrierTimeout,
|
||||
url: url,
|
||||
underlyingQueue: underlyingQueue,
|
||||
httpHeaders: httpHeaders)
|
||||
@@ -109,8 +109,7 @@ public class RemoteAudioSource: AudioStreamSource {
|
||||
|
||||
func close() {
|
||||
retrierTimeout.cancel()
|
||||
netStatusService.stop()
|
||||
streamOperationQueue.isSuspended = true
|
||||
streamOperationQueue.isSuspended = false
|
||||
streamOperationQueue.cancelAllOperations()
|
||||
if let streamTask = streamRequest {
|
||||
streamTask.cancel()
|
||||
@@ -138,12 +137,10 @@ public class RemoteAudioSource: AudioStreamSource {
|
||||
}
|
||||
|
||||
func suspend() {
|
||||
streamRequest?.suspend()
|
||||
streamOperationQueue.isSuspended = true
|
||||
}
|
||||
|
||||
func resume() {
|
||||
streamRequest?.resume()
|
||||
streamOperationQueue.isSuspended = false
|
||||
}
|
||||
|
||||
@@ -154,8 +151,8 @@ public class RemoteAudioSource: AudioStreamSource {
|
||||
guard let self = self else { return }
|
||||
guard connection.isConnected else { return }
|
||||
if self.waitingForNetwork {
|
||||
self.seek(at: self.supportsSeek ? self.position : 0)
|
||||
self.waitingForNetwork = false
|
||||
self.seek(at: self.position)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -163,14 +160,13 @@ public class RemoteAudioSource: AudioStreamSource {
|
||||
private func performOpen(seek seekOffset: Int) {
|
||||
let urlRequest = buildUrlRequest(with: url, seekIfNeeded: seekOffset)
|
||||
|
||||
let request = networkingClient.stream(request: urlRequest)
|
||||
streamRequest = networkingClient.stream(request: urlRequest)
|
||||
.responseStream { [weak self] event in
|
||||
guard let self = self else { return }
|
||||
self.handleResponse(event: event)
|
||||
}
|
||||
.resume()
|
||||
|
||||
streamRequest = request
|
||||
metadataStreamProcessor.delegate = self
|
||||
}
|
||||
|
||||
@@ -181,65 +177,67 @@ public class RemoteAudioSource: AudioStreamSource {
|
||||
case let .response(urlResponse):
|
||||
parseResponseHeader(response: urlResponse)
|
||||
streamOperationQueue.isSuspended = false
|
||||
case let .stream(event):
|
||||
handleStreamEvent(event: event)
|
||||
case let .stream(.success(response)):
|
||||
handleSuccessfulStreamEvent(response: response)
|
||||
case let .stream(.failure(error)):
|
||||
handleFailedStreamEvent(error: error)
|
||||
case let .complete(event):
|
||||
if let error = event.error {
|
||||
delegate?.errorOccured(source: self, error: error)
|
||||
delegate?.errorOccurred(source: self, error: error)
|
||||
} else {
|
||||
addCompletionOperation { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.delegate?.endOfFileOccured(source: self)
|
||||
self.delegate?.endOfFileOccurred(source: self)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleStreamEvent(event: NetworkDataStream.StreamResult) {
|
||||
switch event {
|
||||
case let .success(value):
|
||||
if let audioData = value.data {
|
||||
addStreamOperation { [weak self] in
|
||||
guard let self = self else { return }
|
||||
if self.shouldTryParsingIcycastHeaders {
|
||||
let (header, extractedAudio) = self.icycastHeadersProcessor.proccess(data: audioData)
|
||||
if let header = header {
|
||||
self.shouldTryParsingIcycastHeaders = false
|
||||
let parser = IcycastHeaderParser()
|
||||
self.parsedHeaderOutput = parser.parse(input: header)
|
||||
if let metadataStep = self.parsedHeaderOutput?.metadataStep {
|
||||
self.metadataStreamProcessor.metadataAvailable(step: metadataStep)
|
||||
}
|
||||
|
||||
let audioCount = self.processAudio(data: extractedAudio)
|
||||
self.relativePosition += audioCount
|
||||
return
|
||||
}
|
||||
private func handleSuccessfulStreamEvent(response: NetworkDataStream.Response) {
|
||||
guard let audioData = response.data else {
|
||||
self.delegate?.errorOccurred(source: self, error: NetworkError.missingData)
|
||||
return
|
||||
}
|
||||
addStreamOperation { [weak self] in
|
||||
guard let self = self else { return }
|
||||
if self.shouldTryParsingIcycastHeaders {
|
||||
let (header, extractedAudio) = self.icycastHeadersProcessor.process(data: audioData)
|
||||
if let header = header {
|
||||
self.shouldTryParsingIcycastHeaders = false
|
||||
let parser = IcycastHeaderParser()
|
||||
self.parsedHeaderOutput = parser.parse(input: header)
|
||||
if let metadataStep = self.parsedHeaderOutput?.metadataStep {
|
||||
self.metadataStreamProcessor.metadataAvailable(step: metadataStep)
|
||||
}
|
||||
let audioCount = self.processAudio(data: audioData)
|
||||
self.relativePosition += audioCount
|
||||
}
|
||||
}
|
||||
case .failure:
|
||||
if !netStatusService.isConnected {
|
||||
waitingForNetwork = true
|
||||
let audioCount = self.processAudio(data: extractedAudio)
|
||||
self.relativePosition += audioCount
|
||||
return
|
||||
}
|
||||
waitingForNetwork = false
|
||||
retryOnError()
|
||||
let audioCount = self.processAudio(data: audioData)
|
||||
self.relativePosition += audioCount
|
||||
}
|
||||
}
|
||||
|
||||
private func handleFailedStreamEvent(error: Error) {
|
||||
if !netStatusService.isConnected {
|
||||
waitingForNetwork = true
|
||||
return
|
||||
}
|
||||
waitingForNetwork = false
|
||||
retryOnError()
|
||||
}
|
||||
|
||||
/// Processing audio data, extracting metadata if needed.
|
||||
/// - Parameter data: The audio to be processed
|
||||
/// - Returns: An `Int` value representing the amount of audio data bytes.
|
||||
private func processAudio(data: Data) -> Int {
|
||||
if self.metadataStreamProcessor.canProccessMetadata {
|
||||
let extractedAudioData = self.metadataStreamProcessor.proccessMetadata(data: data)
|
||||
self.delegate?.dataAvailable(source: self, data: extractedAudioData)
|
||||
if metadataStreamProcessor.canProcessMetadata {
|
||||
let extractedAudioData = metadataStreamProcessor.processMetadata(data: data)
|
||||
delegate?.dataAvailable(source: self, data: extractedAudioData)
|
||||
return extractedAudioData.count
|
||||
} else {
|
||||
self.delegate?.dataAvailable(source: self, data: data)
|
||||
delegate?.dataAvailable(source: self, data: data)
|
||||
return data.count
|
||||
}
|
||||
}
|
||||
@@ -260,7 +258,7 @@ public class RemoteAudioSource: AudioStreamSource {
|
||||
supportsSeek = acceptRanges != "none"
|
||||
}
|
||||
|
||||
// check to see if we have metadata to proccess
|
||||
// check to see if we have metadata to process
|
||||
if let metadataStep = parsedHeaderOutput?.metadataStep {
|
||||
metadataStreamProcessor.metadataAvailable(step: metadataStep)
|
||||
}
|
||||
@@ -271,9 +269,12 @@ public class RemoteAudioSource: AudioStreamSource {
|
||||
// check for error
|
||||
if statusCode == 416 { // range not satisfied error
|
||||
if length >= 0 { seekOffset = length }
|
||||
delegate?.endOfFileOccured(source: self)
|
||||
delegate?.endOfFileOccurred(source: self)
|
||||
} else if statusCode >= 300 {
|
||||
delegate?.errorOccured(source: self, error: NetworkError.serverError)
|
||||
delegate?.errorOccurred(
|
||||
source: self,
|
||||
error: NetworkError.serverError
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -290,7 +291,7 @@ public class RemoteAudioSource: AudioStreamSource {
|
||||
urlRequest.addValue("1", forHTTPHeaderField: "Icy-MetaData")
|
||||
urlRequest.addValue("identity", forHTTPHeaderField: "Accept-Encoding")
|
||||
|
||||
if supportsSeek && seekOffset > 0 {
|
||||
if supportsSeek, seekOffset > 0 {
|
||||
urlRequest.addValue("bytes=\(seekOffset)-", forHTTPHeaderField: "Range")
|
||||
}
|
||||
return urlRequest
|
||||
@@ -299,7 +300,7 @@ public class RemoteAudioSource: AudioStreamSource {
|
||||
private func retryOnError() {
|
||||
retrierTimeout.retry { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.seek(at: self.position)
|
||||
self.seek(at: self.supportsSeek ? self.position : 0)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import AVFoundation
|
||||
import CoreAudio
|
||||
|
||||
public final class AudioPlayer {
|
||||
open class AudioPlayer {
|
||||
public weak var delegate: AudioPlayerDelegate?
|
||||
|
||||
public var muted: Bool {
|
||||
@@ -86,6 +86,19 @@ public final class AudioPlayer {
|
||||
/// The current configuration of the player.
|
||||
public let configuration: AudioPlayerConfiguration
|
||||
|
||||
/// A Boolean value that indicates whether the audio engine is running.
|
||||
/// `true` if the engine is running, otherwise, `false`
|
||||
public var isEngineRunning: Bool { audioEngine.isRunning }
|
||||
|
||||
/// The `AVAudioMixerNode` as created by the underlying audio engine
|
||||
public var mainMixerNode: AVAudioMixerNode {
|
||||
audioEngine.mainMixerNode
|
||||
}
|
||||
|
||||
public var frameFiltering: FrameFiltering {
|
||||
frameFilterProcessor
|
||||
}
|
||||
|
||||
/// An `AVAudioFormat` object for the canonical audio stream
|
||||
private var outputAudioFormat: AVAudioFormat = {
|
||||
AVAudioFormat(commonFormat: .pcmFormatFloat32, sampleRate: 44100.0, channels: 2, interleaved: true)!
|
||||
@@ -95,27 +108,23 @@ public final class AudioPlayer {
|
||||
private var stateBeforePaused: InternalState = .initial
|
||||
|
||||
/// The underlying `AVAudioEngine` object
|
||||
let audioEngine = AVAudioEngine()
|
||||
private let audioEngine: AVAudioEngine
|
||||
/// An `AVAudioUnit` object that represents the audio player
|
||||
private(set) var player = AVAudioUnit()
|
||||
/// An `AVAudioUnitTimePitch` that controls the playback rate of the audio engine
|
||||
let rateNode = AVAudioUnitTimePitch()
|
||||
|
||||
/// A Boolean value that indicates whether the audio engine is running.
|
||||
/// `true` if the engine is running, otherwise, `false`
|
||||
var isEngineRunning: Bool { audioEngine.isRunning }
|
||||
private let rateNode = AVAudioUnitTimePitch()
|
||||
|
||||
/// An object representing the context of the audio render.
|
||||
/// Holds the audio buffer and in/out lists as required by the audio rendering
|
||||
let rendererContext: AudioRendererContext
|
||||
private let rendererContext: AudioRendererContext
|
||||
/// An object representing the context of the player.
|
||||
/// Holds the player's state, current playing and reading entries.
|
||||
let playerContext: AudioPlayerContext
|
||||
private let playerContext: AudioPlayerContext
|
||||
|
||||
let fileStreamProcessor: AudioFileStreamProcessor
|
||||
let playerRenderProcessor: AudioPlayerRenderProcessor
|
||||
private let fileStreamProcessor: AudioFileStreamProcessor
|
||||
private let playerRenderProcessor: AudioPlayerRenderProcessor
|
||||
private let frameFilterProcessor: FrameFilterProcessor
|
||||
|
||||
private let audioReadSource: DispatchTimerSource
|
||||
private let serializationQueue: DispatchQueue
|
||||
private let sourceQueue: DispatchQueue
|
||||
|
||||
@@ -125,14 +134,14 @@ public final class AudioPlayer {
|
||||
|
||||
public init(configuration: AudioPlayerConfiguration = .default) {
|
||||
self.configuration = configuration.normalizeValues()
|
||||
|
||||
let engine = AVAudioEngine()
|
||||
self.audioEngine = engine
|
||||
rendererContext = AudioRendererContext(configuration: configuration, outputAudioFormat: outputAudioFormat)
|
||||
playerContext = AudioPlayerContext()
|
||||
entriesQueue = PlayerQueueEntries()
|
||||
|
||||
serializationQueue = DispatchQueue(label: "streaming.core.queue", qos: .userInitiated)
|
||||
sourceQueue = DispatchQueue(label: "source.queue", qos: .userInitiated)
|
||||
audioReadSource = DispatchTimerSource(interval: .milliseconds(200), queue: sourceQueue)
|
||||
sourceQueue = DispatchQueue(label: "source.queue", qos: .default)
|
||||
|
||||
entryProvider = AudioEntryProvider(networkingClient: NetworkingClient(),
|
||||
underlyingQueue: sourceQueue,
|
||||
@@ -146,6 +155,10 @@ public final class AudioPlayer {
|
||||
rendererContext: rendererContext,
|
||||
outputAudioFormat: outputAudioFormat.basicStreamDescription)
|
||||
|
||||
|
||||
frameFilterProcessor = FrameFilterProcessor(mixerNodeProvider: {
|
||||
engine.mainMixerNode
|
||||
})
|
||||
configPlayerContext()
|
||||
configPlayerNode()
|
||||
setupEngine()
|
||||
@@ -154,7 +167,6 @@ public final class AudioPlayer {
|
||||
deinit {
|
||||
playerContext.audioPlayingEntry?.close()
|
||||
clearQueue()
|
||||
stopReadProccessFromSource()
|
||||
rendererContext.clean()
|
||||
}
|
||||
|
||||
@@ -183,14 +195,13 @@ public final class AudioPlayer {
|
||||
do {
|
||||
try self.startEngineIfNeeded()
|
||||
} catch {
|
||||
self.raiseUnxpected(error: .audioSystemError(.engineFailure))
|
||||
self.raiseUnexpected(error: .audioSystemError(.engineFailure))
|
||||
}
|
||||
}
|
||||
|
||||
sourceQueue.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.processSource()
|
||||
self.startReadProcessFromSourceIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -246,7 +257,6 @@ public final class AudioPlayer {
|
||||
public func stop() {
|
||||
guard playerContext.internalState != .stopped else { return }
|
||||
|
||||
stopReadProccessFromSource()
|
||||
serializationQueue.sync {
|
||||
stopEngine(reason: .userAction)
|
||||
}
|
||||
@@ -277,7 +287,6 @@ public final class AudioPlayer {
|
||||
serializationQueue.sync {
|
||||
pauseEngine()
|
||||
}
|
||||
stopReadProccessFromSource()
|
||||
playerContext.audioPlayingEntry?.suspend()
|
||||
sourceQueue.async { [weak self] in
|
||||
self?.processSource()
|
||||
@@ -303,9 +312,10 @@ public final class AudioPlayer {
|
||||
}
|
||||
startPlayer(resetBuffers: false)
|
||||
}
|
||||
startReadProcessFromSourceIfNeeded()
|
||||
}
|
||||
|
||||
/// Seeks the audio to the specified time.
|
||||
/// - Parameter time: A `Double` value specifying the time of the requested seek in seconds
|
||||
public func seek(to time: Double) {
|
||||
guard let playingEntry = playerContext.audioPlayingEntry else {
|
||||
return
|
||||
@@ -328,10 +338,16 @@ public final class AudioPlayer {
|
||||
}
|
||||
}
|
||||
|
||||
/// Attaches the given `AVAudioNode` to the engine
|
||||
/// - Note: The node will be added after the default rate node
|
||||
/// - Parameter node: An instance of `AVAudioNode`
|
||||
public func attach(node: AVAudioNode) {
|
||||
attach(nodes: [node])
|
||||
}
|
||||
|
||||
/// Attaches the given `AVAudioNode`s to the engine
|
||||
/// - Note: The nodes will be added after the default rate node
|
||||
/// - Parameter node: An array of `AVAudioNode` instances
|
||||
public func attach(nodes: [AVAudioNode]) {
|
||||
nodes.forEach { node in
|
||||
customAttachedNodes.append(node)
|
||||
@@ -341,6 +357,8 @@ public final class AudioPlayer {
|
||||
reattachCustomNodes()
|
||||
}
|
||||
|
||||
/// Detaches the given `AVAudioNode` from the engine
|
||||
/// - Parameter node: An instance of `AVAudioNode`
|
||||
public func detach(node: AVAudioNode) {
|
||||
guard customAttachedNodes.contains(node) else {
|
||||
return
|
||||
@@ -350,6 +368,8 @@ public final class AudioPlayer {
|
||||
reattachCustomNodes()
|
||||
}
|
||||
|
||||
/// Detaches the given `AVAudioNode`s from the engine
|
||||
/// - Parameter node: An array of `AVAudioNode` instances
|
||||
public func detachCustomAttachedNodes() {
|
||||
customAttachedNodes.forEach { node in
|
||||
audioEngine.detach(node)
|
||||
@@ -385,7 +405,7 @@ public final class AudioPlayer {
|
||||
audioEngine.prepare()
|
||||
try audioEngine.start()
|
||||
} catch {
|
||||
Logger.error("⚠️ error setuping audio engine: %@", category: .generic, args: error.localizedDescription)
|
||||
Logger.error("⚠️ error setting up audio engine: %@", category: .generic, args: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -400,7 +420,7 @@ public final class AudioPlayer {
|
||||
self.playerRenderProcessor.attachCallback(on: unit, audioFormat: self.outputAudioFormat)
|
||||
case let .failure(error):
|
||||
assertionFailure("couldn't create player unit: \(error)")
|
||||
self.raiseUnxpected(error: .audioSystemError(.playerNotFound))
|
||||
self.raiseUnexpected(error: .audioSystemError(.playerNotFound))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -428,12 +448,12 @@ public final class AudioPlayer {
|
||||
fileStreamProcessor.fileStreamCallback = { [weak self] effect in
|
||||
guard let self = self else { return }
|
||||
switch effect {
|
||||
case .proccessSource:
|
||||
case .processSource:
|
||||
self.sourceQueue.async {
|
||||
self.processSource()
|
||||
}
|
||||
case let .raiseError(error):
|
||||
self.raiseUnxpected(error: error)
|
||||
self.raiseUnexpected(error: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -453,7 +473,7 @@ public final class AudioPlayer {
|
||||
if let first = customAttachedNodes.first {
|
||||
audioEngine.connect(rateNode, to: first, format: nil)
|
||||
}
|
||||
for index in 0..<customAttachedNodes.count - 1 {
|
||||
for index in 0 ..< customAttachedNodes.count - 1 {
|
||||
let current = customAttachedNodes[index]
|
||||
let next = customAttachedNodes[index + 1]
|
||||
let format = current.inputFormat(forBus: 0)
|
||||
@@ -506,24 +526,7 @@ public final class AudioPlayer {
|
||||
Logger.debug("engine stopped 🛑", category: .generic)
|
||||
}
|
||||
|
||||
/// Starts the timer of `audioReadSource` for proccesing the source read stream
|
||||
///
|
||||
/// This calls `processSource` method every `500 ms`
|
||||
private func startReadProcessFromSourceIfNeeded() {
|
||||
guard audioReadSource.state != .activated else { return }
|
||||
audioReadSource.add { [weak self] in
|
||||
self?.processSource()
|
||||
}
|
||||
audioReadSource.activate()
|
||||
}
|
||||
|
||||
/// Stops and removes the handler from the timer, @see `audioReadSource`
|
||||
private func stopReadProccessFromSource() {
|
||||
audioReadSource.suspend()
|
||||
audioReadSource.removeHandler()
|
||||
}
|
||||
|
||||
/// Starts the audio player, reseting the buffers if requested
|
||||
/// Starts the audio player, resetting the buffers if requested
|
||||
///
|
||||
/// - parameter resetBuffers: A `Bool` value indicating if the buffers should be reset, prior starting the player.
|
||||
private func startPlayer(resetBuffers: Bool) {
|
||||
@@ -536,7 +539,7 @@ public final class AudioPlayer {
|
||||
try player.auAudioUnit.startHardware()
|
||||
} catch {
|
||||
stopEngine(reason: .error)
|
||||
raiseUnxpected(error: .audioSystemError(.playerStartError))
|
||||
raiseUnexpected(error: .audioSystemError(.playerStartError))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -544,7 +547,6 @@ public final class AudioPlayer {
|
||||
private func processSource() {
|
||||
dispatchPrecondition(condition: .onQueue(sourceQueue))
|
||||
|
||||
guard !playerContext.disposedRequested else { return }
|
||||
guard playerContext.internalState != .paused else { return }
|
||||
|
||||
if playerContext.internalState == .pendingNext {
|
||||
@@ -581,7 +583,6 @@ public final class AudioPlayer {
|
||||
setCurrentReading(entry: entry, startPlaying: shouldStartPlaying, shouldClearQueue: false)
|
||||
} else if playerContext.audioPlayingEntry == nil {
|
||||
if playerContext.internalState != .stopped {
|
||||
stopReadProccessFromSource()
|
||||
stopEngine(reason: .eof)
|
||||
}
|
||||
}
|
||||
@@ -597,7 +598,7 @@ public final class AudioPlayer {
|
||||
playingEntry.seekRequest.lock.unlock()
|
||||
|
||||
if originalSeekToTimeRequested, playerContext.audioReadingEntry === playingEntry {
|
||||
proccessSeekTime()
|
||||
processSeekTime()
|
||||
|
||||
let version = playingEntry.seekRequest.version.value
|
||||
if currSeekVersion == version {
|
||||
@@ -609,7 +610,7 @@ public final class AudioPlayer {
|
||||
}
|
||||
}
|
||||
|
||||
private func proccessSeekTime() {
|
||||
private func processSeekTime() {
|
||||
assert(playerContext.audioReadingEntry === playerContext.audioPlayingEntry,
|
||||
"reading and playing entry must be the same")
|
||||
fileStreamProcessor.processSeek()
|
||||
@@ -647,7 +648,7 @@ public final class AudioPlayer {
|
||||
}
|
||||
|
||||
private func processFinishPlaying(entry: AudioEntry?, with nextEntry: AudioEntry?) {
|
||||
let playingEntry = playerContext.entriesLock.around { playerContext.audioPlayingEntry }
|
||||
let playingEntry = playerContext.entriesLock.withLock { playerContext.audioPlayingEntry }
|
||||
guard entry == playingEntry else { return }
|
||||
|
||||
let isPlayingSameItemProbablySeek = playerContext.audioPlayingEntry === nextEntry
|
||||
@@ -672,17 +673,17 @@ public final class AudioPlayer {
|
||||
|
||||
if let nextEntry = nextEntry {
|
||||
if !isPlayingSameItemProbablySeek {
|
||||
nextEntry.lock.around {
|
||||
nextEntry.lock.withLock {
|
||||
nextEntry.seekTime = 0
|
||||
}
|
||||
nextEntry.seekRequest.lock.around {
|
||||
nextEntry.seekRequest.lock.withLock {
|
||||
nextEntry.seekRequest.requested = false
|
||||
}
|
||||
}
|
||||
playerContext.entriesLock.lock()
|
||||
playerContext.audioPlayingEntry = nextEntry
|
||||
let playingQueueEntryId = playerContext.audioPlayingEntry?.id ?? AudioEntryId(id: "")
|
||||
playerContext.entriesLock.unlock()
|
||||
let playingQueueEntryId = playingEntry?.id ?? AudioEntryId(id: "")
|
||||
|
||||
notifyDelegateEntryFinishedPlaying(entry, isPlayingSameItemProbablySeek)
|
||||
if !isPlayingSameItemProbablySeek {
|
||||
@@ -724,7 +725,7 @@ public final class AudioPlayer {
|
||||
}
|
||||
}
|
||||
|
||||
private func raiseUnxpected(error: AudioPlayerError) {
|
||||
private func raiseUnexpected(error: AudioPlayerError) {
|
||||
playerContext.setInternalState(to: .error)
|
||||
// todo raise on main thread from playback thread
|
||||
asyncOnMain { [weak self] in
|
||||
@@ -745,7 +746,7 @@ extension AudioPlayer: AudioStreamSourceDelegate {
|
||||
let openFileStreamStatus = fileStreamProcessor.openFileStream(with: source.audioFileHint)
|
||||
guard openFileStreamStatus == noErr else {
|
||||
let streamError = AudioFileStreamError(status: openFileStreamStatus)
|
||||
raiseUnxpected(error: .audioSystemError(.fileStreamError(streamError)))
|
||||
raiseUnexpected(error: .audioSystemError(.fileStreamError(streamError)))
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -755,7 +756,7 @@ extension AudioPlayer: AudioStreamSourceDelegate {
|
||||
guard streamBytesStatus == noErr else {
|
||||
if let playingEntry = playerContext.audioPlayingEntry, playingEntry.has(same: source) {
|
||||
let streamBytesError = AudioFileStreamError(status: streamBytesStatus)
|
||||
raiseUnxpected(error: .streamParseBytesFailure(streamBytesError))
|
||||
raiseUnexpected(error: .streamParseBytesFailure(streamBytesError))
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -766,12 +767,12 @@ extension AudioPlayer: AudioStreamSourceDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
func errorOccured(source: CoreAudioStreamSource, error: Error) {
|
||||
func errorOccurred(source: CoreAudioStreamSource, error: Error) {
|
||||
guard let entry = playerContext.audioReadingEntry, entry.has(same: source) else { return }
|
||||
raiseUnxpected(error: .networkError(.failure(error)))
|
||||
raiseUnexpected(error: .networkError(.failure(error)))
|
||||
}
|
||||
|
||||
func endOfFileOccured(source: CoreAudioStreamSource) {
|
||||
func endOfFileOccurred(source: CoreAudioStreamSource) {
|
||||
let hasSameSource = playerContext.audioReadingEntry?.has(same: source) ?? false
|
||||
guard playerContext.audioReadingEntry == nil || hasSameSource else {
|
||||
source.delegate = nil
|
||||
@@ -802,7 +803,10 @@ extension AudioPlayer: AudioStreamSourceDelegate {
|
||||
playerContext.audioReadingEntry = nil
|
||||
playerContext.entriesLock.unlock()
|
||||
|
||||
processSource()
|
||||
sourceQueue.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.processSource()
|
||||
}
|
||||
}
|
||||
|
||||
func metadataReceived(data: [String: String]) {
|
||||
|
||||
@@ -13,11 +13,11 @@ public struct AudioPlayerConfiguration: Equatable {
|
||||
/// Number of seconds of audio required to before playback first starts.
|
||||
/// - note: Must be larger that `bufferSizeInSeconds`
|
||||
let secondsRequiredToStartPlaying: Double
|
||||
/// Number of seconds of audio required after seek occcurs.
|
||||
/// Number of seconds of audio required after seek occurs.
|
||||
let gracePeriodAfterSeekInSeconds: Double
|
||||
/// Number of seconds of audio required to before playback resumes after a buffer underun
|
||||
/// Number of seconds of audio required to before playback resumes after a buffer underrun
|
||||
/// - note: Must be larger that `bufferSizeInSeconds`
|
||||
let secondsRequiredToStartPlayingAfterBufferUnderun: Int
|
||||
let secondsRequiredToStartPlayingAfterBufferUnderrun: Int
|
||||
|
||||
/// Enables the internal logs
|
||||
let enableLogs: Bool
|
||||
@@ -26,7 +26,7 @@ public struct AudioPlayerConfiguration: Equatable {
|
||||
bufferSizeInSeconds: 10,
|
||||
secondsRequiredToStartPlaying: 1,
|
||||
gracePeriodAfterSeekInSeconds: 0.5,
|
||||
secondsRequiredToStartPlayingAfterBufferUnderun: 1,
|
||||
secondsRequiredToStartPlayingAfterBufferUnderrun: 1,
|
||||
enableLogs: false)
|
||||
/// Initializes the configuration for the `AudioPlayer`
|
||||
///
|
||||
@@ -35,22 +35,22 @@ public struct AudioPlayerConfiguration: Equatable {
|
||||
/// - parameter flushQueueOnSeek: All pending items will be flushed when seeking a track if this is set to `true`
|
||||
/// - parameter bufferSizeInSeconds: The size of the decompressed buffer.
|
||||
/// - parameter secondsRequiredToStartPlaying: Number of seconds of audio required to before playback first starts.
|
||||
/// - parameter gracePeriodAfterSeekInSeconds: Number of seconds of audio required after seek occcurs.
|
||||
/// - parameter secondsRequiredToStartPlayingAfterBufferUnderun: Number of seconds of audio required to before playback resumes after a buffer underun
|
||||
/// - parameter gracePeriodAfterSeekInSeconds: Number of seconds of audio required after seek occurs.
|
||||
/// - parameter secondsRequiredToStartPlayingAfterBufferUnderrun: Number of seconds of audio required to before playback resumes after a buffer underrun
|
||||
/// - parameter enableLogs: Enables the internal logs
|
||||
///
|
||||
public init(flushQueueOnSeek: Bool = true,
|
||||
bufferSizeInSeconds: Double = 10,
|
||||
secondsRequiredToStartPlaying: Double = 1,
|
||||
gracePeriodAfterSeekInSeconds: Double = 0.5,
|
||||
secondsRequiredToStartPlayingAfterBufferUnderun: Int = 1,
|
||||
secondsRequiredToStartPlayingAfterBufferUnderrun: Int = 1,
|
||||
enableLogs: Bool = false)
|
||||
{
|
||||
self.flushQueueOnSeek = flushQueueOnSeek
|
||||
self.bufferSizeInSeconds = bufferSizeInSeconds
|
||||
self.secondsRequiredToStartPlaying = secondsRequiredToStartPlaying
|
||||
self.gracePeriodAfterSeekInSeconds = gracePeriodAfterSeekInSeconds
|
||||
self.secondsRequiredToStartPlayingAfterBufferUnderun = secondsRequiredToStartPlayingAfterBufferUnderun
|
||||
self.secondsRequiredToStartPlayingAfterBufferUnderrun = secondsRequiredToStartPlayingAfterBufferUnderrun
|
||||
self.enableLogs = enableLogs
|
||||
}
|
||||
|
||||
@@ -70,15 +70,15 @@ public struct AudioPlayerConfiguration: Equatable {
|
||||
? defaults.gracePeriodAfterSeekInSeconds
|
||||
: self.gracePeriodAfterSeekInSeconds
|
||||
|
||||
let secondsRequiredToStartPlayingAfterBufferUnderun = self.secondsRequiredToStartPlayingAfterBufferUnderun == 0
|
||||
? defaults.secondsRequiredToStartPlayingAfterBufferUnderun
|
||||
: self.secondsRequiredToStartPlayingAfterBufferUnderun
|
||||
let secondsRequiredToStartPlayingAfterBufferUnderrun = self.secondsRequiredToStartPlayingAfterBufferUnderrun == 0
|
||||
? defaults.secondsRequiredToStartPlayingAfterBufferUnderrun
|
||||
: self.secondsRequiredToStartPlayingAfterBufferUnderrun
|
||||
|
||||
return AudioPlayerConfiguration(flushQueueOnSeek: flushQueueOnSeek,
|
||||
bufferSizeInSeconds: bufferSizeInSeconds,
|
||||
secondsRequiredToStartPlaying: secondsRequiredToStartPlaying,
|
||||
gracePeriodAfterSeekInSeconds: gracePeriodAfterSeekInSeconds,
|
||||
secondsRequiredToStartPlayingAfterBufferUnderun: secondsRequiredToStartPlayingAfterBufferUnderun,
|
||||
secondsRequiredToStartPlayingAfterBufferUnderrun: secondsRequiredToStartPlayingAfterBufferUnderrun,
|
||||
enableLogs: enableLogs)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,30 +6,31 @@
|
||||
import Foundation
|
||||
|
||||
internal final class AudioPlayerContext {
|
||||
var stopReason = Protected<AudioPlayerStopReason>(.none)
|
||||
var stopReason: Atomic<AudioPlayerStopReason>
|
||||
|
||||
var state = Protected<AudioPlayerState>(.ready)
|
||||
var state: Atomic<AudioPlayerState>
|
||||
var stateChanged: ((_ oldState: AudioPlayerState, _ newState: AudioPlayerState) -> Void)?
|
||||
|
||||
var muted = Protected<Bool>(false)
|
||||
var muted: Atomic<Bool>
|
||||
|
||||
var internalState: AudioPlayer.InternalState {
|
||||
playerInternalState.value
|
||||
}
|
||||
|
||||
let entriesLock = UnfairLock()
|
||||
let entriesLock: UnfairLock
|
||||
var audioReadingEntry: AudioEntry?
|
||||
var audioPlayingEntry: AudioEntry?
|
||||
|
||||
var disposedRequested: Bool
|
||||
|
||||
/// This is the player's internal state to use
|
||||
/// - NOTE: Do not use directly instead use the `internalState` to set and get the property
|
||||
/// or the `setInternalState(to:when:)`method
|
||||
private var playerInternalState = Protected<AudioPlayer.InternalState>(.initial)
|
||||
private var playerInternalState = Atomic<AudioPlayer.InternalState>(.initial)
|
||||
|
||||
init() {
|
||||
disposedRequested = false
|
||||
stopReason = Atomic<AudioPlayerStopReason>(.none)
|
||||
state = Atomic<AudioPlayerState>(.ready)
|
||||
muted = Atomic<Bool>(false)
|
||||
entriesLock = UnfairLock()
|
||||
}
|
||||
|
||||
/// Sets the internal state if given the `inState` will be evaluated before assignment occurs.
|
||||
|
||||
@@ -22,7 +22,7 @@ public protocol AudioPlayerDelegate: AnyObject {
|
||||
stopReason: AudioPlayerStopReason,
|
||||
progress: Double,
|
||||
duration: Double)
|
||||
/// Tells the delegate when an unexpected error occured.
|
||||
/// Tells the delegate when an unexpected error occurred.
|
||||
/// - note: Probably a good time to recreate the player when this occurs
|
||||
func audioPlayerUnexpectedError(player: AudioPlayer, error: AudioPlayerError)
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import CoreAudio
|
||||
internal var maxFramesPerSlice: AVAudioFrameCount = 8192
|
||||
|
||||
final class AudioRendererContext {
|
||||
var waiting = Protected<Bool>(false)
|
||||
var waiting = Atomic<Bool>(false)
|
||||
|
||||
let lock = UnfairLock()
|
||||
|
||||
@@ -20,13 +20,11 @@ final class AudioRendererContext {
|
||||
|
||||
let packetsSemaphore = DispatchSemaphore(value: 0)
|
||||
|
||||
var discontinuous: Bool = false
|
||||
|
||||
let framesRequiredToStartPlaying: UInt32
|
||||
let framesRequiredAfterRebuffering: UInt32
|
||||
let framesRequiredForDataAfterSeekPlaying: UInt32
|
||||
|
||||
var waitingForDataAfterSeekFrameCount = Protected<Int32>(0)
|
||||
var waitingForDataAfterSeekFrameCount = Atomic<Int32>(0)
|
||||
|
||||
private let configuration: AudioPlayerConfiguration
|
||||
|
||||
@@ -36,7 +34,7 @@ final class AudioRendererContext {
|
||||
let canonicalStream = outputAudioFormat.basicStreamDescription
|
||||
|
||||
framesRequiredToStartPlaying = UInt32(canonicalStream.mSampleRate) * UInt32(configuration.secondsRequiredToStartPlaying)
|
||||
framesRequiredAfterRebuffering = UInt32(canonicalStream.mSampleRate) * UInt32(configuration.secondsRequiredToStartPlayingAfterBufferUnderun)
|
||||
framesRequiredAfterRebuffering = UInt32(canonicalStream.mSampleRate) * UInt32(configuration.secondsRequiredToStartPlayingAfterBufferUnderrun)
|
||||
framesRequiredForDataAfterSeekPlaying = UInt32(canonicalStream.mSampleRate) * UInt32(configuration.gracePeriodAfterSeekInSeconds)
|
||||
|
||||
let dataByteSize = Int(canonicalStream.mSampleRate * configuration.bufferSizeInSeconds) * Int(canonicalStream.mBytesPerFrame)
|
||||
@@ -77,8 +75,8 @@ private func allocateBufferList(dataByteSize: Int) -> UnsafeMutablePointer<Audio
|
||||
let _bufferList = AudioBufferList.allocate(maximumBuffers: 1)
|
||||
|
||||
_bufferList[0].mDataByteSize = UInt32(dataByteSize)
|
||||
let alingment = MemoryLayout<UInt8>.alignment
|
||||
let mData = UnsafeMutableRawPointer.allocate(byteCount: dataByteSize, alignment: alingment)
|
||||
let alignment = MemoryLayout<UInt8>.alignment
|
||||
let mData = UnsafeMutableRawPointer.allocate(byteCount: dataByteSize, alignment: alignment)
|
||||
_bufferList[0].mData = mData
|
||||
_bufferList[0].mNumberChannels = 2
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import AVFoundation
|
||||
|
||||
enum AudioConvertStatus: Int32 {
|
||||
case done = 100
|
||||
case proccessed = 0
|
||||
case processed = 0
|
||||
}
|
||||
|
||||
struct AudioConvertInfo {
|
||||
@@ -20,11 +20,11 @@ struct AudioConvertInfo {
|
||||
}
|
||||
|
||||
enum FileStreamProcessorEffect {
|
||||
case proccessSource
|
||||
case processSource
|
||||
case raiseError(AudioPlayerError)
|
||||
}
|
||||
|
||||
/// An object that handles the proccessing of AudioFileStream, its packets etc.
|
||||
/// An object that handles the processing of AudioFileStream, its packets etc.
|
||||
final class AudioFileStreamProcessor {
|
||||
private let maxCompressedPacketForBitrate = 4096
|
||||
|
||||
@@ -38,8 +38,9 @@ final class AudioFileStreamProcessor {
|
||||
internal var audioConverter: AudioConverterRef?
|
||||
internal var discontinuous: Bool = false
|
||||
internal var inputFormat = AudioStreamBasicDescription()
|
||||
internal var fileFormat: String = ""
|
||||
internal let fa4mFormat = "fa4m"
|
||||
|
||||
internal var currentFileFormat: String = ""
|
||||
internal let fileFormatsForDelayedConverterCreation: Set = ["fa4m", "f4pm"]
|
||||
|
||||
var isFileStreamOpen: Bool {
|
||||
audioFileStream != nil
|
||||
@@ -115,7 +116,7 @@ final class AudioFileStreamProcessor {
|
||||
readingEntry.lock.unlock()
|
||||
|
||||
let bitrate = readingEntry.calculatedBitrate()
|
||||
if readingEntry.processedPacketsState.count > 0, bitrate > 0 {
|
||||
if readingEntry.packetDuration > 0, bitrate > 0 {
|
||||
var ioFlags = AudioFileStreamSeekFlags(rawValue: 0)
|
||||
var packetsAlignedByteOffset: Int64 = 0
|
||||
let seekPacket = Int64(floor(readingEntry.seekRequest.time / readingEntry.packetDuration))
|
||||
@@ -165,7 +166,7 @@ final class AudioFileStreamProcessor {
|
||||
|
||||
var classDesc = AudioClassDescription()
|
||||
var outputFormat = toFormat
|
||||
if getHardwareCodecClassDescripition(formatId: inputFormat.mFormatID, classDesc: &classDesc) {
|
||||
if getHardwareCodecClassDescription(formatId: inputFormat.mFormatID, classDesc: &classDesc) {
|
||||
AudioConverterNewSpecific(&inputFormat, &outputFormat, 1, &classDesc, &audioConverter)
|
||||
}
|
||||
|
||||
@@ -178,11 +179,12 @@ final class AudioFileStreamProcessor {
|
||||
}
|
||||
}
|
||||
self.inputFormat = inputFormat
|
||||
assignMagicCookieToConverterIfNeeded()
|
||||
}
|
||||
|
||||
private func assignMagicCookieToConverterIfNeeded() {
|
||||
// magic cookie info
|
||||
let fileHint = playerContext.audioReadingEntry?.audioFileHint
|
||||
let isProperFormat = fileHint != kAudioFileAAC_ADTSType && fileHint != kAudioFileM4AType && fileHint != kAudioFileMPEG4Type
|
||||
if let fileStream = audioFileStream, isProperFormat {
|
||||
if let fileStream = audioFileStream {
|
||||
var cookieSize: UInt32 = 0
|
||||
guard AudioFileStreamGetPropertyInfo(fileStream, kAudioFileStreamProperty_MagicCookieData, &cookieSize, nil) == noErr else {
|
||||
return
|
||||
@@ -193,7 +195,7 @@ final class AudioFileStreamProcessor {
|
||||
}
|
||||
guard let converter = audioConverter else {
|
||||
fileStreamCallback?(.raiseError(.audioSystemError(.fileStreamError(.unknownError))))
|
||||
return
|
||||
return
|
||||
}
|
||||
guard AudioConverterSetProperty(converter, kAudioConverterDecompressionMagicCookie, cookieSize, cookie) == noErr else {
|
||||
fileStreamCallback?(.raiseError(.audioSystemError(.fileStreamError(.unknownError))))
|
||||
@@ -229,9 +231,9 @@ final class AudioFileStreamProcessor {
|
||||
case kAudioFileStreamProperty_AudioDataByteCount:
|
||||
processDataByteCount(fileStream: fileStream)
|
||||
case kAudioFileStreamProperty_AudioDataPacketCount:
|
||||
proccessAudioDataPacketCount(fileStream: fileStream)
|
||||
processAudioDataPacketCount(fileStream: fileStream)
|
||||
case kAudioFileStreamProperty_ReadyToProducePackets:
|
||||
// check converter for discontious stream
|
||||
// check converter for discontinuous stream
|
||||
processReadyToProducePackets(fileStream: fileStream)
|
||||
case kAudioFileStreamProperty_FormatList:
|
||||
processFormatList(fileStream: fileStream)
|
||||
@@ -239,7 +241,7 @@ final class AudioFileStreamProcessor {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: AudioFileStream properties Proccessing
|
||||
// MARK: AudioFileStream properties Processing
|
||||
|
||||
private func processDataOffset(fileStream: AudioFileStreamID) {
|
||||
var offset: UInt64 = 0
|
||||
@@ -263,7 +265,7 @@ final class AudioFileStreamProcessor {
|
||||
var size = UInt32(4)
|
||||
AudioFileStreamGetProperty(fileStream, kAudioFileStreamProperty_FileFormat, &size, &fileFormat)
|
||||
if let stringFileFormat = String(data: Data(fileFormat), encoding: .utf8) {
|
||||
self.fileFormat = stringFileFormat
|
||||
currentFileFormat = stringFileFormat
|
||||
}
|
||||
}
|
||||
|
||||
@@ -277,6 +279,9 @@ final class AudioFileStreamProcessor {
|
||||
entry.audioStreamFormat = audioStreamFormat
|
||||
}
|
||||
|
||||
entry.sampleRate = Float(audioStreamFormat.mSampleRate)
|
||||
entry.packetDuration = Double(audioStreamFormat.mFramesPerPacket) / Double(entry.sampleRate)
|
||||
|
||||
var packetBufferSize: UInt32 = 0
|
||||
var status = fileStreamGetProperty(value: &packetBufferSize,
|
||||
fileStream: fileStream,
|
||||
@@ -289,11 +294,11 @@ final class AudioFileStreamProcessor {
|
||||
packetBufferSize = 2048 // default value
|
||||
}
|
||||
}
|
||||
entry.lock.around {
|
||||
entry.lock.withLock {
|
||||
entry.processedPacketsState.bufferSize = packetBufferSize
|
||||
}
|
||||
|
||||
if fileFormat != fa4mFormat {
|
||||
if !fileFormatsForDelayedConverterCreation.contains(currentFileFormat) {
|
||||
createAudioConverter(from: entry.audioStreamFormat, to: outputAudioFormat)
|
||||
}
|
||||
}
|
||||
@@ -306,7 +311,7 @@ final class AudioFileStreamProcessor {
|
||||
entry.audioStreamState.dataByteCount = audioDataByteCount
|
||||
}
|
||||
|
||||
private func proccessAudioDataPacketCount(fileStream: AudioFileStreamID) {
|
||||
private func processAudioDataPacketCount(fileStream: AudioFileStreamID) {
|
||||
guard let entry = playerContext.audioReadingEntry else { return }
|
||||
var audioDataPacketCount: UInt64 = 0
|
||||
fileStreamGetProperty(value: &audioDataPacketCount, fileStream: fileStream, propertyId: kAudioFileStreamProperty_AudioDataPacketCount)
|
||||
@@ -331,7 +336,7 @@ final class AudioFileStreamProcessor {
|
||||
i += step
|
||||
}
|
||||
|
||||
if fileFormat == fa4mFormat {
|
||||
if fileFormatsForDelayedConverterCreation.contains(currentFileFormat) {
|
||||
if let inputStreamFormat = playerContext.audioReadingEntry?.audioStreamFormat {
|
||||
createAudioConverter(from: inputStreamFormat, to: outputAudioFormat)
|
||||
}
|
||||
@@ -346,12 +351,12 @@ final class AudioFileStreamProcessor {
|
||||
inPacketDescriptions: UnsafeMutablePointer<AudioStreamPacketDescription>?)
|
||||
{
|
||||
guard let entry = playerContext.audioReadingEntry else { return }
|
||||
guard entry.audioStreamState.processedDataFormat, !playerContext.disposedRequested else { return }
|
||||
guard entry.audioStreamState.processedDataFormat else { return }
|
||||
|
||||
if let playingEntry = playerContext.audioPlayingEntry,
|
||||
playingEntry.seekRequest.requested, playingEntry.calculatedBitrate() > 0
|
||||
{
|
||||
fileStreamCallback?(.proccessSource)
|
||||
fileStreamCallback?(.processSource)
|
||||
if rendererContext.waiting.value {
|
||||
rendererContext.packetsSemaphore.signal()
|
||||
}
|
||||
@@ -375,11 +380,11 @@ final class AudioFileStreamProcessor {
|
||||
convertInfo.audioBuffer.mNumberChannels = playingAudioStreamFormat.mChannelsPerFrame
|
||||
}
|
||||
|
||||
updateProccessedPackets(inPacketDescriptions: inPacketDescriptions,
|
||||
inNumberPackets: inNumberPackets)
|
||||
updateProcessedPackets(inPacketDescriptions: inPacketDescriptions,
|
||||
inNumberPackets: inNumberPackets)
|
||||
|
||||
var status: OSStatus = noErr
|
||||
packetProccess: while status == noErr {
|
||||
packetProcess: while status == noErr {
|
||||
rendererContext.lock.lock()
|
||||
let bufferContext = rendererContext.bufferContext
|
||||
var used = bufferContext.frameUsedCount
|
||||
@@ -401,8 +406,7 @@ final class AudioFileStreamProcessor {
|
||||
if framesLeftInBuffer > 0 {
|
||||
break
|
||||
}
|
||||
if playerContext.disposedRequested
|
||||
|| playerContext.internalState == .disposed
|
||||
if playerContext.internalState == .disposed
|
||||
|| playerContext.internalState == .pendingNext
|
||||
|| playerContext.internalState == .stopped
|
||||
{
|
||||
@@ -412,7 +416,7 @@ final class AudioFileStreamProcessor {
|
||||
if let playingEntry = playerContext.audioPlayingEntry,
|
||||
playingEntry.seekRequest.requested, playingEntry.calculatedBitrate() > 0
|
||||
{
|
||||
fileStreamCallback?(.proccessSource)
|
||||
fileStreamCallback?(.processSource)
|
||||
if rendererContext.waiting.value {
|
||||
rendererContext.packetsSemaphore.signal()
|
||||
}
|
||||
@@ -457,7 +461,7 @@ final class AudioFileStreamProcessor {
|
||||
framesToDecode = start
|
||||
if framesToDecode == 0 {
|
||||
fillUsedFrames(framesCount: framesAdded)
|
||||
continue packetProccess
|
||||
continue packetProcess
|
||||
}
|
||||
prefillLocalBufferList(bufferList: localBufferList,
|
||||
dataOffset: 0,
|
||||
@@ -475,9 +479,9 @@ final class AudioFileStreamProcessor {
|
||||
if status == AudioConvertStatus.done.rawValue {
|
||||
fillUsedFrames(framesCount: framesAdded)
|
||||
return
|
||||
} else if status == AudioConvertStatus.proccessed.rawValue {
|
||||
} else if status == AudioConvertStatus.processed.rawValue {
|
||||
fillUsedFrames(framesCount: framesAdded)
|
||||
continue packetProccess
|
||||
continue packetProcess
|
||||
} else if status != 0 {
|
||||
fileStreamCallback?(.raiseError(.codecError))
|
||||
return
|
||||
@@ -502,9 +506,9 @@ final class AudioFileStreamProcessor {
|
||||
if status == AudioConvertStatus.done.rawValue {
|
||||
fillUsedFrames(framesCount: framesAdded)
|
||||
return
|
||||
} else if status == AudioConvertStatus.proccessed.rawValue {
|
||||
} else if status == AudioConvertStatus.processed.rawValue {
|
||||
fillUsedFrames(framesCount: framesAdded)
|
||||
continue packetProccess
|
||||
continue packetProcess
|
||||
} else if status != 0 {
|
||||
fileStreamCallback?(.raiseError(.codecError))
|
||||
return
|
||||
@@ -545,8 +549,8 @@ final class AudioFileStreamProcessor {
|
||||
}
|
||||
|
||||
@inline(__always)
|
||||
private func updateProccessedPackets(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 }
|
||||
@@ -618,12 +622,12 @@ private func _converterCallback(inAudioConverter _: AudioConverterRef,
|
||||
ioNumberDataPackets.pointee = convertInfo.pointee.numberOfPackets
|
||||
convertInfo.pointee.done = true
|
||||
|
||||
return AudioConvertStatus.proccessed.rawValue
|
||||
return AudioConvertStatus.processed.rawValue
|
||||
}
|
||||
|
||||
// MARK: HardwareCodedClass method
|
||||
|
||||
private func getHardwareCodecClassDescripition(formatId: UInt32, classDesc: UnsafeMutablePointer<AudioClassDescription>) -> Bool {
|
||||
private func getHardwareCodecClassDescription(formatId: UInt32, classDesc: UnsafeMutablePointer<AudioClassDescription>) -> Bool {
|
||||
#if os(iOS)
|
||||
var size: UInt32 = 0
|
||||
let formatIdSize = UInt32(MemoryLayout.size(ofValue: formatId))
|
||||
|
||||
@@ -198,7 +198,6 @@ final class AudioPlayerRenderProcessor: NSObject {
|
||||
state.contains(.running) && state != .playing
|
||||
}
|
||||
}
|
||||
rendererContext.waitingForDataAfterSeekFrameCount.write { $0 = 0 }
|
||||
}
|
||||
} else {
|
||||
rendererContext.waitingForDataAfterSeekFrameCount.write { $0 = 0 }
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
//
|
||||
// Created by Dimitrios C on 19/05/2021.
|
||||
// Copyright © 2021 Decimal. All rights reserved.
|
||||
//
|
||||
|
||||
import AVFoundation
|
||||
|
||||
///
|
||||
/// - parameter buffer: A buffer of audio captured from the output of an AVAudioNode.
|
||||
/// - parameter when: The time the buffer was captured.
|
||||
///
|
||||
public typealias FilterCallback = (_ buffer: AVAudioPCMBuffer,
|
||||
_ when: AVAudioTime) -> Void
|
||||
|
||||
/// A value type whose instances are used for frame filter
|
||||
/// - Note:
|
||||
/// The filter block will be called from a thread other than the main thread
|
||||
public struct FilterEntry: Equatable {
|
||||
/// A string value indicating the name of the filter
|
||||
public let name: String
|
||||
|
||||
/// A block in which you apply any filtering
|
||||
public let filter: FilterCallback
|
||||
|
||||
public init(name: String, filter: @escaping FilterCallback) {
|
||||
self.name = name
|
||||
self.filter = filter
|
||||
}
|
||||
|
||||
public static func == (lhs: FilterEntry, rhs: FilterEntry) -> Bool {
|
||||
lhs.name == rhs.name
|
||||
}
|
||||
}
|
||||
|
||||
public protocol FrameFiltering {
|
||||
/// A Boolean value indicating whether there are filter entries
|
||||
var hasEntries: Bool { get }
|
||||
|
||||
/// Adds a filter entry at the end of the queue
|
||||
/// - Parameter entry: An instance of `FilterEntry`
|
||||
func add(entry: FilterEntry)
|
||||
|
||||
/// Adds a filter entry after the specified name of another entry
|
||||
/// - Parameters:
|
||||
/// - entry: An instance of `FilterEntry`
|
||||
/// - named: The name of a previously added filter
|
||||
func add(entry: FilterEntry, afterEntry named: String)
|
||||
|
||||
/// Adds a filter entry with the given parameters
|
||||
/// - Parameters:
|
||||
/// - named: The name of the entry to be added
|
||||
/// - filter: The block for the filter handling
|
||||
func add(entry named: String, filter: @escaping FilterCallback)
|
||||
|
||||
/// Adds a filter entry with the given parameters
|
||||
/// - Parameters:
|
||||
/// - name: The name for the new entry
|
||||
/// - filterName: The name of a previously added filters
|
||||
/// - filter: The block for the filter handling
|
||||
func add(entry named: String, after filterName: String, filter: @escaping FilterCallback)
|
||||
|
||||
/// Removes a filter entry
|
||||
/// - Parameter entry: An instance of `FilterEntry` to be removed
|
||||
func remove(entry: FilterEntry)
|
||||
|
||||
/// Attempts to remove a filter entry by its name
|
||||
/// - Parameter named: A `String` representing the name of the filter entry
|
||||
func remove(entry named: String)
|
||||
|
||||
/// Removes all filter entries
|
||||
func removeAll()
|
||||
}
|
||||
|
||||
final class FrameFilterProcessor: NSObject, FrameFiltering {
|
||||
public var hasEntries: Bool {
|
||||
lock.lock(); defer { lock.unlock() }
|
||||
return !entries.isEmpty
|
||||
}
|
||||
|
||||
private let lock = UnfairLock()
|
||||
private let mixerNodeProvider: (() -> AVAudioMixerNode)
|
||||
private lazy var mixerNode: AVAudioMixerNode = {
|
||||
return mixerNodeProvider()
|
||||
}()
|
||||
|
||||
private(set) var entries: [FilterEntry] = []
|
||||
|
||||
private var hasInstalledTap: Bool = false
|
||||
|
||||
init(mixerNodeProvider: @escaping (() -> AVAudioMixerNode)) {
|
||||
self.mixerNodeProvider = mixerNodeProvider
|
||||
}
|
||||
|
||||
public func add(entry: FilterEntry) {
|
||||
lock.lock(); defer { lock.unlock() }
|
||||
entries.append(entry)
|
||||
installTapIfNeeded()
|
||||
}
|
||||
|
||||
public func add(entry: FilterEntry, afterEntry named: String) {
|
||||
lock.lock(); defer { lock.unlock() }
|
||||
guard let entryIndex = entries.firstIndex(where: { $0.name == named }) else {
|
||||
return
|
||||
}
|
||||
if entryIndex.advanced(by: 1) > entries.count {
|
||||
entries.append(entry)
|
||||
} else {
|
||||
entries.insert(entry, at: entryIndex + 1)
|
||||
}
|
||||
installTapIfNeeded()
|
||||
}
|
||||
|
||||
public func add(entry named: String, filter: @escaping FilterCallback) {
|
||||
lock.lock(); defer { lock.unlock() }
|
||||
entries.append(FilterEntry(name: named, filter: filter))
|
||||
installTapIfNeeded()
|
||||
}
|
||||
|
||||
func add(entry named: String, after filterName: String, filter: @escaping FilterCallback) {
|
||||
let entry = FilterEntry(name: named, filter: filter)
|
||||
add(entry: entry, afterEntry: filterName)
|
||||
}
|
||||
|
||||
public func remove(entry: FilterEntry) {
|
||||
lock.lock(); defer { lock.unlock() }
|
||||
guard let entryIndex = entries.firstIndex(where: { $0 == entry }) else {
|
||||
return
|
||||
}
|
||||
entries.remove(at: entryIndex)
|
||||
if entries.isEmpty {
|
||||
removeTap()
|
||||
}
|
||||
}
|
||||
|
||||
public func remove(entry named: String) {
|
||||
lock.lock(); defer { lock.unlock() }
|
||||
guard let entryIndex = entries.firstIndex(where: { $0.name == named }) else {
|
||||
return
|
||||
}
|
||||
entries.remove(at: entryIndex)
|
||||
if entries.isEmpty {
|
||||
removeTap()
|
||||
}
|
||||
}
|
||||
|
||||
public func removeAll() {
|
||||
lock.lock(); defer { lock.unlock() }
|
||||
entries.removeAll()
|
||||
removeTap()
|
||||
}
|
||||
|
||||
private func process(buffer: AVAudioPCMBuffer, when: AVAudioTime) {
|
||||
lock.lock(); defer { lock.unlock() }
|
||||
guard !entries.isEmpty else { return }
|
||||
for entry in entries {
|
||||
entry.filter(buffer, when)
|
||||
}
|
||||
}
|
||||
|
||||
private func installTapIfNeeded() {
|
||||
guard !hasInstalledTap else { return }
|
||||
hasInstalledTap = true
|
||||
let format = mixerNode.outputFormat(forBus: 0)
|
||||
mixerNode.installTap(onBus: 0, bufferSize: 1024, format: format) { [weak self] buffer, when in
|
||||
guard let self = self else { return }
|
||||
guard self.hasEntries else { return }
|
||||
self.process(
|
||||
buffer: buffer,
|
||||
when: when
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func removeTap() {
|
||||
hasInstalledTap = false
|
||||
mixerNode.removeTap(onBus: 0)
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,6 @@ import Foundation
|
||||
/// ```
|
||||
|
||||
final class IcycastHeadersProcessor {
|
||||
|
||||
private var icecastHeaders = Data(capacity: 1024)
|
||||
private var searchComplete = false
|
||||
private var iceHeaderAvailable = false
|
||||
@@ -38,31 +37,32 @@ final class IcycastHeadersProcessor {
|
||||
}
|
||||
|
||||
@inline(__always)
|
||||
func proccess(data: Data) -> (Data?, Data) {
|
||||
let stopProccessingCheckOne: [UInt8] = Array("\n\n".utf8)
|
||||
let stopProccessingCheckTwo: [UInt8] = Array("\r\n\r\n".utf8)
|
||||
func process(data: Data) -> (Data?, Data) {
|
||||
let stopProcessingCheckOne: [UInt8] = Array("\n\n".utf8)
|
||||
let stopProcessingCheckTwo: [UInt8] = Array("\r\n\r\n".utf8)
|
||||
let icyPrefix: [UInt8] = Array("ICY ".utf8)
|
||||
let httpPrefix: [UInt8] = Array("HTTP".utf8)
|
||||
return data.withUnsafeBytes { buffer -> (Data?, Data) in
|
||||
guard !buffer.isEmpty else { return (nil, data) }
|
||||
var bytesRead = 0
|
||||
let bytes = buffer.baseAddress!.assumingMemoryBound(to: UInt8.self)
|
||||
// Read through the bytes and stop when our search is complete
|
||||
// Since we don't know the amount of bytes to be proccessed
|
||||
// Since we don't know the amount of bytes to be processed
|
||||
// we add each character up until we found on of the checks as defined above.
|
||||
while bytesRead < buffer.count, !searchComplete {
|
||||
let pointer = bytes + bytesRead
|
||||
icecastHeaders.append(pointer, count: 1)
|
||||
|
||||
if icecastHeaders.count >= stopProccessingCheckOne.count {
|
||||
if icecastHeaders.suffix(stopProccessingCheckOne.count) == stopProccessingCheckOne {
|
||||
if icecastHeaders.count >= stopProcessingCheckOne.count {
|
||||
if icecastHeaders.suffix(stopProcessingCheckOne.count) == stopProcessingCheckOne {
|
||||
iceHeaderAvailable = true
|
||||
searchComplete = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if icecastHeaders.count >= stopProccessingCheckTwo.count {
|
||||
if icecastHeaders.suffix(stopProccessingCheckTwo.count) == stopProccessingCheckTwo {
|
||||
if icecastHeaders.count >= stopProcessingCheckTwo.count {
|
||||
if icecastHeaders.suffix(stopProcessingCheckTwo.count) == stopProcessingCheckTwo {
|
||||
iceHeaderAvailable = true
|
||||
searchComplete = true
|
||||
break
|
||||
@@ -71,8 +71,9 @@ final class IcycastHeadersProcessor {
|
||||
|
||||
if icecastHeaders.count >= icyPrefix.count {
|
||||
// in case the first 4 chars are not "ICY " nor "HTTP" then we stop the flow
|
||||
if icecastHeaders[..<icyPrefix.count].elementsEqual(icyPrefix) == false &&
|
||||
icecastHeaders[..<httpPrefix.count].elementsEqual(httpPrefix) == false {
|
||||
if icecastHeaders[..<icyPrefix.count].elementsEqual(icyPrefix) == false,
|
||||
icecastHeaders[..<httpPrefix.count].elementsEqual(httpPrefix) == false
|
||||
{
|
||||
iceHeaderAvailable = false
|
||||
searchComplete = true
|
||||
}
|
||||
|
||||
@@ -12,16 +12,16 @@ protocol MetadataStreamSourceDelegate: AnyObject {
|
||||
protocol MetadataStreamSource {
|
||||
var delegate: MetadataStreamSourceDelegate? { get set }
|
||||
|
||||
/// Returns `true` when the stream header has indicated that we can proccess metadata, otherwise `false`.
|
||||
var canProccessMetadata: Bool { get }
|
||||
/// Returns `true` when the stream header has indicated that we can process metadata, otherwise `false`.
|
||||
var canProcessMetadata: Bool { get }
|
||||
|
||||
/// Assigns the metadata step of the metadata
|
||||
func metadataAvailable(step: Int)
|
||||
|
||||
/// Proccess the received data and extract the metadata if any, returns audio data only.
|
||||
/// Process the received data and extract the metadata if any, returns audio data only.
|
||||
/// - parameter data: A `Data` object for parsing any metadata
|
||||
/// - returns: The extracted audio `Data`
|
||||
func proccessMetadata(data: Data) -> Data
|
||||
func processMetadata(data: Data) -> Data
|
||||
|
||||
/// Resets the processor
|
||||
func reset()
|
||||
@@ -44,7 +44,7 @@ protocol MetadataStreamSource {
|
||||
final class MetadataStreamProcessor: MetadataStreamSource {
|
||||
weak var delegate: MetadataStreamSourceDelegate?
|
||||
|
||||
var canProccessMetadata: Bool {
|
||||
var canProcessMetadata: Bool {
|
||||
return metadataStep > 0
|
||||
}
|
||||
|
||||
@@ -73,10 +73,10 @@ final class MetadataStreamProcessor: MetadataStreamSource {
|
||||
audioDataBytesRead = 0
|
||||
}
|
||||
|
||||
// MARK: Proccess Metadata
|
||||
// MARK: Process Metadata
|
||||
|
||||
@inline(__always)
|
||||
func proccessMetadata(data: Data) -> Data {
|
||||
func processMetadata(data: Data) -> Data {
|
||||
data.withUnsafeBytes { buffer -> Data in
|
||||
guard !buffer.isEmpty else { return data }
|
||||
var audioData = Data()
|
||||
|
||||
@@ -17,14 +17,14 @@ final class PlayerQueueEntries {
|
||||
|
||||
/// Returns `true` when both underlying entries are empty
|
||||
var isEmpty: Bool {
|
||||
lock.around {
|
||||
lock.withLock {
|
||||
bufferring.isEmpty && upcoming.isEmpty
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the count of both underlying entries
|
||||
var count: Int {
|
||||
lock.around {
|
||||
lock.withLock {
|
||||
bufferring.count + upcoming.count
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ struct HeaderField {
|
||||
}
|
||||
|
||||
enum IcyHeaderField {
|
||||
public static let icyMentaint = "icy-metaint"
|
||||
public static let icyMetaint = "icy-metaint"
|
||||
}
|
||||
|
||||
struct HTTPHeaderParserOutput {
|
||||
@@ -64,7 +64,7 @@ struct HTTPHeaderParser: HTTPHeaderParsing {
|
||||
}
|
||||
|
||||
var metadataStep = 0
|
||||
if let icyMetaint = value(forHTTPHeaderField: IcyHeaderField.icyMentaint, in: input),
|
||||
if let icyMetaint = value(forHTTPHeaderField: IcyHeaderField.icyMetaint, in: input),
|
||||
let intValue = Int(icyMetaint)
|
||||
{
|
||||
metadataStep = intValue
|
||||
|
||||
@@ -9,9 +9,7 @@
|
||||
import Foundation
|
||||
|
||||
struct IcycastHeaderParser: Parser {
|
||||
|
||||
func parse(input: Data) -> HTTPHeaderParserOutput? {
|
||||
|
||||
guard let icecastValue = String(data: input, encoding: .utf8) else {
|
||||
return nil
|
||||
}
|
||||
@@ -23,7 +21,7 @@ struct IcycastHeaderParser: Parser {
|
||||
result[String(key)] = String(value)
|
||||
}
|
||||
}
|
||||
let metadataStep = Int(result[IcyHeaderField.icyMentaint] ?? "") ?? 0
|
||||
let metadataStep = Int(result[IcyHeaderField.icyMetaint] ?? "") ?? 0
|
||||
let contentType = result[HeaderField.contentType.lowercased()] ?? "audio/mpeg"
|
||||
let typeId = audioFileType(mimeType: contentType)
|
||||
|
||||
|
||||
@@ -18,13 +18,17 @@ struct MetadataParser: Parser {
|
||||
|
||||
func parse(input: Data) -> MetadataOutput {
|
||||
guard let string = String(data: input, encoding: .utf8) else { return .failure(.unableToParse) }
|
||||
// remove added bytes (zeros) and seperate the string on every ';' char
|
||||
// remove added bytes (zeros) and separate the string on every ';' char
|
||||
let pairs = string.trimmingCharacters(in: CharacterSet(charactersIn: "\0")).components(separatedBy: ";")
|
||||
let temp: [String: String] = [:]
|
||||
let metadata = pairs.reduce(into: temp) { result, next in
|
||||
let paired = next.components(separatedBy: "=")
|
||||
if let key = paired.first,
|
||||
let value = paired.last?.replacingOccurrences(of: "'", with: ""), !key.isEmpty
|
||||
let metadata = pairs.reduce(into: [String: String]()) { result, next in
|
||||
let split = next.split(
|
||||
separator: "=",
|
||||
maxSplits: 1,
|
||||
omittingEmptySubsequences: true
|
||||
)
|
||||
.map(String.init)
|
||||
if let key = split.first,
|
||||
let value = split.last?.replacingOccurrences(of: "'", with: ""), !key.isEmpty
|
||||
{
|
||||
result[key] = value
|
||||
}
|
||||
|
||||
+8
-8
@@ -7,27 +7,27 @@ import XCTest
|
||||
|
||||
@testable import AudioStreaming
|
||||
|
||||
class ProtectedTests: XCTestCase {
|
||||
class AtomicTests: XCTestCase {
|
||||
func testProtectedValuesAreAccessedSafely() {
|
||||
measure {
|
||||
let protected = Protected<Int>(0)
|
||||
let atomic = Atomic<Int>(0)
|
||||
|
||||
DispatchQueue.concurrentPerform(iterations: 1_000_000) { _ in
|
||||
_ = protected.value
|
||||
protected.write { $0 += 1 }
|
||||
DispatchQueue.concurrentPerform(iterations: 100000) { _ in
|
||||
_ = atomic.value
|
||||
atomic.write { $0 += 1 }
|
||||
}
|
||||
|
||||
XCTAssertEqual(protected.value, 1_000_000)
|
||||
XCTAssertEqual(atomic.value, 100000)
|
||||
}
|
||||
}
|
||||
|
||||
func testThatProtectedReadAndWriteAreSafe() {
|
||||
measure {
|
||||
let initialValue = "aValue"
|
||||
let protected = Protected<String>(initialValue)
|
||||
let protected = Atomic<String>(initialValue)
|
||||
|
||||
DispatchQueue.concurrentPerform(iterations: 1000) { i in
|
||||
_ = protected.read { $0 }
|
||||
_ = protected.value
|
||||
protected.write { $0 = "\(i)" }
|
||||
}
|
||||
|
||||
+8
-8
@@ -18,19 +18,19 @@ class MetadataStreamProcessorTests: XCTestCase {
|
||||
let processor = MetadataStreamProcessor(parser: parser.eraseToAnyParser())
|
||||
|
||||
// without calling `metadataAvailable(step:)` it should be false
|
||||
XCTAssertFalse(processor.canProccessMetadata)
|
||||
XCTAssertFalse(processor.canProcessMetadata)
|
||||
|
||||
// calling `metadataAvailable(step:)` with zero
|
||||
processor.metadataAvailable(step: 0)
|
||||
|
||||
// it should be false
|
||||
XCTAssertFalse(processor.canProccessMetadata)
|
||||
XCTAssertFalse(processor.canProcessMetadata)
|
||||
|
||||
// calling `metadataAvailable(step:)` with greater zero
|
||||
processor.metadataAvailable(step: 1)
|
||||
|
||||
// it should be true
|
||||
XCTAssertTrue(processor.canProccessMetadata)
|
||||
XCTAssertTrue(processor.canProcessMetadata)
|
||||
}
|
||||
|
||||
func test_Processor_Outputs_Correct_Metadata_ForStep_WithEmptyMetadata() throws {
|
||||
@@ -45,7 +45,7 @@ class MetadataStreamProcessorTests: XCTestCase {
|
||||
// this is the step value as received from the http headers
|
||||
processor.metadataAvailable(step: 16000)
|
||||
|
||||
let audio = processor.proccessMetadata(data: data)
|
||||
let audio = processor.processMetadata(data: data)
|
||||
XCTAssertFalse(audio.isEmpty)
|
||||
|
||||
XCTAssertTrue(metadataDelegateSpy.receivedMetadata.called)
|
||||
@@ -64,7 +64,7 @@ class MetadataStreamProcessorTests: XCTestCase {
|
||||
// this is the step value as received from the http headers
|
||||
processor.metadataAvailable(step: 16000)
|
||||
|
||||
let audio = processor.proccessMetadata(data: data)
|
||||
let audio = processor.processMetadata(data: data)
|
||||
XCTAssertFalse(audio.isEmpty)
|
||||
|
||||
XCTAssertTrue(metadataDelegateSpy.receivedMetadata.called)
|
||||
@@ -83,7 +83,7 @@ class MetadataStreamProcessorTests: XCTestCase {
|
||||
// this is the step value as received from the http headers
|
||||
processor.metadataAvailable(step: 8000)
|
||||
|
||||
let audio = processor.proccessMetadata(data: data)
|
||||
let audio = processor.processMetadata(data: data)
|
||||
XCTAssertFalse(audio.isEmpty)
|
||||
|
||||
XCTAssertTrue(metadataDelegateSpy.receivedMetadata.called)
|
||||
@@ -106,7 +106,7 @@ class MetadataStreamProcessorTests: XCTestCase {
|
||||
// this is the step value as received from the http headers
|
||||
processor.metadataAvailable(step: 16000)
|
||||
|
||||
let audio = processor.proccessMetadata(data: data)
|
||||
let audio = processor.processMetadata(data: data)
|
||||
XCTAssertFalse(audio.isEmpty)
|
||||
|
||||
XCTAssertFalse(metadataDelegateSpy.receivedMetadata.called)
|
||||
@@ -122,7 +122,7 @@ class MetadataStreamProcessorTests: XCTestCase {
|
||||
// this is the step value as received from the http headers
|
||||
processor.metadataAvailable(step: 16000)
|
||||
|
||||
let audio = processor.proccessMetadata(data: data)
|
||||
let audio = processor.processMetadata(data: data)
|
||||
XCTAssertTrue(audio.isEmpty)
|
||||
|
||||
XCTAssertFalse(metadataDelegateSpy.receivedMetadata.called)
|
||||
|
||||
@@ -34,7 +34,7 @@ class HTTPHeaderParserTests: XCTestCase {
|
||||
let headers: [String: String] =
|
||||
[HeaderField.contentLength: "1000",
|
||||
HeaderField.contentType: "audio/mp3",
|
||||
IcyHeaderField.icyMentaint: "16000"]
|
||||
IcyHeaderField.icyMetaint: "16000"]
|
||||
let httpURLResponse = HTTPURLResponse(url: URL(string: "www.google.com")!,
|
||||
statusCode: 200,
|
||||
httpVersion: "",
|
||||
@@ -57,7 +57,7 @@ class HTTPHeaderParserTests: XCTestCase {
|
||||
let headers: [String: String] =
|
||||
[HeaderField.contentLength.lowercased(): "1000",
|
||||
HeaderField.contentType.lowercased(): "audio/mp3",
|
||||
IcyHeaderField.icyMentaint.lowercased(): "16000"]
|
||||
IcyHeaderField.icyMetaint.lowercased(): "16000"]
|
||||
let httpURLResponse = HTTPURLResponse(url: URL(string: "www.google.com")!,
|
||||
statusCode: 200,
|
||||
httpVersion: "",
|
||||
|
||||
@@ -50,6 +50,26 @@ class MetadataParserTests: XCTestCase {
|
||||
}
|
||||
}
|
||||
|
||||
func testParserOutputsCorrectResultWhenEntryContainsEqualSign() throws {
|
||||
let string = "StreamTitle=\'Gramatik - In This Whole World (Original Mix)\';StreamUrl=\'\';track_info=\'k4Smc3RhdHVzoUihQNJiGp6BpHR5cGWhVKJpZKhNWDUxMTYzNISmc3RhdHVzoUOhQNJiGp9cpHR5cGWhVKJpZKhNWDUxMDM3MoSmc3RhdHVzoUOhQNJiGqAqpHR5cGWhVKJpZKhNWDUxMjA5Ng==\';UTC=\'20220226T214447.206\';\0\0\0\0\0\0\0\0\0"
|
||||
let data = string.data(using: .utf8)!
|
||||
|
||||
let parser = MetadataParser()
|
||||
|
||||
let output = parser.parse(input: data)
|
||||
|
||||
switch output {
|
||||
case let .success(values):
|
||||
XCTAssertFalse(values.isEmpty)
|
||||
XCTAssertEqual(values["StreamTitle"], "Gramatik - In This Whole World (Original Mix)")
|
||||
XCTAssertEqual(values["StreamUrl"], "")
|
||||
XCTAssertEqual(values["track_info"], "k4Smc3RhdHVzoUihQNJiGp6BpHR5cGWhVKJpZKhNWDUxMTYzNISmc3RhdHVzoUOhQNJiGp9cpHR5cGWhVKJpZKhNWDUxMDM3MoSmc3RhdHVzoUOhQNJiGqAqpHR5cGWhVKJpZKhNWDUxMjA5Ng==")
|
||||
XCTAssertEqual(values["UTC"], "20220226T214447.206")
|
||||
case .failure:
|
||||
XCTFail()
|
||||
}
|
||||
}
|
||||
|
||||
func testParserOutputsFailureOnEmptyStringData() throws {
|
||||
let data = "".data(using: .utf8)!
|
||||
let parser = MetadataParser()
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@ let package = Package(
|
||||
.target(
|
||||
name: "AudioStreaming",
|
||||
path: "AudioStreaming"
|
||||
)
|
||||
),
|
||||
],
|
||||
swiftLanguageVersions: [.v5]
|
||||
)
|
||||
|
||||
@@ -3,15 +3,15 @@
|
||||
# AudioStreaming
|
||||
An AudioPlayer/Streaming library for iOS written in Swift, allows playback of online audio streaming, local file as well as gapless queueing.
|
||||
|
||||
Under the hood `AudioStreaming` uses `AVAudioEngine` and `CoreAudio` for playback and provides an easy way of applying real-time [audio enhancements](https://developer.apple.com/documentation/avfoundation/audio_playback_recording_and_processing/avaudioengine/audio_units?language=swift).
|
||||
Under the hood `AudioStreaming` uses `AVAudioEngine` and `CoreAudio` for playback and provides an easy way of applying real-time [audio enhancements](https://developer.apple.com/documentation/avfaudio/audio_engine/audio_units).
|
||||
|
||||
#### Supported audio
|
||||
- Online streaming (Shoutcast/ICY streams) with metadata parsing
|
||||
- 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_)
|
||||
|
||||
Known limitations:
|
||||
- As described above non-optimised M4A files are not supported this is a limitation of [AudioFileStream Services](https://developer.apple.com/documentation/audiotoolbox/audio_file_stream_services?language=swift)
|
||||
- As described above non-optimised M4A files are not supported this is a limitation of [AudioFileStream Services](https://developer.apple.com/documentation/audiotoolbox/audio_file_stream_services?language=swift)
|
||||
|
||||
|
||||
# Requirements
|
||||
@@ -22,18 +22,18 @@ Known limitations:
|
||||
|
||||
### Playing an audio source over HTTP
|
||||
Note: You need to keep a reference to the `AudioPlayer` object
|
||||
```
|
||||
```swift
|
||||
let player = AudioPlayer()
|
||||
player.play(url: URL(string: "https://your-remote-url/to/audio-file.mp3")!)
|
||||
```
|
||||
|
||||
### Playing a local file
|
||||
```
|
||||
```swift
|
||||
let player = AudioPlayer()
|
||||
player.play(url: URL(fileURLWithPath: "your-local-path/to/audio-file.mp3")!)
|
||||
```
|
||||
### Queueing audio files
|
||||
```
|
||||
```swift
|
||||
let player = AudioPlayer()
|
||||
// when you want to queue a single url
|
||||
player.queue(url: URL(string: "https://your-remote-url/to/audio-file.mp3")!)
|
||||
@@ -46,7 +46,7 @@ player.queue(urls: [
|
||||
```
|
||||
|
||||
### Adjusting playback properties
|
||||
```
|
||||
```swift
|
||||
let player = AudioPlayer()
|
||||
player.play(url: URL(fileURLWithPath: "your-local-path/to/audio-file.mp3")!)
|
||||
// adjust the playback rate
|
||||
@@ -72,7 +72,7 @@ player.seek(to: 10)
|
||||
```
|
||||
|
||||
### Audio playback properties
|
||||
```
|
||||
```swift
|
||||
let player = AudioPlayer()
|
||||
player.play(url: URL(fileURLWithPath: "your-local-path/to/audio-file.mp3")!)
|
||||
|
||||
@@ -93,7 +93,7 @@ let state = player.stopReason
|
||||
You can inspect various callbacks by using the `delegate` property of the `AudioPlayer` to get informed about the player state, errors etc.
|
||||
View the [AudioPlayerDelegate](AudioStreaming/Streaming/AudioPlayer/AudioPlayerDelegate.swift) for more details
|
||||
|
||||
```
|
||||
```swift
|
||||
let player = AudioPlayer()
|
||||
player.play(url: URL(fileURLWithPath: "your-local-path/to/audio-file.mp3")!)
|
||||
|
||||
@@ -105,9 +105,9 @@ func audioPlayerStateChanged(player: AudioPlayer, with newState: AudioPlayerStat
|
||||
|
||||
### Adding custom audio nodes to AudioPlayer
|
||||
`AudioStreaming` provides an easy way to attach/remove `AVAudioNode`(s).
|
||||
This provides a powerful way of adjusting the playback audio with various enchncements
|
||||
This provides a powerful way of adjusting the playback audio with various enhancements
|
||||
|
||||
```
|
||||
```swift
|
||||
let reverbNode = AVAudioUnitReverb()
|
||||
reverbNode.wetDryMix = 50
|
||||
|
||||
@@ -124,6 +124,41 @@ player.detachCustomAttachedNodes()
|
||||
|
||||
The example project shows an example of adding a custom `AVAudioUnitEQ` node for adding equaliser to the `AudioPlayer`
|
||||
|
||||
### Adding custom frame filter for recording and observation of audio data
|
||||
|
||||
`AudioStreaming` allow for custom frame filters to be added so that recording or other observation for audio that's playing.
|
||||
|
||||
You add a frame filter by using the `AudioPlayer`'s property `frameFiltering`.
|
||||
|
||||
```swift
|
||||
let player = AudioPlayer()
|
||||
let format = player.mainMixerNode.outputFormat(forBus: 0)
|
||||
|
||||
let settings = [
|
||||
AVFormatIDKey: kAudioFormatMPEG4AAC,
|
||||
AVSampleRateKey: format.sampleRate,
|
||||
AVNumberOfChannelsKey: format.channelCount
|
||||
] as [String : Any]
|
||||
|
||||
var audioFile = try? AVAudioFile(
|
||||
forWriting: outputUrl,
|
||||
settings: settings,
|
||||
commonFormat: format.commonFormat,
|
||||
interleaved: format.isInterleaved)
|
||||
|
||||
let record = FilterEntry(name: "record") { buffer, when in
|
||||
try? audioFile?.write(from: buffer)
|
||||
}
|
||||
|
||||
player.frameFiltering.add(entry: record)
|
||||
```
|
||||
See the `FrameFiltering` protocol for more ways of adding and removing frame filters.
|
||||
The callback in which you observe a filter will be run on a thread other than the main thread.
|
||||
|
||||
Under the hood the concrete class for frame filters, `FrameFilterProcessor` installs a tap on the `mainMixerNode` of `AVAudioEngine` in which all the added filter will be called from.
|
||||
|
||||
**Note** since the `mainMixerNode` is publicly exposed extra care should be taken to not install a tap directly and also use frame filters, this result in an exception because only one tap can be installed on an output node, as per Apple's documentation.
|
||||
|
||||
# Installation
|
||||
|
||||
### Cocoapods
|
||||
@@ -164,5 +199,5 @@ Visit [installation instructions](https://github.com/Carthage/Carthage#adding-fr
|
||||
AudioStreaming is available under the MIT license. See the LICENSE file for more info.
|
||||
|
||||
# Attributions
|
||||
This librabry takes inspiration on the already battled-tested streaming library, [StreamingKit](https://github.com/tumtumtum/StreamingKit).
|
||||
This library takes inspiration on the already battled-tested streaming library, [StreamingKit](https://github.com/tumtumtum/StreamingKit).
|
||||
Big 🙏 to Thong Nguyen (@tumtumtum) and Matt Gallagher (@mattgallagher) for [AudioStreamer](https://github.com/mattgallagher/AudioStreamer)
|
||||
|
||||
Reference in New Issue
Block a user