Test streaming FIR filters for iOS: chunk state and tail flushing

Test streaming FIR filters for iOS: chunk state and tail flushing

Test streaming FIR filters for iOS: chunk state and tail flushing

The audio functionality works on Windows, but when you move to a cloud Mac for iOS testing, filtering the entire signal works while chunked input produces spikes at the boundaries. Before changing the volume or filter coefficients, check whether sample history survives across chunks and whether the filter tail is fully emitted at the end. The pure Swift scalar implementation below establishes a correctness baseline for evaluating later optimizations.

Define the input and output contract first

This article covers a single-channel, finite-length causal FIR filter. By convention, h[0] multiplies the current input, and h[1] multiplies the previous input. All history before the stream starts is zero. During processing, each input produces one output; after the stream ends, zero-padding produces the remaining tail.

For a nonempty stream of N inputs and M coefficients, the full output length must be N+M−1. An empty input stream returns an empty array. Repeated finalization must not emit another tail, and no further input may be appended after finalization. If the application needs only N outputs, explicitly adopt a truncated-output contract rather than treating the missing samples as an implementation detail.

Chunks are transport boundaries, not signal boundaries. Do not flush the filter at the end of every chunk or clear its history at the start of the next one.

Use an asymmetric coefficient array in tests so that reversing the coefficient order cannot accidentally pass. Do not start by judging correctness from how smooth the result sounds.

Create a minimal test package on a cloud Mac

Confirm that a working development toolchain is selected, then create a separate directory:

xcrun swift --version
mkdir FIRContract
cd FIRContract
swift package init --type library --name FIRContract

Save the following implementation in Sources/FIRContract/FIRContract.swift. It shifts elements in an array, and its computational cost grows with the number of coefficients. Its purpose is to provide a readable correctness reference, not code to place directly in a real-time audio callback.

struct FIR {
    private let taps: [Double]
    private var history: [Double]
    private var hasInput = false
    private var ended = false

    init(taps: [Double]) {
        precondition(!taps.isEmpty)
        precondition(taps.allSatisfy { $0.isFinite })
        self.taps = taps
        self.history = Array(repeating: 0, count: taps.count)
    }

    mutating func process(_ input: [Double]) -> [Double] {
        precondition(!ended)
        return input.map { sample in
            precondition(sample.isFinite)
            hasInput = true
            history.insert(sample, at: 0)
            history.removeLast()
            return zip(taps, history).reduce(0.0) {
                $0 + $1.0 * $1.1
            }
        }
    }

    mutating func finish() -> [Double] {
        guard !ended else { return [] }
        let tail = hasInput
            ? process(Array(repeating: 0, count: taps.count - 1))
            : []
        ended = true
        return tail
    }
}

Both coefficients and samples reject non-finite values so that one invalid input cannot contaminate multiple subsequent outputs. Preconditions express the program’s internal contract here. When accepting external audio files, validate them before calling the filter and return a recoverable validation error; do not rely on process termination to handle corrupt data.

Check coefficient order and cross-chunk history with a known vector

Replace the contents of the generated test file with the following code. For input [2,4,6] and coefficients [0.5,0.25], the full result calculated by hand is [1,2.5,4,1.5]. The second output must include contributions from both the current input and the previous sample.

import XCTest
@testable import FIRContract

final class FIRContractTests: XCTestCase {
    private func render(_ chunks: [[Double]]) -> [Double] {
        var filter = FIR(taps: [0.5, 0.25])
        var output: [Double] = []
        for chunk in chunks {
            output += filter.process(chunk)
        }
        output += filter.finish()
        XCTAssertTrue(filter.finish().isEmpty)
        return output
    }

    private func check(_ actual: [Double], _ expected: [Double]) {
        XCTAssertEqual(actual.count, expected.count)
        for (a, e) in zip(actual, expected) {
            XCTAssertEqual(a, e, accuracy: 1e-12)
        }
    }

    func testKnownVectorAndChunkLayouts() {
        let expected = [1.0, 2.5, 4.0, 1.5]
        check(render([[2, 4, 6]]), expected)
        check(render([[2], [4], [6]]), expected)
        check(render([[], [2, 4], [], [6], []]), expected)
        check(render([[2], [4, 6]]), expected)
    }

    func testImpulseAndEmptyStream() {
        check(render([[1], [0, 0]]), [0.5, 0.25, 0, 0])
        check(render([[], []]), [])
    }

    func testSingleTapHasNoTail() {
        var filter = FIR(taps: [2])
        check(filter.process([1, -1]), [2, -2])
        XCTAssertTrue(filter.finish().isEmpty)
    }
}

Run swift test from the package root. Check the known vector first, then verify that different chunk layouts produce equivalent results. Comparing only two chunk layouts is not enough: both paths may share the same bug. A unit impulse directly exposes problems with coefficient order and initial history.

Turn chunk boundaries into regression tests

Enumerate split points instead of waiting for the noise to return

For a short, fixed input, enumerate every possible single split point, including the beginning and the end. Create a new filter for each split and compare its output with the result from processing the entire input at once. Add sample-by-sample chunks, alternating chunk sizes, and chunks shorter than the coefficient count. In particular, check that the tail remains complete when M is greater than N.

Inputs should also cover all zeros, alternating positive and negative values, a constant signal, and an impulse. Assert the output length separately before comparing values. Using only zip is insufficient because it ignores any remaining elements in the longer array.

Distinguish end-of-stream from a temporary lack of data

An empty chunk means only that there are no samples in this call; it does not mean the stream has ended. Call finish() only after the upstream source confirms the end of the stream. History must survive a brief pause followed by more input. When testing replay, create a new instance and verify that the new stream does not inherit the previous stream’s tail.

For multichannel audio, maintain separate history for each channel. Passing interleaved left and right channels directly into a single-channel filter treats the two channels as consecutive samples in time, causing crosstalk. Adjusting the tolerance cannot fix this.

Acceptance checklist before integrating an optimized implementation

Keep this scalar reference when replacing the production implementation with a ring buffer or vectorized computation. Use the same input, coefficients, and finalization policy, then compare output lengths and point-by-point errors. A change in floating-point accumulation order does not require bit-for-bit agreement, but absolute and relative error rules must be defined in advance—not relaxed arbitrarily after a failure.

Separately check dynamic allocation, locks, and the processing budget inside the real-time callback. These tests do not establish real-time performance or determine whether the filter’s frequency response meets product requirements. After the tests pass on the host, rerun them in an iOS test target and use a physical device to verify the input format, channel mapping, and continuous playback behavior.

When archiving tests, retain the commit identifier, toolchain version, coefficients, chunk sequence, and failure location. Making every boundary artifact reproducible with a fixed input is what lets you distinguish algorithm-state bugs from format-conversion errors and problems in the actual playback chain.

Frequently asked questions

Why does filtering each audio chunk independently create seams?

An FIR output depends on earlier input samples. Reinitializing at each chunk discards that history and effectively inserts zeros before every boundary. Keep one continuous state per stream and recreate it only for a new stream.

How many output samples should a completed stream contain?

For a nonempty input of N samples and M filter coefficients, full linear convolution contains N+M−1 samples after flushing. A same-length interface returns N samples and must explicitly document that it truncates the tail.

Rent a dedicated Mac for your build.

Choose a nearby location, then check the memory, storage, and billing period your project needs. Each physical machine is dedicated to you, not a virtual machine. Check the console for real-time availability.

Get everything ready before you build.

Review the configuration and billing period before ordering. See the support guide for steps on connecting, migrating your environment, and running your first build.