1xBTS 1xBTS

Voice Gateway Setup

The voice gateway (cdma-voice-gw) is a separate process that bridges CDMA voice calls to SIP/PSTN networks. It handles SIP signaling, RTP media, and EVRC↔G.711 transcoding.

The gateway supports both outbound (handset dials out through your SIP trunk) and inbound (trunk dials a subscriber on your network). See Inbound calls for the inbound side.

Architecture

CDMA Handset
    ↕ air interface (EVRC)
BTS / BSC / MSC media + policy
    ↕ A1 call control + A2p RTP bearer on the cellular side
cdma-voice-gw
    ↕ SIP/RTP (G.711)
SIP Trunk / PSTN

The cellular side separates call control from bearer media: MSC owns call policy, media orchestration, gateway control, and preemption over A1/A2p, while BSC executes the radio leg. The gateway remains the external SIP/RTP bridge for non-local calls.

Ubuntu libre/re Install

The gateway links against Baresip re/libre for SIP signaling. Some Ubuntu libre-dev packages are too old for SIP REGISTER support, so build upstream re before building cdma-voice-gw:

sudo apt install build-essential cmake pkg-config libssl-dev git

git clone https://github.com/baresip/re.git
cd re
git checkout v4.6.0
cmake -B build -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=/usr/local
cmake --build build -j"$(nproc)"
sudo cmake --install build
sudo ldconfig

MSC Configuration

Enable the gateway in config/msc.json:

{
  "voice": {
    "gateway": {
      "enabled": true,
      "endpoint": "http://127.0.0.1:17015",
      "fallback_to_wav": true
    }
  }
}

voice.gateway.enabled toggles MSC-owned routing of non-local calls through the gateway. voice.gateway.endpoint is the gRPC address; voice.gateway.fallback_to_wav plays the configured WAV instead of failing when the gateway is unreachable.

Other MSC voice settings that affect SIP behaviour: sip_ringback_disable (outbound ringback source — see Ringback below), inbound_sip_msc_ringback (inbound ringback source), generate_ringback and send_tones_alert (caller-handset ringback), failure_tone_duration_ms (busy-tone duration on call failure), page_retry_cooldown_ms / page_retry_max_duration_ms (MT page hunt window).

What the caller hears

When a call fails (number not in service, denied by the trunk, etc.) the caller hears a busy tone for failure_tone_duration_ms (default 3 seconds), then the call ends with the handset showing the failure reason. The MSC tells the handset to play the tone locally — no audio is synthesized on our side.

When the trunk is unreachable entirely (gateway down, DNS failure, etc.) and gateway.fallback_to_wav is true, the caller instead hears the configured voice.wav_file after the normal answer delay. This is intended as a “the network ate your call” placeholder rather than a real ring-and-pickup experience.

Ringback

While a call is being set up, someone has to play the ringback the caller hears. Four flags decide who — two for the SIP side, two for the handset side. See each in the msc.json reference for the authoritative defaults and field-level descriptions.

SIP side — what the SIP caller hears

  • sip_ringback_disable — on outbound calls, false (default) makes your network play ringback toward the caller; true defers to the SIP trunk’s early-media ringback.
  • inbound_sip_msc_ringback — on inbound calls, true (default) makes your network play ringback toward the SIP caller (subscriber’s custom ringtone if uploaded, otherwise default tone); false defers to the trunk.

Handset side — what the calling handset hears

  • generate_ringbacktrue (default) streams ringback audio to the calling handset over the call’s audio channel.
  • send_tones_alertfalse (default). When true, instructs the calling handset to play its own ringback tone. Independent of generate_ringback; either, both, or neither are valid.

For most handsets, the defaults produce the most consistent experience. send_tones_alert is mainly useful as a legacy option for users who specifically want the handset to draw its own ringback tone. If you’re not sure, leave everything at the defaults.

Minimum Working Config

The smallest config that actually places an outbound call through a real SIP trunk. Substitute your provider’s hostname, username/DID, and env var name. Save as config/voice-gw.local.json:

