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
@@ -24,13 +24,37 @@ module Wavify
24
24
  new(sample_frames.to_f / sample_rate)
25
25
  end
26
26
 
27
+ # Parses `SS.sss`, `MM:SS.sss`, or `HH:MM:SS.sss` text.
28
+ #
29
+ # @param value [String]
30
+ # @return [Duration]
31
+ def self.parse(value)
32
+ raise InvalidParameterError, "duration must be a String" unless value.is_a?(String)
33
+
34
+ parts = value.strip.split(":")
35
+ component_count = parts.length
36
+ raise InvalidParameterError, "invalid duration: #{value.inspect}" unless component_count.between?(1, 3)
37
+
38
+ seconds = parse_seconds(parts.pop)
39
+ minutes = parts.empty? ? 0.0 : parse_component(parts.pop, :minutes)
40
+ hours = parts.empty? ? 0.0 : parse_component(parts.pop, :hours)
41
+ raise InvalidParameterError, "invalid duration: #{value.inspect}" unless parts.empty?
42
+ raise InvalidParameterError, "minutes must be less than 60" if component_count == 3 && minutes >= 60.0
43
+ raise InvalidParameterError, "seconds must be less than 60" if component_count >= 2 && seconds >= 60.0
44
+
45
+ new((hours * 3600.0) + (minutes * 60.0) + seconds)
46
+ rescue ArgumentError
47
+ raise InvalidParameterError, "invalid duration: #{value.inspect}"
48
+ end
49
+
27
50
  # @param total_seconds [Numeric] non-negative duration in seconds
28
51
  def initialize(total_seconds)
29
- unless total_seconds.is_a?(Numeric) && total_seconds >= 0
30
- raise InvalidParameterError, "total_seconds must be a non-negative Numeric: #{total_seconds.inspect}"
52
+ unless total_seconds.is_a?(Numeric) && total_seconds.respond_to?(:finite?) && total_seconds.finite? && total_seconds >= 0
53
+ raise InvalidParameterError, "total_seconds must be a non-negative finite Numeric: #{total_seconds.inspect}"
31
54
  end
32
55
 
33
56
  @total_seconds = total_seconds.to_f
57
+ freeze
34
58
  end
35
59
 
36
60
  # Compares two durations.
@@ -43,6 +67,14 @@ module Wavify
43
67
  @total_seconds <=> other.total_seconds
44
68
  end
45
69
 
70
+ def eql?(other)
71
+ other.is_a?(Duration) && @total_seconds.eql?(other.total_seconds)
72
+ end
73
+
74
+ def hash
75
+ @total_seconds.hash
76
+ end
77
+
46
78
  def +(other)
47
79
  raise InvalidParameterError, "expected Duration: #{other.inspect}" unless other.is_a?(Duration)
48
80
 
@@ -91,6 +123,26 @@ module Wavify
91
123
 
92
124
  private
93
125
 
126
+ def self.parse_seconds(value)
127
+ seconds = Float(value)
128
+ unless seconds.finite? && !seconds.negative?
129
+ raise InvalidParameterError, "seconds must be a non-negative finite number"
130
+ end
131
+
132
+ seconds
133
+ end
134
+ private_class_method :parse_seconds
135
+
136
+ def self.parse_component(value, name)
137
+ component = Float(value)
138
+ unless component.finite? && !component.negative?
139
+ raise InvalidParameterError, "#{name} must be a non-negative finite number"
140
+ end
141
+
142
+ component
143
+ end
144
+ private_class_method :parse_component
145
+
94
146
  def total_milliseconds
95
147
  (@total_seconds * 1000).round
96
148
  end
@@ -11,30 +11,68 @@ module Wavify
11
11
  PCM_BIT_DEPTHS = [8, 16, 24, 32].freeze
12
12
  # Allowed bit depths for floating point samples.
13
13
  FLOAT_BIT_DEPTHS = [32, 64].freeze
