ruby-audioinfo 0.5.2 → 0.5.4
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +5 -5
- data/.github/workflows/ruby.yml +53 -0
- data/.gitignore +1 -0
- data/.rubocop.yml +10 -0
- data/.rubocop_todo.yml +116 -0
- data/CODE_OF_CONDUCT.md +84 -0
- data/Gemfile +12 -0
- data/Gemfile.lock +76 -0
- data/History.txt +10 -2
- data/README.md +56 -0
- data/Rakefile +11 -37
- data/bin/console +15 -0
- data/codecov.yml +1 -0
- data/lib/audioinfo.rb +165 -186
- data/lib/audioinfo/album.rb +110 -111
- data/lib/audioinfo/case_insensitive_hash.rb +3 -1
- data/lib/audioinfo/mpcinfo.rb +81 -85
- data/lib/audioinfo/version.rb +5 -0
- data/ruby-audioinfo.gemspec +35 -0
- metadata +52 -85
- data/.gemtest +0 -0
- data/Manifest.txt +0 -9
- data/README.rdoc +0 -46
- data/test/mpcinfo.rb +0 -8
- data/test/test_audioinfo.rb +0 -35
- data/test/test_case_insensitive_hash.rb +0 -28
- data/test/test_helper.rb +0 -1
- data/test/test_wav.rb +0 -27
data/lib/audioinfo/album.rb
CHANGED
@@ -1,146 +1,145 @@
|
|
1
|
-
|
1
|
+
# frozen_string_literal: true
|
2
2
|
|
3
|
-
|
3
|
+
require 'audioinfo'
|
4
4
|
|
5
|
-
|
5
|
+
module AudioInfo
|
6
|
+
class Album
|
7
|
+
IMAGE_EXTENSIONS = %w[jpg jpeg gif png].freeze
|
6
8
|
|
7
|
-
|
8
|
-
|
9
|
-
|
9
|
+
# a regexp to match the "multicd" suffix of a "multicd" string
|
10
|
+
# example: "toto (disc 1)" will match ' (disc 1)'
|
11
|
+
MULTICD_REGEXP = /\s*(\(|\[)?\s*(disc|cd):?-?\s*(\d+).*(\)|\])?\s*$/i.freeze
|
10
12
|
|
11
|
-
|
13
|
+
attr_reader :files, :discnum, :multicd, :basename, :infos, :path
|
12
14
|
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
15
|
+
# return the list of images in the album directory, with "folder.*" in first
|
16
|
+
def self.images(path)
|
17
|
+
path = path.dup.force_encoding('binary')
|
18
|
+
arr = Dir.glob(File.join(path, "*.{#{IMAGE_EXTENSIONS.join(',')}}"), File::FNM_CASEFOLD).collect do |f|
|
19
|
+
File.expand_path(f)
|
20
|
+
end
|
21
|
+
# move "folder.*" image on top of the array
|
22
|
+
if folder = arr.detect { |f| f =~ /folder\.[^.]+$/ }
|
23
|
+
arr.delete(folder)
|
24
|
+
arr.unshift(folder)
|
25
|
+
end
|
26
|
+
arr
|
23
27
|
end
|
24
|
-
arr
|
25
|
-
end
|
26
28
|
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
29
|
+
# strip the "multicd" string from the given +name+
|
30
|
+
def self.basename(name)
|
31
|
+
name.sub(MULTICD_REGEXP, '')
|
32
|
+
end
|
31
33
|
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
34
|
+
# return the number of the disc in the box or 0
|
35
|
+
def self.discnum(name)
|
36
|
+
if name =~ MULTICD_REGEXP
|
37
|
+
Regexp.last_match(3).to_i
|
38
|
+
else
|
39
|
+
0
|
40
|
+
end
|
38
41
|
end
|
39
|
-
end
|
40
42
|
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
43
|
+
# open the Album with +path+. +fast_lookup+ will only check
|
44
|
+
# first and last file of the directory
|
45
|
+
def initialize(path, fast_lookup = false)
|
46
|
+
@path = path
|
47
|
+
@multicd = false
|
48
|
+
@basename = @path
|
49
|
+
exts = AudioInfo::SUPPORTED_EXTENSIONS.collect do |ext|
|
50
|
+
ext.gsub(/[a-z]/) { |c| "[#{c.downcase}#{c.upcase}]" }
|
51
|
+
end.join(',')
|
50
52
|
|
51
|
-
|
52
|
-
|
53
|
+
# need to escape the glob path
|
54
|
+
glob_escaped_path = @path.gsub(/([{}?*\[\]])/) { |s| '\\' << s }
|
53
55
|
|
54
|
-
|
55
|
-
|
56
|
+
glob_val = File.join(glob_escaped_path, "*.{#{exts}}")
|
57
|
+
file_names = Dir.glob(glob_val).sort
|
56
58
|
|
57
|
-
|
58
|
-
|
59
|
+
file_names = [file_names.first, file_names.last] if fast_lookup
|
60
|
+
|
61
|
+
@files = file_names.collect do |f|
|
62
|
+
AudioInfo.new(f)
|
63
|
+
end
|
64
|
+
|
65
|
+
@infos = {}
|
66
|
+
@infos['album'] = @files.collect(&:album).uniq
|
67
|
+
@infos['album'] = @infos['album'].first if @infos['album'].size == 1
|
68
|
+
artists = @files.collect(&:artist).uniq
|
69
|
+
@infos['artist'] = artists.size > 1 ? 'various' : artists.first
|
70
|
+
@discnum = self.class.discnum(@infos['album'])
|
71
|
+
|
72
|
+
unless @discnum.zero?
|
73
|
+
@multicd = true
|
74
|
+
@basename = self.class.basename(@infos['album'])
|
75
|
+
end
|
59
76
|
end
|
60
77
|
|
61
|
-
|
62
|
-
|
78
|
+
# is the album empty?
|
79
|
+
def empty?
|
80
|
+
@files.empty?
|
63
81
|
end
|
64
82
|
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
artists = @files.collect { |i| i.artist }.uniq
|
69
|
-
@infos["artist"] = artists.size > 1 ? "various" : artists.first
|
70
|
-
@discnum = self.class.discnum(@infos["album"])
|
83
|
+
# are all the files of the album MusicBrainz tagged ?
|
84
|
+
def mb_tagged?
|
85
|
+
return false if @files.empty?
|
71
86
|
|
72
|
-
|
73
|
-
@
|
74
|
-
|
87
|
+
mb = true
|
88
|
+
@files.each do |f|
|
89
|
+
mb &&= f.mb_tagged?
|
90
|
+
end
|
91
|
+
mb
|
75
92
|
end
|
76
|
-
end
|
77
93
|
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
|
94
|
+
# return an array of images with "folder.*" in first
|
95
|
+
def images
|
96
|
+
self.class.images(@path)
|
97
|
+
end
|
82
98
|
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
99
|
+
# title of the album
|
100
|
+
def title
|
101
|
+
# count the occurences of the title and take the one who has most
|
102
|
+
hash_counted = files.collect(&:album).each_with_object(Hash.new(0)) { |album, hash| hash[album] += 1; }
|
103
|
+
if hash_counted.empty?
|
104
|
+
nil
|
105
|
+
else
|
106
|
+
hash_counted.max_by { |_k, v| v }[0]
|
107
|
+
end
|
89
108
|
end
|
90
|
-
mb
|
91
|
-
end
|
92
109
|
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
end
|
110
|
+
# mbid (MusicBrainz ID) of the album
|
111
|
+
def mbid
|
112
|
+
return nil unless mb_tagged?
|
97
113
|
|
98
|
-
|
99
|
-
def title
|
100
|
-
# count the occurences of the title and take the one who has most
|
101
|
-
hash_counted = self.files.collect { |f| f.album }.inject(Hash.new(0)) { |hash, album| hash[album] += 1; hash }
|
102
|
-
if hash_counted.empty?
|
103
|
-
nil
|
104
|
-
else
|
105
|
-
hash_counted.sort_by { |k, v| v }.last[0]
|
114
|
+
@files.collect { |f| f.musicbrainz_infos['albumid'] }.uniq.first
|
106
115
|
end
|
107
|
-
end
|
108
|
-
|
109
|
-
# mbid (MusicBrainz ID) of the album
|
110
|
-
def mbid
|
111
|
-
return nil unless mb_tagged?
|
112
|
-
@files.collect { |f| f.musicbrainz_infos["albumid"] }.uniq.first
|
113
|
-
end
|
114
116
|
|
115
|
-
|
116
|
-
|
117
|
-
|
118
|
-
|
117
|
+
# is the album multi-artist?
|
118
|
+
def va?
|
119
|
+
@files.collect(&:artist).uniq.size > 1
|
120
|
+
end
|
119
121
|
|
120
|
-
|
121
|
-
|
122
|
-
|
123
|
-
|
124
|
-
|
122
|
+
# pretty print
|
123
|
+
def to_s
|
124
|
+
out = StringIO.new
|
125
|
+
out.puts(@path)
|
126
|
+
out.print "'#{title}'"
|
125
127
|
|
126
|
-
|
127
|
-
out.print " by '#{@files.first.artist}' "
|
128
|
-
end
|
128
|
+
out.print " by '#{@files.first.artist}' " unless va?
|
129
129
|
|
130
|
-
|
130
|
+
out.puts
|
131
131
|
|
132
|
-
|
133
|
-
|
134
|
-
|
135
|
-
|
132
|
+
@files.sort_by(&:tracknum).each do |f|
|
133
|
+
out.printf('%02d %s %3d %s', f.tracknum, f.extension, f.bitrate, f.title)
|
134
|
+
out.print(" #{f.artist}") if va?
|
135
|
+
out.puts
|
136
136
|
end
|
137
|
-
out.puts
|
138
|
-
end
|
139
137
|
|
140
|
-
|
141
|
-
|
138
|
+
out.string
|
139
|
+
end
|
142
140
|
|
143
|
-
|
144
|
-
|
141
|
+
def inspect
|
142
|
+
@infos.inspect
|
143
|
+
end
|
145
144
|
end
|
146
145
|
end
|
data/lib/audioinfo/mpcinfo.rb
CHANGED
@@ -1,13 +1,13 @@
|
|
1
1
|
#!/usr/bin/env ruby
|
2
|
+
# frozen_string_literal: true
|
2
3
|
|
3
|
-
require
|
4
|
-
require
|
4
|
+
require 'stringio'
|
5
|
+
require 'mp3info/id3v2'
|
5
6
|
|
6
7
|
class MpcInfoError < StandardError; end
|
7
8
|
|
8
9
|
class MpcInfo
|
9
|
-
|
10
|
-
PROFILES_NAMES = [
|
10
|
+
PROFILES_NAMES = [
|
11
11
|
'no profile',
|
12
12
|
'Experimental',
|
13
13
|
'unused',
|
@@ -24,17 +24,20 @@ class MpcInfo
|
|
24
24
|
'BrainDead (q = 8.0)',
|
25
25
|
'above BrainDead (q = 9.0)',
|
26
26
|
'above BrainDead (q = 10.0)'
|
27
|
-
]
|
27
|
+
].freeze
|
28
28
|
|
29
|
-
FREQUENCIES = [
|
29
|
+
FREQUENCIES = [44_100, 48_000, 37_800, 32_000].freeze
|
30
30
|
|
31
|
-
SV4_6_HEADER = Regexp.new(
|
31
|
+
SV4_6_HEADER = Regexp.new(
|
32
|
+
'^[\x00\x01\x10\x11\x40\x41\x50\x51\x80\x81\x90\x91\xC0\xC1\xD0\xD1][\x20-37][\x00\x20\x40\x60\x80\xA0\xC0\xE0]/',
|
33
|
+
nil,
|
34
|
+
'n'
|
35
|
+
)
|
32
36
|
|
33
|
-
attr_reader :infos
|
34
|
-
attr_reader :id3v2_tag
|
37
|
+
attr_reader :infos, :id3v2_tag
|
35
38
|
|
36
39
|
def initialize(filename)
|
37
|
-
@file = File.open(filename,
|
40
|
+
@file = File.open(filename, 'rb')
|
38
41
|
|
39
42
|
@infos = {}
|
40
43
|
@infos['raw'] = {}
|
@@ -46,21 +49,20 @@ class MpcInfo
|
|
46
49
|
def parse_infos
|
47
50
|
mpc_header = @file.read(3)
|
48
51
|
|
49
|
-
|
52
|
+
case mpc_header
|
53
|
+
when 'MP+'
|
50
54
|
# this is SV7+
|
51
|
-
|
55
|
+
|
52
56
|
header = StringIO.new(@file.read(25))
|
53
57
|
header_size = 28
|
54
|
-
#stream_version_byte = header.read(4).unpack("V").first
|
55
|
-
stream_version_byte = header.read(1)[0].ord
|
58
|
+
# stream_version_byte = header.read(4).unpack("V").first
|
59
|
+
stream_version_byte = header.read(1)[0].ord # .unpack("c").first
|
56
60
|
|
57
61
|
@infos['stream_major_version'] = (stream_version_byte & 0x0F)
|
58
62
|
@infos['stream_minor_version'] = (stream_version_byte & 0xF0) >> 4
|
59
63
|
|
60
64
|
@infos['frame_count'] = read32(header)
|
61
|
-
if @infos['stream_major_version'] != 7
|
62
|
-
raise(MpcInfoError, "Only Musepack SV7 supported")
|
63
|
-
end
|
65
|
+
raise(MpcInfoError, 'Only Musepack SV7 supported') if @infos['stream_major_version'] != 7
|
64
66
|
|
65
67
|
flags_dword1 = read32(header)
|
66
68
|
|
@@ -83,103 +85,98 @@ class MpcInfo
|
|
83
85
|
@infos['true_gapless'] = ((flags_dword2 & 0x80000000) >> 31) == 1
|
84
86
|
@infos['last_frame_length'] = (flags_dword2 & 0x7FF00000) >> 20
|
85
87
|
|
86
|
-
|
87
|
-
not_sure_what = read32(header, 3)
|
88
|
+
not_sure_what = read32(header, 3)
|
88
89
|
@infos['raw']['encoder_version'] = read8(header)
|
89
|
-
|
90
|
-
@infos['profile'] = PROFILES_NAMES[ @infos['raw']['profile'] ] || 'invalid'
|
91
|
-
@infos['sample_rate'] = FREQUENCIES[ @infos['raw']['sample_rate'] ]
|
92
90
|
|
93
|
-
|
94
|
-
|
95
|
-
end
|
91
|
+
@infos['profile'] = PROFILES_NAMES[@infos['raw']['profile']] || 'invalid'
|
92
|
+
@infos['sample_rate'] = FREQUENCIES[@infos['raw']['sample_rate']]
|
96
93
|
|
97
|
-
|
98
|
-
|
94
|
+
raise(MpcInfoError, 'Corrupt MPC file: frequency == zero') if (@infos['sample_rate']).zero?
|
95
|
+
|
96
|
+
sample_rate = @infos['sample_rate']
|
97
|
+
channels = 2 # appears to be hardcoded
|
99
98
|
@infos['samples'] = (((@infos['frame_count'] - 1) * 1152) + @infos['last_frame_length']) * channels
|
100
99
|
@infos['length'] = (((@infos['frame_count'] - 1) * 1152) + @infos['last_frame_length']) * channels
|
101
100
|
|
102
|
-
@infos['length']
|
103
|
-
if @infos['length']
|
104
|
-
|
105
|
-
end
|
106
|
-
|
101
|
+
@infos['length'] = (@infos['samples'] / channels) / @infos['sample_rate'].to_f
|
102
|
+
raise(MpcInfoError, 'Corrupt MPC file: playtime_seconds == zero') if (@infos['length']).zero?
|
103
|
+
|
107
104
|
# add size of file header to avdataoffset - calc bitrate correctly + MD5 data
|
108
105
|
avdataoffset = header_size
|
109
106
|
|
110
|
-
# FIXME is $ThisFileInfo['avdataend'] == File.size ????
|
107
|
+
# FIXME: is $ThisFileInfo['avdataend'] == File.size ????
|
111
108
|
@infos['bitrate'] = ((@file.stat.size - avdataoffset) * 8) / @infos['length']
|
112
109
|
|
113
110
|
@infos['title_peak'] = @infos['raw']['title_peak']
|
114
111
|
@infos['title_peak_db'] = @infos['title_peak'].zero? ? 0 : peak_db(@infos['title_peak'])
|
115
|
-
if @infos['raw']['title_gain']
|
116
|
-
|
117
|
-
|
118
|
-
|
119
|
-
|
112
|
+
@infos['title_gain_db'] = if (@infos['raw']['title_gain']).negative?
|
113
|
+
(32_768 + @infos['raw']['title_gain']) / -100.0
|
114
|
+
else
|
115
|
+
@infos['raw']['title_gain'] / 100.0
|
116
|
+
end
|
120
117
|
|
121
|
-
@infos['album_peak'] = @infos['raw']['album_peak']
|
118
|
+
@infos['album_peak'] = @infos['raw']['album_peak']
|
122
119
|
@infos['album_peak_db'] = @infos['album_peak'].zero? ? 0 : peak_db(@infos['album_peak'])
|
123
120
|
|
124
|
-
if @infos['raw']['album_gain']
|
125
|
-
|
126
|
-
|
127
|
-
|
128
|
-
|
129
|
-
@infos['encoder_version']
|
130
|
-
|
131
|
-
|
132
|
-
|
133
|
-
|
134
|
-
|
135
|
-
|
136
|
-
|
137
|
-
|
138
|
-
|
139
|
-
|
140
|
-
|
141
|
-
|
142
|
-
|
143
|
-
|
144
|
-
|
145
|
-
|
146
|
-
|
147
|
-
|
148
|
-
=
|
149
|
-
|
121
|
+
@infos['album_gain_db'] = if (@infos['raw']['album_gain']).negative?
|
122
|
+
(32_768 + @infos['raw']['album_gain']) / -100.0
|
123
|
+
else
|
124
|
+
@infos['raw']['album_gain'] / 100.0
|
125
|
+
end
|
126
|
+
@infos['encoder_version'] = encoder_version(@infos['raw']['encoder_version'])
|
127
|
+
|
128
|
+
# #FIXME
|
129
|
+
# $ThisFileInfo['replay_gain']['track']['adjustment'] = @infos['title_gain_db'];
|
130
|
+
# $ThisFileInfo['replay_gain']['album']['adjustment'] = @infos['album_gain_db'];
|
131
|
+
# if @infos['title_peak'] > 0
|
132
|
+
# #$ThisFileInfo['replay_gain']['track']['peak'] = @infos['title_peak']
|
133
|
+
# elsif round(@infos['max_level'] * 1.18) > 0)
|
134
|
+
# // why? I don't know - see mppdec.c
|
135
|
+
# # ThisFileInfo['replay_gain']['track']['peak'] = getid3_lib::CastAsInt(round(@infos['max_level'] * 1.18));
|
136
|
+
# end
|
137
|
+
#
|
138
|
+
# if @infos['album_peak'] > 0
|
139
|
+
# #$ThisFileInfo['replay_gain']['album']['peak'] = @infos['album_peak'];
|
140
|
+
# end
|
141
|
+
#
|
142
|
+
# #ThisFileInfo['audio']['encoder'] =
|
143
|
+
# # 'SV'.@infos['stream_major_version'].'.'.@infos['stream_minor_version'].', '.@infos['encoder_version'];
|
144
|
+
# #$ThisFileInfo['audio']['encoder'] = @infos['encoder_version'];
|
145
|
+
# #$ThisFileInfo['audio']['encoder_options'] = @infos['profile'];
|
146
|
+
when SV4_6_HEADER
|
150
147
|
# this is SV4 - SV6, handle seperately
|
151
148
|
header_size = 8
|
152
|
-
|
149
|
+
when 'ID3'
|
153
150
|
@id3v2_tag = ID3v2.new
|
154
151
|
@id3v2_tag.from_io(@file)
|
155
152
|
@file.seek(@id3v2_tag.io_position)
|
156
153
|
# very dirty hack to allow parsing of mpc infos after id3v2 tag
|
157
|
-
while @file.read(1) !=
|
158
|
-
if @file.read(2) ==
|
154
|
+
while @file.read(1) != 'M'; end
|
155
|
+
if @file.read(2) == 'P+'
|
159
156
|
@file.seek(-3, IO::SEEK_CUR)
|
160
157
|
# we need to reparse the tag, since we have the beggining of the mpc file
|
161
158
|
parse_infos
|
162
159
|
else
|
163
|
-
raise(MpcInfoError,
|
160
|
+
raise(MpcInfoError, 'cannot find MPC header after id3 tag')
|
164
161
|
end
|
165
162
|
else
|
166
|
-
raise(MpcInfoError,
|
163
|
+
raise(MpcInfoError, 'cannot find MPC header')
|
167
164
|
end
|
168
165
|
end
|
169
|
-
|
166
|
+
|
170
167
|
def read8(io)
|
171
168
|
io.read(1)[0].ord
|
172
169
|
end
|
173
170
|
|
174
171
|
def read16(io)
|
175
|
-
io.read(2).
|
172
|
+
io.read(2).unpack1('v')
|
176
173
|
end
|
177
174
|
|
178
175
|
def read32(io, size = 4)
|
179
|
-
io.read(size).
|
176
|
+
io.read(size).unpack1('V')
|
180
177
|
end
|
181
178
|
|
182
|
-
def peak_db(i)
|
179
|
+
def peak_db(i)
|
183
180
|
((Math.log10(i) / Math.log10(2)) - 15) * 6
|
184
181
|
end
|
185
182
|
|
@@ -189,25 +186,24 @@ class MpcInfo
|
|
189
186
|
# EncoderVersion % 2 == 0 Beta (1.06)
|
190
187
|
# EncoderVersion % 2 == 1 Alpha (1.05a...z)
|
191
188
|
|
192
|
-
if encoderversion
|
189
|
+
if encoderversion.zero?
|
193
190
|
# very old version, not known exactly which
|
194
|
-
'Buschmann v1.7.0-v1.7.9 or Klemm v0.90-v1.05'
|
195
|
-
elsif encoderversion % 10
|
191
|
+
'Buschmann v1.7.0-v1.7.9 or Klemm v0.90-v1.05'
|
192
|
+
elsif (encoderversion % 10).zero?
|
196
193
|
# release version
|
197
|
-
|
198
|
-
elsif encoderversion
|
199
|
-
|
194
|
+
format('%.2f', encoderversion / 100.0)
|
195
|
+
elsif encoderversion.even?
|
196
|
+
format('%.2f beta', encoderversion / 100.0)
|
200
197
|
else
|
201
|
-
|
198
|
+
format('%.2f alpha', encoderversion / 100.0)
|
202
199
|
end
|
203
200
|
end
|
204
|
-
|
205
201
|
end
|
206
202
|
|
207
|
-
if __FILE__ == $
|
208
|
-
require
|
203
|
+
if __FILE__ == $PROGRAM_NAME
|
204
|
+
require 'pp'
|
209
205
|
|
210
206
|
mpcinfo = MpcInfo.new(ARGV[0])
|
211
207
|
pp mpcinfo.infos.sort
|
212
|
-
|
208
|
+
|
213
209
|
end
|