Skip to main content

dioxus_audio/
analysis.rs

1//! Platform-independent audio analysis helpers.
2
3use dioxus::prelude::*;
4#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
5use std::cell::Cell;
6use std::fmt;
7#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
8use std::rc::{Rc, Weak};
9use std::sync::Arc;
10use std::time::Duration;
11
12/// Metadata needed to interpret one live Analysis snapshot.
13#[derive(Clone, Copy, Debug, PartialEq)]
14pub struct AnalysisMetadata {
15    sample_rate: f32,
16    fft_size: u32,
17    min_decibels: f32,
18    max_decibels: f32,
19    smoothing: f64,
20}
21
22impl AnalysisMetadata {
23    pub fn new(
24        sample_rate: f32,
25        fft_size: u32,
26        min_decibels: f32,
27        max_decibels: f32,
28        smoothing: f64,
29    ) -> Self {
30        Self {
31            sample_rate,
32            fft_size,
33            min_decibels,
34            max_decibels,
35            smoothing,
36        }
37    }
38
39    /// Effective sample rate of the audio graph, in hertz.
40    pub fn sample_rate(self) -> f32 {
41        self.sample_rate
42    }
43
44    /// Number of time-domain samples in each snapshot.
45    pub fn fft_size(self) -> u32 {
46        self.fft_size
47    }
48
49    /// Number of frequency values in each snapshot.
50    pub fn frequency_bin_count(self) -> u32 {
51        self.fft_size / 2
52    }
53
54    /// Width of each frequency bin, in hertz.
55    pub fn frequency_bin_width(self) -> f32 {
56        if self.fft_size == 0 {
57            0.0
58        } else {
59            self.sample_rate / self.fft_size as f32
60        }
61    }
62
63    /// Center frequency represented by `bin`, or `None` when it is out of range.
64    pub fn frequency_for_bin(self, bin: u32) -> Option<f32> {
65        (bin < self.frequency_bin_count()).then(|| bin as f32 * self.frequency_bin_width())
66    }
67
68    pub fn min_decibels(self) -> f32 {
69        self.min_decibels
70    }
71
72    pub fn max_decibels(self) -> f32 {
73        self.max_decibels
74    }
75
76    /// Convert a normalized byte-frequency value to its configured decibel value.
77    pub fn decibels_for_frequency_value(self, value: f32) -> f32 {
78        self.min_decibels + value.clamp(0.0, 1.0) * (self.max_decibels - self.min_decibels)
79    }
80
81    /// Frequency-domain smoothing time constant in the inclusive range `0.0..=1.0`.
82    pub fn smoothing(self) -> f64 {
83        self.smoothing
84    }
85}
86
87/// Bounded scheduling options for reactive live Analysis.
88#[derive(Clone, Copy, Debug, PartialEq, Eq)]
89pub struct LiveAnalysisOptions {
90    cadence: Duration,
91}
92
93impl LiveAnalysisOptions {
94    pub const MIN_CADENCE: Duration = Duration::from_millis(16);
95    pub const MAX_CADENCE: Duration = Duration::from_secs(1);
96
97    /// Set the polling cadence, clamped to `16ms..=1s`.
98    pub fn with_cadence(mut self, cadence: Duration) -> Self {
99        self.cadence = cadence.clamp(Self::MIN_CADENCE, Self::MAX_CADENCE);
100        self
101    }
102
103    pub fn cadence(self) -> Duration {
104        self.cadence
105    }
106}
107
108impl Default for LiveAnalysisOptions {
109    fn default() -> Self {
110        Self {
111            cadence: Duration::from_millis(50),
112        }
113    }
114}
115
116/// Return normalized root mean square amplitude for one time-domain window.
117pub fn rms_level(samples: &[f32]) -> f32 {
118    if samples.is_empty() {
119        return 0.0;
120    }
121
122    let mean_square =
123        samples.iter().map(|sample| sample * sample).sum::<f32>() / samples.len() as f32;
124    mean_square.sqrt().clamp(0.0, 1.0)
125}
126
127/// Immutable values collected together from one Analyser.
128///
129/// Time-domain values are byte-quantized amplitudes normalized to
130/// `-1.0..=1.0`. Frequency-domain values are byte-quantized magnitudes
131/// normalized to `0.0..=1.0`. `level` is the normalized RMS amplitude of the
132/// same time-domain window, not a peak, perceived loudness, or sound pressure
133/// level measurement.
134#[derive(Clone, Debug, PartialEq)]
135pub struct LiveAnalysisSnapshot {
136    time_domain: Arc<[f32]>,
137    frequency_domain: Arc<[f32]>,
138    level: f32,
139    metadata: AnalysisMetadata,
140}
141
142impl LiveAnalysisSnapshot {
143    pub fn time_domain(&self) -> &[f32] {
144        &self.time_domain
145    }
146
147    pub fn frequency_domain(&self) -> &[f32] {
148        &self.frequency_domain
149    }
150
151    pub fn level(&self) -> f32 {
152        self.level
153    }
154
155    pub fn metadata(&self) -> AnalysisMetadata {
156        self.metadata
157    }
158}
159
160#[derive(Clone, Copy, Debug, PartialEq, Eq)]
161pub enum AnalysisDomain {
162    Waveform,
163    Spectrum,
164}
165
166/// An Analyser has no active source its owner can safely expose.
167#[derive(Clone, Copy, Debug, PartialEq, Eq)]
168pub struct AnalysisUnavailable;
169
170impl fmt::Display for AnalysisUnavailable {
171    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
172        formatter.write_str("Analysis is unavailable")
173    }
174}
175
176impl std::error::Error for AnalysisUnavailable {}
177
178/// Opaque, cheap-to-clone reader for live audio analysis data.
179#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
180#[derive(Clone)]
181pub struct AudioAnalyser {
182    inner: Weak<AudioAnalyserInner>,
183}
184
185#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
186struct AudioAnalyserInner {
187    node: web_sys::AnalyserNode,
188    sample_rate: f32,
189    available: Cell<bool>,
190}
191
192#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
193pub(crate) struct AudioAnalyserControl {
194    inner: Rc<AudioAnalyserInner>,
195}
196
197#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
198impl fmt::Debug for AudioAnalyser {
199    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
200        formatter
201            .debug_struct("AudioAnalyser")
202            .field("available", &self.is_available())
203            .finish_non_exhaustive()
204    }
205}
206
207#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
208impl PartialEq for AudioAnalyser {
209    fn eq(&self, other: &Self) -> bool {
210        self.inner.ptr_eq(&other.inner)
211    }
212}
213
214#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
215impl AudioAnalyserControl {
216    pub(crate) fn new(node: web_sys::AnalyserNode, sample_rate: f32) -> (Self, AudioAnalyser) {
217        let inner = Rc::new(AudioAnalyserInner {
218            node,
219            sample_rate,
220            available: Cell::new(false),
221        });
222        let analyser = AudioAnalyser {
223            inner: Rc::downgrade(&inner),
224        };
225        (Self { inner }, analyser)
226    }
227
228    pub(crate) fn set_available(&self, available: bool) {
229        self.inner.available.set(available);
230    }
231
232    pub(crate) fn node(&self) -> &web_sys::AnalyserNode {
233        &self.inner.node
234    }
235}
236
237#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
238impl AudioAnalyser {
239    fn available_inner(&self) -> Result<Rc<AudioAnalyserInner>, AnalysisUnavailable> {
240        self.inner
241            .upgrade()
242            .filter(|inner| inner.available.get())
243            .ok_or(AnalysisUnavailable)
244    }
245
246    pub fn is_available(&self) -> bool {
247        self.available_inner().is_ok()
248    }
249
250    /// Read current normalized values, or report that the owning source is unavailable.
251    pub fn try_read(&self, domain: AnalysisDomain) -> Result<Vec<f32>, AnalysisUnavailable> {
252        let inner = self.available_inner()?;
253        Ok(read_node(&inner.node, domain))
254    }
255
256    /// Read current normalized values, returning no samples while unavailable.
257    ///
258    /// Use [`Self::try_read`] when source availability must be distinguished from
259    /// a valid empty result.
260    pub fn read(&self, domain: AnalysisDomain) -> Vec<f32> {
261        self.try_read(domain).unwrap_or_default()
262    }
263
264    pub fn try_level(&self) -> Result<f32, AnalysisUnavailable> {
265        Ok(rms_level(&self.try_read(AnalysisDomain::Waveform)?))
266    }
267
268    pub fn level(&self) -> f32 {
269        self.try_level().unwrap_or(0.0)
270    }
271
272    fn snapshot(&self) -> Option<LiveAnalysisSnapshot> {
273        let inner = self.available_inner().ok()?;
274        let time_domain: Arc<[f32]> = read_node(&inner.node, AnalysisDomain::Waveform).into();
275        let frequency_domain = read_node(&inner.node, AnalysisDomain::Spectrum).into();
276        Some(LiveAnalysisSnapshot {
277            level: rms_level(&time_domain),
278            time_domain,
279            frequency_domain,
280            metadata: AnalysisMetadata::new(
281                inner.sample_rate,
282                inner.node.fft_size(),
283                inner.node.min_decibels() as f32,
284                inner.node.max_decibels() as f32,
285                inner.node.smoothing_time_constant(),
286            ),
287        })
288    }
289}
290
291#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
292fn read_node(node: &web_sys::AnalyserNode, domain: AnalysisDomain) -> Vec<f32> {
293    match domain {
294        AnalysisDomain::Waveform => {
295            let mut samples = vec![0_u8; node.fft_size() as usize];
296            node.get_byte_time_domain_data(&mut samples);
297            samples
298                .into_iter()
299                .map(|sample| ((sample as f32 - 128.0) / 128.0).clamp(-1.0, 1.0))
300                .collect()
301        }
302        AnalysisDomain::Spectrum => {
303            let samples = js_sys::Uint8Array::new_with_length(node.frequency_bin_count());
304            node.get_byte_frequency_data_with_u8_array(&samples);
305            let mut values = vec![0_u8; samples.length() as usize];
306            samples.copy_to(&mut values);
307            values
308                .into_iter()
309                .map(|sample| sample as f32 / 255.0)
310                .collect()
311        }
312    }
313}
314
315#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
316#[derive(Clone, Debug, PartialEq)]
317pub struct AudioAnalyser {
318    _private: (),
319}
320
321#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
322impl AudioAnalyser {
323    pub fn is_available(&self) -> bool {
324        false
325    }
326
327    pub fn try_read(&self, _domain: AnalysisDomain) -> Result<Vec<f32>, AnalysisUnavailable> {
328        Err(AnalysisUnavailable)
329    }
330
331    pub fn read(&self, _domain: AnalysisDomain) -> Vec<f32> {
332        Vec::new()
333    }
334
335    pub fn try_level(&self) -> Result<f32, AnalysisUnavailable> {
336        Err(AnalysisUnavailable)
337    }
338
339    pub fn level(&self) -> f32 {
340        0.0
341    }
342
343    fn snapshot(&self) -> Option<LiveAnalysisSnapshot> {
344        None
345    }
346}
347
348/// Reactively collect complete, interpretable snapshots from an optional Analyser.
349///
350/// The output is `None` until an Analyser is available and is cleared when the
351/// Analyser is lost or replaced. Polling is suspended while the document is
352/// hidden and ends when this hook is unmounted. Every hook invocation owns an
353/// independent schedule.
354pub fn use_live_analysis(
355    analyser: ReadSignal<Option<AudioAnalyser>>,
356    options: LiveAnalysisOptions,
357) -> ReadSignal<Option<LiveAnalysisSnapshot>> {
358    use_scheduled_analysis(analyser, options, AudioAnalyser::snapshot)
359}
360
361pub(crate) fn use_live_analysis_domain(
362    analyser: ReadSignal<Option<AudioAnalyser>>,
363    domain: AnalysisDomain,
364) -> ReadSignal<Option<Arc<[f32]>>> {
365    match domain {
366        AnalysisDomain::Waveform => {
367            use_scheduled_analysis(analyser, LiveAnalysisOptions::default(), read_waveform)
368        }
369        AnalysisDomain::Spectrum => {
370            use_scheduled_analysis(analyser, LiveAnalysisOptions::default(), read_spectrum)
371        }
372    }
373}
374
375pub(crate) fn use_live_analysis_level(
376    analyser: ReadSignal<Option<AudioAnalyser>>,
377) -> ReadSignal<Option<f32>> {
378    use_scheduled_analysis(analyser, LiveAnalysisOptions::default(), |analyser| {
379        analyser.try_level().ok()
380    })
381}
382
383fn read_waveform(analyser: &AudioAnalyser) -> Option<Arc<[f32]>> {
384    Some(analyser.try_read(AnalysisDomain::Waveform).ok()?.into())
385}
386
387fn read_spectrum(analyser: &AudioAnalyser) -> Option<Arc<[f32]>> {
388    Some(analyser.try_read(AnalysisDomain::Spectrum).ok()?.into())
389}
390
391fn use_scheduled_analysis<T: 'static>(
392    analyser: ReadSignal<Option<AudioAnalyser>>,
393    options: LiveAnalysisOptions,
394    collect: fn(&AudioAnalyser) -> Option<T>,
395) -> ReadSignal<Option<T>> {
396    let parameters = use_memo(use_reactive!(|(analyser, options)| (analyser, options)));
397    #[allow(unused_mut)]
398    let mut value = use_signal(|| None::<T>);
399    #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
400    let scheduler = use_live_analysis_scheduler();
401
402    use_effect(move || {
403        let (analyser, options) = parameters();
404        value.set(None);
405
406        #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
407        {
408            let generation = scheduler.next_generation();
409            let Some(source_analyser) = analyser() else {
410                return;
411            };
412            let scheduler = scheduler.clone();
413            let scheduler_for_publish = scheduler.clone();
414            gloo_timers::callback::Timeout::new(0, move || {
415                wasm_bindgen_futures::spawn_local(run_live_analysis_schedule(
416                    scheduler,
417                    generation,
418                    options.cadence(),
419                    move || {
420                        if analyser.peek().as_ref() != Some(&source_analyser) {
421                            return false;
422                        }
423                        let next = collect(&source_analyser);
424                        if !scheduler_for_publish.is_current(generation)
425                            || analyser.peek().as_ref() != Some(&source_analyser)
426                        {
427                            return false;
428                        }
429                        if let Some(next) = next {
430                            value.set(Some(next));
431                        } else if value.peek().is_some() {
432                            value.set(None);
433                        }
434                        true
435                    },
436                ));
437            })
438            .forget();
439        }
440
441        #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
442        {
443            let _ = (analyser, options, collect);
444        }
445    });
446
447    value.into()
448}
449
450#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
451pub(crate) struct LiveAnalysisScheduler {
452    generation: Cell<u64>,
453    mounted: Cell<bool>,
454}
455
456#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
457impl Default for LiveAnalysisScheduler {
458    fn default() -> Self {
459        Self {
460            generation: Cell::new(0),
461            mounted: Cell::new(true),
462        }
463    }
464}
465
466#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
467impl LiveAnalysisScheduler {
468    pub(crate) fn next_generation(&self) -> u64 {
469        let generation = self.generation.get().wrapping_add(1);
470        self.generation.set(generation);
471        generation
472    }
473
474    fn is_current(&self, generation: u64) -> bool {
475        self.mounted.get() && self.generation.get() == generation
476    }
477
478    fn unmount(&self) {
479        self.mounted.set(false);
480        self.next_generation();
481    }
482}
483
484#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
485struct AnalysisUnmountGuard(Weak<LiveAnalysisScheduler>);
486
487#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
488impl Drop for AnalysisUnmountGuard {
489    fn drop(&mut self) {
490        if let Some(scheduler) = self.0.upgrade() {
491            scheduler.unmount();
492        }
493    }
494}
495
496#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
497pub(crate) fn use_live_analysis_scheduler() -> Rc<LiveAnalysisScheduler> {
498    let scheduler = use_hook(|| Rc::new(LiveAnalysisScheduler::default()));
499    let scheduler_for_guard = Rc::downgrade(&scheduler);
500    use_hook(|| Rc::new(AnalysisUnmountGuard(scheduler_for_guard)));
501    scheduler
502}
503
504#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
505pub(crate) async fn run_live_analysis_schedule(
506    scheduler: Rc<LiveAnalysisScheduler>,
507    generation: u64,
508    cadence: Duration,
509    mut publish: impl FnMut() -> bool + 'static,
510) {
511    let cadence_millis = cadence.as_millis() as u32;
512    while scheduler.is_current(generation) {
513        if document_hidden() {
514            gloo_timers::future::TimeoutFuture::new(cadence_millis.max(250)).await;
515            continue;
516        }
517        if !publish() {
518            break;
519        }
520        gloo_timers::future::TimeoutFuture::new(cadence_millis).await;
521    }
522}
523
524#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
525fn document_hidden() -> bool {
526    web_sys::window()
527        .and_then(|window| window.document())
528        .is_some_and(|document| document.hidden())
529}
530
531/// Reduce amplitude peaks to at most `buckets` values, preserving the maximum
532/// value from each source window.
533pub fn downsample_peaks(peaks: &[u8], buckets: usize) -> Vec<u8> {
534    let bucket_count = peaks.len().min(buckets);
535    if bucket_count == 0 {
536        return Vec::new();
537    }
538
539    (0..bucket_count)
540        .map(|index| {
541            let start = index * peaks.len() / bucket_count;
542            let end = (index + 1) * peaks.len() / bucket_count;
543            peaks[start..end].iter().copied().max().unwrap_or(0)
544        })
545        .collect()
546}
547
548/// Return the largest distance from Web Audio's unsigned silence value (128),
549/// normalized to the full `u8` range.
550pub fn peak_amplitude(samples: &[u8]) -> u8 {
551    let distance = samples
552        .iter()
553        .map(|sample| (*sample as i16 - 128).unsigned_abs())
554        .max()
555        .unwrap_or(0);
556
557    ((u32::from(distance) * 255 + 64) / 128).min(255) as u8
558}
559
560/// An ordered source-time interval within an audio timeline.
561///
562/// Boundaries are finite, non-negative seconds. They may coincide while the
563/// selection is being edited, but a collapsed selection is not playable.
564#[derive(Clone, Copy, Debug, PartialEq)]
565pub struct WaveformSelection {
566    start: f64,
567    end: f64,
568}
569
570// Construction and mutation exclude NaN, so Waveform Selection has reflexive equality.
571impl Eq for WaveformSelection {}
572
573impl WaveformSelection {
574    pub fn new(start: f64, end: f64) -> Self {
575        let start = finite_non_negative(start);
576        let end = finite_non_negative(end);
577
578        Self {
579            start: start.min(end),
580            end: start.max(end),
581        }
582    }
583
584    pub fn start(self) -> f64 {
585        self.start
586    }
587
588    pub fn end(self) -> f64 {
589        self.end
590    }
591
592    pub fn is_collapsed(self) -> bool {
593        self.start == self.end
594    }
595
596    pub fn with_start(self, start: f64) -> Self {
597        let start = if start.is_finite() {
598            start.clamp(0.0, self.end)
599        } else {
600            self.start
601        };
602        Self {
603            start,
604            end: self.end,
605        }
606    }
607
608    pub fn with_end(self, end: f64) -> Self {
609        let end = if end.is_finite() {
610            end.max(self.start)
611        } else {
612            self.end
613        };
614        Self {
615            start: self.start,
616            end,
617        }
618    }
619
620    /// Clamp each boundary independently to an authoritative source duration.
621    pub fn clamped_to_duration(self, duration_secs: f64) -> Self {
622        let duration_secs = finite_non_negative(duration_secs);
623        Self {
624            start: self.start.min(duration_secs),
625            end: self.end.min(duration_secs),
626        }
627    }
628
629    /// Return whether this is a positive interval inside the source duration.
630    pub fn is_playable_within(self, duration_secs: f64) -> bool {
631        duration_secs.is_finite()
632            && duration_secs > 0.0
633            && !self.is_collapsed()
634            && self.end <= duration_secs
635    }
636}
637
638fn finite_non_negative(value: f64) -> f64 {
639    if value.is_finite() {
640        value.max(0.0)
641    } else {
642        0.0
643    }
644}
645
646/// Trim a source-time interval from interleaved PCM without splitting channel frames.
647pub fn trim_interleaved_pcm<T: Clone>(
648    samples: &[T],
649    channels: usize,
650    duration_secs: f64,
651    selection: WaveformSelection,
652) -> Vec<T> {
653    if channels == 0 || !duration_secs.is_finite() || duration_secs <= 0.0 {
654        return Vec::new();
655    }
656
657    let selection = selection.clamped_to_duration(duration_secs);
658    if selection.is_collapsed() {
659        return Vec::new();
660    }
661
662    let frame_count = samples.len() / channels;
663    let first_frame = (selection.start() / duration_secs * frame_count as f64).floor() as usize;
664    let end_frame = (selection.end() / duration_secs * frame_count as f64).ceil() as usize;
665    samples[first_frame.min(frame_count) * channels..end_frame.min(frame_count) * channels].to_vec()
666}