Skip to content

Audio Streaming

Audio RX/TX via the Icom audio UDP port (default 50003).

Naming Map

Low-level Opus methods are now explicitly suffixed with _opus. High-level PCM APIs are available for both RX and TX.

Scope Preferred method names
Low-level Opus (current) start_audio_rx_opus, stop_audio_rx_opus, start_audio_tx_opus, push_audio_tx_opus, stop_audio_tx_opus, start_audio_opus, stop_audio_opus
High-level PCM start_audio_rx_pcm, stop_audio_rx_pcm, start_audio_tx_pcm, push_audio_tx_pcm, stop_audio_tx_pcm

Deprecated aliases still work during the deprecation window (two minor releases): start_audio_rx, stop_audio_rx, start_audio_tx, push_audio_tx, stop_audio_tx, start_audio, stop_audio.

AudioStream

rigplane.audio.lan_stream.AudioStream

Manages audio RX/TX on the Icom audio UDP port.

Uses an :class:IcomTransport for the underlying UDP communication (discovery, pings, retransmit). Audio-specific packet framing is handled here.

Parameters:

Name Type Description Default
transport IcomTransport

Connected IcomTransport for the audio port.

required

Example::

stream = AudioStream(audio_transport)
await stream.start_rx(my_callback)
# ... later
await stream.stop_rx()

state property

Current audio stream state.

transport property

Underlying transport.

add_rx_tap(callback)

Add an additional RX listener (tap) that receives all audio packets.

get_audio_stats()

Return runtime audio stats for the current stream.

Metrics and units:

  • rx_packets_received / rx_packets_delivered / tx_packets_sent: packet counters (>= 0).
  • packets_lost: inferred missing RX packets (>= 0).
  • packet_loss_percent: percentage in [0.0, 100.0].
  • reorder_depth_ema_ms / jitter_max_ms: reorder-depth EMA and peak deviation estimates in ms (>= 0.0). Despite the legacy jitter_max_ms name, both are reorder-depth metrics, not RFC 3550 jitter.
  • underrun_count / overrun_count: jitter-buffer event counters (>= 0).
  • estimated_latency_ms: current buffering latency estimate in ms (>= 0.0).
  • jitter_buffer_depth_packets / jitter_buffer_pending_packets: packet counts (>= 0).

push_tx(audio_data) async

Send an audio frame to the radio.

Large payloads (e.g. raw PCM) are automatically chunked to fit the IC-7610 maximum audio payload size (1364 bytes per UDP packet), matching the wfview chunking behaviour.

Parameters:

Name Type Description Default
audio_data bytes

Payload encoded with the negotiated TX audio codec.

required

Raises:

Type Description
AudioNotStartedError

If not in transmitting state.

remove_rx_tap(callback)

Remove an RX tap.

start_rx(callback, *, jitter_depth=None) async

Start receiving audio from the radio.

Parameters:

Name Type Description Default
callback Callable[[AudioPacket | None], None]

Called with each decoded :class:AudioPacket. When jitter buffering is enabled, None may be passed for gap placeholders (missing packets).

required
jitter_depth int | None

Override jitter buffer depth (0 to disable). Defaults to the value set at construction time.

None

Raises:

Type Description
AudioAlreadyStartedError

If already receiving or transmitting.

start_tx() async

Start transmitting audio to the radio.

Can be called while already receiving (full-duplex).

Raises:

Type Description
AudioAlreadyStartedError

If already transmitting.

stop_rx() async

Stop receiving audio and flush remaining buffered packets.

stop_tx() async

Stop transmitting audio.

If RX is still active, state reverts to RECEIVING.

Runtime Audio Stats (get_audio_stats)

Use get_audio_stats() on AudioStream or on the Radio (from create_radio) to retrieve a JSON-friendly snapshot of live stream quality metrics.

stats = radio.get_audio_stats()
print(stats["packet_loss_percent"], stats["reorder_depth_ema_ms"])

Metrics, Units, Bounds

