vjt-ruby-audioinfo 0.1.6
Sign up to get free protection for your applications and to get access to all the features.
- 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
|