Making a wavetable synth for the first time...Can somebody point me in the right direction?

611 views Asked by At

I'm trying to make a wavetable synthesizer in Python for the first time (based off an example I found here https://blamsoft.com/tutorials/expanse-creating-wavetables/) but the resultant sound I'm getting doesn't sound tonal at all. My output is just a low grainy buzz. I'm pretty new to making wavetables in Python and I was wondering if anybody might be able to tell me what I'm missing in order to write an A440 sine wavetable to the file "wavetable.wav" and have it actually produce a pure sine tone? Here's what I have at the moment:

import wave
import struct
import numpy as np

frame_count = 256
frame_size = 2048
sps = 44100
freq_hz = 440
file = "wavetable.wav" #write waveform to file

wav_file = wave.open(file, 'w')
wav_file.setparams((1, 2, sps, frame_count, 'NONE', 'not compressed'))


values = bytes(0)

for i in range(frame_count):
    for ii in range(frame_size):
       
        sample = np.sin((float(ii)/frame_size) * (i+128)/256 * 2 * np.pi * freq_hz/sps) * 65535 
        
        
        if sample < 0:
            sample = 0
        
        sample -= 32768
        sample = int(sample)

        values += struct.pack('h', sample) 

wav_file.writeframes(values)
wav_file.close()

print("Generated " + file)

The sine function I have inside the for loop is probably the part I understand the least because I just went by the example verbatim. I'm used to making sine functions like (y = Asin(2πfx)) but I'm not sure what the purpose is of multiplying by ((i+128)/256) and 65535 (16-bit amplitude resolution?). I'm also not sure what the purpose is of subtracting 32768 from each sample. Is anyone able to clarify what I'm missing and maybe point me in the right direction? Am I going about this the wrong way? Any help is appreciated!

1

There are 1 answers

0
arseniiv On

If you just wanted to generate sound data ahead of time and then dump it all into a file, and you’re also comfortable using NumPy, I’d suggest using it with a library like SoundFile. Then there’s no need to delimit the data into frames.

Starting with a naïve approach (using numpy.sin, not trying to optimize things yet), one ends with something like this:

from math import tau
import numpy as np
import soundfile as sf

file_path = 'sine.flac'
sample_rate = 48_000   # hertz
duration = 1.0         # seconds
frequency = 432.0      # hertz
amplitude = 0.8        # (not in decibels!)
start_phase = 0.0      # at what phase to start

sample_count = floor(sample_rate * duration)

# cyclical frequency in sample^-1
omega = frequency * tau / sample_rate

# all phases for which we want to sample our sine
phases = np.linspace(start_phase, start_phase + omega * sample_count,
                     sample_count, endpoint=False)

# our sine wave samples, generated all at once
audio = amplitude * np.sin(phases)

# now write to file
fmt, sub = 'FLAC', 'PCM_24'
assert sf.check_format(fmt, sub) # to make sure we ask the correct thing beforehand
sf.write(file_path, audio, sample_rate, format=fmt, subtype=sub)

This will be a mono sound, you can write stereo using 2d arrays (see NumPy and SoundFile’s docs).

But note that to make a wavetable specifically, you need to be sure it contains just a single period (or an integer number of periods) of the wave exactly, so the playback of the wavetable will be without clicks and have a correct frequency.

You can play chunked sound in real time in Python too, using something like PyAudio. (I’ve not yet used that, so at least for a time this answer would lack code related to that.)

Finally, frankly, all above is unrelated to the generation of sound data from a wavetable: you just pick a wavetable from somewhere, that doesn’t do much for actual synthesis. Here is a simple starting algorithm for that. Assume you want to play back a chunk of sample_count samples and have a wavetable stored in wavetable, a single period which loops perfectly and is normalized. And assume your current wave phase is start_phase, frequency is frequency, sample rate is sample_rate, amplitude is amplitude. Then:

# indices for the wavetable values; this is just for `np.interp` to work
wavetable_period = float(len(wavetable))
wavetable_indices = np.linspace(0, wavetable_period,
                                len(wavetable), endpoint=False)

# frequency of the wavetable played at native resolution
wavetable_freq = sample_rate / wavetable_period

# start index into the wavetable
start_index = start_phase * wavetable_period / tau

# code above you run just once at initialization of this wavetable ↑
# code below is run for each audio chunk ↓

# samples of wavetable per output sample
shift = frequency / wavetable_freq

# fractional indices into the wavetable
indices = np.linspace(start_index, start_index + shift * sample_count,
                      sample_count, endpoint=False)

# linearly interpolated wavetavle sampled at our frequency
audio = np.interp(indices, wavetable_indices, wavetable,
                  period=wavetable_period)
audio *= amplitude

# at last, update `start_index` for the next chunk
start_index += shift * sample_count

Then you output the audio. Though there are better ways to play back a wavetable, linear interpolation is at least a fine start. Frequency slides are also possible with this approach: just compute indices in another way, no longer spaced uniformly.