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,338 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wavify
|
|
4
|
+
module Codecs
|
|
5
|
+
# AIFF codec for PCM audio (AIFC is not currently supported).
|
|
6
|
+
class Aiff < Base
|
|
7
|
+
# Recognized filename extensions.
|
|
8
|
+
EXTENSIONS = %w[.aiff .aif].freeze
|
|
9
|
+
|
|
10
|
+
class << self
|
|
11
|
+
# @param io_or_path [String, IO]
|
|
12
|
+
# @return [Boolean]
|
|
13
|
+
def can_read?(io_or_path)
|
|
14
|
+
if io_or_path.is_a?(String)
|
|
15
|
+
return true if EXTENSIONS.include?(File.extname(io_or_path).downcase)
|
|
16
|
+
return false unless File.file?(io_or_path)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
io, close_io = open_input(io_or_path)
|
|
20
|
+
return false unless io
|
|
21
|
+
|
|
22
|
+
header = io.read(12)
|
|
23
|
+
io.rewind if io.respond_to?(:rewind)
|
|
24
|
+
header&.start_with?("FORM") && header[8, 4] == "AIFF"
|
|
25
|
+
ensure
|
|
26
|
+
io.close if close_io && io
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Reads an AIFF file/IO into a sample buffer.
|
|
30
|
+
#
|
|
31
|
+
# @param io_or_path [String, IO]
|
|
32
|
+
# @param format [Wavify::Core::Format, nil]
|
|
33
|
+
# @return [Wavify::Core::SampleBuffer]
|
|
34
|
+
def read(io_or_path, format: nil)
|
|
35
|
+
io, close_io = open_input(io_or_path)
|
|
36
|
+
ensure_seekable!(io)
|
|
37
|
+
|
|
38
|
+
info = parse_chunks(io)
|
|
39
|
+
source_format = info.fetch(:format)
|
|
40
|
+
samples = read_sound_data(io, info, source_format)
|
|
41
|
+
buffer = Core::SampleBuffer.new(samples, source_format)
|
|
42
|
+
format ? buffer.convert(format) : buffer
|
|
43
|
+
ensure
|
|
44
|
+
io.close if close_io && io
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Writes a sample buffer as AIFF (PCM only).
|
|
48
|
+
#
|
|
49
|
+
# @param io_or_path [String, IO]
|
|
50
|
+
# @param sample_buffer [Wavify::Core::SampleBuffer]
|
|
51
|
+
# @param format [Wavify::Core::Format, nil]
|
|
52
|
+
# @return [String, IO]
|
|
53
|
+
def write(io_or_path, sample_buffer, format: nil)
|
|
54
|
+
raise InvalidParameterError, "sample_buffer must be Core::SampleBuffer" unless sample_buffer.is_a?(Core::SampleBuffer)
|
|
55
|
+
|
|
56
|
+
target_format = format || sample_buffer.format
|
|
57
|
+
raise InvalidParameterError, "format must be Core::Format" unless target_format.is_a?(Core::Format)
|
|
58
|
+
raise UnsupportedFormatError, "AIFF writer supports PCM only" unless target_format.sample_format == :pcm
|
|
59
|
+
|
|
60
|
+
buffer = sample_buffer.format == target_format ? sample_buffer : sample_buffer.convert(target_format)
|
|
61
|
+
|
|
62
|
+
io, close_io = open_output(io_or_path)
|
|
63
|
+
io.rewind if io.respond_to?(:rewind)
|
|
64
|
+
io.truncate(0) if io.respond_to?(:truncate)
|
|
65
|
+
|
|
66
|
+
sample_frames = buffer.sample_frame_count
|
|
67
|
+
comm_chunk = build_comm_chunk(target_format, sample_frames)
|
|
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
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Streams AIFF decoding in frame chunks.
|
|
85
|
+
#
|
|
86
|
+
# @param io_or_path [String, IO]
|
|
87
|
+
# @param chunk_size [Integer]
|
|
88
|
+
# @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?
|
|
91
|
+
raise InvalidParameterError, "chunk_size must be a positive Integer" unless chunk_size.is_a?(Integer) && chunk_size.positive?
|
|
92
|
+
|
|
93
|
+
io, close_io = open_input(io_or_path)
|
|
94
|
+
ensure_seekable!(io)
|
|
95
|
+
|
|
96
|
+
info = parse_chunks(io)
|
|
97
|
+
format = info.fetch(:format)
|
|
98
|
+
bytes_per_frame = format.block_align
|
|
99
|
+
remaining = info.fetch(:sound_data_size)
|
|
100
|
+
io.seek(info.fetch(:sound_data_offset), IO::SEEK_SET)
|
|
101
|
+
|
|
102
|
+
while remaining.positive?
|
|
103
|
+
bytes = [remaining, bytes_per_frame * chunk_size].min
|
|
104
|
+
chunk_data = read_exact(io, bytes, "truncated SSND data")
|
|
105
|
+
yield Core::SampleBuffer.new(decode_samples(chunk_data, format), format)
|
|
106
|
+
remaining -= bytes
|
|
107
|
+
end
|
|
108
|
+
ensure
|
|
109
|
+
io.close if close_io && io
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Streams AIFF encoding through a yielded chunk writer.
|
|
113
|
+
#
|
|
114
|
+
# @param io_or_path [String, IO]
|
|
115
|
+
# @param format [Wavify::Core::Format]
|
|
116
|
+
# @return [Enumerator, String, IO]
|
|
117
|
+
def stream_write(io_or_path, format:)
|
|
118
|
+
return enum_for(__method__, io_or_path, format: format) unless block_given?
|
|
119
|
+
raise InvalidParameterError, "format must be Core::Format" unless format.is_a?(Core::Format)
|
|
120
|
+
raise UnsupportedFormatError, "AIFF stream writer supports PCM only" unless format.sample_format == :pcm
|
|
121
|
+
|
|
122
|
+
chunks = []
|
|
123
|
+
sample_frames = 0
|
|
124
|
+
writer = lambda do |buffer|
|
|
125
|
+
raise InvalidParameterError, "stream chunk must be Core::SampleBuffer" unless buffer.is_a?(Core::SampleBuffer)
|
|
126
|
+
|
|
127
|
+
converted = buffer.format == format ? buffer : buffer.convert(format)
|
|
128
|
+
chunks << encode_samples(converted.samples, format)
|
|
129
|
+
sample_frames += converted.sample_frame_count
|
|
130
|
+
end
|
|
131
|
+
yield writer
|
|
132
|
+
|
|
133
|
+
data = chunks.join
|
|
134
|
+
temp_buffer = Core::SampleBuffer.new(decode_samples(data, format), format)
|
|
135
|
+
write(io_or_path, temp_buffer, format: format)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Reads AIFF metadata without decoding the full audio payload.
|
|
139
|
+
#
|
|
140
|
+
# @param io_or_path [String, IO]
|
|
141
|
+
# @return [Hash]
|
|
142
|
+
def metadata(io_or_path)
|
|
143
|
+
io, close_io = open_input(io_or_path)
|
|
144
|
+
ensure_seekable!(io)
|
|
145
|
+
|
|
146
|
+
info = parse_chunks(io)
|
|
147
|
+
format = info.fetch(:format)
|
|
148
|
+
sample_frame_count = info.fetch(:sample_frame_count)
|
|
149
|
+
|
|
150
|
+
{
|
|
151
|
+
format: format,
|
|
152
|
+
sample_frame_count: sample_frame_count,
|
|
153
|
+
duration: Core::Duration.from_samples(sample_frame_count, format.sample_rate)
|
|
154
|
+
}
|
|
155
|
+
ensure
|
|
156
|
+
io.close if close_io && io
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
private
|
|
160
|
+
|
|
161
|
+
def parse_chunks(io)
|
|
162
|
+
io.rewind
|
|
163
|
+
header = read_exact(io, 12, "missing FORM header")
|
|
164
|
+
raise InvalidFormatError, "invalid AIFF header" unless header.start_with?("FORM")
|
|
165
|
+
|
|
166
|
+
form_type = header[8, 4]
|
|
167
|
+
raise UnsupportedFormatError, "AIFC is not supported yet" if form_type == "AIFC"
|
|
168
|
+
raise InvalidFormatError, "invalid AIFF form type" unless form_type == "AIFF"
|
|
169
|
+
|
|
170
|
+
info = {
|
|
171
|
+
format: nil,
|
|
172
|
+
sample_frame_count: nil,
|
|
173
|
+
sound_data_offset: nil,
|
|
174
|
+
sound_data_size: nil
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
until io.eof?
|
|
178
|
+
chunk_header = io.read(8)
|
|
179
|
+
break if chunk_header.nil?
|
|
180
|
+
raise InvalidFormatError, "truncated AIFF chunk header" unless chunk_header.bytesize == 8
|
|
181
|
+
|
|
182
|
+
chunk_id = chunk_header[0, 4]
|
|
183
|
+
chunk_size = chunk_header[4, 4].unpack1("N")
|
|
184
|
+
|
|
185
|
+
case chunk_id
|
|
186
|
+
when "COMM"
|
|
187
|
+
chunk_data = read_exact(io, chunk_size, "truncated COMM chunk")
|
|
188
|
+
parse_comm_chunk(chunk_data, info)
|
|
189
|
+
when "SSND"
|
|
190
|
+
offset, = read_exact(io, 8, "truncated SSND header").unpack("N2")
|
|
191
|
+
skip_bytes(io, offset)
|
|
192
|
+
sound_data_size = chunk_size - 8 - offset
|
|
193
|
+
raise InvalidFormatError, "invalid SSND chunk size" if sound_data_size.negative?
|
|
194
|
+
|
|
195
|
+
info[:sound_data_offset] = io.pos
|
|
196
|
+
info[:sound_data_size] = sound_data_size
|
|
197
|
+
skip_bytes(io, sound_data_size)
|
|
198
|
+
else
|
|
199
|
+
skip_bytes(io, chunk_size)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
io.read(1) if chunk_size.odd?
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
raise InvalidFormatError, "COMM chunk missing" unless info[:format]
|
|
206
|
+
raise InvalidFormatError, "SSND chunk missing" unless info[:sound_data_offset] && info[:sound_data_size]
|
|
207
|
+
|
|
208
|
+
info[:sample_frame_count] = info[:sound_data_size] / info[:format].block_align if info[:sample_frame_count].nil?
|
|
209
|
+
|
|
210
|
+
if (info[:sound_data_size] % info[:format].block_align) != 0
|
|
211
|
+
raise InvalidFormatError, "SSND data size is not aligned to frame size"
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
info
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def parse_comm_chunk(chunk, info)
|
|
218
|
+
raise InvalidFormatError, "COMM chunk too small" if chunk.bytesize < 18
|
|
219
|
+
|
|
220
|
+
channels, sample_frames, bit_depth = chunk.unpack("n N n")
|
|
221
|
+
sample_rate = decode_extended80(chunk[8, 10])
|
|
222
|
+
rounded_rate = sample_rate.round
|
|
223
|
+
|
|
224
|
+
format = Core::Format.new(
|
|
225
|
+
channels: channels,
|
|
226
|
+
sample_rate: rounded_rate,
|
|
227
|
+
bit_depth: bit_depth,
|
|
228
|
+
sample_format: :pcm
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
info[:format] = format
|
|
232
|
+
info[:sample_frame_count] = sample_frames
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def read_sound_data(io, info, format)
|
|
236
|
+
io.seek(info.fetch(:sound_data_offset), IO::SEEK_SET)
|
|
237
|
+
data = read_exact(io, info.fetch(:sound_data_size), "truncated SSND data")
|
|
238
|
+
decode_samples(data, format)
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def decode_samples(data, format)
|
|
242
|
+
case format.bit_depth
|
|
243
|
+
when 8
|
|
244
|
+
data.unpack("c*")
|
|
245
|
+
when 16
|
|
246
|
+
data.unpack("s>*")
|
|
247
|
+
when 24
|
|
248
|
+
decode_pcm24_be(data)
|
|
249
|
+
when 32
|
|
250
|
+
data.unpack("l>*")
|
|
251
|
+
else
|
|
252
|
+
raise UnsupportedFormatError, "unsupported AIFF bit depth: #{format.bit_depth}"
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def encode_samples(samples, format)
|
|
257
|
+
case format.bit_depth
|
|
258
|
+
when 8
|
|
259
|
+
samples.pack("c*")
|
|
260
|
+
when 16
|
|
261
|
+
samples.pack("s>*")
|
|
262
|
+
when 24
|
|
263
|
+
encode_pcm24_be(samples)
|
|
264
|
+
when 32
|
|
265
|
+
samples.pack("l>*")
|
|
266
|
+
else
|
|
267
|
+
raise UnsupportedFormatError, "unsupported AIFF bit depth: #{format.bit_depth}"
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def decode_pcm24_be(data)
|
|
272
|
+
bytes = data.unpack("C*")
|
|
273
|
+
bytes.each_slice(3).map do |b0, b1, b2|
|
|
274
|
+
value = (b0 << 16) | (b1 << 8) | b2
|
|
275
|
+
value -= 0x1000000 if value.anybits?(0x800000)
|
|
276
|
+
value
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def encode_pcm24_be(samples)
|
|
281
|
+
bytes = samples.flat_map do |sample|
|
|
282
|
+
value = sample.to_i
|
|
283
|
+
value += 0x1000000 if value.negative?
|
|
284
|
+
[(value >> 16) & 0xFF, (value >> 8) & 0xFF, value & 0xFF]
|
|
285
|
+
end
|
|
286
|
+
bytes.pack("C*")
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def build_comm_chunk(format, sample_frames)
|
|
290
|
+
[format.channels, sample_frames, format.bit_depth].pack("n N n") + encode_extended80(format.sample_rate.to_f)
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def build_ssnd_chunk(samples, format)
|
|
294
|
+
[0, 0].pack("N2") + encode_samples(samples, format)
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def write_chunk(io, chunk_id, chunk_data)
|
|
298
|
+
io.write(chunk_id)
|
|
299
|
+
io.write([chunk_data.bytesize].pack("N"))
|
|
300
|
+
io.write(chunk_data)
|
|
301
|
+
io.write("\x00") if chunk_data.bytesize.odd?
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def chunk_size(chunk_data)
|
|
305
|
+
8 + chunk_data.bytesize + (chunk_data.bytesize.odd? ? 1 : 0)
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
def decode_extended80(bytes)
|
|
309
|
+
raise InvalidFormatError, "invalid 80-bit float" unless bytes && bytes.bytesize == 10
|
|
310
|
+
|
|
311
|
+
exponent_word = bytes[0, 2].unpack1("n")
|
|
312
|
+
return 0.0 if exponent_word.zero?
|
|
313
|
+
|
|
314
|
+
sign = exponent_word.nobits?(0x8000) ? 1.0 : -1.0
|
|
315
|
+
exponent = (exponent_word & 0x7FFF) - 16_383
|
|
316
|
+
mantissa = bytes[2, 8].unpack1("Q>")
|
|
317
|
+
sign * mantissa * (2.0**(exponent - 63))
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
def encode_extended80(value)
|
|
321
|
+
raise InvalidParameterError, "AIFF sample_rate must be positive" unless value.is_a?(Numeric) && value.positive?
|
|
322
|
+
|
|
323
|
+
fraction, exponent = Math.frexp(value.to_f) # value = fraction * 2**exponent, fraction in [0.5,1)
|
|
324
|
+
exponent_word = (exponent - 1) + 16_383
|
|
325
|
+
mantissa = ((fraction * 2.0) * (2**63)).round
|
|
326
|
+
|
|
327
|
+
if mantissa >= (2**64)
|
|
328
|
+
mantissa >>= 1
|
|
329
|
+
exponent_word += 1
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
[exponent_word, mantissa].pack("n Q>")
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
end
|
|
336
|
+
end
|
|
337
|
+
end
|
|
338
|
+
end
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wavify
|
|
4
|
+
# Codec implementations for supported container/audio formats.
|
|
5
|
+
module Codecs
|
|
6
|
+
# Abstract codec interface used by {Registry} and {Wavify::Audio}.
|
|
7
|
+
#
|
|
8
|
+
# Concrete codecs implement the class methods below.
|
|
9
|
+
class Base
|
|
10
|
+
class << self
|
|
11
|
+
# Returns whether this codec can read the given path/IO.
|
|
12
|
+
#
|
|
13
|
+
# @param _io_or_path [String, IO]
|
|
14
|
+
# @return [Boolean]
|
|
15
|
+
def can_read?(_io_or_path)
|
|
16
|
+
raise NotImplementedError
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Reads a full audio buffer from a path/IO.
|
|
20
|
+
#
|
|
21
|
+
# @param _io_or_path [String, IO]
|
|
22
|
+
# @param format [Wavify::Core::Format, nil]
|
|
23
|
+
# @return [Wavify::Core::SampleBuffer]
|
|
24
|
+
def read(_io_or_path, format: nil)
|
|
25
|
+
raise NotImplementedError
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Writes a full audio buffer to a path/IO.
|
|
29
|
+
#
|
|
30
|
+
# @param _io_or_path [String, IO]
|
|
31
|
+
# @param _sample_buffer [Wavify::Core::SampleBuffer]
|
|
32
|
+
# @param format [Wavify::Core::Format]
|
|
33
|
+
# @return [String, IO]
|
|
34
|
+
def write(_io_or_path, _sample_buffer, format:)
|
|
35
|
+
raise NotImplementedError
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Streams decoded audio as chunked sample buffers.
|
|
39
|
+
#
|
|
40
|
+
# @param _io_or_path [String, IO]
|
|
41
|
+
# @param chunk_size [Integer] chunk size in frames
|
|
42
|
+
# @return [Enumerator]
|
|
43
|
+
def stream_read(_io_or_path, chunk_size: 4096)
|
|
44
|
+
raise NotImplementedError
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Streams encoded chunks to a path/IO through a yielded writer.
|
|
48
|
+
#
|
|
49
|
+
# @param _io_or_path [String, IO]
|
|
50
|
+
# @param format [Wavify::Core::Format]
|
|
51
|
+
# @return [Enumerator, String, IO]
|
|
52
|
+
def stream_write(_io_or_path, format:)
|
|
53
|
+
raise NotImplementedError
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Reads metadata (format and duration-related info) without full decode.
|
|
57
|
+
#
|
|
58
|
+
# @param _io_or_path [String, IO]
|
|
59
|
+
# @return [Hash]
|
|
60
|
+
def metadata(_io_or_path)
|
|
61
|
+
raise NotImplementedError
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def open_input(io_or_path)
|
|
67
|
+
return [io_or_path, false] if io_or_path.respond_to?(:read)
|
|
68
|
+
raise InvalidParameterError, "input path must be String or IO: #{io_or_path.inspect}" unless io_or_path.is_a?(String)
|
|
69
|
+
|
|
70
|
+
[File.open(io_or_path, "rb"), true]
|
|
71
|
+
rescue Errno::ENOENT
|
|
72
|
+
raise InvalidFormatError, "input file not found: #{io_or_path}"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def open_output(io_or_path)
|
|
76
|
+
return [io_or_path, false] if io_or_path.respond_to?(:write)
|
|
77
|
+
raise InvalidParameterError, "output path must be String or IO: #{io_or_path.inspect}" unless io_or_path.is_a?(String)
|
|
78
|
+
|
|
79
|
+
[File.open(io_or_path, "wb"), true]
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def read_exact(io, size, message)
|
|
83
|
+
data = io.read(size)
|
|
84
|
+
raise InvalidFormatError, message if data.nil? || data.bytesize != size
|
|
85
|
+
|
|
86
|
+
data
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def skip_bytes(io, count)
|
|
90
|
+
return if count.zero?
|
|
91
|
+
|
|
92
|
+
if io.respond_to?(:seek)
|
|
93
|
+
io.seek(count, IO::SEEK_CUR)
|
|
94
|
+
else
|
|
95
|
+
discarded = io.read(count)
|
|
96
|
+
raise InvalidFormatError, "truncated chunk body" unless discarded && discarded.bytesize == count
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def ensure_seekable!(io)
|
|
101
|
+
return if io.respond_to?(:seek) && io.respond_to?(:pos)
|
|
102
|
+
|
|
103
|
+
raise StreamError, "codec requires seekable IO"
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|