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
@@ -11,22 +11,62 @@ module Vizcore
11
11
  DEFAULT_PORT = 4567
12
12
  # Default audio source.
13
13
  DEFAULT_AUDIO_SOURCE = :mic
14
+ # Default RMS noise gate for live audio.
15
+ DEFAULT_NOISE_GATE = 0.01
16
+ # Default scene file hot reload behavior.
17
+ DEFAULT_RELOAD = true
14
18
  # Supported CLI audio source values.
15
19
  SUPPORTED_AUDIO_SOURCES = %i[mic file dummy].freeze
16
20
 
17
- attr_reader :host, :port, :scene_file, :audio_source, :audio_file
21
+ attr_reader :host, :port, :scene_file, :audio_source, :audio_file, :audio_device, :feature_file, :control_preset, :plugin_assets, :noise_gate, :bpm, :osc_port, :projector_mode
18
22
 
19
23
  # @param scene_file [String, Pathname] scene DSL file path
20
24
  # @param host [String] bind host
21
25
  # @param port [Integer] bind port
22
26
  # @param audio_source [Symbol, String] one of `:mic`, `:file`, `:dummy`
23
27
  # @param audio_file [String, Pathname, nil] file path used with `audio_source=:file`
24
- def initialize(scene_file:, host: DEFAULT_HOST, port: DEFAULT_PORT, audio_source: DEFAULT_AUDIO_SOURCE, audio_file: nil)
28
+ # @param audio_device [String, Integer, nil] input device index/name used with `audio_source=:mic`
29
+ # @param feature_file [String, Pathname, nil] recorded feature JSON used instead of live analysis
30
+ # @param control_preset [String, Pathname, nil] browser control preset JSON
31
+ # @param plugin_assets [Array<String, Pathname>] browser plugin renderer files to serve
32
+ # @param noise_gate [Numeric] RMS threshold below which live input is treated as silence
33
+ # @param bpm [Numeric, nil] fixed BPM value used when BPM lock is enabled
34
+ # @param bpm_lock [Boolean] true when the analysis output BPM should stay fixed
35
+ # @param osc_port [Integer, nil] UDP port for OSC control sync
36
+ # @param reload [Boolean] true when scene file changes should be reloaded while running
37
+ # @param projector_mode [Boolean] true when the browser should hide operator UI by default
38
+ def initialize(
39
+ scene_file:,
40
+ host: DEFAULT_HOST,
41
+ port: DEFAULT_PORT,
42
+ audio_source: DEFAULT_AUDIO_SOURCE,
43
+ audio_file: nil,
44
+ audio_device: nil,
45
+ feature_file: nil,
46
+ control_preset: nil,
47
+ plugin_assets: [],
48
+ noise_gate: DEFAULT_NOISE_GATE,
49
+ bpm: nil,
50
+ bpm_lock: false,
51
+ osc_port: nil,
52
+ reload: DEFAULT_RELOAD,
53
+ projector_mode: false
54
+ )
25
55
  @scene_file = Pathname.new(scene_file).expand_path if scene_file
26
56
  @host = host
27
57
  @port = Integer(port)
28
58
  @audio_source = normalize_audio_source(audio_source)
29
59
  @audio_file = audio_file ? Pathname.new(audio_file).expand_path : nil
60
+ @audio_device = normalize_audio_device(audio_device)
61
+ @feature_file = feature_file ? Pathname.new(feature_file).expand_path : nil
62
+ @control_preset = control_preset ? Pathname.new(control_preset).expand_path : nil
63
+ @plugin_assets = normalize_plugin_assets(plugin_assets)
64
+ @noise_gate = normalize_noise_gate(noise_gate)
65
+ @bpm = normalize_bpm(bpm)
66
+ @bpm_lock = !!bpm_lock
67
+ @osc_port = normalize_optional_port(osc_port)
68
+ @reload = !!reload
69
+ @projector_mode = !!projector_mode
30
70
  end
31
71
 
32
72
  # @return [Boolean] true when the configured scene file exists.
@@ -34,6 +74,21 @@ module Vizcore
34
74
  scene_file && scene_file.file?
35
75
  end
36
76
 
77
+ # @return [Boolean] true when browser output should start without operator UI.
78
+ def projector?
79
+ projector_mode
80
+ end
81
+
82
+ # @return [Boolean] true when scene hot reload is enabled.
83
+ def reload?
84
+ @reload
85
+ end
86
+
87
+ # @return [Boolean] true when BPM output should use the fixed BPM value.
88
+ def bpm_lock?
89
+ @bpm_lock
90
+ end
91
+
37
92
  private
