vizcore 1.0.0 → 1.2.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 (90) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +66 -648
  3. data/docs/assets/playground-worker.js +373 -0
  4. data/docs/assets/playground.css +440 -0
  5. data/docs/assets/playground.js +652 -0
  6. data/docs/index.html +2 -1
  7. data/docs/playground.html +81 -0
  8. data/docs/shape_dsl.md +269 -0
  9. data/frontend/index.html +50 -2
  10. data/frontend/src/audio-inspector.js +9 -0
  11. data/frontend/src/custom-shape-param-controls.js +106 -0
  12. data/frontend/src/live-controls.js +219 -7
  13. data/frontend/src/main.js +703 -45
  14. data/frontend/src/mapping-target-selector.js +109 -0
  15. data/frontend/src/midi-learn.js +22 -2
  16. data/frontend/src/performance-monitor.js +137 -1
  17. data/frontend/src/renderer/engine.js +401 -11
  18. data/frontend/src/renderer/layer-manager.js +490 -75
  19. data/frontend/src/runtime-control-preset.js +44 -0
  20. data/frontend/src/scene-patches.js +159 -0
  21. data/frontend/src/shader-error-overlay.js +1 -0
  22. data/frontend/src/shape-editor-controls.js +157 -0
  23. data/frontend/src/visuals/geometry.js +425 -27
  24. data/frontend/src/visuals/image-renderer.js +19 -0
  25. data/frontend/src/visuals/particle-system.js +10 -0
  26. data/frontend/src/visuals/shape-renderer.js +488 -0
  27. data/frontend/src/visuals/spectrogram-renderer.js +14 -0
  28. data/frontend/src/visuals/svg-arc.js +104 -0
  29. data/frontend/src/visuals/text-renderer.js +13 -0
  30. data/frontend/src/websocket-client.js +6 -0
  31. data/lib/vizcore/analysis/adaptive_normalizer.rb +20 -2
  32. data/lib/vizcore/analysis/bpm_estimator.rb +18 -8
  33. data/lib/vizcore/analysis/feature_recorder.rb +117 -7
  34. data/lib/vizcore/analysis/feature_replay.rb +48 -9
  35. data/lib/vizcore/analysis/pipeline.rb +258 -9
  36. data/lib/vizcore/analysis/tap_tempo.rb +17 -2
  37. data/lib/vizcore/audio/calibration.rb +156 -0
  38. data/lib/vizcore/audio/file_input.rb +28 -0
  39. data/lib/vizcore/audio/input_manager.rb +36 -1
  40. data/lib/vizcore/audio/midi_input.rb +5 -0
  41. data/lib/vizcore/audio/ring_buffer.rb +22 -0
  42. data/lib/vizcore/audio.rb +1 -0
  43. data/lib/vizcore/cli/dsl_reference.rb +65 -9
  44. data/lib/vizcore/cli/plugin_checker.rb +93 -0
  45. data/lib/vizcore/cli/scene_diagnostics.rb +2 -2
  46. data/lib/vizcore/cli/scene_inspector.rb +35 -1
  47. data/lib/vizcore/cli/scene_validator.rb +573 -33
  48. data/lib/vizcore/cli/shader_template.rb +7 -2
  49. data/lib/vizcore/cli/shader_uniform_docs.rb +11 -0
  50. data/lib/vizcore/cli.rb +268 -15
  51. data/lib/vizcore/config.rb +40 -3
  52. data/lib/vizcore/control_preset.rb +29 -0
  53. data/lib/vizcore/deep_copy.rb +21 -0
  54. data/lib/vizcore/dsl/color_helpers.rb +155 -0
  55. data/lib/vizcore/dsl/engine.rb +219 -23
  56. data/lib/vizcore/dsl/layer_builder.rb +1072 -21
  57. data/lib/vizcore/dsl/layer_group_builder.rb +10 -12
  58. data/lib/vizcore/dsl/layout_helpers.rb +290 -0
  59. data/lib/vizcore/dsl/mapping_preset_builder.rb +41 -0
  60. data/lib/vizcore/dsl/mapping_resolver.rb +549 -13
  61. data/lib/vizcore/dsl/mapping_transform_builder.rb +50 -0
  62. data/lib/vizcore/dsl/midi_map_executor.rb +219 -23
  63. data/lib/vizcore/dsl/reaction_builder.rb +1 -0
  64. data/lib/vizcore/dsl/scene_builder.rb +83 -13
  65. data/lib/vizcore/dsl/shader_source_resolver.rb +1 -10
  66. data/lib/vizcore/dsl/style_builder.rb +3 -0
  67. data/lib/vizcore/dsl/timeline_builder.rb +91 -8
  68. data/lib/vizcore/dsl/transition_controller.rb +157 -18
  69. data/lib/vizcore/dsl.rb +2 -0
  70. data/lib/vizcore/layer_catalog.rb +5 -2
  71. data/lib/vizcore/plugin_asset_policy.rb +55 -0
  72. data/lib/vizcore/project_manifest.rb +12 -2
  73. data/lib/vizcore/renderer/render_sequence.rb +104 -13
  74. data/lib/vizcore/renderer/scene_frame_source.rb +190 -12
  75. data/lib/vizcore/renderer/scene_serializer.rb +38 -0
  76. data/lib/vizcore/renderer/snapshot.rb +4 -3
  77. data/lib/vizcore/renderer/snapshot_renderer.rb +641 -23
  78. data/lib/vizcore/scene_trust.rb +31 -0
  79. data/lib/vizcore/server/frame_broadcaster.rb +513 -18
  80. data/lib/vizcore/server/rack_app.rb +151 -4
  81. data/lib/vizcore/server/runner.rb +697 -82
  82. data/lib/vizcore/server/websocket_handler.rb +236 -14
  83. data/lib/vizcore/server.rb +21 -0
  84. data/lib/vizcore/shape.rb +742 -0
  85. data/lib/vizcore/sync/osc_message.rb +66 -9
  86. data/lib/vizcore/version.rb +1 -1
  87. data/lib/vizcore.rb +34 -0
  88. data/scripts/browser_capture.mjs +31 -2
  89. data/sig/vizcore.rbs +154 -4
  90. metadata +29 -3
