launchpad_mk2 0.0.1

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.
@@ -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