vjt-ruby-audioinfo 0.1.6
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.
- data/History.txt +28 -0
- data/README.txt +44 -0
- data/Rakefile +37 -0
- data/lib/audioinfo.rb +349 -0
- data/lib/audioinfo/album.rb +151 -0
- data/lib/audioinfo/apetag.rb +53 -0
- data/lib/audioinfo/mpcinfo.rb +213 -0
- data/test/mpcinfo.rb +8 -0
- metadata +137 -0
data/History.txt
ADDED
@@ -0,0 +1,28 @@
|
|
1
|
+
=== 0.1.5 / 2009-03-29
|
2
|
+
|
3
|
+
* flac parsing more robust
|
4
|
+
* musicbrainz_infos support for flac
|
5
|
+
* quick fixes in mpcinfo for id3v2 tag parsing
|
6
|
+
|
7
|
+
=== 0.1.4 / 2008-07-04
|
8
|
+
|
9
|
+
* charset correctly set when commiting MP3 tags too
|
10
|
+
* file is written only if tags have really changed
|
11
|
+
|
12
|
+
=== 0.1.3 / 2008-07-03
|
13
|
+
|
14
|
+
* #tracknum= added
|
15
|
+
* charset correctly set when commiting Ogg tags
|
16
|
+
|
17
|
+
=== 0.1.2 / 2008-04-25
|
18
|
+
|
19
|
+
* fix on parsing of MusicBrainz tags of .wma files
|
20
|
+
|
21
|
+
=== 0.1.1 / 2008-04-17
|
22
|
+
|
23
|
+
* updated gem dependency on MP4Info >= 0.3.2, which fixes utf-8 handling
|
24
|
+
* added lib/shell_escape.rb that lacks from previous version
|
25
|
+
|
26
|
+
=== 0.1 / 2008-03-28
|
27
|
+
|
28
|
+
* first release
|
data/README.txt
ADDED
@@ -0,0 +1,44 @@
|
|
1
|
+
= ruby-audioinfo
|
2
|
+
|
3
|
+
by Guillaume Pierronnet
|
4
|
+
* http://ruby-audioinfo.rubyforge.org
|
5
|
+
* http://rubyforge.org/projects/ruby-audioinfo/
|
6
|
+
|
7
|
+
== DESCRIPTION:
|
8
|
+
|
9
|
+
ruby-audioinfo glue together various audio ruby libraries and presents a unified
|
10
|
+
API to the developper. Currently, supported formats are: mp3, ogg, mpc, ape,
|
11
|
+
wma, flac, aac, mp4, m4a.
|
12
|
+
|
13
|
+
== FEATURES/PROBLEMS:
|
14
|
+
|
15
|
+
* beta write support for mp3 and ogg tags (other to be written)
|
16
|
+
* unified support for tag text-encoding. AudioInfo.new("file", "utf-8") and you're done!
|
17
|
+
* support for MusicBrainz tags
|
18
|
+
* AudioInfo::Album class included, which gives an unified way to manage an album in a given directory.
|
19
|
+
|
20
|
+
== SYNOPSIS:
|
21
|
+
|
22
|
+
AudioInfo.open("audio_file.one_of_supported_extensions") do |info|
|
23
|
+
info.artist # or info["artist"]
|
24
|
+
info.title # or info["title"]
|
25
|
+
info.length # playing time of the file
|
26
|
+
info.bitrate # average bitrate
|
27
|
+
info.to_h # { "artist" => "artist", "title" => "title", etc... }
|
28
|
+
end
|
29
|
+
|
30
|
+
== REQUIREMENTS:
|
31
|
+
|
32
|
+
* ruby-mp3info[http://ruby-mp3info.rubyforge.org/]
|
33
|
+
* ruby-ogginfo[http://ruby-ogginfo.rubyforge.org/]
|
34
|
+
* MP4Info[http://mp4info.rubyforge.org/]
|
35
|
+
* flacinfo-rb[http://rubyforge.org/projects/flacinfo-rb/]
|
36
|
+
* wmainfo-rb[http://rubyforge.org/projects/wmainfo/]
|
37
|
+
|
38
|
+
== INSTALL:
|
39
|
+
|
40
|
+
* sudo gem install ruby-audioinfo
|
41
|
+
|
42
|
+
== LICENSE:
|
43
|
+
|
44
|
+
Ruby
|
data/Rakefile
ADDED
@@ -0,0 +1,37 @@
|
|
1
|
+
require 'rubygems'
|
2
|
+
require File.join(File.expand_path(File.dirname(__FILE__)), 'lib', 'audioinfo')
|
3
|
+
|
4
|
+
begin
|
5
|
+
require 'jeweler'
|
6
|
+
|
7
|
+
Jeweler::Tasks.new do |gemspec|
|
8
|
+
gemspec.name = 'vjt-ruby-audioinfo'
|
9
|
+
gemspec.version = AudioInfo::VERSION
|
10
|
+
gemspec.authors = ['Guillaume Pierronnet', 'Marcello Barnaba']
|
11
|
+
gemspec.email = 'moumar@rubyforge.org'
|
12
|
+
gemspec.date = '2010-03-20'
|
13
|
+
|
14
|
+
gemspec.homepage = 'http://ruby-audioinfo.rubyforge.org'
|
15
|
+
gemspec.summary = 'Unified audio info access library'
|
16
|
+
gemspec.description = 'ruby-audioinfo glues together various audio libraries and presents a single API to the developer.'
|
17
|
+
'Currently, supported formats are: mp3, ogg, mpc, ape, wma, flac, aac, mp4, m4a.'
|
18
|
+
|
19
|
+
gemspec.files = %w( README.txt Rakefile History.txt) + Dir['{lib,test}/**/*']
|
20
|
+
gemspec.extra_rdoc_files = %w( README.txt )
|
21
|
+
gemspec.has_rdoc = true
|
22
|
+
gemspec.require_path = 'lib'
|
23
|
+
|
24
|
+
gemspec.add_dependency 'ruby-mp3info', '>= 0.6.3'
|
25
|
+
gemspec.add_dependency 'ruby-ogginfo', '>= 0.3.1'
|
26
|
+
gemspec.add_dependency 'mp4info', '>= 1.7.3'
|
27
|
+
gemspec.add_dependency 'wmainfo-rb', '>= 0.5'
|
28
|
+
gemspec.add_dependency 'flacinfo-rb', '>= 0.4'
|
29
|
+
end
|
30
|
+
rescue LoadError
|
31
|
+
puts 'Jeweler not available. Install it with: gem install jeweler'
|
32
|
+
end
|
33
|
+
|
34
|
+
#task :tag_svn do
|
35
|
+
# svn_repo = "svn+ssh://rubyforge.org/var/svn/ruby-audioinfo"
|
36
|
+
# sh "svn copy -m 'tagged version #{hoe.version}' #{svn_repo}/trunk #{svn_repo}/tags/REL-#{hoe.version}"
|
37
|
+
#end
|
data/lib/audioinfo.rb
ADDED
@@ -0,0 +1,349 @@
|
|
1
|
+
require "iconv"
|
2
|
+
require "stringio"
|
3
|
+
|
4
|
+
require "mp3info"
|
5
|
+
require "ogginfo"
|
6
|
+
require "wmainfo"
|
7
|
+
require "mp4info"
|
8
|
+
require "flacinfo"
|
9
|
+
|
10
|
+
$: << File.expand_path(File.dirname(__FILE__))
|
11
|
+
|
12
|
+
require "audioinfo/mpcinfo"
|
13
|
+
require "audioinfo/apetag"
|
14
|
+
|
15
|
+
class AudioInfoError < Exception ; end
|
16
|
+
|
17
|
+
class AudioInfo
|
18
|
+
MUSICBRAINZ_FIELDS = {
|
19
|
+
"trmid" => "TRM Id",
|
20
|
+
"artistid" => "Artist Id",
|
21
|
+
"albumid" => "Album Id",
|
22
|
+
"albumtype" => "Album Type",
|
23
|
+
"albumstatus" => "Album Status",
|
24
|
+
"albumartistid" => "Album Artist Id",
|
25
|
+
"sortname" => "Sort Name",
|
26
|
+
"trackid" => "Track Id"
|
27
|
+
}
|
28
|
+
|
29
|
+
SUPPORTED_EXTENSIONS = %w{mp3 ogg mpc wma mp4 aac m4a flac}
|
30
|
+
|
31
|
+
VERSION = "0.1.6"
|
32
|
+
|
33
|
+
attr_reader :path, :extension, :musicbrainz_infos, :tracknum, :bitrate, :vbr
|
34
|
+
attr_reader :artist, :album, :title, :length, :date
|
35
|
+
|
36
|
+
# "block version" of #new()
|
37
|
+
def self.open(*args)
|
38
|
+
audio_info = self.new(*args)
|
39
|
+
ret = nil
|
40
|
+
if block_given?
|
41
|
+
begin
|
42
|
+
ret = yield(audio_info)
|
43
|
+
ensure
|
44
|
+
audio_info.close
|
45
|
+
end
|
46
|
+
else
|
47
|
+
ret = audio_info
|
48
|
+
end
|
49
|
+
ret
|
50
|
+
end
|
51
|
+
|
52
|
+
# open the file with path +fn+ and convert all tags from/to specified +encoding+
|
53
|
+
def initialize(filename, encoding = 'utf-8')
|
54
|
+
raise(AudioInfoError, "path is nil") if filename.nil?
|
55
|
+
@path = filename
|
56
|
+
ext = File.extname(@path)
|
57
|
+
raise(AudioInfoError, "cannot find extension") if ext.empty?
|
58
|
+
@extension = ext[1..-1].downcase
|
59
|
+
@musicbrainz_infos = {}
|
60
|
+
@encoding = encoding
|
61
|
+
|
62
|
+
begin
|
63
|
+
case @extension
|
64
|
+
when 'mp3'
|
65
|
+
@info = Mp3Info.new(filename, :encoding => @encoding)
|
66
|
+
default_tag_fill
|
67
|
+
#"TXXX"=>
|
68
|
+
#["MusicBrainz TRM Id\000",
|
69
|
+
#"MusicBrainz Artist Id\000aba64937-3334-4c65-90a1-4e6b9d4d7ada",
|
70
|
+
#"MusicBrainz Album Id\000e1a223c1-cbc2-427f-a192-5d22fefd7c4c",
|
71
|
+
#"MusicBrainz Album Type\000album",
|
72
|
+
#"MusicBrainz Album Status\000official",
|
73
|
+
#"MusicBrainz Album Artist Id\000"]
|
74
|
+
|
75
|
+
if (arr = @info.tag2["TXXX"]).is_a?(Array)
|
76
|
+
fields = MUSICBRAINZ_FIELDS.invert
|
77
|
+
arr.each do |val|
|
78
|
+
if val =~ /^MusicBrainz (.+)\000(.*)$/
|
79
|
+
short_name = fields[$1]
|
80
|
+
@musicbrainz_infos[short_name] = $2
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|
84
|
+
@bitrate = @info.bitrate
|
85
|
+
i = @info.tag.tracknum
|
86
|
+
@tracknum = (i.is_a?(Array) ? i.last : i).to_i
|
87
|
+
@length = @info.length.to_i
|
88
|
+
@date = @info.tag["date"]
|
89
|
+
@vbr = @info.vbr
|
90
|
+
@info.close
|
91
|
+
|
92
|
+
when 'ogg'
|
93
|
+
@info = OggInfo.new(filename, @encoding)
|
94
|
+
default_fill_musicbrainz_fields
|
95
|
+
default_tag_fill
|
96
|
+
@bitrate = @info.bitrate/1000
|
97
|
+
@tracknum = @info.tag.tracknumber.to_i
|
98
|
+
@length = @info.length.to_i
|
99
|
+
@date = @info.tag["date"]
|
100
|
+
@vbr = true
|
101
|
+
@info.close
|
102
|
+
|
103
|
+
when 'mpc'
|
104
|
+
fill_ape_tag(filename)
|
105
|
+
|
106
|
+
mpc_info = MpcInfo.new(filename)
|
107
|
+
@bitrate = mpc_info.infos['bitrate']/1000
|
108
|
+
@length = mpc_info.infos['length']
|
109
|
+
|
110
|
+
when 'ape'
|
111
|
+
fill_ape_tag(filename)
|
112
|
+
|
113
|
+
when 'wma'
|
114
|
+
@info = WmaInfo.new(filename, :encoding => @encoding)
|
115
|
+
@artist = @info.tags["Author"]
|
116
|
+
@album = @info.tags["AlbumTitle"]
|
117
|
+
@title = @info.tags["Title"]
|
118
|
+
@tracknum = @info.tags["TrackNumber"].to_i
|
119
|
+
@date = @info.tags["Year"]
|
120
|
+
@bitrate = @info.info["bitrate"]
|
121
|
+
@length = @info.info["playtime_seconds"]
|
122
|
+
MUSICBRAINZ_FIELDS.each do |key, original_key|
|
123
|
+
@musicbrainz_infos[key] =
|
124
|
+
@info.info["MusicBrainz/" + original_key.tr(" ", "")] ||
|
125
|
+
@info.info["MusicBrainz/" + original_key]
|
126
|
+
end
|
127
|
+
|
128
|
+
when 'aac', 'mp4', 'm4a'
|
129
|
+
@info = MP4Info.open(filename)
|
130
|
+
@artist = @info.ART
|
131
|
+
@album = @info.ALB
|
132
|
+
@title = @info.NAM
|
133
|
+
@tracknum = ( t = @info.TRKN ) ? t.first : 0
|
134
|
+
@date = @info.DAY
|
135
|
+
@bitrate = @info.BITRATE
|
136
|
+
@length = @info.SECS
|
137
|
+
mapping = MUSICBRAINZ_FIELDS.invert
|
138
|
+
|
139
|
+
faad_info(filename).match(/^MusicBrainz (.+)$/) do
|
140
|
+
name, value = $1.split(/: /, 2)
|
141
|
+
key = mapping[name]
|
142
|
+
@musicbrainz_infos[key] = value
|
143
|
+
end
|
144
|
+
|
145
|
+
when 'flac'
|
146
|
+
@info = FlacInfo.new(filename)
|
147
|
+
tags = convert_tags_encoding(@info.tags, "UTF-8")
|
148
|
+
@artist = tags["ARTIST"] || tags["artist"]
|
149
|
+
@album = tags["ALBUM"] || tags["album"]
|
150
|
+
@title = tags["TITLE"] || tags["title"]
|
151
|
+
@tracknum = (tags["TRACKNUMBER"]||tags["tracknumber"]).to_i
|
152
|
+
@date = tags["DATE"]||tags["date"]
|
153
|
+
@length = @info.streaminfo["total_samples"] / @info.streaminfo["samplerate"].to_f
|
154
|
+
@bitrate = File.size(filename).to_f*8/@length/1024
|
155
|
+
tags.each do |tagname, tagvalue|
|
156
|
+
next unless tagname =~ /^musicbrainz_(.+)$/
|
157
|
+
@musicbrainz_infos[$1] = tags[tagname]
|
158
|
+
end
|
159
|
+
@musicbrainz_infos["trmid"] = tags["musicip_puid"]
|
160
|
+
#default_fill_musicbrainz_fields
|
161
|
+
|
162
|
+
else
|
163
|
+
raise(AudioInfoError, "unsupported extension '.#{@extension}'")
|
164
|
+
end
|
165
|
+
|
166
|
+
if @tracknum == 0
|
167
|
+
@tracknum = nil
|
168
|
+
end
|
169
|
+
|
170
|
+
@musicbrainz_infos.delete_if { |k, v| v.nil? }
|
171
|
+
@hash = { "artist" => @artist,
|
172
|
+
"album" => @album,
|
173
|
+
"title" => @title,
|
174
|
+
"tracknum" => @tracknum,
|
175
|
+
"date" => @date,
|
176
|
+
"length" => @length,
|
177
|
+
"bitrate" => @bitrate,
|
178
|
+
}
|
179
|
+
|
180
|
+
rescue Exception, Mp3InfoError, OggInfoError, ApeTagError => e
|
181
|
+
raise AudioInfoError, e.to_s, e.backtrace
|
182
|
+
end
|
183
|
+
|
184
|
+
@needs_commit = false
|
185
|
+
|
186
|
+
end
|
187
|
+
|
188
|
+
# set the title of the file
|
189
|
+
def title=(v)
|
190
|
+
if @title != v
|
191
|
+
@needs_commit = true
|
192
|
+
@title = v
|
193
|
+
end
|
194
|
+
end
|
195
|
+
|
196
|
+
# set the artist of the file
|
197
|
+
def artist=(v)
|
198
|
+
if @artist != v
|
199
|
+
@needs_commit = true
|
200
|
+
@artist = v
|
201
|
+
end
|
202
|
+
end
|
203
|
+
|
204
|
+
# set the album of the file
|
205
|
+
def album=(v)
|
206
|
+
if @album != v
|
207
|
+
@needs_commit = true
|
208
|
+
@album = v
|
209
|
+
end
|
210
|
+
end
|
211
|
+
|
212
|
+
# set the track number of the file
|
213
|
+
def tracknum=(v)
|
214
|
+
v = v.to_i
|
215
|
+
if @tracknum != v
|
216
|
+
@needs_commit = true
|
217
|
+
@tracknum = v
|
218
|
+
end
|
219
|
+
end
|
220
|
+
|
221
|
+
# hash-like access to tag
|
222
|
+
def [](key)
|
223
|
+
@hash[key]
|
224
|
+
end
|
225
|
+
|
226
|
+
# convert tags to hash
|
227
|
+
def to_h
|
228
|
+
@hash
|
229
|
+
end
|
230
|
+
|
231
|
+
# close the file and commits changes to disk
|
232
|
+
def close
|
233
|
+
if @needs_commit
|
234
|
+
case @info
|
235
|
+
when Mp3Info
|
236
|
+
Mp3Info.open(@path, :encoding => @encoding) do |info|
|
237
|
+
info.tag.artist = @artist
|
238
|
+
info.tag.title = @title
|
239
|
+
info.tag.album = @album
|
240
|
+
info.tag.tracknum = @tracknum
|
241
|
+
end
|
242
|
+
when OggInfo
|
243
|
+
OggInfo.open(@path, @encoding) do |ogg|
|
244
|
+
{ "artist" => @artist,
|
245
|
+
"album" => @album,
|
246
|
+
"title" => @title,
|
247
|
+
"tracknumber" => @tracknum}.each do |k,v|
|
248
|
+
ogg.tag[k] = v.to_s
|
249
|
+
end
|
250
|
+
end
|
251
|
+
|
252
|
+
else
|
253
|
+
raise(AudioInfoError, "implement me")
|
254
|
+
end
|
255
|
+
|
256
|
+
end
|
257
|
+
@needs_commit
|
258
|
+
end
|
259
|
+
=begin
|
260
|
+
{"musicbrainz_albumstatus"=>"official",
|
261
|
+
"artist"=>"Jill Scott",
|
262
|
+
"replaygain_track_gain"=>"-3.29 dB",
|
263
|
+
"tracknumber"=>"1",
|
264
|
+
"title"=>"A long walk (A touch of Jazz Mix)..Jazzanova Love Beats...",
|
265
|
+
"musicbrainz_sortname"=>"Scott, Jill",
|
266
|
+
"musicbrainz_artistid"=>"b1fb6a18-1626-4011-80fb-eaf83dfebcb6",
|
267
|
+
"musicbrainz_albumid"=>"cb2ad8c7-4a02-4e46-ae9a-c7c2463c7235",
|
268
|
+
"replaygain_track_peak"=>"0.82040048",
|
269
|
+
"musicbrainz_albumtype"=>"compilation",
|
270
|
+
"album"=>"...Mixing (Jazzanova)",
|
271
|
+
"musicbrainz_trmid"=>"1ecec0a6-c7c3-4179-abea-ef12dabc7cbd",
|
272
|
+
"musicbrainz_trackid"=>"0a368e63-dddf-441f-849c-ca23f9cb2d49",
|
273
|
+
"musicbrainz_albumartistid"=>"89ad4ac3-39f7-470e-963a-56509c546377"}>
|
274
|
+
=end
|
275
|
+
|
276
|
+
# check if the file is correctly tagged by MusicBrainz
|
277
|
+
def mb_tagged?
|
278
|
+
! @musicbrainz_infos.empty?
|
279
|
+
end
|
280
|
+
|
281
|
+
private
|
282
|
+
|
283
|
+
def sanitize(input)
|
284
|
+
s = input.is_a?(Array) ? input.first : input
|
285
|
+
s.gsub("\000", "")
|
286
|
+
end
|
287
|
+
|
288
|
+
def default_fill_musicbrainz_fields
|
289
|
+
MUSICBRAINZ_FIELDS.keys.each do |field|
|
290
|
+
val = @info.tag["musicbrainz_#{field}"]
|
291
|
+
@musicbrainz_infos[field] = val if val
|
292
|
+
end
|
293
|
+
end
|
294
|
+
|
295
|
+
def default_tag_fill(tag = @info.tag)
|
296
|
+
%w{artist album title}.each do |v|
|
297
|
+
instance_variable_set( "@#{v}".to_sym, sanitize(tag[v].to_s) )
|
298
|
+
end
|
299
|
+
end
|
300
|
+
|
301
|
+
def fill_ape_tag(filename)
|
302
|
+
begin
|
303
|
+
@info = ApeTag.new(filename)
|
304
|
+
tags = convert_tags_encoding(@info.tag, "UTF-8")
|
305
|
+
default_tag_fill(tags)
|
306
|
+
default_fill_musicbrainz_fields
|
307
|
+
@date = @info.tag["year"]
|
308
|
+
@tracknum = 0
|
309
|
+
|
310
|
+
if track = @info.tag['track']
|
311
|
+
@tracknum = @info.tag['track'].split("/").first.to_i
|
312
|
+
end
|
313
|
+
rescue ApeTagError
|
314
|
+
end
|
315
|
+
end
|
316
|
+
|
317
|
+
def convert_tags_encoding(tags_orig, from_encoding)
|
318
|
+
tags = {}
|
319
|
+
Iconv.open(@encoding, from_encoding) do |ic|
|
320
|
+
tags_orig.inject(tags) do |hash, (k, v)|
|
321
|
+
if v.is_a?(String)
|
322
|
+
hash[ic.iconv(k)] = ic.iconv(v)
|
323
|
+
end
|
324
|
+
hash
|
325
|
+
end
|
326
|
+
end
|
327
|
+
tags
|
328
|
+
end
|
329
|
+
|
330
|
+
def faad_info(file)
|
331
|
+
stdout, stdout_w = IO.pipe
|
332
|
+
stderr, stderr_w = IO.pipe
|
333
|
+
|
334
|
+
fork {
|
335
|
+
stdout.close; STDOUT.reopen(stdout_w)
|
336
|
+
stderr.close; STDERR.reopen(stderr_w)
|
337
|
+
exec 'faad', '-i', file
|
338
|
+
}
|
339
|
+
|
340
|
+
stdout_w.close; stderr_w.close
|
341
|
+
pid, status = Process.wait2
|
342
|
+
|
343
|
+
out, err = stdout.read.chomp, stderr.read.chomp
|
344
|
+
stdout.close; stderr.close
|
345
|
+
|
346
|
+
# Return the stderr because faad prints info on that fd...
|
347
|
+
status.exitstatus.zero? ? err : ''
|
348
|
+
end
|
349
|
+
end
|
@@ -0,0 +1,151 @@
|
|
1
|
+
require "audioinfo"
|
2
|
+
|
3
|
+
class AudioInfo::Album
|
4
|
+
|
5
|
+
IMAGE_EXTENSIONS = %w{jpg jpeg gif png}
|
6
|
+
|
7
|
+
# a regexp to match the "multicd" suffix of a "multicd" string
|
8
|
+
# example: "toto (disc 1)" will match ' (disc 1)'
|
9
|
+
MULTICD_REGEXP = /\s*(\(|\[)?\s*(disc|cd):?-?\s*(\d+).*(\)|\])?\s*$/i
|
10
|
+
|
11
|
+
attr_reader :files, :files_on_error, :discnum, :multicd, :basename, :infos, :path
|
12
|
+
|
13
|
+
# return the list of images in the album directory, with "folder.*" in first
|
14
|
+
def self.images(path)
|
15
|
+
arr = Dir.glob( File.join(path, "*.{#{IMAGE_EXTENSIONS.join(",")}}"), File::FNM_CASEFOLD).collect do |f|
|
16
|
+
File.expand_path(f)
|
17
|
+
end
|
18
|
+
# move "folder.*" image on top of the array
|
19
|
+
if folder = arr.detect { |f| f =~ /folder\.[^.]+$/ }
|
20
|
+
arr.delete(folder)
|
21
|
+
arr.unshift(folder)
|
22
|
+
end
|
23
|
+
arr
|
24
|
+
end
|
25
|
+
|
26
|
+
# strip the "multicd" string from the given +name+
|
27
|
+
def self.basename(name)
|
28
|
+
name.sub(MULTICD_REGEXP, '')
|
29
|
+
end
|
30
|
+
|
31
|
+
# return the number of the disc in the box or 0
|
32
|
+
def self.discnum(name)
|
33
|
+
if name =~ MULTICD_REGEXP
|
34
|
+
$3.to_i
|
35
|
+
else
|
36
|
+
0
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
# open the Album with +path+. +fast_lookup+ will only check
|
41
|
+
# first and last file of the directory
|
42
|
+
def initialize(path, fast_lookup = false)
|
43
|
+
@path = path
|
44
|
+
@multicd = false
|
45
|
+
@basename = @path
|
46
|
+
exts = AudioInfo::SUPPORTED_EXTENSIONS.join(",")
|
47
|
+
|
48
|
+
# need to escape the glob path
|
49
|
+
glob_escaped_path = @path.gsub(/([{}?*\[\]])/) { |s| '\\' << s }
|
50
|
+
|
51
|
+
file_names = Dir.glob( File.join(glob_escaped_path, "*.{#{exts}}") , File::FNM_CASEFOLD).sort
|
52
|
+
|
53
|
+
if fast_lookup
|
54
|
+
file_names = [file_names.first, file_names.last]
|
55
|
+
end
|
56
|
+
|
57
|
+
@files_on_error = []
|
58
|
+
|
59
|
+
@files = file_names.collect do |f|
|
60
|
+
begin
|
61
|
+
AudioInfo.new(f)
|
62
|
+
rescue AudioInfoError
|
63
|
+
@files_on_error << f
|
64
|
+
nil
|
65
|
+
end
|
66
|
+
end.compact
|
67
|
+
|
68
|
+
if @files_on_error.empty?
|
69
|
+
@files_on_error = nil
|
70
|
+
end
|
71
|
+
|
72
|
+
@infos = {}
|
73
|
+
@infos["album"] = @files.collect { |i| i.album }.uniq
|
74
|
+
@infos["album"] = @infos["album"].first if @infos["album"].size == 1
|
75
|
+
artists = @files.collect { |i| i.artist }.uniq
|
76
|
+
@infos["artist"] = artists.size > 1 ? "various" : artists.first
|
77
|
+
@discnum = self.class.discnum(@infos["album"])
|
78
|
+
|
79
|
+
if not @discnum.zero?
|
80
|
+
@multicd = true
|
81
|
+
@basename = self.class.basename(@infos["album"])
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
# is the album empty?
|
86
|
+
def empty?
|
87
|
+
@files.empty?
|
88
|
+
end
|
89
|
+
|
90
|
+
# are all the files of the album MusicBrainz tagged ?
|
91
|
+
def mb_tagged?
|
92
|
+
return false if @files.empty?
|
93
|
+
mb = true
|
94
|
+
@files.each do |f|
|
95
|
+
mb &&= f.mb_tagged?
|
96
|
+
end
|
97
|
+
mb
|
98
|
+
end
|
99
|
+
|
100
|
+
# return an array of images with "folder.*" in first
|
101
|
+
def images
|
102
|
+
self.class.images(@path)
|
103
|
+
end
|
104
|
+
|
105
|
+
# title of the album
|
106
|
+
def title
|
107
|
+
albums = @files.collect { |f| f.album }.uniq
|
108
|
+
#if albums.size > 1
|
109
|
+
# "#{albums.first} others candidates: '" + albums[1..-1].join("', '") + "'"
|
110
|
+
#else
|
111
|
+
albums.first
|
112
|
+
#end
|
113
|
+
end
|
114
|
+
|
115
|
+
# mbid (MusicBrainz ID) of the album
|
116
|
+
def mbid
|
117
|
+
return nil unless mb_tagged?
|
118
|
+
@files.collect { |f| f.musicbrainz_infos["albumid"] }.uniq.first
|
119
|
+
end
|
120
|
+
|
121
|
+
# is the album multi-artist?
|
122
|
+
def va?
|
123
|
+
@files.collect { |f| f.artist }.uniq.size > 1
|
124
|
+
end
|
125
|
+
|
126
|
+
# pretty print
|
127
|
+
def to_s
|
128
|
+
out = StringIO.new
|
129
|
+
out.puts(@path)
|
130
|
+
out.print "'#{title}'"
|
131
|
+
|
132
|
+
unless va?
|
133
|
+
out.print " by '#{@files.first.artist}' "
|
134
|
+
end
|
135
|
+
|
136
|
+
out.puts
|
137
|
+
|
138
|
+
@files.sort_by { |f| f.tracknum }.each do |f|
|
139
|
+
out.printf("%02d %s %3d %s", f.tracknum, f.extension, f.bitrate, f.title)
|
140
|
+
if va?
|
141
|
+
out.print(" "+f.artist)
|
142
|
+
end
|
143
|
+
out.puts
|
144
|
+
end
|
145
|
+
|
146
|
+
out.string
|
147
|
+
end
|
148
|
+
|
149
|
+
end
|
150
|
+
|
151
|
+
|
@@ -0,0 +1,53 @@
|
|
1
|
+
# see http://www.personal.uni-jena.de/~pfk/mpp/sv8/apetag.html for specs
|
2
|
+
|
3
|
+
class ApeTagError < StandardError ; end
|
4
|
+
|
5
|
+
class ApeTag
|
6
|
+
attr_reader :tag, :version
|
7
|
+
|
8
|
+
def initialize(filename)
|
9
|
+
@tag = {}
|
10
|
+
|
11
|
+
begin
|
12
|
+
@file = File.new(filename, "rb")
|
13
|
+
@file.seek(-32, IO::SEEK_END)
|
14
|
+
|
15
|
+
preamble, version, tagsize, itemcount, flags =
|
16
|
+
@file.read(24).unpack("A8VVVV")
|
17
|
+
@version = version/1000
|
18
|
+
|
19
|
+
raise(ApeTagError, "cannot find preamble") if preamble != 'APETAGEX'
|
20
|
+
@file.seek(-tagsize, IO::SEEK_END)
|
21
|
+
itemcount.times do |i|
|
22
|
+
len, flags = @file.read(8).unpack("VV")
|
23
|
+
key = ""
|
24
|
+
loop do
|
25
|
+
c = @file.getc
|
26
|
+
break if c == 0
|
27
|
+
key << c
|
28
|
+
end
|
29
|
+
#ugly FIX
|
30
|
+
@tag[key.downcase] = @file.read(len) unless len > 100_000
|
31
|
+
end
|
32
|
+
ensure
|
33
|
+
@file.close
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
if $0 == __FILE__
|
39
|
+
while filename = ARGV.shift
|
40
|
+
puts "Getting info from #{filename}"
|
41
|
+
begin
|
42
|
+
ape = ApeTag.new(filename)
|
43
|
+
rescue ApeTagError
|
44
|
+
puts "error: doesn't appear to be an ape tagged file"
|
45
|
+
else
|
46
|
+
puts ape
|
47
|
+
ape.tag.each do |key, value|
|
48
|
+
puts "#{key} => #{value}"
|
49
|
+
end
|
50
|
+
end
|
51
|
+
puts
|
52
|
+
end
|
53
|
+
end
|
@@ -0,0 +1,213 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require "stringio"
|
4
|
+
require "mp3info/id3v2"
|
5
|
+
|
6
|
+
class MpcInfoError < StandardError; end
|
7
|
+
|
8
|
+
class MpcInfo
|
9
|
+
|
10
|
+
PROFILES_NAMES = [
|
11
|
+
'no profile',
|
12
|
+
'Experimental',
|
13
|
+
'unused',
|
14
|
+
'unused',
|
15
|
+
'unused',
|
16
|
+
'below Telephone (q = 0.0)',
|
17
|
+
'below Telephone (q = 1.0)',
|
18
|
+
'Telephone (q = 2.0)',
|
19
|
+
'Thumb (q = 3.0)',
|
20
|
+
'Radio (q = 4.0)',
|
21
|
+
'Standard (q = 5.0)',
|
22
|
+
'Extreme (q = 6.0)',
|
23
|
+
'Insane (q = 7.0)',
|
24
|
+
'BrainDead (q = 8.0)',
|
25
|
+
'above BrainDead (q = 9.0)',
|
26
|
+
'above BrainDead (q = 10.0)'
|
27
|
+
]
|
28
|
+
|
29
|
+
FREQUENCIES = [ 44100, 48000, 37800, 32000 ]
|
30
|
+
|
31
|
+
SV4_6_HEADER = Regexp.new('^[\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]/', nil, 'n')
|
32
|
+
|
33
|
+
attr_reader :infos
|
34
|
+
attr_reader :id3v2_tag
|
35
|
+
|
36
|
+
def initialize(filename)
|
37
|
+
@file = File.open(filename, "rb")
|
38
|
+
|
39
|
+
@infos = {}
|
40
|
+
@infos['raw'] = {}
|
41
|
+
parse_infos
|
42
|
+
end
|
43
|
+
|
44
|
+
private
|
45
|
+
|
46
|
+
def parse_infos
|
47
|
+
mpc_header = @file.read(3)
|
48
|
+
|
49
|
+
if mpc_header == "MP+"
|
50
|
+
# this is SV7+
|
51
|
+
|
52
|
+
header = StringIO.new(@file.read(25))
|
53
|
+
header_size = 28
|
54
|
+
#stream_version_byte = header.read(4).unpack("V").first
|
55
|
+
stream_version_byte = header.read(1)[0] #.unpack("c").first
|
56
|
+
|
57
|
+
@infos['stream_major_version'] = (stream_version_byte & 0x0F)
|
58
|
+
@infos['stream_minor_version'] = (stream_version_byte & 0xF0) >> 4
|
59
|
+
|
60
|
+
@infos['frame_count'] = read32(header)
|
61
|
+
if @infos['stream_major_version'] != 7
|
62
|
+
raise(MpcInfoError, "Only Musepack SV7 supported")
|
63
|
+
end
|
64
|
+
|
65
|
+
flags_dword1 = read32(header)
|
66
|
+
|
67
|
+
@infos['intensity_stereo'] = ((flags_dword1 & 0x80000000) >> 31) == 1
|
68
|
+
@infos['mid_side_stereo'] = ((flags_dword1 & 0x40000000) >> 30) == 1
|
69
|
+
@infos['max_subband'] = (flags_dword1 & 0x3F000000) >> 24
|
70
|
+
@infos['raw']['profile'] = (flags_dword1 & 0x00F00000) >> 20
|
71
|
+
@infos['begin_loud'] = ((flags_dword1 & 0x00080000) >> 19) == 1
|
72
|
+
@infos['end_loud'] = ((flags_dword1 & 0x00040000) >> 18) == 1
|
73
|
+
@infos['raw']['sample_rate'] = (flags_dword1 & 0x00030000) >> 16
|
74
|
+
@infos['max_level'] = (flags_dword1 & 0x0000FFFF)
|
75
|
+
|
76
|
+
@infos['raw']['title_peak'] = read16(header)
|
77
|
+
@infos['raw']['title_gain'] = read16(header)
|
78
|
+
|
79
|
+
@infos['raw']['album_peak'] = read16(header)
|
80
|
+
@infos['raw']['album_gain'] = read16(header)
|
81
|
+
|
82
|
+
flags_dword2 = read32(header)
|
83
|
+
@infos['true_gapless'] = ((flags_dword2 & 0x80000000) >> 31) == 1
|
84
|
+
@infos['last_frame_length'] = (flags_dword2 & 0x7FF00000) >> 20
|
85
|
+
|
86
|
+
|
87
|
+
not_sure_what = read32(header, 3)
|
88
|
+
@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
|
+
|
93
|
+
if @infos['sample_rate'] == 0
|
94
|
+
raise(MpcInfoError, 'Corrupt MPC file: frequency == zero')
|
95
|
+
end
|
96
|
+
|
97
|
+
sample_rate = @infos['sample_rate'];
|
98
|
+
channels = 2 #appears to be hardcoded
|
99
|
+
@infos['samples'] = (((@infos['frame_count'] - 1) * 1152) + @infos['last_frame_length']) * channels
|
100
|
+
@infos['length'] = (((@infos['frame_count'] - 1) * 1152) + @infos['last_frame_length']) * channels
|
101
|
+
|
102
|
+
@infos['length'] = (@infos['samples'] / channels) / @infos['sample_rate'].to_f
|
103
|
+
if @infos['length'] == 0
|
104
|
+
raise(MpcInfoError, 'Corrupt MPC file: playtime_seconds == zero')
|
105
|
+
end
|
106
|
+
|
107
|
+
# add size of file header to avdataoffset - calc bitrate correctly + MD5 data
|
108
|
+
avdataoffset = header_size
|
109
|
+
|
110
|
+
# FIXME is $ThisFileInfo['avdataend'] == File.size ????
|
111
|
+
@infos['bitrate'] = ((@file.stat.size - avdataoffset) * 8) / @infos['length']
|
112
|
+
|
113
|
+
@infos['title_peak'] = @infos['raw']['title_peak']
|
114
|
+
@infos['title_peak_db'] = @infos['title_peak'].zero? ? 0 : peak_db(@infos['title_peak'])
|
115
|
+
if @infos['raw']['title_gain'] < 0
|
116
|
+
@infos['title_gain_db'] = (32768 + @infos['raw']['title_gain']) / -100.0
|
117
|
+
else
|
118
|
+
@infos['title_gain_db'] = @infos['raw']['title_gain'] / 100.0
|
119
|
+
end
|
120
|
+
|
121
|
+
@infos['album_peak'] = @infos['raw']['album_peak'];
|
122
|
+
@infos['album_peak_db'] = @infos['album_peak'].zero? ? 0 : peak_db(@infos['album_peak'])
|
123
|
+
|
124
|
+
if @infos['raw']['album_gain'] < 0
|
125
|
+
@infos['album_gain_db'] = (32768 + @infos['raw']['album_gain']) / -100.0
|
126
|
+
else
|
127
|
+
@infos['album_gain_db'] = @infos['raw']['album_gain'] / 100.0
|
128
|
+
end
|
129
|
+
@infos['encoder_version'] = encoder_version(@infos['raw']['encoder_version'])
|
130
|
+
|
131
|
+
=begin
|
132
|
+
#FIXME
|
133
|
+
$ThisFileInfo['replay_gain']['track']['adjustment'] = @infos['title_gain_db'];
|
134
|
+
$ThisFileInfo['replay_gain']['album']['adjustment'] = @infos['album_gain_db'];
|
135
|
+
if @infos['title_peak'] > 0
|
136
|
+
#$ThisFileInfo['replay_gain']['track']['peak'] = @infos['title_peak']
|
137
|
+
elsif round(@infos['max_level'] * 1.18) > 0)
|
138
|
+
# ThisFileInfo['replay_gain']['track']['peak'] = getid3_lib::CastAsInt(round(@infos['max_level'] * 1.18)); // why? I don't know - see mppdec.c
|
139
|
+
end
|
140
|
+
|
141
|
+
if @infos['album_peak'] > 0
|
142
|
+
#$ThisFileInfo['replay_gain']['album']['peak'] = @infos['album_peak'];
|
143
|
+
end
|
144
|
+
|
145
|
+
#ThisFileInfo['audio']['encoder'] = 'SV'.@infos['stream_major_version'].'.'.@infos['stream_minor_version'].', '.@infos['encoder_version'];
|
146
|
+
#$ThisFileInfo['audio']['encoder'] = @infos['encoder_version'];
|
147
|
+
#$ThisFileInfo['audio']['encoder_options'] = @infos['profile'];
|
148
|
+
=end
|
149
|
+
elsif mpc_header =~ SV4_6_HEADER
|
150
|
+
# this is SV4 - SV6, handle seperately
|
151
|
+
header_size = 8
|
152
|
+
elsif mpc_header == "ID3"
|
153
|
+
@id3v2_tag = ID3v2.new
|
154
|
+
@id3v2_tag.from_io(@file)
|
155
|
+
@file.seek(@id3v2_tag.io_position)
|
156
|
+
# very dirty hack to allow parsing of mpc infos after id3v2 tag
|
157
|
+
while @file.read(1) != "M"; end
|
158
|
+
if @file.read(2) == "P+"
|
159
|
+
@file.seek(-3, IO::SEEK_CUR)
|
160
|
+
# we need to reparse the tag, since we have the beggining of the mpc file
|
161
|
+
parse_infos
|
162
|
+
else
|
163
|
+
raise(MpcInfoError, "cannot find MPC header after id3 tag")
|
164
|
+
end
|
165
|
+
else
|
166
|
+
raise(MpcInfoError, "cannot find MPC header")
|
167
|
+
end
|
168
|
+
end
|
169
|
+
|
170
|
+
def read8(io)
|
171
|
+
io.read(1)[0]
|
172
|
+
end
|
173
|
+
|
174
|
+
def read16(io)
|
175
|
+
io.read(2).unpack("v").first
|
176
|
+
end
|
177
|
+
|
178
|
+
def read32(io, size = 4)
|
179
|
+
io.read(size).unpack("V").first
|
180
|
+
end
|
181
|
+
|
182
|
+
def peak_db(i)
|
183
|
+
((Math.log10(i) / Math.log10(2)) - 15) * 6
|
184
|
+
end
|
185
|
+
|
186
|
+
def encoder_version(encoderversion)
|
187
|
+
# Encoder version * 100 (106 = 1.06)
|
188
|
+
# EncoderVersion % 10 == 0 Release (1.0)
|
189
|
+
# EncoderVersion % 2 == 0 Beta (1.06)
|
190
|
+
# EncoderVersion % 2 == 1 Alpha (1.05a...z)
|
191
|
+
|
192
|
+
if encoderversion == 0
|
193
|
+
# 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 == 0
|
196
|
+
# release version
|
197
|
+
sprintf("%.2f", encoderversion/100.0)
|
198
|
+
elsif encoderversion % 2 == 0
|
199
|
+
sprintf("%.2f beta", encoderversion / 100.0)
|
200
|
+
else
|
201
|
+
sprintf("%.2f alpha", encoderversion/100.0)
|
202
|
+
end
|
203
|
+
end
|
204
|
+
|
205
|
+
end
|
206
|
+
|
207
|
+
if __FILE__ == $0
|
208
|
+
require "pp"
|
209
|
+
|
210
|
+
mpcinfo = MpcInfo.new(ARGV[0])
|
211
|
+
pp mpcinfo.infos.sort
|
212
|
+
|
213
|
+
end
|
data/test/mpcinfo.rb
ADDED
metadata
ADDED
@@ -0,0 +1,137 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: vjt-ruby-audioinfo
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
prerelease: false
|
5
|
+
segments:
|
6
|
+
- 0
|
7
|
+
- 1
|
8
|
+
- 6
|
9
|
+
version: 0.1.6
|
10
|
+
platform: ruby
|
11
|
+
authors:
|
12
|
+
- Guillaume Pierronnet
|
13
|
+
- Marcello Barnaba
|
14
|
+
autorequire:
|
15
|
+
bindir: bin
|
16
|
+
cert_chain: []
|
17
|
+
|
18
|
+
date: 2010-03-20 00:00:00 +01:00
|
19
|
+
default_executable:
|
20
|
+
dependencies:
|
21
|
+
- !ruby/object:Gem::Dependency
|
22
|
+
name: ruby-mp3info
|
23
|
+
prerelease: false
|
24
|
+
requirement: &id001 !ruby/object:Gem::Requirement
|
25
|
+
requirements:
|
26
|
+
- - ">="
|
27
|
+
- !ruby/object:Gem::Version
|
28
|
+
segments:
|
29
|
+
- 0
|
30
|
+
- 6
|
31
|
+
- 3
|
32
|
+
version: 0.6.3
|
33
|
+
type: :runtime
|
34
|
+
version_requirements: *id001
|
35
|
+
- !ruby/object:Gem::Dependency
|
36
|
+
name: ruby-ogginfo
|
37
|
+
prerelease: false
|
38
|
+
requirement: &id002 !ruby/object:Gem::Requirement
|
39
|
+
requirements:
|
40
|
+
- - ">="
|
41
|
+
- !ruby/object:Gem::Version
|
42
|
+
segments:
|
43
|
+
- 0
|
44
|
+
- 3
|
45
|
+
- 1
|
46
|
+
version: 0.3.1
|
47
|
+
type: :runtime
|
48
|
+
version_requirements: *id002
|
49
|
+
- !ruby/object:Gem::Dependency
|
50
|
+
name: mp4info
|
51
|
+
prerelease: false
|
52
|
+
requirement: &id003 !ruby/object:Gem::Requirement
|
53
|
+
requirements:
|
54
|
+
- - ">="
|
55
|
+
- !ruby/object:Gem::Version
|
56
|
+
segments:
|
57
|
+
- 1
|
58
|
+
- 7
|
59
|
+
- 3
|
60
|
+
version: 1.7.3
|
61
|
+
type: :runtime
|
62
|
+
version_requirements: *id003
|
63
|
+
- !ruby/object:Gem::Dependency
|
64
|
+
name: wmainfo-rb
|
65
|
+
prerelease: false
|
66
|
+
requirement: &id004 !ruby/object:Gem::Requirement
|
67
|
+
requirements:
|
68
|
+
- - ">="
|
69
|
+
- !ruby/object:Gem::Version
|
70
|
+
segments:
|
71
|
+
- 0
|
72
|
+
- 5
|
73
|
+
version: "0.5"
|
74
|
+
type: :runtime
|
75
|
+
version_requirements: *id004
|
76
|
+
- !ruby/object:Gem::Dependency
|
77
|
+
name: flacinfo-rb
|
78
|
+
prerelease: false
|
79
|
+
requirement: &id005 !ruby/object:Gem::Requirement
|
80
|
+
requirements:
|
81
|
+
- - ">="
|
82
|
+
- !ruby/object:Gem::Version
|
83
|
+
segments:
|
84
|
+
- 0
|
85
|
+
- 4
|
86
|
+
version: "0.4"
|
87
|
+
type: :runtime
|
88
|
+
version_requirements: *id005
|
89
|
+
description: ruby-audioinfo glues together various audio libraries and presents a single API to the developer.
|
90
|
+
email: moumar@rubyforge.org
|
91
|
+
executables: []
|
92
|
+
|
93
|
+
extensions: []
|
94
|
+
|
95
|
+
extra_rdoc_files:
|
96
|
+
- README.txt
|
97
|
+
files:
|
98
|
+
- History.txt
|
99
|
+
- README.txt
|
100
|
+
- Rakefile
|
101
|
+
- lib/audioinfo.rb
|
102
|
+
- lib/audioinfo/album.rb
|
103
|
+
- lib/audioinfo/apetag.rb
|
104
|
+
- lib/audioinfo/mpcinfo.rb
|
105
|
+
- test/mpcinfo.rb
|
106
|
+
has_rdoc: true
|
107
|
+
homepage: http://ruby-audioinfo.rubyforge.org
|
108
|
+
licenses: []
|
109
|
+
|
110
|
+
post_install_message:
|
111
|
+
rdoc_options:
|
112
|
+
- --charset=UTF-8
|
113
|
+
require_paths:
|
114
|
+
- lib
|
115
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
116
|
+
requirements:
|
117
|
+
- - ">="
|
118
|
+
- !ruby/object:Gem::Version
|
119
|
+
segments:
|
120
|
+
- 0
|
121
|
+
version: "0"
|
122
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
123
|
+
requirements:
|
124
|
+
- - ">="
|
125
|
+
- !ruby/object:Gem::Version
|
126
|
+
segments:
|
127
|
+
- 0
|
128
|
+
version: "0"
|
129
|
+
requirements: []
|
130
|
+
|
131
|
+
rubyforge_project:
|
132
|
+
rubygems_version: 1.3.6
|
133
|
+
signing_key:
|
134
|
+
specification_version: 3
|
135
|
+
summary: Unified audio info access library
|
136
|
+
test_files:
|
137
|
+
- test/mpcinfo.rb
|