/** * Shared call TTS provider resolution. * * Both the call controller (LLM turn speech) and deterministic system * prompts (verification codes, guardian wait updates, timeout copy) use * this helper so that provider selection, format fallback, and the * native-vs-synthesized strategy decision stay in one implementation. */ import { loadConfig } from "../config/loader.js"; import { getCatalogProvider, getTtsProvider, listCatalogProviderIds, } from "../tts/provider-catalog.js"; import { resolveTtsConfig } from "../tts/tts-config-resolver.js"; import type { TtsProvider, TtsProviderId } from "../tts/types.js"; import { getLogger } from "../util/logger.js"; import type { CallAudioFormat } from "./audio-store.js"; import { evaluateTelephonyTtsPlayability, fishAudioReferenceIdConfigured, } from "./telephony-tts-capability.js"; import { resolveCallStrategy } from "./tts-call-strategy.js"; const log = getLogger("resolve-call-tts-provider"); // --------------------------------------------------------------------------- // Public types // --------------------------------------------------------------------------- export interface ResolvedCallTts { /** The resolved TTS provider, or null when config/registry is unavailable. */ provider: TtsProvider | null; /** * True when the catalog's `callMode` is `"synthesized-play"` -- audio * is synthesized via the provider API and streamed through the audio * store. False when `callMode` is `"native-twilio"` -- text is sent * via `sendTextToken()`, which the media-stream transport re-synthesizes * through daemon TTS. (Collapsing the callMode split is a documented * deferred follow-up.) */ useSynthesizedPath: boolean; /** Audio format to use for synthesized audio. */ audioFormat: CallAudioFormat; } // --------------------------------------------------------------------------- // Options // --------------------------------------------------------------------------- export interface ResolveCallTtsOptions { /** * When true, resolve `audioFormat` to `"pcm"` regardless of the * provider's configured format. The media-stream transport sets this * because it transcodes raw PCM to mu-law -- compressed formats * (mp3, opus) are sent as raw bytes and produce garbled audio. * * Also gates the media-stream playability guard: a configured provider * that cannot produce playable PCM audio (or lacks credentials) is * swapped for a playable fallback provider instead of resolving into a * provider whose only possible media-stream outcome is silence. */ requiresPcmAudio?: boolean; } // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- /** * Resolve the active TTS provider via the global provider abstraction. * * The native-vs-synthesized decision is driven by the catalog's * `callMode` field via {@link resolveCallStrategy}. Providers with * `callMode: "synthesized-play"` have their audio streamed through the * audio store and played via `sendPlayUrl`. Providers with * `callMode: "native-twilio"` send text via `sendTextToken`, which the * media-stream transport re-synthesizes through daemon TTS. * * For PCM-requiring transports (`requiresPcmAudio`, i.e. media-stream), * the resolved provider is validated against the media-stream playability * capability (format + credentials); a not-playable provider is replaced * by {@link findPlayableTelephonyTtsFallback}. * * Falls back to the native path with `mp3` format when the config is * missing a `services.tts` block or the provider is not registered * (e.g. unit tests or early startup). */ export async function resolveCallTtsProvider( options?: ResolveCallTtsOptions, ): Promise { try { const config = loadConfig(); const resolved = resolveTtsConfig(config); // Use the catalog's callMode to decide the call path. const strategy = resolveCallStrategy(config); let providerId = resolved.provider; let useSynthesizedPath = strategy.callMode === "synthesized-play"; // Preflight provider-specific config invariants that would otherwise // fail only at first synthesis call. Fish Audio requires a reference // ID when no per-request voiceId is supplied (the telephony default). const fishAudioUnusable = providerId === "fish-audio" && !fishAudioReferenceIdConfigured(); if (options?.requiresPcmAudio) { // Media-stream transport: every spoken turn is synthesized, so the // provider must produce playable PCM with resolvable credentials // and satisfied config invariants (the capability check covers the // fish-audio referenceId rule). Swap in a playable fallback rather // than resolving into a provider that can only be silent. const capability = await evaluateTelephonyTtsPlayability(providerId); if (capability.status === "not-playable") { const reason = capability.reason; const fallbackId = await findPlayableTelephonyTtsFallback(providerId); if (fallbackId) { log.warn( { providerId, reason, fallbackProviderId: fallbackId }, "Configured TTS provider is not playable on the media-stream transport; falling back", ); providerId = fallbackId; useSynthesizedPath = getCatalogProvider(fallbackId).callMode === "synthesized-play"; } else { log.warn( { providerId, reason }, "Configured TTS provider is not playable on the media-stream transport and no playable fallback provider is available", ); } } } else if (useSynthesizedPath && fishAudioUnusable) { // Non-PCM transport: degrade to the native token path rather than // letting the call stay silent. log.warn( { provider: providerId }, "Synthesized call TTS disabled: fish-audio.referenceId is not configured; falling back to native token path", ); return { provider: null, useSynthesizedPath: false, audioFormat: "mp3" }; } const provider = getTtsProvider(providerId); // Read the user-configured audio format from the resolved provider // config so the streaming store entry's content-type matches the // actual audio bytes the provider produces. // // When requiresPcmAudio is set (media-stream transport), resolve to // PCM so audioBufferToFrames receives raw PCM it can transcode to // mu-law. const audioFormat: CallAudioFormat = options?.requiresPcmAudio ? "pcm" : (() => { const configuredFormat = ( resolved.providerConfig as { format?: string } ).format; return ( configuredFormat && ["mp3", "wav", "opus"].includes(configuredFormat) ? configuredFormat : "mp3" ) as Exclude; })(); return { provider, useSynthesizedPath, audioFormat }; } catch { // Config missing `services.tts` block or provider not registered // (e.g. unit tests or early startup) -- fall back to the native // path where the provider object is not used. return { provider: null, useSynthesizedPath: false, audioFormat: "mp3" }; } } /** * Decode a resolved call audio format into the provider request format * and the audio-store format. * * Transport-forced PCM and user-configured WAV both request raw PCM from * the provider so the audio bytes match the store's content-type — * without this, providers like Fish Audio still return mp3 and the * downstream mu-law transcoder fails on the format mismatch. The store * format follows the request: when raw PCM is requested the entry's * content-type must be audio/pcm, otherwise the store would say * "audio/wav" while the bytes have no RIFF header and * audioBufferToFrames falls through to the wrong decode path. */ export function resolveSynthesisFormats(format: CallAudioFormat): { outputFormat: "pcm" | undefined; storeFormat: CallAudioFormat; } { const outputFormat = format === "pcm" || format === "wav" ? ("pcm" as const) : undefined; return { outputFormat, storeFormat: outputFormat ?? format }; } /** * Find a catalog provider that can produce playable media-stream audio * with resolvable credentials. * * Preference order: the ElevenLabs default first (when its key resolves), * then the remaining catalog providers in display order. The playability * capability already applies the fish-audio `referenceId` invariant, so a * referenceId-less fish-audio setup is never selected. Returns `null` when * no provider qualifies. */ export async function findPlayableTelephonyTtsFallback( excludeProviderId?: string, ): Promise { const candidates = [ ...new Set(["elevenlabs", ...listCatalogProviderIds()]), ].filter((id) => id !== excludeProviderId); for (const candidateId of candidates) { const capability = await evaluateTelephonyTtsPlayability(candidateId); if (capability.status === "playable") { return candidateId; } } return null; } /** * Resolve {@link findPlayableTelephonyTtsFallback} to a registered * provider instance. Returns `null` when no playable fallback exists or * the winning candidate is not registered in the provider registry. * Callers own retry semantics and logging. */ export async function findPlayableTelephonyTtsFallbackProvider( excludeProviderId?: string, ): Promise { const fallbackId = await findPlayableTelephonyTtsFallback(excludeProviderId); if (!fallbackId) { return null; } try { return getTtsProvider(fallbackId); } catch { return null; } }