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}