termwaves-client 0.1.0

Terminal UI for real-time audio visualization, powered by termwaves and PipeWire
mod color;
mod combined;
mod terrain;
mod view;

use std::io::{self, Stdout};
use std::time::Duration;

use crossterm::{
    event::{self, Event, KeyCode, KeyEventKind},
    execute,
    terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
use ratatui::prelude::*;
use ratatui::widgets::{Block, Borders, Clear, Paragraph};

use termwaves::{Spectrum, WaveScope};

use combined::Combined;
use terrain::Terrain;
use view::{Ctx, Placeholder, View};

const SPEC_MIN_HZ: f32 = 24.0;
const SPEC_MAX_HZ: f32 = 20_000.0;
const N_BANDS: usize = 32;

const WINDOW_MIN: usize = 200;
const WINDOW_MAX: usize = 24_000;
const WINDOW_DEFAULT: usize = 4_800;

const FRAME: Duration = Duration::from_millis(16);

#[derive(Clone, Copy, PartialEq, Eq)]
enum Overlay {
    None,
    Help,
    Settings,
}

struct App {
    wave: WaveScope,
    spectrum: Option<Spectrum>,
    window: usize,
    channel: usize,
    views: Vec<Box<dyn View>>,
    active: usize,
    overlay: Overlay,
}

impl App {
    fn new(wave: WaveScope) -> Self {
        let views: Vec<Box<dyn View>> = vec![
            Box::new(Terrain::new(N_BANDS)),
            Box::new(Combined),
            Box::new(Placeholder::new(3)),
            Box::new(Placeholder::new(4)),
            Box::new(Placeholder::new(5)),
            Box::new(Placeholder::new(6)),
            Box::new(Placeholder::new(7)),
            Box::new(Placeholder::new(8)),
        ];
        Self {
            wave,
            spectrum: None,
            window: WINDOW_DEFAULT,
            channel: 0,
            views,
            active: 0,
            overlay: Overlay::None,
        }
    }

    fn ctx(&self) -> Ctx<'_> {
        Ctx {
            wave: &self.wave,
            spectrum: self.spectrum.as_ref(),
            channel: self.channel,
            window: self.window,
        }
    }

    fn tick(&mut self) {
        self.wave.tick();
        if self.spectrum.is_none() && self.wave.is_ready() {
            self.spectrum = Some(Spectrum::new(
                self.wave.sample_rate(),
                N_BANDS,
                SPEC_MIN_HZ,
                SPEC_MAX_HZ,
            ));
        }
        if let Some(spectrum) = self.spectrum.as_mut() {
            spectrum.compute(&self.wave, self.channel);
        }

        let ctx = Ctx {
            wave: &self.wave,
            spectrum: self.spectrum.as_ref(),
            channel: self.channel,
            window: self.window,
        };
        for v in &mut self.views {
            v.tick(&ctx);
        }
    }

    fn select_fkey(&mut self, n: u8) {
        let idx = (n as usize).wrapping_sub(1);
        if idx < self.views.len() {
            self.active = idx;
        }
    }

    fn zoom_in(&mut self) {
        self.window = (self.window / 2).max(WINDOW_MIN);
    }

    fn zoom_out(&mut self) {
        self.window = (self.window * 2).min(WINDOW_MAX);
    }

    fn next_channel(&mut self) {
        let n = self.wave.channel_count();
        if n > 0 {
            self.channel = (self.channel + 1) % n;
        }
    }

    fn toggle_overlay(&mut self, overlay: Overlay) {
        self.overlay = if self.overlay == overlay {
            Overlay::None
        } else {
            overlay
        };
    }

    fn forward_key(&mut self, code: KeyCode) {
        self.views[self.active].handle_key(code);
    }
}

fn main() -> io::Result<()> {
    let handle = termwaves::start();
    let app = App::new(WaveScope::new(handle));

    let mut terminal = setup_terminal()?;
    let result = run(&mut terminal, app);
    restore_terminal(&mut terminal)?;
    result
}

