Update deployment validation plan to reflect Phase 1b completion — pjsua2 built from pjproject 2.17 on caliban without sudo. Document the non-obvious RPATH/patchelf step, the status() method name correction, and two remaining caveats (not captured by pip install, Docker still runs stub media). Update README to point at the new docs/pjsua2-build.md and clarify stub-mode behavior.
131 lines
4.5 KiB
Markdown
131 lines
4.5 KiB
Markdown
# 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.
|