SomePlayer is a Swift Package for streaming and local audio playback on iOS and macOS. It wraps an AVAudioEngine pipeline with remote download support, time/pitch control, voice gain, silence handling, metadata extraction, timeline helpers, delegate callbacks, and an async event stream.
Add the package with the SSH GitHub URL:
.package(url: "git@github.com:smakeev/SomePlayer.git", branch: "main")Then depend on the library product:
.product(name: "SomePlayer", package: "SomePlayer")Supported platforms:
- iOS 17+
- macOS 14+
import SomePlayer
import SwiftUI
@MainActor
final class PlayerModel: ObservableObject {
private let player = SomePlayer(.stream)
private var eventTask: Task<Void, Never>?
@Published var state: SomePlayerState = .undefined
@Published var timeline = SomePlaybackTimelineState(
currentTime: 0,
duration: 0,
currentTimeText: "00:00",
durationText: "00:00",
sliderValue: 0,
sliderMaximumValue: 1,
downloadProgress: 0,
offsetProgress: 0
)
init() {
player.baseRate = 1.2
player.silenceHandlingType = .adaptiveSpeed
let events = player.subscribe()
eventTask = Task.detached { [weak self] in
for await event in events {
await self?.handle(event)
}
}
player.openRemote(URL(string: "https://example.com/audio.mp3")!)
}
func togglePlayback() {
if state == .playing {
player.pause()
} else {
player.play()
}
}
func seek(percent: Float) {
player.seekPercently(to: percent)
}
private func handle(_ event: PlayerEvent) {
switch event {
case .stateChanged(let value):
state = value
case .currentTimeUpdated, .durationUpdated, .downloadProgressUpdated, .offsetChanged:
timeline = player.timelineState
default:
break
}
}
}On iOS, configure AVAudioSession for playback before starting audio.
For typical UI binding (state, current time, duration, download progress, metadata, buffering), prefer SomeplayerEngineDelegate. The engine's DelegateEmitter already throttles delivery on @MainActor (~10 Hz) and coalesces scalar fields to the latest value per tick, so you can drive SwiftUI directly from delegate callbacks without further work.
subscribe() returns the full-rate, unthrottled event stream. It emits as fast as the audio executor produces events and includes high-frequency cases (audioBufferTap, currentTimeUpdated, downloadProgressUpdated, rateChanged while silence-skipping is active). Use it for analytics, debug logging, custom derived state, or to observe events the delegate doesn't surface — not as a general UI binding.
When you do consume subscribe(), prefer a detached task (as in the example above) so the loop runs off the main thread and only the actual UI mutation pays the MainActor hop. If the consuming task inherits MainActor isolation, every event — even ones you ignore — wakes the main thread. If you must update UI from subscribe() and the event is not rare, throttle the updates yourself (and reconsider whether the delegate covers your case — it usually does).
The main example is the SwiftUI project at Examples/SwiftUI/SomePlayerSwiftUIExample.xcodeproj. It contains shared UI plus separate macOS and iOS app targets:
Examples/SwiftUI/macOS/SomePlayerSwiftUIMacApp.swiftExamples/SwiftUI/iOS/SomePlayerSwiftUIiOSApp.swiftExamples/SwiftUI/Shared/PlayerDemoView.swiftExamples/SwiftUI/Shared/PlayerDemoViewModel.swift
The UIKit project at Examples/UIKitExample/SomePlayerUIKitExample.xcodeproj is an older example.
SomePlayerEngine is designed for cross-thread use. Public playback commands enqueue work onto the audio pipeline's serial executor, and latest-value snapshots are protected by OSAllocatedUnfairLock. Delegate delivery is drained on @MainActor by DelegateEmitter at roughly 10 Hz, with scalar events coalesced to the latest value and edge events delivered FIFO. subscribe() returns independent AsyncStream<PlayerEvent> streams and can be consumed from any task; callers should hop to MainActor before mutating UI state.
The engine and several pipeline classes use @unchecked Sendable because they wrap AVFoundation reference types. Their mutable runtime paths are serialized through the audio executor, lock-protected snapshots, or main-actor delegate delivery.
SomePlayer: alias forSomePlayerEngine.SomePlayerState: alias forSomePlayerEngine.PlayerEngineState.SomeSilenceSkippingMode: alias forSomePlayerEngine.SilenceHandlingType.SomePlayerImage:UIImageon UIKit platforms andNSImageon AppKit platforms.
Lightweight model for an item URL and optional title:
SomePlayerItem(url: url, title: "Episode title")Create an engine with a downloading policy:
let player = SomePlayer(.stream)Downloading policies:
.stream: starts playback while downloading; range-capable remote files can restart from a requested byte offset for seeks outside the downloaded window..progressiveDownload: starts playback while downloading and waits for the active download to reach far seek targets..predownload: downloads the full file before moving to.ready.
Playback state:
.undefined: no active item is ready..initializing: metadata and stream setup are running..ready: playback can start..playing: audio is playing..paused: playback is paused..ended: playback reached the end..failed: the engine or scheduling pipeline failed.
Failure types:
.engineStart:AVAudioEnginefailed to start..scheduleBuffer: scheduling an audio buffer failed..createParser: stream parser creation failed.
Opening and control:
openRemote(_:): open a remote URL.openLocal(_:): open a local file URL.play(): start or resume playback.pause(): pause playback.resume(): resume downloading after a recoverable download interruption.restart(): reset the stream and resume from the current item.reset(): restore engine state and tunable settings to defaults, then reload the current URL when present.seek(to:): seek by playback time.seekPercently(to:): seek by0...1fraction of the item.noAssetNeeded(duration:): provide a known duration and skip asset duration loading when possible.
Playback controls:
baseRate: user-selected baseline playback rate. Setting it also updatesrate.rate: current applied playback rate. Silence handling may adjust this abovebaseRate.pitch: pitch shift in AVAudioUnitTimePitch cents.volume: player node volume.globalGain: EQ gain, accepted in-96...24dB. Setting it resets silence-rate state back tobaseRate.silenceHandlingType:.none,.smart,.speedUp, or.adaptiveSpeed.simulatedDownloadChunkDelayMilliseconds: optional per-chunk download delay for testing slow streaming behavior.
Timeline and stream state:
state: currentSomePlayerState.currentTime,formattedCurrentTime: current playback time.duration,formattedDuration: best known duration, using parsed or estimated duration.timelineState: UI-readySomePlaybackTimelineState.downloadProgress: available throughtimelineState.downloadProgressand delegate/event callbacks.offset,offsetProgress: byte offset used when range seeking.rangeHeader: whether the remote server supports range requests.totalSize,headerSize,hasBytes,aboutBitrate: current stream sizing and bitrate estimates.fileDownloaded: whether the current item is fully available.isBuffering: whether the streamer is buffering.isWaitingForDownloader: progressive-download mode is waiting for bytes needed by a pending seek.isGoodForStream: metadata parser found stream-friendly header information.sampleRate: parsed stream sample rate when available.url,isLocal: current source.
Metadata:
title,artist,album: metadata extracted from the asset when available.image: artwork asSomePlayerImage.
Audio analysis:
averagePowerForChannel0,averagePowerForChannel1: latest main-mixer RMS power in decibels.lastBufferSnapshot: sendable copy of the latest main-mixer tap buffer.
Observation:
delegate: receives throttled@MainActorcallbacks throughSomeplayerEngineDelegate.subscribe(): returns an independentAsyncStream<PlayerEvent>with an initial replay of current coalesced state followed by full-rate events.delegateEmitter: exposed for tests and advanced integration; typical consumers setdelegateinstead.
Delegate callbacks are delivered on @MainActor:
updatedDownloadProgress progress:currentTaskProgress:forURL:reports total progress and current task progress.changedStatereports playback state transitions.updatedCurrentTimeandupdatedDurationreport timeline changes.savedSecondsreports time saved by silence handling.offsetChangedreports range-seek byte offset changes.changedImage,changedTitle,changedArtist,changedAlbumreport metadata.isBufferingandisWaitingForDownloaderreport streaming waits.failedDownloadWithErrorreports download failures.failedWithExceptionreports engine failures.seekFailedreports seek errors and has a default empty implementation.
subscribe() emits:
- State and timeline:
.stateChanged,.currentTimeUpdated,.durationUpdated. - Downloading:
.downloadProgressUpdated,.downloadFailed,.offsetChanged,.isGoodForStreamChanged. - Metadata:
.imageChanged,.titleChanged,.artistChanged,.albumChanged. - Buffering:
.bufferingChanged,.waitingForDownloaderChanged. - Playback controls:
.rateChanged,.baseRateChanged,.pitchChanged,.volumeChanged,.globalGainChanged,.silenceHandlingTypeChanged. - Silence handling:
.savedSecondsUpdated. - Audio tap:
.audioBufferTap. - Failures:
.failure,.seekFailed.
UI-ready timeline snapshot:
currentTime,duration: numeric timeline values.currentTimeText,durationText: formatted strings.sliderValue,sliderMaximumValue: values for a time or byte-based slider.downloadProgress: total download progress in0...1.offsetProgress: current range offset progress in0...1.
Sendable audio tap payload:
samples: per-channel floating-point samples.sampleRate: buffer sample rate.frameLength: frame count.sampleTime: tap sample time when available.
Formats TimeInterval values as mm:ss or hh:mm:ss:
let text = SomePlaybackTimeFormatter.string(from: 65) // "01:05"SomePlayerEngine can transparently speed up quiet sections of audio while keeping pitch stable through AVAudioUnitTimePitch. Select a mode with player.silenceHandlingType:
.none— leaves playback atbaseRate..smart— keeps a rolling window of recent loudness, uses the upper percentile as a speech/loudness reference, and gently raises rate when the current buffer is quieter than that reference. Capped atbaseRate + 0.75, with small per-tap steps. Good general-purpose default..speedUp— threshold-and-hysteresis silence detection. Targets a fixed2.5xwhile silent andbaseRatewhile speech is detected. Attack engages quickly; release is more gradual..adaptiveSpeed— builds a rolling loudness model with quiet/speech reference percentiles, maps current loudness into a curved silence score (with a dead zone and a minimum dynamic range so low-contrast content stays stable), and applies a proportional rate boost. Capped atbaseRate + 0.55, with conservative smoothing.
Seeking and playback state changes reset the silence controller; the effective rate returns to baseRate. The currently applied rate is exposed via player.rate and emitted as PlayerEvent.rateChanged(_:) (which can fire frequently while silence-skipping is active).
When silence skipping runs above baseRate, the engine estimates how much real time was saved on each audio-tap buffer and emits it through:
SomeplayerEngineDelegate.playerEngine(_:savedSeconds:)PlayerEvent.savedSecondsUpdated(_:)
Each delivery is a per-buffer delta — accumulate the values for a session total. See Docs/SilenceSkipping.md for the full algorithm description and Docs/Architecture.md / Docs/StreamingPipeline.md for the surrounding engine design.
The engine is composed from smaller streaming pieces, each exposed publicly so the test suite can drive layers in isolation and so advanced integrations can swap or wrap individual stages. Typical apps don't need them — use SomePlayer instead.
TimePitchStreamer—Streamerplus the rate/pitch and global-gain audio nodes. Use to drive the time-pitch audio graph without the engine's higher-level layers.Streamer/Streaming/StreamingDelegate/StreamingState— the AVAudioEngine-backed scheduling pipeline. Use to schedule playback without the engine's state machine, metadata, orPlayerEventstream.Downloader/Downloading/DownloadingState/DownloadEvent—URLSession-based byte fetcher with range-header support. Use to consume the byte stream outside the engine, e.g. for prefetching into your own cache.Parser/Parsing/ParserError— Audio File Stream Services parser producing native audio packets and format/duration info. Use when you have a byte source and want packets without playing them.Reader/Reading/ReaderError— packet-to-LPCMAVAudioPCMBufferconverter. Use to obtain PCM buffers for analysis, transcoding, or non-engine output.AudioPipeline/AudioExecutor— actor + custom serial executor that owns the audio-side work queue. Use to serialize your own audio-side work onto the same executor the engine uses.ID3Parser— stream and asset metadata extraction (isGoodForStream(_:handler:), title/artist/album/artwork/duration). Use to probe a URL or extract metadata without standing up a full engine.ResumableData— byte offset, ready-data count, and HTTP validator info for resuming range-capable downloads. Use when bridging the downloader to your own resume/seek logic.
See Docs/LowLevelAPI.md for the per-type member lists, intended use cases, and stability notes.
Sources/Public- app-facing aliases and lightweight models.Sources/Engine- player engine, event stream, delegate emitter, audio executor, and time/pitch streamer.Sources/Streaming- downloader, parser, reader, stream state, and resumable data.Sources/SilenceDetection- silence analysis and adaptive rate controllers.Sources/Metadata- ID3 and asset metadata parsing.Sources/Utilities- shared helpers.Examples/SwiftUI- main macOS and iOS example.Examples/UIKitExample- older UIKit example.Docs- implementation notes for architecture, streaming, and silence handling.