├── .gitignore ├── rust-toolchain.toml ├── cliff.toml ├── Cargo.toml ├── .cargo └── config.toml ├── CHANGELOG.md ├── README.md └── src └── lib.rs /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | /Cargo.lock 3 | .embuild 4 | -------------------------------------------------------------------------------- /rust-toolchain.toml: -------------------------------------------------------------------------------- 1 | [toolchain] 2 | 3 | channel = "esp" 4 | # You may want to use nightly or stable for riscv targets 5 | -------------------------------------------------------------------------------- /cliff.toml: -------------------------------------------------------------------------------- 1 | # git-cliff ~ default configuration file 2 | # https://git-cliff.org/docs/configuration 3 | 4 | [git] 5 | filter_unconventional = false 6 | # filter out the commits that are not matched by commit parsers 7 | filter_commits = false 8 | 9 | commit_parsers = [ 10 | { message = "^feat", group = "Features" }, 11 | { message = "^fix", group = "Bug Fixes" }, 12 | { message = "^doc", group = "Documentation" }, 13 | { message = "^perf", group = "Performance" }, 14 | { message = "^refactor", group = "Refactor" }, 15 | { message = "^test", group = "🧪 Testing" }, 16 | { message = "^release", skip = true }, 17 | ] 18 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "awedio_esp32" 3 | version = "0.8.0" 4 | edition = "2021" 5 | authors = ["Ben Hansen "] 6 | description = "ESP32 backend for the awedio audio playback library" 7 | license = "MIT OR Apache-2.0" 8 | keywords = ["audio", "playback", "backend"] 9 | categories = ["multimedia::audio", "embedded"] 10 | repository = "https://github.com/boppofun/awedio_esp32" 11 | 12 | [dependencies] 13 | esp-idf-hal = { version = "0.45", default-features = false, features = ["native"] } 14 | awedio = { version = "0.6", default-features = false} 15 | 16 | [features] 17 | report-render-time = [] 18 | 19 | [package.metadata.docs.rs] 20 | default-target = "riscv32imc-esp-espidf" 21 | targets = [] 22 | cargo-args = ["-Z", "build-std"] 23 | -------------------------------------------------------------------------------- /.cargo/config.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | rustflags = "--cfg espidf_time64" 3 | 4 | # Uncomment the relevant target for your chip here (ESP32, ESP32-S2, ESP32-S3 or ESP32-C3) 5 | target = "xtensa-esp32-espidf" 6 | #target = "xtensa-esp32s2-espidf" 7 | #target = "xtensa-esp32s3-espidf" 8 | #target = "riscv32imc-esp-espidf" 9 | 10 | [target.xtensa-esp32-espidf] 11 | linker = "ldproxy" 12 | runner = "espflash --monitor" 13 | 14 | [target.xtensa-esp32s2-espidf] 15 | linker = "ldproxy" 16 | runner = "espflash --monitor" 17 | 18 | [target.xtensa-esp32s3-espidf] 19 | linker = "ldproxy" 20 | runner = "espflash --monitor" 21 | 22 | [target.riscv32imc-esp-espidf] 23 | linker = "ldproxy" 24 | runner = "espflash --monitor" 25 | 26 | [unstable] 27 | 28 | build-std = ["std", "panic_abort"] 29 | # Required for older ESP-IDF versions without a realpath implementation. 30 | # Enabling panic_immediate_abort may remove 100K+ from binary size but panic messages will not be printed. 31 | #build-std-features = ["panic_immediate_abort"] 32 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [0.8.0] - 2025-12-14 2 | 3 | ### Features 4 | 5 | - Preload samples when resuming audio channel 6 | - [**breaking**] Update awedio to v0.6 7 | 8 | ## [0.7.0] - 2025-11-17 9 | 10 | ### Bug Fixes 11 | 12 | - Report-render-time: reference Duration correctly 13 | 14 | ### Refactor 15 | 16 | - Use iterator instead of index when filling buffer 17 | 18 | ## [0.6.0] - 2025-06-02 19 | 20 | ### Features 21 | 22 | - [**breaking**] Update esp-idf-hal 23 | - Update for esp-idf v5.4 24 | - Add auto_disable_channel and on_channel_enable_change callback 25 | 26 | ### Documentation 27 | 28 | - Update github org to boppofun 29 | 30 | # Changelog 31 | 32 | ## Unreleased 33 | 34 | Unreleased changes, if any, can be listed using `git log` or `git cliff -u`. 35 | 36 | 37 | ## [0.5.0] - 2024-05-29 38 | 39 | ### Features 40 | 41 | - [**breaking**] Update to newest awedio allowing for custom BackendSource 42 | 43 | ### Documentation 44 | 45 | - Minor README.md update 46 | 47 | 48 | ## [0.4.1] - 2023-12-15 49 | 50 | ### Bug Fixes 51 | 52 | - Stack size was not being set properly 53 | 54 | ## [0.4.0] - 2023-12-08 55 | 56 | - [**breaking**] update awedio to v0.3.1 57 | 58 | ## [0.3.0] - 2023-11-14 59 | 60 | - refactor!: use thread spawn instead of creating a task 61 | - feat!: update to ESP-IDF v5 and new I2S API in hal 62 | - refactor!: do not set a default for num_frames_per_write 63 | - fix: use new name of library in task name 64 | - feat: make pinned_core_id public so Backend struct can be instantiated 65 | - feat: add report-render-time cargo feature 66 | - refactor: switch buffer from u8 to i16 67 | 68 | ## [0.2.0] - 2023-05-11 69 | 70 | - add comment to rust-toolchain 71 | - update README 72 | - update awedio to v0.2 73 | 74 | ## [0.1.2] - 2023-05-10 75 | 76 | - Add build-std to docs.rs metadata. 77 | 78 | ## [0.1.1] - 2023-05-10 79 | 80 | - Add docs.rs metadata for targets. 81 | 82 | ## [0.1.0] - 2023-05-10 83 | 84 | - Initial release 85 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Awedio ESP32   [![Latest Version]][crates.io] 2 | 3 | ESP32 I2S backend for the [awedio] audio playback library using ESP-IDF. 4 | Requires std and ESP-IDF v5. 5 | 6 | mp3 is supported but may not work well on ESPs without native floating point 7 | support. 8 | 9 | ## Setup 10 | 11 | The caller is responsible for setting up the I2S driver before calling start on 12 | the backend. For example: 13 | 14 | 15 | ```rust no_run 16 | use esp_idf_svc::hal; 17 | use hal::i2s::config; 18 | 19 | const SAMPLE_RATE: u32 = 44100; 20 | const CHANNEL_COUNT: u16 = 1; 21 | 22 | let i2s_config = config::StdConfig::new( 23 | config::Config::default(), 24 | config::StdClkConfig::from_sample_rate_hz(SAMPLE_RATE), 25 | config::StdSlotConfig::philips_slot_default( 26 | config::DataBitWidth::Bits16, 27 | config::SlotMode::Mono, 28 | ), 29 | config::StdGpioConfig::default(), 30 | ); 31 | 32 | let peripherals = hal::peripherals::Peripherals::take().unwrap(); 33 | let i2s = peripherals.i2s0; 34 | let blk = peripherals.pins.gpio44; 35 | let dout = peripherals.pins.gpio42; 36 | let mclk: Option = None; 37 | let ws = peripherals.pins.gpio43; 38 | let driver = hal::i2s::I2sDriver::new_std_tx(i2s, &i2s_config, bclk, dout, mclk, ws).unwrap(); 39 | 40 | let backend = awedio_esp32::Esp32Backend::with_defaults( 41 | driver, 42 | CHANNEL_COUNT, 43 | SAMPLE_RATE, 44 | 128, 45 | ); 46 | let manager = backend.start() 47 | ``` 48 | 49 | In order to get the `rmp3` native dependency to compile for xtensa chips 50 | (if the rmp3-mp3 feature is enabled) you may need to export the following 51 | variables (adjust for your target): 52 | `export CROSS_COMPILE=xtensa-esp32s3-elf; export CFLAGS=-mlongcalls` 53 | 54 | ## Motivation 55 | 56 | Built for creating activities for [10 Buttons](https://www.10Buttons.com), a 57 | screen-less tablet for kids. Purposefully kept generic to be usable in other 58 | contexts. 59 | 60 | ## Features 61 | 62 | * report-render-time: Print to stdout stats about rendering time. 63 | 64 | ## License 65 | 66 | This project is licensed under either of 67 | [Apache License, Version 2.0](https://www.apache.org/licenses/LICENSE-2.0) or 68 | [MIT license](https://opensource.org/licenses/MIT) at your option. 69 | 70 | Unless you explicitly state otherwise, any contribution intentionally submitted 71 | for inclusion in the work by you, as defined in the Apache-2.0 license, shall 72 | be dual licensed as above, without any additional terms or conditions. 73 | 74 | [Latest Version]: https://img.shields.io/crates/v/awedio_esp32.svg 75 | [crates.io]: https://crates.io/crates/awedio_esp32 76 | [awedio]: https://docs.rs/awedio 77 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | #![warn(missing_docs)] 2 | #![doc = include_str!("../README.md")] 3 | 4 | use esp_idf_hal as hal; 5 | 6 | use awedio::{manager::BackendSource, manager::Manager}; 7 | use hal::delay::TickType; 8 | use hal::task::thread; 9 | #[cfg(feature = "report-render-time")] 10 | use std::time::Instant; 11 | 12 | /// An ESP32 backend for the I2S peripheral for ESP-IDF. 13 | pub struct Esp32Backend { 14 | /// The driver to write sound data to. 15 | /// This struct handles enabling the channel so it must not be enabled already. 16 | pub driver: hal::i2s::I2sDriver<'static, hal::i2s::I2sTx>, 17 | /// The number of channels. 1 for mono, 2 for stereo... 18 | pub channel_count: u16, 19 | /// The number of samples per second. 20 | pub sample_rate: u32, 21 | /// The size in frames of the samples buffer given to each call to I2S 22 | /// write. 23 | pub num_frames_per_write: usize, 24 | /// The stack size of the FreeRTOS task. Default may need to be increased if 25 | /// your Sounds sent to the renderer are complex. 26 | pub stack_size: u32, 27 | /// The priority of the FreeRTOS task. 28 | pub task_priority: u32, 29 | /// Whether the FreeRTOS task should be pinned to a core and if so what 30 | /// core. 31 | pub pin_to_core: Option, 32 | /// If true, when there is no audio to play, the I2S channel is disabled and 33 | /// re-enabled later if audio becomes available to play. 34 | /// If false, the channel is enabled during init and never disabled. 35 | /// 36 | /// Note: When enabled the channel is disabled as soon as no audio is available 37 | /// but DMA buffers will likely not have been fully flushed so some audio may 38 | /// be cutoff. If not desired you can flush the DMA buffers size of Sample(0) 39 | /// before having the sounds return Paused/Finished to the Manager. 40 | pub auto_disable_channel: bool, 41 | /// A callback that is called before the I2S channel is enabled when the 42 | /// Manager/Render goes from having no Sound to play to having a Sound and 43 | /// before the channel is disabled when no Sounds are playing. 44 | /// This callback is never called if `auto_disable_channel` is false. 45 | pub on_channel_enable_change: Option>, 46 | } 47 | 48 | impl Esp32Backend { 49 | /// New backend with some defaults of: 50 | /// 51 | /// `i2s_port_num`: 0 52 | /// `stack_size`: 30,000 53 | /// `task_priority`: 19 54 | /// `pin_to_core`: None, 55 | /// 56 | /// Stack size can be substantially lower if not decoding MP3s. This should 57 | /// be improved in the future. 58 | pub fn with_defaults( 59 | driver: hal::i2s::I2sDriver<'static, hal::i2s::I2sTx>, 60 | channel_count: u16, 61 | sample_rate: u32, 62 | num_frames_per_write: usize, 63 | ) -> Self { 64 | Self { 65 | driver, 66 | channel_count, 67 | sample_rate, 68 | num_frames_per_write, 69 | stack_size: 30000, 70 | task_priority: 19, 71 | pin_to_core: None, 72 | auto_disable_channel: true, 73 | on_channel_enable_change: None, 74 | } 75 | } 76 | } 77 | 78 | impl Esp32Backend { 79 | /// Start a new FreeRTOS task that will pull samples generated from Sounds 80 | /// sent to the returned Manager and write them to I2S. 81 | /// 82 | /// The task stops if the Manager and all of its clones are dropped. 83 | pub fn start(self) -> Manager { 84 | let (manager, renderer) = Manager::new(); 85 | self.start_with_backend_source(Box::new(renderer)); 86 | 87 | manager 88 | } 89 | 90 | /// Provide a custom backend_source, normally by wrapping a renderer 91 | /// returned from Manager::new. 92 | pub fn start_with_backend_source(self, mut backend_source: Box) { 93 | backend_source 94 | .set_output_channel_count_and_sample_rate(self.channel_count, self.sample_rate); 95 | let awedio::NextSample::MetadataChanged = backend_source 96 | .next_sample() 97 | .expect("backend_source should never return an error") 98 | else { 99 | panic!("MetadataChanged expected but not received."); 100 | }; 101 | let stack_size = self.stack_size as usize; 102 | let priority: u8 = self.task_priority.try_into().unwrap(); 103 | let pin_to_core = self.pin_to_core; 104 | let orig_spawn_config = thread::ThreadSpawnConfiguration::get().unwrap_or_default(); 105 | let new_config = thread::ThreadSpawnConfiguration { 106 | name: Some("AwedioBackend\0".as_bytes()), 107 | stack_size, // does not do anything 108 | priority, 109 | inherit: false, 110 | pin_to_core, 111 | stack_alloc_caps: Default::default(), 112 | }; 113 | new_config 114 | .set() 115 | .expect("a valid stack size and priority for thread spawn"); 116 | std::thread::Builder::new() 117 | .stack_size(stack_size) 118 | .name("AwedioBackend".to_owned()) 119 | .spawn(|| audio_task(self, backend_source)) 120 | .expect("spawn should succeed"); 121 | orig_spawn_config 122 | .set() 123 | .expect("original spawn config is valid"); 124 | } 125 | } 126 | 127 | fn audio_task(mut backend: Esp32Backend, mut backend_source: Box) { 128 | let mut driver = backend.driver; 129 | let channel_count = backend.channel_count as usize; 130 | let num_frames_per_write = backend.num_frames_per_write; 131 | let mut buf = vec![0_i16; num_frames_per_write * channel_count]; 132 | const SAMPLE_SIZE: usize = std::mem::size_of::(); 133 | assert!(SAMPLE_SIZE == 2); 134 | let pause_time = std::time::Duration::from_millis(20); 135 | let mut stopped = backend.auto_disable_channel; 136 | if !stopped { 137 | driver 138 | .tx_enable() 139 | .expect("tx_enable should always succeed. Was the channel already enabled?"); 140 | } 141 | 142 | #[cfg(feature = "report-render-time")] 143 | let mut render_time_since_report = std::time::Duration::ZERO; 144 | #[cfg(feature = "report-render-time")] 145 | let mut samples_rendered_since_report = 0; 146 | #[cfg(feature = "report-render-time")] 147 | let mut last_report = Instant::now(); 148 | 149 | loop { 150 | #[cfg(feature = "report-render-time")] 151 | let start = Instant::now(); 152 | backend_source.on_start_of_batch(); 153 | #[cfg(feature = "report-render-time")] 154 | let end_start_of_batch = Instant::now(); 155 | let mut paused = false; 156 | let mut finished = false; 157 | let mut have_data = true; 158 | for (i, buf_sample) in buf.iter_mut().enumerate() { 159 | let sample = match backend_source 160 | .next_sample() 161 | .expect("backend source should never return an error") 162 | { 163 | awedio::NextSample::Sample(s) => s, 164 | awedio::NextSample::MetadataChanged => { 165 | unreachable!("we do not change the metadata of the renderer") 166 | } 167 | awedio::NextSample::Paused => { 168 | paused = true; 169 | if i == 0 { 170 | have_data = false; 171 | break; 172 | } 173 | 0 174 | } 175 | awedio::NextSample::Finished => { 176 | finished = true; 177 | if i == 0 { 178 | have_data = false; 179 | break; 180 | } 181 | 0 182 | } 183 | }; 184 | 185 | *buf_sample = sample; 186 | } 187 | if have_data { 188 | #[cfg(feature = "report-render-time")] 189 | { 190 | let end = Instant::now(); 191 | let start_of_batch_time = end_start_of_batch.duration_since(start); 192 | render_time_since_report += end.duration_since(end_start_of_batch); 193 | samples_rendered_since_report += buf.len(); 194 | if end.duration_since(last_report) > std::time::Duration::from_secs(1) { 195 | let budget_micros = samples_rendered_since_report as f32 * 1_000_000.0 196 | / backend.sample_rate as f32 197 | / channel_count as f32; 198 | let percent_budget = 199 | render_time_since_report.as_micros() as f32 / budget_micros * 100.0; 200 | println!( 201 | "Start of batch took {:4}ms. Rendered {:6} frames in {:4}ms. Total {:.1}% of budget.", 202 | start_of_batch_time.as_millis(), 203 | samples_rendered_since_report, 204 | render_time_since_report.as_millis(), 205 | percent_budget 206 | ); 207 | render_time_since_report = std::time::Duration::ZERO; 208 | samples_rendered_since_report = 0; 209 | last_report = end; 210 | } 211 | } 212 | let byte_slice = unsafe { 213 | core::slice::from_raw_parts(buf.as_ptr() as *const u8, buf.len() * SAMPLE_SIZE) 214 | }; 215 | if stopped { 216 | stopped = false; 217 | if let Some(on_change) = &mut backend.on_channel_enable_change { 218 | on_change(true); 219 | } 220 | let loaded = driver 221 | .preload_data(byte_slice) 222 | .expect("preload should succeed"); 223 | assert_eq!(loaded, byte_slice.len()); 224 | driver 225 | .tx_enable() 226 | .expect("tx_enable should always succeed. Was the channel already enabled?"); 227 | } else { 228 | driver 229 | .write_all(byte_slice, BLOCK_TIME.into()) 230 | .expect("I2sDriver::write_all should succeed"); 231 | } 232 | } 233 | 234 | if finished { 235 | break; 236 | } 237 | if paused { 238 | if !stopped && backend.auto_disable_channel { 239 | stopped = true; 240 | if let Some(on_change) = &mut backend.on_channel_enable_change { 241 | on_change(false); 242 | } 243 | driver 244 | .tx_disable() 245 | .expect("tx_disable should always succeed"); 246 | } 247 | // TODO instead of sleeping and polling, have the Renderer 248 | // notify when a new sound is added and wait for that. 249 | std::thread::sleep(pause_time); 250 | continue; 251 | } 252 | } 253 | if backend.auto_disable_channel { 254 | if let Some(on_change) = &mut backend.on_channel_enable_change { 255 | on_change(false); 256 | } 257 | driver 258 | .tx_disable() 259 | .expect("tx_disable should always succeed"); 260 | } 261 | } 262 | 263 | /// Long enough we should not expect to ever return. 264 | const BLOCK_TIME: TickType = TickType::new(100_000_000); 265 | --------------------------------------------------------------------------------