vizcore 0.1.0 → 1.0.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 (126) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +544 -9
  3. data/docs/.nojekyll +0 -0
  4. data/docs/assets/site.css +744 -0
  5. data/docs/assets/vizcore-demo.gif +0 -0
  6. data/docs/assets/vizcore-poster.png +0 -0
  7. data/docs/assets/vj-tunnel.js +159 -0
  8. data/docs/index.html +224 -0
  9. data/examples/README.md +59 -0
  10. data/examples/assets/README.md +19 -0
  11. data/examples/audio_inspector.rb +34 -0
  12. data/examples/club_intro_drop.rb +78 -0
  13. data/examples/kansai_rubykaigi_visual.rb +70 -0
  14. data/examples/live_coding_minimal.rb +22 -0
  15. data/examples/midi_controller_show.rb +78 -0
  16. data/examples/midi_scene_switch.rb +3 -1
  17. data/examples/parser_visualizer.rb +48 -0
  18. data/examples/readme_demo.rb +17 -0
  19. data/examples/rhythm_geometry.rb +34 -0
  20. data/examples/ruby_crystal_show.rb +35 -0
  21. data/examples/shader_playground.rb +18 -0
  22. data/examples/unyo_liquid.rb +59 -0
  23. data/examples/vj_ambient_chill_room.rb +124 -0
  24. data/examples/vj_dnb_jungle.rb +170 -0
  25. data/examples/vj_festival_mainstage.rb +245 -0
  26. data/examples/vj_festival_mainstage.yml +17 -0
  27. data/examples/vj_glitch_industrial.rb +164 -0
  28. data/examples/vj_hiphop_cipher.rb +167 -0
  29. data/examples/vj_jpop_idol_live.rb +210 -0
  30. data/examples/vj_synthwave_retro.rb +173 -0
  31. data/examples/vj_techno_warehouse.rb +195 -0
  32. data/frontend/index.html +468 -2
  33. data/frontend/src/audio-inspector.js +40 -0
  34. data/frontend/src/live-controls.js +131 -0
  35. data/frontend/src/main.js +792 -16
  36. data/frontend/src/midi-learn.js +194 -0
  37. data/frontend/src/performance-monitor.js +183 -0
  38. data/frontend/src/plugin-runtime.js +130 -0
  39. data/frontend/src/projector-mode.js +56 -0
  40. data/frontend/src/renderer/engine.js +148 -3
  41. data/frontend/src/renderer/layer-manager.js +428 -30
  42. data/frontend/src/renderer/shader-manager.js +26 -0
  43. data/frontend/src/runtime-control-preset.js +11 -0
  44. data/frontend/src/shader-error-overlay.js +29 -0
  45. data/frontend/src/shader-param-controls.js +93 -0
  46. data/frontend/src/shaders/builtins.js +380 -2
  47. data/frontend/src/shaders/post-effects.js +52 -0
  48. data/frontend/src/visual-regression.js +67 -0
  49. data/frontend/src/visual-settings-preset.js +103 -0
  50. data/frontend/src/visuals/geometry.js +268 -0
  51. data/frontend/src/visuals/image-renderer.js +291 -0
  52. data/frontend/src/visuals/particle-system.js +56 -10
  53. data/frontend/src/visuals/spectrogram-renderer.js +226 -0
  54. data/frontend/src/visuals/text-renderer.js +112 -11
  55. data/frontend/src/websocket-client.js +12 -1
  56. data/lib/vizcore/analysis/adaptive_normalizer.rb +70 -0
  57. data/lib/vizcore/analysis/beat_detector.rb +4 -2
  58. data/lib/vizcore/analysis/bpm_estimator.rb +8 -0
  59. data/lib/vizcore/analysis/feature_recorder.rb +159 -0
  60. data/lib/vizcore/analysis/feature_replay.rb +84 -0
  61. data/lib/vizcore/analysis/pipeline.rb +235 -11
  62. data/lib/vizcore/analysis/tap_tempo.rb +74 -0
  63. data/lib/vizcore/analysis.rb +4 -0
  64. data/lib/vizcore/audio/dummy_sine_input.rb +1 -1
  65. data/lib/vizcore/audio/fixture_input.rb +65 -0
  66. data/lib/vizcore/audio/input_manager.rb +4 -2
  67. data/lib/vizcore/audio/mic_input.rb +24 -8
  68. data/lib/vizcore/audio/portaudio_ffi.rb +106 -1
  69. data/lib/vizcore/audio.rb +1 -0
  70. data/lib/vizcore/cli/doctor.rb +159 -0
  71. data/lib/vizcore/cli/dsl_reference.rb +99 -0
  72. data/lib/vizcore/cli/layer_docs.rb +46 -0
  73. data/lib/vizcore/cli/scene_diagnostics.rb +23 -0
  74. data/lib/vizcore/cli/scene_inspector.rb +136 -0
  75. data/lib/vizcore/cli/scene_validator.rb +245 -0
  76. data/lib/vizcore/cli/shader_template.rb +68 -0
  77. data/lib/vizcore/cli/shader_uniform_docs.rb +54 -0
  78. data/lib/vizcore/cli.rb +689 -18
  79. data/lib/vizcore/config.rb +103 -2
  80. data/lib/vizcore/control_preset.rb +68 -0
  81. data/lib/vizcore/dsl/engine.rb +277 -5
  82. data/lib/vizcore/dsl/layer_builder.rb +491 -22
  83. data/lib/vizcore/dsl/layer_group_builder.rb +112 -0
  84. data/lib/vizcore/dsl/mapping_resolver.rb +132 -3
  85. data/lib/vizcore/dsl/mapping_transform_builder.rb +71 -0
  86. data/lib/vizcore/dsl/reaction_builder.rb +44 -0
  87. data/lib/vizcore/dsl/scene_builder.rb +61 -5
  88. data/lib/vizcore/dsl/shader_source_resolver.rb +67 -6
  89. data/lib/vizcore/dsl/style_builder.rb +68 -0
  90. data/lib/vizcore/dsl/timeline_builder.rb +138 -0
  91. data/lib/vizcore/dsl/transition_controller.rb +77 -0
  92. data/lib/vizcore/dsl.rb +5 -1
  93. data/lib/vizcore/layer_catalog.rb +273 -0
  94. data/lib/vizcore/project_manifest.rb +152 -0
  95. data/lib/vizcore/renderer/png_writer.rb +57 -0
  96. data/lib/vizcore/renderer/render_sequence.rb +153 -0
  97. data/lib/vizcore/renderer/scene_frame_source.rb +119 -0
  98. data/lib/vizcore/renderer/scene_serializer.rb +36 -3
  99. data/lib/vizcore/renderer/snapshot.rb +38 -0
  100. data/lib/vizcore/renderer/snapshot_renderer.rb +446 -0
  101. data/lib/vizcore/renderer.rb +5 -0
  102. data/lib/vizcore/server/frame_broadcaster.rb +91 -5
  103. data/lib/vizcore/server/gallery_app.rb +155 -0
  104. data/lib/vizcore/server/gallery_page.rb +100 -0
  105. data/lib/vizcore/server/gallery_runner.rb +48 -0
  106. data/lib/vizcore/server/rack_app.rb +203 -4
  107. data/lib/vizcore/server/runner.rb +370 -22
  108. data/lib/vizcore/server/scene_dependency_watcher.rb +79 -0
  109. data/lib/vizcore/server/websocket_handler.rb +60 -10
  110. data/lib/vizcore/server.rb +4 -0
  111. data/lib/vizcore/sync/osc_message.rb +103 -0
  112. data/lib/vizcore/sync/osc_receiver.rb +68 -0
  113. data/lib/vizcore/sync.rb +4 -0
  114. data/lib/vizcore/templates/midi_control_scene.rb +3 -1
  115. data/lib/vizcore/templates/plugin_layer.rb +20 -0
  116. data/lib/vizcore/templates/plugin_readme.md +23 -0
  117. data/lib/vizcore/templates/plugin_renderer.js +43 -0
  118. data/lib/vizcore/templates/plugin_scene.rb +14 -0
  119. data/lib/vizcore/templates/project_readme.md +7 -23
  120. data/lib/vizcore/templates/rubykaigi_scene.rb +30 -0
  121. data/lib/vizcore/version.rb +1 -1
  122. data/lib/vizcore.rb +27 -0
  123. data/scripts/browser_capture.mjs +75 -0
  124. data/sig/vizcore.rbs +362 -0
  125. metadata +83 -3
  126. data/docs/GETTING_STARTED.md +0 -105
