wahwah 0.1.0 → 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +21 -0
  3. data/lib/wahwah.rb +74 -3
  4. data/lib/wahwah/asf/object.rb +39 -0
  5. data/lib/wahwah/asf_tag.rb +220 -0
  6. data/lib/wahwah/errors.rb +6 -0
  7. data/lib/wahwah/flac/block.rb +57 -0
  8. data/lib/wahwah/flac/streaminfo_block.rb +51 -0
  9. data/lib/wahwah/flac_tag.rb +84 -0
  10. data/lib/wahwah/helper.rb +37 -0
  11. data/lib/wahwah/id3/comment_frame_body.rb +21 -0
  12. data/lib/wahwah/id3/frame.rb +180 -0
  13. data/lib/wahwah/id3/frame_body.rb +36 -0
  14. data/lib/wahwah/id3/genre_frame_body.rb +15 -0
  15. data/lib/wahwah/id3/image_frame_body.rb +60 -0
  16. data/lib/wahwah/id3/text_frame_body.rb +16 -0
  17. data/lib/wahwah/id3/v1.rb +96 -0
  18. data/lib/wahwah/id3/v2.rb +60 -0
  19. data/lib/wahwah/id3/v2_header.rb +53 -0
  20. data/lib/wahwah/mp3/mpeg_frame_header.rb +141 -0
  21. data/lib/wahwah/mp3/vbri_header.rb +47 -0
  22. data/lib/wahwah/mp3/xing_header.rb +45 -0
  23. data/lib/wahwah/mp3_tag.rb +110 -0
  24. data/lib/wahwah/mp4/atom.rb +105 -0
  25. data/lib/wahwah/mp4_tag.rb +126 -0
  26. data/lib/wahwah/ogg/flac_tag.rb +37 -0
  27. data/lib/wahwah/ogg/opus_tag.rb +33 -0
  28. data/lib/wahwah/ogg/packets.rb +41 -0
  29. data/lib/wahwah/ogg/page.rb +121 -0
  30. data/lib/wahwah/ogg/pages.rb +24 -0
  31. data/lib/wahwah/ogg/vorbis_comment.rb +51 -0
  32. data/lib/wahwah/ogg/vorbis_tag.rb +35 -0
  33. data/lib/wahwah/ogg_tag.rb +66 -0
  34. data/lib/wahwah/riff/chunk.rb +54 -0
  35. data/lib/wahwah/riff_tag.rb +140 -0
  36. data/lib/wahwah/tag.rb +59 -0
  37. data/lib/wahwah/tag_delegate.rb +16 -0
  38. data/lib/wahwah/version.rb +4 -2
  39. metadata +94 -23
  40. data/.gitignore +0 -8
  41. data/.travis.yml +0 -5
  42. data/Gemfile +0 -6
  43. data/Gemfile.lock +0 -22
  44. data/README.md +0 -35
  45. data/Rakefile +0 -10
  46. data/bin/console +0 -14
  47. data/bin/setup +0 -8
  48. data/wahwah.gemspec +0 -27
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'forwardable'
4
+
5
+ module WahWah
6
+ class Mp3Tag < Tag
7
+ extend TagDelegate
8
+ extend Forwardable
9
+
10
+ def_delegator :@mpeg_frame_header, :version, :mpeg_version
11
+ def_delegator :@mpeg_frame_header, :layer, :mpeg_layer
12
+ def_delegator :@mpeg_frame_header, :kind, :mpeg_kind
13
+ def_delegators :@mpeg_frame_header, :channel_mode, :sample_rate
14
+
15
+ tag_delegate :@id3_tag,
16
+ :title,
17
+ :artist,
18
+ :album,
19
+ :albumartist,
20
+ :composer,
21
+ :comments,
22
+ :track,
23
+ :track_total,
24
+ :genre,
25
+ :year,
26
+ :disc,
27
+ :disc_total,
28
+ :images
29
+
30
+ def id3v2?
31
+ @id3_tag.instance_of? ID3::V2
32
+ end
33
+
34
+ def invalid_id3?
35
+ @id3_tag.nil?
36
+ end
37
+
38
+ def id3_version
39
+ @id3_tag&.version
40
+ end
41
+
42
+ def is_vbr?
43
+ xing_header.valid? || vbri_header.valid?
44
+ end
45
+
46
+ private
47
+ def parse
48
+ @id3_tag = parse_id3_tag
49
+ parse_duration if mpeg_frame_header.valid?
50
+ end
51
+
52
+ def parse_id3_tag
53
+ id3_v1_tag = ID3::V1.new(@file_io)
54
+ id3_v2_tag = ID3::V2.new(@file_io)
55
+
56
+ return id3_v2_tag if id3_v2_tag.valid?
57
+ id3_v1_tag if id3_v1_tag.valid?
58
+ end
59
+
60
+ def parse_duration
61
+ if is_vbr?
62
+ @duration = (frames_count * (mpeg_frame_header.samples_per_frame / sample_rate.to_f)).round
63
+ @bitrate = bytes_count * 8 / @duration / 1000 unless @duration.zero?
64
+ else
65
+ @bitrate = mpeg_frame_header.frame_bitrate
66
+ @duration = (file_size - (@id3_tag&.size || 0)) * 8 / (@bitrate * 1000) unless @bitrate.zero?
67
+ end
68
+ end
69
+
70
+ def mpeg_frame_header
71
+ # Because id3v2 tag on the file header so skip id3v2 tag
72
+ @mpeg_frame_header ||= Mp3::MpegFrameHeader.new(@file_io, id3v2? ? @id3_tag&.size : 0)
73
+ end
74
+
75
+ def xing_header
76
+ @xing_header ||= Mp3::XingHeader.new(@file_io, xing_header_offset)
77
+ end
78
+
79
+ def vbri_header
80
+ @vbri_header ||= Mp3::VbriHeader.new(@file_io, vbri_header_offset)
81
+ end
82
+
83
+ def xing_header_offset
84
+ mpeg_frame_header_position = mpeg_frame_header.position
85
+ mpeg_frame_header_size = Mp3::MpegFrameHeader::HEADER_SIZE
86
+ mpeg_frame_side_info_size = mpeg_version == 'MPEG1' ?
87
+ (channel_mode == 'Single Channel' ? 17 : 32) :
88
+ (channel_mode == 'Single Channel' ? 9 : 17)
89
+
90
+ mpeg_frame_header_position + mpeg_frame_header_size + mpeg_frame_side_info_size
91
+ end
92
+
93
+ def vbri_header_offset
94
+ mpeg_frame_header_position = mpeg_frame_header.position
95
+ mpeg_frame_header_size = Mp3::MpegFrameHeader::HEADER_SIZE
96
+
97
+ mpeg_frame_header_position + mpeg_frame_header_size + 32
98
+ end
99
+
100
+ def frames_count
101
+ return xing_header.frames_count if xing_header.valid?
102
+ vbri_header.frames_count if vbri_header.valid?
103
+ end
104
+
105
+ def bytes_count
106
+ return xing_header.bytes_count if xing_header.valid?
107
+ vbri_header.bytes_count if vbri_header.valid?
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WahWah
4
+ module Mp4
5
+ class Atom
6
+ VERSIONED_ATOMS = %w(meta stsd)
7
+ FLAGGED_ATOMS = %w(stsd)
8
+ HEADER_SIZE = 8
9
+ HEADER_SIZE_FIELD_SIZE = 4
10
+ EXTENDED_HEADER_SIZE = 8
11
+
12
+ attr_reader :size, :type
13
+
14
+ def self.find(file_io, *atom_path)
15
+ file_io.rewind
16
+
17
+ atom_type = atom_path.shift
18
+
19
+ until file_io.eof?
20
+ atom = new(file_io)
21
+
22
+ next unless atom.valid?
23
+ file_io.seek(atom.size, IO::SEEK_CUR); next unless atom.type == atom_type
24
+
25
+ return atom if atom_path.empty?
26
+ return atom.find(*atom_path)
27
+ end
28
+
29
+ # Return empty atom if can not found
30
+ new(StringIO.new(''))
31
+ end
32
+
33
+ # An atom header consists of the following fields:
34
+ #
35
+ # Atom size:
36
+ # A 32-bit integer that indicates the size of the atom, including both the atom header and the atom’s contents,
37
+ # including any contained atoms. Normally, the size field contains the actual size of the atom.
38
+ #
39
+ # Type:
40
+ # A 32-bit integer that contains the type of the atom.
41
+ # This can often be usefully treated as a four-character field with a mnemonic value .
42
+ def initialize(file_io)
43
+ @size, @type = file_io.read(HEADER_SIZE)&.unpack('Na4')
44
+ return unless valid?
45
+
46
+ # If the size field of an atom is set to 1, the type field is followed by a 64-bit extended size field,
47
+ # which contains the actual size of the atom as a 64-bit unsigned integer.
48
+ @size = file_io.read(EXTENDED_HEADER_SIZE).unpack('Q>').first - EXTENDED_HEADER_SIZE if @size == 1
49
+
50
+ # If the size field of an atom is set to 0, which is allowed only for a top-level atom,
51
+ # designates the last atom in the file and indicates that the atom extends to the end of the file.
52
+ @size = file_io.size if @size == 0
53
+ @size = @size - HEADER_SIZE
54
+ @file_io = file_io
55
+ @position = file_io.pos
56
+ end
57
+
58
+ def data
59
+ @file_io.seek(@position)
60
+ @file_io.read(size)
61
+ end
62
+
63
+ def valid?
64
+ !@size.nil? && @size >= HEADER_SIZE
65
+ end
66
+
67
+ def find(*atom_path)
68
+ child_atom_index = data.index(atom_path.first)
69
+
70
+ # Return empty atom if can not found
71
+ return self.class.new(StringIO.new('')) if child_atom_index.nil?
72
+
73
+ # Because before atom type field there are 4 bytes of size field,
74
+ # So the child_atom_index should reduce 4.
75
+ self.class.find(StringIO.new(data[child_atom_index - HEADER_SIZE_FIELD_SIZE..-1]), *atom_path)
76
+ end
77
+
78
+
79
+ def children
80
+ @children ||= parse_children_atoms
81
+ end
82
+
83
+ private
84
+ def parse_children_atoms
85
+ children_atoms = []
86
+ atoms_data = data
87
+
88
+ # Some atoms data contain extra content before child atom data.
89
+ # So reduce those extra content to get child atom data.
90
+ atoms_data = atoms_data[4..-1] if VERSIONED_ATOMS.include? type # Skip 4 bytes for version
91
+ atoms_data = atoms_data[4..-1] if FLAGGED_ATOMS.include? type # Skip 4 bytes for flag
92
+ atoms_data_io = StringIO.new(atoms_data)
93
+
94
+ until atoms_data_io.eof?
95
+ atom = self.class.new(atoms_data_io)
96
+ children_atoms.push(atom)
97
+
98
+ atoms_data_io.seek(atom.size, IO::SEEK_CUR)
99
+ end
100
+
101
+ children_atoms
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WahWah
4
+ class Mp4Tag < Tag
5
+ META_ATOM_MAPPING = {
6
+ "\xA9alb".b => :album,
7
+ "\xA9ART".b => :artist,
8
+ "\xA9cmt".b => :comment,
9
+ "\xA9wrt".b => :composer,
10
+ "\xA9day".b => :year,
11
+ "\xA9gen".b => :genre,
12
+ "\xA9nam".b => :title,
13
+ 'covr'.b => :image,
14
+ 'disk'.b => :disc,
15
+ 'trkn'.b => :track,
16
+ 'aART'.b => :albumartist
17
+ }
18
+
19
+ META_ATOM_DECODE_BY_TYPE = {
20
+ 0 => -> (data) { data }, # reserved
21
+ 1 => -> (data) { Helper.encode_to_utf8(data) }, # UTF-8
22
+ 2 => -> (data) { Helper.encode_to_utf8(data, 'UTF-16BE') }, # UTF-16BE
23
+ 3 => -> (data) { Helper.encode_to_utf8(data, 'SJIS') }, # SJIS
24
+
25
+ 13 => -> (data) { { data: data, mime_type: 'image/jpeg', type: :cover } }, # JPEG
26
+ 14 => -> (data) { { data: data, mime_type: 'image/png', type: :cover } }, # PNG
27
+
28
+ 21 => -> (data) { data.unpack('i>').first }, # Big endian signed integer
29
+ 22 => -> (data) { data.unpack('I>').first }, # Big endian unsigned integer
30
+ 23 => -> (data) { data.unpack('g').first }, # Big endian 32-bit floating point value
31
+ 24 => -> (data) { data.unpack('G').first }, # Big endian 64-bit floating point value
32
+
33
+ 65 => -> (data) { data.unpack('c').first }, # 8-bit signed integer
34
+ 66 => -> (data) { data.unpack('s>').first }, # Big-endian 16-bit signed integer
35
+ 67 => -> (data) { data.unpack('l>').first }, # Big-endian 32-bit signed integer
36
+ 74 => -> (data) { data.unpack('q>').first }, # Big-endian 64-bit signed integer
37
+
38
+ 75 => -> (data) { data.unpack('C').first }, # 8-bit unsigned integer
39
+ 76 => -> (data) { data.unpack('S>').first }, # Big-endian 16-bit unsigned integer
40
+ 77 => -> (data) { data.unpack('L>').first }, # Big-endian 32-bit unsigned integer
41
+ 78 => -> (data) { data.unpack('Q>').first } # Big-endian 64-bit unsigned integer
42
+ }
43
+
44
+ private
45
+ def parse
46
+ movie_atom = Mp4::Atom.find(@file_io, 'moov')
47
+ return unless movie_atom.valid?
48
+
49
+ parse_meta_list_atom movie_atom.find('udta', 'meta', 'ilst')
50
+ parse_mvhd_atom movie_atom.find('mvhd')
51
+ parse_stsd_atom movie_atom.find('trak', 'mdia', 'minf', 'stbl', 'stsd')
52
+ end
53
+
54
+ def parse_meta_list_atom(atom)
55
+ return unless atom.valid?
56
+
57
+ # The metadata item list atom holds a list of actual metadata values that are present in the metadata atom.
58
+ # The metadata items are formatted as a list of items.
59
+ # The metadata item list atom is of type ‘ilst’ and contains a number of metadata items, each of which is an atom.
60
+ # each metadata item atom contains a Value Atom, to hold the value of the metadata item
61
+ atom.children.each do |child_atom|
62
+ attribute_name = META_ATOM_MAPPING[child_atom.type]
63
+
64
+ # The value of the metadata item is expressed as immediate data in a value atom.
65
+ # The value atom starts with two fields: a type indicator, and a locale indicator.
66
+ # Both the type and locale indicators are four bytes long.
67
+ # There may be multiple ‘value’ entries, using different type
68
+ data_atom = child_atom.find('data')
69
+ return unless data_atom.valid?
70
+
71
+ data_type, data_value = data_atom.data.unpack('Nx4a*')
72
+ encoded_data_value = META_ATOM_DECODE_BY_TYPE[data_type]&.call(data_value)
73
+
74
+ next if attribute_name.nil? || encoded_data_value.nil?
75
+
76
+ case attribute_name
77
+ when :image
78
+ @images.push(encoded_data_value)
79
+ when :comment
80
+ @comments.push(encoded_data_value)
81
+ when :track, :disc
82
+ count, total_count = encoded_data_value.unpack('x2nn')
83
+
84
+ instance_variable_set("@#{attribute_name}", count) unless count.zero?
85
+ instance_variable_set("@#{attribute_name}_total", total_count) unless total_count.zero?
86
+ else
87
+ instance_variable_set("@#{attribute_name}", encoded_data_value)
88
+ end
89
+ end
90
+ end
91
+
92
+ def parse_mvhd_atom(atom)
93
+ return unless atom.valid?
94
+
95
+ atom_data = StringIO.new(atom.data)
96
+ version = atom_data.read(1).unpack('c').first
97
+
98
+ # Skip flags
99
+ atom_data.seek(3, IO::SEEK_CUR)
100
+
101
+ if version == 0
102
+ # Skip creation and modification time
103
+ atom_data.seek(8, IO::SEEK_CUR)
104
+
105
+ time_scale, duration = atom_data.read(8).unpack('l>l>')
106
+ elsif version == 1
107
+ # Skip creation and modification time
108
+ atom_data.seek(16, IO::SEEK_CUR)
109
+
110
+ time_scale, duration = atom_data.read(12).unpack('l>q>')
111
+ end
112
+
113
+ @duration = (duration / time_scale.to_f).round
114
+ end
115
+
116
+ def parse_stsd_atom(atom)
117
+ return unless atom.valid?
118
+
119
+ mp4a_atom = atom.find('mp4a')
120
+ esds_atom = atom.find('esds')
121
+
122
+ @sample_rate = mp4a_atom.data.unpack('x22I>').first if mp4a_atom.valid?
123
+ @bitrate = esds_atom.data.unpack('x26I>').first / 1000 if esds_atom.valid?
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WahWah
4
+ module Ogg
5
+ class FlacTag
6
+ include VorbisComment
7
+ include Flac::StreaminfoBlock
8
+
9
+ attr_reader :bitrate, :duration, :sample_rate, *COMMET_FIELD_MAPPING.values
10
+
11
+ def initialize(identification_packet, comment_packet)
12
+ # Identification packet structure:
13
+ #
14
+ # The one-byte packet type 0x7F
15
+ # The four-byte ASCII signature "FLAC", i.e. 0x46, 0x4C, 0x41, 0x43
16
+ # A one-byte binary major version number for the mapping, e.g. 0x01 for mapping version 1.0
17
+ # A one-byte binary minor version number for the mapping, e.g. 0x00 for mapping version 1.0
18
+ # A two-byte, big-endian binary number signifying the number of header (non-audio) packets, not including this one.
19
+ # The four-byte ASCII native FLAC signature "fLaC" according to the FLAC format specification
20
+ # The STREAMINFO metadata block for the stream.
21
+ #
22
+ # The first identification packet is followed by one or more header packets.
23
+ # Each such packet will contain a single native FLAC metadata block.
24
+ # The first of these must be a VORBIS_COMMENT block.
25
+
26
+ id, streaminfo_block_data = identification_packet.unpack('x9A4A*')
27
+
28
+ return unless id == 'fLaC'
29
+ streaminfo_block = Flac::Block.new(StringIO.new(streaminfo_block_data))
30
+ vorbis_comment_block = Flac::Block.new(StringIO.new(comment_packet))
31
+
32
+ parse_streaminfo_block(streaminfo_block.data)
33
+ parse_vorbis_comment(vorbis_comment_block.data)
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WahWah
4
+ module Ogg
5
+ class OpusTag
6
+ include VorbisComment
7
+
8
+ attr_reader :sample_rate, :pre_skip, *COMMET_FIELD_MAPPING.values
9
+
10
+ def initialize(identification_packet, comment_packet)
11
+ # Identification packet structure:
12
+ #
13
+ # 1) "OpusHead"
14
+ # 2) [version] = read 8 bits as unsigned integer
15
+ # 3) [audio_channels] = read 8 bit as unsigned integer
16
+ # 4) [pre_skip] = read 16 bits as unsigned little endian integer
17
+ # 5) [input_sample_rate] = read 32 bits as unsigned little endian integer
18
+ # 6) [output_gain] = read 16 bits as unsigned little endian integer
19
+ # 7) [channel_mapping_family] = read 8 bit as unsigned integer
20
+ # 8) [channel_mapping_table]
21
+ @sample_rate = 48000
22
+ @pre_skip = identification_packet[10..11].unpack('v').first
23
+
24
+ comment_packet_id, comment_packet_body = [comment_packet[0..7], comment_packet[8..-1]]
25
+
26
+ # Opus comment packet start with 'OpusTags'
27
+ return unless comment_packet_id == 'OpusTags'
28
+
29
+ parse_vorbis_comment(comment_packet_body)
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WahWah
4
+ module Ogg
5
+ # From Ogg's perspective, packets can be of any arbitrary size. A
6
+ # specific media mapping will define how to group or break up packets
7
+ # from a specific media encoder. As Ogg pages have a maximum size of
8
+ # about 64 kBytes, sometimes a packet has to be distributed over
9
+ # several pages. To simplify that process, Ogg divides each packet
10
+ # into 255 byte long chunks plus a final shorter chunk. These chunks
11
+ # are called "Ogg Segments". They are only a logical construct and do
12
+ # not have a header for themselves.
13
+ class Packets
14
+ include Enumerable
15
+
16
+ def initialize(file_io)
17
+ @file_io = file_io
18
+ end
19
+
20
+ def each
21
+ @file_io.rewind
22
+
23
+ packet = +''
24
+ pages = Ogg::Pages.new(@file_io)
25
+
26
+ pages.each do |page|
27
+ page.segments.each do |segment|
28
+ packet << segment
29
+
30
+ # Ogg divides each packet into 255 byte long segments plus a final shorter segment.
31
+ # So when segment length is less than 255 byte, it's the final segment.
32
+ if segment.length < 255
33
+ yield packet
34
+ packet = +''
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end