Merge remote-tracking branch 'origin/main' into fix/review-2173

This commit is contained in:
Palash Debnath
2026-09-17 21:32:02 +05:30
53 changed files with 470 additions and 22 deletions
+2
View File
@@ -16,6 +16,8 @@ the frozen-backend fallback mirror it for their toolchains.
### Fixed
- Validate Python dependencies before reusing a desktop runtime and offer setup for incomplete environments (#2176)
- Check active model cloning support before starting voice conversion (#2147)
- Accept both valid SIGKILL diagnostics in the desktop lifecycle regression check (#2170)
- Repair CTranslate2 loading safely across ASR and translation, and retain the loaded Whisper model during CPU fallback (#2165) — thanks @guruthechosen!
+3
View File
@@ -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:
+18
View File
@@ -18,3 +18,21 @@ permissions. No automatic installer-to-installer migration is provided.
The final Tauri updater feeds retain signed Tauri payloads at immutable URLs.
A Tauri updater must never receive an Electron installer.
Electron checks required Python imports before reusing an existing runtime. An
incomplete environment opens setup instead of repeatedly crashing; installation
still requires your explicit action. Automatic selection skips broken legacy
runtimes and uses the Electron runtime location, leaving Tauri data intact.
An explicitly selected runtime is not silently replaced during startup. If that
location contains an incomplete environment Electron does not own, choosing
setup creates a separate runtime in Electrons default location instead of
modifying or taking ownership of the existing environment.
A new custom runtime destination remains selectable. Setup creates and owns it
only when that directory does not already exist; existing unowned roots stay
untouched even if their `project` subdirectory is missing.
Dependency checks ignore inherited `PYTHONPATH` and `PYTHONHOME`, matching backend
startup. If repair switches away from an unowned environment and a healthy
Electron runtime already exists, it is reused without reinstalling dependencies.
+8
View File
@@ -1209,3 +1209,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.
+143
View File
@@ -2,6 +2,10 @@
import { afterEach, expect, it, vi } from 'vitest';
import { EventEmitter } from 'node:events';
const mocks = vi.hoisted(() => ({
runtimeConfig: null as { root: string; owned: boolean } | null,
existingProject: false,
existingRoot: false,
dependencies: vi.fn(async (_project?: string) => true),
ready: vi.fn(async () => false),
compatible: vi.fn(async () => false),
interrupted: vi.fn(async () => false),
@@ -13,8 +17,34 @@ const mocks = vi.hoisted(() => ({
}));
vi.mock('electron', () => ({ app: { isPackaged: true, getPath: () => '/private/voicestudio' } }));
vi.mock('node:child_process', () => ({ spawn: mocks.spawn, spawnSync: vi.fn() }));
vi.mock('node:fs', async (importOriginal) => {
const original = await importOriginal<typeof import('node:fs')>();
return {
...original,
readFileSync: (...args: Parameters<typeof original.readFileSync>) =>
String(args[0]).endsWith('runtime-location.json') && mocks.runtimeConfig
? JSON.stringify(mocks.runtimeConfig)
: original.readFileSync(...args),
writeFileSync: (...args: Parameters<typeof original.writeFileSync>) => {
if (String(args[0]).endsWith('runtime-location.json')) {
mocks.runtimeConfig = JSON.parse(String(args[1]));
return;
}
return original.writeFileSync(...args);
},
mkdirSync: (...args: Parameters<typeof original.mkdirSync>) =>
String(args[0]).includes('private') ? undefined : original.mkdirSync(...args),
existsSync: (path: Parameters<typeof original.existsSync>[0]) =>
String(path).includes('selected')
? String(path).endsWith('project')
? mocks.existingProject
: mocks.existingRoot
: original.existsSync(path),
};
});
vi.mock('node:fs/promises', () => ({ rm: mocks.rm }));
vi.mock('./runtime-project', () => ({
runtimeDependenciesReady: mocks.dependencies,
runtimeReady: mocks.ready,
runtimeCompatible: mocks.compatible,
runtimeInstallInterrupted: mocks.interrupted,
@@ -34,6 +64,12 @@ afterEach(() => {
vi.unstubAllGlobals();
vi.unstubAllEnvs();
vi.clearAllMocks();
mocks.runtimeConfig = null;
mocks.existingProject = false;
mocks.existingRoot = false;
mocks.dependencies.mockResolvedValue(true);
mocks.ready.mockResolvedValue(false);
mocks.compatible.mockResolvedValue(false);
mocks.interrupted.mockResolvedValue(false);
mocks.promoteCaches.mockResolvedValue(undefined);
});
@@ -364,3 +400,110 @@ it('reserves setup before asynchronous checks and cancels stale preflight', asyn
expect(mocks.install).not.toHaveBeenCalled();
expect(supervisor.status.stage).toBe('idle');
});
it.each(['ready', 'compatible'] as const)(
'offers setup instead of spawning a %s runtime with missing dependencies',
async (kind) => {
vi.stubGlobal(
'fetch',
vi.fn(async () => {
throw new Error('no backend');
}),
);
vi.stubEnv('OMNIVOICE_BACKEND_CMD', '');
vi.stubEnv('VOICESTUDIO_SKIP_BACKEND', '');
mocks[kind].mockResolvedValue(true);
mocks.dependencies.mockResolvedValue(false);
const supervisor = new BackendSupervisor();
await supervisor.start();
expect(supervisor.status.stage).toBe('setup_required');
expect(mocks.spawn).not.toHaveBeenCalled();
expect(mocks.install).not.toHaveBeenCalled();
expect(mocks.stage).not.toHaveBeenCalled();
await supervisor.shutdown();
},
);
it.each([true, false])(
'preserves a selected unowned runtime (project exists: %s)',
async (projectExists) => {
const { resolve, join } = await import('node:path');
const selected = resolve('/selected/VoiceStudio');
mocks.runtimeConfig = { root: selected, owned: false };
mocks.existingProject = projectExists;
mocks.existingRoot = true;
mocks.ready.mockResolvedValue(true);
mocks.dependencies.mockResolvedValue(false);
mocks.install.mockRejectedValue(new Error('offline'));
vi.stubGlobal(
'fetch',
vi.fn(async () => {
throw new Error('no backend');
}),
);
vi.stubEnv('OMNIVOICE_BACKEND_CMD', '');
vi.stubEnv('VOICESTUDIO_SKIP_BACKEND', '');
const supervisor = new BackendSupervisor();
await supervisor.start();
expect(supervisor.status.stage).toBe('setup_required');
expect(
mocks.dependencies.mock.calls.every(([project]) => String(project).includes('selected')),
).toBe(true);
expect(mocks.install).not.toHaveBeenCalled();
await supervisor.setupRuntime();
expect(mocks.install.mock.calls[0][1]).toBe(join('/private/voicestudio', 'runtime', 'project'));
expect(mocks.runtimeConfig?.root).not.toBe(selected);
expect(mocks.rm).not.toHaveBeenCalled();
expect(mocks.stage).not.toHaveBeenCalled();
await supervisor.shutdown();
},
);
it('installs into a newly selected custom destination that does not yet exist', async () => {
const { resolve, join } = await import('node:path');
const selected = resolve('/selected/VoiceStudio');
mocks.runtimeConfig = { root: selected, owned: false };
mocks.install.mockRejectedValue(new Error('offline'));
vi.stubGlobal(
'fetch',
vi.fn(async () => {
throw new Error('no backend');
}),
);
vi.stubEnv('OMNIVOICE_BACKEND_CMD', '');
vi.stubEnv('VOICESTUDIO_SKIP_BACKEND', '');
const supervisor = new BackendSupervisor();
await supervisor.start();
await supervisor.setupRuntime();
expect(mocks.install.mock.calls[0][1]).toBe(join(selected, 'project'));
expect(mocks.runtimeConfig).toEqual({ root: selected, owned: true });
await supervisor.shutdown();
});
it('reuses a healthy default runtime after explicit setup leaves an unowned environment', async () => {
const { resolve, join } = await import('node:path');
mocks.runtimeConfig = { root: resolve('/selected/VoiceStudio'), owned: false };
mocks.existingRoot = true;
mocks.ready.mockResolvedValue(true);
mocks.dependencies.mockImplementation(async (project?: string) => !project?.includes('selected'));
vi.stubGlobal(
'fetch',
vi.fn(async () => {
throw new Error('no backend');
}),
);
vi.stubEnv('OMNIVOICE_BACKEND_CMD', '');
vi.stubEnv('VOICESTUDIO_SKIP_BACKEND', '');
const supervisor = new BackendSupervisor();
await supervisor.start();
expect(supervisor.status.stage).toBe('setup_required');
const restart = vi.spyOn(supervisor, 'start').mockResolvedValue();
await supervisor.setupRuntime();
expect(mocks.install).not.toHaveBeenCalled();
expect(mocks.dependencies).toHaveBeenCalledWith(
join('/private/voicestudio', 'runtime', 'project'),
);
expect(restart).toHaveBeenCalledOnce();
restart.mockRestore();
await supervisor.shutdown();
});
+51 -20
View File
@@ -2,6 +2,7 @@ import {
installRuntime,
promoteLegacyRuntimeCaches,
runtimeCompatible,
runtimeDependenciesReady,
runtimeInstallInterrupted,
runtimeReady,
runtimePython,
@@ -482,11 +483,8 @@ export class BackendSupervisor extends EventEmitter<{
}
if (app.isPackaged && !parseBackendCmdOverride(process.env.OMNIVOICE_BACKEND_CMD)) {
const project = await this.resolveRuntimeProject();
if (
!(await runtimeReady(backendRoot(), project)) &&
!(await runtimeCompatible(backendRoot(), project))
) {
const { project, ready } = await this.resolveRuntimeProject();
if (!ready) {
if (gen === this.generation) {
this.runtimeInterrupted = await runtimeInstallInterrupted(project);
this.setStage('setup_required');
@@ -522,7 +520,7 @@ export class BackendSupervisor extends EventEmitter<{
this.stage !== 'setup_required'
)
return;
const project =
let project =
this.runtimeProject ?? join(storedRuntimeRoot() ?? defaultRuntimeRoot(), 'project');
this.runtimeProject = project;
const controller = new AbortController();
@@ -537,15 +535,44 @@ export class BackendSupervisor extends EventEmitter<{
this.setStage('installing', { message: undefined });
try {
const reusable =
(await runtimeReady(backendRoot(), project)) ||
(await runtimeCompatible(backendRoot(), project));
((await runtimeReady(backendRoot(), project)) ||
(await runtimeCompatible(backendRoot(), project))) &&
(await runtimeDependenciesReady(project));
if (gen !== this.generation || controller.signal.aborted) return;
if (reusable) {
await this.start();
return;
}
const runtimeRoot = dirname(project);
let runtimeRoot = dirname(project);
const configured = storedRuntimeLocation();
if (
configured &&
!configured.owned &&
samePath(configured.root, runtimeRoot) &&
!samePath(runtimeRoot, defaultRuntimeRoot()) &&
existsSync(runtimeRoot)
) {
// An explicit setup action may create a new runtime, but must never
// take ownership of (or repair in place) another installation's files.
runtimeRoot = defaultRuntimeRoot();
project = join(runtimeRoot, 'project');
this.runtimeProject = project;
writeRuntimeLocation(runtimeRoot, true);
this.pushLog(
'out',
'Creating a separate Electron runtime; existing environment preserved.',
);
this.emitStatus();
const fallbackReusable =
((await runtimeReady(backendRoot(), project)) ||
(await runtimeCompatible(backendRoot(), project))) &&
(await runtimeDependenciesReady(project));
if (gen !== this.generation || controller.signal.aborted) return;
if (fallbackReusable) {
await this.start();
return;
}
}
if (
configured &&
samePath(configured.root, runtimeRoot) &&
@@ -797,27 +824,28 @@ export class BackendSupervisor extends EventEmitter<{
this.emitStatus();
}
private async resolveRuntimeProject(): Promise<string> {
private async resolveRuntimeProject(): Promise<{ project: string; ready: boolean }> {
const bundle = backendRoot();
const own = join(defaultRuntimeRoot(), 'project');
const configuredRoot = storedRuntimeRoot();
const configured = configuredRoot ? join(configuredRoot, 'project') : null;
const candidates = [
this.runtimeProject,
configured,
own,
...legacyTauriRuntimeProjects(),
].filter((candidate): candidate is string => Boolean(candidate));
// Explicit selection is authoritative, including when it needs setup.
const candidates = (
configured ? [configured] : [this.runtimeProject, own, ...legacyTauriRuntimeProjects()]
).filter((candidate): candidate is string => Boolean(candidate));
for (const project of new Set(candidates.map((candidate) => resolve(candidate)))) {
if ((await runtimeReady(bundle, project)) || (await runtimeCompatible(bundle, project))) {
if (
((await runtimeReady(bundle, project)) || (await runtimeCompatible(bundle, project))) &&
(await runtimeDependenciesReady(project))
) {
this.runtimeProject = project;
if (project !== own && project !== configured)
this.pushLog('out', `Reusing compatible Tauri runtime: ${project}`);
return project;
return { project, ready: true };
}
}
this.runtimeProject = configured ?? own;
return this.runtimeProject;
return { project: this.runtimeProject, ready: false };
}
private emitStatus(): void {
@@ -889,7 +917,10 @@ export class BackendSupervisor extends EventEmitter<{
// Python passes this child-side descriptor to every nested operation.
// Reading the parent side keeps the ownership channel live and lets
// Node observe EOF only after the complete backend subtree releases it.
const drain = child.stdio?.[processOptions.drainFd] as NodeJS.ReadableStream | null | undefined;
const drain = child.stdio?.[processOptions.drainFd] as
| NodeJS.ReadableStream
| null
| undefined;
drain?.on('error', (error: unknown) => {
if (!isExpectedPipeClose(error)) {
this.pushLog('err', `Backend drain stream failed: ${errorMessage(error)}`);
@@ -0,0 +1,63 @@
// @vitest-environment node
import { afterEach, expect, it, vi } from 'vitest';
import { execFile } from 'node:child_process';
import { runtimeDependenciesReady, runtimePython } from './runtime-project';
vi.mock('node:child_process', () => ({ execFile: vi.fn() }));
afterEach(() => {
vi.clearAllMocks();
vi.unstubAllEnvs();
});
it.each([null, new Error('No module named uvicorn'), new Error('ETIMEDOUT'), new Error('ENOENT')])(
'validates imports using the selected interpreter and fails closed (%s)',
async (error) => {
vi.mocked(execFile).mockImplementation(((
_command: unknown,
_args: unknown,
_options: unknown,
callback: (error: Error | null) => void,
) => callback(error)) as never);
const project = '/runtime with spaces';
expect(await runtimeDependenciesReady(project)).toBe(error === null);
expect(execFile).toHaveBeenCalledWith(
runtimePython(project),
['-c', 'import fastapi, uvicorn, omnivoice, faster_whisper'],
expect.objectContaining({
cwd: project,
timeout: 30_000,
windowsHide: true,
env: expect.objectContaining({
HF_HUB_OFFLINE: '1',
TRANSFORMERS_OFFLINE: '1',
PYTHONNOUSERSITE: '1',
}),
}),
expect.any(Function),
);
},
);
it.each(['PYTHONPATH', 'PYTHONHOME'] as const)(
'isolates imports from inherited %s',
async (variable) => {
vi.stubEnv(variable, '/unrelated-python');
vi.mocked(execFile).mockImplementation(((
_command: unknown,
_args: unknown,
options: { env: NodeJS.ProcessEnv },
callback: (error: Error | null) => void,
) => {
const contaminated = Boolean(options.env[variable]);
callback(
variable === 'PYTHONPATH'
? contaminated
? null
: new Error('No module named uvicorn')
: contaminated
? new Error('invalid Python home')
: null,
);
}) as never);
expect(await runtimeDependenciesReady('/selected-runtime')).toBe(variable === 'PYTHONHOME');
expect(process.env[variable]).toBe('/unrelated-python');
},
);
+25
View File
@@ -1,3 +1,4 @@
import { execFile } from 'node:child_process';
import { createHash, randomUUID } from 'node:crypto';
import {
cp,
@@ -247,6 +248,30 @@ export async function runtimeInstallInterrupted(project: string): Promise<boolea
}
}
/** Check the selected interpreter locally before trusting runtime metadata. */
export async function runtimeDependenciesReady(project: string): Promise<boolean> {
const env: NodeJS.ProcessEnv = { ...process.env };
delete env.PYTHONHOME;
delete env.PYTHONPATH;
env.HF_HUB_OFFLINE = '1';
env.TRANSFORMERS_OFFLINE = '1';
env.PYTHONNOUSERSITE = '1';
return new Promise((resolve) => {
execFile(
runtimePython(project),
['-c', 'import fastapi, uvicorn, omnivoice, faster_whisper'],
{
cwd: project,
windowsHide: true,
timeout: 30_000,
maxBuffer: 256 * 1024,
env,
},
(error) => resolve(!error),
);
});
}
export async function runtimeReady(bundle: string, project: string): Promise<boolean> {
if (await runtimeIncomplete(project)) return false;
try {
@@ -2,7 +2,26 @@ 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(() => ({
queryError: false,
retry: vi.fn(),
convert: vi.fn(),
ready: true,
cloning: true as boolean | null,
}));
vi.mock('@/hooks/use-engines', () => ({
useEngines: () => ({
isError: mock.queryError,
error: new Error('Engine query failed'),
retry: mock.retry,
data: mock.queryError ? undefined : {},
activeTtsReady: mock.ready,
activeTts: { supports_cloning: mock.cloning },
}),
}));
vi.mock('@tanstack/react-router', () => ({
Link: ({ children }: { children: React.ReactNode }) => <a>{children}</a>,
}));
vi.mock('@/lib/api/convert', () => ({ convertSpeech: mock.convert }));
vi.mock('@/hooks/use-recording', () => ({
useRecording: () => ({ isRecording: false, isStarting: false, isCleaning: false }),
@@ -22,6 +41,9 @@ import { ConvertVoice } from './convert-voice';
afterEach(() => {
cleanup();
clearConversion();
mock.queryError = false;
mock.ready = true;
mock.cloning = true;
vi.clearAllMocks();
});
function mount() {
@@ -79,3 +101,50 @@ 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();
});
it('offers retry instead of model guidance when engine lookup fails', () => {
mock.queryError = true;
mock.ready = false;
const { upload } = mount();
upload();
fireEvent.click(screen.getByRole('button', { name: 'Alpha' }));
expect(screen.getByRole('button', { name: 'convert.convert' })).toBeDisabled();
expect(screen.queryByText('convert.cloning_required')).not.toBeInTheDocument();
expect(screen.getByText('Engine query failed')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'common.retry' }));
expect(mock.retry).toHaveBeenCalledOnce();
});
@@ -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,32 @@ export function ConvertVoice() {
</label>
<p className="text-xs text-muted-foreground">{t('convert.match_duration_hint')}</p>
</div>
{engines.isError && !engines.data && !busy && (
<div
role="alert"
className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground"
>
<span>{describeError(engines.error)}</span>
<Button variant="outline" size="sm" onClick={engines.retry}>
{t('common.retry')}
</Button>
</div>
)}
{!canClone && !(engines.isError && !engines.data) && !busy && (
<div
role="status"
className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground"
>
<span>{t('convert.cloning_required')}</span>
<Link
to="/settings/models/$family"
params={{ family: 'tts' }}
className="font-medium text-primary hover:underline"
>
{t('modelSettings.models')}
</Link>
</div>
)}
{error && (
<PipelineFailure
fallback={error}
@@ -2245,6 +2245,7 @@
"tailscale_qr_alt": "رمز الاستجابة السريعة لعنوان URL لـ Tailscale"
},
"convert": {
"cloning_required": "اختر نموذجًا جاهزًا لتحويل النص إلى كلام يدعم استنساخ الصوت.",
"source_kicker": "المقطع المصدر",
"drop_audio": "أسقط المقطع المراد تحويله — أو انقر. WAV، MP3، M4A…",
"target_voice": "الصوت الهدف",
@@ -2237,6 +2237,7 @@
"tailscale_qr_alt": "QR-Code für die Tailscale-URL"
},
"convert": {
"cloning_required": "Wähle ein einsatzbereites Sprachsynthesemodell, das Stimmenklonen unterstützt.",
"source_kicker": "Quellclip",
"drop_audio": "Clip zum Konvertieren hier ablegen — oder klicken. WAV, MP3, M4A…",
"target_voice": "Zielstimme",
@@ -2288,6 +2288,7 @@
"tailscale_qr_alt": "QR code for the Tailscale URL"
},
"convert": {
"cloning_required": "Choose a ready text-to-speech model that supports voice cloning.",
"source_kicker": "Source clip",
"drop_audio": "Drop the clip to convert — or click. WAV, MP3, M4A…",
"target_voice": "Target voice",
@@ -2239,6 +2239,7 @@
"tailscale_qr_alt": "Código QR para la URL de Tailscale"
},
"convert": {
"cloning_required": "Elige un modelo de texto a voz listo para usar que permita clonar voces.",
"source_kicker": "Clip de origen",
"drop_audio": "Suelta el clip a convertir — o haz clic. WAV, MP3, M4A…",
"target_voice": "Voz de destino",
@@ -2239,6 +2239,7 @@
"tailscale_qr_alt": "Code QR pour l'URL Tailscale"
},
"convert": {
"cloning_required": "Choisissez un modèle de synthèse vocale prêt à utiliser qui prend en charge le clonage vocal.",
"source_kicker": "Clip source",
"drop_audio": "Déposez le clip à convertir — ou cliquez. WAV, MP3, M4A…",
"target_voice": "Voix cible",
@@ -2237,6 +2237,7 @@
"tailscale_qr_alt": "टेलस्केल यूआरएल के लिए क्यूआर कोड"
},
"convert": {
"cloning_required": "वॉइस क्लोनिंग का समर्थन करने वाला तैयार टेक्स्ट-टू-स्पीच मॉडल चुनें।",
"source_kicker": "स्रोत क्लिप",
"drop_audio": "कन्वर्ट करने के लिए क्लिप यहाँ छोड़ें — या क्लिक करें। WAV, MP3, M4A…",
"target_voice": "लक्ष्य आवाज़",
@@ -2237,6 +2237,7 @@
"tailscale_qr_alt": "Kode QR untuk URL Tailscale"
},
"convert": {
"cloning_required": "Pilih model teks ke suara yang siap digunakan dan mendukung kloning suara.",
"source_kicker": "Klip sumber",
"drop_audio": "Letakkan klip yang akan dikonversi — atau klik. WAV, MP3, M4A…",
"target_voice": "Suara target",
@@ -2239,6 +2239,7 @@
"tailscale_qr_alt": "Codice QR per l'URL Tailscale"
},
"convert": {
"cloning_required": "Scegli un modello di sintesi vocale pronto che supporti la clonazione della voce.",
"source_kicker": "Clip sorgente",
"drop_audio": "Trascina qui il clip da convertire — o fai clic. WAV, MP3, M4A…",
"target_voice": "Voce di destinazione",
@@ -2237,6 +2237,7 @@
"tailscale_qr_alt": "Tailscale URL の QR コード"
},
"convert": {
"cloning_required": "音声クローンに対応した、使用可能な音声合成モデルを選択してください。",
"source_kicker": "ソースクリップ",
"drop_audio": "変換するクリップをドロップ — またはクリック。WAV、MP3、M4A…",
"target_voice": "変換先の声",
@@ -2237,6 +2237,7 @@
"tailscale_qr_alt": "Tailscale URL의 QR 코드"
},
"convert": {
"cloning_required": "음성 복제를 지원하는 사용 가능한 음성 합성 모델을 선택하세요.",
"source_kicker": "소스 클립",
"drop_audio": "변환할 클립을 끌어다 놓거나 클릭하세요. WAV, MP3, M4A…",
"target_voice": "대상 목소리",
@@ -2237,6 +2237,7 @@
"tailscale_qr_alt": "QR-code voor de Tailscale-URL"
},
"convert": {
"cloning_required": "Kies een gebruiksklaar tekst-naar-spraakmodel dat stemmen klonen ondersteunt.",
"source_kicker": "Bronclip",
"drop_audio": "Sleep de te converteren clip hierheen — of klik. WAV, MP3, M4A…",
"target_voice": "Doelstem",
@@ -2241,6 +2241,7 @@
"tailscale_qr_alt": "Kod QR dla adresu URL Tailscale"
},
"convert": {
"cloning_required": "Wybierz gotowy do użycia model syntezy mowy obsługujący klonowanie głosu.",
"source_kicker": "Klip źródłowy",
"drop_audio": "Upuść klip do konwersji — lub kliknij. WAV, MP3, M4A…",
"target_voice": "Głos docelowy",
@@ -2239,6 +2239,7 @@
"tailscale_qr_alt": "Código QR para o URL Tailscale"
},
"convert": {
"cloning_required": "Escolha um modelo de síntese de voz pronto para uso que permita clonar vozes.",
"source_kicker": "Clipe de origem",
"drop_audio": "Solte o clipe a converter — ou clique. WAV, MP3, M4A…",
"target_voice": "Voz de destino",
@@ -2241,6 +2241,7 @@
"tailscale_qr_alt": "QR-код для URL-адреса Tailscale"
},
"convert": {
"cloning_required": "Выберите готовую к работе модель синтеза речи с поддержкой клонирования голоса.",
"source_kicker": "Исходный клип",
"drop_audio": "Перетащите клип для преобразования — или нажмите. WAV, MP3, M4A…",
"target_voice": "Целевой голос",
@@ -2237,6 +2237,7 @@
"tailscale_qr_alt": "QR-kod för Tailscale URL"
},
"convert": {
"cloning_required": "Välj en användningsklar talsyntesmodell som stöder röstkloning.",
"source_kicker": "Källklipp",
"drop_audio": "Släpp klippet som ska konverteras — eller klicka. WAV, MP3, M4A…",
"target_voice": "Målröst",
@@ -2237,6 +2237,7 @@
"tailscale_qr_alt": "รหัส QR สำหรับ Tailscale URL"
},
"convert": {
"cloning_required": "เลือกโมเดลแปลงข้อความเป็นเสียงที่พร้อมใช้งานและรองรับการโคลนเสียง",
"source_kicker": "คลิปต้นฉบับ",
"drop_audio": "วางคลิปที่จะแปลงที่นี่ — หรือคลิก WAV, MP3, M4A…",
"target_voice": "เสียงปลายทาง",
@@ -2237,6 +2237,7 @@
"tailscale_qr_alt": "Kuyruk Ölçeği URL'si için QR kodu"
},
"convert": {
"cloning_required": "Ses klonlamayı destekleyen, kullanıma hazır bir metinden konuşmaya modeli seçin.",
"source_kicker": "Kaynak klip",
"drop_audio": "Dönüştürülecek klibi bırakın — veya tıklayın. WAV, MP3, M4A…",
"target_voice": "Hedef ses",
@@ -2241,6 +2241,7 @@
"tailscale_qr_alt": "QR-код для URL-адреси Tailscale"
},
"convert": {
"cloning_required": "Виберіть готову до роботи модель синтезу мовлення з підтримкою клонування голосу.",
"source_kicker": "Вихідний кліп",
"drop_audio": "Перетягніть кліп для перетворення — або натисніть. WAV, MP3, M4A…",
"target_voice": "Цільовий голос",
@@ -2237,6 +2237,7 @@
"tailscale_qr_alt": "Mã QR cho URL Tailscale"
},
"convert": {
"cloning_required": "Chọn mô hình chuyển văn bản thành giọng nói sẵn sàng sử dụng và hỗ trợ nhân bản giọng nói.",
"source_kicker": "Clip nguồn",
"drop_audio": "Thả clip cần chuyển đổi vào đây — hoặc nhấp. WAV, MP3, M4A…",
"target_voice": "Giọng đích",
@@ -2241,6 +2241,7 @@
"tailscale_qr_alt": "Tailscale URL 的二维码"
},
"convert": {
"cloning_required": "请选择已就绪且支持声音克隆的语音合成模型。",
"source_kicker": "源音频",
"drop_audio": "拖入要转换的音频 — 或点击。WAV、MP3、M4A…",
"target_voice": "目标声音",
@@ -2237,6 +2237,7 @@
"tailscale_qr_alt": "Tailscale URL 的二維碼"
},
"convert": {
"cloning_required": "請選擇已就緒且支援聲音複製的語音合成模型。",
"source_kicker": "來源音訊",
"drop_audio": "拖入要轉換的音訊 — 或點擊。WAV、MP3、M4A…",
"target_voice": "目標聲音",
+1
View File
@@ -2824,6 +2824,7 @@
"none_selected": "لم يُحدد أي صوت — صِف واحدًا، أو أسقط ملفًا صوتيًا، أو اختر من الأسفل."
},
"convert": {
"cloning_required": "اختر نموذجًا جاهزًا لتحويل النص إلى كلام يدعم استنساخ الصوت.",
"source_kicker": "المقطع المصدر",
"drop_audio": "أسقط المقطع المراد تحويله — أو انقر. WAV، MP3، M4A…",
"target_voice": "الصوت الهدف",
+1
View File
@@ -2824,6 +2824,7 @@
"none_selected": "Keine Stimme ausgewählt — beschreiben Sie eine, legen Sie Audio ab oder wählen Sie unten eine aus."
},
"convert": {
"cloning_required": "Wähle ein einsatzbereites Sprachsynthesemodell, das Stimmenklonen unterstützt.",
"source_kicker": "Quellclip",
"drop_audio": "Clip zum Konvertieren hier ablegen — oder klicken. WAV, MP3, M4A…",
"target_voice": "Zielstimme",
+1
View File
@@ -3306,6 +3306,7 @@
"none_selected": "No voice selected — describe one, drop audio, or pick below."
},
"convert": {
"cloning_required": "Choose a ready text-to-speech model that supports voice cloning.",
"source_kicker": "Source clip",
"drop_audio": "Drop the clip to convert — or click. WAV, MP3, M4A…",
"target_voice": "Target voice",
+1
View File
@@ -2824,6 +2824,7 @@
"none_selected": "Ninguna voz seleccionada — describe una, suelta un audio o elige abajo."
},
"convert": {
"cloning_required": "Elige un modelo de texto a voz listo para usar que permita clonar voces.",
"source_kicker": "Clip de origen",
"drop_audio": "Suelta el clip a convertir — o haz clic. WAV, MP3, M4A…",
"target_voice": "Voz de destino",
+1
View File
@@ -2824,6 +2824,7 @@
"none_selected": "Aucune voix sélectionnée — décrivez-en une, déposez un audio ou choisissez ci-dessous."
},
"convert": {
"cloning_required": "Choisissez un modèle de synthèse vocale prêt à utiliser qui prend en charge le clonage vocal.",
"source_kicker": "Clip source",
"drop_audio": "Déposez le clip à convertir — ou cliquez. WAV, MP3, M4A…",
"target_voice": "Voix cible",
+1
View File
@@ -2824,6 +2824,7 @@
"none_selected": "कोई आवाज़ चुनी नहीं गई — किसी का वर्णन करें, ऑडियो छोड़ें, या नीचे से चुनें।"
},
"convert": {
"cloning_required": "वॉइस क्लोनिंग का समर्थन करने वाला तैयार टेक्स्ट-टू-स्पीच मॉडल चुनें।",
"source_kicker": "स्रोत क्लिप",
"drop_audio": "कन्वर्ट करने के लिए क्लिप यहाँ छोड़ें — या क्लिक करें। WAV, MP3, M4A…",
"target_voice": "लक्ष्य आवाज़",
+1
View File
@@ -2824,6 +2824,7 @@
"none_selected": "Belum ada suara yang dipilih — deskripsikan satu, letakkan audio, atau pilih di bawah."
},
"convert": {
"cloning_required": "Pilih model teks ke suara yang siap digunakan dan mendukung kloning suara.",
"source_kicker": "Klip sumber",
"drop_audio": "Letakkan klip yang akan dikonversi — atau klik. WAV, MP3, M4A…",
"target_voice": "Suara target",
+1
View File
@@ -2824,6 +2824,7 @@
"none_selected": "Nessuna voce selezionata — descrivine una, trascina un audio o scegli qui sotto."
},
"convert": {
"cloning_required": "Scegli un modello di sintesi vocale pronto che supporti la clonazione della voce.",
"source_kicker": "Clip sorgente",
"drop_audio": "Trascina qui il clip da convertire — o fai clic. WAV, MP3, M4A…",
"target_voice": "Voce di destinazione",
+1
View File
@@ -2824,6 +2824,7 @@
"none_selected": "声が選択されていません — 説明するか、音声をドロップするか、下から選んでください。"
},
"convert": {
"cloning_required": "音声クローンに対応した、使用可能な音声合成モデルを選択してください。",
"source_kicker": "ソースクリップ",
"drop_audio": "変換するクリップをドロップ — またはクリック。WAV、MP3、M4A…",
"target_voice": "変換先の声",
+1
View File
@@ -3254,6 +3254,7 @@
"none_selected": "선택된 음성이 없습니다 — 설명하거나, 오디오를 놓거나, 아래에서 선택하세요."
},
"convert": {
"cloning_required": "음성 복제를 지원하는 사용 가능한 음성 합성 모델을 선택하세요.",
"source_kicker": "소스 클립",
"drop_audio": "변환할 클립을 끌어다 놓거나 클릭하세요. WAV, MP3, M4A…",
"target_voice": "대상 목소리",
+1
View File
@@ -2824,6 +2824,7 @@
"none_selected": "Geen stem geselecteerd — beschrijf er een, zet audio neer of kies hieronder."
},
"convert": {
"cloning_required": "Kies een gebruiksklaar tekst-naar-spraakmodel dat stemmen klonen ondersteunt.",
"source_kicker": "Bronclip",
"drop_audio": "Sleep de te converteren clip hierheen — of klik. WAV, MP3, M4A…",
"target_voice": "Doelstem",
+1
View File
@@ -2824,6 +2824,7 @@
"none_selected": "Nie wybrano głosu — opisz go, upuść audio albo wybierz poniżej."
},
"convert": {
"cloning_required": "Wybierz gotowy do użycia model syntezy mowy obsługujący klonowanie głosu.",
"source_kicker": "Klip źródłowy",
"drop_audio": "Upuść klip do konwersji — lub kliknij. WAV, MP3, M4A…",
"target_voice": "Głos docelowy",
+1
View File
@@ -2824,6 +2824,7 @@
"none_selected": "Nenhuma voz selecionada — descreva uma, solte um áudio ou escolha abaixo."
},
"convert": {
"cloning_required": "Escolha um modelo de síntese de voz pronto para uso que permita clonar vozes.",
"source_kicker": "Clipe de origem",
"drop_audio": "Solte o clipe a converter — ou clique. WAV, MP3, M4A…",
"target_voice": "Voz de destino",
+1
View File
@@ -2824,6 +2824,7 @@
"none_selected": "Голос не выбран — опишите его, перетащите аудио или выберите ниже."
},
"convert": {
"cloning_required": "Выберите готовую к работе модель синтеза речи с поддержкой клонирования голоса.",
"source_kicker": "Исходный клип",
"drop_audio": "Перетащите клип для преобразования — или нажмите. WAV, MP3, M4A…",
"target_voice": "Целевой голос",
+1
View File
@@ -2824,6 +2824,7 @@
"none_selected": "Ingen röst vald — beskriv en, släpp ljud eller välj nedan."
},
"convert": {
"cloning_required": "Välj en användningsklar talsyntesmodell som stöder röstkloning.",
"source_kicker": "Källklipp",
"drop_audio": "Släpp klippet som ska konverteras — eller klicka. WAV, MP3, M4A…",
"target_voice": "Målröst",
+1
View File
@@ -2824,6 +2824,7 @@
"none_selected": "ยังไม่ได้เลือกเสียง — อธิบายเสียง วางไฟล์เสียง หรือเลือกด้านล่าง"
},
"convert": {
"cloning_required": "เลือกโมเดลแปลงข้อความเป็นเสียงที่พร้อมใช้งานและรองรับการโคลนเสียง",
"source_kicker": "คลิปต้นฉบับ",
"drop_audio": "วางคลิปที่จะแปลงที่นี่ — หรือคลิก WAV, MP3, M4A…",
"target_voice": "เสียงปลายทาง",
+1
View File
@@ -2824,6 +2824,7 @@
"none_selected": "Ses seçilmedi — birini tarif edin, ses dosyası bırakın veya aşağıdan seçin."
},
"convert": {
"cloning_required": "Ses klonlamayı destekleyen, kullanıma hazır bir metinden konuşmaya modeli seçin.",
"source_kicker": "Kaynak klip",
"drop_audio": "Dönüştürülecek klibi bırakın — veya tıklayın. WAV, MP3, M4A…",
"target_voice": "Hedef ses",
+1
View File
@@ -2824,6 +2824,7 @@
"none_selected": "Голос не вибрано — опишіть його, перетягніть аудіо або виберіть нижче."
},
"convert": {
"cloning_required": "Виберіть готову до роботи модель синтезу мовлення з підтримкою клонування голосу.",
"source_kicker": "Вихідний кліп",
"drop_audio": "Перетягніть кліп для перетворення — або натисніть. WAV, MP3, M4A…",
"target_voice": "Цільовий голос",
+1
View File
@@ -2824,6 +2824,7 @@
"none_selected": "Chưa chọn giọng nào — hãy mô tả một giọng, thả tệp âm thanh, hoặc chọn bên dưới."
},
"convert": {
"cloning_required": "Chọn mô hình chuyển văn bản thành giọng nói sẵn sàng sử dụng và hỗ trợ nhân bản giọng nói.",
"source_kicker": "Clip nguồn",
"drop_audio": "Thả clip cần chuyển đổi vào đây — hoặc nhấp. WAV, MP3, M4A…",
"target_voice": "Giọng đích",
+1
View File
@@ -3306,6 +3306,7 @@
"none_selected": "未选择声音——描述一个、拖入音频,或从下方选择。"
},
"convert": {
"cloning_required": "请选择已就绪且支持声音克隆的语音合成模型。",
"source_kicker": "源音频",
"drop_audio": "拖入要转换的音频 — 或点击。WAV、MP3、M4A…",
"target_voice": "目标声音",
+1
View File
@@ -2824,6 +2824,7 @@
"none_selected": "尚未選擇聲音——描述一個、放入音訊,或從下方挑選。"
},
"convert": {
"cloning_required": "請選擇已就緒且支援聲音複製的語音合成模型。",
"source_kicker": "來源音訊",
"drop_audio": "拖入要轉換的音訊 — 或點擊。WAV、MP3、M4A…",
"target_voice": "目標聲音",
+15
View File
@@ -0,0 +1,15 @@
import pytest
@pytest.mark.parametrize('model,expected', [('kokoro', False), ('csm', True), ('org/unknown', False)])
def test_active_mlx_capability_resolves_without_loading(model, expected, monkeypatch):
from api.routers import engines
from services import tts_backend
monkeypatch.setenv('OMNIVOICE_MLX_AUDIO_MODEL', model)
monkeypatch.setattr(tts_backend, 'active_backend_id', lambda: 'mlx-audio')
monkeypatch.setattr(tts_backend, 'list_backends', lambda: [
{'id': 'mlx-audio', 'available': True, 'supports_cloning': None},
])
monkeypatch.setattr(tts_backend.MLXAudioBackend, '_ensure_loaded', lambda self: pytest.fail('loaded weights'))
payload = engines._family_payload('tts', tts_backend)
assert payload['backends'][0]['supports_cloning'] is expected