speech: address PR review feedback and remove unsafe casts

This commit is contained in:
Mohamed Boudra
2026-02-06 22:01:45 +07:00
parent 8c70b3d86b
commit f98d844ff7
10 changed files with 625 additions and 433 deletions
@@ -23,8 +23,6 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod"; import { z } from "zod";
import { homedir } from "node:os";
import { resolve } from "node:path";
import { ensureValidJson } from "../json-utils.js"; import { ensureValidJson } from "../json-utils.js";
import type { Logger } from "pino"; import type { Logger } from "pino";
@@ -51,6 +49,7 @@ import { AgentStorage } from "./agent-storage.js";
import { createWorktree } from "../../utils/worktree.js"; import { createWorktree } from "../../utils/worktree.js";
import { WaitForAgentTracker } from "./wait-for-agent-tracker.js"; import { WaitForAgentTracker } from "./wait-for-agent-tracker.js";
import { scheduleAgentMetadataGeneration } from "./agent-metadata-generator.js"; import { scheduleAgentMetadataGeneration } from "./agent-metadata-generator.js";
import { expandUserPath } from "../path-utils.js";
export interface AgentManagementMcpOptions { export interface AgentManagementMcpOptions {
agentManager: AgentManager; agentManager: AgentManager;
@@ -77,13 +76,6 @@ const AgentStatusEnum = z.enum([
// 50 seconds - surface friendly message before SDK tool timeout (~60s) // 50 seconds - surface friendly message before SDK tool timeout (~60s)
const AGENT_WAIT_TIMEOUT_MS = 50000; const AGENT_WAIT_TIMEOUT_MS = 50000;
function expandPath(path: string): string {
if (path.startsWith("~/") || path === "~") {
return resolve(homedir(), path.slice(2));
}
return resolve(path);
}
async function waitForAgentWithTimeout( async function waitForAgentWithTimeout(
agentManager: AgentManager, agentManager: AgentManager,
agentId: string, agentId: string,
@@ -320,7 +312,7 @@ export async function createAgentManagementMcpServer(
title: string; title: string;
}; };
let resolvedCwd = expandPath(cwd); let resolvedCwd = expandUserPath(cwd);
if (worktreeName) { if (worktreeName) {
if (!baseBranch) { if (!baseBranch) {
+53 -46
View File
@@ -1,9 +1,12 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod"; import { z } from "zod";
import { homedir } from "node:os";
import { resolve } from "node:path";
import { ensureValidJson } from "../json-utils.js"; import { ensureValidJson } from "../json-utils.js";
import type { Logger } from "pino"; import type { Logger } from "pino";
import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js";
import type {
ServerNotification,
ServerRequest,
} from "@modelcontextprotocol/sdk/types.js";
import type { import type {
AgentPromptInput, AgentPromptInput,
@@ -32,6 +35,7 @@ import type {
VoiceCallerContext, VoiceCallerContext,
VoiceSpeakHandler, VoiceSpeakHandler,
} from "../voice-types.js"; } from "../voice-types.js";
import { expandUserPath, resolvePathFromBase } from "../path-utils.js";
export interface AgentMcpServerOptions { export interface AgentMcpServerOptions {
agentManager: AgentManager; agentManager: AgentManager;
@@ -115,11 +119,25 @@ const AgentStatusEnum = z.enum([
// 50 seconds - surface friendly message before SDK tool timeout (~60s) // 50 seconds - surface friendly message before SDK tool timeout (~60s)
const AGENT_WAIT_TIMEOUT_MS = 50000; const AGENT_WAIT_TIMEOUT_MS = 50000;
function expandPath(path: string): string { type McpToolContext = RequestHandlerExtra<ServerRequest, ServerNotification>;
if (path.startsWith("~/") || path === "~") {
return resolve(homedir(), path.slice(2)); function resolveChildAgentCwd(params: {
parentCwd: string;
requestedCwd?: string;
lockedCwd?: string;
allowCustomCwd: boolean;
}): string {
const lockedCwd = params.lockedCwd?.trim();
if (lockedCwd) {
return expandUserPath(lockedCwd);
} }
return resolve(path);
const requestedCwd = params.requestedCwd?.trim();
if (!requestedCwd || !params.allowCustomCwd) {
return params.parentCwd;
}
return resolvePathFromBase(params.parentCwd, requestedCwd);
} }
/** /**
@@ -369,6 +387,11 @@ export async function createAgentMcpServer(
const createAgentInputSchema = callerAgentId const createAgentInputSchema = callerAgentId
? agentToAgentInputSchema ? agentToAgentInputSchema
: topLevelInputSchema; : topLevelInputSchema;
const agentToAgentCreateAgentArgsSchema = z.object(agentToAgentInputSchema);
const topLevelCreateAgentArgsSchema = z.object({
...topLevelInputSchema,
initialMode: topLevelInputSchema.initialMode.optional(),
});
if (options.enableVoiceTools || callerContext?.enableVoiceTools) { if (options.enableVoiceTools || callerContext?.enableVoiceTools) {
server.registerTool( server.registerTool(
@@ -388,7 +411,7 @@ export async function createAgentMcpServer(
ok: z.boolean(), ok: z.boolean(),
}, },
}, },
async (args, context) => { async (args, context?: McpToolContext) => {
if (!callerAgentId) { if (!callerAgentId) {
throw new Error("speak is only available to agent-scoped MCP sessions"); throw new Error("speak is only available to agent-scoped MCP sessions");
} }
@@ -399,7 +422,7 @@ export async function createAgentMcpServer(
await handler({ await handler({
text: args.text, text: args.text,
callerAgentId, callerAgentId,
signal: (context as { signal?: AbortSignal } | undefined)?.signal, signal: context?.signal,
}); });
return { return {
content: [], content: [],
@@ -434,44 +457,31 @@ export async function createAgentMcpServer(
}, },
}, },
async (args: unknown) => { async (args: unknown) => {
const { let provider: AgentProvider;
agentType, let initialPrompt: string;
initialPrompt, let background = false;
background = false, let normalizedTitle: string | null;
title,
} = args as {
cwd?: string;
agentType?: AgentProvider;
initialPrompt: string;
initialMode?: string;
worktreeName?: string;
background?: boolean;
title: string;
};
let resolvedCwd: string; let resolvedCwd: string;
let resolvedMode: string | undefined; let resolvedMode: string | undefined;
if (callerAgentId) { if (callerAgentId) {
const callerArgs = agentToAgentCreateAgentArgsSchema.parse(args);
provider = callerArgs.agentType ?? "claude";
initialPrompt = callerArgs.initialPrompt;
background = callerArgs.background ?? false;
normalizedTitle = callerArgs.title.trim();
const parentAgent = agentManager.getAgent(callerAgentId); const parentAgent = agentManager.getAgent(callerAgentId);
if (!parentAgent) { if (!parentAgent) {
throw new Error(`Parent agent ${callerAgentId} not found`); throw new Error(`Parent agent ${callerAgentId} not found`);
} }
const callerArgs = args as unknown as { cwd?: string }; resolvedCwd = resolveChildAgentCwd({
const requestedCwd = callerArgs.cwd?.trim(); parentCwd: parentAgent.cwd,
const lockedCwd = callerContext?.lockedCwd?.trim(); requestedCwd: callerArgs.cwd,
if (lockedCwd) { lockedCwd: callerContext?.lockedCwd,
resolvedCwd = expandPath(lockedCwd); allowCustomCwd: callerContext?.allowCustomCwd ?? true,
} else if (requestedCwd && (callerContext?.allowCustomCwd ?? true)) { });
resolvedCwd =
requestedCwd.startsWith("/") || requestedCwd.startsWith("~")
? expandPath(requestedCwd)
: resolve(parentAgent.cwd, requestedCwd);
} else {
resolvedCwd = parentAgent.cwd;
}
const provider: AgentProvider = agentType ?? "claude";
const parentMode = parentAgent.currentModeId; const parentMode = parentAgent.currentModeId;
if (parentMode) { if (parentMode) {
resolvedMode = mapModeAcrossProviders( resolvedMode = mapModeAcrossProviders(
@@ -481,12 +491,11 @@ export async function createAgentMcpServer(
); );
} }
} else { } else {
const topLevelArgs = args as unknown as { const topLevelArgs = topLevelCreateAgentArgsSchema.parse(args);
cwd: string; provider = topLevelArgs.agentType ?? "claude";
initialMode: string; initialPrompt = topLevelArgs.initialPrompt;
worktreeName?: string; background = topLevelArgs.background ?? false;
baseBranch?: string; normalizedTitle = topLevelArgs.title.trim();
};
const { const {
cwd, cwd,
initialMode, initialMode,
@@ -494,7 +503,7 @@ export async function createAgentMcpServer(
baseBranch, baseBranch,
} = topLevelArgs; } = topLevelArgs;
resolvedCwd = expandPath(cwd); resolvedCwd = expandUserPath(cwd);
if (worktreeName) { if (worktreeName) {
if (!baseBranch) { if (!baseBranch) {
@@ -513,8 +522,6 @@ export async function createAgentMcpServer(
resolvedMode = initialMode; resolvedMode = initialMode;
} }
const provider: AgentProvider = agentType ?? "claude";
const normalizedTitle = title?.trim() ?? null;
const childAgentDefaultLabels = const childAgentDefaultLabels =
callerAgentId && callerContext?.childAgentDefaultLabels callerAgentId && callerContext?.childAgentDefaultLabels
? callerContext.childAgentDefaultLabels ? callerContext.childAgentDefaultLabels
+22
View File
@@ -0,0 +1,22 @@
import { homedir } from "node:os";
import { isAbsolute, resolve } from "node:path";
function hasHomePrefix(value: string): boolean {
return value === "~" || value.startsWith("~/");
}
export function expandUserPath(value: string): string {
const trimmed = value.trim();
if (hasHomePrefix(trimmed)) {
return resolve(homedir(), trimmed.slice(2));
}
return resolve(trimmed);
}
export function resolvePathFromBase(baseCwd: string, requestedPath: string): string {
const trimmed = requestedPath.trim();
if (hasHomePrefix(trimmed) || isAbsolute(trimmed)) {
return expandUserPath(trimmed);
}
return resolve(baseCwd, trimmed);
}
@@ -2,57 +2,75 @@ import { z } from "zod";
export type SherpaOnnxModelKind = "stt-online" | "stt-offline" | "tts"; export type SherpaOnnxModelKind = "stt-online" | "stt-offline" | "tts";
export type SherpaOnnxModelId = const MODEL_IDS = {
| "zipformer-bilingual-zh-en-2023-02-20" ZIPFORMER_BILINGUAL_ZH_EN_2023_02_20: "zipformer-bilingual-zh-en-2023-02-20",
| "paraformer-bilingual-zh-en" PARAFORMER_BILINGUAL_ZH_EN: "paraformer-bilingual-zh-en",
| "parakeet-tdt-0.6b-v3-int8" PARAKEET_TDT_0_6B_V3_INT8: "parakeet-tdt-0.6b-v3-int8",
| "kitten-nano-en-v0_1-fp16" KITTEN_NANO_EN_V0_1_FP16: "kitten-nano-en-v0_1-fp16",
| "kokoro-en-v0_19" KOKORO_EN_V0_19: "kokoro-en-v0_19",
| "pocket-tts-onnx-int8"; POCKET_TTS_ONNX_INT8: "pocket-tts-onnx-int8",
} as const;
export type SherpaOnnxModelId = (typeof MODEL_IDS)[keyof typeof MODEL_IDS];
export const LOCAL_STT_MODEL_IDS = [ export const LOCAL_STT_MODEL_IDS = [
"zipformer-bilingual-zh-en-2023-02-20", MODEL_IDS.ZIPFORMER_BILINGUAL_ZH_EN_2023_02_20,
"paraformer-bilingual-zh-en", MODEL_IDS.PARAFORMER_BILINGUAL_ZH_EN,
"parakeet-tdt-0.6b-v3-int8", MODEL_IDS.PARAKEET_TDT_0_6B_V3_INT8,
] as const; ] as const;
export type LocalSttModelId = (typeof LOCAL_STT_MODEL_IDS)[number]; export type LocalSttModelId = (typeof LOCAL_STT_MODEL_IDS)[number];
export const LOCAL_TTS_MODEL_IDS = [ export const LOCAL_TTS_MODEL_IDS = [
"kitten-nano-en-v0_1-fp16", MODEL_IDS.KITTEN_NANO_EN_V0_1_FP16,
"kokoro-en-v0_19", MODEL_IDS.KOKORO_EN_V0_19,
"pocket-tts-onnx-int8", MODEL_IDS.POCKET_TTS_ONNX_INT8,
] as const; ] as const;
export type LocalTtsModelId = (typeof LOCAL_TTS_MODEL_IDS)[number]; export type LocalTtsModelId = (typeof LOCAL_TTS_MODEL_IDS)[number];
const STT_MODEL_ALIASES: Record<string, (typeof LOCAL_STT_MODEL_IDS)[number]> = { export const DEFAULT_LOCAL_STT_MODEL: LocalSttModelId = MODEL_IDS.PARAKEET_TDT_0_6B_V3_INT8;
zipformer: "zipformer-bilingual-zh-en-2023-02-20", export const DEFAULT_LOCAL_TTS_MODEL: LocalTtsModelId = MODEL_IDS.POCKET_TTS_ONNX_INT8;
"zipformer-bilingual": "zipformer-bilingual-zh-en-2023-02-20",
paraformer: "paraformer-bilingual-zh-en", const STT_MODEL_ALIASES: Record<string, LocalSttModelId> = {
parakeet: "parakeet-tdt-0.6b-v3-int8", zipformer: MODEL_IDS.ZIPFORMER_BILINGUAL_ZH_EN_2023_02_20,
"parakeet-v3": "parakeet-tdt-0.6b-v3-int8", "zipformer-bilingual": MODEL_IDS.ZIPFORMER_BILINGUAL_ZH_EN_2023_02_20,
"parakeet-tdt": "parakeet-tdt-0.6b-v3-int8", paraformer: MODEL_IDS.PARAFORMER_BILINGUAL_ZH_EN,
parakeet: MODEL_IDS.PARAKEET_TDT_0_6B_V3_INT8,
"parakeet-v3": MODEL_IDS.PARAKEET_TDT_0_6B_V3_INT8,
"parakeet-tdt": MODEL_IDS.PARAKEET_TDT_0_6B_V3_INT8,
}; };
const TTS_MODEL_ALIASES: Record<string, (typeof LOCAL_TTS_MODEL_IDS)[number]> = { const TTS_MODEL_ALIASES: Record<string, LocalTtsModelId> = {
pocket: "pocket-tts-onnx-int8", pocket: MODEL_IDS.POCKET_TTS_ONNX_INT8,
"pocket-tts": "pocket-tts-onnx-int8", "pocket-tts": MODEL_IDS.POCKET_TTS_ONNX_INT8,
kitten: "kitten-nano-en-v0_1-fp16", kitten: MODEL_IDS.KITTEN_NANO_EN_V0_1_FP16,
kokoro: "kokoro-en-v0_19", kokoro: MODEL_IDS.KOKORO_EN_V0_19,
}; };
export const LocalSttModelIdSchema = z.preprocess((value) => { function createAliasedModelIdSchema<T extends readonly [string, ...string[]]>(
if (typeof value !== "string") return value; values: T,
const normalized = value.trim().toLowerCase(); aliases: Record<string, T[number]>
if (!normalized) return value; ) {
return STT_MODEL_ALIASES[normalized] ?? normalized; return z.preprocess((value) => {
}, z.enum(LOCAL_STT_MODEL_IDS)); if (typeof value !== "string") {
return value;
}
const normalized = value.trim().toLowerCase();
if (!normalized) {
return value;
}
return aliases[normalized] ?? normalized;
}, z.enum(values));
}
export const LocalTtsModelIdSchema = z.preprocess((value) => { export const LocalSttModelIdSchema = createAliasedModelIdSchema(
if (typeof value !== "string") return value; LOCAL_STT_MODEL_IDS,
const normalized = value.trim().toLowerCase(); STT_MODEL_ALIASES
if (!normalized) return value; );
return TTS_MODEL_ALIASES[normalized] ?? normalized;
}, z.enum(LOCAL_TTS_MODEL_IDS)); export const LocalTtsModelIdSchema = createAliasedModelIdSchema(
LOCAL_TTS_MODEL_IDS,
TTS_MODEL_ALIASES
);
export type SherpaOnnxModelSpec = { export type SherpaOnnxModelSpec = {
id: SherpaOnnxModelId; id: SherpaOnnxModelId;
@@ -65,8 +83,8 @@ export type SherpaOnnxModelSpec = {
}; };
export const SHERPA_ONNX_MODEL_CATALOG: Record<SherpaOnnxModelId, SherpaOnnxModelSpec> = { export const SHERPA_ONNX_MODEL_CATALOG: Record<SherpaOnnxModelId, SherpaOnnxModelSpec> = {
"zipformer-bilingual-zh-en-2023-02-20": { [MODEL_IDS.ZIPFORMER_BILINGUAL_ZH_EN_2023_02_20]: {
id: "zipformer-bilingual-zh-en-2023-02-20", id: MODEL_IDS.ZIPFORMER_BILINGUAL_ZH_EN_2023_02_20,
kind: "stt-online", kind: "stt-online",
archiveUrl: archiveUrl:
"https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-streaming-zipformer-bilingual-zh-en-2023-02-20.tar.bz2", "https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-streaming-zipformer-bilingual-zh-en-2023-02-20.tar.bz2",
@@ -79,8 +97,8 @@ export const SHERPA_ONNX_MODEL_CATALOG: Record<SherpaOnnxModelId, SherpaOnnxMode
], ],
description: "Streaming Zipformer transducer (fast, good accuracy).", description: "Streaming Zipformer transducer (fast, good accuracy).",
}, },
"paraformer-bilingual-zh-en": { [MODEL_IDS.PARAFORMER_BILINGUAL_ZH_EN]: {
id: "paraformer-bilingual-zh-en", id: MODEL_IDS.PARAFORMER_BILINGUAL_ZH_EN,
kind: "stt-online", kind: "stt-online",
archiveUrl: archiveUrl:
"https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-streaming-paraformer-bilingual-zh-en.tar.bz2", "https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-streaming-paraformer-bilingual-zh-en.tar.bz2",
@@ -88,8 +106,8 @@ export const SHERPA_ONNX_MODEL_CATALOG: Record<SherpaOnnxModelId, SherpaOnnxMode
requiredFiles: ["encoder.int8.onnx", "decoder.int8.onnx", "tokens.txt"], requiredFiles: ["encoder.int8.onnx", "decoder.int8.onnx", "tokens.txt"],
description: "Streaming Paraformer (often strong accuracy; heavier).", description: "Streaming Paraformer (often strong accuracy; heavier).",
}, },
"parakeet-tdt-0.6b-v3-int8": { [MODEL_IDS.PARAKEET_TDT_0_6B_V3_INT8]: {
id: "parakeet-tdt-0.6b-v3-int8", id: MODEL_IDS.PARAKEET_TDT_0_6B_V3_INT8,
kind: "stt-offline", kind: "stt-offline",
archiveUrl: archiveUrl:
"https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8.tar.bz2", "https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8.tar.bz2",
@@ -97,8 +115,8 @@ export const SHERPA_ONNX_MODEL_CATALOG: Record<SherpaOnnxModelId, SherpaOnnxMode
requiredFiles: ["encoder.int8.onnx", "decoder.int8.onnx", "joiner.int8.onnx", "tokens.txt"], requiredFiles: ["encoder.int8.onnx", "decoder.int8.onnx", "joiner.int8.onnx", "tokens.txt"],
description: "NVIDIA Parakeet TDT v3 (offline NeMo transducer, multilingual).", description: "NVIDIA Parakeet TDT v3 (offline NeMo transducer, multilingual).",
}, },
"kitten-nano-en-v0_1-fp16": { [MODEL_IDS.KITTEN_NANO_EN_V0_1_FP16]: {
id: "kitten-nano-en-v0_1-fp16", id: MODEL_IDS.KITTEN_NANO_EN_V0_1_FP16,
kind: "tts", kind: "tts",
archiveUrl: archiveUrl:
"https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/kitten-nano-en-v0_1-fp16.tar.bz2", "https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/kitten-nano-en-v0_1-fp16.tar.bz2",
@@ -106,16 +124,16 @@ export const SHERPA_ONNX_MODEL_CATALOG: Record<SherpaOnnxModelId, SherpaOnnxMode
requiredFiles: ["model.fp16.onnx", "voices.bin", "tokens.txt", "espeak-ng-data"], requiredFiles: ["model.fp16.onnx", "voices.bin", "tokens.txt", "espeak-ng-data"],
description: "KittenTTS (small, fast English TTS).", description: "KittenTTS (small, fast English TTS).",
}, },
"kokoro-en-v0_19": { [MODEL_IDS.KOKORO_EN_V0_19]: {
id: "kokoro-en-v0_19", id: MODEL_IDS.KOKORO_EN_V0_19,
kind: "tts", kind: "tts",
archiveUrl: "https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/kokoro-en-v0_19.tar.bz2", archiveUrl: "https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/kokoro-en-v0_19.tar.bz2",
extractedDir: "kokoro-en-v0_19", extractedDir: "kokoro-en-v0_19",
requiredFiles: ["model.onnx", "voices.bin", "tokens.txt", "espeak-ng-data"], requiredFiles: ["model.onnx", "voices.bin", "tokens.txt", "espeak-ng-data"],
description: "Kokoro TTS (higher quality; larger).", description: "Kokoro TTS (higher quality; larger).",
}, },
"pocket-tts-onnx-int8": { [MODEL_IDS.POCKET_TTS_ONNX_INT8]: {
id: "pocket-tts-onnx-int8", id: MODEL_IDS.POCKET_TTS_ONNX_INT8,
kind: "tts", kind: "tts",
extractedDir: "pocket-tts-onnx-int8", extractedDir: "pocket-tts-onnx-int8",
downloadFiles: [ downloadFiles: [
@@ -1,6 +1,4 @@
import { createRequire } from "node:module"; import { createRequire } from "node:module";
import os from "node:os";
import path from "node:path";
export type SherpaOnnxModule = { export type SherpaOnnxModule = {
createOnlineRecognizer: (config: any) => any; createOnlineRecognizer: (config: any) => any;
@@ -10,39 +8,12 @@ export type SherpaOnnxModule = {
let cached: SherpaOnnxModule | null = null; let cached: SherpaOnnxModule | null = null;
function ensureSherpaNativeLibraryPath(requireFn: NodeRequire): void {
const platform = os.platform();
if (platform !== "darwin" && platform !== "linux") {
return;
}
const platformArch = `${platform}-${os.arch()}`;
const packageName = `sherpa-onnx-${platformArch}`;
let nativeDir: string;
try {
const binaryPath = requireFn.resolve(`${packageName}/sherpa-onnx.node`);
nativeDir = path.dirname(binaryPath);
} catch {
return;
}
const envKey = platform === "darwin" ? "DYLD_LIBRARY_PATH" : "LD_LIBRARY_PATH";
const current = process.env[envKey]?.trim() ?? "";
const entries = current.length > 0 ? current.split(":").filter(Boolean) : [];
if (entries.includes(nativeDir)) {
return;
}
process.env[envKey] = entries.length > 0 ? `${nativeDir}:${entries.join(":")}` : nativeDir;
}
export function loadSherpaOnnx(): SherpaOnnxModule { export function loadSherpaOnnx(): SherpaOnnxModule {
if (cached) { if (cached) {
return cached; return cached;
} }
const require = createRequire(import.meta.url); const require = createRequire(import.meta.url);
ensureSherpaNativeLibraryPath(require);
cached = require("sherpa-onnx") as SherpaOnnxModule; cached = require("sherpa-onnx") as SherpaOnnxModule;
return cached; return cached;
} }
@@ -14,13 +14,16 @@ function platformArch(): string {
return `${platform}-${process.arch}`; return `${platform}-${process.arch}`;
} }
function prependLibraryPath(envKey: "DYLD_LIBRARY_PATH" | "LD_LIBRARY_PATH", dir: string): void { function prependLibraryPath(
envKey: "DYLD_LIBRARY_PATH" | "LD_LIBRARY_PATH" | "PATH",
dir: string
): void {
const current = process.env[envKey] ?? ""; const current = process.env[envKey] ?? "";
const parts = current.split(":").filter(Boolean); const parts = current.split(path.delimiter).filter(Boolean);
if (parts.includes(dir)) { if (parts.includes(dir)) {
return; return;
} }
process.env[envKey] = [dir, ...parts].join(":"); process.env[envKey] = [dir, ...parts].join(path.delimiter);
} }
export function loadSherpaOnnxNode(): SherpaOnnxNodeModule { export function loadSherpaOnnxNode(): SherpaOnnxNodeModule {
@@ -31,8 +34,7 @@ export function loadSherpaOnnxNode(): SherpaOnnxNodeModule {
const require = createRequire(import.meta.url); const require = createRequire(import.meta.url);
// sherpa-onnx-node depends on a platform-specific package (e.g. sherpa-onnx-darwin-arm64) // sherpa-onnx-node depends on a platform-specific package (e.g. sherpa-onnx-darwin-arm64)
// that contains the native addon + its shared libraries. On macOS/Linux we need to ensure // that contains the native addon and shared libraries. Ensure the OS loader path includes it.
// the appropriate library path env var includes that directory before requiring the addon.
const arch = platformArch(); const arch = platformArch();
const pkgName = `sherpa-onnx-${arch}`; const pkgName = `sherpa-onnx-${arch}`;
@@ -43,6 +45,8 @@ export function loadSherpaOnnxNode(): SherpaOnnxNodeModule {
prependLibraryPath("DYLD_LIBRARY_PATH", pkgDir); prependLibraryPath("DYLD_LIBRARY_PATH", pkgDir);
} else if (process.platform === "linux") { } else if (process.platform === "linux") {
prependLibraryPath("LD_LIBRARY_PATH", pkgDir); prependLibraryPath("LD_LIBRARY_PATH", pkgDir);
} else if (process.platform === "win32") {
prependLibraryPath("PATH", pkgDir);
} }
} catch { } catch {
// Best effort - if the platform package isn't present, require() below will throw a useful error. // Best effort - if the platform package isn't present, require() below will throw a useful error.
@@ -1,104 +1,172 @@
import path from "node:path"; import path from "node:path";
import type { STTConfig } from "./providers/openai/stt.js"; import { z } from "zod";
import type { TTSConfig } from "./providers/openai/tts.js";
import type { PersistedConfig } from "../persisted-config.js"; import type { PersistedConfig } from "../persisted-config.js";
import type { PaseoOpenAIConfig, PaseoSpeechConfig } from "../bootstrap.js"; import type { PaseoOpenAIConfig, PaseoSpeechConfig } from "../bootstrap.js";
import { LocalSttModelIdSchema, LocalTtsModelIdSchema } from "./providers/local/sherpa/model-catalog.js"; import {
LocalSttModelIdSchema,
LocalTtsModelIdSchema,
} from "./providers/local/sherpa/model-catalog.js";
import { import {
DEFAULT_LOCAL_STT_MODEL, DEFAULT_LOCAL_STT_MODEL,
DEFAULT_LOCAL_TTS_MODEL, DEFAULT_LOCAL_TTS_MODEL,
DEFAULT_OPENAI_REALTIME_TRANSCRIPTION_MODEL, DEFAULT_OPENAI_REALTIME_TRANSCRIPTION_MODEL,
type SpeechProviderId, DEFAULT_OPENAI_TTS_MODEL,
} from "./speech-types.js"; } from "./speech-defaults.js";
import { SpeechProviderIdSchema } from "./speech-types.js";
const DEFAULT_LOCAL_MODELS_SUBDIR = path.join("models", "local-speech"); const DEFAULT_LOCAL_MODELS_SUBDIR = path.join("models", "local-speech");
const DEFAULT_OPENAI_TTS_MODEL: TTSConfig["model"] = "tts-1";
function parseSpeechProviderId(value: unknown): SpeechProviderId | null { const OpenAiTtsVoiceSchema = z.enum([
if (typeof value !== "string") return null; "alloy",
"echo",
"fable",
"onyx",
"nova",
"shimmer",
]);
const OpenAiTtsModelSchema = z.enum(["tts-1", "tts-1-hd"]);
const OptionalSpeechProviderSchema = z.preprocess((value) => {
if (typeof value !== "string") {
return value;
}
const normalized = value.trim().toLowerCase(); const normalized = value.trim().toLowerCase();
if (!normalized) return null; return normalized.length > 0 ? normalized : undefined;
if (normalized === "openai") return "openai"; }, SpeechProviderIdSchema.optional());
if (normalized === "local") return "local";
return null;
}
function parseBooleanFlag(value: string | undefined): boolean | null { const OptionalBooleanFlagSchema = z.preprocess((value) => {
if (value === undefined) return null; if (typeof value === "boolean") {
return value;
}
if (typeof value !== "string") {
return value;
}
const normalized = value.trim().toLowerCase(); const normalized = value.trim().toLowerCase();
if (normalized === "1" || normalized === "true" || normalized === "yes") return true; if (normalized === "1" || normalized === "true" || normalized === "yes") {
if (normalized === "0" || normalized === "false" || normalized === "no") return false; return true;
return null; }
} if (normalized === "0" || normalized === "false" || normalized === "no") {
return false;
}
return undefined;
}, z.boolean().optional());
function parseNumberOrUndefined(value: string | undefined): number | undefined { const OptionalFiniteNumberSchema = z.preprocess((value) => {
if (value === undefined) return undefined; if (typeof value === "number") {
return Number.isFinite(value) ? value : undefined;
}
if (typeof value !== "string") {
return value;
}
const parsed = Number.parseFloat(value); const parsed = Number.parseFloat(value);
return Number.isFinite(parsed) ? parsed : undefined; return Number.isFinite(parsed) ? parsed : undefined;
} }, z.number().optional());
function parseIntOrUndefined(value: string | undefined): number | undefined { const OptionalIntegerSchema = z.preprocess((value) => {
if (value === undefined) return undefined; if (typeof value === "number") {
return Number.isInteger(value) ? value : undefined;
}
if (typeof value !== "string") {
return value;
}
const parsed = Number.parseInt(value, 10); const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) ? parsed : undefined; return Number.isFinite(parsed) ? parsed : undefined;
} }, z.number().int().optional());
function parseOpenAiConfig( const OptionalTrimmedStringSchema = z.preprocess((value) => {
env: NodeJS.ProcessEnv, if (typeof value !== "string") {
persisted: PersistedConfig, return value;
providers: {
dictationSttProvider: SpeechProviderId;
voiceSttProvider: SpeechProviderId;
voiceTtsProvider: SpeechProviderId;
} }
): PaseoOpenAIConfig | undefined { const trimmed = value.trim();
const apiKey = env.OPENAI_API_KEY ?? persisted.providers?.openai?.apiKey; return trimmed.length > 0 ? trimmed : undefined;
if (!apiKey) return undefined; }, z.string().optional());
const sttConfidenceThreshold = parseNumberOrUndefined(env.STT_CONFIDENCE_THRESHOLD) const ResolvedSpeechConfigSchema = z
?? persisted.features?.dictation?.stt?.confidenceThreshold; .object({
dictationSttProvider: OptionalSpeechProviderSchema.default("local"),
voiceSttProvider: OptionalSpeechProviderSchema.default("local"),
voiceTtsProvider: OptionalSpeechProviderSchema.default("local"),
localModelsDir: z.string().trim().min(1),
localAutoDownload: OptionalBooleanFlagSchema.default(true),
dictationLocalSttModel: LocalSttModelIdSchema.default(DEFAULT_LOCAL_STT_MODEL),
voiceLocalSttModel: LocalSttModelIdSchema.default(DEFAULT_LOCAL_STT_MODEL),
voiceLocalTtsModel: LocalTtsModelIdSchema.default(DEFAULT_LOCAL_TTS_MODEL),
voiceLocalTtsSpeakerId: OptionalIntegerSchema,
voiceLocalTtsSpeed: OptionalFiniteNumberSchema,
anyLocalRequested: z.boolean(),
openaiApiKey: OptionalTrimmedStringSchema,
openaiSttConfidenceThreshold: OptionalFiniteNumberSchema,
openaiSttModel: OptionalTrimmedStringSchema,
openaiTtsVoice: z.preprocess((value) => {
if (typeof value !== "string") {
return value;
}
const normalized = value.trim().toLowerCase();
return normalized.length > 0 ? normalized : undefined;
}, OpenAiTtsVoiceSchema.default("alloy")),
openaiTtsModel: z.preprocess((value) => {
if (typeof value !== "string") {
return value;
}
const normalized = value.trim().toLowerCase();
return normalized.length > 0 ? normalized : undefined;
}, OpenAiTtsModelSchema.default(DEFAULT_OPENAI_TTS_MODEL)),
openaiRealtimeTranscriptionModel: OptionalTrimmedStringSchema.default(
DEFAULT_OPENAI_REALTIME_TRANSCRIPTION_MODEL
),
})
.transform((input): { openai: PaseoOpenAIConfig | undefined; speech: PaseoSpeechConfig } => {
const openai = input.openaiApiKey
? {
apiKey: input.openaiApiKey,
stt: {
apiKey: input.openaiApiKey,
...(input.openaiSttConfidenceThreshold !== undefined
? { confidenceThreshold: input.openaiSttConfidenceThreshold }
: {}),
...(input.openaiSttModel
? { model: input.openaiSttModel }
: {}),
},
tts: {
apiKey: input.openaiApiKey,
voice: input.openaiTtsVoice,
model: input.openaiTtsModel,
responseFormat: "pcm" as const,
},
realtimeTranscriptionModel: input.openaiRealtimeTranscriptionModel,
}
: undefined;
const sttModel = ( return {
env.STT_MODEL openai,
?? (providers.voiceSttProvider === "openai" ? persisted.features?.voiceMode?.stt?.model : undefined) speech: {
?? (providers.dictationSttProvider === "openai" ? persisted.features?.dictation?.stt?.model : undefined) dictationSttProvider: input.dictationSttProvider,
) as STTConfig["model"] | undefined; voiceSttProvider: input.voiceSttProvider,
voiceTtsProvider: input.voiceTtsProvider,
const ttsVoice = ( ...(input.anyLocalRequested
env.TTS_VOICE ? {
|| (providers.voiceTtsProvider === "openai" ? persisted.features?.voiceMode?.tts?.voice : undefined) local: {
|| "alloy" modelsDir: input.localModelsDir,
) as TTSConfig["voice"]; autoDownload: input.localAutoDownload,
const ttsModelRaw = },
env.TTS_MODEL }
|| (providers.voiceTtsProvider === "openai" ? persisted.features?.voiceMode?.tts?.model : undefined) : {}),
|| DEFAULT_OPENAI_TTS_MODEL; dictationLocalSttModel: input.dictationLocalSttModel,
const ttsModel: TTSConfig["model"] = voiceLocalSttModel: input.voiceLocalSttModel,
ttsModelRaw === "tts-1" || ttsModelRaw === "tts-1-hd" ? ttsModelRaw : DEFAULT_OPENAI_TTS_MODEL; voiceLocalTtsModel: input.voiceLocalTtsModel,
...(input.voiceLocalTtsSpeakerId !== undefined
const realtimeTranscriptionModel = ? { voiceLocalTtsSpeakerId: input.voiceLocalTtsSpeakerId }
env.OPENAI_REALTIME_TRANSCRIPTION_MODEL : {}),
|| (providers.dictationSttProvider === "openai" ...(input.voiceLocalTtsSpeed !== undefined
? persisted.features?.dictation?.stt?.model ? { voiceLocalTtsSpeed: input.voiceLocalTtsSpeed }
: undefined) : {}),
|| DEFAULT_OPENAI_REALTIME_TRANSCRIPTION_MODEL; },
};
return { });
apiKey,
stt: {
apiKey,
...(sttConfidenceThreshold !== undefined ? { confidenceThreshold: sttConfidenceThreshold } : {}),
...(sttModel ? { model: sttModel } : {}),
},
tts: {
apiKey,
voice: ttsVoice,
model: ttsModel,
responseFormat: "pcm",
},
realtimeTranscriptionModel,
};
}
export function resolveSpeechConfig(params: { export function resolveSpeechConfig(params: {
paseoHome: string; paseoHome: string;
@@ -111,18 +179,18 @@ export function resolveSpeechConfig(params: {
const { paseoHome, env, persisted } = params; const { paseoHome, env, persisted } = params;
const dictationSttProvider = const dictationSttProvider =
parseSpeechProviderId(env.PASEO_DICTATION_STT_PROVIDER) env.PASEO_DICTATION_STT_PROVIDER
?? parseSpeechProviderId(persisted.features?.dictation?.stt?.provider) ?? persisted.features?.dictation?.stt?.provider
?? "local"; ?? "local";
const voiceSttProvider = const voiceSttProvider =
parseSpeechProviderId(env.PASEO_VOICE_STT_PROVIDER) env.PASEO_VOICE_STT_PROVIDER
?? parseSpeechProviderId(persisted.features?.voiceMode?.stt?.provider) ?? persisted.features?.voiceMode?.stt?.provider
?? "local"; ?? "local";
const voiceTtsProvider = const voiceTtsProvider =
parseSpeechProviderId(env.PASEO_VOICE_TTS_PROVIDER) env.PASEO_VOICE_TTS_PROVIDER
?? parseSpeechProviderId(persisted.features?.voiceMode?.tts?.provider) ?? persisted.features?.voiceMode?.tts?.provider
?? "local"; ?? "local";
const anyLocalRequested = const anyLocalRequested =
@@ -132,65 +200,65 @@ export function resolveSpeechConfig(params: {
env.PASEO_LOCAL_MODELS_DIR !== undefined || env.PASEO_LOCAL_MODELS_DIR !== undefined ||
persisted.providers?.local !== undefined; persisted.providers?.local !== undefined;
const localModelsDir = return ResolvedSpeechConfigSchema.parse({
env.PASEO_LOCAL_MODELS_DIR dictationSttProvider,
?? persisted.providers?.local?.modelsDir voiceSttProvider,
?? path.join(paseoHome, DEFAULT_LOCAL_MODELS_SUBDIR); voiceTtsProvider,
localModelsDir:
const localAutoDownload = env.PASEO_LOCAL_MODELS_DIR
parseBooleanFlag(env.PASEO_LOCAL_AUTO_DOWNLOAD) ?? persisted.providers?.local?.modelsDir
?? persisted.providers?.local?.autoDownload ?? path.join(paseoHome, DEFAULT_LOCAL_MODELS_SUBDIR),
?? true; localAutoDownload:
env.PASEO_LOCAL_AUTO_DOWNLOAD
const dictationLocalSttModel = LocalSttModelIdSchema.parse( ?? persisted.providers?.local?.autoDownload,
env.PASEO_DICTATION_LOCAL_STT_MODEL dictationLocalSttModel:
?? persisted.features?.dictation?.stt?.model env.PASEO_DICTATION_LOCAL_STT_MODEL
?? DEFAULT_LOCAL_STT_MODEL ?? persisted.features?.dictation?.stt?.model
); ?? DEFAULT_LOCAL_STT_MODEL,
voiceLocalSttModel:
const voiceLocalSttModel = LocalSttModelIdSchema.parse( env.PASEO_VOICE_LOCAL_STT_MODEL
env.PASEO_VOICE_LOCAL_STT_MODEL ?? persisted.features?.voiceMode?.stt?.model
?? persisted.features?.voiceMode?.stt?.model ?? DEFAULT_LOCAL_STT_MODEL,
?? DEFAULT_LOCAL_STT_MODEL voiceLocalTtsModel:
); env.PASEO_VOICE_LOCAL_TTS_MODEL
?? persisted.features?.voiceMode?.tts?.model
const voiceLocalTtsModel = LocalTtsModelIdSchema.parse( ?? DEFAULT_LOCAL_TTS_MODEL,
env.PASEO_VOICE_LOCAL_TTS_MODEL voiceLocalTtsSpeakerId:
?? persisted.features?.voiceMode?.tts?.model env.PASEO_VOICE_LOCAL_TTS_SPEAKER_ID
?? DEFAULT_LOCAL_TTS_MODEL ?? persisted.features?.voiceMode?.tts?.speakerId,
); voiceLocalTtsSpeed:
env.PASEO_VOICE_LOCAL_TTS_SPEED
const voiceLocalTtsSpeakerId = ?? persisted.features?.voiceMode?.tts?.speed,
parseIntOrUndefined(env.PASEO_VOICE_LOCAL_TTS_SPEAKER_ID) anyLocalRequested,
?? persisted.features?.voiceMode?.tts?.speakerId; openaiApiKey: env.OPENAI_API_KEY ?? persisted.providers?.openai?.apiKey,
openaiSttConfidenceThreshold:
const voiceLocalTtsSpeed = env.STT_CONFIDENCE_THRESHOLD
parseNumberOrUndefined(env.PASEO_VOICE_LOCAL_TTS_SPEED) ?? persisted.features?.dictation?.stt?.confidenceThreshold,
?? persisted.features?.voiceMode?.tts?.speed; openaiSttModel:
env.STT_MODEL
return { ?? (voiceSttProvider === "openai"
openai: parseOpenAiConfig(env, persisted, { ? persisted.features?.voiceMode?.stt?.model
dictationSttProvider, : undefined)
voiceSttProvider, ?? (dictationSttProvider === "openai"
voiceTtsProvider, ? persisted.features?.dictation?.stt?.model
}), : undefined),
speech: { openaiTtsVoice:
dictationSttProvider, env.TTS_VOICE
voiceSttProvider, ?? (voiceTtsProvider === "openai"
voiceTtsProvider, ? persisted.features?.voiceMode?.tts?.voice
...(anyLocalRequested : undefined)
? { ?? "alloy",
local: { openaiTtsModel:
modelsDir: localModelsDir.trim(), env.TTS_MODEL
autoDownload: localAutoDownload, ?? (voiceTtsProvider === "openai"
}, ? persisted.features?.voiceMode?.tts?.model
} : undefined)
: {}), ?? DEFAULT_OPENAI_TTS_MODEL,
dictationLocalSttModel, openaiRealtimeTranscriptionModel:
voiceLocalSttModel, env.OPENAI_REALTIME_TRANSCRIPTION_MODEL
voiceLocalTtsModel, ?? (dictationSttProvider === "openai"
...(voiceLocalTtsSpeakerId !== undefined ? { voiceLocalTtsSpeakerId } : {}), ? persisted.features?.dictation?.stt?.model
...(voiceLocalTtsSpeed !== undefined ? { voiceLocalTtsSpeed } : {}), : undefined)
}, ?? DEFAULT_OPENAI_REALTIME_TRANSCRIPTION_MODEL,
}; });
} }
@@ -0,0 +1,9 @@
import {
DEFAULT_LOCAL_STT_MODEL,
DEFAULT_LOCAL_TTS_MODEL,
} from "./providers/local/sherpa/model-catalog.js";
export { DEFAULT_LOCAL_STT_MODEL, DEFAULT_LOCAL_TTS_MODEL };
export const DEFAULT_OPENAI_REALTIME_TRANSCRIPTION_MODEL = "gpt-4o-transcribe";
export const DEFAULT_OPENAI_TTS_MODEL = "tts-1";
@@ -29,24 +29,48 @@ import {
DEFAULT_LOCAL_STT_MODEL, DEFAULT_LOCAL_STT_MODEL,
DEFAULT_LOCAL_TTS_MODEL, DEFAULT_LOCAL_TTS_MODEL,
DEFAULT_OPENAI_REALTIME_TRANSCRIPTION_MODEL, DEFAULT_OPENAI_REALTIME_TRANSCRIPTION_MODEL,
type SpeechProviderId, DEFAULT_OPENAI_TTS_MODEL,
} from "./speech-types.js"; } from "./speech-defaults.js";
import type { SpeechProviderId } from "./speech-types.js";
type LocalSttEngine = type LocalSttEngine =
| { kind: "offline"; engine: SherpaOfflineRecognizerEngine } | { kind: "offline"; engine: SherpaOfflineRecognizerEngine }
| { kind: "online"; engine: SherpaOnlineRecognizerEngine }; | { kind: "online"; engine: SherpaOnlineRecognizerEngine };
function buildModelDownloadHint(modelsDir: string, modelId: SherpaOnnxModelId): string { type RequestedSpeechProviders = {
return `Run: tsx packages/server/scripts/download-speech-models.ts --models-dir '${modelsDir}' --model '${modelId}'`;
}
function resolveSpeechProviders(
speechConfig: PaseoSpeechConfig | null
): {
dictationSttProvider: SpeechProviderId; dictationSttProvider: SpeechProviderId;
voiceSttProvider: SpeechProviderId; voiceSttProvider: SpeechProviderId;
voiceTtsProvider: SpeechProviderId; voiceTtsProvider: SpeechProviderId;
} { };
type ResolvedLocalModels = {
dictationLocalSttModel: LocalSttModelId;
voiceLocalSttModel: LocalSttModelId;
voiceLocalTtsModel: LocalTtsModelId;
};
type OpenAiCredentialState = {
openaiSttApiKey: string | undefined;
openaiTtsApiKey: string | undefined;
openaiDictationApiKey: string | undefined;
};
type InitializedLocalSpeech = {
sttService: SpeechToTextProvider | null;
ttsService: TextToSpeechProvider | null;
dictationSttService: SpeechToTextProvider | null;
localVoiceTtsProvider: TextToSpeechProvider | null;
localSttEngines: Map<LocalSttModelId, LocalSttEngine>;
requiredLocalModelIds: SherpaOnnxModelId[];
};
function buildModelDownloadHint(modelId: SherpaOnnxModelId): string {
return `Use 'paseo speech download --model ${modelId}' to download this model.`;
}
function resolveRequestedSpeechProviders(
speechConfig: PaseoSpeechConfig | null
): RequestedSpeechProviders {
return { return {
dictationSttProvider: speechConfig?.dictationSttProvider ?? "local", dictationSttProvider: speechConfig?.dictationSttProvider ?? "local",
voiceSttProvider: speechConfig?.voiceSttProvider ?? "local", voiceSttProvider: speechConfig?.voiceSttProvider ?? "local",
@@ -54,13 +78,9 @@ function resolveSpeechProviders(
}; };
} }
function resolveLocalModels( function resolveConfiguredLocalModels(
speechConfig: PaseoSpeechConfig | null speechConfig: PaseoSpeechConfig | null
): { ): ResolvedLocalModels {
dictationLocalSttModel: LocalSttModelId;
voiceLocalSttModel: LocalSttModelId;
voiceLocalTtsModel: LocalTtsModelId;
} {
return { return {
dictationLocalSttModel: LocalSttModelIdSchema.parse( dictationLocalSttModel: LocalSttModelIdSchema.parse(
speechConfig?.dictationLocalSttModel ?? DEFAULT_LOCAL_STT_MODEL speechConfig?.dictationLocalSttModel ?? DEFAULT_LOCAL_STT_MODEL
@@ -74,12 +94,8 @@ function resolveLocalModels(
}; };
} }
function computeDefaultLocalModelIds(params: { function computeRequiredLocalModelIds(params: {
providers: { providers: RequestedSpeechProviders;
dictationSttProvider: SpeechProviderId;
voiceSttProvider: SpeechProviderId;
voiceTtsProvider: SpeechProviderId;
};
models: { models: {
dictationLocalSttModel: SherpaOnnxModelId; dictationLocalSttModel: SherpaOnnxModelId;
voiceLocalSttModel: SherpaOnnxModelId; voiceLocalSttModel: SherpaOnnxModelId;
@@ -99,6 +115,50 @@ function computeDefaultLocalModelIds(params: {
return Array.from(ids); return Array.from(ids);
} }
function resolveOpenAiCredentials(openaiConfig?: PaseoOpenAIConfig): OpenAiCredentialState {
const openaiApiKey = openaiConfig?.apiKey;
return {
openaiSttApiKey: openaiConfig?.stt?.apiKey ?? openaiApiKey,
openaiTtsApiKey: openaiConfig?.tts?.apiKey ?? openaiApiKey,
openaiDictationApiKey: openaiApiKey,
};
}
function validateOpenAiCredentialRequirements(params: {
providers: RequestedSpeechProviders;
openAiCredentials: OpenAiCredentialState;
logger: Logger;
}): void {
const { providers, openAiCredentials, logger } = params;
const missingOpenAiCredentialsFor: string[] = [];
if (providers.voiceSttProvider === "openai" && !openAiCredentials.openaiSttApiKey) {
missingOpenAiCredentialsFor.push("voice.stt");
}
if (providers.voiceTtsProvider === "openai" && !openAiCredentials.openaiTtsApiKey) {
missingOpenAiCredentialsFor.push("voice.tts");
}
if (providers.dictationSttProvider === "openai" && !openAiCredentials.openaiDictationApiKey) {
missingOpenAiCredentialsFor.push("dictation.stt");
}
if (missingOpenAiCredentialsFor.length > 0) {
logger.error(
{
requestedProviders: {
dictationStt: providers.dictationSttProvider,
voiceStt: providers.voiceSttProvider,
voiceTts: providers.voiceTtsProvider,
},
missingOpenAiCredentialsFor,
},
"Invalid speech configuration: OpenAI provider selected but credentials are missing"
);
throw new Error(
`Missing OpenAI credentials for configured speech features: ${missingOpenAiCredentialsFor.join(", ")}`
);
}
}
async function createLocalSttEngine(params: { async function createLocalSttEngine(params: {
modelId: LocalSttModelId; modelId: LocalSttModelId;
modelsDir: string; modelsDir: string;
@@ -172,95 +232,25 @@ async function createLocalSttEngine(params: {
throw new Error(`Unsupported local STT model '${modelId}'`); throw new Error(`Unsupported local STT model '${modelId}'`);
} }
export type InitializedSpeechRuntime = { async function initializeLocalSpeechServices(params: {
sttService: SpeechToTextProvider | null; providers: RequestedSpeechProviders;
ttsService: TextToSpeechProvider | null; localConfig: NonNullable<PaseoSpeechConfig["local"]> | null;
dictationSttService: SpeechToTextProvider | null; localModels: ResolvedLocalModels;
cleanup: () => void; speechConfig: PaseoSpeechConfig | null;
localModelConfig: {
modelsDir: string;
defaultModelIds: SherpaOnnxModelId[];
} | null;
};
export async function initializeSpeechRuntime(params: {
logger: Logger; logger: Logger;
openaiConfig?: PaseoOpenAIConfig; }): Promise<InitializedLocalSpeech> {
speechConfig?: PaseoSpeechConfig; const { providers, localConfig, localModels, speechConfig, logger } = params;
}): Promise<InitializedSpeechRuntime> {
const logger = params.logger;
const speechConfig = params.speechConfig ?? null;
const localConfig = speechConfig?.local ?? null;
const openaiConfig = params.openaiConfig;
const providers = resolveSpeechProviders(speechConfig); const sttServices = {
const localModels = resolveLocalModels(speechConfig); sttService: null as SpeechToTextProvider | null,
ttsService: null as TextToSpeechProvider | null,
dictationSttService: null as SpeechToTextProvider | null,
};
const wantsLocalDictation = providers.dictationSttProvider === "local"; const localSttEngines = new Map<LocalSttModelId, LocalSttEngine>();
const wantsLocalVoiceStt = providers.voiceSttProvider === "local";
const wantsLocalVoiceTts = providers.voiceTtsProvider === "local";
const openaiApiKey = openaiConfig?.apiKey;
const openaiSttApiKey = openaiConfig?.stt?.apiKey ?? openaiApiKey;
const openaiTtsApiKey = openaiConfig?.tts?.apiKey ?? openaiApiKey;
const openaiDictationApiKey = openaiApiKey;
const missingOpenAiCredentialsFor: string[] = [];
if (providers.voiceSttProvider === "openai" && !openaiSttApiKey) {
missingOpenAiCredentialsFor.push("voice.stt");
}
if (providers.voiceTtsProvider === "openai" && !openaiTtsApiKey) {
missingOpenAiCredentialsFor.push("voice.tts");
}
if (providers.dictationSttProvider === "openai" && !openaiDictationApiKey) {
missingOpenAiCredentialsFor.push("dictation.stt");
}
if (missingOpenAiCredentialsFor.length > 0) {
logger.error(
{
requestedProviders: {
dictationStt: providers.dictationSttProvider,
voiceStt: providers.voiceSttProvider,
voiceTts: providers.voiceTtsProvider,
},
missingOpenAiCredentialsFor,
},
"Invalid speech configuration: OpenAI provider selected but credentials are missing"
);
throw new Error(
`Missing OpenAI credentials for configured speech features: ${missingOpenAiCredentialsFor.join(", ")}`
);
}
logger.info(
{
requestedProviders: {
dictationStt: providers.dictationSttProvider,
voiceStt: providers.voiceSttProvider,
voiceTts: providers.voiceTtsProvider,
},
availability: {
openai: {
stt: Boolean(openaiSttApiKey),
tts: Boolean(openaiTtsApiKey),
dictationStt: Boolean(openaiDictationApiKey),
},
local: {
configured: Boolean(localConfig),
modelsDir: localConfig?.modelsDir ?? null,
autoDownload: localConfig?.autoDownload ?? null,
},
},
},
"Speech provider reconciliation started"
);
let sttService: SpeechToTextProvider | null = null;
let ttsService: TextToSpeechProvider | null = null;
let dictationSttService: SpeechToTextProvider | null = null;
let localVoiceTtsProvider: TextToSpeechProvider | null = null; let localVoiceTtsProvider: TextToSpeechProvider | null = null;
const requiredLocalModelIds = computeDefaultLocalModelIds({ const requiredLocalModelIds = computeRequiredLocalModelIds({
providers, providers,
models: localModels, models: localModels,
}); });
@@ -289,15 +279,14 @@ export async function initializeSpeechRuntime(params: {
modelIds: requiredLocalModelIds, modelIds: requiredLocalModelIds,
autoDownload: localConfig.autoDownload ?? true, autoDownload: localConfig.autoDownload ?? true,
hint: hint:
"Run: npm run dev --workspace=@getpaseo/server, then run: " + "Use `paseo speech models` to inspect status and " +
"`tsx packages/server/scripts/download-speech-models.ts --models-dir <DIR> --model <MODEL_ID>`", "`paseo speech download --model <MODEL_ID>` to fetch missing models.",
}, },
"Failed to ensure local speech models" "Failed to ensure local speech models"
); );
} }
} }
const localSttEngines = new Map<LocalSttModelId, LocalSttEngine>();
const getLocalSttEngine = async ( const getLocalSttEngine = async (
modelId: LocalSttModelId modelId: LocalSttModelId
): Promise<LocalSttEngine | null> => { ): Promise<LocalSttEngine | null> => {
@@ -322,7 +311,7 @@ export async function initializeSpeechRuntime(params: {
err, err,
modelsDir: localConfig.modelsDir, modelsDir: localConfig.modelsDir,
modelId, modelId,
hint: buildModelDownloadHint(localConfig.modelsDir, modelId), hint: buildModelDownloadHint(modelId),
}, },
"Failed to initialize local STT engine (models missing or invalid)" "Failed to initialize local STT engine (models missing or invalid)"
); );
@@ -330,7 +319,7 @@ export async function initializeSpeechRuntime(params: {
} }
}; };
if (wantsLocalVoiceStt) { if (providers.voiceSttProvider === "local") {
if (!localConfig) { if (!localConfig) {
logger.warn( logger.warn(
{ configured: false }, { configured: false },
@@ -339,14 +328,14 @@ export async function initializeSpeechRuntime(params: {
} else { } else {
const voiceEngine = await getLocalSttEngine(localModels.voiceLocalSttModel); const voiceEngine = await getLocalSttEngine(localModels.voiceLocalSttModel);
if (voiceEngine?.kind === "offline") { if (voiceEngine?.kind === "offline") {
sttService = new SherpaOnnxParakeetSTT({ engine: voiceEngine.engine }, logger); sttServices.sttService = new SherpaOnnxParakeetSTT({ engine: voiceEngine.engine }, logger);
} else if (voiceEngine?.kind === "online") { } else if (voiceEngine?.kind === "online") {
sttService = new SherpaOnnxSTT({ engine: voiceEngine.engine }, logger); sttServices.sttService = new SherpaOnnxSTT({ engine: voiceEngine.engine }, logger);
} }
} }
} }
if (wantsLocalDictation) { if (providers.dictationSttProvider === "local") {
if (!localConfig) { if (!localConfig) {
logger.warn( logger.warn(
{ configured: false }, { configured: false },
@@ -355,13 +344,13 @@ export async function initializeSpeechRuntime(params: {
} else { } else {
const dictationEngine = await getLocalSttEngine(localModels.dictationLocalSttModel); const dictationEngine = await getLocalSttEngine(localModels.dictationLocalSttModel);
if (dictationEngine?.kind === "offline") { if (dictationEngine?.kind === "offline") {
dictationSttService = { sttServices.dictationSttService = {
id: "local", id: "local",
createSession: () => createSession: () =>
new SherpaParakeetRealtimeTranscriptionSession({ engine: dictationEngine.engine }), new SherpaParakeetRealtimeTranscriptionSession({ engine: dictationEngine.engine }),
}; };
} else if (dictationEngine?.kind === "online") { } else if (dictationEngine?.kind === "online") {
dictationSttService = { sttServices.dictationSttService = {
id: "local", id: "local",
createSession: () => new SherpaRealtimeTranscriptionSession({ engine: dictationEngine.engine }), createSession: () => new SherpaRealtimeTranscriptionSession({ engine: dictationEngine.engine }),
}; };
@@ -369,7 +358,7 @@ export async function initializeSpeechRuntime(params: {
} }
} }
if (wantsLocalVoiceTts) { if (providers.voiceTtsProvider === "local") {
if (!localConfig) { if (!localConfig) {
logger.warn( logger.warn(
{ configured: false }, { configured: false },
@@ -399,14 +388,14 @@ export async function initializeSpeechRuntime(params: {
logger logger
); );
} }
ttsService = localVoiceTtsProvider; sttServices.ttsService = localVoiceTtsProvider;
} catch (err) { } catch (err) {
logger.error( logger.error(
{ {
err, err,
modelsDir: localConfig.modelsDir, modelsDir: localConfig.modelsDir,
modelId: localModels.voiceLocalTtsModel, modelId: localModels.voiceLocalTtsModel,
hint: buildModelDownloadHint(localConfig.modelsDir, localModels.voiceLocalTtsModel), hint: buildModelDownloadHint(localModels.voiceLocalTtsModel),
}, },
"Failed to initialize local TTS engine (models missing or invalid)" "Failed to initialize local TTS engine (models missing or invalid)"
); );
@@ -414,34 +403,65 @@ export async function initializeSpeechRuntime(params: {
} }
} }
return {
...sttServices,
localVoiceTtsProvider,
localSttEngines,
requiredLocalModelIds,
};
}
function initializeOpenAiSpeechServices(params: {
providers: RequestedSpeechProviders;
openaiConfig?: PaseoOpenAIConfig;
openAiCredentials: OpenAiCredentialState;
existing: {
sttService: SpeechToTextProvider | null;
ttsService: TextToSpeechProvider | null;
dictationSttService: SpeechToTextProvider | null;
};
logger: Logger;
}): {
sttService: SpeechToTextProvider | null;
ttsService: TextToSpeechProvider | null;
dictationSttService: SpeechToTextProvider | null;
} {
const { providers, openaiConfig, openAiCredentials, existing, logger } = params;
let sttService = existing.sttService;
let ttsService = existing.ttsService;
let dictationSttService = existing.dictationSttService;
const needsOpenAiStt = !sttService && providers.voiceSttProvider === "openai"; const needsOpenAiStt = !sttService && providers.voiceSttProvider === "openai";
const needsOpenAiTts = !ttsService && providers.voiceTtsProvider === "openai"; const needsOpenAiTts = !ttsService && providers.voiceTtsProvider === "openai";
const needsOpenAiDictation = !dictationSttService && providers.dictationSttProvider === "openai"; const needsOpenAiDictation = !dictationSttService && providers.dictationSttProvider === "openai";
if ( if (
(needsOpenAiStt || needsOpenAiTts || needsOpenAiDictation) && (needsOpenAiStt || needsOpenAiTts || needsOpenAiDictation) &&
(openaiSttApiKey || openaiTtsApiKey || openaiDictationApiKey) (openAiCredentials.openaiSttApiKey ||
openAiCredentials.openaiTtsApiKey ||
openAiCredentials.openaiDictationApiKey)
) { ) {
logger.info("OpenAI speech provider initialized"); logger.info("OpenAI speech provider initialized");
if (needsOpenAiStt && openaiSttApiKey) { if (needsOpenAiStt && openAiCredentials.openaiSttApiKey) {
const { apiKey: _sttApiKey, ...sttConfig } = openaiConfig?.stt ?? {}; const { apiKey: _sttApiKey, ...sttConfig } = openaiConfig?.stt ?? {};
sttService = new OpenAISTT( sttService = new OpenAISTT(
{ {
apiKey: openaiSttApiKey, apiKey: openAiCredentials.openaiSttApiKey,
...sttConfig, ...sttConfig,
}, },
logger logger
); );
} }
if (needsOpenAiTts && openaiTtsApiKey) { if (needsOpenAiTts && openAiCredentials.openaiTtsApiKey) {
const { apiKey: _ttsApiKey, ...ttsConfig } = openaiConfig?.tts ?? {}; const { apiKey: _ttsApiKey, ...ttsConfig } = openaiConfig?.tts ?? {};
ttsService = new OpenAITTS( ttsService = new OpenAITTS(
{ {
apiKey: openaiTtsApiKey, apiKey: openAiCredentials.openaiTtsApiKey,
voice: "alloy", voice: "alloy",
model: "tts-1", model: DEFAULT_OPENAI_TTS_MODEL,
responseFormat: "pcm", responseFormat: "pcm",
...ttsConfig, ...ttsConfig,
}, },
@@ -449,12 +469,13 @@ export async function initializeSpeechRuntime(params: {
); );
} }
if (needsOpenAiDictation && openaiDictationApiKey) { const dictationApiKey = openAiCredentials.openaiDictationApiKey;
if (needsOpenAiDictation && dictationApiKey) {
dictationSttService = { dictationSttService = {
id: "openai", id: "openai",
createSession: ({ logger: sessionLogger, language, prompt }) => createSession: ({ logger: sessionLogger, language, prompt }) =>
new OpenAIRealtimeTranscriptionSession({ new OpenAIRealtimeTranscriptionSession({
apiKey: openaiDictationApiKey, apiKey: dictationApiKey,
logger: sessionLogger, logger: sessionLogger,
transcriptionModel: transcriptionModel:
openaiConfig?.realtimeTranscriptionModel openaiConfig?.realtimeTranscriptionModel
@@ -469,15 +490,101 @@ export async function initializeSpeechRuntime(params: {
logger.warn("OpenAI speech providers are configured but credentials are missing"); logger.warn("OpenAI speech providers are configured but credentials are missing");
} }
return {
sttService,
ttsService,
dictationSttService,
};
}
export type InitializedSpeechRuntime = {
sttService: SpeechToTextProvider | null;
ttsService: TextToSpeechProvider | null;
dictationSttService: SpeechToTextProvider | null;
cleanup: () => void;
localModelConfig: {
modelsDir: string;
defaultModelIds: SherpaOnnxModelId[];
} | null;
};
export async function initializeSpeechRuntime(params: {
logger: Logger;
openaiConfig?: PaseoOpenAIConfig;
speechConfig?: PaseoSpeechConfig;
}): Promise<InitializedSpeechRuntime> {
const logger = params.logger;
const speechConfig = params.speechConfig ?? null;
const localConfig = speechConfig?.local ?? null;
const openaiConfig = params.openaiConfig;
const providers = resolveRequestedSpeechProviders(speechConfig);
const localModels = resolveConfiguredLocalModels(speechConfig);
const openAiCredentials = resolveOpenAiCredentials(openaiConfig);
validateOpenAiCredentialRequirements({
providers,
openAiCredentials,
logger,
});
logger.info(
{
requestedProviders: {
dictationStt: providers.dictationSttProvider,
voiceStt: providers.voiceSttProvider,
voiceTts: providers.voiceTtsProvider,
},
availability: {
openai: {
stt: Boolean(openAiCredentials.openaiSttApiKey),
tts: Boolean(openAiCredentials.openaiTtsApiKey),
dictationStt: Boolean(openAiCredentials.openaiDictationApiKey),
},
local: {
configured: Boolean(localConfig),
modelsDir: localConfig?.modelsDir ?? null,
autoDownload: localConfig?.autoDownload ?? null,
},
},
},
"Speech provider reconciliation started"
);
const localSpeech = await initializeLocalSpeechServices({
providers,
localConfig,
localModels,
speechConfig,
logger,
});
const openAiSpeech = initializeOpenAiSpeechServices({
providers,
openaiConfig,
openAiCredentials,
existing: {
sttService: localSpeech.sttService,
ttsService: localSpeech.ttsService,
dictationSttService: localSpeech.dictationSttService,
},
logger,
});
const effectiveProviders = { const effectiveProviders = {
dictationStt: dictationSttService?.id ?? "unavailable", dictationStt: openAiSpeech.dictationSttService?.id ?? "unavailable",
voiceStt: sttService?.id ?? "unavailable", voiceStt: openAiSpeech.sttService?.id ?? "unavailable",
voiceTts: !ttsService ? "unavailable" : ttsService === localVoiceTtsProvider ? "local" : "openai", voiceTts:
!openAiSpeech.ttsService
? "unavailable"
: openAiSpeech.ttsService === localSpeech.localVoiceTtsProvider
? "local"
: "openai",
}; };
const unavailableFeatures = [ const unavailableFeatures = [
!dictationSttService ? "dictation.stt" : null, !openAiSpeech.dictationSttService ? "dictation.stt" : null,
!sttService ? "voice.stt" : null, !openAiSpeech.sttService ? "voice.stt" : null,
!ttsService ? "voice.tts" : null, !openAiSpeech.ttsService ? "voice.tts" : null,
].filter((feature): feature is string => feature !== null); ].filter((feature): feature is string => feature !== null);
if (unavailableFeatures.length > 0) { if (unavailableFeatures.length > 0) {
@@ -504,25 +611,25 @@ export async function initializeSpeechRuntime(params: {
); );
const cleanup = () => { const cleanup = () => {
const maybeFreeable = localVoiceTtsProvider as unknown as { free?: () => void } | null; const maybeFreeable = localSpeech.localVoiceTtsProvider as unknown as { free?: () => void } | null;
if (typeof maybeFreeable?.free === "function") { if (typeof maybeFreeable?.free === "function") {
maybeFreeable.free(); maybeFreeable.free();
} }
for (const engine of localSttEngines.values()) { for (const engine of localSpeech.localSttEngines.values()) {
engine.engine.free(); engine.engine.free();
} }
}; };
return { return {
sttService, sttService: openAiSpeech.sttService,
ttsService, ttsService: openAiSpeech.ttsService,
dictationSttService, dictationSttService: openAiSpeech.dictationSttService,
cleanup, cleanup,
localModelConfig: localModelConfig:
localConfig localConfig
? { ? {
modelsDir: localConfig.modelsDir, modelsDir: localConfig.modelsDir,
defaultModelIds: requiredLocalModelIds, defaultModelIds: localSpeech.requiredLocalModelIds,
} }
: null, : null,
}; };
@@ -1,10 +1,4 @@
import type { import { z } from "zod";
LocalSttModelId,
LocalTtsModelId,
} from "./providers/local/sherpa/model-catalog.js";
export type SpeechProviderId = "openai" | "local"; export const SpeechProviderIdSchema = z.enum(["openai", "local"]);
export type SpeechProviderId = z.infer<typeof SpeechProviderIdSchema>;
export const DEFAULT_LOCAL_STT_MODEL: LocalSttModelId = "parakeet-tdt-0.6b-v3-int8";
export const DEFAULT_LOCAL_TTS_MODEL: LocalTtsModelId = "pocket-tts-onnx-int8";
export const DEFAULT_OPENAI_REALTIME_TRANSCRIPTION_MODEL = "gpt-4o-transcribe";