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.
Files changed (78) hide show
  1. checksums.yaml +4 -4
  2. data/frontend/index.html +24 -2
  3. data/frontend/src/audio-inspector.js +9 -0
  4. data/frontend/src/live-controls.js +219 -7
  5. data/frontend/src/main.js +447 -57
  6. data/frontend/src/midi-learn.js +22 -2
  7. data/frontend/src/performance-monitor.js +137 -1
  8. data/frontend/src/renderer/engine.js +391 -10
  9. data/frontend/src/renderer/layer-manager.js +472 -71
  10. data/frontend/src/runtime-control-preset.js +44 -0
  11. data/frontend/src/scene-patches.js +159 -0
  12. data/frontend/src/shader-error-overlay.js +1 -0
  13. data/frontend/src/visuals/image-renderer.js +19 -0
  14. data/frontend/src/visuals/particle-system.js +10 -0
  15. data/frontend/src/visuals/shape-renderer.js +13 -0
  16. data/frontend/src/visuals/spectrogram-renderer.js +14 -0
  17. data/frontend/src/visuals/text-renderer.js +13 -0
  18. data/frontend/src/websocket-client.js +6 -0
  19. data/lib/vizcore/analysis/adaptive_normalizer.rb +20 -2
  20. data/lib/vizcore/analysis/bpm_estimator.rb +18 -8
  21. data/lib/vizcore/analysis/feature_recorder.rb +117 -7
  22. data/lib/vizcore/analysis/feature_replay.rb +48 -9
  23. data/lib/vizcore/analysis/pipeline.rb +258 -9
  24. data/lib/vizcore/analysis/tap_tempo.rb +17 -2
  25. data/lib/vizcore/audio/calibration.rb +156 -0
  26. data/lib/vizcore/audio/file_input.rb +28 -0
  27. data/lib/vizcore/audio/input_manager.rb +36 -1
  28. data/lib/vizcore/audio/midi_input.rb +5 -0
  29. data/lib/vizcore/audio/ring_buffer.rb +22 -0
  30. data/lib/vizcore/audio.rb +1 -0
  31. data/lib/vizcore/cli/dsl_reference.rb +64 -8
  32. data/lib/vizcore/cli/plugin_checker.rb +93 -0
  33. data/lib/vizcore/cli/scene_diagnostics.rb +2 -2
  34. data/lib/vizcore/cli/scene_inspector.rb +35 -1
  35. data/lib/vizcore/cli/scene_validator.rb +487 -39
  36. data/lib/vizcore/cli/shader_template.rb +7 -2
  37. data/lib/vizcore/cli/shader_uniform_docs.rb +11 -0
  38. data/lib/vizcore/cli.rb +268 -15
  39. data/lib/vizcore/config.rb +40 -3
  40. data/lib/vizcore/control_preset.rb +29 -0
  41. data/lib/vizcore/deep_copy.rb +21 -0
  42. data/lib/vizcore/dsl/color_helpers.rb +155 -0
  43. data/lib/vizcore/dsl/engine.rb +219 -23
  44. data/lib/vizcore/dsl/layer_builder.rb +278 -15
  45. data/lib/vizcore/dsl/layer_group_builder.rb +10 -12
  46. data/lib/vizcore/dsl/layout_helpers.rb +290 -0
  47. data/lib/vizcore/dsl/mapping_preset_builder.rb +41 -0
  48. data/lib/vizcore/dsl/mapping_resolver.rb +404 -22
  49. data/lib/vizcore/dsl/mapping_transform_builder.rb +50 -0
  50. data/lib/vizcore/dsl/midi_map_executor.rb +219 -23
  51. data/lib/vizcore/dsl/reaction_builder.rb +1 -0
  52. data/lib/vizcore/dsl/scene_builder.rb +83 -13
  53. data/lib/vizcore/dsl/shader_source_resolver.rb +1 -10
  54. data/lib/vizcore/dsl/style_builder.rb +3 -0
  55. data/lib/vizcore/dsl/timeline_builder.rb +91 -8
  56. data/lib/vizcore/dsl/transition_controller.rb +157 -18
  57. data/lib/vizcore/dsl.rb +2 -0
  58. data/lib/vizcore/layer_catalog.rb +1 -0
  59. data/lib/vizcore/plugin_asset_policy.rb +55 -0
  60. data/lib/vizcore/project_manifest.rb +12 -2
  61. data/lib/vizcore/renderer/render_sequence.rb +104 -13
  62. data/lib/vizcore/renderer/scene_frame_source.rb +179 -14
  63. data/lib/vizcore/renderer/scene_serializer.rb +38 -0
  64. data/lib/vizcore/renderer/snapshot.rb +4 -3
  65. data/lib/vizcore/renderer/snapshot_renderer.rb +134 -8
  66. data/lib/vizcore/scene_trust.rb +31 -0
  67. data/lib/vizcore/server/frame_broadcaster.rb +469 -23
  68. data/lib/vizcore/server/rack_app.rb +151 -4
  69. data/lib/vizcore/server/runner.rb +676 -82
  70. data/lib/vizcore/server/websocket_handler.rb +236 -14
  71. data/lib/vizcore/server.rb +21 -0
  72. data/lib/vizcore/shape.rb +39 -16
  73. data/lib/vizcore/sync/osc_message.rb +66 -9
  74. data/lib/vizcore/version.rb +1 -1
  75. data/lib/vizcore.rb +33 -0
  76. data/scripts/browser_capture.mjs +31 -2
  77. data/sig/vizcore.rbs +55 -4
  78. 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 if drop_for_backpressure?(socket, type)
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
- return false unless buffered_amount && buffered_amount > MAX_BUFFERED_FRAME_BYTES
163
+ begin
164
+ buffered_amount = Integer(buffered_amount) if buffered_amount
165
+ rescue ArgumentError, TypeError
166
+ buffered_amount = nil
167
+ end
129
168
 
130
- increment_dropped_frame_count
131
- true
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 { sockets << socket }
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 { sockets.delete(socket) }
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
- mutex.synchronize { @dropped_frame_count = (@dropped_frame_count || 0) + 1 }
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
@@ -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
- case value
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
- case value
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
- address = parser.read_string
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
- def initialize(address:, arguments: [])
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
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Vizcore
4
4
  # Current gem version.
5
- VERSION = "1.1.0"
5
+ VERSION = "1.2.0"
6
6
  end
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