云端 Mac 上的 iOS 流式 FIR 滤波验收:跨块状态与尾部冲刷

云端 Mac 上的 iOS 流式 FIR 滤波验收:跨块状态与尾部冲刷

Windows 上的音频功能已经完成,移到云端 Mac 跑 iOS 测试时,整段滤波正常,分块输入却在接缝处出现尖峰。先不要调整音量或更换系数:这通常需要检查历史样本是否跨块保留,以及结束时有没有遗漏滤波器尾部。下面用一个纯 Swift 标量实现建立基准,让后续优化有可比较的结果。

先固定输入输出契约

本文处理单声道、有限长度的因果 FIR,约定 h[0] 乘当前输入,h[1] 乘前一个输入。流开始前的历史全部为零,处理中每个输入产生一个输出,结束后再补零输出尾部。

对于非空的 N 个输入和 M 个系数,完整输出长度应为 N+M−1。空输入流返回空数组;重复结束不得再次产生尾部;结束后不能继续追加输入。若业务只需要 N 个输出,应明确采用截尾契约,而不是把少掉的样本当作实现细节。

分块只是传输边界,不是信号边界。不能在每个块结束时冲刷,也不能在下一个块开始时清空历史。

测试系数选用非对称数组,避免系数顺序写反却仍然通过。不要先用平滑效果的主观听感作为正确性判据。

在云端 Mac 建立最小测试包

确认已选择可用的开发工具链,再创建独立目录:

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

将下面实现写入 Sources/FIRContract/FIRContract.swift。它使用数组搬移,计算量随系数数量增长,目的是提供易读的正确性基准,不适合直接放进实时音频回调。

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
    }
}

系数和样本都拒绝非有限值,避免一个异常输入污染后续多个输出。这里用前置条件表达程序内部契约;接收外部音频文件时,应在调用前返回可处理的校验错误,不能依靠进程中止处理损坏数据。

用固定向量检查方向与跨块历史

将生成的测试文件内容替换为以下代码。输入 [2,4,6],系数 [0.5,0.25],手算完整结果为 [1,2.5,4,1.5]。第二个输出必须同时包含当前值和前一个样本的贡献。

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)
    }
}

在包根目录运行 swift test。先检查已知向量,再检查分块等价性:如果只比较两种分块的结果,两条路径可能共享同一个错误。单位脉冲则能直接暴露系数方向和初始历史问题。

把块边界变成回归样本

枚举切分,而不是等待杂音重现

对长度较短的固定输入,枚举每一个单切分位置,包括开头和末尾。每种切分都新建滤波器,再与完整输入结果比较。追加逐样本分块、交替大小分块,以及块长小于系数数量的情况,特别检查 M 大于 N 时尾部仍然完整。

输入还应覆盖全零、正负交替、常量和脉冲。输出长度必须先单独断言,不能只用 zip 比较数值,因为它会忽略较长数组的剩余部分。

将流结束与暂时无数据分开

空块仅表示本次没有样本,不代表流结束。只有上游确认结束才调用 finish();短暂停顿后继续输入必须保留历史。测试重新播放时,应新建实例,并验证新流不会带出上一条流的尾部。

多声道必须每个声道独立维护历史。把交错排列的左右声道直接送进单声道滤波器,会把两个声道当成时间上相邻的样本,造成串音;这不是调整容差可以解决的问题。

接入优化实现前的验收清单

将生产实现替换为环形缓冲或向量化计算时,保留这套标量参考。采用相同输入、系数和结束策略,比较输出长度与逐点误差。浮点累加顺序改变后不要求逐位一致,但必须预先固定绝对误差与相对误差规则,不能失败后随意放宽。

实时回调内还要单独检查动态分配、锁和处理预算;本文测试不证明实时性能,也不评价滤波器频响是否符合产品需求。主机测试通过后,仍应在 iOS 测试目标复跑,并用真机确认输入格式、声道映射和连续播放行为。

归档测试时保留提交标识、工具链版本、系数、分块序列和失败位置。让每一次接缝异常都能还原成固定输入,才能区分算法状态错误、格式转换错误与实际播放链路的问题。

常见问题

为什么每个音频块单独滤波会在接缝处出问题?

FIR 的当前输出依赖此前的输入样本。每块重新初始化会丢掉前一块末尾的历史,等同于在每个接缝前补零。应为每条音频流保留连续状态,只在新流开始时重建。

输入 N 个样本,最终应该得到多少个输出样本?

对于 N 大于零、具有 M 个系数的因果 FIR,若输出完整线性卷积并冲刷尾部,长度为 N+M−1。若接口只保留与输入等长的输出,则为 N,必须明确尾部被截断。

按出包任务,租一台独享 Mac。

先选就近节点,再核对工程所需的内存、存储与计费周期。整台物理机独享,非虚拟机;实际可用状态以控制台实时返回为准。

把准备做完,再开始出包。

核对配置与计费周期后再下单。连接、环境迁移与首次构建的操作步骤,可在支持页查看。