format_parser 0.1.2 → 0.1.3

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 205c4099f44c0080b53e210ea18cebd4484476ba
4
- data.tar.gz: d8145f8f77be44dab386585679a8c7f3ce48869d
3
+ metadata.gz: b0ef6923a01b8fbe52f4491979a3be1224ea7018
4
+ data.tar.gz: a19859f36a81154f73d1071857834cd0e7e1ca74
5
5
  SHA512:
6
- metadata.gz: ad845e5216be2e71e81205cade268d72b1be1ee2e5721a7877a5222a03a43c0f10d18abad8fc1c553165ade0665c7bba9ef5d5a79aa954e8c587ac0d027d6d89
7
- data.tar.gz: 172c0d12205c778dadefe57010c5cfe6635d5398eea385048b8e41b3558a326cba4c332e37b9259abef4589d78f31c39bffb7b9c6adb416417b771cdb2b49272
6
+ metadata.gz: ca6bd5d8324a4dcb41d6f3137a1ab621a016acf8e9fafae5983bb8c325f883ecc72b4046d774e423e5e4ca7f35d048dae20a1e6ca2287d842c111c2714e2d606
7
+ data.tar.gz: 99d6517341e8b48635c8540e7f92cb48e772ed02047eb6cd56412b21de2411c06d64d43878ffa1a3ce43bf8e82b7e886eef4f00b221bfdfe42bafc19435f3f35
data/README.md CHANGED
@@ -7,6 +7,12 @@ minimum amount of data possible.
7
7
  `format_parser` is inspired by [imagesize,](https://rubygems.org/gem/imagesize) [fastimage](https://github.com/sdsykes/fastimage)
8
8
  and [dimensions,](https://github.com/sstephenson/dimensions) borrowing from them where appropriate.
9
9
 
10
+ ## Currently supported filetypes:
11
+
12
+ `TIFF, PSD, PNG, MP3, JPEG, GIF, DPX, AIFF`
13
+
14
+ ...with more on the way!
15
+
10
16
  ## Basic usage
11
17
 
12
18
  Pass an IO object that responds to `read` and `seek` to `FormatParser`.
@@ -32,6 +32,7 @@ Gem::Specification.new do |spec|
32
32
  spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
33
33
  spec.require_paths = ["lib"]
34
34
 
35
+ spec.add_dependency 'ks', '~> 0.0.1'
35
36
  spec.add_dependency 'exifr', '~> 1.0'
36
37
  spec.add_dependency 'faraday', '~> 0.13'
37
38
 
data/lib/care.rb CHANGED
@@ -39,6 +39,10 @@ class Care
39
39
  clear
40
40
  @io.close if @io.respond_to?(:close)
41
41
  end
42
+
43
+ def size
44
+ @io.size
45
+ end
42
46
  end
43
47
 
44
48
  # Stores cached pages of data from the given IO as strings.
@@ -58,7 +62,10 @@ class Care
58
62
  # or fetch pages where necessary
59
63
  def byteslice(io, at, n_bytes)
60
64
  if n_bytes < 1
61
- raise ArgumentError, "The number of bytes to fetch must be a positive Integer"
65
+ raise ArgumentError, "The number of bytes to fetch must be a positive Integer"
66
+ end
67
+ if at < 0
68
+ raise ArgumentError, "Negative offsets are not supported (got #{at})"
62
69
  end
63
70
 
64
71
  first_page = at / @page_size
@@ -124,7 +131,7 @@ class Care
124
131
  # to read following this one, so we can also optimize
125
132
  @lowest_known_empty_page = page_i + 1
126
133
  end
127
-
134
+
128
135
  read_result
129
136
  end
130
137
  end
@@ -58,6 +58,10 @@ module FormatParser
58
58
  # as an Integer
59
59
  attr_accessor :media_duration_frames
60
60
 
61
+ # If a parser wants to provide any extra information to the caller
62
+ # it can be placed here
63
+ attr_accessor :intrinsics
64
+
61
65
  # Only permits assignments via defined accessors
62
66
  def initialize(**attributes)
63
67
  attributes.map { |(k, v)| public_send("#{k}=", v) }
@@ -1,3 +1,3 @@
1
1
  module FormatParser
2
- VERSION = '0.1.2'
2
+ VERSION = '0.1.3'
3
3
  end
@@ -0,0 +1,246 @@
1
+ require 'ks'
2
+
3
+ class FormatParser::MP3Parser
4
+ require_relative 'mp3_parser/id3_v1'
5
+ require_relative 'mp3_parser/id3_v2'
6
+
7
+ class MPEGFrame < Ks.strict(:offset_in_file, :mpeg_id, :channels, :sample_rate, :frame_length, :frame_bitrate)
8
+ end
9
+
10
+ class VBRHeader < Ks.strict(:frames, :byte_count, :toc_entries, :vbr_scale)
11
+ end
12
+
13
+ class MP3Info < Ks.strict(:duration_seconds, :num_channels, :sampling_rate)
14
+ end
15
+
16
+ class InvalidDeepFetch < KeyError
17
+ end
18
+
19
+ # We limit the number of MPEG frames we scan
20
+ # to obtain our duration estimation
21
+ MAX_FRAMES_TO_SCAN = 128
22
+
23
+ # Default frame size for mp3
24
+ SAMPLES_PER_FRAME = 1152
25
+
26
+ def information_from_io(io)
27
+ # Read the last 128 bytes which might contain ID3v1
28
+ id3_v1 = ID3V1.attempt_id3_v1_extraction(io)
29
+ # Read the header bytes that might contain ID3v1
30
+ id3_v2 = ID3V2.attempt_id3_v2_extraction(io)
31
+
32
+ # Compute how many bytes are occupied by the actual MPEG frames
33
+ ignore_bytes_at_tail = id3_v1 ? 128 : 0
34
+ ignore_bytes_at_head = id3_v2 ? io.pos : 0
35
+ bytes_used_by_frames = io.size - ignore_bytes_at_tail - ignore_bytes_at_tail
36
+
37
+ io.seek(ignore_bytes_at_head)
38
+
39
+ maybe_xing_header, initial_frames = parse_mpeg_frames(io)
40
+
41
+ return nil if initial_frames.empty?
42
+
43
+ first_frame = initial_frames.first
44
+
45
+ file_info = FormatParser::FileInformation.new(
46
+ file_nature: :audio,
47
+ file_type: :mp3,
48
+ num_audio_channels: first_frame.channels,
49
+ audio_sample_rate_hz: first_frame.sample_rate,
50
+ # media_duration_frames is omitted because the frames
51
+ # in MPEG are not the same thing as in a movie file - they
52
+ # do not tell anything of substance
53
+ intrinsics: {
54
+ id3_v1: id3_v1 ? id3_v1.to_h : nil,
55
+ id3_v2: id3_v2 ? id3_v2.map(&:to_h) : nil,
56
+ xing_header: maybe_xing_header.to_h,
57
+ initial_frames: initial_frames.map(&:to_h)
58
+ }
59
+ )
60
+
61
+ if maybe_xing_header
62
+ duration = maybe_xing_header.frames * SAMPLES_PER_FRAME / first_frame.sample_rate.to_f
63
+ bit_rate = maybe_xing_header.byte_count * 8 / duration / 1000
64
+ file_info.media_duration_seconds = duration
65
+ return file_info
66
+ end
67
+
68
+ # Estimate duration using the frames we did parse - to have an exact one
69
+ # we would need to have all the frames and thus read most of the file
70
+ avg_bitrate = float_average_over(initial_frames, :frame_bitrate)
71
+ avg_frame_size = float_average_over(initial_frames, :frame_length)
72
+ avg_sample_rate = float_average_over(initial_frames, :sample_rate)
73
+
74
+ est_frame_count = bytes_used_by_frames / avg_frame_size
75
+ est_samples = est_frame_count * SAMPLES_PER_FRAME
76
+ est_duration_seconds = est_samples / avg_sample_rate
77
+
78
+ file_info.media_duration_seconds = est_duration_seconds
79
+ return file_info
80
+ end
81
+
82
+ private
83
+
84
+ # The implementation of the MPEG frames parsing is mostly based on tinytag,
85
+ # a sweet little Python library for parsing audio metadata - do check it out
86
+ # if you have a minute. https://pypi.python.org/pypi/tinytag
87
+ def parse_mpeg_frames(io)
88
+ mpeg_frames = []
89
+
90
+ MAX_FRAMES_TO_SCAN.times do |frame_i|
91
+ # Read through until we can latch onto the 11 sync bits. Read in 4-byte
92
+ # increments to save on read() calls
93
+ data = io.read(4)
94
+
95
+ # If we are at EOF - stop iterating
96
+ break unless data && data.bytesize == 4
97
+
98
+ # Look for the sync pattern. It can be either the last byte being 0xFF,
99
+ # or any of the 2 bytes in sequence being 0xFF and > 0xF0.
100
+ four_bytes = data.unpack('C4')
101
+ seek_jmp = sync_bytes_offset_in_4_byte_seq(four_bytes)
102
+ if seek_jmp > 0
103
+ io.seek(io.pos + seek_jmp)
104
+ next
105
+ end
106
+
107
+ # Once we are past that stage we have latched onto a sync frame header
108
+ sync, conf, bitrate_freq, rest = four_bytes
109
+ frame_detail = parse_mpeg_frame_header(io.pos - 4, sync, conf, bitrate_freq, rest)
110
+ mpeg_frames << frame_detail
111
+
112
+ # There might be a xing header in the first frame that contains
113
+ # all the info we need, otherwise parse multiple frames to find the
114
+ # accurate average bitrate
115
+ if frame_i == 0
116
+ frame_data_str = io.read(frame_detail.frame_length)
117
+ io.seek(io.pos - frame_detail.frame_length)
118
+ xing_header = attempt_xing_header(frame_data_str)
119
+ if xing_header_usable_for_duration?(xing_header)
120
+ return [xing_header, mpeg_frames]
121
+ end
122
+ end
123
+ if frame_detail.frame_length > 1 # jump over current frame body
124
+ io.seek(io.pos + frame_detail.frame_length - 4)
125
+ end
126
+ end
127
+ [nil, mpeg_frames]
128
+ rescue InvalidDeepFetch # A frame was invalid - bail out since it's unlikely we can recover
129
+ [nil, mpeg_frames]
130
+ end
131
+
132
+ def parse_mpeg_frame_header(offset_in_file, sync, conf, bitrate_freq, rest)
133
+ # see this page for the magic values used in mp3:
134
+ # http:/www.mpgedit.org/mpgedit/mpeg_format/mpeghdr.htm
135
+ samplerates = [
136
+ [11025, 12000, 8000], # MPEG 2.5
137
+ [], # reserved
138
+ [22050, 24000, 16000], # MPEG 2
139
+ [44100, 48000, 32000], # MPEG 1
140
+ ]
141
+ v1l1 = [0, 32, 64, 96, 128, 160, 192, 224, 256, 288, 320, 352, 384, 416, 448, 0]
142
+ v1l2 = [0, 32, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 384, 0]
143
+ v1l3 = [0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 0]
144
+ v2l1 = [0, 32, 48, 56, 64, 80, 96, 112, 128, 144, 160, 176, 192, 224, 256, 0]
145
+ v2l2 = [0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 0]
146
+ v2l3 = v2l2
147
+ bitrate_by_version_by_layer = [
148
+ [nil, v2l3, v2l2, v2l1], # MPEG Version 2.5 # note that the layers go
149
+ nil, # reserved # from 3 to 1 by design.
150
+ [nil, v2l3, v2l2, v2l1], # MPEG Version 2 # the first layer id is
151
+ [nil, v1l3, v1l2, v1l1], # MPEG Version 1 # reserved
152
+ ]
153
+ samples_per_frame = 1152 # the default frame size for mp3
154
+ channels_per_channel_mode = [
155
+ 2, # 00 Stereo
156
+ 2, # 01 Joint stereo (Stereo)
157
+ 2, # 10 Dual channel (2 mono channels)
158
+ 1, # 11 Single channel (Mono)
159
+ ]
160
+
161
+ br_id = (bitrate_freq >> 4) & 0x0F # biterate id
162
+ sr_id = (bitrate_freq >> 2) & 0x03 # sample rate id
163
+ padding = bitrate_freq & 0x02 > 0 ? 1 : 0
164
+ mpeg_id = (conf >> 3) & 0x03
165
+ layer_id = (conf >> 1) & 0x03
166
+ channel_mode = (rest >> 6) & 0x03
167
+ channels = channels_per_channel_mode.fetch(channel_mode)
168
+ sample_rate = deep_fetch(samplerates, mpeg_id, sr_id)
169
+ frame_bitrate = deep_fetch(bitrate_by_version_by_layer, mpeg_id, layer_id, br_id)
170
+ frame_length = (144000 * frame_bitrate) / sample_rate + padding
171
+ MPEGFrame.new(
172
+ offset_in_file: offset_in_file,
173
+ mpeg_id: mpeg_id,
174
+ channels: channels,
175
+ sample_rate: sample_rate,
176
+ frame_length: frame_length,
177
+ frame_bitrate: frame_bitrate,
178
+ )
179
+ end
180
+
181
+ # Scan 4 byte values, and check whether there is
182
+ # a pattern of the 11 set bits anywhere within it
183
+ # or whether there is the 0xFF byte at the end
184
+ def sync_bytes_offset_in_4_byte_seq(four_bytes)
185
+ four_bytes[0...3].each_with_index do |byte, i|
186
+ next_byte = four_bytes[i+1]
187
+ if byte == 0xFF && next_byte > 0xE0
188
+ return i
189
+ end
190
+ end
191
+ four_bytes[-1] == 0xFF ? 3 : 4
192
+ end
193
+
194
+ def attempt_xing_header(frame_body)
195
+ unless xing_offset = frame_body.index("Xing")
196
+ return nil # No Xing in this frame
197
+ end
198
+
199
+ io = StringIO.new(frame_body)
200
+ io.seek(xing_offset + 4) # Include the length of "Xing" itself
201
+
202
+ # https://www.codeproject.com/Articles/8295/MPEG-Audio-Frame-Header#XINGHeader
203
+ header_flags, _ = io.read(4).unpack('s>s>')
204
+ frames = byte_count = toc = vbr_scale = nil
205
+
206
+ if header_flags & 1 # FRAMES FLAG
207
+ frames = io.read(4).unpack('N1').first
208
+ end
209
+
210
+ if header_flags & 2 # BYTES FLAG
211
+ byte_count = io.read(4).unpack('N1').first
212
+ end
213
+
214
+ if header_flags & 4 # TOC FLAG
215
+ toc = io.read(100).unpack('C100')
216
+ end
217
+
218
+ if header_flags & 8 # VBR SCALE FLAG
219
+ vbr_scale = io.read(4).unpack('N1').first
220
+ end
221
+
222
+ VBRHeader.new(frames: frames, byte_count: byte_count, toc_entries: toc, vbr_scale: vbr_scale)
223
+ end
224
+
225
+ def average_bytes_and_bitrate(mpeg_frames)
226
+ avg_bytes_per_frame = initial_frames.map(&:frame_length).inject(&:+) / initial_frames.length.to_f
227
+ avg_bitrate_per_frame = initial_frames.map(&:frame_bitrate).inject(&:+) / initial_frames.length.to_f
228
+ [avg_bytes_per_frame, avg_bitrate_per_frame]
229
+ end
230
+
231
+ def xing_header_usable_for_duration?(xing_header)
232
+ xing_header && xing_header.frames && xing_header.byte_count && xing_header.vbr_scale
233
+ end
234
+
235
+ def float_average_over(enum, property)
236
+ enum.map(&property).inject(&:+) / enum.length.to_f
237
+ end
238
+
239
+ def deep_fetch(from, *keys)
240
+ keys.inject(from) { |receiver, key_or_idx| receiver.fetch(key_or_idx) }
241
+ rescue KeyError, IndexError, NoMethodError
242
+ raise InvalidDeepFetch, "Could not retrieve #{keys.inspect} from #{from.inspect}"
243
+ end
244
+
245
+ FormatParser.register_parser_constructor self
246
+ end
@@ -0,0 +1,54 @@
1
+ module FormatParser::MP3Parser::ID3V1
2
+ PACKSPEC = [
3
+ :tag, :a3,
4
+ :song_name, :a30,
5
+ :artist, :a30,
6
+ :album, :a30,
7
+ :year, :N1,
8
+ :comment, :a30,
9
+ :genre, :C,
10
+ ]
11
+ packspec_keys = PACKSPEC.select.with_index{|_, i| i.even? }
12
+ TAG_SIZE_BYTES = 128
13
+
14
+ class TagInformation < Struct.new(*packspec_keys)
15
+ end
16
+
17
+ def attempt_id3_v1_extraction(io)
18
+ if io.size < TAG_SIZE_BYTES # Won't fit the ID3v1 regardless
19
+ return nil
20
+ end
21
+
22
+ io.seek(io.size - 128)
23
+ trailer_bytes = io.read(128)
24
+
25
+ unless trailer_bytes && trailer_bytes.byteslice(0, 3) == 'TAG'
26
+ return nil
27
+ end
28
+
29
+ id3_v1 = parse_id3_v1(trailer_bytes)
30
+
31
+ # If all of the resulting strings are empty this ID3v1 tag is invalid and
32
+ # we should ignore it.
33
+ strings_from_id3v1 = id3_v1.values.select{|e| e.is_a?(String) && e != 'TAG' }
34
+ if strings_from_id3v1.all?(&:empty?)
35
+ return nil
36
+ end
37
+
38
+ id3_v1
39
+ end
40
+
41
+ def parse_id3_v1(byte_str)
42
+ keys, values = PACKSPEC.partition.with_index {|_, i| i.even? }
43
+ unpacked_values = byte_str.unpack(values.join)
44
+ unpacked_values.map! {|e| e.is_a?(String) ? trim_id3v1_string(e) : e }
45
+ TagInformation.new(unpacked_values)
46
+ end
47
+
48
+ # Remove trailing whitespace and trailing nullbytes
49
+ def trim_id3v1_string(str)
50
+ str.tr("\x00".b, '').strip
51
+ end
52
+
53
+ extend self
54
+ end
@@ -0,0 +1,86 @@
1
+ module FormatParser::MP3Parser::ID3V2
2
+ def attempt_id3_v2_extraction(io)
3
+ io.seek(0) # Only support header ID3v2
4
+ header_bytes = io.read(10)
5
+
6
+ return nil unless header_bytes
7
+
8
+ header = parse_id3_v2_header(header_bytes)
9
+ return nil unless header[:tag] == 'ID3'
10
+ return nil unless header[:size] > 0
11
+
12
+ header_tag_payload = io.read(header[:size])
13
+ header_tag_payload = StringIO.new(header_tag_payload)
14
+
15
+ return nil unless header_tag_payload.size == header[:size]
16
+
17
+ frames = []
18
+ loop do
19
+ break if header_tag_payload.eof?
20
+ frame = parse_id3_v2_frame(header_tag_payload)
21
+ # Some files include padding, which is there so that when you edit ID3v2
22
+ # you do not have to overwrite the entire file - you can use this padding to
23
+ # add some more tags or to grow the existing ones. In practice if we hit
24
+ # something with a type of "0x00000000" we have entered the padding zone and
25
+ # there is no point in parsing further
26
+ if frame[:id] == "\x00\x00\x00\x00".b
27
+ break
28
+ else
29
+ frames << frame
30
+ end
31
+ end
32
+ frames
33
+ end
34
+
35
+ def parse_id3_v2_header(byte_str)
36
+ packspec = [
37
+ :tag, :a3,
38
+ :version, :a2,
39
+ :flags, :C1,
40
+ :size, :a4,
41
+ ]
42
+ keys, values = packspec.partition.with_index {|_, i| i.even? }
43
+ unpacked_values = byte_str.unpack(values.join)
44
+ header_data = Hash[keys.zip(unpacked_values)]
45
+
46
+ header_data[:version] = header_data[:version].unpack('C2')
47
+ header_data[:size] = decode_syncsafe_int(header_data[:size])
48
+
49
+ header_data
50
+ end
51
+
52
+ def parse_id3_v2_frame(io)
53
+ id, size, flags = io.read(10).unpack('a4a4a2')
54
+ size = decode_syncsafe_int(size)
55
+ content = io.read(size)
56
+ if content.bytesize != size
57
+ raise "Expected to read #{size} bytes for ID3V2 frame #{id}, but got #{content.bytesize}"
58
+ end
59
+ {id: id, size: size, flags: flags, content: content}
60
+ end
61
+
62
+ # ID3v2 uses "unsynchronized integers", which are unsigned integers smeared
63
+ # over multiple bytes in such a manner that the first bit is always 0 (unset).
64
+ # This is done so that ID3v2 incompatible decoders will not by accident see
65
+ # the 0xFF0xFF0xFF0xFF sequence anywhere that can be mistaken for the MPEG frame
66
+ # synchronisation header. Effectively it is a 7 bit big-endian unsigned integer
67
+ # encoding.
68
+ #
69
+ # 8 bit 255 (0xFF) encoded in this mannner takes 16 bits instead,
70
+ # and looks like this: `0b00000001 01111111`. Note how it avoids having
71
+ # the first bit of the second byte be 1.
72
+ # This method decodes an unsigned integer packed in this fashion
73
+ def decode_syncsafe_int(bytes)
74
+ size = 0
75
+ j = 0
76
+ i = bytes.bytesize - 1
77
+ while i >= 0
78
+ size += 128**i * (bytes.getbyte(j) & 0x7f)
79
+ j += 1
80
+ i -= 1
81
+ end
82
+ size
83
+ end
84
+
85
+ extend self
86
+ end
data/lib/read_limiter.rb CHANGED
@@ -1,5 +1,6 @@
1
1
  class FormatParser::ReadLimiter
2
2
  NO_LIMIT = nil
3
+
3
4
  class BudgetExceeded < StandardError
4
5
  end
5
6
 
@@ -30,6 +31,10 @@ class FormatParser::ReadLimiter
30
31
  @io.seek(to_offset)
31
32
  end
32
33
 
34
+ def size
35
+ @io.size
36
+ end
37
+
33
38
  def read(n)
34
39
  @bytes += n
35
40
  @reads += 1
data/spec/care_spec.rb CHANGED
@@ -14,6 +14,13 @@ describe Care do
14
14
  expect(cache.byteslice(source, 120, 12)).to be_nil
15
15
  end
16
16
 
17
+ it 'raises on a negative read offset' do
18
+ cache = Care::Cache.new(3)
19
+ expect {
20
+ cache.byteslice(source, -2, 3)
21
+ }.to raise_error(/negative/i)
22
+ end
23
+
17
24
  it 'can be cleared' do
18
25
  cache = Care::Cache.new(3)
19
26
  expect(cache.byteslice(source, 0, 3)).to eq("Hel")
@@ -80,7 +87,7 @@ describe Care do
80
87
  methods_not_covered = Set.new(FormatParser::IOConstraint.public_instance_methods) - Set.new(Care::IOWrapper.public_instance_methods)
81
88
  expect(methods_not_covered).to be_empty
82
89
  end
83
-
90
+
84
91
  it 'forwards calls to size() to the underlying IO' do
85
92
  io_double = double('IO')
86
93
  expect(io_double).to receive(:size).and_return(123)
@@ -0,0 +1,31 @@
1
+ require 'spec_helper'
2
+
3
+ describe FormatParser::MP3Parser do
4
+ it 'decodes and estimates duration for a VBR MP3' do
5
+ fpath = fixtures_dir + '/MP3/atc_fixture_vbr.mp3'
6
+ parsed = subject.information_from_io(File.open(fpath, 'rb'))
7
+
8
+ expect(parsed).not_to be_nil
9
+
10
+ expect(parsed.file_nature).to eq(:audio)
11
+ expect(parsed.file_type).to eq(:mp3)
12
+ expect(parsed.num_audio_channels).to eq(2)
13
+ expect(parsed.audio_sample_rate_hz).to eq(44100)
14
+ expect(parsed.intrinsics).not_to be_nil
15
+ expect(parsed.media_duration_seconds).to be_within(0.1).of(0.836)
16
+ end
17
+
18
+ it 'decodes and estimates duration for a CBR MP3' do
19
+ fpath = fixtures_dir + '/MP3/atc_fixture_cbr.mp3'
20
+ parsed = subject.information_from_io(File.open(fpath, 'rb'))
21
+
22
+ expect(parsed).not_to be_nil
23
+
24
+ expect(parsed.file_nature).to eq(:audio)
25
+ expect(parsed.file_type).to eq(:mp3)
26
+ expect(parsed.num_audio_channels).to eq(2)
27
+ expect(parsed.audio_sample_rate_hz).to eq(44100)
28
+ expect(parsed.intrinsics).not_to be_nil
29
+ expect(parsed.media_duration_seconds).to be_within(0.1).of(0.81)
30
+ end
31
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: format_parser
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2
4
+ version: 0.1.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Noah Berman
@@ -9,8 +9,22 @@ authors:
9
9
  autorequire:
10
10
  bindir: exe
11
11
  cert_chain: []
12
- date: 2018-01-08 00:00:00.000000000 Z
12
+ date: 2018-01-09 00:00:00.000000000 Z
13
13
  dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: ks
16
+ requirement: !ruby/object:Gem::Requirement
17
+ requirements:
18
+ - - "~>"
19
+ - !ruby/object:Gem::Version
20
+ version: 0.0.1
21
+ type: :runtime
22
+ prerelease: false
23
+ version_requirements: !ruby/object:Gem::Requirement
24
+ requirements:
25
+ - - "~>"
26
+ - !ruby/object:Gem::Version
27
+ version: 0.0.1
14
28
  - !ruby/object:Gem::Dependency
15
29
  name: exifr
16
30
  requirement: !ruby/object:Gem::Requirement
@@ -141,6 +155,9 @@ files:
141
155
  - lib/parsers/exif_parser.rb
142
156
  - lib/parsers/gif_parser.rb
143
157
  - lib/parsers/jpeg_parser.rb
158
+ - lib/parsers/mp3_parser.rb
159
+ - lib/parsers/mp3_parser/id3_v1.rb
160
+ - lib/parsers/mp3_parser/id3_v2.rb
144
161
  - lib/parsers/png_parser.rb
145
162
  - lib/parsers/psd_parser.rb
146
163
  - lib/parsers/tiff_parser.rb
@@ -155,6 +172,7 @@ files:
155
172
  - spec/parsers/exif_parser_spec.rb
156
173
  - spec/parsers/gif_parser_spec.rb
157
174
  - spec/parsers/jpeg_parser_spec.rb
175
+ - spec/parsers/mp3_parser_spec.rb
158
176
  - spec/parsers/png_parser_spec.rb
159
177
  - spec/parsers/psd_parser_spec.rb
160
178
  - spec/parsers/tiff_parser_spec.rb