Skip to content
macOSDeep Dive Published Updated 8 min readViews unavailable

AVAudioEngine Offline Rendering: Bounded Frames and Honest Completion

Render AVAudioEngine graphs offline in bounded PCM batches, handle manual-rendering statuses, and verify output instead of treating a loop as completion.

AVAudioEngine normally renders against an audio device in real time. Manual rendering changes that contract: the application requests a bounded number of PCM frames, receives an AVAudioPCMBuffer, and decides what to do with those frames. In offline mode, the graph is disconnected from audio hardware and can process data faster than real time, which is useful for file effects, waveform analysis, batch conversion, and repeatable media tests.

Offline rendering is not just playback with the volume muted. The app owns the render loop, output destination, stopping condition, and response to manual-rendering status. A call that requested 4,096 frames may produce fewer, and a non-success status still does not tell the whole story by itself. Use the output buffer’s actual frame length and the graph’s source contract when deciding whether the job is complete.

Build the same graph you intend to process

For a file-processing job, use a file-backed player node as the source, connect the processing nodes, and schedule the input file before rendering begins. Apple documents this pattern in its offline-processing sample. A player node is preferable to assuming that the microphone input is available: manual rendering disconnects the engine’s input and output nodes from hardware, and a graph that depends on live device input needs a different design.

Choose one explicit PCM format for the graph and render buffer. An audio file’s on-disk fileFormat can differ from its PCM processingFormat, especially when the source is compressed. This sample asks AVAudioFile to expose noninterleaved Float32 PCM at the source sample rate, then uses that processing format for the graph, manual render buffer, and output file. The file reader performs the conversion from its on-disk representation. Do not treat frame counts as byte counts or assume different sample rates have interchangeable frame totals.

Apple’s current AVFAudio symbol data marks the throwing connectNode(_:to:format:) and playAudio() APIs as non-beta and non-deprecated, introduced with the 27.0 OS releases. For a macOS 27.0-or-later target, use these throwing APIs and handle their errors instead of carrying forward older nonthrowing connect(_:to:format:) and play() calls. If the application supports an earlier deployment target, isolate the newer calls behind availability checks and retain a tested compatibility path for that target; do not infer availability from the SDK used to compile the app. The Swift sample below targets macOS 27.0 or later, so its enclosing function should be annotated @available(macOS 27.0, *) (or use an equivalent runtime availability branch).

Enable manual mode before starting

Apple documents that the engine must be stopped before enableManualRenderingMode(_:format:maximumFrameCount:) is called. The format must be PCM and match the render buffer. maximumFrameCount is the upper bound for a single render request and the buffer capacity; it is not the total job length. Use a reasonable fixed chunk size rather than allocating a buffer for the entire file.

import AVFAudio

let sourceFile = try AVAudioFile(
    forReading: inputURL,
    commonFormat: .pcmFormatFloat32,
    interleaved: false
)
let format = sourceFile.processingFormat
let engine = AVAudioEngine()
let player = AVAudioPlayerNode()
let reverb = AVAudioUnitReverb()

enum RenderFailure: Error {
    case cannotProgress
    case unexpectedInputStarvation
    case engineError
    case invalidBufferFormat
    case incompleteOutput
    case unknownStatus
}

engine.attach(player)
engine.attach(reverb)
reverb.loadFactoryPreset(.mediumHall)
reverb.wetDryMix = 35
try engine.connectNode(player, to: reverb, format: format)
try engine.connectNode(reverb, to: engine.mainMixerNode, format: format)
player.scheduleFile(sourceFile, at: nil)

// Caller-selected, finite tail budget. Use 0 for an input-duration-only render.
let tailFrameCount: AVAudioFrameCount = configuredTailFrames
if tailFrameCount > 0 {
    let tailSilence = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: tailFrameCount)!
    tailSilence.frameLength = tailFrameCount
    guard let channels = tailSilence.floatChannelData else {
        throw RenderFailure.invalidBufferFormat
    }
    for channel in 0..<Int(format.channelCount) {
        channels[channel].update(repeating: 0, count: Int(tailFrameCount))
    }
    // A nil time queues this buffer immediately after the previously scheduled file.
    player.scheduleBuffer(tailSilence, at: nil)
}

let maxFrames: AVAudioFrameCount = 4096
try engine.enableManualRenderingMode(
    .offline,
    format: format,
    maximumFrameCount: maxFrames
)

This is graph setup; the next snippet starts the engine only after it has established output-file cleanup. Apple documents the specified reading initializer as converting file contents to the requested linear-PCM processing format at the file’s sample rate. The queued silence is optional: it gives the reverb input after the file ends, but configuredTailFrames is an application policy, not a duration Apple promises for a preset. Keep the budget bounded and validate the audible/energy tail for the selected graph.

Request bounded chunks and write only real output

Create the output buffer once with the engine’s manual rendering format and maximum frame capacity. The sample writes an uncompressed CAF whose file settings and processing format both match the rendered Float32 PCM, rather than reusing settings from a possibly compressed source. sourceFile.length is the source-frame count because the reader’s processing format preserves the source sample rate. The loop below adds the explicitly queued silence budget. With a zero budget, it is an input-duration render and intentionally omits any reverb decay after the last source frame. For a tail render, queue silence and extend the end frame by the chosen bounded tail count; do not assume source duration includes effect output.

