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.
- checksums.yaml +7 -0
- data/.gitignore +4 -0
- data/.travis.yml +8 -0
- data/Gemfile +13 -0
- data/LICENSE +21 -0
- data/README.md +48 -0
- data/Rakefile +10 -0
- data/debug_tools/Numark_Orbit_Pad_Coloring.mmon +98 -0
- data/debug_tools/OrbitLightingExample.json +662 -0
- data/debug_tools/Orbit_Color_Test.json +662 -0
- data/debug_tools/Orbit_Colors_And_Reset.1.raw +0 -0
- data/debug_tools/Orbit_Colors_And_Reset.1.txt +82 -0
- data/debug_tools/Orbit_Colors_And_Reset.2.raw +0 -0
- data/debug_tools/Orbit_Colors_And_Reset.2.txt +2 -0
- data/debug_tools/Orbit_Colors_And_Reset.3.raw +0 -0
- data/debug_tools/Orbit_Colors_And_Reset.3.txt +82 -0
- data/debug_tools/Orbit_Colors_And_Reset.mmon +93 -0
- data/debug_tools/Orbit_Preset.1.raw +0 -0
- data/debug_tools/Orbit_Preset.1.txt +82 -0
- data/debug_tools/Orbit_Preset.2.raw +0 -0
- data/debug_tools/Orbit_Preset.2.txt +2 -0
- data/debug_tools/Orbit_Preset.mmon +72 -0
- data/debug_tools/compare.sh +12 -0
- data/debug_tools/decode.rb +14 -0
- data/debug_tools/extract_midi_monitor_sample.sh +33 -0
- data/docs/Numark_Orbit_QuickRef.md +50 -0
- data/examples/launchpad_testbed.rb +141 -0
- data/examples/monitor.rb +61 -0
- data/examples/orbit_testbed.rb +62 -0
- data/lib/control_center.rb +26 -0
- data/lib/surface_master/device.rb +90 -0
- data/lib/surface_master/errors.rb +27 -0
- data/lib/surface_master/interaction.rb +133 -0
- data/lib/surface_master/launchpad/device.rb +159 -0
- data/lib/surface_master/launchpad/errors.rb +11 -0
- data/lib/surface_master/launchpad/interaction.rb +86 -0
- data/lib/surface_master/launchpad/midi_codes.rb +51 -0
- data/lib/surface_master/logging.rb +15 -0
- data/lib/surface_master/orbit/device.rb +160 -0
- data/lib/surface_master/orbit/interaction.rb +29 -0
- data/lib/surface_master/orbit/midi_codes.rb +31 -0
- data/lib/surface_master/version.rb +3 -0
- data/mappings/Orbit_Preset.json +662 -0
- data/surface_master.gemspec +26 -0
- data/test/helper.rb +44 -0
- data/test/test_device.rb +530 -0
- data/test/test_interaction.rb +456 -0
- 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
|