38
93
 
39
94
  def normalize_audio_source(value)
@@ -42,5 +97,51 @@ module Vizcore
42
97
 
43
98
  raise ArgumentError, "Unsupported audio source: #{value}. Use one of: #{SUPPORTED_AUDIO_SOURCES.join(', ')}"
44
99
  end
100
+
101
+ def normalize_audio_device(value)
102
+ return nil if value.nil?
103
+
104
+ normalized = value.to_s.strip
105
+ return nil if normalized.empty?
106
+
107
+ normalized
108
+ end
109
+
110
+ def normalize_noise_gate(value)
111
+ Float(value).clamp(0.0, 1.0)
112
+ rescue ArgumentError, TypeError
113
+ raise ArgumentError, "Noise gate must be numeric"
114
+ end
115
+
116
+ def normalize_bpm(value)
117
+ return nil if value.nil?
118
+
119
+ numeric = Float(value)
120
+ raise ArgumentError, "BPM must be positive" unless numeric.positive?
121
+
122
+ numeric
123
+ rescue ArgumentError, TypeError
124
+ raise ArgumentError, "BPM must be a positive number"
125
+ end
126
+
127
+ def normalize_optional_port(value)
128
+ return nil if value.nil?
129
+
130
+ port_value = Integer(value)
131
+ raise ArgumentError, "OSC port must be between 1 and 65535" unless port_value.between?(1, 65_535)
132
+
133
+ port_value
134
+ rescue ArgumentError, TypeError
135
+ raise ArgumentError, "OSC port must be between 1 and 65535"
136
+ end
137
+
138
+ def normalize_plugin_assets(values)
139
+ Array(values).filter_map do |value|
140
+ raw_value = value.to_s.strip
141
+ next if raw_value.empty?
142
+
143
+ value.is_a?(Pathname) ? value.expand_path : Pathname.new(raw_value).expand_path
144
+ end
145
+ end
45
146
  end
46
147
  end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+ require "pathname"
6
+
7
+ module Vizcore
8
+ # Loads browser control presets shared through the runtime endpoint.
9
+ class ControlPreset
10
+ # @param path [String, Pathname]
11
+ # @return [Hash]
12
+ def self.load(path)
13
+ new(path).load
14
+ end
15
+
16
+ # @param path [String, Pathname]
17
+ # @param payload [Hash]
18
+ # @return [Hash]
19
+ def self.write(path, payload)
20
+ new(path).write(payload)
21
+ end
22
+
23
+ # @param path [String, Pathname]
24
+ def initialize(path)
25
+ @path = Pathname.new(path).expand_path
26
+ end
27
+
28
+ # @return [Hash]
29
+ def load
30
+ raise ArgumentError, "Control preset file not found: #{@path}" unless @path.file?
31
+
32
+ parsed = JSON.parse(@path.read)
33
+ normalize_payload(parsed)
34
+ rescue JSON::ParserError => e
35
+ raise ArgumentError, "Invalid control preset JSON #{@path}: #{e.message}"
36
+ end
37
+
38
+ # @param payload [Hash]
39
+ # @return [Hash]
40
+ def write(payload)
41
+ normalized = normalize_payload(payload)
42
+ FileUtils.mkdir_p(@path.dirname)
43
+ @path.write(JSON.pretty_generate(normalized) << "\n")
44
+ normalized
45
+ end
46
+
47
+ private
48
+
49
+ def normalize_payload(value)
50
+ input = value.is_a?(Hash) ? value : {}
51
+ visual_settings = hash_value(input, "visual_settings", "visualSettings", "settings")
52
+ midi_learn_bindings = hash_value(input, "midi_learn_bindings", "midiLearnBindings", "midi")
53
+
54
+ {}.tap do |payload|
55
+ payload["visual_settings"] = visual_settings if visual_settings
56
+ payload["midi_learn_bindings"] = midi_learn_bindings if midi_learn_bindings
57
+ end
58
+ end
59
+
60
+ def hash_value(input, *keys)
61
+ keys.each do |key|
62
+ value = input[key] || input[key.to_sym]
63
+ return value if value.is_a?(Hash)
64
+ end
65
+ nil
66
+ end
67
+ end
68
+ end
@@ -3,6 +3,8 @@
3
3
  require "pathname"
4
4
  require_relative "file_watcher"
5
5
  require_relative "scene_builder"
6
+ require_relative "style_builder"
7
+ require_relative "timeline_builder"
6
8
 
7
9
  module Vizcore
8
10
  module DSL
