Class: Vizcore::Server::FrameBroadcaster

Inherits:
Object
  • Object
show all
Defined in:
lib/vizcore/server/frame_broadcaster.rb

Overview

Produces audio-reactive frame payloads and broadcasts them over WebSocket.

Constant Summary collapse

FRAME_RATE =

Target broadcast frame rate.

60.0

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(scene_name: "basic", scene_layers: nil, input_manager: nil, analysis_pipeline: nil, mapping_resolver: nil, scene_serializer: nil, frame_scheduler: nil, scene_catalog: nil, transitions: nil, transition_controller: nil, initial_timeline_entry: nil, noise_gate: Vizcore::Analysis::Pipeline::DEFAULT_NOISE_GATE, audio_normalize: nil, bpm: nil, bpm_lock: false, onset_sensitivity: 1.0, fft_preview_bins: Vizcore::Analysis::Pipeline::DEFAULT_FFT_PREVIEW_BINS, peak_hold_frames: 0, silence_reset_frames: Vizcore::Analysis::Pipeline::SILENCE_RESET_FRAMES, error_reporter: nil) ⇒ FrameBroadcaster

Returns a new instance of FrameBroadcaster.

Parameters:

  • scene_name (String) (defaults to: "basic")
  • scene_layers (Array<Hash>, nil) (defaults to: nil)
  • input_manager (Vizcore::Audio::InputManager, nil) (defaults to: nil)
  • analysis_pipeline (Vizcore::Analysis::Pipeline, nil) (defaults to: nil)
  • mapping_resolver (Vizcore::DSL::MappingResolver, nil) (defaults to: nil)
  • scene_serializer (Vizcore::Renderer::SceneSerializer, nil) (defaults to: nil)
  • frame_scheduler (Vizcore::Renderer::FrameScheduler, nil) (defaults to: nil)
  • scene_catalog (Array<Hash>, nil) (defaults to: nil)
  • transitions (Array<Hash>, nil) (defaults to: nil)
  • transition_controller (Vizcore::DSL::TransitionController, nil) (defaults to: nil)
  • initial_timeline_entry (Hash, nil) (defaults to: nil)
  • noise_gate (Numeric) (defaults to: Vizcore::Analysis::Pipeline::DEFAULT_NOISE_GATE)
  • audio_normalize (Hash, nil) (defaults to: nil)
  • bpm (Numeric, nil) (defaults to: nil)
  • bpm_lock (Boolean) (defaults to: false)
  • onset_sensitivity (Numeric) (defaults to: 1.0)
  • fft_preview_bins (Integer) (defaults to: Vizcore::Analysis::Pipeline::DEFAULT_FFT_PREVIEW_BINS)
  • peak_hold_frames (Integer) (defaults to: 0)
  • silence_reset_frames (Integer) (defaults to: Vizcore::Analysis::Pipeline::SILENCE_RESET_FRAMES)
  • error_reporter (#call, nil) (defaults to: nil)


37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
# File 'lib/vizcore/server/frame_broadcaster.rb', line 37

def initialize(
  scene_name: "basic",
  scene_layers: nil,
  input_manager: nil,
  analysis_pipeline: nil,
  mapping_resolver: nil,
  scene_serializer: nil,
  frame_scheduler: nil,
  scene_catalog: nil,
  transitions: nil,
  transition_controller: nil,
  initial_timeline_entry: nil,
  noise_gate: Vizcore::Analysis::Pipeline::DEFAULT_NOISE_GATE,
  audio_normalize: nil,
  bpm: nil,
  bpm_lock: false,
  onset_sensitivity: 1.0,
  fft_preview_bins: Vizcore::Analysis::Pipeline::DEFAULT_FFT_PREVIEW_BINS,
  peak_hold_frames: 0,
  silence_reset_frames: Vizcore::Analysis::Pipeline::SILENCE_RESET_FRAMES,
  error_reporter: nil
)
  @scene_name = scene_name.to_s
  @scene_layers = Array(scene_layers)
  @scene_mutex = Mutex.new
  @input_manager = input_manager || Vizcore::Audio::InputManager.new(source: :mic)
  fft_size = supported_fft_size(@input_manager.frame_size)
  @analysis_pipeline = analysis_pipeline || Vizcore::Analysis::Pipeline.new(
    sample_rate: @input_manager.sample_rate,
    fft_size: fft_size,
    noise_gate: noise_gate,
    audio_normalize: audio_normalize,
    bpm: bpm,
    bpm_lock: bpm_lock,
    onset_sensitivity: onset_sensitivity,
    fft_preview_bins: fft_preview_bins,
    peak_hold_frames: peak_hold_frames,
    silence_reset_frames: silence_reset_frames
  )
  @mapping_resolver = mapping_resolver || Vizcore::DSL::MappingResolver.new
  @scene_serializer = scene_serializer || Vizcore::Renderer::SceneSerializer.new
  @error_reporter = error_reporter || ->(_message) {}
  @transition_controller = transition_controller || Vizcore::DSL::TransitionController.new(
    scenes: scene_catalog || [],
    transitions: transitions || [],
    error_reporter: lambda do |message|
      @error_reporter.call(message)
      report_runtime_message(
        message,
        context: "transition trigger failed",
        source: "transition",
        event: "transition_failed"
      )
    end
  )
  @last_error = nil
  @frame_count = 0
  @last_frame_metrics = {}
  @scene_version = 1
  @last_sent_scene_version = nil
  @last_sent_scene_payload = nil
  @connected_client_count = 0
  @custom_shape_param_overrides = {}
  @layer_param_overrides = {}
  @custom_shape_param_mutex = Mutex.new
  @transport_reference_position = nil
  @transport_reference_wall_seconds = nil
  @transport_drift_seconds = 0.0
  @transport_drift_threshold_seconds = 0.08
  @transport_playing = initial_transport_playing_state
  reset_transition_trigger_counters!
  apply_initial_timeline_entry(initial_timeline_entry)
  @tap_tempo = Vizcore::Analysis::TapTempo.new
  @frame_scheduler = frame_scheduler || Vizcore::Renderer::FrameScheduler.new(frame_rate: FRAME_RATE) do |elapsed|
    tick(elapsed)
  end
end

Instance Attribute Details

#last_errorObject (readonly)

Returns the value of attribute last_error.



115
116
117
# File 'lib/vizcore/server/frame_broadcaster.rb', line 115

def last_error
  @last_error
end

Instance Method Details

#build_frame(elapsed_seconds, samples = nil) ⇒ Hash

Build one frame payload for transport to frontend.

Parameters:

  • _elapsed_seconds (Float)
  • samples (Array<Float>, nil) (defaults to: nil)

Returns:

  • (Hash)

Raises:



324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
# File 'lib/vizcore/server/frame_broadcaster.rb', line 324

def build_frame(elapsed_seconds, samples = nil)
  started_at_ms = monotonic_ms
  apply_transport_drift_correction if file_transport_source?
  audio_samples, audio_capture_ms = capture_or_use_samples(samples)
  sync_last_scene_state_with_connections
  analyzed, audio_analysis_ms = measure_ms { @analysis_pipeline.call(audio_samples) }
  scene = current_scene
  layers, scene_build_ms = measure_ms { build_scene_layers(scene[:layers], analyzed, time: elapsed_seconds, frame: @frame_count) }

  frame = @scene_serializer.audio_frame(
    timestamp: Time.now.to_f,
    audio: analyzed,
    scene_name: scene[:name],
    scene_layers: layers,
    transition: nil,
    metrics: {
      frame_id: @frame_count,
      audio_capture_ms: audio_capture_ms,
      audio_analysis_ms: audio_analysis_ms,
      scene_build_ms: scene_build_ms,
      server_frame_ms: monotonic_ms - started_at_ms
    }
  )
  frame[:scene_version] = scene[:version]
  full_scene = deep_dup(frame[:scene])
  send_full_scene = scene[:version] != @last_sent_scene_version
  if send_full_scene
    full_scene[:version] = scene[:version]
    frame[:scene] = full_scene
    @last_sent_scene_payload = deep_dup(full_scene)
    @last_sent_scene_version = scene[:version]
  else
    patch = scene_delta(previous: @last_sent_scene_payload, current: full_scene, scene_name: scene[:name], scene_version: scene[:version])
    if patch
      frame[:scene] = patch
    else
      frame.delete(:scene)
    end
  end
  @last_sent_scene_payload = deep_dup(full_scene) if scene[:version] == @last_sent_scene_version
  @last_frame_metrics = frame[:metrics] || {}
  frame
rescue StandardError => e
  report_error(e, context: "frame build failed")
  raise Vizcore::FrameBuildError, Vizcore::ErrorFormatting.summarize(e, context: "Frame build failed")
end

#current_scene_snapshotHash

Returns current scene snapshot (‘name`, `layers`).

Returns:

  • (Hash)

    current scene snapshot (‘name`, `layers`)



143
144
145
# File 'lib/vizcore/server/frame_broadcaster.rb', line 143

def current_scene_snapshot
  current_scene
end

#lock_bpm(bpm) ⇒ Float?

Lock analysis BPM from an external sync source.

Parameters:

  • bpm (Numeric)

Returns:

  • (Float, nil)


270
271
272
273
274
275
276
277
278
279
# File 'lib/vizcore/server/frame_broadcaster.rb', line 270

def lock_bpm(bpm)
  numeric = Float(bpm)
  return nil unless numeric.finite? && numeric.positive?
  return numeric unless @analysis_pipeline.respond_to?(:bpm_lock=)

  @analysis_pipeline.bpm_lock = { bpm: numeric, locked: true }
  numeric
rescue ArgumentError, TypeError
  nil
end

#running?Boolean

Returns:

  • (Boolean)


138
139
140
# File 'lib/vizcore/server/frame_broadcaster.rb', line 138

def running?
  @frame_scheduler.running?
end

#runtime_statusHash

Returns runtime health details for control/status endpoints.

Returns:

  • (Hash)

    runtime health details for control/status endpoints



148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
# File 'lib/vizcore/server/frame_broadcaster.rb', line 148

def runtime_status
  scene = current_scene
  {
    current_scene: scene[:name].to_s,
    scene_version: @scene_version,
    fps: FRAME_RATE,
    frame_id: @frame_count,
    sample_rate: input_manager_value(:sample_rate),
    frame_size: input_manager_value(:frame_size),
    input: input_manager_status,
    transport_playing: @scene_mutex.synchronize { @transport_playing },
    transport_drift: transport_drift_status,
    websocket_clients: WebSocketHandler.connection_count,
    dropped_frames: WebSocketHandler.dropped_frame_count,
    websocket_backpressure: WebSocketHandler.backpressure_status,
    last_error: formatted_last_error,
    metrics: deep_dup(@last_frame_metrics)
  }.compact
end

#set_custom_shape_param(layer_name:, custom_shape_index:, param:, value:) ⇒ Object



289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
# File 'lib/vizcore/server/frame_broadcaster.rb', line 289

def set_custom_shape_param(layer_name:, custom_shape_index:, param:, value:)
  layer_key = layer_name.to_s
  param_key = param.to_s.strip
  index = Integer(custom_shape_index)
  numeric = finite_float(value)
  return custom_shape_param_overrides_snapshot if layer_key.empty? || param_key.empty? || index.negative? || numeric.nil?

  @custom_shape_param_mutex.synchronize do
    @custom_shape_param_overrides[layer_key] ||= {}
    @custom_shape_param_overrides[layer_key][index] ||= {}
    @custom_shape_param_overrides[layer_key][index][param_key] = numeric
    deep_dup(@custom_shape_param_overrides)
  end
rescue ArgumentError, TypeError
  custom_shape_param_overrides_snapshot
end

#set_layer_param(layer_name:, param:, value:) ⇒ Object



306
307
308
309
310
311
312
313
314
315
316
# File 'lib/vizcore/server/frame_broadcaster.rb', line 306

def set_layer_param(layer_name:, param:, value:)
  layer_key = layer_name.to_s
  param_key = param.to_s.tr("/", ".").strip
  return layer_param_overrides_snapshot if layer_key.empty? || param_key.empty?

  @custom_shape_param_mutex.synchronize do
    @layer_param_overrides[layer_key] ||= {}
    @layer_param_overrides[layer_key][param_key] = value
    deep_dup(@layer_param_overrides)
  end
end

#startvoid

This method returns an undefined value.



118
119
120
121
122
123
124
125
126
127
# File 'lib/vizcore/server/frame_broadcaster.rb', line 118

def start
  return if running?

  @input_manager.start
  @frame_scheduler.start
rescue StandardError => e
  report_error(e, context: "frame broadcaster start failed")
  @input_manager.stop
  raise
end

#stopvoid

This method returns an undefined value.



130
131
132
133
134
135
# File 'lib/vizcore/server/frame_broadcaster.rb', line 130

def stop
  return unless running?

  @frame_scheduler.stop
  @input_manager.stop
end

#sync_transport(playing:, position_seconds:) ⇒ void

This method returns an undefined value.

Synchronize external playback transport (e.g. browser audio element) with the input source.

Parameters:

  • playing (Boolean)
  • position_seconds (Numeric)


173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
# File 'lib/vizcore/server/frame_broadcaster.rb', line 173

def sync_transport(playing:, position_seconds:)
  position = finite_float(position_seconds)
  @scene_mutex.synchronize do
    @transport_playing = !!playing
    reset_transition_trigger_counters! if transport_position_reset?(position)
    if file_transport_source?
      @transport_reference_position = position
      @transport_reference_wall_seconds = wall_clock_seconds
    end
  end
  return unless @input_manager.respond_to?(:sync_transport)

  @input_manager.sync_transport(playing: playing, position_seconds: position_seconds)
rescue StandardError => e
  report_error(e, context: "audio transport sync failed")
end

#tap_tempo(timestamp_ms:) ⇒ Float?

Apply a manual tap tempo event and lock analysis BPM when enough taps exist.

Parameters:

  • timestamp_ms (Numeric)

Returns:

  • (Float, nil)


257
258
259
260
261
262
263
264
# File 'lib/vizcore/server/frame_broadcaster.rb', line 257

def tap_tempo(timestamp_ms:)
  bpm = @tap_tempo.tap(timestamp_ms: timestamp_ms)
  return nil unless bpm
  return bpm unless @analysis_pipeline.respond_to?(:bpm_lock=)

  @analysis_pipeline.bpm_lock = { bpm: bpm, locked: true }
  bpm
end

#tick(elapsed_seconds, samples = nil) ⇒ Hash

Run one frame tick and broadcast it.

Parameters:

  • elapsed_seconds (Float)
  • samples (Array<Float>, nil) (defaults to: nil)

Returns:

  • (Hash)

    serialized frame



195
196
197
198
199
200
201
# File 'lib/vizcore/server/frame_broadcaster.rb', line 195

def tick(elapsed_seconds, samples = nil)
  @frame_count += 1
  frame = build_frame(elapsed_seconds, samples)
  WebSocketHandler.broadcast(type: "audio_frame", payload: frame)
  evaluate_transition(frame[:audio], frame_count: @frame_count, elapsed_seconds: elapsed_seconds)
  frame
end

#unlock_bpmBoolean

Unlock analysis BPM after an external sync lock.

Returns:

  • (Boolean)


284
285
286
287
# File 'lib/vizcore/server/frame_broadcaster.rb', line 284

def unlock_bpm
  @analysis_pipeline.bpm_lock = { bpm: nil, locked: false } if @analysis_pipeline.respond_to?(:bpm_lock=)
  true
end

#update_analysis_settings(audio_normalize:, bpm: nil, bpm_lock: false, onset_sensitivity: 1.0, fft_preview_bins: Vizcore::Analysis::Pipeline::DEFAULT_FFT_PREVIEW_BINS, peak_hold_frames: 0, silence_reset_frames: Vizcore::Analysis::Pipeline::SILENCE_RESET_FRAMES) ⇒ void

This method returns an undefined value.

Replace audio analysis settings after scene hot reload.

Parameters:

  • audio_normalize (Hash, nil)
  • bpm (Numeric, nil) (defaults to: nil)
  • bpm_lock (Boolean) (defaults to: false)


242
243
244
245
246
247
248
249
250
251
# File 'lib/vizcore/server/frame_broadcaster.rb', line 242

def update_analysis_settings(audio_normalize:, bpm: nil, bpm_lock: false, onset_sensitivity: 1.0, fft_preview_bins: Vizcore::Analysis::Pipeline::DEFAULT_FFT_PREVIEW_BINS, peak_hold_frames: 0, silence_reset_frames: Vizcore::Analysis::Pipeline::SILENCE_RESET_FRAMES)
  return unless @analysis_pipeline.respond_to?(:audio_normalize=)

  @analysis_pipeline.audio_normalize = audio_normalize
  @analysis_pipeline.bpm_lock = { bpm: bpm, locked: bpm_lock } if @analysis_pipeline.respond_to?(:bpm_lock=)
  @analysis_pipeline.onset_sensitivity = onset_sensitivity if @analysis_pipeline.respond_to?(:onset_sensitivity=)
  @analysis_pipeline.fft_preview_bins = fft_preview_bins if @analysis_pipeline.respond_to?(:fft_preview_bins=)
  @analysis_pipeline.peak_hold_frames = peak_hold_frames if @analysis_pipeline.respond_to?(:peak_hold_frames=)
  @analysis_pipeline.silence_reset_frames = silence_reset_frames if @analysis_pipeline.respond_to?(:silence_reset_frames=)
end

#update_scene(scene_name:, scene_layers:) ⇒ void

This method returns an undefined value.

Replace active scene and layers.

Parameters:

  • scene_name (String, Symbol)
  • scene_layers (Array<Hash>)


208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
# File 'lib/vizcore/server/frame_broadcaster.rb', line 208

def update_scene(scene_name:, scene_layers:)
  @scene_mutex.synchronize do
    next_scene_name = scene_name.to_s
    next_scene_layers = Array(scene_layers)
    same_scene = @scene_name == next_scene_name &&
      deep_layers_equal?(@scene_layers, next_scene_layers)

    @scene_name = next_scene_name
    @scene_layers = next_scene_layers
    @scene_version += 1 unless same_scene
    @last_sent_scene_version = nil unless same_scene
    @last_sent_scene_payload = nil unless same_scene
    @mapping_resolver.reset! if @mapping_resolver.respond_to?(:reset!)
    reset_transition_trigger_counters!
  end
end

#update_transition_definition(scenes:, transitions:) ⇒ void

This method returns an undefined value.

Replace transition catalog used by automatic scene switching.

Parameters:

  • scenes (Array<Hash>)
  • transitions (Array<Hash>)


230
231
232
233
234
# File 'lib/vizcore/server/frame_broadcaster.rb', line 230

def update_transition_definition(scenes:, transitions:)
  @scene_mutex.synchronize do
    @transition_controller.update(scenes: scenes, transitions: transitions)
  end
end