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
|
@@ -5,12 +5,14 @@ module Wavify
|
|
|
5
5
|
module Effects
|
|
6
6
|
# Base class for sample-by-sample effects with runtime channel state.
|
|
7
7
|
class EffectBase
|
|
8
|
+
TAIL_CHUNK_FRAMES = DSP::Processor::TAIL_CHUNK_FRAMES
|
|
9
|
+
|
|
8
10
|
# Applies the effect to a sample buffer.
|
|
9
11
|
#
|
|
10
12
|
# @param buffer [Wavify::Core::SampleBuffer]
|
|
11
13
|
# @return [Wavify::Core::SampleBuffer]
|
|
12
14
|
def apply(buffer)
|
|
13
|
-
|
|
15
|
+
DSP::Processor.render(self, buffer)
|
|
14
16
|
end
|
|
15
17
|
|
|
16
18
|
def process(buffer)
|
|
@@ -27,12 +29,62 @@ module Wavify
|
|
|
27
29
|
output = Array.new(float_buffer.samples.length)
|
|
28
30
|
float_buffer.samples.each_with_index do |sample, sample_index|
|
|
29
31
|
channel = sample_index % @runtime_channels
|
|
30
|
-
output[sample_index] = process_sample(sample, channel: channel, sample_rate: @runtime_sample_rate)
|
|
32
|
+
output[sample_index] = process_sample(sample, channel: channel, sample_rate: @runtime_sample_rate)
|
|
31
33
|
end
|
|
32
34
|
|
|
33
35
|
Core::SampleBuffer.new(output, float_format).convert(buffer.format)
|
|
34
36
|
end
|
|
35
37
|
|
|
38
|
+
# Emits effect tail after input has ended.
|
|
39
|
+
#
|
|
40
|
+
# @param format [Wavify::Core::Format, nil] output format for tail chunks
|
|
41
|
+
# @return [Enumerator<Wavify::Core::SampleBuffer>, nil]
|
|
42
|
+
def flush(format: nil)
|
|
43
|
+
return nil unless runtime_prepared?
|
|
44
|
+
|
|
45
|
+
frames = tail_frame_count
|
|
46
|
+
return nil if frames.zero?
|
|
47
|
+
|
|
48
|
+
target_format = format || Core::Format.new(
|
|
49
|
+
channels: @runtime_channels,
|
|
50
|
+
sample_rate: @runtime_sample_rate,
|
|
51
|
+
bit_depth: 32,
|
|
52
|
+
sample_format: :float
|
|
53
|
+
)
|
|
54
|
+
runtime_format = Core::Format.new(
|
|
55
|
+
channels: @runtime_channels,
|
|
56
|
+
sample_rate: @runtime_sample_rate,
|
|
57
|
+
bit_depth: 32,
|
|
58
|
+
sample_format: :float
|
|
59
|
+
)
|
|
60
|
+
Enumerator.new do |yielder|
|
|
61
|
+
remaining = frames
|
|
62
|
+
while remaining.positive?
|
|
63
|
+
chunk_frames = [remaining, TAIL_CHUNK_FRAMES].min
|
|
64
|
+
silence = Core::SampleBuffer.new(Array.new(chunk_frames * @runtime_channels, 0.0), runtime_format)
|
|
65
|
+
yielder << process(silence).convert(target_format)
|
|
66
|
+
remaining -= chunk_frames
|
|
67
|
+
end
|
|
68
|
+
ensure
|
|
69
|
+
reset
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# @return [Float] tail duration in seconds emitted by {#flush}
|
|
74
|
+
def tail_duration
|
|
75
|
+
0.0
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# @return [Float] processing latency in seconds
|
|
79
|
+
def latency
|
|
80
|
+
0.0
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# @return [Float] lookahead duration in seconds
|
|
84
|
+
def lookahead
|
|
85
|
+
0.0
|
|
86
|
+
end
|
|
87
|
+
|
|
36
88
|
def process_sample(_sample, channel:, sample_rate:)
|
|
37
89
|
raise NotImplementedError
|
|
38
90
|
end
|
|
@@ -47,8 +99,23 @@ module Wavify
|
|
|
47
99
|
self
|
|
48
100
|
end
|
|
49
101
|
|
|
102
|
+
# Builds an independent runtime instance for offline rendering.
|
|
103
|
+
def build_runtime
|
|
104
|
+
dup.reset
|
|
105
|
+
end
|
|
106
|
+
|
|
50
107
|
private
|
|
51
108
|
|
|
109
|
+
def runtime_prepared?
|
|
110
|
+
@runtime_sample_rate && @runtime_channels
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def tail_frame_count
|
|
114
|
+
return 0 unless tail_duration.positive?
|
|
115
|
+
|
|
116
|
+
(@runtime_sample_rate * tail_duration).ceil
|
|
117
|
+
end
|
|
118
|
+
|
|
52
119
|
def prepare_runtime_if_needed!(sample_rate:, channels:)
|
|
53
120
|
return if @runtime_sample_rate == sample_rate && @runtime_channels == channels
|
|
54
121
|
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wavify
|
|
4
|
+
module DSP
|
|
5
|
+
module Effects
|
|
6
|
+
# Processor that applies a fixed list of effects in sequence.
|
|
7
|
+
class EffectChain
|
|
8
|
+
attr_reader :effects
|
|
9
|
+
|
|
10
|
+
# @param effects [Array<#process,#call,#apply>]
|
|
11
|
+
def initialize(effects)
|
|
12
|
+
raise InvalidParameterError, "effects must be an Array" unless effects.is_a?(Array)
|
|
13
|
+
|
|
14
|
+
@effects = effects.map { |effect| validate_effect!(effect) }.freeze
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# @param buffer [Wavify::Core::SampleBuffer]
|
|
18
|
+
# @return [Wavify::Core::SampleBuffer]
|
|
19
|
+
def process(buffer)
|
|
20
|
+
raise InvalidParameterError, "buffer must be Core::SampleBuffer" unless buffer.is_a?(Core::SampleBuffer)
|
|
21
|
+
|
|
22
|
+
original_format = buffer.format
|
|
23
|
+
float_format = original_format.with(sample_format: :float, bit_depth: 32)
|
|
24
|
+
processed = @effects.reduce(buffer.convert(float_format)) { |current, effect| process_effect(effect, current) }
|
|
25
|
+
processed.convert(original_format)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Applies every effect using its offline entrypoint when available.
|
|
29
|
+
def apply(buffer)
|
|
30
|
+
raise InvalidParameterError, "buffer must be Core::SampleBuffer" unless buffer.is_a?(Core::SampleBuffer)
|
|
31
|
+
|
|
32
|
+
DSP::Processor.render(self, buffer)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Flushes effect tails through every downstream processor.
|
|
36
|
+
def flush(format: nil)
|
|
37
|
+
Enumerator.new do |yielder|
|
|
38
|
+
@effects.each_with_index do |effect, index|
|
|
39
|
+
DSP::Processor.flush(effect, format: format).each do |tail|
|
|
40
|
+
next unless tail.sample_frame_count.positive?
|
|
41
|
+
|
|
42
|
+
processed = @effects.drop(index + 1).reduce(tail) do |current, downstream|
|
|
43
|
+
process_effect(downstream, current)
|
|
44
|
+
end
|
|
45
|
+
yielder << processed
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# @return [EffectChain] self
|
|
52
|
+
def reset
|
|
53
|
+
@effects.each { |effect| effect.reset if effect.respond_to?(:reset) }
|
|
54
|
+
self
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Builds a chain whose processors do not share runtime state.
|
|
58
|
+
def build_runtime
|
|
59
|
+
runtime_effects = @effects.map { |effect| DSP::Processor.build_runtime(effect) }
|
|
60
|
+
runtime = dup
|
|
61
|
+
runtime.instance_variable_set(:@effects, runtime_effects.freeze)
|
|
62
|
+
runtime.reset
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# @return [Float]
|
|
66
|
+
def latency
|
|
67
|
+
@effects.sum { |effect| DSP::Processor.duration(effect, :latency) }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# @return [Float]
|
|
71
|
+
def lookahead
|
|
72
|
+
@effects.sum { |effect| DSP::Processor.duration(effect, :lookahead) }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# @return [Float]
|
|
76
|
+
def tail_duration
|
|
77
|
+
@effects.sum { |effect| DSP::Processor.duration(effect, :tail_duration) }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def process_effect(effect, buffer)
|
|
83
|
+
DSP::Processor.process(effect, buffer)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def validate_effect!(effect)
|
|
87
|
+
return effect if effect.respond_to?(:process) || effect.respond_to?(:call) || effect.respond_to?(:apply)
|
|
88
|
+
|
|
89
|
+
raise InvalidParameterError, "chain effects must respond to :process, :call, or :apply"
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wavify
|
|
4
|
+
module DSP
|
|
5
|
+
module Effects
|
|
6
|
+
# Peak envelope detector with attack, hold, and release timing.
|
|
7
|
+
class EnvelopeFollower
|
|
8
|
+
def initialize(attack:, hold:, release:)
|
|
9
|
+
@attack = validate_time!(attack, :attack)
|
|
10
|
+
@hold = validate_time!(hold, :hold)
|
|
11
|
+
@release = validate_time!(release, :release)
|
|
12
|
+
reset
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def prepare(sample_rate)
|
|
16
|
+
@attack_coefficient = time_coefficient(@attack, sample_rate)
|
|
17
|
+
@release_coefficient = time_coefficient(@release, sample_rate)
|
|
18
|
+
@hold_frames = (@hold * sample_rate).round
|
|
19
|
+
reset
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def follow(level)
|
|
23
|
+
if level > @envelope
|
|
24
|
+
@envelope = level + (@attack_coefficient * (@envelope - level))
|
|
25
|
+
@hold_remaining = @hold_frames
|
|
26
|
+
elsif @hold_remaining.positive?
|
|
27
|
+
@hold_remaining -= 1
|
|
28
|
+
else
|
|
29
|
+
@envelope = level + (@release_coefficient * (@envelope - level))
|
|
30
|
+
end
|
|
31
|
+
@envelope
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def reset
|
|
35
|
+
@envelope = 0.0
|
|
36
|
+
@hold_remaining = 0
|
|
37
|
+
self
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def time_coefficient(seconds, sample_rate)
|
|
43
|
+
return 0.0 if seconds.zero?
|
|
44
|
+
|
|
45
|
+
Math.exp(-1.0 / (seconds * sample_rate))
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def validate_time!(value, name)
|
|
49
|
+
unless value.is_a?(Numeric) && value.respond_to?(:finite?) && value.finite? && value >= 0.0
|
|
50
|
+
raise InvalidParameterError, "#{name} must be a non-negative finite Numeric"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
value.to_f
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
private_constant :EnvelopeFollower
|
|
57
|
+
|
|
58
|
+
# Shared frame-linked processing for gate and expansion effects.
|
|
59
|
+
module EnvelopeControlledEffect
|
|
60
|
+
def process(buffer)
|
|
61
|
+
raise InvalidParameterError, "buffer must be Core::SampleBuffer" unless buffer.is_a?(Core::SampleBuffer)
|
|
62
|
+
|
|
63
|
+
float_format = buffer.format.with(sample_format: :float, bit_depth: 32)
|
|
64
|
+
float_buffer = buffer.convert(float_format)
|
|
65
|
+
prepare_runtime_if_needed!(sample_rate: float_format.sample_rate, channels: float_format.channels)
|
|
66
|
+
|
|
67
|
+
output = []
|
|
68
|
+
float_buffer.samples.each_slice(@runtime_channels) do |frame|
|
|
69
|
+
envelope = @envelope_follower.follow(frame.reduce(0.0) { |peak, sample| [peak, sample.abs].max })
|
|
70
|
+
gain = gain_for_envelope(envelope)
|
|
71
|
+
output.concat(frame.map { |sample| sample * gain })
|
|
72
|
+
end
|
|
73
|
+
Core::SampleBuffer.new(output, float_format).convert(buffer.format)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def process_sample(_sample, channel:, sample_rate:)
|
|
77
|
+
raise NotImplementedError, "#{self.class} requires frame-aware #apply or #process"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def prepare_runtime_state(sample_rate:, channels:)
|
|
83
|
+
@envelope_follower.prepare(sample_rate)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def reset_runtime_state
|
|
87
|
+
@envelope_follower.reset
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
private_constant :EnvelopeControlledEffect
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../filter"
|
|
4
|
+
|
|
5
|
+
module Wavify
|
|
6
|
+
module DSP
|
|
7
|
+
module Effects
|
|
8
|
+
# Ordered chain of biquad filters for simple EQ curves.
|
|
9
|
+
class EQ
|
|
10
|
+
def initialize(*filters)
|
|
11
|
+
@filters = filters.flatten
|
|
12
|
+
raise InvalidParameterError, "at least one filter is required" if @filters.empty?
|
|
13
|
+
|
|
14
|
+
@filters.each do |filter|
|
|
15
|
+
raise InvalidParameterError, "filters must respond to :apply or :process" unless filter.respond_to?(:apply) || filter.respond_to?(:process)
|
|
16
|
+
end
|
|
17
|
+
@runtime_format = nil
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Convenience constructor for common tone-shaping bands.
|
|
21
|
+
#
|
|
22
|
+
# @param highpass [Numeric, nil]
|
|
23
|
+
# @param lowpass [Numeric, nil]
|
|
24
|
+
# @param presence [Hash, nil] `{ cutoff:, q:, gain_db: }`
|
|
25
|
+
# @return [EQ]
|
|
26
|
+
def self.simple(highpass: nil, lowpass: nil, presence: nil)
|
|
27
|
+
filters = []
|
|
28
|
+
filters << Wavify::DSP::Filter.highpass(cutoff: highpass) if highpass
|
|
29
|
+
filters << Wavify::DSP::Filter.lowpass(cutoff: lowpass) if lowpass
|
|
30
|
+
filters << Wavify::DSP::Filter.peaking(**presence) if presence
|
|
31
|
+
new(filters)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Applies all filters in order.
|
|
35
|
+
#
|
|
36
|
+
# @param buffer [Wavify::Core::SampleBuffer]
|
|
37
|
+
# @return [Wavify::Core::SampleBuffer]
|
|
38
|
+
def process(buffer)
|
|
39
|
+
raise InvalidParameterError, "buffer must be Core::SampleBuffer" unless buffer.is_a?(Core::SampleBuffer)
|
|
40
|
+
|
|
41
|
+
original_format = buffer.format
|
|
42
|
+
float_format = original_format.with(sample_format: :float, bit_depth: 32)
|
|
43
|
+
processed = @filters.reduce(buffer.convert(float_format)) do |current, filter|
|
|
44
|
+
DSP::Processor.process(filter, current)
|
|
45
|
+
end.convert(original_format)
|
|
46
|
+
@runtime_format = buffer.format
|
|
47
|
+
processed
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def apply(buffer)
|
|
51
|
+
DSP::Processor.render(self, buffer)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Resets stateful filters in the chain.
|
|
55
|
+
#
|
|
56
|
+
# @return [EQ] self
|
|
57
|
+
def reset
|
|
58
|
+
@filters.each { |filter| filter.reset if filter.respond_to?(:reset) }
|
|
59
|
+
@runtime_format = nil
|
|
60
|
+
self
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def build_runtime
|
|
64
|
+
self.class.new(@filters.map { |filter| DSP::Processor.build_runtime(filter) })
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Drains the filters' IIR state through the complete EQ chain.
|
|
68
|
+
def flush(format: nil)
|
|
69
|
+
return nil unless @runtime_format
|
|
70
|
+
|
|
71
|
+
Enumerator.new do |yielder|
|
|
72
|
+
@filters.each_with_index do |filter, index|
|
|
73
|
+
DSP::Processor.flush(filter, format: format || @runtime_format).each do |tail|
|
|
74
|
+
processed = @filters.drop(index + 1).reduce(tail) do |current, downstream|
|
|
75
|
+
DSP::Processor.process(downstream, current)
|
|
76
|
+
end
|
|
77
|
+
yielder << processed
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
ensure
|
|
81
|
+
@runtime_format = nil
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# @return [Float]
|
|
86
|
+
def latency
|
|
87
|
+
@filters.sum { |filter| DSP::Processor.duration(filter, :latency) }
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# @return [Float]
|
|
91
|
+
def lookahead
|
|
92
|
+
@filters.sum { |filter| DSP::Processor.duration(filter, :lookahead) }
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# @return [Float]
|
|
96
|
+
def tail_duration
|
|
97
|
+
@filters.sum { |filter| DSP::Processor.duration(filter, :tail_duration) }
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wavify
|
|
4
|
+
module DSP
|
|
5
|
+
module Effects
|
|
6
|
+
# Downward expander that reduces low-level material below a threshold.
|
|
7
|
+
class Expander < EffectBase
|
|
8
|
+
include EnvelopeControlledEffect
|
|
9
|
+
|
|
10
|
+
def initialize(threshold: -40.0, ratio: 2.0, floor: -80.0, attack: 0.001, hold: 0.02, release: 0.05,
|
|
11
|
+
gain_attack: 0.001, gain_release: 0.05)
|
|
12
|
+
super()
|
|
13
|
+
@threshold_db = validate_dbfs!(threshold, :threshold)
|
|
14
|
+
@ratio = validate_ratio!(ratio)
|
|
15
|
+
@floor_db = validate_dbfs!(floor, :floor)
|
|
16
|
+
@floor_gain = db_to_amplitude(@floor_db)
|
|
17
|
+
@gain_attack = validate_time!(gain_attack, :gain_attack)
|
|
18
|
+
@gain_release = validate_time!(gain_release, :gain_release)
|
|
19
|
+
@envelope_follower = EnvelopeFollower.new(attack: attack, hold: hold, release: release)
|
|
20
|
+
reset
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def gain_for_envelope(envelope)
|
|
26
|
+
target = target_gain_for_envelope(envelope)
|
|
27
|
+
coefficient = target > @gain ? @gain_attack_coefficient : @gain_release_coefficient
|
|
28
|
+
@gain = target + (coefficient * (@gain - target))
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def target_gain_for_envelope(envelope)
|
|
32
|
+
return 1.0 if envelope <= 0.0
|
|
33
|
+
|
|
34
|
+
input_db = 20.0 * Math.log10(envelope)
|
|
35
|
+
return 1.0 if input_db >= @threshold_db
|
|
36
|
+
|
|
37
|
+
gain_reduction_db = (@threshold_db - input_db) * (@ratio - 1.0)
|
|
38
|
+
[db_to_amplitude(-gain_reduction_db), @floor_gain].max
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def prepare_runtime_state(sample_rate:, channels:)
|
|
42
|
+
super
|
|
43
|
+
@gain_attack_coefficient = time_coefficient(@gain_attack, sample_rate)
|
|
44
|
+
@gain_release_coefficient = time_coefficient(@gain_release, sample_rate)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def reset_runtime_state
|
|
48
|
+
super
|
|
49
|
+
@gain = 1.0
|
|
50
|
+
@gain_attack_coefficient = nil
|
|
51
|
+
@gain_release_coefficient = nil
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def validate_dbfs!(value, name)
|
|
55
|
+
unless value.is_a?(Numeric) && value.respond_to?(:finite?) && value.finite? && value <= 0.0
|
|
56
|
+
raise InvalidParameterError, "#{name} must be a finite Numeric <= 0 dBFS"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
value.to_f
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def validate_ratio!(value)
|
|
63
|
+
unless value.is_a?(Numeric) && value.respond_to?(:finite?) && value.finite? && value >= 1.0
|
|
64
|
+
raise InvalidParameterError, "ratio must be a finite Numeric >= 1.0"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
value.to_f
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def validate_time!(value, name)
|
|
71
|
+
unless value.is_a?(Numeric) && value.respond_to?(:finite?) && value.finite? && value >= 0.0
|
|
72
|
+
raise InvalidParameterError, "#{name} must be a non-negative finite Numeric"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
value.to_f
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def time_coefficient(seconds, sample_rate)
|
|
79
|
+
return 0.0 if seconds.zero?
|
|
80
|
+
|
|
81
|
+
Math.exp(-1.0 / (seconds * sample_rate))
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def db_to_amplitude(db)
|
|
85
|
+
10.0**(db / 20.0)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wavify
|
|
4
|
+
module DSP
|
|
5
|
+
module Effects
|
|
6
|
+
# Short modulated comb delay effect.
|
|
7
|
+
class Flanger < EffectBase
|
|
8
|
+
MAX_DELAY_SECONDS = 0.006
|
|
9
|
+
TAIL_AMPLITUDE = 1.0e-6
|
|
10
|
+
|
|
11
|
+
def initialize(rate: 0.5, depth: 0.7, feedback: 0.35, mix: 0.5)
|
|
12
|
+
super()
|
|
13
|
+
@rate = validate_positive!(rate, :rate)
|
|
14
|
+
@depth = validate_unit!(depth, :depth)
|
|
15
|
+
@feedback = validate_feedback!(feedback)
|
|
16
|
+
@mix = validate_unit!(mix, :mix)
|
|
17
|
+
reset
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def process_sample(sample, channel:, sample_rate:)
|
|
21
|
+
dry = sample.to_f
|
|
22
|
+
line = @delay_lines.fetch(channel)
|
|
23
|
+
write_index = @write_indices.fetch(channel)
|
|
24
|
+
|
|
25
|
+
mod = @lfo.value_at(channel_phase_offset(channel))
|
|
26
|
+
delay_samples = @base_delay_samples + (@depth_delay_samples * ((mod + 1.0) / 2.0))
|
|
27
|
+
wet = read_fractional_delay(line, write_index, delay_samples)
|
|
28
|
+
|
|
29
|
+
line[write_index] = dry + (wet * @feedback)
|
|
30
|
+
@write_indices[channel] = (write_index + 1) % line.length
|
|
31
|
+
@lfo.next_value if channel == (@runtime_channels - 1)
|
|
32
|
+
|
|
33
|
+
(dry * (1.0 - @mix)) + (wet * @mix)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def tail_duration
|
|
37
|
+
return 0.0 if @mix.zero?
|
|
38
|
+
|
|
39
|
+
repetitions = if @feedback.zero?
|
|
40
|
+
1
|
|
41
|
+
else
|
|
42
|
+
(Math.log(TAIL_AMPLITUDE) / Math.log(@feedback.abs)).ceil
|
|
43
|
+
end
|
|
44
|
+
MAX_DELAY_SECONDS * [repetitions, 1].max
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def latency
|
|
48
|
+
@mix >= 1.0 ? MAX_DELAY_SECONDS : 0.0
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def prepare_runtime_state(sample_rate:, channels:)
|
|
54
|
+
@base_delay_samples = [(sample_rate * 0.0015).round, 1].max
|
|
55
|
+
@depth_delay_samples = (sample_rate * (MAX_DELAY_SECONDS - 0.0015) * @depth).to_f
|
|
56
|
+
line_length = [(@base_delay_samples + @depth_delay_samples.ceil + 3), 8].max
|
|
57
|
+
@delay_lines = Array.new(channels) { Array.new(line_length, 0.0) }
|
|
58
|
+
@write_indices = Array.new(channels, 0)
|
|
59
|
+
@lfo = DSP::LFO.new(rate: @rate, sample_rate: sample_rate)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def reset_runtime_state
|
|
63
|
+
@delay_lines = []
|
|
64
|
+
@write_indices = []
|
|
65
|
+
@base_delay_samples = nil
|
|
66
|
+
@depth_delay_samples = nil
|
|
67
|
+
@lfo = nil
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def read_fractional_delay(line, write_index, delay_samples)
|
|
71
|
+
integer = delay_samples.floor
|
|
72
|
+
fraction = delay_samples - integer
|
|
73
|
+
idx_a = (write_index - integer - 1) % line.length
|
|
74
|
+
idx_b = (idx_a - 1) % line.length
|
|
75
|
+
a = line[idx_a]
|
|
76
|
+
b = line[idx_b]
|
|
77
|
+
a + ((b - a) * fraction)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def channel_phase_offset(channel)
|
|
81
|
+
return 0.0 if @runtime_channels.nil? || @runtime_channels <= 1
|
|
82
|
+
|
|
83
|
+
channel.to_f / @runtime_channels
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def validate_positive!(value, name)
|
|
87
|
+
unless value.is_a?(Numeric) && value.respond_to?(:finite?) && value.finite? && value.positive?
|
|
88
|
+
raise InvalidParameterError, "#{name} must be a positive finite Numeric"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
value.to_f
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def validate_unit!(value, name)
|
|
95
|
+
unless value.is_a?(Numeric) && value.respond_to?(:finite?) && value.finite? && value.between?(0.0, 1.0)
|
|
96
|
+
raise InvalidParameterError, "#{name} must be a finite Numeric in 0.0..1.0"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
value.to_f
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def validate_feedback!(value)
|
|
103
|
+
unless value.is_a?(Numeric) && value.respond_to?(:finite?) && value.finite? && value.between?(-0.95, 0.95)
|
|
104
|
+
raise InvalidParameterError, "feedback must be a finite Numeric in -0.95..0.95"
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
value.to_f
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|