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,必须明确尾部被截断。