跨区团队交接一段录音转换代码时,最难发现的错误往往不是崩溃,而是末尾少了一小段声音。把同一份固定夹具放到云端 Mac,先检查输入结束和输出排空,再检查帧数,可以让不同班次复现同一个问题。本文只验收 PCM 重采样,不把文件编码、麦克风采集和扬声器播放混进结果。
先固定转换契约
记录源采样率、目标采样率、声道数、交错方式、转换质量及 primeMethod。采样率不同,帧数通常也不同;不能把“输入多少帧就输出多少帧”当成成功条件。
保持时长时,理论输出帧数为:
输入帧数 × 目标采样率 ÷ 源采样率
例如,将 48000 帧、48000 Hz 的单声道音频转换为 44100 Hz,理论输出为 44100 帧。非整数结果还涉及取整及转换器边界行为,应把误差预算写进测试,而不是事后放宽。
转换器返回成功,只说明本次调用没有报错;完整性还要求输入明确结束,并把转换器内部剩余的输出取完。
先用非交错 Float32 单声道减少变量。生产链路若使用其他格式,应在这条基线通过后单独增加用例。
用固定信号跑通转换循环
在安装了命令行开发工具的云端 Mac 上,将下面代码保存为 resample.swift,执行 swift resample.swift。它不需要音频设备,也不会播放声音。
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)
这里的 64 帧是示例验收预算,不是框架保证。应先在固定系统和工具链上确认实际差值,再按产品允许的时长误差收紧;44100 Hz 下,64 帧约为 1.45 毫秒。
循环上限只是防止夹具无限运行,不是处理任意长度音频的通用限制。长录音应改为分块供给,并按输入长度设置进度守护。
分开检查结束、排空和内容
输入回调第一次提供缓冲区,后续明确返回 .endOfStream。如果真实数据暂时未到,才使用 .noDataNow;把暂缺当结束会截断音频,把结束当暂缺则可能无法完成。
输出端每次先收集 frameLength 指定的有效数据,再处理状态。不要因为最后一次状态已经结束,就丢掉同一次调用携带的有效帧,也不要读取到 frameCapacity。
用尾部标记定位丢帧
正弦波适合发现空输出和非有限值,却不容易暴露短尾缺失。再增加一份前段静音、最后一段低幅正弦的夹具,检查预期尾部窗口内存在能量。
不要断言最后一个样本必须非零:滤波器会改变边界波形。应同时保存输出长度与尾部窗口能量,让失败能区分“少帧”和“帧数正确但内容为空”。
扩展边界而不是只测一秒
至少加入四组独立用例:全零输入必须保持近似静音;极短输入必须正常结束;非整数换算长度必须符合既定误差预算;反向转换必须重新计算理论帧数。
空输入应由业务包装层明确返回空结果,不要伪造一个有容量却没有有效帧的缓冲区来冒充声音。还要拒绝非法采样率,并限制单次输入长度,避免测试夹具本身耗尽内存。
双声道测试使用左右不同频率,分别检查两个输出声道,防止复制左声道的实现蒙混过关。修改格式后不能继续沿用示例里只读取第一个声道的收集逻辑。
往返重采样也不能逐样本要求相等。滤波和量化会改变样本,应检查时长、有限值、能量与允许误差;真实录音的听感另做验收。
把跨时区交接变成证据交接
每次回归保存提交标识、系统版本、开发工具版本、转换参数、输入帧数、输出帧数和误差。遇到差异先复跑固定夹具,再比较转换策略,不要直接更换节点掩盖问题。
命令行夹具验证共享转换逻辑;合并前仍要在 iOS 测试目标运行同组用例,确认目标系统行为。它不能证明录音权限、音频会话中断或实际输出路由正确。
交接结论应写成“哪个输入、哪组参数、在哪个状态失败”,并附最小夹具。这样下一班成员接手的是可执行证据,而不是一句“音频听起来短了”。
常见问题
输入回调返回 noDataNow,是否表示音频已经结束?
不是。noDataNow 表示当前暂时没有输入,后续仍可能提供数据。确定输入耗尽时应返回 endOfStream,并继续读取转换器输出,直到输出状态为 endOfStream。
重采样后的帧数必须与输入帧数相同吗?
不必。保持时长时,输出帧数应接近输入帧数乘以目标采样率再除以源采样率。应同时记录 primeMethod、转换质量和允许的帧数误差,不能直接比较两边帧数是否相等。
为下一项任务选择云端 Mac
按团队主要所在地选择节点,使用整机独享的物理 Mac mini。浏览器远程桌面需要现代浏览器与稳定网络;配置、计费周期和实际可用状态请在下单前核对。