@@ -72,7 +74,14 @@ module Vizcore
72
74
  @scenes = []
73
75
  @transitions = []
74
76
  @midi_mappings = []
77
+ @key_mappings = []
75
78
  @global_params = {}
79
+ @analysis_settings = {}
80
+ @section_tail = nil
81
+ @timelines = []
82
+ @styles = {}
83
+ @themes = {}
84
+ @scene_registry = {}
76
85
  end
77
86
 
78
87
  # Evaluate DSL methods on this engine instance.
@@ -93,6 +102,28 @@ module Vizcore
93
102
  @audio_inputs << { name: name.to_sym, options: symbolize_keys(options) }
94
103
  end
95
104
 
105
+ # Register a reusable layer parameter style.
106
+ #
107
+ # @param name [Symbol, String] style identifier
108
+ # @yield Style parameter block
109
+ # @return [void]
110
+ def style(name, &block)
111
+ builder = StyleBuilder.new(name: name)
112
+ style_definition = builder.evaluate(&block).to_h
113
+ @styles[style_definition[:name]] = deep_dup(style_definition[:params])
114
+ end
115
+
116
+ # Register a reusable scene-wide layer parameter theme.
117
+ #
118
+ # @param name [Symbol, String] theme identifier
119
+ # @yield Theme parameter block
120
+ # @return [void]
121
+ def theme(name, &block)
122
+ builder = StyleBuilder.new(name: name, kind: "theme")
123
+ theme_definition = builder.evaluate(&block).to_h
124
+ @themes[theme_definition[:name]] = deep_dup(theme_definition[:params])
125
+ end
126
+
96
127
  # Register a MIDI input definition.
97
128
  #
98
129
  # @param name [Symbol, String] input name
@@ -102,15 +133,86 @@ module Vizcore
102
133
  @midi_inputs << { name: name.to_sym, options: symbolize_keys(options) }
103
134
  end
104
135
 
136
+ # Configure analysis-level audio feature normalization.
137
+ #
138
+ # @param mode [Symbol, String] `:off` or `:adaptive`
139
+ # @param options [Hash] optional `window`, `target`, and `floor` values
140
+ # @return [Hash] normalized audio normalization settings
141
+ def audio_normalize(mode: :adaptive, **options)
142
+ settings = normalize_audio_normalize(mode: mode, **options)
143
+ @analysis_settings[:audio_normalize] = settings
144
+ end
145
+
146
+ # Set a fixed BPM value for analysis output.
147
+ #
148
+ # @param value [Numeric]
149
+ # @return [Float]
150
+ def bpm(value)
151
+ @analysis_settings[:bpm] = positive_float(value, "bpm")
152
+ end
153
+
154
+ # Enable or disable fixed BPM output.
155
+ #
156
+ # @param value [Boolean]
157
+ # @return [Boolean]
158
+ def bpm_lock(value = true)
159
+ @analysis_settings[:bpm_lock] = !!value
160
+ end
161
+
162
+ # Enable browser keyboard tap tempo.
163
+ #
164
+ # @param key [Symbol, String] key that should send tap tempo events
165
+ # @return [Hash]
166
+ def tap_tempo(key: :t)
167
+ normalized_key = key.to_s.strip.downcase
168
+ raise ArgumentError, "tap_tempo key must not be empty" if normalized_key.empty?
169
+
170
+ @analysis_settings[:tap_tempo] = { key: normalized_key }
171
+ end
172
+
105
173
  # Define a scene and its layers.
106
174
  #
107
175
  # @param name [Symbol, String] scene identifier
176
+ # @param extends [Symbol, String, nil] optional base scene to copy layers from
108
177
  # @yield Scene definition block
109
178
  # @return [void]
110
- def scene(name, &block)
111
- builder = SceneBuilder.new(name: name)
179
+ def scene(name, extends: nil, &block)
180
+ builder = SceneBuilder.new(name: name, styles: @styles, themes: @themes, layers: inherited_layers(extends))
112
181
  builder.evaluate(&block)
