DEV Community

Klakk 開発チーム
Klakk 開発チーム

Posted on

低遅延の打鍵音をAVAudioPlayerNodeプールで重ねる設計

Macでキー入力に合わせて短い効果音を鳴らすとき、毎回ファイルを開いてプレイヤーを作ると、入力のホットパスにI/Oと初期化が入り込みます。この記事では、私が有料macOSアプリKlakkで使っている現在の構成を題材に、事前準備したPCMバッファAVAudioPlayerNodeの固定プールをどう分けたかを整理します。

1. キー入力より前に音を準備する

再生時に必要なのは、キーコードからすでに変換済みのバッファを引くことだけにします。

private struct PreparedKeySound {
    let buffer: AVAudioPCMBuffer
    let outputGain: Float
}

private var preparedKeySounds: [String: PreparedKeySound] = [:]
private let stateLock = NSLock()
Enter fullscreen mode Exit fullscreen mode

サウンドパックを読み込む段階で、ファイルの読み込み、必要区間の切り出し、出力フォーマットへの変換、先頭の無音除去、音量係数の計算まで済ませます。再生時に変換を始めないことが一番大きな分離です。

辞書の入れ替えは一度組み立ててから短いロック内で行います。

let preparedSounds = try prepareKeySounds(for: soundPack)

stateLock.lock()
currentSoundPack = soundPack
preparedKeySounds = preparedSounds
loggedMissingKeys.removeAll()
stateLock.unlock()
Enter fullscreen mode Exit fullscreen mode

これなら、別のサウンドパックを読み込んでいる途中の半端な辞書を再生側が見ることはありません。

2. Player Nodeを固定数だけ先に接続する

現在の実装では、起動時に10個の AVAudioPlayerNode を作り、同じ mainMixerNode へ接続しています。

private var playerNodePool: [AVAudioPlayerNode] = []
private let maxConcurrentSounds = 10
private var currentNodeIndex = 0

for _ in 0..<maxConcurrentSounds {
    let node = AVAudioPlayerNode()
    audioEngine.attach(node)
    audioEngine.connect(node, to: audioEngine.mainMixerNode, format: nil)
    playerNodePool.append(node)
}
Enter fullscreen mode Exit fullscreen mode

ノードのattach/connectをキー入力のたびに行わず、入力後はラウンドロビンで次のノードを選びます。

let node = playerNodePool[currentNodeIndex]
currentNodeIndex = (currentNodeIndex + 1) % maxConcurrentSounds

node.stop()
node.volume = masterVolume * preparedSound.outputGain * backgroundModeMultiplier
node.scheduleBuffer(preparedSound.buffer, at: nil, options: [])
node.play()
Enter fullscreen mode Exit fullscreen mode

短い音が重なる通常のタイピングでは、一つのノードだけを使うより前の音を切りにくくなります。

3. 固定プールの限界も仕様にする

この方式は無限に音を重ねません。11個目が10個の再生時間内に来れば、ラウンドロビンで戻ったノードを stop() するため、そのノードの古い音は途中で終わります。

これは単純さと上限の明確さを選んだ結果です。改善案としては次の選択肢があります。

  • 再生中でないノードを優先して探す
  • 最も古いノードだけを再利用する
  • 音の長さに応じてプール数を測定で決める
  • 同時発音数、切断数、再生遅延をテストで記録する

また、currentNodeIndex と音量値へ複数キューから同時に触れる設計へ広げるなら、プレイヤー操作だけでなくアプリ側の状態にも直列化が必要です。「AVAudioPlayerNodeを使っているから全状態が安全」とは扱いません。

4. Engine停止時は、その一音を捨てる

出力デバイス変更などでEngineが止まっていたら、現在の実装は再起動を一度だけ要求し、そのキーの音は再生しません。

guard audioEngine.isRunning else {
    restartLock.lock()
    let shouldRestart = !isRestarting
    if shouldRestart { isRestarting = true }
    restartLock.unlock()

    if shouldRestart { restartAudioEngine() }
    return
}
Enter fullscreen mode Exit fullscreen mode

停止中の入力を後からまとめて鳴らすより、次のキーから正常に戻る方が打鍵フィードバックには自然だからです。再起動中フラグをロックで守り、同時に複数の再起動を走らせないことも重要です。

まとめ

低遅延化は「速いAPIを呼ぶ」だけではなく、キー入力後にしないことを決める作業でした。

  1. ファイルI/O、変換、無音除去、ゲイン計算を事前に終える
  2. Player Nodeを固定数だけattach/connectしておく
  3. 再生時は辞書参照、音量設定、schedule/playに限定する
  4. プール上限とEngine再起動時の欠落を隠さない

開発者による開示: 私はKlakkの開発者です。Klakkは14種類の打鍵音を切り替えられる有料Macアプリです。ソフトウェアが追加音を再生するもので、実物のキーボードの物理音や打鍵感は変えません。ASMRや集中の感じ方には個人差があり、ADHDを含む症状を改善する医療製品ではありません。

Top comments (0)