2017-07-19 137 views
1

在过去的几个星期里,我一直在研究一个对我来说都很新的项目,而且我正在学习。我正在使用Raspberry Pi 2构建一个合成器,并且我正在使用Python3进行编码,因为我对该语言有一些基本知识,但没有太多实际经验。到目前为止,我的表现相当不错,但现在我已经触到了我最终会打的墙:表现。在Raspberry Pi上优化Python合成器

我一直在使用Pygame及其声音模块来创建我想要的声音,然后使用我自己的数学算法来计算每个声音的ADS(H)R音量包络。我用8个电位器调整了这个信封。其中3个控制Attack,Decay,Release和另一个设置延音水平的秒数。然后我又增加了4个盆来控制信封的每个部分的曲率(除了其中一个设定了Sustain的Hold值)。我还连接了一个PiTFT屏幕,它画出了整个信封的当前形状和长度,并打印出ADSR的当前值。

要播放声音我使用4x4 Adafruit Trellis板和不同的按钮组合我可以播放C0和C8之间的每个音符。

我使用SciPy和NumPy创建不同类型的声波,如正弦,方形,三角形,锯齿,脉冲和噪声。

由于我一直使用常规循环来根据ADSR包络来改变声音的音量,因此运行PlaySound函数需要一段时间才能完成(取决于我的ADSR设置当然)。这促使我尝试使用线程。我不知道我是否以最好的方式使用它,如果我应该使用它,但这是我能想到的实现复音的唯一方法。否则,它必须等到声音完成后才能恢复主循环。所以现在我可以同时播放几个音符。那么,至少两个笔记。之后它落后,第三个似乎没有播放,直到之前的一个声音结束。

我已经做了一些测试和检查,我应该可以同时支持4个线程,但我可能会错过一些东西。一种猜测是系统本身为其他用途预留了两个线程(核心)。

我也意识到Python并不是最有效的语言,我也一直在研究Pure Data,但是我在绕过它的时候遇到了麻烦(我更喜欢使用代码来实现点击和-drag-GUI)。我想尽可能长时间地继续使用Python。我可能会考虑使用pyo,但是我认为我必须主要从头开始使用我的代码(我愿意这样做,但我不想放弃当前的代码)。

所以。这是我的问题:我怎样才能优化这是真正的复调?两个笔记是不够的。我应该完全跳过这些线程吗?我能否以更好,成本更低的方式实施ADSR信封?我怎样才能清理杂乱的数学?还有哪些其他性能瓶颈,我忽略了? Pygame在屏幕上的绘制似乎现在可以忽略不计,因为如果我完全禁用它,那么几乎没有任何区别。这是我到目前为止的代码:

import pygame 
from pygame.mixer import Sound, get_init, pre_init, get_num_channels 
from array import array 
import RPi.GPIO as GPIO 
import alsaaudio 
import time 
import Adafruit_Trellis 
import Adafruit_MCP3008 
import math 
import _thread 
import os 
import multiprocessing 
import numpy as np 
from scipy import signal as sg 
import struct 

#print(str(multiprocessing.cpu_count())) 

os.putenv('SDL_FBDEV','/dev/fb1') 

fps = pygame.time.Clock() 

FRAMERATE = 100 
MINSEC = 1/FRAMERATE 

BLUE  = ( 0, 0, 255) 
WHITE  = (255, 255, 255) 
DARKRED = (128, 0, 0) 
DARKBLUE = ( 0, 0, 128) 
RED  = (255, 0, 0) 
GREEN  = ( 0, 255, 0) 
DARKGREEN = ( 0, 128, 0) 
YELLOW = (255, 255, 0) 
DARKYELLOW = (128, 128, 0) 
BLACK  = ( 0, 0, 0) 

PTCH = [ 1.00, 1.059633027522936, 1.122324159021407, 1.18960244648318, 
    1.259938837920489, 1.335168195718654, 1.414067278287462, 
    1.498470948012232, 1.587767584097859, 1.681957186544343, 
    1.782262996941896, 1.888073394495413, 2.00 ] 

