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.
Files changed (78) hide show
  1. checksums.yaml +4 -4
  2. data/frontend/index.html +24 -2
  3. data/frontend/src/audio-inspector.js +9 -0
  4. data/frontend/src/live-controls.js +219 -7
  5. data/frontend/src/main.js +447 -57
  6. data/frontend/src/midi-learn.js +22 -2
  7. data/frontend/src/performance-monitor.js +137 -1
  8. data/frontend/src/renderer/engine.js +391 -10
  9. data/frontend/src/renderer/layer-manager.js +472 -71
  10. data/frontend/src/runtime-control-preset.js +44 -0
  11. data/frontend/src/scene-patches.js +159 -0
  12. data/frontend/src/shader-error-overlay.js +1 -0
  13. data/frontend/src/visuals/image-renderer.js +19 -0
  14. data/frontend/src/visuals/particle-system.js +10 -0
  15. data/frontend/src/visuals/shape-renderer.js +13 -0
  16. data/frontend/src/visuals/spectrogram-renderer.js +14 -0
  17. data/frontend/src/visuals/text-renderer.js +13 -0
  18. data/frontend/src/websocket-client.js +6 -0
  19. data/lib/vizcore/analysis/adaptive_normalizer.rb +20 -2
  20. data/lib/vizcore/analysis/bpm_estimator.rb +18 -8
  21. data/lib/vizcore/analysis/feature_recorder.rb +117 -7
  22. data/lib/vizcore/analysis/feature_replay.rb +48 -9
  23. data/lib/vizcore/analysis/pipeline.rb +258 -9
  24. data/lib/vizcore/analysis/tap_tempo.rb +17 -2
  25. data/lib/vizcore/audio/calibration.rb +156 -0
  26. data/lib/vizcore/audio/file_input.rb +28 -0
  27. data/lib/vizcore/audio/input_manager.rb +36 -1
  28. data/lib/vizcore/audio/midi_input.rb +5 -0
  29. data/lib/vizcore/audio/ring_buffer.rb +22 -0
  30. data/lib/vizcore/audio.rb +1 -0
  31. data/lib/vizcore/cli/dsl_reference.rb +64 -8
  32. data/lib/vizcore/cli/plugin_checker.rb +93 -0
  33. data/lib/vizcore/cli/scene_diagnostics.rb +2 -2
  34. data/lib/vizcore/cli/scene_inspector.rb +35 -1
  35. data/lib/vizcore/cli/scene_validator.rb +487 -39
  36. data/lib/vizcore/cli/shader_template.rb +7 -2
  37. data/lib/vizcore/cli/shader_uniform_docs.rb +11 -0
  38. data/lib/vizcore/cli.rb +268 -15
  39. data/lib/vizcore/config.rb +40 -3
  40. data/lib/vizcore/control_preset.rb +29 -0
  41. data/lib/vizcore/deep_copy.rb +21 -0
  42. data/lib/vizcore/dsl/color_helpers.rb +155 -0
  43. data/lib/vizcore/dsl/engine.rb +219 -23
  44. data/lib/vizcore/dsl/layer_builder.rb +278 -15
  45. data/lib/vizcore/dsl/layer_group_builder.rb +10 -12
  46. data/lib/vizcore/dsl/layout_helpers.rb +290 -0
  47. data/lib/vizcore/dsl/mapping_preset_builder.rb +41 -0
  48. data/lib/vizcore/dsl/mapping_resolver.rb +404 -22
  49. data/lib/vizcore/dsl/mapping_transform_builder.rb +50 -0
  50. data/lib/vizcore/dsl/midi_map_executor.rb +219 -23
  51. data/lib/vizcore/dsl/reaction_builder.rb +1 -0
  52. data/lib/vizcore/dsl/scene_builder.rb +83 -13
  53. data/lib/vizcore/dsl/shader_source_resolver.rb +1 -10
  54. data/lib/vizcore/dsl/style_builder.rb +3 -0
  55. data/lib/vizcore/dsl/timeline_builder.rb +91 -8
  56. data/lib/vizcore/dsl/transition_controller.rb +157 -18
  57. data/lib/vizcore/dsl.rb +2 -0
  58. data/lib/vizcore/layer_catalog.rb +1 -0
  59. data/lib/vizcore/plugin_asset_policy.rb +55 -0
  60. data/lib/vizcore/project_manifest.rb +12 -2
  61. data/lib/vizcore/renderer/render_sequence.rb +104 -13
  62. data/lib/vizcore/renderer/scene_frame_source.rb +179 -14
  63. data/lib/vizcore/renderer/scene_serializer.rb +38 -0
  64. data/lib/vizcore/renderer/snapshot.rb +4 -3
  65. data/lib/vizcore/renderer/snapshot_renderer.rb +134 -8
  66. data/lib/vizcore/scene_trust.rb +31 -0
  67. data/lib/vizcore/server/frame_broadcaster.rb +469 -23
  68. data/lib/vizcore/server/rack_app.rb +151 -4
  69. data/lib/vizcore/server/runner.rb +676 -82
  70. data/lib/vizcore/server/websocket_handler.rb +236 -14
  71. data/lib/vizcore/server.rb +21 -0
  72. data/lib/vizcore/shape.rb +39 -16
  73. data/lib/vizcore/sync/osc_message.rb +66 -9
  74. data/lib/vizcore/version.rb +1 -1
  75. data/lib/vizcore.rb +33 -0
  76. data/scripts/browser_capture.mjs +31 -2
  77. data/sig/vizcore.rbs +55 -4
  78. 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
