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}