Skip to main content

rust_synth/tui/
trajectory.rs

1//! Trajectory view — plots the modulation curves of the currently-selected
2//! track over the next N seconds, based on live parameter values. Shows
3//! *where the sound is going*, not where it's been.
4//!
5//! - cyan   : amplitude envelope (BPM pulse × gate, normalized)
6//! - yellow : cutoff trajectory (filter moving through phrase wobble)
7//! - magenta: per-beat decay envelope (heartbeat / pulse preview)
8
9use ratatui::layout::Rect;
10use ratatui::style::{Color, Modifier, Style};
11use ratatui::symbols::Marker;
12use ratatui::widgets::canvas::{Canvas, Context, Line};
13use ratatui::widgets::{Block, Borders};
14use ratatui::Frame;
15
16use super::app::AppState;
17use crate::audio::engine::EngineHandle;
18use crate::audio::preset::PresetKind;
19use crate::math::pulse::{pulse_decay, pulse_sine};
20
21const FORECAST_SECS: f32 = 16.0;
22const POINTS: usize = 256;
23
24pub fn render(f: &mut Frame, area: Rect, engine: &EngineHandle, app: &AppState) {
25    let tracks = engine.tracks.lock();
26    let Some(track) = tracks.get(app.selected_track) else {
27        return;
28    };
29    let s = track.params.snapshot();
30    let bpm = engine.global.bpm.value();
31    let t0 = engine.phase_clock.value();
32    let kind = track.kind;
33    drop(tracks);
34
35    let canvas = Canvas::default()
36        .block(
37            Block::default()
38                .borders(Borders::ALL)
39                .title(format!(" trajectory · next {:.0}s ", FORECAST_SECS))
40                .title_style(Style::default().add_modifier(Modifier::BOLD)),
41        )
42        .marker(Marker::Braille)
43        .x_bounds([0.0, FORECAST_SECS as f64])
44        .y_bounds([-0.05, 1.15])
45        .paint(move |ctx| {
46            // Beat grid — vertical ticks.
47            let beat_period = 60.0 / bpm.max(1.0);
48            let mut tb = 0.0f32;
49            while tb < FORECAST_SECS {
50                ctx.draw(&Line {
51                    x1: tb as f64,
52                    y1: 0.0,
53                    x2: tb as f64,
54                    y2: 1.05,
55                    color: Color::Rgb(30, 30, 40),
56                });
57                tb += beat_period;
58            }
59            // Baseline
60            ctx.draw(&Line {
61                x1: 0.0,
62                y1: 0.0,
63                x2: FORECAST_SECS as f64,
64                y2: 0.0,
65                color: Color::DarkGray,
66            });
67
68            draw_curve(ctx, Color::Cyan, |dt| amplitude_curve(kind, &s, bpm, t0 + dt));
69            draw_curve(ctx, Color::Yellow, |dt| cutoff_curve(kind, &s, bpm, t0 + dt));
70            if matches!(kind, PresetKind::Heartbeat) || s.pulse_depth > 0.05 {
71                draw_curve(ctx, Color::Magenta, |dt| {
72                    pulse_decay((t0 + dt) as f64, bpm as f64, 9.0) as f32
73                });
74            }
75        });
76
77    f.render_widget(canvas, area);
78}
79
80fn draw_curve(ctx: &mut Context, color: Color, mut f: impl FnMut(f32) -> f32) {
81    let mut prev: Option<(f64, f64)> = None;
82    for i in 0..POINTS {
83        let dt = (i as f32 / (POINTS - 1) as f32) * FORECAST_SECS;
84        let v = f(dt).clamp(-0.05, 1.15);
85        let x = dt as f64;
86        let y = v as f64;
87        if let Some((px, py)) = prev {
88            ctx.draw(&Line {
89                x1: px,
90                y1: py,
91                x2: x,
92                y2: y,
93                color,
94            });
95        }
96        prev = Some((x, y));
97    }
98    let _ = Style::default().add_modifier(Modifier::BOLD);
99}
100
101// Amplitude envelope — what the ear actually hears as volume over time.
102fn amplitude_curve(
103    kind: PresetKind,
104    s: &crate::audio::track::TrackSnapshot,
105    bpm: f32,
106    t: f32,
107) -> f32 {
108    let muted = if s.muted { 0.0 } else { 1.0 };
109    let g = s.gain * muted;
110    let pulse = pulse_sine(t as f64, bpm as f64) as f32;
111    let voice = g * (1.0 - s.pulse_depth + s.pulse_depth * pulse);
112    match kind {
113        PresetKind::DroneSub => voice * (0.88 + 0.12 * pulse),
114        PresetKind::Heartbeat => voice * pulse_decay(t as f64, bpm as f64, 9.0) as f32,
115        _ => voice,
116    }
117}
118
119// Cutoff trajectory — filter movement, normalized to [0,1] by visualisation range.
120fn cutoff_curve(
121    kind: PresetKind,
122    s: &crate::audio::track::TrackSnapshot,
123    _bpm: f32,
124    t: f32,
125) -> f32 {
126    let wobble = 1.0 + 0.10 * (0.5 - 0.5 * (t * 0.08).sin());
127    let raw = match kind {
128        PresetKind::PadZimmer => s.cutoff * wobble,
129        PresetKind::DroneSub => s.cutoff.clamp(40.0, 300.0),
130        PresetKind::Shimmer => 4000.0, // fixed HP
131        PresetKind::Heartbeat => s.freq * 0.5,
132        PresetKind::BassPulse => s.cutoff.min(900.0),
133        PresetKind::Bell => s.freq * 2.76,
134        PresetKind::SuperSaw => s.cutoff,
135        PresetKind::PluckSaw => s.cutoff * 0.3 + 180.0,
136    };
137    // Log-map 40..12000 Hz → [0, 1].
138    ((raw.max(1.0).ln() - 40f32.ln()) / (12000f32.ln() - 40f32.ln())).clamp(0.0, 1.0)
139}