refactor(server): extract SpeechService and defer init until after listen

Speech runtime was leaking 8+ individual resolvers into bootstrap and
blocking server startup for ~3s with synchronous Sherpa native model
loading. Refactor into a self-contained SpeechService that owns its
full lifecycle (init, download, monitor, cleanup) and defer start()
until after httpServer.listen() so native loading doesn't block
accepting connections. Server startup drops from ~3.2s to ~0.5s.

Also removes unused client-triggered speech model download/list path
(CLI commands, session handlers, message types) — the service manages
its own models.
This commit is contained in:
Mohamed Boudra
2026-03-30 14:07:55 +07:00
parent db39417be9
commit 7504d20b67
10 changed files with 100 additions and 444 deletions
@@ -1,63 +0,0 @@
import type { Command } from "commander";
import type { CommandError, CommandOptions, ListResult, OutputSchema } from "../../output/index.js";
import { connectToDaemon } from "../../utils/client.js";
interface SpeechDownloadRow {
modelId: string;
status: "downloaded";
}
const speechDownloadSchema: OutputSchema<SpeechDownloadRow> = {
idField: "modelId",
columns: [
{ header: "MODEL", field: "modelId", width: 36 },
{ header: "STATUS", field: "status", width: 12, color: () => "green" },
],
};
export type SpeechDownloadResult = ListResult<SpeechDownloadRow>;
export interface SpeechDownloadOptions extends CommandOptions {
host?: string;
model?: string[];
}
export async function runSpeechDownloadCommand(
options: SpeechDownloadOptions,
_command: Command,
): Promise<SpeechDownloadResult> {
const client = await connectToDaemon({ host: options.host });
try {
const response = await client.downloadSpeechModels({
modelIds: options.model && options.model.length > 0 ? options.model : undefined,
});
if (response.error) {
const commandError: CommandError = {
code: "SPEECH_MODELS_DOWNLOAD_FAILED",
message: response.error,
};
throw commandError;
}
return {
type: "list",
data: response.downloadedModelIds.map((modelId) => ({
modelId,
status: "downloaded" as const,
})),
schema: speechDownloadSchema,
};
} catch (error) {
if (typeof error === "object" && error && "code" in error && "message" in error) {
throw error;
}
const message = error instanceof Error ? error.message : String(error);
const commandError: CommandError = {
code: "SPEECH_MODELS_DOWNLOAD_FAILED",
message: `Failed to download speech models: ${message}`,
};
throw commandError;
} finally {
await client.close().catch(() => {});
}
}
+1 -18
View File
@@ -1,22 +1,5 @@
import { Command } from "commander";
import { withOutput } from "../../output/index.js";
import { runSpeechModelsCommand } from "./models.js";
import { runSpeechDownloadCommand } from "./download.js";
import { addJsonAndDaemonHostOptions, collectMultiple } from "../../utils/command-options.js";
export function createSpeechCommand(): Command {
const speech = new Command("speech").description("Manage local speech models");
addJsonAndDaemonHostOptions(
speech.command("models").description("List local speech model download status"),
).action(withOutput(runSpeechModelsCommand));
addJsonAndDaemonHostOptions(
speech
.command("download")
.description("Download local speech models")
.option("--model <id>", "Model ID to download (repeatable)", collectMultiple, []),
).action(withOutput(runSpeechDownloadCommand));
return speech;
return new Command("speech").description("Speech commands");
}
@@ -1,67 +0,0 @@
import type { Command } from "commander";
import type { CommandError, CommandOptions, ListResult, OutputSchema } from "../../output/index.js";
import { connectToDaemon } from "../../utils/client.js";
interface SpeechModelListItem {
id: string;
kind: string;
status: "downloaded" | "missing";
modelDir: string;
missingFiles: string;
}
const speechModelsSchema: OutputSchema<SpeechModelListItem> = {
idField: "id",
columns: [
{ header: "MODEL", field: "id", width: 36 },
{ header: "KIND", field: "kind", width: 12 },
{
header: "STATUS",
field: "status",
width: 12,
color: (value) => (value === "downloaded" ? "green" : "yellow"),
},
{ header: "MODEL DIR", field: "modelDir", width: 44 },
{ header: "MISSING FILES", field: "missingFiles", width: 40 },
],
};
export type SpeechModelsResult = ListResult<SpeechModelListItem>;
export interface SpeechModelsOptions extends CommandOptions {
host?: string;
}
export async function runSpeechModelsCommand(
options: SpeechModelsOptions,
_command: Command,
): Promise<SpeechModelsResult> {
const client = await connectToDaemon({ host: options.host });
try {
const response = await client.listSpeechModels();
const rows: SpeechModelListItem[] = response.models
.slice()
.sort((a, b) => a.kind.localeCompare(b.kind) || a.id.localeCompare(b.id))
.map((model) => ({
id: model.id,
kind: model.kind,
status: model.isDownloaded ? "downloaded" : "missing",
modelDir: model.modelDir,
missingFiles: model.missingFiles?.join(", ") ?? "",
}));
return {
type: "list",
data: rows,
schema: speechModelsSchema,
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const commandError: CommandError = {
code: "SPEECH_MODELS_LIST_FAILED",
message: `Failed to list speech models: ${message}`,
};
throw commandError;
} finally {
await client.close().catch(() => {});
}
}