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,357 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "puma"
|
|
4
|
+
require_relative "../config"
|
|
5
|
+
require_relative "../dsl"
|
|
6
|
+
require_relative "../errors"
|
|
7
|
+
require_relative "frame_broadcaster"
|
|
8
|
+
require_relative "rack_app"
|
|
9
|
+
require_relative "websocket_handler"
|
|
10
|
+
|
|
11
|
+
module Vizcore
|
|
12
|
+
module Server
|
|
13
|
+
# Bootstraps Rack/Puma, audio pipeline, scene reload, and MIDI runtime.
|
|
14
|
+
class Runner
|
|
15
|
+
# @param config [Vizcore::Config]
|
|
16
|
+
# @param output [#puts]
|
|
17
|
+
def initialize(config, output: $stdout)
|
|
18
|
+
@config = config
|
|
19
|
+
@output = output
|
|
20
|
+
@shader_source_resolver = Vizcore::DSL::ShaderSourceResolver.new
|
|
21
|
+
@scene_catalog_mutex = Mutex.new
|
|
22
|
+
@scene_catalog = []
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Run server lifecycle until interrupted.
|
|
26
|
+
#
|
|
27
|
+
# @raise [Vizcore::ConfigurationError]
|
|
28
|
+
# @raise [Vizcore::SceneLoadError]
|
|
29
|
+
# @return [void]
|
|
30
|
+
def run
|
|
31
|
+
validate_scene_file!
|
|
32
|
+
validate_audio_settings!
|
|
33
|
+
definition = load_definition!
|
|
34
|
+
scene = first_scene(definition) || fallback_scene
|
|
35
|
+
|
|
36
|
+
app = RackApp.new(
|
|
37
|
+
frontend_root: Vizcore.frontend_root,
|
|
38
|
+
audio_source: @config.audio_source,
|
|
39
|
+
audio_file: @config.audio_file,
|
|
40
|
+
scene_names: scene_names_for(definition)
|
|
41
|
+
)
|
|
42
|
+
server = Puma::Server.new(app, nil, min_threads: 0, max_threads: 4)
|
|
43
|
+
server.add_tcp_listener(@config.host, @config.port)
|
|
44
|
+
server.run
|
|
45
|
+
|
|
46
|
+
input_manager = Vizcore::Audio::InputManager.new(
|
|
47
|
+
source: @config.audio_source,
|
|
48
|
+
file_path: @config.audio_file&.to_s
|
|
49
|
+
)
|
|
50
|
+
broadcaster = FrameBroadcaster.new(
|
|
51
|
+
scene_name: scene[:name].to_s,
|
|
52
|
+
scene_layers: scene[:layers],
|
|
53
|
+
scene_catalog: definition[:scenes],
|
|
54
|
+
transitions: definition[:transitions],
|
|
55
|
+
input_manager: input_manager,
|
|
56
|
+
error_reporter: ->(message) { @output.puts(message) }
|
|
57
|
+
)
|
|
58
|
+
replace_scene_catalog(definition[:scenes])
|
|
59
|
+
if @config.audio_source == :file
|
|
60
|
+
broadcaster.sync_transport(playing: false, position_seconds: 0.0)
|
|
61
|
+
end
|
|
62
|
+
broadcaster.start
|
|
63
|
+
register_client_message_handler(broadcaster)
|
|
64
|
+
midi_runtime = start_midi_runtime(definition, broadcaster)
|
|
65
|
+
watcher = start_scene_watcher(broadcaster) do |updated_definition|
|
|
66
|
+
midi_runtime = refresh_midi_runtime(midi_runtime, updated_definition, broadcaster)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
@output.puts("Vizcore server listening at http://#{@config.host}:#{@config.port}")
|
|
70
|
+
@output.puts("Scene: #{scene[:name]}")
|
|
71
|
+
@output.puts("Audio playback: http://#{@config.host}:#{@config.port}/audio-file") if @config.audio_source == :file
|
|
72
|
+
@output.puts("Press Ctrl+C to stop.")
|
|
73
|
+
|
|
74
|
+
wait_for_interrupt
|
|
75
|
+
ensure
|
|
76
|
+
Vizcore::Server::WebSocketHandler.clear_message_handler
|
|
77
|
+
stop_midi_runtime(midi_runtime)
|
|
78
|
+
watcher&.stop
|
|
79
|
+
broadcaster&.stop
|
|
80
|
+
server&.stop(true)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
def validate_scene_file!
|
|
86
|
+
return if @config.scene_exists?
|
|
87
|
+
|
|
88
|
+
message = if @config.scene_file
|
|
89
|
+
"Scene file not found: #{@config.scene_file}"
|
|
90
|
+
else
|
|
91
|
+
"Scene file is required"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
raise Vizcore::ConfigurationError, message
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def load_definition!
|
|
98
|
+
raw_definition = Vizcore::DSL::Engine.load_file(@config.scene_file.to_s)
|
|
99
|
+
resolve_shader_sources(raw_definition)
|
|
100
|
+
rescue StandardError => e
|
|
101
|
+
raise Vizcore::SceneLoadError, Vizcore::ErrorFormatting.summarize(
|
|
102
|
+
e,
|
|
103
|
+
context: "Failed to load scene file #{@config.scene_file}"
|
|
104
|
+
)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def validate_audio_settings!
|
|
108
|
+
return unless @config.audio_source == :file
|
|
109
|
+
return if @config.audio_file && @config.audio_file.file?
|
|
110
|
+
|
|
111
|
+
raise Vizcore::ConfigurationError, "Audio file not found: #{@config.audio_file || '(nil)'}"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def wait_for_interrupt
|
|
115
|
+
stop_requested = false
|
|
116
|
+
%w[INT TERM].each do |signal_name|
|
|
117
|
+
Signal.trap(signal_name) { stop_requested = true }
|
|
118
|
+
rescue ArgumentError
|
|
119
|
+
nil
|
|
120
|
+
end
|
|
121
|
+
sleep(0.1) until stop_requested
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def start_scene_watcher(broadcaster, &on_reload)
|
|
125
|
+
watcher = Vizcore::DSL::Engine.watch_file(@config.scene_file.to_s) do |definition, _changed_path|
|
|
126
|
+
definition = resolve_shader_sources(definition)
|
|
127
|
+
replace_scene_catalog(definition[:scenes])
|
|
128
|
+
scene = first_scene(definition) || fallback_scene
|
|
129
|
+
broadcaster.update_transition_definition(
|
|
130
|
+
scenes: Array(definition[:scenes]),
|
|
131
|
+
transitions: Array(definition[:transitions])
|
|
132
|
+
)
|
|
133
|
+
broadcaster.update_scene(scene_name: scene[:name], scene_layers: scene[:layers])
|
|
134
|
+
on_reload&.call(definition)
|
|
135
|
+
WebSocketHandler.broadcast(
|
|
136
|
+
type: "config_update",
|
|
137
|
+
payload: {
|
|
138
|
+
scene: scene,
|
|
139
|
+
scenes: scene_names_for(definition)
|
|
140
|
+
}
|
|
141
|
+
)
|
|
142
|
+
@output.puts("Scene reloaded: #{scene[:name]}")
|
|
143
|
+
rescue StandardError => e
|
|
144
|
+
@output.puts(Vizcore::ErrorFormatting.summarize(e, context: "Scene reload failed"))
|
|
145
|
+
end
|
|
146
|
+
watcher.start
|
|
147
|
+
watcher
|
|
148
|
+
rescue StandardError => e
|
|
149
|
+
@output.puts(Vizcore::ErrorFormatting.summarize(e, context: "Scene watcher disabled"))
|
|
150
|
+
nil
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def first_scene(definition)
|
|
154
|
+
definition.fetch(:scenes, []).first
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def fallback_scene
|
|
158
|
+
{
|
|
159
|
+
name: @config.scene_file.basename(".rb").to_sym,
|
|
160
|
+
layers: []
|
|
161
|
+
}
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def start_midi_runtime(definition, broadcaster)
|
|
165
|
+
settings = midi_runtime_settings(definition)
|
|
166
|
+
return nil unless settings[:enabled]
|
|
167
|
+
|
|
168
|
+
midi_input = Vizcore::Audio::MidiInput.new(device: settings[:device])
|
|
169
|
+
executor = Vizcore::DSL::MidiMapExecutor.new(
|
|
170
|
+
midi_maps: settings[:midi_maps],
|
|
171
|
+
scenes: settings[:scenes],
|
|
172
|
+
globals: settings[:globals]
|
|
173
|
+
)
|
|
174
|
+
midi_input.start { |event| handle_midi_event(executor, event, broadcaster) }
|
|
175
|
+
@output.puts("MIDI mapping enabled#{settings[:device] ? " (device=#{settings[:device]})" : ""}")
|
|
176
|
+
|
|
177
|
+
{
|
|
178
|
+
input: midi_input,
|
|
179
|
+
executor: executor,
|
|
180
|
+
device: settings[:device]
|
|
181
|
+
}
|
|
182
|
+
rescue StandardError => e
|
|
183
|
+
@output.puts(Vizcore::ErrorFormatting.summarize(e, context: "MIDI runtime disabled"))
|
|
184
|
+
midi_input&.stop
|
|
185
|
+
nil
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def refresh_midi_runtime(runtime, definition, broadcaster)
|
|
189
|
+
settings = midi_runtime_settings(definition)
|
|
190
|
+
return stop_midi_runtime(runtime) unless settings[:enabled]
|
|
191
|
+
return start_midi_runtime(definition, broadcaster) unless runtime
|
|
192
|
+
|
|
193
|
+
if runtime[:device] != settings[:device]
|
|
194
|
+
stop_midi_runtime(runtime)
|
|
195
|
+
return start_midi_runtime(definition, broadcaster)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
runtime[:executor].update(
|
|
199
|
+
midi_maps: settings[:midi_maps],
|
|
200
|
+
scenes: settings[:scenes],
|
|
201
|
+
globals: settings[:globals]
|
|
202
|
+
)
|
|
203
|
+
runtime
|
|
204
|
+
rescue StandardError => e
|
|
205
|
+
@output.puts(Vizcore::ErrorFormatting.summarize(e, context: "MIDI runtime update failed"))
|
|
206
|
+
runtime
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def stop_midi_runtime(runtime)
|
|
210
|
+
return nil unless runtime
|
|
211
|
+
|
|
212
|
+
runtime[:input]&.stop
|
|
213
|
+
nil
|
|
214
|
+
rescue StandardError => e
|
|
215
|
+
@output.puts(Vizcore::ErrorFormatting.summarize(e, context: "MIDI runtime shutdown failed"))
|
|
216
|
+
nil
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def handle_midi_event(executor, event, broadcaster)
|
|
220
|
+
actions = executor.handle_event(event)
|
|
221
|
+
actions.each do |action|
|
|
222
|
+
apply_midi_action(action, executor, broadcaster)
|
|
223
|
+
end
|
|
224
|
+
rescue StandardError => e
|
|
225
|
+
@output.puts(Vizcore::ErrorFormatting.summarize(e, context: "MIDI action failed"))
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def register_client_message_handler(broadcaster)
|
|
229
|
+
Vizcore::Server::WebSocketHandler.on_message do |message|
|
|
230
|
+
handle_client_message(message, broadcaster)
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def handle_client_message(message, broadcaster)
|
|
235
|
+
type = message["type"] || message[:type]
|
|
236
|
+
payload = message["payload"] || message[:payload]
|
|
237
|
+
case type.to_s
|
|
238
|
+
when "transport_sync"
|
|
239
|
+
return unless @config.audio_source == :file
|
|
240
|
+
|
|
241
|
+
values = Hash(payload)
|
|
242
|
+
broadcaster.sync_transport(
|
|
243
|
+
playing: values.fetch("playing", values.fetch(:playing, false)),
|
|
244
|
+
position_seconds: values.fetch("position_seconds", values.fetch(:position_seconds, 0.0))
|
|
245
|
+
)
|
|
246
|
+
when "switch_scene"
|
|
247
|
+
values = Hash(payload)
|
|
248
|
+
target_name = values.fetch("scene", values.fetch(:scene, values.fetch("scene_name", values.fetch(:scene_name, nil))))
|
|
249
|
+
switch_scene_from_client(target_name, broadcaster)
|
|
250
|
+
end
|
|
251
|
+
rescue StandardError => e
|
|
252
|
+
@output.puts(Vizcore::ErrorFormatting.summarize(e, context: "Client control message failed"))
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def apply_midi_action(action, executor, broadcaster)
|
|
256
|
+
case action[:type]
|
|
257
|
+
when :switch_scene
|
|
258
|
+
target_scene = action[:scene]
|
|
259
|
+
return unless target_scene
|
|
260
|
+
|
|
261
|
+
current = broadcaster.current_scene_snapshot
|
|
262
|
+
from_scene = current[:name]
|
|
263
|
+
broadcaster.update_scene(scene_name: target_scene[:name], scene_layers: target_scene[:layers])
|
|
264
|
+
WebSocketHandler.broadcast(
|
|
265
|
+
type: "scene_change",
|
|
266
|
+
payload: {
|
|
267
|
+
from: from_scene.to_s,
|
|
268
|
+
to: target_scene[:name].to_s,
|
|
269
|
+
effect: action[:effect],
|
|
270
|
+
source: "midi"
|
|
271
|
+
}
|
|
272
|
+
)
|
|
273
|
+
when :set_global
|
|
274
|
+
WebSocketHandler.broadcast(
|
|
275
|
+
type: "config_update",
|
|
276
|
+
payload: {
|
|
277
|
+
globals: executor.globals
|
|
278
|
+
}
|
|
279
|
+
)
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def midi_runtime_settings(definition)
|
|
284
|
+
midi_inputs = Array(definition[:midi])
|
|
285
|
+
|
|
286
|
+
{
|
|
287
|
+
enabled: !Array(definition[:midi_maps]).empty?,
|
|
288
|
+
midi_maps: Array(definition[:midi_maps]),
|
|
289
|
+
scenes: Array(definition[:scenes]),
|
|
290
|
+
globals: Hash(definition[:globals] || {}),
|
|
291
|
+
device: midi_inputs.first&.dig(:options, :device)
|
|
292
|
+
}
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def resolve_shader_sources(definition)
|
|
296
|
+
@shader_source_resolver.resolve(definition: definition, scene_file: @config.scene_file.to_s)
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def replace_scene_catalog(scenes)
|
|
300
|
+
@scene_catalog_mutex.synchronize do
|
|
301
|
+
@scene_catalog = Array(scenes)
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def scene_names_for(definition)
|
|
306
|
+
Array(definition[:scenes]).filter_map do |scene|
|
|
307
|
+
name = scene.dig(:name) || scene["name"]
|
|
308
|
+
next if name.nil?
|
|
309
|
+
|
|
310
|
+
value = name.to_s.strip
|
|
311
|
+
next if value.empty?
|
|
312
|
+
|
|
313
|
+
value
|
|
314
|
+
end
|
|
315
|
+
rescue StandardError
|
|
316
|
+
[]
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def switch_scene_from_client(target_name, broadcaster)
|
|
320
|
+
requested = target_name.to_s.strip
|
|
321
|
+
return if requested.empty?
|
|
322
|
+
|
|
323
|
+
target_scene = find_scene_catalog_scene(requested)
|
|
324
|
+
return unless target_scene
|
|
325
|
+
|
|
326
|
+
current = broadcaster.current_scene_snapshot
|
|
327
|
+
from_scene = current[:name]
|
|
328
|
+
broadcaster.update_scene(scene_name: target_scene[:name], scene_layers: target_scene[:layers])
|
|
329
|
+
WebSocketHandler.broadcast(
|
|
330
|
+
type: "scene_change",
|
|
331
|
+
payload: {
|
|
332
|
+
from: from_scene.to_s,
|
|
333
|
+
to: target_scene[:name].to_s,
|
|
334
|
+
effect: nil,
|
|
335
|
+
source: "ui"
|
|
336
|
+
}
|
|
337
|
+
)
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
def find_scene_catalog_scene(name)
|
|
341
|
+
@scene_catalog_mutex.synchronize do
|
|
342
|
+
Array(@scene_catalog).each do |scene|
|
|
343
|
+
raw_name = scene.dig(:name) || scene["name"]
|
|
344
|
+
next unless raw_name
|
|
345
|
+
next unless raw_name.to_s == name
|
|
346
|
+
|
|
347
|
+
layers = scene.dig(:layers) || scene["layers"]
|
|
348
|
+
return { name: raw_name.to_sym, layers: Array(layers) }
|
|
349
|
+
end
|
|
350
|
+
nil
|
|
351
|
+
end
|
|
352
|
+
rescue StandardError
|
|
353
|
+
nil
|
|
354
|
+
end
|
|
355
|
+
end
|
|
356
|
+
end
|
|
357
|
+
end
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "set"
|
|
5
|
+
require "thread"
|
|
6
|
+
require_relative "../errors"
|
|
7
|
+
|
|
8
|
+
module Vizcore
|
|
9
|
+
module Server
|
|
10
|
+
# Stateless WebSocket endpoint manager for frame broadcast transport.
|
|
11
|
+
class WebSocketHandler
|
|
12
|
+
class << self
|
|
13
|
+
# Rack endpoint for WebSocket upgrade handling.
|
|
14
|
+
#
|
|
15
|
+
# @param env [Hash]
|
|
16
|
+
# @return [Array]
|
|
17
|
+
def call(env)
|
|
18
|
+
websocket_klass = faye_websocket_class
|
|
19
|
+
return dependency_error_response unless websocket_klass
|
|
20
|
+
return [426, text_headers, ["WebSocket upgrade required"]] unless websocket_klass.websocket?(env)
|
|
21
|
+
|
|
22
|
+
socket = websocket_klass.new(env, nil, ping: 15)
|
|
23
|
+
|
|
24
|
+
socket.on(:open) { register(socket) }
|
|
25
|
+
socket.on(:close) { unregister(socket) }
|
|
26
|
+
socket.on(:message) { |event| handle_message(socket, event.data) }
|
|
27
|
+
|
|
28
|
+
socket.rack_response
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Broadcast one typed payload to all active websocket clients.
|
|
32
|
+
#
|
|
33
|
+
# @param type [String]
|
|
34
|
+
# @param payload [Hash]
|
|
35
|
+
# @return [Boolean] false when websocket backend is unavailable
|
|
36
|
+
def broadcast(type:, payload:)
|
|
37
|
+
return false unless faye_websocket_class
|
|
38
|
+
|
|
39
|
+
message = JSON.generate(type: type, payload: payload)
|
|
40
|
+
|
|
41
|
+
each_socket do |socket|
|
|
42
|
+
send_message(socket, message)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
true
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# @return [Integer]
|
|
49
|
+
def connection_count
|
|
50
|
+
mutex.synchronize { sockets.size }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# @return [StandardError, nil]
|
|
54
|
+
def last_error
|
|
55
|
+
mutex.synchronize { @last_error }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Register one inbound message handler for client -> server control messages.
|
|
59
|
+
#
|
|
60
|
+
# @yieldparam message [Hash]
|
|
61
|
+
# @return [void]
|
|
62
|
+
def on_message(&block)
|
|
63
|
+
mutex.synchronize { @message_handler = block }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Clear inbound message handler.
|
|
67
|
+
#
|
|
68
|
+
# @return [void]
|
|
69
|
+
def clear_message_handler
|
|
70
|
+
mutex.synchronize { @message_handler = nil }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def faye_websocket_class
|
|
76
|
+
require "faye/websocket"
|
|
77
|
+
Faye::WebSocket
|
|
78
|
+
rescue LoadError
|
|
79
|
+
nil
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def send_message(socket, message)
|
|
83
|
+
if event_machine_reactor_running?
|
|
84
|
+
EventMachine.schedule { safe_send(socket, message) }
|
|
85
|
+
else
|
|
86
|
+
safe_send(socket, message)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def safe_send(socket, message)
|
|
91
|
+
socket.send(message)
|
|
92
|
+
rescue StandardError => e
|
|
93
|
+
set_last_error(e)
|
|
94
|
+
unregister(socket)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def event_machine_reactor_running?
|
|
98
|
+
require "eventmachine"
|
|
99
|
+
EventMachine.reactor_running?
|
|
100
|
+
rescue LoadError
|
|
101
|
+
false
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def dependency_error_response
|
|
105
|
+
[500, json_headers, [JSON.generate(error: "Missing dependency: faye-websocket")]]
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def handle_message(_socket, raw_message)
|
|
109
|
+
message = JSON.parse(raw_message)
|
|
110
|
+
dispatch_message(message)
|
|
111
|
+
message
|
|
112
|
+
rescue JSON::ParserError => e
|
|
113
|
+
set_last_error(e)
|
|
114
|
+
nil
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def register(socket)
|
|
118
|
+
mutex.synchronize { sockets << socket }
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def unregister(socket)
|
|
122
|
+
mutex.synchronize { sockets.delete(socket) }
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def each_socket(&block)
|
|
126
|
+
snapshot = mutex.synchronize { sockets.dup }
|
|
127
|
+
snapshot.each(&block)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def sockets
|
|
131
|
+
@sockets ||= Set.new
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def mutex
|
|
135
|
+
@mutex ||= Mutex.new
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def set_last_error(error)
|
|
139
|
+
mutex.synchronize { @last_error = error }
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def dispatch_message(message)
|
|
143
|
+
handler = mutex.synchronize { @message_handler }
|
|
144
|
+
return unless handler
|
|
145
|
+
return unless message.is_a?(Hash)
|
|
146
|
+
|
|
147
|
+
handler.call(message)
|
|
148
|
+
rescue StandardError => e
|
|
149
|
+
set_last_error(e)
|
|
150
|
+
nil
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def text_headers
|
|
154
|
+
{ "content-type" => "text/plain; charset=utf-8" }
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def json_headers
|
|
158
|
+
{ "content-type" => "application/json; charset=utf-8" }
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Vizcore
|
|
4
|
+
# Rack/WebSocket server runtime namespace.
|
|
5
|
+
module Server
|
|
6
|
+
end
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
require_relative "server/frame_broadcaster"
|
|
10
|
+
require_relative "server/rack_app"
|
|
11
|
+
require_relative "server/runner"
|
|
12
|
+
require_relative "server/websocket_handler"
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# {{project_name}} custom GLSL shader example.
|
|
4
|
+
Vizcore.define do
|
|
5
|
+
scene :shader_art do
|
|
6
|
+
layer :kaleidoscope do
|
|
7
|
+
type :shader
|
|
8
|
+
glsl "../shaders/custom_wave.frag"
|
|
9
|
+
map amplitude => :param_intensity
|
|
10
|
+
map frequency_band(:low) => :param_bass
|
|
11
|
+
map beat? => :param_flash
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
layer :title do
|
|
15
|
+
type :text
|
|
16
|
+
content "{{project_name}}"
|
|
17
|
+
font_size 72
|
|
18
|
+
glow_strength 0.0
|
|
19
|
+
color "#f5f9ff"
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
#version 300 es
|
|
2
|
+
precision mediump float;
|
|
3
|
+
|
|
4
|
+
uniform vec2 u_resolution;
|
|
5
|
+
uniform float u_time;
|
|
6
|
+
uniform float u_amplitude;
|
|
7
|
+
uniform float u_bass;
|
|
8
|
+
uniform float u_param_intensity;
|
|
9
|
+
uniform float u_param_bass;
|
|
10
|
+
uniform float u_param_flash;
|
|
11
|
+
out vec4 outColor;
|
|
12
|
+
|
|
13
|
+
void main() {
|
|
14
|
+
vec2 uv = gl_FragCoord.xy / u_resolution.xy;
|
|
15
|
+
vec2 centered = uv * 2.0 - 1.0;
|
|
16
|
+
centered.x *= u_resolution.x / max(u_resolution.y, 1.0);
|
|
17
|
+
|
|
18
|
+
float intensity = max(u_param_intensity, u_amplitude);
|
|
19
|
+
float bass = max(u_param_bass, u_bass);
|
|
20
|
+
float wave = sin(centered.x * 9.0 + u_time * (2.4 + bass * 7.0));
|
|
21
|
+
float lineDistance = abs(centered.y - wave * 0.28);
|
|
22
|
+
float core = smoothstep(0.045 + intensity * 0.03, 0.0, lineDistance);
|
|
23
|
+
float halo = smoothstep(0.12 + intensity * 0.06, 0.0, lineDistance);
|
|
24
|
+
|
|
25
|
+
vec3 color = vec3(0.02, 0.03, 0.08);
|
|
26
|
+
color += vec3(0.22, 0.68, 0.92) * halo * (0.22 + intensity * 0.45);
|
|
27
|
+
color += vec3(0.50, 0.92, 1.0) * core * (0.55 + intensity * 0.55);
|
|
28
|
+
color += vec3(0.95, 0.28, 0.44) * u_param_flash * 0.18;
|
|
29
|
+
|
|
30
|
+
outColor = vec4(color, 1.0);
|
|
31
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# {{project_name}} transition example.
|
|
4
|
+
Vizcore.define do
|
|
5
|
+
scene :intro do
|
|
6
|
+
layer :background do
|
|
7
|
+
shader :neon_grid
|
|
8
|
+
opacity 0.82
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
layer :geometry do
|
|
12
|
+
type :wireframe_cube
|
|
13
|
+
map fft_spectrum => :deform
|
|
14
|
+
map amplitude => :rotation_speed
|
|
15
|
+
map frequency_band(:high) => :color_shift
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
scene :drop do
|
|
20
|
+
layer :particles do
|
|
21
|
+
type :particle_field
|
|
22
|
+
count 3600
|
|
23
|
+
map amplitude => :speed
|
|
24
|
+
map frequency_band(:low) => :size
|
|
25
|
+
map beat? => :flash
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
layer :title do
|
|
29
|
+
type :text
|
|
30
|
+
content "{{project_name}}"
|
|
31
|
+
font_size 96
|
|
32
|
+
map beat? => :flash
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
transition from: :intro, to: :drop do
|
|
37
|
+
trigger { beat_count >= 64 || frame_count >= 360 }
|
|
38
|
+
effect :crossfade, duration: 1.4
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# {{project_name}} MIDI mapping example.
|
|
4
|
+
Vizcore.define do
|
|
5
|
+
midi :controller, device: :default
|
|
6
|
+
|
|
7
|
+
scene :warmup do
|
|
8
|
+
layer :warm_bg do
|
|
9
|
+
shader :neon_grid
|
|
10
|
+
map frequency_band(:mid) => :intensity
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
scene :impact do
|
|
15
|
+
layer :impact_bg do
|
|
16
|
+
shader :glitch_flash
|
|
17
|
+
map beat? => :flash
|
|
18
|
+
map amplitude => :effect_intensity
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
midi_map note: 36 do
|
|
23
|
+
switch_scene :impact
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
midi_map note: 38 do
|
|
27
|
+
switch_scene :warmup
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
midi_map cc: 1 do |value|
|
|
31
|
+
set :global_intensity, value / 127.0
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# {{project_name}}
|
|
2
|
+
|
|
3
|
+
This project was generated by `vizcore new`.
|
|
4
|
+
|
|
5
|
+
## Start
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
vizcore start scenes/basic.rb
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Included Scenes
|
|
12
|
+
|
|
13
|
+
- `scenes/basic.rb`: Minimal wireframe starter
|
|
14
|
+
- `scenes/intro_drop.rb`: Transition flow with beat trigger
|
|
15
|
+
- `scenes/midi_control.rb`: MIDI note/CC mapping example
|
|
16
|
+
- `scenes/custom_shader.rb`: Custom GLSL + post/VJ effect example
|
|
17
|
+
|
|
18
|
+
## Custom Shader
|
|
19
|
+
|
|
20
|
+
`scenes/custom_shader.rb` references `shaders/custom_wave.frag`.
|
|
21
|
+
Edit that file and save to see hot-reload updates.
|
|
22
|
+
|
|
23
|
+
## MIDI
|
|
24
|
+
|
|
25
|
+
List devices:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
vizcore devices midi
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Start MIDI example:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
vizcore start scenes/midi_control.rb
|
|
35
|
+
```
|