tempo_tapper 0.4.1

terminal tempo tapper
Documentation
// tempo: terminal tempo tapper (library)
// copyright (C) 2022  Nissa <and-nissa@protonmail.com>
// licensed under MIT OR Apache-2.0

#![forbid(unsafe_code)]
#![warn(missing_docs, clippy::pedantic, clippy::nursery)]

//! Library component of [`tempo`](https://gitlab.com/nissaofthesea/tempo), a
//! terminal tempo tapper.

use std::collections::VecDeque;
use std::time::Instant;

#[derive(Clone, Copy, Debug)]
pub(crate) struct CacheF32 {
    val: Option<f32>,

    // incremented every time val is updated. if it gets too high, cache is
    // invalidated to prevent floating point errors from accumulating.
    sadness: usize,
    max_sadness: usize,
}

impl CacheF32 {
    pub const fn new(max_sadness: usize) -> Self {
        Self {
            sadness: 0,
            val: None,
            max_sadness,
        }
    }

    pub fn invalidate(&mut self) {
        self.val = None;
        self.sadness = 0;
    }

    pub fn get<F: FnOnce() -> f32>(&mut self, f: F) -> f32 {
        if let Some(val) = self.val {
            val
        } else {
            let val = f();
            self.val = Some(val);
            val
        }
    }

    pub fn try_mut<F: FnOnce(&mut f32)>(&mut self, f: F) -> Option<f32> {
        self.cry();
        self.val.as_mut().map(|val| {
            f(val);
            *val
        })
    }

    pub fn try_add(&mut self, add: f32) -> Option<f32> {
        self.try_mut(|val| *val += add)
    }

    pub fn try_sub(&mut self, sub: f32) -> Option<f32> {
        self.try_mut(|val| *val -= sub)
    }

    fn cry(&mut self) -> bool {
        if self.sadness == self.max_sadness {
            self.invalidate();
            true
        } else {
            self.sadness += 1;
            false
        }
    }
}

/// Tempo tapper which measures the average BPM between taps.
#[derive(Clone, Debug)]
pub struct Tapper {
    buf: VecDeque<f32>, // len must not exceed u16::MAX
    cap: u16,
    bounded: bool,
    last_tap: Option<Instant>,
    buf_sum: CacheF32, // sum of all bpms
}

impl Tapper {
    const BUF_SUM_MAX_SADNESS: usize = 256;

    /// Returns a new `Tapper` with its buffer capped to `cap`.
    #[must_use]
    pub fn new(cap: u16, bounded: bool) -> Self {
        Self {
            buf: VecDeque::with_capacity(usize::from(cap) + 1),
            cap,
            bounded,
            last_tap: None,
            buf_sum: CacheF32::new(Self::BUF_SUM_MAX_SADNESS),
        }
    }

    /// Records the interval since the last tap.
    pub fn tap(&mut self) {
        let now = Instant::now();

        // update bpm
        if let Some(last) = self.last_tap.replace(now) {
            let elapsed = now.saturating_duration_since(last).as_secs_f32();

            // push a new bpm
            let bpm = elapsed.recip() * 60.0;
            self.buf.push_back(bpm);

            // update cache
            self.buf_sum.try_add(bpm);

            // remove old elements
            self.sync_cap();
        }
    }

    /// Clears the internal buffer of BPMs and forget the last tap.
    pub fn clear(&mut self) {
        self.buf_sum.invalidate();
        self.buf.clear();
        self.last_tap = None;
    }

    /// Resizes the internal buffer to the new capacity.
    pub fn resize(&mut self, new_cap: u16) {
        self.cap = new_cap;
        self.sync_cap();
    }

    /// Switches the internal buffer between being bounded to the capacity or
    /// unbounded.
    pub fn toggle_bounded(&mut self) {
        self.bounded ^= true;
        self.sync_cap();
    }

    /// Return the average BPM between recorded taps.
    #[must_use]
    pub fn bpm(&mut self) -> f32 {
        let count = self.count();
        if count == 0 {
            return 0.0;
        }

        let sum = self.buf_sum.get(|| self.buf.iter().sum());
        sum / f32::from(count)
    }

    /// Returns the number of samples in the internal buffer.
    #[allow(clippy::missing_panics_doc)]
    #[inline]
    #[must_use]
    pub fn count(&self) -> u16 {
        self.buf.len().try_into().unwrap() // len never exceeds u16::MAX
    }

    /// Returns the capacity of the internal buffer.
    ///
    /// # Notes
    ///
    /// The capacity is unaffected by whether the buffer is bounded.
    #[inline]
    #[must_use]
    pub const fn cap(&self) -> u16 {
        self.cap
    }

    /// Returns `true` if a tap has been recorded.
    #[inline]
    #[must_use]
    pub const fn is_recording(&self) -> bool {
        self.last_tap.is_some()
    }

    /// Returns `true` if the internal buffer is bounded.
    #[inline]
    #[must_use]
    pub const fn is_bounded(&self) -> bool {
        self.bounded
    }

    fn sync_cap(&mut self) {
        if self.bounded {
            // we're not comparing u16s here bc this may be called with u16::MAX
            // + 1 elems
            while usize::from(self.cap) < self.buf.len() {
                let bpm = self.buf.pop_front().unwrap();
                self.buf_sum.try_sub(bpm);
            }
        }
    }
}