A SwiftUI waveform visualization framework for audio apps. Handles decoding, caching, FFT analysis, async loading lifecycle, and rendering in one package — with a realtime-safe audio pipeline and zero external dependencies.
Existing options force a choice: a static-image generator with no interaction (DSWaveformImage), a full audio engine dependency (AudioKit), or a UIKit view from 2015 (FDWaveformView). WaveformKit is a single SwiftUI-native package that covers the complete path from audio file to interactive waveform without any of those tradeoffs.
Key design decisions that differentiate it:
- Swift 6 ready — builds clean under the Swift 6 language mode with complete concurrency checking; CI fails the build on any new warning.
- Zero-allocation audio callbacks — FFT processing uses pre-allocated scratch buffers and vectorised vDSP operations. No heap allocations on the audio render thread.
- Async loading lifecycle —
WaveformLoaderdrivesWaveformState(.idle → .loading(progress) → .loaded / .failed) so loading, progress, and error states are first-class, not afterthoughts. - Extensible renderer protocol —
WaveformRendererlets you supply a custom drawing implementation without forking. Built-in styles are backed by the same protocol surface. - Zoom and pan, wired — pinch to zoom anchored under the pinch centroid, drag to pan, double-tap to reset.
WaveformViewportis a plainSendablevalue you can also drive programmatically, and the gesture arithmetic is pure and unit-tested. - Complete, not minimal — decoding, disk caching, FFT spectrum, live mic, seek gestures, markers, accessibility, and snapshot export are all included.
- Six built-in waveform styles: bars, mirrored bars, dancing bars, line, dots, circular
- Four movement modes: progress-fill, reactive (FFT-driven), combined, idle shimmer
WaveformLoaderwithWaveformStateasync lifecycle — progress, error, and retry built inWaveformRendererprotocol for fully custom styles without forking- Pinch-to-zoom, drag-to-pan, and double-tap-to-reset via
WaveformViewport—ScrollView-safe - Seek gestures on all styles — linear drag on bar/line/dot styles, angular drag on circular
- Markers and region overlays with tap callbacks, VoiceOver children, and circular-style support
- Live microphone capture (
MicrophoneRecorder) with bounded memory and interruption handling - Three player paths:
AVPlayer(streaming/local),AVAudioPlayer(local),AVAudioEnginePlayer(local + FFT) - Real FFT spectrum via
MTAudioProcessingTapon the audio render thread (Hann-windowed, 1024-point, log-spaced bands) - Resample cache — amplitude arrays are computed once per summary; 30–60 Hz re-renders hit a dictionary lookup
- Disk waveform cache keyed by file identity, with an LRU byte budget
- VoiceOver: adjustable element with
X:XX of Y:YYvalue; per-marker accessibility children WaveformView.snapshot(...)→CGImagefor thumbnails and share sheets- Peak- or mean-pooled resampling (
WaveformResampleMode) — transients survive a low bar count - Zero external dependencies — AVFoundation, MediaToolbox, Accelerate only
- DocC documentation hosted on Swift Package Index
- iOS 17.0+ / macOS 14.0+ / visionOS 1.0+
- Swift 6.0+ / Xcode 16+
The package builds under the Swift 6 language mode with complete concurrency checking and no
warnings. It declares swiftLanguageModes: [.v6, .v5], so it still compiles if your project pins
a Swift 5 language mode — but the toolchain itself must be 6.0 or newer.
Xcode: File → Add Package Dependencies → enter the repository URL.
Package.swift:
dependencies: [
.package(url: "https://github.com/GRimAce11/WaveformKit.git", from: "0.6.0")
]A runnable showcase app lives in Demo/. Open Demo/Test Waveform.xcodeproj — it references WaveformKit via local path so it builds immediately without an SPM fetch.
| Screen | What it shows |
|---|---|
| Style Gallery | All 6 styles with live movement, colour, and progress controls |
| Playback | WaveformLoader + AVPlayer + markers + seek scrubbing |
| Async Loading | WaveformState lifecycle — progress bar, cancel, retry, error |
| Microphone | Live FFT recording + interruption handling + captured-file playback |
| Custom Renderer | Three WaveformRenderer implementations with annotated source |
| Viewport | Pinch / pan / double-tap gestures, both dragBehavior modes, inside a ScrollView |
The demo generates a test tone on-device at first launch — no bundled audio files, no network required.
The recommended entry point is WaveformLoader + WaveformView(loader:). It handles the loading lifecycle, shows a skeleton shimmer while decoding, and transitions cleanly to the real waveform.
import SwiftUI
import WaveformKit
struct PlayerView: View {
let url: URL
@State private var loader = WaveformLoader()
@State private var adapter = AVPlayerAdapter(player: AVPlayer())
@State private var tap: AVPlayerAmplitudeTap?
var body: some View {
WaveformView(
loader: loader,
currentTime: adapter.currentTime,
amplitude: tap?.currentAmplitude ?? 0,
bands: tap?.bands ?? [],
style: .dancingBars(count: 32),
movement: .reactive(boost: 1.4),
colors: WaveformColors(played: .accentColor, unplayed: .secondary.opacity(0.25)),
onSeek: { adapter.seek(to: $0) }
)
.waveformStateOverlay(loader.state)
.frame(height: 80)
.task {
let player = AVPlayer(url: url)
adapter = AVPlayerAdapter(player: player)
tap = AVPlayerAmplitudeTap(player: player, bandCount: 32)
loader.load(url: url)
player.play()
}
}
}WaveformView(loader:) renders an .idle shimmer while the summary decodes, then transitions to the real waveform once loader.state == .loaded. .waveformStateOverlay adds a progress bar during loading and an error view if decoding fails.
WaveformLoader is an @Observable @MainActor class that manages the full decode cycle. It exposes a single state: WaveformState property that drives your UI reactively.
public enum WaveformState {
case idle
case loading(progress: Double) // 0.0 ... 1.0
case loaded(WaveformSummary)
case failed(Error)
}@State private var loader = WaveformLoader()
var body: some View {
switch loader.state {
case .idle:
Text("No file selected")
case .loading(let p):
ProgressView(value: p).padding()
case .loaded(let summary):
WaveformView(summary: summary, currentTime: 0)
case .failed(let error):
Label(error.localizedDescription, systemImage: "xmark.circle")
}
}// Start a decode (cancels any in-progress decode first)
loader.load(url: fileURL, targetBars: 200, useCache: true)
// Cancel a running decode — state → .idle
loader.cancel()
// Retry after a failure
loader.retry()When a feed mixes files that still need decoding with summaries you already hold — fetched from
a server, restored from a previous session — both go through the same call. A .precomputed
source resolves to .loaded synchronously: no decode, no cache write, no progress ticks.
let source: AudioSource = item.cachedSummary.map(AudioSource.precomputed)
?? .file(item.localURL)
loader.load(source: source)// The same thing load(source:) does for .precomputed, when you have no AudioSource to wrap
loader.set(WaveformSummary.demo(duration: 30))The original one-shot static method is preserved for source compatibility:
let summary = try await WaveformLoader.load(url: url, targetBars: 200)Recorded from the bundled demo app — .bars, .mirroredBars and .dancingBars under .reactive movement.
| Style | Appearance | Typical Use |
|---|---|---|
.bars |
Vertical bars rising from the bottom | Podcast seeker, SoundCloud |
.mirroredBars |
Bars centered on the midline | WhatsApp / iMessage voice notes |
.dancingBars |
Bouncing equalizer bars | "Now Playing" widgets, live audio |
.line |
Smooth filled mirrored curve | Minimal / editorial |
.dots |
Capsules along the midline | Voice note minimal |
.circular |
Radial bars around a centre | Album art overlay, AirPods UI |
WaveformView(summary: s, currentTime: t, style: .bars(count: 120))
WaveformView(summary: s, currentTime: t, style: .mirroredBars())
WaveformView(summary: s, currentTime: t, style: .line(thickness: 1.5))
WaveformView(summary: s, currentTime: t, style: .dots(count: 60))
WaveformView(summary: s, currentTime: t, style: .circular(count: 64))
.aspectRatio(1, contentMode: .fit)
// Live spectrum analyzer
WaveformView(summary: s, currentTime: t,
amplitude: tap.currentAmplitude, bands: tap.bands,
style: .dancingBars(count: 32), movement: .reactive())| Mode | Behaviour |
|---|---|
.progress |
Static waveform; played/unplayed colour split |
.reactive(boost:) |
Bar height scales with live amplitude; no progress fill |
.combined(boost:) |
Progress fill AND reactive amplitude on the played portion |
.idle |
Ping-pong shimmer — loading skeleton or paused state |
A summary is decoded at a fixed resolution (200 bins by default) and a view can ask for any
number of bars. When there are more bins than bars, each bar has to stand for several bins —
resampleMode decides how they collapse.
| Mode | Each bar becomes | Looks like |
|---|---|---|
.peak (default) |
the loudest bin it covers | Transients survive: drum hits, plosives, edits |
.mean |
the average of its bins | Smooth, flat, low-contrast |
WaveformView(summary: s, currentTime: t, style: .bars(count: 60), resampleMode: .peak)Because the decoder has already reduced each bin to an RMS value, .mean is an average of
averages — peaks erode quickly as the bar count drops, and a busy track flattens toward a
uniform block. .peak keeps the shape recognisable as that recording. Use .mean when you
deliberately want an even, ambient level-meter look.
Changed in 0.6.0:
.peakis the new default. Pre-0.6.0 behaviour was mean pooling — passresampleMode: .meanto keep the old appearance.
Implement WaveformRenderer to draw anything without modifying the library.
struct OscilloscopeRenderer: WaveformRenderer {
func draw(
context: inout GraphicsContext,
size: CGSize,
amplitudes: [Float],
progress: Double,
amplitudeScale: CGFloat,
showsProgress: Bool,
colors: WaveformColors
) {
guard amplitudes.count > 1 else { return }
let midY = size.height / 2
var path = Path()
path.move(to: CGPoint(x: 0, y: midY))
for (i, amp) in amplitudes.enumerated() {
let x = size.width * CGFloat(i) / CGFloat(amplitudes.count - 1)
let y = midY - CGFloat(amp) * amplitudeScale * midY
path.addLine(to: CGPoint(x: x, y: y))
}
context.stroke(path, with: .color(colors.played), lineWidth: 1.5)
}
}
WaveformView(
summary: summary,
currentTime: player.currentTime,
style: .custom(renderer: OscilloscopeRenderer(), barCount: 200)
)WaveformRenderer conformances must be Sendable. Stateless value types are the simplest approach; class-based renderers with mutable state need their own synchronisation (@unchecked Sendable + a lock or actor).
Pass a WaveformViewport binding and the view becomes zoomable: pinch to zoom, drag to pan,
double-tap to reset. Without a binding nothing changes — the gestures have no state to drive.
@State private var viewport = WaveformViewport(duration: summary.duration)
WaveformView(
summary: summary,
currentTime: player.currentTime,
viewport: $viewport,
onSeek: { player.seek(to: $0) }
)A pinch is anchored at the gesture centroid, so the audio under your fingers stays put. Zoom is
clamped to maxZoomFactor and minVisibleDuration, whichever binds first.
Pinch always zooms. The ambiguous input is the one-finger drag — it could mean "scrub" or
"scroll the timeline" — so WaveformZoomOptions.dragBehavior picks:
| Behaviour | Drag at 1× | Drag while zoomed | Use for |
|---|---|---|---|
.seek (default) |
seeks | seeks | Players — scrubbing is the point |
.panWhenZoomed |
seeks | pans the visible range | Editors — the waveform is a timeline |
WaveformView(summary: summary, currentTime: t, viewport: $viewport, zoom: .editor)Three presets cover the common cases:
.disabled // no gestures; drive the viewport programmatically only
.editor // dragBehavior = .panWhenZoomed
.inScrollView // vertical drags scroll the enclosing list instead of seekingOr build your own:
WaveformZoomOptions(
dragBehavior: .panWhenZoomed,
maxZoomFactor: 20,
minVisibleDuration: 0.5,
resetsOnDoubleTap: true,
yieldsToVerticalScroll: false
)A waveform row in a scrolling list has a real conflict: a minimumDistance: 0 drag claims the
touch immediately and the list stops scrolling. .inScrollView resolves it by attaching the
gesture simultaneously rather than exclusively, and by withholding any seek or pan until the
touch has travelled scrollIntentThreshold points — at which point a drag taller than it is wide
is abandoned for the rest of the gesture. Taps still seek.
List(episodes) { episode in
WaveformView(
summary: episode.summary,
currentTime: episode.position,
viewport: $viewport,
zoom: .inScrollView,
onSeek: { seek(episode, to: $0) }
)
.frame(height: 44)
}Double-tapping resets to the full-duration view. The first tap of the pair still seeks — on a
waveform a tap means "play from here", and suppressing it would mean delaying every tap by the
double-tap interval. Set resetsOnDoubleTap: false to turn it off.
The viewport is an ordinary value type, so gestures and code can both move it:
// Jump to a specific region
viewport.visibleRange = 95...125 // seconds
// Zoom 4× centred on the playhead
viewport.zoom(to: 4, anchor: player.currentTime / summary.duration)
// Pan forward 10 seconds
viewport.pan(by: 10)
// Reset
viewport.resetZoom()When viewport is nil (the default) or zoomFactor == 1.0, WaveformView behaves exactly as
it did before 0.6.0 — no breaking change.
For local files with live spectrum bands, AVAudioEnginePlayer conforms to both WaveformPlayerAdapter and AmplitudeTap:
let player = try AVAudioEnginePlayer(url: url, bandCount: 32)
player.play()
WaveformView(
summary: summary,
currentTime: player.currentTime,
amplitude: player.currentAmplitude,
bands: player.bands,
style: .dancingBars(count: 32),
movement: .reactive(),
onSeek: { player.seek(to: $0) }
)let player = AVPlayer(url: url)
let adapter = AVPlayerAdapter(player: player)
let tap = AVPlayerAmplitudeTap(player: player, bandCount: 32)
WaveformView(
summary: summary,
currentTime: adapter.currentTime,
amplitude: tap.currentAmplitude,
bands: tap.bands,
onSeek: { adapter.seek(to: $0) }
)MTAudioProcessingTap runs on the audio render thread. The main thread polls at 30 Hz with attack/decay envelope smoothing.
let player = try AVAudioPlayer(contentsOf: url)
let adapter = AVAudioPlayerAdapter(player: player)
let tap = AVAudioPlayerAmplitudeTap(player: player)
// tap.bands is always empty — AVAudioPlayer has no PCM accesslet markers: [WaveformMarker] = [
WaveformMarker(time: 12, color: .yellow, label: "Intro"),
WaveformMarker(time: 48, duration: 22, color: .orange, label: "Verse 1"),
WaveformMarker(time: 95, color: .pink, label: "Drop"),
]
WaveformView(
summary: summary,
currentTime: t,
style: .mirroredBars(count: 120),
markers: markers,
onSeek: { player.seek(to: $0) },
onMarkerTap: { marker in player.seek(to: marker.time) }
)- Point markers (
duration: 0): vertical line + dot. Tap →onMarkerTap. - Region markers (
duration > 0): translucent band + edge stripe. Tap inside or near an edge →onMarkerTap. - A drag always fires
onSeek;onMarkerTaponly fires on a tap (no drag). - All six styles support markers.
.circularrenders radial ticks and arc regions with arc-length hit-testing.
@State private var recorder = MicrophoneRecorder(
bandCount: 32,
binsPerSecond: 20,
maximumDuration: 60,
outputURL: FileManager.default.temporaryDirectory
.appendingPathComponent("memo.caf")
)
var body: some View {
WaveformView(
summary: recorder.summary,
currentTime: recorder.currentTime,
amplitude: recorder.currentAmplitude,
bands: recorder.bands,
style: .mirroredBars(count: 80),
movement: .reactive(boost: 1.4)
)
.frame(height: 60)
.task { try? await recorder.start() }
}Requires NSMicrophoneUsageDescription in Info.plist. Interruptions and route changes are handled automatically. Set autoResumeAfterInterruption: false to stay paused after a phone call.
Memory is bounded: when the amplitude array exceeds maxBins (default 4000), adjacent pairs are averaged in-place. A 24-hour recording stays under 16 KB.
AVPlayerAmplitudeTap and AVAudioEnginePlayer run a 1024-point Hann-windowed FFT on the audio render thread. Bands are logarithmically spaced from 40 Hz to 16 kHz.
let tap = AVPlayerAmplitudeTap(player: player, bandCount: 32)
// tap.bands [Float] — bandCount values in [0, 1]
// tap.currentAmplitude Float — smoothed RMS across channelsWhen tap.bands.count >= count, .dancingBars drives each bar from its own frequency range. Otherwise it falls back to amplitude-driven wobble — still visually convincing for voice/podcast content.
// Automatic via WaveformLoader:
loader.load(url: url) // useCache: true by default
// Manual:
let summary = try await WaveformLoader.load(url: url, targetBars: 200)
// Invalidation:
WaveformCache.remove(url: url, targetBars: 200)
WaveformCache.clear()Cache key: filename + file size + mtime + bar count + format version. Stored in
~/Library/Caches/WaveformKit/.
The cache is bounded by a byte budget and evicts least-recently-used entries once it is exceeded. "Recently used" counts reads as well as writes, so a frequently-opened file outlives a one-off import. Eviction runs automatically after every save.
// Default: 32 MB, on the order of 10 000 summaries at 200 bars
WaveformCache.configuration = .default
// Tighter budget — shrinking it evicts immediately rather than waiting for the next save
WaveformCache.configuration = WaveformCache.Configuration(maximumBytes: 4 * 1024 * 1024)
// Opt out of eviction entirely (pre-0.6.0 behaviour)
WaveformCache.configuration = .unbounded
// For a "Clear cache (12.4 MB)" settings row
let bytes = WaveformCache.currentByteSize
WaveformCache.evictIfNeeded() // reclaim on demandif let cg = WaveformView.snapshot(
summary: summary,
size: CGSize(width: 300, height: 60),
style: .mirroredBars(count: 80),
colors: WaveformColors(played: .accentColor)
) {
let image = UIImage(cgImage: cg) // iOS
}Use this for List / LazyVStack cells instead of a live Canvas per row.
WaveformView is a single adjustable VoiceOver element. Value: "0:42 of 3:14". Swipe up/down seeks by 5 % of duration and routes through onSeek. Marker count is appended to the label.
Each WaveformMarker is exposed as its own focusable child. Phrasing: "Intro, at 0:12" (point), "Verse, 0:48 to 1:10" (region). Reuse labels in custom wrappers: WaveformView.markerAccessibilityLabel(for:).
┌───────────────────────────────────────────────────────────────────┐
│ Decoding + Caching │
│ │
│ AudioDecoder (AVAssetReader + vDSP_rmsqv per-bar RMS) │
│ └──► WaveformSummary (amplitudes, duration, sampleRate, id) │
│ └──► WaveformCache (disk, file-identity key) │
│ │ │
│ WaveformLoader (@Observable, async, cancellable)│
│ └──► WaveformState │
└─────────────────────────────────┬─────────────────────────────────┘
│
┌─────────────────────────────────▼─────────────────────────────────┐
│ Rendering │
│ │
│ WaveformView │
│ ◄── WaveformSummary │
│ ◄── PlayerAdapter.currentTime (30 Hz, @Observable) │
│ ◄── AmplitudeTap.currentAmplitude + .bands │
│ ◄── WaveformViewport? (visible time range) │
│ │
│ ResampleCache (keyed by summary.id + barCount + slice) │
│ WaveformRenderer protocol → 6 built-in + .custom(any Renderer) │
└─────────────────────────────────┬─────────────────────────────────┘
│
┌─────────────────────────────────▼─────────────────────────────────┐
│ Realtime Audio Pipeline │
│ │
│ MTAudioProcessingTap / AVAudioEngine installTap (render thread) │
│ └──► FFTAnalyzer (1024-pt vDSP, ring buffer, zero allocs) │
│ └──► AmplitudeTapStorage │
│ ├── bandScratch (audio thread only) │
│ └── os_unfair_lock → bands (main thread) │
└───────────────────────────────────────────────────────────────────┘
Decoding — AVAssetReader reads the file once, computing RMS per bar via vDSP_rmsqv. The result is serialised to disk. Future opens skip decoding entirely.
Rendering — WaveformView body runs on the main thread. resampleAmplitudes runs once per unique (summary.id, barCount, visibleSlice) and the result is cached in ResampleCache. Under reactive/dancing-bars movement (30–60 Hz body evaluations), re-renders hit the cache — no [Float] allocation per frame.
Realtime pipeline — All FFT work runs on the audio render thread using pre-allocated buffers. The only synchronisation is a single os_unfair_lock held for O(bandCount) scalar stores (~20 ns for 32 bands). No Objective-C, no Swift runtime overhead, no heap allocations in the process callback.
Measured on Apple Silicon (macOS 14, Release, 10 000 iterations):
| Operation | Measured | Budget |
|---|---|---|
FFTAnalyzer.computeBands (1024-pt, 32 bands) |
6.4 µs/call | < 200 µs |
FFTAnalyzer.push (512 frames) |
0.20 µs/call | < 20 µs |
At 44.1 kHz with 1024-frame buffers the audio callback fires ~43 times per second (23 ms period). The FFT consumes under 0.03 % of the available render-thread budget. On A12 Bionic the same operations take roughly 2–4× longer but remain well within the budget.
These numbers are captured by the test suite and will fail CI if they regress beyond the stated budgets.
- No heap allocations — all buffers allocated once in
AmplitudeTapStorage.init. - No Swift runtime overhead — hot-path copies use
UnsafeMutablePointer.update(from:count:)(compiles tomemmove); windowing usesvDSP_vmul. - Short lock window —
os_unfair_lockheld only for the scalar copy of band values to shared storage. - No Objective-C in the process callback.
WaveformColors(
played: .pink,
unplayed: .gray.opacity(0.25),
playedGradient: Gradient(colors: [.pink, .purple]),
unplayedGradient: nil // optional
)playedGradient overrides played when set. Gradients run horizontally on linear styles, vertically on circular.
#Preview {
WaveformView(
summary: .demo(duration: 30, bars: 120),
currentTime: 12,
style: .bars(count: 120),
colors: WaveformColors(played: .accentColor)
)
.frame(height: 80)
.padding()
}WaveformSummary.demo(duration:bars:seed:) generates a deterministic envelope-shaped waveform without a real audio file.
AVAudioPlayerhas no FFT —AVAudioPlayerAmplitudeTap.bandsis always empty. UseAVAudioEnginePlayerfor the local-file + spectrum combination.- Exotic PCM formats — the audio tap handles
Float32andInt16.Int24,Int32, and big-endian variants are skipped (amplitude and bands read 0). - iOS 17 / macOS 14 floor —
@Observablerequires iOS 17+. An iOS 16 backport is on the roadmap. - Swift 6 toolchain required —
Package.swiftusesswift-tools-version: 6.0, so Xcode 15 can no longer resolve the package. The language mode is still selectable; the toolchain is not. - No tvOS — SwiftUI marks
DragGestureunavailable on tvOS, so seeking, panning, marker taps, and double-tap-to-reset cannot compile there. Everything else in the package is tvOS-clean; support needs a focus-engine interaction model, which is Phase 5. - Long recordings —
MicrophoneRecorderhalves the amplitude array when it exceedsmaxBins(default 4000). Temporal resolution on the oldest portions degrades after each halving cycle. - Zoom has no multi-resolution backing yet — at high zoom factors the view resamples a slice of the same flat amplitude array, so detail is limited by
targetBarsat decode time.WaveformSummaryPyramidaddresses this in Phase 4. - Circular style and zoom — pinch on
.circularanchors horizontally, which is geometrically arbitrary on a radial layout. Zoom on circular works but is not the intended pairing.
Completes the Phase 3 deliverables, retires the gaps between the documented API and the shipping behaviour, and brings the package to Swift 6.
Tier 1 — Viewport, wired ✅ complete
- ✅
MagnifyGesture+DragGesturebound toWaveformViewport— pinch anchored at the gesture centroid, drag-to-pan, double-tap to reset - ✅
ScrollView-safe gesture composition viaWaveformZoomOptions.inScrollView - ✅
ResampleCacheLRU bound — a live pinch mints a new slice every frame, which the old evict-on-summary-change policy never reclaimed - ✅
WaveformCacheLRU eviction with a configurable byte budget - ✅
AudioSourcewired intoWaveformLoader.load(source:)
Tier 2 — Swift 6 and render quality ✅ complete
- ✅
swift-tools-version: 6.0withswiftLanguageModes: [.v6, .v5]; the package builds under the Swift 6 language mode with zero warnings - ✅ Nonisolated-
deinitisolation fixed across all six player/recorder types — pollingTimers becameTasks, and engine/observer teardown moved toAudioTeardown - ✅ CI job building with
-warnings-as-errors - ✅
WaveformResampleModewith peak pooling as the new default
Tier 3 — Adoption surface ✅ complete
- ✅ DocC catalog (landing page + three articles) and
.spi.ymlfor hosted documentation and Swift Package Index platform badges. Noswift-docc-plugindependency — Swift Package Index builds DocC itself, so the zero-dependency promise holds - ✅ End-to-end decode tests against a synthesized
AVAudioFile—AudioDecoderhad no direct coverage at all - ✅ visionOS added to
Package.swiftplatforms, with an iOS/visionOS CI build matrix - ❌ tvOS not added:
DragGestureis unavailable there, so the entire interaction path fails to compile. Moved to Phase 5, where it needs a focus-engine model rather than a platform line
WaveformSummaryPyramid— multi-resolution amplitude arrays for efficient high-zoom rendering- Optional Metal-backed renderer path for spectrograms and large bar counts
- ProMotion 120 Hz
TimelineViewfor.dancingBars
- Region selection gesture
- RTL layout support
- tvOS support — needs a focus-engine interaction model, since
DragGestureandMagnifyGestureare both unavailable on the platform - Explicit watchOS target
- iOS 16 backport (
ObservableObject)
Phases are sequenced by stability: each phase is tested and considered stable before the next begins.

