diff --git a/CHANGELOG.md b/CHANGELOG.md index 3301b96e..c17f3943 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,8 @@ the frozen-backend fallback mirror it for their toolchains. ### Fixed +- Check active model cloning support before starting voice conversion (#2147) + - Repair CTranslate2 loading safely across ASR and translation, and retain the loaded Whisper model during CPU fallback (#2165) — thanks @guruthechosen! - Avoid pedalboard wheels that crash on unsupported CPU instructions (#2080) — thanks @D3nii! - Include cuDNN 8 compatibility libraries for CTranslate2 in CUDA containers (#2072) — thanks @basil-k-aji-dev! diff --git a/backend/api/routers/engines.py b/backend/api/routers/engines.py index cd689cc4..88a14658 100644 --- a/backend/api/routers/engines.py +++ b/backend/api/routers/engines.py @@ -85,6 +85,9 @@ def _family_payload(family: str, module): for backend in backends: engine_id = backend.get("id") + if engine_id == active == "mlx-audio": + # Constructor resolves model preferences only; never loads weights. + backend["supports_cloning"] = tts_backend.MLXAudioBackend().supports_cloning if engine_id in LICENSE_GATED_ENGINES: backend["license_required"] = True try: diff --git a/docs/install/troubleshooting.md b/docs/install/troubleshooting.md index 7d0a8d69..2f91c431 100644 --- a/docs/install/troubleshooting.md +++ b/docs/install/troubleshooting.md @@ -1203,3 +1203,11 @@ Sidecar and audio.cpp runtime installation is restricted to requests from the ba Extraction errors show the FFmpeg exit code and the end of its diagnostics, with private paths scrubbed. Use the final error line to distinguish missing audio streams, unsupported inputs, permissions, or disk errors. A version banner alone does not identify the cause; include the final diagnostic and source format when reporting a failure. Explicit generation budgets remain authoritative. If an outer TTS/ASR guard times out or its caller disconnects, the active sidecar receive kills and reaps its captured child; it cannot terminate a later retry. In-process inference keeps its existing lifetime accounting until the native call returns. + +### Voice conversion requires a cloning model + +In Electron, voice conversion stays disabled until the active text-to-speech +model is ready and supports voice cloning. Use the Models link to choose one; +the source recording and target voice are preserved when returning. Preset-only +models such as MLX Kokoro cannot clone a target voice. This capability check does +not download or load model weights. diff --git a/electron/src/renderer/src/features/tools/convert-voice.test.tsx b/electron/src/renderer/src/features/tools/convert-voice.test.tsx index 7a04aa2f..6658e8df 100644 --- a/electron/src/renderer/src/features/tools/convert-voice.test.tsx +++ b/electron/src/renderer/src/features/tools/convert-voice.test.tsx @@ -2,7 +2,16 @@ import { clearConversion } from './conversion-state'; import { cleanup, fireEvent, render, screen, act } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { afterEach, expect, it, vi } from 'vitest'; -const mock = vi.hoisted(() => ({ convert: vi.fn() })); +const mock = vi.hoisted(() => ({ convert: vi.fn(), ready: true, cloning: true as boolean | null })); +vi.mock('@/hooks/use-engines', () => ({ + useEngines: () => ({ + activeTtsReady: mock.ready, + activeTts: { supports_cloning: mock.cloning }, + }), +})); +vi.mock('@tanstack/react-router', () => ({ + Link: ({ children }: { children: React.ReactNode }) => {children}, +})); vi.mock('@/lib/api/convert', () => ({ convertSpeech: mock.convert })); vi.mock('@/hooks/use-recording', () => ({ useRecording: () => ({ isRecording: false, isStarting: false, isCleaning: false }), @@ -22,6 +31,8 @@ import { ConvertVoice } from './convert-voice'; afterEach(() => { cleanup(); clearConversion(); + mock.ready = true; + mock.cloning = true; vi.clearAllMocks(); }); function mount() { @@ -79,3 +90,37 @@ it('retains source and target when returning from model settings', () => { expect(screen.getByRole('button', { name: 'source.wav' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'convert.convert' })).toBeEnabled(); }); + +it.each([false, null])( + 'blocks conversion for unsupported or unknown cloning capability (%s)', + (capability) => { + mock.cloning = capability; + const { upload } = mount(); + upload(); + fireEvent.click(screen.getByRole('button', { name: 'Alpha' })); + const button = screen.getByRole('button', { name: 'convert.convert' }); + expect(button).toBeDisabled(); + expect(screen.getByText('convert.cloning_required')).toBeInTheDocument(); + fireEvent.click(button); + expect(mock.convert).not.toHaveBeenCalled(); + }, +); +it('rechecks engine capability when returning from settings without losing inputs', () => { + const first = mount(); + first.upload(); + fireEvent.click(screen.getByRole('button', { name: 'Alpha' })); + first.unmount(); + mock.cloning = false; + const second = mount(); + expect(screen.getByRole('button', { name: 'convert.convert' })).toBeDisabled(); + second.unmount(); + mock.cloning = true; + mock.ready = false; + const third = mount(); + expect(screen.getByRole('button', { name: 'convert.convert' })).toBeDisabled(); + third.unmount(); + mock.ready = true; + mount(); + expect(screen.getByRole('button', { name: 'convert.convert' })).toBeEnabled(); + expect(screen.getByRole('button', { name: 'source.wav' })).toBeInTheDocument(); +}); diff --git a/electron/src/renderer/src/features/tools/convert-voice.tsx b/electron/src/renderer/src/features/tools/convert-voice.tsx index 6db336e2..fe2b5f6d 100644 --- a/electron/src/renderer/src/features/tools/convert-voice.tsx +++ b/electron/src/renderer/src/features/tools/convert-voice.tsx @@ -11,6 +11,7 @@ import { PipelineFailure } from '@/components/pipeline-failure'; import { AgentFixButton } from '@/components/agent-fix-button'; import { ProfileAvatar } from '@/components/profile-avatar'; import { WaveformPlayer } from '@/components/waveform-player'; +import { useEngines } from '@/hooks/use-engines'; import { useProfiles } from '@/hooks/use-profiles'; import { useRecording } from '@/hooks/use-recording'; import { ApiError, apiPath, describeError } from '@/lib/api/client'; @@ -21,6 +22,8 @@ import { beginAppActivity } from '@/lib/app-activity'; export function ConvertVoice() { const { t } = useTranslation(); const profiles = useProfiles(); + const engines = useEngines(); + const canClone = engines.activeTtsReady && engines.activeTts?.supports_cloning === true; const client = useQueryClient(); const { file, voice, search, match, result } = useConversion(); const setFile = (value: File | null) => setConversion('file', value); @@ -64,7 +67,7 @@ export function ConvertVoice() { }, [file]); useEffect(() => () => request.current?.abort(), []); const voices = (profiles.data ?? []).filter((p) => p.kind === 'clone' && p.ref_audio_path); - const valid = file && voices.some((p) => p.id === voice) && !recordingBusy; + const valid = canClone && file && voices.some((p) => p.id === voice) && !recordingBusy; const run = async () => { if (!valid || request.current) return; const controller = new AbortController(); @@ -210,6 +213,21 @@ export function ConvertVoice() {
{t('convert.match_duration_hint')}
+ {!canClone && !busy && ( +