Skip to content

Commit b71b6f6

Browse files
authored
Improve WAV parser (#243)
* Skip parsing Fact chunks for WAV parser * Improve inline documentation * Small improvements * Be more lenient when returning available metadata * Reverse test and mention regression * Put back the fact logic * Update test * Bump version * Refactor extracting fmt data
1 parent 8d6e457 commit b71b6f6

5 files changed

Lines changed: 35 additions & 42 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
1+
## 2.9.0
2+
* Improve WAV parser by performing a best-effort when extracting metadata from files that do not strictly follow the format spec.
3+
14
## 2.8.0
2-
* Add support for Ruby 3.2 and 3.3
5+
* Add support for Ruby 3.2 and 3.3.
36

47
## 2.7.2
58
* Improved stability for mp4 parser when dealing with corrupted FTYP boxes.

lib/format_parser/version.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
module FormatParser
2-
VERSION = '2.8.0'
2+
VERSION = '2.9.0'
33
end

lib/parsers/wav_parser.rb

Lines changed: 21 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -20,75 +20,60 @@ def call(io)
2020
# with the exception that the Format chunk must precede the Data chunk.
2121
# The specification does not require the Format chunk to be the first chunk
2222
# after the RIFF header.
23-
# http://soundfile.sapp.org/doc/WaveFormat/
24-
# For WAVE files containing PCM audio format we parse the 'fmt ' and
25-
# 'data' chunks while for non PCM audio formats the 'fmt ' and 'fact'
26-
# chunks. In the latter case the order fo appearence of the chunks is
27-
# arbitrary.
28-
fmt_processed = false
29-
fact_processed = false
23+
# https://www.mmsp.ece.mcgill.ca/Documents/AudioFormats/WAVE/WAVE.html
3024
fmt_data = {}
31-
total_sample_frames = 0
25+
data_size = 0
26+
total_sample_frames = nil
3227
loop do
3328
chunk_type, chunk_size = safe_read(io, 8).unpack('a4l')
3429
case chunk_type
3530
when 'fmt ' # watch out: the chunk ID of the format chunk ends with a space
3631
fmt_data = unpack_fmt_chunk(io, chunk_size)
37-
return process_non_pcm(fmt_data, total_sample_frames) if fmt_data[:audio_format] != 1 and fact_processed
38-
fmt_processed = true
3932
when 'data'
40-
return unless fmt_processed # the 'data' chunk cannot preceed the 'fmt ' chunk
41-
return process_pcm(fmt_data, chunk_size) if fmt_data[:audio_format] == 1
42-
safe_skip(io, chunk_size)
33+
data_size = chunk_size
4334
when 'fact'
4435
total_sample_frames = safe_read(io, 4).unpack('l').first
4536
safe_skip(io, chunk_size - 4)
46-
return process_non_pcm(fmt_data, total_sample_frames) if fmt_processed and fmt_data[:audio_format] != 1
47-
fact_processed = true
4837
else
4938
# Skip this chunk until a known chunk is encountered
5039
safe_skip(io, chunk_size)
5140
end
41+
rescue FormatParser::IOUtils::InvalidRead
42+
# We've reached EOF, so it's time to make the most out of the metadata we
43+
# managed to parse
44+
break
5245
end
46+
47+
file_info(fmt_data, data_size, total_sample_frames)
5348
end
5449

5550
def unpack_fmt_chunk(io, chunk_size)
5651
# The size of the fmt chunk is at least 16 bytes. If the format tag's value is not
5752
# 1 compression might be in use for storing the data
5853
# and the fmt chunk might contain extra fields appended to it.
59-
# The last 4 fields of the fmt tag are always:
54+
# The first 6 fields of the fmt tag are always:
55+
# * unsigned short audio format
6056
# * unsigned short channels
6157
# * unsigned long samples per sec
6258
# * unsigned long average bytes per sec
6359
# * unsigned short block align
6460
# * unsigned short bits per sample
6561

66-
fmt_info = safe_read(io, 16).unpack('S_2I2S_2')
62+
_, channels, sample_rate, byte_rate, _, bits_per_sample = safe_read(io, 16).unpack('S_2I2S_2')
6763
safe_skip(io, chunk_size - 16) # skip the extra fields
6864

6965
{
70-
audio_format: fmt_info[0],
71-
channels: fmt_info[1],
72-
sample_rate: fmt_info[2],
73-
byte_rate: fmt_info[3],
74-
block_align: fmt_info[4],
75-
bits_per_sample: fmt_info[5],
66+
channels: channels,
67+
sample_rate: sample_rate,
68+
byte_rate: byte_rate,
69+
bits_per_sample: bits_per_sample,
7670
}
7771
end
7872

79-
def process_pcm(fmt_data, data_size)
80-
return unless fmt_data[:channels] > 0 and fmt_data[:bits_per_sample] > 0
81-
sample_frames = data_size / (fmt_data[:channels] * fmt_data[:bits_per_sample] / 8)
82-
file_info(fmt_data, sample_frames)
83-
end
84-
85-
def process_non_pcm(fmt_data, total_sample_frames)
86-
file_info(fmt_data, total_sample_frames)
87-
end
88-
89-
def file_info(fmt_data, sample_frames)
90-
return unless fmt_data[:sample_rate] > 0
91-
duration_in_seconds = sample_frames / fmt_data[:sample_rate].to_f
73+
def file_info(fmt_data, data_size, sample_frames)
74+
# NOTE: Each sample includes information for each channel
75+
sample_frames ||= data_size / (fmt_data[:channels] * fmt_data[:bits_per_sample] / 8) if fmt_data[:channels] > 0 && fmt_data[:bits_per_sample] > 0
76+
duration_in_seconds = sample_frames / fmt_data[:sample_rate].to_f if fmt_data[:sample_rate] > 0
9277
FormatParser::Audio.new(
9378
format: :wav,
9479
num_audio_channels: fmt_data[:channels],
File renamed without changes.

spec/parsers/wav_parser_spec.rb

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,14 @@
4646
expect(parse_result.media_duration_seconds).to be_within(0.01).of(13.81)
4747
end
4848

49-
it "cannot parse file with audio format different from 1 and no 'fact' chunk" do
50-
expect {
51-
subject.call(File.open(__dir__ + '/../fixtures/WAV/invalid_d_6_Channel_ID.wav', 'rb'))
52-
}.to raise_error(FormatParser::IOUtils::InvalidRead)
49+
it 'returns correct info about non pcm files with no fact chunk' do
50+
parse_result = subject.call(File.open(__dir__ + '/../fixtures/WAV/d_6_Channel_ID.wav', 'rb'))
51+
52+
expect(parse_result.nature).to eq(:audio)
53+
expect(parse_result.format).to eq(:wav)
54+
expect(parse_result.num_audio_channels).to eq(6)
55+
expect(parse_result.audio_sample_rate_hz).to eq(44100)
56+
expect(parse_result.media_duration_frames).to eq(257411)
57+
expect(parse_result.media_duration_seconds).to be_within(0.01).of(5.83)
5358
end
5459
end

0 commit comments

Comments
 (0)