Test iOS audio resampling for tail frames and duration

Test iOS audio resampling for tail frames and duration

Test iOS audio resampling for tail frames and duration

When teams across time zones hand off recording-conversion code, the hardest bug to spot is often not a crash but a small piece of audio missing at the end. Running the same deterministic fixture on a cloud Mac—checking end-of-input handling and output draining before checking frame counts—lets each shift reproduce the same issue. This article validates PCM resampling only; file encoding, microphone capture, and speaker playback are kept out of the results.

Define the conversion contract first

Record the source sample rate, target sample rate, channel count, interleaving, conversion quality, and primeMethod. Different sample rates generally produce different frame counts. “The output has as many frames as the input” is not a valid success criterion.

When preserving duration, the theoretical output frame count is:

input frames × target sample rate ÷ source sample rate

For example, converting 48000 frames of mono audio at 48000 Hz to 44100 Hz theoretically produces 44100 frames. Non-integer results also involve rounding and the converter’s boundary behavior. Set the error budget in the test rather than relaxing it after the fact.

A successful converter call only means that the current call did not report an error. Completeness also requires explicitly ending the input and retrieving all output still buffered inside the converter.

Start with non-interleaved Float32 mono to reduce the number of variables. If the production pipeline uses other formats, add separate cases after this baseline passes.

Run the conversion loop with a deterministic signal

On a cloud Mac with the command-line developer tools installed, save the following code as resample.swift and run swift resample.swift. It requires no audio device and does not play any sound.

import AVFoundation
import Foundation

enum CheckError: Error {
    case setup
    case conversion
    case stalled
}

func resample(_ input: AVAudioPCMBuffer, rate: Double) throws -> [Float] {
    guard
        let format = AVAudioFormat(
            commonFormat: .pcmFormatFloat32,
            sampleRate: rate,
            channels: 1,
            interleaved: false
        ),
        let converter = AVAudioConverter(from: input.format, to: format)
    else {
        throw CheckError.setup
    }

    converter.primeMethod = .none
    converter.sampleRateConverterQuality = AVAudioQuality.high.rawValue

    var supplied = false
    var samples: [Float] = []

    for _ in 0..<1000 {
        guard let output = AVAudioPCMBuffer(
            pcmFormat: format,
            frameCapacity: 4096
        ) else {
            throw CheckError.setup
        }

        var error: NSError?
        let status = converter.convert(to: output, error: &error) {
            _, inputStatus in
            if supplied {
                inputStatus.pointee = .endOfStream
                return nil
            }
            supplied = true
            inputStatus.pointee = .haveData
            return input
        }

        if let error = error {
            throw error
        }
        if status == .error {
            throw CheckError.conversion
        }

        if output.frameLength > 0 {
            guard let channel = output.floatChannelData?[0] else {
                throw CheckError.setup
            }
            samples.append(contentsOf: UnsafeBufferPointer(
                start: channel,
                count: Int(output.frameLength)
            ))
        }

        if status == .endOfStream {
            return samples
        }
    }

    throw CheckError.stalled
}

let sourceRate = 48000.0
let targetRate = 44100.0
let frames: AVAudioFrameCount = 48000

guard
    let sourceFormat = AVAudioFormat(
        commonFormat: .pcmFormatFloat32,
        sampleRate: sourceRate,
        channels: 1,
        interleaved: false
    ),
    let input = AVAudioPCMBuffer(
        pcmFormat: sourceFormat,
        frameCapacity: frames
    ),
    let channel = input.floatChannelData?[0]
else {
    throw CheckError.setup
}

input.frameLength = frames
for index in 0..<Int(frames) {
    channel[index] = Float(
        0.25 * sin(2 * Double.pi * 1000 * Double(index) / sourceRate)
    )
}

let output = try resample(input, rate: targetRate)
let expected = Double(frames) * targetRate / sourceRate
let frameError = abs(Double(output.count) - expected)

print("frames=\(output.count), frameError=\(frameError)")
precondition(!output.isEmpty)
precondition(output.allSatisfy { $0.isFinite })
precondition(frameError <= 64)

The 64-frame tolerance here is an example acceptance budget, not a framework guarantee. First establish the actual difference on a fixed system version and toolchain, then tighten the budget to match the product’s allowed duration error. At 44100 Hz, 64 frames is approximately 1.45 milliseconds.

The loop limit only prevents the fixture from running indefinitely; it is not a general limit for handling audio of arbitrary length. For long recordings, supply input in chunks and set progress guards according to the input length.

Check end-of-input, draining, and content separately

The input callback supplies the buffer on its first invocation, then explicitly reports .endOfStream on subsequent invocations. Use .noDataNow only when real data is temporarily unavailable. Treating a temporary gap as the end truncates the audio; treating the end as a temporary gap can prevent conversion from finishing.

On the output side, collect the valid data indicated by frameLength on every call before handling the status. Do not discard valid frames returned by a call just because that same call reports the end of the stream, and do not read up to frameCapacity.

Use a tail marker to locate dropped frames

A sine wave is useful for detecting empty output and non-finite values, but it does not readily reveal a short missing tail. Add another fixture that starts with silence and ends with a low-amplitude sine wave, then check for energy in the expected tail window.

Do not assert that the last sample must be nonzero: filtering changes the waveform at the boundaries. Save both the output length and the tail-window energy so a failure can distinguish missing frames from an empty signal with the correct frame count.

Cover boundary cases, not just one second of audio

Add at least four independent groups of cases: all-zero input must remain approximately silent; very short input must finish normally; lengths with non-integer conversion results must meet the defined error budget; and conversion in the reverse direction must use a newly calculated theoretical frame count.

The application wrapper should explicitly return an empty result for empty input. Do not pass off a buffer with allocated capacity but no valid frames as audio. Also reject invalid sample rates and limit the length of a single input so the fixture itself does not exhaust memory.

For stereo tests, use different frequencies on the left and right, and check both output channels separately so an implementation that merely duplicates the left channel cannot pass. After changing the format, do not keep the example’s collection logic, which reads only the first channel.

Round-trip resampling must not require sample-by-sample equality either. Filtering and quantization change the samples. Check duration, finite values, energy, and allowed error; assess the perceived sound of real recordings separately.

Make cross-time-zone handoffs evidence-based

For every regression run, save the commit identifier, system version, developer-tool version, conversion parameters, input frame count, output frame count, and error. When results differ, rerun the deterministic fixture first, then compare conversion strategies. Do not simply switch nodes and mask the problem.

The command-line fixture validates the shared conversion logic. Before merging, still run the same cases in an iOS test target to confirm behavior on the target system. It cannot establish that recording permissions, audio-session interruptions, or actual output routing work correctly.

Write the handoff conclusion in terms of “which input, with which parameters, failed in which state,” and attach a minimal fixture. The next shift then receives executable evidence rather than a vague report that “the audio sounds shorter.”

Frequently asked questions

Does noDataNow mean the input has finished?

No. It means input is temporarily unavailable. Return endOfStream when the source is exhausted, then keep collecting output until the converter reports endOfStream.

Should resampling preserve the input frame count?

No. The expected output count is the input count multiplied by the output-to-input sample-rate ratio. Use an explicit tolerance with primeMethod and conversion quality held constant.

Choose a cloud Mac for your next task

Choose a location near where most of your team is based, and use a dedicated physical Mac mini. Browser-based remote desktop requires a modern browser and a stable internet connection. Check the configuration, billing period, and availability before ordering.

Make your next connection from nearby

Check the configuration and billing period first, then choose a location close to where your team is based.