From d8895c70cb22d28784bb0aa22230d3386edf70d4 Mon Sep 17 00:00:00 2001 From: ivan-digital Date: Mon, 6 Jul 2026 08:42:23 +0200 Subject: [PATCH] Offer engine recovery when a model fails to load A failed or stalled model load left the toolbar with only a red status chip; nothing pointed at the engine dropdown as the way out, so users were stuck on a broken engine. Show an explicit "Pick another model" action next to the error chip that opens the engine picker, and title the picker accordingly in that state. Route every engine switch through one shared flow (useEngineSwitch): the toolbar selector and the Hindi demo shortcut now both initialize the sidecar model and settle loading/error status. Previously the demo flipped the store without init, leaving a stale "ready" chip and deferring the whole model download to the first synthesis. --- src/components/ProjectsMenu.tsx | 8 ++- src/components/TopBar.test.tsx | 108 ++++++++++++++++++++++++++++++ src/components/TopBar.tsx | 43 ++++++------ src/hooks/useEngineSwitch.test.ts | 95 ++++++++++++++++++++++++++ src/hooks/useEngineSwitch.ts | 40 +++++++++++ src/i18n/messages.ts | 6 ++ 6 files changed, 277 insertions(+), 23 deletions(-) create mode 100644 src/components/TopBar.test.tsx create mode 100644 src/hooks/useEngineSwitch.test.ts create mode 100644 src/hooks/useEngineSwitch.ts diff --git a/src/components/ProjectsMenu.tsx b/src/components/ProjectsMenu.tsx index d168a5a..79cd9d5 100644 --- a/src/components/ProjectsMenu.tsx +++ b/src/components/ProjectsMenu.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { Check, ChevronDown, FilePlus2, FolderOpen, Loader2, Sparkles, Trash2 } from "lucide-react"; import { useProjectStore } from "../state/projectStore"; +import { useEngineSwitch } from "../hooks/useEngineSwitch"; import { buildDemoProject, buildHindiDemoProject } from "../state/demoProject"; import { deleteProject, @@ -30,7 +31,7 @@ export function ProjectsMenu() { const project = useProjectStore((s) => s.project); const setProject = useProjectStore((s) => s.setProject); const resetProject = useProjectStore((s) => s.resetProject); - const setTtsEngine = useProjectStore((s) => s.setTtsEngine); + const switchEngine = useEngineSwitch(); const savedSnapshot = useProjectStore((s) => s.savedSnapshot); const markSaved = useProjectStore((s) => s.markSaved); const setDemoProgress = useProjectStore((s) => s.setDemoProgress); @@ -208,8 +209,11 @@ export function ProjectsMenu() { setProject(p); markSaved(JSON.stringify(p)); setDemoProgress(null); + // Switch through the shared flow so the model actually initializes (and + // the TopBar chip reflects loading/error) instead of silently flipping + // the store and deferring the load to the first synthesis. if (engines.some((engine) => engine.id === "indic-mio")) { - setTtsEngine("indic-mio"); + void switchEngine("indic-mio"); } } diff --git a/src/components/TopBar.test.tsx b/src/components/TopBar.test.tsx new file mode 100644 index 0000000..c9f8160 --- /dev/null +++ b/src/components/TopBar.test.tsx @@ -0,0 +1,108 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen } from "@testing-library/react"; + +vi.mock("@tauri-apps/plugin-dialog", () => ({ save: vi.fn() })); +vi.mock("../ipc/commands", () => ({ + exportProject: vi.fn(), + initModel: vi.fn().mockResolvedValue(undefined), + interruptModelLoad: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("./ProjectsMenu", () => ({ ProjectsMenu: () => null })); +vi.mock("./DevPing", () => ({ DevPing: () => null })); +vi.mock("../hooks/useSynthesizeAll", () => ({ + useSynthesizeAll: () => ({ run: vi.fn() }), + useUnsynthesizedCount: () => 0, +})); +vi.mock("../hooks/useProjectSave", () => ({ + useProjectSave: () => ({ dirty: false, saving: false, saveNow: vi.fn() }), +})); +vi.mock("../hooks/useUpdater", () => ({ + useUpdater: () => ({ status: "idle", version: "", progress: null, install: vi.fn() }), +})); + +import { TopBar } from "./TopBar"; +import { initModel } from "../ipc/commands"; +import { useProjectStore, type ModelStatus } from "../state/projectStore"; +import type { TtsEngineInfo } from "../ipc/commands"; + +// Radix Select content relies on DOM APIs jsdom doesn't implement. +beforeEach(() => { + Element.prototype.scrollIntoView = vi.fn(); + Element.prototype.hasPointerCapture = vi.fn().mockReturnValue(false); + Element.prototype.releasePointerCapture = vi.fn(); +}); + +function engineInfo(id: string, displayName: string): TtsEngineInfo { + return { + id: id as TtsEngineInfo["id"], + displayName, + modelName: id, + modelId: `test/${id}`, + modelSize: "test", + runtime: "MLX", + precision: "fp16", + languages: ["en"], + voiceProfileModes: ["reference-clone"], + requiresReferenceAudio: true, + requiresReferenceTranscript: false, + requiresLanguage: false, + styleMode: "instruction", + supportsInstruct: true, + supportedMarkers: [], + needsTrim: true, + sampleRate: 24_000, + usePolicy: "commercial-safe", + readiness: "production", + }; +} + +function seedModel(status: ModelStatus, error?: string) { + useProjectStore.setState((s) => ({ + synthesisStatus: "idle", + model: { + ...s.model, + engine: "indic-mio", + language: "en", + engines: [ + engineInfo("indic-mio", "Indic-Mio"), + engineInfo("cosyvoice", "CosyVoice 3"), + engineInfo("voxcpm2", "VoxCPM2"), + ], + status, + error, + }, + })); +} + +describe("TopBar model-error recovery", () => { + beforeEach(() => { + vi.mocked(initModel).mockClear(); + }); + + it("offers picking another model when the load failed", () => { + seedModel("error", "download stalled"); + render(); + const button = screen.getByRole("button", { name: "Pick another model" }); + fireEvent.click(button); + // The engine dropdown opens with the alternatives listed. + expect(screen.getByRole("option", { name: "CosyVoice 3" })).toBeInTheDocument(); + expect(screen.getByRole("option", { name: "VoxCPM2" })).toBeInTheDocument(); + }); + + it("initializes the newly picked engine from the error state", () => { + seedModel("error", "download stalled"); + render(); + fireEvent.click(screen.getByRole("button", { name: "Pick another model" })); + fireEvent.click(screen.getByRole("option", { name: "CosyVoice 3" })); + expect(initModel).toHaveBeenCalledWith("cosyvoice"); + expect(useProjectStore.getState().model.engine).toBe("cosyvoice"); + }); + + it("does not show the recovery button while the model is healthy", () => { + seedModel("ready"); + render(); + expect( + screen.queryByRole("button", { name: "Pick another model" }), + ).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/TopBar.tsx b/src/components/TopBar.tsx index 1a07209..841e991 100644 --- a/src/components/TopBar.tsx +++ b/src/components/TopBar.tsx @@ -7,7 +7,8 @@ import { ProjectsMenu } from "./ProjectsMenu"; import { useSynthesizeAll, useUnsynthesizedCount } from "../hooks/useSynthesizeAll"; import { useProjectSave } from "../hooks/useProjectSave"; import { useUpdater } from "../hooks/useUpdater"; -import { exportProject, initModel, interruptModelLoad, type ExportClip, type TtsEngineId } from "../ipc/commands"; +import { useEngineSwitch } from "../hooks/useEngineSwitch"; +import { exportProject, type ExportClip, type TtsEngineId } from "../ipc/commands"; import { Button } from "./ui/button"; import { Input } from "./ui/input"; import { Badge } from "./ui/badge"; @@ -146,9 +147,8 @@ export function TopBar() { const project = useProjectStore((s) => s.project); const renameProject = useProjectStore((s) => s.renameProject); const model = useProjectStore((s) => s.model); - const setModelStatus = useProjectStore((s) => s.setModelStatus); - const setTtsEngine = useProjectStore((s) => s.setTtsEngine); const setTtsLanguage = useProjectStore((s) => s.setTtsLanguage); + const switchEngine = useEngineSwitch(); const synthesisStatus = useProjectStore((s) => s.synthesisStatus); const synthesisProgress = useProjectStore((s) => s.synthesisProgress); const hasContent = project.tracks.length > 0; @@ -156,6 +156,7 @@ export function TopBar() { const missingCount = useUnsynthesizedCount(); const { run: runSynthesize } = useSynthesizeAll(); const [actionError, setActionError] = useState(null); + const [enginePickerOpen, setEnginePickerOpen] = useState(false); const synthBusy = synthesisStatus === "running"; const synthElapsed = useElapsedSeconds( @@ -205,24 +206,8 @@ export function TopBar() { } async function onEngineChange(next: TtsEngineId) { - if (next === model.engine || synthBusy) return; setActionError(null); - const wasLoading = useProjectStore.getState().model.status === "loading"; - setTtsEngine(next); - setModelStatus("loading"); - try { - if (wasLoading) { - await interruptModelLoad(); - } - await initModel(next); - if (useProjectStore.getState().model.engine === next) { - setModelStatus("ready"); - } - } catch (e) { - if (useProjectStore.getState().model.engine === next) { - setModelStatus("error", String(e)); - } - } + await switchEngine(next); } // Flatten every rendered clip into the {startSec, audioPath} payload the @@ -293,13 +278,17 @@ export function TopBar() { value={model.engine} onValueChange={(value) => void onEngineChange(value as TtsEngineId)} disabled={engineSwitchDisabled} + open={enginePickerOpen} + onOpenChange={setEnginePickerOpen} > @@ -340,6 +329,18 @@ export function TopBar() { engineName={engineName} progress={model.progress} /> + {modelStatus === "error" && model.engines.length > 1 && ( + + )}
diff --git a/src/hooks/useEngineSwitch.test.ts b/src/hooks/useEngineSwitch.test.ts new file mode 100644 index 0000000..8e5eac6 --- /dev/null +++ b/src/hooks/useEngineSwitch.test.ts @@ -0,0 +1,95 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { act, renderHook } from "@testing-library/react"; + +vi.mock("../ipc/commands", () => ({ + initModel: vi.fn(), + interruptModelLoad: vi.fn(), +})); + +import { initModel, interruptModelLoad } from "../ipc/commands"; +import { useProjectStore } from "../state/projectStore"; +import { useEngineSwitch } from "./useEngineSwitch"; + +function setModel(patch: Partial["model"]>) { + useProjectStore.setState((s) => ({ + model: { ...s.model, ...patch }, + })); +} + +beforeEach(() => { + vi.mocked(initModel).mockReset().mockResolvedValue(undefined); + vi.mocked(interruptModelLoad).mockReset().mockResolvedValue(undefined); + useProjectStore.setState({ synthesisStatus: "idle" }); + setModel({ engine: "cosyvoice", status: "ready", error: undefined }); +}); + +describe("useEngineSwitch", () => { + it("initializes the next engine and settles on ready", async () => { + const { result } = renderHook(() => useEngineSwitch()); + await act(() => result.current("voxcpm2")); + expect(initModel).toHaveBeenCalledWith("voxcpm2"); + const { model } = useProjectStore.getState(); + expect(model.engine).toBe("voxcpm2"); + expect(model.status).toBe("ready"); + }); + + it("surfaces init failure as a model error for the new engine", async () => { + vi.mocked(initModel).mockRejectedValue(new Error("download stalled")); + const { result } = renderHook(() => useEngineSwitch()); + await act(() => result.current("voxcpm2")); + const { model } = useProjectStore.getState(); + expect(model.engine).toBe("voxcpm2"); + expect(model.status).toBe("error"); + expect(model.error).toContain("download stalled"); + }); + + it("lets the user switch away from a failed engine", async () => { + setModel({ engine: "indic-mio", status: "error", error: "no space left" }); + const { result } = renderHook(() => useEngineSwitch()); + await act(() => result.current("cosyvoice")); + expect(initModel).toHaveBeenCalledWith("cosyvoice"); + const { model } = useProjectStore.getState(); + expect(model.engine).toBe("cosyvoice"); + expect(model.status).toBe("ready"); + expect(model.error).toBeUndefined(); + }); + + it("interrupts an in-flight load before switching", async () => { + setModel({ status: "loading" }); + const { result } = renderHook(() => useEngineSwitch()); + await act(() => result.current("voxcpm2")); + expect(interruptModelLoad).toHaveBeenCalledTimes(1); + expect(initModel).toHaveBeenCalledWith("voxcpm2"); + }); + + it("is a no-op for the already-active engine", async () => { + const { result } = renderHook(() => useEngineSwitch()); + await act(() => result.current("cosyvoice")); + expect(initModel).not.toHaveBeenCalled(); + }); + + it("is a no-op while synthesis is running", async () => { + useProjectStore.setState({ synthesisStatus: "running" }); + const { result } = renderHook(() => useEngineSwitch()); + await act(() => result.current("voxcpm2")); + expect(initModel).not.toHaveBeenCalled(); + expect(useProjectStore.getState().model.engine).toBe("cosyvoice"); + }); + + it("does not overwrite status when the user switched again mid-init", async () => { + let resolveFirst: () => void = () => {}; + vi.mocked(initModel).mockImplementationOnce( + () => new Promise((resolve) => (resolveFirst = resolve)), + ); + const { result } = renderHook(() => useEngineSwitch()); + let firstSwitch: Promise = Promise.resolve(); + act(() => { + firstSwitch = result.current("voxcpm2"); + }); + // User picks a different engine while voxcpm2 is still initializing. + setModel({ engine: "chatterbox", status: "loading" }); + resolveFirst(); + await act(() => firstSwitch); + expect(useProjectStore.getState().model.status).toBe("loading"); + }); +}); diff --git a/src/hooks/useEngineSwitch.ts b/src/hooks/useEngineSwitch.ts new file mode 100644 index 0000000..cb2d795 --- /dev/null +++ b/src/hooks/useEngineSwitch.ts @@ -0,0 +1,40 @@ +import { useCallback } from "react"; +import { useProjectStore } from "../state/projectStore"; +import { initModel, interruptModelLoad, type TtsEngineId } from "../ipc/commands"; + +// Shared engine-switch flow: flip the store, (re)initialize the sidecar model, +// and settle status — used by the TopBar selector and by flows that switch +// engines programmatically (e.g. the Hindi demo). Interrupts an in-flight +// load so switching away from a stalled download always works. Status updates +// are guarded against the user switching again mid-init: only the request +// that still matches the active engine writes the outcome. +export function useEngineSwitch() { + const setTtsEngine = useProjectStore((s) => s.setTtsEngine); + const setModelStatus = useProjectStore((s) => s.setModelStatus); + + return useCallback( + async (next: TtsEngineId) => { + const state = useProjectStore.getState(); + if (next === state.model.engine || state.synthesisStatus === "running") { + return; + } + const wasLoading = state.model.status === "loading"; + setTtsEngine(next); + setModelStatus("loading"); + try { + if (wasLoading) { + await interruptModelLoad(); + } + await initModel(next); + if (useProjectStore.getState().model.engine === next) { + setModelStatus("ready"); + } + } catch (e) { + if (useProjectStore.getState().model.engine === next) { + setModelStatus("error", String(e)); + } + } + }, + [setTtsEngine, setModelStatus], + ); +} diff --git a/src/i18n/messages.ts b/src/i18n/messages.ts index 0e9c716..3d2f97e 100644 --- a/src/i18n/messages.ts +++ b/src/i18n/messages.ts @@ -119,6 +119,9 @@ const en = { switchLoadingEngine: (engine: string) => `Switch engine and interrupt ${engine} loading`, switchEngineTitle: "Switches the loaded voice-cloning engine", + pickAnotherModel: "Pick another model", + pickAnotherModelTitle: (engine: string) => + `${engine} failed to load — choose a different engine`, synthesisLanguageTitle: "Synthesis language for multilingual engines", waitModel: (engine: string) => `Wait for ${engine} to finish loading`, loadProjectFirst: "Load a project first", @@ -384,6 +387,9 @@ const ru: typeof en = { exportFailed: (error) => `Не удалось экспортировать: ${error}`, switchLoadingEngine: (engine) => `Переключить движок и прервать загрузку ${engine}`, switchEngineTitle: "Переключает загруженный движок клонирования голоса", + pickAnotherModel: "Выбрать другую модель", + pickAnotherModelTitle: (engine) => + `${engine} не загрузился — выберите другой движок`, synthesisLanguageTitle: "Язык синтеза для многоязычных движков", waitModel: (engine) => `Дождитесь завершения загрузки ${engine}`, loadProjectFirst: "Сначала загрузите проект",