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/surface.rb ADDED
@@ -0,0 +1,369 @@
1
+ require 'set'
2
+
3
+ module MusaLCEServer
4
+ # Authoritative model of the physical control surface (Stream Deck
5
+ # buttons, encoders, …) as seen from the server.
6
+ #
7
+ # The surface is **the abstraction shared with hardware** but
8
+ # surface-agnostic: it knows only about named controls with a type
9
+ # and dynamic state. Each control is keyed by its **event name** — a
10
+ # Symbol (e.g. +:launch_chorus+) that doubles as the identifier used
11
+ # with the sequencer's +on+/+launch+ mechanism when the control
12
+ # fires. In MusaLCE the unit of interaction is always the *event*;
13
+ # the surface is the physical face of one or more events.
14
+ #
15
+ # Ownership of the two data axes:
16
+ #
17
+ # - **Inventory** (which controls exist and their type) flows
18
+ # inbound from Pulso Bridge through the DAW extension; the server
19
+ # trusts what it receives (Pulso validates type consistency
20
+ # across physical instances).
21
+ # - **State** (message, enabled, value, …) is owned by the server:
22
+ # the score writes to +surface[:event]+ and changes propagate
23
+ # outbound on +/musalce/surface/state/<prop>+.
24
+ #
25
+ # Inventory may arrive as a full dump (between +inventory/begin+
26
+ # and +inventory/end+, in which case events absent from the dump
27
+ # are purged at end) or as runtime deltas (+inventory/add+ /
28
+ # +inventory/remove+). Re-adding an event with the same type
29
+ # preserves its state; a type change replaces the control and
30
+ # resets state.
31
+ #
32
+ # All mutating methods are expected to run on the sequencer tick
33
+ # thread (inbound OSC messages are routed there by
34
+ # {SurfaceBridge}). Score code writing +surface[:event].xxx =+ ...
35
+ # also runs on that thread (inside +at+/+every+/+on+ blocks),
36
+ # which keeps access serial without explicit locking.
37
+ class Surface
38
+ # @param bridge [SurfaceBridge] the bridge used to emit state outbound
39
+ # @param logger [Logger] the logger
40
+ def initialize(bridge:, logger:)
41
+ @bridge = bridge
42
+ @logger = logger
43
+ @controls = {}
44
+ @pending_events = nil
45
+ end
46
+
47
+ # Returns the control for the given event, or +nil+ if unknown.
48
+ #
49
+ # A control becomes known once its inventory entry has been
50
+ # received from the surface. Score code that runs before that
51
+ # should use safe navigation (+surface[:foo]&.enabled = true+)
52
+ # or guard with {#known?}.
53
+ #
54
+ # @param event [Symbol, String]
55
+ # @return [Control, nil]
56
+ def [](event)
57
+ @controls[event.to_sym]
58
+ end
59
+
60
+ # @return [Array<Symbol>] all known control events
61
+ def events
62
+ @controls.keys
63
+ end
64
+
65
+ # @param event [Symbol, String]
66
+ # @return [Boolean] whether a control with this event is in the inventory
67
+ def known?(event)
68
+ @controls.key?(event.to_sym)
69
+ end
70
+
71
+ # Begins a full inventory dump. Events that are not re-added before
72
+ # {#end_inventory} are purged.
73
+ # @return [void]
74
+ # @api private
75
+ def begin_inventory
76
+ @pending_events = Set.new
77
+ end
78
+
79
+ # Registers (or refreshes) a control in the inventory.
80
+ #
81
+ # If a control with the same event and type already exists, its
82
+ # state is preserved. If the type differs, the existing control
83
+ # is replaced with a fresh instance (state reset).
84
+ #
85
+ # @param event [Symbol, String]
86
+ # @param type [Symbol, String] one of +:toggle+, +:trigger+, +:encoder+
87
+ # @return [Control] the (possibly new) control
88
+ # @api private
89
+ def add_control(event, type)
90
+ event = event.to_sym
91
+ type = type.to_sym
92
+ existing = @controls[event]
93
+
94
+ if existing && existing.class.type_name == type
95
+ ctrl = existing
96
+ else
97
+ ctrl = Control.create(type, event: event, surface: self)
98
+ @controls[event] = ctrl
99
+ @logger.info "Surface: added control #{event} (#{type})"
100
+ end
101
+
102
+ @pending_events << event if @pending_events
103
+ ctrl
104
+ end
105
+
106
+ # Removes a control from the inventory and drops its state.
107
+ # @param event [Symbol, String]
108
+ # @return [Control, nil] the removed control, or nil if unknown
109
+ # @api private
110
+ def remove_control(event)
111
+ event = event.to_sym
112
+ removed = @controls.delete(event)
113
+ @logger.info "Surface: removed control #{event}" if removed
114
+ removed
115
+ end
116
+
117
+ # Ends a full inventory dump. Any event present before the dump
118
+ # but not re-added between {#begin_inventory} and this call is
119
+ # purged. Re-emits all state so the surface re-syncs after the
120
+ # round-trip.
121
+ # @return [void]
122
+ # @api private
123
+ def end_inventory
124
+ if @pending_events
125
+ stale = @controls.keys - @pending_events.to_a
126
+ stale.each do |event|
127
+ @controls.delete(event)
128
+ @logger.info "Surface: purged stale control #{event}"
129
+ end
130
+ @pending_events = nil
131
+ end
132
+ emit_full_state
133
+ end
134
+
135
+ # Re-emits state for every known control. Used after an
136
+ # inventory dump or in response to a +state_request+ from the
137
+ # surface side.
138
+ # @return [void]
139
+ # @api private
140
+ def emit_full_state
141
+ @controls.each_value(&:emit_all_state)
142
+ end
143
+
144
+ # Called by a Control when one of its properties changes; relays
145
+ # to the bridge.
146
+ # @param event [Symbol]
147
+ # @param prop [Symbol]
148
+ # @param value [Array<Object>] OSC-serializable values
149
+ # @return [void]
150
+ # @api private
151
+ def emit_state(event, prop, *value)
152
+ @bridge.send_state(event: event, prop: prop, value: value)
153
+ end
154
+ end
155
+
156
+ # Abstract base for all controls on a {Surface}.
157
+ #
158
+ # Subclasses declare a {.type_name} matching the inventory string
159
+ # received from the surface, expose typed state accessors, and
160
+ # implement {#emit_all_state} to push their current state outbound.
161
+ #
162
+ # Setting a property emits exactly one OSC
163
+ # +/musalce/surface/state/<prop>+ message; the setter is therefore
164
+ # the canonical mutation point. Direct manipulation of instance
165
+ # variables bypasses emission.
166
+ class Control
167
+ # @return [Symbol] the event name this control is bound to
168
+ attr_reader :event
169
+
170
+ # @return [String, nil] the displayable message, +nil+ if unset
171
+ attr_reader :message
172
+
173
+ def initialize(event:, surface:)
174
+ @event = event
175
+ @surface = surface
176
+ @message = nil
177
+ end
178
+
179
+ # Sets the displayable message. Two-line text is allowed; the
180
+ # surface side is responsible for truncation/wrapping.
181
+ # @param value [String, nil]
182
+ # @return [void]
183
+ def message=(value)
184
+ @message = value
185
+ emit(:message, value.to_s)
186
+ end
187
+
188
+ # Sets multiple attributes in a single call. Each key must name a
189
+ # writable attribute of the receiver's type; unknown keys raise
190
+ # +ArgumentError+ so a typo can't silently no-op.
191
+ #
192
+ # Order of assignment follows the kwargs hash insertion order
193
+ # (Ruby >= 1.9 guarantees insertion-ordered iteration). Each
194
+ # assignment goes through the regular setter, so each property
195
+ # emits its own +/musalce/surface/state/<prop>+ message. This is
196
+ # intentional: the wire protocol is per-property, and the plugin
197
+ # merges deltas into the rendered state, so two adjacent
198
+ # +/state/<prop>+ messages render exactly the same as a single
199
+ # batched one would.
200
+ #
201
+ # @example Toggle
202
+ # surface[:launch_chorus].set(enabled: true, message: "Chorus on")
203
+ # @example Encoder
204
+ # surface[:cutoff].set(range: 0..127, value: 64, message: "Cutoff")
205
+ #
206
+ # @param attrs [Hash{Symbol => Object}]
207
+ # @raise [ArgumentError] if a key doesn't correspond to a writer
208
+ # @return [self] for chaining
209
+ def set(**attrs)
210
+ attrs.each do |key, value|
211
+ writer = :"#{key}="
212
+ unless respond_to?(writer)
213
+ raise ArgumentError,
214
+ "#{self.class.type_name} control has no '#{key}' attribute"
215
+ end
216
+ public_send(writer, value)
217
+ end
218
+ self
219
+ end
220
+
221
+ # Re-emits every state property of this control. Called by the
222
+ # surface during +state_request+ or after inventory dumps.
223
+ # @return [void]
224
+ # @api private
225
+ def emit_all_state
226
+ emit(:message, @message.to_s) unless @message.nil?
227
+ end
228
+
229
+ # @return [Symbol] the inventory type identifier
230
+ def self.type_name
231
+ raise NotImplementedError, "#{self} must implement .type_name"
232
+ end
233
+
234
+ # Instantiates the right subclass for the given type.
235
+ # @param type [Symbol]
236
+ # @return [Control]
237
+ # @raise [ArgumentError] if the type is unknown
238
+ # @api private
239
+ def self.create(type, **kwargs)
240
+ case type.to_sym
241
+ when :toggle then Toggle.new(**kwargs)
242
+ when :trigger then Trigger.new(**kwargs)
243
+ when :encoder then Encoder.new(**kwargs)
244
+ else raise ArgumentError, "Unknown control type: #{type.inspect}"
245
+ end
246
+ end
247
+
248
+ protected def emit(prop, *value)
249
+ @surface.emit_state(@event, prop, *value)
250
+ end
251
+ end
252
+
253
+ # A stateful on/off control with a three-valued enabled property:
254
+ # +true+ (on), +false+ (off available), +:inactive+ (control is
255
+ # known but currently not actionable, typically rendered dimmed).
256
+ class Toggle < Control
257
+ def self.type_name = :toggle
258
+
259
+ # @return [Boolean, Symbol] +true+, +false+, or +:inactive+
260
+ attr_reader :enabled
261
+
262
+ def initialize(**kwargs)
263
+ super
264
+ @enabled = :inactive
265
+ end
266
+
267
+ # Sets the enabled state. Accepts +true+, +false+, +:inactive+
268
+ # and their string equivalents.
269
+ # @param value [Boolean, Symbol, String]
270
+ # @raise [ArgumentError] on any other value
271
+ def enabled=(value)
272
+ @enabled = normalize_enabled(value)
273
+ emit(:enabled, @enabled.to_s)
274
+ end
275
+
276
+ # @return [Boolean] true iff +enabled+ is exactly +true+
277
+ def enabled?
278
+ @enabled == true
279
+ end
280
+
281
+ # @return [Boolean] true iff +enabled+ is +:inactive+
282
+ def inactive?
283
+ @enabled == :inactive
284
+ end
285
+
286
+ # Convenience: set to +true+.
287
+ def on! = (self.enabled = true)
288
+ # Convenience: set to +false+.
289
+ def off! = (self.enabled = false)
290
+ # Convenience: set to +:inactive+.
291
+ def inactive! = (self.enabled = :inactive)
292
+
293
+ # Toggles between +true+ and +false+. From +:inactive+ goes to
294
+ # +true+ (entering active service).
295
+ def toggle!
296
+ case @enabled
297
+ when true then off!
298
+ when false then on!
299
+ else on!
300
+ end
301
+ end
302
+
303
+ def emit_all_state
304
+ super
305
+ emit(:enabled, @enabled.to_s)
306
+ end
307
+
308
+ private def normalize_enabled(v)
309
+ case v
310
+ when true, :true, 'true' then true
311
+ when false, :false, 'false' then false
312
+ when :inactive, 'inactive', nil then :inactive
313
+ else
314
+ raise ArgumentError,
315
+ "enabled must be true, false or :inactive (got #{v.inspect})"
316
+ end
317
+ end
318
+ end
319
+
320
+ # A momentary, stateless control. Pressing it fires the
321
+ # corresponding sequencer event; the control itself carries no
322
+ # persistent on/off state beyond an optional {#message}.
323
+ class Trigger < Control
324
+ def self.type_name = :trigger
325
+ end
326
+
327
+ # An absolute-value rotary or fader control with an integer
328
+ # +value+ inside an inclusive +range+. Range defaults to
329
+ # +0..127+ (standard MIDI 7-bit).
330
+ class Encoder < Control
331
+ def self.type_name = :encoder
332
+
333
+ # @return [Integer]
334
+ attr_reader :value
335
+ # @return [Range]
336
+ attr_reader :range
337
+
338
+ def initialize(**kwargs)
339
+ super
340
+ @range = 0..127
341
+ @value = 0
342
+ end
343
+
344
+ # @param v [Integer, Numeric] clamped to {#range}
345
+ def value=(v)
346
+ @value = clamp_to_range(v.to_i)
347
+ emit(:value, @value)
348
+ end
349
+
350
+ # @param r [Range] inclusive integer range; +value+ is re-clamped
351
+ def range=(r)
352
+ raise ArgumentError, "range must be a Range (got #{r.inspect})" unless r.is_a?(Range)
353
+ @range = r
354
+ @value = clamp_to_range(@value)
355
+ emit(:range, r.min, r.max)
356
+ emit(:value, @value)
357
+ end
358
+
359
+ def emit_all_state
360
+ super
361
+ emit(:range, @range.min, @range.max)
362
+ emit(:value, @value)
363
+ end
364
+
365
+ private def clamp_to_range(v)
366
+ [[v, @range.min].max, @range.max].min
367
+ end
368
+ end
369
+ end
data/lib/version.rb CHANGED
@@ -1,3 +1,15 @@
1
+ # Musa Live Coding Environment Server.
2
+ #
3
+ # A Ruby server for live coding with Ableton Live and Bitwig Studio DAWs.
4
+ # Provides OSC communication, MIDI device management, and a REPL for
5
+ # interactive music composition using Musa-DSL.
6
+ #
7
+ # @see MusaLCEServer.run Entry point for starting the server
8
+ # @see MusaLCEServer::Daw Base class for DAW controllers
9
+ #
10
+ # @author Javier Sánchez Yeste
11
+ # @since 0.1.0
1
12
  module MusaLCEServer
