The gateway can now hear. Verified end to end against the Asterisk lab:
speech classifies as live_human, hold music as music, the speech→music
transition tracks the dialplan, and DTMF reaches a real IVR (Asterisk logged
"caller pressed 1 -> accounts" and branched). RTP stats show 0% packet loss.
PJSUAEngine implements the existing SIPEngine interface, so the gateway,
call manager and hold-slayer service are unchanged. It is selected with
SIP_ENGINE=pjsua2; the default stays "sippy" while this is proven, and the
mock remains opt-in as before.
Why a new engine rather than fixing the old path: PJSUA2 exposes no
standalone RTP media object, so it will not surface media for a dialog it
does not own. Owning the dialog is the price of owning the media. Sippy keeps
the SBC roles it is good at — device registration, routing, leg bridging —
and SippyEngine remains 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.
The safety invariants are untouched. This engine is reachable only through
gateway.make_call, which refuses emergency numbers and enforces the
concurrency cap before any SIP action. No new dial path was introduced.
MediaPipeline.add_remote_stream(host, port) is replaced by
attach_call_media(stream_id, audio_media), called from onCallMediaState —
the one place PJSUA2 hands out RTP-backed media. Taps requested before media
comes up are attached when it does, so the classifier never misses the start
of a call.
Three crash/lifetime bugs found by running against the real bindings, none of
which any unit test would have caught:
- pj.Call and pj.Account objects finalised after libDestroy() abort the
process on a native assertion, exactly as media ports do. Both are now
dropped and collected before the pipeline destroys the endpoint.
- PJSUA2 keeps delivering callbacks during interpreter teardown, when module
globals may already be cleared. The callbacks alias what they need locally
and swallow everything: a raise there escapes into C++ and takes the worker
thread with it.
- 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
briefly for the teardown to complete.
Threading follows the established rule: PJSUA2 worker threads reach the loop
only through _post_from_pj → run_coroutine_threadsafe, and any thread PJSUA2
did not create registers itself before touching a PJSUA2 object.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
SippyEngine had never successfully placed a call or registered a trunk.
Every failure was masked by broad exception handlers that logged and marked
the leg terminated, so the gateway reported "ringing" and then ended the
call rather than surfacing the fault. None of it was visible to the test
suite, which runs exclusively on MockSIPEngine.
Found by pointing the gateway at a local Asterisk instance (tests/lab) —
each fix uncovered the next.
1. Trunk registration used kwargs the installed sippy (2.3.0) does not
accept (auth_name/auth_password → user/passw), called register()
instead of doregister(), and passed aor/contact as strings where
SipRegistrationAgent calls .getCopy() and mutates .username/.port,
so SipURL objects are required.
It also posted registered=True at *send* time. Registration is
asynchronous, so a rejected REGISTER would still have reported success
— and /health treats a registered trunk as a condition for "healthy".
Now wired to sippy's rok_cb/rfail_cb, so the rejection status line
(typically a bad trunk password) reaches the operator.
The Contact also fell back to loopback when the SIP bind is 0.0.0.0;
a wildcard address is not somewhere a trunk can send an INVITE.
2. The INVITE passed SDP as a `body` kwarg. CCEventTry takes no such
argument: UacStateIdle unpacks exactly six fields from the data tuple
and expects the SDP as a MsgBody in position four. callingID/calledID
are bare usernames — sippy builds the URIs itself from nh_address.
3. _sip_logger was absent from the global config. SipTransactionManager
dereferences it on every message, so the first SIP packet in either
direction raised KeyError inside the ED thread.
4. SippyCallController was not callable. Sippy invokes event_cb(event, ua)
with CCEvent objects; the class only exposed on_* methods that nothing
called. Added __call__ to dispatch CCEventRing/Connect/Disconnect/Fail
to the existing handlers, guarding the body because an exception
escaping into the ED dispatcher would hang the leg silently.
5. The UA was constructed without credentials, so sippy could not answer
the 401/407 challenge that any authenticating trunk sends. Every
outbound call died on the challenge.
Verified end to end against Asterisk 22.10.1: 180 Ringing → Connected →
23s of audio → clean teardown, with the dialplan executing and audio
playing in real time.
Not fixed here, and still blocking media: MediaPipeline.create_tap is a
stub that logs success and returns a tap nothing ever feeds, so the
classifier receives no audio on a live call.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Engine mode is now explicit: USE_MOCK_SIP=true is the only way to get
the mock engine; an unconfigured trunk fails startup with guidance
instead of silently degrading. Root-caused why the engine always ran
mock: nested pydantic-settings never read .env (no env_file on the
sub-settings classes) — all 8 now declare it.
/health stops lying: reports engine mode (sippy|mock), a live DB
SELECT 1, trunk registration state with reason, and TTS/STT
availability from their last real request; "healthy" now requires
ready + db + sippy + registered trunk.
Error policy: leaf services (tts/transcription/llm_client) raise and
track availability; call-loop callers catch, publish EventType.ERROR
naming the failed service, and apply an explicit fallback. Persistence
writes get one bounded 3x exponential retry, then an ERROR log — no
more silent data loss.
Event bus: a full subscriber queue drops its oldest event (counted)
instead of silently evicting the subscription; subscribe(replay_last=N)
delivers the advertised history replay, used by /ws/events (25).
Receptionist correctness: a matched TAKE_MESSAGE rule beats the LLM;
voicemail polls for early hangup and stops/transcribes/hangs up in
finally; RecordingSession finally keeps its leg_ids so taps detach.
Dead code removed: models/contact.py + Contact table, dtmf_buffer,
transcribe_stream stub, SMS stub in notification.py.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three thread domains were mutating shared dicts with no locks: Sippy's
ED thread wrote _legs/_registered_devices directly from SIP handlers,
the asyncio loop wrote them from make_call/hangup, and
run_in_executor(None, ...) had default-pool threads driving sippy UA
objects. AudioTap.feed() pushed into an asyncio.Queue (not
thread-safe) from the PJSUA2 thread.
New ownership rule, enforced structurally:
- The asyncio loop owns all app-visible state; the only mutator is the
new _on_engine_event funnel. Sippy handlers extract plain strings on
the ED thread and post via run_coroutine_threadsafe.
- The ED thread owns sippy objects plus _ed_ua_to_leg/_ed_leg_to_ua;
loop-side commands (INVITE/BYE/DTMF/trunk register) hop over via
ED2.callFromThread. UA references no longer live on SipCallLeg.
- AudioTap captures its loop and feed() hops via call_soon_threadsafe.
- Fix ED import: installed sippy 2.x exposes ED2, not ED — the old
import could never start the event loop.
Also:
- Wire the never-connected on_leg_state_change callback: outbound
ringing/connected/terminated now reaches CallManager; a call ends
when its last leg terminates (transfers keep it alive). Adds
CallManager.unmap_leg/legs_for_call.
- AudioClassifier.classify(): async entry that runs the FFT work in
asyncio.to_thread and updates history on the loop — all four
hold_slayer call sites now route through it, fixing both the
loop-blocking and the 2-of-4 history gap. DTMF Goertzel loop
replaced by the equivalent vectorized DFT-bin power.
- Task hygiene: gateway.spawn() tracks hold-slayer/receptionist tasks
and stop() cancels them; recording safety-timeout task is retained
and cancelled on stop_recording; engine tracks incoming-call
dispatch tasks.
10 new tests: funnel events from a foreign thread, auto-answer
fallback, AudioTap cross-thread feed, classifier history, leg-state →
call status (including no stomping of ON_HOLD), stop() cancellation.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds read-only access to persisted call records for the dashboard
and implements a client for the Rhema text-to-speech service.
- api/call_history.py: New router providing paged call lists
and detailed call records with transcript metadata.
- services/tts.py: Async client for OpenAI-compatible TTS
endpoints (Rhema/Kokoro) used for call-flow steps.
Complete project scaffolding and core implementation of an AI-powered
telephony system that calls companies, navigates IVR menus, waits on
hold, and transfers to the user when a human answers.
Key components:
- FastAPI server with REST API, WebSocket, and MCP (SSE) interfaces
- SIP/VoIP call management via PJSUA2 with RTP audio streaming
- LLM-powered IVR navigation using OpenAI/Anthropic with tool calling
- Hold detection service combining audio analysis and silence detection
- Real-time STT (Whisper/Deepgram) and TTS (OpenAI/Piper) pipelines
- Call recording with per-channel and mixed audio capture
- Event bus (asyncio pub/sub) for real-time client updates
- Web dashboard with live call monitoring
- SQLite persistence via SQLAlchemy with call history and analytics
- Notification support (email, SMS, webhook, desktop)
- Docker Compose deployment with Opal VoIP and Opal Media containers
- Comprehensive test suite with unit, integration, and E2E tests
- Simplified .gitignore and full project documentation in README