雲端 Mac 的 iOS 音訊重新取樣驗收:尾端影格與時長

雲端 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。透過瀏覽器使用遠端桌面,需要較新版本的瀏覽器與穩定的網路;下單前請確認規格、計費週期及實際供應狀況。

讓下一次連線更靠近團隊

先確認規格與計費週期,再選擇靠近團隊主要使用地區的節點。