FREQ = { # Parsed from http://www.phy.mtu.edu/~suits/notefreqs.html 
    'C0': 16.35, 'Cs0': 17.32, 'D0': 18.35, 'Ds0': 19.45, 'E0': 20.60, 
    'F0': 21.83, 'Fs0': 23.12, 'G0': 24.50, 'Gs0': 25.96, 'A0': 27.50, 
    'As0': 29.14, 'B0': 30.87, 'C1': 32.70, 'Cs1': 34.65, 'D1': 36.71, 
    'Ds1': 38.89, 'E1': 41.20, 'F1': 43.65, 'Fs1': 46.25, 'G1': 49.00, 
    'Gs1': 51.91, 'A1': 55.00, 'As1': 58.27, 'B1': 61.74, 'C2': 65.41, 
    'Cs2': 69.30, 'D2': 73.42, 'Ds2': 77.78, 'E2': 82.41, 'F2': 87.31, 
    'Fs2': 92.50, 'G2': 98.00, 'Gs2': 103.83, 'A2': 110.00, 'As2': 116.54, 
    'B2': 123.47, 'C3': 130.81, 'Cs3': 138.59, 'D3': 146.83, 'Ds3': 155.56, 
    'E3': 164.81, 'F3': 174.61, 'Fs3': 185.00, 'G3': 196.00, 'Gs3': 207.65, 
    'A3': 220.00, 'As3': 233.08, 'B3': 246.94, 'C4': 261.63, 'Cs4': 277.18, 
    'D4': 293.66, 'Ds4': 311.13, 'E4': 329.63, 'F4': 349.23, 'Fs4': 369.99, 
    'G4': 392.00, 'Gs4': 415.30, 'A4': 440.00, 'As4': 466.16, 'B4': 493.88, 
    'C5': 523.25, 'Cs5': 554.37, 'D5': 587.33, 'Ds5': 622.25, 'E5': 659.26, 
    'F5': 698.46, 'Fs5': 739.99, 'G5': 783.99, 'Gs5': 830.61, 'A5': 880.00, 
    'As5': 932.33, 'B5': 987.77, 'C6': 1046.50, 'Cs6': 1108.73, 'D6': 1174.66, 
    'Ds6': 1244.51, 'E6': 1318.51, 'F6': 1396.91, 'Fs6': 1479.98, 'G6': 1567.98, 
    'Gs6': 1661.22, 'A6': 1760.00, 'As6': 1864.66, 'B6': 1975.53, 'C7': 2093.00, 
    'Cs7': 2217.46, 'D7': 2349.32, 'Ds7': 2489.02, 'E7': 2637.02, 'F7': 2793.83, 
    'Fs7': 2959.96, 'G7': 3135.96, 'Gs7': 3322.44, 'A7': 3520.00, 
    'As7': 3729.31, 'B7': 3951.07, 
    'C8': 4186.01, 'Cs8': 4434.92, 'D8': 4698.64, 'Ds8': 4978.03, 
} 

buttons = ['A',PTCH[9],PTCH[10],PTCH[11],'B',PTCH[6],PTCH[7],PTCH[8],'C',PTCH[3],PTCH[4],PTCH[5],PTCH[12],PTCH[0],PTCH[1],PTCH[2] ] 

octaves = { 'BASE':'0', 'A':'1', 'B':'2', 'C':'3', 'AB':'4', 'AC':'5', 'BC':'6', 'ABC':'7' } 