Field Unit Bounds Notes
active boolean true/false Whether stream state is not idle
state string idle / receiving / transmitting Current stream state
rx_packets_received packets >= 0 Parsed RX audio packets
rx_packets_delivered packets >= 0 RX packets delivered to callback
tx_packets_sent packets >= 0 TX packets sent
packets_lost packets >= 0 Inferred missing RX packets
packet_loss_percent percent 0.0..100.0 packets_lost / (delivered + lost)
reorder_depth_ema_ms milliseconds >= 0.0 EMA of reorder depth (not RFC 3550 jitter)
jitter_max_ms milliseconds >= 0.0 Peak observed reorder-depth deviation
underrun_count events >= 0 Jitter-buffer underrun events
overrun_count events >= 0 Jitter-buffer overrun events
estimated_latency_ms milliseconds >= 0.0 Estimated buffering delay
jitter_buffer_depth_packets packets >= 0 Configured jitter depth (0 when disabled)
jitter_buffer_pending_packets packets >= 0 Currently buffered packets
duplicates_dropped packets >= 0 Duplicate RX packets dropped
stale_packets_dropped packets >= 0 Stale/old RX packets dropped
out_of_order_packets packets >= 0 RX packets observed out of sequence

Capture Health And Bridge Metrics

PortAudio-backed capture and the PCM TX bridge expose a second set of metrics that answer a different question from get_audio_stats(): whether the local OS audio callback and the bridge TX path are keeping up.

Capture callback health (RxStreamHealth)

RxStreamHealth snapshots input-side callback delivery for RX-only and full-duplex capture streams.

Field Unit Meaning Typical next step
frames_delivered frames PCM frames successfully handed to the bridge/callback Confirms the callback is running at all
input_overflow_events events PortAudio reported captured input was dropped because the process/backend could not keep up Check host CPU pressure, device/driver stability, and callback cadence before blaming RigPlane TX
input_underflow_events events PortAudio reported the input callback arrived without enough fresh captured samples Check capture device/driver health and OS scheduling; this is still capture-side, not radio TX failure
callback_errors events Callback-level errors while processing capture frames Inspect logs for the paired exception or status context
callback_status_flags map Per-flag totals such as input_overflow / input_underflow Use the exact flag mix to separate capture starvation from other failures

Bridge TX path health (BridgeMetrics)

These counters are surfaced on AudioBridge.metrics, AudioBridge.stats, and runtime bridge-status consumers such as the Web server's audio_bridge_stats.

Field Unit Meaning Not the same as
capture_input_overflows events Cumulative RxStreamHealth.input_overflow_events observed by the active bridge capture stream tx_overruns queue drops
capture_input_underflows events Cumulative RxStreamHealth.input_underflow_events observed by the active bridge capture stream TX playback underruns or radio write failures
capture_callback_status_flags map Bridge-side rollup of capture callback flags, for example {"input_overflow": 3} Silence gating decisions
tx_silence_suppressed frames Frames intentionally skipped because captured PCM stayed below the bridge silence/noise-gate threshold Capture overrun or lost OS buffers
tx_overruns events Bridge TX queue drops: RigPlane evicted stale queued frames to preserve bounded latency before push_audio_tx_pcm() PortAudio callback overflow

TX playback write health (TxStreamHealth)

These counters live on the writable PortAudio TX stream itself and describe playback-side queue pressure after audio has already left the bridge queue.

Field Unit Meaning Not the same as
frames_queued frames Total frames accepted from the producer/write side Callback playback completions
enqueued_audio_ms milliseconds Total audio duration accepted from producer writes Current live queue depth
buffered_audio_ms milliseconds Current queued playback audio still buffered in the writable stream Cumulative accepted or consumed audio
frames_dropped frames TX playback frames dropped by the writable stream while preserving bounded latency Silence gating or radio write failure
dropped_audio_ms milliseconds Total duration represented by dropped playback frames Callback underruns
enqueue_overrun_events events TX playback queue overflow events on the writable stream BridgeMetrics.tx_overruns bridge queue drops
enqueue_overrun_audio_ms milliseconds Total playback-queue audio duration dropped during TX stream overflow handling Bridge capture callback overflow
write_attempts callbacks Total playback callback attempts / output fill cycles Producer write() calls
writes_completed callbacks Playback callbacks that completed their output fill path Producer enqueue success count
write_failures events Playback callback failures or fake-stream write failures Queue overflow or underrun counters
callback_consumed_audio_ms milliseconds Audio duration actually consumed from the queued ring by the playback callback Silence padded during underrun
callback_output_audio_ms milliseconds Total output duration the callback filled, including silence on underrun Audio duration consumed from the queue
callback_underrun_events events Playback callbacks that had to pad with silence because queued audio ran short Bridge queue overflow
callback_underrun_audio_ms milliseconds Total silence-padded output duration caused by playback underruns Queue drops on producer overflow
callback_calls_per_sec_ewma callbacks/sec Smoothed callback cadence estimate for the active PortAudio stream Producer write rate
callback_errors events Callback-level errors while filling playback output Bridge capture callback errors
callback_status_flags map Per-flag totals such as output_underflow from PortAudio status callbacks Bridge metrics or radio send errors
last_error string or null Most recent callback/write failure summary Callback status flag counts

