Extended Audio Decoder and Cache - log5r/simple-media-player GitHub Wiki

追加音声形式のデコーダーとキャッシュ

Issue #4(WMA、WavPack、APE、Musepack)で確認した設計と検証方法。実装と対応範囲はリポジトリの docs/extended-audio-formats.ja.md を参照。

2026-10-03のIssue #13修正では、取り込み時のPCM書き込みを廃止し、キャッシュの排他範囲を変更した。現在の設計と容量超過バッチの比較は拡張音声の取り込み検証とPCMキャッシュ生成を参照。以下はIssue #4時点の経緯を含む。

デコーダーの選択

  • WavPack、Monkey's Audio、Musepackは SFBAudioEngine 0.14.0 の AudioDecoder で、出力PCMを AVAudioFile に書く。
  • WMAは FFmpegBuild 3.6.0 の ASF demuxer・WMA decoder・swresampleを使う。Objective-Cブリッジ内で AVAudioFile に浮動小数点PCMを書き込む。WMA Standard(WMAv2)を実音源で検証。DRMには対応しない。
  • ライブラリへは元ファイルをコピーして保持する。再生、音量正規化、解析、変換書き出しはCAFキャッシュを読み、元ファイル書き出しは元の符号化ファイルをコピーする。埋め込みタグ編集はこの4形式では行わない。
  • ビルド成果物で動的フレームワークの合計はmacOS Debugで約29 MB。FFmpegBuildのLGPLライブラリは動的リンクで同梱し、ライセンス文書もバンドルへ入れる。配布前には依存先すべてのライセンスを再確認する。

キャッシュの不変条件

  • キーは正規化したパス、ファイルサイズ、更新日時のSHA-256。ソース更新時は新しいキーになり、古いデコード結果を誤使用しない。
  • 途中出力はランダム名の一時CAFに書き、検証後に完成名へ移動する。エラー・キャンセル時は一時ファイルを削除する。
  • 1件4 GiBまで。全体8 GiBを超えたら更新日時が古いファイルから削除する。ライブラリ項目削除時は現行キーのキャッシュを削除する。
  • デコードはUIの主スレッド外で行う。曲切り替え時は前の準備タスクをキャンセルし、世代番号で遅れて終了した結果を破棄する。

検証

SimpleMediaPlayerTests/ExtendedAudioSourceTests.swift で各形式の検出とデコード、WMA/WavPackの取り込み・元ファイル書き出し、キャッシュ再使用・更新無効化、破損ファイル、キャンセルを検証する。WMA/WavPackは1秒の合成音、APEはOxideAVの合成テスト音源、Musepackは既存WAVテスト音源から生成する。

xcodebuild -project SimpleMediaPlayer.xcodeproj -scheme SimpleMediaPlayer \
  -destination 'platform=macOS' \
  -only-testing:SimpleMediaPlayerTests/ExtendedAudioSourceTests test
xcodebuild -project SimpleMediaPlayer.xcodeproj -scheme SimpleMediaPlayer \
  -destination 'generic/platform=iOS Simulator' build

iOS実機での再生と、WMA Pro/Lossless、WavPackハイブリッドの.wvcサイドカーは未検証。テストのAPEエンコードはCXXMonkeysAudioのエンコーダーでクラッシュしたため、既存のAPE音源をデコードする方法に変更した。APE再生経路とAPEエンコード経路は別である。

CIの単体テスト

PR #6の初回CIではmacOS・iOSビルドが成功したが、全単体テストの並列実行で、既存の歌詞読み込みと音量正規化の非同期テストが複数タイムアウトした。追加形式のテストは成功した。ローカルで-parallel-testing-enabled NOを指定した全単体テストは成功したため、CIも逐次実行に変更した。非同期処理の待機条件を検証するときは、ランナーの負荷と並列実行の影響を切り分ける。