class Note(pygame.mixer.Sound): 

    def __init__(self, frequency, volume=.1): 
     self.frequency = frequency 
     self.oktostop = False 
     Sound.__init__(self, self.build_samples()) 
     self.set_volume(volume) 

    def playSound(self, Aval, Dval, Sval, Rval, Acurve, Dcurve, Shold, Rcurve, fps): 
     self.set_volume(0) 
     self.play(-1) 
     if Aval >= MINSEC: 
      Alength = round(Aval*FRAMERATE) 

      for num in range(0,Alength+1): 
       fps.tick_busy_loop(FRAMERATE) 
       volume = (Acurve[1]*pow(num*MINSEC,Acurve[0]))/100 
       self.set_volume(volume) 
       #print(fps.get_time()," ",str(volume)) 
     else: 
      self.set_volume(100) 

     if Sval <= 1 and Sval > 0 and Dval >= MINSEC: 
      Dlength = round(Dval*FRAMERATE) 

      for num in range(0,Dlength+1): 
       fps.tick_busy_loop(FRAMERATE) 
       volume = (Dcurve[1]*pow(num*MINSEC,Dcurve[0])+100)/100 
       self.set_volume(volume) 
       #print(fps.get_time()," ",str(volume)) 
     elif Sval <= 1 and Sval > 0 and Dval < MINSEC: 
      self.set_volume(Sval) 
     else: 
      self.set_volume(0) 

     if Shold >= MINSEC: 
      Slength = round(Shold*FRAMERATE) 
      for num in range(0,Slength+1): 
       fps.tick_busy_loop(FRAMERATE) 

     while True: 
      if self.oktostop: 
       if Sval > 0 and Rval >= MINSEC: 
        Rlength = round(Rval*FRAMERATE) 
        for num in range(0,Rlength+1): 
         fps.tick_busy_loop(FRAMERATE) 
         volume = (Rcurve[1]*pow(num*MINSEC,Rcurve[0])+(Sval*100))/100 
         self.set_volume(volume) 
         #print(fps.get_time()," ",str(volume)) 
       self.stop() 
       break 

    def stopSound(self): 
     self.oktostop = True 

    def build_samples(self): 
     Fs = get_init()[0] 
     f = self.frequency 
     sample = Fs/f 
     x = np.arange(sample) 

     # Sine wave 
     #y = 0.5*np.sin(2*np.pi*f*x/Fs) 

     # Square wave 
     y = 0.5*sg.square(2*np.pi*f*x/Fs) 

     # Pulse wave 
     #sig = np.sin(2 * np.pi * x) 
     #y = 0.5*sg.square(2*np.pi*f*x/Fs, duty=(sig + 1)/2) 

     # Sawtooth wave 
     #y = 0.5*sg.sawtooth(2*np.pi*f*x/Fs) 

     # Triangle wave 
     #y = 0.5*sg.sawtooth(2*np.pi*f*x/Fs,0.5) 

     # White noise 
     #y = 0.5*np.random.uniform(-1.000,1.000,sample) 
     return y 


pre_init(44100, -16, 2, 2048) 
pygame.init() 
screen = pygame.display.set_mode((480, 320)) 
pygame.mouse.set_visible(False) 

CLK = 5 
MISO = 6 
MOSI = 13 
CS = 12 

mcp = Adafruit_MCP3008.MCP3008(clk=CLK, cs=CS, miso=MISO, mosi=MOSI) 

Asec = 1.0 
Dsec = 1.0 
Ssec = 1.0 
Rsec = 1.0 

matrix0 = Adafruit_Trellis.Adafruit_Trellis() 
trellis = Adafruit_Trellis.Adafruit_TrellisSet(matrix0) 
NUMTRELLIS = 1 
numKeys = NUMTRELLIS * 16 
I2C_BUS = 1 
trellis.begin((0x70, I2C_BUS)) 

# light up all the LEDs in order 
for i in range(int(numKeys)): 
    trellis.setLED(i) 
    trellis.writeDisplay() 
    time.sleep(0.05) 
# then turn them off 
for i in range(int(numKeys)): 
    trellis.clrLED(i) 
    trellis.writeDisplay() 
    time.sleep(0.05) 


posRecord = {'attack': [], 'decay': [], 'sustain': [], 'release': []} 
octaval = {'A':False,'B':False,'C':False} 
pitch = 0 
tone = None 
old_tone = None 
note = None 
volume = 0 
#m = alsaaudio.Mixer('PCM') 
#mastervol = m.getvolume() 
sounds = {} 
values = [0]*8 
oldvalues = [0]*8 
font = pygame.font.SysFont("comicsansms", 22) 


