dstar_gateway_server/tokio_shell/
transcode.rs

1//! Cross-protocol voice-frame transcoding.
2//!
3//! When `cross_protocol_forwarding` is enabled in `ReflectorConfig`,
4//! a voice frame received on one protocol's endpoint may need to be
5//! re-broadcast on a different protocol's endpoint. This module owns
6//! the re-encoding step and the `CrossProtocolEvent` broadcast
7//! envelope the `Reflector` uses to plumb events between endpoints.
8//!
9//! The transcoding is NOT loss-free across all protocol/frame type
10//! combinations. Specifically:
11//!
12//! - `DPlus`/`DExtra` header frames encode the same 41-byte D-STAR
13//!   header and can be copied directly after protocol-specific
14//!   framing.
15//! - `DCS` voice frames include the D-STAR header embedded in every
16//!   packet — when transcoding `DCS` → `DPlus`/`DExtra`, the
17//!   endpoint emits a synthetic header frame on the first `DCS`
18//!   packet of a stream and data frames for subsequent packets.
19//! - `DPlus`/`DExtra` → `DCS` requires building a 100-byte DCS
20//!   voice frame for every data packet, with the cached header
21//!   embedded.
22//!
23//! The function is deliberately sans-io: it only touches the
24//! caller-supplied scratch buffer and reads from the caller-supplied
25//! `VoiceEvent` / cached header. The caller is responsible for
26//! maintaining the `StreamCache` that holds cached headers across
27//! voice-data packets.
28
29use std::net::SocketAddr;
30
31use dstar_gateway_core::EncodeError;
32use dstar_gateway_core::codec::{dcs, dextra, dplus};
33use dstar_gateway_core::header::DStarHeader;
34use dstar_gateway_core::types::{Module, ProtocolKind, StreamId};
35use dstar_gateway_core::voice::VoiceFrame;
36
37/// Broadcast envelope pushed onto the `Reflector`'s cross-protocol
38/// voice bus when `cross_protocol_forwarding` is enabled.
39///
40/// Each inbound voice frame on any endpoint produces one
41/// `CrossProtocolEvent`. Every other endpoint subscribes to the
42/// broadcast and re-encodes the event via [`transcode_voice`] in
43/// its own protocol before sending to its module members.
44#[derive(Debug, Clone)]
45pub struct CrossProtocolEvent {
46    /// Which protocol the source endpoint speaks.
47    pub source_protocol: ProtocolKind,
48    /// Which peer originated the frame (filtered out of the
49    /// recipient list on all endpoints, same as within-protocol
50    /// fan-out).
51    pub source_peer: SocketAddr,
52    /// Which module the originator is on.
53    pub module: Module,
54    /// The decoded voice event.
55    pub event: VoiceEvent,
56    /// The cached header for this stream, if any. Required for
57    /// `DCS` transcoding but optional for `DPlus`/`DExtra`.
58    pub cached_header: Option<DStarHeader>,
59}
60
61/// A decoded voice event ready for cross-protocol fan-out.
62///
63/// The endpoint's inbound path decodes a raw datagram and produces
64/// one of these variants for each frame it wants to broadcast to
65/// peers on other protocols. The same variant is re-encoded by
66/// [`transcode_voice`] into each destination protocol's wire format.
67#[derive(Debug, Clone)]
68pub enum VoiceEvent {
69    /// Start of a stream. Requires the D-STAR header.
70    StreamStart {
71        /// The D-STAR header for this stream.
72        header: DStarHeader,
73        /// The stream id.
74        stream_id: StreamId,
75    },
76    /// Middle-of-stream voice frame.
77    Frame {
78        /// The stream id.
79        stream_id: StreamId,
80        /// Frame sequence number.
81        seq: u8,
82        /// 9 bytes AMBE + 3 bytes slow data.
83        frame: VoiceFrame,
84    },
85    /// End of stream.
86    StreamEnd {
87        /// The stream id.
88        stream_id: StreamId,
89        /// Final seq value.
90        seq: u8,
91    },
92}
93
94/// Errors returned by [`transcode_voice`].
95#[non_exhaustive]
96#[derive(Debug, thiserror::Error)]
97pub enum TranscodeError {
98    /// Underlying encoder rejected the buffer.
99    #[error("encode failed: {0}")]
100    Encode(#[from] EncodeError),
101    /// `DCS` encoding requires the header to be cached on every
102    /// frame (including `Frame` and `StreamEnd` variants) because
103    /// every `DCS` voice packet embeds the header. The caller must
104    /// pass `Some` for every `DCS` transcode.
105    #[error("cached header is required for DCS transcoding but was None")]
106    MissingCachedHeader,
107}
108
109/// Encode a voice event in the given target protocol's wire format.
110///
111/// For `DCS`, `cached_header` must be `Some` on stream-start AND on
112/// every subsequent frame (because each `DCS` voice packet embeds
113/// the header). For `DPlus` and `DExtra`, `cached_header` is only
114/// consulted on the header frame itself (and on `StreamEnd` where
115/// the downstream protocol still needs the header for its own
116/// framing on some encoders — currently unused, but kept for
117/// symmetry).
118///
119/// Returns the number of bytes written into `out`.
120///
121/// # Errors
122///
123/// - [`TranscodeError::Encode`] wrapping [`EncodeError::BufferTooSmall`]
124///   if `out` is too small for the chosen target protocol's packet
125///   size.
126/// - [`TranscodeError::MissingCachedHeader`] if `target == Dcs` and
127///   `cached_header` is `None`.
128pub fn transcode_voice(
129    target: ProtocolKind,
130    event: &VoiceEvent,
131    cached_header: Option<&DStarHeader>,
132    out: &mut [u8],
133) -> Result<usize, TranscodeError> {
134    // `ProtocolKind` is `#[non_exhaustive]`; cover every known
135    // variant explicitly and fall through to `BufferTooSmall` with
136    // a zero-byte write for any hypothetical future variant we
137    // don't yet know how to encode.
138    match target {
139        ProtocolKind::DExtra => transcode_dextra(event, out),
140        ProtocolKind::DPlus => transcode_dplus(event, out),
141        ProtocolKind::Dcs => transcode_dcs(event, cached_header, out),
142        _ => Err(TranscodeError::Encode(EncodeError::BufferTooSmall {
143            need: 0,
144            have: 0,
145        })),
146    }
147}
148
149fn transcode_dextra(event: &VoiceEvent, out: &mut [u8]) -> Result<usize, TranscodeError> {
150    match event {
151        VoiceEvent::StreamStart { header, stream_id } => {
152            let n = dextra::encode_voice_header(out, *stream_id, header)?;
153            Ok(n)
154        }
155        VoiceEvent::Frame {
156            stream_id,
157            seq,
158            frame,
159        } => {
160            let n = dextra::encode_voice_data(out, *stream_id, *seq, frame)?;
161            Ok(n)
162        }
163        VoiceEvent::StreamEnd { stream_id, seq } => {
164            let n = dextra::encode_voice_eot(out, *stream_id, *seq)?;
165            Ok(n)
166        }
167    }
168}
169
170fn transcode_dplus(event: &VoiceEvent, out: &mut [u8]) -> Result<usize, TranscodeError> {
171    match event {
172        VoiceEvent::StreamStart { header, stream_id } => {
173            let n = dplus::encode_voice_header(out, *stream_id, header)?;
174            Ok(n)
175        }
176        VoiceEvent::Frame {
177            stream_id,
178            seq,
179            frame,
180        } => {
181            let n = dplus::encode_voice_data(out, *stream_id, *seq, frame)?;
182            Ok(n)
183        }
184        VoiceEvent::StreamEnd { stream_id, seq } => {
185            let n = dplus::encode_voice_eot(out, *stream_id, *seq)?;
186            Ok(n)
187        }
188    }
189}
190
191fn transcode_dcs(
192    event: &VoiceEvent,
193    cached_header: Option<&DStarHeader>,
194    out: &mut [u8],
195) -> Result<usize, TranscodeError> {
196    // DCS is special — every voice packet embeds the header, so
197    // `cached_header` is required for every `VoiceEvent` variant.
198    let header = cached_header.ok_or(TranscodeError::MissingCachedHeader)?;
199    match event {
200        VoiceEvent::StreamStart { stream_id, .. } => {
201            // Build a "first frame" packet. We use silence payload
202            // because the header packet itself on `DCS` doesn't
203            // carry an AMBE frame — the stream starts with a
204            // regular voice frame. The caller that converts a
205            // `StreamStart` into a DCS packet should really follow
206            // it with a real frame; this branch exists so the
207            // transcoder is total over the variant set.
208            let frame = VoiceFrame::silence();
209            let n = dcs::encode_voice(out, header, *stream_id, 0, &frame, false)?;
210            Ok(n)
211        }
212        VoiceEvent::Frame {
213            stream_id,
214            seq,
215            frame,
216        } => {
217            let n = dcs::encode_voice(out, header, *stream_id, *seq, frame, false)?;
218            Ok(n)
219        }
220        VoiceEvent::StreamEnd { stream_id, seq } => {
221            // DCS EOT is signaled in the slow_data bytes — the
222            // encoder handles that when `is_end = true`. We pass a
223            // silence frame because the EOT packet's AMBE payload
224            // is conventionally silence.
225            let frame = VoiceFrame::silence();
226            let n = dcs::encode_voice(out, header, *stream_id, *seq, &frame, true)?;
227            Ok(n)
228        }
229    }
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235    use dstar_gateway_core::types::{Callsign, Suffix};
236
237    const fn sid() -> StreamId {
238        match StreamId::new(0xCAFE) {
239            Some(s) => s,
240            None => unreachable!(),
241        }
242    }
243
244    fn test_header() -> DStarHeader {
245        DStarHeader {
246            flag1: 0,
247            flag2: 0,
248            flag3: 0,
249            rpt2: Callsign::from_wire_bytes(*b"REF030 G"),
250            rpt1: Callsign::from_wire_bytes(*b"REF030 C"),
251            ur_call: Callsign::from_wire_bytes(*b"CQCQCQ  "),
252            my_call: Callsign::from_wire_bytes(*b"W1AW    "),
253            my_suffix: Suffix::EMPTY,
254        }
255    }
256
257    fn test_frame() -> VoiceFrame {
258        VoiceFrame {
259            ambe: [0x11; 9],
260            slow_data: [0x22; 3],
261        }
262    }
263
264    type TestResult = Result<(), Box<dyn std::error::Error>>;
265
266    #[test]
267    fn transcode_dextra_to_dplus_header_matches_reference_encoding() -> TestResult {
268        let header = test_header();
269        let event = VoiceEvent::StreamStart {
270            header,
271            stream_id: sid(),
272        };
273        let mut out = [0u8; 128];
274        let n = transcode_voice(ProtocolKind::DPlus, &event, Some(&header), &mut out)?;
275        assert_eq!(n, 58, "DPlus voice header is 58 bytes");
276        // Sanity-check: the output matches the direct DPlus encoder
277        // called with the same inputs.
278        let mut reference = [0u8; 128];
279        let m = dplus::encode_voice_header(&mut reference, sid(), &header)?;
280        assert_eq!(m, n);
281        assert_eq!(&out[..n], &reference[..m]);
282        Ok(())
283    }
284
285    #[test]
286    fn transcode_dplus_to_dcs_requires_cached_header() {
287        let event = VoiceEvent::Frame {
288            stream_id: sid(),
289            seq: 3,
290            frame: test_frame(),
291        };
292        let mut out = [0u8; 128];
293        let result = transcode_voice(ProtocolKind::Dcs, &event, None, &mut out);
294        assert!(
295            matches!(result, Err(TranscodeError::MissingCachedHeader)),
296            "expected MissingCachedHeader, got {result:?}"
297        );
298    }
299
300    #[test]
301    fn transcode_dcs_to_dextra_frame_uses_same_ambe_bytes() -> TestResult {
302        let frame = test_frame();
303        let event = VoiceEvent::Frame {
304            stream_id: sid(),
305            seq: 5,
306            frame,
307        };
308        let mut out = [0u8; 128];
309        let n = transcode_voice(ProtocolKind::DExtra, &event, None, &mut out)?;
310        assert_eq!(n, 27, "DExtra voice data is 27 bytes");
311        // AMBE bytes live at [15..24] in DExtra voice data.
312        assert_eq!(&out[15..24], &frame.ambe);
313        // Slow data at [24..27].
314        assert_eq!(&out[24..27], &frame.slow_data);
315        // Seq at [14].
316        assert_eq!(out[14], 5);
317        Ok(())
318    }
319
320    #[test]
321    fn transcode_dplus_to_dextra_frame_preserves_seq_and_stream_id() -> TestResult {
322        let frame = test_frame();
323        let event = VoiceEvent::Frame {
324            stream_id: sid(),
325            seq: 7,
326            frame,
327        };
328        let mut out = [0u8; 128];
329        let n = transcode_voice(ProtocolKind::DExtra, &event, None, &mut out)?;
330        assert_eq!(n, 27);
331        // Stream id at [12..14] little-endian.
332        assert_eq!(out[12], 0xFE);
333        assert_eq!(out[13], 0xCA);
334        assert_eq!(out[14], 7);
335        Ok(())
336    }
337
338    #[test]
339    fn transcode_dextra_to_dcs_frame_embeds_cached_header() -> TestResult {
340        let header = test_header();
341        let frame = test_frame();
342        let event = VoiceEvent::Frame {
343            stream_id: sid(),
344            seq: 4,
345            frame,
346        };
347        let mut out = [0u8; 128];
348        let n = transcode_voice(ProtocolKind::Dcs, &event, Some(&header), &mut out)?;
349        assert_eq!(n, 100, "DCS voice is 100 bytes");
350        // Magic at [0..4].
351        assert_eq!(&out[..4], b"0001");
352        // MY callsign at [31..39].
353        assert_eq!(&out[31..39], header.my_call.as_bytes());
354        // Stream id at [43..45] little-endian.
355        assert_eq!(out[43], 0xFE);
356        assert_eq!(out[44], 0xCA);
357        // Seq at [45].
358        assert_eq!(out[45], 4);
359        // AMBE at [46..55].
360        assert_eq!(&out[46..55], &frame.ambe);
361        Ok(())
362    }
363
364    #[test]
365    fn transcode_dextra_eot_has_0x40_bit_set() -> TestResult {
366        let event = VoiceEvent::StreamEnd {
367            stream_id: sid(),
368            seq: 20,
369        };
370        let mut out = [0u8; 128];
371        let n = transcode_voice(ProtocolKind::DExtra, &event, None, &mut out)?;
372        assert_eq!(n, 27);
373        assert_eq!(out[14] & 0x40, 0x40, "EOT bit set on seq byte");
374        Ok(())
375    }
376
377    #[test]
378    fn transcode_buffer_too_small_is_error() {
379        let event = VoiceEvent::Frame {
380            stream_id: sid(),
381            seq: 1,
382            frame: test_frame(),
383        };
384        let mut out = [0u8; 4];
385        let result = transcode_voice(ProtocolKind::DExtra, &event, None, &mut out);
386        assert!(
387            matches!(result, Err(TranscodeError::Encode(_))),
388            "expected Encode error, got {result:?}"
389        );
390    }
391}