decolua/9router · error · Error

Gemini TTS returned no audio (finishReason: ${reason}, voice

Error message

Gemini TTS returned no audio (finishReason: ${reason}, voice: ${voiceId}, model: ${modelId})

What it means

Gemini returns audio as inlineData inside candidates[0].content.parts; if the 200 response contains no part with inlineData.data, gemini.js throws this error including the finishReason (or promptFeedback.blockReason), the requested voice and model. Common finish reasons are SAFETY (content blocked), STOP with missing audio (malformed request), RECITATION, or PROHIBITED_CONTENT — i.e. Google responded successfully but declined to produce speech.

Source

Thrown at open-sse/handlers/ttsProviders/gemini.js:85

      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        contents: [{ parts: [{ text: buildPrompt(text, opts.language) }] }],
        generationConfig: {
          responseModalities: ["AUDIO"],
          speechConfig: { voiceConfig: { prebuiltVoiceConfig: { voiceName: voiceId } } },
        },
      }),
    });
    if (!res.ok) {
      const err = await res.json().catch(() => ({}));
      throw new Error(err?.error?.message || `Gemini TTS failed: ${res.status}`);
    }
    const data = await res.json();
    const b64 = data?.candidates?.[0]?.content?.parts?.find((p) => p.inlineData?.data)?.inlineData?.data;
    if (!b64) {
      const reason = data?.candidates?.[0]?.finishReason || data?.promptFeedback?.blockReason || "unknown";
      throw new Error(`Gemini TTS returned no audio (finishReason: ${reason}, voice: ${voiceId}, model: ${modelId})`);
    }
    const wav = pcmToWav(Buffer.from(b64, "base64"));
    return { base64: wav.toString("base64"), format: "wav" };
  },
};

// Voice fetcher — return prebuilt voices (Gemini has no list API)
const PREBUILT_VOICES = [
  { id: "Zephyr", lang: "en", gender: "Female" },
  { id: "Puck", lang: "en", gender: "Male" },
  { id: "Charon", lang: "en", gender: "Male" },
  { id: "Kore", lang: "en", gender: "Female" },
  { id: "Fenrir", lang: "en", gender: "Male" },
  { id: "Leda", lang: "en", gender: "Female" },
  { id: "Orus", lang: "en", gender: "Male" },
  { id: "Aoede", lang: "en", gender: "Female" },
  { id: "Callirrhoe", lang: "en", gender: "Female" },
  { id: "Autonoe", lang: "en", gender: "Female" },

View on GitHub (pinned to 90b52e06ff)

Solutions

  1. Read the finishReason in the message: SAFETY/PROHIBITED_CONTENT/BLOCKLIST → rephrase or sanitize the input text to remove flagged content; RECITATION → remove quoted copyrighted text.
  2. MAX_TOKENS/length issues → split the text into shorter segments and synthesize each separately.
  3. If reason is unknown/empty with a nonstandard voice, retest with a known prebuilt voice (e.g. Kore) and the default model to isolate voice/model incompatibility.
  4. Log the full response JSON on this path to see promptFeedback.blockReason when candidates are empty.
  5. Add a fallback provider for texts Gemini refuses so the TTS pipeline degrades gracefully.

Example fix

// before: send raw text, fail on blocked content
await gemini.synthesize(userText, undefined, creds);
// after: screen and chunk long text
const safe = userText.length > 4000 ? userText.slice(0, 4000) : userText;
try {
  return await gemini.synthesize(safe, "gemini-2.5-flash-preview-tts/Kore", creds);
} catch (e) {
  if (/finishReason: (SAFETY|PROHIBITED_CONTENT|RECITATION)/.test(e.message)) {
    return await fallbackProvider.synthesize(safe, model, otherCreds);
  }
  throw e;
}
Defensive patterns

Strategy: fallback

Validate before calling

// pre-screen the most common blockers before calling Gemini
if (!text?.trim()) throw new Error("TTS text is empty");
if (text.length > 4000) text = chunkForTts(text); // split long text into segments

Type guard

function hasAudioPart(geminiResponse) {
  return Boolean(
    geminiResponse?.candidates?.[0]?.content?.parts?.some(p => p.inlineData?.data)
  );
}

Try / catch

try {
  return await gemini.synthesize(text, model, creds);
} catch (e) {
  const m = e.message.match(/finishReason: (\w+)/);
  if (m && ["SAFETY", "PROHIBITED_CONTENT", "BLOCKLIST", "RECITATION"].includes(m[1])) {
    return await fallbackTtsProvider.synthesize(text, model, otherCreds); // content blocked — degrade gracefully
  }
  throw e;
}

Prevention

When it happens

Trigger: The generateContent call returns HTTP 200 but candidates[0] lacks an inlineData audio part — typically finishReason SAFETY/BLOCKLIST/PROHIBITED_CONTENT for flagged text, RECITATION for copyrighted-like text, MAX_TOKENS when the text is too long for the audio budget, or an empty candidate list (promptFeedback.blockReason set, e.g. blocked prompt).

Common situations: Synthesizing text containing profanity, violence, medical/self-harm topics, or quoted copyrighted material trips Gemini's safety filters; very long text exceeding the TTS output limit; an unsupported voiceName silently producing a candidate without audio; region/age restrictions on the project.

Related errors


AI-assisted analysis of decolua/9router@90b52e06ff (2026-08-30). Data as JSON: /api/errors/3dae03eac71d1dd5. Report an issue: GitHub.