This is a multipart series covering creating realistic guitar sounds in WASM

Guitar Sounds Part 2 - Sounding Better

Last time we create a small widget in Typescript and Rust (wasm) that outputted a sine wave that we could change the pitch of. It doesn’t sound much like a guitar so let’s change that.

Plucked String

Emulating the sound of a plucked string is commonly done with a technique called the Karplus-Strong string synthesis. This sound emulation technique can be broken into four parts:

Steps in Detail…

1. White Noise

Generate some random values to emulate the plucked string. Store it in a buffer that we’ll use as a circular buffer later. The sample rate divided by the size of this buffer is the pitch of the note. For example - a sample rate of 44100 and a pluck buffer size of 441 would result in a fundamental frequency (or pitch) of 100Hz.

use rand::random;
use std::sync::{Arc, Mutex};

const SAMPLE_RATE: u32 = 44100;
const BUFFER_SIZE: usize = 441; // For a pitch of 100Hz

let noise_buffer = Arc::new(Mutex::new(vec![0f32; BUFFER_SIZE]));
for i in 0..BUFFER_SIZE {
    noise_buffer.lock().unwrap()[i] = random::<f32>() * 2.0 - 1.0; // Generate white noise
}

2. Recirculation

Continuously play back the contents of this buffer. As each sample is played, it is also fed back into the beginning of the buffer. This process is similar to the vibration of a string where the vibration (sound) travels back and forth along the string

let buffer_clone = Arc::clone(&noise_buffer);
move |data: &mut [f32], _: &cpal::OutputCallbackInfo| {
    let mut buffer = buffer_clone.lock().unwrap();
    for sample in data.iter_mut() {
        *sample = buffer[0];
        // The recirculation logic will be implemented in the low-pass filter step
    }
}

3. Low Pass Filter

A low pass filter is an signal processing technique that allows signals with a frequency lower than a certain value to “pass through” unaffected. The frequencies that are higher than this value have their amplitude (or volume in audio processing) reduced.

In our case we’ll be doing something that acts like a low pass filter: whenever a sample is fed back into the buffer combine it with the next value and average.

This smooths out the high frequencies over time causing the sound to lose its initial harshness and sound more like a string.

for frame in data.chunks_mut(2) {
    let avg_sample = (buffer[0] + buffer[1]) / 2.0;
    buffer.rotate_left(1);
    buffer[BUFFER_SIZE - 1] = avg_sample;
    frame[0] = avg_sample;
    frame[1] = avg_sample;
}

4. Decay

Over time, fade out the sound to simulate the natural decay of a string vibrating

let decay_factor = 0.999; // Adjust this value for different decay rates
let buffer_clone = Arc::clone(&noise_buffer);
move |data: &mut [f32], _: &cpal::OutputCallbackInfo| {
    let mut buffer = buffer_clone.lock().unwrap();
    for frame in data.chunks_mut(2) {
        let avg_sample = (buffer[0] + buffer[1]) / 2.0 * decay_factor;
        buffer.rotate_left(1);
        buffer[BUFFER_SIZE - 1] = avg_sample;
        frame[0] = avg_sample;
        frame[1] = avg_sample;
    }
}

Putting It All Together

expand to see code…
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
use cpal::Stream;
use rand::random;
use std::i16;
use std::sync::{Arc, Mutex};
use wasm_bindgen::prelude::*;
use web_sys::console;

#[wasm_bindgen]
pub struct Handle(Stream);

#[wasm_bindgen]
pub unsafe fn beep() -> Handle {
    let host = cpal::default_host();
    let device = host
        .default_output_device()
        .expect("failed to find a default output device");
    let config = device
        .default_output_config()
        .expect("failed to create default output config");

    Handle(match config.sample_format() {
        cpal::SampleFormat::F32 => run::<f32>(&device, &config.into()),
        cpal::SampleFormat::I16 => run::<i16>(&device, &config.into()),
        cpal::SampleFormat::U16 => run::<u16>(&device, &config.into()),
        _ => panic!("unsupported sample format"),
    })
}

unsafe fn run<T>(device: &cpal::Device, config: &cpal::StreamConfig) -> Stream
where
    T: cpal::Sample + cpal::SizedSample + cpal::FromSample<f32>,
{
    const BUFFER_SIZE: usize = 440; // For a pitch of ~440Hz
    const DECAY_FACTOR: f32 = 0.999;

    let noise_buffer = Arc::new(Mutex::new(vec![0f32; BUFFER_SIZE]));
    for i in 0..BUFFER_SIZE {
        noise_buffer.lock().unwrap()[i] = random::<f32>() * 2.0 - 1.0; // Generate white noise
    }

    let buffer_clone = Arc::clone(&noise_buffer);

    let err_fn = |err| console::error_1(&format!("an error occurred on stream: {}", err).into());

    let stream = device
        .build_output_stream(
            config,
            move |data: &mut [f32], _: &cpal::OutputCallbackInfo| {
                let mut buffer = buffer_clone.lock().unwrap();
                for frame in data.chunks_mut(2) {
                    let avg_sample = (buffer[0] + buffer[1]) / 2.0 * DECAY_FACTOR;
                    buffer.rotate_left(1);
                    buffer[BUFFER_SIZE - 1] = avg_sample;
                    frame[0] = avg_sample;
                    frame[1] = avg_sample;
                }
            },
            err_fn,
            None,
        )
        .expect("failed to build output stream");
    stream.play().expect("failed to play stream");
    stream
}

It’s really surprising to me how good the Karplus-Strong algorithm sounds.

Changing Pitch

Like we learnt before, pitch is a function of the size of the white noise buffer we allocate at the start. So let’s try some different values, how about the open string values:

The pitches and buffer sizes should be:

stringpitchbuffer sizeclicky!
E82.41535
A110.0400
D146.83300
G196.0225
B246.94179
E329.63134

Different pitches also sound pretty good, I think they could decay a bit quicker so here’s a slider to modify the decay rate globally for each of the above buttons.

This seems to have two results:

  1. It definitely makes the string vibrate for a shorter amount of time, but
  2. The note sounds as though it’s aggressively palm muted

I think, however, that this is good enough. It sounds very much like a fresh guitar string plucked on a guitar with a ton of sustain. So let’s leave it up to future Tom to make this sound better if he wants to.

Conclusion

In this post, we build on our learnings from part 1 and make our generated sound more guitar like.

Taking a look at the initial aims of this project, it seems like we’re in a good place to attempt the first stage - “Generate a simple riff”. So let’s do that in part 3!