15 KiB
Voice Call Support
Overview
signal-cli supports voice calls by spawning a subprocess called
signal-call-tunnel for each call. The tunnel handles WebRTC negotiation and
audio transport. signal-cli communicates with the tunnel over its stdin/stdout
using newline-delimited JSON messages, relaying signaling between the tunnel
and the Signal protocol.
signal-cli signal-call-tunnel
| |
|-- spawn --------------------------->|
|-- config JSON on stdin ------------>|
| |
|-- commands on stdin --------------->|
|<-- events on stdout ----------------|
| | WebRTC
| signaling relay | audio I/O
| |
| (stderr: tunnel logging) -------->| (captured by signal-cli)
Each call gets its own tunnel process. When the call ends, signal-cli closes stdin and destroys the process.
Audio device names (inputDeviceName, outputDeviceName) are opaque strings
returned by the tunnel in its ready message. signal-cli passes them through
to JSON-RPC clients, which use them to connect audio via platform APIs.
Spawning the Tunnel
For each call, signal-cli:
- Spawns
signal-call-tunnel - Writes config JSON followed by a newline to stdin
- Keeps stdin open for subsequent control messages
- Reads control events from stdout
- Captures stderr for logging
The signal-call-tunnel binary is located by searching (in order):
SIGNAL_CALL_TUNNEL_BINenvironment variable<signal-cli install dir>/bin/signal-call-tunnel(detected from jar location)signal-call-tunnelonPATH
Config JSON
The first line written to the tunnel's stdin:
{
"call_id": 12345,
"is_outgoing": true,
"local_device_id": 1,
"input_device_name": "signal_input",
"output_device_name": "signal_output"
}
| Field | Type | Description |
|---|---|---|
call_id |
unsigned 64-bit integer | Call identifier (use unsigned representation) |
is_outgoing |
boolean | Whether this is an outgoing call |
local_device_id |
integer | Signal device ID |
input_device_name |
string (optional) | Requested input audio device name |
output_device_name |
string (optional) | Requested output audio device name |
If input_device_name or output_device_name are omitted, the tunnel
chooses default names. On Linux, these are per-call unique names (e.g.,
signal_input_<call_id>). On macOS, these are the fixed names signal_input
and signal_output, which must match the pre-installed BlackHole drivers.
Control Protocol
Newline-delimited JSON messages over stdin (signal-cli to tunnel) and stdout (tunnel to signal-cli). The first line on stdin is the config JSON. Subsequent lines are control messages.
signal-cli -> Tunnel (stdin)
| Type | When | Fields |
|---|---|---|
createOutgoingCall |
Outgoing call setup | callId, peerId |
proceed |
After offer/receivedOffer | callId, hideIp, iceServers |
receivedOffer |
Incoming call | callId, peerId, opaque, age, senderDeviceId, senderIdentityKey, receiverIdentityKey |
receivedAnswer |
Outgoing call answered | opaque, senderDeviceId, senderIdentityKey, receiverIdentityKey |
receivedIce |
ICE candidates arrive | candidates (array of base64 opaque blobs) |
accept |
User accepts incoming call | (none) |
hangup |
End the call | (none) |
Tunnel -> signal-cli (stdout)
| Type | When | Fields |
|---|---|---|
ready |
Control socket bound, audio devices created | inputDeviceName, outputDeviceName |
sendOffer |
Tunnel generated an offer | callId, opaque, callMediaType |
sendAnswer |
Tunnel generated an answer | callId, opaque |
sendIce |
ICE candidates gathered | callId, candidates (array of {"opaque":"..."}) |
sendHangup |
Tunnel wants to hang up | callId, hangupType |
sendBusy |
Line is busy | callId |
stateChange |
Call state transition | state, reason (optional) |
error |
Something went wrong | message |
Opaque blobs and identity keys are base64-encoded. ICE servers use the format:
{
"urls": [
"turn:example.com"
],
"username": "u",
"password": "p"
}
Startup Sequence
signal-cli signal-call-tunnel
| |
|-- spawn process ------------------> |
|-- config JSON + newline on stdin ---->|
| | parse config
| | initialize audio
| |
|<-------- ready (on stdout) -----------|
| {"type":"ready", |
| "inputDeviceName":"...", |
| "outputDeviceName":"..."} |
| |
|-- control messages on stdin --------->|
|<-- control events on stdout ----------|
Call Flows
Outgoing call
signal-cli signal-call-tunnel Remote Phone
| | |
|-- spawn + config ------->| |
|<-- ready ----------------| |
|-- createOutgoingCall --->| |
|-- proceed (TURN) ------->| |
| | create offer |
|<-- sendOffer ------------| |
|-- offer via Signal -------------------------------->|
|<-- answer via Signal -------------------------------|
|-- receivedAnswer ------->| (+ identity keys) |
|<-- sendIce --------------| |
|-- ICE via Signal -------------------------------> |
|<-- ICE via Signal -------------------------------- |
|-- receivedIce ---------->| |
| | ICE connects |
|<-- stateChange:Connected | |
Incoming call
signal-cli signal-call-tunnel Remote Phone
| | |
|<-- offer via Signal --------------------------------|
|-- spawn + config ------->| |
|<-- ready ----------------| |
|-- receivedOffer -------->| (+ identity keys) |
|-- proceed (TURN) ------->| |
| | process offer |
|<-- sendAnswer -----------| |
|-- answer via Signal -------------------------------->|
|<-- sendIce --------------| |
|-- ICE via Signal ------------------------------> |
|<-- ICE via Signal -------------------------------- |
|-- receivedIce ---------->| |
| | ICE connecting... |
| | |
| (user accepts call) | |
| Java defers accept | |
| | |
|<-- stateChange:Ringing --| (tunnel ready to accept)|
|-- accept --------------->| (deferred accept sent) |
| | accept |
|<-- stateChange:Connected | |
JSON-RPC client perspective
An external application (bot, UI, test script) interacts via JSON-RPC only.
Important: Call event notifications are not sent by default. Clients must
call subscribeCallEvents before initiating or receiving calls. Without this,
incoming calls are silently ignored (no tunnel is spawned).
JSON-RPC Client signal-cli daemon
| |
|-- subscribeCallEvents() ------------>| (required: enables call support)
| |
|-- startCall(recipient) ------------->|
|<-- {callId, state, -|
| inputDeviceName, |
| outputDeviceName} |
| |
|<-- callEvent: RINGING_OUTGOING ------|
| ... remote answers ... |
|<-- callEvent: CONNECTED -------------|
| |
| connect to audio devices |
| (via platform audio APIs) |
| |
|-- hangupCall(callId) --------------->| (or: receive callEvent ENDED)
|<-- callEvent: ENDED -----------------|
| disconnect from audio devices |
For incoming calls:
JSON-RPC Client signal-cli daemon
| |
|-- subscribeCallEvents() ------------>| (if not already subscribed)
| |
|<-- callEvent: RINGING_INCOMING ------| (includes callId, device names)
| |
|-- acceptCall(callId) --------------->|
|<-- {callId, state, -|
| inputDeviceName, |
| outputDeviceName} |
| |
|<-- callEvent: CONNECTING ------------|
|<-- callEvent: CONNECTED -------------|
| |
| connect to audio devices |
| (via platform audio APIs) |
To stop receiving call events, call unsubscribeCallEvents.
State Machine
Call states as seen by JSON-RPC clients:
startCall()
|
v
+----- RINGING_OUTGOING ----+ RINGING_INCOMING -----+
| | | | |
| (timeout | (answered) | (rejected) | acceptCall() | (timeout
| ~60s) | | | | ~60s)
v v v v v
ENDED CONNECTED ENDED CONNECTING ENDED
| |
| v
| CONNECTED
| |
| (hangup/error) | (hangup/error)
v v
ENDED ENDED
For outgoing calls, CONNECTED fires directly when the tunnel reports
Connected state -- there is no intermediate CONNECTING event.
For incoming calls, CONNECTING is set by Java when the user calls
acceptCall(), before the tunnel completes ICE negotiation.
Both directions have a 60-second ring timeout.
Reconnection (ICE restart):
CONNECTED --> RECONNECTING --> CONNECTED (ICE restart succeeded)
|
v
ENDED (ICE restart failed)
RECONNECTING maps from the tunnel's Connecting state, which is emitted
during ICE restarts (not during initial connection).
CallManager.java
lib/src/main/java/org/asamk/signal/manager/helper/CallManager.java
Manages the call lifecycle from the Java side:
- Spawns
signal-call-tunneland writes config JSON to stdin - Keeps stdin open as the control write channel; reads stdout for control events
- Captures stderr for tunnel logging
- Parses
inputDeviceNameandoutputDeviceNamefrom the tunnel'sreadymessage and includes them inCallInfo - Translates tunnel state changes into
CallInfo.Statevalues and firescallEventJSON-RPC notifications to connected clients - Defers the
acceptmessage for incoming calls until the tunnel reportsRingingstate (sending earlier causes the tunnel to drop it) - Schedules a 60-second ring timeout for both incoming and outgoing calls
- On hangup: sends hangup message, closes stdin, and destroys the process
Implementation Notes
Peer ID consistency
The peerId field in createOutgoingCall and receivedOffer must be the actual
remote peer UUID (e.g., senderAddress.toString()). The tunnel rejects ICE
candidates if the peer ID doesn't match across calls, causing "Ignoring
peer-reflexive ICE candidate because the ufrag is unknown."
sendHangup semantics
sendHangup from the tunnel is a request to send a hangup message via Signal
protocol. It is not a local state change -- local state transitions come
exclusively from stateChange events. For single-device clients, ignore
AcceptedOnAnotherDevice, DeclinedOnAnotherDevice, and
BusyOnAnotherDevice hangup types in the hangupType field -- sending these to
the remote peer causes it to terminate the call prematurely.
Call ID serialization
Call IDs can exceed Long.MAX_VALUE in Java. Use Long.toUnsignedString() when
serializing to JSON for the tunnel (which expects unsigned 64-bit integers). In
the config JSON, call_id should also use unsigned representation.
Incoming hangup filtering
When receiving hangup messages via Signal protocol, only honor NORMAL type
hangups. ACCEPTED, DECLINED, and BUSY types are multi-device coordination
messages and should be ignored by single-device clients.
JSON-RPC call ID types
JSON-RPC clients may send call IDs as various numeric types (Long, BigInteger,
Integer). Use Number.longValue() rather than direct casting when extracting
call IDs from JSON-RPC parameters.
Identity key format
Identity keys in senderIdentityKey and receiverIdentityKey must be raw
32-byte Curve25519 public keys (without the 0x05 DJB type prefix). If the
33-byte serialized form is used instead, SRTP key derivation produces different
keys on each side, causing authentication failures.