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,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wavify
|
|
4
|
+
module DSP
|
|
5
|
+
module Effects
|
|
6
|
+
# Peak compressor with threshold, ratio, attack, and release controls.
|
|
7
|
+
class Compressor < EffectBase
|
|
8
|
+
def initialize(threshold: -10, ratio: 4, attack: 0.01, release: 0.1)
|
|
9
|
+
super()
|
|
10
|
+
@threshold_db = validate_numeric!(threshold, :threshold).to_f
|
|
11
|
+
@ratio = validate_ratio!(ratio)
|
|
12
|
+
@attack = validate_time!(attack, :attack)
|
|
13
|
+
@release = validate_time!(release, :release)
|
|
14
|
+
reset
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Processes a single sample for one channel.
|
|
18
|
+
#
|
|
19
|
+
# @param sample [Numeric]
|
|
20
|
+
# @param channel [Integer]
|
|
21
|
+
# @param sample_rate [Integer]
|
|
22
|
+
# @return [Float]
|
|
23
|
+
def process_sample(sample, channel:, sample_rate:)
|
|
24
|
+
x = sample.to_f
|
|
25
|
+
level = x.abs
|
|
26
|
+
|
|
27
|
+
attack_coeff = time_coefficient(@attack, sample_rate)
|
|
28
|
+
release_coeff = time_coefficient(@release, sample_rate)
|
|
29
|
+
envelope = @envelopes.fetch(channel)
|
|
30
|
+
coeff = level > envelope ? attack_coeff : release_coeff
|
|
31
|
+
envelope += (1.0 - coeff) * (level - envelope)
|
|
32
|
+
@envelopes[channel] = envelope
|
|
33
|
+
|
|
34
|
+
gain = gain_for_envelope(envelope)
|
|
35
|
+
x * gain
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def prepare_runtime_state(sample_rate:, channels:)
|
|
41
|
+
@envelopes = Array.new(channels, 0.0)
|
|
42
|
+
@threshold_linear = 10.0**(@threshold_db / 20.0)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def reset_runtime_state
|
|
46
|
+
@envelopes = []
|
|
47
|
+
@threshold_linear = nil
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def gain_for_envelope(envelope)
|
|
51
|
+
return 1.0 if envelope <= 0.0 || envelope <= @threshold_linear
|
|
52
|
+
|
|
53
|
+
input_db = 20.0 * Math.log10(envelope)
|
|
54
|
+
output_db = @threshold_db + ((input_db - @threshold_db) / @ratio)
|
|
55
|
+
gain_db = output_db - input_db
|
|
56
|
+
10.0**(gain_db / 20.0)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def time_coefficient(seconds, sample_rate)
|
|
60
|
+
return 0.0 if seconds <= 0.0
|
|
61
|
+
|
|
62
|
+
Math.exp(-1.0 / (seconds * sample_rate))
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def validate_numeric!(value, name)
|
|
66
|
+
raise InvalidParameterError, "#{name} must be Numeric" unless value.is_a?(Numeric)
|
|
67
|
+
|
|
68
|
+
value
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def validate_ratio!(value)
|
|
72
|
+
raise InvalidParameterError, "ratio must be >= 1.0" unless value.is_a?(Numeric) && value >= 1.0
|
|
73
|
+
|
|
74
|
+
value.to_f
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def validate_time!(value, name)
|
|
78
|
+
raise InvalidParameterError, "#{name} must be a non-negative Numeric" unless value.is_a?(Numeric) && value >= 0.0
|
|
79
|
+
|
|
80
|
+
value.to_f
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wavify
|
|
4
|
+
module DSP
|
|
5
|
+
module Effects
|
|
6
|
+
# Simple feedback delay effect.
|
|
7
|
+
class Delay < EffectBase
|
|
8
|
+
def initialize(time: 0.3, feedback: 0.5, mix: 0.3)
|
|
9
|
+
super()
|
|
10
|
+
@time = validate_time!(time)
|
|
11
|
+
@feedback = validate_ratio!(feedback, :feedback)
|
|
12
|
+
@mix = validate_mix!(mix)
|
|
13
|
+
reset
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Processes a single sample for one channel.
|
|
17
|
+
#
|
|
18
|
+
# @param sample [Numeric]
|
|
19
|
+
# @param channel [Integer]
|
|
20
|
+
# @param sample_rate [Integer]
|
|
21
|
+
# @return [Float]
|
|
22
|
+
def process_sample(sample, channel:, sample_rate:)
|
|
23
|
+
line = @delay_lines.fetch(channel)
|
|
24
|
+
index = @write_indices.fetch(channel)
|
|
25
|
+
delayed = line[index]
|
|
26
|
+
dry = sample.to_f
|
|
27
|
+
wet = delayed
|
|
28
|
+
|
|
29
|
+
output = (dry * (1.0 - @mix)) + (wet * @mix)
|
|
30
|
+
line[index] = (dry + (wet * @feedback)).clamp(-1.0, 1.0)
|
|
31
|
+
@write_indices[channel] = (index + 1) % line.length
|
|
32
|
+
|
|
33
|
+
output
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def prepare_runtime_state(sample_rate:, channels:)
|
|
39
|
+
delay_samples = [(sample_rate * @time).round, 1].max
|
|
40
|
+
@delay_lines = Array.new(channels) { Array.new(delay_samples, 0.0) }
|
|
41
|
+
@write_indices = Array.new(channels, 0)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def reset_runtime_state
|
|
45
|
+
@delay_lines = []
|
|
46
|
+
@write_indices = []
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def validate_time!(value)
|
|
50
|
+
raise InvalidParameterError, "time must be a positive Numeric" unless value.is_a?(Numeric) && value.positive?
|
|
51
|
+
|
|
52
|
+
value.to_f
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def validate_ratio!(value, name)
|
|
56
|
+
raise InvalidParameterError, "#{name} must be in 0.0...1.0" unless value.is_a?(Numeric) && value >= 0.0 && value < 1.0
|
|
57
|
+
|
|
58
|
+
value.to_f
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def validate_mix!(value)
|
|
62
|
+
raise InvalidParameterError, "mix must be Numeric in 0.0..1.0" unless value.is_a?(Numeric) && value.between?(0.0, 1.0)
|
|
63
|
+
|
|
64
|
+
value.to_f
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wavify
|
|
4
|
+
module DSP
|
|
5
|
+
module Effects
|
|
6
|
+
# Soft-clipping distortion with tone shaping and dry/wet mix.
|
|
7
|
+
class Distortion < EffectBase
|
|
8
|
+
def initialize(drive: 0.5, tone: 0.5, mix: 1.0)
|
|
9
|
+
super()
|
|
10
|
+
@drive = validate_unit!(drive, :drive)
|
|
11
|
+
@tone = validate_unit!(tone, :tone)
|
|
12
|
+
@mix = validate_unit!(mix, :mix)
|
|
13
|
+
reset
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Processes a single sample for one channel.
|
|
17
|
+
#
|
|
18
|
+
# @param sample [Numeric]
|
|
19
|
+
# @param channel [Integer]
|
|
20
|
+
# @param sample_rate [Integer]
|
|
21
|
+
# @return [Float]
|
|
22
|
+
def process_sample(sample, channel:, sample_rate:)
|
|
23
|
+
dry = sample.to_f
|
|
24
|
+
wet = Math.tanh(dry * pre_gain)
|
|
25
|
+
|
|
26
|
+
# One-pole low-pass on the distorted signal for tone shaping.
|
|
27
|
+
coeff = tone_coefficient(sample_rate)
|
|
28
|
+
previous = @tone_state.fetch(channel)
|
|
29
|
+
filtered = previous + (coeff * (wet - previous))
|
|
30
|
+
@tone_state[channel] = filtered
|
|
31
|
+
|
|
32
|
+
(dry * (1.0 - @mix)) + (filtered * @mix)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def prepare_runtime_state(sample_rate:, channels:)
|
|
38
|
+
@tone_state = Array.new(channels, 0.0)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def reset_runtime_state
|
|
42
|
+
@tone_state = []
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def pre_gain
|
|
46
|
+
1.0 + (@drive * 19.0)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def tone_coefficient(sample_rate)
|
|
50
|
+
cutoff = 500.0 + (@tone * 7_500.0)
|
|
51
|
+
rc = 1.0 / (2.0 * Math::PI * cutoff)
|
|
52
|
+
dt = 1.0 / sample_rate
|
|
53
|
+
dt / (rc + dt)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def validate_unit!(value, name)
|
|
57
|
+
raise InvalidParameterError, "#{name} must be Numeric in 0.0..1.0" unless value.is_a?(Numeric) && value.between?(0.0, 1.0)
|
|
58
|
+
|
|
59
|
+
value.to_f
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wavify
|
|
4
|
+
module DSP
|
|
5
|
+
module Effects
|
|
6
|
+
# Base class for sample-by-sample effects with runtime channel state.
|
|
7
|
+
class EffectBase
|
|
8
|
+
# Applies the effect to a sample buffer.
|
|
9
|
+
#
|
|
10
|
+
# @param buffer [Wavify::Core::SampleBuffer]
|
|
11
|
+
# @return [Wavify::Core::SampleBuffer]
|
|
12
|
+
def apply(buffer)
|
|
13
|
+
process(buffer)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def process(buffer)
|
|
17
|
+
raise InvalidParameterError, "buffer must be Core::SampleBuffer" unless buffer.is_a?(Core::SampleBuffer)
|
|
18
|
+
|
|
19
|
+
float_format = buffer.format.with(sample_format: :float, bit_depth: 32)
|
|
20
|
+
float_buffer = buffer.convert(float_format)
|
|
21
|
+
|
|
22
|
+
prepare_runtime_if_needed!(
|
|
23
|
+
sample_rate: float_format.sample_rate,
|
|
24
|
+
channels: float_buffer.format.channels
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
output = Array.new(float_buffer.samples.length)
|
|
28
|
+
float_buffer.samples.each_with_index do |sample, sample_index|
|
|
29
|
+
channel = sample_index % @runtime_channels
|
|
30
|
+
output[sample_index] = process_sample(sample, channel: channel, sample_rate: @runtime_sample_rate).clamp(-1.0, 1.0)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
Core::SampleBuffer.new(output, float_format).convert(buffer.format)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def process_sample(_sample, channel:, sample_rate:)
|
|
37
|
+
raise NotImplementedError
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Resets runtime state and cached channel/sample-rate information.
|
|
41
|
+
#
|
|
42
|
+
# @return [EffectBase] self
|
|
43
|
+
def reset
|
|
44
|
+
@runtime_sample_rate = nil
|
|
45
|
+
@runtime_channels = nil
|
|
46
|
+
reset_runtime_state
|
|
47
|
+
self
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def prepare_runtime_if_needed!(sample_rate:, channels:)
|
|
53
|
+
return if @runtime_sample_rate == sample_rate && @runtime_channels == channels
|
|
54
|
+
|
|
55
|
+
@runtime_sample_rate = sample_rate
|
|
56
|
+
@runtime_channels = channels
|
|
57
|
+
reset_runtime_state
|
|
58
|
+
prepare_runtime_state(sample_rate: sample_rate, channels: channels)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def prepare_runtime_state(sample_rate:, channels:); end
|
|
62
|
+
|
|
63
|
+
def reset_runtime_state; end
|
|
64
|
+
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wavify
|
|
4
|
+
module DSP
|
|
5
|
+
module Effects
|
|
6
|
+
# Lightweight Schroeder-style reverb effect.
|
|
7
|
+
class Reverb < EffectBase
|
|
8
|
+
COMB_TAPS_44K = [1116, 1188, 1277, 1356].freeze # :nodoc:
|
|
9
|
+
ALLPASS_TAPS_44K = [556, 441].freeze # :nodoc:
|
|
10
|
+
|
|
11
|
+
def initialize(room_size: 0.5, damping: 0.5, mix: 0.3)
|
|
12
|
+
super()
|
|
13
|
+
@room_size = validate_unit!(room_size, :room_size)
|
|
14
|
+
@damping = validate_unit!(damping, :damping)
|
|
15
|
+
@mix = validate_unit!(mix, :mix)
|
|
16
|
+
reset
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Processes a single sample for one channel.
|
|
20
|
+
#
|
|
21
|
+
# @param sample [Numeric]
|
|
22
|
+
# @param channel [Integer]
|
|
23
|
+
# @param sample_rate [Integer]
|
|
24
|
+
# @return [Float]
|
|
25
|
+
def process_sample(sample, channel:, sample_rate:)
|
|
26
|
+
dry = sample.to_f
|
|
27
|
+
channel_state = @channels_state.fetch(channel)
|
|
28
|
+
|
|
29
|
+
comb_input = dry * @input_gain
|
|
30
|
+
comb_sum = 0.0
|
|
31
|
+
channel_state[:combs].each do |comb|
|
|
32
|
+
comb_sum += process_comb(comb, comb_input)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
wet = comb_sum / channel_state[:combs].length
|
|
36
|
+
channel_state[:allpasses].each do |allpass|
|
|
37
|
+
wet = process_allpass(allpass, wet)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
(dry * (1.0 - @mix)) + (wet * @mix)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def prepare_runtime_state(sample_rate:, channels:)
|
|
46
|
+
scale = sample_rate.to_f / 44_100.0
|
|
47
|
+
comb_feedback = 0.6 + (@room_size * 0.34)
|
|
48
|
+
damping = @damping
|
|
49
|
+
|
|
50
|
+
@input_gain = 0.35
|
|
51
|
+
@channels_state = Array.new(channels) do
|
|
52
|
+
combs = COMB_TAPS_44K.map do |tap|
|
|
53
|
+
length = [(tap * scale).round, 8].max
|
|
54
|
+
{
|
|
55
|
+
buffer: Array.new(length, 0.0),
|
|
56
|
+
index: 0,
|
|
57
|
+
feedback: comb_feedback,
|
|
58
|
+
damping: damping,
|
|
59
|
+
filter_store: 0.0
|
|
60
|
+
}
|
|
61
|
+
end
|
|
62
|
+
allpasses = ALLPASS_TAPS_44K.map do |tap|
|
|
63
|
+
length = [(tap * scale).round, 4].max
|
|
64
|
+
{
|
|
65
|
+
buffer: Array.new(length, 0.0),
|
|
66
|
+
index: 0,
|
|
67
|
+
feedback: 0.5
|
|
68
|
+
}
|
|
69
|
+
end
|
|
70
|
+
{ combs: combs, allpasses: allpasses }
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def reset_runtime_state
|
|
75
|
+
@channels_state = []
|
|
76
|
+
@input_gain = nil
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def process_comb(comb, input_sample)
|
|
80
|
+
buffer = comb[:buffer]
|
|
81
|
+
index = comb[:index]
|
|
82
|
+
delayed = buffer[index]
|
|
83
|
+
|
|
84
|
+
filter_store = (delayed * (1.0 - comb[:damping])) + (comb[:filter_store] * comb[:damping])
|
|
85
|
+
comb[:filter_store] = filter_store
|
|
86
|
+
buffer[index] = (input_sample + (filter_store * comb[:feedback])).clamp(-1.0, 1.0)
|
|
87
|
+
comb[:index] = (index + 1) % buffer.length
|
|
88
|
+
|
|
89
|
+
delayed
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def process_allpass(allpass, input_sample)
|
|
93
|
+
buffer = allpass[:buffer]
|
|
94
|
+
index = allpass[:index]
|
|
95
|
+
delayed = buffer[index]
|
|
96
|
+
|
|
97
|
+
output = delayed - input_sample
|
|
98
|
+
buffer[index] = input_sample + (delayed * allpass[:feedback])
|
|
99
|
+
allpass[:index] = (index + 1) % buffer.length
|
|
100
|
+
|
|
101
|
+
output
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def validate_unit!(value, name)
|
|
105
|
+
raise InvalidParameterError, "#{name} must be Numeric in 0.0..1.0" unless value.is_a?(Numeric) && value.between?(0.0, 1.0)
|
|
106
|
+
|
|
107
|
+
value.to_f
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "effects/effect_base"
|
|
4
|
+
require_relative "effects/delay"
|
|
5
|
+
require_relative "effects/reverb"
|
|
6
|
+
require_relative "effects/chorus"
|
|
7
|
+
require_relative "effects/distortion"
|
|
8
|
+
require_relative "effects/compressor"
|
|
9
|
+
|
|
10
|
+
module Wavify
|
|
11
|
+
module DSP
|
|
12
|
+
# Built-in audio effects namespace.
|
|
13
|
+
module Effects
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
module Wavify
|
|
19
|
+
# Convenience alias for {Wavify::DSP::Effects}.
|
|
20
|
+
Effects = DSP::Effects unless const_defined?(:Effects)
|
|
21
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wavify
|
|
4
|
+
module DSP
|
|
5
|
+
# ADSR envelope generator and buffer processor.
|
|
6
|
+
class Envelope
|
|
7
|
+
attr_reader :attack, :decay, :sustain, :release
|
|
8
|
+
|
|
9
|
+
def initialize(attack:, decay:, sustain:, release:)
|
|
10
|
+
@attack = validate_time!(attack, :attack)
|
|
11
|
+
@decay = validate_time!(decay, :decay)
|
|
12
|
+
@sustain = validate_sustain!(sustain)
|
|
13
|
+
@release = validate_time!(release, :release)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Computes envelope gain at a given playback time.
|
|
17
|
+
#
|
|
18
|
+
# @param time [Numeric]
|
|
19
|
+
# @param note_on_duration [Numeric]
|
|
20
|
+
# @return [Float]
|
|
21
|
+
def gain_at(time, note_on_duration:)
|
|
22
|
+
raise InvalidParameterError, "time must be a non-negative Numeric" unless time.is_a?(Numeric) && time >= 0
|
|
23
|
+
unless note_on_duration.is_a?(Numeric) && note_on_duration >= 0
|
|
24
|
+
raise InvalidParameterError, "note_on_duration must be a non-negative Numeric"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
return attack_gain(time) if time < @attack
|
|
28
|
+
return decay_gain(time) if time < (@attack + @decay)
|
|
29
|
+
return @sustain if time < note_on_duration
|
|
30
|
+
|
|
31
|
+
release_time = time - note_on_duration
|
|
32
|
+
release_gain(release_time)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Applies the envelope to a sample buffer.
|
|
36
|
+
#
|
|
37
|
+
# @param buffer [Wavify::Core::SampleBuffer]
|
|
38
|
+
# @param note_on_duration [Numeric]
|
|
39
|
+
# @return [Wavify::Core::SampleBuffer]
|
|
40
|
+
def apply(buffer, note_on_duration:)
|
|
41
|
+
raise InvalidParameterError, "buffer must be Core::SampleBuffer" unless buffer.is_a?(Core::SampleBuffer)
|
|
42
|
+
unless note_on_duration.is_a?(Numeric) && note_on_duration >= 0
|
|
43
|
+
raise InvalidParameterError, "note_on_duration must be a non-negative Numeric"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
float_format = buffer.format.with(sample_format: :float, bit_depth: 32)
|
|
47
|
+
float_buffer = buffer.convert(float_format)
|
|
48
|
+
channels = float_format.channels
|
|
49
|
+
sample_rate = float_format.sample_rate
|
|
50
|
+
|
|
51
|
+
processed = float_buffer.samples.dup
|
|
52
|
+
processed.each_slice(channels).with_index do |frame, frame_index|
|
|
53
|
+
gain = gain_at(frame_index.to_f / sample_rate, note_on_duration: note_on_duration)
|
|
54
|
+
base = frame_index * channels
|
|
55
|
+
frame.each_index do |channel_index|
|
|
56
|
+
processed[base + channel_index] = frame[channel_index] * gain
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
Core::SampleBuffer.new(processed, float_format).convert(buffer.format)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def attack_gain(time)
|
|
66
|
+
return 1.0 if @attack.zero?
|
|
67
|
+
|
|
68
|
+
time / @attack
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def decay_gain(time)
|
|
72
|
+
return @sustain if @decay.zero?
|
|
73
|
+
|
|
74
|
+
elapsed = time - @attack
|
|
75
|
+
1.0 - ((1.0 - @sustain) * (elapsed / @decay))
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def release_gain(release_time)
|
|
79
|
+
return 0.0 if @release.zero?
|
|
80
|
+
|
|
81
|
+
@sustain * (1.0 - [release_time / @release, 1.0].min)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def validate_time!(value, name)
|
|
85
|
+
raise InvalidParameterError, "#{name} must be a non-negative Numeric" unless value.is_a?(Numeric) && value >= 0
|
|
86
|
+
|
|
87
|
+
value.to_f
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def validate_sustain!(value)
|
|
91
|
+
raise InvalidParameterError, "sustain must be Numeric in 0.0..1.0" unless value.is_a?(Numeric) && value.between?(0.0, 1.0)
|
|
92
|
+
|
|
93
|
+
value.to_f
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|