lookas 1.10.0

Perception aligned terminal-based audio spectrum visualizer
Documentation
use anyhow::Result;
use crossterm::event::KeyCode;
use lookas::{
    audio::{AudioController, AudioError, AudioMode},
    buffer::SharedBuf,
    config::Config,
};
use std::sync::{Arc, Mutex};

mod frame;
mod gate;
mod input;

pub(super) use frame::Frame;

use input::{InputContext, KeyAction, handle_key};

pub enum InputAction {
    Continue,
    Quit,
    AudioChanged,
}

pub enum StartupCapture {
    System,
    MicFallback { system_error: AudioError },
}

pub struct RuntimeDiagnostics {
    pub startup_capture: StartupCapture,
}

pub struct Runtime {
    fft_size: usize,
    audio: AudioController,
    mic_shared: Arc<Mutex<SharedBuf>>,
    sys_shared: Arc<Mutex<SharedBuf>>,
    sr: f32,
    cap: usize,
    sr_u32: u32,
    diagnostics: RuntimeDiagnostics,
}

impl Runtime {
    pub fn new(cfg: &Config) -> Result<Self> {
        let fft_size = cfg.fft_size;
        let cap = ring_cap(fft_size);
        let mic_shared = Arc::new(Mutex::new(SharedBuf::new(cap)));
        let sys_shared = Arc::new(Mutex::new(SharedBuf::new(cap)));

        let mut audio = AudioController::new();
        let startup_capture = match audio.start(
            AudioMode::System,
            mic_shared.clone(),
            sys_shared.clone(),
        ) {
            Ok(()) => StartupCapture::System,
            Err(system_error) => {
                audio.start(
                    AudioMode::Mic,
                    mic_shared.clone(),
                    sys_shared.clone(),
                )?;

                StartupCapture::MicFallback { system_error }
            }
        };

        let diagnostics = RuntimeDiagnostics { startup_capture };

        let sr_u32 = audio.info().sample_rate;
        #[allow(clippy::cast_precision_loss)]
        let sr = sr_u32 as f32;

        Ok(Self {
            fft_size,
            audio,
            mic_shared,
            sys_shared,
            sr,
            cap,
            sr_u32,
            diagnostics,
        })
    }

    pub fn handle_key(
        &mut self,
        code: KeyCode,
    ) -> Result<InputAction> {
        let mut ctx = InputContext {
            audio: &mut self.audio,
            mic_shared: &self.mic_shared,
            sys_shared: &self.sys_shared,
            ring_cap: self.cap,
        };

        if matches!(handle_key(code, &mut ctx)?, KeyAction::Quit) {
            return Ok(InputAction::Quit);
        }

        if self.update_sample_rate() {
            return Ok(InputAction::AudioChanged);
        }

        Ok(InputAction::Continue)
    }

    pub fn set_fft_size(&mut self, fft_size: usize) {
        if fft_size == self.fft_size {
            return;
        }

        let cap = ring_cap(fft_size);

        if let Ok(mut buffer) = self.mic_shared.lock() {
            *buffer = SharedBuf::new(cap);
        }
        if let Ok(mut buffer) = self.sys_shared.lock() {
            *buffer = SharedBuf::new(cap);
        }

        self.fft_size = fft_size;
        self.cap = cap;
    }

    pub const fn fft_size(&self) -> usize {
        self.fft_size
    }

    pub const fn sample_rate(&self) -> f32 {
        self.sr
    }

    pub const fn mode(&self) -> AudioMode {
        self.audio.mode()
    }

    pub const fn diagnostics(&self) -> &RuntimeDiagnostics {
        &self.diagnostics
    }

    pub fn copy_mic_tail(&self, tail: &mut Vec<f32>) -> bool {
        self.mic_shared
            .try_lock()
            .is_ok_and(|b| b.copy_last_n_into(self.fft_size, tail))
    }
    pub fn copy_system_tail(&self, tail: &mut Vec<f32>) -> bool {
        self.sys_shared
            .try_lock()
            .is_ok_and(|b| b.copy_last_n_into(self.fft_size, tail))
    }

    fn update_sample_rate(&mut self) -> bool {
        let new_sr = self.audio.info().sample_rate;
        if new_sr == self.sr_u32 {
            return false;
        }

        self.sr_u32 = new_sr;
        #[allow(clippy::cast_precision_loss)]
        {
            self.sr = self.sr_u32 as f32;
        }
        true
    }
}

#[allow(clippy::arithmetic_side_effects)]
fn ring_cap(fft_size: usize) -> usize {
    ((48_000usize / 10).max(fft_size * 3))
        .max(fft_size * 6)
        .next_power_of_two()
}