Ctrl K
ComunidadGuías…

webrtc_rx_audio.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env python3
"""
webrtc_rx_audio.py — sends RX audio (radio -> browser) over WebRTC (aiortc).
The RX audio path (2026-08-24 onward), replacing the direct
browser<->ham_audio.exe WebSocket/Opus RX path. Reported live over LTE: RX
audio and control both degraded together under any competing network
traffic, worst on WS - classic TCP head-of-line blocking, where one
delayed/lost segment stalls everything queued behind it on the same
connection, regardless of how unrelated that data is. TX mic audio already
went over WebRTC (webrtc_audio.py, aiortc/UDP) and didn't have this problem;
this brings RX to the same transport so a lost packet is a small glitch
instead of a multi-second stall - live-confirmed clearly smoother with 1
listener than the old WS path.

Audio source: the ALREADY-RUNNING AudioStream._rx_loop PCM capture
(audio_stream.py) that normally feeds only the CW decoder (DeepCW) and the
local waterfall preview - subscribe_rx_pcm() taps the same physical capture
instead of opening a second one.

Direction is the reverse of webrtc_audio.py: here the SERVER has the media
to send, so the SERVER creates the offer (browser answers) - opposite of TX,
where the browser (with the mic) offers and the server answers.

Signaling goes over the existing WebSocket (/ws), message types
webrtc_rx_start/webrtc_rx_answer/webrtc_rx_ice/webrtc_rx_stop - deliberately
separate names from webrtc_offer/answer/ice/stop (TX) to avoid any ambiguity
about which direction a given message is for.

Multiple clients can each have their own WebRTCAudioSender at once (RX
listening isn't exclusive like TX) - one instance per connected client,
tracked in App._webrtc_rx_senders keyed by the ws. Each listener is a
separate aiortc connection/track (independent Opus encode per listener,
unlike ham_audio.exe's Rust path which encodes once and fans out the same
bytes to every WS client) - see App._log_rx_webrtc_listeners for the
listener-count/CPU log line used to measure how this scales in practice.
"""
import asyncio, fractions
from aiortc import RTCPeerConnection, RTCSessionDescription, RTCConfiguration, RTCIceServer
from aiortc.mediastreams import MediaStreamTrack
from webrtc_audio import _stun_url, add_ice_candidate_to_pc

OPUS_RATE = 48000


