雲端 Mac 的 iOS 串流 FIR 濾波驗收:跨區塊狀態與尾端排空

雲端 Mac 的 iOS 串流 FIR 濾波驗收:跨區塊狀態與尾端排空

雲端 Mac 的 iOS 串流 FIR 濾波驗收:跨區塊狀態與尾端排空

Windows 上的音訊功能已經完成,移到雲端 Mac 執行 iOS 測試時,整段濾波正常,分塊輸入卻在接縫處出現尖峰。先別急著調整音量或更換係數:這時通常應檢查歷史樣本是否跨區塊保留,以及結束時是否遺漏濾波器尾端。以下用純 Swift 的純量實作建立基準,讓後續最佳化有可比較的結果。

先確立輸入與輸出契約

本文處理單聲道、有限長度的因果 FIR 濾波器,約定 h[0] 乘上目前的輸入,h[1] 乘上前一個輸入。串流開始前的歷史樣本一律視為零;處理期間,每個輸入產生一個輸出;結束後再補零,輸出剩餘尾端。

對非空的 N 個輸入和 M 個係數,完整輸出長度應為 N+M−1。空輸入串流回傳空陣列;重複結束不得再次產生尾端;結束後也不能繼續追加輸入。如果應用只需要 N 個輸出,就應明確採用截尾契約,而不是將少掉的樣本視為實作細節。

區塊只是傳輸邊界,不是訊號邊界。不能在每個區塊結束時排空濾波器,也不能在下一個區塊開始時清除歷史樣本。

測試係數應選用非對稱陣列,避免係數順序寫反卻仍通過測試。不要一開始就以聽起來是否平順作為正確性的判準。

在雲端 Mac 建立最小測試套件

確認已選用可用的開發工具鏈,再建立獨立目錄:

xcrun swift --version
mkdir FIRContract
cd FIRContract
swift package init --type library --name FIRContract

將以下實作寫入 Sources/FIRContract/FIRContract.swift。這份實作會搬移陣列元素,運算量隨係數數量增加,目的是提供易讀的正確性基準,不適合直接放進即時音訊回呼。

struct FIR {
    private let taps: [Double]
    private var history: [Double]
    private var hasInput = false
    private var ended = false

    init(taps: [Double]) {
        precondition(!taps.isEmpty)
        precondition(taps.allSatisfy { $0.isFinite })
        self.taps = taps
        self.history = Array(repeating: 0, count: taps.count)
    }

    mutating func process(_ input: [Double]) -> [Double] {
        precondition(!ended)
        return input.map { sample in
            precondition(sample.isFinite)
            hasInput = true
            history.insert(sample, at: 0)
            history.removeLast()
            return zip(taps, history).reduce(0.0) {
                $0 + $1.0 * $1.1
            }
        }
    }

    mutating func finish() -> [Double] {
        guard !ended else { return [] }
        let tail = hasInput
            ? process(Array(repeating: 0, count: taps.count - 1))
            : []
        ended = true
        return tail
    }
}

係數與樣本都不接受非有限值,避免單一異常輸入污染後續多個輸出。這裡以前置條件表達程式內部契約;接收外部音訊檔案時,應在呼叫前完成驗證,並回傳可處理的驗證錯誤,不能依賴終止程序來處理損毀資料。

用固定向量檢查係數方向與跨區塊歷史

將產生的測試檔內容替換為以下程式碼。輸入為 [2,4,6]、係數為 [0.5,0.25] 時,手算的完整結果是 [1,2.5,4,1.5]。第二個輸出必須同時包含目前輸入與前一個樣本的貢獻。

import XCTest
@testable import FIRContract

final class FIRContractTests: XCTestCase {
    private func render(_ chunks: [[Double]]) -> [Double] {
        var filter = FIR(taps: [0.5, 0.25])
        var output: [Double] = []
        for chunk in chunks {
            output += filter.process(chunk)
        }
        output += filter.finish()
        XCTAssertTrue(filter.finish().isEmpty)
        return output
    }

    private func check(_ actual: [Double], _ expected: [Double]) {
        XCTAssertEqual(actual.count, expected.count)
        for (a, e) in zip(actual, expected) {
            XCTAssertEqual(a, e, accuracy: 1e-12)
        }
    }