2
- VERSION = '0.5.1'.freeze
13
+ # Current version of the musalce-server gem.
14
+ VERSION = '0.8.1'.freeze
3
15
  end
@@ -3,7 +3,7 @@ require_relative 'lib/version'
3
3
  Gem::Specification.new do |s|
4
4
  s.name = 'musalce-server'
5
5
  s.version = MusaLCEServer::VERSION
6
- s.date = '2025-08-23'
6
+ s.date = '2026-05-27'
7
7
  s.summary = 'A Musa DSL live coding environment for Ableton Live 11 and Bitwig Studio 5'
8
8
  s.description = 'This package implements the Server part of the Musa DSL Live Coding Environment for Ableton Live and Bitwig Studio'
9
9
  s.authors = ['Javier Sánchez Yeste']
@@ -15,20 +15,27 @@ Gem::Specification.new do |s|
15
15
 
16
16
  s.required_ruby_version = '>= 2.7'
17
17
 
18
- # TODO
19
- #s.metadata = {
20
- # "source_code_uri" => "https://",
21
- # "homepage_uri" => "",
22
- # "documentation_uri" => "",
23
- # "changelog_uri" => ""
24
- #}
18
+ s.metadata = {
19
+ 'homepage_uri' => s.homepage,
20
+ 'source_code_uri' => s.homepage,
21
+ 'documentation_uri' => 'https://www.rubydoc.info/gems/musalce-server'
22
+ }
25
23
 
