wavify 0.1.0 → 0.2.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 +4 -4
- data/CHANGELOG.md +40 -1
- data/README.md +204 -22
- data/exe/wavify +6 -0
- data/lib/wavify/adapters.rb +81 -0
- data/lib/wavify/audio.rb +1066 -120
- data/lib/wavify/cli.rb +237 -0
- data/lib/wavify/codecs/aiff.rb +388 -87
- data/lib/wavify/codecs/base.rb +98 -5
- data/lib/wavify/codecs/flac.rb +660 -170
- data/lib/wavify/codecs/ogg_vorbis.rb +515 -155
- data/lib/wavify/codecs/raw.rb +235 -45
- data/lib/wavify/codecs/registry.rb +272 -10
- data/lib/wavify/codecs/wav.rb +453 -74
- data/lib/wavify/core/duration.rb +54 -2
- data/lib/wavify/core/format.rb +96 -11
- data/lib/wavify/core/sample_buffer.rb +648 -60
- data/lib/wavify/core/stream.rb +590 -34
- data/lib/wavify/dsl.rb +712 -100
- data/lib/wavify/dsp/automation.rb +109 -0
- data/lib/wavify/dsp/effects/auto_pan.rb +71 -0
- data/lib/wavify/dsp/effects/bitcrusher.rb +74 -0
- data/lib/wavify/dsp/effects/chorus.rb +15 -4
- data/lib/wavify/dsp/effects/compressor.rb +133 -23
- data/lib/wavify/dsp/effects/delay.rb +46 -1
- data/lib/wavify/dsp/effects/distortion.rb +3 -2
- data/lib/wavify/dsp/effects/effect_base.rb +69 -2
- data/lib/wavify/dsp/effects/effect_chain.rb +94 -0
- data/lib/wavify/dsp/effects/envelope_controlled_effect.rb +93 -0
- data/lib/wavify/dsp/effects/eq.rb +102 -0
- data/lib/wavify/dsp/effects/expander.rb +90 -0
- data/lib/wavify/dsp/effects/flanger.rb +112 -0
- data/lib/wavify/dsp/effects/limiter.rb +259 -0
- data/lib/wavify/dsp/effects/mastering_chain.rb +31 -0
- data/lib/wavify/dsp/effects/noise_gate.rb +72 -0
- data/lib/wavify/dsp/effects/phaser.rb +112 -0
- data/lib/wavify/dsp/effects/podcast_chain.rb +32 -0
- data/lib/wavify/dsp/effects/reverb.rb +100 -14
- data/lib/wavify/dsp/effects/soft_limiter.rb +55 -0
- data/lib/wavify/dsp/effects/stereo_widener.rb +73 -0
- data/lib/wavify/dsp/effects/tremolo.rb +66 -0
- data/lib/wavify/dsp/effects/vibrato.rb +102 -0
- data/lib/wavify/dsp/effects.rb +106 -0
- data/lib/wavify/dsp/envelope.rb +90 -19
- data/lib/wavify/dsp/filter.rb +149 -8
- data/lib/wavify/dsp/headroom.rb +57 -0
- data/lib/wavify/dsp/lfo.rb +65 -0
- data/lib/wavify/dsp/loudness_meter.rb +281 -0
- data/lib/wavify/dsp/oscillator.rb +261 -22
- data/lib/wavify/dsp/processor.rb +90 -0
- data/lib/wavify/errors.rb +2 -2
- data/lib/wavify/sequencer/engine.rb +405 -89
- data/lib/wavify/sequencer/note_sequence.rb +45 -7
- data/lib/wavify/sequencer/pattern.rb +121 -12
- data/lib/wavify/sequencer/track.rb +149 -10
- data/lib/wavify/version.rb +1 -1
- data/lib/wavify.rb +18 -0
- data/sig/audio.rbs +86 -0
- data/sig/codecs.rbs +77 -0
- data/sig/core.rbs +108 -0
- data/sig/dsl.rbs +32 -0
- data/sig/dsp.rbs +87 -0
- data/sig/effects.rbs +129 -0
- data/sig/public_api.yml +111 -0
- data/sig/sequencer.rbs +126 -0
- data/sig/stream.rbs +30 -0
- data/sig/wavify.rbs +24 -0
- metadata +44 -58
- data/.serena/.gitignore +0 -1
- data/.serena/memories/project_overview.md +0 -5
- data/.serena/memories/style_and_completion.md +0 -5
- data/.serena/memories/suggested_commands.md +0 -11
- data/.serena/project.yml +0 -126
- data/.simplecov +0 -18
- data/.yardopts +0 -4
- data/Rakefile +0 -190
- data/benchmarks/README.md +0 -46
- data/benchmarks/benchmark_helper.rb +0 -112
- data/benchmarks/dsp_effects_benchmark.rb +0 -46
- data/benchmarks/flac_benchmark.rb +0 -74
- data/benchmarks/streaming_memory_benchmark.rb +0 -94
- data/benchmarks/wav_io_benchmark.rb +0 -110
- data/examples/audio_processing.rb +0 -73
- data/examples/cinematic_transition.rb +0 -118
- data/examples/drum_machine.rb +0 -74
- data/examples/format_convert.rb +0 -81
- data/examples/hybrid_arrangement.rb +0 -165
- data/examples/streaming_master_chain.rb +0 -129
- data/examples/synth_pad.rb +0 -42
- data/tools/fixture_writer.rb +0 -85
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wavify
|
|
4
|
+
module DSP
|
|
5
|
+
# Linear automation curve for time-varying parameters.
|
|
6
|
+
class Automation
|
|
7
|
+
Point = Struct.new(:time, :value, keyword_init: true)
|
|
8
|
+
|
|
9
|
+
attr_reader :points
|
|
10
|
+
|
|
11
|
+
def initialize(points)
|
|
12
|
+
normalized = Array(points).map { |point| coerce_point(point) }.sort_by(&:time)
|
|
13
|
+
raise InvalidParameterError, "automation points must not be empty" if normalized.empty?
|
|
14
|
+
|
|
15
|
+
@points = normalized.freeze
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def value_at(time)
|
|
19
|
+
seconds = validate_time!(time)
|
|
20
|
+
return @points.first.value if seconds <= @points.first.time
|
|
21
|
+
return @points.last.value if seconds >= @points.last.time
|
|
22
|
+
|
|
23
|
+
right_index = @points.bsearch_index { |point| point.time >= seconds }
|
|
24
|
+
left = @points.fetch(right_index - 1)
|
|
25
|
+
right = @points.fetch(right_index)
|
|
26
|
+
span = right.time - left.time
|
|
27
|
+
return right.value if span.zero?
|
|
28
|
+
|
|
29
|
+
ratio = (seconds - left.time) / span
|
|
30
|
+
left.value + ((right.value - left.value) * ratio)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def apply(buffer)
|
|
34
|
+
raise InvalidParameterError, "buffer must be Core::SampleBuffer" unless buffer.is_a?(Core::SampleBuffer)
|
|
35
|
+
raise InvalidParameterError, "automation apply requires a block" unless block_given?
|
|
36
|
+
|
|
37
|
+
format = buffer.format
|
|
38
|
+
point_index = 1
|
|
39
|
+
samples = []
|
|
40
|
+
buffer.samples.each_slice(format.channels).with_index do |frame, frame_index|
|
|
41
|
+
time = frame_index.to_f / format.sample_rate
|
|
42
|
+
point_index += 1 while point_index < @points.length && @points.fetch(point_index).time < time
|
|
43
|
+
value = value_between_points(time, point_index)
|
|
44
|
+
frame.each_with_index do |sample, channel|
|
|
45
|
+
samples << yield(sample, value, time, channel)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
Core::SampleBuffer.new(samples, format)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def apply_gain(buffer, unit: :db)
|
|
52
|
+
mode = unit.to_sym
|
|
53
|
+
raise InvalidParameterError, "unit must be :db or :linear" unless %i[db linear].include?(mode)
|
|
54
|
+
|
|
55
|
+
float_format = buffer.format.with(sample_format: :float, bit_depth: 32)
|
|
56
|
+
processed = apply(buffer.convert(float_format)) do |sample, value, _time, _channel|
|
|
57
|
+
factor = mode == :db ? (10.0**(value / 20.0)) : value
|
|
58
|
+
sample * factor
|
|
59
|
+
end
|
|
60
|
+
processed.convert(buffer.format)
|
|
61
|
+
rescue NoMethodError
|
|
62
|
+
raise InvalidParameterError, "unit must be Symbol/String"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def value_between_points(time, right_index)
|
|
68
|
+
return @points.first.value if time <= @points.first.time
|
|
69
|
+
return @points.last.value if right_index >= @points.length
|
|
70
|
+
|
|
71
|
+
left = @points.fetch(right_index - 1)
|
|
72
|
+
right = @points.fetch(right_index)
|
|
73
|
+
span = right.time - left.time
|
|
74
|
+
return right.value if span.zero?
|
|
75
|
+
|
|
76
|
+
ratio = (time - left.time) / span
|
|
77
|
+
left.value + ((right.value - left.value) * ratio)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def coerce_point(point)
|
|
81
|
+
time, value = case point
|
|
82
|
+
when Point
|
|
83
|
+
[point.time, point.value]
|
|
84
|
+
when Hash
|
|
85
|
+
[point.fetch(:time), point.fetch(:value)]
|
|
86
|
+
else
|
|
87
|
+
point.to_a
|
|
88
|
+
end
|
|
89
|
+
Point.new(time: validate_time!(time), value: validate_value!(value)).freeze
|
|
90
|
+
rescue KeyError, NoMethodError
|
|
91
|
+
raise InvalidParameterError, "automation point must provide time and value"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def validate_time!(time)
|
|
95
|
+
unless time.is_a?(Numeric) && time.respond_to?(:finite?) && time.finite? && time >= 0.0
|
|
96
|
+
raise InvalidParameterError, "automation time must be a non-negative finite Numeric"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
time.to_f
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def validate_value!(value)
|
|
103
|
+
raise InvalidParameterError, "automation value must be a finite Numeric" unless value.is_a?(Numeric) && value.respond_to?(:finite?) && value.finite?
|
|
104
|
+
|
|
105
|
+
value.to_f
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wavify
|
|
4
|
+
module DSP
|
|
5
|
+
module Effects
|
|
6
|
+
# Stereo sine-LFO auto-panner.
|
|
7
|
+
class AutoPan < EffectBase
|
|
8
|
+
def initialize(rate: 0.5, depth: 1.0)
|
|
9
|
+
super()
|
|
10
|
+
@rate = validate_positive!(rate, :rate)
|
|
11
|
+
@depth = validate_unit!(depth, :depth)
|
|
12
|
+
reset
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Processes a single sample for one stereo channel.
|
|
16
|
+
#
|
|
17
|
+
# @param sample [Numeric]
|
|
18
|
+
# @param channel [Integer]
|
|
19
|
+
# @param sample_rate [Integer]
|
|
20
|
+
# @return [Float]
|
|
21
|
+
def process_sample(sample, channel:, sample_rate:)
|
|
22
|
+
raise InvalidParameterError, "AutoPan requires stereo input" unless @runtime_channels == 2
|
|
23
|
+
|
|
24
|
+
position = Math.sin(@phase) * @depth
|
|
25
|
+
left_gain, right_gain = constant_power_pan_gains(position)
|
|
26
|
+
output = sample.to_f * (channel.zero? ? left_gain : right_gain)
|
|
27
|
+
advance_phase! if channel == 1
|
|
28
|
+
output
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def prepare_runtime_state(sample_rate:, channels:)
|
|
34
|
+
@phase = 0.0
|
|
35
|
+
@phase_step = (2.0 * Math::PI * @rate) / sample_rate
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def reset_runtime_state
|
|
39
|
+
@phase = 0.0
|
|
40
|
+
@phase_step = 0.0
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def constant_power_pan_gains(position)
|
|
44
|
+
angle = (position + 1.0) * (Math::PI / 4.0)
|
|
45
|
+
[Math.cos(angle), Math.sin(angle)]
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def advance_phase!
|
|
49
|
+
@phase += @phase_step
|
|
50
|
+
@phase -= (2.0 * Math::PI) while @phase >= (2.0 * Math::PI)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def validate_positive!(value, name)
|
|
54
|
+
unless value.is_a?(Numeric) && value.respond_to?(:finite?) && value.finite? && value.positive?
|
|
55
|
+
raise InvalidParameterError, "#{name} must be a positive finite Numeric"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
value.to_f
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def validate_unit!(value, name)
|
|
62
|
+
unless value.is_a?(Numeric) && value.respond_to?(:finite?) && value.finite? && value.between?(0.0, 1.0)
|
|
63
|
+
raise InvalidParameterError, "#{name} must be a finite Numeric in 0.0..1.0"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
value.to_f
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wavify
|
|
4
|
+
module DSP
|
|
5
|
+
module Effects
|
|
6
|
+
# Bit-depth reduction and sample-rate hold effect.
|
|
7
|
+
class Bitcrusher < EffectBase
|
|
8
|
+
def initialize(bit_depth: 8, downsample: 1, mix: 1.0)
|
|
9
|
+
super()
|
|
10
|
+
@bit_depth = validate_bit_depth!(bit_depth)
|
|
11
|
+
@downsample = validate_downsample!(downsample)
|
|
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
|
+
if (@counters.fetch(channel) % @downsample).zero?
|
|
25
|
+
@held_samples[channel] = quantize(dry)
|
|
26
|
+
end
|
|
27
|
+
@counters[channel] += 1
|
|
28
|
+
|
|
29
|
+
wet = @held_samples.fetch(channel)
|
|
30
|
+
(dry * (1.0 - @mix)) + (wet * @mix)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def prepare_runtime_state(sample_rate:, channels:)
|
|
36
|
+
@held_samples = Array.new(channels, 0.0)
|
|
37
|
+
@counters = Array.new(channels, 0)
|
|
38
|
+
@quantization_steps = (2**@bit_depth) - 1
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def reset_runtime_state
|
|
42
|
+
@held_samples = []
|
|
43
|
+
@counters = []
|
|
44
|
+
@quantization_steps = nil
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def quantize(value)
|
|
48
|
+
normalized = ((value.clamp(-1.0, 1.0) + 1.0) / 2.0)
|
|
49
|
+
((normalized * @quantization_steps).round / @quantization_steps.to_f * 2.0) - 1.0
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def validate_bit_depth!(value)
|
|
53
|
+
raise InvalidParameterError, "bit_depth must be an Integer in 1..24" unless value.is_a?(Integer) && value.between?(1, 24)
|
|
54
|
+
|
|
55
|
+
value
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def validate_downsample!(value)
|
|
59
|
+
raise InvalidParameterError, "downsample must be a positive Integer" unless value.is_a?(Integer) && value.positive?
|
|
60
|
+
|
|
61
|
+
value
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def validate_unit!(value, name)
|
|
65
|
+
unless value.is_a?(Numeric) && value.respond_to?(:finite?) && value.finite? && value.between?(0.0, 1.0)
|
|
66
|
+
raise InvalidParameterError, "#{name} must be a finite Numeric in 0.0..1.0"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
value.to_f
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -31,11 +31,18 @@ module Wavify
|
|
|
31
31
|
|
|
32
32
|
line[write_index] = dry
|
|
33
33
|
@write_indices[channel] = (write_index + 1) % line.length
|
|
34
|
-
advance_lfo!
|
|
34
|
+
advance_lfo! if channel == (@runtime_channels - 1)
|
|
35
35
|
|
|
36
36
|
(dry * (1.0 - @mix)) + (wet * @mix)
|
|
37
37
|
end
|
|
38
38
|
|
|
39
|
+
# @return [Float] maximum modulation delay emitted after input ends
|
|
40
|
+
def tail_duration
|
|
41
|
+
return 0.0 if @mix.zero?
|
|
42
|
+
|
|
43
|
+
0.03
|
|
44
|
+
end
|
|
45
|
+
|
|
39
46
|
private
|
|
40
47
|
|
|
41
48
|
def prepare_runtime_state(sample_rate:, channels:)
|
|
@@ -47,7 +54,7 @@ module Wavify
|
|
|
47
54
|
@delay_lines = Array.new(channels) { Array.new(line_length, 0.0) }
|
|
48
55
|
@write_indices = Array.new(channels, 0)
|
|
49
56
|
@lfo_phase = 0.0
|
|
50
|
-
@lfo_step = (2.0 * Math::PI * @rate) /
|
|
57
|
+
@lfo_step = (2.0 * Math::PI * @rate) / sample_rate
|
|
51
58
|
end
|
|
52
59
|
|
|
53
60
|
def reset_runtime_state
|
|
@@ -82,13 +89,17 @@ module Wavify
|
|
|
82
89
|
end
|
|
83
90
|
|
|
84
91
|
def validate_positive!(value, name)
|
|
85
|
-
|
|
92
|
+
unless value.is_a?(Numeric) && value.respond_to?(:finite?) && value.finite? && value.positive?
|
|
93
|
+
raise InvalidParameterError, "#{name} must be a positive finite Numeric"
|
|
94
|
+
end
|
|
86
95
|
|
|
87
96
|
value.to_f
|
|
88
97
|
end
|
|
89
98
|
|
|
90
99
|
def validate_unit!(value, name)
|
|
91
|
-
|
|
100
|
+
unless value.is_a?(Numeric) && value.respond_to?(:finite?) && value.finite? && value.between?(0.0, 1.0)
|
|
101
|
+
raise InvalidParameterError, "#{name} must be a finite Numeric in 0.0..1.0"
|
|
102
|
+
end
|
|
92
103
|
|
|
93
104
|
value.to_f
|
|
94
105
|
end
|
|
@@ -5,55 +5,116 @@ module Wavify
|
|
|
5
5
|
module Effects
|
|
6
6
|
# Peak compressor with threshold, ratio, attack, and release controls.
|
|
7
7
|
class Compressor < EffectBase
|
|
8
|
-
def initialize(threshold: -10, ratio: 4, attack: 0.01, release: 0.1
|
|
8
|
+
def initialize(threshold: -10, ratio: 4, attack: 0.01, release: 0.1, makeup_gain: 0.0, knee: 0.0,
|
|
9
|
+
sidechain: nil, sidechain_end: :silence)
|
|
9
10
|
super()
|
|
10
11
|
@threshold_db = validate_numeric!(threshold, :threshold).to_f
|
|
11
12
|
@ratio = validate_ratio!(ratio)
|
|
12
13
|
@attack = validate_time!(attack, :attack)
|
|
13
14
|
@release = validate_time!(release, :release)
|
|
15
|
+
@makeup_gain_db = validate_numeric!(makeup_gain, :makeup_gain).to_f
|
|
16
|
+
@knee_db = validate_knee!(knee)
|
|
17
|
+
@sidechain = validate_sidechain!(sidechain)
|
|
18
|
+
@sidechain_end = validate_sidechain_end!(sidechain_end)
|
|
14
19
|
reset
|
|
15
20
|
end
|
|
16
21
|
|
|
17
|
-
|
|
22
|
+
def process(buffer)
|
|
23
|
+
raise InvalidParameterError, "buffer must be Core::SampleBuffer" unless buffer.is_a?(Core::SampleBuffer)
|
|
24
|
+
|
|
25
|
+
float_format = buffer.format.with(sample_format: :float, bit_depth: 32)
|
|
26
|
+
float_buffer = buffer.convert(float_format)
|
|
27
|
+
prepare_runtime_if_needed!(
|
|
28
|
+
sample_rate: float_format.sample_rate,
|
|
29
|
+
channels: float_buffer.format.channels
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
output = []
|
|
33
|
+
float_buffer.samples.each_slice(@runtime_channels) do |frame|
|
|
34
|
+
detector_level = @sidechain ? next_sidechain_level : frame.map(&:abs).max
|
|
35
|
+
gain = gain_for_detector_level(detector_level)
|
|
36
|
+
output.concat(frame.map { |sample| sample * gain })
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
Core::SampleBuffer.new(output, float_format).convert(buffer.format)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Compression requires a complete frame so the detector can remain
|
|
43
|
+
# stereo-linked. Use #process with a SampleBuffer.
|
|
18
44
|
#
|
|
19
45
|
# @param sample [Numeric]
|
|
20
46
|
# @param channel [Integer]
|
|
21
47
|
# @param sample_rate [Integer]
|
|
22
48
|
# @return [Float]
|
|
23
49
|
def process_sample(sample, channel:, sample_rate:)
|
|
24
|
-
|
|
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
|
|
50
|
+
raise NotImplementedError, "Compressor requires frame-aware #apply or #process"
|
|
36
51
|
end
|
|
37
52
|
|
|
38
53
|
private
|
|
39
54
|
|
|
55
|
+
def gain_for_detector_level(level)
|
|
56
|
+
coeff = level > @envelope ? @attack_coefficient : @release_coefficient
|
|
57
|
+
@envelope += (1.0 - coeff) * (level - @envelope)
|
|
58
|
+
|
|
59
|
+
gain_for_envelope(@envelope)
|
|
60
|
+
end
|
|
61
|
+
|
|
40
62
|
def prepare_runtime_state(sample_rate:, channels:)
|
|
41
|
-
@
|
|
63
|
+
@envelope = 0.0
|
|
42
64
|
@threshold_linear = 10.0**(@threshold_db / 20.0)
|
|
65
|
+
@attack_coefficient = time_coefficient(@attack, sample_rate)
|
|
66
|
+
@release_coefficient = time_coefficient(@release, sample_rate)
|
|
67
|
+
@sidechain_cursor = 0
|
|
68
|
+
@sidechain_runtime = if @sidechain
|
|
69
|
+
format = Core::Format.new(
|
|
70
|
+
channels: channels,
|
|
71
|
+
sample_rate: sample_rate,
|
|
72
|
+
bit_depth: 32,
|
|
73
|
+
sample_format: :float
|
|
74
|
+
)
|
|
75
|
+
@sidechain.convert(format)
|
|
76
|
+
end
|
|
43
77
|
end
|
|
44
78
|
|
|
45
79
|
def reset_runtime_state
|
|
46
|
-
@
|
|
80
|
+
@envelope = 0.0
|
|
47
81
|
@threshold_linear = nil
|
|
82
|
+
@attack_coefficient = nil
|
|
83
|
+
@release_coefficient = nil
|
|
84
|
+
@sidechain_cursor = 0
|
|
85
|
+
@sidechain_runtime = nil
|
|
48
86
|
end
|
|
49
87
|
|
|
50
88
|
def gain_for_envelope(envelope)
|
|
51
|
-
return
|
|
89
|
+
return makeup_gain if envelope <= 0.0
|
|
52
90
|
|
|
53
91
|
input_db = 20.0 * Math.log10(envelope)
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
92
|
+
gain_reduction_db = compression_gain_reduction_db(input_db)
|
|
93
|
+
10.0**((gain_reduction_db + @makeup_gain_db) / 20.0)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def compression_gain_reduction_db(input_db)
|
|
97
|
+
over_threshold = input_db - @threshold_db
|
|
98
|
+
if @knee_db.positive?
|
|
99
|
+
half_knee = @knee_db / 2.0
|
|
100
|
+
return 0.0 if over_threshold <= -half_knee
|
|
101
|
+
return hard_knee_gain_reduction_db(over_threshold) if over_threshold >= half_knee
|
|
102
|
+
|
|
103
|
+
knee_position = over_threshold + half_knee
|
|
104
|
+
((1.0 / @ratio) - 1.0) * (knee_position * knee_position) / (2.0 * @knee_db)
|
|
105
|
+
else
|
|
106
|
+
hard_knee_gain_reduction_db(over_threshold)
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def hard_knee_gain_reduction_db(over_threshold)
|
|
111
|
+
return 0.0 unless over_threshold.positive?
|
|
112
|
+
|
|
113
|
+
(over_threshold / @ratio) - over_threshold
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def makeup_gain
|
|
117
|
+
10.0**(@makeup_gain_db / 20.0)
|
|
57
118
|
end
|
|
58
119
|
|
|
59
120
|
def time_coefficient(seconds, sample_rate)
|
|
@@ -63,22 +124,71 @@ module Wavify
|
|
|
63
124
|
end
|
|
64
125
|
|
|
65
126
|
def validate_numeric!(value, name)
|
|
66
|
-
raise InvalidParameterError, "#{name} must be Numeric" unless value.is_a?(Numeric)
|
|
127
|
+
raise InvalidParameterError, "#{name} must be a finite Numeric" unless value.is_a?(Numeric) && value.respond_to?(:finite?) && value.finite?
|
|
67
128
|
|
|
68
129
|
value
|
|
69
130
|
end
|
|
70
131
|
|
|
71
132
|
def validate_ratio!(value)
|
|
72
|
-
|
|
133
|
+
unless value.is_a?(Numeric) && value.respond_to?(:finite?) && value.finite? && value >= 1.0
|
|
134
|
+
raise InvalidParameterError, "ratio must be a finite Numeric >= 1.0"
|
|
135
|
+
end
|
|
73
136
|
|
|
74
137
|
value.to_f
|
|
75
138
|
end
|
|
76
139
|
|
|
77
140
|
def validate_time!(value, name)
|
|
78
|
-
|
|
141
|
+
unless value.is_a?(Numeric) && value.respond_to?(:finite?) && value.finite? && value >= 0.0
|
|
142
|
+
raise InvalidParameterError, "#{name} must be a non-negative finite Numeric"
|
|
143
|
+
end
|
|
79
144
|
|
|
80
145
|
value.to_f
|
|
81
146
|
end
|
|
147
|
+
|
|
148
|
+
def validate_knee!(value)
|
|
149
|
+
unless value.is_a?(Numeric) && value.respond_to?(:finite?) && value.finite? && value >= 0.0
|
|
150
|
+
raise InvalidParameterError, "knee must be a non-negative finite Numeric"
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
value.to_f
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def validate_sidechain!(value)
|
|
157
|
+
return nil if value.nil?
|
|
158
|
+
return value.buffer if defined?(Wavify::Audio) && value.is_a?(Wavify::Audio)
|
|
159
|
+
return value if value.is_a?(Core::SampleBuffer)
|
|
160
|
+
|
|
161
|
+
raise InvalidParameterError, "sidechain must be Audio or Core::SampleBuffer"
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def validate_sidechain_end!(value)
|
|
165
|
+
normalized = value.to_sym if value.respond_to?(:to_sym)
|
|
166
|
+
return normalized if %i[silence hold loop].include?(normalized)
|
|
167
|
+
|
|
168
|
+
raise InvalidParameterError, "sidechain_end must be :silence, :hold, or :loop"
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def next_sidechain_level
|
|
172
|
+
frame_count = @sidechain_runtime.sample_frame_count
|
|
173
|
+
frame_index = sidechain_frame_index(frame_count)
|
|
174
|
+
@sidechain_cursor += 1
|
|
175
|
+
return 0.0 unless frame_index
|
|
176
|
+
|
|
177
|
+
start_index = frame_index * @runtime_channels
|
|
178
|
+
frame = @sidechain_runtime.samples.slice(start_index, @runtime_channels) || []
|
|
179
|
+
frame.map(&:abs).max || 0.0
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def sidechain_frame_index(frame_count)
|
|
183
|
+
return nil if frame_count.zero?
|
|
184
|
+
return @sidechain_cursor if @sidechain_cursor < frame_count
|
|
185
|
+
|
|
186
|
+
case @sidechain_end
|
|
187
|
+
when :silence then nil
|
|
188
|
+
when :hold then frame_count - 1
|
|
189
|
+
when :loop then @sidechain_cursor % frame_count
|
|
190
|
+
end
|
|
191
|
+
end
|
|
82
192
|
end
|
|
83
193
|
end
|
|
84
194
|
end
|
|
@@ -5,6 +5,34 @@ module Wavify
|
|
|
5
5
|
module Effects
|
|
6
6
|
# Simple feedback delay effect.
|
|
7
7
|
class Delay < EffectBase
|
|
8
|
+
NOTE_MULTIPLIERS = {
|
|
9
|
+
whole: 4.0,
|
|
10
|
+
half: 2.0,
|
|
11
|
+
quarter: 1.0,
|
|
12
|
+
eighth: 0.5,
|
|
13
|
+
sixteenth: 0.25,
|
|
14
|
+
thirty_second: 0.125
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
# Builds a tempo-synced delay.
|
|
18
|
+
#
|
|
19
|
+
# @param note [Symbol, String] note value such as `:quarter` or `:eighth`
|
|
20
|
+
# @param tempo [Numeric] beats per minute
|
|
21
|
+
# @param dotted [Boolean] multiply by 1.5
|
|
22
|
+
# @param triplet [Boolean] multiply by 2/3
|
|
23
|
+
# @return [Delay]
|
|
24
|
+
def self.beat(note, tempo:, dotted: false, triplet: false, feedback: 0.5, mix: 0.3)
|
|
25
|
+
multiplier = note_multiplier!(note)
|
|
26
|
+
unless tempo.is_a?(Numeric) && tempo.respond_to?(:finite?) && tempo.finite? && tempo.positive?
|
|
27
|
+
raise InvalidParameterError, "tempo must be a positive finite Numeric"
|
|
28
|
+
end
|
|
29
|
+
raise InvalidParameterError, "dotted and triplet cannot both be true" if dotted && triplet
|
|
30
|
+
|
|
31
|
+
multiplier *= 1.5 if dotted
|
|
32
|
+
multiplier *= (2.0 / 3.0) if triplet
|
|
33
|
+
new(time: (60.0 / tempo.to_f) * multiplier, feedback: feedback, mix: mix)
|
|
34
|
+
end
|
|
35
|
+
|
|
8
36
|
def initialize(time: 0.3, feedback: 0.5, mix: 0.3)
|
|
9
37
|
super()
|
|
10
38
|
@time = validate_time!(time)
|
|
@@ -27,14 +55,31 @@ module Wavify
|
|
|
27
55
|
wet = delayed
|
|
28
56
|
|
|
29
57
|
output = (dry * (1.0 - @mix)) + (wet * @mix)
|
|
30
|
-
line[index] =
|
|
58
|
+
line[index] = dry + (wet * @feedback)
|
|
31
59
|
@write_indices[channel] = (index + 1) % line.length
|
|
32
60
|
|
|
33
61
|
output
|
|
34
62
|
end
|
|
35
63
|
|
|
64
|
+
# @return [Float] estimated feedback tail duration in seconds
|
|
65
|
+
def tail_duration
|
|
66
|
+
return 0.0 if @mix.zero?
|
|
67
|
+
return @time if @feedback.zero?
|
|
68
|
+
|
|
69
|
+
repeats = (Math.log(0.001) / Math.log(@feedback)).ceil
|
|
70
|
+
@time * [[repeats, 1].max, 32].min
|
|
71
|
+
end
|
|
72
|
+
|
|
36
73
|
private
|
|
37
74
|
|
|
75
|
+
def self.note_multiplier!(note)
|
|
76
|
+
normalized = note.to_sym if note.respond_to?(:to_sym)
|
|
77
|
+
return NOTE_MULTIPLIERS.fetch(normalized) if NOTE_MULTIPLIERS.key?(normalized)
|
|
78
|
+
|
|
79
|
+
raise InvalidParameterError, "note must be one of: #{NOTE_MULTIPLIERS.keys.join(', ')}"
|
|
80
|
+
end
|
|
81
|
+
private_class_method :note_multiplier!
|
|
82
|
+
|
|
38
83
|
def prepare_runtime_state(sample_rate:, channels:)
|
|
39
84
|
delay_samples = [(sample_rate * @time).round, 1].max
|
|
40
85
|
@delay_lines = Array.new(channels) { Array.new(delay_samples, 0.0) }
|
|
@@ -24,9 +24,8 @@ module Wavify
|
|
|
24
24
|
wet = Math.tanh(dry * pre_gain)
|
|
25
25
|
|
|
26
26
|
# One-pole low-pass on the distorted signal for tone shaping.
|
|
27
|
-
coeff = tone_coefficient(sample_rate)
|
|
28
27
|
previous = @tone_state.fetch(channel)
|
|
29
|
-
filtered = previous + (
|
|
28
|
+
filtered = previous + (@tone_coefficient * (wet - previous))
|
|
30
29
|
@tone_state[channel] = filtered
|
|
31
30
|
|
|
32
31
|
(dry * (1.0 - @mix)) + (filtered * @mix)
|
|
@@ -36,10 +35,12 @@ module Wavify
|
|
|
36
35
|
|
|
37
36
|
def prepare_runtime_state(sample_rate:, channels:)
|
|
38
37
|
@tone_state = Array.new(channels, 0.0)
|
|
38
|
+
@tone_coefficient = tone_coefficient(sample_rate)
|
|
39
39
|
end
|
|
40
40
|
|
|
41
41
|
def reset_runtime_state
|
|
42
42
|
@tone_state = []
|
|
43
|
+
@tone_coefficient = nil
|
|
43
44
|
end
|
|
44
45
|
|
|
45
46
|
def pre_gain
|