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.
Files changed (71) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +170 -0
  4. data/docs/GETTING_STARTED.md +105 -0
  5. data/examples/assets/complex_demo_loop.wav +0 -0
  6. data/examples/basic.rb +9 -0
  7. data/examples/complex_audio_showcase.rb +261 -0
  8. data/examples/custom_shader.rb +21 -0
  9. data/examples/file_audio_demo.rb +74 -0
  10. data/examples/intro_drop.rb +38 -0
  11. data/examples/midi_scene_switch.rb +32 -0
  12. data/examples/shaders/custom_wave.frag +30 -0
  13. data/exe/vizcore +6 -0
  14. data/frontend/index.html +148 -0
  15. data/frontend/src/main.js +304 -0
  16. data/frontend/src/renderer/engine.js +135 -0
  17. data/frontend/src/renderer/layer-manager.js +456 -0
  18. data/frontend/src/renderer/shader-manager.js +69 -0
  19. data/frontend/src/shaders/builtins.js +244 -0
  20. data/frontend/src/shaders/post-effects.js +85 -0
  21. data/frontend/src/visuals/geometry.js +66 -0
  22. data/frontend/src/visuals/particle-system.js +148 -0
  23. data/frontend/src/visuals/text-renderer.js +143 -0
  24. data/frontend/src/visuals/vj-effects.js +56 -0
  25. data/frontend/src/websocket-client.js +131 -0
  26. data/lib/vizcore/analysis/band_splitter.rb +63 -0
  27. data/lib/vizcore/analysis/beat_detector.rb +70 -0
  28. data/lib/vizcore/analysis/bpm_estimator.rb +86 -0
  29. data/lib/vizcore/analysis/fft_processor.rb +224 -0
  30. data/lib/vizcore/analysis/fftw_ffi.rb +50 -0
  31. data/lib/vizcore/analysis/pipeline.rb +72 -0
  32. data/lib/vizcore/analysis/smoother.rb +74 -0
  33. data/lib/vizcore/analysis.rb +14 -0
  34. data/lib/vizcore/audio/base_input.rb +39 -0
  35. data/lib/vizcore/audio/dummy_sine_input.rb +40 -0
  36. data/lib/vizcore/audio/file_input.rb +163 -0
  37. data/lib/vizcore/audio/input_manager.rb +133 -0
  38. data/lib/vizcore/audio/mic_input.rb +121 -0
  39. data/lib/vizcore/audio/midi_input.rb +246 -0
  40. data/lib/vizcore/audio/portaudio_ffi.rb +243 -0
  41. data/lib/vizcore/audio/ring_buffer.rb +92 -0
  42. data/lib/vizcore/audio.rb +16 -0
  43. data/lib/vizcore/cli.rb +115 -0
  44. data/lib/vizcore/config.rb +46 -0
  45. data/lib/vizcore/dsl/engine.rb +229 -0
  46. data/lib/vizcore/dsl/file_watcher.rb +108 -0
  47. data/lib/vizcore/dsl/layer_builder.rb +182 -0
  48. data/lib/vizcore/dsl/mapping_resolver.rb +81 -0
  49. data/lib/vizcore/dsl/midi_map_executor.rb +188 -0
  50. data/lib/vizcore/dsl/scene_builder.rb +44 -0
  51. data/lib/vizcore/dsl/shader_source_resolver.rb +71 -0
  52. data/lib/vizcore/dsl/transition_controller.rb +166 -0
  53. data/lib/vizcore/dsl.rb +16 -0
  54. data/lib/vizcore/errors.rb +27 -0
  55. data/lib/vizcore/renderer/frame_scheduler.rb +75 -0
  56. data/lib/vizcore/renderer/scene_serializer.rb +73 -0
  57. data/lib/vizcore/renderer.rb +10 -0
  58. data/lib/vizcore/server/frame_broadcaster.rb +351 -0
  59. data/lib/vizcore/server/rack_app.rb +183 -0
  60. data/lib/vizcore/server/runner.rb +357 -0
  61. data/lib/vizcore/server/websocket_handler.rb +163 -0
  62. data/lib/vizcore/server.rb +12 -0
  63. data/lib/vizcore/templates/basic_scene.rb +10 -0
  64. data/lib/vizcore/templates/custom_shader_scene.rb +22 -0
  65. data/lib/vizcore/templates/custom_wave.frag +31 -0
  66. data/lib/vizcore/templates/intro_drop_scene.rb +40 -0
  67. data/lib/vizcore/templates/midi_control_scene.rb +33 -0
  68. data/lib/vizcore/templates/project_readme.md +35 -0
  69. data/lib/vizcore/version.rb +6 -0
  70. data/lib/vizcore.rb +37 -0
  71. 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"
@@ -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