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.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +170 -0
- data/docs/GETTING_STARTED.md +105 -0
- data/examples/assets/complex_demo_loop.wav +0 -0
- data/examples/basic.rb +9 -0
- data/examples/complex_audio_showcase.rb +261 -0
- data/examples/custom_shader.rb +21 -0
- data/examples/file_audio_demo.rb +74 -0
- data/examples/intro_drop.rb +38 -0
- data/examples/midi_scene_switch.rb +32 -0
- data/examples/shaders/custom_wave.frag +30 -0
- data/exe/vizcore +6 -0
- data/frontend/index.html +148 -0
- data/frontend/src/main.js +304 -0
- data/frontend/src/renderer/engine.js +135 -0
- data/frontend/src/renderer/layer-manager.js +456 -0
- data/frontend/src/renderer/shader-manager.js +69 -0
- data/frontend/src/shaders/builtins.js +244 -0
- data/frontend/src/shaders/post-effects.js +85 -0
- data/frontend/src/visuals/geometry.js +66 -0
- data/frontend/src/visuals/particle-system.js +148 -0
- data/frontend/src/visuals/text-renderer.js +143 -0
- data/frontend/src/visuals/vj-effects.js +56 -0
- data/frontend/src/websocket-client.js +131 -0
- data/lib/vizcore/analysis/band_splitter.rb +63 -0
- data/lib/vizcore/analysis/beat_detector.rb +70 -0
- data/lib/vizcore/analysis/bpm_estimator.rb +86 -0
- data/lib/vizcore/analysis/fft_processor.rb +224 -0
- data/lib/vizcore/analysis/fftw_ffi.rb +50 -0
- data/lib/vizcore/analysis/pipeline.rb +72 -0
- data/lib/vizcore/analysis/smoother.rb +74 -0
- data/lib/vizcore/analysis.rb +14 -0
- data/lib/vizcore/audio/base_input.rb +39 -0
- data/lib/vizcore/audio/dummy_sine_input.rb +40 -0
- data/lib/vizcore/audio/file_input.rb +163 -0
- data/lib/vizcore/audio/input_manager.rb +133 -0
- data/lib/vizcore/audio/mic_input.rb +121 -0
- data/lib/vizcore/audio/midi_input.rb +246 -0
- data/lib/vizcore/audio/portaudio_ffi.rb +243 -0
- data/lib/vizcore/audio/ring_buffer.rb +92 -0
- data/lib/vizcore/audio.rb +16 -0
- data/lib/vizcore/cli.rb +115 -0
- data/lib/vizcore/config.rb +46 -0
- data/lib/vizcore/dsl/engine.rb +229 -0
- data/lib/vizcore/dsl/file_watcher.rb +108 -0
- data/lib/vizcore/dsl/layer_builder.rb +182 -0
- data/lib/vizcore/dsl/mapping_resolver.rb +81 -0
- data/lib/vizcore/dsl/midi_map_executor.rb +188 -0
- data/lib/vizcore/dsl/scene_builder.rb +44 -0
- data/lib/vizcore/dsl/shader_source_resolver.rb +71 -0
- data/lib/vizcore/dsl/transition_controller.rb +166 -0
- data/lib/vizcore/dsl.rb +16 -0
- data/lib/vizcore/errors.rb +27 -0
- data/lib/vizcore/renderer/frame_scheduler.rb +75 -0
- data/lib/vizcore/renderer/scene_serializer.rb +73 -0
- data/lib/vizcore/renderer.rb +10 -0
- data/lib/vizcore/server/frame_broadcaster.rb +351 -0
- data/lib/vizcore/server/rack_app.rb +183 -0
- data/lib/vizcore/server/runner.rb +357 -0
- data/lib/vizcore/server/websocket_handler.rb +163 -0
- data/lib/vizcore/server.rb +12 -0
- data/lib/vizcore/templates/basic_scene.rb +10 -0
- data/lib/vizcore/templates/custom_shader_scene.rb +22 -0
- data/lib/vizcore/templates/custom_wave.frag +31 -0
- data/lib/vizcore/templates/intro_drop_scene.rb +40 -0
- data/lib/vizcore/templates/midi_control_scene.rb +33 -0
- data/lib/vizcore/templates/project_readme.md +35 -0
- data/lib/vizcore/version.rb +6 -0
- data/lib/vizcore.rb +37 -0
- 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
|