Compare commits

...

42 Commits

Author SHA1 Message Date
Dimitris C. c62aedd2ba Refactor GitHub Actions workflow for Swift package testing 2025-11-08 20:19:37 +02:00
dimitris-c 571feca637 Refactors project use pure Package.swift and swift test 2025-11-08 20:17:30 +02:00
dimitris-c 2b511561ef fix 2025-11-07 16:27:02 +02:00
dimitris-c be1b79eb84 improves isSeekable property 2025-11-07 16:06:16 +02:00
dimitris-c a2832a5c9f removes debug message 2025-11-07 15:58:21 +02:00
dimitris-c 8fb497adfc Merge branch 'dimitris/feature/ogg' of https://github.com/dimitris-c/AudioStreaming into dimitris/feature/ogg 2025-11-07 15:56:33 +02:00
dimitris-c 6b2a988891 improvements 2025-11-07 15:56:30 +02:00
Dimitris C. 0555a9a46f Update iOS requirement from 13.0 to 15.0 2025-11-07 15:22:45 +02:00
Dimitris C. 594f379afe Update README to remove M4A limitation note
Removed limitation note for non-optimized M4A files.
2025-11-07 15:20:32 +02:00
dimitris-c e5e70ad92f fix rateNode issue 2025-11-07 14:07:49 +02:00
dimitris-c 02a3d6e191 fixes 2025-11-07 14:00:07 +02:00
dimitris-c fcba80cef7 Adds support for Ogg Vorbis 2025-11-06 18:15:40 +02:00
dimitris-c 6b1dc644fe park - working 2025-11-01 22:56:26 +02:00
dimitris-c 448ad711a1 park 2025-10-27 15:14:28 +02:00
Dimitris C. 72028c261f Update swift.yml 2025-10-15 14:06:59 +03:00
dimitris-c 4b8bae96c2 bump version number 2025-10-13 18:33:32 +03:00
Maximilian Bauer bccfc20403 fix seek crash: Double value gets infinit and can't be converted to Int64 (#115)
* fix seek crash: Double value get infinit and can't be converted to Int64

