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.
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
- # TODO adaptar a contrato y semántica de Bitwig (en bitwig sólo hay un track con un nombre determinado, el id no existe)
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
@@ -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