录音导出的 WAV 可以播放,尾部却少了一段;左右声道都有声音,顺序却反了。这类问题不适合靠远程桌面试听定位。在云端 Mac 上,先用固定信号走完“写文件、关闭、回读、比较”的闭环,再把断言接到 iOS 导出器,结果更容易复现。
先定义文件契约
本例只验证不重采样、不调增益的浮点 PCM 路径:采样率为 48000Hz,双声道,长度 4800 帧,即 0.1 秒。左声道固定为 0.25,右声道固定为 -0.5。两侧不同,才能识别交换、混音或单声道复制。
音频中的一帧包含同一时刻各声道的样本,因此这里每声道各有 4800 个样本,不是两个声道合计 4800 个。文件头、附加块和编码格式都会影响文件大小,不能用字节数直接代替帧数。
这是一条文件级验收路径,不依赖扬声器,也不证明播放路由、蓝牙设备或真机录音正常。固定直流信号只用于数值检查,不用于试听。
在云端 Mac 跑通最小闭环
先执行 xcrun --find swift,确认当前开发工具可以找到 Swift。将下面代码保存为 wav-check.swift,在可写目录执行 xcrun swift wav-check.swift。脚本生成自己的临时目录,不覆盖已有文件;断言失败会以非零状态退出。
import Foundation
import AVFoundation
let root = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(
at: root, withIntermediateDirectories: true, attributes: nil
)
defer { try? FileManager.default.removeItem(at: root) }
let url = root.appendingPathComponent("fixture.wav")
let frames: AVAudioFrameCount = 4800
let format = AVAudioFormat(
commonFormat: .pcmFormatFloat32,
sampleRate: 48000,
channels: 2,
interleaved: false
)!
let source = AVAudioPCMBuffer(
pcmFormat: format, frameCapacity: frames
)!
source.frameLength = frames
let input = source.floatChannelData!
for i in 0..<Int(frames) {
input[0][i] = 0.25
input[1][i] = -0.5
}
func writeFixture() throws {
let settings: [String: Any] = [
AVFormatIDKey: kAudioFormatLinearPCM,
AVSampleRateKey: 48000.0,
AVNumberOfChannelsKey: 2,
AVLinearPCMBitDepthKey: 32,
AVLinearPCMIsFloatKey: true,
AVLinearPCMIsBigEndianKey: false,
AVLinearPCMIsNonInterleaved: false
]
let writer = try AVAudioFile(
forWriting: url,
settings: settings,
commonFormat: .pcmFormatFloat32,
interleaved: false
)
try writer.write(from: source)
}
try writeFixture()
let reader = try AVAudioFile(
forReading: url,
commonFormat: .pcmFormatFloat32,
interleaved: false
)
precondition(reader.fileFormat.sampleRate == 48000)
precondition(reader.fileFormat.channelCount == 2)
precondition(reader.length == AVAudioFramePosition(frames))
let output = AVAudioPCMBuffer(
pcmFormat: reader.processingFormat,
frameCapacity: frames + 1
)!
try reader.read(into: output)
precondition(output.frameLength == frames)
let channels = output.floatChannelData!
for channel in 0..<2 {
let expected: Float = channel == 0 ? 0.25 : -0.5
for i in 0..<Int(output.frameLength) {
let value = channels[channel][i]
precondition(value.isFinite)
precondition(abs(value - expected) <= 0.000001)
}
}
print("PASS: 4800 frames, 2 channels, samples verified")
写入器放进函数,是为了让它在回读前结束生命周期,完成文件收尾。不要让尚未关闭的写入器与读取器同时操作输出文件,再把读到的短文件误判为导出器截断。
这里区分了磁盘格式与内存格式:WAV 使用交错存储,处理缓冲区使用非交错浮点数据。读取声道指针时必须以 processingFormat 为依据,不能仅凭扩展名假定内存排列。
把检查接到实际导出器
最小脚本通过后,保留读取和断言部分,把 writeFixture() 替换为项目真正的导出接口。若接口异步执行,测试必须等待成功回调,而且成功应表示文件已完成写入,而不是任务刚进入队列。
分块输入比一次写完更重要
至少覆盖 1、1023、1024、1025 和 4800 帧。再将同一信号拆为多个不等长缓冲区,断言输出帧数等于各块有效帧数之和。最后一块通常不足容量,写入时应使用 frameLength,不是 frameCapacity。
增加首尾脉冲样本也很有用:首帧和末帧各放一个不同幅值,其余为零。这样可以定位前置静音、尾帧丢失及重复拼接。固定电平适合检查通道映射,脉冲适合检查位置,两组夹具不要互相替代。
通过测试目标验收平台路径
脚本运行在 macOS,只证明文件夹具和比较逻辑成立。将共享导出代码放进 iOS 测试目标,在模拟器中运行同一套检查,才能覆盖应用实际调用路径。涉及音频会话或麦克风输入的部分,再单独安排真机测试。
整数 PCM 要另设误差规则
浮点样本写成整数 PCM 时会量化,不能沿用完全相等的断言。先固定归一化范围和舍入策略,再按最低有效位设定容差。还要加入接近满幅、负值和零值样本,检查削波是否符合约定。
重采样则是另一份契约。滤波器可能产生延迟,输出帧数还涉及取整与尾部排空,不能直接套用本例的输入输出等长断言。先把原样导出路径验收稳定,再测试格式转换,避免把多个变量混在一起。
失败时按层收集证据
帧数不符,先查末块有效长度和异步完成时机;帧数正确但左右互换,查声道映射;所有数值异常,查交错布局、浮点与整数格式转换。不要遇到失败就增大容差,那会把格式错误掩盖成通过。
每次失败记录输入块长度序列、文件格式、处理格式、实际帧数、最大误差及首个错误样本位置。测试产物只保存合成音频,避免把真实录音带进流水线。验收标准最终应落实为这些可比较的数值,而不是一句“文件能够播放”。
常见问题
云端 Mac 没有音频输出设备,也能验收 WAV 导出吗?
可以。AVAudioFile 的文件写入和回读不依赖扬声器。帧数、声道顺序和样本误差可以离线验证;实际播放路由与听感需要另做设备验收。
为什么不能要求所有 WAV 文件逐样本完全相等?
只有格式不变、无处理的浮点 PCM 路径适合极小误差断言。整数 PCM 存在量化误差,重采样或增益处理也会改变样本,必须分别定义误差和帧数规则。