2014 年から個人開発で癒し系・壁紙系のアプリを作り続け、累計 5,000 万ダウンロードほどになりました。そのひとつに音声で話しかけられる機能を試していたとき、Gemini Live API から返ってきた音声が、まるで早送りの再生のように甲高く、3 割ほど速く聞こえました。テキストの応答は正しいのに、音だけがおかしい。最初はモデルの問題かと疑って voiceConfig をいくつも切り替えてみましたが、何も変わりませんでした。原因は Gemini ではなく、私が音声を再生する側のサンプルレートを取り違えていたことでした。
同じ症状で時間を溶かしている方のために、再現条件と直し方を残しておきます。
どんな症状か
- 応答音声が甲高く、本来より速く聞こえる(いわゆるチップマンクボイス)
- 逆に低く間延びして聞こえる場合もある
- ザーッというノイズが乗る、あるいはプチプチと途切れる
- テキスト出力やトランスクリプトは正常
音が「速い・高い」なら再生レートが本来より高い、「遅い・低い」なら低い、と考えると原因の切り分けが速くなります。
なぜ起きるのか
Gemini Live API の音声まわりには、混同しやすい数字が 2 つあります。
- 入力(マイクからモデルへ送る音声)は 16,000 Hz(16kHz)の 16bit PCM
- 出力(モデルが返す音声)は 24,000 Hz(24kHz)の 16bit PCM
送信用に 16kHz を使うコードはサンプルが多く、つい再生側もその流れで 16kHz の AudioContext に流し込んでしまいます。すると 24kHz のデータを 16kHz として読むことになり、再生が 24000 / 16000 = 1.5 倍速、ピッチも 1.5 倍に上がります。これが「速回しで甲高い」の正体です。
ブラウザにはもう一段の落とし穴があります。new AudioContext() を引数なしで作ると、多くの環境で 48kHz が既定になります。ここに 24kHz の生 PCM を「48kHz のつもり」で書き込むと、今度は 2 倍速になります。再生レートは「データが本来持つレート」と「再生する側が解釈するレート」を一致させない限り合いません。
直し方その1:ブラウザ(Web Audio API)
受信した 24kHz の PCM を、必ず 24,000 を指定した AudioBuffer に載せます。Live API は int16 の PCM を base64 で返すので、Float32(-1.0〜1.0)へ正しく変換する点も忘れがちです。
// 出力は 24kHz と決め打ちで AudioContext を作る
const playbackCtx = new AudioContext({ sampleRate: 24000 });
// base64 の 16bit PCM(little-endian) → Float32 に変換して再生する
function playPcm24k(base64Pcm) {
const bytes = Uint8Array.from(atob(base64Pcm), (c) => c.charCodeAt(0));
const int16 = new Int16Array(bytes.buffer);
const float32 = new Float32Array(int16.length);
for (let i = 0; i < int16.length; i++) {
float32[i] = int16[i] / 32768; // 16bit を -1.0〜1.0 に正規化
}
// チャンネル数1・サンプルレート24000 で AudioBuffer を作る
const buffer = playbackCtx.createBuffer(1, float32.length, 24000);
buffer.copyToChannel(float32, 0);
const source = playbackCtx.createBufferSource();
source.buffer = buffer;
source.connect(playbackCtx.destination);
source.start();
}AudioContext({ sampleRate: 24000 }) と createBuffer(1, length, 24000) の両方を 24000 で揃えるのがポイントです。片方だけ直しても、ブラウザが内部で勝手にリサンプルして微妙にズレることがあります。
ただし連続再生では source.start() を呼びっぱなしにすると音が重なります。実際のアプリでは、次のチャンクの開始時刻を積み上げて隙間なくつなぐキューにします。
let nextStartTime = 0;
function enqueuePcm24k(base64Pcm) {
const bytes = Uint8Array.from(atob(base64Pcm), (c) => c.charCodeAt(0));
const int16 = new Int16Array(bytes.buffer);
const float32 = new Float32Array(int16.length);
for (let i = 0; i < int16.length; i++) float32[i] = int16[i] / 32768;
const buffer = playbackCtx.createBuffer(1, float32.length, 24000);
buffer.copyToChannel(float32, 0);
const source = playbackCtx.createBufferSource();
source.buffer = buffer;
source.connect(playbackCtx.destination);
const now = playbackCtx.currentTime;
const startAt = Math.max(now, nextStartTime);
source.start(startAt);
nextStartTime = startAt + buffer.duration; // 次チャンクは現チャンクの直後から
}これでプチプチ途切れる症状も同時に解消します。途切れは多くの場合、チャンクごとに start() をその場で呼んでいて再生の隙間が空くことが原因だからです。
直し方その2:iOS(AVAudioEngine)
ネイティブでも考え方は同じです。AVAudioPlayerNode に流すフォーマットを 24,000 Hz で宣言します。ここを録音側の 16,000 Hz のまま使い回すと、やはり速回しになります。
import AVFoundation
let engine = AVAudioEngine()
let player = AVAudioPlayerNode()
// 出力は 24kHz・モノラル・Float32 で宣言する
let outputFormat = AVAudioFormat(
commonFormat: .pcmFormatFloat32,
sampleRate: 24000,
channels: 1,
interleaved: false
)!
func setupPlayer() {
engine.attach(player)
engine.connect(player, to: engine.mainMixerNode, format: outputFormat)
try? engine.start()
player.play()
}
// Live API から受け取った 16bit PCM(Data) を再生
func play(pcm16: Data) {
let sampleCount = pcm16.count / 2
guard let pcmBuffer = AVAudioPCMBuffer(
pcmFormat: outputFormat, frameCapacity: AVAudioFrameCount(sampleCount)
) else { return }
pcmBuffer.frameLength = AVAudioFrameCount(sampleCount)
pcm16.withUnsafeBytes { raw in
let int16Ptr = raw.bindMemory(to: Int16.self)
let dst = pcmBuffer.floatChannelData![0]
for i in 0..<sampleCount {
dst[i] = Float(int16Ptr[i]) / 32768.0 // Int16 → Float32
}
}
player.scheduleBuffer(pcmBuffer)
}scheduleBuffer は内部で順番に再生してくれるので、ブラウザのような開始時刻の管理は不要です。再生フォーマットを 24,000 で宣言しさえすれば、速回しは止まります。
それでも直らないときの切り分け
レートを揃えても直らない場合は、次の 2 つを順に疑います。
ひとつは PCM の解釈です。int16 を float32 に変換せず、バイト列をそのまま Float32Array として読むと、まったく別の数値になりノイズだらけになります。バイト数(2 バイト = 1 サンプル)とエンディアン(リトルエンディアン)を確認してください。
もうひとつはチャンネル数です。Live API の出力はモノラル(1ch)です。ステレオ(2ch)として読むと、左右に半分ずつ振り分けられて速さもピッチも崩れます。createBuffer や AVAudioFormat の channels を 1 にしているか確認します。
同じ轍を踏まないために
AdMob で収益を立てる無料アプリを長く運用してきましたが、音声まわりは「数字を 1 つ取り違えるだけで全部おかしくなる」領域だと改めて感じました。広告と違ってユーザーの耳に直接届くので、速回しの音は一発で「壊れている」と伝わってしまいます。再発を防ぐために、コードのコメントではなく定数名に意味を込めるようにしています。
const GEMINI_INPUT_RATE = 16000; // マイク → モデル
const GEMINI_OUTPUT_RATE = 24000; // モデル → スピーカー16000 と 24000 を直接書かず、この 2 つの定数を入口で 1 度だけ定義しておくと、送信用のレートを再生に流用する事故がほぼ起きなくなります。
まず手元のコードで、再生に使っているサンプルレートが 24000 になっているかどうかを 1 箇所確認してみてください。多くの場合、直すのはその 1 行です。