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.
Files changed (71) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +170 -0
  4. data/docs/GETTING_STARTED.md +105 -0
  5. data/examples/assets/complex_demo_loop.wav +0 -0
  6. data/examples/basic.rb +9 -0
  7. data/examples/complex_audio_showcase.rb +261 -0
  8. data/examples/custom_shader.rb +21 -0
  9. data/examples/file_audio_demo.rb +74 -0
  10. data/examples/intro_drop.rb +38 -0
  11. data/examples/midi_scene_switch.rb +32 -0
  12. data/examples/shaders/custom_wave.frag +30 -0
  13. data/exe/vizcore +6 -0
  14. data/frontend/index.html +148 -0
  15. data/frontend/src/main.js +304 -0
  16. data/frontend/src/renderer/engine.js +135 -0
  17. data/frontend/src/renderer/layer-manager.js +456 -0
  18. data/frontend/src/renderer/shader-manager.js +69 -0
  19. data/frontend/src/shaders/builtins.js +244 -0
  20. data/frontend/src/shaders/post-effects.js +85 -0
  21. data/frontend/src/visuals/geometry.js +66 -0
  22. data/frontend/src/visuals/particle-system.js +148 -0
  23. data/frontend/src/visuals/text-renderer.js +143 -0
  24. data/frontend/src/visuals/vj-effects.js +56 -0
  25. data/frontend/src/websocket-client.js +131 -0
  26. data/lib/vizcore/analysis/band_splitter.rb +63 -0
  27. data/lib/vizcore/analysis/beat_detector.rb +70 -0
  28. data/lib/vizcore/analysis/bpm_estimator.rb +86 -0
  29. data/lib/vizcore/analysis/fft_processor.rb +224 -0
  30. data/lib/vizcore/analysis/fftw_ffi.rb +50 -0
  31. data/lib/vizcore/analysis/pipeline.rb +72 -0
  32. data/lib/vizcore/analysis/smoother.rb +74 -0
  33. data/lib/vizcore/analysis.rb +14 -0
  34. data/lib/vizcore/audio/base_input.rb +39 -0
  35. data/lib/vizcore/audio/dummy_sine_input.rb +40 -0
  36. data/lib/vizcore/audio/file_input.rb +163 -0
  37. data/lib/vizcore/audio/input_manager.rb +133 -0
  38. data/lib/vizcore/audio/mic_input.rb +121 -0
  39. data/lib/vizcore/audio/midi_input.rb +246 -0
  40. data/lib/vizcore/audio/portaudio_ffi.rb +243 -0
  41. data/lib/vizcore/audio/ring_buffer.rb +92 -0
  42. data/lib/vizcore/audio.rb +16 -0
  43. data/lib/vizcore/cli.rb +115 -0
  44. data/lib/vizcore/config.rb +46 -0
  45. data/lib/vizcore/dsl/engine.rb +229 -0
  46. data/lib/vizcore/dsl/file_watcher.rb +108 -0
  47. data/lib/vizcore/dsl/layer_builder.rb +182 -0
  48. data/lib/vizcore/dsl/mapping_resolver.rb +81 -0
  49. data/lib/vizcore/dsl/midi_map_executor.rb +188 -0
  50. data/lib/vizcore/dsl/scene_builder.rb +44 -0
  51. data/lib/vizcore/dsl/shader_source_resolver.rb +71 -0
  52. data/lib/vizcore/dsl/transition_controller.rb +166 -0
  53. data/lib/vizcore/dsl.rb +16 -0
  54. data/lib/vizcore/errors.rb +27 -0
  55. data/lib/vizcore/renderer/frame_scheduler.rb +75 -0
  56. data/lib/vizcore/renderer/scene_serializer.rb +73 -0
  57. data/lib/vizcore/renderer.rb +10 -0
  58. data/lib/vizcore/server/frame_broadcaster.rb +351 -0
  59. data/lib/vizcore/server/rack_app.rb +183 -0
  60. data/lib/vizcore/server/runner.rb +357 -0
  61. data/lib/vizcore/server/websocket_handler.rb +163 -0
  62. data/lib/vizcore/server.rb +12 -0
  63. data/lib/vizcore/templates/basic_scene.rb +10 -0
  64. data/lib/vizcore/templates/custom_shader_scene.rb +22 -0
  65. data/lib/vizcore/templates/custom_wave.frag +31 -0
  66. data/lib/vizcore/templates/intro_drop_scene.rb +40 -0
  67. data/lib/vizcore/templates/midi_control_scene.rb +33 -0
  68. data/lib/vizcore/templates/project_readme.md +35 -0
  69. data/lib/vizcore/version.rb +6 -0
  70. data/lib/vizcore.rb +37 -0
  71. metadata +186 -0
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ Vizcore.define do
4
+ midi :controller, device: :default
5
+
6
+ scene :warmup do
7
+ layer :grid do
8
+ shader :neon_grid
9
+ map frequency_band(:mid) => :intensity
10
+ end
11
+ end
12
+
13
+ scene :impact do
14
+ layer :glitch do
15
+ shader :glitch_flash
16
+ map beat? => :flash
17
+ map amplitude => :effect_intensity
18
+ end
19
+ end
20
+
21
+ midi_map note: 36 do
22
+ switch_scene :impact
23
+ end
24
+
25
+ midi_map note: 38 do
26
+ switch_scene :warmup
27
+ end
28
+
29
+ midi_map cc: 1 do |value|
30
+ set :global_intensity, value / 127.0
31
+ end
32
+ end
@@ -0,0 +1,30 @@
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
+ outColor = vec4(color, 1.0);
30
+ }
data/exe/vizcore ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "vizcore/cli"
5
+
6
+ Vizcore::CLI.start(ARGV)
@@ -0,0 +1,148 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Vizcore</title>
7
+ <style>
8
+ :root {
9
+ color-scheme: dark;
10
+ --bg-top: #071223;
11
+ --bg-bottom: #020308;
12
+ --fg: #dbecff;
13
+ --muted: #7a8ca8;
14
+ --panel-bg: rgba(2, 6, 14, 0.62);
15
+ --panel-border: rgba(153, 195, 255, 0.2);
16
+ }
17
+
18
+ * {
19
+ box-sizing: border-box;
20
+ }
21
+
22
+ html, body {
23
+ margin: 0;
24
+ width: 100%;
25
+ height: 100%;
26
+ overflow: hidden;
27
+ font-family: "IBM Plex Sans", "Noto Sans JP", sans-serif;
28
+ background: radial-gradient(circle at 20% 20%, var(--bg-top), var(--bg-bottom));
29
+ color: var(--fg);
30
+ }
31
+
32
+ #app {
33
+ position: relative;
34
+ width: 100%;
35
+ height: 100%;
36
+ }
37
+
38
+ #vizcore-canvas {
39
+ width: 100%;
40
+ height: 100%;
41
+ display: block;
42
+ }
43
+
44
+ .hud {
45
+ position: absolute;
46
+ left: 1rem;
47
+ top: 1rem;
48
+ padding: 0.75rem 1rem;
49
+ border: 1px solid var(--panel-border);
50
+ border-radius: 0.75rem;
51
+ background: var(--panel-bg);
52
+ backdrop-filter: blur(6px);
53
+ min-width: 18rem;
54
+ }
55
+
56
+ .hud h1 {
57
+ margin: 0 0 0.25rem;
58
+ font-size: 1rem;
59
+ font-weight: 700;
60
+ letter-spacing: 0.08em;
61
+ text-transform: uppercase;
62
+ }
63
+
64
+ .hud p {
65
+ margin: 0.1rem 0;
66
+ font-size: 0.85rem;
67
+ color: var(--muted);
68
+ }
69
+
70
+ .hud p.is-accent {
71
+ color: #c9f7ff;
72
+ }
73
+
74
+ .hud p.is-beat {
75
+ color: #9cffc4;
76
+ text-shadow: 0 0 10px rgba(101, 255, 176, 0.35);
77
+ }
78
+
79
+ .hud button {
80
+ margin-top: 0.35rem;
81
+ width: 100%;
82
+ border: 1px solid rgba(153, 195, 255, 0.35);
83
+ border-radius: 0.5rem;
84
+ padding: 0.45rem 0.6rem;
85
+ background: rgba(30, 64, 110, 0.55);
86
+ color: var(--fg);
87
+ font-size: 0.84rem;
88
+ cursor: pointer;
89
+ }
90
+
91
+ .hud button:hover:not([disabled]) {
92
+ background: rgba(58, 98, 151, 0.65);
93
+ }
94
+
95
+ .scene-switcher {
96
+ margin-top: 0.45rem;
97
+ display: grid;
98
+ grid-template-columns: repeat(2, minmax(0, 1fr));
99
+ gap: 0.35rem;
100
+ }
101
+
102
+ .scene-switcher[hidden] {
103
+ display: none;
104
+ }
105
+
106
+ .scene-switcher button {
107
+ margin-top: 0;
108
+ width: 100%;
109
+ padding: 0.35rem 0.45rem;
110
+ font-size: 0.78rem;
111
+ text-transform: uppercase;
112
+ letter-spacing: 0.05em;
113
+ }
114
+
115
+ .scene-switcher button.is-active {
116
+ border-color: rgba(130, 255, 196, 0.55);
117
+ background: rgba(20, 104, 73, 0.45);
118
+ color: #d9ffe8;
119
+ }
120
+
121
+ .hud button[disabled] {
122
+ opacity: 0.6;
123
+ cursor: not-allowed;
124
+ }
125
+ </style>
126
+ </head>
127
+ <body>
128
+ <div id="app">
129
+ <canvas id="vizcore-canvas"></canvas>
130
+ <section class="hud" aria-live="polite">
131
+ <h1>Vizcore Live</h1>
132
+ <p id="ws-status">WebSocket: connecting...</p>
133
+ <p id="scene-status">Scene: unknown</p>
134
+ <p id="transition-status">Transition: none</p>
135
+ <p id="frame-status">Amplitude: 0.0000</p>
136
+ <p id="bpm-status" class="is-accent">BPM: --</p>
137
+ <p id="beat-status">Beat: off | Count: 0</p>
138
+ <p id="audio-source-status">Audio Source: unknown</p>
139
+ <p id="audio-track-status">Track: none</p>
140
+ <p id="audio-playback-status">Playback: unavailable</p>
141
+ <div id="scene-switcher" class="scene-switcher" hidden aria-label="Scene switcher"></div>
142
+ <button id="audio-toggle" type="button" hidden>Play Audio</button>
143
+ </section>
144
+ </div>
145
+
146
+ <script type="module" src="/src/main.js"></script>
147
+ </body>
148
+ </html>
@@ -0,0 +1,304 @@
1
+ import { Engine } from "./renderer/engine.js";
2
+ import { WebSocketClient } from "./websocket-client.js";
3
+
4
+ const canvas = document.querySelector("#vizcore-canvas");
5
+ const wsStatusElement = document.querySelector("#ws-status");
6
+ const sceneStatusElement = document.querySelector("#scene-status");
7
+ const transitionStatusElement = document.querySelector("#transition-status");
8
+ const frameStatusElement = document.querySelector("#frame-status");
9
+ const bpmStatusElement = document.querySelector("#bpm-status");
10
+ const beatStatusElement = document.querySelector("#beat-status");
11
+ const audioSourceStatusElement = document.querySelector("#audio-source-status");
12
+ const audioTrackStatusElement = document.querySelector("#audio-track-status");
13
+ const audioPlaybackStatusElement = document.querySelector("#audio-playback-status");
14
+ const sceneSwitcherElement = document.querySelector("#scene-switcher");
15
+ const audioToggleButton = document.querySelector("#audio-toggle");
16
+
17
+ const engine = new Engine(canvas);
18
+ engine.init();
19
+ engine.start();
20
+
21
+ let currentSceneName = "unknown";
22
+ let audioElement = null;
23
+ let frameCount = 0;
24
+ let lastConnectedAt = null;
25
+ let lastTransportSyncAt = 0;
26
+ let beatFlashUntil = 0;
27
+ let availableSceneNames = [];
28
+ let pendingSceneName = null;
29
+ let pendingSceneRequestedAt = 0;
30
+
31
+ const websocketUrl = buildWebSocketUrl();
32
+ const client = new WebSocketClient(websocketUrl, {
33
+ onFrame: (frame) => {
34
+ engine.setAudioFrame(frame);
35
+ frameCount += 1;
36
+ let sceneName = String(frame?.scene?.name || currentSceneName);
37
+ const now = performance.now();
38
+ if (
39
+ pendingSceneName &&
40
+ sceneName !== pendingSceneName &&
41
+ now - pendingSceneRequestedAt < 350
42
+ ) {
43
+ sceneName = currentSceneName;
44
+ }
45
+ if (pendingSceneName && sceneName === pendingSceneName) {
46
+ pendingSceneName = null;
47
+ pendingSceneRequestedAt = 0;
48
+ }
49
+ const sceneChanged = sceneName !== currentSceneName;
50
+ currentSceneName = sceneName;
51
+ const amplitude = Number(frame?.audio?.amplitude || 0).toFixed(4);
52
+ const bpm = Number(frame?.audio?.bpm || 0);
53
+ const beat = !!frame?.audio?.beat;
54
+ const beatCount = Math.max(0, Number(frame?.audio?.beat_count || 0) || 0);
55
+ if (beat) {
56
+ beatFlashUntil = performance.now() + 180;
57
+ }
58
+ const beatVisible = performance.now() < beatFlashUntil;
59
+ sceneStatusElement.textContent = `Scene: ${sceneName}`;
60
+ if (sceneChanged) {
61
+ renderSceneButtons();
62
+ }
63
+ frameStatusElement.textContent = `Amplitude: ${amplitude} | Frames: ${frameCount}`;
64
+ bpmStatusElement.textContent = `BPM: ${bpm > 0 ? bpm.toFixed(1) : "--"}`;
65
+ beatStatusElement.textContent = `Beat: ${beatVisible ? "ON" : "off"} | Count: ${beatCount}`;
66
+ beatStatusElement.classList.toggle("is-beat", beatVisible);
67
+ },
68
+ onSceneChange: (payload) => {
69
+ const from = String(payload?.from || "unknown");
70
+ const to = String(payload?.to || "unknown");
71
+ pendingSceneName = null;
72
+ pendingSceneRequestedAt = 0;
73
+ currentSceneName = to;
74
+ sceneStatusElement.textContent = `Scene: ${to}`;
75
+ transitionStatusElement.textContent = `Transition: ${from} -> ${to}`;
76
+ renderSceneButtons();
77
+ },
78
+ onConfigUpdate: (payload) => {
79
+ updateAvailableScenes(payload?.scenes);
80
+ const sceneName = payload?.scene?.name;
81
+ if (sceneName) {
82
+ currentSceneName = String(sceneName);
83
+ sceneStatusElement.textContent = `Scene: ${currentSceneName}`;
84
+ renderSceneButtons();
85
+ }
86
+ },
87
+ onStatus: (status) => {
88
+ if (status === "connected") {
89
+ lastConnectedAt = new Date();
90
+ syncAudioTransportToServer({ force: true });
91
+ } else {
92
+ pendingSceneName = null;
93
+ pendingSceneRequestedAt = 0;
94
+ currentSceneName = "unknown";
95
+ sceneStatusElement.textContent = "Scene: unknown";
96
+ renderSceneButtons();
97
+ }
98
+ const connectedAt = lastConnectedAt ? ` | Last connected: ${formatClock(lastConnectedAt)}` : "";
99
+ wsStatusElement.textContent = `WebSocket: ${status}${connectedAt}`;
100
+ }
101
+ });
102
+
103
+ client.connect();
104
+ void initializeRuntime();
105
+
106
+ async function initializeRuntime() {
107
+ const runtime = await fetchRuntime();
108
+ applyRuntime(runtime);
109
+ }
110
+
111
+ async function fetchRuntime() {
112
+ try {
113
+ const response = await fetch("/runtime", { cache: "no-store" });
114
+ if (!response.ok) {
115
+ return null;
116
+ }
117
+ return await response.json();
118
+ } catch {
119
+ return null;
120
+ }
121
+ }
122
+
123
+ function applyRuntime(runtime) {
124
+ const source = String(runtime?.audio_source || "unknown");
125
+ audioSourceStatusElement.textContent = `Audio Source: ${source}`;
126
+ updateAvailableScenes(runtime?.scene_names);
127
+
128
+ const fileName = runtime?.audio_file_name;
129
+ const fileUrl = runtime?.audio_file_url;
130
+ if (!fileUrl) {
131
+ engine.setMediaElement(null);
132
+ audioTrackStatusElement.textContent = "Track: none";
133
+ audioPlaybackStatusElement.textContent = "Playback: unavailable";
134
+ audioToggleButton.hidden = true;
135
+ return;
136
+ }
137
+
138
+ audioTrackStatusElement.textContent = `Track: ${String(fileName || "source file")}`;
139
+ setupAudioPlayback(fileUrl);
140
+ }
141
+
142
+ function updateAvailableScenes(sceneValues) {
143
+ const names = normalizeSceneNames(sceneValues);
144
+ if (!names.length) {
145
+ return;
146
+ }
147
+ availableSceneNames = names;
148
+ renderSceneButtons();
149
+ }
150
+
151
+ function normalizeSceneNames(sceneValues) {
152
+ const seen = new Set();
153
+ const names = [];
154
+ const entries = Array.isArray(sceneValues) ? sceneValues : [];
155
+
156
+ for (const entry of entries) {
157
+ const rawName = typeof entry === "string" ? entry : entry?.name;
158
+ const name = String(rawName || "").trim();
159
+ if (!name || seen.has(name)) {
160
+ continue;
161
+ }
162
+ seen.add(name);
163
+ names.push(name);
164
+ }
165
+
166
+ return names;
167
+ }
168
+
169
+ function renderSceneButtons() {
170
+ if (!sceneSwitcherElement) {
171
+ return;
172
+ }
173
+
174
+ if (!availableSceneNames.length) {
175
+ sceneSwitcherElement.hidden = true;
176
+ sceneSwitcherElement.replaceChildren();
177
+ return;
178
+ }
179
+
180
+ sceneSwitcherElement.hidden = false;
181
+ const buttons = availableSceneNames.map((sceneName) => {
182
+ const button = document.createElement("button");
183
+ button.type = "button";
184
+ button.textContent = sceneName;
185
+ button.classList.toggle("is-active", sceneName === currentSceneName);
186
+ button.onclick = () => {
187
+ if (sceneName === currentSceneName) {
188
+ return;
189
+ }
190
+ pendingSceneName = sceneName;
191
+ pendingSceneRequestedAt = performance.now();
192
+ currentSceneName = sceneName;
193
+ sceneStatusElement.textContent = `Scene: ${sceneName}`;
194
+ renderSceneButtons();
195
+ client.send("switch_scene", { scene: sceneName });
196
+ };
197
+ return button;
198
+ });
199
+ sceneSwitcherElement.replaceChildren(...buttons);
200
+ }
201
+
202
+ function setupAudioPlayback(audioUrl) {
203
+ if (audioElement) {
204
+ audioElement.pause();
205
+ }
206
+
207
+ audioElement = new Audio(audioUrl);
208
+ audioElement.preload = "auto";
209
+ audioElement.loop = true;
210
+ engine.setMediaElement(audioElement);
211
+
212
+ audioToggleButton.hidden = false;
213
+ audioToggleButton.disabled = false;
214
+
215
+ const updatePlaybackState = () => {
216
+ if (!audioElement) {
217
+ return;
218
+ }
219
+ const state = audioElement.paused ? "paused" : "playing";
220
+ const current = formatSeconds(audioElement.currentTime);
221
+ const duration = Number.isFinite(audioElement.duration) ? formatSeconds(audioElement.duration) : "--:--";
222
+ audioPlaybackStatusElement.textContent = `Playback: ${state} ${current} / ${duration}`;
223
+ audioToggleButton.textContent = audioElement.paused ? "Play Audio" : "Pause Audio";
224
+ };
225
+
226
+ const playAudio = async () => {
227
+ if (!audioElement) {
228
+ return;
229
+ }
230
+ try {
231
+ await audioElement.play();
232
+ updatePlaybackState();
233
+ } catch (error) {
234
+ const message = String(error?.message || "autoplay blocked");
235
+ audioPlaybackStatusElement.textContent = `Playback: blocked (${message})`;
236
+ audioToggleButton.textContent = "Play Audio";
237
+ }
238
+ };
239
+
240
+ audioElement.addEventListener("play", updatePlaybackState);
241
+ audioElement.addEventListener("pause", updatePlaybackState);
242
+ audioElement.addEventListener("timeupdate", updatePlaybackState);
243
+ audioElement.addEventListener("loadedmetadata", updatePlaybackState);
244
+ audioElement.addEventListener("play", () => syncAudioTransportToServer({ force: true }));
245
+ audioElement.addEventListener("pause", () => syncAudioTransportToServer({ force: true }));
246
+ audioElement.addEventListener("seeking", () => syncAudioTransportToServer({ force: true }));
247
+ audioElement.addEventListener("seeked", () => syncAudioTransportToServer({ force: true }));
248
+ audioElement.addEventListener("loadedmetadata", () => syncAudioTransportToServer({ force: true }));
249
+ audioElement.addEventListener("timeupdate", () => syncAudioTransportToServer());
250
+
251
+ audioToggleButton.onclick = async () => {
252
+ if (!audioElement) {
253
+ return;
254
+ }
255
+ if (audioElement.paused) {
256
+ await playAudio();
257
+ return;
258
+ }
259
+ audioElement.pause();
260
+ updatePlaybackState();
261
+ };
262
+
263
+ updatePlaybackState();
264
+ syncAudioTransportToServer({ force: true });
265
+ void playAudio();
266
+ }
267
+
268
+ function formatSeconds(value) {
269
+ const seconds = Math.max(0, Math.floor(Number(value) || 0));
270
+ const minutes = Math.floor(seconds / 60);
271
+ const remain = seconds % 60;
272
+ return `${String(minutes).padStart(2, "0")}:${String(remain).padStart(2, "0")}`;
273
+ }
274
+
275
+ function formatClock(date) {
276
+ const hours = String(date.getHours()).padStart(2, "0");
277
+ const minutes = String(date.getMinutes()).padStart(2, "0");
278
+ const seconds = String(date.getSeconds()).padStart(2, "0");
279
+ return `${hours}:${minutes}:${seconds}`;
280
+ }
281
+
282
+ function syncAudioTransportToServer({ force = false } = {}) {
283
+ if (!audioElement) {
284
+ return;
285
+ }
286
+
287
+ const now = performance.now();
288
+ if (!force && now - lastTransportSyncAt < 80) {
289
+ return;
290
+ }
291
+
292
+ const sent = client.send("transport_sync", {
293
+ playing: !audioElement.paused,
294
+ position_seconds: Number(audioElement.currentTime || 0)
295
+ });
296
+ if (sent) {
297
+ lastTransportSyncAt = now;
298
+ }
299
+ }
300
+
301
+ function buildWebSocketUrl() {
302
+ const protocol = window.location.protocol === "https:" ? "wss" : "ws";
303
+ return `${protocol}://${window.location.host}/ws`;
304
+ }
@@ -0,0 +1,135 @@
1
+ import { LayerManager } from "./layer-manager.js";
2
+ import { ShaderManager } from "./shader-manager.js";
3
+
4
+ export class Engine {
5
+ constructor(canvas) {
6
+ this.canvas = canvas;
7
+ this.gl = null;
8
+ this.shaderManager = null;
9
+ this.layerManager = null;
10
+ this.lastTime = performance.now();
11
+ this.rotation = 0;
12
+ this.currentRotationSpeed = 0.5;
13
+ this.mediaElement = null;
14
+ this.lastMediaTime = null;
15
+ this.frame = {
16
+ audio: {
17
+ amplitude: 0,
18
+ bands: { sub: 0, low: 0, mid: 0, high: 0 },
19
+ fft: [],
20
+ beat: false,
21
+ beat_count: 0,
22
+ bpm: 0
23
+ },
24
+ scene: {
25
+ name: "basic",
26
+ layers: []
27
+ }
28
+ };
29
+ }
30
+
31
+ init() {
32
+ this.gl = this.canvas.getContext("webgl2");
33
+ if (!this.gl) {
34
+ throw new Error("WebGL2 is not supported in this browser");
35
+ }
36
+
37
+ this.shaderManager = new ShaderManager(this.gl);
38
+ this.layerManager = new LayerManager(this.gl, this.shaderManager);
39
+
40
+ this.gl.enable(this.gl.DEPTH_TEST);
41
+ this.gl.enable(this.gl.BLEND);
42
+ this.gl.blendFunc(this.gl.SRC_ALPHA, this.gl.ONE_MINUS_SRC_ALPHA);
43
+ this.resize();
44
+ window.addEventListener("resize", () => this.resize());
45
+ }
46
+
47
+ setAudioFrame(frame) {
48
+ if (!frame || typeof frame !== "object") {
49
+ return;
50
+ }
51
+ this.frame = frame;
52
+ }
53
+
54
+ setMediaElement(mediaElement) {
55
+ this.mediaElement = mediaElement || null;
56
+ this.lastMediaTime = null;
57
+ }
58
+
59
+ start() {
60
+ this.lastTime = performance.now();
61
+ requestAnimationFrame((time) => this.render(time));
62
+ }
63
+
64
+ resize() {
65
+ const width = Math.floor(this.canvas.clientWidth * window.devicePixelRatio);
66
+ const height = Math.floor(this.canvas.clientHeight * window.devicePixelRatio);
67
+ if (this.canvas.width === width && this.canvas.height === height) {
68
+ return;
69
+ }
70
+ this.canvas.width = width;
71
+ this.canvas.height = height;
72
+ this.gl.viewport(0, 0, width, height);
73
+ }
74
+
75
+ render(time) {
76
+ let deltaSeconds = (time - this.lastTime) / 1000;
77
+ this.lastTime = time;
78
+ let visualTimeSeconds = time / 1000;
79
+
80
+ if (this.mediaElement) {
81
+ const currentMediaTime = Number(this.mediaElement.currentTime || 0);
82
+ visualTimeSeconds = currentMediaTime;
83
+
84
+ if (this.mediaElement.paused) {
85
+ deltaSeconds = 0;
86
+ } else if (this.lastMediaTime === null) {
87
+ deltaSeconds = 0;
88
+ } else {
89
+ deltaSeconds = Math.max(0, currentMediaTime - this.lastMediaTime);
90
+ }
91
+
92
+ this.lastMediaTime = currentMediaTime;
93
+ } else {
94
+ this.lastMediaTime = null;
95
+ }
96
+
97
+ const audio = this.frame?.audio || {};
98
+ const layers = Array.isArray(this.frame?.scene?.layers) ? this.frame.scene.layers : [];
99
+ const amplitude = clamp(Number(audio.amplitude || 0), 0, 1);
100
+ const rotationSpeed = resolveRotationSpeed(layers, amplitude);
101
+ this.currentRotationSpeed += (rotationSpeed - this.currentRotationSpeed) * 0.1;
102
+ this.rotation += deltaSeconds * this.currentRotationSpeed;
103
+
104
+ this.gl.clearColor(
105
+ 0.02 + amplitude * 0.05,
106
+ 0.03 + clamp(Number(audio?.bands?.high || 0), 0, 1) * 0.08,
107
+ 0.08 + amplitude * 0.06,
108
+ 1.0
109
+ );
110
+ this.gl.clear(this.gl.COLOR_BUFFER_BIT | this.gl.DEPTH_BUFFER_BIT);
111
+
112
+ this.layerManager.renderScene({
113
+ layers,
114
+ audio,
115
+ time: visualTimeSeconds,
116
+ rotation: this.rotation,
117
+ resolution: [this.canvas.width, this.canvas.height]
118
+ });
119
+
120
+ requestAnimationFrame((nextTime) => this.render(nextTime));
121
+ }
122
+ }
123
+
124
+ const resolveRotationSpeed = (layers, amplitude) => {
125
+ const layerWithSpeed = Array.isArray(layers)
126
+ ? layers.find((layer) => Number.isFinite(Number(layer?.params?.rotation_speed)))
127
+ : null;
128
+ const fromLayer = Number(layerWithSpeed?.params?.rotation_speed);
129
+ if (Number.isFinite(fromLayer)) {
130
+ return clamp(fromLayer, 0.1, 8.0);
131
+ }
132
+ return 0.7 + amplitude * 2.4;
133
+ };
134
+
135
+ const clamp = (value, min, max) => Math.min(Math.max(value, min), max);