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: "Сначала загрузите проект",