surface_master 0.2.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.
Files changed (48) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +4 -0
  3. data/.travis.yml +8 -0
  4. data/Gemfile +13 -0
  5. data/LICENSE +21 -0
  6. data/README.md +48 -0
  7. data/Rakefile +10 -0
  8. data/debug_tools/Numark_Orbit_Pad_Coloring.mmon +98 -0
  9. data/debug_tools/OrbitLightingExample.json +662 -0
  10. data/debug_tools/Orbit_Color_Test.json +662 -0
  11. data/debug_tools/Orbit_Colors_And_Reset.1.raw +0 -0
  12. data/debug_tools/Orbit_Colors_And_Reset.1.txt +82 -0
  13. data/debug_tools/Orbit_Colors_And_Reset.2.raw +0 -0
  14. data/debug_tools/Orbit_Colors_And_Reset.2.txt +2 -0
  15. data/debug_tools/Orbit_Colors_And_Reset.3.raw +0 -0
  16. data/debug_tools/Orbit_Colors_And_Reset.3.txt +82 -0
  17. data/debug_tools/Orbit_Colors_And_Reset.mmon +93 -0
  18. data/debug_tools/Orbit_Preset.1.raw +0 -0
  19. data/debug_tools/Orbit_Preset.1.txt +82 -0
  20. data/debug_tools/Orbit_Preset.2.raw +0 -0
  21. data/debug_tools/Orbit_Preset.2.txt +2 -0
  22. data/debug_tools/Orbit_Preset.mmon +72 -0
  23. data/debug_tools/compare.sh +12 -0
  24. data/debug_tools/decode.rb +14 -0
  25. data/debug_tools/extract_midi_monitor_sample.sh +33 -0
  26. data/docs/Numark_Orbit_QuickRef.md +50 -0
  27. data/examples/launchpad_testbed.rb +141 -0
  28. data/examples/monitor.rb +61 -0
  29. data/examples/orbit_testbed.rb +62 -0
  30. data/lib/control_center.rb +26 -0
  31. data/lib/surface_master/device.rb +90 -0
  32. data/lib/surface_master/errors.rb +27 -0
  33. data/lib/surface_master/interaction.rb +133 -0
  34. data/lib/surface_master/launchpad/device.rb +159 -0
  35. data/lib/surface_master/launchpad/errors.rb +11 -0
  36. data/lib/surface_master/launchpad/interaction.rb +86 -0
  37. data/lib/surface_master/launchpad/midi_codes.rb +51 -0
  38. data/lib/surface_master/logging.rb +15 -0
  39. data/lib/surface_master/orbit/device.rb +160 -0
  40. data/lib/surface_master/orbit/interaction.rb +29 -0
  41. data/lib/surface_master/orbit/midi_codes.rb +31 -0
  42. data/lib/surface_master/version.rb +3 -0
  43. data/mappings/Orbit_Preset.json +662 -0
  44. data/surface_master.gemspec +26 -0
  45. data/test/helper.rb +44 -0
  46. data/test/test_device.rb +530 -0
  47. data/test/test_interaction.rb +456 -0
  48. metadata +121 -0
