> *Your music library. Your hardware. No subscriptions. No ads. No cloud.*
A native iOS + watchOS music player handcrafted in Swift for [Navidrome](https://www.navidrome.org/) servers. Built because every streaming app charges rent for music you already own — and none of them have a waveform that looks like this.
---
## The Visualizer
This is the part worth talking about first.
NavidromePlayer renders a **Mitsuha-style liquid waveform** — named after the classic anime visualization that made smooth, flowing audio waves famous. Every frame is drawn with Catmull-Rom splines, a curve interpolation algorithm that eliminates the jagged polygon edges of typical FFT displays and replaces them with fluid, continuous shapes that genuinely move with the music.
### Three-source pipeline
The visualizer automatically selects the best available data for the current track:
The Companion API runs a full 1024-point FFT pass over your audio files ahead of time, storing the results as compact frame arrays. When you play a song, the app downloads these frames once and caches them locally. Playback syncs the frame index to `currentTime / duration` — frame-perfect visualization with zero CPU cost during playback. A 4-minute song generates ~7,200 frames.
**2. On-device offline analysis** *(great — for downloaded songs)*
For locally stored files, `OfflineAudioAnalyzer` runs a custom Swift/Accelerate FFT pipeline using `vDSP_fft` with a Hann window, a hop size calculated from your target FPS, and a ring buffer that processes the file faster than real-time. The result is identical frame-perfect sync without requiring the Companion API. Analysis runs in a background Task and cancels immediately if you exit the app — it never interferes with battery life.
**3. Live engine FFT** *(good — for streaming)*
When no cached data exists, an `MTAudioProcessingTap` is installed directly on the `AVPlayerItem.audioMix`, tapping the audio stream at the hardware level. FFT results feed the visualizer at 30fps with no perceptible latency.
### The rendering chain
Each frame goes through several stages before anything appears on screen:
- **Log-frequency binning** — raw FFT bins are mapped logarithmically so bass frequencies get appropriate visual weight relative to treble. Linear FFT looks wrong; your ears are logarithmic.
- **Dynamic gain normalization** — a peak follower tracks the loudest frame seen so far and scales the entire output accordingly. Quiet passages still look interesting. Loud passages don't clip.
- **Exponential smoothing** — `displayLevel += (target - displayLevel) * smoothFactor` applied per-bar every frame. The `viscosity` setting controls how snappy or liquid the response feels.
- **Depth shadow** — a second wave drawn behind the main wave at reduced opacity, phase-shifted using a 60/40 blend of current and next Y values to create a parallax drag effect. This gives the wave a sense of depth without any GPU compositing.
- **Wobble phase** — an independent time-based phase offset drives a slow undulation in the wave baseline, keeping the idle state alive and giving the playing state a subtle organic quality.
- **Catmull-Rom splines** — final bar heights are connected with Catmull-Rom interpolation rather than straight lines. The curve passes smoothly through every data point.
### Four styles
| Style | Description |
|-------|-------------|
| **Wave** | Classic Mitsuha liquid waveform with depth shadow and Catmull-Rom curves |
| **Siri Wave** | Symmetric wave mirrored above and below center |
| **Bar** | Vertical frequency bars with rounded tops |
| **Line** | Minimal single-line trace through the frequency data |
### Color modes
- **Dynamic** — colors shift continuously through a hue cycle at a configurable speed
- **Album art** — dominant color extracted from the current track's cover art using a custom pixel sampler, changes on every track
- **Custom** — a fixed color you pick, applied consistently
### Per-view configuration
Now Playing and Mini Player are configured independently. Each has its own point count (8–64), sensitivity, color mode, and style. The Mini Player visualizer suspends automatically when the full Now Playing view is open.
### The architecture rule that makes it work
The entire visualizer lives inside a single `TimelineView(.periodic)` that never unmounts while enabled. Rendering mode (live vs. idle) is decided inside the Canvas body, not by swapping views. This eliminates a SwiftUI compositor bug where the old idle Canvas persists in the render pipeline while the new live view composites its first frame — manifesting as a frozen wave after pause/resume. One stable view identity. No stale frames.
**`VisualizerLevelBox`** is a `fileprivate final class ObservableObject` with zero `@Published` properties, held by `@StateObject`. All mutations happen inside the SwiftUI Canvas closure — never through Combine. The visualizer updates at 60fps without triggering SwiftUI view diffing anywhere in the tree.
**`VisFrameBuffer`** stores pre-analyzed FFT frames as a flat `ContiguousArray<Float>` with memory-mapped I/O (`alwaysMapped`). `copyFrame(at:into:)` does a single contiguous copy into a pre-allocated destination — zero allocations per frame in the hot path.
**Background CPU** — four sources of background CPU were identified and eliminated: vis timers (30/60fps), the periodic time observer (10Hz SwiftUI re-evaluation), the offline FFT analysis Task (ran to completion with no cancellation), and SmartCrossfadeManager's independent 10Hz time observer. All four suspend on `didEnterBackground` and resume conditionally on `willEnterForeground`.
Completely optional. The app is fully functional without it. With it, you get Smart DJ crossfade, silence skipping, loudness normalization, tag editing, batch uploads, and server-precomputed visualizer frames.
Runs as a Docker container alongside Navidrome — typically on the same Raspberry Pi or home server that already runs Navidrome.