termwaves-client 0.1.0

Terminal UI for real-time audio visualization, powered by termwaves and PipeWire
use ratatui::{
    prelude::*,
    widgets::canvas::{Canvas, Line as CanvasLine},
};

use crossterm::event::KeyCode;

use crate::color::surface_color;
use crate::view::{Ctx, View, framed, placeholder_text};

const DEFAULT_DEPTH: usize = 9;
const PEAK_HEIGHT: f32 = 4.0;
const SPIKE_GAMMA: f32 = 2.4;
const CAM_HEIGHT: f32 = 2.0;
const CAM_SETBACK: f32 = 10.0;
const CAM_PITCH: f32 = 0.66;
const FOCAL: f32 = 2.4;
const WORLD_HALF_WIDTH: f32 = 1.4;
const HORIZON_LIFT: f32 = 2.0;
const SMOOTHING: f32 = 0.33;
const ADAPT_ALPHA: f32 = 0.01;
const TILT_STRENGTH: f32 = 1.0;
const PEAK_FLOOR: f32 = 0.001;
const LIGHT_DIR: (f32, f32, f32) = (-0.5, 0.8, 0.5);
const AMBIENT: f32 = 0.2;
const ISLAND_MIN_DEPTH: usize = 6;

const YAW_SPEED_DEFAULT: f32 = 0.03;
const YAW_SPEED_STEP: f32 = 0.01;
const YAW_SPEED_MAX: f32 = 0.3;
const FIELD_DECAY: f32 = 0.94;
const FIELD_STAMP: f32 = 0.6;

pub struct Terrain {
    rows: Vec<f32>,
    width: usize,
    depth: usize,
    head: usize,
    primed: bool,
    centroid: f32,
    peak: f32,
    rotary: bool,
    yaw: f32,
    yaw_speed: f32,
    field: Vec<f32>,
    field_peak: f32,
}

impl Terrain {
    pub fn new(width: usize) -> Self {
        let width = width.max(1);
        Self {
            rows: vec![0.0; DEFAULT_DEPTH * width],
            width,
            depth: DEFAULT_DEPTH,
            head: 0,
            primed: false,
            centroid: 0.5,
            peak: 1.0,
            rotary: false,
            yaw: 0.0,
            yaw_speed: YAW_SPEED_DEFAULT,
            field: vec![0.0; DEFAULT_DEPTH * width],
            field_peak: 1.0,
        }
    }

    fn set_depth(&mut self, depth: usize) {
        if depth == self.depth {
            return;
        }
        self.depth = depth;
        self.rows = vec![0.0; depth * self.width];
        self.field = vec![0.0; depth * self.width];
        self.field_peak = 1.0;
        self.head = 0;
        self.primed = false;
        self.centroid = 0.5;
        self.peak = 1.0;
    }

    fn push(&mut self, magnitudes: &[f32]) {
        let prev = (self.head + self.depth - 1) % self.depth;
        let base = self.head * self.width;
        for x in 0..self.width {
            let target = magnitudes.get(x).copied().unwrap_or(0.0);
            let last = if self.primed {
                self.rows[prev * self.width + x]
            } else {
                target
            };
            self.rows[base + x] = last + SMOOTHING * (target - last);
        }
        self.head = (self.head + 1) % self.depth;
        self.primed = true;
    }

    fn stamp_field(&mut self, magnitudes: &[f32]) {
        let (s, c) = (self.yaw.sin(), self.yaw.cos());
        const RIDGE: f32 = 0.18;
        let last_x = (self.width - 1).max(1) as f32;
        let last_z = (self.depth - 1).max(1) as f32;
        let mut peak = 0.0f32;
        for z in 0..self.depth {
            let v = z as f32 / last_z - 0.5;
            for x in 0..self.width {
                let u = x as f32 / last_x - 0.5;
                let along = u * c + v * s;
                let across = -u * s + v * c;
                let band_pos = (across + 0.5).clamp(0.0, 1.0);
                let band = (band_pos * last_x).round() as usize;
                let mag = magnitudes.get(band).copied().unwrap_or(0.0);
                let weight = (-(along * along) / (RIDGE * RIDGE)).exp();
                let cell = z * self.width + x;
                let deposit = mag * weight * FIELD_STAMP;
                let val = (self.field[cell] * FIELD_DECAY + deposit).min(4.0);
                self.field[cell] = val;
                peak = peak.max(val);
            }
        }
        self.field_peak += ADAPT_ALPHA * (peak - self.field_peak);
    }

