stargazer/tier3/
decoder.rs

1//! AMBE-to-PCM-to-MP3 audio decode pipeline.
2//!
3//! D-STAR voice transmissions encode speech using the AMBE 3600x2450 codec
4//! at 3600 bits/second (2450 bps voice + 1150 bps FEC). Each voice frame is
5//! 9 bytes, transmitted at 50 frames/second (one every 20 ms). The decoder
6//! pipeline converts these compressed frames into standard MP3 audio:
7//!
8//! ```text
9//! [u8; 9] x N           mbelib-rs           mp3lame-encoder
10//! AMBE frames  ------>  PCM i16 @ 8 kHz  ------>  MP3 bytes
11//!              decode_frame()             encode + flush
12//! ```
13//!
14//! ## Pipeline stages
15//!
16//! 1. **AMBE decode** (`mbelib_rs::AmbeDecoder`): Each 9-byte AMBE frame is
17//!    decoded into 160 signed 16-bit PCM samples at 8000 Hz (20 ms of audio).
18//!    The decoder carries inter-frame state for delta prediction and
19//!    phase-continuous synthesis, so frames must be fed sequentially.
20//!
21//! 2. **PCM accumulation**: All 160-sample chunks are concatenated into a
22//!    single `Vec<i16>` buffer. For a typical 3-second D-STAR transmission
23//!    (150 frames), this is 24,000 samples.
24//!
25//! 3. **MP3 encoding** (`mp3lame_encoder`): The accumulated PCM buffer is
26//!    encoded in one pass using LAME configured for mono, 8000 Hz input
27//!    sample rate, CBR at the requested bitrate. A final flush writes any
28//!    remaining LAME internal buffer to complete the MP3 stream.
29
30use mp3lame_encoder::{Bitrate, FlushGap, MonoPcm, Quality};
31
32/// Errors that can occur during the AMBE-to-MP3 decode pipeline.
33///
34/// Wraps the two external library error types (`mp3lame_encoder::BuildError`
35/// and `mp3lame_encoder::EncodeError`) into a single enum so that callers
36/// only need to handle one error type from [`decode_to_mp3`].
37#[derive(Debug)]
38pub(crate) enum DecodeError {
39    /// No AMBE frames were provided — nothing to decode.
40    EmptyInput,
41
42    /// The requested bitrate does not correspond to a valid LAME CBR
43    /// bitrate. Valid values: 8, 16, 24, 32, 40, 48, 64, 80, 96, 112,
44    /// 128, 160, 192, 224, 256, 320 (kbps).
45    UnsupportedBitrate(u32),
46
47    /// Failed to initialize the LAME MP3 encoder (memory allocation
48    /// failure or invalid parameter combination).
49    EncoderInit(mp3lame_encoder::BuildError),
50
51    /// Failed during MP3 encoding of the PCM buffer.
52    Encode(mp3lame_encoder::EncodeError),
53}
54
55impl std::fmt::Display for DecodeError {
56    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
57        match self {
58            Self::EmptyInput => f.write_str("no AMBE frames to decode"),
59            Self::UnsupportedBitrate(br) => {
60                write!(f, "unsupported MP3 bitrate: {br} kbps")
61            }
62            Self::EncoderInit(e) => write!(f, "LAME encoder init failed: {e}"),
63            Self::Encode(e) => write!(f, "MP3 encoding failed: {e}"),
64        }
65    }
66}
67
68impl std::error::Error for DecodeError {
69    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
70        match self {
71            Self::EncoderInit(e) => Some(e),
72            Self::Encode(e) => Some(e),
73            Self::EmptyInput | Self::UnsupportedBitrate(_) => None,
74        }
75    }
76}
77
78impl From<mp3lame_encoder::BuildError> for DecodeError {
79    fn from(e: mp3lame_encoder::BuildError) -> Self {
80        Self::EncoderInit(e)
81    }
82}
83
84impl From<mp3lame_encoder::EncodeError> for DecodeError {
85    fn from(e: mp3lame_encoder::EncodeError) -> Self {
86        Self::Encode(e)
87    }
88}
89
90/// Decodes a sequence of 9-byte AMBE voice frames into an MP3 byte buffer.
91///
92/// # Pipeline
93///
94/// 1. Creates an `mbelib_rs::AmbeDecoder` (one per call — stateful across
95///    frames within the same stream).
96/// 2. Feeds each 9-byte AMBE frame through the decoder, collecting the
97///    resulting 160-sample PCM chunks into a contiguous `Vec<i16>`.
98/// 3. Configures a LAME MP3 encoder for mono 8000 Hz CBR at `bitrate` kbps.
99/// 4. Encodes the entire PCM buffer in one pass, then flushes to finalize
100///    the MP3 stream.
101///
102/// # Arguments
103///
104/// - `frames` — Ordered slice of 9-byte AMBE frames from a single D-STAR
105///   voice stream. Must be in receive order (the decoder uses inter-frame
106///   delta prediction).
107/// - `bitrate` — MP3 constant bitrate in kbps. Must match one of the LAME
108///   `Bitrate` enum values (8, 16, 24, 32, 40, 48, 64, 80, 96, 112, 128,
109///   160, 192, 224, 256, 320).
110///
111/// # Errors
112///
113/// - [`DecodeError::EmptyInput`] if `frames` is empty.
114/// - [`DecodeError::UnsupportedBitrate`] if `bitrate` is not a valid LAME value.
115/// - [`DecodeError::EncoderInit`] if LAME fails to initialize.
116/// - [`DecodeError::Encode`] if LAME fails during encoding or flushing.
117pub(crate) fn decode_to_mp3(frames: &[[u8; 9]], bitrate: u32) -> Result<Vec<u8>, DecodeError> {
118    if frames.is_empty() {
119        return Err(DecodeError::EmptyInput);
120    }
121
122    // Map the u32 bitrate to the mp3lame_encoder::Bitrate enum. LAME only
123    // supports specific CBR values; reject anything else early.
124    let lame_bitrate = bitrate_from_kbps(bitrate)?;
125
126    // --- Stage 1: AMBE decode ---
127    // Create a fresh decoder. Each stream needs its own decoder because
128    // AMBE uses inter-frame delta prediction (gain and spectral magnitudes
129    // are coded as deltas from the previous frame).
130    let mut ambe_decoder = mbelib_rs::AmbeDecoder::new();
131
132    // Pre-allocate the PCM buffer: 160 samples per frame.
133    let total_samples = frames.len() * 160;
134    let mut pcm_buffer: Vec<i16> = Vec::with_capacity(total_samples);
135
136    // Decode each AMBE frame into 160 PCM samples and append to the buffer.
137    for frame in frames {
138        let samples = ambe_decoder.decode_frame(frame);
139        pcm_buffer.extend_from_slice(&samples);
140    }
141
142    // --- Stage 2: MP3 encode ---
143    // Configure LAME for mono, 8000 Hz input (D-STAR native sample rate),
144    // CBR at the requested bitrate.
145    let mut builder = mp3lame_encoder::Builder::new().ok_or(mp3lame_encoder::BuildError::NoMem)?;
146    builder.set_num_channels(1)?;
147    builder.set_sample_rate(8000)?;
148    builder.set_brate(lame_bitrate)?;
149    builder.set_quality(Quality::Best)?;
150    let mut encoder = builder.build()?;
151
152    // Allocate the MP3 output buffer. The mp3lame_encoder crate provides a
153    // helper that computes a safe upper bound: samples * 1.25 + 7200.
154    let max_mp3_size = mp3lame_encoder::max_required_buffer_size(pcm_buffer.len());
155    let mut mp3_buffer: Vec<u8> = Vec::with_capacity(max_mp3_size);
156
157    // Encode the entire PCM buffer in one call. MonoPcm<i16> feeds a
158    // single-channel i16 buffer through lame_encode_buffer.
159    let _encoded = encoder.encode_to_vec(MonoPcm(pcm_buffer.as_slice()), &mut mp3_buffer)?;
160
161    // Flush any remaining data from LAME's internal buffers. FlushGap pads
162    // with zeros to complete the final MP3 frame.
163    let _flushed = encoder.flush_to_vec::<FlushGap>(&mut mp3_buffer)?;
164
165    Ok(mp3_buffer)
166}
167
168/// Maps a bitrate in kbps (u32) to the `mp3lame_encoder::Bitrate` enum.
169///
170/// Returns `Err(DecodeError::UnsupportedBitrate)` if the value doesn't
171/// correspond to any LAME CBR bitrate.
172const fn bitrate_from_kbps(kbps: u32) -> Result<Bitrate, DecodeError> {
173    match kbps {
174        8 => Ok(Bitrate::Kbps8),
175        16 => Ok(Bitrate::Kbps16),
176        24 => Ok(Bitrate::Kbps24),
177        32 => Ok(Bitrate::Kbps32),
178        40 => Ok(Bitrate::Kbps40),
179        48 => Ok(Bitrate::Kbps48),
180        64 => Ok(Bitrate::Kbps64),
181        80 => Ok(Bitrate::Kbps80),
182        96 => Ok(Bitrate::Kbps96),
183        112 => Ok(Bitrate::Kbps112),
184        128 => Ok(Bitrate::Kbps128),
185        160 => Ok(Bitrate::Kbps160),
186        192 => Ok(Bitrate::Kbps192),
187        224 => Ok(Bitrate::Kbps224),
188        256 => Ok(Bitrate::Kbps256),
189        320 => Ok(Bitrate::Kbps320),
190        other => Err(DecodeError::UnsupportedBitrate(other)),
191    }
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197
198    /// AMBE silence frame (same as `dstar_gateway_core::voice::AMBE_SILENCE`).
199    /// Decodes to near-zero PCM samples.
200    const AMBE_SILENCE: [u8; 9] = [0x9E, 0x8D, 0x32, 0x88, 0x26, 0x1A, 0x3F, 0x61, 0xE8];
201
202    #[test]
203    fn empty_input_returns_error() {
204        let result = decode_to_mp3(&[], 64);
205        assert!(
206            matches!(result, Err(DecodeError::EmptyInput)),
207            "expected EmptyInput, got {result:?}"
208        );
209    }
210
211    #[test]
212    fn unsupported_bitrate_returns_error() {
213        let frames = [AMBE_SILENCE];
214        let result = decode_to_mp3(&frames, 100);
215        assert!(
216            matches!(result, Err(DecodeError::UnsupportedBitrate(100))),
217            "expected UnsupportedBitrate(100), got {result:?}"
218        );
219    }
220
221    #[test]
222    fn decode_silence_frames_produces_valid_mp3() -> Result<(), Box<dyn std::error::Error>> {
223        // 50 frames = 1 second of silence.
224        let frames: Vec<[u8; 9]> = vec![AMBE_SILENCE; 50];
225        let mp3 = decode_to_mp3(&frames, 64)?;
226
227        // The output should be non-empty and start with an MP3 sync word
228        // or ID3 tag. LAME may prepend a Xing/Info VBR header even in CBR
229        // mode, so we just check that we got a substantial number of bytes.
230        assert!(
231            mp3.len() > 100,
232            "expected substantial MP3 output, got {} bytes",
233            mp3.len()
234        );
235        Ok(())
236    }
237
238    #[test]
239    fn bitrate_from_kbps_roundtrips_all_valid_values() {
240        let valid = [
241            8, 16, 24, 32, 40, 48, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320,
242        ];
243        for kbps in valid {
244            assert!(
245                bitrate_from_kbps(kbps).is_ok(),
246                "bitrate {kbps} should be valid"
247            );
248        }
249    }
250
251    #[test]
252    fn bitrate_from_kbps_rejects_invalid() {
253        for kbps in [0, 1, 7, 9, 15, 17, 33, 65, 100, 129, 321, 1000] {
254            assert!(
255                bitrate_from_kbps(kbps).is_err(),
256                "bitrate {kbps} should be rejected"
257            );
258        }
259    }
260}