Skip to main content

rusty_esp_audio_core/
lib.rs

1#![cfg_attr(not(feature = "std"), no_std)]
2#![forbid(unsafe_code)]
3//! `rusty_esp_audio-core` — the pure heart of `rusty_esp_audio`.
4//!
5//! ESP-ADF's `audio_pipeline` / `audio_element` / `ringbuf`, `esp_audio_codec`'s
6//! PCM/ADPCM framing and the lite front end of ESP-SR, remade as fixed-block
7//! elements over caller-owned memory.
8//!
9//! Rules this crate lives by (from the Janus mission plan):
10//!
11//! 1. `no_std` by default; `alloc` is a feature, never an assumption.
12//! 2. No drivers, no HAL types, no `esp-*` crate, no allocator. Backends live
13//!    in `rusty_esp_audio-esp`.
14//! 3. Every type that crosses to another Janus package comes from
15//!    `rusty_esp_core`, so packages compose without conversions.
16//! 4. Blocks and buffers are **borrowed over caller-owned memory**; no element
17//!    allocates, ever — not even at construction.
18//! 5. `forbid(unsafe)`. The scalar path is the oracle; any faster path is gated
19//!    byte-identical against it.
20//!
21//! Layout:
22//!
23//! | module | contents |
24//! |---|---|
25//! | [`source`] | `AudioSource` / `AudioSink`, a test tone, a counting sink |
26//! | [`ring`] | `RingBuffer`: whole-frame SPSC ring over a caller slice |
27//! | [`pipeline`] | `Element` and `Pipeline<N>`: the fixed-block graph |
28//! | [`elements`] | gain, DC block, RBJ biquads, AGC, energy VAD, channel ops, linear resampler, format conversion |
29//! | [`codec`] | `pcm` conversions, `adpcm_ima` (IMA ADPCM, WAV layout), `wav` headers |
30//! | [`chip`] | codec chips as register data over `embedded-hal` I²C: `es8311` (ADC + DAC), `es7210` (4-channel ADC) |
31
32#[cfg(feature = "alloc")]
33extern crate alloc;
34
35pub use rusty_esp_core as esp_core;
36
37pub mod chip;
38pub mod codec;
39pub mod elements;
40pub mod pipeline;
41pub mod ring;
42pub mod source;
43
44pub use pipeline::{Element, Pipeline};
45pub use ring::RingBuffer;
46pub use source::{AudioSink, AudioSource};
47
48/// The names a sketch or firmware wants in scope.
49pub mod prelude {
50    pub use rusty_esp_core::prelude::*;
51
52    pub use crate::codec::pcm::convert as convert_pcm;
53    pub use crate::elements::{
54        Agc, AgcConfig, Biquad, BiquadKind, Convert, DcBlock, EnergyVad, Gain, LinearResampler,
55        MonoToStereo, StereoToMono, VadConfig,
56    };
57    pub use crate::pipeline::{Element, Pipeline};
58    pub use crate::ring::RingBuffer;
59    pub use crate::source::{AudioSink, AudioSource, CountingSink, SineSource};
60}
61
62/// Crate version, for capability manifests and logs.
63pub const VERSION: &str = env!("CARGO_PKG_VERSION");
64
65/// Level of an interleaved i16 block in dBFS — `rusty_esp_dsp`'s reduction
66/// (moved there in D0, 2026-09-02), at the path this crate always had.
67#[cfg(not(feature = "pie-s3"))]
68pub use rusty_esp_dsp::sample::{peak_abs_i16, rms_dbfs_i16};
69
70#[cfg(feature = "pie-s3")]
71pub use pie::{peak_abs_i16, rms_dbfs_i16};
72
73/// The chip twins of the reductions this crate re-exports.
74///
75/// `rms_dbfs_i16` is the one that matters: the shipping PDM firmware calls
76/// it once per captured block, right after `Pipeline::process`, and until
77/// this module existed it ran the scalar while a measured −79.6% twin sat
78/// unreachable in `rusty_esp_dsp-esp`.
79///
80/// Each function is `cfg`-switched on the TARGET, not on the feature, so a
81/// host build of this crate with `pie-s3` on still runs the oracle and the
82/// tests still mean what they say.
83#[cfg(feature = "pie-s3")]
84pub(crate) mod pie {
85    /// Level of an interleaved i16 block in dBFS. See
86    /// [`rusty_esp_dsp::sample::rms_dbfs_i16`], which this is gated against.
87    #[must_use]
88    pub fn rms_dbfs_i16(samples: &[u8]) -> f32 {
89        #[cfg(target_arch = "xtensa")]
90        {
91            rusty_esp_dsp_esp::pie_s3::rms_dbfs_i16(samples)
92        }
93        #[cfg(not(target_arch = "xtensa"))]
94        {
95            rusty_esp_dsp::sample::rms_dbfs_i16(samples)
96        }
97    }
98
99    /// Largest magnitude in an i16 block. See
100    /// [`rusty_esp_dsp::sample::peak_abs_i16`], which this is gated against.
101    #[must_use]
102    pub fn peak_abs_i16(a: &[i16]) -> u16 {
103        #[cfg(target_arch = "xtensa")]
104        {
105            rusty_esp_dsp_esp::pie_s3::peak_abs_i16(a)
106        }
107        #[cfg(not(target_arch = "xtensa"))]
108        {
109            rusty_esp_dsp::sample::peak_abs_i16(a)
110        }
111    }
112
113    // ---- the element helpers -----------------------------------------
114    //
115    // Each returns `true` when it handled the block. The `pie-s3`-off twin
116    // of each returns `false` from a `const`-foldable body, so the caller's
117    // scalar arm is never dead code and no `unreachable_code` lint fires.
118
119    /// Mono to stereo. [`crate::elements::MonoToStereo`] is the oracle.
120    pub fn mono_to_stereo(src: &[i16], dst: &mut [i16]) -> bool {
121        #[cfg(target_arch = "xtensa")]
122        {
123            rusty_esp_dsp_esp::pie_s3::mono_to_stereo_i16(src, dst);
124            true
125        }
126        #[cfg(not(target_arch = "xtensa"))]
127        {
128            let _ = (src, dst);
129            false
130        }
131    }
132
133    /// Stereo to mono. [`crate::elements::StereoToMono`] is the oracle.
134    pub fn stereo_to_mono(src: &[i16], dst: &mut [i16]) -> bool {
135        #[cfg(target_arch = "xtensa")]
136        {
137            rusty_esp_dsp_esp::pie_s3::stereo_to_mono_i16(src, dst);
138            true
139        }
140        #[cfg(not(target_arch = "xtensa"))]
141        {
142            let _ = (src, dst);
143            false
144        }
145    }
146
147    /// Saturating sum of two i16 streams. [`crate::elements::mix_i16`] is
148    /// the oracle.
149    pub fn mix(a: &[i16], b: &[i16], out: &mut [i16]) -> bool {
150        #[cfg(target_arch = "xtensa")]
151        {
152            rusty_esp_dsp_esp::pie_s3::mix_i16(a, b, out);
153            true
154        }
155        #[cfg(not(target_arch = "xtensa"))]
156        {
157            let _ = (a, b, out);
158            false
159        }
160    }
161
162    /// The three INTEGER `convert` pairs. The element's own fast arms are
163    /// i16<->f32, which the PIE unit cannot touch -- it is integer-only --
164    /// and the integer pairs went through the generic per-sample table.
165    ///
166    /// Two of the three need no arithmetic once the little-endian layout is
167    /// taken seriously: `(x as i32) << 16` is `x` interleaved with a zero
168    /// halfword below it, and `(x >> 16) as i16` is the high halfword.
169    ///
170    /// Returns the byte count it wrote, or `None` for a pair it does not
171    /// handle or a buffer that does not view as samples.
172    pub fn convert(
173        input: &rusty_esp_core::pcm::PcmBlock<'_>,
174        to: rusty_esp_core::pcm::SampleFormat,
175        out: &mut [u8],
176    ) -> Option<usize> {
177        #[cfg(target_arch = "xtensa")]
178        {
179            use rusty_esp_core::pcm::SampleFormat::{I16, I24In32, I32};
180            use rusty_esp_core::pcm::{as_i16, as_i16_mut, as_i32, as_i32_mut};
181            let from = input.format.sample;
182            let n = rusty_esp_dsp::sample::pcm::output_bytes(from, to, input.data.len());
183            if out.len() < n {
184                return None;
185            }
186            match (from, to) {
187                (I16, I32) | (I16, I24In32) => {
188                    let si = as_i16(input.data)?;
189                    let so = as_i32_mut(&mut out[..n])?;
190                    rusty_esp_dsp_esp::pie_s3::convert_i16_to_i32(si, so);
191                    Some(n)
192                }
193                (I32, I16) | (I24In32, I16) => {
194                    let si = as_i32(input.data)?;
195                    let so = as_i16_mut(&mut out[..n])?;
196                    rusty_esp_dsp_esp::pie_s3::convert_i32_to_i16(si, so);
197                    Some(n)
198                }
199                (I32, I24In32) => {
200                    let si = as_i32(input.data)?;
201                    let so = as_i32_mut(&mut out[..n])?;
202                    rusty_esp_dsp_esp::pie_s3::convert_i32_to_i24in32(si, so);
203                    Some(n)
204                }
205                _ => None,
206            }
207        }
208        #[cfg(not(target_arch = "xtensa"))]
209        {
210            let _ = (input, to, out);
211            None
212        }
213    }
214
215    /// `(x * q15 + (1 << 14)) >> 15`, clamped. The twin restricts itself to
216    /// `|q15| <= 32767` and hands anything louder back, so the caller must
217    /// keep its own wide path.
218    pub fn gain(src: &[i16], q15: i32, dst: &mut [i16]) -> bool {
219        #[cfg(target_arch = "xtensa")]
220        {
221            if q15.unsigned_abs() <= 32767 {
222                rusty_esp_dsp_esp::pie_s3::gain_i16(src, q15, dst);
223                return true;
224            }
225            false
226        }
227        #[cfg(not(target_arch = "xtensa"))]
228        {
229            let _ = (src, q15, dst);
230            false
231        }
232    }
233}
234
235/// The `pie-s3`-off twin of [`pie`]: every helper declines, so each element
236/// takes the scalar arm it already had.
237#[cfg(not(feature = "pie-s3"))]
238pub(crate) mod pie {
239    pub fn mono_to_stereo(_: &[i16], _: &mut [i16]) -> bool {
240        false
241    }
242    pub fn stereo_to_mono(_: &[i16], _: &mut [i16]) -> bool {
243        false
244    }
245    pub fn mix(_: &[i16], _: &[i16], _: &mut [i16]) -> bool {
246        false
247    }
248    pub fn gain(_: &[i16], _: i32, _: &mut [i16]) -> bool {
249        false
250    }
251    pub fn convert(
252        _: &rusty_esp_core::pcm::PcmBlock<'_>,
253        _: rusty_esp_core::pcm::SampleFormat,
254        _: &mut [u8],
255    ) -> Option<usize> {
256        None
257    }
258}
259
260/// Write an `i16` sample as two little-endian bytes.
261#[inline]
262pub(crate) fn put_i16(out: &mut [u8], v: i16) {
263    let b = v.to_le_bytes();
264    out[0] = b[0];
265    out[1] = b[1];
266}
267
268/// Read a little-endian `i16`.
269#[inline]
270pub(crate) fn get_i16(b: &[u8]) -> i16 {
271    i16::from_le_bytes([b[0], b[1]])
272}
273
274/// Saturate an `i32` into `i16`.
275#[inline]
276pub(crate) fn sat16(v: i32) -> i16 {
277    v.clamp(i32::from(i16::MIN), i32::from(i16::MAX)) as i16
278}
279
280/// Round an `f32` to the nearest `i16`, ties away from zero, saturating.
281#[inline]
282pub(crate) fn round_sat16(v: f32) -> i16 {
283    // One definition, in the crate every element already speaks. See
284    // `rusty_esp_core::pcm::round_sat_i16` for why it truncates once and why
285    // its final conversion is unchecked.
286    rusty_esp_core::pcm::round_sat_i16(v)
287}
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292
293    #[test]
294    fn rounding_and_saturation() {
295        assert_eq!(round_sat16(0.4), 0);
296        assert_eq!(round_sat16(0.5), 1);
297        assert_eq!(round_sat16(-0.5), -1);
298        assert_eq!(round_sat16(40000.0), i16::MAX);
299        assert_eq!(round_sat16(-40000.0), i16::MIN);
300        assert_eq!(sat16(70000), i16::MAX);
301        assert_eq!(sat16(-70000), i16::MIN);
302    }
303}