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,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
|