iOSのストリーミングFIR検証:ブロック間の状態と末尾の出力

iOSのストリーミングFIR検証:ブロック間の状態と末尾の出力

Windowsで音声機能の実装を終え、クラウドMacに移してiOS向けのテストを実行すると、信号全体を一度に処理した場合は正常なのに、ブロックに分けて入力すると境界にスパイクが現れることがあります。音量や係数を調整する前に、まず確認すべきなのは、過去のサンプルがブロックをまたいで保持されているか、終了時にフィルタの末尾を出力し忘れていないかです。ここでは純粋なSwiftによるスカラー実装を基準として用意し、その後の最適化結果を比較できるようにします。

まず入出力の契約を決める

本記事では、有限長の入力信号を処理するモノラルの因果FIRフィルタを扱います。h[0]は現在の入力サンプルに、h[1]は1つ前の入力サンプルに掛けるものとします。ストリーム開始前の履歴はすべてゼロです。処理中は入力サンプルごとに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
    }
}

係数もサンプルも有限値だけを受け付け、1つの異常な入力が後続の複数の出力に影響するのを防いでいます。ここではプログラム内部の契約を事前条件で表しています。外部の音声ファイルを受け取る場合は、呼び出し前に検証し、呼び出し側で処理できる検証エラーを返すべきです。破損データへの対処をプロセスの強制終了に頼ってはいけません。

既知のベクトルで係数の向きとブロック間の履歴を確認する

生成されたテストファイルの内容を、次のコードに置き換えます。入力が[2,4,6]、係数が[0.5,0.25]なら、手計算による完全な出力は[1,2.5,4,1.5]です。2番目の出力には、現在の入力値と1つ前のサンプルの両方の寄与が含まれている必要があります。

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を実行します。まず既知のベクトルを確認し、その後でブロック分割による結果の同等性を調べます。分割方法の異なる2つの結果だけを比較すると、どちらの処理経路にも同じ誤りがあり、見逃す可能性があります。単位インパルスを使えば、係数の向きや履歴の初期状態の問題を直接確認できます。

ブロック境界を回帰テストに組み込む

ノイズの再発を待たず、分割位置を列挙する

短い固定入力について、先頭と末尾を含むすべての位置で、2つのブロックに分割するケースを試します。分割ごとにフィルタを新しく作り、入力全体を一度に処理した結果と比較します。さらに、1サンプルずつの分割、ブロックサイズを交互に変える分割、ブロック長が係数の数より小さいケースも追加します。特に、MがNより大きい場合でも末尾が欠けずに出力されることを確認してください。

入力には、全ゼロ、正負が交互に現れる信号、一定値の信号、インパルスも含めます。出力長は、値の比較より先に独立したアサーションで検証する必要があります。zipによる値の比較だけでは、長いほうの配列の余った要素が無視されてしまいます。

ストリームの終了と一時的なデータ不足を区別する

空のブロックは、今回はサンプルがないことを示すだけで、ストリームの終了を意味しません。finish()を呼ぶのは、上流から終了が確定したときだけです。短い中断の後で入力が再開する場合は、履歴を保持していなければなりません。再生し直すケースをテストするときは新しいインスタンスを作り、前のストリームの末尾が新しいストリームに混入しないことも確認します。

マルチチャンネルでは、チャンネルごとに独立した履歴が必要です。左右のサンプルが交互に並ぶインターリーブ形式のデータを、そのままモノラルのフィルタへ渡すと、2つのチャンネルが時間的に隣接するサンプルとして扱われ、クロストークが発生します。これは許容誤差の調整で解決できる問題ではありません。

最適化実装を組み込む前の確認事項

本番実装をリングバッファやベクトル演算に置き換える際も、このスカラー版の基準実装は残しておきます。同じ入力、係数、終了処理の方針を使い、出力長とサンプルごとの誤差を比較します。浮動小数点数の加算順序が変われば、ビット単位の一致は求めません。ただし、絶対誤差と相対誤差の判定ルールは事前に決め、テストが失敗した後で都合よく緩めないことが必要です。

リアルタイムコールバックについては、動的メモリ確保、ロック、処理時間の予算内に収まるかどうかを別途確認します。本記事のテストはリアルタイム性能を証明するものではなく、フィルタの周波数応答が製品要件を満たすかどうかも評価しません。ホスト上のテストが通った後も、iOSのテストターゲットで再実行し、実機で入力形式、チャンネルの対応付け、連続再生時の動作を確認してください。

テスト結果を保存する際は、コミット識別子、ツールチェーンのバージョン、係数、ブロック列、失敗箇所を残します。境界で起きた異常を毎回固定入力として再現できるようにしてこそ、アルゴリズムの状態管理の誤り、形式変換の誤り、実際の再生経路の問題を切り分けられます。

よくある質問

ブロックごとに独立してフィルタをかけると、なぜ継ぎ目ができますか?

FIRの出力は過去の入力サンプルに依存します。毎回初期化すると履歴が失われ、各境界の前にゼロを入れた状態になります。ストリームごとに状態を維持し、新しいストリームでのみ作り直します。

入力がNサンプルなら、最終出力は何サンプルですか?

空でない入力とM個の係数に対する完全な線形畳み込みは、末尾を出力するとN+M−1サンプルです。Nサンプルだけ返す設計なら、末尾を切り捨てる契約を明示します。

ビルド作業に合わせて、専用Macをレンタル。

まず最寄りのロケーションを選び、プロジェクトに必要なメモリ、ストレージ、利用期間を確認しましょう。1台の物理マシンを専有でき、仮想マシンではありません。実際の利用状況はコントロールパネルでリアルタイムに確認できます。

準備を整えてから、ビルドを始めましょう。

構成と利用期間を確認してからお申し込みください。接続方法、環境の移行、初回ビルドの手順はサポートページでご覧いただけます。