14
-
15
- attr_reader :channels, :sample_rate, :bit_depth, :sample_format
14
+ # Speaker positions understood by channel-layout-aware conversions.
15
+ CHANNEL_POSITIONS = %i[
16
+ front_left front_right front_center low_frequency back_left back_right
17
+ front_left_of_center front_right_of_center back_center side_left side_right
18
+ top_center top_front_left top_front_center top_front_right top_back_left
19
+ top_back_center top_back_right
20
+ ].freeze
21
+ # Conventional speaker order used when no explicit channel layout exists.
22
+ DEFAULT_CHANNEL_LAYOUTS = {
23
+ 1 => %i[front_center],
24
+ 2 => %i[front_left front_right],
25
+ 3 => %i[front_left front_right front_center],
26
+ 4 => %i[front_left front_right back_left back_right],
27
+ 5 => %i[front_left front_right front_center back_left back_right],
28
+ 6 => %i[front_left front_right front_center low_frequency side_left side_right],
29
+ 7 => %i[front_left front_right front_center low_frequency back_center side_left side_right],
30
+ 8 => %i[front_left front_right front_center low_frequency back_left back_right side_left side_right]
31
+ }.transform_values(&:freeze).freeze
32
+ UNSPECIFIED_LAYOUT = Object.new.freeze # :nodoc:
33
+ UNKNOWN_LAYOUT = :unknown # :nodoc:
34
+
35
+ attr_reader :channels, :sample_rate, :bit_depth, :valid_bits, :sample_format, :channel_layout
16
36
 
17
37
  # @param channels [Integer] number of interleaved channels (1..32)
18
38
  # @param sample_rate [Integer] sampling rate in Hz
19
39
  # @param bit_depth [Integer] bits per sample
40
+ # @param valid_bits [Integer, nil] significant bits within the sample container
20
41
  # @param sample_format [Symbol,String] `:pcm` or `:float`
21
- def initialize(channels:, sample_rate:, bit_depth:, sample_format: :pcm)
42
+ # @param channel_layout [Array<Symbol>, :unknown, nil] ordered speaker positions
43
+ def initialize(channels:, sample_rate:, bit_depth:, sample_format: :pcm, valid_bits: nil,
44
+ channel_layout: UNSPECIFIED_LAYOUT)
22
45
  @channels = validate_channels(channels)
23
46
  @sample_rate = validate_sample_rate(sample_rate)
24
47
  @sample_format = validate_sample_format(sample_format)
25
48
  @bit_depth = validate_bit_depth(bit_depth, @sample_format)
49
+ @valid_bits = validate_valid_bits(valid_bits || @bit_depth)
50
+ requested_layout = if channel_layout.equal?(UNSPECIFIED_LAYOUT) || channel_layout.nil?
51
+ DEFAULT_CHANNEL_LAYOUTS[@channels]
52
+ elsif channel_layout == UNKNOWN_LAYOUT
53
+ nil
54
+ else
55
+ channel_layout
56
+ end
57
+ @channel_layout = validate_channel_layout(requested_layout)
26
58
  freeze
27
59
  end
28
60
 
29
61
  # Returns a new format with one or more fields replaced.
30
62
  #
31
63
  # @return [Format]
32
- def with(channels: nil, sample_rate: nil, bit_depth: nil, sample_format: nil)
64
+ def with(channels: nil, sample_rate: nil, bit_depth: nil, sample_format: nil, valid_bits: nil,
65
+ channel_layout: UNSPECIFIED_LAYOUT)
66
+ target_channels = channels || @channels
67
+ target_bit_depth = bit_depth || @bit_depth
68
+ target_sample_format = sample_format || @sample_format
33
69
  self.class.new(
34
- channels: channels || @channels,
70
+ channels: target_channels,
35
71
  sample_rate: sample_rate || @sample_rate,
36
- bit_depth: bit_depth || @bit_depth,
37
- sample_format: sample_format || @sample_format
72
+ bit_depth: target_bit_depth,
73
+ sample_format: target_sample_format,
74
+ valid_bits: valid_bits || preserved_valid_bits(target_bit_depth, target_sample_format),
75
+ channel_layout: resolved_channel_layout(channel_layout, target_channels)
38
76
  )
