I'm trying to code a digital wavetable synthesizer that will eventually incorporate real-time pitch/volume input, but I'm stuck making the audio work properly. The basic idea is that the program checks for pitch and volume input about 100 times per second, uses scipy's interpolation function to alter the recorded waveform period's pitch and volume, then writes a signal consisting of the altered waveform period repeated to a duration of at least 0.01s to the Pyaudio stream. I originally tried using pygame, but pygame crashed too easily.
Trying to use callback mode and sleep() to time each cycle resulted in really choppy and rough sound due to audio cliffs or cycle signal overlap, and even when I tested pitches that wouldn't have audio cliffs there's a strange high-pitch component that wasn't in the original waveform.
Using blocking mode resulted in a more faithful signal because there wasn't a need to time the cycles; the previous cycle had to complete before execution resumed. However, the computation to generate each cycle's signal did result in a skip, changing the timbre of the waveform. The change is small because the computation time is much smaller than the 0.01s cycle, but it would be worse if I wanted to increase the pitch/volume update frequency of the program.
Is there a way to make the stream.write() function return ASAP and allow the signal computation but pause execution at the next stream.write() until all samples are played?
import scipy as sp
from scipy import interpolate
from time import sleep, perf_counter
import pyaudio
def pyaudioplay():
# sampled waveform and linear interpolation
waveform = sp.array([ 884, 8343, 18370, 27897, 32767, 30861, 23163, 12692,
2225, -6780, -13815, -17404, -16557, -12404, -6286, 792,
7448, 12458, 15086, 15770, 15699, 15031, 13224, 10705,
8734, 7790, 7621, 7800, 7784, 6964, 5444, 4174,
4435, 6335, 8696, 10156, 10460, 10352, 9950, 9006,
7594, 5835, 3680, -21, -6574, -15732, -25302, -31550,
-31968, -25916, -15352, -3702, 6536, 14429, 19103, 19244,
15471, 9717, 2882, -4359, -10835, -15449, -17816, -18788,
-18685, -16665, -12778, -8588, -5434, -3593, -2882, -3311,
-4370, -5157, -5531, -6112, -7410, -9000, -10042, -10042,
-9206, -8213, -7268, -6188, -4489, -1167], dtype=sp.int16)
size = len(waveform)
wavefunc = interpolate.interp1d(range(size), waveform, kind = 'linear', axis = -1, copy = True, bounds_error = False, fill_value = (waveform[0], waveform[-1]))
# pitch and volume 1.5s time series for testing
length = 150; sample_pitch = 512.79069767441854; max_volume = 1.
t = sp.linspace(0., 3*sp.pi, num = length, endpoint = False)
pitches = (sample_pitch*3/4)+(sample_pitch*1/4)*sp.cos(t) # pitch goes down and up
volumes = max_volume*sp.ones(length) # hold at same volume
# PyAudio stream: blocking mode
p = pyaudio.PyAudio()
stream = p.open(format = pyaudio.paInt16,
channels = 1,
rate = 44100,
output = True,
frames_per_buffer = 0) # also specifies frame_count int for callback
updatePeriod = 441
begin = perf_counter()
record = 0
index = 0
while index < length:
start = perf_counter()
# get pitch and volume
Apitch = pitches[index]
Avolume = volumes[index]
# period with altered width and amplitude to change pitch and volume
Aperiod = (Avolume*wavefunc(sp.arange(0, size, Apitch/sample_pitch))).astype(sp.int16)
cycle = sp.tile(Aperiod, 1+updatePeriod//len(Aperiod)).tobytes()
index += 1
runtime = perf_counter() - start
if runtime > record:
record = runtime
stream.write(cycle)
print('total time:', perf_counter()-begin)
print('largest runtime lag:', record)
stream.stop_stream()
stream.close()
p.terminate()