wavify 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/.serena/.gitignore +1 -0
- data/.serena/memories/project_overview.md +5 -0
- data/.serena/memories/style_and_completion.md +5 -0
- data/.serena/memories/suggested_commands.md +11 -0
- data/.serena/project.yml +126 -0
- data/.simplecov +18 -0
- data/.yardopts +4 -0
- data/CHANGELOG.md +11 -0
- data/LICENSE +21 -0
- data/README.md +196 -0
- data/Rakefile +190 -0
- data/benchmarks/README.md +46 -0
- data/benchmarks/benchmark_helper.rb +112 -0
- data/benchmarks/dsp_effects_benchmark.rb +46 -0
- data/benchmarks/flac_benchmark.rb +74 -0
- data/benchmarks/streaming_memory_benchmark.rb +94 -0
- data/benchmarks/wav_io_benchmark.rb +110 -0
- data/examples/audio_processing.rb +73 -0
- data/examples/cinematic_transition.rb +118 -0
- data/examples/drum_machine.rb +74 -0
- data/examples/format_convert.rb +81 -0
- data/examples/hybrid_arrangement.rb +165 -0
- data/examples/streaming_master_chain.rb +129 -0
- data/examples/synth_pad.rb +42 -0
- data/lib/wavify/audio.rb +483 -0
- data/lib/wavify/codecs/aiff.rb +338 -0
- data/lib/wavify/codecs/base.rb +108 -0
- data/lib/wavify/codecs/flac.rb +1322 -0
- data/lib/wavify/codecs/ogg_vorbis.rb +1447 -0
- data/lib/wavify/codecs/raw.rb +193 -0
- data/lib/wavify/codecs/registry.rb +87 -0
- data/lib/wavify/codecs/wav.rb +459 -0
- data/lib/wavify/core/duration.rb +99 -0
- data/lib/wavify/core/format.rb +133 -0
- data/lib/wavify/core/sample_buffer.rb +216 -0
- data/lib/wavify/core/stream.rb +129 -0
- data/lib/wavify/dsl.rb +537 -0
- data/lib/wavify/dsp/effects/chorus.rb +98 -0
- data/lib/wavify/dsp/effects/compressor.rb +85 -0
- data/lib/wavify/dsp/effects/delay.rb +69 -0
- data/lib/wavify/dsp/effects/distortion.rb +64 -0
- data/lib/wavify/dsp/effects/effect_base.rb +68 -0
- data/lib/wavify/dsp/effects/reverb.rb +112 -0
- data/lib/wavify/dsp/effects.rb +21 -0
- data/lib/wavify/dsp/envelope.rb +97 -0
- data/lib/wavify/dsp/filter.rb +271 -0
- data/lib/wavify/dsp/oscillator.rb +123 -0
- data/lib/wavify/errors.rb +34 -0
- data/lib/wavify/sequencer/engine.rb +278 -0
- data/lib/wavify/sequencer/note_sequence.rb +132 -0
- data/lib/wavify/sequencer/pattern.rb +102 -0
- data/lib/wavify/sequencer/track.rb +298 -0
- data/lib/wavify/sequencer.rb +12 -0
- data/lib/wavify/version.rb +6 -0
- data/lib/wavify.rb +28 -0
- data/tools/fixture_writer.rb +85 -0
- metadata +129 -0
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wavify
|
|
4
|
+
# Digital signal processing primitives.
|
|
5
|
+
module DSP
|
|
6
|
+
# Stateful biquad filter with common factory constructors.
|
|
7
|
+
class Filter
|
|
8
|
+
attr_reader :type, :cutoff, :q, :gain_db
|
|
9
|
+
|
|
10
|
+
# Builds a low-pass filter.
|
|
11
|
+
#
|
|
12
|
+
# @return [Filter]
|
|
13
|
+
def self.lowpass(cutoff:, q: 0.707)
|
|
14
|
+
new(:lowpass, cutoff: cutoff, q: q)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Builds a high-pass filter.
|
|
18
|
+
#
|
|
19
|
+
# @return [Filter]
|
|
20
|
+
def self.highpass(cutoff:, q: 0.707)
|
|
21
|
+
new(:highpass, cutoff: cutoff, q: q)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.bandpass(center:, bandwidth:)
|
|
25
|
+
raise InvalidParameterError, "bandwidth must be positive" unless bandwidth.is_a?(Numeric) && bandwidth.positive?
|
|
26
|
+
|
|
27
|
+
new(:bandpass, cutoff: center, q: center.to_f / bandwidth)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Builds a notch filter.
|
|
31
|
+
#
|
|
32
|
+
# @return [Filter]
|
|
33
|
+
def self.notch(cutoff:, q: 0.707)
|
|
34
|
+
new(:notch, cutoff: cutoff, q: q)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Builds a peaking EQ filter.
|
|
38
|
+
#
|
|
39
|
+
# @return [Filter]
|
|
40
|
+
def self.peaking(cutoff:, q: 1.0, gain_db: 0.0)
|
|
41
|
+
new(:peaking, cutoff: cutoff, q: q, gain_db: gain_db)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Builds a low-shelf EQ filter.
|
|
45
|
+
#
|
|
46
|
+
# @return [Filter]
|
|
47
|
+
def self.lowshelf(cutoff:, gain_db:)
|
|
48
|
+
new(:lowshelf, cutoff: cutoff, gain_db: gain_db)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Builds a high-shelf EQ filter.
|
|
52
|
+
#
|
|
53
|
+
# @return [Filter]
|
|
54
|
+
def self.highshelf(cutoff:, gain_db:)
|
|
55
|
+
new(:highshelf, cutoff: cutoff, gain_db: gain_db)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def initialize(type, cutoff:, q: 0.707, gain_db: 0.0)
|
|
59
|
+
@type = validate_type!(type)
|
|
60
|
+
@cutoff = validate_cutoff!(cutoff)
|
|
61
|
+
@q = validate_q!(q)
|
|
62
|
+
@gain_db = validate_gain!(gain_db)
|
|
63
|
+
|
|
64
|
+
@coefficients = nil
|
|
65
|
+
@coeff_sample_rate = nil
|
|
66
|
+
@channel_states = []
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def apply(buffer)
|
|
70
|
+
raise InvalidParameterError, "buffer must be Core::SampleBuffer" unless buffer.is_a?(Core::SampleBuffer)
|
|
71
|
+
|
|
72
|
+
float_format = buffer.format.with(sample_format: :float, bit_depth: 32)
|
|
73
|
+
float_buffer = buffer.convert(float_format)
|
|
74
|
+
processed = process_interleaved(
|
|
75
|
+
float_buffer.samples,
|
|
76
|
+
sample_rate: float_format.sample_rate,
|
|
77
|
+
channels: float_format.channels
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
Core::SampleBuffer.new(processed, float_format).convert(buffer.format)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def process_sample(sample, sample_rate:, channel: 0)
|
|
84
|
+
raise InvalidParameterError, "sample must be Numeric" unless sample.is_a?(Numeric)
|
|
85
|
+
raise InvalidParameterError, "channel must be a non-negative Integer" unless channel.is_a?(Integer) && channel >= 0
|
|
86
|
+
raise InvalidParameterError, "sample_rate must be a positive Integer" unless sample_rate.is_a?(Integer) && sample_rate.positive?
|
|
87
|
+
|
|
88
|
+
update_coefficients!(sample_rate)
|
|
89
|
+
ensure_channel_states!(channel + 1)
|
|
90
|
+
state = @channel_states[channel]
|
|
91
|
+
x = sample.to_f
|
|
92
|
+
y = compute_biquad(state, x)
|
|
93
|
+
update_state!(state, x, y)
|
|
94
|
+
y
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Clears internal filter state for all channels.
|
|
98
|
+
#
|
|
99
|
+
# @return [void]
|
|
100
|
+
def reset
|
|
101
|
+
@channel_states = []
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
private
|
|
105
|
+
|
|
106
|
+
def process_interleaved(samples, sample_rate:, channels:)
|
|
107
|
+
update_coefficients!(sample_rate)
|
|
108
|
+
ensure_channel_states!(channels)
|
|
109
|
+
|
|
110
|
+
output = Array.new(samples.length)
|
|
111
|
+
samples.each_with_index do |sample, sample_index|
|
|
112
|
+
channel = sample_index % channels
|
|
113
|
+
state = @channel_states[channel]
|
|
114
|
+
x = sample.to_f
|
|
115
|
+
y = compute_biquad(state, x)
|
|
116
|
+
update_state!(state, x, y)
|
|
117
|
+
output[sample_index] = y
|
|
118
|
+
end
|
|
119
|
+
output
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def update_coefficients!(sample_rate)
|
|
123
|
+
return if @coeff_sample_rate == sample_rate && @coefficients
|
|
124
|
+
|
|
125
|
+
@coefficients = coefficients_for(sample_rate)
|
|
126
|
+
@coeff_sample_rate = sample_rate
|
|
127
|
+
reset
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def ensure_channel_states!(channels)
|
|
131
|
+
return if @channel_states.length == channels
|
|
132
|
+
|
|
133
|
+
@channel_states = Array.new(channels) { { x1: 0.0, x2: 0.0, y1: 0.0, y2: 0.0 } }
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def compute_biquad(state, x)
|
|
137
|
+
b0, b1, b2, a1, a2 = @coefficients
|
|
138
|
+
(b0 * x) + (b1 * state[:x1]) + (b2 * state[:x2]) - (a1 * state[:y1]) - (a2 * state[:y2])
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def update_state!(state, x, y)
|
|
142
|
+
state[:x2] = state[:x1]
|
|
143
|
+
state[:x1] = x
|
|
144
|
+
state[:y2] = state[:y1]
|
|
145
|
+
state[:y1] = y
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def coefficients_for(sample_rate)
|
|
149
|
+
omega = 2.0 * Math::PI * @cutoff / sample_rate
|
|
150
|
+
cos_w = Math.cos(omega)
|
|
151
|
+
sin_w = Math.sin(omega)
|
|
152
|
+
alpha = sin_w / (2.0 * @q)
|
|
153
|
+
a = 10.0**(@gain_db / 40.0)
|
|
154
|
+
|
|
155
|
+
b0, b1, b2, a0, a1, a2 = case @type
|
|
156
|
+
when :lowpass
|
|
157
|
+
[
|
|
158
|
+
(1.0 - cos_w) / 2.0,
|
|
159
|
+
1.0 - cos_w,
|
|
160
|
+
(1.0 - cos_w) / 2.0,
|
|
161
|
+
1.0 + alpha,
|
|
162
|
+
-2.0 * cos_w,
|
|
163
|
+
1.0 - alpha
|
|
164
|
+
]
|
|
165
|
+
when :highpass
|
|
166
|
+
[
|
|
167
|
+
(1.0 + cos_w) / 2.0,
|
|
168
|
+
-(1.0 + cos_w),
|
|
169
|
+
(1.0 + cos_w) / 2.0,
|
|
170
|
+
1.0 + alpha,
|
|
171
|
+
-2.0 * cos_w,
|
|
172
|
+
1.0 - alpha
|
|
173
|
+
]
|
|
174
|
+
when :bandpass
|
|
175
|
+
[
|
|
176
|
+
alpha,
|
|
177
|
+
0.0,
|
|
178
|
+
-alpha,
|
|
179
|
+
1.0 + alpha,
|
|
180
|
+
-2.0 * cos_w,
|
|
181
|
+
1.0 - alpha
|
|
182
|
+
]
|
|
183
|
+
when :notch
|
|
184
|
+
[
|
|
185
|
+
1.0,
|
|
186
|
+
-2.0 * cos_w,
|
|
187
|
+
1.0,
|
|
188
|
+
1.0 + alpha,
|
|
189
|
+
-2.0 * cos_w,
|
|
190
|
+
1.0 - alpha
|
|
191
|
+
]
|
|
192
|
+
when :peaking
|
|
193
|
+
[
|
|
194
|
+
1.0 + (alpha * a),
|
|
195
|
+
-2.0 * cos_w,
|
|
196
|
+
1.0 - (alpha * a),
|
|
197
|
+
1.0 + (alpha / a),
|
|
198
|
+
-2.0 * cos_w,
|
|
199
|
+
1.0 - (alpha / a)
|
|
200
|
+
]
|
|
201
|
+
when :lowshelf
|
|
202
|
+
shelf_coefficients(:low, cos_w, sin_w, a)
|
|
203
|
+
when :highshelf
|
|
204
|
+
shelf_coefficients(:high, cos_w, sin_w, a)
|
|
205
|
+
else
|
|
206
|
+
raise InvalidParameterError, "unsupported filter type: #{@type}"
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
[
|
|
210
|
+
b0 / a0,
|
|
211
|
+
b1 / a0,
|
|
212
|
+
b2 / a0,
|
|
213
|
+
a1 / a0,
|
|
214
|
+
a2 / a0
|
|
215
|
+
]
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def shelf_coefficients(mode, cos_w, sin_w, a)
|
|
219
|
+
sqrt_a = Math.sqrt(a)
|
|
220
|
+
two_sqrt_a_alpha = 2.0 * sqrt_a * (sin_w / 2.0)
|
|
221
|
+
if mode == :low
|
|
222
|
+
[
|
|
223
|
+
a * ((a + 1.0) - ((a - 1.0) * cos_w) + two_sqrt_a_alpha),
|
|
224
|
+
2.0 * a * ((a - 1.0) - ((a + 1.0) * cos_w)),
|
|
225
|
+
a * ((a + 1.0) - ((a - 1.0) * cos_w) - two_sqrt_a_alpha),
|
|
226
|
+
(a + 1.0) + ((a - 1.0) * cos_w) + two_sqrt_a_alpha,
|
|
227
|
+
-2.0 * ((a - 1.0) + ((a + 1.0) * cos_w)),
|
|
228
|
+
(a + 1.0) + ((a - 1.0) * cos_w) - two_sqrt_a_alpha
|
|
229
|
+
]
|
|
230
|
+
else
|
|
231
|
+
[
|
|
232
|
+
a * ((a + 1.0) + ((a - 1.0) * cos_w) + two_sqrt_a_alpha),
|
|
233
|
+
-2.0 * a * ((a - 1.0) + ((a + 1.0) * cos_w)),
|
|
234
|
+
a * ((a + 1.0) + ((a - 1.0) * cos_w) - two_sqrt_a_alpha),
|
|
235
|
+
(a + 1.0) - ((a - 1.0) * cos_w) + two_sqrt_a_alpha,
|
|
236
|
+
2.0 * ((a - 1.0) - ((a + 1.0) * cos_w)),
|
|
237
|
+
(a + 1.0) - ((a - 1.0) * cos_w) - two_sqrt_a_alpha
|
|
238
|
+
]
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def validate_type!(type)
|
|
243
|
+
value = type.to_sym
|
|
244
|
+
supported = %i[lowpass highpass bandpass notch peaking lowshelf highshelf]
|
|
245
|
+
raise InvalidParameterError, "unsupported filter type: #{type.inspect}" unless supported.include?(value)
|
|
246
|
+
|
|
247
|
+
value
|
|
248
|
+
rescue NoMethodError
|
|
249
|
+
raise InvalidParameterError, "filter type must be Symbol/String: #{type.inspect}"
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def validate_cutoff!(cutoff)
|
|
253
|
+
raise InvalidParameterError, "cutoff must be a positive Numeric" unless cutoff.is_a?(Numeric) && cutoff.positive?
|
|
254
|
+
|
|
255
|
+
cutoff.to_f
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def validate_q!(q)
|
|
259
|
+
raise InvalidParameterError, "q must be a positive Numeric" unless q.is_a?(Numeric) && q.positive?
|
|
260
|
+
|
|
261
|
+
q.to_f
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def validate_gain!(gain_db)
|
|
265
|
+
raise InvalidParameterError, "gain_db must be Numeric" unless gain_db.is_a?(Numeric)
|
|
266
|
+
|
|
267
|
+
gain_db.to_f
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
end
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wavify
|
|
4
|
+
module DSP
|
|
5
|
+
# Oscillator and noise source generator.
|
|
6
|
+
class Oscillator
|
|
7
|
+
# Supported waveform symbols.
|
|
8
|
+
WAVEFORMS = %i[sine square sawtooth triangle white_noise pink_noise].freeze
|
|
9
|
+
|
|
10
|
+
def initialize(waveform:, frequency:, amplitude: 1.0, phase: 0.0, random: Random.new)
|
|
11
|
+
@waveform = validate_waveform!(waveform)
|
|
12
|
+
@frequency = validate_frequency!(frequency)
|
|
13
|
+
@amplitude = validate_amplitude!(amplitude)
|
|
14
|
+
@phase = phase.to_f
|
|
15
|
+
@random = random
|
|
16
|
+
reset_pink_noise!
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Generates a finite sample buffer in the requested format.
|
|
20
|
+
#
|
|
21
|
+
# @param duration_seconds [Numeric]
|
|
22
|
+
# @param format [Wavify::Core::Format]
|
|
23
|
+
# @return [Wavify::Core::SampleBuffer]
|
|
24
|
+
def generate(duration_seconds, format:)
|
|
25
|
+
validate_format!(format)
|
|
26
|
+
unless duration_seconds.is_a?(Numeric) && duration_seconds >= 0
|
|
27
|
+
raise InvalidParameterError, "duration_seconds must be a non-negative Numeric"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
sample_frames = (duration_seconds.to_f * format.sample_rate).round
|
|
31
|
+
samples = Array.new(sample_frames * format.channels)
|
|
32
|
+
|
|
33
|
+
sample_frames.times do |frame_index|
|
|
34
|
+
value = sample_at(frame_index, format.sample_rate)
|
|
35
|
+
base_index = frame_index * format.channels
|
|
36
|
+
format.channels.times { |channel| samples[base_index + channel] = value }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
Core::SampleBuffer.new(samples, format)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Returns an infinite enumerator of mono sample values.
|
|
43
|
+
#
|
|
44
|
+
# @param format [Wavify::Core::Format]
|
|
45
|
+
# @return [Enumerator<Float>]
|
|
46
|
+
def each_sample(format:)
|
|
47
|
+
validate_format!(format)
|
|
48
|
+
|
|
49
|
+
Enumerator.new do |yielder|
|
|
50
|
+
index = 0
|
|
51
|
+
loop do
|
|
52
|
+
yielder << sample_at(index, format.sample_rate)
|
|
53
|
+
index += 1
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def validate_waveform!(waveform)
|
|
61
|
+
value = waveform.to_sym
|
|
62
|
+
raise InvalidParameterError, "unsupported waveform: #{waveform.inspect}" unless WAVEFORMS.include?(value)
|
|
63
|
+
|
|
64
|
+
value
|
|
65
|
+
rescue NoMethodError
|
|
66
|
+
raise InvalidParameterError, "waveform must be Symbol/String: #{waveform.inspect}"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def validate_frequency!(frequency)
|
|
70
|
+
raise InvalidParameterError, "frequency must be a positive Numeric" unless frequency.is_a?(Numeric) && frequency.positive?
|
|
71
|
+
|
|
72
|
+
frequency.to_f
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def validate_amplitude!(amplitude)
|
|
76
|
+
raise InvalidParameterError, "amplitude must be Numeric in 0.0..1.0" unless amplitude.is_a?(Numeric) && amplitude.between?(0.0, 1.0)
|
|
77
|
+
|
|
78
|
+
amplitude.to_f
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def validate_format!(format)
|
|
82
|
+
raise InvalidParameterError, "format must be Core::Format" unless format.is_a?(Core::Format)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def sample_at(index, sample_rate)
|
|
86
|
+
t = @phase + (index.to_f / sample_rate)
|
|
87
|
+
raw = case @waveform
|
|
88
|
+
when :sine then Math.sin(2.0 * Math::PI * @frequency * t)
|
|
89
|
+
when :square then Math.sin(2.0 * Math::PI * @frequency * t) >= 0 ? 1.0 : -1.0
|
|
90
|
+
when :sawtooth then (2.0 * ((@frequency * t) % 1.0)) - 1.0
|
|
91
|
+
when :triangle then (2.0 * ((2.0 * ((@frequency * t) % 1.0)) - 1.0).abs) - 1.0
|
|
92
|
+
when :white_noise then @random.rand(-1.0..1.0)
|
|
93
|
+
when :pink_noise then next_pink_noise
|
|
94
|
+
end
|
|
95
|
+
(raw * @amplitude).clamp(-1.0, 1.0)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Lightweight pink-noise approximation (Paul Kellet filter).
|
|
99
|
+
def next_pink_noise
|
|
100
|
+
white = @random.rand(-1.0..1.0)
|
|
101
|
+
|
|
102
|
+
@pink_b0 = (0.99765 * @pink_b0) + (white * 0.0990460)
|
|
103
|
+
@pink_b1 = (0.96300 * @pink_b1) + (white * 0.2965164)
|
|
104
|
+
@pink_b2 = (0.57000 * @pink_b2) + (white * 1.0526913)
|
|
105
|
+
@pink_b3 = (0.7616 * @pink_b3) - (white * 0.5511934)
|
|
106
|
+
@pink_b4 = (0.8500 * @pink_b4) - (white * 0.7616)
|
|
107
|
+
@pink_b5 = white * 0.115926
|
|
108
|
+
|
|
109
|
+
@pink_b0 + @pink_b1 + @pink_b2 + @pink_b3 + @pink_b4 + @pink_b5 + (white * 0.5362)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def reset_pink_noise!
|
|
113
|
+
@pink_b0 = 0.0
|
|
114
|
+
@pink_b1 = 0.0
|
|
115
|
+
@pink_b2 = 0.0
|
|
116
|
+
@pink_b3 = 0.0
|
|
117
|
+
@pink_b4 = 0.0
|
|
118
|
+
@pink_b5 = 0.0
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wavify
|
|
4
|
+
# Base error class for all Wavify-specific exceptions.
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
# Base class for format and codec-related errors.
|
|
8
|
+
class FormatError < Error; end
|
|
9
|
+
# Raised when input/output data is malformed for a supported format.
|
|
10
|
+
class InvalidFormatError < FormatError; end
|
|
11
|
+
# Raised when a format is recognized but not supported by the implementation.
|
|
12
|
+
class UnsupportedFormatError < FormatError; end
|
|
13
|
+
# Raised when no codec can be selected for an input/output target.
|
|
14
|
+
class CodecNotFoundError < FormatError; end
|
|
15
|
+
|
|
16
|
+
# Base class for processing pipeline failures.
|
|
17
|
+
class ProcessingError < Error; end
|
|
18
|
+
# Raised when sample buffer conversion fails.
|
|
19
|
+
class BufferConversionError < ProcessingError; end
|
|
20
|
+
# Raised when a streaming operation cannot proceed.
|
|
21
|
+
class StreamError < ProcessingError; end
|
|
22
|
+
|
|
23
|
+
# Base class for DSP parameter/processing errors.
|
|
24
|
+
class DSPError < Error; end
|
|
25
|
+
# Raised when method parameters are invalid.
|
|
26
|
+
class InvalidParameterError < DSPError; end
|
|
27
|
+
|
|
28
|
+
# Base class for sequencer and DSL-related errors.
|
|
29
|
+
class SequencerError < Error; end
|
|
30
|
+
# Raised when rhythmic pattern notation is invalid.
|
|
31
|
+
class InvalidPatternError < SequencerError; end
|
|
32
|
+
# Raised when note/chord notation is invalid.
|
|
33
|
+
class InvalidNoteError < SequencerError; end
|
|
34
|
+
end
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wavify
|
|
4
|
+
module Sequencer
|
|
5
|
+
# Schedules sequencer tracks and renders them into audio.
|
|
6
|
+
class Engine
|
|
7
|
+
# Default beats-per-bar value used when omitted.
|
|
8
|
+
DEFAULT_BEATS_PER_BAR = 4
|
|
9
|
+
|
|
10
|
+
attr_reader :tempo, :format, :beats_per_bar
|
|
11
|
+
|
|
12
|
+
def initialize(tempo:, format: Wavify::Core::Format::CD_QUALITY, beats_per_bar: DEFAULT_BEATS_PER_BAR)
|
|
13
|
+
@tempo = validate_tempo!(tempo)
|
|
14
|
+
@format = validate_format!(format)
|
|
15
|
+
@beats_per_bar = validate_beats_per_bar!(beats_per_bar)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# @return [Float] seconds per beat at the current tempo
|
|
19
|
+
def seconds_per_beat
|
|
20
|
+
60.0 / @tempo
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @return [Float] duration of one bar in seconds
|
|
24
|
+
def bar_duration_seconds
|
|
25
|
+
seconds_per_beat * @beats_per_bar
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def step_duration_seconds(resolution)
|
|
29
|
+
raise SequencerError, "resolution must be a positive Integer" unless resolution.is_a?(Integer) && resolution.positive?
|
|
30
|
+
|
|
31
|
+
bar_duration_seconds / resolution.to_f
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def timeline_for_track(track, bars:, start_bar: 0)
|
|
35
|
+
raise SequencerError, "track must be a Sequencer::Track" unless track.is_a?(Track)
|
|
36
|
+
raise SequencerError, "bars must be a positive Integer" unless bars.is_a?(Integer) && bars.positive?
|
|
37
|
+
raise SequencerError, "start_bar must be a non-negative Integer" unless start_bar.is_a?(Integer) && start_bar >= 0
|
|
38
|
+
|
|
39
|
+
events = []
|
|
40
|
+
events.concat(schedule_pattern_events(track, bars: bars, start_bar: start_bar)) if track.pattern?
|
|
41
|
+
events.concat(schedule_note_events(track, bars: bars, start_bar: start_bar)) if track.notes?
|
|
42
|
+
events.concat(schedule_chord_events(track, bars: bars, start_bar: start_bar)) if track.chords?
|
|
43
|
+
events.sort_by { |event| [event[:start_time], event[:kind].to_s] }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Builds a combined event timeline for tracks and optional arrangement.
|
|
47
|
+
#
|
|
48
|
+
# @param tracks [Array<Wavify::Sequencer::Track>]
|
|
49
|
+
# @param arrangement [Array<Hash>, nil]
|
|
50
|
+
# @param default_bars [Integer]
|
|
51
|
+
# @return [Array<Hash>]
|
|
52
|
+
def build_timeline(tracks:, arrangement: nil, default_bars: 1)
|
|
53
|
+
track_map = normalize_tracks(tracks)
|
|
54
|
+
|
|
55
|
+
if arrangement
|
|
56
|
+
build_arranged_timeline(track_map, arrangement)
|
|
57
|
+
else
|
|
58
|
+
raise SequencerError, "default_bars must be a positive Integer" unless default_bars.is_a?(Integer) && default_bars.positive?
|
|
59
|
+
|
|
60
|
+
events = track_map.values.flat_map do |track|
|
|
61
|
+
timeline_for_track(track, bars: default_bars)
|
|
62
|
+
end
|
|
63
|
+
events.sort_by { |event| [event[:start_time], event[:track].to_s] }
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Renders tracks into a mixed audio object.
|
|
68
|
+
#
|
|
69
|
+
# @param tracks [Array<Wavify::Sequencer::Track>]
|
|
70
|
+
# @param arrangement [Array<Hash>, nil]
|
|
71
|
+
# @param default_bars [Integer]
|
|
72
|
+
# @return [Wavify::Audio]
|
|
73
|
+
def render(tracks:, arrangement: nil, default_bars: 1)
|
|
74
|
+
track_map = normalize_tracks(tracks)
|
|
75
|
+
sections = arrangement ? normalize_arrangement(arrangement, track_map) : nil
|
|
76
|
+
|
|
77
|
+
rendered_audios = if sections
|
|
78
|
+
render_arranged_tracks(track_map, sections)
|
|
79
|
+
else
|
|
80
|
+
track_map.values.filter_map { |track| render_track_audio(track, bars: default_bars) }
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
return Wavify::Audio.silence(0.0, format: @format) if rendered_audios.empty?
|
|
84
|
+
|
|
85
|
+
Wavify::Audio.mix(*rendered_audios)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
def normalize_tracks(tracks)
|
|
91
|
+
list = tracks.is_a?(Array) ? tracks : Array(tracks)
|
|
92
|
+
raise SequencerError, "tracks must not be empty" if list.empty?
|
|
93
|
+
|
|
94
|
+
list.each_with_object({}) do |track, map|
|
|
95
|
+
raise SequencerError, "track must be a Sequencer::Track" unless track.is_a?(Track)
|
|
96
|
+
|
|
97
|
+
map[track.name] = track
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def normalize_arrangement(arrangement, track_map)
|
|
102
|
+
raise SequencerError, "arrangement must be an Array" unless arrangement.is_a?(Array)
|
|
103
|
+
|
|
104
|
+
cursor_bar = 0
|
|
105
|
+
arrangement.map do |section|
|
|
106
|
+
raise SequencerError, "section must be a Hash" unless section.is_a?(Hash)
|
|
107
|
+
|
|
108
|
+
name = section.fetch(:name, "section_#{cursor_bar}").to_sym
|
|
109
|
+
bars = section.fetch(:bars)
|
|
110
|
+
raise SequencerError, "section bars must be a positive Integer" unless bars.is_a?(Integer) && bars.positive?
|
|
111
|
+
|
|
112
|
+
track_names = Array(section.fetch(:tracks)).map(&:to_sym)
|
|
113
|
+
raise SequencerError, "section tracks must not be empty" if track_names.empty?
|
|
114
|
+
|
|
115
|
+
unknown = track_names - track_map.keys
|
|
116
|
+
raise SequencerError, "unknown tracks in section #{name}: #{unknown.join(', ')}" unless unknown.empty?
|
|
117
|
+
|
|
118
|
+
normalized = { name: name, bars: bars, tracks: track_names, start_bar: cursor_bar }
|
|
119
|
+
cursor_bar += bars
|
|
120
|
+
normalized
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def build_arranged_timeline(track_map, arrangement)
|
|
125
|
+
sections = normalize_arrangement(arrangement, track_map)
|
|
126
|
+
events = sections.flat_map do |section|
|
|
127
|
+
section[:tracks].flat_map do |track_name|
|
|
128
|
+
timeline_for_track(track_map.fetch(track_name), bars: section[:bars], start_bar: section[:start_bar])
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
events.sort_by { |event| [event[:start_time], event[:track].to_s] }
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def render_arranged_tracks(track_map, sections)
|
|
135
|
+
sections.flat_map do |section|
|
|
136
|
+
section[:tracks].filter_map do |track_name|
|
|
137
|
+
render_track_audio(track_map.fetch(track_name), bars: section[:bars], start_bar: section[:start_bar])
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def render_track_audio(track, bars:, start_bar: 0)
|
|
143
|
+
events = timeline_for_track(track, bars: bars, start_bar: start_bar)
|
|
144
|
+
note_events = events.select { |event| %i[note chord].include?(event[:kind]) }
|
|
145
|
+
return nil if note_events.empty?
|
|
146
|
+
|
|
147
|
+
track_format = @format.channels == 2 ? @format : @format.with(channels: 2)
|
|
148
|
+
total_end_time = note_events.map { |event| event[:start_time] + event[:duration] }.max || 0.0
|
|
149
|
+
audio = Wavify::Audio.silence(total_end_time, format: track_format.with(sample_format: :float, bit_depth: 32))
|
|
150
|
+
mixed = audio.buffer.samples.dup
|
|
151
|
+
|
|
152
|
+
note_events.each do |event|
|
|
153
|
+
frequencies = event[:midi_notes].map { |midi| midi_to_frequency(midi) }
|
|
154
|
+
note_audio = render_chord_tone(frequencies, event[:duration], track, track_format.with(sample_format: :float, bit_depth: 32))
|
|
155
|
+
start_frame = (event[:start_time] * track_format.sample_rate).round
|
|
156
|
+
start_index = start_frame * track_format.channels
|
|
157
|
+
|
|
158
|
+
note_audio.buffer.samples.each_with_index do |sample, index|
|
|
159
|
+
target_index = start_index + index
|
|
160
|
+
break if target_index >= mixed.length
|
|
161
|
+
|
|
162
|
+
mixed[target_index] += sample
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
mixed.map! { |sample| sample.clamp(-1.0, 1.0) }
|
|
167
|
+
rendered = Wavify::Audio.new(Wavify::Core::SampleBuffer.new(mixed, track_format.with(sample_format: :float, bit_depth: 32)))
|
|
168
|
+
rendered = rendered.gain(track.gain_db) if track.gain_db != 0.0
|
|
169
|
+
rendered = rendered.pan(track.pan_position) if track.pan_position != 0.0
|
|
170
|
+
rendered = apply_track_effects(rendered, track.effects) if track.effects?
|
|
171
|
+
rendered.convert(@format)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def render_chord_tone(frequencies, duration, track, format)
|
|
175
|
+
note_audios = frequencies.map do |frequency|
|
|
176
|
+
tone = Wavify::Audio.tone(frequency: frequency, duration: duration, format: format, waveform: track.waveform)
|
|
177
|
+
if track.envelope
|
|
178
|
+
tone.apply(lambda do |buffer|
|
|
179
|
+
track.envelope.apply(buffer, note_on_duration: duration)
|
|
180
|
+
end)
|
|
181
|
+
else
|
|
182
|
+
tone
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
Wavify::Audio.mix(*note_audios)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def schedule_pattern_events(track, bars:, start_bar:)
|
|
190
|
+
step_duration = step_duration_seconds(track.pattern.length)
|
|
191
|
+
|
|
192
|
+
(0...bars).flat_map do |bar_offset|
|
|
193
|
+
track.pattern.filter(&:trigger?).map do |step|
|
|
194
|
+
absolute_bar = start_bar + bar_offset
|
|
195
|
+
start_time = (absolute_bar * bar_duration_seconds) + (step.index * step_duration)
|
|
196
|
+
{
|
|
197
|
+
kind: :trigger,
|
|
198
|
+
track: track.name,
|
|
199
|
+
bar: absolute_bar,
|
|
200
|
+
step_index: step.index,
|
|
201
|
+
start_time: start_time,
|
|
202
|
+
duration: step_duration,
|
|
203
|
+
velocity: step.velocity
|
|
204
|
+
}
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def schedule_note_events(track, bars:, start_bar:)
|
|
210
|
+
base_step_duration = step_duration_seconds(track.note_resolution)
|
|
211
|
+
|
|
212
|
+
(0...bars).flat_map do |bar_offset|
|
|
213
|
+
track.note_sequence.each_with_object([]) do |event, result|
|
|
214
|
+
next if event.rest?
|
|
215
|
+
|
|
216
|
+
absolute_bar = start_bar + bar_offset
|
|
217
|
+
start_time = (absolute_bar * bar_duration_seconds) + (event.index * base_step_duration)
|
|
218
|
+
result << {
|
|
219
|
+
kind: :note,
|
|
220
|
+
track: track.name,
|
|
221
|
+
bar: absolute_bar,
|
|
222
|
+
step_index: event.index,
|
|
223
|
+
start_time: start_time,
|
|
224
|
+
duration: base_step_duration,
|
|
225
|
+
midi_notes: [event.midi_note]
|
|
226
|
+
}
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def schedule_chord_events(track, bars:, start_bar:)
|
|
232
|
+
progression = track.chord_progression
|
|
233
|
+
return [] unless progression
|
|
234
|
+
|
|
235
|
+
(0...bars).map do |bar_offset|
|
|
236
|
+
chord = progression[bar_offset % progression.length]
|
|
237
|
+
absolute_bar = start_bar + bar_offset
|
|
238
|
+
{
|
|
239
|
+
kind: :chord,
|
|
240
|
+
track: track.name,
|
|
241
|
+
bar: absolute_bar,
|
|
242
|
+
step_index: 0,
|
|
243
|
+
start_time: absolute_bar * bar_duration_seconds,
|
|
244
|
+
duration: bar_duration_seconds,
|
|
245
|
+
midi_notes: chord.fetch(:midi_notes),
|
|
246
|
+
chord: chord.fetch(:token)
|
|
247
|
+
}
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def midi_to_frequency(midi_note)
|
|
252
|
+
440.0 * (2.0**((midi_note - 69) / 12.0))
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def apply_track_effects(audio, effects)
|
|
256
|
+
effects.reduce(audio) { |current, effect| current.apply(effect) }
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def validate_tempo!(tempo)
|
|
260
|
+
raise SequencerError, "tempo must be a positive Numeric" unless tempo.is_a?(Numeric) && tempo.positive?
|
|
261
|
+
|
|
262
|
+
tempo.to_f
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def validate_format!(format)
|
|
266
|
+
raise SequencerError, "format must be a Core::Format" unless format.is_a?(Wavify::Core::Format)
|
|
267
|
+
|
|
268
|
+
format
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def validate_beats_per_bar!(beats_per_bar)
|
|
272
|
+
raise SequencerError, "beats_per_bar must be a positive Integer" unless beats_per_bar.is_a?(Integer) && beats_per_bar.positive?
|
|
273
|
+
|
|
274
|
+
beats_per_bar
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
end
|