Repository navigation
Pitch Shift Audio Quality Implementation
実装日: 2026-10-17。修正前の調査を受け、音声の通常再生、エンジン再生成、調整済みコピーの書き出しに共通の品質設定と出力ピーク保護を追加した。動画はキー変更の対象外であり、この変更には含めない。このページは Apple TimePitch でピッチを変換していた段階の記録であり、その後の方式変更は Signalsmith Stretch によるピッチ処理に記録した。
検証対象は、d3dd9b44d20ad787ae70caa5cc71d9570d889d71 を基にした 2026-10-17 の作業ツリー。アプリの変更はこの記録時点では未コミットである。最終の音質テスト、iOS ビルド、lint の結果を後段に記録する。
TimePitch の品質設定
AudioProcessingQuality.makeTimePitch() で overlap = 32 を設定し、通常再生、完全リセット、書き出しのすべてで同じ生成処理を使う。元の既定値 8 より処理フレームの重なりを増やし、ピッチ変換に伴う不要な周波数成分を減らすことを狙った。初期調査では改善量が入力によって異なったため、音楽一般の品質改善や報告症状の解消を保証する設定とは扱わない。
この変更で音量の上限は保証できない。初期実験では overlap を上げたことで出力ピークが増えた入力もあったため、品質設定とは別にリミッターを追加した。
サンプルレート変換後のピーク保護
通常再生の接続順は次のとおり。AudioOutputProtection が最後のリミッターと減衰用 EQ をまとめて管理し、書き出しでも同じ処理を使う。
player volume → TimePitch → 音量自動調整 → EQ → reverb
→ mainMixer(サンプルレート/チャンネル変換)
→ Apple PeakLimiter(pre-gain +1 dB)
→ 0-band EQ(post-gain −1 dB)→ output
リミッターを mainMixer の前に置くと、その後のサンプルレート変換で新たなピークが生じる余地がある。このため出力形式への変換を mainMixer で済ませ、リミッター以降を同じ形式で接続する。修正前の単体テストでは、−3 半音の倍音信号でピーク 1.3578058、48 kHz から 44.1 kHz へ変換する境界入力で 1.569067 を記録した。これらは .xcresult から取得した float PCM の値であり、スピーカー出力の測定値ではない。
PeakLimiter の値は AudioUnitSetParameter で明示する。attack は 0.002 秒、decay は 0.04 秒、pre-gain は +1 dB とし、その後に −1 dB を掛ける。SDK コメントと実行時の既定値に差があったため、既定値に依存しない。制限されない小振幅の信号では前後のゲインが相殺され、リミッターが動作する大きな信号では出力に余裕を残す。すべての入力に対する true peak の保証や、すでに原音に含まれる歪みの除去は対象にしていない。
出力形式の選択
接続には engine.outputNode.outputFormat(forBus: 0) を使う。独立実験では、48 kHz の手動レンダリングを設定しても、接続前の inputFormat が 44.1 kHz のまま残った。inputFormat を使って接続した試作では、48 kHz の入力と出力の間に不要な形式変換が入り、短いインパルスが複数のサンプルに広がった。
outputFormat を使う試作では、接続後の outputNode の入力、出力、最終 EQ の出力がすべて 48 kHz になった。同じ条件の実時間エンジンでもこの形式一致を確認したが、実際の Bluetooth/AirPlay/USB 機器を切り替えた結果ではない。
| 48 kHz、64 フレーム、TimePitch をバイパスした試験 |
inputFormat で接続 |
outputFormat で接続 |
|---|---|---|
絶対値 0.00001 超のサンプル数 |
76 | 2 |
| 最初/最後の該当位置 | 110/210 | 96/159 |
| 最初/最後のインパルスの値 | 形式変換で複数サンプルに分散 | 0.1/−0.08 |
上の位置は先頭遅延を除去する前のゼロ始まりのフレーム番号である。出力形式をそろえた条件ではリミッターの遅延が 96 フレーム(約 2 ms)となり、元の 0/63 フレームの間隔が保たれた。実験ソースと結果は benchmarks/pitch-protection-2026-10-17 の再実行手順 に保存する。
書き出しの先頭遅延と末尾排出
書き出しは、入力ファイルの直後に実際のゼロ PCM バッファを予約する。player が単に EOF へ達した状態では、無音として扱われてリミッター内部の遅延サンプルが引き出されない場合があったためである。追加バッファは、少なくとも 4096 入力フレームとし、リミッターの latency と tailTime、入力サンプルレート、再生速度から必要量も計算する。
保存時にはリミッターが報告する latency を出力フレーム数へ換算し、先頭から除去する。換算は最近傍への丸めとし、96.000004 のような浮動小数点誤差で 1 フレーム余計に切らない。保存する長さは ceil(原音の秒数 ÷ rate × 出力サンプルレート) に固定する。排出用のゼロ PCM 自体を、余分な無音としてファイルへ加えない。
キー 0、速度 1 の書き出しでは TimePitch をバイパスする。短音の実験では、この条件でも TimePitch の起動時の窓処理によって先頭インパルスが減衰した。これは書き出しで変換を必要としない場合の処理であり、通常再生中のキー変更に伴って TimePitch の bypass を切り替える変更ではない。
途中まで出力しただけでは書き出し成功としない。期待フレーム数に達する前の insufficientDataFromInputNode は失敗とし、cannotDoInCurrentContext と成功扱いの 0 フレーム出力には連続 8 回の再試行上限を置く。エンコード中の失敗やキャンセルでは finish() に進まず cancel() で終了する。finish() の実行中にキャンセルされた場合も、その後にキャンセルを検出して保存を確定しない。末尾で必要な出力を取得してから保存を確定する。
出力先変更と再生操作の競合
出力先が変わると、接続先の形式と player の予約状態が変わる可能性がある。通常再生ではノードを先に attach し、engine を開始する直前に controlQueue 上で旧 tap を外し、現在の出力形式で接続して、最終 EQ に tap を設置する。解析に使う音声もピーク保護後の値となる。
構成変更通知も controlQueue で扱う。通知対象の engine が現在の engine と同じで、engine が止まっており、直前まで再生中だった場合に、表示用に保存した時刻から再予約する。停止・一時停止・読み込み・シークの要求 ID はキュー投入前に更新されるため、通知処理時に active ID と適用済み ID が一致しなければ自動再開しない。
再開処理中にも要求の有効性を確認する。engine.start() と player.prepare() の後に再確認し、古い要求なら予約を破棄して player を停止する。表示上の再生中フラグを公開するときと、MainActor へエラーを通知するときにも ID を照合する。これにより、遅れた再開や失敗通知で新しい操作の状態を変更しない。AudioUnit 操作の間は表示用ロックを保持しない。
一時停止・停止時には reverb の残響とともにリミッターを動作させ、既存の tail 期限でバッファを reset する。動画への切り替え・クリアでは即座に停止して reset し、完全リセットでは同じ品質設定のノードを作り直す。構成変更で player の時刻が無効になった場合も、pause/stop は表示用の時刻を使い、セグメント先頭へ戻さない。
構成変更の専用回帰テストは次の 7 ケースを持つ。実機の出力先変更の代わりに engine 停止と通知を与え、prepare の完了を保留して順序を制御する。最終的に停止していることだけでなく、古い再生が一瞬でも始まると発生する fade の回数も確認する。
| ケース | 確認内容 |
|---|---|
| 通常の構成変更 | 保存位置から一度再開し、誤った終端通知を出さない |
| prepare 中の pause | 旧要求で再開せず、位置を保持する |
| prepare 中の stop | 旧要求で再開せず、位置を 0 にする |
| prepare 中の load | 旧要求で再開せず、新しいファイルの読み込みを受け付ける |
| prepare 中の suspend | 旧要求で再開せず、音声を停止する |
| prepare 中の新しい play | 古い予約を破棄し、二重に予約せず一度だけ終端に達する |
| engine 再生成後の旧 engine 通知 | 新しい engine を再び作動させたり、予約し直したりしない |
検証状況
| 検証 | 状態と証跡 |
|---|---|
| 修正前のピーク超過 |
.xcresult で −3 半音の peak 1.3578058、SRC 条件の peak 1.569067 を確認。元の結果バンドルは .build/DerivedData/Logs/Test/Test-SimpleMediaPlayer-2026.10.17_16-14-32-+0900.xcresult
|
| macOS の再生・停止・準備・終端・エンコード回帰 | 32 テスト関数が成功。ログ: .build-logs/pitch-playback-regression-macos-20261017-162144-23973.log。上の構成変更 7 ケースを含む |
| macOS の曲切り替え |
AudioEngineTrackSwitchTests の 1 テスト関数が成功。ログ: .build-logs/pitch-track-switch-final-macos-20261017-163132-32915.log
|
| 最終実装のピーク/長さ/先頭末尾/WAV 再読み込み | 13 テスト関数が成功(PitchProcessingTests 6、AudioRenderingTests 2、AudioEncodingCancellationTests 5)。ログ: .build-logs/pitch-quality-final-macos-20261017-162255-26514.log
|
| iOS 27.0 SDK での全体ビルド | 既存 UI の ReservedRegion、ArrangementView などの API が見つからず失敗。ログ: .build-logs/pitch-ios-build-20261017-162117-23121.log。音声変更が原因で全体ビルドに失敗したとは判定しない |
| iOS 27.1 SDK での全体ビルドと iPhone 実行 | Xcode 27.1 RC でビルド成功。iOS 27 の iPhone 18 Pro シミュレータで音質・構成変更・レンダリング・キャンセルの 15 テスト関数が成功。ログ: .build-logs/pitch-iphone-regression-20261017-162535-27867.log
|
| iPad シミュレータでの回帰 | 音質と構成変更の 10 テスト関数が成功。ログ: .build-logs/pitch-ipad-regression-20261017-162835-30591.log。iPhone 実行時のビルド成果物を使い、test-without-building で実行 |
| lint |
./scripts/lint.sh --quiet が終了コード 0。既存の警告 37 件、ベースライン変更なし。ログ: .build-logs/pitch-final-swiftlint.log
|
macOS の再生回帰と音質確認には 7 関数の重複があり、追加の曲切り替え 1 関数を含めた合計は異なる 39 テスト関数となる。パラメーター付きテストの各入力を、別のテスト関数として加算していない。
曲切り替えの既存テストは、play の直後に次の load を送ってフェードが始まることを期待していた。今回の要求 ID 照合では、まだ開始していない play は次の load によって無効になる。このため、テストの準備で実際の再生開始を待つように変更し、フェード中に次のファイルを開けるという本来の検証条件を保った。準備中の load による再生取消は、構成変更の専用テストで別に確認した。
PitchProcessingTests は、48/96 kHz の倍音信号を −3 半音にした出力、48→44.1 kHz のサンプルレート変換、44.1 kHz WAV の保存後の再読み込みを検証した。64/4800 フレームの小振幅インパルスは、先頭・末尾の値と位置、余分な無音がないことを検証した。速度 0.5/2 の期待長、エンコード失敗・キャンセル時の終了方法も対象に含む。
報告音源のオフライン比較は次節に記録した。iPhone 実機の聴感、再生中の連続したキー・速度変更、端末ごとの CPU 負荷と処理期限、Bluetooth/AirPlay/USB 切り替え時の実音は未検証である。合成音の PCM ピークが上限内でも、報告された「ガビガビ」が解消したかは別に確認する。
提供音源と別アプリの録画による追加比較
2026-10-17 に報告音源と、問題がないとされた別アプリの画面録画が提供された。素材はローカルでのみ処理し、音源・録画・試聴用コピーは Wiki へ保存していない。録画は約 16.8 秒で、主な再生区間は録画の約 2.48〜16.01 秒だった。画面の設定は速度 100%、ピッチ −3.00。原音に対する周波数配置の比較でも −3 半音を確認した。録画の音量・EQ・フォルマント設定は確定できない。
現在の AudioProcessingQuality.swift と TransformedAudioRenderer.swift をそのまま組み込んだ独立プログラムで、原音の冒頭 20 秒を −300 cents、rate 1 でレンダリングした。比較用に修正前の renderer と、修正前の overlap だけを 32 にした版も実行した。いずれも macOS のオフライン処理であり、iPhone の実時間出力を録音したものではない。通常再生の既定音量 0.75 を調べる場合は、同じ AAC デコード結果を TimePitch の前で 0.75 倍した。
| 冒頭 20 秒の処理 | 修正前の最大絶対値 | 現在の最大絶対値 | 修正前/現在の絶対値 1 超過サンプル数 |
|---|---|---|---|
| 前段音量 1.0 | 1.40788 | 0.89120 | 337/0 |
| 前段音量 0.75 | 1.05588 | 0.89076 | 5/0 |
サンプル数は左右チャンネルの合計。ピーク制御の効果はこの音源でも確認できた。ただし、これは知覚上の音質改善の証明ではない。
対数 RMS 包絡の相関で位置を合わせると、録画時刻から約 2.22794 秒を引いた位置が原音・各レンダリングの時刻と対応した。比較には録画の 3.0〜15.7 秒と、レンダリングの約 0.77206〜13.47206 秒の 12.7 秒を使用し、冒頭の別の短音と停止操作を除外した。区間ごとの対応も確認し、10 ms 精度の確認では速度差を検出しなかった。試聴用コピーは固定ゲインだけで −20 LUFS にそろえ、再計測でも各コピーが −20.0 LUFS、true peak が −8.94 dBTP 以下となることを確認した。比較用の増幅による新たなクリッピングは生じさせていない。
この対応区間では、修正前のピークが音量 1.0 でも 0.98313、音量 0.75 では 0.73735 で、どちらも上限超過はなかった。現在の音量 0.75 のピークも 0.82018 で上限内だった。上表の音量 0.75 の超過は原音時刻の約 13.765 秒以降にあり、録画との対応区間の外である。したがって、比較できる区間の音の荒れをフルスケール超過だけで説明することはできない。
音量をそろえたスペクトログラムと周波数配置の指標では、修正後が別アプリへ明確に近づいたとする結果は得られなかった。周波数配置の平均類似度は修正前約 0.879、現在約 0.878 でほぼ同じだった。この指標は音質点数ではなく、小さな差を劣化の証拠にも使わない。参照音声は AAC 約 123 kbps、原音は AAC 約 267 kbps で再圧縮の条件も異なるため、波形差をそのままノイズ量とは扱わない。
今回の環境では音声入力による試聴はできず、人の耳での評価は行っていない。確認できたのはピーク超過の抑制と、対応区間では音質改善を裏付ける明確な解析結果が得られなかったことまでである。別アプリと同等の聴感になった、または報告症状が解消したとは結論しない。