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.
Files changed (90) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +40 -1
  3. data/README.md +204 -22
  4. data/exe/wavify +6 -0
  5. data/lib/wavify/adapters.rb +81 -0
  6. data/lib/wavify/audio.rb +1066 -120
  7. data/lib/wavify/cli.rb +237 -0
  8. data/lib/wavify/codecs/aiff.rb +388 -87
  9. data/lib/wavify/codecs/base.rb +98 -5
  10. data/lib/wavify/codecs/flac.rb +660 -170
  11. data/lib/wavify/codecs/ogg_vorbis.rb +515 -155
  12. data/lib/wavify/codecs/raw.rb +235 -45
  13. data/lib/wavify/codecs/registry.rb +272 -10
  14. data/lib/wavify/codecs/wav.rb +453 -74
  15. data/lib/wavify/core/duration.rb +54 -2
  16. data/lib/wavify/core/format.rb +96 -11
  17. data/lib/wavify/core/sample_buffer.rb +648 -60
  18. data/lib/wavify/core/stream.rb +590 -34
  19. data/lib/wavify/dsl.rb +712 -100
  20. data/lib/wavify/dsp/automation.rb +109 -0
  21. data/lib/wavify/dsp/effects/auto_pan.rb +71 -0
  22. data/lib/wavify/dsp/effects/bitcrusher.rb +74 -0
  23. data/lib/wavify/dsp/effects/chorus.rb +15 -4
  24. data/lib/wavify/dsp/effects/compressor.rb +133 -23
  25. data/lib/wavify/dsp/effects/delay.rb +46 -1
  26. data/lib/wavify/dsp/effects/distortion.rb +3 -2
  27. data/lib/wavify/dsp/effects/effect_base.rb +69 -2
  28. data/lib/wavify/dsp/effects/effect_chain.rb +94 -0
  29. data/lib/wavify/dsp/effects/envelope_controlled_effect.rb +93 -0
  30. data/lib/wavify/dsp/effects/eq.rb +102 -0
  31. data/lib/wavify/dsp/effects/expander.rb +90 -0
  32. data/lib/wavify/dsp/effects/flanger.rb +112 -0
  33. data/lib/wavify/dsp/effects/limiter.rb +259 -0
  34. data/lib/wavify/dsp/effects/mastering_chain.rb +31 -0
  35. data/lib/wavify/dsp/effects/noise_gate.rb +72 -0
  36. data/lib/wavify/dsp/effects/phaser.rb +112 -0
  37. data/lib/wavify/dsp/effects/podcast_chain.rb +32 -0
  38. data/lib/wavify/dsp/effects/reverb.rb +100 -14
  39. data/lib/wavify/dsp/effects/soft_limiter.rb +55 -0
  40. data/lib/wavify/dsp/effects/stereo_widener.rb +73 -0
  41. data/lib/wavify/dsp/effects/tremolo.rb +66 -0
  42. data/lib/wavify/dsp/effects/vibrato.rb +102 -0
  43. data/lib/wavify/dsp/effects.rb +106 -0
  44. data/lib/wavify/dsp/envelope.rb +90 -19
  45. data/lib/wavify/dsp/filter.rb +149 -8
  46. data/lib/wavify/dsp/headroom.rb +57 -0
  47. data/lib/wavify/dsp/lfo.rb +65 -0
  48. data/lib/wavify/dsp/loudness_meter.rb +281 -0
  49. data/lib/wavify/dsp/oscillator.rb +261 -22
  50. data/lib/wavify/dsp/processor.rb +90 -0
  51. data/lib/wavify/errors.rb +2 -2
  52. data/lib/wavify/sequencer/engine.rb +405 -89
  53. data/lib/wavify/sequencer/note_sequence.rb +45 -7
  54. data/lib/wavify/sequencer/pattern.rb +121 -12
  55. data/lib/wavify/sequencer/track.rb +149 -10
  56. data/lib/wavify/version.rb +1 -1
  57. data/lib/wavify.rb +18 -0
  58. data/sig/audio.rbs +86 -0
  59. data/sig/codecs.rbs +77 -0
  60. data/sig/core.rbs +108 -0
  61. data/sig/dsl.rbs +32 -0
  62. data/sig/dsp.rbs +87 -0
  63. data/sig/effects.rbs +129 -0
  64. data/sig/public_api.yml +111 -0
  65. data/sig/sequencer.rbs +126 -0
  66. data/sig/stream.rbs +30 -0
  67. data/sig/wavify.rbs +24 -0
  68. metadata +44 -58
  69. data/.serena/.gitignore +0 -1
  70. data/.serena/memories/project_overview.md +0 -5
  71. data/.serena/memories/style_and_completion.md +0 -5
  72. data/.serena/memories/suggested_commands.md +0 -11
  73. data/.serena/project.yml +0 -126
  74. data/.simplecov +0 -18
  75. data/.yardopts +0 -4
  76. data/Rakefile +0 -190
  77. data/benchmarks/README.md +0 -46
  78. data/benchmarks/benchmark_helper.rb +0 -112
  79. data/benchmarks/dsp_effects_benchmark.rb +0 -46
  80. data/benchmarks/flac_benchmark.rb +0 -74
  81. data/benchmarks/streaming_memory_benchmark.rb +0 -94
  82. data/benchmarks/wav_io_benchmark.rb +0 -110
  83. data/examples/audio_processing.rb +0 -73
  84. data/examples/cinematic_transition.rb +0 -118
  85. data/examples/drum_machine.rb +0 -74
  86. data/examples/format_convert.rb +0 -81
  87. data/examples/hybrid_arrangement.rb +0 -165
  88. data/examples/streaming_master_chain.rb +0 -129
  89. data/examples/synth_pad.rb +0 -42
  90. 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
@@ -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
@@ -2,15 +2,19 @@
2
2
 
3
3
  module Wavify
4
4
  module DSP
5
- # ADSR envelope generator and buffer processor.
5
+ # AHDSR envelope generator and buffer processor.
6
6
  class Envelope
7
- attr_reader :attack, :decay, :sustain, :release
7
+ CURVES = %i[linear exp log].freeze # :nodoc:
8
8
 
9
- def initialize(attack:, decay:, sustain:, release:)
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
- 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"
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 attack_gain(time) if time < @attack
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 note_on_duration.is_a?(Numeric) && note_on_duration >= 0
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
- 1.0 - ((1.0 - @sustain) * (elapsed / @decay))
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
- @sustain * (1.0 - [release_time / @release, 1.0].min)
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
- raise InvalidParameterError, "#{name} must be a non-negative Numeric" unless value.is_a?(Numeric) && value >= 0
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
- raise InvalidParameterError, "sustain must be Numeric in 0.0..1.0" unless value.is_a?(Numeric) && value.between?(0.0, 1.0)
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