launchpad 0.0.2 → 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- data/README.rdoc +55 -8
- data/Rakefile +2 -1
- data/examples/color_picker.rb +99 -0
- data/examples/colors.rb +9 -8
- data/examples/doodle.rb +68 -0
- data/examples/drawing_board.rb +25 -0
- data/examples/feedback.rb +31 -2
- data/examples/reset.rb +6 -0
- data/examples/setup.rb +3 -1
- data/launchpad.gemspec +24 -4
- data/lib/launchpad.rb +2 -167
- data/lib/launchpad/device.rb +244 -0
- data/lib/launchpad/errors.rb +37 -0
- data/lib/launchpad/interaction.rb +89 -0
- data/lib/launchpad/midi_codes.rb +44 -0
- data/lib/launchpad/version.rb +2 -2
- data/test/helper.rb +37 -1
- data/test/test_device.rb +429 -0
- data/test/test_interaction.rb +183 -0
- metadata +38 -4
- data/test/test_launchpad-gem.rb +0 -7
@@ -0,0 +1,244 @@
|
|
1
|
+
require 'portmidi'
|
2
|
+
|
3
|
+
require 'launchpad/errors'
|
4
|
+
require 'launchpad/midi_codes'
|
5
|
+
require 'launchpad/version'
|
6
|
+
|
7
|
+
module Launchpad
|
8
|
+
|
9
|
+
class Device
|
10
|
+
|
11
|
+
include MidiCodes
|
12
|
+
|
13
|
+
# Initializes the launchpad
|
14
|
+
# {
|
15
|
+
# :device_name => Name of the MIDI device to use, optional, defaults to Launchpad
|
16
|
+
# :input => true/false, whether to use MIDI input for user interaction, optional, defaults to true
|
17
|
+
# :output => true/false, whether to use MIDI output for data display, optional, defaults to true
|
18
|
+
# }
|
19
|
+
def initialize(opts = nil)
|
20
|
+
opts = {
|
21
|
+
:device_name => 'Launchpad',
|
22
|
+
:input => true,
|
23
|
+
:output => true
|
24
|
+
}.merge(opts || {})
|
25
|
+
|
26
|
+
Portmidi.start
|
27
|
+
|
28
|
+
if opts[:input]
|
29
|
+
input_device = Portmidi.input_devices.select {|device| device.name == opts[:device_name]}.first
|
30
|
+
raise NoSuchDeviceError.new("MIDI input device #{opts[:device_name]} doesn't exist") if input_device.nil?
|
31
|
+
begin
|
32
|
+
@input = Portmidi::Input.new(input_device.device_id)
|
33
|
+
rescue RuntimeError => e
|
34
|
+
raise DeviceBusyError.new(e)
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
if opts[:output]
|
39
|
+
output_device = Portmidi.output_devices.select {|device| device.name == opts[:device_name]}.first
|
40
|
+
raise NoSuchDeviceError.new("MIDI output device #{opts[:device_name]} doesn't exist") if output_device.nil?
|
41
|
+
begin
|
42
|
+
@output = Portmidi::Output.new(output_device.device_id)
|
43
|
+
rescue RuntimeError => e
|
44
|
+
raise DeviceBusyError.new(e)
|
45
|
+
end
|
46
|
+
reset
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
# Resets the launchpad - all settings are reset and all LEDs are switched off
|
51
|
+
def reset
|
52
|
+
output(Status::CC, Status::NIL, Status::NIL)
|
53
|
+
end
|
54
|
+
|
55
|
+
# Lights all LEDs (for testing purposes)
|
56
|
+
# takes an optional parameter brightness (:off/:low/:medium/:high, defaults to :high)
|
57
|
+
def test_leds(brightness = :high)
|
58
|
+
brightness = brightness(brightness)
|
59
|
+
if brightness == 0
|
60
|
+
reset
|
61
|
+
else
|
62
|
+
output(Status::CC, Status::NIL, Velocity::TEST_LEDS + brightness)
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
# Changes a single LED
|
67
|
+
# type => one of :grid, :up, :down, :left, :right, :session, :user1, :user2, :mixer, :scene1 - :scene8
|
68
|
+
# opts => {
|
69
|
+
# :x => x coordinate (0 based from top left, mandatory if type is :grid)
|
70
|
+
# :y => y coordinate (0 based from top left, mandatory if type is :grid)
|
71
|
+
# :red => brightness of red LED (0-3, optional, defaults to 0)
|
72
|
+
# :green => brightness of red LED (0-3, optional, defaults to 0)
|
73
|
+
# :mode => button behaviour (:normal, :flashing, :buffering, optional, defaults to :normal)
|
74
|
+
# }
|
75
|
+
def change(type, opts = nil)
|
76
|
+
opts ||= {}
|
77
|
+
status = %w(up down left right session user1 user2 mixer).include?(type.to_s) ? Status::CC : Status::ON
|
78
|
+
output(status, note(type, opts), velocity(opts))
|
79
|
+
end
|
80
|
+
|
81
|
+
# Changes all LEDs at once
|
82
|
+
# velocities is an array of arrays, each containing a
|
83
|
+
# color value calculated using the formula
|
84
|
+
# color = 16 * green + red
|
85
|
+
# with green and red each ranging from 0-3
|
86
|
+
# first the grid, then the scene buttons (top to bottom), then the top control buttons (left to right), maximum 80 values
|
87
|
+
def change_all(*colors)
|
88
|
+
# ensure that colors is at least and most 80 elements long
|
89
|
+
colors = colors.flatten[0..79]
|
90
|
+
colors += [0] * (80 - colors.size) if colors.size < 80
|
91
|
+
# HACK switch off first grid LED to reset rapid LED change pointer
|
92
|
+
output(Status::ON, 0, 0)
|
93
|
+
# send colors in slices of 2
|
94
|
+
colors.each_slice(2) do |c1, c2|
|
95
|
+
output(Status::MULTI, velocity(c1), velocity(c2))
|
96
|
+
end
|
97
|
+
end
|
98
|
+
|
99
|
+
# Switches LEDs marked as flashing on (when using custom timer for flashing)
|
100
|
+
def flashing_on
|
101
|
+
output(Status::CC, Status::NIL, Velocity::FLASHING_ON)
|
102
|
+
end
|
103
|
+
|
104
|
+
# Switches LEDs marked as flashing off (when using custom timer for flashing)
|
105
|
+
def flashing_off
|
106
|
+
output(Status::CC, Status::NIL, Velocity::FLASHING_OFF)
|
107
|
+
end
|
108
|
+
|
109
|
+
# Starts flashing LEDs marked as flashing automatically (stop by calling #flashing_on or #flashing_off)
|
110
|
+
def flashing_auto
|
111
|
+
output(Status::CC, Status::NIL, Velocity::FLASHING_AUTO)
|
112
|
+
end
|
113
|
+
|
114
|
+
# def start_buffering
|
115
|
+
# output(CC, 0x00, 0x31)
|
116
|
+
# @buffering = true
|
117
|
+
# end
|
118
|
+
#
|
119
|
+
# def flush_buffer(end_buffering = true)
|
120
|
+
# output(CC, 0x00, 0x34)
|
121
|
+
# if end_buffering
|
122
|
+
# output(CC, 0x00, 0x30)
|
123
|
+
# @buffering = false
|
124
|
+
# end
|
125
|
+
# end
|
126
|
+
|
127
|
+
# Reads user actions (button presses/releases) that aren't handled yet
|
128
|
+
# [
|
129
|
+
# {
|
130
|
+
# :timestamp => integer indicating the time when the action occured
|
131
|
+
# :state => :down/:up, whether the button has been pressed or released
|
132
|
+
# :type => which button has been pressed, one of :grid, :up, :down, :left, :right, :session, :user1, :user2, :mixer, :scene1 - :scene8
|
133
|
+
# :x => x coordinate (0-7), only set when :type is :grid
|
134
|
+
# :y => y coordinate (0-7), only set when :type is :grid
|
135
|
+
# }, ...
|
136
|
+
# ]
|
137
|
+
def read_pending_actions
|
138
|
+
Array(input).collect do |midi_message|
|
139
|
+
(code, note, velocity) = midi_message[:message]
|
140
|
+
data = {
|
141
|
+
:timestamp => midi_message[:timestamp],
|
142
|
+
:state => (velocity == 127 ? :down : :up)
|
143
|
+
}
|
144
|
+
data[:type] = case code
|
145
|
+
when Status::ON
|
146
|
+
case note
|
147
|
+
when SceneButton::SCENE1 then :scene1
|
148
|
+
when SceneButton::SCENE2 then :scene2
|
149
|
+
when SceneButton::SCENE3 then :scene3
|
150
|
+
when SceneButton::SCENE4 then :scene4
|
151
|
+
when SceneButton::SCENE5 then :scene5
|
152
|
+
when SceneButton::SCENE6 then :scene6
|
153
|
+
when SceneButton::SCENE7 then :scene7
|
154
|
+
when SceneButton::SCENE8 then :scene8
|
155
|
+
else
|
156
|
+
data[:x] = note % 16
|
157
|
+
data[:y] = note / 16
|
158
|
+
:grid
|
159
|
+
end
|
160
|
+
when Status::CC
|
161
|
+
case note
|
162
|
+
when ControlButton::UP then :up
|
163
|
+
when ControlButton::DOWN then :down
|
164
|
+
when ControlButton::LEFT then :left
|
165
|
+
when ControlButton::RIGHT then :right
|
166
|
+
when ControlButton::SESSION then :session
|
167
|
+
when ControlButton::USER1 then :user1
|
168
|
+
when ControlButton::USER2 then :user2
|
169
|
+
when ControlButton::MIXER then :mixer
|
170
|
+
end
|
171
|
+
end
|
172
|
+
data
|
173
|
+
end
|
174
|
+
end
|
175
|
+
|
176
|
+
private
|
177
|
+
|
178
|
+
def input
|
179
|
+
raise NoInputAllowedError if @input.nil?
|
180
|
+
@input.read(16)
|
181
|
+
end
|
182
|
+
|
183
|
+
def output(*args)
|
184
|
+
raise NoOutputAllowedError if @output.nil?
|
185
|
+
@output.write([{:message => args, :timestamp => 0}])
|
186
|
+
nil
|
187
|
+
end
|
188
|
+
|
189
|
+
def note(type, opts)
|
190
|
+
case type
|
191
|
+
when :up then ControlButton::UP
|
192
|
+
when :down then ControlButton::DOWN
|
193
|
+
when :left then ControlButton::LEFT
|
194
|
+
when :right then ControlButton::RIGHT
|
195
|
+
when :session then ControlButton::SESSION
|
196
|
+
when :user1 then ControlButton::USER1
|
197
|
+
when :user2 then ControlButton::USER2
|
198
|
+
when :mixer then ControlButton::MIXER
|
199
|
+
when :scene1 then SceneButton::SCENE1
|
200
|
+
when :scene2 then SceneButton::SCENE2
|
201
|
+
when :scene3 then SceneButton::SCENE3
|
202
|
+
when :scene4 then SceneButton::SCENE4
|
203
|
+
when :scene5 then SceneButton::SCENE5
|
204
|
+
when :scene6 then SceneButton::SCENE6
|
205
|
+
when :scene7 then SceneButton::SCENE7
|
206
|
+
when :scene8 then SceneButton::SCENE8
|
207
|
+
else
|
208
|
+
x = (opts[:x] || -1).to_i
|
209
|
+
y = (opts[:y] || -1).to_i
|
210
|
+
raise NoValidGridCoordinatesError.new("you need to specify valid coordinates (x/y, 0-7, from top left), you specified: x=#{x}, y=#{y}") if x < 0 || x > 7 || y < 0 || y > 7
|
211
|
+
y * 16 + x
|
212
|
+
end
|
213
|
+
end
|
214
|
+
|
215
|
+
def velocity(opts)
|
216
|
+
color = if opts.is_a?(Hash)
|
217
|
+
red = brightness(opts[:red] || 0)
|
218
|
+
green = brightness(opts[:green] || 0)
|
219
|
+
16 * green + red
|
220
|
+
else
|
221
|
+
opts.to_i
|
222
|
+
end
|
223
|
+
flags = case opts[:mode]
|
224
|
+
when :flashing then 8
|
225
|
+
when :buffering then 0
|
226
|
+
else 12
|
227
|
+
end
|
228
|
+
color + flags
|
229
|
+
end
|
230
|
+
|
231
|
+
def brightness(brightness)
|
232
|
+
case brightness
|
233
|
+
when 0, :off then 0
|
234
|
+
when 1, :low, :lo then 1
|
235
|
+
when 2, :medium, :med then 2
|
236
|
+
when 3, :high, :hi then 3
|
237
|
+
else
|
238
|
+
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}")
|
239
|
+
end
|
240
|
+
end
|
241
|
+
|
242
|
+
end
|
243
|
+
|
244
|
+
end
|
@@ -0,0 +1,37 @@
|
|
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 at all 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
|
@@ -0,0 +1,89 @@
|
|
1
|
+
require 'launchpad/device'
|
2
|
+
|
3
|
+
module Launchpad
|
4
|
+
|
5
|
+
class Interaction
|
6
|
+
|
7
|
+
attr_reader :device, :active
|
8
|
+
|
9
|
+
# Initializes the launchpad interaction
|
10
|
+
# {
|
11
|
+
# :device => Launchpad::Device instance, optional
|
12
|
+
# :device_name => Name of the MIDI device to use, optional, defaults to Launchpad, ignored when :device is specified
|
13
|
+
# :latency => delay (in s, fractions allowed) between MIDI pulls, optional, defaults to 0.001
|
14
|
+
# }
|
15
|
+
def initialize(opts = nil)
|
16
|
+
opts ||= {}
|
17
|
+
@device = opts[:device] || Device.new(opts.merge(:input => true, :output => true))
|
18
|
+
@latency = (opts[:latency] || 0.001).to_f.abs
|
19
|
+
@active = false
|
20
|
+
end
|
21
|
+
|
22
|
+
# Starts interacting with the launchpad, blocking
|
23
|
+
def start
|
24
|
+
@active = true
|
25
|
+
while @active do
|
26
|
+
@device.read_pending_actions.each {|action| respond_to_action(action)}
|
27
|
+
sleep @latency unless @latency <= 0
|
28
|
+
end
|
29
|
+
@device.reset
|
30
|
+
rescue Portmidi::DeviceError => e
|
31
|
+
raise CommunicationError.new(e)
|
32
|
+
end
|
33
|
+
|
34
|
+
# Stops interacting with the launchpad
|
35
|
+
def stop
|
36
|
+
@active = false
|
37
|
+
end
|
38
|
+
|
39
|
+
# Registers a response to one or more actions
|
40
|
+
# types => the type of action to respond to, one or more of :all, :grid, :up, :down, :left, :right, :session, :user1, :user2, :mixer, :scene1 - :scene8, optional, defaults to :all
|
41
|
+
# state => which state transition to respond to, one of :down, :up, :both, optional, defaults to :both
|
42
|
+
# opts => {
|
43
|
+
# :exclusive => whether all other responses to the given types shall be deregistered first
|
44
|
+
# }
|
45
|
+
def response_to(types = :all, state = :both, opts = nil, &block)
|
46
|
+
types = Array(types)
|
47
|
+
opts ||= {}
|
48
|
+
no_response_to(types, state) if opts[:exclusive] == true
|
49
|
+
Array(state == :both ? %w(down up) : state).each do |state|
|
50
|
+
types.each {|type| responses[type.to_sym][state.to_sym] << block}
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
# Deregisters all responses to one or more actions
|
55
|
+
# type => the type of response to clear, one or more of :all (not meaning "all responses" but "responses registered for type :all"), :grid, :up, :down, :left, :right, :session, :user1, :user2, :mixer, :scene1 - :scene8, optional, defaults to nil (meaning "all responses")
|
56
|
+
# state => which state transition to not respond to, one of :down, :up, :both, optional, defaults to :both
|
57
|
+
def no_response_to(types = nil, state = :both)
|
58
|
+
types = Array(types)
|
59
|
+
Array(state == :both ? %w(down up) : state).each do |state|
|
60
|
+
types.each {|type| responses[type.to_sym][state.to_sym].clear}
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
# Responds to an action by executing all matching responses
|
65
|
+
# type => the type of action to respond to, one of :grid, :up, :down, :left, :right, :session, :user1, :user2, :mixer, :scene1 - :scene8
|
66
|
+
# state => which state transition to respond to, one of :down, :up
|
67
|
+
# opts => {
|
68
|
+
# :x => x coordinate (0 based from top left)
|
69
|
+
# :y => y coordinate (0 based from top left)
|
70
|
+
# }, unused unless type is :grid
|
71
|
+
def respond_to(type, state, opts = nil)
|
72
|
+
respond_to_action((opts || {}).merge(:type => type, :state => state))
|
73
|
+
end
|
74
|
+
|
75
|
+
private
|
76
|
+
|
77
|
+
def responses
|
78
|
+
@responses ||= Hash.new {|hash, key| hash[key] = {:down => [], :up => []}}
|
79
|
+
end
|
80
|
+
|
81
|
+
def respond_to_action(action)
|
82
|
+
type = action[:type].to_sym
|
83
|
+
state = action[:state].to_sym
|
84
|
+
(responses[type][state] + responses[:all][state]).each {|block| block.call(self, action)}
|
85
|
+
end
|
86
|
+
|
87
|
+
end
|
88
|
+
|
89
|
+
end
|
@@ -0,0 +1,44 @@
|
|
1
|
+
module Launchpad
|
2
|
+
|
3
|
+
module MidiCodes
|
4
|
+
|
5
|
+
module Status
|
6
|
+
NIL = 0x00
|
7
|
+
OFF = 0x80
|
8
|
+
ON = 0x90
|
9
|
+
MULTI = 0x92
|
10
|
+
CC = 0xB0
|
11
|
+
end
|
12
|
+
|
13
|
+
module Velocity
|
14
|
+
FLASHING_ON = 0x20
|
15
|
+
FLASHING_OFF = 0x21
|
16
|
+
FLASHING_AUTO = 0x28
|
17
|
+
TEST_LEDS = 0x7C
|
18
|
+
end
|
19
|
+
|
20
|
+
module ControlButton
|
21
|
+
UP = 0x68
|
22
|
+
DOWN = 0x69
|
23
|
+
LEFT = 0x6A
|
24
|
+
RIGHT = 0x6B
|
25
|
+
SESSION = 0x6C
|
26
|
+
USER1 = 0x6D
|
27
|
+
USER2 = 0x6E
|
28
|
+
MIXER = 0x6F
|
29
|
+
end
|
30
|
+
|
31
|
+
module SceneButton
|
32
|
+
SCENE1 = 0x08
|
33
|
+
SCENE2 = 0x18
|
34
|
+
SCENE3 = 0x28
|
35
|
+
SCENE4 = 0x38
|
36
|
+
SCENE5 = 0x48
|
37
|
+
SCENE6 = 0x58
|
38
|
+
SCENE7 = 0x68
|
39
|
+
SCENE8 = 0x78
|
40
|
+
end
|
41
|
+
|
42
|
+
end
|
43
|
+
|
44
|
+
end
|
data/lib/launchpad/version.rb
CHANGED
@@ -1,3 +1,3 @@
|
|
1
|
-
|
2
|
-
VERSION = '0.0
|
1
|
+
module Launchpad
|
2
|
+
VERSION = '0.1.0'
|
3
3
|
end
|
data/test/helper.rb
CHANGED
@@ -1,6 +1,13 @@
|
|
1
1
|
require 'rubygems'
|
2
2
|
require 'test/unit'
|
3
|
-
|
3
|
+
require 'shoulda'
|
4
|
+
require 'mocha'
|
5
|
+
|
6
|
+
begin
|
7
|
+
require 'redgreen' if ENV['TM_FILENAME'].nil?
|
8
|
+
rescue MissingSourceFile
|
9
|
+
# ignore - just for colorization
|
10
|
+
end
|
4
11
|
|
5
12
|
$LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
|
6
13
|
$LOAD_PATH.unshift(File.dirname(__FILE__))
|
@@ -8,3 +15,32 @@ require 'launchpad'
|
|
8
15
|
|
9
16
|
class Test::Unit::TestCase
|
10
17
|
end
|
18
|
+
|
19
|
+
# mock Portmidi for tests
|
20
|
+
module Portmidi
|
21
|
+
|
22
|
+
class DeviceError < StandardError; end
|
23
|
+
|
24
|
+
class Input
|
25
|
+
attr_accessor :device_id
|
26
|
+
def initialize(device_id)
|
27
|
+
self.device_id = device_id
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
class Output
|
32
|
+
attr_accessor :device_id
|
33
|
+
def initialize(device_id)
|
34
|
+
self.device_id = device_id
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
def self.input_devices; mock_devices; end
|
39
|
+
def self.output_devices; mock_devices; end
|
40
|
+
def self.start; end
|
41
|
+
|
42
|
+
end
|
43
|
+
|
44
|
+
def mock_devices(opts = {})
|
45
|
+
[Portmidi::Device.new(opts[:id] || 1, 0, 0, opts[:name] || 'Launchpad')]
|
46
|
+
end
|