awedio_esp32 0.10.0

ESP32 backend for the awedio audio playback library
#![warn(missing_docs)]
#![doc = include_str!("../README.md")]

use esp_idf_hal as hal;

use awedio::{manager::BackendSource, manager::Manager};
use hal::delay::TickType;
use hal::task::thread;
#[cfg(feature = "report-render-time")]
use std::time::Instant;

/// An ESP32 backend for the I2S peripheral for ESP-IDF.
pub struct Esp32Backend {
    /// The driver to write sound data to.
    /// This struct handles enabling the channel so it must not be enabled
    /// already.
    pub driver: hal::i2s::I2sDriver<'static, hal::i2s::I2sTx>,
    /// The number of channels. 1 for mono, 2 for stereo...
    pub channel_count: u16,
    /// The number of samples per second.
    pub sample_rate: u32,
    /// The size in frames of the samples buffer given to each call to I2S
    /// write.
    pub num_frames_per_write: usize,
    /// The stack size of the FreeRTOS task. Default may need to be increased if
    /// your Sounds sent to the renderer are complex.
    pub stack_size: u32,
    /// The priority of the FreeRTOS task.
    pub task_priority: u32,
    /// Whether the FreeRTOS task should be pinned to a core and if so what
    /// core.
    pub pin_to_core: Option<hal::cpu::Core>,
    /// If true, when there is no audio to play, the I2S channel is disabled and
    /// re-enabled later if audio becomes available to play.
    /// If false, the channel is enabled during init and never disabled.
    ///
    /// Note: When enabled the channel is disabled as soon as no audio is
    /// available but DMA buffers will likely not have been fully flushed so
    /// some audio may be cutoff. If not desired you can flush the DMA
    /// buffers size of Sample(0) before having the sounds return
    /// Paused/Finished to the Manager.
    pub auto_disable_channel: bool,
    /// A callback that is called before the I2S channel is enabled when the
    /// Manager/Render goes from having no Sound to play to having a Sound and
    /// before the channel is disabled when no Sounds are playing.
    /// This callback is never called if `auto_disable_channel` is false.
    pub on_channel_enable_change: Option<Box<dyn FnMut(bool) + 'static + Send + Sync>>,
}

impl Esp32Backend {
    /// New backend with some defaults of:
    ///
    /// `i2s_port_num`: 0
    /// `stack_size`: 30,000
    /// `task_priority`: 19
    /// `pin_to_core`: None,
    ///
    /// Stack size can be substantially lower if not decoding MP3s. This should
    /// be improved in the future.
    pub fn with_defaults(
        driver: hal::i2s::I2sDriver<'static, hal::i2s::I2sTx>,
        channel_count: u16,
        sample_rate: u32,
        num_frames_per_write: usize,
    ) -> Self {
        Self {
            driver,
            channel_count,
            sample_rate,
            num_frames_per_write,
            stack_size: 30000,
            task_priority: 19,
            pin_to_core: None,
            auto_disable_channel: true,
            on_channel_enable_change: None,
        }
    }
}

impl Esp32Backend {
    /// Start a new FreeRTOS task that will pull samples generated from Sounds
    /// sent to the returned Manager and write them to I2S.
    ///
    /// The task stops if the Manager and all of its clones are dropped.
    pub fn start(self) -> Manager {
        let (manager, renderer) = Manager::new();
        self.start_with_backend_source(Box::new(renderer));

        manager
    }

    /// Provide a custom backend_source, normally by wrapping a renderer
    /// returned from Manager::new.
    pub fn start_with_backend_source(self, mut backend_source: Box<dyn BackendSource>) {
        backend_source
            .set_output_channel_count_and_sample_rate(self.channel_count, self.sample_rate);
        let awedio::NextSample::MetadataChanged = backend_source
            .next_sample()
            .expect("backend_source should never return an error")
        else {
            panic!("MetadataChanged expected but not received.");
        };
        let stack_size = self.stack_size as usize;
        let priority: u8 = self.task_priority.try_into().unwrap();
        let pin_to_core = self.pin_to_core;
        let orig_spawn_config = thread::ThreadSpawnConfiguration::get().unwrap_or_default();
        let new_config = thread::ThreadSpawnConfiguration {
            name: Some(c"AwedioBackend"),
            stack_size, // does not do anything
            priority,
            inherit: false,
            pin_to_core,
            stack_alloc_caps: Default::default(),
        };
        new_config
            .set()
            .expect("a valid stack size and priority for thread spawn");
        std::thread::Builder::new()
            .stack_size(stack_size)
            .name("AwedioBackend".to_owned())
            .spawn(|| audio_task(self, backend_source))
            .expect("spawn should succeed");
        orig_spawn_config
            .set()
            .expect("original spawn config is valid");
    }
}

