Hi! Following up from our email exchange, as you suggested — moving it here so it's public and searchable.
TL;DR. I bought the audio model, installed it on a Comelit audio-only building intercom, and traced it.
Three things are now established: (1) Ring's signaling accepts an audio-only live_view for
intercom_handset_audio and answers sendrecv to a sendrecv offer; (2) media really flows in
the receive direction; (3) we really do transmit — thousands of RTP packets with real payload,
measured with pc.getStats(). What does not work: nobody at the door station hears us. Meanwhile
the official Ring app talks two-way on the exact same installation, so the path
Ring → Comelit → outdoor speaker exists and works — the gap is in our client. Below are the logs, the
things I've ruled out, and the one hypothesis I'm left with (Opus frame duration, 20 ms vs 60 ms).
Setup
- Device: Ring Intercom (audio model), kind
intercom_handset_audio, on a Comelit Mini 2738W/A
(Simplebus2, audio-only building intercom). Installed and working in the Ring app (talk + door open).
- Test rig: standalone Python script,
python-ring-doorbell 0.9.14 + aiortc 1.9.0 + av 12.3.0.
Not Home Assistant — deliberately, to isolate the question.
- Physical state during all tests: handset and its plastic shell detached from the wall base (this is
the state in which the official app works, so I kept it identical for every A/B).
What I changed vs. upstream: exactly one field
I did not rewrite RingWebRtcStream. I wrapped the websocket and rewrote a single field on its
way out, so that the trace proves the existing code path works as-is:
# upstream ring_doorbell/webrtcstream.py hardcodes:
# "stream_options": {"audio_enabled": True, "video_enabled": True}
if msg.get("method") == "live_view":
msg["body"]["stream_options"] = {"audio_enabled": True, "video_enabled": False}
The resulting live_view message (redacted):
{
"method": "live_view",
"dialog_id": "<DIALOG_ID>",
"body": {
"doorbot_id": "<DEVICE_ID>",
"stream_options": { "audio_enabled": true, "video_enabled": false },
"sdp": "<offer>",
"type": "offer"
}
}
Everything else — ticket endpoint, websocket, message handling — is stock python-ring-doorbell.
Result 1 — recvonly offer → sendonly answer
Ring accepts the session and streams audio. Answer contains no m=video line at all, so
video_enabled: false is honoured:
v=0
o=- 3993650225 3993650225 IN IP4 <IP>
s=-
t=0 0
a=group:BUNDLE 0
m=audio 9 UDP/TLS/RTP/SAVPF 96
c=IN IP4 <IP>
a=sendonly
a=mid:0
a=rtcp-mux
a=setup:active
a=fingerprint:sha-256 <FINGERPRINT>
a=ice-ufrag:<UFRAG>
a=ice-pwd:<PWD>
a=candidate:1 1 UDP 2013266431 <IPv6> 40942 typ host
a=candidate:3 1 TCP 1010828799 <IPv6> 443 typ host tcptype passive
a=candidate:4 1 UDP 2013266430 <IPv6> 35516 typ host
a=candidate:7 1 UDP 2013266429 <IP> 49277 typ host
a=candidate:9 1 TCP 1010828031 <IP> 443 typ host tcptype passive
a=rtpmap:96 OPUS/48000/2
a=fmtp:96 useinbandfec=0
a=ssrc:2775838177 msid:<...>
a=ssrc:2775838177 cname:<...>
ICE completes and media really flows — not just signaling. I recorded 15.1 s to WAV:
RMS 57, peak 1879 (i.e. not digital silence — ambient noise from the outdoor unit's mic).
During a real call the downlink is loud and unmistakable (RMS up to ~3974 with clipping: the call tone
and the visitor's voice). The receive direction is a solved problem.
Result 2 — sendrecv offer → sendrecv answer
Offer (aiortc, abridged):
m=audio 59458 UDP/TLS/RTP/SAVPF 96 0 8
a=sendrecv
a=mid:0
a=rtpmap:96 opus/48000/2
a=rtpmap:0 PCMU/8000
a=rtpmap:8 PCMA/8000
Answer:
m=audio 9 UDP/TLS/RTP/SAVPF 96
a=sendrecv
a=mid:0
a=rtcp-mux
a=rtpmap:96 OPUS/48000/2
a=fmtp:96 useinbandfec=0
Ring did not downgrade it to sendonly — which it demonstrably does when the offer is recvonly
(Result 1). So at the SDP level the backend is willing to receive an upstream audio track from us.
Side observation: in the sendrecv session the received audio was ~10x hotter than in the recvonly
one (RMS 547 / peak 26104 vs 57 / 1879), consistent with the device switching into an actual talk mode.
Note that neither side ever negotiates ptime / maxptime. This turns out to matter — see below.
Result 3 — we really do transmit (getStats)
This is the part that changed my mind about where the problem is. In the first physical test I concluded
"it doesn't work" purely from not hearing anything, without ever measuring the sender. I added
pc.getStats() at the end of the session (outbound-rtp: packetsSent / bytesSent):
| session |
encoder frame |
frames produced |
RTP packets sent |
bytes sent |
≈ bytes/packet |
| 2026-07-24 17:16 |
20 ms |
848 |
848 |
162 534 |
~192 |
| 2026-07-24 17:29 |
20 ms |
8819 |
8819 |
1 698 376 |
~192 |
| 2026-07-24 18:02 |
60 ms |
3879 |
1292 |
751 051 |
~581 |
| 2026-07-26 12:21 |
60 ms |
6399 |
2133 |
1 229 974 |
~577 |
For reference, aiortc encoding digital silence produces ~3-byte payloads. ~192 bytes at 20 ms
is a normal Opus voice payload, so there is genuine audio inside the packets and they leave the machine.
"It's a bug on our side, nothing is being sent" is ruled out.
Result 4 — the physical test at the door station: nothing is heard
Test method: send a loud, unmistakable signal upstream during a real call (someone rings from the
outdoor panel, the Comelit bridges the audio path, ~1 min before it times out) and have a human stand at
the outdoor panel and listen.
- 8 sessions with a 1 kHz beep (0.4 s on / 0.8 s off), listener at the outdoor panel → nothing heard.
- Repeated with
number.*volume_della_voce and *volume_del_microfono (Ring's own volume entities)
raised from 7/11 and 5/11 to 11/11 → no change. "Volume too low" is ruled out.
- Same physical state, official Ring app: two-way talk works, low latency, clearly audible at the
outdoor panel. So the device, the bus and the outdoor speaker are all fine.
- Still pending: the same listening test with the 60 ms encoder patch and a spoken-voice clip
(a 1 kHz tone is easy to confuse with the intercom's own tones). That's the next thing I'll run.
A methodological warning for anyone trying this remotely
Before doing the physical test I tried to detect my own tone in the downlink (Goertzel /
autocorrelation on the received WAV), hoping acoustic re-entry would prove the uplink was live.
That method is blind by construction: the Ring's AEC is specifically designed to remove our signal
from the return path. Control: in the same recording the analysis happily locks onto a periodic source at
4.90 s (the intercom's own call tone) but finds zero at my clip's 4.13 s period. That is equally
consistent with "it doesn't come out" and "it comes out but AEC suppresses it" — it decides nothing.
Only a human ear at the door station is a valid judge.
The message sequence
us --> live_view
<-- session_created
<-- sdp
us --> activate_session
<-- notification
us --> camera_options
<-- camera_started <-- on a device with no camera
us --> ping
<-- pong
Note camera_started coming back from a camera-less device: further evidence the signaling path is
generic and not video-specific, which matches what you said over email.
⚠️ Gotcha for anyone reproducing this: 60 ms Opus frames (both directions)
Decoder side. The audio Intercom sends 2880-sample (60 ms) Opus frames. aiortc hardcodes 20 ms:
# aiortc/codecs/opus.py
SAMPLES_PER_FRAME = 960
...
length = lib.opus_decode(..., SAMPLES_PER_FRAME, 0)
assert length == SAMPLES_PER_FRAME # <-- fires on every frame
The assertion kills the audio-decoder thread, so the session looks established but nothing is ever
decoded and no audio is recorded — with no obvious error unless you're watching stderr. I work around
it by decoding into a 120 ms buffer and trimming to the returned length.
Encoder side — my current hypothesis for the silent uplink. Since Ring sends 60 ms frames, its
embedded decoder plausibly expects 60 ms too, and since no ptime is negotiated in either SDP,
nothing aligns us: we fire 20 ms frames and they may simply be dropped. So I wrote the symmetric patch
(nobody seems to have touched the encoder side before) — an AudioResampler with frame_size = 2880
feeding opus_encode:
samples = 2880 # 60 ms @ 48 kHz, a valid Opus frame duration
self.resampler = AudioResampler(format="s16", layout="stereo", rate=48000, frame_size=samples)
Verified in the wild: packets go from ~192 B to ~577-581 B with a 3:1 frames-to-packets ratio, and
Ring accepts them without tearing down the session (see the table above). Whether that's the cure is
exactly what the pending listening test will tell.
What is ruled out, what is left
Ruled out: nothing is transmitted · volume too low · SDP-level refusal · a broken device/bus/outdoor
speaker (the official app works on the same install, same physical state).
Left standing, in order of how much I believe them:
- Opus frame duration mismatch (20 ms vs 60 ms), untested by ear so far;
- the backend accepts an upstream track for
intercom_handset_audio but doesn't route it to the
bus — i.e. the app uses a different mechanism for this kind;
- some extra signaling step the app performs that we don't (an explicit "talk"/mic-enable message?).
If it turns out to be (2) or (3), my next move is to intercept the official app's signaling and diff it
against ours — but before spending that effort I'd rather ask you, since you already have a working
two-way client on the other kind.
Questions for you
- On
intercom_handset_video, when push-to-talk works in your card, what Opus frame size / ptime does
the browser end up using upstream? If plain 20 ms browser Opus is audible on the video model, my
60 ms theory is probably wrong and the difference lives in the backend for the audio kind. This single
answer would save me a lot of guessing.
- Did you ever need any extra websocket message to make the uplink actually audible (something like
a talk / mic-enable / camera_options variant), or is live_view + activate_session enough?
- Have you ever captured the official app's signaling? Any known asymmetry between the two intercom
kinds in what the backend does with the upstream track?
- Does anything in the answers you get (e.g.
rms_version, session fields) differ between kinds in a way
you've had to special-case?
- Your offer from our email — developing the audio-only two-way mode with me testing on real hardware —
still stands from my side. I'm happy to run dev builds, capture traces, and test anything you need on
the audio model. HTTPS on my HA instance is on my list as a prerequisite for browser mic access.
Full redacted traces (SDP + websocket transcripts + the TX: stats lines) are attached; raw captures stay
on my machine since they contain my public IP and session identifiers. Happy to trace anything else while
the rig is set up.
Hi! Following up from our email exchange, as you suggested — moving it here so it's public and searchable.
TL;DR. I bought the audio model, installed it on a Comelit audio-only building intercom, and traced it.
Three things are now established: (1) Ring's signaling accepts an audio-only
live_viewforintercom_handset_audioand answerssendrecvto asendrecvoffer; (2) media really flows inthe receive direction; (3) we really do transmit — thousands of RTP packets with real payload,
measured with
pc.getStats(). What does not work: nobody at the door station hears us. Meanwhilethe official Ring app talks two-way on the exact same installation, so the path
Ring → Comelit → outdoor speaker exists and works — the gap is in our client. Below are the logs, the
things I've ruled out, and the one hypothesis I'm left with (Opus frame duration, 20 ms vs 60 ms).
Setup
intercom_handset_audio, on a Comelit Mini 2738W/A(Simplebus2, audio-only building intercom). Installed and working in the Ring app (talk + door open).
python-ring-doorbell0.9.14 +aiortc1.9.0 +av12.3.0.Not Home Assistant — deliberately, to isolate the question.
the state in which the official app works, so I kept it identical for every A/B).
What I changed vs. upstream: exactly one field
I did not rewrite
RingWebRtcStream. I wrapped the websocket and rewrote a single field on itsway out, so that the trace proves the existing code path works as-is:
The resulting
live_viewmessage (redacted):{ "method": "live_view", "dialog_id": "<DIALOG_ID>", "body": { "doorbot_id": "<DEVICE_ID>", "stream_options": { "audio_enabled": true, "video_enabled": false }, "sdp": "<offer>", "type": "offer" } }Everything else — ticket endpoint, websocket, message handling — is stock
python-ring-doorbell.Result 1 —
recvonlyoffer →sendonlyanswerRing accepts the session and streams audio. Answer contains no
m=videoline at all, sovideo_enabled: falseis honoured:ICE completes and media really flows — not just signaling. I recorded 15.1 s to WAV:
RMS 57, peak 1879 (i.e. not digital silence — ambient noise from the outdoor unit's mic).
During a real call the downlink is loud and unmistakable (RMS up to ~3974 with clipping: the call tone
and the visitor's voice). The receive direction is a solved problem.
Result 2 —
sendrecvoffer →sendrecvanswerOffer (aiortc, abridged):
Answer:
Ring did not downgrade it to
sendonly— which it demonstrably does when the offer isrecvonly(Result 1). So at the SDP level the backend is willing to receive an upstream audio track from us.
Side observation: in the
sendrecvsession the received audio was ~10x hotter than in therecvonlyone (RMS 547 / peak 26104 vs 57 / 1879), consistent with the device switching into an actual talk mode.
Note that neither side ever negotiates
ptime/maxptime. This turns out to matter — see below.Result 3 — we really do transmit (
getStats)This is the part that changed my mind about where the problem is. In the first physical test I concluded
"it doesn't work" purely from not hearing anything, without ever measuring the sender. I added
pc.getStats()at the end of the session (outbound-rtp: packetsSent / bytesSent):For reference,
aiortcencoding digital silence produces ~3-byte payloads. ~192 bytes at 20 msis a normal Opus voice payload, so there is genuine audio inside the packets and they leave the machine.
"It's a bug on our side, nothing is being sent" is ruled out.
Result 4 — the physical test at the door station: nothing is heard
Test method: send a loud, unmistakable signal upstream during a real call (someone rings from the
outdoor panel, the Comelit bridges the audio path, ~1 min before it times out) and have a human stand at
the outdoor panel and listen.
number.*volume_della_voceand*volume_del_microfono(Ring's own volume entities)raised from 7/11 and 5/11 to 11/11 → no change. "Volume too low" is ruled out.
outdoor panel. So the device, the bus and the outdoor speaker are all fine.
(a 1 kHz tone is easy to confuse with the intercom's own tones). That's the next thing I'll run.
A methodological warning for anyone trying this remotely
Before doing the physical test I tried to detect my own tone in the downlink (Goertzel /
autocorrelation on the received WAV), hoping acoustic re-entry would prove the uplink was live.
That method is blind by construction: the Ring's AEC is specifically designed to remove our signal
from the return path. Control: in the same recording the analysis happily locks onto a periodic source at
4.90 s (the intercom's own call tone) but finds zero at my clip's 4.13 s period. That is equally
consistent with "it doesn't come out" and "it comes out but AEC suppresses it" — it decides nothing.
Only a human ear at the door station is a valid judge.
The message sequence
Note
camera_startedcoming back from a camera-less device: further evidence the signaling path isgeneric and not video-specific, which matches what you said over email.
Decoder side. The audio Intercom sends 2880-sample (60 ms) Opus frames.
aiortchardcodes 20 ms:The assertion kills the
audio-decoderthread, so the session looks established but nothing is everdecoded and no audio is recorded — with no obvious error unless you're watching stderr. I work around
it by decoding into a 120 ms buffer and trimming to the returned length.
Encoder side — my current hypothesis for the silent uplink. Since Ring sends 60 ms frames, its
embedded decoder plausibly expects 60 ms too, and since no
ptimeis negotiated in either SDP,nothing aligns us: we fire 20 ms frames and they may simply be dropped. So I wrote the symmetric patch
(nobody seems to have touched the encoder side before) — an
AudioResamplerwithframe_size = 2880feeding
opus_encode:Verified in the wild: packets go from ~192 B to ~577-581 B with a 3:1 frames-to-packets ratio, and
Ring accepts them without tearing down the session (see the table above). Whether that's the cure is
exactly what the pending listening test will tell.
What is ruled out, what is left
Ruled out: nothing is transmitted · volume too low · SDP-level refusal · a broken device/bus/outdoor
speaker (the official app works on the same install, same physical state).
Left standing, in order of how much I believe them:
intercom_handset_audiobut doesn't route it to thebus — i.e. the app uses a different mechanism for this kind;
If it turns out to be (2) or (3), my next move is to intercept the official app's signaling and diff it
against ours — but before spending that effort I'd rather ask you, since you already have a working
two-way client on the other kind.
Questions for you
intercom_handset_video, when push-to-talk works in your card, what Opus frame size / ptime doesthe browser end up using upstream? If plain 20 ms browser Opus is audible on the video model, my
60 ms theory is probably wrong and the difference lives in the backend for the audio kind. This single
answer would save me a lot of guessing.
a talk / mic-enable /
camera_optionsvariant), or islive_view+activate_sessionenough?kinds in what the backend does with the upstream track?
rms_version, session fields) differ between kinds in a wayyou've had to special-case?
still stands from my side. I'm happy to run dev builds, capture traces, and test anything you need on
the audio model. HTTPS on my HA instance is on my list as a prerequisite for browser mic access.
Full redacted traces (SDP + websocket transcripts + the
TX:stats lines) are attached; raw captures stayon my machine since they contain my public IP and session identifiers. Happy to trace anything else while
the rig is set up.