while True: 
    fps.tick_busy_loop(FRAMERATE) 

    #print(fps.get_time()) 
    update = False 
    #m.setvolume(int(round(MCP3008(4).value*100))) 
    #mastervol = m.getvolume() 
    values = [0]*8 
    for i in range(8): 
     # The read_adc function will get the value of the specified channel (0-7). 
     values[i] = mcp.read_adc(i)/1000 
     if values[i] >= 1: 
      values[i] = 1 
    # Print the ADC values. 
    #print('| {0:>4} | {1:>4} | {2:>4} | {3:>4} | {4:>4} | {5:>4} | {6:>4} | {7:>4} |'.format(*values)) 
    #print(str(pygame.mixer.Channel(0).get_busy())+" "+str(pygame.mixer.Channel(1).get_busy())+" "+str(pygame.mixer.Channel(2).get_busy())+" "+str(pygame.mixer.Channel(3).get_busy())+" "+str(pygame.mixer.Channel(4).get_busy())+" "+str(pygame.mixer.Channel(5).get_busy())+" "+str(pygame.mixer.Channel(6).get_busy())+" "+str(pygame.mixer.Channel(7).get_busy())) 

    Sval = values[2]*Ssec 
    Aval = values[0]*Asec 
    if Sval == 1: 
     Dval = 0 
    else: 
     Dval = values[1]*Dsec 
    if Sval < MINSEC: 
     Rval = 0 
    else: 
     Rval = values[3]*Rsec 

    if Aval > 0: 
     if values[4] <= MINSEC: values[4] = MINSEC 
     Acurve = [round(values[4]*4,3),round(100/pow(Aval,(values[4]*4)),3)] 
    else: 
     Acurve = False 
    if Dval > 0: 
     if values[5] <= MINSEC: values[5] = MINSEC 
     Dcurve = [round(values[5]*4,3),round(((Sval*100)-100)/pow(Dval,(values[5]*4)),3)] 
    else: 
     Dcurve = False 
    Shold = values[6]*4*Ssec 
    if Rval > 0 and Sval > 0: 
     if values[7] <= MINSEC: values[7] = MINSEC 
     Rcurve = [round(values[7]*4,3),round(-Sval*100/pow(Rval,(values[7]*4)),3)] 
    else: 
     Rcurve = False 

    if update: 
     screen.fill((0, 0, 0)) 

     scrnvals = ["A: "+str(round(Aval,2))+"s","D: "+str(round(Dval,2))+"s","S: "+str(round(Sval,2)),"R: "+str(round(Rval,2))+"s","H: "+str(round(Shold,2))+"s","ENV: "+str(round(Aval,2)+round(Dval,2)+round(Shold,2)+round(Rval,2))+"s"] 

     for line in range(len(scrnvals)): 
      text = font.render(scrnvals[line], True, (0, 128, 0)) 
      screen.blit(text,(60*line+40, 250)) 

     # Width of one second in number of pixels 
     ASCALE = 20 
     DSCALE = 20 
     SSCALE = 20 
     RSCALE = 20 

     if Aval >= MINSEC: 
      if Aval <= 1: 
       ASCALE = 80 
      else: 
       ASCALE = 20 
      # Attack 
      for yPos in range(0,101): 
       xPos = round(pow((yPos/Acurve[1]),(1/Acurve[0]))*ASCALE) 
       posRecord['attack'].append((int(xPos) + 40, int(-yPos) + 130)) 

      if len(posRecord['attack']) > 1: 
       pygame.draw.lines(screen, DARKRED, False, posRecord['attack'], 2) 

     if Dval >= MINSEC: 
      if Dval <= 1: 
       DSCALE = 80 
      else: 
       DSCALE = 20 
      # Decay 
      for yPos in range(100,round(Sval*100)-1,-1): 
       xPos = round(pow(((yPos-100)/Dcurve[1]),(1/Dcurve[0]))*DSCALE) 
       #print(str(yPos)+" = "+str(Dcurve[1])+"*"+str(xPos)+"^"+str(Dcurve[0])+"+100") 
       posRecord['decay'].append((int(xPos) + 40 + round(Aval*ASCALE), int(-yPos) + 130)) 

      if len(posRecord['decay']) > 1: 
       pygame.draw.lines(screen, DARKGREEN, False, posRecord['decay'], 2) 

     # Sustain 
     if Shold >= MINSEC: 
      for xPos in range(0,round(Shold*SSCALE)): 
       posRecord['sustain'].append((int(xPos) + 40 + round(Aval*ASCALE) + round(Dval*DSCALE), int(100-Sval*100) + 30)) 

      if len(posRecord['sustain']) > 1: 
       pygame.draw.lines(screen, DARKYELLOW, False, posRecord['sustain'], 2) 

     if Rval >= MINSEC: 
      if Rval <= 1: 
       RSCALE = 80 
      else: 
       RSCALE = 20 
      # Release 
      for yPos in range(round(Sval*100),-1,-1): 
       xPos = round(pow(((yPos-round(Sval*100))/Rcurve[1]),(1/Rcurve[0]))*RSCALE) 
       #print(str(xPos)+" = (("+str(yPos)+"-"+str(round(Sval*100))+")/"+str(Rcurve[1])+")^(1/"+str(Rcurve[0])+")") 
       posRecord['release'].append((int(xPos) + 40 + round(Aval*ASCALE) + round(Dval*DSCALE) + round(Shold*SSCALE), int(-yPos) + 130)) 

      if len(posRecord['release']) > 1: 
       pygame.draw.lines(screen, DARKBLUE, False, posRecord['release'], 2) 

     posRecord = {'attack': [], 'decay': [], 'sustain': [], 'release': []} 

     pygame.display.update() 

    tone = None 
    pitch = 0 
    time.sleep(MINSEC) 
    # If a button was just pressed or released... 
    if trellis.readSwitches(): 
     # go through every button 
     for i in range(numKeys): 
      # if it was pressed, turn it on 
      if trellis.justPressed(i): 
       print('v{0}'.format(i)) 
       trellis.setLED(i) 

       if i == 0: 
        octaval['A'] = True 
       elif i == 4: 
        octaval['B'] = True 
       elif i == 8: 
        octaval['C'] = True 
       else: 
        pitch = buttons[i] 
        button = i 


      # if it was released, turn it off 
      if trellis.justReleased(i): 
       print('^{0}'.format(i)) 
       trellis.clrLED(i) 
       if i == 0: 
        octaval['A'] = False 
       elif i == 4: 
        octaval['B'] = False 
       elif i == 8: 
        octaval['C'] = False 
       else: 
        sounds[i].stopSound() 

     # tell the trellis to set the LEDs we requested 
     trellis.writeDisplay() 

    octa = '' 
    if octaval['A']: 
     octa += 'A' 
    if octaval['B']: 
     octa += 'B' 
    if octaval['C']: 
     octa += 'C' 
    if octa == '': 
     octa = 'BASE' 

    if pitch > 0: 
     tone = FREQ['C0']*pow(2,int(octaves[octa]))*pitch 


    if tone: 
     sounds[button] = Note(tone) 
     _thread.start_new_thread(sounds[button].playSound,(Aval, Dval, Sval, Rval, Acurve, Dcurve, Shold, Rcurve, fps)) 
     print(str(tone)) 

