Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions src/components/ProjectsMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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");
}
}

Expand Down
108 changes: 108 additions & 0 deletions src/components/TopBar.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<TopBar />);
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(<TopBar />);
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(<TopBar />);
expect(
screen.queryByRole("button", { name: "Pick another model" }),
).not.toBeInTheDocument();
});
});
43 changes: 22 additions & 21 deletions src/components/TopBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -146,16 +147,16 @@ 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;
const { dirty, saving, saveNow } = useProjectSave();
const missingCount = useUnsynthesizedCount();
const { run: runSynthesize } = useSynthesizeAll();
const [actionError, setActionError] = useState<string | null>(null);
const [enginePickerOpen, setEnginePickerOpen] = useState(false);

const synthBusy = synthesisStatus === "running";
const synthElapsed = useElapsedSeconds(
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -293,13 +278,17 @@ export function TopBar() {
value={model.engine}
onValueChange={(value) => void onEngineChange(value as TtsEngineId)}
disabled={engineSwitchDisabled}
open={enginePickerOpen}
onOpenChange={setEnginePickerOpen}
>
<SelectTrigger
className="h-7 w-[158px] shrink-0 text-xs"
title={
modelStatus === "loading"
? t.topBar.switchLoadingEngine(engineName)
: t.topBar.switchEngineTitle
: modelStatus === "error"
? t.topBar.pickAnotherModelTitle(engineName)
: t.topBar.switchEngineTitle
}
>
<SelectValue />
Expand Down Expand Up @@ -340,6 +329,18 @@ export function TopBar() {
engineName={engineName}
progress={model.progress}
/>
{modelStatus === "error" && model.engines.length > 1 && (
<Button
variant="outline"
size="sm"
onClick={() => setEnginePickerOpen(true)}
disabled={engineSwitchDisabled}
className="h-7 shrink-0 border-destructive/50 text-xs"
title={t.topBar.pickAnotherModelTitle(engineName)}
>
{t.topBar.pickAnotherModel}
</Button>
)}
<div className="hidden shrink-0 2xl:flex">
<DevPing />
</div>
Expand Down
95 changes: 95 additions & 0 deletions src/hooks/useEngineSwitch.test.ts
Original file line number Diff line number Diff line change
@@ -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<ReturnType<typeof useProjectStore.getState>["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<void>((resolve) => (resolveFirst = resolve)),
);
const { result } = renderHook(() => useEngineSwitch());
let firstSwitch: Promise<void> = 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");
});
});
40 changes: 40 additions & 0 deletions src/hooks/useEngineSwitch.ts
Original file line number Diff line number Diff line change
@@ -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],
);
}
6 changes: 6 additions & 0 deletions src/i18n/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -384,6 +387,9 @@ const ru: typeof en = {
exportFailed: (error) => `Не удалось экспортировать: ${error}`,
switchLoadingEngine: (engine) => `Переключить движок и прервать загрузку ${engine}`,
switchEngineTitle: "Переключает загруженный движок клонирования голоса",
pickAnotherModel: "Выбрать другую модель",
pickAnotherModelTitle: (engine) =>
`${engine} не загрузился — выберите другой движок`,
synthesisLanguageTitle: "Язык синтеза для многоязычных движков",
waitModel: (engine) => `Дождитесь завершения загрузки ${engine}`,
loadProjectFirst: "Сначала загрузите проект",
Expand Down
Loading