musalce-server 0.5.1 → 0.8.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 +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 +317 -9
- data/docs/architecture.md +242 -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/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
|
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
|
-
|
|
13
|
+
# Current version of the musalce-server gem.
|
|
14
|
+
VERSION = '0.8.0'.freeze
|
|
3
15
|
end
|
data/musalce-server.gemspec
CHANGED
|
@@ -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 = '
|
|
6
|
+
s.date = '2026-05-16'
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
|
24
|
+
s.add_runtime_dependency 'musa-dsl', '~> 0.40'
|
|
27
25
|
|
|
28
|
-
s.add_runtime_dependency 'midi-communications', '~> 0.
|
|
29
|
-
s.add_runtime_dependency 'midi-events', '~> 0.
|
|
30
|
-
s.add_runtime_dependency 'midi-parser', '~> 0.
|
|
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
|