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>
196 lines
6.5 KiB
Python
196 lines
6.5 KiB
Python
"""
|
|
Hold Slayer Gateway — Configuration
|
|
|
|
All settings loaded from environment variables / .env file.
|
|
"""
|
|
|
|
from pydantic import Field, SecretStr
|
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
|
|
|
|
class SIPTrunkSettings(BaseSettings):
|
|
"""SIP trunk provider configuration."""
|
|
|
|
model_config = SettingsConfigDict(env_prefix="SIP_TRUNK_", env_file=".env", extra="ignore")
|
|
|
|
host: str = "sip.provider.com"
|
|
port: int = 5060
|
|
username: str = ""
|
|
password: SecretStr = SecretStr("")
|
|
transport: str = "udp" # udp, tcp, tls
|
|
did: str = "" # Your phone number (E.164)
|
|
|
|
|
|
class GatewaySIPSettings(BaseSettings):
|
|
"""Gateway SIP listener for device registration."""
|
|
|
|
model_config = SettingsConfigDict(env_prefix="GATEWAY_SIP_", env_file=".env", extra="ignore")
|
|
|
|
host: str = "0.0.0.0"
|
|
port: int = 5060
|
|
domain: str = "gateway.local"
|
|
|
|
|
|
class SpeachesSettings(BaseSettings):
|
|
"""Speaches STT service configuration."""
|
|
|
|
model_config = SettingsConfigDict(env_prefix="SPEACHES_", env_file=".env", extra="ignore")
|
|
|
|
url: str = "http://localhost:22070"
|
|
model: str = "whisper-large-v3"
|
|
|
|
|
|
class ClassifierSettings(BaseSettings):
|
|
"""Audio classifier thresholds."""
|
|
|
|
model_config = SettingsConfigDict(env_prefix="CLASSIFIER_", env_file=".env", extra="ignore")
|
|
|
|
music_threshold: float = 0.7
|
|
speech_threshold: float = 0.6
|
|
silence_threshold: float = 0.85
|
|
window_seconds: float = 3.0
|
|
|
|
|
|
class LLMSettings(BaseSettings):
|
|
"""LLM service configuration (OpenAI-compatible API)."""
|
|
|
|
model_config = SettingsConfigDict(env_prefix="LLM_", env_file=".env", extra="ignore")
|
|
|
|
base_url: str = "http://localhost:11434/v1"
|
|
model: str = "llama3"
|
|
api_key: SecretStr = SecretStr("not-needed")
|
|
timeout: float = 30.0
|
|
max_tokens: int = 1024
|
|
temperature: float = 0.3
|
|
|
|
|
|
class HoldSlayerSettings(BaseSettings):
|
|
"""Hold Slayer behavior settings."""
|
|
|
|
model_config = SettingsConfigDict(env_prefix="HOLD_SLAYER_", env_prefix_allow_empty=True, env_file=".env", extra="ignore")
|
|
|
|
default_transfer_device: str = Field(
|
|
default="sip_phone", validation_alias="DEFAULT_TRANSFER_DEVICE"
|
|
)
|
|
max_hold_time: int = Field(default=7200, validation_alias="MAX_HOLD_TIME")
|
|
hold_check_interval: float = Field(default=2.0, validation_alias="HOLD_CHECK_INTERVAL")
|
|
|
|
|
|
class TTSSettings(BaseSettings):
|
|
"""Rhema TTS service configuration (OpenAI-compatible /v1/audio/speech)."""
|
|
|
|
model_config = SettingsConfigDict(env_prefix="TTS_", env_file=".env", extra="ignore")
|
|
|
|
base_url: str = "http://localhost:8000"
|
|
model: str = "speaches-ai/Kokoro-82M-v1.0-ONNX"
|
|
voice: str = "af_heart"
|
|
api_key: SecretStr = SecretStr("")
|
|
timeout: float = 30.0
|
|
sample_rate: int = 16000
|
|
|
|
|
|
class CasdoorSettings(BaseSettings):
|
|
"""Casdoor SSO (OIDC) configuration.
|
|
|
|
When `enabled` is true the browser authenticates via Casdoor and every
|
|
surface is gated to the owner; the SDK is used only for the OAuth2 code
|
|
exchange in the /auth/callback route (JWTs are validated against the
|
|
endpoint's JWKS). When false, the app runs in dev-owner mode (loopback only).
|
|
"""
|
|
|
|
model_config = SettingsConfigDict(env_prefix="CASDOOR_", env_file=".env", extra="ignore")
|
|
|
|
enabled: bool = False
|
|
endpoint: str = "https://id.ouranos.helu.ca"
|
|
client_id: str = ""
|
|
client_secret: SecretStr = SecretStr("")
|
|
org_name: str = "heluca"
|
|
app_name: str = ""
|
|
|
|
|
|
class ReceptionistSettings(BaseSettings):
|
|
"""AI Receptionist behavior settings."""
|
|
|
|
model_config = SettingsConfigDict(env_prefix="RECEPTIONIST_", env_file=".env", extra="ignore")
|
|
|
|
enabled: bool = True
|
|
greeting_template: str = (
|
|
"Hi, you've reached Robert's line. Who's calling, and what's this about?"
|
|
)
|
|
message_prompt: str = "Please leave your message after the tone."
|
|
listen_timeout_s: float = 15.0
|
|
end_of_utterance_silence_s: float = 1.2
|
|
message_max_seconds: int = 90
|
|
llm_persona: str = (
|
|
"You are a helpful, concise phone receptionist. Decide whether to ring "
|
|
"the owner, take a message, or politely decline."
|
|
)
|
|
|
|
|
|
class Settings(BaseSettings):
|
|
"""Root application settings."""
|
|
|
|
model_config = SettingsConfigDict(
|
|
env_file=".env",
|
|
env_file_encoding="utf-8",
|
|
extra="ignore",
|
|
)
|
|
|
|
# Database — no default credentials; must be set in the environment
|
|
database_url: str = ""
|
|
|
|
# Server
|
|
host: str = "0.0.0.0"
|
|
port: int = 8000
|
|
debug: bool = False
|
|
log_level: str = "info"
|
|
|
|
# Auth — Casdoor SSO for the browser + owner-minted PATs for MCP/CLI,
|
|
# gated to a single owner. `owner_name` is the Casdoor username that owns
|
|
# this gateway (everyone else gets 403). `public_base_url` seeds the OAuth
|
|
# discovery URLs; blank derives them from request headers. Both cross-cut
|
|
# every surface, so they live on the root model (like DATABASE_URL); the
|
|
# Casdoor connection knobs live under the CASDOOR_ prefix.
|
|
owner_name: str = ""
|
|
public_base_url: str = ""
|
|
|
|
# Outbound-call safety cap (REST + MCP make_call)
|
|
max_concurrent_calls: int = 4
|
|
|
|
# Explicit engine mode — the mock engine must be asked for. An
|
|
# unconfigured trunk without this flag fails startup instead of
|
|
# silently degrading to a gateway that can't place real calls.
|
|
use_mock_sip: bool = False
|
|
|
|
# SIP stack: "sippy" (signalling only — the classifier gets no audio) or
|
|
# "pjsua2" (call control + media, the only path where audio reaches the
|
|
# classifier). Opt-in while the PJSUA2 engine is proven against the lab;
|
|
# see docs/architecture.md → "Media plane: why PJSUA2 places the call".
|
|
sip_engine: str = "sippy"
|
|
|
|
# Notifications
|
|
notify_sms_number: str = ""
|
|
|
|
# Sub-configs
|
|
sip_trunk: SIPTrunkSettings = Field(default_factory=SIPTrunkSettings)
|
|
gateway_sip: GatewaySIPSettings = Field(default_factory=GatewaySIPSettings)
|
|
speaches: SpeachesSettings = Field(default_factory=SpeachesSettings)
|
|
classifier: ClassifierSettings = Field(default_factory=ClassifierSettings)
|
|
llm: LLMSettings = Field(default_factory=LLMSettings)
|
|
hold_slayer: HoldSlayerSettings = Field(default_factory=HoldSlayerSettings)
|
|
tts: TTSSettings = Field(default_factory=TTSSettings)
|
|
receptionist: ReceptionistSettings = Field(default_factory=ReceptionistSettings)
|
|
casdoor: CasdoorSettings = Field(default_factory=CasdoorSettings)
|
|
|
|
|
|
# Singleton
|
|
_settings: Settings | None = None
|
|
|
|
|
|
def get_settings() -> Settings:
|
|
"""Get cached application settings."""
|
|
global _settings
|
|
if _settings is None:
|
|
_settings = Settings()
|
|
return _settings
|