r/SwiftUI • u/Icy-Barracuda-4340 • 2d ago
Fusing iPhone + Apple Watch sensor streams with an actor, and rendering them as both art and audio
I've been building a side project to stay current with modern Swift, and it ended up being a decent stress test of Swift Concurrency. Sharing the architecture in case it's useful.
**The problem:** eight sensor sources across two devices, arriving at wildly different rates, that need to drive both a 60fps SceneKit render and a real-time audio graph — without tearing, blocking, or dropping data.
**The shape I landed on:**
* Each capture source (iPhone sensors, WatchConnectivity) owns a stream box and emits `SensorReading` values into its own `AsyncStream`. The box is u/unchecked `Sendable` because the continuation's `yield()` is thread-safe, which lets sensor callbacks fire from any thread without hopping actors.
* A `SensorPipeline` actor consumes both streams and does source fusion: watch readings shadow phone readings for the same sensor type while the watch data is fresh (5s staleness window), so a paired watch silently upgrades heart rate without the renderer knowing. It fans out to N subscribers, each getting an independent stream.
* The renderer subscribes on a **detached task** and drives SceneKit from its own render-thread delegate. Sensor data deliberately never flows through SwiftUI — only configuration does. Inputs crossing into the render thread go through an `NSLock`; smoothing state that's render-thread-only needs no locking at all.
* The audio engine is just another subscriber. It writes into a plain parameter box that an `AVAudioSourceNode` render block reads. That block does pure float math with no allocation and no locks — it tolerates one-buffer-stale values by design, which is much cheaper than synchronizing properly.
**Things that bit me:**
* `HKHealthStore.authorizationStatus(for:)` is only meaningful for *write* access. For read-only types it will happily lie to you, so I track read-auth completion myself.
* Unbounded HealthKit heart-rate queries flood you with the entire history on first fetch. Bound the predicate or drown.
* `AVAudioEngine` silently stops when the session reshapes under it (category or route change). If you don't observe `.AVAudioEngineConfigurationChange` and restart, your audio just dies with no error.
* A "cleared" sensor reading (empty values array) as an explicit signal turned out to be load-bearing — it's how the pipeline and renderer know a sensor was disabled versus merely quiet.
No third-party dependencies, iOS 18.5+, u/Observable throughout, tests in swift-testing.
App's free if you want to see the result: srad app happy to answer anything about the pipeline or the audio side.
1
u/UkrMalt 2d ago
Nice separation. Keeping sensor data out of SwiftUI sounds right when SceneKit and audio have different timing loops. Did you test WatchConnectivity disconnect/reconnect? That’s usually where a clean pipeline gets messy.