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
data/lib/wavify/codecs/wav.rb
CHANGED
|
@@ -9,10 +9,47 @@ module Wavify
|
|
|
9
9
|
WAV_FORMAT_PCM = 0x0001 # :nodoc:
|
|
10
10
|
WAV_FORMAT_FLOAT = 0x0003 # :nodoc:
|
|
11
11
|
WAV_FORMAT_EXTENSIBLE = 0xFFFE # :nodoc:
|
|
12
|
+
RIFF_MAX_SIZE = 0xFFFF_FFFF # :nodoc:
|
|
12
13
|
|
|
13
14
|
GUID_TAIL = [0x80, 0x00, 0x00, 0xAA, 0x00, 0x38, 0x9B, 0x71].pack("C*").freeze # :nodoc:
|
|
14
15
|
PCM_SUBFORMAT_GUID = ([WAV_FORMAT_PCM, 0x0000, 0x0010].pack("V v v") + GUID_TAIL).freeze # :nodoc:
|
|
15
16
|
FLOAT_SUBFORMAT_GUID = ([WAV_FORMAT_FLOAT, 0x0000, 0x0010].pack("V v v") + GUID_TAIL).freeze # :nodoc:
|
|
17
|
+
SPEAKER_BITS = {
|
|
18
|
+
front_left: 0x0001,
|
|
19
|
+
front_right: 0x0002,
|
|
20
|
+
front_center: 0x0004,
|
|
21
|
+
low_frequency: 0x0008,
|
|
22
|
+
back_left: 0x0010,
|
|
23
|
+
back_right: 0x0020,
|
|
24
|
+
front_left_of_center: 0x0040,
|
|
25
|
+
front_right_of_center: 0x0080,
|
|
26
|
+
back_center: 0x0100,
|
|
27
|
+
side_left: 0x0200,
|
|
28
|
+
side_right: 0x0400,
|
|
29
|
+
top_center: 0x0800,
|
|
30
|
+
top_front_left: 0x1000,
|
|
31
|
+
top_front_center: 0x2000,
|
|
32
|
+
top_front_right: 0x4000,
|
|
33
|
+
top_back_left: 0x8000,
|
|
34
|
+
top_back_center: 0x1_0000,
|
|
35
|
+
top_back_right: 0x2_0000
|
|
36
|
+
}.freeze # :nodoc:
|
|
37
|
+
INFO_TAGS = {
|
|
38
|
+
"INAM" => :title,
|
|
39
|
+
"IART" => :artist,
|
|
40
|
+
"ICMT" => :comment,
|
|
41
|
+
"ICOP" => :copyright,
|
|
42
|
+
"ICRD" => :date,
|
|
43
|
+
"IGNR" => :genre,
|
|
44
|
+
"ISFT" => :software,
|
|
45
|
+
"ITCH" => :technician
|
|
46
|
+
}.freeze # :nodoc:
|
|
47
|
+
INFO_TAG_CODES = INFO_TAGS.invert.freeze # :nodoc:
|
|
48
|
+
SMPL_LOOP_TYPES = {
|
|
49
|
+
0 => :forward,
|
|
50
|
+
1 => :alternating,
|
|
51
|
+
2 => :backward
|
|
52
|
+
}.freeze # :nodoc:
|
|
16
53
|
|
|
17
54
|
class << self
|
|
18
55
|
# @param io_or_path [String, IO]
|
|
@@ -26,9 +63,10 @@ module Wavify
|
|
|
26
63
|
io, close_io = open_input(io_or_path)
|
|
27
64
|
return false unless io
|
|
28
65
|
|
|
29
|
-
header = io
|
|
30
|
-
|
|
31
|
-
|
|
66
|
+
header = probe_bytes(io, 12)
|
|
67
|
+
(header&.start_with?("RIFF") || header&.start_with?("RF64")) && header[8, 4] == "WAVE"
|
|
68
|
+
rescue StreamError
|
|
69
|
+
false
|
|
32
70
|
ensure
|
|
33
71
|
io.close if close_io && io
|
|
34
72
|
end
|
|
@@ -38,11 +76,12 @@ module Wavify
|
|
|
38
76
|
# @param io_or_path [String, IO]
|
|
39
77
|
# @param format [Wavify::Core::Format, nil] optional output conversion
|
|
40
78
|
# @return [Wavify::Core::SampleBuffer]
|
|
41
|
-
def read(io_or_path, format: nil)
|
|
79
|
+
def read(io_or_path, format: nil, warning_io: nil)
|
|
42
80
|
io, close_io = open_input(io_or_path)
|
|
43
81
|
ensure_seekable!(io)
|
|
44
82
|
|
|
45
83
|
info = parse_chunk_directory(io)
|
|
84
|
+
emit_warnings(info.fetch(:warnings), warning_io)
|
|
46
85
|
source_format = info.fetch(:format)
|
|
47
86
|
samples = read_data_chunk(io, info, source_format)
|
|
48
87
|
buffer = Core::SampleBuffer.new(samples, source_format)
|
|
@@ -57,14 +96,15 @@ module Wavify
|
|
|
57
96
|
# @param sample_buffer [Wavify::Core::SampleBuffer]
|
|
58
97
|
# @param format [Wavify::Core::Format, nil]
|
|
59
98
|
# @return [String, IO]
|
|
60
|
-
def write(io_or_path, sample_buffer, format: nil)
|
|
99
|
+
def write(io_or_path, sample_buffer, format: nil, info: nil, **codec_options)
|
|
100
|
+
validate_no_codec_options!(codec_options, operation: "WAV write")
|
|
61
101
|
raise InvalidParameterError, "sample_buffer must be Core::SampleBuffer" unless sample_buffer.is_a?(Core::SampleBuffer)
|
|
62
102
|
|
|
63
103
|
target_format = format || sample_buffer.format
|
|
64
104
|
raise InvalidParameterError, "format must be Core::Format" unless target_format.is_a?(Core::Format)
|
|
65
105
|
|
|
66
106
|
buffer = sample_buffer.format == target_format ? sample_buffer : sample_buffer.convert(target_format)
|
|
67
|
-
stream_write(io_or_path, format: target_format) do |writer|
|
|
107
|
+
stream_write(io_or_path, format: target_format, info: info) do |writer|
|
|
68
108
|
writer.call(buffer)
|
|
69
109
|
end
|
|
70
110
|
end
|
|
@@ -74,14 +114,15 @@ module Wavify
|
|
|
74
114
|
# @param io_or_path [String, IO]
|
|
75
115
|
# @param chunk_size [Integer]
|
|
76
116
|
# @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?
|
|
117
|
+
def stream_read(io_or_path, chunk_size: 4096, warning_io: nil)
|
|
118
|
+
return enum_for(__method__, io_or_path, chunk_size: chunk_size, warning_io: warning_io) unless block_given?
|
|
79
119
|
raise InvalidParameterError, "chunk_size must be a positive Integer" unless chunk_size.is_a?(Integer) && chunk_size.positive?
|
|
80
120
|
|
|
81
121
|
io, close_io = open_input(io_or_path)
|
|
82
122
|
ensure_seekable!(io)
|
|
83
123
|
|
|
84
124
|
info = parse_chunk_directory(io)
|
|
125
|
+
emit_warnings(info.fetch(:warnings), warning_io)
|
|
85
126
|
format = info.fetch(:format)
|
|
86
127
|
bytes_per_frame = format.block_align
|
|
87
128
|
remaining = info.fetch(:data_size)
|
|
@@ -103,16 +144,16 @@ module Wavify
|
|
|
103
144
|
# @param io_or_path [String, IO]
|
|
104
145
|
# @param format [Wavify::Core::Format]
|
|
105
146
|
# @return [Enumerator, String, IO]
|
|
106
|
-
def stream_write(io_or_path, format:)
|
|
107
|
-
|
|
147
|
+
def stream_write(io_or_path, format:, info: nil, **codec_options)
|
|
148
|
+
validate_no_codec_options!(codec_options, operation: "WAV stream_write")
|
|
149
|
+
return enum_for(__method__, io_or_path, format: format, info: info, **codec_options) unless block_given?
|
|
108
150
|
raise InvalidParameterError, "format must be Core::Format" unless format.is_a?(Core::Format)
|
|
109
151
|
|
|
110
152
|
io, close_io = open_output(io_or_path)
|
|
111
153
|
ensure_seekable!(io)
|
|
112
|
-
io
|
|
113
|
-
io.truncate(0) if io.respond_to?(:truncate)
|
|
154
|
+
prepare_output!(io, owned: close_io)
|
|
114
155
|
|
|
115
|
-
header = write_stream_header(io, format)
|
|
156
|
+
header = write_stream_header(io, format, info: info)
|
|
116
157
|
total_data_bytes = 0
|
|
117
158
|
total_sample_frames = 0
|
|
118
159
|
|
|
@@ -120,17 +161,25 @@ module Wavify
|
|
|
120
161
|
raise InvalidParameterError, "stream chunk must be Core::SampleBuffer" unless chunk.is_a?(Core::SampleBuffer)
|
|
121
162
|
|
|
122
163
|
buffer = chunk.format == format ? chunk : chunk.convert(format)
|
|
164
|
+
encoded_bytes = buffer.sample_frame_count * format.block_align
|
|
165
|
+
next_data_bytes = total_data_bytes + encoded_bytes
|
|
166
|
+
next_sample_frames = total_sample_frames + buffer.sample_frame_count
|
|
167
|
+
projected_container_bytes = (io.pos - header.fetch(:container_start)) + encoded_bytes + (next_data_bytes.odd? ? 1 : 0)
|
|
168
|
+
validate_riff_sizes!(next_data_bytes, next_sample_frames, projected_container_bytes)
|
|
123
169
|
encoded = encode_samples(buffer.samples, format)
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
170
|
+
unless encoded.bytesize == encoded_bytes
|
|
171
|
+
raise StreamError, "WAV encoder produced an unexpected byte count"
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
write_all(io, encoded)
|
|
175
|
+
total_data_bytes = next_data_bytes
|
|
176
|
+
total_sample_frames = next_sample_frames
|
|
127
177
|
end
|
|
128
178
|
|
|
129
179
|
yield writer
|
|
130
|
-
io
|
|
180
|
+
write_all(io, "\x00") if total_data_bytes.odd?
|
|
131
181
|
finalize_stream_header(io, header, total_data_bytes, total_sample_frames)
|
|
132
|
-
io
|
|
133
|
-
io.rewind if io.respond_to?(:rewind)
|
|
182
|
+
finalize_output!(io, owned: close_io)
|
|
134
183
|
io_or_path
|
|
135
184
|
ensure
|
|
136
185
|
io.close if close_io && io
|
|
@@ -153,7 +202,19 @@ module Wavify
|
|
|
153
202
|
sample_frame_count: sample_frame_count,
|
|
154
203
|
duration: Core::Duration.from_samples(sample_frame_count, format.sample_rate),
|
|
155
204
|
fact_sample_length: info[:fact_sample_length],
|
|
156
|
-
smpl: info[:smpl]
|
|
205
|
+
smpl: info[:smpl],
|
|
206
|
+
loops: normalized_smpl_loops(info[:smpl]),
|
|
207
|
+
cue: info[:cue],
|
|
208
|
+
cue_points: info[:cue]&.fetch(:points) || [],
|
|
209
|
+
info: info[:info],
|
|
210
|
+
bext: info[:bext],
|
|
211
|
+
broadcast_extension: info[:bext],
|
|
212
|
+
rf64: info[:rf64],
|
|
213
|
+
container_bit_depth: info[:container_bit_depth],
|
|
214
|
+
valid_bits_per_sample: info[:valid_bits_per_sample],
|
|
215
|
+
channel_mask: info[:channel_mask],
|
|
216
|
+
channel_layout: format.channel_layout,
|
|
217
|
+
warnings: info[:warnings].freeze
|
|
157
218
|
}
|
|
158
219
|
ensure
|
|
159
220
|
io.close if close_io && io
|
|
@@ -161,28 +222,38 @@ module Wavify
|
|
|
161
222
|
|
|
162
223
|
private
|
|
163
224
|
|
|
164
|
-
def
|
|
165
|
-
|
|
225
|
+
def emit_warnings(warnings, warning_io)
|
|
226
|
+
return if warning_io.nil?
|
|
227
|
+
raise InvalidParameterError, "warning_io must respond to :puts or be nil" unless warning_io.respond_to?(:puts)
|
|
228
|
+
|
|
229
|
+
warnings.each { |warning| warning_io.puts("WAV warning: #{warning}") }
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def write_stream_header(io, format, info:)
|
|
233
|
+
container_start = io.pos
|
|
234
|
+
write_all(io, "RIFF")
|
|
166
235
|
riff_size_offset = io.pos
|
|
167
|
-
io
|
|
168
|
-
io
|
|
236
|
+
write_all(io, [0].pack("V"))
|
|
237
|
+
write_all(io, "WAVE")
|
|
169
238
|
|
|
170
239
|
fmt_chunk = build_fmt_chunk(format)
|
|
171
240
|
write_chunk(io, "fmt ", fmt_chunk)
|
|
241
|
+
write_chunk(io, "LIST", build_info_list_chunk(info)) if info
|
|
172
242
|
|
|
173
243
|
fact_sample_offset = nil
|
|
174
244
|
if format.sample_format != :pcm
|
|
175
|
-
io
|
|
176
|
-
io
|
|
245
|
+
write_all(io, "fact")
|
|
246
|
+
write_all(io, [4].pack("V"))
|
|
177
247
|
fact_sample_offset = io.pos
|
|
178
|
-
io
|
|
248
|
+
write_all(io, [0].pack("V"))
|
|
179
249
|
end
|
|
180
250
|
|
|
181
|
-
io
|
|
251
|
+
write_all(io, "data")
|
|
182
252
|
data_size_offset = io.pos
|
|
183
|
-
io
|
|
253
|
+
write_all(io, [0].pack("V"))
|
|
184
254
|
|
|
185
255
|
{
|
|
256
|
+
container_start: container_start,
|
|
186
257
|
riff_size_offset: riff_size_offset,
|
|
187
258
|
data_size_offset: data_size_offset,
|
|
188
259
|
fact_sample_offset: fact_sample_offset
|
|
@@ -191,72 +262,143 @@ module Wavify
|
|
|
191
262
|
|
|
192
263
|
def finalize_stream_header(io, header, total_data_bytes, total_sample_frames)
|
|
193
264
|
file_end = io.pos
|
|
265
|
+
container_bytes = file_end - header.fetch(:container_start)
|
|
266
|
+
validate_riff_sizes!(total_data_bytes, total_sample_frames, container_bytes)
|
|
194
267
|
|
|
195
268
|
io.seek(header.fetch(:data_size_offset), IO::SEEK_SET)
|
|
196
|
-
io
|
|
269
|
+
write_all(io, [total_data_bytes].pack("V"))
|
|
197
270
|
|
|
198
271
|
fact_sample_offset = header[:fact_sample_offset]
|
|
199
272
|
if fact_sample_offset
|
|
200
273
|
io.seek(fact_sample_offset, IO::SEEK_SET)
|
|
201
|
-
io
|
|
274
|
+
write_all(io, [total_sample_frames].pack("V"))
|
|
202
275
|
end
|
|
203
276
|
|
|
204
277
|
io.seek(header.fetch(:riff_size_offset), IO::SEEK_SET)
|
|
205
|
-
io
|
|
278
|
+
write_all(io, [container_bytes - 8].pack("V"))
|
|
206
279
|
io.seek(file_end, IO::SEEK_SET)
|
|
207
280
|
end
|
|
208
281
|
|
|
209
282
|
def parse_chunk_directory(io)
|
|
210
|
-
io.
|
|
283
|
+
container_start = io.pos
|
|
211
284
|
header = read_exact(io, 12, "missing RIFF/WAVE header")
|
|
212
|
-
|
|
285
|
+
container_id = header[0, 4]
|
|
286
|
+
unless %w[RIFF RF64].include?(container_id) && header[8, 4] == "WAVE"
|
|
287
|
+
raise InvalidFormatError, "invalid WAV header"
|
|
288
|
+
end
|
|
289
|
+
riff_size = header[4, 4].unpack1("V")
|
|
290
|
+
container_end = container_id == "RIFF" ? container_start + 8 + riff_size : nil
|
|
291
|
+
raise InvalidFormatError, "RIFF size is smaller than WAVE header" if container_end && container_end < container_start + 12
|
|
292
|
+
if container_end && io.respond_to?(:size) && container_end > io.size
|
|
293
|
+
raise InvalidFormatError, "RIFF size exceeds available input"
|
|
294
|
+
end
|
|
213
295
|
|
|
214
296
|
info = {
|
|
215
297
|
format: nil,
|
|
298
|
+
container_bit_depth: nil,
|
|
299
|
+
valid_bits_per_sample: nil,
|
|
300
|
+
channel_mask: nil,
|
|
301
|
+
warnings: [],
|
|
216
302
|
data_offset: nil,
|
|
217
303
|
data_size: nil,
|
|
218
304
|
sample_frame_count: nil,
|
|
219
305
|
fact_sample_length: nil,
|
|
220
|
-
smpl: nil
|
|
306
|
+
smpl: nil,
|
|
307
|
+
cue: nil,
|
|
308
|
+
info: nil,
|
|
309
|
+
bext: nil,
|
|
310
|
+
rf64: container_id == "RF64" ? {} : nil
|
|
221
311
|
}
|
|
312
|
+
seen_chunks = {}
|
|
313
|
+
|
|
314
|
+
loop do
|
|
315
|
+
break if container_end && io.pos >= container_end
|
|
316
|
+
break if !container_end && io.eof?
|
|
317
|
+
if container_end && (container_end - io.pos) < 8
|
|
318
|
+
raise InvalidFormatError, "truncated chunk header inside RIFF container"
|
|
319
|
+
end
|
|
222
320
|
|
|
223
|
-
|
|
224
|
-
chunk_header = io.read(8)
|
|
225
|
-
break if chunk_header.nil?
|
|
226
|
-
raise InvalidFormatError, "truncated chunk header" unless chunk_header.bytesize == 8
|
|
321
|
+
chunk_header = read_exact(io, 8, "truncated chunk header")
|
|
227
322
|
|
|
228
323
|
chunk_id = chunk_header[0, 4]
|
|
229
324
|
chunk_size = chunk_header[4, 4].unpack1("V")
|
|
325
|
+
consumed_size = chunk_size
|
|
326
|
+
if ["fmt ", "data", "ds64"].include?(chunk_id) && seen_chunks[chunk_id]
|
|
327
|
+
raise InvalidFormatError, "duplicate #{chunk_id.inspect} chunk"
|
|
328
|
+
end
|
|
329
|
+
seen_chunks[chunk_id] = true
|
|
230
330
|
|
|
231
331
|
case chunk_id
|
|
232
332
|
when "fmt "
|
|
233
333
|
chunk_data = read_exact(io, chunk_size, "truncated fmt chunk")
|
|
234
|
-
|
|
334
|
+
fmt_info = parse_fmt_chunk(chunk_data)
|
|
335
|
+
info[:format] = fmt_info.fetch(:format)
|
|
336
|
+
info[:container_bit_depth] = fmt_info.fetch(:container_bit_depth)
|
|
337
|
+
info[:valid_bits_per_sample] = fmt_info.fetch(:valid_bits_per_sample)
|
|
338
|
+
info[:channel_mask] = fmt_info.fetch(:channel_mask)
|
|
339
|
+
info[:warnings].concat(fmt_info.fetch(:warnings))
|
|
235
340
|
when "data"
|
|
236
341
|
info[:data_offset] = io.pos
|
|
237
|
-
info
|
|
238
|
-
|
|
342
|
+
data_size = rf64_chunk_size(info, :data_size, chunk_size)
|
|
343
|
+
consumed_size = data_size
|
|
344
|
+
info[:data_size] = data_size
|
|
345
|
+
skip_bytes(io, data_size)
|
|
346
|
+
when "ds64"
|
|
347
|
+
chunk_data = read_exact(io, chunk_size, "truncated ds64 chunk")
|
|
348
|
+
info[:rf64] = parse_ds64_chunk(chunk_data)
|
|
349
|
+
container_end = container_start + 8 + info.fetch(:rf64).fetch(:riff_size)
|
|
350
|
+
if io.respond_to?(:size) && container_end > io.size
|
|
351
|
+
raise InvalidFormatError, "RF64 size exceeds available input"
|
|
352
|
+
end
|
|
239
353
|
when "fact"
|
|
240
354
|
chunk_data = read_exact(io, chunk_size, "truncated fact chunk")
|
|
241
355
|
info[:fact_sample_length] = chunk_data.unpack1("V") if chunk_data.bytesize >= 4
|
|
242
356
|
when "smpl"
|
|
243
357
|
chunk_data = read_exact(io, chunk_size, "truncated smpl chunk")
|
|
244
358
|
info[:smpl] = parse_smpl_chunk(chunk_data)
|
|
359
|
+
when "cue "
|
|
360
|
+
chunk_data = read_exact(io, chunk_size, "truncated cue chunk")
|
|
361
|
+
info[:cue] = parse_cue_chunk(chunk_data)
|
|
362
|
+
when "bext"
|
|
363
|
+
chunk_data = read_exact(io, chunk_size, "truncated bext chunk")
|
|
364
|
+
info[:bext] = parse_bext_chunk(chunk_data)
|
|
365
|
+
when "LIST"
|
|
366
|
+
chunk_data = read_exact(io, chunk_size, "truncated LIST chunk")
|
|
367
|
+
list_info = parse_list_chunk(chunk_data)
|
|
368
|
+
info[:info] = list_info if list_info
|
|
245
369
|
else
|
|
246
370
|
skip_bytes(io, chunk_size)
|
|
247
371
|
end
|
|
248
372
|
|
|
249
|
-
skip_padding_byte(io,
|
|
373
|
+
skip_padding_byte(io, consumed_size)
|
|
374
|
+
if container_end && io.pos > container_end
|
|
375
|
+
raise InvalidFormatError, "chunk exceeds RIFF container boundary"
|
|
376
|
+
end
|
|
250
377
|
end
|
|
251
378
|
|
|
252
379
|
raise InvalidFormatError, "fmt chunk missing" unless info[:format]
|
|
253
380
|
raise InvalidFormatError, "data chunk missing" unless info[:data_offset] && info[:data_size]
|
|
254
381
|
|
|
255
382
|
validate_data_chunk!(io, info)
|
|
256
|
-
|
|
383
|
+
rf64_frame_count = info.dig(:rf64, :sample_frame_count)
|
|
384
|
+
info[:sample_frame_count] = if rf64_frame_count&.positive?
|
|
385
|
+
rf64_frame_count
|
|
386
|
+
else
|
|
387
|
+
info[:data_size] / info[:format].block_align
|
|
388
|
+
end
|
|
389
|
+
validate_smpl_loops!(info[:smpl], info[:sample_frame_count])
|
|
257
390
|
info
|
|
258
391
|
end
|
|
259
392
|
|
|
393
|
+
def rf64_chunk_size(info, key, chunk_size)
|
|
394
|
+
return chunk_size unless chunk_size == 0xFFFF_FFFF
|
|
395
|
+
|
|
396
|
+
rf64_size = info.dig(:rf64, key)
|
|
397
|
+
raise InvalidFormatError, "RF64 #{key} requires ds64 chunk" unless rf64_size
|
|
398
|
+
|
|
399
|
+
rf64_size
|
|
400
|
+
end
|
|
401
|
+
|
|
260
402
|
def validate_data_chunk!(io, info)
|
|
261
403
|
format = info.fetch(:format)
|
|
262
404
|
data_size = info.fetch(:data_size)
|
|
@@ -280,7 +422,9 @@ module Wavify
|
|
|
280
422
|
def parse_fmt_chunk(chunk)
|
|
281
423
|
raise InvalidFormatError, "fmt chunk is too small" if chunk.bytesize < 16
|
|
282
424
|
|
|
283
|
-
audio_format, channels, sample_rate, byte_rate, block_align,
|
|
425
|
+
audio_format, channels, sample_rate, byte_rate, block_align, container_bit_depth = chunk.unpack("v v V V v v")
|
|
426
|
+
valid_bits = container_bit_depth
|
|
427
|
+
channel_mask = nil
|
|
284
428
|
|
|
285
429
|
if audio_format == WAV_FORMAT_EXTENSIBLE
|
|
286
430
|
raise InvalidFormatError, "fmt extensible chunk is too small" if chunk.bytesize < 40
|
|
@@ -289,9 +433,19 @@ module Wavify
|
|
|
289
433
|
raise InvalidFormatError, "invalid extensible fmt chunk size" if extension_size < 22 || chunk.bytesize < (18 + extension_size)
|
|
290
434
|
|
|
291
435
|
valid_bits = chunk[18, 2].unpack1("v")
|
|
436
|
+
valid_bits = container_bit_depth if valid_bits.zero?
|
|
437
|
+
if valid_bits > container_bit_depth
|
|
438
|
+
raise InvalidFormatError, "valid bits per sample exceeds container bit depth"
|
|
439
|
+
end
|
|
440
|
+
channel_mask = chunk[20, 4].unpack1("V")
|
|
292
441
|
sub_format_guid = chunk[24, 16]
|
|
293
|
-
audio_format = sub_format_guid
|
|
294
|
-
|
|
442
|
+
audio_format = case sub_format_guid
|
|
443
|
+
when PCM_SUBFORMAT_GUID then WAV_FORMAT_PCM
|
|
444
|
+
when FLOAT_SUBFORMAT_GUID then WAV_FORMAT_FLOAT
|
|
445
|
+
else
|
|
446
|
+
raise UnsupportedFormatError,
|
|
447
|
+
"unsupported WAV extensible subformat GUID: #{sub_format_guid.unpack1('H*')}"
|
|
448
|
+
end
|
|
295
449
|
end
|
|
296
450
|
|
|
297
451
|
sample_format = case audio_format
|
|
@@ -304,21 +458,32 @@ module Wavify
|
|
|
304
458
|
format = Core::Format.new(
|
|
305
459
|
channels: channels,
|
|
306
460
|
sample_rate: sample_rate,
|
|
307
|
-
bit_depth:
|
|
308
|
-
sample_format: sample_format
|
|
461
|
+
bit_depth: container_bit_depth,
|
|
462
|
+
sample_format: sample_format,
|
|
463
|
+
valid_bits: valid_bits,
|
|
464
|
+
channel_layout: channel_layout_for_mask(channel_mask, channels)
|
|
309
465
|
)
|
|
310
466
|
|
|
311
467
|
expected_block_align = format.block_align
|
|
312
468
|
expected_byte_rate = format.byte_rate
|
|
313
|
-
|
|
314
|
-
|
|
469
|
+
raise InvalidFormatError, "fmt chunk has inconsistent block_align" unless block_align == expected_block_align
|
|
470
|
+
|
|
471
|
+
warnings = []
|
|
472
|
+
if byte_rate != expected_byte_rate
|
|
473
|
+
warnings << "fmt chunk byte_rate #{byte_rate} does not match expected #{expected_byte_rate}"
|
|
315
474
|
end
|
|
316
475
|
|
|
317
|
-
|
|
476
|
+
{
|
|
477
|
+
format: format,
|
|
478
|
+
container_bit_depth: container_bit_depth,
|
|
479
|
+
valid_bits_per_sample: valid_bits,
|
|
480
|
+
channel_mask: channel_mask,
|
|
481
|
+
warnings: warnings
|
|
482
|
+
}
|
|
318
483
|
end
|
|
319
484
|
|
|
320
485
|
def parse_smpl_chunk(chunk)
|
|
321
|
-
|
|
486
|
+
raise InvalidFormatError, "smpl chunk too small" if chunk.bytesize < 36
|
|
322
487
|
|
|
323
488
|
manufacturer, product, sample_period, midi_unity_note, midi_pitch_fraction,
|
|
324
489
|
smpte_format, smpte_offset, loop_count, sampler_data = chunk.unpack("V9")
|
|
@@ -326,7 +491,7 @@ module Wavify
|
|
|
326
491
|
loops = []
|
|
327
492
|
offset = 36
|
|
328
493
|
loop_count.times do
|
|
329
|
-
|
|
494
|
+
raise InvalidFormatError, "truncated smpl loop table" if offset + 24 > chunk.bytesize
|
|
330
495
|
|
|
331
496
|
identifier, loop_type, start_frame, end_frame, fraction, play_count = chunk.byteslice(offset, 24).unpack("V6")
|
|
332
497
|
loops << {
|
|
@@ -354,27 +519,164 @@ module Wavify
|
|
|
354
519
|
}
|
|
355
520
|
end
|
|
356
521
|
|
|
522
|
+
def normalized_smpl_loops(smpl)
|
|
523
|
+
return [] unless smpl
|
|
524
|
+
|
|
525
|
+
smpl.fetch(:loops).map do |loop|
|
|
526
|
+
{
|
|
527
|
+
identifier: loop.fetch(:identifier),
|
|
528
|
+
type: SMPL_LOOP_TYPES.fetch(loop.fetch(:type), loop.fetch(:type)),
|
|
529
|
+
start_frame: loop.fetch(:start_frame),
|
|
530
|
+
end_frame: loop.fetch(:end_frame),
|
|
531
|
+
length_frames: loop.fetch(:end_frame) - loop.fetch(:start_frame) + 1,
|
|
532
|
+
play_count: loop.fetch(:play_count)
|
|
533
|
+
}
|
|
534
|
+
end
|
|
535
|
+
end
|
|
536
|
+
|
|
537
|
+
def parse_cue_chunk(chunk)
|
|
538
|
+
raise InvalidFormatError, "cue chunk too small" if chunk.bytesize < 4
|
|
539
|
+
|
|
540
|
+
cue_count = chunk.unpack1("V")
|
|
541
|
+
points = []
|
|
542
|
+
offset = 4
|
|
543
|
+
cue_count.times do
|
|
544
|
+
raise InvalidFormatError, "truncated cue point table" if offset + 24 > chunk.bytesize
|
|
545
|
+
|
|
546
|
+
identifier, position, data_chunk_id, chunk_start, block_start, sample_offset =
|
|
547
|
+
chunk.byteslice(offset, 24).unpack("V V A4 V V V")
|
|
548
|
+
points << {
|
|
549
|
+
identifier: identifier,
|
|
550
|
+
position: position,
|
|
551
|
+
data_chunk_id: data_chunk_id,
|
|
552
|
+
chunk_start: chunk_start,
|
|
553
|
+
block_start: block_start,
|
|
554
|
+
sample_offset: sample_offset
|
|
555
|
+
}
|
|
556
|
+
offset += 24
|
|
557
|
+
end
|
|
558
|
+
|
|
559
|
+
{ cue_count: cue_count, points: points }
|
|
560
|
+
end
|
|
561
|
+
|
|
562
|
+
def parse_ds64_chunk(chunk)
|
|
563
|
+
raise InvalidFormatError, "ds64 chunk too small" if chunk.bytesize < 28
|
|
564
|
+
|
|
565
|
+
riff_size, data_size, sample_frame_count = chunk.byteslice(0, 24).unpack("Q< Q< Q<")
|
|
566
|
+
table_count = chunk.byteslice(24, 4).unpack1("V")
|
|
567
|
+
table = {}
|
|
568
|
+
offset = 28
|
|
569
|
+
table_count.times do
|
|
570
|
+
raise InvalidFormatError, "truncated ds64 table" if offset + 12 > chunk.bytesize
|
|
571
|
+
|
|
572
|
+
chunk_id = chunk.byteslice(offset, 4)
|
|
573
|
+
size = chunk.byteslice(offset + 4, 8).unpack1("Q<")
|
|
574
|
+
table[chunk_id] = size
|
|
575
|
+
offset += 12
|
|
576
|
+
end
|
|
577
|
+
|
|
578
|
+
{
|
|
579
|
+
riff_size: riff_size,
|
|
580
|
+
data_size: data_size,
|
|
581
|
+
sample_frame_count: sample_frame_count,
|
|
582
|
+
table: table
|
|
583
|
+
}
|
|
584
|
+
end
|
|
585
|
+
|
|
586
|
+
def parse_bext_chunk(chunk)
|
|
587
|
+
return nil if chunk.bytesize < 602
|
|
588
|
+
|
|
589
|
+
coding_history = chunk.bytesize > 602 ? strip_trailing_nuls(chunk.byteslice(602, chunk.bytesize - 602)) : ""
|
|
590
|
+
{
|
|
591
|
+
description: wav_string(chunk.byteslice(0, 256)),
|
|
592
|
+
originator: wav_string(chunk.byteslice(256, 32)),
|
|
593
|
+
originator_reference: wav_string(chunk.byteslice(288, 32)),
|
|
594
|
+
origination_date: wav_string(chunk.byteslice(320, 10)),
|
|
595
|
+
origination_time: wav_string(chunk.byteslice(330, 8)),
|
|
596
|
+
time_reference: chunk.byteslice(338, 8).unpack1("Q<"),
|
|
597
|
+
version: chunk.byteslice(346, 2).unpack1("v"),
|
|
598
|
+
umid: chunk.byteslice(348, 64).unpack1("H*"),
|
|
599
|
+
loudness_value: bext_loudness_value(chunk, 412),
|
|
600
|
+
loudness_range: bext_loudness_value(chunk, 414),
|
|
601
|
+
max_true_peak_level: bext_loudness_value(chunk, 416),
|
|
602
|
+
max_momentary_loudness: bext_loudness_value(chunk, 418),
|
|
603
|
+
max_short_term_loudness: bext_loudness_value(chunk, 420),
|
|
604
|
+
coding_history: coding_history
|
|
605
|
+
}
|
|
606
|
+
end
|
|
607
|
+
|
|
608
|
+
def wav_string(bytes)
|
|
609
|
+
strip_trailing_nuls(bytes).split("\x00", 2).first.to_s.force_encoding(Encoding::UTF_8).scrub
|
|
610
|
+
end
|
|
611
|
+
|
|
612
|
+
def bext_loudness_value(chunk, offset)
|
|
613
|
+
return nil if chunk.bytesize < offset + 2
|
|
614
|
+
|
|
615
|
+
chunk.byteslice(offset, 2).unpack1("s<")
|
|
616
|
+
end
|
|
617
|
+
|
|
618
|
+
def parse_list_chunk(chunk)
|
|
619
|
+
return nil if chunk.bytesize < 4
|
|
620
|
+
return nil unless chunk[0, 4] == "INFO"
|
|
621
|
+
|
|
622
|
+
result = { raw: {}, raw_bytes: {} }
|
|
623
|
+
offset = 4
|
|
624
|
+
while offset + 8 <= chunk.bytesize
|
|
625
|
+
tag = chunk[offset, 4]
|
|
626
|
+
size = chunk[offset + 4, 4].unpack1("V")
|
|
627
|
+
offset += 8
|
|
628
|
+
break if offset + size > chunk.bytesize
|
|
629
|
+
|
|
630
|
+
raw_value = strip_trailing_nuls(chunk.byteslice(offset, size))
|
|
631
|
+
value = raw_value.dup.force_encoding(Encoding::UTF_8).scrub
|
|
632
|
+
result[:raw][tag] = value
|
|
633
|
+
result[:raw_bytes][tag] = raw_value.freeze
|
|
634
|
+
semantic_key = INFO_TAGS[tag]
|
|
635
|
+
result[semantic_key] = value if semantic_key
|
|
636
|
+
offset += size
|
|
637
|
+
offset += 1 if size.odd?
|
|
638
|
+
end
|
|
639
|
+
|
|
640
|
+
result
|
|
641
|
+
end
|
|
642
|
+
|
|
643
|
+
def validate_smpl_loops!(smpl, sample_frame_count)
|
|
644
|
+
return unless smpl
|
|
645
|
+
|
|
646
|
+
smpl.fetch(:loops).each do |loop|
|
|
647
|
+
start_frame = loop.fetch(:start_frame)
|
|
648
|
+
end_frame = loop.fetch(:end_frame)
|
|
649
|
+
if end_frame < start_frame || end_frame >= sample_frame_count
|
|
650
|
+
raise InvalidFormatError,
|
|
651
|
+
"smpl loop range #{start_frame}..#{end_frame} is outside #{sample_frame_count} sample frames"
|
|
652
|
+
end
|
|
653
|
+
end
|
|
654
|
+
end
|
|
655
|
+
|
|
357
656
|
def decode_samples(data_chunk, format)
|
|
358
657
|
if format.sample_format == :float
|
|
359
658
|
return data_chunk.unpack("e*") if format.bit_depth == 32
|
|
360
659
|
return data_chunk.unpack("E*") if format.bit_depth == 64
|
|
361
660
|
elsif format.sample_format == :pcm
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
661
|
+
samples = data_chunk.unpack("C*").map { |byte| byte - 128 } if format.bit_depth == 8
|
|
662
|
+
samples = data_chunk.unpack("s<*") if format.bit_depth == 16
|
|
663
|
+
samples = decode_pcm24(data_chunk) if format.bit_depth == 24
|
|
664
|
+
samples = data_chunk.unpack("l<*") if format.bit_depth == 32
|
|
665
|
+
return canonicalize_valid_bits(samples, format) if samples
|
|
366
666
|
end
|
|
367
667
|
|
|
368
668
|
raise UnsupportedFormatError, "unsupported WAV bit depth: #{format.bit_depth}"
|
|
369
669
|
end
|
|
370
670
|
|
|
371
671
|
def decode_pcm24(data)
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
672
|
+
samples = Array.new(data.bytesize / 3)
|
|
673
|
+
samples.length.times do |index|
|
|
674
|
+
offset = index * 3
|
|
675
|
+
value = data.getbyte(offset) | (data.getbyte(offset + 1) << 8) | (data.getbyte(offset + 2) << 16)
|
|
375
676
|
value -= 0x1000000 if value.anybits?(0x800000)
|
|
376
|
-
value
|
|
677
|
+
samples[index] = value
|
|
377
678
|
end
|
|
679
|
+
samples
|
|
378
680
|
end
|
|
379
681
|
|
|
380
682
|
def encode_samples(samples, format)
|
|
@@ -385,7 +687,7 @@ module Wavify
|
|
|
385
687
|
elsif format.sample_format == :pcm
|
|
386
688
|
min = -(2**(format.bit_depth - 1))
|
|
387
689
|
max = (2**(format.bit_depth - 1)) - 1
|
|
388
|
-
ints = samples.map { |sample| sample.to_i.clamp(min, max) }
|
|
690
|
+
ints = canonicalize_valid_bits(samples.map { |sample| sample.to_i.clamp(min, max) }, format)
|
|
389
691
|
return ints.map { |sample| sample + 128 }.pack("C*") if format.bit_depth == 8
|
|
390
692
|
return ints.pack("s<*") if format.bit_depth == 16
|
|
391
693
|
return encode_pcm24(ints) if format.bit_depth == 24
|
|
@@ -396,23 +698,24 @@ module Wavify
|
|
|
396
698
|
end
|
|
397
699
|
|
|
398
700
|
def encode_pcm24(samples)
|
|
399
|
-
bytes = samples.
|
|
701
|
+
bytes = String.new(capacity: samples.length * 3, encoding: Encoding::BINARY)
|
|
702
|
+
samples.each do |sample|
|
|
400
703
|
value = sample
|
|
401
704
|
value += 0x1000000 if value.negative?
|
|
402
|
-
|
|
705
|
+
bytes << (value & 0xFF) << ((value >> 8) & 0xFF) << ((value >> 16) & 0xFF)
|
|
403
706
|
end
|
|
404
|
-
bytes
|
|
707
|
+
bytes
|
|
405
708
|
end
|
|
406
709
|
|
|
407
710
|
def build_fmt_chunk(format)
|
|
408
711
|
return build_standard_fmt_chunk(format) unless use_extensible_format?(format)
|
|
409
712
|
|
|
410
713
|
base_format_code = format.sample_format == :pcm ? WAV_FORMAT_PCM : WAV_FORMAT_FLOAT
|
|
411
|
-
channel_mask = channel_mask_for(format
|
|
714
|
+
channel_mask = channel_mask_for(format)
|
|
412
715
|
sub_format_guid = base_format_code == WAV_FORMAT_PCM ? PCM_SUBFORMAT_GUID : FLOAT_SUBFORMAT_GUID
|
|
413
716
|
|
|
414
717
|
[WAV_FORMAT_EXTENSIBLE, format.channels, format.sample_rate, format.byte_rate, format.block_align, format.bit_depth,
|
|
415
|
-
22, format.
|
|
718
|
+
22, format.valid_bits, channel_mask].pack("v v V V v v v v V") + sub_format_guid
|
|
416
719
|
end
|
|
417
720
|
|
|
418
721
|
def build_standard_fmt_chunk(format)
|
|
@@ -421,11 +724,55 @@ module Wavify
|
|
|
421
724
|
.pack("v v V V v v")
|
|
422
725
|
end
|
|
423
726
|
|
|
727
|
+
def build_info_list_chunk(info)
|
|
728
|
+
normalized = normalize_info_tags!(info)
|
|
729
|
+
chunks = normalized.map do |tag, value|
|
|
730
|
+
data = "#{value}\x00".b
|
|
731
|
+
tag + [data.bytesize].pack("V") + data + (data.bytesize.odd? ? "\x00" : "")
|
|
732
|
+
end
|
|
733
|
+
|
|
734
|
+
"INFO" + chunks.join
|
|
735
|
+
end
|
|
736
|
+
|
|
737
|
+
def normalize_info_tags!(info)
|
|
738
|
+
raise InvalidParameterError, "WAV info must be a Hash" unless info.is_a?(Hash)
|
|
739
|
+
|
|
740
|
+
info.each_with_object({}) do |(key, value), normalized|
|
|
741
|
+
tag = info_tag_for!(key)
|
|
742
|
+
raise InvalidParameterError, "WAV info values must be String-compatible" unless value.respond_to?(:to_s)
|
|
743
|
+
|
|
744
|
+
normalized[tag] = value.to_s
|
|
745
|
+
end
|
|
746
|
+
end
|
|
747
|
+
|
|
748
|
+
def info_tag_for!(key)
|
|
749
|
+
return key if key.is_a?(String) && key.match?(/\A[A-Z0-9 ]{4}\z/)
|
|
750
|
+
|
|
751
|
+
semantic = key.to_sym if key.respond_to?(:to_sym)
|
|
752
|
+
tag = INFO_TAG_CODES[semantic]
|
|
753
|
+
return tag if tag
|
|
754
|
+
|
|
755
|
+
raise InvalidParameterError, "unsupported WAV info tag: #{key.inspect}"
|
|
756
|
+
end
|
|
757
|
+
|
|
424
758
|
def use_extensible_format?(format)
|
|
425
|
-
|
|
759
|
+
default_layout = Core::Format::DEFAULT_CHANNEL_LAYOUTS[format.channels]
|
|
760
|
+
nonstandard_layout = format.channel_layout && format.channel_layout != default_layout
|
|
761
|
+
format.channels > 2 || format.bit_depth > 16 || format.valid_bits != format.bit_depth || nonstandard_layout
|
|
426
762
|
end
|
|
427
763
|
|
|
428
|
-
def channel_mask_for(
|
|
764
|
+
def channel_mask_for(format)
|
|
765
|
+
if format.channel_layout
|
|
766
|
+
return format.channel_layout.sum do |position|
|
|
767
|
+
SPEAKER_BITS.fetch(position) do
|
|
768
|
+
raise UnsupportedFormatError, "WAV cannot encode channel position: #{position.inspect}"
|
|
769
|
+
end
|
|
770
|
+
end
|
|
771
|
+
end
|
|
772
|
+
|
|
773
|
+
return 0 if format.channel_layout.nil?
|
|
774
|
+
|
|
775
|
+
channels = format.channels
|
|
429
776
|
return 0 if channels <= 0
|
|
430
777
|
return 0x4 if channels == 1
|
|
431
778
|
return 0x3 if channels == 2
|
|
@@ -439,11 +786,43 @@ module Wavify
|
|
|
439
786
|
((1 << [channels, 32].min) - 1) & 0xFFFF_FFFF
|
|
440
787
|
end
|
|
441
788
|
|
|
789
|
+
def channel_layout_for_mask(channel_mask, channels)
|
|
790
|
+
return Core::Format::DEFAULT_CHANNEL_LAYOUTS[channels] if channel_mask.nil?
|
|
791
|
+
return Core::Format::UNKNOWN_LAYOUT if channel_mask.zero?
|
|
792
|
+
|
|
793
|
+
unknown_bits = channel_mask & ~SPEAKER_BITS.values.reduce(0, :|)
|
|
794
|
+
raise UnsupportedFormatError, "unsupported WAV channel mask bits: 0x#{unknown_bits.to_s(16)}" unless unknown_bits.zero?
|
|
795
|
+
|
|
796
|
+
layout = SPEAKER_BITS.filter_map { |position, bit| position if channel_mask.anybits?(bit) }
|
|
797
|
+
unless layout.length == channels
|
|
798
|
+
raise InvalidFormatError, "WAV channel mask has #{layout.length} positions for #{channels} channels"
|
|
799
|
+
end
|
|
800
|
+
|
|
801
|
+
layout
|
|
802
|
+
end
|
|
803
|
+
|
|
804
|
+
def canonicalize_valid_bits(samples, format)
|
|
805
|
+
shift = format.bit_depth - format.valid_bits
|
|
806
|
+
return samples if shift.zero?
|
|
807
|
+
|
|
808
|
+
samples.map { |sample| (sample >> shift) << shift }
|
|
809
|
+
end
|
|
810
|
+
|
|
442
811
|
def write_chunk(io, chunk_id, chunk_data)
|
|
443
|
-
io
|
|
444
|
-
io
|
|
445
|
-
io
|
|
446
|
-
io
|
|
812
|
+
write_all(io, chunk_id)
|
|
813
|
+
write_all(io, [chunk_data.bytesize].pack("V"))
|
|
814
|
+
write_all(io, chunk_data)
|
|
815
|
+
write_all(io, "\x00") if chunk_data.bytesize.odd?
|
|
816
|
+
end
|
|
817
|
+
|
|
818
|
+
def validate_riff_sizes!(data_bytes, sample_frames, container_bytes)
|
|
819
|
+
return if data_bytes <= RIFF_MAX_SIZE && sample_frames <= RIFF_MAX_SIZE && (container_bytes - 8) <= RIFF_MAX_SIZE
|
|
820
|
+
|
|
821
|
+
raise UnsupportedFormatError, "WAV output exceeds RIFF 32-bit size limits; RF64 writing is not supported"
|
|
822
|
+
end
|
|
823
|
+
|
|
824
|
+
def strip_trailing_nuls(bytes)
|
|
825
|
+
bytes.to_s.sub(/\x00+\z/, "")
|
|
447
826
|
end
|
|
448
827
|
|
|
449
828
|
def skip_padding_byte(io, chunk_size)
|