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.
Files changed (137) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +70 -117
  3. data/docs/.nojekyll +0 -0
  4. data/docs/assets/playground-worker.js +373 -0
  5. data/docs/assets/playground.css +440 -0
  6. data/docs/assets/playground.js +652 -0
  7. data/docs/assets/site.css +744 -0
  8. data/docs/assets/vizcore-demo.gif +0 -0
  9. data/docs/assets/vizcore-poster.png +0 -0
  10. data/docs/assets/vj-tunnel.js +159 -0
  11. data/docs/index.html +225 -0
  12. data/docs/playground.html +81 -0
  13. data/docs/shape_dsl.md +269 -0
  14. data/examples/README.md +59 -0
  15. data/examples/assets/README.md +19 -0
  16. data/examples/audio_inspector.rb +34 -0
  17. data/examples/club_intro_drop.rb +78 -0
  18. data/examples/kansai_rubykaigi_visual.rb +70 -0
  19. data/examples/live_coding_minimal.rb +22 -0
  20. data/examples/midi_controller_show.rb +78 -0
  21. data/examples/midi_scene_switch.rb +3 -1
  22. data/examples/parser_visualizer.rb +48 -0
  23. data/examples/readme_demo.rb +17 -0
  24. data/examples/rhythm_geometry.rb +34 -0
  25. data/examples/ruby_crystal_show.rb +35 -0
  26. data/examples/shader_playground.rb +18 -0
  27. data/examples/unyo_liquid.rb +59 -0
  28. data/examples/vj_ambient_chill_room.rb +124 -0
  29. data/examples/vj_dnb_jungle.rb +170 -0
  30. data/examples/vj_festival_mainstage.rb +245 -0
  31. data/examples/vj_festival_mainstage.yml +17 -0
  32. data/examples/vj_glitch_industrial.rb +164 -0
  33. data/examples/vj_hiphop_cipher.rb +167 -0
  34. data/examples/vj_jpop_idol_live.rb +210 -0
  35. data/examples/vj_synthwave_retro.rb +173 -0
  36. data/examples/vj_techno_warehouse.rb +195 -0
  37. data/frontend/index.html +494 -2
  38. data/frontend/src/audio-inspector.js +40 -0
  39. data/frontend/src/custom-shape-param-controls.js +106 -0
  40. data/frontend/src/live-controls.js +131 -0
  41. data/frontend/src/main.js +1060 -16
  42. data/frontend/src/mapping-target-selector.js +109 -0
  43. data/frontend/src/midi-learn.js +194 -0
  44. data/frontend/src/performance-monitor.js +183 -0
  45. data/frontend/src/plugin-runtime.js +130 -0
  46. data/frontend/src/projector-mode.js +56 -0
  47. data/frontend/src/renderer/engine.js +157 -3
  48. data/frontend/src/renderer/layer-manager.js +442 -30
  49. data/frontend/src/renderer/shader-manager.js +26 -0
  50. data/frontend/src/runtime-control-preset.js +11 -0
  51. data/frontend/src/shader-error-overlay.js +29 -0
  52. data/frontend/src/shader-param-controls.js +93 -0
  53. data/frontend/src/shaders/builtins.js +380 -2
  54. data/frontend/src/shaders/post-effects.js +52 -0
  55. data/frontend/src/shape-editor-controls.js +157 -0
  56. data/frontend/src/visual-regression.js +67 -0
  57. data/frontend/src/visual-settings-preset.js +103 -0
  58. data/frontend/src/visuals/geometry.js +666 -0
  59. data/frontend/src/visuals/image-renderer.js +291 -0
  60. data/frontend/src/visuals/particle-system.js +56 -10
  61. data/frontend/src/visuals/shape-renderer.js +475 -0
  62. data/frontend/src/visuals/spectrogram-renderer.js +226 -0
  63. data/frontend/src/visuals/svg-arc.js +104 -0
  64. data/frontend/src/visuals/text-renderer.js +112 -11
  65. data/frontend/src/websocket-client.js +12 -1
  66. data/lib/vizcore/analysis/adaptive_normalizer.rb +70 -0
  67. data/lib/vizcore/analysis/beat_detector.rb +4 -2
  68. data/lib/vizcore/analysis/bpm_estimator.rb +8 -0
  69. data/lib/vizcore/analysis/feature_recorder.rb +159 -0
  70. data/lib/vizcore/analysis/feature_replay.rb +84 -0
  71. data/lib/vizcore/analysis/pipeline.rb +235 -11
  72. data/lib/vizcore/analysis/tap_tempo.rb +74 -0
  73. data/lib/vizcore/analysis.rb +4 -0
  74. data/lib/vizcore/audio/dummy_sine_input.rb +1 -1
  75. data/lib/vizcore/audio/fixture_input.rb +65 -0
  76. data/lib/vizcore/audio/input_manager.rb +4 -2
  77. data/lib/vizcore/audio/mic_input.rb +24 -8
  78. data/lib/vizcore/audio/portaudio_ffi.rb +106 -1
  79. data/lib/vizcore/audio.rb +1 -0
  80. data/lib/vizcore/cli/doctor.rb +159 -0
  81. data/lib/vizcore/cli/dsl_reference.rb +99 -0
  82. data/lib/vizcore/cli/layer_docs.rb +46 -0
  83. data/lib/vizcore/cli/scene_diagnostics.rb +23 -0
  84. data/lib/vizcore/cli/scene_inspector.rb +136 -0
  85. data/lib/vizcore/cli/scene_validator.rb +337 -0
  86. data/lib/vizcore/cli/shader_template.rb +68 -0
  87. data/lib/vizcore/cli/shader_uniform_docs.rb +54 -0
  88. data/lib/vizcore/cli.rb +689 -18
  89. data/lib/vizcore/config.rb +103 -2
  90. data/lib/vizcore/control_preset.rb +68 -0
  91. data/lib/vizcore/dsl/engine.rb +277 -5
  92. data/lib/vizcore/dsl/layer_builder.rb +1280 -23
  93. data/lib/vizcore/dsl/layer_group_builder.rb +112 -0
  94. data/lib/vizcore/dsl/mapping_resolver.rb +290 -7
  95. data/lib/vizcore/dsl/mapping_transform_builder.rb +71 -0
  96. data/lib/vizcore/dsl/reaction_builder.rb +44 -0
  97. data/lib/vizcore/dsl/scene_builder.rb +61 -5
  98. data/lib/vizcore/dsl/shader_source_resolver.rb +67 -6
  99. data/lib/vizcore/dsl/style_builder.rb +68 -0
  100. data/lib/vizcore/dsl/timeline_builder.rb +138 -0
  101. data/lib/vizcore/dsl/transition_controller.rb +77 -0
  102. data/lib/vizcore/dsl.rb +5 -1
  103. data/lib/vizcore/layer_catalog.rb +275 -0
  104. data/lib/vizcore/project_manifest.rb +152 -0
  105. data/lib/vizcore/renderer/png_writer.rb +57 -0
  106. data/lib/vizcore/renderer/render_sequence.rb +153 -0
  107. data/lib/vizcore/renderer/scene_frame_source.rb +132 -0
  108. data/lib/vizcore/renderer/scene_serializer.rb +36 -3
  109. data/lib/vizcore/renderer/snapshot.rb +38 -0
  110. data/lib/vizcore/renderer/snapshot_renderer.rb +938 -0
  111. data/lib/vizcore/renderer.rb +5 -0
  112. data/lib/vizcore/server/frame_broadcaster.rb +143 -8
  113. data/lib/vizcore/server/gallery_app.rb +155 -0
  114. data/lib/vizcore/server/gallery_page.rb +100 -0
  115. data/lib/vizcore/server/gallery_runner.rb +48 -0
  116. data/lib/vizcore/server/rack_app.rb +203 -4
  117. data/lib/vizcore/server/runner.rb +391 -22
  118. data/lib/vizcore/server/scene_dependency_watcher.rb +79 -0
  119. data/lib/vizcore/server/websocket_handler.rb +60 -10
  120. data/lib/vizcore/server.rb +4 -0
  121. data/lib/vizcore/shape.rb +719 -0
  122. data/lib/vizcore/sync/osc_message.rb +103 -0
  123. data/lib/vizcore/sync/osc_receiver.rb +68 -0
  124. data/lib/vizcore/sync.rb +4 -0
  125. data/lib/vizcore/templates/midi_control_scene.rb +3 -1
  126. data/lib/vizcore/templates/plugin_layer.rb +20 -0
  127. data/lib/vizcore/templates/plugin_readme.md +23 -0
  128. data/lib/vizcore/templates/plugin_renderer.js +43 -0
  129. data/lib/vizcore/templates/plugin_scene.rb +14 -0
  130. data/lib/vizcore/templates/project_readme.md +7 -23
  131. data/lib/vizcore/templates/rubykaigi_scene.rb +30 -0
  132. data/lib/vizcore/version.rb +1 -1
  133. data/lib/vizcore.rb +28 -0
  134. data/scripts/browser_capture.mjs +75 -0
  135. data/sig/vizcore.rbs +461 -0
  136. metadata +94 -3
  137. 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 text = String(content || "").trim();