26
- s.add_runtime_dependency 'musa-dsl', '~> 0', '>= 0.26.0'
24
+ s.add_runtime_dependency 'musa-dsl', '~> 0.40'
27
25
 
28
- s.add_runtime_dependency 'midi-communications', '~> 0.6'
29
- s.add_runtime_dependency 'midi-events', '~> 0.6'
30
- s.add_runtime_dependency 'midi-parser', '~> 0.4'
26
+ s.add_runtime_dependency 'midi-communications', '~> 0.7'
27
+ s.add_runtime_dependency 'midi-events', '~> 0.7'
28
+ s.add_runtime_dependency 'midi-parser', '~> 0.5'
31
29
 
32
- #s.add_runtime_dependency 'eventmachine', '~> 1.2', '>= 1.2.7'
33
30
  s.add_runtime_dependency 'osc-ruby', '~> 1.1', '>= 1.1.5'
31
+ # EventMachine is an *optional* dep of osc-ruby (only needed when
32
+ # using OSC::EMServer, which we do in daw.rb to receive OSC from
33
+ # the DAW extension). osc-ruby doesn't declare it, so we must.
34
+ s.add_runtime_dependency 'eventmachine', '~> 1.2'
35
+
36
+ s.add_development_dependency 'rspec', '~> 3'
37
+
38
+ s.add_development_dependency 'yard', '~> 0.9'
39
+ s.add_development_dependency 'redcarpet', '~> 3.6'
40
+ s.add_development_dependency 'webrick', '~> 1.8'
34
41
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: musalce-server
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.1
4
+ version: 0.8.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Javier Sánchez Yeste
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2025-08-23 00:00:00.000000000 Z
10
+ date: 2026-05-27 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: musa-dsl
@@ -15,62 +15,56 @@ dependencies:
15
15
  requirements:
