vizcore 1.1.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/frontend/index.html +24 -2
- data/frontend/src/audio-inspector.js +9 -0
- data/frontend/src/live-controls.js +219 -7
- data/frontend/src/main.js +447 -57
- data/frontend/src/midi-learn.js +22 -2
- data/frontend/src/performance-monitor.js +137 -1
- data/frontend/src/renderer/engine.js +391 -10
- data/frontend/src/renderer/layer-manager.js +472 -71
- data/frontend/src/runtime-control-preset.js +44 -0
- data/frontend/src/scene-patches.js +159 -0
- data/frontend/src/shader-error-overlay.js +1 -0
- data/frontend/src/visuals/image-renderer.js +19 -0
- data/frontend/src/visuals/particle-system.js +10 -0
- data/frontend/src/visuals/shape-renderer.js +13 -0
- data/frontend/src/visuals/spectrogram-renderer.js +14 -0
- data/frontend/src/visuals/text-renderer.js +13 -0
- data/frontend/src/websocket-client.js +6 -0
- data/lib/vizcore/analysis/adaptive_normalizer.rb +20 -2
- data/lib/vizcore/analysis/bpm_estimator.rb +18 -8
- data/lib/vizcore/analysis/feature_recorder.rb +117 -7
- data/lib/vizcore/analysis/feature_replay.rb +48 -9
- data/lib/vizcore/analysis/pipeline.rb +258 -9
- data/lib/vizcore/analysis/tap_tempo.rb +17 -2
- data/lib/vizcore/audio/calibration.rb +156 -0
- data/lib/vizcore/audio/file_input.rb +28 -0
- data/lib/vizcore/audio/input_manager.rb +36 -1
- data/lib/vizcore/audio/midi_input.rb +5 -0
- data/lib/vizcore/audio/ring_buffer.rb +22 -0
- data/lib/vizcore/audio.rb +1 -0
- data/lib/vizcore/cli/dsl_reference.rb +64 -8
- data/lib/vizcore/cli/plugin_checker.rb +93 -0
- data/lib/vizcore/cli/scene_diagnostics.rb +2 -2
- data/lib/vizcore/cli/scene_inspector.rb +35 -1
- data/lib/vizcore/cli/scene_validator.rb +487 -39
- data/lib/vizcore/cli/shader_template.rb +7 -2
- data/lib/vizcore/cli/shader_uniform_docs.rb +11 -0
- data/lib/vizcore/cli.rb +268 -15
- data/lib/vizcore/config.rb +40 -3
- data/lib/vizcore/control_preset.rb +29 -0
- data/lib/vizcore/deep_copy.rb +21 -0
- data/lib/vizcore/dsl/color_helpers.rb +155 -0
- data/lib/vizcore/dsl/engine.rb +219 -23
- data/lib/vizcore/dsl/layer_builder.rb +278 -15
- data/lib/vizcore/dsl/layer_group_builder.rb +10 -12
- data/lib/vizcore/dsl/layout_helpers.rb +290 -0
- data/lib/vizcore/dsl/mapping_preset_builder.rb +41 -0
- data/lib/vizcore/dsl/mapping_resolver.rb +404 -22
- data/lib/vizcore/dsl/mapping_transform_builder.rb +50 -0
- data/lib/vizcore/dsl/midi_map_executor.rb +219 -23
- data/lib/vizcore/dsl/reaction_builder.rb +1 -0
- data/lib/vizcore/dsl/scene_builder.rb +83 -13
- data/lib/vizcore/dsl/shader_source_resolver.rb +1 -10
- data/lib/vizcore/dsl/style_builder.rb +3 -0
- data/lib/vizcore/dsl/timeline_builder.rb +91 -8
- data/lib/vizcore/dsl/transition_controller.rb +157 -18
- data/lib/vizcore/dsl.rb +2 -0
- data/lib/vizcore/layer_catalog.rb +1 -0
- data/lib/vizcore/plugin_asset_policy.rb +55 -0
- data/lib/vizcore/project_manifest.rb +12 -2
- data/lib/vizcore/renderer/render_sequence.rb +104 -13
- data/lib/vizcore/renderer/scene_frame_source.rb +179 -14
- data/lib/vizcore/renderer/scene_serializer.rb +38 -0
- data/lib/vizcore/renderer/snapshot.rb +4 -3
- data/lib/vizcore/renderer/snapshot_renderer.rb +134 -8
- data/lib/vizcore/scene_trust.rb +31 -0
- data/lib/vizcore/server/frame_broadcaster.rb +469 -23
- data/lib/vizcore/server/rack_app.rb +151 -4
- data/lib/vizcore/server/runner.rb +676 -82
- data/lib/vizcore/server/websocket_handler.rb +236 -14
- data/lib/vizcore/server.rb +21 -0
- data/lib/vizcore/shape.rb +39 -16
- data/lib/vizcore/sync/osc_message.rb +66 -9
- data/lib/vizcore/version.rb +1 -1
- data/lib/vizcore.rb +33 -0
- data/scripts/browser_capture.mjs +31 -2
- data/sig/vizcore.rbs +55 -4
- metadata +18 -3
|
@@ -50,13 +50,42 @@ module Vizcore
|
|
|
50
50
|
input = value.is_a?(Hash) ? value : {}
|
|
51
51
|
visual_settings = hash_value(input, "visual_settings", "visualSettings", "settings")
|
|
52
52
|
midi_learn_bindings = hash_value(input, "midi_learn_bindings", "midiLearnBindings", "midi")
|
|
53
|
+
scene_overrides = hash_value(input, "scene_overrides", "sceneOverrides")
|
|
53
54
|
|
|
54
55
|
{}.tap do |payload|
|
|
55
56
|
payload["visual_settings"] = visual_settings if visual_settings
|
|
56
57
|
payload["midi_learn_bindings"] = midi_learn_bindings if midi_learn_bindings
|
|
58
|
+
normalized_scene_overrides = normalize_scene_overrides(scene_overrides)
|
|
59
|
+
payload["scene_overrides"] = normalized_scene_overrides if normalized_scene_overrides
|
|
57
60
|
end
|
|
58
61
|
end
|
|
59
62
|
|
|
63
|
+
def normalize_scene_overrides(value)
|
|
64
|
+
return nil unless value
|
|
65
|
+
raw_overrides = value.is_a?(Hash) ? value : {}
|
|
66
|
+
normalized = {}
|
|
67
|
+
|
|
68
|
+
raw_overrides.each do |raw_scene, raw_override|
|
|
69
|
+
scene_name = raw_scene.to_s.strip
|
|
70
|
+
next if scene_name.empty?
|
|
71
|
+
|
|
72
|
+
scene_override = normalize_scene_override(raw_override)
|
|
73
|
+
next if scene_override.empty?
|
|
74
|
+
|
|
75
|
+
normalized[scene_name] = scene_override
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
normalized.empty? ? nil : normalized
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def normalize_scene_override(value)
|
|
82
|
+
input = value.is_a?(Hash) ? value : {}
|
|
83
|
+
{
|
|
84
|
+
"visual_settings" => hash_value(input, "visual_settings", "visualSettings"),
|
|
85
|
+
"midi_learn_bindings" => hash_value(input, "midi_learn_bindings", "midiLearnBindings", "midi")
|
|
86
|
+
}.select { |_, entry| entry }
|
|
87
|
+
end
|
|
88
|
+
|
|
60
89
|
def hash_value(input, *keys)
|
|
61
90
|
keys.each do |key|
|
|
62
91
|
value = input[key] || input[key.to_sym]
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Vizcore
|
|
4
|
+
# Small recursive copier for plain payload hashes/arrays passed between DSL, server, and renderer.
|
|
5
|
+
module DeepCopy
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
# @param value [Object]
|
|
9
|
+
# @return [Object]
|
|
10
|
+
def copy(value)
|
|
11
|
+
case value
|
|
12
|
+
when Hash
|
|
13
|
+
value.each_with_object({}) { |(key, entry), output| output[key] = copy(entry) }
|
|
14
|
+
when Array
|
|
15
|
+
value.map { |entry| copy(entry) }
|
|
16
|
+
else
|
|
17
|
+
value
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Vizcore
|
|
4
|
+
module DSL
|
|
5
|
+
# Shared color helper methods for Ruby DSL builders.
|
|
6
|
+
module ColorHelpers
|
|
7
|
+
def hsl(hue, saturation, lightness)
|
|
8
|
+
rgb = hsl_to_rgb(Float(hue), Float(saturation), Float(lightness))
|
|
9
|
+
format("#%02x%02x%02x", *rgb)
|
|
10
|
+
rescue ArgumentError, TypeError
|
|
11
|
+
raise ArgumentError, "hsl requires numeric hue, saturation, and lightness"
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def hsv(hue, saturation, value)
|
|
15
|
+
rgb = hsv_to_rgb(Float(hue), Float(saturation), Float(value))
|
|
16
|
+
format("#%02x%02x%02x", *rgb)
|
|
17
|
+
rescue ArgumentError, TypeError
|
|
18
|
+
raise ArgumentError, "hsv requires numeric hue, saturation, and value"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Build a reusable gradient descriptor for layer color fields.
|
|
22
|
+
#
|
|
23
|
+
# @param type [Symbol, String] :linear, :radial, or a supported gradient kind
|
|
24
|
+
# @param colors [Array<String>] at least two hex-like color values
|
|
25
|
+
# @param stops [Array<Numeric>, nil] optional stop points in 0.0..1.0
|
|
26
|
+
# @param position [Numeric, nil] optional fixed position in 0.0..1.0
|
|
27
|
+
# @return [Hash]
|
|
28
|
+
def gradient(type: :linear, colors:, stops: nil, position: nil)
|
|
29
|
+
gradient_type = normalize_gradient_type(type)
|
|
30
|
+
normalized_colors = normalize_colors_for_gradient(colors)
|
|
31
|
+
raise ArgumentError, "gradient requires at least two colors" if normalized_colors.length < 2
|
|
32
|
+
|
|
33
|
+
stops = normalize_gradient_stops(stops, normalized_colors.length)
|
|
34
|
+
|
|
35
|
+
descriptor = {
|
|
36
|
+
type: gradient_type,
|
|
37
|
+
colors: normalized_colors
|
|
38
|
+
}
|
|
39
|
+
descriptor[:stops] = stops if stops
|
|
40
|
+
descriptor[:position] = normalize_gradient_position(position) unless position.nil?
|
|
41
|
+
{ gradient: descriptor }
|
|
42
|
+
rescue ArgumentError, TypeError
|
|
43
|
+
raise
|
|
44
|
+
rescue StandardError
|
|
45
|
+
raise ArgumentError, "gradient requires numeric or parseable color values"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def hsl_to_rgb(hue, saturation, lightness)
|
|
51
|
+
hue = hue % 360.0
|
|
52
|
+
saturation = normalize_percent_value(saturation)
|
|
53
|
+
lightness = normalize_percent_value(lightness)
|
|
54
|
+
|
|
55
|
+
return [0, 0, 0] if saturation.zero?
|
|
56
|
+
|
|
57
|
+
q = lightness < 0.5 ? lightness * (1.0 + saturation) : lightness + saturation - (lightness * saturation)
|
|
58
|
+
p = 2.0 * lightness - q
|
|
59
|
+
h = hue / 360.0
|
|
60
|
+
|
|
61
|
+
[
|
|
62
|
+
hue_channel_to_rgb(h + 1.0 / 3.0, p, q),
|
|
63
|
+
hue_channel_to_rgb(h, p, q),
|
|
64
|
+
hue_channel_to_rgb(h - 1.0 / 3.0, p, q)
|
|
65
|
+
]
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def hue_channel_to_rgb(value, p, q)
|
|
69
|
+
value += 1.0 while value < 0.0
|
|
70
|
+
value -= 1.0 while value > 1.0
|
|
71
|
+
|
|
72
|
+
channel =
|
|
73
|
+
if value < 1.0 / 6.0
|
|
74
|
+
p + (q - p) * 6.0 * value
|
|
75
|
+
elsif value < 1.0 / 2.0
|
|
76
|
+
q
|
|
77
|
+
elsif value < 2.0 / 3.0
|
|
78
|
+
p + (q - p) * (2.0 / 3.0 - value) * 6.0
|
|
79
|
+
else
|
|
80
|
+
p
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
(channel * 255.0).round.clamp(0, 255)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def hsv_to_rgb(hue, saturation, value)
|
|
87
|
+
hue = hue % 360.0
|
|
88
|
+
saturation = normalize_percent_value(saturation)
|
|
89
|
+
value = normalize_percent_value(value)
|
|
90
|
+
return [0, 0, 0] if saturation.zero?
|
|
91
|
+
|
|
92
|
+
sector = hue / 60.0
|
|
93
|
+
i = sector.floor.to_i
|
|
94
|
+
f = sector - i
|
|
95
|
+
p = value * (1.0 - saturation)
|
|
96
|
+
q = value * (1.0 - f * saturation)
|
|
97
|
+
t = value * (1.0 - (1.0 - f) * saturation)
|
|
98
|
+
|
|
99
|
+
[
|
|
100
|
+
[value, t, p],
|
|
101
|
+
[q, value, p],
|
|
102
|
+
[p, value, t],
|
|
103
|
+
[p, q, value],
|
|
104
|
+
[t, p, value],
|
|
105
|
+
[value, p, q]
|
|
106
|
+
][i % 6].map { |channel| (channel * 255.0).round.clamp(0, 255) }
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def normalize_percent_value(value)
|
|
110
|
+
normalized = Float(value)
|
|
111
|
+
return normalized / 100.0 if normalized > 1.0
|
|
112
|
+
|
|
113
|
+
normalized.clamp(0.0, 1.0)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def normalize_gradient_type(value)
|
|
117
|
+
symbol = value.to_s.strip.downcase
|
|
118
|
+
raise ArgumentError, "gradient type must be linear or radial" if symbol.empty?
|
|
119
|
+
|
|
120
|
+
case symbol
|
|
121
|
+
when "linear", "radial"
|
|
122
|
+
symbol
|
|
123
|
+
else
|
|
124
|
+
raise ArgumentError, "unsupported gradient type: #{value}"
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def normalize_colors_for_gradient(colors)
|
|
129
|
+
values = Array(colors).flatten.map { |color| color.to_s.strip }.reject(&:empty?)
|
|
130
|
+
raise ArgumentError, "gradient requires at least two colors" if values.empty?
|
|
131
|
+
|
|
132
|
+
values.each do |value|
|
|
133
|
+
next if value.match?(/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/)
|
|
134
|
+
raise ArgumentError, "gradient colors must be hex strings"
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
values
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def normalize_gradient_stops(stops, color_count)
|
|
141
|
+
return nil if stops.nil?
|
|
142
|
+
|
|
143
|
+
values = Array(stops).map { |value| Float(value) }
|
|
144
|
+
raise ArgumentError, "gradient stops must match color count" if values.length != color_count
|
|
145
|
+
|
|
146
|
+
values
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def normalize_gradient_position(value)
|
|
150
|
+
value = Float(value)
|
|
151
|
+
value.clamp(0.0, 1.0)
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
data/lib/vizcore/dsl/engine.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "pathname"
|
|
4
|
+
require_relative "../deep_copy"
|
|
4
5
|
require_relative "file_watcher"
|
|
5
6
|
require_relative "scene_builder"
|
|
6
7
|
require_relative "style_builder"
|
|
@@ -76,12 +77,15 @@ module Vizcore
|
|
|
76
77
|
@midi_mappings = []
|
|
77
78
|
@key_mappings = []
|
|
78
79
|
@global_params = {}
|
|
80
|
+
@mapping_presets = {}
|
|
79
81
|
@analysis_settings = {}
|
|
80
82
|
@section_tail = nil
|
|
81
83
|
@timelines = []
|
|
82
84
|
@styles = {}
|
|
83
85
|
@themes = {}
|
|
84
86
|
@scene_registry = {}
|
|
87
|
+
@strict = false
|
|
88
|
+
@seed = nil
|
|
85
89
|
end
|
|
86
90
|
|
|
87
91
|
# Evaluate DSL methods on this engine instance.
|
|
@@ -124,6 +128,17 @@ module Vizcore
|
|
|
124
128
|
@themes[theme_definition[:name]] = deep_dup(theme_definition[:params])
|
|
125
129
|
end
|
|
126
130
|
|
|
131
|
+
# Register reusable mapping behavior for layer-level targets.
|
|
132
|
+
#
|
|
133
|
+
# @param name [Symbol, String] mapping preset identifier
|
|
134
|
+
# @yield Mapping preset block
|
|
135
|
+
# @return [void]
|
|
136
|
+
def mapping(name, &block)
|
|
137
|
+
builder = MappingPresetBuilder.new(name: name, strict: @strict)
|
|
138
|
+
preset_definition = builder.evaluate(&block).to_h
|
|
139
|
+
@mapping_presets[preset_definition[:name]] = deep_dup(preset_definition[:mappings])
|
|
140
|
+
end
|
|
141
|
+
|
|
127
142
|
# Register a MIDI input definition.
|
|
128
143
|
#
|
|
129
144
|
# @param name [Symbol, String] input name
|
|
@@ -133,6 +148,23 @@ module Vizcore
|
|
|
133
148
|
@midi_inputs << { name: name.to_sym, options: symbolize_keys(options) }
|
|
134
149
|
end
|
|
135
150
|
|
|
151
|
+
# Enable strict DSL validation while the file is evaluated.
|
|
152
|
+
#
|
|
153
|
+
# @return [Boolean]
|
|
154
|
+
def strict!
|
|
155
|
+
@strict = true
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Set a deterministic Ruby random seed for offline rendering.
|
|
159
|
+
#
|
|
160
|
+
# @param value [Integer]
|
|
161
|
+
# @return [Integer]
|
|
162
|
+
def seed(value)
|
|
163
|
+
@seed = Integer(value)
|
|
164
|
+
rescue ArgumentError, TypeError
|
|
165
|
+
raise ArgumentError, "seed must be an integer"
|
|
166
|
+
end
|
|
167
|
+
|
|
136
168
|
# Configure analysis-level audio feature normalization.
|
|
137
169
|
#
|
|
138
170
|
# @param mode [Symbol, String] `:off` or `:adaptive`
|
|
@@ -143,6 +175,15 @@ module Vizcore
|
|
|
143
175
|
@analysis_settings[:audio_normalize] = settings
|
|
144
176
|
end
|
|
145
177
|
|
|
178
|
+
# Configure analysis feature extraction behavior.
|
|
179
|
+
#
|
|
180
|
+
# @param options [Hash] optional onset/FFT/silence/peak-hold settings
|
|
181
|
+
# @return [Hash] normalized analysis settings
|
|
182
|
+
def audio_analysis(**options)
|
|
183
|
+
settings = normalize_audio_analysis(options)
|
|
184
|
+
@analysis_settings.merge!(settings)
|
|
185
|
+
end
|
|
186
|
+
|
|
146
187
|
# Set a fixed BPM value for analysis output.
|
|
147
188
|
#
|
|
148
189
|
# @param value [Numeric]
|
|
@@ -177,7 +218,7 @@ module Vizcore
|
|
|
177
218
|
# @yield Scene definition block
|
|
178
219
|
# @return [void]
|
|
179
220
|
def scene(name, extends: nil, &block)
|
|
180
|
-
builder = SceneBuilder.new(name: name, styles: @styles, themes: @themes, layers: inherited_layers(extends))
|
|
221
|
+
builder = SceneBuilder.new(name: name, styles: @styles, themes: @themes, mapping_presets: @mapping_presets, layers: inherited_layers(extends), strict: @strict)
|
|
181
222
|
builder.evaluate(&block)
|
|
182
223
|
scene_definition = builder.to_h
|
|
183
224
|
@scenes << scene_definition
|
|
@@ -190,15 +231,30 @@ module Vizcore
|
|
|
190
231
|
# @param name [Symbol, String] scene/section identifier
|
|
191
232
|
# @param bars [Integer] section duration in bars
|
|
192
233
|
# @param beats_per_bar [Integer] meter used to convert bars into beats
|
|
234
|
+
# @param loop [Boolean] whether the section should loop to itself
|
|
235
|
+
# @param hold [Numeric] optional additional beats to wait before transitioning
|
|
236
|
+
# @param outro [Boolean] whether to skip auto-transitioning to the next section
|
|
193
237
|
# @yield Scene definition block
|
|
194
238
|
# @return [void]
|
|
195
|
-
def section(name, bars:, beats_per_bar: 4, &block)
|
|
239
|
+
def section(name, bars:, beats_per_bar: 4, loop: false, hold: 0, outro: false, &block)
|
|
196
240
|
section_name = name.to_sym
|
|
197
241
|
section_beats = positive_integer(bars, "section bars") * positive_integer(beats_per_bar, "beats_per_bar")
|
|
242
|
+
normalized_hold = non_negative_float(hold, "section hold")
|
|
243
|
+
is_loop = !!loop
|
|
244
|
+
is_outro = !!outro
|
|
245
|
+
if is_loop && is_outro
|
|
246
|
+
raise ArgumentError, "section cannot be both loop and outro"
|
|
247
|
+
end
|
|
198
248
|
|
|
199
249
|
scene(section_name, &block)
|
|
200
250
|
add_section_transition(to: section_name) if @section_tail
|
|
201
|
-
@section_tail = {
|
|
251
|
+
@section_tail = {
|
|
252
|
+
name: section_name,
|
|
253
|
+
beats: section_beats,
|
|
254
|
+
hold: normalized_hold,
|
|
255
|
+
loop: is_loop,
|
|
256
|
+
outro: is_outro
|
|
257
|
+
}
|
|
202
258
|
end
|
|
203
259
|
|
|
204
260
|
# Define ordered scene markers and derive transitions between them.
|
|
@@ -209,7 +265,7 @@ module Vizcore
|
|
|
209
265
|
def timeline(beats_per_bar: TimelineBuilder::DEFAULT_BEATS_PER_BAR, &block)
|
|
210
266
|
raise ArgumentError, "timeline requires a block" unless block
|
|
211
267
|
|
|
212
|
-
builder = TimelineBuilder.new(beats_per_bar: beats_per_bar).evaluate(&block)
|
|
268
|
+
builder = TimelineBuilder.new(beats_per_bar: beats_per_bar, bpm: @analysis_settings[:bpm]).evaluate(&block)
|
|
213
269
|
entries = builder.to_h
|
|
214
270
|
@timelines << entries unless entries.empty?
|
|
215
271
|
@transitions.concat(builder.transitions)
|
|
@@ -236,15 +292,26 @@ module Vizcore
|
|
|
236
292
|
# @param note [Integer, nil] note number trigger
|
|
237
293
|
# @param cc [Integer, nil] control-change trigger
|
|
238
294
|
# @param pc [Integer, nil] program-change trigger
|
|
295
|
+
# @param channel [Integer, nil] optional MIDI channel condition (1..16; 0 aliases channel 1)
|
|
296
|
+
# @param relative [Boolean] true when CC values should be treated as relative encoder deltas
|
|
297
|
+
# @param deadband [Numeric, nil] minimum CC value change required to emit an action
|
|
298
|
+
# @param smooth [Numeric, Boolean, nil] optional CC smoothing alpha
|
|
299
|
+
# @param pickup [Boolean, nil] when true, waits for CC to reach local pickup point before emitting updates
|
|
239
300
|
# @yield Action block executed by midi runtime
|
|
240
301
|
# @raise [ArgumentError] when no trigger is supplied
|
|
241
302
|
# @return [void]
|
|
242
|
-
def midi_map(note: nil, cc: nil, pc: nil, &block)
|
|
303
|
+
def midi_map(note: nil, cc: nil, pc: nil, channel: nil, relative: false, deadband: nil, smooth: nil, pickup: nil, allow_multiple: false, &block)
|
|
243
304
|
trigger = {}
|
|
244
305
|
trigger[:note] = Integer(note) unless note.nil?
|
|
245
306
|
trigger[:cc] = Integer(cc) unless cc.nil?
|
|
246
307
|
trigger[:pc] = Integer(pc) unless pc.nil?
|
|
247
308
|
raise ArgumentError, "midi_map requires note, cc or pc" if trigger.empty?
|
|
309
|
+
trigger[:channel] = normalize_midi_channel(channel) unless channel.nil?
|
|
310
|
+
trigger[:relative] = true if relative && trigger.key?(:cc)
|
|
311
|
+
trigger[:deadband] = non_negative_float(deadband, "midi deadband") unless deadband.nil?
|
|
312
|
+
trigger[:smooth] = normalize_midi_smooth(smooth) unless smooth.nil? || smooth == false
|
|
313
|
+
trigger[:pickup] = pickup if trigger.key?(:cc) && trigger[:cc].between?(0, 127) && !!pickup
|
|
314
|
+
trigger[:allow_multiple] = !!allow_multiple
|
|
248
315
|
|
|
249
316
|
@midi_mappings << {
|
|
250
317
|
trigger: trigger,
|
|
@@ -282,6 +349,7 @@ module Vizcore
|
|
|
282
349
|
|
|
283
350
|
# @return [Hash] deep-copied definition payload for renderer/runtime.
|
|
284
351
|
def result
|
|
352
|
+
append_pending_section_transition
|
|
285
353
|
definition = {
|
|
286
354
|
audio: @audio_inputs.map { |item| deep_dup(item) },
|
|
287
355
|
midi: @midi_inputs.map { |item| deep_dup(item) },
|
|
@@ -289,11 +357,14 @@ module Vizcore
|
|
|
289
357
|
transitions: @transitions.map { |transition| deep_dup(transition) },
|
|
290
358
|
midi_maps: @midi_mappings.map { |mapping| deep_dup(mapping) },
|
|
291
359
|
key_mappings: @key_mappings.map { |mapping| deep_dup(mapping) },
|
|
360
|
+
mapping_presets: @mapping_presets.map { |name, mappings| { name: name, mappings: deep_dup(mappings) } },
|
|
292
361
|
globals: deep_dup(@global_params),
|
|
293
362
|
analysis: deep_dup(@analysis_settings),
|
|
294
363
|
styles: @styles.map { |name, params| { name: name, params: deep_dup(params) } },
|
|
295
364
|
themes: @themes.map { |name, params| { name: name, params: deep_dup(params) } }
|
|
296
365
|
}
|
|
366
|
+
definition[:strict] = true if @strict
|
|
367
|
+
definition[:seed] = @seed unless @seed.nil?
|
|
297
368
|
definition[:timelines] = @timelines.map { |timeline| deep_dup(timeline) } unless @timelines.empty?
|
|
298
369
|
definition
|
|
299
370
|
end
|
|
@@ -314,6 +385,17 @@ module Vizcore
|
|
|
314
385
|
settings[:window] = positive_float(options[:window], "audio_normalize window") if options.key?(:window)
|
|
315
386
|
settings[:target] = unit_float(options[:target], "audio_normalize target") if options.key?(:target)
|
|
316
387
|
settings[:floor] = unit_float(options[:floor], "audio_normalize floor") if options.key?(:floor)
|
|
388
|
+
settings[:per_band] = !!options[:per_band] if options.key?(:per_band)
|
|
389
|
+
settings
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
def normalize_audio_analysis(options)
|
|
393
|
+
settings = {}
|
|
394
|
+
settings[:onset_sensitivity] = positive_float(options[:onset_sensitivity], "onset_sensitivity") if options.key?(:onset_sensitivity)
|
|
395
|
+
settings[:fft_bins] = ranged_integer(options[:fft_bins], "fft_bins", 8, 128) if options.key?(:fft_bins)
|
|
396
|
+
peak_hold = options.key?(:peak_hold) ? options[:peak_hold] : options[:peak_hold_frames]
|
|
397
|
+
settings[:peak_hold_frames] = ranged_integer(peak_hold, "peak_hold", 0, 10_000) unless peak_hold.nil?
|
|
398
|
+
settings[:silence_reset_frames] = ranged_integer(options[:silence_reset_frames], "silence_reset_frames", 1, 10_000) if options.key?(:silence_reset_frames)
|
|
317
399
|
settings
|
|
318
400
|
end
|
|
319
401
|
|
|
@@ -324,6 +406,39 @@ module Vizcore
|
|
|
324
406
|
numeric
|
|
325
407
|
end
|
|
326
408
|
|
|
409
|
+
def ranged_integer(value, name, min, max)
|
|
410
|
+
numeric = Integer(value)
|
|
411
|
+
raise ArgumentError, "#{name} must be between #{min} and #{max}" unless numeric.between?(min, max)
|
|
412
|
+
|
|
413
|
+
numeric
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
def normalize_midi_channel(value)
|
|
417
|
+
channel = Integer(value)
|
|
418
|
+
return 0 if channel.zero?
|
|
419
|
+
return channel - 1 if channel.between?(1, 16)
|
|
420
|
+
|
|
421
|
+
raise ArgumentError, "midi channel must be between 1 and 16"
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
def non_negative_float(value, name)
|
|
425
|
+
numeric = Float(value)
|
|
426
|
+
raise ArgumentError, "#{name} must be non-negative" if numeric.negative?
|
|
427
|
+
|
|
428
|
+
numeric
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
def normalize_midi_smooth(value)
|
|
432
|
+
return 0.25 if value == true
|
|
433
|
+
|
|
434
|
+
numeric = Float(value)
|
|
435
|
+
raise ArgumentError, "midi smooth must be between 0.0 and 1.0" unless numeric.between?(0.0, 1.0)
|
|
436
|
+
|
|
437
|
+
numeric
|
|
438
|
+
rescue ArgumentError, TypeError
|
|
439
|
+
raise ArgumentError, "midi smooth must be true or between 0.0 and 1.0"
|
|
440
|
+
end
|
|
441
|
+
|
|
327
442
|
def positive_float(value, name)
|
|
328
443
|
numeric = Float(value)
|
|
329
444
|
raise ArgumentError, "#{name} must be positive" unless numeric.positive?
|
|
@@ -352,11 +467,29 @@ module Vizcore
|
|
|
352
467
|
def add_section_transition(to:)
|
|
353
468
|
from = @section_tail.fetch(:name)
|
|
354
469
|
beats = @section_tail.fetch(:beats)
|
|
470
|
+
hold = @section_tail.fetch(:hold, 0.0)
|
|
471
|
+
return if @section_tail.fetch(:loop, false)
|
|
472
|
+
return if @section_tail.fetch(:outro, false)
|
|
473
|
+
|
|
355
474
|
@transitions << {
|
|
356
475
|
from: from,
|
|
357
476
|
to: to,
|
|
358
|
-
trigger: proc { beat_count >= beats }
|
|
477
|
+
trigger: proc { beat_count >= (beats + hold) }
|
|
478
|
+
}
|
|
479
|
+
end
|
|
480
|
+
|
|
481
|
+
def append_pending_section_transition
|
|
482
|
+
return unless @section_tail && @section_tail.fetch(:loop, false)
|
|
483
|
+
|
|
484
|
+
from = @section_tail.fetch(:name)
|
|
485
|
+
beats = @section_tail.fetch(:beats, 0)
|
|
486
|
+
hold = @section_tail.fetch(:hold, 0.0)
|
|
487
|
+
@transitions << {
|
|
488
|
+
from: from,
|
|
489
|
+
to: from,
|
|
490
|
+
trigger: proc { beat_count >= (beats + hold) }
|
|
359
491
|
}
|
|
492
|
+
@section_tail = @section_tail.merge(loop: false)
|
|
360
493
|
end
|
|
361
494
|
|
|
362
495
|
def inherited_layers(scene_name)
|
|
@@ -370,16 +503,7 @@ module Vizcore
|
|
|
370
503
|
end
|
|
371
504
|
|
|
372
505
|
def deep_dup(value)
|
|
373
|
-
|
|
374
|
-
when Hash
|
|
375
|
-
value.each_with_object({}) do |(key, entry), output|
|
|
376
|
-
output[key] = deep_dup(entry)
|
|
377
|
-
end
|
|
378
|
-
when Array
|
|
379
|
-
value.map { |entry| deep_dup(entry) }
|
|
380
|
-
else
|
|
381
|
-
value
|
|
382
|
-
end
|
|
506
|
+
Vizcore::DeepCopy.copy(value)
|
|
383
507
|
end
|
|
384
508
|
|
|
385
509
|
# Builder object for `transition` block internals.
|
|
@@ -451,36 +575,50 @@ module Vizcore
|
|
|
451
575
|
#
|
|
452
576
|
# @param name [Symbol, String]
|
|
453
577
|
# @return [void]
|
|
454
|
-
def switch_scene(name)
|
|
578
|
+
def switch_scene(name, effect: nil)
|
|
455
579
|
scene_name = name.to_s.strip
|
|
456
580
|
raise ArgumentError, "switch_scene scene must not be empty" if scene_name.empty?
|
|
457
581
|
|
|
458
|
-
|
|
582
|
+
action = { type: :switch_scene, scene: scene_name }
|
|
583
|
+
action[:effect] = effect unless effect.nil?
|
|
584
|
+
assign_action(action)
|
|
459
585
|
end
|
|
460
586
|
|
|
461
587
|
# Toggle browser blackout output.
|
|
462
588
|
#
|
|
463
589
|
# @return [void]
|
|
464
|
-
def blackout
|
|
465
|
-
live_control(:blackout)
|
|
590
|
+
def blackout(value = nil, fade: nil, release: nil, color: nil)
|
|
591
|
+
live_control(:blackout, value, fade: fade, release: release, color: color)
|
|
466
592
|
end
|
|
467
593
|
|
|
468
594
|
# Toggle browser freeze output.
|
|
469
595
|
#
|
|
470
596
|
# @return [void]
|
|
471
|
-
def freeze
|
|
472
|
-
live_control(:freeze)
|
|
597
|
+
def freeze(value = nil, fade: nil, release: nil)
|
|
598
|
+
live_control(:freeze, value, fade: fade, release: release)
|
|
473
599
|
end
|
|
474
600
|
|
|
475
601
|
# Toggle a browser live control.
|
|
476
602
|
#
|
|
477
603
|
# @param control [Symbol, String]
|
|
604
|
+
# @param value [Object, nil] target value; nil means UI-side toggle for keyboard/live mapping
|
|
478
605
|
# @return [void]
|
|
479
|
-
def live_control(control)
|
|
606
|
+
def live_control(control, value = nil, fade: nil, release: nil, color: nil)
|
|
480
607
|
normalized = control.to_s.strip.downcase.to_sym
|
|
481
608
|
raise ArgumentError, "unsupported live control: #{control}" unless %i[blackout freeze].include?(normalized)
|
|
482
609
|
|
|
483
610
|
assign_action(type: :live_control, control: normalized)
|
|
611
|
+
|
|
612
|
+
# Preserve explicit value and transition timings when provided by DSL authors.
|
|
613
|
+
# `nil` is kept for UI-side toggles to avoid changing existing keyboard ergonomics.
|
|
614
|
+
action = @action
|
|
615
|
+
action[:value] = value unless value.nil?
|
|
616
|
+
action[:fade] = normalize_control_transition(fade)
|
|
617
|
+
action[:release] = normalize_control_transition(release)
|
|
618
|
+
action[:color] = normalize_control_color(color) unless color.nil?
|
|
619
|
+
action.delete(:fade) if action[:fade].nil?
|
|
620
|
+
action.delete(:release) if action[:release].nil?
|
|
621
|
+
action.delete(:color) if action[:color].nil?
|
|
484
622
|
end
|
|
485
623
|
|
|
486
624
|
# @return [Hash] serialized key action
|
|
@@ -495,6 +633,64 @@ module Vizcore
|
|
|
495
633
|
|
|
496
634
|
@action = action
|
|
497
635
|
end
|
|
636
|
+
|
|
637
|
+
def normalize_control_transition(value)
|
|
638
|
+
return nil if value.nil?
|
|
639
|
+
|
|
640
|
+
numeric = Float(value)
|
|
641
|
+
return nil if numeric.negative? || !numeric.finite?
|
|
642
|
+
|
|
643
|
+
numeric
|
|
644
|
+
rescue ArgumentError, TypeError
|
|
645
|
+
nil
|
|
646
|
+
end
|
|
647
|
+
|
|
648
|
+
def normalize_control_color(value)
|
|
649
|
+
return nil if value.nil?
|
|
650
|
+
|
|
651
|
+
if value.is_a?(Array)
|
|
652
|
+
return nil unless (3..4).cover?(value.length)
|
|
653
|
+
|
|
654
|
+
channels = Array(value).map { |entry| Float(entry, exception: false) }
|
|
655
|
+
return nil if channels.include?(nil)
|
|
656
|
+
|
|
657
|
+
rgb = channels.take(3)
|
|
658
|
+
alpha = channels[3]
|
|
659
|
+
normalized_rgb = if rgb.all? { |channel| channel.between?(0.0, 1.0) }
|
|
660
|
+
rgb
|
|
661
|
+
else
|
|
662
|
+
rgb.map { |channel| channel / 255.0 }
|
|
663
|
+
end
|
|
664
|
+
normalized = normalized_rgb.map { |channel| [0.0, [1.0, channel].min].max }
|
|
665
|
+
return alpha.nil? ? normalized : normalized + [normalize_control_alpha(alpha)]
|
|
666
|
+
end
|
|
667
|
+
|
|
668
|
+
raw = value.to_s.strip
|
|
669
|
+
match = raw.match(/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/)
|
|
670
|
+
return nil unless match
|
|
671
|
+
|
|
672
|
+
raw_hex = match[1]
|
|
673
|
+
hex = raw_hex.length == 3 || raw_hex.length == 4 ? raw_hex.chars.map { |char| "#{char}#{char}" }.join("") : raw_hex
|
|
674
|
+
|
|
675
|
+
[
|
|
676
|
+
Integer("0x#{hex[0, 2]}", 16),
|
|
677
|
+
Integer("0x#{hex[2, 2]}", 16),
|
|
678
|
+
Integer("0x#{hex[4, 2]}", 16),
|
|
679
|
+
Integer("0x#{hex[6, 2]}", 16)
|
|
680
|
+
].take(raw_hex.length > 4 ? 4 : 3).map { |channel| [0.0, [1.0, channel / 255.0].min].max }
|
|
681
|
+
rescue ArgumentError, TypeError
|
|
682
|
+
nil
|
|
683
|
+
end
|
|
684
|
+
|
|
685
|
+
def normalize_control_alpha(value)
|
|
686
|
+
return nil if value.nil?
|
|
687
|
+
|
|
688
|
+
alpha = Float(value, exception: false)
|
|
689
|
+
return nil if alpha.nil?
|
|
690
|
+
return [0.0, [1.0, alpha].min].max if alpha.between?(0.0, 1.0)
|
|
691
|
+
|
|
692
|
+
[0.0, [1.0, alpha / 255.0].min].max
|
|
693
|
+
end
|
|
498
694
|
end
|
|
499
695
|
end
|
|
500
696
|
end
|