[Feature] Allow per-call voice override in the Say verb via options
Summary
Let an external Voice app override the configured TTS voice on a per-utterance basis by passing a voice through say(text, options).
Problem / Motivation
The say() verb already accepts an options argument (SayOptions) that is faithfully carried end-to-end: VoiceResponse.say → Say verb → SayRequest.options (protobuf Struct) → createSayHandler → voiceClient.synthesize(text, options) → SpeechHandler → tts.synthesize(text, options). The engine-facing type even declares a voice slot (SynthOptions = { voice: string }).
But no TTS engine reads it. Google.ts and ElevenLabs.ts resolve the voice exclusively from this.engineConfig.config.voice and use options only for logging. So today there is no way to change the voice per call — and passing { voice } silently does nothing, which is a bug-like trap given the type advertises support.
Proposed Solution
- In each TTS engine (
Google,ElevenLabs,Azure,Deepgram), resolve the voice asoptions.voice ?? this.engineConfig.config.voice. - Add an optional
voicefield to theSayverb's Zod validation schema (mods/voice/src/verbs/Say.ts) so it isn't dropped at validation. - Document the option on
VoiceResponse.sayand consider validating the voice value per engine.
Acceptance Criteria
-
response.say("Hello", { voice: "<engine voice>" })uses the supplied voice - Omitting
voicefalls back to the app's configured TTS voice (no behavior change) - Supported across Google, ElevenLabs, Azure, and Deepgram engines
-
Sayverb schema accepts an optionalvoice - Invalid/unknown voice values are handled gracefully (clear error or documented fallback)
Notes / Architectural Consideration
This may be a good checkpoint to evaluate moving the TTS and STT engines out of apiserver and into the voice module, removing them from the apiserver. Worth weighing the ownership/coupling of the speech engines as part of (or right before) this work.
Priority
P2
Source: fonoster/fonoster