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,188 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Vizcore
|
|
4
|
+
module DSL
|
|
5
|
+
# Executes `midi_map` action blocks against incoming MIDI events.
|
|
6
|
+
class MidiMapExecutor
|
|
7
|
+
# @param midi_maps [Array<Hash>]
|
|
8
|
+
# @param scenes [Array<Hash>]
|
|
9
|
+
# @param globals [Hash]
|
|
10
|
+
def initialize(midi_maps:, scenes:, globals: {})
|
|
11
|
+
update(midi_maps: midi_maps, scenes: scenes, globals: globals)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# @param midi_maps [Array<Hash>]
|
|
15
|
+
# @param scenes [Array<Hash>]
|
|
16
|
+
# @param globals [Hash, nil]
|
|
17
|
+
# @return [void]
|
|
18
|
+
def update(midi_maps:, scenes:, globals: nil)
|
|
19
|
+
@midi_maps = normalize_midi_maps(midi_maps)
|
|
20
|
+
@scenes = normalize_scenes(scenes)
|
|
21
|
+
@globals = normalize_globals(globals) unless globals.nil?
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# @return [Hash] mutable global parameter snapshot
|
|
25
|
+
def globals
|
|
26
|
+
@globals.dup
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @param event [Vizcore::Audio::MidiInput::Event]
|
|
30
|
+
# @return [Array<Hash>] runtime actions (`:switch_scene`, `:set_global`)
|
|
31
|
+
def handle_event(event)
|
|
32
|
+
@midi_maps.each_with_object([]) do |mapping, actions|
|
|
33
|
+
next unless mapping_match?(mapping[:trigger], event)
|
|
34
|
+
|
|
35
|
+
context = ActionContext.new(scenes: @scenes, globals: @globals)
|
|
36
|
+
invoke_action_block(context, mapping[:action], event, mapping[:trigger])
|
|
37
|
+
actions.concat(context.actions)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def normalize_midi_maps(midi_maps)
|
|
44
|
+
Array(midi_maps).filter_map do |mapping|
|
|
45
|
+
values = symbolize_hash(mapping)
|
|
46
|
+
trigger = symbolize_hash(values[:trigger])
|
|
47
|
+
action = values[:action]
|
|
48
|
+
next if trigger.empty?
|
|
49
|
+
next unless action.respond_to?(:call)
|
|
50
|
+
|
|
51
|
+
{
|
|
52
|
+
trigger: trigger,
|
|
53
|
+
action: action
|
|
54
|
+
}
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def normalize_scenes(scenes)
|
|
59
|
+
Array(scenes).each_with_object({}) do |scene, output|
|
|
60
|
+
values = symbolize_hash(scene)
|
|
61
|
+
name = values[:name]
|
|
62
|
+
next unless name
|
|
63
|
+
|
|
64
|
+
output[name.to_sym] = {
|
|
65
|
+
name: name.to_sym,
|
|
66
|
+
layers: Array(values[:layers]).map { |layer| deep_dup(layer) }
|
|
67
|
+
}
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def normalize_globals(globals)
|
|
72
|
+
symbolize_hash(globals)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def mapping_match?(trigger, event)
|
|
76
|
+
if trigger.key?(:note)
|
|
77
|
+
event.type == :note_on && event.data1 == trigger[:note].to_i
|
|
78
|
+
elsif trigger.key?(:cc)
|
|
79
|
+
event.type == :control_change && event.data1 == trigger[:cc].to_i
|
|
80
|
+
elsif trigger.key?(:pc)
|
|
81
|
+
event.type == :program_change && event.data1 == trigger[:pc].to_i
|
|
82
|
+
else
|
|
83
|
+
false
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def invoke_action_block(context, action, event, trigger)
|
|
88
|
+
value = event_value(event, trigger)
|
|
89
|
+
if action.arity.zero?
|
|
90
|
+
context.instance_exec(&action)
|
|
91
|
+
else
|
|
92
|
+
context.instance_exec(value, &action)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def event_value(event, trigger)
|
|
97
|
+
if trigger.key?(:note) || trigger.key?(:cc)
|
|
98
|
+
event.data2.to_i.clamp(0, 127)
|
|
99
|
+
elsif trigger.key?(:pc)
|
|
100
|
+
event.data1.to_i.clamp(0, 127)
|
|
101
|
+
else
|
|
102
|
+
0
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def symbolize_hash(value)
|
|
107
|
+
Hash(value).each_with_object({}) do |(key, entry), output|
|
|
108
|
+
output[key.to_sym] = entry
|
|
109
|
+
end
|
|
110
|
+
rescue StandardError
|
|
111
|
+
{}
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def deep_dup(value)
|
|
115
|
+
case value
|
|
116
|
+
when Hash
|
|
117
|
+
value.each_with_object({}) do |(key, entry), output|
|
|
118
|
+
output[key] = deep_dup(entry)
|
|
119
|
+
end
|
|
120
|
+
when Array
|
|
121
|
+
value.map { |entry| deep_dup(entry) }
|
|
122
|
+
else
|
|
123
|
+
value
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Runtime DSL context used while executing one `midi_map` action block.
|
|
128
|
+
# @api private
|
|
129
|
+
class ActionContext
|
|
130
|
+
# Collected runtime actions emitted by DSL calls.
|
|
131
|
+
attr_reader :actions
|
|
132
|
+
|
|
133
|
+
# @param scenes [Hash]
|
|
134
|
+
# @param globals [Hash]
|
|
135
|
+
def initialize(scenes:, globals:)
|
|
136
|
+
@scenes = scenes
|
|
137
|
+
@globals = globals
|
|
138
|
+
@actions = []
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# @param name [Symbol, String]
|
|
142
|
+
# @param effect [Hash, nil]
|
|
143
|
+
# @return [void]
|
|
144
|
+
def switch_scene(name, effect: nil)
|
|
145
|
+
scene = @scenes[name.to_sym]
|
|
146
|
+
return unless scene
|
|
147
|
+
|
|
148
|
+
@actions << {
|
|
149
|
+
type: :switch_scene,
|
|
150
|
+
scene: {
|
|
151
|
+
name: scene[:name],
|
|
152
|
+
layers: scene[:layers].map { |layer| deep_dup(layer) }
|
|
153
|
+
},
|
|
154
|
+
effect: deep_dup(effect)
|
|
155
|
+
}
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# @param key [Symbol, String]
|
|
159
|
+
# @param value [Object]
|
|
160
|
+
# @return [void]
|
|
161
|
+
def set(key, value)
|
|
162
|
+
symbol_key = key.to_sym
|
|
163
|
+
@globals[symbol_key] = value
|
|
164
|
+
@actions << {
|
|
165
|
+
type: :set_global,
|
|
166
|
+
key: symbol_key,
|
|
167
|
+
value: value
|
|
168
|
+
}
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
private
|
|
172
|
+
|
|
173
|
+
def deep_dup(value)
|
|
174
|
+
case value
|
|
175
|
+
when Hash
|
|
176
|
+
value.each_with_object({}) do |(key, entry), output|
|
|
177
|
+
output[key] = deep_dup(entry)
|
|
178
|
+
end
|
|
179
|
+
when Array
|
|
180
|
+
value.map { |entry| deep_dup(entry) }
|
|
181
|
+
else
|
|
182
|
+
value
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "layer_builder"
|
|
4
|
+
|
|
5
|
+
module Vizcore
|
|
6
|
+
module DSL
|
|
7
|
+
# Collects layer definitions inside a single scene block.
|
|
8
|
+
class SceneBuilder
|
|
9
|
+
# @param name [Symbol, String] scene identifier
|
|
10
|
+
def initialize(name:)
|
|
11
|
+
@name = name.to_sym
|
|
12
|
+
@layers = []
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Evaluate a scene block.
|
|
16
|
+
#
|
|
17
|
+
# @yield Layer definitions
|
|
18
|
+
# @return [Vizcore::DSL::SceneBuilder]
|
|
19
|
+
def evaluate(&block)
|
|
20
|
+
instance_eval(&block) if block
|
|
21
|
+
self
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Define one layer in this scene.
|
|
25
|
+
#
|
|
26
|
+
# @param name [Symbol, String] layer identifier
|
|
27
|
+
# @yield Layer definition block
|
|
28
|
+
# @return [void]
|
|
29
|
+
def layer(name, &block)
|
|
30
|
+
builder = LayerBuilder.new(name: name)
|
|
31
|
+
builder.evaluate(&block)
|
|
32
|
+
@layers << builder.to_h
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# @return [Hash] serialized scene payload
|
|
36
|
+
def to_h
|
|
37
|
+
{
|
|
38
|
+
name: @name,
|
|
39
|
+
layers: @layers.map { |layer| layer.dup }
|
|
40
|
+
}
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
|
|
5
|
+
module Vizcore
|
|
6
|
+
module DSL
|
|
7
|
+
# Replaces layer `glsl` paths with inlined shader source text.
|
|
8
|
+
class ShaderSourceResolver
|
|
9
|
+
# @param definition [Hash] DSL definition payload
|
|
10
|
+
# @param scene_file [String, Pathname] source scene file
|
|
11
|
+
# @raise [ArgumentError] when a referenced GLSL file is missing
|
|
12
|
+
# @return [Hash] deep-copied definition with `:glsl_source` entries
|
|
13
|
+
def resolve(definition:, scene_file:)
|
|
14
|
+
scene_path = Pathname.new(scene_file.to_s).expand_path
|
|
15
|
+
base_dir = scene_path.dirname
|
|
16
|
+
output = deep_dup(definition)
|
|
17
|
+
output[:scenes] = Array(output[:scenes]).map do |scene|
|
|
18
|
+
scene_hash = symbolize_hash(scene)
|
|
19
|
+
scene_hash[:layers] = Array(scene_hash[:layers]).map do |layer|
|
|
20
|
+
resolve_layer(layer, base_dir: base_dir)
|
|
21
|
+
end
|
|
22
|
+
scene_hash
|
|
23
|
+
end
|
|
24
|
+
output
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def resolve_layer(layer, base_dir:)
|
|
30
|
+
layer_hash = symbolize_hash(layer)
|
|
31
|
+
shader_path = layer_hash[:glsl]
|
|
32
|
+
return layer_hash unless shader_path
|
|
33
|
+
|
|
34
|
+
full_path = resolve_path(base_dir: base_dir, shader_path: shader_path)
|
|
35
|
+
raise ArgumentError, "GLSL file not found: #{shader_path}" unless full_path.file?
|
|
36
|
+
|
|
37
|
+
layer_hash[:glsl] = shader_path.to_s
|
|
38
|
+
layer_hash[:glsl_source] = full_path.read
|
|
39
|
+
layer_hash
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def resolve_path(base_dir:, shader_path:)
|
|
43
|
+
path = Pathname.new(shader_path.to_s)
|
|
44
|
+
return path.expand_path if path.absolute?
|
|
45
|
+
|
|
46
|
+
base_dir.join(path).expand_path
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def symbolize_hash(value)
|
|
50
|
+
Hash(value).each_with_object({}) do |(key, entry), output|
|
|
51
|
+
output[key.to_sym] = entry
|
|
52
|
+
end
|
|
53
|
+
rescue StandardError
|
|
54
|
+
{}
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def deep_dup(value)
|
|
58
|
+
case value
|
|
59
|
+
when Hash
|
|
60
|
+
value.each_with_object({}) do |(key, entry), output|
|
|
61
|
+
output[key] = deep_dup(entry)
|
|
62
|
+
end
|
|
63
|
+
when Array
|
|
64
|
+
value.map { |entry| deep_dup(entry) }
|
|
65
|
+
else
|
|
66
|
+
value
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Vizcore
|
|
4
|
+
module DSL
|
|
5
|
+
# Evaluates transition rules and returns scene-change payloads.
|
|
6
|
+
class TransitionController
|
|
7
|
+
# @param scenes [Array<Hash>]
|
|
8
|
+
# @param transitions [Array<Hash>]
|
|
9
|
+
def initialize(scenes:, transitions:)
|
|
10
|
+
update(scenes: scenes, transitions: transitions)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# @param scenes [Array<Hash>]
|
|
14
|
+
# @param transitions [Array<Hash>]
|
|
15
|
+
# @return [void]
|
|
16
|
+
def update(scenes:, transitions:)
|
|
17
|
+
@scenes_by_name = normalize_scenes(scenes)
|
|
18
|
+
@transitions = normalize_transitions(transitions)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# @param scene_name [String, Symbol]
|
|
22
|
+
# @param audio [Hash]
|
|
23
|
+
# @param frame_count [Integer]
|
|
24
|
+
# @return [Hash, nil] transition payload when condition matches
|
|
25
|
+
def next_transition(scene_name:, audio:, frame_count: 0)
|
|
26
|
+
current = scene_name.to_sym
|
|
27
|
+
transition = @transitions.find do |entry|
|
|
28
|
+
entry[:from] == current && trigger_match?(entry[:trigger], audio, frame_count)
|
|
29
|
+
end
|
|
30
|
+
return nil unless transition
|
|
31
|
+
|
|
32
|
+
target_scene = @scenes_by_name[transition[:to]]
|
|
33
|
+
return nil unless target_scene
|
|
34
|
+
|
|
35
|
+
{
|
|
36
|
+
from: transition[:from],
|
|
37
|
+
to: transition[:to],
|
|
38
|
+
effect: transition[:effect],
|
|
39
|
+
scene: deep_dup(target_scene)
|
|
40
|
+
}
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def normalize_scenes(scenes)
|
|
46
|
+
Array(scenes).each_with_object({}) do |scene, output|
|
|
47
|
+
values = symbolize_hash(scene)
|
|
48
|
+
name = values[:name]
|
|
49
|
+
next unless name
|
|
50
|
+
|
|
51
|
+
output[name.to_sym] = {
|
|
52
|
+
name: name.to_sym,
|
|
53
|
+
layers: Array(values[:layers]).map { |layer| deep_dup(layer) }
|
|
54
|
+
}
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def normalize_transitions(transitions)
|
|
59
|
+
Array(transitions).filter_map do |transition|
|
|
60
|
+
values = symbolize_hash(transition)
|
|
61
|
+
from = values[:from]
|
|
62
|
+
to = values[:to]
|
|
63
|
+
next unless from && to
|
|
64
|
+
|
|
65
|
+
{
|
|
66
|
+
from: from.to_sym,
|
|
67
|
+
to: to.to_sym,
|
|
68
|
+
trigger: values[:trigger],
|
|
69
|
+
effect: deep_dup(values[:effect])
|
|
70
|
+
}
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def trigger_match?(trigger, audio, frame_count)
|
|
75
|
+
return false unless trigger.respond_to?(:call)
|
|
76
|
+
|
|
77
|
+
TriggerContext.new(audio, frame_count: frame_count).instance_exec(&trigger)
|
|
78
|
+
rescue StandardError
|
|
79
|
+
false
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def symbolize_hash(value)
|
|
83
|
+
Hash(value).each_with_object({}) do |(key, entry), output|
|
|
84
|
+
output[key.to_sym] = entry
|
|
85
|
+
end
|
|
86
|
+
rescue StandardError
|
|
87
|
+
{}
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def deep_dup(value)
|
|
91
|
+
case value
|
|
92
|
+
when Hash
|
|
93
|
+
value.each_with_object({}) do |(key, entry), output|
|
|
94
|
+
output[key] = deep_dup(entry)
|
|
95
|
+
end
|
|
96
|
+
when Array
|
|
97
|
+
value.map { |entry| deep_dup(entry) }
|
|
98
|
+
else
|
|
99
|
+
value
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Runtime DSL context exposed to transition trigger blocks.
|
|
104
|
+
# @api private
|
|
105
|
+
class TriggerContext
|
|
106
|
+
# @param audio [Hash]
|
|
107
|
+
# @param frame_count [Integer]
|
|
108
|
+
def initialize(audio, frame_count:)
|
|
109
|
+
@audio = symbolize_hash(audio)
|
|
110
|
+
@bands = symbolize_hash(@audio[:bands])
|
|
111
|
+
@frame_count = Integer(frame_count)
|
|
112
|
+
rescue StandardError
|
|
113
|
+
@frame_count = 0
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# @return [Float]
|
|
117
|
+
def amplitude
|
|
118
|
+
@audio[:amplitude].to_f
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# @param name [Symbol, String]
|
|
122
|
+
# @return [Float]
|
|
123
|
+
def frequency_band(name)
|
|
124
|
+
@bands[name.to_sym].to_f
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# @return [Array<Float>]
|
|
128
|
+
def fft_spectrum
|
|
129
|
+
Array(@audio[:fft])
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# @return [Boolean]
|
|
133
|
+
def beat?
|
|
134
|
+
!!@audio[:beat]
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# @return [Integer]
|
|
138
|
+
def beat_count
|
|
139
|
+
Integer(@audio[:beat_count] || 0)
|
|
140
|
+
rescue StandardError
|
|
141
|
+
0
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# @return [Float]
|
|
145
|
+
def bpm
|
|
146
|
+
@audio[:bpm].to_f
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# @return [Integer]
|
|
150
|
+
def frame_count
|
|
151
|
+
@frame_count
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
private
|
|
155
|
+
|
|
156
|
+
def symbolize_hash(value)
|
|
157
|
+
Hash(value).each_with_object({}) do |(key, entry), output|
|
|
158
|
+
output[key.to_sym] = entry
|
|
159
|
+
end
|
|
160
|
+
rescue StandardError
|
|
161
|
+
{}
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
data/lib/vizcore/dsl.rb
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Vizcore
|
|
4
|
+
# DSL builders and runtime helpers.
|
|
5
|
+
module DSL
|
|
6
|
+
end
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
require_relative "dsl/layer_builder"
|
|
10
|
+
require_relative "dsl/file_watcher"
|
|
11
|
+
require_relative "dsl/mapping_resolver"
|
|
12
|
+
require_relative "dsl/midi_map_executor"
|
|
13
|
+
require_relative "dsl/scene_builder"
|
|
14
|
+
require_relative "dsl/shader_source_resolver"
|
|
15
|
+
require_relative "dsl/transition_controller"
|
|
16
|
+
require_relative "dsl/engine"
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Vizcore
|
|
4
|
+
# Invalid or missing user-provided configuration.
|
|
5
|
+
class ConfigurationError < ArgumentError; end
|
|
6
|
+
|
|
7
|
+
# Scene DSL could not be loaded or resolved.
|
|
8
|
+
class SceneLoadError < ArgumentError; end
|
|
9
|
+
|
|
10
|
+
# Audio source initialization/processing failure.
|
|
11
|
+
class AudioSourceError < StandardError; end
|
|
12
|
+
|
|
13
|
+
# Frame generation failed in the render pipeline.
|
|
14
|
+
class FrameBuildError < StandardError; end
|
|
15
|
+
|
|
16
|
+
# Small helper for concise contextual error messages.
|
|
17
|
+
module ErrorFormatting
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
# @param error [Exception]
|
|
21
|
+
# @param context [String]
|
|
22
|
+
# @return [String]
|
|
23
|
+
def summarize(error, context:)
|
|
24
|
+
"#{context}: #{error.class}: #{error.message}"
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Vizcore
|
|
4
|
+
module Renderer
|
|
5
|
+
# Fixed-interval scheduler used by the frame broadcast loop.
|
|
6
|
+
class FrameScheduler
|
|
7
|
+
# Default frame rate used by renderer loops.
|
|
8
|
+
DEFAULT_FRAME_RATE = 60.0
|
|
9
|
+
|
|
10
|
+
# @param frame_rate [Float]
|
|
11
|
+
# @param monotonic_clock [#call, nil]
|
|
12
|
+
# @param sleeper [#call, nil]
|
|
13
|
+
# @param error_handler [#call, nil]
|
|
14
|
+
# @yieldparam elapsed [Float]
|
|
15
|
+
def initialize(frame_rate: DEFAULT_FRAME_RATE, monotonic_clock: nil, sleeper: nil, error_handler: nil, &on_tick)
|
|
16
|
+
@frame_rate = Float(frame_rate)
|
|
17
|
+
raise ArgumentError, "frame_rate must be positive" unless @frame_rate.positive?
|
|
18
|
+
|
|
19
|
+
@frame_interval = 1.0 / @frame_rate
|
|
20
|
+
@monotonic_clock = monotonic_clock || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
|
|
21
|
+
@sleeper = sleeper || ->(seconds) { sleep(seconds) }
|
|
22
|
+
@error_handler = error_handler || ->(error) { raise error }
|
|
23
|
+
@on_tick = on_tick
|
|
24
|
+
@running = false
|
|
25
|
+
@thread = nil
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# @return [void]
|
|
29
|
+
def start
|
|
30
|
+
return if running?
|
|
31
|
+
|
|
32
|
+
@running = true
|
|
33
|
+
started_at = @monotonic_clock.call
|
|
34
|
+
@thread = Thread.new { run_loop(started_at) }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @param timeout [Float]
|
|
38
|
+
# @return [void]
|
|
39
|
+
def stop(timeout: 1.0)
|
|
40
|
+
return unless running?
|
|
41
|
+
|
|
42
|
+
@running = false
|
|
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 run_loop(started_at)
|
|
59
|
+
while running?
|
|
60
|
+
begin
|
|
61
|
+
loop_started = @monotonic_clock.call
|
|
62
|
+
elapsed = loop_started - started_at
|
|
63
|
+
@on_tick&.call(elapsed)
|
|
64
|
+
|
|
65
|
+
duration = @monotonic_clock.call - loop_started
|
|
66
|
+
sleep_time = @frame_interval - duration
|
|
67
|
+
@sleeper.call(sleep_time) if sleep_time.positive?
|
|
68
|
+
rescue StandardError => e
|
|
69
|
+
@error_handler.call(e)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Vizcore
|
|
4
|
+
module Renderer
|
|
5
|
+
# Serializes analysis and scene state into transport payloads.
|
|
6
|
+
class SceneSerializer
|
|
7
|
+
# @param timestamp [Numeric]
|
|
8
|
+
# @param audio [Hash]
|
|
9
|
+
# @param scene_name [String, Symbol]
|
|
10
|
+
# @param scene_layers [Array<Hash>]
|
|
11
|
+
# @param transition [Hash, nil]
|
|
12
|
+
# @return [Hash]
|
|
13
|
+
def audio_frame(timestamp:, audio:, scene_name:, scene_layers:, transition: nil)
|
|
14
|
+
{
|
|
15
|
+
timestamp: Float(timestamp),
|
|
16
|
+
audio: serialize_audio(audio),
|
|
17
|
+
scene: serialize_scene(scene_name, scene_layers),
|
|
18
|
+
transition: transition
|
|
19
|
+
}
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def serialize_audio(audio)
|
|
25
|
+
bands = symbolize_hash(audio[:bands])
|
|
26
|
+
|
|
27
|
+
{
|
|
28
|
+
amplitude: round_float(audio[:amplitude]),
|
|
29
|
+
bands: bands.transform_values { |value| round_float(value) },
|
|
30
|
+
fft: Array(audio[:fft]).map { |value| round_float(value) },
|
|
31
|
+
beat: !!audio[:beat],
|
|
32
|
+
beat_count: Integer(audio[:beat_count] || 0),
|
|
33
|
+
bpm: audio[:bpm]
|
|
34
|
+
}
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def serialize_scene(scene_name, scene_layers)
|
|
38
|
+
{
|
|
39
|
+
name: scene_name.to_s,
|
|
40
|
+
layers: Array(scene_layers).map { |layer| serialize_layer(layer) }
|
|
41
|
+
}
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def serialize_layer(layer)
|
|
45
|
+
values = symbolize_hash(layer)
|
|
46
|
+
|
|
47
|
+
output = {
|
|
48
|
+
name: values.fetch(:name).to_s,
|
|
49
|
+
type: (values[:type] || :geometry).to_s,
|
|
50
|
+
params: symbolize_hash(values[:params])
|
|
51
|
+
}
|
|
52
|
+
output[:shader] = values[:shader].to_s if values[:shader]
|
|
53
|
+
output[:glsl] = values[:glsl].to_s if values[:glsl]
|
|
54
|
+
output[:glsl_source] = values[:glsl_source].to_s if values[:glsl_source]
|
|
55
|
+
output
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def symbolize_hash(value)
|
|
59
|
+
Hash(value).each_with_object({}) do |(key, entry), output|
|
|
60
|
+
output[key.to_sym] = entry
|
|
61
|
+
end
|
|
62
|
+
rescue StandardError
|
|
63
|
+
{}
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def round_float(value, digits: 4)
|
|
67
|
+
Float(value).round(digits)
|
|
68
|
+
rescue StandardError
|
|
69
|
+
0.0
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|