Compare commits

...

66 Commits

Author SHA1 Message Date
Dimitris C. 2f3c7912e8 bump version number 2024-12-30 13:59:27 +02:00
Patrick McConnell 548d599628 Add tvOS Support (#102)
* add tvOS support

* update tvOS requirement to v16
2024-12-30 13:56:28 +02:00
Dimitris C. 17f532556a Delete AudioStreaming.podspec 2024-09-22 22:12:37 +03:00
Dimitris C. 00bd6cd81b Update README.md 2024-09-22 21:55:01 +03:00
dimitris-c ce2b88ac03 version bump 2024-09-19 14:43:02 +03:00
Jackson Harper 624e575980 Allow playing custom streams (#94)
* Allow playing custom streams

This lets users implement custom streams that can be played. For
example, I have a websocket interface that I fetch data from. I
can wrap that stream into a CoreAudioStreamSource and add that to
the player.

* Add example of using a custom stream

* Add ability to queue custom streams
2024-09-19 14:35:40 +03:00
dimitris-c b89d3d953f version bump 2024-08-25 17:43:56 +03:00
dimitris-c 4951b54ede check and assign magic cookie on ReadyToProducePackets 2024-08-25 17:41:59 +03:00
dimitris-c 2337cd3844 bump version number 2024-07-28 17:01:20 +03:00
Dimitris C. f8f836125d Fixes audio cutoff on flac files (#89) 2024-07-28 16:59:00 +03:00
dimitris-c d24bca48a2 Add usage of OSAllocatedUnfairLock for macOS 13+ 2024-07-11 14:24:20 +03:00
dimitris-c 1916a0628a Use OSAllocatedUnfairLock on iOS 16+ 2024-07-11 14:18:27 +03:00
Dimitris C 579fd26846 Update README.md 2024-05-23 16:49:21 +03:00
dimitris-c ed8352ff68 version bump 1.2.3 2024-05-17 22:53:43 +03:00
Dimitris C 933a22bb72 Adds macOS support, updates example project (#81)
Also fixes a thread safety issue
2024-05-17 22:53:16 +03:00
dimitris-c 47d3e5bea5 version bump 1.2.2 2024-05-16 13:05:45 +03:00
Dimitris C fde33feeef feature(Example): Update example project (#80)
* Adds initial new example project

* example project progress

* progress

* progress

* progress

* update comment on example project

* adds animations to eq view

* progress

* refactor

* progress

* Updates example, fixes noise output on mute

* Update swift.yml

* Update swift.yml

* fix tests

* Update README.md
2024-05-16 13:03:21 +03:00
Dimitris C 94bc48c7f1 Update swift.yml 2024-05-16 12:39:25 +03:00
Dimitris C de2c04cdaf Update README.md 2024-05-15 10:13:58 +03:00
dimitris-c 738397c637 version bump 1.2.1 2024-05-15 00:33:47 +03:00
Dimitris C 1f70860473 feature(Mp4): Adds support for non-optimised mp4 audio for local files (#79)
* adds handling for non-optimized m4a for local files

* seek improvements

* small improvement in render processor

* improvements on seeking on local files

* improvements on seeking

* nit
2024-05-15 00:30:01 +03:00
Dimitris C a8865bb4d8 Mp4 Restructure account for large mdat box size (#78) 2024-05-09 14:45:02 +03:00
Dimitris C dd2e790ca6 Update README.md 2024-04-01 17:51:23 +03:00
Dimitris C c5bdbdd692 update readme.md (#71) 2024-04-01 16:46:23 +03:00
dimitris-c ffa5bf8f2c version bump to 1.2.0 2024-04-01 16:42:04 +03:00
dimitris-c 9d8973e971 update gitignore 2024-04-01 16:06:42 +03:00
Dimitris C cb72197f8e feature(Mp4): Support for non-optimised mp4 (#67)
* initial work for supporting non-optimised mp4

* Update AppCoordinator.swift

* some refactor and fixed seek for a restructured mp4

* nit

* nit

* nit

* runs swiftlint

* improvements

* improvements

* handles case where we the stream is not seekable for an mp4 file

* better check for mp4, seekable and moov atom

* nit

* fix an issue with seek

* some refactoring
2024-04-01 16:02:51 +03:00
Dimitris C 374da9bc22 removes measure file 2024-03-28 15:38:08 +02:00
Dimitris C 38d0bdb5d9 fix a glitch sound on pause and play (#69) 2024-03-10 19:23:58 +02:00
Dimitris C decb12641d fix incorrect stopReason on finish delegate method (#66) 2024-02-29 14:49:51 +02:00
Dimitris C 4e485f924a Fixes an issue with seek on FileAudioSource (#65) 2024-02-27 19:03:57 +02:00
junyaninflection 7e770197e6 version bump to 1.1.0 (#59) 2023-08-15 17:22:17 +03:00
junyaninflection 6f552e60c0 Lazy initialize singleton mixer node (#58) 2023-08-14 19:22:24 +03:00
dimitris-c 0f2a1f7b8a Version Bump
Signed-off-by: dimitris-c <d.chatzieleftheriou@gmail.com>
2022-09-01 18:16:24 +03:00
dimitris-c 0c2c7ba685 Fixes an issue with seek functionality
Lowers the sourceQueue qos to default

Signed-off-by: dimitris-c <d.chatzieleftheriou@gmail.com>
2022-09-01 17:46:13 +03:00
dimitris-c 50174a7f4a Fix wrong next entry on audioPlayerDidStartPlaying
Signed-off-by: dimitris-c <d.chatzieleftheriou@gmail.com>
2022-08-30 12:59:02 +03:00
dimitris-c cc82e79d50 Updates UnfairLock
Signed-off-by: dimitris-c <d.chatzieleftheriou@gmail.com>
2022-08-30 01:47:26 +03:00
dimitris-c 578bbcdbe8 Version bump
Signed-off-by: dimitris-c <d.chatzieleftheriou@gmail.com>
2022-02-28 17:46:41 +02:00
dimitris-c 56c6483fc0 Some nits
Signed-off-by: dimitris-c <d.chatzieleftheriou@gmail.com>
2022-02-28 17:46:28 +02:00
Dimitris C fca0930b01 Fixes remote audio source network issues (#35) 2022-02-27 00:05:15 +02:00
Dimitrios C 2f08ea4131 Fixes AudioExample from not compiling 2022-02-21 23:08:31 +02:00
Dimitris C 5ac825ed7a Update swift.yml 2022-02-21 22:59:05 +02:00
Dimitris C 4856a30bb6 Update swift.yml 2022-02-21 22:56:57 +02:00
Dimitris Apostolou f15f0f6eae Fix typos (#34) 2022-02-20 22:40:50 +02:00
Dimitris C da19dd9488 Update swift.yml 2022-02-20 22:30:45 +02:00
Dimitris C e57c6aabe5 Update README.md 2021-12-16 17:46:29 +02:00
Dimitris C f6f9554b25 Update swift.yml 2021-12-07 12:44:03 +02:00
Dimitris C e9bace4447 Update swift.yml 2021-12-07 12:42:33 +02:00
Dimitris C 40b9d03ea8 Update swift.yml 2021-12-07 12:38:06 +02:00
Dimitris C 3247b54c86 Update swift.yml 2021-12-07 12:36:04 +02:00
Dimitris C d78de29daf Update swift.yml 2021-12-06 20:03:09 +02:00
dimitris-c 0758c14909 Bumping version 2021-12-06 20:01:12 +02:00
Dimitris C 03c6a7692c Fixes an issue where the memory is not released after paused audio (#33) 2021-12-06 19:58:50 +02:00
dimitris-c 02a3606185 Fixes unit tests 2021-10-05 18:55:34 +03:00
Dimitris C 7e45a7b2f5 Removes unneeded DispatchTimerSource (#32)
Correct Spelling issues
Fix an issue in RemoteAudioSource when icycast headers are not available
2021-10-05 18:32:55 +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
106 changed files with 4762 additions and 2750 deletions
+2 -2
View File
@@ -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 15 Pro"]
steps:
- uses: actions/checkout@v2
- name: iOS - ${{ matrix.destination }}
+3
View File
@@ -10,6 +10,7 @@ xcuserdata/
*.xccheckout
## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4)
**/.DS_Store
build/
DerivedData/
*.moved-aside
@@ -88,3 +89,5 @@ fastlane/test_output
# https://github.com/johnno1962/injectionforxcode
iOSInjectionProject/
/.DS_Store
/AudioExample/AudioExample/.DS_Store
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>classNames</key>
<dict>
<key>AtomicTests</key>
<dict>
<key>testProtectedValuesAreAccessedSafely()</key>
<dict>
<key>com.apple.XCTPerformanceMetric_WallClockTime</key>
<dict>
<key>baselineAverage</key>
<real>0.029769</real>
<key>baselineIntegrationDisplayName</key>
<string>Local Baseline</string>
</dict>
</dict>
</dict>
</dict>
</dict>
</plist>
@@ -0,0 +1,40 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>runDestinationsByUUID</key>
<dict>
<key>E340D9FA-D19A-49BB-82AA-9D0E236D4288</key>
<dict>
<key>localComputer</key>
<dict>
<key>busSpeedInMHz</key>
<integer>0</integer>
<key>cpuCount</key>
<integer>1</integer>
<key>cpuKind</key>
<string>Apple M1 Pro</string>
<key>cpuSpeedInMHz</key>
<integer>0</integer>
<key>logicalCPUCoresPerPackage</key>
<integer>10</integer>
<key>modelCode</key>
<string>MacBookPro18,1</string>
<key>physicalCPUCoresPerPackage</key>
<integer>10</integer>
<key>platformIdentifier</key>
<string>com.apple.platform.macosx</string>
</dict>
<key>targetArchitecture</key>
<string>arm64</string>
<key>targetDevice</key>
<dict>
<key>modelCode</key>
<string>iPhone16,1</string>
<key>platformIdentifier</key>
<string>com.apple.platform.iphonesimulator</string>
</dict>
</dict>
</dict>
</dict>
</plist>
@@ -1,432 +0,0 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 50;
objects = {
/* Begin PBXBuildFile section */
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 */; };
B5220954256188590086FB3A /* EqualizerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5220953256188590086FB3A /* EqualizerViewModel.swift */; };
B524D59C2560176C00F5A88F /* bensound-jazzyfrenchy.mp3 in Resources */ = {isa = PBXBuildFile; fileRef = B524D59B2560176C00F5A88F /* bensound-jazzyfrenchy.mp3 */; };
B524D5A12560302100F5A88F /* PlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B524D5A02560302100F5A88F /* PlayerViewController.swift */; };
B524D5A32560303000F5A88F /* PlayerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B524D5A22560303000F5A88F /* PlayerViewModel.swift */; };
B524D5A52560303D00F5A88F /* PlayerControlsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B524D5A42560303D00F5A88F /* PlayerControlsViewController.swift */; };
B524D5A72560305800F5A88F /* PlayerControlsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B524D5A62560305800F5A88F /* PlayerControlsViewModel.swift */; };
B524D5A9256031DE00F5A88F /* AppCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = B524D5A8256031DE00F5A88F /* AppCoordinator.swift */; };
B524D5AD25604E4B00F5A88F /* PlaylistItemsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = B524D5AC25604E4B00F5A88F /* PlaylistItemsService.swift */; };
B524D5AF25604ED900F5A88F /* AudioContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = B524D5AE25604ED900F5A88F /* AudioContent.swift */; };
B580CB0E2561B912006D7DD8 /* EqualizerService.swift in Sources */ = {isa = PBXBuildFile; fileRef = B580CB0D2561B912006D7DD8 /* EqualizerService.swift */; };
B5AEDBD52475274C007D8101 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5AEDBD42475274C007D8101 /* AppDelegate.swift */; };
B5AEDBDE2475274D007D8101 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B5AEDBDD2475274D007D8101 /* Assets.xcassets */; };
B5AEDBE12475274D007D8101 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = B5AEDBDF2475274D007D8101 /* LaunchScreen.storyboard */; };
B5F883C624780A3D00D277C1 /* AudioStreaming.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B5F883C524780A3C00D277C1 /* AudioStreaming.framework */; };
B5F883C724780A3D00D277C1 /* AudioStreaming.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = B5F883C524780A3C00D277C1 /* AudioStreaming.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
/* End PBXBuildFile section */
/* Begin PBXCopyFilesBuildPhase section */
B5F883C824780A3D00D277C1 /* Embed Frameworks */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 10;
files = (
B5F883C724780A3D00D277C1 /* AudioStreaming.framework in Embed Frameworks */,
);
name = "Embed Frameworks";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
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>"; };
B5220953256188590086FB3A /* EqualizerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EqualizerViewModel.swift; sourceTree = "<group>"; };
B524D59B2560176C00F5A88F /* bensound-jazzyfrenchy.mp3 */ = {isa = PBXFileReference; lastKnownFileType = audio.mp3; path = "bensound-jazzyfrenchy.mp3"; sourceTree = "<group>"; };
B524D5A02560302100F5A88F /* PlayerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerViewController.swift; sourceTree = "<group>"; };
B524D5A22560303000F5A88F /* PlayerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerViewModel.swift; sourceTree = "<group>"; };
B524D5A42560303D00F5A88F /* PlayerControlsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerControlsViewController.swift; sourceTree = "<group>"; };
B524D5A62560305800F5A88F /* PlayerControlsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerControlsViewModel.swift; sourceTree = "<group>"; };
B524D5A8256031DE00F5A88F /* AppCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppCoordinator.swift; sourceTree = "<group>"; };
B524D5AC25604E4B00F5A88F /* PlaylistItemsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaylistItemsService.swift; sourceTree = "<group>"; };
B524D5AE25604ED900F5A88F /* AudioContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioContent.swift; sourceTree = "<group>"; };
B580CB0D2561B912006D7DD8 /* EqualizerService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EqualizerService.swift; sourceTree = "<group>"; };
B5AEDBD12475274C007D8101 /* AudioExample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = AudioExample.app; sourceTree = BUILT_PRODUCTS_DIR; };
B5AEDBD42475274C007D8101 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
B5AEDBDD2475274D007D8101 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
B5AEDBE02475274D007D8101 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
B5AEDBE22475274D007D8101 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
B5F883C524780A3C00D277C1 /* AudioStreaming.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = AudioStreaming.framework; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
B5AEDBCE2475274C007D8101 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
B5F883C624780A3D00D277C1 /* AudioStreaming.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
B524D59D2560177C00F5A88F /* Resources */ = {
isa = PBXGroup;
children = (
B524D59B2560176C00F5A88F /* bensound-jazzyfrenchy.mp3 */,
B5AEDBDD2475274D007D8101 /* Assets.xcassets */,
B5AEDBDF2475274D007D8101 /* LaunchScreen.storyboard */,
);
path = Resources;
sourceTree = "<group>";
};
B524D5AA25604E2E00F5A88F /* Services */ = {
isa = PBXGroup;
children = (
B524D5AE25604ED900F5A88F /* AudioContent.swift */,
B524D5AC25604E4B00F5A88F /* PlaylistItemsService.swift */,
B5220835256051830086FB3A /* AudioPlayerService.swift */,
B5220947256074910086FB3A /* MulticastDelegate.swift */,
B580CB0D2561B912006D7DD8 /* EqualizerService.swift */,
);
path = Services;
sourceTree = "<group>";
};
B524D5AB25604E3500F5A88F /* Controllers */ = {
isa = PBXGroup;
children = (
B524D5A02560302100F5A88F /* PlayerViewController.swift */,
B524D5A22560303000F5A88F /* PlayerViewModel.swift */,
B524D5A42560303D00F5A88F /* PlayerControlsViewController.swift */,
B524D5A62560305800F5A88F /* PlayerControlsViewModel.swift */,
B522094F2561883E0086FB3A /* EqualizerViewController.swift */,
B5220953256188590086FB3A /* EqualizerViewModel.swift */,
);
path = Controllers;
sourceTree = "<group>";
};
B5AEDBC82475274C007D8101 = {
isa = PBXGroup;
children = (
B5AEDBD32475274C007D8101 /* AudioExample */,
B5AEDBD22475274C007D8101 /* Products */,
B5F883C424780A3C00D277C1 /* Frameworks */,
);
sourceTree = "<group>";
wrapsLines = 0;
};
B5AEDBD22475274C007D8101 /* Products */ = {
isa = PBXGroup;
children = (
B5AEDBD12475274C007D8101 /* AudioExample.app */,
);
name = Products;
sourceTree = "<group>";
};
B5AEDBD32475274C007D8101 /* AudioExample */ = {
isa = PBXGroup;
children = (
B5AEDBD42475274C007D8101 /* AppDelegate.swift */,
B524D5A8256031DE00F5A88F /* AppCoordinator.swift */,
B524D5AA25604E2E00F5A88F /* Services */,
B524D5AB25604E3500F5A88F /* Controllers */,
B524D59D2560177C00F5A88F /* Resources */,
B5AEDBE22475274D007D8101 /* Info.plist */,
);
path = AudioExample;
sourceTree = "<group>";
};
B5F883C424780A3C00D277C1 /* Frameworks */ = {
isa = PBXGroup;
children = (
B5F883C524780A3C00D277C1 /* AudioStreaming.framework */,
);
name = Frameworks;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
B5AEDBD02475274C007D8101 /* AudioExample */ = {
isa = PBXNativeTarget;
buildConfigurationList = B5AEDBE52475274D007D8101 /* Build configuration list for PBXNativeTarget "AudioExample" */;
buildPhases = (
B5AEDBCD2475274C007D8101 /* Sources */,
B5AEDBCE2475274C007D8101 /* Frameworks */,
B5AEDBCF2475274C007D8101 /* Resources */,
B5F883C824780A3D00D277C1 /* Embed Frameworks */,
);
buildRules = (
);
dependencies = (
);
name = AudioExample;
productName = AudioExample;
productReference = B5AEDBD12475274C007D8101 /* AudioExample.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
B5AEDBC92475274C007D8101 /* Project object */ = {
isa = PBXProject;
attributes = {
LastSwiftUpdateCheck = 1140;
LastUpgradeCheck = 1200;
ORGANIZATIONNAME = "Dimitrios Chatzieleftheriou";
TargetAttributes = {
B5AEDBD02475274C007D8101 = {
CreatedOnToolsVersion = 11.4;
};
};
};
buildConfigurationList = B5AEDBCC2475274C007D8101 /* Build configuration list for PBXProject "AudioExample" */;
compatibilityVersion = "Xcode 9.3";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = B5AEDBC82475274C007D8101;
productRefGroup = B5AEDBD22475274C007D8101 /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
B5AEDBD02475274C007D8101 /* AudioExample */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
B5AEDBCF2475274C007D8101 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
B524D59C2560176C00F5A88F /* bensound-jazzyfrenchy.mp3 in Resources */,
B5AEDBE12475274D007D8101 /* LaunchScreen.storyboard in Resources */,
B5AEDBDE2475274D007D8101 /* Assets.xcassets in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
B5AEDBCD2475274C007D8101 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
B524D5AF25604ED900F5A88F /* AudioContent.swift in Sources */,
B524D5A9256031DE00F5A88F /* AppCoordinator.swift in Sources */,
B524D5AD25604E4B00F5A88F /* PlaylistItemsService.swift in Sources */,
B524D5A32560303000F5A88F /* PlayerViewModel.swift in Sources */,
B5220836256051830086FB3A /* AudioPlayerService.swift in Sources */,
B5AEDBD52475274C007D8101 /* AppDelegate.swift in Sources */,
B524D5A12560302100F5A88F /* PlayerViewController.swift in Sources */,
B580CB0E2561B912006D7DD8 /* EqualizerService.swift in Sources */,
B5220954256188590086FB3A /* EqualizerViewModel.swift in Sources */,
B5220948256074910086FB3A /* MulticastDelegate.swift in Sources */,
B524D5A52560303D00F5A88F /* PlayerControlsViewController.swift in Sources */,
B524D5A72560305800F5A88F /* PlayerControlsViewModel.swift in Sources */,
B52209502561883E0086FB3A /* EqualizerViewController.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXVariantGroup section */
B5AEDBDF2475274D007D8101 /* LaunchScreen.storyboard */ = {
isa = PBXVariantGroup;
children = (
B5AEDBE02475274D007D8101 /* Base */,
);
name = LaunchScreen.storyboard;
sourceTree = "<group>";
};
/* End PBXVariantGroup section */
/* Begin XCBuildConfiguration section */
B5AEDBE32475274D007D8101 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.4;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
name = Debug;
};
B5AEDBE42475274D007D8101 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.4;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SDKROOT = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
VALIDATE_PRODUCT = YES;
};
name = Release;
};
B5AEDBE62475274D007D8101 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_TEAM = 5Y92JCRVR7;
INFOPLIST_FILE = AudioExample/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.decimal.AudioExample;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
B5AEDBE72475274D007D8101 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_TEAM = 5Y92JCRVR7;
INFOPLIST_FILE = AudioExample/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.decimal.AudioExample;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
B5AEDBCC2475274C007D8101 /* Build configuration list for PBXProject "AudioExample" */ = {
isa = XCConfigurationList;
buildConfigurations = (
B5AEDBE32475274D007D8101 /* Debug */,
B5AEDBE42475274D007D8101 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
B5AEDBE52475274D007D8101 /* Build configuration list for PBXNativeTarget "AudioExample" */ = {
isa = XCConfigurationList;
buildConfigurations = (
B5AEDBE62475274D007D8101 /* Debug */,
B5AEDBE72475274D007D8101 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = B5AEDBC92475274C007D8101 /* Project object */;
}
@@ -1,125 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1200"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "B5AEDBD02475274C007D8101"
BuildableName = "AudioExample.app"
BlueprintName = "AudioExample"
ReferencedContainer = "container:AudioExample.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
enableThreadSanitizer = "YES"
codeCoverageEnabled = "YES">
<TestPlans>
<TestPlanReference
reference = "container:../AudioStreamingTests/AudioExample.xctestplan"
default = "YES">
</TestPlanReference>
</TestPlans>
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "YES"
testExecutionOrdering = "random">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "B5AEDBB624744153007D8101"
BuildableName = "AudioStreamingTests.xctest"
BlueprintName = "AudioStreamingTests"
ReferencedContainer = "container:../AudioStreaming.xcodeproj">
</BuildableReference>
<SkippedTests>
<Test
Identifier = "ProtectedTests">
</Test>
</SkippedTests>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
enableASanStackUseAfterReturn = "YES"
disableMainThreadChecker = "YES"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
enableGPUValidationMode = "1"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "B5AEDBD02475274C007D8101"
BuildableName = "AudioExample.app"
BlueprintName = "AudioExample"
ReferencedContainer = "container:AudioExample.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<EnvironmentVariables>
<EnvironmentVariable
key = "OS_ACTIVITY_MODE"
value = "disable"
isEnabled = "NO">
</EnvironmentVariable>
</EnvironmentVariables>
<AdditionalOptions>
<AdditionalOption
key = "MallocStackLogging"
value = ""
isEnabled = "YES">
</AdditionalOption>
<AdditionalOption
key = "PrefersMallocStackLoggingLite"
value = ""
isEnabled = "YES">
</AdditionalOption>
</AdditionalOptions>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "B5AEDBD02475274C007D8101"
BuildableName = "AudioExample.app"
BlueprintName = "AudioExample"
ReferencedContainer = "container:AudioExample.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
@@ -1,22 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>SchemeUserState</key>
<dict>
<key>AudioExample.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>0</integer>
</dict>
</dict>
<key>SuppressBuildableAutocreation</key>
<dict>
<key>B5AEDBD02475274C007D8101</key>
<dict>
<key>primary</key>
<true/>
</dict>
</dict>
</dict>
</plist>
@@ -1,63 +0,0 @@
//
// AppCoordinator.swift
// AudioExample
//
// Created by Dimitrios Chatzieleftheriou on 14/11/2020.
// Copyright © 2020 Dimitrios Chatzieleftheriou. All rights reserved.
//
import UIKit
final class AppCoordinator {
enum Route {
case equalizer
}
private var navigationController: UINavigationController?
private let playerService: AudioPlayerService
private let equaliserService: EqualizerService
init() {
playerService = AudioPlayerService()
equaliserService = EqualizerService(playerService: playerService)
}
func start(window: UIWindow) {
window.rootViewController = buildMain()
window.makeKeyAndVisible()
}
private func buildMain() -> UINavigationController {
let playlistItemsService = PlaylistItemsService(initialItemsProvider: provideInitialPlaylistItems)
let viewModel = PlayerViewModel(playlistItemsService: playlistItemsService,
playerService: playerService,
routeTo: { [weak self] in self?.routeTo($0) })
let viewController = PlayerViewController(viewModel: viewModel,
controlsProvider: providePlayerControls)
let navigationController = UINavigationController(rootViewController: viewController)
self.navigationController = navigationController
return navigationController
}
private func routeTo(_ route: AppCoordinator.Route) {
switch route {
case .equalizer:
showEqualizerControls()
}
}
private func providePlayerControls() -> UIViewController {
let viewModel = PlayerControlsViewModel(playerService: playerService)
return PlayerControlsViewController(viewModel: viewModel)
}
private func showEqualizerControls() {
let viewModel = EqualzerViewModel(equalizerService: equaliserService)
let viewController = EqualizerViewController(viewModel: viewModel)
let navigationController = UINavigationController(rootViewController: viewController)
self.navigationController?.present(navigationController, animated: true, completion: nil)
}
}
@@ -1,25 +0,0 @@
//
// AppDelegate.swift
// AudioExample
//
// Created by Dimitrios Chatzieleftheriou on 20/05/2020.
// Copyright © 2020 Dimitrios Chatzieleftheriou. All rights reserved.
//
import UIKit
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
var appCoordinator: AppCoordinator?
func application(_: UIApplication, didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
let window = UIWindow(frame: UIScreen.main.bounds)
let appCoordinator = AppCoordinator()
appCoordinator.start(window: window)
self.window = window
self.appCoordinator = appCoordinator
return true
}
}
@@ -1,155 +0,0 @@
//
// EqualizerViewController.swift
// AudioExample
//
// Created by Dimitrios Chatzieleftheriou on 15/11/2020.
// Copyright © 2020 Dimitrios Chatzieleftheriou. All rights reserved.
//
import UIKit
class EqualizerViewController: UIViewController {
private lazy var enableTextLabel = UILabel()
private lazy var enableButton = UISwitch()
private var eqSlider = [UISlider]()
private let viewModel: EqualzerViewModel
init(viewModel: EqualzerViewModel) {
self.viewModel = viewModel
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
title = "Equaliser"
view.backgroundColor = .systemBackground
navigationItem.leftBarButtonItem = UIBarButtonItem(title: "Reset", style: .plain, target: self, action: #selector(resetEq))
enableTextLabel.translatesAutoresizingMaskIntoConstraints = false
enableTextLabel.text = "Enable"
enableButton.translatesAutoresizingMaskIntoConstraints = false
enableButton.isOn = viewModel.equaliserIsOn
enableButton.onTintColor = .systemTeal
enableButton.addTarget(self, action: #selector(enableEq), for: .valueChanged)
let enableStackView = UIStackView(arrangedSubviews: [enableTextLabel, enableButton])
enableStackView.translatesAutoresizingMaskIntoConstraints = false
enableStackView.axis = .horizontal
enableStackView.alignment = .center
enableStackView.spacing = 10
enableStackView.isLayoutMarginsRelativeArrangement = true
enableStackView.directionalLayoutMargins = .init(top: 10, leading: 10, bottom: 10, trailing: 10)
let equaliserControls = UIStackView(arrangedSubviews: buildSliders())
equaliserControls.translatesAutoresizingMaskIntoConstraints = false
equaliserControls.axis = .vertical
equaliserControls.alignment = .fill
equaliserControls.distribution = .fillEqually
let stackView = UIStackView(arrangedSubviews: [enableStackView, equaliserControls])
stackView.translatesAutoresizingMaskIntoConstraints = false
stackView.axis = .vertical
stackView.isLayoutMarginsRelativeArrangement = true
stackView.directionalLayoutMargins = .init(top: 10, leading: 10, bottom: 10, trailing: 10)
view.addSubview(stackView)
NSLayoutConstraint.activate(
[
enableStackView.heightAnchor.constraint(equalToConstant: 60),
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)
]
)
}
@objc func enableEq() {
viewModel.enableEq(enableButton.isOn)
}
@objc func resetEq() {
viewModel.resetEq { value in
eqSlider.forEach { $0.setValue(value, animated: true) }
}
}
private func buildSliders() -> [UIView] {
var sliders = [UIView]()
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)
}
return sliders
}
@objc private func valueChanged(_ slider: UISlider) {
viewModel.update(gain: slider.value, for: slider.tag)
}
private func buildSlider(item: EQBand, index: Int) -> UIView {
let freqLabel = UILabel()
freqLabel.translatesAutoresizingMaskIntoConstraints = false
freqLabel.text = item.frequency
freqLabel.textAlignment = .right
freqLabel.widthAnchor.constraint(equalToConstant: 40).isActive = true
let slider = UISlider()
slider.translatesAutoresizingMaskIntoConstraints = false
slider.tag = index // cheating here
slider.minimumValue = item.min
slider.maximumValue = item.max
slider.value = item.value
slider.isContinuous = true
slider.addTarget(self, action: #selector(valueChanged(_:)), for: .valueChanged)
eqSlider.append(slider)
let minLabel = UILabel()
minLabel.translatesAutoresizingMaskIntoConstraints = false
minLabel.text = "\(item.min)db"
let centerLabel = UILabel()
centerLabel.translatesAutoresizingMaskIntoConstraints = false
centerLabel.text = "0db"
centerLabel.textAlignment = .center
let maxLabel = UILabel()
maxLabel.translatesAutoresizingMaskIntoConstraints = false
maxLabel.text = "\(item.max)db"
maxLabel.textAlignment = .right
let dbStackView = UIStackView(arrangedSubviews: [minLabel, centerLabel, maxLabel])
dbStackView.translatesAutoresizingMaskIntoConstraints = false
dbStackView.axis = .horizontal
dbStackView.distribution = .fillEqually
let stackViewSlider = UIStackView(arrangedSubviews: [slider, dbStackView])
stackViewSlider.spacing = 5
stackViewSlider.translatesAutoresizingMaskIntoConstraints = false
stackViewSlider.axis = .vertical
stackViewSlider.setContentHuggingPriority(.fittingSizeLevel, for: .horizontal)
stackViewSlider.setContentCompressionResistancePriority(.fittingSizeLevel, for: .horizontal)
let stackView = UIStackView(arrangedSubviews: [freqLabel, stackViewSlider])
stackView.spacing = 10
stackView.translatesAutoresizingMaskIntoConstraints = false
stackView.axis = .horizontal
stackView.distribution = .fillProportionally
stackView.alignment = .fill
stackView.isLayoutMarginsRelativeArrangement = true
stackView.directionalLayoutMargins = .init(top: 0, leading: 10, bottom: 0, trailing: 10)
return stackView
}
}
@@ -1,67 +0,0 @@
//
// EqualzerViewModel.swift
// AudioExample
//
// Created by Dimitrios Chatzieleftheriou on 15/11/2020.
// Copyright © 2020 Dimitrios Chatzieleftheriou. All rights reserved.
//
import AVFoundation
struct EQBand {
let frequency: String
let min: Float
let max: Float
let value: Float
}
final class EqualzerViewModel {
private var bands: [EQBand] = []
private let equalizerService: EqualizerService
var equaliserIsOn: Bool {
equalizerService.isActivated
}
init(equalizerService: EqualizerService) {
self.equalizerService = equalizerService
bands = equalizerService.bands.map { item in
var measurement = item.frequency
var frequency = String(Int(measurement))
if item.frequency >= 1_000 {
measurement = item.frequency / 1000
frequency = "\(String(Int(measurement)))K"
}
return EQBand(frequency: frequency, min: -12, max: 12, value: item.gain)
}
}
func enableEq(_ enable: Bool) {
if enable {
equalizerService.activate()
} else {
equalizerService.deactive()
}
}
func resetEq(updateSliders: (_ value: Float) -> Void) {
equalizerService.reset()
updateSliders(0)
}
func update(gain: Float, for index: Int) {
equalizerService.update(gain: gain, for: index)
}
func numberOfBands() -> Int {
equalizerService.bands.count
}
func band(at index: Int) -> EQBand? {
guard index < numberOfBands() else { return nil }
return bands[index]
}
}
@@ -1,213 +0,0 @@
//
// PlayerControlsViewController.swift
// AudioExample
//
// Created by Dimitrios Chatzieleftheriou on 14/11/2020.
// Copyright © 2020 Dimitrios Chatzieleftheriou. All rights reserved.
//
import UIKit
class PlayerControlsViewController: UIViewController {
private lazy var resumeButton = UIButton()
private lazy var stopButton = UIButton(type: .custom)
private lazy var muteButton = UIButton()
private lazy var slider = UISlider()
private lazy var elapsedPlayTimeLabel = UILabel()
private lazy var remainingPlayTimeLabel = UILabel()
private lazy var rateSlider = UISlider()
private lazy var rateSliderValueLabel = UILabel()
private lazy var playerStatus = UILabel()
private let viewModel: PlayerControlsViewModel
init(viewModel: PlayerControlsViewModel) {
self.viewModel = viewModel
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder _: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemBackground
setupUI()
setupBinding()
}
private func setupUI() {
muteButton.translatesAutoresizingMaskIntoConstraints = false
muteButton.setTitle("Mute", for: .normal)
muteButton.setTitleColor(.label, for: .normal)
muteButton.setTitleColor(.secondaryLabel, for: .highlighted)
muteButton.setTitleColor(.tertiaryLabel, for: .disabled)
muteButton.accessibilityIdentifier = "muteButton"
muteButton.addTarget(self, action: #selector(toggleMute), for: .touchUpInside)
resumeButton.translatesAutoresizingMaskIntoConstraints = false
resumeButton.setTitle("Pause", for: .normal)
resumeButton.accessibilityIdentifier = "resumeButton"
resumeButton.setTitleColor(.label, for: .normal)
resumeButton.setTitleColor(.secondaryLabel, for: .highlighted)
resumeButton.setTitleColor(.tertiaryLabel, for: .disabled)
resumeButton.addTarget(self, action: #selector(pauseResume), for: .touchUpInside)
stopButton.translatesAutoresizingMaskIntoConstraints = false
stopButton.setTitle("Stop", for: .normal)
stopButton.setTitleColor(.label, for: .normal)
stopButton.setTitleColor(.secondaryLabel, for: .highlighted)
stopButton.setTitleColor(.tertiaryLabel, for: .disabled)
stopButton.accessibilityIdentifier = "stopButton"
stopButton.addTarget(self, action: #selector(stop), for: .touchUpInside)
let controlsStackView = UIStackView(arrangedSubviews: [resumeButton, stopButton, muteButton])
controlsStackView.translatesAutoresizingMaskIntoConstraints = false
controlsStackView.axis = .horizontal
controlsStackView.distribution = .fillEqually
controlsStackView.alignment = .center
controlsStackView.accessibilityIdentifier = "controlsStackView"
slider.translatesAutoresizingMaskIntoConstraints = false
slider.accessibilityIdentifier = "slider"
slider.tintColor = .systemGray2
slider.thumbTintColor = .systemGray
slider.isContinuous = true
slider.semanticContentAttribute = .playback
slider.addTarget(self, action: #selector(sliderTouchedDown), for: .touchDown)
slider.addTarget(self, action: #selector(sliderTouchedUp), for: [.touchUpInside, .touchUpOutside])
slider.addTarget(self, action: #selector(sliderValueChanged), for: .valueChanged)
elapsedPlayTimeLabel.text = "--:--"
elapsedPlayTimeLabel.accessibilityIdentifier = "elapsedPlayTimeLabel"
elapsedPlayTimeLabel.translatesAutoresizingMaskIntoConstraints = false
elapsedPlayTimeLabel.font = UIFont.preferredFont(forTextStyle: .footnote)
elapsedPlayTimeLabel.textAlignment = .left
remainingPlayTimeLabel.text = "--:--"
remainingPlayTimeLabel.accessibilityIdentifier = "remainingPlayTimeLabel"
remainingPlayTimeLabel.translatesAutoresizingMaskIntoConstraints = false
remainingPlayTimeLabel.font = UIFont.preferredFont(forTextStyle: .footnote)
remainingPlayTimeLabel.textAlignment = .right
let playbackTimeLabelsStack = UIStackView(arrangedSubviews: [elapsedPlayTimeLabel, remainingPlayTimeLabel])
playbackTimeLabelsStack.translatesAutoresizingMaskIntoConstraints = false
playbackTimeLabelsStack.axis = .horizontal
playbackTimeLabelsStack.distribution = .fillEqually
playbackTimeLabelsStack.accessibilityIdentifier = "playbackTimeLabelsStack"
playerStatus.text = ""
playerStatus.translatesAutoresizingMaskIntoConstraints = false
playerStatus.numberOfLines = 0
playerStatus.accessibilityIdentifier = "playerStatus-label"
let sliderLabel = UILabel()
sliderLabel.translatesAutoresizingMaskIntoConstraints = false
sliderLabel.text = "Rate: "
rateSliderValueLabel.translatesAutoresizingMaskIntoConstraints = false
rateSliderValueLabel.text = viewModel.currentRateTitle
rateSlider.translatesAutoresizingMaskIntoConstraints = false
rateSlider.minimumValue = viewModel.rateMinValue
rateSlider.maximumValue = viewModel.rateMaxValue
rateSlider.value = viewModel.rateMinValue
rateSlider.addTarget(self, action: #selector(rateValueChanged), for: .valueChanged)
let sliderWarningLabel = UILabel()
sliderWarningLabel.translatesAutoresizingMaskIntoConstraints = false
sliderWarningLabel.text = "Adjusting rate in live broadcast is not recommended"
sliderWarningLabel.numberOfLines = 2
sliderWarningLabel.textColor = .systemRed
let rateSliderStackView = UIStackView(arrangedSubviews: [sliderLabel, rateSlider, rateSliderValueLabel])
rateSliderStackView.spacing = 10
rateSliderStackView.axis = .horizontal
let controlsAndSliderStack = UIStackView(arrangedSubviews: [controlsStackView,
slider,
playbackTimeLabelsStack,
playerStatus,
rateSliderStackView,
sliderWarningLabel])
controlsAndSliderStack.translatesAutoresizingMaskIntoConstraints = false
controlsAndSliderStack.spacing = 10
controlsAndSliderStack.setCustomSpacing(15, after: playbackTimeLabelsStack)
controlsAndSliderStack.axis = .vertical
controlsAndSliderStack.distribution = .fill
controlsAndSliderStack.alignment = .fill
controlsAndSliderStack.isLayoutMarginsRelativeArrangement = true
controlsAndSliderStack.layoutMargins = .init(top: 15, left: 10, bottom: 0, right: 10)
controlsAndSliderStack.accessibilityIdentifier = "controlsAndSliderStack"
view.addSubview(controlsAndSliderStack)
view.accessibilityIdentifier = "controller-view"
NSLayoutConstraint.activate([
controlsAndSliderStack.topAnchor.constraint(equalTo: view.topAnchor),
controlsAndSliderStack.leadingAnchor.constraint(equalTo: view.leadingAnchor),
controlsAndSliderStack.trailingAnchor.constraint(equalTo: view.trailingAnchor),
])
}
private func setupBinding() {
viewModel.updateContent = { [unowned self] effect in
switch effect {
case let .updateMuteButton(title):
self.muteButton.setTitle(title, for: .normal)
case let .updatePauseResumeButton(title):
self.resumeButton.setTitle(title, for: .normal)
case let .updateSliderMinMaxValue(min, max):
self.slider.minimumValue = min
self.slider.maximumValue = max
case let .updateSliderValue(value):
self.slider.value = value
case let .updateMetadata(title):
self.playerStatus.text = title
}
}
viewModel.updateProgressAndDurationTitles = { [elapsedPlayTimeLabel, remainingPlayTimeLabel] progress, duration in
elapsedPlayTimeLabel.text = progress
remainingPlayTimeLabel.text = duration
}
}
@objc private func rateValueChanged() {
viewModel.update(rate: rateSlider.value) { [rateSlider] value in
rateSlider.value = value
}
rateSliderValueLabel.text = viewModel.currentRateTitle
}
@objc private func toggleMute() {
viewModel.toggleMute()
}
@objc private func pauseResume() {
viewModel.togglePauseResume()
}
@objc private func stop() {
viewModel.stop()
}
@objc
func sliderTouchedDown() {
viewModel.seek(action: .started)
}
@objc
func sliderTouchedUp() {
viewModel.seek(action: .ended)
}
@objc
func sliderValueChanged() {
viewModel.seek(action: .updateSeek(time: slider.value))
}
}
@@ -1,166 +0,0 @@
//
// PlayerControlsViewModel.swift
// AudioExample
//
// Created by Dimitrios Chatzieleftheriou on 14/11/2020.
// Copyright © 2020 Dimitrios Chatzieleftheriou. All rights reserved.
//
import AudioStreaming
import Foundation
import UIKit
enum SeekAction: Equatable {
case started
case updateSeek(time: Float)
case ended
}
enum ControlsEffects {
case updateMuteButton(String)
case updatePauseResumeButton(String)
case updateSliderMinMaxValue(min: Float, max: Float)
case updateSliderValue(value: Float)
case updateMetadata(String)
}
final class PlayerControlsViewModel {
var updateContent: ((ControlsEffects) -> Void)?
var updateProgressAndDurationTitles: ((String, String) -> Void)?
private let playerService: AudioPlayerService
private var displayLink: CADisplayLink?
private var seekTime: Float = 0
private var isScrubbing: Bool = false
let rateMinValue: Float = 1.0
let rateMaxValue: Float = 3.0
var currentRateTitle: String {
String(format: "%.1fx", playerService.rate)
}
init(playerService: AudioPlayerService) {
self.playerService = playerService
self.playerService.delegate.add(delegate: self)
}
func stop() {
playerService.stop()
stopDisplayLink(resetLabels: true)
updateContent?(.updatePauseResumeButton("Pause"))
}
func togglePauseResume() {
playerService.toggle()
let isPaused = playerService.state == .paused
updateContent?(.updatePauseResumeButton(isPaused ? "Resume" : "Pause"))
}
func toggleMute() {
playerService.toggleMute()
let isMuted = playerService.isMuted
updateContent?(.updateMuteButton(isMuted ? "Unmute" : "Mute"))
}
func seek(action: SeekAction) {
switch action {
case .started:
isScrubbing = true
seekTime = 0
case let .updateSeek(time):
seekTime = time
case .ended:
isScrubbing = false
if playerService.duration > 0 {
playerService.seek(at: seekTime)
}
}
}
func update(rate: Float, updater: (Float) -> Void) {
let rate = round(rate / 0.5) * 0.5
playerService.update(rate: rate)
updater(rate)
}
private func startDisplayLink() {
displayLink?.invalidate()
displayLink = nil
displayLink = UIScreen.main.displayLink(withTarget: self, selector: #selector(tick))
displayLink?.preferredFramesPerSecond = 6
displayLink?.add(to: .current, forMode: .common)
}
private func stopDisplayLink(resetLabels: Bool) {
displayLink?.invalidate()
displayLink = nil
if resetLabels {
resetLabelsAndSlider()
}
}
@objc private func tick() {
let duration = playerService.duration
let progress = playerService.progress
if duration > 0 {
let elapsed = Int(progress)
let remaining = Int(duration - progress)
updateContent?(.updateSliderMinMaxValue(min: 0.0, max: Float(duration)))
if !isScrubbing {
updateContent?(.updateSliderValue(value: Float(progress)))
}
updateProgressAndDurationTitles?(timeFrom(seconds: elapsed), timeFrom(seconds: remaining))
} else {
let elapsed = Int(progress)
updateProgressAndDurationTitles?("Live broadcast", timeFrom(seconds: elapsed))
}
}
private func resetLabelsAndSlider() {
updateProgressAndDurationTitles?("--:--", "--:--")
updateContent?(.updateSliderMinMaxValue(min: 0, max: 0))
updateContent?(.updateSliderValue(value: 0))
}
private func timeFrom(seconds: Int) -> String {
let correctSeconds = seconds % 60
let minutes = (seconds / 60) % 60
let hours = seconds / 3600
if hours > 0 {
return String(format: "%02d:%02d:%02d", hours, minutes, correctSeconds)
}
return String(format: "%02d:%02d", minutes, correctSeconds)
}
}
extension PlayerControlsViewModel: AudioPlayerServiceDelegate {
func didStopPlaying() {
stopDisplayLink(resetLabels: true)
updateContent?(.updateMetadata(""))
}
func statusChanged(status _: AudioPlayerState) {}
func didStartPlaying() {
startDisplayLink()
resetLabelsAndSlider()
updateContent?(.updateMetadata(""))
}
func errorOccured(error _: AudioPlayerError) {}
func metadataReceived(metadata: [String: String]) {
guard !metadata.isEmpty else { return }
if let title = metadata["StreamTitle"] {
updateContent?(.updateMetadata("Now Playing: \(title)"))
} else {
updateContent?(.updateMetadata(""))
}
}
}
@@ -1,162 +0,0 @@
//
// PlayerViewController.swift
// AudioExample
//
// Created by Dimitrios Chatzieleftheriou on 14/11/2020.
// Copyright © 2020 Dimitrios Chatzieleftheriou. All rights reserved.
//
import UIKit
class PlayerViewController: UIViewController {
private lazy var tableView = UITableView()
private let viewModel: PlayerViewModel
private var controlsProvider: () -> UIViewController
private var playerControlsController: UIViewController?
init(viewModel: PlayerViewModel, controlsProvider: @escaping () -> UIViewController) {
self.viewModel = viewModel
self.controlsProvider = controlsProvider
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder _: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
setupUI()
viewModel.reloadContent = { [weak self] action in
switch action {
case .all:
self?.tableView.reloadData()
case let .item(indexPath):
self?.tableView.reloadRows(at: [indexPath], with: .automatic)
}
}
}
private func setupUI() {
title = "Player"
view.backgroundColor = .systemBackground
navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .add,
target: self,
action: #selector(addNowPlaylistItem))
navigationItem.leftBarButtonItem = UIBarButtonItem(image: UIImage(systemName: "slider.horizontal.3"),
style: .plain,
target: self,
action: #selector(showEqualizer))
tableView.translatesAutoresizingMaskIntoConstraints = false
tableView.delegate = self
tableView.dataSource = self
tableView.register(PlaylistTableViewCell.self, forCellReuseIdentifier: "PlaylistCell")
let controlsController = controlsProvider()
playerControlsController = controlsController
let stackView = UIStackView()
stackView.axis = .vertical
stackView.alignment = .fill
stackView.distribution = .fillProportionally
stackView.translatesAutoresizingMaskIntoConstraints = false
stackView.addArrangedSubview(tableView)
addChild(controlsController)
controlsController.view.translatesAutoresizingMaskIntoConstraints = false
stackView.addArrangedSubview(controlsController.view)
controlsController.didMove(toParent: self)
view.addSubview(stackView)
NSLayoutConstraint.activate(
[
controlsController.view.widthAnchor.constraint(equalTo: view.widthAnchor),
stackView.topAnchor.constraint(equalTo: view.topAnchor),
stackView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
stackView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
stackView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
]
)
}
@objc private func showEqualizer() {
viewModel.showEqualizer()
}
@objc private func addNowPlaylistItem() {
let controller = UIAlertController(title: "Add new item", message: "", preferredStyle: .alert)
controller.addTextField { (textField) in
textField.placeholder = "Insert url here"
}
let saveAction = UIAlertAction(title: "Save", style: .default) { [viewModel] action in
if let textfield = controller.textFields?.first,
let text = textfield.text {
viewModel.add(urlString: text)
}
}
let cancelAction = UIAlertAction(title: "Cancel", style: .cancel, handler: nil)
controller.addAction(saveAction)
controller.addAction(cancelAction)
self.present(controller, animated: true, completion: nil)
}
}
extension PlayerViewController: UITableViewDataSource {
func tableView(_: UITableView, numberOfRowsInSection _: Int) -> Int {
viewModel.itemsCount
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "PlaylistCell", for: indexPath)
guard let item = viewModel.item(at: indexPath) else {
return cell
}
cell.textLabel?.text = item.name
cell.detailTextLabel?.text = item.queues ? "Queue item" : nil
update(status: item.status, of: cell)
return cell
}
private func update(status: PlaylistItem.Status, of cell: UITableViewCell) {
switch status {
case .buffering:
let activity = UIActivityIndicatorView(style: .medium)
activity.startAnimating()
cell.accessoryView = activity
case .playing:
cell.accessoryView = UIImageView(image: UIImage(systemName: "play.fill"))
case .paused:
cell.accessoryView = UIImageView(image: UIImage(systemName: "pause.fill"))
case .stopped:
cell.accessoryView = nil
}
cell.accessoryView?.tintColor = .systemTeal
}
}
extension PlayerViewController: UITableViewDelegate {
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
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")
}
}
@@ -1,107 +0,0 @@
//
// PlayerViewModel.swift
// AudioExample
//
// Created by Dimitrios Chatzieleftheriou on 14/11/2020.
// Copyright © 2020 Dimitrios Chatzieleftheriou. All rights reserved.
//
import AudioStreaming
import Foundation
enum ReloadAction {
case all
case item(IndexPath)
}
final class PlayerViewModel {
private let playerService: AudioPlayerService
private let playlistItemsService: PlaylistItemsService
private let routeTo: ((AppCoordinator.Route) -> Void)
private var currentPlayingItemIndex: Int?
var reloadContent: ((ReloadAction) -> Void)?
init(playlistItemsService: PlaylistItemsService,
playerService: AudioPlayerService,
routeTo: @escaping (AppCoordinator.Route) -> Void) {
self.playlistItemsService = playlistItemsService
self.playerService = playerService
self.routeTo = routeTo
self.playerService.delegate.add(delegate: self)
}
func showEqualizer() {
routeTo(.equalizer)
}
var itemsCount: Int {
playlistItemsService.itemsCount
}
func add(urlString: String) {
let detector = try! NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
let result = detector.firstMatch(in: urlString, options: [], range: NSRange(location: 0, length: urlString.utf16.count))
guard let url = URL(string: urlString), result != nil else {
print("malformed url error")
return
}
playlistItemsService.add(item: PlaylistItem(url: url, name: urlString, status: .stopped, queues: false))
reloadContent?(.all)
}
func item(at indexPath: IndexPath) -> PlaylistItem? {
playlistItemsService.item(at: indexPath.row)
}
func playItem(at indexPath: IndexPath) {
guard let item = item(at: indexPath) else { return }
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
}
}
}
extension PlayerViewModel: AudioPlayerServiceDelegate {
func statusChanged(status: AudioPlayerState) {
guard let item = currentPlayingItemIndex else { return }
switch status {
case .bufferring:
playlistItemsService.setStatus(for: item, status: .buffering)
reloadContent?(.item(IndexPath(item: item, section: 0)))
case .playing:
playlistItemsService.setStatus(for: item, status: .playing)
reloadContent?(.item(IndexPath(item: item, section: 0)))
case .paused:
playlistItemsService.setStatus(for: item, status: .paused)
reloadContent?(.item(IndexPath(item: item, section: 0)))
case .stopped:
playlistItemsService.setStatus(for: item, status: .stopped)
reloadContent?(.item(IndexPath(item: item, section: 0)))
default:
break
}
}
func errorOccured(error _: AudioPlayerError) {
currentPlayingItemIndex = nil
}
func metadataReceived(metadata _: [String: String]) {}
func didStopPlaying() {}
func didStartPlaying() {}
}
-52
View File
@@ -1,52 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>armv7</string>
</array>
<key>UIRequiresFullScreen</key>
<true/>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
</plist>
@@ -1,98 +0,0 @@
{
"images" : [
{
"idiom" : "iphone",
"scale" : "2x",
"size" : "20x20"
},
{
"idiom" : "iphone",
"scale" : "3x",
"size" : "20x20"
},
{
"idiom" : "iphone",
"scale" : "2x",
"size" : "29x29"
},
{
"idiom" : "iphone",
"scale" : "3x",
"size" : "29x29"
},
{
"idiom" : "iphone",
"scale" : "2x",
"size" : "40x40"
},
{
"idiom" : "iphone",
"scale" : "3x",
"size" : "40x40"
},
{
"idiom" : "iphone",
"scale" : "2x",
"size" : "60x60"
},
{
"idiom" : "iphone",
"scale" : "3x",
"size" : "60x60"
},
{
"idiom" : "ipad",
"scale" : "1x",
"size" : "20x20"
},
{
"idiom" : "ipad",
"scale" : "2x",
"size" : "20x20"
},
{
"idiom" : "ipad",
"scale" : "1x",
"size" : "29x29"
},
{
"idiom" : "ipad",
"scale" : "2x",
"size" : "29x29"
},
{
"idiom" : "ipad",
"scale" : "1x",
"size" : "40x40"
},
{
"idiom" : "ipad",
"scale" : "2x",
"size" : "40x40"
},
{
"idiom" : "ipad",
"scale" : "1x",
"size" : "76x76"
},
{
"idiom" : "ipad",
"scale" : "2x",
"size" : "76x76"
},
{
"idiom" : "ipad",
"scale" : "2x",
"size" : "83.5x83.5"
},
{
"idiom" : "ios-marketing",
"scale" : "1x",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -1,25 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="13122.16" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
<dependencies>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="13104.12"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<scenes>
<!--View Controller-->
<scene sceneID="EHf-IW-A2E">
<objects>
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" xcode11CocoaTouchSystemColor="systemBackgroundColor" cocoaTouchSystemColor="whiteColor"/>
<viewLayoutGuide key="safeArea" id="6Tk-OE-BBY"/>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="53" y="375"/>
</scene>
</scenes>
</document>
@@ -1,68 +0,0 @@
//
// AudioContent.swift
// AudioExample
//
// Created by Dimitrios Chatzieleftheriou on 14/11/2020.
// Copyright © 2020 Dimitrios Chatzieleftheriou. All rights reserved.
//
import Foundation
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 {
case .offradio:
return "Offradio (stream)"
case .enlefko:
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:
return "Khruangbin (mp3 preview)"
case .piano:
return "Piano (mp3)"
case .local:
return "Local file (mp3)"
case .podcast:
return "Swift by Sundell. Ep. 50 (mp3)"
}
}
var streamUrl: URL {
switch self {
case .enlefko:
return URL(string: "https://stream.radiojar.com/srzwv225e3quv")!
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")!
case .kosmos:
return URL(string: "https://radiostreaming.ert.gr/ert-kosmos")!
case .radiox:
return URL(string: "https://media-ssl.musicradio.com/RadioXLondon")!
case .khruangbin:
return URL(string: "https://p.scdn.co/mp3-preview/cab4b09c23ffc11774d879977131df9d150fcef4?cid=d8a5ed958d274c2e8ee717e6a4b0971d")!
case .piano:
return URL(string: "https://www.kozco.com/tech/piano2-CoolEdit.mp3")!
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")!
}
}
}
@@ -1,31 +0,0 @@
//
// MulticastDelegate.swift
// AudioExample
//
// Created by Dimitrios Chatzieleftheriou on 14/11/2020.
// Copyright © 2020 Dimitrios Chatzieleftheriou. All rights reserved.
//
import Foundation
class MulticastDelegate<Delegate> {
private let delegates = NSHashTable<AnyObject>.weakObjects()
func add(delegate: Delegate) {
delegates.add(delegate as AnyObject)
}
func remove(delegate: Delegate) {
for oneDelegate in delegates.allObjects.reversed() {
if oneDelegate === delegate as AnyObject {
delegates.remove(oneDelegate)
}
}
}
func invoke(invocation: (Delegate) -> Void) {
for delegate in delegates.allObjects.reversed() {
invocation(delegate as! Delegate)
}
}
}
@@ -1,37 +0,0 @@
//
// NowPlayingCenter.swift
// AudioExample
//
// Created by Dimitrios Chatzieleftheriou on 15/11/2020.
// Copyright © 2020 Dimitrios Chatzieleftheriou. All rights reserved.
//
import MediaPlayer
final class NowPlayingCenter {
private let infoCenter: MPNowPlayingInfoCenter
init(infoCenter: MPNowPlayingInfoCenter = .default()){
self.infoCenter = infoCenter
}
func change(item: PlaylistItem, isLiveStream: Bool) {
var nowPlayingInfo = infoCenter.nowPlayingInfo ?? [String: Any]()
nowPlayingInfo[MPNowPlayingInfoPropertyMediaType] = MPNowPlayingInfoMediaType.audio.rawValue
nowPlayingInfo[MPNowPlayingInfoPropertyIsLiveStream] = isLiveStream
nowPlayingInfo[MPMediaItemPropertyArtist] = item.name
infoCenter.nowPlayingInfo = nowPlayingInfo
}
func update(with metadata: [String: String], with item: PlaylistItem) {
var nowPlayingInfo = infoCenter.nowPlayingInfo ?? [String: Any]()
nowPlayingInfo[MPMediaItemPropertyTitle] = metadata["StreamTitle"]
nowPlayingInfo[MPMediaItemPropertyArtist] = item.name
infoCenter.nowPlayingInfo = nowPlayingInfo
}
}
@@ -1,86 +0,0 @@
//
// PlaylistItemsService.swift
// AudioExample
//
// Created by Dimitrios Chatzieleftheriou on 14/11/2020.
// Copyright © 2020 Dimitrios Chatzieleftheriou. All rights reserved.
//
import Foundation
struct PlaylistItem: Equatable {
enum Status: Equatable {
case playing
case paused
case buffering
case stopped
}
let url: URL
let name: String
let status: Status
let queues: Bool
init(content: AudioContent, queues: Bool) {
name = content.title
url = content.streamUrl
status = .stopped
self.queues = queues
}
init(url: URL, name: String, status: Status, queues: Bool) {
self.url = url
self.name = name
self.status = status
self.queues = queues
}
}
final class PlaylistItemsService {
private var items: [PlaylistItem] = []
var itemsCount: Int {
items.count
}
let protectedItemCount: Int
init(initialItemsProvider: () -> [PlaylistItem]) {
items = initialItemsProvider()
protectedItemCount = items.count
}
func item(at index: Int) -> PlaylistItem? {
guard index < items.count else { return nil }
return items[index]
}
func index(for item: PlaylistItem) -> Int? {
items.firstIndex(of: item)
}
func add(item: PlaylistItem) {
items.append(item)
}
func remove(item: PlaylistItem) {
if let index = items.firstIndex(of: item) {
items.remove(at: index)
}
}
func setStatus(for index: Int, status: PlaylistItem.Status) {
guard let item = item(at: index) else {
return
}
items[index] = PlaylistItem(url: item.url, name: item.name, 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) }
return allItems + casesForQueuingItems
}
@@ -0,0 +1,515 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 56;
objects = {
/* Begin PBXBuildFile section */
42BE42F52C9322AA00C0E448 /* CustomStreamSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42BE42F42C9322AA00C0E448 /* CustomStreamSource.swift */; };
9806E8182BC5D12500757370 /* App.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9806E8172BC5D12500757370 /* App.swift */; };
9806E81A2BC5D12500757370 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9806E8192BC5D12500757370 /* ContentView.swift */; };
9806E81C2BC5D12700757370 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 9806E81B2BC5D12700757370 /* Assets.xcassets */; };
9806E81F2BC5D12700757370 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 9806E81E2BC5D12700757370 /* Preview Assets.xcassets */; };
9806E8262BC5D2A900757370 /* Sidebar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9806E8252BC5D2A900757370 /* Sidebar.swift */; };
9806E82A2BC68F8700757370 /* AudioPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9806E8292BC68F8700757370 /* AudioPlayerView.swift */; };
9806E8312BC6927D00757370 /* AudioPlayerModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9806E8302BC6927D00757370 /* AudioPlayerModel.swift */; };
9816A8A52BC7D8A200AD1299 /* AudioStreaming.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9816A8A42BC7D8A200AD1299 /* AudioStreaming.framework */; };
9816A8A62BC7D8A200AD1299 /* AudioStreaming.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 9816A8A42BC7D8A200AD1299 /* AudioStreaming.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
9816A8AA2BC7F4F000AD1299 /* AudioTrack.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9816A8A92BC7F4F000AD1299 /* AudioTrack.swift */; };
9816A8AC2BC820DF00AD1299 /* AudioContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9816A8AB2BC820DF00AD1299 /* AudioContent.swift */; };
9816A8B12BC8330C00AD1299 /* bensound-jazzyfrenchy.mp3 in Resources */ = {isa = PBXBuildFile; fileRef = 9816A8AD2BC832DB00AD1299 /* bensound-jazzyfrenchy.mp3 */; };
9816A8B22BC8330C00AD1299 /* bensound-jazzyfrenchy.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 9816A8AE2BC832DB00AD1299 /* bensound-jazzyfrenchy.m4a */; };
9816A8B32BC8330C00AD1299 /* hipjazz.wav in Resources */ = {isa = PBXBuildFile; fileRef = 9816A8AF2BC832DC00AD1299 /* hipjazz.wav */; };
9816A8BB2BC87BC200AD1299 /* AudioPlayerService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9816A8BA2BC87BC200AD1299 /* AudioPlayerService.swift */; };
984DE9552BDAE59C004B427A /* Notifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 984DE9542BDAE59C004B427A /* Notifier.swift */; };
984DE9572BDAFC7E004B427A /* AudioPlayerControlsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 984DE9562BDAFC7E004B427A /* AudioPlayerControlsView.swift */; };
989E08E72BF7A4E300599F17 /* PrefersTabNavigationEnvironmentKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = 989E08E62BF7A4E300599F17 /* PrefersTabNavigationEnvironmentKey.swift */; };
98BFB41A2BC97AF800E812C0 /* DisplayLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98BFB4192BC97AF800E812C0 /* DisplayLink.swift */; };
98BFB41D2BCD7BB800E812C0 /* EqualizerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98BFB41C2BCD7BB800E812C0 /* EqualizerView.swift */; };
98BFB41F2BCD814000E812C0 /* EqualizerService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98BFB41E2BCD814000E812C0 /* EqualizerService.swift */; };
98BFB4232BCE78AB00E812C0 /* AddNewAudioURLView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98BFB4222BCE78AB00E812C0 /* AddNewAudioURLView.swift */; };
98E6119C2BC72C0E0036BC47 /* DetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98E6119B2BC72C0E0036BC47 /* DetailView.swift */; };
/* End PBXBuildFile section */
/* Begin PBXCopyFilesBuildPhase section */
9816A8A72BC7D8A200AD1299 /* Embed Frameworks */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 10;
files = (
9816A8A62BC7D8A200AD1299 /* AudioStreaming.framework in Embed Frameworks */,
);
name = "Embed Frameworks";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
42BE42F42C9322AA00C0E448 /* CustomStreamSource.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CustomStreamSource.swift; sourceTree = "<group>"; };
9806E8142BC5D12500757370 /* AudioPlayer.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = AudioPlayer.app; sourceTree = BUILT_PRODUCTS_DIR; };
9806E8172BC5D12500757370 /* App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App.swift; sourceTree = "<group>"; };
9806E8192BC5D12500757370 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
9806E81B2BC5D12700757370 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
9806E81E2BC5D12700757370 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = "<group>"; };
9806E8252BC5D2A900757370 /* Sidebar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar.swift; sourceTree = "<group>"; };
9806E8292BC68F8700757370 /* AudioPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlayerView.swift; sourceTree = "<group>"; };
9806E8302BC6927D00757370 /* AudioPlayerModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlayerModel.swift; sourceTree = "<group>"; };
9816A8A42BC7D8A200AD1299 /* AudioStreaming.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = AudioStreaming.framework; sourceTree = BUILT_PRODUCTS_DIR; };
9816A8A92BC7F4F000AD1299 /* AudioTrack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioTrack.swift; sourceTree = "<group>"; };
9816A8AB2BC820DF00AD1299 /* AudioContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioContent.swift; sourceTree = "<group>"; };
9816A8AD2BC832DB00AD1299 /* bensound-jazzyfrenchy.mp3 */ = {isa = PBXFileReference; lastKnownFileType = audio.mp3; path = "bensound-jazzyfrenchy.mp3"; sourceTree = "<group>"; };
9816A8AE2BC832DB00AD1299 /* bensound-jazzyfrenchy.m4a */ = {isa = PBXFileReference; lastKnownFileType = file; path = "bensound-jazzyfrenchy.m4a"; sourceTree = "<group>"; };
9816A8AF2BC832DC00AD1299 /* hipjazz.wav */ = {isa = PBXFileReference; lastKnownFileType = audio.wav; path = hipjazz.wav; sourceTree = "<group>"; };
9816A8BA2BC87BC200AD1299 /* AudioPlayerService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlayerService.swift; sourceTree = "<group>"; };
984DE9542BDAE59C004B427A /* Notifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Notifier.swift; sourceTree = "<group>"; };
984DE9562BDAFC7E004B427A /* AudioPlayerControlsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlayerControlsView.swift; sourceTree = "<group>"; };
989E08E62BF7A4E300599F17 /* PrefersTabNavigationEnvironmentKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrefersTabNavigationEnvironmentKey.swift; sourceTree = "<group>"; };
98BFB4192BC97AF800E812C0 /* DisplayLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisplayLink.swift; sourceTree = "<group>"; };
98BFB41B2BCAAD8A00E812C0 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
98BFB41C2BCD7BB800E812C0 /* EqualizerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EqualizerView.swift; sourceTree = "<group>"; };
98BFB41E2BCD814000E812C0 /* EqualizerService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EqualizerService.swift; sourceTree = "<group>"; };
98BFB4222BCE78AB00E812C0 /* AddNewAudioURLView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddNewAudioURLView.swift; sourceTree = "<group>"; };
98E6119B2BC72C0E0036BC47 /* DetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailView.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
9806E8112BC5D12500757370 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
9816A8A52BC7D8A200AD1299 /* AudioStreaming.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
9806E80B2BC5D12500757370 = {
isa = PBXGroup;
children = (
9806E8162BC5D12500757370 /* AudioPlayer */,
9806E8152BC5D12500757370 /* Products */,
9816A8A32BC7D8A200AD1299 /* Frameworks */,
);
sourceTree = "<group>";
};
9806E8152BC5D12500757370 /* Products */ = {
isa = PBXGroup;
children = (
9806E8142BC5D12500757370 /* AudioPlayer.app */,
);
name = Products;
sourceTree = "<group>";
};
9806E8162BC5D12500757370 /* AudioPlayer */ = {
isa = PBXGroup;
children = (
98BFB41B2BCAAD8A00E812C0 /* Info.plist */,
984DE9532BDAE57F004B427A /* Dependencies */,
984DE9522BDAE571004B427A /* Helpers */,
9816A8A82BC7F4DE00AD1299 /* Common */,
9806E8282BC68F7300757370 /* Content */,
9806E8272BC68F6600757370 /* Navigation */,
9806E8172BC5D12500757370 /* App.swift */,
9806E81B2BC5D12700757370 /* Assets.xcassets */,
9816A8B02BC832E100AD1299 /* Resources */,
9806E81D2BC5D12700757370 /* Preview Content */,
);
path = AudioPlayer;
sourceTree = "<group>";
};
9806E81D2BC5D12700757370 /* Preview Content */ = {
isa = PBXGroup;
children = (
9806E81E2BC5D12700757370 /* Preview Assets.xcassets */,
);
path = "Preview Content";
sourceTree = "<group>";
};
9806E8272BC68F6600757370 /* Navigation */ = {
isa = PBXGroup;
children = (
9806E8192BC5D12500757370 /* ContentView.swift */,
98E6119B2BC72C0E0036BC47 /* DetailView.swift */,
9806E8252BC5D2A900757370 /* Sidebar.swift */,
);
path = Navigation;
sourceTree = "<group>";
};
9806E8282BC68F7300757370 /* Content */ = {
isa = PBXGroup;
children = (
98E3921C2BD845E100B586E9 /* AudioPlayer */,
);
path = Content;
sourceTree = "<group>";
};
9816A8A32BC7D8A200AD1299 /* Frameworks */ = {
isa = PBXGroup;
children = (
9816A8A42BC7D8A200AD1299 /* AudioStreaming.framework */,
);
name = Frameworks;
sourceTree = "<group>";
};
9816A8A82BC7F4DE00AD1299 /* Common */ = {
isa = PBXGroup;
children = (
98BFB4222BCE78AB00E812C0 /* AddNewAudioURLView.swift */,
9816A8A92BC7F4F000AD1299 /* AudioTrack.swift */,
9816A8AB2BC820DF00AD1299 /* AudioContent.swift */,
);
path = Common;
sourceTree = "<group>";
};
9816A8B02BC832E100AD1299 /* Resources */ = {
isa = PBXGroup;
children = (
9816A8AE2BC832DB00AD1299 /* bensound-jazzyfrenchy.m4a */,
9816A8AF2BC832DC00AD1299 /* hipjazz.wav */,
9816A8AD2BC832DB00AD1299 /* bensound-jazzyfrenchy.mp3 */,
);
path = Resources;
sourceTree = "<group>";
};
984DE9522BDAE571004B427A /* Helpers */ = {
isa = PBXGroup;
children = (
98BFB4192BC97AF800E812C0 /* DisplayLink.swift */,
984DE9542BDAE59C004B427A /* Notifier.swift */,
989E08E62BF7A4E300599F17 /* PrefersTabNavigationEnvironmentKey.swift */,
);
path = Helpers;
sourceTree = "<group>";
};
984DE9532BDAE57F004B427A /* Dependencies */ = {
isa = PBXGroup;
children = (
98BFB41E2BCD814000E812C0 /* EqualizerService.swift */,
9816A8BA2BC87BC200AD1299 /* AudioPlayerService.swift */,
);
path = Dependencies;
sourceTree = "<group>";
};
98E3921C2BD845E100B586E9 /* AudioPlayer */ = {
isa = PBXGroup;
children = (
42BE42F42C9322AA00C0E448 /* CustomStreamSource.swift */,
9806E8302BC6927D00757370 /* AudioPlayerModel.swift */,
9806E8292BC68F8700757370 /* AudioPlayerView.swift */,
98BFB41C2BCD7BB800E812C0 /* EqualizerView.swift */,
984DE9562BDAFC7E004B427A /* AudioPlayerControlsView.swift */,
);
path = AudioPlayer;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
9806E8132BC5D12500757370 /* AudioPlayer */ = {
isa = PBXNativeTarget;
buildConfigurationList = 9806E8222BC5D12700757370 /* Build configuration list for PBXNativeTarget "AudioPlayer" */;
buildPhases = (
9806E8102BC5D12500757370 /* Sources */,
9806E8112BC5D12500757370 /* Frameworks */,
9806E8122BC5D12500757370 /* Resources */,
9816A8A72BC7D8A200AD1299 /* Embed Frameworks */,
);
buildRules = (
);
dependencies = (
);
name = AudioPlayer;
productName = AudioPlayer;
productReference = 9806E8142BC5D12500757370 /* AudioPlayer.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
9806E80C2BC5D12500757370 /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 1530;
LastUpgradeCheck = 1530;
TargetAttributes = {
9806E8132BC5D12500757370 = {
CreatedOnToolsVersion = 15.3;
};
};
};
buildConfigurationList = 9806E80F2BC5D12500757370 /* Build configuration list for PBXProject "AudioPlayer" */;
compatibilityVersion = "Xcode 14.0";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = 9806E80B2BC5D12500757370;
productRefGroup = 9806E8152BC5D12500757370 /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
9806E8132BC5D12500757370 /* AudioPlayer */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
9806E8122BC5D12500757370 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
9806E81F2BC5D12700757370 /* Preview Assets.xcassets in Resources */,
9806E81C2BC5D12700757370 /* Assets.xcassets in Resources */,
9816A8B12BC8330C00AD1299 /* bensound-jazzyfrenchy.mp3 in Resources */,
9816A8B22BC8330C00AD1299 /* bensound-jazzyfrenchy.m4a in Resources */,
9816A8B32BC8330C00AD1299 /* hipjazz.wav in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
9806E8102BC5D12500757370 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
98BFB4232BCE78AB00E812C0 /* AddNewAudioURLView.swift in Sources */,
98BFB41D2BCD7BB800E812C0 /* EqualizerView.swift in Sources */,
98BFB41A2BC97AF800E812C0 /* DisplayLink.swift in Sources */,
9806E81A2BC5D12500757370 /* ContentView.swift in Sources */,
98E6119C2BC72C0E0036BC47 /* DetailView.swift in Sources */,
9816A8AC2BC820DF00AD1299 /* AudioContent.swift in Sources */,
9806E8262BC5D2A900757370 /* Sidebar.swift in Sources */,
984DE9552BDAE59C004B427A /* Notifier.swift in Sources */,
9806E82A2BC68F8700757370 /* AudioPlayerView.swift in Sources */,
9806E8312BC6927D00757370 /* AudioPlayerModel.swift in Sources */,
98BFB41F2BCD814000E812C0 /* EqualizerService.swift in Sources */,
9816A8AA2BC7F4F000AD1299 /* AudioTrack.swift in Sources */,
9816A8BB2BC87BC200AD1299 /* AudioPlayerService.swift in Sources */,
984DE9572BDAFC7E004B427A /* AudioPlayerControlsView.swift in Sources */,
9806E8182BC5D12500757370 /* App.swift in Sources */,
42BE42F52C9322AA00C0E448 /* CustomStreamSource.swift in Sources */,
989E08E72BF7A4E300599F17 /* PrefersTabNavigationEnvironmentKey.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin XCBuildConfiguration section */
9806E8202BC5D12700757370 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.4;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
name = Debug;
};
9806E8212BC5D12700757370 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.4;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SDKROOT = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
VALIDATE_PRODUCT = YES;
};
name = Release;
};
9806E8232BC5D12700757370 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_ASSET_PATHS = "\"AudioPlayer/Preview Content\"";
DEVELOPMENT_TEAM = TJ7GUC6B8Y;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = AudioPlayer/Info.plist;
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.decimal.AudioPlayer;
PRODUCT_NAME = "$(TARGET_NAME)";
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx";
SUPPORTS_MACCATALYST = NO;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
9806E8242BC5D12700757370 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_ASSET_PATHS = "\"AudioPlayer/Preview Content\"";
DEVELOPMENT_TEAM = TJ7GUC6B8Y;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = AudioPlayer/Info.plist;
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.decimal.AudioPlayer;
PRODUCT_NAME = "$(TARGET_NAME)";
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx";
SUPPORTS_MACCATALYST = NO;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
9806E80F2BC5D12500757370 /* Build configuration list for PBXProject "AudioPlayer" */ = {
isa = XCConfigurationList;
buildConfigurations = (
9806E8202BC5D12700757370 /* Debug */,
9806E8212BC5D12700757370 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
9806E8222BC5D12700757370 /* Build configuration list for PBXNativeTarget "AudioPlayer" */ = {
isa = XCConfigurationList;
buildConfigurations = (
9806E8232BC5D12700757370 /* Debug */,
9806E8242BC5D12700757370 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = 9806E80C2BC5D12500757370 /* Project object */;
}
@@ -0,0 +1,79 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1530"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "9806E8132BC5D12500757370"
BuildableName = "AudioPlayer.app"
BlueprintName = "AudioPlayer"
ReferencedContainer = "container:AudioPlayer.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
enableThreadSanitizer = "YES"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "9806E8132BC5D12500757370"
BuildableName = "AudioPlayer.app"
BlueprintName = "AudioPlayer"
ReferencedContainer = "container:AudioPlayer.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "9806E8132BC5D12500757370"
BuildableName = "AudioPlayer.app"
BlueprintName = "AudioPlayer"
ReferencedContainer = "container:AudioPlayer.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
+55
View File
@@ -0,0 +1,55 @@
//
// Created by Dimitris C.
// Copyright © 2024 Decimal. All rights reserved.
//
import AudioStreaming
import SwiftUI
@main
struct AudioPlayerApp: App {
@State var model = AppModel()
var body: some Scene {
WindowGroup {
ContentView()
.environment(model)
}
}
}
@Observable
class AppModel {
@ObservationIgnored
let audioPlayerService: AudioPlayerService
@ObservationIgnored
let equalizerService: EqualizerService
init(
audioPlayerService: AudioPlayerService = provideAudioPlayerService(),
equalizerService: (AudioPlayerService) -> EqualizerService = provideEqualizerService
) {
self.audioPlayerService = audioPlayerService
self.equalizerService = equalizerService(audioPlayerService)
}
}
func provideEqualizerService(playerService: AudioPlayerService) -> EqualizerService {
EqualizerService(playerService: playerService)
}
func provideAudioPlayerService() -> AudioPlayerService {
AudioPlayerService(
audioPlayerProvider: provideDefaultAudioPlayer
)
}
func provideDefaultAudioPlayer() -> AudioPlayer {
AudioPlayer(
configuration: .init(
flushQueueOnSeek: false,
enableLogs: true
)
)
}
@@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,13 @@
{
"images" : [
{
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,88 @@
//
// Created by Dimitris C.
// Copyright © 2024 Decimal. All rights reserved.
//
import SwiftUI
struct AddNewAudioURLView: View {
@Environment(\.dismiss) var dismiss
private let urlStyle = URL.FormatStyle(path: .omitWhen(.path, matches: ["/"]), query: .omitWhen(.query, matches: [""]))
@State private var audioUrl: URL?
var onAddNewUrl: (URL) -> Void
init(onAddNewUrl: @escaping (URL) -> Void) {
self.onAddNewUrl = onAddNewUrl
}
var body: some View {
NavigationStack {
VStack {
VStack(alignment: .leading) {
TextField(value: $audioUrl, format: urlStyle, prompt: nil, label: {
Text("Insert URL")
})
#if os(iOS)
.keyboardType(.URL)
#endif
.autocorrectionDisabled()
.textFieldStyle(RoundedBorderTextFieldStyle())
.onSubmit {
if let url = audioUrl {
onAddNewUrl(url)
dismiss()
}
}
}
.padding(.horizontal, 16)
Button {
if let url = audioUrl {
onAddNewUrl(url)
dismiss()
}
} label: {
HStack {
Image(systemName: "plus")
Text("Add")
}
.foregroundStyle(Color.white)
}
.buttonStyle(.plain)
.disabled(audioUrl == nil)
.opacity(audioUrl == nil ? 0.5 : 1.0)
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(.mint)
.clipShape(RoundedRectangle(cornerRadius: 16))
}
.navigationTitle("Add Audio URL")
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.toolbar {
#if os(iOS)
ToolbarItem(placement: .topBarTrailing) {
Button {
dismiss()
} label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(Color.gray)
}
.buttonStyle(.plain)
}
#else
ToolbarItem(placement: .confirmationAction) {
Button("Done", action: dismiss.callAsFunction)
}
#endif
}
}
}
}
#Preview {
AddNewAudioURLView(onAddNewUrl: { _ in })
}
@@ -0,0 +1,131 @@
//
// Created by Dimitris C.
// Copyright © 2024 Decimal. All rights reserved.
//
import Foundation
enum AudioContent {
case offradio
case enlefko
case pepper966
case kosmos
case kosmosJazz
case radiox
case khruangbin
case piano
case optimized
case nonOptimized
case remoteWave
case local
case localWave
case loopBeatFlac
case custom(String)
var title: String {
switch self {
case .offradio:
return "Offradio"
case .enlefko:
return "Enlefko"
case .pepper966:
return "Pepper 96.6"
case .kosmos:
return "Kosmos 93.6"
case .kosmosJazz:
return "Kosmos Jazz"
case .radiox:
return "Radio X"
case .khruangbin:
return "Khruangbin"
case .piano:
return "Piano"
case .remoteWave:
return "Sample remote"
case .local:
return "Jazzy Frenchy"
case .localWave:
return "Local file"
case .optimized:
return "Jazzy Frenchy"
case .nonOptimized:
return "Jazzy Frenchy"
case .loopBeatFlac:
return "Beat loop"
case .custom(let url):
return url
}
}
var subtitle: String? {
switch self {
case .offradio:
return "Stream • offradio.gr"
case .enlefko:
return "Stream • enlefko.fm"
case .pepper966:
return "Stream • pepper966.gr"
case .kosmos:
return "Stream • ertecho.gr"
case .kosmosJazz:
return "Stream • ertecho.gr"
case .radiox:
return "Stream • globalplayer.com"
case .khruangbin:
return "Remote mp3"
case .piano:
return "Remote mp3"
case .remoteWave:
return "wave"
case .local:
return "Music by: bensound.com"
case .localWave:
return "Music by: bensound.com"
case .optimized:
return "Music by: bensound.com - m4a optimized"
case .nonOptimized:
return "Music by: bensound.com - m4a non-optimized"
case .loopBeatFlac:
return "Remote flac"
case .custom:
return ""
}
}
var streamUrl: URL {
switch self {
case .enlefko:
return URL(string: "https://stream.radiojar.com/srzwv225e3quv")!
case .offradio:
return URL(string: "https://s3.yesstreaming.net:17062/stream")!
case .pepper966:
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 .kosmosJazz:
return URL(string: "https://radiostreaming.ert.gr/ert-webjazz")!
case .radiox:
return URL(string: "https://media-ssl.musicradio.com/RadioXLondon")!
case .khruangbin:
return URL(string: "https://p.scdn.co/mp3-preview/cab4b09c23ffc11774d879977131df9d150fcef4?cid=d8a5ed958d274c2e8ee717e6a4b0971d")!
case .piano:
return URL(string: "https://www.kozco.com/tech/piano2-CoolEdit.mp3")!
case .optimized:
return URL(string: "https://github.com/dimitris-c/sample-audio/raw/main/bensound-jazzyfrenchy-optimized.m4a")!
case .nonOptimized:
return URL(string: "https://github.com/dimitris-c/sample-audio/raw/main/bensound-jazzyfrenchy.m4a")!
case .local:
let path = Bundle.main.path(forResource: "bensound-jazzyfrenchy", ofType: "mp3")!
return URL(fileURLWithPath: path)
case .localWave:
let path = Bundle.main.path(forResource: "hipjazz", ofType: "wav")!
return URL(fileURLWithPath: path)
case .remoteWave:
return URL(string: "https://github.com/dimitris-c/sample-audio/raw/main/5-MB-WAV.wav")!
case .loopBeatFlac:
return URL(string: "https://github.com/dimitris-c/sample-audio/raw/main/drumbeat-loop.flac")!
case .custom(let url):
return URL(string: url)!
}
}
}
@@ -0,0 +1,120 @@
//
// Created by Dimitris C.
// Copyright © 2024 Decimal. All rights reserved.
//
import SwiftUI
enum AudioTrackStatus {
case playing
case paused
case buffering
case error
case idle
var isPlaying: Bool {
self == .playing || self == .paused
}
var isError: Bool {
self == .error
}
}
@Observable
public class AudioTrack: Identifiable, Equatable {
public static func == (lhs: AudioTrack, rhs: AudioTrack) -> Bool {
lhs.id == rhs.id
}
public var id: String {
url.absoluteString
}
let title: String
let subtitle: String?
let url: URL
var status: AudioTrackStatus
private let content: AudioContent
init(from content: AudioContent, status: AudioTrackStatus = .idle) {
self.title = content.title
self.subtitle = content.subtitle
self.status = status
self.url = content.streamUrl
self.content = content
}
}
struct AudioTrackView: View {
@Bindable var track: AudioTrack
private let action: () -> Void
init(track: AudioTrack, action: @escaping () -> Void = {}) {
self.track = track
self.action = action
}
var body: some View {
Button(action: action, label: {
HStack {
VStack(alignment: .leading) {
Text(track.title)
.font(.headline)
.fontWeight(.medium)
.foregroundStyle(.black)
.padding(.top, 8)
.padding(.bottom, track.subtitle == nil ? 8 : 0)
if let subtitle = track.subtitle {
Text(subtitle)
.font(.subheadline)
.fontWeight(.regular)
.foregroundStyle(Color.gray)
}
}
Spacer()
status
}
})
}
@ViewBuilder var status: some View {
if track.status == .error {
Image(systemName: "exclamationmark.circle")
.font(.headline)
.foregroundStyle(.red)
} else {
if track.status.isPlaying {
Image(systemName: track.status == .playing ? "play.fill" : "pause.fill")
.font(.headline)
.foregroundStyle(.mint)
} else if track.status == .buffering {
ProgressView()
.progressViewStyle(.circular)
.frame(alignment: .center)
.scaleEffect(0.7)
}
}
}
}
#Preview {
List {
AudioTrackView(
track: AudioTrack(from: .enlefko)
)
AudioTrackView(
track: AudioTrack(from: .enlefko, status: .playing)
)
AudioTrackView(
track: AudioTrack(from: .enlefko, status: .paused)
)
AudioTrackView(
track: AudioTrack(from: .enlefko, status: .error)
)
}
}
@@ -0,0 +1,331 @@
//
// Created by Dimitris Chatzieleftheriou on 26/04/2024.
//
import AVFoundation
import SwiftUI
import AudioStreaming
struct AudioPlayerControls: View {
@State var model: Model
@Binding var currentTrack: AudioTrack?
init(appModel: AppModel, currentTrack: Binding<AudioTrack?>) {
self._model = State(wrappedValue: Model(audioPlayerService: appModel.audioPlayerService))
self._currentTrack = currentTrack
}
var body: some View {
VStack(alignment: .leading) {
HStack {
Button(action: { model.playPause() }) {
Image(systemName: model.isPlaying ? "pause" : "play")
.symbolVariant(.fill)
.font(.title)
.imageScale(.small)
}
.buttonStyle(.plain)
.contentTransition(.symbolEffect(.replace))
Button(action: {
model.stop()
currentTrack = nil
}) {
Image(systemName: "stop")
.symbolVariant(.fill)
.font(.title)
.imageScale(.small)
}
.buttonStyle(.plain)
.padding(.leading, 8)
Spacer()
HStack {
Slider(value: $model.volume)
.frame(width: 80)
.onChange(of: model.volume) { _, newValue in
model.update(volume: newValue)
}
Button(action: { model.mute() }) {
Image(systemName: model.iconForVolume)
.symbolVariant(model.isMuted || model.volume == 0 ? .slash.fill : .fill)
.foregroundStyle(.teal, .gray)
.font(.title.monospaced())
.imageScale(.small)
}
.buttonStyle(.plain)
.frame(width: 20, height: 20)
}
}
.tint(.mint)
.padding(16)
if let audioMetadata = model.liveAudioMetadata, model.isLiveAudioStreaming {
Text("Now Playing: \(audioMetadata)")
.font(.caption)
.foregroundStyle(.black)
.padding(.horizontal, 16)
}
Divider()
VStack {
Slider(
value: $model.currentTime,
in: 0...(model.totalTime ?? 1.0),
onEditingChanged: { scrubStarted in
if scrubStarted {
model.scrubState = .started
} else {
model.scrubState = .ended(model.currentTime)
}
}
)
.disabled(model.totalTime == nil)
HStack {
Text(model.formattedCurrentTime ?? "--:--")
Spacer()
Text(model.formattedTotalTime ?? "")
}
.foregroundStyle(.black)
.font(.caption)
.fontWeight(.medium)
}
.padding(.bottom, 8)
.padding(.horizontal, 16)
Divider()
VStack(alignment: .leading) {
Text("Playback Rate: \(String(format: "%0.1f", model.playbackRate))")
.font(.subheadline)
.fontWeight(.medium)
.foregroundStyle(.black)
Slider(value: $model.playbackRate, in: 1.0...4.0, step: 0.2)
.onChange(of: model.playbackRate) { _, new in
model.update(rate: Float(new))
}
}
.padding(.bottom, 8)
.padding(.horizontal, 16)
}
.onChange(of: currentTrack) { oldValue, newValue in
if let track = newValue {
model.play(track)
}
}
}
}
enum ScrubState: Equatable {
case idle
case started
case ended(Double)
}
extension AudioPlayerControls {
@Observable
final class Model {
@ObservationIgnored
private(set) var audioPlayerService: AudioPlayerService
@ObservationIgnored
private var displayLink: DisplayLink?
var isLiveAudioStreaming: Bool {
totalTime == 0
}
var liveAudioMetadata: String?
var isPlaying: Bool = false
var isMuted: Bool = false
var volume: Float = 0.5
var playbackRate: Double = 0.0
var currentTime: Double = 0
var totalTime: Double?
var scrubState: ScrubState = .idle
var formattedCurrentTime: String?
var formattedTotalTime: String?
var currentTrack: AudioTrack?
var iconForVolume: String {
if isMuted || volume == 0 {
return "speaker"
}
if volume < 0.4 {
return "speaker.wave.1"
} else if volume < 0.8 {
return "speaker.wave.2"
} else {
return "speaker.wave.3"
}
}
init(audioPlayerService: AudioPlayerService) {
self.audioPlayerService = audioPlayerService
registerObservations()
}
deinit {
displayLink?.deactivate()
displayLink = nil
}
func registerObservations() {
Task { @MainActor in
for await status in await audioPlayerService.statusChangedNotifier.values() {
isPlaying = status == .playing
displayLink?.isPaused = !isPlaying
switch status {
case .bufferring:
currentTrack?.status = .buffering
case .error:
currentTrack?.status = .error
currentTrack = nil
case .playing:
currentTrack?.status = .playing
case .paused:
currentTrack?.status = .paused
case .stopped:
currentTrack?.status = .idle
default:
currentTrack?.status = .idle
}
}
}
Task { @MainActor in
for await metadata in await audioPlayerService.metadataReceivedNotifier.values() {
guard !metadata.isEmpty else { break }
if let title = metadata["StreamTitle"] {
liveAudioMetadata = title.isEmpty ? "-" : title
} else {
liveAudioMetadata = nil
}
}
}
Task { @MainActor in
for await startStopped in await audioPlayerService.playingStartedStopped.values() {
if startStopped.started {
self.didStartPlaying()
} else {
self.didStopPlaying()
}
}
}
}
func mute() {
isMuted.toggle()
audioPlayerService.toggleMute()
}
func playPause() {
if audioPlayerService.state == .playing {
audioPlayerService.pause()
} else {
audioPlayerService.resume()
}
}
func update(rate: Float) {
let rate = round(rate / 0.2) * 0.2
audioPlayerService.update(rate: rate)
}
func update(volume: Float) {
audioPlayerService.update(volume: volume)
}
func stop() {
isPlaying = false
audioPlayerService.stop()
currentTrack?.status = .idle
currentTrack = nil
}
func play(_ track: AudioTrack) {
if track != currentTrack {
currentTrack?.status = .idle
if track.url.scheme == "custom" {
let source = createStreamSource()
let audioFormat = AVAudioFormat(
commonFormat: .pcmFormatFloat32, sampleRate: 44100, channels: 2, interleaved: false
)!
audioPlayerService.play(source: source, entryId: track.url.absoluteString, format: audioFormat)
currentTrack = track
} else {
audioPlayerService.play(url: track.url)
}
}
}
func createStreamSource() -> CoreAudioStreamSource {
return CustomStreamAudioSource(underlyingQueue: audioPlayerService.player.sourceQueue)
}
func onTick() {
let duration = audioPlayerService.duration
let progress = audioPlayerService.progress
if duration > 0 {
let elapsed = Int(progress)
let remaining = Int(duration - progress)
totalTime = duration
switch scrubState {
case .idle:
currentTime = progress
case .started:
break
case .ended(let seekTime):
currentTime = seekTime
if audioPlayerService.duration > 0 {
audioPlayerService.seek(at: seekTime)
}
scrubState = .idle
}
formattedCurrentTime = timeFrom(seconds: Int(elapsed))
formattedTotalTime = timeFrom(seconds: remaining)
} else {
let elapsed = Int(progress)
formattedCurrentTime = timeFrom(seconds: Int(elapsed))
if formattedTotalTime != nil {
formattedTotalTime = nil
}
}
}
func resetLabels() {
currentTime = 0
totalTime = 0
formattedCurrentTime = nil
formattedTotalTime = nil
}
private func timeFrom(seconds: Int) -> String {
let correctSeconds = seconds % 60
let minutes = (seconds / 60) % 60
let hours = seconds / 3600
if hours > 0 {
return String(format: "%02d:%02d:%02d", hours, minutes, correctSeconds)
}
return String(format: "%02d:%02d", minutes, correctSeconds)
}
private func didStartPlaying() {
self.displayLink = DisplayLink(onTick: { [weak self] _ in
self?.onTick()
})
displayLink?.activate()
}
private func didStopPlaying() {
resetLabels()
liveAudioMetadata = nil
playbackRate = 1.0
displayLink?.deactivate()
}
}
}
@@ -0,0 +1,76 @@
//
// Created by Dimitris C.
// Copyright © 2024 Decimal. All rights reserved.
//
#if os(iOS)
import UIKit
#else
import AppKit
#endif
import Foundation
import AudioStreaming
struct AudioPlaylist: Equatable, Identifiable {
var id: String { title }
let title: String
var tracks: [AudioTrack]
}
@Observable
public class AudioPlayerModel {
@ObservationIgnored
private(set) var audioPlayerService: AudioPlayerService
var audioTracks: [AudioPlaylist] = []
var currentTrack: AudioTrack?
init(audioTracksProvider: () -> [AudioPlaylist] = audioTracksProvider, audioPlayerService: AudioPlayerService) {
self.audioPlayerService = audioPlayerService
self.audioTracks = audioTracksProvider()
}
deinit {
audioPlayerService.stop()
}
func addNewAudioTrack(url: URL) {
let customIndex = audioTracks.firstIndex(where: { $0.id == "Custom" })
let audioTrack = AudioTrack(from: .custom(url.absoluteString), status: .idle)
let playlist = AudioPlaylist(title: "Custom", tracks: [audioTrack])
if let customIndex {
let tracks = audioTracks[customIndex].tracks
if !tracks.contains(audioTrack) {
audioTracks[customIndex].tracks.append(audioTrack)
}
} else {
audioTracks.append(playlist)
}
}
func play(_ track: AudioTrack) {
if track != currentTrack {
currentTrack = track
}
}
}
private let radioTracks: [AudioContent] = [.offradio, .enlefko, .pepper966, .kosmos, .kosmosJazz, .radiox]
private let audioTracks: [AudioContent] = [.khruangbin, .piano, .optimized, .nonOptimized, .remoteWave, .local, .localWave, .loopBeatFlac]
private let customStreams: [AudioContent] = [.custom("custom://sinwave")]
func audioTracksProvider() -> [AudioPlaylist] {
[
AudioPlaylist(title: "Radio", tracks: radioTracks.map { AudioTrack.init(from: $0) }),
AudioPlaylist(title: "Tracks", tracks: audioTracks.map { AudioTrack.init(from:$0) }),
AudioPlaylist(title: "Generated", tracks: customStreams.map { AudioTrack.init(from:$0) })
]
}
func audioQueueTrackProvider() -> [AudioPlaylist] {
[
AudioPlaylist(title: "Tracks", tracks: audioTracks.map { AudioTrack.init(from:$0) })
]
}
@@ -0,0 +1,103 @@
//
// Created by Dimitris C.
// Copyright © 2024 Decimal. All rights reserved.
//
import SwiftUI
struct AudioPlayerView: View {
@Environment(AppModel.self) var appModel
@State var model: AudioPlayerModel
@State var eqSheetIsShown: Bool = false
@State var addNewAudioIsShown: Bool = false
init(appModel: AppModel) {
self._model = State(wrappedValue: AudioPlayerModel(audioPlayerService: appModel.audioPlayerService))
}
var body: some View {
ScrollViewReader { proxy in
List {
ForEach(model.audioTracks) { section in
Section {
ForEach(section.tracks) { track in
AudioTrackView(track: track) {
model.play(track)
}
.id(track.id)
}
} header: {
Text(section.title)
}
}
}
.onChange(of: model.audioTracks) { _, newValue in
if let lastId = newValue.last?.tracks.last?.id {
withAnimation {
proxy.scrollTo(lastId, anchor: .bottom)
}
}
}
}
.safeAreaInset(edge: .bottom) {
AudioPlayerControls(appModel: appModel, currentTrack: $model.currentTrack)
.background(
.ultraThinMaterial.shadow(
ShadowStyle.drop(color: .black.opacity(0.1), radius: 8, x: 0, y: -10)
)
)
}
.ignoresSafeArea(.keyboard, edges: .bottom)
.navigationTitle("Audio Player")
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.toolbar {
#if os(iOS)
let placement: ToolbarItemPlacement = .topBarTrailing
#else
let placement: ToolbarItemPlacement = .automatic
#endif
ToolbarItemGroup(placement: placement) {
Button {
eqSheetIsShown.toggle()
} label: {
Image(systemName: "slider.horizontal.3")
}
.buttonStyle(.plain)
Button {
addNewAudioIsShown.toggle()
} label: {
Image(systemName: "plus")
}
.buttonStyle(.plain)
}
}
.sheet(isPresented: $eqSheetIsShown) {
EqualizerView(appModel: appModel)
#if os(iOS)
.presentationDetents([.medium])
#elseif os(macOS)
.frame(minWidth: 520, maxWidth: .infinity, minHeight: 400, maxHeight: .infinity)
#endif
}
.sheet(isPresented: $addNewAudioIsShown) {
AddNewAudioURLView(
onAddNewUrl: { url in
model.addNewAudioTrack(url: url)
}
)
#if os(iOS)
.presentationDetents([.height(150)])
#elseif os(macOS)
.frame(minWidth: 320, maxWidth: .infinity, minHeight: 140, maxHeight: .infinity)
#endif
}
}
}
#Preview {
AudioPlayerView(appModel: AppModel())
}
@@ -0,0 +1,139 @@
//
// CustomStreamSource.swift
// AudioPlayer
//
// Created by Jackson Harper on 12/9/24.
//
import AVFoundation
import Foundation
import AudioStreaming
// This is a basic example of playing a custom audio stream. We generate
// a small audio data on load and then pass it off to AudioStreaming.
final class CustomStreamAudioSource: NSObject, CoreAudioStreamSource {
weak var delegate: AudioStreamSourceDelegate?
var underlyingQueue: DispatchQueue
var position = 0
var length = 0
var audioFileHint: AudioFileTypeID {
kAudioFileWAVEType
}
init(underlyingQueue: DispatchQueue) {
self.underlyingQueue = underlyingQueue
}
// no-op
func close() {}
// no-op
func suspend() {}
func resume() {}
func seek(at _: Int) {
// The streaming process is started by a seek(0) call from AudioStreaming
generateData()
}
private func generateData() {
let frequency = 440.0
let sampleRate = 44100
let duration = 20.0
let lpcmData = generateSineWave(frequency: frequency, sampleRate: sampleRate, duration: duration)
let waveFile = createWavFile(using: lpcmData)
// We enqueue this because during startup the seek call will be made, but the player
// is not completely setup and ready to handle data yet, as its expected to be
// generated asyncronously.
underlyingQueue.asyncAfter(deadline: .now().advanced(by: .milliseconds(100))) {
self.delegate?.dataAvailable(source: self, data: waveFile)
}
}
}
// Functions for generating some sample data
// Function to generate a sine wave as Data
func generateSineWave(frequency: Double, sampleRate: Int, duration: Double, amplitude: Double = 0.5) -> Data {
let numberOfSamples = Int(Double(sampleRate) * duration)
let twoPi = 2.0 * Double.pi
var lpcmData = Data()
for sampleIndex in 0 ..< numberOfSamples {
let time = Double(sampleIndex) / Double(sampleRate)
let sampleValue = amplitude * sin(twoPi * frequency * time)
let pcmValue = Int16(sampleValue * Double(Int16.max))
withUnsafeBytes(of: pcmValue.littleEndian) { lpcmData.append(contentsOf: $0) }
}
return lpcmData
}
func createWavFile(using rawData: Data) -> Data {
let waveHeaderFormate = createWaveHeader(data: rawData) as Data
let waveFileData = waveHeaderFormate + rawData
return waveFileData
}
// from: https://stackoverflow.com/questions/49399823/in-ios-how-to-create-audio-file-wav-mp3-file-from-data
private func createWaveHeader(data: Data) -> NSData {
let sampleRate: Int32 = 44100
let chunkSize: Int32 = 36 + Int32(data.count)
let subChunkSize: Int32 = 16
let format: Int16 = 1
let channels: Int16 = 2
let bitsPerSample: Int16 = 16
let byteRate: Int32 = sampleRate * Int32(channels * bitsPerSample / 8)
let blockAlign: Int16 = channels * bitsPerSample / 8
let dataSize = Int32(data.count)
let header = NSMutableData()
header.append([UInt8]("RIFF".utf8), length: 4)
header.append(intToByteArray(chunkSize), length: 4)
// WAVE
header.append([UInt8]("WAVE".utf8), length: 4)
// FMT
header.append([UInt8]("fmt ".utf8), length: 4)
header.append(intToByteArray(subChunkSize), length: 4)
header.append(shortToByteArray(format), length: 2)
header.append(shortToByteArray(channels), length: 2)
header.append(intToByteArray(sampleRate), length: 4)
header.append(intToByteArray(byteRate), length: 4)
header.append(shortToByteArray(blockAlign), length: 2)
header.append(shortToByteArray(bitsPerSample), length: 2)
header.append([UInt8]("data".utf8), length: 4)
header.append(intToByteArray(dataSize), length: 4)
return header
}
private func intToByteArray(_ i: Int32) -> [UInt8] {
return [
// little endian
UInt8(truncatingIfNeeded: i & 0xFF),
UInt8(truncatingIfNeeded: (i >> 8) & 0xFF),
UInt8(truncatingIfNeeded: (i >> 16) & 0xFF),
UInt8(truncatingIfNeeded: (i >> 24) & 0xFF),
]
}
private func shortToByteArray(_ i: Int16) -> [UInt8] {
return [
// little endian
UInt8(truncatingIfNeeded: i & 0xFF),
UInt8(truncatingIfNeeded: (i >> 8) & 0xFF),
]
}
@@ -0,0 +1,335 @@
//
// Created by Dimitris C.
// Copyright © 2024 Decimal. All rights reserved.
//
import SwiftUI
@Observable
class EQBand: Identifiable {
var frequency: String
var min: Float
var max: Float
var value: Float
@ObservationIgnored
let index: Int
init(index: Int, frequency: String, min: Float, max: Float, value: Float) {
self.index = index
self.frequency = frequency
self.min = min
self.max = max
self.value = value
}
}
struct EqualizerView: View {
@Environment(\.dismiss) var dismiss
@Environment(AppModel.self) var appModel
@State var model: Model
init(appModel: AppModel) {
self._model = State(wrappedValue: Model(equalizerService: appModel.equalizerService))
}
var body: some View {
NavigationStack {
VStack(spacing: 16) {
EQSliderView()
.frame(height: 180)
.padding(.horizontal, 16)
.environment(model)
HStack(alignment: .center, spacing: 16) {
Button {
withAnimation {
model.isEnabled.toggle()
model.enable()
}
} label: {
HStack {
Image(systemName: model.isEnabled ? "waveform.slash" : "waveform")
.contentTransition(.symbolEffect(.replace))
Text(model.isEnabled ? "Disable": "Enable")
.font(.body)
}
.foregroundStyle(Color.white)
}
.padding(.horizontal, 16)
.padding(.vertical, 16)
.background(model.isEnabled ? .red : .mint)
.clipShape(RoundedRectangle(cornerRadius: 16))
Button {
model.reset()
} label: {
HStack {
Text("Reset")
.font(.body)
}
.foregroundStyle(Color.white)
}
.padding(.horizontal, 16)
.padding(.vertical, 16)
.background(.mint)
.clipShape(RoundedRectangle(cornerRadius: 16))
}
.padding(.top, 24)
}
.task {
Task {
model.generateBands()
}
}
.navigationTitle("Equalizer")
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.toolbar {
#if os(iOS)
ToolbarItem(placement: .topBarTrailing) {
Button {
dismiss()
} label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(Color.gray)
}
}
#else
ToolbarItem(placement: .confirmationAction) {
Button("Done", action: dismiss.callAsFunction)
}
#endif
}
}
}
}
struct EQSliderView: View {
@Environment(EqualizerView.Model.self) var eqModel
@State private var dragPointYLocations: [CGFloat] = Array(repeating: .zero, count: 6)
@State private var resetPoints: [Double] = Array(repeating: .zero, count: 6)
@State private var eqViewFrame: CGRect = .zero
var body: some View {
VStack(spacing: 0) {
HStack(spacing: 0) {
// Draw labels for gain values
VStack {
Text("\(Int(eqModel.maxGain))db")
.font(.caption2)
.foregroundColor(.black)
Spacer()
Text("0dB")
.font(.caption2)
.foregroundColor(.black)
Spacer()
Text("\(Int(eqModel.minGain))db")
.font(.caption2)
.foregroundColor(.black)
}
GeometryReader { innerGeo in
ZStack {
LineShape(values: eqModel.shouldReset ? resetPoints : dragPointYLocations.map { Double($0) })
.stroke(Color.mint, lineWidth: 2)
.animation(.easeInOut(duration: 0.2), value: eqModel.shouldReset)
.onAppear {
resetPoints = resetPoints.map { _ in Double(gainToYPosition(at: 0, in: innerGeo.size)) }
}
Path { path in
for index in 0..<dragPointYLocations.count {
let x = positionForDragPoint(at: index, size: innerGeo.size)
path.move(to: CGPoint(x: x, y: 0))
path.addLine(to: CGPoint(x: x, y: innerGeo.size.height))
}
path.move(to: CGPoint(x: 0, y: innerGeo.size.height / 2))
path.addLine(to: CGPoint(x: innerGeo.size.width, y: innerGeo.size.height / 2))
}
.stroke(Color.gray.opacity(0.5), lineWidth: 1)
ForEach(eqModel.bands) { band in
Circle()
.fill(Color.mint)
.frame(width: 20, height: 20)
.position(x: positionForDragPoint(at: band.index, size: innerGeo.size), y: dragPointYLocations[band.index])
.gesture(
DragGesture()
.onChanged { value in
let newY = min(max(value.location.y, 0), innerGeo.size.height)
dragPointYLocations[band.index] = newY
updateGainValue(at: band.index, in: innerGeo.size)
}
)
.onAppear {
dragPointYLocations[band.index] = gainToYPosition(at: band.value, in: innerGeo.size)
}
.onChange(of: eqModel.shouldReset) { _, reset in
if reset {
resetPositions(in: innerGeo.size)
}
}
}
}
ForEach(eqModel.bands) { band in
Text(band.frequency)
.position(x: positionForDragPoint(at: band.index, size: innerGeo.size), y: innerGeo.size.height + 8)
.font(.caption)
.foregroundColor(.black)
}
}
}
}
}
func positionForDragPoint(at index: Int, size: CGSize) -> CGFloat {
size.width / 12 * CGFloat(index * 2 + 1)
}
func updateGainValue(at index: Int, in size: CGSize) {
let percentage = dragPointYLocations[index] / size.height
let gain = (1 - Float(percentage)) * (eqModel.maxGain - eqModel.minGain) + eqModel.minGain
eqModel.update(gain: gain, index: index)
}
func gainToYPosition(at gain: Float, in size: CGSize) -> CGFloat {
let percentage = 1 - (gain - eqModel.minGain) / (eqModel.maxGain - eqModel.minGain)
return CGFloat(percentage) * size.height
}
func resetPositions(in size: CGSize) {
let reset = dragPointYLocations.map { _ in gainToYPosition(at: 0, in: size) }
withAnimation(.easeInOut(duration: 0.2)) {
dragPointYLocations = reset
}
}
}
extension EqualizerView {
@Observable
class Model {
@ObservationIgnored
private let equalizerService: EqualizerService
var dragPointYLocations: [CGFloat] = Array(repeating: .zero, count: 6)
var isEnabled: Bool = false
var bands: [EQBand] = []
let minGain: Float = -12
let maxGain: Float = 12
var shouldReset: Bool = false
init(equalizerService: EqualizerService) {
self.equalizerService = equalizerService
isEnabled = equalizerService.isActivated
}
func generateBands() {
bands = equalizerService.bands.enumerated().map { index, item in
var measurement = item.frequency
var frequency = String(Int(measurement))
if item.frequency >= 1000 {
measurement = item.frequency / 1000
frequency = "\(String(Int(measurement)))K"
}
return EQBand(index: index, frequency: frequency, min: minGain, max: maxGain, value: item.gain)
}
}
func enable() {
if isEnabled {
equalizerService.activate()
} else {
equalizerService.deactivate()
}
}
func update(gain: Float, index: Int) {
shouldReset = false
bands[index].value = gain
equalizerService.update(gain: gain, for: index)
}
func reset() {
guard !shouldReset else {
return
}
shouldReset = true
equalizerService.reset()
for band in bands {
band.value = 0.0
}
}
}
}
struct LineShape: Shape {
var values: [Double]
var animatableData: AnimatableLine {
get { AnimatableLine(values: values) }
set { values = newValue.values }
}
func path(in rect: CGRect) -> Path {
var path = Path()
path.move(to: CGPoint(x: rect.size.width / 12, y: values.first ?? 0))
for index in 1..<values.count {
let x = positionForDragPoint(at: index, size: rect.size)
let y = values[index]
path.addLine(to: CGPoint(x: x, y: y))
}
return path
}
func positionForDragPoint(at index: Int, size: CGSize) -> CGFloat {
size.width / 12 * CGFloat(index * 2 + 1)
}
}
struct AnimatableLine : VectorArithmetic {
var values: [Double]
var magnitudeSquared: Double {
return values.map { $0 * $0 }.reduce(0, +)
}
mutating func scale(by rhs: Double) {
values = values.map { $0 * rhs }
}
static var zero: AnimatableLine {
return AnimatableLine(values: [0.0])
}
static func - (lhs: AnimatableLine, rhs: AnimatableLine) -> AnimatableLine {
return AnimatableLine(values: zip(lhs.values, rhs.values).map(-))
}
static func -= (lhs: inout AnimatableLine, rhs: AnimatableLine) {
lhs = lhs - rhs
}
static func + (lhs: AnimatableLine, rhs: AnimatableLine) -> AnimatableLine {
return AnimatableLine(values: zip(lhs.values, rhs.values).map(+))
}
static func += (lhs: inout AnimatableLine, rhs: AnimatableLine) {
lhs = lhs + rhs
}
}
#Preview {
EqualizerView(appModel: AppModel())
}
@@ -1,26 +1,23 @@
//
// AudioPlayerService.swift
// AudioExample
//
// Created by Dimitrios Chatzieleftheriou on 14/11/2020.
// Copyright © 2020 Dimitrios Chatzieleftheriou. All rights reserved.
// Created by Dimitris C.
// Copyright © 2024 Decimal. All rights reserved.
//
import AudioStreaming
import AVFoundation
protocol AudioPlayerServiceDelegate: AnyObject {
func didStartPlaying()
func didStopPlaying()
func didStartPlaying(id: AudioEntryId)
func didStopPlaying(id: AudioEntryId, reason: AudioPlayerStopReason)
func statusChanged(status: AudioPlayerState)
func errorOccured(error: AudioPlayerError)
func errorOccurred(error: AudioPlayerError)
func metadataReceived(metadata: [String: String])
}
final class AudioPlayerService {
var delegate = MulticastDelegate<AudioPlayerServiceDelegate>()
weak var delegate: AudioPlayerServiceDelegate?
private var player: AudioPlayer
var player: AudioPlayer
private var audioSystemResetObserver: Any?
var duration: Double {
@@ -43,8 +40,15 @@ final class AudioPlayerService {
player.state
}
init() {
player = AudioPlayer(configuration: .init(enableLogs: true))
var statusChangedNotifier = Notifier<AudioPlayerState>()
var metadataReceivedNotifier = Notifier<[String: String]>()
var playingStartedStopped = Notifier<(started: Bool, AudioEntryId, AudioPlayerStopReason?)>()
private let audioPlayerProvider: () -> AudioPlayer
init(audioPlayerProvider: @escaping () -> AudioPlayer) {
self.audioPlayerProvider = audioPlayerProvider
player = audioPlayerProvider()
player.delegate = self
configureAudioSession()
@@ -56,6 +60,11 @@ final class AudioPlayerService {
player.play(url: url)
}
func play(source: CoreAudioStreamSource, entryId: String, format: AVAudioFormat) {
activateAudioSession()
player.play(source: source, entryId: entryId, format: format)
}
func queue(url: URL) {
activateAudioSession()
player.queue(url: url)
@@ -82,6 +91,10 @@ final class AudioPlayerService {
player.rate = rate
}
func update(volume: Float) {
player.volume = volume
}
func add(_ node: AVAudioNode) {
player.attach(node: node)
}
@@ -98,26 +111,31 @@ final class AudioPlayerService {
}
}
func seek(at time: Float) {
player.seek(to: Double(time))
func seek(at time: Double) {
player.seek(to: time)
}
private func recreatePlayer() {
player = AudioPlayer(configuration: .init(enableLogs: true))
player = audioPlayerProvider()
player.delegate = self
}
private func registerSessionEvents() {
// Note that a real app might need to observer other AVAudioSession notifications as well
audioSystemResetObserver = NotificationCenter.default.addObserver(forName: AVAudioSession.mediaServicesWereResetNotification,
object: nil,
queue: nil) { [unowned self] _ in
#if os(iOS)
audioSystemResetObserver = NotificationCenter.default.addObserver(
forName: AVAudioSession.mediaServicesWereResetNotification,
object: nil,
queue: nil
) { [unowned self] _ in
self.configureAudioSession()
self.recreatePlayer()
}
#endif
}
private func configureAudioSession() {
#if os(iOS)
do {
print("AudioSession category is AVAudioSessionCategoryPlayback")
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, policy: .longFormAudio, options: [])
@@ -125,9 +143,11 @@ final class AudioPlayerService {
} catch let error as NSError {
print("Couldn't setup audio session category to Playback \(error.localizedDescription)")
}
#endif
}
private func activateAudioSession() {
#if os(iOS)
do {
print("AudioSession is active")
try AVAudioSession.sharedInstance().setActive(true, options: [])
@@ -135,45 +155,55 @@ final class AudioPlayerService {
} catch let error as NSError {
print("Couldn't set audio session to active: \(error.localizedDescription)")
}
#endif
}
private func deactivateAudioSession() {
#if os(iOS)
do {
print("AudioSession is deactivated")
try AVAudioSession.sharedInstance().setActive(false)
} catch let error as NSError {
print("Couldn't deactivate audio session: \(error.localizedDescription)")
}
#endif
}
}
extension AudioPlayerService: AudioPlayerDelegate {
func audioPlayerDidStartPlaying(player _: AudioPlayer, with _: AudioEntryId) {
delegate.invoke(invocation: { $0.didStartPlaying() })
func audioPlayerDidStartPlaying(player _: AudioPlayer, with id: AudioEntryId) {
print("audioPlayerDidStartPlaying entryId: \(id)")
delegate?.didStartPlaying(id: id)
Task { await playingStartedStopped.send((true, id, nil)) }
}
func audioPlayerDidFinishBuffering(player _: AudioPlayer, with _: AudioEntryId) {}
func audioPlayerStateChanged(player _: AudioPlayer, with newState: AudioPlayerState, previous _: AudioPlayerState) {
delegate.invoke(invocation: { $0.statusChanged(status: newState) })
print("audioPlayerDidStartPlaying newState: \(newState)")
Task { await statusChangedNotifier.send(newState) }
delegate?.statusChanged(status: newState)
}
func audioPlayerDidFinishPlaying(player _: AudioPlayer,
entryId _: AudioEntryId,
stopReason _: AudioPlayerStopReason,
entryId id: AudioEntryId,
stopReason reason: AudioPlayerStopReason,
progress _: Double,
duration _: Double)
{
delegate.invoke(invocation: { $0.didStopPlaying() })
print("audioPlayerDidFinishPlaying entryId: \(id), reason: \(reason)")
Task { await playingStartedStopped.send((false, id, reason)) }
delegate?.didStopPlaying(id: id, reason: reason)
}
func audioPlayerUnexpectedError(player _: AudioPlayer, error: AudioPlayerError) {
delegate.invoke(invocation: { $0.errorOccured(error: error) })
delegate?.errorOccurred(error: error)
}
func audioPlayerDidCancel(player _: AudioPlayer, queuedItems _: [AudioEntryId]) {}
func audioPlayerDidReadMetadata(player _: AudioPlayer, metadata: [String: String]) {
delegate.invoke(invocation: { $0.metadataReceived(metadata: metadata) })
Task { await metadataReceivedNotifier.send(metadata) }
delegate?.metadataReceived(metadata: metadata)
}
}
@@ -1,16 +1,13 @@
//
// EqualizerService.swift
// AudioExample
//
// Created by Dimitrios Chatzieleftheriou on 15/11/2020.
// Copyright © 2020 Dimitrios Chatzieleftheriou. All rights reserved.
// Created by Dimitris C.
// Copyright © 2024 Decimal. All rights reserved.
//
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 = [60, 150, 400, 1000, 2400, 15000]
private let eqUnit: AVAudioUnitEQ
var bands: [AVAudioUnitEQFilterParameters] {
@@ -23,7 +20,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 +42,7 @@ final class EqualizerService {
playerService.add(eqUnit)
}
func deactive() {
func deactivate() {
isActivated = false
playerService.remove(eqUnit)
}
@@ -0,0 +1,138 @@
#if os(iOS)
import UIKit
#else
import AppKit
#endif
final class DisplayLink {
private var displayLink: DisplayLinkPlatform?
var isPaused: Bool = true {
didSet {
displayLink?.isPaused = isPaused
}
}
init(onTick: @escaping (DisplayLinkFrame) -> Void) {
displayLink = DisplayLinkPlatform()
displayLink?.onTick = onTick
}
deinit {
deactivate()
}
func activate() {
displayLink?.activate()
self.isPaused = false
}
func deactivate() {
displayLink?.deactivate()
isPaused = true
}
}
struct DisplayLinkFrame {
var timestamp: TimeInterval
var duration: TimeInterval
}
#if os(iOS)
final class DisplayLinkPlatform {
private final class DisplayLinkTarget {
var onTick: ((DisplayLinkFrame) -> Void)?
@objc func tick(_ link: CADisplayLink) {
onTick?(DisplayLinkFrame(timestamp: link.timestamp, duration: link.duration))
}
}
var onTick: ((DisplayLinkFrame) -> Void)?
private var target = DisplayLinkTarget()
var displayLink: CADisplayLink?
var isPaused: Bool {
get { displayLink?.isPaused ?? false }
set { displayLink?.isPaused = newValue }
}
init() {
displayLink = CADisplayLink(target: target, selector: #selector(DisplayLinkTarget.tick(_:)))
target.onTick = { [weak self] value in
self?.onTick?(value)
}
}
deinit {
displayLink?.invalidate()
}
func activate() {
displayLink?.invalidate()
displayLink = nil
displayLink = CADisplayLink(target: target, selector: #selector(DisplayLinkTarget.tick(_:)))
displayLink?.preferredFrameRateRange = .init(minimum: 6, maximum: 10)
displayLink?.add(to: .current, forMode: .common)
}
func deactivate() {
displayLink?.invalidate()
displayLink = nil
}
}
#else
final class DisplayLinkPlatform {
var onTick: ((DisplayLinkFrame) -> Void)?
var isPaused: Bool = true {
didSet {
guard isPaused != oldValue else { return }
if isPaused == true {
CVDisplayLinkStop(self.displayLink)
} else {
CVDisplayLinkStart(self.displayLink)
}
}
}
/// The CVDisplayLink that powers this DisplayLink instance.
var displayLink: CVDisplayLink = {
var dl: CVDisplayLink? = nil
CVDisplayLinkCreateWithActiveCGDisplays(&dl)
return dl!
}()
init() {
CVDisplayLinkSetOutputHandler(self.displayLink, { [weak self] (displayLink, inNow, inOutputTime, flageIn, flagsOut) -> CVReturn in
let frame = DisplayLinkFrame(
timestamp: inNow.pointee.timeInterval,
duration: inOutputTime.pointee.timeInterval - inNow.pointee.timeInterval)
DispatchQueue.main.async {
guard self?.isPaused == false else { return }
self?.onTick?(frame)
}
return kCVReturnSuccess
})
}
func activate() {
isPaused = true
}
func deactivate() {
isPaused = false
}
}
extension CVTimeStamp {
fileprivate var timeInterval: TimeInterval {
return TimeInterval(videoTime) / TimeInterval(self.videoTimeScale)
}
}
#endif
@@ -0,0 +1,29 @@
//
// Created by Dimitris Chatzieleftheriou on 25/04/2024.
//
import Foundation
actor Notifier<Output> {
private var continuations: [UUID: AsyncStream<Output>.Continuation] = [:]
func values(bufferingPolicy limit: AsyncStream<Output>.Continuation.BufferingPolicy = .bufferingNewest(1)) -> AsyncStream<Output> {
AsyncStream<Output>(bufferingPolicy: limit) { continuation in
let id = UUID()
continuations[id] = continuation
continuation.onTermination = { _ in
Task { await self.cancel(id) }
}
}
}
func send(_ value: Output) {
for continuation in continuations.values {
continuation.yield(value)
}
}
private func cancel(_ id: UUID) {
continuations[id] = nil
}
}
@@ -0,0 +1,24 @@
import SwiftUI
struct PrefersStackNavigationEnvironmentKey: EnvironmentKey {
static var defaultValue: Bool = false
}
extension EnvironmentValues {
var prefersStackNavigation: Bool {
get { self[PrefersStackNavigationEnvironmentKey.self] }
set { self[PrefersStackNavigationEnvironmentKey.self] = newValue }
}
}
#if os(iOS)
extension PrefersStackNavigationEnvironmentKey: UITraitBridgedEnvironmentKey {
static func read(from traitCollection: UITraitCollection) -> Bool {
return traitCollection.userInterfaceIdiom == .phone || traitCollection.userInterfaceIdiom == .tv
}
static func write(to mutableTraits: inout UIMutableTraits, value: Bool) {
// Do not write.
}
}
#endif
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>
</dict>
</plist>
@@ -0,0 +1,35 @@
//
// Created by Dimitris C.
// Copyright © 2024 Decimal. All rights reserved.
//
import SwiftUI
struct ContentView: View {
@Environment(AppModel.self) var appModel
@Environment(\.prefersStackNavigation) private var prefersStackNavigation
@State private var selection: NavigationContent?
var body: some View {
if prefersStackNavigation {
NavigationStack {
ContentSidebar(selection: $selection)
.navigationTitle("Home")
}
} else {
NavigationSplitView {
ContentSidebar(selection: $selection)
.navigationTitle("Home")
} detail: {
if let selection {
DetailView(selection: selection)
}
}
.onAppear {
selection = .audioPlayer
}
}
}
}
@@ -0,0 +1,22 @@
//
// Created by Dimitris C.
// Copyright © 2024 Decimal. All rights reserved.
//
import SwiftUI
struct DetailView: View {
@Environment(AppModel.self) var appModel
var selection: NavigationContent
var body: some View {
switch selection {
case .audioPlayer:
AudioPlayerView(appModel: appModel)
case .audioQueue: // TODO
EmptyView()
}
}
}
@@ -0,0 +1,48 @@
//
// Created by Dimitris C.
// Copyright © 2024 Decimal. All rights reserved.
//
import SwiftUI
enum NavigationContent: Hashable {
case audioPlayer
case audioQueue
}
struct ContentSidebar: View {
@Binding var selection: NavigationContent?
var body: some View {
List(selection: $selection) {
NavigationLink(value: NavigationContent.audioPlayer) {
Label("Audio Player", systemImage: "play")
}
NavigationLink(value: NavigationContent.audioQueue) {
Label("Audio Queue", systemImage: "play.square.stack")
}
}
.navigationTitle("Home")
.navigationDestination(item: $selection, destination: { selection in
DetailView(selection: selection)
})
}
}
struct Sidebar_Previews: PreviewProvider {
struct Preview: View {
@State private var selection: NavigationContent? = NavigationContent.audioPlayer
var body: some View {
ContentSidebar(selection: $selection)
}
}
static var previews: some View {
NavigationSplitView {
Preview()
} detail: {
Text("Detail!")
}
}
}
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.
-19
View File
@@ -1,19 +0,0 @@
Pod::Spec.new do |s|
s.name = 'AudioStreaming'
s.version = '0.3.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'
s.authors = { 'Dimitris C.' => 'dimmdesign@gmail.com' }
s.source = { :git => 'https://github.com/dimitris-c/AudioStreaming.git', :tag => s.version }
s.ios.deployment_target = '12.0'
s.swift_versions = ['5.1', '5.2', '5.3']
s.source_files = 'AudioStreaming/**/*.swift'
s.pod_target_xcconfig = {
'SWIFT_INSTALL_OBJC_HEADER' => 'NO'
}
end
+87 -31
View File
@@ -3,10 +3,15 @@
archiveVersion = 1;
classes = {
};
objectVersion = 52;
objectVersion = 55;
objects = {
/* Begin PBXBuildFile section */
98ABF69E2BAB07A20059C441 /* Mp4Restructure.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98ABF69D2BAB07A20059C441 /* Mp4Restructure.swift */; };
98C82AE62B8CA8BC00AED485 /* RemoteMp4Restructure.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98C82AE52B8CA8BC00AED485 /* RemoteMp4Restructure.swift */; };
98CC396E28BD651E006C9FF9 /* Atomic.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98CC396D28BD651E006C9FF9 /* Atomic.swift */; };
98DC00CC2B961F5E0068900A /* ByteBuffer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98DC00CB2B961F5E0068900A /* ByteBuffer.swift */; };
98DC00CE2B9726380068900A /* ByteBufferTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98DC00CD2B9726380068900A /* ByteBufferTests.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 */; };
@@ -33,7 +38,6 @@
B5667A902499018D00D93F85 /* AudioFileStreamProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5667A8F2499018D00D93F85 /* AudioFileStreamProcessor.swift */; };
B5667A922499063D00D93F85 /* AudioPlayerContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5667A912499063D00D93F85 /* AudioPlayerContext.swift */; };
B5667B3E249BC43100D93F85 /* AudioPlayerRenderProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5667B3D249BC43000D93F85 /* AudioPlayerRenderProcessor.swift */; };
B5737340254DE43E003DFBEC /* measure.swift in Sources */ = {isa = PBXBuildFile; fileRef = B573733F254DE43E003DFBEC /* measure.swift */; };
B57829CF2548B32B00C78D36 /* Lock.swift in Sources */ = {isa = PBXBuildFile; fileRef = B57829CE2548B32B00C78D36 /* Lock.swift */; };
B58386382544A2C10087A712 /* EntryFrames.swift in Sources */ = {isa = PBXBuildFile; fileRef = B58386372544A2C10087A712 /* EntryFrames.swift */; };
B5838640254584A50087A712 /* ProcessedPackets.swift in Sources */ = {isa = PBXBuildFile; fileRef = B583863F254584A50087A712 /* ProcessedPackets.swift */; };
@@ -50,9 +54,12 @@
B59D0B6F255C904900D6CCE5 /* FileAudioSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = B59D0B6E255C904900D6CCE5 /* FileAudioSource.swift */; };
B59DF10424916FD50043C498 /* DispatchQueue+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = B59DF10324916FD50043C498 /* DispatchQueue+Helpers.swift */; };
B59DF1A32493E90C0043C498 /* AudioFileStream+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = B59DF1A22493E90C0043C498 /* AudioFileStream+Helpers.swift */; };
B5AEDBB824744153007D8101 /* AudioStreaming.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B5AEDBAE24744153007D8101 /* AudioStreaming.framework */; };
B5AEDBB824744153007D8101 /* AudioStreaming.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B5AEDBAE24744153007D8101 /* AudioStreaming.framework */; platformFilters = (ios, tvos, ); };
B5AEDBBF24744153007D8101 /* AudioStreaming.h in Headers */ = {isa = PBXBuildFile; fileRef = B5AEDBB124744153007D8101 /* AudioStreaming.h */; settings = {ATTRIBUTES = (Public, ); }; };
B5B36E432655A32200DC96F5 /* FrameFilterProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5B36E422655A32200DC96F5 /* FrameFilterProcessor.swift */; };
B5B3B7CC248647ED00656828 /* AudioPlayerState.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5B3B7CB248647ED00656828 /* AudioPlayerState.swift */; };
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 */; };
@@ -61,8 +68,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 */
@@ -91,6 +97,11 @@
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
98ABF69D2BAB07A20059C441 /* Mp4Restructure.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Mp4Restructure.swift; sourceTree = "<group>"; };
98C82AE52B8CA8BC00AED485 /* RemoteMp4Restructure.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteMp4Restructure.swift; sourceTree = "<group>"; };
98CC396D28BD651E006C9FF9 /* Atomic.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Atomic.swift; sourceTree = "<group>"; };
98DC00CB2B961F5E0068900A /* ByteBuffer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ByteBuffer.swift; sourceTree = "<group>"; };
98DC00CD2B9726380068900A /* ByteBufferTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ByteBufferTests.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>"; };
@@ -118,7 +129,6 @@
B5667A8F2499018D00D93F85 /* AudioFileStreamProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioFileStreamProcessor.swift; sourceTree = "<group>"; };
B5667A912499063D00D93F85 /* AudioPlayerContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlayerContext.swift; sourceTree = "<group>"; };
B5667B3D249BC43000D93F85 /* AudioPlayerRenderProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlayerRenderProcessor.swift; sourceTree = "<group>"; };
B573733F254DE43E003DFBEC /* measure.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = measure.swift; sourceTree = "<group>"; };
B57829CE2548B32B00C78D36 /* Lock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Lock.swift; sourceTree = "<group>"; };
B580CB1D25628CF4006D7DD8 /* AudioStreaming.podspec */ = {isa = PBXFileReference; lastKnownFileType = text; path = AudioStreaming.podspec; sourceTree = "<group>"; };
B580CB1E25628CF4006D7DD8 /* LICENSE */ = {isa = PBXFileReference; lastKnownFileType = text; path = LICENSE; sourceTree = "<group>"; };
@@ -142,7 +152,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>"; };
@@ -152,8 +165,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>"; };
@@ -178,6 +190,15 @@
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
98C82AE42B8CA8AA00AED485 /* Mp4 */ = {
isa = PBXGroup;
children = (
98C82AE52B8CA8BC00AED485 /* RemoteMp4Restructure.swift */,
98ABF69D2BAB07A20059C441 /* Mp4Restructure.swift */,
);
path = Mp4;
sourceTree = "<group>";
};
B5276B70247D4D3D00D2F56A /* Network */ = {
isa = PBXGroup;
children = (
@@ -205,6 +226,7 @@
B55CEAB32485107C0001C498 /* Parser.swift */,
B55A736B247FCB420050C53D /* HTTPHeaderParser.swift */,
B55CE96D248058B60001C498 /* MetadataParser.swift */,
B5D4A40825D9321400E1450C /* IcycastHeaderParser.swift */,
);
path = Parsers;
sourceTree = "<group>";
@@ -254,9 +276,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>";
@@ -293,6 +317,7 @@
B58BD7FC255DB653005B756D /* Audio Source */ = {
isa = PBXGroup;
children = (
98C82AE42B8CA8AA00AED485 /* Mp4 */,
B5EF9556247E9439003E8FF8 /* AudioStreamSource.swift */,
B5EF955C247ECBB1003E8FF8 /* RemoteAudioSource.swift */,
B59D0B6E255C904900D6CCE5 /* FileAudioSource.swift */,
@@ -312,11 +337,11 @@
B592E13025460883008866FB /* Helpers */ = {
isa = PBXGroup;
children = (
B573733F254DE43E003DFBEC /* measure.swift */,
98DC00CB2B961F5E0068900A /* ByteBuffer.swift */,
98CC396D28BD651E006C9FF9 /* Atomic.swift */,
B514657E248E3884005C03F7 /* DispatchTimerSource.swift */,
B57829CE2548B32B00C78D36 /* Lock.swift */,
B500731F24D00BAC00BB4475 /* Logger.swift */,
B5F883B52476DADB00D277C1 /* Protected.swift */,
B54C3E55255F286D00B356F2 /* Retrier.swift */,
);
path = Helpers;
@@ -422,8 +447,8 @@
B5F883B42476DABE00D277C1 /* Core */ = {
isa = PBXGroup;
children = (
B592E11E2545FF33008866FB /* Structures */,
B55CE97624813BA10001C498 /* Extensions */,
B592E11E2545FF33008866FB /* Structures */,
B5276B70247D4D3D00D2F56A /* Network */,
B592E13025460883008866FB /* Helpers */,
);
@@ -434,10 +459,11 @@
isa = PBXGroup;
children = (
B5EF954A247DA450003E8FF8 /* Network */,
B5F883B82477CBF600D277C1 /* ProtectedTests.swift */,
B5F883B82477CBF600D277C1 /* AtomicTests.swift */,
B51FE0C12488F96A00F2A4D2 /* QueueTests.swift */,
B592E12825460146008866FB /* BiMapTests.swift */,
B592E133254608B4008866FB /* DispatchTimerSourceTests.swift */,
98DC00CD2B9726380068900A /* ByteBufferTests.swift */,
);
path = Core;
sourceTree = "<group>";
@@ -502,8 +528,9 @@
B5AEDBA524744153007D8101 /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
LastSwiftUpdateCheck = 1140;
LastUpgradeCheck = 1200;
LastUpgradeCheck = 1620;
ORGANIZATIONNAME = Decimal;
TargetAttributes = {
B5AEDBAD24744153007D8101 = {
@@ -561,6 +588,7 @@
/* Begin PBXShellScriptBuildPhase section */
B583864B2545858E0087A712 /* SwiftLint */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
@@ -575,7 +603,7 @@
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "if which swiftlint >/dev/null; then\n swiftlint\nelse\n echo \"warning: SwiftLint not installed, download from https://github.com/realm/SwiftLint\"\nfi\n";
shellScript = "if [[ \"$(uname -m)\" == arm64 ]]; then\n export PATH=\"/opt/homebrew/bin:$PATH\"\nfi\n\nif which swiftlint > /dev/null; then\n swiftlint\nelse\n echo \"warning: SwiftLint not installed, download from https://github.com/realm/SwiftLint\"\nfi\n";
};
/* End PBXShellScriptBuildPhase section */
@@ -590,23 +618,27 @@
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 */,
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 */,
B5737340254DE43E003DFBEC /* measure.swift in Sources */,
B55CEABC24853CD20001C498 /* AudioPlayer.swift in Sources */,
B5667B3E249BC43100D93F85 /* AudioPlayerRenderProcessor.swift in Sources */,
B5276B6F247D21A000D2F56A /* NetworkingClient.swift in Sources */,
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 */,
98DC00CC2B961F5E0068900A /* ByteBuffer.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 */,
@@ -621,12 +653,13 @@
B55CE96E248058B60001C498 /* MetadataParser.swift in Sources */,
B5838644254584BE0087A712 /* AudioStreamState.swift in Sources */,
B500732024D00BAC00BB4475 /* Logger.swift in Sources */,
98C82AE62B8CA8BC00AED485 /* RemoteMp4Restructure.swift in Sources */,
B5276B74247D4D9F00D2F56A /* NetworkSessionDelegate.swift in Sources */,
B55F77D624DACE140057F431 /* BufferContext.swift in Sources */,
B5838648254584D90087A712 /* SeekRequest.swift in Sources */,
B5D82E65255DD562009EDAA4 /* NetStatusService.swift in Sources */,
B55CE97824813BCA0001C498 /* UnsafeMutablePointer+Helpers.swift in Sources */,
B5F883B62476DADB00D277C1 /* Protected.swift in Sources */,
98ABF69E2BAB07A20059C441 /* Mp4Restructure.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -639,10 +672,11 @@
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 */,
98DC00CE2B9726380068900A /* ByteBufferTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -651,6 +685,10 @@
/* Begin PBXTargetDependency section */
B5AEDBBA24744153007D8101 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
platformFilters = (
ios,
tvos,
);
target = B5AEDBAD24744153007D8101 /* AudioStreaming */;
targetProxy = B5AEDBB924744153007D8101 /* PBXContainerItemProxy */;
};
@@ -695,6 +733,7 @@
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
@@ -709,8 +748,9 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
MARKETING_VERSION = 0.1.0;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MACOSX_DEPLOYMENT_TARGET = 13.0;
MARKETING_VERSION = 1.2.7;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
@@ -760,6 +800,7 @@
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
@@ -768,8 +809,9 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
MARKETING_VERSION = 0.1.0;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MACOSX_DEPLOYMENT_TARGET = 13.0;
MARKETING_VERSION = 1.2.7;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SDKROOT = iphoneos;
@@ -785,29 +827,34 @@
isa = XCBuildConfiguration;
buildSettings = {
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_IDENTITY = "";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 2;
DEFINES_MODULE = YES;
DYLIB_COMPATIBILITY_VERSION = 1;
DYLIB_CURRENT_VERSION = 1;
DYLIB_INSTALL_NAME_BASE = "@rpath";
ENABLE_MODULE_VERIFIER = YES;
INFOPLIST_FILE = AudioStreaming/Info.plist;
INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks";
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@loader_path/Frameworks",
);
MARKETING_VERSION = 0.3.0;
MARKETING_VERSION = 1.2.7;
MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu11 gnu++14";
OTHER_LDFLAGS = "-ObjC";
PRODUCT_BUNDLE_IDENTIFIER = com.decimal.AudioStreaming;
PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)";
SKIP_INSTALL = YES;
SUPPORTED_PLATFORMS = "appletvos appletvsimulator iphoneos iphonesimulator macosx";
SUPPORTS_MACCATALYST = NO;
SWIFT_OBJC_BRIDGING_HEADER = "";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
TARGETED_DEVICE_FAMILY = "1,2,3";
};
name = Debug;
};
@@ -815,38 +862,43 @@
isa = XCBuildConfiguration;
buildSettings = {
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_IDENTITY = "";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 2;
DEFINES_MODULE = YES;
DYLIB_COMPATIBILITY_VERSION = 1;
DYLIB_CURRENT_VERSION = 1;
DYLIB_INSTALL_NAME_BASE = "@rpath";
ENABLE_MODULE_VERIFIER = YES;
INFOPLIST_FILE = AudioStreaming/Info.plist;
INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks";
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@loader_path/Frameworks",
);
MARKETING_VERSION = 0.3.0;
MARKETING_VERSION = 1.2.7;
MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu11 gnu++14";
OTHER_LDFLAGS = "-ObjC";
PRODUCT_BUNDLE_IDENTIFIER = com.decimal.AudioStreaming;
PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)";
SKIP_INSTALL = YES;
SUPPORTED_PLATFORMS = "appletvos appletvsimulator iphoneos iphonesimulator macosx";
SUPPORTS_MACCATALYST = NO;
SWIFT_OBJC_BRIDGING_HEADER = "";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
TARGETED_DEVICE_FAMILY = "1,2,3";
};
name = Release;
};
B5AEDBC624744153007D8101 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_STYLE = Automatic;
INFOPLIST_FILE = AudioStreamingTests/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@@ -854,19 +906,21 @@
);
PRODUCT_BUNDLE_IDENTIFIER = com.decimal.AudioStreamingTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SUPPORTED_PLATFORMS = "appletvos appletvsimulator iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = YES;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
TARGETED_DEVICE_FAMILY = "1,2,3";
};
name = Debug;
};
B5AEDBC724744153007D8101 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_STYLE = Automatic;
INFOPLIST_FILE = AudioStreamingTests/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@@ -874,8 +928,10 @@
);
PRODUCT_BUNDLE_IDENTIFIER = com.decimal.AudioStreamingTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SUPPORTED_PLATFORMS = "appletvos appletvsimulator iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
TARGETED_DEVICE_FAMILY = "1,2,3";
};
name = Release;
};
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1200"
LastUpgradeVersion = "1620"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
+1 -1
View File
@@ -2,7 +2,7 @@
<Workspace
version = "1.0">
<FileRef
location = "group:AudioExample/AudioExample.xcodeproj">
location = "group:AudioPlayer/AudioPlayer.xcodeproj">
</FileRef>
<FileRef
location = "container:AudioStreaming.xcodeproj">
@@ -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>`
@@ -8,11 +8,13 @@ import AVFoundation
@discardableResult
func fileStreamGetProperty<Value>(value: inout Value, fileStream streamId: AudioFileStreamID, propertyId: AudioFileStreamPropertyID) -> OSStatus {
var (size, _) = fileStreamGetPropertyInfo(fileStream: streamId, propertyId: propertyId)
let status = AudioFileStreamGetProperty(streamId, propertyId, &size, &value)
guard status == noErr else {
return withUnsafeMutablePointer(to: &value) { pointer in
let status = AudioFileStreamGetProperty(streamId, propertyId, &size, pointer)
guard status == noErr else {
return status
}
return status
}
return status
}
func fileStreamGetPropertyInfo(fileStream streamId: AudioFileStreamID, propertyId: AudioFileStreamPropertyID) -> (size: UInt32, status: OSStatus) {
@@ -112,3 +114,50 @@ public enum AudioFileStreamError: CustomDebugStringConvertible {
}
}
}
public extension AudioFileStreamPropertyID {
var description: String {
switch self {
case kAudioFileStreamProperty_ReadyToProducePackets:
return "Ready to produce packets"
case kAudioFileStreamProperty_FileFormat:
return "File format"
case kAudioFileStreamProperty_DataFormat:
return "Data format"
case kAudioFileStreamProperty_AudioDataByteCount:
return "Byte count"
case kAudioFileStreamProperty_AudioDataPacketCount:
return "Packet count"
case kAudioFileStreamProperty_DataOffset:
return "Data offset"
case kAudioFileStreamProperty_BitRate:
return "Bit rate"
case kAudioFileStreamProperty_FormatList:
return "Format list"
case kAudioFileStreamProperty_MagicCookieData:
return "Magic cookie"
case kAudioFileStreamProperty_MaximumPacketSize:
return "Max packet size"
case kAudioFileStreamProperty_ChannelLayout:
return "Channel layout"
case kAudioFileStreamProperty_PacketToFrame:
return "Packet to frame"
case kAudioFileStreamProperty_FrameToPacket:
return "Frame to packet"
case kAudioFileStreamProperty_PacketToByte:
return "Packet to byte"
case kAudioFileStreamProperty_ByteToPacket:
return "Byte to packet"
case kAudioFileStreamProperty_PacketTableInfo:
return "Packet table"
case kAudioFileStreamProperty_PacketSizeUpperBound:
return "Packet size upper bound"
case kAudioFileStreamProperty_AverageBytesPerPacket:
return "Average bytes per packet"
case kAudioFileStreamProperty_InfoDictionary:
return "Info dictionary"
default:
return "Unknown"
}
}
}
@@ -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) }
}
}
@@ -0,0 +1,187 @@
//
// Created by Dimitrios Chatzieleftheriou on 4/03/2024.
// Copyright © 2024 Decimal. All rights reserved.
//
import Foundation
// Struct representing a buffer for handling binary data
struct ByteBuffer {
// Custom errors for ByteBuffer
enum Error: Swift.Error {
case eof // End of file
case parse // Parsing error
}
// Data storage for the buffer
private(set) var storage = Data()
// Current offset in the buffer
var offset: Int = 0
// Calculated property for the number of bytes available for reading
var bytesAvailable: Int {
storage.count - offset
}
// Calculated property for the length of the buffer
var length: Int {
get {
storage.count
}
set {
// Adjusting the length of the buffer
switch true {
case storage.count < newValue:
storage.append(Data(count: newValue - storage.count))
case newValue < storage.count:
storage = storage.subdata(in: 0 ..< newValue)
default:
break
}
}
}
// Subscript for accessing individual bytes in the buffer
subscript(i: Int) -> UInt8 {
get { storage[i] }
set { storage[i] = newValue }
}
// Initialize the buffer with given data
init(data: Data) {
storage = data
offset = 0
}
// Initialize the buffer with a specified size, filling it with zeros
init(size: Int) {
storage = Data(repeating: 0x00, count: size)
offset = 0
}
// Clear the buffer (reset offset to zero)
@discardableResult
mutating func clear() -> Self {
offset = 0
return self
}
// Rewind the buffer (reset offset to zero)
mutating func rewind() {
offset = 0
}
// Read a specified number of bytes from the buffer
mutating func readBytes(_ length: Int) throws -> Data {
guard length <= bytesAvailable else {
throw ByteBuffer.Error.eof
}
offset += length
return storage.subdata(in: offset - length ..< offset)
}
// Write data into the buffer
@discardableResult
mutating func writeBytes(_ value: Data) -> Self {
// If the offset is at the end, append the value to the data
if offset == storage.count {
storage.append(value)
offset = storage.count
return self
}
// Otherwise, write the value into the buffer at the current offset
let length: Int = min(storage.count, value.count)
storage[offset ..< offset + length] = value[0 ..< length]
// If the value is longer than the remaining space, append the rest to the data
if length == storage.count {
storage.append(value[length ..< value.count])
}
offset += value.count
return self
}
// Write integer value into the buffer
@discardableResult
mutating func put<T: FixedWidthInteger>(_ value: T) -> ByteBuffer {
writeBytes(value.data)
}
// Write float value into the buffer
@discardableResult
mutating func put(_ value: Float) -> ByteBuffer {
writeBytes(Data(value.data.reversed()))
}
// Write double value into the buffer
@discardableResult
mutating func put(_ value: Double) -> ByteBuffer {
writeBytes(Data(value.data.reversed()))
}
// Read an integer value from the buffer
mutating func getInteger<T: FixedWidthInteger>() throws -> T {
let sizeOfInteger = MemoryLayout<T>.size
guard sizeOfInteger <= bytesAvailable else {
throw ByteBuffer.Error.eof
}
offset += sizeOfInteger
return T(data: storage[offset - sizeOfInteger ..< offset]).bigEndian
}
// Read an integer value from a specific index in the buffer
func getInteger<T: FixedWidthInteger>(_ index: Int) throws -> T {
let sizeOfInteger = MemoryLayout<T>.size
guard sizeOfInteger + index <= length else {
throw ByteBuffer.Error.eof
}
return T(data: storage[index ..< index + sizeOfInteger]).bigEndian
}
// Read a float value from the buffer
mutating func getFloat() throws -> Float {
let sizeOfFloat = MemoryLayout<UInt32>.size
guard sizeOfFloat <= bytesAvailable else {
throw ByteBuffer.Error.eof
}
offset += sizeOfFloat
return Float(data: Data(storage.subdata(in: offset - sizeOfFloat ..< offset).reversed()))
}
// Read a double value from the buffer
mutating func getDouble() throws -> Double {
let sizeOfDouble = MemoryLayout<UInt64>.size
guard sizeOfDouble <= bytesAvailable else {
throw ByteBuffer.Error.eof
}
offset += sizeOfDouble
return Double(data: Data(storage.subdata(in: offset - sizeOfDouble ..< offset).reversed()))
}
}
// Extension to provide conformance to ExpressibleByIntegerLiteral for easy conversion between integers and Data
extension ExpressibleByIntegerLiteral {
// Convert integer to Data
var data: Data {
return withUnsafePointer(to: self) { pointer in
Data(bytes: pointer, count: MemoryLayout<Self>.size)
}
}
// Initialize from Data
init(data: Data) {
let diff: Int = MemoryLayout<Self>.size - data.count
if diff > 0 {
var buffer = Data(repeating: 0, count: diff)
buffer.append(data)
self = buffer.withUnsafeBytes { $0.baseAddress!.assumingMemoryBound(to: Self.self).pointee }
return
}
self = data.withUnsafeBytes { $0.baseAddress!.assumingMemoryBound(to: Self.self).pointee }
}
// Initialize from Data slice
init(data: Slice<Data>) {
self.init(data: Data(data))
}
}
@@ -11,10 +11,10 @@ import Foundation
final class DispatchTimerSource {
private var handler: (() -> Void)?
private let timer: DispatchSourceTimer
internal var state: SourceState = .suspended
var state: SourceState = .suspended
/// The state of the timer
internal enum SourceState {
enum SourceState {
case activated
case suspended
}
+96 -19
View File
@@ -4,50 +4,127 @@
//
import Foundation
import os
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)
func deallocate()
}
/// A wrapper for `os_unfair_lock`
/// - Tag: UnfairLock
final class UnfairLock: Lock {
private let unfairLock: os_unfair_lock_t
internal init() {
var unfairLock: Lock
init() {
if #available(iOS 16.0, *), #available(macOS 13.0, *) {
unfairLock = OSStorageLock()
} else {
unfairLock = UnfairStorageLock()
}
}
deinit {
deallocate()
}
func deallocate() {
unfairLock.deallocate()
}
@inlinable
func withLock<Result>(body: () throws -> Result) rethrows -> Result {
try unfairLock.withLock(body: body)
}
@inlinable
func withLock(body: () -> Void) {
unfairLock.withLock(body: body)
}
@inlinable
func lock() {
unfairLock.lock()
}
@inlinable
func unlock() {
unfairLock.unlock()
}
}
@available(iOS 16.0, *)
@available(macOS 13, *)
private class OSStorageLock: Lock {
@usableFromInline
let osLock = OSAllocatedUnfairLock()
@inlinable
func lock() {
osLock.lock()
}
@inlinable
func unlock() {
osLock.unlock()
}
func withLock<Result>(body: () throws -> Result) rethrows -> Result {
try osLock.withLockUnchecked(body)
}
func withLock(body: () -> Void) {
osLock.withLockUnchecked(body)
}
func deallocate() {} // no-op
}
private class UnfairStorageLock: Lock {
@usableFromInline
let unfairLock: UnsafeMutablePointer<os_unfair_lock>
init() {
unfairLock = .allocate(capacity: 1)
unfairLock.initialize(to: os_unfair_lock())
}
deinit {
func deallocate() {
unfairLock.deinitialize(count: 1)
unfairLock.deallocate()
}
@inline(__always)
internal func lock() {
@inlinable
func withLock<Result>(body: () throws -> Result) rethrows -> Result {
os_unfair_lock_lock(unfairLock)
defer { os_unfair_lock_unlock(unfairLock) }
return try body()
}
@inlinable
func withLock(body: () -> Void) {
os_unfair_lock_lock(unfairLock)
defer { os_unfair_lock_unlock(unfairLock) }
body()
}
@inlinable
func lock() {
os_unfair_lock_lock(unfairLock)
}
@inline(__always)
internal func unlock() {
@inlinable
func unlock() {
os_unfair_lock_unlock(unfairLock)
}
}
+4 -4
View File
@@ -8,7 +8,7 @@ import os
private let loggingSubsystem = "audio.streaming.log"
internal enum Logger {
enum Logger {
private static let audioRendering = OSLog(subsystem: loggingSubsystem, category: "audio.rendering")
private static let networking = OSLog(subsystem: loggingSubsystem, category: "audio.networking")
private static let generic = OSLog(subsystem: loggingSubsystem, category: "audio.streaming.generic")
@@ -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) }
}
}
+2 -1
View File
@@ -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()
}
-15
View File
@@ -1,15 +0,0 @@
//
// measure.swift
// AudioStreaming
//
// Created by Dimitrios Chatzieleftheriou on 31/10/2020.
// Copyright © 2020 Decimal. All rights reserved.
//
import Foundation
func measure(name: String = "", block: () -> Void) {
let started = ProcessInfo.processInfo.systemUptime
block()
print("diff for \(name): \(String(format: "%.6f", ProcessInfo.processInfo.systemUptime - started))")
}
@@ -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
@@ -5,7 +5,7 @@
import Foundation
internal final class NetworkDataStream {
final class NetworkDataStream {
typealias StreamResult = Result<Response, Error>
typealias StreamCompletion = (_ event: NetworkDataStream.ResponseEvent) -> Void
@@ -52,7 +52,7 @@ internal final class NetworkDataStream {
task?.response as? HTTPURLResponse
}
internal init(id: UUID, underlyingQueue: DispatchQueue) {
init(id: UUID, underlyingQueue: DispatchQueue) {
self.id = id
self.underlyingQueue = underlyingQueue
state = .initialised
@@ -94,7 +94,7 @@ internal final class NetworkDataStream {
// MARK: Internal
internal func didReceive(response: HTTPURLResponse?) {
func didReceive(response: HTTPURLResponse?) {
underlyingQueue.async { [weak self] in
guard let self = self else { return }
guard let streamCallback = self.streamCallback else { return }
@@ -102,7 +102,7 @@ internal final class NetworkDataStream {
}
}
internal func didReceive(data: Data, response: HTTPURLResponse?) {
func didReceive(data: Data, response: HTTPURLResponse?) {
underlyingQueue.async { [weak self] in
guard let self = self else { return }
guard let streamCallback = self.streamCallback else { return }
@@ -111,7 +111,7 @@ internal final class NetworkDataStream {
}
}
internal func didComplete(with error: Error?, response: HTTPURLResponse?) {
func didComplete(with error: Error?, response: HTTPURLResponse?) {
underlyingQueue.async { [weak self] in
guard let self = self else { return }
guard let stream = self.streamCallback else { return }
@@ -5,10 +5,10 @@
import Foundation
internal final class NetworkSessionDelegate: NSObject, URLSessionDataDelegate {
final class NetworkSessionDelegate: NSObject, URLSessionDataDelegate {
weak var taskProvider: StreamTaskProvider?
internal func stream(for task: URLSessionTask) -> NetworkDataStream? {
func stream(for task: URLSessionTask) -> NetworkDataStream? {
guard let taskProvider = taskProvider else {
assertionFailure("couldn't found taskProvider")
return nil
@@ -16,22 +16,22 @@ internal final class NetworkSessionDelegate: NSObject, URLSessionDataDelegate {
return taskProvider.dataStream(for: task)
}
internal func urlSession(_: URLSession,
dataTask: URLSessionDataTask,
didReceive data: Data)
func urlSession(_: URLSession,
dataTask: URLSessionDataTask,
didReceive data: Data)
{
guard let stream = self.stream(for: dataTask) else {
guard let stream = stream(for: dataTask) else {
return
}
stream.didReceive(data: data,
response: dataTask.response as? HTTPURLResponse)
}
internal func urlSession(_: URLSession,
task: URLSessionTask,
didCompleteWithError error: Error?)
func urlSession(_: URLSession,
task: URLSessionTask,
didCompleteWithError error: Error?)
{
guard let stream = self.stream(for: task) else {
guard let stream = stream(for: task) else {
return
}
stream.didComplete(with: error, response: task.response as? HTTPURLResponse)
@@ -42,7 +42,7 @@ internal final class NetworkSessionDelegate: NSObject, URLSessionDataDelegate {
didReceive response: URLResponse,
completionHandler: @escaping (URLSession.ResponseDisposition) -> Void)
{
guard let stream = self.stream(for: dataTask) else {
guard let stream = stream(for: dataTask) else {
return
}
stream.didReceive(response: response as? HTTPURLResponse)
@@ -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
}
@@ -41,7 +44,7 @@ extension URLSessionConfiguration {
}
}
internal final class NetworkingClient {
final class NetworkingClient {
let session: URLSession
weak var delegate: NetworkSessionDelegate?
let networkQueue: DispatchQueue
@@ -49,9 +52,9 @@ internal final class NetworkingClient {
var tasksLock = UnfairLock()
var tasks = BiMap<URLSessionTask, NetworkDataStream>()
internal init(configuration: URLSessionConfiguration = .networkingConfiguration,
delegate: NetworkSessionDelegate = NetworkSessionDelegate(),
networkQueue: DispatchQueue = DispatchQueue(label: "audio.streaming.session.network.queue"))
init(configuration: URLSessionConfiguration = .networkingConfiguration,
delegate: NetworkSessionDelegate = NetworkSessionDelegate(),
networkQueue: DispatchQueue = DispatchQueue(label: "audio.streaming.session.network.queue"))
{
let delegateQueue = operationQueue(underlyingQueue: networkQueue)
let session = URLSession(configuration: configuration, delegate: delegate, delegateQueue: delegateQueue)
@@ -67,22 +70,38 @@ internal final class NetworkingClient {
/// Creates a data stream for the given `URLRequest`
/// - parameter request: A `URLRequest` to be used for the data stream
internal func stream(request: URLRequest) -> NetworkDataStream {
func stream(request: URLRequest) -> NetworkDataStream {
let stream = NetworkDataStream(id: UUID(), underlyingQueue: networkQueue)
setupRequest(stream, request: request)
return stream
}
internal func remove(task: NetworkDataStream) {
tasksLock.lock(); defer { tasksLock.unlock() }
if !tasks.isEmpty {
tasks[task] = nil
func remove(task: NetworkDataStream) {
tasksLock.withLock {
if !tasks.isEmpty {
tasks[task] = nil
}
}
}
@discardableResult
func task(request: URLRequest, completion: @escaping (Result<Data, Error>) -> Void) -> URLSessionDataTask {
let task = session.dataTask(with: request) { data, _, error in
if let error {
completion(Result<Data, Error>.failure(error))
return
}
if let data {
completion(Result<Data, Error>.success(data))
}
}
task.resume()
return task
}
// 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) {
@@ -96,14 +115,16 @@ internal final class NetworkingClient {
// MARK: StreamTaskProvider conformance
extension NetworkingClient: StreamTaskProvider {
internal func dataStream(for request: URLSessionTask) -> NetworkDataStream? {
tasksLock.lock(); defer { tasksLock.unlock() }
return tasks[request] ?? nil
func dataStream(for request: URLSessionTask) -> NetworkDataStream? {
tasksLock.withLock {
tasks[request] ?? nil
}
}
internal func sessionTask(for stream: NetworkDataStream) -> URLSessionTask? {
tasksLock.lock(); defer { tasksLock.unlock() }
return tasks[stream] ?? nil
func sessionTask(for stream: NetworkDataStream) -> URLSessionTask? {
tasksLock.withLock {
tasks[stream] ?? nil
}
}
}
+1 -1
View File
@@ -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] = [:]
+27 -1
View File
@@ -25,13 +25,15 @@
+---+ +---+
```
*/
final class Queue<Element>: Sequence, CustomDebugStringConvertible {
final class Queue<Element: Equatable>: Sequence, CustomDebugStringConvertible {
private var _storage: [Element] = []
var isEmpty: Bool { _storage.isEmpty }
var count: Int { _storage.count }
var items: [Element] { _storage }
/// Inserts an item at the end of the queue
func enqueue(item: Element) {
_storage.insert(item, at: 0)
@@ -55,6 +57,30 @@ final class Queue<Element>: Sequence, CustomDebugStringConvertible {
}
}
/// Inserts an item at a specific index in the queue
func insert(item: Element, at index: Int) {
guard index >= 0 && index <= count else {
fatalError("Index out of range")
}
_storage.insert(item, at: index)
}
func remove(item: Element) {
guard let index = _storage.firstIndex(of: item) else {
return
}
_storage.remove(at: index)
}
/// Removes the item at the specified index in the queue
@discardableResult
func remove(at index: Int) -> Element? {
guard index >= 0 && index < count else {
return nil
}
return _storage.remove(at: index)
}
/// Retrieves the last item
func peek() -> Element? {
_storage.last
@@ -7,11 +7,11 @@ import AudioToolbox
import AVFoundation
public struct AudioEntryId: Equatable {
internal var unique = UUID()
var unique = UUID()
public var id: String
}
internal class AudioEntry {
class AudioEntry {
private let estimationMinPackets = 2
private let estimationMinPacketsPreferred = 64
@@ -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() {
@@ -94,7 +92,9 @@ internal class AudioEntry {
func reset() {
lock.lock(); defer { lock.unlock() }
framesState = EntryFramesState()
framesState.played = 0
framesState.queued = 0
framesState.lastFrameQueued = -1
}
func has(same source: CoreAudioStreamSource) -> Bool {
@@ -109,7 +109,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
@@ -121,15 +121,21 @@ internal class AudioEntry {
}
func duration() -> Double {
guard sampleRate > 0 else { return 0 }
lock.lock()
guard sampleRate > 0 else {
lock.unlock()
return 0
}
if let audioDataPacketOffset = audioStreamState.dataPacketOffset {
let framesPerPacket = UInt64(audioStreamFormat.mFramesPerPacket)
if audioDataPacketOffset > 0, framesPerPacket > 0 {
return Double(audioDataPacketOffset * framesPerPacket) / audioStreamFormat.mSampleRate
let duration = Double(audioDataPacketOffset * framesPerPacket) / audioStreamFormat.mSampleRate
lock.unlock()
return duration
}
}
lock.unlock()
let calculatedBitrate = self.calculatedBitrate()
if calculatedBitrate < 1.0 || source.length == 0 {
return 0
@@ -151,12 +157,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]) {
@@ -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)
}
}
@@ -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
}
@@ -6,18 +6,18 @@
import AudioToolbox
import Foundation
protocol AudioStreamSourceDelegate: AnyObject {
public protocol AudioStreamSourceDelegate: AnyObject {
/// Indicates that there's data available
func dataAvailable(source: CoreAudioStreamSource, data: Data)
/// Indicates an error occurred
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])
}
protocol CoreAudioStreamSource: AnyObject {
public protocol CoreAudioStreamSource: AnyObject {
/// An `Int` that represents the position of the audio
var position: Int { get }
/// The length of the audio in bytes
@@ -3,6 +3,7 @@
// Copyright © 2020 Decimal. All rights reserved.
//
import Foundation
import AVFoundation
final class FileAudioSource: NSObject, CoreAudioStreamSource {
@@ -17,6 +18,12 @@ final class FileAudioSource: NSObject, CoreAudioStreamSource {
audioFileType(fileExtension: url.pathExtension)
}
private var isMp4: Bool {
audioFileHint == kAudioFileM4AType || audioFileHint == kAudioFileMPEG4Type
}
private var mp4IsAlreadyOptimized: Bool = false
private var seekOffset: Int
private let url: URL
@@ -26,6 +33,8 @@ final class FileAudioSource: NSObject, CoreAudioStreamSource {
private var buffer: UnsafeMutablePointer<UInt8>
private var inputStream: InputStream?
private var mp4Restructure: Mp4Restructure
init(url: URL,
fileManager: FileManager = .default,
underlyingQueue: DispatchQueue,
@@ -35,6 +44,7 @@ final class FileAudioSource: NSObject, CoreAudioStreamSource {
self.underlyingQueue = underlyingQueue
self.fileManager = fileManager
self.readSize = readSize
self.mp4Restructure = Mp4Restructure()
buffer = UnsafeMutablePointer.uint8pointer(of: readSize)
seekOffset = 0
position = 0
@@ -43,6 +53,7 @@ final class FileAudioSource: NSObject, CoreAudioStreamSource {
deinit {
buffer.deallocate()
mp4Restructure.clear()
}
func close() {
@@ -54,12 +65,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,40 +76,38 @@ 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 status = inputStream?.streamStatus ?? .closed
if status == .atEnd || status == .closed || status == .error {
reopened = true
close()
open(inputStream: inputStream)
try open()
}
let attributes = try fileManager.attributesOfItem(atPath: url.path)
length = (attributes[.size] as? Int) ?? 0
var offset = seekOffset
if isMp4, mp4Restructure.dataOptimized {
offset = mp4Restructure.seekAdjusted(offset: seekOffset)
}
if inputStream.setProperty(seekOffset, forKey: .fileCurrentOffsetKey) {
position = seekOffset
if inputStream?.setProperty(offset, forKey: .fileCurrentOffsetKey) == true {
position = offset
} else {
position = 0
}
if !reopened {
if inputStream.hasBytesAvailable {
dataAvailable()
underlyingQueue.async { [weak self] in
if self?.inputStream?.hasBytesAvailable == true {
self?.dataAvailable()
}
}
}
}
@@ -112,17 +117,62 @@ final class FileAudioSource: NSObject, CoreAudioStreamSource {
let read = inputStream.read(buffer, maxLength: readSize)
if read > 0 {
let data = Data(bytes: buffer, count: read)
delegate?.dataAvailable(source: self, data: data)
if isMp4, !mp4IsAlreadyOptimized {
if !mp4Restructure.dataOptimized {
do {
if let mp4OptimizeInfo = try mp4Restructure.checkIsOptimized(data: data) {
try performMp4Restructure(inputStream: inputStream, mp4OptimizeInfo: mp4OptimizeInfo)
} else {
mp4IsAlreadyOptimized = true
delegate?.dataAvailable(source: self, data: data)
}
} catch {
delegate?.errorOccurred(source: self, error: error)
}
} else {
delegate?.dataAvailable(source: self, data: data)
}
} else {
delegate?.dataAvailable(source: self, data: data)
}
position += read
} else {
position += getCurrentOffsetFromStream()
}
}
private func open(inputStream: InputStream) {
func performMp4Restructure(inputStream: InputStream, mp4OptimizeInfo: Mp4OptimizeInfo) throws {
let offsetAccepted = inputStream.setProperty(mp4OptimizeInfo.moovOffset, forKey: .fileCurrentOffsetKey)
if offsetAccepted {
let moovDataBuffer = UnsafeMutablePointer.uint8pointer(of: mp4OptimizeInfo.moovSize)
defer { moovDataBuffer.deallocate() }
let moovRead = inputStream.read(moovDataBuffer, maxLength: mp4OptimizeInfo.moovSize)
if moovRead > 0 {
let data = Data(bytes: moovDataBuffer, count: moovRead)
let moovData = try mp4Restructure.restructureMoov(data: data)
delegate?.dataAvailable(source: self, data: moovData.initialData)
if !inputStream.setProperty(moovData.mdatOffset, forKey: .fileCurrentOffsetKey) {
delegate?.errorOccurred(source: self, error: AudioSystemError.playerStartError)
}
} else {
delegate?.errorOccurred(source: self, error: AudioSystemError.playerStartError)
}
} else {
delegate?.errorOccurred(source: self, error: inputStream.streamError ?? AudioSystemError.playerStartError)
}
}
private func open() throws {
guard let inputStream = InputStream(url: url) else {
throw AudioSystemError.playerStartError
}
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 +189,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
}
@@ -0,0 +1,280 @@
//
// Created by Dimitrios Chatzieleftheriou on 20/03/2024.
// Copyright © 2020 Decimal. All rights reserved.
//
import Foundation
struct MP4Atom: Equatable, CustomDebugStringConvertible {
let type: Int
let size: Int
let offset: Int
var data: Data?
var isFreeSpaceAtom: Bool {
type == Atoms.free || type == Atoms.skip || type == Atoms.wide
}
var debugDescription: String {
"[Atom][size: \(size))][type: \(Atoms.integerToFourCC(type) ?? "")][offset: \(offset)]"
}
}
struct Mp4OptimizeInfo: Equatable {
let moovOffset: Int
let moovSize: Int
}
/// These are some atoms, helpful for audio mp4
enum Atoms {
static var ftyp: Int { fourCcToInt("ftyp") }
static var moov: Int { fourCcToInt("moov") }
static var mdat: Int { fourCcToInt("mdat") }
static var free: Int { fourCcToInt("free") }
static var skip: Int { fourCcToInt("skip") }
static var wide: Int { fourCcToInt("wide") }
static var cmov: Int { fourCcToInt("cmov") }
static var stco: Int { fourCcToInt("stco") }
static var co64: Int { fourCcToInt("c064") }
static var atomPreampleSize: Int = 8
static func fourCcToInt(_ fourCc: String) -> Int {
let data = fourCc.data(using: .ascii)!
return Int(bigEndian: Int(data: data))
}
static func integerToFourCC(_ value: Int) -> String? {
guard value >= 0, value <= 0xFFFF_FFFF else {
return nil // Integer value out of range
}
var bytes: [UInt8] = []
bytes.append(UInt8((value >> 24) & 0xFF))
bytes.append(UInt8((value >> 16) & 0xFF))
bytes.append(UInt8((value >> 8) & 0xFF))
bytes.append(UInt8(value & 0xFF))
let data = Data(bytes)
return String(data: data, encoding: .ascii)
}
}
enum Mp4RestructureError: Error {
case unableToRestructureData
case missingMoovData
case invalidMoovAtom
case invalidAtomSize
case invalidAtomType
case invalidOffset
case missingMdatAtom
case missingMoovAtom
case compressedAtomNotSupported
case nonOptimizedMp4AndServerCannotSeek
case networkError(Error)
}
final class Mp4Restructure {
private var atomOffset: Int = 0
private var atoms: [MP4Atom] = []
private var ftyp: MP4Atom?
private var foundMoov = false
private var foundMdat = false
private(set) var dataOptimized: Bool = false
private var moovAtomSize: Int = 0
func clear() {
atomOffset = 0
atoms = []
ftyp = nil
foundMdat = false
foundMoov = false
}
/// Adjust the seekOffset of subtracting the moovAtomSize
/// - Parameter offset: A byte offset
/// - Returns: An adjusted byte offset
func seekAdjusted(offset: Int) -> Int {
offset - moovAtomSize
}
func restructureMoov(data: Data) throws -> (initialData: Data, mdatOffset: Int) {
let (atomData, moovSize) = try doRestructureMoov(data: data)
moovAtomSize = moovSize
guard let mdatIndex = atoms.firstIndex(where: { $0.type == Atoms.mdat }) else {
throw Mp4RestructureError.missingMdatAtom
}
let mdatAtom = atoms[mdatIndex]
let atoms = Array(atoms[..<mdatIndex])
let dataOfAtomsBefore = atoms.filter { $0.data != nil }.compactMap(\.data)
let accumulatedInitialData = dataOfAtomsBefore.reduce(into: Data()) { partialResult, data in
partialResult.append(data)
}
let initialData = accumulatedInitialData + atomData
let mdatOffset: Int
if let ftyp = ftyp {
mdatOffset = ftyp.offset + ftyp.size
} else {
let freeSpaceAtoms = atoms.filter(\.isFreeSpaceAtom)
let freeSpaceSize = freeSpaceAtoms.reduce(into: 0) { partialResult, atom in
partialResult += atom.size
}
mdatOffset = mdatAtom.offset - freeSpaceSize
}
dataOptimized = true
return (initialData, mdatOffset)
}
/// Returns `nil` if the data is optimized otherwise `Mp4OptimizeInfo`
func checkIsOptimized(data: Data) throws -> Mp4OptimizeInfo? {
while atomOffset < UInt64(data.count) {
var atomSize = try Int(getInteger(data: data, offset: atomOffset) as UInt32)
let atomType = try Int(getInteger(data: data, offset: atomOffset + 4) as UInt32)
switch atomType {
case Atoms.ftyp:
let ftypData = data[Int(atomOffset) ..< atomSize]
let ftyp = MP4Atom(type: atomType, size: atomSize, offset: atomOffset, data: ftypData)
self.ftyp = ftyp
atoms.append(ftyp)
case Atoms.mdat:
// ref: https://developer.apple.com/documentation/quicktime-file-format/movie_data_atom
// This atom can be quite large, and may exceed 2^32 bytes, in which case the size field will be set to 1,
// and the header will contain a 64-bit extended size field.
if atomSize == 1 {
atomSize = Int(try getInteger(data: data, offset: atomOffset + 8) as UInt64)
}
let mdat = MP4Atom(type: atomType, size: atomSize, offset: atomOffset)
atoms.append(mdat)
foundMdat = true
case Atoms.moov:
let moov = MP4Atom(type: atomType, size: atomSize, offset: atomOffset)
atoms.append(moov)
foundMoov = true
default:
let atom = MP4Atom(type: atomType, size: atomSize, offset: atomOffset)
atoms.append(atom)
}
if ftyp != nil {
if foundMoov && !foundMdat {
Logger.debug("🕵️ detected an optimized mp4", category: .generic)
return nil
} else if !foundMoov && foundMdat {
Logger.debug("🕵️ detected an non-optimized mp4", category: .generic)
let possibleMoovOffset = Int(atomOffset) + atomSize
return Mp4OptimizeInfo(moovOffset: possibleMoovOffset, moovSize: atomSize)
}
}
atomOffset += atomSize
}
return nil
}
/// logic taken from qt-faststart.c over at ffmpeg
/// https://github.com/FFmpeg/FFmpeg/blob/b47b2c5b912558b639c8542993e1256f9c69e675/tools/qt-faststart.c
private func doRestructureMoov(data: Data) throws -> (Data, Int) {
var moovAtomSize: Int = 0
var moovAtomType: Int = 0
var originalData = ByteBuffer(data: data)
var offset: Int = 0
// do search for moov within the new data
while offset < originalData.length {
moovAtomSize = Int(try originalData.getInteger() as UInt32)
moovAtomType = Int(try originalData.getInteger() as UInt32)
if moovAtomType == Atoms.moov {
break
}
offset += moovAtomSize
}
// error if we couldn't find an moov type
guard moovAtomType == Atoms.moov else {
throw Mp4RestructureError.missingMoovAtom
}
originalData.offset = offset
var moovAtom = ByteBuffer(size: moovAtomSize)
let slicedData: Data = try originalData.readBytes(moovAtom.length)
moovAtom.writeBytes(slicedData)
moovAtom.rewind()
if try Int(moovAtom.getInteger(12) as UInt32) == Atoms.cmov {
Logger.debug("Compressed moov atom not supported", category: .generic)
throw Mp4RestructureError.compressedAtomNotSupported
}
var atomType: Int
var atomSize: Int
// crawl through the atom and restructure offsets
while moovAtom.bytesAvailable >= 8 {
let atomHead = moovAtom.offset
atomType = try Int(moovAtom.getInteger(atomHead + 4) as UInt32)
if !(atomType == Atoms.stco || atomType == Atoms.co64) {
moovAtom.offset += 1
continue
}
atomSize = try Int(moovAtom.getInteger(atomHead) as UInt32)
if atomSize > moovAtom.bytesAvailable {
Logger.debug("aborting due to a bad size on an atom", category: .generic)
throw Mp4RestructureError.unableToRestructureData
}
// we need to skip the offset by `12` which come from the bytes of [size/4][type/4][version/1][flags/3]
// more info https://developer.apple.com/documentation/quicktime-file-format/chunk_offset_atom
moovAtom.offset = atomHead + 12
if moovAtom.bytesAvailable < 4 {
Logger.debug("aborting due to a malformed atom", category: .generic)
throw Mp4RestructureError.unableToRestructureData
}
// the next integer determines the `Number of entries`
// https://developer.apple.com/documentation/quicktime-file-format/chunk_offset_atom/number_of_entries
let numberOfOffsetEntries = try Int(moovAtom.getInteger() as UInt32)
if atomType == Atoms.stco {
Logger.debug("🏗️ patching stco atom...", category: .generic)
if moovAtom.bytesAvailable < numberOfOffsetEntries * 4 {
Logger.debug("aborting due to bad atom..", category: .generic)
throw Mp4RestructureError.unableToRestructureData
}
for _ in 0 ..< numberOfOffsetEntries {
let currentOffset = try Int(moovAtom.getInteger(moovAtom.offset) as UInt32)
// adjust the offset by adding the size of moov atom
let adjustOffset = currentOffset + moovAtomSize
if currentOffset < 0, adjustOffset >= 0 {
throw Mp4RestructureError.unableToRestructureData
}
moovAtom.put(UInt32(adjustOffset).bigEndian)
}
} else if atomType == Atoms.co64 {
Logger.debug("🏗️ patching co64 atom...", category: .generic)
if moovAtom.bytesAvailable < numberOfOffsetEntries * 8 {
Logger.debug("aborting due to bad atom..", category: .generic)
throw Mp4RestructureError.unableToRestructureData
}
for _ in 0 ..< numberOfOffsetEntries {
let currentOffset: Int = try moovAtom.getInteger(moovAtom.offset)
// adjust the offset by adding the size of moov atom
moovAtom.put(currentOffset + moovAtomSize)
}
}
}
return (moovAtom.storage, moovAtomSize)
}
func getInteger<T: FixedWidthInteger>(data: Data, offset: Int) throws -> T {
let sizeOfInteger = MemoryLayout<T>.size
guard sizeOfInteger <= data.count else {
throw ByteBuffer.Error.eof
}
let _offset = offset + sizeOfInteger
return T(data: data[_offset - sizeOfInteger ..< _offset]).bigEndian
}
}
@@ -0,0 +1,146 @@
//
// Created by Dimitrios Chatzieleftheriou on 10/03/2024.
// Copyright © 2020 Decimal. All rights reserved.
//
import Foundation
final class RemoteMp4Restructure {
struct RestructuredData {
var initialData: Data
var mdatOffset: Int
}
private var audioData: Data
private var atomOffset: Int = 0
private var atoms: [MP4Atom] = []
private var ftyp: MP4Atom?
private var foundMoov = false
private var foundMdat = false
private var task: NetworkDataStream?
private(set) var dataOptimized: Bool = false
private var moovAtomSize: Int = 0
private let url: URL
private let networking: NetworkingClient
private let mp4Restructure: Mp4Restructure
init(url: URL, networking: NetworkingClient, restructure: Mp4Restructure = Mp4Restructure()) {
self.url = url
self.networking = networking
self.audioData = Data()
self.mp4Restructure = restructure
}
func clear() {
mp4Restructure.clear()
audioData = Data()
task?.cancel()
task = nil
}
/// Adjust the seekOffset of subtracting the moovAtomSize
/// - Parameter offset: A byte offset
/// - Returns: An adjusted byte offset
func seekAdjusted(offset: Int) -> Int {
mp4Restructure.seekAdjusted(offset: offset)
}
///
/// Gather audio and parse along the way, if moov atom is found, continue as usual
/// if mdat is found before moov:
/// - Get mdat size and make a byte request Range: bytes=mdatAtomSize- for possible moov atom
/// - once the request is complete search for an moov atom and restructure it
/// - finally, make a byte request Range: bytes=mdatOffset- to get the mdat
/// Atoms needs to be as following for the AudioFileStreamParse to work
/// [ftyp][moov][mdat]
///
func optimizeIfNeeded(completion: @escaping (Result<RestructuredData?, Error>) -> Void) {
task = networking.stream(request: urlForPartialContent(with: url, offset: 0))
.responseStream { [weak self] event in
guard let self else { return }
switch event {
case .response:
break
case let .stream(.success(response)):
guard let data = response.data else {
self.audioData = Data()
completion(.failure(Mp4RestructureError.unableToRestructureData))
return
}
self.audioData.append(data)
do {
let value = try self.mp4Restructure.checkIsOptimized(data: self.audioData)
if let value {
guard response.response?.statusCode == 206 else {
Logger.error("⛔️ mp4 error: no moov before mdat and the stream is not seekable", category: .networking)
completion(.failure(Mp4RestructureError.nonOptimizedMp4AndServerCannotSeek))
return
}
// stop request, fetch moov and restructure
self.audioData = Data()
self.task?.cancel()
self.task = nil
self.fetchAndRestructureMoovAtom(offset: value.moovOffset) { result in
switch result {
case let .success(value):
let data = value.data
let offset = value.offset
self.dataOptimized = true
completion(.success(RestructuredData(initialData: data, mdatOffset: offset)))
case let .failure(error):
completion(.failure(Mp4RestructureError.networkError(error)))
}
}
} else {
self.audioData = Data()
self.task?.cancel()
self.task = nil
completion(.success(nil))
}
} catch {
completion(.failure(Mp4RestructureError.invalidAtomSize))
}
case let .stream(.failure(error)):
completion(.failure(Mp4RestructureError.networkError(error)))
case .complete:
break
}
}
task?.resume()
}
func fetchAndRestructureMoovAtom(offset: Int, completion: @escaping (Result<(data: Data, offset: Int), Error>) -> Void) {
networking.task(request: urlForPartialContent(with: url, offset: offset)) { [weak self] result in
guard let self else { return }
switch result {
case let .success(data):
do {
let (initialData, mdatOffset) = try self.mp4Restructure.restructureMoov(data: data)
completion(.success((initialData, mdatOffset)))
} catch {
completion(.failure(error))
}
case let .failure(failure):
completion(.failure(Mp4RestructureError.networkError(failure)))
}
}
}
private func urlForPartialContent(with url: URL, offset: Int) -> URLRequest {
var urlRequest = URLRequest(url: url)
urlRequest.networkServiceType = .avStreaming
urlRequest.cachePolicy = .reloadIgnoringLocalCacheData
urlRequest.timeoutInterval = 60
urlRequest.addValue("*/*", forHTTPHeaderField: "Accept")
urlRequest.addValue("identity", forHTTPHeaderField: "Accept-Encoding")
urlRequest.addValue("bytes=\(offset)-", forHTTPHeaderField: "Range")
return urlRequest
}
}
@@ -8,14 +8,18 @@ import AVFoundation
import Foundation
import Network
public class RemoteAudioSource: AudioStreamSource {
weak var delegate: AudioStreamSourceDelegate?
enum RemoteAudioSourceError: Error {
case mp4NotSeekable
}
var position: Int {
public class RemoteAudioSource: AudioStreamSource {
public weak var delegate: AudioStreamSourceDelegate?
public var position: Int {
return seekOffset + relativePosition
}
var length: Int {
public var length: Int {
guard let parsedHeader = parsedHeaderOutput else { return 0 }
return parsedHeader.fileLength
}
@@ -31,23 +35,29 @@ public class RemoteAudioSource: AudioStreamSource {
private var seekOffset: Int
private var supportsSeek: Bool
internal var metadataStreamProcessor: MetadataStreamSource
var metadataStreamProcessor: MetadataStreamSource
internal var audioFileHint: AudioFileTypeID {
private var shouldTryParsingIcycastHeaders: Bool = false
private let icycastHeadersProcessor: IcycastHeadersProcessor
public var audioFileHint: AudioFileTypeID {
guard let output = parsedHeaderOutput, output.typeId != 0 else {
return audioFileType(fileExtension: url.pathExtension)
}
return output.typeId
}
internal let underlyingQueue: DispatchQueue
internal let streamOperationQueue: OperationQueue
internal let netStatusService: NetStatusProvider
internal var waitingForNetwork = false
internal let retrierTimeout: Retrier
private let mp4Restructure: RemoteMp4Restructure
public let underlyingQueue: DispatchQueue
let streamOperationQueue: OperationQueue
let netStatusService: NetStatusProvider
var waitingForNetwork = false
let retrierTimeout: Retrier
init(networking: NetworkingClient,
metadataStreamSource: MetadataStreamSource,
icycastHeadersProcessor: IcycastHeadersProcessor,
netStatusProvider: NetStatusProvider,
retrier: Retrier,
url: URL,
@@ -62,6 +72,7 @@ public class RemoteAudioSource: AudioStreamSource {
seekOffset = 0
supportsSeek = false
netStatusService = netStatusProvider
self.icycastHeadersProcessor = icycastHeadersProcessor
self.underlyingQueue = underlyingQueue
streamOperationQueue = OperationQueue()
streamOperationQueue.underlyingQueue = underlyingQueue
@@ -69,6 +80,7 @@ public class RemoteAudioSource: AudioStreamSource {
streamOperationQueue.isSuspended = true
streamOperationQueue.name = "remote.audio.source.data.stream.queue"
retrierTimeout = retrier
mp4Restructure = RemoteMp4Restructure(url: url, networking: networkingClient)
startNetworkService()
}
@@ -80,11 +92,13 @@ public class RemoteAudioSource: AudioStreamSource {
let metadataParser = MetadataParser()
let metadataProcessor = MetadataStreamProcessor(parser: metadataParser.eraseToAnyParser())
let netStatusProvider = NetStatusService(network: NWPathMonitor())
let retrierTimout = Retrier(interval: .seconds(1), maxInterval: 5, underlyingQueue: nil)
let icyheaderProcessor = IcycastHeadersProcessor()
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)
@@ -100,10 +114,9 @@ public class RemoteAudioSource: AudioStreamSource {
httpHeaders: [:])
}
func close() {
public func close() {
retrierTimeout.cancel()
netStatusService.stop()
streamOperationQueue.isSuspended = true
streamOperationQueue.isSuspended = false
streamOperationQueue.cancelAllOperations()
if let streamTask = streamRequest {
streamTask.cancel()
@@ -112,7 +125,7 @@ public class RemoteAudioSource: AudioStreamSource {
streamRequest = nil
}
func seek(at offset: Int) {
public func seek(at offset: Int) {
close()
relativePosition = 0
@@ -122,19 +135,20 @@ public class RemoteAudioSource: AudioStreamSource {
return
}
mp4Restructure.clear()
retrierTimeout.cancel()
metadataStreamProcessor.reset()
icycastHeadersProcessor.reset()
shouldTryParsingIcycastHeaders = false
performOpen(seek: offset)
}
func suspend() {
streamRequest?.suspend()
public func suspend() {
streamOperationQueue.isSuspended = true
}
func resume() {
streamRequest?.resume()
public func resume() {
streamOperationQueue.isSuspended = false
}
@@ -145,26 +159,79 @@ 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)
}
}
}
private func performOpen(seek seekOffset: Int) {
let urlRequest = buildUrlRequest(with: url, seekIfNeeded: seekOffset)
if seekOffset == 0 {
initialRequest { [weak self] in
guard let self else { return }
if self.parsedHeaderOutput?.isMp4 == true {
self.handleMp4Files()
} else {
self.doPerfomOpen(seek: 0)
}
}
} else {
if mp4Restructure.dataOptimized {
let adjustedOffset = mp4Restructure.seekAdjusted(offset: seekOffset)
doPerfomOpen(seek: adjustedOffset)
} else {
doPerfomOpen(seek: seekOffset)
}
}
}
let request = networkingClient.stream(request: urlRequest)
private func doPerfomOpen(seek seekOffset: Int) {
let urlRequest = buildUrlRequest(with: url, seekIfNeeded: seekOffset)
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
}
private func initialRequest(completion: @escaping () -> Void) {
let urlRequest = fetchUrlForPartialContent(with: url)
let task: NetworkDataStream = networkingClient.stream(request: urlRequest)
task.responseStream { [weak self] event in
switch event {
case let .response(urlResponse):
self?.parseResponseHeader(response: urlResponse)
task.cancel()
completion()
default:
break
}
}.resume()
}
private func handleMp4Files() {
mp4Restructure.optimizeIfNeeded { [weak self] result in
guard let self else { return }
switch result {
case let .success(value):
if let value {
self.addStreamOperation {
let audioCount = self.processAudio(data: value.initialData)
self.relativePosition += audioCount
}
self.doPerfomOpen(seek: value.mdatOffset)
} else {
self.doPerfomOpen(seek: 0)
}
case let .failure(failure):
self.delegate?.errorOccurred(source: self, error: failure)
}
}
}
// MARK: - Network Handle Methods
private func handleResponse(event: NetworkDataStream.ResponseEvent) {
@@ -172,42 +239,68 @@ 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 data = value.data {
addStreamOperation { [weak self] in
guard let self = self else { return }
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)
private func handleSuccessfulStreamEvent(response: NetworkDataStream.Response) {
guard let audioData = response.data else {
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)
}
self.relativePosition += data.count
}
}
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 metadataStreamProcessor.canProcessMetadata {
let extractedAudioData = metadataStreamProcessor.processMetadata(data: data)
delegate?.dataAvailable(source: self, data: extractedAudioData)
return extractedAudioData.count
} else {
delegate?.dataAvailable(source: self, data: data)
return data.count
}
}
@@ -217,20 +310,35 @@ public class RemoteAudioSource: AudioStreamSource {
let parser = HTTPHeaderParser()
parsedHeaderOutput = parser.parse(input: response)
if let acceptRanges = parser.value(forHTTPHeaderField: HeaderField.acceptRanges, in: response) {
if parsedHeaderOutput == nil {
shouldTryParsingIcycastHeaders = true
checkHTTP(statusCode: httpStatusCode)
return
}
if httpStatusCode == 206 {
supportsSeek = true
} else if let acceptRanges = parser.value(forHTTPHeaderField: HeaderField.acceptRanges, in: response) {
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)
}
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 {
delegate?.errorOccured(source: self, error: NetworkError.serverError)
delegate?.endOfFileOccurred(source: self)
} else if statusCode >= 300 {
delegate?.errorOccurred(
source: self,
error: NetworkError.serverError
)
}
}
@@ -247,16 +355,32 @@ 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
}
private func fetchUrlForPartialContent(with url: URL) -> URLRequest {
var urlRequest = URLRequest(url: url)
urlRequest.networkServiceType = .avStreaming
urlRequest.cachePolicy = .reloadIgnoringLocalCacheData
urlRequest.timeoutInterval = 60
for header in additionalRequestHeaders {
urlRequest.addValue(header.value, forHTTPHeaderField: header.key)
}
urlRequest.addValue("*/*", forHTTPHeaderField: "Accept")
urlRequest.addValue("1", forHTTPHeaderField: "Icy-MetaData")
urlRequest.addValue("identity", forHTTPHeaderField: "Accept-Encoding")
urlRequest.addValue("bytes=0-1", forHTTPHeaderField: "Range")
return urlRequest
}
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)
}
}
@@ -267,6 +391,7 @@ public class RemoteAudioSource: AudioStreamSource {
/// - Parameter block: A closure to be executed
private func addStreamOperation(_ block: @escaping () -> Void) {
let operation = BlockOperation(block: block)
operation.qualityOfService = .userInitiated
streamOperationQueue.addOperation(operation)
}
@@ -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,38 +86,45 @@ 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)!
}()
private var outputAudioFormat: AVAudioFormat = .init(commonFormat: .pcmFormatFloat32, sampleRate: 44100.0, channels: 2, interleaved: true)!
/// Keeps track of the player's state before being paused.
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
public let sourceQueue: DispatchQueue
private let entryProvider: AudioEntryProviding
@@ -125,27 +132,38 @@ public final class AudioPlayer {
public init(configuration: AudioPlayerConfiguration = .default) {
self.configuration = configuration.normalizeValues()
let engine = AVAudioEngine()
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,
outputAudioFormat: outputAudioFormat)
entryProvider = AudioEntryProvider(
networkingClient: NetworkingClient(),
underlyingQueue: sourceQueue,
outputAudioFormat: outputAudioFormat
)
fileStreamProcessor = AudioFileStreamProcessor(playerContext: playerContext,
rendererContext: rendererContext,
outputAudioFormat: outputAudioFormat.basicStreamDescription)
fileStreamProcessor = AudioFileStreamProcessor(
playerContext: playerContext,
rendererContext: rendererContext,
outputAudioFormat: outputAudioFormat.basicStreamDescription
)
playerRenderProcessor = AudioPlayerRenderProcessor(playerContext: playerContext,
rendererContext: rendererContext,
outputAudioFormat: outputAudioFormat.basicStreamDescription)
playerRenderProcessor = AudioPlayerRenderProcessor(
playerContext: playerContext,
rendererContext: rendererContext,
outputAudioFormat: outputAudioFormat.basicStreamDescription
)
frameFilterProcessor = FrameFilterProcessor(
mixerNodeProvider: {
engine.mainMixerNode
}
)
configPlayerContext()
configPlayerNode()
setupEngine()
@@ -154,7 +172,6 @@ public final class AudioPlayer {
deinit {
playerContext.audioPlayingEntry?.close()
clearQueue()
stopReadProccessFromSource()
rendererContext.clean()
}
@@ -173,6 +190,20 @@ public final class AudioPlayer {
/// - parameter headers: A `Dictionary` specifying any additional headers to be pass to the network request.
public func play(url: URL, headers: [String: String]) {
let audioEntry = entryProvider.provideAudioEntry(url: url, headers: headers)
play(audioEntry: audioEntry)
}
/// Starts the audio playback for the supplied stream
///
/// - parameter source: A `CoreAudioStreamSource` that will providing streaming data
/// - parameter entryId: A `String` that provides a unique id for this item
/// - parameter format: An `AVAudioFormat` the format of this audio source
public func play(source: CoreAudioStreamSource, entryId: String, format: AVAudioFormat) {
let audioEntry = AudioEntry(source: source, entryId: AudioEntryId(id: entryId), outputAudioFormat: format)
play(audioEntry: audioEntry)
}
private func play(audioEntry: AudioEntry) {
audioEntry.delegate = self
checkRenderWaitingAndNotifyIfNeeded()
@@ -183,14 +214,32 @@ 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()
}
}
public func playNextInQueue() {
checkRenderWaitingAndNotifyIfNeeded()
serializationQueue.sync {
if entriesQueue.count(for: .upcoming) > 0 {
playerContext.setInternalState(to: .pendingNext)
}
do {
try self.startEngineIfNeeded()
} catch {
self.raiseUnexpected(error: .audioSystemError(.engineFailure))
}
}
sourceQueue.async { [weak self] in
guard let self = self else { return }
self.processSource()
self.startReadProcessFromSourceIfNeeded()
}
}
@@ -208,15 +257,29 @@ public final class AudioPlayer {
queue(urls: urls, headers: [:])
}
/// Queues the specified URL
public func queue(url: URL, after afterUrl: URL) {
queue(url: url, headers: [:], after: afterUrl)
}
/// Queues the specified audio stream
///
/// - 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]) {
/// - parameter source: A `CoreAudioStreamSource` that will providing streaming data
/// - parameter entryId: A `String` that provides a unique id for this item
/// - parameter format: An `AVAudioFormat` the format of this audio source
public func queue(source: CoreAudioStreamSource, entryId: String, format: AVAudioFormat) {
let audioEntry = AudioEntry(source: source, entryId: AudioEntryId(id: entryId), outputAudioFormat: format)
queue(audioEntry: audioEntry)
}
public func removeFromQueue(url: URL) {
serializationQueue.sync {
let audioEntry = entryProvider.provideAudioEntry(url: url, headers: headers)
audioEntry.delegate = self
entriesQueue.enqueue(item: audioEntry, type: .upcoming)
if let item = entriesQueue.items(type: .upcoming).first(where: { $0.id.id == url.absoluteString }) {
entriesQueue.remove(item: item, type: .upcoming)
if playerContext.audioPlayingEntry?.id.id == item.id.id {
stop(clearQueue: false)
}
}
}
checkRenderWaitingAndNotifyIfNeeded()
sourceQueue.async { [weak self] in
@@ -224,6 +287,15 @@ public final class AudioPlayer {
}
}
/// Queues the specified URL
///
/// - Parameter url: A `URL` specifying the audio content to be played.
/// - parameter headers: A `Dictionary` specifying any additional headers to be pass to the network request.
public func queue(url: URL, headers: [String: String], after afterUrl: URL? = nil) {
let audioEntry = entryProvider.provideAudioEntry(url: url, headers: headers)
queue(audioEntry: audioEntry, after: afterUrl)
}
/// Queues the specified URLs
///
/// - Parameter url: A array of `URL`s specifying the audio content to be played.
@@ -242,11 +314,27 @@ public final class AudioPlayer {
}
}
private func queue(audioEntry: AudioEntry, after afterUrl: URL? = nil) {
serializationQueue.sync {
audioEntry.delegate = self
if let afterUrl = afterUrl {
if let afterUrlEntry = entriesQueue.items(type: .upcoming).first(where: { $0.id.id == afterUrl.absoluteString }) {
entriesQueue.insert(item: audioEntry, type: .upcoming, after: afterUrlEntry)
}
} else {
entriesQueue.enqueue(item: audioEntry, type: .upcoming)
}
}
checkRenderWaitingAndNotifyIfNeeded()
sourceQueue.async { [weak self] in
self?.processSource()
}
}
/// Stops the audio playback
public func stop() {
public func stop(clearQueue: Bool = true) {
guard playerContext.internalState != .stopped else { return }
stopReadProccessFromSource()
serializationQueue.sync {
stopEngine(reason: .userAction)
}
@@ -259,7 +347,9 @@ public final class AudioPlayer {
self.processFinishPlaying(entry: playingEntry, with: nil)
}
self.clearQueue()
if clearQueue {
self.clearQueue()
}
self.playerContext.entriesLock.lock()
self.playerContext.audioReadingEntry = nil
self.playerContext.audioPlayingEntry = nil
@@ -277,7 +367,6 @@ public final class AudioPlayer {
serializationQueue.sync {
pauseEngine()
}
stopReadProccessFromSource()
playerContext.audioPlayingEntry?.suspend()
sourceQueue.async { [weak self] in
self?.processSource()
@@ -303,9 +392,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,12 +418,18 @@ 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
for node in nodes {
customAttachedNodes.append(node)
}
nodes.forEach(audioEngine.attach)
@@ -341,6 +437,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,8 +448,10 @@ 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
for node in customAttachedNodes {
audioEngine.detach(node)
}
attachAndConnectDefaultNodes()
@@ -385,7 +485,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 +500,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 +528,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 +553,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)
@@ -489,6 +589,7 @@ public final class AudioPlayer {
/// Pauses the audio engine and stops the player's hardware
private func pauseEngine() {
guard isEngineRunning else { return }
audioEngine.reset()
audioEngine.pause()
player.auAudioUnit.stopHardware()
Logger.debug("engine paused ⏸", category: .generic)
@@ -506,24 +607,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 +620,7 @@ public final class AudioPlayer {
try player.auAudioUnit.startHardware()
} catch {
stopEngine(reason: .error)
raiseUnxpected(error: .audioSystemError(.playerStartError))
raiseUnexpected(error: .audioSystemError(.playerStartError))
}
}
@@ -544,7 +628,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 {
@@ -553,8 +636,8 @@ public final class AudioPlayer {
setCurrentReading(entry: entry, startPlaying: true, shouldClearQueue: true)
rendererContext.resetBuffers()
} else if let playingEntry = playerContext.audioPlayingEntry,
playingEntry.seekRequest.requested,
playingEntry != playerContext.audioReadingEntry
playingEntry.seekRequest.requested,
playingEntry != playerContext.audioReadingEntry
{
playingEntry.audioStreamState.processedDataFormat = false
playingEntry.reset()
@@ -581,7 +664,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 +679,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 +691,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,44 +729,41 @@ 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
let notifyDelegateEntryFinishedPlaying: (AudioEntry?, Bool) -> Void = { [weak self] entry, _ in
guard let self = self else { return }
if let entry = entry, !isPlayingSameItemProbablySeek {
let entryId = entry.id
let progressInFrames = entry.progressInFrames()
let progress = Double(progressInFrames) / self.outputAudioFormat.basicStreamDescription.mSampleRate
let duration = entry.duration()
asyncOnMain {
self.delegate?.audioPlayerDidFinishPlaying(player: self,
entryId: entryId,
stopReason: self.stopReason,
progress: progress,
duration: duration)
}
}
}
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 let entry = entry, !isPlayingSameItemProbablySeek {
let entryId = entry.id
let progressInFrames = entry.progressInFrames()
let progress = Double(progressInFrames) / outputAudioFormat.basicStreamDescription.mSampleRate
let duration = entry.duration()
asyncOnMain { [weak self] in
guard let self else { return }
self.delegate?.audioPlayerDidFinishPlaying(
player: self,
entryId: entryId,
stopReason: self.stopReason,
progress: progress,
duration: duration
)
}
}
if !isPlayingSameItemProbablySeek {
playerContext.setInternalState(to: .waitingForData)
@@ -694,10 +773,29 @@ public final class AudioPlayer {
}
}
} else {
notifyDelegateEntryFinishedPlaying(entry, isPlayingSameItemProbablySeek)
playerContext.entriesLock.lock()
playerContext.audioPlayingEntry = nil
playerContext.entriesLock.unlock()
if let entry = entry, !isPlayingSameItemProbablySeek {
let entryId = entry.id
let progressInFrames = entry.progressInFrames()
let progress = Double(progressInFrames) / outputAudioFormat.basicStreamDescription.mSampleRate
let duration = entry.duration()
sourceQueue.async { [weak self] in
guard let self else { return }
self.processSource()
asyncOnMain {
self.delegate?.audioPlayerDidFinishPlaying(
player: self,
entryId: entryId,
stopReason: self.stopReason,
progress: progress,
duration: duration
)
}
}
}
}
sourceQueue.async { [weak self] in
self?.processSource()
@@ -724,9 +822,8 @@ 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
guard let self = self else { return }
self.delegate?.audioPlayerUnexpectedError(player: self, error: error)
@@ -736,7 +833,7 @@ public final class AudioPlayer {
}
extension AudioPlayer: AudioStreamSourceDelegate {
func dataAvailable(source: CoreAudioStreamSource, data: Data) {
public func dataAvailable(source: CoreAudioStreamSource, data: Data) {
guard let readingEntry = playerContext.audioReadingEntry, readingEntry.has(same: source) else {
return
}
@@ -745,7 +842,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 +852,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 +863,12 @@ extension AudioPlayer: AudioStreamSourceDelegate {
}
}
func errorOccured(source: CoreAudioStreamSource, error: Error) {
public 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) {
public func endOfFileOccurred(source: CoreAudioStreamSource) {
let hasSameSource = playerContext.audioReadingEntry?.has(same: source) ?? false
guard playerContext.audioReadingEntry == nil || hasSameSource else {
source.delegate = nil
@@ -802,10 +899,13 @@ 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]) {
public func metadataReceived(data: [String: String]) {
asyncOnMain { [weak self] in
guard let self = self else { return }
self.delegate?.audioPlayerDidReadMetadata(player: self, metadata: data)
@@ -13,20 +13,20 @@ 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
public static let `default` = AudioPlayerConfiguration(flushQueueOnSeek: true,
public static let `default` = AudioPlayerConfiguration(flushQueueOnSeek: false,
bufferSizeInSeconds: 10,
secondsRequiredToStartPlaying: 1,
gracePeriodAfterSeekInSeconds: 0.5,
secondsRequiredToStartPlayingAfterBufferUnderun: 1,
secondsRequiredToStartPlayingAfterBufferUnderrun: 7,
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)
}
}
@@ -5,31 +5,32 @@
import Foundation
internal final class AudioPlayerContext {
var stopReason = Protected<AudioPlayerStopReason>(.none)
final class AudioPlayerContext {
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.
@@ -37,11 +38,13 @@ internal final class AudioPlayerContext {
/// - parameter state: The new `PlayerInternalState`
/// - parameter inState: If the `inState` expression is not nil, the internalState will be set if the evaluated expression is `true`
/// - NOTE: This sets the underlying `__playerInternalState` variable
internal func setInternalState(to state: AudioPlayer.InternalState,
when inState: ((AudioPlayer.InternalState) -> Bool)? = nil)
func setInternalState(to state: AudioPlayer.InternalState,
when inState: ((AudioPlayer.InternalState) -> Bool)? = nil)
{
let newValues = playerStateAndStopReason(for: state)
stopReason.write { $0 = newValues.stopReason }
if let stopReason = newValues.stopReason {
self.stopReason.write { $0 = stopReason }
}
guard state != internalState else { return }
if let inState = inState, !inState(internalState) {
return
@@ -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)
@@ -13,11 +13,11 @@ extension AudioPlayer {
static let initial = InternalState([])
static let running = InternalState(rawValue: 1)
static let playing = InternalState(rawValue: 1 << 1 | InternalState.running.rawValue)
static let rebuffering = InternalState(rawValue: 1 << 2 | InternalState.running.rawValue)
static let waitingForData = InternalState(rawValue: 1 << 3 | InternalState.running.rawValue)
static let waitingForDataAfterSeek = InternalState(rawValue: 1 << 4 | InternalState.running.rawValue)
static let paused = InternalState(rawValue: 1 << 5 | InternalState.running.rawValue)
static let playing = InternalState(rawValue: (1 << 1) | InternalState.running.rawValue)
static let rebuffering = InternalState(rawValue: (1 << 2) | InternalState.running.rawValue)
static let waitingForData = InternalState(rawValue: (1 << 3) | InternalState.running.rawValue)
static let waitingForDataAfterSeek = InternalState(rawValue: (1 << 4) | InternalState.running.rawValue)
static let paused = InternalState(rawValue: (1 << 5) | InternalState.running.rawValue)
static let stopped = InternalState(rawValue: 1 << 9)
static let pendingNext = InternalState(rawValue: 1 << 10)
static let disposed = InternalState(rawValue: 1 << 30)
@@ -30,26 +30,26 @@ extension AudioPlayer {
/// Helper method that returns `AudioPlayerState` and `StopReason` based on the given `InternalState`
/// - Parameter internalState: A value of `InternalState`
/// - Returns: A tuple of `(AudioPlayerState, AudioPlayerStopReason)`
func playerStateAndStopReason(for internalState: AudioPlayer.InternalState) -> (state: AudioPlayerState,
stopReason: AudioPlayerStopReason)
{
func playerStateAndStopReason(
for internalState: AudioPlayer.InternalState
) -> (state: AudioPlayerState, stopReason: AudioPlayerStopReason?) {
switch internalState {
case .initial:
return (.ready, .none)
return (.ready, AudioPlayerStopReason.none)
case .running, .playing, .waitingForDataAfterSeek:
return (.playing, .none)
return (.playing, AudioPlayerStopReason.none)
case .pendingNext, .rebuffering, .waitingForData:
return (.bufferring, .none)
return (.bufferring, AudioPlayerStopReason.none)
case .stopped:
return (.stopped, .userAction)
return (.stopped, nil)
case .paused:
return (.paused, .none)
return (.paused, AudioPlayerStopReason.none)
case .disposed:
return (.disposed, .userAction)
case .error:
return (.error, .error)
return (.error, AudioPlayerStopReason.error)
default:
return (.ready, .none)
return (.ready, AudioPlayerStopReason.none)
}
}
@@ -6,27 +6,25 @@
import AVFoundation
import CoreAudio
internal var maxFramesPerSlice: AVAudioFrameCount = 8192
var maxFramesPerSlice: AVAudioFrameCount = 8192
final class AudioRendererContext {
var waiting = Protected<Bool>(false)
let waiting = Atomic<Bool>(false)
let lock = UnfairLock()
let bufferContext: BufferContext
var audioBuffer: AudioBuffer
var inOutAudioBufferList: UnsafeMutablePointer<AudioBufferList>
let audioBuffer: AudioBuffer
let inOutAudioBufferList: UnsafeMutablePointer<AudioBufferList>
let packetsSemaphore = DispatchSemaphore(value: 0)
var discontinuous: Bool = false
let framesRequiredToStartPlaying: Double
let framesRequiredAfterRebuffering: Double
let framesRequiredForDataAfterSeekPlaying: Double
let framesRequiredToStartPlaying: UInt32
let framesRequiredAfterRebuffering: UInt32
let framesRequiredForDataAfterSeekPlaying: UInt32
var waitingForDataAfterSeekFrameCount = Protected<Int32>(0)
let waitingForDataAfterSeekFrameCount = Atomic<Int32>(0)
private let configuration: AudioPlayerConfiguration
@@ -35,9 +33,9 @@ final class AudioRendererContext {
let canonicalStream = outputAudioFormat.basicStreamDescription
framesRequiredToStartPlaying = UInt32(canonicalStream.mSampleRate) * UInt32(configuration.secondsRequiredToStartPlaying)
framesRequiredAfterRebuffering = UInt32(canonicalStream.mSampleRate) * UInt32(configuration.secondsRequiredToStartPlayingAfterBufferUnderun)
framesRequiredForDataAfterSeekPlaying = UInt32(canonicalStream.mSampleRate) * UInt32(configuration.gracePeriodAfterSeekInSeconds)
framesRequiredToStartPlaying = Double(canonicalStream.mSampleRate) * Double(configuration.secondsRequiredToStartPlaying)
framesRequiredAfterRebuffering = Double(canonicalStream.mSampleRate) * Double(configuration.secondsRequiredToStartPlayingAfterBufferUnderrun)
framesRequiredForDataAfterSeekPlaying = Double(canonicalStream.mSampleRate) * Double(configuration.gracePeriodAfterSeekInSeconds)
let dataByteSize = Int(canonicalStream.mSampleRate * configuration.bufferSizeInSeconds) * Int(canonicalStream.mBytesPerFrame)
inOutAudioBufferList = allocateBufferList(dataByteSize: dataByteSize)
@@ -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
@@ -34,12 +34,13 @@ final class AudioFileStreamProcessor {
private let rendererContext: AudioRendererContext
private let outputAudioFormat: AudioStreamBasicDescription
internal var audioFileStream: AudioFileStreamID?
internal var audioConverter: AudioConverterRef?
internal var discontinuous: Bool = false
internal var inputFormat = AudioStreamBasicDescription()
internal var fileFormat: String = ""
internal let fa4mFormat = "fa4m"
var audioFileStream: AudioFileStreamID?
var audioConverter: AudioConverterRef?
var discontinuous: Bool = false
var inputFormat = AudioStreamBasicDescription()
var currentFileFormat: String = ""
let fileFormatsForDelayedConverterCreation: Set = ["fa4m", "f4pm"]
var isFileStreamOpen: Bool {
audioFileStream != nil
@@ -115,26 +116,19 @@ 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))
let seekStatus = AudioFileStreamSeek(stream, seekPacket, &packetsAlignedByteOffset, &ioFlags)
guard seekStatus == noErr else {
let streamError = AudioFileStreamError(status: seekStatus)
Logger.error("seek failed %@", category: .generic, args: streamError.debugDescription)
return
}
let dataOffset = Int64(readingEntry.audioStreamState.dataOffset)
if !ioFlags.contains(.offsetIsEstimated) {
seekByteOffset = packetsAlignedByteOffset + dataOffset
let delta = Double((seekByteOffset - dataOffset) - packetsAlignedByteOffset) / bitrate * 8
if seekStatus == noErr, !ioFlags.contains(.offsetIsEstimated) {
let delta = Double((seekByteOffset - dataOffset) - packetsAlignedByteOffset) / (bitrate * 8)
readingEntry.lock.lock()
readingEntry.seekTime -= delta
readingEntry.lock.unlock()
seekByteOffset = packetsAlignedByteOffset + dataOffset
}
}
@@ -165,7 +159,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 +172,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 +188,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))))
@@ -219,41 +214,49 @@ final class AudioFileStreamProcessor {
propertyId: AudioFileStreamPropertyID,
flags _: UnsafeMutablePointer<AudioFileStreamPropertyFlags>)
{
guard let entry = playerContext.audioReadingEntry else { return }
switch propertyId {
case kAudioFileStreamProperty_DataOffset:
processDataOffset(fileStream: fileStream)
processDataOffset(entry: entry, fileStream: fileStream)
case kAudioFileStreamProperty_FileFormat:
processFileFormat(fileStream: fileStream)
case kAudioFileStreamProperty_DataFormat:
processDataFormat(fileStream: fileStream)
processDataFormat(entry: entry, fileStream: fileStream)
case kAudioFileStreamProperty_AudioDataByteCount:
processDataByteCount(fileStream: fileStream)
processDataByteCount(entry: entry, fileStream: fileStream)
case kAudioFileStreamProperty_AudioDataPacketCount:
proccessAudioDataPacketCount(fileStream: fileStream)
processAudioDataPacketCount(entry: entry, fileStream: fileStream)
case kAudioFileStreamProperty_ReadyToProducePackets:
// check converter for discontious stream
processReadyToProducePackets(fileStream: fileStream)
// check converter for discontinuous stream
assignMagicCookieToConverterIfNeeded()
processPacketUpperBoundAndMaxPacketSize(entry: entry, fileStream: fileStream)
processReadyToProducePackets(entry: entry, fileStream: fileStream)
case kAudioFileStreamProperty_FormatList:
processFormatList(fileStream: fileStream)
default: break
processFormatList(entry: entry, fileStream: fileStream)
default:
break
}
}
// MARK: AudioFileStream properties Proccessing
// MARK: AudioFileStream properties Processing
private func processDataOffset(fileStream: AudioFileStreamID) {
private func processDataOffset(entry: AudioEntry, fileStream: AudioFileStreamID) {
var offset: UInt64 = 0
fileStreamGetProperty(value: &offset, fileStream: fileStream, propertyId: kAudioFileStreamProperty_DataOffset)
playerContext.audioReadingEntry?.audioStreamState.processedDataFormat = true
playerContext.audioReadingEntry?.audioStreamState.dataOffset = offset
entry.lock.lock(); defer { entry.lock.unlock() }
entry.audioStreamState.processedDataFormat = true
entry.audioStreamState.dataOffset = offset
}
private func processReadyToProducePackets(fileStream: AudioFileStreamID) {
private func processReadyToProducePackets(entry: AudioEntry, fileStream: AudioFileStreamID) {
var packetCount: UInt64 = 0
var packetCountSize = UInt32(MemoryLayout.size(ofValue: packetCount))
AudioFileStreamGetProperty(fileStream, kAudioFileStreamProperty_AudioDataPacketCount, &packetCountSize, &packetCount)
playerContext.audioPlayingEntry?.audioStreamState.dataPacketCount = Double(packetCount)
if playerContext.audioPlayingEntry?.audioStreamFormat.mFormatID != kAudioFormatLinearPCM {
entry.lock.lock(); defer { entry.lock.unlock() }
entry.audioStreamState.dataPacketCount = Double(packetCount)
let entryFormatID = entry.audioStreamFormat.mFormatID
let isFLAC = entryFormatID == kAudioFormatFLAC
if entryFormatID != kAudioFormatLinearPCM && !isFLAC {
discontinuous = true
}
}
@@ -263,13 +266,13 @@ 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
}
}
private func processDataFormat(fileStream: AudioFileStreamID) {
private func processDataFormat(entry: AudioEntry, fileStream: AudioFileStreamID) {
var audioStreamFormat = AudioStreamBasicDescription()
guard let entry = playerContext.audioReadingEntry else { return }
entry.lock.lock(); defer { entry.lock.unlock() }
if !entry.audioStreamState.processedDataFormat {
fileStreamGetProperty(value: &audioStreamFormat, fileStream: fileStream, propertyId: kAudioFileStreamProperty_DataFormat)
@@ -277,6 +280,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,31 +295,49 @@ final class AudioFileStreamProcessor {
packetBufferSize = 2048 // default value
}
}
entry.lock.around {
entry.processedPacketsState.bufferSize = packetBufferSize
}
if fileFormat != fa4mFormat {
entry.processedPacketsState.bufferSize = packetBufferSize
if !fileFormatsForDelayedConverterCreation.contains(currentFileFormat) {
createAudioConverter(from: entry.audioStreamFormat, to: outputAudioFormat)
}
}
}
private func processDataByteCount(fileStream: AudioFileStreamID) {
guard let entry = playerContext.audioReadingEntry else { return }
private func processPacketUpperBoundAndMaxPacketSize(entry: AudioEntry, fileStream: AudioFileStreamID) {
var packetBufferSize: UInt32 = 0
var status = fileStreamGetProperty(value: &packetBufferSize,
fileStream: fileStream,
propertyId: kAudioFileStreamProperty_PacketSizeUpperBound)
if status != 0 || packetBufferSize == 0 {
status = fileStreamGetProperty(value: &packetBufferSize,
fileStream: fileStream,
propertyId: kAudioFileStreamProperty_MaximumPacketSize)
if status != 0 || packetBufferSize == 0 {
packetBufferSize = 2048 // default value
}
}
entry.lock.withLock {
entry.processedPacketsState.bufferSize = packetBufferSize
}
}
private func processDataByteCount(entry: AudioEntry, fileStream: AudioFileStreamID) {
var audioDataByteCount: UInt64 = 0
fileStreamGetProperty(value: &audioDataByteCount, fileStream: fileStream, propertyId: kAudioFileStreamProperty_AudioDataByteCount)
entry.lock.lock(); defer { entry.lock.unlock() }
entry.audioStreamState.dataByteCount = audioDataByteCount
}
private func proccessAudioDataPacketCount(fileStream: AudioFileStreamID) {
guard let entry = playerContext.audioReadingEntry else { return }
private func processAudioDataPacketCount(entry: AudioEntry, fileStream: AudioFileStreamID) {
var audioDataPacketCount: UInt64 = 0
fileStreamGetProperty(value: &audioDataPacketCount, fileStream: fileStream, propertyId: kAudioFileStreamProperty_AudioDataPacketCount)
entry.lock.lock(); defer { entry.lock.unlock() }
entry.audioStreamState.dataPacketOffset = audioDataPacketCount
}
private func processFormatList(fileStream: AudioFileStreamID) {
private func processFormatList(entry: AudioEntry, fileStream: AudioFileStreamID) {
entry.lock.lock(); defer { entry.lock.unlock() }
let info = fileStreamGetPropertyInfo(fileStream: fileStream, propertyId: kAudioFileStreamProperty_FormatList)
guard info.status == noErr else { return }
var list: [AudioFormatListItem] = Array(repeating: AudioFormatListItem(), count: Int(info.size))
@@ -331,7 +355,7 @@ final class AudioFileStreamProcessor {
i += step
}
if fileFormat == fa4mFormat {
if fileFormatsForDelayedConverterCreation.contains(currentFileFormat) {
if let inputStreamFormat = playerContext.audioReadingEntry?.audioStreamFormat {
createAudioConverter(from: inputStreamFormat, to: outputAudioFormat)
}
@@ -340,51 +364,56 @@ final class AudioFileStreamProcessor {
// MARK: Packets Proc
func propertyPacketsProc(inNumberBytes: UInt32,
inNumberPackets: UInt32,
inInputData: UnsafeRawPointer,
inPacketDescriptions: UnsafeMutablePointer<AudioStreamPacketDescription>?)
{
func propertyPacketsProc(
inNumberBytes: UInt32,
inNumberPackets: UInt32,
inInputData: UnsafeRawPointer,
inPacketDescriptions: UnsafeMutablePointer<AudioStreamPacketDescription>?
) {
guard let entry = playerContext.audioReadingEntry else { return }
guard entry.audioStreamState.processedDataFormat, !playerContext.disposedRequested else { return }
if let playingEntry = playerContext.audioPlayingEntry,
playingEntry.seekRequest.requested, playingEntry.calculatedBitrate() > 0
{
fileStreamCallback?(.proccessSource)
if rendererContext.waiting.value {
rendererContext.packetsSemaphore.signal()
}
return
}
guard entry.audioStreamState.processedDataFormat else { return }
guard let converter = audioConverter else {
Logger.error("Couldn't find audio converter", category: .audioRendering)
return
}
if let playingEntry = playerContext.audioPlayingEntry,
playingEntry.seekRequest.requested, playingEntry.calculatedBitrate() > 0
{
fileStreamCallback?(.processSource)
if rendererContext.waiting.value {
rendererContext.packetsSemaphore.signal()
}
return
}
// reset discontinuity
discontinuous = false
var convertInfo = AudioConvertInfo(done: false,
numberOfPackets: inNumberPackets,
packDescription: inPacketDescriptions)
var convertInfo = AudioConvertInfo(
done: false,
numberOfPackets: inNumberPackets,
packDescription: inPacketDescriptions
)
convertInfo.audioBuffer.mData = UnsafeMutableRawPointer(mutating: inInputData)
convertInfo.audioBuffer.mDataByteSize = inNumberBytes
if let playingAudioStreamFormat = playerContext.audioPlayingEntry?.audioStreamFormat {
convertInfo.audioBuffer.mNumberChannels = playingAudioStreamFormat.mChannelsPerFrame
}
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
var start = bufferContext.frameStartIndex
var end = bufferContext.end
var end = (bufferContext.frameStartIndex + bufferContext.frameUsedCount) % bufferContext.totalFrameCount
var framesLeftInBuffer = bufferContext.totalFrameCount - used
rendererContext.lock.unlock()
@@ -401,8 +430,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 +440,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()
}
@@ -433,16 +461,20 @@ final class AudioFileStreamProcessor {
var framesToDecode: UInt32 = rendererContext.bufferContext.totalFrameCount - end
let offset = Int(end * rendererContext.bufferContext.sizeInBytes)
prefillLocalBufferList(bufferList: localBufferList,
dataOffset: offset,
framesToDecode: framesToDecode)
prefillLocalBufferList(
bufferList: localBufferList,
dataOffset: offset,
framesToDecode: framesToDecode
)
status = AudioConverterFillComplexBuffer(converter,
_converterCallback,
&convertInfo,
&framesToDecode,
localBufferList.unsafeMutablePointer,
nil)
status = AudioConverterFillComplexBuffer(
converter,
_converterCallback,
&convertInfo,
&framesToDecode,
localBufferList.unsafeMutablePointer,
nil
)
framesAdded = framesToDecode
@@ -457,27 +489,31 @@ final class AudioFileStreamProcessor {
framesToDecode = start
if framesToDecode == 0 {
fillUsedFrames(framesCount: framesAdded)
continue packetProccess
continue packetProcess
}
prefillLocalBufferList(bufferList: localBufferList,
dataOffset: 0,
framesToDecode: framesToDecode)
prefillLocalBufferList(
bufferList: localBufferList,
dataOffset: 0,
framesToDecode: framesToDecode
)
status = AudioConverterFillComplexBuffer(converter,
_converterCallback,
&convertInfo,
&framesToDecode,
localBufferList.unsafeMutablePointer,
nil)
status = AudioConverterFillComplexBuffer(
converter,
_converterCallback,
&convertInfo,
&framesToDecode,
localBufferList.unsafeMutablePointer,
nil
)
framesAdded += framesToDecode
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
@@ -487,24 +523,28 @@ final class AudioFileStreamProcessor {
var framesToDecode: UInt32 = start - end
let offset = Int(end * rendererContext.bufferContext.sizeInBytes)
prefillLocalBufferList(bufferList: localBufferList,
dataOffset: offset,
framesToDecode: framesToDecode)
prefillLocalBufferList(
bufferList: localBufferList,
dataOffset: offset,
framesToDecode: framesToDecode
)
status = AudioConverterFillComplexBuffer(converter,
_converterCallback,
&convertInfo,
&framesToDecode,
localBufferList.unsafeMutablePointer,
nil)
status = AudioConverterFillComplexBuffer(
converter,
_converterCallback,
&convertInfo,
&framesToDecode,
localBufferList.unsafeMutablePointer,
nil
)
framesAdded = framesToDecode
if status == AudioConvertStatus.done.rawValue {
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
@@ -519,10 +559,11 @@ final class AudioFileStreamProcessor {
/// - parameter dataOffset: An `Int` value indicating any offset to be applied to the buffer data
/// - parameter framesToDecode: An `UInt32` value indicating the frames to be decoded, used in calculating the data size of the buffer.
@inline(__always)
private func prefillLocalBufferList(bufferList: UnsafeMutableAudioBufferListPointer,
dataOffset: Int,
framesToDecode: UInt32)
{
private func prefillLocalBufferList(
bufferList: UnsafeMutableAudioBufferListPointer,
dataOffset: Int,
framesToDecode: UInt32
) {
if let mData = rendererContext.audioBuffer.mData {
bufferList[0].mData = dataOffset > 0 ? mData + dataOffset : mData
}
@@ -545,9 +586,10 @@ 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 }
let processedPackCount = readingEntry.processedPacketsState.count
@@ -567,23 +609,25 @@ final class AudioFileStreamProcessor {
// MARK: - AudioFileStream proc method
private func _propertyListenerProc(clientData: UnsafeMutableRawPointer,
fileStream: AudioFileStreamID,
propertyId: AudioFileStreamPropertyID,
flags: UnsafeMutablePointer<AudioFileStreamPropertyFlags>)
{
private func _propertyListenerProc(
clientData: UnsafeMutableRawPointer,
fileStream: AudioFileStreamID,
propertyId: AudioFileStreamPropertyID,
flags: UnsafeMutablePointer<AudioFileStreamPropertyFlags>
) {
let processor = clientData.to(type: AudioFileStreamProcessor.self)
processor.propertyListenerProc(fileStream: fileStream,
propertyId: propertyId,
flags: flags)
}
private func _propertyPacketsProc(clientData: UnsafeMutableRawPointer,
inNumberBytes: UInt32,
inNumberPackets: UInt32,
inInputData: UnsafeRawPointer,
inPacketDescriptions: UnsafeMutablePointer<AudioStreamPacketDescription>?)
{
private func _propertyPacketsProc(
clientData: UnsafeMutableRawPointer,
inNumberBytes: UInt32,
inNumberPackets: UInt32,
inInputData: UnsafeRawPointer,
inPacketDescriptions: UnsafeMutablePointer<AudioStreamPacketDescription>?
) {
let processor = clientData.to(type: AudioFileStreamProcessor.self)
processor.propertyPacketsProc(inNumberBytes: inNumberBytes,
inNumberPackets: inNumberPackets,
@@ -593,12 +637,13 @@ private func _propertyPacketsProc(clientData: UnsafeMutableRawPointer,
// MARK: - AudioConverterFillComplexBuffer callback method
private func _converterCallback(inAudioConverter _: AudioConverterRef,
ioNumberDataPackets: UnsafeMutablePointer<UInt32>,
ioData: UnsafeMutablePointer<AudioBufferList>,
outDataPacketDescription: UnsafeMutablePointer<UnsafeMutablePointer<AudioStreamPacketDescription>?>?,
inUserData: UnsafeMutableRawPointer?) -> OSStatus
{
private func _converterCallback(
inAudioConverter _: AudioConverterRef,
ioNumberDataPackets: UnsafeMutablePointer<UInt32>,
ioData: UnsafeMutablePointer<AudioBufferList>,
outDataPacketDescription: UnsafeMutablePointer<UnsafeMutablePointer<AudioStreamPacketDescription>?>?,
inUserData: UnsafeMutableRawPointer?
) -> OSStatus {
guard let convertInfo = inUserData?.assumingMemoryBound(to: AudioConvertInfo.self) else { return 0 }
// we need to tell the converter to stop converting after it should stop converting
@@ -618,12 +663,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))
@@ -64,33 +64,34 @@ final class AudioPlayerRenderProcessor: NSObject {
let frameSizeInBytes = bufferContext.sizeInBytes
let used = bufferContext.frameUsedCount
let start = bufferContext.frameStartIndex
let end = bufferContext.end
let end = (bufferContext.frameStartIndex + bufferContext.frameUsedCount) % bufferContext.totalFrameCount
let signal = rendererContext.waiting.value && used < bufferContext.totalFrameCount / 2
if let playingEntry = playingEntry {
playingEntry.lock.lock()
let framesState = playingEntry.framesState
playingEntry.lock.unlock()
if state == .waitingForData {
var requiredFramesToStart = rendererContext.framesRequiredToStartPlaying
if framesState.lastFrameQueued >= 0 {
requiredFramesToStart = min(requiredFramesToStart, UInt32(playingEntry.framesState.lastFrameQueued))
requiredFramesToStart = min(requiredFramesToStart, Double(playingEntry.framesState.lastFrameQueued))
}
if let readingEntry = readingEntry, readingEntry === playingEntry,
framesState.queued < requiredFramesToStart
if readingEntry === playingEntry, framesState.queued < Int(requiredFramesToStart)
{
waitForBuffer = true
}
} else if state == .rebuffering {
var requiredFramesToStart = rendererContext.framesRequiredAfterRebuffering
if framesState.lastFrameQueued >= 0 {
requiredFramesToStart = min(requiredFramesToStart, UInt32(framesState.lastFrameQueued - framesState.queued))
requiredFramesToStart = min(requiredFramesToStart, Double(framesState.lastFrameQueued - framesState.queued))
}
if used < requiredFramesToStart {
if used < Int(requiredFramesToStart) {
waitForBuffer = true
}
} else if state == .waitingForDataAfterSeek {
var requiredFramesToStart: Int = 1024
var requiredFramesToStart = 1024
if framesState.lastFrameQueued >= 0 {
requiredFramesToStart = min(requiredFramesToStart, framesState.lastFrameQueued - framesState.queued)
}
@@ -102,21 +103,19 @@ final class AudioPlayerRenderProcessor: NSObject {
rendererContext.lock.unlock()
var totalFramesCopied: UInt32 = 0
if used > 0 && !waitForBuffer && state.contains(.running) && state != .paused {
if used > 0 && !waitForBuffer && playingEntry != nil && state.contains(.running) && state != .paused {
if end > start {
let framesToCopy = min(inNumberFrames, used)
bufferList.mBuffers.mNumberChannels = 2
bufferList.mBuffers.mDataByteSize = frameSizeInBytes * framesToCopy
if isMuted {
writeSilence(outputBuffer: &bufferList.mBuffers,
outputBufferSize: 0,
offset: Int(bufferList.mBuffers.mDataByteSize))
if let mData = bufferList.mBuffers.mData {
memset(mData, 0, Int(bufferList.mBuffers.mDataByteSize))
}
} else {
if let mDataBuffer = audioBuffer.mData {
memcpy(bufferList.mBuffers.mData,
mDataBuffer + Int(start * frameSizeInBytes),
Int(bufferList.mBuffers.mDataByteSize))
memcpy(bufferList.mBuffers.mData, mDataBuffer + Int(start * frameSizeInBytes), Int(bufferList.mBuffers.mDataByteSize))
}
}
totalFramesCopied = framesToCopy
@@ -132,14 +131,12 @@ final class AudioPlayerRenderProcessor: NSObject {
bufferList.mBuffers.mDataByteSize = frameSizeInBytes * frameToCopy
if isMuted {
writeSilence(outputBuffer: &bufferList.mBuffers,
outputBufferSize: 0,
offset: Int(bufferList.mBuffers.mDataByteSize))
if let mData = bufferList.mBuffers.mData {
memset(mData, 0, Int(bufferList.mBuffers.mDataByteSize))
}
} else {
if let mDataBuffer = audioBuffer.mData {
memcpy(bufferList.mBuffers.mData,
mDataBuffer + Int(start * frameSizeInBytes),
Int(bufferList.mBuffers.mDataByteSize))
memcpy(bufferList.mBuffers.mData, mDataBuffer + Int(start * frameSizeInBytes), Int(bufferList.mBuffers.mDataByteSize))
}
}
@@ -151,14 +148,10 @@ final class AudioPlayerRenderProcessor: NSObject {
bufferList.mBuffers.mDataByteSize += frameSizeInBytes * moreFramesToCopy
if let ioBufferData = bufferList.mBuffers.mData {
if isMuted {
writeSilence(outputBuffer: &bufferList.mBuffers,
outputBufferSize: Int(frameSizeInBytes * moreFramesToCopy),
offset: Int(frameToCopy * frameSizeInBytes))
memset(ioBufferData + Int(frameToCopy * frameSizeInBytes), 0, Int(frameSizeInBytes * moreFramesToCopy))
} else {
if let mDataBuffer = audioBuffer.mData {
memcpy(ioBufferData + Int(frameToCopy * frameSizeInBytes),
mDataBuffer,
Int(frameSizeInBytes * moreFramesToCopy))
memcpy(ioBufferData + Int(frameToCopy * frameSizeInBytes), mDataBuffer, Int(frameSizeInBytes * moreFramesToCopy))
}
}
}
@@ -170,6 +163,7 @@ final class AudioPlayerRenderProcessor: NSObject {
bufferContext.frameUsedCount -= totalFramesCopied
rendererContext.lock.unlock()
}
if playerContext.internalState != .playing {
playerContext.setInternalState(to: .playing, when: { state -> Bool in
state.contains(.running) && state != .paused
@@ -179,11 +173,11 @@ final class AudioPlayerRenderProcessor: NSObject {
if totalFramesCopied < inNumberFrames {
let delta = inNumberFrames - totalFramesCopied
writeSilence(outputBuffer: &bufferList.mBuffers,
outputBufferSize: Int(delta * frameSizeInBytes),
offset: Int(totalFramesCopied * frameSizeInBytes))
if let mData = bufferList.mBuffers.mData {
memset(mData + Int(totalFramesCopied * frameSizeInBytes), 0, Int(delta * frameSizeInBytes))
}
if playingEntry != nil || AudioPlayer.InternalState.waiting.contains(state) {
if !(playingEntry == nil || state == .waitingForDataAfterSeek || state == .waitingForData || state == .rebuffering) {
if playerContext.internalState != .rebuffering {
playerContext.setInternalState(to: .rebuffering, when: { state -> Bool in
state.contains(.running) && state != .paused
@@ -192,13 +186,12 @@ final class AudioPlayerRenderProcessor: NSObject {
} else if state == .waitingForDataAfterSeek {
if totalFramesCopied == 0 {
rendererContext.waitingForDataAfterSeekFrameCount.write { $0 += Int32(inNumberFrames - totalFramesCopied) }
if rendererContext.waitingForDataAfterSeekFrameCount.value > rendererContext.framesRequiredForDataAfterSeekPlaying {
if rendererContext.waitingForDataAfterSeekFrameCount.value > Int(rendererContext.framesRequiredForDataAfterSeekPlaying) {
if playerContext.internalState != .playing {
playerContext.setInternalState(to: .playing) { state -> Bool in
state.contains(.running) && state != .playing
}
}
rendererContext.waitingForDataAfterSeekFrameCount.write { $0 = 0 }
}
} else {
rendererContext.waitingForDataAfterSeekFrameCount.write { $0 = 0 }
@@ -211,7 +204,7 @@ final class AudioPlayerRenderProcessor: NSObject {
}
currentPlayingEntry.lock.lock()
var extraFramesPlayedNotAssigned: Int = 0
var extraFramesPlayedNotAssigned = 0
var framesPlayedForCurrent = Int(totalFramesCopied)
if currentPlayingEntry.framesState.lastFrameQueued >= 0 {
@@ -273,10 +266,11 @@ final class AudioPlayerRenderProcessor: NSObject {
return UnsafePointer(rendererContext.inOutAudioBufferList)
}
func render(inNumberFrames: UInt32,
ioData: UnsafeMutablePointer<AudioBufferList>,
flags _: UnsafeMutablePointer<AudioUnitRenderActionFlags>) -> OSStatus
{
func render(
inNumberFrames: UInt32,
ioData: UnsafeMutablePointer<AudioBufferList>,
flags _: UnsafeMutablePointer<AudioUnitRenderActionFlags>
) -> OSStatus {
var status = noErr
rendererContext.inOutAudioBufferList[0].mBuffers.mData = ioData.pointee.mBuffers.mData
@@ -311,24 +305,18 @@ final class AudioPlayerRenderProcessor: NSObject {
return status
}
func renderProvider(flags: UnsafeMutablePointer<AudioUnitRenderActionFlags>,
timeStamp _: UnsafePointer<AudioTimeStamp>,
inNumberFrames: AUAudioFrameCount,
inputBusNumber: Int,
inputData: UnsafeMutablePointer<AudioBufferList>) -> AUAudioUnitStatus
{
func renderProvider(
flags: UnsafeMutablePointer<AudioUnitRenderActionFlags>,
timeStamp _: UnsafePointer<AudioTimeStamp>,
inNumberFrames: AUAudioFrameCount,
inputBusNumber: Int,
inputData: UnsafeMutablePointer<AudioBufferList>
) -> AUAudioUnitStatus {
guard inputBusNumber == 0 else { return noErr }
return render(inNumberFrames: inNumberFrames, ioData: inputData, flags: flags)
}
@inline(__always)
private func writeSilence(outputBuffer: inout AudioBuffer,
outputBufferSize: Int,
offset: Int)
{
guard let mData = outputBuffer.mData else { return }
memset(mData + offset, 0, outputBufferSize)
outputBuffer.mDataByteSize = UInt32(outputBufferSize)
outputBuffer.mNumberChannels = outputAudioFormat.mChannelsPerFrame
return render(
inNumberFrames: inNumberFrames,
ioData: inputData,
flags: flags
)
}
}
@@ -0,0 +1,176 @@
//
// 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 = 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)
}
}
@@ -0,0 +1,92 @@
//
// 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 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 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 >= stopProcessingCheckOne.count {
if icecastHeaders.suffix(stopProcessingCheckOne.count) == stopProcessingCheckOne {
iceHeaderAvailable = true
searchComplete = true
break
}
}
if icecastHeaders.count >= stopProcessingCheckTwo.count {
if icecastHeaders.suffix(stopProcessingCheckTwo.count) == stopProcessingCheckTwo {
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)
}
}
}
@@ -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()
@@ -5,13 +5,11 @@
import AVFoundation
private let outputChannels: UInt32 = 2
enum UnitDescriptions {
static var output: AudioComponentDescription = {
static let output: AudioComponentDescription = {
var desc = AudioComponentDescription()
desc.componentType = kAudioUnitType_Output
#if os(iOS)
#if os(iOS) || os(tvOS)
desc.componentSubType = kAudioUnitSubType_RemoteIO
#else
desc.componentSubType = kAudioUnitSubType_DefaultOutput
@@ -7,7 +7,7 @@ import AudioToolbox
import Foundation
/// mapping from mime types to `AudioFileTypeID`
internal let fileTypesFromMimeType: [String: AudioFileTypeID] =
let fileTypesFromMimeType: [String: AudioFileTypeID] =
[
"audio/mp3": kAudioFileMP3Type,
"audio/mpg": kAudioFileMP3Type,
@@ -33,6 +33,7 @@ internal let fileTypesFromMimeType: [String: AudioFileTypeID] =
"video/3gpp": kAudioFile3GPType,
"audio/3gp2": kAudioFile3GP2Type,
"video/3gp2": kAudioFile3GP2Type,
"audio/flac": kAudioFileFLACType
]
/// Method that converts mime type to AudioFileTypeID
@@ -44,7 +45,7 @@ func audioFileType(mimeType: String) -> AudioFileTypeID {
}
/// mapping from file extension to `AudioFileTypeID`
internal let fileTypesFromFileExtension: [String: AudioFileTypeID] =
let fileTypesFromFileExtension: [String: AudioFileTypeID] =
[
"mp3": kAudioFileMP3Type,
"wav": kAudioFileWAVEType,
@@ -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
}
}
@@ -34,6 +34,17 @@ final class PlayerQueueEntries {
upcoming = Queue<AudioEntry>()
}
/// Returns an array containing all items in the queue for the specified `type`.
///
/// - Note: This method returns the items in the queue without removing them.
///
/// - Parameter type: A `PlayerQueueType` specifying the type of the queue.
/// - Returns: An array of `AudioEntry` objects representing the items in the queue.
func items(type: PlayerQueueType) -> [AudioEntry] {
lock.lock(); defer { lock.unlock() }
return queue(for: type).items
}
/// Adds the `item` to the underlying queue for the specified `type`
/// - parameter item: An `AudioEntry` object to be added
/// - parameter type: The type fo the underlying queue as expressed by `PlayerQueueType`
@@ -51,6 +62,32 @@ final class PlayerQueueEntries {
return queue(for: type).dequeue()
}
func insert(item: AudioEntry, type: PlayerQueueType, after afterItem: AudioEntry) {
lock.lock(); defer { lock.unlock() }
if let indexForAfterItem = queue(for: type).items.firstIndex(of: afterItem) {
queue(for: .upcoming).insert(item: item, at: indexForAfterItem)
}
}
/// Inserts the `item` at the specified index in the underlying queue for the specified `type`.
/// - Parameters:
/// - item: An `AudioEntry` object to be added.
/// - type: The type of the underlying queue as expressed by `PlayerQueueType`.
/// - index: The index at which to insert the item.
func insert(item: AudioEntry, type: PlayerQueueType, at index: Int) {
lock.lock(); defer { lock.unlock() }
queue(for: type).insert(item: item, at: index)
}
/// Removes the item at the specified index from the underlying queue for the specified `type`.
/// - Parameters:
/// - type: The type of the underlying queue as expressed by `PlayerQueueType`.
/// - index: The index of the item to remove.
func remove(item: AudioEntry, type: PlayerQueueType) {
lock.lock(); defer { lock.unlock() }
queue(for: type).remove(item: item)
}
/// Appends (skips) the `items` to the underlying queue for the specified `type`
/// - parameter item: An `AudioEntry` object to be added
/// - parameter type: The type fo the underlying queue as expressed by `PlayerQueueType`
@@ -6,7 +6,7 @@
import AudioToolbox.AudioFile
import Foundation
struct HeaderField {
enum HeaderField {
public static let acceptRanges = "Accept-Ranges"
public static let contentLength = "Content-Length"
public static let contentType = "Content-Type"
@@ -14,7 +14,7 @@ struct HeaderField {
}
enum IcyHeaderField {
public static let icyMentaint = "icy-metaint"
public static let icyMetaint = "icy-metaint"
}
struct HTTPHeaderParserOutput {
@@ -22,6 +22,11 @@ struct HTTPHeaderParserOutput {
let typeId: AudioFileTypeID
// Metadata Support
let metadataStep: Int
let seekable: Bool
var isMp4: Bool {
(typeId == kAudioFileMPEG4Type || typeId == kAudioFileM4AType)
}
}
protocol HTTPHeaderParsing: Parser {
@@ -39,14 +44,14 @@ struct HTTPHeaderParser: HTTPHeaderParsing {
typealias Output = HTTPHeaderParserOutput?
func parse(input: HTTPURLResponse) -> HTTPHeaderParserOutput? {
guard let headers = input.allHeaderFields as? [String: String], !headers.isEmpty else { return nil }
guard let headers = input.allHeaderFields as? [String: String], headers.count > 2 else { return nil }
var typeId: UInt32 = 0
if let contentType = input.mimeType {
typeId = audioFileType(mimeType: contentType)
}
var fileLength: Int = 0
var fileLength = 0
if input.statusCode == 200 {
let contentLength = value(forHTTPHeaderField: HeaderField.contentLength, in: input)
if let contentLength = contentLength, let length = Int(contentLength) {
@@ -64,15 +69,18 @@ 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
}
return HTTPHeaderParserOutput(fileLength: fileLength,
typeId: typeId,
metadataStep: metadataStep)
return HTTPHeaderParserOutput(
fileLength: fileLength,
typeId: typeId,
metadataStep: metadataStep,
seekable: input.statusCode == 206
)
}
}
@@ -91,10 +99,8 @@ extension Parser where Self: HTTPHeaderParsing {
private func valueForCaseInsensitiveKey(_ key: String, fields: [String: String]) -> String? {
let keyToBeFound = key.lowercased()
for (key, value) in fields {
if key.lowercased() == keyToBeFound {
return value
}
for (key, value) in fields where key.lowercased() == keyToBeFound {
return value
}
return nil
}
@@ -0,0 +1,35 @@
//
// 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.icyMetaint] ?? "") ?? 0
let contentType = result[HeaderField.contentType.lowercased()] ?? "audio/mpeg"
let typeId = audioFileType(mimeType: contentType)
return HTTPHeaderParserOutput(
fileLength: 0,
typeId: typeId,
metadataStep: metadataStep,
seekable: false
)
}
}
@@ -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
}
@@ -1,41 +0,0 @@
{
"configurations" : [
{
"id" : "A1B13C01-AF5C-46DD-990A-A369639F2AD3",
"name" : "Configuration 1",
"options" : {
}
}
],
"defaultOptions" : {
"environmentVariableEntries" : [
{
"enabled" : false,
"key" : "OS_ACTIVITY_MODE",
"value" : "disable"
}
],
"targetForVariableExpansion" : {
"containerPath" : "container:AudioExample.xcodeproj",
"identifier" : "B5AEDBD02475274C007D8101",
"name" : "AudioExample"
},
"testExecutionOrdering" : "random",
"threadSanitizerEnabled" : true
},
"testTargets" : [
{
"parallelizable" : true,
"skippedTests" : [
"ProtectedTests"
],
"target" : {
"containerPath" : "container:..\/AudioStreaming.xcodeproj",
"identifier" : "B5AEDBB624744153007D8101",
"name" : "AudioStreamingTests"
}
}
],
"version" : 1
}
@@ -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: 100_000) { _ in
_ = atomic.value
atomic.write { $0 += 1 }
}
XCTAssertEqual(protected.value, 1_000_000)
XCTAssertEqual(atomic.value, 100_000)
}
}
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)" }
}
+1 -1
View File
@@ -1,5 +1,5 @@
//
// BiMap.swift
// BiMapTests.swift
// AudioStreamingTests
//
// Created by Dimitrios Chatzieleftheriou on 26/05/2020.
@@ -0,0 +1,75 @@
//
// Copyright © Blockchain Luxembourg S.A. All rights reserved.
import XCTest
@testable import AudioStreaming
final class ByteBufferTests: XCTestCase {
func testWriteAndReadBytes() {
var buffer = ByteBuffer(size: 10)
// Write bytes to the buffer
let testData = Data([0x01, 0x02, 0x03, 0x04])
buffer.writeBytes(testData)
buffer.rewind()
// Read the written bytes
do {
let readData = try buffer.readBytes(4)
XCTAssertEqual(readData, testData)
} catch {
XCTFail("Error reading bytes: \(error)")
}
}
func testWriteAndReadInteger() {
var buffer = ByteBuffer(size: 8)
// Write integer to the buffer
let testInteger: UInt32 = 123_456_789
buffer.put(testInteger)
buffer.rewind()
// Read the written integer
do {
let readInteger: UInt32 = try buffer.getInteger()
XCTAssertEqual(readInteger, testInteger.bigEndian)
} catch {
XCTFail("Error reading integer: \(error)")
}
}
func testWriteAndReadFloat() {
var buffer = ByteBuffer(size: 8)
// Write float to the buffer
let testFloat: Float = 123.456
buffer.put(testFloat)
buffer.rewind()
// Read the written float
do {
let readFloat: Float = try buffer.getFloat()
XCTAssertEqual(readFloat, testFloat, accuracy: 0.001)
} catch {
XCTFail("Error reading float: \(error)")
}
}
func testWriteAndReadDouble() {
var buffer = ByteBuffer(size: 8)
// Write double to the buffer
let testDouble = 123.456
buffer.put(testDouble)
buffer.rewind()
// Read the written double
do {
let readDouble: Double = try buffer.getDouble()
XCTAssertEqual(readDouble, testDouble, accuracy: 0.001)
} catch {
XCTFail("Error reading double: \(error)")
}
}
}
@@ -1,5 +1,5 @@
//
// DispatchReadSourceTests.swift
// DispatchTimerSourceTests.swift
// AudioStreamingTests
//
// Created by Dimitrios Chatzieleftheriou on 25/10/2020.
@@ -4,7 +4,6 @@
//
import XCTest
@testable import AudioStreaming
class NetworkingClientTests: XCTestCase {

Some files were not shown because too many files have changed in this diff Show More