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=[],