fn run(terminal: &mut Terminal<CrosstermBackend<Stdout>>, mut app: App) -> io::Result<()> {
    loop {
        app.tick();
        terminal.draw(|f| ui(f, &app))?;

        if event::poll(FRAME)?
            && let Event::Key(key) = event::read()?
            && key.kind == KeyEventKind::Press
        {
            match key.code {
                KeyCode::Esc if app.overlay != Overlay::None => app.overlay = Overlay::None,
                KeyCode::Char('q') | KeyCode::Esc => return Ok(()),
                KeyCode::F(9) => app.toggle_overlay(Overlay::Help),
                KeyCode::F(10) => app.toggle_overlay(Overlay::Settings),
                KeyCode::Char('+') | KeyCode::Char('=') => app.zoom_in(),
                KeyCode::Char('-') | KeyCode::Char('_') => app.zoom_out(),
                KeyCode::Tab | KeyCode::Char('c') => app.next_channel(),
                KeyCode::F(n) => app.select_fkey(n),
                code => app.forward_key(code),
            }
        }
    }
}

fn ui(f: &mut Frame, app: &App) {
    let chunks = Layout::vertical([Constraint::Length(1), Constraint::Min(0)]).split(f.area());

    render_status(f, chunks[0], app);
    app.views[app.active].render(f, chunks[1], &app.ctx());

    match app.overlay {
        Overlay::None => {}
        Overlay::Help => render_help(f, chunks[1]),
        Overlay::Settings => render_settings(f, chunks[1]),
    }
}

fn render_status(f: &mut Frame, area: Rect, app: &App) {
    let status = if app.wave.is_ready() {
        format!(
            " termwaves: {} · ch {}/{} @ {} Hz · window {} samp   [F9 help · F10 settings]",
            app.views[app.active].name(),
            app.channel,
            app.wave.channel_count(),
            app.wave.sample_rate(),
            app.window,
        )
    } else {
        " termwaves: waiting for audio…   [F9 help · q quit]".to_string()
    };
    f.render_widget(
        Line::from(status).style(Style::default().add_modifier(Modifier::DIM)),
        area,
    );
}

fn render_overlay(f: &mut Frame, area: Rect, title: &str, lines: Vec<Line>) {
    let content_w = lines
        .iter()
        .map(Line::width)
        .max()
        .unwrap_or(0)
        .max(title.len()) as u16;
    let w = (content_w + 4).min(area.width);
    let h = (lines.len() as u16 + 2).min(area.height);
    let x = area.x + (area.width.saturating_sub(w)) / 2;
    let y = area.y + (area.height.saturating_sub(h)) / 2;
    let panel = Rect::new(x, y, w, h);

    let block = Block::default()
        .borders(Borders::ALL)
        .title(title.to_string());
    let inner = block.inner(panel);
    f.render_widget(Clear, panel);
    f.render_widget(block, panel);
    f.render_widget(Paragraph::new(lines), inner);
}

fn render_help(f: &mut Frame, area: Rect) {
    let key = |k: &str, desc: &str| {
        Line::from(vec![
            Span::styled(
                format!("{k:<10}"),
                Style::default().add_modifier(Modifier::BOLD),
            ),
            Span::raw(desc.to_string()),
        ])
    };
    let section = |title: &str| {
        Line::from(Span::styled(
            title.to_string(),
            Style::default().add_modifier(Modifier::DIM),
        ))
    };
    let lines = vec![
        key("F1-F8", "switch view"),
        key("+ / -", "zoom window"),
        key("Tab / c", "next channel"),
        key("F9", "toggle this help"),
        key("F10", "settings"),
        key("Esc", "close overlay"),
        key("q", "quit"),
        Line::from(""),
        section("3D terrain (F1)"),
        key("1-9", "terrain depth"),
        key("r", "toggle rotary mode"),
        key("[ / ]", "rotary speed -/+"),
    ];
    render_overlay(f, area, " Help ", lines);
}

fn render_settings(f: &mut Frame, area: Rect) {
    let lines = vec![
        Line::from("Settings coming soon.").style(Style::default().add_modifier(Modifier::DIM)),
        Line::from(""),
        Line::from("Press F10 or Esc to close."),
    ];
    render_overlay(f, area, " Settings ", lines);
}

fn setup_terminal() -> io::Result<Terminal<CrosstermBackend<Stdout>>> {
    enable_raw_mode()?;
    let mut stdout = io::stdout();
    execute!(stdout, EnterAlternateScreen)?;
    Terminal::new(CrosstermBackend::new(stdout))
}

fn restore_terminal(terminal: &mut Terminal<CrosstermBackend<Stdout>>) -> io::Result<()> {
    disable_raw_mode()?;
    execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
    terminal.show_cursor()
}