let buffer = AVAudioPCMBuffer(
    pcmFormat: engine.manualRenderingFormat,
    frameCapacity: engine.manualRenderingMaximumFrameCount
)!
let temporaryDirectoryURL = outputURL.deletingLastPathComponent()
    .appendingPathComponent(".render-\(UUID().uuidString)", isDirectory: true)
var outputFile: AVAudioFile?
defer {
    player.stop()
    engine.stop()
    outputFile?.close()
    try? FileManager.default.removeItem(at: temporaryDirectoryURL)
}

try FileManager.default.createDirectory(
    at: temporaryDirectoryURL,
    withIntermediateDirectories: false
)
let temporaryURL = temporaryDirectoryURL.appendingPathComponent(outputURL.lastPathComponent)
outputFile = try AVAudioFile(
    forWriting: temporaryURL,
    settings: format.settings,
    commonFormat: format.commonFormat,
    interleaved: format.isInterleaved
)
guard let file = outputFile else {
    throw RenderFailure.incompleteOutput
}

try engine.start()
try player.playAudio()

var consecutiveNoProgress = 0
let renderEndFrame = sourceFile.length + AVAudioFramePosition(tailFrameCount)
while engine.manualRenderingSampleTime < renderEndFrame {
    let remaining = renderEndFrame - engine.manualRenderingSampleTime
    let request = AVAudioFrameCount(min(
        remaining,
        AVAudioFramePosition(buffer.frameCapacity)
    ))
    let status = try engine.renderOffline(request, to: buffer)

    // frameLength is the number of frames actually rendered, not request.
    if buffer.frameLength > 0 {
        try file.write(from: buffer)
        consecutiveNoProgress = 0
    }

    switch status {
    case .success:
        guard buffer.frameLength > 0 else {
            throw RenderFailure.cannotProgress
        }
        consecutiveNoProgress = 0
    case .cannotDoInCurrentContext:
        if buffer.frameLength == 0 {
            consecutiveNoProgress += 1
            guard consecutiveNoProgress <= 16 else {
                throw RenderFailure.cannotProgress
            }
        }
        continue
    case .insufficientDataFromInputNode:
        // This player-file graph does not use the engine input node.
        throw RenderFailure.unexpectedInputStarvation
    case .error:
        throw RenderFailure.engineError
    @unknown default:
        throw RenderFailure.unknownStatus
    }
}

guard engine.manualRenderingSampleTime == renderEndFrame,
      file.length == renderEndFrame else {
    throw RenderFailure.incompleteOutput
}

file.close()
outputFile = nil
let verificationFile = try AVAudioFile(forReading: temporaryURL)
guard verificationFile.length == renderEndFrame else {
    throw RenderFailure.incompleteOutput
}
verificationFile.close()

if FileManager.default.fileExists(atPath: outputURL.path) {
    _ = try FileManager.default.replaceItemAt(outputURL, withItemAt: temporaryURL)
} else {
    try FileManager.default.moveItem(at: temporaryURL, to: outputURL)
}

RenderFailure above represents application-defined error cases. Apple defines .insufficientDataFromInputNode for a request where the engine input node cannot provide enough data at that moment; its sample treats it as applicable only when the input node is a source. Do not reinterpret that code as a universal end-of-file signal for a scheduled file player. .cannotDoInCurrentContext means the engine cannot perform that render call under the current conditions. Apple’s sample retries it, but a production implementation should count retries and detect a zero-progress loop so a permanent condition does not spin forever.

Apple also documents that buffer.frameLength indicates the number of PCM samples rendered on exit, irrespective of the returned status. Inspect it rather than assuming every request was filled. The example bounds consecutive .cannotDoInCurrentContext retries and rejects a success status with no frame progress; production code should tune that bound to its cancellation and scheduling policy. If partial output is accepted by the product, record the rendered length and status, write only the actual buffer frames, then follow an explicit recovery rule. If the job requires a complete artifact, fail the export when the source stops early and do not label a partial file complete.

Verify the artifact and the processing contract

The sample counts frames against renderEndFrame, closes the temporary CAF so its header is updated, reopens it for a frame-count check, and only then moves or replaces the destination. It stages output in a uniquely named directory beside the destination, keeping the staged file on the same volume for replacement. A defer closes any still-open writer and removes the staging directory on both success and failure, so setup, rendering, verification, or publication errors do not leave a partial file at the final export URL. For stronger application-specific validation, also check the reopened file’s channel count, sample rate, and representative waveform before replacement.

Test a short file, a file exactly one chunk long, a file one frame beyond a chunk boundary, an empty or unreadable input, a processing error, and an output-write failure. Include a long file to confirm memory use remains bounded. For DSP tests, compare meaningful properties such as duration, peak range, channel routing, or a tolerance-based reference. Do not promise bit-identical output across OS versions, plug-ins, or hardware merely because the render loop is offline; the offline mode removes the hardware clock dependency, not every source of implementation or algorithm variation.

Keep real-time and offline modes separate in the architecture. Apple describes manual real-time rendering as operating under real-time constraints and not making blocking calls while rendering. Disk writes, progress UI, retry sleeps, and file management belong on the controlling application path, not inside a real-time render callback. Offline work can run as fast as the graph permits, but it still needs cancellation, bounded memory, resource cleanup, and a truthful completion signal.

Related:

Sources:

Comments