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,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wavify
|
|
4
|
+
module DSP
|
|
5
|
+
module Effects
|
|
6
|
+
# Soft-knee peak limiter for taming overs without a hard flat top.
|
|
7
|
+
class SoftLimiter < EffectBase
|
|
8
|
+
def initialize(threshold: 0.8, ceiling: 1.0, drive: 1.0)
|
|
9
|
+
super()
|
|
10
|
+
@threshold = validate_unit!(threshold, :threshold)
|
|
11
|
+
@ceiling = validate_unit!(ceiling, :ceiling)
|
|
12
|
+
raise InvalidParameterError, "ceiling must be greater than threshold" unless @ceiling > @threshold
|
|
13
|
+
|
|
14
|
+
@drive = validate_positive!(drive, :drive)
|
|
15
|
+
reset
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Processes a single sample for one channel.
|
|
19
|
+
#
|
|
20
|
+
# @param sample [Numeric]
|
|
21
|
+
# @param channel [Integer]
|
|
22
|
+
# @param sample_rate [Integer]
|
|
23
|
+
# @return [Float]
|
|
24
|
+
def process_sample(sample, channel:, sample_rate:)
|
|
25
|
+
value = sample.to_f
|
|
26
|
+
magnitude = value.abs
|
|
27
|
+
return value if magnitude <= @threshold
|
|
28
|
+
|
|
29
|
+
range = @ceiling - @threshold
|
|
30
|
+
sign = value.negative? ? -1.0 : 1.0
|
|
31
|
+
limited = @threshold + (range * (1.0 - Math.exp(-((magnitude - @threshold) * @drive) / range)))
|
|
32
|
+
sign * limited.clamp(@threshold, @ceiling)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def validate_unit!(value, name)
|
|
38
|
+
unless value.is_a?(Numeric) && value.respond_to?(:finite?) && value.finite? && value.between?(0.0, 1.0)
|
|
39
|
+
raise InvalidParameterError, "#{name} must be a finite Numeric in 0.0..1.0"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
value.to_f
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def validate_positive!(value, name)
|
|
46
|
+
unless value.is_a?(Numeric) && value.respond_to?(:finite?) && value.finite? && value.positive?
|
|
47
|
+
raise InvalidParameterError, "#{name} must be a positive finite Numeric"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
value.to_f
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wavify
|
|
4
|
+
module DSP
|
|
5
|
+
module Effects
|
|
6
|
+
# Mid/side stereo width processor.
|
|
7
|
+
class StereoWidener
|
|
8
|
+
def initialize(width: 1.25)
|
|
9
|
+
@width = validate_width!(width)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Applies mid/side width adjustment to a stereo buffer.
|
|
13
|
+
#
|
|
14
|
+
# @param buffer [Wavify::Core::SampleBuffer]
|
|
15
|
+
# @return [Wavify::Core::SampleBuffer]
|
|
16
|
+
def process(buffer)
|
|
17
|
+
raise InvalidParameterError, "buffer must be Core::SampleBuffer" unless buffer.is_a?(Core::SampleBuffer)
|
|
18
|
+
raise InvalidParameterError, "StereoWidener requires stereo input" unless buffer.format.channels == 2
|
|
19
|
+
|
|
20
|
+
float_format = buffer.format.with(sample_format: :float, bit_depth: 32)
|
|
21
|
+
float_buffer = buffer.convert(float_format)
|
|
22
|
+
widened = float_buffer.samples.each_slice(2).flat_map do |left, right|
|
|
23
|
+
mid = (left + right) / 2.0
|
|
24
|
+
side = ((left - right) / 2.0) * @width
|
|
25
|
+
[mid + side, mid - side]
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
Wavify::Core::SampleBuffer.new(widened, float_format).convert(buffer.format)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def apply(buffer)
|
|
32
|
+
DSP::Processor.render(self, buffer)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def build_runtime
|
|
36
|
+
dup.reset
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Stateless processor API compatibility.
|
|
40
|
+
#
|
|
41
|
+
# @return [StereoWidener] self
|
|
42
|
+
def reset
|
|
43
|
+
self
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# @return [Float]
|
|
47
|
+
def latency
|
|
48
|
+
0.0
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# @return [Float]
|
|
52
|
+
def lookahead
|
|
53
|
+
0.0
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# @return [Float]
|
|
57
|
+
def tail_duration
|
|
58
|
+
0.0
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def validate_width!(value)
|
|
64
|
+
unless value.is_a?(Numeric) && value.respond_to?(:finite?) && value.finite? && value >= 0.0
|
|
65
|
+
raise InvalidParameterError, "width must be a non-negative finite Numeric"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
value.to_f
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wavify
|
|
4
|
+
module DSP
|
|
5
|
+
module Effects
|
|
6
|
+
# Amplitude modulation effect using a sine LFO.
|
|
7
|
+
class Tremolo < EffectBase
|
|
8
|
+
def initialize(rate: 5.0, depth: 0.5, mix: 1.0)
|
|
9
|
+
super()
|
|
10
|
+
@rate = validate_positive!(rate, :rate)
|
|
11
|
+
@depth = validate_unit!(depth, :depth)
|
|
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
|
+
mod = 1.0 - (@depth * ((Math.sin(@phase) + 1.0) / 2.0))
|
|
25
|
+
wet = dry * mod
|
|
26
|
+
advance_phase! if channel == (@runtime_channels - 1)
|
|
27
|
+
|
|
28
|
+
(dry * (1.0 - @mix)) + (wet * @mix)
|
|
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 advance_phase!
|
|
44
|
+
@phase += @phase_step
|
|
45
|
+
@phase -= (2.0 * Math::PI) while @phase >= (2.0 * Math::PI)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def validate_positive!(value, name)
|
|
49
|
+
unless value.is_a?(Numeric) && value.respond_to?(:finite?) && value.finite? && value.positive?
|
|
50
|
+
raise InvalidParameterError, "#{name} must be a positive finite Numeric"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
value.to_f
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def validate_unit!(value, name)
|
|
57
|
+
unless value.is_a?(Numeric) && value.respond_to?(:finite?) && value.finite? && value.between?(0.0, 1.0)
|
|
58
|
+
raise InvalidParameterError, "#{name} must be a finite Numeric in 0.0..1.0"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
value.to_f
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wavify
|
|
4
|
+
module DSP
|
|
5
|
+
module Effects
|
|
6
|
+
# Pitch modulation effect using a short modulated delay line.
|
|
7
|
+
class Vibrato < EffectBase
|
|
8
|
+
BASE_DELAY_SECONDS = 0.006
|
|
9
|
+
DEPTH_DELAY_SECONDS = 0.004
|
|
10
|
+
|
|
11
|
+
def initialize(rate: 5.0, depth: 0.5, mix: 1.0)
|
|
12
|
+
super()
|
|
13
|
+
@rate = validate_positive!(rate, :rate)
|
|
14
|
+
@depth = validate_unit!(depth, :depth)
|
|
15
|
+
@mix = validate_unit!(mix, :mix)
|
|
16
|
+
reset
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def process_sample(sample, channel:, sample_rate:)
|
|
20
|
+
dry = sample.to_f
|
|
21
|
+
line = @delay_lines.fetch(channel)
|
|
22
|
+
write_index = @write_indices.fetch(channel)
|
|
23
|
+
|
|
24
|
+
mod = @lfo.value_at(channel_phase_offset(channel))
|
|
25
|
+
delay_samples = @base_delay_samples + (@depth_delay_samples * ((mod + 1.0) / 2.0))
|
|
26
|
+
wet = read_fractional_delay(line, write_index, delay_samples)
|
|
27
|
+
|
|
28
|
+
line[write_index] = dry
|
|
29
|
+
@write_indices[channel] = (write_index + 1) % line.length
|
|
30
|
+
@lfo.next_value if channel == (@runtime_channels - 1)
|
|
31
|
+
|
|
32
|
+
(dry * (1.0 - @mix)) + (wet * @mix)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def tail_duration
|
|
36
|
+
return 0.0 if @mix.zero?
|
|
37
|
+
|
|
38
|
+
maximum_delay
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def latency
|
|
42
|
+
@mix >= 1.0 ? maximum_delay : 0.0
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def prepare_runtime_state(sample_rate:, channels:)
|
|
48
|
+
@base_delay_samples = [(sample_rate * BASE_DELAY_SECONDS).round, 1].max
|
|
49
|
+
@depth_delay_samples = (sample_rate * DEPTH_DELAY_SECONDS * @depth).to_f
|
|
50
|
+
line_length = [(@base_delay_samples + @depth_delay_samples.ceil + 3), 8].max
|
|
51
|
+
@delay_lines = Array.new(channels) { Array.new(line_length, 0.0) }
|
|
52
|
+
@write_indices = Array.new(channels, 0)
|
|
53
|
+
@lfo = DSP::LFO.new(rate: @rate, sample_rate: sample_rate)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def reset_runtime_state
|
|
57
|
+
@delay_lines = []
|
|
58
|
+
@write_indices = []
|
|
59
|
+
@base_delay_samples = nil
|
|
60
|
+
@depth_delay_samples = nil
|
|
61
|
+
@lfo = nil
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def read_fractional_delay(line, write_index, delay_samples)
|
|
65
|
+
integer = delay_samples.floor
|
|
66
|
+
fraction = delay_samples - integer
|
|
67
|
+
idx_a = (write_index - integer - 1) % line.length
|
|
68
|
+
idx_b = (idx_a - 1) % line.length
|
|
69
|
+
a = line[idx_a]
|
|
70
|
+
b = line[idx_b]
|
|
71
|
+
a + ((b - a) * fraction)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def channel_phase_offset(channel)
|
|
75
|
+
return 0.0 if @runtime_channels.nil? || @runtime_channels <= 1
|
|
76
|
+
|
|
77
|
+
channel.to_f / @runtime_channels
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def maximum_delay
|
|
81
|
+
BASE_DELAY_SECONDS + (DEPTH_DELAY_SECONDS * @depth)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def validate_positive!(value, name)
|
|
85
|
+
unless value.is_a?(Numeric) && value.respond_to?(:finite?) && value.finite? && value.positive?
|
|
86
|
+
raise InvalidParameterError, "#{name} must be a positive finite Numeric"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
value.to_f
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def validate_unit!(value, name)
|
|
93
|
+
unless value.is_a?(Numeric) && value.respond_to?(:finite?) && value.finite? && value.between?(0.0, 1.0)
|
|
94
|
+
raise InvalidParameterError, "#{name} must be a finite Numeric in 0.0..1.0"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
value.to_f
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
data/lib/wavify/dsp/effects.rb
CHANGED
|
@@ -1,16 +1,122 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "effects/effect_base"
|
|
4
|
+
require_relative "effects/envelope_controlled_effect"
|
|
4
5
|
require_relative "effects/delay"
|
|
5
6
|
require_relative "effects/reverb"
|
|
6
7
|
require_relative "effects/chorus"
|
|
7
8
|
require_relative "effects/distortion"
|
|
8
9
|
require_relative "effects/compressor"
|
|
10
|
+
require_relative "effects/limiter"
|
|
11
|
+
require_relative "effects/soft_limiter"
|
|
12
|
+
require_relative "effects/noise_gate"
|
|
13
|
+
require_relative "effects/tremolo"
|
|
14
|
+
require_relative "effects/vibrato"
|
|
15
|
+
require_relative "effects/flanger"
|
|
16
|
+
require_relative "effects/phaser"
|
|
17
|
+
require_relative "effects/bitcrusher"
|
|
18
|
+
require_relative "effects/expander"
|
|
19
|
+
require_relative "effects/auto_pan"
|
|
20
|
+
require_relative "effects/stereo_widener"
|
|
21
|
+
require_relative "effects/eq"
|
|
22
|
+
require_relative "effects/effect_chain"
|
|
23
|
+
require_relative "effects/mastering_chain"
|
|
24
|
+
require_relative "effects/podcast_chain"
|
|
9
25
|
|
|
10
26
|
module Wavify
|
|
11
27
|
module DSP
|
|
12
28
|
# Built-in audio effects namespace.
|
|
13
29
|
module Effects
|
|
30
|
+
BUILTIN_EFFECTS = {
|
|
31
|
+
delay: Delay,
|
|
32
|
+
reverb: Reverb,
|
|
33
|
+
chorus: Chorus,
|
|
34
|
+
distortion: Distortion,
|
|
35
|
+
compressor: Compressor,
|
|
36
|
+
limiter: Limiter,
|
|
37
|
+
soft_limiter: SoftLimiter,
|
|
38
|
+
noise_gate: NoiseGate,
|
|
39
|
+
tremolo: Tremolo,
|
|
40
|
+
vibrato: Vibrato,
|
|
41
|
+
flanger: Flanger,
|
|
42
|
+
phaser: Phaser,
|
|
43
|
+
bitcrusher: Bitcrusher,
|
|
44
|
+
expander: Expander,
|
|
45
|
+
auto_pan: AutoPan,
|
|
46
|
+
stereo_widener: StereoWidener,
|
|
47
|
+
eq: EQ,
|
|
48
|
+
mastering_chain: MasteringChain,
|
|
49
|
+
podcast_chain: PodcastChain
|
|
50
|
+
}.freeze
|
|
51
|
+
|
|
52
|
+
class << self
|
|
53
|
+
# Registers an effect class or factory under a DSL-friendly name.
|
|
54
|
+
#
|
|
55
|
+
# @param name [Symbol, String]
|
|
56
|
+
# @param factory [Class, #call]
|
|
57
|
+
# @return [Class, #call]
|
|
58
|
+
def register(name, factory = nil, &block)
|
|
59
|
+
key = normalize_effect_name!(name)
|
|
60
|
+
value = factory || block
|
|
61
|
+
raise InvalidParameterError, "effect factory must be a Class or callable object" unless effect_factory?(value)
|
|
62
|
+
|
|
63
|
+
registry_mutex.synchronize { registry[key] = value }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Removes a custom effect or restores a built-in implementation.
|
|
67
|
+
def unregister(name)
|
|
68
|
+
key = normalize_effect_name!(name)
|
|
69
|
+
registry_mutex.synchronize do
|
|
70
|
+
previous = registry[key]
|
|
71
|
+
if BUILTIN_EFFECTS.key?(key)
|
|
72
|
+
registry[key] = BUILTIN_EFFECTS.fetch(key)
|
|
73
|
+
else
|
|
74
|
+
registry.delete(key)
|
|
75
|
+
end
|
|
76
|
+
previous
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Builds a registered effect instance.
|
|
81
|
+
#
|
|
82
|
+
# @param name [Symbol, String]
|
|
83
|
+
# @return [Object]
|
|
84
|
+
def build(name, **params)
|
|
85
|
+
key = normalize_effect_name!(name)
|
|
86
|
+
factory = registry_mutex.synchronize { registry[key] }
|
|
87
|
+
raise InvalidParameterError, "unsupported effect: #{key}" unless factory
|
|
88
|
+
|
|
89
|
+
effect = factory.is_a?(Class) ? factory.new(**params) : factory.call(**params)
|
|
90
|
+
return effect if effect.respond_to?(:process) || effect.respond_to?(:call) || effect.respond_to?(:apply)
|
|
91
|
+
|
|
92
|
+
raise InvalidParameterError, "registered effect #{key} must build a processor"
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# @return [Hash<Symbol, Object>]
|
|
96
|
+
def registered_effects
|
|
97
|
+
registry_mutex.synchronize { registry.dup.freeze }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
private
|
|
101
|
+
|
|
102
|
+
def registry
|
|
103
|
+
@registry ||= BUILTIN_EFFECTS.dup
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def registry_mutex
|
|
107
|
+
@registry_mutex ||= Mutex.new
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def normalize_effect_name!(name)
|
|
111
|
+
raise InvalidParameterError, "effect name must be Symbol or String" unless name.is_a?(Symbol) || name.is_a?(String)
|
|
112
|
+
|
|
113
|
+
name.to_sym
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def effect_factory?(value)
|
|
117
|
+
value.is_a?(Class) || value.respond_to?(:call)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
14
120
|
end
|
|
15
121
|
end
|
|
16
122
|
end
|
data/lib/wavify/dsp/envelope.rb
CHANGED
|
@@ -2,15 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
module Wavify
|
|
4
4
|
module DSP
|
|
5
|
-
#
|
|
5
|
+
# AHDSR envelope generator and buffer processor.
|
|
6
6
|
class Envelope
|
|
7
|
-
|
|
7
|
+
CURVES = %i[linear exp log].freeze # :nodoc:
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
attr_reader :attack, :hold, :decay, :sustain, :release, :curve
|
|
10
|
+
|
|
11
|
+
def initialize(attack:, decay:, sustain:, release:, hold: 0.0, curve: :linear)
|
|
10
12
|
@attack = validate_time!(attack, :attack)
|
|
13
|
+
@hold = validate_time!(hold, :hold)
|
|
11
14
|
@decay = validate_time!(decay, :decay)
|
|
12
15
|
@sustain = validate_sustain!(sustain)
|
|
13
16
|
@release = validate_time!(release, :release)
|
|
17
|
+
@curve = validate_curve!(curve)
|
|
14
18
|
end
|
|
15
19
|
|
|
16
20
|
# Computes envelope gain at a given playback time.
|
|
@@ -19,17 +23,17 @@ module Wavify
|
|
|
19
23
|
# @param note_on_duration [Numeric]
|
|
20
24
|
# @return [Float]
|
|
21
25
|
def gain_at(time, note_on_duration:)
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
26
|
+
unless finite_nonnegative?(time)
|
|
27
|
+
raise InvalidParameterError, "time must be a non-negative finite Numeric"
|
|
28
|
+
end
|
|
29
|
+
unless finite_nonnegative?(note_on_duration)
|
|
30
|
+
raise InvalidParameterError, "note_on_duration must be a non-negative finite Numeric"
|
|
25
31
|
end
|
|
26
32
|
|
|
27
|
-
return
|
|
28
|
-
return decay_gain(time) if time < (@attack + @decay)
|
|
29
|
-
return @sustain if time < note_on_duration
|
|
33
|
+
return sustain_stage_gain(time) if time < note_on_duration
|
|
30
34
|
|
|
31
35
|
release_time = time - note_on_duration
|
|
32
|
-
release_gain(release_time)
|
|
36
|
+
release_gain(release_time, start_gain: sustain_stage_gain(note_on_duration))
|
|
33
37
|
end
|
|
34
38
|
|
|
35
39
|
# Applies the envelope to a sample buffer.
|
|
@@ -39,8 +43,8 @@ module Wavify
|
|
|
39
43
|
# @return [Wavify::Core::SampleBuffer]
|
|
40
44
|
def apply(buffer, note_on_duration:)
|
|
41
45
|
raise InvalidParameterError, "buffer must be Core::SampleBuffer" unless buffer.is_a?(Core::SampleBuffer)
|
|
42
|
-
unless
|
|
43
|
-
raise InvalidParameterError, "note_on_duration must be a non-negative Numeric"
|
|
46
|
+
unless finite_nonnegative?(note_on_duration)
|
|
47
|
+
raise InvalidParameterError, "note_on_duration must be a non-negative finite Numeric"
|
|
44
48
|
end
|
|
45
49
|
|
|
46
50
|
float_format = buffer.format.with(sample_format: :float, bit_depth: 32)
|
|
@@ -60,38 +64,105 @@ module Wavify
|
|
|
60
64
|
Core::SampleBuffer.new(processed, float_format).convert(buffer.format)
|
|
61
65
|
end
|
|
62
66
|
|
|
67
|
+
# Applies the envelope and extends a short source through the release.
|
|
68
|
+
# The terminal source frame is held when more source audio is required.
|
|
69
|
+
def render_with_tail(buffer, note_on_duration:)
|
|
70
|
+
raise InvalidParameterError, "buffer must be Core::SampleBuffer" unless buffer.is_a?(Core::SampleBuffer)
|
|
71
|
+
unless finite_nonnegative?(note_on_duration)
|
|
72
|
+
raise InvalidParameterError, "note_on_duration must be a non-negative finite Numeric"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
target_frames = ((note_on_duration + @release) * buffer.format.sample_rate).ceil
|
|
76
|
+
return apply(buffer, note_on_duration: note_on_duration) if buffer.sample_frame_count >= target_frames
|
|
77
|
+
|
|
78
|
+
channels = buffer.format.channels
|
|
79
|
+
terminal_frame = buffer.samples.last(channels)
|
|
80
|
+
terminal_frame = Array.new(channels, 0) if terminal_frame.empty?
|
|
81
|
+
missing_frames = target_frames - buffer.sample_frame_count
|
|
82
|
+
extended = buffer.concat(Core::SampleBuffer.new(terminal_frame * missing_frames, buffer.format))
|
|
83
|
+
apply(extended, note_on_duration: note_on_duration)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def tail_duration
|
|
87
|
+
@release
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def latency
|
|
91
|
+
0.0
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def lookahead
|
|
95
|
+
0.0
|
|
96
|
+
end
|
|
97
|
+
|
|
63
98
|
private
|
|
64
99
|
|
|
65
100
|
def attack_gain(time)
|
|
66
101
|
return 1.0 if @attack.zero?
|
|
67
102
|
|
|
68
|
-
time / @attack
|
|
103
|
+
curve_factor(time / @attack)
|
|
69
104
|
end
|
|
70
105
|
|
|
71
106
|
def decay_gain(time)
|
|
72
107
|
return @sustain if @decay.zero?
|
|
73
108
|
|
|
74
|
-
elapsed = time - @attack
|
|
75
|
-
|
|
109
|
+
elapsed = time - @attack - @hold
|
|
110
|
+
progress = curve_factor(elapsed / @decay)
|
|
111
|
+
1.0 - ((1.0 - @sustain) * progress)
|
|
76
112
|
end
|
|
77
113
|
|
|
78
|
-
def release_gain(release_time)
|
|
114
|
+
def release_gain(release_time, start_gain:)
|
|
79
115
|
return 0.0 if @release.zero?
|
|
80
116
|
|
|
81
|
-
|
|
117
|
+
start_gain * (1.0 - curve_factor(release_time / @release))
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def sustain_stage_gain(time)
|
|
121
|
+
return attack_gain(time) if time < @attack
|
|
122
|
+
return 1.0 if time < (@attack + @hold)
|
|
123
|
+
return decay_gain(time) if time < (@attack + @hold + @decay)
|
|
124
|
+
|
|
125
|
+
@sustain
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def curve_factor(value)
|
|
129
|
+
progress = value.clamp(0.0, 1.0)
|
|
130
|
+
case @curve
|
|
131
|
+
when :linear
|
|
132
|
+
progress
|
|
133
|
+
when :exp
|
|
134
|
+
progress * progress
|
|
135
|
+
when :log
|
|
136
|
+
Math.log10(1.0 + (9.0 * progress))
|
|
137
|
+
end
|
|
82
138
|
end
|
|
83
139
|
|
|
84
140
|
def validate_time!(value, name)
|
|
85
|
-
|
|
141
|
+
unless value.is_a?(Numeric) && value.respond_to?(:finite?) && value.finite? && value >= 0
|
|
142
|
+
raise InvalidParameterError, "#{name} must be a non-negative finite Numeric"
|
|
143
|
+
end
|
|
86
144
|
|
|
87
145
|
value.to_f
|
|
88
146
|
end
|
|
89
147
|
|
|
90
148
|
def validate_sustain!(value)
|
|
91
|
-
|
|
149
|
+
unless value.is_a?(Numeric) && value.respond_to?(:finite?) && value.finite? && value.between?(0.0, 1.0)
|
|
150
|
+
raise InvalidParameterError, "sustain must be a finite Numeric in 0.0..1.0"
|
|
151
|
+
end
|
|
92
152
|
|
|
93
153
|
value.to_f
|
|
94
154
|
end
|
|
155
|
+
|
|
156
|
+
def validate_curve!(value)
|
|
157
|
+
normalized = value.to_sym if value.respond_to?(:to_sym)
|
|
158
|
+
return normalized if CURVES.include?(normalized)
|
|
159
|
+
|
|
160
|
+
raise InvalidParameterError, "curve must be one of: #{CURVES.join(', ')}"
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def finite_nonnegative?(value)
|
|
164
|
+
value.is_a?(Numeric) && value.respond_to?(:finite?) && value.finite? && value >= 0.0
|
|
165
|
+
end
|
|
95
166
|
end
|
|
96
167
|
end
|
|
97
168
|
end
|