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.
- checksums.yaml +7 -0
- data/.serena/.gitignore +1 -0
- data/.serena/memories/project_overview.md +5 -0
- data/.serena/memories/style_and_completion.md +5 -0
- data/.serena/memories/suggested_commands.md +11 -0
- data/.serena/project.yml +126 -0
- data/.simplecov +18 -0
- data/.yardopts +4 -0
- data/CHANGELOG.md +11 -0
- data/LICENSE +21 -0
- data/README.md +196 -0
- data/Rakefile +190 -0
- data/benchmarks/README.md +46 -0
- data/benchmarks/benchmark_helper.rb +112 -0
- data/benchmarks/dsp_effects_benchmark.rb +46 -0
- data/benchmarks/flac_benchmark.rb +74 -0
- data/benchmarks/streaming_memory_benchmark.rb +94 -0
- data/benchmarks/wav_io_benchmark.rb +110 -0
- data/examples/audio_processing.rb +73 -0
- data/examples/cinematic_transition.rb +118 -0
- data/examples/drum_machine.rb +74 -0
- data/examples/format_convert.rb +81 -0
- data/examples/hybrid_arrangement.rb +165 -0
- data/examples/streaming_master_chain.rb +129 -0
- data/examples/synth_pad.rb +42 -0
- data/lib/wavify/audio.rb +483 -0
- data/lib/wavify/codecs/aiff.rb +338 -0
- data/lib/wavify/codecs/base.rb +108 -0
- data/lib/wavify/codecs/flac.rb +1322 -0
- data/lib/wavify/codecs/ogg_vorbis.rb +1447 -0
- data/lib/wavify/codecs/raw.rb +193 -0
- data/lib/wavify/codecs/registry.rb +87 -0
- data/lib/wavify/codecs/wav.rb +459 -0
- data/lib/wavify/core/duration.rb +99 -0
- data/lib/wavify/core/format.rb +133 -0
- data/lib/wavify/core/sample_buffer.rb +216 -0
- data/lib/wavify/core/stream.rb +129 -0
- data/lib/wavify/dsl.rb +537 -0
- data/lib/wavify/dsp/effects/chorus.rb +98 -0
- data/lib/wavify/dsp/effects/compressor.rb +85 -0
- data/lib/wavify/dsp/effects/delay.rb +69 -0
- data/lib/wavify/dsp/effects/distortion.rb +64 -0
- data/lib/wavify/dsp/effects/effect_base.rb +68 -0
- data/lib/wavify/dsp/effects/reverb.rb +112 -0
- data/lib/wavify/dsp/effects.rb +21 -0
- data/lib/wavify/dsp/envelope.rb +97 -0
- data/lib/wavify/dsp/filter.rb +271 -0
- data/lib/wavify/dsp/oscillator.rb +123 -0
- data/lib/wavify/errors.rb +34 -0
- data/lib/wavify/sequencer/engine.rb +278 -0
- data/lib/wavify/sequencer/note_sequence.rb +132 -0
- data/lib/wavify/sequencer/pattern.rb +102 -0
- data/lib/wavify/sequencer/track.rb +298 -0
- data/lib/wavify/sequencer.rb +12 -0
- data/lib/wavify/version.rb +6 -0
- data/lib/wavify.rb +28 -0
- data/tools/fixture_writer.rb +85 -0
- 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
|