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
@@ -12,7 +12,7 @@ module Wavify
12
12
  "fLaC" => ->(bytes) { bytes.start_with?("fLaC") },
13
13
  "OggS" => ->(bytes) { bytes.start_with?("OggS") },
14
14
  "FORM" => lambda do |bytes|
15
- bytes.bytesize >= 12 && bytes.start_with?("FORM") && bytes[8, 4] == "AIFF"
15
+ bytes.bytesize >= 12 && bytes.start_with?("FORM") && %w[AIFF AIFC].include?(bytes[8, 4])
16
16
  end
17
17
  }.freeze
18
18
 
@@ -25,6 +25,7 @@ module Wavify
25
25
  ".oga" => OggVorbis,
26
26
  ".aiff" => Aiff,
27
27
  ".aif" => Aiff,
28
+ ".aifc" => Aiff,
28
29
  ".raw" => Raw,
29
30
  ".pcm" => Raw
30
31
  }.freeze
@@ -37,38 +38,272 @@ module Wavify
37
38
  ["FORM", Aiff]
38
39
  ].freeze
39
40
 
41
+ # Maximum leading-byte window allowed for a custom magic probe.
42
+ MAX_MAGIC_PROBE_SIZE = 65_536
43
+
44
+ BUILTIN_MAGIC_ENTRIES = MAGIC_CODEC_ORDER.each_with_index.map do |(magic_key, codec), index|
45
+ {
46
+ extension: nil,
47
+ codec: codec,
48
+ probe: MAGIC_PROBES.fetch(magic_key),
49
+ probe_size: 12,
50
+ priority: 0,
51
+ sequence: index,
52
+ custom: false
53
+ }.freeze
54
+ end.freeze
55
+
56
+ CODEC_POSITIONAL_ARITY = {
57
+ read: 1,
58
+ write: 2,
59
+ stream_read: 1,
60
+ stream_write: 1,
61
+ metadata: 1
62
+ }.freeze
63
+
40
64
  class << self
41
65
  # Detects the codec for a path or IO object.
66
+ # Read detection prefers magic bytes when available.
42
67
  #
43
68
  # @param io_or_path [String, IO]
69
+ # @param filename [String, nil] optional filename hint for IO inputs
44
70
  # @return [Class] codec class
