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}