+ };
@@ -7,6 +7,7 @@ export const buildShaderErrorDetail = ({ layer, error, phase }) => {
7
7
  name,
8
8
  shader,
9
9
  phase: String(phase || "shader"),
10
+ event: "shader_failed",
10
11
  message: normalizeErrorMessage(error),
11
12
  };
12
13
  };
@@ -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
- def initialize(window_size: DEFAULT_WINDOW_SIZE, target: DEFAULT_TARGET, floor: DEFAULT_FLOOR)
14
+ # @param per_band [Boolean] true when band levels should track independent peaks
15
+ def initialize(window_size: DEFAULT_WINDOW_SIZE, target: DEFAULT_TARGET, floor: DEFAULT_FLOOR, per_band: false)
15
16
  @window_size = normalize_window_size(window_size)
16
17
  @target = normalize_unit(target, DEFAULT_TARGET)
17
18
  @floor = normalize_unit(floor, DEFAULT_FLOOR)
19
+ @per_band = !!per_band
18
20
  @history = []
21
+ @band_history = Hash.new { |history, key| history[key] = [] }
19
22
  end
20
23
 
21
24
  # @param amplitude [Numeric] current RMS amplitude
@@ -30,7 +33,7 @@ module Vizcore
30
33
  gain = @target / [@history.max.to_f, @floor].max
31
34
  {
32
35
  amplitude: scale_value(current_amplitude, gain),
33
- bands: scale_hash(bands, gain),
36
+ bands: normalize_bands(bands, gain),
34
37
  fft: scale_array(fft, gain),
35
38
  gain: gain
36
39
  }
@@ -56,6 +59,21 @@ module Vizcore
56
59
  {}
57
60
  end
58
61
 
62
+ def normalize_bands(values, amplitude_gain)
63
+ return scale_hash(values, amplitude_gain) unless @per_band
64
+
65
+ Hash(values).each_with_object({}) do |(key, value), output|
66
+ normalized = normalize_unit(value, 0.0)
67
+ history = @band_history[key.to_sym]
68
+ history << normalized
69
+ history.shift while history.length > @window_size
70
+ gain = @target / [history.max.to_f, @floor].max
71
+ output[key] = scale_value(normalized, gain)
72
+ end
73
+ rescue StandardError
74
+ {}
75
+ end
76
+
59
77
  def scale_array(values, gain)
60
78
  Array(values).map { |value| scale_value(value, gain) }
61
79
  end
@@ -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
- return @current_bpm if onset_count < @min_onsets
33
+ if onset_count < @min_onsets
34
+ @confidence = 0.0
35
+ return @current_bpm
36
+ end
33
37
 
34
- candidate = estimate_candidate_bpm
35
- return @current_bpm if candidate <= 0.0
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
- payload = record
42
- output_path.write("#{JSON.pretty_generate(payload)}\n")
43
- {
44
- path: output_path,
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
- sample_rate: payload.fetch("metadata").fetch("sample_rate")
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)