Skip to content

Track Switch Latency

log5r edited this page Oct 11, 2026 · 2 revisions

曲の切り替えの遅延と協調プールの占有

Issue: #66(元は #34)、PR: #57、#59、#64。確認日: 2026-10-11(JST)。

計測の条件

  • 環境: Apple M3(8 コア)、macOS 27.0.1、Xcode 27.0。
  • 比べたリビジョン: 修正前は #57 のマージの直前の 6889bd1、修正後は main の 88f559c。
  • 曲: 240 秒、44.1 kHz、ステレオの合成音。MP3・M4A・FLAC・WV・WMA は ffmpeg で、APE・MPC は SFBAudioEngine 0.14 のエンコーダーで作った。切り替え元の曲は 3 分の M4A。
  • 区間: AudioEngineService.load(url:) の先頭から、performPlay の playerNode.play() が戻るまで。アプリと同じく、load の直後に play() を呼んだ。
  • 測り方: コミットしない一時計測コード(ContinuousClock と OSSignposter)を使った。テストホストから実物の AudioEngineService を呼び、実際に音を出した。UI と PlayerViewModel の経路は通していない。
  • 回数: 各条件 5 回の中央値。単位は ms。

load から play までの時間

前の曲の再生中に切り替えた場合(正規化 OFF)の結果。

形式・PCM キャッシュ Release 修正前 Release 修正後
MP3 / M4A / FLAC 105 / 106 / 105 104 / 104 / 105
拡張形式、キャッシュあり 103〜105 100〜105
WMA、キャッシュなし 320 237
MPC、キャッシュなし 293 190
APE、キャッシュなし 1351 1092
WV、キャッシュなし 584 587
  • 修正前は「フェードアウト(約 95〜100 ms)+準備+再生開始」だった。修正後は「フェードアウトと準備の長い方+再生開始」になった。
  • 通常形式とキャッシュのある拡張形式では、時間の大半がフェードアウトなので、前後で差はない。
  • 準備にかかる時間は、キャッシュがあれば 1〜5 ms。キャッシュがない場合は、WV 約 400 ms、WMA 約 200 ms、MPC 約 180 ms、APE 約 1 秒(Debug では約 4.5 秒)。
  • 停止中から開始する場合は、前後で一貫した差がない。時間の大半は、待機中の一時停止から engine.start() で再開する処理(10〜260 ms)である。
  • 正規化の ON/OFF とゲインのキャッシュの有無は、この区間をほとんど変えない。ゲインは play の後に適用されるためである。
  • Debug でも同じ傾向だった。

