├── .gitignore ├── .travis.yml ├── Gemfile ├── LICENSE ├── README.rdoc ├── Rakefile ├── examples ├── binary_clock.rb ├── color_picker.rb ├── colors.rb ├── doodle.rb ├── double_buffering.rb ├── drawing_board.rb ├── feedback.rb └── reset.rb ├── launchpad.gemspec ├── lib ├── launchpad.rb └── launchpad │ ├── device.rb │ ├── errors.rb │ ├── interaction.rb │ ├── logging.rb │ ├── midi_codes.rb │ └── version.rb └── test ├── helper.rb ├── test_device.rb └── test_interaction.rb /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | .bundle 3 | Gemfile.lock 4 | pkg/* 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | rvm: 3 | - 1.8.7 4 | - 1.9.3 5 | - 2.0.0 6 | before_install: 7 | - sudo apt-get update 8 | - sudo apt-get install libportmidi-dev 9 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | # Specify your gem's dependencies in launchpad.gemspec 4 | gemspec 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2009 Thomas Jachmann 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.rdoc: -------------------------------------------------------------------------------- 1 | = launchpad 2 | 3 | {Build Status}[https://travis-ci.org/thomasjachmann/launchpad] 4 | 5 | This gem provides a ruby interface to access novation's launchpad programmatically. LEDs can be lighted and button presses can be responded to. Internally, launchpad's MIDI input/output is used to accomplish this. 6 | 7 | The interfaces should be rather stable now (sorry, I changed quite a bit since the last release), so experiment with them and comment on their usability. This still is work in progress. If you need anything or think the interfaces could be improved in any way, please contact me. 8 | 9 | Sometimes, the launchpad won't react to anything or react to/light up the wrong LEDs. Don't despair, just dis- and reconnect the thing. It seems that some (unexpected) MIDI signals make it hickup. 10 | 11 | 12 | == More Info 13 | 14 | If you don't know what launchpad is, visit: 15 | 16 | * Novation's site at http://de.novationmusic.com/products/midi_controller/launchpad 17 | * my demo videos for this library at http://www.youtube.com/thomasjachmann 18 | * other demos on youtube http://www.youtube.com/results?search_query=novation+launchpad 19 | 20 | If you're into other languages or want to know what goes on behind the scenes MIDI wise, have a look at: 21 | 22 | * Novation's MIDI programmer's reference at {www.novationmusic.com/support/launchpad}[http://www.novationmusic.com/support/launchpad/] (bottom of the page) 23 | * Tobi Tobes' port of my gem to processing at http://github.com/rngtng/launchpad 24 | 25 | 26 | == Requirements 27 | 28 | * Roger B. Dannenberg's {portmidi library}[http://sourceforge.net/projects/portmedia/] 29 | * Jan Krutisch's {portmidi gem}[http://github.com/halfbyte/portmidi] 30 | 31 | 32 | == Compatibility 33 | 34 | The gem is known to be compatible with the following ruby versions: 35 | 36 | * MRI 1.8.7 37 | * MRI 1.9.3 38 | * MRI 2.0.0 39 | 40 | 41 | == Installation 42 | 43 | The gem is hosted on RubyGems[https://rubygems.org/], so in order to use it, you're gonna gem install it: 44 | 45 | gem install launchpad 46 | 47 | 48 | == Usage 49 | 50 | There are two main entry points: 51 | 52 | * require 'launchpad/device', providing Launchpad::Device, which handles all the basic input/output stuff 53 | * require 'launchpad/interaction' or just 'launchpad', additionally providing Launchpad::Interaction, which lets you respond to actions (button presses/releases) 54 | 55 | This is a simple example (only requiring the device for output) that switches on all LEDs (for testing), resets the launchpad again and then lights the grid button at position 4/4 (from top left). 56 | 57 | require 'launchpad/device' 58 | 59 | device = Launchpad::Device.new 60 | device.test_leds 61 | sleep 1 62 | device.reset 63 | sleep 1 64 | device.change :grid, :x => 4, :y => 4, :red => :high, :green => :low 65 | 66 | This is an interaction example lighting all grid buttons in red when pressed and keeping them lit. 67 | 68 | require 'launchpad' 69 | 70 | interaction = Launchpad::Interaction.new 71 | interaction.response_to(:grid, :down) do |interaction, action| 72 | interaction.device.change(:grid, action.merge(:red => :high)) 73 | end 74 | interaction.response_to(:mixer, :down) do |interaction, action| 75 | interaction.stop 76 | end 77 | 78 | interaction.start 79 | 80 | 81 | For more details, see the examples. examples/color_picker.rb is the most complex example with interaction. 82 | 83 | 84 | == Future plans 85 | 86 | * bitmap rendering 87 | * internal tracking of LED states for both buffers 88 | 89 | 90 | == Changelog 91 | 92 | === v.0.3.0 93 | 94 | * logging 95 | * reworked multi threading for action handling 96 | * compatibility with ruby 1.8.7 and 2.0.0 97 | * interaction responses for presses on single grid buttons/button areas/columns/rows 98 | 99 | === v.0.2.2 100 | 101 | * single threading fix: prevent ThreadError when Launchpad::Interaction#stop is called within an action response 102 | 103 | === v.0.2.1 104 | 105 | * Launchpad::Interaction#close now properly stops interaction first 106 | * multi threading: Launchpad::Interaction#start method can be called with :detached => true to allow calling thread to continue 107 | 108 | === v.0.2.0 109 | 110 | * double buffering (see Launchpad::Device#buffering_mode) 111 | * don't update grid button 0,0 before change_all (in order to reset rapid update pointer), use MIDI message without visual effect 112 | * (at least) doubled the speed of change_all by not sending each message individually but sending them in one go (as an array) 113 | 114 | === v0.1.1 115 | 116 | * ability to close device/interaction to free portmidi resources 117 | * ability to initialize devices using device ids as well as device names 118 | * complete documentation for http://rdoc.info/projects/thomasjachmann/launchpad 119 | 120 | === v0.1.0 121 | 122 | * first feature complete version with kinda stable API 123 | 124 | 125 | == Copyright 126 | 127 | Copyright (c) 2009 Thomas Jachmann. See LICENSE for details. 128 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'bundler/gem_tasks' 2 | 3 | require 'rake/testtask' 4 | Rake::TestTask.new(:test) do |test| 5 | test.libs << 'lib' << 'test' 6 | test.pattern = 'test/**/test_*.rb' 7 | test.verbose = true 8 | end 9 | 10 | task :default => :test 11 | -------------------------------------------------------------------------------- /examples/binary_clock.rb: -------------------------------------------------------------------------------- 1 | require 'launchpad' 2 | 3 | device = Launchpad::Device.new 4 | 5 | on = { :red => :high, :green => :off } 6 | off = { :red => :off, :green => :lo } 7 | 8 | digit_map = [ 9 | [off, off, off, off], 10 | [on , off, off, off], 11 | [off, on , off, off], 12 | [on , on , off, off], 13 | [off, off, on , off], 14 | [on , off, on , off], 15 | [off, on , on , off], 16 | [on , on , on , off], 17 | [off, off, off, on ], 18 | [on , off, off, on ] 19 | ] 20 | 21 | while true do 22 | Time.now.strftime('%H%M%S').split('').each_with_index do |digit, x| 23 | digit_map[digit.to_i].each_with_index do |color, y| 24 | device.change :grid, color.merge(:x => x, :y => (7 - y)) 25 | end 26 | end 27 | 28 | sleep 0.25 29 | end 30 | -------------------------------------------------------------------------------- /examples/color_picker.rb: -------------------------------------------------------------------------------- 1 | require 'launchpad' 2 | 3 | interaction = Launchpad::Interaction.new 4 | 5 | # build color arrays for color display views 6 | colors_single = [ 7 | [ 0, 1, 2, 3, 0, 0, 0, 0], 8 | [16, 17, 18, 19, 0, 0, 0, 0], 9 | [32, 33, 34, 35, 0, 0, 0, 0], 10 | [48, 49, 50, 51, 0, 0, 0, 0], 11 | [0] * 8, 12 | [0] * 8, 13 | [0] * 8, 14 | [0] * 8, 15 | [0] * 8 16 | ] 17 | colors_double = [ 18 | [ 0, 0, 1, 1, 2, 2, 3, 3], 19 | [ 0, 0, 1, 1, 2, 2, 3, 3], 20 | [16, 16, 17, 17, 18, 18, 19, 19], 21 | [16, 16, 17, 17, 18, 18, 19, 19], 22 | [32, 32, 33, 33, 34, 34, 35, 35], 23 | [32, 32, 33, 33, 34, 34, 35, 35], 24 | [48, 48, 49, 49, 50, 50, 51, 51], 25 | [48, 48, 49, 49, 50, 50, 51, 51], 26 | [0] * 8 27 | ] 28 | colors_mirrored = [ 29 | [ 0, 1, 2, 3, 3, 2, 1, 0], 30 | [16, 17, 18, 19, 19, 18, 17, 16], 31 | [32, 33, 34, 35, 35, 34, 33, 32], 32 | [48, 49, 50, 51, 51, 50, 49, 48], 33 | [48, 49, 50, 51, 51, 50, 49, 48], 34 | [32, 33, 34, 35, 35, 34, 33, 32], 35 | [16, 17, 18, 19, 19, 18, 17, 16], 36 | [ 0, 1, 2, 3, 3, 2, 1, 0], 37 | [0] * 8 38 | ] 39 | 40 | # setup color display views 41 | def display_color_view(colors) 42 | lambda do |interaction, action| 43 | # set color 44 | interaction.device.change_all(colors) 45 | # register mute interactor on scene buttons 46 | interaction.response_to(%w(scene1 scene2 scene3 scene4 scene5 scene6 scene7 scene8), :down, :exclusive => true, &@mute) 47 | end 48 | end 49 | interaction.response_to(:up, :down, &display_color_view(colors_single + [48, 16, 16, 16])) 50 | interaction.response_to(:down, :down, &display_color_view(colors_double + [16, 48, 16, 16])) 51 | interaction.response_to(:left, :down, &display_color_view(colors_mirrored + [16, 16, 48, 16])) 52 | 53 | # setup color picker view 54 | def display_color(opts) 55 | lambda do |interaction, action| 56 | @red = opts[:red] if opts[:red] 57 | @green = opts[:green] if opts[:green] 58 | colors = [(@green * 16 + @red)] * 64 59 | scenes = [@red == 3 ? 51 : 3, @red == 2 ? 51 : 2, @red == 1 ? 51 : 1, @red == 0 ? 51 : 0, @green == 3 ? 51 : 48, @green == 2 ? 51 : 32, @green == 1 ? 51 : 16, @green == 0 ? 51 : 0] 60 | interaction.device.change_all(colors + scenes + [16, 16, 16, 48]) 61 | end 62 | end 63 | interaction.response_to(:right, :down) do |interaction, action| 64 | @red = 0 65 | @green = 0 66 | # register color picker interactors on scene buttons 67 | interaction.response_to(:scene1, :down, :exclusive => true, &display_color(:red => 3)) 68 | interaction.response_to(:scene2, :down, :exclusive => true, &display_color(:red => 2)) 69 | interaction.response_to(:scene3, :down, :exclusive => true, &display_color(:red => 1)) 70 | interaction.response_to(:scene4, :down, :exclusive => true, &display_color(:red => 0)) 71 | interaction.response_to(:scene5, :down, :exclusive => true, &display_color(:green => 3)) 72 | interaction.response_to(:scene6, :down, :exclusive => true, &display_color(:green => 2)) 73 | interaction.response_to(:scene7, :down, :exclusive => true, &display_color(:green => 1)) 74 | interaction.response_to(:scene8, :down, :exclusive => true, &display_color(:green => 0)) 75 | # display color 76 | interaction.respond_to(:scene8, :down) 77 | end 78 | 79 | # mixer button terminates interaction on button up 80 | interaction.response_to(:mixer) do |interaction, action| 81 | interaction.device.change(:mixer, :red => action[:state] == :down ? :hi : :off) 82 | interaction.stop if action[:state] == :up 83 | end 84 | 85 | # setup mute display interactors on all unused buttons 86 | @mute = display_color_view([0] * 72 + [16, 16, 16, 16]) 87 | interaction.response_to(%w(session user1 user2 grid), :down, &@mute) 88 | 89 | # display mute view 90 | interaction.respond_to(:session, :down) 91 | 92 | # start interacting 93 | interaction.start 94 | 95 | # sleep so that the messages can be sent before the program terminates 96 | sleep 0.1 97 | -------------------------------------------------------------------------------- /examples/colors.rb: -------------------------------------------------------------------------------- 1 | require 'launchpad' 2 | 3 | device = Launchpad::Device.new(:input => false, :output => true) 4 | 5 | pos_x = pos_y = 0 6 | 4.times do |red| 7 | 4.times do |green| 8 | device.change :grid, :x => pos_x, :y => pos_y, :red => red, :green => green 9 | device.change :grid, :x => 7 - pos_x, :y => pos_y, :red => red, :green => green 10 | device.change :grid, :x => pos_x, :y => 7 - pos_y, :red => red, :green => green 11 | device.change :grid, :x => 7 - pos_x, :y => 7 - pos_y, :red => red, :green => green 12 | pos_y += 1 13 | # sleep, otherwise the connection drops some messages - WTF? 14 | sleep 0.01 15 | end 16 | pos_x += 1 17 | pos_y = 0 18 | end 19 | 20 | # sleep so that the messages can be sent before the program terminates 21 | sleep 0.1 22 | -------------------------------------------------------------------------------- /examples/doodle.rb: -------------------------------------------------------------------------------- 1 | require 'launchpad' 2 | 3 | interaction = Launchpad::Interaction.new 4 | 5 | current_color = { 6 | :red => :hi, 7 | :green => :hi, 8 | :mode => :normal 9 | } 10 | 11 | def update_scene_buttons(d, color) 12 | on = {:red => :hi, :green => :hi} 13 | d.change(:scene1, color[:red] == :hi ? on : {:red => :hi}) 14 | d.change(:scene2, color[:red] == :med ? on : {:red => :med}) 15 | d.change(:scene3, color[:red] == :lo ? on : {:red => :lo}) 16 | d.change(:scene4, color[:red] == :off ? on : {:red => :off}) 17 | d.change(:scene5, color[:green] == :hi ? on : {:green => :hi}) 18 | d.change(:scene6, color[:green] == :med ? on : {:green => :med}) 19 | d.change(:scene7, color[:green] == :lo ? on : {:green => :lo}) 20 | d.change(:scene8, color[:green] == :off ? on : {:green => :off}) 21 | d.change(:user1, :green => color[:mode] == :normal ? :lo : :hi, :mode => :flashing) 22 | d.change(:user2, :green => color[:mode] == :normal ? :hi : :lo) 23 | end 24 | 25 | def choose_color(color, opts) 26 | lambda do |interaction, action| 27 | color.update(opts) 28 | update_scene_buttons(interaction.device, color) 29 | end 30 | end 31 | 32 | # register color picker interactors on scene buttons 33 | interaction.response_to(:scene1, :down, &choose_color(current_color, :red => :hi)) 34 | interaction.response_to(:scene2, :down, &choose_color(current_color, :red => :med)) 35 | interaction.response_to(:scene3, :down, &choose_color(current_color, :red => :lo)) 36 | interaction.response_to(:scene4, :down, &choose_color(current_color, :red => :off)) 37 | interaction.response_to(:scene5, :down, &choose_color(current_color, :green => :hi)) 38 | interaction.response_to(:scene6, :down, &choose_color(current_color, :green => :med)) 39 | interaction.response_to(:scene7, :down, &choose_color(current_color, :green => :lo)) 40 | interaction.response_to(:scene8, :down, &choose_color(current_color, :green => :off)) 41 | 42 | # register mode picker interactors on user buttons 43 | interaction.response_to(:user1, :down, &choose_color(current_color, :mode => :flashing)) 44 | interaction.response_to(:user2, :down, &choose_color(current_color, :mode => :normal)) 45 | 46 | # update scene buttons and start flashing 47 | update_scene_buttons(interaction.device, current_color) 48 | interaction.device.flashing_auto 49 | 50 | # feedback for grid buttons 51 | interaction.response_to(:grid, :down) do |interaction, action| 52 | #coord = 16 * action[:y] + action[:x] 53 | #brightness = flags[coord] ? :off : :hi 54 | #flags[coord] = !flags[coord] 55 | interaction.device.change(:grid, action.merge(current_color)) 56 | end 57 | 58 | # mixer button terminates interaction on button up 59 | interaction.response_to(:mixer) do |interaction, action| 60 | interaction.device.change(:mixer, :red => action[:state] == :down ? :hi : :off) 61 | interaction.stop if action[:state] == :up 62 | end 63 | 64 | # start interacting 65 | interaction.start 66 | 67 | # sleep so that the messages can be sent before the program terminates 68 | sleep 0.1 69 | -------------------------------------------------------------------------------- /examples/double_buffering.rb: -------------------------------------------------------------------------------- 1 | require 'launchpad' 2 | 3 | interaction = Launchpad::Interaction.new 4 | 5 | # store and change button states, ugly but well... 6 | @button_states = [ 7 | [false, false, false, false, false, false, false, false], 8 | [false, false, false, false, false, false, false, false], 9 | [[false, false], [false, false], [false, false], [false, false], [false, false], [false, false], [false, false], [false, false]], 10 | [[false, false], [false, false], [false, false], [false, false], [false, false], [false, false], [false, false], [false, false]], 11 | [[false, false], [false, false], [false, false], [false, false], [false, false], [false, false], [false, false], [false, false]], 12 | [[false, false], [false, false], [false, false], [false, false], [false, false], [false, false], [false, false], [false, false]], 13 | [[false, false], [false, false], [false, false], [false, false], [false, false], [false, false], [false, false], [false, false]], 14 | [[false, false], [false, false], [false, false], [false, false], [false, false], [false, false], [false, false], [false, false]] 15 | ] 16 | def change_button_state(action) 17 | if action[:y] > 1 18 | which = @active_buffer_button == :user2 ? 1 : 0 19 | @button_states[action[:y]][action[:x]][which] = !@button_states[action[:y]][action[:x]][which] 20 | else 21 | @button_states[action[:y]][action[:x]] = !@button_states[action[:y]][action[:x]] 22 | end 23 | end 24 | 25 | # setup grid buttons to: 26 | # * set LEDs in normal mode on the first row 27 | # * set LEDs in flashing mode on the second row 28 | # * set LEDs in buffering mode on all other rows 29 | interaction.response_to(:grid, :down) do |interaction, action| 30 | color = change_button_state(action) ? @color : {} 31 | case action[:y] 32 | when 0 33 | interaction.device.change(:grid, action.merge(color)) 34 | when 1 35 | interaction.device.buffering_mode(:flashing => false, :display_buffer => 1, :update_buffer => 0) 36 | interaction.device.change(:grid, action.merge(color).merge(:mode => :flashing)) 37 | interaction.respond_to(@active_buffer_button, :down) 38 | else 39 | interaction.device.change(:grid, action.merge(color).merge(:mode => :buffering)) 40 | end 41 | end 42 | 43 | # green feedback for buffer buttons 44 | interaction.response_to([:session, :user1, :user2], :down) do |interaction, action| 45 | case @active_buffer_button = action[:type] 46 | when :session 47 | interaction.device.buffering_mode(:flashing => true) 48 | when :user1 49 | interaction.device.buffering_mode(:display_buffer => 0, :update_buffer => 0) 50 | when :user2 51 | interaction.device.buffering_mode(:display_buffer => 1, :update_buffer => 1) 52 | end 53 | interaction.device.change(:session, :red => @active_buffer_button == :session ? :hi : :lo, :green => @active_buffer_button == :session ? :hi : :lo) 54 | interaction.device.change(:user1, :red => @active_buffer_button == :user1 ? :hi : :lo, :green => @active_buffer_button == :user1 ? :hi : :lo) 55 | interaction.device.change(:user2, :red => @active_buffer_button == :user2 ? :hi : :lo, :green => @active_buffer_button == :user2 ? :hi : :lo) 56 | end 57 | 58 | # setup color picker 59 | def display_color(opts) 60 | lambda do |interaction, action| 61 | @red = opts[:red] if opts[:red] 62 | @green = opts[:green] if opts[:green] 63 | if @red == 0 && @green == 0 64 | @red = 1 if opts[:red] 65 | @green = 1 if opts[:green] 66 | end 67 | @color = {:red => @red, :green => @green} 68 | on = {:red => 3, :green => 3} 69 | interaction.device.change(:scene1, @red == 3 ? on : {:red => 3}) 70 | interaction.device.change(:scene2, @red == 2 ? on : {:red => 2}) 71 | interaction.device.change(:scene3, @red == 1 ? on : {:red => 1}) 72 | interaction.device.change(:scene4, @red == 0 ? on : {:red => 0}) 73 | interaction.device.change(:scene5, @green == 3 ? on : {:green => 3}) 74 | interaction.device.change(:scene6, @green == 2 ? on : {:green => 2}) 75 | interaction.device.change(:scene7, @green == 1 ? on : {:green => 1}) 76 | interaction.device.change(:scene8, @green == 0 ? on : {:green => 0}) 77 | end 78 | end 79 | # register color picker interactors on scene buttons 80 | interaction.response_to(:scene1, :down, :exclusive => true, &display_color(:red => 3)) 81 | interaction.response_to(:scene2, :down, :exclusive => true, &display_color(:red => 2)) 82 | interaction.response_to(:scene3, :down, :exclusive => true, &display_color(:red => 1)) 83 | interaction.response_to(:scene4, :down, :exclusive => true, &display_color(:red => 0)) 84 | interaction.response_to(:scene5, :down, :exclusive => true, &display_color(:green => 3)) 85 | interaction.response_to(:scene6, :down, :exclusive => true, &display_color(:green => 2)) 86 | interaction.response_to(:scene7, :down, :exclusive => true, &display_color(:green => 1)) 87 | interaction.response_to(:scene8, :down, :exclusive => true, &display_color(:green => 0)) 88 | # pick green 89 | interaction.respond_to(:scene5, :down) 90 | 91 | # mixer button terminates interaction on button up 92 | interaction.response_to(:mixer) do |interaction, action| 93 | interaction.device.change(:mixer, :red => action[:state] == :down ? :hi : :off) 94 | interaction.stop if action[:state] == :up 95 | end 96 | 97 | # start in auto flashing mode 98 | interaction.respond_to(:session, :down) 99 | 100 | # start interacting 101 | interaction.start 102 | 103 | # sleep so that the messages can be sent before the program terminates 104 | sleep 0.1 105 | -------------------------------------------------------------------------------- /examples/drawing_board.rb: -------------------------------------------------------------------------------- 1 | require 'launchpad' 2 | 3 | interaction = Launchpad::Interaction.new 4 | 5 | flags = Hash.new(false) 6 | 7 | # yellow feedback for grid buttons 8 | interaction.response_to(:grid, :down) do |interaction, action| 9 | coord = 16 * action[:y] + action[:x] 10 | brightness = flags[coord] ? :off : :hi 11 | flags[coord] = !flags[coord] 12 | interaction.device.change(:grid, action.merge(:red => brightness, :green => brightness)) 13 | end 14 | 15 | # mixer button terminates interaction on button up 16 | interaction.response_to(:mixer) do |interaction, action| 17 | interaction.device.change(:mixer, :red => action[:state] == :down ? :hi : :off) 18 | interaction.stop if action[:state] == :up 19 | end 20 | 21 | # start interacting 22 | interaction.start 23 | 24 | # sleep so that the messages can be sent before the program terminates 25 | sleep 0.1 26 | -------------------------------------------------------------------------------- /examples/feedback.rb: -------------------------------------------------------------------------------- 1 | require 'launchpad' 2 | 3 | interaction = Launchpad::Interaction.new 4 | 5 | def brightness(action) 6 | action[:state] == :down ? :hi : :off 7 | end 8 | 9 | # yellow feedback for grid buttons 10 | interaction.response_to(:grid) do |interaction, action| 11 | b = brightness(action) 12 | interaction.device.change(:grid, action.merge(:red => b, :green => b)) 13 | end 14 | 15 | # red feedback for top control buttons 16 | interaction.response_to([:up, :down, :left, :right, :session, :user1, :user2, :mixer]) do |interaction, action| 17 | interaction.device.change(action[:type], :red => brightness(action)) 18 | end 19 | 20 | # green feedback for scene buttons 21 | interaction.response_to([:scene1, :scene2, :scene3, :scene4, :scene5, :scene6, :scene7, :scene8]) do |interaction, action| 22 | interaction.device.change(action[:type], :green => brightness(action)) 23 | end 24 | 25 | # mixer button terminates interaction on button up 26 | interaction.response_to(:mixer, :up) do |interaction, action| 27 | interaction.stop 28 | end 29 | 30 | # start interacting 31 | interaction.start 32 | 33 | # sleep so that the messages can be sent before the program terminates 34 | sleep 0.1 35 | -------------------------------------------------------------------------------- /examples/reset.rb: -------------------------------------------------------------------------------- 1 | require 'launchpad' 2 | 3 | Launchpad::Device.new.reset 4 | 5 | # sleep so that the messages can be sent before the program terminates 6 | sleep 0.1 7 | -------------------------------------------------------------------------------- /launchpad.gemspec: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | $:.push File.expand_path("../lib", __FILE__) 3 | require "launchpad/version" 4 | 5 | Gem::Specification.new do |s| 6 | s.name = "launchpad" 7 | s.version = Launchpad::VERSION 8 | s.authors = ["Thomas Jachmann"] 9 | s.email = ["self@thomasjachmann.com"] 10 | s.homepage = "https://github.com/thomasjachmann/launchpad" 11 | s.summary = %q{A gem for accessing novation's launchpad programmatically and easily.} 12 | s.description = %q{This gem provides an interface to access novation's launchpad programmatically. LEDs can be lighted and button presses can be evaluated using launchpad's MIDI input/output.} 13 | 14 | s.rubyforge_project = "launchpad" 15 | 16 | s.add_dependency "portmidi", ">= 0.0.6" 17 | s.add_dependency "ffi" 18 | s.add_development_dependency "rake" 19 | if RUBY_VERSION < "1.9" 20 | s.add_development_dependency "minitest" 21 | # s.add_development_dependency "ruby-debug" 22 | else 23 | s.add_development_dependency "minitest-reporters" 24 | # s.add_development_dependency "debugger" 25 | end 26 | s.add_development_dependency "mocha" 27 | 28 | # s.has_rdoc = true 29 | 30 | s.files = `git ls-files`.split("\n") 31 | s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n") 32 | s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) } 33 | s.require_paths = ["lib"] 34 | end 35 | -------------------------------------------------------------------------------- /lib/launchpad.rb: -------------------------------------------------------------------------------- 1 | require 'launchpad/interaction' 2 | 3 | # All the fun of launchpad in one module! 4 | # 5 | # See Launchpad::Device for basic access to launchpad input/ouput 6 | # and Launchpad::Interaction for advanced interaction features. 7 | # 8 | # The following parameters will be used throughout the library, so here are the ranges: 9 | # 10 | # [+type+] type of the button, one of 11 | # 12 | # :grid, 13 | # :up, :down, :left, :right, :session, :user1, :user2, :mixer, 14 | # :scene1 - :scene8 15 | # 16 | # [x/y] x/y coordinate (used when type is set to :grid), 17 | # 0-7 (from left to right/top to bottom), 18 | # mandatory when +type+ is set to :grid 19 | # [red/green] brightness of the red/green LED, 20 | # can be set to one of four levels: 21 | # * off (:off, 0) 22 | # * low brightness (:low, :lo, 1) 23 | # * medium brightness (:medium, :med, 2) 24 | # * full brightness (:high, :hi, 3) 25 | # optional, defaults to :off 26 | # [+mode+] button mode, 27 | # one of 28 | # * :normal 29 | # * :flashing (LED is marked as flashing, see Launchpad::Device.flashing_on, Launchpad::Device.flashing_off and Launchpad::Device.flashing_auto) 30 | # * :buffering (LED is written to buffer, see Launchpad::Device.start_buffering, Launchpad::Device.flush_buffer) 31 | # optional, defaults to :normal 32 | # [+state+] whether the button is pressed or released, :down/:up 33 | module Launchpad 34 | end 35 | -------------------------------------------------------------------------------- /lib/launchpad/device.rb: -------------------------------------------------------------------------------- 1 | require 'portmidi' 2 | 3 | require 'launchpad/errors' 4 | require 'launchpad/logging' 5 | require 'launchpad/midi_codes' 6 | require 'launchpad/version' 7 | 8 | module Launchpad 9 | 10 | # This class is used to exchange data with the launchpad. 11 | # It provides methods to light LEDs and to get information about button presses/releases. 12 | # 13 | # Example: 14 | # 15 | # require 'launchpad/device' 16 | # 17 | # device = Launchpad::Device.new 18 | # device.test_leds 19 | # sleep 1 20 | # device.reset 21 | # sleep 1 22 | # device.change :grid, :x => 4, :y => 4, :red => :high, :green => :low 23 | class Device 24 | 25 | include Logging 26 | include MidiCodes 27 | 28 | CODE_NOTE_TO_DATA_TYPE = { 29 | [Status::ON, SceneButton::SCENE1] => :scene1, 30 | [Status::ON, SceneButton::SCENE2] => :scene2, 31 | [Status::ON, SceneButton::SCENE3] => :scene3, 32 | [Status::ON, SceneButton::SCENE4] => :scene4, 33 | [Status::ON, SceneButton::SCENE5] => :scene5, 34 | [Status::ON, SceneButton::SCENE6] => :scene6, 35 | [Status::ON, SceneButton::SCENE7] => :scene7, 36 | [Status::ON, SceneButton::SCENE8] => :scene8, 37 | [Status::CC, ControlButton::UP] => :up, 38 | [Status::CC, ControlButton::DOWN] => :down, 39 | [Status::CC, ControlButton::LEFT] => :left, 40 | [Status::CC, ControlButton::RIGHT] => :right, 41 | [Status::CC, ControlButton::SESSION] => :session, 42 | [Status::CC, ControlButton::USER1] => :user1, 43 | [Status::CC, ControlButton::USER2] => :user2, 44 | [Status::CC, ControlButton::MIXER] => :mixer 45 | }.freeze 46 | 47 | TYPE_TO_NOTE = { 48 | :up => ControlButton::UP, 49 | :down => ControlButton::DOWN, 50 | :left => ControlButton::LEFT, 51 | :right => ControlButton::RIGHT, 52 | :session => ControlButton::SESSION, 53 | :user1 => ControlButton::USER1, 54 | :user2 => ControlButton::USER2, 55 | :mixer => ControlButton::MIXER, 56 | :scene1 => SceneButton::SCENE1, 57 | :scene2 => SceneButton::SCENE2, 58 | :scene3 => SceneButton::SCENE3, 59 | :scene4 => SceneButton::SCENE4, 60 | :scene5 => SceneButton::SCENE5, 61 | :scene6 => SceneButton::SCENE6, 62 | :scene7 => SceneButton::SCENE7, 63 | :scene8 => SceneButton::SCENE8 64 | }.freeze 65 | 66 | # Initializes the launchpad device. When output capabilities are requested, 67 | # the launchpad will be reset. 68 | # 69 | # Optional options hash: 70 | # 71 | # [:input] whether to use MIDI input for user interaction, 72 | # true/false, optional, defaults to +true+ 73 | # [:output] whether to use MIDI output for data display, 74 | # true/false, optional, defaults to +true+ 75 | # [:input_device_id] ID of the MIDI input device to use, 76 | # optional, :device_name will be used if omitted 77 | # [:output_device_id] ID of the MIDI output device to use, 78 | # optional, :device_name will be used if omitted 79 | # [:device_name] Name of the MIDI device to use, 80 | # optional, defaults to "Launchpad" 81 | # [:logger] [Logger] to be used by this device instance, can be changed afterwards 82 | # 83 | # Errors raised: 84 | # 85 | # [Launchpad::NoSuchDeviceError] when device with ID or name specified does not exist 86 | # [Launchpad::DeviceBusyError] when device with ID or name specified is busy 87 | def initialize(opts = nil) 88 | opts = { 89 | :input => true, 90 | :output => true 91 | }.merge(opts || {}) 92 | 93 | self.logger = opts[:logger] 94 | logger.debug "initializing Launchpad::Device##{object_id} with #{opts.inspect}" 95 | 96 | Portmidi.start 97 | 98 | @input = create_device!(Portmidi.input_devices, Portmidi::Input, 99 | :id => opts[:input_device_id], 100 | :name => opts[:device_name] 101 | ) if opts[:input] 102 | @output = create_device!(Portmidi.output_devices, Portmidi::Output, 103 | :id => opts[:output_device_id], 104 | :name => opts[:device_name] 105 | ) if opts[:output] 106 | 107 | reset if output_enabled? 108 | end 109 | 110 | # Closes the device - nothing can be done with the device afterwards. 111 | def close 112 | logger.debug "closing Launchpad::Device##{object_id}" 113 | @input.close unless @input.nil? 114 | @input = nil 115 | @output.close unless @output.nil? 116 | @output = nil 117 | end 118 | 119 | # Determines whether this device has been closed. 120 | def closed? 121 | !(input_enabled? || output_enabled?) 122 | end 123 | 124 | # Determines whether this device can be used to read input. 125 | def input_enabled? 126 | !@input.nil? 127 | end 128 | 129 | # Determines whether this device can be used to output data. 130 | def output_enabled? 131 | !@output.nil? 132 | end 133 | 134 | # Resets the launchpad - all settings are reset and all LEDs are switched off. 135 | # 136 | # Errors raised: 137 | # 138 | # [Launchpad::NoOutputAllowedError] when output is not enabled 139 | def reset 140 | output(Status::CC, Status::NIL, Status::NIL) 141 | end 142 | 143 | # Lights all LEDs (for testing purposes). 144 | # 145 | # Parameters (see Launchpad for values): 146 | # 147 | # [+brightness+] brightness of both LEDs for all buttons 148 | # 149 | # Errors raised: 150 | # 151 | # [Launchpad::NoOutputAllowedError] when output is not enabled 152 | def test_leds(brightness = :high) 153 | brightness = brightness(brightness) 154 | if brightness == 0 155 | reset 156 | else 157 | output(Status::CC, Status::NIL, Velocity::TEST_LEDS + brightness) 158 | end 159 | end 160 | 161 | # Changes a single LED. 162 | # 163 | # Parameters (see Launchpad for values): 164 | # 165 | # [+type+] type of the button to change 166 | # 167 | # Optional options hash (see Launchpad for values): 168 | # 169 | # [:x] x coordinate 170 | # [:y] y coordinate 171 | # [:red] brightness of red LED 172 | # [:green] brightness of green LED 173 | # [:mode] button mode, defaults to :normal, one of: 174 | # [:normal/tt>] updates the LED for all circumstances (the new value will be written to both buffers) 175 | # [:flashing/tt>] updates the LED for flashing (the new value will be written to buffer 0 while the LED will be off in buffer 1, see buffering_mode) 176 | # [:buffering/tt>] updates the LED for the current update_buffer only 177 | # 178 | # Errors raised: 179 | # 180 | # [Launchpad::NoValidGridCoordinatesError] when coordinates aren't within the valid range 181 | # [Launchpad::NoValidBrightnessError] when brightness values aren't within the valid range 182 | # [Launchpad::NoOutputAllowedError] when output is not enabled 183 | def change(type, opts = nil) 184 | opts ||= {} 185 | status = %w(up down left right session user1 user2 mixer).include?(type.to_s) ? Status::CC : Status::ON 186 | output(status, note(type, opts), velocity(opts)) 187 | end 188 | 189 | # Changes all LEDs in batch mode. 190 | # 191 | # Parameters (see Launchpad for values): 192 | # 193 | # [+colors] an array of colors, each either being an integer or a Hash 194 | # * integer: calculated using the formula 195 | # color = 16 * green + red 196 | # * Hash: 197 | # [:red] brightness of red LED 198 | # [:green] brightness of green LED 199 | # [:mode] button mode, defaults to :normal, one of: 200 | # [:normal/tt>] updates the LEDs for all circumstances (the new value will be written to both buffers) 201 | # [:flashing/tt>] updates the LEDs for flashing (the new values will be written to buffer 0 while the LEDs will be off in buffer 1, see buffering_mode) 202 | # [:buffering/tt>] updates the LEDs for the current update_buffer only 203 | # the array consists of 64 colors for the grid buttons, 204 | # 8 colors for the scene buttons (top to bottom) 205 | # and 8 colors for the top control buttons (left to right), 206 | # maximum 80 values - excessive values will be ignored, 207 | # missing values will be filled with 0 208 | # 209 | # Errors raised: 210 | # 211 | # [Launchpad::NoValidBrightnessError] when brightness values aren't within the valid range 212 | # [Launchpad::NoOutputAllowedError] when output is not enabled 213 | def change_all(*colors) 214 | # ensure that colors is at least and most 80 elements long 215 | colors = colors.flatten[0..79] 216 | colors += [0] * (80 - colors.size) if colors.size < 80 217 | # send normal MIDI message to reset rapid LED change pointer 218 | # in this case, set mapping mode to x-y layout (the default) 219 | output(Status::CC, Status::NIL, GridLayout::XY) 220 | # send colors in slices of 2 221 | messages = [] 222 | colors.each_slice(2) do |c1, c2| 223 | messages << message(Status::MULTI, velocity(c1), velocity(c2)) 224 | end 225 | output_messages(messages) 226 | end 227 | 228 | # Switches LEDs marked as flashing on when using custom timer for flashing. 229 | # 230 | # Errors raised: 231 | # 232 | # [Launchpad::NoOutputAllowedError] when output is not enabled 233 | def flashing_on 234 | buffering_mode(:display_buffer => 0) 235 | end 236 | 237 | # Switches LEDs marked as flashing off when using custom timer for flashing. 238 | # 239 | # Errors raised: 240 | # 241 | # [Launchpad::NoOutputAllowedError] when output is not enabled 242 | def flashing_off 243 | buffering_mode(:display_buffer => 1) 244 | end 245 | 246 | # Starts flashing LEDs marked as flashing automatically. 247 | # Stop flashing by calling flashing_on or flashing_off. 248 | # 249 | # Errors raised: 250 | # 251 | # [Launchpad::NoOutputAllowedError] when output is not enabled 252 | def flashing_auto 253 | buffering_mode(:flashing => true) 254 | end 255 | 256 | # Controls the two buffers. 257 | # 258 | # Optional options hash: 259 | # 260 | # [:display_buffer] which buffer to use for display, defaults to +0+ 261 | # [:update_buffer] which buffer to use for updates when :mode is set to :buffering, defaults to +0+ (see change) 262 | # [:copy] whether to copy the LEDs states from the new display_buffer over to the new update_buffer, true/false, defaults to false 263 | # [:flashing] whether to start flashing by automatically switching between the two buffers for display, true/false, defaults to false 264 | # 265 | # Errors raised: 266 | # 267 | # [Launchpad::NoOutputAllowedError] when output is not enabled 268 | def buffering_mode(opts = nil) 269 | opts = { 270 | :display_buffer => 0, 271 | :update_buffer => 0, 272 | :copy => false, 273 | :flashing => false 274 | }.merge(opts || {}) 275 | data = opts[:display_buffer] + 4 * opts[:update_buffer] + 32 276 | data += 16 if opts[:copy] 277 | data += 8 if opts[:flashing] 278 | output(Status::CC, Status::NIL, data) 279 | end 280 | 281 | # Reads user actions (button presses/releases) that haven't been handled yet. 282 | # This is non-blocking, so when nothing happend yet you'll get an empty array. 283 | # 284 | # Returns: 285 | # 286 | # an array of hashes with (see Launchpad for values): 287 | # 288 | # [:timestamp] integer indicating the time when the action occured 289 | # [:state] state of the button after action 290 | # [:type] type of the button 291 | # [:x] x coordinate 292 | # [:y] y coordinate 293 | # 294 | # Errors raised: 295 | # 296 | # [Launchpad::NoInputAllowedError] when input is not enabled 297 | def read_pending_actions 298 | Array(input).collect do |midi_message| 299 | (code, note, velocity) = midi_message[:message] 300 | data = { 301 | :timestamp => midi_message[:timestamp], 302 | :state => (velocity == 127 ? :down : :up) 303 | } 304 | data[:type] = CODE_NOTE_TO_DATA_TYPE[[code, note]] || :grid 305 | if data[:type] == :grid 306 | data[:x] = note % 16 307 | data[:y] = note / 16 308 | end 309 | data 310 | end 311 | end 312 | 313 | private 314 | 315 | # Creates input/output devices. 316 | # 317 | # Parameters: 318 | # 319 | # [+devices+] array of portmidi devices 320 | # [+device_type] class to instantiate (Portmidi::Input/Portmidi::Output) 321 | # 322 | # Options hash: 323 | # 324 | # [:id] id of the MIDI device to use 325 | # [:name] name of the MIDI device to use, 326 | # only used when :id is not specified, 327 | # defaults to "Launchpad" 328 | # 329 | # Returns: 330 | # 331 | # newly created device 332 | # 333 | # Errors raised: 334 | # 335 | # [Launchpad::NoSuchDeviceError] when device with ID or name specified does not exist 336 | # [Launchpad::DeviceBusyError] when device with ID or name specified is busy 337 | def create_device!(devices, device_type, opts) 338 | logger.debug "creating #{device_type} with #{opts.inspect}, choosing from portmidi devices #{devices.inspect}" 339 | id = opts[:id] 340 | if id.nil? 341 | name = opts[:name] || 'Launchpad' 342 | device = devices.select {|device| device.name == name}.first 343 | id = device.device_id unless device.nil? 344 | end 345 | if id.nil? 346 | message = "MIDI device #{opts[:id] || opts[:name]} doesn't exist" 347 | logger.fatal message 348 | raise NoSuchDeviceError.new(message) 349 | end 350 | device_type.new(id) 351 | rescue RuntimeError => e 352 | logger.fatal "error creating #{device_type}: #{e.inspect}" 353 | raise DeviceBusyError.new(e) 354 | end 355 | 356 | # Reads input from the MIDI device. 357 | # 358 | # Returns: 359 | # 360 | # an array of hashes with: 361 | # 362 | # [:message] an array of 363 | # MIDI status code, 364 | # MIDI data 1 (note), 365 | # MIDI data 2 (velocity) 366 | # and a fourth value 367 | # [:timestamp] integer indicating the time when the MIDI message was created 368 | # 369 | # Errors raised: 370 | # 371 | # [Launchpad::NoInputAllowedError] when output is not enabled 372 | def input 373 | if @input.nil? 374 | logger.error "trying to read from device that's not been initialized for input" 375 | raise NoInputAllowedError 376 | end 377 | @input.read(16) 378 | end 379 | 380 | # Writes data to the MIDI device. 381 | # 382 | # Parameters: 383 | # 384 | # [+status+] MIDI status code 385 | # [+data1+] MIDI data 1 (note) 386 | # [+data2+] MIDI data 2 (velocity) 387 | # 388 | # Errors raised: 389 | # 390 | # [Launchpad::NoOutputAllowedError] when output is not enabled 391 | def output(status, data1, data2) 392 | output_messages([message(status, data1, data2)]) 393 | end 394 | 395 | # Writes several messages to the MIDI device. 396 | # 397 | # Parameters: 398 | # 399 | # [+messages+] an array of hashes (usually created with message) with: 400 | # [:message] an array of 401 | # MIDI status code, 402 | # MIDI data 1 (note), 403 | # MIDI data 2 (velocity) 404 | # [:timestamp] integer indicating the time when the MIDI message was created 405 | def output_messages(messages) 406 | if @output.nil? 407 | logger.error "trying to write to device that's not been initialized for output" 408 | raise NoOutputAllowedError 409 | end 410 | logger.debug "writing messages to launchpad:\n #{messages.join("\n ")}" if logger.debug? 411 | @output.write(messages) 412 | nil 413 | end 414 | 415 | # Calculates the MIDI data 1 value (note) for a button. 416 | # 417 | # Parameters (see Launchpad for values): 418 | # 419 | # [+type+] type of the button 420 | # 421 | # Options hash: 422 | # 423 | # [:x] x coordinate 424 | # [:y] y coordinate 425 | # 426 | # Returns: 427 | # 428 | # integer to be used for MIDI data 1 429 | # 430 | # Errors raised: 431 | # 432 | # [Launchpad::NoValidGridCoordinatesError] when coordinates aren't within the valid range 433 | def note(type, opts) 434 | note = TYPE_TO_NOTE[type] 435 | if note.nil? 436 | x = (opts[:x] || -1).to_i 437 | y = (opts[:y] || -1).to_i 438 | if x < 0 || x > 7 || y < 0 || y > 7 439 | logger.error "wrong coordinates specified: x=#{x}, y=#{y}" 440 | raise NoValidGridCoordinatesError.new("you need to specify valid coordinates (x/y, 0-7, from top left), you specified: x=#{x}, y=#{y}") 441 | end 442 | note = y * 16 + x 443 | end 444 | note 445 | end 446 | 447 | # Calculates the MIDI data 2 value (velocity) for given brightness and mode values. 448 | # 449 | # Options hash: 450 | # 451 | # [:red] brightness of red LED 452 | # [:green] brightness of green LED 453 | # [:mode] button mode, defaults to :normal, one of: 454 | # [:normal/tt>] updates the LED for all circumstances (the new value will be written to both buffers) 455 | # [:flashing/tt>] updates the LED for flashing (the new value will be written to buffer 0 while in buffer 1, the value will be :off, see ) 456 | # [:buffering/tt>] updates the LED for the current update_buffer only 457 | # 458 | # Returns: 459 | # 460 | # integer to be used for MIDI data 2 461 | # 462 | # Errors raised: 463 | # 464 | # [Launchpad::NoValidBrightnessError] when brightness values aren't within the valid range 465 | def velocity(opts) 466 | if opts.is_a?(Hash) 467 | red = brightness(opts[:red] || 0) 468 | green = brightness(opts[:green] || 0) 469 | color = 16 * green + red 470 | flags = case opts[:mode] 471 | when :flashing then 8 472 | when :buffering then 0 473 | else 12 474 | end 475 | color + flags 476 | else 477 | opts.to_i + 12 478 | end 479 | end 480 | 481 | # Calculates the integer brightness for given brightness values. 482 | # 483 | # Parameters (see Launchpad for values): 484 | # 485 | # [+brightness+] brightness 486 | # 487 | # Errors raised: 488 | # 489 | # [Launchpad::NoValidBrightnessError] when brightness values aren't within the valid range 490 | def brightness(brightness) 491 | case brightness 492 | when 0, :off then 0 493 | when 1, :low, :lo then 1 494 | when 2, :medium, :med then 2 495 | when 3, :high, :hi then 3 496 | else 497 | logger.error "wrong brightness specified: #{brightness}" 498 | raise NoValidBrightnessError.new("you need to specify the brightness as 0/1/2/3, :off/:low/:medium/:high or :off/:lo/:hi, you specified: #{brightness}") 499 | end 500 | end 501 | 502 | # Creates a MIDI message. 503 | # 504 | # Parameters: 505 | # 506 | # [+status+] MIDI status code 507 | # [+data1+] MIDI data 1 (note) 508 | # [+data2+] MIDI data 2 (velocity) 509 | # 510 | # Returns: 511 | # 512 | # an array with: 513 | # 514 | # [:message] an array of 515 | # MIDI status code, 516 | # MIDI data 1 (note), 517 | # MIDI data 2 (velocity) 518 | # [:timestamp] integer indicating the time when the MIDI message was created, in this case 0 519 | def message(status, data1, data2) 520 | {:message => [status, data1, data2], :timestamp => 0} 521 | end 522 | 523 | end 524 | 525 | end 526 | -------------------------------------------------------------------------------- /lib/launchpad/errors.rb: -------------------------------------------------------------------------------- 1 | module Launchpad 2 | 3 | # Generic launchpad error. 4 | class LaunchpadError < StandardError; end 5 | 6 | # Error raised when the MIDI device specified doesn't exist. 7 | class NoSuchDeviceError < LaunchpadError; end 8 | 9 | # Error raised when the MIDI device specified is busy. 10 | class DeviceBusyError < LaunchpadError; end 11 | 12 | # Error raised when an input has been requested, although 13 | # launchpad has been initialized without input. 14 | class NoInputAllowedError < LaunchpadError; end 15 | 16 | # Error raised when an output has been requested, although 17 | # launchpad has been initialized without output. 18 | class NoOutputAllowedError < LaunchpadError; end 19 | 20 | # Error raised when x/y coordinates outside of the grid 21 | # or none were specified. 22 | class NoValidGridCoordinatesError < LaunchpadError; end 23 | 24 | # Error raised when wrong brightness was specified. 25 | class NoValidBrightnessError < LaunchpadError; end 26 | 27 | # Error raised when anything fails while communicating 28 | # with the launchpad. 29 | class CommunicationError < LaunchpadError 30 | attr_accessor :source 31 | def initialize(e) 32 | super(e.portmidi_error) 33 | self.source = e 34 | end 35 | end 36 | 37 | end 38 | -------------------------------------------------------------------------------- /lib/launchpad/interaction.rb: -------------------------------------------------------------------------------- 1 | require 'launchpad/device' 2 | require 'launchpad/logging' 3 | 4 | module Launchpad 5 | 6 | # This class provides advanced interaction features. 7 | # 8 | # Example: 9 | # 10 | # require 'launchpad' 11 | # 12 | # interaction = Launchpad::Interaction.new 13 | # interaction.response_to(:grid, :down) do |interaction, action| 14 | # interaction.device.change(:grid, action.merge(:red => :high)) 15 | # end 16 | # interaction.response_to(:mixer, :down) do |interaction, action| 17 | # interaction.stop 18 | # end 19 | # 20 | # interaction.start 21 | class Interaction 22 | 23 | include Logging 24 | 25 | # Returns the Launchpad::Device the Launchpad::Interaction acts on. 26 | attr_reader :device 27 | 28 | # Returns whether the Launchpad::Interaction is active or not. 29 | attr_reader :active 30 | 31 | # Initializes the interaction. 32 | # 33 | # Optional options hash: 34 | # 35 | # [:device] Launchpad::Device to act on, 36 | # optional, :input_device_id/:output_device_id will be used if omitted 37 | # [:input_device_id] ID of the MIDI input device to use, 38 | # optional, :device_name will be used if omitted 39 | # [:output_device_id] ID of the MIDI output device to use, 40 | # optional, :device_name will be used if omitted 41 | # [:device_name] Name of the MIDI device to use, 42 | # optional, defaults to "Launchpad" 43 | # [:latency] delay (in s, fractions allowed) between MIDI pulls, 44 | # optional, defaults to 0.001 (1ms) 45 | # [:logger] [Logger] to be used by this interaction instance, can be changed afterwards 46 | # 47 | # Errors raised: 48 | # 49 | # [Launchpad::NoSuchDeviceError] when device with ID or name specified does not exist 50 | # [Launchpad::DeviceBusyError] when device with ID or name specified is busy 51 | def initialize(opts = nil) 52 | opts ||= {} 53 | 54 | self.logger = opts[:logger] 55 | logger.debug "initializing Launchpad::Interaction##{object_id} with #{opts.inspect}" 56 | 57 | @device = opts[:device] 58 | @device ||= Device.new(opts.merge( 59 | :input => true, 60 | :output => true, 61 | :logger => opts[:logger] 62 | )) 63 | @latency = (opts[:latency] || 0.001).to_f.abs 64 | @active = false 65 | 66 | @action_threads = ThreadGroup.new 67 | end 68 | 69 | # Sets the logger to be used by the current instance and the device. 70 | # 71 | # [+logger+] the [Logger] instance 72 | def logger=(logger) 73 | @logger = logger 74 | @device.logger = logger if @device 75 | end 76 | 77 | # Closes the interaction's device - nothing can be done with the interaction/device afterwards. 78 | # 79 | # Errors raised: 80 | # 81 | # [Launchpad::NoInputAllowedError] when input is not enabled on the interaction's device 82 | # [Launchpad::CommunicationError] when anything unexpected happens while communicating with the 83 | def close 84 | logger.debug "closing Launchpad::Interaction##{object_id}" 85 | stop 86 | @device.close 87 | end 88 | 89 | # Determines whether this interaction's device has been closed. 90 | def closed? 91 | @device.closed? 92 | end 93 | 94 | # Starts interacting with the launchpad. Resets the device when 95 | # the interaction was properly stopped via stop or close. 96 | # 97 | # Optional options hash: 98 | # 99 | # [:detached] true/false, 100 | # whether to detach the interaction, method is blocking when +false+, 101 | # optional, defaults to +false+ 102 | # 103 | # Errors raised: 104 | # 105 | # [Launchpad::NoInputAllowedError] when input is not enabled on the interaction's device 106 | # [Launchpad::NoOutputAllowedError] when output is not enabled on the interaction's device 107 | # [Launchpad::CommunicationError] when anything unexpected happens while communicating with the launchpad 108 | def start(opts = nil) 109 | logger.debug "starting Launchpad::Interaction##{object_id}" 110 | 111 | opts = { 112 | :detached => false 113 | }.merge(opts || {}) 114 | 115 | @active = true 116 | 117 | @reader_thread ||= Thread.new do 118 | begin 119 | while @active do 120 | @device.read_pending_actions.each do |action| 121 | action_thread = Thread.new(action) do |action| 122 | respond_to_action(action) 123 | end 124 | @action_threads.add(action_thread) 125 | end 126 | sleep @latency# if @latency > 0.0 127 | end 128 | rescue Portmidi::DeviceError => e 129 | logger.fatal "could not read from device, stopping to read actions" 130 | raise CommunicationError.new(e) 131 | rescue Exception => e 132 | logger.fatal "error causing action reading to stop: #{e.inspect}" 133 | raise e 134 | ensure 135 | @device.reset 136 | end 137 | end 138 | @reader_thread.join unless opts[:detached] 139 | end 140 | 141 | # Stops interacting with the launchpad. 142 | # 143 | # Errors raised: 144 | # 145 | # [Launchpad::NoInputAllowedError] when input is not enabled on the interaction's device 146 | # [Launchpad::CommunicationError] when anything unexpected happens while communicating with the 147 | def stop 148 | logger.debug "stopping Launchpad::Interaction##{object_id}" 149 | @active = false 150 | if @reader_thread 151 | # run (resume from sleep) and wait for @reader_thread to end 152 | @reader_thread.run if @reader_thread.alive? 153 | @reader_thread.join 154 | @reader_thread = nil 155 | end 156 | ensure 157 | @action_threads.list.each do |thread| 158 | begin 159 | thread.kill 160 | thread.join 161 | rescue Exception => e 162 | logger.error "error when killing action thread: #{e.inspect}" 163 | end 164 | end 165 | nil 166 | end 167 | 168 | # Registers a response to one or more actions. 169 | # 170 | # Parameters (see Launchpad for values): 171 | # 172 | # [+types+] one or an array of button types to respond to, 173 | # additional value :all for all buttons 174 | # [+state+] button state to respond to, 175 | # additional value :both 176 | # 177 | # Optional options hash: 178 | # 179 | # [:exclusive] true/false, 180 | # whether to deregister all other responses to the specified actions, 181 | # optional, defaults to +false+ 182 | # [:x] x coordinate(s), can contain arrays and ranges, when specified 183 | # without y coordinate, it's interpreted as a whole column 184 | # [:y] y coordinate(s), can contain arrays and ranges, when specified 185 | # without x coordinate, it's interpreted as a whole row 186 | # 187 | # Takes a block which will be called when an action matching the parameters occurs. 188 | # 189 | # Block parameters: 190 | # 191 | # [+interaction+] the interaction object that received the action 192 | # [+action+] the action received from Launchpad::Device.read_pending_actions 193 | def response_to(types = :all, state = :both, opts = nil, &block) 194 | logger.debug "setting response to #{types.inspect} for state #{state.inspect} with #{opts.inspect}" 195 | types = Array(types) 196 | opts ||= {} 197 | no_response_to(types, state) if opts[:exclusive] == true 198 | Array(state == :both ? %w(down up) : state).each do |state| 199 | types.each do |type| 200 | combined_types(type, opts).each do |combined_type| 201 | responses[combined_type][state.to_sym] << block 202 | end 203 | end 204 | end 205 | nil 206 | end 207 | 208 | # Deregisters all responses to one or more actions. 209 | # 210 | # Parameters (see Launchpad for values): 211 | # 212 | # [+types+] one or an array of button types to respond to, 213 | # additional value :all for actions on all buttons 214 | # (but not meaning "all responses"), 215 | # optional, defaults to +nil+, meaning "all responses" 216 | # [+state+] button state to respond to, 217 | # additional value :both 218 | # 219 | # Optional options hash: 220 | # 221 | # [:x] x coordinate(s), can contain arrays and ranges, when specified 222 | # without y coordinate, it's interpreted as a whole column 223 | # [:y] y coordinate(s), can contain arrays and ranges, when specified 224 | # without x coordinate, it's interpreted as a whole row 225 | def no_response_to(types = nil, state = :both, opts = nil) 226 | logger.debug "removing response to #{types.inspect} for state #{state.inspect}" 227 | types = Array(types) 228 | Array(state == :both ? %w(down up) : state).each do |state| 229 | types.each do |type| 230 | combined_types(type, opts).each do |combined_type| 231 | responses[combined_type][state.to_sym].clear 232 | end 233 | end 234 | end 235 | nil 236 | end 237 | 238 | # Responds to an action by executing all matching responses, effectively simulating 239 | # a button press/release. 240 | # 241 | # Parameters (see Launchpad for values): 242 | # 243 | # [+type+] type of the button to trigger 244 | # [+state+] state of the button 245 | # 246 | # Optional options hash (see Launchpad for values): 247 | # 248 | # [:x] x coordinate 249 | # [:y] y coordinate 250 | def respond_to(type, state, opts = nil) 251 | respond_to_action((opts || {}).merge(:type => type, :state => state)) 252 | end 253 | 254 | private 255 | 256 | # Returns the hash storing all responses. Keys are button types, values are 257 | # hashes themselves, keys are :down/:up, values are arrays of responses. 258 | def responses 259 | @responses ||= Hash.new {|hash, key| hash[key] = {:down => [], :up => []}} 260 | end 261 | 262 | # Returns an array of grid positions for a range. 263 | # 264 | # Parameters: 265 | # 266 | # [+range+] the range definitions, can be 267 | # * a Fixnum 268 | # * a Range 269 | # * an Array of Fixnum, Range or Array objects 270 | def grid_range(range) 271 | return nil if range.nil? 272 | Array(range).flatten.map do |pos| 273 | pos.respond_to?(:to_a) ? pos.to_a : pos 274 | end.flatten.uniq 275 | end 276 | 277 | # Returns a list of combined types for the type and opts specified. Combined 278 | # types are just the type, except for grid, where the opts are interpreted 279 | # and all combinations of x and y coordinates are added as a position suffix. 280 | # 281 | # Example: 282 | # 283 | # combined_types(:grid, :x => 1..2, y => 2) => [:grid12, :grid22] 284 | # 285 | # Parameters (see Launchpad for values): 286 | # 287 | # [+type+] type of the button 288 | # 289 | # Optional options hash: 290 | # 291 | # [:x] x coordinate(s), can contain arrays and ranges, when specified 292 | # without y coordinate, it's interpreted as a whole column 293 | # [:y] y coordinate(s), can contain arrays and ranges, when specified 294 | # without x coordinate, it's interpreted as a whole row 295 | def combined_types(type, opts = nil) 296 | if type.to_sym == :grid && opts 297 | x = grid_range(opts[:x]) 298 | y = grid_range(opts[:y]) 299 | return [:grid] if x.nil? && y.nil? # whole grid 300 | x ||= ['-'] # whole row 301 | y ||= ['-'] # whole column 302 | x.product(y).map {|x, y| :"grid#{x}#{y}"} 303 | else 304 | [type.to_sym] 305 | end 306 | end 307 | 308 | # Reponds to an action by executing all matching responses. 309 | # 310 | # Parameters: 311 | # 312 | # [+action+] hash containing an action from Launchpad::Device.read_pending_actions 313 | def respond_to_action(action) 314 | type = action[:type].to_sym 315 | state = action[:state].to_sym 316 | actions = [] 317 | if type == :grid 318 | actions += responses[:"grid#{action[:x]}#{action[:y]}"][state] 319 | actions += responses[:"grid#{action[:x]}-"][state] 320 | actions += responses[:"grid-#{action[:y]}"][state] 321 | end 322 | actions += responses[type][state] 323 | actions += responses[:all][state] 324 | actions.compact.each {|block| block.call(self, action)} 325 | nil 326 | rescue Exception => e 327 | logger.error "error when responding to action #{action.inspect}: #{e.inspect}" 328 | raise e 329 | end 330 | 331 | end 332 | 333 | end -------------------------------------------------------------------------------- /lib/launchpad/logging.rb: -------------------------------------------------------------------------------- 1 | require 'logger' 2 | 3 | module Launchpad 4 | 5 | # This module provides logging facilities. Just include it to be able to log 6 | # stuff. 7 | module Logging 8 | 9 | # Returns the logger to be used by the current instance. 10 | # 11 | # Returns: 12 | # 13 | # the logger set externally or a logger that swallows everything 14 | def logger 15 | @logger ||= Logger.new(nil) 16 | end 17 | 18 | # Sets the logger to be used by the current instance. 19 | # 20 | # [+logger+] the [Logger] instance 21 | def logger=(logger) 22 | @logger = logger 23 | end 24 | 25 | end 26 | 27 | end 28 | -------------------------------------------------------------------------------- /lib/launchpad/midi_codes.rb: -------------------------------------------------------------------------------- 1 | module Launchpad 2 | 3 | # Module defining constants for MIDI codes. 4 | module MidiCodes 5 | 6 | # Module defining MIDI status codes. 7 | module Status 8 | NIL = 0x00 9 | OFF = 0x80 10 | ON = 0x90 11 | MULTI = 0x92 12 | CC = 0xB0 13 | end 14 | 15 | # Module defininig MIDI data 1 (note) codes for control buttons. 16 | module ControlButton 17 | UP = 0x68 18 | DOWN = 0x69 19 | LEFT = 0x6A 20 | RIGHT = 0x6B 21 | SESSION = 0x6C 22 | USER1 = 0x6D 23 | USER2 = 0x6E 24 | MIXER = 0x6F 25 | end 26 | 27 | # Module defininig MIDI data 1 (note) codes for scene buttons. 28 | module SceneButton 29 | SCENE1 = 0x08 30 | SCENE2 = 0x18 31 | SCENE3 = 0x28 32 | SCENE4 = 0x38 33 | SCENE5 = 0x48 34 | SCENE6 = 0x58 35 | SCENE7 = 0x68 36 | SCENE8 = 0x78 37 | end 38 | 39 | # Module defining MIDI data 2 (velocity) codes. 40 | module Velocity 41 | TEST_LEDS = 0x7C 42 | end 43 | 44 | # Module defining MIDI data 2 codes for selecting the grid layout. 45 | module GridLayout 46 | XY = 0x01 47 | DRUM_RACK = 0x02 48 | end 49 | 50 | end 51 | 52 | end 53 | -------------------------------------------------------------------------------- /lib/launchpad/version.rb: -------------------------------------------------------------------------------- 1 | module Launchpad 2 | VERSION = '0.3.0' 3 | end -------------------------------------------------------------------------------- /test/helper.rb: -------------------------------------------------------------------------------- 1 | require 'minitest/spec' 2 | require 'minitest/autorun' 3 | 4 | begin 5 | require 'minitest/reporters' 6 | MiniTest::Reporters.use! 7 | rescue LoadError 8 | # ignore when it's not there - must be ruby 1.8 9 | end 10 | 11 | require 'mocha/setup' 12 | 13 | require 'launchpad' 14 | 15 | # mock Portmidi for tests 16 | module Portmidi 17 | 18 | class Input 19 | attr_accessor :device_id 20 | def initialize(device_id) 21 | self.device_id = device_id 22 | end 23 | def read(*args); nil; end 24 | def close; nil; end 25 | end 26 | 27 | class Output 28 | attr_accessor :device_id 29 | def initialize(device_id) 30 | self.device_id = device_id 31 | end 32 | def write(*args); nil; end 33 | def close; nil; end 34 | end 35 | 36 | def self.input_devices; mock_devices; end 37 | def self.output_devices; mock_devices; end 38 | def self.start; end 39 | 40 | end 41 | 42 | def mock_devices(opts = {}) 43 | [Portmidi::Device.new(opts[:id] || 1, 0, 0, opts[:name] || 'Launchpad')] 44 | end 45 | -------------------------------------------------------------------------------- /test/test_device.rb: -------------------------------------------------------------------------------- 1 | require 'helper' 2 | 3 | describe Launchpad::Device do 4 | 5 | CONTROL_BUTTONS = { 6 | :up => 0x68, 7 | :down => 0x69, 8 | :left => 0x6A, 9 | :right => 0x6B, 10 | :session => 0x6C, 11 | :user1 => 0x6D, 12 | :user2 => 0x6E, 13 | :mixer => 0x6F 14 | } 15 | SCENE_BUTTONS = { 16 | :scene1 => 0x08, 17 | :scene2 => 0x18, 18 | :scene3 => 0x28, 19 | :scene4 => 0x38, 20 | :scene5 => 0x48, 21 | :scene6 => 0x58, 22 | :scene7 => 0x68, 23 | :scene8 => 0x78 24 | } 25 | COLORS = { 26 | nil => 0, 0 => 0, :off => 0, 27 | 1 => 1, :lo => 1, :low => 1, 28 | 2 => 2, :med => 2, :medium => 2, 29 | 3 => 3, :hi => 3, :high => 3 30 | } 31 | STATES = { 32 | :down => 127, 33 | :up => 0 34 | } 35 | 36 | def expects_output(device, *args) 37 | args = [args] unless args.first.is_a?(Array) 38 | messages = args.collect {|data| {:message => data, :timestamp => 0}} 39 | device.instance_variable_get('@output').expects(:write).with(messages) 40 | end 41 | 42 | def stub_input(device, *args) 43 | device.instance_variable_get('@input').stubs(:read).returns(args) 44 | end 45 | 46 | describe '#initialize' do 47 | 48 | it 'tries to initialize both input and output when not specified' do 49 | Portmidi.expects(:input_devices).returns(mock_devices) 50 | Portmidi.expects(:output_devices).returns(mock_devices) 51 | d = Launchpad::Device.new 52 | refute_nil d.instance_variable_get('@input') 53 | refute_nil d.instance_variable_get('@output') 54 | end 55 | 56 | it 'does not try to initialize input when set to false' do 57 | Portmidi.expects(:input_devices).never 58 | d = Launchpad::Device.new(:input => false) 59 | assert_nil d.instance_variable_get('@input') 60 | refute_nil d.instance_variable_get('@output') 61 | end 62 | 63 | it 'does not try to initialize output when set to false' do 64 | Portmidi.expects(:output_devices).never 65 | d = Launchpad::Device.new(:output => false) 66 | refute_nil d.instance_variable_get('@input') 67 | assert_nil d.instance_variable_get('@output') 68 | end 69 | 70 | it 'does not try to initialize any of both when set to false' do 71 | Portmidi.expects(:input_devices).never 72 | Portmidi.expects(:output_devices).never 73 | d = Launchpad::Device.new(:input => false, :output => false) 74 | assert_nil d.instance_variable_get('@input') 75 | assert_nil d.instance_variable_get('@output') 76 | end 77 | 78 | it 'initializes the correct input output devices when specified by name' do 79 | Portmidi.stubs(:input_devices).returns(mock_devices(:id => 4, :name => 'Launchpad Name')) 80 | Portmidi.stubs(:output_devices).returns(mock_devices(:id => 5, :name => 'Launchpad Name')) 81 | d = Launchpad::Device.new(:device_name => 'Launchpad Name') 82 | assert_equal Portmidi::Input, (input = d.instance_variable_get('@input')).class 83 | assert_equal 4, input.device_id 84 | assert_equal Portmidi::Output, (output = d.instance_variable_get('@output')).class 85 | assert_equal 5, output.device_id 86 | end 87 | 88 | it 'initializes the correct input output devices when specified by id' do 89 | Portmidi.stubs(:input_devices).returns(mock_devices(:id => 4)) 90 | Portmidi.stubs(:output_devices).returns(mock_devices(:id => 5)) 91 | d = Launchpad::Device.new(:input_device_id => 4, :output_device_id => 5, :device_name => 'nonexistant') 92 | assert_equal Portmidi::Input, (input = d.instance_variable_get('@input')).class 93 | assert_equal 4, input.device_id 94 | assert_equal Portmidi::Output, (output = d.instance_variable_get('@output')).class 95 | assert_equal 5, output.device_id 96 | end 97 | 98 | it 'raises NoSuchDeviceError when requested input device does not exist' do 99 | assert_raises Launchpad::NoSuchDeviceError do 100 | Portmidi.stubs(:input_devices).returns(mock_devices(:name => 'Launchpad Input')) 101 | Launchpad::Device.new 102 | end 103 | end 104 | 105 | it 'raises NoSuchDeviceError when requested output device does not exist' do 106 | assert_raises Launchpad::NoSuchDeviceError do 107 | Portmidi.stubs(:output_devices).returns(mock_devices(:name => 'Launchpad Output')) 108 | Launchpad::Device.new 109 | end 110 | end 111 | 112 | it 'raises DeviceBusyError when requested input device is busy' do 113 | assert_raises Launchpad::DeviceBusyError do 114 | Portmidi::Input.stubs(:new).raises(RuntimeError) 115 | Launchpad::Device.new 116 | end 117 | end 118 | 119 | it 'raises DeviceBusyError when requested output device is busy' do 120 | assert_raises Launchpad::DeviceBusyError do 121 | Portmidi::Output.stubs(:new).raises(RuntimeError) 122 | Launchpad::Device.new 123 | end 124 | end 125 | 126 | it 'stores the logger given' do 127 | logger = Logger.new(nil) 128 | device = Launchpad::Device.new(:logger => logger) 129 | assert_same logger, device.logger 130 | end 131 | 132 | end 133 | 134 | describe '#close' do 135 | 136 | it 'does not fail when neither input nor output are there' do 137 | Launchpad::Device.new(:input => false, :output => false).close 138 | end 139 | 140 | describe 'with input and output devices' do 141 | 142 | before do 143 | Portmidi::Input.stubs(:new).returns(@input = mock('input')) 144 | Portmidi::Output.stubs(:new).returns(@output = mock('output', :write => nil)) 145 | @device = Launchpad::Device.new 146 | end 147 | 148 | it 'closes input/output and raise NoInputAllowedError/NoOutputAllowedError on subsequent read/write accesses' do 149 | @input.expects(:close) 150 | @output.expects(:close) 151 | @device.close 152 | assert_raises Launchpad::NoInputAllowedError do 153 | @device.read_pending_actions 154 | end 155 | assert_raises Launchpad::NoOutputAllowedError do 156 | @device.change(:session) 157 | end 158 | end 159 | 160 | end 161 | 162 | end 163 | 164 | describe '#closed?' do 165 | 166 | it 'returns true when neither input nor output are there' do 167 | assert Launchpad::Device.new(:input => false, :output => false).closed? 168 | end 169 | 170 | it 'returns false when initialized with input' do 171 | assert !Launchpad::Device.new(:input => true, :output => false).closed? 172 | end 173 | 174 | it 'returns false when initialized with output' do 175 | assert !Launchpad::Device.new(:input => false, :output => true).closed? 176 | end 177 | 178 | it 'returns false when initialized with both but true after calling close' do 179 | d = Launchpad::Device.new 180 | assert !d.closed? 181 | d.close 182 | assert d.closed? 183 | end 184 | 185 | end 186 | 187 | { 188 | :reset => [0xB0, 0x00, 0x00], 189 | :flashing_on => [0xB0, 0x00, 0x20], 190 | :flashing_off => [0xB0, 0x00, 0x21], 191 | :flashing_auto => [0xB0, 0x00, 0x28] 192 | }.each do |method, codes| 193 | describe "##{method}" do 194 | 195 | it 'raises NoOutputAllowedError when not initialized with output' do 196 | assert_raises Launchpad::NoOutputAllowedError do 197 | Launchpad::Device.new(:output => false).send(method) 198 | end 199 | end 200 | 201 | it "sends #{codes.inspect}" do 202 | d = Launchpad::Device.new 203 | expects_output(d, *codes) 204 | d.send(method) 205 | end 206 | 207 | end 208 | end 209 | 210 | describe '#test_leds' do 211 | 212 | it 'raises NoOutputAllowedError when not initialized with output' do 213 | assert_raises Launchpad::NoOutputAllowedError do 214 | Launchpad::Device.new(:output => false).test_leds 215 | end 216 | end 217 | 218 | describe 'initialized with output' do 219 | 220 | before do 221 | @device = Launchpad::Device.new(:input => false) 222 | end 223 | 224 | it 'returns nil' do 225 | assert_nil @device.test_leds 226 | end 227 | 228 | COLORS.merge(nil => 3).each do |name, value| 229 | if value == 0 230 | it "sends 0xB0, 0x00, 0x00 when given #{name}" do 231 | expects_output(@device, 0xB0, 0x00, 0x00) 232 | @device.test_leds(value) 233 | end 234 | else 235 | it "sends 0xB0, 0x00, 0x7C + #{value} when given #{name}" do 236 | d = Launchpad::Device.new 237 | expects_output(@device, 0xB0, 0x00, 0x7C + value) 238 | value.nil? ? @device.test_leds : @device.test_leds(value) 239 | end 240 | end 241 | end 242 | 243 | end 244 | 245 | end 246 | 247 | describe '#change' do 248 | 249 | it 'raises NoOutputAllowedError when not initialized with output' do 250 | assert_raises Launchpad::NoOutputAllowedError do 251 | Launchpad::Device.new(:output => false).change(:up) 252 | end 253 | end 254 | 255 | describe 'initialized with output' do 256 | 257 | before do 258 | @device = Launchpad::Device.new(:input => false) 259 | end 260 | 261 | it 'returns nil' do 262 | assert_nil @device.change(:up) 263 | end 264 | 265 | describe 'control buttons' do 266 | CONTROL_BUTTONS.each do |type, value| 267 | it "sends 0xB0, #{value}, 12 when given #{type}" do 268 | expects_output(@device, 0xB0, value, 12) 269 | @device.change(type) 270 | end 271 | end 272 | end 273 | 274 | describe 'scene buttons' do 275 | SCENE_BUTTONS.each do |type, value| 276 | it "sends 0x90, #{value}, 12 when given #{type}" do 277 | expects_output(@device, 0x90, value, 12) 278 | @device.change(type) 279 | end 280 | end 281 | end 282 | 283 | describe 'grid buttons' do 284 | 8.times do |x| 285 | 8.times do |y| 286 | it "sends 0x90, #{16 * y + x}, 12 when given :grid, :x => #{x}, :y => #{y}" do 287 | expects_output(@device, 0x90, 16 * y + x, 12) 288 | @device.change(:grid, :x => x, :y => y) 289 | end 290 | end 291 | end 292 | 293 | it 'raises NoValidGridCoordinatesError if x is not specified' do 294 | assert_raises Launchpad::NoValidGridCoordinatesError do 295 | @device.change(:grid, :y => 1) 296 | end 297 | end 298 | 299 | it 'raises NoValidGridCoordinatesError if x is below 0' do 300 | assert_raises Launchpad::NoValidGridCoordinatesError do 301 | @device.change(:grid, :x => -1, :y => 1) 302 | end 303 | end 304 | 305 | it 'raises NoValidGridCoordinatesError if x is above 7' do 306 | assert_raises Launchpad::NoValidGridCoordinatesError do 307 | @device.change(:grid, :x => 8, :y => 1) 308 | end 309 | end 310 | 311 | it 'raises NoValidGridCoordinatesError if y is not specified' do 312 | assert_raises Launchpad::NoValidGridCoordinatesError do 313 | @device.change(:grid, :x => 1) 314 | end 315 | end 316 | 317 | it 'raises NoValidGridCoordinatesError if y is below 0' do 318 | assert_raises Launchpad::NoValidGridCoordinatesError do 319 | @device.change(:grid, :x => 1, :y => -1) 320 | end 321 | end 322 | 323 | it 'raises NoValidGridCoordinatesError if y is above 7' do 324 | assert_raises Launchpad::NoValidGridCoordinatesError do 325 | @device.change(:grid, :x => 1, :y => 8) 326 | end 327 | end 328 | 329 | end 330 | 331 | describe 'colors' do 332 | COLORS.each do |red_key, red_value| 333 | COLORS.each do |green_key, green_value| 334 | it "sends 0x90, 0, #{16 * green_value + red_value + 12} when given :red => #{red_key}, :green => #{green_key}" do 335 | expects_output(@device, 0x90, 0, 16 * green_value + red_value + 12) 336 | @device.change(:grid, :x => 0, :y => 0, :red => red_key, :green => green_key) 337 | end 338 | end 339 | end 340 | 341 | it 'raises NoValidBrightnessError if red is below 0' do 342 | assert_raises Launchpad::NoValidBrightnessError do 343 | @device.change(:grid, :x => 0, :y => 0, :red => -1) 344 | end 345 | end 346 | 347 | it 'raises NoValidBrightnessError if red is above 3' do 348 | assert_raises Launchpad::NoValidBrightnessError do 349 | @device.change(:grid, :x => 0, :y => 0, :red => 4) 350 | end 351 | end 352 | 353 | it 'raises NoValidBrightnessError if red is an unknown symbol' do 354 | assert_raises Launchpad::NoValidBrightnessError do 355 | @device.change(:grid, :x => 0, :y => 0, :red => :unknown) 356 | end 357 | end 358 | 359 | it 'raises NoValidBrightnessError if green is below 0' do 360 | assert_raises Launchpad::NoValidBrightnessError do 361 | @device.change(:grid, :x => 0, :y => 0, :green => -1) 362 | end 363 | end 364 | 365 | it 'raises NoValidBrightnessError if green is above 3' do 366 | assert_raises Launchpad::NoValidBrightnessError do 367 | @device.change(:grid, :x => 0, :y => 0, :green => 4) 368 | end 369 | end 370 | 371 | it 'raises NoValidBrightnessError if green is an unknown symbol' do 372 | assert_raises Launchpad::NoValidBrightnessError do 373 | @device.change(:grid, :x => 0, :y => 0, :green => :unknown) 374 | end 375 | end 376 | 377 | end 378 | 379 | describe 'mode' do 380 | 381 | it 'sends color + 12 when nothing given' do 382 | expects_output(@device, 0x90, 0, 12) 383 | @device.change(:grid, :x => 0, :y => 0, :red => 0, :green => 0) 384 | end 385 | 386 | it 'sends color + 12 when given :normal' do 387 | expects_output(@device, 0x90, 0, 12) 388 | @device.change(:grid, :x => 0, :y => 0, :red => 0, :green => 0, :mode => :normal) 389 | end 390 | 391 | it 'sends color + 8 when given :flashing' do 392 | expects_output(@device, 0x90, 0, 8) 393 | @device.change(:grid, :x => 0, :y => 0, :red => 0, :green => 0, :mode => :flashing) 394 | end 395 | 396 | it 'sends color when given :buffering' do 397 | expects_output(@device, 0x90, 0, 0) 398 | @device.change(:grid, :x => 0, :y => 0, :red => 0, :green => 0, :mode => :buffering) 399 | end 400 | 401 | end 402 | 403 | end 404 | 405 | end 406 | 407 | describe '#change_all' do 408 | 409 | it 'raises NoOutputAllowedError when not initialized with output' do 410 | assert_raises Launchpad::NoOutputAllowedError do 411 | Launchpad::Device.new(:output => false).change_all 412 | end 413 | end 414 | 415 | describe 'initialized with output' do 416 | 417 | before do 418 | @device = Launchpad::Device.new(:input => false) 419 | end 420 | 421 | it 'returns nil' do 422 | assert_nil @device.change_all([0]) 423 | end 424 | 425 | it 'fills colors with 0, set grid layout to XY and flush colors' do 426 | expects_output(@device, 0xB0, 0, 0x01) 427 | expects_output(@device, *([[0x92, 17, 17]] * 20 + [[0x92, 12, 12]] * 20)) 428 | @device.change_all([5] * 40) 429 | end 430 | 431 | it 'cuts off exceeding colors, set grid layout to XY and flush colors' do 432 | expects_output(@device, 0xB0, 0, 0x01) 433 | expects_output(@device, *([[0x92, 17, 17]] * 40)) 434 | @device.change_all([5] * 100) 435 | end 436 | 437 | end 438 | 439 | end 440 | 441 | describe '#buffering_mode' do 442 | 443 | it 'raises NoOutputAllowedError when not initialized with output' do 444 | assert_raises Launchpad::NoOutputAllowedError do 445 | Launchpad::Device.new(:output => false).buffering_mode 446 | end 447 | end 448 | 449 | { 450 | nil => [0xB0, 0x00, 0x20], 451 | {} => [0xB0, 0x00, 0x20], 452 | {:display_buffer => 1} => [0xB0, 0x00, 0x21], 453 | {:update_buffer => 1} => [0xB0, 0x00, 0x24], 454 | {:copy => true} => [0xB0, 0x00, 0x30], 455 | {:flashing => true} => [0xB0, 0x00, 0x28], 456 | { 457 | :display_buffer => 1, 458 | :update_buffer => 1, 459 | :copy => true, 460 | :flashing => true 461 | } => [0xB0, 0x00, 0x3D] 462 | }.each do |opts, codes| 463 | it "sends #{codes.inspect} when called with #{opts.inspect}" do 464 | d = Launchpad::Device.new 465 | expects_output(d, *codes) 466 | d.buffering_mode(opts) 467 | end 468 | end 469 | 470 | end 471 | 472 | describe '#read_pending_actions' do 473 | 474 | it 'raises NoInputAllowedError when not initialized with input' do 475 | assert_raises Launchpad::NoInputAllowedError do 476 | Launchpad::Device.new(:input => false).read_pending_actions 477 | end 478 | end 479 | 480 | describe 'initialized with input' do 481 | 482 | before do 483 | @device = Launchpad::Device.new(:output => false) 484 | end 485 | 486 | describe 'control buttons' do 487 | CONTROL_BUTTONS.each do |type, value| 488 | STATES.each do |state, velocity| 489 | it "builds proper action for control button #{type}, #{state}" do 490 | stub_input(@device, {:timestamp => 0, :message => [0xB0, value, velocity]}) 491 | assert_equal [{:timestamp => 0, :state => state, :type => type}], @device.read_pending_actions 492 | end 493 | end 494 | end 495 | end 496 | 497 | describe 'scene buttons' do 498 | SCENE_BUTTONS.each do |type, value| 499 | STATES.each do |state, velocity| 500 | it "builds proper action for scene button #{type}, #{state}" do 501 | stub_input(@device, {:timestamp => 0, :message => [0x90, value, velocity]}) 502 | assert_equal [{:timestamp => 0, :state => state, :type => type}], @device.read_pending_actions 503 | end 504 | end 505 | end 506 | end 507 | 508 | describe '#grid buttons' do 509 | 8.times do |x| 510 | 8.times do |y| 511 | STATES.each do |state, velocity| 512 | it "builds proper action for grid button #{x},#{y}, #{state}" do 513 | stub_input(@device, {:timestamp => 0, :message => [0x90, 16 * y + x, velocity]}) 514 | assert_equal [{:timestamp => 0, :state => state, :type => :grid, :x => x, :y => y}], @device.read_pending_actions 515 | end 516 | end 517 | end 518 | end 519 | end 520 | 521 | it 'builds proper actions for multiple pending actions' do 522 | stub_input(@device, {:timestamp => 1, :message => [0x90, 0, 127]}, {:timestamp => 2, :message => [0xB0, 0x68, 0]}) 523 | assert_equal [{:timestamp => 1, :state => :down, :type => :grid, :x => 0, :y => 0}, {:timestamp => 2, :state => :up, :type => :up}], @device.read_pending_actions 524 | end 525 | 526 | end 527 | 528 | end 529 | 530 | end 531 | -------------------------------------------------------------------------------- /test/test_interaction.rb: -------------------------------------------------------------------------------- 1 | require 'helper' 2 | require 'timeout' 3 | 4 | class BreakError < StandardError; end 5 | 6 | describe Launchpad::Interaction do 7 | 8 | # returns true/false whether the operation ended or the timeout was hit 9 | def timeout(timeout = 0.02, &block) 10 | Timeout.timeout(timeout, &block) 11 | true 12 | rescue Timeout::Error 13 | false 14 | end 15 | 16 | def press(interaction, type, opts = nil) 17 | interaction.respond_to(type, :down, opts) 18 | interaction.respond_to(type, :up, opts) 19 | end 20 | 21 | 22 | def press_all(interaction) 23 | %w(up down left right session user1 user2 mixer).each do |type| 24 | press(interaction, type.to_sym) 25 | end 26 | 8.times do |y| 27 | 8.times do |x| 28 | press(interaction, :grid, :x => x, :y => y) 29 | end 30 | press(interaction, :"scene#{y + 1}") 31 | end 32 | end 33 | 34 | describe '#initialize' do 35 | 36 | it 'creates device if not given' do 37 | device = Launchpad::Device.new 38 | Launchpad::Device.expects(:new). 39 | with(:input => true, :output => true, :logger => nil). 40 | returns(device) 41 | interaction = Launchpad::Interaction.new 42 | assert_same device, interaction.device 43 | end 44 | 45 | it 'creates device with given device_name' do 46 | device = Launchpad::Device.new 47 | Launchpad::Device.expects(:new). 48 | with(:device_name => 'device', :input => true, :output => true, :logger => nil). 49 | returns(device) 50 | interaction = Launchpad::Interaction.new(:device_name => 'device') 51 | assert_same device, interaction.device 52 | end 53 | 54 | it 'creates device with given input_device_id' do 55 | device = Launchpad::Device.new 56 | Launchpad::Device.expects(:new). 57 | with(:input_device_id => 'in', :input => true, :output => true, :logger => nil). 58 | returns(device) 59 | interaction = Launchpad::Interaction.new(:input_device_id => 'in') 60 | assert_same device, interaction.device 61 | end 62 | 63 | it 'creates device with given output_device_id' do 64 | device = Launchpad::Device.new 65 | Launchpad::Device.expects(:new). 66 | with(:output_device_id => 'out', :input => true, :output => true, :logger => nil). 67 | returns(device) 68 | interaction = Launchpad::Interaction.new(:output_device_id => 'out') 69 | assert_same device, interaction.device 70 | end 71 | 72 | it 'creates device with given input_device_id/output_device_id' do 73 | device = Launchpad::Device.new 74 | Launchpad::Device.expects(:new). 75 | with(:input_device_id => 'in', :output_device_id => 'out', :input => true, :output => true, :logger => nil). 76 | returns(device) 77 | interaction = Launchpad::Interaction.new(:input_device_id => 'in', :output_device_id => 'out') 78 | assert_same device, interaction.device 79 | end 80 | 81 | it 'initializes device if given' do 82 | device = Launchpad::Device.new 83 | interaction = Launchpad::Interaction.new(:device => device) 84 | assert_same device, interaction.device 85 | end 86 | 87 | it 'stores the logger given' do 88 | logger = Logger.new(nil) 89 | interaction = Launchpad::Interaction.new(:logger => logger) 90 | assert_same logger, interaction.logger 91 | assert_same logger, interaction.device.logger 92 | end 93 | 94 | it 'doesn\'t activate the interaction' do 95 | assert !Launchpad::Interaction.new.active 96 | end 97 | 98 | end 99 | 100 | describe '#logger=' do 101 | 102 | it 'stores the logger and passes it to the device as well' do 103 | logger = Logger.new(nil) 104 | interaction = Launchpad::Interaction.new 105 | interaction.logger = logger 106 | assert_same logger, interaction.logger 107 | assert_same logger, interaction.device.logger 108 | end 109 | 110 | end 111 | 112 | describe '#close' do 113 | 114 | it 'stops the interaction' do 115 | interaction = Launchpad::Interaction.new 116 | interaction.expects(:stop) 117 | interaction.close 118 | end 119 | 120 | it 'closes the device' do 121 | interaction = Launchpad::Interaction.new 122 | interaction.device.expects(:close) 123 | interaction.close 124 | end 125 | 126 | end 127 | 128 | describe '#closed?' do 129 | 130 | it 'returns false on a newly created interaction, but true after closing' do 131 | interaction = Launchpad::Interaction.new 132 | assert !interaction.closed? 133 | interaction.close 134 | assert interaction.closed? 135 | end 136 | 137 | end 138 | 139 | describe '#start' do 140 | 141 | before do 142 | @interaction = Launchpad::Interaction.new 143 | end 144 | 145 | after do 146 | mocha_teardown # so that expectations on Thread.join don't fail in here 147 | begin 148 | @interaction.close 149 | rescue 150 | # ignore, should be handled in tests, this is just to close all the spawned threads 151 | end 152 | end 153 | 154 | it 'sets active to true in blocking mode' do 155 | refute @interaction.active 156 | erg = timeout { @interaction.start } 157 | refute erg, 'there was no timeout' 158 | assert @interaction.active 159 | end 160 | 161 | it 'sets active to true in detached mode' do 162 | refute @interaction.active 163 | @interaction.start(:detached => true) 164 | assert @interaction.active 165 | end 166 | 167 | it 'blocks in blocking mode' do 168 | erg = timeout { @interaction.start } 169 | refute erg, 'there was no timeout' 170 | end 171 | 172 | it 'returns immediately in detached mode' do 173 | erg = timeout { @interaction.start(:detached => true) } 174 | assert erg, 'there was a timeout' 175 | end 176 | 177 | it 'raises CommunicationError when Portmidi::DeviceError occurs' do 178 | @interaction.device.stubs(:read_pending_actions).raises(Portmidi::DeviceError.new(0)) 179 | assert_raises Launchpad::CommunicationError do 180 | @interaction.start 181 | end 182 | end 183 | 184 | describe 'action handling' do 185 | 186 | before do 187 | @interaction.response_to(:mixer, :down) { @mixer_down = true } 188 | @interaction.response_to(:mixer, :up) do |i,a| 189 | sleep 0.001 # sleep to make "sure" :mixer :down has been processed 190 | i.stop 191 | end 192 | @interaction.device.expects(:read_pending_actions). 193 | at_least_once. 194 | returns([ 195 | { 196 | :timestamp => 0, 197 | :state => :down, 198 | :type => :mixer 199 | }, 200 | { 201 | :timestamp => 0, 202 | :state => :up, 203 | :type => :mixer 204 | } 205 | ]) 206 | end 207 | 208 | it 'calls respond_to_action with actions from respond_to_action in blocking mode' do 209 | erg = timeout(0.5) { @interaction.start } 210 | assert erg, 'the actions weren\'t called' 211 | assert @mixer_down, 'the mixer button wasn\'t pressed' 212 | end 213 | 214 | it 'calls respond_to_action with actions from respond_to_action in detached mode' do 215 | @interaction.start(:detached => true) 216 | erg = timeout(0.5) { while @interaction.active; sleep 0.01; end } 217 | assert erg, 'there was a timeout' 218 | assert @mixer_down, 'the mixer button wasn\'t pressed' 219 | end 220 | 221 | end 222 | 223 | describe 'latency' do 224 | 225 | before do 226 | @device = @interaction.device 227 | @times = [] 228 | @device.instance_variable_set("@test_interaction_latency_times", @times) 229 | def @device.read_pending_actions 230 | @test_interaction_latency_times << Time.now.to_f 231 | [] 232 | end 233 | end 234 | 235 | it 'sleeps with default latency of 0.001s when none given' do 236 | timeout { @interaction.start } 237 | assert @times.size > 1 238 | @times.each_cons(2) do |a,b| 239 | assert_in_delta 0.001, b - a, 0.01 240 | end 241 | end 242 | 243 | it 'sleeps with given latency' do 244 | @interaction = Launchpad::Interaction.new(:latency => 0.5, :device => @device) 245 | timeout(0.55) { @interaction.start } 246 | assert @times.size > 1 247 | @times.each_cons(2) do |a,b| 248 | assert_in_delta 0.5, b - a, 0.01 249 | end 250 | end 251 | 252 | it 'sleeps with absolute value of given negative latency' do 253 | @interaction = Launchpad::Interaction.new(:latency => -0.1, :device => @device) 254 | timeout(0.15) { @interaction.start } 255 | assert @times.size > 1 256 | @times.each_cons(2) do |a,b| 257 | assert_in_delta 0.1, b - a, 0.01 258 | end 259 | end 260 | 261 | it 'does not sleep when latency is 0' do 262 | @interaction = Launchpad::Interaction.new(:latency => 0, :device => @device) 263 | timeout(0.001) { @interaction.start } 264 | assert @times.size > 1 265 | @times.each_cons(2) do |a,b| 266 | assert_in_delta 0, b - a, 0.1 267 | end 268 | end 269 | 270 | end 271 | 272 | it 'resets the device after the loop' do 273 | @interaction.device.expects(:reset) 274 | @interaction.start(:detached => true) 275 | @interaction.stop 276 | end 277 | 278 | it 'raises NoOutputAllowedError on closed interaction' do 279 | @interaction.close 280 | assert_raises Launchpad::NoOutputAllowedError do 281 | @interaction.start 282 | end 283 | end 284 | 285 | end 286 | 287 | describe '#stop' do 288 | 289 | before do 290 | @interaction = Launchpad::Interaction.new 291 | end 292 | 293 | it 'sets active to false in blocking mode' do 294 | erg = timeout { @interaction.start } 295 | refute erg, 'there was no timeout' 296 | assert @interaction.active 297 | @interaction.stop 298 | assert !@interaction.active 299 | end 300 | 301 | it 'sets active to false in detached mode' do 302 | @interaction.start(:detached => true) 303 | assert @interaction.active 304 | @interaction.stop 305 | assert !@interaction.active 306 | end 307 | 308 | it 'is callable anytime' do 309 | @interaction.stop 310 | @interaction.start(:detached => true) 311 | @interaction.stop 312 | @interaction.stop 313 | end 314 | 315 | # this is kinda greybox tested, since I couldn't come up with another way to test tread handling [thomas, 2010-01-24] 316 | it 'raises pending exceptions in detached mode' do 317 | t = Thread.new {raise BreakError} 318 | Thread.expects(:new).returns(t) 319 | @interaction.start(:detached => true) 320 | assert_raises BreakError do 321 | @interaction.stop 322 | end 323 | end 324 | 325 | end 326 | 327 | describe '#response_to/#no_response_to/#respond_to' do 328 | 329 | before do 330 | @interaction = Launchpad::Interaction.new 331 | end 332 | 333 | it 'calls all responses that match, and not others' do 334 | @interaction.response_to(:mixer, :down) {|i, a| @mixer_down = true} 335 | @interaction.response_to(:all, :down) {|i, a| @all_down = true} 336 | @interaction.response_to(:all, :up) {|i, a| @all_up = true} 337 | @interaction.response_to(:grid, :down) {|i, a| @grid_down = true} 338 | @interaction.respond_to(:mixer, :down) 339 | assert @mixer_down 340 | assert @all_down 341 | assert !@all_up 342 | assert !@grid_down 343 | end 344 | 345 | it 'does not call responses when they are deregistered' do 346 | @interaction.response_to(:mixer, :down) {|i, a| @mixer_down = true} 347 | @interaction.response_to(:mixer, :up) {|i, a| @mixer_up = true} 348 | @interaction.response_to(:all, :both) {|i, a| @all_down = a[:state] == :down} 349 | @interaction.no_response_to(:mixer, :down) 350 | @interaction.respond_to(:mixer, :down) 351 | assert !@mixer_down 352 | assert !@mixer_up 353 | assert @all_down 354 | @interaction.respond_to(:mixer, :up) 355 | assert !@mixer_down 356 | assert @mixer_up 357 | assert !@all_down 358 | end 359 | 360 | it 'does not call responses registered for both when removing for one of both states' do 361 | @interaction.response_to(:mixer, :both) {|i, a| @mixer = true} 362 | @interaction.no_response_to(:mixer, :down) 363 | @interaction.respond_to(:mixer, :down) 364 | assert !@mixer 365 | @interaction.respond_to(:mixer, :up) 366 | assert @mixer 367 | end 368 | 369 | it 'removes other responses when adding a new exclusive response' do 370 | @interaction.response_to(:mixer, :both) {|i, a| @mixer = true} 371 | @interaction.response_to(:mixer, :down, :exclusive => true) {|i, a| @exclusive_mixer = true} 372 | @interaction.respond_to(:mixer, :down) 373 | assert !@mixer 374 | assert @exclusive_mixer 375 | @interaction.respond_to(:mixer, :up) 376 | assert @mixer 377 | assert @exclusive_mixer 378 | end 379 | 380 | it 'allows for multiple types' do 381 | @downs = [] 382 | @interaction.response_to([:up, :down], :down) {|i, a| @downs << a[:type]} 383 | @interaction.respond_to(:up, :down) 384 | @interaction.respond_to(:down, :down) 385 | @interaction.respond_to(:up, :down) 386 | assert_equal [:up, :down, :up], @downs 387 | end 388 | 389 | describe 'allows to bind to specific grid buttons' do 390 | 391 | before do 392 | @downs = [] 393 | @action = lambda {|i, a| @downs << [a[:x], a[:y]]} 394 | end 395 | 396 | it 'one specific grid button' do 397 | @interaction.response_to(:grid, :down, :x => 4, :y => 2, &@action) 398 | press_all @interaction 399 | assert_equal [[4, 2]], @downs 400 | end 401 | 402 | it 'a complete row of grid buttons' do 403 | @interaction.response_to(:grid, :down, :y => 2, &@action) 404 | press_all @interaction 405 | assert_equal [[0, 2], [1, 2], [2, 2], [3, 2], [4, 2], [5, 2], [6, 2], [7, 2]], @downs 406 | end 407 | 408 | it 'a complete column of grid buttons' do 409 | @interaction.response_to(:grid, :down, :x => 3, &@action) 410 | press_all @interaction 411 | assert_equal [[3, 0], [3, 1], [3, 2], [3, 3], [3, 4], [3, 5], [3, 6], [3, 7]], @downs 412 | end 413 | 414 | it 'a complex range of grid buttons' do 415 | @interaction.response_to(:grid, :down, :x => [1,[2]], :y => [1, 3..5], &@action) 416 | press_all @interaction 417 | assert_equal [[1, 1], [2, 1], [1, 3], [2, 3], [1, 4], [2, 4], [1, 5], [2, 5]], @downs 418 | end 419 | 420 | it 'a specific grid buttons, a column, a row, all grid buttons and all buttons' do 421 | @interaction.response_to(:all, :down) {|i, a| @downs << [a[:x], a[:y], :all]} 422 | @interaction.response_to(:grid, :down) {|i, a| @downs << [a[:x], a[:y], :grid]} 423 | @interaction.response_to(:grid, :down, :x => 0) {|i, a| @downs << [a[:x], a[:y], :col]} 424 | @interaction.response_to(:grid, :down, :y => 0) {|i, a| @downs << [a[:x], a[:y], :row]} 425 | @interaction.response_to(:grid, :down, :x => 0, :y => 0, &@action) 426 | press @interaction, :grid, :x => 0, :y => 0 427 | assert_equal [[0, 0], [0, 0, :col], [0, 0, :row], [0, 0, :grid], [0, 0, :all]], @downs 428 | end 429 | 430 | end 431 | 432 | end 433 | 434 | describe 'regression tests' do 435 | 436 | it 'does not raise an exception or write an error to the logger when calling stop within a response in attached mode' do 437 | log = StringIO.new 438 | logger = Logger.new(log) 439 | logger.level = Logger::ERROR 440 | i = Launchpad::Interaction.new(:logger => logger) 441 | i.response_to(:mixer, :down) {|i,a| i.stop} 442 | i.device.expects(:read_pending_actions). 443 | at_least_once. 444 | returns([{ 445 | :timestamp => 0, 446 | :state => :down, 447 | :type => :mixer 448 | }]) 449 | erg = timeout { i.start } 450 | # assert erg, 'the actions weren\'t called' 451 | assert_equal '', log.string 452 | end 453 | 454 | end 455 | 456 | end 457 | --------------------------------------------------------------------------------