    func testKnownVectorAndChunkLayouts() {
        let expected = [1.0, 2.5, 4.0, 1.5]
        check(render([[2, 4, 6]]), expected)
        check(render([[2], [4], [6]]), expected)
        check(render([[], [2, 4], [], [6], []]), expected)
        check(render([[2], [4, 6]]), expected)
    }

    func testImpulseAndEmptyStream() {
        check(render([[1], [0, 0]]), [0.5, 0.25, 0, 0])
        check(render([[], []]), [])
    }

    func testSingleTapHasNoTail() {
        var filter = FIR(taps: [2])
        check(filter.process([1, -1]), [2, -2])
        XCTAssertTrue(filter.finish().isEmpty)
    }
}

在套件根目錄執行 swift test。先檢查已知向量,再檢查不同分塊方式的結果是否等價:如果只比較兩種分塊方式,兩條執行路徑可能帶有同一個錯誤。單位脈衝則能直接暴露係數方向與初始歷史樣本的問題。

將區塊邊界納入回歸測試

列舉切分位置,而不是等待雜音重現

對較短的固定輸入,列舉每一個單次切分位置,包括開頭與末尾。每種切分方式都建立新的濾波器,再與整段輸入的結果比較。接著加入逐樣本分塊、區塊大小交替,以及區塊長度小於係數數量的情況,尤其要檢查 M 大於 N 時,尾端是否仍然完整。

輸入也應涵蓋全零、正負交替、常數與脈衝。必須先單獨斷言輸出長度,不能只用 zip 比較數值,因為它會忽略較長陣列中剩餘的元素。

區分串流結束與暫時沒有資料

空區塊只表示這次沒有樣本,不代表串流結束。只有上游確認結束時,才呼叫 finish();短暫停頓後繼續輸入,必須保留歷史樣本。測試重新播放時,應建立新實例,並驗證新的串流不會帶出上一條串流的尾端。

多聲道音訊必須為每個聲道各自維護歷史樣本。將交錯排列的左右聲道直接送進單聲道濾波器,會把兩個聲道當成時間上相鄰的樣本,造成串音;這不是調整容許誤差就能解決的問題。

整合最佳化實作前的驗收清單

將正式環境的實作改為環形緩衝區或向量化運算時,應保留這套純量參考實作。採用相同的輸入、係數與結束策略,比較輸出長度及逐點誤差。浮點累加順序改變後,不要求位元完全一致,但必須事先訂好絕對誤差與相對誤差的規則,不能在測試失敗後任意放寬。

即時回呼內還需要另外檢查動態記憶體配置、鎖定與處理時間預算;本文的測試不能證明即時效能,也不評估濾波器的頻率響應是否符合產品需求。主機端測試通過後,仍應在 iOS 測試目標中重新執行,並以實機確認輸入格式、聲道對應與連續播放行為。

封存測試時,應保留提交識別碼、工具鏈版本、係數、分塊序列與失敗位置。讓每一次接縫異常都能以固定輸入重現,才能區分演算法狀態錯誤、格式轉換錯誤,以及實際播放鏈路的問題。

常見問題

為什麼每個音訊區塊各自濾波會產生接縫?

FIR 的目前輸出依賴先前的輸入取樣。每個區塊重新初始化會遺失歷史,等同於在每個交界前補零。每條串流應保留連續狀態,只有新串流開始時才重建。

輸入 N 個取樣,結束後應有多少輸出?

對非空的 N 個輸入取樣與 M 個濾波係數,完整線性卷積在尾端排空後共有 N+M−1 個取樣。若介面只輸出 N 個,必須明確記錄尾端已截斷。

依照打包需求,租用專屬 Mac。

先選擇鄰近的節點,再確認專案所需的記憶體、儲存空間與計費週期。整台實體機由您獨享,並非虛擬機;實際可用狀態以控制台即時回報為準。

準備就緒,再開始打包。

下單前請確認配置與計費週期。連線、環境搬移及首次建置的操作步驟,都可以在支援頁面查看。