--- phonemizer/backend/espeak/api.py.upstream +++ phonemizer/backend/espeak/api.py @@ -79,8 +79,48 @@ else: weakref.finalize(self, self._delete, self._library, self._tempdir) - espeak_copy = pathlib.Path(self._tempdir) / library_path.name - shutil.copy(library_path, espeak_copy, follow_symlinks=False) + # Local developer bundle adaptation: keep a private espeak image while + # preserving the relocated dependency layout. No host library fallback. + import os + bundled_runtime = os.environ.get("WHITEBOARD_BUNDLED_RUNTIME") + if sys.platform == "darwin" and bundled_runtime: + runtime_root = pathlib.Path(bundled_runtime).resolve(strict=True) + expected_library = (runtime_root / "espeak/lib/libespeak-ng.dylib").resolve(strict=True) + expected_data = (runtime_root / "espeak/share/espeak-ng-data").resolve(strict=True) + dependency_directory = (runtime_root / "lib").resolve(strict=True) + if (library_path.resolve() != expected_library or not expected_library.is_relative_to(runtime_root) + or not dependency_directory.is_dir() or not dependency_directory.is_relative_to(runtime_root)): + raise RuntimeError("Bundled phonemizer must use the bundle's espeak and dependency directory") + try: + selected_data = pathlib.Path(os.fsdecode(data_path)).resolve(strict=True) if data_path else None + except OSError: + selected_data = None + if selected_data != expected_data: + raise RuntimeError("Bundled phonemizer must use the bundle's espeak data") + # Keep private native images in the signed bundle. Loading a + # runtime-created copy triggers macOS security assessment. + import queue + candidates = queue.SimpleQueue() + for index in range(8): + image = expected_library.with_name(f"libespeak-ng-instance-{index}.dylib").resolve(strict=True) + if image.parent != expected_library.parent: + raise RuntimeError("Bundled espeak image escapes its signed library folder") + candidates.put(image) + pools = globals().setdefault("_studio_signed_espeak_images", {}) + images = pools.setdefault(str(runtime_root), candidates) + try: + espeak_copy = images.get_nowait() + except queue.Empty: + raise RuntimeError("Bundled speech worker exhausted its private signed images; restart narration") from None + # espeak-ng silently falls back to its compiled Homebrew data path + # when a relocated absolute data path is too long. Pass a short + # per-instance symlink; cleanup removes the link, not the data. + data_link = pathlib.Path(self._tempdir) / "espeak-ng-data" + data_link.symlink_to(expected_data, target_is_directory=True) + data_path = os.fsencode(data_link) + else: + espeak_copy = pathlib.Path(self._tempdir) / library_path.name + shutil.copy(library_path, espeak_copy, follow_symlinks=False) # finally load the library copy and initialize it. 0x02 is # AUDIO_OUTPUT_SYNCHRONOUS in the espeak API