ruby-audioinfo 0.5.2 → 0.5.4
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 +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
|