simplify speech bootstrap and clean daemon startup locking

This commit is contained in:
Mohamed Boudra
2026-02-17 12:16:02 +07:00
parent efde557d6f
commit 34e35621f0
24 changed files with 68 additions and 136 deletions
-1
View File
@@ -156,7 +156,6 @@ export default async function globalSetup() {
// Keep e2e bootstrap fast and deterministic; terminal/sidebar tests do not need speech. // Keep e2e bootstrap fast and deterministic; terminal/sidebar tests do not need speech.
PASEO_DICTATION_ENABLED: "0", PASEO_DICTATION_ENABLED: "0",
PASEO_VOICE_MODE_ENABLED: "0", PASEO_VOICE_MODE_ENABLED: "0",
PASEO_LOCAL_AUTO_DOWNLOAD: "0",
NODE_ENV: 'development', NODE_ENV: 'development',
}, },
stdio: ['ignore', 'pipe', 'pipe'], stdio: ['ignore', 'pipe', 'pipe'],
+1 -11
View File
@@ -25,9 +25,6 @@ interface OnboardOptions extends DaemonStartOptions {
} }
type OnboardPersistedConfig = PersistedConfig & { type OnboardPersistedConfig = PersistedConfig & {
providers?: PersistedConfig['providers'] & {
local?: PersistedConfig['providers'] extends { local?: infer T } ? T : { autoDownload?: boolean }
}
features?: PersistedConfig['features'] & { features?: PersistedConfig['features'] & {
dictation?: PersistedConfig['features'] extends { dictation?: infer T } dictation?: PersistedConfig['features'] extends { dictation?: infer T }
? T & { enabled?: boolean } ? T & { enabled?: boolean }
@@ -103,13 +100,6 @@ function savePersistedConfig(paseoHome: string, config: OnboardPersistedConfig):
function applyVoiceSelection(config: OnboardPersistedConfig, enabled: boolean): OnboardPersistedConfig { function applyVoiceSelection(config: OnboardPersistedConfig, enabled: boolean): OnboardPersistedConfig {
return { return {
...config, ...config,
providers: {
...config.providers,
local: {
...config.providers?.local,
autoDownload: enabled,
},
},
features: { features: {
...config.features, ...config.features,
dictation: { dictation: {
@@ -366,7 +356,7 @@ export async function runOnboard(options: OnboardOptions): Promise<void> {
const config = loadConfig(paseoHome, { cli: toCliOverrides(options) }) const config = loadConfig(paseoHome, { cli: toCliOverrides(options) })
const voiceStatus = voiceEnabled const voiceStatus = voiceEnabled
? 'Voice features enabled. Local speech models will download in the background if missing.' ? 'Voice features enabled. Local speech models will be downloaded automatically if missing.'
: 'Voice features disabled. Local speech models will not be downloaded.' : 'Voice features disabled. Local speech models will not be downloaded.'
log.message(voiceStatus) log.message(voiceStatus)
-4
View File
@@ -46,14 +46,10 @@ try {
dictation?: { enabled?: boolean } dictation?: { enabled?: boolean }
voiceMode?: { enabled?: boolean } voiceMode?: { enabled?: boolean }
} }
providers?: {
local?: { autoDownload?: boolean }
}
} }
assert.strictEqual(config.features?.dictation?.enabled, false, 'dictation.enabled should be false') assert.strictEqual(config.features?.dictation?.enabled, false, 'dictation.enabled should be false')
assert.strictEqual(config.features?.voiceMode?.enabled, false, 'voiceMode.enabled should be false') assert.strictEqual(config.features?.voiceMode?.enabled, false, 'voiceMode.enabled should be false')
assert.strictEqual(config.providers?.local?.autoDownload, false, 'local.autoDownload should be false')
const daemonLog = await readFile(join(paseoHome, 'daemon.log'), 'utf-8') const daemonLog = await readFile(join(paseoHome, 'daemon.log'), 'utf-8')
assert( assert(
!daemonLog.includes('Ensuring local speech models'), !daemonLog.includes('Ensuring local speech models'),
+6
View File
@@ -1,7 +1,13 @@
import { fileURLToPath } from "url"; import { fileURLToPath } from "url";
import { existsSync } from "node:fs"; import { existsSync } from "node:fs";
import dotenv from "dotenv";
import { runSupervisor } from "./supervisor.js"; import { runSupervisor } from "./supervisor.js";
dotenv.config({
path: fileURLToPath(new URL("../.env", import.meta.url)),
quiet: true,
});
const WORKER_ENTRY = fileURLToPath(new URL("../src/server/index.ts", import.meta.url)); const WORKER_ENTRY = fileURLToPath(new URL("../src/server/index.ts", import.meta.url));
if (!existsSync(WORKER_ENTRY)) { if (!existsSync(WORKER_ENTRY)) {
throw new Error(`Dev worker entry not found: ${WORKER_ENTRY}`); throw new Error(`Dev worker entry not found: ${WORKER_ENTRY}`);
@@ -42,5 +42,5 @@ function parseArgs(argv: string[]): { modelsDir: string; modelIds: LocalSpeechMo
const logger = createRootLogger({ level: "info", format: "pretty" }); const logger = createRootLogger({ level: "info", format: "pretty" });
const { modelsDir, modelIds } = parseArgs(process.argv.slice(2)); const { modelsDir, modelIds } = parseArgs(process.argv.slice(2));
await ensureLocalSpeechModels({ modelsDir, modelIds, autoDownload: true, logger }); await ensureLocalSpeechModels({ modelsDir, modelIds, logger });
logger.info({ modelsDir, modelIds }, "Done downloading speech models"); logger.info({ modelsDir, modelIds }, "Done downloading speech models");
@@ -284,7 +284,6 @@ logger.info(
await ensureLocalSpeechModels({ await ensureLocalSpeechModels({
modelsDir: options.modelsDir, modelsDir: options.modelsDir,
modelIds: options.modelIds, modelIds: options.modelIds,
autoDownload: true,
logger, logger,
}); });
@@ -18,12 +18,11 @@ type CliOptions = {
outPath?: string; outPath?: string;
model: LocalSttModelId; model: LocalSttModelId;
modelsDir: string; modelsDir: string;
autoDownload: boolean;
}; };
function usage(): string { function usage(): string {
return [ return [
"Usage: npm run speech:transcribe:local -- <wavPath> [--out <outPath>] [--model <modelId>] [--models-dir <dir>] [--no-auto-download]", "Usage: npm run speech:transcribe:local -- <wavPath> [--out <outPath>] [--model <modelId>] [--models-dir <dir>]",
"", "",
"Examples:", "Examples:",
" npm run speech:transcribe:local -- ./sample.wav", " npm run speech:transcribe:local -- ./sample.wav",
@@ -52,7 +51,6 @@ function parseArgs(argv: string[]): CliOptions {
let outPath: string | undefined; let outPath: string | undefined;
let model = LocalSttModelIdSchema.parse(process.env.PASEO_LOCAL_STT_MODEL ?? DEFAULT_LOCAL_STT_MODEL); let model = LocalSttModelIdSchema.parse(process.env.PASEO_LOCAL_STT_MODEL ?? DEFAULT_LOCAL_STT_MODEL);
let modelsDir = defaultModelsDir; let modelsDir = defaultModelsDir;
let autoDownload = true;
for (let i = 0; i < argv.length; i++) { for (let i = 0; i < argv.length; i++) {
const arg = argv[i]; const arg = argv[i];
@@ -87,11 +85,6 @@ function parseArgs(argv: string[]): CliOptions {
continue; continue;
} }
if (arg === "--no-auto-download") {
autoDownload = false;
continue;
}
if (arg.startsWith("-")) { if (arg.startsWith("-")) {
throw new Error(`Unknown option: ${arg}`); throw new Error(`Unknown option: ${arg}`);
} }
@@ -108,7 +101,6 @@ function parseArgs(argv: string[]): CliOptions {
...(outPath ? { outPath } : {}), ...(outPath ? { outPath } : {}),
model, model,
modelsDir, modelsDir,
autoDownload,
}; };
} }
@@ -139,7 +131,6 @@ async function main(): Promise<void> {
providers, providers,
local: { local: {
modelsDir: options.modelsDir, modelsDir: options.modelsDir,
autoDownload: options.autoDownload,
models: { models: {
dictationStt: options.model, dictationStt: options.model,
voiceStt: options.model, voiceStt: options.model,
+1 -11
View File
@@ -63,7 +63,7 @@ import type {
AgentProvider, AgentProvider,
} from "./agent/agent-sdk-types.js"; } from "./agent/agent-sdk-types.js";
import type { AgentProviderRuntimeSettingsMap } from "./agent/provider-launch-config.js"; import type { AgentProviderRuntimeSettingsMap } from "./agent/provider-launch-config.js";
import { acquirePidLock, releasePidLock, isLocked, PidLockError } from "./pid-lock.js"; import { acquirePidLock, releasePidLock } from "./pid-lock.js";
import { isHostAllowed, type AllowedHostsConfig } from "./allowed-hosts.js"; import { isHostAllowed, type AllowedHostsConfig } from "./allowed-hosts.js";
import { import {
createVoiceMcpSocketBridgeManager, createVoiceMcpSocketBridgeManager,
@@ -137,16 +137,6 @@ export async function createPaseoDaemon(
): Promise<PaseoDaemon> { ): Promise<PaseoDaemon> {
const logger = rootLogger.child({ module: "bootstrap" }); const logger = rootLogger.child({ module: "bootstrap" });
// Fail fast before expensive bootstrap (speech/runtime/model checks) if another daemon is active.
const lockState = await isLocked(config.paseoHome);
if (lockState.locked) {
const existingLock = lockState.info;
throw new PidLockError(
`Another Paseo daemon is already running (PID ${existingLock?.pid ?? "unknown"}, started ${existingLock?.startedAt ?? "unknown"})`,
existingLock
);
}
// Acquire PID lock before expensive bootstrap work so duplicate starts fail immediately. // Acquire PID lock before expensive bootstrap work so duplicate starts fail immediately.
await acquirePidLock(config.paseoHome, config.listen); await acquirePidLock(config.paseoHome, config.listen);
@@ -186,7 +186,8 @@ 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_DICTATION_ENABLED: "0",
PASEO_VOICE_MODE_ENABLED: "0",
PASEO_LOG_FORMAT: "json", PASEO_LOG_FORMAT: "json",
}; };
-6
View File
@@ -1,9 +1,3 @@
import dotenv from "dotenv";
import { fileURLToPath } from "node:url";
import path from "node:path";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
dotenv.config({ path: path.resolve(__dirname, "../../.env") });
import { createPaseoDaemon } from "./bootstrap.js"; import { createPaseoDaemon } from "./bootstrap.js";
import { loadConfig } from "./config.js"; import { loadConfig } from "./config.js";
import { resolvePaseoHome } from "./paseo-home.js"; import { resolvePaseoHome } from "./paseo-home.js";
+30 -2
View File
@@ -22,7 +22,6 @@ const ProviderCredentialsSchema = z
const LocalSpeechProviderSchema = z const LocalSpeechProviderSchema = z
.object({ .object({
modelsDir: z.string().min(1).optional(), modelsDir: z.string().min(1).optional(),
autoDownload: z.boolean().optional(),
}) })
.strict(); .strict();
@@ -175,6 +174,34 @@ function getLogger(logger: LoggerLike | undefined): LoggerLike | undefined {
return logger?.child({ module: "config" }); return logger?.child({ module: "config" });
} }
function stripDeprecatedLocalSpeechConfigFields(parsed: unknown): unknown {
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return parsed;
}
const root = { ...(parsed as Record<string, unknown>) };
const providers = root.providers;
if (!providers || typeof providers !== "object" || Array.isArray(providers)) {
return root;
}
const providersRecord = { ...(providers as Record<string, unknown>) };
const local = providersRecord.local;
if (!local || typeof local !== "object" || Array.isArray(local)) {
root.providers = providersRecord;
return root;
}
const localRecord = { ...(local as Record<string, unknown>) };
if ("autoDownload" in localRecord) {
delete localRecord.autoDownload;
}
providersRecord.local = localRecord;
root.providers = providersRecord;
return root;
}
export function loadPersistedConfig( export function loadPersistedConfig(
paseoHome: string, paseoHome: string,
logger?: LoggerLike logger?: LoggerLike
@@ -209,7 +236,8 @@ export function loadPersistedConfig(
throw new Error(`[Config] Invalid JSON in ${configPath}: ${message}`); throw new Error(`[Config] Invalid JSON in ${configPath}: ${message}`);
} }
const result = PersistedConfigSchema.safeParse(parsed); const migrated = stripDeprecatedLocalSpeechConfigFields(parsed);
const result = PersistedConfigSchema.safeParse(migrated);
if (!result.success) { if (!result.success) {
const issues = result.error.issues const issues = result.error.issues
.map((i) => ` - ${i.path.join(".")}: ${i.message}`) .map((i) => ` - ${i.path.join(".")}: ${i.message}`)
-1
View File
@@ -2879,7 +2879,6 @@ export class Session {
await ensureLocalSpeechModels({ await ensureLocalSpeechModels({
modelsDir, modelsDir,
modelIds, modelIds,
autoDownload: true,
logger: this.sessionLogger, logger: this.sessionLogger,
}); });
this.emit({ this.emit({
@@ -24,7 +24,6 @@ export type LocalSpeechModelConfig = {
export type LocalSpeechProviderConfig = { export type LocalSpeechProviderConfig = {
modelsDir: string; modelsDir: string;
autoDownload?: boolean;
models: LocalSpeechModelConfig; models: LocalSpeechModelConfig;
}; };
@@ -36,17 +35,6 @@ export type { LocalSpeechModelId, LocalSttModelId, LocalTtsModelId };
const DEFAULT_LOCAL_MODELS_SUBDIR = path.join("models", "local-speech"); const DEFAULT_LOCAL_MODELS_SUBDIR = path.join("models", "local-speech");
const BooleanStringSchema = z
.string()
.trim()
.toLowerCase()
.pipe(z.enum(["1", "0", "true", "false", "yes", "no"]))
.transform((value) => value === "1" || value === "true" || value === "yes");
const OptionalBooleanFlagSchema = z
.union([z.boolean(), BooleanStringSchema])
.optional();
const NumberLikeSchema = z.union([ const NumberLikeSchema = z.union([
z.number(), z.number(),
z.string().trim().min(1), z.string().trim().min(1),
@@ -63,7 +51,6 @@ const OptionalIntegerSchema = NumberLikeSchema
const LocalSpeechResolutionSchema = z.object({ const LocalSpeechResolutionSchema = z.object({
includeProviderConfig: z.boolean(), includeProviderConfig: z.boolean(),
modelsDir: z.string().trim().min(1), modelsDir: z.string().trim().min(1),
autoDownload: OptionalBooleanFlagSchema.default(true),
dictationLocalSttModel: LocalSttModelIdSchema.default(DEFAULT_LOCAL_STT_MODEL), dictationLocalSttModel: LocalSttModelIdSchema.default(DEFAULT_LOCAL_STT_MODEL),
voiceLocalSttModel: LocalSttModelIdSchema.default(DEFAULT_LOCAL_STT_MODEL), voiceLocalSttModel: LocalSttModelIdSchema.default(DEFAULT_LOCAL_STT_MODEL),
voiceLocalTtsModel: LocalTtsModelIdSchema.default(DEFAULT_LOCAL_TTS_MODEL), voiceLocalTtsModel: LocalTtsModelIdSchema.default(DEFAULT_LOCAL_TTS_MODEL),
@@ -98,7 +85,7 @@ function shouldIncludeLocalProviderConfig(params: {
return ( return (
localRequestedByFeature || localRequestedByFeature ||
params.env.PASEO_LOCAL_MODELS_DIR !== undefined || params.env.PASEO_LOCAL_MODELS_DIR !== undefined ||
params.persisted.providers?.local !== undefined params.persisted.providers?.local?.modelsDir !== undefined
); );
} }
@@ -116,9 +103,6 @@ export function resolveLocalSpeechConfig(params: {
params.env.PASEO_LOCAL_MODELS_DIR ?? params.env.PASEO_LOCAL_MODELS_DIR ??
params.persisted.providers?.local?.modelsDir ?? params.persisted.providers?.local?.modelsDir ??
path.join(params.paseoHome, DEFAULT_LOCAL_MODELS_SUBDIR), path.join(params.paseoHome, DEFAULT_LOCAL_MODELS_SUBDIR),
autoDownload:
params.env.PASEO_LOCAL_AUTO_DOWNLOAD ??
params.persisted.providers?.local?.autoDownload,
dictationLocalSttModel: dictationLocalSttModel:
params.env.PASEO_DICTATION_LOCAL_STT_MODEL ?? params.env.PASEO_DICTATION_LOCAL_STT_MODEL ??
persistedLocalFeatureModel( persistedLocalFeatureModel(
@@ -160,7 +144,6 @@ export function resolveLocalSpeechConfig(params: {
parsed.includeProviderConfig parsed.includeProviderConfig
? { ? {
modelsDir: parsed.modelsDir, modelsDir: parsed.modelsDir,
autoDownload: parsed.autoDownload,
models: { models: {
dictationStt: parsed.dictationLocalSttModel, dictationStt: parsed.dictationLocalSttModel,
voiceStt: parsed.voiceLocalSttModel, voiceStt: parsed.voiceLocalSttModel,
@@ -36,13 +36,11 @@ export function getLocalSpeechModelDir(modelsDir: string, modelId: LocalSpeechMo
export async function ensureLocalSpeechModels(options: { export async function ensureLocalSpeechModels(options: {
modelsDir: string; modelsDir: string;
modelIds: LocalSpeechModelId[]; modelIds: LocalSpeechModelId[];
autoDownload?: boolean;
logger: import("pino").Logger; logger: import("pino").Logger;
}): Promise<Record<LocalSpeechModelId, string>> { }): Promise<Record<LocalSpeechModelId, string>> {
return ensureSherpaOnnxModels({ return ensureSherpaOnnxModels({
modelsDir: options.modelsDir, modelsDir: options.modelsDir,
modelIds: options.modelIds, modelIds: options.modelIds,
autoDownload: options.autoDownload ?? true,
logger: options.logger, logger: options.logger,
}); });
} }
@@ -36,7 +36,6 @@ type ResolvedLocalModels = {
type LocalSpeechAvailability = { type LocalSpeechAvailability = {
configured: boolean; configured: boolean;
modelsDir: string | null; modelsDir: string | null;
autoDownload: boolean | null;
}; };
export type InitializedLocalSpeech = { export type InitializedLocalSpeech = {
@@ -79,7 +78,6 @@ export function getLocalSpeechAvailability(
return { return {
configured: Boolean(localConfig), configured: Boolean(localConfig),
modelsDir: localConfig?.modelsDir ?? null, modelsDir: localConfig?.modelsDir ?? null,
autoDownload: localConfig?.autoDownload ?? null,
}; };
} }
@@ -186,12 +184,10 @@ export async function initializeLocalSpeechServices(params: {
providers: RequestedSpeechProviders; providers: RequestedSpeechProviders;
speechConfig: PaseoSpeechConfig | null; speechConfig: PaseoSpeechConfig | null;
logger: Logger; logger: Logger;
modelEnsureAutoDownload?: boolean;
}): Promise<InitializedLocalSpeech> { }): Promise<InitializedLocalSpeech> {
const { providers, logger, speechConfig } = params; const { providers, logger, speechConfig } = params;
const localConfig = speechConfig?.local ?? null; const localConfig = speechConfig?.local ?? null;
const localModels = resolveConfiguredLocalModels(speechConfig); const localModels = resolveConfiguredLocalModels(speechConfig);
const modelEnsureAutoDownload = params.modelEnsureAutoDownload ?? (localConfig?.autoDownload ?? true);
let sttService: SpeechToTextProvider | null = null; let sttService: SpeechToTextProvider | null = null;
let ttsService: TextToSpeechProvider | null = null; let ttsService: TextToSpeechProvider | null = null;
@@ -209,28 +205,25 @@ export async function initializeLocalSpeechServices(params: {
{ {
modelsDir: localConfig.modelsDir, modelsDir: localConfig.modelsDir,
modelIds: requiredLocalModelIds, modelIds: requiredLocalModelIds,
autoDownload: modelEnsureAutoDownload,
}, },
"Ensuring local speech models" "Ensuring local speech models"
); );
await ensureLocalSpeechModels({ await ensureLocalSpeechModels({
modelsDir: localConfig.modelsDir, modelsDir: localConfig.modelsDir,
modelIds: requiredLocalModelIds, modelIds: requiredLocalModelIds,
autoDownload: modelEnsureAutoDownload,
logger, logger,
}); });
} catch (err) { } catch (err) {
logger.error( logger.warn(
{ {
err, err,
modelsDir: localConfig.modelsDir, modelsDir: localConfig.modelsDir,
modelIds: requiredLocalModelIds, modelIds: requiredLocalModelIds,
autoDownload: modelEnsureAutoDownload,
hint: hint:
"Use `paseo speech models` to inspect status and " + "Use `paseo speech models` to inspect status and " +
"`paseo speech download --model <MODEL_ID>` to fetch missing models.", "`paseo speech download --model <MODEL_ID>` to fetch missing models.",
}, },
"Failed to ensure local speech models" "Local speech model bootstrap failed"
); );
} }
} }
@@ -256,14 +249,14 @@ export async function initializeLocalSpeechServices(params: {
localSttEngines.set(modelId, created); localSttEngines.set(modelId, created);
return created; return created;
} catch (err) { } catch (err) {
logger.error( logger.warn(
{ {
err, err,
modelsDir: localConfig.modelsDir, modelsDir: localConfig.modelsDir,
modelId, modelId,
hint: buildModelDownloadHint(modelId), hint: buildModelDownloadHint(modelId),
}, },
"Failed to initialize local STT engine (models missing or invalid)" "Local STT engine unavailable"
); );
return null; return null;
} }
@@ -340,14 +333,14 @@ export async function initializeLocalSpeechServices(params: {
} }
ttsService = localVoiceTtsProvider; ttsService = localVoiceTtsProvider;
} catch (err) { } catch (err) {
logger.error( logger.warn(
{ {
err, err,
modelsDir: localConfig.modelsDir, modelsDir: localConfig.modelsDir,
modelId: localModels.voiceLocalTtsModel, modelId: localModels.voiceLocalTtsModel,
hint: buildModelDownloadHint(localModels.voiceLocalTtsModel), hint: buildModelDownloadHint(localModels.voiceLocalTtsModel),
}, },
"Failed to initialize local TTS engine (models missing or invalid)" "Local TTS engine unavailable"
); );
} }
} }
@@ -35,25 +35,12 @@ describe("sherpa model downloader", () => {
const out = await ensureSherpaOnnxModel({ const out = await ensureSherpaOnnxModel({
modelsDir, modelsDir,
modelId: "kitten-nano-en-v0_1-fp16", modelId: "kitten-nano-en-v0_1-fp16",
autoDownload: false,
logger, logger,
}); });
expect(out).toBe(modelDir); expect(out).toBe(modelDir);
}); });
test("ensureSherpaOnnxModel throws when missing and autoDownload is false", async () => {
const modelsDir = makeTmpDir();
await expect(
ensureSherpaOnnxModel({
modelsDir,
modelId: "zipformer-bilingual-zh-en-2023-02-20",
autoDownload: false,
logger,
})
).rejects.toThrow(/auto-download/i);
});
test("ensureSherpaOnnxModel logs lifecycle events without progress spam", async () => { test("ensureSherpaOnnxModel logs lifecycle events without progress spam", async () => {
const modelsDir = makeTmpDir(); const modelsDir = makeTmpDir();
const infoMessages: string[] = []; const infoMessages: string[] = [];
@@ -82,7 +69,6 @@ describe("sherpa model downloader", () => {
await ensureSherpaOnnxModel({ await ensureSherpaOnnxModel({
modelsDir, modelsDir,
modelId: "pocket-tts-onnx-int8", modelId: "pocket-tts-onnx-int8",
autoDownload: true,
logger: loggerWithSpy, logger: loggerWithSpy,
}); });
} finally { } finally {
@@ -11,7 +11,6 @@ import { getSherpaOnnxModelSpec, type SherpaOnnxModelId } from "./model-catalog.
export type EnsureSherpaOnnxModelOptions = { export type EnsureSherpaOnnxModelOptions = {
modelsDir: string; modelsDir: string;
modelId: SherpaOnnxModelId; modelId: SherpaOnnxModelId;
autoDownload: boolean;
logger: pino.Logger; logger: pino.Logger;
}; };
@@ -104,13 +103,6 @@ export async function ensureSherpaOnnxModel(options: EnsureSherpaOnnxModelOption
return modelDir; return modelDir;
} }
if (!options.autoDownload) {
throw new Error(
`Missing local speech model files for ${options.modelId} in ${modelDir}. ` +
`Set PASEO_LOCAL_AUTO_DOWNLOAD=1 to auto-download.`
);
}
logger.info({ modelsDir: options.modelsDir }, "Starting model download"); logger.info({ modelsDir: options.modelsDir }, "Starting model download");
try { try {
@@ -207,7 +199,6 @@ export async function ensureSherpaOnnxModel(options: EnsureSherpaOnnxModelOption
export async function ensureSherpaOnnxModels(options: { export async function ensureSherpaOnnxModels(options: {
modelsDir: string; modelsDir: string;
modelIds: SherpaOnnxModelId[]; modelIds: SherpaOnnxModelId[];
autoDownload: boolean;
logger: pino.Logger; logger: pino.Logger;
}): Promise<Record<SherpaOnnxModelId, string>> { }): Promise<Record<SherpaOnnxModelId, string>> {
const uniq = Array.from(new Set(options.modelIds)); const uniq = Array.from(new Set(options.modelIds));
@@ -216,7 +207,6 @@ export async function ensureSherpaOnnxModels(options: {
out[id] = await ensureSherpaOnnxModel({ out[id] = await ensureSherpaOnnxModel({
modelsDir: options.modelsDir, modelsDir: options.modelsDir,
modelId: id, modelId: id,
autoDownload: options.autoDownload,
logger: options.logger, logger: options.logger,
}); });
} }
@@ -106,7 +106,6 @@ describe("speech models (download E2E)", () => {
await ensureSherpaOnnxModels({ await ensureSherpaOnnxModels({
modelsDir, modelsDir,
modelIds, modelIds,
autoDownload: true,
logger, logger,
}); });
@@ -121,7 +120,6 @@ describe("speech models (download E2E)", () => {
}, },
local: { local: {
modelsDir, modelsDir,
autoDownload: false,
models: { models: {
dictationStt: dictationStt:
set === "parakeet-pocket" set === "parakeet-pocket"
@@ -35,7 +35,6 @@ describe("resolveSpeechConfig", () => {
}); });
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,
models: { models: {
dictationStt: "parakeet-tdt-0.6b-v2-int8", dictationStt: "parakeet-tdt-0.6b-v2-int8",
voiceStt: "parakeet-tdt-0.6b-v2-int8", voiceStt: "parakeet-tdt-0.6b-v2-int8",
@@ -67,7 +66,6 @@ describe("resolveSpeechConfig", () => {
PASEO_VOICE_LOCAL_TTS_SPEAKER_ID: "5", PASEO_VOICE_LOCAL_TTS_SPEAKER_ID: "5",
PASEO_VOICE_LOCAL_TTS_SPEED: "1.35", PASEO_VOICE_LOCAL_TTS_SPEED: "1.35",
PASEO_LOCAL_MODELS_DIR: "/tmp/models", PASEO_LOCAL_MODELS_DIR: "/tmp/models",
PASEO_LOCAL_AUTO_DOWNLOAD: "0",
OPENAI_API_KEY: "env-key", OPENAI_API_KEY: "env-key",
PASEO_VOICE_STT_PROVIDER: "openai", PASEO_VOICE_STT_PROVIDER: "openai",
PASEO_DICTATION_STT_PROVIDER: "local", PASEO_DICTATION_STT_PROVIDER: "local",
@@ -82,7 +80,6 @@ describe("resolveSpeechConfig", () => {
expect(result.speech.local).toEqual({ expect(result.speech.local).toEqual({
modelsDir: "/tmp/models", modelsDir: "/tmp/models",
autoDownload: false,
models: { models: {
dictationStt: "zipformer-bilingual-zh-en-2023-02-20", dictationStt: "zipformer-bilingual-zh-en-2023-02-20",
voiceStt: "parakeet-tdt-0.6b-v3-int8", voiceStt: "parakeet-tdt-0.6b-v3-int8",
@@ -79,7 +79,6 @@ describe("initializeSpeechRuntime readiness", () => {
availability: { availability: {
configured: false, configured: false,
modelsDir: null, modelsDir: null,
autoDownload: null,
}, },
cleanup: () => {}, cleanup: () => {},
}); });
@@ -115,7 +114,6 @@ describe("initializeSpeechRuntime readiness", () => {
availability: { availability: {
configured: false, configured: false,
modelsDir: null, modelsDir: null,
autoDownload: null,
}, },
cleanup: () => {}, cleanup: () => {},
}); });
@@ -205,7 +205,6 @@ function buildVoiceFeatureReadiness(params: {
missingLocalModelIds: LocalSpeechModelId[]; missingLocalModelIds: LocalSpeechModelId[];
backgroundDownloadInProgress: boolean; backgroundDownloadInProgress: boolean;
backgroundDownloadError: string | null; backgroundDownloadError: string | null;
localAutoDownloadEnabled: boolean;
}): SpeechReadinessState { }): SpeechReadinessState {
const enabled = params.realtimeVoice.enabled || params.dictation.enabled; const enabled = params.realtimeVoice.enabled || params.dictation.enabled;
if (!enabled) { if (!enabled) {
@@ -246,7 +245,7 @@ function buildVoiceFeatureReadiness(params: {
available: false, available: false,
reasonCode: "models_missing", reasonCode: "models_missing",
message: `Voice features are unavailable: missing local models (${joinModelIds(missingModelIds)}).`, message: `Voice features are unavailable: missing local models (${joinModelIds(missingModelIds)}).`,
retryable: params.localAutoDownloadEnabled, retryable: true,
missingModelIds, missingModelIds,
}; };
} }
@@ -328,7 +327,6 @@ export async function initializeSpeechRuntime(params: {
const openaiConfig = params.openaiConfig; const openaiConfig = params.openaiConfig;
const providers = resolveRequestedSpeechProviders(speechConfig); const providers = resolveRequestedSpeechProviders(speechConfig);
const requestedProviders = describeRequestedProviders(providers); const requestedProviders = describeRequestedProviders(providers);
const localAutoDownloadEnabled = speechConfig?.local?.autoDownload ?? true;
validateOpenAiCredentialRequirements({ validateOpenAiCredentialRequirements({
providers, providers,
@@ -382,7 +380,6 @@ export async function initializeSpeechRuntime(params: {
missingLocalModelIds, missingLocalModelIds,
backgroundDownloadInProgress, backgroundDownloadInProgress,
backgroundDownloadError, backgroundDownloadError,
localAutoDownloadEnabled,
}); });
return { return {
generatedAt: new Date().toISOString(), generatedAt: new Date().toISOString(),
@@ -459,12 +456,11 @@ export async function initializeSpeechRuntime(params: {
}); });
}; };
const reconcileServices = async (modelEnsureAutoDownload: boolean): Promise<void> => { const reconcileServices = async (): Promise<void> => {
const nextLocalSpeech = await initializeLocalSpeechServices({ const nextLocalSpeech = await initializeLocalSpeechServices({
providers, providers,
speechConfig, speechConfig,
logger, logger,
modelEnsureAutoDownload,
}); });
const nextOpenAiSpeech = initializeOpenAiSpeechServices({ const nextOpenAiSpeech = initializeOpenAiSpeechServices({
providers, providers,
@@ -521,13 +517,13 @@ export async function initializeSpeechRuntime(params: {
} }
}; };
const runReconcile = async (params: { modelEnsureAutoDownload: boolean }): Promise<void> => { const runReconcile = async (): Promise<void> => {
if (reconcileInFlight) { if (reconcileInFlight) {
await reconcileInFlight; await reconcileInFlight;
publishReadinessIfChanged(); publishReadinessIfChanged();
return; return;
} }
reconcileInFlight = reconcileServices(params.modelEnsureAutoDownload).finally(() => { reconcileInFlight = reconcileServices().finally(() => {
reconcileInFlight = null; reconcileInFlight = null;
}); });
await reconcileInFlight; await reconcileInFlight;
@@ -550,7 +546,7 @@ export async function initializeSpeechRuntime(params: {
} }
const modelsDir = localModelConfig?.modelsDir ?? null; const modelsDir = localModelConfig?.modelsDir ?? null;
const modelIds = [...missingLocalModelIds]; const modelIds = [...missingLocalModelIds];
if (!modelsDir || modelIds.length === 0 || !localAutoDownloadEnabled) { if (!modelsDir || modelIds.length === 0) {
return; return;
} }
@@ -571,10 +567,9 @@ export async function initializeSpeechRuntime(params: {
await ensureLocalSpeechModels({ await ensureLocalSpeechModels({
modelsDir, modelsDir,
modelIds, modelIds,
autoDownload: true,
logger, logger,
}); });
await runReconcile({ modelEnsureAutoDownload: false }); await runReconcile();
backgroundDownloadError = null; backgroundDownloadError = null;
} catch (error) { } catch (error) {
backgroundDownloadError = error instanceof Error ? error.message : String(error); backgroundDownloadError = error instanceof Error ? error.message : String(error);
@@ -610,12 +605,11 @@ export async function initializeSpeechRuntime(params: {
missingLocalModelIds.length === 0 && missingLocalModelIds.length === 0 &&
!backgroundDownloadInProgress !backgroundDownloadInProgress
) { ) {
await runReconcile({ modelEnsureAutoDownload: false }); await runReconcile();
} }
if ( if (
missingLocalModelIds.length > 0 && missingLocalModelIds.length > 0 &&
localAutoDownloadEnabled &&
!backgroundDownloadInProgress && !backgroundDownloadInProgress &&
!backgroundDownloadError !backgroundDownloadError
) { ) {
@@ -629,10 +623,10 @@ export async function initializeSpeechRuntime(params: {
} }
}; };
await runReconcile({ modelEnsureAutoDownload: false }); await runReconcile();
const snapshot = computeReadinessSnapshot(); const snapshot = computeReadinessSnapshot();
if (snapshot.voiceFeature.enabled && !snapshot.voiceFeature.available) { if (snapshot.voiceFeature.enabled && !snapshot.voiceFeature.available) {
if (missingLocalModelIds.length > 0 && localAutoDownloadEnabled) { if (missingLocalModelIds.length > 0) {
startBackgroundDownload(); startBackgroundDownload();
} }
scheduleMonitor(); scheduleMonitor();
@@ -95,9 +95,6 @@
"modelsDir": { "modelsDir": {
"type": "string", "type": "string",
"minLength": 1 "minLength": 1
},
"autoDownload": {
"type": "boolean"
} }
}, },
"additionalProperties": false "additionalProperties": false
@@ -197,6 +194,9 @@
"dictation": { "dictation": {
"type": "object", "type": "object",
"properties": { "properties": {
"enabled": {
"type": "boolean"
},
"stt": { "stt": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -230,6 +230,9 @@
"voiceMode": { "voiceMode": {
"type": "object", "type": "object",
"properties": { "properties": {
"enabled": {
"type": "boolean"
},
"llm": { "llm": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -177,7 +177,7 @@ docker run --rm -i \\
<li><code className="font-mono">OPENAI_API_KEY</code> — override OpenAI provider key</li> <li><code className="font-mono">OPENAI_API_KEY</code> — override OpenAI provider key</li>
<li><code className="font-mono">PASEO_VOICE_LLM_PROVIDER</code> — override voice LLM provider (<code className="font-mono">claude</code>, <code className="font-mono">codex</code>, <code className="font-mono">opencode</code>)</li> <li><code className="font-mono">PASEO_VOICE_LLM_PROVIDER</code> — override voice LLM provider (<code className="font-mono">claude</code>, <code className="font-mono">codex</code>, <code className="font-mono">opencode</code>)</li>
<li><code className="font-mono">PASEO_DICTATION_STT_PROVIDER</code>, <code className="font-mono">PASEO_VOICE_STT_PROVIDER</code>, <code className="font-mono">PASEO_VOICE_TTS_PROVIDER</code> — override voice provider selection (<code className="font-mono">local</code> or <code className="font-mono">openai</code>)</li> <li><code className="font-mono">PASEO_DICTATION_STT_PROVIDER</code>, <code className="font-mono">PASEO_VOICE_STT_PROVIDER</code>, <code className="font-mono">PASEO_VOICE_TTS_PROVIDER</code> — override voice provider selection (<code className="font-mono">local</code> or <code className="font-mono">openai</code>)</li>
<li><code className="font-mono">PASEO_LOCAL_MODELS_DIR</code> and <code className="font-mono">PASEO_LOCAL_AUTO_DOWNLOAD</code> — control local model directory and download behavior</li> <li><code className="font-mono">PASEO_LOCAL_MODELS_DIR</code> — control local model directory</li>
<li><code className="font-mono">PASEO_DICTATION_LOCAL_STT_MODEL</code> — override local dictation STT model</li> <li><code className="font-mono">PASEO_DICTATION_LOCAL_STT_MODEL</code> — override local dictation STT model</li>
<li><code className="font-mono">PASEO_VOICE_LOCAL_STT_MODEL</code>, <code className="font-mono">PASEO_VOICE_LOCAL_TTS_MODEL</code> — override local voice STT/TTS models</li> <li><code className="font-mono">PASEO_VOICE_LOCAL_STT_MODEL</code>, <code className="font-mono">PASEO_VOICE_LOCAL_TTS_MODEL</code> — override local voice STT/TTS models</li>
<li><code className="font-mono">PASEO_VOICE_LOCAL_TTS_SPEAKER_ID</code>, <code className="font-mono">PASEO_VOICE_LOCAL_TTS_SPEED</code> — optional local voice TTS tuning</li> <li><code className="font-mono">PASEO_VOICE_LOCAL_TTS_SPEAKER_ID</code>, <code className="font-mono">PASEO_VOICE_LOCAL_TTS_SPEED</code> — optional local voice TTS tuning</li>
+3 -4
View File
@@ -55,7 +55,7 @@ function VoiceDocs() {
</p> </p>
<p className="text-white/60 leading-relaxed"> <p className="text-white/60 leading-relaxed">
Missing models are downloaded at daemon startup into{' '} Missing models are downloaded at daemon startup into{' '}
<code className="font-mono">$PASEO_HOME/models/local-speech</code> when auto-download is enabled. <code className="font-mono">$PASEO_HOME/models/local-speech</code>.
Downloads happen only for missing files. Downloads happen only for missing files.
</p> </p>
<pre className="bg-card border border-border rounded-lg p-4 font-mono text-sm overflow-x-auto text-white/80"> <pre className="bg-card border border-border rounded-lg p-4 font-mono text-sm overflow-x-auto text-white/80">
@@ -71,8 +71,7 @@ function VoiceDocs() {
}, },
"providers": { "providers": {
"local": { "local": {
"modelsDir": "~/.paseo/models/local-speech", "modelsDir": "~/.paseo/models/local-speech"
"autoDownload": true
} }
} }
}`} }`}
@@ -107,7 +106,7 @@ function VoiceDocs() {
<ul className="text-white/60 space-y-2 list-disc list-inside"> <ul className="text-white/60 space-y-2 list-disc list-inside">
<li><code className="font-mono">OPENAI_API_KEY</code> — OpenAI speech credentials</li> <li><code className="font-mono">OPENAI_API_KEY</code> — OpenAI speech credentials</li>
<li><code className="font-mono">PASEO_VOICE_LLM_PROVIDER</code> — voice agent provider override</li> <li><code className="font-mono">PASEO_VOICE_LLM_PROVIDER</code> — voice agent provider override</li>
<li><code className="font-mono">PASEO_LOCAL_MODELS_DIR</code>, <code className="font-mono">PASEO_LOCAL_AUTO_DOWNLOAD</code> — local model storage and download policy</li> <li><code className="font-mono">PASEO_LOCAL_MODELS_DIR</code> — local model storage directory</li>
<li><code className="font-mono">PASEO_DICTATION_LOCAL_STT_MODEL</code> — local dictation STT model ID</li> <li><code className="font-mono">PASEO_DICTATION_LOCAL_STT_MODEL</code> — local dictation STT model ID</li>
<li><code className="font-mono">PASEO_VOICE_LOCAL_STT_MODEL</code>, <code className="font-mono">PASEO_VOICE_LOCAL_TTS_MODEL</code> — local voice STT/TTS model IDs</li> <li><code className="font-mono">PASEO_VOICE_LOCAL_STT_MODEL</code>, <code className="font-mono">PASEO_VOICE_LOCAL_TTS_MODEL</code> — local voice STT/TTS model IDs</li>
<li><code className="font-mono">PASEO_VOICE_LOCAL_TTS_SPEAKER_ID</code>, <code className="font-mono">PASEO_VOICE_LOCAL_TTS_SPEED</code> — optional local voice TTS tuning</li> <li><code className="font-mono">PASEO_VOICE_LOCAL_TTS_SPEAKER_ID</code>, <code className="font-mono">PASEO_VOICE_LOCAL_TTS_SPEED</code> — optional local voice TTS tuning</li>