{
  "grpc": {
    "listen_addr": "127.0.0.1:17015"
  },
  "sip": {
    "listen_addr": "10.30.4.105:5062",
    "transport": "udp",
    "request_uri_template": "sip:{called}@sip.example.com:5060",
    "from_domain": "sip.example.com",
    "caller_id_override": "+14805551212",
    "keepalive_interval_secs": 25,
    "auth": {
      "username": "your-sip-username",
      "password_env": "VOICE_GW_SIP_PASSWORD"
    },
    "registration": {
      "enabled": true,
      "registrar_uri": "sip:sip.example.com:5060",
      "expires_secs": 300
    }
  },
  "rtp": {
    "listen_addr": "10.30.4.105",
    "port_range": [17100, 17200],
    "preferred_codecs": ["PCMU", "PCMA"]
  },
  "nat": {
    "mode": "stun_latch",
    "stun_server": "stun.l.google.com:19302"
  }
}

The five fields you must change for your environment:

  • sip.listen_addr — concrete local IP:port. Libre rejects wildcard binds (0.0.0.0, ::); the gateway fails validation at startup. Use the LAN address of the host running the gateway.
  • sip.from_domain — must match the domain your trunk expects in the From URI. Mismatches typically come back as 403 Forbidden or 407 Proxy Authentication Required.
  • sip.request_uri_template — provider-supplied SIP URI. The literal {called} is replaced with the dialed digits at INVITE time.
  • sip.caller_id_override — your DID in E.164 (e.g. +14805551212). When null, the handset’s MDN is used as the From user, which most trunks reject as an un-owned number.
  • sip.auth.password_env — name of the env var holding the SIP password. Prefer this over auth.password so secrets never live in the JSON file. Setting both is a validation error.

The voice-gw.json reference lists every available field.

Gateway Configuration

Field-level descriptions and defaults for every voice-gateway setting live in the voice-gw.json reference. This section covers the operational guidance that doesn’t fit in a reference table — how to choose values, common deployment patterns, and gotchas.

SIP

The sip section is where most deployment-specific values live. A few non-obvious points:

  • auth.password_env and auth.password are mutually exclusive — set exactly one. With password_env the gateway reads std::env::var(<name>) at startup; if the var is unset, validation fails before any SIP traffic. Prefer password_env so secrets never live in JSON.
  • auth.username is the SIP digest username your provider issues — some carriers use a tech-prefix or account ID rather than the DID. If it’s not the same as the DID, the trunk’s 401 will point at the wrong field.
  • caller_id_override should be a DID you actually own on the trunk (E.164, e.g. +14805551212). When null, the handset’s MDN is used as the From user, which most public trunks reject.
  • keepalive_interval_secs requires registration.enabled=true — the gateway refuses to start otherwise.

RTP and NAT

Pick a configuration based on where the gateway runs relative to the public internet:

For STUN, set rtp.listen_addr to your actual LAN IP rather than 0.0.0.0: STUN binds to the literal RTP socket and asks the STUN server what (IP, port) it sees, and that mapping is what goes into outgoing SDP. A wildcard bind discovers whichever interface the OS happened to source the packet from, which is fragile. Google’s public server (stun.l.google.com:19302) is fine for testing; in production, use whatever STUN your provider publishes.

At call time: STUN binding request from the chosen RTP socket → external (IP, port) placed in outgoing SDP → first inbound RTP source from the SIP peer is “latched” as the return path (handles asymmetric NAT mappings the SDP couldn’t predict).

Calls and jitter buffer

See calls for concurrency and timeout fields, and jitter_buffer_ms for the adaptive (40–80 ms) inbound RTP reorder buffer. EVRC silence frames are inserted when packets arrive late.

Running the Gateway

The conventional name for your real config is config/voice-gw.local.json (gitignored alongside voice-gw.example.json). Point the gateway at it and supply the SIP password via the env var named in auth.password_env:

# Inline:
VOICE_GW_SIP_PASSWORD='...' \
  cargo run --release -p cdma-voice-gw -- --config config/voice-gw.local.json