113
- @scenes << builder.to_h
182
+ scene_definition = builder.to_h
183
+ @scenes << scene_definition
184
+ @scene_registry[scene_definition[:name]] = deep_dup(scene_definition)
185
+ end
186
+
187
+ # Define a beat-counted song section as a scene and auto-transition to the
188
+ # following section.
189
+ #
190
+ # @param name [Symbol, String] scene/section identifier
191
+ # @param bars [Integer] section duration in bars
192
+ # @param beats_per_bar [Integer] meter used to convert bars into beats
193
+ # @yield Scene definition block
194
+ # @return [void]
195
+ def section(name, bars:, beats_per_bar: 4, &block)
196
+ section_name = name.to_sym
197
+ section_beats = positive_integer(bars, "section bars") * positive_integer(beats_per_bar, "beats_per_bar")
198
+
199
+ scene(section_name, &block)
200
+ add_section_transition(to: section_name) if @section_tail
201
+ @section_tail = { name: section_name, beats: section_beats }
202
+ end
203
+
204
+ # Define ordered scene markers and derive transitions between them.
205
+ #
206
+ # @param beats_per_bar [Integer] meter used by timeline `bars(...)` markers
207
+ # @yield Timeline marker block
208
+ # @return [void]
209
+ def timeline(beats_per_bar: TimelineBuilder::DEFAULT_BEATS_PER_BAR, &block)
210
+ raise ArgumentError, "timeline requires a block" unless block
211
+
212
+ builder = TimelineBuilder.new(beats_per_bar: beats_per_bar).evaluate(&block)
213
+ entries = builder.to_h
214
+ @timelines << entries unless entries.empty?
215
+ @transitions.concat(builder.transitions)
114
216
  end
115
217
 
116
218
  # Define a transition between scenes.
@@ -150,6 +252,25 @@ module Vizcore
150
252
  }
151
253
  end
152
254
 
255
+ # Register a browser keyboard shortcut for runtime controls.
256
+ #
257
+ # @param value [Symbol, String] browser KeyboardEvent key value
258
+ # @yield Action block (`switch_scene`, `blackout`, or `freeze`)
259
+ # @raise [ArgumentError] when the key or action is missing
260
+ # @return [void]
261
+ def key(value, &block)
262
+ binding_key = normalize_keyboard_key(value)
263
+ builder = KeyBindingBuilder.new
264
+ builder.instance_eval(&block) if block
265
+ action = builder.to_h
266
+ raise ArgumentError, "key #{binding_key.inspect} requires an action" if action.empty?
267
+
268
+ @key_mappings << {
269
+ key: binding_key,
270
+ action: action
271
+ }
272
+ end
273
+
153
274
  # Set a mutable global value shared with scene/runtime logic.
154
275
  #
155
276
  # @param key [Symbol, String] global key
@@ -161,14 +282,20 @@ module Vizcore
161
282
 
162
283
  # @return [Hash] deep-copied definition payload for renderer/runtime.
163
284
  def result
