vizcore 0.1.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 (71) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +170 -0
  4. data/docs/GETTING_STARTED.md +105 -0
  5. data/examples/assets/complex_demo_loop.wav +0 -0
  6. data/examples/basic.rb +9 -0
  7. data/examples/complex_audio_showcase.rb +261 -0
  8. data/examples/custom_shader.rb +21 -0
  9. data/examples/file_audio_demo.rb +74 -0
  10. data/examples/intro_drop.rb +38 -0
  11. data/examples/midi_scene_switch.rb +32 -0
  12. data/examples/shaders/custom_wave.frag +30 -0
  13. data/exe/vizcore +6 -0
  14. data/frontend/index.html +148 -0
  15. data/frontend/src/main.js +304 -0
  16. data/frontend/src/renderer/engine.js +135 -0
  17. data/frontend/src/renderer/layer-manager.js +456 -0
  18. data/frontend/src/renderer/shader-manager.js +69 -0
  19. data/frontend/src/shaders/builtins.js +244 -0
  20. data/frontend/src/shaders/post-effects.js +85 -0
  21. data/frontend/src/visuals/geometry.js +66 -0
  22. data/frontend/src/visuals/particle-system.js +148 -0
  23. data/frontend/src/visuals/text-renderer.js +143 -0
  24. data/frontend/src/visuals/vj-effects.js +56 -0
  25. data/frontend/src/websocket-client.js +131 -0
  26. data/lib/vizcore/analysis/band_splitter.rb +63 -0
  27. data/lib/vizcore/analysis/beat_detector.rb +70 -0
  28. data/lib/vizcore/analysis/bpm_estimator.rb +86 -0
  29. data/lib/vizcore/analysis/fft_processor.rb +224 -0
  30. data/lib/vizcore/analysis/fftw_ffi.rb +50 -0
  31. data/lib/vizcore/analysis/pipeline.rb +72 -0
  32. data/lib/vizcore/analysis/smoother.rb +74 -0
  33. data/lib/vizcore/analysis.rb +14 -0
  34. data/lib/vizcore/audio/base_input.rb +39 -0
  35. data/lib/vizcore/audio/dummy_sine_input.rb +40 -0
  36. data/lib/vizcore/audio/file_input.rb +163 -0
  37. data/lib/vizcore/audio/input_manager.rb +133 -0
  38. data/lib/vizcore/audio/mic_input.rb +121 -0
  39. data/lib/vizcore/audio/midi_input.rb +246 -0
  40. data/lib/vizcore/audio/portaudio_ffi.rb +243 -0
  41. data/lib/vizcore/audio/ring_buffer.rb +92 -0
  42. data/lib/vizcore/audio.rb +16 -0
  43. data/lib/vizcore/cli.rb +115 -0
  44. data/lib/vizcore/config.rb +46 -0
  45. data/lib/vizcore/dsl/engine.rb +229 -0
  46. data/lib/vizcore/dsl/file_watcher.rb +108 -0
  47. data/lib/vizcore/dsl/layer_builder.rb +182 -0
  48. data/lib/vizcore/dsl/mapping_resolver.rb +81 -0
  49. data/lib/vizcore/dsl/midi_map_executor.rb +188 -0
  50. data/lib/vizcore/dsl/scene_builder.rb +44 -0
  51. data/lib/vizcore/dsl/shader_source_resolver.rb +71 -0
  52. data/lib/vizcore/dsl/transition_controller.rb +166 -0
  53. data/lib/vizcore/dsl.rb +16 -0
  54. data/lib/vizcore/errors.rb +27 -0
  55. data/lib/vizcore/renderer/frame_scheduler.rb +75 -0
  56. data/lib/vizcore/renderer/scene_serializer.rb +73 -0
  57. data/lib/vizcore/renderer.rb +10 -0
  58. data/lib/vizcore/server/frame_broadcaster.rb +351 -0
  59. data/lib/vizcore/server/rack_app.rb +183 -0
  60. data/lib/vizcore/server/runner.rb +357 -0
  61. data/lib/vizcore/server/websocket_handler.rb +163 -0
  62. data/lib/vizcore/server.rb +12 -0
  63. data/lib/vizcore/templates/basic_scene.rb +10 -0
  64. data/lib/vizcore/templates/custom_shader_scene.rb +22 -0
  65. data/lib/vizcore/templates/custom_wave.frag +31 -0
  66. data/lib/vizcore/templates/intro_drop_scene.rb +40 -0
  67. data/lib/vizcore/templates/midi_control_scene.rb +33 -0
  68. data/lib/vizcore/templates/project_readme.md +35 -0
  69. data/lib/vizcore/version.rb +6 -0
  70. data/lib/vizcore.rb +37 -0
  71. metadata +186 -0
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+
5
+ module Vizcore
6
+ # Runtime configuration for CLI/server startup.
7
+ class Config
8
+ # Default host used by `vizcore start`.
9
+ DEFAULT_HOST = "127.0.0.1"
10
+ # Default HTTP/WebSocket port.
11
+ DEFAULT_PORT = 4567
12
+ # Default audio source.
13
+ DEFAULT_AUDIO_SOURCE = :mic
14
+ # Supported CLI audio source values.
15
+ SUPPORTED_AUDIO_SOURCES = %i[mic file dummy].freeze
16
+
17
+ attr_reader :host, :port, :scene_file, :audio_source, :audio_file
18
+
19
+ # @param scene_file [String, Pathname] scene DSL file path
20
+ # @param host [String] bind host
21
+ # @param port [Integer] bind port
22
+ # @param audio_source [Symbol, String] one of `:mic`, `:file`, `:dummy`
23
+ # @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)
25
+ @scene_file = Pathname.new(scene_file).expand_path if scene_file
26
+ @host = host
27
+ @port = Integer(port)
28
+ @audio_source = normalize_audio_source(audio_source)
29
+ @audio_file = audio_file ? Pathname.new(audio_file).expand_path : nil
30
+ end
31
+
32
+ # @return [Boolean] true when the configured scene file exists.
33
+ def scene_exists?
34
+ scene_file && scene_file.file?
35
+ end
36
+
37
+ private
38
+
39
+ def normalize_audio_source(value)
40
+ source = value.to_sym
41
+ return source if SUPPORTED_AUDIO_SOURCES.include?(source)
42
+
43
+ raise ArgumentError, "Unsupported audio source: #{value}. Use one of: #{SUPPORTED_AUDIO_SOURCES.join(', ')}"
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,229 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require_relative "file_watcher"
5
+ require_relative "scene_builder"
6
+
7
+ module Vizcore
8
+ module DSL
9
+ # Evaluates and stores scene definitions built with the Vizcore Ruby DSL.
10
+ class Engine
11
+ # Thread-local key used when evaluating scene files.
12
+ THREAD_KEY = :vizcore_current_dsl_engine
13
+
14
+ class << self
15
+ # Evaluate a DSL block using the current thread-local engine, or a new engine.
16
+ #
17
+ # @yield Scene/audio/midi DSL configuration block
18
+ # @return [Hash] serialized DSL definition
19
+ def define(&block)
20
+ engine = current || new
21
+ engine.evaluate(&block)
22
+ end
23
+
24
+ # Load and evaluate a scene file.
25
+ #
26
+ # @param path [String, Pathname] scene file path
27
+ # @raise [ArgumentError] when the scene file does not exist
28
+ # @return [Hash] serialized DSL definition
29
+ def load_file(path)
30
+ scene_path = Pathname.new(path.to_s).expand_path
31
+ raise ArgumentError, "Scene file not found: #{scene_path}" unless scene_path.file?
32
+
33
+ engine = new
34
+ with_current(engine) { Kernel.load(scene_path.to_s) }
35
+ engine.result
36
+ end
37
+
38
+ # Build a file watcher that reloads and yields definitions on change.
39
+ #
40
+ # @param path [String, Pathname] scene file path to watch
41
+ # @param poll_interval [Float] watcher poll interval in seconds
42
+ # @param listener_factory [#call, nil] optional listener factory for tests
43
+ # @yieldparam definition [Hash] reloaded DSL definition
44
+ # @yieldparam changed_path [Pathname] path reported by the watcher
45
+ # @return [Vizcore::DSL::FileWatcher]
46
+ def watch_file(path, poll_interval: FileWatcher::DEFAULT_POLL_INTERVAL, listener_factory: nil, &on_change)
47
+ FileWatcher.new(path: path, poll_interval: poll_interval, listener_factory: listener_factory) do |changed_path|
48
+ definition = load_file(changed_path.to_s)
49
+ on_change&.call(definition, changed_path)
50
+ end
51
+ end
52
+
53
+ # @return [Vizcore::DSL::Engine, nil] current thread-local DSL engine.
54
+ def current
55
+ Thread.current[THREAD_KEY]
56
+ end
57
+
58
+ private
59
+
60
+ def with_current(engine)
61
+ previous = current
62
+ Thread.current[THREAD_KEY] = engine
63
+ yield
64
+ ensure
65
+ Thread.current[THREAD_KEY] = previous
66
+ end
67
+ end
68
+
69
+ def initialize
70
+ @audio_inputs = []
71
+ @midi_inputs = []
72
+ @scenes = []
73
+ @transitions = []
74
+ @midi_mappings = []
75
+ @global_params = {}
76
+ end
77
+
78
+ # Evaluate DSL methods on this engine instance.
79
+ #
80
+ # @yield DSL configuration block
81
+ # @return [Hash] serialized DSL definition
82
+ def evaluate(&block)
83
+ instance_eval(&block) if block
84
+ result
85
+ end
86
+
87
+ # Register an audio input definition.
88
+ #
89
+ # @param name [Symbol, String] input name
90
+ # @param options [Hash] input options
91
+ # @return [void]
92
+ def audio(name, **options)
93
+ @audio_inputs << { name: name.to_sym, options: symbolize_keys(options) }
94
+ end
95
+
96
+ # Register a MIDI input definition.
97
+ #
98
+ # @param name [Symbol, String] input name
99
+ # @param options [Hash] input options
100
+ # @return [void]
101
+ def midi(name, **options)
102
+ @midi_inputs << { name: name.to_sym, options: symbolize_keys(options) }
103
+ end
104
+
105
+ # Define a scene and its layers.
106
+ #
107
+ # @param name [Symbol, String] scene identifier
108
+ # @yield Scene definition block
109
+ # @return [void]
110
+ def scene(name, &block)
111
+ builder = SceneBuilder.new(name: name)
112
+ builder.evaluate(&block)
113
+ @scenes << builder.to_h
114
+ end
115
+
116
+ # Define a transition between scenes.
117
+ #
118
+ # @param from [Symbol, String] source scene name
119
+ # @param to [Symbol, String] target scene name
120
+ # @yield Optional transition block (`effect`, `trigger`)
121
+ # @return [void]
122
+ def transition(from:, to:, &block)
123
+ definition = {
124
+ from: from.to_sym,
125
+ to: to.to_sym
126
+ }
127
+ builder = TransitionBuilder.new
128
+ builder.instance_eval(&block) if block
129
+ @transitions << definition.merge(builder.to_h)
130
+ end
131
+
132
+ # Register a MIDI trigger/action mapping.
133
+ #
134
+ # @param note [Integer, nil] note number trigger
135
+ # @param cc [Integer, nil] control-change trigger
136
+ # @param pc [Integer, nil] program-change trigger
137
+ # @yield Action block executed by midi runtime
138
+ # @raise [ArgumentError] when no trigger is supplied
139
+ # @return [void]
140
+ def midi_map(note: nil, cc: nil, pc: nil, &block)
141
+ trigger = {}
142
+ trigger[:note] = Integer(note) unless note.nil?
143
+ trigger[:cc] = Integer(cc) unless cc.nil?
144
+ trigger[:pc] = Integer(pc) unless pc.nil?
145
+ raise ArgumentError, "midi_map requires note, cc or pc" if trigger.empty?
146
+
147
+ @midi_mappings << {
148
+ trigger: trigger,
149
+ action: block
150
+ }
151
+ end
152
+
153
+ # Set a mutable global value shared with scene/runtime logic.
154
+ #
155
+ # @param key [Symbol, String] global key
156
+ # @param value [Object] global value
157
+ # @return [Object] assigned value
158
+ def set(key, value)
159
+ @global_params[key.to_sym] = value
160
+ end
161
+
162
+ # @return [Hash] deep-copied definition payload for renderer/runtime.
163
+ def result
164
+ {
165
+ audio: @audio_inputs.map { |item| deep_dup(item) },
166
+ midi: @midi_inputs.map { |item| deep_dup(item) },
167
+ scenes: @scenes.map { |scene| deep_dup(scene) },
168
+ transitions: @transitions.map { |transition| deep_dup(transition) },
169
+ midi_maps: @midi_mappings.map { |mapping| deep_dup(mapping) },
170
+ globals: deep_dup(@global_params)
171
+ }
172
+ end
173
+
174
+ private
175
+
176
+ def symbolize_keys(hash)
177
+ hash.each_with_object({}) do |(key, value), output|
178
+ output[key.to_sym] = value
179
+ end
180
+ end
181
+
182
+ def deep_dup(value)
183
+ case value
184
+ when Hash
185
+ value.each_with_object({}) do |(key, entry), output|
186
+ output[key] = deep_dup(entry)
187
+ end
188
+ when Array
189
+ value.map { |entry| deep_dup(entry) }
190
+ else
191
+ value
192
+ end
193
+ end
194
+
195
+ # Builder object for `transition` block internals.
196
+ # @api private
197
+ class TransitionBuilder
198
+ def initialize
199
+ @effect = nil
200
+ @trigger = nil
201
+ end
202
+
203
+ # @param name [Symbol, String] transition effect name
204
+ # @param options [Hash] effect options
205
+ # @return [void]
206
+ def effect(name, **options)
207
+ @effect = {
208
+ name: name.to_sym,
209
+ options: options.each_with_object({}) { |(key, value), output| output[key.to_sym] = value }
210
+ }
211
+ end
212
+
213
+ # @yield Trigger predicate executed in transition context
214
+ # @return [void]
215
+ def trigger(&block)
216
+ @trigger = block
217
+ end
218
+
219
+ # @return [Hash] serialized transition extras
220
+ def to_h
221
+ output = {}
222
+ output[:effect] = @effect if @effect
223
+ output[:trigger] = @trigger if @trigger
224
+ output
225
+ end
226
+ end
227
+ end
228
+ end
229
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+
5
+ module Vizcore
6
+ module DSL
7
+ # Watches one scene file and invokes a callback when it changes.
8
+ class FileWatcher
9
+ # Default polling interval in seconds when `listen` is unavailable.
10
+ DEFAULT_POLL_INTERVAL = 0.25
11
+
12
+ # @param path [String, Pathname]
13
+ # @param poll_interval [Float]
14
+ # @param listener_factory [#call, nil]
15
+ # @yieldparam changed_path [Pathname]
16
+ def initialize(path:, poll_interval: DEFAULT_POLL_INTERVAL, listener_factory: nil, &on_change)
17
+ @path = Pathname.new(path.to_s).expand_path
18
+ @poll_interval = Float(poll_interval)
19
+ @listener_factory = listener_factory
20
+ @on_change = on_change
21
+ @running = false
22
+ @listener = nil
23
+ @thread = nil
24
+ end
25
+
26
+ # @return [Boolean]
27
+ def start
28
+ return if running?
29
+
30
+ @running = true
31
+ start_with_listener || start_with_polling
32
+ end
33
+
34
+ # @param timeout [Float]
35
+ # @return [void]
36
+ def stop(timeout: 1.0)
37
+ return unless running?
38
+
39
+ @running = false
40
+ @listener&.stop
41
+ @listener = nil
42
+
43
+ thread = @thread
44
+ @thread = nil
45
+ return unless thread
46
+ return if thread == Thread.current
47
+
48
+ thread.join(timeout)
49
+ end
50
+
51
+ # @return [Boolean]
52
+ def running?
53
+ @running
54
+ end
55
+
56
+ private
57
+
58
+ def start_with_listener
59
+ factory = @listener_factory || default_listener_factory
60
+ return false unless factory
61
+
62
+ file_pattern = /\A#{Regexp.escape(@path.basename.to_s)}\z/
63
+ @listener = factory.call(@path.dirname.to_s, file_pattern) do |modified, added, _removed|
64
+ changed = (Array(modified) + Array(added)).map { |entry| Pathname.new(entry.to_s).expand_path }
65
+ next unless changed.include?(@path)
66
+
67
+ @on_change&.call(@path)
68
+ end
69
+ @listener.start
70
+ true
71
+ rescue StandardError
72
+ @listener = nil
73
+ false
74
+ end
75
+
76
+ def start_with_polling
77
+ @thread = Thread.new { poll_loop }
78
+ end
79
+
80
+ def poll_loop
81
+ last_mtime = file_mtime
82
+
83
+ while running?
84
+ sleep(@poll_interval)
85
+ current_mtime = file_mtime
86
+ changed = !current_mtime.nil? && (last_mtime.nil? || current_mtime > last_mtime)
87
+ if changed
88
+ @on_change&.call(@path)
89
+ last_mtime = current_mtime
90
+ end
91
+ end
92
+ end
93
+
94
+ def file_mtime
95
+ return nil unless @path.file?
96
+
97
+ @path.mtime
98
+ end
99
+
100
+ def default_listener_factory
101
+ require "listen"
102
+ ->(directory, pattern, &block) { Listen.to(directory, only: pattern, &block) }
103
+ rescue LoadError
104
+ nil
105
+ end
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,182 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vizcore
4
+ module DSL
5
+ # Builder for one render layer in a scene.
6
+ class LayerBuilder
7
+ # @param name [Symbol, String] layer identifier
8
+ def initialize(name:)
9
+ @name = name.to_sym
10
+ @type = nil
11
+ @shader = nil
12
+ @glsl = nil
13
+ @params = {}
14
+ @mappings = []
15
+ end
16
+
17
+ # Evaluate a layer block.
18
+ #
19
+ # @yield Layer DSL methods
20
+ # @return [Vizcore::DSL::LayerBuilder]
21
+ def evaluate(&block)
22
+ instance_eval(&block) if block
23
+ self
24
+ end
25
+
26
+ # @param value [Symbol, String] layer type (`shader`, `particle_field`, etc.)
27
+ # @return [Symbol]
28
+ def type(value)
29
+ @type = value.to_sym
30
+ end
31
+
32
+ # @param value [Symbol, String] built-in shader key
33
+ # @return [Symbol]
34
+ def shader(value)
35
+ @shader = value.to_sym
36
+ @type ||= :shader
37
+ end
38
+
39
+ # @param path [String, Pathname] custom fragment shader path
40
+ # @return [String]
41
+ def glsl(path)
42
+ @glsl = path.to_s
43
+ @type ||= :shader
44
+ end
45
+
46
+ # @param value [Integer] particle count or similar numeric parameter
47
+ # @return [Integer]
48
+ def count(value)
49
+ @params[:count] = Integer(value)
50
+ end
51
+
52
+ # @param value [String] text content
53
+ # @return [String]
54
+ def content(value)
55
+ @params[:content] = value.to_s
56
+ end
57
+
58
+ # @param value [Integer] font size in pixels
59
+ # @return [Integer]
60
+ def font_size(value)
61
+ @params[:font_size] = Integer(value)
62
+ end
63
+
64
+ # Map analysis source(s) to layer parameter target(s).
65
+ #
66
+ # @param definition [Hash] mapping pairs (`source` => `target`)
67
+ # @raise [ArgumentError] when the mapping is empty or invalid
68
+ # @return [void]
69
+ def map(definition)
70
+ mapping = Hash(definition)
71
+ raise ArgumentError, "map requires at least one mapping pair" if mapping.empty?
72
+
73
+ mapping.each do |source, target|
74
+ @mappings << {
75
+ source: normalize_source(source),
76
+ target: target.to_sym
77
+ }
78
+ end
79
+ end
80
+
81
+ # @return [Hash] source descriptor for overall amplitude
82
+ def amplitude
83
+ source(:amplitude)
84
+ end
85
+
86
+ # @param name [Symbol, String] band key (`sub`, `low`, `mid`, `high`)
87
+ # @return [Hash] source descriptor for a frequency band
88
+ def frequency_band(name)
89
+ source(:frequency_band, band: name.to_sym)
90
+ end
91
+
92
+ # @return [Hash] source descriptor for FFT spectrum array
93
+ def fft_spectrum
94
+ source(:fft_spectrum)
95
+ end
96
+
97
+ # @return [Hash] source descriptor for beat trigger
98
+ def beat?
99
+ source(:beat)
100
+ end
101
+
102
+ # @return [Hash] source descriptor for beat counter
103
+ def beat_count
104
+ source(:beat_count)
105
+ end
106
+
107
+ # @return [Hash] source descriptor for estimated BPM
108
+ def bpm
109
+ source(:bpm)
110
+ end
111
+
112
+ # @return [Hash] serialized layer payload
113
+ def to_h
114
+ layer = {
115
+ name: @name,
116
+ type: resolved_type,
117
+ params: @params.dup
118
+ }
119
+ layer[:shader] = @shader if @shader
120
+ layer[:glsl] = @glsl if @glsl
121
+ layer[:mappings] = @mappings.map { |mapping| mapping.dup } unless @mappings.empty?
122
+ layer
123
+ end
124
+
125
+ # Stores dynamic one-argument setters into `params`.
126
+ # @api private
127
+ def method_missing(method_name, *args, &block)
128
+ if block.nil? && args.length == 1
129
+ @params[method_name.to_sym] = args.first
130
+ return args.first
131
+ end
132
+
133
+ super
134
+ end
135
+
136
+ def respond_to_missing?(method_name, include_private = false)
137
+ @params.key?(method_name.to_sym) || super
138
+ end
139
+
140
+ private
141
+
142
+ def resolved_type
143
+ return @type if @type
144
+ return :shader if @shader || @glsl
145
+
146
+ :geometry
147
+ end
148
+
149
+ def normalize_source(source_value)
150
+ case source_value
151
+ when Hash
152
+ kind = source_value[:kind] || source_value["kind"]
153
+ raise ArgumentError, "mapping source hash must contain :kind" unless kind
154
+
155
+ source(kind.to_sym, **normalize_source_options(source_value))
156
+ when Symbol
157
+ source(source_value)
158
+ when String
159
+ source(source_value.to_sym)
160
+ else
161
+ raise ArgumentError, "unsupported mapping source: #{source_value.inspect}"
162
+ end
163
+ end
164
+
165
+ def normalize_source_options(source_value)
166
+ source_value.each_with_object({}) do |(key, value), options|
167
+ symbol_key = key.to_sym
168
+ next if symbol_key == :kind
169
+
170
+ options[symbol_key] = value.respond_to?(:to_sym) ? value.to_sym : value
171
+ end
172
+ end
173
+
174
+ def source(kind, **options)
175
+ {
176
+ kind: kind.to_sym,
177
+ **options
178
+ }
179
+ end
180
+ end
181
+ end
182
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vizcore
4
+ module DSL
5
+ # Resolves `map` definitions into concrete per-layer parameter values.
6
+ class MappingResolver
7
+ # @param scene_layers [Array<Hash>]
8
+ # @param audio [Hash]
9
+ # @return [Array<Hash>] normalized layer payloads with resolved params
10
+ def resolve_layers(scene_layers:, audio:)
11
+ normalize_scene_layers(scene_layers).map do |layer|
12
+ resolve_layer(layer, audio)
13
+ end
14
+ end
15
+
16
+ private
17
+
18
+ def resolve_layer(layer, audio)
19
+ params = (layer[:params] || {}).dup
20
+ params.merge!(resolve_mappings(layer[:mappings], audio))
21
+
22
+ output = {
23
+ name: layer.fetch(:name).to_s,
24
+ type: (layer[:type] || :geometry).to_s,
25
+ params: params
26
+ }
27
+ output[:shader] = layer[:shader].to_s if layer[:shader]
28
+ output[:glsl] = layer[:glsl].to_s if layer[:glsl]
29
+ output[:glsl_source] = layer[:glsl_source].to_s if layer[:glsl_source]
30
+ output
31
+ end
32
+
33
+ def resolve_mappings(mappings, audio)
34
+ Array(mappings).each_with_object({}) do |mapping, resolved|
35
+ source = mapping[:source]
36
+ target = mapping[:target]
37
+ next unless source && target
38
+
39
+ value = resolve_source_value(source, audio)
40
+ resolved[target.to_sym] = value unless value.nil?
41
+ end
42
+ end
43
+
44
+ def resolve_source_value(source, audio)
45
+ case source[:kind]&.to_sym
46
+ when :amplitude
47
+ audio[:amplitude]
48
+ when :frequency_band
49
+ audio.dig(:bands, source[:band]&.to_sym)
50
+ when :fft_spectrum
51
+ audio[:fft]
52
+ when :beat
53
+ audio[:beat]
54
+ when :beat_count
55
+ audio[:beat_count]
56
+ when :bpm
57
+ audio[:bpm]
58
+ else
59
+ nil
60
+ end
61
+ end
62
+
63
+ def normalize_scene_layers(scene_layers)
64
+ Array(scene_layers).map { |layer| deep_symbolize(layer) }
65
+ end
66
+
67
+ def deep_symbolize(value)
68
+ case value
69
+ when Hash
70
+ value.each_with_object({}) do |(key, entry), output|
71
+ output[key.to_sym] = deep_symbolize(entry)
72
+ end
73
+ when Array
74
+ value.map { |entry| deep_symbolize(entry) }
75
+ else
76
+ value
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end