云端 Mac 上的 iOS 音频重采样验收:尾帧与时长

云端 Mac 上的 iOS 音频重采样验收:尾帧与时长

跨区团队交接一段录音转换代码时,最难发现的错误往往不是崩溃,而是末尾少了一小段声音。把同一份固定夹具放到云端 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。浏览器远程桌面需要现代浏览器与稳定网络;配置、计费周期和实际可用状态请在下单前核对。

把下一次连接安排在近处

先核对配置与周期,再选择团队主要使用地区附近的节点。