Skip to main content

netget/server/rtp/
actions.rs

1//! RTP protocol actions.
2//!
3//! The model never emits samples or bytes. It answers a received-packet event with a *structured
4//! description* of what to stream — a tone frequency, DTMF digits, silence — and the server
5//! (`mod.rs` + `media.rs`) synthesizes G.711 and frames it into RTP. This mirrors VNC, where the
6//! model describes a screen and Rust owns the pixels.
7
8use crate::llm::actions::{
9    protocol_trait::{ActionResult, Protocol, Server},
10    ActionDefinition, Parameter,
11};
12use crate::protocol::log_template::LogTemplate;
13use crate::protocol::EventType;
14use crate::state::app_state::AppState;
15use anyhow::{Context, Result};
16use serde_json::json;
17use std::sync::LazyLock;
18
19pub struct RtpProtocol;
20
21impl RtpProtocol {
22    pub fn new() -> Self {
23        Self
24    }
25}
26
27impl Default for RtpProtocol {
28    fn default() -> Self {
29        Self::new()
30    }
31}
32
33impl Protocol for RtpProtocol {
34    /// One parameter, read by [`crate::server::rtp::RtpConfig::from_params`].
35    ///
36    /// RTP is the one protocol here where the model genuinely cannot be on the per-packet path,
37    /// so the ceiling is configuration rather than a constant.
38    fn get_startup_parameters(&self) -> Vec<crate::llm::actions::ParameterDefinition> {
39        use crate::llm::actions::ParameterDefinition;
40        vec![ParameterDefinition {
41            name: "llm_max_per_minute".to_string(),
42            type_hint: "number".to_string(),
43            description: format!(
44                "Ceiling on model consultations per rolling minute (default {}). A single G.711 \
45                 stream is 50 packets per second, so without a ceiling one stream is 50 LLM \
46                 calls a second. Datagrams over the ceiling are dropped and nothing is sent, \
47                 logged as decision=fail_closed_rate_limited. A script or static event handler \
48                 is never charged against this, so 0 - forbidding model consultation entirely - \
49                 is the right value for a server driven wholly by handlers.",
50                crate::server::rtp::DEFAULT_LLM_MAX_PER_MINUTE
51            ),
52            required: false,
53            example: json!(30),
54            default: None,
55        }]
56    }
57
58    fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
59        vec![send_rtp_audio_action(), send_rtcp_sender_report_action()]
60    }
61    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
62        vec![send_rtp_audio_action(), send_rtcp_sender_report_action()]
63    }
64    fn protocol_name(&self) -> &'static str {
65        "RTP"
66    }
67    fn get_event_types(&self) -> Vec<EventType> {
68        vec![RTP_RECEIVED_EVENT.clone(), RTCP_RECEIVED_EVENT.clone()]
69    }
70    fn stack_name(&self) -> &'static str {
71        "ETH>IP>UDP>RTP"
72    }
73    fn keywords(&self) -> Vec<&'static str> {
74        vec![
75            "rtp",
76            "real-time transport",
77            "rtp media stream",
78            "voip audio",
79        ]
80    }
81    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
82        use crate::protocol::metadata::{DevelopmentState, ProtocolMetadataV2};
83        ProtocolMetadataV2::builder()
84            .connectionless()
85            .state(DevelopmentState::Experimental)
86            .well_known_udp_port(5004)
87            // Deliberately silent: An RTP packet asserts media content at a timestamp; there
88            // is no error packet, and a fabricated one is played.
89            .deliberately_silent()
90            .implementation(
91                "Manual RFC 3550 packetizer with a G.711 (PCMU/PCMA) synthesis engine; the model \
92                 describes content, Rust owns the samples and framing",
93            )
94            .llm_control(
95                "What each stream carries (tone frequency, DTMF digits, silence, or hex-encoded \
96                 codec bytes) and the response to inbound RTP/RTCP",
97            )
98            .e2e_testing(
99                "Byte-literal RTP header assertions (mock LLM); interop with ffmpeg/ffprobe via \
100                 the rtsp front door",
101            )
102            .notes(
103                "PCMU (PT 0) and PCMA (PT 8) audio genuinely synthesize and decode in ffmpeg. \
104                 There is no video codec: video PTs are out of scope. Speech is NOT synthesized — \
105                 there is no TTS; ask for a tone/DTMF/silence, or supply raw codec bytes hex-encoded. \
106                 RTCP is a minimal SR with no report blocks.",
107            )
108            .build()
109    }
110    fn description(&self) -> &'static str {
111        "RTP media server that synthesizes G.711 audio streams under LLM direction"
112    }
113    fn example_prompt(&self) -> &'static str {
114        "Start an RTP server on port 40000. When a packet arrives, stream a 440 Hz PCMU tone back."
115    }
116    fn group_name(&self) -> &'static str {
117        "Proxy & Network"
118    }
119    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
120        use crate::llm::actions::StartupExamples;
121
122        // Deterministic: play a 440 Hz tone in response to every received RTP
123        // packet, no LLM call.
124        let script = r#"import json, sys
125data = json.load(sys.stdin)
126event = data["event"]
127if data["event_type_id"] == "rtp_packet_received":
128    actions = [{"type": "send_rtp_audio", "tone_hz": 440, "duration_ms": 200}]
129else:
130    actions = []
131print(json.dumps({"actions": actions}))"#;
132
133        StartupExamples::new(
134            json!({
135                "type": "open_server",
136                "port": 40000,
137                "base_stack": "rtp",
138                "instruction": "RTP media endpoint. When an RTP packet arrives, answer with send_rtp_audio streaming a 440 Hz PCMU tone for 2 seconds, echoing the caller's SSRC."
139            }),
140            json!({
141                "type": "open_server",
142                "port": 40000,
143                "base_stack": "rtp",
144                "event_handlers": [{
145                    "event_pattern": "rtp_packet_received",
146                    "handler": {"type": "script", "language": "python", "code": script}
147                }]
148            }),
149            json!({
150                "type": "open_server",
151                "port": 40000,
152                "base_stack": "rtp",
153                "event_handlers": [{
154                    "event_pattern": "rtp_packet_received",
155                    "handler": {
156                        "type": "static",
157                        "actions": [{
158                            "type": "send_rtp_audio",
159                            "payload_type": "pcmu",
160                            "content": "tone",
161                            "tone_hz": 440,
162                            "duration_ms": 1000
163                        }]
164                    }
165                }]
166            }),
167        )
168    }
169}
170
171impl Server for RtpProtocol {
172    fn spawn(
173        &self,
174        ctx: crate::protocol::SpawnContext,
175    ) -> std::pin::Pin<
176        Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
177    > {
178        Box::pin(async move {
179            use crate::server::rtp::{RtpConfig, RtpServer};
180            let cfg = RtpConfig::from_params(&ctx.startup_params)?;
181            RtpServer::spawn_with_llm_actions(
182                ctx.legacy_listen_addr(),
183                ctx.llm_client,
184                ctx.state,
185                ctx.status_tx,
186                ctx.server_id,
187                cfg,
188            )
189            .await
190        })
191    }
192
193    fn execute_action(&self, action: serde_json::Value) -> Result<ActionResult> {
194        // Wire I/O (synthesis + send) is performed in mod.rs from the raw action JSON, exactly as
195        // SIP builds its response there. Here we only validate that the action is well-formed so a
196        // malformed one is reported rather than silently sent.
197        let action_type = action
198            .get("type")
199            .and_then(|v| v.as_str())
200            .context("Missing 'type' field in action")?;
201        match action_type {
202            "send_rtp_audio" => {
203                // Validate content/codec now for a clean error; discard the synthesized bytes.
204                let codec = action
205                    .get("payload_type")
206                    .and_then(|v| v.as_str())
207                    .map(crate::server::rtp::media::AudioCodec::parse)
208                    .unwrap_or(Ok(crate::server::rtp::media::AudioCodec::Pcmu))?;
209                let content = crate::server::rtp::media::parse_audio_content(&action)?;
210                let duration = action
211                    .get("duration_ms")
212                    .and_then(|v| v.as_u64())
213                    .unwrap_or(1000);
214                crate::server::rtp::media::synthesize(codec, &content, duration)?;
215                Ok(ActionResult::NoAction)
216            }
217            "send_rtcp_sender_report" => Ok(ActionResult::NoAction),
218            _ => Err(anyhow::anyhow!("Unknown RTP action: {}", action_type)),
219        }
220    }
221}
222
223pub static RTP_RECEIVED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
224    EventType::new(
225        "rtp_packet_received",
226        "An RTP packet arrived. Answer with send_rtp_audio to stream media back to the caller \
227         (describe the content — a tone, DTMF, or silence — never raw samples). Echo the caller's \
228         ssrc if you want the reply attributed to the same source, or omit it for a fresh one.",
229        json!({
230            "type": "send_rtp_audio",
231            "payload_type": "pcmu",
232            "content": "tone",
233            "tone_hz": 440,
234            "duration_ms": 1000
235        }),
236    )
237    .with_actions(vec![
238        send_rtp_audio_action(),
239        send_rtcp_sender_report_action(),
240    ])
241    .with_log_template(
242        LogTemplate::new()
243            .with_info("RTP pkt pt={payload_type} seq={sequence}")
244            .with_debug("RTP in pt={payload_type} seq={sequence} ssrc={ssrc} len={payload_len}")
245            .with_trace("RTP: {json_pretty(.)}"),
246    )
247});
248
249pub static RTCP_RECEIVED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
250    EventType::new(
251        "rtcp_packet_received",
252        "An RTCP control packet arrived (sender/receiver report, BYE, …). Optionally answer with \
253         send_rtcp_sender_report to report this endpoint's own sending statistics.",
254        json!({
255            "type": "send_rtcp_sender_report",
256            "packet_count": 50,
257            "octet_count": 8000
258        }),
259    )
260    .with_actions(vec![send_rtcp_sender_report_action()])
261    .with_log_template(
262        LogTemplate::new()
263            .with_info("RTCP pkt type={packet_type}")
264            .with_debug("RTCP in type={packet_type} len={length}")
265            .with_trace("RTCP: {json_pretty(.)}"),
266    )
267});
268
269pub fn send_rtp_audio_action() -> ActionDefinition {
270    ActionDefinition {
271        name: "send_rtp_audio".to_string(),
272        description: "Stream synthesized G.711 audio to the caller as RTP packets. Describe the \
273                      content structurally; the server owns the samples and the packet framing."
274            .to_string(),
275        parameters: vec![
276            Parameter {
277                name: "payload_type".to_string(),
278                type_hint: "string".to_string(),
279                description:
280                    "Codec: \"pcmu\" (PT 0, µ-law) or \"pcma\" (PT 8, A-law). Default pcmu."
281                        .to_string(),
282                required: false,
283            },
284            Parameter {
285                name: "content".to_string(),
286                type_hint: "string".to_string(),
287                description: "\"tone\", \"dtmf\", \"silence\", or \"raw\". Default tone."
288                    .to_string(),
289                required: false,
290            },
291            Parameter {
292                name: "tone_hz".to_string(),
293                type_hint: "number".to_string(),
294                description: "Tone frequency in Hz (20-3800) when content=tone. Default 440."
295                    .to_string(),
296                required: false,
297            },
298            Parameter {
299                name: "digits".to_string(),
300                type_hint: "string".to_string(),
301                description: "DTMF digits (0-9,*,#,A-D) when content=dtmf.".to_string(),
302                required: false,
303            },
304            Parameter {
305                name: "duration_ms".to_string(),
306                type_hint: "number".to_string(),
307                description: "Stream length in milliseconds (1-30000). Default 1000. Ignored \
308                              for content=dtmf, whose length is 200 ms per digit, and for \
309                              content=raw, whose length is the decoded payload; both are held \
310                              to the same 30 s ceiling."
311                    .to_string(),
312                required: false,
313            },
314            Parameter {
315                name: "encoding".to_string(),
316                type_hint: "string".to_string(),
317                description: "For content=raw only: must be \"hex\". The server hex-decodes \
318                              `samples` into codec bytes for real (never base64, never sniffed)."
319                    .to_string(),
320                required: false,
321            },
322            Parameter {
323                name: "samples".to_string(),
324                type_hint: "string".to_string(),
325                description:
326                    "For content=raw only: hex-encoded, already codec-encoded payload bytes."
327                        .to_string(),
328                required: false,
329            },
330            Parameter {
331                name: "ssrc".to_string(),
332                type_hint: "number".to_string(),
333                description: "Synchronization source id (32-bit). Omit for a random one."
334                    .to_string(),
335                required: false,
336            },
337            // Both are read by the executor and handed to RtpPacketizer, but were declared
338            // nowhere - so a model continuing an existing stream could not keep its numbering
339            // continuous, and every call restarted the sequence and timestamp.
340            Parameter {
341                name: "start_sequence".to_string(),
342                type_hint: "number".to_string(),
343                description: "RTP sequence number for the first packet (16-bit). Omit to                     continue from a random start; set it to resume an existing stream without                     a gap."
344                    .to_string(),
345                required: false,
346            },
347            Parameter {
348                name: "start_timestamp".to_string(),
349                type_hint: "number".to_string(),
350                description: "RTP timestamp for the first packet (32-bit, 8kHz for G.711).                     Omit for a random start; set it to keep a resumed stream's clock                     continuous."
351                    .to_string(),
352                required: false,
353            },
354        ],
355        example: json!({
356            "type": "send_rtp_audio",
357            "payload_type": "pcmu",
358            "content": "tone",
359            "tone_hz": 440,
360            "duration_ms": 2000,
361            "ssrc": 305419896
362        }),
363        log_template: Some(
364            LogTemplate::new()
365                .with_info("→ RTP {content} {payload_type} {duration_ms}ms")
366                .with_debug("RTP send_rtp_audio content={content} pt={payload_type}"),
367        ),
368    }
369}
370
371pub fn send_rtcp_sender_report_action() -> ActionDefinition {
372    ActionDefinition {
373        name: "send_rtcp_sender_report".to_string(),
374        description: "Send a minimal RTCP Sender Report (RFC 3550 §6.4.1) to the caller."
375            .to_string(),
376        parameters: vec![
377            Parameter {
378                name: "ssrc".to_string(),
379                type_hint: "number".to_string(),
380                description: "Sender SSRC. Omit for a random one.".to_string(),
381                required: false,
382            },
383            Parameter {
384                name: "rtp_timestamp".to_string(),
385                type_hint: "number".to_string(),
386                description: "RTP timestamp reported in the SR. Default 0.".to_string(),
387                required: false,
388            },
389            Parameter {
390                name: "packet_count".to_string(),
391                type_hint: "number".to_string(),
392                description: "Sender's cumulative packet count. Default 0.".to_string(),
393                required: false,
394            },
395            Parameter {
396                name: "octet_count".to_string(),
397                type_hint: "number".to_string(),
398                description: "Sender's cumulative payload octet count. Default 0.".to_string(),
399                required: false,
400            },
401        ],
402        example: json!({
403            "type": "send_rtcp_sender_report",
404            "packet_count": 50,
405            "octet_count": 8000
406        }),
407        log_template: Some(
408            LogTemplate::new()
409                .with_info("→ RTCP SR")
410                .with_debug("RTP send_rtcp_sender_report packets={packet_count}"),
411        ),
412    }
413}