├── .flake8 ├── .gitignore ├── DESIGN.md ├── MANUAL.md ├── README.md ├── arpeggiator.py ├── chords.py ├── clock.py ├── colors.py ├── fluidsynth.py ├── gridgets.py ├── griode.py ├── griode.service ├── keyboard.py ├── latch.py ├── launchpad.py ├── looper.py ├── mid2loop.py ├── mid2loop.sh ├── mixer.py ├── notes.py ├── palette.py ├── palette.yaml ├── persistence.py ├── pickers.py ├── quantize.py ├── requirements.txt ├── scales.py ├── soundfonts └── download-soundfonts.sh ├── state └── README.md └── termpad.py /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | exclude = scales.py, chords.py 3 | ignore = 4 | E201 # White space after [ 5 | E202 # White space before ] 6 | E225 # Spaces around operators 7 | E226 # Spaces around arithmetic operators 8 | E228 # Spaces around modulo operator 9 | E241 # Multiple spaces after ":" 10 | E251 # Spaces around = in keyword parameters 11 | E302 # For when we use ##### instead of double line break 12 | E501 # Line too long 13 | W503 # Line break before binary operator 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | *.swp 3 | *.sf2 4 | *.sav 5 | *.db 6 | *~ 7 | -------------------------------------------------------------------------------- /DESIGN.md: -------------------------------------------------------------------------------- 1 | This is the design document for griode internals. 2 | 3 | ## What's a gridget? 4 | 5 | It's a widget for the grid! You can interact with a gridget, anmd it 6 | can display itself. 7 | 8 | 9 | ## What's a "led"? 10 | 11 | A `led` can be either a tuple, or a string. 12 | 13 | Tuples correspond to pads, used to play notes or interact with a gridget. 14 | The tuple will be `(row, column)` where both numbers start at 1. 15 | 16 | Strings correspond to buttons, used to switch modes and macros. 17 | 8 strings are pre-defined; these are the "mandatory" buttons that should 18 | be available for griode to be usable. 19 | - LEFT RIGHT UP DOWN 20 | - BUTTON_1 BUTTON_2 BUTTON_3 BUTTON_4 21 | 22 | The other buttons will be mapped to arbitrary strings, and can be used 23 | as macros or shortcuts. It doesn't matter if there are 0, 8, or 100. 24 | 25 | ## Message flow 26 | 27 | input → looper → devicechains → latch → arpeggiator → synth 28 | 29 | Each "stage" sends messages directly to the next, using the `send()` method. 30 | 31 | 32 | ## Data model 33 | 34 | - griode 35 | - clock 36 | - cue(when, func, args) 37 | - looper 38 | - send(message) 39 | - tick() 40 | - beats_per_bar💾 41 | - loops[line,column] 42 | - next_tick 43 | - channel💾 44 | - tick_in💾 } beginning of the loop 45 | - tick_out💾 } when reaching that tick, rewind to tick_in 46 | - notes{}💾 } the key is the position the loop (in 24th of qnote) 47 | - note 48 | - velocity 49 | - duration } this is also in 24th of quarter note 50 | - synth 51 | - instruments[] 52 | - messages() 53 | - fonts{font_index}{group}{program}{bank_index} 54 | - send(message) 55 | - devicechains[] 56 | - send(message) 57 | - font_index💾 58 | - group_index💾 59 | - instr_index💾 60 | - bank_index💾 61 | - latch 62 | - enabled💾 63 | - arpeggiator 64 | - enabled💾 65 | - pattern_length💾 66 | - interval💾 67 | - pattern[]💾 68 | (velocity, gate, [harmonies]) 69 | - tick(tick) 70 | - scale💾 71 | - key💾 72 | - grids[] 73 | - grid_name 74 | - channel💾 75 | - focus(gridget, leds[]) 76 | - surface 77 | - colorpicker 78 | - notepickers[] 79 | - send(message, source_object) 80 | - instrumentpickers[] 81 | - change(instrument) 82 | - scalepicker 83 | - change(...) 84 | - arpconfigs[] 85 | - step(step) 86 | - loopcontroller 87 | - tick(tick) 88 | 89 | 90 | ## Gridget interface 91 | 92 | - pad_pressed(row, column, velocity) 93 | - button_pressed(button) 94 | - surface 95 | 96 | 97 | ## Surface interface 98 | 99 | - [row, column] 100 | - [button] 101 | - parent 102 | 103 | 104 | ## Idea for a NLDR-like device chain 105 | 106 | ``` 107 | CH1 108 | CH2 109 | CH3 110 | CH4 111 | CH5 note -> latch device -> fan-out device -> CH6,7,8,9 112 | CH6 drone -> instrument 113 | CH7 bass -> riff -> instrument 114 | CH8 pad -> chord -> instrument 115 | CH9 motif -> arp -> instrument 116 | ``` 117 | -------------------------------------------------------------------------------- /MANUAL.md: -------------------------------------------------------------------------------- 1 | MANUAL.md 2 | 3 | # Griode user manual 4 | 5 | This is a very succint user manual. Its goal is to explain Griode's menu 6 | structure and some of the non-obvious features. 7 | 8 | 9 | ## General concept 10 | 11 | Griode separates the Launchpad controls in two categories. 12 | 13 | - There are 64 square *pads* arranged in a 8x8 grid. This is the 14 | main "play area". On the Launchpad Pro, they are pressure sensitive. 15 | - And then there are round *buttons* located around the pads. 16 | On the Launchpad Pro, there are 8 buttons on each side (32 in total), 17 | but in the Launchpad S, Mini, and Mk2, there are 8 buttons on top 18 | and 8 buttons on the right (so 16 total). 19 | 20 | Griode has many different "gridgets". There is a gridget to play notes, 21 | a gridget to select an instrument, another to change the scale, etc. 22 | At any given time, only one gridget is shown on the *pads*, and you 23 | can switch to another gridget by using the *buttons*. 24 | 25 | If this was confusing, you can think of the gridgets as different 26 | screens, or different apps, each with a different purpose. 27 | 28 | Gridget is a porte-manteau between "grid" and "widget"; widget 29 | being here a [GUI widget](https://en.wikipedia.org/wiki/Widget_(GUI)). 30 | 31 | 32 | ## Buttons 33 | 34 | Only the 8 buttons on the top row are used by Griode. 35 | 36 | The first 4 buttons are UP, DOWN, LEFT, RIGHT. On almost every Launchpad, 37 | there are arrows on these buttons so you can identify them easily; 38 | except on the Launchpad Mini. On the Launchpad Mini, the buttons are 39 | labeled 1 2 3 4 5 6 7 8; so 1 2 3 4 are actually UP DOWN LEFT RIGHT. 40 | 41 | The arrows have different purposes in each gridget; for instance, 42 | when playing notes, the arrows can be used to transpose the grid, 43 | but they have different roles in other gridgets. 44 | 45 | The 4 next buttons have different labels on different Launchpad models: 46 | 47 | - SESSION NOTE DEVICE USER (Launchpad Pro) 48 | - SESSION USER1 USER2 MIXER (Launchpad Mk2 and Launchpad S) 49 | - 5 6 7 8 (Launchpad Mini) 50 | 51 | These buttons are used to navigate the Griode menus, and switch 52 | between gridgets. 53 | 54 | Internally, they are known as BUTTON_1 BUTTON_2 BUTTON_3 BUTTON_4; 55 | but we will refer to them as SESSION/5, NOTE/USER1/6, DEVICE/USER2/7, USER/MIXER/8. 56 | 57 | They are mapped to the same functions; in other words, pressing 58 | DEVICE on the Launchpad Pro is like pressing USER2 on the Launchpad Mk2 59 | and it's like pressing 7 on the Launchpad Mini. 60 | 61 | 62 | ## Menus 63 | 64 | The gridgets are organized in 4 menus. In other words, we have 65 | 4 menus, and in each menus, we have multiple gridgets. In each 66 | menu, there is one "active" or "current" gridget; and there is 67 | one "active" or "current" menu. At any given time, the gridget 68 | that you see on the grid (and that you can interact with) is 69 | the active gridget in the active menu. 70 | 71 | The active menu is shown by a bright pink color. The other 3 72 | menus are shown with a pale pink color. You can change the 73 | active menu by pushing the corresponding button. 74 | 75 | For instance, when you start Griode, it defaults to the NOTE/USER1/6 76 | menu, and you can play some notes. 77 | If you push DEVICE/USER2/7, you will then go to that menu 78 | (which should show you the instrument selector). Push again 79 | NOTE/USER1/6, and you're back where you started. 80 | 81 | If you push again the button of the currently active menu, 82 | then you cycle through the different grigets in that menu. 83 | 84 | That's all! 85 | 86 | 87 | ## Structure 88 | 89 | Here is the menu structure: 90 | 91 | - SESSION/5 92 | - loop recorder 93 | - scale selector 94 | - NOTE/USER1/6 95 | - chromatic keyboard 96 | - diatonic keyboard 97 | - tonnetz keyboard aka magic tone keyboard 98 | - DEVICE/USER2/7 99 | - instrument selector 100 | - arpeggiator 101 | - latch 102 | - USER/MIXER/8 103 | - rainbow palette 104 | - volume, chorus, and reverb faders 105 | - tempo 106 | 107 | 108 | ## Instrument selector 109 | 110 | When you go to the instrument selector, the grid will 111 | be divided in two zones. The five rows on top are used 112 | to select the instrment, and the three remaining row on 113 | the bottom let you play notes (so that you can test the 114 | instrument without having to continuously switch back and 115 | forth between the instrument selector and the keyboard). 116 | 117 | The top five rows have the following roles. 118 | 119 | - The top row lets you select the SoundFont that you wish 120 | to use, and whether you want to pick a melodic patch 121 | or a drum kit. If you only have one SoundFont, you should 122 | see two buttons lit up: the first one to select a melodic 123 | patch, the second one to select a drum kit. Now, if you 124 | have two SoundFonts, you will see four buttons. They will 125 | let you choose (in this order) melodic instruments from 126 | the first file, melodic instruments from the second file, 127 | drums from the first file, drums from the second file. 128 | Each additional file adds two buttons here, one for melodic 129 | and the other for drum patches. 130 | - The second and third rows let you select the family of 131 | instrument (for melodic instruments only). The list 132 | of families is available in the [GM specification]( 133 | https://www.midi.org/specifications-old/item/gm-level-1-sound-set 134 | ). Of course, if you use a SoundFont that doesn't follow 135 | General MIDI conventions, this mapping will have a different 136 | meaning. 137 | - The fourth row lets you select the individual instrument 138 | within the family. 139 | - The fifth row lets you select the variation (when available) 140 | for a specific instrument. Most instruments will have 141 | only one version (and therefore, that fifth row will only 142 | have one active button) but some can have more. 143 | 144 | 145 | ## Loading SoundFonts 146 | 147 | Griode automatically loads sound fonts from `soundfonts/?.sf2`. 148 | The download script will automatically download a couple 149 | of sound fonts, and create symlinks named `0.sf2` and `1.sf2` 150 | pointing to these files. 151 | 152 | The recommended way to select which sound fonts to use is to 153 | place them all in the `soundfonts` directory (or anywhere else) 154 | and then create symlinks in the `soundfonts` directory. 155 | 156 | Example: 157 | 158 | - Download some super cool SF2 file named `steinway.sf2` 159 | - Copy `steinway.sf2` to the `soundfonts` directory 160 | - `cd soundfonts; ln -s steinway.sf2 2.sf2` 161 | - And voilà, that SF2 file will be loaded next time you start Griode! 162 | 163 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Griode 2 | 3 | Griode lets you play music using a LaunchPad or a similar controller. 4 | 5 | For short demos and tutorials, check this [YouTube playlist](https://www.youtube.com/playlist?list=PLBAFXs0YjviK9PzKnr3MDsRU6YAJgeH1K). 6 | 7 | I gave a few talks about Griode: 8 | - Berlin Hack&Tell at C-Base (May 2018) [🔗](https://berlinhackandtell.rocks/2018-05-29-no61-revolutionary-may-hacks) 9 | - Linux Technologies Berlin (July 2018) [🔗](https://www.meetup.com/linux-technologies-berlin/events/252302070/) 10 | - Python Users Berlin (April 2019) [🔗](https://www.meetup.com/Python-Users-Berlin-PUB/events/258989401/) 11 | 12 | [Here are the slides](https://docs.google.com/presentation/d/1G0bdcSyqqoCiD4dK7tq6PZsoMaM_VVexx_n8m4kSYYE/edit#slide=id.p) that I use when presenting Griode. 13 | 14 | This [poster](https://drive.google.com/file/d/16wTihPzhnTIVcAFDvHevtFG-0K0qXPPB) 15 | explains how to switch instruments and modes. 16 | The original version of the poster was made by Jérôme Petazzoni to 17 | present at PyCon in 2019; and it was then much improved 18 | by [Elodie Trinh](https://twitter.com/mintismycolor) (thanks Elodie!♥). 19 | 20 | There is also a basic (and incomplete) [user manual](MANUAL.md) that 21 | you may (or not) find helpful. 22 | 23 | 24 | ## Quick start 25 | 26 | Here are some quick instructions to get you started, assuming that you 27 | have a LaunchPad connected to an Debian/Ubuntu system. 28 | 29 | ``` 30 | git clone git://github.com/jpetazzo/griode 31 | cd griode 32 | sudo apt-get install python3-pip python3-dev libasound2-dev libjack-dev fluidsynth 33 | pip3 install --user -r requirements.txt 34 | ( cd soundfonts; ./download-soundfonts.sh; ) 35 | ./griode.py 36 | ``` 37 | 38 | Your LaunchPad should light up with a red and white pattern, and pressing 39 | pads should make piano sounds. 40 | 41 | 42 | ### Installing on a Raspberry Pi 43 | 44 | If you want to setup Griode on a Raspberry Pi, you can use the 45 | instructions above, but **make sure that you run them as the `pi` user.** 46 | 47 | You can use the "lite" (text only) or the "desktop" version, it 48 | doesn't matter for Griode. 49 | 50 | If you want Griode to start automatically when the Pi is powered on, 51 | see [this paragraph](#Starting-automatically-on-boot). 52 | 53 | 54 | ## Detailed setup instructions 55 | 56 | You need: 57 | 58 | - Python 3 59 | - FluidSynth (to generate sounds) 60 | - at least one SoundFont (instrument bank used by FluidSynth) 61 | - a LaunchPad or similar MIDI controller 62 | 63 | 64 | ### Installing Python dependencies 65 | 66 | On Debian/Ubuntu systems, you will need the following system packages, 67 | so that the `python-rtmidi` Python package can be installed correctly: 68 | 69 | ```bash 70 | apt-get install python3-dev libasound2-dev libjack-dev 71 | ``` 72 | 73 | You can then install Griode's requirements with `pip`: 74 | 75 | ```bash 76 | pip install --user -r requirements.txt 77 | ``` 78 | 79 | Of course, you are welcome to use `virtualenv` or anything like that 80 | if you want. 81 | 82 | If you get compilation errors, you might need extra packages (libraries or headers). 83 | 84 | Note: if you have problems related to the installation of `python-rtmidi`, 85 | you might be tempted to try to install `rtmidi` instead. DO NOT! The two 86 | packages are slightly incompatible; so after installing `rtmidi`, perhaps 87 | Griode will start, but you will get another bizarre error at a later point. 88 | 89 | 90 | ### Installing FluidSynth 91 | 92 | Fluidsynth is a software synthesizer. On Debian/Ubuntu systems, you 93 | can install it with: 94 | 95 | ``` 96 | apt-get install fluidsynth 97 | ``` 98 | 99 | 100 | ### Installing SoundFonts 101 | 102 | Griode requires at least one "SoundFont" so that FluidSynth can make 103 | sounds. The easiest way to get started is to go to the `soundfonts/` 104 | subdirectory, and run the script `download-soundfonts.sh`. 105 | 106 | Griode will load SoundFonts called `?.sf2` in alphabetical order. 107 | The `download-soundfonts.sh` script will create a symlink `0.sf2` 108 | pointing to the "GeneralUser GS" SoundFont, which contains the 109 | 128 instruments of the General MIDI standard, as well as a few 110 | variations, and a few drum kits. 111 | 112 | You are welcome to download your own soundfonts, place them in 113 | the `soundfonts/` subdirectory, and create symlinks to these files: 114 | they will be loaded when you start Griode. 115 | 116 | 117 | #### What are soundfonts? 118 | 119 | SoundFonts are instrument banks used by some audio hardware and by FluidSynth 120 | to generate notes of music. The typical extension for SoundFont files is `.sf2`. 121 | 122 | There are many SoundFonts available out there. 123 | Some of them are tiny: the Sound Blaster AWE32 (a sound card from the mid-90s) 124 | had 512 KB of RAM to load SoundFonts, and there are SoundFonts of that size 125 | that offer the 100+ instruments of the General Midi standard! And some 126 | SoundFonts are huge: I saw some 1 GB SoundFonts out there with just a couple 127 | of piano instruments in them, but in very high quality (i.e. using different 128 | samples for each note and for different velocity levels.) 129 | 130 | Here are a few links to some SF2 files: 131 | - [GeneralUser](http://www.schristiancollins.com/generaluser.php) 132 | - [Fluid SoundFont](https://packages.debian.org/source/sid/fluid-soundfont) 133 | - [Soundfonts4U](https://sites.google.com/site/soundfonts4u/) 134 | - [8bitsf](https://musical-artifacts.com/artifacts/23/8bitsf.SF2) 135 | - [BASSMIDI](https://kode54.net/bassmididrv/BASSMIDI_Driver_Installation_and_Configuration.htm) 136 | 137 | 138 | ### LaunchPad 139 | 140 | Griode currently supports the Launchpad Pro, the Launchpad MK2 (aka "RGB"), 141 | and has partial support for the Launchpad S. You can plug multiple 142 | controllers and use them simultaneously. 143 | 144 | Griode relies on the name of the MIDI port reported by the `mido` library 145 | to detect your Launchpad(s). This has been tested on Linux, but the port 146 | names might be different on macOS or Windows. 147 | 148 | 149 | ## Debugging 150 | 151 | You can set the `LOG_LEVEL` environment variable to any valid `logging` 152 | level, e.g. `DEBUG` or `INFO`: 153 | 154 | ``` 155 | export LOG_LEVEL=DEBUG 156 | ./griode.py 157 | ``` 158 | 159 | Note: 160 | - `DEBUG` level is (and will always be) very verbose. 161 | - You can put the log level in lowercase if you want. 162 | - The default log level is `INFO`. 163 | 164 | 165 | ### Persistence 166 | 167 | Griode saves all persistent information to the `state/` subdirectory. 168 | If you want to reset Griode (or some of its subsystems) to factory defaults, 169 | you can wipe out this directory (or some of the files therein). 170 | 171 | 172 | ### Starting automatically on boot 173 | 174 | If you are using a Raspberry Pi running the Raspbian distribution, 175 | and want to automatically start Griode on boot, you can use the provided 176 | systemd unit (`griode.service`). 177 | 178 | After checking out the code in `/home/pi/griode`, and confirming that 179 | it runs correctly, just run: 180 | 181 | ``` 182 | sudo systemctl enable /home/pi/griode/griode.service 183 | sudo systemctl start griode 184 | ``` 185 | 186 | Griode will start, and it will be automatically restarted when the 187 | Pi reboots. 188 | 189 | If it doesn't start, or if you want to see what's going on: 190 | 191 | ``` 192 | sudo systemctl status griode 193 | sudo journalctl -u griode 194 | ``` 195 | 196 | 197 | ## Bugs 198 | 199 | - If you keep a note pressed while switching to another gridget, 200 | the note will continue to play. This is almost by design. 201 | - If you keep a note pressed while stopping recording, it might 202 | record a zero-length notes. 203 | - If a sync is triggered while notes are pressed, it might result 204 | in zero-length notes. 205 | 206 | -------------------------------------------------------------------------------- /arpeggiator.py: -------------------------------------------------------------------------------- 1 | import enum 2 | import logging 3 | import mido 4 | 5 | from gridgets import Gridget, Surface 6 | from palette import palette 7 | from persistence import persistent_attrs, persistent_attrs_init 8 | 9 | """ 10 | The arpeggiator config has multiple screens. 11 | 12 | You scroll between the different screens with UP/DOWN. 13 | 14 | ARPSETUP: 15 | E......T -> Enable/disable arp; tempo monitor 16 | ........ 17 | BBBBBBBB -> watch arp steps (to monitor speed); change speed/interval 18 | ........ 19 | .X...X.. \ 20 | ...X...X \ note order (use LEFT/RIGHT to change order) 21 | X...X... / 22 | ..X...X. / 23 | 24 | MOTIFSETUP: 25 | ....X... \ 26 | ..X..X.. } when in scale mode, use first/lowest/highest/last note played 27 | ...X.... / as the root of the scale (use LEFT/RIGHT to change) 28 | ........ 29 | XX.XXXX. 30 | XXXXXXXS -> Select scale mode 31 | ........ 32 | X.X.X.XS -> Select "notes played" mode 33 | 34 | VELOGATE: assign velocity and gate values. 35 | VVVVVVVV \ 36 | VVVVVVVV \ Configure velocity level of each step 37 | VVVVVVVV / 38 | VVVVVVVV / 39 | GGGGGGGG \ 40 | GGGGGGGG } Configure gate (note length) for each step 41 | GGGGGGGG / 42 | EEEEEEEE -> Enable or disable steps 43 | 44 | MOTIF: configure a melodic motif. 45 | ........ 46 | ........ 47 | ........ 48 | ........ 49 | ........ = ... 50 | ........ = 3rd 51 | ........ = 2nd 52 | ........ = root note (selected by default) 53 | """ 54 | 55 | 56 | class Page(enum.Enum): 57 | # FIXME MOTIFSETUP = 1 58 | ARPSETUP = 2 59 | VELOGATE = 3 60 | MOTIF = 4 61 | 62 | 63 | class NoteOrder(enum.Enum): # When a note is added, it should be played ... 64 | FIRST = 1 # - as soon as possible 65 | LAST = 2 # - after all other notes 66 | ASCENDING = 3 # - in ascending order (playing upward arpeggio) 67 | DESCENDING = 4 # - in descending order (downward arpeggio) 68 | BOUNCING = 5 # - we're playing up-then-down-then-up arpeggio 69 | 70 | 71 | class MotifMode(enum.Enum): 72 | DISABLED = 1 # Do not use the motif, just spell out notes in buffer 73 | SCALE = 2 # Use the motif, mapping steps to the current scale 74 | BUFFER = 3 # Use the motif, mapping steps to the notes buffer 75 | 76 | 77 | class ScaleKey(enum.Enum): # Which note will be the root of the scale? 78 | FIRST = 1 # - the first note in the buffer 79 | LAST = 2 # - the last note in the buffer 80 | LOWER = 3 # - the lowest note in the buffer 81 | HIGHER = 4 # - the highest note in the buffer 82 | NEXT = 5 # - the next note in the buffer 83 | 84 | 85 | @persistent_attrs( 86 | enabled=False, interval=6, pattern_length=4, 87 | pattern=[[4, 3, [0]], [1, 2, [0]], [3, 1, [0]], [1, 2, [0]]], 88 | note_order=NoteOrder.FIRST, 89 | motif_mode=MotifMode.DISABLED, 90 | scale_key=ScaleKey.FIRST, 91 | ) 92 | class Arpeggiator(object): 93 | 94 | def __init__(self, devicechain): 95 | self.devicechain = devicechain 96 | persistent_attrs_init(self, str(devicechain.channel)) 97 | self.notes = [] 98 | self.next_note = 0 # That's a position in self.notes 99 | self.direction = 1 # Always 1, except when in BOUNCING mode 100 | self.latch_notes = set() 101 | self.playing = [] 102 | self.next_step = 0 # That's a position in self.pattern 103 | self.next_tick = 0 # Note: next_tick==0 also means "NOW!" 104 | 105 | def tick(self, tick): 106 | # OK, first, let's see if some notes are currently playing, 107 | # but should be stopped. 108 | for note, deadline in self.playing: 109 | if tick > deadline: 110 | self.output(mido.Message("note_on", note=note, velocity=0)) 111 | self.playing.remove((note, deadline)) 112 | 113 | # If we're disabled, stop right there 114 | if not self.enabled: 115 | self.notes.clear() 116 | return 117 | # If it's not time yet to spell out the next note, stop right there 118 | if tick < self.next_tick: 119 | return 120 | # If there are no notes in the buffer (=pressed), stop right there 121 | if self.notes == []: 122 | return 123 | # If we just got "woken up" set next_tick to the correct value 124 | if self.next_tick == 0: 125 | self.next_tick = tick 126 | 127 | # Yay it's time to spell out the next note(s)! 128 | logging.debug("next_step={} -> {}" 129 | .format(self.next_step, self.pattern[self.next_step])) 130 | velocity, gate, motif = self.pattern[self.next_step] 131 | velocity = velocity*31 132 | duration = gate*self.interval//3 133 | if self.motif_mode == MotifMode.DISABLED: 134 | # Do not use a scale, or rather, use a one-note scale. 135 | # (This way, Motif will trigger octaves.) 136 | scale = [self.notes[self.next_note]] 137 | if self.motif_mode == MotifMode.BUFFER: 138 | # Use the buffer as a scale to play from. 139 | scale = self.notes 140 | if self.motif_mode == MotifMode.SCALE: 141 | # OK, we want to use the current (global) scale. 142 | # But we have to determine the root note. 143 | if self.scale_key == ScaleKey.FIRST: 144 | key = self.notes[0] 145 | if self.scale_key == ScaleKey.LAST: 146 | key = self.notes[-1] 147 | if self.scale_key == ScaleKey.LOWER: 148 | key = min(self.notes) 149 | if self.scale_key == ScaleKey.HIGHER: 150 | key = max(self.notes) 151 | if self.scale_key == ScaleKey.NEXT: 152 | key = self.notes[self.next_note] 153 | scale = [key+note for note in self.devicechain.griode.scale] 154 | 155 | logging.debug("computed scale: {}".format(scale)) 156 | # OK, now we have a scale. Let's map the motif to the scale. 157 | for step in motif: 158 | octave = 0 159 | # If the step is too high, jump as many octaves higher as necessary 160 | while step >= len(scale): 161 | octave += 1 162 | step -= len(scale) 163 | # If the step is too low (i.e. negative), jump a few octaves down 164 | while step < 0: 165 | octave -= 1 166 | step += len(scale) 167 | note = scale[step] + 12*octave 168 | # Make sure that the note stays within the MIDI range 169 | while note > 127: 170 | note -= 12 171 | while note < 0: 172 | note += 12 173 | logging.debug("playing note={} velo={} duration={}" 174 | .format(note, velocity, duration)) 175 | self.output(mido.Message("note_on", note=note, velocity=velocity)) 176 | self.playing.append((note, tick+duration)) 177 | 178 | # Cycle to the next position in the notes buffer. 179 | self.next_note += self.direction 180 | # If we are past the beginning of the buffer... 181 | # (This happens only in BOUNCING mode.) 182 | if self.next_note < 0: 183 | # Just go back the other way. 184 | self.next_note = 1 185 | self.direction = 1 186 | # If we are past the end of the buffer... 187 | if self.next_note >= len(self.notes): 188 | # Special case for bouncing mode: go back down! 189 | if self.note_order == NoteOrder.BOUNCING: 190 | self.direction = -1 191 | self.next_note -= 2 192 | # Handle the case where there is only one note in the buffer 193 | if self.next_note < 0: 194 | self.next_note = 0 195 | else: 196 | self.next_note = 0 197 | 198 | # Update displays 199 | for grid in self.devicechain.griode.grids: 200 | arpconfig = grid.arpconfigs[self.devicechain.channel] 201 | arpconfig.current_step = self.next_step 202 | arpconfig.draw() 203 | # And prepare for next step 204 | self.next_tick += self.interval 205 | self.next_step += 1 206 | if self.next_step >= self.pattern_length: 207 | self.next_step = 0 208 | 209 | def send(self, message): 210 | if message.type == "note_on" and self.enabled: 211 | if message.velocity > 0: 212 | # If this is the first note played, "wake up" the arpeggiator. 213 | if self.notes == []: 214 | self.next_tick = 0 215 | self.next_step = 0 216 | self.notes.append(message.note) 217 | else: 218 | # Add the note. 219 | # The position where we add it depends of NoteOrder. 220 | if self.note_order == NoteOrder.FIRST: 221 | self.notes.insert(self.next_note, message.note) 222 | if self.note_order == NoteOrder.LAST: 223 | if self.next_note == 0: 224 | self.notes.append(message.note) 225 | else: 226 | self.notes.insert(self.next_note-1) 227 | if self.note_order == NoteOrder.ASCENDING: 228 | self.notes.append(message.note) 229 | self.notes.sort() 230 | # FIXME fix up self.next_note 231 | if self.note_order == NoteOrder.DESCENDING: 232 | self.notes.append(message.note) 233 | self.notes.sort() 234 | self.notes.reverse() 235 | # FIXME fix up self.next_note 236 | if self.note_order == NoteOrder.BOUNCING: 237 | self.notes.append(message.note) 238 | self.notes.sort() 239 | if self.direction == -1: 240 | self.notes.reverse() 241 | # FIXME fix up self.next_note 242 | else: 243 | if message.note in self.notes: 244 | index = self.notes.index(message.note) 245 | if self.next_note > index: 246 | self.next_note -= 1 247 | self.notes.remove(message.note) 248 | if self.next_note >= len(self.notes): 249 | self.next_note = 0 250 | else: 251 | self.output(message) 252 | 253 | def output(self, message): 254 | message = message.copy(channel=self.devicechain.channel) 255 | self.devicechain.griode.synth.send(message) 256 | 257 | 258 | class ArpConfig(Gridget): 259 | 260 | def __init__(self, grid, channel): 261 | self.grid = grid 262 | self.channel = channel 263 | self.current_step = 0 264 | self.display_offset = 0 # Step shown on first column 265 | self.page = Page.VELOGATE 266 | self.surface = Surface(grid.surface) 267 | self.surface["UP"] = palette.CHANNEL[self.channel] 268 | self.surface["DOWN"] = palette.CHANNEL[self.channel] 269 | self.surface["LEFT"] = palette.CHANNEL[self.channel] 270 | self.surface["RIGHT"] = palette.CHANNEL[self.channel] 271 | self.draw() 272 | 273 | @property 274 | def arpeggiator(self): 275 | return self.grid.griode.devicechains[self.channel].arpeggiator 276 | 277 | def draw(self): 278 | if self.page in [Page.MOTIF, Page.VELOGATE]: 279 | self.draw_steps() 280 | if self.page == Page.ARPSETUP: 281 | self.draw_arpsetup() 282 | 283 | def draw_arpsetup(self): 284 | for led in self.surface: 285 | if isinstance(led, tuple): 286 | color = palette.BLACK 287 | if led == (8, 1): 288 | color = palette.SWITCH[self.arpeggiator.enabled] 289 | row, column = led 290 | 291 | def color_enum(klass, column, value): 292 | if column == value: 293 | return palette.SWITCH[1] 294 | if column in [e.value for e in klass]: 295 | return palette.SWITCH[0] 296 | return palette.BLACK 297 | 298 | if row == 6: 299 | color = color_enum(NoteOrder, column, 300 | self.arpeggiator.note_order.value) 301 | if row == 4: 302 | color = color_enum(MotifMode, column, 303 | self.arpeggiator.motif_mode.value) 304 | if row == 2: 305 | color = color_enum(ScaleKey, column, 306 | self.arpeggiator.scale_key.value) 307 | 308 | self.surface[led] = color 309 | 310 | def draw_steps(self): 311 | for led in self.surface: 312 | if isinstance(led, tuple): 313 | color = palette.BLACK 314 | row, column = led 315 | step = column - 1 + self.display_offset 316 | if step >= self.arpeggiator.pattern_length: 317 | if row == 1: 318 | color = palette.TRIG 319 | else: 320 | velocity, gate, harmonies = self.arpeggiator.pattern[step] 321 | if self.page == Page.VELOGATE: 322 | if row == 1: 323 | if step == self.current_step: 324 | color = palette.PLAY 325 | else: 326 | color = palette.TRIG[1] 327 | if row in [2, 3, 4]: 328 | if gate > row-2: 329 | if harmonies: 330 | color = palette.GATE[1] 331 | else: 332 | color = palette.GATE[0] 333 | if row in [5, 6, 7, 8]: 334 | if velocity > row-5: 335 | if harmonies: 336 | color = palette.VELO[1] 337 | else: 338 | color = palette.VELO[0] 339 | if self.page == Page.MOTIF: 340 | if row-1 in harmonies: 341 | if step == self.current_step: 342 | color = palette.PLAY 343 | else: 344 | if velocity < 2: 345 | color = palette.MOTIF[0] 346 | else: 347 | color = palette.MOTIF[1] 348 | self.surface[led] = color 349 | 350 | def pad_pressed(self, row, column, velocity): 351 | if velocity == 0: 352 | return 353 | step = column - 1 + self.display_offset 354 | 355 | if self.page == Page.VELOGATE: 356 | if row == 1: 357 | while len(self.arpeggiator.pattern) <= step: 358 | self.arpeggiator.pattern.append([1, 1, [0]]) 359 | self.arpeggiator.pattern_length = step+1 360 | if row in [2, 3, 4]: 361 | self.arpeggiator.pattern[step][1] = row-1 362 | if row in [5, 6, 7, 8]: 363 | self.arpeggiator.pattern[step][0] = row-4 364 | 365 | if self.page == Page.MOTIF: 366 | harmony = row-1 367 | if harmony in self.arpeggiator.pattern[step][2]: 368 | self.arpeggiator.pattern[step][2].remove(harmony) 369 | else: 370 | self.arpeggiator.pattern[step][2].append(harmony) 371 | 372 | if self.page == Page.ARPSETUP: 373 | if (row, column) == (8, 1): 374 | self.arpeggiator.enabled = not self.arpeggiator.enabled 375 | if row == 6: 376 | self.arpeggiator.note_order = NoteOrder(column) 377 | if row == 4: 378 | self.arpeggiator.motif_mode = MotifMode(column) 379 | if row == 2: 380 | self.arpeggiator.scale_key = ScaleKey(column) 381 | if row == 1: 382 | self.arpeggiator.interval = [None, 24, 16, 12, 8, 6, 4, 3, 2][column] 383 | 384 | self.draw() 385 | 386 | def button_pressed(self, button): 387 | try: 388 | if button == "UP": 389 | self.page = Page(self.page.value-1) 390 | self.draw() 391 | if button == "DOWN": 392 | self.page = Page(self.page.value+1) 393 | self.draw() 394 | except ValueError: 395 | # Ignore attempts to scroll up/down to non-existent pages 396 | pass 397 | if button == "LEFT": 398 | if self.display_offset > 0: 399 | self.display_offset -= 1 400 | self.draw() 401 | if button == "RIGHT": 402 | if self.display_offset < self.arpeggiator.pattern_length - 2: 403 | self.display_offset += 1 404 | self.draw() 405 | -------------------------------------------------------------------------------- /chords.py: -------------------------------------------------------------------------------- 1 | from notes import * 2 | 3 | MAJOR = (C, E, G) 4 | MINOR = (C, Eflat, G) 5 | SEVENTH = (C, E, G, Bflat) 6 | -------------------------------------------------------------------------------- /clock.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import resource 3 | import time 4 | 5 | 6 | from gridgets import Gridget, Surface 7 | from palette import palette 8 | from persistence import persistent_attrs, persistent_attrs_init 9 | 10 | 11 | NUMBERS = """ 12 | ### # ### ### # # ### ### ### ### ### 13 | # # # # # # # # # # # # # # 14 | # # # ### ### ### ### ### # ### ### 15 | # # # # # # # # # # # # # 16 | ### # ### ### # ### ### # ### ### 17 | """.strip().split("\n") 18 | 19 | 20 | @persistent_attrs(bpm=120) 21 | class Clock(object): 22 | 23 | def __init__(self, griode): 24 | self.griode = griode 25 | persistent_attrs_init(self) 26 | self.tick = 0 # 24 ticks per quarter note 27 | self.next = time.time() 28 | self.cues = [] 29 | 30 | def cue(self, when, func, args): 31 | self.cues.append((self.tick+when, func, args)) 32 | 33 | def callback(self): 34 | expired_cues = [cue for cue in self.cues if cue[0] <= self.tick] 35 | for when, func, args in expired_cues: 36 | func(*args) 37 | for cue in expired_cues: 38 | self.cues.remove(cue) 39 | for devicechain in self.griode.devicechains: 40 | devicechain.arpeggiator.tick(self.tick) 41 | for grid in self.griode.grids: 42 | grid.tick(self.tick) 43 | for grid in self.griode.grids: 44 | grid.loopcontroller.tick(self.tick) 45 | self.griode.looper.tick(self.tick) 46 | self.griode.cpu.tick(self.tick) 47 | self.griode.tick(self.tick) 48 | 49 | # Return how long it is until the next tick. 50 | # (Or zero if the next tick is due now, or overdue.) 51 | def poll(self): 52 | now = time.time() 53 | if now < self.next: 54 | return self.next - now 55 | self.tick += 1 56 | self.callback() 57 | # Compute when we're due next 58 | self.next += 60.0 / self.bpm / 24 59 | if now > self.next: 60 | logging.warning("We're running late by {} seconds!" 61 | .format(self.next-now)) 62 | # If we are late, should we try to stay aligned, or skip? 63 | margin = 0.0 # Put 1.0 for pseudo-realtime 64 | if now > self.next + margin: 65 | logging.warning("Catching up (deciding that next tick = now).") 66 | self.next = now 67 | return 0 68 | return self.next - now 69 | 70 | # Wait until next tick is due. 71 | def once(self): 72 | time.sleep(self.poll()) 73 | 74 | ############################################################################## 75 | 76 | class CPU(object): 77 | # Keep track of our CPU usage. 78 | 79 | def __init__(self, griode): 80 | self.griode = griode 81 | self.last_usage = 0 82 | self.last_time = 0 83 | self.last_shown = 0 84 | 85 | def tick(self, tick): 86 | r = resource.getrusage(resource.RUSAGE_SELF) 87 | new_usage = r.ru_utime + r.ru_stime 88 | new_time = time.time() 89 | if new_time > self.last_shown + 1.0: 90 | percent = (new_usage-self.last_usage)/(new_time-self.last_time) 91 | logging.debug("CPU usage: {:.2%}".format(percent)) 92 | self.last_shown = new_time 93 | self.last_usage = new_usage 94 | self.last_time = new_time 95 | 96 | ############################################################################## 97 | 98 | class BPMSetter(Gridget): 99 | 100 | def __init__(self, grid): 101 | self.grid = grid 102 | self.surface = Surface(grid.surface) 103 | self.surface[1, 1] = palette.DIGIT[0] 104 | self.surface[1, 3] = palette.DIGIT[0] 105 | self.surface[1, 6] = palette.DIGIT[0] 106 | self.surface[1, 8] = palette.DIGIT[0] 107 | self.draw() 108 | 109 | @property 110 | def bpm(self): 111 | return self.grid.griode.clock.bpm 112 | 113 | @bpm.setter 114 | def bpm(self, value): 115 | self.grid.griode.clock.bpm = value 116 | 117 | def draw(self): 118 | d1 = self.bpm // 100 119 | d2 = self.bpm // 10 % 10 120 | d3 = self.bpm % 10 121 | if d1 == 0: 122 | for row in range(3, 9): 123 | for column in [1, 8]: 124 | self.surface[row, column] = palette.BLACK 125 | self.draw_digit(d2, 3, 2, palette.DIGIT[2]) 126 | self.draw_digit(d3, 3, 5, palette.DIGIT[3]) 127 | else: 128 | self.draw_digit(d1, 3, 1, palette.DIGIT[1]) 129 | self.draw_digit(d2, 3, 3, palette.DIGIT[2]) 130 | self.draw_digit(d3, 3, 6, palette.DIGIT[3]) 131 | 132 | def draw_digit(self, digit, row, column, color): 133 | for line in range(5): 134 | three_dots = NUMBERS[line][4*digit:4*digit+3] 135 | for dot in range(3): 136 | if three_dots[dot] == "#": 137 | draw_color = color 138 | else: 139 | draw_color = palette.BLACK 140 | draw_row = row + 4 - line 141 | draw_column = column + dot 142 | self.surface[draw_row, draw_column] = draw_color 143 | 144 | def pad_pressed(self, row, column, velocity): 145 | # FIXME: provide visual feedback when these buttons are pressed. 146 | if velocity == 0: 147 | return 148 | if row == 1: 149 | if column == 1: 150 | self.bpm -= 10 151 | if column == 3: 152 | self.bpm -= 1 153 | if column == 6: 154 | self.bpm += 1 155 | if column == 8: 156 | self.bpm += 10 157 | if self.bpm < 50: 158 | self.bpm = 50 159 | if self.bpm > 199: 160 | self.bpm = 199 161 | self.draw() 162 | -------------------------------------------------------------------------------- /colors.py: -------------------------------------------------------------------------------- 1 | COLORS = { 2 | "BLACK": 0, 3 | "GREY_LO": 1, 4 | "GREY_MD": 2, 5 | "WHITE": 3, 6 | "ROSE": 4, 7 | "RED_HI": 5, 8 | "RED": 6, 9 | "RED_LO": 7, 10 | "RED_AMBER": 8, 11 | "AMBER_HI": 9, 12 | "AMBER": 10, 13 | "AMBER_LO": 11, 14 | "AMBER_YELLOW": 12, 15 | "YELLOW_HI": 13, 16 | "YELLOW": 14, 17 | "YELLOW_LO": 15, 18 | "YELLOW_LIME": 16, 19 | "LIME_HI": 17, 20 | "LIME": 18, 21 | "LIME_LO": 19, 22 | "LIME_GREEN": 20, 23 | "GREEN_HI": 21, 24 | "GREEN": 22, 25 | "GREEN_LO": 23, 26 | "GREEN_SPRING": 24, 27 | "SPRING_HI": 25, 28 | "SPRING": 26, 29 | "SPRING_LO": 27, 30 | "SPRING_TURQUOISE": 28, 31 | "TURQUOISE_LO": 29, 32 | "TURQUOISE": 30, 33 | "TURQUOISE_HI": 31, 34 | "TURQUOISE_CYAN": 32, 35 | "CYAN_HI": 33, 36 | "CYAN": 34, 37 | "CYAN_LO": 35, 38 | "CYAN_SKY": 36, 39 | "SKY_HI": 37, 40 | "SKY": 38, 41 | "SKY_LO": 39, 42 | "SKY_OCEAN": 40, 43 | "OCEAN_HI": 41, 44 | "OCEAN": 42, 45 | "OCEAN_LO": 43, 46 | "OCEAN_BLUE": 44, 47 | "BLUE_HI": 45, 48 | "BLUE": 46, 49 | "BLUE_LO": 47, 50 | "BLUE_ORCHID": 48, 51 | "ORCHID_HI": 49, 52 | "ORCHID": 50, 53 | "ORCHID_LO": 51, 54 | "ORCHID_MAGENTA": 52, 55 | "MAGENTA_HI": 53, 56 | "MAGENTA": 54, 57 | "MAGENTA_LO": 55, 58 | "MAGENTA_PINK": 56, 59 | "PINK_HI": 57, 60 | "PINK": 58, 61 | "PINK_LO": 59, 62 | } 63 | 64 | by_number = dict() 65 | 66 | for color in COLORS: 67 | globals()[color] = COLORS[color] 68 | by_number[COLORS[color]] = color 69 | -------------------------------------------------------------------------------- /fluidsynth.py: -------------------------------------------------------------------------------- 1 | import glob 2 | import logging 3 | import os 4 | import mido 5 | import re 6 | import subprocess 7 | import sys 8 | import time 9 | 10 | # When we start the fluidsynth process, we use "MMA" bank select mode. 11 | # This is the only mode that allows more than 128 banks (since it uses 12 | # two control change messages to encode the bank number). 13 | # 14 | # For more details, see: 15 | # https://github.com/FluidSynth/fluidsynth/blob/28a794a61cbca3181b21e2781d93c1bffc7c1b97/src/synth/fluid_synth.h#L55 16 | 17 | 18 | class Instrument(object): 19 | 20 | def __init__(self, font, program, bank, name): 21 | self.font = font # fluidsynth font number (starts at 1) 22 | self.program = program # MIDI program number [0..127] 23 | self.bank = bank # MIDI bank [includes offset, so [0..9128] 24 | self.name = name # string (not guaranteed to be unique!) 25 | # This information is computed once all instruments are loaded 26 | self.is_drumkit = None # True for drumkits, False for others 27 | self.font_index = None # this is a UI value; starts at 0 28 | self.bank_index = None # this is a UI value; starts at 0 29 | 30 | def messages(self): 31 | """Generate MIDI messages to switch to that instrument.""" 32 | return [ 33 | mido.Message("control_change", control=0, value=self.bank//128), 34 | mido.Message("control_change", control=32, value=self.bank%128), 35 | mido.Message("program_change", program=self.program), 36 | ] 37 | 38 | def __repr__(self): 39 | return ("Instrument({0.font}, {0.program}, {0.bank}, {0.name})" 40 | .format(self)) 41 | 42 | class Fluidsynth(object): 43 | 44 | def __init__(self): 45 | soundfonts = sorted(glob.glob("soundfonts/?.sf2")) 46 | 47 | # Pre-flight check 48 | if not soundfonts: 49 | print("No soundfont could be found. Fluidsynth cannot start.") 50 | print("Suggestion: 'cd soundfonts; ./download-soundfonts.sh'") 51 | exit(1) 52 | 53 | # Try to detect which sound driver to use. 54 | audio_driver = os.environ.get("GRIODE_AUDIO_DRIVER") 55 | if audio_driver is None: 56 | uid = os.getuid() 57 | pulseaudio_pidfile = "/run/user/{}/pulse/pid".format(uid) 58 | if os.path.isfile(pulseaudio_pidfile): 59 | try: 60 | pulseaudio_pid = int(open(pulseaudio_pidfile).read()) 61 | except: 62 | logging.exception("Could not read pulseaudio PID") 63 | pulseaudio_pid = None 64 | if pulseaudio_pid is not None: 65 | if os.path.isdir("/proc/{}".format(pulseaudio_pid)): 66 | audio_driver = "pulseaudio" 67 | if audio_driver is None: 68 | if sys.platform == "linux": 69 | audio_driver = "alsa" 70 | if sys.platform == "darwin": 71 | audio_driver = "coreaudio" 72 | if audio_driver is None: 73 | logging.error("Could not determine audio driver.") 74 | logging.error("Please set GRIODE_AUDIO_DRIVER.") 75 | exit(1) 76 | logging.info("Using audio driver: {}".format(audio_driver)) 77 | 78 | popen_args = [ 79 | "fluidsynth", "-a", audio_driver, 80 | "-o", "synth.midi-bank-select=mma", 81 | "-o", "synth.sample-rate=44100", 82 | "-c", "8", "-p", "griode" 83 | ] 84 | 85 | # Invoke fluidsynth a first time to enumerate instruments 86 | logging.debug("Invoking fluidsynth to enumerate instruments...") 87 | self.fluidsynth = subprocess.Popen( 88 | popen_args, stdin=subprocess.PIPE, stdout=subprocess.PIPE) 89 | msg = "" 90 | for i, soundfont in enumerate(soundfonts): 91 | font_id = i+1 92 | offset = i*1000 93 | msg += "load {} 1 {}\n".format(soundfont, offset) 94 | msg += "inst {}\n".format(font_id) 95 | self.fluidsynth.stdin.write(msg.encode("ascii")) 96 | self.fluidsynth.stdin.flush() 97 | self.fluidsynth.stdin.close() 98 | output = self.fluidsynth.stdout.read().decode("ascii") 99 | instruments = re.findall("\n([0-9]{3,})-([0-9]{3}) (.*)", output) 100 | self.instruments = [] 101 | for bank, prog, name in instruments: 102 | bank = int(bank) 103 | prog = int(prog) 104 | font_id = bank // 1000 105 | instrument = Instrument(font_id, prog, bank, name) 106 | self.instruments.append(instrument) 107 | logging.info("Found {} instruments".format(len(self.instruments))) 108 | 109 | self.fonts = build_fonts(self.instruments) 110 | 111 | # Re-order the instruments list 112 | # (This is used to cycle through instruments in order) 113 | def get_instrument_order(i): 114 | return (i.font_index, i.program, i.bank_index) 115 | self.instruments.sort(key=get_instrument_order) 116 | 117 | # And now, restart fluidsynth but for actual synth use 118 | logging.debug("Starting fluidsynth as a synthesizer...") 119 | self.fluidsynth = subprocess.Popen( 120 | popen_args, stdin=subprocess.PIPE, stdout=subprocess.PIPE) 121 | self.fluidsynth.stdin.write(msg.encode("ascii")) 122 | self.fluidsynth.stdin.flush() 123 | 124 | # Find the MIDI port created by fluidsynth and open it 125 | logging.debug("Waiting for fluidsynth MIDI port to show up...") 126 | deadline = time.time() + 5 127 | while time.time() < deadline: 128 | port_names = [p for p in mido.get_output_names() if "griode" in p] 129 | if port_names == []: 130 | time.sleep(0.1) 131 | continue 132 | if len(port_names) > 1: 133 | logging.warning("Found more than one port for griode") 134 | self.synth_port = mido.open_output(port_names[0]) 135 | logging.info("Connected to MIDI output {}" 136 | .format(port_names[0])) 137 | break 138 | else: 139 | logging.error("Failed to locate the fluidsynth port!") 140 | exit(1) 141 | 142 | def send(self, message): 143 | self.synth_port.send(message) 144 | 145 | 146 | def classify(list_of_things, get_key): 147 | """Transform a `list_of_things` into a `dict_of_things`. 148 | 149 | Each thing will be put in dict_of_things[k] where k 150 | is obtained by applying the function `get_key` to the thing. 151 | """ 152 | dict_of_things = {} 153 | for thing in list_of_things: 154 | key = get_key(thing) 155 | if key not in dict_of_things: 156 | dict_of_things[key] = [] 157 | dict_of_things[key].append(thing) 158 | return dict_of_things 159 | 160 | 161 | def get_dk_and_font(i): 162 | if i.bank%1000 < 100: 163 | return (False, i.font) 164 | else: 165 | return (True, i.font) 166 | 167 | def get_group(i): 168 | return i.program // 8 169 | 170 | def get_instr(i): 171 | return i.program % 8 172 | 173 | def get_bank(i): 174 | return i.bank 175 | 176 | def build_fonts(instruments): 177 | fonts = classify(instruments, get_dk_and_font) 178 | for dk_and_font, instruments in fonts.items(): 179 | groups = classify(instruments, get_group) 180 | for group, instruments in groups.items(): 181 | instrs = classify(instruments, get_instr) 182 | for instr, instruments in instrs.items(): 183 | banks = classify(instruments, get_bank) 184 | banks = sorted(banks.items()) 185 | # Annotate instruments with the bank_index 186 | for bank_index, (bank_value, instruments) in enumerate(banks): 187 | assert len(instruments) == 1 188 | instruments[0].bank_index = bank_index 189 | instrs[instr] = {instruments[0].bank_index: instruments[0] 190 | for (bank_value, instruments) in banks} 191 | groups[group] = instrs 192 | fonts[dk_and_font] = groups 193 | fonts = sorted(fonts.items()) 194 | 195 | # We could use enumerate() here, but let's try to be readable a bit... 196 | for font_index in range(len(fonts)): 197 | (is_drumkit, fluidsynth_font), font = fonts[font_index] 198 | for group in font.values(): 199 | for instr in group.values(): 200 | for instrument in instr.values(): 201 | instrument.font_index = font_index 202 | instrument.is_drumkit = is_drumkit 203 | 204 | fonts = [font for ((is_drumkit, fs_font), font) in fonts] 205 | fonts = dict(enumerate(fonts)) 206 | return fonts 207 | 208 | # fonts[font_index=0..N][group=0..15][program=0..7][bank_index=0..N] 209 | -------------------------------------------------------------------------------- /gridgets.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from palette import palette 4 | 5 | ############################################################################## 6 | 7 | # And first, a few constants 8 | 9 | ARROWS = "UP DOWN LEFT RIGHT".split() 10 | MENU = "BUTTON_1 BUTTON_2 BUTTON_3 BUTTON_4".split() 11 | 12 | ############################################################################## 13 | 14 | class Surface(object): 15 | 16 | def __init__(self, parent): 17 | # Initialize our "framebuffer" 18 | self.leds = {} 19 | for led in parent: 20 | self.leds[led] = palette.BLACK 21 | # Setup the masked surface 22 | # (By default, it filters out all display) 23 | self.parent = MaskedSurface(parent) 24 | 25 | def __iter__(self): 26 | return self.leds.__iter__() 27 | 28 | def __getitem__(self, led): 29 | return self.leds[led] 30 | 31 | def __setitem__(self, led, color): 32 | if led not in self.leds: 33 | logging.error("LED {} does not exist!".format(led)) 34 | else: 35 | current_color = self.leds[led] 36 | if color != current_color: 37 | self.leds[led] = color 38 | if self.parent: 39 | self.parent[led] = color 40 | 41 | ############################################################################## 42 | 43 | class MaskedSurface(object): 44 | 45 | def __init__(self, parent): 46 | self.parent = parent 47 | self.mask = set() # leds that are ALLOWED 48 | 49 | def __iter__(self): 50 | return self.mask.__iter__() 51 | 52 | def __setitem__(self, led, color): 53 | if led in self.mask: 54 | self.parent[led] = color 55 | 56 | ############################################################################## 57 | 58 | class Gridget(object): 59 | 60 | def pad_pressed(self, row, column, velocity): 61 | pass 62 | 63 | def button_pressed(self, button): 64 | pass 65 | 66 | ############################################################################## 67 | 68 | class Menu(Gridget): 69 | 70 | def __init__(self, grid): 71 | self.grid = grid 72 | self.surface = Surface(grid.surface) 73 | self.menu = dict( 74 | BUTTON_1 = [ 75 | self.grid.loopcontroller, 76 | self.grid.scalepicker, 77 | ], 78 | BUTTON_2 = [ 79 | self.grid.notepickers, 80 | ], 81 | BUTTON_3 = [ 82 | self.grid.instrumentpickers, 83 | self.grid.arpconfigs, 84 | self.grid.latchconfigs, 85 | ], 86 | BUTTON_4 = [ 87 | self.grid.colorpicker, 88 | self.grid.faders, 89 | self.grid.bpmsetter, 90 | ], 91 | ) 92 | self.current = "BUTTON_2" 93 | self.draw() 94 | 95 | def draw(self): 96 | for button in self.menu: 97 | if button == self.current: 98 | self.surface[button] = palette.MENU[1] 99 | else: 100 | self.surface[button] = palette.MENU[0] 101 | 102 | def button_pressed(self, button): 103 | entries = self.menu[button] 104 | if button == self.current: 105 | # Cycle through the entries of one menu 106 | entries.append(entries.pop(0)) 107 | cycle = True 108 | else: 109 | # Switch to another menu 110 | self.current = button 111 | cycle = False 112 | entry = entries[0] 113 | # Resolve the exact gridget 114 | if isinstance(entry, list): 115 | gridget = entry[self.grid.channel] 116 | else: 117 | gridget = entry 118 | # Special case for the notepicker 119 | if len(entries) == 1 and cycle: 120 | gridget.cycle() 121 | else: 122 | self.grid.focus(gridget) 123 | self.draw() 124 | -------------------------------------------------------------------------------- /griode.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import logging 3 | import mido 4 | import os 5 | import time 6 | 7 | 8 | from arpeggiator import ArpConfig, Arpeggiator 9 | from clock import BPMSetter, Clock, CPU 10 | from fluidsynth import Fluidsynth 11 | from latch import Latch, LatchConfig 12 | from looper import Looper, LoopController 13 | from gridgets import MENU, Menu 14 | from mixer import Faders, Mixer 15 | import notes 16 | from palette import palette 17 | from persistence import cache, persistent_attrs, persistent_attrs_init 18 | from pickers import ColorPicker, InstrumentPicker, NotePicker, ScalePicker 19 | import scales 20 | 21 | 22 | log_format = "[%(levelname)s] %(filename)s:%(lineno)d %(funcName)s() -> %(message)s" 23 | log_level = os.environ.get("LOG_LEVEL", "INFO").upper() 24 | logging.basicConfig(level=log_level, format=log_format) 25 | 26 | 27 | @persistent_attrs(key=notes.C, scale=scales.MAJOR) 28 | class Griode(object): 29 | 30 | def __init__(self): 31 | persistent_attrs_init(self) 32 | self.synth = Fluidsynth() 33 | self.devicechains = [DeviceChain(self, i) for i in range(16)] 34 | self.grids = [] 35 | self.cpu = CPU(self) 36 | self.clock = Clock(self) 37 | self.looper = Looper(self) 38 | self.mixer = Mixer(self) 39 | self.detect_devices() 40 | # FIXME: probably make this configurable somehow (env var...?) 41 | if False: 42 | from termpad import ASCIIGrid 43 | self.grids.append(ASCIIGrid(self, 0, 1)) 44 | 45 | def tick(self, tick): 46 | pass 47 | 48 | def detect_devices(self, initial=True): 49 | from launchpad import LaunchpadMK2, LaunchpadPro, LaunchpadS 50 | from keyboard import Keyboard 51 | logging.debug("Enumerating MIDI ports...") 52 | configured_ports = { grid.grid_name for grid in self.grids } 53 | try: 54 | detected_ports = set(mido.get_ioport_names()) 55 | except: 56 | logging.exception("Error while enumerating MIDI ports") 57 | detected_ports = set() 58 | for port_name in detected_ports - configured_ports: 59 | # Detected a new device! Yay! 60 | klass = None 61 | if "Launchpad Pro MIDI 2" in port_name: 62 | klass = LaunchpadPro 63 | if "Launchpad MK2" in port_name: 64 | klass = LaunchpadMK2 65 | if "Launchpad S" in port_name: 66 | klass = LaunchpadS 67 | if "Launchpad Mini" in port_name: 68 | klass = LaunchpadS 69 | if "reface" in port_name: 70 | klass = Keyboard 71 | if klass is not None: 72 | # FIXME find a better way than this for hotplug! 73 | if not initial: 74 | logging.info("Detected hotplug of new device: {}".format(port_name)) 75 | time.sleep(4) 76 | self.grids.append(klass(self, port_name)) 77 | for port_name in configured_ports - detected_ports: 78 | # Removing a device 79 | logging.info("Device {} is no longer plugged. Removing it." 80 | .format(port_name)) 81 | self.grids = [g for g in self.grids if g.grid_name != port_name] 82 | 83 | ############################################################################## 84 | 85 | @persistent_attrs(channel=0) 86 | class Grid(object): 87 | 88 | def __init__(self, griode, grid_name): 89 | self.griode = griode 90 | self.grid_name = grid_name 91 | persistent_attrs_init(self, grid_name) 92 | self.surface_map = {} # maps leds to gridgets 93 | self.colorpicker = ColorPicker(self) 94 | self.faders = Faders(self) 95 | self.bpmsetter = BPMSetter(self) 96 | self.notepickers = [NotePicker(self, i) for i in range(16)] 97 | self.instrumentpickers = [InstrumentPicker(self, i) for i in range(16)] 98 | self.scalepicker = ScalePicker(self) 99 | self.arpconfigs = [ArpConfig(self, i) for i in range(16)] 100 | self.latchconfigs = [LatchConfig(self, i) for i in range(16)] 101 | self.loopcontroller = LoopController(self) 102 | self.menu = Menu(self) 103 | self.focus(self.menu, MENU) 104 | self.focus(self.notepickers[self.channel]) 105 | 106 | def focus(self, gridget, leds=None): 107 | # By default, map the gridget to everything, except MENU 108 | if leds is None: 109 | leds = [led for led in self.surface if led not in MENU] 110 | # For each mapped led ... 111 | for led in leds: 112 | # Unmap the widget(s) that was "owning" that led 113 | if led in self.surface_map: 114 | self.surface_map[led].surface.parent.mask.remove(led) 115 | # Update the map 116 | self.surface_map[led] = gridget 117 | # Map the new widget 118 | gridget.surface.parent.mask.add(led) 119 | # Draw it 120 | self.surface[led] = gridget.surface[led] 121 | 122 | def tick(self, tick): 123 | pass 124 | 125 | ############################################################################## 126 | 127 | @persistent_attrs(font_index=0, group_index=0, instr_index=0, bank_index=0) 128 | class DeviceChain(object): 129 | 130 | def __init__(self, griode, channel): 131 | self.griode = griode 132 | self.channel = channel 133 | persistent_attrs_init(self, str(channel)) 134 | self.latch = Latch(self) 135 | self.arpeggiator = Arpeggiator(self) 136 | self.program_change() 137 | 138 | # The variables `..._index` indicate which instrument is currently selected. 139 | # Note: perhaps this instrument does not exist. In that case, the 140 | # `instrument` property below will fallback to an (existing) one. 141 | @property 142 | def instrument(self): 143 | fonts = self.griode.synth.fonts 144 | groups = fonts.get(self.font_index, fonts[0]) 145 | instrs = groups.get(self.group_index, groups[0]) 146 | banks = instrs.get(self.instr_index, instrs[0]) 147 | instrument = banks.get(self.bank_index, banks[0]) 148 | return instrument 149 | 150 | def program_change(self): 151 | instrument = self.instrument 152 | logging.info("Channel {} switching to instrument B{} P{}: {}" 153 | .format(self.channel, instrument.bank, 154 | instrument.program, instrument.name)) 155 | for message in instrument.messages(): 156 | self.send(message.copy(channel=self.channel)) 157 | 158 | def send(self, message): 159 | self.latch.send(message) 160 | 161 | ############################################################################## 162 | 163 | PATTERN_SAVING = [(1, 1), (1, 2), (2, 1), (2, 2)] 164 | PATTERN_DONE = [(1, 1)] 165 | 166 | def show_pattern(griode, pattern, color_on, color_off): 167 | for grid in griode.grids: 168 | for led in grid.surface: 169 | if led in pattern: 170 | grid.surface[led] = color_on 171 | else: 172 | grid.surface[led] = color_off 173 | 174 | 175 | def main(): 176 | griode = Griode() 177 | try: 178 | while True: 179 | griode.clock.once() 180 | except KeyboardInterrupt: 181 | show_pattern(griode, PATTERN_SAVING, palette.ACTIVE, palette.BLACK) 182 | for db in cache.values(): 183 | db.close() 184 | show_pattern(griode, PATTERN_DONE, palette.ACTIVE, palette.BLACK) 185 | 186 | 187 | 188 | if __name__ == "__main__": 189 | main() 190 | -------------------------------------------------------------------------------- /griode.service: -------------------------------------------------------------------------------- 1 | # This is to be used on a Raspberry Pi, where the code is checked out 2 | # in /home/pi/griode. To install the unit: 3 | # systemctl enable $PWD/griode.service 4 | 5 | [Unit] 6 | Description=Griode 7 | 8 | [Install] 9 | WantedBy=multi-user.target 10 | 11 | [Service] 12 | WorkingDirectory=/home/pi/griode 13 | ExecStart=/home/pi/griode/griode.py 14 | User=pi 15 | Group=audio 16 | Restart=always 17 | 18 | -------------------------------------------------------------------------------- /keyboard.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import mido 3 | 4 | 5 | class Dummy(object): 6 | 7 | def tick(self, tick): 8 | pass 9 | 10 | def draw(self): 11 | pass 12 | 13 | def __getitem__(self, i): 14 | logging.debug("Returning recursive dummy object for key {}".format(i)) 15 | if isinstance(i, int) and i>15: 16 | raise IndexError 17 | return self 18 | 19 | def __setitem__(self, i, v): 20 | pass 21 | 22 | def send(self, *args): 23 | pass 24 | 25 | 26 | class Keyboard(object): 27 | 28 | def __init__(self, griode, port_name): 29 | self.griode = griode 30 | self.port_name = port_name 31 | self.grid_name = port_name # FIXME 32 | self.midi_in = mido.open_input(port_name) 33 | self.midi_in.callback = self.callback 34 | 35 | self.loopcontroller = Dummy() 36 | self.notepickers = Dummy() 37 | self.arpconfigs = Dummy() 38 | self.surface = {} 39 | 40 | def callback(self, message): 41 | logging.debug("{} got message {}".format(self, message)) 42 | self.griode.devicechains[message.channel].send(message) 43 | #self.griode.synth.send(message) 44 | 45 | def tick(self, tick): 46 | pass -------------------------------------------------------------------------------- /latch.py: -------------------------------------------------------------------------------- 1 | import mido 2 | 3 | from gridgets import Surface 4 | from palette import palette 5 | from persistence import persistent_attrs, persistent_attrs_init 6 | 7 | 8 | @persistent_attrs(enabled=False) 9 | class Latch(object): 10 | 11 | def __init__(self, devicechain): 12 | self.devicechain = devicechain 13 | persistent_attrs_init(self, str(devicechain.channel)) 14 | self.notes = set() 15 | 16 | def send(self, message): 17 | if self.enabled and message.type == "note_on": 18 | note = message.note 19 | if message.velocity > 0: 20 | if note not in self.notes: 21 | self.notes.add(note) 22 | self.output(message) 23 | else: 24 | self.notes.remove(note) 25 | self.output(message.copy(velocity=0)) 26 | else: 27 | pass 28 | else: 29 | self.output(message) 30 | 31 | def stop_all(self): 32 | for note in self.notes: 33 | message=mido.Message("note_on", channel=self.devicechain.channel, 34 | note=note, velocity=0) 35 | self.output(message) 36 | self.notes.clear() 37 | 38 | def output(self, message): 39 | self.devicechain.arpeggiator.send(message) 40 | 41 | 42 | class LatchConfig(object): 43 | 44 | def __init__(self, grid, channel): 45 | self.grid = grid 46 | self.channel = channel 47 | self.surface = Surface(grid.surface) 48 | self.draw() 49 | 50 | @property 51 | def latch(self): 52 | return self.grid.griode.devicechains[self.channel].latch 53 | 54 | def draw(self): 55 | self.surface[7, 2] = palette.SWITCH[self.latch.enabled] 56 | 57 | def pad_pressed(self, row, column, velocity): 58 | if velocity == 0: 59 | return 60 | if (row, column) == (7, 2): 61 | if self.latch.enabled: 62 | self.latch.stop_all() 63 | self.latch.enabled = not self.latch.enabled 64 | self.draw() 65 | -------------------------------------------------------------------------------- /launchpad.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import mido 3 | 4 | from gridgets import ARROWS, MENU 5 | from griode import Grid 6 | from palette import palette 7 | 8 | class LaunchPad(Grid): 9 | 10 | def __init__(self, griode, port_name): 11 | logging.info("Opening grid device {}".format(port_name)) 12 | self.grid_in = mido.open_input(port_name) 13 | self.grid_out = mido.open_output(port_name) 14 | for message in self.setup: 15 | self.grid_out.send(message) 16 | self.surface = LPSurface(self) 17 | Grid.__init__(self, griode, port_name) 18 | self.grid_in.callback = self.process_message 19 | 20 | def process_message(self, message): 21 | logging.debug("{} got message {}".format(self, message)) 22 | 23 | # OK this is a hack to use fluidsynth directly with the Launchpad Pro 24 | if getattr(message, "channel", None) == 8: 25 | self.griode.synth.send(message) 26 | return 27 | 28 | # Ignore aftertouch messages for now 29 | if message.type == "polytouch": 30 | return 31 | 32 | # Let's try to find out if the performer pressed a pad/button 33 | led = None 34 | velocity = None 35 | if message.type == "note_on": 36 | led = self.message2led.get(("NOTE", message.note)) 37 | velocity = message.velocity 38 | elif message.type == "control_change": 39 | led = self.message2led.get(("CC", message.control)) 40 | 41 | if led is None: 42 | logging.warning("Unhandled message: {}".format(message)) 43 | return 44 | 45 | gridget = self.surface_map.get(led) 46 | if gridget is None: 47 | logging.warning("Button {} is not routed to any gridget.".format(led)) 48 | return 49 | 50 | if isinstance(led, tuple): 51 | row, column = led 52 | gridget.pad_pressed(row, column, velocity) 53 | elif isinstance(led, str): 54 | # Only emit button_pressed when the button is pressed 55 | # (i.e. not when it is released, which corresponds to value=0) 56 | if message.value == 127: 57 | gridget.button_pressed(led) 58 | 59 | def tick(self, tick): 60 | # This is a hack to work around a bug on the Raspberry Pi. 61 | # Sometimes, when no message has been sent for a while (a 62 | # few seconds), outgoing MIDI messages seem to be delayed, 63 | # causing a perceptible lag in visual feedback. It doesn't 64 | # happen if we keep sending messages continuously. 65 | self.grid_out.send(mido.Message("active_sensing")) 66 | 67 | 68 | class LPSurface(object): 69 | 70 | def __init__(self, launchpad): 71 | self.launchpad = launchpad 72 | 73 | def __iter__(self): 74 | return self.launchpad.led2message.__iter__() 75 | 76 | def __setitem__(self, led, color): 77 | if isinstance(color, int): 78 | logging.warning("Raw color used: launchpad[{}] = {}".format(led, color)) 79 | else: 80 | color = color[self.launchpad.palette] 81 | message_type, parameter = self.launchpad.led2message[led] 82 | if message_type == "NOTE": 83 | message = mido.Message("note_on", note=parameter, velocity=color) 84 | elif message_type == "CC": 85 | message = mido.Message("control_change", control=parameter, value=color) 86 | self.launchpad.grid_out.send(message) 87 | 88 | 89 | class LaunchpadPro(LaunchPad): 90 | 91 | palette = "RGB" 92 | message2led = {} 93 | led2message = {} 94 | for row in range(1, 9): 95 | for column in range(1, 9): 96 | note = 10*row + column 97 | message2led["NOTE", note] = row, column 98 | led2message[row, column] = "NOTE", note 99 | for i, button in enumerate(ARROWS + MENU): 100 | control = 91 + i 101 | message2led["CC", control] = button 102 | led2message[button] = "CC", control 103 | 104 | setup = [ 105 | # This SysEx message switches the LaunchPad Pro to "programmer" mode 106 | mido.Message("sysex", data=[0, 32, 41, 2, 16, 44, 3]), 107 | # And this one sets the front/side LED 108 | mido.Message("sysex", data=[0, 32, 41, 2, 16, 10, 99, 0]), 109 | ] 110 | 111 | 112 | class LaunchpadMK2(LaunchPad): 113 | 114 | palette = "RGB" 115 | message2led = {} 116 | led2message = {} 117 | 118 | for row in range(1, 9): 119 | for column in range(1, 9): 120 | note = 10*row + column 121 | message2led["NOTE", note] = row, column 122 | led2message[row, column] = "NOTE", note 123 | for i, button in enumerate(ARROWS + MENU): 124 | control = 104 + i 125 | message2led["CC", control] = button 126 | led2message[button] = "CC", control 127 | 128 | setup = [] 129 | 130 | 131 | class LaunchpadS(LaunchPad): 132 | 133 | palette = "RG" 134 | message2led = {} 135 | led2message = {} 136 | 137 | for row in range(1, 9): 138 | for column in range(1, 9): 139 | note = 16*(8-row) + column-1 140 | message2led["NOTE", note] = row, column 141 | led2message[row, column] = "NOTE", note 142 | for i, button in enumerate(ARROWS + MENU): 143 | control = 104 + i 144 | message2led["CC", control] = button 145 | led2message[button] = "CC", control 146 | 147 | setup = [] 148 | -------------------------------------------------------------------------------- /looper.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import mido 3 | import time 4 | 5 | import colors 6 | from gridgets import Gridget, Surface 7 | from palette import palette 8 | from persistence import persistent_attrs, persistent_attrs_init 9 | 10 | 11 | on_off_colors = palette.SWITCH 12 | 13 | 14 | class Note(object): 15 | 16 | def __init__(self, note, velocity, duration): 17 | self.note = note 18 | self.velocity = velocity 19 | self.duration = duration 20 | 21 | def __repr__(self): 22 | return ("Note(note={}, velocity={}, duration={})" 23 | .format(self.note, self.velocity, self.duration)) 24 | 25 | 26 | @persistent_attrs( 27 | notes={}, channel=None, tick_in=0, tick_out=0, teach_interval=0) 28 | class Loop(object): 29 | 30 | def __init__(self, looper, cell): 31 | self.looper = looper 32 | self.cell = cell 33 | persistent_attrs_init(self, "{},{}".format(*cell)) 34 | self.next_tick = 0 # next "position" to be played in self.notes 35 | 36 | def __repr__(self): 37 | return "Loop({})".format(self.cell) 38 | 39 | 40 | class Flash(Gridget): 41 | 42 | def __init__(self, grid): 43 | self.grid = grid 44 | self.surface = Surface(grid.surface) 45 | 46 | def flash(self, color): 47 | for led in self.surface: 48 | if isinstance(led, tuple): 49 | self.surface[led] = color 50 | self.grid.focus(self) 51 | time.sleep(0.3) 52 | self.grid.focus(self.grid.notepickers[self.grid.channel]) 53 | 54 | 55 | class Teacher(object): 56 | 57 | def __init__(self, looper): 58 | self.looper = looper 59 | self.teacher_loop = None 60 | self.student_loop = Loop(looper, "student") 61 | self.phase = "STOP" # "TEACHER" "STUDENT" 62 | 63 | def select(self, loop): 64 | self.teacher_loop = loop 65 | self.student_loop.channel = loop.channel 66 | for grid in self.looper.griode.grids: 67 | grid.channel = loop.channel 68 | grid.focus(grid.notepickers[grid.channel]) 69 | grid.flash = Flash(grid) # FIXME 70 | self.tick_interval = loop.teach_interval or 24 * 8 71 | self.tick_in = 0 72 | self.tick_out = self.tick_in + self.tick_interval 73 | loop.tick_in = 0 74 | loop.tick_out = 0 # Do not loop that! 75 | self.flash(colors.YELLOW) 76 | self.teacher() 77 | 78 | def flash(self, color): 79 | for grid in self.looper.griode.grids: 80 | grid.flash.flash(color) 81 | 82 | def stop(self): 83 | self.phase = "STOP" 84 | logging.debug("phase=STOP") 85 | self.looper.playing = False 86 | self.looper.loops_playing.clear() 87 | self.looper.loops_recording.clear() 88 | 89 | def teacher(self): 90 | self.phase = "TEACHER" 91 | logging.debug("phase=TEACHER") 92 | self.looper.playing = False 93 | self.looper.loops_recording.clear() 94 | self.looper.loops_playing.clear() 95 | self.looper.loops_playing.add(self.teacher_loop) 96 | self.teacher_loop.next_tick = self.tick_in 97 | self.teacher_notes = [] 98 | for tick in range(self.tick_in, self.tick_out): 99 | for note in self.teacher_loop.notes.get(tick, []): 100 | self.teacher_notes.append(note.note) 101 | if self.teacher_notes == []: 102 | # A silence long enough will be interpreted as end of song 103 | self.stop() 104 | else: 105 | self.flash(palette.BLACK) 106 | self.looper.playing = True 107 | 108 | def student(self): 109 | self.phase = "STUDENT" 110 | logging.debug("phase=STUDENT") 111 | self.looper.playing = False 112 | self.student_loop.notes.clear() 113 | self.student_loop.next_tick = 0 114 | self.looper.loops_recording.add(self.student_loop) 115 | self.looper.loops_playing.clear() 116 | self.flash(colors.YELLOW) 117 | self.looper.playing = True 118 | 119 | def tick(self, tick): 120 | if self.phase == "TEACHER": 121 | if self.teacher_loop.next_tick >= self.tick_out: 122 | self.student() 123 | if self.phase == "STUDENT": 124 | # Once per beat, check how we did in this loop 125 | if tick%24 == 0: 126 | student_notes = [] 127 | for tick in self.student_loop.notes: 128 | for note in self.student_loop.notes.get(tick, []): 129 | if note.duration > 0: 130 | student_notes.append(note.note) 131 | if student_notes == self.teacher_notes: 132 | # Yay! 133 | logging.info("Got the right notes!") 134 | self.flash(colors.GREEN) 135 | self.tick_in += self.tick_interval 136 | self.tick_out += self.tick_interval 137 | self.teacher() 138 | elif (any(x!=y for x, y in zip(self.teacher_notes, student_notes)) 139 | or 140 | len(student_notes) > len(self.teacher_notes) 141 | or 142 | self.student_loop.next_tick >= 2*self.tick_interval): 143 | # Bzzzt wrong 144 | logging.info("Bzzzt try again!") 145 | logging.info("Teacher notes: {}" 146 | .format(self.teacher_notes)) 147 | logging.info("Student notes: {}" 148 | .format(student_notes)) 149 | self.flash(colors.RED) 150 | self.teacher() 151 | 152 | 153 | @persistent_attrs(beats_per_bar=4) 154 | class Looper(object): 155 | 156 | def __init__(self, griode): 157 | self.griode = griode 158 | persistent_attrs_init(self) 159 | self.playing = False 160 | self.last_tick = 0 # Last (=current) tick 161 | self.loops_playing = set() # Contains instances of Loop 162 | self.loops_recording = set() # Also instances of Loop 163 | self.notes_recording = {} # note -> (Note(), tick_when_started) 164 | self.notes_playing = [] # (stop_tick, channel, note) 165 | self.loops = {} 166 | self.teacher = Teacher(self) 167 | for row in range(1, 9): 168 | for column in range(1, 9): 169 | self.loops[row, column] = Loop(self, (row, column)) 170 | 171 | def send(self, message): 172 | if self.playing and message.type == "note_on": 173 | for loop in self.loops_recording: 174 | if loop.channel == message.channel: 175 | if message.velocity > 0: 176 | note = Note(message.note, message.velocity, 0) 177 | logging.info("recording {}...".format(note)) 178 | if loop.next_tick not in loop.notes: 179 | loop.notes[loop.next_tick] = [] 180 | loop.notes[loop.next_tick].append(note) 181 | self.notes_recording[message.note] = (note, self.last_tick) 182 | else: 183 | note, tick_started = self.notes_recording.pop(message.note) 184 | note.duration = self.last_tick - tick_started 185 | logging.info("recorded {}".format(note)) 186 | # No matter what: let the message through the chain 187 | self.output(message) 188 | 189 | def output(self, message): 190 | channel = message.channel 191 | devicechain = self.griode.devicechains[channel] 192 | devicechain.send(message) 193 | 194 | def tick(self, tick): 195 | self.last_tick = tick 196 | # First, check if there are notes that should be stopped. 197 | notes_to_stop = [note for note in self.notes_playing if note[0] <= tick] 198 | for note in notes_to_stop: 199 | message = mido.Message( 200 | "note_on", channel=note[1], note=note[2], velocity=0) 201 | self.output(message) 202 | self.notes_playing.remove(note) 203 | # Light off notepickers 204 | for grid in self.griode.grids: 205 | grid.notepickers[note[1]].send(message, self) 206 | # Only play stuff if we are really playing (i.e. not paused) 207 | if not self.playing: 208 | return 209 | # OK now, for each loop that is playing... 210 | for loop in self.loops_playing: 211 | # Figure out which notes should be started *now* 212 | for note in loop.notes.get(loop.next_tick, []): 213 | logging.info("Play {} from {}".format(note, loop)) 214 | self.notes_playing.append( 215 | (tick+note.duration, loop.channel, note.note)) 216 | message = mido.Message( 217 | "note_on", channel=loop.channel, 218 | note=note.note, velocity=note.velocity) 219 | self.output(message) 220 | # Light up notepickers 221 | for grid in self.griode.grids: 222 | grid.notepickers[loop.channel].send(message, self) 223 | # Advance each loop that is currently playing or recording 224 | for loop in self.loops_playing | self.loops_recording: 225 | loop.next_tick += 1 226 | # If we're past the end of the loop, jump to begin of loop 227 | if loop.tick_out > 0 and loop.next_tick >= loop.tick_out: 228 | loop.next_tick = loop.tick_in 229 | # Teacher logic 230 | self.teacher.tick(tick) 231 | 232 | class LoopController(Gridget): 233 | 234 | """ 235 | ^ v < > = PLAY REC REWIND PLAY/PAUSE 236 | Then the 64 pads are available for loops 237 | 238 | Press more than 1 second on a pad to edit it 239 | Press less than 1 second to select/deselect it for play/rec 240 | """ 241 | 242 | def __init__(self, grid): 243 | self.grid = grid 244 | self.surface = Surface(grid.surface) 245 | self.loopeditor = LoopEditor(grid) 246 | self.stepsequencer = StepSequencer(grid) 247 | self.mode = "LEARN" # or "REC" or "PLAY" 248 | self.pads_held = {} # maps pad to time when pressed 249 | self.draw() 250 | 251 | @property 252 | def looper(self): 253 | return self.grid.griode.looper 254 | 255 | def blink(self, color, play, rec): 256 | tick = self.looper.last_tick 257 | # If play: slow blink (once per quarter note) 258 | # If rec: fast blink (twice per quarter note) 259 | # If play and rec: alternate slow and fast blink 260 | if play and rec: 261 | if tick % 48 > 24: 262 | rec = False 263 | else: 264 | play = False 265 | if play: 266 | if tick % 24 > 18: 267 | return palette.BLACK 268 | else: 269 | return color 270 | if rec: 271 | if tick % 12 > 4: 272 | return palette.BLACK 273 | else: 274 | return color 275 | return color 276 | 277 | def draw(self): 278 | for led in self.surface: 279 | if isinstance(led, tuple): 280 | color = colors.GREY_LO 281 | loop = self.looper.loops[led] 282 | if loop.channel is not None: 283 | color = palette.CHANNEL[loop.channel] 284 | # If that loop is selected for play or rec, show it 285 | # (With fancy blinking) 286 | color = self.blink( 287 | color, 288 | loop in self.looper.loops_playing, 289 | loop in self.looper.loops_recording) 290 | self.surface[led] = color 291 | # UP = playback, DOWN = record 292 | self.surface["UP"] = on_off_colors[self.mode == "PLAY"] 293 | self.surface["DOWN"] = on_off_colors[self.mode == "REC"] 294 | self.surface["UP"] = self.blink( 295 | self.surface["UP"], self.looper.loops_playing, False) 296 | self.surface["DOWN"] = self.blink( 297 | self.surface["DOWN"], False, self.looper.loops_recording) 298 | # LEFT = rewind all loops (but keep playing if we're playing) 299 | # (but also used to delete a loop!) 300 | self.surface["LEFT"] = on_off_colors[bool(self.pads_held)] 301 | # RIGHT = play/pause 302 | self.surface["RIGHT"] = on_off_colors[self.looper.playing] 303 | 304 | def tick(self, tick): 305 | for cell, time_pressed in self.pads_held.items(): 306 | if time.time() > time_pressed + 1.0: 307 | self.pads_held.clear() 308 | # Enter edit mode for that pad 309 | loop = self.looper.loops[cell] 310 | self.loopeditor.loop = loop 311 | self.grid.focus(self.loopeditor) 312 | break 313 | self.draw() 314 | self.loopeditor.draw() 315 | self.stepsequencer.draw() 316 | 317 | def pad_pressed(self, row, column, velocity): 318 | # We don't act when the pad is pressed, but when it is released. 319 | # (When the pad is pressed, we record the time, so we can later 320 | # detect when a pad is held more than 1s to enter edit mode.) 321 | if velocity > 0: 322 | self.pads_held[row, column] = time.time() 323 | return 324 | # When pad is released, if "something" removed it from the 325 | # pads_held dict, ignore the action. 326 | if (row, column) not in self.pads_held: 327 | return 328 | del self.pads_held[row, column] 329 | 330 | if self.mode == "PLAY": 331 | loop = self.looper.loops[row, column] 332 | # Does that loop actually exist? 333 | if loop.channel is not None: 334 | if loop in self.looper.loops_playing: 335 | self.looper.loops_playing.remove(loop) 336 | else: 337 | self.looper.loops_playing.add(loop) 338 | 339 | if self.mode == "REC": 340 | loop = self.looper.loops[row, column] 341 | # If we tapped an empty cell, create a new loop 342 | if loop.channel is None: 343 | loop.channel = self.grid.channel 344 | if loop in self.looper.loops_recording: 345 | self.looper.loops_recording.remove(loop) 346 | else: 347 | self.looper.loops_recording.add(loop) 348 | # FIXME: stop recording other loops on the same channel 349 | 350 | if self.mode == "LEARN": 351 | loop = self.looper.loops[row, column] 352 | if loop.channel is not None: 353 | self.looper.teacher.select(loop) 354 | 355 | # Update all loopcontrollers to show new state 356 | for grid in self.grid.griode.grids: 357 | grid.loopcontroller.draw() 358 | 359 | def button_pressed(self, button): 360 | if button == "UP": 361 | self.mode = "PLAY" 362 | if button == "DOWN": 363 | self.mode = "REC" 364 | if button == "LEFT": 365 | if self.pads_held: 366 | # Delete! 367 | for cell in self.pads_held: 368 | # OK, this is hackish. 369 | # We can't easily wipe out an object from the persistence 370 | # system, so we re-initialize it to empty values instead. 371 | loop = self.looper.loops[cell] 372 | loop.channel = None 373 | loop.tick_in = loop.tick_out = 0 374 | loop.notes.clear() 375 | self.looper.loops_playing.discard(loop) 376 | self.looper.loops_recording.discard(loop) 377 | self.pads_held.clear() 378 | else: 379 | # Rewind 380 | for loop in self.looper.loops_playing: 381 | loop.next_tick = loop.tick_in 382 | for loop in self.looper.loops_recording: 383 | loop.next_tick = loop.tick_in 384 | # FIXME should we also undo the last recording? 385 | if button == "RIGHT": 386 | if self.looper.playing: 387 | for loop in self.looper.loops_recording: 388 | # Save! Save all the things! 389 | loop.db.sync() 390 | # I'm not sure that this logic should be here, 391 | # but it should be somewhere, so here we go... 392 | # When stopping, if any loop doesn't have a 393 | # tick_out point, add one. (By rounding up to 394 | # the end of the bar.) 395 | if loop.tick_out == 0: 396 | loop.tick_out = 24 * ((loop.tick_out + 23) // 24) 397 | # And then toggle the playing flag. 398 | self.looper.playing = not self.looper.playing 399 | for grid in self.grid.griode.grids: 400 | grid.loopcontroller.draw() 401 | 402 | 403 | class CellPicker(Gridget): 404 | 405 | def __init__(self, grid): 406 | self.grid = grid 407 | self.surface = Surface(grid.surface) 408 | self.ticks_per_cell = 12 409 | self._loop = None 410 | 411 | @property 412 | def loop(self): 413 | return self._loop 414 | 415 | @loop.setter 416 | def loop(self, value): 417 | self._loop = value 418 | self.draw() 419 | 420 | def rc2cell(self, row, column): 421 | # Map row,column to a cell number (starting at zero) 422 | return (8-row)*8 + column-1 423 | 424 | def rc2ticks(self, row, column): 425 | # Return list of ticks in a given cell 426 | cell = self.rc2cell(row, column) 427 | return range(cell*self.ticks_per_cell, (cell+1)*self.ticks_per_cell) 428 | 429 | 430 | class LoopEditor(CellPicker): 431 | 432 | def __init__(self, grid): 433 | super().__init__(grid) 434 | self.action = None 435 | 436 | def draw(self): 437 | if self.loop is None: 438 | return 439 | for led in self.surface: 440 | if isinstance(led, tuple): 441 | row, column = led 442 | color = palette.BLACK 443 | ticks = self.rc2ticks(row, column) 444 | for tick in ticks: 445 | if tick in self.loop.notes: 446 | color = colors.GREY_LO 447 | if self.loop.looper.playing: 448 | if self.loop in (self.loop.looper.loops_playing | 449 | self.loop.looper.loops_recording): 450 | if self.loop.next_tick in ticks: 451 | color = palette.CHANNEL[self.loop.channel] 452 | if self.loop.tick_in in ticks: 453 | color = colors.PINK_HI 454 | if self.loop.tick_out-1 in ticks: 455 | color = colors.PINK_HI 456 | self.surface[led] = color 457 | 458 | def pad_pressed(self, row, column, velocity): 459 | if velocity == 0: 460 | return 461 | if self.action == "SET_TICK_IN": 462 | tick = self.rc2ticks(row, column)[0] 463 | logging.info("Moving tick_in for loop {} to {}" 464 | .format(self.loop, tick)) 465 | self.loop.tick_in = tick 466 | self.action = None 467 | elif self.action == "SET_TICK_OUT": 468 | tick = self.rc2ticks(row, column)[-1] + 1 469 | logging.info("Moving tick_out for loop {} to {}" 470 | .format(self.loop, tick)) 471 | self.loop.tick_out = tick 472 | self.action = None 473 | else: 474 | ticks = self.rc2ticks(row, column) 475 | if self.loop.tick_out == 0 and 0 in ticks: 476 | logging.info("Action is now SET_TICK_OUT (initial)") 477 | self.action = "SET_TICK_OUT" 478 | elif self.loop.tick_out-1 in ticks: 479 | logging.info("Action is now SET_TICK_OUT") 480 | self.action = "SET_TICK_OUT" 481 | elif self.loop.tick_in in ticks: 482 | logging.info("Action is now SET_TICK_IN") 483 | self.action = "SET_TICK_IN" 484 | else: 485 | for tick in ticks: 486 | for note in self.loop.notes.get(tick, []): 487 | # FIXME: Send note_off message when the pad is released 488 | message = mido.Message( 489 | "note_on", channel=self.loop.channel, 490 | note=note.note, velocity=note.velocity) 491 | self.grid.griode.synth.send(message) 492 | self.grid.griode.synth.send(message.copy(velocity=0)) 493 | self.draw() 494 | 495 | def button_pressed(self, button): 496 | if button == "UP": 497 | self.grid.focus(self.grid.loopcontroller) 498 | if button == "DOWN": 499 | self.grid.loopcontroller.stepsequencer.loop = self.loop # FIXME urgh 500 | self.grid.focus(self.grid.loopcontroller.stepsequencer) 501 | 502 | class StepSequencer(CellPicker): 503 | 504 | def __init__(self, grid): 505 | super().__init__(grid) 506 | self.note = None 507 | 508 | @property 509 | def notepicker(self): 510 | return self.grid.notepickers[self.grid.channel] 511 | 512 | def draw(self): 513 | if self.loop is None: 514 | return 515 | for led in self.surface: 516 | if isinstance(led, tuple): 517 | row, column = led 518 | if row in [1, 2, 3, 4]: 519 | color = self.notepicker.surface[led] 520 | if self.note is not None: 521 | if self.note == self.notepicker.led2note[row, column]: 522 | color = colors.PINK_HI 523 | else: 524 | color = palette.BLACK 525 | ticks = self.rc2ticks(row, column) 526 | # Show if there are notes in this cell 527 | has_first = bool(self.loop.notes.get(ticks[0])) 528 | has_other = any(bool(self.loop.notes.get(tick)) 529 | for tick in ticks[1:]) 530 | if has_first and has_other: 531 | color = colors.GREY_LO 532 | if has_first and not has_other: 533 | color = colors.WHITE 534 | if not has_first and has_other: 535 | color = colors.GREY_LO 536 | # And now, override that color if the current note is there 537 | for note in self.loop.notes.get(ticks[0], []): 538 | if note.note == self.note: 539 | color = colors.PINK_HI 540 | # But override even more to show the current play position 541 | if self.loop.looper.playing: 542 | if self.loop in (self.loop.looper.loops_playing | 543 | self.loop.looper.loops_recording): 544 | if self.loop.next_tick in ticks: 545 | color = colors.AMBER_HI 546 | self.surface[led] = color 547 | 548 | def pad_pressed(self, row, column, velocity): 549 | if row in [1, 2, 3, 4]: 550 | self.notepicker.pad_pressed(row, column, velocity) 551 | self.note = self.notepicker.led2note[row, column] 552 | self.draw() 553 | else: 554 | if velocity == 0: 555 | return 556 | if self.note is None: 557 | return 558 | ticks = self.rc2ticks(row, column) 559 | for note in self.loop.notes.get(ticks[0], []): 560 | if note.note == self.note: 561 | self.loop.notes[ticks[0]].remove(note) 562 | break 563 | else: 564 | if ticks[0] not in self.loop.notes: 565 | self.loop.notes[ticks[0]] = [] 566 | self.loop.notes[ticks[0]].append( 567 | Note(note=self.note, velocity=velocity, duration=self.ticks_per_cell)) 568 | 569 | def button_pressed(self, button): 570 | if button == "UP": 571 | self.grid.focus(self.grid.loopcontroller.loopeditor) 572 | -------------------------------------------------------------------------------- /mid2loop.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import mido 4 | import sys 5 | 6 | from looper import Loop, Note 7 | 8 | # Syntax: mid2loop 9 | 10 | midi_file = mido.MidiFile(filename=sys.argv[1]) 11 | cell = sys.argv[2:4] 12 | bars = int(sys.argv[4]) 13 | 14 | loop = Loop(looper=None, cell=cell) 15 | 16 | loop.notes.clear() 17 | loop.channel = 3 18 | 19 | def quantize(t, q): 20 | return q*round(t/q) 21 | 22 | def time2tick(t): 23 | return quantize(24 * t / tempo * 1000000, 6) 24 | 25 | now = 0 26 | tempo = 0 # microseconds per quarter note 27 | notes = {} # note -> start_time 28 | for message in midi_file: 29 | now += message.time 30 | if message.type == "time_signature": 31 | print("# time signature: {}/{}".format(message.numerator, message.denominator)) 32 | loop.teach_interval = bars * 24 * message.numerator 33 | if message.type == "set_tempo": 34 | tempo = message.tempo 35 | if message.type == "note_on" and message.velocity > 0: 36 | notes[message.note] = now 37 | elif message.type in ("note_on", "note_off"): 38 | start_time = notes.pop(message.note, None) 39 | if start_time is None: 40 | print("# unmatched note: {}".format(message.note)) 41 | else: 42 | duration = now - start_time 43 | start_tick = time2tick(start_time) 44 | duration_tick = time2tick(duration) 45 | print(message.note, start_tick, duration_tick) 46 | if start_tick not in loop.notes: 47 | loop.notes[start_tick] = [] 48 | loop.notes[start_tick].append(Note(message.note, 108, duration_tick)) 49 | 50 | from persistence import cache 51 | for db in cache.values(): 52 | db.close() 53 | 54 | -------------------------------------------------------------------------------- /mid2loop.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | for N in $(seq 1 8); 3 | do 4 | [ -f songs/$N.mid ] || continue 5 | ./mid2loop.py songs/$N.mid 1 $N 2 6 | ./mid2loop.py songs/$N.mid 2 $N 4 7 | done 8 | -------------------------------------------------------------------------------- /mixer.py: -------------------------------------------------------------------------------- 1 | import enum 2 | import logging 3 | import mido 4 | 5 | from gridgets import Gridget, Surface 6 | from palette import palette 7 | from persistence import persistent_attrs, persistent_attrs_init 8 | 9 | 10 | class Page(enum.Enum): 11 | VOLUME = 1 12 | CHORUS = 2 13 | REVERB = 3 14 | 15 | 16 | @persistent_attrs(volume=16*[96], chorus=16*[0], reverb=16*[0]) 17 | class Mixer(object): 18 | 19 | def __init__(self, griode): 20 | self.griode = griode 21 | persistent_attrs_init(self) 22 | # FIXME don't duplicate the CC mappings 23 | for cc, array in [ 24 | (7, self.volume), 25 | (91, self.chorus), 26 | (93, self.reverb), 27 | ]: 28 | for channel, value in enumerate(array): 29 | m = mido.Message("control_change", control=cc, value=value) 30 | self.griode.devicechains[channel].send(m) 31 | 32 | 33 | class Faders(Gridget): 34 | 35 | def __init__(self, grid): 36 | self.grid = grid 37 | self.surface = Surface(grid.surface) 38 | self.page = Page.VOLUME 39 | self.first_channel = 0 40 | self.draw() 41 | 42 | @property 43 | def mixer(self): 44 | return self.grid.griode.mixer 45 | 46 | @property 47 | def array(self): 48 | if self.page == Page.VOLUME: 49 | return self.mixer.volume 50 | if self.page == Page.CHORUS: 51 | return self.mixer.chorus 52 | if self.page == Page.REVERB: 53 | return self.mixer.reverb 54 | 55 | @property 56 | def cc(self): 57 | if self.page == Page.VOLUME: 58 | return 7 59 | if self.page == Page.CHORUS: 60 | return 91 61 | if self.page == Page.REVERB: 62 | return 93 63 | 64 | def draw(self): 65 | for led in self.surface: 66 | if isinstance(led, tuple): 67 | row, column = led 68 | channel = self.first_channel + column - 1 69 | value = self.array[channel] 70 | n_leds = (value+16)//16 71 | color = palette.BLACK 72 | if row <= n_leds: 73 | color = palette.CHANNEL[channel] 74 | self.surface[led] = color 75 | 76 | def pad_pressed(self, row, column, velocity): 77 | if velocity == 0: 78 | return 79 | channel = self.first_channel + column - 1 80 | value = 127*(row-1)//7 81 | self.array[channel] = value 82 | logging.info("Setting {} for channel {} to {}" 83 | .format(self.page, channel, value)) 84 | message = mido.Message( 85 | "control_change", channel=channel, 86 | control=self.cc, value=value) 87 | self.grid.griode.synth.send(message) 88 | self.draw() 89 | 90 | def button_pressed(self, button): 91 | if button == "LEFT": 92 | self.first_channel = 0 93 | if button == "RIGHT": 94 | self.first_channel = 8 95 | if button == "UP": 96 | self.page = Page(self.page.value-1) 97 | if button == "DOWN": 98 | self.page = Page(self.page.value+1) 99 | self.draw() 100 | -------------------------------------------------------------------------------- /notes.py: -------------------------------------------------------------------------------- 1 | C = 0 2 | Csharp = 1 3 | Dflat = 1 4 | D = 2 5 | Dsharp = 3 6 | Eflat = 3 7 | E = 4 8 | F = 5 9 | Fsharp = 6 10 | Gflat = 6 11 | G = 7 12 | Gsharp = 8 13 | Aflat = 8 14 | A = 9 15 | Asharp = 10 16 | Bflat = 10 17 | B = 11 18 | -------------------------------------------------------------------------------- /palette.py: -------------------------------------------------------------------------------- 1 | # The file palette.yaml contains two sections: 2 | # PALETTES 3 | # COLORS 4 | # 5 | # PALETTES contains color maps for various devices. 6 | # Currently, there is RGB and RG (the latter is for the 7 | # Launchpad Mini and Launchpad S, which only have red 8 | # and green leds, and cannot achieve a full palette). 9 | # 10 | # COLORS contains arrays of names of colors to be used 11 | # in the code. For each virtual color, there is one 12 | # entry per palette, indicating how to render that 13 | # virtual color with the corresponding palette. 14 | # For instance: 15 | # POWERINDICATOR: 16 | # RGB: [ RED, GREEN ] 17 | # RG : [ R3G0, R0G3 ] 18 | # In the code, we would do: 19 | # 20 | # from palette import palette 21 | # grid[(x,y)] = palette.POWERINDICATOR[0] 22 | # 23 | # When rendering on a RGB device, it will use RED, 24 | # and when rendering on a RG device, it will use R3G0. 25 | 26 | import yaml 27 | 28 | 29 | class Palette(object): 30 | 31 | def __init__(self, data): 32 | for color_name, color_data in data["COLORS"].items(): 33 | cycle = dict() 34 | # Store the color name. 35 | # (We don't use it, but it could help with debugging.) 36 | cycle[""] = color_name 37 | for real_palette_name, real_colors_names in color_data.items(): 38 | for i, real_color_name in enumerate(real_colors_names): 39 | if i not in cycle: 40 | cycle[i] = dict() 41 | cycle[i][""] = i 42 | real_palette = data["PALETTES"][real_palette_name] 43 | cycle[i][real_palette_name] = real_palette[real_color_name] 44 | # This allows to use palette.FOO to access palette.FOO[0] 45 | for palette in cycle[0]: 46 | if palette != "": 47 | cycle[palette] = cycle[0][palette] 48 | setattr(self, color_name, cycle) 49 | 50 | 51 | data = yaml.safe_load(open("palette.yaml")) 52 | 53 | palette = Palette(data) 54 | 55 | def test(): 56 | print("palette.ROOT[0][RGB] = ", palette.ROOT[0]["RGB"]) 57 | print("palette.MENU[1][RG] = ", palette.MENU[1]["RG"]) 58 | 59 | if __name__ == "__main__": 60 | test() 61 | -------------------------------------------------------------------------------- /palette.yaml: -------------------------------------------------------------------------------- 1 | # See palette.py for an explanation of this format. 2 | 3 | PALETTES: 4 | # Palette for the Launchpad RGB and Launchpad Pro 5 | RGB: 6 | BLACK: 0 7 | GREY_LO: 1 8 | GREY_MD: 2 9 | WHITE: 3 10 | ROSE: 4 11 | RED_HI: 5 12 | RED: 6 13 | RED_LO: 7 14 | RED_AMBER: 8 15 | AMBER_HI: 9 16 | AMBER: 10 17 | AMBER_LO: 11 18 | AMBER_YELLOW: 12 19 | YELLOW_HI: 13 20 | YELLOW: 14 21 | YELLOW_LO: 15 22 | YELLOW_LIME: 16 23 | LIME_HI: 17 24 | LIME: 18 25 | LIME_LO: 19 26 | LIME_GREEN: 20 27 | GREEN_HI: 21 28 | GREEN: 22 29 | GREEN_LO: 23 30 | GREEN_SPRING: 24 31 | SPRING_HI: 25 32 | SPRING: 26 33 | SPRING_LO: 27 34 | SPRING_TURQUOISE: 28 35 | TURQUOISE_LO: 29 36 | TURQUOISE: 30 37 | TURQUOISE_HI: 31 38 | TURQUOISE_CYAN: 32 39 | CYAN_HI: 33 40 | CYAN: 34 41 | CYAN_LO: 35 42 | CYAN_SKY: 36 43 | SKY_HI: 37 44 | SKY: 38 45 | SKY_LO: 39 46 | SKY_OCEAN: 40 47 | OCEAN_HI: 41 48 | OCEAN: 42 49 | OCEAN_LO: 43 50 | OCEAN_BLUE: 44 51 | BLUE_HI: 45 52 | BLUE: 46 53 | BLUE_LO: 47 54 | BLUE_ORCHID: 48 55 | ORCHID_HI: 49 56 | ORCHID: 50 57 | ORCHID_LO: 51 58 | ORCHID_MAGENTA: 52 59 | MAGENTA_HI: 53 60 | MAGENTA: 54 61 | MAGENTA_LO: 55 62 | MAGENTA_PINK: 56 63 | PINK_HI: 57 64 | PINK: 58 65 | PINK_LO: 59 66 | 67 | # Palette for the Launchpad S and Launchpad Mini 68 | # (It just has red and green leds, and they can 69 | # have 4 intensity levels, where 0=OFF 3=MAX.) 70 | RG: 71 | R0G0: 0 72 | R1G0: 1 73 | R2G0: 2 74 | R3G0: 3 75 | R0G1: 16 76 | R1G1: 17 77 | R2G1: 18 78 | R3G1: 19 79 | R0G2: 32 80 | R1G2: 33 81 | R2G2: 34 82 | R3G2: 35 83 | R0G3: 48 84 | R1G3: 49 85 | R2G3: 50 86 | R3G3: 51 87 | 88 | COLORS: 89 | 90 | BLACK: 91 | RGB: [ BLACK ] 92 | RG: [ R0G0 ] 93 | 94 | ACTIVE: 95 | RGB: [ PINK_HI ] 96 | RG: [ R3G3 ] 97 | 98 | # CHANNEL = colors of the 16 MIDI channels. 99 | # For the RG palette, we use the same 8 colors twice. 100 | CHANNEL: &channels 101 | RGB: [ RED_HI, AMBER_HI, YELLOW_HI, GREEN_HI, 102 | SKY_HI, BLUE_HI, ORCHID_HI, MAGENTA_HI, 103 | RED_LO, AMBER_LO, YELLOW_LO, GREEN_LO, 104 | SKY_LO, BLUE_LO, ORCHID_LO, MAGENTA_LO ] 105 | RG: [ R3G0, R3G2, R2G3, R0G3, 106 | R2G0, R2G1, R1G2, R0G2, 107 | R3G0, R3G2, R2G3, R0G3, 108 | R2G0, R2G1, R1G2, R0G2 ] 109 | 110 | # ROOT = colors to use for the root key of the scale. 111 | # For now, it is the same color as the channel. 112 | ROOT: *channels 113 | 114 | # Which color to use to displays notes that are within 115 | # the scale. It is a paler color. 116 | INSCALE: 117 | RGB: [ GREY_MD, GREY_MD, GREY_MD, GREY_MD, 118 | GREY_MD, GREY_MD, GREY_MD, GREY_MD, 119 | GREY_MD, GREY_MD, GREY_MD, GREY_MD, 120 | GREY_MD, GREY_MD, GREY_MD, GREY_MD ] 121 | RG: [ R0G1, R0G1, R1G0, R1G0, 122 | R0G1, R0G1, R1G0, R1G0, 123 | R0G1, R0G1, R1G0, R1G0, 124 | R0G1, R0G1, R1G0, R1G0 ] 125 | 126 | # Which color to use to indicate notes being played. 127 | # The first color = notes played by us, the second 128 | # color = notes played by other mechanisms. 129 | PLAY: 130 | RGB: [ WHITE, AMBER ] 131 | RG: [ R3G3, R1G1 ] 132 | 133 | # The following colors are OFF/ON for various sections. 134 | MENU: 135 | RGB: [ ROSE, PINK_HI ] 136 | RG: [ R1G0, R3G0 ] 137 | 138 | SWITCH: 139 | RGB: [ ROSE, PINK_HI ] 140 | RG: [ R1G0, R3G0 ] 141 | 142 | # Instrument picker 143 | BANK: 144 | RGB: [ ROSE, PINK_HI ] 145 | RG: [ R1G0, R3G0 ] 146 | 147 | GROUP: 148 | RGB: [ AMBER_YELLOW, PINK_HI ] 149 | RG: [ R0G1, R0G3 ] 150 | 151 | INSTR: 152 | RGB: [ LIME_GREEN, PINK_HI ] 153 | RG: [ R1G0, R3G0 ] 154 | 155 | VAR: 156 | RGB: [ CYAN_SKY, PINK_HI ] 157 | RG: [ R0G1, R0G3 ] 158 | 159 | # Arpeggiator 160 | VELO: 161 | RGB: [ GREY_LO, LIME ] 162 | RG: [ R0G1, R0G3 ] 163 | 164 | GATE: 165 | RGB: [ GREY_LO, SPRING ] 166 | RG: [ R1G0, R3G0 ] 167 | 168 | TRIG: 169 | RGB: [ GREY_LO, GREEN_HI ] 170 | RG: [ R0G1, R0G3 ] 171 | 172 | MOTIF: 173 | RGB: [ GREY_LO, GREEN_HI ] 174 | RG: [ R0G1, R0G3 ] 175 | 176 | # BPM / tempo setter 177 | DIGIT: 178 | RGB: [ PINK, RED, WHITE, BLUE ] 179 | RG: [ R1G1, R3G0, R3G3, R0G3 ] 180 | 181 | # Scale picker 182 | SCALEROOT: 183 | RGB: [ MAGENTA_PINK ] 184 | RG: [ R1G0 ] 185 | 186 | SCALENOTES: 187 | RGB: [ BLUE_ORCHID ] 188 | RG: [ R1G0 ] 189 | 190 | SCALEPICK: 191 | RGB: [ GREEN ] 192 | RG: [ R1G0 ] -------------------------------------------------------------------------------- /persistence.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import shelve 3 | 4 | cache = {} 5 | 6 | def shelve_open(filename): 7 | if filename not in cache: 8 | cache[filename] = shelve.open(filename, writeback=True) 9 | return cache[filename] 10 | 11 | def persistent_attrs(**kwargs): 12 | def wrap_class(klass): 13 | for attr_name, default_value in kwargs.items(): 14 | def getter(self, attr_name=attr_name, default_value=default_value): 15 | if attr_name not in self.db: 16 | logging.debug( 17 | "Initializing {}/{} with default value {}" 18 | .format(self.db_filename, attr_name, default_value)) 19 | self.db[attr_name] = default_value 20 | return self.db[attr_name] 21 | def setter(self, value, attr_name=attr_name): 22 | logging.debug( 23 | "Setting {}/{} to {}" 24 | .format(self.db_filename, attr_name, value)) 25 | self.db[attr_name] = value 26 | setattr(klass, attr_name, property(getter, setter)) 27 | return klass 28 | return wrap_class 29 | 30 | def persistent_attrs_init(self, id_str=None): 31 | if id_str is None: 32 | self.db_filename = "state/{}.sav".format(self.__class__.__name__) 33 | else: 34 | self.db_filename = "state/{}__{}.sav".format(self.__class__.__name__, id_str) 35 | logging.debug("Opening shelf {}".format(self.db_filename)) 36 | self.db = shelve_open(self.db_filename) 37 | -------------------------------------------------------------------------------- /pickers.py: -------------------------------------------------------------------------------- 1 | import collections 2 | import enum 3 | import logging 4 | import mido 5 | 6 | import scales 7 | from gridgets import Gridget, Surface 8 | from palette import palette 9 | from persistence import persistent_attrs, persistent_attrs_init 10 | 11 | 12 | DRUMKIT_MAPPINGS = dict( 13 | FOUR_FOUR = [ 14 | [55, 49, 56, 57], 15 | [41, 43, 47, 50], 16 | [40, 38, 46, 53], 17 | [37, 36, 42, 51], 18 | ], 19 | FOUR_EIGHT = [ 20 | [49, 57, 55, 52, 53, 59, 51, None], 21 | [50, 48, 47, 45, 43, 41, None, 46], 22 | [40, 38, 37, None, 39, 54, None, 42], 23 | [36, 35, None, None, 75, 56, None, 44], 24 | ], 25 | ) 26 | 27 | 28 | class Melodic(enum.Enum): 29 | CHROMATIC = 1 30 | DIATONIC = 2 31 | MAGIC = 3 32 | 33 | 34 | Drumkit = enum.Enum("Drumkit", list(DRUMKIT_MAPPINGS.keys())) 35 | 36 | 37 | 38 | @persistent_attrs(root=48, 39 | drumkit_mapping=Drumkit.FOUR_EIGHT, 40 | melodic_mapping=Melodic.CHROMATIC) 41 | class NotePicker(Gridget): 42 | 43 | def __init__(self, grid, channel): 44 | self.grid = grid 45 | self.surface = Surface(grid.surface) 46 | for button in "UP DOWN LEFT RIGHT".split(): 47 | self.surface[button] = palette.CHANNEL[channel] 48 | self.channel = channel 49 | persistent_attrs_init(self, "{}__{}".format(self.grid.grid_name, channel)) 50 | self.led2note = {} 51 | self.note2leds = collections.defaultdict(list) 52 | devicechain = self.grid.griode.devicechains[self.grid.channel] 53 | if devicechain.instrument.is_drumkit: 54 | self.mapping = self.drumkit_mapping 55 | else: 56 | self.mapping = self.melodic_mapping 57 | self.switch() 58 | 59 | @property 60 | def key(self): 61 | return self.grid.griode.key 62 | 63 | @property 64 | def scale(self): 65 | return self.grid.griode.scale 66 | 67 | def mode(self, is_drumkit): 68 | if is_drumkit: 69 | self.mapping = self.drumkit_mapping 70 | else: 71 | self.mapping = self.melodic_mapping 72 | self.switch() 73 | 74 | def cycle(self): 75 | m = self.mapping 76 | try: 77 | self.mapping = m.__class__(m.value+1) 78 | except ValueError: 79 | self.mapping = m.__class__(1) 80 | self.switch() 81 | 82 | def switch(self): 83 | # If we are in diatonic mode, we force the root key to be the root 84 | # of the scale, otherwise the whole screen will be off. 85 | # FIXME: allow to shift the diatonic mode. 86 | if self.mapping == Melodic.DIATONIC: 87 | root = self.root//12 * 12 + self.grid.griode.key 88 | else: 89 | root = self.root 90 | self.led2note.clear() 91 | for led in self.surface: 92 | if isinstance(led, tuple): 93 | row, column = led 94 | if self.mapping == Melodic.CHROMATIC: 95 | shift = 5 96 | note = shift*(row-1) + (column-1) 97 | note += root 98 | elif self.mapping == Melodic.DIATONIC: 99 | shift = 3 100 | note = shift*(row-1) + (column-1) 101 | octave = note//len(self.scale) 102 | step = note%len(self.scale) 103 | note = root + 12*octave + self.scale[step] 104 | elif self.mapping == Melodic.MAGIC: 105 | note = (column-1)*7 - (column-1)//2*12 106 | note += (row-1)*4 107 | note += root 108 | elif isinstance(self.mapping, Drumkit): 109 | padmap = DRUMKIT_MAPPINGS[self.mapping.name] 110 | try: 111 | note = padmap[::-1][row-1][column-1] 112 | except IndexError: 113 | note = None 114 | self.led2note[led] = note 115 | self.note2leds.clear() 116 | for led, note in self.led2note.items(): 117 | if note not in self.note2leds: 118 | self.note2leds[note] = [] 119 | self.note2leds[note].append(led) 120 | self.draw() 121 | 122 | def is_key(self, note): 123 | return note%12 == self.key%12 124 | 125 | def is_in_scale(self, note): 126 | scale = [ (self.key + n)%12 for n in self.scale ] 127 | return note%12 in scale 128 | 129 | def note2color(self, note): 130 | # For drumkit, just show which notes are mapped. 131 | if isinstance(self.mapping, Drumkit): 132 | if note is not None: 133 | return palette.CHANNEL[self.channel] 134 | else: 135 | return palette.BLACK 136 | 137 | # For other layouts, properly show notes that are in scale. 138 | if self.is_key(note): 139 | return palette.CHANNEL[self.channel] 140 | if self.is_in_scale(note): 141 | return palette.INSCALE[self.channel] 142 | return palette.BLACK 143 | 144 | def draw(self): 145 | for led in self.surface: 146 | if led in self.led2note: 147 | note = self.led2note[led] 148 | color = self.note2color(note) 149 | self.surface[led] = color 150 | 151 | def button_pressed(self, button): 152 | # FIXME allow to change layout for DRUMKIT? Or? 153 | if button == "UP": 154 | self.root += 12 155 | elif button == "DOWN": 156 | self.root -= 12 157 | elif button == "LEFT": 158 | self.root -= 1 159 | elif button == "RIGHT": 160 | self.root += 1 161 | self.switch() 162 | 163 | def pad_pressed(self, row, column, velocity): 164 | note = self.led2note[row, column] 165 | if note is None: 166 | return 167 | # Velocity curve (this is kind of a hack for now) 168 | # FIXME this probably should be moved to the devicechains 169 | if velocity > 0: 170 | velocity = 63 + velocity//2 171 | # Send that note to the message chain 172 | message = mido.Message( 173 | "note_on", channel=self.channel, 174 | note=note, velocity=velocity) 175 | self.grid.griode.looper.send(message) 176 | # Then light up all instrumentpickers 177 | for grid in self.grid.griode.grids: 178 | picker = grid.notepickers[self.channel] 179 | picker.send(message, self) 180 | 181 | def send(self, message, source_object): 182 | if message.type == "note_on": 183 | if message.velocity == 0: 184 | color = self.note2color(message.note) 185 | elif source_object == self: 186 | color = palette.PLAY[0] 187 | else: 188 | color = palette.PLAY[1] 189 | leds = self.note2leds[message.note] 190 | for led in leds: 191 | self.surface[led] = color 192 | 193 | ############################################################################## 194 | 195 | class InstrumentPicker(Gridget): 196 | 197 | def __init__(self, grid, channel): 198 | self.grid = grid 199 | self.channel = channel 200 | self.surface = Surface(grid.surface) 201 | self.surface["UP"] = palette.CHANNEL[channel] 202 | self.surface["DOWN"] = palette.CHANNEL[channel] 203 | if channel > 0: 204 | self.surface["LEFT"] = palette.CHANNEL[channel-1] 205 | if channel < 15: 206 | self.surface["RIGHT"] = palette.CHANNEL[channel+1] 207 | self.draw() 208 | 209 | @property 210 | def devicechain(self): 211 | return self.grid.griode.devicechains[self.channel] 212 | 213 | @property 214 | def fonts(self): 215 | return self.grid.griode.synth.fonts 216 | 217 | @property 218 | def groups(self): 219 | return self.fonts.get(self.devicechain.font_index, self.fonts[0]) 220 | 221 | @property 222 | def instrs(self): 223 | return self.groups.get(self.devicechain.group_index, self.groups[0]) 224 | 225 | @property 226 | def banks(self): 227 | return self.instrs.get(self.devicechain.instr_index, self.instrs[0]) 228 | 229 | def draw(self): 230 | leds = self.get_leds() 231 | for led in self.surface: 232 | if led in leds: 233 | self.surface[led] = leds[led] 234 | elif isinstance(led, tuple): 235 | color = palette.BLACK 236 | row, column = led 237 | if row == 8: 238 | font_index = column-1 239 | if font_index in self.fonts: 240 | color = palette.BANK[0] 241 | if row in [6, 7]: 242 | color = palette.GROUP[0] 243 | if row == 5: 244 | color = palette.INSTR[0] 245 | if row == 4: 246 | bank_index = column-1 247 | if bank_index in self.banks: 248 | color = palette.VAR[0] 249 | if row in [1, 2, 3]: 250 | color = self.grid.notepickers[self.channel].surface[led] 251 | self.surface[led] = color 252 | 253 | def get_leds(self): 254 | # Which leds are supposed to be ON for the current instrument 255 | leds = {} 256 | instrument = self.devicechain.instrument 257 | group_index = instrument.program//8 258 | instr_index = instrument.program%8 259 | for led in [ 260 | (8, 1+instrument.font_index), 261 | (7-(group_index//8), 1+group_index%8), 262 | (5, 1+instr_index), 263 | (4, 1+instrument.bank_index)]: 264 | leds[led] = palette.ACTIVE 265 | return leds 266 | 267 | def pad_pressed(self, row, col, velocity): 268 | current_is_drumkit = self.devicechain.instrument.is_drumkit 269 | if row in [1, 2, 3]: 270 | self.grid.notepickers[self.channel].pad_pressed(row, col, velocity) 271 | return 272 | if velocity == 0: 273 | return 274 | if self.surface[row, col] == palette.BLACK: 275 | return 276 | if row==8: 277 | self.devicechain.font_index = col-1 278 | if row==7: 279 | self.devicechain.group_index = col-1 280 | if row==6: 281 | self.devicechain.group_index = 8+col-1 282 | if row==5: 283 | self.devicechain.instr_index = col-1 284 | if row==4: 285 | self.devicechain.bank_index = col -1 286 | # Switch to new instrument 287 | self.devicechain.program_change() 288 | # Repaint 289 | self.draw() 290 | # If we switched from melodic to rhythmic, update NotePicker 291 | new_is_drumkit = self.devicechain.instrument.is_drumkit 292 | if current_is_drumkit != new_is_drumkit: 293 | self.grid.notepickers[self.channel].mode(new_is_drumkit) 294 | 295 | def button_pressed(self, button): 296 | if button == "LEFT" and self.channel>0: 297 | self.grid.channel = self.channel-1 298 | self.grid.focus(self.grid.instrumentpickers[self.channel-1]) 299 | if button == "RIGHT" and self.channel<15: 300 | self.grid.channel = self.channel+1 301 | self.grid.focus(self.grid.instrumentpickers[self.channel+1]) 302 | if button in ["UP", "DOWN"]: 303 | instruments = self.grid.griode.synth.instruments 304 | instrument_index = instruments.index(self.devicechain.instrument) 305 | if button == "UP": 306 | instrument_index -= 1 307 | else: 308 | instrument_index += 1 309 | if instrument_index < 0: 310 | instrument = instruments[-1] 311 | elif instrument_index >= len(instruments): 312 | instrument = instruments[0] 313 | else: 314 | instrument = instruments[instrument_index] 315 | self.devicechain.font_index = instrument.font_index 316 | self.devicechain.group_index = instrument.program//8 317 | self.devicechain.instr_index = instrument.program%8 318 | self.devicechain.bank_index = instrument.bank_index 319 | self.devicechain.program_change() 320 | self.draw() 321 | 322 | 323 | ############################################################################## 324 | 325 | class ScalePicker(Gridget): 326 | """ 327 | ##.###.. sharp of the key below 328 | CDEFGAB. pick key 329 | ........ 330 | ##.###.. sharp of the key below 331 | CDEFGABX keys in scale; X = play the scale 332 | ........ 333 | XXXXXXXX modes 334 | XXXXXXXX scales 335 | """ 336 | 337 | def __init__(self, grid): 338 | self.grid = grid 339 | self.surface = Surface(grid.surface) 340 | self.draw() 341 | 342 | def draw(self): 343 | leds = self.get_leds() 344 | for led in self.surface: 345 | if led in leds: 346 | self.surface[led] = leds[led] 347 | elif isinstance(led, tuple): 348 | row, column = led 349 | color = palette.BLACK 350 | if row == 8 and column in [1, 2, 4, 5, 6]: 351 | color = palette.SCALEROOT 352 | if row == 7 and column != 8: 353 | color = palette.SCALEROOT 354 | if row == 5 and column in [1, 2, 4, 5, 6]: 355 | color = palette.SCALENOTES 356 | if row == 4 and column != 8: 357 | color = palette.SCALENOTES 358 | if row == 4 and column == 8: 359 | color = palette.TRIG 360 | if row == 2 or row == 1: 361 | try: 362 | scales.palette[row-1][column-1] 363 | color = palette.SCALEPICK 364 | except IndexError: 365 | pass 366 | self.surface[led] = color 367 | 368 | def get_leds(self): 369 | leds = {} 370 | 371 | key = self.grid.griode.key 372 | 373 | row, column = note2piano[key] 374 | leds[row+6, column] = palette.ACTIVE 375 | 376 | current_scale = self.grid.griode.scale 377 | for note in current_scale: 378 | row, column = note2piano[note] 379 | leds[row+3, column] = palette.ACTIVE 380 | 381 | for row, line in enumerate(scales.palette): 382 | for column, scale in enumerate(line): 383 | if scale == tuple(current_scale): 384 | leds[row+1, column+1] = palette.ACTIVE 385 | 386 | return leds 387 | 388 | def cue(self, notes): 389 | duration = 12 # In ticks 390 | send = self.grid.griode.synth.send 391 | cue = self.grid.griode.clock.cue 392 | for i, note in enumerate(notes): 393 | message = mido.Message("note_on", channel=self.grid.channel, 394 | note=48+note, velocity=96) 395 | cue(duration*i, send, (message, )) 396 | cue(duration*(i+1), send, (message.copy(velocity=0), )) 397 | 398 | def pad_pressed(self, row, column, velocity): 399 | if velocity == 0: 400 | return 401 | 402 | # Change the key in which we're playing 403 | if row in [7, 8]: 404 | note = piano2note.get((row-6, column)) 405 | if note is not None: 406 | self.cue([note]) 407 | self.grid.griode.key = note 408 | 409 | # Manually tweak the scale 410 | if row in [4, 5]: 411 | note = piano2note.get((row-3, column)) 412 | if note is not None: 413 | self.cue([note+self.grid.griode.key]) 414 | if note != 0: # Do not remove the first note of the scale! 415 | if note in self.grid.griode.scale: 416 | self.grid.griode.scale.remove(note) 417 | else: 418 | self.grid.griode.scale.append(note) 419 | self.grid.griode.scale.sort() 420 | 421 | # Play the current scale 422 | if (row, column) == (4, 8): 423 | scale = [self.grid.griode.key + n 424 | for n in self.grid.griode.scale + [12]] 425 | self.cue(scale) 426 | 427 | # Pick a scale from the palette 428 | if row in [1, 2]: 429 | try: 430 | scale = scales.palette[row-1][column-1] 431 | self.grid.griode.scale = list(scale) 432 | except IndexError: 433 | pass 434 | 435 | self.draw() 436 | for grid in self.grid.griode.grids: 437 | for notepicker in grid.notepickers: 438 | notepicker.draw() 439 | 440 | 441 | # Maps notes to a pseudo-piano layout 442 | # (with black keys on the top row and white keys on the bottom row) 443 | 444 | note2piano = [ 445 | (1, 1), (2, 1), (1, 2), (2, 2), (1, 3), 446 | (1, 4), (2, 4), (1, 5), (2, 5), (1, 6), (2, 6), (1, 7) 447 | ] 448 | 449 | piano2note = { (r, c): n for (n, (r, c)) in enumerate(note2piano) } 450 | 451 | ############################################################################## 452 | 453 | class ColorPicker(Gridget): 454 | 455 | def __init__(self, grid): 456 | self.grid = grid 457 | self.surface = Surface(grid.surface) 458 | for led in self.surface: 459 | if isinstance(led, tuple): 460 | row, column = led 461 | color = (row-1)*8 + column-1 462 | self.surface[led] = color 463 | 464 | def pad_pressed(self, row, column, velocity): 465 | if velocity > 0: 466 | color = (row-1)*8 + column-1 467 | print("Color #{}".format(color)) 468 | -------------------------------------------------------------------------------- /quantize.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import shelve 4 | import sys 5 | 6 | # QUANTIZE will be the accuracy of quantization. 7 | # E.g. 24 = quant to the quarter note. 8 | # Magic value of 0 = do nothing. 9 | filename, QUANTIZE = sys.argv[1:] 10 | 11 | QUANTIZE = int(QUANTIZE) 12 | 13 | db = shelve.open(sys.argv[1]) 14 | 15 | notes = db["notes"] 16 | 17 | 18 | def quantize(tick): 19 | if QUANTIZE == 0: 20 | return 21 | offset = tick % QUANTIZE 22 | if offset < QUANTIZE/2: 23 | new_tick = tick-offset 24 | else: 25 | new_tick = tick-offset+QUANTIZE 26 | if tick != new_tick: 27 | print("Quantizing {} to {}".format(tick, new_tick)) 28 | return new_tick 29 | 30 | 31 | def move(src, dst): 32 | print("{} -> {}".format(src, dst)) 33 | if dst not in notes: 34 | notes[dst] = [] 35 | notes[dst].extend(notes[src]) 36 | del notes[src] 37 | 38 | 39 | ticks = sorted(notes.keys()) 40 | for tick, next_tick in zip(ticks, ticks[1:]): 41 | for note in notes[tick]: 42 | bar = tick//24//4 43 | beat = tick/24%4 44 | print("{bar:3}.{beat:3} | {note.note:3} | {note.velocity:3} | {note.duration:3}" 45 | .format(bar=bar, beat=beat, note=note)) 46 | note.duration = quantize(note.duration) 47 | if note.duration == 0: 48 | note.duration = quantize(next_tick - tick) 49 | 50 | for note in notes[ticks[-1]]: 51 | if note.duration == 0 and QUANTIZE > 0: 52 | note.duration = 24 53 | 54 | ticks = sorted(notes.keys()) 55 | for src in ticks: 56 | dst = quantize(src) 57 | if src != dst: 58 | move(src, dst) 59 | 60 | ticks = sorted(notes.keys()) 61 | first = ticks[0] 62 | if first > 0 and QUANTIZE > 0: 63 | for tick in ticks: 64 | move(tick, tick-first) 65 | 66 | db["notes"] = notes 67 | 68 | db.close() 69 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | colorama 2 | mido 3 | python-rtmidi 4 | PyYAML 5 | -------------------------------------------------------------------------------- /scales.py: -------------------------------------------------------------------------------- 1 | from notes import * 2 | 3 | MAJOR = (C, D, E, F, G, A, B) 4 | MINOR = (C, D, Eflat, F, G, Aflat, B) 5 | 6 | def greek_mode(n): 7 | scale = MAJOR[n:] + tuple(note+12 for note in MAJOR[:n]) 8 | scale = tuple(note - scale[0] for note in scale) 9 | return scale 10 | 11 | IONIAN = greek_mode(0) # aka the major scale :-) 12 | DORIAN = greek_mode(1) 13 | PHRYGIAN = greek_mode(2) 14 | LYDIAN = greek_mode(3) 15 | MIXOLYDIAN = greek_mode(4) 16 | AEOLIAN = greek_mode(5) 17 | LOCRIAN = greek_mode(6) 18 | 19 | BLUES = (C, Eflat, F, Fsharp, G, Bflat) 20 | 21 | # double harmonic major, aka flamenco, aka arabic 22 | ARABIC = (C, Dflat, E, F, G, Aflat, B) 23 | 24 | # double harmonic minor aka hungarian minor 25 | HUNGARIAN_MINOR = (C, D, Eflat, Fsharp, G, Aflat, B) 26 | 27 | # hungarian 28 | HUNGARIAN = (C, D, Eflat, Fsharp, G, Aflat, Bflat) 29 | 30 | # phrygian dominant aka altered phrygian aka freygish 31 | FREYGISH = (C, Dflat, E, F, G, Aflat, Bflat) 32 | 33 | palette = ( 34 | (MINOR, ARABIC, HUNGARIAN_MINOR, HUNGARIAN, FREYGISH, BLUES), 35 | (MAJOR, DORIAN, PHRYGIAN, LYDIAN, MIXOLYDIAN, AEOLIAN, LOCRIAN), 36 | ) 37 | -------------------------------------------------------------------------------- /soundfonts/download-soundfonts.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # General User 4 | URL="https://www.dropbox.com/s/4x27l49kxcwamp5/GeneralUser_GS_1.471.zip?dl=1" 5 | ARCHIVE="generaluser.zip" 6 | FILEPATH="GeneralUser GS 1.471/GeneralUser GS v1.471.sf2" 7 | FILENAME="generaluser.sf2" 8 | if ! [ -f "$FILENAME" ]; then 9 | if ! [ -f "$ARCHIVE" ]; then 10 | curl -L -o "$ARCHIVE" "$URL" 11 | fi 12 | unzip -p "$ARCHIVE" "$FILEPATH" > "$FILENAME" 13 | rm "$ARCHIVE" 14 | fi 15 | 16 | # Fluid SoundFont 17 | URL="http://http.debian.net/debian/pool/main/f/fluid-soundfont/fluid-soundfont_3.1.orig.tar.gz" 18 | ARCHIVE="fluidsoundfont.tgz" 19 | FILEPATH="fluid-soundfont-3.1/FluidR3_GM.sf2" 20 | FILENAME="fluidsoundfont.sf2" 21 | if ! [ -f "$FILENAME" ]; then 22 | if ! [ -f "$ARCHIVE" ]; then 23 | curl -L -o "$ARCHIVE" "$URL" 24 | fi 25 | tar -Ozxf "$ARCHIVE" "$FILEPATH" > "$FILENAME" 26 | rm "$ARCHIVE" 27 | fi 28 | 29 | # AWE32 ROM dump 30 | URL="http://heretics-hexens.ucoz.com/1mgm.sf2" 31 | 32 | [ ! -e 0.sf2 ] && [ -e generaluser.sf2 ] && ln -s generaluser.sf2 0.sf2 33 | -------------------------------------------------------------------------------- /state/README.md: -------------------------------------------------------------------------------- 1 | Griode will create state files in this directory. 2 | 3 | The files use Python's `shelve` format. 4 | -------------------------------------------------------------------------------- /termpad.py: -------------------------------------------------------------------------------- 1 | import colorama 2 | import os 3 | 4 | import colors 5 | from gridgets import ARROWS, MENU 6 | from griode import Grid 7 | 8 | 9 | class ASCIIGrid(Grid): 10 | 11 | def __init__(self, griode, fd_in, fd_out): 12 | self.fd_in = fd_in 13 | self.fd_out = fd_out 14 | self.surface = ASCIISurface(self) 15 | Grid.__init__(self, griode, "tty") 16 | 17 | 18 | class ASCIISurface(object): 19 | 20 | def __init__(self, grid): 21 | self.grid = grid 22 | self.write(colorama.ansi.clear_screen()) 23 | 24 | def __iter__(self): 25 | return (ARROWS + MENU + [(row, column) 26 | for row in range(1, 9) 27 | for column in range(1, 9)]).__iter__() 28 | 29 | def write(self, s): 30 | os.write(self.grid.fd_out, s.encode("utf-8")) 31 | 32 | def __setitem__(self, led, color): 33 | # This is a janky map but it will do for now 34 | char = { 35 | palette.BLACK: " ", 36 | colors.PINK_HI: "X", 37 | colors.ROSE: ".", 38 | colors.GREY_LO: ".", 39 | }.get(color, "o") 40 | if isinstance(led, tuple): 41 | row, column = led 42 | else: 43 | row = 10 44 | column = (ARROWS+MENU).index(led) + 1 45 | pos = colorama.Cursor.POS(column*2, 11-row) 46 | bottom = colorama.Cursor.POS(1, 12) 47 | self.write(pos + char + bottom) 48 | --------------------------------------------------------------------------------