PJSUA2 media plane — audio finally reaches the classifier #8
Reference in New Issue
Block a user
Delete Branch "feat/pjsua2-media-tap"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
The gateway can hear. Real RTP audio now flows from a call into the classifier, verified end to end against the Asterisk lab.
Why a refactor was needed
The original design was Sippy signals, PJSUA2 carries media. It cannot work, for a reason that is not obvious until you try it:
PJSUA2 exposes no standalone RTP media object. Every
AudioMediasubclass in the Python bindings is a file player, recorder, tone generator or capture port. RTP is reachable only throughpj.Call.getAudioMedia(), on a dialog PJSUA2 itself owns. There is no "give me media for this remote host:port" API.So
MediaPipeline.add_remote_stream()was not unfinished work — it was a function that could not be written against this API. The consequence: audio never reached the classifier, andcreate_taplogged success while returning a tap nothing ever fed.Owning the dialog is the price of owning the media, so
PJSUAEngineplaces the call. Sippy keeps the SBC roles it is good at — device registration, routing, leg bridging — andSippyEngineremains fully functional for signalling; it simply cannot carry media, which its media branch now says plainly instead of calling a method that could never work.Full rationale, including the two rejected alternatives, is in
docs/architecture.md→ "Media plane: why PJSUA2 places the call".Safety invariants — untouched
PJSUAEngineis reachable only throughgateway.make_call, which refuses emergency numbers and enforces the concurrency cap before any SIP action. No new dial path was introduced. The guard's position as the first check is unchanged.Verified against the lab
live_human0.75, stablemusic→ speech, tracking the dialplancaller pressed 1 -> accountsand branchedUpunder one bridge idDTMF reaching a real IVR closes out
send_dtmf, which was a no-op underMockSIPEngine.Opt-in
Selected with
SIP_ENGINE=pjsua2. Default stayssippywhile this proves out; the mock remains opt-in as before.PJSUAEngineimplements the existingSIPEngineinterface, so the gateway, call manager and hold-slayer service are unchanged.Bugs found by running against real bindings
None of these would be caught by a unit test:
pj.Callandpj.Accountfinalised afterlibDestroy()abort the process on a native assertion, exactly as media ports do. Both are now dropped and collected before the endpoint is destroyed.hangup()only queues the BYE, so shutdown deleted the account with a call still active and left the far end on an unclosed dialog.stop()now waits for teardown.Threading
Follows the established rule. PJSUA2 worker threads reach the loop only through
_post_from_pj→run_coroutine_threadsafe;onFrameReceivedtouches nothing butAudioTap.feed(thread-safe viacall_soon_threadsafe); any thread PJSUA2 did not create registers itself before touching a PJSUA2 object.Lab fixture correction
The speech fixture classified correctly on its first 3 s window and drifted to
musicafter — I had validated only the first window and reported it as verified, which overstated it.One modelling error:
_detect_tonalitylooks for autocorrelation periodicity in the 50–1000 Hz lag range, and each syllable used a constantf0, which is perfectly periodic there. That scoredis_tonal=True, handing music a free 0.3 that speech could not outrun. The fundamental now follows a pitch contour with jitter, integrated to phase.All three fixtures now classify correctly in 100% of windows (music 27/27, speech 5/5, silence 2/2), and stay correct when the window is stepped by half a window.
tests/test_lab_fixtures.pyguards this by sweeping every window — precisely what the original validation missed.What this does not prove
Stated plainly so the branch is not over-trusted:
Accountregistration returned404 Not Found. Expected, but unexercised.onIncomingCallis written but never fired.get_registered_devices()is a stub.This is a working media plane, not a finished engine.
Checks
ruff checkclean on all new files; unchanged from baseline on pre-existing ones🤖 Generated with Claude Code
Added:
3150f78— Asterisk logs made usableDeployed the lab to Virgo Dev (galatea) and checked the logs through Grafana. Logging was configured correctly and shipping to Loki with the right labels — and the stream was still useless: 100% healthcheck chatter, every line wrapped in ANSI escape codes. The real SIP events were present and completely buried.
Three causes, each masking the next:
asterisk.confwas never mounted. It was written in the first lab commit carryingnocolor = yesand has been inert ever since. Now mounted. It also overrode[directories]andrunuser/rungroup, which the image sets up correctly itself — removed, since overriding them risks breaking the container for no gain.The image's command is
-vvvdddf— verbosity 3 and debug 3 forced on the command line, which overrides bothasterisk.confandlogger.conf. That is why editinglogger.confalone changed nothing. Overridden in compose to drop-vand-d.The actual source: the image's healthcheck makes ~7 separate
asterisk -rxconnections every 30s, and Asterisk logs a connect/disconnect pair for each. Not verbosity at all. Replaced with a single check on a 60s interval.That last check now runs
pjsip show transports | grep <port>rather thancore show version. It fails when Asterisk is up but unconfigured — precisely the state that produced ahealthycontainer with no SIP stack on first deploy. The healthcheck now encodes the bug it previously missed.Verified on galatea
Container still healthy, transport bound on 21061, dialplan loaded. 169 tests pass.
Deployment note
The corresponding Ansible-side fixes are on
virgomain (8995747,bd6d8da) — config file mode (the container'sasteriskuser is a different uid, so640made every config unreadable and Asterisk started with no configuration while reporting healthy), and the liveness check (wait_forcannot check a UDP port — it opens a TCP connection).I applied these by hand on galatea to verify them, so a playbook re-run is worth doing to confirm Ansible asserts the same state.
Now-visible startup
ERRORlines are benign module-load messages for CDR backends we do not use (cdr_pgsql,cdr_sqlite3_custom) — they were always there, just buried.