TTS: extract status surface

This commit is contained in:
Gustavo Madeira Santana 2026-03-15 20:11:21 +00:00
parent 2c32b8834d
commit d047f604d3
5 changed files with 249 additions and 77 deletions

View File

@ -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() };

View File

@ -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);
});
});

View File

@ -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<typeof resolveExtensionHostTtsAutoMode>;
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");
}

View File

@ -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)));
}

View File

@ -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"}).`);