diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e9e721f --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +build/ +*.c +*.so +local_config.* diff --git a/0 Saw/definition.txt b/0 Saw/definition.txt new file mode 100644 index 0000000..814d501 --- /dev/null +++ b/0 Saw/definition.txt @@ -0,0 +1,2 @@ +%midinote.wav +%%velocitysensitivity=1 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..08338f8 --- /dev/null +++ b/Makefile @@ -0,0 +1,2 @@ +samplerbox_audio.so: samplerbox_audio.pyx samplerbox_audio_neon.pyx + python setup.py build_ext --inplace diff --git a/README.md b/README.md index 1326e48..d5840cd 100644 --- a/README.md +++ b/README.md @@ -22,9 +22,8 @@ SamplerBox works with the RaspberryPi's built-in soundcard, but it is recommende 2. Download SamplerBox and build it with: ~~~ - git clone https://github.com/josephernest/SamplerBox.git - cd SamplerBox ; sudo python setup.py build_ext --inplace - ~~~ + git clone https://github.com/josephernest/SamplerBox.git ; + cd SamplerBox ; make 3. Run the soft with `python samplerbox.py`. @@ -54,4 +53,4 @@ Author : Joseph Ernest (twitter: [@JosephErnest](http:/twitter.com/JosephErnest) [License](#license) ---- -[Creative Commons BY-SA 3.0](http://creativecommons.org/licenses/by-sa/3.0/) \ No newline at end of file +[Creative Commons BY-SA 3.0](http://creativecommons.org/licenses/by-sa/3.0/) diff --git a/peripherals/hd44780.py b/peripherals/hd44780.py new file mode 100644 index 0000000..c14cac6 --- /dev/null +++ b/peripherals/hd44780.py @@ -0,0 +1,127 @@ +# The wiring for the LCD is as follows: +# 1 : GND +# 2 : 5V +# 3 : Contrast (0-5V)* +# 4 : RS (Register Select) +# 5 : R/W (Read Write) - GROUND THIS PIN +# 6 : Enable or Strobe +# 7 : Data Bit 0 - NOT USED +# 8 : Data Bit 1 - NOT USED +# 9 : Data Bit 2 - NOT USED +# 10: Data Bit 3 - NOT USED +# 11: Data Bit 4 +# 12: Data Bit 5 +# 13: Data Bit 6 +# 14: Data Bit 7 +# 15: LCD Backlight +5V** +# 16: LCD Backlight GND + +#import +import RPi.GPIO as GPIO +import time + +# Define GPIO to LCD mapping +LCD_RS = 25 +LCD_E = 24 +LCD_D4 = 23 +LCD_D5 = 22 +LCD_D6 = 27 +LCD_D7 = 17 + + +# Define some device constants +LCD_WIDTH = 16 # Maximum characters per line +LCD_CHR = True +LCD_CMD = False + +LCD_LINE_1 = 0x80 # LCD RAM address for the 1st line +LCD_LINE_2 = 0xC0 # LCD RAM address for the 2nd line + +# Timing constants +E_PULSE = 0.0005 +E_DELAY = 0.0005 + +GPIO.setwarnings(True) +GPIO.setmode(GPIO.BCM) # Use BCM GPIO numbers +GPIO.setup(LCD_E, GPIO.OUT) # E +GPIO.setup(LCD_RS, GPIO.OUT) # RS +GPIO.setup(LCD_D4, GPIO.OUT) # DB4 +GPIO.setup(LCD_D5, GPIO.OUT) # DB5 +GPIO.setup(LCD_D6, GPIO.OUT) # DB6 +GPIO.setup(LCD_D7, GPIO.OUT) # DB7 + + + +def lcd_init(): + # Initialise display + lcd_byte(0x33,LCD_CMD) # 110011 Initialise + lcd_byte(0x32,LCD_CMD) # 110010 Initialise + lcd_byte(0x06,LCD_CMD) # 000110 Cursor move direction + lcd_byte(0x0C,LCD_CMD) # 001100 Display On,Cursor Off, Blink Off + lcd_byte(0x28,LCD_CMD) # 101000 Data length, number of lines, font size + lcd_byte(0x01,LCD_CMD) # 000001 Clear display + + time.sleep(E_DELAY) + +def lcd_byte(bits, mode): + # Send byte to data pins + # bits = data + # mode = True for character + # False for command + + GPIO.output(LCD_RS, mode) # RS + + # High bits + GPIO.output(LCD_D4, False) + GPIO.output(LCD_D5, False) + GPIO.output(LCD_D6, False) + GPIO.output(LCD_D7, False) + if bits&0x10==0x10: + GPIO.output(LCD_D4, True) + if bits&0x20==0x20: + GPIO.output(LCD_D5, True) + if bits&0x40==0x40: + GPIO.output(LCD_D6, True) + if bits&0x80==0x80: + GPIO.output(LCD_D7, True) + + # Toggle 'Enable' pin + lcd_toggle_enable() + + # Low bits + GPIO.output(LCD_D4, False) + GPIO.output(LCD_D5, False) + GPIO.output(LCD_D6, False) + GPIO.output(LCD_D7, False) + if bits&0x01==0x01: + GPIO.output(LCD_D4, True) + if bits&0x02==0x02: + GPIO.output(LCD_D5, True) + if bits&0x04==0x04: + GPIO.output(LCD_D6, True) + if bits&0x08==0x08: + GPIO.output(LCD_D7, True) + + # Toggle 'Enable' pin + lcd_toggle_enable() + +def lcd_toggle_enable(): + # Toggle enable + time.sleep(E_DELAY) + GPIO.output(LCD_E, True) + time.sleep(E_PULSE) + GPIO.output(LCD_E, False) + time.sleep(E_DELAY) + +def lcd_string(message,line): + # Send string to display + + + + + message = message.ljust(LCD_WIDTH," ") + + lcd_byte(line, LCD_CMD) + + for i in range(LCD_WIDTH): + lcd_byte(ord(message[i]),LCD_CHR) diff --git a/samplerbox.py b/samplerbox.py index 8275f87..82faf76 100644 --- a/samplerbox.py +++ b/samplerbox.py @@ -10,7 +10,6 @@ ######################################### -# LOCAL # CONFIG ######################################### @@ -18,9 +17,16 @@ SAMPLES_DIR = "." # The root directory containing the sample-sets. Example: "/media/" to look for samples on a USB stick / SD card USE_SERIALPORT_MIDI = False # Set to True to enable MIDI IN via SerialPort (e.g. RaspberryPi's GPIO UART pins) USE_I2C_7SEGMENTDISPLAY = False # Set to True to use a 7-segment display via I2C +USE_HD44780DISPLAY = False # Set to True to use HD44780 display USE_BUTTONS = False # Set to True to use momentary buttons (connected to RaspberryPi's GPIO pins) to change preset MAX_POLYPHONY = 80 # This can be set higher, but 80 is a safe value +LOCAL_CONFIG = 'local_config.py' # Local config filename +DEBUG = False # Enable to switch verbose logging on +# Load local config if available +import os.path +if os.path.isfile(LOCAL_CONFIG): + execfile(LOCAL_CONFIG) ######################################### # IMPORT @@ -37,8 +43,8 @@ from chunk import Chunk import struct import rtmidi_python as rtmidi -import samplerbox_audio - +import samplerbox_audio # legacy audio (pre RPi-2 models) +#import samplerbox_audio_neon as samplerbox_audio # ARM NEON instruction set ######################################### # SLIGHT MODIFICATION OF PYTHON'S WAVE MODULE @@ -105,12 +111,13 @@ def getloops(self): class PlayingSound: - def __init__(self, sound, note): + def __init__(self, sound, note, velocity): self.sound = sound self.pos = 0 self.fadeoutpos = 0 self.isfadeout = False self.note = note + self.velocity = velocity def fadeout(self, i): self.isfadeout = True @@ -140,8 +147,9 @@ def __init__(self, filename, midinote, velocity): wf.close() - def play(self, note): - snd = PlayingSound(self, note) + def play(self, note, velocity): + actual_velocity = 1-globalvelocitysensitivity + (globalvelocitysensitivity * (velocity/127.0)) + snd = PlayingSound(self, note, actual_velocity) playingsounds.append(snd) return snd @@ -178,7 +186,7 @@ def AudioCallback(outdata, frame_count, time_info, status): global playingsounds rmlist = [] playingsounds = playingsounds[-MAX_POLYPHONY:] - b = samplerbox_audio.mixaudiobuffers(playingsounds, rmlist, frame_count, FADEOUT, FADEOUTLENGTH, SPEED) + b = samplerbox_audio.mixaudiobuffers(playingsounds, rmlist, frame_count, FADEOUT, FADEOUTLENGTH, SPEED, globalvolume) for e in rmlist: try: playingsounds.remove(e) @@ -202,7 +210,7 @@ def MidiCallback(message, time_stamp): if messagetype == 9: # Note on midinote += globaltranspose try: - playingnotes.setdefault(midinote, []).append(samples[midinote, velocity].play(midinote)) + playingnotes.setdefault(midinote, []).append(samples[midinote, velocity].play(midinote, velocity)) except: pass @@ -262,10 +270,12 @@ def ActuallyLoad(): global samples global playingsounds global globalvolume, globaltranspose + global globalvelocitysensitivity playingsounds = [] samples = {} globalvolume = 10 ** (-12.0/20) # -12dB default global volume globaltranspose = 0 + globalvelocitysensitivity = 0 # default midi velocity sensitivity samplesdir = SAMPLES_DIR if os.listdir(SAMPLES_DIR) else '.' # use current folder (containing 0 Saw) if no user media containing samples has been found @@ -274,10 +284,10 @@ def ActuallyLoad(): dirname = os.path.join(samplesdir, basename) if not basename: print 'Preset empty: %s' % preset - display("E%03d" % preset) + display("E%03d" % preset, [str(preset), 'EMPTY']) return print 'Preset loading: %s (%s)' % (preset, basename) - display("L%03d" % preset) + display("L%03d" % preset, [basename, 'Loading']) definitionfname = os.path.join(dirname, "definition.txt") if os.path.isfile(definitionfname): @@ -290,6 +300,9 @@ def ActuallyLoad(): if r'%%transpose' in pattern: globaltranspose = int(pattern.split('=')[1].strip()) continue + if r'%%velocitysensitivity' in pattern: + globalvelocitysensitivity = float(pattern.split('=')[1].strip()) + continue defaultparams = {'midinote': '0', 'velocity': '127', 'notename': ''} if len(pattern.split(',')) > 1: defaultparams.update(dict([item.split('=') for item in pattern.split(',', 1)[1].replace(' ', '').replace('%', '').split(',')])) @@ -339,10 +352,10 @@ def ActuallyLoad(): pass if len(initial_keys) > 0: print 'Preset loaded: ' + str(preset) - display("%04d" % preset) + display("%04d" % preset, [basename, 'Ready']) else: print 'Preset empty: ' + str(preset) - display("E%03d" % preset) + display("E%03d" % preset, [str(preset), 'EMPTY']) ######################################### @@ -371,19 +384,19 @@ def ActuallyLoad(): def Buttons(): GPIO.setmode(GPIO.BCM) - GPIO.setup(18, GPIO.IN, pull_up_down=GPIO.PUD_UP) - GPIO.setup(17, GPIO.IN, pull_up_down=GPIO.PUD_UP) + GPIO.setup(26, GPIO.IN, pull_up_down=GPIO.PUD_UP) + GPIO.setup(16, GPIO.IN, pull_up_down=GPIO.PUD_UP) global preset, lastbuttontime while True: now = time.time() - if not GPIO.input(18) and (now - lastbuttontime) > 0.2: + if not GPIO.input(26) and (now - lastbuttontime) > 0.2: lastbuttontime = now preset -= 1 if preset < 0: preset = 127 LoadSamples() - elif not GPIO.input(17) and (now - lastbuttontime) > 0.2: + elif not GPIO.input(16) and (now - lastbuttontime) > 0.2: lastbuttontime = now preset += 1 if preset > 127: @@ -407,7 +420,7 @@ def Buttons(): bus = smbus.SMBus(1) # using I2C - def display(s): + def display(s, lines): for k in '\x76\x79\x00' + s: # position cursor at 0 try: bus.write_byte(0x71, ord(k)) @@ -421,9 +434,17 @@ def display(s): display('----') time.sleep(0.5) +if USE_HD44780DISPLAY: + execfile('peripherals/hd44780.py') + lcd_init() + + def display(s, lines): + lcd_string(lines[0], LCD_LINE_1) + lcd_string(lines[1], LCD_LINE_2) + else: - def display(s): + def display(s, l): pass @@ -443,6 +464,7 @@ def MidiSerialCallback(): i = 0 while i < 3: data = ord(ser.read(1)) # read a byte + print data if data >> 7 != 0: i = 0 # status byte! this is the beginning of a midi message: http://www.midi.org/techspecs/midimessages.php message[i] = data diff --git a/samplerbox_audio.pyx b/samplerbox_audio.pyx index d6725a9..7c826b4 100644 --- a/samplerbox_audio.pyx +++ b/samplerbox_audio.pyx @@ -14,18 +14,20 @@ import cython import numpy cimport numpy -def mixaudiobuffers(list playingsounds, list rmlist, int frame_count, numpy.ndarray FADEOUT, int FADEOUTLENGTH, numpy.ndarray SPEED): +def mixaudiobuffers(list playingsounds, list rmlist, int frame_count, numpy.ndarray FADEOUT, int FADEOUTLENGTH, numpy.ndarray SPEED, double GLOBALVOLUME): cdef int i, ii, k, l, N, length, looppos, fadeoutpos - cdef float speed, newsz, pos, j + cdef float speed, newsz, pos, j, velocity cdef numpy.ndarray b = numpy.zeros(2 * frame_count, numpy.float32) # output buffer cdef float* bb = (b.data) # and its pointer cdef numpy.ndarray z cdef short* zz cdef float* fadeout = (FADEOUT.data) + cdef double multiplier for snd in playingsounds: pos = snd.pos fadeoutpos = snd.fadeoutpos + velocity = snd.velocity * GLOBALVOLUME looppos = snd.sound.loop length = snd.sound.nframes speed = SPEED[snd.note - snd.sound.midinote] @@ -39,42 +41,32 @@ def mixaudiobuffers(list playingsounds, list rmlist, int frame_count, numpy.ndar rmlist.append(snd) N = ((length - 4 - pos) / speed) + multiplier = velocity + + ii = 0 + for i in range(N): + j = pos + ii * speed + ii += 1 + k = j + if k > length - 2: + pos = looppos + 1 + snd.pos = pos + ii = 0 + j = pos + ii * speed + k = j + if snd.isfadeout: + multiplier = velocity * fadeout[fadeoutpos + i] + bb[2 * i] += (zz[2 * k] + (j - k) * (zz[2 * k + 2] - zz[2 * k])) * multiplier # linear interpolation + bb[2 * i + 1] += (zz[2 * k + 1] + (j - k) * (zz[2 * k + 3] - zz[2 * k + 1])) * multiplier + if snd.isfadeout: if fadeoutpos > FADEOUTLENGTH: rmlist.append(snd) - ii = 0 - for i in range(N): - j = pos + ii * speed - ii += 1 - k = j - if k > length - 2: - pos = looppos + 1 - snd.pos = pos - ii = 0 - j = pos + ii * speed - k = j - bb[2 * i] += (zz[2 * k] + (j - k) * (zz[2 * k + 2] - zz[2 * k])) * fadeout[fadeoutpos + i] # linear interpolation - bb[2 * i + 1] += (zz[2 * k + 1] + (j - k) * (zz[2 * k + 3] - zz[2 * k + 1])) * fadeout[fadeoutpos + i] - snd.fadeoutpos += i - - else: - ii = 0 - for i in range(N): - j = pos + ii * speed - ii += 1 - k = j - if k > length - 2: - pos = looppos + 1 - snd.pos = pos - ii = 0 - j = pos + ii * speed - k = j - bb[2 * i] += zz[2 * k] + (j - k) * (zz[2 * k + 2] - zz[2 * k]) # linear interpolation - bb[2 * i + 1] += zz[2 * k + 1] + (j - k) * (zz[2 * k + 3] - zz[2 * k + 1]) + snd.fadeoutpos += N snd.pos += ii * speed - return b + return b.astype(numpy.int16) def binary24_to_int16(char *data, int length): cdef int i @@ -83,4 +75,4 @@ def binary24_to_int16(char *data, int length): for i in range(length): b[2*i] = data[3*i+1] b[2*i+1] = data[3*i+2] - return res \ No newline at end of file + return res diff --git a/samplerbox_audio_neon.pyx b/samplerbox_audio_neon.pyx new file mode 100644 index 0000000..d3a30f4 --- /dev/null +++ b/samplerbox_audio_neon.pyx @@ -0,0 +1,178 @@ +# +# SamplerBox +# +# author: Joseph Ernest (twitter: @JosephErnest, mail: contact@samplerbox.org) +# P.T. (NEON instruction set) +# url: http://www.samplerbox.org/ +# license: Creative Commons ShareAlike 3.0 (http://creativecommons.org/licenses/by-sa/3.0/) +# +# samplerbox_audio.pyx: Audio engine (Cython) +# + + + +import cython +import numpy +cimport numpy + +cdef extern from "arm_neon.h": + # "c type" here is effectively ignored by Cython + ctypedef short int16_t + ctypedef short int16x4_t + ctypedef short int16x8_t + ctypedef long int32x2_t + ctypedef long int32x4_t + ctypedef float float32_t + ctypedef float float32x2_t + ctypedef float float32x4_t + + float32x4_t vld1q_f32(float32_t* ptr) + void vst1_f32(float32_t *ptr, float32x2_t val) + void vst1q_f32(float32_t *ptr, float32x4_t val) + int16x4_t vld1_s16(int16_t* ptr) + int16x8_t vld1q_s16(int16_t* ptr) + void vst1q_s16(int16_t *ptr, int16x8_t val) + + float32x4_t vdupq_n_f32(float32_t value) + + int16x4_t vaddq_s16(int16x8_t a, int16x8_t b) + float32x2_t vadd_f32(float32x2_t a, float32x2_t b) + float32x4_t vaddq_f32(float32x4_t a, float32x4_t b) + float32x2_t vsub_f32(float32x2_t a, float32x2_t b) + float32x2_t vmul_n_f32(float32x2_t a, float32_t b) + float32x4_t vmulq_n_f32(float32x4_t a, float32_t b) + float32x2_t vget_high_f32(float32x4_t a) + float32x2_t vget_low_f32(float32x4_t a) + int16x8_t vcombine_s16(int16x4_t a, int16x4_t b) + int32x4_t vcombine_s32(int32x2_t a, int32x2_t b) + + int16x4_t vmovn_s32(int32x4_t a) + int32x4_t vmovl_s16(int16x4_t a) + int32x2_t vcvt_s32_f32(float32x2_t a) + float32x4_t vcvtq_f32_s32(int32x4_t a) + +def mixaudiobuffers(list playingsounds, list rmlist, int frame_count, numpy.ndarray FADEOUT, int FADEOUTLENGTH, numpy.ndarray SPEED, double GLOBALVOLUME): + cdef int i, ii, k, l, N, length, looppos, fadeoutpos + cdef float speed, newsz, pos, j, velocity + + cdef int channel_size = 2 * frame_count + cdef numpy.ndarray b = numpy.require(numpy.zeros(channel_size, numpy.int16), requirements=['A', 'C']) # output buffer + cdef short* bb = (b.data) # and its pointer + + # input sample buffers and calculation register references + cdef numpy.ndarray z + cdef short* zz + cdef int16x4_t zz_sample + cdef int32x4_t zz_sample32 + cdef float32_t sample[2] + cdef float32x4_t sample_vector + cdef float32x2_t sample_pair_vector[2] + cdef int32x2_t master + cdef int16x4_t low_combined, high_combined + cdef int16x8_t buffer_vector, full_combined + + # velocity / ADSR / fadeout variables + cdef float* fadeout = (FADEOUT.data) + cdef double multiplier + cdef char is_fadeout + + sound_no = 0 + for snd in playingsounds: + pos = snd.pos + fadeoutpos = snd.fadeoutpos + velocity = snd.velocity * GLOBALVOLUME + looppos = snd.sound.loop + length = snd.sound.nframes + if snd.isfadeout: + is_fadeout = 1 + else: + is_fadeout = 0 + speed = SPEED[snd.note - snd.sound.midinote] + newsz = frame_count * speed + z = snd.sound.data + zz = (z.data) + + N = frame_count + + if (pos + frame_count * speed > length - 4) and (looppos == -1): + rmlist.append(snd) + N = ((length - 4 - pos) / speed) + + multiplier = velocity + + ii = 0 + for i in range(N): + j = pos + ii * speed + ii += 1 + k = j + if k > length - 2: + pos = looppos + 1 + snd.pos = pos + ii = 0 + j = pos + ii * speed + k = j + if is_fadeout == 1: + multiplier = velocity * fadeout[fadeoutpos + i] + + j -= k + + # vectorise samples at the pointer location + zz_sample = vld1_s16(&zz[2 * k]) + # convert to floating-point format + zz_sample32 = vmovl_s16(zz_sample) + sample_vector = vcvtq_f32_s32(zz_sample32) + + # pre-mix multiplier + sample_vector = vmulq_n_f32(sample_vector, multiplier) + + # linear interpolation + sample_pair_vector[0] = vget_low_f32(sample_vector) # sample0, sample1 + sample_pair_vector[1] = vget_high_f32(sample_vector) # sample2, sample3 + sample_pair_vector[1] = vsub_f32(sample_pair_vector[1], sample_pair_vector[0]) # s2-s0, s3-s1 + sample_pair_vector[1] = vmul_n_f32(sample_pair_vector[1], j) # j*(s2-s0), ... + sample_pair_vector[1] = vadd_f32(sample_pair_vector[1], sample_pair_vector[0]) # s0 + j*(s2-s0), ... + + master = vcvt_s32_f32(sample_pair_vector[1]) + if i % 2 == 0: + previous_master = master + else: + if i % 4 == 1: + low_combined = vmovn_s32(vcombine_s32(previous_master, master)) + else: # i % 3 == 3 + high_combined = vmovn_s32(vcombine_s32(previous_master, master)) + full_combined = vcombine_s16(low_combined, high_combined) + buffer_vector = vld1q_s16(&bb[i * 2 - 6]) # load buffer at the position + buffer_vector = vaddq_s16(buffer_vector, full_combined) # add calculated samples to the buffer + vst1q_s16(&bb[i * 2 - 6], buffer_vector) + + if is_fadeout == 1: + if fadeoutpos > FADEOUTLENGTH: + rmlist.append(snd) + snd.fadeoutpos += N + + snd.pos += ii * speed + sound_no += 1 + + return b + +# Calculate how many mixing channels are needed +# +# Mixer mixes two channels at a time (2 x 32bit float frame x 2 channels = 128bit) +# so the result always have to be a multiply of two. +# +# Extra 8 channels are added to prevent race condition if more sounds are triggered since +# mixing buffer has been allocated. +# +def mixing_channels(int sounds): + if sounds % 2 > 0: + sounds += 1 + return sounds + 8 + +def binary24_to_int16(char *data, int length): + cdef int i + res = numpy.zeros(length, numpy.int16) + b = ((res).data) + for i in range(length): + b[2*i] = data[3*i+1] + b[2*i+1] = data[3*i+2] + return res diff --git a/setup.py b/setup.py index c329224..396f191 100644 --- a/setup.py +++ b/setup.py @@ -1,5 +1,12 @@ from distutils.core import setup from Cython.Build import cythonize +from distutils.extension import Extension import numpy -setup(ext_modules = cythonize("samplerbox_audio.pyx"), include_dirs=[numpy.get_include()]) \ No newline at end of file +extensions = [ + Extension("samplerbox_audio", ["samplerbox_audio.pyx"]), + Extension("samplerbox_audio_neon", ["samplerbox_audio_neon.pyx"], + extra_compile_args=["-mcpu=cortex-a7", "-mtune=arm1176jzf-s", "-mfloat-abi=hard", "-mfpu=neon-vfpv4", "-ftree-vectorize", "-ffast-math", "-O3"]), +] + +setup(ext_modules = cythonize(extensions), include_dirs=[numpy.get_include()])