45
- def detect(io_or_path)
46
- detect_by_extension(io_or_path) || detect_by_magic(io_or_path) || raise_not_found(io_or_path)
71
+ def detect(io_or_path, strict: false, filename: nil)
72
+ detect_for_read(io_or_path, strict: strict, filename: filename)
73
+ end
74
+
75
+ # Detects the codec for reading.
76
+ #
77
+ # @param io_or_path [String, IO]
78
+ # @param strict [Boolean] raise when extension and magic bytes disagree
79
+ # @param filename [String, nil] optional filename hint for IO inputs
80
+ # @return [Class] codec class
81
+ def detect_for_read(io_or_path, strict: false, filename: nil)
82
+ extension_codec = detect_by_extension(io_or_path, filename: filename)
83
+ magic_codec = if non_rewindable_io?(io_or_path)
84
+ if strict
85
+ raise InvalidParameterError, "strict codec detection requires rewindable IO"
86
+ end
87
+ unless extension_codec
88
+ raise InvalidParameterError,
89
+ "codec detection requires rewindable IO; pass filename: as a codec hint"
90
+ end
91
+ else
92
+ detect_by_magic(io_or_path)
93
+ end
94
+ if strict && extension_codec && magic_codec && extension_codec != magic_codec
95
+ raise InvalidFormatError,
96
+ "codec mismatch: extension implies #{extension_codec.name}, magic bytes imply #{magic_codec.name}"
97
+ end
98
+
99
+ magic_codec || extension_codec || raise_not_found(filename || io_or_path)
100
+ end
101
+
102
+ # Detects the codec for writing.
103
+ # Write detection intentionally prefers the target filename extension.
104
+ #
105
+ # @param io_or_path [String, IO]
106
+ # @return [Class] codec class
107
+ def detect_for_write(io_or_path, filename: nil)
108
+ detect_by_extension(io_or_path, filename: filename) || detect_by_magic(io_or_path) || raise_not_found(filename || io_or_path)
109
+ end
110
+
111
+ # Resolves an explicit codec class or registered extension name.
112
+ def resolve(codec)
113
+ if codec.respond_to?(:read) && codec.respond_to?(:write)
114
+ validate_codec!(codec)
115
+ return codec
116
+ end
117
+
118
+ key = codec.to_s
119
+ normalized = normalize_extension(key)
120
+ extensions_mutex.synchronize { extensions[normalized] } || raise_not_found(codec)
121
+ end
122
+
123
+ # Registers or replaces a codec for a filename extension.
124
+ #
125
+ # @param extension [String]
126
+ # @param codec [Class]
127
+ # @return [Class] codec
128
+ def register(extension, codec, magic: nil, priority: 0, probe_size: 12)
129
+ normalized_extension = normalize_extension(extension)
130
+ validate_codec!(codec)
131
+ magic_probe, normalized_probe_size = normalize_magic_probe(magic, probe_size)
132
+ unless priority.is_a?(Integer)
133
+ raise InvalidParameterError, "priority must be an Integer"
134
+ end
135
+
136
+ extensions_mutex.synchronize do
137
+ extensions[normalized_extension] = codec
138
+ magic_entries.delete_if { |entry| entry[:custom] && entry[:extension] == normalized_extension }
139
+ if magic_probe
140
+ magic_entries << {
141
+ extension: normalized_extension,
142
+ codec: codec,
143
+ probe: magic_probe,
144
+ probe_size: normalized_probe_size,
145
+ priority: priority,
146
+ sequence: next_magic_sequence,
147
+ custom: true
148
+ }.freeze
149
+ end
150
+ end
151
+ codec
152
+ end
153
+
154
+ # Removes a custom codec mapping or restores the built-in mapping.
155
+ def unregister(extension)
156
+ normalized_extension = normalize_extension(extension)
157
+ extensions_mutex.synchronize do
158
+ previous = extensions[normalized_extension]
159
+ if EXTENSIONS.key?(normalized_extension)
160
+ extensions[normalized_extension] = EXTENSIONS.fetch(normalized_extension)
161
+ else
162
+ extensions.delete(normalized_extension)
163
+ end
164
+ magic_entries.delete_if { |entry| entry[:custom] && entry[:extension] == normalized_extension }
165
+ previous
166
+ end
167
+ end
168
+
169
+ # @return [Array<String>] supported extension names without leading dots
170
+ def supported_formats
171
+ extension_snapshot.keys.map { |extension| extension.delete_prefix(".") }.uniq.sort.freeze
172
+ end
173
+
174
+ # @return [Array<String>] extension names whose codec dependencies are available
175
+ def available_formats
176
+ extension_snapshot.filter_map do |extension, codec|
177
+ next if codec.respond_to?(:available?) && !codec.available?
178
+
179
+ extension.delete_prefix(".")
180
+ end.uniq.sort.freeze
47
181
  end
48
182
 
49
183
  private
50
184
 
51
- def detect_by_extension(io_or_path)
52
- return unless io_or_path.is_a?(String)
185
+ def detect_by_extension(io_or_path, filename: nil)
186
+ source = filename || io_or_path
187
+ return unless source.is_a?(String)
188
+
189
+ extensions_mutex.synchronize { extensions[File.extname(source).downcase] }
190
+ end
191
+
192
+ def extensions
193
+ @extensions ||= EXTENSIONS.dup
194
+ end
195
+
196
+ def magic_entries
197
+ @magic_entries ||= BUILTIN_MAGIC_ENTRIES.dup
198
+ end
199
+
200
+ def next_magic_sequence
201
+ @magic_sequence = [@magic_sequence || BUILTIN_MAGIC_ENTRIES.length, BUILTIN_MAGIC_ENTRIES.length].max + 1
202
+ end
53
203
 
