wavify 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. checksums.yaml +7 -0
  2. data/.serena/.gitignore +1 -0
  3. data/.serena/memories/project_overview.md +5 -0
  4. data/.serena/memories/style_and_completion.md +5 -0
  5. data/.serena/memories/suggested_commands.md +11 -0
  6. data/.serena/project.yml +126 -0
  7. data/.simplecov +18 -0
  8. data/.yardopts +4 -0
  9. data/CHANGELOG.md +11 -0
  10. data/LICENSE +21 -0
  11. data/README.md +196 -0
  12. data/Rakefile +190 -0
  13. data/benchmarks/README.md +46 -0
  14. data/benchmarks/benchmark_helper.rb +112 -0
  15. data/benchmarks/dsp_effects_benchmark.rb +46 -0
  16. data/benchmarks/flac_benchmark.rb +74 -0
  17. data/benchmarks/streaming_memory_benchmark.rb +94 -0
  18. data/benchmarks/wav_io_benchmark.rb +110 -0
  19. data/examples/audio_processing.rb +73 -0
  20. data/examples/cinematic_transition.rb +118 -0
  21. data/examples/drum_machine.rb +74 -0
  22. data/examples/format_convert.rb +81 -0
  23. data/examples/hybrid_arrangement.rb +165 -0
  24. data/examples/streaming_master_chain.rb +129 -0
  25. data/examples/synth_pad.rb +42 -0
  26. data/lib/wavify/audio.rb +483 -0
  27. data/lib/wavify/codecs/aiff.rb +338 -0
  28. data/lib/wavify/codecs/base.rb +108 -0
  29. data/lib/wavify/codecs/flac.rb +1322 -0
  30. data/lib/wavify/codecs/ogg_vorbis.rb +1447 -0
  31. data/lib/wavify/codecs/raw.rb +193 -0
  32. data/lib/wavify/codecs/registry.rb +87 -0
  33. data/lib/wavify/codecs/wav.rb +459 -0
  34. data/lib/wavify/core/duration.rb +99 -0
  35. data/lib/wavify/core/format.rb +133 -0
  36. data/lib/wavify/core/sample_buffer.rb +216 -0
  37. data/lib/wavify/core/stream.rb +129 -0
  38. data/lib/wavify/dsl.rb +537 -0
  39. data/lib/wavify/dsp/effects/chorus.rb +98 -0
  40. data/lib/wavify/dsp/effects/compressor.rb +85 -0
  41. data/lib/wavify/dsp/effects/delay.rb +69 -0
  42. data/lib/wavify/dsp/effects/distortion.rb +64 -0
  43. data/lib/wavify/dsp/effects/effect_base.rb +68 -0
  44. data/lib/wavify/dsp/effects/reverb.rb +112 -0
  45. data/lib/wavify/dsp/effects.rb +21 -0
  46. data/lib/wavify/dsp/envelope.rb +97 -0
  47. data/lib/wavify/dsp/filter.rb +271 -0
  48. data/lib/wavify/dsp/oscillator.rb +123 -0
  49. data/lib/wavify/errors.rb +34 -0
  50. data/lib/wavify/sequencer/engine.rb +278 -0
  51. data/lib/wavify/sequencer/note_sequence.rb +132 -0
  52. data/lib/wavify/sequencer/pattern.rb +102 -0
  53. data/lib/wavify/sequencer/track.rb +298 -0
  54. data/lib/wavify/sequencer.rb +12 -0
  55. data/lib/wavify/version.rb +6 -0
  56. data/lib/wavify.rb +28 -0
  57. data/tools/fixture_writer.rb +85 -0
  58. metadata +129 -0
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wavify
4
+ module DSP
5
+ module Effects
6
+ # Peak compressor with threshold, ratio, attack, and release controls.
7
+ class Compressor < EffectBase
8
+ def initialize(threshold: -10, ratio: 4, attack: 0.01, release: 0.1)
9
+ super()
10
+ @threshold_db = validate_numeric!(threshold, :threshold).to_f
11
+ @ratio = validate_ratio!(ratio)
12
+ @attack = validate_time!(attack, :attack)
13
+ @release = validate_time!(release, :release)
14
+ reset
15
+ end
16
+
17
+ # Processes a single sample for one channel.
18
+ #
19
+ # @param sample [Numeric]
20
+ # @param channel [Integer]
21
+ # @param sample_rate [Integer]
22
+ # @return [Float]
23
+ def process_sample(sample, channel:, sample_rate:)
24
+ x = sample.to_f
25
+ level = x.abs
26
+
27
+ attack_coeff = time_coefficient(@attack, sample_rate)
28
+ release_coeff = time_coefficient(@release, sample_rate)
29
+ envelope = @envelopes.fetch(channel)
30
+ coeff = level > envelope ? attack_coeff : release_coeff
31
+ envelope += (1.0 - coeff) * (level - envelope)
32
+ @envelopes[channel] = envelope
33
+
34
+ gain = gain_for_envelope(envelope)
35
+ x * gain
36
+ end
37
+
38
+ private
39
+
40
+ def prepare_runtime_state(sample_rate:, channels:)
41
+ @envelopes = Array.new(channels, 0.0)
42
+ @threshold_linear = 10.0**(@threshold_db / 20.0)
43
+ end
44
+
45
+ def reset_runtime_state
46
+ @envelopes = []
47
+ @threshold_linear = nil
48
+ end
49
+
50
+ def gain_for_envelope(envelope)
51
+ return 1.0 if envelope <= 0.0 || envelope <= @threshold_linear
52
+
53
+ input_db = 20.0 * Math.log10(envelope)
54
+ output_db = @threshold_db + ((input_db - @threshold_db) / @ratio)
55
+ gain_db = output_db - input_db
56
+ 10.0**(gain_db / 20.0)
57
+ end
58
+
59
+ def time_coefficient(seconds, sample_rate)
60
+ return 0.0 if seconds <= 0.0
61
+
62
+ Math.exp(-1.0 / (seconds * sample_rate))
63
+ end
64
+
65
+ def validate_numeric!(value, name)
66
+ raise InvalidParameterError, "#{name} must be Numeric" unless value.is_a?(Numeric)
67
+
68
+ value
69
+ end
70
+
71
+ def validate_ratio!(value)
72
+ raise InvalidParameterError, "ratio must be >= 1.0" unless value.is_a?(Numeric) && value >= 1.0
73
+
74
+ value.to_f
75
+ end
76
+
77
+ def validate_time!(value, name)
78
+ raise InvalidParameterError, "#{name} must be a non-negative Numeric" unless value.is_a?(Numeric) && value >= 0.0
79
+
80
+ value.to_f
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wavify
4
+ module DSP
5
+ module Effects
6
+ # Simple feedback delay effect.
7
+ class Delay < EffectBase
8
+ def initialize(time: 0.3, feedback: 0.5, mix: 0.3)
9
+ super()
10
+ @time = validate_time!(time)
11
+ @feedback = validate_ratio!(feedback, :feedback)
12
+ @mix = validate_mix!(mix)
13
+ reset
14
+ end
15
+
16
+ # Processes a single sample for one channel.
17
+ #
18
+ # @param sample [Numeric]
19
+ # @param channel [Integer]
20
+ # @param sample_rate [Integer]
21
+ # @return [Float]
22
+ def process_sample(sample, channel:, sample_rate:)
23
+ line = @delay_lines.fetch(channel)
24
+ index = @write_indices.fetch(channel)
25
+ delayed = line[index]
26
+ dry = sample.to_f
27
+ wet = delayed
28
+
29
+ output = (dry * (1.0 - @mix)) + (wet * @mix)
30
+ line[index] = (dry + (wet * @feedback)).clamp(-1.0, 1.0)
31
+ @write_indices[channel] = (index + 1) % line.length
32
+
33
+ output
34
+ end
35
+
36
+ private
37
+
38
+ def prepare_runtime_state(sample_rate:, channels:)
39
+ delay_samples = [(sample_rate * @time).round, 1].max
40
+ @delay_lines = Array.new(channels) { Array.new(delay_samples, 0.0) }
41
+ @write_indices = Array.new(channels, 0)
42
+ end
43
+
44
+ def reset_runtime_state
45
+ @delay_lines = []
46
+ @write_indices = []
47
+ end
48
+
49
+ def validate_time!(value)
50
+ raise InvalidParameterError, "time must be a positive Numeric" unless value.is_a?(Numeric) && value.positive?
51
+
52
+ value.to_f
53
+ end
54
+
55
+ def validate_ratio!(value, name)
56
+ raise InvalidParameterError, "#{name} must be in 0.0...1.0" unless value.is_a?(Numeric) && value >= 0.0 && value < 1.0
57
+
58
+ value.to_f
59
+ end
60
+
61
+ def validate_mix!(value)
62
+ raise InvalidParameterError, "mix must be Numeric in 0.0..1.0" unless value.is_a?(Numeric) && value.between?(0.0, 1.0)
63
+
64
+ value.to_f
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wavify
4
+ module DSP
5
+ module Effects
6
+ # Soft-clipping distortion with tone shaping and dry/wet mix.
7
+ class Distortion < EffectBase
8
+ def initialize(drive: 0.5, tone: 0.5, mix: 1.0)
9
+ super()
10
+ @drive = validate_unit!(drive, :drive)
11
+ @tone = validate_unit!(tone, :tone)
12
+ @mix = validate_unit!(mix, :mix)
13
+ reset
14
+ end
15
+
16
+ # Processes a single sample for one channel.
17
+ #
18
+ # @param sample [Numeric]
19
+ # @param channel [Integer]
20
+ # @param sample_rate [Integer]
21
+ # @return [Float]
22
+ def process_sample(sample, channel:, sample_rate:)
23
+ dry = sample.to_f
24
+ wet = Math.tanh(dry * pre_gain)
25
+
26
+ # One-pole low-pass on the distorted signal for tone shaping.
27
+ coeff = tone_coefficient(sample_rate)
28
+ previous = @tone_state.fetch(channel)
29
+ filtered = previous + (coeff * (wet - previous))
30
+ @tone_state[channel] = filtered
31
+
32
+ (dry * (1.0 - @mix)) + (filtered * @mix)
33
+ end
34
+
35
+ private
36
+
37
+ def prepare_runtime_state(sample_rate:, channels:)
38
+ @tone_state = Array.new(channels, 0.0)
39
+ end
40
+
41
+ def reset_runtime_state
42
+ @tone_state = []
43
+ end
44
+
45
+ def pre_gain
46
+ 1.0 + (@drive * 19.0)
47
+ end
48
+
49
+ def tone_coefficient(sample_rate)
50
+ cutoff = 500.0 + (@tone * 7_500.0)
51
+ rc = 1.0 / (2.0 * Math::PI * cutoff)
52
+ dt = 1.0 / sample_rate
53
+ dt / (rc + dt)
54
+ end
55
+
56
+ def validate_unit!(value, name)
57
+ raise InvalidParameterError, "#{name} must be Numeric in 0.0..1.0" unless value.is_a?(Numeric) && value.between?(0.0, 1.0)
58
+
59
+ value.to_f
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wavify
4
+ module DSP
5
+ module Effects
6
+ # Base class for sample-by-sample effects with runtime channel state.
7
+ class EffectBase
8
+ # Applies the effect to a sample buffer.
9
+ #
10
+ # @param buffer [Wavify::Core::SampleBuffer]
11
+ # @return [Wavify::Core::SampleBuffer]
12
+ def apply(buffer)
13
+ process(buffer)
14
+ end
15
+
16
+ def process(buffer)
17
+ raise InvalidParameterError, "buffer must be Core::SampleBuffer" unless buffer.is_a?(Core::SampleBuffer)
18
+
19
+ float_format = buffer.format.with(sample_format: :float, bit_depth: 32)
20
+ float_buffer = buffer.convert(float_format)
21
+
22
+ prepare_runtime_if_needed!(
23
+ sample_rate: float_format.sample_rate,
24
+ channels: float_buffer.format.channels
25
+ )
26
+
27
+ output = Array.new(float_buffer.samples.length)
28
+ float_buffer.samples.each_with_index do |sample, sample_index|
29
+ channel = sample_index % @runtime_channels
30
+ output[sample_index] = process_sample(sample, channel: channel, sample_rate: @runtime_sample_rate).clamp(-1.0, 1.0)
31
+ end
32
+
33
+ Core::SampleBuffer.new(output, float_format).convert(buffer.format)
34
+ end
35
+
36
+ def process_sample(_sample, channel:, sample_rate:)
37
+ raise NotImplementedError
38
+ end
39
+
40
+ # Resets runtime state and cached channel/sample-rate information.
41
+ #
42
+ # @return [EffectBase] self
43
+ def reset
44
+ @runtime_sample_rate = nil
45
+ @runtime_channels = nil
46
+ reset_runtime_state
47
+ self
48
+ end
49
+
50
+ private
51
+
52
+ def prepare_runtime_if_needed!(sample_rate:, channels:)
53
+ return if @runtime_sample_rate == sample_rate && @runtime_channels == channels
54
+
55
+ @runtime_sample_rate = sample_rate
56
+ @runtime_channels = channels
57
+ reset_runtime_state
58
+ prepare_runtime_state(sample_rate: sample_rate, channels: channels)
59
+ end
60
+
61
+ def prepare_runtime_state(sample_rate:, channels:); end
62
+
63
+ def reset_runtime_state; end
64
+
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wavify
4
+ module DSP
5
+ module Effects
6
+ # Lightweight Schroeder-style reverb effect.
7
+ class Reverb < EffectBase
8
+ COMB_TAPS_44K = [1116, 1188, 1277, 1356].freeze # :nodoc:
9
+ ALLPASS_TAPS_44K = [556, 441].freeze # :nodoc:
10
+
11
+ def initialize(room_size: 0.5, damping: 0.5, mix: 0.3)
12
+ super()
13
+ @room_size = validate_unit!(room_size, :room_size)
14
+ @damping = validate_unit!(damping, :damping)
15
+ @mix = validate_unit!(mix, :mix)
16
+ reset
17
+ end
18
+
19
+ # Processes a single sample for one channel.
20
+ #
21
+ # @param sample [Numeric]
22
+ # @param channel [Integer]
23
+ # @param sample_rate [Integer]
24
+ # @return [Float]
25
+ def process_sample(sample, channel:, sample_rate:)
26
+ dry = sample.to_f
27
+ channel_state = @channels_state.fetch(channel)
28
+
29
+ comb_input = dry * @input_gain
30
+ comb_sum = 0.0
31
+ channel_state[:combs].each do |comb|
32
+ comb_sum += process_comb(comb, comb_input)
33
+ end
34
+
35
+ wet = comb_sum / channel_state[:combs].length
36
+ channel_state[:allpasses].each do |allpass|
37
+ wet = process_allpass(allpass, wet)
38
+ end
39
+
40
+ (dry * (1.0 - @mix)) + (wet * @mix)
41
+ end
42
+
43
+ private
44
+
45
+ def prepare_runtime_state(sample_rate:, channels:)
46
+ scale = sample_rate.to_f / 44_100.0
47
+ comb_feedback = 0.6 + (@room_size * 0.34)
48
+ damping = @damping
49
+
50
+ @input_gain = 0.35
51
+ @channels_state = Array.new(channels) do
52
+ combs = COMB_TAPS_44K.map do |tap|
53
+ length = [(tap * scale).round, 8].max
54
+ {
55
+ buffer: Array.new(length, 0.0),
56
+ index: 0,
57
+ feedback: comb_feedback,
58
+ damping: damping,
59
+ filter_store: 0.0
60
+ }
61
+ end
62
+ allpasses = ALLPASS_TAPS_44K.map do |tap|
63
+ length = [(tap * scale).round, 4].max
64
+ {
65
+ buffer: Array.new(length, 0.0),
66
+ index: 0,
67
+ feedback: 0.5
68
+ }
69
+ end
70
+ { combs: combs, allpasses: allpasses }
71
+ end
72
+ end
73
+
74
+ def reset_runtime_state
75
+ @channels_state = []
76
+ @input_gain = nil
77
+ end
78
+
79
+ def process_comb(comb, input_sample)
80
+ buffer = comb[:buffer]
81
+ index = comb[:index]
82
+ delayed = buffer[index]
83
+
84
+ filter_store = (delayed * (1.0 - comb[:damping])) + (comb[:filter_store] * comb[:damping])
85
+ comb[:filter_store] = filter_store
86
+ buffer[index] = (input_sample + (filter_store * comb[:feedback])).clamp(-1.0, 1.0)
87
+ comb[:index] = (index + 1) % buffer.length
88
+
89
+ delayed
90
+ end
91
+
92
+ def process_allpass(allpass, input_sample)
93
+ buffer = allpass[:buffer]
94
+ index = allpass[:index]
95
+ delayed = buffer[index]
96
+
97
+ output = delayed - input_sample
98
+ buffer[index] = input_sample + (delayed * allpass[:feedback])
99
+ allpass[:index] = (index + 1) % buffer.length
100
+
101
+ output
102
+ end
103
+
104
+ def validate_unit!(value, name)
105
+ raise InvalidParameterError, "#{name} must be Numeric in 0.0..1.0" unless value.is_a?(Numeric) && value.between?(0.0, 1.0)
106
+
107
+ value.to_f
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "effects/effect_base"
4
+ require_relative "effects/delay"
5
+ require_relative "effects/reverb"
6
+ require_relative "effects/chorus"
7
+ require_relative "effects/distortion"
8
+ require_relative "effects/compressor"
9
+
10
+ module Wavify
11
+ module DSP
12
+ # Built-in audio effects namespace.
13
+ module Effects
14
+ end
15
+ end
16
+ end
17
+
18
+ module Wavify
19
+ # Convenience alias for {Wavify::DSP::Effects}.
20
+ Effects = DSP::Effects unless const_defined?(:Effects)
21
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wavify
4
+ module DSP
5
+ # ADSR envelope generator and buffer processor.
6
+ class Envelope
7
+ attr_reader :attack, :decay, :sustain, :release
8
+
9
+ def initialize(attack:, decay:, sustain:, release:)
10
+ @attack = validate_time!(attack, :attack)
11
+ @decay = validate_time!(decay, :decay)
12
+ @sustain = validate_sustain!(sustain)
13
+ @release = validate_time!(release, :release)
14
+ end
15
+
16
+ # Computes envelope gain at a given playback time.
17
+ #
18
+ # @param time [Numeric]
19
+ # @param note_on_duration [Numeric]
20
+ # @return [Float]
21
+ def gain_at(time, note_on_duration:)
22
+ raise InvalidParameterError, "time must be a non-negative Numeric" unless time.is_a?(Numeric) && time >= 0
23
+ unless note_on_duration.is_a?(Numeric) && note_on_duration >= 0
24
+ raise InvalidParameterError, "note_on_duration must be a non-negative Numeric"
25
+ end
26
+
27
+ return attack_gain(time) if time < @attack
28
+ return decay_gain(time) if time < (@attack + @decay)
29
+ return @sustain if time < note_on_duration
30
+
31
+ release_time = time - note_on_duration
32
+ release_gain(release_time)
33
+ end
34
+
35
+ # Applies the envelope to a sample buffer.
36
+ #
37
+ # @param buffer [Wavify::Core::SampleBuffer]
38
+ # @param note_on_duration [Numeric]
39
+ # @return [Wavify::Core::SampleBuffer]
40
+ def apply(buffer, note_on_duration:)
41
+ raise InvalidParameterError, "buffer must be Core::SampleBuffer" unless buffer.is_a?(Core::SampleBuffer)
42
+ unless note_on_duration.is_a?(Numeric) && note_on_duration >= 0
43
+ raise InvalidParameterError, "note_on_duration must be a non-negative Numeric"
44
+ end
45
+
46
+ float_format = buffer.format.with(sample_format: :float, bit_depth: 32)
47
+ float_buffer = buffer.convert(float_format)
48
+ channels = float_format.channels
49
+ sample_rate = float_format.sample_rate
50
+
51
+ processed = float_buffer.samples.dup
52
+ processed.each_slice(channels).with_index do |frame, frame_index|
53
+ gain = gain_at(frame_index.to_f / sample_rate, note_on_duration: note_on_duration)
54
+ base = frame_index * channels
55
+ frame.each_index do |channel_index|
56
+ processed[base + channel_index] = frame[channel_index] * gain
57
+ end
58
+ end
59
+
60
+ Core::SampleBuffer.new(processed, float_format).convert(buffer.format)
61
+ end
62
+
63
+ private
64
+
65
+ def attack_gain(time)
66
+ return 1.0 if @attack.zero?
67
+
68
+ time / @attack
69
+ end
70
+
71
+ def decay_gain(time)
72
+ return @sustain if @decay.zero?
73
+
74
+ elapsed = time - @attack
75
+ 1.0 - ((1.0 - @sustain) * (elapsed / @decay))
76
+ end
77
+
78
+ def release_gain(release_time)
79
+ return 0.0 if @release.zero?
80
+
81
+ @sustain * (1.0 - [release_time / @release, 1.0].min)
82
+ end
83
+
84
+ def validate_time!(value, name)
85
+ raise InvalidParameterError, "#{name} must be a non-negative Numeric" unless value.is_a?(Numeric) && value >= 0
86
+
87
+ value.to_f
88
+ end
89
+
90
+ def validate_sustain!(value)
91
+ raise InvalidParameterError, "sustain must be Numeric in 0.0..1.0" unless value.is_a?(Numeric) && value.between?(0.0, 1.0)
92
+
93
+ value.to_f
94
+ end
95
+ end
96
+ end
97
+ end