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/aiff.rb
CHANGED
|
@@ -2,10 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
module Wavify
|
|
4
4
|
module Codecs
|
|
5
|
-
# AIFF codec for PCM audio
|
|
5
|
+
# AIFF codec for PCM audio and uncompressed AIFF-C variants.
|
|
6
6
|
class Aiff < Base
|
|
7
7
|
# Recognized filename extensions.
|
|
8
|
-
EXTENSIONS = %w[.aiff .aif].freeze
|
|
8
|
+
EXTENSIONS = %w[.aiff .aif .aifc].freeze
|
|
9
|
+
AIFF_MAX_SIZE = 0xFFFF_FFFF # :nodoc:
|
|
9
10
|
|
|
10
11
|
class << self
|
|
11
12
|
# @param io_or_path [String, IO]
|
|
@@ -19,9 +20,10 @@ module Wavify
|
|
|
19
20
|
io, close_io = open_input(io_or_path)
|
|
20
21
|
return false unless io
|
|
21
22
|
|
|
22
|
-
header = io
|
|
23
|
-
|
|
24
|
-
|
|
23
|
+
header = probe_bytes(io, 12)
|
|
24
|
+
header&.start_with?("FORM") && %w[AIFF AIFC].include?(header[8, 4])
|
|
25
|
+
rescue StreamError
|
|
26
|
+
false
|
|
25
27
|
ensure
|
|
26
28
|
io.close if close_io && io
|
|
27
29
|
end
|
|
@@ -31,11 +33,12 @@ module Wavify
|
|
|
31
33
|
# @param io_or_path [String, IO]
|
|
32
34
|
# @param format [Wavify::Core::Format, nil]
|
|
33
35
|
# @return [Wavify::Core::SampleBuffer]
|
|
34
|
-
def read(io_or_path, format: nil)
|
|
36
|
+
def read(io_or_path, format: nil, warning_io: nil)
|
|
35
37
|
io, close_io = open_input(io_or_path)
|
|
36
38
|
ensure_seekable!(io)
|
|
37
39
|
|
|
38
40
|
info = parse_chunks(io)
|
|
41
|
+
emit_warnings(info.fetch(:warnings), warning_io)
|
|
39
42
|
source_format = info.fetch(:format)
|
|
40
43
|
samples = read_sound_data(io, info, source_format)
|
|
41
44
|
buffer = Core::SampleBuffer.new(samples, source_format)
|
|
@@ -50,7 +53,9 @@ module Wavify
|
|
|
50
53
|
# @param sample_buffer [Wavify::Core::SampleBuffer]
|
|
51
54
|
# @param format [Wavify::Core::Format, nil]
|
|
52
55
|
# @return [String, IO]
|
|
53
|
-
def write(io_or_path, sample_buffer, format: nil
|
|
56
|
+
def write(io_or_path, sample_buffer, format: nil, form_type: nil, compression_type: nil, compression_name: nil,
|
|
57
|
+
**codec_options)
|
|
58
|
+
validate_no_codec_options!(codec_options, operation: "AIFF write")
|
|
54
59
|
raise InvalidParameterError, "sample_buffer must be Core::SampleBuffer" unless sample_buffer.is_a?(Core::SampleBuffer)
|
|
55
60
|
|
|
56
61
|
target_format = format || sample_buffer.format
|
|
@@ -58,27 +63,13 @@ module Wavify
|
|
|
58
63
|
raise UnsupportedFormatError, "AIFF writer supports PCM only" unless target_format.sample_format == :pcm
|
|
59
64
|
|
|
60
65
|
buffer = sample_buffer.format == target_format ? sample_buffer : sample_buffer.convert(target_format)
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
ssnd_chunk = build_ssnd_chunk(buffer.samples, target_format)
|
|
69
|
-
form_size = 4 + chunk_size(comm_chunk) + chunk_size(ssnd_chunk)
|
|
70
|
-
|
|
71
|
-
io.write("FORM")
|
|
72
|
-
io.write([form_size].pack("N"))
|
|
73
|
-
io.write("AIFF")
|
|
74
|
-
write_chunk(io, "COMM", comm_chunk)
|
|
75
|
-
write_chunk(io, "SSND", ssnd_chunk)
|
|
76
|
-
io.flush if io.respond_to?(:flush)
|
|
77
|
-
io.rewind if io.respond_to?(:rewind)
|
|
78
|
-
|
|
79
|
-
io_or_path
|
|
80
|
-
ensure
|
|
81
|
-
io.close if close_io && io
|
|
66
|
+
stream_write(
|
|
67
|
+
io_or_path,
|
|
68
|
+
format: target_format,
|
|
69
|
+
form_type: form_type,
|
|
70
|
+
compression_type: compression_type,
|
|
71
|
+
compression_name: compression_name
|
|
72
|
+
) { |writer| writer.call(buffer) }
|
|
82
73
|
end
|
|
83
74
|
|
|
84
75
|
# Streams AIFF decoding in frame chunks.
|
|
@@ -86,14 +77,15 @@ module Wavify
|
|
|
86
77
|
# @param io_or_path [String, IO]
|
|
87
78
|
# @param chunk_size [Integer]
|
|
88
79
|
# @return [Enumerator]
|
|
89
|
-
def stream_read(io_or_path, chunk_size: 4096)
|
|
90
|
-
return enum_for(__method__, io_or_path, chunk_size: chunk_size) unless block_given?
|
|
80
|
+
def stream_read(io_or_path, chunk_size: 4096, warning_io: nil)
|
|
81
|
+
return enum_for(__method__, io_or_path, chunk_size: chunk_size, warning_io: warning_io) unless block_given?
|
|
91
82
|
raise InvalidParameterError, "chunk_size must be a positive Integer" unless chunk_size.is_a?(Integer) && chunk_size.positive?
|
|
92
83
|
|
|
93
84
|
io, close_io = open_input(io_or_path)
|
|
94
85
|
ensure_seekable!(io)
|
|
95
86
|
|
|
96
87
|
info = parse_chunks(io)
|
|
88
|
+
emit_warnings(info.fetch(:warnings), warning_io)
|
|
97
89
|
format = info.fetch(:format)
|
|
98
90
|
bytes_per_frame = format.block_align
|
|
99
91
|
remaining = info.fetch(:sound_data_size)
|
|
@@ -102,7 +94,7 @@ module Wavify
|
|
|
102
94
|
while remaining.positive?
|
|
103
95
|
bytes = [remaining, bytes_per_frame * chunk_size].min
|
|
104
96
|
chunk_data = read_exact(io, bytes, "truncated SSND data")
|
|
105
|
-
yield Core::SampleBuffer.new(decode_samples(chunk_data, format), format)
|
|
97
|
+
yield Core::SampleBuffer.new(decode_samples(chunk_data, format, byte_order: info.fetch(:byte_order)), format)
|
|
106
98
|
remaining -= bytes
|
|
107
99
|
end
|
|
108
100
|
ensure
|
|
@@ -114,25 +106,62 @@ module Wavify
|
|
|
114
106
|
# @param io_or_path [String, IO]
|
|
115
107
|
# @param format [Wavify::Core::Format]
|
|
116
108
|
# @return [Enumerator, String, IO]
|
|
117
|
-
def stream_write(io_or_path, format:
|
|
118
|
-
|
|
109
|
+
def stream_write(io_or_path, format:, form_type: nil, compression_type: nil, compression_name: nil,
|
|
110
|
+
**codec_options)
|
|
111
|
+
validate_no_codec_options!(codec_options, operation: "AIFF stream_write")
|
|
112
|
+
unless block_given?
|
|
113
|
+
return enum_for(
|
|
114
|
+
__method__,
|
|
115
|
+
io_or_path,
|
|
116
|
+
format: format,
|
|
117
|
+
form_type: form_type,
|
|
118
|
+
compression_type: compression_type,
|
|
119
|
+
compression_name: compression_name,
|
|
120
|
+
**codec_options
|
|
121
|
+
)
|
|
122
|
+
end
|
|
119
123
|
raise InvalidParameterError, "format must be Core::Format" unless format.is_a?(Core::Format)
|
|
120
124
|
raise UnsupportedFormatError, "AIFF stream writer supports PCM only" unless format.sample_format == :pcm
|
|
121
125
|
|
|
122
|
-
|
|
123
|
-
|
|
126
|
+
write_options = normalize_write_options(
|
|
127
|
+
io_or_path,
|
|
128
|
+
form_type: form_type,
|
|
129
|
+
compression_type: compression_type,
|
|
130
|
+
compression_name: compression_name
|
|
131
|
+
)
|
|
132
|
+
io, close_io = open_output(io_or_path)
|
|
133
|
+
ensure_seekable!(io)
|
|
134
|
+
prepare_output!(io, owned: close_io)
|
|
135
|
+
|
|
136
|
+
header = write_stream_header(io, format, write_options)
|
|
137
|
+
total_data_bytes = 0
|
|
138
|
+
total_sample_frames = 0
|
|
124
139
|
writer = lambda do |buffer|
|
|
125
140
|
raise InvalidParameterError, "stream chunk must be Core::SampleBuffer" unless buffer.is_a?(Core::SampleBuffer)
|
|
126
141
|
|
|
127
142
|
converted = buffer.format == format ? buffer : buffer.convert(format)
|
|
128
|
-
|
|
129
|
-
|
|
143
|
+
encoded_bytes = converted.sample_frame_count * format.block_align
|
|
144
|
+
validate_aiff_sizes!(
|
|
145
|
+
data_bytes: total_data_bytes + encoded_bytes,
|
|
146
|
+
sample_frames: total_sample_frames + converted.sample_frame_count
|
|
147
|
+
)
|
|
148
|
+
encoded = encode_samples(converted.samples, format, byte_order: write_options.fetch(:byte_order))
|
|
149
|
+
unless encoded.bytesize == encoded_bytes
|
|
150
|
+
raise StreamError, "AIFF encoder produced an unexpected byte count"
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
write_all(io, encoded)
|
|
154
|
+
total_data_bytes += encoded_bytes
|
|
155
|
+
total_sample_frames += converted.sample_frame_count
|
|
130
156
|
end
|
|
131
157
|
yield writer
|
|
132
158
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
159
|
+
write_all(io, "\x00") if total_data_bytes.odd?
|
|
160
|
+
finalize_stream_header(io, header, total_data_bytes, total_sample_frames)
|
|
161
|
+
finalize_output!(io, owned: close_io)
|
|
162
|
+
io_or_path
|
|
163
|
+
ensure
|
|
164
|
+
io.close if close_io && io
|
|
136
165
|
end
|
|
137
166
|
|
|
138
167
|
# Reads AIFF metadata without decoding the full audio payload.
|
|
@@ -150,7 +179,16 @@ module Wavify
|
|
|
150
179
|
{
|
|
151
180
|
format: format,
|
|
152
181
|
sample_frame_count: sample_frame_count,
|
|
153
|
-
duration: Core::Duration.from_samples(sample_frame_count, format.sample_rate)
|
|
182
|
+
duration: Core::Duration.from_samples(sample_frame_count, format.sample_rate),
|
|
183
|
+
form_type: info[:form_type],
|
|
184
|
+
compression_type: info[:compression_type],
|
|
185
|
+
compression_name: info[:compression_name],
|
|
186
|
+
encoded_sample_rate: info[:encoded_sample_rate],
|
|
187
|
+
container_bit_depth: info[:container_bit_depth],
|
|
188
|
+
valid_bits_per_sample: info[:valid_bits_per_sample],
|
|
189
|
+
warnings: info[:warnings].freeze,
|
|
190
|
+
markers: info[:markers],
|
|
191
|
+
instrument: info[:instrument]
|
|
154
192
|
}
|
|
155
193
|
ensure
|
|
156
194
|
io.close if close_io && io
|
|
@@ -158,48 +196,138 @@ module Wavify
|
|
|
158
196
|
|
|
159
197
|
private
|
|
160
198
|
|
|
199
|
+
def emit_warnings(warnings, warning_io)
|
|
200
|
+
return if warning_io.nil?
|
|
201
|
+
raise InvalidParameterError, "warning_io must respond to :puts or be nil" unless warning_io.respond_to?(:puts)
|
|
202
|
+
|
|
203
|
+
warnings.each { |warning| warning_io.puts("AIFF warning: #{warning}") }
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def write_stream_header(io, format, write_options)
|
|
207
|
+
write_all(io, "FORM")
|
|
208
|
+
form_size_offset = io.pos
|
|
209
|
+
write_all(io, [0].pack("N"))
|
|
210
|
+
write_all(io, write_options.fetch(:form_type))
|
|
211
|
+
|
|
212
|
+
comm_chunk = build_comm_chunk(
|
|
213
|
+
format,
|
|
214
|
+
0,
|
|
215
|
+
compression_type: write_options.fetch(:compression_type),
|
|
216
|
+
compression_name: write_options.fetch(:compression_name)
|
|
217
|
+
)
|
|
218
|
+
comm_frame_count_offset = io.pos + 10
|
|
219
|
+
write_chunk(io, "COMM", comm_chunk)
|
|
220
|
+
|
|
221
|
+
write_all(io, "SSND")
|
|
222
|
+
ssnd_size_offset = io.pos
|
|
223
|
+
write_all(io, [0].pack("N"))
|
|
224
|
+
write_all(io, [0, 0].pack("N2"))
|
|
225
|
+
|
|
226
|
+
{
|
|
227
|
+
container_start: form_size_offset - 4,
|
|
228
|
+
form_size_offset: form_size_offset,
|
|
229
|
+
comm_frame_count_offset: comm_frame_count_offset,
|
|
230
|
+
ssnd_size_offset: ssnd_size_offset
|
|
231
|
+
}
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def finalize_stream_header(io, header, total_data_bytes, total_sample_frames)
|
|
235
|
+
file_end = io.pos
|
|
236
|
+
form_size = file_end - header.fetch(:container_start) - 8
|
|
237
|
+
validate_aiff_sizes!(
|
|
238
|
+
data_bytes: total_data_bytes,
|
|
239
|
+
sample_frames: total_sample_frames,
|
|
240
|
+
form_size: form_size
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
io.seek(header.fetch(:form_size_offset), IO::SEEK_SET)
|
|
244
|
+
write_all(io, [form_size].pack("N"))
|
|
245
|
+
io.seek(header.fetch(:comm_frame_count_offset), IO::SEEK_SET)
|
|
246
|
+
write_all(io, [total_sample_frames].pack("N"))
|
|
247
|
+
io.seek(header.fetch(:ssnd_size_offset), IO::SEEK_SET)
|
|
248
|
+
write_all(io, [total_data_bytes + 8].pack("N"))
|
|
249
|
+
io.seek(file_end, IO::SEEK_SET)
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def validate_aiff_sizes!(data_bytes:, sample_frames:, form_size: nil)
|
|
253
|
+
if data_bytes > (AIFF_MAX_SIZE - 8) || sample_frames > AIFF_MAX_SIZE || (form_size && form_size > AIFF_MAX_SIZE)
|
|
254
|
+
raise UnsupportedFormatError, "AIFF output exceeds 32-bit container limits"
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
|
|
161
258
|
def parse_chunks(io)
|
|
162
|
-
io.
|
|
259
|
+
container_start = io.pos
|
|
163
260
|
header = read_exact(io, 12, "missing FORM header")
|
|
164
261
|
raise InvalidFormatError, "invalid AIFF header" unless header.start_with?("FORM")
|
|
165
262
|
|
|
166
263
|
form_type = header[8, 4]
|
|
167
|
-
raise
|
|
168
|
-
|
|
264
|
+
raise InvalidFormatError, "invalid AIFF form type" unless %w[AIFF AIFC].include?(form_type)
|
|
265
|
+
container_end = container_start + 8 + header[4, 4].unpack1("N")
|
|
266
|
+
raise InvalidFormatError, "FORM size is smaller than AIFF header" if container_end < container_start + 12
|
|
267
|
+
if io.respond_to?(:size) && container_end > io.size
|
|
268
|
+
raise InvalidFormatError, "FORM size exceeds available input"
|
|
269
|
+
end
|
|
169
270
|
|
|
170
271
|
info = {
|
|
272
|
+
form_type: form_type,
|
|
171
273
|
format: nil,
|
|
172
274
|
sample_frame_count: nil,
|
|
173
275
|
sound_data_offset: nil,
|
|
174
|
-
sound_data_size: nil
|
|
276
|
+
sound_data_size: nil,
|
|
277
|
+
byte_order: :big,
|
|
278
|
+
compression_type: form_type == "AIFF" ? "NONE" : nil,
|
|
279
|
+
compression_name: nil,
|
|
280
|
+
encoded_sample_rate: nil,
|
|
281
|
+
container_bit_depth: nil,
|
|
282
|
+
valid_bits_per_sample: nil,
|
|
283
|
+
warnings: [],
|
|
284
|
+
markers: [],
|
|
285
|
+
instrument: nil
|
|
175
286
|
}
|
|
287
|
+
seen_chunks = {}
|
|
176
288
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
289
|
+
while io.pos < container_end
|
|
290
|
+
raise InvalidFormatError, "truncated AIFF chunk header" if (container_end - io.pos) < 8
|
|
291
|
+
|
|
292
|
+
chunk_header = read_exact(io, 8, "truncated AIFF chunk header")
|
|
181
293
|
|
|
182
294
|
chunk_id = chunk_header[0, 4]
|
|
183
295
|
chunk_size = chunk_header[4, 4].unpack1("N")
|
|
296
|
+
if %w[COMM SSND].include?(chunk_id) && seen_chunks[chunk_id]
|
|
297
|
+
raise InvalidFormatError, "duplicate #{chunk_id.inspect} chunk"
|
|
298
|
+
end
|
|
299
|
+
seen_chunks[chunk_id] = true
|
|
184
300
|
|
|
185
301
|
case chunk_id
|
|
186
302
|
when "COMM"
|
|
187
303
|
chunk_data = read_exact(io, chunk_size, "truncated COMM chunk")
|
|
188
304
|
parse_comm_chunk(chunk_data, info)
|
|
189
305
|
when "SSND"
|
|
306
|
+
raise InvalidFormatError, "invalid SSND chunk size" if chunk_size < 8
|
|
307
|
+
|
|
190
308
|
offset, = read_exact(io, 8, "truncated SSND header").unpack("N2")
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
309
|
+
available_sound_bytes = chunk_size - 8
|
|
310
|
+
if offset > available_sound_bytes
|
|
311
|
+
raise InvalidFormatError, "SSND offset exceeds chunk body"
|
|
312
|
+
end
|
|
194
313
|
|
|
314
|
+
skip_bytes(io, offset)
|
|
315
|
+
sound_data_size = available_sound_bytes - offset
|
|
195
316
|
info[:sound_data_offset] = io.pos
|
|
196
317
|
info[:sound_data_size] = sound_data_size
|
|
197
318
|
skip_bytes(io, sound_data_size)
|
|
319
|
+
when "MARK"
|
|
320
|
+
chunk_data = read_exact(io, chunk_size, "truncated MARK chunk")
|
|
321
|
+
info[:markers] = parse_mark_chunk(chunk_data)
|
|
322
|
+
when "INST"
|
|
323
|
+
chunk_data = read_exact(io, chunk_size, "truncated INST chunk")
|
|
324
|
+
info[:instrument] = parse_inst_chunk(chunk_data)
|
|
198
325
|
else
|
|
199
326
|
skip_bytes(io, chunk_size)
|
|
200
327
|
end
|
|
201
328
|
|
|
202
|
-
io
|
|
329
|
+
read_exact(io, 1, "missing padding byte after odd-sized AIFF chunk") if chunk_size.odd?
|
|
330
|
+
raise InvalidFormatError, "chunk exceeds FORM container boundary" if io.pos > container_end
|
|
203
331
|
end
|
|
204
332
|
|
|
205
333
|
raise InvalidFormatError, "COMM chunk missing" unless info[:format]
|
|
@@ -210,6 +338,11 @@ module Wavify
|
|
|
210
338
|
if (info[:sound_data_size] % info[:format].block_align) != 0
|
|
211
339
|
raise InvalidFormatError, "SSND data size is not aligned to frame size"
|
|
212
340
|
end
|
|
341
|
+
decoded_frame_count = info[:sound_data_size] / info[:format].block_align
|
|
342
|
+
if info[:sample_frame_count] != decoded_frame_count
|
|
343
|
+
raise InvalidFormatError,
|
|
344
|
+
"COMM declares #{info[:sample_frame_count]} frames but SSND contains #{decoded_frame_count}"
|
|
345
|
+
end
|
|
213
346
|
|
|
214
347
|
info
|
|
215
348
|
end
|
|
@@ -217,88 +350,256 @@ module Wavify
|
|
|
217
350
|
def parse_comm_chunk(chunk, info)
|
|
218
351
|
raise InvalidFormatError, "COMM chunk too small" if chunk.bytesize < 18
|
|
219
352
|
|
|
220
|
-
channels, sample_frames,
|
|
353
|
+
channels, sample_frames, valid_bits = chunk.unpack("n N n")
|
|
221
354
|
sample_rate = decode_extended80(chunk[8, 10])
|
|
222
355
|
rounded_rate = sample_rate.round
|
|
356
|
+
container_bit_depth = ((valid_bits + 7) / 8) * 8
|
|
357
|
+
unless Core::Format::PCM_BIT_DEPTHS.include?(container_bit_depth)
|
|
358
|
+
raise UnsupportedFormatError, "unsupported AIFF sample size: #{valid_bits} bits"
|
|
359
|
+
end
|
|
360
|
+
if sample_rate != rounded_rate
|
|
361
|
+
info[:warnings] << "COMM sample rate #{sample_rate} rounded to #{rounded_rate} Hz"
|
|
362
|
+
end
|
|
363
|
+
if info.fetch(:form_type) == "AIFC"
|
|
364
|
+
raise InvalidFormatError, "AIFC COMM chunk too small" if chunk.bytesize < 22
|
|
365
|
+
|
|
366
|
+
compression_type = chunk.byteslice(18, 4)
|
|
367
|
+
info[:compression_type] = compression_type
|
|
368
|
+
info[:compression_name] = parse_pascal_string(chunk.byteslice(22, chunk.bytesize - 22).to_s)
|
|
369
|
+
info[:byte_order] = byte_order_for_aifc!(compression_type)
|
|
370
|
+
end
|
|
223
371
|
|
|
224
372
|
format = Core::Format.new(
|
|
225
373
|
channels: channels,
|
|
226
374
|
sample_rate: rounded_rate,
|
|
227
|
-
bit_depth:
|
|
228
|
-
sample_format: :pcm
|
|
375
|
+
bit_depth: container_bit_depth,
|
|
376
|
+
sample_format: :pcm,
|
|
377
|
+
valid_bits: valid_bits
|
|
229
378
|
)
|
|
230
379
|
|
|
231
380
|
info[:format] = format
|
|
232
381
|
info[:sample_frame_count] = sample_frames
|
|
382
|
+
info[:encoded_sample_rate] = sample_rate
|
|
383
|
+
info[:container_bit_depth] = container_bit_depth
|
|
384
|
+
info[:valid_bits_per_sample] = valid_bits
|
|
233
385
|
end
|
|
234
386
|
|
|
235
387
|
def read_sound_data(io, info, format)
|
|
236
388
|
io.seek(info.fetch(:sound_data_offset), IO::SEEK_SET)
|
|
237
389
|
data = read_exact(io, info.fetch(:sound_data_size), "truncated SSND data")
|
|
238
|
-
decode_samples(data, format)
|
|
390
|
+
decode_samples(data, format, byte_order: info.fetch(:byte_order))
|
|
239
391
|
end
|
|
240
392
|
|
|
241
|
-
def
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
393
|
+
def parse_mark_chunk(chunk)
|
|
394
|
+
raise InvalidFormatError, "MARK chunk too small" if chunk.bytesize < 2
|
|
395
|
+
|
|
396
|
+
marker_count = chunk.unpack1("n")
|
|
397
|
+
markers = []
|
|
398
|
+
offset = 2
|
|
399
|
+
marker_count.times do
|
|
400
|
+
raise InvalidFormatError, "truncated MARK marker" if offset + 7 > chunk.bytesize
|
|
401
|
+
|
|
402
|
+
identifier = chunk.byteslice(offset, 2).unpack1("n")
|
|
403
|
+
position = chunk.byteslice(offset + 2, 4).unpack1("N")
|
|
404
|
+
name_length = chunk.getbyte(offset + 6)
|
|
405
|
+
name_start = offset + 7
|
|
406
|
+
raise InvalidFormatError, "truncated MARK marker name" if name_start + name_length > chunk.bytesize
|
|
407
|
+
|
|
408
|
+
raw_name = chunk.byteslice(name_start, name_length).b.freeze
|
|
409
|
+
name = raw_name.dup.force_encoding(Encoding::UTF_8).scrub.freeze
|
|
410
|
+
markers << { identifier: identifier, position: position, name: name, raw_name_bytes: raw_name }.freeze
|
|
411
|
+
offset = name_start + name_length
|
|
412
|
+
offset += 1 if (name_length + 1).odd?
|
|
253
413
|
end
|
|
414
|
+
markers
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
def parse_inst_chunk(chunk)
|
|
418
|
+
return nil if chunk.bytesize < 20
|
|
419
|
+
|
|
420
|
+
base_note, detune, low_note, high_note, low_velocity, high_velocity, gain =
|
|
421
|
+
chunk.byteslice(0, 8).unpack("C6s>")
|
|
422
|
+
sustain_mode, sustain_begin, sustain_end, release_mode, release_begin, release_end =
|
|
423
|
+
chunk.byteslice(8, 12).unpack("n n n n n n")
|
|
424
|
+
|
|
425
|
+
{
|
|
426
|
+
base_note: base_note,
|
|
427
|
+
detune: detune,
|
|
428
|
+
low_note: low_note,
|
|
429
|
+
high_note: high_note,
|
|
430
|
+
low_velocity: low_velocity,
|
|
431
|
+
high_velocity: high_velocity,
|
|
432
|
+
gain: gain,
|
|
433
|
+
sustain_loop: { mode: sustain_mode, begin_marker: sustain_begin, end_marker: sustain_end },
|
|
434
|
+
release_loop: { mode: release_mode, begin_marker: release_begin, end_marker: release_end }
|
|
435
|
+
}
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
def byte_order_for_aifc!(compression_type)
|
|
439
|
+
return :big if compression_type == "NONE"
|
|
440
|
+
return :little if compression_type == "sowt"
|
|
441
|
+
|
|
442
|
+
raise UnsupportedFormatError, "unsupported AIFF-C compression type: #{compression_type.inspect}"
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
def normalize_write_options(io_or_path, form_type:, compression_type:, compression_name:)
|
|
446
|
+
requested_form = normalize_write_form_type(form_type || inferred_form_type(io_or_path, compression_type))
|
|
447
|
+
requested_compression = compression_type&.to_s
|
|
448
|
+
requested_form = "AIFC" if requested_compression
|
|
449
|
+
if requested_form == "AIFF"
|
|
450
|
+
return { form_type: "AIFF", compression_type: nil, compression_name: nil, byte_order: :big }
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
normalized_compression = requested_compression || "NONE"
|
|
454
|
+
byte_order = byte_order_for_aifc!(normalized_compression)
|
|
455
|
+
{
|
|
456
|
+
form_type: "AIFC",
|
|
457
|
+
compression_type: normalized_compression,
|
|
458
|
+
compression_name: compression_name || default_aifc_compression_name(normalized_compression),
|
|
459
|
+
byte_order: byte_order
|
|
460
|
+
}
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
def inferred_form_type(io_or_path, compression_type)
|
|
464
|
+
return "AIFC" if compression_type
|
|
465
|
+
return "AIFC" if io_or_path.is_a?(String) && File.extname(io_or_path).casecmp(".aifc").zero?
|
|
466
|
+
|
|
467
|
+
"AIFF"
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
def normalize_write_form_type(value)
|
|
471
|
+
normalized = value.to_s.upcase
|
|
472
|
+
return normalized if %w[AIFF AIFC].include?(normalized)
|
|
473
|
+
|
|
474
|
+
raise InvalidParameterError, "form_type must be :aiff or :aifc"
|
|
475
|
+
end
|
|
476
|
+
|
|
477
|
+
def default_aifc_compression_name(compression_type)
|
|
478
|
+
compression_type == "sowt" ? "little-endian PCM" : "not compressed"
|
|
479
|
+
end
|
|
480
|
+
|
|
481
|
+
def parse_pascal_string(data)
|
|
482
|
+
return nil if data.empty?
|
|
483
|
+
|
|
484
|
+
length = data.getbyte(0).to_i
|
|
485
|
+
return "" if length.zero?
|
|
486
|
+
raise InvalidFormatError, "truncated Pascal string" if length > data.bytesize - 1
|
|
487
|
+
|
|
488
|
+
data.byteslice(1, length).to_s.force_encoding(Encoding::UTF_8).scrub
|
|
254
489
|
end
|
|
255
490
|
|
|
256
|
-
def
|
|
491
|
+
def decode_samples(data, format, byte_order: :big)
|
|
492
|
+
samples = case format.bit_depth
|
|
493
|
+
when 8
|
|
494
|
+
data.unpack("c*")
|
|
495
|
+
when 16
|
|
496
|
+
byte_order == :little ? data.unpack("s<*") : data.unpack("s>*")
|
|
497
|
+
when 24
|
|
498
|
+
byte_order == :little ? decode_pcm24_le(data) : decode_pcm24_be(data)
|
|
499
|
+
when 32
|
|
500
|
+
byte_order == :little ? data.unpack("l<*") : data.unpack("l>*")
|
|
501
|
+
else
|
|
502
|
+
raise UnsupportedFormatError, "unsupported AIFF bit depth: #{format.bit_depth}"
|
|
503
|
+
end
|
|
504
|
+
canonicalize_valid_bits(samples, format)
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
def encode_samples(samples, format, byte_order: :big)
|
|
508
|
+
samples = canonicalize_valid_bits(samples, format)
|
|
257
509
|
case format.bit_depth
|
|
258
510
|
when 8
|
|
259
511
|
samples.pack("c*")
|
|
260
512
|
when 16
|
|
261
|
-
samples.pack("s>*")
|
|
513
|
+
byte_order == :little ? samples.pack("s<*") : samples.pack("s>*")
|
|
262
514
|
when 24
|
|
263
|
-
encode_pcm24_be(samples)
|
|
515
|
+
byte_order == :little ? encode_pcm24_le(samples) : encode_pcm24_be(samples)
|
|
264
516
|
when 32
|
|
265
|
-
samples.pack("l>*")
|
|
517
|
+
byte_order == :little ? samples.pack("l<*") : samples.pack("l>*")
|
|
266
518
|
else
|
|
267
519
|
raise UnsupportedFormatError, "unsupported AIFF bit depth: #{format.bit_depth}"
|
|
268
520
|
end
|
|
269
521
|
end
|
|
270
522
|
|
|
271
523
|
def decode_pcm24_be(data)
|
|
272
|
-
|
|
273
|
-
|
|
524
|
+
samples = Array.new(data.bytesize / 3)
|
|
525
|
+
samples.length.times do |index|
|
|
526
|
+
offset = index * 3
|
|
527
|
+
b0 = data.getbyte(offset)
|
|
528
|
+
b1 = data.getbyte(offset + 1)
|
|
529
|
+
b2 = data.getbyte(offset + 2)
|
|
274
530
|
value = (b0 << 16) | (b1 << 8) | b2
|
|
275
531
|
value -= 0x1000000 if value.anybits?(0x800000)
|
|
276
|
-
value
|
|
532
|
+
samples[index] = value
|
|
533
|
+
end
|
|
534
|
+
samples
|
|
535
|
+
end
|
|
536
|
+
|
|
537
|
+
def decode_pcm24_le(data)
|
|
538
|
+
samples = Array.new(data.bytesize / 3)
|
|
539
|
+
samples.length.times do |index|
|
|
540
|
+
offset = index * 3
|
|
541
|
+
b0 = data.getbyte(offset)
|
|
542
|
+
b1 = data.getbyte(offset + 1)
|
|
543
|
+
b2 = data.getbyte(offset + 2)
|
|
544
|
+
value = b0 | (b1 << 8) | (b2 << 16)
|
|
545
|
+
value -= 0x1000000 if value.anybits?(0x800000)
|
|
546
|
+
samples[index] = value
|
|
277
547
|
end
|
|
548
|
+
samples
|
|
278
549
|
end
|
|
279
550
|
|
|
280
551
|
def encode_pcm24_be(samples)
|
|
281
|
-
bytes = samples.
|
|
552
|
+
bytes = String.new(capacity: samples.length * 3, encoding: Encoding::BINARY)
|
|
553
|
+
samples.each do |sample|
|
|
282
554
|
value = sample.to_i
|
|
283
555
|
value += 0x1000000 if value.negative?
|
|
284
|
-
|
|
556
|
+
bytes << ((value >> 16) & 0xFF) << ((value >> 8) & 0xFF) << (value & 0xFF)
|
|
285
557
|
end
|
|
286
|
-
bytes
|
|
558
|
+
bytes
|
|
287
559
|
end
|
|
288
560
|
|
|
289
|
-
def
|
|
290
|
-
|
|
561
|
+
def encode_pcm24_le(samples)
|
|
562
|
+
bytes = String.new(capacity: samples.length * 3, encoding: Encoding::BINARY)
|
|
563
|
+
samples.each do |sample|
|
|
564
|
+
value = sample.to_i
|
|
565
|
+
value += 0x1000000 if value.negative?
|
|
566
|
+
bytes << (value & 0xFF) << ((value >> 8) & 0xFF) << ((value >> 16) & 0xFF)
|
|
567
|
+
end
|
|
568
|
+
bytes
|
|
569
|
+
end
|
|
570
|
+
|
|
571
|
+
def canonicalize_valid_bits(samples, format)
|
|
572
|
+
shift = format.bit_depth - format.valid_bits
|
|
573
|
+
return samples if shift.zero?
|
|
574
|
+
|
|
575
|
+
samples.map { |sample| (sample.to_i >> shift) << shift }
|
|
576
|
+
end
|
|
577
|
+
|
|
578
|
+
def build_comm_chunk(format, sample_frames, compression_type: nil, compression_name: nil)
|
|
579
|
+
base = [format.channels, sample_frames, format.bit_depth].pack("n N n") + encode_extended80(format.sample_rate.to_f)
|
|
580
|
+
return base unless compression_type
|
|
581
|
+
|
|
582
|
+
base + compression_type + build_pascal_string(compression_name.to_s)
|
|
583
|
+
end
|
|
584
|
+
|
|
585
|
+
def build_pascal_string(value)
|
|
586
|
+
bytes = value.b
|
|
587
|
+
raise InvalidParameterError, "AIFF Pascal strings cannot exceed 255 bytes" if bytes.bytesize > 255
|
|
588
|
+
|
|
589
|
+
data = [bytes.bytesize].pack("C") + bytes
|
|
590
|
+
data << "\x00" if data.bytesize.odd?
|
|
591
|
+
data
|
|
291
592
|
end
|
|
292
593
|
|
|
293
|
-
def build_ssnd_chunk(samples, format)
|
|
294
|
-
[0, 0].pack("N2") + encode_samples(samples, format)
|
|
594
|
+
def build_ssnd_chunk(samples, format, byte_order: :big)
|
|
595
|
+
[0, 0].pack("N2") + encode_samples(samples, format, byte_order: byte_order)
|
|
295
596
|
end
|
|
296
597
|
|
|
297
598
|
def write_chunk(io, chunk_id, chunk_data)
|
|
298
|
-
io
|
|
299
|
-
io
|
|
300
|
-
io
|
|
301
|
-
io
|
|
599
|
+
write_all(io, chunk_id)
|
|
600
|
+
write_all(io, [chunk_data.bytesize].pack("N"))
|
|
601
|
+
write_all(io, chunk_data)
|
|
602
|
+
write_all(io, "\x00") if chunk_data.bytesize.odd?
|
|
302
603
|
end
|
|
303
604
|
|
|
304
605
|
def chunk_size(chunk_data)
|