云端 Mac 上的 iOS WAV 导出验收:帧数、声道顺序与样本保真

云端 Mac 上的 iOS WAV 导出验收:帧数、声道顺序与样本保真

录音导出的 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 存在量化误差,重采样或增益处理也会改变样本,必须分别定义误差和帧数规则。

把下一次构建放到云端工位

整台 Mac mini 独享,非虚拟机。按任务选择租期,实际可用状态以控制台实时返回为准。

立即租用云端 Mac

先核对配置,再安排任务

查看 Desk M4 的计费周期与附加项,或按上手文档准备第一次连接。