diff --git a/backend/python/fish-speech/Makefile b/backend/python/fish-speech/Makefile index ace1ef3ded1d..875f91d7362a 100644 --- a/backend/python/fish-speech/Makefile +++ b/backend/python/fish-speech/Makefile @@ -8,8 +8,13 @@ run: fish-speech bash run.sh @echo "fish-speech run." +.PHONY: test-unit +test-unit: + python3 -m unittest -v prepare_upstream_test.py + bash run_test.sh + .PHONY: test -test: fish-speech +test: fish-speech test-unit @echo "Testing fish-speech..." bash test.sh @echo "fish-speech tested." diff --git a/backend/python/fish-speech/install.sh b/backend/python/fish-speech/install.sh index 4d03d344c38a..c80b98903217 100644 --- a/backend/python/fish-speech/install.sh +++ b/backend/python/fish-speech/install.sh @@ -44,6 +44,13 @@ fi # It requires native portaudio libs which aren't available on all build environments. sed -i.bak '/"pyaudio"/d' "${FISH_SPEECH_DIR}/pyproject.toml" +# CUDA 13 has no torch 2.8 wheels, so fish-speech's exact upstream pin would +# make pip select the CPU-only aarch64 wheel from PyPI. Prepare the cloned tree +# before resolving it, and use soundfile for reference audio because torchcodec +# does not publish Linux aarch64 wheels. +python3 "${backend_dir}/prepare_upstream.py" "${FISH_SPEECH_DIR}" \ + --cuda-major "${CUDA_MAJOR_VERSION:-}" + # Install fish-speech deps from source (without the package itself since we use PYTHONPATH) ensureVenv if [ "x${USE_PIP}" == "xtrue" ]; then diff --git a/backend/python/fish-speech/prepare_upstream.py b/backend/python/fish-speech/prepare_upstream.py new file mode 100644 index 000000000000..108948b90888 --- /dev/null +++ b/backend/python/fish-speech/prepare_upstream.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MIT + +import argparse +from pathlib import Path + + +TORCH_28 = '"torch==2.8.0"' +TORCH_29 = '"torch==2.9.1"' +TORCHAUDIO_28 = '"torchaudio==2.8.0"' +TORCHAUDIO_29 = '"torchaudio==2.9.1"' +TORCHAUDIO_LOAD = ( + " waveform, original_sr = " + "torchaudio.load(reference_audio, backend=self.backend)" +) +SOUNDFILE_LOAD = "\n".join( + ( + " import soundfile as _sf", + " import torch as _torch", + "", + " data, original_sr = _sf.read(", + ' reference_audio, dtype="float32", always_2d=True', + " )", + " waveform = _torch.from_numpy(data.T.copy())", + ) +) + + +def patch_cuda13_dependencies(pyproject: Path) -> None: + content = pyproject.read_text() + if ( + TORCH_28 not in content + and TORCHAUDIO_28 not in content + and TORCH_29 in content + and TORCHAUDIO_29 in content + ): + return + if TORCH_28 not in content or TORCHAUDIO_28 not in content: + raise RuntimeError("fish-speech's torch 2.8 dependency pins have changed") + + content = content.replace(TORCH_28, TORCH_29) + content = content.replace(TORCHAUDIO_28, TORCHAUDIO_29) + pyproject.write_text(content) + + +def patch_reference_loader(loader: Path) -> None: + content = loader.read_text() + if TORCHAUDIO_LOAD not in content and content.count(SOUNDFILE_LOAD) == 1: + return + if content.count(TORCHAUDIO_LOAD) != 1: + raise RuntimeError("fish-speech's torchaudio.load call has changed") + + loader.write_text(content.replace(TORCHAUDIO_LOAD, SOUNDFILE_LOAD)) + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("source", type=Path) + parser.add_argument("--cuda-major") + args = parser.parse_args() + + if args.cuda_major == "13": + patch_cuda13_dependencies(args.source / "pyproject.toml") + patch_reference_loader( + args.source / "fish_speech/inference_engine/reference_loader.py" + ) + + +if __name__ == "__main__": + main() diff --git a/backend/python/fish-speech/prepare_upstream_test.py b/backend/python/fish-speech/prepare_upstream_test.py new file mode 100644 index 000000000000..7a1df73c7464 --- /dev/null +++ b/backend/python/fish-speech/prepare_upstream_test.py @@ -0,0 +1,136 @@ +# SPDX-License-Identifier: MIT + +import importlib.util +import sys +import tempfile +import types +import unittest +from pathlib import Path + + +MODULE_PATH = Path(__file__).with_name("prepare_upstream.py") + + +def load_prepare_upstream(): + if not MODULE_PATH.exists(): + raise AssertionError("prepare_upstream.py is missing") + spec = importlib.util.spec_from_file_location("prepare_upstream", MODULE_PATH) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +class FakeAudioData: + @property + def T(self): + return self + + def copy(self): + return "channels-first" + + +class PrepareUpstreamTests(unittest.TestCase): + def test_cuda13_dependencies_follow_available_pytorch_wheels(self): + prepare_upstream = load_prepare_upstream() + + with tempfile.TemporaryDirectory() as tmp: + pyproject = Path(tmp) / "pyproject.toml" + pyproject.write_text( + 'dependencies = [\n "torch==2.8.0",\n "torchaudio==2.8.0",\n]\n' + 'stable = [\n "torch==2.8.0",\n "torchaudio",\n]\n' + ) + + prepare_upstream.patch_cuda13_dependencies(pyproject) + + self.assertEqual( + pyproject.read_text(), + 'dependencies = [\n "torch==2.9.1",\n "torchaudio==2.9.1",\n]\n' + 'stable = [\n "torch==2.9.1",\n "torchaudio",\n]\n', + ) + + def test_reference_audio_uses_soundfile_without_torchcodec(self): + prepare_upstream = load_prepare_upstream() + + with tempfile.TemporaryDirectory() as tmp: + loader = Path(tmp) / "reference_loader.py" + loader.write_text( + "class ReferenceLoader:\n" + " def load_audio(self, reference_audio):\n" + " waveform, original_sr = torchaudio.load(reference_audio, backend=self.backend)\n" + " return waveform, original_sr\n" + ) + prepare_upstream.patch_reference_loader(loader) + + calls = [] + fake_soundfile = types.SimpleNamespace( + read=lambda source, **kwargs: ( + calls.append((source, kwargs)) or FakeAudioData(), + 24000, + ) + ) + fake_torch = types.SimpleNamespace( + from_numpy=lambda data: ("tensor", data), + ) + previous_soundfile = sys.modules.get("soundfile") + previous_torch = sys.modules.get("torch") + sys.modules["soundfile"] = fake_soundfile + sys.modules["torch"] = fake_torch + try: + namespace = {"torchaudio": None} + exec(compile(loader.read_text(), str(loader), "exec"), namespace) + instance = namespace["ReferenceLoader"]() + instance.backend = "soundfile" + + waveform, sample_rate = instance.load_audio("voice.wav") + finally: + if previous_soundfile is None: + del sys.modules["soundfile"] + else: + sys.modules["soundfile"] = previous_soundfile + if previous_torch is None: + del sys.modules["torch"] + else: + sys.modules["torch"] = previous_torch + + self.assertEqual(waveform, ("tensor", "channels-first")) + self.assertEqual(sample_rate, 24000) + self.assertEqual( + calls, + [("voice.wav", {"dtype": "float32", "always_2d": True})], + ) + + def test_reference_loader_drift_fails_the_build(self): + prepare_upstream = load_prepare_upstream() + + with tempfile.TemporaryDirectory() as tmp: + loader = Path(tmp) / "reference_loader.py" + loader.write_text("def load_audio():\n pass\n") + + with self.assertRaisesRegex(RuntimeError, "torchaudio.load call"): + prepare_upstream.patch_reference_loader(loader) + + def test_preparation_can_be_repeated(self): + prepare_upstream = load_prepare_upstream() + + with tempfile.TemporaryDirectory() as tmp: + pyproject = Path(tmp) / "pyproject.toml" + pyproject.write_text( + 'dependencies = ["torch==2.8.0", "torchaudio==2.8.0"]\n' + ) + loader = Path(tmp) / "reference_loader.py" + loader.write_text( + "def load_audio(reference_audio):\n" + " waveform, original_sr = torchaudio.load(reference_audio, backend=self.backend)\n" + ) + + prepare_upstream.patch_cuda13_dependencies(pyproject) + prepare_upstream.patch_reference_loader(loader) + try: + prepare_upstream.patch_cuda13_dependencies(pyproject) + prepare_upstream.patch_reference_loader(loader) + except RuntimeError as err: + self.fail(f"preparation is not idempotent: {err}") + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/python/fish-speech/requirements-cublas13.txt b/backend/python/fish-speech/requirements-cublas13.txt index c367ab45c8e1..841666316714 100644 --- a/backend/python/fish-speech/requirements-cublas13.txt +++ b/backend/python/fish-speech/requirements-cublas13.txt @@ -1,3 +1,3 @@ --extra-index-url https://download.pytorch.org/whl/cu130 -torch -torchaudio +torch==2.9.1+cu130 +torchaudio==2.9.1 diff --git a/backend/python/fish-speech/requirements-l4t13.txt b/backend/python/fish-speech/requirements-l4t13.txt index c367ab45c8e1..841666316714 100644 --- a/backend/python/fish-speech/requirements-l4t13.txt +++ b/backend/python/fish-speech/requirements-l4t13.txt @@ -1,3 +1,3 @@ --extra-index-url https://download.pytorch.org/whl/cu130 -torch -torchaudio +torch==2.9.1+cu130 +torchaudio==2.9.1 diff --git a/backend/python/fish-speech/run.sh b/backend/python/fish-speech/run.sh index eae121f37b0b..d7a5cc230f1a 100644 --- a/backend/python/fish-speech/run.sh +++ b/backend/python/fish-speech/run.sh @@ -6,4 +6,8 @@ else source $backend_dir/../common/libbackend.sh fi -startBackend $@ +# Editable installs record their build-time absolute source path, which becomes +# stale when the backend is relocated under /backends at install time. +export PYTHONPATH="${EDIR}/fish-speech-src${PYTHONPATH:+:${PYTHONPATH}}" + +startBackend "$@" diff --git a/backend/python/fish-speech/run_test.sh b/backend/python/fish-speech/run_test.sh new file mode 100644 index 000000000000..65affb91f875 --- /dev/null +++ b/backend/python/fish-speech/run_test.sh @@ -0,0 +1,27 @@ +#!/bin/bash +# SPDX-License-Identifier: MIT +set -euo pipefail + +backend_dir=$(cd "$(dirname "$0")" && pwd) +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT + +mkdir -p "$work/backend/common" "$work/backend/fish-speech-src" +cp "$backend_dir/run.sh" "$work/backend/run.sh" + +cat > "$work/backend/common/libbackend.sh" <<'EOF' +EDIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +startBackend() { + printf '%s\n' "$PYTHONPATH" +} +EOF + +actual=$(PYTHONPATH=/existing/path bash "$work/backend/run.sh") +expected="$work/backend/fish-speech-src:/existing/path" + +if [ "$actual" != "$expected" ]; then + printf 'expected PYTHONPATH %s, got %s\n' "$expected" "$actual" >&2 + exit 1 +fi + +echo "PASS: relocated fish-speech source is importable"