@@ -0,0 +1,488 @@
1
+ import { describeSvgArc } from "./svg-arc.js";
2
+
3
+ const SHAPE_VERTEX_SHADER = `#version 300 es
4
+ in vec2 a_position;
5
+ in vec2 a_uv;
6
+ out vec2 v_uv;
7
+ void main() {
8
+ v_uv = a_uv;
9
+ gl_Position = vec4(a_position, 0.0, 1.0);
10
+ }
11
+ `;
12
+
13
+ const SHAPE_FRAGMENT_SHADER = `#version 300 es
14
+ precision mediump float;
15
+ in vec2 v_uv;
16
+ uniform sampler2D u_texture;
17
+ uniform float u_intensity;
18
+ out vec4 outColor;
19
+
20
+ void main() {
21
+ vec4 texel = texture(u_texture, v_uv);
22
+ outColor = vec4(texel.rgb, texel.a * u_intensity);
23
+ }
24
+ `;
25
+
26
+ const QUAD_VERTICES = new Float32Array([
27
+ -1.0, -1.0, 0.0, 1.0,
28
+ 1.0, -1.0, 1.0, 1.0,
29
+ -1.0, 1.0, 0.0, 0.0,
30
+ 1.0, 1.0, 1.0, 0.0
31
+ ]);
32
+
33
+ export class ShapeRenderer {
34
+ constructor(gl, shaderManager) {
35
+ this.gl = gl;
36
+ this.shaderManager = shaderManager;
37
+ this.program = this.shaderManager.getProgram("shape-renderer", SHAPE_VERTEX_SHADER, SHAPE_FRAGMENT_SHADER);
38
+ this.positionLocation = this.gl.getAttribLocation(this.program, "a_position");
39
+ this.uvLocation = this.gl.getAttribLocation(this.program, "a_uv");
40
+ this.textureLocation = this.gl.getUniformLocation(this.program, "u_texture");
41
+ this.intensityLocation = this.gl.getUniformLocation(this.program, "u_intensity");
42
+
43
+ this.buffer = this.gl.createBuffer();
44
+ this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.buffer);
45
+ this.gl.bufferData(this.gl.ARRAY_BUFFER, QUAD_VERTICES, this.gl.STATIC_DRAW);
46
+
47
+ this.canvas = typeof document === "undefined" ? null : document.createElement("canvas");
48
+ this.ctx = this.canvas?.getContext("2d") || null;
49
+
50
+ this.texture = this.gl.createTexture();
51
+ this.gl.bindTexture(this.gl.TEXTURE_2D, this.texture);
52
+ this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_MIN_FILTER, this.gl.LINEAR);
53
+ this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_MAG_FILTER, this.gl.LINEAR);
54
+ this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_WRAP_S, this.gl.CLAMP_TO_EDGE);
55
+ this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_WRAP_T, this.gl.CLAMP_TO_EDGE);
56
+ }
57
+
58
+ render({ params = {}, color = "#e5f3ff", resolution = [1280, 720], audio = {} } = {}) {
59
+ const shapes = Array.isArray(params.shapes) ? params.shapes : [];
60
+ if (!this.ctx || shapes.length === 0) {
61
+ return false;
62
+ }
63
+
64
+ this.syncCanvasSize(resolution);
65
+ this.drawShapesToCanvas({ shapes, params, color });
66
+ this.uploadTexture();
67
+ const pulse = clamp(Number(audio?.beat_pulse || 0), 0, 1);
68
+ this.drawQuad({ intensity: 0.92 + pulse * 0.08 });
69
+ return true;
70
+ }
71
+
72
+ syncCanvasSize(resolution) {
73
+ const width = clamp(Math.floor(Number(resolution?.[0] || this.gl.drawingBufferWidth || 1024)), 1, 4096);
74
+ const height = clamp(Math.floor(Number(resolution?.[1] || this.gl.drawingBufferHeight || 1024)), 1, 4096);
75
+ if (this.canvas.width === width && this.canvas.height === height) {
76
+ return;
77
+ }
78
+ this.canvas.width = width;
79
+ this.canvas.height = height;
80
+ }
81
+
82
+ drawShapesToCanvas({ shapes, params, color }) {
83
+ const ctx = this.ctx;
84
+ const context = shapeCoordinateContext(params);
85
+ ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
86
+
87
+ shapes.forEach((shape) => {
88
+ const kind = shapeKind(shape);
89
+ if (!kind) return;
90
+
91
+ ctx.save();
92
+ applyShapeTransform(ctx, shape, context, this.canvas);
93
+ drawShapePath(ctx, shape, kind, context, this.canvas);
94
+ paintShapePath(ctx, shape, kind, color);
95
+ ctx.restore();
96
+ });
97
+ }
98
+
99
+ uploadTexture() {
100
+ this.gl.bindTexture(this.gl.TEXTURE_2D, this.texture);
101
+ this.gl.texImage2D(
102
+ this.gl.TEXTURE_2D,
103
+ 0,
104
+ this.gl.RGBA,
105
+ this.gl.RGBA,
106
+ this.gl.UNSIGNED_BYTE,
107
+ this.canvas
108
+ );
109
+ }
110
+
111
+ drawQuad({ intensity }) {
112
+ const gl = this.gl;
113
+ gl.useProgram(this.program);
114
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.buffer);
115
+ gl.enableVertexAttribArray(this.positionLocation);
116
+ gl.vertexAttribPointer(this.positionLocation, 2, gl.FLOAT, false, 16, 0);
117
+ gl.enableVertexAttribArray(this.uvLocation);
118
+ gl.vertexAttribPointer(this.uvLocation, 2, gl.FLOAT, false, 16, 8);
119
+
120
+ gl.activeTexture(gl.TEXTURE0);
121
+ gl.bindTexture(gl.TEXTURE_2D, this.texture);
122
+ gl.uniform1i(this.textureLocation, 0);
123
+ gl.uniform1f(this.intensityLocation, clamp(Number(intensity || 1), 0, 1));
124
+ gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
125
+ }
126
+
127
+ dispose() {
128
+ if (this.texture) {
129
+ this.gl.deleteTexture(this.texture);
130
+ this.texture = null;
131
+ }
132
+ if (this.buffer) {
133
+ this.gl.deleteBuffer(this.buffer);
134
+ this.buffer = null;
135
+ }
136
+ this.canvas = null;
137
+ this.ctx = null;
138
+ }
139
+ }
140
+
141
+ export const shapeCoordinateContext = (params = {}) => {
142
+ const requestedUnits = String(params.units || "").trim().toLowerCase();
143
+ if (requestedUnits) {
144
+ return { units: requestedUnits };
145
+ }
146
+
147
+ const version = Number(params.shape_schema_version ?? params.shapeSchemaVersion ?? 1);
148
+ return { units: version >= 2 ? "logical" : "legacy" };
149
+ };
150
+
151
+ export const resolveShapeCanvasPoint = (x, y, context, canvas) => {
152
+ return [
153
+ resolveShapeCanvasCoordinate(x, context, canvas, "x"),
154
+ resolveShapeCanvasCoordinate(y, context, canvas, "y")
155
+ ];
156
+ };
157
+
158
+ export const resolveShapeCanvasLength = (value, context, canvas, axis = "radius") => {
159
+ const numeric = Math.abs(finiteNumber(value, 0));
160
+ if (context.units === "ndc" || numeric <= 2) {
161
+ return numeric * Math.min(canvas.width, canvas.height) * 0.5;
162
+ }
163
+
164
+ return numeric;
165
+ };
166
+
167
+ export const resolveShapeStyle = (shape, layerColor = "#e5f3ff") => {
168
+ const fill = normalizePaint(shape?.fill);
169
+ const strokeColor = normalizePaint(shape?.stroke_color ?? shape?.strokeColor) || layerColor;
170
+ const strokeWidth = normalizeStrokeWidth(shape);
171
+ const opacity = clamp(finiteNumber(shape?.opacity, 1), 0, 1);
172
+ const dash = Array.isArray(shape?.dash) ? shape.dash.map((value) => Math.max(0, finiteNumber(value, 0))) : [];
173
+ return { fill, strokeColor, strokeWidth, opacity, dash };
174
+ };
175
+
176
+ export const shouldStrokeShape = (shape, kind) => {
177
+ if (kind === "line" || kind === "polyline" || kind === "path") {
178
+ return true;
179
+ }
180
+ return !normalizePaint(shape?.fill) || shape?.stroke !== undefined || shape?.stroke_width !== undefined || shape?.stroke_color !== undefined;
181
+ };
182
+
183
+ const drawShapePath = (ctx, shape, kind, context, canvas) => {
184
+ ctx.beginPath();
185
+ if (kind === "circle") {
186
+ appendCirclePath(ctx, shape, context, canvas);
187
+ } else if (kind === "line") {
188
+ appendLinePath(ctx, shape, context, canvas);
189
+ } else if (kind === "rect") {
190
+ appendRectPath(ctx, shape, context, canvas);
191
+ } else if (kind === "polygon" || kind === "polyline") {
192
+ appendPolygonPath(ctx, shape, context, canvas, kind === "polygon");
193
+ } else if (kind === "path") {
194
+ appendCustomPath(ctx, shape, context, canvas);
195
+ } else if (kind === "star") {
196
+ appendStarPath(ctx, shape, context, canvas);
197
+ }
198
+ };
199
+
200
+ const paintShapePath = (ctx, shape, kind, layerColor) => {
201
+ const style = resolveShapeStyle(shape, layerColor);
202
+ ctx.globalAlpha = style.opacity;
203
+ ctx.lineWidth = style.strokeWidth;
204
+ ctx.strokeStyle = style.strokeColor;
205
+ ctx.fillStyle = style.fill || "rgba(0, 0, 0, 0)";
206
+ ctx.lineCap = normalizeLineCap(shape?.line_cap ?? shape?.lineCap);
207
+ ctx.lineJoin = normalizeLineJoin(shape?.line_join ?? shape?.lineJoin);
208
+ ctx.miterLimit = clamp(finiteNumber(shape?.miter_limit ?? shape?.miterLimit, 10), 1, 64);
209
+ if (typeof ctx.setLineDash === "function") {
210
+ ctx.setLineDash(style.dash);
211
+ }
212
+
213
+ if (style.fill) {
214
+ ctx.fill();
215
+ }
216
+ if (shouldStrokeShape(shape, kind) && style.strokeWidth > 0) {
217
+ ctx.stroke();
218
+ }
219
+ };
220
+
221
+ const appendCirclePath = (ctx, shape, context, canvas) => {
222
+ const count = clampInt(shape.count || 1, 1, 64);
223
+ const segments = clampInt(shape.segments || 96, 12, 256);
224
+ const radius = resolveShapeCanvasLength(shape.radius ?? 100, context, canvas, "radius");
225
+ const [x, y] = resolveShapeCanvasPoint(shape.x ?? 0, shape.y ?? 0, context, canvas);
226
+
227
+ for (let ring = 0; ring < count; ring += 1) {
228
+ ctx.moveTo(x + radius * ((ring + 1) / count), y);
229
+ ctx.arc(x, y, radius * ((ring + 1) / count), 0, Math.PI * 2, false);
230
+ }
231
+ };
232
+
233
+ const appendLinePath = (ctx, shape, context, canvas) => {
234
+ const defaults = context.units === "legacy" || context.units === "ndc" ? [-0.8, 0, 0.8, 0] : [-100, 0, 100, 0];
235
+ const from = resolveShapeCanvasPoint(shape.x1 ?? defaults[0], shape.y1 ?? defaults[1], context, canvas);
236
+ const to = resolveShapeCanvasPoint(shape.x2 ?? defaults[2], shape.y2 ?? defaults[3], context, canvas);
237
+ ctx.moveTo(from[0], from[1]);
238
+ ctx.lineTo(to[0], to[1]);
239
+ };
240
+
241
+ const appendRectPath = (ctx, shape, context, canvas) => {
242
+ const [x, y] = resolveShapeCanvasPoint(shape.x ?? 0, shape.y ?? 0, context, canvas);
243
+ const width = resolveShapeCanvasLength(shape.width ?? 100, context, canvas, "x");
244
+ const height = resolveShapeCanvasLength(shape.height ?? 100, context, canvas, "y");
245
+ const radius = clamp(resolveShapeCanvasLength(shape.radius ?? 0, context, canvas, "radius"), 0, Math.min(width, height) / 2);
246
+ const left = x - width / 2;
247
+ const top = y - height / 2;
248
+
249
+ if (radius <= 0) {
250
+ ctx.rect(left, top, width, height);
251
+ return;
252
+ }
253
+
254
+ ctx.moveTo(left + radius, top);
255
+ ctx.lineTo(left + width - radius, top);
256
+ ctx.quadraticCurveTo(left + width, top, left + width, top + radius);
257
+ ctx.lineTo(left + width, top + height - radius);
258
+ ctx.quadraticCurveTo(left + width, top + height, left + width - radius, top + height);
259
+ ctx.lineTo(left + radius, top + height);
260
+ ctx.quadraticCurveTo(left, top + height, left, top + height - radius);
261
+ ctx.lineTo(left, top + radius);
262
+ ctx.quadraticCurveTo(left, top, left + radius, top);
263
+ ctx.closePath();
264
+ };
265
+
266
+ const appendPolygonPath = (ctx, shape, context, canvas, defaultClosed) => {
267
+ const points = normalizeShapePoints(shape.points, context, canvas);
268
+ if (points.length < (defaultClosed ? 3 : 2)) {
269
+ return;
270
+ }
271
+ ctx.moveTo(points[0][0], points[0][1]);
272
+ points.slice(1).forEach((point) => ctx.lineTo(point[0], point[1]));
273
+ if (shape.closed ?? defaultClosed) {
274
+ ctx.closePath();
275
+ }
276
+ };
277
+
278
+ const appendStarPath = (ctx, shape, context, canvas) => {
279
+ const tips = clampInt(shape.points || 5, 3, 128);
280
+ const radius = resolveShapeCanvasLength(shape.radius ?? 100, context, canvas, "radius");
281
+ const innerRadius = resolveShapeCanvasLength(shape.inner_radius ?? finiteNumber(shape.radius, 100) * 0.5, context, canvas, "radius");
282
+ const [cx, cy] = resolveShapeCanvasPoint(shape.x ?? 0, shape.y ?? 0, context, canvas);
283
+ const rotation = (finiteNumber(shape.rotation, -90) / 180) * Math.PI;
284
+
285
+ for (let index = 0; index < tips * 2; index += 1) {
286
+ const angle = rotation + (index / (tips * 2)) * Math.PI * 2;
287
+ const pointRadius = index % 2 === 0 ? radius : innerRadius;
288
+ const x = cx + Math.cos(angle) * pointRadius;
289
+ const y = cy - Math.sin(angle) * pointRadius;
290
+ if (index === 0) {
291
+ ctx.moveTo(x, y);
292
+ } else {
293
+ ctx.lineTo(x, y);
294
+ }
295
+ }
296
+ ctx.closePath();
297
+ };
298
+
299
+ const appendCustomPath = (ctx, shape, context, canvas) => {
300
+ let current = null;
301
+ (Array.isArray(shape.commands) ? shape.commands : []).forEach((entry) => {
302
+ const command = Array.isArray(entry) ? String(entry[0] || "").toUpperCase() : "";
303
+ const values = Array.isArray(entry) ? entry.slice(1).map((value) => finiteNumber(value, 0)) : [];
304
+ if (command === "M" && values.length >= 2) {
305
+ current = resolveShapeCanvasPoint(values[0], values[1], context, canvas);
306
+ ctx.moveTo(current[0], current[1]);
307
+ } else if (command === "L" && values.length >= 2) {
308
+ current = resolveShapeCanvasPoint(values[0], values[1], context, canvas);
309
+ ctx.lineTo(current[0], current[1]);
310
+ } else if (command === "H" && current && values.length >= 1) {
311
+ current = [resolveShapeCanvasCoordinate(values[0], context, canvas, "x"), current[1]];
312
+ ctx.lineTo(current[0], current[1]);
313
+ } else if (command === "V" && current && values.length >= 1) {
314
+ current = [current[0], resolveShapeCanvasCoordinate(values[0], context, canvas, "y")];
315
+ ctx.lineTo(current[0], current[1]);
316
+ } else if (command === "Q" && values.length >= 4) {
317
+ const control = resolveShapeCanvasPoint(values[0], values[1], context, canvas);
318
+ current = resolveShapeCanvasPoint(values[2], values[3], context, canvas);
319
+ ctx.quadraticCurveTo(control[0], control[1], current[0], current[1]);
320
+ } else if (command === "C" && values.length >= 6) {
321
+ const c1 = resolveShapeCanvasPoint(values[0], values[1], context, canvas);
322
+ const c2 = resolveShapeCanvasPoint(values[2], values[3], context, canvas);
323
+ current = resolveShapeCanvasPoint(values[4], values[5], context, canvas);
324
+ ctx.bezierCurveTo(c1[0], c1[1], c2[0], c2[1], current[0], current[1]);
325
+ } else if (command === "A" && current && values.length >= 7) {
326
+ current = appendSvgArcPath(ctx, current, values, context, canvas);
327
+ } else if (command === "Z") {
328
+ ctx.closePath();
329
+ }
330
+ });
331
+ };
332
+
333
+ const appendSvgArcPath = (ctx, current, values, context, canvas) => {
334
+ const endpoint = resolveShapeCanvasPoint(values[5], values[6], context, canvas);
335
+ const arc = describeSvgArc({
336
+ from: current,
337
+ to: endpoint,
338
+ rx: resolveShapeCanvasLength(values[0], context, canvas, "x"),
339
+ ry: resolveShapeCanvasLength(values[1], context, canvas, "y"),
340
+ xAxisRotation: -finiteNumber(values[2], 0),
341
+ largeArc: !!values[3],
342
+ sweep: !!values[4]
343
+ });
344
+
345
+ if (arc && typeof ctx.ellipse === "function") {
346
+ ctx.ellipse(
347
+ arc.cx,
348
+ arc.cy,
349
+ arc.rx,
350
+ arc.ry,
351
+ arc.rotation,
352
+ arc.startAngle,
353
+ arc.startAngle + arc.deltaAngle,
354
+ arc.deltaAngle < 0
355
+ );
356
+ } else {
357
+ ctx.lineTo(endpoint[0], endpoint[1]);
358
+ }
359
+
360
+ return endpoint;
361
+ };
362
+
363
+ const applyShapeTransform = (ctx, shape, context, canvas) => {
364
+ const transform = shape?.transform || {};
365
+ const origin = resolveShapeCanvasPoint(
366
+ transform.origin?.x ?? transform.origin?.[0] ?? 0,
367
+ transform.origin?.y ?? transform.origin?.[1] ?? 0,
368
+ context,
369
+ canvas
370
+ );
371
+ const translate = resolveShapeCanvasVector(transform.translate || shape?.translate, context, canvas);
372
+ const rotation = finiteNumber(transform.rotate ?? shape?.rotate ?? shape?.rotation, 0);
373
+ const scale = normalizeShapeScale(transform.scale ?? shape?.scale);
374
+
375
+ ctx.translate(origin[0], origin[1]);
376
+ ctx.translate(translate.x, translate.y);
377
+ ctx.rotate((-rotation / 180) * Math.PI);
378
+ ctx.scale(scale.x, scale.y);
379
+ ctx.translate(-origin[0], -origin[1]);
380
+ };
381
+
382
+ const resolveShapeCanvasCoordinate = (value, context, canvas, axis) => {
383
+ const numeric = finiteNumber(value, 0);
384
+ if (context.units === "ndc") {
385
+ return axis === "x"
386
+ ? canvas.width * 0.5 + numeric * canvas.width * 0.5
387
+ : canvas.height * 0.5 - numeric * canvas.height * 0.5;
388
+ }
389
+
390
+ if (logicalShapeUnits(context.units)) {
391
+ return axis === "x" ? canvas.width * 0.5 + numeric : canvas.height * 0.5 - numeric;
392
+ }
393
+
394
+ if (screenShapeUnits(context.units)) {
395
+ return numeric;
396
+ }
397
+
398
+ return legacyShapeCoordinate(numeric, axis, canvas);
399
+ };
400
+
401
+ const legacyShapeCoordinate = (numeric, axis, canvas) => {
402
+ if (Math.abs(numeric) <= 1.5) {
403
+ return axis === "x"
404
+ ? canvas.width * 0.5 + numeric * canvas.width * 0.5
405
+ : canvas.height * 0.5 - numeric * canvas.height * 0.5;
406
+ }
407
+ return numeric;
408
+ };
409
+
410
+ const resolveShapeCanvasVector = (value, context, canvas) => {
411
+ if (!value || typeof value !== "object") {
412
+ return { x: 0, y: 0 };
413
+ }
414
+ const x = Array.isArray(value) ? value[0] : value.x;
415
+ const y = Array.isArray(value) ? value[1] : value.y;
416
+ if (context.units === "ndc") {
417
+ return {
418
+ x: finiteNumber(x, 0) * canvas.width * 0.5,
419
+ y: -finiteNumber(y, 0) * canvas.height * 0.5
420
+ };
421
+ }
422
+ return { x: finiteNumber(x, 0), y: -finiteNumber(y, 0) };
423
+ };
424
+
425
+ const normalizeShapePoints = (value, context, canvas) => {
426
+ if (!Array.isArray(value)) {
427
+ return [];
428
+ }
429
+ return value
430
+ .filter((point) => Array.isArray(point) && point.length >= 2)
431
+ .map((point) => resolveShapeCanvasPoint(point[0], point[1], context, canvas));
432
+ };
433
+
434
+ const normalizeShapeScale = (value) => {
435
+ if (value && typeof value === "object") {
436
+ return {
437
+ x: clamp(finiteNumber(value.x, 1), -8, 8),
438
+ y: clamp(finiteNumber(value.y, 1), -8, 8)
439
+ };
440
+ }
441
+ const scale = clamp(finiteNumber(value, 1), -8, 8);
442
+ return { x: scale, y: scale };
443
+ };
444
+
445
+ const normalizeStrokeWidth = (shape) => {
446
+ const width = shape?.stroke_width ?? shape?.strokeWidth ?? (typeof shape?.stroke === "number" ? shape.stroke : 1);
447
+ return clamp(finiteNumber(width, 1), 0, 512);
448
+ };
449
+
450
+ const normalizePaint = (value) => {
451
+ const paint = String(value ?? "").trim();
452
+ if (!paint || paint === "none" || paint === "transparent") {
453
+ return null;
454
+ }
455
+ return paint;
456
+ };
457
+
458
+ const normalizeLineCap = (value) => {
459
+ const cap = String(value || "butt").trim().toLowerCase();
460
+ return cap === "round" || cap === "square" ? cap : "butt";
461
+ };
462
+
463
+ const normalizeLineJoin = (value) => {
464
+ const join = String(value || "miter").trim().toLowerCase();
465
+ return join === "round" || join === "bevel" ? join : "miter";
466
+ };
467
+
468
+ const shapeKind = (shape) => {
469
+ const kind = String(shape?.kind || shape?.type || "").toLowerCase();
470
+ return ["circle", "line", "rect", "polygon", "polyline", "path", "star"].includes(kind) ? kind : null;
471
+ };
472
+
473
+ const logicalShapeUnits = (value) => ["logical", "center", "center_origin", "px"].includes(value);
474
+
475
+ const screenShapeUnits = (value) => ["screen", "canvas", "viewport"].includes(value);
476
+
477
+ const clampInt = (value, min, max) => {
478
+ const numeric = Number(value);
479
+ if (!Number.isFinite(numeric)) return min;
480
+ return Math.round(Math.min(Math.max(numeric, min), max));
481
+ };
482
+
483
+ const finiteNumber = (value, fallback) => {
484
+ const numeric = Number(value);
485
+ return Number.isFinite(numeric) ? numeric : fallback;
486
+ };
487
+
488
+ const clamp = (value, min, max) => Math.min(Math.max(value, min), max);
@@ -124,6 +124,20 @@ export class SpectrogramRenderer {
124
124
  gl.uniform1f(this.opacityLocation, clamp(Number(opacity || 1), 0, 1));
125
125
  gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
126
126
  }
