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,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vizcore
4
+ module Analysis
5
+ # End-to-end analysis pipeline from PCM samples to renderer-ready features.
6
+ class Pipeline
7
+ attr_reader :fft_processor, :band_splitter, :beat_detector, :bpm_estimator, :smoother
8
+
9
+ # @param sample_rate [Integer]
10
+ # @param fft_size [Integer]
11
+ # @param window [Symbol]
12
+ # @param beat_detector [Vizcore::Analysis::BeatDetector, nil]
13
+ # @param bpm_estimator [Vizcore::Analysis::BPMEstimator, nil]
14
+ # @param smoother [Vizcore::Analysis::Smoother, nil]
15
+ def initialize(sample_rate: 44_100, fft_size: 1024, window: :hamming, beat_detector: nil, bpm_estimator: nil, smoother: nil)
16
+ @fft_processor = FFTProcessor.new(sample_rate: sample_rate, fft_size: fft_size, window: window)
17
+ @band_splitter = BandSplitter.new(sample_rate: sample_rate, fft_size: fft_size)
18
+ @beat_detector = beat_detector || BeatDetector.new
19
+ frame_rate = sample_rate.to_f / fft_size.to_f
20
+ @bpm_estimator = bpm_estimator || BPMEstimator.new(frame_rate: frame_rate)
21
+ @smoother = smoother || Smoother.new(alpha: 0.35)
22
+ end
23
+
24
+ # @param samples [Array<Numeric>] audio frame samples
25
+ # @return [Hash] normalized analysis payload consumed by frame broadcaster
26
+ def call(samples)
27
+ fft = @fft_processor.call(samples)
28
+ bands = @band_splitter.call(fft[:magnitudes])
29
+ beat = @beat_detector.call(samples)
30
+ bpm = @bpm_estimator.call(beat: beat[:beat])
31
+ amplitude = rms(samples)
32
+ spectrum_preview = preview_spectrum(fft[:magnitudes])
33
+
34
+ {
35
+ amplitude: @smoother.smooth(:amplitude, amplitude),
36
+ bands: @smoother.smooth_hash(bands, namespace: :bands),
37
+ fft: @smoother.smooth_array(spectrum_preview, namespace: :fft),
38
+ beat: beat[:beat],
39
+ beat_count: beat[:beat_count],
40
+ bpm: @smoother.smooth(:bpm, bpm, alpha: 0.2),
41
+ peak_frequency: fft[:peak_frequency]
42
+ }
43
+ end
44
+
45
+ private
46
+
47
+ def preview_spectrum(magnitudes, bins: 32)
48
+ values = Array(magnitudes)
49
+ return Array.new(bins, 0.0) if values.empty?
50
+
51
+ step = [values.length / bins, 1].max
52
+
53
+ Array.new(bins) do |index|
54
+ window = values[index * step, step]
55
+ next 0.0 if window.nil? || window.empty?
56
+
57
+ (window.sum / window.length.to_f).clamp(0.0, 1.0)
58
+ end
59
+ end
60
+
61
+ def rms(samples)
62
+ values = Array(samples).map { |sample| Float(sample) }
63
+ return 0.0 if values.empty?
64
+
65
+ sum = values.reduce(0.0) { |acc, sample| acc + sample * sample }
66
+ Math.sqrt(sum / values.length.to_f).clamp(0.0, 1.0)
67
+ rescue ArgumentError, TypeError
68
+ 0.0
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vizcore
4
+ module Analysis
5
+ # Utility for smoothing scalar, hash, and array signals with EMA.
6
+ class Smoother
7
+ # @param alpha [Float] default smoothing coefficient (0.0..1.0)
8
+ def initialize(alpha: 0.35)
9
+ @alpha = normalize_alpha(alpha)
10
+ @states = {}
11
+ end
12
+
13
+ # Smooth one scalar value under a key.
14
+ #
15
+ # @param key [Object] state key
16
+ # @param value [Numeric]
17
+ # @param alpha [Float]
18
+ # @return [Float]
19
+ def smooth(key, value, alpha: @alpha)
20
+ normalized = Float(value)
21
+ step = normalize_alpha(alpha)
22
+ previous = @states[key]
23
+ current = previous.nil? ? normalized : previous + (normalized - previous) * step
24
+ @states[key] = current
25
+ rescue ArgumentError, TypeError
26
+ 0.0
27
+ end
28
+
29
+ # Smooth each value in a hash independently.
30
+ #
31
+ # @param values [Hash]
32
+ # @param namespace [Object]
33
+ # @param alpha [Float]
34
+ # @return [Hash]
35
+ def smooth_hash(values, namespace:, alpha: @alpha)
36
+ Hash(values).each_with_object({}) do |(entry_key, value), result|
37
+ result[entry_key] = smooth([namespace, entry_key], value, alpha: alpha)
38
+ end
39
+ end
40
+
41
+ # Smooth each value in an array independently.
42
+ #
43
+ # @param values [Array]
44
+ # @param namespace [Object]
45
+ # @param alpha [Float]
46
+ # @return [Array<Float>]
47
+ def smooth_array(values, namespace:, alpha: @alpha)
48
+ Array(values).each_with_index.map do |value, index|
49
+ smooth([namespace, index], value, alpha: alpha)
50
+ end
51
+ end
52
+
53
+ # Reset smoothing state.
54
+ #
55
+ # @param namespace [Object, nil] when provided, resets keys under this namespace only
56
+ # @return [void]
57
+ def reset(namespace = nil)
58
+ return @states.clear if namespace.nil?
59
+
60
+ @states.delete_if do |key, _value|
61
+ key.is_a?(Array) && key.first == namespace
62
+ end
63
+ end
64
+
65
+ private
66
+
67
+ def normalize_alpha(value)
68
+ Float(value).clamp(0.0, 1.0)
69
+ rescue ArgumentError, TypeError
70
+ 0.35
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vizcore
4
+ # Analysis components used to transform raw audio into visual parameters.
5
+ module Analysis
6
+ end
7
+ end
8
+
9
+ require_relative "analysis/band_splitter"
10
+ require_relative "analysis/beat_detector"
11
+ require_relative "analysis/bpm_estimator"
12
+ require_relative "analysis/fft_processor"
13
+ require_relative "analysis/pipeline"
14
+ require_relative "analysis/smoother"
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vizcore
4
+ module Audio
5
+ # Base class for audio inputs used by {Vizcore::Audio::InputManager}.
6
+ class BaseInput
7
+ attr_reader :sample_rate
8
+
9
+ # @param sample_rate [Integer]
10
+ def initialize(sample_rate: 44_100)
11
+ @sample_rate = Integer(sample_rate)
12
+ @running = false
13
+ end
14
+
15
+ # @return [Vizcore::Audio::BaseInput]
16
+ def start
17
+ @running = true
18
+ self
19
+ end
20
+
21
+ # @return [Vizcore::Audio::BaseInput]
22
+ def stop
23
+ @running = false
24
+ self
25
+ end
26
+
27
+ # @return [Boolean]
28
+ def running?
29
+ @running
30
+ end
31
+
32
+ # @param frame_size [Integer]
33
+ # @return [Array<Float>] silence by default
34
+ def read(frame_size)
35
+ Array.new(Integer(frame_size), 0.0)
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_input"
4
+
5
+ module Vizcore
6
+ module Audio
7
+ # Deterministic sine-wave generator used as fallback/dummy source.
8
+ class DummySineInput < BaseInput
9
+ # Default oscillator amplitude.
10
+ DEFAULT_AMPLITUDE = 0.45
11
+ # Default oscillator frequency in Hz.
12
+ DEFAULT_FREQUENCY = 220.0
13
+
14
+ # @param sample_rate [Integer]
15
+ # @param frequency [Float] sine frequency in Hz
16
+ # @param amplitude [Float] clamped to 0.0..1.0
17
+ def initialize(sample_rate: 44_100, frequency: DEFAULT_FREQUENCY, amplitude: DEFAULT_AMPLITUDE)
18
+ super(sample_rate: sample_rate)
19
+ @frequency = Float(frequency)
20
+ @amplitude = Float(amplitude).clamp(0.0, 1.0)
21
+ @phase = 0.0
22
+ end
23
+
24
+ # @param frame_size [Integer]
25
+ # @return [Array<Float>] generated sine wave samples
26
+ def read(frame_size)
27
+ count = Integer(frame_size)
28
+ return Array.new(count, 0.0) unless running?
29
+
30
+ step = (2.0 * Math::PI * @frequency) / sample_rate
31
+
32
+ Array.new(count) do
33
+ value = Math.sin(@phase) * @amplitude
34
+ @phase += step
35
+ value
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "thread"
5
+ require_relative "../errors"
6
+ require_relative "base_input"
7
+
8
+ module Vizcore
9
+ module Audio
10
+ # File-backed audio input for WAV and ffmpeg-decoded formats.
11
+ class FileInput < BaseInput
12
+ # Supported file extensions.
13
+ SUPPORTED_EXTENSIONS = %w[.wav .mp3 .flac].freeze
14
+ attr_reader :last_error
15
+ attr_reader :stream_sample_rate
16
+
17
+ # @param path [String, Pathname]
18
+ # @param sample_rate [Integer]
19
+ # @param command_runner [#capture3]
20
+ # @param ffmpeg_checker [#call, nil]
21
+ def initialize(path:, sample_rate: 44_100, command_runner: Open3, ffmpeg_checker: nil)
22
+ super(sample_rate: sample_rate)
23
+ @path = path
24
+ @command_runner = command_runner
25
+ @ffmpeg_checker = ffmpeg_checker || method(:ffmpeg_available?)
26
+ @cursor = 0
27
+ @last_error = nil
28
+ @stream_sample_rate = sample_rate
29
+ @state_mutex = Mutex.new
30
+ @transport_paused = false
31
+ @samples = load_samples
32
+ end
33
+
34
+ # @param frame_size [Integer]
35
+ # @return [Array<Float>] file samples (looped), or silence when unavailable
36
+ def read(frame_size)
37
+ count = Integer(frame_size)
38
+ return Array.new(count, 0.0) unless running?
39
+ return Array.new(count, 0.0) if @samples.empty?
40
+
41
+ @state_mutex.synchronize do
42
+ return Array.new(count, 0.0) if @transport_paused
43
+
44
+ Array.new(count) do
45
+ sample = @samples[@cursor]
46
+ @cursor = (@cursor + 1) % @samples.length
47
+ sample
48
+ end
49
+ end
50
+ end
51
+
52
+ # Synchronize file cursor with an external playback transport (browser audio element).
53
+ #
54
+ # @param playing [Boolean]
55
+ # @param position_seconds [Numeric]
56
+ # @return [Vizcore::Audio::FileInput]
57
+ def sync_transport(playing:, position_seconds:)
58
+ return self if @samples.empty?
59
+
60
+ seconds = Float(position_seconds)
61
+ @state_mutex.synchronize do
62
+ @transport_paused = !playing
63
+ @cursor = seconds_to_cursor(seconds)
64
+ end
65
+ self
66
+ rescue StandardError
67
+ self
68
+ end
69
+
70
+ private
71
+
72
+ def load_samples
73
+ return [] unless @path
74
+ return record_error(AudioSourceError.new("Audio file not found: #{@path}")) unless File.file?(@path)
75
+ return record_error(AudioSourceError.new("Unsupported audio format: #{extension}")) unless SUPPORTED_EXTENSIONS.include?(extension)
76
+
77
+ return load_wav_samples if extension == ".wav"
78
+
79
+ load_compressed_samples
80
+ end
81
+
82
+ def extension
83
+ File.extname(@path).downcase
84
+ end
85
+
86
+ def load_wav_samples
87
+ require "wavefile"
88
+
89
+ samples = []
90
+ WaveFile::Reader.new(@path) do |reader|
91
+ @stream_sample_rate = reader.native_format.sample_rate
92
+
93
+ reader.each_buffer(1024) do |buffer|
94
+ mono = if buffer.channels == 1
95
+ buffer.samples
96
+ else
97
+ buffer.samples.map { |frame| frame.is_a?(Array) ? frame.sum / frame.length.to_f : frame }
98
+ end
99
+ samples.concat(mono.map { |sample| Float(sample) })
100
+ end
101
+ end
102
+ @last_error = nil
103
+ samples
104
+ rescue LoadError => e
105
+ record_error(
106
+ AudioSourceError.new(
107
+ "wavefile gem is required for WAV input: #{e.message}"
108
+ )
109
+ )
110
+ end
111
+
112
+ def load_compressed_samples
113
+ return record_error(AudioSourceError.new("ffmpeg is unavailable")) unless @ffmpeg_checker.call
114
+
115
+ stdout, _stderr, status = @command_runner.capture3(*ffmpeg_decode_command)
116
+ return record_error(AudioSourceError.new("ffmpeg decode failed with non-zero status")) unless status.success?
117
+
118
+ @last_error = nil
119
+ stdout.unpack("e*").map { |sample| Float(sample) }
120
+ rescue StandardError => e
121
+ record_error(AudioSourceError.new("ffmpeg decode failed: #{e.message}"))
122
+ end
123
+
124
+ def ffmpeg_decode_command
125
+ [
126
+ "ffmpeg",
127
+ "-hide_banner",
128
+ "-loglevel",
129
+ "error",
130
+ "-i",
131
+ @path.to_s,
132
+ "-f",
133
+ "f32le",
134
+ "-ac",
135
+ "1",
136
+ "-ar",
137
+ sample_rate.to_s,
138
+ "pipe:1"
139
+ ]
140
+ end
141
+
142
+ def ffmpeg_available?
143
+ system("ffmpeg", "-version", out: File::NULL, err: File::NULL)
144
+ rescue StandardError
145
+ false
146
+ end
147
+
148
+ def record_error(error)
149
+ @last_error = error
150
+ []
151
+ end
152
+
153
+ def seconds_to_cursor(seconds)
154
+ return 0 if @samples.empty?
155
+
156
+ rate = @stream_sample_rate.to_f.positive? ? @stream_sample_rate.to_f : sample_rate.to_f
157
+ index = (seconds * rate).floor
158
+ index %= @samples.length
159
+ index.negative? ? index + @samples.length : index
160
+ end
161
+ end
162
+ end
163
+ end
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "dummy_sine_input"
4
+ require_relative "file_input"
5
+ require_relative "mic_input"
6
+ require_relative "midi_input"
7
+ require_relative "portaudio_ffi"
8
+ require_relative "ring_buffer"
9
+
10
+ module Vizcore
11
+ module Audio
12
+ # High-level coordinator for audio frame capture and ring-buffer storage.
13
+ class InputManager
14
+ # Default analysis/input sample rate.
15
+ DEFAULT_SAMPLE_RATE = 44_100
16
+ # Default samples read per frame.
17
+ DEFAULT_FRAME_SIZE = 1024
18
+ # Default ring buffer capacity in samples.
19
+ DEFAULT_RING_BUFFER_SIZE = 4096
20
+
21
+ attr_reader :frame_size, :sample_rate, :ring_buffer, :source_name
22
+
23
+ # @param source [Symbol, String] input source (`:mic`, `:file`, `:dummy`)
24
+ # @param sample_rate [Integer] sample rate in Hz
25
+ # @param frame_size [Integer] frame size used by capture loop
26
+ # @param ring_buffer_size [Integer] stored sample capacity
27
+ # @param file_path [String, nil] source file path for `:file`
28
+ def initialize(source: :mic, sample_rate: DEFAULT_SAMPLE_RATE, frame_size: DEFAULT_FRAME_SIZE, ring_buffer_size: DEFAULT_RING_BUFFER_SIZE, file_path: nil)
29
+ @source_name = source.to_sym
30
+ @sample_rate = Integer(sample_rate)
31
+ @frame_size = Integer(frame_size)
32
+ @ring_buffer = RingBuffer.new(ring_buffer_size)
33
+ @input = build_input(file_path)
34
+ @sample_rate = resolve_input_sample_rate(@input, fallback: @sample_rate)
35
+ end
36
+
37
+ # @return [Vizcore::Audio::InputManager]
38
+ def start
39
+ @input.start
40
+ self
41
+ end
42
+
43
+ # @return [Vizcore::Audio::InputManager]
44
+ def stop
45
+ @input.stop
46
+ self
47
+ end
48
+
49
+ # @return [Boolean]
50
+ def running?
51
+ @input.running?
52
+ end
53
+
54
+ # Capture one frame from the underlying input and append to ring buffer.
55
+ #
56
+ # @return [Array<Float>]
57
+ def capture_frame(read_size = frame_size)
58
+ count = Integer(read_size)
59
+ samples = @input.read(count)
60
+ ring_buffer.write(samples)
61
+ samples
62
+ end
63
+
64
+ # @param count [Integer]
65
+ # @return [Array<Float>] recent samples from the ring buffer
66
+ def latest_samples(count = frame_size)
67
+ ring_buffer.latest(count)
68
+ end
69
+
70
+ # @param frame_rate [Numeric]
71
+ # @return [Integer] approximate real-time sample count to ingest per render tick
72
+ def realtime_capture_size(frame_rate)
73
+ rate = Float(frame_rate)
74
+ return frame_size unless rate.positive?
75
+
76
+ [(@sample_rate.to_f / rate).round, 1].max
77
+ rescue StandardError
78
+ frame_size
79
+ end
80
+
81
+ # @param playing [Boolean]
82
+ # @param position_seconds [Numeric]
83
+ # @return [void]
84
+ def sync_transport(playing:, position_seconds:)
85
+ return unless @input.respond_to?(:sync_transport)
86
+
87
+ @input.sync_transport(playing: playing, position_seconds: position_seconds)
88
+ end
89
+
90
+ # @return [Array<Hash>] detected audio devices or fallback dummy descriptor
91
+ def self.available_audio_devices
92
+ devices = PortAudioFFI.input_devices
93
+ return devices unless devices.empty?
94
+
95
+ [
96
+ { index: 0, name: "default (dummy fallback)", max_input_channels: 1, default_sample_rate: DEFAULT_SAMPLE_RATE.to_f }
97
+ ]
98
+ end
99
+
100
+ # @return [Array<Hash>] detected MIDI devices or virtual fallback descriptor
101
+ def self.available_midi_devices
102
+ devices = MidiInput.available_devices
103
+ return devices unless devices.empty?
104
+
105
+ [{ id: "virtual-0", name: "virtual-midi (optional dependency: unimidi)" }]
106
+ end
107
+
108
+ private
109
+
110
+ def build_input(file_path)
111
+ case @source_name
112
+ when :mic
113
+ MicInput.new(sample_rate: sample_rate)
114
+ when :file
115
+ FileInput.new(path: file_path, sample_rate: sample_rate)
116
+ when :dummy
117
+ DummySineInput.new(sample_rate: sample_rate)
118
+ else
119
+ raise ArgumentError, "unsupported audio source: #{@source_name}"
120
+ end
121
+ end
122
+
123
+ def resolve_input_sample_rate(input, fallback:)
124
+ return fallback unless input.respond_to?(:stream_sample_rate)
125
+
126
+ rate = input.stream_sample_rate
127
+ Integer(rate)
128
+ rescue StandardError
129
+ fallback
130
+ end
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_input"
4
+ require_relative "dummy_sine_input"
5
+ require_relative "../errors"
6
+ require_relative "portaudio_ffi"
7
+
8
+ module Vizcore
9
+ module Audio
10
+ # Microphone input using PortAudio, with automatic fallback to dummy source.
11
+ class MicInput < BaseInput
12
+ attr_reader :device, :last_error
13
+
14
+ # @param device [Symbol, String]
15
+ # @param sample_rate [Integer]
16
+ # @param fallback_input [Vizcore::Audio::BaseInput, nil]
17
+ # @param portaudio_backend [Module]
18
+ # @param channels [Integer]
19
+ # @param frames_per_buffer [Integer]
20
+ def initialize(device: :default, sample_rate: 44_100, fallback_input: nil, portaudio_backend: PortAudioFFI, channels: 1, frames_per_buffer: 1024)
21
+ super(sample_rate: sample_rate)
22
+ @device = device
23
+ @channels = Integer(channels)
24
+ @frames_per_buffer = Integer(frames_per_buffer)
25
+ @fallback_input = fallback_input || DummySineInput.new(sample_rate: sample_rate)
26
+ @portaudio_backend = portaudio_backend
27
+ @stream = nil
28
+ @using_fallback = false
29
+ @last_error = nil
30
+ end
31
+
32
+ # @return [Vizcore::Audio::MicInput]
33
+ def start
34
+ super
35
+ @using_fallback = false
36
+
37
+ @stream = open_stream
38
+ @using_fallback = !@stream
39
+ @fallback_input.start if @using_fallback
40
+ self
41
+ end
42
+
43
+ # @return [Vizcore::Audio::MicInput]
44
+ def stop
45
+ close_stream
46
+ @fallback_input.stop if @using_fallback
47
+ @using_fallback = false
48
+ super
49
+ end
50
+
51
+ # @param frame_size [Integer]
52
+ # @return [Array<Float>] microphone frame or fallback samples
53
+ def read(frame_size)
54
+ count = Integer(frame_size)
55
+ return Array.new(count, 0.0) unless running?
56
+
57
+ if @stream
58
+ samples = @stream.read(count)
59
+ @last_error = nil
60
+ normalize_samples(samples, count)
61
+ else
62
+ @fallback_input.read(count)
63
+ end
64
+ rescue StandardError => e
65
+ @last_error = AudioSourceError.new("Microphone read failed: #{e.message}")
66
+ switch_to_fallback
67
+ @fallback_input.read(count)
68
+ end
69
+
70
+ # @return [Boolean]
71
+ def using_fallback?
72
+ @using_fallback
73
+ end
74
+
75
+ private
76
+
77
+ def open_stream
78
+ stream = @portaudio_backend.open_default_input_stream(
79
+ sample_rate: sample_rate,
80
+ channels: @channels,
81
+ frames_per_buffer: @frames_per_buffer
82
+ )
83
+ return nil unless stream
84
+ return stream if stream.start
85
+
86
+ @portaudio_backend.close_stream(stream)
87
+ nil
88
+ rescue StandardError => e
89
+ @last_error = AudioSourceError.new("Microphone stream open failed: #{e.message}")
90
+ nil
91
+ end
92
+
93
+ def close_stream
94
+ return unless @stream
95
+
96
+ @portaudio_backend.close_stream(@stream)
97
+ ensure
98
+ @stream = nil
99
+ end
100
+
101
+ def switch_to_fallback
102
+ return if @using_fallback
103
+
104
+ close_stream
105
+ @using_fallback = true
106
+ @fallback_input.start
107
+ end
108
+
109
+ def normalize_samples(samples, expected_count)
110
+ normalized = Array(samples).map { |sample| Float(sample) }
111
+ if normalized.length < expected_count
112
+ normalized + Array.new(expected_count - normalized.length, 0.0)
113
+ else
114
+ normalized.first(expected_count)
115
+ end
116
+ rescue ArgumentError, TypeError
117
+ Array.new(expected_count, 0.0)
118
+ end
119
+ end
120
+ end
121
+ end