OpenAI Realtime API をブラウザーから使う実装で、ある日突然マイクの音声が全く認識されなくなりました。
調査した結果、AudioContext のサンプルレート指定と MediaStreamSource の組み合わせに起因する問題でした。
同じパターンは OpenAI の公式サンプルコードや多くの実装例で使われており、Chrome や macOS のアップデートをきっかけに再現する可能性があります。
環境
- Chrome: 最新版 (macOS)
- macOS: Darwin 23.2.0
- openai npm SDK: 6.33.0
- OpenAI Realtime API (GA版、
gpt-realtimeモデル) - 音声フォーマット: PCM16, 24kHz
現象
OpenAI Realtime API に WebSocket 経由でマイク音声を送信する構成で、次の挙動が発生しました。
- WebSocket 接続は正常に確立される
-
session.updateも正常に適用される - AudioWorklet からのコールバックは呼ばれ、音声チャンクの送信処理も実行される
- しかし、送信されるデータの中身が全てゼロ(無音)
- サーバー側の VAD(Voice Activity Detection)が発話を検出しない
-
input_audio_tokensが 0 のまま
[Audio] 初回送信: base64長=6400, raw長=4800, socket.readyState=1,
先頭bytes=[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]
数日前までは同じコードで正常に動作していました。
原因
問題のコードは以下のパターンです。
// Recorder の初期化
this.audioContext = new AudioContext({ sampleRate: 24000 });
await this.audioContext.audioWorklet.addModule("./audio-worklet-processor.js");
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
this.mediaStreamSource = this.audioContext.createMediaStreamSource(stream);
this.workletNode = new AudioWorkletNode(this.audioContext, "audio-worklet-processor");
this.mediaStreamSource.connect(this.workletNode);
OpenAI Realtime API が 24kHz PCM16 を要求するため、AudioContext を 24kHz で作成し、getUserMedia で取得したマイクストリーム(通常 48kHz)を接続しています。ブラウザー内部のリサンプラーが 48kHz から 24kHz への変換する前提の実装です。
このパターンは以下の場所で広く使われています。
- OpenAI の Realtime API サンプルコード
- 各種ブログ記事やチュートリアル
- Microsoft のサンプル実装
しかし、MediaStream を異なるサンプルレートの AudioContext に接続した場合の動作は W3C Web Audio API 仕様上で未定義です。
The behavior is not defined for "A MediaStream added to an AudioContext with the different sample rate" case
-- WebAudio/web-audio-api#2322
Chrome ではリサンプリングが内部的に行われますが、その実装は OS の音声ドライバやブラウザーのバージョンに依存します。macOS や Chrome のアップデートによって、リサンプリングが正常に機能しなくなり、AudioWorklet に渡されるデータが全てゼロになることがあります。
Firefox では同じパターンで NotSupportedError が発生します。Chrome は無音データを返すため、エラーにならず気づきにくいという問題があります。
ブラウザーをアップデートしていないのに発生する理由
「何も変えていないのに突然動かなくなった」という状況は十分に起こり得ます。
- Chrome の自動アップデート -- Chrome はバックグラウンドで自動更新されます。ユーザーが明示的にアップデートしなくても、ブラウザーの再起動時に新しいバージョンへ切り替わります。WebAudio のリサンプラー実装はバージョンごとに変わります。これまで動いていたサンプルレート変換が、新しいバージョンで動作しなくなる場合もあります。
-
macOS のマイナーアップデート -- macOS のセキュリティアップデートやシステム更新は自動で適用されます。Chrome の
AudioContextは内部的に macOS の CoreAudio を経由します。そのため OS 側のオーディオドライバーが変わると、挙動も変わります。
いずれもユーザーが意図的に操作しなくても適用されるため、「昨日まで動いていたコードが今日は無音になる」という状況が発生します。仕様上の未定義動作に依存している以上、環境変化に対して脆弱であることは避けられません。
筆者の環境で発生した直接の原因
筆者の環境では、macOS は更新されていませんでしたが、Chrome が以下のように自動更新されていました。
| 日付 | Chrome バージョン |
|---|---|
| 3月19日 | 146.0.7680.81 |
| 3月24日 | 146.0.7680.154 |
| 3月26日 | 146.0.7680.165(現在) |
Chrome 146.0.7680.165 には CVE-2026-4677 として、WebAudio の高深刻度セキュリティ脆弱性の修正が含まれています。このセキュリティパッチが WebAudio 内部のリサンプリング挙動へ影響した可能性が高いと考えられます。その結果、それまで正常に動作していた AudioContext({ sampleRate: 24000 }) と MediaStreamSource の組み合わせが、無音データを返すようになりました。
セキュリティパッチは適用しないわけにいかないため、根本的な対策としてはやはりネイティブサンプルレートで録音し、AudioWorklet 内でリサンプリングを行う方法が適切です。
診断方法
マイク自体が動作しているかを確認するには、ネイティブサンプルレートの AudioContext で AnalyserNode を使います。
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
// マイクの状態確認
const track = stream.getAudioTracks()[0];
console.log(`track: ${track.label}, enabled=${track.enabled}, muted=${track.muted}`);
console.log(`sampleRate=${track.getSettings().sampleRate}`);
// ネイティブサンプルレートで音量を確認
const diagCtx = new AudioContext(); // sampleRate 指定なし
const diagSource = diagCtx.createMediaStreamSource(stream);
const analyser = diagCtx.createAnalyser();
analyser.fftSize = 256;
diagSource.connect(analyser);
const dataArray = new Uint8Array(analyser.frequencyBinCount);
setTimeout(() => {
analyser.getByteFrequencyData(dataArray);
const maxVal = Math.max(...dataArray);
console.log(`音量: max=${maxVal}, diagCtx.sampleRate=${diagCtx.sampleRate}`);
// max > 10 ならマイクは正常。問題はリサンプリング側。
diagSource.disconnect();
diagCtx.close();
}, 2000);
この診断で max > 10 かつ diagCtx.sampleRate が 48000 であれば、マイク自体は正常で、24kHz AudioContext のリサンプリングが原因です。
解決策
AudioContext をネイティブサンプルレート(sampleRate 指定なし)で作成し、AudioWorklet 内でダウンサンプリングを行います。
Recorder の修正
// 修正前
this.audioContext = new AudioContext({ sampleRate: 24000 });
// 修正後
this.audioContext = new AudioContext(); // ネイティブサンプルレート(通常48kHz)
// AudioWorklet にサンプルレート情報を通知
this.workletNode.port.postMessage({
type: "init",
nativeSampleRate: this.audioContext.sampleRate,
targetSampleRate: 24000,
});
AudioWorklet プロセッサの修正
class PCMProcessor extends AudioWorkletProcessor {
constructor() {
super();
this.nativeSampleRate = 48000;
this.targetSampleRate = 24000;
this.resampleRatio = 1;
this.port.onmessage = (event) => {
if (event.data.type === "init") {
this.nativeSampleRate = event.data.nativeSampleRate;
this.targetSampleRate = event.data.targetSampleRate;
this.resampleRatio = this.nativeSampleRate / this.targetSampleRate;
}
};
}
process(inputs, outputs, parameters) {
const input = inputs[0];
if (input.length > 0) {
const float32Buffer = input[0];
if (this.resampleRatio <= 1) {
const int16Buffer = this.convertFloat32ToInt16(float32Buffer);
this.port.postMessage(int16Buffer);
} else {
// ダウンサンプリング(例: 48kHz -> 24kHz)
const resampledLength = Math.floor(
float32Buffer.length / this.resampleRatio
);
const resampled = new Float32Array(resampledLength);
for (let i = 0; i < resampledLength; i++) {
resampled[i] = float32Buffer[Math.floor(i * this.resampleRatio)];
}
const int16Buffer = this.convertFloat32ToInt16(resampled);
this.port.postMessage(int16Buffer);
}
}
return true;
}
convertFloat32ToInt16(float32Array) {
const int16Array = new Int16Array(float32Array.length);
for (let i = 0; i < float32Array.length; i++) {
let val = Math.floor(float32Array[i] * 0x7fff);
val = Math.max(-0x8000, Math.min(0x7fff, val));
int16Array[i] = val;
}
return int16Array;
}
}
registerProcessor("audio-worklet-processor", PCMProcessor);
48kHz と 24kHz の比率は 2:1 なので、1サンプルおきに取得する単純なダウンサンプリングで十分です。
より高品質なリサンプリングが必要な場合はローパスフィルタを挟むこともできますが、音声認識用途ではこの方法で問題ありません。
この問題はブラウザー JS 固有
この問題は Python や Node.js(サーバーサイド)では発生しません。
| 環境 | 音声キャプチャ方法 | この問題の影響 |
|---|---|---|
| Python |
pyaudio や sounddevice 等で OS のオーディオドライバに直接アクセスし、指定サンプルレートで録音。リサンプリングもライブラリ内部で確実に処理される |
発生しない |
| Node.js(サーバーサイド) | 同上。OS レベルの録音ライブラリを使用 | 発生しない |
| ブラウザー JS |
getUserMedia -> AudioContext -> AudioWorklet のパイプライン。サンプルレート変換はブラウザー内部のリサンプラーに依存し、その動作は W3C 仕様上も未定義 |
発生する |
Python 版の OpenAI Realtime API 実装が問題なく動作するのはこのためです。
sounddevice であれば sd.InputStream(samplerate=24000) のように指定すると、OS のオーディオドライバが確実に 24kHz でキャプチャまたはリサンプリングを行います。ブラウザーの AudioContext のように「異なるサンプルレートの MediaStream を接続した場合の動作が未定義」という問題がそもそも存在しません。
そのため Python で動作確認したあとにブラウザー版を実装すると、同じ 24kHz 指定のつもりでも挙動が異なります。原因の特定にも時間がかかります。
補足: OpenAI Realtime API の Beta から GA への移行
この問題の調査中に、OpenAI Realtime API が Beta から GA に移行していることも判明しました。
2025年9月に gpt-4o-realtime-preview の非推奨が通知され、2026年3月頃に削除されています。
GA 版では以下の変更があります。
| 項目 | Beta | GA |
|---|---|---|
| モデル名 | gpt-4o-realtime-preview |
gpt-realtime |
| session.update |
session.voice, session.turn_detection 等がフラット |
session.audio.input.turn_detection, session.audio.output.voice 等にネスト |
| session.type | 不要 |
"realtime" が必須 |
| modalities | session.modalities |
session.output_modalities |
| イベント名 | response.audio_transcript.delta |
response.output_audio_transcript.delta |
| SDK import | openai/beta/realtime/websocket |
openai/realtime/websocket |
GA 版への移行と同時にサンプルレートの問題が発生すると、原因の切り分けが難しくなります。
まずサンプルレートを修正し、その後に GA 移行を進めることをお勧めします。
まとめ
-
AudioContext({ sampleRate: 24000 })とgetUserMediaの組み合わせは、ブラウザーや OS のアップデートで無音になる可能性がある - W3C 仕様上、異なるサンプルレートの
AudioContextにMediaStreamを接続した場合の動作は未定義 - 対策として、
AudioContextはネイティブサンプルレートで作成し、AudioWorklet 内でダウンサンプリングを行う - OpenAI Realtime API の公式サンプルやチュートリアルの多くがこのパターンを使っているため、同じ問題に遭遇する開発者は少なくないと思われる
参考
- WebAudio/web-audio-api#2322 - MediaStream sample rate mismatch が未定義動作
- Chromium Issue #835767 - common AudioContext/getUserMedia pattern broken
- MDN browser-compat-data#16213 - Firefox AudioContext/getUserMedia sample rate mismatch
- Chromium Issue - AudioContext needs to support optional sample rate
- OpenAI Realtime API Guide
- OpenAI API Deprecations