974 lines
36 KiB
Python
974 lines
36 KiB
Python
#!/usr/bin/python
|
|
|
|
#Audio Tools, a module and set of tools for manipulating audio data
|
|
#Copyright (C) 2007-2011 Brian Langenberger
|
|
|
|
#This program is free software; you can redistribute it and/or modify
|
|
#it under the terms of the GNU General Public License as published by
|
|
#the Free Software Foundation; either version 2 of the License, or
|
|
#(at your option) any later version.
|
|
|
|
#This program is distributed in the hope that it will be useful,
|
|
#but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
#MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
#GNU General Public License for more details.
|
|
|
|
#You should have received a copy of the GNU General Public License
|
|
#along with this program; if not, write to the Free Software
|
|
#Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
|
|
|
|
|
|
from audiotools import (AudioFile, InvalidFile, PCMReader, PCMConverter,
|
|
Con, transfer_data, transfer_framelist_data,
|
|
subprocess, BIN, BIG_ENDIAN, ApeTag, ReplayGain,
|
|
ignore_sigint, open_files, EncodingError,
|
|
DecodingError, PCMReaderError, ChannelMask,
|
|
__default_quality__, config)
|
|
from __id3__ import *
|
|
import gettext
|
|
|
|
gettext.install("audiotools", unicode=True)
|
|
|
|
|
|
#######################
|
|
#MP3
|
|
#######################
|
|
|
|
|
|
class MPEG_Frame_Header(Con.Adapter):
|
|
#mpeg_version->sample_rate bits->Hz
|
|
SAMPLE_RATE = [[11025, 12000, 8000, None],
|
|
[None, None, None, None],
|
|
[22050, 24000, 16000, None],
|
|
[44100, 48000, 32000, None]]
|
|
|
|
#(mpeg_version, layer)->bitrate bits->bits per second
|
|
BIT_RATE = {(3, 3): [0, 32000, 64000, 96000,
|
|
128000, 160000, 192000, 224000,
|
|
256000, 288000, 320000, 352000,
|
|
384000, 416000, 448000, None],
|
|
(3, 2): [0, 32000, 48000, 56000,
|
|
64000, 80000, 96000, 112000,
|
|
128000, 160000, 192000, 224000,
|
|
256000, 320000, 384000, None],
|
|
(3, 1): [0, 32000, 40000, 48000,
|
|
56000, 64000, 80000, 96000,
|
|
112000, 128000, 160000, 192000,
|
|
224000, 256000, 320000, None],
|
|
(2, 3): [0, 32000, 48000, 56000,
|
|
64000, 80000, 96000, 112000,
|
|
128000, 144000, 160000, 176000,
|
|
192000, 224000, 256000, None],
|
|
(2, 2): [0, 8000, 16000, 24000,
|
|
32000, 40000, 48000, 56000,
|
|
64000, 80000, 96000, 112000,
|
|
128000, 144000, 160000, None]}
|
|
|
|
#mpeg_version->Hz->sample_rate bits
|
|
SAMPLE_RATE_REVERSE = {0: {11025: 0,
|
|
12000: 1,
|
|
8000: 2},
|
|
1: {None: 0},
|
|
2: {22050: 0,
|
|
24000: 1,
|
|
16000: 2,
|
|
None: 3},
|
|
3: {44100: 0,
|
|
48000: 1,
|
|
32000: 2,
|
|
None: 3}}
|
|
|
|
BIT_RATE_REVERSE = dict([(key, dict([(rate, i) for (i, rate) in
|
|
enumerate(values)]))
|
|
for (key, values) in BIT_RATE.items()])
|
|
|
|
def __init__(self, name):
|
|
Con.Adapter.__init__(
|
|
self,
|
|
Con.BitStruct("mp3_header",
|
|
Con.Const(Con.Bits("sync", 11), 0x7FF),
|
|
Con.Bits("mpeg_version", 2),
|
|
Con.Bits("layer", 2),
|
|
Con.Flag("no_crc16", 1),
|
|
Con.Bits("bitrate", 4),
|
|
Con.Bits("sample_rate", 2),
|
|
Con.Bits("pad", 1),
|
|
Con.Bits("private", 1),
|
|
Con.Bits("channel", 2),
|
|
Con.Bits("mode_extension", 2),
|
|
Con.Flag("copyright", 1),
|
|
Con.Flag("original", 1),
|
|
Con.Bits("emphasis", 2)))
|
|
|
|
def _encode(self, obj, content):
|
|
obj.sample_rate = self.SAMPLE_RATE_REVERSE[obj.mpeg_version][
|
|
obj.sample_rate]
|
|
obj.bitrate = self.BIT_RATE_REVERSE[(obj.mpeg_version, obj.layer)][
|
|
obj.bitrate]
|
|
return obj
|
|
|
|
def _decode(self, obj, content):
|
|
obj.sample_rate = self.SAMPLE_RATE[obj.mpeg_version][obj.sample_rate]
|
|
obj.channel_count = [2, 2, 2, 1][obj.channel]
|
|
obj.bitrate = self.BIT_RATE[(obj.mpeg_version, obj.layer)][obj.bitrate]
|
|
|
|
if (obj.layer == 3):
|
|
obj.byte_length = (((12 * obj.bitrate) / obj.sample_rate) +
|
|
obj.pad) * 4
|
|
else:
|
|
obj.byte_length = ((144 * obj.bitrate) / obj.sample_rate) + obj.pad
|
|
|
|
return obj
|
|
|
|
|
|
def MPEG_crc16(data, total_bits):
|
|
def crc16_val(value, crc, total_bits):
|
|
value <<= 8
|
|
for i in xrange(total_bits):
|
|
value <<= 1
|
|
crc <<= 1
|
|
|
|
if (((crc ^ value) & 0x10000)):
|
|
crc ^= 0x8005
|
|
|
|
return crc & 0xFFFF
|
|
|
|
checksum = 0xFFFF
|
|
data = map(ord, data)
|
|
while (total_bits >= 8):
|
|
checksum = crc16_val(data.pop(0), checksum, 8)
|
|
total_bits -= 8
|
|
|
|
if (total_bits > 0):
|
|
return crc16_val(data.pop(0), checksum, total_bits)
|
|
else:
|
|
return checksum
|
|
|
|
|
|
class InvalidMP3(InvalidFile):
|
|
"""Raised by invalid files during MP3 initialization."""
|
|
|
|
pass
|
|
|
|
|
|
class MP3Audio(AudioFile):
|
|
"""An MP3 audio file."""
|
|
|
|
SUFFIX = "mp3"
|
|
NAME = SUFFIX
|
|
DEFAULT_COMPRESSION = "2"
|
|
#0 is better quality/lower compression
|
|
#9 is worse quality/higher compression
|
|
COMPRESSION_MODES = ("0", "1", "2", "3", "4", "5", "6",
|
|
"medium", "standard", "extreme", "insane")
|
|
COMPRESSION_DESCRIPTIONS = {"0": _(u"high quality, larger files, " +
|
|
u"corresponds to lame's -V0"),
|
|
"6": _(u"lower quality, smaller files, " +
|
|
u"corresponds to lame's -V6"),
|
|
"medium": _(u"corresponds to lame's " +
|
|
u"--preset medium"),
|
|
"standard": _(u"corresponds to lame's " +
|
|
u"--preset standard"),
|
|
"extreme": _(u"corresponds to lame's " +
|
|
u"--preset extreme"),
|
|
"insane": _(u"corresponds to lame's " +
|
|
u"--preset insane")}
|
|
BINARIES = ("lame",)
|
|
REPLAYGAIN_BINARIES = ("mp3gain", )
|
|
|
|
#MPEG1, Layer 1
|
|
#MPEG1, Layer 2,
|
|
#MPEG1, Layer 3,
|
|
#MPEG2, Layer 1,
|
|
#MPEG2, Layer 2,
|
|
#MPEG2, Layer 3
|
|
MP3_BITRATE = ((None, None, None, None, None, None),
|
|
(32, 32, 32, 32, 8, 8),
|
|
(64, 48, 40, 48, 16, 16),
|
|
(96, 56, 48, 56, 24, 24),
|
|
(128, 64, 56, 64, 32, 32),
|
|
(160, 80, 64, 80, 40, 40),
|
|
(192, 96, 80, 96, 48, 48),
|
|
(224, 112, 96, 112, 56, 56),
|
|
(256, 128, 112, 128, 64, 64),
|
|
(288, 160, 128, 144, 80, 80),
|
|
(320, 192, 160, 160, 96, 96),
|
|
(352, 224, 192, 176, 112, 112),
|
|
(384, 256, 224, 192, 128, 128),
|
|
(416, 320, 256, 224, 144, 144),
|
|
(448, 384, 320, 256, 160, 160))
|
|
|
|
#MPEG1, MPEG2, MPEG2.5
|
|
MP3_SAMPLERATE = ((44100, 22050, 11025),
|
|
(48000, 24000, 12000),
|
|
(32000, 16000, 8000))
|
|
|
|
MP3_FRAME_HEADER = Con.BitStruct("mp3_header",
|
|
Con.Const(Con.Bits("sync", 11), 0x7FF),
|
|
Con.Bits("mpeg_version", 2),
|
|
Con.Bits("layer", 2),
|
|
Con.Flag("protection", 1),
|
|
Con.Bits("bitrate", 4),
|
|
Con.Bits("sampling_rate", 2),
|
|
Con.Bits("padding", 1),
|
|
Con.Bits("private", 1),
|
|
Con.Bits("channel", 2),
|
|
Con.Bits("mode_extension", 2),
|
|
Con.Flag("copyright", 1),
|
|
Con.Flag("original", 1),
|
|
Con.Bits("emphasis", 2))
|
|
|
|
XING_HEADER = Con.Struct("xing_header",
|
|
Con.Bytes("header_id", 4),
|
|
Con.Bytes("flags", 4),
|
|
Con.UBInt32("num_frames"),
|
|
Con.UBInt32("bytes"),
|
|
Con.StrictRepeater(100, Con.Byte("toc_entries")),
|
|
Con.UBInt32("quality"))
|
|
|
|
def __init__(self, filename):
|
|
"""filename is a plain string."""
|
|
|
|
AudioFile.__init__(self, filename)
|
|
|
|
try:
|
|
mp3file = file(filename, "rb")
|
|
except IOError, msg:
|
|
raise InvalidMP3(str(msg))
|
|
|
|
try:
|
|
try:
|
|
MP3Audio.__find_next_mp3_frame__(mp3file)
|
|
except ValueError:
|
|
raise InvalidMP3(_(u"MP3 frame not found"))
|
|
fr = MP3Audio.MP3_FRAME_HEADER.parse(mp3file.read(4))
|
|
self.__samplerate__ = MP3Audio.__get_mp3_frame_sample_rate__(fr)
|
|
self.__channels__ = MP3Audio.__get_mp3_frame_channels__(fr)
|
|
self.__framelength__ = self.__length__()
|
|
finally:
|
|
mp3file.close()
|
|
|
|
@classmethod
|
|
def is_type(cls, file):
|
|
"""Returns True if the given file object describes this format.
|
|
|
|
Takes a seekable file pointer rewound to the start of the file."""
|
|
|
|
ID3v2Comment.skip(file)
|
|
|
|
try:
|
|
frame = cls.MP3_FRAME_HEADER.parse_stream(file)
|
|
if ((frame.sync == 0x07FF) and
|
|
(frame.mpeg_version in (0x03, 0x02, 0x00)) and
|
|
(frame.layer in (0x01, 0x03))):
|
|
return True
|
|
else:
|
|
#oddly, MP3s sometimes turn up in RIFF containers
|
|
#this isn't a good idea, but can be supported nonetheless
|
|
file.seek(-cls.MP3_FRAME_HEADER.sizeof(), 1)
|
|
header = file.read(12)
|
|
if ((header[0:4] == 'RIFF') and
|
|
(header[8:12] == 'RMP3')):
|
|
return True
|
|
else:
|
|
return False
|
|
except:
|
|
return False
|
|
|
|
def lossless(self):
|
|
"""Returns False."""
|
|
|
|
return False
|
|
|
|
def to_pcm(self):
|
|
"""Returns a PCMReader object containing the track's PCM data."""
|
|
|
|
#if mpg123 is available, use that for decoding
|
|
if (BIN.can_execute(BIN["mpg123"])):
|
|
sub = subprocess.Popen([BIN["mpg123"], "-qs", self.filename],
|
|
stdout=subprocess.PIPE,
|
|
stderr=file(os.devnull, "a"))
|
|
return PCMReader(sub.stdout,
|
|
sample_rate=self.sample_rate(),
|
|
channels=self.channels(),
|
|
bits_per_sample=16,
|
|
channel_mask=int(ChannelMask.from_channels(
|
|
self.channels())),
|
|
process=sub,
|
|
big_endian=BIG_ENDIAN)
|
|
else:
|
|
#if not, use LAME for decoding
|
|
if (self.filename.endswith("." + self.SUFFIX)):
|
|
if (BIG_ENDIAN):
|
|
endian = ['-x']
|
|
else:
|
|
endian = []
|
|
|
|
sub = subprocess.Popen([BIN['lame']] + endian + \
|
|
["--decode", "-t", "--quiet",
|
|
self.filename, "-"],
|
|
stdout=subprocess.PIPE)
|
|
return PCMReader(
|
|
sub.stdout,
|
|
sample_rate=self.sample_rate(),
|
|
channels=self.channels(),
|
|
bits_per_sample=16,
|
|
channel_mask=int(self.channel_mask()),
|
|
process=sub)
|
|
else:
|
|
import tempfile
|
|
from audiotools import TempWaveReader
|
|
#copy our file to one that ends with .mp3
|
|
tempmp3 = tempfile.NamedTemporaryFile(suffix='.' + self.SUFFIX)
|
|
f = open(self.filename, 'rb')
|
|
transfer_data(f.read, tempmp3.write)
|
|
f.close()
|
|
tempmp3.flush()
|
|
|
|
#decode the mp3 file to a WAVE file
|
|
wave = tempfile.NamedTemporaryFile(suffix='.wav')
|
|
returnval = subprocess.call([BIN['lame'], "--decode",
|
|
"--quiet",
|
|
tempmp3.name, wave.name])
|
|
tempmp3.close()
|
|
|
|
if (returnval == 0):
|
|
#return WAVE file as a stream
|
|
wave.seek(0, 0)
|
|
return TempWaveReader(wave)
|
|
else:
|
|
return PCMReaderError(
|
|
error_message=u"lame exited with error",
|
|
sample_rate=self.sample_rate(),
|
|
channels=self.channels(),
|
|
channel_mask=int(self.channel_mask()),
|
|
bits_per_sample=16)
|
|
|
|
@classmethod
|
|
def __help_output__(cls):
|
|
import cStringIO
|
|
help_data = cStringIO.StringIO()
|
|
sub = subprocess.Popen([BIN['lame'], '--help'],
|
|
stdout=subprocess.PIPE)
|
|
transfer_data(sub.stdout.read, help_data.write)
|
|
sub.wait()
|
|
return help_data.getvalue()
|
|
|
|
@classmethod
|
|
def __lame_version__(cls):
|
|
try:
|
|
version = re.findall(r'version \d+\.\d+',
|
|
cls.__help_output__())[0]
|
|
return tuple(map(int, version[len('version '):].split(".")))
|
|
except IndexError:
|
|
return (0, 0)
|
|
|
|
@classmethod
|
|
def from_pcm(cls, filename, pcmreader, compression=None):
|
|
"""Encodes a new file from PCM data.
|
|
|
|
Takes a filename string, PCMReader object
|
|
and optional compression level string.
|
|
Encodes a new audio file from pcmreader's data
|
|
at the given filename with the specified compression level
|
|
and returns a new MP3Audio object."""
|
|
|
|
import decimal
|
|
import bisect
|
|
|
|
if ((compression is None) or
|
|
(compression not in cls.COMPRESSION_MODES)):
|
|
compression = __default_quality__(cls.NAME)
|
|
|
|
if ((pcmreader.channels > 2) or
|
|
(pcmreader.sample_rate not in (32000, 48000, 44100))):
|
|
pcmreader = PCMConverter(
|
|
pcmreader,
|
|
sample_rate=[32000, 32000, 44100, 48000][bisect.bisect(
|
|
[32000, 44100, 48000], pcmreader.sample_rate)],
|
|
channels=min(pcmreader.channels, 2),
|
|
channel_mask=ChannelMask.from_channels(
|
|
min(pcmreader.channels, 2)),
|
|
bits_per_sample=16)
|
|
|
|
if (pcmreader.channels > 1):
|
|
mode = "j"
|
|
else:
|
|
mode = "m"
|
|
|
|
#FIXME - not sure if all LAME versions support "--little-endian"
|
|
# #LAME 3.98 (and up, presumably) handle the byteswap correctly
|
|
# #LAME 3.97 always uses -x
|
|
# if (BIG_ENDIAN or (cls.__lame_version__() < (3,98))):
|
|
# endian = ['-x']
|
|
# else:
|
|
# endian = []
|
|
|
|
devnull = file(os.devnull, 'ab')
|
|
|
|
if (str(compression) in map(str, range(0, 10))):
|
|
compression = ["-V" + str(compression)]
|
|
else:
|
|
compression = ["--preset", str(compression)]
|
|
|
|
sub = subprocess.Popen([
|
|
BIN['lame'], "--quiet",
|
|
"-r",
|
|
"-s", str(decimal.Decimal(pcmreader.sample_rate) / 1000),
|
|
"--bitwidth", str(pcmreader.bits_per_sample),
|
|
"--signed", "--little-endian",
|
|
"-m", mode] + compression + ["-", filename],
|
|
stdin=subprocess.PIPE,
|
|
stdout=devnull,
|
|
stderr=devnull,
|
|
preexec_fn=ignore_sigint)
|
|
|
|
try:
|
|
transfer_framelist_data(pcmreader, sub.stdin.write)
|
|
except (IOError, ValueError), err:
|
|
sub.stdin.close()
|
|
sub.wait()
|
|
cls.__unlink__(filename)
|
|
raise EncodingError(str(err))
|
|
except Exception, err:
|
|
sub.stdin.close()
|
|
sub.wait()
|
|
cls.__unlink__(filename)
|
|
raise err
|
|
|
|
try:
|
|
pcmreader.close()
|
|
except DecodingError, err:
|
|
cls.__unlink__(filename)
|
|
raise EncodingError(err.error_message)
|
|
sub.stdin.close()
|
|
|
|
devnull.close()
|
|
|
|
if (sub.wait() == 0):
|
|
return MP3Audio(filename)
|
|
else:
|
|
cls.__unlink__(filename)
|
|
raise EncodingError(u"error encoding file with lame")
|
|
|
|
def bits_per_sample(self):
|
|
"""Returns an integer number of bits-per-sample this track contains."""
|
|
|
|
return 16
|
|
|
|
def channels(self):
|
|
"""Returns an integer number of channels this track contains."""
|
|
|
|
return self.__channels__
|
|
|
|
def sample_rate(self):
|
|
"""Returns the rate of the track's audio as an integer number of Hz."""
|
|
|
|
return self.__samplerate__
|
|
|
|
def get_metadata(self):
|
|
"""Returns a MetaData object, or None.
|
|
|
|
Raises IOError if unable to read the file."""
|
|
|
|
f = file(self.filename, "rb")
|
|
try:
|
|
if (f.read(3) != "ID3"): # no ID3v2 tag, try ID3v1
|
|
id3v1 = ID3v1Comment.read_id3v1_comment(self.filename)
|
|
if (id3v1[-1] == -1): # no ID3v1 either
|
|
return None
|
|
else:
|
|
return ID3v1Comment(id3v1)
|
|
else:
|
|
id3v2 = ID3v2Comment.read_id3v2_comment(self.filename)
|
|
|
|
id3v1 = ID3v1Comment.read_id3v1_comment(self.filename)
|
|
if (id3v1[-1] == -1): # only ID3v2, no ID3v1
|
|
return id3v2
|
|
else: # both ID3v2 and ID3v1
|
|
return ID3CommentPair(
|
|
id3v2,
|
|
ID3v1Comment(id3v1))
|
|
finally:
|
|
f.close()
|
|
|
|
def set_metadata(self, metadata):
|
|
"""Takes a MetaData object and sets this track's metadata.
|
|
|
|
This metadata includes track name, album name, and so on.
|
|
Raises IOError if unable to write the file."""
|
|
|
|
if (metadata is None):
|
|
return
|
|
|
|
if ((not isinstance(metadata, ID3v2Comment)) and
|
|
(not isinstance(metadata, ID3v1Comment))):
|
|
DEFAULT_ID3V2 = "id3v2.3"
|
|
DEFAULT_ID3V1 = "id3v1.1"
|
|
|
|
id3v2_class = {"id3v2.2": ID3v22Comment,
|
|
"id3v2.3": ID3v23Comment,
|
|
"id3v2.4": ID3v24Comment,
|
|
"none": None}.get(config.get_default("ID3",
|
|
"id3v2",
|
|
DEFAULT_ID3V2),
|
|
DEFAULT_ID3V2)
|
|
id3v1_class = {"id3v1.1": ID3v1Comment,
|
|
"none": None}.get(config.get_default("ID3",
|
|
"id3v1",
|
|
DEFAULT_ID3V1))
|
|
if ((id3v2_class is not None) and (id3v1_class is not None)):
|
|
metadata = ID3CommentPair.converted(metadata,
|
|
id3v2_class=id3v2_class,
|
|
id3v1_class=id3v1_class)
|
|
elif (id3v2_class is not None):
|
|
metadata = id3v2_class.converted(metadata)
|
|
elif (id3v1_class is not None):
|
|
metadata = id3v1_class.converted(metadata)
|
|
else:
|
|
return
|
|
|
|
#get the original MP3 data
|
|
f = file(self.filename, "rb")
|
|
MP3Audio.__find_mp3_start__(f)
|
|
data_start = f.tell()
|
|
MP3Audio.__find_last_mp3_frame__(f)
|
|
data_end = f.tell()
|
|
f.seek(data_start, 0)
|
|
mp3_data = f.read(data_end - data_start)
|
|
f.close()
|
|
|
|
if (isinstance(metadata, ID3CommentPair)):
|
|
id3v2 = metadata.id3v2.build()
|
|
id3v1 = metadata.id3v1.build_tag()
|
|
elif (isinstance(metadata, ID3v2Comment)):
|
|
id3v2 = metadata.build()
|
|
id3v1 = ""
|
|
elif (isinstance(metadata, ID3v1Comment)):
|
|
id3v2 = ""
|
|
id3v1 = metadata.build_tag()
|
|
|
|
#write id3v2 + data + id3v1 to file
|
|
f = file(self.filename, "wb")
|
|
f.write(id3v2)
|
|
f.write(mp3_data)
|
|
f.write(id3v1)
|
|
f.close()
|
|
|
|
def delete_metadata(self):
|
|
"""Deletes the track's MetaData.
|
|
|
|
This removes or unsets tags as necessary in order to remove all data.
|
|
Raises IOError if unable to write the file."""
|
|
|
|
#get the original MP3 data
|
|
f = file(self.filename, "rb")
|
|
MP3Audio.__find_mp3_start__(f)
|
|
data_start = f.tell()
|
|
MP3Audio.__find_last_mp3_frame__(f)
|
|
data_end = f.tell()
|
|
f.seek(data_start, 0)
|
|
mp3_data = f.read(data_end - data_start)
|
|
f.close()
|
|
|
|
#write data to file
|
|
f = file(self.filename, "wb")
|
|
f.write(mp3_data)
|
|
f.close()
|
|
|
|
#places mp3file at the position of the next MP3 frame's start
|
|
@classmethod
|
|
def __find_next_mp3_frame__(cls, mp3file):
|
|
#if we're starting at an ID3v2 header, skip it to save a bunch of time
|
|
ID3v2Comment.skip(mp3file)
|
|
|
|
#then find the next mp3 frame
|
|
(b1, b2) = mp3file.read(2)
|
|
while ((b1 != chr(0xFF)) or ((ord(b2) & 0xE0) != 0xE0)):
|
|
mp3file.seek(-1, 1)
|
|
(b1, b2) = mp3file.read(2)
|
|
mp3file.seek(-2, 1)
|
|
|
|
#places mp3file at the position of the MP3 file's start
|
|
#either at the next frame (most commonly)
|
|
#or at the "RIFF????RMP3" header
|
|
@classmethod
|
|
def __find_mp3_start__(cls, mp3file):
|
|
#if we're starting at an ID3v2 header, skip it to save a bunch of time
|
|
ID3v2Comment.skip(mp3file)
|
|
|
|
while (True):
|
|
byte = mp3file.read(1)
|
|
while ((byte != chr(0xFF)) and (byte != 'R') and (len(byte) > 0)):
|
|
byte = mp3file.read(1)
|
|
|
|
if (byte == chr(0xFF)): # possibly a frame sync
|
|
mp3file.seek(-1, 1)
|
|
try:
|
|
header = cls.MP3_FRAME_HEADER.parse_stream(mp3file)
|
|
if ((header.sync == 0x07FF) and
|
|
(header.mpeg_version in (0x03, 0x02, 0x00)) and
|
|
(header.layer in (0x01, 0x02, 0x03))):
|
|
mp3file.seek(-4, 1)
|
|
return
|
|
else:
|
|
mp3file.seek(-3, 1)
|
|
except:
|
|
continue
|
|
elif (byte == 'R'): # possibly a 'RIFF????RMP3' header
|
|
header = mp3file.read(11)
|
|
if ((header[0:3] == 'IFF') and
|
|
(header[7:11] == 'RMP3')):
|
|
mp3file.seek(-12, 1)
|
|
return
|
|
else:
|
|
mp3file.seek(-11, 1)
|
|
elif (len(byte) == 0): # we've run out of MP3 file
|
|
return
|
|
|
|
#places mp3file at the position of the last MP3 frame's end
|
|
#(either the last byte in the file or just before the ID3v1 tag)
|
|
#this may not be strictly accurate if ReplayGain data is present,
|
|
#since APEv2 tags came before the ID3v1 tag,
|
|
#but we're not planning to change that tag anyway
|
|
@classmethod
|
|
def __find_last_mp3_frame__(cls, mp3file):
|
|
mp3file.seek(-128, 2)
|
|
if (mp3file.read(3) == 'TAG'):
|
|
mp3file.seek(-128, 2)
|
|
return
|
|
else:
|
|
mp3file.seek(0, 2)
|
|
return
|
|
|
|
#header is a Construct parsed from 4 bytes sent to MP3_FRAME_HEADER
|
|
#returns the total length of the frame, including the header
|
|
#(subtract 4 when doing a seek or read to the next one)
|
|
@classmethod
|
|
def __mp3_frame_length__(cls, header):
|
|
layer = 4 - header.layer # layer 1, 2 or 3
|
|
|
|
bit_rate = MP3Audio.__get_mp3_frame_bitrate__(header)
|
|
if (bit_rate is None):
|
|
raise InvalidMP3(_(u"Invalid bit rate"))
|
|
|
|
sample_rate = MP3Audio.__get_mp3_frame_sample_rate__(header)
|
|
|
|
if (layer == 1):
|
|
return (12 * (bit_rate * 1000) / sample_rate + header.padding) * 4
|
|
else:
|
|
return 144 * (bit_rate * 1000) / sample_rate + header.padding
|
|
|
|
#takes a parsed MP3_FRAME_HEADER
|
|
#returns the mp3's sample rate based on that information
|
|
#(typically 44100)
|
|
@classmethod
|
|
def __get_mp3_frame_sample_rate__(cls, frame):
|
|
try:
|
|
if (frame.mpeg_version == 0x00): # MPEG 2.5
|
|
return MP3Audio.MP3_SAMPLERATE[frame.sampling_rate][2]
|
|
elif (frame.mpeg_version == 0x02): # MPEG 2
|
|
return MP3Audio.MP3_SAMPLERATE[frame.sampling_rate][1]
|
|
else: # MPEG 1
|
|
return MP3Audio.MP3_SAMPLERATE[frame.sampling_rate][0]
|
|
except IndexError:
|
|
raise InvalidMP3(_(u"Invalid sampling rate"))
|
|
|
|
@classmethod
|
|
def __get_mp3_frame_channels__(cls, frame):
|
|
if (frame.channel == 0x03):
|
|
return 1
|
|
else:
|
|
return 2
|
|
|
|
@classmethod
|
|
def __get_mp3_frame_bitrate__(cls, frame):
|
|
layer = 4 - frame.layer # layer 1, 2 or 3
|
|
|
|
try:
|
|
if (frame.mpeg_version == 0x00): # MPEG 2.5
|
|
return MP3Audio.MP3_BITRATE[frame.bitrate][layer + 2]
|
|
elif (frame.mpeg_version == 0x02): # MPEG 2
|
|
return MP3Audio.MP3_BITRATE[frame.bitrate][layer + 2]
|
|
elif (frame.mpeg_version == 0x03): # MPEG 1
|
|
return MP3Audio.MP3_BITRATE[frame.bitrate][layer - 1]
|
|
else:
|
|
return 0
|
|
except IndexError:
|
|
raise InvalidMP3(_(u"Invalid bit rate"))
|
|
|
|
def cd_frames(self):
|
|
"""Returns the total length of the track in CD frames.
|
|
|
|
Each CD frame is 1/75th of a second."""
|
|
|
|
#calculate length at create-time so that we can
|
|
#throw InvalidMP3 as soon as possible
|
|
return self.__framelength__
|
|
|
|
#returns the length of this file in CD frame
|
|
#raises InvalidMP3 if any portion of the frame is invalid
|
|
def __length__(self):
|
|
mp3file = file(self.filename, "rb")
|
|
|
|
try:
|
|
MP3Audio.__find_next_mp3_frame__(mp3file)
|
|
|
|
start_position = mp3file.tell()
|
|
|
|
fr = MP3Audio.MP3_FRAME_HEADER.parse(mp3file.read(4))
|
|
|
|
first_frame = mp3file.read(MP3Audio.__mp3_frame_length__(fr) - 4)
|
|
|
|
sample_rate = MP3Audio.__get_mp3_frame_sample_rate__(fr)
|
|
|
|
if (fr.mpeg_version == 0x00): # MPEG 2.5
|
|
version = 3
|
|
elif (fr.mpeg_version == 0x02): # MPEG 2
|
|
version = 3
|
|
else: # MPEG 1
|
|
version = 0
|
|
|
|
try:
|
|
if (fr.layer == 0x03): # layer 1
|
|
frames_per_sample = 384
|
|
bit_rate = MP3Audio.MP3_BITRATE[fr.bitrate][version]
|
|
elif (fr.layer == 0x02): # layer 2
|
|
frames_per_sample = 1152
|
|
bit_rate = MP3Audio.MP3_BITRATE[fr.bitrate][version + 1]
|
|
elif (fr.layer == 0x01): # layer 3
|
|
frames_per_sample = 1152
|
|
bit_rate = MP3Audio.MP3_BITRATE[fr.bitrate][version + 2]
|
|
else:
|
|
raise InvalidMP3(_(u"Unsupported MPEG layer"))
|
|
except IndexError:
|
|
raise InvalidMP3(_(u"Invalid bit rate"))
|
|
|
|
if ('Xing' in first_frame):
|
|
#the first frame has a Xing header,
|
|
#use that to calculate the mp3's length
|
|
xing_header = MP3Audio.XING_HEADER.parse(
|
|
first_frame[first_frame.index('Xing'):])
|
|
|
|
return (xing_header.num_frames * frames_per_sample * 75 /
|
|
sample_rate)
|
|
else:
|
|
#no Xing header,
|
|
#assume a constant bitrate file
|
|
mp3file.seek(-128, 2)
|
|
if (mp3file.read(3) == "TAG"):
|
|
end_position = mp3file.tell() - 3
|
|
else:
|
|
mp3file.seek(0, 2)
|
|
end_position = mp3file.tell()
|
|
|
|
return ((end_position - start_position) * 75 * 8 /
|
|
(bit_rate * 1000))
|
|
finally:
|
|
mp3file.close()
|
|
|
|
def total_frames(self):
|
|
"""Returns the total PCM frames of the track as an integer."""
|
|
|
|
return self.cd_frames() * self.sample_rate() / 75
|
|
|
|
@classmethod
|
|
def can_add_replay_gain(cls):
|
|
"""Returns True if we have the necessary binaries to add ReplayGain."""
|
|
|
|
return BIN.can_execute(BIN['mp3gain'])
|
|
|
|
@classmethod
|
|
def lossless_replay_gain(cls):
|
|
"""Returns False."""
|
|
|
|
return False
|
|
|
|
@classmethod
|
|
def add_replay_gain(cls, filenames, progress=None):
|
|
"""Adds ReplayGain values to a list of filename strings.
|
|
|
|
All the filenames must be of this AudioFile type.
|
|
Raises ValueError if some problem occurs during ReplayGain application.
|
|
"""
|
|
|
|
track_names = [track.filename for track in
|
|
open_files(filenames) if
|
|
isinstance(track, cls)]
|
|
|
|
if (progress is not None):
|
|
progress(0, 1)
|
|
|
|
if ((len(track_names) > 0) and (BIN.can_execute(BIN['mp3gain']))):
|
|
devnull = file(os.devnull, 'ab')
|
|
sub = subprocess.Popen([BIN['mp3gain'], '-f', '-k', '-q', '-r'] + \
|
|
track_names,
|
|
stdout=devnull,
|
|
stderr=devnull)
|
|
sub.wait()
|
|
|
|
devnull.close()
|
|
|
|
if (progress is not None):
|
|
progress(1, 1)
|
|
|
|
def mpeg_frames(self):
|
|
"""Yields (header, data) tuples of the file's contents.
|
|
|
|
header is an MPEG_Frame_Header Construct.
|
|
data is a string of MP3 data."""
|
|
|
|
header_struct = MPEG_Frame_Header("header")
|
|
f = open(self.filename, 'rb')
|
|
try:
|
|
#FIXME - this won't handle RIFF RMP3 well
|
|
#perhaps I should use tracklint to clean those up
|
|
MP3Audio.__find_last_mp3_frame__(f)
|
|
stop_position = f.tell()
|
|
f.seek(0, 0)
|
|
MP3Audio.__find_mp3_start__(f)
|
|
while (f.tell() < stop_position):
|
|
header = header_struct.parse_stream(f)
|
|
data = f.read(header.byte_length - 4)
|
|
yield (header, data)
|
|
finally:
|
|
f.close()
|
|
|
|
def verify(self, progress=None):
|
|
from . import verify
|
|
try:
|
|
f = open(self.filename, 'rb')
|
|
except IOError, err:
|
|
raise InvalidMP3(str(err))
|
|
|
|
#MP3 verification is likely to be so fast
|
|
#that individual calls to progress() are
|
|
#a waste of time.
|
|
if (progress is not None):
|
|
progress(0, 1)
|
|
|
|
try:
|
|
try:
|
|
#skip ID3v2/ID3v1 tags during verification
|
|
self.__find_mp3_start__(f)
|
|
start = f.tell()
|
|
self.__find_last_mp3_frame__(f)
|
|
end = f.tell()
|
|
f.seek(start, 0)
|
|
|
|
verify.mpeg(f, start, end)
|
|
if (progress is not None):
|
|
progress(1, 1)
|
|
|
|
return True
|
|
except (IOError, ValueError), err:
|
|
raise InvalidMP3(str(err))
|
|
finally:
|
|
f.close()
|
|
|
|
|
|
#######################
|
|
#MP2 AUDIO
|
|
#######################
|
|
|
|
class MP2Audio(MP3Audio):
|
|
"""An MP2 audio file."""
|
|
|
|
SUFFIX = "mp2"
|
|
NAME = SUFFIX
|
|
DEFAULT_COMPRESSION = str(192)
|
|
COMPRESSION_MODES = tuple(map(str, (64, 96, 112, 128, 160, 192,
|
|
224, 256, 320, 384)))
|
|
COMPRESSION_DESCRIPTIONS = {"64": _(u"total bitrate of 64kbps"),
|
|
"384": _(u"total bitrate of 384kbps")}
|
|
BINARIES = ("lame", "twolame")
|
|
|
|
@classmethod
|
|
def is_type(cls, file):
|
|
"""Returns True if the given file object describes this format.
|
|
|
|
Takes a seekable file pointer rewound to the start of the file."""
|
|
|
|
ID3v2Comment.skip(file)
|
|
|
|
try:
|
|
frame = cls.MP3_FRAME_HEADER.parse_stream(file)
|
|
|
|
return ((frame.sync == 0x07FF) and
|
|
(frame.mpeg_version in (0x03, 0x02, 0x00)) and
|
|
(frame.layer == 0x02))
|
|
except:
|
|
return False
|
|
|
|
@classmethod
|
|
def from_pcm(cls, filename, pcmreader, compression=None):
|
|
"""Encodes a new file from PCM data.
|
|
|
|
Takes a filename string, PCMReader object
|
|
and optional compression level string.
|
|
Encodes a new audio file from pcmreader's data
|
|
at the given filename with the specified compression level
|
|
and returns a new MP2Audio object."""
|
|
|
|
import decimal
|
|
import bisect
|
|
|
|
if ((compression is None) or
|
|
(compression not in cls.COMPRESSION_MODES)):
|
|
compression = __default_quality__(cls.NAME)
|
|
|
|
if ((pcmreader.channels > 2) or
|
|
(pcmreader.sample_rate not in (32000, 48000, 44100)) or
|
|
(pcmreader.bits_per_sample != 16)):
|
|
pcmreader = PCMConverter(
|
|
pcmreader,
|
|
sample_rate=[32000, 32000, 44100, 48000][bisect.bisect(
|
|
[32000, 44100, 48000], pcmreader.sample_rate)],
|
|
channels=min(pcmreader.channels, 2),
|
|
channel_mask=pcmreader.channel_mask,
|
|
bits_per_sample=16)
|
|
|
|
devnull = file(os.devnull, 'ab')
|
|
|
|
sub = subprocess.Popen([BIN['twolame'], "--quiet",
|
|
"-r",
|
|
"-s", str(pcmreader.sample_rate),
|
|
"--samplesize", str(pcmreader.bits_per_sample),
|
|
"-N", str(pcmreader.channels),
|
|
"-m", "a",
|
|
"-b", compression,
|
|
"-",
|
|
filename],
|
|
stdin=subprocess.PIPE,
|
|
stdout=devnull,
|
|
stderr=devnull,
|
|
preexec_fn=ignore_sigint)
|
|
|
|
try:
|
|
transfer_framelist_data(pcmreader, sub.stdin.write)
|
|
except (ValueError, IOError), err:
|
|
sub.stdin.close()
|
|
sub.wait()
|
|
cls.__unlink__(filename)
|
|
raise EncodingError(str(err))
|
|
except Exception, err:
|
|
sub.stdin.close()
|
|
sub.wait()
|
|
cls.__unlink__(filename)
|
|
raise err
|
|
|
|
try:
|
|
pcmreader.close()
|
|
except DecodingError, err:
|
|
cls.__unlink__(filename)
|
|
raise EncodingError(err.error_message)
|
|
|
|
sub.stdin.close()
|
|
devnull.close()
|
|
|
|
if (sub.wait() == 0):
|
|
return MP2Audio(filename)
|
|
else:
|
|
cls.__unlink__(filename)
|
|
raise EncodingError(u"twolame exited with error")
|