# Or sourced from a file kept out of git:
set -a
source ~/.config/1xbts/voice-gw.env   # contains VOICE_GW_SIP_PASSWORD=...
set +a
cargo run --release -p cdma-voice-gw -- --config config/voice-gw.local.json

The gateway validates the config at startup — missing env var, wildcard SIP bind, mismatched password/password_env, or a 0.0.0.0 RTP bind without advertise_addr will all fail before any SIP traffic. The MSC runtime connects automatically when voice.gateway.enabled is true.

SIP Trunk Providers

The gateway works with any standard SIP trunk that accepts UDP/TCP/TLS digest-auth INVITEs. The Minimum Working Config above is the typical config for any public PSTN provider — registration enabled, digest auth via password_env, caller_id_override set to a DID you own on the trunk.

Local SIP server (no auth)

For development against a local FreeSWITCH/Asterisk on the LAN, you can skip auth and registration entirely:

{
  "sip": {
    "listen_addr": "192.168.1.50:5060",
    "request_uri_template": "sip:{called}@192.168.1.200:5060",
    "from_domain": "192.168.1.50",
    "auth": { "username": "" },
    "registration": { "enabled": false }
  },
  "rtp": {
    "listen_addr": "192.168.1.50",
    "port_range": [17100, 17200]
  },
  "nat": { "mode": "disabled" }
}

Inbound calls

When the gateway is reachable from your SIP trunk, an inbound INVITE is matched to a subscriber by comparing the Request-URI user portion (e.g. 4805551212) against subscriber.phone_number in the HLR. A leading + is stripped before matching, so +14805551212 and 14805551212 both work.

If a subscriber matches and is currently registered, the MSC pages the handset and bridges audio between SIP RTP and the bearer when the subscriber answers.

Reject mapping

Trunk seesWhy
404 Not FoundNo subscriber has that DID, or the Request-URI user is not all digits
480 Temporarily UnavailableSubscriber exists but isn’t currently registered / paging timed out
486 Busy HereSubscriber is already on another call
488 Not Acceptable HereTrunk’s SDP didn’t offer a G.711 codec
487 Request TerminatedCaller hung up before the subscriber answered (gateway sends this on CANCEL)
408 Request TimeoutGateway didn’t get a decision back in sip.inbound_decision_timeout_ms
503 Service UnavailableRTP socket couldn’t be allocated, MSC unreachable, or other gateway-side error

Trust model

There is no authentication on inbound INVITEs in v1 — anyone who can reach the SIP listen port can attempt to call any subscriber on your network. Bind sip.listen_addr to a private interface, or firewall the port to your trunk provider’s source addresses.

If you publish the same SIP listen port to the public internet without filtering, expect drive-by INVITE traffic.

Transcoding

The gateway transcodes between EVRC (air interface) and G.711 (SIP/RTP):

DirectionPipeline
Handset → PSTNEVRC primary bits → EVRC decode → PCM → G.711 encode → RTP
PSTN → HandsetRTP → G.711 decode → PCM → EVRC encode → EVRC primary bits

Media frames on the cellular bearer are exchanged as raw EVRC codec payloads (without MuxPDU headers). The BSC adds or strips the MuxPDU MM=0 header for the Abis and air-interface side, and the MSC forwards non-local call media between A2p and the gateway. A2p uses RTP/UDP per circuit and the EVRC RTP payload format from RFC 3558. Erasure frames with rate_bps=0 are skipped in the gateway media path.

Troubleshooting

SymptomCheck
Gateway not connectingMSC voice.gateway.endpoint matches gateway grpc.listen_addr
SIP INVITE rejectedAuth credentials, from_domain, trunk provider settings
No audioRTP listen address reachable from SIP peer, firewall rules on RTP port range
One-way audioNAT config — try stun_latch mode, or set advertise_addr
Choppy audioJitter buffer too small, network latency to SIP peer
Calls fall back to WAVGateway process not running, or fallback_to_wav is true
DNS resolution failureCheck request_uri_template domain resolves, try IP directly