launchpad 0.0.2 → 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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