A Better(Maybe) iOS Audio Stream & Play Swift Framework
- iOS 15.0+
- Swift 6.0+ (Xcode 16+ toolchain)
import APlay
...
let url = URL(string: "path/to/audio/resource")!
let player = APlay()
player.eventPipeline.delegate(to: self, with: { (target, event) in
// event handling
})
player.play(url)
...The built-in player wires an NBandEQ into its audio graph when equalizerBandFrequencies is
configured (the default is [50, 100, 200, 400, 800, 1600, 2600, 16000]). Band gains can be
adjusted at any time:
// boost the low band by 6 dB (index order matches Configuration.equalizerBandFrequencies)
player.setEqualizerBandGain(6, at: 0)Play a list with gaplessPlaybackEnabled turned on and the next track is preloaded while the
current one is still playing; at the end of the track the output audio unit swaps in the
buffered source without stopping, so consecutive tracks of the same format play without a gap:
let player = APlay(configuration: APlay.Configuration(gaplessPlaybackEnabled: true))
player.loopPattern = .stopWhenAllPlayed(.order)
player.play([first, second, third])The default is off, so single-track playback behaves exactly as before. A handoff between different audio formats (for example MP3 → FLAC) still re-initializes the graph and is not seamless.
Anything Core Audio can stream-decode, APlay can play. The matrix below is pinned by
MacTests/FormatCompatibilityTests.swift, which drives every bundled fixture through the
real decoder (AudioFileStream + AudioConverter) — a format only counts as supported
when it both parses its metadata and decodes to canonical PCM.
| Format | Container / codec | Decode | Note |
|---|---|---|---|
| AAC | MP4 (.m4a) |
✅ | |
| AAC | raw ADTS (.aac) |
✅ | |
| MP3 | CBR and VBR (.mp3) |
✅ | seek supported |
| FLAC | native (.flac) |
✅ | seek supported (with a seek table) |
| Opus | in OGG (.opus) |
✅ | Core Audio parses the container on this platform |
| WAVE | PCM (.wav) |
✅ | tolerates extra chunks (LIST/INFO, FLLR) before data; seek supported |
| ALAC | MP4 (.m4a) |
✅ | |
| ALAC | CAF (.caf) |
the packet table trails the audio data, so the streaming parser reports optm. Local files play through APlayExtras |
|
| AIFF / AIFF-C | PCM (.aiff, .aifc) |
AudioFileStream reports a packet discontinuity (dsc!). Local files play through APlayExtras |
The hint table also routes these Core Audio-native extensions, but no fixture ships for
them, so they are not covered by the test matrix: .m4b, .ac3, .amr, .3gp,
.3g2, .mp2, .mp1, .au/.snd, .rf64, .sd2. Formats Core Audio does not ship
(Vorbis, Opus outside an OGG container, …) need an injected custom decoder — see
audioDecoderBuilder and the Todo list.
Earlier releases could only run in DEBUG mode: with optimization enabled (-O) the decode
loop would stall. The root cause was a set of dangling pointers around the audio converter —
outDataPacketDescription pointed at a stack-local AudioStreamPacketDescription that the
converter dereferences after the input callback returns, and the decode/output buffers were
handed to Core Audio through unscoped inout references. These are now backed by stable,
object-owned storage and scoped pointer access, so optimized Release builds work correctly.
ℹ️ Plain
http://streams: iOS blocks non-HTTPS URLs via App Transport Security by default. If your stream URL ishttp://..., add anNSAllowsArbitraryLoads(or a per-domain) exception to your app'sInfo.plist, otherwise the open will fail with a permission error. This is the most common cause of "cannot play HTTP stream".
Run ./generate_docs.sh
-
CPU-friendly design to avoid excessive peaks
-
Support seek on WAVE, and FLAC(with seektable)
-
Support all type of audio format(MP3, WAVE, FLAC, etc...) that iOS already support(Not fully tested)
-
Digest(Tested), Basic(not tested) proxy support
-
Multiple protocols supported: ShoutCast, standard HTTP, local files
-
Prepared for tough network conditions: restart on failures,restart on not full content streamed when end of stream
-
Metadata support: ShoutCast metadata, ID3V1, ID3v1.1, ID3v2.2, ID3v2.3, ID3v2.4, FLAC metadata
-
Local disk storing: user can add folders for local resource loading
-
Playback can start immediately without needing to wait for buffering
-
Pre-load a track with
prepare(_:): the streamer and decoder buffer the audio without starting the output audio unit, so a laterplay(_:)of the same URL starts instantly from the filled ring buffer -
Gapless playlist playback: with
Configuration(gaplessPlaybackEnabled: true)the next track is preloaded while the current one plays, and the output audio unit switches sources at the end of the track without stopping — no gap and no.pausedstate at the handoff (same format only; a format change still re-initializes) -
Support cached the stream contents to a file
-
Built-in
NBandEQequalizer wired into theAUPlayeraudio graph (band frequencies configurable viaConfiguration.equalizerBandFrequencies; band gains adjustable at runtime viasetEqualizerBandGain(_:at:)) -
Custom logging module and logging into file supported
-
Open protocols to support customizing.
AudioDecoderCompatible,ConfigurationCompatible,LoggerCompatible... -
Swift 6 language mode with strict concurrency checking enabled
Swift Package Manager is the only supported way to add APlay — there is no CocoaPods spec and no Carthage support anymore:
.package(url: "https://github.com/CodeEagle/APlay.git", from: "2.0.0")Add the APlay product to your app target. The package declares macOS 12+, iOS 15+,
tvOS 15+ and visionOS 1+ as supported platforms. The same Package.swift also builds
the APlayMacPlayback end-to-end validation target and the APlayTests suite on
macOS, so swift build and swift test are the single source of truth for the
framework.
Platform notes: on iOS and visionOS the audio session is configured with the
.playback category and the long-form-audio route sharing policy, and the lock screen
/ AirPlay 2 remote commands are wired (see AirPlay 2 and remote control below). tvOS
has no AVAudioSession, MPNowPlayingInfoCenter or background-task concept, so those
stay compiled out there — the decoder, ring buffer and render path work unchanged.
The two rows above marked ⚠️ stream only are seekable file formats whose layout a
streaming parser cannot handle — the packet table sits after the audio data, or the
container reports a discontinuity. Local files in those formats still play, but only
through a seekable file decoder.
APlayExtras is an optional companion library that adds exactly that. Add the product
only when you need it; apps that never touch CAF/AIFF/AIFF-C stay on APlay alone with
no extra code:
.product(name: "APlayExtras", package: "APlay")import APlay
import APlayExtras
// `audioDecoderBuilder` is read-only after init, so the builder goes through the
// configuration initializer. The fallback keeps every other format unchanged.
let player = APlay(configuration: APlay.Configuration(
audioDecoderBuilder: APlayExtras.fileDecoder(fallback: APlay.Configuration().audioDecoderBuilder)))Local CAF/AIFF/AIFF-C files route through an ExtAudioFile-backed decoder; everything
else goes to the fallback you supply. An app that already injects its own decoder (a
custom codec, for example) can wrap it instead of the built-in one.
The audio session runs the .playback category with the longFormAudio route sharing
policy — the combination AirPlay 2 expects for long-form audio — and the lock screen,
Control Center and AirPlay 2 remote commands are wired to the player by default, so a
HomePod, an Apple TV or the iOS lock screen can control playback without extra app
code: play / pause / toggle, next / previous track, change position (when the track is
seekable) and ±15 s skip:
let player = APlay() // remote commands already installedPass Configuration(enableRemoteCommandHandling: false) to opt out.
To let the user pick an AirPlay route, add an AVRoutePickerView (or an MPVolumeView
with its route button) to your UI — that is app-level UI the framework deliberately does
not ship. Now-playing metadata (title / artist / album / artwork / elapsed time) is
already published to MPNowPlayingInfoCenter via metadataUpdate.
The player runs an AVAudioUnitEQ in its render chain, one band per entry of
Configuration.equalizerBandFrequencies (8 by default: 50 / 100 / 200 / 400 / 800 /
1600 / 2600 / 16000 Hz). The first band is a low shelf, the last a high shelf and the
rest parametric, so a single curve shapes the whole spectrum.
let player = APlay()
// A preset is plain data — one gain in dB per band, in the same order as the
// configured frequencies. It takes effect immediately; playback is not restarted.
player.applyEqualizerPreset(.rock)
// Or a single band, clamped to the audio unit's -96...24 dB range.
player.setEqualizerBandGain(3.5, at: 2)
// Read the current curve back.
let gains: [Float] = player.equalizerGainsTen curves are bundled — flat, rock, pop, jazz, classical, bassBoost,
trebleBoost, vocal, electronic, acoustic — or build your own:
let curve = EqualizerPreset(name: "My Curve", gains: [4, 3, 2, 1, 0, 1, 2, 3])
player.applyEqualizerPreset(curve)A preset whose band count differs from the configuration's frequencies is ignored (and logged), so a saved curve never silently shifts the wrong frequencies after a configuration change.
- AirPlay 2 support: the session runs the
longFormAudioroute sharing policy and the lock screen / Control Center / AirPlay 2 remote commands (play / pause / next / previous / seek / ±15 s skip) are wired in by default viaMPRemoteCommandCenter; route picking stays app-level UI (AVRoutePickerView) - AudioEffectUnit support: band frequencies and gains are configurable, and gains apply at runtime either per band or as a whole preset — see "Equalizer" above.
- Custom decoder formats (see issue #17): the
audioDecoderBuilderseam already hands an injected decoder the stream's file hint (verified for.opus), so an app can decode a format Core Audio does not ship — the remaining gap is bundling a reference implementation.
