From d047f604d3fd78d2b015f3c2b8fe5e89a7e33974 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Sun, 15 Mar 2026 20:11:21 +0000 Subject: [PATCH] TTS: extract status surface --- src/auto-reply/reply/commands-tts.ts | 46 ++-------- src/extension-host/tts-status.test.ts | 117 ++++++++++++++++++++++++++ src/extension-host/tts-status.ts | 108 ++++++++++++++++++++++++ src/gateway/server-methods/tts.ts | 26 +----- src/tts/tts.ts | 29 +++---- 5 files changed, 249 insertions(+), 77 deletions(-) create mode 100644 src/extension-host/tts-status.test.ts create mode 100644 src/extension-host/tts-status.ts diff --git a/src/auto-reply/reply/commands-tts.ts b/src/auto-reply/reply/commands-tts.ts index 6e29026c41b..9c24c3993c5 100644 --- a/src/auto-reply/reply/commands-tts.ts +++ b/src/auto-reply/reply/commands-tts.ts @@ -1,14 +1,12 @@ import { - isExtensionHostTtsProviderConfigured, - resolveExtensionHostTtsApiKey, -} from "../../extension-host/tts-runtime-registry.js"; + formatExtensionHostTtsStatusText, + resolveExtensionHostTtsStatusSnapshot, +} from "../../extension-host/tts-status.js"; import { logVerbose } from "../../globals.js"; import { - getLastTtsAttempt, getTtsMaxLength, getTtsProvider, isSummarizationEnabled, - isTtsEnabled, resolveTtsConfig, resolveTtsPrefsPath, setLastTtsAttempt, @@ -161,18 +159,16 @@ export const handleTtsCommands: CommandHandler = async (params, allowTextCommand if (action === "provider") { const currentProvider = getTtsProvider(config, prefsPath); if (!args.trim()) { - const hasOpenAI = Boolean(resolveExtensionHostTtsApiKey(config, "openai")); - const hasElevenLabs = Boolean(resolveExtensionHostTtsApiKey(config, "elevenlabs")); - const hasEdge = isExtensionHostTtsProviderConfigured(config, "edge"); + const status = resolveExtensionHostTtsStatusSnapshot({ config, prefsPath }); return { shouldContinue: false, reply: { text: `🎙️ TTS provider\n` + `Primary: ${currentProvider}\n` + - `OpenAI key: ${hasOpenAI ? "✅" : "❌"}\n` + - `ElevenLabs key: ${hasElevenLabs ? "✅" : "❌"}\n` + - `Edge enabled: ${hasEdge ? "✅" : "❌"}\n` + + `OpenAI key: ${status.hasOpenAIKey ? "✅" : "❌"}\n` + + `ElevenLabs key: ${status.hasElevenLabsKey ? "✅" : "❌"}\n` + + `Edge enabled: ${status.edgeEnabled ? "✅" : "❌"}\n` + `Usage: /tts provider openai | elevenlabs | edge`, }, }; @@ -249,32 +245,8 @@ export const handleTtsCommands: CommandHandler = async (params, allowTextCommand } if (action === "status") { - const enabled = isTtsEnabled(config, prefsPath); - const provider = getTtsProvider(config, prefsPath); - const hasKey = isExtensionHostTtsProviderConfigured(config, provider); - const maxLength = getTtsMaxLength(prefsPath); - const summarize = isSummarizationEnabled(prefsPath); - const last = getLastTtsAttempt(); - const lines = [ - "📊 TTS status", - `State: ${enabled ? "✅ enabled" : "❌ disabled"}`, - `Provider: ${provider} (${hasKey ? "✅ configured" : "❌ not configured"})`, - `Text limit: ${maxLength} chars`, - `Auto-summary: ${summarize ? "on" : "off"}`, - ]; - if (last) { - const timeAgo = Math.round((Date.now() - last.timestamp) / 1000); - lines.push(""); - lines.push(`Last attempt (${timeAgo}s ago): ${last.success ? "✅" : "❌"}`); - lines.push(`Text: ${last.textLength} chars${last.summarized ? " (summarized)" : ""}`); - if (last.success) { - lines.push(`Provider: ${last.provider ?? "unknown"}`); - lines.push(`Latency: ${last.latencyMs ?? 0}ms`); - } else if (last.error) { - lines.push(`Error: ${last.error}`); - } - } - return { shouldContinue: false, reply: { text: lines.join("\n") } }; + const status = resolveExtensionHostTtsStatusSnapshot({ config, prefsPath }); + return { shouldContinue: false, reply: { text: formatExtensionHostTtsStatusText(status) } }; } return { shouldContinue: false, reply: ttsUsage() }; diff --git a/src/extension-host/tts-status.test.ts b/src/extension-host/tts-status.test.ts new file mode 100644 index 00000000000..6f5f90426e9 --- /dev/null +++ b/src/extension-host/tts-status.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from "vitest"; +import { + formatExtensionHostTtsStatusText, + resolveExtensionHostTtsStatusSnapshot, + setExtensionHostLastTtsAttempt, +} from "./tts-status.js"; + +describe("tts-status", () => { + it("builds a status snapshot from host-owned preferences and runtime state", () => { + const config = { + auto: "always", + provider: "openai", + providerSource: "config", + prefsPath: "/tmp/tts-status.json", + modelOverrides: { + enabled: true, + allowText: true, + allowProvider: false, + allowVoice: true, + allowModelId: true, + allowVoiceSettings: true, + allowNormalization: true, + allowSeed: true, + }, + elevenlabs: { + apiKey: undefined, + baseUrl: "https://api.elevenlabs.io", + voiceId: "voice-id", + modelId: "eleven_multilingual_v2", + voiceSettings: { + stability: 0.5, + similarityBoost: 0.75, + style: 0, + useSpeakerBoost: true, + speed: 1, + }, + }, + openai: { + apiKey: "openai-key", + baseUrl: "https://api.openai.com/v1", + model: "gpt-4o-mini-tts", + voice: "alloy", + }, + edge: { + enabled: true, + voice: "en-US-MichelleNeural", + lang: "en-US", + outputFormat: "audio-24khz-48kbitrate-mono-mp3", + outputFormatConfigured: false, + saveSubtitles: false, + }, + mode: "final", + maxTextLength: 4096, + timeoutMs: 30000, + }; + + const status = resolveExtensionHostTtsStatusSnapshot({ + config, + prefsPath: "/tmp/tts-status.json", + }); + + expect(status).toMatchObject({ + enabled: true, + auto: "always", + provider: "openai", + providerConfigured: true, + hasOpenAIKey: true, + edgeEnabled: true, + maxLength: 1500, + summarize: true, + }); + expect(status.fallbackProviders.length).toBeGreaterThan(0); + expect(status.fallbackProviders).toContain(status.fallbackProvider); + }); + + it("formats the last attempt details in the host-owned status text", () => { + setExtensionHostLastTtsAttempt({ + timestamp: 1000, + success: false, + textLength: 42, + summarized: true, + error: "provider failed", + }); + + const text = formatExtensionHostTtsStatusText( + { + enabled: true, + auto: "always", + provider: "openai", + providerConfigured: true, + fallbackProvider: "edge", + fallbackProviders: ["edge"], + prefsPath: "/tmp/tts-status.json", + maxLength: 1500, + summarize: true, + hasOpenAIKey: true, + hasElevenLabsKey: false, + edgeEnabled: true, + lastAttempt: { + timestamp: 1000, + success: false, + textLength: 42, + summarized: true, + error: "provider failed", + }, + }, + 6000, + ); + + expect(text).toContain("📊 TTS status"); + expect(text).toContain("Last attempt (5s ago): ❌"); + expect(text).toContain("Text: 42 chars (summarized)"); + expect(text).toContain("Error: provider failed"); + + setExtensionHostLastTtsAttempt(undefined); + }); +}); diff --git a/src/extension-host/tts-status.ts b/src/extension-host/tts-status.ts new file mode 100644 index 00000000000..438177764fa --- /dev/null +++ b/src/extension-host/tts-status.ts @@ -0,0 +1,108 @@ +import type { TtsProvider } from "../config/types.tts.js"; +import type { ResolvedTtsConfig } from "../tts/tts.js"; +import { + getExtensionHostTtsMaxLength, + isExtensionHostTtsEnabled, + isExtensionHostTtsSummarizationEnabled, + resolveExtensionHostTtsAutoMode, +} from "./tts-preferences.js"; +import { + isExtensionHostTtsProviderConfigured, + resolveExtensionHostTtsApiKey, + resolveExtensionHostTtsProviderOrder, +} from "./tts-runtime-registry.js"; +import { resolveExtensionHostTtsProvider } from "./tts-runtime-setup.js"; + +export type ExtensionHostTtsStatusEntry = { + timestamp: number; + success: boolean; + textLength: number; + summarized: boolean; + provider?: string; + latencyMs?: number; + error?: string; +}; + +export type ExtensionHostTtsStatusSnapshot = { + enabled: boolean; + auto: ReturnType; + provider: TtsProvider; + providerConfigured: boolean; + fallbackProvider: TtsProvider | null; + fallbackProviders: TtsProvider[]; + prefsPath: string; + maxLength: number; + summarize: boolean; + hasOpenAIKey: boolean; + hasElevenLabsKey: boolean; + edgeEnabled: boolean; + lastAttempt?: ExtensionHostTtsStatusEntry; +}; + +let lastExtensionHostTtsAttempt: ExtensionHostTtsStatusEntry | undefined; + +export function getExtensionHostLastTtsAttempt(): ExtensionHostTtsStatusEntry | undefined { + return lastExtensionHostTtsAttempt; +} + +export function setExtensionHostLastTtsAttempt( + entry: ExtensionHostTtsStatusEntry | undefined, +): void { + lastExtensionHostTtsAttempt = entry; +} + +export function resolveExtensionHostTtsStatusSnapshot(params: { + config: ResolvedTtsConfig; + prefsPath: string; +}): ExtensionHostTtsStatusSnapshot { + const { config, prefsPath } = params; + const provider = resolveExtensionHostTtsProvider(config, prefsPath); + const fallbackProviders = resolveExtensionHostTtsProviderOrder(provider) + .slice(1) + .filter((candidate) => isExtensionHostTtsProviderConfigured(config, candidate)); + return { + enabled: isExtensionHostTtsEnabled(config, prefsPath), + auto: resolveExtensionHostTtsAutoMode({ config, prefsPath }), + provider, + providerConfigured: isExtensionHostTtsProviderConfigured(config, provider), + fallbackProvider: fallbackProviders[0] ?? null, + fallbackProviders, + prefsPath, + maxLength: getExtensionHostTtsMaxLength(prefsPath), + summarize: isExtensionHostTtsSummarizationEnabled(prefsPath), + hasOpenAIKey: Boolean(resolveExtensionHostTtsApiKey(config, "openai")), + hasElevenLabsKey: Boolean(resolveExtensionHostTtsApiKey(config, "elevenlabs")), + edgeEnabled: isExtensionHostTtsProviderConfigured(config, "edge"), + lastAttempt: getExtensionHostLastTtsAttempt(), + }; +} + +export function formatExtensionHostTtsStatusText( + status: ExtensionHostTtsStatusSnapshot, + now = Date.now(), +): string { + const lines = [ + "📊 TTS status", + `State: ${status.enabled ? "✅ enabled" : "❌ disabled"}`, + `Provider: ${status.provider} (${status.providerConfigured ? "✅ configured" : "❌ not configured"})`, + `Text limit: ${status.maxLength} chars`, + `Auto-summary: ${status.summarize ? "on" : "off"}`, + ]; + if (!status.lastAttempt) { + return lines.join("\n"); + } + + const timeAgo = Math.round((now - status.lastAttempt.timestamp) / 1000); + lines.push(""); + lines.push(`Last attempt (${timeAgo}s ago): ${status.lastAttempt.success ? "✅" : "❌"}`); + lines.push( + `Text: ${status.lastAttempt.textLength} chars${status.lastAttempt.summarized ? " (summarized)" : ""}`, + ); + if (status.lastAttempt.success) { + lines.push(`Provider: ${status.lastAttempt.provider ?? "unknown"}`); + lines.push(`Latency: ${status.lastAttempt.latencyMs ?? 0}ms`); + } else if (status.lastAttempt.error) { + lines.push(`Error: ${status.lastAttempt.error}`); + } + return lines.join("\n"); +} diff --git a/src/gateway/server-methods/tts.ts b/src/gateway/server-methods/tts.ts index 5ea7569516d..dbb711b1183 100644 --- a/src/gateway/server-methods/tts.ts +++ b/src/gateway/server-methods/tts.ts @@ -1,15 +1,10 @@ import { loadConfig } from "../../config/config.js"; -import { - isExtensionHostTtsProviderConfigured, - resolveExtensionHostTtsApiKey, - resolveExtensionHostTtsProviderOrder, -} from "../../extension-host/tts-runtime-registry.js"; +import { isExtensionHostTtsProviderConfigured } from "../../extension-host/tts-runtime-registry.js"; +import { resolveExtensionHostTtsStatusSnapshot } from "../../extension-host/tts-status.js"; import { OPENAI_TTS_MODELS, OPENAI_TTS_VOICES, getTtsProvider, - isTtsEnabled, - resolveTtsAutoMode, resolveTtsConfig, resolveTtsPrefsPath, setTtsEnabled, @@ -26,22 +21,7 @@ export const ttsHandlers: GatewayRequestHandlers = { const cfg = loadConfig(); const config = resolveTtsConfig(cfg); const prefsPath = resolveTtsPrefsPath(config); - const provider = getTtsProvider(config, prefsPath); - const autoMode = resolveTtsAutoMode({ config, prefsPath }); - const fallbackProviders = resolveExtensionHostTtsProviderOrder(provider) - .slice(1) - .filter((candidate) => isExtensionHostTtsProviderConfigured(config, candidate)); - respond(true, { - enabled: isTtsEnabled(config, prefsPath), - auto: autoMode, - provider, - fallbackProvider: fallbackProviders[0] ?? null, - fallbackProviders, - prefsPath, - hasOpenAIKey: Boolean(resolveExtensionHostTtsApiKey(config, "openai")), - hasElevenLabsKey: Boolean(resolveExtensionHostTtsApiKey(config, "elevenlabs")), - edgeEnabled: isExtensionHostTtsProviderConfigured(config, "edge"), - }); + respond(true, resolveExtensionHostTtsStatusSnapshot({ config, prefsPath })); } catch (err) { respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err))); } diff --git a/src/tts/tts.ts b/src/tts/tts.ts index 5a12e94880f..6737cb4ee04 100644 --- a/src/tts/tts.ts +++ b/src/tts/tts.ts @@ -39,6 +39,11 @@ import { resolveExtensionHostTtsProvider, resolveExtensionHostTtsRequestSetup, } from "../extension-host/tts-runtime-setup.js"; +import { + getExtensionHostLastTtsAttempt, + setExtensionHostLastTtsAttempt, + type ExtensionHostTtsStatusEntry, +} from "../extension-host/tts-status.js"; import { logVerbose } from "../globals.js"; import { DEFAULT_OPENAI_BASE_URL, @@ -178,17 +183,7 @@ export type TtsTelephonyResult = { sampleRate?: number; }; -type TtsStatusEntry = { - timestamp: number; - success: boolean; - textLength: number; - summarized: boolean; - provider?: string; - latencyMs?: number; - error?: string; -}; - -let lastTtsAttempt: TtsStatusEntry | undefined; +type TtsStatusEntry = ExtensionHostTtsStatusEntry; export const normalizeTtsAutoMode = normalizeExtensionHostTtsAutoMode; @@ -341,11 +336,11 @@ export const isSummarizationEnabled = isExtensionHostTtsSummarizationEnabled; export const setSummarizationEnabled = setExtensionHostTtsSummarizationEnabled; export function getLastTtsAttempt(): TtsStatusEntry | undefined { - return lastTtsAttempt; + return getExtensionHostLastTtsAttempt(); } export function setLastTtsAttempt(entry: TtsStatusEntry | undefined): void { - lastTtsAttempt = entry; + setExtensionHostLastTtsAttempt(entry); } export const TTS_PROVIDERS = EXTENSION_HOST_TTS_PROVIDER_IDS; @@ -440,14 +435,14 @@ export async function maybeApplyTtsToPayload(params: { }); if (result.success && result.audioPath) { - lastTtsAttempt = { + setExtensionHostLastTtsAttempt({ timestamp: Date.now(), success: true, textLength: (params.payload.text ?? "").length, summarized: plan.wasSummarized, provider: result.provider, latencyMs: result.latencyMs, - }; + }); const shouldVoice = isExtensionHostTtsVoiceBubbleChannel(params.channel) && result.voiceCompatible === true; @@ -459,13 +454,13 @@ export async function maybeApplyTtsToPayload(params: { return finalPayload; } - lastTtsAttempt = { + setExtensionHostLastTtsAttempt({ timestamp: Date.now(), success: false, textLength: (params.payload.text ?? "").length, summarized: plan.wasSummarized, error: result.error, - }; + }); const latency = Date.now() - ttsStart; logVerbose(`TTS: conversion failed after ${latency}ms (${result.error ?? "unknown"}).`);