レビューで確認した非同期処理

  • 読み込み中のシークは音声ファイルの準備完了まで保持する。別の曲のloadを呼ぶ時点で前曲のシークを同期的に消し、その後に新曲へのシークが来れば保持できるようにする。
  • シーク処理は直列キューに遅れて届くため、前曲のシーク処理がキューに残った状態でloadと新曲へのseekを続けて呼ぶと、新曲の要求を前曲の処理が消費し得る。シーク要求とキュー上の処理に同じ読み込みIDを付け、IDが一致するときだけ要求を消費する。準備完了もキュー上の世代番号と同期的に更新した読み込みIDの両方で検証する。再現条件は「旧曲のシークをキューに投入 → 新曲を読み込む → 新曲の再生位置を指定」で、旧処理が新要求を消費せず、新曲の準備後にその位置へ移ることを確認する。
  • 同じ曲でも、stopの前に積まれたシーク処理が停止後の新しいシークを消費し得る。個々のシークにもIDを付けて古いキュー項目を無効化する。準備中のpauseでは保留中の位置を残して自動再生だけ解除し、stopでは保留中のシークを消す。確認条件は「準備中にシーク → 一時停止/停止 → 準備完了」で、意図しない再生が始まらないこと。
  • 動画へ切り替えるAudioEngineService.suspend()では読み込み世代を進める。さらに、メインスレッドへ渡した再生時間・形式・エラー通知も読み込みIDを再確認する。これにより、キャンセル後に完了した古い読み込みが現在の画面を更新しない。
  • FFmpegのWMAデコーダーをflushした後はavcodec_receive_frameが正常な終端(AVERROR_EOF)で終わったことを確認する。途中で得たPCMがあっても、flush時にエラーになったファイルはキャッシュとして採用しない。

追加レビューで確認した入力形式と削除処理

  • 現在の項目を削除するとき、通常のstopだけでは準備中のデコードが残る。clearCurrentItem()から音声エンジンのsuspend()も呼び、読み込みIDを即座に無効化して準備タスクをキャンセルする。これにより削除後の形式・エラー通知を防ぎ、removeCacheがデコード完了までロック待ちする時間を減らす。
  • SFBAudioEngineのAPE/Musepack判定は先頭ID3v2タグを読み飛ばす。アプリ側の形式判定も、ID3v2ヘッダーのsynchsafeサイズと必要ならフッターを加えた位置から署名を確認する。WavPackとWMAにはこの処理を適用しない。130バイトのパディングを持つID3v2タグを付けたAPE/Musepackを、形式判定・メタデータ取得・CAFデコードまでテストする。
  • SFBAudioEngineのAudioProperties.bitrateにはKiB/sというコメントがあるが、実装はTagLibのAudioProperties::bitrate()(kb/s)をそのまま転送している。表示用のbitrateKbpsには無変換で渡す。WavPackのテスト音源ではファイルサイズと再生時間から算出した概算ビットレートと比較し、約8倍の誤表示を検出する。
  • readableURLはデコード中ずっと共通のキャッシュロックを保持する。ライブラリ項目の削除でメインアクターから同期的にremoveCacheを呼ぶと、無関係の音源のデコード完了までUIが止まる。削除前にキャッシュURLを確定し、元ファイルと項目はその場で削除する。キャッシュファイルの削除だけを別タスクへ移し、ロック待ちをメインアクターから外す。キャッシュ削除は最終的に完了することを単体テストで確認する。
  • この4形式は埋め込みタグを書き戻せない。取り込み時にはファイル内の画像をMediaItem.artworkDataへ保存するが、以後の編集画面ではその保存値を正本とする。ドラフトを再作成するたびにファイル内の画像を読み直すと、置換・削除が元に戻る。画像付きWavPackを使い、置換と削除の保存後にドラフトを再取得する回帰テストで確認する。
  • WMAのデコーダーは通常のパケット処理後に空パケットを送り、残ったフレームを取り出す。最初のフレームがこの終端処理で届く場合もあるため、出力CAFとリサンプラーの初期化を通常処理と終端処理で共通化する。短尺の合成WMAでもデコードとキャッシュ作成を確認する。
  • LibraryServiceの取り込みはメインアクターから始まる。WMAのavformat_find_stream_infoやSFBAudioEngineのメタデータ取得は同期処理なので、形式判定と情報取得をExtendedAudioSource.probeInfoのutilityタスクに移し、呼び出し元のキャンセルを伝える。編集画面、メディア情報画面、書き出し計画で拡張形式のタグを読み直す経路も同じ処理を使う。メディア情報画面のWMA/WavPack表示と、書き出し名・アルバム名の取得をテストで確認する。