    fn adapt(&mut self) {
        if !self.primed || self.width < 2 {
            return;
        }
        let mut energy = 0.0f32;
        let mut weighted = 0.0f32;
        let mut peak = 0.0f32;
        let span = (self.width - 1) as f32;
        for ring in 0..self.depth {
            for x in 0..self.width {
                let m = self.rows[ring * self.width + x];
                let e = m * m;
                energy += e;
                weighted += e * (x as f32 / span);
                peak = peak.max(m);
            }
        }
        let frame_centroid = if energy > 0.0 { weighted / energy } else { 0.5 };
        self.centroid += ADAPT_ALPHA * (frame_centroid - self.centroid);
        self.peak += ADAPT_ALPHA * (peak - self.peak);
    }

    fn emphasis(&self, x: usize) -> f32 {
        let pos = x as f32 / (self.width - 1).max(1) as f32;
        let alignment = (1.0 - (pos - self.centroid).abs()).max(0.0);
        1.0 + (TILT_STRENGTH - 1.0) * alignment
    }

    fn height(&self, r: usize, x: usize) -> f32 {
        if self.rotary {
            let raw = self.field[r * self.width + x];
            return (raw / self.field_peak.max(PEAK_FLOOR)).min(1.0);
        }
        let norm = self.peak.max(PEAK_FLOOR);
        let ring = (self.head + self.depth - 1 - r) % self.depth;
        let raw = self.rows[ring * self.width + x];
        (raw * self.emphasis(x) / norm).min(1.0)
    }

    fn island_factor(&self, r: usize) -> f32 {
        if self.rotary || self.depth < ISLAND_MIN_DEPTH {
            return 1.0;
        }
        let t = r as f32 / (self.depth - 1) as f32;
        (t * std::f32::consts::PI).sin()
    }

    fn surface_y(&self, x: usize, r: usize) -> f32 {
        self.height(r, x).powf(SPIKE_GAMMA) * PEAK_HEIGHT * self.island_factor(r)
    }

    fn world(&self, x: usize, r: usize) -> (f32, f32, f32) {
        let wx = (x as f32 / (self.width - 1) as f32 - 0.5) * 2.0 * WORLD_HALF_WIDTH;
        let wy = self.surface_y(x, r);
        let wz = r as f32;
        (wx, wy, wz)
    }

    fn face_shade(&self, x: usize, r: usize) -> Option<f32> {
        if x + 1 >= self.width || r + 1 >= self.depth {
            return None;
        }
        let a = self.world(x, r);
        let b = self.world(x + 1, r);
        let c = self.world(x, r + 1);
        let e1 = (b.0 - a.0, b.1 - a.1, b.2 - a.2);
        let e2 = (c.0 - a.0, c.1 - a.1, c.2 - a.2);
        let mut n = (
            e1.1 * e2.2 - e1.2 * e2.1,
            e1.2 * e2.0 - e1.0 * e2.2,
            e1.0 * e2.1 - e1.1 * e2.0,
        );
        if n.1 < 0.0 {
            n = (-n.0, -n.1, -n.2);
        }
        let nlen = (n.0 * n.0 + n.1 * n.1 + n.2 * n.2).sqrt().max(1e-6);
        let (lx, ly, lz) = LIGHT_DIR;
        let llen = (lx * lx + ly * ly + lz * lz).sqrt();
        let dot = (n.0 * lx + n.1 * ly + n.2 * lz) / (nlen * llen);
        Some(dot.max(0.0).mul_add(1.0 - AMBIENT, AMBIENT))
    }

    fn edge_shade(&self, x: usize, r: usize, along_x: bool) -> f32 {
        let (a, b) = if along_x {
            (
                self.face_shade(x, r),
                r.checked_sub(1).and_then(|rp| self.face_shade(x, rp)),
            )
        } else {
            (
                self.face_shade(x, r),
                x.checked_sub(1).and_then(|xp| self.face_shade(xp, r)),
            )
        };
        match (a, b) {
            (Some(a), Some(b)) => 0.5 * (a + b),
            (Some(s), None) | (None, Some(s)) => s,
            (None, None) => AMBIENT + (1.0 - AMBIENT) * 0.5,
        }
    }

