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    let c = s.character as f64;
78    match kind {
79        PresetKind::PadZimmer => {
80            let r1 = 1.0 + lerp3(1.0, 0.501, 0.618, c);
81            let r2 = 2.0 + lerp3(0.0, 0.013, 0.414, c);
82            let r3 = 3.0 + lerp3(0.0, 0.007, 0.739, c);
83            (Color::Cyan, format!("partials [1, {r1:.3}, {r2:.3}, {r3:.3}]"))
84        }
85        PresetKind::DroneSub => (
86            Color::Magenta,
87            format!("sub sine + noise @ ≤{} Hz", s.cutoff.min(300.0) as u32),
88        ),
89        PresetKind::Shimmer => {
90            let r1 = lerp3(2.0, 2.0, 2.1, c);
91            let r2 = lerp3(3.0, 3.0, 3.3, c);
92            let r3 = lerp3(4.0, 4.007, 4.8, c);
93            (Color::LightYellow, format!("partials [×{r1:.2}, ×{r2:.2}, ×{r3:.2}]"))
94        }
95        PresetKind::Heartbeat => {
96            let drop = lerp3(0.3, 1.5, 3.0, c);
97            (Color::Red, format!("pitch-swept kick · drop ×{drop:.2}"))
98        }
99        PresetKind::BassPulse => (
100            Color::Green,
101            "sine stack [×½, ×1, ×2]".to_string(),
102        ),
103        PresetKind::Bell => {
104            let ratio = lerp3(1.41, 2.76, 4.18, c);
105            (Color::LightBlue, format!("FM ratio {ratio:.2} · depth {:.2}", s.resonance.min(0.65)))
106        }
107        PresetKind::SuperSaw => (
108            Color::LightGreen,
109            format!("7-saw unison · spread {:.0} ct", s.detune.abs()),
110        ),
111        PresetKind::PluckSaw => (
112            Color::Yellow,
113            format!("2-saw · detune {:+.0} ct", s.detune),
114        ),
115    }
116}
117
118// Local lerp3 so we don't need to cross-import from audio::preset.
119fn lerp3(a: f64, b: f64, d: f64, c: f64) -> f64 {
120    let c = c.clamp(0.0, 1.0);
121    if c < 0.5 {
122        a + (b - a) * (c * 2.0)
123    } else {
124        b + (d - b) * ((c - 0.5) * 2.0)
125    }
126}
127
128fn draw_waveshape(ctx: &mut Context, kind: PresetKind, s: &TrackSnapshot, color: Color) {
129    let mut prev: Option<(f64, f64)> = None;
130    for i in 0..POINTS {
131        let x = i as f64 / (POINTS - 1) as f64 * CYCLES;
132        // Phase along the fundamental — x = 1 means one cycle.
133        let y = sample(kind, s, x).clamp(-1.1, 1.1);
134        if let Some((px, py)) = prev {
135            ctx.draw(&Line {
136                x1: px,
137                y1: py,
138                x2: x,
139                y2: y,
140                color,
141            });
142        }
143        prev = Some((x, y));
144    }
145}
146
147fn sample(kind: PresetKind, s: &TrackSnapshot, phase: f64) -> f64 {
148    let tau = std::f64::consts::TAU;
149    let p = tau * phase;
150    let c = s.character as f64;
151    match kind {
152        PresetKind::PadZimmer => {
153            let det = s.detune as f64 * 0.000578;
154            let r1 = 1.0 + lerp3(1.0, 0.501, 0.618, c);
155            let r2 = 2.0 + lerp3(0.0, 0.013, 0.414, c);
156            let r3 = 3.0 + lerp3(0.0, 0.007, 0.739, c);
157            0.30 * (p * 1.000).sin()
158                + 0.20 * (p * r1 * (1.0 + det)).sin()
159                + 0.14 * (p * r2 * (1.0 + det)).sin()
160                + 0.08 * (p * r3).sin()
161        }
162        PresetKind::DroneSub => {
163            // Sub sine + small pseudo-noise (deterministic) — the real
164            // preset has brown noise but showing random would jitter
165            // every frame. Use a detuned 2nd sine for visual texture.
166            0.60 * (p * 0.5).sin() + 0.15 * (p * 1.0).sin() + 0.08 * (p * 2.03).sin()
167        }
168        PresetKind::Shimmer => {
169            let r1 = lerp3(2.0, 2.0, 2.1, c);
170            let r2 = lerp3(3.0, 3.0, 3.3, c);
171            let r3 = lerp3(4.0, 4.007, 4.8, c);
172            0.40 * (p * r1).sin() + 0.30 * (p * r2).sin() + 0.20 * (p * r3).sin()
173        }
174        PresetKind::Heartbeat => {
175            let drop_scale = lerp3(0.3, 1.5, 3.0, c);
176            let pitch = 0.7 + drop_scale * (-phase * 5.0).exp();
177            let env = (-phase * 2.5).exp();
178            (p * pitch).sin() * env
179        }
180        PresetKind::BassPulse => {
181            0.55 * (p * 1.0).sin() + 0.22 * (p * 2.0).sin() + 0.35 * (p * 0.5).sin()
182        }
183        PresetKind::Bell => {
184            let depth = s.resonance.min(0.65) as f64;
185            let ratio = lerp3(1.41, 2.76, 4.18, c);
186            let modulator = (p * ratio).sin() * (depth * 3.5);
187            (p + modulator).sin()
188        }
189        PresetKind::SuperSaw => {
190            const OFFS: [f64; 7] = [-1.0, -0.66, -0.33, 0.0, 0.33, 0.66, 1.0];
191            let width = (s.detune.abs() as f64).max(1.0);
192            let mut sum = 0.0;
193            for off in OFFS {
194                let ratio = 2.0_f64.powf(off * width / 1200.0);
195                let x = phase * ratio;
196                // Naïve saw: 2·frac(x + 0.5) − 1 ∈ [−1, 1].
197                sum += 2.0 * (x - (x + 0.5).floor());
198            }
199            sum / OFFS.len() as f64
200        }
201        PresetKind::PluckSaw => {
202            let cents_b = s.detune as f64 * 0.5;
203            let ratio_b = 2.0_f64.powf(cents_b / 1200.0);
204            let sa = 2.0 * (phase - (phase + 0.5).floor());
205            let xb = phase * ratio_b;
206            let sb = 2.0 * (xb - (xb + 0.5).floor());
207            0.5 * sa + 0.5 * sb
208        }
209    }
210}