DEV Community

MoreYogurt
MoreYogurt

Posted on

Building a native FLAC/DSD player in SwiftUI

Disclosure: I'm the developer of Trove, the app this post is about. It's a commercial app with a free download and a one-time Pro purchase; it isn't open source.

I've spent the last few months building Trove, a music player for people who keep their own files: FLAC rips, ALAC, the occasional APE album, DSD downloads, and collections stored on a NAS. It runs on iPhone, iPad, Mac and Apple Watch, with platform-specific interfaces built around a shared project.

The interesting part hasn't been drawing a play button. It's been making a player behave predictably when the files, tags, devices and networks belong to the listener—not to a catalog I control.

1. Two paths into one playback engine

Apple's audio stack can handle a useful range of formats, including FLAC, ALAC, MP3, AAC, WAV and AIFF. For those files, Trove uses the native audio path.

The extended-format path covers files such as APE, Ogg Vorbis, Opus, WMA, DSF/DFF, WavPack, TTA and Musepack. For these, Trove uses FFmpeg through the FFmpeg-Kit Swift package. The integrated package uses an LGPL build; Trove itself remains a commercial, closed-source app.

The implementation is deliberately simple: FFmpeg converts the source to a temporary 24-bit PCM WAV, and the normal playback engine opens that file. The conversion command doesn't force a sample rate. Once the WAV is ready, the same playback features—including EQ and speed controls—can work with it.

There's a cost to that simplicity. This is transcode first, then play, not streaming decoding. A track can take time to prepare, and its temporary WAV takes disk space during playback. If I were designing this path again, I'd evaluate streaming decoding earlier: reusing the existing engine was a practical starting point, but the delay and disk usage become more noticeable with long or high-resolution files.

2. DSD goes through PCM

DSD files such as DSF and DFF need a different path from the formats opened directly by Apple's audio APIs. Trove sends them through the same FFmpeg conversion path described above: the result is a 24-bit PCM WAV, which then goes through the normal audio engine.

That means the current implementation should be described as DSD-to-PCM playback, not native DSD or DoP output. The command doesn't specify a fixed output sample rate; the conversion result depends on FFmpeg and the input. I wouldn't promise a particular DSD rate or DAC behavior without testing the exact file and output device.

3. Gapless playback is a scheduling problem

A live album is an effective test of whether a player's queue is doing more than advancing to the next filename.

Trove uses AVAudioEngine and AVAudioPlayerNode. When the next track is eligible for gapless playback, the player schedules it on the same node before the current track ends. At the transition, the engine advances its bookkeeping instead of stopping and rebuilding the playback graph.

There are limits. Trove's gapless path requires matching sample rates and channel counts, and it doesn't use that path for crossfade or tracks that need the extended-format conversion described above. Other cases fall back to the normal transition.

Shuffle made the scheduling problem more interesting: a player needs to know which track is next before it reaches the boundary. Trove keeps a stable shuffle order for a round and discards a prepared next track when an action changes what should play next.

Scheduling also isn't the whole story for compressed audio. Encoder delay and padding can affect track boundaries, so I wouldn't infer that every MP3 or AAC file is sample-perfect just because the next segment was queued in time. That needs its own file-level testing.

4. Shared foundations, different Apple devices

Sharing code across Apple platforms helps, but each device still needs its own interaction model.

On the Mac, Trove's menu bar player uses SwiftUI's MenuBarExtra. System playback information and remote commands use MPNowPlayingInfoCenter and MPRemoteCommandCenter, so the app can participate in familiar system controls.

The Watch is a useful counterexample to sharing everything. Its app has a separate playback implementation rather than bringing over the iPhone and Mac engine wholesale. The larger engine was built around features such as EQ, speed and gapless scheduling; the Watch has different constraints and a smaller job.

5. CarPlay: a library rarely fits on one screen

CarPlay audio apps provide system templates rather than drawing arbitrary screens. Lists have an item limit, and a music library can easily exceed it.

Trove has four CarPlay tabs: Playlists, Albums, Artists and Songs. For long browsable lists, it uses the system's CPListTemplate.maximumItemCount as the limit and builds an index. An oversized alphabetical section is split into numbered ranges; if the index itself is too large, sections are folded into nested ranges. Lists where order matters, such as an album's track order, are divided by position instead.

The paging logic is separate from the CarPlay UI, which makes it possible to test the grouping without a car. One practical detail: the Playlists tab itself currently limits the number of playlist entries it shows, so I wouldn't claim that every CarPlay list can display an unlimited number of items.

The CarPlay interface has been checked in the simulator; that is not the same as testing it in a real car.

6. Apple Watch: transfer, then play locally

The Watch app can play transferred songs without the iPhone nearby. Trove sends files using WatchConnectivity's WCSession.transferFile, along with metadata such as the title and artist. The Watch keeps its own copy and plays it locally.

That division keeps the Watch experience understandable: transfer music from the phone first, then listen from the Watch. The Watch app doesn't browse a NAS directly, and its supported playback formats shouldn't be assumed to be identical to the iPhone's FFmpeg-backed path.

7. Local-first means handling imperfect files

A local library comes with years of decisions made by different tagging tools. There's no server in Trove's playback path that can quietly correct every malformed title or missing cover.

One bug involved accented text. An earlier repair rule tried to distinguish misdecoded Chinese text from Western European text, but it assumed adjacent accented letters would be rare. Portuguese endings such as -ção broke that assumption: the bytes could be mistaken for Chinese characters, turning a title such as “Coração” into garbled text.

The revised rule considers the shape and case of the surrounding text more carefully. Another change made repair retryable: if a file was on a disconnected network share or external drive, Trove would remember that it still needed checking instead of declaring the repair finished forever.

The lesson wasn't that one heuristic can identify every encoding perfectly. It was that repair logic needs representative real-world examples—and a way to recover when the original file is temporarily unavailable.

What I'd do differently

A few decisions became clearer only after working through real libraries:

  • Test uncommon formats with real files earlier. A supported extension and a successful build don't prove that every variation of a format decodes correctly.
  • Treat temporary files and preparation time as product behavior. Transcoding into WAV simplified the engine, but users still experience the wait and the storage cost.
  • Make library repairs retryable from the start. A missing NAS or unplugged drive shouldn't turn a temporary failure into a permanent skipped repair.
  • Keep system integration logic testable outside the system UI. Separating CarPlay's paging rules from its templates made large-library behavior easier to reason about.

Wrapping up

Building a music player for files people already own means deciding where to use the platform, where to add a decoder, and where to accept a simpler first implementation with clear limits.

For listeners trying to use a FLAC collection on an iPhone, I also wrote up the practical options in how to play FLAC on iPhone.

Happy to answer questions in the comments.

Top comments (0)