Refactor speech provider selection model and harden CI startup

This commit is contained in:
Mohamed Boudra
2026-02-06 22:48:52 +07:00
parent cdb7ef12ae
commit bc4e2daf67
16 changed files with 368 additions and 175 deletions
@@ -13,9 +13,16 @@ describe("paseo daemon bootstrap", () => {
const daemonHandle = await createTestPaseoDaemon({ const daemonHandle = await createTestPaseoDaemon({
openai: { apiKey: "test-openai-api-key" }, openai: { apiKey: "test-openai-api-key" },
speech: { speech: {
dictationSttProvider: "openai", providers: {
voiceSttProvider: "openai", dictationStt: { provider: "openai", explicit: true },
voiceTtsProvider: "openai", voiceStt: { provider: "openai", explicit: true },
voiceTts: { provider: "openai", explicit: true },
},
localModels: {
dictationStt: "parakeet-tdt-0.6b-v3-int8",
voiceStt: "parakeet-tdt-0.6b-v3-int8",
voiceTts: "pocket-tts-onnx-int8",
},
}, },
}); });
try { try {
@@ -56,9 +63,16 @@ describe("paseo daemon bootstrap", () => {
appBaseUrl: "https://app.paseo.sh", appBaseUrl: "https://app.paseo.sh",
openai: undefined, openai: undefined,
speech: { speech: {
dictationSttProvider: "openai", providers: {
voiceSttProvider: "openai", dictationStt: { provider: "openai", explicit: true },
voiceTtsProvider: "openai", voiceStt: { provider: "openai", explicit: true },
voiceTts: { provider: "openai", explicit: true },
},
localModels: {
dictationStt: "parakeet-tdt-0.6b-v3-int8",
voiceStt: "parakeet-tdt-0.6b-v3-int8",
voiceTts: "pocket-tts-onnx-int8",
},
}, },
}; };
+34 -24
View File
@@ -43,6 +43,7 @@ import { VoiceAssistantWebSocketServer } from "./websocket-server.js";
import { DownloadTokenStore } from "./file-download/token-store.js"; import { DownloadTokenStore } from "./file-download/token-store.js";
import type { OpenAiSpeechProviderConfig } from "./speech/providers/openai/config.js"; import type { OpenAiSpeechProviderConfig } from "./speech/providers/openai/config.js";
import type { LocalSpeechProviderConfig } from "./speech/providers/local/config.js"; import type { LocalSpeechProviderConfig } from "./speech/providers/local/config.js";
import type { RequestedSpeechProviders } from "./speech/speech-types.js";
import { initializeSpeechRuntime } from "./speech/speech-runtime.js"; import { initializeSpeechRuntime } from "./speech/speech-runtime.js";
import { AgentManager } from "./agent/agent-manager.js"; import { AgentManager } from "./agent/agent-manager.js";
import { AgentStorage } from "./agent/agent-storage.js"; import { AgentStorage } from "./agent/agent-storage.js";
@@ -105,15 +106,15 @@ export type PaseoOpenAIConfig = OpenAiSpeechProviderConfig;
export type PaseoLocalSpeechConfig = LocalSpeechProviderConfig; export type PaseoLocalSpeechConfig = LocalSpeechProviderConfig;
export type PaseoSpeechConfig = { export type PaseoSpeechConfig = {
dictationSttProvider?: "openai" | "local"; providers: RequestedSpeechProviders;
voiceSttProvider?: "openai" | "local";
voiceTtsProvider?: "openai" | "local";
local?: PaseoLocalSpeechConfig; local?: PaseoLocalSpeechConfig;
dictationLocalSttModel?: string; localModels: {
voiceLocalSttModel?: string; dictationStt: string;
voiceLocalTtsModel?: string; voiceStt: string;
voiceLocalTtsSpeakerId?: number; voiceTts: string;
voiceLocalTtsSpeed?: number; voiceTtsSpeakerId?: number;
voiceTtsSpeed?: number;
};
}; };
export type PaseoDaemonConfig = { export type PaseoDaemonConfig = {
@@ -347,25 +348,34 @@ export async function createPaseoDaemon(
voiceEnabledProviders.find((provider) => voiceLlmAvailability[provider]) ?? null; voiceEnabledProviders.find((provider) => voiceLlmAvailability[provider]) ?? null;
} }
if (!resolvedVoiceLlmProvider) { let resolvedVoiceLlmModeId: string | null = null;
logger.error( let resolvedVoiceLlmModel: string | null = null;
{ requestedVoiceLlmProvider, voiceLlmAvailability },
"No voice LLM provider available"
);
throw new Error("No voice LLM provider available");
}
const resolvedVoiceProviderDefinition = AGENT_PROVIDER_DEFINITIONS.find( if (!resolvedVoiceLlmProvider) {
(definition) => definition.id === resolvedVoiceLlmProvider if (voiceLlmProviderExplicit) {
); logger.error(
if (!resolvedVoiceProviderDefinition?.voice?.enabled) { { requestedVoiceLlmProvider, voiceLlmAvailability },
throw new Error( "No voice LLM provider available"
`Provider '${resolvedVoiceLlmProvider}' is missing voice metadata in agent registry` );
throw new Error("No voice LLM provider available");
}
logger.warn(
{ requestedVoiceLlmProvider, voiceLlmAvailability },
"No default voice LLM provider available; voice mode will be disabled until a provider is configured"
); );
} else {
const resolvedVoiceProviderDefinition = AGENT_PROVIDER_DEFINITIONS.find(
(definition) => definition.id === resolvedVoiceLlmProvider
);
if (!resolvedVoiceProviderDefinition?.voice?.enabled) {
throw new Error(
`Provider '${resolvedVoiceLlmProvider}' is missing voice metadata in agent registry`
);
}
resolvedVoiceLlmModeId = resolvedVoiceProviderDefinition.voice.defaultModeId;
resolvedVoiceLlmModel =
config.voiceLlmModel ?? resolvedVoiceProviderDefinition.voice.defaultModel ?? null;
} }
const resolvedVoiceLlmModeId = resolvedVoiceProviderDefinition.voice.defaultModeId;
const resolvedVoiceLlmModel =
config.voiceLlmModel ?? resolvedVoiceProviderDefinition.voice.defaultModel ?? null;
logger.info( logger.info(
{ {
@@ -93,33 +93,47 @@ describe("daemon client E2E", () => {
let ctx: DaemonTestContext; let ctx: DaemonTestContext;
beforeAll(async () => { beforeAll(async () => {
const speechConfig = openaiApiKey const speechConfig =
? { openaiApiKey
dictationSttProvider: "openai" as const, ? {
voiceSttProvider: "openai" as const, providers: {
voiceTtsProvider: "openai" as const, dictationStt: { provider: "openai" as const, explicit: true },
} voiceStt: { provider: "openai" as const, explicit: true },
: { voiceTts: { provider: "openai" as const, explicit: true },
dictationSttProvider: "local" as const, },
voiceSttProvider: "local" as const, localModels: {
voiceTtsProvider: "local" as const, dictationStt: "parakeet-tdt-0.6b-v3-int8",
local: { voiceStt: "parakeet-tdt-0.6b-v3-int8",
modelsDir: localModelsDir, voiceTts: "pocket-tts-onnx-int8",
}, },
dictationLocalSttModel: }
process.env.PASEO_DICTATION_LOCAL_STT_MODEL ?? : hasLocalSpeech
"zipformer-bilingual-zh-en-2023-02-20", ? {
voiceLocalSttModel: providers: {
process.env.PASEO_VOICE_LOCAL_STT_MODEL ?? dictationStt: { provider: "local" as const, explicit: true },
"zipformer-bilingual-zh-en-2023-02-20", voiceStt: { provider: "local" as const, explicit: true },
voiceLocalTtsModel: voiceTts: { provider: "local" as const, explicit: true },
process.env.PASEO_VOICE_LOCAL_TTS_MODEL ?? "kitten-nano-en-v0_1-fp16", },
}; local: {
modelsDir: localModelsDir,
},
localModels: {
dictationStt:
process.env.PASEO_DICTATION_LOCAL_STT_MODEL ??
"zipformer-bilingual-zh-en-2023-02-20",
voiceStt:
process.env.PASEO_VOICE_LOCAL_STT_MODEL ??
"zipformer-bilingual-zh-en-2023-02-20",
voiceTts:
process.env.PASEO_VOICE_LOCAL_TTS_MODEL ?? "kitten-nano-en-v0_1-fp16",
},
}
: undefined;
ctx = await createDaemonTestContext({ ctx = await createDaemonTestContext({
dictationFinalTimeoutMs: 5000, dictationFinalTimeoutMs: 5000,
...(openaiApiKey ? { openai: { apiKey: openaiApiKey } } : {}), ...(openaiApiKey ? { openai: { apiKey: openaiApiKey } } : {}),
speech: speechConfig, ...(speechConfig ? { speech: speechConfig } : {}),
}); });
}, 60000); }, 60000);
@@ -186,6 +186,7 @@ describe("ConnectionOfferV2 (daemon E2E)", () => {
PASEO_HOME: tempHome, PASEO_HOME: tempHome,
PASEO_LISTEN: `0.0.0.0:${port}`, PASEO_LISTEN: `0.0.0.0:${port}`,
OPENAI_API_KEY: "", OPENAI_API_KEY: "",
PASEO_LOCAL_AUTO_DOWNLOAD: "0",
PASEO_LOG_FORMAT: "json", PASEO_LOG_FORMAT: "json",
}; };
@@ -32,15 +32,11 @@ const ProvidersSchema = z
}) })
.strict(); .strict();
const SpeechProviderIdSchema = z.preprocess( const SpeechProviderIdSchema = z
(value) => { .string()
if (typeof value !== "string") { .trim()
return value; .toLowerCase()
} .pipe(z.enum(["openai", "local"]));
return value.trim().toLowerCase();
},
z.enum(["openai", "local"])
);
const FeatureDictationSchema = z const FeatureDictationSchema = z
.object({ .object({
@@ -68,7 +68,7 @@ const LocalSpeechResolutionSchema = z.object({
}); });
function persistedLocalFeatureModel( function persistedLocalFeatureModel(
provider: RequestedSpeechProviders[keyof RequestedSpeechProviders], provider: RequestedSpeechProviders[keyof RequestedSpeechProviders]["provider"],
model: string | undefined model: string | undefined
): string | undefined { ): string | undefined {
if (provider !== "local") { if (provider !== "local") {
@@ -83,9 +83,9 @@ function shouldIncludeLocalProviderConfig(params: {
persisted: PersistedConfig; persisted: PersistedConfig;
}): boolean { }): boolean {
const localRequestedByFeature = const localRequestedByFeature =
params.providers.dictationSttProvider === "local" || params.providers.dictationStt.provider === "local" ||
params.providers.voiceSttProvider === "local" || params.providers.voiceStt.provider === "local" ||
params.providers.voiceTtsProvider === "local"; params.providers.voiceTts.provider === "local";
return ( return (
localRequestedByFeature || localRequestedByFeature ||
@@ -114,21 +114,21 @@ export function resolveLocalSpeechConfig(params: {
dictationLocalSttModel: dictationLocalSttModel:
params.env.PASEO_DICTATION_LOCAL_STT_MODEL ?? params.env.PASEO_DICTATION_LOCAL_STT_MODEL ??
persistedLocalFeatureModel( persistedLocalFeatureModel(
params.providers.dictationSttProvider, params.providers.dictationStt.provider,
params.persisted.features?.dictation?.stt?.model params.persisted.features?.dictation?.stt?.model
) ?? ) ??
DEFAULT_LOCAL_STT_MODEL, DEFAULT_LOCAL_STT_MODEL,
voiceLocalSttModel: voiceLocalSttModel:
params.env.PASEO_VOICE_LOCAL_STT_MODEL ?? params.env.PASEO_VOICE_LOCAL_STT_MODEL ??
persistedLocalFeatureModel( persistedLocalFeatureModel(
params.providers.voiceSttProvider, params.providers.voiceStt.provider,
params.persisted.features?.voiceMode?.stt?.model params.persisted.features?.voiceMode?.stt?.model
) ?? ) ??
DEFAULT_LOCAL_STT_MODEL, DEFAULT_LOCAL_STT_MODEL,
voiceLocalTtsModel: voiceLocalTtsModel:
params.env.PASEO_VOICE_LOCAL_TTS_MODEL ?? params.env.PASEO_VOICE_LOCAL_TTS_MODEL ??
persistedLocalFeatureModel( persistedLocalFeatureModel(
params.providers.voiceTtsProvider, params.providers.voiceTts.provider,
params.persisted.features?.voiceMode?.tts?.model params.persisted.features?.voiceMode?.tts?.model
) ?? ) ??
DEFAULT_LOCAL_TTS_MODEL, DEFAULT_LOCAL_TTS_MODEL,
@@ -63,13 +63,13 @@ function resolveConfiguredLocalModels(
): ResolvedLocalModels { ): ResolvedLocalModels {
return { return {
dictationLocalSttModel: LocalSttModelIdSchema.parse( dictationLocalSttModel: LocalSttModelIdSchema.parse(
speechConfig?.dictationLocalSttModel ?? DEFAULT_LOCAL_STT_MODEL speechConfig?.localModels.dictationStt ?? DEFAULT_LOCAL_STT_MODEL
), ),
voiceLocalSttModel: LocalSttModelIdSchema.parse( voiceLocalSttModel: LocalSttModelIdSchema.parse(
speechConfig?.voiceLocalSttModel ?? DEFAULT_LOCAL_STT_MODEL speechConfig?.localModels.voiceStt ?? DEFAULT_LOCAL_STT_MODEL
), ),
voiceLocalTtsModel: LocalTtsModelIdSchema.parse( voiceLocalTtsModel: LocalTtsModelIdSchema.parse(
speechConfig?.voiceLocalTtsModel ?? DEFAULT_LOCAL_TTS_MODEL speechConfig?.localModels.voiceTts ?? DEFAULT_LOCAL_TTS_MODEL
), ),
}; };
} }
@@ -90,13 +90,13 @@ function computeRequiredLocalModelIds(params: {
models: ResolvedLocalModels; models: ResolvedLocalModels;
}): LocalSpeechModelId[] { }): LocalSpeechModelId[] {
const ids = new Set<LocalSpeechModelId>(); const ids = new Set<LocalSpeechModelId>();
if (params.providers.dictationSttProvider === "local") { if (params.providers.dictationStt.provider === "local") {
ids.add(params.models.dictationLocalSttModel); ids.add(params.models.dictationLocalSttModel);
} }
if (params.providers.voiceSttProvider === "local") { if (params.providers.voiceStt.provider === "local") {
ids.add(params.models.voiceLocalSttModel); ids.add(params.models.voiceLocalSttModel);
} }
if (params.providers.voiceTtsProvider === "local") { if (params.providers.voiceTts.provider === "local") {
ids.add(params.models.voiceLocalTtsModel); ids.add(params.models.voiceLocalTtsModel);
} }
return Array.from(ids); return Array.from(ids);
@@ -260,7 +260,7 @@ export async function initializeLocalSpeechServices(params: {
} }
}; };
if (providers.voiceSttProvider === "local") { if (providers.voiceStt.provider === "local") {
if (!localConfig) { if (!localConfig) {
logger.warn( logger.warn(
{ configured: false }, { configured: false },
@@ -276,7 +276,7 @@ export async function initializeLocalSpeechServices(params: {
} }
} }
if (providers.dictationSttProvider === "local") { if (providers.dictationStt.provider === "local") {
if (!localConfig) { if (!localConfig) {
logger.warn( logger.warn(
{ configured: false }, { configured: false },
@@ -299,7 +299,7 @@ export async function initializeLocalSpeechServices(params: {
} }
} }
if (providers.voiceTtsProvider === "local") { if (providers.voiceTts.provider === "local") {
if (!localConfig) { if (!localConfig) {
logger.warn( logger.warn(
{ configured: false }, { configured: false },
@@ -323,8 +323,8 @@ export async function initializeLocalSpeechServices(params: {
{ {
preset: localModels.voiceLocalTtsModel, preset: localModels.voiceLocalTtsModel,
modelDir, modelDir,
speakerId: speechConfig?.voiceLocalTtsSpeakerId, speakerId: speechConfig?.localModels.voiceTtsSpeakerId,
speed: speechConfig?.voiceLocalTtsSpeed, speed: speechConfig?.localModels.voiceTtsSpeed,
}, },
logger logger
); );
@@ -115,23 +115,27 @@ describe("speech models (download E2E)", () => {
paseoHomeRoot, paseoHomeRoot,
dictationFinalTimeoutMs: 8000, dictationFinalTimeoutMs: 8000,
speech: { speech: {
dictationSttProvider: "local", providers: {
voiceSttProvider: "local", dictationStt: { provider: "local", explicit: true },
voiceTtsProvider: "local", voiceStt: { provider: "local", explicit: true },
voiceTts: { provider: "local", explicit: true },
},
local: { local: {
modelsDir, modelsDir,
autoDownload: false, autoDownload: false,
}, },
dictationLocalSttModel: localModels: {
set === "parakeet-pocket" dictationStt:
? "parakeet-tdt-0.6b-v3-int8" set === "parakeet-pocket"
: "zipformer-bilingual-zh-en-2023-02-20", ? "parakeet-tdt-0.6b-v3-int8"
voiceLocalSttModel: : "zipformer-bilingual-zh-en-2023-02-20",
set === "parakeet-pocket" voiceStt:
? "parakeet-tdt-0.6b-v3-int8" set === "parakeet-pocket"
: "zipformer-bilingual-zh-en-2023-02-20", ? "parakeet-tdt-0.6b-v3-int8"
voiceLocalTtsModel: : "zipformer-bilingual-zh-en-2023-02-20",
set === "parakeet-pocket" ? "pocket-tts-onnx-int8" : "kitten-nano-en-v0_1-fp16", voiceTts:
set === "parakeet-pocket" ? "pocket-tts-onnx-int8" : "kitten-nano-en-v0_1-fp16",
},
}, },
}); });
@@ -0,0 +1,46 @@
import { describe, expect, test } from "vitest";
import { PersistedConfigSchema } from "../../../persisted-config.js";
import { resolveOpenAiSpeechConfig } from "./config.js";
describe("resolveOpenAiSpeechConfig", () => {
test("treats empty OPENAI_API_KEY as unset", () => {
const persisted = PersistedConfigSchema.parse({});
const env = {
OPENAI_API_KEY: "",
} as NodeJS.ProcessEnv;
const resolved = resolveOpenAiSpeechConfig({
env,
persisted,
providers: {
dictationStt: { provider: "local", explicit: false },
voiceStt: { provider: "local", explicit: false },
voiceTts: { provider: "local", explicit: false },
},
});
expect(resolved).toBeUndefined();
});
test("uses trimmed OPENAI_API_KEY when configured", () => {
const persisted = PersistedConfigSchema.parse({});
const env = {
OPENAI_API_KEY: " sk-test ",
} as NodeJS.ProcessEnv;
const resolved = resolveOpenAiSpeechConfig({
env,
persisted,
providers: {
dictationStt: { provider: "openai", explicit: true },
voiceStt: { provider: "openai", explicit: true },
voiceTts: { provider: "openai", explicit: true },
},
});
expect(resolved?.apiKey).toBe("sk-test");
expect(resolved?.stt?.apiKey).toBe("sk-test");
expect(resolved?.tts?.apiKey).toBe("sk-test");
});
});
@@ -35,7 +35,11 @@ const OptionalFiniteNumberSchema = NumberLikeSchema
.pipe(z.coerce.number().finite()) .pipe(z.coerce.number().finite())
.optional(); .optional();
const OptionalTrimmedStringSchema = z.string().trim().min(1).optional(); const OptionalTrimmedStringSchema = z
.string()
.trim()
.optional()
.transform((value) => (value && value.length > 0 ? value : undefined));
const OpenAiSpeechResolutionSchema = z.object({ const OpenAiSpeechResolutionSchema = z.object({
apiKey: OptionalTrimmedStringSchema, apiKey: OptionalTrimmedStringSchema,
@@ -70,27 +74,27 @@ export function resolveOpenAiSpeechConfig(params: {
params.persisted.features?.dictation?.stt?.confidenceThreshold, params.persisted.features?.dictation?.stt?.confidenceThreshold,
sttModel: sttModel:
params.env.STT_MODEL ?? params.env.STT_MODEL ??
(params.providers.voiceSttProvider === "openai" (params.providers.voiceStt.provider === "openai"
? params.persisted.features?.voiceMode?.stt?.model ? params.persisted.features?.voiceMode?.stt?.model
: undefined) ?? : undefined) ??
(params.providers.dictationSttProvider === "openai" (params.providers.dictationStt.provider === "openai"
? params.persisted.features?.dictation?.stt?.model ? params.persisted.features?.dictation?.stt?.model
: undefined), : undefined),
ttsVoice: ttsVoice:
params.env.TTS_VOICE ?? params.env.TTS_VOICE ??
(params.providers.voiceTtsProvider === "openai" (params.providers.voiceTts.provider === "openai"
? params.persisted.features?.voiceMode?.tts?.voice ? params.persisted.features?.voiceMode?.tts?.voice
: undefined) ?? : undefined) ??
"alloy", "alloy",
ttsModel: ttsModel:
params.env.TTS_MODEL ?? params.env.TTS_MODEL ??
(params.providers.voiceTtsProvider === "openai" (params.providers.voiceTts.provider === "openai"
? params.persisted.features?.voiceMode?.tts?.model ? params.persisted.features?.voiceMode?.tts?.model
: undefined) ?? : undefined) ??
DEFAULT_OPENAI_TTS_MODEL, DEFAULT_OPENAI_TTS_MODEL,
realtimeTranscriptionModel: realtimeTranscriptionModel:
params.env.OPENAI_REALTIME_TRANSCRIPTION_MODEL ?? params.env.OPENAI_REALTIME_TRANSCRIPTION_MODEL ??
(params.providers.dictationSttProvider === "openai" (params.providers.dictationStt.provider === "openai"
? params.persisted.features?.dictation?.stt?.model ? params.persisted.features?.dictation?.stt?.model
: undefined) ?? : undefined) ??
DEFAULT_OPENAI_REALTIME_TRANSCRIPTION_MODEL, DEFAULT_OPENAI_REALTIME_TRANSCRIPTION_MODEL,
@@ -60,13 +60,16 @@ export function validateOpenAiCredentialRequirements(params: {
const openAiCredentials = resolveOpenAiCredentials(openaiConfig); const openAiCredentials = resolveOpenAiCredentials(openaiConfig);
const missingOpenAiCredentialsFor: string[] = []; const missingOpenAiCredentialsFor: string[] = [];
if (providers.voiceSttProvider === "openai" && !openAiCredentials.openaiSttApiKey) { if (providers.voiceStt.provider === "openai" && !openAiCredentials.openaiSttApiKey) {
missingOpenAiCredentialsFor.push("voice.stt"); missingOpenAiCredentialsFor.push("voice.stt");
} }
if (providers.voiceTtsProvider === "openai" && !openAiCredentials.openaiTtsApiKey) { if (providers.voiceTts.provider === "openai" && !openAiCredentials.openaiTtsApiKey) {
missingOpenAiCredentialsFor.push("voice.tts"); missingOpenAiCredentialsFor.push("voice.tts");
} }
if (providers.dictationSttProvider === "openai" && !openAiCredentials.openaiDictationApiKey) { if (
providers.dictationStt.provider === "openai" &&
!openAiCredentials.openaiDictationApiKey
) {
missingOpenAiCredentialsFor.push("dictation.stt"); missingOpenAiCredentialsFor.push("dictation.stt");
} }
@@ -74,9 +77,9 @@ export function validateOpenAiCredentialRequirements(params: {
logger.error( logger.error(
{ {
requestedProviders: { requestedProviders: {
dictationStt: providers.dictationSttProvider, dictationStt: providers.dictationStt.provider,
voiceStt: providers.voiceSttProvider, voiceStt: providers.voiceStt.provider,
voiceTts: providers.voiceTtsProvider, voiceTts: providers.voiceTts.provider,
}, },
missingOpenAiCredentialsFor, missingOpenAiCredentialsFor,
}, },
@@ -101,9 +104,10 @@ export function initializeOpenAiSpeechServices(params: {
let ttsService = existing.ttsService; let ttsService = existing.ttsService;
let dictationSttService = existing.dictationSttService; let dictationSttService = existing.dictationSttService;
const needsOpenAiStt = !sttService && providers.voiceSttProvider === "openai"; const needsOpenAiStt = !sttService && providers.voiceStt.provider === "openai";
const needsOpenAiTts = !ttsService && providers.voiceTtsProvider === "openai"; const needsOpenAiTts = !ttsService && providers.voiceTts.provider === "openai";
const needsOpenAiDictation = !dictationSttService && providers.dictationSttProvider === "openai"; const needsOpenAiDictation =
!dictationSttService && providers.dictationStt.provider === "openai";
if ( if (
(needsOpenAiStt || needsOpenAiTts || needsOpenAiDictation) && (needsOpenAiStt || needsOpenAiTts || needsOpenAiDictation) &&
@@ -18,16 +18,25 @@ describe("resolveSpeechConfig", () => {
}); });
expect(result.openai).toBeUndefined(); expect(result.openai).toBeUndefined();
expect(result.speech.dictationSttProvider).toBe("local"); expect(result.speech.providers.dictationStt).toEqual({
expect(result.speech.voiceSttProvider).toBe("local"); provider: "local",
expect(result.speech.voiceTtsProvider).toBe("local"); explicit: false,
});
expect(result.speech.providers.voiceStt).toEqual({
provider: "local",
explicit: false,
});
expect(result.speech.providers.voiceTts).toEqual({
provider: "local",
explicit: false,
});
expect(result.speech.local).toEqual({ expect(result.speech.local).toEqual({
modelsDir: path.join(paseoHome, "models", "local-speech"), modelsDir: path.join(paseoHome, "models", "local-speech"),
autoDownload: true, autoDownload: true,
}); });
expect(result.speech.dictationLocalSttModel).toBe("parakeet-tdt-0.6b-v3-int8"); expect(result.speech.localModels.dictationStt).toBe("parakeet-tdt-0.6b-v3-int8");
expect(result.speech.voiceLocalSttModel).toBe("parakeet-tdt-0.6b-v3-int8"); expect(result.speech.localModels.voiceStt).toBe("parakeet-tdt-0.6b-v3-int8");
expect(result.speech.voiceLocalTtsModel).toBe("pocket-tts-onnx-int8"); expect(result.speech.localModels.voiceTts).toBe("pocket-tts-onnx-int8");
}); });
test("resolves feature-scoped local model env vars", () => { test("resolves feature-scoped local model env vars", () => {
@@ -65,11 +74,23 @@ describe("resolveSpeechConfig", () => {
modelsDir: "/tmp/models", modelsDir: "/tmp/models",
autoDownload: false, autoDownload: false,
}); });
expect(result.speech.dictationLocalSttModel).toBe("zipformer-bilingual-zh-en-2023-02-20"); expect(result.speech.providers.dictationStt).toEqual({
expect(result.speech.voiceLocalSttModel).toBe("parakeet-tdt-0.6b-v3-int8"); provider: "local",
expect(result.speech.voiceLocalTtsModel).toBe("kitten-nano-en-v0_1-fp16"); explicit: true,
expect(result.speech.voiceLocalTtsSpeakerId).toBe(5); });
expect(result.speech.voiceLocalTtsSpeed).toBe(1.35); expect(result.speech.providers.voiceStt).toEqual({
provider: "openai",
explicit: true,
});
expect(result.speech.providers.voiceTts).toEqual({
provider: "local",
explicit: true,
});
expect(result.speech.localModels.dictationStt).toBe("zipformer-bilingual-zh-en-2023-02-20");
expect(result.speech.localModels.voiceStt).toBe("parakeet-tdt-0.6b-v3-int8");
expect(result.speech.localModels.voiceTts).toBe("kitten-nano-en-v0_1-fp16");
expect(result.speech.localModels.voiceTtsSpeakerId).toBe(5);
expect(result.speech.localModels.voiceTtsSpeed).toBe(1.35);
expect(result.openai?.apiKey).toBe("env-key"); expect(result.openai?.apiKey).toBe("env-key");
expect(result.openai?.stt?.model).toBe("gpt-4o-transcribe"); expect(result.openai?.stt?.model).toBe("gpt-4o-transcribe");
}); });
@@ -87,8 +108,8 @@ describe("resolveSpeechConfig", () => {
persisted, persisted,
}); });
expect(result.speech.dictationLocalSttModel).toBe("parakeet-tdt-0.6b-v3-int8"); expect(result.speech.localModels.dictationStt).toBe("parakeet-tdt-0.6b-v3-int8");
expect(result.speech.voiceLocalSttModel).toBe("parakeet-tdt-0.6b-v3-int8"); expect(result.speech.localModels.voiceStt).toBe("parakeet-tdt-0.6b-v3-int8");
expect(result.speech.voiceLocalTtsModel).toBe("pocket-tts-onnx-int8"); expect(result.speech.localModels.voiceTts).toBe("pocket-tts-onnx-int8");
}); });
}); });
@@ -6,6 +6,7 @@ import { resolveLocalSpeechConfig } from "./providers/local/config.js";
import { resolveOpenAiSpeechConfig } from "./providers/openai/config.js"; import { resolveOpenAiSpeechConfig } from "./providers/openai/config.js";
import { import {
SpeechProviderIdSchema, SpeechProviderIdSchema,
type RequestedSpeechProvider,
type RequestedSpeechProviders, type RequestedSpeechProviders,
} from "./speech-types.js"; } from "./speech-types.js";
@@ -17,29 +18,53 @@ const OptionalSpeechProviderSchema = z
.optional(); .optional();
const RequestedSpeechProvidersSchema = z.object({ const RequestedSpeechProvidersSchema = z.object({
dictationSttProvider: OptionalSpeechProviderSchema.default("local"), dictationStt: OptionalSpeechProviderSchema.default("local"),
voiceSttProvider: OptionalSpeechProviderSchema.default("local"), voiceStt: OptionalSpeechProviderSchema.default("local"),
voiceTtsProvider: OptionalSpeechProviderSchema.default("local"), voiceTts: OptionalSpeechProviderSchema.default("local"),
}); });
function resolveRequestedSpeechProviders(params: { function resolveRequestedSpeechProviders(params: {
env: NodeJS.ProcessEnv; env: NodeJS.ProcessEnv;
persisted: PersistedConfig; persisted: PersistedConfig;
}): RequestedSpeechProviders { }): RequestedSpeechProviders {
return RequestedSpeechProvidersSchema.parse({ const resolveFeatureProvider = (
dictationSttProvider: configuredValue: string | undefined,
params.env.PASEO_DICTATION_STT_PROVIDER ?? parsedValue: z.infer<typeof SpeechProviderIdSchema>
params.persisted.features?.dictation?.stt?.provider ?? ): RequestedSpeechProvider => ({
"local", provider: parsedValue,
voiceSttProvider: explicit: configuredValue !== undefined,
params.env.PASEO_VOICE_STT_PROVIDER ??
params.persisted.features?.voiceMode?.stt?.provider ??
"local",
voiceTtsProvider:
params.env.PASEO_VOICE_TTS_PROVIDER ??
params.persisted.features?.voiceMode?.tts?.provider ??
"local",
}); });
const dictationSttProviderFromConfig =
params.env.PASEO_DICTATION_STT_PROVIDER ??
params.persisted.features?.dictation?.stt?.provider;
const voiceSttProviderFromConfig =
params.env.PASEO_VOICE_STT_PROVIDER ??
params.persisted.features?.voiceMode?.stt?.provider;
const voiceTtsProviderFromConfig =
params.env.PASEO_VOICE_TTS_PROVIDER ??
params.persisted.features?.voiceMode?.tts?.provider;
const parsed = RequestedSpeechProvidersSchema.parse({
dictationStt: dictationSttProviderFromConfig ?? "local",
voiceStt: voiceSttProviderFromConfig ?? "local",
voiceTts: voiceTtsProviderFromConfig ?? "local",
});
return {
dictationStt: resolveFeatureProvider(
dictationSttProviderFromConfig,
parsed.dictationStt
),
voiceStt: resolveFeatureProvider(
voiceSttProviderFromConfig,
parsed.voiceStt
),
voiceTts: resolveFeatureProvider(
voiceTtsProviderFromConfig,
parsed.voiceTts
),
};
} }
export function resolveSpeechConfig(params: { export function resolveSpeechConfig(params: {
@@ -71,21 +96,21 @@ export function resolveSpeechConfig(params: {
return { return {
openai, openai,
speech: { speech: {
dictationSttProvider: providers.dictationSttProvider, providers,
voiceSttProvider: providers.voiceSttProvider,
voiceTtsProvider: providers.voiceTtsProvider,
...(local.local ...(local.local
? { local: local.local } ? { local: local.local }
: {}), : {}),
dictationLocalSttModel: local.dictationLocalSttModel, localModels: {
voiceLocalSttModel: local.voiceLocalSttModel, dictationStt: local.dictationLocalSttModel,
voiceLocalTtsModel: local.voiceLocalTtsModel, voiceStt: local.voiceLocalSttModel,
...(local.voiceLocalTtsSpeakerId !== undefined voiceTts: local.voiceLocalTtsModel,
? { voiceLocalTtsSpeakerId: local.voiceLocalTtsSpeakerId } ...(local.voiceLocalTtsSpeakerId !== undefined
: {}), ? { voiceTtsSpeakerId: local.voiceLocalTtsSpeakerId }
...(local.voiceLocalTtsSpeed !== undefined : {}),
? { voiceLocalTtsSpeed: local.voiceLocalTtsSpeed } ...(local.voiceLocalTtsSpeed !== undefined
: {}), ? { voiceTtsSpeed: local.voiceLocalTtsSpeed }
: {}),
},
}, },
}; };
} }
@@ -17,10 +17,15 @@ import type { RequestedSpeechProviders } from "./speech-types.js";
function resolveRequestedSpeechProviders( function resolveRequestedSpeechProviders(
speechConfig: PaseoSpeechConfig | null speechConfig: PaseoSpeechConfig | null
): RequestedSpeechProviders { ): RequestedSpeechProviders {
const fromConfig = speechConfig?.providers;
if (fromConfig) {
return fromConfig;
}
return { return {
dictationSttProvider: speechConfig?.dictationSttProvider ?? "local", dictationStt: { provider: "local", explicit: false },
voiceSttProvider: speechConfig?.voiceSttProvider ?? "local", voiceStt: { provider: "local", explicit: false },
voiceTtsProvider: speechConfig?.voiceTtsProvider ?? "local", voiceTts: { provider: "local", explicit: false },
}; };
} }
@@ -54,9 +59,9 @@ export async function initializeSpeechRuntime(params: {
logger.info( logger.info(
{ {
requestedProviders: { requestedProviders: {
dictationStt: providers.dictationSttProvider, dictationStt: providers.dictationStt.provider,
voiceStt: providers.voiceSttProvider, voiceStt: providers.voiceStt.provider,
voiceTts: providers.voiceTtsProvider, voiceTts: providers.voiceTts.provider,
}, },
availability: { availability: {
openai: getOpenAiSpeechAvailability(openaiConfig), openai: getOpenAiSpeechAvailability(openaiConfig),
@@ -98,30 +103,66 @@ export async function initializeSpeechRuntime(params: {
!openAiSpeech.sttService ? "voice.stt" : null, !openAiSpeech.sttService ? "voice.stt" : null,
!openAiSpeech.ttsService ? "voice.tts" : null, !openAiSpeech.ttsService ? "voice.tts" : null,
].filter((feature): feature is string => feature !== null); ].filter((feature): feature is string => feature !== null);
const explicitlyConfiguredUnavailableFeatures = unavailableFeatures.filter((feature) => {
if (feature === "dictation.stt") {
return providers.dictationStt.explicit;
}
if (feature === "voice.stt") {
return providers.voiceStt.explicit;
}
return providers.voiceTts.explicit;
});
if (unavailableFeatures.length > 0) { if (explicitlyConfiguredUnavailableFeatures.length > 0) {
logger.error( logger.error(
{ {
requestedProviders: { requestedProviders: {
dictationStt: providers.dictationSttProvider, dictationStt: providers.dictationStt.provider,
voiceStt: providers.voiceSttProvider, voiceStt: providers.voiceStt.provider,
voiceTts: providers.voiceTtsProvider, voiceTts: providers.voiceTts.provider,
},
explicitProviders: {
dictationStt: providers.dictationStt.explicit,
voiceStt: providers.voiceStt.explicit,
voiceTts: providers.voiceTts.explicit,
},
effectiveProviders,
unavailableFeatures: explicitlyConfiguredUnavailableFeatures,
},
"Speech provider reconciliation failed: configured features are unavailable"
);
throw new Error(
`Configured speech features unavailable: ${explicitlyConfiguredUnavailableFeatures.join(", ")}`
);
}
if (unavailableFeatures.length > 0) {
logger.warn(
{
requestedProviders: {
dictationStt: providers.dictationStt.provider,
voiceStt: providers.voiceStt.provider,
voiceTts: providers.voiceTts.provider,
},
explicitProviders: {
dictationStt: providers.dictationStt.explicit,
voiceStt: providers.voiceStt.explicit,
voiceTts: providers.voiceTts.explicit,
}, },
effectiveProviders, effectiveProviders,
unavailableFeatures, unavailableFeatures,
}, },
"Speech provider reconciliation failed: configured features are unavailable" "Speech provider reconciliation completed with unavailable default features"
);
} else {
logger.info(
{
effectiveProviders,
},
"Speech provider reconciliation completed"
); );
throw new Error(`Configured speech features unavailable: ${unavailableFeatures.join(", ")}`);
} }
logger.info(
{
effectiveProviders,
},
"Speech provider reconciliation completed"
);
return { return {
sttService: openAiSpeech.sttService, sttService: openAiSpeech.sttService,
ttsService: openAiSpeech.ttsService, ttsService: openAiSpeech.ttsService,
@@ -3,8 +3,14 @@ import { z } from "zod";
export const SpeechProviderIdSchema = z.enum(["openai", "local"]); export const SpeechProviderIdSchema = z.enum(["openai", "local"]);
export type SpeechProviderId = z.infer<typeof SpeechProviderIdSchema>; export type SpeechProviderId = z.infer<typeof SpeechProviderIdSchema>;
export const RequestedSpeechProviderSchema = z.object({
provider: SpeechProviderIdSchema,
explicit: z.boolean(),
});
export type RequestedSpeechProvider = z.infer<typeof RequestedSpeechProviderSchema>;
export type RequestedSpeechProviders = { export type RequestedSpeechProviders = {
dictationSttProvider: SpeechProviderId; dictationStt: RequestedSpeechProvider;
voiceSttProvider: SpeechProviderId; voiceStt: RequestedSpeechProvider;
voiceTtsProvider: SpeechProviderId; voiceTts: RequestedSpeechProvider;
}; };
@@ -48,9 +48,16 @@ function waitForSignal<T>(
agentClients: {}, agentClients: {},
openai: { apiKey: openaiApiKey! }, openai: { apiKey: openaiApiKey! },
speech: { speech: {
dictationSttProvider: "openai", providers: {
voiceSttProvider: "openai", dictationStt: { provider: "openai", explicit: true },
voiceTtsProvider: "openai", voiceStt: { provider: "openai", explicit: true },
voiceTts: { provider: "openai", explicit: true },
},
localModels: {
dictationStt: "parakeet-tdt-0.6b-v3-int8",
voiceStt: "parakeet-tdt-0.6b-v3-int8",
voiceTts: "pocket-tts-onnx-int8",
},
}, },
voiceLlmProvider: "codex", voiceLlmProvider: "codex",
voiceLlmProviderExplicit: true, voiceLlmProviderExplicit: true,