Merge remote-tracking branch 'origin/main' into fix/review-2175
# Conflicts: # CHANGELOG.md
This commit is contained in:
@@ -17,6 +17,8 @@ the frozen-backend fallback mirror it for their toolchains.
|
||||
### Fixed
|
||||
|
||||
- Name the voice profile when its saved language is one the active engine can't speak, instead of advising a language picker already set to Auto (#2175, #2156) — thanks @shivsin25!
|
||||
- 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!
|
||||
@@ -103,6 +105,7 @@ the frozen-backend fallback mirror it for their toolchains.
|
||||
- Transcribing an M4A file with PyTorch Whisper works, instead of failing with "Format not recognised" (#2042, #2039)
|
||||
- PyTorch Whisper runs on 6 GB NVIDIA cards instead of falling back to CPU, because its memory check now fits the model it loads (#2044, #2041)
|
||||
- MCP tools wait as long as the backend does, so a long transcription no longer fails at 120 s with an empty error (#2043, #2040)
|
||||
- Installing a gated model now sends your Hugging Face token on the fast download path too, so pyannote diarisation and gated engine weights stop failing with "401 Unauthorized" when the token is saved in Settings (#2173, #2163)
|
||||
- Generating on an older NVIDIA GPU (Tesla T4, and other pre-Ampere cards) no longer kills the backend on the first request — CUDA graphs are not captured below sm_80 (#2135)
|
||||
- "Disable torch.compile" in Settings → Performance now works on macOS and Linux, not only Windows; it was greyed out on the platforms that needed it (#2135)
|
||||
- Setting `TORCH_COMPILE_DISABLE=1` in the environment now actually disables torch.compile, for the in-process engine and engine subprocesses alike (#2135)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -230,7 +230,16 @@ def _segmented_snapshot(repo_id: str, *, endpoint: "str | None", revision: str)
|
||||
from services.segmented_download import segmented_download
|
||||
from services.token_resolver import resolve as _resolve_token
|
||||
|
||||
token = _resolve_token()
|
||||
# `resolve()` returns a ResolvedToken record, not the bearer string, and
|
||||
# every consumer below is typed `token: str | None`. Handing over the
|
||||
# record fails silently rather than loudly (#2163): huggingface_hub's
|
||||
# build_hf_headers ignores a non-str token and falls back to its own
|
||||
# ambient discovery, so a token held only in VoiceStudio's settings sends
|
||||
# NO Authorization header at all and every gated file 401s; our own
|
||||
# segmented_download interpolates it into `f"Bearer {token}"` and sends a
|
||||
# malformed header carrying the raw secret. Unwrap once, here.
|
||||
_resolved = _resolve_token()
|
||||
token = _resolved.token if _resolved else None
|
||||
api = HfApi(endpoint=endpoint, token=token)
|
||||
info = api.repo_info(repo_id, repo_type="model", revision=revision)
|
||||
commit = info.sha
|
||||
|
||||
@@ -1543,10 +1543,15 @@ def _step_fetch_weights(spec: SidecarSpec, job: dict) -> None:
|
||||
# other model download in the app — see setup/download.py): the
|
||||
# source checkout is unpinned upstream `main` anyway, and hf_hub
|
||||
# checksum-verifies each artifact. Hence the B615 waiver below.
|
||||
# Unwrap to the bearer string: snapshot_download takes `token: str |
|
||||
# None`, and a ResolvedToken record is ignored in favour of ambient
|
||||
# discovery, so gated engine weights 401 for a user whose token lives
|
||||
# in VoiceStudio's settings rather than HF's own cache (#2163).
|
||||
_resolved = resolve_token()
|
||||
kwargs: dict = {
|
||||
"repo_id": spec.weights_repo_id,
|
||||
"local_dir": str(wdir),
|
||||
"token": resolve_token(),
|
||||
"token": _resolved.token if _resolved else None,
|
||||
}
|
||||
if spec.weights_revision:
|
||||
kwargs["revision"] = spec.weights_revision
|
||||
|
||||
@@ -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 Electron’s 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.
|
||||
|
||||
@@ -188,7 +188,13 @@ before the token works for downloads.
|
||||
3. Retry the job. The token state in **Settings → API Keys** should now show
|
||||
the "App" row with a green check next to your username.
|
||||
|
||||
**Linked issue:** [#35](https://github.com/debpalash/VoiceStudio/issues/35)
|
||||
If the token and license are already valid but an older build still reports
|
||||
401 during installation, update VoiceStudio and retry. Settings tokens now
|
||||
reach both the fast download path and engine-weight installers; no token
|
||||
rotation is needed for that fixed client bug.
|
||||
|
||||
**Linked issues:** [#35](https://github.com/debpalash/VoiceStudio/issues/35),
|
||||
[#2163](https://github.com/debpalash/VoiceStudio/issues/2163)
|
||||
|
||||
### PocketTTS gated weights
|
||||
|
||||
@@ -1203,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.
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
},
|
||||
);
|
||||
@@ -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",
|
||||
|
||||
@@ -2289,6 +2289,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": "目標聲音",
|
||||
|
||||
@@ -2825,6 +2825,7 @@
|
||||
"none_selected": "لم يُحدد أي صوت — صِف واحدًا، أو أسقط ملفًا صوتيًا، أو اختر من الأسفل."
|
||||
},
|
||||
"convert": {
|
||||
"cloning_required": "اختر نموذجًا جاهزًا لتحويل النص إلى كلام يدعم استنساخ الصوت.",
|
||||
"source_kicker": "المقطع المصدر",
|
||||
"drop_audio": "أسقط المقطع المراد تحويله — أو انقر. WAV، MP3، M4A…",
|
||||
"target_voice": "الصوت الهدف",
|
||||
|
||||
@@ -2825,6 +2825,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",
|
||||
|
||||
@@ -3307,6 +3307,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",
|
||||
|
||||
@@ -2825,6 +2825,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",
|
||||
|
||||
@@ -2825,6 +2825,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",
|
||||
|
||||
@@ -2825,6 +2825,7 @@
|
||||
"none_selected": "कोई आवाज़ चुनी नहीं गई — किसी का वर्णन करें, ऑडियो छोड़ें, या नीचे से चुनें।"
|
||||
},
|
||||
"convert": {
|
||||
"cloning_required": "वॉइस क्लोनिंग का समर्थन करने वाला तैयार टेक्स्ट-टू-स्पीच मॉडल चुनें।",
|
||||
"source_kicker": "स्रोत क्लिप",
|
||||
"drop_audio": "कन्वर्ट करने के लिए क्लिप यहाँ छोड़ें — या क्लिक करें। WAV, MP3, M4A…",
|
||||
"target_voice": "लक्ष्य आवाज़",
|
||||
|
||||
@@ -2825,6 +2825,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",
|
||||
|
||||
@@ -2825,6 +2825,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",
|
||||
|
||||
@@ -2825,6 +2825,7 @@
|
||||
"none_selected": "声が選択されていません — 説明するか、音声をドロップするか、下から選んでください。"
|
||||
},
|
||||
"convert": {
|
||||
"cloning_required": "音声クローンに対応した、使用可能な音声合成モデルを選択してください。",
|
||||
"source_kicker": "ソースクリップ",
|
||||
"drop_audio": "変換するクリップをドロップ — またはクリック。WAV、MP3、M4A…",
|
||||
"target_voice": "変換先の声",
|
||||
|
||||
@@ -3255,6 +3255,7 @@
|
||||
"none_selected": "선택된 음성이 없습니다 — 설명하거나, 오디오를 놓거나, 아래에서 선택하세요."
|
||||
},
|
||||
"convert": {
|
||||
"cloning_required": "음성 복제를 지원하는 사용 가능한 음성 합성 모델을 선택하세요.",
|
||||
"source_kicker": "소스 클립",
|
||||
"drop_audio": "변환할 클립을 끌어다 놓거나 클릭하세요. WAV, MP3, M4A…",
|
||||
"target_voice": "대상 목소리",
|
||||
|
||||
@@ -2825,6 +2825,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",
|
||||
|
||||
@@ -2825,6 +2825,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",
|
||||
|
||||
@@ -2825,6 +2825,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",
|
||||
|
||||
@@ -2825,6 +2825,7 @@
|
||||
"none_selected": "Голос не выбран — опишите его, перетащите аудио или выберите ниже."
|
||||
},
|
||||
"convert": {
|
||||
"cloning_required": "Выберите готовую к работе модель синтеза речи с поддержкой клонирования голоса.",
|
||||
"source_kicker": "Исходный клип",
|
||||
"drop_audio": "Перетащите клип для преобразования — или нажмите. WAV, MP3, M4A…",
|
||||
"target_voice": "Целевой голос",
|
||||
|
||||
@@ -2825,6 +2825,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",
|
||||
|
||||
@@ -2825,6 +2825,7 @@
|
||||
"none_selected": "ยังไม่ได้เลือกเสียง — อธิบายเสียง วางไฟล์เสียง หรือเลือกด้านล่าง"
|
||||
},
|
||||
"convert": {
|
||||
"cloning_required": "เลือกโมเดลแปลงข้อความเป็นเสียงที่พร้อมใช้งานและรองรับการโคลนเสียง",
|
||||
"source_kicker": "คลิปต้นฉบับ",
|
||||
"drop_audio": "วางคลิปที่จะแปลงที่นี่ — หรือคลิก WAV, MP3, M4A…",
|
||||
"target_voice": "เสียงปลายทาง",
|
||||
|
||||
@@ -2825,6 +2825,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",
|
||||
|
||||
@@ -2825,6 +2825,7 @@
|
||||
"none_selected": "Голос не вибрано — опишіть його, перетягніть аудіо або виберіть нижче."
|
||||
},
|
||||
"convert": {
|
||||
"cloning_required": "Виберіть готову до роботи модель синтезу мовлення з підтримкою клонування голосу.",
|
||||
"source_kicker": "Вихідний кліп",
|
||||
"drop_audio": "Перетягніть кліп для перетворення — або натисніть. WAV, MP3, M4A…",
|
||||
"target_voice": "Цільовий голос",
|
||||
|
||||
@@ -2825,6 +2825,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",
|
||||
|
||||
@@ -3307,6 +3307,7 @@
|
||||
"none_selected": "未选择声音——描述一个、拖入音频,或从下方选择。"
|
||||
},
|
||||
"convert": {
|
||||
"cloning_required": "请选择已就绪且支持声音克隆的语音合成模型。",
|
||||
"source_kicker": "源音频",
|
||||
"drop_audio": "拖入要转换的音频 — 或点击。WAV、MP3、M4A…",
|
||||
"target_voice": "目标声音",
|
||||
|
||||
@@ -2825,6 +2825,7 @@
|
||||
"none_selected": "尚未選擇聲音——描述一個、放入音訊,或從下方挑選。"
|
||||
},
|
||||
"convert": {
|
||||
"cloning_required": "請選擇已就緒且支援聲音複製的語音合成模型。",
|
||||
"source_kicker": "來源音訊",
|
||||
"drop_audio": "拖入要轉換的音訊 — 或點擊。WAV、MP3、M4A…",
|
||||
"target_voice": "目標聲音",
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,135 @@
|
||||
"""#2163: a gated model install must actually send the HF bearer token.
|
||||
|
||||
``token_resolver.resolve()`` returns a ``ResolvedToken`` *record*. Every
|
||||
huggingface_hub entry point — and our own ``segmented_download`` — takes
|
||||
``token: str | None``. The segmented accelerator handed the record straight
|
||||
through, and neither consumer complains:
|
||||
|
||||
* ``build_hf_headers`` ignores a non-``str`` token and falls back to
|
||||
huggingface_hub's own ambient discovery, so a token held only in
|
||||
VoiceStudio's Settings produces **no** ``Authorization`` header at all and
|
||||
every gated file 401s;
|
||||
* ``segmented_download`` interpolates it into ``f"Bearer {token}"``, sending a
|
||||
malformed header that also inlines the raw secret into the request.
|
||||
|
||||
Either way the accelerator 401s on the first file of a gated repo, is disabled
|
||||
for the rest of the install, and logs a 401 that reads like the user's token or
|
||||
license grant is at fault when it is neither.
|
||||
"""
|
||||
import importlib
|
||||
import os
|
||||
from types import SimpleNamespace
|
||||
|
||||
os.environ.setdefault("OMNIVOICE_MODEL", "test")
|
||||
os.environ.setdefault("OMNIVOICE_DISABLE_FILE_LOG", "1")
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def download():
|
||||
return importlib.import_module("api.routers.setup.download")
|
||||
|
||||
|
||||
GATED_REPO = "pyannote/speaker-diarization-3.1"
|
||||
REVISION = "c" * 40
|
||||
|
||||
|
||||
def _drive_segmented(download, monkeypatch, tmp_path, resolved):
|
||||
"""Run ``_segmented_snapshot`` with every network seam mocked.
|
||||
|
||||
Returns the token value each of the three consumers actually received.
|
||||
"""
|
||||
import huggingface_hub
|
||||
from huggingface_hub import file_download as hf_file_download
|
||||
from services import segmented_download as sd_mod
|
||||
from services import token_resolver
|
||||
|
||||
monkeypatch.setattr(token_resolver, "resolve", lambda *a, **k: resolved)
|
||||
monkeypatch.setattr(huggingface_hub.constants, "HF_HUB_CACHE", str(tmp_path))
|
||||
|
||||
seen: dict = {}
|
||||
|
||||
class _FakeApi:
|
||||
def __init__(self, *, endpoint=None, token=None):
|
||||
seen["hf_api"] = token
|
||||
|
||||
def repo_info(self, repo_id, repo_type=None, revision=None):
|
||||
# Gated repos serve metadata unauthenticated and gate the file
|
||||
# bytes — which is why the 401 in #2163 lands on the first
|
||||
# resolve() call rather than here.
|
||||
return SimpleNamespace(
|
||||
sha=revision,
|
||||
siblings=[SimpleNamespace(rfilename="config.yaml")],
|
||||
)
|
||||
|
||||
monkeypatch.setattr(huggingface_hub, "HfApi", _FakeApi)
|
||||
|
||||
def _fake_metadata(url, token=None, **_kw):
|
||||
seen["file_metadata"] = token
|
||||
return SimpleNamespace(etag='"deadbeef"', location=url, size=10)
|
||||
|
||||
monkeypatch.setattr(hf_file_download, "get_hf_file_metadata", _fake_metadata)
|
||||
|
||||
async def _fake_segmented(url, blob_path, *, token=None, **_kw):
|
||||
seen["segmented"] = token
|
||||
with open(blob_path, "wb") as fh:
|
||||
fh.write(b"config: ok")
|
||||
return blob_path
|
||||
|
||||
monkeypatch.setattr(sd_mod, "segmented_download", _fake_segmented)
|
||||
|
||||
download._segmented_snapshot(GATED_REPO, endpoint=None, revision=REVISION)
|
||||
return seen
|
||||
|
||||
|
||||
def test_segmented_install_sends_the_bearer_string_to_every_consumer(
|
||||
download, monkeypatch, tmp_path
|
||||
):
|
||||
from services.token_resolver import ResolvedToken
|
||||
seen = _drive_segmented(
|
||||
download,
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
ResolvedToken(token="hf_gatedsecret", source="app", username="tester"),
|
||||
)
|
||||
|
||||
assert seen == {
|
||||
"hf_api": "hf_gatedsecret",
|
||||
"file_metadata": "hf_gatedsecret",
|
||||
"segmented": "hf_gatedsecret",
|
||||
}
|
||||
# The record itself must never cross the seam — that is the whole bug.
|
||||
for consumer, value in seen.items():
|
||||
assert isinstance(value, str), f"{consumer} received {type(value).__name__}"
|
||||
|
||||
|
||||
def test_segmented_install_sends_no_token_when_none_resolves(
|
||||
download, monkeypatch, tmp_path
|
||||
):
|
||||
# No token anywhere: every consumer must get a real None so huggingface_hub
|
||||
# treats the repo as anonymous, never the string "None".
|
||||
seen = _drive_segmented(download, monkeypatch, tmp_path, None)
|
||||
assert seen == {"hf_api": None, "file_metadata": None, "segmented": None}
|
||||
|
||||
|
||||
def test_a_token_record_would_build_a_broken_authorization_header():
|
||||
"""Why the unwrap matters, pinned at our own auth seam.
|
||||
|
||||
``_auth_headers`` is the function that turns the token into the header the
|
||||
segmented downloader sends. Given the bearer string it produces a valid
|
||||
header; given the record it produces a malformed one that also inlines the
|
||||
raw secret. This is the failure #2163 reported as a 401.
|
||||
"""
|
||||
from services.token_resolver import ResolvedToken
|
||||
from services.segmented_download import _auth_headers
|
||||
|
||||
url = "https://huggingface.co/pyannote/speaker-diarization-3.1/resolve/main/config.yaml"
|
||||
record = ResolvedToken(token="hf_gatedsecret", source="app", username="tester")
|
||||
|
||||
assert _auth_headers(url, record.token) == {"Authorization": "Bearer hf_gatedsecret"}
|
||||
|
||||
broken = _auth_headers(url, record)["Authorization"]
|
||||
assert broken != "Bearer hf_gatedsecret"
|
||||
assert "ResolvedToken" in broken
|
||||
@@ -8,10 +8,17 @@ from services import hf_revisions
|
||||
|
||||
def test_every_catalog_repo_has_an_immutable_revision():
|
||||
catalog = yaml.safe_load(Path("backend/config/models.yaml").read_text(encoding="utf-8"))
|
||||
# Dependency repos are downloaded by the installer exactly like top-level
|
||||
# ones (setup/download.py resolves `revision_for(dependency["repo_id"])`),
|
||||
# and `revision_for` raises on an unpinned repo — so an unpinned dependency
|
||||
# ships an install that always fails. Pin them under the same rule (#2163).
|
||||
curated = []
|
||||
for model in catalog["models"]:
|
||||
curated.append(model["repo_id"])
|
||||
for dependency in model.get("dependencies") or ():
|
||||
curated.append(dependency["repo_id"])
|
||||
missing = {
|
||||
model["repo_id"]
|
||||
for model in catalog["models"]
|
||||
if model["repo_id"] not in hf_revisions.CURATED_REVISIONS
|
||||
repo_id for repo_id in curated if repo_id not in hf_revisions.CURATED_REVISIONS
|
||||
}
|
||||
assert missing == set()
|
||||
assert all(len(revision) == 40 for revision in hf_revisions.CURATED_REVISIONS.values())
|
||||
|
||||
@@ -43,6 +43,39 @@ def test_every_repo_id_is_well_formed():
|
||||
assert rid and _REPO_RE.match(rid), f"malformed repo_id: {rid!r}"
|
||||
|
||||
|
||||
def test_config_only_entries_declare_the_files_that_complete_them():
|
||||
"""A ``config_only`` repo carries no weights of its own, so the install
|
||||
validator judges it by ``config_required_files`` instead of a weight floor.
|
||||
Declare none and the entry is permanently uninstallable: the completeness
|
||||
check returns False and the install fails with a message whose list of
|
||||
required files is empty. That is the shape #2163 reported, so it is a
|
||||
catalogue invariant rather than something a user should discover.
|
||||
"""
|
||||
for m in _models():
|
||||
if not m.get("config_only"):
|
||||
continue
|
||||
required = m.get("config_required_files")
|
||||
assert isinstance(required, list) and required, f"{m['repo_id']}: config_only needs a nonempty config_required_files list"
|
||||
assert all(
|
||||
isinstance(name, str) and name.strip() for name in required
|
||||
), f"{m['repo_id']}: blank entry in config_required_files"
|
||||
|
||||
|
||||
def test_dependency_declarations_are_installable():
|
||||
"""Every declared dependency needs an id and the files that prove it landed
|
||||
— the installer rejects a dependency snapshot that lacks them."""
|
||||
for m in _models():
|
||||
for dependency in m.get("dependencies") or ():
|
||||
rid = dependency.get("repo_id")
|
||||
assert rid and _REPO_RE.match(rid), f"malformed dependency repo_id: {rid!r}"
|
||||
required = dependency.get("required_files")
|
||||
assert isinstance(required, list) and required and all(
|
||||
isinstance(name, str) and name.strip() for name in required
|
||||
), (
|
||||
f"{m['repo_id']} → {rid}: dependency needs required_files"
|
||||
)
|
||||
|
||||
|
||||
def test_required_fields_present():
|
||||
for m in _models():
|
||||
for field in ("repo_id", "label", "role"):
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
"""#2163: installing the pyannote diarisation pipeline end to end.
|
||||
|
||||
The pipeline repo (`pyannote/speaker-diarization-3.1`) carries a `config.yaml`
|
||||
and no weights of its own — the real checkpoints live in the two repositories
|
||||
its catalogue entry declares as `dependencies`. That shape exercises three
|
||||
things at once, and the report hit all three:
|
||||
|
||||
* the finished-snapshot validator must accept a weightless `config_only` repo
|
||||
instead of rejecting it as a truncated download;
|
||||
* both dependency repositories must actually be fetched, or the install
|
||||
"succeeds" with nothing that can run;
|
||||
* every download must carry the resolved HF bearer token **as a string**, since
|
||||
the pipeline and segmentation repos are gated.
|
||||
|
||||
This is the integration guard for the whole scenario; the token seam itself is
|
||||
unit-tested in ``test_gated_install_token_2163.py``.
|
||||
"""
|
||||
import asyncio
|
||||
import importlib
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
os.environ.setdefault("OMNIVOICE_MODEL", "test")
|
||||
os.environ.setdefault("OMNIVOICE_DISABLE_FILE_LOG", "1")
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
|
||||
PIPELINE = "pyannote/speaker-diarization-3.1"
|
||||
SEGMENTATION = "pyannote/segmentation-3.0"
|
||||
EMBEDDING = "pyannote/wespeaker-voxceleb-resnet34-LM"
|
||||
|
||||
_WEIGHT_BYTES = 6 * 1024 * 1024 # clears the 5 MB .bin floor in setup/models
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def download():
|
||||
return importlib.import_module("api.routers.setup.download")
|
||||
|
||||
|
||||
def _install_pyannote(download, monkeypatch, tmp_path):
|
||||
"""Run POST /models/install for the pipeline repo with the Hub mocked.
|
||||
|
||||
Returns (snapshot_download kwargs per call, emitted SSE events).
|
||||
"""
|
||||
from services.token_resolver import ResolvedToken
|
||||
import huggingface_hub
|
||||
from services import hf_revisions, performance_profiles, token_resolver
|
||||
from utils import hf_progress
|
||||
|
||||
monkeypatch.setattr(
|
||||
token_resolver,
|
||||
"resolve",
|
||||
lambda *a, **k: ResolvedToken(
|
||||
token="hf_gatedsecret", source="app", username="tester"
|
||||
),
|
||||
)
|
||||
|
||||
calls: list[dict] = []
|
||||
|
||||
def fake_snapshot_download(**kwargs):
|
||||
calls.append(kwargs)
|
||||
if kwargs.get("dry_run"):
|
||||
return []
|
||||
repo_id = kwargs["repo_id"]
|
||||
path = tmp_path / repo_id.replace("/", "__")
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
# Mirror the real repos: the pipeline ships only a config, each
|
||||
# dependency ships a config plus its checkpoint.
|
||||
(path / "config.yaml").write_text("pipeline: ok\n", encoding="utf-8")
|
||||
if repo_id != PIPELINE:
|
||||
(path / "pytorch_model.bin").write_bytes(b"\0" * _WEIGHT_BYTES)
|
||||
return str(path)
|
||||
|
||||
monkeypatch.setattr(huggingface_hub, "snapshot_download", fake_snapshot_download)
|
||||
monkeypatch.setattr(download, "compute_plan", lambda _plan: {
|
||||
"total_bytes": 1, "cached_bytes": 0, "to_download_bytes": 1,
|
||||
"n_files": 1, "n_cached": 0,
|
||||
})
|
||||
monkeypatch.setattr(download, "disk_space_error", lambda *_a, **_k: None)
|
||||
# Force the snapshot_download path so this test covers the install flow;
|
||||
# the segmented accelerator has its own unit tests.
|
||||
monkeypatch.setattr(download, "_segmented_enabled", lambda: False)
|
||||
monkeypatch.setattr(hf_revisions, "remember_revision", lambda *_a: None)
|
||||
monkeypatch.setattr(performance_profiles, "reconcile_active_profile", lambda: None)
|
||||
|
||||
events: list[dict] = []
|
||||
listener_id = hf_progress.register_listener(lambda ev: events.append(ev))
|
||||
|
||||
async def _run():
|
||||
await download.install_model(download.InstallModelRequest(repo_id=PIPELINE))
|
||||
pending = [t for t in asyncio.all_tasks() if t is not asyncio.current_task()]
|
||||
if pending:
|
||||
await asyncio.gather(*pending)
|
||||
|
||||
try:
|
||||
asyncio.run(_run())
|
||||
finally:
|
||||
hf_progress.unregister_listener(listener_id)
|
||||
download._install_cooldowns.pop(PIPELINE, None)
|
||||
download._install_failures.pop(PIPELINE, None)
|
||||
|
||||
return calls, events
|
||||
|
||||
|
||||
def test_pyannote_pipeline_install_completes(download, monkeypatch, tmp_path):
|
||||
calls, events = _install_pyannote(download, monkeypatch, tmp_path)
|
||||
|
||||
phases = [e.get("phase") for e in events]
|
||||
errors = [e for e in events if e.get("phase") == "install_error"]
|
||||
assert not errors, f"install failed: {[e.get('error') for e in errors]}"
|
||||
assert "install_done" in phases
|
||||
|
||||
# A weightless pipeline repo is a valid install, not a truncated download —
|
||||
# the "no model weights were found in the snapshot" rejection in the report.
|
||||
assert PIPELINE not in str(errors)
|
||||
|
||||
|
||||
def test_pyannote_install_fetches_both_dependency_repositories(
|
||||
download, monkeypatch, tmp_path
|
||||
):
|
||||
calls, _events = _install_pyannote(download, monkeypatch, tmp_path)
|
||||
|
||||
real = [c for c in calls if not c.get("dry_run")]
|
||||
fetched = [c["repo_id"] for c in real]
|
||||
assert fetched == [PIPELINE, SEGMENTATION, EMBEDDING], (
|
||||
"the pipeline config alone is not a runnable install"
|
||||
)
|
||||
|
||||
# Each dependency is filtered to the files its catalogue entry declares.
|
||||
by_repo = {c["repo_id"]: c for c in real}
|
||||
for dependency in (SEGMENTATION, EMBEDDING):
|
||||
assert by_repo[dependency]["allow_patterns"] == [
|
||||
"config.yaml",
|
||||
"pytorch_model.bin",
|
||||
]
|
||||
# The pipeline repo itself is unfiltered — it has no allow_patterns.
|
||||
assert "allow_patterns" not in by_repo[PIPELINE]
|
||||
|
||||
|
||||
def test_every_pyannote_download_carries_the_bearer_string(
|
||||
download, monkeypatch, tmp_path
|
||||
):
|
||||
calls, _events = _install_pyannote(download, monkeypatch, tmp_path)
|
||||
|
||||
assert calls, "no download was attempted"
|
||||
for call in calls:
|
||||
token = call.get("token")
|
||||
assert token == "hf_gatedsecret", f"{call['repo_id']} sent {token!r}"
|
||||
assert isinstance(token, str)
|
||||
@@ -620,6 +620,64 @@ def test_weights_step_downloads_via_endpoint_autoselect(monkeypatch):
|
||||
assert si._weights_present(spec)
|
||||
|
||||
|
||||
def test_weights_download_sends_the_bearer_string_not_the_token_record(monkeypatch):
|
||||
"""#2163: `token_resolver.resolve()` returns a ResolvedToken record, but
|
||||
snapshot_download takes `token: str | None` and silently ignores a non-str
|
||||
— falling back to huggingface_hub's own ambient token discovery. So gated
|
||||
engine weights 401 for a user whose token lives in VoiceStudio's Settings
|
||||
rather than HF's cache. Every other weights test here stubs resolve() to
|
||||
None, which is exactly why this went unnoticed."""
|
||||
from services.token_resolver import ResolvedToken
|
||||
|
||||
spec = _mk_spec(weights_repo_id="Example/Gated")
|
||||
seen = {}
|
||||
|
||||
def fake_snapshot_download(**kwargs):
|
||||
seen.update(kwargs)
|
||||
Path(kwargs["local_dir"]).mkdir(parents=True, exist_ok=True)
|
||||
(Path(kwargs["local_dir"]) / "config.yaml").write_text("ok\n")
|
||||
(Path(kwargs["local_dir"]) / "w.safetensors").write_bytes(b"\0" * (6 * 1024 * 1024))
|
||||
|
||||
import huggingface_hub
|
||||
monkeypatch.setattr(huggingface_hub, "snapshot_download", fake_snapshot_download)
|
||||
monkeypatch.setattr("services.endpoint_race.effective_endpoint", lambda: None)
|
||||
monkeypatch.setattr(
|
||||
"services.token_resolver.resolve",
|
||||
lambda: ResolvedToken(token="hf_gatedsecret", source="app", username="tester"),
|
||||
)
|
||||
|
||||
job = si._new_job(spec.engine_id)
|
||||
si._job_step(job, "fetch_weights")["state"] = "running"
|
||||
si._step_fetch_weights(spec, job)
|
||||
|
||||
assert seen["token"] == "hf_gatedsecret"
|
||||
assert isinstance(seen["token"], str)
|
||||
|
||||
|
||||
def test_weights_download_sends_no_token_when_none_resolves(monkeypatch):
|
||||
# The other half of the contract: no token anywhere must reach
|
||||
# snapshot_download as a real None, never the string "None".
|
||||
spec = _mk_spec(weights_repo_id="Example/Open")
|
||||
seen = {}
|
||||
|
||||
def fake_snapshot_download(**kwargs):
|
||||
seen.update(kwargs)
|
||||
Path(kwargs["local_dir"]).mkdir(parents=True, exist_ok=True)
|
||||
(Path(kwargs["local_dir"]) / "config.yaml").write_text("ok\n")
|
||||
(Path(kwargs["local_dir"]) / "w.safetensors").write_bytes(b"\0" * (6 * 1024 * 1024))
|
||||
|
||||
import huggingface_hub
|
||||
monkeypatch.setattr(huggingface_hub, "snapshot_download", fake_snapshot_download)
|
||||
monkeypatch.setattr("services.endpoint_race.effective_endpoint", lambda: None)
|
||||
monkeypatch.setattr("services.token_resolver.resolve", lambda: None)
|
||||
|
||||
job = si._new_job(spec.engine_id)
|
||||
si._job_step(job, "fetch_weights")["state"] = "running"
|
||||
si._step_fetch_weights(spec, job)
|
||||
|
||||
assert seen["token"] is None
|
||||
|
||||
|
||||
def test_weights_revision_is_pinned_and_old_marker_forces_upgrade(monkeypatch):
|
||||
spec = _mk_spec(
|
||||
weights_repo_id="Example/Weights",
|
||||
|
||||
Reference in New Issue
Block a user