class _RxAudioTrack(MediaStreamTrack):
    """Pulls PCM chunks from an AudioStream subscriber queue and yields them
    as av.AudioFrame - aiortc encodes to Opus/RTP internally. Paced by the
    real hardware capture rate on the OTHER end of the queue (each chunk is
    ~20ms of real audio, produced roughly every 20ms by _rx_loop's blocking
    PyAudio read) - no manual sleep/clock-tracking needed here, awaiting the
    queue already blocks for the right amount of time."""
    kind = "audio"

    def __init__(self, audio_stream, loop):
        super().__init__()
        self._audio_stream = audio_stream
        self._queue = audio_stream.subscribe_rx_pcm(loop)
        self._rate = getattr(audio_stream, "_rx_rate", None) or OPUS_RATE
        self._timestamp = 0
        self._recv_count = 0

    async def recv(self):
        # DIAGNOSTIC (2026-08-24): reported live - the connection reaches
        # ICE 'completed'/state 'connected', real audio is flowing
        # server-side (_rx_loop's own frame counter proves that, but NOT
        # that THIS track's queue is actually being drained), then closes
        # on its own after ~20-30s with no 'failed'/'disconnected' state in
        # between - meaning something OTHER than the connectionstatechange
        # handler is closing it. If recv() itself raises, aiortc's RTP
        # send loop swallows it silently and tears the connection down -
        # this print is the only way to see that instead of guessing.
        #
        # pts: sample-count based, NOT wall-clock. A wall-clock version was
        # tried (2026-08-24) on the theory that _safe_put_nowait
        # (audio_stream.py) dropping chunks under load was desyncing a
        # naive running counter - it turned out NOT to be the cause of the
        # real bug that day (a client-side createMediaStreamSource issue,
        # fixed in ws.js) and wall-clock pts on its own measurably made
        # playback choppier: real recv() scheduling jitter (asyncio/OS,
        # a few ms here and there) leaked directly into frame spacing,
        # where Opus/RTP expect each chunk to represent an exact, uniform
        # duration. Sample-count keeps that duration exact regardless of
        # when recv() actually got scheduled - a genuinely dropped chunk
        # just becomes a small silent gap, not a source of constant jitter.
        try:
            import av
            pcm = await self._queue.get()
            frame = av.AudioFrame(format="s16", layout="mono", samples=len(pcm) // 2)
            frame.planes[0].update(pcm)
            frame.sample_rate = self._rate
            frame.pts = self._timestamp
            frame.time_base = fractions.Fraction(1, self._rate)
            self._timestamp += len(pcm) // 2
            self._recv_count += 1
            if self._recv_count % 250 == 0:
                print(f"[webrtc-rx] track recv() #{self._recv_count} OK", flush=True)
            return frame
        except Exception as e:
            import traceback
            print(f"[webrtc-rx] track recv() FAILED after {self._recv_count} frames: {e}", flush=True)
            traceback.print_exc()
            raise

    def stop(self):
        print(f"[webrtc-rx] track stop() called after {self._recv_count} recv() calls", flush=True)
        self._audio_stream.unsubscribe_rx_pcm(self._queue)
        super().stop()


class WebRTCAudioSender:
    """One instance per client that opts into WebRTC RX audio. Server-side
    offer/answer flow (mirrors WebRTCAudioReceiver's structure, opposite
    roles)."""

    def __init__(self, audio_stream, on_failed=None):
        self._audio_stream = audio_stream
        self._pc: RTCPeerConnection | None = None
        self._track: _RxAudioTrack | None = None
        # Optional callback invoked once when the connection reaches
        # 'failed' on its own (real WebRTC-level death - ICE consent
        # checks decided the peer is gone), not on every close(). Lets the
        # app-level tracking dict (App._webrtc_rx_senders) remove this
        # entry immediately instead of leaving an inert, closed sender
        # sitting there until the client's own reconnect happens to
        # overwrite it - harmless (no growing resource use, just a
        # temporarily inflated listener count in the capacity log), but
        # cheap to keep accurate.
        self._on_failed = on_failed

    async def create_offer(self) -> dict:
        """Start a fresh connection (closing any previous one for this
        client) and return an SDP offer for the browser to answer."""
        await self.close()

        config = RTCConfiguration(iceServers=[RTCIceServer(urls=await _stun_url())])
        pc = RTCPeerConnection(configuration=config)
        self._pc = pc

        loop = asyncio.get_running_loop()
        track = _RxAudioTrack(self._audio_stream, loop)
        self._track = track
        pc.addTrack(track)

        @pc.on("connectionstatechange")
        async def on_state_change():
            print(f"[webrtc-rx] Connection state: {pc.connectionState}")
            # 'disconnected' is DELIBERATELY not included here - per the
            # WebRTC spec it's a transient state (the ICE transport is
            # actively trying to recover, e.g. after a brief real-world
            # network hiccup) that often self-heals within a second or two
            # without any action needed. Treating it the same as 'failed'
            # (a decisive, unrecoverable ICE failure) tore down and forced
            # a full renegotiation on every minor blip - reported live as
            # "connects, then dies almost immediately, repeatedly" over a
            # real internet path (DuckDNS tunnel, not localhost/LAN).
            if pc.connectionState == "failed":
                await self.close()
                if self._on_failed:
                    self._on_failed()

        @pc.on("iceconnectionstatechange")
        async def on_ice_state_change():
            # Separate from connectionstatechange above - ICE state alone
            # narrows down whether a future "failed" is a real NAT/STUN
            # traversal problem (ICE itself never reaches "completed") vs.
            # something else entirely (ICE fine, DTLS or media path issue).
            print(f"[webrtc-rx] ICE state: {pc.iceConnectionState}")

        offer = await pc.createOffer()
        await pc.setLocalDescription(offer)

        # Wait for ICE gathering to finish (max 3s) so the offer carries all
        # candidates - same non-trickle-on-the-way-out approach as the TX
        # answer path in webrtc_audio.py, kept for consistency/simplicity.
        for _ in range(30):
            if pc.iceGatheringState == "complete":
                break
            await asyncio.sleep(0.1)

        return {"sdp": pc.localDescription.sdp, "type": pc.localDescription.type}

    async def set_answer(self, sdp: str, type_: str = "answer"):
        if not self._pc:
            return
        answer = RTCSessionDescription(sdp=sdp, type=type_)
        await self._pc.setRemoteDescription(answer)

    async def add_ice_candidate(self, candidate: dict):
        if not self._pc:
            return
        await add_ice_candidate_to_pc(self._pc, candidate)

    async def close(self):
        # DIAGNOSTIC (2026-08-24): logs WHO called close() (a short stack
        # summary) - see the matching comment on _RxAudioTrack.recv(). If
        # this fires from somewhere OTHER than "webrtc_rx_start" (a fresh
        # connection replacing an old one) or "failed" in
        # on_state_change, that's direct evidence of a third, not-yet-
        # found trigger closing otherwise-healthy connections.
        if self._pc:
            import traceback
            caller = traceback.extract_stack()[-2]
            print(f"[webrtc-rx] close() called from {caller.name} ({caller.filename}:{caller.lineno})", flush=True)
        if self._track:
            self._track.stop()
            self._track = None
        if self._pc:
            try:
                await self._pc.close()
            except Exception:
                pass
            self._pc = None