164
- {
285
+ definition = {
165
286
  audio: @audio_inputs.map { |item| deep_dup(item) },
166
287
  midi: @midi_inputs.map { |item| deep_dup(item) },
167
288
  scenes: @scenes.map { |scene| deep_dup(scene) },
168
289
  transitions: @transitions.map { |transition| deep_dup(transition) },
169
290
  midi_maps: @midi_mappings.map { |mapping| deep_dup(mapping) },
170
- globals: deep_dup(@global_params)
291
+ key_mappings: @key_mappings.map { |mapping| deep_dup(mapping) },
292
+ globals: deep_dup(@global_params),
293
+ analysis: deep_dup(@analysis_settings),
294
+ styles: @styles.map { |name, params| { name: name, params: deep_dup(params) } },
295
+ themes: @themes.map { |name, params| { name: name, params: deep_dup(params) } }
171
296
  }
297
+ definition[:timelines] = @timelines.map { |timeline| deep_dup(timeline) } unless @timelines.empty?
298
+ definition
172
299
  end
173
300
 
174
301
  private
@@ -179,6 +306,69 @@ module Vizcore
179
306
  end
180
307
  end
181
308
 
309
+ def normalize_audio_normalize(mode:, **options)
310
+ normalized_mode = mode.to_s.strip.to_sym
311
+ raise ArgumentError, "unsupported audio_normalize mode: #{mode}" unless %i[off adaptive].include?(normalized_mode)
312
+
313
+ settings = { mode: normalized_mode }
314
+ settings[:window] = positive_float(options[:window], "audio_normalize window") if options.key?(:window)
315
+ settings[:target] = unit_float(options[:target], "audio_normalize target") if options.key?(:target)
316
+ settings[:floor] = unit_float(options[:floor], "audio_normalize floor") if options.key?(:floor)
317
+ settings
318
+ end
319
+
320
+ def positive_integer(value, name)
321
+ numeric = Integer(value)
322
+ raise ArgumentError, "#{name} must be positive" unless numeric.positive?
323
+
324
+ numeric
325
+ end
326
+
327
+ def positive_float(value, name)
328
+ numeric = Float(value)
329
+ raise ArgumentError, "#{name} must be positive" unless numeric.positive?
330
+
331
+ numeric
332
+ end
333
+
334
+ def unit_float(value, name)
335
+ numeric = Float(value)
336
+ raise ArgumentError, "#{name} must be between 0.0 and 1.0" unless numeric.between?(0.0, 1.0)
337
+
338
+ numeric
339
+ end
340
+
341
+ def normalize_keyboard_key(value)
342
+ raw = value.to_s
343
+ return "space" if raw == " "
344
+
345
+ normalized = raw.strip.downcase
346
+ normalized = "space" if normalized == "spacebar"
347
+ raise ArgumentError, "key value must not be empty" if normalized.empty?
348
+
349
+ normalized
350
+ end
351
+
352
+ def add_section_transition(to:)
353
+ from = @section_tail.fetch(:name)
354
+ beats = @section_tail.fetch(:beats)
355
+ @transitions << {
356
+ from: from,
357
+ to: to,
358
+ trigger: proc { beat_count >= beats }
359
+ }
360
+ end
361
+
362
+ def inherited_layers(scene_name)
363
+ return [] if scene_name.nil?
364
+
365
+ normalized = scene_name.to_sym
366
+ base_scene = @scene_registry[normalized]
367
+ raise ArgumentError, "unknown base scene: #{normalized}" unless base_scene
368
+
369
+ deep_dup(base_scene.fetch(:layers))
370
+ end
371
+
182
372
  def deep_dup(value)
183
373
  case value
184
374
  when Hash
@@ -216,6 +406,31 @@ module Vizcore
216
406
  @trigger = block
217
407
  end
218
408
 
409
+ # Trigger after a scene-local beat count reaches the given value.
410
+ #
411
+ # @param count [Integer]
412
+ # @return [void]
413
+ def on_beat(count)
414
+ beat_target = Integer(count)
415
+ raise ArgumentError, "on_beat count must be positive" unless beat_target.positive?
416
+
417
+ @trigger = proc { beat_count >= beat_target }
418
+ end
419
+
420
+ # Trigger after a scene-local bar count reaches the given value.
421
+ #
422
+ # @param count [Integer]
423
+ # @param beats_per_bar [Integer]
424
+ # @return [void]
425
+ def on_bar(count, beats_per_bar: 4)
426
+ bar_target = Integer(count)
427
+ beats = Integer(beats_per_bar)
428
+ raise ArgumentError, "on_bar count must be positive" unless bar_target.positive?
429
+ raise ArgumentError, "beats_per_bar must be positive" unless beats.positive?
430
+
431
+ on_beat(bar_target * beats)
432
+ end
433
+
219
434
  # @return [Hash] serialized transition extras
220
435
  def to_h
221
436
  output = {}
@@ -224,6 +439,63 @@ module Vizcore
224
439
  output
225
440
  end
226
441
  end
442
+
443
+ # Builder object for `key` block internals.
444
+ # @api private
445
+ class KeyBindingBuilder
446
+ def initialize
447
+ @action = nil
448
+ end
449
+
450
+ # Switch to a named scene when the key is pressed.
451
+ #
452
+ # @param name [Symbol, String]
453
+ # @return [void]
454
+ def switch_scene(name)
455
+ scene_name = name.to_s.strip
456
+ raise ArgumentError, "switch_scene scene must not be empty" if scene_name.empty?
457
+
458
+ assign_action(type: :switch_scene, scene: scene_name)
459
+ end
460
+
461
+ # Toggle browser blackout output.
462
+ #
463
+ # @return [void]
464
+ def blackout
465
+ live_control(:blackout)
466
+ end
467
+
468
+ # Toggle browser freeze output.
469
+ #
470
+ # @return [void]
471
+ def freeze
472
+ live_control(:freeze)
473
+ end
474
+
475
+ # Toggle a browser live control.
476
+ #
477
+ # @param control [Symbol, String]
478
+ # @return [void]
479
+ def live_control(control)
480
+ normalized = control.to_s.strip.downcase.to_sym
481
+ raise ArgumentError, "unsupported live control: #{control}" unless %i[blackout freeze].include?(normalized)
482
+
483
+ assign_action(type: :live_control, control: normalized)
484
+ end
485
+
486
+ # @return [Hash] serialized key action
487
+ def to_h
488
+ @action || {}
489
+ end
490
+
491
+ private
492
+
493
+ def assign_action(action)
494
+ raise ArgumentError, "key mapping already has an action" if @action
495
+
496
+ @action = action
497
+ end
498
+ end
227
499
  end
228
500
  end
229
501
  end