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,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
data/frontend/index.html
ADDED
|
@@ -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);
|