Skip to main content

netget/server/rtp/
mod.rs

1//! RTP server (RFC 3550).
2//!
3//! Binds a UDP socket and speaks Real-time Transport Protocol. When a datagram arrives it is
4//! parsed as RTP (or RTCP) and turned into an event; the model answers by *describing* what the
5//! stream should carry (a tone, DTMF, silence) and this server synthesizes G.711 and frames it
6//! into correct RTP packets sent back to the peer. See `media.rs` for the VNC-style text-to-media
7//! engine, shared with the `rtsp` control server.
8//!
9//! # RTP is high-rate, so the model is behind a budget
10//!
11//! A single G.711 stream is 50 packets per second, and the model does not belong on that path:
12//! one LLM call per inbound datagram would exhaust the budget in seconds, and every consultation
13//! that does happen can authorize up to 30 seconds of outbound media — so an attacker spoofing
14//! a source address gets an amplifier as well. Two gates stand in front of `call_llm`, in the
15//! shape `src/server/tuntap/` established:
16//!
17//! ```text
18//!   datagram ─▶ GATE 1  run the handler; did it ANSWER?  ── yes ──▶ no model call, always runs
19//!              │        (script / static / manual rule)
20//!              └─▶ GATE 2  a rolling per-minute budget   ── over ──▶ dropped,
21//!                          (`llm_max_per_minute`, 30)                decision=fail_closed_rate_limited
22//! ```
23//!
24//! Gate 1 is why the budget is safe to set low: script and static handlers are the intended way
25//! to run RTP at rate and are never charged. **The exemption is decided by the handler's answer,
26//! not by its presence in the routing table** — a `Script` rule whose language is unknown, whose
27//! interpreter is missing, or whose code throws answers `FallbackToLlm`, and such a datagram is
28//! charged like any unclaimed one. Deciding this from configuration was a live defect in this
29//! file: `call_llm` dispatches the handler itself, so a declining script reached the model with
30//! the budget already skipped, at wire rate, with `llm_max_per_minute` inert. See
31//! `tests/handler_result_decides_the_bound_test.rs` and
32//! `tests/server/rtp/script_fallback_budget_test.rs`.
33//!
34//! Gate 2 is a *ceiling*, not a smoothing filter — a sliding window rather than a leaky bucket,
35//! so "never more than N in any minute" is literally true. Setting `llm_max_per_minute` to 0
36//! forbids model consultation outright, which is the right configuration for a server driven
37//! entirely by handlers.
38
39pub mod actions;
40pub mod media;
41
42use crate::server::connection::ConnectionId;
43use crate::utils::clock::Instant;
44use anyhow::Result;
45use std::collections::VecDeque;
46use std::net::SocketAddr;
47use std::sync::{Arc, Mutex};
48use std::time::Duration;
49use tokio::net::UdpSocket;
50use tokio::sync::mpsc;
51use tracing::{error, trace};
52
53use crate::llm::action_helper::call_llm;
54use crate::llm::actions::protocol_trait::Protocol;
55use crate::llm::event_handler_executor::EventHandlerResult;
56use crate::llm::ollama_client::OllamaClient;
57use crate::logging::emit::Log;
58use crate::protocol::{Event, StartupParams};
59use crate::scripting::EventHandlerType;
60use crate::server::RtpProtocol;
61use crate::state::app_state::AppState;
62use actions::{RTCP_RECEIVED_EVENT, RTP_RECEIVED_EVENT};
63use media::{AudioCodec, RtpPacketizer};
64
65/// Default ceiling on model consultations per rolling minute.
66///
67/// Deliberately far below a real stream's 50 packets per second: the model is for deciding what
68/// a stream carries, not for being asked about every frame of it.
69pub const DEFAULT_LLM_MAX_PER_MINUTE: u32 = 30;
70
71/// A rolling one-minute ceiling on model consultations.
72///
73/// A sliding window rather than a token bucket, for the same reason `tuntap::EscalationBudget`
74/// is: the guarantee the parameter promises is the literal one — never more than N in any
75/// minute. A leaky bucket refilling continuously permits exactly the short bursts this exists
76/// to prevent.
77#[derive(Debug)]
78pub struct RtpLlmBudget {
79    max_per_minute: u32,
80    window: Duration,
81    hits: VecDeque<Instant>,
82    /// Consultations refused since the last time a refusal was reported to the operator. The
83    /// status channel is unbounded and RTP is high-rate, so one line per dropped packet would
84    /// itself be the denial of service; the count is folded into the next report instead.
85    suppressed: u64,
86}
87
88impl RtpLlmBudget {
89    pub fn new(max_per_minute: u32) -> Self {
90        Self {
91            max_per_minute,
92            window: Duration::from_secs(60),
93            hits: VecDeque::new(),
94            suppressed: 0,
95        }
96    }
97
98    pub fn max_per_minute(&self) -> u32 {
99        self.max_per_minute
100    }
101
102    /// Take one consultation if the window has room. Returns false when it does not.
103    pub fn try_take(&mut self) -> bool {
104        self.try_take_at(Instant::now())
105    }
106
107    /// [`Self::try_take`] against a caller-supplied clock, so the window is testable without
108    /// sleeping for a minute.
109    pub fn try_take_at(&mut self, now: Instant) -> bool {
110        if self.max_per_minute == 0 {
111            return false;
112        }
113        while let Some(front) = self.hits.front() {
114            if now.duration_since(*front) >= self.window {
115                self.hits.pop_front();
116            } else {
117                break;
118            }
119        }
120        if self.hits.len() as u32 >= self.max_per_minute {
121            return false;
122        }
123        self.hits.push_back(now);
124        true
125    }
126
127    /// Record a refusal and say whether it should be reported loudly.
128    ///
129    /// The first refusal after a period of admissions is reported with however many were
130    /// suppressed before it; the rest go to the file log only.
131    pub fn note_refusal(&mut self) -> Option<u64> {
132        self.suppressed += 1;
133        if self.suppressed == 1 {
134            Some(0)
135        } else {
136            None
137        }
138    }
139
140    /// Clear the suppression counter, returning how many refusals went unreported.
141    pub fn take_suppressed(&mut self) -> u64 {
142        std::mem::take(&mut self.suppressed)
143    }
144}
145
146/// Startup configuration read from declared parameters.
147#[derive(Debug, Clone)]
148pub struct RtpConfig {
149    /// Ceiling on model consultations per rolling minute. Zero forbids them entirely.
150    pub llm_max_per_minute: u32,
151}
152
153impl Default for RtpConfig {
154    fn default() -> Self {
155        Self {
156            llm_max_per_minute: DEFAULT_LLM_MAX_PER_MINUTE,
157        }
158    }
159}
160
161impl RtpConfig {
162    /// Read the declared startup parameters. Propagates the parameter error with `?` rather
163    /// than unwrapping it, so a bad value names itself instead of killing the spawn task.
164    pub fn from_params(params: &Option<StartupParams>) -> Result<Self> {
165        let mut cfg = Self::default();
166        let Some(params) = params else {
167            return Ok(cfg);
168        };
169        if let Some(v) = params.get_optional_u64("llm_max_per_minute")? {
170            if v > u32::MAX as u64 {
171                anyhow::bail!("llm_max_per_minute {v} is out of range");
172            }
173            cfg.llm_max_per_minute = v as u32;
174        }
175        Ok(cfg)
176    }
177}
178
179/// RTP server that generates/answers media streams under LLM control.
180pub struct RtpServer;
181
182impl RtpServer {
183    /// Spawn the RTP server. Awaits the socket bind so a failure is reported to
184    /// `server_startup` as `Err`, and registers the accept loop so `stop_server` can abort it.
185    pub async fn spawn_with_llm_actions(
186        listen_addr: SocketAddr,
187        llm_client: OllamaClient,
188        app_state: Arc<AppState>,
189        status_tx: mpsc::UnboundedSender<String>,
190        server_id: crate::state::ServerId,
191        cfg: RtpConfig,
192    ) -> Result<SocketAddr> {
193        let socket = Arc::new(UdpSocket::bind(listen_addr).await?);
194        let local_addr = socket.local_addr()?;
195        Log::new(Some(&status_tx)).info(format!(
196            "RTP server listening on {} (llm_max_per_minute={})",
197            local_addr, cfg.llm_max_per_minute
198        ));
199
200        let protocol = Arc::new(RtpProtocol::new());
201        let task_registrar = app_state.clone();
202        let budget = Arc::new(Mutex::new(RtpLlmBudget::new(cfg.llm_max_per_minute)));
203
204        let accept_handle = tokio::spawn(async move {
205            let mut buffer = vec![0u8; 65535];
206            loop {
207                match socket.recv_from(&mut buffer).await {
208                    Ok((n, peer_addr)) => {
209                        let data = buffer[..n].to_vec();
210                        // One connection entry per *peer*, not per datagram. Adding one for
211                        // every packet made a 50 pps stream push 50 rows and 50
212                        // `__UPDATE_UI__` messages a second onto the unbounded status channel,
213                        // and left `send_rtp_audio`'s remote-address lookup picking the first
214                        // of a hundred identical entries.
215                        let connection_id =
216                            Self::track_peer(&app_state, server_id, peer_addr, local_addr, n).await;
217                        if connection_id.is_none() {
218                            trace!("RTP datagram for a server that is no longer registered");
219                            continue;
220                        }
221                        let connection_id = connection_id.expect("checked is_none above");
222
223                        let llm_clone = llm_client.clone();
224                        let state_clone = app_state.clone();
225                        let status_clone = status_tx.clone();
226                        let socket_clone = socket.clone();
227                        let protocol_clone = protocol.clone();
228                        let budget_clone = budget.clone();
229
230                        // Tracked, not detached: stop_server must abort this task too.
231                        let task_owner = app_state.clone();
232                        task_owner
233                            .spawn_server_task(server_id, async move {
234                                Self::handle_datagram(
235                                    &data,
236                                    peer_addr,
237                                    local_addr,
238                                    connection_id,
239                                    server_id,
240                                    &llm_clone,
241                                    &state_clone,
242                                    &status_clone,
243                                    &socket_clone,
244                                    protocol_clone.as_ref(),
245                                    &budget_clone,
246                                )
247                                .await;
248                            })
249                            .await;
250                    }
251                    Err(e) => {
252                        Log::new(Some(&status_tx)).error(format!("RTP recv error: {}", e));
253                    }
254                }
255            }
256        });
257
258        task_registrar
259            .register_server_task(server_id, accept_handle)
260            .await;
261        Ok(local_addr)
262    }
263
264    /// Attribute an inbound datagram to a connection entry for `peer_addr`, creating one only
265    /// if this peer has no live entry.
266    ///
267    /// Returns `None` when the server is gone from state, which is the one case where there is
268    /// nothing to attribute the packet to. RTP declares `.connectionless()`, so the 10-second
269    /// idle sweep reclaims entries for peers that have stopped sending; a peer that resumes
270    /// simply gets a fresh one.
271    async fn track_peer(
272        state: &AppState,
273        server_id: crate::state::ServerId,
274        peer_addr: SocketAddr,
275        local_addr: SocketAddr,
276        bytes: usize,
277    ) -> Option<ConnectionId> {
278        let existing = state
279            .with_server_mut(server_id, |s| {
280                s.connections
281                    .values_mut()
282                    .find(|c| c.remote_addr == peer_addr)
283                    .map(|c| {
284                        c.bytes_received += bytes as u64;
285                        c.packets_received += 1;
286                        c.last_activity = crate::utils::clock::Instant::now();
287                        c.id
288                    })
289            })
290            .await;
291
292        match existing {
293            // Server not registered any more.
294            None => None,
295            // Registered, and this peer already has an entry that has just been updated.
296            Some(Some(id)) => Some(id),
297            // Registered, first datagram from this peer: add an entry and repaint once.
298            Some(None) => {
299                use crate::state::server::{
300                    ConnectionState as ServerConnectionState, ConnectionStatus,
301                    ProtocolConnectionInfo,
302                };
303                let id = ConnectionId::new(state.get_next_unified_id().await);
304                let now = crate::utils::clock::Instant::now();
305                state
306                    .add_connection_to_server(
307                        server_id,
308                        ServerConnectionState {
309                            id,
310                            remote_addr: peer_addr,
311                            local_addr,
312                            bytes_sent: 0,
313                            bytes_received: bytes as u64,
314                            packets_sent: 0,
315                            packets_received: 1,
316                            last_activity: now,
317                            status: ConnectionStatus::Active,
318                            status_changed_at: now,
319                            protocol_info: ProtocolConnectionInfo::empty(),
320                        },
321                    )
322                    .await;
323                Some(id)
324            }
325        }
326    }
327
328    /// What the *configuration* says would answer this event. **Diagnostics only.**
329    ///
330    /// This must never decide whether the budget applies, and it used to. A `Script` rule
331    /// reads as "answers in-process, costs no model call" right up until
332    /// `execute_script_handler` returns `FallbackToLlm` — `python3` not installed, an unknown
333    /// language name, or a script that threw — and by then the gate had been skipped on the
334    /// strength of the rule merely *existing*. `call_llm` then dispatched the same handler
335    /// itself, watched it decline, and consulted the model uncharged. On a 50 pps stream that
336    /// is 50 uncounted consultations a second with `llm_max_per_minute` inert, and each one
337    /// can authorize up to 30 seconds of outbound media.
338    ///
339    /// [`Self::handle_datagram`] therefore runs the handler and branches on its **answer**,
340    /// the way `tuntap::handle_frame` does. What is left for this function is the one thing
341    /// the answer cannot express: that a rule was configured and did *not* answer, which in
342    /// the log is otherwise indistinguishable from no rule at all.
343    pub async fn configured_handler_kind(
344        state: &AppState,
345        server_id: crate::state::ServerId,
346        event_type_id: &str,
347    ) -> Option<&'static str> {
348        let config = state.get_event_handler_config(server_id).await?;
349        match config.find_handler(event_type_id)? {
350            EventHandlerType::Script { .. } => Some("script"),
351            EventHandlerType::Static { .. } => Some("static"),
352            EventHandlerType::Manual { .. } => Some("manual"),
353            EventHandlerType::Llm { .. } => Some("llm"),
354        }
355    }
356
357    #[allow(clippy::too_many_arguments)]
358    async fn handle_datagram(
359        data: &[u8],
360        peer_addr: SocketAddr,
361        local_addr: SocketAddr,
362        connection_id: ConnectionId,
363        server_id: crate::state::ServerId,
364        llm: &OllamaClient,
365        state: &AppState,
366        status_tx: &mpsc::UnboundedSender<String>,
367        socket: &UdpSocket,
368        protocol: &RtpProtocol,
369        budget: &Mutex<RtpLlmBudget>,
370    ) {
371        let is_rtcp = media::is_rtcp(data);
372        let base = serde_json::json!({
373            "peer_addr": peer_addr.to_string(),
374            "local_addr": local_addr.to_string(),
375            "connection_id": connection_id.to_string(),
376        });
377
378        let event = if is_rtcp {
379            let mut d = base;
380            d["packet_type"] = serde_json::json!(data.get(1).copied().unwrap_or(0));
381            d["length"] = serde_json::json!(data.len());
382            Event {
383                event_type: &RTCP_RECEIVED_EVENT,
384                data: d,
385            }
386        } else {
387            let parsed = match media::parse_rtp(data) {
388                Some(p) => p,
389                None => {
390                    Log::new(Some(status_tx)).warn(format!(
391                        "RTP unparseable datagram from {} ({} bytes)",
392                        peer_addr,
393                        data.len()
394                    ));
395                    return;
396                }
397            };
398            Log::new(Some(status_tx)).trace(format!(
399                "RTP in: pt={} seq={} ts={} ssrc={:08x} len={}",
400                parsed.payload_type,
401                parsed.sequence,
402                parsed.timestamp,
403                parsed.ssrc,
404                parsed.payload_len
405            ));
406            let mut d = base;
407            d["payload_type"] = serde_json::json!(parsed.payload_type);
408            d["sequence"] = serde_json::json!(parsed.sequence);
409            d["timestamp"] = serde_json::json!(parsed.timestamp);
410            d["ssrc"] = serde_json::json!(parsed.ssrc);
411            d["marker"] = serde_json::json!(parsed.marker);
412            d["payload_len"] = serde_json::json!(parsed.payload_len);
413            Event {
414                event_type: &RTP_RECEIVED_EVENT,
415                data: d,
416            }
417        };
418
419        let event_id = event.event_type.id.clone();
420
421        // Gate 1: a deterministic rule answers in-process and is never charged — but the
422        // exemption belongs to a rule that *answered*, not to one that was merely configured.
423        // Inspecting `find_handler` and skipping gate 2 on a `Script` match was the hole:
424        // `execute_script_handler` returns `FallbackToLlm` when the language name is unknown,
425        // when the interpreter is missing, or when the script throws, and `call_llm` — which
426        // dispatches the handler again itself — then reached the model with the budget already
427        // bypassed. So the handler runs here and its answer decides.
428        //
429        // Gate 2: everything the handler did not answer is charged to the rolling per-minute
430        // budget. Without it a 50 pps stream is 50 LLM calls a second, and each consultation
431        // can authorize up to 30 seconds of outbound media, so a spoofed source address turns
432        // the server into an amplifier. Over budget the datagram is dropped and nothing goes on
433        // the wire, which is the same silence every other RTP failure produces — hence the
434        // `decision=` tag.
435        let handler_outcome = crate::llm::event_handler_executor::try_execute_event_handler(
436            state,
437            server_id,
438            Some(connection_id),
439            &event_id,
440            &event.event_type.description,
441            Some(event.data.clone()),
442            Some(protocol),
443        )
444        .await;
445
446        let (result, answered_by) = match handler_outcome {
447            Ok(EventHandlerResult::Handled(r)) => {
448                // `call_llm` is not reached on this path, so the two things it does for every
449                // event regardless of who answers are done here: the pipe tap, which must fire
450                // even when a handler answers, and the access-log entry the dashboard's request
451                // pane reads. Skipping them would make a handler-answered datagram invisible.
452                crate::pipe::dispatch_pipes(state, server_id, &event).await;
453                state
454                    .record_access_log(
455                        crate::state::AccessLogOwner::Server(server_id.as_u32()),
456                        protocol.protocol_name(),
457                        Some(connection_id.as_u32()),
458                        &event_id,
459                        event.data.clone(),
460                        r.access_log_actions(),
461                    )
462                    .await;
463                (r, "handler")
464            }
465            Err(e) => {
466                // A manual rule that timed out or was dismissed, or a handler that failed
467                // hard. RTP has no error frame, so this is the same silence as a backend
468                // failure and is separated from it only by its tag.
469                Log::new(Some(status_tx)).error(format!(
470                    "RTP {} from {} decision=fail_closed_handler_error; no media sent: {}",
471                    event_id, peer_addr, e
472                ));
473                return;
474            }
475            Ok(EventHandlerResult::FallbackToLlm { .. }) => {
476                let refused = {
477                    let mut b = budget.lock().expect("RTP budget mutex poisoned");
478                    if b.try_take() {
479                        None
480                    } else {
481                        let report_now = b.note_refusal().is_some();
482                        Some((b.max_per_minute(), report_now))
483                    }
484                };
485                if let Some((max, report_now)) = refused {
486                    // A configured rule that declined is the case an operator will otherwise
487                    // misread: the routing table says "a handler answers this" and the charge
488                    // says otherwise. Name it.
489                    let why = match Self::configured_handler_kind(state, server_id, &event_id).await
490                    {
491                        Some(kind @ ("script" | "static" | "manual")) => format!(
492                            " (the {kind} rule for this event did not answer — an unknown \
493                                 language, a missing interpreter or a failing script falls \
494                                 through to the model, which is charged)"
495                        ),
496                        _ => String::new(),
497                    };
498                    let line = format!(
499                        "RTP {} from {} decision=fail_closed_rate_limited; no media sent \
500                         (already used the {} model consultation(s) this minute allows — raise \
501                         llm_max_per_minute, or answer this event with a script/static handler, \
502                         which is never charged){}",
503                        event_id, peer_addr, max, why
504                    );
505                    if report_now {
506                        Log::new(Some(status_tx)).warn(line);
507                    } else {
508                        trace!("{line}");
509                    }
510                    return;
511                }
512                // Admitted: report anything that was dropped while the window was full, so the
513                // count is not lost just because the flood stopped.
514                let suppressed = {
515                    let mut b = budget.lock().expect("RTP budget mutex poisoned");
516                    b.take_suppressed()
517                };
518                if suppressed > 1 {
519                    Log::new(Some(status_tx)).warn(format!(
520                        "RTP dropped {} further datagram(s) over the model budget before this one",
521                        suppressed - 1
522                    ));
523                }
524
525                // `call_llm` dispatches the handlers again before reaching the model. For a
526                // handler that has just said it cannot answer that is a second no-op (an
527                // unknown or missing language) or a second failing script run — wasteful, not
528                // harmful, and the alternative is a second entry point into `src/llm/` whose
529                // two copies would drift. The *answering* path never gets here, so nothing that
530                // answered is ever run twice: no script that produced actions runs again, and
531                // no manual rule parks twice.
532                match call_llm(llm, state, server_id, Some(connection_id), &event, protocol).await {
533                    Ok(r) => (r, "model"),
534                    Err(e) => {
535                        // Fail closed: RTP is a one-way media transport with no error frame and
536                        // no request/response turn, so we emit nothing on the wire rather than
537                        // falling through to some default stream (which would fabricate media
538                        // the model never authorized) or inventing an RTCP BYE for a session we
539                        // never joined. The peer is not blocked on us; the operator is the one
540                        // who needs to know, so the whole error goes to the log and the status
541                        // stream and nothing goes to the socket.
542                        //
543                        // `decision=` mirrors `src/server/radius/`: an operator greps
544                        // `fail_closed_` to find every datagram the model did not actually
545                        // answer, and the overloaded / errored split is kept even though RTP
546                        // has no way to express it on the wire. There is no `model_reject`
547                        // counterpart — RTP has no accept/deny semantics, so a model declining
548                        // to stream is exactly the `*_sent_nothing` case below.
549                        let decision = if crate::utils::WireFailure::classify(&e).is_overloaded() {
550                            "fail_closed_backend_overloaded"
551                        } else {
552                            "fail_closed_backend_error"
553                        };
554                        Log::new(Some(status_tx)).error(format!(
555                            "RTP {} from {} decision={}; no media sent: {}",
556                            event_id, peer_addr, decision, e
557                        ));
558                        return;
559                    }
560                }
561            }
562        };
563
564        if result.raw_actions.is_empty() {
565            // Whoever answered, answered "stream nothing". For RTP that is legitimate — a
566            // receiver owes its sender no media — but on the wire it is byte-identical to a
567            // backend outage, so the two must be separable here.
568            Log::new(Some(status_tx)).info(format!(
569                "RTP {} from {} decision={}_sent_nothing (no media requested)",
570                event_id, peer_addr, answered_by
571            ));
572        }
573        for action in &result.raw_actions {
574            Self::execute_send_action(action, peer_addr, socket, status_tx, state, server_id).await;
575        }
576    }
577
578    /// Interpret one model action and, if it is a media-send, synthesize and transmit RTP.
579    async fn execute_send_action(
580        action: &serde_json::Value,
581        peer_addr: SocketAddr,
582        socket: &UdpSocket,
583        status_tx: &mpsc::UnboundedSender<String>,
584        state: &AppState,
585        server_id: crate::state::ServerId,
586    ) {
587        let action_type = action.get("type").and_then(|v| v.as_str()).unwrap_or("");
588        match action_type {
589            "send_rtp_audio" => {
590                Self::send_rtp_audio(action, peer_addr, socket, status_tx, state, server_id).await;
591            }
592            "send_rtcp_sender_report" => {
593                Self::send_rtcp(action, peer_addr, socket, status_tx).await;
594            }
595            // Common/no-op actions (set_memory, show_message, …) are executed by the shared
596            // executor already; nothing to put on the wire here.
597            "" => {
598                Log::new(Some(status_tx)).warn(format!(
599                    "RTP action with no \"type\" field, ignored: {}",
600                    action
601                ));
602            }
603            other => {
604                // Not on the wire, but not silent either: a misspelled media action is
605                // otherwise indistinguishable from the model deciding to stream nothing.
606                Log::new(Some(status_tx)).debug(format!(
607                    "RTP no wire output for action type \"{}\" (handled by the shared executor \
608                     if it is a common action)",
609                    other
610                ));
611            }
612        }
613    }
614
615    async fn send_rtp_audio(
616        action: &serde_json::Value,
617        peer_addr: SocketAddr,
618        socket: &UdpSocket,
619        status_tx: &mpsc::UnboundedSender<String>,
620        state: &AppState,
621        server_id: crate::state::ServerId,
622    ) {
623        let codec = match action
624            .get("payload_type")
625            .and_then(|v| v.as_str())
626            .map(AudioCodec::parse)
627            .unwrap_or(Ok(AudioCodec::Pcmu))
628        {
629            Ok(c) => c,
630            Err(e) => {
631                Log::new(Some(status_tx)).warn(format!("RTP send_rtp_audio: {}", e));
632                return;
633            }
634        };
635        let content = match media::parse_audio_content(action) {
636            Ok(c) => c,
637            Err(e) => {
638                Log::new(Some(status_tx)).warn(format!("RTP send_rtp_audio content: {}", e));
639                return;
640            }
641        };
642        let duration_ms = action
643            .get("duration_ms")
644            .and_then(|v| v.as_u64())
645            .unwrap_or(1000);
646        let payload = match media::synthesize(codec, &content, duration_ms) {
647            Ok(p) => p,
648            Err(e) => {
649                Log::new(Some(status_tx)).warn(format!("RTP synthesis: {}", e));
650                return;
651            }
652        };
653
654        let ssrc = action
655            .get("ssrc")
656            .and_then(|v| v.as_u64())
657            .map(|v| v as u32)
658            .unwrap_or_else(|| rand::random());
659        let seq = action
660            .get("start_sequence")
661            .and_then(|v| v.as_u64())
662            .map(|v| v as u16);
663        let ts = action
664            .get("start_timestamp")
665            .and_then(|v| v.as_u64())
666            .map(|v| v as u32);
667        let mut packetizer = RtpPacketizer::new(ssrc, codec.payload_type(), seq, ts);
668        let packets = packetizer.packetize(&payload, media::G711_SAMPLES_PER_FRAME);
669
670        let mut sent = 0u64;
671        let mut bytes = 0u64;
672        for pkt in &packets {
673            match socket.send_to(pkt, peer_addr).await {
674                Ok(w) => {
675                    sent += 1;
676                    bytes += w as u64;
677                }
678                Err(e) => {
679                    error!("RTP send failed to {}: {}", peer_addr, e);
680                    break;
681                }
682            }
683            // Pace at the frame interval (20 ms) so this is a real-time stream, not a burst.
684            tokio::time::sleep(std::time::Duration::from_millis(20)).await;
685        }
686        state
687            .with_server_mut(server_id, |s| {
688                if let Some(c) = s
689                    .connections
690                    .values_mut()
691                    .find(|c| c.remote_addr == peer_addr)
692                {
693                    c.bytes_sent += bytes;
694                    c.packets_sent += sent;
695                }
696            })
697            .await;
698        // FileOnly: the send_rtp_audio action's own log_template already reports
699        // "-> RTP {content} {payload_type} {duration_ms}ms" to the TUI at INFO.
700        Log::new(Some(status_tx)).debug(format!(
701            "RTP sent {} {} packet(s) ({} bytes) to {}",
702            sent,
703            codec.rtpmap_name(),
704            bytes,
705            peer_addr
706        ));
707    }
708
709    async fn send_rtcp(
710        action: &serde_json::Value,
711        peer_addr: SocketAddr,
712        socket: &UdpSocket,
713        status_tx: &mpsc::UnboundedSender<String>,
714    ) {
715        let ssrc = action
716            .get("ssrc")
717            .and_then(|v| v.as_u64())
718            .map(|v| v as u32)
719            .unwrap_or_else(rand::random);
720        let rtp_ts = action
721            .get("rtp_timestamp")
722            .and_then(|v| v.as_u64())
723            .unwrap_or(0) as u32;
724        let packet_count = action
725            .get("packet_count")
726            .and_then(|v| v.as_u64())
727            .unwrap_or(0) as u32;
728        let octet_count = action
729            .get("octet_count")
730            .and_then(|v| v.as_u64())
731            .unwrap_or(0) as u32;
732        let sr = media::build_rtcp_sender_report(ssrc, rtp_ts, packet_count, octet_count);
733        match socket.send_to(&sr, peer_addr).await {
734            Ok(w) => {
735                // FileOnly: the send_rtcp_sender_report action's own log_template already
736                // reports "-> RTCP SR" to the TUI at INFO.
737                Log::new(Some(status_tx)).debug(format!("RTCP SR to {} ({} bytes)", peer_addr, w));
738            }
739            Err(e) => {
740                Log::new(Some(status_tx)).error(format!("RTCP send failed: {}", e));
741            }
742        }
743    }
744}