60
- if (!text) {
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 maxFontSize = Math.max(48, Math.floor(this.canvas.height * 0.22));
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
- text,
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({ text, fontSize, time, color, amplitude, glowStrength }) {
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 = "center";
108
+ ctx.textAlign = textAlign;
96
109
  ctx.textBaseline = "middle";
97
- ctx.font = `700 ${fontSize}px "IBM Plex Sans", "Noto Sans JP", sans-serif`;
98
- ctx.shadowColor = "rgba(110, 208, 255, 0.35)";
99
- ctx.shadowBlur = glow;
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
- ctx.fillText(text, this.canvas.width / 2 + xShift, this.canvas.height / 2);
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
- def initialize(history_size: 43, sensitivity: 1.35, refractory_frames: 4, min_history: 8)
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.positive?
34
+ beat = enough_history && refractory_ok && instant_energy > threshold && instant_energy >= @min_energy
33
35
 
34
36
  if beat
35
37
  @beat_count += 1
@@ -44,6 +44,14 @@ module Vizcore
44
44
  @current_bpm
45
45
  end
46
46
 
47
+ # Clear accumulated onset history and the current tempo estimate.
48
+ #
49
+ # @return [void]
50
+ def reset
51
+ @history.clear
52
+ @current_bpm = 0.0
53
+ end
54
+
47
55
  private
48
56
 
49
57
  def onset_count
@@ -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