vizcore 0.1.0 → 1.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 +4 -4
- data/README.md +70 -117
- data/docs/.nojekyll +0 -0
- data/docs/assets/playground-worker.js +373 -0
- data/docs/assets/playground.css +440 -0
- data/docs/assets/playground.js +652 -0
- data/docs/assets/site.css +744 -0
- data/docs/assets/vizcore-demo.gif +0 -0
- data/docs/assets/vizcore-poster.png +0 -0
- data/docs/assets/vj-tunnel.js +159 -0
- data/docs/index.html +225 -0
- data/docs/playground.html +81 -0
- data/docs/shape_dsl.md +269 -0
- data/examples/README.md +59 -0
- data/examples/assets/README.md +19 -0
- data/examples/audio_inspector.rb +34 -0
- data/examples/club_intro_drop.rb +78 -0
- data/examples/kansai_rubykaigi_visual.rb +70 -0
- data/examples/live_coding_minimal.rb +22 -0
- data/examples/midi_controller_show.rb +78 -0
- data/examples/midi_scene_switch.rb +3 -1
- data/examples/parser_visualizer.rb +48 -0
- data/examples/readme_demo.rb +17 -0
- data/examples/rhythm_geometry.rb +34 -0
- data/examples/ruby_crystal_show.rb +35 -0
- data/examples/shader_playground.rb +18 -0
- data/examples/unyo_liquid.rb +59 -0
- data/examples/vj_ambient_chill_room.rb +124 -0
- data/examples/vj_dnb_jungle.rb +170 -0
- data/examples/vj_festival_mainstage.rb +245 -0
- data/examples/vj_festival_mainstage.yml +17 -0
- data/examples/vj_glitch_industrial.rb +164 -0
- data/examples/vj_hiphop_cipher.rb +167 -0
- data/examples/vj_jpop_idol_live.rb +210 -0
- data/examples/vj_synthwave_retro.rb +173 -0
- data/examples/vj_techno_warehouse.rb +195 -0
- data/frontend/index.html +494 -2
- data/frontend/src/audio-inspector.js +40 -0
- data/frontend/src/custom-shape-param-controls.js +106 -0
- data/frontend/src/live-controls.js +131 -0
- data/frontend/src/main.js +1060 -16
- data/frontend/src/mapping-target-selector.js +109 -0
- data/frontend/src/midi-learn.js +194 -0
- data/frontend/src/performance-monitor.js +183 -0
- data/frontend/src/plugin-runtime.js +130 -0
- data/frontend/src/projector-mode.js +56 -0
- data/frontend/src/renderer/engine.js +157 -3
- data/frontend/src/renderer/layer-manager.js +442 -30
- data/frontend/src/renderer/shader-manager.js +26 -0
- data/frontend/src/runtime-control-preset.js +11 -0
- data/frontend/src/shader-error-overlay.js +29 -0
- data/frontend/src/shader-param-controls.js +93 -0
- data/frontend/src/shaders/builtins.js +380 -2
- data/frontend/src/shaders/post-effects.js +52 -0
- data/frontend/src/shape-editor-controls.js +157 -0
- data/frontend/src/visual-regression.js +67 -0
- data/frontend/src/visual-settings-preset.js +103 -0
- data/frontend/src/visuals/geometry.js +666 -0
- data/frontend/src/visuals/image-renderer.js +291 -0
- data/frontend/src/visuals/particle-system.js +56 -10
- data/frontend/src/visuals/shape-renderer.js +475 -0
- data/frontend/src/visuals/spectrogram-renderer.js +226 -0
- data/frontend/src/visuals/svg-arc.js +104 -0
- data/frontend/src/visuals/text-renderer.js +112 -11
- data/frontend/src/websocket-client.js +12 -1
- data/lib/vizcore/analysis/adaptive_normalizer.rb +70 -0
- data/lib/vizcore/analysis/beat_detector.rb +4 -2
- data/lib/vizcore/analysis/bpm_estimator.rb +8 -0
- data/lib/vizcore/analysis/feature_recorder.rb +159 -0
- data/lib/vizcore/analysis/feature_replay.rb +84 -0
- data/lib/vizcore/analysis/pipeline.rb +235 -11
- data/lib/vizcore/analysis/tap_tempo.rb +74 -0
- data/lib/vizcore/analysis.rb +4 -0
- data/lib/vizcore/audio/dummy_sine_input.rb +1 -1
- data/lib/vizcore/audio/fixture_input.rb +65 -0
- data/lib/vizcore/audio/input_manager.rb +4 -2
- data/lib/vizcore/audio/mic_input.rb +24 -8
- data/lib/vizcore/audio/portaudio_ffi.rb +106 -1
- data/lib/vizcore/audio.rb +1 -0
- data/lib/vizcore/cli/doctor.rb +159 -0
- data/lib/vizcore/cli/dsl_reference.rb +99 -0
- data/lib/vizcore/cli/layer_docs.rb +46 -0
- data/lib/vizcore/cli/scene_diagnostics.rb +23 -0
- data/lib/vizcore/cli/scene_inspector.rb +136 -0
- data/lib/vizcore/cli/scene_validator.rb +337 -0
- data/lib/vizcore/cli/shader_template.rb +68 -0
- data/lib/vizcore/cli/shader_uniform_docs.rb +54 -0
- data/lib/vizcore/cli.rb +689 -18
- data/lib/vizcore/config.rb +103 -2
- data/lib/vizcore/control_preset.rb +68 -0
- data/lib/vizcore/dsl/engine.rb +277 -5
- data/lib/vizcore/dsl/layer_builder.rb +1280 -23
- data/lib/vizcore/dsl/layer_group_builder.rb +112 -0
- data/lib/vizcore/dsl/mapping_resolver.rb +290 -7
- data/lib/vizcore/dsl/mapping_transform_builder.rb +71 -0
- data/lib/vizcore/dsl/reaction_builder.rb +44 -0
- data/lib/vizcore/dsl/scene_builder.rb +61 -5
- data/lib/vizcore/dsl/shader_source_resolver.rb +67 -6
- data/lib/vizcore/dsl/style_builder.rb +68 -0
- data/lib/vizcore/dsl/timeline_builder.rb +138 -0
- data/lib/vizcore/dsl/transition_controller.rb +77 -0
- data/lib/vizcore/dsl.rb +5 -1
- data/lib/vizcore/layer_catalog.rb +275 -0
- data/lib/vizcore/project_manifest.rb +152 -0
- data/lib/vizcore/renderer/png_writer.rb +57 -0
- data/lib/vizcore/renderer/render_sequence.rb +153 -0
- data/lib/vizcore/renderer/scene_frame_source.rb +132 -0
- data/lib/vizcore/renderer/scene_serializer.rb +36 -3
- data/lib/vizcore/renderer/snapshot.rb +38 -0
- data/lib/vizcore/renderer/snapshot_renderer.rb +938 -0
- data/lib/vizcore/renderer.rb +5 -0
- data/lib/vizcore/server/frame_broadcaster.rb +143 -8
- data/lib/vizcore/server/gallery_app.rb +155 -0
- data/lib/vizcore/server/gallery_page.rb +100 -0
- data/lib/vizcore/server/gallery_runner.rb +48 -0
- data/lib/vizcore/server/rack_app.rb +203 -4
- data/lib/vizcore/server/runner.rb +391 -22
- data/lib/vizcore/server/scene_dependency_watcher.rb +79 -0
- data/lib/vizcore/server/websocket_handler.rb +60 -10
- data/lib/vizcore/server.rb +4 -0
- data/lib/vizcore/shape.rb +719 -0
- data/lib/vizcore/sync/osc_message.rb +103 -0
- data/lib/vizcore/sync/osc_receiver.rb +68 -0
- data/lib/vizcore/sync.rb +4 -0
- data/lib/vizcore/templates/midi_control_scene.rb +3 -1
- data/lib/vizcore/templates/plugin_layer.rb +20 -0
- data/lib/vizcore/templates/plugin_readme.md +23 -0
- data/lib/vizcore/templates/plugin_renderer.js +43 -0
- data/lib/vizcore/templates/plugin_scene.rb +14 -0
- data/lib/vizcore/templates/project_readme.md +7 -23
- data/lib/vizcore/templates/rubykaigi_scene.rb +30 -0
- data/lib/vizcore/version.rb +1 -1
- data/lib/vizcore.rb +28 -0
- data/scripts/browser_capture.mjs +75 -0
- data/sig/vizcore.rbs +461 -0
- metadata +94 -3
- data/docs/GETTING_STARTED.md +0 -105
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
export const describeSvgArc = ({ from, to, rx, ry, xAxisRotation = 0, largeArc = false, sweep = false }) => {
|
|
2
|
+
const start = pointPair(from);
|
|
3
|
+
const end = pointPair(to);
|
|
4
|
+
if (!start || !end) return null;
|
|
5
|
+
if (samePoint(start, end)) return null;
|
|
6
|
+
|
|
7
|
+
let radiusX = Math.abs(finiteNumber(rx, 0));
|
|
8
|
+
let radiusY = Math.abs(finiteNumber(ry, 0));
|
|
9
|
+
if (radiusX <= 0 || radiusY <= 0) return null;
|
|
10
|
+
|
|
11
|
+
const rotation = (finiteNumber(xAxisRotation, 0) / 180) * Math.PI;
|
|
12
|
+
const cos = Math.cos(rotation);
|
|
13
|
+
const sin = Math.sin(rotation);
|
|
14
|
+
const dx = (start[0] - end[0]) / 2;
|
|
15
|
+
const dy = (start[1] - end[1]) / 2;
|
|
16
|
+
const x1p = cos * dx + sin * dy;
|
|
17
|
+
const y1p = -sin * dx + cos * dy;
|
|
18
|
+
|
|
19
|
+
const radiusScale = ((x1p * x1p) / (radiusX * radiusX)) + ((y1p * y1p) / (radiusY * radiusY));
|
|
20
|
+
if (radiusScale > 1) {
|
|
21
|
+
const scale = Math.sqrt(radiusScale);
|
|
22
|
+
radiusX *= scale;
|
|
23
|
+
radiusY *= scale;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const rx2 = radiusX * radiusX;
|
|
27
|
+
const ry2 = radiusY * radiusY;
|
|
28
|
+
const x1p2 = x1p * x1p;
|
|
29
|
+
const y1p2 = y1p * y1p;
|
|
30
|
+
const denominator = (rx2 * y1p2) + (ry2 * x1p2);
|
|
31
|
+
if (denominator === 0) return null;
|
|
32
|
+
|
|
33
|
+
const numerator = Math.max(0, (rx2 * ry2) - (rx2 * y1p2) - (ry2 * x1p2));
|
|
34
|
+
const sign = Boolean(largeArc) === Boolean(sweep) ? -1 : 1;
|
|
35
|
+
const coefficient = sign * Math.sqrt(numerator / denominator);
|
|
36
|
+
const cxp = coefficient * ((radiusX * y1p) / radiusY);
|
|
37
|
+
const cyp = coefficient * (-(radiusY * x1p) / radiusX);
|
|
38
|
+
const cx = (cos * cxp) - (sin * cyp) + ((start[0] + end[0]) / 2);
|
|
39
|
+
const cy = (sin * cxp) + (cos * cyp) + ((start[1] + end[1]) / 2);
|
|
40
|
+
|
|
41
|
+
const startVector = [(x1p - cxp) / radiusX, (y1p - cyp) / radiusY];
|
|
42
|
+
const endVector = [(-x1p - cxp) / radiusX, (-y1p - cyp) / radiusY];
|
|
43
|
+
const startAngle = vectorAngle([1, 0], startVector);
|
|
44
|
+
let deltaAngle = vectorAngle(startVector, endVector);
|
|
45
|
+
|
|
46
|
+
if (!sweep && deltaAngle > 0) {
|
|
47
|
+
deltaAngle -= Math.PI * 2;
|
|
48
|
+
} else if (sweep && deltaAngle < 0) {
|
|
49
|
+
deltaAngle += Math.PI * 2;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
cx,
|
|
54
|
+
cy,
|
|
55
|
+
rx: radiusX,
|
|
56
|
+
ry: radiusY,
|
|
57
|
+
rotation,
|
|
58
|
+
startAngle,
|
|
59
|
+
deltaAngle
|
|
60
|
+
};
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export const svgArcPoint = (arc, progress) => {
|
|
64
|
+
const angle = arc.startAngle + (arc.deltaAngle * progress);
|
|
65
|
+
const cosRotation = Math.cos(arc.rotation);
|
|
66
|
+
const sinRotation = Math.sin(arc.rotation);
|
|
67
|
+
const x = Math.cos(angle) * arc.rx;
|
|
68
|
+
const y = Math.sin(angle) * arc.ry;
|
|
69
|
+
|
|
70
|
+
return [
|
|
71
|
+
arc.cx + (cosRotation * x) - (sinRotation * y),
|
|
72
|
+
arc.cy + (sinRotation * x) + (cosRotation * y)
|
|
73
|
+
];
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export const svgArcSegmentCount = (arc, detail = 32) => {
|
|
77
|
+
const safeDetail = clampInt(detail, 4, 128);
|
|
78
|
+
return Math.max(1, Math.ceil((Math.abs(arc.deltaAngle) / (Math.PI * 2)) * safeDetail));
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const pointPair = (value) => {
|
|
82
|
+
if (!Array.isArray(value) || value.length < 2) return null;
|
|
83
|
+
|
|
84
|
+
return [finiteNumber(value[0], 0), finiteNumber(value[1], 0)];
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const samePoint = (a, b) => Math.abs(a[0] - b[0]) < 1e-9 && Math.abs(a[1] - b[1]) < 1e-9;
|
|
88
|
+
|
|
89
|
+
const vectorAngle = (from, to) => {
|
|
90
|
+
const cross = (from[0] * to[1]) - (from[1] * to[0]);
|
|
91
|
+
const dot = (from[0] * to[0]) + (from[1] * to[1]);
|
|
92
|
+
return Math.atan2(cross, dot);
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const clampInt = (value, min, max) => {
|
|
96
|
+
const numeric = Number(value);
|
|
97
|
+
if (!Number.isFinite(numeric)) return min;
|
|
98
|
+
return Math.round(Math.min(Math.max(numeric, min), max));
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const finiteNumber = (value, fallback) => {
|
|
102
|
+
const numeric = Number(value);
|
|
103
|
+
return Number.isFinite(numeric) ? numeric : fallback;
|
|
104
|
+
};
|
|
@@ -55,9 +55,9 @@ export class TextRenderer {
|
|
|
55
55
|
this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_WRAP_T, this.gl.CLAMP_TO_EDGE);
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
render({ content, fontSize, audio, time, color, glowStrength }) {
|
|
59
|
-
const
|
|
60
|
-
if (!
|
|
58
|
+
render({ content, fontSize, audio, time, color, fontFamily, align, letterSpacing, strokeWidth, strokeColor, shadowColor, shadowBlur, glowStrength }) {
|
|
59
|
+
const lines = normalizeTextLines(content);
|
|
60
|
+
if (!lines.length) {
|
|
61
61
|
return;
|
|
62
62
|
}
|
|
63
63
|
|
|
@@ -65,15 +65,24 @@ export class TextRenderer {
|
|
|
65
65
|
|
|
66
66
|
const amp = clamp(Number(audio?.amplitude || 0), 0, 1);
|
|
67
67
|
const beatBoost = audio?.beat ? 1.0 : 0.0;
|
|
68
|
-
const
|
|
68
|
+
const singleLineMax = Math.floor(this.canvas.height * 0.22);
|
|
69
|
+
const multilineMax = Math.floor((this.canvas.height * 0.58) / Math.max(lines.length, 1));
|
|
70
|
+
const maxFontSize = Math.max(32, Math.min(singleLineMax, multilineMax));
|
|
69
71
|
const dynamicSize = Math.round(
|
|
70
72
|
clamp(Number(fontSize || 96), 18, maxFontSize) * (1 + amp * 0.08 + beatBoost * 0.04)
|
|
71
73
|
);
|
|
72
74
|
this.drawTextToCanvas({
|
|
73
|
-
|
|
75
|
+
lines,
|
|
74
76
|
fontSize: dynamicSize,
|
|
75
77
|
time,
|
|
76
78
|
color,
|
|
79
|
+
fontFamily,
|
|
80
|
+
align,
|
|
81
|
+
letterSpacing,
|
|
82
|
+
strokeWidth,
|
|
83
|
+
strokeColor,
|
|
84
|
+
shadowColor,
|
|
85
|
+
shadowBlur,
|
|
77
86
|
amplitude: amp,
|
|
78
87
|
glowStrength: Number(glowStrength ?? 0.15)
|
|
79
88
|
});
|
|
@@ -81,7 +90,7 @@ export class TextRenderer {
|
|
|
81
90
|
this.drawQuad({ intensity: 0.85 + amp * 0.15 });
|
|
82
91
|
}
|
|
83
92
|
|
|
84
|
-
drawTextToCanvas({
|
|
93
|
+
drawTextToCanvas({ lines, fontSize, time, color, fontFamily, align, letterSpacing, strokeWidth, strokeColor, shadowColor, shadowBlur, amplitude, glowStrength }) {
|
|
85
94
|
const ctx = this.ctx;
|
|
86
95
|
ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
|
87
96
|
|
|
@@ -91,14 +100,30 @@ export class TextRenderer {
|
|
|
91
100
|
const safeColor = typeof color === "string" && color.trim() ? color : "#e5f3ff";
|
|
92
101
|
const glow = clamp(Number(glowStrength || 0), 0, 1) * (1.5 + amplitude * 5.0);
|
|
93
102
|
const xShift = Math.sin(time * 2.0) * (2 + amplitude * 4);
|
|
103
|
+
const textAlign = normalizeTextAlign(align);
|
|
104
|
+
const spacing = normalizeLetterSpacing(letterSpacing);
|
|
105
|
+
const stroke = clamp(Number(strokeWidth || 0), 0, 24);
|
|
106
|
+
const shadow = shadowBlur === undefined ? glow : clamp(Number(shadowBlur || 0), 0, 80);
|
|
94
107
|
|
|
95
|
-
ctx.textAlign =
|
|
108
|
+
ctx.textAlign = textAlign;
|
|
96
109
|
ctx.textBaseline = "middle";
|
|
97
|
-
ctx.font = `700 ${fontSize}px
|
|
98
|
-
ctx.shadowColor = "rgba(110, 208, 255, 0.35)";
|
|
99
|
-
ctx.shadowBlur =
|
|
110
|
+
ctx.font = `700 ${fontSize}px ${normalizeFontFamily(fontFamily)}`;
|
|
111
|
+
ctx.shadowColor = normalizeTextColor(shadowColor, "rgba(110, 208, 255, 0.35)");
|
|
112
|
+
ctx.shadowBlur = shadow;
|
|
100
113
|
ctx.fillStyle = safeColor;
|
|
101
|
-
|
|
114
|
+
const x = resolveTextX(this.canvas.width, textAlign) + xShift;
|
|
115
|
+
const lineHeight = fontSize * 1.16;
|
|
116
|
+
const startY = this.canvas.height / 2 - ((lines.length - 1) * lineHeight) / 2;
|
|
117
|
+
lines.forEach((line, index) => {
|
|
118
|
+
const y = startY + index * lineHeight;
|
|
119
|
+
if (stroke > 0) {
|
|
120
|
+
ctx.lineJoin = "round";
|
|
121
|
+
ctx.lineWidth = stroke;
|
|
122
|
+
ctx.strokeStyle = normalizeTextColor(strokeColor, safeColor);
|
|
123
|
+
drawText(ctx, { text: line, x, y, align: textAlign, letterSpacing: spacing, method: "strokeText" });
|
|
124
|
+
}
|
|
125
|
+
drawText(ctx, { text: line, x, y, align: textAlign, letterSpacing: spacing, method: "fillText" });
|
|
126
|
+
});
|
|
102
127
|
}
|
|
103
128
|
|
|
104
129
|
syncCanvasSize() {
|
|
@@ -141,3 +166,79 @@ export class TextRenderer {
|
|
|
141
166
|
}
|
|
142
167
|
|
|
143
168
|
const clamp = (value, min, max) => Math.min(Math.max(value, min), max);
|
|
169
|
+
|
|
170
|
+
export const normalizeTextAlign = (value) => {
|
|
171
|
+
const align = String(value || "center").trim().toLowerCase();
|
|
172
|
+
if (align === "left" || align === "right" || align === "center") {
|
|
173
|
+
return align;
|
|
174
|
+
}
|
|
175
|
+
return "center";
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
export const resolveTextX = (width, align) => {
|
|
179
|
+
const canvasWidth = Number(width) || 0;
|
|
180
|
+
if (align === "left") return canvasWidth * 0.12;
|
|
181
|
+
if (align === "right") return canvasWidth * 0.88;
|
|
182
|
+
return canvasWidth * 0.5;
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
export const normalizeTextLines = (value) => {
|
|
186
|
+
return String(value || "")
|
|
187
|
+
.split(/\r?\n/)
|
|
188
|
+
.map((line) => line.trim())
|
|
189
|
+
.filter((line) => line.length > 0)
|
|
190
|
+
.slice(0, 6);
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
export const normalizeLetterSpacing = (value) => {
|
|
194
|
+
const spacing = Number(value);
|
|
195
|
+
if (!Number.isFinite(spacing)) return 0;
|
|
196
|
+
return clamp(spacing, 0, 96);
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
export const measureLetterSpacedText = (ctx, text, letterSpacing = 0) => {
|
|
200
|
+
const chars = Array.from(String(text || ""));
|
|
201
|
+
if (!chars.length) return 0;
|
|
202
|
+
|
|
203
|
+
const spacing = normalizeLetterSpacing(letterSpacing);
|
|
204
|
+
const glyphWidth = chars.reduce((total, char) => {
|
|
205
|
+
return total + Number(ctx.measureText(char)?.width || 0);
|
|
206
|
+
}, 0);
|
|
207
|
+
return glyphWidth + spacing * (chars.length - 1);
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
export const resolveLetterSpacedStartX = (ctx, text, x, align, letterSpacing = 0) => {
|
|
211
|
+
const width = measureLetterSpacedText(ctx, text, letterSpacing);
|
|
212
|
+
if (align === "right") return x - width;
|
|
213
|
+
if (align === "center") return x - width / 2;
|
|
214
|
+
return x;
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
export const normalizeFontFamily = (value) => {
|
|
218
|
+
const family = String(value || "").trim();
|
|
219
|
+
if (!family) return "\"IBM Plex Sans\", \"Noto Sans JP\", sans-serif";
|
|
220
|
+
if (family.includes(",")) return `${family}, "IBM Plex Sans", "Noto Sans JP", sans-serif`;
|
|
221
|
+
|
|
222
|
+
return `"${family.replaceAll("\"", "")}", "IBM Plex Sans", "Noto Sans JP", sans-serif`;
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
const normalizeTextColor = (value, fallback) => {
|
|
226
|
+
const color = String(value || "").trim();
|
|
227
|
+
return color || fallback;
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
const drawText = (ctx, { text, x, y, align, letterSpacing, method }) => {
|
|
231
|
+
if (letterSpacing <= 0) {
|
|
232
|
+
ctx[method](text, x, y);
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const originalAlign = ctx.textAlign;
|
|
237
|
+
ctx.textAlign = "left";
|
|
238
|
+
let cursor = resolveLetterSpacedStartX(ctx, text, x, align, letterSpacing);
|
|
239
|
+
for (const char of Array.from(text)) {
|
|
240
|
+
ctx[method](char, cursor, y);
|
|
241
|
+
cursor += Number(ctx.measureText(char)?.width || 0) + letterSpacing;
|
|
242
|
+
}
|
|
243
|
+
ctx.textAlign = originalAlign;
|
|
244
|
+
};
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
const RECONNECT_INTERVAL_MS = 1000;
|
|
2
2
|
const READY_STATE_CONNECTING = 0;
|
|
3
3
|
const READY_STATE_OPEN = 1;
|
|
4
|
+
export const PROTOCOL_VERSION = "vizcore.frame.v1";
|
|
4
5
|
|
|
5
6
|
export class WebSocketClient {
|
|
6
7
|
constructor(url, callbacks = {}) {
|
|
@@ -8,6 +9,7 @@ export class WebSocketClient {
|
|
|
8
9
|
this.onFrame = callbacks.onFrame || (() => {});
|
|
9
10
|
this.onSceneChange = callbacks.onSceneChange || (() => {});
|
|
10
11
|
this.onConfigUpdate = callbacks.onConfigUpdate || (() => {});
|
|
12
|
+
this.onLatencyProbe = callbacks.onLatencyProbe || (() => {});
|
|
11
13
|
this.onStatus = callbacks.onStatus || (() => {});
|
|
12
14
|
this.socket = null;
|
|
13
15
|
this.reconnectTimer = null;
|
|
@@ -97,6 +99,10 @@ export class WebSocketClient {
|
|
|
97
99
|
return;
|
|
98
100
|
}
|
|
99
101
|
|
|
102
|
+
if (message.protocol && message.protocol !== PROTOCOL_VERSION) {
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
100
106
|
if (message.type === "audio_frame") {
|
|
101
107
|
this.onFrame(message.payload);
|
|
102
108
|
return;
|
|
@@ -109,6 +115,11 @@ export class WebSocketClient {
|
|
|
109
115
|
|
|
110
116
|
if (message.type === "config_update") {
|
|
111
117
|
this.onConfigUpdate(message.payload);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (message.type === "latency_probe") {
|
|
122
|
+
this.onLatencyProbe(message.payload);
|
|
112
123
|
}
|
|
113
124
|
}
|
|
114
125
|
|
|
@@ -122,7 +133,7 @@ export class WebSocketClient {
|
|
|
122
133
|
}
|
|
123
134
|
|
|
124
135
|
try {
|
|
125
|
-
this.socket.send(JSON.stringify({ type, payload }));
|
|
136
|
+
this.socket.send(JSON.stringify({ protocol: PROTOCOL_VERSION, type, payload }));
|
|
126
137
|
return true;
|
|
127
138
|
} catch {
|
|
128
139
|
return false;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Vizcore
|
|
4
|
+
module Analysis
|
|
5
|
+
# Scales audio features against a rolling amplitude peak for repeatable mappings.
|
|
6
|
+
class AdaptiveNormalizer
|
|
7
|
+
DEFAULT_WINDOW_SIZE = 128
|
|
8
|
+
DEFAULT_TARGET = 0.85
|
|
9
|
+
DEFAULT_FLOOR = 0.05
|
|
10
|
+
|
|
11
|
+
# @param window_size [Integer] number of recent active frames used to track the peak
|
|
12
|
+
# @param target [Numeric] desired level for the rolling peak
|
|
13
|
+
# @param floor [Numeric] minimum peak level used when calculating gain
|
|
14
|
+
def initialize(window_size: DEFAULT_WINDOW_SIZE, target: DEFAULT_TARGET, floor: DEFAULT_FLOOR)
|
|
15
|
+
@window_size = normalize_window_size(window_size)
|
|
16
|
+
@target = normalize_unit(target, DEFAULT_TARGET)
|
|
17
|
+
@floor = normalize_unit(floor, DEFAULT_FLOOR)
|
|
18
|
+
@history = []
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# @param amplitude [Numeric] current RMS amplitude
|
|
22
|
+
# @param bands [Hash] current frequency band values
|
|
23
|
+
# @param fft [Array<Numeric>] current FFT preview values
|
|
24
|
+
# @return [Hash] normalized feature values plus the applied gain
|
|
25
|
+
def call(amplitude:, bands:, fft:)
|
|
26
|
+
current_amplitude = normalize_unit(amplitude, 0.0)
|
|
27
|
+
@history << current_amplitude
|
|
28
|
+
@history.shift while @history.length > @window_size
|
|
29
|
+
|
|
30
|
+
gain = @target / [@history.max.to_f, @floor].max
|
|
31
|
+
{
|
|
32
|
+
amplitude: scale_value(current_amplitude, gain),
|
|
33
|
+
bands: scale_hash(bands, gain),
|
|
34
|
+
fft: scale_array(fft, gain),
|
|
35
|
+
gain: gain
|
|
36
|
+
}
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def normalize_window_size(value)
|
|
42
|
+
Integer(value).clamp(1, 10_000)
|
|
43
|
+
rescue ArgumentError, TypeError
|
|
44
|
+
DEFAULT_WINDOW_SIZE
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def normalize_unit(value, fallback)
|
|
48
|
+
Float(value).clamp(0.0, 1.0)
|
|
49
|
+
rescue ArgumentError, TypeError
|
|
50
|
+
fallback
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def scale_hash(values, gain)
|
|
54
|
+
Hash(values).transform_values { |value| scale_value(value, gain) }
|
|
55
|
+
rescue StandardError
|
|
56
|
+
{}
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def scale_array(values, gain)
|
|
60
|
+
Array(values).map { |value| scale_value(value, gain) }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def scale_value(value, gain)
|
|
64
|
+
(Float(value) * gain).clamp(0.0, 1.0)
|
|
65
|
+
rescue ArgumentError, TypeError
|
|
66
|
+
0.0
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -10,11 +10,13 @@ module Vizcore
|
|
|
10
10
|
# @param sensitivity [Float] multiplier applied to moving average energy
|
|
11
11
|
# @param refractory_frames [Integer] minimum frames between beat events
|
|
12
12
|
# @param min_history [Integer] minimum history size before detecting beats
|
|
13
|
-
|
|
13
|
+
# @param min_energy [Float] absolute energy floor required for beat detection
|
|
14
|
+
def initialize(history_size: 43, sensitivity: 1.35, refractory_frames: 4, min_history: 8, min_energy: 1e-6)
|
|
14
15
|
@history_size = Integer(history_size)
|
|
15
16
|
@sensitivity = Float(sensitivity)
|
|
16
17
|
@refractory_frames = Integer(refractory_frames)
|
|
17
18
|
@min_history = Integer(min_history)
|
|
19
|
+
@min_energy = Float(min_energy)
|
|
18
20
|
@energy_history = []
|
|
19
21
|
@frame_index = 0
|
|
20
22
|
@last_beat_frame = -@refractory_frames
|
|
@@ -29,7 +31,7 @@ module Vizcore
|
|
|
29
31
|
threshold = average_energy * @sensitivity
|
|
30
32
|
enough_history = @energy_history.length >= @min_history
|
|
31
33
|
refractory_ok = (@frame_index - @last_beat_frame) > @refractory_frames
|
|
32
|
-
beat = enough_history && refractory_ok && instant_energy > threshold && instant_energy
|
|
34
|
+
beat = enough_history && refractory_ok && instant_energy > threshold && instant_energy >= @min_energy
|
|
33
35
|
|
|
34
36
|
if beat
|
|
35
37
|
@beat_count += 1
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "json"
|
|
5
|
+
require "pathname"
|
|
6
|
+
require_relative "../audio/file_input"
|
|
7
|
+
require_relative "../audio/input_manager"
|
|
8
|
+
require_relative "pipeline"
|
|
9
|
+
|
|
10
|
+
module Vizcore
|
|
11
|
+
module Analysis
|
|
12
|
+
# Records deterministic audio analysis features from a file source.
|
|
13
|
+
class FeatureRecorder
|
|
14
|
+
VERSION = "vizcore.features.v1"
|
|
15
|
+
DEFAULT_FRAME_COUNT = 300
|
|
16
|
+
DEFAULT_FRAME_RATE = 30.0
|
|
17
|
+
|
|
18
|
+
def initialize(
|
|
19
|
+
audio_file:,
|
|
20
|
+
frames: DEFAULT_FRAME_COUNT,
|
|
21
|
+
fps: DEFAULT_FRAME_RATE,
|
|
22
|
+
noise_gate: Pipeline::DEFAULT_NOISE_GATE,
|
|
23
|
+
audio_normalize: nil,
|
|
24
|
+
bpm: nil,
|
|
25
|
+
bpm_lock: false
|
|
26
|
+
)
|
|
27
|
+
@audio_file = Pathname.new(audio_file.to_s).expand_path
|
|
28
|
+
@frames = normalize_frame_count(frames)
|
|
29
|
+
@fps = normalize_frame_rate(fps)
|
|
30
|
+
@noise_gate = Float(noise_gate)
|
|
31
|
+
@audio_normalize = audio_normalize
|
|
32
|
+
@bpm = bpm
|
|
33
|
+
@bpm_lock = bpm_lock
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# @param out [String, Pathname] JSON output path
|
|
37
|
+
# @return [Hash] recorder metadata
|
|
38
|
+
def write(out:)
|
|
39
|
+
output_path = Pathname.new(out.to_s).expand_path
|
|
40
|
+
FileUtils.mkdir_p(output_path.dirname)
|
|
41
|
+
payload = record
|
|
42
|
+
output_path.write("#{JSON.pretty_generate(payload)}\n")
|
|
43
|
+
{
|
|
44
|
+
path: output_path,
|
|
45
|
+
frames: @frames,
|
|
46
|
+
fps: @fps,
|
|
47
|
+
sample_rate: payload.fetch("metadata").fetch("sample_rate")
|
|
48
|
+
}
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def record
|
|
54
|
+
validate_audio_file!
|
|
55
|
+
input = Vizcore::Audio::FileInput.new(path: @audio_file.to_s)
|
|
56
|
+
raise ArgumentError, input.last_error.message if input.last_error
|
|
57
|
+
|
|
58
|
+
input.start
|
|
59
|
+
sample_rate = input.stream_sample_rate
|
|
60
|
+
capture_size = capture_size_for(sample_rate)
|
|
61
|
+
pipeline = build_pipeline(sample_rate)
|
|
62
|
+
features = @frames.times.map do |index|
|
|
63
|
+
{
|
|
64
|
+
"index" => index,
|
|
65
|
+
"time" => (index / @fps).round(6),
|
|
66
|
+
"audio" => serializable(pipeline.call(input.read(capture_size)))
|
|
67
|
+
}
|
|
68
|
+
end
|
|
69
|
+
payload(sample_rate: sample_rate, capture_size: capture_size, features: features)
|
|
70
|
+
ensure
|
|
71
|
+
input&.stop
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def payload(sample_rate:, capture_size:, features:)
|
|
75
|
+
{
|
|
76
|
+
"version" => VERSION,
|
|
77
|
+
"metadata" => {
|
|
78
|
+
"audio_file" => @audio_file.to_s,
|
|
79
|
+
"frames" => @frames,
|
|
80
|
+
"fps" => @fps,
|
|
81
|
+
"sample_rate" => sample_rate,
|
|
82
|
+
"capture_size" => capture_size,
|
|
83
|
+
"noise_gate" => @noise_gate,
|
|
84
|
+
"bpm" => @bpm,
|
|
85
|
+
"bpm_lock" => @bpm_lock,
|
|
86
|
+
"audio_normalize" => serializable(@audio_normalize)
|
|
87
|
+
},
|
|
88
|
+
"features" => features
|
|
89
|
+
}
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def build_pipeline(sample_rate)
|
|
93
|
+
Pipeline.new(
|
|
94
|
+
sample_rate: sample_rate,
|
|
95
|
+
fft_size: supported_fft_size(Vizcore::Audio::InputManager::DEFAULT_FRAME_SIZE),
|
|
96
|
+
noise_gate: @noise_gate,
|
|
97
|
+
audio_normalize: @audio_normalize,
|
|
98
|
+
bpm: @bpm,
|
|
99
|
+
bpm_lock: @bpm_lock
|
|
100
|
+
)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def validate_audio_file!
|
|
104
|
+
raise ArgumentError, "Audio file not found: #{@audio_file}" unless @audio_file.file?
|
|
105
|
+
return if Vizcore::Audio::FileInput::SUPPORTED_EXTENSIONS.include?(@audio_file.extname.downcase)
|
|
106
|
+
|
|
107
|
+
raise ArgumentError, "Unsupported audio format: #{@audio_file.extname.downcase}"
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def capture_size_for(sample_rate)
|
|
111
|
+
[(sample_rate.to_f / @fps).round, 1].max
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def supported_fft_size(size)
|
|
115
|
+
value = Integer(size)
|
|
116
|
+
return value if value.positive? && (value & (value - 1)).zero?
|
|
117
|
+
|
|
118
|
+
Vizcore::Audio::InputManager::DEFAULT_FRAME_SIZE
|
|
119
|
+
rescue StandardError
|
|
120
|
+
Vizcore::Audio::InputManager::DEFAULT_FRAME_SIZE
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def serializable(value)
|
|
124
|
+
case value
|
|
125
|
+
when Hash
|
|
126
|
+
value.each_with_object({}) do |(key, entry), output|
|
|
127
|
+
output[key.to_s] = serializable(entry)
|
|
128
|
+
end
|
|
129
|
+
when Array
|
|
130
|
+
value.map { |entry| serializable(entry) }
|
|
131
|
+
when Float
|
|
132
|
+
value.finite? ? value.round(6) : 0.0
|
|
133
|
+
when Symbol
|
|
134
|
+
value.to_s
|
|
135
|
+
else
|
|
136
|
+
value
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def normalize_frame_count(value)
|
|
141
|
+
count = Integer(value)
|
|
142
|
+
raise ArgumentError, "frames must be positive" unless count.positive?
|
|
143
|
+
|
|
144
|
+
count
|
|
145
|
+
rescue ArgumentError, TypeError
|
|
146
|
+
raise ArgumentError, "frames must be a positive integer"
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def normalize_frame_rate(value)
|
|
150
|
+
rate = Float(value)
|
|
151
|
+
raise ArgumentError, "fps must be positive" unless rate.positive?
|
|
152
|
+
|
|
153
|
+
rate
|
|
154
|
+
rescue ArgumentError, TypeError
|
|
155
|
+
raise ArgumentError, "fps must be a positive number"
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "pathname"
|
|
5
|
+
require_relative "feature_recorder"
|
|
6
|
+
|
|
7
|
+
module Vizcore
|
|
8
|
+
module Analysis
|
|
9
|
+
# Replays recorded analysis features as a pipeline-compatible source.
|
|
10
|
+
class FeatureReplay
|
|
11
|
+
attr_reader :metadata
|
|
12
|
+
|
|
13
|
+
def initialize(path:)
|
|
14
|
+
@path = Pathname.new(path.to_s).expand_path
|
|
15
|
+
payload = load_payload
|
|
16
|
+
@metadata = deep_symbolize(payload.fetch("metadata", {}))
|
|
17
|
+
@features = load_features(payload)
|
|
18
|
+
@cursor = 0
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# @param _samples [Array<Float>, nil] ignored; replay data already contains analyzed features
|
|
22
|
+
# @return [Hash<Symbol, Object>] recorded audio analysis for the next frame
|
|
23
|
+
def call(_samples = nil)
|
|
24
|
+
audio = @features.fetch(@cursor)
|
|
25
|
+
@cursor = (@cursor + 1) % @features.length
|
|
26
|
+
deep_dup(audio)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def frame_count
|
|
30
|
+
@features.length
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def load_payload
|
|
36
|
+
raise ArgumentError, "Feature file not found: #{@path}" unless @path.file?
|
|
37
|
+
|
|
38
|
+
payload = JSON.parse(@path.read)
|
|
39
|
+
version = payload["version"]
|
|
40
|
+
return payload if version == FeatureRecorder::VERSION
|
|
41
|
+
|
|
42
|
+
raise ArgumentError, "Unsupported feature file version: #{version.inspect}"
|
|
43
|
+
rescue JSON::ParserError => e
|
|
44
|
+
raise ArgumentError, "Invalid feature file JSON: #{e.message}"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def load_features(payload)
|
|
48
|
+
features = Array(payload["features"]).map.with_index do |entry, index|
|
|
49
|
+
audio = Hash(entry).fetch("audio", nil)
|
|
50
|
+
raise ArgumentError, "Feature frame #{index} is missing audio data" unless audio.is_a?(Hash)
|
|
51
|
+
|
|
52
|
+
deep_symbolize(audio)
|
|
53
|
+
end
|
|
54
|
+
raise ArgumentError, "Feature file contains no frames" if features.empty?
|
|
55
|
+
|
|
56
|
+
features
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def deep_symbolize(value)
|
|
60
|
+
case value
|
|
61
|
+
when Hash
|
|
62
|
+
value.each_with_object({}) do |(key, entry), output|
|
|
63
|
+
output[key.to_s.to_sym] = deep_symbolize(entry)
|
|
64
|
+
end
|
|
65
|
+
when Array
|
|
66
|
+
value.map { |entry| deep_symbolize(entry) }
|
|
67
|
+
else
|
|
68
|
+
value
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def deep_dup(value)
|
|
73
|
+
case value
|
|
74
|
+
when Hash
|
|
75
|
+
value.each_with_object({}) { |(key, entry), output| output[key] = deep_dup(entry) }
|
|
76
|
+
when Array
|
|
77
|
+
value.map { |entry| deep_dup(entry) }
|
|
78
|
+
else
|
|
79
|
+
value
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|