16
16
  - - "~>"
17
17
  - !ruby/object:Gem::Version
18
- version: '0'
19
- - - ">="
20
- - !ruby/object:Gem::Version
21
- version: 0.26.0
18
+ version: '0.40'
22
19
  type: :runtime
23
20
  prerelease: false
24
21
  version_requirements: !ruby/object:Gem::Requirement
25
22
  requirements:
26
23
  - - "~>"
27
24
  - !ruby/object:Gem::Version
28
- version: '0'
29
- - - ">="
30
- - !ruby/object:Gem::Version
31
- version: 0.26.0
25
+ version: '0.40'
32
26
  - !ruby/object:Gem::Dependency
33
27
  name: midi-communications
34
28
  requirement: !ruby/object:Gem::Requirement
35
29
  requirements:
36
30
  - - "~>"
37
31
  - !ruby/object:Gem::Version
38
- version: '0.6'
32
+ version: '0.7'
39
33
  type: :runtime
40
34
  prerelease: false
41
35
  version_requirements: !ruby/object:Gem::Requirement
42
36
  requirements:
43
37
  - - "~>"
44
38
  - !ruby/object:Gem::Version
45
- version: '0.6'
39
+ version: '0.7'
46
40
  - !ruby/object:Gem::Dependency
47
41
  name: midi-events
