+1 (415) 993-7206

Spatial Audio in RealityKit: The Cheapest Realism You Can Add to an AR App

Written against iOS 18–26 and RealityKit's current audio APIs, and re-checked against the iOS 27 betas. Code is Swift, RealityKit-first — SceneKit is deprecated and none of this is worth building there.

Most AR demos are silent. Most AR products should not be.

Audio is the cheapest realism you can buy in an AR app. A virtual object that hums, clicks, or thuds when it lands stops looking like a decal on the camera feed and starts feeling like it is in the room. It is also the cheapest guidance you can buy: a sound pulling from off-screen left is a better "look over here" than any arrow you can draw. And unlike more geometry or a fancier shader, audio costs you almost nothing in frame rate or thermals — which matters, because on a phone thermals are the real budget.

This tutorial covers the three audio components RealityKit gives you, how to pick between them, how to make sound respect the room your user is standing in, and the handful of mistakes we see in almost every audit.

The three audio components

RealityKit does not have "an audio API." It has three, and choosing wrong is the most common problem we find.

ComponentBehaves likeUse it for
SpatialAudioComponentA point source in 3D with distance falloff and directivityAnything that belongs to an object: engine noise, a UI click on a virtual button, footsteps
AmbientAudioComponentA bed that surrounds the listener, no distance falloffRoom tone, music, weather — the "everywhere" layer
ChannelAudioComponentRaw channels straight to the output, no spatializationNarration, voiceover, accessibility prompts, tutorial VO

The rule of thumb: if the user should be able to turn their head and locate it, it is spatial. If it should follow them everywhere, it is ambient. If it is a voice talking to them rather than in the scene, it is channel audio.

Narration is the one people get wrong most often. Spatialized narration sounds like a person hiding behind the sofa. Put it on a channel.

Loading and playing a sound

Audio in RealityKit is played by an entity, which is what gives it a position. The modern path is to load an AudioFileResource and hand it to the entity's audio playback controller.

import RealityKit
import SwiftUI

struct EngineView: View {
    var body: some View {
        RealityView { content in
            let machine = try! await Entity(named: "Machine", in: realityKitContentBundle)

            // 1. Make the entity a spatial emitter.
            var spatial = SpatialAudioComponent()
            spatial.gain = -6.0                     // decibels, 0 is unity
            spatial.directivity = .beam(focus: 0.4) // 0 = omni, 1 = tight beam
            machine.spatialAudio = spatial

            // 2. Load the audio.
            let hum = try! await AudioFileResource(
                named: "engine_loop.wav",
                configuration: .init(shouldLoop: true)
            )

            // 3. Play it *from* that entity.
            machine.playAudio(hum)

            content.add(machine)
        }
    }
}

Three things to notice.

Gain is in decibels, not a 0–1 scale. gain = -6.0 is roughly half as loud as unity; 0 is full. Designers hand you assets normalized to peak, so you will almost always be applying negative gain, not positive.

Directivity is free realism. A real speaker, mouth, or engine is louder in front than behind. .beam(focus:) gives you that for nothing. Use it on anything with an obvious front — a TV, a character, a machine with a vent — and leave .omnidirectional for hums and rattles.

The emitter's transform matters. Audio radiates from the entity's origin. If your model's origin is at the floor under a 2 m machine, the sound comes from the floor. Parent a small empty entity at the vent, the speaker grille, the mouth — wherever the sound would physically come from — and attach the audio to that.

let vent = Entity()
vent.position = [0, 1.4, 0.3]      // local to the machine
machine.addChild(vent)
vent.spatialAudio = SpatialAudioComponent(gain: -6.0)
vent.playAudio(hum)

Make it sound like the actual room

This is the part that separates a demo from a product, and it is the part almost nobody ships.

On LiDAR devices you already have a mesh of the room from scene reconstruction (see LiDAR scene reconstruction and occlusion). RealityKit can use an understanding of the room to apply reverb, so a sound in a tiled bathroom is not mixed identically to the same sound in a carpeted office. Where the platform exposes automatic room-aware reverb, take it; where you need to override, set the reverb preset explicitly per space.

// Scene-wide reverb. Prefer automatic where available; override for
// authored spaces (a "cathedral" scene should not sound like a cupboard).
content.audioListener = cameraAnchor        // where the ears are

