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,459 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wavify
|
|
4
|
+
module Codecs
|
|
5
|
+
# WAV codec with PCM, float, and WAVE_FORMAT_EXTENSIBLE support.
|
|
6
|
+
class Wav < Base
|
|
7
|
+
# Recognized filename extensions.
|
|
8
|
+
EXTENSIONS = %w[.wav .wave].freeze
|
|
9
|
+
WAV_FORMAT_PCM = 0x0001 # :nodoc:
|
|
10
|
+
WAV_FORMAT_FLOAT = 0x0003 # :nodoc:
|
|
11
|
+
WAV_FORMAT_EXTENSIBLE = 0xFFFE # :nodoc:
|
|
12
|
+
|
|
13
|
+
GUID_TAIL = [0x80, 0x00, 0x00, 0xAA, 0x00, 0x38, 0x9B, 0x71].pack("C*").freeze # :nodoc:
|
|
14
|
+
PCM_SUBFORMAT_GUID = ([WAV_FORMAT_PCM, 0x0000, 0x0010].pack("V v v") + GUID_TAIL).freeze # :nodoc:
|
|
15
|
+
FLOAT_SUBFORMAT_GUID = ([WAV_FORMAT_FLOAT, 0x0000, 0x0010].pack("V v v") + GUID_TAIL).freeze # :nodoc:
|
|
16
|
+
|
|
17
|
+
class << self
|
|
18
|
+
# @param io_or_path [String, IO]
|
|
19
|
+
# @return [Boolean]
|
|
20
|
+
def can_read?(io_or_path)
|
|
21
|
+
if io_or_path.is_a?(String)
|
|
22
|
+
return true if EXTENSIONS.include?(File.extname(io_or_path).downcase)
|
|
23
|
+
return false unless File.file?(io_or_path)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
io, close_io = open_input(io_or_path)
|
|
27
|
+
return false unless io
|
|
28
|
+
|
|
29
|
+
header = io.read(12)
|
|
30
|
+
io.rewind if io.respond_to?(:rewind)
|
|
31
|
+
header&.start_with?("RIFF") && header[8, 4] == "WAVE"
|
|
32
|
+
ensure
|
|
33
|
+
io.close if close_io && io
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Reads a WAV file/IO into a sample buffer.
|
|
37
|
+
#
|
|
38
|
+
# @param io_or_path [String, IO]
|
|
39
|
+
# @param format [Wavify::Core::Format, nil] optional output conversion
|
|
40
|
+
# @return [Wavify::Core::SampleBuffer]
|
|
41
|
+
def read(io_or_path, format: nil)
|
|
42
|
+
io, close_io = open_input(io_or_path)
|
|
43
|
+
ensure_seekable!(io)
|
|
44
|
+
|
|
45
|
+
info = parse_chunk_directory(io)
|
|
46
|
+
source_format = info.fetch(:format)
|
|
47
|
+
samples = read_data_chunk(io, info, source_format)
|
|
48
|
+
buffer = Core::SampleBuffer.new(samples, source_format)
|
|
49
|
+
format ? buffer.convert(format) : buffer
|
|
50
|
+
ensure
|
|
51
|
+
io.close if close_io && io
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Writes a sample buffer as WAV.
|
|
55
|
+
#
|
|
56
|
+
# @param io_or_path [String, IO]
|
|
57
|
+
# @param sample_buffer [Wavify::Core::SampleBuffer]
|
|
58
|
+
# @param format [Wavify::Core::Format, nil]
|
|
59
|
+
# @return [String, IO]
|
|
60
|
+
def write(io_or_path, sample_buffer, format: nil)
|
|
61
|
+
raise InvalidParameterError, "sample_buffer must be Core::SampleBuffer" unless sample_buffer.is_a?(Core::SampleBuffer)
|
|
62
|
+
|
|
63
|
+
target_format = format || sample_buffer.format
|
|
64
|
+
raise InvalidParameterError, "format must be Core::Format" unless target_format.is_a?(Core::Format)
|
|
65
|
+
|
|
66
|
+
buffer = sample_buffer.format == target_format ? sample_buffer : sample_buffer.convert(target_format)
|
|
67
|
+
stream_write(io_or_path, format: target_format) do |writer|
|
|
68
|
+
writer.call(buffer)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Streams WAV data chunks as sample buffers.
|
|
73
|
+
#
|
|
74
|
+
# @param io_or_path [String, IO]
|
|
75
|
+
# @param chunk_size [Integer]
|
|
76
|
+
# @return [Enumerator]
|
|
77
|
+
def stream_read(io_or_path, chunk_size: 4096)
|
|
78
|
+
return enum_for(__method__, io_or_path, chunk_size: chunk_size) unless block_given?
|
|
79
|
+
raise InvalidParameterError, "chunk_size must be a positive Integer" unless chunk_size.is_a?(Integer) && chunk_size.positive?
|
|
80
|
+
|
|
81
|
+
io, close_io = open_input(io_or_path)
|
|
82
|
+
ensure_seekable!(io)
|
|
83
|
+
|
|
84
|
+
info = parse_chunk_directory(io)
|
|
85
|
+
format = info.fetch(:format)
|
|
86
|
+
bytes_per_frame = format.block_align
|
|
87
|
+
remaining = info.fetch(:data_size)
|
|
88
|
+
|
|
89
|
+
io.seek(info.fetch(:data_offset), IO::SEEK_SET)
|
|
90
|
+
while remaining.positive?
|
|
91
|
+
to_read = [remaining, chunk_size * bytes_per_frame].min
|
|
92
|
+
chunk_data = read_exact(io, to_read, "truncated data chunk")
|
|
93
|
+
samples = decode_samples(chunk_data, format)
|
|
94
|
+
yield Core::SampleBuffer.new(samples, format)
|
|
95
|
+
remaining -= to_read
|
|
96
|
+
end
|
|
97
|
+
ensure
|
|
98
|
+
io.close if close_io && io
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Streams WAV encoding and finalizes the RIFF header on completion.
|
|
102
|
+
#
|
|
103
|
+
# @param io_or_path [String, IO]
|
|
104
|
+
# @param format [Wavify::Core::Format]
|
|
105
|
+
# @return [Enumerator, String, IO]
|
|
106
|
+
def stream_write(io_or_path, format:)
|
|
107
|
+
return enum_for(__method__, io_or_path, format: format) unless block_given?
|
|
108
|
+
raise InvalidParameterError, "format must be Core::Format" unless format.is_a?(Core::Format)
|
|
109
|
+
|
|
110
|
+
io, close_io = open_output(io_or_path)
|
|
111
|
+
ensure_seekable!(io)
|
|
112
|
+
io.rewind if io.respond_to?(:rewind)
|
|
113
|
+
io.truncate(0) if io.respond_to?(:truncate)
|
|
114
|
+
|
|
115
|
+
header = write_stream_header(io, format)
|
|
116
|
+
total_data_bytes = 0
|
|
117
|
+
total_sample_frames = 0
|
|
118
|
+
|
|
119
|
+
writer = lambda do |chunk|
|
|
120
|
+
raise InvalidParameterError, "stream chunk must be Core::SampleBuffer" unless chunk.is_a?(Core::SampleBuffer)
|
|
121
|
+
|
|
122
|
+
buffer = chunk.format == format ? chunk : chunk.convert(format)
|
|
123
|
+
encoded = encode_samples(buffer.samples, format)
|
|
124
|
+
io.write(encoded)
|
|
125
|
+
total_data_bytes += encoded.bytesize
|
|
126
|
+
total_sample_frames += buffer.sample_frame_count
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
yield writer
|
|
130
|
+
io.write("\x00") if total_data_bytes.odd?
|
|
131
|
+
finalize_stream_header(io, header, total_data_bytes, total_sample_frames)
|
|
132
|
+
io.flush if io.respond_to?(:flush)
|
|
133
|
+
io.rewind if io.respond_to?(:rewind)
|
|
134
|
+
io_or_path
|
|
135
|
+
ensure
|
|
136
|
+
io.close if close_io && io
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Reads WAV metadata without fully decoding samples.
|
|
140
|
+
#
|
|
141
|
+
# @param io_or_path [String, IO]
|
|
142
|
+
# @return [Hash]
|
|
143
|
+
def metadata(io_or_path)
|
|
144
|
+
io, close_io = open_input(io_or_path)
|
|
145
|
+
ensure_seekable!(io)
|
|
146
|
+
|
|
147
|
+
info = parse_chunk_directory(io)
|
|
148
|
+
format = info.fetch(:format)
|
|
149
|
+
sample_frame_count = info.fetch(:sample_frame_count)
|
|
150
|
+
|
|
151
|
+
{
|
|
152
|
+
format: format,
|
|
153
|
+
sample_frame_count: sample_frame_count,
|
|
154
|
+
duration: Core::Duration.from_samples(sample_frame_count, format.sample_rate),
|
|
155
|
+
fact_sample_length: info[:fact_sample_length],
|
|
156
|
+
smpl: info[:smpl]
|
|
157
|
+
}
|
|
158
|
+
ensure
|
|
159
|
+
io.close if close_io && io
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
private
|
|
163
|
+
|
|
164
|
+
def write_stream_header(io, format)
|
|
165
|
+
io.write("RIFF")
|
|
166
|
+
riff_size_offset = io.pos
|
|
167
|
+
io.write([0].pack("V"))
|
|
168
|
+
io.write("WAVE")
|
|
169
|
+
|
|
170
|
+
fmt_chunk = build_fmt_chunk(format)
|
|
171
|
+
write_chunk(io, "fmt ", fmt_chunk)
|
|
172
|
+
|
|
173
|
+
fact_sample_offset = nil
|
|
174
|
+
if format.sample_format != :pcm
|
|
175
|
+
io.write("fact")
|
|
176
|
+
io.write([4].pack("V"))
|
|
177
|
+
fact_sample_offset = io.pos
|
|
178
|
+
io.write([0].pack("V"))
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
io.write("data")
|
|
182
|
+
data_size_offset = io.pos
|
|
183
|
+
io.write([0].pack("V"))
|
|
184
|
+
|
|
185
|
+
{
|
|
186
|
+
riff_size_offset: riff_size_offset,
|
|
187
|
+
data_size_offset: data_size_offset,
|
|
188
|
+
fact_sample_offset: fact_sample_offset
|
|
189
|
+
}
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def finalize_stream_header(io, header, total_data_bytes, total_sample_frames)
|
|
193
|
+
file_end = io.pos
|
|
194
|
+
|
|
195
|
+
io.seek(header.fetch(:data_size_offset), IO::SEEK_SET)
|
|
196
|
+
io.write([total_data_bytes].pack("V"))
|
|
197
|
+
|
|
198
|
+
fact_sample_offset = header[:fact_sample_offset]
|
|
199
|
+
if fact_sample_offset
|
|
200
|
+
io.seek(fact_sample_offset, IO::SEEK_SET)
|
|
201
|
+
io.write([total_sample_frames].pack("V"))
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
io.seek(header.fetch(:riff_size_offset), IO::SEEK_SET)
|
|
205
|
+
io.write([file_end - 8].pack("V"))
|
|
206
|
+
io.seek(file_end, IO::SEEK_SET)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def parse_chunk_directory(io)
|
|
210
|
+
io.rewind
|
|
211
|
+
header = read_exact(io, 12, "missing RIFF/WAVE header")
|
|
212
|
+
raise InvalidFormatError, "invalid WAV header" unless header.start_with?("RIFF") && header[8, 4] == "WAVE"
|
|
213
|
+
|
|
214
|
+
info = {
|
|
215
|
+
format: nil,
|
|
216
|
+
data_offset: nil,
|
|
217
|
+
data_size: nil,
|
|
218
|
+
sample_frame_count: nil,
|
|
219
|
+
fact_sample_length: nil,
|
|
220
|
+
smpl: nil
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
until io.eof?
|
|
224
|
+
chunk_header = io.read(8)
|
|
225
|
+
break if chunk_header.nil?
|
|
226
|
+
raise InvalidFormatError, "truncated chunk header" unless chunk_header.bytesize == 8
|
|
227
|
+
|
|
228
|
+
chunk_id = chunk_header[0, 4]
|
|
229
|
+
chunk_size = chunk_header[4, 4].unpack1("V")
|
|
230
|
+
|
|
231
|
+
case chunk_id
|
|
232
|
+
when "fmt "
|
|
233
|
+
chunk_data = read_exact(io, chunk_size, "truncated fmt chunk")
|
|
234
|
+
info[:format] = parse_fmt_chunk(chunk_data)
|
|
235
|
+
when "data"
|
|
236
|
+
info[:data_offset] = io.pos
|
|
237
|
+
info[:data_size] = chunk_size
|
|
238
|
+
skip_bytes(io, chunk_size)
|
|
239
|
+
when "fact"
|
|
240
|
+
chunk_data = read_exact(io, chunk_size, "truncated fact chunk")
|
|
241
|
+
info[:fact_sample_length] = chunk_data.unpack1("V") if chunk_data.bytesize >= 4
|
|
242
|
+
when "smpl"
|
|
243
|
+
chunk_data = read_exact(io, chunk_size, "truncated smpl chunk")
|
|
244
|
+
info[:smpl] = parse_smpl_chunk(chunk_data)
|
|
245
|
+
else
|
|
246
|
+
skip_bytes(io, chunk_size)
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
skip_padding_byte(io, chunk_size)
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
raise InvalidFormatError, "fmt chunk missing" unless info[:format]
|
|
253
|
+
raise InvalidFormatError, "data chunk missing" unless info[:data_offset] && info[:data_size]
|
|
254
|
+
|
|
255
|
+
validate_data_chunk!(io, info)
|
|
256
|
+
info[:sample_frame_count] = info[:data_size] / info[:format].block_align
|
|
257
|
+
info
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def validate_data_chunk!(io, info)
|
|
261
|
+
format = info.fetch(:format)
|
|
262
|
+
data_size = info.fetch(:data_size)
|
|
263
|
+
data_offset = info.fetch(:data_offset)
|
|
264
|
+
block_align = format.block_align
|
|
265
|
+
|
|
266
|
+
raise InvalidFormatError, "data chunk size is not aligned to frame size" unless (data_size % block_align).zero?
|
|
267
|
+
|
|
268
|
+
return unless io.respond_to?(:size)
|
|
269
|
+
return unless data_offset + data_size > io.size
|
|
270
|
+
|
|
271
|
+
raise InvalidFormatError, "data chunk exceeds file size"
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def read_data_chunk(io, info, format)
|
|
275
|
+
io.seek(info.fetch(:data_offset), IO::SEEK_SET)
|
|
276
|
+
data = read_exact(io, info.fetch(:data_size), "truncated data chunk")
|
|
277
|
+
decode_samples(data, format)
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def parse_fmt_chunk(chunk)
|
|
281
|
+
raise InvalidFormatError, "fmt chunk is too small" if chunk.bytesize < 16
|
|
282
|
+
|
|
283
|
+
audio_format, channels, sample_rate, byte_rate, block_align, bit_depth = chunk.unpack("v v V V v v")
|
|
284
|
+
|
|
285
|
+
if audio_format == WAV_FORMAT_EXTENSIBLE
|
|
286
|
+
raise InvalidFormatError, "fmt extensible chunk is too small" if chunk.bytesize < 40
|
|
287
|
+
|
|
288
|
+
extension_size = chunk[16, 2].unpack1("v")
|
|
289
|
+
raise InvalidFormatError, "invalid extensible fmt chunk size" if extension_size < 22 || chunk.bytesize < (18 + extension_size)
|
|
290
|
+
|
|
291
|
+
valid_bits = chunk[18, 2].unpack1("v")
|
|
292
|
+
sub_format_guid = chunk[24, 16]
|
|
293
|
+
audio_format = sub_format_guid.unpack1("v")
|
|
294
|
+
bit_depth = valid_bits if valid_bits.positive?
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
sample_format = case audio_format
|
|
298
|
+
when WAV_FORMAT_PCM then :pcm
|
|
299
|
+
when WAV_FORMAT_FLOAT then :float
|
|
300
|
+
else
|
|
301
|
+
raise UnsupportedFormatError, "unsupported WAV format code: #{audio_format}"
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
format = Core::Format.new(
|
|
305
|
+
channels: channels,
|
|
306
|
+
sample_rate: sample_rate,
|
|
307
|
+
bit_depth: bit_depth,
|
|
308
|
+
sample_format: sample_format
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
expected_block_align = format.block_align
|
|
312
|
+
expected_byte_rate = format.byte_rate
|
|
313
|
+
unless block_align == expected_block_align && byte_rate == expected_byte_rate
|
|
314
|
+
raise InvalidFormatError, "fmt chunk has inconsistent byte_rate/block_align"
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
format
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
def parse_smpl_chunk(chunk)
|
|
321
|
+
return nil if chunk.bytesize < 36
|
|
322
|
+
|
|
323
|
+
manufacturer, product, sample_period, midi_unity_note, midi_pitch_fraction,
|
|
324
|
+
smpte_format, smpte_offset, loop_count, sampler_data = chunk.unpack("V9")
|
|
325
|
+
|
|
326
|
+
loops = []
|
|
327
|
+
offset = 36
|
|
328
|
+
loop_count.times do
|
|
329
|
+
break if offset + 24 > chunk.bytesize
|
|
330
|
+
|
|
331
|
+
identifier, loop_type, start_frame, end_frame, fraction, play_count = chunk.byteslice(offset, 24).unpack("V6")
|
|
332
|
+
loops << {
|
|
333
|
+
identifier: identifier,
|
|
334
|
+
type: loop_type,
|
|
335
|
+
start_frame: start_frame,
|
|
336
|
+
end_frame: end_frame,
|
|
337
|
+
fraction: fraction,
|
|
338
|
+
play_count: play_count
|
|
339
|
+
}
|
|
340
|
+
offset += 24
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
{
|
|
344
|
+
manufacturer: manufacturer,
|
|
345
|
+
product: product,
|
|
346
|
+
sample_period: sample_period,
|
|
347
|
+
midi_unity_note: midi_unity_note,
|
|
348
|
+
midi_pitch_fraction: midi_pitch_fraction,
|
|
349
|
+
smpte_format: smpte_format,
|
|
350
|
+
smpte_offset: smpte_offset,
|
|
351
|
+
sampler_data: sampler_data,
|
|
352
|
+
loop_count: loop_count,
|
|
353
|
+
loops: loops
|
|
354
|
+
}
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
def decode_samples(data_chunk, format)
|
|
358
|
+
if format.sample_format == :float
|
|
359
|
+
return data_chunk.unpack("e*") if format.bit_depth == 32
|
|
360
|
+
return data_chunk.unpack("E*") if format.bit_depth == 64
|
|
361
|
+
elsif format.sample_format == :pcm
|
|
362
|
+
return data_chunk.unpack("C*").map { |byte| byte - 128 } if format.bit_depth == 8
|
|
363
|
+
return data_chunk.unpack("s<*") if format.bit_depth == 16
|
|
364
|
+
return decode_pcm24(data_chunk) if format.bit_depth == 24
|
|
365
|
+
return data_chunk.unpack("l<*") if format.bit_depth == 32
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
raise UnsupportedFormatError, "unsupported WAV bit depth: #{format.bit_depth}"
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
def decode_pcm24(data)
|
|
372
|
+
bytes = data.unpack("C*")
|
|
373
|
+
bytes.each_slice(3).map do |b0, b1, b2|
|
|
374
|
+
value = b0 | (b1 << 8) | (b2 << 16)
|
|
375
|
+
value -= 0x1000000 if value.anybits?(0x800000)
|
|
376
|
+
value
|
|
377
|
+
end
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
def encode_samples(samples, format)
|
|
381
|
+
if format.sample_format == :float
|
|
382
|
+
normalized = samples.map { |sample| sample.to_f.clamp(-1.0, 1.0) }
|
|
383
|
+
return normalized.pack("e*") if format.bit_depth == 32
|
|
384
|
+
return normalized.pack("E*") if format.bit_depth == 64
|
|
385
|
+
elsif format.sample_format == :pcm
|
|
386
|
+
min = -(2**(format.bit_depth - 1))
|
|
387
|
+
max = (2**(format.bit_depth - 1)) - 1
|
|
388
|
+
ints = samples.map { |sample| sample.to_i.clamp(min, max) }
|
|
389
|
+
return ints.map { |sample| sample + 128 }.pack("C*") if format.bit_depth == 8
|
|
390
|
+
return ints.pack("s<*") if format.bit_depth == 16
|
|
391
|
+
return encode_pcm24(ints) if format.bit_depth == 24
|
|
392
|
+
return ints.pack("l<*") if format.bit_depth == 32
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
raise UnsupportedFormatError, "cannot encode WAV format: #{format.inspect}"
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
def encode_pcm24(samples)
|
|
399
|
+
bytes = samples.flat_map do |sample|
|
|
400
|
+
value = sample
|
|
401
|
+
value += 0x1000000 if value.negative?
|
|
402
|
+
[value & 0xFF, (value >> 8) & 0xFF, (value >> 16) & 0xFF]
|
|
403
|
+
end
|
|
404
|
+
bytes.pack("C*")
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
def build_fmt_chunk(format)
|
|
408
|
+
return build_standard_fmt_chunk(format) unless use_extensible_format?(format)
|
|
409
|
+
|
|
410
|
+
base_format_code = format.sample_format == :pcm ? WAV_FORMAT_PCM : WAV_FORMAT_FLOAT
|
|
411
|
+
channel_mask = channel_mask_for(format.channels)
|
|
412
|
+
sub_format_guid = base_format_code == WAV_FORMAT_PCM ? PCM_SUBFORMAT_GUID : FLOAT_SUBFORMAT_GUID
|
|
413
|
+
|
|
414
|
+
[WAV_FORMAT_EXTENSIBLE, format.channels, format.sample_rate, format.byte_rate, format.block_align, format.bit_depth,
|
|
415
|
+
22, format.bit_depth, channel_mask].pack("v v V V v v v v V") + sub_format_guid
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
def build_standard_fmt_chunk(format)
|
|
419
|
+
format_code = format.sample_format == :pcm ? WAV_FORMAT_PCM : WAV_FORMAT_FLOAT
|
|
420
|
+
[format_code, format.channels, format.sample_rate, format.byte_rate, format.block_align, format.bit_depth]
|
|
421
|
+
.pack("v v V V v v")
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
def use_extensible_format?(format)
|
|
425
|
+
format.channels > 2 || format.bit_depth > 16
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
def channel_mask_for(channels)
|
|
429
|
+
return 0 if channels <= 0
|
|
430
|
+
return 0x4 if channels == 1
|
|
431
|
+
return 0x3 if channels == 2
|
|
432
|
+
return 0x7 if channels == 3
|
|
433
|
+
return 0x33 if channels == 4
|
|
434
|
+
return 0x37 if channels == 5
|
|
435
|
+
return 0x3F if channels == 6
|
|
436
|
+
return 0x13F if channels == 7
|
|
437
|
+
return 0x63F if channels == 8
|
|
438
|
+
|
|
439
|
+
((1 << [channels, 32].min) - 1) & 0xFFFF_FFFF
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
def write_chunk(io, chunk_id, chunk_data)
|
|
443
|
+
io.write(chunk_id)
|
|
444
|
+
io.write([chunk_data.bytesize].pack("V"))
|
|
445
|
+
io.write(chunk_data)
|
|
446
|
+
io.write("\x00") if chunk_data.bytesize.odd?
|
|
447
|
+
end
|
|
448
|
+
|
|
449
|
+
def skip_padding_byte(io, chunk_size)
|
|
450
|
+
return unless chunk_size.odd?
|
|
451
|
+
|
|
452
|
+
padding = io.read(1)
|
|
453
|
+
raise InvalidFormatError, "missing padding byte after odd-sized chunk" unless padding && padding.bytesize == 1
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
end
|
|
457
|
+
end
|
|
458
|
+
end
|
|
459
|
+
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wavify
|
|
4
|
+
module Core
|
|
5
|
+
# Immutable duration value object used across codecs and sequencer APIs.
|
|
6
|
+
class Duration
|
|
7
|
+
include Comparable
|
|
8
|
+
|
|
9
|
+
attr_reader :total_seconds
|
|
10
|
+
|
|
11
|
+
# Builds a duration from a frame count and sample rate.
|
|
12
|
+
#
|
|
13
|
+
# @param sample_frames [Integer]
|
|
14
|
+
# @param sample_rate [Integer]
|
|
15
|
+
# @return [Duration]
|
|
16
|
+
def self.from_samples(sample_frames, sample_rate)
|
|
17
|
+
unless sample_frames.is_a?(Integer) && sample_frames >= 0
|
|
18
|
+
raise InvalidParameterError, "sample_frames must be a non-negative Integer: #{sample_frames.inspect}"
|
|
19
|
+
end
|
|
20
|
+
unless sample_rate.is_a?(Integer) && sample_rate.positive?
|
|
21
|
+
raise InvalidParameterError, "sample_rate must be a positive Integer: #{sample_rate.inspect}"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
new(sample_frames.to_f / sample_rate)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# @param total_seconds [Numeric] non-negative duration in seconds
|
|
28
|
+
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}"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
@total_seconds = total_seconds.to_f
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Compares two durations.
|
|
37
|
+
#
|
|
38
|
+
# @param other [Duration]
|
|
39
|
+
# @return [-1, 0, 1, nil]
|
|
40
|
+
def <=>(other)
|
|
41
|
+
return nil unless other.is_a?(Duration)
|
|
42
|
+
|
|
43
|
+
@total_seconds <=> other.total_seconds
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def +(other)
|
|
47
|
+
raise InvalidParameterError, "expected Duration: #{other.inspect}" unless other.is_a?(Duration)
|
|
48
|
+
|
|
49
|
+
self.class.new(@total_seconds + other.total_seconds)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def -(other)
|
|
53
|
+
raise InvalidParameterError, "expected Duration: #{other.inspect}" unless other.is_a?(Duration)
|
|
54
|
+
|
|
55
|
+
value = @total_seconds - other.total_seconds
|
|
56
|
+
raise InvalidParameterError, "resulting duration cannot be negative: #{value}" if value.negative?
|
|
57
|
+
|
|
58
|
+
self.class.new(value)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# @return [Integer] hours component for {#to_s}
|
|
62
|
+
def hours
|
|
63
|
+
(total_milliseconds / 3_600_000).floor
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# @return [Integer] minutes component for {#to_s}
|
|
67
|
+
def minutes
|
|
68
|
+
((total_milliseconds % 3_600_000) / 60_000).floor
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# @return [Integer] seconds component for {#to_s}
|
|
72
|
+
def seconds
|
|
73
|
+
((total_milliseconds % 60_000) / 1000).floor
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# @return [Integer] milliseconds component for {#to_s}
|
|
77
|
+
def milliseconds
|
|
78
|
+
(total_milliseconds % 1000).floor
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Returns a clock-style string (`HH:MM:SS.mmm`).
|
|
82
|
+
#
|
|
83
|
+
# @return [String]
|
|
84
|
+
def to_s
|
|
85
|
+
format("%<hours>02d:%<minutes>02d:%<seconds>02d.%<milliseconds>03d",
|
|
86
|
+
hours: hours,
|
|
87
|
+
minutes: minutes,
|
|
88
|
+
seconds: seconds,
|
|
89
|
+
milliseconds: milliseconds)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
def total_milliseconds
|
|
95
|
+
(@total_seconds * 1000).round
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wavify
|
|
4
|
+
# Core immutable value objects used by codecs, DSP, and high-level APIs.
|
|
5
|
+
module Core
|
|
6
|
+
# Describes the audio sample layout (channels, sample rate, and sample type).
|
|
7
|
+
class Format
|
|
8
|
+
# Supported symbolic sample format kinds.
|
|
9
|
+
SUPPORTED_SAMPLE_FORMATS = %i[pcm float].freeze
|
|
10
|
+
# Allowed bit depths for integer PCM.
|
|
11
|
+
PCM_BIT_DEPTHS = [8, 16, 24, 32].freeze
|
|
12
|
+
# Allowed bit depths for floating point samples.
|
|
13
|
+
FLOAT_BIT_DEPTHS = [32, 64].freeze
|
|
14
|
+
|
|
15
|
+
attr_reader :channels, :sample_rate, :bit_depth, :sample_format
|
|
16
|
+
|
|
17
|
+
# @param channels [Integer] number of interleaved channels (1..32)
|
|
18
|
+
# @param sample_rate [Integer] sampling rate in Hz
|
|
19
|
+
# @param bit_depth [Integer] bits per sample
|
|
20
|
+
# @param sample_format [Symbol,String] `:pcm` or `:float`
|
|
21
|
+
def initialize(channels:, sample_rate:, bit_depth:, sample_format: :pcm)
|
|
22
|
+
@channels = validate_channels(channels)
|
|
23
|
+
@sample_rate = validate_sample_rate(sample_rate)
|
|
24
|
+
@sample_format = validate_sample_format(sample_format)
|
|
25
|
+
@bit_depth = validate_bit_depth(bit_depth, @sample_format)
|
|
26
|
+
freeze
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Returns a new format with one or more fields replaced.
|
|
30
|
+
#
|
|
31
|
+
# @return [Format]
|
|
32
|
+
def with(channels: nil, sample_rate: nil, bit_depth: nil, sample_format: nil)
|
|
33
|
+
self.class.new(
|
|
34
|
+
channels: channels || @channels,
|
|
35
|
+
sample_rate: sample_rate || @sample_rate,
|
|
36
|
+
bit_depth: bit_depth || @bit_depth,
|
|
37
|
+
sample_format: sample_format || @sample_format
|
|
38
|
+
)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def mono?
|
|
42
|
+
@channels == 1
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def stereo?
|
|
46
|
+
@channels == 2
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# @return [Integer] bytes per audio frame (all channels)
|
|
50
|
+
def block_align
|
|
51
|
+
@channels * bytes_per_sample
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# @return [Integer] bytes per second for this format
|
|
55
|
+
def byte_rate
|
|
56
|
+
@sample_rate * block_align
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# @return [Integer] bytes used by one sample value
|
|
60
|
+
def bytes_per_sample
|
|
61
|
+
@bit_depth / 8
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Value equality for audio format parameters.
|
|
65
|
+
#
|
|
66
|
+
# @param other [Object]
|
|
67
|
+
# @return [Boolean]
|
|
68
|
+
def ==(other)
|
|
69
|
+
return false unless other.is_a?(Format)
|
|
70
|
+
|
|
71
|
+
@channels == other.channels &&
|
|
72
|
+
@sample_rate == other.sample_rate &&
|
|
73
|
+
@bit_depth == other.bit_depth &&
|
|
74
|
+
@sample_format == other.sample_format
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
alias eql? ==
|
|
78
|
+
|
|
79
|
+
# @return [Integer] hash value compatible with {#eql?}
|
|
80
|
+
def hash
|
|
81
|
+
[@channels, @sample_rate, @bit_depth, @sample_format].hash
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
def validate_channels(channels)
|
|
87
|
+
unless channels.is_a?(Integer) && channels.between?(1, 32)
|
|
88
|
+
raise InvalidFormatError, "channels must be an Integer between 1 and 32: #{channels.inspect}"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
channels
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def validate_sample_rate(sample_rate)
|
|
95
|
+
unless sample_rate.is_a?(Integer) && sample_rate.between?(8_000, 768_000)
|
|
96
|
+
raise InvalidFormatError, "sample_rate must be an Integer between 8000 and 768000: #{sample_rate.inspect}"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
sample_rate
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def validate_sample_format(sample_format)
|
|
103
|
+
format = sample_format.to_sym
|
|
104
|
+
raise UnsupportedFormatError, "unsupported sample_format: #{sample_format.inspect}" unless SUPPORTED_SAMPLE_FORMATS.include?(format)
|
|
105
|
+
|
|
106
|
+
format
|
|
107
|
+
rescue NoMethodError
|
|
108
|
+
raise InvalidFormatError, "sample_format must be Symbol/String: #{sample_format.inspect}"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def validate_bit_depth(bit_depth, sample_format)
|
|
112
|
+
raise InvalidFormatError, "bit_depth must be an Integer: #{bit_depth.inspect}" unless bit_depth.is_a?(Integer)
|
|
113
|
+
|
|
114
|
+
allowed_depths = sample_format == :pcm ? PCM_BIT_DEPTHS : FLOAT_BIT_DEPTHS
|
|
115
|
+
unless allowed_depths.include?(bit_depth)
|
|
116
|
+
raise InvalidFormatError,
|
|
117
|
+
"bit_depth #{bit_depth} is invalid for #{sample_format}. Allowed: #{allowed_depths.join(', ')}"
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
bit_depth
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
public
|
|
124
|
+
|
|
125
|
+
# Stereo 44.1kHz 16-bit PCM preset.
|
|
126
|
+
CD_QUALITY = new(channels: 2, sample_rate: 44_100, bit_depth: 16, sample_format: :pcm)
|
|
127
|
+
# Stereo 96kHz 24-bit PCM preset.
|
|
128
|
+
DVD_QUALITY = new(channels: 2, sample_rate: 96_000, bit_depth: 24, sample_format: :pcm)
|
|
129
|
+
# Mono 16kHz 16-bit PCM preset for speech-focused workflows.
|
|
130
|
+
VOICE = new(channels: 1, sample_rate: 16_000, bit_depth: 16, sample_format: :pcm)
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|