Canonical producer/callback names above are the stable contract. Legacy aliases remain available on TxStreamHealth attributes and in TxStreamHealth.to_dict() for compatibility during the deprecation window:

  • queued_audio_ms -> enqueued_audio_ms
  • consumed_audio_ms -> callback_consumed_audio_ms
  • written_audio_ms -> callback_output_audio_ms
  • overrun_audio_ms -> enqueue_overrun_audio_ms
  • overrun_events -> enqueue_overrun_events
  • underrun_audio_ms -> callback_underrun_audio_ms
  • underrun_events -> callback_underrun_events
  • write_calls_per_sec_ewma -> callback_calls_per_sec_ewma

PortAudio-backed TX streams populate write_attempts, writes_completed, callback cadence, and the callback-duration fields from actual playback-callback activity. Fake TX/test-double streams are compatibility helpers rather than PortAudio callback simulators: FakeTxStream keeps its historical producer-write accounting, while duplex test doubles may only report captured write frames and leave callback attempt, duration, or cadence fields at their defaults unless they explicitly simulate playback.

Interpretation Rules

  • capture_input_overflows > 0 means the OS capture callback already lost input before RigPlane could bridge it. Start with local capture/device pressure.
  • tx_overruns > 0 with zero capture overflow means capture kept running, but the bridge TX queue backed up and RigPlane dropped stale frames on purpose.
  • TxStreamHealth.enqueue_overrun_events > 0, enqueue_overrun_audio_ms > 0, or frames_dropped > 0 mean the writable TX playback queue overflowed later in the path; that is distinct from bridge queue pressure and uses different counters. Legacy alias names still report the same values.
  • tx_silence_suppressed > 0 means quiet frames were filtered by policy. This is expected during RX silence and is not evidence of callback starvation.
  • Downstream TX push/write failures live elsewhere: TxStreamHealth.write_failures, last_error, or radio/backend error logs indicate that RigPlane tried to send audio onward and that stage failed after capture succeeded.

AudioPacket

rigplane.audio.lan_stream.AudioPacket dataclass

Parsed audio packet.

Attributes:

Name Type Description
ident int

Audio stream identifier (0x0080 for TX, varies for RX).

send_seq int

Audio-level sequence number.

data bytes

Raw audio payload (format depends on negotiated codec — PCM16, uLaw, or Opus). Bytes after Icom LAN header.

AudioState

rigplane.audio.lan_stream.AudioState

Bases: StrEnum

Audio stream state.

JitterBuffer

rigplane.audio.lan_stream.JitterBuffer

Reorder-and-delay buffer for incoming audio packets.

Collects packets and delivers them in sequence-number order after a configurable depth of buffering. Handles out-of-order packets, duplicates, and gaps (delivering None for missing packets).

Parameters:

Name Type Description Default
depth int

Number of packets to buffer before delivery (default 5, which is ~100 ms at 20 ms/packet).

5

Example::

jb = JitterBuffer(depth=5)
for pkt in jb.push(audio_packet):
    if pkt is None:
        # gap — insert silence
        ...
    else:
        play(pkt.data)

depth property

Configured buffer depth (number of packets).

duplicate_count property

Count of duplicate packets dropped.

gap_count property

Count of inferred missing packets (gap placeholders).

overrun_count property

Count of jitter-buffer overrun events.

pending property

Number of packets currently held in the buffer.

stale_count property

Count of stale/old packets dropped.

underrun_count property

Count of jitter-buffer underrun events.

flush()

Flush all buffered packets in order (for stream end).

Returns:

Type Description
list[AudioPacket | None]

Remaining packets in order (None for gaps).

push(packet)

Insert a packet and return any packets ready for delivery.

Packets are delivered in order. If a gap is detected (missing sequence number), None is yielded in its place.

