A SIP-to-Mumble audio bridge. Receives inbound SIP calls and routes audio bidirectionally into Mumble servers. Each call gets its own independent Mumble connection, and calls can be routed to different Mumble servers using a custom X-Mumble-Server SIP header.
This has been a project I've wanted to do for a while. After joining a startup I wanted to really stress test the current generation of models (Opus 4.6) and see what they could really do. Most of this codebase was vibecoded I provided high level design, for example I knew that I wanted to bind pjsip into this library since it's fast and battle tested. For the most part almost none of the Rust code was written by a human here.
- Multiple simultaneous SIP calls, each with its own Mumble bot user
- Per-call Mumble server routing via
X-Mumble-ServerSIP header - Opus audio encoding/decoding at 48kHz
- Lock-free audio pipeline between PJSIP and Mumble
- Configurable max concurrent calls
- DTMF Navigation
*for previous channel#for next channel
- Optional Pocket-TTS channel-name announcements (managed sidecar via
uvx) - Persistent caller settings via SQLite — callers get auto-generated nicknames instead of exposing phone numbers
System packages:
- binutils, make, gcc (for building PJSIP)
- protoc (for Mumble protocol buffers)
- ALSA development libraries (
libasound2-devon Debian/Ubuntu) - OpenSSL development libraries (
libssl-dev) - Opus development library (
libopus-dev) - UUID library (
uuid-dev) - Rust / cargo
uvx(only required when[tts].enabled = true)
git clone --recursive https://github.com/youruser/mumble-sip.git
cd mumble-sip
# If you already cloned without --recursive:
git submodule update --init --recursive
cargo build --releaseCopy config.toml.example to config.toml and edit it:
[sip]
listen_port = 5060
account_uri = "sip:bridge@pbx.example.com"
registrar = "sip:pbx.example.com"
username = "bridge"
password = "secret"
max_concurrent_calls = 10
[mumble]
default_host = "mumble.example.com"
port = 64738
password = ""
channel = "SIP Calls"
accept_invalid_cert = true
[audio]
sample_rate = 48000
frame_duration_ms = 10
opus_bitrate = 32000
jitter_buffer_ms = 60
[tts]
enabled = false
host = "127.0.0.1"
port = 8000
voice = "eponine"
announce_on_connect = true
startup_timeout_ms = 20000
request_timeout_ms = 3000
announcement_debounce_ms = 750
auto_restart = true
[database]
path = "mumble-sip.db"Callers are assigned Docker-style generated nicknames (e.g. "relaxed_babbage") that persist across calls. Phone numbers are never exposed as Mumble usernames. The nickname mapping is stored in a local SQLite database (default: mumble-sip.db in the working directory), created automatically on first run.
The [database].path setting controls where the database file is stored. No external database server is required.
./target/release/mumble-sip config.tomlDefine an endpoint in pjsip.conf (or the equivalent in your Asterisk config) for the mumble-sip bridge. There are two approaches depending on your network setup:
The bridge registers itself with Asterisk using the [sip].username and [sip].password from config.toml. This is the simplest approach and works well when the bridge's IP may change (e.g., DHCP, containers):
; pjsip.conf
[mumble-bridge-transport]
type = transport
protocol = udp
bind = 0.0.0.0:5060
[mumble-bridge]
type = endpoint
transport = mumble-bridge-transport
context = mumble-bridge
disallow = all
allow = ulaw
allow = alaw
aors = mumble-bridge
auth = mumble-bridge-auth
[mumble-bridge-auth]
type = auth
auth_type = userpass
username = bridge ; must match [sip].username in config.toml
password = secret ; must match [sip].password in config.toml
[mumble-bridge]
type = aor
max_contacts = 1If the bridge runs at a known, fixed IP you can skip registration entirely. Asterisk identifies the bridge by IP and always knows where to send calls:
; pjsip.conf
[mumble-bridge-transport]
type = transport
protocol = udp
bind = 0.0.0.0:5060
[mumble-bridge]
type = endpoint
transport = mumble-bridge-transport
context = mumble-bridge
disallow = all
allow = ulaw
allow = alaw
aors = mumble-bridge
[mumble-bridge]
type = aor
contact = sip:bridge@10.0.0.50:5060 ; IP/port of mumble-sip server
[mumble-bridge]
type = identify
endpoint = mumble-bridge
match = 10.0.0.50 ; IP of mumble-sip serverWith this approach, the [sip].username, [sip].password, and [sip].registrar settings in config.toml are unused.
Route a specific extension to the bridge. All calls go to the default Mumble server from config.toml:
; extensions.conf
[default]
exten => 7000,1,NoOp(Routing to Mumble bridge)
same => n,Dial(PJSIP/mumble-bridge)
same => n,Hangup()Route calls to different Mumble servers based on the dialed extension using the custom X-Mumble-Server header:
; extensions.conf
[default]
; Extension 7001 → mumble-server-a.example.com
exten => 7001,1,NoOp(Routing to Mumble Server A)
same => n,Set(PJSIP_HEADER(add,X-Mumble-Server)=mumble-server-a.example.com)
same => n,Dial(PJSIP/mumble-bridge)
same => n,Hangup()
; Extension 7002 → mumble-server-b.example.com
exten => 7002,1,NoOp(Routing to Mumble Server B)
same => n,Set(PJSIP_HEADER(add,X-Mumble-Server)=mumble-server-b.example.com)
same => n,Dial(PJSIP/mumble-bridge)
same => n,Hangup()
; Extension 7003 → uses default from config.toml (no header)
exten => 7003,1,NoOp(Routing to default Mumble server)
same => n,Dial(PJSIP/mumble-bridge)
same => n,Hangup()For dynamic routing, you can look up the Mumble server from a database or variable:
; extensions.conf
[mumble-rooms]
exten => _70XX,1,NoOp(Dynamic Mumble routing for ${EXTEN})
same => n,Set(MUMBLE_HOST=${DB(mumble/servers/${EXTEN})})
same => n,GotoIf($["${MUMBLE_HOST}" = ""]?no_server)
same => n,Set(PJSIP_HEADER(add,X-Mumble-Server)=${MUMBLE_HOST})
same => n,Dial(PJSIP/mumble-bridge)
same => n,Hangup()
same => n(no_server),Playback(ss-noservice)
same => n,Hangup()Populate the AstDB entries:
asterisk -rx 'database put mumble/servers 7010 mumble-a.example.com'
asterisk -rx 'database put mumble/servers 7011 mumble-b.example.com'If using the older chan_sip driver instead of PJSIP:
; sip.conf
[mumble-bridge]
type = peer
host = 10.0.0.50
port = 5060
disallow = all
allow = ulaw
allow = alaw
context = mumble-bridge; extensions.conf
[default]
exten => 7001,1,NoOp(Routing to Mumble via chan_sip)
same => n,SIPAddHeader(X-Mumble-Server: mumble-server-a.example.com)
same => n,Dial(SIP/mumble-bridge)
same => n,Hangup()flowchart LR
subgraph P["PJSIP callbacks / media threads"]
A["on_incoming_call / on_call_state / on_dtmf_digit"] --> B["SipEvent::*"]
C["on_call_media_state(call_id)"] --> D["Attach custom pjmedia_port to pjsua conference bridge"]
E["Call N media via custom port<br/>put_frame/get_frame (PCM)"]
end
B --> F["sip_events (tokio mpsc::unbounded)"]
subgraph T["Tokio runtime"]
F --> G["Main event loop"]
G -->|"IncomingCall"| H["spawn SessionManager::on_incoming_call"]
G -->|"CallStateChanged(disconnected)"| I["SessionManager::on_call_disconnected"]
G -->|"DtmfDigit"| J["SessionManager::on_dtmf_digit"]
subgraph S["Per-call Session N"]
H --> K["Connect to Mumble + create ring buffers/custom media port"]
K --> L["register_pending_port(call_id, media_port)"]
M["Encoder task: SIP PCM -> Opus"] --> N["Voice forwarder task -> MumbleSender.send_voice"]
O["Mumble event task: recv Opus/events"] --> Q["Decoder/mixer task: Opus + sounds -> SIP PCM"]
end
end
L -. consumed by .-> C
E --> M
Q --> E
flowchart LR
subgraph P["PJSIP callback thread"]
A["RFC 2833 DTMF digit"] --> B["on_dtmf_digit(call_id, digit)"]
B --> C["SipEvent::DtmfDigit { call_id, digit }"]
end
C --> D["sip_events (mpsc)"]
subgraph T["Tokio runtime"]
D --> E["main event loop recv()"]
E --> F["SessionManager::on_dtmf_digit(call_id, digit)"]
F --> G{"digit"}
G -- "*" --> H["target = current_channel_id - 1"]
G -- "#" --> I["target = min(current + 1, max_channel_id)"]
G -- "other" --> J["ignore"]
H --> K["play navigation sound"]
I --> K
K --> L["mumble_sender.join_channel(target)"]
L -- "success" --> M["update current_channel_id"]
M --> N["channel_watch_tx.send(target)"]
L -- "error" --> O["log warning (session state unchanged)"]
end