Repository navigation
Pitch Shifting with Signalsmith Stretch
2026-10-17、音声の通常再生と調整済みコピーの書き出しで、ピッチ変換を Signalsmith Stretch に変更した。前回の overlap 調整とピーク保護では対象区間の参照との一致度が上がらなかったため、変換アルゴリズム自体を変更した。Apple TimePitch は速度変更に使用し、出力ピーク保護は引き続き適用する。動画の再生処理は今回の変更に含めない。
この記録は、同日の Signalsmith 導入と、レビューで見つかった TimePitch の遅延計算の修正を対象とする。参照音声との比較、合成信号による検証、アプリの回帰テストを区別して記録する。人の耳による評価と iPhone 実機での確認は未実施であり、報告された聴感上の症状が解消したとは結論していない。
実装は PR #95 で main に統合した。追加の遅延修正と最終検証の対象は 31801c662c7083b9b748f653a972cf19e3fe8454、マージコミットは aa356b6b3d9954029e9f6265ed9625cc99fdfad6 である。
採用バージョンと配布条件
採用したのは C++11 のヘッダーライブラリ Signalsmith Stretch の default preset である。計算を分散する splitComputation は無効、tonality の追加指定と formant 補正は無効とした。アプリには公式リポジトリの必要なヘッダーを改変せずに取り込み、FFT には Accelerate を使用する。
| ライブラリ | タグ | 固定した commit |
|---|---|---|
| Signalsmith Stretch | 1.4.0 |
a670068d9aeb64913331d5cc29337b19a457a7df |
| Signalsmith Linear | 0.6.4 |
de55e6a50ffcf6f8f43f649692d94691c7025151 |
Linear の版は Stretch の CMake 設定に従った。Stretch のタグは 1.4.0 だが、同じ版のヘッダー内の version 定数は 1.3.2 のままであるため、採用版の識別にはタグと commit を使う。
両ライブラリは MIT ライセンスで、著作権表示とライセンス本文を複製物に含める条件がある。アプリ内の LICENSE-Stretch.txt と LICENSE-Linear.txt に全文を保持する。Stretch のライセンス、Linear のライセンスを参照する。
速度とピッチの分離
通常再生の接続順は次のとおり。SMPPitchEffect が Objective-C++ の Audio Unit を生成し、Swift からピッチとリセットを要求できるようにする。
player → Apple TimePitch(速度のみ、pitch = 0)
→ Signalsmith Stretch(ピッチのみ、入出力フレーム数は同じ)
→ フェード用 mixer → 音量自動調整 → EQ → reverb
→ mainMixer(出力形式への変換)→ PeakLimiter → −1 dB gain → output
TimePitch は速度 1 のときに bypass する。Signalsmith はピッチ 0 でも処理を続け、ピッチを 0 とそれ以外で切り替える際に遅延が変わらないようにした。default preset の処理遅延は inputLatency() + outputLatency() で、44.1/48/96 kHz の検証では 120 ms だった。ライブラリの契約と計算式は 固定版 READMEと 固定版ヘッダーで確認した。
ピッチ 0 の固定遅延を除いた出力は、短い入力でも原信号を保った。64/4800/22050 フレームの両端インパルス、正弦波、乱数を使った検証では、C++ 単体の最大絶対誤差は 1.94e−7 以下だった。本番 Audio Unit 経由の両端インパルスも、44.1/48/96 kHz で 1.76e−7 以下だった。
Audio Unit の処理とリセットの競合
FFT の構成、ライブラリの設定、入力・出力バッファの確保は allocateRenderResources で行う。render callback は用意済みのバッファを使用し、Swift や Objective-C のオブジェクト操作を経由せず C++ の処理を呼ぶ。ピッチ値と reset 世代には lock-free であることを static_assert した atomic を使い、DSP の状態変更は render 側で行う。
C++ 単体では、1〜4096 フレームの全ブロック長を使い、毎回のピッチ変更と 37 回ごとの reset を加えた。44.1/48 kHz、計算分散の有無を変えた条件で、process、reset、setTransposeSemitones の呼び出し中に C++ new/new[] の追加確保はなかった。Accelerate 内部の malloc 等は測定していないため、内部処理を含むすべてのメモリー確保が 0 であるという主張には使わない。
公開 SMPPitchEffect.reset() は atomic な reset 要求だけを送る。初期実装で AUAudioUnit.reset を経由させたところ、pull callback 内から reset する再現条件で flushEventSchedule 内の mutex を待ち続けた。Audio Unit 標準の reset override は superclass 呼び出しを維持し、アプリの transport 操作から送る要求と分けた。
入力要求と出力にも reset 世代を対応させる。render 開始時の世代を保持し、入力取得後と DSP 処理後に再照合する。途中で reset が要求された場合は DSP を reset し、そのブロックの出力を無音にする。入力取得後だけで reset を適用する中間版では、pull 中に reset して旧インパルスを返すと peak 0.5 の旧音が残った。世代照合後は 44.1/48/96 kHz の全条件で旧音の peak が 0 になった。
ピッチ −3 半音では、512 フレーム固定と [1, 7, 64, 511, 4096, 13] の不規則な分割で同じ出力を得た。440 Hz の処理後に reset して 880 Hz を入力した場合も、新しい Audio Unit に 880 Hz だけを入力した出力と一致した。入力ブロックの分割と通常の reset で別の周波数成分を残さないことを確認している。
Sandbox とビルド設定
アプリ内 Audio Unit の登録には kAudioComponentFlag_SandboxSafe を付ける。フラグなしでは sandbox 内の初期化が失敗したが、追加後は同じ本番アダプターの検証を sandbox 内で実行できた。App Sandbox の無効化や追加 entitlement による回避は行っていない。このフラグの条件は Xcode 27 の AudioComponent.h にある AudioComponentRegister の説明で確認した。
PitchShiftAudioUnit.mm は Debug を含めてファイル単位で -O2 を指定する。公式 READMEでは未最適化の Debug 処理が大幅に遅くなると説明されている。アプリ全体の最適化設定は変更せず、数値演算を行う翻訳単位に指定した。macOS と iOS arm64 の独立コンパイルも確認した。
追加したピッチ変換 Audio Unit の生成と初期グラフの構築は controlQueue に移し、サービスの初期化時に MainActor で同期実行しない。コンポーネント登録を含む初期化で UI を待たせないためである。生成済みの pitchEffectStorage は同じキューで扱い、初期グラフの構築と後続の再生操作は FIFO 順に実行する。
終端の排出と書き出し長
Audio Unit は upstream の OutputIsSilence をそのまま出力へ伝えない。入力が無音になった時点でも 120 ms の遅延中に音が残るため、入力にゼロを与えて DSP を処理し、残っている出力を取得する。Audio Unit の latency と tailTime にもこの遅延を報告する。
書き出しでは元ファイルの後にゼロ PCM を予約し、TimePitch・Signalsmith・出力保護が報告する処理遅延と、出力保護の終端を排出する。先頭から合計遅延に相当するフレームを除き、元の長さと速度から求めた期待フレーム数だけをエンコーダーへ渡す。途中出力だけで成功とせず、期待長に達する前のエラーやキャンセルでは保存を確定しない。速度変更では入力側の padding 長も速度に合わせて増減する。
AVAudioEngine の独立検証では、64/4800 フレームを scheduleFile と scheduleSegment で予約し、追加の padding を予約しない条件でも両端のインパルスを取得した。実時間エンジンをミュートした別の検証では、両端の出力と dataPlayedBack を確認した。通知時刻は最後の tap サンプルから推定した host 時刻の約 2.6〜6.4 ms 前で、120 ms 分を切り捨てる通知ではなかった。オフライン手動レンダリングでは同通知が発火しなかったため、完了時刻は実時間側の結果だけで判断する。
TimePitch の実時間遅延
表示時計の初期遅延には、Signalsmith と出力保護だけでなく TimePitch の latency も加算する。初期実装では TimePitch 分を含めておらず、実際に出力された位置より表示時計が先へ進んだ。一時停止時にその時計を保存すると、再開位置も先になるため、表示だけの問題ではなかった。
Apple の AVAudioNode.latency は、ノードの信号処理による入出力間の遅延を秒で表す。Xcode 27 SDK のヘッダーには、0 が遅延なしと未報告の両方を意味すると記載されている。そこで、48 kHz、pitch 0、overlap 32 の TimePitch を単独のグラフで動かし、報告値とインパルス出力を照合した。
| 速度 | 実時間モードの latency
|
offline 出力からの波形の遅れ |
|---|---|---|
| 0.25 | 213.333 ms | 10240 フレーム |
| 0.5 | 128 ms | 6144 フレーム |
| 1 | 85.333 ms | 4096 フレーム |
| 2 | 64 ms | 3072 フレーム |
| 4 | 53.333 ms | 2560 フレーム |
表は bypass を無効にした手動実時間レンダリングの結果である。各条件の実時間出力を表のフレーム数だけ戻すと、offline 出力との最大絶対誤差は 0 だった。offline モードの報告値は全条件で 0 であり、実時間の値を offline の固定 trim 量として使う根拠はない。
速度 1 の bypass でも 4096 フレームの遅延が残った。標準の出力デバイスを使うエンジンでも、予約した host 時刻を基準にした出力 tap の遅延は 85.334〜85.345 ms だった。TimePitch を外した比較条件は −0.007〜0.000 ms である。したがって bypass 時も latency の加算を省かない。この測定は mainMixer をミュートして行い、スピーカーから聞こえる時刻は測定していない。
遅延の取得は engine.start() の後に行う。今回の SDK では開始前と停止後に 0 を返し、実行中は速度の設定直後に報告値が更新された。通常再生では、開始済みエンジンでセグメントを予約する際に TimePitch・Signalsmith・出力保護を合計する。offline renderer も開始後の同じ構成要素を合計し、入力 padding と先頭 trim でその計算を共有する。モードごとの報告値を使うため、今回の offline 出力へ実時間の遅延を追加しない。
bypass と速度を独立に変えた検証では、bypass 中の実出力は速度の設定値によらず 4096 フレーム遅れた一方、latency は設定値に応じて変わった。アプリでは速度 1 のときだけ bypass するため、この不一致の条件には入らない。再生中に速度を変えた際の報告値の更新は確認したが、変更途中の音と表示時刻の対応は追加の確認対象として残す。
アプリの AudioEngineProcessingLatencyTests では、手動実時間モードで 48 kHz の出力を正確に 48000 フレームずつ描画する。接続ノードが報告する遅延の総和から期待する音源位置を求め、速度 0.5/1/2 のシーク・一時停止・再開後の時計を比較した。ハードウェアの進行や固定の遅延値には依存しない。修正前の計算式に戻すと全速度で計 12 件の不一致を検出し、修正後は同じテストが成功した。
フェードと表示時刻
停止・一時停止・切り替え時のフェードは、Signalsmith の後段に置いた専用 mixer で行う。入力側の音量を下げるだけでは、遅延中の音を残したまま DSP を reset することになる。EQ と reverb の前でフェードしてから player とピッチ処理を reset することで、従来の残響処理も維持する。
表示時刻は出力側の処理済みフレームから進め、再予約後の初期遅延を差し引く。一時停止・再開・エンジン再構築には同じ保存済み時刻を使い、遅延前の player の読込位置と表示位置を混在させない。速度変更の直後は、DSP に残る以前の速度の音と新しい表示速度の間に一時的な差が残り得るため、連続変更時の表示と実音は追加の確認対象とする。
参照音声との比較
提供音源と参照アプリの録画はローカルだけで処理した。音源、録画、生成音声、元ファイルの識別情報を Wiki に含めていない。ピッチ −3 半音、速度 1 のレンダリングを、録画の音楽区間 3.0〜15.7 秒と時刻合わせして比較した。ピーク値は冒頭 20 秒の出力全体から求めた。
解析用 PCM は ffmpeg で 44.1 kHz・2 チャンネル・float32 にそろえ、NumPy 側は float64 で演算した。対数 RMS 包絡は、441 サンプル(10 ms)の窓内で左右の二乗値を平均し、平方根を取って 1e−6 以上に制限した値の自然対数である。まず窓を 441 サンプルずつ進め、時刻差 −3〜+1 秒を 10 ms 刻みで探索して Pearson 相関が最大になる位置を求めた。続いて同じ窓を 44 サンプル(約 0.998 ms)ずつ進め、粗い探索結果の前後 30 ステップを再探索した。表には、この細かい探索で得た相関を記載する。
周波数配置には左右平均の信号を使い、8192 サンプルの対称 Hann 窓と 441 サンプルの hop で FFT の振幅を求めた。周波数軸を 80 × 2^(k/48) Hz(k = 0...299、1 半音あたり 4 点)へ線形補間し、各窓の最大振幅の 1e−4 と 1e−9 の大きい方を下限にして自然対数を取った。窓の中心時刻を基準に、比較区間を 20 ms 刻みで時刻補間した後、周波数軸の 25 点移動平均を引く(両端は端の値で 12 点ずつ補う)。さらに各窓の平均を引き、L2 ノルムで正規化して、参照との内積を全時刻で平均した値が表の類似度である。ノルムの下限は 1e−12 とした。ピッチ差も −6〜+6 半音を 0.25 半音刻みで走査し、表の全条件で最良の差が 0 半音であることを確認した。
| 処理 | 入力側の音量 | 参照との対数 RMS 包絡の相関 | 周波数配置の平均類似度 | 出力全体の最大絶対値 |
|---|---|---|---|---|
| 直前の Apple TimePitch overlap 32 +出力保護 | 1.0 | 0.9428 | 0.8769 | 0.8912 |
| 直前の Apple TimePitch overlap 32 +出力保護 | 0.75 | 0.9433 | 0.8770 | 0.8908 |
| Signalsmith の本番 renderer +出力保護 | 1.0 | 0.9668 | 0.8877 | 0.8910 |
| Signalsmith の本番 renderer +出力保護 | 0.75 | 0.9673 | 0.8879 | 0.8910 |
同じ音量同士の比較でも、今回の区間では包絡と周波数配置が参照に近づいた。出力の絶対値 1 超過は新実装の両音量条件で 0 サンプルだった。これらは信号の特徴を記述する値であり、知覚上の音質点数ではない。参照録画と原音では AAC の圧縮条件、ゲイン、ステレオ像も異なるため、波形差をノイズ量として評価していない。
ローカルの試聴用コピーは、対応する 12.7 秒を固定ゲインだけで −20 LUFS にそろえ、すべてのコピーを再測定して確認した。これは比較時の音量差を減らすための処理であり、人の耳で音質を評価した結果ではない。
合成音のすべての指標で Apple の処理を上回ったとは扱わない。低音や和音では周波数のずれや帯域外成分が残り、単一の正弦波で良い値を得ても音楽一般の品質を保証しない。採用判断は、対象区間の参照との対応、固定遅延を管理できること、ブロック・終端・リセットの検証結果、配布条件を合わせたものである。
検証状況
| 検証 | 状態と証跡 |
|---|---|
| C++ の固定遅延・可変ブロック・確保計測 | 成功。stream-tests.jsonl を native 検証アーカイブに保存 |
| 本番 Audio Unit の短音・不規則ブロック・reset 競合 | 成功。修正前の旧音 peak 0.5 と修正後の 0、通常 reset 後の一致を同アーカイブに保存 |
| AVAudioEngine の終端とミュート実時間出力 | 合成音で成功。出力デバイス別の試験や聴感評価は含まない |
| SandboxSafe 追加後の macOS 初回回帰 | 14 テスト成功。.build-logs/signalsmith-sandbox-macos-20261017-170658-50923.log。後続の reset 競合修正前の結果 |
| TimePitch 遅延修正前の macOS 回帰 | 47 テスト関数成功、失敗 0。reset 競合修正と初期化経路の見直しを含む。.build-logs/signalsmith-final3-macos-20261017-171738-57239.log
|
| TimePitch 遅延修正前の iPhone シミュレータ回帰 | 38 テスト関数成功、失敗 0。reset 競合修正を含む。.build-logs/signalsmith-final3-iphone-20261017-171738-57238.log
|
| TimePitch 遅延修正前の iPad シミュレータ回帰 | 38 テスト関数成功、失敗 0。同時点のビルドを test-without-building で検証。.build-logs/signalsmith-final-ipad-20261017-171910-58632.log
|
| TimePitch 遅延修正前の lint | 全体 lint 成功。既存の警告 37 件、ベースラインへの追加なし |
| TimePitch の実時間/offline 遅延 | bypass 無効の手動レンダリング全速度で波形差が報告遅延と一致。速度 1 の bypass は標準デバイスでも確認。ソースと結果を native 検証アーカイブに保存 |
| TimePitch 遅延修正前後の表示時計の回帰 | 修正前は 3 速度すべてで失敗、修正後は成功。修正前のログは .build-logs/pr95-clock-before-20261017-174435-74345.log、修正後は下記の各 OS の回帰に含む |
| TimePitch 遅延修正後の macOS 回帰 | 48 テスト関数成功、失敗 0。.build-logs/pr95-review-final-macos-20261017-174731-77023.log
|
| TimePitch 遅延修正後の iPhone シミュレータ回帰 | 39 テスト関数成功、失敗 0。.build-logs/pr95-review-final-iphone-20261017-174729-77002.log
|
| TimePitch 遅延修正後の iPad シミュレータ回帰 | 39 テスト関数成功、失敗 0。同じ最終ビルドを使用。.build-logs/pr95-review-final-ipad-20261017-174905-78330.log
|
| TimePitch 遅延修正後の lint | 全体 lint 成功。既存の警告 37 件、ベースラインへの追加なし。.build-logs/pr95-review-final-lint.log
|
| TimePitch 遅延修正後の CI | コミット 31801c6 の実行で全ステップ成功。macOS・iOS のビルド、macOS 全単体テスト、SwiftLint を通過 |
未検証なのは、人の耳による比較、iPhone/iPad 実機の処理負荷と連続キー・速度変更、Bluetooth/AirPlay/USB の実出力先切り替え、音源一般の品質である。実機の出力を録音した値と、オフライン renderer の値を混同しない。