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,475 @@
|
|
|
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
|
+
|
|
128
|
+
export const shapeCoordinateContext = (params = {}) => {
|
|
129
|
+
const requestedUnits = String(params.units || "").trim().toLowerCase();
|
|
130
|
+
if (requestedUnits) {
|
|
131
|
+
return { units: requestedUnits };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const version = Number(params.shape_schema_version ?? params.shapeSchemaVersion ?? 1);
|
|
135
|
+
return { units: version >= 2 ? "logical" : "legacy" };
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
export const resolveShapeCanvasPoint = (x, y, context, canvas) => {
|
|
139
|
+
return [
|
|
140
|
+
resolveShapeCanvasCoordinate(x, context, canvas, "x"),
|
|
141
|
+
resolveShapeCanvasCoordinate(y, context, canvas, "y")
|
|
142
|
+
];
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
export const resolveShapeCanvasLength = (value, context, canvas, axis = "radius") => {
|
|
146
|
+
const numeric = Math.abs(finiteNumber(value, 0));
|
|
147
|
+
if (context.units === "ndc" || numeric <= 2) {
|
|
148
|
+
return numeric * Math.min(canvas.width, canvas.height) * 0.5;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return numeric;
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
export const resolveShapeStyle = (shape, layerColor = "#e5f3ff") => {
|
|
155
|
+
const fill = normalizePaint(shape?.fill);
|
|
156
|
+
const strokeColor = normalizePaint(shape?.stroke_color ?? shape?.strokeColor) || layerColor;
|
|
157
|
+
const strokeWidth = normalizeStrokeWidth(shape);
|
|
158
|
+
const opacity = clamp(finiteNumber(shape?.opacity, 1), 0, 1);
|
|
159
|
+
const dash = Array.isArray(shape?.dash) ? shape.dash.map((value) => Math.max(0, finiteNumber(value, 0))) : [];
|
|
160
|
+
return { fill, strokeColor, strokeWidth, opacity, dash };
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
export const shouldStrokeShape = (shape, kind) => {
|
|
164
|
+
if (kind === "line" || kind === "polyline" || kind === "path") {
|
|
165
|
+
return true;
|
|
166
|
+
}
|
|
167
|
+
return !normalizePaint(shape?.fill) || shape?.stroke !== undefined || shape?.stroke_width !== undefined || shape?.stroke_color !== undefined;
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
const drawShapePath = (ctx, shape, kind, context, canvas) => {
|
|
171
|
+
ctx.beginPath();
|
|
172
|
+
if (kind === "circle") {
|
|
173
|
+
appendCirclePath(ctx, shape, context, canvas);
|
|
174
|
+
} else if (kind === "line") {
|
|
175
|
+
appendLinePath(ctx, shape, context, canvas);
|
|
176
|
+
} else if (kind === "rect") {
|
|
177
|
+
appendRectPath(ctx, shape, context, canvas);
|
|
178
|
+
} else if (kind === "polygon" || kind === "polyline") {
|
|
179
|
+
appendPolygonPath(ctx, shape, context, canvas, kind === "polygon");
|
|
180
|
+
} else if (kind === "path") {
|
|
181
|
+
appendCustomPath(ctx, shape, context, canvas);
|
|
182
|
+
} else if (kind === "star") {
|
|
183
|
+
appendStarPath(ctx, shape, context, canvas);
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
const paintShapePath = (ctx, shape, kind, layerColor) => {
|
|
188
|
+
const style = resolveShapeStyle(shape, layerColor);
|
|
189
|
+
ctx.globalAlpha = style.opacity;
|
|
190
|
+
ctx.lineWidth = style.strokeWidth;
|
|
191
|
+
ctx.strokeStyle = style.strokeColor;
|
|
192
|
+
ctx.fillStyle = style.fill || "rgba(0, 0, 0, 0)";
|
|
193
|
+
ctx.lineCap = normalizeLineCap(shape?.line_cap ?? shape?.lineCap);
|
|
194
|
+
ctx.lineJoin = normalizeLineJoin(shape?.line_join ?? shape?.lineJoin);
|
|
195
|
+
ctx.miterLimit = clamp(finiteNumber(shape?.miter_limit ?? shape?.miterLimit, 10), 1, 64);
|
|
196
|
+
if (typeof ctx.setLineDash === "function") {
|
|
197
|
+
ctx.setLineDash(style.dash);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (style.fill) {
|
|
201
|
+
ctx.fill();
|
|
202
|
+
}
|
|
203
|
+
if (shouldStrokeShape(shape, kind) && style.strokeWidth > 0) {
|
|
204
|
+
ctx.stroke();
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
const appendCirclePath = (ctx, shape, context, canvas) => {
|
|
209
|
+
const count = clampInt(shape.count || 1, 1, 64);
|
|
210
|
+
const segments = clampInt(shape.segments || 96, 12, 256);
|
|
211
|
+
const radius = resolveShapeCanvasLength(shape.radius ?? 100, context, canvas, "radius");
|
|
212
|
+
const [x, y] = resolveShapeCanvasPoint(shape.x ?? 0, shape.y ?? 0, context, canvas);
|
|
213
|
+
|
|
214
|
+
for (let ring = 0; ring < count; ring += 1) {
|
|
215
|
+
ctx.moveTo(x + radius * ((ring + 1) / count), y);
|
|
216
|
+
ctx.arc(x, y, radius * ((ring + 1) / count), 0, Math.PI * 2, false);
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
const appendLinePath = (ctx, shape, context, canvas) => {
|
|
221
|
+
const defaults = context.units === "legacy" || context.units === "ndc" ? [-0.8, 0, 0.8, 0] : [-100, 0, 100, 0];
|
|
222
|
+
const from = resolveShapeCanvasPoint(shape.x1 ?? defaults[0], shape.y1 ?? defaults[1], context, canvas);
|
|
223
|
+
const to = resolveShapeCanvasPoint(shape.x2 ?? defaults[2], shape.y2 ?? defaults[3], context, canvas);
|
|
224
|
+
ctx.moveTo(from[0], from[1]);
|
|
225
|
+
ctx.lineTo(to[0], to[1]);
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
const appendRectPath = (ctx, shape, context, canvas) => {
|
|
229
|
+
const [x, y] = resolveShapeCanvasPoint(shape.x ?? 0, shape.y ?? 0, context, canvas);
|
|
230
|
+
const width = resolveShapeCanvasLength(shape.width ?? 100, context, canvas, "x");
|
|
231
|
+
const height = resolveShapeCanvasLength(shape.height ?? 100, context, canvas, "y");
|
|
232
|
+
const radius = clamp(resolveShapeCanvasLength(shape.radius ?? 0, context, canvas, "radius"), 0, Math.min(width, height) / 2);
|
|
233
|
+
const left = x - width / 2;
|
|
234
|
+
const top = y - height / 2;
|
|
235
|
+
|
|
236
|
+
if (radius <= 0) {
|
|
237
|
+
ctx.rect(left, top, width, height);
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
ctx.moveTo(left + radius, top);
|
|
242
|
+
ctx.lineTo(left + width - radius, top);
|
|
243
|
+
ctx.quadraticCurveTo(left + width, top, left + width, top + radius);
|
|
244
|
+
ctx.lineTo(left + width, top + height - radius);
|
|
245
|
+
ctx.quadraticCurveTo(left + width, top + height, left + width - radius, top + height);
|
|
246
|
+
ctx.lineTo(left + radius, top + height);
|
|
247
|
+
ctx.quadraticCurveTo(left, top + height, left, top + height - radius);
|
|
248
|
+
ctx.lineTo(left, top + radius);
|
|
249
|
+
ctx.quadraticCurveTo(left, top, left + radius, top);
|
|
250
|
+
ctx.closePath();
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
const appendPolygonPath = (ctx, shape, context, canvas, defaultClosed) => {
|
|
254
|
+
const points = normalizeShapePoints(shape.points, context, canvas);
|
|
255
|
+
if (points.length < (defaultClosed ? 3 : 2)) {
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
ctx.moveTo(points[0][0], points[0][1]);
|
|
259
|
+
points.slice(1).forEach((point) => ctx.lineTo(point[0], point[1]));
|
|
260
|
+
if (shape.closed ?? defaultClosed) {
|
|
261
|
+
ctx.closePath();
|
|
262
|
+
}
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
const appendStarPath = (ctx, shape, context, canvas) => {
|
|
266
|
+
const tips = clampInt(shape.points || 5, 3, 128);
|
|
267
|
+
const radius = resolveShapeCanvasLength(shape.radius ?? 100, context, canvas, "radius");
|
|
268
|
+
const innerRadius = resolveShapeCanvasLength(shape.inner_radius ?? finiteNumber(shape.radius, 100) * 0.5, context, canvas, "radius");
|
|
269
|
+
const [cx, cy] = resolveShapeCanvasPoint(shape.x ?? 0, shape.y ?? 0, context, canvas);
|
|
270
|
+
const rotation = (finiteNumber(shape.rotation, -90) / 180) * Math.PI;
|
|
271
|
+
|
|
272
|
+
for (let index = 0; index < tips * 2; index += 1) {
|
|
273
|
+
const angle = rotation + (index / (tips * 2)) * Math.PI * 2;
|
|
274
|
+
const pointRadius = index % 2 === 0 ? radius : innerRadius;
|
|
275
|
+
const x = cx + Math.cos(angle) * pointRadius;
|
|
276
|
+
const y = cy - Math.sin(angle) * pointRadius;
|
|
277
|
+
if (index === 0) {
|
|
278
|
+
ctx.moveTo(x, y);
|
|
279
|
+
} else {
|
|
280
|
+
ctx.lineTo(x, y);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
ctx.closePath();
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
const appendCustomPath = (ctx, shape, context, canvas) => {
|
|
287
|
+
let current = null;
|
|
288
|
+
(Array.isArray(shape.commands) ? shape.commands : []).forEach((entry) => {
|
|
289
|
+
const command = Array.isArray(entry) ? String(entry[0] || "").toUpperCase() : "";
|
|
290
|
+
const values = Array.isArray(entry) ? entry.slice(1).map((value) => finiteNumber(value, 0)) : [];
|
|
291
|
+
if (command === "M" && values.length >= 2) {
|
|
292
|
+
current = resolveShapeCanvasPoint(values[0], values[1], context, canvas);
|
|
293
|
+
ctx.moveTo(current[0], current[1]);
|
|
294
|
+
} else if (command === "L" && values.length >= 2) {
|
|
295
|
+
current = resolveShapeCanvasPoint(values[0], values[1], context, canvas);
|
|
296
|
+
ctx.lineTo(current[0], current[1]);
|
|
297
|
+
} else if (command === "H" && current && values.length >= 1) {
|
|
298
|
+
current = [resolveShapeCanvasCoordinate(values[0], context, canvas, "x"), current[1]];
|
|
299
|
+
ctx.lineTo(current[0], current[1]);
|
|
300
|
+
} else if (command === "V" && current && values.length >= 1) {
|
|
301
|
+
current = [current[0], resolveShapeCanvasCoordinate(values[0], context, canvas, "y")];
|
|
302
|
+
ctx.lineTo(current[0], current[1]);
|
|
303
|
+
} else if (command === "Q" && values.length >= 4) {
|
|
304
|
+
const control = resolveShapeCanvasPoint(values[0], values[1], context, canvas);
|
|
305
|
+
current = resolveShapeCanvasPoint(values[2], values[3], context, canvas);
|
|
306
|
+
ctx.quadraticCurveTo(control[0], control[1], current[0], current[1]);
|
|
307
|
+
} else if (command === "C" && values.length >= 6) {
|
|
308
|
+
const c1 = resolveShapeCanvasPoint(values[0], values[1], context, canvas);
|
|
309
|
+
const c2 = resolveShapeCanvasPoint(values[2], values[3], context, canvas);
|
|
310
|
+
current = resolveShapeCanvasPoint(values[4], values[5], context, canvas);
|
|
311
|
+
ctx.bezierCurveTo(c1[0], c1[1], c2[0], c2[1], current[0], current[1]);
|
|
312
|
+
} else if (command === "A" && current && values.length >= 7) {
|
|
313
|
+
current = appendSvgArcPath(ctx, current, values, context, canvas);
|
|
314
|
+
} else if (command === "Z") {
|
|
315
|
+
ctx.closePath();
|
|
316
|
+
}
|
|
317
|
+
});
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
const appendSvgArcPath = (ctx, current, values, context, canvas) => {
|
|
321
|
+
const endpoint = resolveShapeCanvasPoint(values[5], values[6], context, canvas);
|
|
322
|
+
const arc = describeSvgArc({
|
|
323
|
+
from: current,
|
|
324
|
+
to: endpoint,
|
|
325
|
+
rx: resolveShapeCanvasLength(values[0], context, canvas, "x"),
|
|
326
|
+
ry: resolveShapeCanvasLength(values[1], context, canvas, "y"),
|
|
327
|
+
xAxisRotation: -finiteNumber(values[2], 0),
|
|
328
|
+
largeArc: !!values[3],
|
|
329
|
+
sweep: !!values[4]
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
if (arc && typeof ctx.ellipse === "function") {
|
|
333
|
+
ctx.ellipse(
|
|
334
|
+
arc.cx,
|
|
335
|
+
arc.cy,
|
|
336
|
+
arc.rx,
|
|
337
|
+
arc.ry,
|
|
338
|
+
arc.rotation,
|
|
339
|
+
arc.startAngle,
|
|
340
|
+
arc.startAngle + arc.deltaAngle,
|
|
341
|
+
arc.deltaAngle < 0
|
|
342
|
+
);
|
|
343
|
+
} else {
|
|
344
|
+
ctx.lineTo(endpoint[0], endpoint[1]);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
return endpoint;
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
const applyShapeTransform = (ctx, shape, context, canvas) => {
|
|
351
|
+
const transform = shape?.transform || {};
|
|
352
|
+
const origin = resolveShapeCanvasPoint(
|
|
353
|
+
transform.origin?.x ?? transform.origin?.[0] ?? 0,
|
|
354
|
+
transform.origin?.y ?? transform.origin?.[1] ?? 0,
|
|
355
|
+
context,
|
|
356
|
+
canvas
|
|
357
|
+
);
|
|
358
|
+
const translate = resolveShapeCanvasVector(transform.translate || shape?.translate, context, canvas);
|
|
359
|
+
const rotation = finiteNumber(transform.rotate ?? shape?.rotate ?? shape?.rotation, 0);
|
|
360
|
+
const scale = normalizeShapeScale(transform.scale ?? shape?.scale);
|
|
361
|
+
|
|
362
|
+
ctx.translate(origin[0], origin[1]);
|
|
363
|
+
ctx.translate(translate.x, translate.y);
|
|
364
|
+
ctx.rotate((-rotation / 180) * Math.PI);
|
|
365
|
+
ctx.scale(scale.x, scale.y);
|
|
366
|
+
ctx.translate(-origin[0], -origin[1]);
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
const resolveShapeCanvasCoordinate = (value, context, canvas, axis) => {
|
|
370
|
+
const numeric = finiteNumber(value, 0);
|
|
371
|
+
if (context.units === "ndc") {
|
|
372
|
+
return axis === "x"
|
|
373
|
+
? canvas.width * 0.5 + numeric * canvas.width * 0.5
|
|
374
|
+
: canvas.height * 0.5 - numeric * canvas.height * 0.5;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
if (logicalShapeUnits(context.units)) {
|
|
378
|
+
return axis === "x" ? canvas.width * 0.5 + numeric : canvas.height * 0.5 - numeric;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
if (screenShapeUnits(context.units)) {
|
|
382
|
+
return numeric;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
return legacyShapeCoordinate(numeric, axis, canvas);
|
|
386
|
+
};
|
|
387
|
+
|
|
388
|
+
const legacyShapeCoordinate = (numeric, axis, canvas) => {
|
|
389
|
+
if (Math.abs(numeric) <= 1.5) {
|
|
390
|
+
return axis === "x"
|
|
391
|
+
? canvas.width * 0.5 + numeric * canvas.width * 0.5
|
|
392
|
+
: canvas.height * 0.5 - numeric * canvas.height * 0.5;
|
|
393
|
+
}
|
|
394
|
+
return numeric;
|
|
395
|
+
};
|
|
396
|
+
|
|
397
|
+
const resolveShapeCanvasVector = (value, context, canvas) => {
|
|
398
|
+
if (!value || typeof value !== "object") {
|
|
399
|
+
return { x: 0, y: 0 };
|
|
400
|
+
}
|
|
401
|
+
const x = Array.isArray(value) ? value[0] : value.x;
|
|
402
|
+
const y = Array.isArray(value) ? value[1] : value.y;
|
|
403
|
+
if (context.units === "ndc") {
|
|
404
|
+
return {
|
|
405
|
+
x: finiteNumber(x, 0) * canvas.width * 0.5,
|
|
406
|
+
y: -finiteNumber(y, 0) * canvas.height * 0.5
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
return { x: finiteNumber(x, 0), y: -finiteNumber(y, 0) };
|
|
410
|
+
};
|
|
411
|
+
|
|
412
|
+
const normalizeShapePoints = (value, context, canvas) => {
|
|
413
|
+
if (!Array.isArray(value)) {
|
|
414
|
+
return [];
|
|
415
|
+
}
|
|
416
|
+
return value
|
|
417
|
+
.filter((point) => Array.isArray(point) && point.length >= 2)
|
|
418
|
+
.map((point) => resolveShapeCanvasPoint(point[0], point[1], context, canvas));
|
|
419
|
+
};
|
|
420
|
+
|
|
421
|
+
const normalizeShapeScale = (value) => {
|
|
422
|
+
if (value && typeof value === "object") {
|
|
423
|
+
return {
|
|
424
|
+
x: clamp(finiteNumber(value.x, 1), -8, 8),
|
|
425
|
+
y: clamp(finiteNumber(value.y, 1), -8, 8)
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
const scale = clamp(finiteNumber(value, 1), -8, 8);
|
|
429
|
+
return { x: scale, y: scale };
|
|
430
|
+
};
|
|
431
|
+
|
|
432
|
+
const normalizeStrokeWidth = (shape) => {
|
|
433
|
+
const width = shape?.stroke_width ?? shape?.strokeWidth ?? (typeof shape?.stroke === "number" ? shape.stroke : 1);
|
|
434
|
+
return clamp(finiteNumber(width, 1), 0, 512);
|
|
435
|
+
};
|
|
436
|
+
|
|
437
|
+
const normalizePaint = (value) => {
|
|
438
|
+
const paint = String(value ?? "").trim();
|
|
439
|
+
if (!paint || paint === "none" || paint === "transparent") {
|
|
440
|
+
return null;
|
|
441
|
+
}
|
|
442
|
+
return paint;
|
|
443
|
+
};
|
|
444
|
+
|
|
445
|
+
const normalizeLineCap = (value) => {
|
|
446
|
+
const cap = String(value || "butt").trim().toLowerCase();
|
|
447
|
+
return cap === "round" || cap === "square" ? cap : "butt";
|
|
448
|
+
};
|
|
449
|
+
|
|
450
|
+
const normalizeLineJoin = (value) => {
|
|
451
|
+
const join = String(value || "miter").trim().toLowerCase();
|
|
452
|
+
return join === "round" || join === "bevel" ? join : "miter";
|
|
453
|
+
};
|
|
454
|
+
|
|
455
|
+
const shapeKind = (shape) => {
|
|
456
|
+
const kind = String(shape?.kind || shape?.type || "").toLowerCase();
|
|
457
|
+
return ["circle", "line", "rect", "polygon", "polyline", "path", "star"].includes(kind) ? kind : null;
|
|
458
|
+
};
|
|
459
|
+
|
|
460
|
+
const logicalShapeUnits = (value) => ["logical", "center", "center_origin", "px"].includes(value);
|
|
461
|
+
|
|
462
|
+
const screenShapeUnits = (value) => ["screen", "canvas", "viewport"].includes(value);
|
|
463
|
+
|
|
464
|
+
const clampInt = (value, min, max) => {
|
|
465
|
+
const numeric = Number(value);
|
|
466
|
+
if (!Number.isFinite(numeric)) return min;
|
|
467
|
+
return Math.round(Math.min(Math.max(numeric, min), max));
|
|
468
|
+
};
|
|
469
|
+
|
|
470
|
+
const finiteNumber = (value, fallback) => {
|
|
471
|
+
const numeric = Number(value);
|
|
472
|
+
return Number.isFinite(numeric) ? numeric : fallback;
|
|
473
|
+
};
|
|
474
|
+
|
|
475
|
+
const clamp = (value, min, max) => Math.min(Math.max(value, min), max);
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
const SPECTROGRAM_VERTEX_SHADER = `#version 300 es
|
|
2
|
+
in vec2 a_position;
|
|
3
|
+
in vec2 a_uv;
|
|
4
|
+
out vec2 v_uv;
|
|
5
|
+
void main() {
|
|
6
|
+
v_uv = a_uv;
|
|
7
|
+
gl_Position = vec4(a_position, 0.0, 1.0);
|
|
8
|
+
}
|
|
9
|
+
`;
|
|
10
|
+
|
|
11
|
+
const SPECTROGRAM_FRAGMENT_SHADER = `#version 300 es
|
|
12
|
+
precision mediump float;
|
|
13
|
+
in vec2 v_uv;
|
|
14
|
+
uniform sampler2D u_texture;
|
|
15
|
+
uniform float u_opacity;
|
|
16
|
+
out vec4 outColor;
|
|
17
|
+
|
|
18
|
+
void main() {
|
|
19
|
+
vec4 texel = texture(u_texture, v_uv);
|
|
20
|
+
outColor = vec4(texel.rgb, texel.a * u_opacity);
|
|
21
|
+
}
|
|
22
|
+
`;
|
|
23
|
+
|
|
24
|
+
const QUAD_VERTICES = new Float32Array([
|
|
25
|
+
-1.0, -1.0, 0.0, 1.0,
|
|
26
|
+
1.0, -1.0, 1.0, 1.0,
|
|
27
|
+
-1.0, 1.0, 0.0, 0.0,
|
|
28
|
+
1.0, 1.0, 1.0, 0.0
|
|
29
|
+
]);
|
|
30
|
+
|
|
31
|
+
export class SpectrogramRenderer {
|
|
32
|
+
constructor(gl, shaderManager) {
|
|
33
|
+
this.gl = gl;
|
|
34
|
+
this.shaderManager = shaderManager;
|
|
35
|
+
this.program = this.shaderManager.getProgram(
|
|
36
|
+
"spectrogram-renderer",
|
|
37
|
+
SPECTROGRAM_VERTEX_SHADER,
|
|
38
|
+
SPECTROGRAM_FRAGMENT_SHADER
|
|
39
|
+
);
|
|
40
|
+
this.positionLocation = this.gl.getAttribLocation(this.program, "a_position");
|
|
41
|
+
this.uvLocation = this.gl.getAttribLocation(this.program, "a_uv");
|
|
42
|
+
this.textureLocation = this.gl.getUniformLocation(this.program, "u_texture");
|
|
43
|
+
this.opacityLocation = this.gl.getUniformLocation(this.program, "u_opacity");
|
|
44
|
+
this.histories = new Map();
|
|
45
|
+
|
|
46
|
+
this.buffer = this.gl.createBuffer();
|
|
47
|
+
this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.buffer);
|
|
48
|
+
this.gl.bufferData(this.gl.ARRAY_BUFFER, QUAD_VERTICES, this.gl.STATIC_DRAW);
|
|
49
|
+
|
|
50
|
+
this.canvas = document.createElement("canvas");
|
|
51
|
+
this.ctx = this.canvas.getContext("2d");
|
|
52
|
+
|
|
53
|
+
this.texture = this.gl.createTexture();
|
|
54
|
+
this.gl.bindTexture(this.gl.TEXTURE_2D, this.texture);
|
|
55
|
+
this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_MIN_FILTER, this.gl.LINEAR);
|
|
56
|
+
this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_MAG_FILTER, this.gl.LINEAR);
|
|
57
|
+
this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_WRAP_S, this.gl.CLAMP_TO_EDGE);
|
|
58
|
+
this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_WRAP_T, this.gl.CLAMP_TO_EDGE);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
render({ key, audio, params = {} }) {
|
|
62
|
+
const bins = normalizeSpectrogramBins(params.bins);
|
|
63
|
+
const historySize = normalizeSpectrogramHistory(params.history);
|
|
64
|
+
const scroll = normalizeSpectrogramScroll(params.scroll);
|
|
65
|
+
const gain = normalizeSpectrogramGain(params.gain);
|
|
66
|
+
const spectrum = normalizeSpectrogramSpectrum(audio?.fft, bins, gain);
|
|
67
|
+
const history = this.updateHistory({ key, bins, historySize, spectrum });
|
|
68
|
+
const image = buildSpectrogramPixels({ history, bins, historySize, scroll });
|
|
69
|
+
|
|
70
|
+
this.drawImage(image);
|
|
71
|
+
this.uploadTexture();
|
|
72
|
+
this.drawQuad({ opacity: 1 });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
updateHistory({ key, bins, historySize, spectrum }) {
|
|
76
|
+
const cacheKey = String(key || "default");
|
|
77
|
+
const current = this.histories.get(cacheKey);
|
|
78
|
+
const history = current?.bins === bins && current?.historySize === historySize ? current.frames : [];
|
|
79
|
+
history.push(spectrum);
|
|
80
|
+
while (history.length > historySize) {
|
|
81
|
+
history.shift();
|
|
82
|
+
}
|
|
83
|
+
this.histories.set(cacheKey, { bins, historySize, frames: history });
|
|
84
|
+
return history;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
drawImage({ width, height, pixels }) {
|
|
88
|
+
if (this.canvas.width !== width) {
|
|
89
|
+
this.canvas.width = width;
|
|
90
|
+
}
|
|
91
|
+
if (this.canvas.height !== height) {
|
|
92
|
+
this.canvas.height = height;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const imageData = this.ctx.createImageData(width, height);
|
|
96
|
+
imageData.data.set(pixels);
|
|
97
|
+
this.ctx.putImageData(imageData, 0, 0);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
uploadTexture() {
|
|
101
|
+
this.gl.bindTexture(this.gl.TEXTURE_2D, this.texture);
|
|
102
|
+
this.gl.texImage2D(
|
|
103
|
+
this.gl.TEXTURE_2D,
|
|
104
|
+
0,
|
|
105
|
+
this.gl.RGBA,
|
|
106
|
+
this.gl.RGBA,
|
|
107
|
+
this.gl.UNSIGNED_BYTE,
|
|
108
|
+
this.canvas
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
drawQuad({ opacity }) {
|
|
113
|
+
const gl = this.gl;
|
|
114
|
+
gl.useProgram(this.program);
|
|
115
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, this.buffer);
|
|
116
|
+
gl.enableVertexAttribArray(this.positionLocation);
|
|
117
|
+
gl.vertexAttribPointer(this.positionLocation, 2, gl.FLOAT, false, 16, 0);
|
|
118
|
+
gl.enableVertexAttribArray(this.uvLocation);
|
|
119
|
+
gl.vertexAttribPointer(this.uvLocation, 2, gl.FLOAT, false, 16, 8);
|
|
120
|
+
|
|
121
|
+
gl.activeTexture(gl.TEXTURE0);
|
|
122
|
+
gl.bindTexture(gl.TEXTURE_2D, this.texture);
|
|
123
|
+
gl.uniform1i(this.textureLocation, 0);
|
|
124
|
+
gl.uniform1f(this.opacityLocation, clamp(Number(opacity || 1), 0, 1));
|
|
125
|
+
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export const normalizeSpectrogramScroll = (value) => {
|
|
130
|
+
const scroll = String(value || "vertical").trim().toLowerCase();
|
|
131
|
+
return scroll === "horizontal" ? "horizontal" : "vertical";
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
export const normalizeSpectrogramBins = (value) => {
|
|
135
|
+
return clampInt(value || 64, 16, 256);
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
export const normalizeSpectrogramHistory = (value) => {
|
|
139
|
+
return clampInt(value || 96, 16, 512);
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
export const normalizeSpectrogramGain = (value) => {
|
|
143
|
+
const gain = Number(value);
|
|
144
|
+
if (!Number.isFinite(gain)) return 1;
|
|
145
|
+
return clamp(gain, 0.1, 8);
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
export const normalizeSpectrogramSpectrum = (value, bins, gain = 1) => {
|
|
149
|
+
const input = Array.isArray(value) || ArrayBuffer.isView(value) ? Array.from(value) : [];
|
|
150
|
+
const output = [];
|
|
151
|
+
const safeBins = normalizeSpectrogramBins(bins);
|
|
152
|
+
const safeGain = normalizeSpectrogramGain(gain);
|
|
153
|
+
|
|
154
|
+
for (let index = 0; index < safeBins; index += 1) {
|
|
155
|
+
const progress = safeBins === 1 ? 0 : index / (safeBins - 1);
|
|
156
|
+
output.push(clamp(sampleSpectrum(input, progress) * safeGain, 0, 1));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return output;
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
export const buildSpectrogramPixels = ({ history, bins, historySize, scroll }) => {
|
|
163
|
+
const safeBins = normalizeSpectrogramBins(bins);
|
|
164
|
+
const safeHistorySize = normalizeSpectrogramHistory(historySize);
|
|
165
|
+
const direction = normalizeSpectrogramScroll(scroll);
|
|
166
|
+
const width = direction === "vertical" ? safeBins : safeHistorySize;
|
|
167
|
+
const height = direction === "vertical" ? safeHistorySize : safeBins;
|
|
168
|
+
const pixels = new Uint8ClampedArray(width * height * 4);
|
|
169
|
+
const frames = Array.isArray(history) ? history.slice(-safeHistorySize) : [];
|
|
170
|
+
|
|
171
|
+
frames.forEach((frame, frameIndex) => {
|
|
172
|
+
const timeIndex = safeHistorySize - frames.length + frameIndex;
|
|
173
|
+
for (let bin = 0; bin < safeBins; bin += 1) {
|
|
174
|
+
const value = clamp(Number(frame?.[bin] || 0), 0, 1);
|
|
175
|
+
const x = direction === "vertical" ? bin : timeIndex;
|
|
176
|
+
const y = direction === "vertical" ? timeIndex : safeBins - 1 - bin;
|
|
177
|
+
writePixel(pixels, width, x, y, spectrogramColor(value));
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
return { width, height, pixels };
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
export const spectrogramColor = (value) => {
|
|
185
|
+
const energy = clamp(Number(value || 0), 0, 1);
|
|
186
|
+
const mid = 1 - Math.abs(energy * 2 - 1);
|
|
187
|
+
return [
|
|
188
|
+
Math.round(10 + energy * 245),
|
|
189
|
+
Math.round(18 + Math.max(0, energy - 0.18) * 250),
|
|
190
|
+
Math.round(36 + mid * 155 + energy * 36),
|
|
191
|
+
Math.round(energy * 255)
|
|
192
|
+
];
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
const writePixel = (pixels, width, x, y, color) => {
|
|
196
|
+
const offset = ((y * width) + x) * 4;
|
|
197
|
+
pixels[offset] = color[0];
|
|
198
|
+
pixels[offset + 1] = color[1];
|
|
199
|
+
pixels[offset + 2] = color[2];
|
|
200
|
+
pixels[offset + 3] = color[3];
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
const sampleSpectrum = (spectrum, progress) => {
|
|
204
|
+
if (!spectrum.length) return 0;
|
|
205
|
+
|
|
206
|
+
const position = progress * (spectrum.length - 1);
|
|
207
|
+
const left = Math.floor(position);
|
|
208
|
+
const right = Math.min(left + 1, spectrum.length - 1);
|
|
209
|
+
const mix = position - left;
|
|
210
|
+
const from = finiteNumber(spectrum[left], 0);
|
|
211
|
+
const to = finiteNumber(spectrum[right], 0);
|
|
212
|
+
return clamp(from + (to - from) * mix, 0, 1);
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
const finiteNumber = (value, fallback) => {
|
|
216
|
+
const numeric = Number(value);
|
|
217
|
+
return Number.isFinite(numeric) ? numeric : fallback;
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
const clampInt = (value, min, max) => {
|
|
221
|
+
const numeric = Number(value);
|
|
222
|
+
if (!Number.isFinite(numeric)) return min;
|
|
223
|
+
return Math.round(Math.min(Math.max(numeric, min), max));
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
const clamp = (value, min, max) => Math.min(Math.max(value, min), max);
|