127
+
128
+ dispose() {
129
+ this.histories.clear();
130
+ if (this.texture) {
131
+ this.gl.deleteTexture(this.texture);
132
+ this.texture = null;
133
+ }
134
+ if (this.buffer) {
135
+ this.gl.deleteBuffer(this.buffer);
136
+ this.buffer = null;
137
+ }
138
+ this.canvas = null;
139
+ this.ctx = null;
140
+ }
127
141
  }
128
142
 
129
143
  export const normalizeSpectrogramScroll = (value) => {
@@ -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
+ };
@@ -163,6 +163,19 @@ export class TextRenderer {
163
163
  gl.uniform1f(this.intensityLocation, clamp(Number(intensity || 1), 0, 1));
164
164
  gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
165
165
  }
166
+
167
+ dispose() {
168
+ if (this.texture) {
169
+ this.gl.deleteTexture(this.texture);
170
+ this.texture = null;
171
+ }
172
+ if (this.buffer) {
173
+ this.gl.deleteBuffer(this.buffer);
174
+ this.buffer = null;
175
+ }
176
+ this.canvas = null;
177
+ this.ctx = null;
178
+ }
166
179
  }
167
180
 
168
181
  const clamp = (value, min, max) => Math.min(Math.max(value, min), max);
@@ -10,6 +10,7 @@ export class WebSocketClient {
10
10
  this.onSceneChange = callbacks.onSceneChange || (() => {});
11
11
  this.onConfigUpdate = callbacks.onConfigUpdate || (() => {});
12
12
  this.onLatencyProbe = callbacks.onLatencyProbe || (() => {});
13
+ this.onRuntimeError = callbacks.onRuntimeError || (() => {});
13
14
  this.onStatus = callbacks.onStatus || (() => {});
14
15
  this.socket = null;
15
16
  this.reconnectTimer = null;
@@ -120,6 +121,11 @@ export class WebSocketClient {
120
121
 
121
122
  if (message.type === "latency_probe") {
122
123
  this.onLatencyProbe(message.payload);
124
+ return;
125
+ }
126
+
127
+ if (message.type === "runtime_error") {
128
+ this.onRuntimeError(message.payload);
123
129
  }
124
130
  }
125
131
 
@@ -11,11 +11,14 @@ module Vizcore
11
11
  # @param window_size [Integer] number of recent active frames used to track the peak
12
12
  # @param target [Numeric] desired level for the rolling peak
13
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)
14
+ # @param per_band [Boolean] true when band levels should track independent peaks
15
+ def initialize(window_size: DEFAULT_WINDOW_SIZE, target: DEFAULT_TARGET, floor: DEFAULT_FLOOR, per_band: false)
15
16
  @window_size = normalize_window_size(window_size)