48
42
  requirement: !ruby/object:Gem::Requirement
49
43
  requirements:
50
44
  - - "~>"
51
45
  - !ruby/object:Gem::Version
52
- version: '0.6'
46
+ version: '0.7'
53
47
  type: :runtime
54
48
  prerelease: false
55
49
  version_requirements: !ruby/object:Gem::Requirement
56
50
  requirements:
57
51
  - - "~>"
58
52
  - !ruby/object:Gem::Version
59
- version: '0.6'
53
+ version: '0.7'
60
54
  - !ruby/object:Gem::Dependency
61
55
  name: midi-parser
62
56
  requirement: !ruby/object:Gem::Requirement
63
57
  requirements:
64
58
  - - "~>"
65
59
  - !ruby/object:Gem::Version
66
- version: '0.4'
60
+ version: '0.5'
67
61
  type: :runtime
68
62
  prerelease: false
69
63
  version_requirements: !ruby/object:Gem::Requirement
70
64
  requirements:
71
65
  - - "~>"
72
66
  - !ruby/object:Gem::Version
73
- version: '0.4'
67
+ version: '0.5'
74
68
  - !ruby/object:Gem::Dependency
75
69
  name: osc-ruby
76
70
  requirement: !ruby/object:Gem::Requirement
@@ -91,6 +85,76 @@ dependencies:
91
85
  - - ">="
92
86
  - !ruby/object:Gem::Version
93
87
  version: 1.1.5