GPIO.cleanup() 
+0

我不知道在PyGame中这样做是否可行,我会尝试使用允许您定义音频回调函数的东西。你可以从我的这个例子中找到一些灵感:https://github.com/spatialaudio/jackclient-python/blob/master/examples/midi_sine_numpy.py。这是使用JACK,但它应该很容易翻译这与[pyaudio](https://people.csail.mit.edu/hubert/pyaudio/)或[sounddevice](http:// python-sounddevice .readthedocs.io /)模块。 – Matthias

回答

1

你此刻在做什么,是发射一个声音,放弃所有的控制,直到声音已被播放。这里的一般方法是改变它,并一次处理一个样本,并将其推送到缓冲区,这是可以周期性播放的。该样本将是您所有声音/信号的总和。这样,您可以决定每个样本,如果要触发新的声音,并且可以决定在播放音符的同时播放音符的时间。一种方法是安装一个定时器,如果你想要48kHz的采样率,那么每1/48000秒触发一次回调函数。

如果您需要处理大量的声音,但不是一个语音的线程,那么您仍然可以使用多线程进行并行处理,这在我的观点中会过度。如果这是必要的或不取决于你做了多少过滤/处理以及你的程序有效/无效。

例如

sample_counter = 0 
output_buffer = list() 

def callback_fct(): 
    pitch_0 = 2 
    pitch_1 = 4 
    sample_counter += 1  #time in ms 
    signal_0 = waveform(sample_counter * pitch_0) 
    signal_1 = waveform(sample_counter * pitch_1) 
    signal_out = signal_0 * 0.5 + signal_1 *0.5 
    output_buffer.append(signal_out) 
    return 0 

