launchpad 0.0.2 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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
|