Compare commits

..

41 Commits

Author SHA1 Message Date
dimitris-c 4e8a3f0289 experimenting with RemoteAudioSource 2021-09-29 13:21:54 +03:00
Dimitris C 30b4189778 Bumping version (#30) 2021-09-07 15:47:59 +03:00
Dimitris C 8bdc2a64f7 Fixes a memory leak issue in RemoteAudioSource (#29) 2021-09-07 15:36:51 +03:00
Dimitris C 65de9d90c0 Version bump (#19)
* Bumps version

* Bumps version

Co-authored-by: Dimitrios C <dimitrisc@DimitrisC-Macbook-Pro.local>
2021-05-25 00:01:31 +03:00
Dimitris C 217a88f171 Adds frame filters to allow recording, monitoring, and observation of audio (#18)
* Adds frame filters feature

* nit

* Updates Readme file

Co-authored-by: Dimitrios C <dimitrisc@DimitrisC-Macbook-Pro.local>
2021-05-24 23:58:16 +03:00
Dimitrios C 566dc86f3f Bumps version 2021-05-18 23:57:01 +03:00
Dimitrios C d8aa58525c Makes AudioPlayer an open class
Exposes AudioEngine’s mainMixerNode
Added missing documentation
2021-05-18 23:54:43 +03:00
Dimitris C 8197db0016 Update README.md 2021-04-12 13:10:44 +03:00
Dimitris C c2aee1669b Bump version (#17)
Co-authored-by: Dimitrios C <dimitrisc@DimitrisC-Macbook-Pro.local>
2021-03-22 12:15:42 +02:00
Mushthak Ebrahim 334be32bf9 Fix header not get passed into method (#16) 2021-03-02 18:15:26 +02:00
Dimitris C a2da46f85b Bump version (#15) 2021-02-14 16:37:49 +02:00
Dimitris C aca69debd1 Adds support for Shoutcast headers in audio stream (#14)
* Adds support for Shoutcast headers in audio stream

* Renames proccessIcecastHeaders to process(data:

* Updates comment on IcycastHeadersProcessor
2021-02-14 16:33:37 +02:00
Dimitris C e032d34ff7 Merge branch 'main' of https://github.com/dimitris-c/AudioStreaming into main 2021-01-16 11:40:01 +02:00
Dimitris C 280d3464c1 Bump version number 2021-01-16 11:39:41 +02:00
Dimitris C f0811c4fc8 Fixes queueing multiple items (#13)
* Fixes queing multiple items

Adds error callback on AudioConverter failure

* Adds a new radio stream in AudioExample

* Updates Readme file
2021-01-16 11:37:26 +02:00
Dimitris C 6c9ef18d4e Fixes an issue when queueing a song (#10)
- Updates AudioExample with initial queuing of items
2020-12-07 22:12:30 +00:00
Jacky db8aa646da return current seeking time as progress (#9)
* return current seeking time as progress

* Update AudioStreaming/Streaming/AudioPlayer/AudioPlayer.swift

Co-authored-by: Dimitris C. <d.chatzieleftheriou@gmail.com>

Co-authored-by: Dimitris C. <d.chatzieleftheriou@gmail.com>
2020-12-02 14:22:58 +00:00
Dimitris C c84f4d9d24 Merge pull request #8 from dimitris-c/bug/magic-cookie-fix
Fixes magicCookie issue
2020-12-01 18:12:50 +00:00
Dimitris C 22e46114a6 Fixes magicCookie issue
Fixes an issue with the magic cookie being set to AudioFileStream instead of AudioConverter.
2020-12-01 17:49:25 +00:00
Dimitris C 38bdd32526 Merge pull request #7 from dimitris-c/bug/httpheaders-caseinsensitive
Better parsing of header fields for case insensitive
2020-12-01 12:42:17 +00:00
Dimitris C 28fa4463e0 Update AudioStreaming/Streaming/Parsers/HTTPHeaderParser.swift 2020-12-01 11:08:08 +00:00
Dimitris C abd8c91b46 Better parsing of header fields for case insensitive 2020-12-01 10:51:15 +00:00
dimitris-c 86d6e3a05a version bump: 0.2.0 2020-11-27 12:08:28 +00:00
Dimitris C 474a390b29 Update README.md
Fixes dead link
2020-11-26 09:30:54 +00:00
Dimitris C f8cd25bd68 Update README.md 2020-11-19 14:03:52 +00:00
Dimitris C 767978e70a Update swift.yml 2020-11-19 14:02:24 +00:00
Dimitris C 85b45f6dfa Fixes memory leak in FileAudioSource 2020-11-19 13:57:09 +00:00
Dimitris C 0e6cadba1b removes overflow subtraction operator 2020-11-19 13:51:23 +00:00
Dimitris C ed58739be0 Squashed commit of the following:
commit 52385c28089e2440f9ebea20abedf5be1c518cda
Author: Dimitris C <d.chatzieleftheriou@gmail.com>
Date:   Thu Nov 19 13:14:59 2020 +0000

    Fixes and issue with arithmetic overflow

    # Conflicts:
    #	AudioStreaming/Streaming/Audio Source/RemoteAudioSource.swift
    #	AudioStreaming/Streaming/AudioPlayer/Processors/AudioFileStreamProcessor.swift
2020-11-19 13:20:46 +00:00
Dimitris C 10455ed4be Adds podcast in AudioExample 2020-11-17 17:29:42 +00:00
Dimitris C 6f48f3a526 Update README.md 2020-11-17 09:45:26 +00:00
Dimitris C ed03fcdd0e Update README.md 2020-11-16 22:09:56 +00:00
Dimitris C 6e3b50d6f9 Update README.md 2020-11-16 22:05:30 +00:00
Dimitris C 21b245c114 Merge pull request #3 from dimitris-c/ci
Create swift.yml
2020-11-16 21:36:45 +00:00
Dimitris C 8599d66bec Update swift.yml 2020-11-16 21:31:54 +00:00
Dimitris C c6bd74a68c Update swift.yml 2020-11-16 21:30:57 +00:00
Dimitris C bb3e518d08 Create swift.yml 2020-11-16 21:20:34 +00:00
Dimitris C 1097743d57 Merge pull request #2 from dimitris-c/update-readme-file-1
Added README.md
2020-11-16 21:14:44 +00:00
Dimitris C 2efe273f9e Create README.md 2020-11-16 21:14:25 +00:00
Dimitris C 2ddd11d255 Updates podspec 2020-11-16 12:21:54 +00:00
Dimitris C 33575385e3 Removes bogus file 2020-11-16 12:11:18 +00:00
22 changed files with 886 additions and 464 deletions
+24
View File
@@ -0,0 +1,24 @@
name: "AudioStreaming CI"
on:
push:
branches:
- main
- hotfix
pull_request:
branches:
- '*'
jobs:
iOS:
name: Test iOS
runs-on: macOS-latest
env:
DEVELOPER_DIR: /Applications/Xcode_12.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"]
steps:
- uses: actions/checkout@v2
- name: iOS - ${{ matrix.destination }}
run: set -o pipefail && env NSUnbufferedIO=YES xcodebuild -project "AudioStreaming.xcodeproj" -scheme "AudioStreaming" -destination "${{ matrix.destination }}" clean test | xcpretty
@@ -55,7 +55,7 @@ class PlayerViewController: UIViewController {
tableView.translatesAutoresizingMaskIntoConstraints = false
tableView.delegate = self
tableView.dataSource = self
tableView.register(UITableViewCell.self, forCellReuseIdentifier: "PlaylistCell")
tableView.register(PlaylistTableViewCell.self, forCellReuseIdentifier: "PlaylistCell")
let controlsController = controlsProvider()
playerControlsController = controlsController
@@ -120,6 +120,7 @@ extension PlayerViewController: UITableViewDataSource {
return cell
}
cell.textLabel?.text = item.name
cell.detailTextLabel?.text = item.queues ? "Queue item" : nil
update(status: item.status, of: cell)
return cell
}
@@ -147,3 +148,15 @@ extension PlayerViewController: UITableViewDelegate {
viewModel.playItem(at: indexPath)
}
}
final class PlaylistTableViewCell: UITableViewCell {
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: .subtitle, reuseIdentifier: reuseIdentifier)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
@@ -47,7 +47,7 @@ final class PlayerViewModel {
print("malformed url error")
return
}
playlistItemsService.add(item: PlaylistItem(url: url, name: urlString, status: .stopped))
playlistItemsService.add(item: PlaylistItem(url: url, name: urlString, status: .stopped, queues: false))
reloadContent?(.all)
}
@@ -57,13 +57,20 @@ final class PlayerViewModel {
func playItem(at indexPath: IndexPath) {
guard let item = item(at: indexPath) else { return }
if let index = currentPlayingItemIndex {
playlistItemsService.setStatus(for: index, status: .stopped)
reloadContent?(.item(IndexPath(row: index, section: 0)))
currentPlayingItemIndex = nil
if item.queues {
playerService.queue(url: item.url)
if currentPlayingItemIndex == nil {
currentPlayingItemIndex = indexPath.row
}
} else {
if let index = currentPlayingItemIndex {
playlistItemsService.setStatus(for: index, status: .stopped)
reloadContent?(.item(IndexPath(row: index, section: 0)))
currentPlayingItemIndex = nil
}
playerService.play(url: item.url)
currentPlayingItemIndex = indexPath.row
}
playerService.play(url: item.url)
currentPlayingItemIndex = indexPath.row
}
}
@@ -12,10 +12,12 @@ enum AudioContent: Int, CaseIterable {
case offradio
case enlefko
case pepper966
case kosmos
case radiox
case khruangbin
case piano
case local
case podcast
var title: String {
switch self {
@@ -25,6 +27,8 @@ enum AudioContent: Int, CaseIterable {
return "Enlefko (stream)"
case .pepper966:
return "Pepper 96.6 (stream)"
case .kosmos:
return "Kosmos 93.6 (stream)"
case .radiox:
return "Radio X (stream)"
case .khruangbin:
@@ -33,6 +37,8 @@ enum AudioContent: Int, CaseIterable {
return "Piano (mp3)"
case .local:
return "Local file (mp3)"
case .podcast:
return "Swift by Sundell. Ep. 50 (mp3)"
}
}
@@ -44,6 +50,8 @@ enum AudioContent: Int, CaseIterable {
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")!
case .kosmos:
return URL(string: "https://radiostreaming.ert.gr/ert-kosmos")!
case .radiox:
return URL(string: "https://media-ssl.musicradio.com/RadioXLondon")!
case .khruangbin:
@@ -53,6 +61,8 @@ 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")!
}
}
}
@@ -56,6 +56,11 @@ final class AudioPlayerService {
player.play(url: url)
}
func queue(url: URL) {
activateAudioSession()
player.queue(url: url)
}
func stop() {
player.stop()
deactivateAudioSession()
@@ -19,17 +19,20 @@ struct PlaylistItem: Equatable {
let url: URL
let name: String
let status: Status
let queues: Bool
init(content: AudioContent) {
init(content: AudioContent, queues: Bool) {
name = content.title
url = content.streamUrl
status = .stopped
self.queues = queues
}
init(url: URL, name: String, status: Status) {
init(url: URL, name: String, status: Status, queues: Bool) {
self.url = url
self.name = name
self.status = status
self.queues = queues
}
}
@@ -70,10 +73,14 @@ final class PlaylistItemsService {
guard let item = item(at: index) else {
return
}
items[index] = PlaylistItem(url: item.url, name: item.name, status: status)
items[index] = PlaylistItem(url: item.url, name: item.name, status: status, queues: item.queues)
}
}
func provideInitialPlaylistItems() -> [PlaylistItem] {
AudioContent.allCases.map(PlaylistItem.init(content:))
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) }
return allItems + casesForQueuingItems
}
+2 -4
View File
@@ -1,6 +1,6 @@
Pod::Spec.new do |s|
s.name = 'AudioStreaming'
s.version = '0.1.0'
s.version = '0.7.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'
@@ -13,9 +13,7 @@ Pod::Spec.new do |s|
s.source_files = 'AudioStreaming/**/*.swift'
s.frameworks = 'AVFoundation', ' CoreAudio', 'AudioToolbox', 'Network'
s.pod_target_xcconfig = {
'SWIFT_INSTALL_OBJC_HEADER' => 'NO'
}
end
end
+16 -2
View File
@@ -52,7 +52,10 @@
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 */; };
B5D82E65255DD562009EDAA4 /* NetStatusService.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5D82E64255DD562009EDAA4 /* NetStatusService.swift */; };
B5DB66E2255C2EAB00B8DF53 /* AudioEntryProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5DB66E1255C2EAB00B8DF53 /* AudioEntryProvider.swift */; };
B5E1DE2524B70B4200955BFB /* AudioPlayerConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5E1DE2424B70B4200955BFB /* AudioPlayerConfiguration.swift */; };
@@ -142,7 +145,10 @@
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>"; };
B5D82E64255DD562009EDAA4 /* NetStatusService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetStatusService.swift; sourceTree = "<group>"; };
B5DB66DA255C079C00B8DF53 /* AVFoundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AVFoundation.framework; path = System/Library/Frameworks/AVFoundation.framework; sourceTree = SDKROOT; };
B5DB66E1255C2EAB00B8DF53 /* AudioEntryProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioEntryProvider.swift; sourceTree = "<group>"; };
@@ -205,6 +211,7 @@
B55CEAB32485107C0001C498 /* Parser.swift */,
B55A736B247FCB420050C53D /* HTTPHeaderParser.swift */,
B55CE96D248058B60001C498 /* MetadataParser.swift */,
B5D4A40825D9321400E1450C /* IcycastHeaderParser.swift */,
);
path = Parsers;
sourceTree = "<group>";
@@ -254,9 +261,11 @@
B55CEAC024855AA20001C498 /* Processors */ = {
isa = PBXGroup;
children = (
B5B36E422655A32200DC96F5 /* FrameFilterProcessor.swift */,
B5667A8F2499018D00D93F85 /* AudioFileStreamProcessor.swift */,
B5667B3D249BC43000D93F85 /* AudioPlayerRenderProcessor.swift */,
B55CE97024810DE20001C498 /* MetadataStreamProcessor.swift */,
B5D4A40B25D9445600E1450C /* IcycastHeadersProcessor.swift */,
);
path = Processors;
sourceTree = "<group>";
@@ -594,6 +603,7 @@
B51B9F9A24DBE5BF00BDEAA2 /* AVAudioFormat+Convenience.swift in Sources */,
B51FE0C624890CCB00F2A4D2 /* PlayerQueueEntries.swift in Sources */,
B5EF9557247E9439003E8FF8 /* AudioStreamSource.swift in Sources */,
B5D4A40925D9321400E1450C /* IcycastHeaderParser.swift in Sources */,
B59DF1A32493E90C0043C498 /* AudioFileStream+Helpers.swift in Sources */,
B54D876D2490E4A000C361A0 /* UnitDescriptions.swift in Sources */,
B514657F248E3884005C03F7 /* DispatchTimerSource.swift in Sources */,
@@ -604,9 +614,11 @@
B5EF955B247EBCB3003E8FF8 /* AudioFileType.swift in Sources */,
B592E1252545FF9A008866FB /* BiMap.swift in Sources */,
B5DB66E2255C2EAB00B8DF53 /* AudioEntryProvider.swift in Sources */,
B5D4A41025D948EF00E1450C /* IcycastHeadersProcessor.swift in Sources */,
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 */,
@@ -786,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;
@@ -798,7 +811,7 @@
"@executable_path/Frameworks",
"@loader_path/Frameworks",
);
MARKETING_VERSION = 0.1.0;
MARKETING_VERSION = 0.7.0;
OTHER_LDFLAGS = "-ObjC";
PRODUCT_BUNDLE_IDENTIFIER = com.decimal.AudioStreaming;
PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)";
@@ -816,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;
@@ -828,7 +842,7 @@
"@executable_path/Frameworks",
"@loader_path/Frameworks",
);
MARKETING_VERSION = 0.1.0;
MARKETING_VERSION = 0.7.0;
OTHER_LDFLAGS = "-ObjC";
PRODUCT_BUNDLE_IDENTIFIER = com.decimal.AudioStreaming;
PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)";
@@ -9,7 +9,7 @@ extension AVAudioFormat {
/// The underlying audio stream description.
///
/// This exposes the `pointee` value of the `UsafePointer<AudioStreamBasicDescription>`
var basicStreamDescription: AudioStreamBasicDescription {
public var basicStreamDescription: AudioStreamBasicDescription {
return streamDescription.pointee
}
}
@@ -33,6 +33,7 @@ internal final class NetworkDataStream {
let error: Error?
}
private var lock = UnfairLock()
private var streamCallback: StreamCompletion?
/// The serial queue for all internal async actions.
@@ -66,6 +67,7 @@ internal final class NetworkDataStream {
@discardableResult
func responseStream(completion: @escaping StreamCompletion) -> Self {
lock.lock(); defer { lock.unlock() }
streamCallback = completion
return self
}
@@ -79,6 +81,7 @@ internal final class NetworkDataStream {
}
func cancel() {
lock.lock(); defer { lock.unlock() }
guard state.canBecome(.cancelled) else { return }
state = .cancelled
streamCallback = nil
@@ -46,10 +46,10 @@ final class AudioEntryProvider: AudioEntryProviding {
FileAudioSource(url: url, underlyingQueue: underlyingQueue)
}
func source(for url: URL, headers _: [String: String]) -> CoreAudioStreamSource {
func source(for url: URL, headers: [String: String]) -> CoreAudioStreamSource {
guard !url.isFileURL else {
return provideFileAudioSource(url: url)
}
return provideAudioSource(url: url, headers: [:])
return provideAudioSource(url: url, headers: headers)
}
}
@@ -1,278 +0,0 @@
//
// Created by Dimitrios Chatzieleftheriou on 27/05/2020.
// Copyright © 2020 Decimal. All rights reserved.
//
import AudioToolbox
import Foundation
typealias CoreAudioURLBlock = (URL) -> Void
public class RemoteAudioSource: NSObject, AudioStreamSource {
var inputStream: InputStream?
weak var delegate: AudioStreamSourceDelegate?
var position: Int {
return seekOffset + relativePosition
}
var length: Int {
guard let parsedHeader = parsedHeaderOutput else { return 0 }
return parsedHeader.fileLength
}
private let url: URL
private let networking: NetworkingClient
internal let metadataStreamProccessor: MetadataStreamSource
private var streamRequest: NetworkDataStream?
private var additionalRequestHeaders: [String: String]
private var httpStatusCode: Int
private var httpResponse: HTTPURLResponse?
private var parsedHeaderOutput: HTTPHeaderParserOutput?
private var relativePosition: Int
private var seekOffset: Int
internal var dispatchQueue: DispatchQueue?
init(networking: NetworkingClient,
metadataStreamSource: MetadataStreamSource,
url: URL,
httpHeaders: [String: String])
{
self.networking = networking
metadataStreamProccessor = metadataStreamSource
self.url = url
additionalRequestHeaders = httpHeaders
httpStatusCode = 0
relativePosition = 0
seekOffset = 0
}
convenience init(networking: NetworkingClient, url: URL, httpHeaders: [String: String]) {
let metadataParser = MetadataParser()
let metadataProccessor = MetadataStreamProccessor(parser: metadataParser.eraseToAnyParser())
self.init(networking: networking,
metadataStreamSource: metadataProccessor,
url: url,
httpHeaders: httpHeaders)
}
convenience init(networking: NetworkingClient, url: URL) {
self.init(networking: networking,
url: url,
httpHeaders: [:])
}
func setup(for queue: DispatchQueue) {
dispatchQueue = queue
guard let stream = inputStream else {
return
}
stream.delegate = self
stream.set(onQueue: queue)
return
}
func removeFromQueue() {
guard let stream = inputStream else { return }
stream.delegate = nil
stream.unsetFromQueue()
}
func close() {
if inputStream != nil {
if dispatchQueue != nil {
removeFromQueue()
}
inputStream?.close()
inputStream = nil
}
}
func seek(at offset: Int) {
guard let queue = dispatchQueue else {
return
}
dispatchPrecondition(condition: .onQueue(queue))
close()
relativePosition = 0
seekOffset = offset
if let supportsSeek = parsedHeaderOutput?.supportsSeek,
!supportsSeek, offset != relativePosition
{
return
}
performOpen(seek: seekOffset)
}
func audioFileHint() -> AudioFileTypeID {
return audioFileType(fileExtension: url.pathExtension)
}
func read(into buffer: UnsafeMutablePointer<UInt8>, size: Int) -> Int {
return performRead(into: buffer, size: size)
}
// MARK: Private
private func performRead(into buffer: UnsafeMutablePointer<UInt8>, size: Int) -> Int {
guard size != 0 else { return 0 }
guard let stream = inputStream else { return 0 }
var read: Int = 0
// Metadata parsing
// if metadataStreamProccessor.canProccessMetadata {
// read = metadataStreamProccessor.proccessFromRead(into: buffer, size: size, using: stream) { [weak self] position in
// self?.relativePosition += position
// }
// } else {
read = stream.read(buffer, maxLength: size)
// }
guard read > 0 else { return read }
relativePosition += read
return read
}
private func performOpen(seek seekOffset: Int) {
let urlRequest = buildUrlRequest(with: url, seekIfNeeded: seekOffset)
let streamRequest = networking.stream(request: urlRequest)
.responseStream(on: .global(qos: .default)) { [weak self] event in
switch event {
case let .stream(result):
switch result {
case let .success(response):
self?.httpResponse = response.response
default:
break
}
case let .complete(completion):
print(completion)
}
}
self.streamRequest = streamRequest
inputStream = streamRequest.asInputStream()
guard let inputStream = inputStream else {
errorOccured()
return
}
inputStream.setProperty(StreamNetworkServiceTypeValue.background, forKey: .networkServiceType)
if let scheme = url.scheme, scheme == "https" {
inputStream.setProperty(StreamSocketSecurityLevel.negotiatedSSL, forKey: .socketSecurityLevelKey)
let sslSettings: [String: Any] = [kCFStreamSSLValidatesCertificateChain as String: false]
inputStream.setProperty(sslSettings, forKey: kCFStreamPropertySSLSettings as Stream.PropertyKey)
}
performSoftSetup()
httpStatusCode = 0
inputStream.open()
}
private func parseHeader(response: HTTPURLResponse?) -> Bool {
guard let response = response else { return false }
httpStatusCode = response.statusCode
// parse the header response
let parser = HTTPHeaderParser()
parsedHeaderOutput = parser.parse(input: response)
// check to see if we have metadata to proccess
if let metadataStep = parsedHeaderOutput?.metadataStep {
metadataStreamProccessor.metadataAvailable(step: metadataStep)
}
// check for error
if httpStatusCode == 416 { // range not satisfied error
if length >= 0 { seekOffset = length }
endOfFileOccurred()
return false
} else if httpStatusCode >= 300 {
errorOccured()
return false
}
return true
}
private func performSoftSetup() {
guard let queue = dispatchQueue, let inputStream = inputStream else { return }
inputStream.delegate = self
inputStream.set(onQueue: queue)
}
private func buildUrlRequest(with _: URL, seekIfNeeded seekOffset: Int) -> URLRequest {
var urlRequest = URLRequest(url: url)
urlRequest.timeoutInterval = 30
urlRequest.networkServiceType = .avStreaming
for header in additionalRequestHeaders {
urlRequest.addValue(header.value, forHTTPHeaderField: header.key)
}
urlRequest.addValue("*/*", forHTTPHeaderField: "Accept")
urlRequest.addValue("1", forHTTPHeaderField: "Icy-Metadata")
if let supportsSeek = parsedHeaderOutput?.supportsSeek, supportsSeek, seekOffset > 0 {
urlRequest.addValue("bytes=\(seekOffset)", forHTTPHeaderField: "Range")
}
return urlRequest
}
}
// MARK: StreamEventsSource
extension RemoteAudioSource: StreamEventsSource {
func openCompleted() {
print("open completed")
}
func dataAvailable() {
guard inputStream != nil else { return }
if httpStatusCode == 0 {
guard parseHeader(response: httpResponse) else { return }
if hasBytesAvailable {
delegate?.dataAvailable(source: self)
}
} else {
delegate?.dataAvailable(source: self)
}
}
func endOfFileOccurred() {
delegate?.endOfFileOccured(source: self)
}
func errorOccured() {
delegate?.errorOccured(source: self)
}
}
// MARK: StreamDelegate
extension RemoteAudioSource: StreamDelegate {
public func stream(_: Stream, handle eventCode: Stream.Event) {
switch eventCode {
case .openCompleted:
openCompleted()
case .hasBytesAvailable:
dataAvailable()
case .endEncountered:
endOfFileOccurred()
case .errorOccurred:
errorOccured()
default: break
}
}
}
@@ -41,6 +41,10 @@ final class FileAudioSource: NSObject, CoreAudioStreamSource {
length = 0
}
deinit {
buffer.deallocate()
}
func close() {
guard let inputStream = inputStream else {
return
@@ -29,9 +29,13 @@ public class RemoteAudioSource: AudioStreamSource {
private var parsedHeaderOutput: HTTPHeaderParserOutput?
private var relativePosition: Int
private var seekOffset: Int
private var supportsSeek: Bool
internal var metadataStreamProcessor: MetadataStreamSource
private var shouldTryParsingIcycastHeaders: Bool = false
private let icycastHeadersProcessor: IcycastHeadersProcessor
internal var audioFileHint: AudioFileTypeID {
guard let output = parsedHeaderOutput, output.typeId != 0 else {
return audioFileType(fileExtension: url.pathExtension)
@@ -40,13 +44,13 @@ public class RemoteAudioSource: AudioStreamSource {
}
internal let underlyingQueue: DispatchQueue
internal let streamOperationQueue: OperationQueue
internal let netStatusService: NetStatusProvider
internal var waitingForNetwork = false
internal let retrierTimeout: Retrier
init(networking: NetworkingClient,
metadataStreamSource: MetadataStreamSource,
icycastHeadersProcessor: IcycastHeadersProcessor,
netStatusProvider: NetStatusProvider,
retrier: Retrier,
url: URL,
@@ -59,13 +63,10 @@ public class RemoteAudioSource: AudioStreamSource {
additionalRequestHeaders = httpHeaders
relativePosition = 0
seekOffset = 0
supportsSeek = false
netStatusService = netStatusProvider
self.underlyingQueue = underlyingQueue
streamOperationQueue = OperationQueue()
streamOperationQueue.underlyingQueue = underlyingQueue
streamOperationQueue.maxConcurrentOperationCount = 1
streamOperationQueue.isSuspended = true
streamOperationQueue.name = "remote.audio.source.data.stream.queue"
self.icycastHeadersProcessor = icycastHeadersProcessor
self.underlyingQueue = DispatchQueue(label: "remote.audio.source.queue", target: underlyingQueue)
retrierTimeout = retrier
startNetworkService()
}
@@ -78,9 +79,11 @@ public class RemoteAudioSource: AudioStreamSource {
let metadataParser = MetadataParser()
let metadataProcessor = MetadataStreamProcessor(parser: metadataParser.eraseToAnyParser())
let netStatusProvider = NetStatusService(network: NWPathMonitor())
let icyheaderProcessor = IcycastHeadersProcessor()
let retrierTimout = Retrier(interval: .seconds(1), maxInterval: 5, underlyingQueue: nil)
self.init(networking: networking,
metadataStreamSource: metadataProcessor,
icycastHeadersProcessor: icyheaderProcessor,
netStatusProvider: netStatusProvider,
retrier: retrierTimout,
url: url,
@@ -101,8 +104,6 @@ public class RemoteAudioSource: AudioStreamSource {
func close() {
retrierTimeout.cancel()
netStatusService.stop()
streamOperationQueue.isSuspended = true
streamOperationQueue.cancelAllOperations()
if let streamTask = streamRequest {
streamTask.cancel()
networkingClient.remove(task: streamTask)
@@ -116,26 +117,24 @@ public class RemoteAudioSource: AudioStreamSource {
relativePosition = 0
seekOffset = offset
if let supportsSeek = parsedHeaderOutput?.supportsSeek,
!supportsSeek, offset != relativePosition
{
if !supportsSeek, offset != relativePosition {
return
}
retrierTimeout.cancel()
metadataStreamProcessor.reset()
icycastHeadersProcessor.reset()
shouldTryParsingIcycastHeaders = false
performOpen(seek: offset)
}
func suspend() {
streamRequest?.suspend()
streamOperationQueue.isSuspended = true
}
func resume() {
streamRequest?.resume()
streamOperationQueue.isSuspended = false
}
// MARK: Private
@@ -157,7 +156,9 @@ public class RemoteAudioSource: AudioStreamSource {
let request = networkingClient.stream(request: urlRequest)
.responseStream { [weak self] event in
guard let self = self else { return }
self.handleResponse(event: event)
self.underlyingQueue.sync {
self.handleResponse(event: event)
}
}
.resume()
@@ -171,17 +172,13 @@ public class RemoteAudioSource: AudioStreamSource {
switch event {
case let .response(urlResponse):
parseResponseHeader(response: urlResponse)
streamOperationQueue.isSuspended = false
case let .stream(event):
handleStreamEvent(event: event)
case let .complete(event):
if let error = event.error {
delegate?.errorOccured(source: self, error: error)
} else {
addCompletionOperation { [weak self] in
guard let self = self else { return }
self.delegate?.endOfFileOccured(source: self)
}
delegate?.endOfFileOccured(source: self)
}
}
}
@@ -189,17 +186,24 @@ public class RemoteAudioSource: AudioStreamSource {
private func handleStreamEvent(event: NetworkDataStream.StreamResult) {
switch event {
case let .success(value):
addStreamOperation { [weak self] in
guard let self = self else { return }
if let data = value.data {
if self.metadataStreamProcessor.canProccessMetadata {
let extractedAudioData = self.metadataStreamProcessor.proccessMetadata(data: data)
self.delegate?.dataAvailable(source: self, data: extractedAudioData)
} else {
self.delegate?.dataAvailable(source: self, data: data)
if let audioData = value.data {
if shouldTryParsingIcycastHeaders {
let (header, extractedAudio) = icycastHeadersProcessor.proccess(data: audioData)
if let header = header {
shouldTryParsingIcycastHeaders = false
let parser = IcycastHeaderParser()
parsedHeaderOutput = parser.parse(input: header)
if let metadataStep = parsedHeaderOutput?.metadataStep {
metadataStreamProcessor.metadataAvailable(step: metadataStep)
}
let audioCount = processAudio(data: extractedAudio)
relativePosition += audioCount
return
}
self.relativePosition += data.count
}
let audioCount = processAudio(data: audioData)
relativePosition += audioCount
}
case .failure:
if !netStatusService.isConnected {
@@ -211,20 +215,49 @@ public class RemoteAudioSource: AudioStreamSource {
}
}
/// 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)
return extractedAudioData.count
} else {
self.delegate?.dataAvailable(source: self, data: data)
return data.count
}
}
private func parseResponseHeader(response: HTTPURLResponse?) {
guard let response = response else { return }
let httpStatusCode = response.statusCode
let parser = HTTPHeaderParser()
parsedHeaderOutput = parser.parse(input: response)
if parsedHeaderOutput == nil {
shouldTryParsingIcycastHeaders = true
checkHTTP(statusCode: httpStatusCode)
return
}
if let acceptRanges = parser.value(forHTTPHeaderField: HeaderField.acceptRanges, in: response) {
supportsSeek = acceptRanges != "none"
}
// check to see if we have metadata to proccess
if let metadataStep = parsedHeaderOutput?.metadataStep {
metadataStreamProcessor.metadataAvailable(step: metadataStep)
}
checkHTTP(statusCode: httpStatusCode)
}
private func checkHTTP(statusCode: Int) {
// check for error
if httpStatusCode == 416 { // range not satisfied error
if statusCode == 416 { // range not satisfied error
if length >= 0 { seekOffset = length }
delegate?.endOfFileOccured(source: self)
} else if httpStatusCode >= 300 {
} else if statusCode >= 300 {
delegate?.errorOccured(source: self, error: NetworkError.serverError)
}
}
@@ -233,7 +266,7 @@ public class RemoteAudioSource: AudioStreamSource {
var urlRequest = URLRequest(url: url)
urlRequest.networkServiceType = .avStreaming
urlRequest.cachePolicy = .reloadIgnoringLocalCacheData
urlRequest.timeoutInterval = 60
urlRequest.timeoutInterval = 240
for header in additionalRequestHeaders {
urlRequest.addValue(header.value, forHTTPHeaderField: header.key)
@@ -242,7 +275,7 @@ public class RemoteAudioSource: AudioStreamSource {
urlRequest.addValue("1", forHTTPHeaderField: "Icy-MetaData")
urlRequest.addValue("identity", forHTTPHeaderField: "Accept-Encoding")
if let supportsSeek = parsedHeaderOutput?.supportsSeek, supportsSeek, seekOffset > 0 {
if supportsSeek && seekOffset > 0 {
urlRequest.addValue("bytes=\(seekOffset)-", forHTTPHeaderField: "Range")
}
return urlRequest
@@ -254,25 +287,6 @@ public class RemoteAudioSource: AudioStreamSource {
self.seek(at: self.position)
}
}
// MARK: - Network Stream Operation Queue
/// Schedules the given block on the stream operation queue
///
/// - Parameter block: A closure to be executed
private func addStreamOperation(_ block: @escaping () -> Void) {
let operation = BlockOperation(block: block)
streamOperationQueue.addOperation(operation)
}
/// Schedules the given block on the stream operation queue as a completion
///
/// - Parameter block: A closure to be executed
private func addCompletionOperation(_ block: @escaping () -> Void) {
let operation = BlockOperation(block: block)
operation.queuePriority = .veryLow
streamOperationQueue.addOperation(operation)
}
}
extension RemoteAudioSource: MetadataStreamSourceDelegate {
@@ -6,7 +6,7 @@
import AVFoundation
import CoreAudio
public final class AudioPlayer {
open class AudioPlayer {
public weak var delegate: AudioPlayerDelegate?
public var muted: Bool {
@@ -70,7 +70,14 @@ public final class AudioPlayer {
playerContext.entriesLock.lock()
let playingEntry = playerContext.audioPlayingEntry
playerContext.entriesLock.unlock()
guard let entry = playingEntry, !entry.seekRequest.requested else { return 0 }
guard let entry = playingEntry else { return 0 }
entry.seekRequest.lock.lock()
let seekRequested = entry.seekRequest.requested
let seekTime = entry.seekRequest.time
entry.seekRequest.lock.unlock()
if seekRequested {
return seekTime
}
return entry.progress
}
@@ -79,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)!
@@ -88,28 +108,25 @@ 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 underlyingQueue = DispatchQueue(label: "streaming.core.queue", qos: .userInitiated, attributes: .concurrent)
private let serializationQueue: DispatchQueue
private let sourceQueue: DispatchQueue
private let entryProvider: AudioEntryProviding
@@ -123,7 +140,8 @@ public final class AudioPlayer {
playerContext = AudioPlayerContext()
entriesQueue = PlayerQueueEntries()
sourceQueue = DispatchQueue(label: "source.queue", qos: .userInitiated, target: underlyingQueue)
serializationQueue = DispatchQueue(label: "streaming.core.queue", qos: .userInitiated)
sourceQueue = DispatchQueue(label: "source.queue", qos: .userInitiated)
audioReadSource = DispatchTimerSource(interval: .milliseconds(200), queue: sourceQueue)
entryProvider = AudioEntryProvider(networkingClient: NetworkingClient(),
@@ -134,6 +152,8 @@ public final class AudioPlayer {
rendererContext: rendererContext,
outputAudioFormat: outputAudioFormat.basicStreamDescription)
frameFilterProcessor = FrameFilterProcessor(mixerNode: audioEngine.mainMixerNode)
playerRenderProcessor = AudioPlayerRenderProcessor(playerContext: playerContext,
rendererContext: rendererContext,
outputAudioFormat: outputAudioFormat.basicStreamDescription)
@@ -144,7 +164,6 @@ public final class AudioPlayer {
}
deinit {
// todo more stuff to release...
playerContext.audioPlayingEntry?.close()
clearQueue()
stopReadProccessFromSource()
@@ -167,18 +186,21 @@ public final class AudioPlayer {
public func play(url: URL, headers: [String: String]) {
let audioEntry = entryProvider.provideAudioEntry(url: url, headers: headers)
audioEntry.delegate = self
clearQueue()
entriesQueue.enqueue(item: audioEntry, type: .upcoming)
playerContext.setInternalState(to: .pendingNext)
checkRenderWaitingAndNotifyIfNeeded()
sourceQueue.async { [weak self] in
guard let self = self else { return }
serializationQueue.sync {
clearQueue()
entriesQueue.enqueue(item: audioEntry, type: .upcoming)
playerContext.setInternalState(to: .pendingNext)
do {
try self.startEngineIfNeeded()
} catch {
self.raiseUnxpected(error: .audioSystemError(.engineFailure))
}
}
sourceQueue.async { [weak self] in
guard let self = self else { return }
self.processSource()
self.startReadProcessFromSourceIfNeeded()
}
@@ -186,19 +208,47 @@ public final class AudioPlayer {
/// Queues the specified URL
///
/// - Parameter url: A `URL` specifying the audio context to be played.
/// - Parameter url: A `URL` specifying the audio content to be played.
public func queue(url: URL) {
queue(url: url, headers: [:])
}
/// Queues the specified URLs
///
/// - Parameter url: A `URL` specifying the audio content to be played.
public func queue(urls: [URL]) {
queue(urls: urls, headers: [:])
}
/// Queues the specified URL
///
/// - Parameter url: A `URL` specifying the audio context to be played.
/// - Parameter url: A `URL` specifying the audio content to be played.
/// - parameter headers: A `Dictionary` specifying any additional headers to be pass to the network request.
public func queue(url: URL, headers: [String: String]) {
let audioEntry = entryProvider.provideAudioEntry(url: url, headers: headers)
audioEntry.delegate = self
entriesQueue.enqueue(item: audioEntry, type: .upcoming)
serializationQueue.sync {
let audioEntry = entryProvider.provideAudioEntry(url: url, headers: headers)
audioEntry.delegate = self
entriesQueue.enqueue(item: audioEntry, type: .upcoming)
}
checkRenderWaitingAndNotifyIfNeeded()
sourceQueue.async { [weak self] in
self?.processSource()
}
}
/// Queues the specified URLs
///
/// - Parameter url: A array of `URL`s specifying the audio content to be played.
/// - parameter headers: A `Dictionary` specifying any additional headers to be pass to the network request.
public func queue(urls: [URL], headers: [String: String]) {
serializationQueue.sync {
for url in urls {
let audioEntry = entryProvider.provideAudioEntry(url: url, headers: headers)
audioEntry.delegate = self
entriesQueue.enqueue(item: audioEntry, type: .upcoming)
}
}
checkRenderWaitingAndNotifyIfNeeded()
sourceQueue.async { [weak self] in
self?.processSource()
}
@@ -208,8 +258,11 @@ public final class AudioPlayer {
public func stop() {
guard playerContext.internalState != .stopped else { return }
stopEngine(reason: .userAction)
stopReadProccessFromSource()
serializationQueue.sync {
stopEngine(reason: .userAction)
}
checkRenderWaitingAndNotifyIfNeeded()
sourceQueue.async { [weak self] in
guard let self = self else { return }
self.playerContext.audioReadingEntry?.delegate = nil
@@ -226,7 +279,6 @@ public final class AudioPlayer {
self.processSource()
}
checkRenderWaitingAndNotifyIfNeeded()
}
/// Pauses the audio playback
@@ -234,8 +286,9 @@ public final class AudioPlayer {
if playerContext.internalState != .paused, playerContext.internalState.contains(.running) {
stateBeforePaused = playerContext.internalState
playerContext.setInternalState(to: .paused)
pauseEngine()
serializationQueue.sync {
pauseEngine()
}
stopReadProccessFromSource()
playerContext.audioPlayingEntry?.suspend()
sourceQueue.async { [weak self] in
@@ -248,23 +301,25 @@ public final class AudioPlayer {
public func resume() {
guard playerContext.internalState == .paused else { return }
playerContext.setInternalState(to: stateBeforePaused)
// check if seek time requested and reset buffers
do {
try startEngine()
} catch {
Logger.debug("resuming audio engine failed: %@", category: .generic, args: error.localizedDescription)
}
if let playingEntry = playerContext.audioReadingEntry {
if playingEntry.seekRequest.requested {
rendererContext.resetBuffers()
serializationQueue.sync {
do {
try startEngine()
} catch {
Logger.debug("resuming audio engine failed: %@", category: .generic, args: error.localizedDescription)
}
playingEntry.resume()
if let playingEntry = playerContext.audioReadingEntry {
if playingEntry.seekRequest.requested {
rendererContext.resetBuffers()
}
playingEntry.resume()
}
startPlayer(resetBuffers: false)
}
startPlayer(resetBuffers: false)
startReadProcessFromSourceIfNeeded()
}
/// Seeks the audio to the specified time.
/// - Parameter time: A `Double` value specifing the time of the requested seek in seconds
public func seek(to time: Double) {
guard let playingEntry = playerContext.audioPlayingEntry else {
return
@@ -287,10 +342,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)
@@ -300,6 +361,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
@@ -309,6 +372,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)
@@ -375,9 +440,11 @@ public final class AudioPlayer {
playerRenderProcessor.audioFinishedPlaying = { [weak self] entry in
guard let self = self else { return }
self.sourceQueue.async {
self.serializationQueue.sync {
let nextEntry = self.entriesQueue.dequeue(type: .buffering)
self.processFinishPlaying(entry: entry, with: nextEntry)
}
self.sourceQueue.async {
self.processSource()
}
}
@@ -455,10 +522,6 @@ public final class AudioPlayer {
///
/// - parameter reason: A value of `AudioPlayerStopReason` indicating the reason the engine stopped.
private func stopEngine(reason: AudioPlayerStopReason) {
guard isEngineRunning && player.auAudioUnit.isRunning else {
Logger.debug("already already stopped 🛑", category: .generic)
return
}
audioEngine.stop()
player.auAudioUnit.stopHardware()
rendererContext.resetBuffers()
@@ -472,10 +535,11 @@ public final class AudioPlayer {
/// 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()
// TODO: this might be needed after all...
// audioReadSource.add { [weak self] in
// self?.processSource()
// }
// audioReadSource.activate()
}
/// Stops and removes the handler from the timer, @see `audioReadSource`
@@ -491,11 +555,8 @@ public final class AudioPlayer {
if resetBuffers {
rendererContext.resetBuffers()
}
if !isEngineRunning && !player.auAudioUnit.isRunning {
Logger.debug("trying to start the player when audio engine and player are already running", category: .generic)
return
}
do {
try startEngineIfNeeded()
try player.auAudioUnit.allocateRenderResources()
try player.auAudioUnit.startHardware()
} catch {
@@ -542,7 +603,7 @@ public final class AudioPlayer {
let entry = entriesQueue.dequeue(type: .upcoming)
let shouldStartPlaying = playerContext.audioPlayingEntry == nil
playerContext.setInternalState(to: .waitingForData)
setCurrentReading(entry: entry, startPlaying: shouldStartPlaying, shouldClearQueue: true)
setCurrentReading(entry: entry, startPlaying: shouldStartPlaying, shouldClearQueue: false)
} else if playerContext.audioPlayingEntry == nil {
if playerContext.internalState != .stopped {
stopReadProccessFromSource()
@@ -574,7 +635,8 @@ public final class AudioPlayer {
}
private func proccessSeekTime() {
assert(playerContext.audioReadingEntry === playerContext.audioPlayingEntry, "reading and playing entry must be the same")
assert(playerContext.audioReadingEntry === playerContext.audioPlayingEntry,
"reading and playing entry must be the same")
fileStreamProcessor.processSeek()
}
@@ -662,7 +724,9 @@ public final class AudioPlayer {
playerContext.audioPlayingEntry = nil
playerContext.entriesLock.unlock()
}
processSource()
sourceQueue.async { [weak self] in
self?.processSource()
}
checkRenderWaitingAndNotifyIfNeeded()
}
@@ -191,7 +191,11 @@ final class AudioFileStreamProcessor {
guard AudioFileStreamGetProperty(fileStream, kAudioFileStreamProperty_MagicCookieData, &cookieSize, &cookie) == noErr else {
return
}
guard AudioFileStreamSetProperty(fileStream, kAudioConverterDecompressionMagicCookie, cookieSize, cookie) == noErr else {
guard let converter = audioConverter else {
fileStreamCallback?(.raiseError(.audioSystemError(.fileStreamError(.unknownError))))
return
}
guard AudioConverterSetProperty(converter, kAudioConverterDecompressionMagicCookie, cookieSize, cookie) == noErr else {
fileStreamCallback?(.raiseError(.audioSystemError(.fileStreamError(.unknownError))))
return
}
@@ -382,41 +386,43 @@ final class AudioFileStreamProcessor {
var start = bufferContext.frameStartIndex
var end = bufferContext.end
var framesLeftInBuffer = max(bufferContext.totalFrameCount &- used, 0)
var framesLeftInBuffer = bufferContext.totalFrameCount - used
rendererContext.lock.unlock()
if framesLeftInBuffer == 0 {
rendererContext.lock.lock()
let bufferContext = rendererContext.bufferContext
used = bufferContext.frameUsedCount
start = bufferContext.frameStartIndex
end = bufferContext.end
framesLeftInBuffer = max(bufferContext.totalFrameCount &- used, 0)
rendererContext.lock.unlock()
if framesLeftInBuffer > 0 {
break packetProccess
}
if playerContext.disposedRequested
|| playerContext.internalState == .disposed
|| playerContext.internalState == .pendingNext
|| playerContext.internalState == .stopped
{
return
}
if let playingEntry = playerContext.audioPlayingEntry,
playingEntry.seekRequest.requested, playingEntry.calculatedBitrate() > 0
{
fileStreamCallback?(.proccessSource)
if rendererContext.waiting.value {
rendererContext.packetsSemaphore.signal()
while true {
rendererContext.lock.lock()
let bufferContext = rendererContext.bufferContext
used = bufferContext.frameUsedCount
start = bufferContext.frameStartIndex
end = (bufferContext.frameStartIndex + bufferContext.frameUsedCount) % bufferContext.totalFrameCount
framesLeftInBuffer = bufferContext.totalFrameCount - used
rendererContext.lock.unlock()
if framesLeftInBuffer > 0 {
break
}
if playerContext.disposedRequested
|| playerContext.internalState == .disposed
|| playerContext.internalState == .pendingNext
|| playerContext.internalState == .stopped
{
return
}
return
}
rendererContext.waiting.write { $0 = true }
rendererContext.packetsSemaphore.wait()
rendererContext.waiting.write { $0 = false }
if let playingEntry = playerContext.audioPlayingEntry,
playingEntry.seekRequest.requested, playingEntry.calculatedBitrate() > 0
{
fileStreamCallback?(.proccessSource)
if rendererContext.waiting.value {
rendererContext.packetsSemaphore.signal()
}
return
}
rendererContext.waiting.write { $0 = true }
rendererContext.packetsSemaphore.wait()
rendererContext.waiting.write { $0 = false }
}
}
let localBufferList = AudioBufferList.allocate(maximumBuffers: 1)
@@ -444,7 +450,7 @@ final class AudioFileStreamProcessor {
fillUsedFrames(framesCount: framesAdded)
return
} else if status != 0 {
/// raise undexpected error... codec error
fileStreamCallback?(.raiseError(.codecError))
return
}
@@ -473,7 +479,7 @@ final class AudioFileStreamProcessor {
fillUsedFrames(framesCount: framesAdded)
continue packetProccess
} else if status != 0 {
/// raise undexpected error... codec error
fileStreamCallback?(.raiseError(.codecError))
return
}
} else {
@@ -500,7 +506,7 @@ final class AudioFileStreamProcessor {
fillUsedFrames(framesCount: framesAdded)
continue packetProccess
} else if status != 0 {
/// raise undexpected error... codec error
fileStreamCallback?(.raiseError(.codecError))
return
}
}
@@ -0,0 +1,177 @@
//
// 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 hanlding
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 hanlding
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)
/// Attemps 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 mixerNode: AVAudioMixerNode
private(set) var entries: [FilterEntry] = []
private var hasInstalledTap: Bool = false
init(mixerNode: AVAudioMixerNode) {
self.mixerNode = mixerNode
}
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)
}
}
@@ -0,0 +1,91 @@
//
// IcycastHeadersProcessor.swift
// AudioStreaming
//
// Created by Dimitrios C on 14/02/2021.
// Copyright © 2021 Decimal. All rights reserved.
//
import Foundation
/// ICY is built on HTTP some old servers might still send headers in the stream.
/// From a server point of view, this should be considered deprecated and should not be used as it might break HTML5 compatibility.
/// Although there are some servers still using this, this class will extract those headers from the stream
///
/// The format of the headers is as follows:
/// ```
/// =================================================================
/// [ ICY 200 OK ]
/// [ icy-mentaint: the number of bytes between 2 metadata chunks ]
/// [ icy-br: send the bitrate in kilobits per second ]
/// [ icy-genre: sends the genre ]
/// [ icy-name: sends the stream's name ]
/// [ icy-url: is the URL of the radio station ]
/// [ icy-pub: can be 1 or 0 to tell if it is listed or not ]
/// =================================================================
/// ```
final class IcycastHeadersProcessor {
private var icecastHeaders = Data(capacity: 1024)
private var searchComplete = false
private var iceHeaderAvailable = false
func reset() {
icecastHeaders = Data(capacity: 1024)
searchComplete = false
iceHeaderAvailable = false
}
@inline(__always)
func proccess(data: Data) -> (Data?, Data) {
let stopProccessingCheckOne: [UInt8] = Array("\n\n".utf8)
let stopProccessingCheckTwo: [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
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
// 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 {
iceHeaderAvailable = true
searchComplete = true
break
}
}
if icecastHeaders.count >= stopProccessingCheckTwo.count {
if icecastHeaders.suffix(stopProccessingCheckTwo.count) == stopProccessingCheckTwo {
iceHeaderAvailable = true
searchComplete = true
break
}
}
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 {
iceHeaderAvailable = false
searchComplete = true
}
}
bytesRead += 1
}
if !iceHeaderAvailable {
return (nil, data)
}
let extractedAudio = data[icecastHeaders.count...]
iceHeaderAvailable = false
return (icecastHeaders, extractedAudio)
}
}
}
@@ -18,21 +18,28 @@ enum IcyHeaderField {
}
struct HTTPHeaderParserOutput {
let supportsSeek: Bool
let fileLength: Int
let typeId: AudioFileTypeID
// Metadata Support
let metadataStep: Int
}
struct HTTPHeaderParser: Parser {
protocol HTTPHeaderParsing: Parser {
/// Returns the value for the given field of the headers in the given `HTTPURLResponse`
///
/// - Parameters:
/// - field: The header field to be searched
/// - response: The `HTTPURLResponse` for the header
/// - Returns: A `String` if the field exists in the headers otherwise `nil`
func value(forHTTPHeaderField field: String, in response: HTTPURLResponse) -> String?
}
struct HTTPHeaderParser: HTTPHeaderParsing {
typealias Input = HTTPURLResponse
typealias Output = HTTPHeaderParserOutput?
func parse(input: HTTPURLResponse) -> HTTPHeaderParserOutput? {
guard let headers = input.allHeaderFields as? [String: String], !headers.isEmpty else { return nil }
let supportsSeek = headers[HeaderField.acceptRanges] != "none"
guard let headers = input.allHeaderFields as? [String: String], headers.count > 2 else { return nil }
var typeId: UInt32 = 0
if let contentType = input.mimeType {
@@ -41,13 +48,12 @@ struct HTTPHeaderParser: Parser {
var fileLength: Int = 0
if input.statusCode == 200 {
if let contentLength = headers[HeaderField.contentLength],
let length = Int(contentLength)
{
let contentLength = value(forHTTPHeaderField: HeaderField.contentLength, in: input)
if let contentLength = contentLength, let length = Int(contentLength) {
fileLength = length
}
} else if input.statusCode == 206 {
if let contentLength = headers[HeaderField.contentRange] {
if let contentLength = value(forHTTPHeaderField: HeaderField.contentRange, in: input) {
let components = contentLength.components(separatedBy: "/")
if components.count == 2 {
if let last = components.last, let length = Int(last) {
@@ -58,15 +64,38 @@ struct HTTPHeaderParser: Parser {
}
var metadataStep = 0
if let icyMetaint = headers[IcyHeaderField.icyMentaint],
if let icyMetaint = value(forHTTPHeaderField: IcyHeaderField.icyMentaint, in: input),
let intValue = Int(icyMetaint)
{
metadataStep = intValue
}
return HTTPHeaderParserOutput(supportsSeek: supportsSeek,
fileLength: fileLength,
return HTTPHeaderParserOutput(fileLength: fileLength,
typeId: typeId,
metadataStep: metadataStep)
}
}
extension Parser where Self: HTTPHeaderParsing {
func value(forHTTPHeaderField field: String, in response: HTTPURLResponse) -> String? {
if #available(iOS 13.0, *) {
return response.value(forHTTPHeaderField: field)
} else {
if let fields = response.allHeaderFields as? [String: String] {
return valueForCaseInsensitiveKey(field, fields: fields)
} else {
return nil
}
}
}
private func valueForCaseInsensitiveKey(_ key: String, fields: [String: String]) -> String? {
let keyToBeFound = key.lowercased()
for (key, value) in fields {
if key.lowercased() == keyToBeFound {
return value
}
}
return nil
}
}
@@ -0,0 +1,34 @@
//
// IcycastHeaderParser.swift
// AudioStreaming
//
// Created by Dimitrios C on 14/02/2021.
// Copyright © 2021 Decimal. All rights reserved.
//
import Foundation
struct IcycastHeaderParser: Parser {
func parse(input: Data) -> HTTPHeaderParserOutput? {
guard let icecastValue = String(data: input, encoding: .utf8) else {
return nil
}
let headers = icecastValue.components(separatedBy: CharacterSet(charactersIn: "\r\n"))
var result = [String: String]()
for header in headers where !header.isEmpty {
let values = header.split(separator: ":", maxSplits: 1, omittingEmptySubsequences: true)
if let key = values.first, let value = values.last {
result[String(key)] = String(value)
}
}
let metadataStep = Int(result[IcyHeaderField.icyMentaint] ?? "") ?? 0
let contentType = result[HeaderField.contentType.lowercased()] ?? "audio/mpeg"
let typeId = audioFileType(mimeType: contentType)
return HTTPHeaderParserOutput(fileLength: 0,
typeId: typeId,
metadataStep: metadataStep)
}
}
@@ -32,8 +32,7 @@ class HTTPHeaderParserTests: XCTestCase {
// When
let headers: [String: String] =
[HeaderField.acceptRanges: "range",
HeaderField.contentLength: "1000",
[HeaderField.contentLength: "1000",
HeaderField.contentType: "audio/mp3",
IcyHeaderField.icyMentaint: "16000"]
let httpURLResponse = HTTPURLResponse(url: URL(string: "www.google.com")!,
@@ -46,23 +45,21 @@ class HTTPHeaderParserTests: XCTestCase {
// Then
XCTAssertNotNil(output)
XCTAssertEqual(output!.fileLength, 1000)
XCTAssertEqual(output!.supportsSeek, true)
XCTAssertEqual(output!.typeId, kAudioFileMP3Type)
XCTAssertEqual(output!.metadataStep, 16000)
}
func testReturnCorrectValuesOnRequestThatSupportsSeekRanges() throws {
func testReturnCorectValuesOnCaseInsensitiveHeaderFiels() throws {
// Given
let parser = HTTPHeaderParser()
// When
let headers: [String: String] =
[HeaderField.acceptRanges: "range",
HeaderField.contentLength: "1000",
HeaderField.contentType: "audio/mp3",
HeaderField.contentRange: "100/1000"]
[HeaderField.contentLength.lowercased(): "1000",
HeaderField.contentType.lowercased(): "audio/mp3",
IcyHeaderField.icyMentaint.lowercased(): "16000"]
let httpURLResponse = HTTPURLResponse(url: URL(string: "www.google.com")!,
statusCode: 206,
statusCode: 200,
httpVersion: "",
headerFields: headers)
@@ -71,7 +68,7 @@ class HTTPHeaderParserTests: XCTestCase {
// Then
XCTAssertNotNil(output)
XCTAssertEqual(output!.fileLength, 1000)
XCTAssertEqual(output!.supportsSeek, true)
XCTAssertEqual(output!.typeId, kAudioFileMP3Type)
XCTAssertEqual(output!.metadataStep, 16000)
}
}
+203
View File
@@ -0,0 +1,203 @@
![AudioStreaming CI](https://github.com/dimitris-c/AudioStreaming/workflows/AudioStreaming%20CI/badge.svg)
# 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/avfaudio/audio_engine/audio_units).
#### Supported audio
- Online streaming (Shoutcast/ICY streams) with metadata parsing
- AIFF, AIFC, WAVE, CAF, NeXT, ADTS, MPEG Audio Layer 3, AAC audio formats
- M4A (_Optimized files only_)
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)
# Requirements
- iOS 12.0+
- Swift 5.x
# Using AudioStreaming
### Playing an audio source over HTTP
Note: You need to keep a reference to the `AudioPlayer` object
```
let player = AudioPlayer()
player.play(url: URL(string: "https://your-remote-url/to/audio-file.mp3")!)
```
### Playing a local file
```
let player = AudioPlayer()
player.play(url: URL(fileURLWithPath: "your-local-path/to/audio-file.mp3")!)
```
### Queueing audio files
```
let player = AudioPlayer()
// when you want to queue a single url
player.queue(url: URL(string: "https://your-remote-url/to/audio-file.mp3")!)
// or if you want to queue a list of urls use
player.queue(urls: [
URL(fileURLWithPath: "your-local-path/to/audio-file.mp3")!,
URL(fileURLWithPath: "your-local-path/to/audio-file-2.mp3")!
])
```
### Adjusting playback properties
```
let player = AudioPlayer()
player.play(url: URL(fileURLWithPath: "your-local-path/to/audio-file.mp3")!)
// adjust the playback rate
player.rate = 2.0
// adjusting the volume
player.volume = 0.5
// mute/unmute the audio
player.mute = true
// pause the playback
player.pause()
// resume the playback
player.resume()
// stop the playback
player.stop()
// seeking to to a time (in seconds)
player.seek(to: 10)
```
### Audio playback properties
```
let player = AudioPlayer()
player.play(url: URL(fileURLWithPath: "your-local-path/to/audio-file.mp3")!)
// To get the audio file duration
let duration = player.duration
// To get the progress of the player
let progress = player.progress
// To get the state of the player, for possible values view the `AudioPlayerState` enum
let state = player.state
// To get the stop reason of the player, for possible values view the `AudioPlayerStopReason` enum
let state = player.stopReason
```
### AudioPlayer Delegate
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
```
let player = AudioPlayer()
player.play(url: URL(fileURLWithPath: "your-local-path/to/audio-file.mp3")!)
player.delegate = self // an object conforming to AudioPlayerDelegate
// observing the audio player state, provides the new and previous state of the player.
func audioPlayerStateChanged(player: AudioPlayer, with newState: AudioPlayerState, previous: AudioPlayerState) {}
```
### 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
```
let reverbNode = AVAudioUnitReverb()
reverbNode.wetDryMix = 50
let player = AudioPlayer()
// attach a single node
player.attach(node: reverbNode)
// detach a single node
player.detach(node: reverbNode)
// detach all custom added nodes
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 fliters 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`.
```
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 fitler 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 documention.
# Installation
### Cocoapods
[Cocoapods](https://cocoapods.org/) is a dependency manager for Cocoa projects. You can install it with the following command:
```
$ gem install cocoapods
```
To intergrate AudioStreaming with [Cocoapods](https://cocoapods.org/) to your Xcode project add the following to your `Podfile`:
```
pod 'AudioStreaming'
```
### Swift Package Manager
On Xcode 11.0+ you can add a new dependency by going to **File / Swift Packages / Add Package Dependency...**
and enter package repository URL https://github.com/dimitris-c/AudioStreaming.git, then follow the instructions.
### Carthage
[Carthage](https://github.com/Carthage/Carthage) is a decentralized dependency manager that builds your dependencies and provides you with frameworks.
You can install Carthage with Homebrew using the following command:
```
$ brew update
$ brew install carthage
```
To integrate AudioStreaming into your Xcode project using Carthage, add the following to your `Cartfile`:
```
github "dimitris-c/AudioStreaming"
```
Visit [installation instructions](https://github.com/Carthage/Carthage#adding-frameworks-to-an-application) on Carthage to install the framework
# Licence
AudioStreaming is available under the MIT license. See the LICENSE file for more info.
# Attributions
This librabry 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)