While prototyping a voice feature for one of my relaxation apps, the speech coming back from Gemini Live API sounded high-pitched and roughly a third too fast — like a fast-forwarded tape. The text response was correct; only the audio was wrong. I first blamed the model and cycled through several voiceConfig options, but nothing changed. The cause wasn't Gemini at all. I had the playback sample rate wrong on my side.
If you're burning time on the same symptom, here are the repro conditions and the fix.
What the symptom looks like
- The response audio is high-pitched and plays faster than it should (the classic chipmunk voice)
- Sometimes the opposite: low and dragging
- A constant hiss, or choppy stop-start playback
- Text output and transcripts are perfectly fine
A quick rule: if it's "fast and high," your playback rate is too high; if it's "slow and low," it's too low. That alone narrows the cause fast.
Why it happens
Gemini Live API's audio path has two numbers that are easy to confuse.
- Input (mic to model) is 16,000 Hz (16kHz) 16-bit PCM
- Output (model to you) is 24,000 Hz (24kHz) 16-bit PCM
There are plenty of samples that use 16kHz for the send path, so it's tempting to push the playback through a 16kHz AudioContext out of habit. Now you're reading 24kHz data as if it were 16kHz, so playback runs at 24000 / 16000 = 1.5x speed, with pitch raised by the same 1.5x. That's the "sped up and high-pitched" sound.
Browsers add another trap. Creating new AudioContext() with no arguments defaults to 48kHz in many environments. Feed raw 24kHz PCM into that "as if it were 48kHz" and you get 2x speed. The rate only matches when the rate the data actually carries and the rate the playback side assumes are the same.
Fix #1: Browser (Web Audio API)
Always load the incoming 24kHz PCM into an AudioBuffer that you explicitly create at 24,000. Live API returns int16 PCM as base64, so converting it correctly to Float32 (-1.0 to 1.0) is the other step people miss.
// Decide the output is 24kHz and build the AudioContext accordingly
const playbackCtx = new AudioContext({ sampleRate: 24000 });
// base64 16-bit PCM (little-endian) -> Float32, then play
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; // normalize 16-bit to -1.0..1.0
}
// 1 channel, sample rate 24000
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();
}The point is to set both AudioContext({ sampleRate: 24000 }) and createBuffer(1, length, 24000) to 24000. Fix only one and the browser may resample internally and leave you slightly off.
For continuous playback, calling source.start() repeatedly overlaps the audio. In a real app, stack each chunk's start time so they connect seamlessly.
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; // next chunk starts right after this one
}This also clears up the choppy playback, which usually comes from calling start() on each chunk on the spot and leaving gaps between them.
Fix #2: iOS (AVAudioEngine)
The idea is identical on native. Declare the format you feed into AVAudioPlayerNode at 24,000 Hz. Reuse the recording side's 16,000 Hz here and you get the same sped-up result.
import AVFoundation
let engine = AVAudioEngine()
let player = AVAudioPlayerNode()
// Output: 24kHz, mono, 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()
}
// Play 16-bit PCM (Data) received from Live API
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 plays buffers in order internally, so you don't need the start-time bookkeeping the browser requires. Declare the playback format at 24,000 and the speed-up stops.
When it still isn't fixed
If matching the rate doesn't fix it, suspect these two in order.
First, how you interpret the PCM. If you read the byte array straight into a Float32Array without converting int16 to float32, you get completely different values and wall-to-wall noise. Check the byte count (2 bytes = 1 sample) and the endianness (little-endian).
Second, the channel count. Live API output is mono (1ch). Read it as stereo (2ch) and the samples get split across left and right, breaking both speed and pitch. Confirm channels is 1 in createBuffer or AVAudioFormat.
Avoiding the same trap
I've been building apps as an indie developer since 2014, and audio remains the area where getting a single number wrong throws everything off. To prevent a repeat, I put the meaning into constant names rather than comments.
const GEMINI_INPUT_RATE = 16000; // mic -> model
const GEMINI_OUTPUT_RATE = 24000; // model -> speakerDefine these two once at the entry point instead of writing 16000 and 24000 inline, and you almost never reuse the send rate for playback by accident.
Start by checking one thing in your own code: whether the sample rate used for playback is 24000. In most cases, that's the single line you need to change.