Skip to content
macOSDeep Dive Published Updated 8 min readViews unavailable

AVAudioEngine on macOS: Graph Construction, Format Negotiation, and Recovery

Build resilient AVAudioEngine graphs by validating node formats, owning graph changes, handling start failures, and recovering from device reconfiguration.

AVAudioEngine is a graph coordinator, not a promise that audio hardware is always available. Nodes describe sources, effects, mixers, and I/O endpoints; connections describe signal flow; the engine prepares and starts the graph against the current audio system. A graph that was valid before an output device change can encounter a different sample rate, channel layout, or unavailable route afterward. Treat construction, start, runtime change, and teardown as separate states with explicit ownership.

The most useful mental model is a directed signal graph. AVAudioPlayerNode or a source node can feed a mixer, optional processing nodes can transform audio, and the main mixer feeds the output node. The engine manages rendering between connected nodes, but it does not decide application policy such as whether a player should resume after a device switch, whether a recording is complete, or how to report a failed route to a user.

Construct a graph before starting it

Attach each node to exactly one engine, then connect its buses. Nodes do not provide useful processing merely because an instance exists. Connections establish the routes the render graph will use. Build the graph in one owner, such as an audio coordinator, so a view controller cannot race a teardown while another object reconnects a node.

import AVFAudio

final class PlaybackGraph {
    private let engine = AVAudioEngine()
    private let player = AVAudioPlayerNode()

    func prepareAndStart() throws {
        engine.attach(player)
        engine.connect(player, to: engine.mainMixerNode, format: nil)
        engine.prepare()
        try engine.start()
    }

    func stop() {
        player.stop()
        engine.stop()
    }
}

This example demonstrates a minimal player-to-mixer route. It has no file scheduling, UI, or restart policy. In an app, retain the graph owner while playback is expected, and make prepareAndStart() idempotent or guard it with an explicit state. Repeated calls that attach the same node again are not a substitute for a defined rebuild procedure.

connect(_:to:format:) connects bus zero on the source to bus zero on the destination, except that a mixer destination uses its next available input bus. If the format argument is non-nil, the engine uses it for the source output bus and matches the destination input bus to that source output. For more complex graphs, use the bus-specific overload and verify each source and destination bus rather than assuming every node has a single connection point.

Format compatibility is a graph invariant

An audio bus has a format expressed through properties such as sample rate and channel count. Apple documents that formats must match exactly when connecting nodes, with exceptions for mixer and output nodes. A convenient nil format asks the engine to use its connection rules; it does not mean arbitrary mismatched formats will always work. Inspect inputFormat(forBus:) and outputFormat(forBus:) when a connection fails or when a custom effect has strict format requirements.

Do not hard-code a device’s current rate as a permanent application invariant. Hardware I/O formats reflect the connected audio device, and route changes can alter them. If a node requires a particular format, place a supported conversion boundary deliberately and test the complete path. If the graph is for manual rendering, treat its configured render format separately from the hardware-backed path; do not infer hardware state from an offline renderer.

Start is a fallible operation

prepare() can reduce work at the moment playback begins, but it does not prove that hardware will start. start() throws when graph structure is invalid, when an audio-session error occurs on platforms that use that API, or when the driver cannot start hardware. On macOS, the relevant failure may be the route or hardware state rather than a defect in the media file. Preserve the error for diagnostics, show a recoverable state, and do not update the UI to “playing” until the engine and player state actually support that claim.

A useful startup sequence is: configure nodes, connect the graph, schedule required media, prepare, start, then begin playback. The order depends on the source and product behavior, but each operation should have a rollback. If start() fails, stop or reset the partially configured state, retain the user’s chosen item, and permit a deliberate retry after the route changes. Avoid recursive immediate retries because an unavailable device will not become available merely through a tight loop.

Keep graph mutation serialized