    fn render_wireframe(&self, f: &mut Frame, area: Rect) {
        if !self.primed || self.width < 2 {
            return;
        }

        let sx = (area.width as f64 * 2.0).max(1.0);
        let sy = (area.height as f64 * 4.0).max(1.0);

        const CELL_ASPECT: f64 = 2.0;
        let aspect = (sy / sx) * (CELL_ASPECT * CELL_ASPECT);

        let width = self.width;
        let depth = self.depth;
        let project = |wx: f32, wy: f32, wz: f32| -> Option<(f64, f64)> {
            let ex = wx;
            let ty = wy - CAM_HEIGHT;
            let tz = wz + CAM_SETBACK;

            let (sp, cp) = (CAM_PITCH.sin(), CAM_PITCH.cos());
            let ey = ty * cp - tz * sp;
            let ez = ty * sp + tz * cp;
            if ez <= 0.05 {
                return None;
            }

            let ndc_x = (FOCAL * ex / ez) as f64 * aspect;
            let ndc_y = (FOCAL * ey / ez + HORIZON_LIFT) as f64;
            let px = (ndc_x * 0.5 + 0.5) * sx;
            let py = (ndc_y * 0.5 + 0.5) * sy;
            Some((px, py))
        };
        let vertex = |x: usize, r: usize| {
            let (wx, wy, wz) = self.world(x, r);
            project(wx, wy, wz)
        };

        let canvas = Canvas::default()
            .x_bounds([0.0, sx])
            .y_bounds([0.0, sy])
            .paint(move |ctx| {
                for r in (0..depth).rev() {
                    for x in 0..width {
                        let Some((px, py)) = vertex(x, r) else {
                            continue;
                        };

                        if x + 1 < width
                            && let Some((nx, ny)) = vertex(x + 1, r)
                        {
                            ctx.draw(&CanvasLine {
                                x1: px,
                                y1: py,
                                x2: nx,
                                y2: ny,
                                color: surface_color(self.edge_shade(x, r, true)),
                            });
                        }
                        if r > 0
                            && let Some((nx, ny)) = vertex(x, r - 1)
                        {
                            ctx.draw(&CanvasLine {
                                x1: px,
                                y1: py,
                                x2: nx,
                                y2: ny,
                                color: surface_color(self.edge_shade(x, r - 1, false)),
                            });
                        }
                    }
                }
            });
        f.render_widget(canvas, area);
    }
}

impl View for Terrain {
    fn name(&self) -> &str {
        "3D terrain"
    }

    fn tick(&mut self, ctx: &Ctx) {
        if self.rotary {
            self.yaw = (self.yaw + self.yaw_speed).rem_euclid(std::f32::consts::TAU);
        }
        if let Some(spectrum) = ctx.spectrum {
            let row: Vec<f32> = spectrum.bands().iter().map(|b| b.magnitude).collect();
            self.push(&row);
            self.adapt();
            if self.rotary {
                self.stamp_field(&row);
            }
        }
    }

    fn handle_key(&mut self, code: KeyCode) -> bool {
        match code {
            KeyCode::Char(c @ '1'..='9') => {
                self.set_depth((c as usize - '0' as usize) * 2);
                true
            }
            KeyCode::Char('r') => {
                self.rotary = !self.rotary;
                if self.rotary {
                    self.field.iter_mut().for_each(|c| *c = 0.0);
                    self.field_peak = 1.0;
                }
                true
            }
            KeyCode::Char(']') => {
                self.yaw_speed = (self.yaw_speed + YAW_SPEED_STEP).min(YAW_SPEED_MAX);
                true
            }
            KeyCode::Char('[') => {
                self.yaw_speed = (self.yaw_speed - YAW_SPEED_STEP).max(-YAW_SPEED_MAX);
                true
            }
            _ => false,
        }
    }

    fn render(&self, f: &mut Frame, area: Rect, _ctx: &Ctx) {
        let inner = framed(f, area, "3D terrain");
        if self.primed {
            self.render_wireframe(f, inner);
        } else {
            placeholder_text(f, inner, "warming up…");
        }
    }
}