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,193 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wavify
4
+ module Codecs
5
+ # Raw PCM/float sample codec.
6
+ #
7
+ # Since raw audio has no container metadata, callers must provide a
8
+ # {Wavify::Core::Format} when reading/stream-reading/metadata.
9
+ class Raw < Base
10
+ # Recognized filename extensions.
11
+ EXTENSIONS = %w[.raw .pcm].freeze
12
+
13
+ class << self
14
+ # @param io_or_path [String, IO]
15
+ # @return [Boolean]
16
+ def can_read?(io_or_path)
17
+ return true if io_or_path.respond_to?(:read)
18
+ return false unless io_or_path.is_a?(String)
19
+
20
+ EXTENSIONS.include?(File.extname(io_or_path).downcase)
21
+ end
22
+
23
+ # Reads a raw audio file/IO into a sample buffer.
24
+ #
25
+ # @param io_or_path [String, IO]
26
+ # @param format [Wavify::Core::Format]
27
+ # @return [Wavify::Core::SampleBuffer]
28
+ def read(io_or_path, format: nil)
29
+ target_format = validate_format!(format)
30
+ io, close_io = open_input(io_or_path)
31
+ data = io.read || "".b
32
+ samples = decode_samples(data, target_format)
33
+ Core::SampleBuffer.new(samples, target_format)
34
+ ensure
35
+ io.close if close_io && io
36
+ end
37
+
38
+ # Writes a raw audio buffer to a path/IO.
39
+ #
40
+ # @param io_or_path [String, IO]
41
+ # @param sample_buffer [Wavify::Core::SampleBuffer]
42
+ # @param format [Wavify::Core::Format]
43
+ # @return [String, IO]
44
+ def write(io_or_path, sample_buffer, format:)
45
+ raise InvalidParameterError, "sample_buffer must be Core::SampleBuffer" unless sample_buffer.is_a?(Core::SampleBuffer)
46
+
47
+ target_format = validate_format!(format)
48
+ buffer = sample_buffer.format == target_format ? sample_buffer : sample_buffer.convert(target_format)
49
+
50
+ io, close_io = open_output(io_or_path)
51
+ io.write(encode_samples(buffer.samples, target_format))
52
+ io.flush if io.respond_to?(:flush)
53
+ io.rewind if io.respond_to?(:rewind)
54
+ io_or_path
55
+ ensure
56
+ io.close if close_io && io
57
+ end
58
+
59
+ # Streams raw audio decoding in frame chunks.
60
+ #
61
+ # @param io_or_path [String, IO]
62
+ # @param format [Wavify::Core::Format]
63
+ # @param chunk_size [Integer]
64
+ # @return [Enumerator]
65
+ def stream_read(io_or_path, format:, chunk_size: 4096)
66
+ return enum_for(__method__, io_or_path, format: format, chunk_size: chunk_size) unless block_given?
67
+
68
+ target_format = validate_format!(format)
69
+ io, close_io = open_input(io_or_path)
70
+ bytes_per_frame = target_format.block_align
71
+ raw_chunk_size = chunk_size * bytes_per_frame
72
+
73
+ loop do
74
+ chunk = io.read(raw_chunk_size)
75
+ break if chunk.nil? || chunk.empty?
76
+
77
+ raise InvalidFormatError, "raw data chunk does not align with format frame size" unless (chunk.bytesize % bytes_per_frame).zero?
78
+
79
+ samples = decode_samples(chunk, target_format)
80
+ yield Core::SampleBuffer.new(samples, target_format)
81
+ end
82
+ ensure
83
+ io.close if close_io && io
84
+ end
85
+
86
+ # Streams raw audio encoding via a yielded chunk writer.
87
+ #
88
+ # @param io_or_path [String, IO]
89
+ # @param format [Wavify::Core::Format]
90
+ # @return [Enumerator, String, IO]
91
+ def stream_write(io_or_path, format:)
92
+ return enum_for(__method__, io_or_path, format: format) unless block_given?
93
+
94
+ target_format = validate_format!(format)
95
+ io, close_io = open_output(io_or_path)
96
+
97
+ writer = lambda do |sample_buffer|
98
+ raise InvalidParameterError, "stream chunk must be Core::SampleBuffer" unless sample_buffer.is_a?(Core::SampleBuffer)
99
+
100
+ buffer = sample_buffer.format == target_format ? sample_buffer : sample_buffer.convert(target_format)
101
+ io.write(encode_samples(buffer.samples, target_format))
102
+ end
103
+
104
+ yield writer
105
+ io.flush if io.respond_to?(:flush)
106
+ io.rewind if io.respond_to?(:rewind)
107
+ io_or_path
108
+ ensure
109
+ io.close if close_io && io
110
+ end
111
+
112
+ # Reads raw audio metadata using byte size and explicit format.
113
+ #
114
+ # @param io_or_path [String, IO]
115
+ # @param format [Wavify::Core::Format]
116
+ # @return [Hash]
117
+ def metadata(io_or_path, format:)
118
+ target_format = validate_format!(format)
119
+ io, close_io = open_input(io_or_path)
120
+ byte_size = io.respond_to?(:size) ? io.size : io.read&.bytesize.to_i
121
+ sample_frames = byte_size / target_format.block_align
122
+
123
+ {
124
+ format: target_format,
125
+ sample_frame_count: sample_frames,
126
+ duration: Core::Duration.from_samples(sample_frames, target_format.sample_rate)
127
+ }
128
+ ensure
129
+ io.close if close_io && io
130
+ end
131
+
132
+ private
133
+
134
+ def validate_format!(format)
135
+ raise InvalidFormatError, "format is required for raw pcm codec" unless format.is_a?(Core::Format)
136
+
137
+ format
138
+ end
139
+
140
+ def decode_samples(data, format)
141
+ if format.sample_format == :float
142
+ return data.unpack("e*") if format.bit_depth == 32
143
+ return data.unpack("E*") if format.bit_depth == 64
144
+ elsif format.sample_format == :pcm
145
+ return data.unpack("C*").map { |byte| byte - 128 } if format.bit_depth == 8
146
+ return data.unpack("s<*") if format.bit_depth == 16
147
+ return decode_pcm24(data) if format.bit_depth == 24
148
+ return data.unpack("l<*") if format.bit_depth == 32
149
+ end
150
+
151
+ raise UnsupportedFormatError, "unsupported raw format: #{format.sample_format}/#{format.bit_depth}"
152
+ end
153
+
154
+ def encode_samples(samples, format)
155
+ if format.sample_format == :float
156
+ normalized = samples.map { |sample| sample.to_f.clamp(-1.0, 1.0) }
157
+ return normalized.pack("e*") if format.bit_depth == 32
158
+ return normalized.pack("E*") if format.bit_depth == 64
159
+ elsif format.sample_format == :pcm
160
+ min = -(2**(format.bit_depth - 1))
161
+ max = (2**(format.bit_depth - 1)) - 1
162
+ ints = samples.map { |sample| sample.to_i.clamp(min, max) }
163
+ return ints.map { |sample| sample + 128 }.pack("C*") if format.bit_depth == 8
164
+ return ints.pack("s<*") if format.bit_depth == 16
165
+ return encode_pcm24(ints) if format.bit_depth == 24
166
+ return ints.pack("l<*") if format.bit_depth == 32
167
+ end
168
+
169
+ raise UnsupportedFormatError, "unsupported raw format: #{format.sample_format}/#{format.bit_depth}"
170
+ end
171
+
172
+ def decode_pcm24(data)
173
+ bytes = data.unpack("C*")
174
+ bytes.each_slice(3).map do |b0, b1, b2|
175
+ value = b0 | (b1 << 8) | (b2 << 16)
176
+ value -= 0x1000000 if value.anybits?(0x800000)
177
+ value
178
+ end
179
+ end
180
+
181
+ def encode_pcm24(samples)
182
+ bytes = samples.flat_map do |sample|
183
+ value = sample
184
+ value += 0x1000000 if value.negative?
185
+ [value & 0xFF, (value >> 8) & 0xFF, (value >> 16) & 0xFF]
186
+ end
187
+ bytes.pack("C*")
188
+ end
189
+
190
+ end
191
+ end
192
+ end
193
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wavify
4
+ module Codecs
5
+ # Selects a codec implementation by extension and/or magic bytes.
6
+ class Registry
7
+ # Probe lambdas used to match container signatures from leading bytes.
8
+ MAGIC_PROBES = {
9
+ "RIFF" => lambda do |bytes|
10
+ bytes.bytesize >= 12 && bytes.start_with?("RIFF") && bytes[8, 4] == "WAVE"
11
+ end,
12
+ "fLaC" => ->(bytes) { bytes.start_with?("fLaC") },
13
+ "OggS" => ->(bytes) { bytes.start_with?("OggS") },
14
+ "FORM" => lambda do |bytes|
15
+ bytes.bytesize >= 12 && bytes.start_with?("FORM") && bytes[8, 4] == "AIFF"
16
+ end
17
+ }.freeze
18
+
19
+ # Extension-to-codec mapping for path-based detection.
20
+ EXTENSIONS = {
21
+ ".wav" => Wav,
22
+ ".wave" => Wav,
23
+ ".flac" => Flac,
24
+ ".ogg" => OggVorbis,
25
+ ".oga" => OggVorbis,
26
+ ".aiff" => Aiff,
27
+ ".aif" => Aiff,
28
+ ".raw" => Raw,
29
+ ".pcm" => Raw
30
+ }.freeze
31
+
32
+ # Probe order for magic-byte detection.
33
+ MAGIC_CODEC_ORDER = [
34
+ ["RIFF", Wav],
35
+ ["fLaC", Flac],
36
+ ["OggS", OggVorbis],
37
+ ["FORM", Aiff]
38
+ ].freeze
39
+
40
+ class << self
41
+ # Detects the codec for a path or IO object.
42
+ #
43
+ # @param io_or_path [String, IO]
44
+ # @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)
47
+ end
48
+
49
+ private
50
+
51
+ def detect_by_extension(io_or_path)
52
+ return unless io_or_path.is_a?(String)
53
+
54
+ EXTENSIONS[File.extname(io_or_path).downcase]
55
+ end
56
+
57
+ def detect_by_magic(io_or_path)
58
+ io, close_io = ensure_io(io_or_path)
59
+ return unless io
60
+
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)
65
+ end
66
+
67
+ nil
68
+ ensure
69
+ io.close if close_io && io
70
+ end
71
+
72
+ def ensure_io(io_or_path)
73
+ return [io_or_path, false] if io_or_path.respond_to?(:read)
74
+ return [nil, false] unless io_or_path.is_a?(String) && File.file?(io_or_path)
75
+
76
+ [File.open(io_or_path, "rb"), true]
77
+ rescue Errno::ENOENT
78
+ [nil, false]
79
+ end
80
+
81
+ def raise_not_found(io_or_path)
82
+ raise CodecNotFoundError, "codec not found for input: #{io_or_path.inspect}"
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end