rust_synth/tui/
trajectory.rs1use 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 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 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
101fn 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
119fn 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, 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 ((raw.max(1.0).ln() - 40f32.ln()) / (12000f32.ln() - 40f32.ln())).clamp(0.0, 1.0)
139}