WV で短くならない理由

  • 前の曲を止めると、0.25 秒後にエンジンを一時停止する予約が入る(suspendAfterTail → AudioEngineIdleSuspension)。
  • 準備がフェードアウトの終了から 0.25 秒を超えると、この一時停止が実行され、performPlay で engine.start() が必要になる。
  • 一時停止の直後に再開すると、engine.start() が高くつく。
    • 修正前: 一時停止の 156 ms 後に再開し、engine.start() に 75 ms かかった。
    • 修正後: 一時停止の 53 ms 後に再開し、engine.start() に 184 ms かかった。
  • これで、準備を並行させて短くなった分が相殺される。準備が待たれている間は一時停止を見送る方法が考えられる(#75)。

協調プールの占有と 50 ms ごとの起床

条件は、キャッシュのない拡張形式を、正規化 ON・ゲイン未計測・楽曲解析ありで初めて再生する場合。次の 3 つの方法で確かめた。

  1. 一時カウンター: ExtendedAudioCache.reserveDecode が待つたびに、待った時間とスレッドを記録した。
    • 修正前は、曲によって 2〜83 回待った。1 回の待機は中央値 54 ms で、50 ms のポーリングに当たる。待っていたのは準備のタスク(user-initiated の協調スレッド)で、デコードしていたのは楽曲解析(utility の協調スレッド)だった。デコードの間、協調スレッドが最大 2 本ふさがった。
    • 修正後は、待機は 0〜1 回だった。処理はすべて SimpleMediaPlayer.BlockingWork のスレッドで動き、ふさがった協調スレッドはなかった。
  2. 遅延プローブ: 10 ms ごとに Task.detached を作り、作成から開始までの遅れを測った。
    • 既定のプール幅では、前後とも 100 ms を超える遅れはなかった。
    • LIBDISPATCH_COOPERATIVE_POOL_STRICT=1(プール 1 スレッド)では、修正前は APE のデコードの間、utility のタスクが約 4.5 秒止まった。修正後のデコード中の遅れは最大 23 ms だった。
    • 修正後でも、楽曲解析ありでは、デコードの後に utility が最大 0.55〜1.2 秒遅れた。楽曲解析なしでは最大 57 ms なので、楽曲解析の処理が協調プールを占有しているとみられる(#76)。
  3. xctrace: xcrun xctrace record --template 'Swift Concurrency' --attach <pid> で、許可を求められずに記録できた。
    • WV の 1 回分では、修正前は楽曲解析と準備とラウドネス計測が協調スレッドで続けて Running だった。ラウドネス計測は 1.2 秒以上かかった。
    • 修正後は、準備が BlockingWork で 411 ms、ラウドネス計測は約 1.4 秒後に BlockingWork で 27 ms だった。
    • 50 ms ごとの起床は、このテンプレートには表れない。ポーリング中のタスクは Running のままだからである。

未検証

  • 正規化 ON での冒頭のノイズと、途中のゲイン変化の聴き比べ
  • 旧バージョンで保存したゲインからの移行(audioLoudnessNormalizationCache.v1 の削除と再計測の有無)
  • iPhone 実機での切り替えと正規化
  • UI を通した実アプリの操作経路、実際の楽曲での準備時間
  • Release での xctrace の記録

補足

SFBAudioEngine 0.14 の Monkey's Audio のエンコーダーは、書き込みバイト数の出力先に NULL を渡されるとクラッシュした(SFBMonkeysAudioEncoder.mm:71)。アプリはこのエンコーダーを使っていない。計測用の APE は、複製したパッケージに NULL チェックを一時的に入れて作った。

追加確認と準備中の一時停止(#75、#66)

確認日: 2026-10-11(JST)。前掲の数値と未検証一覧は88f559c時点の記録として維持する。この追記では、#75 の一時停止条件と、#66 の旧ゲイン移行を確認した。

準備待ちでの engine.start の回避

旧曲を止めて残響を排出する期限が来たとき、現在の読み込み世代の準備中で再生を希望している場合だけ、engine.pause() を見送る。残響バッファは期限にリセットする。現世代の準備失敗やキャンセル完了では再生希望を解除し、一時停止を再予約する。旧世代の完了は世代番号と load ID の両方で除外する。

準備中の seek(autoPlay:false) は以前の再生希望を解除し、その後の play() は保留 seek も再生希望へ更新する。stop/pause は準備を続ける従来の意味を保つ。これらの完了順序を AudioEnginePreparationSuspensionTests で固定し、期限前/期限超過、停止希望、失敗、キャンセル、後着 play、suspend、次 load、reset を確認した。状態は所有する control queue から非同期に検査する。

現行の作業中 Release source を使い、before の AudioEngineService だけを88f559cから取得した。前曲 prev.m4a を再生し、対象の互換 PCM cache を各回削除してから load → play を呼んだ。各形式5回の中央値は次のとおり。区間は load 呼び出しから playerNode.play 完了までで、装置からの可聴音の開始時刻ではない。

形式 修正前 load→play 修正後 load→play 修正前 prepare 修正後 prepare 修正前 start pause 回数 前→後
WV 550.679 ms 426.533 ms 430.896 ms 424.428 ms 110.228 ms 5→0
WMA 250.785 ms 250.149 ms 249.168 ms 244.967 ms 発生なし 0→0
APE 1045.680 ms 964.053 ms 983.803 ms 954.804 ms 52.244 ms 5→0
MPC 221.047 ms 175.593 ms 214.647 ms 166.329 ms 発生なし 0→0

修正後は全形式で engine.start が発生せず、WV/APE では準備中 pause と restart の解消を確認できた。MPC の変化は prepare 自体の差なので、この修正の効果と断定しない。WMA/MPC は修正前も実時間の期限約357〜360 msより先に準備が終わった。前後とも stop 後500 msの放置で pause した。

実 mixer tap で残響も観測した。largeHall・wet100%・volume0.15で旧曲を再生し、無音 WAV の準備を止めた。load後300〜500 msの最大振幅は0.017809156、期限後は1.4925881e-7、準備後の無音曲は5.648787e-8だった。期限後にも2 bufferを受信し、その間 engine.pause は起きなかった。エンジンを保持したまま残響を消せることを、tap 出力の範囲で確認した。

計測本体は swiftc -O -g。同じ Release build-for-testing の採用依存 object を前後でリンクし、これらには coverage counter が含まれる。配布 binary の絶対時間の再現ではない。関連5 suite・29 testsは5.084秒で成功し、最終統合 Release 全単体804件・129 suiteも39.156秒ですべて成功した。全体 SwiftLint は終了0だった。iPhone Simulatorの全797件・130 suiteも54.632秒で成功したが、iPhone実機の切り替えと正規化は未検証である。

旧形式のゲイン移行

旧6889bd1の AudioLoudnessNormalizer をそのままコンパイルした seed プロセスで、専用 UserDefaults domain に旧形式のゲイン256件を保存した。合成ファイル名に Unicode と | を含めた。別プロセスの現行 cache に移行すると、256件すべてが旧値と一致し、旧キーの削除と再計測0件を確認できた。

自動移行の最初の entryURL → legacyMigration → UserDefaults.standard も別プロセスで確認した。cache の保存先だけを隔離し、旧 seed の domain と新プロセス名を専用の同じ名前にそろえた。この条件下で、未変更の自動移行経路が256/256件を引き継ぎ、旧キーを削除し、新gainファイル256件を作った。専用 domain は終了時に削除した。利用者の既存 defaults や通常の cache は変更していない。

恒久テスト aFullLegacyDictionaryMigratesWithoutMeasuringAgain も追加した。ここで確認したのは合成データと専用 domain の移行であり、実楽曲の冒頭ノイズ・途中のゲイン変化の聴き比べ、実機と UI の操作経路は残る。

再現資材と解析側の残条件

各回の元値・再現コード・trace の相関表は benchmarks の README にまとめた。巨大な trace bundle と音声は保存対象から外し、数値JSONと処理・CPU stackの要約を残した。元のtraceは /private/tmp/smp-issues-75-76/ にある。パラメータ化したセット全体の再実行は未実施で、元計測は固定tmpパスの版で行った。

解析時に残る utility 開始遅延の再現と、効果がなかった executor preference 候補は MusicAnalysis-Executor-Trace に記録する。#76 の調査結果を、#75 の切り替え改善と混同しない。

実compile sourceとcommandは同じbenchmarksの source-snapshots/、差替え内容は source-provenance.md、SHA-256は SHA256SUMS に保存した。

Clone this wiki locally