vizcore 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +170 -0
- data/docs/GETTING_STARTED.md +105 -0
- data/examples/assets/complex_demo_loop.wav +0 -0
- data/examples/basic.rb +9 -0
- data/examples/complex_audio_showcase.rb +261 -0
- data/examples/custom_shader.rb +21 -0
- data/examples/file_audio_demo.rb +74 -0
- data/examples/intro_drop.rb +38 -0
- data/examples/midi_scene_switch.rb +32 -0
- data/examples/shaders/custom_wave.frag +30 -0
- data/exe/vizcore +6 -0
- data/frontend/index.html +148 -0
- data/frontend/src/main.js +304 -0
- data/frontend/src/renderer/engine.js +135 -0
- data/frontend/src/renderer/layer-manager.js +456 -0
- data/frontend/src/renderer/shader-manager.js +69 -0
- data/frontend/src/shaders/builtins.js +244 -0
- data/frontend/src/shaders/post-effects.js +85 -0
- data/frontend/src/visuals/geometry.js +66 -0
- data/frontend/src/visuals/particle-system.js +148 -0
- data/frontend/src/visuals/text-renderer.js +143 -0
- data/frontend/src/visuals/vj-effects.js +56 -0
- data/frontend/src/websocket-client.js +131 -0
- data/lib/vizcore/analysis/band_splitter.rb +63 -0
- data/lib/vizcore/analysis/beat_detector.rb +70 -0
- data/lib/vizcore/analysis/bpm_estimator.rb +86 -0
- data/lib/vizcore/analysis/fft_processor.rb +224 -0
- data/lib/vizcore/analysis/fftw_ffi.rb +50 -0
- data/lib/vizcore/analysis/pipeline.rb +72 -0
- data/lib/vizcore/analysis/smoother.rb +74 -0
- data/lib/vizcore/analysis.rb +14 -0
- data/lib/vizcore/audio/base_input.rb +39 -0
- data/lib/vizcore/audio/dummy_sine_input.rb +40 -0
- data/lib/vizcore/audio/file_input.rb +163 -0
- data/lib/vizcore/audio/input_manager.rb +133 -0
- data/lib/vizcore/audio/mic_input.rb +121 -0
- data/lib/vizcore/audio/midi_input.rb +246 -0
- data/lib/vizcore/audio/portaudio_ffi.rb +243 -0
- data/lib/vizcore/audio/ring_buffer.rb +92 -0
- data/lib/vizcore/audio.rb +16 -0
- data/lib/vizcore/cli.rb +115 -0
- data/lib/vizcore/config.rb +46 -0
- data/lib/vizcore/dsl/engine.rb +229 -0
- data/lib/vizcore/dsl/file_watcher.rb +108 -0
- data/lib/vizcore/dsl/layer_builder.rb +182 -0
- data/lib/vizcore/dsl/mapping_resolver.rb +81 -0
- data/lib/vizcore/dsl/midi_map_executor.rb +188 -0
- data/lib/vizcore/dsl/scene_builder.rb +44 -0
- data/lib/vizcore/dsl/shader_source_resolver.rb +71 -0
- data/lib/vizcore/dsl/transition_controller.rb +166 -0
- data/lib/vizcore/dsl.rb +16 -0
- data/lib/vizcore/errors.rb +27 -0
- data/lib/vizcore/renderer/frame_scheduler.rb +75 -0
- data/lib/vizcore/renderer/scene_serializer.rb +73 -0
- data/lib/vizcore/renderer.rb +10 -0
- data/lib/vizcore/server/frame_broadcaster.rb +351 -0
- data/lib/vizcore/server/rack_app.rb +183 -0
- data/lib/vizcore/server/runner.rb +357 -0
- data/lib/vizcore/server/websocket_handler.rb +163 -0
- data/lib/vizcore/server.rb +12 -0
- data/lib/vizcore/templates/basic_scene.rb +10 -0
- data/lib/vizcore/templates/custom_shader_scene.rb +22 -0
- data/lib/vizcore/templates/custom_wave.frag +31 -0
- data/lib/vizcore/templates/intro_drop_scene.rb +40 -0
- data/lib/vizcore/templates/midi_control_scene.rb +33 -0
- data/lib/vizcore/templates/project_readme.md +35 -0
- data/lib/vizcore/version.rb +6 -0
- data/lib/vizcore.rb +37 -0
- metadata +186 -0
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../audio"
|
|
4
|
+
require_relative "../analysis"
|
|
5
|
+
require_relative "../dsl"
|
|
6
|
+
require_relative "../errors"
|
|
7
|
+
require_relative "../renderer"
|
|
8
|
+
|
|
9
|
+
module Vizcore
|
|
10
|
+
module Server
|
|
11
|
+
# Produces audio-reactive frame payloads and broadcasts them over WebSocket.
|
|
12
|
+
class FrameBroadcaster
|
|
13
|
+
# Target broadcast frame rate.
|
|
14
|
+
FRAME_RATE = 60.0
|
|
15
|
+
|
|
16
|
+
# @param scene_name [String]
|
|
17
|
+
# @param scene_layers [Array<Hash>, nil]
|
|
18
|
+
# @param input_manager [Vizcore::Audio::InputManager, nil]
|
|
19
|
+
# @param analysis_pipeline [Vizcore::Analysis::Pipeline, nil]
|
|
20
|
+
# @param mapping_resolver [Vizcore::DSL::MappingResolver, nil]
|
|
21
|
+
# @param scene_serializer [Vizcore::Renderer::SceneSerializer, nil]
|
|
22
|
+
# @param frame_scheduler [Vizcore::Renderer::FrameScheduler, nil]
|
|
23
|
+
# @param scene_catalog [Array<Hash>, nil]
|
|
24
|
+
# @param transitions [Array<Hash>, nil]
|
|
25
|
+
# @param transition_controller [Vizcore::DSL::TransitionController, nil]
|
|
26
|
+
# @param error_reporter [#call, nil]
|
|
27
|
+
def initialize(
|
|
28
|
+
scene_name: "basic",
|
|
29
|
+
scene_layers: nil,
|
|
30
|
+
input_manager: nil,
|
|
31
|
+
analysis_pipeline: nil,
|
|
32
|
+
mapping_resolver: nil,
|
|
33
|
+
scene_serializer: nil,
|
|
34
|
+
frame_scheduler: nil,
|
|
35
|
+
scene_catalog: nil,
|
|
36
|
+
transitions: nil,
|
|
37
|
+
transition_controller: nil,
|
|
38
|
+
error_reporter: nil
|
|
39
|
+
)
|
|
40
|
+
@scene_name = scene_name
|
|
41
|
+
@scene_layers = Array(scene_layers)
|
|
42
|
+
@scene_mutex = Mutex.new
|
|
43
|
+
@input_manager = input_manager || Vizcore::Audio::InputManager.new(source: :mic)
|
|
44
|
+
fft_size = supported_fft_size(@input_manager.frame_size)
|
|
45
|
+
@analysis_pipeline = analysis_pipeline || Vizcore::Analysis::Pipeline.new(
|
|
46
|
+
sample_rate: @input_manager.sample_rate,
|
|
47
|
+
fft_size: fft_size
|
|
48
|
+
)
|
|
49
|
+
@mapping_resolver = mapping_resolver || Vizcore::DSL::MappingResolver.new
|
|
50
|
+
@scene_serializer = scene_serializer || Vizcore::Renderer::SceneSerializer.new
|
|
51
|
+
@transition_controller = transition_controller || Vizcore::DSL::TransitionController.new(
|
|
52
|
+
scenes: scene_catalog || [],
|
|
53
|
+
transitions: transitions || []
|
|
54
|
+
)
|
|
55
|
+
@error_reporter = error_reporter || ->(_message) {}
|
|
56
|
+
@last_error = nil
|
|
57
|
+
@frame_count = 0
|
|
58
|
+
@transport_playing = initial_transport_playing_state
|
|
59
|
+
reset_transition_trigger_counters!
|
|
60
|
+
@frame_scheduler = frame_scheduler || Vizcore::Renderer::FrameScheduler.new(frame_rate: FRAME_RATE) do |elapsed|
|
|
61
|
+
tick(elapsed)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
attr_reader :last_error
|
|
66
|
+
|
|
67
|
+
# @return [void]
|
|
68
|
+
def start
|
|
69
|
+
return if running?
|
|
70
|
+
|
|
71
|
+
@input_manager.start
|
|
72
|
+
@frame_scheduler.start
|
|
73
|
+
rescue StandardError => e
|
|
74
|
+
report_error(e, context: "frame broadcaster start failed")
|
|
75
|
+
@input_manager.stop
|
|
76
|
+
raise
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# @return [void]
|
|
80
|
+
def stop
|
|
81
|
+
return unless running?
|
|
82
|
+
|
|
83
|
+
@frame_scheduler.stop
|
|
84
|
+
@input_manager.stop
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# @return [Boolean]
|
|
88
|
+
def running?
|
|
89
|
+
@frame_scheduler.running?
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# @return [Hash] current scene snapshot (`name`, `layers`)
|
|
93
|
+
def current_scene_snapshot
|
|
94
|
+
current_scene
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Synchronize external playback transport (e.g. browser audio element) with the input source.
|
|
98
|
+
#
|
|
99
|
+
# @param playing [Boolean]
|
|
100
|
+
# @param position_seconds [Numeric]
|
|
101
|
+
# @return [void]
|
|
102
|
+
def sync_transport(playing:, position_seconds:)
|
|
103
|
+
@scene_mutex.synchronize do
|
|
104
|
+
@transport_playing = !!playing
|
|
105
|
+
reset_transition_trigger_counters! if transport_position_reset?(position_seconds)
|
|
106
|
+
end
|
|
107
|
+
return unless @input_manager.respond_to?(:sync_transport)
|
|
108
|
+
|
|
109
|
+
@input_manager.sync_transport(playing: playing, position_seconds: position_seconds)
|
|
110
|
+
rescue StandardError => e
|
|
111
|
+
report_error(e, context: "audio transport sync failed")
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Run one frame tick and broadcast it.
|
|
115
|
+
#
|
|
116
|
+
# @param elapsed_seconds [Float]
|
|
117
|
+
# @param samples [Array<Float>, nil]
|
|
118
|
+
# @return [Hash] serialized frame
|
|
119
|
+
def tick(elapsed_seconds, samples = nil)
|
|
120
|
+
@frame_count += 1
|
|
121
|
+
frame = build_frame(elapsed_seconds, samples)
|
|
122
|
+
WebSocketHandler.broadcast(type: "audio_frame", payload: frame)
|
|
123
|
+
evaluate_transition(frame[:audio], frame_count: @frame_count)
|
|
124
|
+
frame
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Replace active scene and layers.
|
|
128
|
+
#
|
|
129
|
+
# @param scene_name [String, Symbol]
|
|
130
|
+
# @param scene_layers [Array<Hash>]
|
|
131
|
+
# @return [void]
|
|
132
|
+
def update_scene(scene_name:, scene_layers:)
|
|
133
|
+
@scene_mutex.synchronize do
|
|
134
|
+
@scene_name = scene_name.to_s
|
|
135
|
+
@scene_layers = Array(scene_layers)
|
|
136
|
+
reset_transition_trigger_counters!
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Replace transition catalog used by automatic scene switching.
|
|
141
|
+
#
|
|
142
|
+
# @param scenes [Array<Hash>]
|
|
143
|
+
# @param transitions [Array<Hash>]
|
|
144
|
+
# @return [void]
|
|
145
|
+
def update_transition_definition(scenes:, transitions:)
|
|
146
|
+
@scene_mutex.synchronize do
|
|
147
|
+
@transition_controller.update(scenes: scenes, transitions: transitions)
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Build one frame payload for transport to frontend.
|
|
152
|
+
#
|
|
153
|
+
# @param _elapsed_seconds [Float]
|
|
154
|
+
# @param samples [Array<Float>, nil]
|
|
155
|
+
# @raise [Vizcore::FrameBuildError] when frame construction fails
|
|
156
|
+
# @return [Hash]
|
|
157
|
+
def build_frame(_elapsed_seconds, samples = nil)
|
|
158
|
+
audio_samples = samples || capture_samples
|
|
159
|
+
analyzed = @analysis_pipeline.call(audio_samples)
|
|
160
|
+
scene = current_scene
|
|
161
|
+
layers = build_scene_layers(scene[:layers], analyzed)
|
|
162
|
+
|
|
163
|
+
@scene_serializer.audio_frame(
|
|
164
|
+
timestamp: Time.now.to_f,
|
|
165
|
+
audio: analyzed,
|
|
166
|
+
scene_name: scene[:name],
|
|
167
|
+
scene_layers: layers,
|
|
168
|
+
transition: nil
|
|
169
|
+
)
|
|
170
|
+
rescue StandardError => e
|
|
171
|
+
report_error(e, context: "frame build failed")
|
|
172
|
+
raise Vizcore::FrameBuildError, Vizcore::ErrorFormatting.summarize(e, context: "Frame build failed")
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
private
|
|
176
|
+
|
|
177
|
+
def capture_samples
|
|
178
|
+
ingest_count =
|
|
179
|
+
if @input_manager.respond_to?(:realtime_capture_size)
|
|
180
|
+
@input_manager.realtime_capture_size(FRAME_RATE)
|
|
181
|
+
else
|
|
182
|
+
@input_manager.frame_size
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
@input_manager.capture_frame(ingest_count)
|
|
186
|
+
samples = Array(@input_manager.latest_samples(@input_manager.frame_size))
|
|
187
|
+
return samples if samples.length == @input_manager.frame_size
|
|
188
|
+
return Array.new(@input_manager.frame_size, 0.0) if samples.empty?
|
|
189
|
+
|
|
190
|
+
Array.new(@input_manager.frame_size - samples.length, 0.0) + samples
|
|
191
|
+
rescue StandardError => e
|
|
192
|
+
report_error(e, context: "audio capture failed")
|
|
193
|
+
fallback_frame_size = @input_manager.respond_to?(:frame_size) ? Integer(@input_manager.frame_size) : 1024
|
|
194
|
+
Array.new(fallback_frame_size, 0.0)
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def supported_fft_size(size)
|
|
198
|
+
value = Integer(size)
|
|
199
|
+
return value if power_of_two?(value)
|
|
200
|
+
|
|
201
|
+
1024
|
|
202
|
+
rescue StandardError
|
|
203
|
+
1024
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def power_of_two?(value)
|
|
207
|
+
value.positive? && (value & (value - 1)).zero?
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def build_scene_layers(scene_layers, analyzed)
|
|
211
|
+
return default_scene_layers(analyzed) if scene_layers.empty?
|
|
212
|
+
|
|
213
|
+
@mapping_resolver.resolve_layers(scene_layers: scene_layers, audio: analyzed)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def default_scene_layers(analyzed)
|
|
217
|
+
amplitude = analyzed[:amplitude]
|
|
218
|
+
high = analyzed.dig(:bands, :high).to_f
|
|
219
|
+
|
|
220
|
+
[
|
|
221
|
+
{
|
|
222
|
+
name: "wireframe_cube",
|
|
223
|
+
type: "geometry",
|
|
224
|
+
params: {
|
|
225
|
+
rotation_speed: (0.4 + amplitude * 1.5).round(4),
|
|
226
|
+
color_shift: high.round(4)
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
]
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def current_scene
|
|
233
|
+
@scene_mutex.synchronize do
|
|
234
|
+
{
|
|
235
|
+
name: @scene_name,
|
|
236
|
+
layers: Array(@scene_layers)
|
|
237
|
+
}
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def evaluate_transition(audio, frame_count:)
|
|
242
|
+
return if transition_evaluation_paused?
|
|
243
|
+
|
|
244
|
+
transition = @scene_mutex.synchronize do
|
|
245
|
+
scene = {
|
|
246
|
+
name: @scene_name,
|
|
247
|
+
layers: Array(@scene_layers)
|
|
248
|
+
}
|
|
249
|
+
trigger_frame_count, trigger_audio = transition_trigger_inputs(
|
|
250
|
+
scene_name: scene[:name],
|
|
251
|
+
audio: audio,
|
|
252
|
+
frame_count: frame_count
|
|
253
|
+
)
|
|
254
|
+
@transition_controller.next_transition(
|
|
255
|
+
scene_name: scene[:name],
|
|
256
|
+
audio: trigger_audio,
|
|
257
|
+
frame_count: trigger_frame_count
|
|
258
|
+
)
|
|
259
|
+
end
|
|
260
|
+
return unless transition
|
|
261
|
+
|
|
262
|
+
update_scene(scene_name: transition[:to], scene_layers: transition.dig(:scene, :layers))
|
|
263
|
+
WebSocketHandler.broadcast(
|
|
264
|
+
type: "scene_change",
|
|
265
|
+
payload: {
|
|
266
|
+
from: transition[:from].to_s,
|
|
267
|
+
to: transition[:to].to_s,
|
|
268
|
+
effect: transition[:effect]
|
|
269
|
+
}
|
|
270
|
+
)
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def reset_transition_trigger_counters!
|
|
274
|
+
@transition_counter_scene_name = nil
|
|
275
|
+
@transition_counter_frame_base = 0
|
|
276
|
+
@transition_counter_beat_base = 0
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def transition_evaluation_paused?
|
|
280
|
+
@scene_mutex.synchronize { file_transport_source? && !@transport_playing }
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def initial_transport_playing_state
|
|
284
|
+
file_transport_source? ? false : true
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def file_transport_source?
|
|
288
|
+
return false unless @input_manager.is_a?(Vizcore::Audio::InputManager)
|
|
289
|
+
|
|
290
|
+
@input_manager.source_name.to_sym == :file
|
|
291
|
+
rescue StandardError
|
|
292
|
+
false
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def transport_position_reset?(position_seconds)
|
|
296
|
+
Float(position_seconds) <= 0.05
|
|
297
|
+
rescue StandardError
|
|
298
|
+
false
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def transition_trigger_inputs(scene_name:, audio:, frame_count:)
|
|
302
|
+
sync_transition_trigger_counters(scene_name: scene_name, audio: audio, frame_count: frame_count)
|
|
303
|
+
|
|
304
|
+
global_frame_count = Integer(frame_count)
|
|
305
|
+
scene_frame_count = [global_frame_count - @transition_counter_frame_base, 0].max
|
|
306
|
+
|
|
307
|
+
audio_hash = Hash(audio)
|
|
308
|
+
global_beat_count = extract_beat_count(audio_hash)
|
|
309
|
+
scene_beat_count = [global_beat_count - @transition_counter_beat_base, 0].max
|
|
310
|
+
|
|
311
|
+
[scene_frame_count, audio_hash.merge(beat_count: scene_beat_count)]
|
|
312
|
+
rescue StandardError
|
|
313
|
+
[0, { beat_count: 0 }]
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def sync_transition_trigger_counters(scene_name:, audio:, frame_count:)
|
|
317
|
+
normalized_scene_name = scene_name.to_s
|
|
318
|
+
return if @transition_counter_scene_name == normalized_scene_name
|
|
319
|
+
|
|
320
|
+
audio_hash = Hash(audio)
|
|
321
|
+
global_frame_count = Integer(frame_count)
|
|
322
|
+
global_beat_count = extract_beat_count(audio_hash)
|
|
323
|
+
|
|
324
|
+
@transition_counter_scene_name = normalized_scene_name
|
|
325
|
+
@transition_counter_frame_base = [global_frame_count - 1, 0].max
|
|
326
|
+
# Include the current frame's beat in the new scene-local counter when a beat is detected.
|
|
327
|
+
@transition_counter_beat_base = global_beat_count - (truthy_audio_beat?(audio_hash) ? 1 : 0)
|
|
328
|
+
rescue StandardError
|
|
329
|
+
reset_transition_trigger_counters!
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
def extract_beat_count(audio)
|
|
333
|
+
Integer(audio[:beat_count] || audio["beat_count"] || 0)
|
|
334
|
+
rescue StandardError
|
|
335
|
+
0
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
def truthy_audio_beat?(audio)
|
|
339
|
+
!!(audio[:beat] || audio["beat"])
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def report_error(error, context:)
|
|
343
|
+
@last_error = error
|
|
344
|
+
@error_reporter.call(Vizcore::ErrorFormatting.summarize(error, context: context))
|
|
345
|
+
rescue StandardError
|
|
346
|
+
nil
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
end
|
|
350
|
+
end
|
|
351
|
+
end
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "pathname"
|
|
5
|
+
require "rack"
|
|
6
|
+
require_relative "websocket_handler"
|
|
7
|
+
|
|
8
|
+
module Vizcore
|
|
9
|
+
module Server
|
|
10
|
+
# Rack app serving frontend assets, health endpoint, and WebSocket upgrade.
|
|
11
|
+
class RackApp
|
|
12
|
+
AUDIO_FILE_PATH = "/audio-file"
|
|
13
|
+
RUNTIME_PATH = "/runtime"
|
|
14
|
+
|
|
15
|
+
# @param frontend_root [Pathname]
|
|
16
|
+
# @param websocket_path [String]
|
|
17
|
+
# @param audio_source [Symbol, String, nil]
|
|
18
|
+
# @param audio_file [String, Pathname, nil]
|
|
19
|
+
# @param scene_names [Array<String, Symbol>, nil]
|
|
20
|
+
def initialize(frontend_root:, websocket_path: "/ws", audio_source: nil, audio_file: nil, scene_names: nil)
|
|
21
|
+
@frontend_root = frontend_root.expand_path
|
|
22
|
+
@websocket_path = websocket_path
|
|
23
|
+
@audio_source = audio_source&.to_sym
|
|
24
|
+
@audio_file = audio_file ? Pathname.new(audio_file).expand_path : nil
|
|
25
|
+
@scene_names = normalize_scene_names(scene_names)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# @param env [Hash]
|
|
29
|
+
# @return [Array(Integer, Hash, Array<String>)]
|
|
30
|
+
def call(env)
|
|
31
|
+
request = Rack::Request.new(env)
|
|
32
|
+
|
|
33
|
+
return WebSocketHandler.call(env) if request.path_info == @websocket_path
|
|
34
|
+
return health_response if request.path_info == "/health"
|
|
35
|
+
return runtime_response if request.path_info == RUNTIME_PATH
|
|
36
|
+
return audio_file_response(request) if request.path_info == AUDIO_FILE_PATH
|
|
37
|
+
|
|
38
|
+
serve_static(request.path_info)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def health_response
|
|
44
|
+
body = JSON.generate(status: "ok", websocket_clients: WebSocketHandler.connection_count)
|
|
45
|
+
[200, json_headers.merge("content-length" => body.bytesize.to_s), [body]]
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def runtime_response
|
|
49
|
+
payload = {
|
|
50
|
+
status: "ok",
|
|
51
|
+
audio_source: (@audio_source || :unknown).to_s,
|
|
52
|
+
audio_file_name: nil,
|
|
53
|
+
audio_file_url: nil,
|
|
54
|
+
scene_names: @scene_names
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if audio_file_available?
|
|
58
|
+
payload[:audio_file_name] = @audio_file.basename.to_s
|
|
59
|
+
payload[:audio_file_url] = AUDIO_FILE_PATH
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
body = JSON.generate(payload)
|
|
63
|
+
[200, json_headers.merge("content-length" => body.bytesize.to_s), [body]]
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def audio_file_response(request)
|
|
67
|
+
return not_found_response unless audio_file_available?
|
|
68
|
+
|
|
69
|
+
file_size = @audio_file.size
|
|
70
|
+
range = parse_byte_range(request.get_header("HTTP_RANGE"), file_size)
|
|
71
|
+
return range_not_satisfiable_response(file_size) if range == :invalid
|
|
72
|
+
|
|
73
|
+
if range
|
|
74
|
+
byte_start, byte_end = range
|
|
75
|
+
length = byte_end - byte_start + 1
|
|
76
|
+
body = File.binread(@audio_file, length, byte_start)
|
|
77
|
+
return [
|
|
78
|
+
206,
|
|
79
|
+
audio_headers(content_length: body.bytesize).merge("content-range" => "bytes #{byte_start}-#{byte_end}/#{file_size}"),
|
|
80
|
+
[body]
|
|
81
|
+
]
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
body = File.binread(@audio_file)
|
|
85
|
+
[200, audio_headers(content_length: body.bytesize), [body]]
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def serve_static(path_info)
|
|
89
|
+
path = path_info == "/" ? "index.html" : path_info.delete_prefix("/")
|
|
90
|
+
full_path = File.expand_path(path, @frontend_root.to_s)
|
|
91
|
+
|
|
92
|
+
return not_found_response unless full_path.start_with?(@frontend_root.to_s)
|
|
93
|
+
return not_found_response unless File.file?(full_path)
|
|
94
|
+
|
|
95
|
+
body = File.binread(full_path)
|
|
96
|
+
headers = {
|
|
97
|
+
"content-type" => Rack::Mime.mime_type(File.extname(full_path), "text/plain"),
|
|
98
|
+
"content-length" => body.bytesize.to_s,
|
|
99
|
+
"cache-control" => "no-store, max-age=0, must-revalidate"
|
|
100
|
+
}
|
|
101
|
+
[200, headers, [body]]
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def not_found_response
|
|
105
|
+
[404, text_headers.merge("content-length" => "9"), ["Not Found"]]
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def text_headers
|
|
109
|
+
{ "content-type" => "text/plain; charset=utf-8" }
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def json_headers
|
|
113
|
+
{ "content-type" => "application/json; charset=utf-8" }
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def audio_file_available?
|
|
117
|
+
@audio_source == :file && @audio_file && @audio_file.file?
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def audio_headers(content_length:)
|
|
121
|
+
{
|
|
122
|
+
"content-type" => Rack::Mime.mime_type(@audio_file.extname, "application/octet-stream"),
|
|
123
|
+
"content-length" => content_length.to_s,
|
|
124
|
+
"cache-control" => "no-store, max-age=0, must-revalidate",
|
|
125
|
+
"accept-ranges" => "bytes"
|
|
126
|
+
}
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def normalize_scene_names(values)
|
|
130
|
+
Array(values).filter_map do |entry|
|
|
131
|
+
name = entry.to_s.strip
|
|
132
|
+
next if name.empty?
|
|
133
|
+
|
|
134
|
+
name
|
|
135
|
+
end.uniq
|
|
136
|
+
rescue StandardError
|
|
137
|
+
[]
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def parse_byte_range(raw_range, file_size)
|
|
141
|
+
range_value = raw_range.to_s.strip
|
|
142
|
+
return nil if range_value.empty?
|
|
143
|
+
return :invalid unless range_value.start_with?("bytes=")
|
|
144
|
+
return :invalid if file_size <= 0
|
|
145
|
+
|
|
146
|
+
match = /\Abytes=(\d*)-(\d*)\z/.match(range_value)
|
|
147
|
+
return :invalid unless match
|
|
148
|
+
|
|
149
|
+
start_part = match[1]
|
|
150
|
+
end_part = match[2]
|
|
151
|
+
|
|
152
|
+
if start_part.empty?
|
|
153
|
+
return :invalid if end_part.empty?
|
|
154
|
+
|
|
155
|
+
suffix_length = Integer(end_part)
|
|
156
|
+
return :invalid unless suffix_length.positive?
|
|
157
|
+
|
|
158
|
+
return [0, file_size - 1] if suffix_length >= file_size
|
|
159
|
+
|
|
160
|
+
return [file_size - suffix_length, file_size - 1]
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
start_offset = Integer(start_part)
|
|
164
|
+
return :invalid if start_offset.negative? || start_offset >= file_size
|
|
165
|
+
|
|
166
|
+
if end_part.empty?
|
|
167
|
+
return [start_offset, file_size - 1]
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
end_offset = Integer(end_part)
|
|
171
|
+
return :invalid if end_offset < start_offset
|
|
172
|
+
|
|
173
|
+
[start_offset, [end_offset, file_size - 1].min]
|
|
174
|
+
rescue StandardError
|
|
175
|
+
:invalid
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def range_not_satisfiable_response(file_size)
|
|
179
|
+
[416, text_headers.merge("content-range" => "bytes */#{file_size}", "content-length" => "0"), []]
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|