onedrop_renderer/
waveform.rs

1//! Waveform pass — MD2 modes 0..7 wired into the renderer.
2//!
3//! `WaveformRenderer` owns four pipelines (alpha vs additive × lines vs
4//! dots) that share a uniform + storage layout. Per frame the host:
5//!
6//! 1. Smooths the audio samples (CPU IIR) and uploads them to
7//!    [`WaveformRenderer::update_wave_samples`].
8//! 2. Updates the [`WaveUniforms`] payload from `WaveParams`.
9//! 3. Calls [`WaveformRenderer::render`] into the warp output texture so
10//!    the wave participates in the feedback loop (matches MD2's order).
11//!
12//! Mode 6 (`double_line`) is drawn in two passes: the second call sets
13//! `flip_y = 1` so the wave mirrors around `wave_y`. Modes 0..5 and 7
14//! ignore `flip_y`.
15
16use bytemuck::{Pod, Zeroable};
17use wgpu::util::DeviceExt;
18
19use crate::config::WaveParams;
20
21/// MilkDrop 2 waveform mode (`nWaveMode`, 0..7).
22///
23/// Numbering matches the MD2 preset format: presets read
24/// `nWaveMode = 4` as `DerivativeLine`, etc.
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26#[repr(u32)]
27pub enum WaveformMode {
28    Circle = 0,
29    XyOscopeOctagonal = 1,
30    XyOscopeQuadrilateral = 2,
31    Blob = 3,
32    DerivativeLine = 4,
33    ExplosiveHash = 5,
34    DoubleLine = 6,
35    DoubleHorizontal = 7,
36}
37
38impl WaveformMode {
39    /// Convert a raw `nWaveMode` int to a mode, clamping anything outside
40    /// `[0, 7]` to `Circle`. MD2 silently does the same.
41    pub fn from_i32(v: i32) -> Self {
42        match v {
43            0 => Self::Circle,
44            1 => Self::XyOscopeOctagonal,
45            2 => Self::XyOscopeQuadrilateral,
46            3 => Self::Blob,
47            4 => Self::DerivativeLine,
48            5 => Self::ExplosiveHash,
49            6 => Self::DoubleLine,
50            7 => Self::DoubleHorizontal,
51            _ => Self::Circle,
52        }
53    }
54
55    /// True when the wave is drawn as a closed loop (samples wrap).
56    pub fn is_closed(self) -> bool {
57        matches!(self, Self::Circle | Self::Blob)
58    }
59
60    /// True when MD2 draws the mode in two passes (mirrored). Currently
61    /// only `DoubleLine`.
62    pub fn is_double_pass(self) -> bool {
63        matches!(self, Self::DoubleLine)
64    }
65}
66
67/// Standard MD2 waveform sample length — 512 samples per channel before
68/// downmix. The shader treats `wave_samples` as mono; the host is
69/// responsible for the L/R mix.
70pub const NUM_WAVE_SAMPLES: usize = 512;
71
72/// Default IIR smoothing factor when `wave_smoothing` is 0. MD2 uses a
73/// 5-tap moving average; a single-pole IIR with α≈0.5 is a cheap visual
74/// match.
75const DEFAULT_SMOOTHING_ALPHA: f32 = 0.0;
76
77/// Per-frame uniforms consumed by the waveform shader. Field order
78/// MUST match `Uniforms` in `waveform_advanced.wgsl`.
79#[repr(C)]
80#[derive(Debug, Clone, Copy, Pod, Zeroable)]
81pub struct WaveUniforms {
82    pub resolution: [f32; 2],
83    pub time: f32,
84    pub mode: u32,
85
86    pub wave_x: f32,
87    pub wave_y: f32,
88    pub wave_scale: f32,
89    pub wave_alpha: f32,
90
91    pub wave_param: f32,
92    pub aspect: f32,
93    pub thickness: f32,
94    pub smoothing: f32,
95
96    pub wave_color: [f32; 4],
97
98    pub num_samples: u32,
99    pub is_thick: u32,
100    pub is_dots: u32,
101    /// Index offset into the (now 2N-wide) sample buffer. `0` picks the
102    /// left-channel half (and the mono-fallback when no right channel
103    /// is uploaded); `NUM_WAVE_SAMPLES` picks the right-channel half.
104    /// The split-channel path picks left vs. right by changing this
105    /// offset between two consecutive draws at different `wave_y`
106    /// offsets when the top-vs-bottom L/R split is enabled.
107    pub sample_offset: u32,
108}
109
110impl Default for WaveUniforms {
111    fn default() -> Self {
112        Self {
113            resolution: [1.0, 1.0],
114            time: 0.0,
115            mode: 0,
116            wave_x: 0.5,
117            wave_y: 0.5,
118            wave_scale: 0.5,
119            wave_alpha: 1.0,
120            wave_param: 0.0,
121            aspect: 1.0,
122            thickness: 0.004,
123            smoothing: 0.0,
124            wave_color: [1.0, 1.0, 1.0, 1.0],
125            num_samples: NUM_WAVE_SAMPLES as u32,
126            is_thick: 0,
127            is_dots: 0,
128            sample_offset: 0,
129        }
130    }
131}
132
133/// Legacy storage element kept for backwards-compatibility with older
134/// tests that referenced `WavePoint`. The new pipeline streams a flat
135/// `array<f32>` and does NOT consume `WavePoint`; the type is retained
136/// only so external code that still uses it keeps compiling.
137#[repr(C)]
138#[derive(Debug, Clone, Copy, Pod, Zeroable)]
139pub struct WavePoint {
140    pub position: [f32; 2],
141    pub value: f32,
142    pub _padding: f32,
143}
144
145/// GPU waveform pass — owns its pipelines, uniforms, and a sample
146/// storage buffer of fixed length `NUM_WAVE_SAMPLES`.
147pub struct WaveformRenderer {
148    /// 4 pipelines: (alpha lines, additive lines, alpha dots, additive
149    /// dots). Indexed by `pipeline_index(is_dots, is_additive)`.
150    pipelines: [wgpu::RenderPipeline; 4],
151
152    bind_group: wgpu::BindGroup,
153    uniform_buffer: wgpu::Buffer,
154    sample_buffer: wgpu::Buffer,
155
156    /// Last uniforms uploaded, kept for `flip_y` double-pass dispatch.
157    last_uniforms: WaveUniforms,
158}
159
160#[inline]
161fn pipeline_index(is_dots: bool, is_additive: bool) -> usize {
162    (is_additive as usize) | ((is_dots as usize) << 1)
163}
164
165impl WaveformRenderer {
166    /// Create a new renderer targeting `format`. `format` must match the
167    /// texture the host will dispatch into (typically `render_texture`).
168    pub fn new(device: &wgpu::Device, format: wgpu::TextureFormat) -> Self {
169        let shader = crate::pipeline_helpers::load_wgsl(
170            device,
171            "Waveform Shader",
172            include_str!("../shaders/waveform_advanced.wgsl"),
173        );
174
175        let uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor {
176            label: Some("Waveform Uniforms"),
177            size: std::mem::size_of::<WaveUniforms>() as u64,
178            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
179            mapped_at_creation: false,
180        });
181
182        // Initial sample buffer is zero-filled silence. Allocate 2 ×
183        // `NUM_WAVE_SAMPLES` to hold both channels back-to-back:
184        // `[0 .. N)` = left, `[N .. 2N)` = right. The mono path
185        // duplicates the left buffer into the right half so a
186        // shader that reads either offset sees coherent data.
187        let zeros = [0.0f32; NUM_WAVE_SAMPLES * 2];
188        let sample_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
189            label: Some("Waveform Samples"),
190            contents: bytemuck::cast_slice(&zeros),
191            usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
192        });
193
194        let bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
195            label: Some("Waveform BGL"),
196            entries: &[
197                wgpu::BindGroupLayoutEntry {
198                    binding: 0,
199                    visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
200                    ty: wgpu::BindingType::Buffer {
201                        ty: wgpu::BufferBindingType::Uniform,
202                        has_dynamic_offset: false,
203                        min_binding_size: None,
204                    },
205                    count: None,
206                },
207                wgpu::BindGroupLayoutEntry {
208                    binding: 1,
209                    visibility: wgpu::ShaderStages::VERTEX,
210                    ty: wgpu::BindingType::Buffer {
211                        ty: wgpu::BufferBindingType::Storage { read_only: true },
212                        has_dynamic_offset: false,
213                        min_binding_size: None,
214                    },
215                    count: None,
216                },
217            ],
218        });
219
220        let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
221            label: Some("Waveform BG"),
222            layout: &bgl,
223            entries: &[
224                wgpu::BindGroupEntry {
225                    binding: 0,
226                    resource: uniform_buffer.as_entire_binding(),
227                },
228                wgpu::BindGroupEntry {
229                    binding: 1,
230                    resource: sample_buffer.as_entire_binding(),
231                },
232            ],
233        });
234
235        let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
236            label: Some("Waveform Layout"),
237            bind_group_layouts: &[Some(&bgl)],
238            immediate_size: 0,
239        });
240
241        let make_pipeline = |label: &str, additive: bool| -> wgpu::RenderPipeline {
242            let blend = crate::pipeline_helpers::blend_state_for(additive);
243            device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
244                label: Some(label),
245                layout: Some(&layout),
246                vertex: wgpu::VertexState {
247                    module: &shader,
248                    entry_point: Some("vs_main"),
249                    buffers: &[],
250                    compilation_options: Default::default(),
251                },
252                fragment: Some(wgpu::FragmentState {
253                    module: &shader,
254                    entry_point: Some("fs_main"),
255                    targets: &[Some(wgpu::ColorTargetState {
256                        format,
257                        blend: Some(blend),
258                        write_mask: wgpu::ColorWrites::ALL,
259                    })],
260                    compilation_options: Default::default(),
261                }),
262                primitive: wgpu::PrimitiveState {
263                    topology: wgpu::PrimitiveTopology::TriangleList,
264                    ..Default::default()
265                },
266                depth_stencil: None,
267                multisample: wgpu::MultisampleState::default(),
268                multiview_mask: None,
269                cache: None,
270            })
271        };
272
273        let pipelines = [
274            make_pipeline("Wave Lines Alpha", false),
275            make_pipeline("Wave Lines Additive", true),
276            make_pipeline("Wave Dots Alpha", false),
277            make_pipeline("Wave Dots Additive", true),
278        ];
279
280        Self {
281            pipelines,
282            bind_group,
283            uniform_buffer,
284            sample_buffer,
285            last_uniforms: WaveUniforms::default(),
286        }
287    }
288
289    /// Push a fresh sample buffer (mono, length capped at
290    /// `NUM_WAVE_SAMPLES`; shorter buffers are zero-padded). Apply CPU
291    /// IIR smoothing first with [`apply_smoothing`] if you want
292    /// `wave_smoothing` to actually attenuate spikes.
293    ///
294    /// Mono path: the same data is written into both halves of the
295    /// 2N-wide buffer so a shader pass at either `sample_offset` sees
296    /// coherent samples. Stereo callers use
297    /// [`Self::update_wave_samples_lr`] instead.
298    pub fn update_wave_samples(&self, queue: &wgpu::Queue, samples: &[f32]) {
299        let mut buf = [0.0f32; NUM_WAVE_SAMPLES * 2];
300        let n = samples.len().min(NUM_WAVE_SAMPLES);
301        buf[..n].copy_from_slice(&samples[..n]);
302        buf[NUM_WAVE_SAMPLES..NUM_WAVE_SAMPLES + n].copy_from_slice(&samples[..n]);
303        queue.write_buffer(&self.sample_buffer, 0, bytemuck::cast_slice(&buf));
304    }
305
306    /// Push split L/R sample buffers. Left lands at offset `0` (the
307    /// default `sample_offset` of `0` reads it); right lands at offset
308    /// `NUM_WAVE_SAMPLES`. Used by the top-vs-bottom split path —
309    /// `RenderChain::record_passes` dispatches two waveform passes with
310    /// distinct `sample_offset` + `wave_y` so the listener sees L on
311    /// the upper half and R on the lower half.
312    pub fn update_wave_samples_lr(&self, queue: &wgpu::Queue, left: &[f32], right: &[f32]) {
313        let mut buf = [0.0f32; NUM_WAVE_SAMPLES * 2];
314        let nl = left.len().min(NUM_WAVE_SAMPLES);
315        let nr = right.len().min(NUM_WAVE_SAMPLES);
316        buf[..nl].copy_from_slice(&left[..nl]);
317        buf[NUM_WAVE_SAMPLES..NUM_WAVE_SAMPLES + nr].copy_from_slice(&right[..nr]);
318        queue.write_buffer(&self.sample_buffer, 0, bytemuck::cast_slice(&buf));
319    }
320
321    /// Push fresh uniforms. Keeps a CPU-side copy for double-pass mode 6.
322    pub fn update_uniforms(&mut self, queue: &wgpu::Queue, u: &WaveUniforms) {
323        self.last_uniforms = *u;
324        queue.write_buffer(&self.uniform_buffer, 0, bytemuck::bytes_of(u));
325    }
326
327    /// Render the waveform into `view` using the previously uploaded
328    /// uniforms + samples. Loads existing contents (no clear) — meant
329    /// to draw on top of the warp pass output.
330    pub fn render(&self, encoder: &mut wgpu::CommandEncoder, view: &wgpu::TextureView) {
331        let mode = WaveformMode::from_i32(self.last_uniforms.mode as i32);
332        let is_dots = self.last_uniforms.is_dots != 0 || mode == WaveformMode::ExplosiveHash;
333        // Additive blending follows `b_additive_waves`. The
334        // uniform doesn't carry it directly — the host sets blend by
335        // calling `set_additive` before `render`. Default to alpha.
336        let is_additive = false; // overridden by `render_with_blend`
337        self.render_with_blend(encoder, view, is_dots, is_additive);
338    }
339
340    /// Same as [`Self::render`] but with explicit blend selection. Used
341    /// by the host to honour `b_additive_waves` without round-tripping a
342    /// flag through the uniforms.
343    pub fn render_with_blend(
344        &self,
345        encoder: &mut wgpu::CommandEncoder,
346        view: &wgpu::TextureView,
347        is_dots: bool,
348        is_additive: bool,
349    ) {
350        let mode = WaveformMode::from_i32(self.last_uniforms.mode as i32);
351        let num_samples = self.last_uniforms.num_samples;
352        if num_samples == 0 {
353            return;
354        }
355        // For closed shapes we draw the same number of segments as
356        // samples (wrap), open shapes use samples-1.
357        let segments = if mode.is_closed() {
358            num_samples
359        } else {
360            num_samples.saturating_sub(1)
361        };
362        let vertex_count = segments * 6;
363
364        let pidx = pipeline_index(is_dots, is_additive);
365        let pipeline = &self.pipelines[pidx];
366
367        let mut rp = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
368            label: Some("Waveform Pass"),
369            color_attachments: &[Some(wgpu::RenderPassColorAttachment {
370                view,
371                depth_slice: None,
372                resolve_target: None,
373                ops: wgpu::Operations {
374                    load: wgpu::LoadOp::Load,
375                    store: wgpu::StoreOp::Store,
376                },
377            })],
378            depth_stencil_attachment: None,
379            timestamp_writes: None,
380            occlusion_query_set: None,
381            multiview_mask: None,
382        });
383        rp.set_pipeline(pipeline);
384        rp.set_bind_group(0, &self.bind_group, &[]);
385        rp.draw(0..vertex_count, 0..1);
386    }
387}
388
389/// Apply a single-pole IIR low-pass to a buffer of waveform samples,
390/// matching MD2's `f_wave_smoothing`. `smoothing` ∈ `[0, 1]`:
391/// 0 = no smoothing, 0.9 ≈ heavy attenuation. Mutates in place.
392///
393/// The pole α is mapped from `smoothing` so that the perceptual range
394/// matches MilkDrop's slider: a `smoothing` of 0.75 → α ≈ 0.625, which
395/// is the MD2 sweet spot for visible spike rejection without making the
396/// waveform look like a flat line.
397pub fn apply_smoothing(samples: &mut [f32], smoothing: f32) {
398    let s = smoothing.clamp(0.0, 1.0);
399    if s <= DEFAULT_SMOOTHING_ALPHA {
400        return;
401    }
402    // Map smoothing slider to IIR pole. f_wave_smoothing=0.75 → α≈0.625.
403    let alpha = (s * 0.833).clamp(0.0, 0.95);
404    let mut prev = samples.first().copied().unwrap_or(0.0);
405    for s in samples.iter_mut() {
406        let next = prev * alpha + *s * (1.0 - alpha);
407        *s = next;
408        prev = next;
409    }
410}
411
412/// Build the `WaveUniforms` payload from a `WaveParams` snapshot, the
413/// current frame's instantaneous volume (used for
414/// `b_mod_wave_alpha_by_volume`), and the current frame resolution +
415/// aspect.
416pub fn build_uniforms(
417    params: WaveParams,
418    instantaneous_vol: f32,
419    width: u32,
420    height: u32,
421    time: f32,
422) -> WaveUniforms {
423    let aspect = width as f32 / height.max(1) as f32;
424    let alpha_mod = if params.mod_alpha_by_volume {
425        let v = instantaneous_vol.clamp(0.0, 1.0);
426        let span = (params.mod_alpha_end - params.mod_alpha_start)
427            .abs()
428            .max(1e-3);
429        ((v - params.mod_alpha_start) / span).clamp(0.0, 1.0)
430    } else {
431        1.0
432    };
433    let mut color = [params.r, params.g, params.b, params.a * alpha_mod];
434    if params.maximize_color {
435        // MD2 b_maximize_wave_color — push the colour to full saturation
436        // by rescaling so max(rgb) = 1.
437        let m = color[0].max(color[1]).max(color[2]).max(1e-3);
438        color[0] /= m;
439        color[1] /= m;
440        color[2] /= m;
441    }
442    let thickness = if params.thick { 0.0075 } else { 0.0035 };
443    WaveUniforms {
444        resolution: [width as f32, height as f32],
445        time,
446        mode: WaveformMode::from_i32(params.mode) as u32,
447        wave_x: params.x,
448        wave_y: params.y,
449        wave_scale: params.scale,
450        wave_alpha: params.a,
451        wave_param: params.param,
452        aspect,
453        thickness,
454        smoothing: params.smoothing,
455        wave_color: color,
456        num_samples: NUM_WAVE_SAMPLES as u32,
457        is_thick: params.thick as u32,
458        is_dots: params.dots as u32,
459        sample_offset: 0,
460    }
461}
462
463#[cfg(test)]
464mod tests {
465    use super::*;
466
467    #[test]
468    fn waveform_mode_from_i32_known() {
469        assert_eq!(WaveformMode::from_i32(0), WaveformMode::Circle);
470        assert_eq!(WaveformMode::from_i32(1), WaveformMode::XyOscopeOctagonal);
471        assert_eq!(WaveformMode::from_i32(7), WaveformMode::DoubleHorizontal);
472    }
473
474    #[test]
475    fn waveform_mode_from_i32_clamps_out_of_range() {
476        assert_eq!(WaveformMode::from_i32(-1), WaveformMode::Circle);
477        assert_eq!(WaveformMode::from_i32(99), WaveformMode::Circle);
478        assert_eq!(WaveformMode::from_i32(8), WaveformMode::Circle);
479    }
480
481    #[test]
482    fn waveform_mode_is_closed_for_circle_and_blob() {
483        assert!(WaveformMode::Circle.is_closed());
484        assert!(WaveformMode::Blob.is_closed());
485        assert!(!WaveformMode::DoubleLine.is_closed());
486        assert!(!WaveformMode::DerivativeLine.is_closed());
487    }
488
489    #[test]
490    fn smoothing_zero_is_passthrough() {
491        let mut s = vec![1.0_f32, -1.0, 1.0, -1.0];
492        let orig = s.clone();
493        apply_smoothing(&mut s, 0.0);
494        assert_eq!(s, orig);
495    }
496
497    #[test]
498    fn smoothing_attenuates_oscillation() {
499        let mut s = vec![1.0_f32, -1.0, 1.0, -1.0, 1.0, -1.0, 1.0, -1.0];
500        apply_smoothing(&mut s, 0.9);
501        // After smoothing, peak-to-peak amplitude should drop significantly
502        // (one-pole IIR with α≈0.75).
503        let max = s.iter().cloned().fold(f32::MIN, f32::max);
504        let min = s.iter().cloned().fold(f32::MAX, f32::min);
505        assert!(
506            (max - min) < 1.5,
507            "expected smoothed wave to attenuate (got peak-to-peak {})",
508            max - min
509        );
510    }
511
512    #[test]
513    fn build_uniforms_maps_fields() {
514        let params = WaveParams {
515            r: 1.0,
516            g: 0.5,
517            b: 0.25,
518            a: 0.8,
519            x: 0.4,
520            y: 0.6,
521            mode: 4,
522            scale: 0.7,
523            param: 0.3,
524            smoothing: 0.5,
525            thick: true,
526            dots: false,
527            additive: true,
528            maximize_color: false,
529            mod_alpha_by_volume: false,
530            mod_alpha_start: 0.0,
531            mod_alpha_end: 1.0,
532            split_lr: false,
533        };
534        let u = build_uniforms(params, 0.0, 1280, 720, 1.5);
535        assert_eq!(u.mode, WaveformMode::DerivativeLine as u32);
536        assert!((u.wave_x - 0.4).abs() < 1e-5);
537        assert!((u.aspect - (1280.0 / 720.0)).abs() < 1e-3);
538        assert_eq!(u.is_thick, 1);
539        assert_eq!(u.is_dots, 0);
540    }
541
542    #[test]
543    fn build_uniforms_maximize_color_normalizes_rgb() {
544        let params = WaveParams {
545            r: 0.5,
546            g: 0.25,
547            b: 0.25,
548            a: 1.0,
549            x: 0.5,
550            y: 0.5,
551            mode: 0,
552            scale: 0.5,
553            param: 0.0,
554            smoothing: 0.0,
555            thick: false,
556            dots: false,
557            additive: false,
558            maximize_color: true,
559            mod_alpha_by_volume: false,
560            mod_alpha_start: 0.0,
561            mod_alpha_end: 1.0,
562            split_lr: false,
563        };
564        let u = build_uniforms(params, 0.0, 100, 100, 0.0);
565        assert!((u.wave_color[0] - 1.0).abs() < 1e-5);
566        assert!((u.wave_color[1] - 0.5).abs() < 1e-5);
567        assert!((u.wave_color[2] - 0.5).abs() < 1e-5);
568    }
569
570    #[test]
571    fn build_uniforms_mod_alpha_by_volume_scales_alpha() {
572        let params = WaveParams {
573            r: 1.0,
574            g: 1.0,
575            b: 1.0,
576            a: 1.0,
577            x: 0.5,
578            y: 0.5,
579            mode: 0,
580            scale: 0.5,
581            param: 0.0,
582            smoothing: 0.0,
583            thick: false,
584            dots: false,
585            additive: false,
586            maximize_color: false,
587            mod_alpha_by_volume: true,
588            mod_alpha_start: 0.0,
589            mod_alpha_end: 1.0,
590            split_lr: false,
591        };
592        let low = build_uniforms(params, 0.1, 100, 100, 0.0);
593        let high = build_uniforms(params, 0.9, 100, 100, 0.0);
594        assert!(low.wave_color[3] < high.wave_color[3]);
595    }
596}