54
- EXTENSIONS[File.extname(io_or_path).downcase]
204
+ def extension_snapshot
205
+ extensions_mutex.synchronize { extensions.dup }
206
+ end
207
+
208
+ def extensions_mutex
209
+ @extensions_mutex ||= Mutex.new
210
+ end
211
+
212
+ def normalize_extension(extension)
213
+ unless extension.is_a?(String) && extension.match?(/\A\.?[a-z0-9]+\z/i)
214
+ raise InvalidParameterError, "extension must be a file extension String"
215
+ end
216
+
217
+ extension.start_with?(".") ? extension.downcase : ".#{extension.downcase}"
218
+ end
219
+
220
+ def validate_codec!(codec)
221
+ missing = CODEC_POSITIONAL_ARITY.keys.reject { |method| codec.respond_to?(method) }
222
+ unless missing.empty?
223
+ raise InvalidParameterError, "codec must respond to: #{missing.join(', ')}"
224
+ end
225
+
226
+ invalid = CODEC_POSITIONAL_ARITY.filter_map do |method_name, positional_count|
227
+ method = codec.method(method_name)
228
+ method_name unless accepts_positional_arguments?(method, positional_count)
229
+ end
230
+ unless invalid.empty?
231
+ raise InvalidParameterError,
232
+ "codec methods have incompatible positional signatures: #{invalid.join(', ')}"
233
+ end
234
+
235
+ %i[write stream_write].each do |method_name|
236
+ parameters = codec.method(method_name).parameters
237
+ accepts_format = parameters.any? do |kind, name|
238
+ kind == :rest || (%i[key keyreq keyrest].include?(kind) && (name == :format || kind == :keyrest))
239
+ end
240
+ raise InvalidParameterError, "codec #{method_name} must accept format:" unless accepts_format
241
+ end
242
+
243
+ codec
244
+ end
245
+
246
+ def accepts_positional_arguments?(method, expected)
247
+ parameters = method.parameters
248
+ return true if parameters.any? { |kind, _| kind == :rest }
249
+
250
+ parameters.count { |kind, _| %i[req opt].include?(kind) } >= expected
251
+ end
252
+
253
+ def normalize_magic_probe(magic, probe_size)
254
+ return [nil, nil] if magic.nil?
255
+ unless probe_size.is_a?(Integer) && probe_size.between?(1, MAX_MAGIC_PROBE_SIZE)
256
+ raise InvalidParameterError, "probe_size must be an Integer in 1..#{MAX_MAGIC_PROBE_SIZE}"
257
+ end
258
+
259
+ if magic.is_a?(String)
260
+ raise InvalidParameterError, "magic String must not be empty" if magic.empty?
261
+
262
+ bytes = magic.b.dup.freeze
263
+ return [->(input) { input.start_with?(bytes) }, [probe_size, bytes.bytesize].max]
264
+ end
265
+ unless magic.respond_to?(:call)
266
+ raise InvalidParameterError, "magic must be a non-empty String or callable"
267
+ end
268
+
269
+ [magic, probe_size]
55
270
  end
56
271
 
57
272
  def detect_by_magic(io_or_path)
58
273
  io, close_io = ensure_io(io_or_path)
59
274
  return unless io
60
275
 
61
- probe = io.read(12)
62
- io.rewind if io.respond_to?(:rewind)
63
- MAGIC_CODEC_ORDER.each do |magic_key, codec|
64
- return codec if MAGIC_PROBES.fetch(magic_key).call(probe)
276
+ original_position = io.pos if io.respond_to?(:pos)
277
+ entries = extensions_mutex.synchronize { magic_entries.dup }
278
+ probe_size = entries.map { |entry| entry.fetch(:probe_size) }.max || 12
279
+ probe = io.read(probe_size).to_s
280
+ entries.sort_by { |entry| [-entry.fetch(:priority), -entry.fetch(:sequence)] }.each do |entry|
281
+ matched = entry.fetch(:probe).call(probe)
282
+ unless matched == true || matched == false || matched.nil?
283
+ raise InvalidParameterError, "codec magic probe must return boolean or nil"
284
+ end
285
+ return entry.fetch(:codec) if matched
65
286
  end
66
287
 
67
288
  nil
68
289
  ensure
290
+ if !close_io && original_position && io.respond_to?(:seek)
291
+ io.seek(original_position, IO::SEEK_SET)
292
+ end
69
293
  io.close if close_io && io
70
294
  end
71
295
 
296
+ def non_rewindable_io?(io_or_path)
297
+ return false unless io_or_path.respond_to?(:read)
298
+ return true unless io_or_path.respond_to?(:pos) && io_or_path.respond_to?(:seek)
299
+
300
+ position = io_or_path.pos
301
+ io_or_path.seek(position, IO::SEEK_SET)
302
+ false
303
+ rescue IOError, SystemCallError
304
+ true
305
+ end
306
+
72
307
  def ensure_io(io_or_path)
73
308
  return [io_or_path, false] if io_or_path.respond_to?(:read)
74
309
  return [nil, false] unless io_or_path.is_a?(String) && File.file?(io_or_path)
@@ -83,5 +318,32 @@ module Wavify
83
318
  end
84
319
  end
85
320
  end
321
+
322
+ class << self
323
+ # @see Registry.detect
324
+ def detect(io_or_path, strict: false, filename: nil)
325
+ Registry.detect(io_or_path, strict: strict, filename: filename)
326
+ end
327
+
328
+ # @see Registry.register
329
+ def register(extension, codec, **options)
330
+ Registry.register(extension, codec, **options)
331
+ end
332
+
333
+ # @see Registry.unregister
334
+ def unregister(extension)
335
+ Registry.unregister(extension)
336
+ end
337
+
338
+ # @see Registry.supported_formats
339
+ def supported_formats
340
+ Registry.supported_formats
341
+ end
342
+
343
+ # @see Registry.available_formats
344
+ def available_formats
345
+ Registry.available_formats
346
+ end
347
+ end
86
348
  end
87
349
  end