From 829d4d6d5ba1aaeae419ee1b9377a0a0cfea1996 Mon Sep 17 00:00:00 2001 From: Eva Date: Wed, 18 Feb 2026 16:51:48 +0100 Subject: [PATCH] fix(mlx): bundle native libs and broaden error handling for Apple Silicon The distributed macOS aarch64 binary shipped without MLX acceleration despite the model and backend code supporting it. Two root causes: 1. **OSError not caught in platform_detect.py** PyInstaller bundles isolate the filesystem, so when MLX tries to load its Metal shader libraries (.metallib) it raises OSError, not ImportError. platform_detect.get_backend_type() only caught ImportError, causing a silent fallback to PyTorch even on Apple Silicon hardware. Fix: broaden the except clause to (ImportError, OSError, RuntimeError) and import mlx.core instead of mlx (forces native lib loading eagerly). 2. **collect_data_files used instead of collect_all for MLX** build_binary.py and voicebox-server.spec used --collect-data / collect_data_files for mlx and mlx_audio. This copies Python source and pure-Python data, but NOT native shared libraries (.dylib, .metallib). Fix: switch to --collect-all / collect_all which captures binaries too, then pass them to Analysis(binaries=...) in the spec. Result: macOS Apple Silicon users now get MLX inference (~4-5x faster than PyTorch CPU), matching the performance documented in the README. --- backend/build_binary.py | 10 +++++++--- backend/platform_detect.py | 12 +++++++----- backend/voicebox-server.spec | 11 ++++++++--- 3 files changed, 22 insertions(+), 11 deletions(-) diff --git a/backend/build_binary.py b/backend/build_binary.py index a2973cd4..73f21d23 100644 --- a/backend/build_binary.py +++ b/backend/build_binary.py @@ -83,9 +83,13 @@ def build_server(): '--hidden-import', 'mlx_audio.stt', '--collect-submodules', 'mlx', '--collect-submodules', 'mlx_audio', - # Collect MLX data files including Metal shader libraries (.metallib) - '--collect-data', 'mlx', - '--collect-data', 'mlx_audio', + # Use --collect-all so PyInstaller bundles both data files AND + # native shared libraries (.dylib, .metallib) for MLX. + # Previously only --collect-data was used, which caused MLX to + # raise OSError at runtime inside the bundled binary because + # the Metal shader libraries were missing. + '--collect-all', 'mlx', + '--collect-all', 'mlx_audio', ]) else: print("Building for non-Apple Silicon platform - PyTorch only") diff --git a/backend/platform_detect.py b/backend/platform_detect.py index c4db19dc..1ec2980a 100644 --- a/backend/platform_detect.py +++ b/backend/platform_detect.py @@ -19,15 +19,17 @@ def is_apple_silicon() -> bool: def get_backend_type() -> Literal["mlx", "pytorch"]: """ Detect the best backend for the current platform. - + Returns: - "mlx" on Apple Silicon (if MLX is available), "pytorch" otherwise + "mlx" on Apple Silicon (if MLX is available and functional), "pytorch" otherwise """ if is_apple_silicon(): try: - import mlx + import mlx.core # noqa: F401 — triggers native lib loading return "mlx" - except ImportError: - # MLX not installed, fallback to PyTorch + except (ImportError, OSError, RuntimeError): + # MLX not installed, or native libraries failed to load inside a + # PyInstaller bundle (OSError on missing .dylib / .metallib). + # Fall through to PyTorch. return "pytorch" return "pytorch" diff --git a/backend/voicebox-server.spec b/backend/voicebox-server.spec index 5d6bb317..feccfae0 100644 --- a/backend/voicebox-server.spec +++ b/backend/voicebox-server.spec @@ -6,8 +6,13 @@ from PyInstaller.utils.hooks import copy_metadata datas = [] hiddenimports = ['backend', 'backend.main', 'backend.config', 'backend.database', 'backend.models', 'backend.profiles', 'backend.history', 'backend.tts', 'backend.transcribe', 'backend.platform_detect', 'backend.backends', 'backend.backends.pytorch_backend', 'backend.utils.audio', 'backend.utils.cache', 'backend.utils.progress', 'backend.utils.hf_progress', 'backend.utils.validation', 'torch', 'transformers', 'fastapi', 'uvicorn', 'sqlalchemy', 'librosa', 'soundfile', 'qwen_tts', 'qwen_tts.inference', 'qwen_tts.inference.qwen3_tts_model', 'qwen_tts.inference.qwen3_tts_tokenizer', 'qwen_tts.core', 'qwen_tts.cli', 'pkg_resources.extern', 'backend.backends.mlx_backend', 'mlx', 'mlx.core', 'mlx.nn', 'mlx_audio', 'mlx_audio.tts', 'mlx_audio.stt'] datas += collect_data_files('qwen_tts') -datas += collect_data_files('mlx') -datas += collect_data_files('mlx_audio') +# Use collect_all (not collect_data_files) so native .dylib and .metallib +# files are bundled as binaries, not data. Without this, MLX raises OSError +# when loading Metal shaders inside the PyInstaller bundle. +from PyInstaller.utils.hooks import collect_all as _collect_all +_mlx_datas, _mlx_bins, _mlx_hidden = _collect_all('mlx') +_mlxa_datas, _mlxa_bins, _mlxa_hidden = _collect_all('mlx_audio') +datas += _mlx_datas + _mlxa_datas datas += copy_metadata('qwen-tts') hiddenimports += collect_submodules('qwen_tts') hiddenimports += collect_submodules('jaraco') @@ -18,7 +23,7 @@ hiddenimports += collect_submodules('mlx_audio') a = Analysis( ['server.py'], pathex=[], - binaries=[], + binaries=_mlx_bins + _mlxa_bins, datas=datas, hiddenimports=hiddenimports, hookspath=[],