OpenAI Realtime API の response.cancel が効かない原因
OpenAI の Realtime API で音声実況 AI を作っています。場面が変わったときに前の話題を打ち切って新しい場面について喋らせようと response.cancel を送ったところ、次のエラーが返ってきました。
response_cancel_not_active「キャンセルすべき応答は存在しない」という意味ですが、スピーカーからはまだ前の話題が流れていました。
実測: 12 秒分の発話を約 2 秒で生成し終えている
ログを追うと、モデルは 12 秒分の発話を約 2 秒で生成し終えていました。それ以降は音声バッファが流れているだけで、サーバーから見れば「生成中の応答」はもう存在しません。response.cancel が止めようとしたのは生成であって、私が止めたかったのは再生でした。
この食い違いの結果、次のことが起きていました。
- 打ち切ったつもりの新しい応答が、再生待ち行列の後ろに積まれる。前の話題が最後まで流れてから新しい話題が始まるので、実況が常に一つ遅れる(語るのをやめて相槌にした対処)
- 音が出る前の応答を次の合図で打ち切ると、出力ゼロの応答が量産される。生成は始まっているのでキャンセルは通るが、ユーザーには何も届かない(進行装置のフラッシュで音声が出なくなった例)。これが連鎖すると、合図が来るたびに自分の応答を自分で消し続ける状態になる
機構: 生成・送出・再生は別の状態
Realtime API では、応答のライフサイクルが 3 つの状態に分かれています。
- 生成: モデルがトークンを作っている。
response.cancelで止められるのはここだけ - 送出: 生成済みの音声がサーバーからクライアントへ流れている
- 再生: クライアント側のバッファにある音声がスピーカーから出ている
私が使っている WebRTC 接続では、未再生の音声は output_audio_buffer.clear で捨てられます。公式ガイドにも、WebRTC / SIP では output_audio_buffer.clear で未再生の音声を消す、と書かれていました。WebSocket 接続の場合は音声の再生をクライアントが自前で実装するので、捨てる対象も自前の再生キューにある音声です。
発話を今すぐ止めるには、生成のキャンセルと再生バッファの破棄を両方送る必要がありました。どちらか一方だけでは、生成が終わっていればキャンセルは止める対象が無く、再生バッファに音声が残っていればその音声が流れ続けます。
どう直したか
- 打ち切りは
response.cancelとoutput_audio_buffer.clearをセットで送る。response_cancel_not_activeは「生成はもう終わっている」という情報として扱い、エラーにしない - 打ち切りの対象を「音声が出始めた応答」に限定する。まだ音が出ていない応答を次の合図で消すと、出力ゼロの応答が連鎖するので、音が出る前の応答は次の合図を待たせる
履歴は再生した範囲に切り詰められ、遅れはゼロにならない
output_audio_buffer.clearは会話履歴も途中で切り詰めるので、モデルから見た「自分が言ったこと」は実際に再生された範囲になります。これは望ましい挙動ですが、履歴を完全に残したい用途には向きません- 打ち切りの対象を絞ったぶん、「音が出始める直前の古い応答」は流れてしまうことがあり、遅れは完全にはゼロになっていません
- 「12 秒を約 2 秒で」は私の構成での一例で、発話の長さやモデルの負荷で変わります。生成が再生より速いという関係だけが一定です
生成・送出・再生を別々に追跡する
- ストリーミング音声 API では「生成」「送出」「再生」の 3 つを別々に追跡する。止めたいのがどれなのかを先に決める(Gemini Live API でも turnComplete が音声より先に届く)
response_cancel_not_activeは失敗ではなく「生成はもう終わっている」という状態の通知として読む- 打ち切りは「音声が出てから」に限定する。出る前の応答まで消すと、何も聞こえない状態が続く
response.cancel は発話を止めるコマンドではなく生成を止めるコマンドで、モデルは音声がスピーカーから流れ終わるよりずっと早く生成を終えています。