mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-14 13:22:37 +00:00
Reinstall local speech models whose extraction was interrupted (#6210)
* fix(speech): publish models only after extraction completes * fix(speech): reinstall local speech models whose extraction was interrupted The daemon decided whether a local speech model was installed with its own check in speech-runtime.ts: every required file exists and is non-empty. A model whose extraction was stopped part-way passed that check, so the daemon never called the downloader again and dictation aborted on the truncated file on every start. The models module now owns "is this model installed", and the runtime asks it. A model whose archive is still in .downloads did not finish installing, since the archive is removed only after the model is in place, so it is reinstalled from that archive. Extraction goes to a directory beside the archive and moves into place once complete. Co-authored-by: 3ae3ae <ogyshjsm@naver.com> * fix(speech): remove the model archive before moving the model into place * docs(speech): describe when the model archive is removed --------- Co-authored-by: 3ae3ae <ogyshjsm@naver.com> Co-authored-by: Bae Seongmin <83870598+3ae3ae@users.noreply.github.com> Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
3ae3ae
Bae Seongmin
parent
4ede81e841
commit
c301d6e8aa
@@ -0,0 +1,70 @@
|
|||||||
|
import { execFileSync } from "node:child_process";
|
||||||
|
import {
|
||||||
|
existsSync,
|
||||||
|
mkdirSync,
|
||||||
|
mkdtempSync,
|
||||||
|
readFileSync,
|
||||||
|
rmSync,
|
||||||
|
truncateSync,
|
||||||
|
writeFileSync,
|
||||||
|
} from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import pino from "pino";
|
||||||
|
import { describe, expect, onTestFinished, test } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
ensureLocalSpeechModels,
|
||||||
|
getLocalSpeechModelDir,
|
||||||
|
listLocalSpeechModels,
|
||||||
|
listMissingLocalSpeechModels,
|
||||||
|
} from "./models.js";
|
||||||
|
|
||||||
|
const logger = pino({ level: "silent" });
|
||||||
|
const modelId = "parakeet-tdt-0.6b-v2-int8";
|
||||||
|
const spec = listLocalSpeechModels().find((model) => model.id === modelId)!;
|
||||||
|
|
||||||
|
function writeCompleteModel(modelDir: string): void {
|
||||||
|
mkdirSync(modelDir, { recursive: true });
|
||||||
|
for (const file of spec.requiredFiles) {
|
||||||
|
writeFileSync(path.join(modelDir, file), `complete ${file}`.padEnd(64_000, "."));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupModelsDirWithArchive(): { modelsDir: string; modelDir: string; archive: string } {
|
||||||
|
const root = mkdtempSync(path.join(tmpdir(), "paseo-local-models-"));
|
||||||
|
onTestFinished(() => rmSync(root, { recursive: true, force: true }));
|
||||||
|
const sourceRoot = path.join(root, "source");
|
||||||
|
writeCompleteModel(path.join(sourceRoot, spec.extractedDir));
|
||||||
|
const modelsDir = path.join(root, "models");
|
||||||
|
const archive = path.join(
|
||||||
|
modelsDir,
|
||||||
|
".downloads",
|
||||||
|
path.basename(new URL(spec.archiveUrl).pathname),
|
||||||
|
);
|
||||||
|
mkdirSync(path.dirname(archive), { recursive: true });
|
||||||
|
execFileSync("tar", ["cf", archive, "-C", sourceRoot, spec.extractedDir]);
|
||||||
|
return { modelsDir, modelDir: getLocalSpeechModelDir(modelsDir, modelId), archive };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("local speech models", () => {
|
||||||
|
test("reinstalls a model whose extraction was interrupted", async () => {
|
||||||
|
// An interrupted extraction leaves the archive in .downloads and a truncated file in place.
|
||||||
|
const { modelsDir, modelDir, archive } = setupModelsDirWithArchive();
|
||||||
|
writeCompleteModel(modelDir);
|
||||||
|
const encoder = path.join(modelDir, spec.requiredFiles[0]);
|
||||||
|
truncateSync(encoder, 1_000);
|
||||||
|
|
||||||
|
expect(await listMissingLocalSpeechModels({ modelsDir, modelIds: [modelId] })).toEqual([
|
||||||
|
modelId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
await ensureLocalSpeechModels({ modelsDir, modelIds: [modelId], logger });
|
||||||
|
|
||||||
|
expect(readFileSync(encoder, "utf8")).toBe(
|
||||||
|
`complete ${spec.requiredFiles[0]}`.padEnd(64_000, "."),
|
||||||
|
);
|
||||||
|
expect(existsSync(archive)).toBe(false);
|
||||||
|
expect(await listMissingLocalSpeechModels({ modelsDir, modelIds: [modelId] })).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,8 @@
|
|||||||
import { ensureSherpaOnnxModels, getSherpaOnnxModelDir } from "./sherpa/model-downloader.js";
|
import {
|
||||||
|
ensureSherpaOnnxModels,
|
||||||
|
getSherpaOnnxModelDir,
|
||||||
|
isSherpaOnnxModelInstalled,
|
||||||
|
} from "./sherpa/model-downloader.js";
|
||||||
import {
|
import {
|
||||||
DEFAULT_LOCAL_STT_MODEL,
|
DEFAULT_LOCAL_STT_MODEL,
|
||||||
DEFAULT_LOCAL_TTS_MODEL,
|
DEFAULT_LOCAL_TTS_MODEL,
|
||||||
@@ -30,6 +34,16 @@ export function getLocalSpeechModelDir(modelsDir: string, modelId: LocalSpeechMo
|
|||||||
return getSherpaOnnxModelDir(modelsDir, modelId);
|
return getSherpaOnnxModelDir(modelsDir, modelId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function listMissingLocalSpeechModels(options: {
|
||||||
|
modelsDir: string;
|
||||||
|
modelIds: LocalSpeechModelId[];
|
||||||
|
}): Promise<LocalSpeechModelId[]> {
|
||||||
|
const installed = await Promise.all(
|
||||||
|
options.modelIds.map((modelId) => isSherpaOnnxModelInstalled(options.modelsDir, modelId)),
|
||||||
|
);
|
||||||
|
return options.modelIds.filter((_modelId, index) => !installed[index]);
|
||||||
|
}
|
||||||
|
|
||||||
export async function ensureLocalSpeechModels(options: {
|
export async function ensureLocalSpeechModels(options: {
|
||||||
modelsDir: string;
|
modelsDir: string;
|
||||||
modelIds: LocalSpeechModelId[];
|
modelIds: LocalSpeechModelId[];
|
||||||
|
|||||||
@@ -1,10 +1,21 @@
|
|||||||
import { describe, expect, test } from "vitest";
|
import { describe, expect, test } from "vitest";
|
||||||
import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
|
import {
|
||||||
|
mkdtempSync,
|
||||||
|
mkdirSync,
|
||||||
|
writeFileSync,
|
||||||
|
readFileSync,
|
||||||
|
rmSync,
|
||||||
|
existsSync,
|
||||||
|
truncateSync,
|
||||||
|
readdirSync,
|
||||||
|
} from "node:fs";
|
||||||
|
import { execFileSync } from "node:child_process";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import pino from "pino";
|
import pino from "pino";
|
||||||
|
|
||||||
import { ensureSherpaOnnxModel, getSherpaOnnxModelDir } from "./model-downloader.js";
|
import { ensureSherpaOnnxModel, getSherpaOnnxModelDir } from "./model-downloader.js";
|
||||||
|
import { getSherpaOnnxModelSpec } from "./model-catalog.js";
|
||||||
|
|
||||||
function makeTmpDir(): string {
|
function makeTmpDir(): string {
|
||||||
return mkdtempSync(path.join(tmpdir(), "paseo-speech-models-"));
|
return mkdtempSync(path.join(tmpdir(), "paseo-speech-models-"));
|
||||||
@@ -13,6 +24,67 @@ function makeTmpDir(): string {
|
|||||||
const logger = pino({ level: "silent" });
|
const logger = pino({ level: "silent" });
|
||||||
|
|
||||||
describe("sherpa model downloader", () => {
|
describe("sherpa model downloader", () => {
|
||||||
|
test("does not expose a partial model when tar extraction fails", async () => {
|
||||||
|
const root = makeTmpDir();
|
||||||
|
const modelsDir = path.join(root, "models");
|
||||||
|
const spec = getSherpaOnnxModelSpec("kokoro-en-v0_19");
|
||||||
|
const sourceDir = path.join(root, "source", spec.extractedDir);
|
||||||
|
const archive = path.join(
|
||||||
|
modelsDir,
|
||||||
|
".downloads",
|
||||||
|
path.basename(new URL(spec.archiveUrl).pathname),
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
mkdirSync(sourceDir, { recursive: true });
|
||||||
|
mkdirSync(path.dirname(archive), { recursive: true });
|
||||||
|
writeFileSync(path.join(sourceDir, "model.onnx"), Buffer.alloc(32_768, 1));
|
||||||
|
execFileSync("tar", [
|
||||||
|
"cf",
|
||||||
|
archive,
|
||||||
|
"-C",
|
||||||
|
path.dirname(sourceDir),
|
||||||
|
`${spec.extractedDir}/model.onnx`,
|
||||||
|
]);
|
||||||
|
truncateSync(archive, 1_024);
|
||||||
|
await expect(ensureSherpaOnnxModel({ modelsDir, modelId: spec.id, logger })).rejects.toThrow(
|
||||||
|
"tar exited",
|
||||||
|
);
|
||||||
|
expect(existsSync(getSherpaOnnxModelDir(modelsDir, spec.id))).toBe(false);
|
||||||
|
expect(existsSync(archive)).toBe(true);
|
||||||
|
expect(readdirSync(modelsDir)).toEqual([".downloads"]);
|
||||||
|
} finally {
|
||||||
|
rmSync(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("repairs a partial legacy extraction when its cached archive remains", async () => {
|
||||||
|
const root = makeTmpDir();
|
||||||
|
const modelsDir = path.join(root, "models");
|
||||||
|
const spec = getSherpaOnnxModelSpec("kokoro-en-v0_19");
|
||||||
|
const sourceDir = path.join(root, "source", spec.extractedDir);
|
||||||
|
const modelDir = getSherpaOnnxModelDir(modelsDir, spec.id);
|
||||||
|
const archive = path.join(
|
||||||
|
modelsDir,
|
||||||
|
".downloads",
|
||||||
|
path.basename(new URL(spec.archiveUrl).pathname),
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
for (const dir of [sourceDir, modelDir]) {
|
||||||
|
mkdirSync(path.join(dir, "espeak-ng-data"), { recursive: true });
|
||||||
|
for (const filename of ["model.onnx", "voices.bin", "tokens.txt"]) {
|
||||||
|
writeFileSync(path.join(dir, filename), "complete model data");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mkdirSync(path.dirname(archive), { recursive: true });
|
||||||
|
execFileSync("tar", ["cf", archive, "-C", path.dirname(sourceDir), spec.extractedDir]);
|
||||||
|
writeFileSync(path.join(modelDir, "model.onnx"), "truncated");
|
||||||
|
await ensureSherpaOnnxModel({ modelsDir, modelId: spec.id, logger });
|
||||||
|
expect(readFileSync(path.join(modelDir, "model.onnx"), "utf8")).toBe("complete model data");
|
||||||
|
} finally {
|
||||||
|
rmSync(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("getSherpaOnnxModelDir maps modelId to extractedDir", () => {
|
test("getSherpaOnnxModelDir maps modelId to extractedDir", () => {
|
||||||
const modelsDir = "/tmp/models";
|
const modelsDir = "/tmp/models";
|
||||||
expect(getSherpaOnnxModelDir(modelsDir, "parakeet-tdt-0.6b-v2-int8")).toContain(
|
expect(getSherpaOnnxModelDir(modelsDir, "parakeet-tdt-0.6b-v2-int8")).toContain(
|
||||||
|
|||||||
@@ -90,6 +90,38 @@ async function extractTarArchive(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SherpaOnnxModelPaths {
|
||||||
|
modelDir: string;
|
||||||
|
archivePath: string;
|
||||||
|
extractionDir: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSherpaOnnxModelPaths(
|
||||||
|
modelsDir: string,
|
||||||
|
modelId: SherpaOnnxModelId,
|
||||||
|
): SherpaOnnxModelPaths {
|
||||||
|
const spec = getSherpaOnnxModelSpec(modelId);
|
||||||
|
const downloadsDir = path.join(modelsDir, ".downloads");
|
||||||
|
return {
|
||||||
|
modelDir: path.join(modelsDir, spec.extractedDir),
|
||||||
|
archivePath: path.join(downloadsDir, path.basename(new URL(spec.archiveUrl).pathname)),
|
||||||
|
extractionDir: path.join(downloadsDir, `${spec.extractedDir}.extracting`),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// The archive is removed only as its model moves into place, so a model whose archive is
|
||||||
|
// still in .downloads did not finish installing and may hold truncated files.
|
||||||
|
export async function isSherpaOnnxModelInstalled(
|
||||||
|
modelsDir: string,
|
||||||
|
modelId: SherpaOnnxModelId,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const spec = getSherpaOnnxModelSpec(modelId);
|
||||||
|
const { modelDir, archivePath } = getSherpaOnnxModelPaths(modelsDir, modelId);
|
||||||
|
return (
|
||||||
|
!(await isNonEmptyFile(archivePath)) && (await hasRequiredFiles(modelDir, spec.requiredFiles))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async function isNonEmptyFile(filePath: string): Promise<boolean> {
|
async function isNonEmptyFile(filePath: string): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
const s = await stat(filePath);
|
const s = await stat(filePath);
|
||||||
@@ -110,18 +142,17 @@ export async function ensureSherpaOnnxModel(
|
|||||||
});
|
});
|
||||||
|
|
||||||
const spec = getSherpaOnnxModelSpec(options.modelId);
|
const spec = getSherpaOnnxModelSpec(options.modelId);
|
||||||
const modelDir = path.join(options.modelsDir, spec.extractedDir);
|
const { modelDir, archivePath, extractionDir } = getSherpaOnnxModelPaths(
|
||||||
if (await hasRequiredFiles(modelDir, spec.requiredFiles)) {
|
options.modelsDir,
|
||||||
|
options.modelId,
|
||||||
|
);
|
||||||
|
if (await isSherpaOnnxModelInstalled(options.modelsDir, options.modelId)) {
|
||||||
return modelDir;
|
return modelDir;
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info({ modelsDir: options.modelsDir }, "Starting model download");
|
logger.info({ modelsDir: options.modelsDir }, "Starting model download");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const downloadsDir = path.join(options.modelsDir, ".downloads");
|
|
||||||
const archiveFilename = path.basename(new URL(spec.archiveUrl).pathname);
|
|
||||||
const archivePath = path.join(downloadsDir, archiveFilename);
|
|
||||||
|
|
||||||
if (!(await isNonEmptyFile(archivePath))) {
|
if (!(await isNonEmptyFile(archivePath))) {
|
||||||
await downloadToFile({
|
await downloadToFile({
|
||||||
url: spec.archiveUrl,
|
url: spec.archiveUrl,
|
||||||
@@ -138,32 +169,40 @@ export async function ensureSherpaOnnxModel(
|
|||||||
},
|
},
|
||||||
"Extracting model archive",
|
"Extracting model archive",
|
||||||
);
|
);
|
||||||
await extractTarArchive(archivePath, options.modelsDir, options.signal);
|
// Extract beside the archive and move into place once complete, so an interrupted
|
||||||
|
// extraction never leaves partial files at modelDir.
|
||||||
logger.info(
|
await rm(extractionDir, { recursive: true, force: true });
|
||||||
{
|
|
||||||
modelId: options.modelId,
|
|
||||||
modelDir,
|
|
||||||
},
|
|
||||||
"Verifying downloaded model files",
|
|
||||||
);
|
|
||||||
if (!(await hasRequiredFiles(modelDir, spec.requiredFiles))) {
|
|
||||||
throw new Error(
|
|
||||||
`Downloaded and extracted ${archiveFilename}, but required files are still missing in ${modelDir}.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
{
|
|
||||||
modelId: options.modelId,
|
|
||||||
archivePath,
|
|
||||||
},
|
|
||||||
"Finalizing model artifacts",
|
|
||||||
);
|
|
||||||
try {
|
try {
|
||||||
await rm(archivePath, { force: true });
|
await extractTarArchive(archivePath, extractionDir, options.signal);
|
||||||
} catch {
|
const extractedModelDir = path.join(extractionDir, spec.extractedDir);
|
||||||
// ignore
|
|
||||||
|
logger.info(
|
||||||
|
{
|
||||||
|
modelId: options.modelId,
|
||||||
|
modelDir,
|
||||||
|
},
|
||||||
|
"Verifying downloaded model files",
|
||||||
|
);
|
||||||
|
if (!(await hasRequiredFiles(extractedModelDir, spec.requiredFiles))) {
|
||||||
|
throw new Error(
|
||||||
|
`Downloaded and extracted ${path.basename(archivePath)}, but required files are missing.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
{
|
||||||
|
modelId: options.modelId,
|
||||||
|
archivePath,
|
||||||
|
},
|
||||||
|
"Finalizing model artifacts",
|
||||||
|
);
|
||||||
|
// A retained archive marks the model as not installed, so remove it before the model
|
||||||
|
// is moved into place. If removal fails, nothing at modelDir looks installed.
|
||||||
|
await rm(modelDir, { recursive: true, force: true });
|
||||||
|
await rm(archivePath, { force: true, maxRetries: 3 });
|
||||||
|
await rename(extractedModelDir, modelDir);
|
||||||
|
} finally {
|
||||||
|
await rm(extractionDir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info({ modelDir }, "Model download completed");
|
logger.info({ modelDir }, "Model download completed");
|
||||||
|
|||||||
@@ -36,8 +36,7 @@ vi.mock("./providers/openai/runtime.js", () => ({
|
|||||||
|
|
||||||
vi.mock("./providers/local/models.js", () => ({
|
vi.mock("./providers/local/models.js", () => ({
|
||||||
ensureLocalSpeechModels: ensureLocalSpeechModelsMock,
|
ensureLocalSpeechModels: ensureLocalSpeechModelsMock,
|
||||||
getLocalSpeechModelDir: vi.fn(() => ""),
|
listMissingLocalSpeechModels: vi.fn(async ({ modelIds }: { modelIds: string[] }) => modelIds),
|
||||||
listLocalSpeechModels: vi.fn(() => []),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
function createStubStt(id: string): SpeechToTextProvider {
|
function createStubStt(id: string): SpeechToTextProvider {
|
||||||
|
|||||||
@@ -1,14 +1,8 @@
|
|||||||
import { stat } from "node:fs/promises";
|
|
||||||
import { join } from "node:path";
|
|
||||||
import type { Logger } from "pino";
|
import type { Logger } from "pino";
|
||||||
|
|
||||||
import type { PaseoOpenAIConfig, PaseoSpeechConfig } from "../bootstrap.js";
|
import type { PaseoOpenAIConfig, PaseoSpeechConfig } from "../bootstrap.js";
|
||||||
import type { LocalSpeechModelId } from "./providers/local/config.js";
|
import type { LocalSpeechModelId } from "./providers/local/config.js";
|
||||||
import {
|
import { ensureLocalSpeechModels, listMissingLocalSpeechModels } from "./providers/local/models.js";
|
||||||
ensureLocalSpeechModels,
|
|
||||||
getLocalSpeechModelDir,
|
|
||||||
listLocalSpeechModels,
|
|
||||||
} from "./providers/local/models.js";
|
|
||||||
import { initializeLocalSpeechServices } from "./providers/local/runtime.js";
|
import { initializeLocalSpeechServices } from "./providers/local/runtime.js";
|
||||||
import {
|
import {
|
||||||
getOpenAiSpeechAvailability,
|
getOpenAiSpeechAvailability,
|
||||||
@@ -76,48 +70,6 @@ function resolveRequestedSpeechProviders(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function hasRequiredLocalModelFile(filePath: string): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
const fileStat = await stat(filePath);
|
|
||||||
if (fileStat.isDirectory()) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return fileStat.isFile() && fileStat.size > 0;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function findMissingRequiredLocalModels(params: {
|
|
||||||
modelsDir: string | null;
|
|
||||||
requiredModelIds: LocalSpeechModelId[];
|
|
||||||
}): Promise<LocalSpeechModelId[]> {
|
|
||||||
const { modelsDir, requiredModelIds } = params;
|
|
||||||
if (!modelsDir || requiredModelIds.length === 0) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const specsById = new Map(listLocalSpeechModels().map((model) => [model.id, model]));
|
|
||||||
const missing = new Set<LocalSpeechModelId>();
|
|
||||||
|
|
||||||
const checks = await Promise.all(
|
|
||||||
requiredModelIds.map(async (modelId) => {
|
|
||||||
const spec = specsById.get(modelId);
|
|
||||||
if (!spec) return { modelId, missing: true };
|
|
||||||
const modelDir = getLocalSpeechModelDir(modelsDir, modelId);
|
|
||||||
const filePresence = await Promise.all(
|
|
||||||
spec.requiredFiles.map((relPath) => hasRequiredLocalModelFile(join(modelDir, relPath))),
|
|
||||||
);
|
|
||||||
return { modelId, missing: !filePresence.every((present) => present) };
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
for (const check of checks) {
|
|
||||||
if (check.missing) missing.add(check.modelId);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Array.from(missing);
|
|
||||||
}
|
|
||||||
|
|
||||||
function joinModelIds(modelIds: LocalSpeechModelId[]): string {
|
function joinModelIds(modelIds: LocalSpeechModelId[]): string {
|
||||||
if (modelIds.length === 0) {
|
if (modelIds.length === 0) {
|
||||||
return "none";
|
return "none";
|
||||||
@@ -493,10 +445,12 @@ export function createSpeechService(params: {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const refreshMissingLocalModels = async (): Promise<void> => {
|
const refreshMissingLocalModels = async (): Promise<void> => {
|
||||||
missingLocalModelIds = await findMissingRequiredLocalModels({
|
missingLocalModelIds = localModelConfig
|
||||||
modelsDir: localModelConfig?.modelsDir ?? null,
|
? await listMissingLocalSpeechModels({
|
||||||
requiredModelIds: localModelConfig?.defaultModelIds ?? [],
|
modelsDir: localModelConfig.modelsDir,
|
||||||
});
|
modelIds: localModelConfig.defaultModelIds,
|
||||||
|
})
|
||||||
|
: [];
|
||||||
};
|
};
|
||||||
|
|
||||||
const reconcileServices = async (): Promise<void> => {
|
const reconcileServices = async (): Promise<void> => {
|
||||||
|
|||||||
Reference in New Issue
Block a user