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,246 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "thread"
|
|
4
|
+
require_relative "../errors"
|
|
5
|
+
|
|
6
|
+
module Vizcore
|
|
7
|
+
module Audio
|
|
8
|
+
# Poll-based MIDI input wrapper using the `unimidi` backend.
|
|
9
|
+
class MidiInput
|
|
10
|
+
# Normalized MIDI event payload.
|
|
11
|
+
Event = Struct.new(:type, :channel, :data1, :data2, :raw, :timestamp, keyword_init: true)
|
|
12
|
+
# Poll interval for empty reads in seconds.
|
|
13
|
+
DEFAULT_POLL_INTERVAL = 0.01
|
|
14
|
+
|
|
15
|
+
class << self
|
|
16
|
+
# @param backend [Module, nil]
|
|
17
|
+
# @return [Array<Hash>] available MIDI devices
|
|
18
|
+
def available_devices(backend: nil)
|
|
19
|
+
midi_backend = backend || load_backend
|
|
20
|
+
return [] unless midi_backend
|
|
21
|
+
|
|
22
|
+
midi_backend::Input.all.each_with_index.map do |device, index|
|
|
23
|
+
{
|
|
24
|
+
id: extract_device_id(device, index),
|
|
25
|
+
name: extract_device_name(device)
|
|
26
|
+
}
|
|
27
|
+
end
|
|
28
|
+
rescue StandardError
|
|
29
|
+
[]
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def load_backend
|
|
35
|
+
require "unimidi"
|
|
36
|
+
UniMIDI
|
|
37
|
+
rescue LoadError
|
|
38
|
+
nil
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def extract_device_id(device, fallback)
|
|
42
|
+
return device.device_id if device.respond_to?(:device_id)
|
|
43
|
+
fallback
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def extract_device_name(device)
|
|
47
|
+
return device.name if device.respond_to?(:name) && device.name
|
|
48
|
+
"unknown-midi-device"
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# @param device [Integer, String, Symbol, nil]
|
|
53
|
+
# @param backend [Module, nil]
|
|
54
|
+
# @param poll_interval [Float]
|
|
55
|
+
def initialize(device: nil, backend: nil, poll_interval: DEFAULT_POLL_INTERVAL)
|
|
56
|
+
@device = device
|
|
57
|
+
@backend = backend || self.class.send(:load_backend)
|
|
58
|
+
@poll_interval = Float(poll_interval)
|
|
59
|
+
@running = false
|
|
60
|
+
@thread = nil
|
|
61
|
+
@input = nil
|
|
62
|
+
@events = Queue.new
|
|
63
|
+
@callback = nil
|
|
64
|
+
@last_error = nil
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
attr_reader :last_error
|
|
68
|
+
|
|
69
|
+
# @yieldparam event [Vizcore::Audio::MidiInput::Event]
|
|
70
|
+
# @return [Vizcore::Audio::MidiInput]
|
|
71
|
+
def start(&callback)
|
|
72
|
+
return self if running?
|
|
73
|
+
|
|
74
|
+
@callback = callback if block_given?
|
|
75
|
+
@input = open_input
|
|
76
|
+
return self unless @input
|
|
77
|
+
|
|
78
|
+
@running = true
|
|
79
|
+
@thread = Thread.new { consume_loop }
|
|
80
|
+
self
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# @return [Vizcore::Audio::MidiInput]
|
|
84
|
+
def stop
|
|
85
|
+
@running = false
|
|
86
|
+
join_thread
|
|
87
|
+
close_input
|
|
88
|
+
self
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# @return [Boolean]
|
|
92
|
+
def running?
|
|
93
|
+
@running
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# @param max [Integer, nil]
|
|
97
|
+
# @return [Array<Vizcore::Audio::MidiInput::Event>]
|
|
98
|
+
def poll(max = nil)
|
|
99
|
+
limit = max ? Integer(max) : nil
|
|
100
|
+
result = []
|
|
101
|
+
|
|
102
|
+
while limit.nil? || result.length < limit
|
|
103
|
+
begin
|
|
104
|
+
result << @events.pop(true)
|
|
105
|
+
rescue ThreadError
|
|
106
|
+
break
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
result
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
private
|
|
114
|
+
|
|
115
|
+
def open_input
|
|
116
|
+
return nil unless @backend
|
|
117
|
+
|
|
118
|
+
devices = @backend::Input.all
|
|
119
|
+
return nil if devices.empty?
|
|
120
|
+
|
|
121
|
+
device = select_device(devices)
|
|
122
|
+
unless device
|
|
123
|
+
@last_error = AudioSourceError.new("MIDI device not found: #{@device}")
|
|
124
|
+
return nil
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
device.respond_to?(:open) ? device.open : device
|
|
128
|
+
rescue StandardError => e
|
|
129
|
+
@last_error = AudioSourceError.new("MIDI open failed: #{e.message}")
|
|
130
|
+
nil
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def select_device(devices)
|
|
134
|
+
return devices.first if @device.nil? || @device == :default || @device == :first
|
|
135
|
+
return devices[@device] if @device.is_a?(Integer)
|
|
136
|
+
|
|
137
|
+
needle = @device.to_s.downcase
|
|
138
|
+
devices.find do |device|
|
|
139
|
+
name = device.respond_to?(:name) ? device.name.to_s.downcase : ""
|
|
140
|
+
id = device.respond_to?(:device_id) ? device.device_id.to_s.downcase : ""
|
|
141
|
+
name == needle || id == needle
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def consume_loop
|
|
146
|
+
while @running
|
|
147
|
+
raw_message = read_message
|
|
148
|
+
unless raw_message
|
|
149
|
+
sleep(@poll_interval)
|
|
150
|
+
next
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
event = parse_message(raw_message)
|
|
154
|
+
next unless event
|
|
155
|
+
|
|
156
|
+
@events << event
|
|
157
|
+
@callback&.call(event)
|
|
158
|
+
end
|
|
159
|
+
rescue StandardError => e
|
|
160
|
+
@last_error = AudioSourceError.new("MIDI consume loop failed: #{e.message}")
|
|
161
|
+
@running = false
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def read_message
|
|
165
|
+
return nil unless @input
|
|
166
|
+
return @input.gets if @input.respond_to?(:gets)
|
|
167
|
+
return @input.read if @input.respond_to?(:read)
|
|
168
|
+
|
|
169
|
+
nil
|
|
170
|
+
rescue StandardError => e
|
|
171
|
+
@last_error = AudioSourceError.new("MIDI read failed: #{e.message}")
|
|
172
|
+
nil
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def parse_message(raw_message)
|
|
176
|
+
bytes = normalize_message(raw_message)
|
|
177
|
+
return nil if bytes.empty?
|
|
178
|
+
|
|
179
|
+
status = bytes[0]
|
|
180
|
+
command = status & 0xF0
|
|
181
|
+
channel = status & 0x0F
|
|
182
|
+
data1 = bytes[1] || 0
|
|
183
|
+
data2 = bytes[2] || 0
|
|
184
|
+
|
|
185
|
+
Event.new(
|
|
186
|
+
type: detect_event_type(command, data2),
|
|
187
|
+
channel: channel,
|
|
188
|
+
data1: data1,
|
|
189
|
+
data2: data2,
|
|
190
|
+
raw: bytes,
|
|
191
|
+
timestamp: Time.now.to_f
|
|
192
|
+
)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def normalize_message(raw_message)
|
|
196
|
+
values =
|
|
197
|
+
if raw_message.respond_to?(:data)
|
|
198
|
+
Array(raw_message.data)
|
|
199
|
+
else
|
|
200
|
+
Array(raw_message)
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
values.map { |value| Integer(value) & 0xFF }
|
|
204
|
+
rescue StandardError => e
|
|
205
|
+
@last_error = AudioSourceError.new("MIDI message parse failed: #{e.message}")
|
|
206
|
+
[]
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def detect_event_type(command, data2)
|
|
210
|
+
case command
|
|
211
|
+
when 0x80
|
|
212
|
+
:note_off
|
|
213
|
+
when 0x90
|
|
214
|
+
data2.zero? ? :note_off : :note_on
|
|
215
|
+
when 0xB0
|
|
216
|
+
:control_change
|
|
217
|
+
when 0xC0
|
|
218
|
+
:program_change
|
|
219
|
+
else
|
|
220
|
+
:unknown
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def join_thread
|
|
225
|
+
thread = @thread
|
|
226
|
+
@thread = nil
|
|
227
|
+
return unless thread
|
|
228
|
+
|
|
229
|
+
thread.join(0.3)
|
|
230
|
+
thread.kill if thread.alive?
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def close_input
|
|
234
|
+
input = @input
|
|
235
|
+
@input = nil
|
|
236
|
+
return unless input
|
|
237
|
+
return unless input.respond_to?(:close)
|
|
238
|
+
|
|
239
|
+
input.close
|
|
240
|
+
rescue StandardError => e
|
|
241
|
+
@last_error = AudioSourceError.new("MIDI close failed: #{e.message}")
|
|
242
|
+
nil
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
end
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Vizcore
|
|
4
|
+
module Audio
|
|
5
|
+
# PortAudio FFI bridge for microphone stream access and device discovery.
|
|
6
|
+
module PortAudioFFI
|
|
7
|
+
# Runtime wrapper for an opened PortAudio input stream.
|
|
8
|
+
class Stream
|
|
9
|
+
# @param mod [Module] ffi-bound PortAudio module
|
|
10
|
+
# @param pointer [FFI::Pointer] native stream pointer
|
|
11
|
+
# @param channels [Integer] input channel count
|
|
12
|
+
def initialize(mod:, pointer:, channels:)
|
|
13
|
+
@mod = mod
|
|
14
|
+
@pointer = pointer
|
|
15
|
+
@channels = channels
|
|
16
|
+
@started = false
|
|
17
|
+
@closed = false
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# @return [Boolean] true when stream start succeeded
|
|
21
|
+
def start
|
|
22
|
+
return true if @started
|
|
23
|
+
return false if @closed
|
|
24
|
+
|
|
25
|
+
result = @mod.Pa_StartStream(@pointer)
|
|
26
|
+
return false unless ok?(result)
|
|
27
|
+
|
|
28
|
+
@started = true
|
|
29
|
+
true
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# @param frame_size [Integer]
|
|
33
|
+
# @return [Array<Float>] mono samples or silence on failure
|
|
34
|
+
def read(frame_size)
|
|
35
|
+
frames = Integer(frame_size)
|
|
36
|
+
return Array.new(frames, 0.0) unless @started
|
|
37
|
+
|
|
38
|
+
buffer = ffi_module::MemoryPointer.new(:float, frames * @channels)
|
|
39
|
+
result = @mod.Pa_ReadStream(@pointer, buffer, frames)
|
|
40
|
+
return Array.new(frames, 0.0) unless ok?(result)
|
|
41
|
+
|
|
42
|
+
samples = buffer.read_array_of_float(frames * @channels)
|
|
43
|
+
return samples if @channels == 1
|
|
44
|
+
|
|
45
|
+
downmix(samples, frames)
|
|
46
|
+
rescue StandardError
|
|
47
|
+
Array.new(frames, 0.0)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# @return [Boolean] true when stop call succeeded
|
|
51
|
+
def stop
|
|
52
|
+
return true if @closed || !@started
|
|
53
|
+
|
|
54
|
+
result = @mod.Pa_StopStream(@pointer)
|
|
55
|
+
@started = false if ok?(result)
|
|
56
|
+
ok?(result)
|
|
57
|
+
rescue StandardError
|
|
58
|
+
false
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# @return [void]
|
|
62
|
+
def close
|
|
63
|
+
return if @closed
|
|
64
|
+
|
|
65
|
+
stop
|
|
66
|
+
@mod.Pa_CloseStream(@pointer) unless @pointer.nil? || @pointer.null?
|
|
67
|
+
@closed = true
|
|
68
|
+
rescue StandardError
|
|
69
|
+
@closed = true
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def downmix(samples, frames)
|
|
75
|
+
Array.new(frames) do |frame|
|
|
76
|
+
offset = frame * @channels
|
|
77
|
+
chunk = samples[offset, @channels]
|
|
78
|
+
chunk.sum / @channels.to_f
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def ok?(result)
|
|
83
|
+
result == self.class.pa_no_error
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def ffi_module
|
|
87
|
+
self.class.ffi_module
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
class << self
|
|
91
|
+
# @return [Module] loaded ffi module
|
|
92
|
+
def ffi_module
|
|
93
|
+
require "ffi"
|
|
94
|
+
FFI
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# @return [Integer]
|
|
98
|
+
def pa_no_error
|
|
99
|
+
0
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
extend self
|
|
105
|
+
|
|
106
|
+
# Default mono channel count.
|
|
107
|
+
DEFAULT_CHANNELS = 1
|
|
108
|
+
# PortAudio no-error status code.
|
|
109
|
+
PA_NO_ERROR = 0
|
|
110
|
+
# PortAudio float sample format code.
|
|
111
|
+
PA_FLOAT_32 = 0x0000_0001
|
|
112
|
+
|
|
113
|
+
# @return [Boolean] true when PortAudio native library can be loaded
|
|
114
|
+
def available?
|
|
115
|
+
!ffi_module.nil?
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# @return [Array<Hash>] available input device descriptors
|
|
119
|
+
def input_devices
|
|
120
|
+
mod = ffi_module
|
|
121
|
+
return [] unless mod
|
|
122
|
+
return [] unless ok?(mod.Pa_Initialize)
|
|
123
|
+
|
|
124
|
+
count = mod.Pa_GetDeviceCount
|
|
125
|
+
return [] if count <= 0
|
|
126
|
+
|
|
127
|
+
count.times.filter_map do |index|
|
|
128
|
+
pointer = mod.Pa_GetDeviceInfo(index)
|
|
129
|
+
next if pointer.null?
|
|
130
|
+
|
|
131
|
+
info = mod::DeviceInfo.new(pointer)
|
|
132
|
+
next unless info[:maxInputChannels].positive?
|
|
133
|
+
|
|
134
|
+
{
|
|
135
|
+
index: index,
|
|
136
|
+
name: info[:name].read_string,
|
|
137
|
+
max_input_channels: info[:maxInputChannels],
|
|
138
|
+
default_sample_rate: info[:defaultSampleRate].to_f
|
|
139
|
+
}
|
|
140
|
+
end
|
|
141
|
+
ensure
|
|
142
|
+
mod&.Pa_Terminate
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# @param sample_rate [Float]
|
|
146
|
+
# @param channels [Integer]
|
|
147
|
+
# @param frames_per_buffer [Integer]
|
|
148
|
+
# @return [Vizcore::Audio::PortAudioFFI::Stream, nil]
|
|
149
|
+
def open_default_input_stream(sample_rate:, channels: DEFAULT_CHANNELS, frames_per_buffer: 1024)
|
|
150
|
+
mod = ffi_module
|
|
151
|
+
return nil unless mod
|
|
152
|
+
return nil unless ok?(mod.Pa_Initialize)
|
|
153
|
+
|
|
154
|
+
stream_ptr_ptr = ffi::MemoryPointer.new(:pointer)
|
|
155
|
+
|
|
156
|
+
result = mod.Pa_OpenDefaultStream(
|
|
157
|
+
stream_ptr_ptr,
|
|
158
|
+
Integer(channels),
|
|
159
|
+
0,
|
|
160
|
+
PA_FLOAT_32,
|
|
161
|
+
Float(sample_rate),
|
|
162
|
+
Integer(frames_per_buffer),
|
|
163
|
+
nil,
|
|
164
|
+
nil
|
|
165
|
+
)
|
|
166
|
+
return terminate_with_nil(mod) unless ok?(result)
|
|
167
|
+
|
|
168
|
+
stream_pointer = stream_ptr_ptr.read_pointer
|
|
169
|
+
return terminate_with_nil(mod) if stream_pointer.null?
|
|
170
|
+
|
|
171
|
+
Stream.new(mod: mod, pointer: stream_pointer, channels: Integer(channels))
|
|
172
|
+
rescue StandardError
|
|
173
|
+
mod&.Pa_Terminate
|
|
174
|
+
nil
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# @param stream [Vizcore::Audio::PortAudioFFI::Stream, nil]
|
|
178
|
+
# @return [nil]
|
|
179
|
+
def close_stream(stream)
|
|
180
|
+
stream&.close
|
|
181
|
+
ffi_module&.Pa_Terminate
|
|
182
|
+
rescue StandardError
|
|
183
|
+
nil
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
private
|
|
187
|
+
|
|
188
|
+
def terminate_with_nil(mod)
|
|
189
|
+
mod.Pa_Terminate
|
|
190
|
+
nil
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def ffi
|
|
194
|
+
@ffi ||= begin
|
|
195
|
+
require "ffi"
|
|
196
|
+
FFI
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def ok?(result)
|
|
201
|
+
result == PA_NO_ERROR
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def ffi_module
|
|
205
|
+
return @ffi_module if defined?(@ffi_module)
|
|
206
|
+
|
|
207
|
+
@ffi_module = build_ffi_module
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def build_ffi_module
|
|
211
|
+
mod = Module.new
|
|
212
|
+
mod.extend(ffi::Library)
|
|
213
|
+
mod.ffi_lib("portaudio")
|
|
214
|
+
|
|
215
|
+
device_info = Class.new(ffi::Struct)
|
|
216
|
+
device_info.layout :structVersion, :int,
|
|
217
|
+
:name, :pointer,
|
|
218
|
+
:hostApi, :int,
|
|
219
|
+
:maxInputChannels, :int,
|
|
220
|
+
:maxOutputChannels, :int,
|
|
221
|
+
:defaultLowInputLatency, :double,
|
|
222
|
+
:defaultLowOutputLatency, :double,
|
|
223
|
+
:defaultHighInputLatency, :double,
|
|
224
|
+
:defaultHighOutputLatency, :double,
|
|
225
|
+
:defaultSampleRate, :double
|
|
226
|
+
mod.const_set(:DeviceInfo, device_info)
|
|
227
|
+
|
|
228
|
+
mod.attach_function :Pa_Initialize, [], :int
|
|
229
|
+
mod.attach_function :Pa_Terminate, [], :int
|
|
230
|
+
mod.attach_function :Pa_GetDeviceCount, [], :int
|
|
231
|
+
mod.attach_function :Pa_GetDeviceInfo, [:int], :pointer
|
|
232
|
+
mod.attach_function :Pa_OpenDefaultStream, [:pointer, :int, :int, :ulong, :double, :ulong, :pointer, :pointer], :int
|
|
233
|
+
mod.attach_function :Pa_StartStream, [:pointer], :int
|
|
234
|
+
mod.attach_function :Pa_StopStream, [:pointer], :int
|
|
235
|
+
mod.attach_function :Pa_CloseStream, [:pointer], :int
|
|
236
|
+
mod.attach_function :Pa_ReadStream, [:pointer, :pointer, :ulong], :int
|
|
237
|
+
mod
|
|
238
|
+
rescue LoadError, ffi::NotFoundError, StandardError
|
|
239
|
+
nil
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "thread"
|
|
4
|
+
|
|
5
|
+
module Vizcore
|
|
6
|
+
module Audio
|
|
7
|
+
# Thread-safe circular buffer for recent audio samples.
|
|
8
|
+
class RingBuffer
|
|
9
|
+
attr_reader :capacity
|
|
10
|
+
|
|
11
|
+
# @param capacity [Integer]
|
|
12
|
+
def initialize(capacity)
|
|
13
|
+
raise ArgumentError, "capacity must be positive" unless capacity.to_i.positive?
|
|
14
|
+
|
|
15
|
+
@capacity = Integer(capacity)
|
|
16
|
+
@buffer = Array.new(@capacity, 0.0)
|
|
17
|
+
@write_index = 0
|
|
18
|
+
@size = 0
|
|
19
|
+
@mutex = Mutex.new
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# @param samples [Array<Numeric>]
|
|
23
|
+
# @return [void]
|
|
24
|
+
def write(samples)
|
|
25
|
+
normalized = normalize_samples(samples)
|
|
26
|
+
return if normalized.empty?
|
|
27
|
+
|
|
28
|
+
@mutex.synchronize do
|
|
29
|
+
normalized.each do |sample|
|
|
30
|
+
@buffer[@write_index] = sample
|
|
31
|
+
@write_index = (@write_index + 1) % @capacity
|
|
32
|
+
@size += 1 if @size < @capacity
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @param sample [Numeric]
|
|
38
|
+
# @return [void]
|
|
39
|
+
def push(sample)
|
|
40
|
+
write([sample])
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# @param count [Integer, nil]
|
|
44
|
+
# @return [Array<Float>] newest values first-in-order
|
|
45
|
+
def latest(count = nil)
|
|
46
|
+
@mutex.synchronize do
|
|
47
|
+
return [] if @size.zero?
|
|
48
|
+
|
|
49
|
+
requested = count ? Integer(count) : @size
|
|
50
|
+
return [] if requested <= 0
|
|
51
|
+
|
|
52
|
+
length = [requested, @size].min
|
|
53
|
+
start = (@write_index - length) % @capacity
|
|
54
|
+
|
|
55
|
+
extract_range(start, length)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# @return [Integer]
|
|
60
|
+
def size
|
|
61
|
+
@mutex.synchronize { @size }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# @return [void]
|
|
65
|
+
def clear
|
|
66
|
+
@mutex.synchronize do
|
|
67
|
+
@buffer.fill(0.0)
|
|
68
|
+
@write_index = 0
|
|
69
|
+
@size = 0
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def extract_range(start, length)
|
|
76
|
+
if start + length <= @capacity
|
|
77
|
+
@buffer[start, length].dup
|
|
78
|
+
else
|
|
79
|
+
tail = @buffer[start, @capacity - start]
|
|
80
|
+
head = @buffer[0, length - tail.length]
|
|
81
|
+
tail + head
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def normalize_samples(samples)
|
|
86
|
+
Array(samples).map { |sample| Float(sample) }
|
|
87
|
+
rescue ArgumentError, TypeError
|
|
88
|
+
raise ArgumentError, "samples must be numeric"
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Vizcore
|
|
4
|
+
# Audio input/runtime namespace.
|
|
5
|
+
module Audio
|
|
6
|
+
end
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
require_relative "audio/base_input"
|
|
10
|
+
require_relative "audio/dummy_sine_input"
|
|
11
|
+
require_relative "audio/file_input"
|
|
12
|
+
require_relative "audio/input_manager"
|
|
13
|
+
require_relative "audio/mic_input"
|
|
14
|
+
require_relative "audio/midi_input"
|
|
15
|
+
require_relative "audio/portaudio_ffi"
|
|
16
|
+
require_relative "audio/ring_buffer"
|
data/lib/vizcore/cli.rb
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "pathname"
|
|
5
|
+
require "thor"
|
|
6
|
+
require_relative "../vizcore"
|
|
7
|
+
require_relative "audio"
|
|
8
|
+
require_relative "config"
|
|
9
|
+
require_relative "server"
|
|
10
|
+
|
|
11
|
+
module Vizcore
|
|
12
|
+
# Thor-based CLI entrypoint for Vizcore.
|
|
13
|
+
class CLI < Thor
|
|
14
|
+
package_name "vizcore"
|
|
15
|
+
|
|
16
|
+
# Exit with non-zero status when a Thor command fails.
|
|
17
|
+
#
|
|
18
|
+
# @return [Boolean]
|
|
19
|
+
def self.exit_on_failure?
|
|
20
|
+
true
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
default_command :help
|
|
24
|
+
|
|
25
|
+
desc "start SCENE_FILE", "Start vizcore HTTP/WebSocket server"
|
|
26
|
+
option :host, type: :string, default: Config::DEFAULT_HOST, desc: "Bind host"
|
|
27
|
+
option :port, type: :numeric, default: Config::DEFAULT_PORT, desc: "Bind port"
|
|
28
|
+
option :audio_source, type: :string, default: Config::DEFAULT_AUDIO_SOURCE.to_s, desc: "Audio source: mic, file, dummy"
|
|
29
|
+
option :audio_file, type: :string, desc: "Path to audio file used when --audio-source file (wav/mp3/flac)"
|
|
30
|
+
# Start the Vizcore server with the given scene file.
|
|
31
|
+
#
|
|
32
|
+
# @param scene_file [String] path to a Ruby scene DSL file
|
|
33
|
+
# @raise [Thor::Error] when CLI arguments are invalid
|
|
34
|
+
# @return [void]
|
|
35
|
+
def start(scene_file)
|
|
36
|
+
config = Config.new(
|
|
37
|
+
scene_file: scene_file,
|
|
38
|
+
host: options.fetch(:host),
|
|
39
|
+
port: options.fetch(:port),
|
|
40
|
+
audio_source: options.fetch(:audio_source),
|
|
41
|
+
audio_file: options[:audio_file]
|
|
42
|
+
)
|
|
43
|
+
Server::Runner.new(config).run
|
|
44
|
+
rescue ArgumentError => e
|
|
45
|
+
raise Thor::Error, e.message
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
desc "new NAME", "Create a starter project scaffold"
|
|
49
|
+
# Generate a new Vizcore project scaffold.
|
|
50
|
+
#
|
|
51
|
+
# @param name [String] directory name for the new project
|
|
52
|
+
# @return [void]
|
|
53
|
+
def new(name)
|
|
54
|
+
root = Pathname.new(name).expand_path
|
|
55
|
+
FileUtils.mkdir_p(root.join("scenes"))
|
|
56
|
+
FileUtils.mkdir_p(root.join("shaders"))
|
|
57
|
+
|
|
58
|
+
write_template("project_readme.md", root.join("README.md"), project_name: name)
|
|
59
|
+
write_template("basic_scene.rb", root.join("scenes", "basic.rb"), project_name: name)
|
|
60
|
+
write_template("intro_drop_scene.rb", root.join("scenes", "intro_drop.rb"), project_name: name)
|
|
61
|
+
write_template("midi_control_scene.rb", root.join("scenes", "midi_control.rb"), project_name: name)
|
|
62
|
+
write_template("custom_shader_scene.rb", root.join("scenes", "custom_shader.rb"), project_name: name)
|
|
63
|
+
write_template("custom_wave.frag", root.join("shaders", "custom_wave.frag"), project_name: name)
|
|
64
|
+
|
|
65
|
+
say("Created project scaffold: #{root}")
|
|
66
|
+
say("Next: cd #{name} && vizcore start scenes/basic.rb")
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
desc "devices [TYPE]", "Show available devices (audio or midi)"
|
|
70
|
+
# Print audio and/or MIDI devices detected by the runtime.
|
|
71
|
+
#
|
|
72
|
+
# @param type [String, nil] `audio`, `midi`, or nil for both
|
|
73
|
+
# @raise [Thor::Error] when an unknown type is provided
|
|
74
|
+
# @return [void]
|
|
75
|
+
def devices(type = nil)
|
|
76
|
+
case type
|
|
77
|
+
when nil
|
|
78
|
+
print_audio_devices
|
|
79
|
+
print_midi_devices
|
|
80
|
+
when "audio"
|
|
81
|
+
print_audio_devices
|
|
82
|
+
when "midi"
|
|
83
|
+
print_midi_devices
|
|
84
|
+
else
|
|
85
|
+
raise Thor::Error, "Unknown type: #{type}. Use `audio` or `midi`."
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
private
|
|
90
|
+
|
|
91
|
+
def write_template(template_name, destination, project_name:)
|
|
92
|
+
template_path = Vizcore.templates_root.join(template_name)
|
|
93
|
+
body = template_path.read.gsub("{{project_name}}", project_name)
|
|
94
|
+
destination.write(body)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def print_audio_devices
|
|
98
|
+
say("Audio devices:")
|
|
99
|
+
Vizcore::Audio::InputManager.available_audio_devices.each do |device|
|
|
100
|
+
index = device[:index]
|
|
101
|
+
name = device[:name]
|
|
102
|
+
channels = device[:max_input_channels]
|
|
103
|
+
sample_rate = device[:default_sample_rate]
|
|
104
|
+
say(" - #{index}: #{name} (inputs=#{channels}, rate=#{sample_rate})")
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def print_midi_devices
|
|
109
|
+
say("MIDI devices:")
|
|
110
|
+
Vizcore::Audio::InputManager.available_midi_devices.each do |device|
|
|
111
|
+
say(" - #{device[:id]}: #{device[:name]}")
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|