Compare commits

...

82 Commits

Author SHA1 Message Date
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
Dimitris C e032d34ff7 Merge branch 'main' of https://github.com/dimitris-c/AudioStreaming into main 2021-01-16 11:40:01 +02:00
Dimitris C 280d3464c1 Bump version number 2021-01-16 11:39:41 +02:00
Dimitris C f0811c4fc8 Fixes queueing multiple items (#13)
* Fixes queing multiple items

Adds error callback on AudioConverter failure

* Adds a new radio stream in AudioExample

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

* Update AudioStreaming/Streaming/AudioPlayer/AudioPlayer.swift

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

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

    Fixes and issue with arithmetic overflow

    # Conflicts:
    #	AudioStreaming/Streaming/Audio Source/RemoteAudioSource.swift
    #	AudioStreaming/Streaming/AudioPlayer/Processors/AudioFileStreamProcessor.swift
2020-11-19 13:20:46 +00:00
Dimitris C 10455ed4be Adds podcast in AudioExample 2020-11-17 17:29:42 +00:00
Dimitris C 6f48f3a526 Update README.md 2020-11-17 09:45:26 +00:00
Dimitris C ed03fcdd0e Update README.md 2020-11-16 22:09:56 +00:00
Dimitris C 6e3b50d6f9 Update README.md 2020-11-16 22:05:30 +00:00
Dimitris C 21b245c114 Merge pull request #3 from dimitris-c/ci
Create swift.yml
2020-11-16 21:36:45 +00:00
Dimitris C 8599d66bec Update swift.yml 2020-11-16 21:31:54 +00:00
Dimitris C c6bd74a68c Update swift.yml 2020-11-16 21:30:57 +00:00
Dimitris C bb3e518d08 Create swift.yml 2020-11-16 21:20:34 +00:00
Dimitris C 1097743d57 Merge pull request #2 from dimitris-c/update-readme-file-1
Added README.md
2020-11-16 21:14:44 +00:00
Dimitris C 2efe273f9e Create README.md 2020-11-16 21:14:25 +00:00
Dimitris C 2ddd11d255 Updates podspec 2020-11-16 12:21:54 +00:00
Dimitris C 33575385e3 Removes bogus file 2020-11-16 12:11:18 +00:00
104 changed files with 4749 additions and 2951 deletions
+24
View File
@@ -0,0 +1,24 @@
name: "AudioStreaming CI"
on:
push:
branches:
- main
- hotfix
pull_request:
branches:
- '*'
jobs:
iOS:
name: Test iOS
runs-on: macOS-latest
env:
DEVELOPER_DIR: /Applications/Xcode.app/Contents/Developer
strategy:
matrix:
destination: ["OS=latest,name=iPhone 15 Pro"]
steps:
- uses: actions/checkout@v2
- name: iOS - ${{ matrix.destination }}
run: set -o pipefail && env NSUnbufferedIO=YES xcodebuild -project "AudioStreaming.xcodeproj" -scheme "AudioStreaming" -destination "${{ matrix.destination }}" clean test | xcpretty
+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,149 +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(UITableViewCell.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
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)
}
}
@@ -1,100 +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))
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 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,58 +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 radiox
case khruangbin
case piano
case local
var title: String {
switch self {
case .offradio:
return "Offradio (stream)"
case .enlefko:
return "Enlefko (stream)"
case .pepper966:
return "Pepper 96.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)"
}
}
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 .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)
}
}
}
@@ -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,79 +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
init(content: AudioContent) {
name = content.title
url = content.streamUrl
status = .stopped
}
init(url: URL, name: String, status: Status) {
self.url = url
self.name = name
self.status = status
}
}
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)
}
}
func provideInitialPlaylistItems() -> [PlaylistItem] {
AudioContent.allCases.map(PlaylistItem.init(content:))
}
@@ -0,0 +1,511 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 56;
objects = {
/* Begin PBXBuildFile section */
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 */
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 = (
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 */,
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,124 @@
//
// 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 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 .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 .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 .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,318 @@
//
// Created by Dimitris Chatzieleftheriou on 26/04/2024.
//
import AVFoundation
import SwiftUI
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
audioPlayerService.play(url: track.url)
currentTrack = track
}
}
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,74 @@
//
// 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]
func audioTracksProvider() -> [AudioPlaylist] {
[
AudioPlaylist(title: "Radio", tracks: radioTracks.map { AudioTrack.init(from: $0) }),
AudioPlaylist(title: "Tracks", tracks: audioTracks.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,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,24 +1,21 @@
//
// 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
private var audioSystemResetObserver: Any?
@@ -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 queue(url: URL) {
activateAudioSession()
player.queue(url: url)
}
func stop() {
player.stop()
deactivateAudioSession()
@@ -77,6 +86,10 @@ final class AudioPlayerService {
player.rate = rate
}
func update(volume: Float) {
player.volume = volume
}
func add(_ node: AVAudioNode) {
player.attach(node: node)
}
@@ -93,26 +106,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: [])
@@ -120,9 +138,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: [])
@@ -130,45 +150,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.
+3 -5
View File
@@ -1,21 +1,19 @@
Pod::Spec.new do |s|
s.name = 'AudioStreaming'
s.version = '0.1.0'
s.version = '1.2.3'
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.ios.deployment_target = '13.0'
s.swift_versions = ['5.1', '5.2', '5.3']
s.source_files = 'AudioStreaming/**/*.swift'
s.frameworks = 'AVFoundation', ' CoreAudio', 'AudioToolbox', 'Network'
s.pod_target_xcconfig = {
'SWIFT_INSTALL_OBJC_HEADER' => 'NO'
}
end
end
+62 -22
View File
@@ -7,6 +7,11 @@
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 */; };
@@ -52,7 +56,10 @@
B59DF1A32493E90C0043C498 /* AudioFileStream+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = B59DF1A22493E90C0043C498 /* AudioFileStream+Helpers.swift */; };
B5AEDBB824744153007D8101 /* AudioStreaming.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B5AEDBAE24744153007D8101 /* AudioStreaming.framework */; };
B5AEDBBF24744153007D8101 /* AudioStreaming.h in Headers */ = {isa = PBXBuildFile; fileRef = B5AEDBB124744153007D8101 /* AudioStreaming.h */; settings = {ATTRIBUTES = (Public, ); }; };
B5B36E432655A32200DC96F5 /* FrameFilterProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5B36E422655A32200DC96F5 /* FrameFilterProcessor.swift */; };
B5B3B7CC248647ED00656828 /* AudioPlayerState.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5B3B7CB248647ED00656828 /* AudioPlayerState.swift */; };
B5D4A40925D9321400E1450C /* IcycastHeaderParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5D4A40825D9321400E1450C /* IcycastHeaderParser.swift */; };
B5D4A41025D948EF00E1450C /* IcycastHeadersProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5D4A40B25D9445600E1450C /* IcycastHeadersProcessor.swift */; };
B5D82E65255DD562009EDAA4 /* NetStatusService.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5D82E64255DD562009EDAA4 /* NetStatusService.swift */; };
B5DB66E2255C2EAB00B8DF53 /* AudioEntryProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5DB66E1255C2EAB00B8DF53 /* AudioEntryProvider.swift */; };
B5E1DE2524B70B4200955BFB /* AudioPlayerConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5E1DE2424B70B4200955BFB /* AudioPlayerConfiguration.swift */; };
@@ -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>";
@@ -575,7 +601,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 +616,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 +651,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 +670,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;
};
@@ -709,8 +741,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.1.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
@@ -768,8 +801,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.1.0;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SDKROOT = iphoneos;
@@ -786,23 +820,25 @@
buildSettings = {
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 2;
DEFINES_MODULE = YES;
DYLIB_COMPATIBILITY_VERSION = 1;
DYLIB_CURRENT_VERSION = 1;
DYLIB_INSTALL_NAME_BASE = "@rpath";
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.1.0;
MARKETING_VERSION = 1.2.3;
OTHER_LDFLAGS = "-ObjC";
PRODUCT_BUNDLE_IDENTIFIER = com.decimal.AudioStreaming;
PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)";
SKIP_INSTALL = YES;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx";
SUPPORTS_MACCATALYST = NO;
SWIFT_OBJC_BRIDGING_HEADER = "";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
@@ -816,23 +852,25 @@
buildSettings = {
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 2;
DEFINES_MODULE = YES;
DYLIB_COMPATIBILITY_VERSION = 1;
DYLIB_CURRENT_VERSION = 1;
DYLIB_INSTALL_NAME_BASE = "@rpath";
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.1.0;
MARKETING_VERSION = 1.2.3;
OTHER_LDFLAGS = "-ObjC";
PRODUCT_BUNDLE_IDENTIFIER = com.decimal.AudioStreaming;
PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)";
SKIP_INSTALL = YES;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx";
SUPPORTS_MACCATALYST = NO;
SWIFT_OBJC_BRIDGING_HEADER = "";
SWIFT_VERSION = 5.0;
@@ -847,6 +885,7 @@
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",
@@ -867,6 +906,7 @@
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",
+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
}
+24 -17
View File
@@ -8,46 +8,53 @@ import Foundation
protocol Lock {
func lock()
func unlock()
}
extension Lock {
// Execute a closure while acquiring a lock and returns the closure value
@inline(__always)
func around<Value>(_ closure: () -> Value) -> Value {
lock(); defer { unlock() }
return closure()
}
func withLock<Result>(body: () throws -> Result) rethrows -> Result
// Execute a closure while acquiring a lock
@inline(__always)
func around(_ closure: () -> Void) {
lock(); defer { unlock() }
closure()
}
func withLock(body: () -> Void)
}
/// A wrapper for `os_unfair_lock`
/// - Tag: UnfairLock
final class UnfairLock: Lock {
private let unfairLock: os_unfair_lock_t
@usableFromInline let unfairLock: UnsafeMutablePointer<os_unfair_lock>
internal init() {
init() {
unfairLock = .allocate(capacity: 1)
unfairLock.initialize(to: os_unfair_lock())
}
deinit {
unfairLock.deinitialize(count: 1)
unfairLock.deallocate()
}
@inlinable
@inline(__always)
internal func lock() {
func withLock<Result>(body: () throws -> Result) rethrows -> Result {
os_unfair_lock_lock(unfairLock)
defer { os_unfair_lock_unlock(unfairLock) }
return try body()
}
@inlinable
@inline(__always)
func withLock(body: () -> Void) {
os_unfair_lock_lock(unfairLock)
defer { os_unfair_lock_unlock(unfairLock) }
body()
}
@inlinable
@inline(__always)
func lock() {
os_unfair_lock_lock(unfairLock)
}
@inlinable
@inline(__always)
internal func unlock() {
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)
}
}
@@ -1,278 +0,0 @@
//
// Created by Dimitrios Chatzieleftheriou on 27/05/2020.
// Copyright © 2020 Decimal. All rights reserved.
//
import AudioToolbox
import Foundation
typealias CoreAudioURLBlock = (URL) -> Void
public class RemoteAudioSource: NSObject, AudioStreamSource {
var inputStream: InputStream?
weak var delegate: AudioStreamSourceDelegate?
var position: Int {
return seekOffset + relativePosition
}
var length: Int {
guard let parsedHeader = parsedHeaderOutput else { return 0 }
return parsedHeader.fileLength
}
private let url: URL
private let networking: NetworkingClient
internal let metadataStreamProccessor: MetadataStreamSource
private var streamRequest: NetworkDataStream?
private var additionalRequestHeaders: [String: String]
private var httpStatusCode: Int
private var httpResponse: HTTPURLResponse?
private var parsedHeaderOutput: HTTPHeaderParserOutput?
private var relativePosition: Int
private var seekOffset: Int
internal var dispatchQueue: DispatchQueue?
init(networking: NetworkingClient,
metadataStreamSource: MetadataStreamSource,
url: URL,
httpHeaders: [String: String])
{
self.networking = networking
metadataStreamProccessor = metadataStreamSource
self.url = url
additionalRequestHeaders = httpHeaders
httpStatusCode = 0
relativePosition = 0
seekOffset = 0
}
convenience init(networking: NetworkingClient, url: URL, httpHeaders: [String: String]) {
let metadataParser = MetadataParser()
let metadataProccessor = MetadataStreamProccessor(parser: metadataParser.eraseToAnyParser())
self.init(networking: networking,
metadataStreamSource: metadataProccessor,
url: url,
httpHeaders: httpHeaders)
}
convenience init(networking: NetworkingClient, url: URL) {
self.init(networking: networking,
url: url,
httpHeaders: [:])
}
func setup(for queue: DispatchQueue) {
dispatchQueue = queue
guard let stream = inputStream else {
return
}
stream.delegate = self
stream.set(onQueue: queue)
return
}
func removeFromQueue() {
guard let stream = inputStream else { return }
stream.delegate = nil
stream.unsetFromQueue()
}
func close() {
if inputStream != nil {
if dispatchQueue != nil {
removeFromQueue()
}
inputStream?.close()
inputStream = nil
}
}
func seek(at offset: Int) {
guard let queue = dispatchQueue else {
return
}
dispatchPrecondition(condition: .onQueue(queue))
close()
relativePosition = 0
seekOffset = offset
if let supportsSeek = parsedHeaderOutput?.supportsSeek,
!supportsSeek, offset != relativePosition
{
return
}
performOpen(seek: seekOffset)
}
func audioFileHint() -> AudioFileTypeID {
return audioFileType(fileExtension: url.pathExtension)
}
func read(into buffer: UnsafeMutablePointer<UInt8>, size: Int) -> Int {
return performRead(into: buffer, size: size)
}
// MARK: Private
private func performRead(into buffer: UnsafeMutablePointer<UInt8>, size: Int) -> Int {
guard size != 0 else { return 0 }
guard let stream = inputStream else { return 0 }
var read: Int = 0
// Metadata parsing
// if metadataStreamProccessor.canProccessMetadata {
// read = metadataStreamProccessor.proccessFromRead(into: buffer, size: size, using: stream) { [weak self] position in
// self?.relativePosition += position
// }
// } else {
read = stream.read(buffer, maxLength: size)
// }
guard read > 0 else { return read }
relativePosition += read
return read
}
private func performOpen(seek seekOffset: Int) {
let urlRequest = buildUrlRequest(with: url, seekIfNeeded: seekOffset)
let streamRequest = networking.stream(request: urlRequest)
.responseStream(on: .global(qos: .default)) { [weak self] event in
switch event {
case let .stream(result):
switch result {
case let .success(response):
self?.httpResponse = response.response
default:
break
}
case let .complete(completion):
print(completion)
}
}
self.streamRequest = streamRequest
inputStream = streamRequest.asInputStream()
guard let inputStream = inputStream else {
errorOccured()
return
}
inputStream.setProperty(StreamNetworkServiceTypeValue.background, forKey: .networkServiceType)
if let scheme = url.scheme, scheme == "https" {
inputStream.setProperty(StreamSocketSecurityLevel.negotiatedSSL, forKey: .socketSecurityLevelKey)
let sslSettings: [String: Any] = [kCFStreamSSLValidatesCertificateChain as String: false]
inputStream.setProperty(sslSettings, forKey: kCFStreamPropertySSLSettings as Stream.PropertyKey)
}
performSoftSetup()
httpStatusCode = 0
inputStream.open()
}
private func parseHeader(response: HTTPURLResponse?) -> Bool {
guard let response = response else { return false }
httpStatusCode = response.statusCode
// parse the header response
let parser = HTTPHeaderParser()
parsedHeaderOutput = parser.parse(input: response)
// check to see if we have metadata to proccess
if let metadataStep = parsedHeaderOutput?.metadataStep {
metadataStreamProccessor.metadataAvailable(step: metadataStep)
}
// check for error
if httpStatusCode == 416 { // range not satisfied error
if length >= 0 { seekOffset = length }
endOfFileOccurred()
return false
} else if httpStatusCode >= 300 {
errorOccured()
return false
}
return true
}
private func performSoftSetup() {
guard let queue = dispatchQueue, let inputStream = inputStream else { return }
inputStream.delegate = self
inputStream.set(onQueue: queue)
}
private func buildUrlRequest(with _: URL, seekIfNeeded seekOffset: Int) -> URLRequest {
var urlRequest = URLRequest(url: url)
urlRequest.timeoutInterval = 30
urlRequest.networkServiceType = .avStreaming
for header in additionalRequestHeaders {
urlRequest.addValue(header.value, forHTTPHeaderField: header.key)
}
urlRequest.addValue("*/*", forHTTPHeaderField: "Accept")
urlRequest.addValue("1", forHTTPHeaderField: "Icy-Metadata")
if let supportsSeek = parsedHeaderOutput?.supportsSeek, supportsSeek, seekOffset > 0 {
urlRequest.addValue("bytes=\(seekOffset)", forHTTPHeaderField: "Range")
}
return urlRequest
}
}
// MARK: StreamEventsSource
extension RemoteAudioSource: StreamEventsSource {
func openCompleted() {
print("open completed")
}
func dataAvailable() {
guard inputStream != nil else { return }
if httpStatusCode == 0 {
guard parseHeader(response: httpResponse) else { return }
if hasBytesAvailable {
delegate?.dataAvailable(source: self)
}
} else {
delegate?.dataAvailable(source: self)
}
}
func endOfFileOccurred() {
delegate?.endOfFileOccured(source: self)
}
func errorOccured() {
delegate?.errorOccured(source: self)
}
}
// MARK: StreamDelegate
extension RemoteAudioSource: StreamDelegate {
public func stream(_: Stream, handle eventCode: Stream.Event) {
switch eventCode {
case .openCompleted:
openCompleted()
case .hasBytesAvailable:
dataAvailable()
case .endEncountered:
endOfFileOccurred()
case .errorOccurred:
errorOccured()
default: break
}
}
}
@@ -8,6 +8,6 @@ import Foundation
final class SeekRequest {
let lock = UnfairLock()
var requested: Bool = false
var version = Protected<Int>(0)
var version = Atomic<Int>(0)
var time: Double = 0
}
@@ -10,9 +10,9 @@ protocol AudioStreamSourceDelegate: AnyObject {
/// Indicates that there's data available
func dataAvailable(source: CoreAudioStreamSource, data: Data)
/// Indicates an error occurred
func errorOccured(source: CoreAudioStreamSource, error: Error)
func errorOccurred(source: CoreAudioStreamSource, error: Error)
/// Indicates end of file has occurred
func endOfFileOccured(source: CoreAudioStreamSource)
func endOfFileOccurred(source: CoreAudioStreamSource)
/// Indicates metadata read from stream
func metadataReceived(data: [String: String])
}
@@ -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,12 +44,18 @@ 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
length = 0
}
deinit {
buffer.deallocate()
mp4Restructure.clear()
}
func close() {
guard let inputStream = inputStream else {
return
@@ -50,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 {
@@ -65,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()
}
}
}
}
@@ -108,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 {
@@ -135,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,6 +8,10 @@ import AVFoundation
import Foundation
import Network
enum RemoteAudioSourceError: Error {
case mp4NotSeekable
}
public class RemoteAudioSource: AudioStreamSource {
weak var delegate: AudioStreamSourceDelegate?
@@ -29,24 +33,31 @@ public class RemoteAudioSource: AudioStreamSource {
private var parsedHeaderOutput: HTTPHeaderParserOutput?
private var relativePosition: Int
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
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
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,
@@ -59,7 +70,9 @@ public class RemoteAudioSource: AudioStreamSource {
additionalRequestHeaders = httpHeaders
relativePosition = 0
seekOffset = 0
supportsSeek = false
netStatusService = netStatusProvider
self.icycastHeadersProcessor = icycastHeadersProcessor
self.underlyingQueue = underlyingQueue
streamOperationQueue = OperationQueue()
streamOperationQueue.underlyingQueue = underlyingQueue
@@ -67,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()
}
@@ -78,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,8 +116,7 @@ public class RemoteAudioSource: AudioStreamSource {
func close() {
retrierTimeout.cancel()
netStatusService.stop()
streamOperationQueue.isSuspended = true
streamOperationQueue.isSuspended = false
streamOperationQueue.cancelAllOperations()
if let streamTask = streamRequest {
streamTask.cancel()
@@ -116,25 +131,24 @@ public class RemoteAudioSource: AudioStreamSource {
relativePosition = 0
seekOffset = offset
if let supportsSeek = parsedHeaderOutput?.supportsSeek,
!supportsSeek, offset != relativePosition
{
if !supportsSeek, offset != relativePosition {
return
}
mp4Restructure.clear()
retrierTimeout.cancel()
metadataStreamProcessor.reset()
icycastHeadersProcessor.reset()
shouldTryParsingIcycastHeaders = false
performOpen(seek: offset)
}
func suspend() {
streamRequest?.suspend()
streamOperationQueue.isSuspended = true
}
func resume() {
streamRequest?.resume()
streamOperationQueue.isSuspended = false
}
@@ -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):
addStreamOperation { [weak self] in
guard let self = self else { return }
if let data = value.data {
if self.metadataStreamProcessor.canProccessMetadata {
let extractedAudioData = self.metadataStreamProcessor.proccessMetadata(data: data)
self.delegate?.dataAvailable(source: self, data: extractedAudioData)
} else {
self.delegate?.dataAvailable(source: self, data: data)
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
}
}
@@ -216,16 +309,36 @@ public class RemoteAudioSource: AudioStreamSource {
let httpStatusCode = response.statusCode
let parser = HTTPHeaderParser()
parsedHeaderOutput = parser.parse(input: response)
// check to see if we have metadata to proccess
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 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
)
}
}
@@ -242,16 +355,32 @@ public class RemoteAudioSource: AudioStreamSource {
urlRequest.addValue("1", forHTTPHeaderField: "Icy-MetaData")
urlRequest.addValue("identity", forHTTPHeaderField: "Accept-Encoding")
if let supportsSeek = parsedHeaderOutput?.supportsSeek, supportsSeek, seekOffset > 0 {
if supportsSeek, seekOffset > 0 {
urlRequest.addValue("bytes=\(seekOffset)-", forHTTPHeaderField: "Range")
}
return urlRequest
}
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)
}
}
@@ -262,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 {
@@ -70,7 +70,14 @@ public final class AudioPlayer {
playerContext.entriesLock.lock()
let playingEntry = playerContext.audioPlayingEntry
playerContext.entriesLock.unlock()
guard let entry = playingEntry, !entry.seekRequest.requested else { return 0 }
guard let entry = playingEntry else { return 0 }
entry.seekRequest.lock.lock()
let seekRequested = entry.seekRequest.requested
let seekTime = entry.seekRequest.time
entry.seekRequest.lock.unlock()
if seekRequested {
return seekTime
}
return entry.progress
}
@@ -79,37 +86,44 @@ 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 underlyingQueue = DispatchQueue(label: "streaming.core.queue", qos: .userInitiated, attributes: .concurrent)
private let serializationQueue: DispatchQueue
private let sourceQueue: DispatchQueue
private let entryProvider: AudioEntryProviding
@@ -118,36 +132,46 @@ 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()
sourceQueue = DispatchQueue(label: "source.queue", qos: .userInitiated, target: underlyingQueue)
audioReadSource = DispatchTimerSource(interval: .milliseconds(200), queue: sourceQueue)
serializationQueue = DispatchQueue(label: "streaming.core.queue", qos: .userInitiated)
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()
}
deinit {
// todo more stuff to release...
playerContext.audioPlayingEntry?.close()
clearQueue()
stopReadProccessFromSource()
rendererContext.clean()
}
@@ -167,49 +191,126 @@ public final class AudioPlayer {
public func play(url: URL, headers: [String: String]) {
let audioEntry = entryProvider.provideAudioEntry(url: url, headers: headers)
audioEntry.delegate = self
clearQueue()
entriesQueue.enqueue(item: audioEntry, type: .upcoming)
playerContext.setInternalState(to: .pendingNext)
checkRenderWaitingAndNotifyIfNeeded()
sourceQueue.async { [weak self] in
guard let self = self else { return }
serializationQueue.sync {
clearQueue()
entriesQueue.enqueue(item: audioEntry, type: .upcoming)
playerContext.setInternalState(to: .pendingNext)
do {
try self.startEngineIfNeeded()
} catch {
self.raiseUnxpected(error: .audioSystemError(.engineFailure))
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()
}
}
/// Queues the specified URL
///
/// - Parameter url: A `URL` specifying the audio context to be played.
/// - Parameter url: A `URL` specifying the audio content to be played.
public func queue(url: URL) {
queue(url: url, headers: [:])
}
/// Queues the specified URLs
///
/// - Parameter url: A `URL` specifying the audio content to be played.
public func queue(urls: [URL]) {
queue(urls: urls, headers: [:])
}
public func queue(url: URL, after afterUrl: URL) {
queue(url: url, headers: [:], after: afterUrl)
}
public func removeFromQueue(url: URL) {
serializationQueue.sync {
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
self?.processSource()
}
}
/// Queues the specified URL
///
/// - Parameter url: A `URL` specifying the audio context to be played.
/// - Parameter url: A `URL` specifying the audio content to be played.
/// - parameter headers: A `Dictionary` specifying any additional headers to be pass to the network request.
public func queue(url: URL, headers: [String: String]) {
let audioEntry = entryProvider.provideAudioEntry(url: url, headers: headers)
audioEntry.delegate = self
entriesQueue.enqueue(item: audioEntry, type: .upcoming)
public func queue(url: URL, headers: [String: String], after afterUrl: URL? = nil) {
serializationQueue.sync {
let audioEntry = entryProvider.provideAudioEntry(url: url, headers: headers)
audioEntry.delegate = self
if let afterUrl = afterUrl {
if let afterUrlEntry = entriesQueue.items(type: .upcoming).first(where: { $0.id.id == afterUrl.absoluteString }) {
entriesQueue.insert(item: audioEntry, type: .upcoming, after: afterUrlEntry)
}
} else {
entriesQueue.enqueue(item: audioEntry, type: .upcoming)
}
}
checkRenderWaitingAndNotifyIfNeeded()
sourceQueue.async { [weak self] in
self?.processSource()
}
}
/// Queues the specified URLs
///
/// - Parameter url: A array of `URL`s specifying the audio content to be played.
/// - parameter headers: A `Dictionary` specifying any additional headers to be pass to the network request.
public func queue(urls: [URL], headers: [String: String]) {
serializationQueue.sync {
for url in urls {
let audioEntry = entryProvider.provideAudioEntry(url: url, headers: headers)
audioEntry.delegate = self
entriesQueue.enqueue(item: audioEntry, type: .upcoming)
}
}
checkRenderWaitingAndNotifyIfNeeded()
sourceQueue.async { [weak self] in
self?.processSource()
}
}
/// Stops the audio playback
public func stop() {
public func stop(clearQueue: Bool = true) {
guard playerContext.internalState != .stopped else { return }
stopEngine(reason: .userAction)
stopReadProccessFromSource()
serializationQueue.sync {
stopEngine(reason: .userAction)
}
checkRenderWaitingAndNotifyIfNeeded()
sourceQueue.async { [weak self] in
guard let self = self else { return }
self.playerContext.audioReadingEntry?.delegate = nil
@@ -218,7 +319,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
@@ -226,7 +329,6 @@ public final class AudioPlayer {
self.processSource()
}
checkRenderWaitingAndNotifyIfNeeded()
}
/// Pauses the audio playback
@@ -234,9 +336,9 @@ public final class AudioPlayer {
if playerContext.internalState != .paused, playerContext.internalState.contains(.running) {
stateBeforePaused = playerContext.internalState
playerContext.setInternalState(to: .paused)
pauseEngine()
stopReadProccessFromSource()
serializationQueue.sync {
pauseEngine()
}
playerContext.audioPlayingEntry?.suspend()
sourceQueue.async { [weak self] in
self?.processSource()
@@ -248,23 +350,24 @@ public final class AudioPlayer {
public func resume() {
guard playerContext.internalState == .paused else { return }
playerContext.setInternalState(to: stateBeforePaused)
// check if seek time requested and reset buffers
do {
try startEngine()
} catch {
Logger.debug("resuming audio engine failed: %@", category: .generic, args: error.localizedDescription)
}
if let playingEntry = playerContext.audioReadingEntry {
if playingEntry.seekRequest.requested {
rendererContext.resetBuffers()
serializationQueue.sync {
do {
try startEngine()
} catch {
Logger.debug("resuming audio engine failed: %@", category: .generic, args: error.localizedDescription)
}
playingEntry.resume()
if let playingEntry = playerContext.audioReadingEntry {
if playingEntry.seekRequest.requested {
rendererContext.resetBuffers()
}
playingEntry.resume()
}
startPlayer(resetBuffers: false)
}
startPlayer(resetBuffers: false)
startReadProcessFromSourceIfNeeded()
}
/// Seeks the audio to the specified time.
/// - Parameter time: A `Double` value specifying the time of the requested seek in seconds
public func seek(to time: Double) {
guard let playingEntry = playerContext.audioPlayingEntry else {
return
@@ -287,12 +390,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)
@@ -300,6 +409,8 @@ public final class AudioPlayer {
reattachCustomNodes()
}
/// Detaches the given `AVAudioNode` from the engine
/// - Parameter node: An instance of `AVAudioNode`
public func detach(node: AVAudioNode) {
guard customAttachedNodes.contains(node) else {
return
@@ -309,8 +420,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()
@@ -344,7 +457,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)
}
}
@@ -359,7 +472,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))
}
}
}
@@ -375,9 +488,11 @@ public final class AudioPlayer {
playerRenderProcessor.audioFinishedPlaying = { [weak self] entry in
guard let self = self else { return }
self.sourceQueue.async {
self.serializationQueue.sync {
let nextEntry = self.entriesQueue.dequeue(type: .buffering)
self.processFinishPlaying(entry: entry, with: nextEntry)
}
self.sourceQueue.async {
self.processSource()
}
}
@@ -385,12 +500,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)
}
}
}
@@ -410,7 +525,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)
@@ -446,6 +561,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)
@@ -455,10 +571,6 @@ public final class AudioPlayer {
///
/// - parameter reason: A value of `AudioPlayerStopReason` indicating the reason the engine stopped.
private func stopEngine(reason: AudioPlayerStopReason) {
guard isEngineRunning && player.auAudioUnit.isRunning else {
Logger.debug("already already stopped 🛑", category: .generic)
return
}
audioEngine.stop()
player.auAudioUnit.stopHardware()
rendererContext.resetBuffers()
@@ -467,40 +579,20 @@ 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) {
if resetBuffers {
rendererContext.resetBuffers()
}
if !isEngineRunning && !player.auAudioUnit.isRunning {
Logger.debug("trying to start the player when audio engine and player are already running", category: .generic)
return
}
do {
try startEngineIfNeeded()
try player.auAudioUnit.allocateRenderResources()
try player.auAudioUnit.startHardware()
} catch {
stopEngine(reason: .error)
raiseUnxpected(error: .audioSystemError(.playerStartError))
raiseUnexpected(error: .audioSystemError(.playerStartError))
}
}
@@ -508,7 +600,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 {
@@ -517,8 +608,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()
@@ -542,10 +633,9 @@ public final class AudioPlayer {
let entry = entriesQueue.dequeue(type: .upcoming)
let shouldStartPlaying = playerContext.audioPlayingEntry == nil
playerContext.setInternalState(to: .waitingForData)
setCurrentReading(entry: entry, startPlaying: shouldStartPlaying, shouldClearQueue: true)
setCurrentReading(entry: entry, startPlaying: shouldStartPlaying, shouldClearQueue: false)
} else if playerContext.audioPlayingEntry == nil {
if playerContext.internalState != .stopped {
stopReadProccessFromSource()
stopEngine(reason: .eof)
}
}
@@ -561,7 +651,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 {
@@ -573,8 +663,9 @@ public final class AudioPlayer {
}
}
private func proccessSeekTime() {
assert(playerContext.audioReadingEntry === playerContext.audioPlayingEntry, "reading and playing entry must be the same")
private func processSeekTime() {
assert(playerContext.audioReadingEntry === playerContext.audioPlayingEntry,
"reading and playing entry must be the same")
fileStreamProcessor.processSeek()
}
@@ -610,44 +701,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)
@@ -657,12 +745,33 @@ 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()
}
processSource()
checkRenderWaitingAndNotifyIfNeeded()
}
@@ -685,9 +794,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)
@@ -706,7 +814,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
}
}
@@ -716,7 +824,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
}
@@ -727,12 +835,12 @@ extension AudioPlayer: AudioStreamSourceDelegate {
}
}
func errorOccured(source: CoreAudioStreamSource, error: Error) {
func errorOccurred(source: CoreAudioStreamSource, error: Error) {
guard let entry = playerContext.audioReadingEntry, entry.has(same: source) else { return }
raiseUnxpected(error: .networkError(.failure(error)))
raiseUnexpected(error: .networkError(.failure(error)))
}
func endOfFileOccured(source: CoreAudioStreamSource) {
func endOfFileOccurred(source: CoreAudioStreamSource) {
let hasSameSource = playerContext.audioReadingEntry?.has(same: source) ?? false
guard playerContext.audioReadingEntry == nil || hasSameSource else {
source.delegate = nil
@@ -763,7 +871,10 @@ extension AudioPlayer: AudioStreamSourceDelegate {
playerContext.audioReadingEntry = nil
playerContext.entriesLock.unlock()
processSource()
sourceQueue.async { [weak self] in
guard let self = self else { return }
self.processSource()
}
}
func metadataReceived(data: [String: String]) {
@@ -13,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: 1,
enableLogs: false)
/// Initializes the configuration for the `AudioPlayer`
///
@@ -35,22 +35,22 @@ public struct AudioPlayerConfiguration: Equatable {
/// - parameter flushQueueOnSeek: All pending items will be flushed when seeking a track if this is set to `true`
/// - parameter bufferSizeInSeconds: The size of the decompressed buffer.
/// - parameter secondsRequiredToStartPlaying: Number of seconds of audio required to before playback first starts.
/// - parameter gracePeriodAfterSeekInSeconds: Number of seconds of audio required after seek occcurs.
/// - parameter secondsRequiredToStartPlayingAfterBufferUnderun: Number of seconds of audio required to before playback resumes after a buffer underun
/// - parameter gracePeriodAfterSeekInSeconds: Number of seconds of audio required after seek occurs.
/// - parameter secondsRequiredToStartPlayingAfterBufferUnderrun: Number of seconds of audio required to before playback resumes after a buffer underrun
/// - parameter enableLogs: Enables the internal logs
///
public init(flushQueueOnSeek: Bool = true,
bufferSizeInSeconds: Double = 10,
secondsRequiredToStartPlaying: Double = 1,
gracePeriodAfterSeekInSeconds: Double = 0.5,
secondsRequiredToStartPlayingAfterBufferUnderun: Int = 1,
secondsRequiredToStartPlayingAfterBufferUnderrun: Int = 1,
enableLogs: Bool = false)
{
self.flushQueueOnSeek = flushQueueOnSeek
self.bufferSizeInSeconds = bufferSizeInSeconds
self.secondsRequiredToStartPlaying = secondsRequiredToStartPlaying
self.gracePeriodAfterSeekInSeconds = gracePeriodAfterSeekInSeconds
self.secondsRequiredToStartPlayingAfterBufferUnderun = secondsRequiredToStartPlayingAfterBufferUnderun
self.secondsRequiredToStartPlayingAfterBufferUnderrun = secondsRequiredToStartPlayingAfterBufferUnderrun
self.enableLogs = enableLogs
}
@@ -70,15 +70,15 @@ public struct AudioPlayerConfiguration: Equatable {
? defaults.gracePeriodAfterSeekInSeconds
: self.gracePeriodAfterSeekInSeconds
let secondsRequiredToStartPlayingAfterBufferUnderun = self.secondsRequiredToStartPlayingAfterBufferUnderun == 0
? defaults.secondsRequiredToStartPlayingAfterBufferUnderun
: self.secondsRequiredToStartPlayingAfterBufferUnderun
let secondsRequiredToStartPlayingAfterBufferUnderrun = self.secondsRequiredToStartPlayingAfterBufferUnderrun == 0
? defaults.secondsRequiredToStartPlayingAfterBufferUnderrun
: self.secondsRequiredToStartPlayingAfterBufferUnderrun
return AudioPlayerConfiguration(flushQueueOnSeek: flushQueueOnSeek,
bufferSizeInSeconds: bufferSizeInSeconds,
secondsRequiredToStartPlaying: secondsRequiredToStartPlaying,
gracePeriodAfterSeekInSeconds: gracePeriodAfterSeekInSeconds,
secondsRequiredToStartPlayingAfterBufferUnderun: secondsRequiredToStartPlayingAfterBufferUnderun,
secondsRequiredToStartPlayingAfterBufferUnderrun: secondsRequiredToStartPlayingAfterBufferUnderrun,
enableLogs: enableLogs)
}
}
@@ -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)
@@ -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: UInt32
let framesRequiredAfterRebuffering: UInt32
let framesRequiredForDataAfterSeekPlaying: UInt32
var waitingForDataAfterSeekFrameCount = Protected<Int32>(0)
let waitingForDataAfterSeekFrameCount = Atomic<Int32>(0)
private let configuration: AudioPlayerConfiguration
@@ -36,7 +34,7 @@ final class AudioRendererContext {
let canonicalStream = outputAudioFormat.basicStreamDescription
framesRequiredToStartPlaying = UInt32(canonicalStream.mSampleRate) * UInt32(configuration.secondsRequiredToStartPlaying)
framesRequiredAfterRebuffering = UInt32(canonicalStream.mSampleRate) * UInt32(configuration.secondsRequiredToStartPlayingAfterBufferUnderun)
framesRequiredAfterRebuffering = UInt32(canonicalStream.mSampleRate) * UInt32(configuration.secondsRequiredToStartPlayingAfterBufferUnderrun)
framesRequiredForDataAfterSeekPlaying = UInt32(canonicalStream.mSampleRate) * UInt32(configuration.gracePeriodAfterSeekInSeconds)
let dataByteSize = Int(canonicalStream.mSampleRate * configuration.bufferSizeInSeconds) * Int(canonicalStream.mBytesPerFrame)
@@ -77,8 +75,8 @@ private func allocateBufferList(dataByteSize: Int) -> UnsafeMutablePointer<Audio
let _bufferList = AudioBufferList.allocate(maximumBuffers: 1)
_bufferList[0].mDataByteSize = UInt32(dataByteSize)
let alingment = MemoryLayout<UInt8>.alignment
let mData = UnsafeMutableRawPointer.allocate(byteCount: dataByteSize, alignment: alingment)
let alignment = MemoryLayout<UInt8>.alignment
let mData = UnsafeMutableRawPointer.allocate(byteCount: dataByteSize, alignment: alignment)
_bufferList[0].mData = mData
_bufferList[0].mNumberChannels = 2
@@ -9,7 +9,7 @@ import AVFoundation
enum AudioConvertStatus: Int32 {
case done = 100
case proccessed = 0
case processed = 0
}
struct AudioConvertInfo {
@@ -20,11 +20,11 @@ struct AudioConvertInfo {
}
enum FileStreamProcessorEffect {
case proccessSource
case processSource
case raiseError(AudioPlayerError)
}
/// An object that handles the proccessing of AudioFileStream, its packets etc.
/// An object that handles the processing of AudioFileStream, its packets etc.
final class AudioFileStreamProcessor {
private let maxCompressedPacketForBitrate = 4096
@@ -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
@@ -191,7 +186,11 @@ final class AudioFileStreamProcessor {
guard AudioFileStreamGetProperty(fileStream, kAudioFileStreamProperty_MagicCookieData, &cookieSize, &cookie) == noErr else {
return
}
guard AudioFileStreamSetProperty(fileStream, kAudioConverterDecompressionMagicCookie, cookieSize, cookie) == noErr else {
guard let converter = audioConverter else {
fileStreamCallback?(.raiseError(.audioSystemError(.fileStreamError(.unknownError))))
return
}
guard AudioConverterSetProperty(converter, kAudioConverterDecompressionMagicCookie, cookieSize, cookie) == noErr else {
fileStreamCallback?(.raiseError(.audioSystemError(.fileStreamError(.unknownError))))
return
}
@@ -215,41 +214,46 @@ 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
processReadyToProducePackets(entry: entry, fileStream: fileStream)
processPacketUpperBoundAndMaxPacketSize(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 { playerContext.audioReadingEntry?.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)
if entry.audioStreamFormat.mFormatID != kAudioFormatLinearPCM {
discontinuous = true
}
}
@@ -259,13 +263,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)
@@ -273,6 +277,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,
@@ -285,31 +292,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))
@@ -327,7 +352,7 @@ final class AudioFileStreamProcessor {
i += step
}
if fileFormat == fa4mFormat {
if fileFormatsForDelayedConverterCreation.contains(currentFileFormat) {
if let inputStreamFormat = playerContext.audioReadingEntry?.audioStreamFormat {
createAudioConverter(from: inputStreamFormat, to: outputAudioFormat)
}
@@ -336,18 +361,19 @@ 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 }
guard entry.audioStreamState.processedDataFormat else { return }
if let playingEntry = playerContext.audioPlayingEntry,
playingEntry.seekRequest.requested, playingEntry.calculatedBitrate() > 0
{
fileStreamCallback?(.proccessSource)
fileStreamCallback?(.processSource)
if rendererContext.waiting.value {
rendererContext.packetsSemaphore.signal()
}
@@ -371,52 +397,53 @@ final class AudioFileStreamProcessor {
convertInfo.audioBuffer.mNumberChannels = playingAudioStreamFormat.mChannelsPerFrame
}
updateProccessedPackets(inPacketDescriptions: inPacketDescriptions,
inNumberPackets: inNumberPackets)
updateProcessedPackets(inPacketDescriptions: inPacketDescriptions,
inNumberPackets: inNumberPackets)
var status: OSStatus = noErr
packetProccess: while status == noErr {
packetProcess: while status == noErr {
rendererContext.lock.lock()
let bufferContext = rendererContext.bufferContext
var used = bufferContext.frameUsedCount
var start = bufferContext.frameStartIndex
var end = bufferContext.end
var framesLeftInBuffer = max(bufferContext.totalFrameCount &- used, 0)
var framesLeftInBuffer = bufferContext.totalFrameCount - used
rendererContext.lock.unlock()
if framesLeftInBuffer == 0 {
rendererContext.lock.lock()
let bufferContext = rendererContext.bufferContext
used = bufferContext.frameUsedCount
start = bufferContext.frameStartIndex
end = bufferContext.end
framesLeftInBuffer = max(bufferContext.totalFrameCount &- used, 0)
rendererContext.lock.unlock()
if framesLeftInBuffer > 0 {
break packetProccess
}
if playerContext.disposedRequested
|| playerContext.internalState == .disposed
|| playerContext.internalState == .pendingNext
|| playerContext.internalState == .stopped
{
return
}
if let playingEntry = playerContext.audioPlayingEntry,
playingEntry.seekRequest.requested, playingEntry.calculatedBitrate() > 0
{
fileStreamCallback?(.proccessSource)
if rendererContext.waiting.value {
rendererContext.packetsSemaphore.signal()
while true {
rendererContext.lock.lock()
let bufferContext = rendererContext.bufferContext
used = bufferContext.frameUsedCount
start = bufferContext.frameStartIndex
end = (bufferContext.frameStartIndex + bufferContext.frameUsedCount) % bufferContext.totalFrameCount
framesLeftInBuffer = bufferContext.totalFrameCount - used
rendererContext.lock.unlock()
if framesLeftInBuffer > 0 {
break
}
if playerContext.internalState == .disposed
|| playerContext.internalState == .pendingNext
|| playerContext.internalState == .stopped
{
return
}
return
}
rendererContext.waiting.write { $0 = true }
rendererContext.packetsSemaphore.wait()
rendererContext.waiting.write { $0 = false }
if let playingEntry = playerContext.audioPlayingEntry,
playingEntry.seekRequest.requested, playingEntry.calculatedBitrate() > 0
{
fileStreamCallback?(.processSource)
if rendererContext.waiting.value {
rendererContext.packetsSemaphore.signal()
}
return
}
rendererContext.waiting.write { $0 = true }
rendererContext.packetsSemaphore.wait()
rendererContext.waiting.write { $0 = false }
}
}
let localBufferList = AudioBufferList.allocate(maximumBuffers: 1)
@@ -427,16 +454,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
@@ -444,36 +475,40 @@ final class AudioFileStreamProcessor {
fillUsedFrames(framesCount: framesAdded)
return
} else if status != 0 {
/// raise undexpected error... codec error
fileStreamCallback?(.raiseError(.codecError))
return
}
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 {
/// raise undexpected error... codec error
fileStreamCallback?(.raiseError(.codecError))
return
}
} else {
@@ -481,26 +516,30 @@ 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 {
/// raise undexpected error... codec error
fileStreamCallback?(.raiseError(.codecError))
return
}
}
@@ -513,10 +552,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
}
@@ -539,9 +579,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
@@ -561,23 +602,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,
@@ -587,12 +630,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
@@ -612,12 +656,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))
@@ -90,7 +90,7 @@ final class AudioPlayerRenderProcessor: NSObject {
waitForBuffer = true
}
} else if state == .waitingForDataAfterSeek {
var requiredFramesToStart: Int = 1024
var requiredFramesToStart = 1024
if framesState.lastFrameQueued >= 0 {
requiredFramesToStart = min(requiredFramesToStart, framesState.lastFrameQueued - framesState.queued)
}
@@ -109,14 +109,12 @@ final class AudioPlayerRenderProcessor: NSObject {
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 +130,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 +147,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))
}
}
}
@@ -179,9 +171,9 @@ 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 playerContext.internalState != .rebuffering {
@@ -198,7 +190,6 @@ final class AudioPlayerRenderProcessor: NSObject {
state.contains(.running) && state != .playing
}
}
rendererContext.waitingForDataAfterSeekFrameCount.write { $0 = 0 }
}
} else {
rendererContext.waitingForDataAfterSeekFrameCount.write { $0 = 0 }
@@ -211,7 +202,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 +264,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 +303,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()
@@ -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,
@@ -44,7 +44,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,40 +14,51 @@ struct HeaderField {
}
enum IcyHeaderField {
public static let icyMentaint = "icy-metaint"
public static let icyMetaint = "icy-metaint"
}
struct HTTPHeaderParserOutput {
let supportsSeek: Bool
let fileLength: Int
let typeId: AudioFileTypeID
// Metadata Support
let metadataStep: Int
let seekable: Bool
var isMp4: Bool {
(typeId == kAudioFileMPEG4Type || typeId == kAudioFileM4AType)
}
}
struct HTTPHeaderParser: Parser {
protocol HTTPHeaderParsing: Parser {
/// Returns the value for the given field of the headers in the given `HTTPURLResponse`
///
/// - Parameters:
/// - field: The header field to be searched
/// - response: The `HTTPURLResponse` for the header
/// - Returns: A `String` if the field exists in the headers otherwise `nil`
func value(forHTTPHeaderField field: String, in response: HTTPURLResponse) -> String?
}
struct HTTPHeaderParser: HTTPHeaderParsing {
typealias Input = HTTPURLResponse
typealias Output = HTTPHeaderParserOutput?
func parse(input: HTTPURLResponse) -> HTTPHeaderParserOutput? {
guard let headers = input.allHeaderFields as? [String: String], !headers.isEmpty else { return nil }
let supportsSeek = headers[HeaderField.acceptRanges] != "none"
guard let headers = input.allHeaderFields as? [String: String], headers.count > 2 else { return nil }
var typeId: UInt32 = 0
if let contentType = input.mimeType {
typeId = audioFileType(mimeType: contentType)
}
var fileLength: Int = 0
var fileLength = 0
if input.statusCode == 200 {
if let contentLength = headers[HeaderField.contentLength],
let length = Int(contentLength)
{
let contentLength = value(forHTTPHeaderField: HeaderField.contentLength, in: input)
if let contentLength = contentLength, let length = Int(contentLength) {
fileLength = length
}
} else if input.statusCode == 206 {
if let contentLength = headers[HeaderField.contentRange] {
if let contentLength = value(forHTTPHeaderField: HeaderField.contentRange, in: input) {
let components = contentLength.components(separatedBy: "/")
if components.count == 2 {
if let last = components.last, let length = Int(last) {
@@ -58,15 +69,39 @@ struct HTTPHeaderParser: Parser {
}
var metadataStep = 0
if let icyMetaint = headers[IcyHeaderField.icyMentaint],
if let icyMetaint = value(forHTTPHeaderField: IcyHeaderField.icyMetaint, in: input),
let intValue = Int(icyMetaint)
{
metadataStep = intValue
}
return HTTPHeaderParserOutput(supportsSeek: supportsSeek,
fileLength: fileLength,
typeId: typeId,
metadataStep: metadataStep)
return HTTPHeaderParserOutput(
fileLength: fileLength,
typeId: typeId,
metadataStep: metadataStep,
seekable: input.statusCode == 206
)
}
}
extension Parser where Self: HTTPHeaderParsing {
func value(forHTTPHeaderField field: String, in response: HTTPURLResponse) -> String? {
if #available(iOS 13.0, *) {
return response.value(forHTTPHeaderField: field)
} else {
if let fields = response.allHeaderFields as? [String: String] {
return valueForCaseInsensitiveKey(field, fields: fields)
} else {
return nil
}
}
}
private func valueForCaseInsensitiveKey(_ key: String, fields: [String: String]) -> String? {
let keyToBeFound = key.lowercased()
for (key, value) in fields 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 {
+23
View File
@@ -83,4 +83,27 @@ class QueueTests: XCTestCase {
queue.removeAll()
XCTAssertTrue(queue.isEmpty)
}
func testInsertingAtSpecificIndex() {
let queue = Queue<Int>()
queue.enqueue(item: 1)
queue.enqueue(item: 2)
queue.enqueue(item: 3)
queue.insert(item: 6, at: 1)
XCTAssertEqual(queue.count, 4)
XCTAssertEqual(queue.remove(at: 1), 6)
}
func testRemovingAtSpecificIndex() {
let queue = Queue<Int>()
queue.enqueue(item: 1)
queue.enqueue(item: 2)
queue.enqueue(item: 3)
XCTAssertEqual(queue.remove(at: 1), 2)
XCTAssertEqual(queue.count, 2)
}
}
@@ -13,28 +13,29 @@ import XCTest
class MetadataStreamProcessorTests: XCTestCase {
var metadataDelegateSpy = MetadataDelegateSpy()
let bundle = Bundle(for: MetadataStreamProcessorTests.self)
func test_Processor_SendsCorrectValues_IfItCanProcessMetadata() throws {
let parser = MetadataParser()
let processor = MetadataStreamProcessor(parser: parser.eraseToAnyParser())
// without calling `metadataAvailable(step:)` it should be false
XCTAssertFalse(processor.canProccessMetadata)
XCTAssertFalse(processor.canProcessMetadata)
// calling `metadataAvailable(step:)` with zero
processor.metadataAvailable(step: 0)
// it should be false
XCTAssertFalse(processor.canProccessMetadata)
XCTAssertFalse(processor.canProcessMetadata)
// calling `metadataAvailable(step:)` with greater zero
processor.metadataAvailable(step: 1)
// it should be true
XCTAssertTrue(processor.canProccessMetadata)
XCTAssertTrue(processor.canProcessMetadata)
}
func test_Processor_Outputs_Correct_Metadata_ForStep_WithEmptyMetadata() throws {
let bundle = Bundle(for: MetadataStreamProcessorTests.self)
let url = bundle.url(forResource: "raw-stream-audio-empty-metadata", withExtension: nil)!
let data = try Data(contentsOf: url)
@@ -45,7 +46,7 @@ class MetadataStreamProcessorTests: XCTestCase {
// this is the step value as received from the http headers
processor.metadataAvailable(step: 16000)
let audio = processor.proccessMetadata(data: data)
let audio = processor.processMetadata(data: data)
XCTAssertFalse(audio.isEmpty)
XCTAssertTrue(metadataDelegateSpy.receivedMetadata.called)
@@ -53,7 +54,6 @@ class MetadataStreamProcessorTests: XCTestCase {
}
func test_Processor_Outputs_Correct_Metadata_ForStep_WithMetadata() throws {
let bundle = Bundle(for: MetadataStreamProcessorTests.self)
let url = bundle.url(forResource: "raw-stream-audio-normal-metadata", withExtension: nil)!
let data = try Data(contentsOf: url)
@@ -64,7 +64,7 @@ class MetadataStreamProcessorTests: XCTestCase {
// this is the step value as received from the http headers
processor.metadataAvailable(step: 16000)
let audio = processor.proccessMetadata(data: data)
let audio = processor.processMetadata(data: data)
XCTAssertFalse(audio.isEmpty)
XCTAssertTrue(metadataDelegateSpy.receivedMetadata.called)
@@ -72,7 +72,6 @@ class MetadataStreamProcessorTests: XCTestCase {
}
func test_Processor_Outputs_Correct_Metadata_ForStep_WithMetadata_Alt() throws {
let bundle = Bundle(for: MetadataStreamProcessorTests.self)
let url = bundle.url(forResource: "raw-stream-audio-normal-metadata-alt", withExtension: nil)!
let data = try Data(contentsOf: url)
@@ -83,7 +82,7 @@ class MetadataStreamProcessorTests: XCTestCase {
// this is the step value as received from the http headers
processor.metadataAvailable(step: 8000)
let audio = processor.proccessMetadata(data: data)
let audio = processor.processMetadata(data: data)
XCTAssertFalse(audio.isEmpty)
XCTAssertTrue(metadataDelegateSpy.receivedMetadata.called)
@@ -95,7 +94,6 @@ class MetadataStreamProcessorTests: XCTestCase {
}
func test_Processor_Outputs_Correct_Metadata_ForStep_NoMetadata() throws {
let bundle = Bundle(for: MetadataStreamProcessorTests.self)
let url = bundle.url(forResource: "raw-stream-audio-no-metadata", withExtension: nil)!
let data = try Data(contentsOf: url)
@@ -106,7 +104,7 @@ class MetadataStreamProcessorTests: XCTestCase {
// this is the step value as received from the http headers
processor.metadataAvailable(step: 16000)
let audio = processor.proccessMetadata(data: data)
let audio = processor.processMetadata(data: data)
XCTAssertFalse(audio.isEmpty)
XCTAssertFalse(metadataDelegateSpy.receivedMetadata.called)
@@ -122,7 +120,7 @@ class MetadataStreamProcessorTests: XCTestCase {
// this is the step value as received from the http headers
processor.metadataAvailable(step: 16000)
let audio = processor.proccessMetadata(data: data)
let audio = processor.processMetadata(data: data)
XCTAssertTrue(audio.isEmpty)
XCTAssertFalse(metadataDelegateSpy.receivedMetadata.called)

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