@@ -0,0 +1,446 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "png_writer"
4
+
5
+ module Vizcore
6
+ module Renderer
7
+ # Renders a deterministic software preview PNG from a scene frame.
8
+ class SnapshotRenderer
9
+ DEFAULT_WIDTH = 1280
10
+ DEFAULT_HEIGHT = 720
11
+ PALETTE = [
12
+ [56, 189, 248],
13
+ [225, 29, 72],
14
+ [101, 255, 176],
15
+ [244, 114, 182],
16
+ [250, 204, 21]
17
+ ].freeze
18
+
19
+ def initialize(width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT)
20
+ @width = normalize_dimension(width)
21
+ @height = normalize_dimension(height)
22
+ end
23
+
24
+ attr_reader :width, :height
25
+
26
+ # @param scene [Hash]
27
+ # @param audio [Hash]
28
+ # @return [String] PNG bytes
29
+ def render(scene:, audio:)
30
+ canvas = Canvas.new(width: width, height: height)
31
+ canvas.fill_gradient(background_top(audio), background_bottom(audio))
32
+ layers = Array(scene[:layers] || scene["layers"])
33
+ layers = [default_layer] if layers.empty?
34
+ layers.each_with_index { |layer, index| render_layer(canvas, layer, audio, index) }
35
+ PngWriter.encode(width: width, height: height, rgba: canvas.bytes)
36
+ end
37
+
38
+ private
39
+
40
+ def render_layer(canvas, layer, audio, index)
41
+ type = (layer[:type] || layer["type"] || "geometry").to_s
42
+ color = layer_color(layer, audio, index)
43
+
44
+ case type
45
+ when "shader"
46
+ render_shader_layer(canvas, audio, color, index)
47
+ when "particle_field"
48
+ render_particle_layer(canvas, layer, audio, color)
49
+ when "text"
50
+ render_text_layer(canvas, layer, audio, color)
51
+ when "svg", "svg_layer", "image", "image_layer", "photo", "video", "video_layer", "footage"
52
+ render_image_layer(canvas, layer, audio, color)
53
+ when "waveform", "waveform_layer"
54
+ render_waveform_layer(canvas, layer, audio, color)
55
+ when "spectrogram", "spectrogram_layer"
56
+ render_spectrogram_layer(canvas, layer, audio, color)
57
+ when "shape", "shapes", "shape_layer"
58
+ render_shape_layer(canvas, layer, audio, color)
59
+ when "mesh", "mesh_layer", "preset_mesh"
60
+ render_mesh_layer(canvas, layer, audio, color, index)
61
+ else
62
+ render_geometry_layer(canvas, audio, color, index)
63
+ end
64
+ end
65
+
66
+ def render_shader_layer(canvas, audio, color, index)
67
+ amplitude = clamp(audio[:amplitude])
68
+ y_base = height * (0.35 + index * 0.12)
69
+ 5.times do |wave_index|
70
+ alpha = 0.16 + amplitude * 0.18
71
+ offset = wave_index * height * 0.055
72
+ canvas.draw_wave(y_base + offset, amplitude: amplitude, color: color, alpha: alpha)
73
+ end
74
+ end
75
+
76
+ def render_particle_layer(canvas, layer, audio, color)
77
+ amplitude = clamp(audio[:amplitude])
78
+ count = [[Integer(layer.dig(:params, :count) || layer.dig("params", "count") || 420), 80].max, 900].min
79
+ count.times do |index|
80
+ x = (Math.sin(index * 12.9898) * 43_758.5453).abs % width
81
+ y = (Math.sin(index * 78.233) * 12_345.6789).abs % height
82
+ radius = 1 + (index % 3) + (amplitude * 2).round
83
+ canvas.fill_circle(x, y, radius, color, alpha: 0.35 + amplitude * 0.45)
84
+ end
85
+ rescue ArgumentError, TypeError
86
+ nil
87
+ end
88
+
89
+ def render_text_layer(canvas, layer, audio, color)
90
+ params = Hash(layer[:params] || layer["params"] || {})
91
+ content = params[:content] || params["content"] || layer[:name] || layer["name"] || "Vizcore"
92
+ canvas.draw_label(
93
+ content.to_s,
94
+ x: width * 0.5,
95
+ y: height * 0.72,
96
+ color: color,
97
+ alpha: 0.62 + clamp(audio[:beat_pulse]) * 0.28,
98
+ letter_spacing: normalize_letter_spacing(params)
99
+ )
100
+ end
101
+
102
+ def render_image_layer(canvas, layer, audio, color)
103
+ params = Hash(layer[:params] || layer["params"] || {})
104
+ label = params[:file] || params["file"] || layer[:name] || layer["name"] || "image"
105
+ scale = Float(params[:scale] || params["scale"] || 1).clamp(0.1, 4.0)
106
+ pulse = clamp(audio[:beat_pulse])
107
+ size = [width, height].min * (0.18 + pulse * 0.06) * scale
108
+ x = width * 0.5
109
+ y = height * 0.5
110
+ canvas.draw_rect_outline(x - size / 2, y - size / 2, size, size, color, alpha: 0.72)
111
+ canvas.draw_label(File.basename(label.to_s), x: x, y: y + size * 0.62, color: color, alpha: 0.66)
112
+ rescue ArgumentError, TypeError
113
+ nil
114
+ end
115
+
116
+ def render_waveform_layer(canvas, layer, audio, color)
117
+ params = Hash(layer[:params] || layer["params"] || {})
118
+ amplitude = clamp(audio[:amplitude])
119
+ style = (params[:style] || params["style"] || "line").to_s
120
+ height_scale = normalize_waveform_height(params)
121
+ alpha = 0.45 + amplitude * 0.35
122
+ y_base = height * 0.5
123
+
124
+ canvas.draw_wave(y_base, amplitude: amplitude, color: color, alpha: alpha, height_scale: height_scale)
125
+ return unless %w[mirror ribbon].include?(style)
126
+
127
+ canvas.draw_wave(y_base, amplitude: amplitude, color: color, alpha: alpha * 0.68, height_scale: -height_scale)
128
+ rescue ArgumentError, TypeError
129
+ nil
130
+ end
131
+
132
+ def render_spectrogram_layer(canvas, layer, audio, color)
133
+ params = Hash(layer[:params] || layer["params"] || {})
134
+ fft = Array(audio[:fft] || audio["fft"])
135
+ bins = [[Integer(params[:bins] || params["bins"] || 32), 8].max, 96].min
136
+ gain = Float(params[:gain] || params["gain"] || 1).clamp(0.1, 8.0)
137
+ band_width = width.to_f / bins
138
+ rows = 18
139
+ row_height = height * 0.5 / rows
140
+ top = height * 0.22
141
+
142
+ rows.times do |row|
143
+ age = row.to_f / [rows - 1, 1].max
144
+ bins.times do |bin|
145
+ value = clamp(Float(fft[bin % [fft.length, 1].max] || 0) * gain)
146
+ alpha = (0.08 + value * 0.52) * (1.0 - age * 0.62)
147
+ x = bin * band_width
148
+ y = top + row * row_height
149
+ canvas.fill_rect(x, y, band_width.ceil + 1, row_height.ceil + 1, color, alpha: alpha)
150
+ end
151
+ end
152
+ rescue ArgumentError, TypeError
153
+ nil
154
+ end
155
+
156
+ def render_shape_layer(canvas, layer, audio, color)
157
+ params = Hash(layer[:params] || layer["params"] || {})
158
+ shapes = Array(params[:shapes] || params["shapes"])
159
+ pulse = clamp(audio[:beat_pulse])
160
+ alpha = 0.58 + pulse * 0.24
161
+
162
+ shapes.each do |shape|
163
+ shape_hash = Hash(shape)
164
+ case (shape_hash[:kind] || shape_hash["kind"]).to_s
165
+ when "circle"
166
+ render_circle_shape(canvas, shape_hash, color, alpha)
167
+ when "line"
168
+ render_line_shape(canvas, shape_hash, color, alpha)
169
+ end
170
+ end
171
+ rescue ArgumentError, TypeError
172
+ nil
173
+ end
174
+
175
+ def render_circle_shape(canvas, shape, color, alpha)
176
+ count = [[Integer(shape[:count] || shape["count"] || 1), 1].max, 32].min
177
+ radius = Float(shape[:radius] || shape["radius"] || 100).abs
178
+ x = Float(shape[:x] || shape["x"] || width * 0.5)
179
+ y = Float(shape[:y] || shape["y"] || height * 0.5)
180
+ x = width * 0.5 if x.abs <= 1.5
181
+ y = height * 0.5 if y.abs <= 1.5
182
+
183
+ count.times do |index|
184
+ canvas.draw_circle_outline(x, y, radius * ((index + 1).to_f / count), color, alpha: alpha)
185
+ end
186
+ end
187
+
188
+ def render_line_shape(canvas, shape, color, alpha)
189
+ x1 = Float(shape[:x1] || shape["x1"] || width * 0.2)
190
+ y1 = Float(shape[:y1] || shape["y1"] || height * 0.5)
191
+ x2 = Float(shape[:x2] || shape["x2"] || width * 0.8)
192
+ y2 = Float(shape[:y2] || shape["y2"] || height * 0.5)
193
+ canvas.draw_line(x1, y1, x2, y2, color, alpha: alpha)
194
+ end
195
+
196
+ def render_mesh_layer(canvas, layer, audio, color, index)
197
+ params = Hash(layer[:params] || layer["params"] || {})
198
+ amplitude = clamp(audio[:amplitude])
199
+ high = audio.dig(:bands, :high) || audio.dig("bands", "high")
200
+ deform = clamp(params[:deform] || params["deform"] || high || amplitude)
201
+ scale = Float(params[:scale] || params["scale"] || 1).clamp(0.1, 3.0)
202
+ radius = [width, height].min * (0.20 + amplitude * 0.08 + deform * 0.08) * scale
203
+ cx = width * (0.5 + (index - 1) * 0.06)
204
+ cy = height * 0.48
205
+ top = [cx, cy - radius * 0.62]
206
+ bottom = [cx, cy + radius * 0.62]
207
+ ring = 6.times.map do |point_index|
208
+ angle = (point_index.to_f / 6) * Math::PI * 2 + Math::PI / 6
209
+ [cx + Math.cos(angle) * radius * 0.72, cy + Math.sin(angle) * radius * 0.36]
210
+ end
211
+
212
+ ring.each_with_index do |point, point_index|
213
+ next_point = ring[(point_index + 1) % ring.length]
214
+ canvas.draw_line(point[0], point[1], next_point[0], next_point[1], color, alpha: 0.62)
215
+ canvas.draw_line(top[0], top[1], point[0], point[1], color, alpha: 0.54)
216
+ canvas.draw_line(bottom[0], bottom[1], next_point[0], next_point[1], color, alpha: 0.42)
217
+ end
218
+
219
+ 3.times do |point_index|
220
+ from = ring[point_index]
221
+ to = ring[point_index + 3]
222
+ canvas.draw_line(from[0], from[1], to[0], to[1], color, alpha: 0.32 + deform * 0.2)
223
+ end
224
+ rescue ArgumentError, TypeError
225
+ nil
226
+ end
227
+
228
+ def render_geometry_layer(canvas, audio, color, index)
229
+ amplitude = clamp(audio[:amplitude])
230
+ size = [width, height].min * (0.22 + amplitude * 0.18)
231
+ cx = width * (0.5 + (index - 1) * 0.08)
232
+ cy = height * 0.48
233
+ offset = size * 0.24
234
+ canvas.draw_rect_outline(cx - size / 2, cy - size / 2, size, size, color, alpha: 0.78)
235
+ canvas.draw_rect_outline(cx - size / 2 + offset, cy - size / 2 - offset, size, size, color, alpha: 0.46)
236
+ 4.times do |corner|
237
+ x1 = cx - size / 2 + (corner.even? ? 0 : size)
238
+ y1 = cy - size / 2 + (corner < 2 ? 0 : size)
239
+ canvas.draw_line(x1, y1, x1 + offset, y1 - offset, color, alpha: 0.55)
240
+ end
241
+ end
242
+
243
+ def background_top(audio)
244
+ amplitude = clamp(audio[:amplitude])
245
+ high = clamp(audio.dig(:bands, :high))
246
+ [4 + (amplitude * 22).round, 10 + (high * 38).round, 24 + (amplitude * 34).round]
247
+ end
248
+
249
+ def background_bottom(audio)
250
+ low = clamp(audio.dig(:bands, :low))
251
+ [1 + (low * 30).round, 4 + (low * 18).round, 12 + (low * 44).round]
252
+ end
253
+
254
+ def layer_color(layer, audio, index)
255
+ base = configured_layer_color(layer, index) || PALETTE[index % PALETTE.length]
256
+ beat = clamp(audio[:beat_pulse])
257
+ name_factor = (layer[:shader] || layer["shader"] || layer[:name] || layer["name"]).to_s.bytes.sum % 38
258
+ base.map { |value| [[value + name_factor + (beat * 30).round, 255].min, 0].max }
259
+ end
260
+
261
+ def configured_layer_color(layer, index)
262
+ params = Hash(layer[:params] || layer["params"] || {})
263
+ color = configured_color(params) || palette_color(params, index)
264
+ parse_hex_color(color)
265
+ rescue StandardError
266
+ nil
267
+ end
268
+
269
+ def configured_color(params)
270
+ [params[:color], params["color"]].map { |value| value.to_s.strip }.find { |value| !value.empty? }
271
+ end
272
+
273
+ def palette_color(params, index)
274
+ palette = Array(params[:palette] || params["palette"]).map { |color| color.to_s.strip }.reject(&:empty?)
275
+ return nil if palette.empty?
276
+
277
+ palette[index % palette.length]
278
+ end
279
+
280
+ def parse_hex_color(value)
281
+ match = value.to_s.strip.match(/\A#(?<hex>[0-9a-fA-F]{3}|[0-9a-fA-F]{6})\z/)
282
+ return nil unless match
283
+
284
+ hex = match[:hex]
285
+ hex = hex.chars.map { |char| "#{char}#{char}" }.join if hex.length == 3
286
+ [hex[0, 2], hex[2, 2], hex[4, 2]].map { |component| component.to_i(16) }
287
+ end
288
+
289
+ def default_layer
290
+ { type: "geometry", name: "snapshot" }
291
+ end
292
+
293
+ def normalize_dimension(value)
294
+ Integer(value).clamp(64, 4096)
295
+ rescue ArgumentError, TypeError
296
+ DEFAULT_WIDTH
297
+ end
298
+
299
+ def clamp(value)
300
+ Float(value || 0).clamp(0.0, 1.0)
301
+ rescue ArgumentError, TypeError
302
+ 0.0
303
+ end
304
+
305
+ def normalize_letter_spacing(params)
306
+ Float(params[:letter_spacing] || params["letter_spacing"] || 0).clamp(0.0, 96.0)
307
+ rescue ArgumentError, TypeError
308
+ 0.0
309
+ end
310
+
311
+ def normalize_waveform_height(params)
312
+ Float(params[:height] || params["height"] || 0.46).clamp(0.05, 1.1)
313
+ rescue ArgumentError, TypeError
314
+ 0.46
315
+ end
316
+
317
+ # Tiny RGBA canvas with alpha blending and a few primitive drawing helpers.
318
+ class Canvas
319
+ def initialize(width:, height:)
320
+ @width = width
321
+ @height = height
322
+ @bytes = String.new(capacity: width * height * 4, encoding: Encoding::BINARY)
323
+ @bytes << ([0, 0, 0, 255].pack("C4") * (width * height))
324
+ end
325
+
326
+ attr_reader :width, :height, :bytes
327
+
328
+ def fill_gradient(top, bottom)
329
+ height.times do |y|
330
+ t = y.to_f / [height - 1, 1].max
331
+ color = 3.times.map { |index| interpolate(top[index], bottom[index], t).round }
332
+ width.times { |x| set_pixel(x, y, color, 255) }
333
+ end
334
+ end
335
+
336
+ def draw_wave(y_base, amplitude:, color:, alpha:, height_scale: 1.0)
337
+ previous = nil
338
+ width.times do |x|
339
+ phase = (x.to_f / width) * Math::PI * 4.0
340
+ y = y_base + Math.sin(phase) * height * (0.06 + amplitude * 0.08) * height_scale
341
+ draw_line(previous[0], previous[1], x, y, color, alpha: alpha) if previous
342
+ previous = [x, y]
343
+ end
344
+ end
345
+
346
+ def draw_rect_outline(x, y, rect_width, rect_height, color, alpha:)
347
+ draw_line(x, y, x + rect_width, y, color, alpha: alpha)
348
+ draw_line(x + rect_width, y, x + rect_width, y + rect_height, color, alpha: alpha)
349
+ draw_line(x + rect_width, y + rect_height, x, y + rect_height, color, alpha: alpha)
350
+ draw_line(x, y + rect_height, x, y, color, alpha: alpha)
351
+ end
352
+
353
+ def draw_circle_outline(cx, cy, radius, color, alpha:)
354
+ segments = 96
355
+ previous = nil
356
+ (0..segments).each do |index|
357
+ angle = (index.to_f / segments) * Math::PI * 2
358
+ point = [cx + Math.cos(angle) * radius, cy + Math.sin(angle) * radius]
359
+ draw_line(previous[0], previous[1], point[0], point[1], color, alpha: alpha) if previous
360
+ previous = point
361
+ end
362
+ end
363
+
364
+ def fill_rect(x, y, rect_width, rect_height, color, alpha:)
365
+ start_x = x.round
366
+ end_x = (x + rect_width).round
367
+ start_y = y.round
368
+ end_y = (y + rect_height).round
369
+ start_y.upto(end_y) do |py|
370
+ start_x.upto(end_x) { |px| blend_pixel(px, py, color, alpha) }
371
+ end
372
+ end
373
+
374
+ def draw_line(x1, y1, x2, y2, color, alpha:)
375
+ x1 = x1.round
376
+ y1 = y1.round
377
+ x2 = x2.round
378
+ y2 = y2.round
379
+ steps = [(x2 - x1).abs, (y2 - y1).abs].max
380
+ return blend_pixel(x1, y1, color, alpha) if steps.zero?
381
+
382
+ steps.times do |step|
383
+ t = step.to_f / steps
384
+ blend_pixel(interpolate(x1, x2, t).round, interpolate(y1, y2, t).round, color, alpha)
385
+ end
386
+ end
387
+
388
+ def fill_circle(cx, cy, radius, color, alpha:)
389
+ cx = cx.round
390
+ cy = cy.round
391
+ radius = radius.round
392
+ (cy - radius).upto(cy + radius) do |y|
393
+ (cx - radius).upto(cx + radius) do |x|
394
+ next if ((x - cx)**2) + ((y - cy)**2) > radius**2
395
+
396
+ blend_pixel(x, y, color, alpha)
397
+ end
398
+ end
399
+ end
400
+
401
+ def draw_label(text, x:, y:, color:, alpha:, letter_spacing: 0.0)
402
+ chars = text.each_byte.first(24)
403
+ char_width = 14 + Float(letter_spacing).clamp(0.0, 96.0).round
404
+ total_width = chars.length * char_width
405
+ start_x = x.round - total_width / 2
406
+ chars.each_with_index do |byte, index|
407
+ height_factor = 0.35 + (byte % 9) * 0.07
408
+ fill_bar(start_x + index * char_width, y.round, 9, (42 * height_factor).round, color, alpha)
409
+ end
410
+ end
411
+
412
+ private
413
+
414
+ def fill_bar(x, baseline, bar_width, bar_height, color, alpha)
415
+ (baseline - bar_height).upto(baseline) do |y|
416
+ x.upto(x + bar_width) { |px| blend_pixel(px, y, color, alpha) }
417
+ end
418
+ end
419
+
420
+ def blend_pixel(x, y, color, alpha)
421
+ return if x.negative? || y.negative? || x >= width || y >= height
422
+
423
+ offset = ((y * width) + x) * 4
424
+ amount = Float(alpha).clamp(0.0, 1.0)
425
+ 3.times do |index|
426
+ current = bytes.getbyte(offset + index)
427
+ bytes.setbyte(offset + index, interpolate(current, color[index], amount).round)
428
+ end
429
+ bytes.setbyte(offset + 3, 255)
430
+ end
431
+
432
+ def set_pixel(x, y, color, alpha)
433
+ offset = ((y * width) + x) * 4
434
+ bytes.setbyte(offset, color[0])
435
+ bytes.setbyte(offset + 1, color[1])
436
+ bytes.setbyte(offset + 2, color[2])
437
+ bytes.setbyte(offset + 3, alpha)
438
+ end
439
+
440
+ def interpolate(from, to, amount)
441
+ from + (to - from) * amount
442
+ end
443
+ end
444
+ end
445
+ end
446
+ end
@@ -7,4 +7,9 @@ module Vizcore
7
7
  end
8
8
 
9
9
  require_relative "renderer/frame_scheduler"
10
+ require_relative "renderer/png_writer"
11
+ require_relative "renderer/scene_frame_source"
12
+ require_relative "renderer/render_sequence"
10
13
  require_relative "renderer/scene_serializer"
14
+ require_relative "renderer/snapshot"
15
+ require_relative "renderer/snapshot_renderer"
@@ -23,6 +23,10 @@ module Vizcore
23
23
  # @param scene_catalog [Array<Hash>, nil]
24
24
  # @param transitions [Array<Hash>, nil]
25
25
  # @param transition_controller [Vizcore::DSL::TransitionController, nil]
26
+ # @param noise_gate [Numeric]
27
+ # @param audio_normalize [Hash, nil]
28
+ # @param bpm [Numeric, nil]
29
+ # @param bpm_lock [Boolean]
26
30
  # @param error_reporter [#call, nil]
27
31
  def initialize(
28
32
  scene_name: "basic",
@@ -35,6 +39,10 @@ module Vizcore
35
39
  scene_catalog: nil,
36
40
  transitions: nil,
37
41
  transition_controller: nil,
42
+ noise_gate: Vizcore::Analysis::Pipeline::DEFAULT_NOISE_GATE,
43
+ audio_normalize: nil,
44
+ bpm: nil,
45
+ bpm_lock: false,
38
46
  error_reporter: nil
39
47
  )
40
48
  @scene_name = scene_name
@@ -44,7 +52,11 @@ module Vizcore
44
52
  fft_size = supported_fft_size(@input_manager.frame_size)
45
53
  @analysis_pipeline = analysis_pipeline || Vizcore::Analysis::Pipeline.new(
46
54
  sample_rate: @input_manager.sample_rate,
47
- fft_size: fft_size
55
+ fft_size: fft_size,
56
+ noise_gate: noise_gate,
57
+ audio_normalize: audio_normalize,
58
+ bpm: bpm,
59
+ bpm_lock: bpm_lock
48
60
  )
49
61
  @mapping_resolver = mapping_resolver || Vizcore::DSL::MappingResolver.new
50
62
  @scene_serializer = scene_serializer || Vizcore::Renderer::SceneSerializer.new
@@ -57,6 +69,7 @@ module Vizcore
57
69
  @frame_count = 0
58
70
  @transport_playing = initial_transport_playing_state
59
71
  reset_transition_trigger_counters!
72
+ @tap_tempo = Vizcore::Analysis::TapTempo.new
60
73
  @frame_scheduler = frame_scheduler || Vizcore::Renderer::FrameScheduler.new(frame_rate: FRAME_RATE) do |elapsed|
61
74
  tick(elapsed)
62
75
  end
@@ -148,6 +161,55 @@ module Vizcore
148
161
  end
149
162
  end
150
163
 
164
+ # Replace audio analysis settings after scene hot reload.
165
+ #
166
+ # @param audio_normalize [Hash, nil]
167
+ # @param bpm [Numeric, nil]
168
+ # @param bpm_lock [Boolean]
169
+ # @return [void]
170
+ def update_analysis_settings(audio_normalize:, bpm: nil, bpm_lock: false)
171
+ return unless @analysis_pipeline.respond_to?(:audio_normalize=)
172
+
173
+ @analysis_pipeline.audio_normalize = audio_normalize
174
+ @analysis_pipeline.bpm_lock = { bpm: bpm, locked: bpm_lock } if @analysis_pipeline.respond_to?(:bpm_lock=)
175
+ end
176
+
177
+ # Apply a manual tap tempo event and lock analysis BPM when enough taps exist.
178
+ #
179
+ # @param timestamp_ms [Numeric]
180
+ # @return [Float, nil]
181
+ def tap_tempo(timestamp_ms:)
182
+ bpm = @tap_tempo.tap(timestamp_ms: timestamp_ms)
183
+ return nil unless bpm
184
+ return bpm unless @analysis_pipeline.respond_to?(:bpm_lock=)
185
+
186
+ @analysis_pipeline.bpm_lock = { bpm: bpm, locked: true }
187
+ bpm
188
+ end
189
+
190
+ # Lock analysis BPM from an external sync source.
191
+ #
192
+ # @param bpm [Numeric]
193
+ # @return [Float, nil]
194
+ def lock_bpm(bpm)
195
+ numeric = Float(bpm)
196
+ return nil unless numeric.finite? && numeric.positive?
197
+ return numeric unless @analysis_pipeline.respond_to?(:bpm_lock=)
198
+
199
+ @analysis_pipeline.bpm_lock = { bpm: numeric, locked: true }
200
+ numeric
201
+ rescue ArgumentError, TypeError
202
+ nil
203
+ end
204
+
205
+ # Unlock analysis BPM after an external sync lock.
206
+ #
207
+ # @return [Boolean]
208
+ def unlock_bpm
209
+ @analysis_pipeline.bpm_lock = { bpm: nil, locked: false } if @analysis_pipeline.respond_to?(:bpm_lock=)
210
+ true
211
+ end
212
+
151
213
  # Build one frame payload for transport to frontend.
152
214
  #
153
215
  # @param _elapsed_seconds [Float]
@@ -155,17 +217,25 @@ module Vizcore
155
217
  # @raise [Vizcore::FrameBuildError] when frame construction fails
156
218
  # @return [Hash]
157
219
  def build_frame(_elapsed_seconds, samples = nil)
158
- audio_samples = samples || capture_samples
159
- analyzed = @analysis_pipeline.call(audio_samples)
220
+ started_at_ms = monotonic_ms
221
+ audio_samples, audio_capture_ms = capture_or_use_samples(samples)
222
+ analyzed, audio_analysis_ms = measure_ms { @analysis_pipeline.call(audio_samples) }
160
223
  scene = current_scene
161
- layers = build_scene_layers(scene[:layers], analyzed)
224
+ layers, scene_build_ms = measure_ms { build_scene_layers(scene[:layers], analyzed) }
162
225
 
163
226
  @scene_serializer.audio_frame(
164
227
  timestamp: Time.now.to_f,
165
228
  audio: analyzed,
166
229
  scene_name: scene[:name],
167
230
  scene_layers: layers,
168
- transition: nil
231
+ transition: nil,
232
+ metrics: {
233
+ frame_id: @frame_count,
234
+ audio_capture_ms: audio_capture_ms,
235
+ audio_analysis_ms: audio_analysis_ms,
236
+ scene_build_ms: scene_build_ms,
237
+ server_frame_ms: monotonic_ms - started_at_ms
238
+ }
169
239
  )
170
240
  rescue StandardError => e
171
241
  report_error(e, context: "frame build failed")
@@ -174,6 +244,22 @@ module Vizcore
174
244
 
175
245
  private
176
246
 
247
+ def capture_or_use_samples(samples)
248
+ return [samples, 0.0] if samples
249
+
250
+ measure_ms { capture_samples }
251
+ end
252
+
253
+ def measure_ms
254
+ started_at = monotonic_ms
255
+ result = yield
256
+ [result, monotonic_ms - started_at]
257
+ end
258
+
259
+ def monotonic_ms
260
+ Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond)
261
+ end
262
+
177
263
  def capture_samples
178
264
  ingest_count =
179
265
  if @input_manager.respond_to?(:realtime_capture_size)