Repository navigation
Track Switch Latency
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。
前の曲の再生中に切り替えた場合(正規化 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 でも同じ傾向だった。
- 前の曲を止めると、0.25 秒後にエンジンを一時停止する予約が入る(
suspendAfterTail→AudioEngineIdleSuspension)。 - 準備がフェードアウトの終了から 0.25 秒を超えると、この一時停止が実行され、
performPlayでengine.start()が必要になる。 - 一時停止の直後に再開すると、
engine.start()が高くつく。- 修正前: 一時停止の 156 ms 後に再開し、
engine.start()に 75 ms かかった。 - 修正後: 一時停止の 53 ms 後に再開し、
engine.start()に 184 ms かかった。
- 修正前: 一時停止の 156 ms 後に再開し、
- これで、準備を並行させて短くなった分が相殺される。準備が待たれている間は一時停止を見送る方法が考えられる(#75)。
条件は、キャッシュのない拡張形式を、正規化 ON・ゲイン未計測・楽曲解析ありで初めて再生する場合。次の 3 つの方法で確かめた。
-
一時カウンター:
ExtendedAudioCache.reserveDecodeが待つたびに、待った時間とスレッドを記録した。- 修正前は、曲によって 2〜83 回待った。1 回の待機は中央値 54 ms で、50 ms のポーリングに当たる。待っていたのは準備のタスク(user-initiated の協調スレッド)で、デコードしていたのは楽曲解析(utility の協調スレッド)だった。デコードの間、協調スレッドが最大 2 本ふさがった。
- 修正後は、待機は 0〜1 回だった。処理はすべて
SimpleMediaPlayer.BlockingWorkのスレッドで動き、ふさがった協調スレッドはなかった。
-
遅延プローブ: 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)。
-
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 チェックを一時的に入れて作った。
確認日: 2026-10-11(JST)。前掲の数値と未検証一覧は88f559c時点の記録として維持する。この追記では、#75 の一時停止条件と、#66 の旧ゲイン移行を確認した。
旧曲を止めて残響を排出する期限が来たとき、現在の読み込み世代の準備中で再生を希望している場合だけ、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 に保存した。