16
17
  @target = normalize_unit(target, DEFAULT_TARGET)
17
18
  @floor = normalize_unit(floor, DEFAULT_FLOOR)
19
+ @per_band = !!per_band
18
20
  @history = []
21
+ @band_history = Hash.new { |history, key| history[key] = [] }
19
22
  end
20
23
 
21
24
  # @param amplitude [Numeric] current RMS amplitude
@@ -30,7 +33,7 @@ module Vizcore
30
33
  gain = @target / [@history.max.to_f, @floor].max
31
34
  {
32
35
  amplitude: scale_value(current_amplitude, gain),
33
- bands: scale_hash(bands, gain),
36
+ bands: normalize_bands(bands, gain),
34
37
  fft: scale_array(fft, gain),
35
38
  gain: gain
36
39
  }
@@ -56,6 +59,21 @@ module Vizcore
56
59
  {}
57
60
  end
58
61
 
62
+ def normalize_bands(values, amplitude_gain)
63
+ return scale_hash(values, amplitude_gain) unless @per_band
64
+
65
+ Hash(values).each_with_object({}) do |(key, value), output|
66
+ normalized = normalize_unit(value, 0.0)
67
+ history = @band_history[key.to_sym]
68
+ history << normalized
69
+ history.shift while history.length > @window_size
70
+ gain = @target / [history.max.to_f, @floor].max
71
+ output[key] = scale_value(normalized, gain)
72
+ end
73
+ rescue StandardError
74
+ {}
75
+ end
76
+
59
77
  def scale_array(values, gain)
60
78
  Array(values).map { |value| scale_value(value, gain) }
61
79
  end