mirror of
https://github.com/mudler/LocalAI.git
synced 2026-09-12 14:22:11 -04:00
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 Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
1 parent
3535675fd4
commit
f8e228e0ba
8 files changed
+255
-6
No files matched your search
@@ -11,8 +11,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."
|
||||
|
||||
@@ -44,6 +44,13 @@ fi
|
||||
# editable install. pyaudio is only used by the upstream playback client.
|
||||
bash "${backend_dir}/prepare-source.sh" "${BUILD_TYPE:-}" "${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
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -1,3 +1,3 @@
|
||||
--extra-index-url https://download.pytorch.org/whl/cu130
|
||||
torch
|
||||
torchaudio
|
||||
torch==2.9.1+cu130
|
||||
torchaudio==2.9.1
|
||||
@@ -1,3 +1,3 @@
|
||||
--extra-index-url https://download.pytorch.org/whl/cu130
|
||||
torch
|
||||
torchaudio
|
||||
torch==2.9.1+cu130
|
||||
torchaudio==2.9.1
|
||||
@@ -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 "$@"
|
||||
@@ -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"
|
||||
Reference in new issue
Block a user