# Building the PJSUA2 Python bindings The media pipeline ([core/media_pipeline.py](../core/media_pipeline.py)) needs the `pjsua2` Python bindings. They are **not pip-installable** — they are SWIG bindings compiled from pjproject. Without them `MediaPipeline.start()` catches `ImportError` and runs in **stub mode**: signaling works, but audio routing, recording, tapping and playback are all no-ops that *return successfully*. That last part is the trap — a stub gateway looks healthy while being unable to speak or listen. Verified on Ubuntu 25.10 / Python 3.13.7 / gcc 15.2, pjproject **2.17**, 2026-07-28. --- ## Prerequisites `swig` and `patchelf` are both needed and both have PyPI wheels, so **no `sudo` is required** — install them into the venv: ```bash pip install swig patchelf ``` System dev libraries (already present on caliban; `libsrtp2-dev` is *not* needed — pjproject bundles its own SRTP): ``` libasound2-dev libssl-dev libopus-dev uuid-dev python3-dev ``` ## Build ```bash mkdir -p ~/src && cd ~/src git clone --depth 1 --branch 2.17 https://github.com/pjsip/pjproject.git cd pjproject # Minimal config_site.h — enable TLS transport support echo '#define PJ_HAS_SSL_SOCK 1' > pjlib/include/pj/config_site.h # -fPIC is REQUIRED: the Python extension links these into a shared object. # --enable-shared builds the .so files the bindings load at runtime. CFLAGS="-fPIC -O2" CXXFLAGS="-fPIC -O2" ./configure \ --enable-shared \ --disable-video --disable-libyuv --disable-libwebrtc \ --prefix=$HOME/.local make dep && make -j$(nproc) && make install ``` Confirm configure found what the gateway needs (all should say yes/enabled): OpenSSL, ALSA (`alsa/version.h`), OPUS. ```bash # Python bindings cd pjsip-apps/src/swig make python cd python && python setup.py install ``` ## The RPATH step — do not skip this The shared libraries install to `~/.local/lib`, which is **not** on the default loader path, and neither the extension nor pjproject's own libraries carry an RPATH. Straight after `setup.py install` the import fails with: ``` ImportError: libpjsua2.so.2: cannot open shared object file ``` Rather than requiring `LD_LIBRARY_PATH` everywhere (it would have to be set for `uvicorn`, systemd, cron and any subprocess — easy to miss, and it fails at call time, not startup), bake the path into the binaries: ```bash # The extension module … patchelf --set-rpath $HOME/.local/lib \ $VIRTUAL_ENV/lib/python3.13/site-packages/_pjsua2.cpython-313-x86_64-linux-gnu.so # … and pjproject's libraries, which must also find each other. cd ~/.local/lib && for f in libpj*.so.2; do patchelf --set-rpath $HOME/.local/lib "$f" done ``` Patching only the extension is not enough: it resolves `libpjsua2`, which then fails on its own transitive deps (`libpjsua`, `libpjsip`, `libpjmedia`, `libpj`, …). Patch the whole set. ## Verify ```bash # 1. Loads with a completely empty environment (proves RPATH, not inherited vars) cd / && env -i $VIRTUAL_ENV/bin/python -c \ "import pjsua2 as pj; ep=pj.Endpoint(); ep.libCreate(); print(ep.libVersion().full); ep.libDestroy()" # 2. No unresolved libraries ldd $VIRTUAL_ENV/lib/python3.13/site-packages/_pjsua2*.so | grep "not found" # 3. The real gate — Hold Slayer's own pipeline reports it python -c " import asyncio from core.media_pipeline import MediaPipeline async def m(): p = MediaPipeline(); await p.start() assert p.status()['pjsua2_available'] is True print('pjsua2_available: True'); await p.stop() asyncio.run(m())" ``` `ImportError` in step 1 or `pjsua2_available: False` in step 3 means you are still in stub mode. ## Notes - **Not captured by `pip install -e ".[dev]"`.** This build lives outside the Python packaging metadata, so a fresh venv, a rebuilt container, or another host needs it repeated. Treat it as host provisioning. - **The Docker image deliberately does not build this** (see the comment at the top of the [Dockerfile](../Dockerfile)) — the container therefore runs stub media. Anything validating audio must run outside the image, or the Dockerfile needs a build stage adding. - **Threading:** PJSUA2 starts its own worker threads, in addition to the Sippy ED thread. Per the concurrency rule, PJSUA2 objects belong to the media pipeline and must not be touched from the Sippy thread or mutated directly from the asyncio loop. - The extension compiles against system headers (`/usr/include/python3.13`) rather than the venv's. Harmless while both are the same 3.13.7 with matching SOABI — worth re-checking if the venv's Python is ever upgraded independently.