Parameters:

Name Type Description Default
packet AudioPacket

Incoming audio packet.

required

Returns:

Type Description
list[AudioPacket | None]

List of packets (or None for gaps) ready for playback.

list[AudioPacket | None]

May be empty if more buffering is needed.

Packet Functions

rigplane.audio.lan_stream.parse_audio_packet(data)

Parse a raw UDP audio packet into an :class:AudioPacket.

Parameters:

Name Type Description Default
data bytes

Raw UDP packet bytes (must be > 0x18 bytes).

required

Returns:

Type Description
AudioPacket | None

Parsed AudioPacket, or None if the packet is too short or

AudioPacket | None

is a control/retransmit packet (type != DATA).

rigplane.audio.lan_stream.build_audio_packet(audio_data, *, sender_id, receiver_id, send_seq, ident=TX_IDENT)

Build a raw UDP audio packet from negotiated-codec audio data.

Parameters:

Name Type Description Default
audio_data bytes

Audio payload encoded with the negotiated stream codec.

required
sender_id int

Our connection ID.

required
receiver_id int

Radio's connection ID.

required
send_seq int

Audio-level sequence number.

required
ident int

Audio ident field (default TX_IDENT=0x0080).

TX_IDENT

Returns:

Type Description
bytes

Complete UDP packet bytes ready to send.

Internal Transcoder Layer

rigplane now includes an internal PCM<->Opus transcoder foundation used for future high-level PCM APIs.

  • Module: rigplane._audio_transcoder (internal, no stability guarantee yet)
  • Backend: optional opuslib (pip install rigplane[audio])
  • Typed failures:
  • AudioCodecBackendError for missing backend
  • AudioFormatError for invalid PCM/Opus frame formats
  • AudioTranscodeError for codec encode/decode failures

AudioBus (pub/sub multi-consumer)

rigplane.audio.bus.AudioBus

Fan-out distribution bus for radio audio packets.

Parameters:

Name Type Description Default
radio Any

A radio instance implementing AudioCapable.

required
jitter_depth int

Jitter buffer depth passed to radio RX start.

5

last_rx_frame_monotonic property

time.monotonic() of the most recent RX fan-out, or None.

RX liveness heartbeat (MOR-564): observability only, no watchdog.

restart_rx() async

Re-establish RX on the radio using the bus's own callback.