Two practical notes from client work:

  • Set the listener. In a RealityView on iOS, the listener defaults sensibly, but as soon as you have a camera anchor, a portal, or a scaled-down "tabletop" scene, you need to be explicit about where the ears are. A tabletop scene scaled to 1:20 with a listener at world scale will sound wrong in a way testers describe as "muffled" and cannot explain.
  • Scale affects distance falloff. If you scale a scene down, distances shrink, and a sound authored for a 5 m room now falls off over 25 cm. Author your falloff after you have settled the scene scale, not before.

Trigger audio off ARKit events, not timers

The moments worth sonifying are the moments ARKit already tells you about:

  • Plane found / placement confirmed — a short, quiet confirmation tick. This is the single highest-value sound in a placement app.
  • Object anchored or re-localized — when a persistent anchor comes back, a soft cue tells the user the app remembered, which is otherwise invisible.
  • Collision / physics contact — subscribe to CollisionEvents.Began and play an impact whose gain scales with impulse.
  • Tracking degraded — a low, non-alarming cue paired with your coaching UI.
content.subscribe(to: CollisionEvents.Began.self, on: box) { event in
    let impulse = Float(event.impulse)
    var spatial = SpatialAudioComponent()
    spatial.gain = min(0, -24 + impulse * 6)   // quiet taps stay quiet
    event.entityA.spatialAudio = spatial
    event.entityA.playAudio(thud)
}

Scaling gain by impulse is a two-line change that testers consistently read as "the physics got better." The physics did not change.

Asset choices that actually matter

  • Mono for anything spatial. A stereo file handed to a spatial emitter wastes the second channel and, on some paths, defeats spatialization. Mono in, spatial out. Save stereo for ambient beds and channel narration.
  • 48 kHz, and compress the long stuff. Short one-shots as uncompressed WAV; loops and music as compressed. Dozens of uncompressed loops are a memory problem on older iPhones long before they are a CPU problem.
  • Loop points, not fades. A hum that audibly restarts every 4 seconds is worse than no hum. Ask your sound designer for seamless loops and check them on-device with headphones off.
  • Keep concurrent voices low. Every simultaneous spatial source costs. Twenty emitters in a scene is fine if three are audible at once; twenty playing at once is a mix nobody can parse anyway.

Do not break the user's phone experience

Two audio session behaviors get AR apps one-star reviews:

  1. Stopping the user's music. If your audio is decorative, use a session category that mixes with others rather than interrupting. If it is essential — narration in a guided tour — interrupting is defensible, but say so.
  2. Playing loudly on launch. The user is in an office, a store aisle, a factory floor. Start muted or near-muted with an obvious, persistent toggle, and remember the setting.

Also ship a real mute control in your own UI, not just "use the hardware switch." Enterprise deployments frequently mandate silent operation on the floor.

Accessibility: audio is not optional here

For low-vision users, spatialized audio is not polish; it is the interface. A placement app that plays a rising tick as the reticle finds a valid surface, and a confirmation when it locks, is usable without seeing the reticle at all. Pair it with VoiceOver-friendly channel narration and haptics via Core Haptics, and you have an AR experience that a meaningful number of users can actually operate. This is also, increasingly, a procurement requirement in public-sector and large-enterprise deals.

Testing audio without waving a phone around

You cannot unit-test "does it sound good," but you can test the parts that break silently:

  • Assert that every emitter entity has a SpatialAudioComponent (or deliberately does not) after scene load.
  • Assert gains are within a sane range — a designer's +12 sneaking into a build is a real bug we have found in shipped apps.
  • Replay a recorded ARKit session (see testing ARKit apps) and assert the expected audio triggers fired in order.

Then do the thing no CI can do: walk the space with headphones, and walk it again with the phone speaker in a noisy room. Speaker playback in a warehouse is the actual delivery condition for most enterprise AR, and mixes tuned on AirPods fall apart there.

A 90-minute checklist

If you have an AR app today with no audio, this is a single afternoon:

  1. Add one ambient bed at about -18 dB, mixing with other audio.
  2. Add a placement confirmation tick and a collision thud.
  3. Put emitters at physically plausible origins, mono, with directivity where it makes sense.
  4. Set the audio listener explicitly and re-check falloff at final scene scale.
  5. Add a mute toggle that persists.
  6. Test on speaker, in a loud room, on the oldest device you support.

That list is roughly the difference between "cool demo" and "people believe the object is there."


Need a hand? RealityRogue's senior ARKit and RealityKit developers do this work on client codebases every week — as a full build, or as contract developers embedded in your iOS team. Get in touch with your app concept, scope, or timeline and we will tell you honestly what it takes.