クラウド 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 フレームは、この例で設定した合格判定の許容誤差であり、フレームワークによる保証ではありません。まず OS とツールチェーンを固定して実際の差を確認し、製品で許容できる時間のずれに合わせて基準を厳しくします。44100 Hz では、64 フレームは約 1.45 ミリ秒です。
ループ回数の上限は、テストが無限に実行されるのを防ぐためのものです。任意の長さの音声を処理するための汎用的な制限ではありません。長時間の録音は分割して供給し、入力の長さに応じて処理の進行を監視する仕組みを設けます。
入力終了、出力の取り切り、内容を分けて確認する
入力コールバックでは、初回にバッファーを渡し、それ以降は明示的に .endOfStream を返します。.noDataNow を使うのは、実データが一時的に届いていない場合だけです。一時的なデータ不足を終了扱いすると音声が途切れ、終了を一時的なデータ不足として扱うと処理が完了しなくなる可能性があります。
出力側では毎回、まず frameLength が示す有効なデータを回収し、その後でステータスを処理します。最後のステータスが終了を示していても、同じ呼び出しで返された有効なフレームを捨ててはいけません。また、frameCapacity まで読み取らないようにします。
末尾のマーカーでフレーム欠落を特定する
正弦波は空の出力や非有限値の検出に適していますが、末尾の短い欠落は見つけにくい信号です。前半が無音で最後の区間だけ低振幅の正弦波になっている固定テストデータを追加し、想定した末尾の時間窓にエネルギーがあるか確認します。
最後のサンプルが必ず非ゼロである、というアサーションは避けます。フィルターによって境界の波形が変化するためです。出力の長さと末尾の時間窓のエネルギーを両方保存すれば、失敗時に「フレームが足りない」のか「フレーム数は正しいが内容が空なのか」を切り分けられます。
1 秒のテストだけでなく境界ケースも追加する
少なくとも、独立した四つのテストケースを追加します。全サンプルがゼロの入力はほぼ無音を維持すること、極端に短い入力でも正常に終了すること、変換後の長さが整数にならない場合も所定の許容誤差内に収まること、逆方向の変換では理論フレーム数を計算し直すことを確認します。
空の入力に対しては、アプリケーション側のラッパー層で明示的に空の結果を返します。容量だけがあり、有効なフレームを持たないバッファーを音声の代わりに渡してはいけません。また、不正なサンプルレートを拒否し、1 回の入力長を制限して、テスト自体がメモリを使い果たさないようにします。
ステレオのテストでは左右に異なる周波数を使い、出力の両チャンネルを個別に確認します。これにより、左チャンネルをコピーするだけの実装がテストを通過するのを防げます。形式を変更したら、最初のチャンネルだけを読み取るサンプルコードの収集処理を、そのまま使い続けることはできません。
往復のリサンプリングでも、サンプル単位の一致を求めてはいけません。フィルタリングや量子化によってサンプルは変わるため、長さ、値が有限であること、エネルギー、許容誤差を確認します。実際の録音を聴いたときの品質は、別途検証します。
タイムゾーンをまたぐ引き継ぎを、証拠の引き継ぎにする
回帰テストのたびに、コミット識別子、OS バージョン、開発ツールのバージョン、変換パラメーター、入力フレーム数、出力フレーム数、誤差を保存します。差が出たら、まず固定テストデータで再実行し、その後で変換方針を比較します。ノードを変更して問題を覆い隠してはいけません。
コマンドラインのテストプログラムで検証するのは、共通の変換ロジックです。マージ前には、iOS のテストターゲットでも同じケースを実行し、対象 OS での挙動を確認する必要があります。このテストだけでは、録音権限、オーディオセッションの割り込み、実際の出力ルートが正しいことは証明できません。
引き継ぎの結論には、「どの入力とパラメーターの組み合わせで、どのステータスのときに失敗したか」を記載し、最小限の再現用テストを添えます。そうすれば、次の担当者が受け取るのは「音声が短く聞こえる」という一言ではなく、実行して確かめられる証拠になります。
よくある質問
noDataNow は入力の終了を意味しますか?
いいえ。一時的に入力がない状態です。入力をすべて渡したら endOfStream を返し、出力側も endOfStream になるまで変換結果を読み取ります。
変換前後のフレーム数は一致させるべきですか?
一致させる必要はありません。入力フレーム数に出力レートと入力レートの比を掛けて期待値を求め、primeMethod と変換品質を固定したうえで許容誤差を判定します。
次のタスクに最適なクラウドMacを選ぶ
チームの主な所在地に近いリージョンを選び、Mac miniを専有で利用できます。ブラウザーからのリモートデスクトップには、最新のブラウザーと安定したネットワークが必要です。構成、課金期間、実際の空き状況は注文前にご確認ください。