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.
@@ -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
@@ -1,3 +1,3 @@
1
- class Launchpad
2
- VERSION = '0.0.2'
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
- #require 'shoulda'
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