@@ -0,0 +1,27 @@
1
+ module SurfaceMaster
2
+ # Unclassified error.
3
+ class GenericError < StandardError; end
4
+
5
+ # Error raised when the MIDI device specified doesn't exist.
6
+ class NoSuchDeviceError < GenericError; end
7
+
8
+ # Error raised when the MIDI device specified is busy.
9
+ class DeviceBusyError < GenericError; end
10
+
11
+ # Error raised when an input has been requested, although device has been initialized without
12
+ # input.
13
+ class NoInputAllowedError < GenericError; end
14
+
15
+ # Error raised when an output has been requested, although device has been initialized without
16
+ # output.
17
+ class NoOutputAllowedError < GenericError; end
18
+
19
+ # Error raised when anything fails while communicating with a device.
20
+ class CommunicationError < GenericError
21
+ attr_accessor :source
22
+ def initialize(e)
23
+ super(e.portmidi_error)
24
+ self.source = e
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,133 @@
1
+ module SurfaceMaster
2
+ # Base class for event-based drivers. Sub-classes should extend the constructor, and implement `respond_to_action`
3
+ class Interaction
4
+ include Logging
5
+
6
+ attr_reader :device, :active
7
+
8
+ def initialize(opts = nil)
9
+ opts ||= {}
10
+
11
+ self.logger = opts[:logger]
12
+ logger.debug "Initializing #{self.class}##{object_id} with #{opts.inspect}"
13
+
14
+ @use_threads = opts[:use_threads] || true
15
+ @device = opts[:device]
16
+ @device ||= @device_class.new(opts.merge(input: true,
17
+ output: true,
18
+ logger: opts[:logger]))
19
+ @latency = (opts[:latency] || 0.001).to_f.abs
20
+ @active = false
21
+
22
+ @action_threads = ThreadGroup.new
23
+ end
24
+
25
+ def change(opts); @device.change(opts); end
26
+ def changes(opts); @device.changes(opts); end
27
+
28
+ def close
29
+ logger.debug "Closing #{self.class}##{object_id}"
30
+ stop
31
+ @device.close
32
+ end
33
+
34
+ def closed?; @device.closed?; end
35
+
36
+ def start(opts = nil)
37
+ logger.debug "Starting #{self.class}##{object_id}"
38
+
39
+ opts = { detached: false }.merge(opts || {})
40
+
41
+ @active = true
42
+
43
+ @reader_thread ||= Thread.new do
44
+ begin
45
+ while @active do
46
+ @device.read.each do |action|
47
+ if @use_threads
48
+ action_thread = Thread.new(action) do |act|
49
+ respond_to_action(act)
50
+ end
51
+ @action_threads.add(action_thread)
52
+ else
53
+ respond_to_action(action)
54
+ end
55
+ end
56
+ sleep @latency# if @latency > 0.0
57
+ end
58
+ rescue Portmidi::DeviceError => e
59
+ logger.fatal "Could not read from device, stopping reader!"
60
+ raise SurfaceMaster::CommunicationError.new(e)
61
+ rescue Exception => e
62
+ logger.fatal "Unkown error, stopping reader: #{e.inspect}"
63
+ raise e
64
+ ensure
65
+ @device.reset!
66
+ end
67
+ end
68
+ @reader_thread.join unless opts[:detached]
69
+ end
70
+
71
+ def stop
72
+ logger.debug "Stopping #{self.class}##{object_id}"
73
+ @active = false
74
+ if @reader_thread
75
+ # run (resume from sleep) and wait for @reader_thread to end
76
+ @reader_thread.run if @reader_thread.alive?
77
+ @reader_thread.join
78
+ @reader_thread = nil
79
+ end
80
+ ensure
81
+ @action_threads.list.each do |thread|
82
+ begin
83
+ thread.kill
84
+ thread.join
85
+ rescue StandardException => e # TODO: RuntimeError, Exception, or this?
86
+ logger.error "Error when killing action thread: #{e.inspect}"
87
+ end
88
+ end
89
+ nil
90
+ end
91
+
92
+ def response_to(types = :all, state = :both, opts = nil, &block)
93
+ logger.debug "Setting response to #{types.inspect} for state #{state.inspect} with #{opts.inspect}"
94
+ types = Array(types)
95
+ opts ||= {}
96
+ no_response_to(types, state) if opts[:exclusive] == true
97
+ Array(state == :both ? %i(down up) : state).each do |st|
98
+ types.each do |type|
99
+ combined_types(type, opts).each do |combined_type|
100
+ responses[combined_type][st] << block
101
+ end
102
+ end
103
+ end
104
+ nil
105
+ end
106
+
107
+ def no_response_to(types = nil, state = :both, opts = nil)
108
+ logger.debug "Removing response to #{types.inspect} for state #{state.inspect}"
109
+ types = Array(types)
110
+ Array(state == :both ? %i(down up) : state).each do |st|
111
+ types.each do |type|
112
+ combined_types(type, opts).each do |combined_type|
113
+ responses[combined_type][st].clear
114
+ end
115
+ end
116
+ end
117
+ nil
118
+ end
119
+
120
+ def respond_to(type, state, opts = nil)
121
+ respond_to_action((opts || {}).merge(type: type, state: state))
122
+ end
123
+
124
+ protected
125
+
126
+ def responses
127
+ # TODO: Generalize for arbitrary actions...
128
+ @responses ||= Hash.new { |hash, key| hash[key] = { down: [], up: [] } }
129
+ end
130
+
131
+ def respond_to_action(action); end
132
+ end
133
+ end
@@ -0,0 +1,159 @@
1
+ module SurfaceMaster
2
+ module Launchpad
3
+ class Device < SurfaceMaster::Device
4
+ include MIDICodes
5
+
6
+ # TODO: Rename scenes to match Mk2
7
+ CODE_NOTE_TO_TYPE = Hash.new { |*_| :grid }
8
+ .merge([Status::ON, Scene::SCENE1] => :scene1,
9
+ [Status::ON, Scene::SCENE2] => :scene2,
10
+ [Status::ON, Scene::SCENE3] => :scene3,
11
+ [Status::ON, Scene::SCENE4] => :scene4,
12
+ [Status::ON, Scene::SCENE5] => :scene5,
13
+ [Status::ON, Scene::SCENE6] => :scene6,
14
+ [Status::ON, Scene::SCENE7] => :scene7,
15
+ [Status::ON, Scene::SCENE8] => :scene8,
16
+ [Status::CC, Control::UP] => :up,
17
+ [Status::CC, Control::DOWN] => :down,
18
+ [Status::CC, Control::LEFT] => :left,
19
+ [Status::CC, Control::RIGHT] => :right,
20
+ [Status::CC, Control::SESSION] => :session,
21
+ [Status::CC, Control::USER1] => :user1,
22
+ [Status::CC, Control::USER2] => :user2,
23
+ [Status::CC, Control::MIXER] => :mixer)
24
+ .freeze
25
+ TYPE_TO_NOTE = { up: Control::UP,
26
+ down: Control::DOWN,
27
+ left: Control::LEFT,
28
+ right: Control::RIGHT,
29
+ session: Control::SESSION,
30
+ user1: Control::USER1,
31
+ user2: Control::USER2,
32
+ mixer: Control::MIXER,
33
+ scene1: Scene::SCENE1, # Volume
34
+ scene2: Scene::SCENE2, # Pan
35
+ scene3: Scene::SCENE3, # Send A
36
+ scene4: Scene::SCENE4, # Send B
37
+ scene5: Scene::SCENE5, # Stop
38
+ scene6: Scene::SCENE6, # Mute
39
+ scene7: Scene::SCENE7, # Solo
40
+ scene8: Scene::SCENE8 }.freeze # Record Arm
41
+
42
+ def initialize(opts = nil)
43
+ @name = "Launchpad MK2"
44
+ super(opts)
45
+ reset! if output_enabled?
46
+ end
47
+
48
+ def reset
49
+ # TODO: Suss out what this should be for the Mark 2.
50
+ layout!(0x00)
51
+ output!(Status::CC, Status::NIL, Status::NIL)
52
+ end
53
+
54
+ # TODO: Support more of the LaunchPad Mark 2's functionality.
55
+
56
+ def change(opts = nil)
57
+ opts ||= {}
58
+ command, payload = color_payload(opts)
59
+ sysex!(command, payload[:led], payload[:color])
60
+ end
61
+
62
+ def changes(values)
63
+ msg_by_command = {}
64
+ values.each do |value|
65
+ command, payload = color_payload(value)
66
+ (msg_by_command[command] ||= []) << payload
67
+ end
68
+ msg_by_command.each do |command, payloads|
69
+ # The documented batch size for RGB LED updates is 80. The docs lie, at least on my current
70
+ # firmware version -- anything above 62 crashes the device hard.
71
+ while (slice = payloads.shift(62)).length > 0
72
+ sysex!(command, *slice.map { |payload| [payload[:led], payload[:color]] })
73
+ end
74
+ end
75
+ end
76
+
77
+ def read
78
+ super.collect do |input|
79
+ note = input[:note]
80
+ input[:type] = CODE_NOTE_TO_TYPE[[input[:code], note]] || :grid
81
+ if input[:type] == :grid
82
+ note = note - 11
83
+ input[:x] = note % 10
84
+ input[:y] = note / 10
85
+ end
86
+ input
87
+ end
88
+ end
89
+
90
+ protected
91
+
92
+ def layout!(mode); sysex!(0x22, mode); end
93
+ def sysex_prefix; @sysex_prefix ||= super + [0x00, 0x20, 0x29, 0x02, 0x18]; end
94
+
95
+ def decode_led(opts)
96
+ case
97
+ when opts[:cc]
98
+ [:cc, TYPE_TO_NOTE[opts[:cc]]]
99
+ when opts[:grid]
100
+ if opts[:grid] == :all
101
+ [:all, nil]
102
+ else
103
+ [:grid, (opts[:grid][1] * 10) + opts[:grid][0] + 11]
104
+ end
105
+ when opts[:column]
106
+ [:column, opts[:column]]
107
+ when opts[:row]
108
+ [:row, opts[:row]]
109
+ end
110
+ end
111
+
112
+ TYPE_TO_COMMAND = { cc: 0x0B,
113
+ grid: 0x0B,
114
+ column: 0x0C,
115
+ row: 0x0D,
116
+ all: 0x0E }.freeze
117
+ def color_payload(opts)
118
+ type, led = decode_led(opts)
119
+ command = TYPE_TO_COMMAND[type]
120
+ case type
121
+ when :cc, :grid
122
+ color = [opts[:red] || 0x00, opts[:green] || 0x00, opts[:blue] || 0x00]
123
+ when :column, :row, :all
124
+ color = opts[:color] || 0x00
125
+ end
126
+ [command, { led: led, color: color }]
127
+ end
128
+
129
+ def output!(status, data1, data2)
130
+ outputs!(message(status, data1, data2))
131
+ end
132
+
133
+ def outputs!(*messages)
134
+ messages = Array(messages)
135
+ if @output.nil?
136
+ logger.error "trying to write to device that's not been initialized for output"
137
+ raise SurfaceMaster::NoOutputAllowedError
138
+ end
139
+ logger.debug "writing messages to launchpad:\n #{messages.join("\n ")}" if logger.debug?
140
+ @output.write(messages)
141
+ nil
142
+ end
143
+
144
+ def note(type, opts)
145
+ note = TYPE_TO_NOTE[type]
146
+ if note.nil?
147
+ x = (opts[:x] || -1).to_i
148
+ y = (opts[:y] || -1).to_i
149
+ if x < 0 || x > 7 || y < 0 || y > 7
150
+ logger.error "wrong coordinates specified: x=#{x}, y=#{y}"
151
+ raise NoValidGridCoordinatesError.new("you need to specify valid coordinates (x/y, 0-7, from top left), you specified: x=#{x}, y=#{y}")
152
+ end
153
+ note = y * 10 + x
154
+ end
155
+ note
156
+ end
157
+ end
158
+ end
159
+ end
@@ -0,0 +1,11 @@
1
+ module Launchpad
2
+ # Generic launchpad error.
3
+ class LaunchpadError < SurfaceMaster::GenericError; end
4
+
5
+ # Error raised when <tt>x/y</tt> coordinates outside of the grid
6
+ # or none were specified.
7
+ class NoValidGridCoordinatesError < LaunchpadError; end
8
+
9
+ # Error raised when wrong brightness was specified.
10
+ class NoValidBrightnessError < LaunchpadError; end
11
+ end
@@ -0,0 +1,86 @@
1
+ module SurfaceMaster
2
+ module Launchpad
3
+ class Interaction < SurfaceMaster::Interaction
4
+ def initialize(opts = nil)
5
+ @device_class = Device
6
+ super(opts)
7
+ end
8
+
9
+ # def response_to(types = :all, state = :both, opts = nil, &block)
10
+ # logger.debug "setting response to #{types.inspect} for state #{state.inspect} with #{opts.inspect}"
11
+ # types = Array(types)
12
+ # opts ||= {}
13
+ # no_response_to(types, state) if opts[:exclusive] == true
14
+ # Array(state == :both ? %i(down up) : state).each do |st|
15
+ # types.each do |type|
16
+ # combined_types(type, opts).each do |combined_type|
17
+ # responses[combined_type][st] << block
18
+ # end
19
+ # end
20
+ # end
21
+ # nil
22
+ # end
23
+
24
+ # def no_response_to(types = nil, state = :both, opts = nil)
25
+ # logger.debug "removing response to #{types.inspect} for state #{state.inspect}"
26
+ # types = Array(types)
27
+ # Array(state == :both ? %i(down up) : state).each do |st|
28
+ # types.each do |type|
29
+ # combined_types(type, opts).each do |combined_type|
30
+ # responses[combined_type][st].clear
31
+ # end
32
+ # end
33
+ # end
34
+ # nil
35
+ # end
36
+
37
+ # def respond_to(type, state, opts = nil)
38
+ # respond_to_action((opts || {}).merge(type: type, state: state))
39
+ # end
40
+
41
+ protected
42
+
43
+ def responses
44
+ @responses ||= Hash.new { |hash, key| hash[key] = { down: [], up: [] } }
45
+ end
46
+
47
+ def grid_range(range)
48
+ return nil if range.nil?
49
+ Array(range).flatten.map do |pos|
50
+ pos.respond_to?(:to_a) ? pos.to_a : pos
51
+ end.flatten.uniq
52
+ end
53
+
54
+ def combined_types(type, opts = nil)
55
+ if type.to_sym == :grid && opts
56
+ x = grid_range(opts[:x])
57
+ y = grid_range(opts[:y])
58
+ return [:grid] if x.nil? && y.nil? # whole grid
59
+ x ||= ['-'] # whole row
60
+ y ||= ['-'] # whole column
61
+ x.product(y).map { |xx, yy| :"grid#{xx}#{yy}" }
62
+ else
63
+ [type.to_sym]
64
+ end
65
+ end
66
+
67
+ def respond_to_action(action)
68
+ type = action[:type].to_sym
69
+ state = action[:state].to_sym
70
+ actions = []
71
+ if type == :grid
72
+ actions += responses[:"grid#{action[:x]}#{action[:y]}"][state]
73
+ actions += responses[:"grid#{action[:x]}-"][state]
74
+ actions += responses[:"grid-#{action[:y]}"][state]
75
+ end
76
+ actions += responses[type][state]
77
+ actions += responses[:all][state]
78
+ actions.compact.each {|block| block.call(self, action)}
79
+ nil
80
+ rescue Exception => e # TODO: StandardException, RuntimeError, or Exception?
81
+ logger.error "Error when responding to action #{action.inspect}: #{e.inspect}"
82
+ raise e
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,51 @@
1
+ module SurfaceMaster
2
+ module Launchpad
3
+ module MIDICodes
4
+ # Module defining MIDI status codes.
5
+ # TODO: Some of these are fairly generic? Can we hoist them?
6
+ module Status
7
+ NIL = 0x00
8
+ OFF = 0x80
9
+ ON = 0x90
10
+ MULTI = 0x92
11
+ CC = 0xB0
12
+ end
13
+
14
+ # Module defininig MIDI data 1 (note) codes for control buttons.
15
+ module Control
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
+ # TODO: Rename to match Mk2...
28
+ module Scene
29
+ SCENE1 = 0x59
30
+ SCENE2 = 0x4f
31
+ SCENE3 = 0x45
32
+ SCENE4 = 0x3b
33
+ SCENE5 = 0x31
34
+ SCENE6 = 0x27
35
+ SCENE7 = 0x1d
36
+ SCENE8 = 0x13
37
+ end
38
+
39
+ # Module defining MIDI data 2 (velocity) codes.
40
+ module Velocity
41
+ TEST_LEDS = 0x7C
42
+ end
43
+
44
+ # Module defining MIDI data 2 codes for selecting the grid layout.
45
+ module GridLayout
46
+ XY = 0x01
47
+ DRUM_RACK = 0x02
48
+ end
49
+ end
50
+ end
51
+ end