launchpad_mk2 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,37 @@
1
+ module LaunchpadMk2
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 <tt>x/y</tt> 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 NoValidColorError < 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,333 @@
1
+ require 'launchpad_mk2/device'
2
+ require 'launchpad_mk2/logging'
3
+
4
+ module LaunchpadMk2
5
+
6
+ # This class provides advanced interaction features.
7
+ #
8
+ # Example:
9
+ #
10
+ # require 'launchpad_mk2'
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
+ # [<tt>:device</tt>] Launchpad::Device to act on,
36
+ # optional, <tt>:input_device_id/:output_device_id</tt> will be used if omitted
37
+ # [<tt>:input_device_id</tt>] ID of the MIDI input device to use,
38
+ # optional, <tt>:device_name</tt> will be used if omitted
39
+ # [<tt>:output_device_id</tt>] ID of the MIDI output device to use,
40
+ # optional, <tt>:device_name</tt> will be used if omitted
41
+ # [<tt>:device_name</tt>] Name of the MIDI device to use,
42
+ # optional, defaults to "Launchpad"
43
+ # [<tt>:latency</tt>] delay (in s, fractions allowed) between MIDI pulls,
44
+ # optional, defaults to 0.001 (1ms)
45
+ # [<tt>:logger</tt>] [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
+ @reader_thread = nil
53
+ @device = nil
54
+ opts ||= {}
55
+
56
+ self.logger = opts[:logger]
57
+ logger.debug "initializing Launchpad::Interaction##{object_id} with #{opts.inspect}"
58
+
59
+ @device ||= opts[:device]
60
+ @device ||= Device.new(opts.merge(
61
+ :input => true,
62
+ :output => true,
63
+ :logger => opts[:logger]
64
+ ))
65
+ @latency = (opts[:latency] || 0.001).to_f.abs
66
+ @active = false
67
+
68
+ @action_threads = ThreadGroup.new
69
+ end
70
+
71
+ # Sets the logger to be used by the current instance and the device.
72
+ #
73
+ # [+logger+] the [Logger] instance
74
+ def logger=(logger)
75
+ @logger = logger
76
+ @device.logger = logger if @device
77
+ end
78
+
79
+ # Closes the interaction's device - nothing can be done with the interaction/device afterwards.
80
+ #
81
+ # Errors raised:
82
+ #
83
+ # [Launchpad::NoInputAllowedError] when input is not enabled on the interaction's device
84
+ # [Launchpad::CommunicationError] when anything unexpected happens while communicating with the
85
+ def close
86
+ logger.debug "closing Launchpad::Interaction##{object_id}"
87
+ stop
88
+ @device.close
89
+ end
90
+
91
+ # Determines whether this interaction's device has been closed.
92
+ def closed?
93
+ @device.closed?
94
+ end
95
+
96
+ # Starts interacting with the launchpad. Resets the device when
97
+ # the interaction was properly stopped via stop or close.
98
+ #
99
+ # Optional options hash:
100
+ #
101
+ # [<tt>:detached</tt>] <tt>true/false</tt>,
102
+ # whether to detach the interaction, method is blocking when +false+,
103
+ # optional, defaults to +false+
104
+ #
105
+ # Errors raised:
106
+ #
107
+ # [Launchpad::NoInputAllowedError] when input is not enabled on the interaction's device
108
+ # [Launchpad::NoOutputAllowedError] when output is not enabled on the interaction's device
109
+ # [Launchpad::CommunicationError] when anything unexpected happens while communicating with the launchpad
110
+ def start(opts = nil)
111
+ logger.debug "starting Launchpad::Interaction##{object_id}"
112
+
113
+ opts = {
114
+ :detached => false
115
+ }.merge(opts || {})
116
+
117
+ @active = true
118
+
119
+ @reader_thread ||= Thread.new do
120
+ begin
121
+ while @active do
122
+ @device.read_pending_actions.each do |pending_action|
123
+ action_thread = Thread.new(pending_action) do |action|
124
+ respond_to_action(action)
125
+ end
126
+ @action_threads.add(action_thread)
127
+ end
128
+ sleep @latency# if @latency > 0.0
129
+ end
130
+ rescue Portmidi::DeviceError => e
131
+ logger.fatal "could not read from device, stopping to read actions"
132
+ raise CommunicationError.new(e)
133
+ rescue Exception => e
134
+ logger.fatal "error causing action reading to stop: #{e.inspect}"
135
+ raise e
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 <tt>:all</tt> for all buttons
174
+ # [+state+] button state to respond to,
175
+ # additional value <tt>:both</tt>
176
+ #
177
+ # Optional options hash:
178
+ #
179
+ # [<tt>:exclusive</tt>] <tt>true/false</tt>,
180
+ # whether to deregister all other responses to the specified actions,
181
+ # optional, defaults to +false+
182
+ # [<tt>:x</tt>] x coordinate(s), can contain arrays and ranges, when specified
183
+ # without y coordinate, it's interpreted as a whole column
184
+ # [<tt>:y</tt>] 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 |current_state|
199
+ types.each do |type|
200
+ combined_types(type, opts).each do |combined_type|
201
+ responses[combined_type][current_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 <tt>:all</tt> 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 <tt>:both</tt>
218
+ #
219
+ # Optional options hash:
220
+ #
221
+ # [<tt>:x</tt>] x coordinate(s), can contain arrays and ranges, when specified
222
+ # without y coordinate, it's interpreted as a whole column
223
+ # [<tt>:y</tt>] 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 |current_state|
229
+ types.each do |type|
230
+ combined_types(type, opts).each do |combined_type|
231
+ responses[combined_type][current_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
+ # [<tt>:x</tt>] x coordinate
249
+ # [<tt>:y</tt>] 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 <tt>:down/:up</tt>, 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
+ # [<tt>:x</tt>] x coordinate(s), can contain arrays and ranges, when specified
292
+ # without y coordinate, it's interpreted as a whole column
293
+ # [<tt>:y</tt>] 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 {|current_x, current_y| :"grid#{current_x}#{current_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
@@ -0,0 +1,27 @@
1
+ require 'logger'
2
+
3
+ module LaunchpadMk2
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
@@ -0,0 +1,40 @@
1
+ module LaunchpadMk2
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
+ CC = 0xB0
12
+ end
13
+
14
+ # Module defininig MIDI data 1 (note) codes for control buttons.
15
+ module ControlButton
16
+ UP = 0x68
17
+ DOWN = 0x69
18
+ LEFT = 0x6A
19
+ RIGHT = 0x6B
20
+ SESSION = 0x6C
21
+ USER1 = 0x6D
22
+ USER2 = 0x6E
23
+ MIXER = 0x6F
24
+ end
25
+
26
+ # Module defininig MIDI data 1 (note) codes for scene buttons.
27
+ module SceneButton
28
+ SCENE1 = 0x59
29
+ SCENE2 = 0x4F
30
+ SCENE3 = 0x45
31
+ SCENE4 = 0x3B
32
+ SCENE5 = 0x31
33
+ SCENE6 = 0x27
34
+ SCENE7 = 0x1D
35
+ SCENE8 = 0x13
36
+ end
37
+
38
+ end
39
+
40
+ end
@@ -0,0 +1,3 @@
1
+ module LaunchpadMk2
2
+ VERSION = '0.0.1'
3
+ end
@@ -0,0 +1,45 @@
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_mk2'
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 write_sysex(*args); nil; end
34
+ def close; nil; end
35
+ end
36
+
37
+ def self.input_devices; mock_devices; end
38
+ def self.output_devices; mock_devices; end
39
+ def self.start; end
40
+
41
+ end
42
+
43
+ def mock_devices(opts = {})
44
+ [Portmidi::Device.new(opts[:id] || 1, 0, 0, opts[:name] || Launchpad::Device::MK2_DEVICE_NAME)]
45
+ end