mirui 0.46.3

A lightweight, no_std ECS-driven UI framework for embedded, mobile, desktop, and WebAssembly
Documentation
use web_sys::{AudioContext, DynamicsCompressorNode, GainNode, OscillatorNode, OscillatorType};

use super::{
    AudioBank, AudioCommand, AudioOutputState, AudioSink, AudioTone, CueId, Score, Waveform,
};

const NODE_CAPACITY: usize = 64;
const LOOP_CAPACITY: usize = 4;

struct ScheduledNode {
    cue: Option<CueId>,
    oscillator: OscillatorNode,
    _gain: GainNode,
    ends_at: f64,
}

#[derive(Clone, Copy)]
struct LoopSlot {
    cue: CueId,
    gain: u8,
    next_start: f64,
}

pub struct WebAudioSink {
    bank: Option<&'static AudioBank>,
    context: Option<AudioContext>,
    master: Option<GainNode>,
    compressor: Option<DynamicsCompressorNode>,
    nodes: [Option<ScheduledNode>; NODE_CAPACITY],
    loops: [Option<LoopSlot>; LOOP_CAPACITY],
    master_gain: u8,
    muted: bool,
    state: AudioOutputState,
}

impl Default for WebAudioSink {
    fn default() -> Self {
        Self::new()
    }
}

impl WebAudioSink {
    pub fn new() -> Self {
        Self {
            bank: None,
            context: None,
            master: None,
            compressor: None,
            nodes: core::array::from_fn(|_| None),
            loops: [None; LOOP_CAPACITY],
            master_gain: 220,
            muted: false,
            state: AudioOutputState::Starting,
        }
    }

    fn ensure_context(&mut self) -> Result<(), wasm_bindgen::JsValue> {
        if self.context.is_some() {
            return Ok(());
        }
        let context = AudioContext::new()?;
        let master = context.create_gain()?;
        let compressor = context.create_dynamics_compressor()?;
        compressor.threshold().set_value(-14.0);
        compressor.knee().set_value(16.0);
        compressor.ratio().set_value(6.0);
        compressor.attack().set_value(0.004);
        compressor.release().set_value(0.18);
        master.connect_with_audio_node(&compressor)?;
        compressor.connect_with_audio_node(&context.destination())?;
        self.context = Some(context);
        self.master = Some(master);
        self.compressor = Some(compressor);
        self.apply_master_gain()?;
        Ok(())
    }

    fn apply_master_gain(&self) -> Result<(), wasm_bindgen::JsValue> {
        let (Some(context), Some(master)) = (&self.context, &self.master) else {
            return Ok(());
        };
        let gain = if self.muted {
            0.0
        } else {
            f32::from(self.master_gain) / 255.0
        };
        master
            .gain()
            .set_value_at_time(gain, context.current_time())?;
        Ok(())
    }

    fn schedule_cue(
        &mut self,
        cue: CueId,
        gain: u8,
        starts_at: f64,
    ) -> Result<f64, wasm_bindgen::JsValue> {
        let bank = self
            .bank
            .ok_or_else(|| wasm_bindgen::JsValue::from_str("audio bank is not started"))?;
        let score = &bank
            .cue(cue)
            .ok_or_else(|| wasm_bindgen::JsValue::from_str("unknown audio cue"))?
            .score;
        let duration = f64::from(score.length_ticks) / f64::from(score.ticks_per_second.max(1));
        for note in score.notes {
            let start =
                starts_at + f64::from(note.start_ticks) / f64::from(score.ticks_per_second.max(1));
            let end =
                start + f64::from(note.duration_ticks) / f64::from(score.ticks_per_second.max(1));
            self.schedule_note(Some(cue), gain, *note, start, end)?;
        }
        Ok(duration)
    }

    fn schedule_note(
        &mut self,
        cue: Option<CueId>,
        cue_gain: u8,
        note: super::NoteEvent,
        start: f64,
        end: f64,
    ) -> Result<(), wasm_bindgen::JsValue> {
        let Some(slot) = self.nodes.iter().position(Option::is_none) else {
            return Ok(());
        };
        let context = self
            .context
            .as_ref()
            .ok_or_else(|| wasm_bindgen::JsValue::from_str("audio context is locked"))?;
        let master = self
            .master
            .as_ref()
            .ok_or_else(|| wasm_bindgen::JsValue::from_str("master gain is unavailable"))?;
        let oscillator = context.create_oscillator()?;
        oscillator.set_type(match note.waveform {
            Waveform::Sine => OscillatorType::Sine,
            Waveform::Triangle => OscillatorType::Triangle,
            Waveform::Square => OscillatorType::Square,
            Waveform::Noise => OscillatorType::Sawtooth,
        });
        let frequency = 440.0_f32 * 2.0_f32.powf((f32::from(note.pitch) - 69.0) / 12.0);
        oscillator.frequency().set_value(frequency);
        let gain = context.create_gain()?;
        let peak = (f32::from(note.velocity) / 255.0) * (f32::from(cue_gain) / 255.0) * 0.24;
        let attack_end = (start + 0.008).min(end);
        gain.gain().set_value_at_time(0.0001, start)?;
        gain.gain()
            .exponential_ramp_to_value_at_time(peak.max(0.0001), attack_end)?;
        gain.gain().exponential_ramp_to_value_at_time(0.0001, end)?;
        oscillator.connect_with_audio_node(&gain)?;
        gain.connect_with_audio_node(master)?;
        oscillator.start_with_when(start)?;
        oscillator.stop_with_when(end + 0.01)?;
        self.nodes[slot] = Some(ScheduledNode {
            cue,
            oscillator,
            _gain: gain,
            ends_at: end + 0.02,
        });
        Ok(())
    }