39
77
  end
40
78
 
@@ -68,17 +106,19 @@ module Wavify
68
106
  def ==(other)
69
107
  return false unless other.is_a?(Format)
70
108
 
71
- @channels == other.channels &&
109
+ @channels == other.channels &&
72
110
  @sample_rate == other.sample_rate &&
73
111
  @bit_depth == other.bit_depth &&
74
- @sample_format == other.sample_format
112
+ @valid_bits == other.valid_bits &&
113
+ @sample_format == other.sample_format &&
114
+ @channel_layout == other.channel_layout
75
115
  end
76
116
 
77
117
  alias eql? ==
78
118
 
79
119
  # @return [Integer] hash value compatible with {#eql?}
80
120
  def hash
81
- [@channels, @sample_rate, @bit_depth, @sample_format].hash
121
+ [@channels, @sample_rate, @bit_depth, @valid_bits, @sample_format, @channel_layout].hash
82
122
  end
83
123
 
84
124
  private
@@ -120,7 +160,51 @@ module Wavify
120
160
  bit_depth
121
161
  end
122
162
 
123
- public
163
+ def validate_valid_bits(valid_bits)
164
+ unless valid_bits.is_a?(Integer) && valid_bits.positive? && valid_bits <= @bit_depth
165
+ raise InvalidFormatError, "valid_bits must be an Integer between 1 and #{@bit_depth}: #{valid_bits.inspect}"
166
+ end
167
+ if @sample_format == :float && valid_bits != @bit_depth
168
+ raise InvalidFormatError, "floating-point valid_bits must equal bit_depth"
169
+ end
170
+
171
+ valid_bits
172
+ end
173
+
174
+ def validate_channel_layout(channel_layout)
175
+ return nil if channel_layout.nil?
176
+ raise InvalidFormatError, "channel_layout must be an Array" unless channel_layout.is_a?(Array)
177
+ unless channel_layout.length == @channels
178
+ raise InvalidFormatError, "channel_layout must contain exactly #{@channels} positions"
179
+ end
180
+
181
+ normalized = channel_layout.map do |position|
182
+ value = position.to_sym
183
+ unless CHANNEL_POSITIONS.include?(value)
184
+ raise InvalidFormatError, "unsupported channel position: #{position.inspect}"
185
+ end
186
+
187
+ value
188
+ rescue NoMethodError
189
+ raise InvalidFormatError, "channel positions must be Symbol/String: #{position.inspect}"
190
+ end
191
+ raise InvalidFormatError, "channel_layout positions must be unique" unless normalized.uniq.length == normalized.length
192
+
193
+ normalized.freeze
194
+ end
195
+
196
+ def preserved_valid_bits(target_bit_depth, target_sample_format)
197
+ return @valid_bits if target_bit_depth == @bit_depth && target_sample_format.to_sym == @sample_format
198
+
199
+ nil
200
+ end
201
+
202
+ def resolved_channel_layout(channel_layout, target_channels)
203
+ return channel_layout unless channel_layout.equal?(UNSPECIFIED_LAYOUT)
204
+ return @channel_layout || UNKNOWN_LAYOUT if target_channels == @channels
205
+
206
+ DEFAULT_CHANNEL_LAYOUTS[target_channels]
207
+ end
124
208
 
125
209
  # Stereo 44.1kHz 16-bit PCM preset.
126
210
  CD_QUALITY = new(channels: 2, sample_rate: 44_100, bit_depth: 16, sample_format: :pcm)
@@ -128,6 +212,7 @@ module Wavify
128
212
  DVD_QUALITY = new(channels: 2, sample_rate: 96_000, bit_depth: 24, sample_format: :pcm)
129
213
  # Mono 16kHz 16-bit PCM preset for speech-focused workflows.
130
214
  VOICE = new(channels: 1, sample_rate: 16_000, bit_depth: 16, sample_format: :pcm)
215
+
131
216
  end
132
217
  end
133
218
  end