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
|
@@ -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]
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|