if __name__ == "__main__": 
    call_this_function_every_ms(callback_fct) 
    play_sound_from_outputbuffer() #plays sound from outputbuffer by popping samples from the beginning of the list. 

就是这样的。波形()函数会根据实际时间乘以所需音高为您提供采样值。在C语言中,你可以用指针做所有事情,在Wavetable结束时会溢出,所以你不必处理这个问题,什么时候你应该重置sample_counter而不会在波形中产生小故障(它会变得很真实不久)。但我很确定,有更多的“pythonic”的说法。以更低级别的语言来做这件事的另一个好的理由是速度。只要涉及真正的DSP,您就会计算您的处理器时钟滴答声。那时Python可能会有太多开销。

+0

因此,如果我要设置1kHz的采样率开始,函数将每隔1/1000秒运行一次并计算当时的声音总和并播放它1毫秒,然后下一毫秒它会重复这一点。你有任何示例代码?即使伪代码也会有所帮助。 :) –

+0

你不会播放一毫秒,而是将所有内容都推送到一个环缓冲区,这就是说100ms长。并不断演奏。如果你不推动某些东西,它会每100ms重复一次。这样你的计算能力就可以有所不同了。我会在答案中加入一些代码,因为在评论中写代码很痛苦。 – Rantanplan

0

你说得对,python可能是瓶颈之一。商业软合成器几乎无一例外都是用C++编写的,以利用各种优化 - 其中最相关的是使用矢量处理单元。

有,然而,大量的优化开放你在Python:

  • 您计算每个样品的信封,并以昂贵的方式(使用pow() - 这是不完全的硬件加速上的ARM Cortex CPU可以预先计算传递函数,并简单地将其与每个样本相乘,我也怀疑在44.1kHz或更高的频率下,您不需要在每个样本上更改包络,或许每100个样本都足够好。
  • 你的振荡器也计算每个样本,据我所知,每音符播放。其中一些是相当便宜,但是三角形f实际的软合成器使用振荡器波形表和相位累加器作为近似值。

事情你必须

  • 精度的控制较少:你最终生成一个16位的样品。我怀疑,默认情况下,Python对所有内容都使用双精度 - 具有48位尾数 - 约比您需要的宽度宽3倍。
  • 双精度数学函数在ARM Cortex A部件上很慢 - 事实上非常重要。单精度可以通过VPU执行许多操作,您可以在DSP中使用很多操作,例如MAC(乘法 - 累加)以一个周期(尽管它们需要16个周期来清除管线)。双精度慢几个数量级。

@ Rantanplan的回答超出了软件体系结构的类型 - 软件合成器是由事件驱动的,并且定期调用一个渲染处理程序来提供样本。一个和弦softsynth并行做这些。

在井优化的实施各样品的每个语音的处理将涉及: *从波表中的一个查找由包络(具有第一算出的缓冲器使用整数数学偏移) *乘法 *混匀在输出缓冲区中与其他人进行采样。

性能的关键在于在这个紧密的循环中几乎没有流量控制语句。

定期地,可能每回调间隔,信封将被更新。这样可以在具有VPU的CPU上同时并行处理几个相邻的采样 - 这样在ARM Cortex A部件上将是双向的。

+0

你写的一些内容覆盖了我的头,但也有一些帮助。我仍然不确定如何使用音频缓冲区实际编写我的代码,但我正在寻找外部工具来帮助我,例如FluidSynth和pyo。从我和你的Rantanplan的回答中我可以得出,我可能已经能够发出声音,但是以我已经怀疑的可能性最低(因而不可持续)的方式发出声音。我还有很多要学习,我想我至少知道现在从哪里开始。 :) –