musalce-server 0.5.1 → 0.8.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.
- checksums.yaml +4 -4
- data/.github/workflows/notify-plugin.yml +17 -0
- data/.gitignore +3 -0
- data/.version +6 -0
- data/.yardopts +6 -0
- data/README.md +307 -10
- data/docs/architecture.md +178 -0
- data/lib/bitwig/bitwig.rb +32 -0
- data/lib/bitwig/controllers.rb +93 -0
- data/lib/bitwig/handler.rb +30 -0
- data/lib/bitwig/tracks.rb +46 -0
- data/lib/daw.rb +193 -4
- data/lib/live/handler.rb +14 -0
- data/lib/live/live.rb +36 -0
- data/lib/live/tracks.rb +67 -2
- data/lib/midi-devices.rb +58 -1
- data/lib/musalce-server.rb +33 -0
- data/lib/surface-bridge.rb +171 -0
- data/lib/surface.rb +369 -0
- data/lib/version.rb +13 -1
- data/musalce-server.gemspec +20 -13
- metadata +90 -17
data/lib/live/live.rb
CHANGED
|
@@ -4,8 +4,27 @@ require_relative 'handler'
|
|
|
4
4
|
require_relative 'tracks'
|
|
5
5
|
|
|
6
6
|
module MusaLCEServer
|
|
7
|
+
# Ableton Live integration module.
|
|
8
|
+
#
|
|
9
|
+
# Provides support for live coding with Ableton Live 11+ through
|
|
10
|
+
# the MusaLCE for Live MIDI Remote Script.
|
|
11
|
+
#
|
|
12
|
+
# @see https://github.com/javier-sy/MusaLCEforLive MIDI Remote Script
|
|
7
13
|
module Live
|
|
14
|
+
# DAW controller for Ableton Live.
|
|
15
|
+
#
|
|
16
|
+
# Implements the {Daw} interface for Ableton Live, providing
|
|
17
|
+
# track management and MIDI routing through the MusaLCE for Live
|
|
18
|
+
# MIDI Remote Script.
|
|
19
|
+
#
|
|
20
|
+
# @note Transport controls (play, stop, etc.) are not implemented
|
|
21
|
+
# for Live as the MIDI Remote Script API doesn't support them.
|
|
22
|
+
#
|
|
23
|
+
# @example
|
|
24
|
+
# # Started via MusaLCEServer.run('live')
|
|
25
|
+
# daw.track('Bass').out.note(60, velocity: 100, duration: 1)
|
|
8
26
|
class Live < Daw
|
|
27
|
+
# @api private
|
|
9
28
|
def daw_initialize(midi_devices:, clock:, osc_server:, osc_client:, logger:)
|
|
10
29
|
super
|
|
11
30
|
tracks = Tracks.new(midi_devices, logger: logger)
|
|
@@ -16,6 +35,13 @@ module MusaLCEServer
|
|
|
16
35
|
return tracks, handler
|
|
17
36
|
end
|
|
18
37
|
|
|
38
|
+
# Retrieves track(s) by name.
|
|
39
|
+
#
|
|
40
|
+
# Unlike Bitwig, Live can have multiple tracks with the same name.
|
|
41
|
+
#
|
|
42
|
+
# @param name [String] the track name
|
|
43
|
+
# @param all [Boolean] if true, returns all matching tracks; otherwise returns first match
|
|
44
|
+
# @return [Track, Array<Track>] the track(s) matching the name
|
|
19
45
|
def track(name, all: false)
|
|
20
46
|
if all
|
|
21
47
|
@tracks.find_by_name(name)
|
|
@@ -24,6 +50,16 @@ module MusaLCEServer
|
|
|
24
50
|
end
|
|
25
51
|
end
|
|
26
52
|
|
|
53
|
+
# Sets the MIDI device to use for clock synchronization.
|
|
54
|
+
#
|
|
55
|
+
# @param midi_device_name [String] default device name to search for
|
|
56
|
+
# @param manufacturer [String, nil] optional manufacturer filter
|
|
57
|
+
# @param model [String, nil] optional model filter
|
|
58
|
+
# @param name [String, nil] optional name filter (overrides midi_device_name)
|
|
59
|
+
# @return [void]
|
|
60
|
+
#
|
|
61
|
+
# @example
|
|
62
|
+
# daw.midi_sync('IAC Driver Bus 1')
|
|
27
63
|
def midi_sync(midi_device_name, manufacturer: nil, model: nil, name: nil)
|
|
28
64
|
name ||= midi_device_name
|
|
29
65
|
|
data/lib/live/tracks.rb
CHANGED
|
@@ -2,7 +2,17 @@ require 'musa-dsl/core-ext/dynamic-proxy'
|
|
|
2
2
|
|
|
3
3
|
module MusaLCEServer
|
|
4
4
|
module Live
|
|
5
|
+
# Represents a track in Ableton Live.
|
|
6
|
+
#
|
|
7
|
+
# Tracks in Live are identified by their internal ID and can have
|
|
8
|
+
# MIDI input routing configured. The output is dynamically proxied
|
|
9
|
+
# to allow routing changes without recreating the track object.
|
|
5
10
|
class Track
|
|
11
|
+
# Creates a new track.
|
|
12
|
+
#
|
|
13
|
+
# @param id [Integer] the Live track ID
|
|
14
|
+
# @param midi_devices [MIDIDevices] the MIDI devices manager
|
|
15
|
+
# @param logger [Logger] the logger
|
|
6
16
|
def initialize(id, midi_devices, logger:)
|
|
7
17
|
@id = id
|
|
8
18
|
@midi_devices = midi_devices
|
|
@@ -11,35 +21,49 @@ module MusaLCEServer
|
|
|
11
21
|
@output = Musa::Extension::DynamicProxy::DynamicProxy.new
|
|
12
22
|
end
|
|
13
23
|
|
|
24
|
+
# @!attribute [r] id
|
|
25
|
+
# @return [Integer] the Live track ID
|
|
26
|
+
# @!attribute [r] name
|
|
27
|
+
# @return [String, nil] the track name
|
|
14
28
|
attr_reader :id, :name
|
|
15
29
|
|
|
30
|
+
# Returns the MIDI output for this track.
|
|
31
|
+
#
|
|
32
|
+
# @return [Musa::Extension::DynamicProxy::DynamicProxy] proxy to the MIDI voice
|
|
16
33
|
def out
|
|
17
34
|
@output
|
|
18
35
|
end
|
|
19
36
|
|
|
37
|
+
# @api private
|
|
20
38
|
def _update_name(value)
|
|
21
39
|
@name = value
|
|
22
40
|
@logger.info "track #{@id} assigned name #{@name}"
|
|
23
41
|
end
|
|
24
42
|
|
|
43
|
+
# @api private
|
|
25
44
|
def _update_has_midi_input(value);
|
|
26
45
|
@has_midi_input = value == 1;
|
|
27
46
|
end
|
|
47
|
+
# @api private
|
|
28
48
|
def _update_has_midi_output(value);
|
|
29
49
|
@has_midi_output = value == 1;
|
|
30
50
|
end
|
|
51
|
+
# @api private
|
|
31
52
|
def _update_has_audio_input(value);
|
|
32
53
|
@has_audio_input = value == 1;
|
|
33
54
|
end
|
|
55
|
+
# @api private
|
|
34
56
|
def _update_has_audio_output(value);
|
|
35
57
|
@has_audio_output = value == 1;
|
|
36
58
|
end
|
|
37
59
|
|
|
60
|
+
# @api private
|
|
38
61
|
def _update_current_input_routing(value)
|
|
39
62
|
@current_input_routing = value
|
|
40
63
|
_update_current_input_sub_routing(@current_input_sub_routing)
|
|
41
64
|
end
|
|
42
65
|
|
|
66
|
+
# @api private
|
|
43
67
|
def _update_current_input_sub_routing(value)
|
|
44
68
|
@current_input_sub_routing = value
|
|
45
69
|
|
|
@@ -59,24 +83,40 @@ module MusaLCEServer
|
|
|
59
83
|
@output.receiver = effective_midi_voice
|
|
60
84
|
end
|
|
61
85
|
|
|
86
|
+
# @api private
|
|
62
87
|
def _update_current_output_routing(value);
|
|
63
88
|
@current_output_routing = value
|
|
64
89
|
end
|
|
65
90
|
|
|
91
|
+
# @api private
|
|
66
92
|
def _update_current_output_sub_routing(value);
|
|
67
93
|
@current_output_sub_routing = value
|
|
68
94
|
end
|
|
69
95
|
end
|
|
70
96
|
|
|
97
|
+
# Collection of tracks for Ableton Live.
|
|
98
|
+
#
|
|
99
|
+
# Manages track registration and lookup, automatically creating
|
|
100
|
+
# and updating tracks based on OSC messages from the MIDI Remote Script.
|
|
101
|
+
#
|
|
102
|
+
# @api private
|
|
71
103
|
class Tracks
|
|
72
104
|
include Enumerable
|
|
73
105
|
|
|
106
|
+
# Creates a new tracks collection.
|
|
107
|
+
#
|
|
108
|
+
# @param midi_devices [MIDIDevices] the MIDI devices manager
|
|
109
|
+
# @param logger [Logger] the logger
|
|
74
110
|
def initialize(midi_devices, logger:)
|
|
75
111
|
@midi_devices = midi_devices
|
|
76
112
|
@logger = logger
|
|
77
113
|
@tracks = {}
|
|
78
114
|
end
|
|
79
115
|
|
|
116
|
+
# Processes a batch of track data, creating, updating, and deleting tracks.
|
|
117
|
+
#
|
|
118
|
+
# @param tracks_data [Array<Array>] array of track data arrays
|
|
119
|
+
# @return [void]
|
|
80
120
|
def grant_registry_collection(tracks_data)
|
|
81
121
|
tracks_to_delete = Set[*@tracks.keys]
|
|
82
122
|
|
|
@@ -91,6 +131,19 @@ module MusaLCEServer
|
|
|
91
131
|
end
|
|
92
132
|
end
|
|
93
133
|
|
|
134
|
+
# Registers or updates a track with the provided data.
|
|
135
|
+
#
|
|
136
|
+
# @param id [Integer] the track ID
|
|
137
|
+
# @param name [String, nil] the track name
|
|
138
|
+
# @param has_midi_input [Integer, nil] 1 if track has MIDI input
|
|
139
|
+
# @param has_midi_output [Integer, nil] 1 if track has MIDI output
|
|
140
|
+
# @param has_audio_input [Integer, nil] 1 if track has audio input
|
|
141
|
+
# @param has_audio_output [Integer, nil] 1 if track has audio output
|
|
142
|
+
# @param current_input_routing [String, nil] input routing device name
|
|
143
|
+
# @param current_input_sub_routing [String, nil] input sub-routing (channel)
|
|
144
|
+
# @param current_output_routing [String, nil] output routing device name
|
|
145
|
+
# @param current_output_sub_routing [String, nil] output sub-routing
|
|
146
|
+
# @return [void]
|
|
94
147
|
def grant_registry(id, name = nil,
|
|
95
148
|
has_midi_input = nil, has_midi_output = nil,
|
|
96
149
|
has_audio_input = nil, has_audio_output = nil,
|
|
@@ -115,6 +168,10 @@ module MusaLCEServer
|
|
|
115
168
|
track._update_current_output_sub_routing(current_output_sub_routing) if current_output_sub_routing
|
|
116
169
|
end
|
|
117
170
|
|
|
171
|
+
# Iterates over all tracks.
|
|
172
|
+
#
|
|
173
|
+
# @yield [Track] each track
|
|
174
|
+
# @return [Enumerator] if no block given
|
|
118
175
|
def each(&block)
|
|
119
176
|
if block_given?
|
|
120
177
|
@tracks.values.each(&block)
|
|
@@ -123,12 +180,20 @@ module MusaLCEServer
|
|
|
123
180
|
end
|
|
124
181
|
end
|
|
125
182
|
|
|
183
|
+
# Retrieves a track by ID.
|
|
184
|
+
#
|
|
185
|
+
# @param id [Integer] the track ID
|
|
186
|
+
# @return [Track, nil] the track or nil if not found
|
|
126
187
|
def [](id)
|
|
127
188
|
@tracks[id]
|
|
128
189
|
end
|
|
129
190
|
|
|
130
|
-
#
|
|
131
|
-
|
|
191
|
+
# Finds all tracks with the given name.
|
|
192
|
+
#
|
|
193
|
+
# @param name [String] the track name
|
|
194
|
+
# @return [Array<Track>] matching tracks
|
|
195
|
+
#
|
|
196
|
+
# @todo Adapt to Bitwig semantics where track names are unique
|
|
132
197
|
def find_by_name(name)
|
|
133
198
|
@tracks.values.select { |_| _.name == name }
|
|
134
199
|
end
|
data/lib/midi-devices.rb
CHANGED
|
@@ -3,9 +3,22 @@ require 'midi-communications'
|
|
|
3
3
|
require 'musa-dsl/midi/midi-voices'
|
|
4
4
|
|
|
5
5
|
module MusaLCEServer
|
|
6
|
+
# Manages available MIDI output devices.
|
|
7
|
+
#
|
|
8
|
+
# Provides enumeration and lookup of MIDI devices, automatically
|
|
9
|
+
# synchronizing with the system's available devices.
|
|
10
|
+
#
|
|
11
|
+
# @example Iterating over devices
|
|
12
|
+
# midi_devices.each { |device| puts device.name }
|
|
13
|
+
#
|
|
14
|
+
# @example Finding a device by name suffix
|
|
15
|
+
# device = midi_devices.find('IAC Driver Bus 1')
|
|
6
16
|
class MIDIDevices
|
|
7
17
|
include Enumerable
|
|
8
18
|
|
|
19
|
+
# Creates a new MIDI devices manager.
|
|
20
|
+
#
|
|
21
|
+
# @param sequencer [Musa::Sequencer::Sequencer] the sequencer for MIDI voice management
|
|
9
22
|
def initialize(sequencer)
|
|
10
23
|
@sequencer = sequencer
|
|
11
24
|
@low_level_devices = {}
|
|
@@ -13,6 +26,11 @@ module MusaLCEServer
|
|
|
13
26
|
sync
|
|
14
27
|
end
|
|
15
28
|
|
|
29
|
+
# Synchronizes the device list with system MIDI devices.
|
|
30
|
+
#
|
|
31
|
+
# Adds newly connected devices and removes disconnected ones.
|
|
32
|
+
#
|
|
33
|
+
# @return [void]
|
|
16
34
|
def sync
|
|
17
35
|
names = @low_level_devices.keys
|
|
18
36
|
|
|
@@ -30,15 +48,32 @@ module MusaLCEServer
|
|
|
30
48
|
end
|
|
31
49
|
end
|
|
32
50
|
|
|
51
|
+
# Retrieves a device by exact name.
|
|
52
|
+
#
|
|
53
|
+
# @param name [String] the exact device name
|
|
54
|
+
# @return [MIDIDevice, nil] the device or nil if not found
|
|
33
55
|
def [](name)
|
|
34
56
|
@low_level_devices[name]
|
|
35
57
|
end
|
|
36
58
|
|
|
59
|
+
# Finds a device by name suffix.
|
|
60
|
+
#
|
|
61
|
+
# Useful when device names include prefixes that vary by system.
|
|
62
|
+
#
|
|
63
|
+
# @param name [String] the name suffix to match
|
|
64
|
+
# @return [MIDIDevice, nil] the first matching device or nil
|
|
65
|
+
#
|
|
66
|
+
# @example
|
|
67
|
+
# device = midi_devices.find('Bus 1') # Matches 'IAC Driver Bus 1'
|
|
37
68
|
def find(name)
|
|
38
69
|
full_name = @low_level_devices.keys.find { |_| _.end_with?(name) }
|
|
39
70
|
@low_level_devices[full_name]
|
|
40
71
|
end
|
|
41
72
|
|
|
73
|
+
# Iterates over all MIDI devices.
|
|
74
|
+
#
|
|
75
|
+
# @yield [MIDIDevice] each device
|
|
76
|
+
# @return [Enumerator] if no block given
|
|
42
77
|
def each(&block)
|
|
43
78
|
if block_given?
|
|
44
79
|
@low_level_devices.values.each(&block)
|
|
@@ -47,27 +82,49 @@ module MusaLCEServer
|
|
|
47
82
|
end
|
|
48
83
|
end
|
|
49
84
|
end
|
|
50
|
-
|
|
85
|
+
|
|
86
|
+
# Wrapper for a MIDI output device with voice management.
|
|
87
|
+
#
|
|
88
|
+
# Provides access to individual MIDI channels as voices and
|
|
89
|
+
# panic functionality.
|
|
51
90
|
class MIDIDevice
|
|
91
|
+
# Creates a new MIDI device wrapper.
|
|
92
|
+
#
|
|
93
|
+
# @param sequencer [Musa::Sequencer::Sequencer] the sequencer for voice management
|
|
94
|
+
# @param low_level_device [MIDICommunications::Output] the underlying MIDI device
|
|
52
95
|
def initialize(sequencer, low_level_device)
|
|
53
96
|
@low_level_device = low_level_device
|
|
54
97
|
@voices = Musa::MIDIVoices::MIDIVoices.new(sequencer: sequencer, output: low_level_device, channels: 0..15, do_log: true)
|
|
55
98
|
end
|
|
56
99
|
|
|
100
|
+
# @!attribute [r] low_level_device
|
|
101
|
+
# @return [MIDICommunications::Output] the underlying MIDI output device
|
|
57
102
|
attr_reader :low_level_device
|
|
58
103
|
|
|
104
|
+
# Returns the device name.
|
|
105
|
+
#
|
|
106
|
+
# @return [String] the device name
|
|
59
107
|
def name
|
|
60
108
|
@low_level_device.name
|
|
61
109
|
end
|
|
62
110
|
|
|
111
|
+
# Sends All Notes Off and reset to all channels.
|
|
112
|
+
#
|
|
113
|
+
# @return [void]
|
|
63
114
|
def panic!
|
|
64
115
|
@voices.panic reset: true
|
|
65
116
|
end
|
|
66
117
|
|
|
118
|
+
# Returns the MIDI channels/voices for this device.
|
|
119
|
+
#
|
|
120
|
+
# @return [Array<Musa::MIDIVoices::MIDIVoice>] the 16 MIDI channels
|
|
67
121
|
def channels
|
|
68
122
|
@voices.voices
|
|
69
123
|
end
|
|
70
124
|
|
|
125
|
+
# Returns the display name of the device.
|
|
126
|
+
#
|
|
127
|
+
# @return [String] the display name
|
|
71
128
|
def to_s
|
|
72
129
|
@low_level_device.display_name
|
|
73
130
|
end
|
data/lib/musalce-server.rb
CHANGED
|
@@ -4,10 +4,43 @@ require 'osc-ruby'
|
|
|
4
4
|
require 'osc-ruby/em_server'
|
|
5
5
|
|
|
6
6
|
require_relative 'version'
|
|
7
|
+
require_relative 'surface'
|
|
8
|
+
require_relative 'surface-bridge'
|
|
7
9
|
require_relative 'live/live'
|
|
8
10
|
require_relative 'bitwig/bitwig'
|
|
9
11
|
|
|
12
|
+
# Musa Live Coding Environment Server.
|
|
13
|
+
#
|
|
14
|
+
# This module provides the main entry point for the MusaLCE server,
|
|
15
|
+
# which enables live coding with Ableton Live 11+ and Bitwig Studio 5+.
|
|
16
|
+
#
|
|
17
|
+
# The server provides:
|
|
18
|
+
# - OSC communication with DAW controller extensions
|
|
19
|
+
# - MIDI device management and routing
|
|
20
|
+
# - A REPL (Read-Eval-Print-Loop) for interactive live coding
|
|
21
|
+
# - Integration with Musa-DSL sequencer for music composition
|
|
22
|
+
#
|
|
23
|
+
# @example Starting the server for Bitwig Studio
|
|
24
|
+
# MusaLCEServer.run('bitwig')
|
|
25
|
+
#
|
|
26
|
+
# @example Starting the server for Ableton Live
|
|
27
|
+
# MusaLCEServer.run('live')
|
|
28
|
+
#
|
|
29
|
+
# @see Daw Base class for DAW controllers
|
|
30
|
+
# @see Bitwig::Bitwig Bitwig Studio driver
|
|
31
|
+
# @see Live::Live Ableton Live driver
|
|
10
32
|
module MusaLCEServer
|
|
33
|
+
# Starts the MusaLCE server for the specified DAW.
|
|
34
|
+
#
|
|
35
|
+
# This method initializes the DAW controller, sets up the REPL environment,
|
|
36
|
+
# and starts the main server loop. The server runs until `shutdown` is called from the REPL.
|
|
37
|
+
#
|
|
38
|
+
# @param daw_name [String] the DAW to connect to ('bitwig' or 'live')
|
|
39
|
+
# @return [void]
|
|
40
|
+
# @raise [ArgumentError] if daw_name is nil or not a supported DAW
|
|
41
|
+
#
|
|
42
|
+
# @example
|
|
43
|
+
# MusaLCEServer.run('bitwig')
|
|
11
44
|
def self.run(daw_name)
|
|
12
45
|
raise ArgumentError, 'A daw must be specified. Options: \'bitwig\' or \'live\'' unless daw_name
|
|
13
46
|
raise ArgumentError, "Incompatible DAW '#{daw_name}'. Options: 'bitwig' or 'live'" unless %w[bitwig live].include?(daw_name)
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
require 'osc-ruby'
|
|
2
|
+
|
|
3
|
+
module MusaLCEServer
|
|
4
|
+
# OSC bridge between the server-side {Surface} and the physical
|
|
5
|
+
# control surface (Stream Deck, …) reached through the chain
|
|
6
|
+
# MusaLCEServer ↔ MusaLCEforXXX ↔ Pulso Bridge ↔ plugin.
|
|
7
|
+
#
|
|
8
|
+
# Two responsibilities:
|
|
9
|
+
#
|
|
10
|
+
# 1. **Outbound emission** — translates {Surface} state changes
|
|
11
|
+
# and sync requests into +/musalce/surface/*+ OSC messages
|
|
12
|
+
# sent on the existing UDP client (port 10001, shared with
|
|
13
|
+
# {Daw}).
|
|
14
|
+
#
|
|
15
|
+
# 2. **Inbound dispatch** — receives +/musalce/surface/*+ messages
|
|
16
|
+
# on the OSC server (EM reactor thread), enqueues them, and
|
|
17
|
+
# drains the queue on the sequencer tick thread via
|
|
18
|
+
# {#drain}. This is critical: inventory mutations and trigger
|
|
19
|
+
# dispatch may invoke arbitrary user DSL code (+play+, +at+,
|
|
20
|
+
# +launch+, …) which must run on the sequencer thread.
|
|
21
|
+
#
|
|
22
|
+
# Wired up by {Daw#initialize}; expected to be the sole emitter
|
|
23
|
+
# of +/musalce/surface/*+ on the server side.
|
|
24
|
+
class SurfaceBridge
|
|
25
|
+
# @return [Surface] the surface this bridge dispatches inbound
|
|
26
|
+
# messages to; set during {Daw} initialization after both
|
|
27
|
+
# instances exist.
|
|
28
|
+
attr_accessor :surface
|
|
29
|
+
|
|
30
|
+
# @param osc_client [OSC::Client] outbound OSC client (shared
|
|
31
|
+
# with the active {Handler})
|
|
32
|
+
# @param sequencer [Musa::Sequencer::Sequencer] used to launch
|
|
33
|
+
# user-defined event handlers in response to surface triggers
|
|
34
|
+
# @param logger [Logger] the logger
|
|
35
|
+
def initialize(osc_client, sequencer, logger:)
|
|
36
|
+
@client = osc_client
|
|
37
|
+
@sequencer = sequencer
|
|
38
|
+
@logger = logger
|
|
39
|
+
@inbox = Queue.new
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Sends +/musalce/surface/sync_request+ outbound. Used on
|
|
43
|
+
# server startup to ask Pulso Bridge (via the DAW extension) to
|
|
44
|
+
# dump its current inventory. The reply arrives as a sequence
|
|
45
|
+
# of +inventory/begin+, +inventory/add+ ..., +inventory/end+.
|
|
46
|
+
# @return [void]
|
|
47
|
+
def request_sync
|
|
48
|
+
send_osc '/musalce/surface/sync_request'
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Sends +/musalce/surface/state/<prop>+ for a property change.
|
|
52
|
+
#
|
|
53
|
+
# One address per property so the Java relay and the surface
|
|
54
|
+
# plugin can dispatch on a fixed argument layout per address
|
|
55
|
+
# (event + N typed args). All values are serialized to strings
|
|
56
|
+
# on the wire — receivers parse them based on the address.
|
|
57
|
+
# Keeps the Java forwarder generic without inspecting typetags.
|
|
58
|
+
#
|
|
59
|
+
# @param event [Symbol] the event the control is bound to
|
|
60
|
+
# @param prop [Symbol] the property name (+:message+,
|
|
61
|
+
# +:enabled+, +:value+, +:range+, …)
|
|
62
|
+
# @param value [Array<Object>] one or more values for the
|
|
63
|
+
# property (e.g. one for +:message+, two for +:range+)
|
|
64
|
+
# @return [void]
|
|
65
|
+
def send_state(event:, prop:, value:)
|
|
66
|
+
args = value.map { |v| serialize_arg(v) }
|
|
67
|
+
send_osc "/musalce/surface/state/#{prop}", event.to_s, *args
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Registers all inbound +/musalce/surface/*+ handlers on the
|
|
71
|
+
# given OSC server. Each handler enqueues the message; actual
|
|
72
|
+
# processing happens on {#drain}.
|
|
73
|
+
#
|
|
74
|
+
# @param osc_server [OSC::EMServer]
|
|
75
|
+
# @return [void]
|
|
76
|
+
def register_inbound(osc_server)
|
|
77
|
+
osc_server.add_method('/musalce/surface/inventory/begin') do |_msg|
|
|
78
|
+
@inbox << [:inventory_begin]
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
osc_server.add_method('/musalce/surface/inventory/add') do |msg|
|
|
82
|
+
args = msg.to_a
|
|
83
|
+
@inbox << [:inventory_add, args[0], args[1]]
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
osc_server.add_method('/musalce/surface/inventory/remove') do |msg|
|
|
87
|
+
@inbox << [:inventory_remove, msg.to_a[0]]
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
osc_server.add_method('/musalce/surface/inventory/end') do |_msg|
|
|
91
|
+
@inbox << [:inventory_end]
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
osc_server.add_method('/musalce/surface/state_request') do |_msg|
|
|
95
|
+
@inbox << [:state_request]
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
osc_server.add_method('/musalce/surface/trigger') do |msg|
|
|
99
|
+
args = msg.to_a
|
|
100
|
+
@inbox << [:trigger, args[0], (args[1] || '').to_s]
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Drains the inbound queue. Called from the sequencer tick
|
|
105
|
+
# thread (via +before_tick+) so every dispatched action runs in
|
|
106
|
+
# a context where DSL methods like +launch+, +play+, +at+ are
|
|
107
|
+
# safe to invoke.
|
|
108
|
+
# @return [void]
|
|
109
|
+
# @api private
|
|
110
|
+
def drain
|
|
111
|
+
loop do
|
|
112
|
+
msg = @inbox.pop(true)
|
|
113
|
+
dispatch(msg)
|
|
114
|
+
end
|
|
115
|
+
rescue ThreadError
|
|
116
|
+
# Queue empty — done draining.
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
private def dispatch(msg)
|
|
120
|
+
kind = msg[0]
|
|
121
|
+
case kind
|
|
122
|
+
when :inventory_begin
|
|
123
|
+
@surface.begin_inventory
|
|
124
|
+
when :inventory_add
|
|
125
|
+
event, type = msg[1], msg[2]
|
|
126
|
+
if event.nil? || type.nil?
|
|
127
|
+
@logger.warn "/musalce/surface/inventory/add missing event or type (#{msg.inspect})"
|
|
128
|
+
else
|
|
129
|
+
@surface.add_control(event, type)
|
|
130
|
+
end
|
|
131
|
+
when :inventory_remove
|
|
132
|
+
event = msg[1]
|
|
133
|
+
@surface.remove_control(event) unless event.nil?
|
|
134
|
+
when :inventory_end
|
|
135
|
+
@surface.end_inventory
|
|
136
|
+
when :state_request
|
|
137
|
+
@surface.emit_full_state
|
|
138
|
+
when :trigger
|
|
139
|
+
event, payload = msg[1], msg[2]
|
|
140
|
+
if event.nil? || event.to_s.empty?
|
|
141
|
+
@logger.warn '/musalce/surface/trigger received without event'
|
|
142
|
+
elsif !@surface.known?(event)
|
|
143
|
+
@logger.warn "/musalce/surface/trigger for unknown event #{event.inspect}; ignoring"
|
|
144
|
+
else
|
|
145
|
+
@sequencer.launch(event.to_sym, payload)
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
rescue StandardError => e
|
|
149
|
+
@logger.error "Error dispatching surface message #{msg.inspect}: #{e.class}: #{e.message}"
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# All values cross the wire as strings: this lets the Java
|
|
153
|
+
# relay forward generically without inspecting OSC typetags,
|
|
154
|
+
# and lets the plugin parse per-property. Receivers know how
|
|
155
|
+
# to interpret each value from the OSC address.
|
|
156
|
+
private def serialize_arg(v)
|
|
157
|
+
v.nil? ? '' : v.to_s
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
private def send_osc(address, *args)
|
|
161
|
+
counter = 0
|
|
162
|
+
begin
|
|
163
|
+
@client.send OSC::Message.new(address, *args)
|
|
164
|
+
rescue Errno::ECONNREFUSED
|
|
165
|
+
counter += 1
|
|
166
|
+
@logger.warn "Errno::ECONNREFUSED sending #{address} #{args}. Retrying... (#{counter})"
|
|
167
|
+
retry if counter < 3
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|