在Raspberry Pi上优化Python合成器

时间:2017-07-19 07:34:26

标签: python audio raspberry-pi

在过去的几周里,我一直致力于一个对我来说都很新鲜的项目,而且我一直在学习。我正在使用Raspberry Pi 2构建合成器,我在Python3中对其进行编码,因为我对该语言有一些基本知识,但没有太多真实的经验。到目前为止,我已经很好地混淆了,但是我现在已经碰到了我最终会遇到的墙:性能。

我一直在使用Pygame及其Sound模块来创建我想要的声音,然后使用我自己的数学算法来计算每个声音的ADS(H)R音量包络。我用8个电位器调整了这个信封。其中3个控制攻击,衰减,释放的长度,以及另一个设置延音等级的长度。然后我又添加了4个控制信封每个部分曲率的控制台(除了其中一个控制器设置了持续的保持值)。我也连接了一个PiTFT屏幕,它绘制了整个信封的当前形状和长度,并打印出ADSR的当前值。

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

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

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

我已经完成了一些测试和检查,我应该能够同时最多使用4个线程,但我可能会遗漏一些东西。一个猜测是系统本身已经为其他用途预留了两个线程(核心)。

我也意识到Python不是最有效的语言,我也一直在研究纯数据,但是我无法绕过它(我更喜欢代码而不是代码)单击并拖动贵)。我想尽可能长时间地使用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()

2 个答案:

答案 0 :(得分:1)

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

你仍然可以使用多线程进行并行处理,如果你需要处理很多声音,而不是一个声音用于一个声音,这对我的意见来说太过分了。如果这是必要的,取决于你做了多少过滤/处理,以及你的程序有效/无效。

e.g。

/*------- Toggle switch --------*/

.switch {
  position: relative;
  display: block;
  vertical-align: top;
  width: 70px;
  height: 30px;
  padding: 3px;
  margin: 0 10px 10px 0;
  background: linear-gradient(to bottom, #eeeeee, #FFFFFF 25px);
  background-image: -webkit-linear-gradient(top, #eeeeee, #FFFFFF 25px);
  border-radius: 18px;
  box-shadow: inset 0 -1px white, inset 0 1px 1px rgba(0, 0, 0, 0.05);
  cursor: pointer;
  box-sizing: content-box;
}

.switch-input {
  position: absolute;
  top: 0;
  left: 0;
  opacity: 0;
  box-sizing: content-box;
}

.switch-label {
  position: relative;
  display: block;
  height: inherit;
  font-size: 10px;
  text-transform: uppercase;
  background: #bfbfbf;
  border-radius: inherit;
  box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.12), inset 0 0 2px rgba(0, 0, 0, 0.15);
  box-sizing: content-box;
}

.switch-label:before,
.switch-label:after {
  position: absolute;
  top: 50%;
  margin-top: -.5em;
  line-height: 1;
  -webkit-transition: inherit;
  -moz-transition: inherit;
  -o-transition: inherit;
  transition: inherit;
  box-sizing: content-box;
}

.switch-label:before {
  content: attr(data-off);
  right: 11px;
  color: #000;
  text-shadow: 0 1px rgba(255, 255, 255, 0.5);
}

.switch-label:after {
  content: attr(data-on);
  left: 11px;
  color: #FFFFFF;
  text-shadow: 0 1px rgba(0, 0, 0, 0.2);
  opacity: 0;
}

.switch-input:checked~.switch-label {
  background: #20c000;
  box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.15), inset 0 0 3px rgba(0, 0, 0, 0.2);
}

.switch-input:checked~.switch-label:before {
  opacity: 0;
}

.switch-input:checked~.switch-label:after {
  opacity: 1;
}

.switch-handle {
  position: absolute;
  top: 4px;
  left: 4px;
  width: 28px;
  height: 28px;
  background: linear-gradient(to bottom, #FFFFFF 40%, #f0f0f0);
  background-image: -webkit-linear-gradient(top, #FFFFFF 40%, #f0f0f0);
  border-radius: 100%;
  box-shadow: 1px 1px 5px rgba(0, 0, 0, 0.2);
}

.switch-handle:before {
  content: "";
  position: absolute;
  top: 50%;
  left: 50%;
  margin: -6px 0 0 -6px;
  width: 12px;
  height: 12px;
  background: linear-gradient(to bottom, #eeeeee, #FFFFFF);
  background-image: -webkit-linear-gradient(top, #eeeeee, #FFFFFF);
  border-radius: 6px;
  box-shadow: inset 0 1px rgba(0, 0, 0, 0.02);
}

.switch-input:checked~.switch-handle {
  left: 44px;
  box-shadow: -1px 1px 5px rgba(0, 0, 0, 0.2);
}


/* Transition */

.switch-label,
.switch-handle {
  transition: All 0.3s ease;
  -webkit-transition: All 0.3s ease;
  -moz-transition: All 0.3s ease;
  -o-transition: All 0.3s ease;
}

这样的事情。波形()函数会根据所需音高的实际时间给出样本值。在C中你会用指针完成所有这一切,在Wavetable结束时溢出,所以你不必处理这个问题,当你应该重置你的sample_counter而不会在波形中出现毛刺(它会变得真实很快真的很棒)。但我很害羞,还有更多&#34; pythonic&#34;对此有所了解。用更低级别语言执行此操作的另一个好理由是速度。一旦涉及真正的DSP,您将计算处理器时钟周期。那时python可能只是有太多的开销。

答案 1 :(得分:0)

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

尽管如此,在Python中还有很多优化:

  • 您正在计算每个样本的包络,并且以昂贵的方式(使用pow() - 在ARM Cortex CPU上没有完全硬件加速。您可以预先计算传递函数并简单地将其与每个我也怀疑在44.1kHz或更高的频率下,你不需要每个样本都改变一个包络 - 也许每100个左右就足够了。
  • 您的振荡器也是按样本计算的,据我所知,每个音符播放。其中一些相当便宜,但触发功能较少,实用的软合成器使用振荡器波表和相位累加器作为近似值。

你控制不了的东西

  • 准确性:您最终会生成一个16位样本。我怀疑默认情况下,Python对所有东西都使用双精度 - 它有一个48位的尾数 - 大约比你需要的宽3倍。
  • ARM Cortex A部件的双精度数学函数速度很慢 - 实际上非常重要。单精度可以通过VPU进行许多操作,您可以在DSP中使用很多操作,例如MAC(乘法累加),只需一个周期(尽管它们需要16个周期才能清除流水线)。双精度要慢几个数量级。

@Rantanplan上面的回答提到了软件架构的软件架构 - 一种是事件驱动的,并且周期性地调用渲染处理器来提供样本。复音softsynth和平做这些。

在优化的实施中,每种语音的每个样本的处理将涉及:  *来自波表的一次查找(首先使用整数数学计算缓冲区偏移量)  *乘以包络  *将样本与输出缓冲区中的其他样本混合。

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

可能每个回调间隔周期性地更新信封。这在具有VPU的CPU上同时对几个相邻的样本进行并行化 - 因此在ARM Cortex A部件上将是双向的。