Skip to content

fix: don't silently substitute an OpenAI voice on TTS failover (#533) - #538

Open
rwunsch wants to merge 1 commit into
mbailey:masterfrom
rwunsch:fix/no-silent-openai-voice-substitution
Open

rwunsch wants to merge 1 commit into
mbailey:masterfrom
rwunsch:fix/no-silent-openai-voice-substitution

Conversation

@rwunsch

@rwunsch rwunsch commented Sep 6, 2026

Copy link
Copy Markdown

Fixes #533.

The problem

When TTS failover reaches the OpenAI endpoint, the requested voice is remapped
(af_skynova, anything unmapped → alloy). So a Kokoro or Piper hiccup doesn't
surface as an error — it surfaces as the assistant suddenly speaking in a different
voice
, and a billed one.

Why it's worth changing

  1. It's silent. Nothing in the return value or the logs says the voice was substituted.
    The first signal is auditory, mid-conversation.
  2. It converts a free local failure into a billed cloud call the user didn't ask for and
    wasn't told about.
  3. It collapses distinct voices onto one. af_sky and af_sarah both become nova.
    With several agents sharing the channel, the voice is how you tell them apart.
  4. It masks the real fault. The outage that triggered the fallback goes unreported, so
    the user debugs "why did the voice change" instead of "why is Kokoro down".

The change

The requested voice is passed through unchanged. An endpoint that doesn't own it now fails
loudly, surfacing the underlying outage.

The old behaviour stays reachable behind VOICEMODE_TTS_VOICE_SUBSTITUTION (default
false), and when it fires it logs at WARNING rather than INFO — so even opted in, it's
no longer silent.

Blast radius is small: one function, no config migration, and users with no OpenAI key
see no change at all since they never reached this path. Callers asking for a real OpenAI
voice are unaffected either way.

Tests

tests/test_tts_voice_substitution.py (6 cases) covers the default-off flag, the
pass-through, the mapping table sitting behind the guard rather than in front of it, and
the WARNING-level log.

I also updated the existing test_tts_error_handling.py::test_voice_mapping, which asserted
the old default — it now asserts pass-through, and a parallel
test_voice_mapping_when_substitution_is_enabled covers the opt-in path, so both behaviours
stay covered.

tests/test_tts_voice_substitution.py  11 passed
tests/ -k "failover or tts or voice or provider"  301 passed, 21 skipped

If you'd rather have the flag default the other way, or drop it entirely and always pass
through, both are easy — say which and I'll adjust.

simple_tts_failover remapped a local voice to an OpenAI voice when it fell
through to the OpenAI endpoint (af_sky -> nova, anything unmapped -> alloy). A
Kokoro or Piper hiccup therefore did not surface as an error -- it surfaced as
the assistant suddenly speaking in a different, and billed, voice.

Four reasons that is worth changing:
 - it is silent: nothing in the return value or the logs said the voice changed,
   so the first signal was auditory and mid-conversation
 - it converts a free local failure into a billed cloud call nobody asked for
 - it collapses distinct voices onto one (af_sky and af_sarah both became nova),
   which matters when several agents share a channel and the voice is how you
   tell them apart
 - it masks the real fault: the outage that caused the fallback goes unreported

The requested voice is now passed through unchanged, so an endpoint that does
not own it fails loudly. The old behaviour stays available behind
VOICEMODE_TTS_VOICE_SUBSTITUTION (default false) and logs at WARNING rather than
INFO when it fires.

Users with no OpenAI key see no change -- they never reached this path.
Updated the existing test_voice_mapping to assert the new default and added a
parallel case covering the opt-in path, so both behaviours stay covered.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Falling back to OpenAI silently substitutes a different voice

1 participant