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