    fn schedule_tone(&mut self, tone: AudioTone) -> Result<(), wasm_bindgen::JsValue> {
        let context = self
            .context
            .as_ref()
            .ok_or_else(|| wasm_bindgen::JsValue::from_str("audio context is locked"))?;
        let start = context.current_time() + 0.006 + f64::from(tone.delay_ms) / 1_000.0;
        let end = start + f64::from(tone.duration_ms) / 1_000.0;
        self.schedule_note(
            None,
            u8::MAX,
            super::NoteEvent::new(0, 1, tone.pitch, tone.gain, tone.waveform),
            start,
            end,
        )
    }

    fn cleanup(&mut self, now: f64) {
        for node in &mut self.nodes {
            if node.as_ref().is_some_and(|node| node.ends_at <= now) {
                *node = None;
            }
        }
    }

    fn pump_loops(&mut self) -> Result<(), wasm_bindgen::JsValue> {
        let Some(context) = &self.context else {
            return Ok(());
        };
        let now = context.current_time();
        self.cleanup(now);
        for index in 0..LOOP_CAPACITY {
            let Some(mut slot) = self.loops[index] else {
                continue;
            };
            if slot.next_start <= now + 0.35 {
                let duration = self.schedule_cue(slot.cue, slot.gain, slot.next_start)?;
                slot.next_start += duration.max(0.05);
                self.loops[index] = Some(slot);
            }
        }
        Ok(())
    }

    fn stop_cue(&mut self, cue: CueId) {
        for slot in &mut self.loops {
            if slot.is_some_and(|slot| slot.cue == cue) {
                *slot = None;
            }
        }
        for node in &mut self.nodes {
            if node.as_ref().is_some_and(|node| node.cue == Some(cue)) {
                if let Some(active) = node.take() {
                    let _ = active.oscillator.stop();
                }
            }
        }
    }

    fn play(&mut self, cue: CueId, gain: u8) -> Result<(), wasm_bindgen::JsValue> {
        let bank = self
            .bank
            .ok_or_else(|| wasm_bindgen::JsValue::from_str("audio bank is not started"))?;
        let score: &'static Score = &bank
            .cue(cue)
            .ok_or_else(|| wasm_bindgen::JsValue::from_str("unknown audio cue"))?
            .score;
        let context = self
            .context
            .as_ref()
            .ok_or_else(|| wasm_bindgen::JsValue::from_str("audio context is locked"))?;
        let now = context.current_time() + 0.015;
        if score.looping {
            if self.loops.iter().flatten().any(|slot| slot.cue == cue) {
                return Ok(());
            }
            if let Some(slot) = self.loops.iter_mut().find(|slot| slot.is_none()) {
                *slot = Some(LoopSlot {
                    cue,
                    gain,
                    next_start: now,
                });
                self.pump_loops()?;
            }
        } else {
            self.schedule_cue(cue, gain, now)?;
        }
        Ok(())
    }
}

impl AudioSink for WebAudioSink {
    type Error = wasm_bindgen::JsValue;

    fn start(&mut self, bank: &'static AudioBank) -> Result<(), Self::Error> {
        self.bank = Some(bank);
        self.state = AudioOutputState::Locked;
        Ok(())
    }

    fn unlock(&mut self) -> Result<(), Self::Error> {
        self.ensure_context()?;
        if let Some(context) = &self.context {
            let _ = context.resume()?;
        }
        self.state = AudioOutputState::Ready;
        Ok(())
    }

    fn update(&mut self) -> Result<(), Self::Error> {
        if self.state == AudioOutputState::Ready {
            self.pump_loops()?;
        }
        Ok(())
    }

    fn submit(&mut self, command: AudioCommand) -> Result<(), Self::Error> {
        match command {
            AudioCommand::Play { cue, gain } => self.play(cue, gain),
            AudioCommand::Tone(tone) => self.schedule_tone(tone),
            AudioCommand::Stop { cue } => {
                self.stop_cue(cue);
                Ok(())
            }
            AudioCommand::StopAll => {
                for slot in &mut self.loops {
                    *slot = None;
                }
                for node in &mut self.nodes {
                    if let Some(active) = node.take() {
                        let _ = active.oscillator.stop();
                    }
                }
                Ok(())
            }
            AudioCommand::SetMasterGain(gain) => {
                self.master_gain = gain;
                self.apply_master_gain()
            }
            AudioCommand::SetMuted(muted) => {
                self.muted = muted;
                self.apply_master_gain()
            }
        }
    }

    fn suspend(&mut self) {
        if let Some(context) = &self.context {
            let _ = context.suspend();
        }
        self.state = AudioOutputState::Suspended;
    }

    fn resume(&mut self) -> Result<(), Self::Error> {
        if let Some(context) = &self.context {
            let _ = context.resume()?;
            self.state = AudioOutputState::Ready;
        } else {
            self.state = AudioOutputState::Locked;
        }
        Ok(())
    }

    fn stop(&mut self) {
        let _ = self.submit(AudioCommand::StopAll);
        if let Some(context) = self.context.take() {
            let _ = context.close();
        }
        self.master = None;
        self.compressor = None;
        self.state = AudioOutputState::Stopped;
    }

    fn state(&self) -> AudioOutputState {
        self.state
    }
}