Skip to main content

rust_synth/tui/
waveshape.rs

1//! Waveshape preview — draws two cycles of the fundamental waveform of
2//! the selected track. Unlike `trajectory`, this shows the *actual*
3//! time-domain shape the oscillator outputs, so you can immediately
4//! *see* sine vs saw vs FM vs super-saw.
5//!
6//! Sampled synthetically (not from the live graph) so it stays stable
7//! even when the preset is muted. Changes reflect user params in real
8//! time — dial detune and the SuperSaw stack visibly fattens; push FM
9//! depth and the Bell waveform warps out of a pure sine.
10
11use ratatui::layout::Rect;
12use ratatui::style::{Color, Modifier, Style};
13use ratatui::symbols::Marker;
14use ratatui::widgets::canvas::{Canvas, Context, Line};
15use ratatui::widgets::{Block, Borders};
16use ratatui::Frame;
17
18use super::app::AppState;
19use crate::audio::engine::EngineHandle;
20use crate::audio::preset::PresetKind;
21use crate::audio::track::TrackSnapshot;
22
23/// How many time-domain points to sample across two periods.
24const POINTS: usize = 240;
25/// Plot spans this many fundamental cycles horizontally.
26const CYCLES: f64 = 2.0;
27
28pub fn render(f: &mut Frame, area: Rect, engine: &EngineHandle, app: &AppState) {
29    let tracks = engine.tracks.lock();
30    let Some(track) = tracks.get(app.selected_track) else {
31        return;
32    };
33    let s = track.params.snapshot();
34    let kind = track.kind;
35    let name = track.name.clone();
36    drop(tracks);
37
38    let (color, subtitle) = describe(kind, &s);
39    let title = format!(" waveshape · {} · {} ", name, subtitle);
40
41    let canvas = Canvas::default()
42        .block(
43            Block::default()
44                .borders(Borders::ALL)
45                .title(title)
46                .title_style(Style::default().add_modifier(Modifier::BOLD)),
47        )
48        .marker(Marker::Braille)
49        .x_bounds([0.0, CYCLES])
50        .y_bounds([-1.15, 1.15])
51        .paint(move |ctx| {
52            // Zero line so positive/negative excursions are readable.
53            ctx.draw(&Line {
54                x1: 0.0,
55                y1: 0.0,
56                x2: CYCLES,
57                y2: 0.0,
58                color: Color::Rgb(40, 40, 48),
59            });
60            // Cycle boundary.
61            for k in 1..CYCLES as u32 {
62                ctx.draw(&Line {
63                    x1: k as f64,
64                    y1: -1.1,
65                    x2: k as f64,
66                    y2: 1.1,
67                    color: Color::Rgb(30, 30, 38),
68                });
69            }
70            draw_waveshape(ctx, kind, &s, color);
71        });
72
73    f.render_widget(canvas, area);
74}
75
76fn describe(kind: PresetKind, s: &TrackSnapshot) -> (Color, String) {
77    match kind {
78        PresetKind::PadZimmer => (
79            Color::Cyan,
80            "4 detuned partials [1, 1.501, 2.013, 3.007]".to_string(),
81        ),
82        PresetKind::DroneSub => (
83            Color::Magenta,
84            format!("sub sine + brown @ ≤{} Hz", s.cutoff.min(300.0) as u32),
85        ),
86        PresetKind::Shimmer => (
87            Color::LightYellow,
88            "3 high partials [×2, ×3, ×4.007]".to_string(),
89        ),
90        PresetKind::Heartbeat => (
91            Color::Red,
92            "kick body · pitch-swept sine".to_string(),
93        ),
94        PresetKind::BassPulse => (
95            Color::Green,
96            "sine stack [×½, ×1, ×2]".to_string(),
97        ),
98        PresetKind::Bell => (
99            Color::LightBlue,
100            format!("FM · mod 2.76, depth {:.2}", s.resonance.min(0.65)),
101        ),
102        PresetKind::SuperSaw => (
103            Color::LightGreen,
104            format!("7-saw unison · spread {:.0} ct", s.detune.abs()),
105        ),
106        PresetKind::PluckSaw => (
107            Color::Yellow,
108            format!("2-saw · detune {:+.0} ct", s.detune),
109        ),
110    }
111}
112
113fn draw_waveshape(ctx: &mut Context, kind: PresetKind, s: &TrackSnapshot, color: Color) {
114    let mut prev: Option<(f64, f64)> = None;
115    for i in 0..POINTS {
116        let x = i as f64 / (POINTS - 1) as f64 * CYCLES;
117        // Phase along the fundamental — x = 1 means one cycle.
118        let y = sample(kind, s, x).clamp(-1.1, 1.1);
119        if let Some((px, py)) = prev {
120            ctx.draw(&Line {
121                x1: px,
122                y1: py,
123                x2: x,
124                y2: y,
125                color,
126            });
127        }
128        prev = Some((x, y));
129    }
130}
131
132fn sample(kind: PresetKind, s: &TrackSnapshot, phase: f64) -> f64 {
133    let tau = std::f64::consts::TAU;
134    let p = tau * phase;
135    match kind {
136        PresetKind::PadZimmer => {
137            let det = s.detune as f64 * 0.000578;
138            0.30 * (p * 1.000).sin()
139                + 0.20 * (p * 1.501 * (1.0 + det)).sin()
140                + 0.14 * (p * 2.013 * (1.0 + det)).sin()
141                + 0.08 * (p * 3.007).sin()
142        }
143        PresetKind::DroneSub => {
144            // Sub sine + small pseudo-noise (deterministic) — the real
145            // preset has brown noise but showing random would jitter
146            // every frame. Use a detuned 2nd sine for visual texture.
147            0.60 * (p * 0.5).sin() + 0.15 * (p * 1.0).sin() + 0.08 * (p * 2.03).sin()
148        }
149        PresetKind::Shimmer => {
150            0.40 * (p * 2.000).sin() + 0.30 * (p * 3.000).sin() + 0.20 * (p * 4.007).sin()
151        }
152        PresetKind::Heartbeat => {
153            // Show the kick body shape: pitch-swept sine across the 2
154            // cycles — starts fast, slows. env decay layered on top.
155            let pitch = 0.7 + 1.5 * (-phase * 5.0).exp();
156            let env = (-phase * 2.5).exp();
157            (p * pitch).sin() * env
158        }
159        PresetKind::BassPulse => {
160            0.55 * (p * 1.0).sin() + 0.22 * (p * 2.0).sin() + 0.35 * (p * 0.5).sin()
161        }
162        PresetKind::Bell => {
163            // Real preset: mod_freq = f·2.76, mod amplitude = resonance·450 Hz.
164            // Modulation index for visualisation: resonance·3 (dimensionless).
165            let depth = s.resonance.min(0.65) as f64;
166            let modulator = (p * 2.76).sin() * (depth * 3.5);
167            (p + modulator).sin()
168        }
169        PresetKind::SuperSaw => {
170            const OFFS: [f64; 7] = [-1.0, -0.66, -0.33, 0.0, 0.33, 0.66, 1.0];
171            let width = (s.detune.abs() as f64).max(1.0);
172            let mut sum = 0.0;
173            for off in OFFS {
174                let ratio = 2.0_f64.powf(off * width / 1200.0);
175                let x = phase * ratio;
176                // Naïve saw: 2·frac(x + 0.5) − 1 ∈ [−1, 1].
177                sum += 2.0 * (x - (x + 0.5).floor());
178            }
179            sum / OFFS.len() as f64
180        }
181        PresetKind::PluckSaw => {
182            let cents_b = s.detune as f64 * 0.5;
183            let ratio_b = 2.0_f64.powf(cents_b / 1200.0);
184            let sa = 2.0 * (phase - (phase + 0.5).floor());
185            let xb = phase * ratio_b;
186            let sb = 2.0 * (xb - (xb + 0.5).floor());
187            0.5 * sa + 0.5 * sb
188        }
189    }
190}