* add CoreAudio import to be able to build for macOS Catalyst
2025-10-13 18:32:34 +03:00
Dimitris C. 69dc0d631c fix(MP4): MP4 restructure improvements and some other fixes (#120)
* Adds mp4 restructure improvements

* fixes data race

* fix incorrect parsing of formatList

* adds more handling on propertyListenerProc
2025-10-13 18:32:09 +03:00
Tiger W 4d9bb98aed Support customizing HTTP method and HTTP body (#108) 2025-05-30 14:38:29 +03:00
Dimitris C. 31368a54c1 Revert "Revert "Expose the framesPlayed attribute so progress can be tracked …" (#111)
This reverts commit d3b563c7cd.
2025-05-30 10:13:46 +03:00
Dimitris C. d3b563c7cd Revert "Expose the framesPlayed attribute so progress can be tracked based on…" (#110)
This reverts commit a416cc8e92.
2025-05-29 17:50:50 +03:00
Jackson Harper a416cc8e92 Expose the framesPlayed attribute so progress can be tracked based on frames instead of time (#109) 2025-05-29 17:45:31 +03:00
Stuart A. Malone f36ca68faa Mark state and error types as Sendable so clients can pass them (#105)
across isolation boundaries.
2025-02-26 17:20:33 +02:00
Dimitris C. 2f3c7912e8 bump version number 2024-12-30 13:59:27 +02:00
Patrick McConnell 548d599628 Add tvOS Support (#102)
* add tvOS support

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

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

* Add example of using a custom stream

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

* example project progress

* progress

* progress

* progress

* update comment on example project

* adds animations to eq view

* progress

* refactor

* progress

* Updates example, fixes noise output on mute

* Update swift.yml

* Update swift.yml

* fix tests

* Update README.md
2024-05-16 13:03:21 +03:00
Dimitris C 94bc48c7f1 Update swift.yml 2024-05-16 12:39:25 +03:00
Dimitris C de2c04cdaf Update README.md 2024-05-15 10:13:58 +03:00
95 changed files with 4352 additions and 3539 deletions
+6 -7
View File
@@ -10,15 +10,14 @@ on:
- '*'
jobs:
iOS:
name: Test iOS
test:
name: Test Swift Package
runs-on: macOS-latest
env:
DEVELOPER_DIR: /Applications/Xcode.app/Contents/Developer
strategy:
matrix:
destination: ["OS=latest,name=iPhone 13 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
- name: Build
run: swift build
- name: Run tests
run: swift test --parallel
+3 -3
View File
@@ -41,11 +41,11 @@ playground.xcworkspace
# Packages/
# Package.pins
# Package.resolved
# *.xcodeproj
*.xcodeproj
#
# Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata
# hence it is not needed unless you have added a package configuration file to your project
# .swiftpm
.swiftpm
.build/
@@ -58,7 +58,7 @@ playground.xcworkspace
# Pods/
#
# Add this line if you want to avoid checking in source code from the Xcode workspace
# *.xcworkspace
*.xcworkspace
# Carthage
#
@@ -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>
@@ -4,33 +4,33 @@
<dict>
<key>runDestinationsByUUID</key>
<dict>
<key>31DA71B1-4664-472C-BD35-0711EE94837A</key>
<key>E340D9FA-D19A-49BB-82AA-9D0E236D4288</key>
<dict>
<key>localComputer</key>
<dict>
<key>busSpeedInMHz</key>
<integer>400</integer>
<integer>0</integer>
<key>cpuCount</key>
<integer>1</integer>
<key>cpuKind</key>
<string>8-Core Intel Core i9</string>
<string>Apple M1 Pro</string>
<key>cpuSpeedInMHz</key>
<integer>2300</integer>
<integer>0</integer>
<key>logicalCPUCoresPerPackage</key>
<integer>16</integer>
<integer>10</integer>
<key>modelCode</key>
<string>MacBookPro16,1</string>
<string>MacBookPro18,1</string>
<key>physicalCPUCoresPerPackage</key>
<integer>8</integer>
<integer>10</integer>
<key>platformIdentifier</key>
<string>com.apple.platform.macosx</string>
</dict>
<key>targetArchitecture</key>
<string>x86_64</string>
<string>arm64</string>
<key>targetDevice</key>
<dict>
<key>modelCode</key>
<string>iPhone12,1</string>
<string>iPhone16,1</string>
<key>platformIdentifier</key>
<string>com.apple.platform.iphonesimulator</string>
</dict>
+321
View File
@@ -0,0 +1,321 @@
#include "include/VorbisFileBridge.h"
#include <stdlib.h>
#include <string.h>
#include <pthread.h>
#include <vorbis/vorbisfile.h>
struct VFRemoteStream {
uint8_t *buf;
size_t cap, head, tail, size;
int eof;
long long pos; // Current read position in the stream
long long total_pushed; // Total bytes pushed into the buffer
pthread_mutex_t m;
pthread_cond_t cv;
};
// Simple ring buffer write
static size_t rb_write(struct VFRemoteStream *s, const uint8_t *src, size_t len) {
size_t written = 0;
while (written < len) {
size_t free_space = s->cap - s->size;
if (free_space == 0) break;
size_t chunk = s->cap - s->tail;
if (chunk > len - written) chunk = len - written;
if (chunk > free_space) chunk = free_space;
memcpy(s->buf + s->tail, src + written, chunk);
s->tail = (s->tail + chunk) % s->cap;
s->size += chunk;
written += chunk;
}
return written;
}
// Simple ring buffer read
static size_t rb_read(struct VFRemoteStream *s, uint8_t *dst, size_t len) {
size_t read = 0;
while (read < len && s->size > 0) {
size_t chunk = s->cap - s->head;
if (chunk > s->size) chunk = s->size;
if (chunk > len - read) chunk = len - read;
memcpy(dst + read, s->buf + s->head, chunk);
s->head = (s->head + chunk) % s->cap;
s->size -= chunk;
read += chunk;
}
return read;
}
// Create a stream buffer
VFStreamRef VFStreamCreate(size_t capacity_bytes) {
struct VFRemoteStream *s = (struct VFRemoteStream *)calloc(1, sizeof(struct VFRemoteStream));
if (!s) return NULL;
s->buf = (uint8_t *)malloc(capacity_bytes);
if (!s->buf) { free(s); return NULL; }
s->cap = capacity_bytes;
pthread_mutex_init(&s->m, NULL);
pthread_cond_init(&s->cv, NULL);
return s;
}
// Destroy a stream buffer
void VFStreamDestroy(VFStreamRef sr) {
struct VFRemoteStream *s = (struct VFRemoteStream *)sr;
if (!s) return;
pthread_mutex_destroy(&s->m);
pthread_cond_destroy(&s->cv);
free(s->buf);
free(s);
}
// Get available bytes in the buffer
size_t VFStreamAvailableBytes(VFStreamRef sr) {
struct VFRemoteStream *s = (struct VFRemoteStream *)sr;
if (!s) return 0;
pthread_mutex_lock(&s->m);
size_t sz = s->size;
pthread_mutex_unlock(&s->m);
return sz;
}
// Push data into the stream
void VFStreamPush(VFStreamRef sr, const uint8_t *data, size_t len) {
struct VFRemoteStream *s = (struct VFRemoteStream *)sr;
if (!s || !data || len == 0) return;
pthread_mutex_lock(&s->m);
size_t written_total = 0;
while (written_total < len) {
size_t w = rb_write(s, data + written_total, len - written_total);
written_total += w;
if (written_total < len) {
// Buffer full, wait for consumer to read
pthread_cond_wait(&s->cv, &s->m);
}
}
s->total_pushed += (long long)len;
pthread_cond_broadcast(&s->cv);
pthread_mutex_unlock(&s->m);
}
// Mark the stream as EOF
void VFStreamMarkEOF(VFStreamRef sr) {
struct VFRemoteStream *s = (struct VFRemoteStream *)sr;
if (!s) return;
pthread_mutex_lock(&s->m);
s->eof = 1;
pthread_cond_broadcast(&s->cv);
pthread_mutex_unlock(&s->m);
}
// libvorbisfile callbacks
// Read callback for libvorbisfile
static size_t read_cb(void *ptr, size_t size, size_t nmemb, void *datasrc) {
struct VFRemoteStream *s = (struct VFRemoteStream *)datasrc;
size_t want_bytes = size * nmemb;
size_t got = 0;
pthread_mutex_lock(&s->m);
// Read what's available NOW - don't block waiting for more data
while (got < want_bytes && s->size > 0) {
size_t chunk = rb_read(s, (uint8_t *)ptr + got, want_bytes - got);
s->pos += (long long)chunk;
got += chunk;
if (chunk == 0) break;
// Allow producer to push more
pthread_cond_broadcast(&s->cv);
}
// If nothing available and EOF, we're done
if (got == 0 && s->eof) {
// Return 0 to signal EOF to libvorbisfile
}
pthread_mutex_unlock(&s->m);
return size ? (got / size) : 0;
}
// Seek callback - seek within the ring buffer
static int seek_cb(void *datasrc, ogg_int64_t offset, int whence) {
struct VFRemoteStream *s = (struct VFRemoteStream *)datasrc;
if (!s) return -1;
pthread_mutex_lock(&s->m);
ogg_int64_t new_pos = 0;
switch (whence) {
case SEEK_SET:
new_pos = offset;
break;
case SEEK_CUR:
new_pos = s->pos + offset;
break;
case SEEK_END:
new_pos = s->total_pushed + offset;
break;
default:
pthread_mutex_unlock(&s->m);
return -1;
}
// Check if the new position is valid (within available data)
if (new_pos < 0 || new_pos > s->total_pushed) {
pthread_mutex_unlock(&s->m);
return -1; // Can't seek outside available data
}
// Calculate how much data we've already consumed from the buffer
long long already_consumed = s->pos - ((long long)s->total_pushed - (long long)s->size);
// Calculate the new head position
long long pos_delta = new_pos - s->pos;
// For forward seeks, we need to have enough data in the buffer
if (pos_delta > 0 && pos_delta > (long long)s->size) {
pthread_mutex_unlock(&s->m);
return -1; // Not enough data in buffer to seek forward
}
// For backward seeks, check if that data is still in the buffer
if (pos_delta < 0 && (-pos_delta) > already_consumed) {
pthread_mutex_unlock(&s->m);
return -1; // Data has been discarded from buffer
}
// Adjust head pointer
if (pos_delta >= 0) {
// Forward seek: advance head
s->head = (s->head + pos_delta) % s->cap;
s->size -= (size_t)pos_delta;
} else {
// Backward seek: rewind head
size_t rewind = (size_t)(-pos_delta);
if (s->head >= rewind) {
s->head -= rewind;
} else {
s->head = s->cap - (rewind - s->head);
}
s->size += rewind;
}
s->pos = new_pos;
pthread_mutex_unlock(&s->m);
return 0;
}
// Close callback - no-op
static int close_cb(void *datasrc) {
(void)datasrc;
return 0;
}
// Tell callback - return current position
static long tell_cb(void *datasrc) {
struct VFRemoteStream *s = (struct VFRemoteStream *)datasrc;
return (long)s->pos;
}
// Open a vorbis file using callbacks
int VFOpen(VFStreamRef sr, VFFileRef *out_vf) {
struct VFRemoteStream *s = (struct VFRemoteStream *)sr;
if (!s || !out_vf) return -1;
OggVorbis_File *vf = (OggVorbis_File *)malloc(sizeof(OggVorbis_File));
if (!vf) return -1;
ov_callbacks cbs;
cbs.read_func = read_cb;
cbs.seek_func = NULL; // Non-seekable streaming (seeking handled at Swift level)
cbs.close_func = close_cb;
cbs.tell_func = tell_cb;
int rc = ov_open_callbacks((void *)s, vf, NULL, 0, cbs);
if (rc < 0) { free(vf); return rc; }
*out_vf = (VFFileRef)vf;
return 0;
}
// Clear a vorbis file
void VFClear(VFFileRef fr) {
OggVorbis_File *vf = (OggVorbis_File *)fr;
if (!vf) return;
ov_clear(vf);
free(vf);
}
// Get stream info
int VFGetInfo(VFFileRef fr, VFStreamInfo *out_info) {
OggVorbis_File *vf = (OggVorbis_File *)fr;
if (!vf || !out_info) return -1;
vorbis_info const *info = ov_info(vf, -1);
if (!info) return -1;
out_info->sample_rate = info->rate;
out_info->channels = info->channels;
out_info->total_pcm_samples = ov_pcm_total(vf, -1);
out_info->duration_seconds = ov_time_total(vf, -1);
out_info->bitrate_nominal = info->bitrate_nominal;
return 0;
}
// Read deinterleaved float PCM frames
long VFReadFloat(VFFileRef fr, float ***out_pcm, int max_frames) {
OggVorbis_File *vf = (OggVorbis_File *)fr;
if (!vf || !out_pcm || max_frames <= 0) return -1;
int bitstream = 0;
long frames = ov_read_float(vf, out_pcm, max_frames, &bitstream);
// Returns: frames read (0 = EOF, <0 = error)
return frames;
}
// Read interleaved float PCM frames (legacy, less efficient)
long VFReadInterleavedFloat(VFFileRef fr, float *dst, int max_frames) {
OggVorbis_File *vf = (OggVorbis_File *)fr;
if (!vf || !dst || max_frames <= 0) return -1;
int bitstream = 0;
float **pcm = NULL;
long frames = ov_read_float(vf, &pcm, max_frames, &bitstream);
if (frames <= 0) return frames; // 0 EOF, <0 error/hole
vorbis_info const *info = ov_info(vf, -1);
int ch = info->channels;
// Interleave the PCM data
for (long f = 0; f < frames; ++f) {
for (int c = 0; c < ch; ++c) {
dst[f * ch + c] = pcm[c][f];
}
}
return frames;
}
// Seek to a specific time in seconds
int VFSeekTime(VFFileRef fr, double time_seconds) {
OggVorbis_File *vf = (OggVorbis_File *)fr;
if (!vf) return -1;
// Use ov_time_seek for time-based seeking
// Returns 0 on success, nonzero on failure
return ov_time_seek(vf, time_seconds);
}
// Check if the stream is seekable
int VFIsSeekable(VFFileRef fr) {
OggVorbis_File *vf = (OggVorbis_File *)fr;
if (!vf) return 0;
// Returns nonzero if the stream is seekable
return ov_seekable(vf);
}
+13
View File
@@ -0,0 +1,13 @@
//
// AudioCodecs.h
// AudioStreaming
//
// Created on 25/10/2025.
//
#ifndef AudioCodecs_h
#define AudioCodecs_h
#import "VorbisFileBridge.h"
#endif /* AudioCodecs_h */
+58
View File
@@ -0,0 +1,58 @@
#ifndef VORBIS_FILE_BRIDGE_H
#define VORBIS_FILE_BRIDGE_H
#include <stddef.h>
#include <stdint.h>
// Opaque refs for Swift-friendly API
typedef void * VFStreamRef;
typedef void * VFFileRef;
#ifdef __cplusplus
extern "C" {
#endif
// Stream info structure
typedef struct {
int sample_rate;
int channels;
long long total_pcm_samples; // -1 if unknown
double duration_seconds; // < 0 if unknown
long bitrate_nominal; // nominal bitrate in bits/sec, or 0 if unknown
} VFStreamInfo;
// Stream lifecycle
VFStreamRef VFStreamCreate(size_t capacity_bytes);
void VFStreamDestroy(VFStreamRef s);
size_t VFStreamAvailableBytes(VFStreamRef s);
// Feeding data
void VFStreamPush(VFStreamRef s, const uint8_t *data, size_t len);
void VFStreamMarkEOF(VFStreamRef s);
// Decoder lifecycle
// Returns 0 on success, negative on error (same codes as ov_open_callbacks)
int VFOpen(VFStreamRef s, VFFileRef *out_vf);
void VFClear(VFFileRef vf);
// Query info; returns 0 on success
int VFGetInfo(VFFileRef vf, VFStreamInfo *out_info);
// Read interleaved float32 PCM frames into dst; returns number of frames read, 0 on EOF, <0 on error
long VFReadInterleavedFloat(VFFileRef vf, float *dst, int max_frames);
// Read deinterleaved float32 PCM frames (channel-by-channel); returns number of frames read, 0 on EOF, <0 on error
// out_pcm will point to an array of channel pointers (float**)
long VFReadFloat(VFFileRef vf, float ***out_pcm, int max_frames);
// Seek to a specific time in seconds; returns 0 on success, <0 on error
int VFSeekTime(VFFileRef vf, double time_seconds);
// Check if the stream is seekable; returns 1 if seekable, 0 if not
int VFIsSeekable(VFFileRef vf);
#ifdef __cplusplus
}
#endif
#endif // VORBIS_FILE_BRIDGE_H
+4
View File
@@ -0,0 +1,4 @@
module AudioCodecs {
umbrella header "AudioCodecs.h"
export *
}
BIN
View File
Binary file not shown.
@@ -1,442 +0,0 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 50;
objects = {
/* Begin PBXBuildFile section */
984808A028C0F549001160E6 /* hipjazz.wav in Resources */ = {isa = PBXBuildFile; fileRef = 9848089F28C0F549001160E6 /* hipjazz.wav */; };
98C82AE22B8CA16A00AED485 /* bensound-jazzyfrenchy.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 98C82AE12B8CA0F000AED485 /* bensound-jazzyfrenchy.m4a */; };
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 */
9848089F28C0F549001160E6 /* hipjazz.wav */ = {isa = PBXFileReference; lastKnownFileType = audio.wav; path = hipjazz.wav; sourceTree = "<group>"; };
98C82AE12B8CA0F000AED485 /* bensound-jazzyfrenchy.m4a */ = {isa = PBXFileReference; lastKnownFileType = file; path = "bensound-jazzyfrenchy.m4a"; sourceTree = "<group>"; };
B5220835256051830086FB3A /* AudioPlayerService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlayerService.swift; sourceTree = "<group>"; };
B5220947256074910086FB3A /* MulticastDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MulticastDelegate.swift; sourceTree = "<group>"; };
B522094F2561883E0086FB3A /* EqualizerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EqualizerViewController.swift; sourceTree = "<group>"; };
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 = (
98C82AE12B8CA0F000AED485 /* bensound-jazzyfrenchy.m4a */,
9848089F28C0F549001160E6 /* hipjazz.wav */,
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 */,
98C82AE22B8CA16A00AED485 /* bensound-jazzyfrenchy.m4a in Resources */,
984808A028C0F549001160E6 /* hipjazz.wav 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 = Manual;
DEVELOPMENT_TEAM = "";
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)";
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
B5AEDBE72475274D007D8101 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Manual;
DEVELOPMENT_TEAM = "";
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)";
PROVISIONING_PROFILE_SPECIFIER = "";
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,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 AVFoundation
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)
}
@available(*, unavailable)
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,66 +0,0 @@
//
// EqualizerViewModel.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 >= 1000 {
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.deactivate()
}
}
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 errorOccurred(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,167 +0,0 @@
//
// PlayerViewController.swift
// AudioExample
//
// Created by Dimitrios Chatzieleftheriou on 14/11/2020.
// Copyright © 2020 Dimitrios Chatzieleftheriou. All rights reserved.
//
import UIKit
class PlayerViewController: UIViewController {
private lazy var tableView = UITableView()
private let viewModel: PlayerViewModel
private var controlsProvider: () -> UIViewController
private var playerControlsController: UIViewController?
init(viewModel: PlayerViewModel, controlsProvider: @escaping () -> UIViewController) {
self.viewModel = viewModel
self.controlsProvider = controlsProvider
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder _: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
setupUI()
viewModel.reloadContent = { [weak self] action in
switch action {
case .all:
self?.tableView.reloadData()
case let .item(indexPath):
self?.tableView.reloadRows(at: [indexPath], with: .automatic)
}
}
}
private func setupUI() {
title = "Player"
view.backgroundColor = .systemBackground
navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .add,
target: self,
action: #selector(addNowPlaylistItem))
navigationItem.leftBarButtonItem = UIBarButtonItem(image: UIImage(systemName: "slider.horizontal.3"),
style: .plain,
target: self,
action: #selector(showEqualizer))
tableView.translatesAutoresizingMaskIntoConstraints = false
tableView.delegate = self
tableView.dataSource = self
tableView.register(PlaylistTableViewCell.self, forCellReuseIdentifier: "PlaylistCell")
let controlsController = controlsProvider()
playerControlsController = controlsController
let stackView = UIStackView()
stackView.axis = .vertical
stackView.alignment = .fill
stackView.distribution = .fillProportionally
stackView.translatesAutoresizingMaskIntoConstraints = false
stackView.addArrangedSubview(tableView)
addChild(controlsController)
controlsController.view.translatesAutoresizingMaskIntoConstraints = false
stackView.addArrangedSubview(controlsController.view)
controlsController.didMove(toParent: self)
view.addSubview(stackView)
NSLayoutConstraint.activate(
[
controlsController.view.widthAnchor.constraint(equalTo: view.widthAnchor),
stackView.topAnchor.constraint(equalTo: view.topAnchor),
stackView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
stackView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
stackView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
]
)
}
@objc private func showEqualizer() {
viewModel.showEqualizer()
}
@objc private func addNowPlaylistItem() {
let controller = UIAlertController(title: "Add new item", message: "", preferredStyle: .alert)
controller.addTextField { textField in
textField.placeholder = "Insert url here"
}
let saveAction = UIAlertAction(title: "Save", style: .default) { [viewModel] _ 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)
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
let queuedItem = item.queues ? "Queue item" : nil
cell.detailTextLabel?.text = queuedItem ?? item.subtitle
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
case .error:
cell.accessoryView = UIImageView(image: UIImage(systemName: "exclamationmark.octagon"))
cell.accessoryView?.tintColor = .red
}
guard status != .error else { return }
cell.accessoryView?.tintColor = .systemTeal
}
}
extension PlayerViewController: UITableViewDelegate {
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
viewModel.playItem(at: indexPath)
}
}
final class PlaylistTableViewCell: UITableViewCell {
override init(style _: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: .subtitle, reuseIdentifier: reuseIdentifier)
}
@available(*, unavailable)
required init?(coder _: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
@@ -1,112 +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, subtitle: nil, status: .stopped, queues: false))
reloadContent?(.all)
}
func item(at indexPath: IndexPath) -> PlaylistItem? {
playlistItemsService.item(at: indexPath.row)
}
func playItem(at indexPath: IndexPath) {
guard let item = item(at: indexPath) else { return }
if item.queues {
playerService.queue(url: item.url)
if currentPlayingItemIndex == nil {
currentPlayingItemIndex = indexPath.row
}
} else {
if let index = currentPlayingItemIndex {
playlistItemsService.setStatus(for: index, status: .stopped)
reloadContent?(.item(IndexPath(row: index, section: 0)))
currentPlayingItemIndex = nil
}
playerService.play(url: item.url)
currentPlayingItemIndex = indexPath.row
}
}
}
extension PlayerViewModel: AudioPlayerServiceDelegate {
func statusChanged(status: AudioPlayerState) {
guard let item = currentPlayingItemIndex else { return }
switch status {
case .bufferring:
playlistItemsService.setStatus(for: item, status: .buffering)
reloadContent?(.item(IndexPath(item: item, section: 0)))
case .playing:
playlistItemsService.setStatus(for: item, status: .playing)
reloadContent?(.item(IndexPath(item: item, section: 0)))
case .paused:
playlistItemsService.setStatus(for: item, status: .paused)
reloadContent?(.item(IndexPath(item: item, section: 0)))
case .stopped:
playlistItemsService.setStatus(for: item, status: .stopped)
reloadContent?(.item(IndexPath(item: item, section: 0)))
case .error:
playlistItemsService.setStatus(for: item, status: .error)
reloadContent?(.item(IndexPath(item: item, section: 0)))
default:
playlistItemsService.setStatus(for: item, status: .stopped)
reloadContent?(.all)
}
}
func errorOccurred(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>
Binary file not shown.
@@ -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,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,36 +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,96 +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
case error
}
let url: URL
let name: String
let subtitle: String?
let status: Status
let queues: Bool
init(content: AudioContent, queues: Bool) {
name = content.title
subtitle = content.subtitle
url = content.streamUrl
status = .stopped
self.queues = queues
}
init(url: URL, name: String, subtitle: String?, status: Status, queues: Bool) {
self.url = url
self.name = name
self.subtitle = subtitle
self.status = status
self.queues = queues
}
}
final class PlaylistItemsService {
private var items: [PlaylistItem] = []
var itemsCount: Int {
items.count
}
let protectedItemCount: Int
init(initialItemsProvider: () -> [PlaylistItem]) {
items = initialItemsProvider()
protectedItemCount = items.count
}
func item(at index: Int) -> PlaylistItem? {
guard index < items.count else { return nil }
return items[index]
}
func index(for item: PlaylistItem) -> Int? {
items.firstIndex(of: item)
}
func add(item: PlaylistItem) {
items.append(item)
}
func remove(item: PlaylistItem) {
if let index = items.firstIndex(of: item) {
items.remove(at: index)
}
}
func setStatus(for index: Int, status: PlaylistItem.Status) {
guard let item = item(at: index) else {
return
}
items[index] = PlaylistItem(
url: item.url,
name: item.name,
subtitle: item.subtitle,
status: status,
queues: item.queues
)
}
}
func provideInitialPlaylistItems() -> [PlaylistItem] {
let allCases = AudioContent.allCases
let casesForQueueing: [AudioContent] = [.piano, .local, .khruangbin]
let allItems = allCases.map { PlaylistItem(content: $0, queues: false) }
let casesForQueuingItems = casesForQueueing.map { PlaylistItem(content: $0, queues: true) }
return allItems + casesForQueuingItems
}
@@ -0,0 +1,522 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 60;
objects = {
/* Begin PBXBuildFile section */
42BE42F52C9322AA00C0E448 /* CustomStreamSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42BE42F42C9322AA00C0E448 /* CustomStreamSource.swift */; };
9806E8182BC5D12500757370 /* App.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9806E8172BC5D12500757370 /* App.swift */; };
9806E81A2BC5D12500757370 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9806E8192BC5D12500757370 /* ContentView.swift */; };
9806E81C2BC5D12700757370 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 9806E81B2BC5D12700757370 /* Assets.xcassets */; };
9806E81F2BC5D12700757370 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 9806E81E2BC5D12700757370 /* Preview Assets.xcassets */; };
9806E8262BC5D2A900757370 /* Sidebar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9806E8252BC5D2A900757370 /* Sidebar.swift */; };
9806E82A2BC68F8700757370 /* AudioPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9806E8292BC68F8700757370 /* AudioPlayerView.swift */; };
9806E8312BC6927D00757370 /* AudioPlayerModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9806E8302BC6927D00757370 /* AudioPlayerModel.swift */; };
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 */; };
981DA0762EAD61A90062223D /* AudioStreaming in Frameworks */ = {isa = PBXBuildFile; productRef = 981DA0752EAD61A90062223D /* AudioStreaming */; };
984DE9552BDAE59C004B427A /* Notifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 984DE9542BDAE59C004B427A /* Notifier.swift */; };
984DE9572BDAFC7E004B427A /* AudioPlayerControlsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 984DE9562BDAFC7E004B427A /* AudioPlayerControlsView.swift */; };
9881693E2EBCDB0100CE7EFF /* hipjazz.ogg in Resources */ = {isa = PBXBuildFile; fileRef = 9881693D2EBCDB0100CE7EFF /* hipjazz.ogg */; };
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 PBXFileReference section */
42BE42F42C9322AA00C0E448 /* CustomStreamSource.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CustomStreamSource.swift; sourceTree = "<group>"; };
9806E8142BC5D12500757370 /* AudioPlayer.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = AudioPlayer.app; sourceTree = BUILT_PRODUCTS_DIR; };
9806E8172BC5D12500757370 /* App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App.swift; sourceTree = "<group>"; };
9806E8192BC5D12500757370 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
9806E81B2BC5D12700757370 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
9806E81E2BC5D12700757370 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = "<group>"; };
9806E8252BC5D2A900757370 /* Sidebar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar.swift; sourceTree = "<group>"; };
9806E8292BC68F8700757370 /* AudioPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlayerView.swift; sourceTree = "<group>"; };
9806E8302BC6927D00757370 /* AudioPlayerModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlayerModel.swift; sourceTree = "<group>"; };
9816A8A42BC7D8A200AD1299 /* AudioStreaming.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = AudioStreaming.framework; sourceTree = BUILT_PRODUCTS_DIR; };
9816A8A92BC7F4F000AD1299 /* AudioTrack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioTrack.swift; sourceTree = "<group>"; };
9816A8AB2BC820DF00AD1299 /* AudioContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioContent.swift; sourceTree = "<group>"; };
9816A8AD2BC832DB00AD1299 /* bensound-jazzyfrenchy.mp3 */ = {isa = PBXFileReference; lastKnownFileType = audio.mp3; path = "bensound-jazzyfrenchy.mp3"; sourceTree = "<group>"; };
9816A8AE2BC832DB00AD1299 /* bensound-jazzyfrenchy.m4a */ = {isa = PBXFileReference; lastKnownFileType = file; path = "bensound-jazzyfrenchy.m4a"; sourceTree = "<group>"; };
9816A8AF2BC832DC00AD1299 /* hipjazz.wav */ = {isa = PBXFileReference; lastKnownFileType = audio.wav; path = hipjazz.wav; sourceTree = "<group>"; };
9816A8BA2BC87BC200AD1299 /* AudioPlayerService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlayerService.swift; sourceTree = "<group>"; };
984DE9542BDAE59C004B427A /* Notifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Notifier.swift; sourceTree = "<group>"; };
984DE9562BDAFC7E004B427A /* AudioPlayerControlsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlayerControlsView.swift; sourceTree = "<group>"; };
9881693D2EBCDB0100CE7EFF /* hipjazz.ogg */ = {isa = PBXFileReference; lastKnownFileType = file; path = hipjazz.ogg; 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 = (
981DA0762EAD61A90062223D /* AudioStreaming 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 = (
9881693D2EBCDB0100CE7EFF /* hipjazz.ogg */,
9816A8AE2BC832DB00AD1299 /* bensound-jazzyfrenchy.m4a */,
9816A8AF2BC832DC00AD1299 /* hipjazz.wav */,
9816A8AD2BC832DB00AD1299 /* bensound-jazzyfrenchy.mp3 */,
);
path = Resources;
sourceTree = "<group>";
};
984DE9522BDAE571004B427A /* Helpers */ = {
isa = PBXGroup;
children = (
98BFB4192BC97AF800E812C0 /* DisplayLink.swift */,
984DE9542BDAE59C004B427A /* Notifier.swift */,
989E08E62BF7A4E300599F17 /* PrefersTabNavigationEnvironmentKey.swift */,
);
path = Helpers;
sourceTree = "<group>";
};
984DE9532BDAE57F004B427A /* Dependencies */ = {
isa = PBXGroup;
children = (
98BFB41E2BCD814000E812C0 /* EqualizerService.swift */,
9816A8BA2BC87BC200AD1299 /* AudioPlayerService.swift */,
);
path = Dependencies;
sourceTree = "<group>";
};
98E3921C2BD845E100B586E9 /* AudioPlayer */ = {
isa = PBXGroup;
children = (
42BE42F42C9322AA00C0E448 /* CustomStreamSource.swift */,
9806E8302BC6927D00757370 /* AudioPlayerModel.swift */,
9806E8292BC68F8700757370 /* AudioPlayerView.swift */,
98BFB41C2BCD7BB800E812C0 /* EqualizerView.swift */,
984DE9562BDAFC7E004B427A /* AudioPlayerControlsView.swift */,
);
path = AudioPlayer;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
9806E8132BC5D12500757370 /* AudioPlayer */ = {
isa = PBXNativeTarget;
buildConfigurationList = 9806E8222BC5D12700757370 /* Build configuration list for PBXNativeTarget "AudioPlayer" */;
buildPhases = (
9806E8102BC5D12500757370 /* Sources */,
9806E8112BC5D12500757370 /* Frameworks */,
9806E8122BC5D12500757370 /* Resources */,
);
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;
packageReferences = (
981DA0742EAD61A90062223D /* XCLocalSwiftPackageReference "../../AudioStreaming" */,
);
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 */,
9881693E2EBCDB0100CE7EFF /* hipjazz.ogg in Resources */,
9816A8B32BC8330C00AD1299 /* hipjazz.wav in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
9806E8102BC5D12500757370 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
98BFB4232BCE78AB00E812C0 /* AddNewAudioURLView.swift in Sources */,
98BFB41D2BCD7BB800E812C0 /* EqualizerView.swift in Sources */,
98BFB41A2BC97AF800E812C0 /* DisplayLink.swift in Sources */,
9806E81A2BC5D12500757370 /* ContentView.swift in Sources */,
98E6119C2BC72C0E0036BC47 /* DetailView.swift in Sources */,
9816A8AC2BC820DF00AD1299 /* AudioContent.swift in Sources */,
9806E8262BC5D2A900757370 /* Sidebar.swift in Sources */,
984DE9552BDAE59C004B427A /* Notifier.swift in Sources */,
9806E82A2BC68F8700757370 /* AudioPlayerView.swift in Sources */,
9806E8312BC6927D00757370 /* AudioPlayerModel.swift in Sources */,
98BFB41F2BCD814000E812C0 /* EqualizerService.swift in Sources */,
9816A8AA2BC7F4F000AD1299 /* AudioTrack.swift in Sources */,
9816A8BB2BC87BC200AD1299 /* AudioPlayerService.swift in Sources */,
984DE9572BDAFC7E004B427A /* AudioPlayerControlsView.swift in Sources */,
9806E8182BC5D12500757370 /* App.swift in Sources */,
42BE42F52C9322AA00C0E448 /* CustomStreamSource.swift in Sources */,
989E08E72BF7A4E300599F17 /* PrefersTabNavigationEnvironmentKey.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin XCBuildConfiguration section */
9806E8202BC5D12700757370 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.4;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
name = Debug;
};
9806E8212BC5D12700757370 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.4;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SDKROOT = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
VALIDATE_PRODUCT = YES;
};
name = Release;
};
9806E8232BC5D12700757370 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_ASSET_PATHS = "\"AudioPlayer/Preview Content\"";
DEVELOPMENT_TEAM = TJ7GUC6B8Y;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = AudioPlayer/Info.plist;
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 13.5;
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",
);
MACOSX_DEPLOYMENT_TARGET = 13.5;
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 */
/* Begin XCLocalSwiftPackageReference section */
981DA0742EAD61A90062223D /* XCLocalSwiftPackageReference "../../AudioStreaming" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = ../../AudioStreaming;
};
/* End XCLocalSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
981DA0752EAD61A90062223D /* AudioStreaming */ = {
isa = XCSwiftPackageProductDependency;
productName = AudioStreaming;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = 9806E80C2BC5D12500757370 /* Project object */;
}
@@ -0,0 +1,24 @@
{
"originHash" : "2be026a121d718059bac101ee8cabdd866a56e3b58b2908f27213c8a08755a25",
"pins" : [
{
"identity" : "ogg-binary-xcframework",
"kind" : "remoteSourceControl",
"location" : "https://github.com/sbooth/ogg-binary-xcframework",
"state" : {
"revision" : "c0e822e18738ad913864e98d9614927ac1e9337c",
"version" : "0.1.2"
}
},
{
"identity" : "vorbis-binary-xcframework",
"kind" : "remoteSourceControl",
"location" : "https://github.com/sbooth/vorbis-binary-xcframework",
"state" : {
"revision" : "842020eabcebe410e698c68545d6597b2d232e51",
"version" : "0.1.2"
}
}
],
"version" : 3
}
@@ -1,10 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1200"
LastUpgradeVersion = "1530"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
@@ -14,10 +15,10 @@
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "B5AEDBD02475274C007D8101"
BuildableName = "AudioExample.app"
BlueprintName = "AudioExample"
ReferencedContainer = "container:AudioExample.xcodeproj">
BlueprintIdentifier = "9806E8132BC5D12500757370"
BuildableName = "AudioPlayer.app"
BlueprintName = "AudioPlayer"
ReferencedContainer = "container:AudioPlayer.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
@@ -27,64 +28,29 @@
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>
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
enableASanStackUseAfterReturn = "YES"
disableMainThreadChecker = "YES"
enableThreadSanitizer = "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">
BlueprintIdentifier = "9806E8132BC5D12500757370"
BuildableName = "AudioPlayer.app"
BlueprintName = "AudioPlayer"
ReferencedContainer = "container:AudioPlayer.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<EnvironmentVariables>
<EnvironmentVariable
key = "OS_ACTIVITY_MODE"
value = "disable"
isEnabled = "NO">
</EnvironmentVariable>
</EnvironmentVariables>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
@@ -96,10 +62,10 @@
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "B5AEDBD02475274C007D8101"
BuildableName = "AudioExample.app"
BlueprintName = "AudioExample"
ReferencedContainer = "container:AudioExample.xcodeproj">
BlueprintIdentifier = "9806E8132BC5D12500757370"
BuildableName = "AudioPlayer.app"
BlueprintName = "AudioPlayer"
ReferencedContainer = "container:AudioPlayer.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
+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 })
}
@@ -1,18 +1,16 @@
//
// AudioContent.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 Foundation
enum AudioContent: Int, CaseIterable {
enum AudioContent {
case offradio
case enlefko
case pepper966
case kosmos
case kosmosJazz
case radiox
case khruangbin
case piano
@@ -21,62 +19,86 @@ enum AudioContent: Int, CaseIterable {
case remoteWave
case local
case localWave
case loopBeatFlac
case oggVorbis
case oggVorbisLocal
case custom(String)
var title: String {
switch self {
case .offradio:
return "Offradio (stream)"
return "Offradio"
case .enlefko:
return "Enlefko (stream)"
return "Enlefko"
case .pepper966:
return "Pepper 96.6 (stream)"
return "Pepper 96.6"
case .kosmos:
return "Kosmos 93.6 (stream)"
return "Kosmos 93.6"
case .kosmosJazz:
return "Kosmos Jazz"
case .radiox:
return "Radio X (stream)"
return "Radio X"
case .khruangbin:
return "Khruangbin (mp3 preview)"
return "Khruangbin"
case .piano:
return "Piano (mp3)"
return "Piano"
case .remoteWave:
return "Sample remote (wave)"
return "Sample remote"
case .local:
return "Jazzy Frenchy (local mp3)"
return "Jazzy Frenchy"
case .localWave:
return "Local file (local wave)"
return "Hip Jazz"
case .optimized:
return "Jazze French (m4a - optimized)"
return "Jazzy Frenchy"
case .nonOptimized:
return "Jazze French (m4a - non-optimized)"
return "Jazzy Frenchy"
case .loopBeatFlac:
return "Beat loop"
case .oggVorbis:
return "Jazzy Fetchy"
case .oggVorbisLocal:
return "Hip Jazz"
case .custom(let url):
return url
}
}
var subtitle: String? {
switch self {
case .offradio:
return nil
return "Stream • offradio.gr"
case .enlefko:
return nil
return "Stream • enlefko.fm"
case .pepper966:
return nil
return "Stream • pepper966.gr"
case .kosmos:
return nil
return "Stream • ertecho.gr"
case .kosmosJazz:
return "Stream • ertecho.gr"
case .radiox:
return nil
return "Stream • globalplayer.com"
case .khruangbin:
return nil
return "Remote mp3"
case .piano:
return nil
return "Remote mp3"
case .remoteWave:
return nil
return "Local wav"
case .local:
return "Music by: bensound.com"
case .localWave:
return "Music by: bensound.com"
case .optimized:
return "Music by: bensound.com"
return "Music by: bensound.com - m4a optimized"
case .nonOptimized:
return "Music by: bensound.com"
return "Music by: bensound.com - m4a non-optimized"
case .loopBeatFlac:
return "Remote flac"
case .oggVorbis:
return "Remote Ogg Vorbis"
case .oggVorbisLocal:
return "Local Ogg Vorbis"
case .custom:
return ""
}
}
@@ -90,6 +112,8 @@ enum AudioContent: Int, CaseIterable {
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:
@@ -101,13 +125,22 @@ enum AudioContent: Int, CaseIterable {
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: "m4a")!
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://file-examples.com/wp-content/storage/2017/11/file_example_WAV_5MG.wav")!
return URL(string: "https://github.com/dimitris-c/sample-audio/raw/main/5-MB-WAV.wav")!
case .loopBeatFlac:
return URL(string: "https://github.com/dimitris-c/sample-audio/raw/main/drumbeat-loop.flac")!
case .oggVorbis:
return URL(string: "https://github.com/dimitris-c/sample-audio/raw/refs/heads/main/bensound-jazzyfrenchy.ogg")!
case .oggVorbisLocal:
let path = Bundle.main.path(forResource: "hipjazz", ofType: "ogg")!
return URL(fileURLWithPath: path)
case .custom(let url):
return URL(string: url)!
}
}
}
@@ -0,0 +1,120 @@
//
// Created by Dimitris C.
// Copyright © 2024 Decimal. All rights reserved.
//
import SwiftUI
enum AudioTrackStatus {
case playing
case paused
case buffering
case error
case idle
var isPlaying: Bool {
self == .playing || self == .paused
}
var isError: Bool {
self == .error
}
}
@Observable
public class AudioTrack: Identifiable, Equatable {
public static func == (lhs: AudioTrack, rhs: AudioTrack) -> Bool {
lhs.id == rhs.id
}
public var id: String {
url.absoluteString
}
let title: String
let subtitle: String?
let url: URL
var status: AudioTrackStatus
private let content: AudioContent
init(from content: AudioContent, status: AudioTrackStatus = .idle) {
self.title = content.title
self.subtitle = content.subtitle
self.status = status
self.url = content.streamUrl
self.content = content
}
}
struct AudioTrackView: View {
@Bindable var track: AudioTrack
private let action: () -> Void
init(track: AudioTrack, action: @escaping () -> Void = {}) {
self.track = track
self.action = action
}
var body: some View {
Button(action: action, label: {
HStack {
VStack(alignment: .leading) {
Text(track.title)
.font(.headline)
.fontWeight(.medium)
.foregroundStyle(.black)
.padding(.top, 8)
.padding(.bottom, track.subtitle == nil ? 8 : 0)
if let subtitle = track.subtitle {
Text(subtitle)
.font(.subheadline)
.fontWeight(.regular)
.foregroundStyle(Color.gray)
}
}
Spacer()
status
}
})
}
@ViewBuilder var status: some View {
if track.status == .error {
Image(systemName: "exclamationmark.circle")
.font(.headline)
.foregroundStyle(.red)
} else {
if track.status.isPlaying {
Image(systemName: track.status == .playing ? "play.fill" : "pause.fill")
.font(.headline)
.foregroundStyle(.mint)
} else if track.status == .buffering {
ProgressView()
.progressViewStyle(.circular)
.frame(alignment: .center)
.scaleEffect(0.7)
}
}
}
}
#Preview {
List {
AudioTrackView(
track: AudioTrack(from: .enlefko)
)
AudioTrackView(
track: AudioTrack(from: .enlefko, status: .playing)
)
AudioTrackView(
track: AudioTrack(from: .enlefko, status: .paused)
)
AudioTrackView(
track: AudioTrack(from: .enlefko, status: .error)
)
}
}
@@ -0,0 +1,331 @@
//
// Created by Dimitris Chatzieleftheriou on 26/04/2024.
//
import AVFoundation
import SwiftUI
import AudioStreaming
struct AudioPlayerControls: View {
@State var model: Model
@Binding var currentTrack: AudioTrack?
init(appModel: AppModel, currentTrack: Binding<AudioTrack?>) {
self._model = State(wrappedValue: Model(audioPlayerService: appModel.audioPlayerService))
self._currentTrack = currentTrack
}
var body: some View {
VStack(alignment: .leading) {
HStack {
Button(action: { model.playPause() }) {
Image(systemName: model.isPlaying ? "pause" : "play")
.symbolVariant(.fill)
.font(.title)
.imageScale(.small)
}
.buttonStyle(.plain)
.contentTransition(.symbolEffect(.replace))
Button(action: {
model.stop()
currentTrack = nil
}) {
Image(systemName: "stop")
.symbolVariant(.fill)
.font(.title)
.imageScale(.small)
}
.buttonStyle(.plain)
.padding(.leading, 8)
Spacer()
HStack {
Slider(value: $model.volume)
.frame(width: 80)
.onChange(of: model.volume) { _, newValue in
model.update(volume: newValue)
}
Button(action: { model.mute() }) {
Image(systemName: model.iconForVolume)
.symbolVariant(model.isMuted || model.volume == 0 ? .slash.fill : .fill)
.foregroundStyle(.teal, .gray)
.font(.title.monospaced())
.imageScale(.small)
}
.buttonStyle(.plain)
.frame(width: 20, height: 20)
}
}
.tint(.mint)
.padding(16)
if let audioMetadata = model.liveAudioMetadata, model.isLiveAudioStreaming {
Text("Now Playing: \(audioMetadata)")
.font(.caption)
.foregroundStyle(.black)
.padding(.horizontal, 16)
}
Divider()
VStack {
Slider(
value: $model.currentTime,
in: 0...(model.totalTime ?? 1.0),
onEditingChanged: { scrubStarted in
if scrubStarted {
model.scrubState = .started
} else {
model.scrubState = .ended(model.currentTime)
}
}
)
.disabled(model.totalTime == nil)
HStack {
Text(model.formattedCurrentTime ?? "--:--")
Spacer()
Text(model.formattedTotalTime ?? "")
}
.foregroundStyle(.black)
.font(.caption)
.fontWeight(.medium)
}
.padding(.bottom, 8)
.padding(.horizontal, 16)
Divider()
VStack(alignment: .leading) {
Text("Playback Rate: \(String(format: "%0.1f", model.playbackRate))")
.font(.subheadline)
.fontWeight(.medium)
.foregroundStyle(.black)
Slider(value: $model.playbackRate, in: 1.0...4.0, step: 0.2)
.onChange(of: model.playbackRate) { _, new in
model.update(rate: Float(new))
}
}
.padding(.bottom, 8)
.padding(.horizontal, 16)
}
.onChange(of: currentTrack) { oldValue, newValue in
if let track = newValue {
model.play(track)
}
}
}
}
enum ScrubState: Equatable {
case idle
case started
case ended(Double)
}
extension AudioPlayerControls {
@Observable
final class Model {
@ObservationIgnored
private(set) var audioPlayerService: AudioPlayerService
@ObservationIgnored
private var displayLink: DisplayLink?
var isLiveAudioStreaming: Bool {
totalTime == 0
}
var liveAudioMetadata: String?
var isPlaying: Bool = false
var isMuted: Bool = false
var volume: Float = 0.5
var playbackRate: Double = 0.0
var currentTime: Double = 0
var totalTime: Double?
var scrubState: ScrubState = .idle
var formattedCurrentTime: String?
var formattedTotalTime: String?
var currentTrack: AudioTrack?
var iconForVolume: String {
if isMuted || volume == 0 {
return "speaker"
}
if volume < 0.4 {
return "speaker.wave.1"
} else if volume < 0.8 {
return "speaker.wave.2"
} else {
return "speaker.wave.3"
}
}
init(audioPlayerService: AudioPlayerService) {
self.audioPlayerService = audioPlayerService
registerObservations()
}
deinit {
displayLink?.deactivate()
displayLink = nil
}
func registerObservations() {
Task { @MainActor in
for await status in await audioPlayerService.statusChangedNotifier.values() {
isPlaying = status == .playing
displayLink?.isPaused = !isPlaying
switch status {
case .bufferring:
currentTrack?.status = .buffering
case .error:
currentTrack?.status = .error
currentTrack = nil
case .playing:
currentTrack?.status = .playing
case .paused:
currentTrack?.status = .paused
case .stopped:
currentTrack?.status = .idle
default:
currentTrack?.status = .idle
}
}
}
Task { @MainActor in
for await metadata in await audioPlayerService.metadataReceivedNotifier.values() {
guard !metadata.isEmpty else { break }
if let title = metadata["StreamTitle"] {
liveAudioMetadata = title.isEmpty ? "-" : title
} else {
liveAudioMetadata = nil
}
}
}
Task { @MainActor in
for await startStopped in await audioPlayerService.playingStartedStopped.values() {
if startStopped.started {
self.didStartPlaying()
} else {
self.didStopPlaying()
}
}
}
}
func mute() {
isMuted.toggle()
audioPlayerService.toggleMute()
}
func playPause() {
if audioPlayerService.state == .playing {
audioPlayerService.pause()
} else {
audioPlayerService.resume()
}
}
func update(rate: Float) {
let rate = round(rate / 0.2) * 0.2
audioPlayerService.update(rate: rate)
}
func update(volume: Float) {
audioPlayerService.update(volume: volume)
}
func stop() {
isPlaying = false
audioPlayerService.stop()
currentTrack?.status = .idle
currentTrack = nil
}
func play(_ track: AudioTrack) {
if track != currentTrack {
currentTrack?.status = .idle
if track.url.scheme == "custom" {
let source = createStreamSource()
let audioFormat = AVAudioFormat(
commonFormat: .pcmFormatFloat32, sampleRate: 44100, channels: 2, interleaved: false
)!
audioPlayerService.play(source: source, entryId: track.url.absoluteString, format: audioFormat)
currentTrack = track
} else {
audioPlayerService.play(url: track.url)
}
}
}
func createStreamSource() -> CoreAudioStreamSource {
return CustomStreamAudioSource(underlyingQueue: audioPlayerService.player.sourceQueue)
}
func onTick() {
let duration = audioPlayerService.duration
let progress = audioPlayerService.progress
if duration > 0 {
let elapsed = Int(progress)
let remaining = Int(duration - progress)
totalTime = duration
switch scrubState {
case .idle:
currentTime = progress
case .started:
break
case .ended(let seekTime):
currentTime = seekTime
if audioPlayerService.duration > 0 {
audioPlayerService.seek(at: seekTime)
}
scrubState = .idle
}
formattedCurrentTime = timeFrom(seconds: Int(elapsed))
formattedTotalTime = timeFrom(seconds: remaining)
} else {
let elapsed = Int(progress)
formattedCurrentTime = timeFrom(seconds: Int(elapsed))
if formattedTotalTime != nil {
formattedTotalTime = nil
}
}
}
func resetLabels() {
currentTime = 0
totalTime = 0
formattedCurrentTime = nil
formattedTotalTime = nil
}
private func timeFrom(seconds: Int) -> String {
let correctSeconds = seconds % 60
let minutes = (seconds / 60) % 60
let hours = seconds / 3600
if hours > 0 {
return String(format: "%02d:%02d:%02d", hours, minutes, correctSeconds)
}
return String(format: "%02d:%02d", minutes, correctSeconds)
}
private func didStartPlaying() {
self.displayLink = DisplayLink(onTick: { [weak self] _ in
self?.onTick()
})
displayLink?.activate()
}
private func didStopPlaying() {
resetLabels()
liveAudioMetadata = nil
playbackRate = 1.0
displayLink?.deactivate()
}
}
}
@@ -0,0 +1,76 @@
//
// Created by Dimitris C.
// Copyright © 2024 Decimal. All rights reserved.
//
#if os(iOS)
import UIKit
#else
import AppKit
#endif
import Foundation
import AudioStreaming
struct AudioPlaylist: Equatable, Identifiable {
var id: String { title }
let title: String
var tracks: [AudioTrack]
}
@Observable
public class AudioPlayerModel {
@ObservationIgnored
private(set) var audioPlayerService: AudioPlayerService
var audioTracks: [AudioPlaylist] = []
var currentTrack: AudioTrack?
init(audioTracksProvider: () -> [AudioPlaylist] = audioTracksProvider, audioPlayerService: AudioPlayerService) {
self.audioPlayerService = audioPlayerService
self.audioTracks = audioTracksProvider()
}
deinit {
audioPlayerService.stop()
}
func addNewAudioTrack(url: URL) {
let customIndex = audioTracks.firstIndex(where: { $0.id == "Custom" })
let audioTrack = AudioTrack(from: .custom(url.absoluteString), status: .idle)
let playlist = AudioPlaylist(title: "Custom", tracks: [audioTrack])
if let customIndex {
let tracks = audioTracks[customIndex].tracks
if !tracks.contains(audioTrack) {
audioTracks[customIndex].tracks.append(audioTrack)
}
} else {
audioTracks.append(playlist)
}
}
func play(_ track: AudioTrack) {
if track != currentTrack {
currentTrack = track
}
}
}
private let radioTracks: [AudioContent] = [.offradio, .enlefko, .pepper966, .kosmos, .kosmosJazz, .radiox]
private let audioTracks: [AudioContent] = [.khruangbin, .piano, .optimized, .nonOptimized, .remoteWave, .local, .localWave, .loopBeatFlac, .oggVorbis, .oggVorbisLocal]
private let customStreams: [AudioContent] = [.custom("custom://sinwave")]
func audioTracksProvider() -> [AudioPlaylist] {
[
AudioPlaylist(title: "Radio", tracks: radioTracks.map { AudioTrack.init(from: $0) }),
AudioPlaylist(title: "Tracks", tracks: audioTracks.map { AudioTrack.init(from:$0) }),
AudioPlaylist(title: "Generated", tracks: customStreams.map { AudioTrack.init(from:$0) })
]
}
func audioQueueTrackProvider() -> [AudioPlaylist] {
[
AudioPlaylist(title: "Tracks", tracks: audioTracks.map { AudioTrack.init(from:$0) })
]
}
@@ -0,0 +1,103 @@
//
// Created by Dimitris C.
// Copyright © 2024 Decimal. All rights reserved.
//
import SwiftUI
struct AudioPlayerView: View {
@Environment(AppModel.self) var appModel
@State var model: AudioPlayerModel
@State var eqSheetIsShown: Bool = false
@State var addNewAudioIsShown: Bool = false
init(appModel: AppModel) {
self._model = State(wrappedValue: AudioPlayerModel(audioPlayerService: appModel.audioPlayerService))
}
var body: some View {
ScrollViewReader { proxy in
List {
ForEach(model.audioTracks) { section in
Section {
ForEach(section.tracks) { track in
AudioTrackView(track: track) {
model.play(track)
}
.id(track.id)
}
} header: {
Text(section.title)
}
}
}
.onChange(of: model.audioTracks) { _, newValue in
if let lastId = newValue.last?.tracks.last?.id {
withAnimation {
proxy.scrollTo(lastId, anchor: .bottom)
}
}
}
}
.safeAreaInset(edge: .bottom) {
AudioPlayerControls(appModel: appModel, currentTrack: $model.currentTrack)
.background(
.ultraThinMaterial.shadow(
ShadowStyle.drop(color: .black.opacity(0.1), radius: 8, x: 0, y: -10)
)
)
}
.ignoresSafeArea(.keyboard, edges: .bottom)
.navigationTitle("Audio Player")
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.toolbar {
#if os(iOS)
let placement: ToolbarItemPlacement = .topBarTrailing
#else
let placement: ToolbarItemPlacement = .automatic
#endif
ToolbarItemGroup(placement: placement) {
Button {
eqSheetIsShown.toggle()
} label: {
Image(systemName: "slider.horizontal.3")
}
.buttonStyle(.plain)
Button {
addNewAudioIsShown.toggle()
} label: {
Image(systemName: "plus")
}
.buttonStyle(.plain)
}
}
.sheet(isPresented: $eqSheetIsShown) {
EqualizerView(appModel: appModel)
#if os(iOS)
.presentationDetents([.medium])
#elseif os(macOS)
.frame(minWidth: 520, maxWidth: .infinity, minHeight: 400, maxHeight: .infinity)
#endif
}
.sheet(isPresented: $addNewAudioIsShown) {
AddNewAudioURLView(
onAddNewUrl: { url in
model.addNewAudioTrack(url: url)
}
)
#if os(iOS)
.presentationDetents([.height(150)])
#elseif os(macOS)
.frame(minWidth: 320, maxWidth: .infinity, minHeight: 140, maxHeight: .infinity)
#endif
}
}
}
#Preview {
AudioPlayerView(appModel: AppModel())
}
@@ -0,0 +1,139 @@
//
// CustomStreamSource.swift
// AudioPlayer
//
// Created by Jackson Harper on 12/9/24.
//
import AVFoundation
import Foundation
import AudioStreaming
// This is a basic example of playing a custom audio stream. We generate
// a small audio data on load and then pass it off to AudioStreaming.
final class CustomStreamAudioSource: NSObject, CoreAudioStreamSource {
weak var delegate: AudioStreamSourceDelegate?
var underlyingQueue: DispatchQueue
var position = 0
var length = 0
var audioFileHint: AudioFileTypeID {
kAudioFileWAVEType
}
init(underlyingQueue: DispatchQueue) {
self.underlyingQueue = underlyingQueue
}
// no-op
func close() {}
// no-op
func suspend() {}
func resume() {}
func seek(at _: Int) {
// The streaming process is started by a seek(0) call from AudioStreaming
generateData()
}
private func generateData() {
let frequency = 440.0
let sampleRate = 44100
let duration = 20.0
let lpcmData = generateSineWave(frequency: frequency, sampleRate: sampleRate, duration: duration)
let waveFile = createWavFile(using: lpcmData)
// We enqueue this because during startup the seek call will be made, but the player
// is not completely setup and ready to handle data yet, as its expected to be
// generated asyncronously.
underlyingQueue.asyncAfter(deadline: .now().advanced(by: .milliseconds(100))) {
self.delegate?.dataAvailable(source: self, data: waveFile)
}
}
}
// Functions for generating some sample data
// Function to generate a sine wave as Data
func generateSineWave(frequency: Double, sampleRate: Int, duration: Double, amplitude: Double = 0.5) -> Data {
let numberOfSamples = Int(Double(sampleRate) * duration)
let twoPi = 2.0 * Double.pi
var lpcmData = Data()
for sampleIndex in 0 ..< numberOfSamples {
let time = Double(sampleIndex) / Double(sampleRate)
let sampleValue = amplitude * sin(twoPi * frequency * time)
let pcmValue = Int16(sampleValue * Double(Int16.max))
withUnsafeBytes(of: pcmValue.littleEndian) { lpcmData.append(contentsOf: $0) }
}
return lpcmData
}
func createWavFile(using rawData: Data) -> Data {
let waveHeaderFormate = createWaveHeader(data: rawData) as Data
let waveFileData = waveHeaderFormate + rawData
return waveFileData
}
// from: https://stackoverflow.com/questions/49399823/in-ios-how-to-create-audio-file-wav-mp3-file-from-data
private func createWaveHeader(data: Data) -> NSData {
let sampleRate: Int32 = 44100
let chunkSize: Int32 = 36 + Int32(data.count)
let subChunkSize: Int32 = 16
let format: Int16 = 1
let channels: Int16 = 2
let bitsPerSample: Int16 = 16
let byteRate: Int32 = sampleRate * Int32(channels * bitsPerSample / 8)
let blockAlign: Int16 = channels * bitsPerSample / 8
let dataSize = Int32(data.count)
let header = NSMutableData()
header.append([UInt8]("RIFF".utf8), length: 4)
header.append(intToByteArray(chunkSize), length: 4)
// WAVE
header.append([UInt8]("WAVE".utf8), length: 4)
// FMT
header.append([UInt8]("fmt ".utf8), length: 4)
header.append(intToByteArray(subChunkSize), length: 4)
header.append(shortToByteArray(format), length: 2)
header.append(shortToByteArray(channels), length: 2)
header.append(intToByteArray(sampleRate), length: 4)
header.append(intToByteArray(byteRate), length: 4)
header.append(shortToByteArray(blockAlign), length: 2)
header.append(shortToByteArray(bitsPerSample), length: 2)
header.append([UInt8]("data".utf8), length: 4)
header.append(intToByteArray(dataSize), length: 4)
return header
}
private func intToByteArray(_ i: Int32) -> [UInt8] {
return [
// little endian
UInt8(truncatingIfNeeded: i & 0xFF),
UInt8(truncatingIfNeeded: (i >> 8) & 0xFF),
UInt8(truncatingIfNeeded: (i >> 16) & 0xFF),
UInt8(truncatingIfNeeded: (i >> 24) & 0xFF),
]
}
private func shortToByteArray(_ i: Int16) -> [UInt8] {
return [
// little endian
UInt8(truncatingIfNeeded: i & 0xFF),
UInt8(truncatingIfNeeded: (i >> 8) & 0xFF),
]
}
@@ -0,0 +1,335 @@
//
// Created by Dimitris C.
// Copyright © 2024 Decimal. All rights reserved.
//
import SwiftUI
@Observable
class EQBand: Identifiable {
var frequency: String
var min: Float
var max: Float
var value: Float
@ObservationIgnored
let index: Int
init(index: Int, frequency: String, min: Float, max: Float, value: Float) {
self.index = index
self.frequency = frequency
self.min = min
self.max = max
self.value = value
}
}
struct EqualizerView: View {
@Environment(\.dismiss) var dismiss
@Environment(AppModel.self) var appModel
@State var model: Model
init(appModel: AppModel) {
self._model = State(wrappedValue: Model(equalizerService: appModel.equalizerService))
}
var body: some View {
NavigationStack {
VStack(spacing: 16) {
EQSliderView()
.frame(height: 180)
.padding(.horizontal, 16)
.environment(model)
HStack(alignment: .center, spacing: 16) {
Button {
withAnimation {
model.isEnabled.toggle()
model.enable()
}
} label: {
HStack {
Image(systemName: model.isEnabled ? "waveform.slash" : "waveform")
.contentTransition(.symbolEffect(.replace))
Text(model.isEnabled ? "Disable": "Enable")
.font(.body)
}
.foregroundStyle(Color.white)
}
.padding(.horizontal, 16)
.padding(.vertical, 16)
.background(model.isEnabled ? .red : .mint)
.clipShape(RoundedRectangle(cornerRadius: 16))
Button {
model.reset()
} label: {
HStack {
Text("Reset")
.font(.body)
}
.foregroundStyle(Color.white)
}
.padding(.horizontal, 16)
.padding(.vertical, 16)
.background(.mint)
.clipShape(RoundedRectangle(cornerRadius: 16))
}
.padding(.top, 24)
}
.task {
Task {
model.generateBands()
}
}
.navigationTitle("Equalizer")
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.toolbar {
#if os(iOS)
ToolbarItem(placement: .topBarTrailing) {
Button {
dismiss()
} label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(Color.gray)
}
}
#else
ToolbarItem(placement: .confirmationAction) {
Button("Done", action: dismiss.callAsFunction)
}
#endif
}
}
}
}
struct EQSliderView: View {
@Environment(EqualizerView.Model.self) var eqModel
@State private var dragPointYLocations: [CGFloat] = Array(repeating: .zero, count: 6)
@State private var resetPoints: [Double] = Array(repeating: .zero, count: 6)
@State private var eqViewFrame: CGRect = .zero
var body: some View {
VStack(spacing: 0) {
HStack(spacing: 0) {
// Draw labels for gain values
VStack {
Text("\(Int(eqModel.maxGain))db")
.font(.caption2)
.foregroundColor(.black)
Spacer()
Text("0dB")
.font(.caption2)
.foregroundColor(.black)
Spacer()
Text("\(Int(eqModel.minGain))db")
.font(.caption2)
.foregroundColor(.black)
}
GeometryReader { innerGeo in
ZStack {
LineShape(values: eqModel.shouldReset ? resetPoints : dragPointYLocations.map { Double($0) })
.stroke(Color.mint, lineWidth: 2)
.animation(.easeInOut(duration: 0.2), value: eqModel.shouldReset)
.onAppear {
resetPoints = resetPoints.map { _ in Double(gainToYPosition(at: 0, in: innerGeo.size)) }
}
Path { path in
for index in 0..<dragPointYLocations.count {
let x = positionForDragPoint(at: index, size: innerGeo.size)
path.move(to: CGPoint(x: x, y: 0))
path.addLine(to: CGPoint(x: x, y: innerGeo.size.height))
}
path.move(to: CGPoint(x: 0, y: innerGeo.size.height / 2))
path.addLine(to: CGPoint(x: innerGeo.size.width, y: innerGeo.size.height / 2))
}
.stroke(Color.gray.opacity(0.5), lineWidth: 1)
ForEach(eqModel.bands) { band in
Circle()
.fill(Color.mint)
.frame(width: 20, height: 20)
.position(x: positionForDragPoint(at: band.index, size: innerGeo.size), y: dragPointYLocations[band.index])
.gesture(
DragGesture()
.onChanged { value in
let newY = min(max(value.location.y, 0), innerGeo.size.height)
dragPointYLocations[band.index] = newY
updateGainValue(at: band.index, in: innerGeo.size)
}
)
.onAppear {
dragPointYLocations[band.index] = gainToYPosition(at: band.value, in: innerGeo.size)
}
.onChange(of: eqModel.shouldReset) { _, reset in
if reset {
resetPositions(in: innerGeo.size)
}
}
}
}
ForEach(eqModel.bands) { band in
Text(band.frequency)
.position(x: positionForDragPoint(at: band.index, size: innerGeo.size), y: innerGeo.size.height + 8)
.font(.caption)
.foregroundColor(.black)
}
}
}
}
}
func positionForDragPoint(at index: Int, size: CGSize) -> CGFloat {
size.width / 12 * CGFloat(index * 2 + 1)
}
func updateGainValue(at index: Int, in size: CGSize) {
let percentage = dragPointYLocations[index] / size.height
let gain = (1 - Float(percentage)) * (eqModel.maxGain - eqModel.minGain) + eqModel.minGain
eqModel.update(gain: gain, index: index)
}
func gainToYPosition(at gain: Float, in size: CGSize) -> CGFloat {
let percentage = 1 - (gain - eqModel.minGain) / (eqModel.maxGain - eqModel.minGain)
return CGFloat(percentage) * size.height
}
func resetPositions(in size: CGSize) {
let reset = dragPointYLocations.map { _ in gainToYPosition(at: 0, in: size) }
withAnimation(.easeInOut(duration: 0.2)) {
dragPointYLocations = reset
}
}
}
extension EqualizerView {
@Observable
class Model {
@ObservationIgnored
private let equalizerService: EqualizerService
var dragPointYLocations: [CGFloat] = Array(repeating: .zero, count: 6)
var isEnabled: Bool = false
var bands: [EQBand] = []
let minGain: Float = -12
let maxGain: Float = 12
var shouldReset: Bool = false
init(equalizerService: EqualizerService) {
self.equalizerService = equalizerService
isEnabled = equalizerService.isActivated
}
func generateBands() {
bands = equalizerService.bands.enumerated().map { index, item in
var measurement = item.frequency
var frequency = String(Int(measurement))
if item.frequency >= 1000 {
measurement = item.frequency / 1000
frequency = "\(String(Int(measurement)))K"
}
return EQBand(index: index, frequency: frequency, min: minGain, max: maxGain, value: item.gain)
}
}
func enable() {
if isEnabled {
equalizerService.activate()
} else {
equalizerService.deactivate()
}
}
func update(gain: Float, index: Int) {
shouldReset = false
bands[index].value = gain
equalizerService.update(gain: gain, for: index)
}
func reset() {
guard !shouldReset else {
return
}
shouldReset = true
equalizerService.reset()
for band in bands {
band.value = 0.0
}
}
}
}
struct LineShape: Shape {
var values: [Double]
var animatableData: AnimatableLine {
get { AnimatableLine(values: values) }
set { values = newValue.values }
}
func path(in rect: CGRect) -> Path {
var path = Path()
path.move(to: CGPoint(x: rect.size.width / 12, y: values.first ?? 0))
for index in 1..<values.count {
let x = positionForDragPoint(at: index, size: rect.size)
let y = values[index]
path.addLine(to: CGPoint(x: x, y: y))
}
return path
}
func positionForDragPoint(at index: Int, size: CGSize) -> CGFloat {
size.width / 12 * CGFloat(index * 2 + 1)
}
}
struct AnimatableLine : VectorArithmetic {
var values: [Double]
var magnitudeSquared: Double {
return values.map { $0 * $0 }.reduce(0, +)
}
mutating func scale(by rhs: Double) {
values = values.map { $0 * rhs }
}
static var zero: AnimatableLine {
return AnimatableLine(values: [0.0])
}
static func - (lhs: AnimatableLine, rhs: AnimatableLine) -> AnimatableLine {
return AnimatableLine(values: zip(lhs.values, rhs.values).map(-))
}
static func -= (lhs: inout AnimatableLine, rhs: AnimatableLine) {
lhs = lhs - rhs
}
static func + (lhs: AnimatableLine, rhs: AnimatableLine) -> AnimatableLine {
return AnimatableLine(values: zip(lhs.values, rhs.values).map(+))
}
static func += (lhs: inout AnimatableLine, rhs: AnimatableLine) {
lhs = lhs + rhs
}
}
#Preview {
EqualizerView(appModel: AppModel())
}
@@ -1,26 +1,23 @@
//
// AudioPlayerService.swift
// AudioExample
//
// Created by Dimitrios Chatzieleftheriou on 14/11/2020.
// Copyright © 2020 Dimitrios Chatzieleftheriou. All rights reserved.
// Created by Dimitris C.
// Copyright © 2024 Decimal. All rights reserved.
//
import AudioStreaming
import AVFoundation
protocol AudioPlayerServiceDelegate: AnyObject {
func didStartPlaying()
func didStopPlaying()
func didStartPlaying(id: AudioEntryId)
func didStopPlaying(id: AudioEntryId, reason: AudioPlayerStopReason)
func statusChanged(status: AudioPlayerState)
func errorOccurred(error: AudioPlayerError)
func metadataReceived(metadata: [String: String])
}
final class AudioPlayerService {
var delegate = MulticastDelegate<AudioPlayerServiceDelegate>()
weak var delegate: AudioPlayerServiceDelegate?
private var player: AudioPlayer
var player: AudioPlayer
private var audioSystemResetObserver: Any?
var duration: Double {
@@ -43,8 +40,15 @@ final class AudioPlayerService {
player.state
}
init() {
player = AudioPlayer(configuration: .init(enableLogs: true))
var statusChangedNotifier = Notifier<AudioPlayerState>()
var metadataReceivedNotifier = Notifier<[String: String]>()
var playingStartedStopped = Notifier<(started: Bool, AudioEntryId, AudioPlayerStopReason?)>()
private let audioPlayerProvider: () -> AudioPlayer
init(audioPlayerProvider: @escaping () -> AudioPlayer) {
self.audioPlayerProvider = audioPlayerProvider
player = audioPlayerProvider()
player.delegate = self
configureAudioSession()
@@ -56,6 +60,11 @@ final class AudioPlayerService {
player.play(url: url)
}
func play(source: CoreAudioStreamSource, entryId: String, format: AVAudioFormat) {
activateAudioSession()
player.play(source: source, entryId: entryId, format: format)
}
func queue(url: URL) {
activateAudioSession()
player.queue(url: url)
@@ -82,6 +91,10 @@ final class AudioPlayerService {
player.rate = rate
}
func update(volume: Float) {
player.volume = volume
}
func add(_ node: AVAudioNode) {
player.attach(node: node)
}
@@ -98,27 +111,31 @@ final class AudioPlayerService {
}
}
func seek(at time: Float) {
player.seek(to: Double(time))
func seek(at time: Double) {
player.seek(to: time)
}
private func recreatePlayer() {
player = AudioPlayer(configuration: .init(enableLogs: true))
player = audioPlayerProvider()
player.delegate = self
}
private func registerSessionEvents() {
// Note that a real app might need to observer other AVAudioSession notifications as well
audioSystemResetObserver = NotificationCenter.default.addObserver(forName: AVAudioSession.mediaServicesWereResetNotification,
object: nil,
queue: nil)
{ [unowned self] _ in
#if os(iOS)
audioSystemResetObserver = NotificationCenter.default.addObserver(
forName: AVAudioSession.mediaServicesWereResetNotification,
object: nil,
queue: nil
) { [unowned self] _ in
self.configureAudioSession()
self.recreatePlayer()
}
#endif
}
private func configureAudioSession() {
#if os(iOS)
do {
print("AudioSession category is AVAudioSessionCategoryPlayback")
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, policy: .longFormAudio, options: [])
@@ -126,9 +143,11 @@ final class AudioPlayerService {
} catch let error as NSError {
print("Couldn't setup audio session category to Playback \(error.localizedDescription)")
}
#endif
}
private func activateAudioSession() {
#if os(iOS)
do {
print("AudioSession is active")
try AVAudioSession.sharedInstance().setActive(true, options: [])
@@ -136,29 +155,34 @@ 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 id: AudioEntryId) {
print("audioPlayerDidStartPlaying entryId: \(id)")
delegate.invoke(invocation: { $0.didStartPlaying() })
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) {
print("audioPlayerDidStartPlaying newState: \(newState)")
delegate.invoke(invocation: { $0.statusChanged(status: newState) })
print("audioPlayerStateChanged newState: \(newState)")
Task { await statusChangedNotifier.send(newState) }
delegate?.statusChanged(status: newState)
}
func audioPlayerDidFinishPlaying(player _: AudioPlayer,
@@ -168,16 +192,18 @@ extension AudioPlayerService: AudioPlayerDelegate {
duration _: Double)
{
print("audioPlayerDidFinishPlaying entryId: \(id), reason: \(reason)")
delegate.invoke(invocation: { $0.didStopPlaying() })
Task { await playingStartedStopped.send((false, id, reason)) }
delegate?.didStopPlaying(id: id, reason: reason)
}
func audioPlayerUnexpectedError(player _: AudioPlayer, error: AudioPlayerError) {
delegate.invoke(invocation: { $0.errorOccurred(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, 1000, 2000, 4000, 8000, 16000]
private let _freqs = [60, 150, 400, 1000, 2400, 15000]
private let eqUnit: AVAudioUnitEQ
var bands: [AVAudioUnitEQFilterParameters] {
@@ -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
@@ -2,7 +2,9 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PreviewsEnabled</key>
<false/>
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>
</dict>
</plist>
@@ -0,0 +1,35 @@
//
// Created by Dimitris C.
// Copyright © 2024 Decimal. All rights reserved.
//
import SwiftUI
struct ContentView: View {
@Environment(AppModel.self) var appModel
@Environment(\.prefersStackNavigation) private var prefersStackNavigation
@State private var selection: NavigationContent?
var body: some View {
if prefersStackNavigation {
NavigationStack {
ContentSidebar(selection: $selection)
.navigationTitle("Home")
}
} else {
NavigationSplitView {
ContentSidebar(selection: $selection)
.navigationTitle("Home")
} detail: {
if let selection {
DetailView(selection: selection)
}
}
.onAppear {
selection = .audioPlayer
}
}
}
}
@@ -0,0 +1,22 @@
//
// Created by Dimitris C.
// Copyright © 2024 Decimal. All rights reserved.
//
import SwiftUI
struct DetailView: View {
@Environment(AppModel.self) var appModel
var selection: NavigationContent
var body: some View {
switch selection {
case .audioPlayer:
AudioPlayerView(appModel: appModel)
case .audioQueue: // TODO
EmptyView()
}
}
}
@@ -0,0 +1,48 @@
//
// Created by Dimitris C.
// Copyright © 2024 Decimal. All rights reserved.
//
import SwiftUI
enum NavigationContent: Hashable {
case audioPlayer
case audioQueue
}
struct ContentSidebar: View {
@Binding var selection: NavigationContent?
var body: some View {
List(selection: $selection) {
NavigationLink(value: NavigationContent.audioPlayer) {
Label("Audio Player", systemImage: "play")
}
NavigationLink(value: NavigationContent.audioQueue) {
Label("Audio Queue", systemImage: "play.square.stack")
}
}
.navigationTitle("Home")
.navigationDestination(item: $selection, destination: { selection in
DetailView(selection: selection)
})
}
}
struct Sidebar_Previews: PreviewProvider {
struct Preview: View {
@State private var selection: NavigationContent? = NavigationContent.audioPlayer
var body: some View {
ContentSidebar(selection: $selection)
}
}
static var previews: some View {
NavigationSplitView {
Preview()
} detail: {
Text("Detail!")
}
}
}
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.
-19
View File
@@ -1,19 +0,0 @@
Pod::Spec.new do |s|
s.name = 'AudioStreaming'
s.version = '1.2.1'
s.license = 'MIT'
s.summary = 'An AudioPlayer/Streaming library for iOS written in Swift using AVAudioEngine.'
s.homepage = 'https://github.com/dimitris-c/AudioStreaming'
s.authors = { 'Dimitris C.' => 'dimmdesign@gmail.com' }
s.source = { :git => 'https://github.com/dimitris-c/AudioStreaming.git', :tag => s.version }
s.ios.deployment_target = '13.0'
s.swift_versions = ['5.1', '5.2', '5.3']
s.source_files = 'AudioStreaming/**/*.swift'
s.pod_target_xcconfig = {
'SWIFT_INSTALL_OBJC_HEADER' => 'NO'
}
end
-951
View File
@@ -1,951 +0,0 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 52;
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 */; };
B51FE0C02488F67C00F2A4D2 /* Queue.swift in Sources */ = {isa = PBXBuildFile; fileRef = B51FE0BF2488F67C00F2A4D2 /* Queue.swift */; };
B51FE0C22488F96A00F2A4D2 /* QueueTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B51FE0C12488F96A00F2A4D2 /* QueueTests.swift */; };
B51FE0C624890CCB00F2A4D2 /* PlayerQueueEntries.swift in Sources */ = {isa = PBXBuildFile; fileRef = B51FE0C3248905B400F2A4D2 /* PlayerQueueEntries.swift */; };
B51FE0C824892D1600F2A4D2 /* PlayerQueueEntriesTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = B51FE0C724892D1600F2A4D2 /* PlayerQueueEntriesTest.swift */; };
B5276B6F247D21A000D2F56A /* NetworkingClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5276B6E247D21A000D2F56A /* NetworkingClient.swift */; };
B5276B74247D4D9F00D2F56A /* NetworkSessionDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5276B73247D4D9F00D2F56A /* NetworkSessionDelegate.swift */; };
B54C3E56255F286D00B356F2 /* Retrier.swift in Sources */ = {isa = PBXBuildFile; fileRef = B54C3E55255F286D00B356F2 /* Retrier.swift */; };
B54D876D2490E4A000C361A0 /* UnitDescriptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = B54D876C2490E4A000C361A0 /* UnitDescriptions.swift */; };
B54D876F2490E4DD00C361A0 /* AudioRendererContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = B54D876E2490E4DD00C361A0 /* AudioRendererContext.swift */; };
B55A736C247FCB420050C53D /* HTTPHeaderParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = B55A736B247FCB420050C53D /* HTTPHeaderParser.swift */; };
B55CE96E248058B60001C498 /* MetadataParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = B55CE96D248058B60001C498 /* MetadataParser.swift */; };
B55CE97124810DE20001C498 /* MetadataStreamProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = B55CE97024810DE20001C498 /* MetadataStreamProcessor.swift */; };
B55CE97824813BCA0001C498 /* UnsafeMutablePointer+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = B55CE97724813BCA0001C498 /* UnsafeMutablePointer+Helpers.swift */; };
B55CEAB42485107C0001C498 /* Parser.swift in Sources */ = {isa = PBXBuildFile; fileRef = B55CEAB32485107C0001C498 /* Parser.swift */; };
B55CEAB82485172D0001C498 /* HTTPHeaderParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B55CEAB72485172D0001C498 /* HTTPHeaderParserTests.swift */; };
B55CEABA248530C00001C498 /* MetadataParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = B55CEAB9248530C00001C498 /* MetadataParser.swift */; };
B55CEABC24853CD20001C498 /* AudioPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = B55CEABB24853CD20001C498 /* AudioPlayer.swift */; };
B55F77CF24D82ADE0057F431 /* AudioPlayerDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = B55F77CE24D82ADE0057F431 /* AudioPlayerDelegate.swift */; };
B55F77D124D82CD50057F431 /* AVAudioUnit+Convenience.swift in Sources */ = {isa = PBXBuildFile; fileRef = B55F77D024D82CD50057F431 /* AVAudioUnit+Convenience.swift */; };
B55F77D624DACE140057F431 /* BufferContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = B55F77D524DACE140057F431 /* BufferContext.swift */; };
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 */; };
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 */; };
B5838644254584BE0087A712 /* AudioStreamState.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5838643254584BE0087A712 /* AudioStreamState.swift */; };
B5838648254584D90087A712 /* SeekRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5838647254584D90087A712 /* SeekRequest.swift */; };
B592E1252545FF9A008866FB /* BiMap.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5276B71247D4D5B00D2F56A /* BiMap.swift */; };
B592E12925460146008866FB /* BiMapTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B592E12825460146008866FB /* BiMapTests.swift */; };
B592E134254608B4008866FB /* DispatchTimerSourceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B592E133254608B4008866FB /* DispatchTimerSourceTests.swift */; };
B59CB46C25420B4D00F8CAD0 /* MetadataStreamProcessorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B59CB46B25420B4D00F8CAD0 /* MetadataStreamProcessorTests.swift */; };
B59CB4BB25421F3500F8CAD0 /* raw-stream-audio-normal-metadata in Resources */ = {isa = PBXBuildFile; fileRef = B59CB4BA25421F3500F8CAD0 /* raw-stream-audio-normal-metadata */; };
B59CB4C225421F7A00F8CAD0 /* raw-stream-audio-empty-metadata in Resources */ = {isa = PBXBuildFile; fileRef = B59CB4B225421D8200F8CAD0 /* raw-stream-audio-empty-metadata */; };
B59CB4C625421FD400F8CAD0 /* raw-stream-audio-no-metadata in Resources */ = {isa = PBXBuildFile; fileRef = B59CB4C525421FD400F8CAD0 /* raw-stream-audio-no-metadata */; };
B59CB4CE2542204D00F8CAD0 /* raw-stream-audio-normal-metadata-alt in Resources */ = {isa = PBXBuildFile; fileRef = B59CB4CD2542204D00F8CAD0 /* raw-stream-audio-normal-metadata-alt */; };
B59D0B6F255C904900D6CCE5 /* FileAudioSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = B59D0B6E255C904900D6CCE5 /* FileAudioSource.swift */; };
B59DF10424916FD50043C498 /* DispatchQueue+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = B59DF10324916FD50043C498 /* DispatchQueue+Helpers.swift */; };
B59DF1A32493E90C0043C498 /* AudioFileStream+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = B59DF1A22493E90C0043C498 /* AudioFileStream+Helpers.swift */; };
B5AEDBB824744153007D8101 /* AudioStreaming.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B5AEDBAE24744153007D8101 /* AudioStreaming.framework */; };
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 */; };
B5EF954E247DA5AC003E8FF8 /* NetworkingClientTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5EF954D247DA5AC003E8FF8 /* NetworkingClientTests.swift */; };
B5EF9555247E9393003E8FF8 /* AudioEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5EF9554247E9393003E8FF8 /* AudioEntry.swift */; };
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 */; };
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 */
/* Begin PBXContainerItemProxy section */
B5AEDBB924744153007D8101 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = B5AEDBA524744153007D8101 /* Project object */;
proxyType = 1;
remoteGlobalIDString = B5AEDBAD24744153007D8101;
remoteInfo = AudioStreaming;
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
B57A4F7D24AB4E6C00D7EA51 /* Embed Frameworks */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 10;
files = (
);
name = "Embed Frameworks";
runOnlyForDeploymentPostprocessing = 0;
};
/* 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>"; };
B51FE0BF2488F67C00F2A4D2 /* Queue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Queue.swift; sourceTree = "<group>"; };
B51FE0C12488F96A00F2A4D2 /* QueueTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QueueTests.swift; sourceTree = "<group>"; };
B51FE0C3248905B400F2A4D2 /* PlayerQueueEntries.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerQueueEntries.swift; sourceTree = "<group>"; };
B51FE0C724892D1600F2A4D2 /* PlayerQueueEntriesTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerQueueEntriesTest.swift; sourceTree = "<group>"; };
B5276B6E247D21A000D2F56A /* NetworkingClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkingClient.swift; sourceTree = "<group>"; };
B5276B71247D4D5B00D2F56A /* BiMap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BiMap.swift; sourceTree = "<group>"; };
B5276B73247D4D9F00D2F56A /* NetworkSessionDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkSessionDelegate.swift; sourceTree = "<group>"; };
B54C3E55255F286D00B356F2 /* Retrier.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Retrier.swift; sourceTree = "<group>"; };
B54D876C2490E4A000C361A0 /* UnitDescriptions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnitDescriptions.swift; sourceTree = "<group>"; };
B54D876E2490E4DD00C361A0 /* AudioRendererContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioRendererContext.swift; sourceTree = "<group>"; };
B55A736B247FCB420050C53D /* HTTPHeaderParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPHeaderParser.swift; sourceTree = "<group>"; };
B55CE96D248058B60001C498 /* MetadataParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetadataParser.swift; sourceTree = "<group>"; };
B55CE97024810DE20001C498 /* MetadataStreamProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetadataStreamProcessor.swift; sourceTree = "<group>"; };
B55CE97724813BCA0001C498 /* UnsafeMutablePointer+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UnsafeMutablePointer+Helpers.swift"; sourceTree = "<group>"; };
B55CEAB32485107C0001C498 /* Parser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Parser.swift; sourceTree = "<group>"; };
B55CEAB72485172D0001C498 /* HTTPHeaderParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPHeaderParserTests.swift; sourceTree = "<group>"; };
B55CEAB9248530C00001C498 /* MetadataParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetadataParser.swift; sourceTree = "<group>"; };
B55CEABB24853CD20001C498 /* AudioPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlayer.swift; sourceTree = "<group>"; };
B55F77CE24D82ADE0057F431 /* AudioPlayerDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlayerDelegate.swift; sourceTree = "<group>"; };
B55F77D024D82CD50057F431 /* AVAudioUnit+Convenience.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AVAudioUnit+Convenience.swift"; sourceTree = "<group>"; };
B55F77D524DACE140057F431 /* BufferContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BufferContext.swift; sourceTree = "<group>"; };
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>"; };
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>"; };
B580CB1F25628D09006D7DD8 /* Package.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Package.swift; sourceTree = "<group>"; };
B58386372544A2C10087A712 /* EntryFrames.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EntryFrames.swift; sourceTree = "<group>"; };
B583863F254584A50087A712 /* ProcessedPackets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProcessedPackets.swift; sourceTree = "<group>"; };
B5838643254584BE0087A712 /* AudioStreamState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioStreamState.swift; sourceTree = "<group>"; };
B5838647254584D90087A712 /* SeekRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SeekRequest.swift; sourceTree = "<group>"; };
B592E12825460146008866FB /* BiMapTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BiMapTests.swift; sourceTree = "<group>"; };
B592E133254608B4008866FB /* DispatchTimerSourceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DispatchTimerSourceTests.swift; sourceTree = "<group>"; };
B59CB46B25420B4D00F8CAD0 /* MetadataStreamProcessorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetadataStreamProcessorTests.swift; sourceTree = "<group>"; };
B59CB4B225421D8200F8CAD0 /* raw-stream-audio-empty-metadata */ = {isa = PBXFileReference; lastKnownFileType = file; path = "raw-stream-audio-empty-metadata"; sourceTree = "<group>"; };
B59CB4BA25421F3500F8CAD0 /* raw-stream-audio-normal-metadata */ = {isa = PBXFileReference; lastKnownFileType = file; path = "raw-stream-audio-normal-metadata"; sourceTree = "<group>"; };
B59CB4C525421FD400F8CAD0 /* raw-stream-audio-no-metadata */ = {isa = PBXFileReference; lastKnownFileType = file; path = "raw-stream-audio-no-metadata"; sourceTree = "<group>"; };
B59CB4CD2542204D00F8CAD0 /* raw-stream-audio-normal-metadata-alt */ = {isa = PBXFileReference; lastKnownFileType = file; path = "raw-stream-audio-normal-metadata-alt"; sourceTree = "<group>"; };
B59D0B6E255C904900D6CCE5 /* FileAudioSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileAudioSource.swift; sourceTree = "<group>"; };
B59DF10324916FD50043C498 /* DispatchQueue+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DispatchQueue+Helpers.swift"; sourceTree = "<group>"; };
B59DF1A22493E90C0043C498 /* AudioFileStream+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AudioFileStream+Helpers.swift"; sourceTree = "<group>"; };
B5AEDBAE24744153007D8101 /* AudioStreaming.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = AudioStreaming.framework; sourceTree = BUILT_PRODUCTS_DIR; };
B5AEDBB124744153007D8101 /* AudioStreaming.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = AudioStreaming.h; sourceTree = "<group>"; };
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>"; };
B5E1DE2424B70B4200955BFB /* AudioPlayerConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlayerConfiguration.swift; sourceTree = "<group>"; };
B5EF954D247DA5AC003E8FF8 /* NetworkingClientTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkingClientTests.swift; sourceTree = "<group>"; };
B5EF9554247E9393003E8FF8 /* AudioEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioEntry.swift; sourceTree = "<group>"; };
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>"; };
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>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
B5AEDBAB24744153007D8101 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
B5AEDBB424744153007D8101 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
B5AEDBB824744153007D8101 /* AudioStreaming.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* 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 = (
B5D82E64255DD562009EDAA4 /* NetStatusService.swift */,
B5276B6E247D21A000D2F56A /* NetworkingClient.swift */,
B5276B73247D4D9F00D2F56A /* NetworkSessionDelegate.swift */,
B5F883C22477DC4400D277C1 /* NetworkDataStream.swift */,
);
path = Network;
sourceTree = "<group>";
};
B55A7369247FCB160050C53D /* Audio Entry */ = {
isa = PBXGroup;
children = (
B58386362544A2A60087A712 /* Models */,
B5DB66E1255C2EAB00B8DF53 /* AudioEntryProvider.swift */,
B5EF9554247E9393003E8FF8 /* AudioEntry.swift */,
);
path = "Audio Entry";
sourceTree = "<group>";
};
B55A736A247FCB310050C53D /* Parsers */ = {
isa = PBXGroup;
children = (
B55CEAB32485107C0001C498 /* Parser.swift */,
B55A736B247FCB420050C53D /* HTTPHeaderParser.swift */,
B55CE96D248058B60001C498 /* MetadataParser.swift */,
B5D4A40825D9321400E1450C /* IcycastHeaderParser.swift */,
);
path = Parsers;
sourceTree = "<group>";
};
B55CE97624813BA10001C498 /* Extensions */ = {
isa = PBXGroup;
children = (
B5FB6C0425516507002C0A37 /* AudioConverter+Helpers.swift */,
B59DF1A22493E90C0043C498 /* AudioFileStream+Helpers.swift */,
B55CE97724813BCA0001C498 /* UnsafeMutablePointer+Helpers.swift */,
B59DF10324916FD50043C498 /* DispatchQueue+Helpers.swift */,
B55F77D024D82CD50057F431 /* AVAudioUnit+Convenience.swift */,
B51B9F9924DBE5BF00BDEAA2 /* AVAudioFormat+Convenience.swift */,
);
path = Extensions;
sourceTree = "<group>";
};
B55CEAB5248517170001C498 /* Streaming */ = {
isa = PBXGroup;
children = (
B59CB4B125421D8200F8CAD0 /* Metadata Stream Processor */,
B55CEAB62485171E0001C498 /* Parsers */,
B51FE0C724892D1600F2A4D2 /* PlayerQueueEntriesTest.swift */,
);
path = Streaming;
sourceTree = "<group>";
};
B55CEAB62485171E0001C498 /* Parsers */ = {
isa = PBXGroup;
children = (
B55CEAB72485172D0001C498 /* HTTPHeaderParserTests.swift */,
B55CEAB9248530C00001C498 /* MetadataParser.swift */,
);
path = Parsers;
sourceTree = "<group>";
};
B55CEABF24855A900001C498 /* Helpers */ = {
isa = PBXGroup;
children = (
B51FE0C3248905B400F2A4D2 /* PlayerQueueEntries.swift */,
B5EF955A247EBCB3003E8FF8 /* AudioFileType.swift */,
B55F77D524DACE140057F431 /* BufferContext.swift */,
);
path = Helpers;
sourceTree = "<group>";
};
B55CEAC024855AA20001C498 /* Processors */ = {
isa = PBXGroup;
children = (
B5B36E422655A32200DC96F5 /* FrameFilterProcessor.swift */,
B5667A8F2499018D00D93F85 /* AudioFileStreamProcessor.swift */,
B5667B3D249BC43000D93F85 /* AudioPlayerRenderProcessor.swift */,
B55CE97024810DE20001C498 /* MetadataStreamProcessor.swift */,
B5D4A40B25D9445600E1450C /* IcycastHeadersProcessor.swift */,
);
path = Processors;
sourceTree = "<group>";
};
B57A4F7A24AB4E6C00D7EA51 /* Frameworks */ = {
isa = PBXGroup;
children = (
B5DB66DA255C079C00B8DF53 /* AVFoundation.framework */,
);
name = Frameworks;
sourceTree = "<group>";
};
B580CB1C25628CE4006D7DD8 /* Deployment */ = {
isa = PBXGroup;
children = (
B580CB1D25628CF4006D7DD8 /* AudioStreaming.podspec */,
B580CB1E25628CF4006D7DD8 /* LICENSE */,
B580CB1F25628D09006D7DD8 /* Package.swift */,
);
name = Deployment;
sourceTree = "<group>";
};
B58386362544A2A60087A712 /* Models */ = {
isa = PBXGroup;
children = (
B58386372544A2C10087A712 /* EntryFrames.swift */,
B583863F254584A50087A712 /* ProcessedPackets.swift */,
B5838643254584BE0087A712 /* AudioStreamState.swift */,
B5838647254584D90087A712 /* SeekRequest.swift */,
);
path = Models;
sourceTree = "<group>";
};
B58BD7FC255DB653005B756D /* Audio Source */ = {
isa = PBXGroup;
children = (
98C82AE42B8CA8AA00AED485 /* Mp4 */,
B5EF9556247E9439003E8FF8 /* AudioStreamSource.swift */,
B5EF955C247ECBB1003E8FF8 /* RemoteAudioSource.swift */,
B59D0B6E255C904900D6CCE5 /* FileAudioSource.swift */,
);
path = "Audio Source";
sourceTree = "<group>";
};
B592E11E2545FF33008866FB /* Structures */ = {
isa = PBXGroup;
children = (
B5276B71247D4D5B00D2F56A /* BiMap.swift */,
B51FE0BF2488F67C00F2A4D2 /* Queue.swift */,
);
path = Structures;
sourceTree = "<group>";
};
B592E13025460883008866FB /* Helpers */ = {
isa = PBXGroup;
children = (
98DC00CB2B961F5E0068900A /* ByteBuffer.swift */,
98CC396D28BD651E006C9FF9 /* Atomic.swift */,
B514657E248E3884005C03F7 /* DispatchTimerSource.swift */,
B57829CE2548B32B00C78D36 /* Lock.swift */,
B500731F24D00BAC00BB4475 /* Logger.swift */,
B54C3E55255F286D00B356F2 /* Retrier.swift */,
);
path = Helpers;
sourceTree = "<group>";
};
B59CB4B125421D8200F8CAD0 /* Metadata Stream Processor */ = {
isa = PBXGroup;
children = (
B59CB4B525421D8D00F8CAD0 /* raw-audio-streams */,
B59CB46B25420B4D00F8CAD0 /* MetadataStreamProcessorTests.swift */,
);
path = "Metadata Stream Processor";
sourceTree = "<group>";
};
B59CB4B525421D8D00F8CAD0 /* raw-audio-streams */ = {
isa = PBXGroup;
children = (
B59CB4CD2542204D00F8CAD0 /* raw-stream-audio-normal-metadata-alt */,
B59CB4C525421FD400F8CAD0 /* raw-stream-audio-no-metadata */,
B59CB4BA25421F3500F8CAD0 /* raw-stream-audio-normal-metadata */,
B59CB4B225421D8200F8CAD0 /* raw-stream-audio-empty-metadata */,
);
path = "raw-audio-streams";
sourceTree = "<group>";
};
B5AEDBA424744153007D8101 = {
isa = PBXGroup;
children = (
B580CB1C25628CE4006D7DD8 /* Deployment */,
B5AEDBB024744153007D8101 /* AudioStreaming */,
B5AEDBBB24744153007D8101 /* AudioStreamingTests */,
B5AEDBAF24744153007D8101 /* Products */,
B57A4F7A24AB4E6C00D7EA51 /* Frameworks */,
);
sourceTree = "<group>";
};
B5AEDBAF24744153007D8101 /* Products */ = {
isa = PBXGroup;
children = (
B5AEDBAE24744153007D8101 /* AudioStreaming.framework */,
B5AEDBB724744153007D8101 /* AudioStreamingTests.xctest */,
);
name = Products;
sourceTree = "<group>";
};
B5AEDBB024744153007D8101 /* AudioStreaming */ = {
isa = PBXGroup;
children = (
B5F883B42476DABE00D277C1 /* Core */,
B5EF9553247E9235003E8FF8 /* Streaming */,
B5AEDBB124744153007D8101 /* AudioStreaming.h */,
B5AEDBB224744153007D8101 /* Info.plist */,
);
path = AudioStreaming;
sourceTree = "<group>";
};
B5AEDBBB24744153007D8101 /* AudioStreamingTests */ = {
isa = PBXGroup;
children = (
B5FFF5FD2549FA02006BBB7C /* AudioExample.xctestplan */,
B5F883B72477CBC900D277C1 /* Core */,
B55CEAB5248517170001C498 /* Streaming */,
B5AEDBBE24744153007D8101 /* Info.plist */,
);
path = AudioStreamingTests;
sourceTree = "<group>";
};
B5E1DE2924B7179E00955BFB /* AudioPlayer */ = {
isa = PBXGroup;
children = (
B54D876C2490E4A000C361A0 /* UnitDescriptions.swift */,
B5B3B7CB248647ED00656828 /* AudioPlayerState.swift */,
B5E1DE2424B70B4200955BFB /* AudioPlayerConfiguration.swift */,
B55F77CE24D82ADE0057F431 /* AudioPlayerDelegate.swift */,
B55CEABB24853CD20001C498 /* AudioPlayer.swift */,
B5667A912499063D00D93F85 /* AudioPlayerContext.swift */,
B54D876E2490E4DD00C361A0 /* AudioRendererContext.swift */,
B55CEAC024855AA20001C498 /* Processors */,
);
path = AudioPlayer;
sourceTree = "<group>";
};
B5EF954A247DA450003E8FF8 /* Network */ = {
isa = PBXGroup;
children = (
B5EF954D247DA5AC003E8FF8 /* NetworkingClientTests.swift */,
);
path = Network;
sourceTree = "<group>";
};
B5EF9553247E9235003E8FF8 /* Streaming */ = {
isa = PBXGroup;
children = (
B5E1DE2924B7179E00955BFB /* AudioPlayer */,
B58BD7FC255DB653005B756D /* Audio Source */,
B55A7369247FCB160050C53D /* Audio Entry */,
B55CEABF24855A900001C498 /* Helpers */,
B55A736A247FCB310050C53D /* Parsers */,
);
path = Streaming;
sourceTree = "<group>";
};
B5F883B42476DABE00D277C1 /* Core */ = {
isa = PBXGroup;
children = (
B55CE97624813BA10001C498 /* Extensions */,
B592E11E2545FF33008866FB /* Structures */,
B5276B70247D4D3D00D2F56A /* Network */,
B592E13025460883008866FB /* Helpers */,
);
path = Core;
sourceTree = "<group>";
};
B5F883B72477CBC900D277C1 /* Core */ = {
isa = PBXGroup;
children = (
B5EF954A247DA450003E8FF8 /* Network */,
B5F883B82477CBF600D277C1 /* AtomicTests.swift */,
B51FE0C12488F96A00F2A4D2 /* QueueTests.swift */,
B592E12825460146008866FB /* BiMapTests.swift */,
B592E133254608B4008866FB /* DispatchTimerSourceTests.swift */,
98DC00CD2B9726380068900A /* ByteBufferTests.swift */,
);
path = Core;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXHeadersBuildPhase section */
B5AEDBA924744153007D8101 /* Headers */ = {
isa = PBXHeadersBuildPhase;
buildActionMask = 2147483647;
files = (
B5AEDBBF24744153007D8101 /* AudioStreaming.h in Headers */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXHeadersBuildPhase section */
/* Begin PBXNativeTarget section */
B5AEDBAD24744153007D8101 /* AudioStreaming */ = {
isa = PBXNativeTarget;
buildConfigurationList = B5AEDBC224744153007D8101 /* Build configuration list for PBXNativeTarget "AudioStreaming" */;
buildPhases = (
B5AEDBA924744153007D8101 /* Headers */,
B5AEDBAA24744153007D8101 /* Sources */,
B5AEDBAB24744153007D8101 /* Frameworks */,
B5AEDBAC24744153007D8101 /* Resources */,
B57A4F7D24AB4E6C00D7EA51 /* Embed Frameworks */,
B583864B2545858E0087A712 /* SwiftLint */,
);
buildRules = (
);
dependencies = (
);
name = AudioStreaming;
packageProductDependencies = (
);
productName = AudioStreaming;
productReference = B5AEDBAE24744153007D8101 /* AudioStreaming.framework */;
productType = "com.apple.product-type.framework";
};
B5AEDBB624744153007D8101 /* AudioStreamingTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = B5AEDBC524744153007D8101 /* Build configuration list for PBXNativeTarget "AudioStreamingTests" */;
buildPhases = (
B5AEDBB324744153007D8101 /* Sources */,
B5AEDBB424744153007D8101 /* Frameworks */,
B5AEDBB524744153007D8101 /* Resources */,
);
buildRules = (
);
dependencies = (
B5AEDBBA24744153007D8101 /* PBXTargetDependency */,
);
name = AudioStreamingTests;
productName = AudioStreamingTests;
productReference = B5AEDBB724744153007D8101 /* AudioStreamingTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
B5AEDBA524744153007D8101 /* Project object */ = {
isa = PBXProject;
attributes = {
LastSwiftUpdateCheck = 1140;
LastUpgradeCheck = 1200;
ORGANIZATIONNAME = Decimal;
TargetAttributes = {
B5AEDBAD24744153007D8101 = {
CreatedOnToolsVersion = 11.4;
LastSwiftMigration = 1200;
};
B5AEDBB624744153007D8101 = {
CreatedOnToolsVersion = 11.4;
LastSwiftMigration = 1140;
};
};
};
buildConfigurationList = B5AEDBA824744153007D8101 /* Build configuration list for PBXProject "AudioStreaming" */;
compatibilityVersion = "Xcode 11.0";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = B5AEDBA424744153007D8101;
packageReferences = (
);
productRefGroup = B5AEDBAF24744153007D8101 /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
B5AEDBAD24744153007D8101 /* AudioStreaming */,
B5AEDBB624744153007D8101 /* AudioStreamingTests */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
B5AEDBAC24744153007D8101 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
B5AEDBB524744153007D8101 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
B59CB4BB25421F3500F8CAD0 /* raw-stream-audio-normal-metadata in Resources */,
B59CB4C225421F7A00F8CAD0 /* raw-stream-audio-empty-metadata in Resources */,
B59CB4C625421FD400F8CAD0 /* raw-stream-audio-no-metadata in Resources */,
B59CB4CE2542204D00F8CAD0 /* raw-stream-audio-normal-metadata-alt in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
B583864B2545858E0087A712 /* SwiftLint */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
);
name = SwiftLint;
outputFileListPaths = (
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
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 */
/* Begin PBXSourcesBuildPhase section */
B5AEDBAA24744153007D8101 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
B58386382544A2C10087A712 /* EntryFrames.swift in Sources */,
B5EF955D247ECBB1003E8FF8 /* RemoteAudioSource.swift in Sources */,
B57829CF2548B32B00C78D36 /* Lock.swift in Sources */,
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 */,
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 */,
B55CEAB42485107C0001C498 /* Parser.swift in Sources */,
B5FB6C0525516507002C0A37 /* AudioConverter+Helpers.swift in Sources */,
B5E1DE2524B70B4200955BFB /* AudioPlayerConfiguration.swift in Sources */,
B5F883C32477DC4400D277C1 /* NetworkDataStream.swift in Sources */,
B54D876F2490E4DD00C361A0 /* AudioRendererContext.swift in Sources */,
B55F77CF24D82ADE0057F431 /* AudioPlayerDelegate.swift in Sources */,
B55A736C247FCB420050C53D /* HTTPHeaderParser.swift in Sources */,
B55F77D124D82CD50057F431 /* AVAudioUnit+Convenience.swift in Sources */,
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 */,
98ABF69E2BAB07A20059C441 /* Mp4Restructure.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
B5AEDBB324744153007D8101 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
B5EF954E247DA5AC003E8FF8 /* NetworkingClientTests.swift in Sources */,
B59CB46C25420B4D00F8CAD0 /* MetadataStreamProcessorTests.swift in Sources */,
B51FE0C824892D1600F2A4D2 /* PlayerQueueEntriesTest.swift in Sources */,
B55CEABA248530C00001C498 /* MetadataParser.swift in Sources */,
B51FE0C22488F96A00F2A4D2 /* QueueTests.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;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
B5AEDBBA24744153007D8101 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = B5AEDBAD24744153007D8101 /* AudioStreaming */;
targetProxy = B5AEDBB924744153007D8101 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */
B5AEDBC024744153007D8101 /* 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;
CURRENT_PROJECT_VERSION = 1;
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.0;
MARKETING_VERSION = 1.1.0;
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";
VERSIONING_SYSTEM = "apple-generic";
VERSION_INFO_PREFIX = "";
};
name = Debug;
};
B5AEDBC124744153007D8101 /* 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;
CURRENT_PROJECT_VERSION = 1;
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.0;
MARKETING_VERSION = 1.1.0;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SDKROOT = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
VALIDATE_PRODUCT = YES;
VERSIONING_SYSTEM = "apple-generic";
VERSION_INFO_PREFIX = "";
};
name = Release;
};
B5AEDBC324744153007D8101 /* Debug */ = {
isa = XCBuildConfiguration;
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 = 13.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@loader_path/Frameworks",
);
MARKETING_VERSION = 1.2.1;
OTHER_LDFLAGS = "-ObjC";
PRODUCT_BUNDLE_IDENTIFIER = com.decimal.AudioStreaming;
PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)";
SKIP_INSTALL = YES;
SUPPORTS_MACCATALYST = NO;
SWIFT_OBJC_BRIDGING_HEADER = "";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
B5AEDBC424744153007D8101 /* Release */ = {
isa = XCBuildConfiguration;
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 = 13.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@loader_path/Frameworks",
);
MARKETING_VERSION = 1.2.1;
OTHER_LDFLAGS = "-ObjC";
PRODUCT_BUNDLE_IDENTIFIER = com.decimal.AudioStreaming;
PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)";
SKIP_INSTALL = YES;
SUPPORTS_MACCATALYST = NO;
SWIFT_OBJC_BRIDGING_HEADER = "";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
B5AEDBC624744153007D8101 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_STYLE = Automatic;
INFOPLIST_FILE = AudioStreamingTests/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@loader_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.decimal.AudioStreamingTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
B5AEDBC724744153007D8101 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_STYLE = Automatic;
INFOPLIST_FILE = AudioStreamingTests/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@loader_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.decimal.AudioStreamingTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
B5AEDBA824744153007D8101 /* Build configuration list for PBXProject "AudioStreaming" */ = {
isa = XCConfigurationList;
buildConfigurations = (
B5AEDBC024744153007D8101 /* Debug */,
B5AEDBC124744153007D8101 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
B5AEDBC224744153007D8101 /* Build configuration list for PBXNativeTarget "AudioStreaming" */ = {
isa = XCConfigurationList;
buildConfigurations = (
B5AEDBC324744153007D8101 /* Debug */,
B5AEDBC424744153007D8101 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
B5AEDBC524744153007D8101 /* Build configuration list for PBXNativeTarget "AudioStreamingTests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
B5AEDBC624744153007D8101 /* Debug */,
B5AEDBC724744153007D8101 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = B5AEDBA524744153007D8101 /* Project object */;
}
@@ -1,8 +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>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>
@@ -1,45 +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>classNames</key>
<dict>
<key>ProtectedTests</key>
<dict>
<key>testProtectedValuesAreAccessedSafely()</key>
<dict>
<key>com.apple.XCTPerformanceMetric_WallClockTime</key>
<dict>
<key>baselineAverage</key>
<real>4.7921</real>
<key>baselineIntegrationDisplayName</key>
<string>Local Baseline</string>
</dict>
</dict>
<key>testThatProtectedReadAndWriteAreSafe()</key>
<dict>
<key>com.apple.XCTPerformanceMetric_WallClockTime</key>
<dict>
<key>baselineAverage</key>
<real>0.0067462</real>
<key>baselineIntegrationDisplayName</key>
<string>Local Baseline</string>
</dict>
</dict>
</dict>
<key>QueueTests</key>
<dict>
<key>testComplexity()</key>
<dict>
<key>com.apple.XCTPerformanceMetric_WallClockTime</key>
<dict>
<key>baselineAverage</key>
<real>3.7871</real>
<key>baselineIntegrationDisplayName</key>
<string>Local Baseline</string>
</dict>
</dict>
</dict>
</dict>
</dict>
</plist>
@@ -1,103 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1200"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "B5AEDBAD24744153007D8101"
BuildableName = "AudioStreaming.framework"
BlueprintName = "AudioStreaming"
ReferencedContainer = "container:AudioStreaming.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
enableThreadSanitizer = "YES"
codeCoverageEnabled = "YES"
onlyGenerateCoverageForSpecifiedTargets = "YES">
<CodeCoverageTargets>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "B5AEDBAD24744153007D8101"
BuildableName = "AudioStreaming.framework"
BlueprintName = "AudioStreaming"
ReferencedContainer = "container:AudioStreaming.xcodeproj">
</BuildableReference>
</CodeCoverageTargets>
<Testables>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "B5AEDBB624744153007D8101"
BuildableName = "AudioStreamingTests.xctest"
BlueprintName = "AudioStreamingTests"
ReferencedContainer = "container:AudioStreaming.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
disableMainThreadChecker = "YES"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
enableGPUValidationMode = "1"
allowLocationSimulation = "YES">
<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">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "B5AEDBAD24744153007D8101"
BuildableName = "AudioStreaming.framework"
BlueprintName = "AudioStreaming"
ReferencedContainer = "container:AudioStreaming.xcodeproj">
</BuildableReference>
</MacroExpansion>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
@@ -1,32 +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>AudioStreaming.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>0</integer>
</dict>
</dict>
<key>SuppressBuildableAutocreation</key>
<dict>
<key>B5AEDBAD24744153007D8101</key>
<dict>
<key>primary</key>
<true/>
</dict>
<key>B5AEDBB624744153007D8101</key>
<dict>
<key>primary</key>
<true/>
</dict>
<key>B5B3B7D22486993B00656828</key>
<dict>
<key>primary</key>
<true/>
</dict>
</dict>
</dict>
</plist>
-10
View File
@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "group:AudioExample/AudioExample.xcodeproj">
</FileRef>
<FileRef
location = "container:AudioStreaming.xcodeproj">
</FileRef>
</Workspace>
@@ -1,8 +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>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>
@@ -1,18 +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>BuildLocationStyle</key>
<string>UseAppPreferences</string>
<key>CustomBuildLocationType</key>
<string>RelativeToDerivedData</string>
<key>DerivedDataLocationStyle</key>
<string>Default</string>
<key>IssueFilterStyle</key>
<string>ShowActiveSchemeOnly</string>
<key>LiveSourceIssuesEnabled</key>
<true/>
<key>ShowSharedSchemesAutomaticallyEnabled</key>
<true/>
</dict>
</plist>
@@ -1,74 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Bucket
uuid = "FECE436A-3E7C-4680-91FF-008D425B5539"
type = "0"
version = "2.0">
<Breakpoints>
<BreakpointProxy
BreakpointExtensionID = "Xcode.Breakpoint.SwiftErrorBreakpoint">
<BreakpointContent
uuid = "5041EB46-A677-4654-B257-5EE0996DB4C7"
shouldBeEnabled = "No"
ignoreCount = "0"
continueAfterRunningActions = "No">
</BreakpointContent>
</BreakpointProxy>
<BreakpointProxy
BreakpointExtensionID = "Xcode.Breakpoint.ExceptionBreakpoint">
<BreakpointContent
uuid = "556803C5-B0C9-4A2B-BE87-2D41721D1F54"
shouldBeEnabled = "Yes"
ignoreCount = "0"
continueAfterRunningActions = "No"
breakpointStackSelectionBehavior = "1"
scope = "1"
stopOnStyle = "0">
</BreakpointContent>
</BreakpointProxy>
<BreakpointProxy
BreakpointExtensionID = "Xcode.Breakpoint.RuntimeIssueBreakpoint">
<BreakpointContent
uuid = "F3650228-D513-48BC-AB78-8A8A0F688628"
shouldBeEnabled = "Yes"
ignoreCount = "0"
continueAfterRunningActions = "No"
breakpointStackSelectionBehavior = "1"
type = "1">
</BreakpointContent>
</BreakpointProxy>
<BreakpointProxy
BreakpointExtensionID = "Xcode.Breakpoint.SymbolicBreakpoint">
<BreakpointContent
uuid = "8E93EE2C-5F40-468B-9880-089C10EC6FE1"
shouldBeEnabled = "Yes"
ignoreCount = "0"
continueAfterRunningActions = "No"
symbolName = "malloc_error_break"
moduleName = "">
<Locations>
<Location
uuid = "8E93EE2C-5F40-468B-9880-089C10EC6FE1 - 9d096611641b4e7c"
shouldBeEnabled = "Yes"
ignoreCount = "0"
continueAfterRunningActions = "No"
symbolName = "malloc_error_break"
moduleName = "libsystem_malloc.dylib"
usesParentBreakpointCondition = "Yes"
offsetFromSymbolStart = "0">
</Location>
</Locations>
</BreakpointContent>
</BreakpointProxy>
<BreakpointProxy
BreakpointExtensionID = "Xcode.Breakpoint.RuntimeIssueBreakpoint">
<BreakpointContent
uuid = "0BEDF6EA-D92A-448C-A0E4-FBDADBF90794"
shouldBeEnabled = "Yes"
ignoreCount = "0"
continueAfterRunningActions = "No"
breakpointStackSelectionBehavior = "1"
type = "65535">
</BreakpointContent>
</BreakpointProxy>
</Breakpoints>
</Bucket>
@@ -5,7 +5,7 @@
import AVFoundation
public enum AudioConverterError: CustomDebugStringConvertible {
public enum AudioConverterError: CustomDebugStringConvertible, Sendable {
case badPropertySizeError
case formatNotSupported
case inputSampleRateOutOfRange
@@ -16,6 +16,7 @@ public enum AudioConverterError: CustomDebugStringConvertible {
case propertyNotSupported
case requiresPacketDescriptionsError
case unspecifiedError
case cannotCreateConverter
init(osstatus: OSStatus) {
switch osstatus {
@@ -65,7 +66,9 @@ public enum AudioConverterError: CustomDebugStringConvertible {
case .requiresPacketDescriptionsError:
return "Required packet descriptions (error)"
case .unspecifiedError:
return "Unspecified error "
return "Unspecified error"
case .cannotCreateConverter:
return "Cannot create audio converter"
}
}
}
@@ -29,7 +29,7 @@ func fileStreamGetPropertyInfo(fileStream streamId: AudioFileStreamID, propertyI
///
/// Reference:
/// [Audio File Stream Errors](https://developer.apple.com/documentation/audiotoolbox/1391572-audio_file_stream_errors?language=objc)
public enum AudioFileStreamError: CustomDebugStringConvertible {
public enum AudioFileStreamError: CustomDebugStringConvertible, Sendable {
case badPropertySize
case dataUnavailable
case discontinuityCantRecover
+76 -6
View File
@@ -4,6 +4,7 @@
//
import Foundation
import os
protocol Lock {
func lock()
@@ -14,24 +15,96 @@ protocol Lock {
// Execute a closure while acquiring a lock
func withLock(body: () -> Void)
func deallocate()
}
/// A wrapper for `os_unfair_lock`
/// - Tag: UnfairLock
final class UnfairLock: Lock {
@usableFromInline let unfairLock: UnsafeMutablePointer<os_unfair_lock>
var unfairLock: Lock
init() {
if #available(iOS 16.0, *), #available(macOS 13.0, *) {
unfairLock = OSStorageLock()
} else {
unfairLock = UnfairStorageLock()
}
}
deinit {
deallocate()
}
func deallocate() {
unfairLock.deallocate()
}
@inlinable
func withLock<Result>(body: () throws -> Result) rethrows -> Result {
try unfairLock.withLock(body: body)
}
@inlinable
func withLock(body: () -> Void) {
unfairLock.withLock(body: body)
}
@inlinable
func lock() {
unfairLock.lock()
}
@inlinable
func unlock() {
unfairLock.unlock()
}
}
@available(iOS 16.0, *)
@available(macOS 13, *)
private class OSStorageLock: Lock {
@usableFromInline
let osLock = OSAllocatedUnfairLock()
@inlinable
func lock() {
osLock.lock()
}
@inlinable
func unlock() {
osLock.unlock()
}
func withLock<Result>(body: () throws -> Result) rethrows -> Result {
try osLock.withLockUnchecked(body)
}
func withLock(body: () -> Void) {
osLock.withLockUnchecked(body)
}
func deallocate() {} // no-op
}
private class UnfairStorageLock: Lock {
@usableFromInline
let unfairLock: UnsafeMutablePointer<os_unfair_lock>
init() {
unfairLock = .allocate(capacity: 1)
unfairLock.initialize(to: os_unfair_lock())
}
deinit {
func deallocate() {
unfairLock.deinitialize(count: 1)
unfairLock.deallocate()
}
@inlinable
@inline(__always)
func withLock<Result>(body: () throws -> Result) rethrows -> Result {
os_unfair_lock_lock(unfairLock)
defer { os_unfair_lock_unlock(unfairLock) }
@@ -39,7 +112,6 @@ final class UnfairLock: Lock {
}
@inlinable
@inline(__always)
func withLock(body: () -> Void) {
os_unfair_lock_lock(unfairLock)
defer { os_unfair_lock_unlock(unfairLock) }
@@ -47,13 +119,11 @@ final class UnfairLock: Lock {
}
@inlinable
@inline(__always)
func lock() {
os_unfair_lock_lock(unfairLock)
}
@inlinable
@inline(__always)
func unlock() {
os_unfair_lock_unlock(unfairLock)
}
+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
@@ -0,0 +1,212 @@
import Foundation
import AudioCodecs
import AVFoundation
/// A simple decoder for Ogg Vorbis files using libvorbisfile
final class VorbisFileDecoder {
// Core properties
private var stream: VFStreamRef?
private var vf: VFFileRef?
// Audio format properties
private(set) var sampleRate: Int = 0
private(set) var channels: Int = 0
private(set) var durationSeconds: Double = -1
private(set) var totalPcmSamples: Int64 = -1
private(set) var nominalBitrate: Int = 0
private(set) var processingFormat: AVAudioFormat?
// Thread safety
private let decoderLock = NSLock()
// Silent frame generation
private var silentFrameBuffer: UnsafeMutablePointer<Float>?
private var silentFrameSize = 0
/// Create the stream buffer with specified capacity
/// - Parameter capacityBytes: Size of the ring buffer in bytes
func create(capacityBytes: Int) {
decoderLock.lock()
defer { decoderLock.unlock() }
stream = VFStreamCreate(capacityBytes)
}
/// Clean up resources
func destroy() {
decoderLock.lock()
defer { decoderLock.unlock() }
if let vf = vf { VFClear(vf) }
if let stream = stream { VFStreamDestroy(stream) }
vf = nil
stream = nil
if let silentFrameBuffer = silentFrameBuffer {
silentFrameBuffer.deallocate()
self.silentFrameBuffer = nil
}
}
deinit {
destroy()
}
/// Push data into the stream buffer
/// - Parameter data: The Ogg Vorbis data to decode
func push(_ data: Data) {
decoderLock.lock()
defer { decoderLock.unlock() }
data.withUnsafeBytes { rawBuf in
guard let base = rawBuf.baseAddress?.assumingMemoryBound(to: UInt8.self),
rawBuf.count > 0,
let stream = stream else { return }
VFStreamPush(stream, base, rawBuf.count)
}
}
/// Get the number of bytes currently available in the stream buffer
/// - Returns: Number of bytes available
func availableBytes() -> Int {
decoderLock.lock()
defer { decoderLock.unlock() }
guard let stream = stream else { return 0 }
return Int(VFStreamAvailableBytes(stream))
}
/// Mark the end of the stream
func markEOF() {
decoderLock.lock()
defer { decoderLock.unlock() }
if let stream = stream {
VFStreamMarkEOF(stream)
}
}
/// Try to open the Vorbis file if enough data is available
/// - Throws: Error if opening fails
func openIfNeeded() throws {
decoderLock.lock()
defer { decoderLock.unlock() }
guard vf == nil, let stream = stream else { return }
var outVF: VFFileRef?
let rc = VFOpen(stream, &outVF)
if rc < 0 {
Logger.error("Failed to open Vorbis file", category: .audioRendering)
throw NSError(domain: "VorbisFileDecoder", code: Int(rc),
userInfo: [NSLocalizedDescriptionKey: "Failed to open Vorbis file"])
}
vf = outVF
// Get stream info
var info = VFStreamInfo()
if VFGetInfo(outVF, &info) == 0 {
sampleRate = Int(info.sample_rate)
channels = Int(info.channels)
totalPcmSamples = Int64(info.total_pcm_samples)
durationSeconds = info.duration_seconds
nominalBitrate = Int(info.bitrate_nominal)
// Create audio format
let layoutTag: AudioChannelLayoutTag
switch channels {
case 1: layoutTag = kAudioChannelLayoutTag_Mono
case 2: layoutTag = kAudioChannelLayoutTag_Stereo
default: layoutTag = kAudioChannelLayoutTag_Unknown | UInt32(channels)
}
let channelLayout = AVAudioChannelLayout(layoutTag: layoutTag)!
processingFormat = AVAudioFormat(
commonFormat: .pcmFormatFloat32,
sampleRate: Double(sampleRate),
interleaved: false,
channelLayout: channelLayout
)
// Create silent frame buffer
silentFrameSize = 1024 * channels
silentFrameBuffer = UnsafeMutablePointer<Float>.allocate(capacity: silentFrameSize)
for i in 0..<silentFrameSize {
silentFrameBuffer?[i] = 0.0
}
} else {
Logger.error("Failed to get stream info", category: .audioRendering)
}
}
/// Read decoded frames into an AVAudioPCMBuffer
/// - Parameters:
/// - buffer: The buffer to fill with audio data
/// - frameCount: Maximum number of frames to read
/// - Returns: Number of frames read, 0 on EOF, negative on error
func readFrames(into buffer: AVAudioPCMBuffer, frameCount: Int) -> Int {
decoderLock.lock()
defer { decoderLock.unlock() }
guard let vf = vf, buffer.format.channelCount > 0 else {
return generateSilentFrames(into: buffer, frameCount: frameCount)
}
// Get float channel data from buffer
guard let floatChannelData = buffer.floatChannelData else {
return generateSilentFrames(into: buffer, frameCount: frameCount)
}
// Read deinterleaved frames directly
let maxFrames = min(frameCount, Int(buffer.frameCapacity))
var pcmChannels: UnsafeMutablePointer<UnsafeMutablePointer<Float>?>?
let framesRead = Int(VFReadFloat(vf, &pcmChannels, Int32(maxFrames)))
// If no frames were read, generate silent frames instead of returning 0
if framesRead <= 0 {
return generateSilentFrames(into: buffer, frameCount: frameCount)
}
// Copy deinterleaved data directly (no conversion needed!)
guard let pcm = pcmChannels else {
return generateSilentFrames(into: buffer, frameCount: frameCount)
}
let channelCount = min(Int(buffer.format.channelCount), channels)
for ch in 0..<channelCount {
guard let input = pcm[ch] else { continue }
let output = floatChannelData[ch]
memcpy(output, input, framesRead * MemoryLayout<Float>.stride)
}
return framesRead
}
/// Generate silent frames when no real audio data is available
/// This prevents EOF detection by never returning 0 frames
private func generateSilentFrames(into buffer: AVAudioPCMBuffer, frameCount: Int) -> Int {
guard let floatChannelData = buffer.floatChannelData,
channels > 0 else { return 1 }
// Use a small frame count to ensure we keep checking for real data
let framesToGenerate = min(128, frameCount)
// Fill buffer with zeros
for ch in 0..<min(Int(buffer.format.channelCount), channels) {
let dst = floatChannelData[ch]
for frame in 0..<framesToGenerate {
dst[frame] = 0.0
}
}
return framesToGenerate
}
/// Reset the decoder state
func reset() {
destroy()
}
}
@@ -37,6 +37,11 @@ class AudioEntry {
return seekTime + (Double(framesState.played) / outputAudioFormat.sampleRate)
}
var framesPlayed: Int {
lock.lock(); defer { lock.unlock() }
return framesState.played
}
var audioStreamFormat = AudioStreamBasicDescription()
/// Hold the seek time, if a seek was requested
@@ -103,6 +108,9 @@ class AudioEntry {
func calculatedBitrate() -> Double {
lock.lock(); defer { lock.unlock() }
if let explicitBitRate = audioStreamState.bitRate, explicitBitRate > 0 {
return explicitBitRate
}
let packets = processedPacketsState
if packetDuration > 0 {
let packetsCount = packets.count
@@ -121,15 +129,21 @@ class AudioEntry {
}
func duration() -> Double {
guard sampleRate > 0 else { return 0 }
lock.lock()
guard sampleRate > 0 else {
lock.unlock()
return 0
}
if let audioDataPacketOffset = audioStreamState.dataPacketOffset {
let framesPerPacket = UInt64(audioStreamFormat.mFramesPerPacket)
if audioDataPacketOffset > 0, framesPerPacket > 0 {
return Double(audioDataPacketOffset * framesPerPacket) / audioStreamFormat.mSampleRate
let duration = Double(audioDataPacketOffset * framesPerPacket) / audioStreamFormat.mSampleRate
lock.unlock()
return duration
}
}
lock.unlock()
let calculatedBitrate = self.calculatedBitrate()
if calculatedBitrate < 1.0 || source.length == 0 {
return 0
@@ -6,6 +6,7 @@
import AVFoundation
protocol AudioEntryProviding {
func provideAudioEntry(url: URL, httpMethod: String?, httpBody: Data?, headers: [String: String]) -> AudioEntry
func provideAudioEntry(url: URL, headers: [String: String]) -> AudioEntry
func provideAudioEntry(url: URL) -> AudioEntry
}
@@ -25,7 +26,14 @@ final class AudioEntryProvider: AudioEntryProviding {
}
func provideAudioEntry(url: URL, headers: [String: String]) -> AudioEntry {
let source = self.source(for: url, headers: headers)
let source = self.source(for: url, httpMethod: nil, httpBody: nil, headers: headers)
return AudioEntry(source: source,
entryId: AudioEntryId(id: url.absoluteString),
outputAudioFormat: outputAudioFormat)
}
func provideAudioEntry(url: URL, httpMethod: String?, httpBody: Data?, headers: [String: String]) -> AudioEntry {
let source = self.source(for: url, httpMethod: httpMethod, httpBody: httpBody, headers: headers)
return AudioEntry(source: source,
entryId: AudioEntryId(id: url.absoluteString),
outputAudioFormat: outputAudioFormat)
@@ -34,10 +42,12 @@ final class AudioEntryProvider: AudioEntryProviding {
func provideAudioEntry(url: URL) -> AudioEntry {
provideAudioEntry(url: url, headers: [:])
}
func provideAudioSource(url: URL, headers: [String: String]) -> AudioStreamSource {
func provideAudioSource(url: URL, httpMethod: String?, httpBody: Data?, headers: [String: String]) -> AudioStreamSource {
RemoteAudioSource(networking: networkingClient,
url: url,
httpMethod: httpMethod,
httpBody: httpBody,
underlyingQueue: underlyingQueue,
httpHeaders: headers)
}
@@ -46,10 +56,10 @@ final class AudioEntryProvider: AudioEntryProviding {
FileAudioSource(url: url, underlyingQueue: underlyingQueue)
}
func source(for url: URL, headers: [String: String]) -> CoreAudioStreamSource {
func source(for url: URL, httpMethod: String?, httpBody: Data?, headers: [String: String]) -> CoreAudioStreamSource {
guard !url.isFileURL else {
return provideFileAudioSource(url: url)
}
return provideAudioSource(url: url, headers: headers)
return provideAudioSource(url: url, httpMethod: httpMethod, httpBody: httpBody, headers: headers)
}
}
@@ -5,6 +5,27 @@
import AVFoundation
struct OggVorbisStreamInfo {
var serialNumber: UInt32 = 0
var pageCount: UInt64 = 0
var totalSamples: UInt64 = 0
var sampleRate: UInt32 = 0
var channels: UInt8 = 0
var bitRate: UInt32 = 0
var nominalBitrate: UInt32 = 0
var minBitrate: UInt32 = 0
var maxBitrate: UInt32 = 0
var blocksize0: Int = 0
var blocksize1: Int = 0
var commentHeader: [String: String] = [:]
// For seeking
var granulePosition: Int64 = 0
var pageOffsets: [Int64] = []
var pageGranules: [Int64] = []
}
final class AudioStreamState {
var processedDataFormat: Bool = false
var dataOffset: UInt64 = 0
@@ -12,4 +33,13 @@ final class AudioStreamState {
var dataPacketOffset: UInt64?
var dataPacketCount: Double = 0
var streamFormat = AudioStreamBasicDescription()
var bitRate: Double?
// Flag to indicate when the audio format is ready for decoding
var readyForDecoding: Bool = false
// Add Ogg Vorbis-specific metadata
var oggVorbisStreamInfo: OggVorbisStreamInfo?
var hasAttemptedOggVorbisParse: Bool = false
var initialOggBytes: Data?
}
@@ -6,7 +6,7 @@
import AudioToolbox
import Foundation
protocol AudioStreamSourceDelegate: AnyObject {
public protocol AudioStreamSourceDelegate: AnyObject {
/// Indicates that there's data available
func dataAvailable(source: CoreAudioStreamSource, data: Data)
/// Indicates an error occurred
@@ -17,7 +17,7 @@ protocol AudioStreamSourceDelegate: AnyObject {
func metadataReceived(data: [String: String])
}
protocol CoreAudioStreamSource: AnyObject {
public protocol CoreAudioStreamSource: AnyObject {
/// An `Int` that represents the position of the audio
var position: Int { get }
/// The length of the audio in bytes
@@ -120,11 +120,15 @@ final class FileAudioSource: NSObject, CoreAudioStreamSource {
if isMp4, !mp4IsAlreadyOptimized {
if !mp4Restructure.dataOptimized {
do {
if let mp4OptimizeInfo = try mp4Restructure.checkIsOptimized(data: data) {
try performMp4Restructure(inputStream: inputStream, mp4OptimizeInfo: mp4OptimizeInfo)
} else {
switch try mp4Restructure.checkIsOptimized(data: data) {
case .undetermined:
// Not enough bytes yet; wait for more data before deciding
break
case .optimized:
mp4IsAlreadyOptimized = true
delegate?.dataAvailable(source: self, data: data)
case let .needsRestructure(moovOffset):
try performMp4Restructure(inputStream: inputStream, moovOffset: moovOffset)
}
} catch {
delegate?.errorOccurred(source: self, error: error)
@@ -141,24 +145,71 @@ final class FileAudioSource: NSObject, CoreAudioStreamSource {
}
}
func performMp4Restructure(inputStream: InputStream, mp4OptimizeInfo: Mp4OptimizeInfo) throws {
let offsetAccepted = inputStream.setProperty(mp4OptimizeInfo.moovOffset, forKey: .fileCurrentOffsetKey)
if offsetAccepted {
let moovDataBuffer = UnsafeMutablePointer.uint8pointer(of: mp4OptimizeInfo.moovSize)
defer { moovDataBuffer.deallocate() }
let moovRead = inputStream.read(moovDataBuffer, maxLength: mp4OptimizeInfo.moovSize)
if moovRead > 0 {
let data = Data(bytes: moovDataBuffer, count: moovRead)
let moovData = try mp4Restructure.restructureMoov(data: data)
delegate?.dataAvailable(source: self, data: moovData.initialData)
if !inputStream.setProperty(moovData.mdatOffset, forKey: .fileCurrentOffsetKey) {
delegate?.errorOccurred(source: self, error: AudioSystemError.playerStartError)
}
} else {
delegate?.errorOccurred(source: self, error: AudioSystemError.playerStartError)
}
} else {
func performMp4Restructure(inputStream: InputStream, moovOffset: Int) throws {
let offsetAccepted = inputStream.setProperty(moovOffset, forKey: .fileCurrentOffsetKey)
if !offsetAccepted {
delegate?.errorOccurred(source: self, error: inputStream.streamError ?? AudioSystemError.playerStartError)
return
}
// Read moov header (8 bytes)
var header = [UInt8](repeating: 0, count: 8)
let headerRead = inputStream.read(&header, maxLength: 8)
guard headerRead == 8 else {
delegate?.errorOccurred(source: self, error: AudioSystemError.playerStartError)
return
}
// Parse size and type (big endian)
let size32 = Data(header[0 ..< 4]).withUnsafeBytes { $0.load(as: UInt32.self) }.bigEndian
let type32 = Data(header[4 ..< 8]).withUnsafeBytes { $0.load(as: UInt32.self) }.bigEndian
guard Int(type32) == Atoms.moov else {
delegate?.errorOccurred(source: self, error: Mp4RestructureError.missingMoovAtom)
return
}
var moovSize = Int(size32)
var moovData = Data(header)
// Extended size (64-bit)
if moovSize == 1 {
var ext = [UInt8](repeating: 0, count: 8)
let extRead = inputStream.read(&ext, maxLength: 8)
guard extRead == 8 else {
delegate?.errorOccurred(source: self, error: AudioSystemError.playerStartError)
return
}
let ext64 = Data(ext).withUnsafeBytes { $0.load(as: UInt64.self) }.bigEndian
moovSize = Int(ext64)
moovData.append(contentsOf: ext)
}
let remaining = moovSize - moovData.count
if remaining < 0 {
delegate?.errorOccurred(source: self, error: AudioSystemError.playerStartError)
return
}
if remaining > 0 {
var buffer = [UInt8](repeating: 0, count: remaining)
var total = 0
while total < remaining {
let readBytes = buffer.withUnsafeMutableBytes { ptr -> Int in
let base = ptr.baseAddress!.assumingMemoryBound(to: UInt8.self).advanced(by: total)
return inputStream.read(base, maxLength: remaining - total)
}
guard readBytes > 0 else {
delegate?.errorOccurred(source: self, error: AudioSystemError.playerStartError)
return
}
total += readBytes
}
moovData.append(contentsOf: buffer)
}
let moovResult = try mp4Restructure.restructureMoov(data: moovData)
delegate?.dataAvailable(source: self, data: moovResult.initialData)
if !inputStream.setProperty(moovResult.mdatOffset, forKey: .fileCurrentOffsetKey) {
delegate?.errorOccurred(source: self, error: AudioSystemError.playerStartError)
}
}
@@ -36,7 +36,7 @@ enum Atoms {
static var cmov: Int { fourCcToInt("cmov") }
static var stco: Int { fourCcToInt("stco") }
static var co64: Int { fourCcToInt("c064") }
static var co64: Int { fourCcToInt("co64") }
static var atomPreampleSize: Int = 8
@@ -75,6 +75,12 @@ enum Mp4RestructureError: Error {
case networkError(Error)
}
enum OptimizeCheckResult: Equatable {
case optimized
case needsRestructure(moovOffset: Int)
case undetermined
}
final class Mp4Restructure {
private var atomOffset: Int = 0
@@ -129,24 +135,36 @@ final class Mp4Restructure {
return (initialData, mdatOffset)
}
/// Returns `nil` if the data is optimized otherwise `Mp4OptimizeInfo`
func checkIsOptimized(data: Data) throws -> Mp4OptimizeInfo? {
while atomOffset < UInt64(data.count) {
var atomSize = try Int(getInteger(data: data, offset: atomOffset) as UInt32)
let atomType = try Int(getInteger(data: data, offset: atomOffset + 4) as UInt32)
/// Incrementally checks if the MP4 is optimized. Returns tri-state result.
func checkIsOptimized(data: Data) throws -> OptimizeCheckResult {
while atomOffset + 8 <= data.count {
var atomSize: Int = try Int(getInteger(data: data, offset: atomOffset) as UInt32)
let atomType: Int = try Int(getInteger(data: data, offset: atomOffset + 4) as UInt32)
var headerSize = 8
// Handle extended size (64-bit)
if atomSize == 1 {
if atomOffset + 16 > data.count { break }
let ext: UInt64 = try getInteger(data: data, offset: atomOffset + 8)
atomSize = Int(ext)
headerSize = 16
} else if atomSize == 0 {
// Size extends to EOF; with partial data we can't determine full box
break
}
// Bounds and sanity checks
if atomSize < headerSize || atomOffset + atomSize > data.count { break }
switch atomType {
case Atoms.ftyp:
let ftypData = data[Int(atomOffset) ..< atomSize]
let start = atomOffset
let end = atomOffset + atomSize
let ftypData = data[start ..< end]
let ftyp = MP4Atom(type: atomType, size: atomSize, offset: atomOffset, data: ftypData)
self.ftyp = ftyp
atoms.append(ftyp)
case Atoms.mdat:
// ref: https://developer.apple.com/documentation/quicktime-file-format/movie_data_atom
// This atom can be quite large, and may exceed 2^32 bytes, in which case the size field will be set to 1,
// and the header will contain a 64-bit extended size field.
if atomSize == 1 {
atomSize = Int(try getInteger(data: data, offset: atomOffset + 8) as UInt64)
}
let mdat = MP4Atom(type: atomType, size: atomSize, offset: atomOffset)
atoms.append(mdat)
foundMdat = true
@@ -158,19 +176,21 @@ final class Mp4Restructure {
let atom = MP4Atom(type: atomType, size: atomSize, offset: atomOffset)
atoms.append(atom)
}
if ftyp != nil {
if foundMoov && !foundMdat {
Logger.debug("🕵️ detected an optimized mp4", category: .generic)
return nil
return .optimized
} else if !foundMoov && foundMdat {
Logger.debug("🕵️ detected an non-optimized mp4", category: .generic)
let possibleMoovOffset = Int(atomOffset) + atomSize
return Mp4OptimizeInfo(moovOffset: possibleMoovOffset, moovSize: atomSize)
Logger.debug("🕵️ detected a non-optimized mp4", category: .generic)
let possibleMoovOffset = atomOffset + atomSize
return .needsRestructure(moovOffset: possibleMoovOffset)
}
}
atomOffset += atomSize
}
return nil
return .undetermined
}
/// logic taken from qt-faststart.c over at ffmpeg
@@ -236,6 +256,8 @@ final class Mp4Restructure {
// the next integer determines the `Number of entries`
// https://developer.apple.com/documentation/quicktime-file-format/chunk_offset_atom/number_of_entries
let numberOfOffsetEntries = try Int(moovAtom.getInteger() as UInt32)
// Adjust by moov size
let adjustDelta = moovAtomSize
if atomType == Atoms.stco {
Logger.debug("🏗️ patching stco atom...", category: .generic)
if moovAtom.bytesAvailable < numberOfOffsetEntries * 4 {
@@ -246,7 +268,7 @@ final class Mp4Restructure {
for _ in 0 ..< numberOfOffsetEntries {
let currentOffset = try Int(moovAtom.getInteger(moovAtom.offset) as UInt32)
// adjust the offset by adding the size of moov atom
let adjustOffset = currentOffset + moovAtomSize
let adjustOffset = currentOffset + adjustDelta
if currentOffset < 0, adjustOffset >= 0 {
throw Mp4RestructureError.unableToRestructureData
@@ -261,8 +283,8 @@ final class Mp4Restructure {
}
for _ in 0 ..< numberOfOffsetEntries {
let currentOffset: Int = try moovAtom.getInteger(moovAtom.offset)
// adjust the offset by adding the size of moov atom
moovAtom.put(currentOffset + moovAtomSize)
// adjust the offset by adding the size of moov atom (write as big-endian 64-bit)
moovAtom.put(UInt64(currentOffset + adjustDelta).bigEndian)
}
}
}
@@ -271,10 +293,10 @@ final class Mp4Restructure {
func getInteger<T: FixedWidthInteger>(data: Data, offset: Int) throws -> T {
let sizeOfInteger = MemoryLayout<T>.size
guard sizeOfInteger <= data.count else {
guard offset >= 0, offset + sizeOfInteger <= data.count else {
throw ByteBuffer.Error.eof
}
let _offset = offset + sizeOfInteger
return T(data: data[_offset - sizeOfInteger ..< _offset]).bigEndian
let end = offset + sizeOfInteger
return T(data: data[offset ..< end]).bigEndian
}
}
@@ -75,8 +75,15 @@ final class RemoteMp4Restructure {
}
self.audioData.append(data)
do {
let value = try self.mp4Restructure.checkIsOptimized(data: self.audioData)
if let value {
switch try self.mp4Restructure.checkIsOptimized(data: self.audioData) {
case .undetermined:
break // keep streaming until decision can be made
case .optimized:
self.audioData = Data()
self.task?.cancel()
self.task = nil
completion(.success(nil))
case let .needsRestructure(moovOffset):
guard response.response?.statusCode == 206 else {
Logger.error("⛔️ mp4 error: no moov before mdat and the stream is not seekable", category: .networking)
completion(.failure(Mp4RestructureError.nonOptimizedMp4AndServerCannotSeek))
@@ -86,22 +93,15 @@ final class RemoteMp4Restructure {
self.audioData = Data()
self.task?.cancel()
self.task = nil
self.fetchAndRestructureMoovAtom(offset: value.moovOffset) { result in
self.fetchAndRestructureMoovAtom(offset: moovOffset) { result in
switch result {
case let .success(value):
let data = value.data
let offset = value.offset
self.dataOptimized = true
completion(.success(RestructuredData(initialData: data, mdatOffset: offset)))
completion(.success(RestructuredData(initialData: value.data, mdatOffset: value.offset)))
case let .failure(error):
completion(.failure(Mp4RestructureError.networkError(error)))
}
}
} else {
self.audioData = Data()
self.task?.cancel()
self.task = nil
completion(.success(nil))
}
} catch {
completion(.failure(Mp4RestructureError.invalidAtomSize))
@@ -132,6 +132,8 @@ final class RemoteMp4Restructure {
}
}
// removed warmup range helper
private func urlForPartialContent(with url: URL, offset: Int) -> URLRequest {
var urlRequest = URLRequest(url: url)
urlRequest.networkServiceType = .avStreaming
@@ -13,18 +13,20 @@ enum RemoteAudioSourceError: Error {
}
public class RemoteAudioSource: AudioStreamSource {
weak var delegate: AudioStreamSourceDelegate?
public weak var delegate: AudioStreamSourceDelegate?
var position: Int {
public var position: Int {
return seekOffset + relativePosition
}
var length: Int {
public var length: Int {
guard let parsedHeader = parsedHeaderOutput else { return 0 }
return parsedHeader.fileLength
}
private let url: URL
private let httpMethod: String?
private let httpBody: Data?
private let networkingClient: NetworkingClient
private var streamRequest: NetworkDataStream?
@@ -40,7 +42,7 @@ public class RemoteAudioSource: AudioStreamSource {
private var shouldTryParsingIcycastHeaders: Bool = false
private let icycastHeadersProcessor: IcycastHeadersProcessor
var audioFileHint: AudioFileTypeID {
public var audioFileHint: AudioFileTypeID {
guard let output = parsedHeaderOutput, output.typeId != 0 else {
return audioFileType(fileExtension: url.pathExtension)
}
@@ -49,7 +51,7 @@ public class RemoteAudioSource: AudioStreamSource {
private let mp4Restructure: RemoteMp4Restructure
let underlyingQueue: DispatchQueue
public let underlyingQueue: DispatchQueue
let streamOperationQueue: OperationQueue
let netStatusService: NetStatusProvider
var waitingForNetwork = false
@@ -61,12 +63,16 @@ public class RemoteAudioSource: AudioStreamSource {
netStatusProvider: NetStatusProvider,
retrier: Retrier,
url: URL,
httpMethod: String?,
httpBody: Data?,
underlyingQueue: DispatchQueue,
httpHeaders: [String: String])
{
networkingClient = networking
metadataStreamProcessor = metadataStreamSource
self.url = url
self.httpMethod = httpMethod
self.httpBody = httpBody
additionalRequestHeaders = httpHeaders
relativePosition = 0
seekOffset = 0
@@ -83,9 +89,11 @@ public class RemoteAudioSource: AudioStreamSource {
mp4Restructure = RemoteMp4Restructure(url: url, networking: networkingClient)
startNetworkService()
}
convenience init(networking: NetworkingClient,
url: URL,
httpMethod: String?,
httpBody: Data?,
underlyingQueue: DispatchQueue,
httpHeaders: [String: String])
{
@@ -100,6 +108,21 @@ public class RemoteAudioSource: AudioStreamSource {
netStatusProvider: netStatusProvider,
retrier: retrierTimeout,
url: url,
httpMethod: httpMethod,
httpBody: httpBody,
underlyingQueue: underlyingQueue,
httpHeaders: httpHeaders)
}
convenience init(networking: NetworkingClient,
url: URL,
underlyingQueue: DispatchQueue,
httpHeaders: [String: String])
{
self.init(networking: networking,
url: url,
httpMethod: nil,
httpBody: nil,
underlyingQueue: underlyingQueue,
httpHeaders: httpHeaders)
}
@@ -114,7 +137,7 @@ public class RemoteAudioSource: AudioStreamSource {
httpHeaders: [:])
}
func close() {
public func close() {
retrierTimeout.cancel()
streamOperationQueue.isSuspended = false
streamOperationQueue.cancelAllOperations()
@@ -125,7 +148,7 @@ public class RemoteAudioSource: AudioStreamSource {
streamRequest = nil
}
func seek(at offset: Int) {
public func seek(at offset: Int) {
close()
relativePosition = 0
@@ -144,11 +167,11 @@ public class RemoteAudioSource: AudioStreamSource {
performOpen(seek: offset)
}
func suspend() {
public func suspend() {
streamOperationQueue.isSuspended = true
}
func resume() {
public func resume() {
streamOperationQueue.isSuspended = false
}
@@ -347,6 +370,8 @@ public class RemoteAudioSource: AudioStreamSource {
urlRequest.networkServiceType = .avStreaming
urlRequest.cachePolicy = .reloadIgnoringLocalCacheData
urlRequest.timeoutInterval = 60
urlRequest.httpMethod = httpMethod
urlRequest.httpBody = httpBody
for header in additionalRequestHeaders {
urlRequest.addValue(header.value, forHTTPHeaderField: header.key)
@@ -366,6 +391,8 @@ public class RemoteAudioSource: AudioStreamSource {
urlRequest.networkServiceType = .avStreaming
urlRequest.cachePolicy = .reloadIgnoringLocalCacheData
urlRequest.timeoutInterval = 60
urlRequest.httpMethod = httpMethod
urlRequest.httpBody = httpBody
for header in additionalRequestHeaders {
urlRequest.addValue(header.value, forHTTPHeaderField: header.key)
@@ -31,7 +31,10 @@ open class AudioPlayer {
/// result in the audio being exhausted before it could fetch new data.
public var rate: Float {
get { rateNode.rate }
set { rateNode.rate = newValue }
set {
rateNode.rate = newValue
rateNode.bypass = (newValue == 1.0)
}
}
/// The player's current state.
@@ -81,6 +84,41 @@ open class AudioPlayer {
return entry.progress
}
/// The number of audio frames that have been played
public var framesPlayed: Int {
guard playerContext.internalState != .pendingNext else { return 0 }
playerContext.entriesLock.lock()
let playingEntry = playerContext.audioPlayingEntry
playerContext.entriesLock.unlock()
guard let entry = playingEntry else { return 0 }
return entry.framesPlayed
}
/// Indicates whether seeking is supported for the currently playing audio
///
/// Returns `false` if:
/// - The audio format doesn't support seeking (e.g., Ogg Vorbis streams)
/// - The stream has no valid duration (e.g., live radio streams)
///
/// Use this property to enable/disable seek controls in your UI
public var isSeekable: Bool {
guard playerContext.internalState != .pendingNext else { return true }
playerContext.entriesLock.lock()
let playingEntry = playerContext.audioPlayingEntry
playerContext.entriesLock.unlock()
guard let entry = playingEntry else { return true }
// Check if format supports seeking (Ogg Vorbis doesn't)
if entry.audioFileHint == kAudioFileOggType {
return false
}
// Check if stream has a valid duration (live streams don't)
let entryDuration = entry.duration()
return entryDuration > 0
}
public private(set) var customAttachedNodes = [AVAudioNode]()
/// The current configuration of the player.
@@ -124,7 +162,7 @@ open class AudioPlayer {
private let frameFilterProcessor: FrameFilterProcessor
private let serializationQueue: DispatchQueue
private let sourceQueue: DispatchQueue
public let sourceQueue: DispatchQueue
private let entryProvider: AudioEntryProviding
@@ -166,6 +204,7 @@ open class AudioPlayer {
)
configPlayerContext()
configPlayerNode()
configureRateNode()
setupEngine()
}
@@ -190,6 +229,31 @@ open class AudioPlayer {
/// - parameter headers: A `Dictionary` specifying any additional headers to be pass to the network request.
public func play(url: URL, headers: [String: String]) {
let audioEntry = entryProvider.provideAudioEntry(url: url, headers: headers)
play(audioEntry: audioEntry)
}
/// Starts the audio playback for the given URL
///
/// - parameter url: A `URL` specifying the audio context to be played.
/// - parameter httpMethod: A `String` specifying the HTTP method to use (e.g. "GET", "POST").
/// - parameter httpBody: A "Data" specifying the HTTP request body, if any.
/// - parameter headers: A `Dictionary` specifying any additional headers to be pass to the network request.
public func play(url: URL, httpMethod: String?, httpBody: Data?, headers: [String: String]) {
let audioEntry = entryProvider.provideAudioEntry(url: url, httpMethod: httpMethod, httpBody: httpBody, headers: headers)
play(audioEntry: audioEntry)
}
/// Starts the audio playback for the supplied stream
///
/// - parameter source: A `CoreAudioStreamSource` that will providing streaming data
/// - parameter entryId: A `String` that provides a unique id for this item
/// - parameter format: An `AVAudioFormat` the format of this audio source
public func play(source: CoreAudioStreamSource, entryId: String, format: AVAudioFormat) {
let audioEntry = AudioEntry(source: source, entryId: AudioEntryId(id: entryId), outputAudioFormat: format)
play(audioEntry: audioEntry)
}
private func play(audioEntry: AudioEntry) {
audioEntry.delegate = self
checkRenderWaitingAndNotifyIfNeeded()
@@ -210,6 +274,25 @@ open class AudioPlayer {
}
}
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()
}
}
/// Queues the specified URL
///
/// - Parameter url: A `URL` specifying the audio content to be played.
@@ -224,15 +307,29 @@ open class AudioPlayer {
queue(urls: urls, headers: [:])
}
/// Queues the specified URL
public func queue(url: URL, after afterUrl: URL) {
queue(url: url, headers: [:], after: afterUrl)
}
/// Queues the specified audio stream
///
/// - Parameter url: A `URL` specifying the audio content to be played.
/// - parameter headers: A `Dictionary` specifying any additional headers to be pass to the network request.
public func queue(url: URL, headers: [String: String]) {
/// - parameter source: A `CoreAudioStreamSource` that will providing streaming data
/// - parameter entryId: A `String` that provides a unique id for this item
/// - parameter format: An `AVAudioFormat` the format of this audio source
public func queue(source: CoreAudioStreamSource, entryId: String, format: AVAudioFormat) {
let audioEntry = AudioEntry(source: source, entryId: AudioEntryId(id: entryId), outputAudioFormat: format)
queue(audioEntry: audioEntry)
}
public func removeFromQueue(url: URL) {
serializationQueue.sync {
let audioEntry = entryProvider.provideAudioEntry(url: url, headers: headers)
audioEntry.delegate = self
entriesQueue.enqueue(item: audioEntry, type: .upcoming)
if let item = entriesQueue.items(type: .upcoming).first(where: { $0.id.id == url.absoluteString }) {
entriesQueue.remove(item: item, type: .upcoming)
if playerContext.audioPlayingEntry?.id.id == item.id.id {
stop(clearQueue: false)
}
}
}
checkRenderWaitingAndNotifyIfNeeded()
sourceQueue.async { [weak self] in
@@ -240,6 +337,15 @@ open class AudioPlayer {
}
}
/// Queues the specified URL
///
/// - Parameter url: A `URL` specifying the audio content to be played.
/// - parameter headers: A `Dictionary` specifying any additional headers to be pass to the network request.
public func queue(url: URL, headers: [String: String], after afterUrl: URL? = nil) {
let audioEntry = entryProvider.provideAudioEntry(url: url, headers: headers)
queue(audioEntry: audioEntry, after: afterUrl)
}
/// Queues the specified URLs
///
/// - Parameter url: A array of `URL`s specifying the audio content to be played.
@@ -258,8 +364,25 @@ open class AudioPlayer {
}
}
private func queue(audioEntry: AudioEntry, after afterUrl: URL? = nil) {
serializationQueue.sync {
audioEntry.delegate = self
if let afterUrl = afterUrl {
if let afterUrlEntry = entriesQueue.items(type: .upcoming).first(where: { $0.id.id == afterUrl.absoluteString }) {
entriesQueue.insert(item: audioEntry, type: .upcoming, after: afterUrlEntry)
}
} else {
entriesQueue.enqueue(item: audioEntry, type: .upcoming)
}
}
checkRenderWaitingAndNotifyIfNeeded()
sourceQueue.async { [weak self] in
self?.processSource()
}
}
/// Stops the audio playback
public func stop() {
public func stop(clearQueue: Bool = true) {
guard playerContext.internalState != .stopped else { return }
serializationQueue.sync {
@@ -274,7 +397,9 @@ open 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
@@ -414,6 +539,16 @@ open class AudioPlayer {
}
}
// Add this new method
private func configureRateNode() {
// Set overlap to a lower value for faster transitions (default is 8.0)
rateNode.overlap = 4.0
// Ensure pitch is not shifted
rateNode.pitch = 0
// Bypass by default since rate starts at 1.0
rateNode.bypass = true
}
/// Creates and configures an `AVAudioUnit` with an output configuration
/// and assigns it to the `player` variable.
private func configPlayerNode() {
@@ -555,18 +690,22 @@ open class AudioPlayer {
guard playerContext.internalState != .paused else { return }
let snapshot = playerContext.entriesLock.withLock {
(reading: playerContext.audioReadingEntry, playing: playerContext.audioPlayingEntry)
}
if playerContext.internalState == .pendingNext {
let entry = entriesQueue.dequeue(type: .upcoming)
playerContext.setInternalState(to: .waitingForData)
setCurrentReading(entry: entry, startPlaying: true, shouldClearQueue: true)
rendererContext.resetBuffers()
} else if let playingEntry = playerContext.audioPlayingEntry,
} else if let playingEntry = snapshot.playing,
playingEntry.seekRequest.requested,
playingEntry != playerContext.audioReadingEntry
playingEntry != snapshot.reading
{
playingEntry.audioStreamState.processedDataFormat = false
playingEntry.reset()
if let readingEntry = playerContext.audioReadingEntry {
if let readingEntry = snapshot.reading {
readingEntry.delegate = nil
readingEntry.close()
}
@@ -581,20 +720,20 @@ open class AudioPlayer {
setCurrentReading(entry: playingEntry, startPlaying: true, shouldClearQueue: false)
}
} else if playerContext.audioReadingEntry == nil {
} else if snapshot.reading == nil {
if entriesQueue.count(for: .upcoming) > 0 {
let entry = entriesQueue.dequeue(type: .upcoming)
let shouldStartPlaying = playerContext.audioPlayingEntry == nil
let shouldStartPlaying = snapshot.playing == nil
playerContext.setInternalState(to: .waitingForData)
setCurrentReading(entry: entry, startPlaying: shouldStartPlaying, shouldClearQueue: false)
} else if playerContext.audioPlayingEntry == nil {
} else if snapshot.playing == nil {
if playerContext.internalState != .stopped {
stopEngine(reason: .eof)
}
}
}
if let playingEntry = playerContext.audioPlayingEntry,
if let playingEntry = snapshot.playing,
playingEntry.audioStreamState.processedDataFormat,
playingEntry.calculatedBitrate() > 0.0
{
@@ -758,7 +897,7 @@ open class AudioPlayer {
}
extension AudioPlayer: AudioStreamSourceDelegate {
func dataAvailable(source: CoreAudioStreamSource, data: Data) {
public func dataAvailable(source: CoreAudioStreamSource, data: Data) {
guard let readingEntry = playerContext.audioReadingEntry, readingEntry.has(same: source) else {
return
}
@@ -788,12 +927,12 @@ extension AudioPlayer: AudioStreamSourceDelegate {
}
}
func errorOccurred(source: CoreAudioStreamSource, error: Error) {
public func errorOccurred(source: CoreAudioStreamSource, error: Error) {
guard let entry = playerContext.audioReadingEntry, entry.has(same: source) else { return }
raiseUnexpected(error: .networkError(.failure(error)))
}
func endOfFileOccurred(source: CoreAudioStreamSource) {
public func endOfFileOccurred(source: CoreAudioStreamSource) {
let hasSameSource = playerContext.audioReadingEntry?.has(same: source) ?? false
guard playerContext.audioReadingEntry == nil || hasSameSource else {
source.delegate = nil
@@ -830,7 +969,7 @@ extension AudioPlayer: AudioStreamSourceDelegate {
}
}
func metadataReceived(data: [String: String]) {
public func metadataReceived(data: [String: String]) {
asyncOnMain { [weak self] in
guard let self = self else { return }
self.delegate?.audioPlayerDidReadMetadata(player: self, metadata: data)
@@ -22,11 +22,11 @@ public struct AudioPlayerConfiguration: Equatable {
/// 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,
secondsRequiredToStartPlayingAfterBufferUnderrun: 1,
secondsRequiredToStartPlayingAfterBufferUnderrun: 7,
enableLogs: false)
/// Initializes the configuration for the `AudioPlayer`
///
@@ -13,11 +13,11 @@ extension AudioPlayer {
static let initial = InternalState([])
static let running = InternalState(rawValue: 1)
static let playing = InternalState(rawValue: 1 << 1 | InternalState.running.rawValue)
static let rebuffering = InternalState(rawValue: 1 << 2 | InternalState.running.rawValue)
static let waitingForData = InternalState(rawValue: 1 << 3 | InternalState.running.rawValue)
static let waitingForDataAfterSeek = InternalState(rawValue: 1 << 4 | InternalState.running.rawValue)
static let paused = InternalState(rawValue: 1 << 5 | InternalState.running.rawValue)
static let playing = InternalState(rawValue: (1 << 1) | InternalState.running.rawValue)
static let rebuffering = InternalState(rawValue: (1 << 2) | InternalState.running.rawValue)
static let waitingForData = InternalState(rawValue: (1 << 3) | InternalState.running.rawValue)
static let waitingForDataAfterSeek = InternalState(rawValue: (1 << 4) | InternalState.running.rawValue)
static let paused = InternalState(rawValue: (1 << 5) | InternalState.running.rawValue)
static let stopped = InternalState(rawValue: 1 << 9)
static let pendingNext = InternalState(rawValue: 1 << 10)
static let disposed = InternalState(rawValue: 1 << 30)
@@ -55,7 +55,7 @@ func playerStateAndStopReason(
// MARK: Public States
public enum AudioPlayerState: Equatable {
public enum AudioPlayerState: Equatable, Sendable {
case ready
case running
case playing
@@ -66,7 +66,7 @@ public enum AudioPlayerState: Equatable {
case disposed
}
public enum AudioPlayerStopReason: Equatable {
public enum AudioPlayerStopReason: Equatable, Sendable {
case none
case eof
case userAction
@@ -74,7 +74,7 @@ public enum AudioPlayerStopReason: Equatable {
case disposed
}
public enum AudioPlayerError: LocalizedError, Equatable {
public enum AudioPlayerError: LocalizedError, Equatable, Sendable {
case streamParseBytesFailure(AudioFileStreamError)
case audioSystemError(AudioSystemError)
case codecError
@@ -100,12 +100,13 @@ public enum AudioPlayerError: LocalizedError, Equatable {
}
}
public enum AudioSystemError: LocalizedError, Equatable {
public enum AudioSystemError: LocalizedError, Equatable, Sendable {
case engineFailure
case playerNotFound
case playerStartError
case fileStreamError(AudioFileStreamError)
case converterError(AudioConverterError)
case codecError
public var errorDescription: String? {
switch self {
@@ -116,6 +117,8 @@ public enum AudioSystemError: LocalizedError, Equatable {
return "Audio file stream error'd: \(error)"
case let .converterError(error):
return "Audio converter error'd: \(error)"
case .codecError:
return "Audio codec error"
}
}
}
@@ -9,22 +9,22 @@ import CoreAudio
var maxFramesPerSlice: AVAudioFrameCount = 8192
final class AudioRendererContext {
var waiting = Atomic<Bool>(false)
let waiting = Atomic<Bool>(false)
let lock = UnfairLock()
let bufferContext: BufferContext
var audioBuffer: AudioBuffer
var inOutAudioBufferList: UnsafeMutablePointer<AudioBufferList>
let audioBuffer: AudioBuffer
let inOutAudioBufferList: UnsafeMutablePointer<AudioBufferList>
let packetsSemaphore = DispatchSemaphore(value: 0)
let framesRequiredToStartPlaying: UInt32
let framesRequiredAfterRebuffering: UInt32
let framesRequiredForDataAfterSeekPlaying: UInt32
let framesRequiredToStartPlaying: Double
let framesRequiredAfterRebuffering: Double
let framesRequiredForDataAfterSeekPlaying: Double
var waitingForDataAfterSeekFrameCount = Atomic<Int32>(0)
let waitingForDataAfterSeekFrameCount = Atomic<Int32>(0)
private let configuration: AudioPlayerConfiguration
@@ -33,9 +33,9 @@ final class AudioRendererContext {
let canonicalStream = outputAudioFormat.basicStreamDescription
framesRequiredToStartPlaying = UInt32(canonicalStream.mSampleRate) * UInt32(configuration.secondsRequiredToStartPlaying)
framesRequiredAfterRebuffering = UInt32(canonicalStream.mSampleRate) * UInt32(configuration.secondsRequiredToStartPlayingAfterBufferUnderrun)
framesRequiredForDataAfterSeekPlaying = UInt32(canonicalStream.mSampleRate) * UInt32(configuration.gracePeriodAfterSeekInSeconds)
framesRequiredToStartPlaying = Double(canonicalStream.mSampleRate) * Double(configuration.secondsRequiredToStartPlaying)
framesRequiredAfterRebuffering = Double(canonicalStream.mSampleRate) * Double(configuration.secondsRequiredToStartPlayingAfterBufferUnderrun)
framesRequiredForDataAfterSeekPlaying = Double(canonicalStream.mSampleRate) * Double(configuration.gracePeriodAfterSeekInSeconds)
let dataByteSize = Int(canonicalStream.mSampleRate * configuration.bufferSizeInSeconds) * Int(canonicalStream.mBytesPerFrame)
inOutAudioBufferList = allocateBufferList(dataByteSize: dataByteSize)
@@ -6,6 +6,7 @@
//
import AVFoundation
import CoreAudio
enum AudioConvertStatus: Int32 {
case done = 100
@@ -33,6 +34,13 @@ final class AudioFileStreamProcessor {
private let playerContext: AudioPlayerContext
private let rendererContext: AudioRendererContext
private let outputAudioFormat: AudioStreamBasicDescription
// Add Ogg Vorbis processor
private lazy var oggVorbisProcessor = OggVorbisStreamProcessor(
playerContext: playerContext,
rendererContext: rendererContext,
outputAudioFormat: outputAudioFormat
)
var audioFileStream: AudioFileStreamID?
var audioConverter: AudioConverterRef?
@@ -41,9 +49,12 @@ final class AudioFileStreamProcessor {
var currentFileFormat: String = ""
let fileFormatsForDelayedConverterCreation: Set = ["fa4m", "f4pm"]
// Track if we're processing Ogg Vorbis
private var isProcessingOggVorbis: Bool = false
var isFileStreamOpen: Bool {
audioFileStream != nil
audioFileStream != nil || isProcessingOggVorbis
}
init(playerContext: AudioPlayerContext,
@@ -53,6 +64,11 @@ final class AudioFileStreamProcessor {
self.playerContext = playerContext
self.rendererContext = rendererContext
self.outputAudioFormat = outputAudioFormat
// Set up Ogg Vorbis processor callback
oggVorbisProcessor.processorCallback = { [weak self] effect in
self?.fileStreamCallback?(effect)
}
}
/// Opens the `AudioFileStream`
@@ -62,12 +78,25 @@ final class AudioFileStreamProcessor {
/// - Returns: An `OSStatus` value indicating if an error occurred or not.
func openFileStream(with fileHint: AudioFileTypeID) -> OSStatus {
let data = UnsafeMutableRawPointer.from(object: self)
return AudioFileStreamOpen(data, _propertyListenerProc, _propertyPacketsProc, fileHint, &audioFileStream)
// Check if this is an Ogg Vorbis file
if fileHint == kAudioFileOggType {
isProcessingOggVorbis = true
return noErr
} else {
isProcessingOggVorbis = false
let data = UnsafeMutableRawPointer.from(object: self)
return AudioFileStreamOpen(data, _propertyListenerProc, _propertyPacketsProc, fileHint, &audioFileStream)
}
}
/// Closes the currently open `AudioFileStream` instance, if opened.
func closeFileStreamIfNeeded() {
if isProcessingOggVorbis {
isProcessingOggVorbis = false
oggVorbisProcessor.cleanup()
return
}
guard let fileStream = audioFileStream else {
Logger.debug("audio file stream not opened", category: .generic)
return
@@ -82,8 +111,14 @@ final class AudioFileStreamProcessor {
///
/// - Returns: An `OSStatus` value indicating if an error occurred or not.
func parseFileStreamBytes(data: Data) -> OSStatus {
guard let stream = audioFileStream else { return 0 }
guard !data.isEmpty else { return 0 }
// Check if we're processing Ogg Vorbis
if isProcessingOggVorbis {
return oggVorbisProcessor.parseOggVorbisData(data: data)
}
guard let stream = audioFileStream else { return 0 }
let flags: AudioFileStreamParseFlags = discontinuous ? .discontinuity : .init()
return data.withUnsafeBytes { buffer -> OSStatus in
AudioFileStreamParseBytes(stream, UInt32(buffer.count), buffer.baseAddress, flags)
@@ -91,10 +126,17 @@ final class AudioFileStreamProcessor {
}
func processSeek() {
guard let stream = audioFileStream else { return }
guard let readingEntry = playerContext.audioReadingEntry else {
return
}
// If processing Ogg Vorbis, use the Ogg Vorbis processor
if isProcessingOggVorbis {
oggVorbisProcessor.processSeek()
return
}
guard let stream = audioFileStream else { return }
guard readingEntry.calculatedBitrate() > 0.0 || (playerContext.audioPlayingEntry?.length ?? 0) > 0 else {
return
@@ -104,6 +146,8 @@ final class AudioFileStreamProcessor {
let dataLengthInBytes = Double(readingEntry.audioDataLengthBytes())
let entryDuration = readingEntry.duration()
let duration = entryDuration < readingEntry.progress && entryDuration > 0 ? readingEntry.progress : entryDuration
guard duration > 0.0 else { return }
var seekByteOffset = Int64(dataOffset + (readingEntry.seekRequest.time / duration) * dataLengthInBytes)
@@ -214,23 +258,29 @@ final class AudioFileStreamProcessor {
propertyId: AudioFileStreamPropertyID,
flags _: UnsafeMutablePointer<AudioFileStreamPropertyFlags>)
{
guard let entry = playerContext.audioReadingEntry else { return }
switch propertyId {
case kAudioFileStreamProperty_DataOffset:
processDataOffset(fileStream: fileStream)
processDataOffset(entry: entry, fileStream: fileStream)
case kAudioFileStreamProperty_FileFormat:
processFileFormat(fileStream: fileStream)
case kAudioFileStreamProperty_DataFormat:
processDataFormat(fileStream: fileStream)
processDataFormat(entry: entry, fileStream: fileStream)
case kAudioFileStreamProperty_AudioDataByteCount:
processDataByteCount(fileStream: fileStream)
processDataByteCount(entry: entry, fileStream: fileStream)
case kAudioFileStreamProperty_AudioDataPacketCount:
processAudioDataPacketCount(fileStream: fileStream)
processAudioDataPacketCount(entry: entry, fileStream: fileStream)
case kAudioFileStreamProperty_BitRate:
processBitRate(entry: entry, fileStream: fileStream)
case kAudioFileStreamProperty_ReadyToProducePackets:
// check converter for discontinuous stream
processReadyToProducePackets(fileStream: fileStream)
processPacketUpperBoundAndMaxPacketSize(fileStream: fileStream)
assignMagicCookieToConverterIfNeeded()
processPacketUpperBoundAndMaxPacketSize(entry: entry, fileStream: fileStream)
processReadyToProducePackets(entry: entry, fileStream: fileStream)
case kAudioFileStreamProperty_FormatList:
processFormatList(fileStream: fileStream)
processFormatList(entry: entry, fileStream: fileStream)
case kAudioFileStreamProperty_PacketTableInfo:
processPacketTableInfo(entry: entry, fileStream: fileStream)
default:
break
}
@@ -238,19 +288,23 @@ final class AudioFileStreamProcessor {
// MARK: AudioFileStream properties Processing
private func processDataOffset(fileStream: AudioFileStreamID) {
private func processDataOffset(entry: AudioEntry, fileStream: AudioFileStreamID) {
var offset: UInt64 = 0
fileStreamGetProperty(value: &offset, fileStream: fileStream, propertyId: kAudioFileStreamProperty_DataOffset)
playerContext.audioReadingEntry?.audioStreamState.processedDataFormat = true
playerContext.audioReadingEntry?.audioStreamState.dataOffset = offset
entry.lock.lock(); defer { entry.lock.unlock() }
entry.audioStreamState.processedDataFormat = true
entry.audioStreamState.dataOffset = offset
}
private func processReadyToProducePackets(fileStream: AudioFileStreamID) {
private func processReadyToProducePackets(entry: AudioEntry, fileStream: AudioFileStreamID) {
var packetCount: UInt64 = 0
var packetCountSize = UInt32(MemoryLayout.size(ofValue: packetCount))
AudioFileStreamGetProperty(fileStream, kAudioFileStreamProperty_AudioDataPacketCount, &packetCountSize, &packetCount)
playerContext.audioPlayingEntry?.audioStreamState.dataPacketCount = Double(packetCount)
if playerContext.audioPlayingEntry?.audioStreamFormat.mFormatID != kAudioFormatLinearPCM {
entry.lock.lock(); defer { entry.lock.unlock() }
entry.audioStreamState.dataPacketCount = Double(packetCount)
let entryFormatID = entry.audioStreamFormat.mFormatID
let isFLAC = entryFormatID == kAudioFormatFLAC
if entryFormatID != kAudioFormatLinearPCM && !isFLAC {
discontinuous = true
}
}
@@ -264,9 +318,9 @@ final class AudioFileStreamProcessor {
}
}
private func processDataFormat(fileStream: AudioFileStreamID) {
private func processDataFormat(entry: AudioEntry, fileStream: AudioFileStreamID) {
var audioStreamFormat = AudioStreamBasicDescription()
guard let entry = playerContext.audioReadingEntry else { return }
entry.lock.lock(); defer { entry.lock.unlock() }
if !entry.audioStreamState.processedDataFormat {
fileStreamGetProperty(value: &audioStreamFormat, fileStream: fileStream, propertyId: kAudioFileStreamProperty_DataFormat)
@@ -289,9 +343,8 @@ final class AudioFileStreamProcessor {
packetBufferSize = 2048 // default value
}
}
entry.lock.withLock {
entry.processedPacketsState.bufferSize = packetBufferSize
}
entry.processedPacketsState.bufferSize = packetBufferSize
if !fileFormatsForDelayedConverterCreation.contains(currentFileFormat) {
createAudioConverter(from: entry.audioStreamFormat, to: outputAudioFormat)
@@ -299,8 +352,7 @@ final class AudioFileStreamProcessor {
}
}
private func processPacketUpperBoundAndMaxPacketSize(fileStream: AudioFileStreamID) {
guard let entry = playerContext.audioReadingEntry else { return }
private func processPacketUpperBoundAndMaxPacketSize(entry: AudioEntry, fileStream: AudioFileStreamID) {
var packetBufferSize: UInt32 = 0
var status = fileStreamGetProperty(value: &packetBufferSize,
fileStream: fileStream,
@@ -318,55 +370,90 @@ final class AudioFileStreamProcessor {
}
}
private func processDataByteCount(fileStream: AudioFileStreamID) {
guard let entry = playerContext.audioReadingEntry else { return }
private func processDataByteCount(entry: AudioEntry, fileStream: AudioFileStreamID) {
var audioDataByteCount: UInt64 = 0
fileStreamGetProperty(value: &audioDataByteCount, fileStream: fileStream, propertyId: kAudioFileStreamProperty_AudioDataByteCount)
entry.lock.lock(); defer { entry.lock.unlock() }
entry.audioStreamState.dataByteCount = audioDataByteCount
}
private func processAudioDataPacketCount(fileStream: AudioFileStreamID) {
guard let entry = playerContext.audioReadingEntry else { return }
private func processAudioDataPacketCount(entry: AudioEntry, fileStream: AudioFileStreamID) {
var audioDataPacketCount: UInt64 = 0
fileStreamGetProperty(value: &audioDataPacketCount, fileStream: fileStream, propertyId: kAudioFileStreamProperty_AudioDataPacketCount)
entry.lock.lock(); defer { entry.lock.unlock() }
entry.audioStreamState.dataPacketOffset = audioDataPacketCount
}
private func processFormatList(fileStream: AudioFileStreamID) {
private func processBitRate(entry: AudioEntry, fileStream: AudioFileStreamID) {
var bitRate: UInt32 = 0
let status = fileStreamGetProperty(value: &bitRate, fileStream: fileStream, propertyId: kAudioFileStreamProperty_BitRate)
guard status == noErr else { return }
entry.lock.lock(); defer { entry.lock.unlock() }
entry.audioStreamState.bitRate = Double(bitRate)
}
private func processPacketTableInfo(entry: AudioEntry, fileStream: AudioFileStreamID) {
var pti = AudioFilePacketTableInfo(mNumberValidFrames: 0,
mPrimingFrames: 0,
mRemainderFrames: 0)
let status = fileStreamGetProperty(value: &pti, fileStream: fileStream, propertyId: kAudioFileStreamProperty_PacketTableInfo)
guard status == noErr else { return }
// Use valid frames to refine duration if present
entry.lock.lock(); defer { entry.lock.unlock() }
if pti.mNumberValidFrames > 0 {
entry.audioStreamState.dataPacketCount = Double(pti.mNumberValidFrames) / Double(max(1, entry.audioStreamFormat.mFramesPerPacket))
}
}
private func processFormatList(entry: AudioEntry, fileStream: AudioFileStreamID) {
let info = fileStreamGetPropertyInfo(fileStream: fileStream, propertyId: kAudioFileStreamProperty_FormatList)
guard info.status == noErr else { return }
var list: [AudioFormatListItem] = Array(repeating: AudioFormatListItem(), count: Int(info.size))
var size = UInt32(info.size)
guard info.status == noErr, info.size > 0 else { return }
let itemStride = MemoryLayout<AudioFormatListItem>.stride
let itemCount = Int(info.size) / itemStride
guard itemCount > 0 else { return }
var list = [AudioFormatListItem](repeating: AudioFormatListItem(), count: itemCount)
var size = UInt32(itemCount * itemStride)
AudioFileStreamGetProperty(fileStream, kAudioFileStreamProperty_FormatList, &size, &list)
let step = MemoryLayout<AudioFormatListItem>.size
var i = 0
while i * step < size {
var chosenASBD: AudioStreamBasicDescription?
for i in 0..<itemCount {
let asbd = list[i].mASBD
let formatId = asbd.mFormatID
if formatId == kAudioFormatMPEG4AAC_HE || formatId == kAudioFormatMPEG4AAC_HE_V2 {
playerContext.audioReadingEntry?.audioStreamFormat = asbd
chosenASBD = asbd
break
}
i += step
if chosenASBD == nil {
chosenASBD = asbd
}
}
if fileFormatsForDelayedConverterCreation.contains(currentFileFormat) {
if let inputStreamFormat = playerContext.audioReadingEntry?.audioStreamFormat {
createAudioConverter(from: inputStreamFormat, to: outputAudioFormat)
if let asbd = chosenASBD {
entry.lock.withLock { entry.audioStreamFormat = asbd }
if fileFormatsForDelayedConverterCreation.contains(currentFileFormat) {
createAudioConverter(from: asbd, to: outputAudioFormat)
}
}
}
// MARK: Packets Proc
func propertyPacketsProc(inNumberBytes: UInt32,
inNumberPackets: UInt32,
inInputData: UnsafeRawPointer,
inPacketDescriptions: UnsafeMutablePointer<AudioStreamPacketDescription>?)
{
func propertyPacketsProc(
inNumberBytes: UInt32,
inNumberPackets: UInt32,
inInputData: UnsafeRawPointer,
inPacketDescriptions: UnsafeMutablePointer<AudioStreamPacketDescription>?
) {
guard let entry = playerContext.audioReadingEntry else { return }
guard entry.audioStreamState.processedDataFormat else { return }
guard let converter = audioConverter else {
Logger.error("Couldn't find audio converter", category: .audioRendering)
return
}
if let playingEntry = playerContext.audioPlayingEntry,
playingEntry.seekRequest.requested, playingEntry.calculatedBitrate() > 0
{
@@ -377,25 +464,24 @@ final class AudioFileStreamProcessor {
return
}
guard let converter = audioConverter else {
Logger.error("Couldn't find audio converter", category: .audioRendering)
return
}
// reset discontinuity
discontinuous = false
var convertInfo = AudioConvertInfo(done: false,
numberOfPackets: inNumberPackets,
packDescription: inPacketDescriptions)
var convertInfo = AudioConvertInfo(
done: false,
numberOfPackets: inNumberPackets,
packDescription: inPacketDescriptions
)
convertInfo.audioBuffer.mData = UnsafeMutableRawPointer(mutating: inInputData)
convertInfo.audioBuffer.mDataByteSize = inNumberBytes
if let playingAudioStreamFormat = playerContext.audioPlayingEntry?.audioStreamFormat {
convertInfo.audioBuffer.mNumberChannels = playingAudioStreamFormat.mChannelsPerFrame
}
updateProcessedPackets(inPacketDescriptions: inPacketDescriptions,
inNumberPackets: inNumberPackets)
updateProcessedPackets(
inPacketDescriptions: inPacketDescriptions,
inNumberPackets: inNumberPackets
)
var status: OSStatus = noErr
packetProcess: while status == noErr {
@@ -403,7 +489,7 @@ final class AudioFileStreamProcessor {
let bufferContext = rendererContext.bufferContext
var used = bufferContext.frameUsedCount
var start = bufferContext.frameStartIndex
var end = bufferContext.end
var end = (bufferContext.frameStartIndex + bufferContext.frameUsedCount) % bufferContext.totalFrameCount
var framesLeftInBuffer = bufferContext.totalFrameCount - used
rendererContext.lock.unlock()
@@ -451,16 +537,20 @@ final class AudioFileStreamProcessor {
var framesToDecode: UInt32 = rendererContext.bufferContext.totalFrameCount - end
let offset = Int(end * rendererContext.bufferContext.sizeInBytes)
prefillLocalBufferList(bufferList: localBufferList,
dataOffset: offset,
framesToDecode: framesToDecode)
prefillLocalBufferList(
bufferList: localBufferList,
dataOffset: offset,
framesToDecode: framesToDecode
)
status = AudioConverterFillComplexBuffer(converter,
_converterCallback,
&convertInfo,
&framesToDecode,
localBufferList.unsafeMutablePointer,
nil)
status = AudioConverterFillComplexBuffer(
converter,
_converterCallback,
&convertInfo,
&framesToDecode,
localBufferList.unsafeMutablePointer,
nil
)
framesAdded = framesToDecode
@@ -477,16 +567,20 @@ final class AudioFileStreamProcessor {
fillUsedFrames(framesCount: framesAdded)
continue packetProcess
}
prefillLocalBufferList(bufferList: localBufferList,
dataOffset: 0,
framesToDecode: framesToDecode)
prefillLocalBufferList(
bufferList: localBufferList,
dataOffset: 0,
framesToDecode: framesToDecode
)
status = AudioConverterFillComplexBuffer(converter,
_converterCallback,
&convertInfo,
&framesToDecode,
localBufferList.unsafeMutablePointer,
nil)
status = AudioConverterFillComplexBuffer(
converter,
_converterCallback,
&convertInfo,
&framesToDecode,
localBufferList.unsafeMutablePointer,
nil
)
framesAdded += framesToDecode
@@ -505,16 +599,20 @@ final class AudioFileStreamProcessor {
var framesToDecode: UInt32 = start - end
let offset = Int(end * rendererContext.bufferContext.sizeInBytes)
prefillLocalBufferList(bufferList: localBufferList,
dataOffset: offset,
framesToDecode: framesToDecode)
prefillLocalBufferList(
bufferList: localBufferList,
dataOffset: offset,
framesToDecode: framesToDecode
)
status = AudioConverterFillComplexBuffer(converter,
_converterCallback,
&convertInfo,
&framesToDecode,
localBufferList.unsafeMutablePointer,
nil)
status = AudioConverterFillComplexBuffer(
converter,
_converterCallback,
&convertInfo,
&framesToDecode,
localBufferList.unsafeMutablePointer,
nil
)
framesAdded = framesToDecode
if status == AudioConvertStatus.done.rawValue {
@@ -537,10 +635,11 @@ final class AudioFileStreamProcessor {
/// - parameter dataOffset: An `Int` value indicating any offset to be applied to the buffer data
/// - parameter framesToDecode: An `UInt32` value indicating the frames to be decoded, used in calculating the data size of the buffer.
@inline(__always)
private func prefillLocalBufferList(bufferList: UnsafeMutableAudioBufferListPointer,
dataOffset: Int,
framesToDecode: UInt32)
{
private func prefillLocalBufferList(
bufferList: UnsafeMutableAudioBufferListPointer,
dataOffset: Int,
framesToDecode: UInt32
) {
if let mData = rendererContext.audioBuffer.mData {
bufferList[0].mData = dataOffset > 0 ? mData + dataOffset : mData
}
@@ -563,9 +662,10 @@ final class AudioFileStreamProcessor {
}
@inline(__always)
private func updateProcessedPackets(inPacketDescriptions: UnsafeMutablePointer<AudioStreamPacketDescription>?,
inNumberPackets: UInt32)
{
private func updateProcessedPackets(
inPacketDescriptions: UnsafeMutablePointer<AudioStreamPacketDescription>?,
inNumberPackets: UInt32
) {
guard let inPacketDescriptions = inPacketDescriptions else { return }
guard let readingEntry = playerContext.audioReadingEntry else { return }
let processedPackCount = readingEntry.processedPacketsState.count
@@ -585,23 +685,25 @@ final class AudioFileStreamProcessor {
// MARK: - AudioFileStream proc method
private func _propertyListenerProc(clientData: UnsafeMutableRawPointer,
fileStream: AudioFileStreamID,
propertyId: AudioFileStreamPropertyID,
flags: UnsafeMutablePointer<AudioFileStreamPropertyFlags>)
{
private func _propertyListenerProc(
clientData: UnsafeMutableRawPointer,
fileStream: AudioFileStreamID,
propertyId: AudioFileStreamPropertyID,
flags: UnsafeMutablePointer<AudioFileStreamPropertyFlags>
) {
let processor = clientData.to(type: AudioFileStreamProcessor.self)
processor.propertyListenerProc(fileStream: fileStream,
propertyId: propertyId,
flags: flags)
}
private func _propertyPacketsProc(clientData: UnsafeMutableRawPointer,
inNumberBytes: UInt32,
inNumberPackets: UInt32,
inInputData: UnsafeRawPointer,
inPacketDescriptions: UnsafeMutablePointer<AudioStreamPacketDescription>?)
{
private func _propertyPacketsProc(
clientData: UnsafeMutableRawPointer,
inNumberBytes: UInt32,
inNumberPackets: UInt32,
inInputData: UnsafeRawPointer,
inPacketDescriptions: UnsafeMutablePointer<AudioStreamPacketDescription>?
) {
let processor = clientData.to(type: AudioFileStreamProcessor.self)
processor.propertyPacketsProc(inNumberBytes: inNumberBytes,
inNumberPackets: inNumberPackets,
@@ -611,12 +713,13 @@ private func _propertyPacketsProc(clientData: UnsafeMutableRawPointer,
// MARK: - AudioConverterFillComplexBuffer callback method
private func _converterCallback(inAudioConverter _: AudioConverterRef,
ioNumberDataPackets: UnsafeMutablePointer<UInt32>,
ioData: UnsafeMutablePointer<AudioBufferList>,
outDataPacketDescription: UnsafeMutablePointer<UnsafeMutablePointer<AudioStreamPacketDescription>?>?,
inUserData: UnsafeMutableRawPointer?) -> OSStatus
{
private func _converterCallback(
inAudioConverter _: AudioConverterRef,
ioNumberDataPackets: UnsafeMutablePointer<UInt32>,
ioData: UnsafeMutablePointer<AudioBufferList>,
outDataPacketDescription: UnsafeMutablePointer<UnsafeMutablePointer<AudioStreamPacketDescription>?>?,
inUserData: UnsafeMutableRawPointer?
) -> OSStatus {
guard let convertInfo = inUserData?.assumingMemoryBound(to: AudioConvertInfo.self) else { return 0 }
// we need to tell the converter to stop converting after it should stop converting
@@ -64,29 +64,30 @@ final class AudioPlayerRenderProcessor: NSObject {
let frameSizeInBytes = bufferContext.sizeInBytes
let used = bufferContext.frameUsedCount
let start = bufferContext.frameStartIndex
let end = bufferContext.end
let end = (bufferContext.frameStartIndex + bufferContext.frameUsedCount) % bufferContext.totalFrameCount
let signal = rendererContext.waiting.value && used < bufferContext.totalFrameCount / 2
if let playingEntry = playingEntry {
playingEntry.lock.lock()
let framesState = playingEntry.framesState
playingEntry.lock.unlock()
if state == .waitingForData {
var requiredFramesToStart = rendererContext.framesRequiredToStartPlaying
if framesState.lastFrameQueued >= 0 {
requiredFramesToStart = min(requiredFramesToStart, UInt32(playingEntry.framesState.lastFrameQueued))
requiredFramesToStart = min(requiredFramesToStart, Double(playingEntry.framesState.lastFrameQueued))
}
if let readingEntry = readingEntry, readingEntry === playingEntry,
framesState.queued < requiredFramesToStart
if readingEntry === playingEntry, framesState.queued < Int(requiredFramesToStart)
{
waitForBuffer = true
}
} else if state == .rebuffering {
var requiredFramesToStart = rendererContext.framesRequiredAfterRebuffering
if framesState.lastFrameQueued >= 0 {
requiredFramesToStart = min(requiredFramesToStart, UInt32(framesState.lastFrameQueued - framesState.queued))
requiredFramesToStart = min(requiredFramesToStart, Double(framesState.lastFrameQueued - framesState.queued))
}
if used < requiredFramesToStart {
if used < Int(requiredFramesToStart) {
waitForBuffer = true
}
} else if state == .waitingForDataAfterSeek {
@@ -102,21 +103,19 @@ final class AudioPlayerRenderProcessor: NSObject {
rendererContext.lock.unlock()
var totalFramesCopied: UInt32 = 0
if used > 0 && !waitForBuffer && state.contains(.running) && state != .paused {
if used > 0 && !waitForBuffer && playingEntry != nil && state.contains(.running) && state != .paused {
if end > start {
let framesToCopy = min(inNumberFrames, used)
bufferList.mBuffers.mNumberChannels = 2
bufferList.mBuffers.mDataByteSize = frameSizeInBytes * framesToCopy
if isMuted {
writeSilence(outputBuffer: &bufferList.mBuffers,
outputBufferSize: 0,
offset: Int(bufferList.mBuffers.mDataByteSize))
if let mData = bufferList.mBuffers.mData {
memset(mData, 0, Int(bufferList.mBuffers.mDataByteSize))
}
} else {
if let mDataBuffer = audioBuffer.mData {
memcpy(bufferList.mBuffers.mData,
mDataBuffer + Int(start * frameSizeInBytes),
Int(bufferList.mBuffers.mDataByteSize))
memcpy(bufferList.mBuffers.mData, mDataBuffer + Int(start * frameSizeInBytes), Int(bufferList.mBuffers.mDataByteSize))
}
}
totalFramesCopied = framesToCopy
@@ -132,14 +131,12 @@ final class AudioPlayerRenderProcessor: NSObject {
bufferList.mBuffers.mDataByteSize = frameSizeInBytes * frameToCopy
if isMuted {
writeSilence(outputBuffer: &bufferList.mBuffers,
outputBufferSize: 0,
offset: Int(bufferList.mBuffers.mDataByteSize))
if let mData = bufferList.mBuffers.mData {
memset(mData, 0, Int(bufferList.mBuffers.mDataByteSize))
}
} else {
if let mDataBuffer = audioBuffer.mData {
memcpy(bufferList.mBuffers.mData,
mDataBuffer + Int(start * frameSizeInBytes),
Int(bufferList.mBuffers.mDataByteSize))
memcpy(bufferList.mBuffers.mData, mDataBuffer + Int(start * frameSizeInBytes), Int(bufferList.mBuffers.mDataByteSize))
}
}
@@ -151,14 +148,10 @@ final class AudioPlayerRenderProcessor: NSObject {
bufferList.mBuffers.mDataByteSize += frameSizeInBytes * moreFramesToCopy
if let ioBufferData = bufferList.mBuffers.mData {
if isMuted {
writeSilence(outputBuffer: &bufferList.mBuffers,
outputBufferSize: Int(frameSizeInBytes * moreFramesToCopy),
offset: Int(frameToCopy * frameSizeInBytes))
memset(ioBufferData + Int(frameToCopy * frameSizeInBytes), 0, Int(frameSizeInBytes * moreFramesToCopy))
} else {
if let mDataBuffer = audioBuffer.mData {
memcpy(ioBufferData + Int(frameToCopy * frameSizeInBytes),
mDataBuffer,
Int(frameSizeInBytes * moreFramesToCopy))
memcpy(ioBufferData + Int(frameToCopy * frameSizeInBytes), mDataBuffer, Int(frameSizeInBytes * moreFramesToCopy))
}
}
}
@@ -170,6 +163,7 @@ final class AudioPlayerRenderProcessor: NSObject {
bufferContext.frameUsedCount -= totalFramesCopied
rendererContext.lock.unlock()
}
if playerContext.internalState != .playing {
playerContext.setInternalState(to: .playing, when: { state -> Bool in
state.contains(.running) && state != .paused
@@ -183,7 +177,7 @@ final class AudioPlayerRenderProcessor: NSObject {
memset(mData + Int(totalFramesCopied * frameSizeInBytes), 0, Int(delta * frameSizeInBytes))
}
if playingEntry != nil || AudioPlayer.InternalState.waiting.contains(state) {
if !(playingEntry == nil || state == .waitingForDataAfterSeek || state == .waitingForData || state == .rebuffering) {
if playerContext.internalState != .rebuffering {
playerContext.setInternalState(to: .rebuffering, when: { state -> Bool in
state.contains(.running) && state != .paused
@@ -192,7 +186,7 @@ final class AudioPlayerRenderProcessor: NSObject {
} else if state == .waitingForDataAfterSeek {
if totalFramesCopied == 0 {
rendererContext.waitingForDataAfterSeekFrameCount.write { $0 += Int32(inNumberFrames - totalFramesCopied) }
if rendererContext.waitingForDataAfterSeekFrameCount.value > rendererContext.framesRequiredForDataAfterSeekPlaying {
if rendererContext.waitingForDataAfterSeekFrameCount.value > Int(rendererContext.framesRequiredForDataAfterSeekPlaying) {
if playerContext.internalState != .playing {
playerContext.setInternalState(to: .playing) { state -> Bool in
state.contains(.running) && state != .playing
@@ -272,10 +266,11 @@ final class AudioPlayerRenderProcessor: NSObject {
return UnsafePointer(rendererContext.inOutAudioBufferList)
}
func render(inNumberFrames: UInt32,
ioData: UnsafeMutablePointer<AudioBufferList>,
flags _: UnsafeMutablePointer<AudioUnitRenderActionFlags>) -> OSStatus
{
func render(
inNumberFrames: UInt32,
ioData: UnsafeMutablePointer<AudioBufferList>,
flags _: UnsafeMutablePointer<AudioUnitRenderActionFlags>
) -> OSStatus {
var status = noErr
rendererContext.inOutAudioBufferList[0].mBuffers.mData = ioData.pointee.mBuffers.mData
@@ -310,22 +305,18 @@ final class AudioPlayerRenderProcessor: NSObject {
return status
}
func renderProvider(flags: UnsafeMutablePointer<AudioUnitRenderActionFlags>,
timeStamp _: UnsafePointer<AudioTimeStamp>,
inNumberFrames: AUAudioFrameCount,
inputBusNumber: Int,
inputData: UnsafeMutablePointer<AudioBufferList>) -> AUAudioUnitStatus
{
func renderProvider(
flags: UnsafeMutablePointer<AudioUnitRenderActionFlags>,
timeStamp _: UnsafePointer<AudioTimeStamp>,
inNumberFrames: AUAudioFrameCount,
inputBusNumber: Int,
inputData: UnsafeMutablePointer<AudioBufferList>
) -> AUAudioUnitStatus {
guard inputBusNumber == 0 else { return noErr }
return render(inNumberFrames: inNumberFrames, ioData: inputData, flags: flags)
}
@inline(__always)
private func writeSilence(outputBuffer: inout AudioBuffer,
outputBufferSize: Int,
offset: Int)
{
guard let mData = outputBuffer.mData else { return }
memset(mData + offset, 0, outputBufferSize)
return render(
inNumberFrames: inNumberFrames,
ioData: inputData,
flags: flags
)
}
}
@@ -0,0 +1,489 @@
//
// OggVorbisStreamProcessor.swift
// AudioStreaming
//
// Created on 25/10/2025.
//
import Foundation
import AVFoundation
import CoreAudio
/// A processor for Ogg Vorbis audio streams using libvorbisfile
final class OggVorbisStreamProcessor {
/// The callback to notify when processing is complete or an error occurs
var processorCallback: ((FileStreamProcessorEffect) -> Void)?
// MARK: - Constants
/// Correction factor for Ogg container overhead in bitrate-based duration calculation.
/// Ogg containers add 3-4% overhead (page headers, packet headers, metadata).
/// The nominal bitrate only accounts for audio data, not container overhead.
/// By reducing the bitrate slightly, we increase the calculated duration to match reality.
private let oggContainerOverheadFactor: Double = 0.96 // 4% overhead
/// Fallback bitrate estimates when nominal bitrate is unavailable
private let fallbackBitrateStereo: Double = 160_000 // 160 kbps for stereo
private let fallbackBitrateMono: Double = 96_000 // 96 kbps for mono
// MARK: - Properties
private let playerContext: AudioPlayerContext
private let rendererContext: AudioRendererContext
private let outputAudioFormat: AudioStreamBasicDescription
private let vfDecoder = VorbisFileDecoder()
private var isInitialized = false
// Audio converter for format conversion
private var audioConverter: AVAudioConverter?
// Buffer for PCM conversion
private var pcmBuffer: AVAudioPCMBuffer?
private let frameCount = 1024
// Seeking state (currently unused - seeking not fully supported)
// Future enhancement: implement proper seeking for local files
// Debug logging
private var totalFramesProcessed = 0
private var dataChunkCount = 0
// MARK: - Initialization
/// Initialize the OggVorbisStreamProcessor
/// - Parameters:
/// - playerContext: The audio player context
/// - rendererContext: The audio renderer context
/// - outputAudioFormat: The output audio format
init(playerContext: AudioPlayerContext,
rendererContext: AudioRendererContext,
outputAudioFormat: AudioStreamBasicDescription) {
self.playerContext = playerContext
self.rendererContext = rendererContext
self.outputAudioFormat = outputAudioFormat
}
deinit {
cleanup()
}
/// Clean up all resources and reset state
func cleanup() {
cleanupBuffers()
audioConverter = nil
// Destroy and reset the decoder
vfDecoder.destroy()
isInitialized = false
totalFramesProcessed = 0
}
// MARK: - Data Processing
/// Parse Ogg Vorbis data
/// - Parameter data: The Ogg Vorbis data to parse
/// - Returns: An OSStatus indicating success or failure
func parseOggVorbisData(data: Data) -> OSStatus {
guard let entry = playerContext.audioReadingEntry else { return 0 }
dataChunkCount += 1
if !isInitialized {
vfDecoder.create(capacityBytes: 2_097_152)
isInitialized = true
totalFramesProcessed = 0
}
vfDecoder.push(data)
if !entry.audioStreamState.processedDataFormat {
let availableBytes = vfDecoder.availableBytes()
if availableBytes >= 16384 {
do {
try vfDecoder.openIfNeeded()
if vfDecoder.sampleRate > 0 && vfDecoder.channels > 0 {
setupAudioFormat()
if pcmBuffer == nil, let processingFormat = vfDecoder.processingFormat {
pcmBuffer = AVAudioPCMBuffer(pcmFormat: processingFormat, frameCapacity: UInt32(frameCount))
}
}
} catch {
return noErr
}
} else {
return noErr
}
}
guard entry.audioStreamState.processedDataFormat else {
return noErr
}
// Handle seek requests
if let playingEntry = playerContext.audioPlayingEntry,
playingEntry.seekRequest.requested, playingEntry.calculatedBitrate() > 0 {
// This is the correct usage of .processSource - only for seek requests
processorCallback?(.processSource)
if rendererContext.waiting.value {
rendererContext.packetsSemaphore.signal()
}
return noErr
}
// Decode frames continuously - matching AudioFileStreamProcessor behavior
// Wait for renderer buffer space if needed, just like regular audio processing
var consecutiveNoFrames = 0
var totalDecoded = 0
decodeLoop: while true {
// Check player state
if playerContext.internalState == .disposed
|| playerContext.internalState == .pendingNext
|| playerContext.internalState == .stopped {
break
}
// Check if there's space in the buffer
rendererContext.lock.lock()
let totalFrames = rendererContext.bufferContext.totalFrameCount
let usedFrames = rendererContext.bufferContext.frameUsedCount
rendererContext.lock.unlock()
guard usedFrames <= totalFrames else {
break decodeLoop
}
var framesLeft = totalFrames - usedFrames
if framesLeft == 0 {
while true {
rendererContext.lock.lock()
let totalFrames = rendererContext.bufferContext.totalFrameCount
let usedFrames = rendererContext.bufferContext.frameUsedCount
rendererContext.lock.unlock()
if usedFrames > totalFrames {
break decodeLoop
}
framesLeft = totalFrames - usedFrames
if framesLeft > 0 {
break
}
if playerContext.internalState == .disposed
|| playerContext.internalState == .pendingNext
|| playerContext.internalState == .stopped {
break decodeLoop
}
if let playingEntry = playerContext.audioPlayingEntry,
playingEntry.seekRequest.requested, playingEntry.calculatedBitrate() > 0 {
processorCallback?(.processSource)
if rendererContext.waiting.value {
rendererContext.packetsSemaphore.signal()
}
break decodeLoop
}
rendererContext.waiting.write { $0 = true }
rendererContext.packetsSemaphore.wait()
rendererContext.waiting.write { $0 = false }
}
}
let availableBytes = vfDecoder.availableBytes()
if availableBytes < 4096 {
consecutiveNoFrames += 1
if consecutiveNoFrames >= 3 {
break decodeLoop
}
continue
}
let status = decodeAndFillBuffer()
if status != noErr {
consecutiveNoFrames += 1
if consecutiveNoFrames >= 3 {
break decodeLoop
}
} else {
consecutiveNoFrames = 0
totalDecoded += 1
}
}
if totalDecoded > 0 && rendererContext.waiting.value {
rendererContext.packetsSemaphore.signal()
}
return noErr
}
/// Decode audio and fill the renderer buffer
/// - Returns: noErr if frames were decoded, otherwise an error/no-data status
private func decodeAndFillBuffer() -> OSStatus {
guard let pcmBuffer = pcmBuffer else {
return OSStatus(-1)
}
let framesRead = vfDecoder.readFrames(into: pcmBuffer, frameCount: frameCount)
if framesRead <= 0 {
return OSStatus(-1)
}
pcmBuffer.frameLength = UInt32(framesRead)
processDecodedAudio(pcmBuffer: pcmBuffer, framesRead: framesRead)
totalFramesProcessed += framesRead
return noErr
}
// MARK: - Audio Format Setup
// Setup audio format using the processingFormat from VorbisFileDecoder
private func setupAudioFormat() {
guard let entry = playerContext.audioReadingEntry,
let processingFormat = vfDecoder.processingFormat else { return }
entry.lock.lock()
// Use the decoder's deinterleaved format directly
var asbd = processingFormat.streamDescription.pointee
// Store the format in the entry
entry.audioStreamFormat = asbd
entry.sampleRate = Float(vfDecoder.sampleRate)
entry.packetDuration = Double(1) / Double(vfDecoder.sampleRate)
// For streaming Ogg files, totalPcmSamples may not be available (returns error code)
// In that case, use bitrate-based duration calculation with container overhead correction
if vfDecoder.totalPcmSamples > 0 {
// We have total samples - use packet offset for accurate duration
entry.audioStreamState.dataPacketOffset = UInt64(vfDecoder.totalPcmSamples)
} else {
// Streaming - use bitrate for duration estimation
if vfDecoder.nominalBitrate > 0 {
entry.audioStreamState.bitRate = Double(vfDecoder.nominalBitrate) * oggContainerOverheadFactor
} else {
// Fallback: use typical bitrates for Vorbis quality
let estimatedBitrate = vfDecoder.channels == 2 ? fallbackBitrateStereo : fallbackBitrateMono
entry.audioStreamState.bitRate = estimatedBitrate * oggContainerOverheadFactor
}
}
entry.audioStreamState.processedDataFormat = true
entry.audioStreamState.readyForDecoding = true
entry.lock.unlock()
// Create audio converter from decoder format to output format
createAudioConverter(from: processingFormat, to: outputAudioFormat)
}
/// Create audio converter from decoder format to output format
private func createAudioConverter(from sourceFormat: AVAudioFormat, to destFormat: AudioStreamBasicDescription) {
audioConverter = nil
var dest = destFormat
guard let destAVFormat = AVAudioFormat(streamDescription: &dest) else {
Logger.error("Failed to create output AVAudioFormat", category: .audioRendering)
return
}
guard let converter = AVAudioConverter(from: sourceFormat, to: destAVFormat) else {
Logger.error("Failed to create AVAudioConverter", category: .audioRendering)
return
}
audioConverter = converter
}
// MARK: - Audio Processing
/// Process decoded audio using AVAudioConverter
/// - Parameters:
/// - pcmBuffer: The PCM buffer containing decoded audio
/// - framesRead: Number of frames read
private func processDecodedAudio(pcmBuffer: AVAudioPCMBuffer, framesRead: Int) {
guard let entry = playerContext.audioReadingEntry,
let converter = audioConverter else { return }
// Set the input buffer's frame length
pcmBuffer.frameLength = UInt32(framesRead)
// Create output buffer with converter's output format
guard let outputBuffer = AVAudioPCMBuffer(
pcmFormat: converter.outputFormat,
frameCapacity: UInt32(framesRead)
) else { return }
// Process through AudioConverter
rendererContext.lock.lock()
let bufferContext = rendererContext.bufferContext
let used = bufferContext.frameUsedCount
let totalFrames = bufferContext.totalFrameCount
let end = (bufferContext.frameStartIndex + bufferContext.frameUsedCount) % bufferContext.totalFrameCount
rendererContext.lock.unlock()
guard used <= totalFrames else {
return
}
var framesLeft = totalFrames - used
// Wait for buffer space if needed
if framesLeft == 0 {
while true {
rendererContext.lock.lock()
let currentUsed = rendererContext.bufferContext.frameUsedCount
let currentTotal = rendererContext.bufferContext.totalFrameCount
rendererContext.lock.unlock()
if currentUsed > currentTotal {
return
}
framesLeft = currentTotal - currentUsed
if framesLeft > 0 {
break
}
if playerContext.internalState == .disposed
|| playerContext.internalState == .pendingNext
|| playerContext.internalState == .stopped {
return
}
if let playingEntry = playerContext.audioPlayingEntry,
playingEntry.seekRequest.requested, playingEntry.calculatedBitrate() > 0 {
processorCallback?(.processSource)
if rendererContext.waiting.value {
rendererContext.packetsSemaphore.signal()
}
return
}
rendererContext.waiting.write { $0 = true }
rendererContext.packetsSemaphore.wait()
rendererContext.waiting.write { $0 = false }
}
}
var error: NSError?
var inputConsumed = false
let status = converter.convert(to: outputBuffer, error: &error) { inNumPackets, outStatus in
if inputConsumed {
outStatus.pointee = .noDataNow
return nil
}
inputConsumed = true
outStatus.pointee = .haveData
return pcmBuffer
}
guard status != .error, outputBuffer.frameLength > 0 else {
return
}
rendererContext.lock.lock()
let start = rendererContext.bufferContext.frameStartIndex
let currentEnd = (rendererContext.bufferContext.frameStartIndex + rendererContext.bufferContext.frameUsedCount) % rendererContext.bufferContext.totalFrameCount
let totalFrameCount = rendererContext.bufferContext.totalFrameCount
let currentUsed = rendererContext.bufferContext.frameUsedCount
rendererContext.lock.unlock()
// Calculate actual space available
let actualFramesLeft = totalFrameCount - currentUsed
let framesToCopy = min(UInt32(outputBuffer.frameLength), actualFramesLeft)
guard let sourceData = outputBuffer.audioBufferList.pointee.mBuffers.mData?.assumingMemoryBound(to: UInt8.self) else { return }
let bytesPerFrame = Int(rendererContext.bufferContext.sizeInBytes)
let destData = rendererContext.audioBuffer.mData?.assumingMemoryBound(to: UInt8.self)
if currentEnd >= start {
// Ring buffer wraps
let framesToEnd = totalFrameCount - currentEnd
let firstChunkFrames = min(framesToCopy, framesToEnd)
let firstChunkBytes = Int(firstChunkFrames) * bytesPerFrame
let firstChunkOffset = Int(currentEnd) * bytesPerFrame
// Copy first chunk to end of buffer
memcpy(destData?.advanced(by: firstChunkOffset), sourceData, firstChunkBytes)
// Copy second chunk to start of buffer if needed
if firstChunkFrames < framesToCopy {
let secondChunkFrames = framesToCopy - firstChunkFrames
let secondChunkBytes = Int(secondChunkFrames) * bytesPerFrame
memcpy(destData, sourceData.advanced(by: firstChunkBytes), secondChunkBytes)
}
} else {
// No wrap
let chunkBytes = Int(framesToCopy) * bytesPerFrame
let offset = Int(currentEnd) * bytesPerFrame
memcpy(destData?.advanced(by: offset), sourceData, chunkBytes)
}
fillUsedFrames(framesCount: framesToCopy)
updateProcessedPackets(inNumberPackets: framesToCopy)
}
/// Process a seek request
///
/// Seeking is not supported for Ogg Vorbis streams.
/// For HTTP streams, seeking is extremely difficult because:
/// 1. Need to find Ogg page boundaries
/// 2. Need Vorbis headers to initialize decoder
/// 3. Headers are only at the beginning of the file
///
/// Note: Future enhancement could support seeking in local files
/// by fetching headers and using libvorbisfile's built-in seeking.
func processSeek() {
// Seeking not supported - UI should check AudioPlayer.isSeekable
}
// MARK: - Helper Methods
/// Update the processed packets information
/// - Parameter inNumberPackets: The number of packets processed
private func updateProcessedPackets(inNumberPackets: UInt32) {
guard let readingEntry = playerContext.audioReadingEntry else { return }
let processedPackCount = readingEntry.processedPacketsState.count
let maxPackets = 4096
if processedPackCount < maxPackets {
let count = min(Int(inNumberPackets), maxPackets - Int(processedPackCount))
let packetSize: UInt32 = UInt32(readingEntry.audioStreamFormat.mBytesPerFrame)
readingEntry.lock.lock()
readingEntry.processedPacketsState.sizeTotal += (packetSize * UInt32(count))
readingEntry.processedPacketsState.count += UInt32(count)
readingEntry.lock.unlock()
}
}
/// Advances the processed frames for buffer and reading entry
/// - Parameter frameCount: The number of frames to advance
@inline(__always)
private func fillUsedFrames(framesCount: UInt32) {
rendererContext.lock.lock()
rendererContext.bufferContext.frameUsedCount += framesCount
rendererContext.lock.unlock()
playerContext.audioReadingEntry?.lock.lock()
playerContext.audioReadingEntry?.framesState.queued += Int(framesCount)
playerContext.audioReadingEntry?.lock.unlock()
}
/// Clean up allocated buffers
private func cleanupBuffers() {
pcmBuffer = nil
}
}
@@ -5,13 +5,11 @@
import AVFoundation
private let outputChannels: UInt32 = 2
enum UnitDescriptions {
static var output: AudioComponentDescription = {
static let output: AudioComponentDescription = {
var desc = AudioComponentDescription()
desc.componentType = kAudioUnitType_Output
#if os(iOS)
#if os(iOS) || os(tvOS)
desc.componentSubType = kAudioUnitSubType_RemoteIO
#else
desc.componentSubType = kAudioUnitSubType_DefaultOutput
@@ -7,6 +7,9 @@ import AudioToolbox
import Foundation
/// mapping from mime types to `AudioFileTypeID`
// Custom file type for Ogg Vorbis
let kAudioFileOggType: AudioFileTypeID = 0x6F676720 // 'ogg '
let fileTypesFromMimeType: [String: AudioFileTypeID] =
[
"audio/mp3": kAudioFileMP3Type,
@@ -33,6 +36,10 @@ let fileTypesFromMimeType: [String: AudioFileTypeID] =
"video/3gpp": kAudioFile3GPType,
"audio/3gp2": kAudioFile3GP2Type,
"video/3gp2": kAudioFile3GP2Type,
"audio/flac": kAudioFileFLACType,
"audio/ogg": kAudioFileOggType,
"audio/vorbis": kAudioFileOggType,
"application/ogg": kAudioFileOggType
]
/// Method that converts mime type to AudioFileTypeID
@@ -57,6 +64,8 @@ let fileTypesFromFileExtension: [String: AudioFileTypeID] =
"ac3": kAudioFileAC3Type,
"3gp": kAudioFile3GPType,
"flac": kAudioFileFLACType,
"ogg": kAudioFileOggType,
"oga": kAudioFileOggType,
]
func audioFileType(fileExtension: String) -> AudioFileTypeID {
@@ -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`
@@ -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
}
+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)
}
}
@@ -34,8 +34,7 @@ class MetadataStreamProcessorTests: XCTestCase {
}
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 url = Bundle.module.url(forResource: "raw-audio-streams/raw-stream-audio-empty-metadata", withExtension: nil)!
let data = try Data(contentsOf: url)
@@ -53,8 +52,7 @@ 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 url = Bundle.module.url(forResource: "raw-audio-streams/raw-stream-audio-normal-metadata", withExtension: nil)!
let data = try Data(contentsOf: url)
@@ -72,8 +70,7 @@ 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 url = Bundle.module.url(forResource: "raw-audio-streams/raw-stream-audio-normal-metadata-alt", withExtension: nil)!
let data = try Data(contentsOf: url)
@@ -95,8 +92,7 @@ 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 url = Bundle.module.url(forResource: "raw-audio-streams/raw-stream-audio-no-metadata", withExtension: nil)!
let data = try Data(contentsOf: url)
+24
View File
@@ -0,0 +1,24 @@
{
"originHash" : "68edf8cb05464ad5cb63fbf7d9ab8c54c5bfd1afcf71a774b97ff4f35f7e3fd1",
"pins" : [
{
"identity" : "ogg-binary-xcframework",
"kind" : "remoteSourceControl",
"location" : "https://github.com/sbooth/ogg-binary-xcframework",
"state" : {
"revision" : "c0e822e18738ad913864e98d9614927ac1e9337c",
"version" : "0.1.2"
}
},
{
"identity" : "vorbis-binary-xcframework",
"kind" : "remoteSourceControl",
"location" : "https://github.com/sbooth/vorbis-binary-xcframework",
"state" : {
"revision" : "842020eabcebe410e698c68545d6597b2d232e51",
"version" : "0.1.2"
}
}
],
"version" : 3
}
+50 -6
View File
@@ -1,23 +1,67 @@
// swift-tools-version:5.3
// swift-tools-version:5.10
import PackageDescription
let package = Package(
name: "AudioStreaming",
platforms: [
.iOS(.v12),
.iOS(.v15),
.macOS(.v13),
.tvOS(.v16)
],
products: [
.library(
name: "AudioStreaming",
targets: ["AudioStreaming"]
targets: ["AudioCodecs", "AudioStreaming"]
),
],
dependencies: [
.package(url: "https://github.com/sbooth/ogg-binary-xcframework", exact: "0.1.2"),
.package(url: "https://github.com/sbooth/vorbis-binary-xcframework", exact: "0.1.2")
],
targets: [
// C target for audio codec bridges
.target(
name: "AudioCodecs",
dependencies: [
.product(name: "ogg", package: "ogg-binary-xcframework"),
.product(name: "vorbis", package: "vorbis-binary-xcframework")
],
path: "AudioCodecs",
publicHeadersPath: "include",
cSettings: [
.headerSearchPath("."),
.headerSearchPath("include")
],
linkerSettings: [
.linkedFramework("AudioToolbox"),
.linkedFramework("Foundation")
]
),
// Main Swift target
.target(
name: "AudioStreaming",
path: "AudioStreaming"
dependencies: [
"AudioCodecs",
.product(name: "ogg", package: "ogg-binary-xcframework"),
.product(name: "vorbis", package: "vorbis-binary-xcframework")
],
path: "AudioStreaming",
exclude: ["AudioStreaming.h", "Streaming/OggVorbis", "Info.plist"],
swiftSettings: []
),
],
swiftLanguageVersions: [.v5]
.testTarget(
name: "AudioStreamingTests",
dependencies: [
"AudioStreaming"
],
path: "AudioStreamingTests",
exclude: ["Info.plist", "Streaming/output"],
resources: [
// Test resources for metadata stream processor tests
.copy("Streaming/Metadata Stream Processor/raw-audio-streams")
]
)
]
)
+36 -28
View File
@@ -1,4 +1,4 @@
![AudioStreaming CI](https://github.com/dimitris-c/AudioStreaming/workflows/AudioStreaming%20CI/badge.svg)
[![AudioStreaming CI](https://github.com/dimitris-c/AudioStreaming/actions/workflows/swift.yml/badge.svg)](https://github.com/dimitris-c/AudioStreaming/actions/workflows/swift.yml)
# AudioStreaming
An AudioPlayer/Streaming library for iOS written in Swift, allows playback of online audio streaming, local file as well as gapless queueing.
@@ -8,16 +8,25 @@ Under the hood `AudioStreaming` uses `AVAudioEngine` and `CoreAudio` for playbac
#### Supported audio
- Online streaming (Shoutcast/ICY streams) with metadata parsing
- AIFF, AIFC, WAVE, CAF, NeXT, ADTS, MPEG Audio Layer 3, AAC audio formats
- M4A (_Optimized files only_)
- M4A (optimized and non-optimized) from v1.2.0
- **Ogg Vorbis** (both local and remote files) ✨
As of 1.2.0 version, there's support for non-optimized remote M4A, please report any issues
#### Known limitations
Known limitations:
~~- As described above non-optimised M4A files are not supported this is a limitation of [AudioFileStream Services](https://developer.apple.com/documentation/audiotoolbox/audio_file_stream_services?language=swift)~~
**Ogg Vorbis Seeking:**
- Seeking is **not supported** for Ogg Vorbis files in the current release
- This is due to technical challenges with the Ogg container format over HTTP streaming:
- Seeking requires finding precise Ogg page boundaries in the stream
- The Vorbis decoder needs the full headers (identification, comment, and setup packets) to initialize, which are only available at the beginning of the file
- HTTP range requests need to be carefully orchestrated to fetch headers and seek to the correct position
- Your UI can check `player.isSeekable` to determine if seeking is available for the currently playing file
- Future releases may add experimental support for seeking using progressive download or intelligent header caching
# Requirements
- iOS 13.0+
- iOS 15.0+
- macOS 13.0+
- tvOS 16.0+
- Swift 5.x
# Using AudioStreaming
@@ -163,38 +172,37 @@ Under the hood the concrete class for frame filters, `FrameFilterProcessor` inst
# Installation
### Cocoapods
[Cocoapods](https://cocoapods.org/) is a dependency manager for Cocoa projects. You can install it with the following command:
```
$ gem install cocoapods
```
To intergrate AudioStreaming with [Cocoapods](https://cocoapods.org/) to your Xcode project add the following to your `Podfile`:
```
pod 'AudioStreaming'
```
### Swift Package Manager
On Xcode 11.0+ you can add a new dependency by going to **File / Swift Packages / Add Package Dependency...**
and enter package repository URL https://github.com/dimitris-c/AudioStreaming.git, then follow the instructions.
### Carthage
# Development
[Carthage](https://github.com/Carthage/Carthage) is a decentralized dependency manager that builds your dependencies and provides you with frameworks.
### Testing
You can install Carthage with Homebrew using the following command:
```
$ brew update
$ brew install carthage
This package uses Swift Package Manager for development and testing. To run tests:
```bash
# Run all tests
swift test
# Run tests in parallel for faster execution
swift test --parallel
# Build the package
swift build
```
To integrate AudioStreaming into your Xcode project using Carthage, add the following to your `Cartfile`:
### Opening in Xcode
You can open the package directly in Xcode:
```bash
open Package.swift
```
github "dimitris-c/AudioStreaming"
```
Visit [installation instructions](https://github.com/Carthage/Carthage#adding-frameworks-to-an-application) on Carthage to install the framework
Or simply double-click the `Package.swift` file. Xcode will automatically resolve dependencies and make the package ready for development.
# Licence