88
+ - !ruby/object:Gem::Dependency
89
+ name: eventmachine
90
+ requirement: !ruby/object:Gem::Requirement
91
+ requirements:
92
+ - - "~>"
93
+ - !ruby/object:Gem::Version
94
+ version: '1.2'
95
+ type: :runtime
96
+ prerelease: false
97
+ version_requirements: !ruby/object:Gem::Requirement
98
+ requirements:
99
+ - - "~>"
100
+ - !ruby/object:Gem::Version
101
+ version: '1.2'
102
+ - !ruby/object:Gem::Dependency
103
+ name: rspec
104
+ requirement: !ruby/object:Gem::Requirement
105
+ requirements:
106
+ - - "~>"
107
+ - !ruby/object:Gem::Version
108
+ version: '3'
109
+ type: :development
110
+ prerelease: false
111
+ version_requirements: !ruby/object:Gem::Requirement
112
+ requirements:
113
+ - - "~>"
114
+ - !ruby/object:Gem::Version
115
+ version: '3'
116
+ - !ruby/object:Gem::Dependency
117
+ name: yard
118
+ requirement: !ruby/object:Gem::Requirement
119
+ requirements:
120
+ - - "~>"
121
+ - !ruby/object:Gem::Version
122
+ version: '0.9'
123
+ type: :development
124
+ prerelease: false
125
+ version_requirements: !ruby/object:Gem::Requirement
126
+ requirements:
127
+ - - "~>"
128
+ - !ruby/object:Gem::Version
129
+ version: '0.9'
130
+ - !ruby/object:Gem::Dependency
131
+ name: redcarpet
132
+ requirement: !ruby/object:Gem::Requirement
133
+ requirements:
134
+ - - "~>"
135
+ - !ruby/object:Gem::Version
136
+ version: '3.6'
137
+ type: :development
138
+ prerelease: false
139
+ version_requirements: !ruby/object:Gem::Requirement
140
+ requirements:
141
+ - - "~>"
142
+ - !ruby/object:Gem::Version
143
+ version: '3.6'
144
+ - !ruby/object:Gem::Dependency
145
+ name: webrick
146
+ requirement: !ruby/object:Gem::Requirement
147
+ requirements:
148
+ - - "~>"
149
+ - !ruby/object:Gem::Version
150
+ version: '1.8'
151
+ type: :development
152
+ prerelease: false
153
+ version_requirements: !ruby/object:Gem::Requirement
154
+ requirements:
155
+ - - "~>"
156
+ - !ruby/object:Gem::Version
157
+ version: '1.8'
94
158
  description: This package implements the Server part of the Musa DSL Live Coding Environment
95
159
  for Ableton Live and Bitwig Studio
96
160
  email: javier.sy@gmail.com
@@ -99,11 +163,15 @@ executables:
99
163
  extensions: []
100
164
  extra_rdoc_files: []
101
165
  files:
166
+ - ".github/workflows/notify-plugin.yml"
102
167
  - ".gitignore"
168
+ - ".version"
169
+ - ".yardopts"
103
170
  - Gemfile
104
171
  - LICENSE
105
172
  - README.md
106
173
  - bin/musalce-server
174
+ - docs/architecture.md
107
175
  - lib/bitwig/bitwig.rb
108
176
  - lib/bitwig/controllers.rb
109
177
  - lib/bitwig/handler.rb
@@ -114,12 +182,17 @@ files:
114
182
  - lib/live/tracks.rb
115
183
  - lib/midi-devices.rb
116
184
  - lib/musalce-server.rb
185
+ - lib/surface-bridge.rb
186
+ - lib/surface.rb
117
187
  - lib/version.rb
118
188
  - musalce-server.gemspec
119
189
  homepage: https://github.com/javier-sy/musalce-server
120
190
  licenses:
121
191
  - GPL-3.0-or-later
122
- metadata: {}
192
+ metadata:
193
+ homepage_uri: https://github.com/javier-sy/musalce-server
194
+ source_code_uri: https://github.com/javier-sy/musalce-server
195
+ documentation_uri: https://www.rubydoc.info/gems/musalce-server
123
196
  rdoc_options: []
124
197
  require_paths:
125
198
  - lib