Skip to main content

webaudio_synth_lab/
webaudio_synth_lab.rs

1use std::{error::Error, thread, time::Duration};
2
3use kira::{
4    AudioManager, AudioManagerSettings, Frame, backend::cpal::CpalBackend, info::MockInfoBuilder,
5    sound::SoundData,
6};
7use melody_bay::{
8    AnalyserNode, AnalyserOptions, AudioBuffer, AudioBufferSourceOptions, AudioContext,
9    AudioContextOptions, BiquadFilterOptions, BiquadFilterType, ChannelMergerOptions,
10    ChannelSplitterOptions, ConstantSourceOptions, ConvolverOptions, DelayOptions,
11    DynamicsCompressorOptions, GainOptions, IirFilterOptions, OscillatorOptions, OscillatorType,
12    Oversample, PannerOptions, PeriodicWaveOptions, StereoPannerOptions, WaveShaperOptions,
13    Waveform,
14};
15
16const SAMPLE_RATE: u32 = 48_000;
17const DURATION: f64 = 6.0;
18
19fn main() -> Result<(), Box<dyn Error>> {
20    let mut manager = AudioManager::<CpalBackend>::new(AudioManagerSettings::default())?;
21    let mut context = AudioContext::try_new_with_options(AudioContextOptions {
22        sample_rate: Some(SAMPLE_RATE),
23        ..Default::default()
24    })?;
25    let analyser = build_synth_lab(&mut context, DURATION)?;
26
27    println!(
28        "destination: {:?}",
29        context.node_info(context.destination())?
30    );
31    println!("analyser: {:?}", context.node_info(&analyser)?);
32    let stats = preflight(&context, 2.0)?;
33    println!(
34        "preflight peak={:.3} rms={:.3} max_step={:.4}",
35        stats.peak, stats.rms, stats.max_step
36    );
37
38    let handle = manager.play(context.sound_data().sample_rate(SAMPLE_RATE))?;
39    for tick in 0..12 {
40        thread::sleep(Duration::from_millis(500));
41        let mut bins = vec![0.0; analyser.frequency_bin_count().min(10)];
42        analyser.get_float_frequency_data(&mut bins);
43        let brightest = bins.iter().copied().fold(f32::NEG_INFINITY, f32::max);
44        println!(
45            "t={:.1}s peak={:.3} rms={:.3} brightest_bin_db={:.1}",
46            (tick + 1) as f32 * 0.5,
47            analyser.peak(),
48            analyser.rms(),
49            brightest
50        );
51    }
52    handle.stop();
53    Ok(())
54}
55
56fn build_synth_lab(
57    context: &mut AudioContext,
58    stop_time: f64,
59) -> Result<AnalyserNode, Box<dyn Error>> {
60    let custom_wave = context.try_create_periodic_wave_with_options(
61        [0.0, 0.0, 0.22, 0.12],
62        [0.0, 1.0, 0.35, 0.18],
63        PeriodicWaveOptions {
64            disable_normalization: false,
65        },
66    )?;
67    let carrier = context.try_create_oscillator_with_options(OscillatorOptions {
68        oscillator_type: OscillatorType::Custom(custom_wave),
69        frequency: 110.0,
70        detune: -8.0,
71    })?;
72    carrier
73        .frequency()
74        .linear_ramp_to_value_at_time(146.83, 3.0)?;
75    carrier.try_start(0.0)?;
76    carrier.try_stop(stop_time)?;
77
78    let overtone = context.try_create_oscillator_with_options(OscillatorOptions {
79        oscillator_type: OscillatorType::Basic(Waveform::Triangle),
80        frequency: 220.0,
81        detune: 4.0,
82    })?;
83    overtone.try_start(0.0)?;
84    overtone.try_stop(stop_time)?;
85
86    let buffer = AudioBuffer::try_from_mono(
87        SAMPLE_RATE,
88        SAMPLE_RATE as usize / 2,
89        (0..SAMPLE_RATE / 2).map(|i| {
90            let t = i as f32 / SAMPLE_RATE as f32;
91            let burst = (1.0 - t * 2.0).max(0.0);
92            (t * std::f32::consts::TAU * 880.0).sin() * burst * 0.18
93        }),
94    )?;
95    let buffer_source =
96        context.try_create_buffer_source_with_options(AudioBufferSourceOptions {
97            buffer: Some(buffer),
98            playback_rate: 0.85,
99            detune: 0.0,
100            looping: false,
101            loop_start: 0.0,
102            loop_end: 0.5,
103        })?;
104    buffer_source.try_start(2.0)?;
105    buffer_source.try_stop(3.2)?;
106
107    let lfo =
108        context.try_create_constant_source_with_options(ConstantSourceOptions { offset: 0.012 })?;
109    lfo.try_start(0.0)?;
110    lfo.try_stop(stop_time)?;
111
112    let carrier_gain = context.try_create_gain_with_options(GainOptions { gain: 0.0 })?;
113    carrier_gain.gain().set_value_at_time(0.0, 0.0)?;
114    carrier_gain
115        .gain()
116        .linear_ramp_to_value_at_time(0.11, 0.18)?;
117    carrier_gain
118        .gain()
119        .set_value_curve_at_time([0.11, 0.08, 0.13, 0.09, 0.12], 0.4, 2.8)?;
120    carrier_gain.gain().set_target_at_time(0.07, 3.4, 0.55)?;
121    carrier_gain
122        .gain()
123        .cancel_and_hold_at_time(stop_time - 0.35)?;
124    carrier_gain
125        .gain()
126        .linear_ramp_to_value_at_time(0.0, stop_time)?;
127
128    let burst_gain = context.try_create_gain_with_options(GainOptions { gain: 0.12 })?;
129    let delay = context.try_create_delay_with_options(DelayOptions {
130        max_delay_time: 0.75,
131        delay_time: 0.08,
132    })?;
133    let biquad = context.try_create_biquad_filter_with_options(BiquadFilterOptions {
134        filter_type: BiquadFilterType::Lowpass,
135        frequency: 1_200.0,
136        q: 0.9,
137        ..Default::default()
138    })?;
139    biquad
140        .frequency()
141        .linear_ramp_to_value_at_time(2_400.0, 2.5)?;
142    biquad.frequency().set_target_at_time(700.0, 3.5, 0.8)?;
143
144    let iir = context.try_create_iir_filter_with_options(IirFilterOptions {
145        feedforward: vec![0.55, 0.35],
146        feedback: vec![1.0, -0.18],
147    })?;
148    let shaper = context.try_create_wave_shaper_with_options(WaveShaperOptions {
149        curve: Some(vec![-1.0, -0.35, 0.0, 0.35, 1.0]),
150        oversample: Oversample::TwoX,
151    })?;
152    let stereo =
153        context.try_create_stereo_panner_with_options(StereoPannerOptions { pan: -0.15 })?;
154    stereo.pan().linear_ramp_to_value_at_time(0.25, stop_time)?;
155    let panner = context.try_create_panner_with_options(PannerOptions {
156        position_x: 0.8,
157        position_z: 1.5,
158        ..Default::default()
159    })?;
160    let compressor =
161        context.try_create_dynamics_compressor_with_options(DynamicsCompressorOptions {
162            threshold: -20.0,
163            knee: 18.0,
164            ratio: 2.5,
165            attack: 0.01,
166            release: 0.22,
167        })?;
168    let convolver = context.try_create_convolver_with_options(ConvolverOptions {
169        buffer: Some(AudioBuffer::try_from_mono(
170            SAMPLE_RATE,
171            8,
172            [0.55, 0.28, 0.15, 0.08, 0.04, 0.02, 0.01, 0.005],
173        )?),
174        disable_normalization: true,
175    })?;
176    let analyser = context.try_create_analyser_with_options(AnalyserOptions {
177        fft_size: 1024,
178        min_decibels: -100.0,
179        max_decibels: -12.0,
180        smoothing_time_constant: 0.35,
181    })?;
182    let splitter = context.try_create_channel_splitter_with_options(ChannelSplitterOptions {
183        number_of_outputs: 2,
184    })?;
185    let merger = context.try_create_channel_merger_with_options(ChannelMergerOptions {
186        number_of_inputs: 2,
187    })?;
188    let output = context.try_create_gain_with_options(GainOptions { gain: 0.82 })?;
189
190    context.connect(carrier, &carrier_gain)?;
191    context.connect(overtone, &carrier_gain)?;
192    context.connect(buffer_source, &burst_gain)?;
193    context.connect(&carrier_gain, &delay)?;
194    context.connect(&delay, &biquad)?;
195    context.connect_param(lfo, delay.delay_time())?;
196    context.connect(&biquad, &iir)?;
197    context.connect(&iir, &shaper)?;
198    context.connect(&shaper, &stereo)?;
199    context.connect(&stereo, &panner)?;
200    context.connect(&burst_gain, &splitter)?;
201    context.connect_with_indices(&splitter, 0, &merger, 0)?;
202    context.connect_with_indices(&splitter, 1, &merger, 1)?;
203    context.connect(&panner, &compressor)?;
204    context.connect(&merger, &compressor)?;
205    context.connect(&compressor, &convolver)?;
206    context.connect(&convolver, &output)?;
207    context.connect(&output, &analyser)?;
208    context.connect(&analyser, context.destination())?;
209
210    println!("output node info: {:?}", context.node_info(&output)?);
211    Ok(analyser)
212}
213
214#[derive(Clone, Copy, Debug)]
215struct AudioStats {
216    peak: f32,
217    rms: f32,
218    max_step: f32,
219}
220
221fn preflight(context: &AudioContext, seconds: f64) -> Result<AudioStats, Box<dyn Error>> {
222    let (mut sound, _) = context.sound_data().sample_rate(SAMPLE_RATE).into_sound()?;
223    let info = MockInfoBuilder::new().build();
224    let mut out = vec![Frame::ZERO; (seconds * SAMPLE_RATE as f64) as usize];
225    sound.process(&mut out, 1.0 / SAMPLE_RATE as f64, &info);
226    Ok(audio_stats(&out))
227}
228
229fn audio_stats(out: &[Frame]) -> AudioStats {
230    let peak = out
231        .iter()
232        .map(|frame| frame.left.abs().max(frame.right.abs()))
233        .fold(0.0, f32::max);
234    let rms = (out
235        .iter()
236        .map(|frame| {
237            let mono = (frame.left + frame.right) * 0.5;
238            mono * mono
239        })
240        .sum::<f32>()
241        / out.len().max(1) as f32)
242        .sqrt();
243    let max_step = out
244        .windows(2)
245        .map(|frames| {
246            (frames[1].left - frames[0].left)
247                .abs()
248                .max((frames[1].right - frames[0].right).abs())
249        })
250        .fold(0.0, f32::max);
251    AudioStats {
252        peak,
253        rms,
254        max_step,
255    }
256}
257
258#[cfg(test)]
259mod tests {
260    use super::*;
261    use melody_bay::OfflineAudioContext;
262
263    #[test]
264    fn synth_lab_live_graph_stays_listenable() {
265        let mut context = AudioContext::try_new_with_sample_rate(SAMPLE_RATE).unwrap();
266        build_synth_lab(&mut context, DURATION).unwrap();
267        let stats = preflight(&context, 4.0).unwrap();
268        assert!(stats.peak > 0.02, "rendered near silence: {stats:?}");
269        assert!(stats.peak < 0.55, "peak too hot: {stats:?}");
270        assert!(stats.rms < 0.18, "rms too dense: {stats:?}");
271        assert!(stats.max_step < 0.08, "click-like sample jump: {stats:?}");
272    }
273
274    #[test]
275    fn synth_lab_offline_context_renders() {
276        let mut context = OfflineAudioContext::try_new(2, SAMPLE_RATE as usize, SAMPLE_RATE)
277            .expect("offline context");
278        let osc = context.create_oscillator();
279        osc.set_type(Waveform::Sine);
280        osc.frequency().set_value(220.0).unwrap();
281        osc.try_start(0.0).unwrap();
282        osc.try_stop(0.25).unwrap();
283        let gain = context.create_gain();
284        gain.gain().set_value(0.1).unwrap();
285        context.connect(osc, &gain).unwrap();
286        context.connect(&gain, context.destination()).unwrap();
287        let rendered = context.start_rendering().unwrap();
288        assert!(
289            rendered
290                .channel_data(0)
291                .unwrap()
292                .iter()
293                .any(|sample| sample.abs() > 0.001)
294        );
295    }
296}