vizcore 1.1.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/frontend/index.html +24 -2
- data/frontend/src/audio-inspector.js +9 -0
- data/frontend/src/live-controls.js +219 -7
- data/frontend/src/main.js +447 -57
- data/frontend/src/midi-learn.js +22 -2
- data/frontend/src/performance-monitor.js +137 -1
- data/frontend/src/renderer/engine.js +391 -10
- data/frontend/src/renderer/layer-manager.js +472 -71
- data/frontend/src/runtime-control-preset.js +44 -0
- data/frontend/src/scene-patches.js +159 -0
- data/frontend/src/shader-error-overlay.js +1 -0
- data/frontend/src/visuals/image-renderer.js +19 -0
- data/frontend/src/visuals/particle-system.js +10 -0
- data/frontend/src/visuals/shape-renderer.js +13 -0
- data/frontend/src/visuals/spectrogram-renderer.js +14 -0
- data/frontend/src/visuals/text-renderer.js +13 -0
- data/frontend/src/websocket-client.js +6 -0
- data/lib/vizcore/analysis/adaptive_normalizer.rb +20 -2
- data/lib/vizcore/analysis/bpm_estimator.rb +18 -8
- data/lib/vizcore/analysis/feature_recorder.rb +117 -7
- data/lib/vizcore/analysis/feature_replay.rb +48 -9
- data/lib/vizcore/analysis/pipeline.rb +258 -9
- data/lib/vizcore/analysis/tap_tempo.rb +17 -2
- data/lib/vizcore/audio/calibration.rb +156 -0
- data/lib/vizcore/audio/file_input.rb +28 -0
- data/lib/vizcore/audio/input_manager.rb +36 -1
- data/lib/vizcore/audio/midi_input.rb +5 -0
- data/lib/vizcore/audio/ring_buffer.rb +22 -0
- data/lib/vizcore/audio.rb +1 -0
- data/lib/vizcore/cli/dsl_reference.rb +64 -8
- data/lib/vizcore/cli/plugin_checker.rb +93 -0
- data/lib/vizcore/cli/scene_diagnostics.rb +2 -2
- data/lib/vizcore/cli/scene_inspector.rb +35 -1
- data/lib/vizcore/cli/scene_validator.rb +487 -39
- data/lib/vizcore/cli/shader_template.rb +7 -2
- data/lib/vizcore/cli/shader_uniform_docs.rb +11 -0
- data/lib/vizcore/cli.rb +268 -15
- data/lib/vizcore/config.rb +40 -3
- data/lib/vizcore/control_preset.rb +29 -0
- data/lib/vizcore/deep_copy.rb +21 -0
- data/lib/vizcore/dsl/color_helpers.rb +155 -0
- data/lib/vizcore/dsl/engine.rb +219 -23
- data/lib/vizcore/dsl/layer_builder.rb +278 -15
- data/lib/vizcore/dsl/layer_group_builder.rb +10 -12
- data/lib/vizcore/dsl/layout_helpers.rb +290 -0
- data/lib/vizcore/dsl/mapping_preset_builder.rb +41 -0
- data/lib/vizcore/dsl/mapping_resolver.rb +404 -22
- data/lib/vizcore/dsl/mapping_transform_builder.rb +50 -0
- data/lib/vizcore/dsl/midi_map_executor.rb +219 -23
- data/lib/vizcore/dsl/reaction_builder.rb +1 -0
- data/lib/vizcore/dsl/scene_builder.rb +83 -13
- data/lib/vizcore/dsl/shader_source_resolver.rb +1 -10
- data/lib/vizcore/dsl/style_builder.rb +3 -0
- data/lib/vizcore/dsl/timeline_builder.rb +91 -8
- data/lib/vizcore/dsl/transition_controller.rb +157 -18
- data/lib/vizcore/dsl.rb +2 -0
- data/lib/vizcore/layer_catalog.rb +1 -0
- data/lib/vizcore/plugin_asset_policy.rb +55 -0
- data/lib/vizcore/project_manifest.rb +12 -2
- data/lib/vizcore/renderer/render_sequence.rb +104 -13
- data/lib/vizcore/renderer/scene_frame_source.rb +179 -14
- data/lib/vizcore/renderer/scene_serializer.rb +38 -0
- data/lib/vizcore/renderer/snapshot.rb +4 -3
- data/lib/vizcore/renderer/snapshot_renderer.rb +134 -8
- data/lib/vizcore/scene_trust.rb +31 -0
- data/lib/vizcore/server/frame_broadcaster.rb +469 -23
- data/lib/vizcore/server/rack_app.rb +151 -4
- data/lib/vizcore/server/runner.rb +676 -82
- data/lib/vizcore/server/websocket_handler.rb +236 -14
- data/lib/vizcore/server.rb +21 -0
- data/lib/vizcore/shape.rb +39 -16
- data/lib/vizcore/sync/osc_message.rb +66 -9
- data/lib/vizcore/version.rb +1 -1
- data/lib/vizcore.rb +33 -0
- data/scripts/browser_capture.mjs +31 -2
- data/sig/vizcore.rbs +55 -4
- metadata +18 -3
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "json"
|
|
4
4
|
require "set"
|
|
5
5
|
require "thread"
|
|
6
|
+
require "uri"
|
|
6
7
|
require_relative "../errors"
|
|
7
8
|
|
|
8
9
|
module Vizcore
|
|
@@ -12,6 +13,14 @@ module Vizcore
|
|
|
12
13
|
PROTOCOL_VERSION = "vizcore.frame.v1"
|
|
13
14
|
MAX_BUFFERED_FRAME_BYTES = 1_000_000
|
|
14
15
|
DROPPABLE_MESSAGE_TYPES = Set["audio_frame"].freeze
|
|
16
|
+
VALID_CLIENT_ROLES = Set["projector", "control", "monitor"].freeze
|
|
17
|
+
CONTROL_ROLE = "control".freeze
|
|
18
|
+
PROJECTOR_ROLE = "projector".freeze
|
|
19
|
+
MONITOR_ROLE = "monitor".freeze
|
|
20
|
+
READ_ONLY_ROLES = Set[PROJECTOR_ROLE, MONITOR_ROLE].freeze
|
|
21
|
+
READ_ONLY_ALLOWED_MESSAGE_TYPES = Set["latency_probe", "client_runtime_error"].freeze
|
|
22
|
+
LOW_BANDWIDTH_ROLES = Set[CONTROL_ROLE, MONITOR_ROLE].freeze
|
|
23
|
+
CONTROL_AUDIO_FRAME_INTERVAL = 4
|
|
15
24
|
|
|
16
25
|
class << self
|
|
17
26
|
# Rack endpoint for WebSocket upgrade handling.
|
|
@@ -25,7 +34,7 @@ module Vizcore
|
|
|
25
34
|
|
|
26
35
|
socket = websocket_klass.new(env, nil, ping: 15)
|
|
27
36
|
|
|
28
|
-
socket.on(:open) { register(socket) }
|
|
37
|
+
socket.on(:open) { register(socket, role: websocket_role_for_env(env)) }
|
|
29
38
|
socket.on(:close) { unregister(socket) }
|
|
30
39
|
socket.on(:message) { |event| handle_message(socket, event.data) }
|
|
31
40
|
|
|
@@ -78,6 +87,28 @@ module Vizcore
|
|
|
78
87
|
mutex.synchronize { @dropped_frame_count || 0 }
|
|
79
88
|
end
|
|
80
89
|
|
|
90
|
+
# @return [Hash] current websocket backpressure metrics for control/status surfaces.
|
|
91
|
+
def backpressure_status
|
|
92
|
+
mutex.synchronize do
|
|
93
|
+
clients = sockets.map { |socket| backpressure_client_status(socket) }
|
|
94
|
+
{
|
|
95
|
+
threshold_bytes: MAX_BUFFERED_FRAME_BYTES,
|
|
96
|
+
active_clients: sockets.size,
|
|
97
|
+
total: {
|
|
98
|
+
dropped_frames: socket_backpressure_totals[:dropped_frames],
|
|
99
|
+
dropped_payload_bytes: socket_backpressure_totals[:dropped_payload_bytes],
|
|
100
|
+
sent_frames: socket_backpressure_totals[:sent_frames],
|
|
101
|
+
sent_payload_bytes: socket_backpressure_totals[:sent_payload_bytes],
|
|
102
|
+
avg_payload_bytes: average(
|
|
103
|
+
socket_backpressure_totals[:sent_payload_bytes],
|
|
104
|
+
socket_backpressure_totals[:sent_frames]
|
|
105
|
+
)
|
|
106
|
+
},
|
|
107
|
+
clients: clients
|
|
108
|
+
}
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
81
112
|
# Register one inbound message handler for client -> server control messages.
|
|
82
113
|
#
|
|
83
114
|
# @yieldparam message [Hash]
|
|
@@ -103,32 +134,52 @@ module Vizcore
|
|
|
103
134
|
end
|
|
104
135
|
|
|
105
136
|
def send_message(socket, message, type:)
|
|
106
|
-
return
|
|
137
|
+
return unless should_send_to_socket?(socket, type: type)
|
|
138
|
+
|
|
139
|
+
message_bytes = message.bytesize
|
|
140
|
+
return if drop_for_backpressure?(socket, type, payload_bytes: message_bytes)
|
|
107
141
|
|
|
108
142
|
if event_machine_reactor_running?
|
|
109
|
-
EventMachine.schedule { safe_send(socket, message, type: type) }
|
|
143
|
+
EventMachine.schedule { safe_send(socket, message, type: type, payload_bytes: message_bytes) }
|
|
110
144
|
else
|
|
111
|
-
safe_send(socket, message, type: type)
|
|
145
|
+
safe_send(socket, message, type: type, payload_bytes: message_bytes)
|
|
112
146
|
end
|
|
113
147
|
end
|
|
114
148
|
|
|
115
|
-
def safe_send(socket, message, type:)
|
|
116
|
-
return if drop_for_backpressure?(socket, type)
|
|
149
|
+
def safe_send(socket, message, type:, payload_bytes:)
|
|
150
|
+
return if drop_for_backpressure?(socket, type, payload_bytes: payload_bytes)
|
|
117
151
|
|
|
118
152
|
socket.send(message)
|
|
153
|
+
record_message_sent(socket, payload_bytes)
|
|
119
154
|
rescue StandardError => e
|
|
120
155
|
set_last_error(e)
|
|
121
156
|
unregister(socket)
|
|
122
157
|
end
|
|
123
158
|
|
|
124
|
-
def drop_for_backpressure?(socket, type)
|
|
159
|
+
def drop_for_backpressure?(socket, type, payload_bytes: nil)
|
|
125
160
|
return false unless DROPPABLE_MESSAGE_TYPES.include?(type.to_s)
|
|
126
161
|
|
|
127
162
|
buffered_amount = socket_buffered_amount(socket)
|
|
128
|
-
|
|
163
|
+
begin
|
|
164
|
+
buffered_amount = Integer(buffered_amount) if buffered_amount
|
|
165
|
+
rescue ArgumentError, TypeError
|
|
166
|
+
buffered_amount = nil
|
|
167
|
+
end
|
|
129
168
|
|
|
130
|
-
|
|
131
|
-
|
|
169
|
+
if buffered_amount && buffered_amount > MAX_BUFFERED_FRAME_BYTES
|
|
170
|
+
mutex.synchronize do
|
|
171
|
+
refresh_socket_backpressure_metrics(socket, buffered_amount: buffered_amount)
|
|
172
|
+
increment_dropped_frame_count
|
|
173
|
+
increment_client_drop(socket, payload_bytes: payload_bytes)
|
|
174
|
+
end
|
|
175
|
+
return true
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
if buffered_amount
|
|
179
|
+
mutex.synchronize { refresh_socket_backpressure_metrics(socket, buffered_amount: buffered_amount) }
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
false
|
|
132
183
|
end
|
|
133
184
|
|
|
134
185
|
def socket_buffered_amount(socket)
|
|
@@ -160,12 +211,21 @@ module Vizcore
|
|
|
160
211
|
nil
|
|
161
212
|
end
|
|
162
213
|
|
|
163
|
-
def register(socket)
|
|
164
|
-
mutex.synchronize
|
|
214
|
+
def register(socket, role: PROJECTOR_ROLE)
|
|
215
|
+
mutex.synchronize do
|
|
216
|
+
sockets << socket
|
|
217
|
+
socket_backpressure_metrics[socket_id(socket)] = default_backpressure_metrics
|
|
218
|
+
client_backpressure_metrics(socket)[:role] = normalize_client_role(role)
|
|
219
|
+
client_backpressure_metrics(socket)[:control_audio_frame_index] = 0
|
|
220
|
+
socket_backpressure_metrics[socket_id(socket)] = client_backpressure_metrics(socket)
|
|
221
|
+
end
|
|
165
222
|
end
|
|
166
223
|
|
|
167
224
|
def unregister(socket)
|
|
168
|
-
mutex.synchronize
|
|
225
|
+
mutex.synchronize do
|
|
226
|
+
sockets.delete(socket)
|
|
227
|
+
socket_backpressure_metrics.delete(socket_id(socket))
|
|
228
|
+
end
|
|
169
229
|
end
|
|
170
230
|
|
|
171
231
|
def each_socket(&block)
|
|
@@ -177,6 +237,158 @@ module Vizcore
|
|
|
177
237
|
@sockets ||= Set.new
|
|
178
238
|
end
|
|
179
239
|
|
|
240
|
+
def socket_backpressure_metrics
|
|
241
|
+
@socket_backpressure_metrics ||= {}
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def socket_backpressure_totals
|
|
245
|
+
@socket_backpressure_totals ||= {
|
|
246
|
+
dropped_frames: 0,
|
|
247
|
+
dropped_payload_bytes: 0,
|
|
248
|
+
sent_frames: 0,
|
|
249
|
+
sent_payload_bytes: 0
|
|
250
|
+
}
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def backpressure_client_status(socket)
|
|
254
|
+
metrics = client_backpressure_metrics(socket)
|
|
255
|
+
{
|
|
256
|
+
id: socket_id(socket).to_s,
|
|
257
|
+
role: metrics[:role],
|
|
258
|
+
buffered_amount: metrics[:buffered_amount],
|
|
259
|
+
peak_buffered_amount: metrics[:peak_buffered_amount],
|
|
260
|
+
dropped_frames: metrics[:dropped_frames],
|
|
261
|
+
dropped_payload_bytes: metrics[:dropped_payload_bytes],
|
|
262
|
+
sent_frames: metrics[:sent_frames],
|
|
263
|
+
sent_payload_bytes: metrics[:sent_payload_bytes],
|
|
264
|
+
avg_payload_bytes: average(metrics[:sent_payload_bytes], metrics[:sent_frames]),
|
|
265
|
+
estimated_lag_frames: estimated_lag_frames(
|
|
266
|
+
metrics[:buffered_amount],
|
|
267
|
+
metrics[:sent_payload_bytes],
|
|
268
|
+
metrics[:sent_frames]
|
|
269
|
+
),
|
|
270
|
+
last_payload_bytes: metrics[:last_payload_bytes]
|
|
271
|
+
}
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def socket_id(socket)
|
|
275
|
+
socket.object_id
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def client_backpressure_metrics(socket)
|
|
279
|
+
socket_backpressure_metrics.fetch(socket_id(socket)) do
|
|
280
|
+
socket_backpressure_metrics[socket_id(socket)] = default_backpressure_metrics
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def socket_role(socket)
|
|
285
|
+
client_backpressure_metrics(socket)[:role]
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def should_send_to_socket?(socket, type:)
|
|
289
|
+
return true unless LOW_BANDWIDTH_ROLES.include?(socket_role(socket))
|
|
290
|
+
return true unless type.to_s == "audio_frame"
|
|
291
|
+
|
|
292
|
+
control_audio_frame_due?(socket)
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def control_audio_frame_due?(socket)
|
|
296
|
+
metrics = client_backpressure_metrics(socket)
|
|
297
|
+
metrics[:control_audio_frame_index] = (metrics[:control_audio_frame_index] || 0) + 1
|
|
298
|
+
count = metrics[:control_audio_frame_index]
|
|
299
|
+
|
|
300
|
+
return true if count == 1
|
|
301
|
+
return true if (count % CONTROL_AUDIO_FRAME_INTERVAL).zero?
|
|
302
|
+
|
|
303
|
+
false
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def refresh_socket_backpressure_metrics(socket, buffered_amount: nil)
|
|
307
|
+
metrics = client_backpressure_metrics(socket)
|
|
308
|
+
amount = buffered_amount.nil? ? socket_buffered_amount(socket) : buffered_amount
|
|
309
|
+
return metrics unless amount
|
|
310
|
+
|
|
311
|
+
integer_amount = amount.to_i
|
|
312
|
+
metrics[:buffered_amount] = integer_amount
|
|
313
|
+
metrics[:peak_buffered_amount] = [metrics[:peak_buffered_amount], integer_amount].max
|
|
314
|
+
metrics
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
def increment_client_drop(socket, payload_bytes: nil)
|
|
318
|
+
payload_bytes = Integer(payload_bytes || 0)
|
|
319
|
+
metrics = client_backpressure_metrics(socket)
|
|
320
|
+
metrics[:dropped_frames] += 1
|
|
321
|
+
metrics[:dropped_payload_bytes] += payload_bytes
|
|
322
|
+
socket_backpressure_totals[:dropped_frames] += 1
|
|
323
|
+
socket_backpressure_totals[:dropped_payload_bytes] += payload_bytes
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def record_message_sent(socket, payload_bytes)
|
|
327
|
+
payload_bytes = Integer(payload_bytes || 0)
|
|
328
|
+
buffered_amount = socket_buffered_amount(socket)
|
|
329
|
+
buffered_amount = Integer(buffered_amount) if buffered_amount
|
|
330
|
+
|
|
331
|
+
mutex.synchronize do
|
|
332
|
+
metrics = client_backpressure_metrics(socket)
|
|
333
|
+
metrics[:sent_frames] += 1
|
|
334
|
+
metrics[:sent_payload_bytes] += payload_bytes
|
|
335
|
+
metrics[:last_payload_bytes] = payload_bytes
|
|
336
|
+
socket_backpressure_totals[:sent_frames] += 1
|
|
337
|
+
socket_backpressure_totals[:sent_payload_bytes] += payload_bytes
|
|
338
|
+
refresh_socket_backpressure_metrics(socket, buffered_amount: buffered_amount) if buffered_amount
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def average(numerator, denominator)
|
|
343
|
+
return 0.0 if denominator.to_i <= 0
|
|
344
|
+
|
|
345
|
+
numerator.to_f / denominator.to_f
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
def estimated_lag_frames(buffered_amount, payload_bytes, sent_frames)
|
|
349
|
+
avg_payload_bytes = average(payload_bytes, sent_frames)
|
|
350
|
+
return 0.0 if avg_payload_bytes <= 0.0
|
|
351
|
+
|
|
352
|
+
buffered_amount.to_f / avg_payload_bytes
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
def default_backpressure_metrics
|
|
356
|
+
{
|
|
357
|
+
role: PROJECTOR_ROLE,
|
|
358
|
+
control_audio_frame_index: 0,
|
|
359
|
+
buffered_amount: 0,
|
|
360
|
+
peak_buffered_amount: 0,
|
|
361
|
+
dropped_frames: 0,
|
|
362
|
+
dropped_payload_bytes: 0,
|
|
363
|
+
sent_frames: 0,
|
|
364
|
+
sent_payload_bytes: 0,
|
|
365
|
+
last_payload_bytes: 0
|
|
366
|
+
}
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
def websocket_role_for_env(env)
|
|
370
|
+
return PROJECTOR_ROLE unless env.is_a?(Hash)
|
|
371
|
+
|
|
372
|
+
role = query_param(String(env["QUERY_STRING"] || ""), "role")
|
|
373
|
+
normalize_client_role(role)
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
def query_param(query_string, key)
|
|
377
|
+
URI.decode_www_form(query_string).each do |entry_key, value|
|
|
378
|
+
return value if entry_key == key
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
nil
|
|
382
|
+
rescue StandardError
|
|
383
|
+
nil
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
def normalize_client_role(role)
|
|
387
|
+
return PROJECTOR_ROLE unless VALID_CLIENT_ROLES.include?(role.to_s)
|
|
388
|
+
|
|
389
|
+
role.to_s
|
|
390
|
+
end
|
|
391
|
+
|
|
180
392
|
def mutex
|
|
181
393
|
@mutex ||= Mutex.new
|
|
182
394
|
end
|
|
@@ -186,7 +398,7 @@ module Vizcore
|
|
|
186
398
|
end
|
|
187
399
|
|
|
188
400
|
def increment_dropped_frame_count
|
|
189
|
-
|
|
401
|
+
@dropped_frame_count = (@dropped_frame_count || 0) + 1
|
|
190
402
|
end
|
|
191
403
|
|
|
192
404
|
def dispatch_message(message, socket)
|
|
@@ -194,12 +406,22 @@ module Vizcore
|
|
|
194
406
|
return unless handler
|
|
195
407
|
return unless message.is_a?(Hash)
|
|
196
408
|
|
|
409
|
+
type = message["type"] || message[:type]
|
|
410
|
+
role = socket_role(socket)
|
|
411
|
+
return unless client_message_allowed?(role: role, type: type)
|
|
412
|
+
|
|
197
413
|
handler.call(message, socket)
|
|
198
414
|
rescue StandardError => e
|
|
199
415
|
set_last_error(e)
|
|
200
416
|
nil
|
|
201
417
|
end
|
|
202
418
|
|
|
419
|
+
def client_message_allowed?(role:, type:)
|
|
420
|
+
return true unless READ_ONLY_ROLES.include?(role.to_s)
|
|
421
|
+
|
|
422
|
+
READ_ONLY_ALLOWED_MESSAGE_TYPES.include?(type.to_s)
|
|
423
|
+
end
|
|
424
|
+
|
|
203
425
|
def text_headers
|
|
204
426
|
{ "content-type" => "text/plain; charset=utf-8" }
|
|
205
427
|
end
|
data/lib/vizcore/server.rb
CHANGED
|
@@ -1,8 +1,29 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "config"
|
|
4
|
+
|
|
3
5
|
module Vizcore
|
|
4
6
|
# Rack/WebSocket server runtime namespace.
|
|
5
7
|
module Server
|
|
8
|
+
# Start a Vizcore server from Ruby code.
|
|
9
|
+
#
|
|
10
|
+
# @param config [Vizcore::Config, Hash, nil] runtime config or Config keyword options
|
|
11
|
+
# @param output [#puts] stream used by the runner
|
|
12
|
+
# @param options [Hash] Config keyword options when `config` is nil
|
|
13
|
+
# @return [void]
|
|
14
|
+
def self.start(config = nil, output: $stdout, **options)
|
|
15
|
+
runtime_config = start_config(config, options)
|
|
16
|
+
Runner.new(runtime_config, output: output).run
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.start_config(config, options)
|
|
20
|
+
return Config.new(**options) if config.nil?
|
|
21
|
+
return config if config.is_a?(Config) && options.empty?
|
|
22
|
+
return Config.new(**config.merge(options)) if config.is_a?(Hash)
|
|
23
|
+
|
|
24
|
+
raise ArgumentError, "server start requires a Vizcore::Config or config options"
|
|
25
|
+
end
|
|
26
|
+
private_class_method :start_config
|
|
6
27
|
end
|
|
7
28
|
end
|
|
8
29
|
|
data/lib/vizcore/shape.rb
CHANGED
|
@@ -110,14 +110,7 @@ module Vizcore
|
|
|
110
110
|
end
|
|
111
111
|
|
|
112
112
|
def deep_dup(value)
|
|
113
|
-
|
|
114
|
-
when Hash
|
|
115
|
-
value.each_with_object({}) { |(key, entry), output| output[key] = deep_dup(entry) }
|
|
116
|
-
when Array
|
|
117
|
-
value.map { |entry| deep_dup(entry) }
|
|
118
|
-
else
|
|
119
|
-
value
|
|
120
|
-
end
|
|
113
|
+
Vizcore::DeepCopy.copy(value)
|
|
121
114
|
end
|
|
122
115
|
|
|
123
116
|
def shape_definition(renderer)
|
|
@@ -329,14 +322,26 @@ module Vizcore
|
|
|
329
322
|
numeric(band(:low))
|
|
330
323
|
end
|
|
331
324
|
|
|
325
|
+
def bass_peak
|
|
326
|
+
numeric(band_peak(:low))
|
|
327
|
+
end
|
|
328
|
+
|
|
332
329
|
def mid
|
|
333
330
|
numeric(band(:mid))
|
|
334
331
|
end
|
|
335
332
|
|
|
333
|
+
def mid_peak
|
|
334
|
+
numeric(band_peak(:mid))
|
|
335
|
+
end
|
|
336
|
+
|
|
336
337
|
def high
|
|
337
338
|
numeric(band(:high))
|
|
338
339
|
end
|
|
339
340
|
|
|
341
|
+
def high_peak
|
|
342
|
+
numeric(band_peak(:high))
|
|
343
|
+
end
|
|
344
|
+
|
|
340
345
|
def fft
|
|
341
346
|
Array(@payload[:fft])
|
|
342
347
|
end
|
|
@@ -349,6 +354,26 @@ module Vizcore
|
|
|
349
354
|
numeric(@payload[:beat_pulse])
|
|
350
355
|
end
|
|
351
356
|
|
|
357
|
+
def beat_phase
|
|
358
|
+
numeric(@payload[:beat_phase])
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
def bar_phase
|
|
362
|
+
numeric(@payload[:bar_phase])
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
def bar_count
|
|
366
|
+
Integer(@payload[:bar_count] || 0)
|
|
367
|
+
rescue ArgumentError, TypeError
|
|
368
|
+
0
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
def phrase_count
|
|
372
|
+
Integer(@payload[:phrase_count] || 0)
|
|
373
|
+
rescue ArgumentError, TypeError
|
|
374
|
+
0
|
|
375
|
+
end
|
|
376
|
+
|
|
352
377
|
def kick
|
|
353
378
|
numeric(drum(:kick))
|
|
354
379
|
end
|
|
@@ -372,6 +397,11 @@ module Vizcore
|
|
|
372
397
|
bands[name]
|
|
373
398
|
end
|
|
374
399
|
|
|
400
|
+
def band_peak(name)
|
|
401
|
+
peaks = symbolize_hash(@payload[:band_peaks])
|
|
402
|
+
peaks[name]
|
|
403
|
+
end
|
|
404
|
+
|
|
375
405
|
def drum(name)
|
|
376
406
|
drums = symbolize_hash(@payload[:drums])
|
|
377
407
|
drums[name]
|
|
@@ -689,14 +719,7 @@ module Vizcore
|
|
|
689
719
|
end
|
|
690
720
|
|
|
691
721
|
def deep_dup(value)
|
|
692
|
-
|
|
693
|
-
when Hash
|
|
694
|
-
value.each_with_object({}) { |(key, entry), output| output[key] = deep_dup(entry) }
|
|
695
|
-
when Array
|
|
696
|
-
value.map { |entry| deep_dup(entry) }
|
|
697
|
-
else
|
|
698
|
-
value
|
|
699
|
-
end
|
|
722
|
+
Vizcore::DeepCopy.copy(value)
|
|
700
723
|
end
|
|
701
724
|
end
|
|
702
725
|
end
|
|
@@ -4,37 +4,46 @@ module Vizcore
|
|
|
4
4
|
module Sync
|
|
5
5
|
# Minimal OSC 1.0 message parser for control sync.
|
|
6
6
|
class OscMessage
|
|
7
|
-
attr_reader :address, :arguments
|
|
7
|
+
attr_reader :address, :arguments, :timetag
|
|
8
8
|
|
|
9
9
|
# @param data [String]
|
|
10
|
-
# @return [Vizcore::Sync::OscMessage, nil]
|
|
10
|
+
# @return [Vizcore::Sync::OscMessage, Array<Vizcore::Sync::OscMessage>, nil]
|
|
11
11
|
def self.parse(data)
|
|
12
12
|
parser = Parser.new(data)
|
|
13
|
-
|
|
14
|
-
return nil unless address&.start_with?("/")
|
|
15
|
-
|
|
16
|
-
tags = parser.read_string
|
|
17
|
-
arguments = parser.read_arguments(tags)
|
|
18
|
-
new(address: address, arguments: arguments)
|
|
13
|
+
parser.parse_packet
|
|
19
14
|
rescue StandardError
|
|
20
15
|
nil
|
|
21
16
|
end
|
|
22
17
|
|
|
23
18
|
# @param address [String]
|
|
24
19
|
# @param arguments [Array]
|
|
25
|
-
|
|
20
|
+
# @param timetag [Float, nil]
|
|
21
|
+
def initialize(address:, arguments: [], timetag: nil)
|
|
26
22
|
@address = address
|
|
27
23
|
@arguments = Array(arguments)
|
|
24
|
+
@timetag = timetag
|
|
28
25
|
end
|
|
29
26
|
|
|
30
27
|
# @api private
|
|
31
28
|
class Parser
|
|
29
|
+
BUNDLE_SIGNATURE = "#bundle"
|
|
30
|
+
NTP_TO_UNIX_OFFSET = 2_208_988_800
|
|
31
|
+
|
|
32
32
|
# @param data [String]
|
|
33
33
|
def initialize(data)
|
|
34
34
|
@data = data.to_s.b
|
|
35
35
|
@offset = 0
|
|
36
36
|
end
|
|
37
37
|
|
|
38
|
+
# @return [Vizcore::Sync::OscMessage, Array<Vizcore::Sync::OscMessage>, nil]
|
|
39
|
+
def parse_packet(default_timetag: nil)
|
|
40
|
+
signature_or_address = read_string
|
|
41
|
+
return nil unless signature_or_address
|
|
42
|
+
|
|
43
|
+
return parse_bundle if signature_or_address == BUNDLE_SIGNATURE
|
|
44
|
+
parse_message(signature_or_address, timetag: default_timetag)
|
|
45
|
+
end
|
|
46
|
+
|
|
38
47
|
# @return [String, nil]
|
|
39
48
|
def read_string
|
|
40
49
|
start = @offset
|
|
@@ -47,6 +56,42 @@ module Vizcore
|
|
|
47
56
|
value
|
|
48
57
|
end
|
|
49
58
|
|
|
59
|
+
def parse_bundle
|
|
60
|
+
bundle_timetag = parse_timetag(read_uint64)
|
|
61
|
+
messages = []
|
|
62
|
+
|
|
63
|
+
until @offset >= @data.bytesize
|
|
64
|
+
break if @offset + 4 > @data.bytesize
|
|
65
|
+
|
|
66
|
+
begin
|
|
67
|
+
message_size = read_int32
|
|
68
|
+
rescue StandardError
|
|
69
|
+
break
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
break if message_size <= 0
|
|
73
|
+
|
|
74
|
+
begin
|
|
75
|
+
message_data = read_bytes(message_size)
|
|
76
|
+
rescue StandardError
|
|
77
|
+
break
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
parsed = Parser.new(message_data).parse_packet(default_timetag: bundle_timetag)
|
|
81
|
+
messages.concat(Array(parsed))
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
messages
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def parse_message(address, timetag: nil)
|
|
88
|
+
return nil unless address&.start_with?("/")
|
|
89
|
+
|
|
90
|
+
tags = read_string
|
|
91
|
+
arguments = read_arguments(tags)
|
|
92
|
+
OscMessage.new(address: address, arguments: arguments, timetag: timetag)
|
|
93
|
+
end
|
|
94
|
+
|
|
50
95
|
# @param tags [String, nil]
|
|
51
96
|
# @return [Array]
|
|
52
97
|
def read_arguments(tags)
|
|
@@ -86,6 +131,10 @@ module Vizcore
|
|
|
86
131
|
read_bytes(4).unpack1("g")
|
|
87
132
|
end
|
|
88
133
|
|
|
134
|
+
def read_uint64
|
|
135
|
+
read_bytes(8).unpack1("Q>")
|
|
136
|
+
end
|
|
137
|
+
|
|
89
138
|
def read_bytes(length)
|
|
90
139
|
raise ArgumentError, "OSC payload truncated" if @offset + length > @data.bytesize
|
|
91
140
|
|
|
@@ -94,6 +143,14 @@ module Vizcore
|
|
|
94
143
|
end
|
|
95
144
|
end
|
|
96
145
|
|
|
146
|
+
def parse_timetag(raw)
|
|
147
|
+
return nil if raw == 0
|
|
148
|
+
|
|
149
|
+
seconds = (raw >> 32) - NTP_TO_UNIX_OFFSET
|
|
150
|
+
fraction = raw & 0xFFFF_FFFF
|
|
151
|
+
seconds + (fraction / 4_294_967_296.0)
|
|
152
|
+
end
|
|
153
|
+
|
|
97
154
|
def align_offset
|
|
98
155
|
@offset += 1 while (@offset % 4).positive?
|
|
99
156
|
end
|
data/lib/vizcore/version.rb
CHANGED
data/lib/vizcore.rb
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "vizcore/version"
|
|
4
|
+
require_relative "vizcore/deep_copy"
|
|
4
5
|
require_relative "vizcore/errors"
|
|
5
6
|
require_relative "vizcore/layer_catalog"
|
|
7
|
+
require_relative "vizcore/plugin_asset_policy"
|
|
8
|
+
require_relative "vizcore/scene_trust"
|
|
6
9
|
require_relative "vizcore/shape"
|
|
7
10
|
require_relative "vizcore/dsl"
|
|
11
|
+
require_relative "vizcore/analysis"
|
|
12
|
+
require_relative "vizcore/audio"
|
|
8
13
|
require "pathname"
|
|
9
14
|
|
|
10
15
|
# Main namespace for the Vizcore runtime and DSL entrypoints.
|
|
@@ -29,6 +34,19 @@ module Vizcore
|
|
|
29
34
|
root.join("lib", "vizcore", "templates")
|
|
30
35
|
end
|
|
31
36
|
|
|
37
|
+
# @param command_available [#call, nil] optional command lookup for tests
|
|
38
|
+
# @return [Hash<Symbol, Boolean>] optional runtime feature availability
|
|
39
|
+
def self.features(command_available: nil)
|
|
40
|
+
command_available ||= method(:command_available?)
|
|
41
|
+
{
|
|
42
|
+
mic: feature_available? { Audio::PortAudioFFI.available? },
|
|
43
|
+
midi: feature_available? { Audio::MidiInput.available? },
|
|
44
|
+
ffmpeg: command_available.call("ffmpeg"),
|
|
45
|
+
browser_capture: root.join("scripts", "browser_capture.mjs").file? && command_available.call("node"),
|
|
46
|
+
fftw: feature_available? { Analysis::FFTProcessor.fftw_available? }
|
|
47
|
+
}
|
|
48
|
+
end
|
|
49
|
+
|
|
32
50
|
# Evaluate a Vizcore DSL definition block.
|
|
33
51
|
#
|
|
34
52
|
# @yield DSL configuration block (`audio`, `scene`, `midi_map`, etc.)
|
|
@@ -62,4 +80,19 @@ module Vizcore
|
|
|
62
80
|
description: description
|
|
63
81
|
)
|
|
64
82
|
end
|
|
83
|
+
|
|
84
|
+
def self.feature_available?
|
|
85
|
+
!!yield
|
|
86
|
+
rescue StandardError
|
|
87
|
+
false
|
|
88
|
+
end
|
|
89
|
+
private_class_method :feature_available?
|
|
90
|
+
|
|
91
|
+
def self.command_available?(command)
|
|
92
|
+
ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).any? do |directory|
|
|
93
|
+
path = File.join(directory, command)
|
|
94
|
+
File.file?(path) && File.executable?(path)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
private_class_method :command_available?
|
|
65
98
|
end
|