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.
- checksums.yaml +4 -4
- data/README.md +544 -9
- data/docs/.nojekyll +0 -0
- data/docs/assets/site.css +744 -0
- data/docs/assets/vizcore-demo.gif +0 -0
- data/docs/assets/vizcore-poster.png +0 -0
- data/docs/assets/vj-tunnel.js +159 -0
- data/docs/index.html +224 -0
- data/examples/README.md +59 -0
- data/examples/assets/README.md +19 -0
- data/examples/audio_inspector.rb +34 -0
- data/examples/club_intro_drop.rb +78 -0
- data/examples/kansai_rubykaigi_visual.rb +70 -0
- data/examples/live_coding_minimal.rb +22 -0
- data/examples/midi_controller_show.rb +78 -0
- data/examples/midi_scene_switch.rb +3 -1
- data/examples/parser_visualizer.rb +48 -0
- data/examples/readme_demo.rb +17 -0
- data/examples/rhythm_geometry.rb +34 -0
- data/examples/ruby_crystal_show.rb +35 -0
- data/examples/shader_playground.rb +18 -0
- data/examples/unyo_liquid.rb +59 -0
- data/examples/vj_ambient_chill_room.rb +124 -0
- data/examples/vj_dnb_jungle.rb +170 -0
- data/examples/vj_festival_mainstage.rb +245 -0
- data/examples/vj_festival_mainstage.yml +17 -0
- data/examples/vj_glitch_industrial.rb +164 -0
- data/examples/vj_hiphop_cipher.rb +167 -0
- data/examples/vj_jpop_idol_live.rb +210 -0
- data/examples/vj_synthwave_retro.rb +173 -0
- data/examples/vj_techno_warehouse.rb +195 -0
- data/frontend/index.html +468 -2
- data/frontend/src/audio-inspector.js +40 -0
- data/frontend/src/live-controls.js +131 -0
- data/frontend/src/main.js +792 -16
- data/frontend/src/midi-learn.js +194 -0
- data/frontend/src/performance-monitor.js +183 -0
- data/frontend/src/plugin-runtime.js +130 -0
- data/frontend/src/projector-mode.js +56 -0
- data/frontend/src/renderer/engine.js +148 -3
- data/frontend/src/renderer/layer-manager.js +428 -30
- data/frontend/src/renderer/shader-manager.js +26 -0
- data/frontend/src/runtime-control-preset.js +11 -0
- data/frontend/src/shader-error-overlay.js +29 -0
- data/frontend/src/shader-param-controls.js +93 -0
- data/frontend/src/shaders/builtins.js +380 -2
- data/frontend/src/shaders/post-effects.js +52 -0
- data/frontend/src/visual-regression.js +67 -0
- data/frontend/src/visual-settings-preset.js +103 -0
- data/frontend/src/visuals/geometry.js +268 -0
- data/frontend/src/visuals/image-renderer.js +291 -0
- data/frontend/src/visuals/particle-system.js +56 -10
- data/frontend/src/visuals/spectrogram-renderer.js +226 -0
- data/frontend/src/visuals/text-renderer.js +112 -11
- data/frontend/src/websocket-client.js +12 -1
- data/lib/vizcore/analysis/adaptive_normalizer.rb +70 -0
- data/lib/vizcore/analysis/beat_detector.rb +4 -2
- data/lib/vizcore/analysis/bpm_estimator.rb +8 -0
- data/lib/vizcore/analysis/feature_recorder.rb +159 -0
- data/lib/vizcore/analysis/feature_replay.rb +84 -0
- data/lib/vizcore/analysis/pipeline.rb +235 -11
- data/lib/vizcore/analysis/tap_tempo.rb +74 -0
- data/lib/vizcore/analysis.rb +4 -0
- data/lib/vizcore/audio/dummy_sine_input.rb +1 -1
- data/lib/vizcore/audio/fixture_input.rb +65 -0
- data/lib/vizcore/audio/input_manager.rb +4 -2
- data/lib/vizcore/audio/mic_input.rb +24 -8
- data/lib/vizcore/audio/portaudio_ffi.rb +106 -1
- data/lib/vizcore/audio.rb +1 -0
- data/lib/vizcore/cli/doctor.rb +159 -0
- data/lib/vizcore/cli/dsl_reference.rb +99 -0
- data/lib/vizcore/cli/layer_docs.rb +46 -0
- data/lib/vizcore/cli/scene_diagnostics.rb +23 -0
- data/lib/vizcore/cli/scene_inspector.rb +136 -0
- data/lib/vizcore/cli/scene_validator.rb +245 -0
- data/lib/vizcore/cli/shader_template.rb +68 -0
- data/lib/vizcore/cli/shader_uniform_docs.rb +54 -0
- data/lib/vizcore/cli.rb +689 -18
- data/lib/vizcore/config.rb +103 -2
- data/lib/vizcore/control_preset.rb +68 -0
- data/lib/vizcore/dsl/engine.rb +277 -5
- data/lib/vizcore/dsl/layer_builder.rb +491 -22
- data/lib/vizcore/dsl/layer_group_builder.rb +112 -0
- data/lib/vizcore/dsl/mapping_resolver.rb +132 -3
- data/lib/vizcore/dsl/mapping_transform_builder.rb +71 -0
- data/lib/vizcore/dsl/reaction_builder.rb +44 -0
- data/lib/vizcore/dsl/scene_builder.rb +61 -5
- data/lib/vizcore/dsl/shader_source_resolver.rb +67 -6
- data/lib/vizcore/dsl/style_builder.rb +68 -0
- data/lib/vizcore/dsl/timeline_builder.rb +138 -0
- data/lib/vizcore/dsl/transition_controller.rb +77 -0
- data/lib/vizcore/dsl.rb +5 -1
- data/lib/vizcore/layer_catalog.rb +273 -0
- data/lib/vizcore/project_manifest.rb +152 -0
- data/lib/vizcore/renderer/png_writer.rb +57 -0
- data/lib/vizcore/renderer/render_sequence.rb +153 -0
- data/lib/vizcore/renderer/scene_frame_source.rb +119 -0
- data/lib/vizcore/renderer/scene_serializer.rb +36 -3
- data/lib/vizcore/renderer/snapshot.rb +38 -0
- data/lib/vizcore/renderer/snapshot_renderer.rb +446 -0
- data/lib/vizcore/renderer.rb +5 -0
- data/lib/vizcore/server/frame_broadcaster.rb +91 -5
- data/lib/vizcore/server/gallery_app.rb +155 -0
- data/lib/vizcore/server/gallery_page.rb +100 -0
- data/lib/vizcore/server/gallery_runner.rb +48 -0
- data/lib/vizcore/server/rack_app.rb +203 -4
- data/lib/vizcore/server/runner.rb +370 -22
- data/lib/vizcore/server/scene_dependency_watcher.rb +79 -0
- data/lib/vizcore/server/websocket_handler.rb +60 -10
- data/lib/vizcore/server.rb +4 -0
- data/lib/vizcore/sync/osc_message.rb +103 -0
- data/lib/vizcore/sync/osc_receiver.rb +68 -0
- data/lib/vizcore/sync.rb +4 -0
- data/lib/vizcore/templates/midi_control_scene.rb +3 -1
- data/lib/vizcore/templates/plugin_layer.rb +20 -0
- data/lib/vizcore/templates/plugin_readme.md +23 -0
- data/lib/vizcore/templates/plugin_renderer.js +43 -0
- data/lib/vizcore/templates/plugin_scene.rb +14 -0
- data/lib/vizcore/templates/project_readme.md +7 -23
- data/lib/vizcore/templates/rubykaigi_scene.rb +30 -0
- data/lib/vizcore/version.rb +1 -1
- data/lib/vizcore.rb +27 -0
- data/scripts/browser_capture.mjs +75 -0
- data/sig/vizcore.rbs +362 -0
- metadata +83 -3
- data/docs/GETTING_STARTED.md +0 -105
data/lib/vizcore/config.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
data/lib/vizcore/dsl/engine.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|