Graph mutation includes attaching or detaching nodes, changing connections, replacing effects, and changing engine modes. Route such operations through one serial control path and document which operations are allowed during active rendering. The engine exposes APIs that support particular live changes, but this does not make every arbitrary mutation safe or glitch-free. If a transition requires a coherent graph, pause or stop playback, apply the changes together, prepare again, and restart according to the product’s playback-position policy.

Do not treat stop() as an application-level reset of every node’s semantic state. It stops the engine and releases prepared resources, while player scheduling, source state, user intent, and any external file handle remain the app’s responsibility. Conversely, reset() resets audio nodes and is not a generic cleanup call for every error. Choose the API that matches the state transition and test what remains scheduled after each supported path.

Device changes and recovery

When the engine’s I/O unit observes a change to input or output hardware channel count or sample rate, Apple documents that the engine stops, uninitializes, and posts AVAudioEngineConfigurationChangeNotification. Nodes remain attached and connected with their previously set formats; reestablish connections if the formats need to change. The notification callback is delivered on an internal dispatch queue, and Apple warns not to deallocate the engine from within that handler because synchronous teardown can deadlock. Enqueue recovery work on the graph’s control executor rather than performing blocking teardown inside the callback. Record the old route, new observed formats, engine running state, and the user’s playback intent so recovery is diagnosable.

Recovery should be a state machine, not a try start() scattered across notification handlers. One possible policy is running -> interruption/configuration change -> stop -> inspect -> rebuild if required -> prepare -> start -> restore playback intent. If rebuilding fails, remain in an explicit unavailable state with retry affordance. Do not replay a file from the beginning unless that is intentional; preserve a known playback position where the player and media format allow it, and revalidate scheduled segments rather than assuming the old schedule survived.

Input capture adds its own authorization, device-selection, and privacy requirements. A graph with an input node is not proof that a microphone is authorized or that the selected input is the expected one. Keep capture consent and file finalization separate from graph readiness. When a recording path stops unexpectedly, close and validate the output artifact, and surface incomplete capture rather than naming it as a completed recording.

Taps and realtime boundaries

An audio tap observes output at a node bus. Apple’s current AVAudioNode documentation marks the older installTap API deprecated and lists installAudioTap as a beta API. Do not upgrade production code to a beta method solely because a current documentation page exposes it; check the SDK and deployment policy used to ship the product. For the existing tap API, only one tap may be installed per bus, and the callback may run off the main thread. Keep its work bounded, avoid UI calls and blocking I/O there, and transfer data to a suitable worker when processing can tolerate it.

Realtime rendering has stricter latency constraints than ordinary background work. Avoid allocations, locks that can contend, synchronous disk access, logging bursts, and unbounded tasks on a render callback. A callback’s presence is not permission to perform expensive analysis inline. Measure dropouts and callback duration under representative CPU and device transitions; report the measured workload and hardware instead of assuming a graph is realtime-safe because it plays a short test tone.

Operational test matrix

Test a cold start with the built-in output, a disconnected or unavailable device, a sample-rate change, a channel-count change, playback while switching output, graph rebuild after stop(), and failure during file-backed recording. Also test a node connection with the expected format and one intentionally invalid format in a disposable test harness. Assert that a failed start does not leave the interface claiming playback, that graph rebuild does not duplicate nodes, and that stop or retry does not lose user-visible position unexpectedly.

Log graph transitions rather than raw audio: engine state, route identity at an appropriate privacy level, node and bus formats, start errors, and recovery outcome. Never dump captured buffers into diagnostics. A successful start() proves the engine started at that moment; it does not prove the output is audible, a long session will remain stable, or the recording was durably finalized. Verify those product outcomes independently.

The production contract is precise: own the graph, validate formats, treat start as fallible, serialize mutations, and recover from configuration changes with an explicit state machine. AVAudioEngine supplies the signal-processing graph; the application supplies continuity, user intent, and evidence that the resulting audio operation actually completed.

Related:

Sources:

Comments