Repository navigation
Extended Audio Cache Import Optimization
2026-10-03、Issue #13 の修正で確認した原因、設計、測定方法を記録する。対象形式はWMA、WavPack、Monkey's Audio、Musepack。変更前のソースは 90557a1f14fbf269ebbee1a0100b620d4737afd8。修正コミットは 4494b07。
変更前は、取り込みの妥当性確認で ExtendedAudioSource.readableURL を呼び、全音声をCAFへ書いていた。キャッシュ容量を超える一括取り込みでは、初期に取り込んだ曲のCAFが後続の取り込みで削除され、初回再生で同じPCMをもう一度書く。44.1 kHz・ステレオ・32 bitの4分音声は1曲約80.7 MiB、3000曲で約236.6 GiBという計算になるが、この規模の実ファイル書き込みは測定していない。
修正後は、取り込み用の validate が全音声をデコードしてサンプルを破棄する。出力形式ごとに必要時にヘッダーだけのCAFを作成して付加サイズを測り、その値を再利用する。PCMサンプルはこの測定にも書かない。再生、音量正規化、音楽解析、変換書き出しだけが、必要になった時点でCAFを生成する。PR #20のレビュー修正後のAPIは readableFile であり、後述の読み取り用ハンドルを返す。原本のコピー、タグの取得、ライブラリ登録、元ファイル書き出しは従来の保存先を使う。全音声の検証を残すため、デコードのCPU時間は引き続き必要となる。
取り込み用の破棄処理とCAF書き込みは、同じデコーダー初期化とフレーム検証を使う。SFBAudioEngineでは、出力バッファの確保、累積サイズ上限に加え、既知の宣言フレーム数と実際の出力フレーム数が一致することを確認する。採用したWavPackデコーダーは WavpackUnpackSamples が0を返すと終端として扱うため、先頭が読めただけの短い破損入力を成功としないためにフレーム数の比較が必要となる。
WMAはFFmpegBuild 3.6.0に含まれるFFmpeg n8.1.3を使う。通常パケットの出力を処理した後、NULLパケットを送り、avcodec_receive_frame が AVERROR_EOF を返すまで排出する。終端で初めて届くフレームも通常フレームと同じ初期化と検証を通す。途中の出力があっても、終端のデコーダーエラー、キャンセル、容量超過があれば成功にしない。契約は採用版の avcodec.h と同梱ヘッダーを確認した。
PCMを書かない検証へ分離すると、音声サンプルだけが4 GiB以内でもCAFのヘッダーを加えた全体が4 GiBを超える境界が生じる。これを放置すると、取り込みは成功し、初回再生のキャッシュ生成で容量超過になる。
検証とキャッシュ生成は、同じ出力形式のヘッダーサイズを測る共通処理を使い、PCMの累積サイズにこの付加サイズを含めて判定する。CAFのヘッダーサイズを固定値にせず、AVAudioFile でヘッダーだけのファイルを作って実測する。結果は形式ごとに再利用し、一時ファイルは削除する。取り込み時にはPCMキャッシュも音声サンプルのファイル出力も作らないが、この最初のヘッダー測定には少量のディスク書き込みがある。
変更前のキャッシュロックは全ファイルで共通であり、情報読み取り、全デコード、CAF書き込み、公開、容量調整を通して保持されていた。そのため、別の曲のキャッシュが完成済みでも、取り込み中のデコードの終了を待つ。
修正後は、保存先URLごとに要求状態を持ち、同じ原本への要求だけが実行中の変換を待つ。全デコードと一時CAF書き込みではロックを保持しない。キャッシュ確認、完成CAFの公開、容量調整だけを排他区間で行う。同じ保存先の待機中も、呼び出し元のキャンセルを確認する。容量上限の計算は完成CAFだけを対象にし、並行生成中の一時CAFを削除しない。PR #20のレビュー修正後は、放棄された一時CAFの回収も行う。
公開時には、要求が無効化されていないことと、原本のパス・サイズ・更新日時から算出した識別情報が変わっていないことを再確認する。項目削除では原本を消す前に要求を無効化し、その後のキャッシュ削除を別タスクで行う。キャンセル要求だけで遅延結果を防ぐ設計にはしていない。再生準備では従来の読み込み世代と要求ID、解析では世代IDも確認する。
実デコーダーとアプリの処理を抽出してコンパイルし、WMA、WavPack、APE、Musepackを25件ずつ、合計100件処理した。WMA/WavPackは既存の1秒テスト音源、APEは0.3秒のテスト音源、Musepackは既存WAVから生成する。異なるパスにコピーし、キャッシュキーの一致で処理が省略されないようにする。
測定用コピーだけキャッシュ全体の上限を512 KiBへ縮小し、保存先を一時ディレクトリへ変更した。変更前のCAF合計は約11.4 MiBで、容量を約23倍超える。製品の上限は1件4 GiB、全体8 GiBのまま。変更前と変更後を交互に3回実行し、中央値を比較する。準備用コピー、Musepack生成、メタデータの集約、データベース登録は計測に含めない。
| 取り込み時の検証100件 | 変更前 | 変更後 |
|---|---|---|
| CAFの論理書き込み量 | 11,986,900 B | 0 B |
| プロセスのディスク書き込みカウンター増分 | 12,185,600 B | 12,288 B |
| 検証終了時に残るキャッシュ | 479,476 B | 0 B |
| 経過時間の中央値 | 0.300 s | 0.142 s |
| CPU時間の中央値(user + system) | 0.225 s | 0.138 s |
変更後の12,288 Bの書き込みは、このバッチで使う出力形式のヘッダーだけを測定した分である。PCM書き込みと完成キャッシュはともに0 B。
容量超過で変更前の最初の曲が退避済みとなるため、その曲の再生準備も別に計測した。両方とも176,128 BのCAFを新しく生成し、43,008フレームを読み取れた。準備時間の中央値は変更前1.67 ms、変更後3.55 ms。取り込み後の再生準備は、変更前も変更後もデコードが必要となる条件である。
測定はApple SiliconのmacOS 27で実行した。経過時間にはOSの背景負荷も含む。この測定では、3000曲のアプリ全体の取り込み時間、8 GiBを超える実キャッシュ、実機のフラッシュ書き込み量や電池消費は確認していない。プロセスカウンターはmacOSの proc_pid_rusage の ri_diskio_byteswritten であり、SSD内部の書き込み量ではない。
再現用スクリプトはアプリリポジトリの scripts/benchmark-extended-audio-cache.sh と scripts/ExtendedAudioCacheBenchmark.swift。macOS Debugビルド後に次のコマンドを実行する。詳細結果と測定対象ソースのハッシュは、このWikiの benchmarks/extended-audio-cache-2026-10-03/ に保存する。
bash scripts/benchmark-extended-audio-cache.sh 90557a1f14fbf269ebbee1a0100b620d4737afd8- 拡張形式の取り込みでPCMキャッシュを作らず、元ファイル書き出しとタグ取得を維持する。
- デコードを意図的に保留した曲Aがある間に、完成済みの別曲Bを読み出せることを、保留の解除前の完了で確認する。
- 同じ保存先の同時要求を1回の変換へ集約し、待機側のキャンセル、変換失敗後の再試行、項目削除後の遅延完了を確認する。
- キャッシュ容量を小さくした試験で、完成結果の容量調整と並行作成中の一時CAFの保持を確認する。
- 短尺WMA、破損・切り詰めた入力、キャンセル、終端で失敗するデコーダー、1件の上限を超える入力を確認する。
Xcode 27で次のテストが成功した。macOSは全単体テスト、iOSシミュレーターは変更に関連する4スイートを対象にした。
| 実行先 | 結果 |
|---|---|
| macOS 27 | 441テスト、70スイート成功 |
| iPhone 18 Pro / iOS 27 Simulator | 29テスト、4スイート成功 |
| iPad Pro 11インチ(M5)/ iOS 27 Simulator | 29テスト、4スイート成功 |
| SwiftLint 0.65.1 | 新規違反なし、既存の警告43件 |
単体テストの再実行は、macOSでは -only-testing:SimpleMediaPlayerTests、iOS Simulatorでは次の4スイートを指定する。ExtendedAudioValidationTests には、実際のCAFサイズと同じ上限では成功し、1 B小さい上限では検証と変換の両方が失敗する試験を含む。
xcodebuild -project SimpleMediaPlayer.xcodeproj -scheme SimpleMediaPlayer \
-destination 'platform=iOS Simulator,name=iPhone 18 Pro,OS=27.0' \
-parallel-testing-enabled NO \
-only-testing:SimpleMediaPlayerTests/ExtendedAudioSourceTests \
-only-testing:SimpleMediaPlayerTests/ExtendedAudioValidationTests \
-only-testing:SimpleMediaPlayerTests/ExtendedAudioCacheTests \
-only-testing:SimpleMediaPlayerTests/ExtendedAudioImportPlaybackTests testiPadは実行先を iPad Pro 11-inch (M5) に置き換える。同じ最終ビルドを使う場合は test-without-building で実行する。
3環境すべてで、256個の異なるWavPackファイルを実際に LibraryService へ取り込みながら、別のキャッシュ済みWMAを AudioEngineService で再生する試験も成功した。LibraryService.isImporting がtrueの間に、音声エンジンの解析用フレームを受信し、再生時間が進むことを確認する。これにより、キャッシュURLの取得だけで応答性を確認したとはしていない。
iPhone/iPad実機の音切れ、電池消費は未検証。シミュレーターの結果は実機の音声出力品質を保証しない。
レビュー修正のアプリコミットは ba9b596。以下はこのコミットの設計と検証結果である。
2026-10-03、PR #20 の2件の指摘を確認し、どちらも修正した。返却済みCAFの保護に関する指摘では、URLを返した直後に要求の利用数が0になるため、呼び出し元が AVAudioFile を開く前に別の変換が容量調整を実行し、そのCAFを削除できた。異常終了時の一時CAFに関する指摘では、隠し一時ファイルを容量調整から常に除外するため、前回のアプリ実行で残った .partial.caf が回収されなかった。
読み取りAPIはURLだけを返す方式から、URLと解放操作を持つ ReadableFile を返す方式へ変更した。ハンドルを保持する間は要求の利用数を維持し、容量調整の対象から外す。再生は読み込み要求と再生中のファイル、音量解析と変換書き出しは読み取りの完了、音楽解析は遅延読み取りを行うアセットと解析セッションの完了まで保持する。明示的な解放は複数回呼んでも1回だけ利用数を減らし、解放し忘れた場合はハンドルの破棄時に解放する。
呼び出し元では、再生準備の採用・拒否・キャンセルに応じてハンドルを移動または解放する。停止と一時停止は読み込み済みのファイルを保持し、エンジンの終了では解放する。エンジンを作り直す場合は再度読み取り用ハンドルを取得する。ライブラリ項目の削除操作では、再生のクリア、音量正規化状態のリセット、音楽解析のキャンセルを行う。同期的な音量解析と変換書き出しの読み取り期間、非同期の音楽解析におけるアセットの利用期間も保持区間に含める。
追加レビューでは、MainActorから呼ぶハンドル解放とライブラリ項目の削除も確認した。容量調整だけをバックグラウンド化しても、その前に同じ状態ロックを取得すると、バックグラウンドの公開・容量調整が保持するロックを待つ。そこで、要求の利用数と無効化を管理する状態ロックと、ワーカーのキャッシュI/Oを直列化するファイル操作ロックを分けた。ハンドル解放の処理全体は保守キューへ投入し、UIからの削除要求は短い状態更新を済ませてファイル操作を保守キューへ渡す。公開と容量調整がI/O中でも、これらの操作がその終了を同期的に待たない設計とする。
利用中のCAFだけで8 GiBを超える場合は、一時的な容量超過を許容する。最後の利用者がハンドルを解放すると、バックグラウンドの保守キューで再び容量を調整する。ライブラリ項目を明示的に削除する処理はこの保護の対象外であり、保留中の変換を無効化して現在のCAFを削除する。保守キューの削除要求には世代を付け、同じ保存先へ新たな読み取り要求が入った場合は旧世代の削除を失効させる。これにより、遅れて動いた削除が新しいCAFを消さない。
一時CAFは、現行プロセスで書き込み中のURLを要求とは別に記録する。要求が無効化されてもデコーダーがまだ終了していない場合があるため、この記録はデコーダーの後処理まで保持する。キャッシュの取得と容量調整では .partial.caf のうち書き込み中でないものを削除し、現行デコーダーのファイルを残す。通常終了、失敗、キャンセルでは従来どおりデコーダーの後処理が一時CAFを削除する。
macOSでは、同じ一時ディレクトリに /var/... と /private/var/... の両方でアクセスできる。要求が保持するURLとファイル列挙が返すURLの表記が異なると、利用中のCAFや一時CAFを同じファイルとして判定できない場合があった。容量調整と一時CAF回収では、列挙結果のファイル名を入力ディレクトリへ付け直してURLを比較する。UI側の正規化のために新たなファイル属性取得を増やさず、NSTemporaryDirectory() が返すURLでもハンドルの保護が成立することを確認した。
| 発生条件 | 再現と確認方法 |
|---|---|
| 取得後・ファイルを開く前の容量調整 |
leasePreservesEntryBetweenAcquisitionAndOpen で上限を1 Bにし、曲Aのハンドル取得後に曲Bを公開してからAを開く。新規変換とキャッシュヒットの両方で読めることを確認する。 |
| 共有する2人の利用者の片方が解放 |
oneReaderReleasingDoesNotUnpinAnotherReader で片方の解放後に保守キューを完了させ、もう片方が読めることを確認する。最後の解放後には削除される。 |
| キャッシュヒット時に残っている一時CAF |
cacheHitRemovesAbandonedPartialFiles で放棄された .partial.caf を置き、再変換せずに回収されることと無関係な隠しCAFを残すことを確認する。 |
| 無効化済みデコーダーが一時CAFへ書き込み中 |
invalidatedDecoderTemporaryIsPreservedUntilItFinishes で変換を保留して無効化し、別曲を公開する。保留中は一時CAFを残し、変換終了時に公開を拒否して回収する。 |
| 一時CAFの移動後に原本が変わる |
sourceChangeAfterMoveRejectsPublishedEntry で移動前の原本確認は成功し、移動後の確認は失敗させる。要求をキャンセル扱いにし、移動済みのCAFと一時ファイルを残さないことを確認する。 |
| 削除タスクより先に新世代の変換が完了 |
delayedRemovalDoesNotDeleteNewGeneration で保守キューを停止し、削除要求後に同じ保存先へ新たなCAFを作る。キューの再開後も新しいCAFを読めることを確認する。 |
| キャッシュI/Oが保留中のMainActor操作 |
uiReleaseAndRemovalDoNotWaitForFilesystemValidation でワーカーの原本確認を保留し、MainActorからハンドルを解放して削除要求を出す。保留解除前に操作が完了し、解除後は旧要求の結果を適用せずCAFを削除することを確認する。 |
競合テストの待機方法も見直した。従来はSwiftの協調スレッドプール上でテストと保留中のデコードがセマフォを同期的に待ち、デコードを解除するテスト側の処理が実行されない場合があった。同期的なキャッシュ・デコーダー呼び出しはGCDを使う専用 TaskExecutor へ配置し、テスト側の観測は継続を使って非同期に待つ。これによりタスクのキャンセル状態を保ちながら、観測側が協調スレッドプールを占有することを避ける。
両レビュー指摘は、実キャッシュを使う修正前・修正後の比較でも確認した。返却後に別曲を公開してから最初のCAFを開く試験と、放棄した .partial.caf の回収試験は、修正前では両方失敗し、修正後では両方成功した。NSTemporaryDirectory() の /var 表記を使った場合も修正後は成功した。
レビュー修正後にも同じ512 KiB上限の100件バッチを変更前と交互に3回実行した。詳細な測定結果、実際にコンパイルしたソース、SHA-256は benchmarks/extended-audio-cache-pr20-2026-10-03/ に保存する。
| 取り込み時の検証100件 | 取り込み時CAF生成 | レビュー修正後 |
|---|---|---|
| CAFの論理書き込み量 | 11,986,900 B | 0 B |
| プロセスのディスク書き込みカウンター増分 | 12,185,600 B | 12,288 B |
| 検証終了時に残るキャッシュ | 479,476 B | 0 B |
| 経過時間の中央値 | 0.212391 s | 0.143751 s |
| CPU時間の中央値(user + system) | 0.207450 s | 0.135108 s |
最初の曲の再生準備は、両方とも176,128 BのCAFを作り、43,008フレームを読み取れた。レビュー修正後も、取り込み検証のPCM書き込みと完成キャッシュは0 Bを維持している。
最終ソースをXcode 27でビルドし、次の検証が成功した。iPhoneは関連5スイートの36テストと、キャッシュ削除を含む LibraryServicePersistenceTests の7テストを分けて実行した。iPadは同じ6スイートを一括実行した。
| 実行先 | 結果 |
|---|---|
| macOS 27、全単体テスト | 448テスト、71スイート成功 |
| macOS 27、厳格な協調スレッドプール | キャッシュ関連17テスト、2スイート成功 |
| iPhone 18 Pro / iOS 27 Simulator | 合計43テスト、6スイート成功 |
| iPad Pro 11インチ(M5)/ iOS 27 Simulator | 43テスト、6スイート成功 |
| SwiftLint 0.65.1 | 既存警告43件、エラー0件、新規違反0件、ベースライン変更なし |
厳格な協調スレッドプールの確認は LIBDISPATCH_COOPERATIVE_POOL_STRICT=1 を設定して行った。iPhoneとiPadでは、256ファイルの実際の取り込み中にキャッシュ済みWMAの音声出力と再生時間の進行を確認する試験も成功した。実機の音切れと電池消費は今回も未検証である。