Compare commits

...

1 Commits

Author SHA1 Message Date
localai-org-maint-bot
e165e0b5b0 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
2026-08-04 14:08:29 +00:00
8 changed files with 255 additions and 6 deletions

View File

@@ -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."

View File

@@ -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

View File

@@ -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()

View File

@@ -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()

View File

@@ -1,3 +1,3 @@
--extra-index-url https://download.pytorch.org/whl/cu130
torch
torchaudio
torch==2.9.1+cu130
torchaudio==2.9.1

View File

@@ -1,3 +1,3 @@
--extra-index-url https://download.pytorch.org/whl/cu130
torch
torchaudio
torch==2.9.1+cu130
torchaudio==2.9.1

View File

@@ -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 "$@"

View File

@@ -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"