vizcore 1.1.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.
- checksums.yaml +4 -4
- data/frontend/index.html +24 -2
- data/frontend/src/audio-inspector.js +9 -0
- data/frontend/src/live-controls.js +219 -7
- data/frontend/src/main.js +447 -57
- data/frontend/src/midi-learn.js +22 -2
- data/frontend/src/performance-monitor.js +137 -1
- data/frontend/src/renderer/engine.js +391 -10
- data/frontend/src/renderer/layer-manager.js +472 -71
- data/frontend/src/runtime-control-preset.js +44 -0
- data/frontend/src/scene-patches.js +159 -0
- data/frontend/src/shader-error-overlay.js +1 -0
- data/frontend/src/visuals/image-renderer.js +19 -0
- data/frontend/src/visuals/particle-system.js +10 -0
- data/frontend/src/visuals/shape-renderer.js +13 -0
- data/frontend/src/visuals/spectrogram-renderer.js +14 -0
- data/frontend/src/visuals/text-renderer.js +13 -0
- data/frontend/src/websocket-client.js +6 -0
- data/lib/vizcore/analysis/adaptive_normalizer.rb +20 -2
- data/lib/vizcore/analysis/bpm_estimator.rb +18 -8
- data/lib/vizcore/analysis/feature_recorder.rb +117 -7
- data/lib/vizcore/analysis/feature_replay.rb +48 -9
- data/lib/vizcore/analysis/pipeline.rb +258 -9
- data/lib/vizcore/analysis/tap_tempo.rb +17 -2
- data/lib/vizcore/audio/calibration.rb +156 -0
- data/lib/vizcore/audio/file_input.rb +28 -0
- data/lib/vizcore/audio/input_manager.rb +36 -1
- data/lib/vizcore/audio/midi_input.rb +5 -0
- data/lib/vizcore/audio/ring_buffer.rb +22 -0
- data/lib/vizcore/audio.rb +1 -0
- data/lib/vizcore/cli/dsl_reference.rb +64 -8
- data/lib/vizcore/cli/plugin_checker.rb +93 -0
- data/lib/vizcore/cli/scene_diagnostics.rb +2 -2
- data/lib/vizcore/cli/scene_inspector.rb +35 -1
- data/lib/vizcore/cli/scene_validator.rb +487 -39
- data/lib/vizcore/cli/shader_template.rb +7 -2
- data/lib/vizcore/cli/shader_uniform_docs.rb +11 -0
- data/lib/vizcore/cli.rb +268 -15
- data/lib/vizcore/config.rb +40 -3
- data/lib/vizcore/control_preset.rb +29 -0
- data/lib/vizcore/deep_copy.rb +21 -0
- data/lib/vizcore/dsl/color_helpers.rb +155 -0
- data/lib/vizcore/dsl/engine.rb +219 -23
- data/lib/vizcore/dsl/layer_builder.rb +278 -15
- data/lib/vizcore/dsl/layer_group_builder.rb +10 -12
- data/lib/vizcore/dsl/layout_helpers.rb +290 -0
- data/lib/vizcore/dsl/mapping_preset_builder.rb +41 -0
- data/lib/vizcore/dsl/mapping_resolver.rb +404 -22
- data/lib/vizcore/dsl/mapping_transform_builder.rb +50 -0
- data/lib/vizcore/dsl/midi_map_executor.rb +219 -23
- data/lib/vizcore/dsl/reaction_builder.rb +1 -0
- data/lib/vizcore/dsl/scene_builder.rb +83 -13
- data/lib/vizcore/dsl/shader_source_resolver.rb +1 -10
- data/lib/vizcore/dsl/style_builder.rb +3 -0
- data/lib/vizcore/dsl/timeline_builder.rb +91 -8
- data/lib/vizcore/dsl/transition_controller.rb +157 -18
- data/lib/vizcore/dsl.rb +2 -0
- data/lib/vizcore/layer_catalog.rb +1 -0
- data/lib/vizcore/plugin_asset_policy.rb +55 -0
- data/lib/vizcore/project_manifest.rb +12 -2
- data/lib/vizcore/renderer/render_sequence.rb +104 -13
- data/lib/vizcore/renderer/scene_frame_source.rb +179 -14
- data/lib/vizcore/renderer/scene_serializer.rb +38 -0
- data/lib/vizcore/renderer/snapshot.rb +4 -3
- data/lib/vizcore/renderer/snapshot_renderer.rb +134 -8
- data/lib/vizcore/scene_trust.rb +31 -0
- data/lib/vizcore/server/frame_broadcaster.rb +469 -23
- data/lib/vizcore/server/rack_app.rb +151 -4
- data/lib/vizcore/server/runner.rb +676 -82
- data/lib/vizcore/server/websocket_handler.rb +236 -14
- data/lib/vizcore/server.rb +21 -0
- data/lib/vizcore/shape.rb +39 -16
- data/lib/vizcore/sync/osc_message.rb +66 -9
- data/lib/vizcore/version.rb +1 -1
- data/lib/vizcore.rb +33 -0
- data/scripts/browser_capture.mjs +31 -2
- data/sig/vizcore.rbs +55 -4
- metadata +18 -3
|
@@ -3,9 +3,53 @@ export const normalizeRuntimeControlPreset = (value) => {
|
|
|
3
3
|
return {
|
|
4
4
|
visualSettings: objectValue(input.visual_settings) || objectValue(input.visualSettings) || null,
|
|
5
5
|
midiLearnBindings: objectValue(input.midi_learn_bindings) || objectValue(input.midiLearnBindings) || null,
|
|
6
|
+
sceneOverrides: normalizeSceneOverrides(
|
|
7
|
+
input.scene_overrides || input.sceneOverrides || null
|
|
8
|
+
),
|
|
6
9
|
};
|
|
7
10
|
};
|
|
8
11
|
|
|
9
12
|
const objectValue = (value) => {
|
|
10
13
|
return value && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
11
14
|
};
|
|
15
|
+
|
|
16
|
+
const normalizeSceneOverrides = (value) => {
|
|
17
|
+
if (!value || typeof value !== "object") {
|
|
18
|
+
return {};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const output = {};
|
|
22
|
+
for (const [rawSceneName, rawOverride] of Object.entries(value)) {
|
|
23
|
+
const sceneName = String(rawSceneName || "").trim();
|
|
24
|
+
if (!sceneName) {
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const override = normalizeSceneOverride(rawOverride);
|
|
29
|
+
if (!override) {
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
output[sceneName] = override;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return output;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const normalizeSceneOverride = (value) => {
|
|
40
|
+
const input = value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
41
|
+
const visualSettings = objectValue(input.visual_settings) || objectValue(input.visualSettings);
|
|
42
|
+
const midiLearnBindings = objectValue(input.midi_learn_bindings) || objectValue(input.midiLearnBindings);
|
|
43
|
+
if (!visualSettings && !midiLearnBindings) {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const output = {};
|
|
48
|
+
if (visualSettings) {
|
|
49
|
+
output.visualSettings = visualSettings;
|
|
50
|
+
}
|
|
51
|
+
if (midiLearnBindings) {
|
|
52
|
+
output.midiLearnBindings = midiLearnBindings;
|
|
53
|
+
}
|
|
54
|
+
return output;
|
|
55
|
+
};
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
const REMOVED_LAYER = Symbol("vizcore.scene.removed_layer");
|
|
2
|
+
|
|
3
|
+
const isObject = (value) => value !== null && typeof value === "object";
|
|
4
|
+
|
|
5
|
+
const clonePayload = (value) => {
|
|
6
|
+
if (!isObject(value) && !Array.isArray(value)) {
|
|
7
|
+
return value;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
if (typeof structuredClone === "function") {
|
|
11
|
+
try {
|
|
12
|
+
return structuredClone(value);
|
|
13
|
+
} catch {
|
|
14
|
+
// Fall back to JSON clone below.
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
try {
|
|
19
|
+
return JSON.parse(JSON.stringify(value));
|
|
20
|
+
} catch {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const sceneVersion = (value) => {
|
|
26
|
+
const numeric = Number(value);
|
|
27
|
+
return Number.isFinite(numeric) ? numeric : null;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const normalizeIndex = (value) => {
|
|
31
|
+
const numeric = Number(value);
|
|
32
|
+
return Number.isInteger(numeric) && numeric >= 0 ? numeric : null;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export const applyScenePayload = (payload) => {
|
|
36
|
+
if (!isObject(payload)) {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const scene = clonePayload(payload);
|
|
41
|
+
if (!isObject(scene)) {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (!Array.isArray(scene.layers)) {
|
|
46
|
+
scene.layers = [];
|
|
47
|
+
} else {
|
|
48
|
+
scene.layers = scene.layers.map((entry) => clonePayload(entry)).filter(Boolean);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return scene;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export const applyScenePatch = (currentScene, patch) => {
|
|
55
|
+
if (!isObject(currentScene) || !isObject(patch) || !Array.isArray(Array.isArray(patch.layers) ? patch.layers : null)) {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const currentName = String(currentScene.name || "");
|
|
60
|
+
if (String(patch.name || "") !== currentName) {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const currentVersion = sceneVersion(currentScene.version);
|
|
65
|
+
const patchVersion = sceneVersion(patch.version);
|
|
66
|
+
if (currentVersion !== null && patchVersion !== null && patchVersion !== currentVersion) {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const next = applyScenePayload(currentScene);
|
|
71
|
+
if (!isObject(next)) {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const layers = next.layers;
|
|
76
|
+
for (const entry of patch.layers) {
|
|
77
|
+
if (!isObject(entry)) {
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const index = normalizeIndex(entry.index);
|
|
82
|
+
if (index === null) {
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (entry.remove) {
|
|
87
|
+
if (index < layers.length) {
|
|
88
|
+
layers[index] = REMOVED_LAYER;
|
|
89
|
+
}
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (Object.prototype.hasOwnProperty.call(entry, "layer")) {
|
|
94
|
+
if (!isObject(entry.layer)) {
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
while (layers.length <= index) {
|
|
98
|
+
layers.push(REMOVED_LAYER);
|
|
99
|
+
}
|
|
100
|
+
layers[index] = applyScenePayload(entry.layer);
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (!Object.prototype.hasOwnProperty.call(entry, "params")) {
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (!isObject(entry.params)) {
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const layer = layers[index];
|
|
112
|
+
if (!isObject(layer) || layer === REMOVED_LAYER) {
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const params = isObject(layer.params) ? clonePayload(layer.params) : {};
|
|
117
|
+
layers[index] = {
|
|
118
|
+
...layer,
|
|
119
|
+
params: {
|
|
120
|
+
...params,
|
|
121
|
+
...clonePayload(entry.params)
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
next.version = patchVersion !== null ? patchVersion : next.version;
|
|
127
|
+
if (Object.prototype.hasOwnProperty.call(patch, "schema_version")) {
|
|
128
|
+
next.schema_version = patch.schema_version;
|
|
129
|
+
}
|
|
130
|
+
next.layers = layers.filter((layer) => layer !== REMOVED_LAYER);
|
|
131
|
+
|
|
132
|
+
return next;
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
export const resolveScenePayload = ({ incomingScene = null, currentScene = null, frameVersion = null } = {}) => {
|
|
136
|
+
if (!isObject(incomingScene) && !isObject(currentScene)) {
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (incomingScene.patch) {
|
|
141
|
+
if (!isObject(currentScene)) {
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const expectedVersion = sceneVersion(frameVersion);
|
|
146
|
+
const currentVersion = sceneVersion(currentScene.version);
|
|
147
|
+
if (expectedVersion !== null && currentVersion !== null && expectedVersion !== currentVersion) {
|
|
148
|
+
return applyScenePayload(currentScene);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return applyScenePatch(currentScene, incomingScene) || applyScenePayload(currentScene);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (!isObject(incomingScene)) {
|
|
155
|
+
return applyScenePayload(currentScene);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return applyScenePayload(incomingScene);
|
|
159
|
+
};
|
|
@@ -198,6 +198,25 @@ export class ImageRenderer {
|
|
|
198
198
|
gl.uniform1f(this.invertLocation, normalizeInvert(invert));
|
|
199
199
|
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
|
|
200
200
|
}
|
|
201
|
+
|
|
202
|
+
dispose() {
|
|
203
|
+
for (const media of this.media.values()) {
|
|
204
|
+
if (isVideoElement(media)) {
|
|
205
|
+
media.pause?.();
|
|
206
|
+
media.removeAttribute?.("src");
|
|
207
|
+
media.load?.();
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
this.media.clear();
|
|
211
|
+
if (this.texture) {
|
|
212
|
+
this.gl.deleteTexture(this.texture);
|
|
213
|
+
this.texture = null;
|
|
214
|
+
}
|
|
215
|
+
if (this.buffer) {
|
|
216
|
+
this.gl.deleteBuffer(this.buffer);
|
|
217
|
+
this.buffer = null;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
201
220
|
}
|
|
202
221
|
|
|
203
222
|
export const resolveMediaSource = (value) => {
|
|
@@ -170,6 +170,16 @@ export class ParticleSystem {
|
|
|
170
170
|
);
|
|
171
171
|
gl.drawArrays(gl.POINTS, 0, this.count);
|
|
172
172
|
}
|
|
173
|
+
|
|
174
|
+
dispose() {
|
|
175
|
+
if (this.buffer) {
|
|
176
|
+
this.gl.deleteBuffer(this.buffer);
|
|
177
|
+
this.buffer = null;
|
|
178
|
+
}
|
|
179
|
+
this.positions = new Float32Array(0);
|
|
180
|
+
this.velocities = new Float32Array(0);
|
|
181
|
+
this.count = 0;
|
|
182
|
+
}
|
|
173
183
|
}
|
|
174
184
|
|
|
175
185
|
const clampInt = (value, min, max) => {
|
|
@@ -123,6 +123,19 @@ export class ShapeRenderer {
|
|
|
123
123
|
gl.uniform1f(this.intensityLocation, clamp(Number(intensity || 1), 0, 1));
|
|
124
124
|
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
|
|
125
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
|
+
}
|
|
126
139
|
}
|
|
127
140
|
|
|
128
141
|
export const shapeCoordinateContext = (params = {}) => {
|
|
@@ -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) => {
|
|
@@ -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
|
-
|
|
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:
|
|
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
|
|
@@ -4,7 +4,7 @@ module Vizcore
|
|
|
4
4
|
module Analysis
|
|
5
5
|
# Estimates tempo (BPM) from beat onsets using lag autocorrelation.
|
|
6
6
|
class BPMEstimator
|
|
7
|
-
attr_reader :frame_rate
|
|
7
|
+
attr_reader :confidence, :frame_rate
|
|
8
8
|
|
|
9
9
|
# @param frame_rate [Float] analysis frames per second
|
|
10
10
|
# @param min_bpm [Float] minimum candidate BPM
|
|
@@ -21,6 +21,7 @@ module Vizcore
|
|
|
21
21
|
@min_onsets = Integer(min_onsets)
|
|
22
22
|
@history = []
|
|
23
23
|
@current_bpm = 0.0
|
|
24
|
+
@confidence = 0.0
|
|
24
25
|
end
|
|
25
26
|
|
|
26
27
|
# @param beat [Boolean] whether the current frame contains a beat onset
|
|
@@ -29,10 +30,17 @@ module Vizcore
|
|
|
29
30
|
@history << (beat ? 1.0 : 0.0)
|
|
30
31
|
@history.shift while @history.length > @history_size
|
|
31
32
|
|
|
32
|
-
|
|
33
|
+
if onset_count < @min_onsets
|
|
34
|
+
@confidence = 0.0
|
|
35
|
+
return @current_bpm
|
|
36
|
+
end
|
|
33
37
|
|
|
34
|
-
candidate = estimate_candidate_bpm
|
|
35
|
-
|
|
38
|
+
candidate, confidence = estimate_candidate_bpm
|
|
39
|
+
if candidate <= 0.0
|
|
40
|
+
@confidence = 0.0
|
|
41
|
+
return @current_bpm
|
|
42
|
+
end
|
|
43
|
+
@confidence = confidence
|
|
36
44
|
|
|
37
45
|
@current_bpm =
|
|
38
46
|
if @current_bpm <= 0.0
|
|
@@ -50,6 +58,7 @@ module Vizcore
|
|
|
50
58
|
def reset
|
|
51
59
|
@history.clear
|
|
52
60
|
@current_bpm = 0.0
|
|
61
|
+
@confidence = 0.0
|
|
53
62
|
end
|
|
54
63
|
|
|
55
64
|
private
|
|
@@ -60,11 +69,11 @@ module Vizcore
|
|
|
60
69
|
|
|
61
70
|
def estimate_candidate_bpm
|
|
62
71
|
n = @history.length
|
|
63
|
-
return 0.0 if n < 2
|
|
72
|
+
return [0.0, 0.0] if n < 2
|
|
64
73
|
|
|
65
74
|
min_lag = [(60.0 * @frame_rate / @max_bpm).round, 1].max
|
|
66
75
|
max_lag = [(60.0 * @frame_rate / @min_bpm).round, n - 1].min
|
|
67
|
-
return 0.0 if min_lag > max_lag
|
|
76
|
+
return [0.0, 0.0] if min_lag > max_lag
|
|
68
77
|
|
|
69
78
|
best_lag = nil
|
|
70
79
|
best_score = -Float::INFINITY
|
|
@@ -77,9 +86,10 @@ module Vizcore
|
|
|
77
86
|
best_lag = lag
|
|
78
87
|
end
|
|
79
88
|
|
|
80
|
-
return 0.0 unless best_lag && best_score.positive?
|
|
89
|
+
return [0.0, 0.0] unless best_lag && best_score.positive?
|
|
81
90
|
|
|
82
|
-
(60.0 * @frame_rate / best_lag).clamp(@min_bpm, @max_bpm)
|
|
91
|
+
bpm = (60.0 * @frame_rate / best_lag).clamp(@min_bpm, @max_bpm)
|
|
92
|
+
[bpm, (best_score / onset_count.to_f).clamp(0.0, 1.0)]
|
|
83
93
|
end
|
|
84
94
|
|
|
85
95
|
def autocorrelation_at_lag(lag)
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "fileutils"
|
|
4
|
+
require "digest"
|
|
4
5
|
require "json"
|
|
5
6
|
require "pathname"
|
|
6
7
|
require_relative "../audio/file_input"
|
|
@@ -22,7 +23,8 @@ module Vizcore
|
|
|
22
23
|
noise_gate: Pipeline::DEFAULT_NOISE_GATE,
|
|
23
24
|
audio_normalize: nil,
|
|
24
25
|
bpm: nil,
|
|
25
|
-
bpm_lock: false
|
|
26
|
+
bpm_lock: false,
|
|
27
|
+
cache_root: nil
|
|
26
28
|
)
|
|
27
29
|
@audio_file = Pathname.new(audio_file.to_s).expand_path
|
|
28
30
|
@frames = normalize_frame_count(frames)
|
|
@@ -31,6 +33,16 @@ module Vizcore
|
|
|
31
33
|
@audio_normalize = audio_normalize
|
|
32
34
|
@bpm = bpm
|
|
33
35
|
@bpm_lock = bpm_lock
|
|
36
|
+
@cache_root = normalize_cache_root(cache_root)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Absolute cache file path for current recorder settings.
|
|
40
|
+
#
|
|
41
|
+
# @return [Pathname, nil]
|
|
42
|
+
def cache_path
|
|
43
|
+
return nil unless @cache_root
|
|
44
|
+
|
|
45
|
+
@cache_root.join("#{cache_key}.json")
|
|
34
46
|
end
|
|
35
47
|
|
|
36
48
|
# @param out [String, Pathname] JSON output path
|
|
@@ -38,18 +50,116 @@ module Vizcore
|
|
|
38
50
|
def write(out:)
|
|
39
51
|
output_path = Pathname.new(out.to_s).expand_path
|
|
40
52
|
FileUtils.mkdir_p(output_path.dirname)
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
53
|
+
|
|
54
|
+
if cache_path
|
|
55
|
+
cached_payload = load_cached_payload(cache_path)
|
|
56
|
+
output_payload = cached_payload if cached_payload
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
output_payload ||= record
|
|
60
|
+
output_path.write("#{JSON.pretty_generate(output_payload)}\n")
|
|
61
|
+
write_cached_output(output_payload) if cache_path && cache_path != output_path
|
|
62
|
+
|
|
63
|
+
metadata_from_payload(output_payload, path: output_path)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Stable hash key for this recorder configuration.
|
|
67
|
+
#
|
|
68
|
+
# @return [String]
|
|
69
|
+
def cache_key
|
|
70
|
+
self.class.cache_key(
|
|
71
|
+
version: VERSION,
|
|
72
|
+
audio_file: @audio_file,
|
|
45
73
|
frames: @frames,
|
|
46
74
|
fps: @fps,
|
|
47
|
-
|
|
48
|
-
|
|
75
|
+
noise_gate: @noise_gate,
|
|
76
|
+
audio_normalize: @audio_normalize,
|
|
77
|
+
bpm: @bpm,
|
|
78
|
+
bpm_lock: @bpm_lock
|
|
79
|
+
)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# @return [Hash] recorder metadata
|
|
83
|
+
def self.cache_key(version:, audio_file:, frames:, fps:, noise_gate:, audio_normalize:, bpm:, bpm_lock:)
|
|
84
|
+
audio_path = Pathname.new(audio_file.to_s).expand_path
|
|
85
|
+
Digest::SHA256.hexdigest(
|
|
86
|
+
JSON.generate(
|
|
87
|
+
{
|
|
88
|
+
version: version,
|
|
89
|
+
audio_file: audio_path.to_s,
|
|
90
|
+
audio_file_size: audio_file_size(audio_path),
|
|
91
|
+
audio_file_mtime: audio_file_mtime(audio_path),
|
|
92
|
+
frames: frames,
|
|
93
|
+
fps: fps,
|
|
94
|
+
noise_gate: noise_gate,
|
|
95
|
+
audio_normalize: audio_normalize,
|
|
96
|
+
bpm: bpm,
|
|
97
|
+
bpm_lock: bpm_lock
|
|
98
|
+
}
|
|
99
|
+
)
|
|
100
|
+
)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def self.audio_file_size(path)
|
|
104
|
+
file_stat_value(path, :size)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def self.audio_file_mtime(path)
|
|
108
|
+
file_stat_value(path, :mtime).to_i
|
|
109
|
+
rescue StandardError
|
|
110
|
+
0
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def self.file_stat_value(path, name)
|
|
114
|
+
stat = Pathname.new(path).stat
|
|
115
|
+
stat.send(name)
|
|
116
|
+
rescue StandardError
|
|
117
|
+
0
|
|
49
118
|
end
|
|
50
119
|
|
|
51
120
|
private
|
|
52
121
|
|
|
122
|
+
def load_cached_payload(path)
|
|
123
|
+
payload = JSON.parse(Pathname.new(path).read)
|
|
124
|
+
return unless payload.is_a?(Hash)
|
|
125
|
+
|
|
126
|
+
payload if valid_cached_payload?(payload)
|
|
127
|
+
rescue StandardError
|
|
128
|
+
nil
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def valid_cached_payload?(payload)
|
|
132
|
+
payload.fetch("version", nil) == VERSION &&
|
|
133
|
+
payload.fetch("metadata", {}).fetch("frames", nil) == @frames &&
|
|
134
|
+
(Float(payload.fetch("metadata", {}).fetch("fps", nil)) - @fps).abs < Float::EPSILON &&
|
|
135
|
+
payload.fetch("features", nil).is_a?(Array) &&
|
|
136
|
+
!payload.fetch("features", []).empty?
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def write_cached_output(payload)
|
|
140
|
+
cache = cache_path
|
|
141
|
+
return unless cache
|
|
142
|
+
|
|
143
|
+
FileUtils.mkdir_p(cache.dirname)
|
|
144
|
+
cache.write("#{JSON.pretty_generate(payload)}\n")
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def metadata_from_payload(payload, path:)
|
|
148
|
+
metadata = payload.fetch("metadata", {})
|
|
149
|
+
{
|
|
150
|
+
path: path,
|
|
151
|
+
frames: metadata.fetch("frames"),
|
|
152
|
+
fps: metadata.fetch("fps"),
|
|
153
|
+
sample_rate: metadata.fetch("sample_rate")
|
|
154
|
+
}
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def normalize_cache_root(value)
|
|
158
|
+
return nil if value.nil?
|
|
159
|
+
|
|
160
|
+
Pathname.new(value.to_s).expand_path
|
|
161
|
+
end
|
|
162
|
+
|
|
53
163
|
def record
|
|
54
164
|
validate_audio_file!
|
|
55
165
|
input = Vizcore::Audio::FileInput.new(path: @audio_file.to_s)
|