diff --git a/backend/engines/voxcpm2_subprocess/main.py b/backend/engines/voxcpm2_subprocess/main.py index d008acf7..c3e34410 100644 --- a/backend/engines/voxcpm2_subprocess/main.py +++ b/backend/engines/voxcpm2_subprocess/main.py @@ -142,6 +142,20 @@ def _sample_rate(model) -> int: return VOXCPM2_SAMPLE_RATE +def _at_engine_rate(wav, sample_rate: int): + """The waveform at VOXCPM2_SAMPLE_RATE. The parent reads the PCM at that + fixed rate (it trims the tail and labels the audio with it), so a model + reporting another rate is resampled here rather than mislabelled.""" + if sample_rate == VOXCPM2_SAMPLE_RATE: + return wav + import torch # noqa: PLC0415 + import torchaudio # noqa: PLC0415 + + tensor = torch.as_tensor(wav.detach().cpu() if hasattr(wav, "detach") else wav, + dtype=torch.float32).reshape(-1) + return torchaudio.functional.resample(tensor, sample_rate, VOXCPM2_SAMPLE_RATE) + + def _to_pcm_b64(wav) -> tuple[str, int]: """A float waveform in [-1, 1] (numpy or torch) as base64 int16 PCM.""" import numpy as np # noqa: PLC0415 @@ -189,11 +203,11 @@ def _handle_synthesize(msg: dict, stdout) -> None: prompt_wav_path=ref_audio if ref_text else None, prompt_text=ref_text, ) - pcm_b64, n_samples = _to_pcm_b64(wav) + pcm_b64, n_samples = _to_pcm_b64(_at_engine_rate(wav, _sample_rate(model))) _send(stdout, { "op": "audio", "audio_pcm_b64": pcm_b64, - "sample_rate": _sample_rate(model), + "sample_rate": VOXCPM2_SAMPLE_RATE, "n_samples": n_samples, }) diff --git a/backend/services/sidecar_install.py b/backend/services/sidecar_install.py index b407c8c9..ad589ae1 100644 --- a/backend/services/sidecar_install.py +++ b/backend/services/sidecar_install.py @@ -517,7 +517,14 @@ def engine_venv_python(env_var: str) -> Optional[Path]: if not env_dir: return None py = _venv_python(Path(env_dir) / ".venv") - return py if py.is_file() else None + # The interpreter alone proves nothing: a reinstall that failed partway + # leaves it behind. The completion marker is written only after the + # engine's import probe passed in this venv, and removed when a new + # dependency step starts, so it is the probe's verdict without running a + # multi-second import on every engine-list refresh. + if not py.is_file() or not (Path(env_dir) / _INSTALL_COMPLETE_MARKER).is_file(): + return None + return py def _legacy_managed_checkouts(spec: SidecarSpec) -> tuple[Path, ...]: diff --git a/tests/test_pockettts_deferred.py b/tests/test_pockettts_deferred.py index 88f49f7f..68214b29 100644 --- a/tests/test_pockettts_deferred.py +++ b/tests/test_pockettts_deferred.py @@ -289,7 +289,7 @@ def test_prefers_the_venv_its_one_click_install_made(monkeypatch, tmp_path, mock interpreter, where `uv sync --extra pockettts` installs it.""" from pathlib import Path - from services.sidecar_install import _venv_python + from services.sidecar_install import _INSTALL_COMPLETE_MARKER, _venv_python mock_settings_store["pockettts"] = True monkeypatch.delenv("OMNIVOICE_POCKETTTS_DIR", raising=False) @@ -298,6 +298,7 @@ def test_prefers_the_venv_its_one_click_install_made(monkeypatch, tmp_path, mock py = _venv_python(tmp_path / ".venv") py.parent.mkdir(parents=True) py.write_text("#!fake\n") + (tmp_path / _INSTALL_COMPLETE_MARKER).write_text("x\n", encoding="utf-8") monkeypatch.setenv("OMNIVOICE_POCKETTTS_DIR", str(tmp_path)) assert _backend_cls().venv_python() == py # Available without pocket_tts importable in the app's own environment. diff --git a/tests/test_sidecar_install.py b/tests/test_sidecar_install.py index 29c2364a..fa5a8b30 100644 --- a/tests/test_sidecar_install.py +++ b/tests/test_sidecar_install.py @@ -1216,6 +1216,9 @@ def test_engine_venv_python_needs_a_real_interpreter(monkeypatch, tmp_path): py = si._venv_python(tmp_path / ".venv") py.parent.mkdir(parents=True) py.write_text("#!fake\n") + # An interpreter without the marker is a failed or unfinished install. + assert si.engine_venv_python("OMNIVOICE_FAKE_SIDE_DIR") is None + (tmp_path / si._INSTALL_COMPLETE_MARKER).write_text("x\n", encoding="utf-8") assert si.engine_venv_python("OMNIVOICE_FAKE_SIDE_DIR") == py diff --git a/tests/test_supertonic3.py b/tests/test_supertonic3.py index 4d63c8d4..acfebad6 100644 --- a/tests/test_supertonic3.py +++ b/tests/test_supertonic3.py @@ -389,7 +389,7 @@ def test_prefers_the_venv_its_one_click_install_made(monkeypatch, tmp_path, mock from pathlib import Path from engines.supertonic3.backend import Supertonic3Backend - from services.sidecar_install import _venv_python + from services.sidecar_install import _INSTALL_COMPLETE_MARKER, _venv_python mock_settings_store["supertonic3"] = True monkeypatch.delenv("OMNIVOICE_SUPERTONIC3_DIR", raising=False) @@ -398,6 +398,7 @@ def test_prefers_the_venv_its_one_click_install_made(monkeypatch, tmp_path, mock py = _venv_python(tmp_path / ".venv") py.parent.mkdir(parents=True) py.write_text("#!fake\n") + (tmp_path / _INSTALL_COMPLETE_MARKER).write_text("x\n", encoding="utf-8") monkeypatch.setenv("OMNIVOICE_SUPERTONIC3_DIR", str(tmp_path)) assert Supertonic3Backend.venv_python() == py # Available without supertonic importable in the app's own environment. diff --git a/tests/test_voxcpm2_subprocess.py b/tests/test_voxcpm2_subprocess.py index 80c50b26..92e0a913 100644 --- a/tests/test_voxcpm2_subprocess.py +++ b/tests/test_voxcpm2_subprocess.py @@ -129,11 +129,15 @@ def test_retries_a_transient_download_failure_only(monkeypatch): assert sidecar._with_retries(flaky) == "model" assert len(attempts) == 3 + broken_calls = [] + def broken(): + broken_calls.append(1) raise ValueError("bad config") with pytest.raises(ValueError): sidecar._with_retries(broken) + assert len(broken_calls) == 1 # a permanent error is not retried def test_the_sidecar_imports_nothing_from_the_app(): @@ -145,7 +149,7 @@ def test_the_sidecar_imports_nothing_from_the_app(): def test_the_class_switches_to_the_sidecar_once_its_venv_exists(monkeypatch, tmp_path): from engines.voxcpm2_subprocess import VoxCPM2SubprocessBackend from services import tts_backend - from services.sidecar_install import _venv_python + from services.sidecar_install import _INSTALL_COMPLETE_MARKER, _venv_python monkeypatch.setenv("OMNIVOICE_VOXCPM2_DIR", "") monkeypatch.delenv("OMNIVOICE_VOXCPM2_DIR") @@ -154,6 +158,7 @@ def test_the_class_switches_to_the_sidecar_once_its_venv_exists(monkeypatch, tmp py = _venv_python(tmp_path / ".venv") py.parent.mkdir(parents=True) py.write_text("#!fake\n") + (tmp_path / _INSTALL_COMPLETE_MARKER).write_text("x\n", encoding="utf-8") monkeypatch.setenv("OMNIVOICE_VOXCPM2_DIR", str(tmp_path)) cls = tts_backend.get_backend_class("voxcpm2") @@ -197,3 +202,29 @@ def test_the_sidecar_class_prepares_the_reference_and_trims_the_tail(monkeypatch assert sent["ref_audio"] == "/clip.wav.prepared.wav" assert trimmed["sr"] == 48000 assert tuple(out.shape) == (1, 10) + + +def test_output_is_resampled_to_the_48_khz_the_parent_assumes(monkeypatch): + """The parent trims and labels the PCM at a fixed 48 kHz, so a model that + reports another rate must be resampled, not passed through.""" + sidecar = _load_sidecar(monkeypatch, []) + sidecar._handle_synthesize({"text": "warm up"}, io.BytesIO()) + type(sidecar._MODEL).sample_rate = 24000 + out = io.BytesIO() + sidecar._handle_synthesize({"text": "hi"}, out) + audio = _frames(out)[-1] + assert audio["sample_rate"] == 48000 + assert audio["n_samples"] == 960 # 480 samples at 24 kHz + + +def test_a_failed_install_leaves_voxcpm2_in_process(monkeypatch, tmp_path): + """A venv interpreter without the completion marker (a reinstall that + failed partway) must not hide the working in-process engine.""" + from services import tts_backend + from services.sidecar_install import _venv_python + + py = _venv_python(tmp_path / ".venv") + py.parent.mkdir(parents=True) + py.write_text("#!fake\n") + monkeypatch.setenv("OMNIVOICE_VOXCPM2_DIR", str(tmp_path)) + assert tts_backend.get_backend_class("voxcpm2") is tts_backend.VoxCPM2Backend