Used after a half-duplex TX cycle (e.g. the web poller's PTT-off transition on Icom CI-V backends): the radio's single-slot RX callback must be restored to :meth:_on_opus_packet so subscribers keep receiving frames. No-op when the bus has no active subscribers.

A re-arm failure is non-fatal for the established session (a hiccup must not crash healthy subscribers) but never masked as success: rx_active drops to False so stats and the recovery watchdog see the dead RX leg (MOR-582). The typed already-started case is benign — RX stayed live through the TX cycle (LAN) and remains wired to this bus, so it stays marked live.

stop() async

Stop the bus and all subscribers.

subscribe(name='', queue_size=_DEFAULT_QUEUE_SIZE)

Create a new subscription (not yet active — call start() or use as context manager).

taps(stage)

Return the :class:TapRegistry for a named RX stage on this bus.

Only STAGE_RX_PCM is hosted here; reserved stage names raise KeyError. Taps are attachable/detachable at runtime and add no cost when empty (the registry no-ops without subscribers).

rigplane.audio.bus.AudioSubscription

A single subscriber to the audio bus.

Receives copies of every audio packet via an internal asyncio.Queue. Can be iterated with async for or read manually with :meth:get.

Parameters:

Name Type Description Default
bus AudioBus

Parent AudioBus.

required
name str

Human-readable subscriber name (for logging).

''
queue_size int

Maximum buffered packets before dropping.

_DEFAULT_QUEUE_SIZE

aclose(timeout=_DEFAULT_CLOSE_TIMEOUT) async

Deactivate this subscription and await bus removal.

Parameters:

Name Type Description Default
timeout float | None

Maximum seconds to wait for teardown. None disables the timeout for callers that intentionally want unbounded cleanup.

_DEFAULT_CLOSE_TIMEOUT

deliver(packet)

Called by the bus to deliver a packet (non-blocking).

get(timeout=None) async

Get the next packet (blocks until available or timeout).

get_nowait()

Get a packet without blocking (raises QueueEmpty).

start() async

Activate this subscription (registers with the bus).

Raises if this subscription's demand triggers the radio RX start and the start fails (MOR-582): the subscription is left inactive and unregistered — never "attached" to a bus that is not receiving.

stop()

Deactivate this subscription and schedule bus removal.

This method is intentionally synchronous for backward compatibility. Prefer :meth:aclose in async teardown paths when callers need to know removal has completed.

The AudioBus provides pub/sub distribution for radio RX audio. Multiple consumers (WebSocket broadcaster, audio bridge, recorders) share a single radio RX stream.

Basic Usage

from rigplane import create_radio, LanBackendConfig

config = LanBackendConfig(host="192.168.1.100", username="u", password="p")
async with create_radio(config) as radio:
    # Subscribe to audio bus
    async with radio.audio_bus.subscribe(name="my-app") as sub:
        async for packet in sub:
            if packet is not None:
                process(packet.data)  # opus bytes

Multiple Consumers

bus = radio.audio_bus

# Web UI gets audio
web = bus.subscribe(name="web-audio")
await web.start()

# Bridge gets audio simultaneously
bridge = bus.subscribe(name="audio-bridge")
await bridge.start()

# Both receive the same packets independently
# First subscriber triggers radio.start_audio_rx_opus()
# Last awaited close triggers radio.stop_audio_rx_opus()
await web.aclose()
await bridge.aclose()

Subscriptions support async with, await sub.aclose(), and the older sub.stop() convenience path. Prefer async with or await aclose() when coordinating restart/teardown: the awaited close path removes the subscriber before the caller proceeds, so bridge or WebSocket restarts do not race a stale subscriber.

Properties

Property Type Description
subscriber_count int Number of active subscribers
rx_active bool Whether radio RX is currently streaming

Queue And Frame Semantics

Audio queues are bounded to preserve real-time behavior. When the bridge TX queue overflows, RigPlane drops the oldest queued audio and keeps the newest live frame. Diagnostics count that bridge-side event as tx_overruns.

When the writable TX playback queue overflows later in the path, TxStreamHealth tracks it separately via enqueue_overrun_events, enqueue_overrun_audio_ms, and frames_dropped. Those playback counters are not reported as tx_overruns. Legacy aliases overrun_events and overrun_audio_ms still mirror the canonical values for compatibility.

Do not confuse bridge queue drops with PortAudio capture callback overflow: tx_overruns means RigPlane chose to evict stale already-captured audio, whereas capture_input_overflows / input_overflow_events mean the OS/backend capture callback reported lost input before the bridge queue decision.

PortAudio capture uses engine-native callback periods (blocksize=0) and then losslessly re-chunks the continuous callback stream into fixed frame_ms frames before handing it to PCM TX validators. At 48 kHz, 16-bit mono, frame_ms=20 means each emitted TX frame is 1920 bytes. Consumers should still treat WebSocket/DataChannel frame_ms as an advisory label and derive actual duration from payload size and metadata.

Module Constants

MAX_AUDIO_PAYLOAD

rigplane.audio.MAX_AUDIO_PAYLOAD: int = 1364

Maximum audio payload in bytes per TX UDP packet.

The IC-7610 silently drops TX audio UDP packets whose payload exceeds 1364 bytes. This limit is undocumented but observed empirically and matches the wfview source:

// wfview: audio.data.mid(len, 1364)

push_tx() automatically chunks oversized payloads:

push_tx(pcm_frame)  # 1920-byte 20ms PCM frame @ 48kHz/16-bit
  → chunk 0: bytes [0 : 1364]    → 1364-byte UDP payload  ✓
  → chunk 1: bytes [1364 : 1920] →  556-byte UDP payload  ✓

The two chunk sizes — 1364 and 556 bytes — correspond to the fixed audio payload sizes documented in wfview for the IC-7610. Low-level callers do not need to pre-chunk payloads. The high-level push_audio_tx_pcm() API still requires one complete PCM frame at the configured sample rate, channel count, and frame duration.

Usage

RX Audio (callback-based)

from rigplane import create_radio, LanBackendConfig

config = LanBackendConfig(host="192.168.1.100", username="u", password="p")
async with create_radio(config) as radio:
    received = []

    def on_audio(pkt):
        if pkt is not None:  # None = gap (missing packet)
            received.append(pkt.data)

    await radio.start_audio_rx_opus(on_audio)
    await asyncio.sleep(10)
    await radio.stop_audio_rx_opus()

RX Audio (high-level PCM)

config = LanBackendConfig(host="192.168.1.100", username="u", password="p")
async with create_radio(config) as radio:
    def on_pcm(frame: bytes | None) -> None:
        if frame is None:
            return  # gap placeholder from jitter buffer
        # frame is 16-bit little-endian PCM for configured format
        process_pcm(frame)

    await radio.start_audio_rx_pcm(
        on_pcm,
        sample_rate=48000,
        channels=1,
        frame_ms=20,
        jitter_depth=5,
    )
    await asyncio.sleep(10)
    await radio.stop_audio_rx_pcm()

TX Audio (push-based)

config = LanBackendConfig(host="192.168.1.100", username="u", password="p")
async with create_radio(config) as radio:
    await radio.start_audio_tx_opus()
    await radio.push_audio_tx_opus(audio_payload)
    await radio.stop_audio_tx_opus()

The low-level method names are historical. For direct Icom LAN sessions, rigplane currently negotiates TX as PCM_1CH_16BIT, so the TX payload sent to the radio is raw PCM16LE. Opus TX payloads are only valid for endpoints that negotiate an Opus TX codec, such as wfview-compatible server paths.

TX Audio (high-level PCM)

config = LanBackendConfig(host="192.168.1.100", username="u", password="p")
async with create_radio(config) as radio:
    await radio.start_audio_tx_pcm(sample_rate=48000, channels=1, frame_ms=20)
    await radio.push_audio_tx_pcm(pcm_frame)  # one 20ms PCM frame (1920 bytes)
    await radio.stop_audio_tx_pcm()

Full-Duplex

config = LanBackendConfig(host="192.168.1.100", username="u", password="p")
async with create_radio(config) as radio:
    await radio.start_audio_opus(rx_callback=on_audio, tx_enabled=True)
    # ... push TX frames, receive RX via callback ...
    await radio.stop_audio_opus()

Codec Selection

from rigplane import create_radio, LanBackendConfig, AudioCodec

config = LanBackendConfig(
    host="192.168.1.100",
    username="u",
    password="p",
    audio_codec=AudioCodec.PCM_1CH_16BIT,  # default
    audio_sample_rate=48000,
)
async with create_radio(config) as radio:
    ...

Capability Introspection

Use the capability API to inspect negotiated client-side audio options and defaults. The same API is available on the Radio returned by create_radio and on IcomRadio (legacy):

from rigplane import create_radio, get_audio_capabilities, LanBackendConfig

config = LanBackendConfig(host="192.168.1.100", username="u", password="p")
# Static defaults (no connection required):
caps = get_audio_capabilities()
print(caps.supported_codecs)
print(caps.supported_sample_rates_hz)
print(caps.supported_channels)
print(caps.default_codec, caps.default_sample_rate_hz, caps.default_channels)

For legacy LAN-only code, IcomRadio.audio_capabilities() returns the same structure.

Deterministic default selection rules:

  1. Codec: first supported codec in rigplane preference order.
  2. Sample rate: highest supported sample rate.
  3. Channels: the channel count implied by default codec (fallback: minimum supported channels).

Opus codecs

OPUS_1CH (0x40) and OPUS_2CH (0x41) are only supported when the radio reports connection_type == "WFVIEW". Standard connections use LPCM16 (0x04).

Migration

Use the explicit _opus methods now:

Deprecated alias Replacement
start_audio_rx start_audio_rx_opus
stop_audio_rx stop_audio_rx_opus
start_audio_tx start_audio_tx_opus
push_audio_tx push_audio_tx_opus
stop_audio_tx stop_audio_tx_opus
start_audio start_audio_opus
stop_audio stop_audio_opus

For RX PCM, migrate callback-side decoding to the built-in API:

  • Before: start_audio_rx_opus() + manual Opus decode in callback.
  • Now: start_audio_rx_pcm() and receive bytes | None directly.

For TX PCM, migrate manual Opus encoding to the built-in API:

  • Before: manually prepare the low-level negotiated-codec payload and call push_audio_tx_opus().
  • Now: start_audio_tx_pcm() and push_audio_tx_pcm() with fixed-size PCM frames.