diff --git a/README.md b/README.md index 411afc0..2e464f6 100644 --- a/README.md +++ b/README.md @@ -16,25 +16,27 @@ Website: [www.samplerbox.org](https://www.samplerbox.org) SamplerBox works with the RaspberryPi's built-in soundcard, but it is recommended to use a USB DAC (PCM2704 USB DAC for less than 10€ on eBay is fine) for better sound quality. -1. Install the required dependencies (Python-related packages and audio libraries): +1. Install the required dependencies (Python-related packages and audio libraries - the current version requires at least Python 3.7): ~~~ - sudo apt-get update ; sudo apt-get -y install git python-dev python-pip python-numpy cython python-smbus libportaudio2 libffi-dev - sudo pip install rtmidi-python cffi sounddevice + sudo apt-get update + sudo apt-get -y install git python3-pip python3-smbus python3-numpy libportaudio2 raspberrypi-kernel + sudo pip3 install cython rtmidi-python cffi sounddevice pyserial ~~~ 2. Download SamplerBox and build it with: ~~~ git clone https://github.com/josephernest/SamplerBox.git - cd SamplerBox ; sudo python setup.py build_ext --inplace + cd SamplerBox + sudo python3 setup.py build_ext --inplace ~~~ -3. Run the soft with `python samplerbox.py`. +3. Run the soft with `sudo python3 samplerbox.py`. 4. Play some notes on the connected MIDI keyboard, you'll hear some sound! -*(Optional)* Modify `samplerbox.py`'s first lines if you want to change root directory for sample-sets, default soundcard, etc. +*(Optional)* Modify `config.py` if you want to change root directory for sample-sets, default soundcard, etc. [How to use it](#howto) @@ -42,6 +44,8 @@ SamplerBox works with the RaspberryPi's built-in soundcard, but it is recommende See the [FAQ](https://www.samplerbox.org/faq) on https://www.samplerbox.org. +Note: the current version also works on Windows if all the required modules are installed. + [ISO image](#isoimage) ---- diff --git a/config.py b/config.py index 5b5c06b..b23bbc4 100644 --- a/config.py +++ b/config.py @@ -5,8 +5,8 @@ AUDIO_DEVICE_ID = 2 # change this number to use another soundcard 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_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 +USE_BUTTONS = False # Set to True to use momentary buttons (connected to RaspberryPi's GPIO pins) to change preset +USE_I2C_7SEGMENTDISPLAY = False # Set to True to use a 7-segment display via I2C +USE_SERIALPORT_MIDI = False # Set to True to enable MIDI IN via SerialPort (e.g. RaspberryPi's GPIO UART pins) USE_SYSTEMLED = False # Flashing LED after successful boot, only works on RPi/Linux \ No newline at end of file diff --git a/isoimage/maker.sh b/isoimage/maker.sh index b4b61e5..d1f4fb2 100644 --- a/isoimage/maker.sh +++ b/isoimage/maker.sh @@ -19,7 +19,7 @@ mount -v -t ext4 -o sync /dev/mapper/loop0p2 sdcard mount -v -t vfat -o sync /dev/mapper/loop0p1 sdcard/boot echo root:root | chroot sdcard chpasswd chroot sdcard apt update -chroot sdcard apt install -y build-essential python-dev python-pip cython python-smbus python-numpy python-rpi.gpio python-serial portaudio19-dev alsa-utils git libportaudio2 libffi-dev raspberrypi-kernel ntpdate +chroot sdcard apt install -y build-essential python-dev python-pip cython python-smbus python-numpy python-rpi.gpio python-serial alsa-utils git libportaudio2 libffi-dev raspberrypi-kernel ntpdate chroot sdcard pip install rtmidi-python pyaudio cffi sounddevice chroot sdcard sh -c "cd /root ; git clone https://github.com/josephernest/SamplerBox.git ; cd SamplerBox ; python setup.py build_ext --inplace" cp -R root/* sdcard @@ -29,10 +29,6 @@ chroot sdcard systemctl disable regenerate_ssh_host_keys sshswitch chroot sdcard ssh-keygen -A -v chroot sdcard systemctl enable ssh sed -i 's/ENV{pvolume}:="-20dB"/ENV{pvolume}:="-10dB"/' sdcard/usr/share/alsa/init/default -sed -i 's/USE_SERIALPORT_MIDI = False/USE_SERIALPORT_MIDI = True/' sdcard/root/SamplerBox/samplerbox.py -sed -i 's/USE_I2C_7SEGMENTDISPLAY = False/USE_I2C_7SEGMENTDISPLAY = True/' sdcard/root/SamplerBox/samplerbox.py -sed -i 's/USE_BUTTONS = False/USE_BUTTONS = True/' sdcard/root/SamplerBox/samplerbox.py -sed -i 's,SAMPLES_DIR = ".",SAMPLES_DIR = "/media/",' sdcard/root/SamplerBox/samplerbox.py echo "PermitRootLogin yes" >> sdcard/etc/ssh/sshd_config echo "alias rw=\"mount -o remount,rw /\"" >> sdcard/root/.bashrc sync diff --git a/isoimage/root/boot/cmdline.txt b/isoimage/root/boot/cmdline.txt index 4b2a81d..51777ba 100644 --- a/isoimage/root/boot/cmdline.txt +++ b/isoimage/root/boot/cmdline.txt @@ -1 +1 @@ -root=/dev/mmcblk0p2 ro rootwait console=tty1 selinux=0 plymouth.enable=0 elevator=deadline bcm2708.uart_clock=3000000 +root=/dev/mmcblk0p2 ro rootwait console=tty1 selinux=0 plymouth.enable=0 elevator=deadline diff --git a/isoimage/root/boot/config.txt b/isoimage/root/boot/config.txt index 783b9ec..287f04c 100644 --- a/isoimage/root/boot/config.txt +++ b/isoimage/root/boot/config.txt @@ -1,7 +1,5 @@ dtoverlay=disable-bt device_tree_param=i2c_arm=on -init_uart_clock=2441406 -init_uart_baud=38400 boot_delay=0 disable_splash=1 disable_audio_dither=1 diff --git a/isoimage/root/etc/systemd/system/samplerbox.service b/isoimage/root/etc/systemd/system/samplerbox.service index 474ef35..0ce8edd 100644 --- a/isoimage/root/etc/systemd/system/samplerbox.service +++ b/isoimage/root/etc/systemd/system/samplerbox.service @@ -1,11 +1,8 @@ [Unit] Description=Starts SamplerBox -DefaultDependencies=false - [Service] Type=simple -ExecStart=/root/SamplerBox/samplerbox.sh +ExecStart=/usr/bin/python /root/SamplerBox/samplerbox.py WorkingDirectory=/root/SamplerBox/ - [Install] -WantedBy=local-fs.target +RequiredBy=basic.target \ No newline at end of file diff --git a/samplerbox.py b/samplerbox.py index a26e6fb..e36189e 100644 --- a/samplerbox.py +++ b/samplerbox.py @@ -5,28 +5,15 @@ # url: http://www.samplerbox.org/ # license: Creative Commons ShareAlike 3.0 (http://creativecommons.org/licenses/by-sa/3.0/) # -# samplerbox.py: Main file +# samplerbox.py: Main file (now requiring at least Python 3.7) # - -######################################### -# LOCAL -# CONFIG -######################################### - -AUDIO_DEVICE_ID = 2 # change this number to use another soundcard -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_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 -USE_SYSTEMLED = True - ######################################### # IMPORT # MODULES ######################################### +from config import * import wave import time import numpy @@ -39,14 +26,12 @@ import rtmidi_python as rtmidi import samplerbox_audio - ######################################### # SLIGHT MODIFICATION OF PYTHON'S WAVE MODULE # TO READ CUE MARKERS & LOOP MARKERS ######################################### class waveread(wave.Wave_read): - def initfp(self, file): self._convert = None self._soundpos = 0 @@ -54,10 +39,10 @@ def initfp(self, file): self._loops = [] self._ieee = False self._file = Chunk(file, bigendian=0) - if self._file.getname() != 'RIFF': - raise Error, 'file does not start with RIFF id' - if self._file.read(4) != 'WAVE': - raise Error, 'not a WAVE file' + if self._file.getname() != b'RIFF': + raise IOError('file does not start with RIFF id') + if self._file.read(4) != b'WAVE': + raise IOError('not a WAVE file') self._fmt_chunk_read = 0 self._data_chunk = None while 1: @@ -67,21 +52,21 @@ def initfp(self, file): except EOFError: break chunkname = chunk.getname() - if chunkname == 'fmt ': + if chunkname == b'fmt ': self._read_fmt_chunk(chunk) self._fmt_chunk_read = 1 - elif chunkname == 'data': + elif chunkname == b'data': if not self._fmt_chunk_read: - raise Error, 'data chunk before fmt chunk' + raise IOError('data chunk before fmt chunk') self._data_chunk = chunk self._nframes = chunk.chunksize // self._framesize self._data_seek_needed = 0 - elif chunkname == 'cue ': + elif chunkname == b'cue ': numcue = struct.unpack(' 1 else None midinote = note velocity = message[2] if len(message) > 2 else None - if messagetype == 9 and velocity == 0: messagetype = 8 - if messagetype == 9: # Note on midinote += globaltranspose try: playingnotes.setdefault(midinote, []).append(samples[midinote, velocity].play(midinote)) except: pass - elif messagetype == 8: # Note off midinote += globaltranspose if midinote in playingnotes: @@ -215,22 +190,18 @@ def MidiCallback(message, time_stamp): else: n.fadeout(50) playingnotes[midinote] = [] - elif messagetype == 12: # Program change - print 'Program change ' + str(note) + print('Program change ' + str(note)) preset = note LoadSamples() - elif (messagetype == 11) and (note == 64) and (velocity < 64): # sustain pedal off for n in sustainplayingnotes: n.fadeout(50) sustainplayingnotes = [] sustain = False - elif (messagetype == 11) and (note == 64) and (velocity >= 64): # sustain pedal on sustain = True - ######################################### # LOAD SAMPLES # @@ -239,7 +210,6 @@ def MidiCallback(message, time_stamp): LoadingThread = None LoadingInterrupt = False - def LoadSamples(): global LoadingThread global LoadingInterrupt @@ -256,7 +226,6 @@ def LoadSamples(): NOTES = ["c", "c#", "d", "d#", "e", "f", "f#", "g", "g#", "a", "a#", "b"] - def ActuallyLoad(): global preset global samples @@ -266,19 +235,16 @@ def ActuallyLoad(): samples = {} globalvolume = 10 ** (-12.0/20) # -12dB default global volume globaltranspose = 0 - samplesdir = SAMPLES_DIR if os.listdir(SAMPLES_DIR) else '.' # use current folder (containing 0 Saw) if no user media containing samples has been found - basename = next((f for f in os.listdir(samplesdir) if f.startswith("%d " % preset)), None) # or next(glob.iglob("blah*"), None) if basename: dirname = os.path.join(samplesdir, basename) if not basename: - print 'Preset empty: %s' % preset + print('Preset empty: %s' % preset) display("E%03d" % preset) return - print 'Preset loading: %s (%s)' % (preset, basename) + print('Preset loading: %s (%s)' % (preset, basename)) display("L%03d" % preset) - definitionfname = os.path.join(dirname, "definition.txt") if os.path.isfile(definitionfname): with open(definitionfname, 'r') as definitionfile: @@ -294,9 +260,9 @@ def ActuallyLoad(): if len(pattern.split(',')) > 1: defaultparams.update(dict([item.split('=') for item in pattern.split(',', 1)[1].replace(' ', '').replace('%', '').split(',')])) pattern = pattern.split(',')[0] - pattern = re.escape(pattern.strip()) - pattern = pattern.replace(r"\%midinote", r"(?P\d+)").replace(r"\%velocity", r"(?P\d+)")\ - .replace(r"\%notename", r"(?P[A-Ga-g]#?[0-9])").replace(r"\*", r".*?").strip() # .*? => non greedy + pattern = re.escape(pattern.strip()) # note for Python 3.7+: "%" is no longer escaped with "\" + pattern = pattern.replace(r"%midinote", r"(?P\d+)").replace(r"%velocity", r"(?P\d+)")\ + .replace(r"%notename", r"(?P[A-Ga-g]#?[0-9])").replace(r"\*", r".*?").strip() # .*? => non greedy for fname in os.listdir(dirname): if LoadingInterrupt: return @@ -310,8 +276,7 @@ def ActuallyLoad(): midinote = NOTES.index(notename[:-1].lower()) + (int(notename[-1])+2) * 12 samples[midinote, velocity] = Sound(os.path.join(dirname, fname), midinote, velocity) except: - print "Error in definition file, skipping line %s." % (i+1) - + print("Error in definition file, skipping line %s." % (i+1)) else: for midinote in range(0, 127): if LoadingInterrupt: @@ -319,32 +284,30 @@ def ActuallyLoad(): file = os.path.join(dirname, "%d.wav" % midinote) if os.path.isfile(file): samples[midinote, 127] = Sound(file, midinote, 127) - initial_keys = set(samples.keys()) - for midinote in xrange(128): + for midinote in range(128): lastvelocity = None - for velocity in xrange(128): + for velocity in range(128): if (midinote, velocity) not in initial_keys: samples[midinote, velocity] = lastvelocity else: if not lastvelocity: - for v in xrange(velocity): + for v in range(velocity): samples[midinote, v] = samples[midinote, velocity] lastvelocity = samples[midinote, velocity] if not lastvelocity: - for velocity in xrange(128): + for velocity in range(128): try: samples[midinote, velocity] = samples[midinote-1, velocity] except: pass if len(initial_keys) > 0: - print 'Preset loaded: ' + str(preset) + print('Preset loaded: ' + str(preset)) display("%04d" % preset) else: - print 'Preset empty: ' + str(preset) + print('Preset empty: ' + str(preset)) display("E%03d" % preset) - ######################################### # OPEN AUDIO DEVICE # @@ -353,12 +316,11 @@ def ActuallyLoad(): try: sd = sounddevice.OutputStream(device=AUDIO_DEVICE_ID, blocksize=512, samplerate=44100, channels=2, dtype='int16', callback=AudioCallback) sd.start() - print 'Opened audio device #%i' % AUDIO_DEVICE_ID + print('Opened audio device #%i' % AUDIO_DEVICE_ID) except: - print 'Invalid audio device #%i' % AUDIO_DEVICE_ID + print('Invalid audio device #%i' % AUDIO_DEVICE_ID) exit(1) - ######################################### # BUTTONS THREAD (RASPBERRY PI GPIO) # @@ -366,9 +328,7 @@ def ActuallyLoad(): if USE_BUTTONS: import RPi.GPIO as GPIO - lastbuttontime = 0 - def Buttons(): GPIO.setmode(GPIO.BCM) GPIO.setup(18, GPIO.IN, pull_up_down=GPIO.PUD_UP) @@ -382,21 +342,17 @@ def Buttons(): if preset < 0: preset = 127 LoadSamples() - elif not GPIO.input(17) and (now - lastbuttontime) > 0.2: lastbuttontime = now preset += 1 if preset > 127: preset = 0 LoadSamples() - time.sleep(0.020) - ButtonsThread = threading.Thread(target=Buttons) ButtonsThread.daemon = True ButtonsThread.start() - ######################################### # 7-SEGMENT DISPLAY # @@ -404,9 +360,7 @@ def Buttons(): if USE_I2C_7SEGMENTDISPLAY: import smbus - bus = smbus.SMBus(1) # using I2C - def display(s): for k in '\x76\x79\x00' + s: # position cursor at 0 try: @@ -417,16 +371,12 @@ def display(s): except: pass time.sleep(0.002) - display('----') time.sleep(0.5) - else: - def display(s): pass - ######################################### # MIDI IN via SERIAL PORT # @@ -434,9 +384,7 @@ def display(s): if USE_SERIALPORT_MIDI: import serial - - ser = serial.Serial('/dev/ttyAMA0', baudrate=38400) # see hack in /boot/cmline.txt : 38400 is 31250 baud for MIDI! - + ser = serial.Serial('/dev/ttyAMA0', baudrate=31250) def MidiSerialCallback(): message = [0, 0, 0] while True: @@ -451,12 +399,10 @@ def MidiSerialCallback(): message[2] = 0 i = 3 MidiCallback(message, None) - MidiThread = threading.Thread(target=MidiSerialCallback) MidiThread.daemon = True MidiThread.start() - ######################################### # LOAD FIRST SOUNDBANK # @@ -466,7 +412,7 @@ def MidiSerialCallback(): LoadSamples() ######################################### -# System Led +# SYSTEM LED # ######################################### if USE_SYSTEMLED: @@ -478,14 +424,14 @@ def MidiSerialCallback(): # MAIN LOOP ######################################### -midi_in = [rtmidi.MidiIn()] +midi_in = [rtmidi.MidiIn(b'in')] previous = [] while True: for port in midi_in[0].ports: - if port not in previous and 'Midi Through' not in port: - midi_in.append(rtmidi.MidiIn()) + if port not in previous and b'Midi Through' not in port: + midi_in.append(rtmidi.MidiIn(b'in')) midi_in[-1].callback = MidiCallback midi_in[-1].open_port(port) - print 'Opened MIDI: ' + port + print('Opened MIDI: ' + str(port)) previous = midi_in[0].ports time.sleep(2)