fn audio_task(mut backend: Esp32Backend, mut backend_source: Box<dyn BackendSource>) {
    let mut driver = backend.driver;
    let channel_count = backend.channel_count as usize;
    let num_frames_per_write = backend.num_frames_per_write;
    let mut buf = vec![0_i16; num_frames_per_write * channel_count];
    const SAMPLE_SIZE: usize = std::mem::size_of::<i16>();
    const {
        assert!(SAMPLE_SIZE == 2);
    }
    let pause_time = std::time::Duration::from_millis(20);
    let mut stopped = backend.auto_disable_channel;
    if !stopped {
        driver
            .tx_enable()
            .expect("tx_enable should always succeed. Was the channel already enabled?");
    }

    #[cfg(feature = "report-render-time")]
    let mut render_time_since_report = std::time::Duration::ZERO;
    #[cfg(feature = "report-render-time")]
    let mut samples_rendered_since_report = 0;
    #[cfg(feature = "report-render-time")]
    let mut last_report = Instant::now();

    loop {
        #[cfg(feature = "report-render-time")]
        let start = Instant::now();
        backend_source.on_start_of_batch();
        #[cfg(feature = "report-render-time")]
        let end_start_of_batch = Instant::now();
        let mut paused = false;
        let mut finished = false;
        let mut have_data = true;
        for (i, buf_sample) in buf.iter_mut().enumerate() {
            let sample = match backend_source
                .next_sample()
                .expect("backend source should never return an error")
            {
                awedio::NextSample::Sample(s) => s,
                awedio::NextSample::MetadataChanged => {
                    unreachable!("we do not change the metadata of the renderer")
                }
                awedio::NextSample::Paused => {
                    paused = true;
                    if i == 0 {
                        have_data = false;
                        break;
                    }
                    0
                }
                awedio::NextSample::Finished => {
                    finished = true;
                    if i == 0 {
                        have_data = false;
                        break;
                    }
                    0
                }
            };

            *buf_sample = sample;
        }
        if have_data {
            #[cfg(feature = "report-render-time")]
            {
                let end = Instant::now();
                let start_of_batch_time = end_start_of_batch.duration_since(start);
                render_time_since_report += end.duration_since(end_start_of_batch);
                samples_rendered_since_report += buf.len();
                if end.duration_since(last_report) > std::time::Duration::from_secs(1) {
                    let budget_micros = samples_rendered_since_report as f32 * 1_000_000.0
                        / backend.sample_rate as f32
                        / channel_count as f32;
                    let percent_budget =
                        render_time_since_report.as_micros() as f32 / budget_micros * 100.0;
                    println!(
                        "Start of batch took {:4}ms. Rendered {:6} frames in {:4}ms. Total {:.1}% of budget.",
                        start_of_batch_time.as_millis(),
                        samples_rendered_since_report,
                        render_time_since_report.as_millis(),
                        percent_budget
                    );
                    render_time_since_report = std::time::Duration::ZERO;
                    samples_rendered_since_report = 0;
                    last_report = end;
                }
            }
            let byte_slice = unsafe {
                core::slice::from_raw_parts(buf.as_ptr() as *const u8, buf.len() * SAMPLE_SIZE)
            };
            if stopped {
                stopped = false;
                if let Some(on_change) = &mut backend.on_channel_enable_change {
                    on_change(true);
                }
                let loaded = driver
                    .preload_data(byte_slice)
                    .expect("preload should succeed");
                assert_eq!(loaded, byte_slice.len());
                driver
                    .tx_enable()
                    .expect("tx_enable should always succeed. Was the channel already enabled?");
            } else {
                driver
                    .write_all(byte_slice, BLOCK_TIME.into())
                    .expect("I2sDriver::write_all should succeed");
            }
        }

        if finished {
            break;
        }
        if paused {
            if !stopped && backend.auto_disable_channel {
                stopped = true;
                if let Some(on_change) = &mut backend.on_channel_enable_change {
                    on_change(false);
                }
                driver
                    .tx_disable()
                    .expect("tx_disable should always succeed");
            }
            // TODO instead of sleeping and polling, have the Renderer
            // notify when a new sound is added and wait for that.
            std::thread::sleep(pause_time);
            continue;
        }
    }
    if backend.auto_disable_channel {
        if let Some(on_change) = &mut backend.on_channel_enable_change {
            on_change(false);
        }
        driver
            .tx_disable()
            .expect("tx_disable should always succeed");
    }
}

/// Long enough we should not expect to ever return.
const BLOCK_TIME: TickType = TickType::new(100_000_000);