From e165e0b5b065947e8f7f27da93f59e6dec316f8b Mon Sep 17 00:00:00 2001 From: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com> Date: Tue, 4 Aug 2026 14:08:29 +0000 Subject: [PATCH] fix(fish-speech): support CUDA 13 on arm64 Keep the relocated upstream source importable, select CUDA 13 PyTorch wheels instead of the aarch64 CPU fallback, and decode reference audio without torchcodec, which has no Linux arm64 wheels. Assisted-by: Codex:gpt-5 --- backend/python/fish-speech/Makefile | 7 +- backend/python/fish-speech/install.sh | 7 + .../python/fish-speech/prepare_upstream.py | 70 +++++++++ .../fish-speech/prepare_upstream_test.py | 136 ++++++++++++++++++ .../fish-speech/requirements-cublas13.txt | 4 +- .../python/fish-speech/requirements-l4t13.txt | 4 +- backend/python/fish-speech/run.sh | 6 +- backend/python/fish-speech/run_test.sh | 27 ++++ 8 files changed, 255 insertions(+), 6 deletions(-) create mode 100644 backend/python/fish-speech/prepare_upstream.py create mode 100644 backend/python/fish-speech/prepare_upstream_test.py create mode 100644 backend/python/fish-speech/run_test.sh 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"