#!/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 """The core Python Audio Tools module.""" import sys if (sys.version_info < (2, 5, 0, 'final', 0)): print >> sys.stderr, "*** Python 2.5.0 or better required" sys.exit(1) from . import construct as Con from . import pcm as pcm import subprocess import re import cStringIO import os import os.path import ConfigParser import optparse import struct from itertools import izip import gettext import unicodedata import cPickle gettext.install("audiotools", unicode=True) class RawConfigParser(ConfigParser.RawConfigParser): """Extends RawConfigParser to provide additional methods.""" def get_default(self, section, option, default): """Returns a default if option is not found in section.""" try: return self.get(section, option) except ConfigParser.NoSectionError: return default except ConfigParser.NoOptionError: return default def getboolean_default(self, section, option, default): """Returns a default if option is not found in section.""" try: return self.getboolean(section, option) except ConfigParser.NoSectionError: return default except ConfigParser.NoOptionError: return default def set_default(self, section, option, value): try: self.set(section, option, value) except ConfigParser.NoSectionError: self.add_section(section) self.set(section, option, value) def getint_default(self, section, option, default): """Returns a default int if option is not found in section.""" try: return self.getint(section, option) except ConfigParser.NoSectionError: return default except ConfigParser.NoOptionError: return default config = RawConfigParser() config.read([os.path.join("/etc", "audiotools.cfg"), os.path.join(sys.prefix, "etc", "audiotools.cfg"), os.path.expanduser('~/.audiotools.cfg')]) BUFFER_SIZE = 0x100000 class __system_binaries__: def __init__(self, config): self.config = config def __getitem__(self, command): try: return self.config.get("Binaries", command) except ConfigParser.NoSectionError: return command except ConfigParser.NoOptionError: return command def can_execute(self, command): if (os.sep in command): return os.access(command, os.X_OK) else: for path in os.environ.get('PATH', os.defpath).split(os.pathsep): if (os.access(os.path.join(path, command), os.X_OK)): return True return False BIN = __system_binaries__(config) DEFAULT_CDROM = config.get_default("System", "cdrom", "/dev/cdrom") FREEDB_SERVER = config.get_default("FreeDB", "server", "us.freedb.org") FREEDB_PORT = config.getint_default("FreeDB", "port", 80) MUSICBRAINZ_SERVER = config.get_default("MusicBrainz", "server", "musicbrainz.org") MUSICBRAINZ_PORT = config.getint_default("MusicBrainz", "port", 80) THUMBNAIL_FORMAT = config.get_default("Thumbnail", "format", "jpeg") THUMBNAIL_SIZE = config.getint_default("Thumbnail", "size", 150) VERSION = "2.17" FILENAME_FORMAT = config.get_default( "Filenames", "format", '%(track_number)2.2d - %(track_name)s.%(suffix)s') FS_ENCODING = config.get_default("System", "fs_encoding", sys.getfilesystemencoding()) if (FS_ENCODING is None): FS_ENCODING = 'UTF-8' IO_ENCODING = config.get_default("System", "io_encoding", "UTF-8") VERBOSITY_LEVELS = ("quiet", "normal", "debug") DEFAULT_VERBOSITY = config.get_default("Defaults", "verbosity", "normal") if (DEFAULT_VERBOSITY not in VERBOSITY_LEVELS): DEFAULT_VERBOSITY = "normal" DEFAULT_TYPE = config.get_default("System", "default_type", "wav") def __default_quality__(audio_type): quality = DEFAULT_QUALITY.get(audio_type, "") try: if (quality not in TYPE_MAP[audio_type].COMPRESSION_MODES): return TYPE_MAP[audio_type].DEFAULT_COMPRESSION else: return quality except KeyError: return "" try: import cpucount MAX_CPUS = cpucount.cpucount() except ImportError: MAX_CPUS = 1 if (config.has_option("System", "maximum_jobs")): MAX_JOBS = config.getint_default("System", "maximum_jobs", 1) else: MAX_JOBS = MAX_CPUS BIG_ENDIAN = sys.byteorder == 'big' def get_umask(): """Returns the current file creation umask as an integer. This is XORed with creation bits integers when used with os.open to create new files. For example: >>> fd = os.open(filename, os.WRONLY | os.O_CREAT, 0666 ^ get_umask()) """ mask = os.umask(0) os.umask(mask) return mask ####################### #Output Messaging ####################### class OptionParser(optparse.OptionParser): """Extends OptionParser to use IO_ENCODING as text encoding. This ensures the encoding remains consistent if --help output is piped to a pager vs. sent to a tty. """ def _get_encoding(self, file): return IO_ENCODING OptionGroup = optparse.OptionGroup def Messenger(executable, options): """Returns a Messenger object based on set verbosity level in options.""" if (not hasattr(options, "verbosity")): return VerboseMessenger(executable) elif ((options.verbosity == 'normal') or (options.verbosity == 'debug')): return VerboseMessenger(executable) else: return SilentMessenger(executable) __ANSI_SEQUENCE__ = re.compile(u"\u001B\[[0-9;]+.") __CHAR_WIDTHS__ = {"Na": 1, "A": 1, "W": 2, "F": 2, "N": 1, "H": 1} def str_width(s): """Returns the width of unicode string s, in characters. This accounts for multi-code Unicode characters as well as embedded ANSI sequences. """ return sum( [__CHAR_WIDTHS__.get(unicodedata.east_asian_width(char), 1) for char in unicodedata.normalize('NFC', __ANSI_SEQUENCE__.sub(u"", s))]) class display_unicode: """A class for abstracting unicode string truncation. This is necessary because not all Unicode characters are the same length when displayed onscreen. """ def __init__(self, unicode_string): self.__string__ = unicodedata.normalize( 'NFC', __ANSI_SEQUENCE__.sub(u"", unicode(unicode_string))) self.__char_widths__ = tuple( [__CHAR_WIDTHS__.get(unicodedata.east_asian_width(char), 1) for char in self.__string__]) def __unicode__(self): return self.__string__ def __len__(self): return sum(self.__char_widths__) def __repr__(self): return "display_unicode(%s)" % (repr(self.__string__)) def __add__(self, unicode_string): return display_unicode(self.__string__ + unicode(unicode_string)) def head(self, display_characters): """returns a display_unicode object truncated to the given length Characters at the end of the string are removed as needed.""" output_chars = [] for (char, width) in zip(self.__string__, self.__char_widths__): if (width <= display_characters): output_chars.append(char) display_characters -= width else: break return display_unicode(u"".join(output_chars)) def tail(self, display_characters): """returns a display_unicode object truncated to the given length Characters at the beginning of the string are removed as needed.""" output_chars = [] for (char, width) in zip(reversed(self.__string__), reversed(self.__char_widths__)): if (width <= display_characters): output_chars.append(char) display_characters -= width else: break output_chars.reverse() return display_unicode(u"".join(output_chars)) def split(self, display_characters): """returns a tuple of display_unicode objects The first is up to 'display_characters' in length. The second contains the remainder of the string. """ head_chars = [] tail_chars = [] for (char, width) in zip(self.__string__, self.__char_widths__): if (width <= display_characters): head_chars.append(char) display_characters -= width else: tail_chars.append(char) display_characters = -1 return (display_unicode(u"".join(head_chars)), display_unicode(u"".join(tail_chars))) class __MessengerRow__: def __init__(self): self.strings = [] # a list of unicode strings self.alignments = [] # a list of booleans # False if left-aligned, True if right-aligned self.total_lengths = [] # a list of total length integers, # to be set at print-time def add_string(self, string, left_aligned): self.strings.append(string) self.alignments.append(left_aligned) self.total_lengths.append(str_width(string)) def lengths(self): return map(str_width, self.strings) def set_total_lengths(self, total_lengths): self.total_lengths = total_lengths def __unicode__(self): output_string = [] for (string, right_aligned, length) in zip(self.strings, self.alignments, self.total_lengths): if (str_width(string) < length): if (not right_aligned): output_string.append(string) output_string.append(u" " * (length - str_width(string))) else: output_string.append(u" " * (length - str_width(string))) output_string.append(string) else: output_string.append(string) return u"".join(output_string) class __DividerRow__: def __init__(self, dividers): self.dividers = dividers self.total_lengths = [] def lengths(self): return [1 for x in self.dividers] def set_total_lengths(self, total_lengths): self.total_lengths = total_lengths def __unicode__(self): return u"".join([divider * length for (divider, length) in zip(self.dividers, self.total_lengths)]) class VerboseMessenger: """This class is for displaying formatted output in a consistent way. It performs proper unicode string encoding based on IO_ENCODING, but can also display tabular data and ANSI-escaped data with less effort. """ #a set of ANSI SGR codes RESET = 0 BOLD = 1 FAINT = 2 ITALIC = 3 UNDERLINE = 4 BLINK_SLOW = 5 BLINK_FAST = 6 REVERSE = 7 STRIKEOUT = 9 FG_BLACK = 30 FG_RED = 31 FG_GREEN = 32 FG_YELLOW = 33 FG_BLUE = 34 FG_MAGENTA = 35 FG_CYAN = 36 FG_WHITE = 37 BG_BLACK = 40 BG_RED = 41 BG_GREEN = 42 BG_YELLOW = 43 BG_BLUE = 44 BG_MAGENTA = 45 BG_CYAN = 46 BG_WHITE = 47 def __init__(self, executable): """executable is a plain string of what script is being run. This is typically for use by the usage() method.""" self.executable = executable self.output_msg_rows = [] # a list of __MessengerRow__ objects def output(self, s): """Displays an output message unicode string to stdout. This appends a newline to that message.""" sys.stdout.write(s.encode(IO_ENCODING, 'replace')) sys.stdout.write(os.linesep) def partial_output(self, s): """Displays a partial output message unicode string to stdout. This flushes output so that message is displayed""" sys.stdout.write(s.encode(IO_ENCODING, 'replace')) sys.stdout.flush() def new_row(self): """Sets up a new tabbed row for outputting aligned text. This must be called prior to calling output_column().""" self.output_msg_rows.append(__MessengerRow__()) def blank_row(self): """Generates a completely blank row of aligned text. This cannot be the first row of aligned text.""" if (len(self.output_msg_rows) == 0): raise ValueError("first output row cannot be blank") else: self.new_row() for i in xrange(len(self.output_msg_rows[0].lengths())): self.output_column(u"") def divider_row(self, dividers): """Adds a row of unicode divider characters. There should be one character in dividers per output column. For example: >>> m = VerboseMessenger("audiotools") >>> m.new_row() >>> m.output_column(u'Foo') >>> m.output_column(u' ') >>> m.output_column(u'Bar') >>> m.divider_row([u'-',u' ',u'-']) >>> m.output_rows() Foo Bar --- --- """ self.output_msg_rows.append(__DividerRow__(dividers)) def output_column(self, string, right_aligned=False): """Adds a column of aligned unicode data.""" if (len(self.output_msg_rows) > 0): self.output_msg_rows[-1].add_string(string, right_aligned) else: raise ValueError( "you must perform \"new_row\" before adding columns") def output_rows(self): """Outputs all of our accumulated output rows as aligned output. This operates by calling our output() method. Therefore, subclasses that have overridden output() to noops (silent messengers) will also have silent output_rows() methods. """ lengths = [row.lengths() for row in self.output_msg_rows] if (len(lengths) == 0): raise ValueError("you must generate at least one output row") if (len(set(map(len, lengths))) != 1): raise ValueError("all output rows must be the same length") max_lengths = [] for i in xrange(len(lengths[0])): max_lengths.append(max([length[i] for length in lengths])) for row in self.output_msg_rows: row.set_total_lengths(max_lengths) for row in self.output_msg_rows: self.output(unicode(row)) self.output_msg_rows = [] def info(self, s): """Displays an informative message unicode string to stderr. This appends a newline to that message.""" sys.stderr.write(s.encode(IO_ENCODING, 'replace')) sys.stderr.write(os.linesep) def info_rows(self): """Outputs all of our accumulated output rows as aligned info. This operates by calling our info() method. Therefore, subclasses that have overridden info() to noops (silent messengers) will also have silent info_rows() methods. """ lengths = [row.lengths() for row in self.output_msg_rows] if (len(lengths) == 0): raise ValueError("you must generate at least one output row") if (len(set(map(len, lengths))) != 1): raise ValueError("all output rows must be the same length") max_lengths = [] for i in xrange(len(lengths[0])): max_lengths.append(max([length[i] for length in lengths])) for row in self.output_msg_rows: row.set_total_lengths(max_lengths) for row in self.output_msg_rows: self.info(unicode(row)) self.output_msg_rows = [] def partial_info(self, s): """Displays a partial informative message unicode string to stdout. This flushes output so that message is displayed""" sys.stderr.write(s.encode(IO_ENCODING, 'replace')) sys.stderr.flush() #what's the difference between output() and info() ? #output() is for a program's primary data #info() is for incidental information #for example, trackinfo(1) should use output() for what it displays #since that output is its primary function #but track2track should use info() for its lines of progress #since its primary function is converting audio #and tty output is purely incidental def error(self, s): """Displays an error message unicode string to stderr. This appends a newline to that message.""" sys.stderr.write("*** Error: ") sys.stderr.write(s.encode(IO_ENCODING, 'replace')) sys.stderr.write(os.linesep) def os_error(self, oserror): """Displays an properly formatted OSError exception to stderr. This appends a newline to that message.""" self.error(u"[Errno %d] %s: '%s'" % \ (oserror.errno, oserror.strerror.decode('utf-8', 'replace'), self.filename(oserror.filename))) def warning(self, s): """Displays a warning message unicode string to stderr. This appends a newline to that message.""" sys.stderr.write("*** Warning: ") sys.stderr.write(s.encode(IO_ENCODING, 'replace')) sys.stderr.write(os.linesep) def usage(self, s): """Displays the program's usage unicode string to stderr. This appends a newline to that message.""" sys.stderr.write("*** Usage: ") sys.stderr.write(self.executable.decode('ascii')) sys.stderr.write(" ") sys.stderr.write(s.encode(IO_ENCODING, 'replace')) sys.stderr.write(os.linesep) def filename(self, s): """Decodes a filename string to unicode. This uses the system's encoding to perform translation.""" return s.decode(FS_ENCODING, 'replace') def ansi(self, s, codes): """Generates an ANSI code as a unicode string. Takes a unicode string to be escaped and a list of ANSI SGR codes. Returns an ANSI-escaped unicode terminal string with those codes activated followed by the unescaped code if the Messenger's stdout is to a tty terminal. Otherwise, the string is returned unmodified. For example: >>> VerboseMessenger("audiotools").ansi(u"foo", ... [VerboseMessenger.BOLD]) u'\\x1b[1mfoo\\x1b[0m' """ if (sys.stdout.isatty()): return u"\u001B[%sm%s\u001B[0m" % \ (";".join(map(unicode, codes)), s) else: return s def ansi_clearline(self): """Generates a set of clear line ANSI escape codes to stdout. This works only if stdout is a tty. Otherwise, it does nothing. For example: >>> msg = VerboseMessenger("audiotools") >>> msg.partial_output(u"working") >>> time.sleep(1) >>> msg.ansi_clearline() >>> msg.output(u"done") """ if (sys.stdout.isatty()): sys.stdout.write(( # move cursor to column 0 u"\u001B[0G" + # clear everything after cursor u"\u001B[0K").encode(IO_ENCODING)) sys.stdout.flush() def ansi_uplines(self, lines): """Moves the cursor up by the given number of lines.""" if (sys.stdout.isatty()): sys.stdout.write(u"\u001B[%dA" % (lines)) sys.stdout.flush() def ansi_cleardown(self): """Clears the remainder of the screen from the cursor downward.""" if (sys.stdout.isatty()): sys.stdout.write(u"\u001B[0J") sys.stdout.flush() def ansi_err(self, s, codes): """Generates an ANSI code as a unicode string. Takes a unicode string to be escaped and a list of ANSI SGR codes. Returns an ANSI-escaped unicode terminal string with those codes activated followed by the unescapde code if the Messenger's stderr is to a tty terminal. Otherwise, the string is returned unmodified.""" if (sys.stderr.isatty()): return u"\u001B[%sm%s\u001B[0m" % \ (";".join(map(unicode, codes)), s) else: return s def terminal_size(self, fd): """returns the current terminal size as (height, width)""" import fcntl import termios import struct #this isn't all that portable, but will have to do return struct.unpack('hh', fcntl.ioctl(fd, termios.TIOCGWINSZ, '1234')) class SilentMessenger(VerboseMessenger): def output(self, s): """Performs no output, resulting in silence.""" pass def partial_output(self, s): """Performs no output, resulting in silence.""" pass def warning(self, s): """Performs no output, resulting in silence.""" pass def info(self, s): """Performs no output, resulting in silence.""" pass def partial_info(self, s): """Performs no output, resulting in silence.""" pass def ansi_clearline(self): """Performs no output, resulting in silence.""" pass def ansi_uplines(self, lines): """Performs no output, resulting in silence.""" pass def ansi_cleardown(self): """Performs no output, resulting in silence.""" pass class ProgressDisplay: """A class for displaying incremental progress updates to the screen.""" def __init__(self, messenger): """Takes a Messenger object for displaying output.""" import time self.messenger = messenger self.previous_output = [] self.progress_rows = [] self.last_output_time = 0.0 self.time = time.time if (sys.stdout.isatty()): self.add_row = self.add_row_tty self.delete_row = self.delete_row_tty self.update_row = self.update_row_tty self.refresh = self.refresh_tty self.clear = self.clear_tty else: self.add_row = self.add_row_nontty self.delete_row = self.delete_row_nontty self.update_row = self.update_row_nontty self.refresh = self.refresh_nontty self.clear = self.clear_nontty def add_row_tty(self, row_id, output_line): """Adds a row of output to be displayed with progress indicated. row_id should be a unique identifier output_line should be a unicode string""" new_row = ProgressRow(row_id, output_line) if (None in self.progress_rows): self.progress_rows[self.progress_rows.index(None)] = new_row else: self.progress_rows.append(new_row) def add_row_nontty(self, row_id, output_line): """Adds a row of output to be displayed with progress indicated. row_id should be a unique identifier output_line should be a unicode string""" pass def delete_row_tty(self, row_id): """Removes the row with the given ID.""" row_index = None for (i, row) in enumerate(self.progress_rows): if ((row is not None) and (row.id == row_id)): row_index = i break if (row_index is not None): self.progress_rows[row_index] = None def delete_row_nontty(self, row_id): """Removes the row with the given ID.""" pass def update_row_tty(self, row_id, current, total): """Updates the given row with a new current and total status.""" for row in self.progress_rows: if ((row is not None) and (row.id == row_id)): row.update(current, total) self.refresh() def update_row_nontty(self, row_id, current, total): """Updates the given row with a new current and total status.""" pass def refresh_tty(self): """Refreshes the display of all status rows. This deletes and redraws output as necessary, depending on whether output has changed since previously displayed.""" now = self.time() if ((now - self.last_output_time) < .25): return screen_width = self.messenger.terminal_size(sys.stdout)[1] new_output = [progress_row.unicode(screen_width) for progress_row in self.progress_rows if progress_row is not None] if (new_output != self.previous_output): self.clear() for output in new_output: self.messenger.output(output) self.previous_output = new_output self.last_output_time = now def refresh_nontty(self): """Refreshes the display of all status rows. This deletes and redraws output as necessary, depending on whether output has changed since previously displayed.""" pass def clear_tty(self): """Clears all previously displayed output.""" if (len(self.previous_output) > 0): self.messenger.ansi_clearline() self.messenger.ansi_uplines(len(self.previous_output)) self.messenger.ansi_cleardown() self.previous_output = [] self.last_output_time = 0.0 def clear_nontty(self): """Clears all previously displayed output.""" pass class SingleProgressDisplay(ProgressDisplay): """A specialized ProgressDisplay for handling a single line of output.""" def __init__(self, messenger, progress_text): """Takes a Messenger class and unicode string for output.""" ProgressDisplay.__init__(self, messenger) self.add_row(0, progress_text) def update(self, current, total): """Updates the output line with new current and total values.""" self.update_row(0, current, total) class ReplayGainProgressDisplay(ProgressDisplay): """A specialized ProgressDisplay for handling ReplayGain application.""" def __init__(self, messenger, lossless_replay_gain): """Takes a Messenger and whether ReplayGain is lossless or not.""" ProgressDisplay.__init__(self, messenger) self.lossless_replay_gain = lossless_replay_gain if (lossless_replay_gain): self.add_row(0, _(u"Adding ReplayGain")) else: self.add_row(0, _(u"Applying ReplayGain")) if (sys.stdout.isatty()): self.initial_message = self.initial_message_tty self.update = self.update_tty self.final_message = self.final_message_tty self.replaygain_row = self.progress_rows[0] else: self.initial_message = self.initial_message_nontty self.update = self.update_nontty self.final_message = self.final_message_nontty def initial_message_tty(self): """Displays a message that ReplayGain application has started.""" pass def initial_message_nontty(self): """Displays a message that ReplayGain application has started.""" if (self.lossless_replay_gain): self.messenger.info( _(u"Adding ReplayGain metadata. This may take some time.")) else: self.messenger.info( _(u"Applying ReplayGain. This may take some time.")) def update_tty(self, current, total): """Updates the current status of ReplayGain application.""" self.replaygain_row.update(current, total) self.refresh() def update_nontty(self, current, total): """Updates the current status of ReplayGain application.""" pass def final_message_tty(self): """Displays a message that ReplayGain application is complete.""" self.clear() if (self.lossless_replay_gain): self.messenger.info(_(u"ReplayGain added")) else: self.messenger.info(_(u"ReplayGain applied")) def final_message_nontty(self): """Displays a message that ReplayGain application is complete.""" pass class ProgressRow: """A class for displaying a single row of progress output.""" def __init__(self, row_id, output_line): """row_id is a unique identifier. output_line is a unicode string""" self.id = row_id self.output_line = display_unicode(output_line) self.current = 0 self.total = 0 self.cached_split_point = -1 self.cached_width = -1 self.cached_unicode = u"" self.ansi = VerboseMessenger("").ansi def update(self, current, total): """updates our row with the current progress values""" self.current = current self.total = total def unicode(self, width): """returns a unicode string formatted to the given width""" try: split_point = (width * self.current) / self.total except ZeroDivisionError: split_point = 0 if ((width == self.cached_width) and (split_point == self.cached_split_point)): return self.cached_unicode else: self.cached_width = width self.cached_split_point = split_point if (len(self.output_line) < width): output_line = self.output_line else: output_line = self.output_line.tail(width) output_line += u" " * (width - len(output_line)) (head, tail) = output_line.split(split_point) output_line = (self.ansi(unicode(head), [VerboseMessenger.FG_WHITE, VerboseMessenger.BG_BLUE]) + unicode(tail)) self.cached_unicode = output_line return output_line class UnsupportedFile(Exception): """Raised by open() if the file can be opened but not identified.""" pass class InvalidFile(Exception): """Raised during initialization if the file is invalid in some way.""" pass class InvalidFormat(Exception): """Raised if an audio file cannot be created correctly from from_pcm() due to having a PCM format unsupported by the output format.""" pass class EncodingError(IOError): """Raised if an audio file cannot be created correctly from from_pcm() due to an error by the encoder.""" def __init__(self, error_message): IOError.__init__(self) self.error_message = error_message def __reduce__(self): return (EncodingError, (self.error_message, )) def __str__(self): if (isinstance(self.error_message, unicode)): return self.error_message.encode('ascii', 'replace') else: return str(self.error_message) def __unicode__(self): return unicode(self.error_message) class UnsupportedChannelMask(EncodingError): """Raised if the encoder does not support the file's channel mask.""" def __init__(self, filename, mask): EncodingError.__init__( self, _(u"Unable to write \"%(target_filename)s\"" + u" with channel assignment \"%(assignment)s\"") % {"target_filename": VerboseMessenger(None).filename(filename), "assignment": audiotools.ChannelMask(mask)}) class UnsupportedChannelCount(EncodingError): """Raised if the encoder does not support the file's channel count.""" def __init__(self, filename, count): EncodingError.__init__( self, _(u"Unable to write \"%(target_filename)s\"" + u" with %(channels)d channel input") % {"target_filename": VerboseMessenger(None).filename(filename), "channels": count}) class UnsupportedBitsPerSample(EncodingError): """Raised if the encoder does not support the file's bits-per-sample.""" def __init__(self, filename, bits_per_sample): EncodingError.__init__( self, _(u"Unable to write \"%(target_filename)s\"" + u" with %(bps)d bits per sample") % {"target_filename": VerboseMessenger(None).filename(filename), "bps": bits_per_sample}) class DecodingError(IOError): """Raised if the decoder exits with an error. Typically, a from_pcm() method will catch this error and raise EncodingError.""" def __init__(self, error_message): IOError.__init__(self) self.error_message = error_message def open(filename): """Returns an AudioFile located at the given filename path. This works solely by examining the file's contents after opening it. Raises UnsupportedFile if it's not a file we support based on its headers. Raises InvalidFile if the file appears to be something we support, but has errors of some sort. Raises IOError if some problem occurs attempting to open the file. """ available_types = frozenset(TYPE_MAP.values()) f = file(filename, "rb") try: for audioclass in TYPE_MAP.values(): f.seek(0, 0) if (audioclass.is_type(f)): return audioclass(filename) else: raise UnsupportedFile(filename) finally: f.close() #takes a list of filenames #returns a list of AudioFile objects, sorted by track_number() #any unsupported files are filtered out def open_files(filename_list, sorted=True, messenger=None): """Returns a list of AudioFile objects from a list of filenames. Files are sorted by album number then track number, by default. Unsupported files are filtered out. Error messages are sent to messenger, if given. """ toreturn = [] if (messenger is None): messenger = Messenger("audiotools", None) for filename in filename_list: try: toreturn.append(open(filename)) except UnsupportedFile: pass except IOError, err: messenger.warning( _(u"Unable to open \"%s\"" % (messenger.filename(filename)))) except InvalidFile, err: messenger.error(unicode(err)) if (sorted): toreturn.sort(lambda x, y: cmp((x.album_number(), x.track_number()), (y.album_number(), y.track_number()))) return toreturn #takes a root directory #iterates recursively over any and all audio files in it #optionally sorted by directory name and track_number() #any unsupported files are filtered out def open_directory(directory, sorted=True, messenger=None): """Yields an AudioFile via a recursive search of directory. Files are sorted by album number/track number by default, on a per-directory basis. Any unsupported files are filtered out. Error messages are sent to messenger, if given. """ for (basedir, subdirs, filenames) in os.walk(directory): if (sorted): subdirs.sort() for audiofile in open_files([os.path.join(basedir, filename) for filename in filenames], sorted=sorted, messenger=messenger): yield audiofile def group_tracks(tracks): """takes an iterable collection of tracks yields list of tracks grouped by album where their album_name and album_number match, if possible""" collection = {} for track in tracks: metadata = track.get_metadata() if (metadata is not None): collection.setdefault((track.album_number(), metadata.album_name), []).append(track) else: collection.setdefault((track.album_number(), None), []).append(track) for tracks in collection.values(): yield tracks class UnknownAudioType(Exception): """Raised if filename_to_type finds no possibilities..""" def __init__(self, suffix): self.suffix = suffix def error_msg(self, messenger): messenger.error(_(u"Unsupported audio type \"%s\"") % (self.suffix)) class AmbiguousAudioType(UnknownAudioType): """Raised if filename_to_type finds more than one possibility.""" def __init__(self, suffix, type_list): self.suffix = suffix self.type_list = type_list def error_msg(self, messenger): messenger.error(_(u"Ambiguious suffix type \"%s\"") % (self.suffix)) messenger.info((_(u"Please use the -t option to specify %s") % (u" or ".join([u"\"%s\"" % (t.NAME.decode('ascii')) for t in self.type_list])))) def filename_to_type(path): """Given a path to a file, return its audio type based on suffix. For example: >>> filename_to_type("/foo/file.flac") Raises an UnknownAudioType exception if the type is unknown. Raise AmbiguousAudioType exception if the type is ambiguous. """ (path, ext) = os.path.splitext(path) if (len(ext) > 0): ext = ext[1:] # remove the "." SUFFIX_MAP = {} for audio_type in TYPE_MAP.values(): SUFFIX_MAP.setdefault(audio_type.SUFFIX, []).append(audio_type) if (ext in SUFFIX_MAP.keys()): if (len(SUFFIX_MAP[ext]) == 1): return SUFFIX_MAP[ext][0] else: raise AmbiguousAudioType(ext, SUFFIX_MAP[ext]) else: raise UnknownAudioType(ext) else: raise UnknownAudioType(ext) class ChannelMask: """An integer-like class that abstracts a PCMReader's channel assignments All channels in a FrameList will be in RIFF WAVE order as a sensible convention. But which channel corresponds to which speaker is decided by this mask. For example, a 4 channel PCMReader with the channel mask 0x33 corresponds to the bits 00110011 reading those bits from right to left (least significant first) the "front_left", "front_right", "back_left", "back_right" speakers are set. Therefore, the PCMReader's 4 channel FrameLists are laid out as follows: channel 0 -> front_left channel 1 -> front_right channel 2 -> back_left channel 3 -> back_right since the "front_center" and "low_frequency" bits are not set, those channels are skipped in the returned FrameLists. Many formats store their channels internally in a different order. Their PCMReaders will be expected to reorder channels and set a ChannelMask matching this convention. And, their from_pcm() functions will be expected to reverse the process. A ChannelMask of 0 is "undefined", which means that channels aren't assigned to *any* speaker. This is an ugly last resort for handling formats where multi-channel assignments aren't properly defined. In this case, a from_pcm() method is free to assign the undefined channels any way it likes, and is under no obligation to keep them undefined when passing back out to to_pcm() """ SPEAKER_TO_MASK = {"front_left": 0x1, "front_right": 0x2, "front_center": 0x4, "low_frequency": 0x8, "back_left": 0x10, "back_right": 0x20, "front_left_of_center": 0x40, "front_right_of_center": 0x80, "back_center": 0x100, "side_left": 0x200, "side_right": 0x400, "top_center": 0x800, "top_front_left": 0x1000, "top_front_center": 0x2000, "top_front_right": 0x4000, "top_back_left": 0x8000, "top_back_center": 0x10000, "top_back_right": 0x20000} MASK_TO_SPEAKER = dict(map(reversed, map(list, SPEAKER_TO_MASK.items()))) MASK_TO_NAME = {0x1: _(u"front left"), 0x2: _(u"front right"), 0x4: _(u"front center"), 0x8: _(u"low frequency"), 0x10: _(u"back left"), 0x20: _(u"back right"), 0x40: _(u"front right of center"), 0x80: _(u"front left of center"), 0x100: _(u"back center"), 0x200: _(u"side left"), 0x400: _(u"side right"), 0x800: _(u"top center"), 0x1000: _(u"top front left"), 0x2000: _(u"top front center"), 0x4000: _(u"top front right"), 0x8000: _(u"top back left"), 0x10000: _(u"top back center"), 0x20000: _(u"top back right")} def __init__(self, mask): """mask should be an integer channel mask value.""" mask = int(mask) for (speaker, speaker_mask) in self.SPEAKER_TO_MASK.items(): setattr(self, speaker, (mask & speaker_mask) != 0) def __unicode__(self): return u", ".join([self.MASK_TO_NAME[key] for key in sorted(self.MASK_TO_SPEAKER.keys()) if getattr(self, self.MASK_TO_SPEAKER[key])]) def __repr__(self): return "ChannelMask(%s)" % \ ",".join(["%s=%s" % (field, getattr(self, field)) for field in self.SPEAKER_TO_MASK.keys() if (getattr(self, field))]) def __int__(self): import operator return reduce(operator.or_, [self.SPEAKER_TO_MASK[field] for field in self.SPEAKER_TO_MASK.keys() if getattr(self, field)], 0) def __eq__(self, v): return int(self) == int(v) def __ne__(self, v): return int(self) != int(v) def __len__(self): return sum([1 for field in self.SPEAKER_TO_MASK.keys() if getattr(self, field)]) def defined(self): """Returns True if this ChannelMask is defined.""" return int(self) != 0 def undefined(self): """Returns True if this ChannelMask is undefined.""" return int(self) == 0 def channels(self): """Returns a list of speaker strings this mask contains. Returned in the order in which they should appear in the PCM stream. """ c = [] for (mask, speaker) in sorted(self.MASK_TO_SPEAKER.items(), lambda x, y: cmp(x[0], y[0])): if (getattr(self, speaker)): c.append(speaker) return c def index(self, channel_name): """Returns the index of the given channel name within this mask. For example, given the mask 0xB (fL, fR, LFE, but no fC) index("low_frequency") will return 2. If the channel is not in this mask, raises ValueError.""" return self.channels().index(channel_name) @classmethod def from_fields(cls, **fields): """Given a set of channel arguments, returns a new ChannelMask. For example: >>> ChannelMask.from_fields(front_left=True,front_right=True) ChannelMask(front_right=True,front_left=True) """ mask = cls(0) for (key, value) in fields.items(): if (key in cls.SPEAKER_TO_MASK.keys()): setattr(mask, key, bool(value)) else: raise KeyError(key) return mask @classmethod def from_channels(cls, channel_count): """Given a channel count, returns a new ChannelMask. This is only valid for channel counts 1 and 2. All other values trigger a ValueError.""" if (channel_count == 2): return cls(0x3) elif (channel_count == 1): return cls(0x4) else: raise ValueError("ambiguous channel assignment") class PCMReader: """A class that wraps around a file object and generates pcm.FrameLists""" def __init__(self, file, sample_rate, channels, channel_mask, bits_per_sample, process=None, signed=True, big_endian=False): """Fields are as follows: file - a file-like object with read() and close() methods sample_rate - an integer number of Hz channels - an integer number of channels channel_mask - an integer channel mask value bits_per_sample - an integer number of bits per sample process - an optional subprocess object signed - True if the file's samples are signed integers big_endian - True if the file's samples are stored big-endian The process, signed and big_endian arguments are optional. PCMReader-compatible objects need only expose the sample_rate, channels, channel_mask and bits_per_sample fields along with the read() and close() methods. """ self.file = file self.sample_rate = sample_rate self.channels = channels self.channel_mask = channel_mask self.bits_per_sample = bits_per_sample self.process = process self.signed = signed self.big_endian = big_endian def read(self, bytes): """Try to read a pcm.FrameList of size "bytes". This is *not* guaranteed to read exactly that number of bytes. It may return less (at the end of the stream, especially). It may return more. However, it must always return a non-empty FrameList until the end of the PCM stream is reached. May raise IOError if unable to read the input file, or ValueError if the input file has some sort of error. """ bytes -= (bytes % (self.channels * self.bits_per_sample / 8)) return pcm.FrameList(self.file.read(max( bytes, self.channels * self.bits_per_sample / 8)), self.channels, self.bits_per_sample, self.big_endian, self.signed) def close(self): """Closes the stream for reading. Any subprocess is waited for also so for proper cleanup. May return DecodingError if a helper subprocess exits with an error status.""" self.file.close() if (self.process is not None): if (self.process.wait() != 0): raise DecodingError(u"subprocess exited with error") class PCMReaderError(PCMReader): """A dummy PCMReader which automatically raises DecodingError. This is to be returned by an AudioFile's to_pcm() method if some error occurs when initializing a decoder. An encoder's from_pcm() method will then catch the DecodingError at close()-time and propogate an EncodingError.""" def __init__(self, error_message, sample_rate, channels, channel_mask, bits_per_sample): PCMReader.__init__(self, None, sample_rate, channels, channel_mask, bits_per_sample) self.error_message = error_message def read(self, bytes): """Always returns an empty framelist.""" return pcm.from_list([], self.channels, self.bits_per_sample, True) def close(self): """Always raises DecodingError.""" raise DecodingError(self.error_message) def analyze_frames(pcmreader): """Iterates over a PCMReader's analyze_frame() results.""" frame = pcmreader.analyze_frame() while (frame is not None): yield frame frame = pcmreader.analyze_frame() pcmreader.close() def to_pcm_progress(audiofile, progress): if (progress is None): return audiofile.to_pcm() else: return PCMReaderProgress(audiofile.to_pcm(), audiofile.total_frames(), progress) class PCMReaderProgress: def __init__(self, pcmreader, total_frames, progress): self.__read__ = pcmreader.read self.__close__ = pcmreader.close self.sample_rate = pcmreader.sample_rate self.channels = pcmreader.channels self.channel_mask = pcmreader.channel_mask self.bits_per_sample = pcmreader.bits_per_sample self.current_frames = 0 self.total_frames = total_frames self.progress = progress def read(self, bytes): frame = self.__read__(bytes) self.current_frames += frame.frames self.progress(self.current_frames, self.total_frames) return frame def close(self): self.__close__() class ReorderedPCMReader: """A PCMReader wrapper which reorders its output channels.""" def __init__(self, pcmreader, channel_order): """Initialized with a PCMReader and list of channel number integers. For example, to swap the channels of a stereo stream: >>> ReorderedPCMReader(reader,[1,0]) """ self.pcmreader = pcmreader self.sample_rate = pcmreader.sample_rate self.channels = pcmreader.channels self.channel_mask = pcmreader.channel_mask self.bits_per_sample = pcmreader.bits_per_sample self.channel_order = channel_order def read(self, bytes): """Try to read a pcm.FrameList of size "bytes".""" framelist = self.pcmreader.read(bytes) return pcm.from_channels([framelist.channel(channel) for channel in self.channel_order]) def close(self): """Closes the stream.""" self.pcmreader.close() def transfer_data(from_function, to_function): """Sends BUFFER_SIZE strings from from_function to to_function. This continues until an empty string is returned from from_function.""" try: s = from_function(BUFFER_SIZE) while (len(s) > 0): to_function(s) s = from_function(BUFFER_SIZE) except IOError: #this usually means a broken pipe, so we can only hope #the data reader is closing down correctly pass def transfer_framelist_data(pcmreader, to_function, signed=True, big_endian=False): """Sends pcm.FrameLists from pcmreader to to_function. FrameLists are converted to strings using the signed and big_endian arguments. This continues until an empty FrameLists is returned from pcmreader. """ f = pcmreader.read(BUFFER_SIZE) while (len(f) > 0): to_function(f.to_bytes(big_endian, signed)) f = pcmreader.read(BUFFER_SIZE) def threaded_transfer_framelist_data(pcmreader, to_function, signed=True, big_endian=False): """Sends pcm.FrameLists from pcmreader to to_function via threads. FrameLists are converted to strings using the signed and big_endian arguments. This continues until an empty FrameLists is returned from pcmreader. It operates by splitting reading and writing into threads in the hopes that an intermittant reader will not disrupt the writer. """ import threading import Queue def send_data(pcmreader, queue): try: s = pcmreader.read(BUFFER_SIZE) while (len(s) > 0): queue.put(s) s = pcmreader.read(BUFFER_SIZE) queue.put(None) except (IOError, ValueError): queue.put(None) data_queue = Queue.Queue(10) #thread.start_new_thread(send_data,(from_function,data_queue)) thread = threading.Thread(target=send_data, args=(pcmreader, data_queue)) thread.setDaemon(True) thread.start() s = data_queue.get() while (s is not None): to_function(s) s = data_queue.get() class __capped_stream_reader__: #allows a maximum number of bytes "length" to #be read from file-like object "stream" #(used for reading IFF chunks, among others) def __init__(self, stream, length): self.stream = stream self.remaining = length def read(self, bytes): data = self.stream.read(min(bytes, self.remaining)) self.remaining -= len(data) return data def close(self): self.stream.close() def pcm_cmp(pcmreader1, pcmreader2): """Returns True if the PCM data in pcmreader1 equals pcmreader2. The readers must be closed separately. """ if ((pcmreader1.sample_rate != pcmreader2.sample_rate) or (pcmreader1.channels != pcmreader2.channels) or (pcmreader1.bits_per_sample != pcmreader2.bits_per_sample)): return False reader1 = BufferedPCMReader(pcmreader1) reader2 = BufferedPCMReader(pcmreader2) s1 = reader1.read(BUFFER_SIZE) s2 = reader2.read(BUFFER_SIZE) while ((len(s1) > 0) and (len(s2) > 0)): if (s1 != s2): transfer_data(reader1.read, lambda x: x) transfer_data(reader2.read, lambda x: x) return False else: s1 = reader1.read(BUFFER_SIZE) s2 = reader2.read(BUFFER_SIZE) return True def stripped_pcm_cmp(pcmreader1, pcmreader2): """Returns True if the stripped PCM data of pcmreader1 equals pcmreader2. This operates by reading each PCM streams entirely to memory, performing strip() on their output and comparing checksums (which permits us to store just one big blob of memory at a time). """ if ((pcmreader1.sample_rate != pcmreader2.sample_rate) or (pcmreader1.channels != pcmreader2.channels) or (pcmreader1.bits_per_sample != pcmreader2.bits_per_sample)): return False try: from hashlib import sha1 as sha except ImportError: from sha import new as sha data = cStringIO.StringIO() transfer_framelist_data(pcmreader1, data.write) sum1 = sha(data.getvalue().strip(chr(0x00))) data = cStringIO.StringIO() transfer_framelist_data(pcmreader2, data.write) sum2 = sha(data.getvalue().strip(chr(0x00))) del(data) return sum1.digest() == sum2.digest() def pcm_frame_cmp(pcmreader1, pcmreader2): """Returns the PCM Frame number of the first mismatch. If the two streams match completely, returns None. May raise IOError or ValueError if problems occur when reading PCM streams.""" if ((pcmreader1.sample_rate != pcmreader2.sample_rate) or (pcmreader1.channels != pcmreader2.channels) or (pcmreader1.bits_per_sample != pcmreader2.bits_per_sample)): return 0 if ((pcmreader1.channel_mask != 0) and (pcmreader2.channel_mask != 0) and (pcmreader1.channel_mask != pcmreader2.channel_mask)): return 0 frame_number = 0 reader1 = BufferedPCMReader(pcmreader1) reader2 = BufferedPCMReader(pcmreader2) framelist1 = reader1.read(BUFFER_SIZE) framelist2 = reader2.read(BUFFER_SIZE) while ((len(framelist1) > 0) and (len(framelist2) > 0)): if (framelist1 != framelist2): for i in xrange(min(framelist1.frames, framelist2.frames)): if (framelist1.frame(i) != framelist2.frame(i)): return frame_number + i else: return frame_number + i else: frame_number += framelist1.frames framelist1 = reader1.read(BUFFER_SIZE) framelist2 = reader2.read(BUFFER_SIZE) return None class PCMCat(PCMReader): """A PCMReader for concatenating several PCMReaders.""" def __init__(self, pcmreaders): """pcmreaders is an iterator of PCMReader objects. Note that this currently does no error checking to ensure reads have the same sample_rate, channels, bits_per_sample or channel mask! One must perform that check prior to building a PCMCat. """ self.reader_queue = pcmreaders try: self.first = self.reader_queue.next() except StopIteration: raise ValueError(_(u"You must have at least 1 PCMReader")) self.sample_rate = self.first.sample_rate self.channels = self.first.channels self.channel_mask = self.first.channel_mask self.bits_per_sample = self.first.bits_per_sample def read(self, bytes): """Try to read a pcm.FrameList of size "bytes".""" try: s = self.first.read(bytes) if (len(s) > 0): return s else: self.first.close() self.first = self.reader_queue.next() return self.read(bytes) except StopIteration: return pcm.from_list([], self.channels, self.bits_per_sample, True) def close(self): """Closes the stream for reading.""" pass class __buffer__: def __init__(self, channels, bits_per_sample, framelists=None): if (framelists is None): self.buffer = [] else: self.buffer = framelists self.end_frame = pcm.from_list([], channels, bits_per_sample, True) self.bytes_per_sample = bits_per_sample / 8 #returns the length of the entire buffer in bytes def __len__(self): if (len(self.buffer) > 0): return sum(map(len, self.buffer)) * self.bytes_per_sample else: return 0 def framelist(self): import operator return reduce(operator.concat, self.buffer, self.end_frame) def push(self, s): self.buffer.append(s) def pop(self): return self.buffer.pop(0) def unpop(self, s): self.buffer.insert(0, s) class BufferedPCMReader: """A PCMReader which reads exact counts of bytes.""" def __init__(self, pcmreader): """pcmreader is a regular PCMReader object.""" self.pcmreader = pcmreader self.sample_rate = pcmreader.sample_rate self.channels = pcmreader.channels self.channel_mask = pcmreader.channel_mask self.bits_per_sample = pcmreader.bits_per_sample self.buffer = __buffer__(self.channels, self.bits_per_sample) self.reader_finished = False def close(self): """Closes the sub-pcmreader and frees our internal buffer.""" del(self.buffer) self.pcmreader.close() def read(self, bytes): """Reads as close to "bytes" number of bytes without going over. This uses an internal buffer to ensure reading the proper number of bytes on each call. """ #fill our buffer to at least "bytes", possibly more self.__fill__(bytes) output_framelist = self.buffer.framelist() (output, remainder) = output_framelist.split( output_framelist.frame_count(bytes)) self.buffer.buffer = [remainder] return output #try to fill our internal buffer to at least "bytes" def __fill__(self, bytes): while ((len(self.buffer) < bytes) and (not self.reader_finished)): s = self.pcmreader.read(BUFFER_SIZE) if (len(s) > 0): self.buffer.push(s) else: self.reader_finished = True class LimitedPCMReader: def __init__(self, buffered_pcmreader, total_pcm_frames): """buffered_pcmreader should be a BufferedPCMReader which ensures we won't pull more frames off the reader than necessary upon calls to read()""" self.pcmreader = buffered_pcmreader self.total_pcm_frames = total_pcm_frames self.sample_rate = self.pcmreader.sample_rate self.channels = self.pcmreader.channels self.channel_mask = self.pcmreader.channel_mask self.bits_per_sample = self.pcmreader.bits_per_sample self.bytes_per_frame = self.channels * (self.bits_per_sample / 8) def read(self, bytes): if (self.total_pcm_frames > 0): frame = self.pcmreader.read( min(bytes, self.total_pcm_frames * self.bytes_per_frame)) self.total_pcm_frames -= frame.frames return frame else: return pcm.FrameList("", self.channels, self.bits_per_sample, False, True) def close(self): self.total_pcm_frames = 0 def pcm_split(reader, pcm_lengths): """Yields a PCMReader object from reader for each pcm_length (in frames). Each sub-reader is pcm_length PCM frames long with the same channels, bits_per_sample, sample_rate and channel_mask as the full stream. reader is closed upon completion. """ import tempfile def chunk_sizes(total_size, chunk_size): while (total_size > chunk_size): total_size -= chunk_size yield chunk_size yield total_size full_data = BufferedPCMReader(reader) for byte_length in [i * reader.channels * reader.bits_per_sample / 8 for i in pcm_lengths]: if (byte_length > (BUFFER_SIZE * 10)): #if the sub-file length is somewhat large, use a temporary file sub_file = tempfile.TemporaryFile() for size in chunk_sizes(byte_length, BUFFER_SIZE): sub_file.write(full_data.read(size).to_bytes(False, True)) sub_file.seek(0, 0) else: #if the sub-file length is very small, use StringIO sub_file = cStringIO.StringIO( full_data.read(byte_length).to_bytes(False, True)) yield PCMReader(sub_file, reader.sample_rate, reader.channels, reader.channel_mask, reader.bits_per_sample) full_data.close() #going from many channels to less channels class __channel_remover__: def __init__(self, old_channel_mask, new_channel_mask): old_channels = ChannelMask(old_channel_mask).channels() self.channels_to_keep = [] for new_channel in ChannelMask(new_channel_mask).channels(): if (new_channel in old_channels): self.channels_to_keep.append(old_channels.index(new_channel)) def convert(self, frame_list): return pcm.from_channels( [frame_list.channel(i) for i in self.channels_to_keep]) class __channel_adder__: def __init__(self, channels): self.channels = channels def convert(self, frame_list): current_channels = [frame_list.channel(i) for i in xrange(frame_list.channels)] while (len(current_channels) < self.channels): current_channels.append(current_channels[0]) return pcm.from_channels(current_channels) class __stereo_to_mono__: def __init__(self): pass def convert(self, frame_list): return pcm.from_list( [(l + r) / 2 for l, r in izip(frame_list.channel(0), frame_list.channel(1))], 1, frame_list.bits_per_sample, True) #going from many channels to 2 class __downmixer__: def __init__(self, old_channel_mask, old_channel_count): #grab the front_left, front_right, front_center, #back_left and back_right channels from old frame_list, if possible #missing channels are replaced with 0-sample channels #excess channels are dropped entirely #side_left and side_right may be substituted for back_left/right #but back channels take precedence if (int(old_channel_mask) == 0): #if the old_channel_mask is undefined #invent a channel mask based on the channel count old_channel_mask = {1: ChannelMask.from_fields(front_center=True), 2: ChannelMask.from_fields(front_left=True, front_right=True), 3: ChannelMask.from_fields(front_left=True, front_right=True, front_center=True), 4: ChannelMask.from_fields(front_left=True, front_right=True, back_left=True, back_right=True), 5: ChannelMask.from_fields(front_left=True, front_right=True, front_center=True, back_left=True, back_right=True)}[ min(old_channel_count, 5)] else: old_channel_mask = ChannelMask(old_channel_mask) #channels_to_keep is an array of channel offsets #where the index is: #0 - front_left #1 - front_right #2 - front_center #3 - back/side_left #4 - back/side_right #if -1, the channel is blank self.channels_to_keep = [] for channel in ["front_left", "front_right", "front_center"]: if (getattr(old_channel_mask, channel)): self.channels_to_keep.append(old_channel_mask.index(channel)) else: self.channels_to_keep.append(-1) if (old_channel_mask.back_left): self.channels_to_keep.append(old_channel_mask.index("back_left")) elif (old_channel_mask.side_left): self.channels_to_keep.append(old_channel_mask.index("side_left")) else: self.channels_to_keep.append(-1) if (old_channel_mask.back_right): self.channels_to_keep.append(old_channel_mask.index("back_right")) elif (old_channel_mask.side_right): self.channels_to_keep.append(old_channel_mask.index("side_right")) else: self.channels_to_keep.append(-1) self.has_empty_channels = (-1 in self.channels_to_keep) def convert(self, frame_list): REAR_GAIN = 0.6 CENTER_GAIN = 0.7 if (self.has_empty_channels): empty_channel = pcm.from_list([0] * frame_list.frames, 1, frame_list.bits_per_sample, True) if (self.channels_to_keep[0] != -1): Lf = frame_list.channel(self.channels_to_keep[0]) else: Lf = empty_channel if (self.channels_to_keep[1] != -1): Rf = frame_list.channel(self.channels_to_keep[1]) else: Rf = empty_channel if (self.channels_to_keep[2] != -1): C = frame_list.channel(self.channels_to_keep[2]) else: C = empty_channel if (self.channels_to_keep[3] != -1): Lr = frame_list.channel(self.channels_to_keep[3]) else: Lr = empty_channel if (self.channels_to_keep[4] != -1): Rr = frame_list.channel(self.channels_to_keep[4]) else: Rr = empty_channel mono_rear = [0.7 * (Lr_i + Rr_i) for Lr_i, Rr_i in izip(Lr, Rr)] converter = lambda x: int(round(x)) left_channel = pcm.from_list( [converter(Lf_i + (REAR_GAIN * mono_rear_i) + (CENTER_GAIN * C_i)) for Lf_i, mono_rear_i, C_i in izip(Lf, mono_rear, C)], 1, frame_list.bits_per_sample, True) right_channel = pcm.from_list( [converter(Rf_i - (REAR_GAIN * mono_rear_i) + (CENTER_GAIN * C_i)) for Rf_i, mono_rear_i, C_i in izip(Rf, mono_rear, C)], 1, frame_list.bits_per_sample, True) return pcm.from_channels([left_channel, right_channel]) #going from many channels to 1 class __downmix_to_mono__: def __init__(self, old_channel_mask, old_channel_count): self.downmix = __downmixer__(old_channel_mask, old_channel_count) self.mono = __stereo_to_mono__() def convert(self, frame_list): return self.mono.convert(self.downmix.convert(frame_list)) class __convert_sample_rate__: def __init__(self, old_sample_rate, new_sample_rate, channels, bits_per_sample): from . import resample self.resampler = resample.Resampler( channels, float(new_sample_rate) / float(old_sample_rate), 0) self.unresampled = pcm.FloatFrameList([], channels) self.bits_per_sample = bits_per_sample def convert(self, frame_list): #FIXME - The floating-point output from resampler.process() #should be normalized rather than just chopping off #excessively high or low samples (above 1.0 or below -1.0) #during conversion to PCM. #Unfortunately, that'll require building a second pass #into the conversion process which will complicate PCMConverter #a lot. (output, self.unresampled) = self.resampler.process( self.unresampled + frame_list.to_float(), (len(frame_list) == 0) and (len(self.unresampled) == 0)) return output.to_int(self.bits_per_sample) class __convert_sample_rate_and_bits_per_sample__(__convert_sample_rate__): def convert(self, frame_list): (output, self.unresampled) = self.resampler.process( self.unresampled + frame_list.to_float(), (len(frame_list) == 0) and (len(self.unresampled) == 0)) return __add_dither__(output.to_int(self.bits_per_sample)) class __convert_bits_per_sample__: def __init__(self, bits_per_sample): self.bits_per_sample = bits_per_sample def convert(self, frame_list): return __add_dither__( frame_list.to_float().to_int(self.bits_per_sample)) def __add_dither__(frame_list): if (frame_list.bits_per_sample >= 16): random_bytes = map(ord, os.urandom((len(frame_list) / 8) + 1)) white_noise = [(random_bytes[i / 8] & (1 << (i % 8))) >> (i % 8) for i in xrange(len(frame_list))] else: white_noise = [0] * len(frame_list) return pcm.from_list([i ^ w for (i, w) in izip(frame_list, white_noise)], frame_list.channels, frame_list.bits_per_sample, True) class PCMConverter: """A PCMReader wrapper for converting attributes. For example, this can be used to alter sample_rate, bits_per_sample, channel_mask, channel count, or any combination of those attributes. It resamples, downsamples, etc. to achieve the proper output. """ def __init__(self, pcmreader, sample_rate, channels, channel_mask, bits_per_sample): """Takes a PCMReader input and the attributes of the new stream.""" self.sample_rate = sample_rate self.channels = channels self.bits_per_sample = bits_per_sample self.channel_mask = channel_mask self.reader = pcmreader self.conversions = [] if (self.reader.channels != self.channels): if (self.channels == 1): self.conversions.append( __downmix_to_mono__(pcmreader.channel_mask, pcmreader.channels)) elif (self.channels == 2): self.conversions.append( __downmixer__(pcmreader.channel_mask, pcmreader.channels)) elif (self.channels < pcmreader.channels): self.conversions.append( __channel_remover__(pcmreader.channel_mask, channel_mask)) elif (self.channels > pcmreader.channels): self.conversions.append( __channel_adder__(self.channels)) if (self.reader.sample_rate != self.sample_rate): #if we're converting sample rate and bits-per-sample #at the same time, short-circuit the conversion to do both at once #which can be sped up somewhat if (self.reader.bits_per_sample != self.bits_per_sample): self.conversions.append( __convert_sample_rate_and_bits_per_sample__( self.reader.sample_rate, self.sample_rate, self.channels, self.bits_per_sample)) else: self.conversions.append( __convert_sample_rate__( self.reader.sample_rate, self.sample_rate, self.channels, self.bits_per_sample)) else: if (self.reader.bits_per_sample != self.bits_per_sample): self.conversions.append( __convert_bits_per_sample__( self.bits_per_sample)) def read(self, bytes): """Try to read a pcm.FrameList of size "bytes".""" frame_list = self.reader.read(bytes) for converter in self.conversions: frame_list = converter.convert(frame_list) return frame_list def close(self): """Closes the stream for reading.""" self.reader.close() class ReplayGainReader: """A PCMReader which applies ReplayGain on its output.""" def __init__(self, pcmreader, replaygain, peak): """Fields are: pcmreader - a PCMReader object replaygain - a floating point dB value peak - the maximum absolute value PCM sample, as a float The latter two are typically stored with the file, split into album gain and track gain pairs which the user can apply based on preference. """ self.reader = pcmreader self.sample_rate = pcmreader.sample_rate self.channels = pcmreader.channels self.channel_mask = pcmreader.channel_mask self.bits_per_sample = pcmreader.bits_per_sample self.replaygain = replaygain self.peak = peak self.bytes_per_sample = self.bits_per_sample / 8 self.multiplier = 10 ** (replaygain / 20) #if we're increasing the volume (multipler is positive) #and that increases the peak beyond 1.0 (which causes clipping) #reduce the multiplier so that the peak doesn't go beyond 1.0 if ((self.multiplier * self.peak) > 1.0): self.multiplier = 1.0 / self.peak def read(self, bytes): """Try to read a pcm.FrameList of size "bytes".""" multiplier = self.multiplier samples = self.reader.read(bytes) if (self.bits_per_sample >= 16): random_bytes = map(ord, os.urandom((len(samples) / 8) + 1)) white_noise = [(random_bytes[i / 8] & (1 << (i % 8))) >> (i % 8) for i in xrange(len(samples))] else: white_noise = [0] * len(samples) return pcm.from_list( [(int(round(s * multiplier)) ^ w) for (s, w) in izip(samples, white_noise)], samples.channels, samples.bits_per_sample, True) def close(self): """Closes the stream for reading.""" self.reader.close() def applicable_replay_gain(tracks): """Returns True if ReplayGain can be applied to a list of AudioFiles. This checks their sample rate and channel count to determine applicability.""" sample_rates = set([track.sample_rate() for track in tracks]) if ((len(sample_rates) > 1) or (list(sample_rates)[0] not in (48000, 44100, 32000, 24000, 22050, 16000, 12000, 11025, 8000, 18900, 37800, 56000, 64000, 88200, 96000, 112000, 128000, 144000, 176400, 192000))): return False channels = set([track.channels() for track in tracks]) if ((len(channels) > 1) or (list(channels)[0] not in (1, 2))): return False return True def calculate_replay_gain(tracks, progress=None): """Yields (track, track_gain, track_peak, album_gain, album_peak) for each AudioFile in the list of tracks. Raises ValueError if a problem occurs during calculation.""" from . import replaygain as replaygain sample_rate = set([track.sample_rate() for track in tracks]) if (len(sample_rate) != 1): raise ValueError(("at least one track is required " + "and all must have the same sample rate")) total_frames = sum([track.total_frames() for track in tracks]) processed_frames = 0 rg = replaygain.ReplayGain(list(sample_rate)[0]) gains = [] for track in tracks: pcm = track.to_pcm() frame = pcm.read(BUFFER_SIZE) while (len(frame) > 0): rg.update(frame) processed_frames += frame.frames if (progress is not None): progress(processed_frames, total_frames) frame = pcm.read(BUFFER_SIZE) pcm.close() (track_gain, track_peak) = rg.title_gain() gains.append((track, track_gain, track_peak)) (album_gain, album_peak) = rg.album_gain() for (track, track_gain, track_peak) in gains: yield (track, track_gain, track_peak, album_gain, album_peak) class InterruptableReader(PCMReader): """A PCMReader meant for audio recording. It runs read() in a separate thread and stops recording when SIGINT is caught. """ def __init__(self, pcmreader, verbose=True): """Takes PCMReader object and verbosity flag.""" #FIXME - update this for Messenger support import threading import Queue import signal PCMReader.__init__(self, pcmreader, sample_rate=pcmreader.sample_rate, channels=pcmreader.channels, channel_mask=pcmreader.channel_mask, bits_per_sample=pcmreader.bits_per_sample) self.stop_reading = False self.data_queue = Queue.Queue() self.old_sigint = signal.signal(signal.SIGINT, self.stop) thread = threading.Thread(target=self.send_data) thread.setDaemon(True) thread.start() self.verbose = verbose def stop(self, *args): """The SIGINT signal handler which stops recording.""" import signal self.stop_reading = True signal.signal(signal.SIGINT, self.old_sigint) if (self.verbose): print "Stopping..." def send_data(self): """The thread for outputting PCM data from reader.""" #try to use a half second long buffer BUFFER_SIZE = self.sample_rate * (self.bits_per_sample / 8) * \ self.channels / 2 s = self.file.read(BUFFER_SIZE) while ((len(s) > 0) and (not self.stop_reading)): self.data_queue.put(s) s = self.file.read(BUFFER_SIZE) self.data_queue.put("") def read(self, length): """Try to read a pcm.FrameList of size "bytes".""" return self.data_queue.get() def ignore_sigint(): """Sets the SIGINT signal to SIG_IGN. Some encoder executables require this in order for InterruptableReader to work correctly since we want to catch SIGINT ourselves in that case and perform a proper shutdown.""" import signal signal.signal(signal.SIGINT, signal.SIG_IGN) def make_dirs(destination_path): """Ensures all directories leading to destination_path are created. Raises OSError if a problem occurs during directory creation. """ dirname = os.path.dirname(destination_path) if ((dirname != '') and (not os.path.isdir(dirname))): os.makedirs(dirname) ####################### #Generic MetaData ####################### class MetaData: """The base class for storing textual AudioFile metadata. This includes things like track name, track number, album name and so forth. It also includes embedded images, if present. Fields are stored with the same name they are initialized with. Except for images, they can all be manipulated directly (images have dedicated set/get/delete methods instead). Subclasses are expected to override getattr/setattr so that updating attributes will adjust the low-level attributes accordingly. """ __FIELDS__ = ("track_name", "track_number", "track_total", "album_name", "artist_name", "performer_name", "composer_name", "conductor_name", "media", "ISRC", "catalog", "copyright", "publisher", "year", "date", "album_number", "album_total", "comment") __INTEGER_FIELDS__ = ("track_number", "track_total", "album_number", "album_total") def __init__(self, track_name=u"", track_number=0, track_total=0, album_name=u"", artist_name=u"", performer_name=u"", composer_name=u"", conductor_name=u"", media=u"", ISRC=u"", catalog=u"", copyright=u"", publisher=u"", year=u"", date=u"", album_number=0, album_total=0, comment=u"", images=None): """Fields are as follows: track_name - the name of this individual track track_number - the number of this track track_total - the total number of tracks album_name - the name of this track's album artist_name - the song's original creator/composer performer_name - the song's performing artist composer_name - the song's composer name conductor_name - the song's conductor's name media - the album's media type (CD,tape,etc.) ISRC - the song's ISRC catalog - the album's catalog number copyright - the song's copyright information publisher - the song's publisher year - the album's release year date - the original recording date album_number - the disc's volume number, if any album_total - the total number of discs, if any comment - the track's comment string images - a list of Image objects track_number, track_total, album_number and album_total are ints. images is an optional list of Image objects. The rest are unicode strings. """ #we're avoiding self.foo = foo because #__setattr__ might need to be redefined #which could lead to unwelcome side-effects self.__dict__['track_name'] = track_name self.__dict__['track_number'] = track_number self.__dict__['track_total'] = track_total self.__dict__['album_name'] = album_name self.__dict__['artist_name'] = artist_name self.__dict__['performer_name'] = performer_name self.__dict__['composer_name'] = composer_name self.__dict__['conductor_name'] = conductor_name self.__dict__['media'] = media self.__dict__['ISRC'] = ISRC self.__dict__['catalog'] = catalog self.__dict__['copyright'] = copyright self.__dict__['publisher'] = publisher self.__dict__['year'] = year self.__dict__['date'] = date self.__dict__['album_number'] = album_number self.__dict__['album_total'] = album_total self.__dict__['comment'] = comment if (images is not None): self.__dict__['__images__'] = list(images) else: self.__dict__['__images__'] = list() def __repr__(self): return ("MetaData(%s)" % ( ",".join(["%s"] * (len(MetaData.__FIELDS__))))) % \ tuple(["%s=%s" % (field, repr(getattr(self, field))) for field in MetaData.__FIELDS__]) def __delattr__(self, field): if (field in self.__FIELDS__): if (field in self.__INTEGER_FIELDS__): self.__dict__[field] = 0 else: self.__dict__[field] = u"" else: try: del(self.__dict__[field]) except KeyError: raise AttributeError(field) #returns the type of comment this is, as a unicode string def __comment_name__(self): return u'MetaData' #returns a list of (key,value) tuples def __comment_pairs__(self): return zip(("Title", "Artist", "Performer", "Composer", "Conductor", "Album", "Catalog", "Track Number", "Track Total", "Volume Number", "Volume Total", "ISRC", "Publisher", "Media", "Year", "Date", "Copyright", "Comment"), (self.track_name, self.artist_name, self.performer_name, self.composer_name, self.conductor_name, self.album_name, self.catalog, str(self.track_number), str(self.track_total), str(self.album_number), str(self.album_total), self.ISRC, self.publisher, self.media, self.year, self.date, self.copyright, self.comment)) def __unicode__(self): comment_pairs = self.__comment_pairs__() if (len(comment_pairs) > 0): max_key_length = max([len(pair[0]) for pair in comment_pairs]) line_template = u"%%(key)%(length)d.%(length)ds : %%(value)s" % \ {"length": max_key_length} base_comment = unicode(os.linesep.join( [_(u"%s Comment:") % (self.__comment_name__())] + \ [line_template % {"key": key, "value": value} for (key, value) in comment_pairs])) else: base_comment = u"" if (len(self.images()) > 0): return u"%s%s%s" % \ (base_comment, os.linesep * 2, os.linesep.join([unicode(p) for p in self.images()])) else: return base_comment def __eq__(self, metadata): if (metadata is not None): return set([(getattr(self, attr) == getattr(metadata, attr)) for attr in MetaData.__FIELDS__]) == set([True]) else: return False def __ne__(self, metadata): return not self.__eq__(metadata) @classmethod def converted(cls, metadata): """Converts metadata from another class to this one, if necessary. Takes a MetaData-compatible object (or None) and returns a new MetaData subclass with the data fields converted or None if metadata is None or conversion isn't possible. For instance, VorbisComment.converted() returns a VorbisComment class. This way, AudioFiles can offload metadata conversions. """ if (metadata is not None): fields = dict([(field, getattr(metadata, field)) for field in cls.__FIELDS__]) fields["images"] = metadata.images() return MetaData(**fields) else: return None @classmethod def supports_images(cls): """Returns True if this MetaData class supports embedded images.""" return True def images(self): """Returns a list of embedded Image objects.""" #must return a copy of our internal array #otherwise this will likely not act as expected when deleting return self.__images__[:] def front_covers(self): """Returns a subset of images() which are front covers.""" return [i for i in self.images() if i.type == 0] def back_covers(self): """Returns a subset of images() which are back covers.""" return [i for i in self.images() if i.type == 1] def leaflet_pages(self): """Returns a subset of images() which are leaflet pages.""" return [i for i in self.images() if i.type == 2] def media_images(self): """Returns a subset of images() which are media images.""" return [i for i in self.images() if i.type == 3] def other_images(self): """Returns a subset of images() which are other images.""" return [i for i in self.images() if i.type == 4] def add_image(self, image): """Embeds an Image object in this metadata. Implementations of this method should also affect the underlying metadata value (e.g. adding a new Image to FlacMetaData should add another METADATA_BLOCK_PICTURE block to the metadata). """ if (self.supports_images()): self.__images__.append(image) else: raise ValueError(_(u"This MetaData type does not support images")) def delete_image(self, image): """Deletes an Image object from this metadata. Implementations of this method should also affect the underlying metadata value (e.g. removing an existing Image from FlacMetaData should remove that same METADATA_BLOCK_PICTURE block from the metadata). """ if (self.supports_images()): self.__images__.pop(self.__images__.index(image)) else: raise ValueError(_(u"This MetaData type does not support images")) def merge(self, metadata): """Updates any currently empty entries from metadata's values. >>> m = MetaData(track_name=u"Track 1",artist_name=u"Artist") >>> m2 = MetaData(track_name=u"Track 2",album_name=u"Album") >>> m.merge(m2) >>> m.track_name u'Track 1' >>> m.artist_name u'Artist' >>> m.album_name u'Album' Subclasses of MetaData should implement this method to handle any empty fields their format supports. """ if (metadata is None): return fields = {} for field in self.__FIELDS__: if (field not in self.__INTEGER_FIELDS__): if (len(getattr(self, field)) == 0): setattr(self, field, getattr(metadata, field)) else: if (getattr(self, field) == 0): setattr(self, field, getattr(metadata, field)) if ((len(self.images()) == 0) and self.supports_images()): for img in metadata.images(): self.add_image(img) class AlbumMetaData(dict): """A container for several MetaData objects. They can be retrieved by track number.""" def __init__(self, metadata_iter): """metadata_iter is an iterator of MetaData objects.""" dict.__init__(self, dict([(m.track_number, m) for m in metadata_iter])) def metadata(self): """Returns a single MetaData object of all consistent fields. For example, if album_name is the same in all MetaData objects, the returned object will have that album_name value. If track_name differs, the returned object will not have a track_name field. """ return MetaData(**dict([(field, list(items)[0]) for (field, items) in [(field, set([getattr(track, field) for track in self.values()])) for field in MetaData.__FIELDS__] if (len(items) == 1)])) class MetaDataFileException(Exception): """A superclass of XMCDException and MBXMLException. This allows one to handle any sort of metadata file exception consistently.""" def __unicode__(self): return _(u"Invalid XMCD or MusicBrainz XML file") class AlbumMetaDataFile: """A base class for MetaData containing files. This includes FreeDB's XMCD files and MusicBrainz's XML files.""" def __init__(self, album_name, artist_name, year, catalog, extra, track_metadata): """track_metadata is a list of tuples. The rest are unicode.""" self.album_name = album_name self.artist_name = artist_name self.year = year self.catalog = catalog self.extra = extra self.track_metadata = track_metadata def __len__(self): return len(self.track_metadata) def to_string(self): """Returns this object as a plain string of data.""" raise NotImplementedError() @classmethod def from_string(cls, string): """Given a plain string, returns an object of this class. Raises MetaDataFileException if a parsing error occurs.""" raise NotImplementedError() def get_track(self, index): """Given a track index (from 0), returns (name, artist, extra). name, artist and extra are unicode strings. Raises IndexError if out-of-bounds.""" return self.track_metadata[i] def set_track(self, index, name, artist, extra): """Sets the track at the given index (from 0) to the given values. Raises IndexError if out-of-bounds.""" self.track_metadata[i] = (name, artist, extra) @classmethod def from_tracks(cls, tracks): """Given a list of AudioFile objects, returns an AlbumMetaDataFile. All files are presumed to be from the same album.""" raise NotImplementedError() @classmethod def from_cuesheet(cls, cuesheet, total_frames, sample_rate, metadata=None): """Returns an AlbumMetaDataFile from a cuesheet. This must also include a total_frames and sample_rate integer. This works by generating a set of empty tracks and calling the from_tracks() method to build a MetaData file with the proper placeholders. metadata, if present, is applied to all tracks.""" if (metadata is None): metadata = MetaData() return cls.from_tracks([DummyAudioFile( length=(pcm_frames * 75) / sample_rate, metadata=metadata, track_number=i + 1) for (i, pcm_frames) in enumerate( cuesheet.pcm_lengths(total_frames))]) def track_metadata(self, track_number): """Given a track_number (from 1), returns a MetaData object. Raises IndexError if out-of-bounds or None if track_number is 0.""" if (track_number == 0): return None (track_name, track_artist, track_extra) = self.get_track(track_number - 1) if (len(track_artist) == 0): track_artist = self.artist_name return MetaData(track_name=track_name, track_number=track_number, track_total=len(self), album_name=self.album_name, artist_name=track_artist, catalog=self.catalog, year=self.year) def get(self, track_index, default): try: return self.track_metadata(track_index) except IndexError: return default def track_metadatas(self): """Iterates over all the MetaData objects in this file.""" for i in xrange(len(self)): yield self.track_metadata(i + 1) def metadata(self): """Returns a single MetaData object of all consistent fields. For example, if album_name is the same in all MetaData objects, the returned object will have that album_name value. If track_name differs, the returned object will not have a track_name field. """ return MetaData(**dict([(field, list(items)[0]) for (field, items) in [(field, set([getattr(track, field) for track in self.track_metadatas()])) for field in MetaData.__FIELDS__] if (len(items) == 1)])) ####################### #Image MetaData ####################### class Image: """An image data container.""" def __init__(self, data, mime_type, width, height, color_depth, color_count, description, type): """Fields are as follows: data - plain string of the actual binary image data mime_type - unicode string of the image's MIME type width - width of image, as integer number of pixels height - height of image, as integer number of pixels color_depth - color depth of image (24 for JPEG, 8 for GIF, etc.) color_count - number of palette colors, or 0 description - a unicode string type - an integer type whose values are: 0 - front cover 1 - back cover 2 - leaflet page 3 - media 4 - other """ self.data = data self.mime_type = mime_type self.width = width self.height = height self.color_depth = color_depth self.color_count = color_count self.description = description self.type = type def suffix(self): """Returns the image's recommended suffix as a plain string. For example, an image with mime_type "image/jpeg" return "jpg". """ return {"image/jpeg": "jpg", "image/jpg": "jpg", "image/gif": "gif", "image/png": "png", "image/x-ms-bmp": "bmp", "image/tiff": "tiff"}.get(self.mime_type, "bin") def type_string(self): """Returns the image's type as a human readable plain string. For example, an image of type 0 returns "Front Cover". """ return {0: "Front Cover", 1: "Back Cover", 2: "Leaflet Page", 3: "Media", 4: "Other"}.get(self.type, "Other") def __repr__(self): return ("Image(mime_type=%s,width=%s,height=%s,color_depth=%s," + "color_count=%s,description=%s,type=%s,...)") % \ (repr(self.mime_type), repr(self.width), repr(self.height), repr(self.color_depth), repr(self.color_count), repr(self.description), repr(self.type)) def __unicode__(self): return u"Picture : %s (%d\u00D7%d,'%s')" % \ (self.type_string(), self.width, self.height, self.mime_type) @classmethod def new(cls, image_data, description, type): """Builds a new Image object from raw data. image_data is a plain string of binary image data. description is a unicode string. type as an image type integer. The width, height, color_depth and color_count fields are determined by parsing the binary image data. Raises InvalidImage if some error occurs during parsing. """ img = image_metrics(image_data) return Image(data=image_data, mime_type=img.mime_type, width=img.width, height=img.height, color_depth=img.bits_per_pixel, color_count=img.color_count, description=description, type=type) def thumbnail(self, width, height, format): """Returns a new Image object with the given attributes. width and height are integers. format is a string such as "JPEG". """ return Image.new(thumbnail_image(self.data, width, height, format), self.description, self.type) def __eq__(self, image): if (image is not None): return set([(getattr(self, attr) == getattr(image, attr)) for attr in ("data", "mime_type", "width", "height", "color_depth", "color_count", "description", "type")]) == set([True]) else: return False def __ne__(self, image): return not self.__eq__(image) ####################### #ReplayGain Metadata ####################### class ReplayGain: """A container for ReplayGain data.""" def __init__(self, track_gain, track_peak, album_gain, album_peak): """Values are: track_gain - a dB float value track_peak - the highest absolute value PCM sample, as a float album_gain - a dB float value album_peak - the highest absolute value PCM sample, as a float They are also attributes. """ self.track_gain = float(track_gain) self.track_peak = float(track_peak) self.album_gain = float(album_gain) self.album_peak = float(album_peak) def __repr__(self): return "ReplayGain(%s,%s,%s,%s)" % \ (self.track_gain, self.track_peak, self.album_gain, self.album_peak) def __eq__(self, rg): return ((self.track_gain == rg.track_gain) and (self.track_peak == rg.track_peak) and (self.album_gain == rg.album_gain) and (self.album_peak == rg.album_peak)) def __ne__(self, rg): return not self.__eq__(rg) ####################### #Generic Audio File ####################### class UnsupportedTracknameField(Exception): """Raised by AudioFile.track_name() if its format string contains unknown fields.""" def __init__(self, field): self.field = field def error_msg(self, messenger): messenger.error(_(u"Unknown field \"%s\" in file format") % \ (self.field)) messenger.info(_(u"Supported fields are:")) for field in sorted(MetaData.__FIELDS__ + \ ("album_track_number", "suffix")): if (field == 'track_number'): messenger.info(u"%(track_number)2.2d") else: messenger.info(u"%%(%s)s" % (field)) messenger.info(u"%(basename)s") class AudioFile: """An abstract class representing audio files on disk. This class should be extended to handle different audio file formats.""" SUFFIX = "" NAME = "" DEFAULT_COMPRESSION = "" COMPRESSION_MODES = ("",) COMPRESSION_DESCRIPTIONS = {} BINARIES = tuple() REPLAYGAIN_BINARIES = tuple() def __init__(self, filename): """filename is a plain string. Raises InvalidFile or subclass if the file is invalid in some way.""" self.filename = filename @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.""" return False def bits_per_sample(self): """Returns an integer number of bits-per-sample this track contains.""" raise NotImplementedError() def channels(self): """Returns an integer number of channels this track contains.""" raise NotImplementedError() def channel_mask(self): """Returns a ChannelMask object of this track's channel layout.""" #WARNING - This only returns valid masks for 1 and 2 channel audio #anything over 2 channels raises a ValueError #since there isn't any standard on what those channels should be. #AudioFiles that support more than 2 channels should override #this method with one that returns the proper mask. return ChannelMask.from_channels(self.channels()) def lossless(self): """Returns True if this track's data is stored losslessly.""" raise NotImplementedError() 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.""" pass def get_metadata(self): """Returns a MetaData object, or None. Raises IOError if unable to read the file.""" return None 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.""" pass def total_frames(self): """Returns the total PCM frames of the track as an integer.""" raise NotImplementedError() def cd_frames(self): """Returns the total length of the track in CD frames. Each CD frame is 1/75th of a second.""" try: return (self.total_frames() * 75) / self.sample_rate() except ZeroDivisionError: return 0 def seconds_length(self): """Returns the length of the track as a Decimal number of seconds.""" import decimal try: return (decimal.Decimal(self.total_frames()) / decimal.Decimal(self.sample_rate())) except decimal.DivisionByZero: return decimal.Decimal(0) def sample_rate(self): """Returns the rate of the track's audio as an integer number of Hz.""" raise NotImplementedError() def to_pcm(self): """Returns a PCMReader object containing the track's PCM data. If an error occurs initializing a decoder, this should return a PCMReaderError with an appropriate error message.""" #if a subclass implements to_wave(), #this doesn't need to be implemented #if a subclass implements to_pcm(), #to_wave() doesn't need to be implemented #or, one can implement both raise NotImplementedError() @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 AudioFile-compatible object. For example, to encode the FlacAudio file "file.flac" from "file.wav" at compression level "5": >>> flac = FlacAudio.from_pcm("file.flac", ... WaveAudio("file.wav").to_pcm(), ... "5") May raise EncodingError if some problem occurs when encoding the input file. This includes an error in the input stream, a problem writing the output file, or even an EncodingError subclass such as "UnsupportedBitsPerSample" if the input stream is formatted in a way this class is unable to support. """ raise NotImplementedError() def convert(self, target_path, target_class, compression=None, progress=None): """Encodes a new AudioFile from existing AudioFile. Take a filename string, target class and optional compression string. Encodes a new AudioFile in the target class and returns the resulting object. May raise EncodingError if some problem occurs during encoding.""" return target_class.from_pcm(target_path, to_pcm_progress(self, progress), compression) @classmethod def __unlink__(cls, filename): try: os.unlink(filename) except OSError: pass def track_number(self): """Returns this track's number as an integer. This first checks MetaData and then makes a guess from the filename. If neither yields a good result, returns 0.""" metadata = self.get_metadata() if ((metadata is not None) and (metadata.track_number > 0)): return metadata.track_number else: try: return int(re.findall( r'\d{2,3}', os.path.basename(self.filename))[0]) % 100 except IndexError: return 0 def album_number(self): """Returns this track's album number as an integer. This first checks MetaData and then makes a guess from the filename. If neither yields a good result, returns 0.""" metadata = self.get_metadata() if ((metadata is not None) and (metadata.album_number > 0)): return metadata.album_number elif ((metadata is not None) and (metadata.track_number > 0)): return 0 else: try: long_track_number = int(re.findall( r'\d{3}', os.path.basename(self.filename))[0]) return long_track_number / 100 except IndexError: return 0 @classmethod def track_name(cls, file_path, track_metadata=None, format=None, suffix=None): """Constructs a new filename string. Given a plain string to an existing path, a MetaData-compatible object (or None), a UTF-8-encoded Python format string and an ASCII-encoded suffix string (such as "mp3") returns a plain string of a new filename with format's fields filled-in and encoded as FS_ENCODING. Raises UnsupportedTracknameField if the format string contains invalid template fields.""" if (format is None): format = FILENAME_FORMAT if (suffix is None): suffix = cls.SUFFIX try: #prefer a track number from MetaData, if available if ((track_metadata is not None) and (track_metadata.track_number > 0)): track_number = track_metadata.track_number else: try: track_number = int(re.findall( r'\d{2,4}', os.path.basename(file_path))[0]) % 100 except IndexError: track_number = 0 #prefer an album_number from MetaData, if available if ((track_metadata is not None) and (track_metadata.album_number > 0)): album_number = track_metadata.album_number else: try: album_number = int(re.findall( r'\d{2,4}', os.path.basename(file_path))[0]) / 100 except IndexError: album_number = 0 if (track_metadata is not None): track_total = track_metadata.track_total album_total = track_metadata.album_total else: track_total = 0 album_total = 0 format_dict = {u"track_number": track_number, u"album_number": album_number, u"track_total": track_total, u"album_total": album_total, u"suffix": suffix.decode('ascii')} if (album_number == 0): format_dict[u"album_track_number"] = u"%2.2d" % (track_number) else: album_digits = len(str(album_total)) format_dict[u"album_track_number"] = ( u"%%%(album_digits)d.%(album_digits)dd%%2.2d" % {"album_digits": album_digits} % (album_number, track_number)) if (track_metadata is not None): for field in track_metadata.__FIELDS__: if ((field != "suffix") and (field not in MetaData.__INTEGER_FIELDS__)): format_dict[field.decode('ascii')] = getattr( track_metadata, field).replace(u'/', u'-').replace(unichr(0), u' ') else: for field in MetaData.__FIELDS__: if (field not in MetaData.__INTEGER_FIELDS__): format_dict[field.decode('ascii')] = u"" format_dict[u"basename"] = os.path.splitext( os.path.basename(file_path))[0].decode(FS_ENCODING, 'replace') return (format.decode('utf-8', 'replace') % format_dict).encode( FS_ENCODING, 'replace') except KeyError, error: raise UnsupportedTracknameField(unicode(error.args[0])) @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)] @classmethod def can_add_replay_gain(cls): """Returns True if we have the necessary binaries to add ReplayGain.""" return False @classmethod def lossless_replay_gain(cls): """Returns True of applying ReplayGain is a lossless process. For example, if it is applied by adding metadata tags rather than altering the file's data itself.""" return True def replay_gain(self): """Returns a ReplayGain object of our ReplayGain values. Returns None if we have no values. Note that if applying ReplayGain is a lossy process, this will typically also return None.""" return None def set_cuesheet(self, cuesheet): """Imports cuesheet data from a Cuesheet-compatible object. This are objects with catalog(), ISRCs(), indexes(), and pcm_lengths() methods. Raises IOError if an error occurs setting the cuesheet.""" pass def get_cuesheet(self): """Returns the embedded Cuesheet-compatible object, or None. Raises IOError if a problem occurs when reading the file.""" return None def __eq__(self, audiofile): if (isinstance(audiofile, AudioFile)): p1 = self.to_pcm() p2 = audiofile.to_pcm() try: return pcm_cmp(p1, p2) finally: p1.close() p2.close() else: return False def __ne__(self, audiofile): return not self.__eq__(audiofile) def verify(self, progress=None): """Verifies the current file for correctness. Returns True if the file is okay. Raises an InvalidFile with an error message if there is some problem with the file.""" total_frames = self.total_frames() decoder = self.to_pcm() pcm_frame_count = 0 try: framelist = decoder.read(BUFFER_SIZE) while (len(framelist) > 0): pcm_frame_count += framelist.frames if (progress is not None): progress(pcm_frame_count, total_frames) framelist = decoder.read(BUFFER_SIZE) except (IOError, ValueError), err: raise InvalidFile(str(err)) try: decoder.close() except DecodingError, err: raise InvalidFile(err.error_message) if (pcm_frame_count == total_frames): return True else: raise InvalidFile("incorrect PCM frame count") @classmethod def has_binaries(cls, system_binaries): """Returns True if all the required binaries can be found. Checks the __system_binaries__ class for which path to check.""" return set([True] + \ [system_binaries.can_execute(system_binaries[command]) for command in cls.BINARIES]) == set([True]) class WaveContainer(AudioFile): """An audio type which supports storing foreign RIFF chunks. These chunks must be preserved during a round-trip: >>> WaveContainer("file", "input.wav").to_wave("output.wav") """ def to_wave(self, wave_filename, progress=None): """Writes the contents of this file to the given .wav filename string. Raises EncodingError if some error occurs during decoding.""" pcmreader = to_pcm_progress(self, progress) WaveAudio.from_pcm(wave_filename, pcmreader) pcmreader.close() @classmethod def from_wave(cls, filename, wave_filename, compression=None, progress=None): """Encodes a new AudioFile from an existing .wav file. Takes a filename string, wave_filename string of an existing WaveAudio file and an optional compression level string. Encodes a new audio file from the wave's data at the given filename with the specified compression level and returns a new AudioFile compatible object. For example, to encode FlacAudio file "flac.flac" from "file.wav" at compression level "5": >>> flac = FlacAudio.from_wave("file.flac","file.wav","5") """ return cls.from_pcm(filename, to_pcm_progress(WaveAudio(wave_filename), progress), compression) def has_foreign_riff_chunks(self): """Returns True if the audio file contains non-audio RIFF chunks. During transcoding, if the source audio file has foreign RIFF chunks and the target audio format supports foreign RIFF chunks, conversion should be routed through .wav conversion to avoid losing those chunks.""" return False def convert(self, target_path, target_class, compression=None, progress=None): """Encodes a new AudioFile from existing AudioFile. Take a filename string, target class and optional compression string. Encodes a new AudioFile in the target class and returns the resulting object. May raise EncodingError if some problem occurs during encoding.""" import tempfile if (target_class == WaveAudio): self.to_wave(target_path, progress=progress) return WaveAudio(target_path) elif (self.has_foreign_riff_chunks() and hasattr(target_class, "from_wave")): temp_wave = tempfile.NamedTemporaryFile(suffix=".wav") try: #we'll only log the second leg of conversion, #since that's likely to be the slower portion self.to_wave(temp_wave.name) return target_class.from_wave(target_path, temp_wave.name, compression, progress=progress) finally: temp_wave.close() else: return target_class.from_pcm(target_path, to_pcm_progress(self, progress), compression) class AiffContainer(AudioFile): """An audio type which supports storing foreign AIFF chunks. These chunks must be preserved during a round-trip: >>> AiffContainer("file", "input.aiff").to_aiff("output.aiff") """ def to_aiff(self, aiff_filename, progress=None): """Writes the contents of this file to the given .aiff filename string. Raises EncodingError if some error occurs during decoding.""" pcmreader = to_pcm_progress(self, progress) AiffAudio.from_pcm(aiff_filename, pcmreader) pcmreader.close() @classmethod def from_aiff(cls, filename, aiff_filename, compression=None, progress=None): """Encodes a new AudioFile from an existing .aiff file. Takes a filename string, aiff_filename string of an existing AiffAudio file and an optional compression level string. Encodes a new audio file from the wave's data at the given filename with the specified compression level and returns a new AudioFile compatible object. For example, to encode FlacAudio file "flac.flac" from "file.aiff" at compression level "5": >>> flac = FlacAudio.from_wave("file.flac","file.aiff","5") """ return cls.from_pcm(filename, to_pcm_progress(AiffAudio(wave_filename)), compression) def has_foreign_aiff_chunks(self): """Returns True if the audio file contains non-audio AIFF chunks. During transcoding, if the source audio file has foreign AIFF chunks and the target audio format supports foreign AIFF chunks, conversion should be routed through .aiff conversion to avoid losing those chunks.""" return False def convert(self, target_path, target_class, compression=None, progress=None): """Encodes a new AudioFile from existing AudioFile. Take a filename string, target class and optional compression string. Encodes a new AudioFile in the target class and returns the resulting object. May raise EncodingError if some problem occurs during encoding.""" if (target_class == AiffAudio): self.to_aiff(target_path) return AiffAudio(target_path) elif (self.has_foreign_aiff_chunks() and hasattr(target_class, "from_aiff")): temp_aiff = tempfile.NamedTemporaryFile(suffix=".aiff") try: self.to_aiff(temp_aiff.name) return target_class.from_aiff(target_path, temp_aiff.name, compression) finally: temp_aiff.close() else: return target_class.from_pcm(target_path, to_pcm_progress(self, progress), compression) class DummyAudioFile(AudioFile): """A placeholder AudioFile object with external data.""" def __init__(self, length, metadata, track_number=0): """Fields are as follows: length - the dummy track's length, in CD frames metadata - a MetaData object track_number - the track's number on CD, starting from 1 """ self.__length__ = length self.__metadata__ = metadata self.__track_number__ = track_number AudioFile.__init__(self, "") def get_metadata(self): """Returns a MetaData object, or None.""" return self.__metadata__ def cd_frames(self): """Returns the total length of the track in CD frames. Each CD frame is 1/75th of a second.""" return self.__length__ def track_number(self): """Returns this track's number as an integer.""" return self.__track_number__ def sample_rate(self): """Returns 44100.""" return 44100 def total_frames(self): """Returns the total PCM frames of the track as an integer.""" return (self.cd_frames() * self.sample_rate()) / 75 ########################### #Cuesheet/TOC file handling ########################### #Cuesheets and TOC files are bundled into a unified Sheet interface class SheetException(ValueError): """A parent exception for CueException and TOCException.""" pass def read_sheet(filename): """Returns a TOCFile or Cuesheet object from filename. May raise a SheetException if the file cannot be parsed correctly.""" import toc import cue try: #try TOC first, since its CD_DA header makes it easier to spot return toc.read_tocfile(filename) except SheetException: return cue.read_cuesheet(filename) def parse_timestamp(s): """Parses a timestamp string into an integer. This presumes the stamp is stored: "hours:minutes:frames" where each CD frame is 1/75th of a second. Or, if the stamp is a plain integer, it is returned directly. This does no error checking. Presumably a regex will ensure the stamp is formatted correctly before parsing it to an int. """ if (":" in s): (m, s, f) = map(int, s.split(":")) return (m * 60 * 75) + (s * 75) + f else: return int(s) def build_timestamp(i): """Returns a timestamp string from an integer number of CD frames. Each CD frame is 1/75th of a second. """ return "%2.2d:%2.2d:%2.2d" % ((i / 75) / 60, (i / 75) % 60, i % 75) def sheet_to_unicode(sheet, total_frames): """Returns formatted unicode from a cuesheet object and total PCM frames. Its output is pretty-printed for eventual display by trackinfo. """ #FIXME? - This (and pcm_lengths() in general) assumes all cuesheets #have a sample rate of 44100Hz. #It's difficult to envision a scenario #in which this assumption doesn't hold #The point of cuesheets is to manage disc-based data as #"solid" archives which can be rewritten identically to the original #yet this only works for CD audio, which must always be 44100Hz. #DVD-Audio is encoded into AOB files which cannot be mapped to cuesheets #and SACD's DSD format is beyond the scope of these PCM-centric tools. ISRCs = sheet.ISRCs() tracks = unicode(os.linesep).join( [" Track %2.2d - %2.2d:%2.2d%s" % \ (i + 1, length / 44100 / 60, length / 44100 % 60, (" (ISRC %s)" % (ISRCs[i + 1].decode('ascii', 'replace'))) if ((i + 1) in ISRCs.keys()) else u"") for (i, length) in enumerate(sheet.pcm_lengths(total_frames))]) if ((sheet.catalog() is not None) and (len(sheet.catalog()) > 0)): return u" Catalog - %s%s%s" % \ (sheet.catalog().decode('ascii', 'replace'), os.linesep, tracks) else: return tracks def at_a_time(total, per): """Yields "per" integers from "total" until exhausted. For example: >>> list(at_a_time(10, 3)) [3, 3, 3, 1] """ for i in xrange(total / per): yield per yield total % per from __image__ import * from __wav__ import * from __au__ import * from __vorbiscomment__ import * from __id3__ import * from __aiff__ import * from __flac__ import * from __ape__ import * from __mp3__ import * from __vorbis__ import * from __m4a__ import * from __wavpack__ import * from __musepack__ import * from __speex__ import * from __shn__ import * from __dvda__ import * ####################### #CD data ####################### #keep in mind the whole of CD reading isn't remotely thread-safe #due to the linear nature of CD access, #reading from more than one track of a given CD at the same time #is something code should avoid at all costs! #there's simply no way to accomplish that cleanly class CDDA: """A CDDA device which contains CDTrackReader objects.""" def __init__(self, device_name, speed=None, perform_logging=True): """device_name is a string, speed is an optional int.""" import cdio cdrom_type = cdio.identify_cdrom(device_name) if (cdrom_type & cdio.CD_IMAGE): self.cdda = cdio.CDImage(device_name, cdrom_type) self.perform_logging = False else: self.cdda = cdio.CDDA(device_name) if (speed is not None): self.cdda.set_speed(speed) self.perform_logging = perform_logging self.total_tracks = len([track_type for track_type in map(self.cdda.track_type, xrange(1, self.cdda.total_tracks() + 1)) if (track_type == 0)]) def __len__(self): return self.total_tracks def __getitem__(self, key): if ((key < 1) or (key > self.total_tracks)): raise IndexError(key) else: try: sample_offset = int(config.get_default("System", "cdrom_read_offset", "0")) except ValueError: sample_offset = 0 reader = CDTrackReader(self.cdda, int(key), self.perform_logging) if (sample_offset == 0): return reader elif (sample_offset > 0): import math pcm_frames = reader.length() * 588 #adjust start and end sectors to account for the offset reader.start += (sample_offset / 588) reader.end += int(math.ceil(sample_offset / 588.0)) reader.end = min(reader.end, self.last_sector()) #then wrap the reader in a window to fine-tune the offset reader = PCMReaderWindow(reader, sample_offset, pcm_frames) reader.track_number = reader.pcmreader.track_number reader.rip_log = reader.pcmreader.rip_log reader.length = reader.pcmreader.length reader.offset = reader.pcmreader.offset return reader elif (sample_offset < 0): import math pcm_frames = reader.length() * 588 #adjust start and end sectors to account for the offset reader.start += sample_offset / 588 reader.end += int(math.ceil(sample_offset / 588.0)) #then wrap the reader in a window to fine-tune the offset if (reader.start >= self.first_sector()): reader = PCMReaderWindow( reader, sample_offset + (-(sample_offset / 588) * 588), pcm_frames) else: reader.start = self.first_sector() reader = PCMReaderWindow(reader, sample_offset, pcm_frames) reader.track_number = reader.pcmreader.track_number reader.rip_log = reader.pcmreader.rip_log reader.length = reader.pcmreader.length reader.offset = reader.pcmreader.offset return reader def __iter__(self): for i in range(1, self.total_tracks + 1): yield self[i] def length(self): """Returns the length of the CD in CD frames.""" #lead-in should always be 150 return self.last_sector() + 150 + 1 def close(self): """Closes the CDDA device.""" pass def first_sector(self): """Returns the first sector's location, in CD frames.""" return self.cdda.first_sector() def last_sector(self): """Returns the last sector's location, in CD frames.""" return self.cdda.last_sector() class PCMReaderWindow: """A class for cropping a PCMReader to a specific window of frames""" def __init__(self, pcmreader, initial_offset, pcm_frames): """initial_offset is how many frames to crop, and may be negative pcm_frames is the total length of the window If the window is outside the PCMReader's data (that is, initial_offset is negative, or pcm_frames is longer than the total stream) those samples are padded with 0s.""" self.pcmreader = pcmreader self.sample_rate = pcmreader.sample_rate self.channels = pcmreader.channels self.channel_mask = pcmreader.channel_mask self.bits_per_sample = pcmreader.bits_per_sample self.initial_offset = initial_offset self.pcm_frames = pcm_frames def read(self, bytes): if (self.pcm_frames > 0): if (self.initial_offset == 0): #once the initial offset is accounted for, #read a framelist from the pcmreader framelist = self.pcmreader.read(bytes) if (framelist.frames <= self.pcm_frames): if (framelist.frames > 0): #return framelist if it has data #and is smaller than remaining frames self.pcm_frames -= framelist.frames return framelist else: #otherwise, pad remaining data with 0s framelist = pcm.from_list([0] * (self.pcm_frames) * self.channels, self.channels, self.bits_per_sample, True) self.pcm_frames = 0 return framelist else: #crop framelist to be smaller #if its data is larger than what remains to be read framelist = framelist.split(self.pcm_frames)[0] self.pcm_frames = 0 return framelist elif (self.initial_offset > 0): #remove frames if initial offset is positive #if initial_offset is large, read as many framelists as needed framelist = self.pcmreader.read(bytes) while (self.initial_offset > framelist.frames): self.initial_offset -= framelist.frames framelist = self.pcmreader.read(bytes) (removed, framelist) = framelist.split(self.initial_offset) self.initial_offset -= removed.frames if (framelist.frames > 0): self.pcm_frames -= framelist.frames return framelist else: #if the entire framelist is cropped, #return another one entirely return self.read(bytes) elif (self.initial_offset < 0): #pad framelist with 0s if initial offset is negative framelist = pcm.from_list([0] * (-self.initial_offset) * self.channels, self.channels, self.bits_per_sample, True) self.initial_offset = 0 self.pcm_frames -= framelist.frames return framelist else: #once all frames have been sent, return empty framelists return pcm.FrameList("", self.channels, self.bits_per_sample, False, True) def close(self): self.pcmreader.close() class CDTrackLog(dict): """A container for CD reading log information, implemented as a dict.""" #PARANOIA_CB_READ Read off adjust ??? #PARANOIA_CB_VERIFY Verifying jitter #PARANOIA_CB_FIXUP_EDGE Fixed edge jitter #PARANOIA_CB_FIXUP_ATOM Fixed atom jitter #PARANOIA_CB_SCRATCH Unsupported #PARANOIA_CB_REPAIR Unsupported #PARANOIA_CB_SKIP Skip exhausted retry #PARANOIA_CB_DRIFT Skip exhausted retry #PARANOIA_CB_BACKOFF Unsupported #PARANOIA_CB_OVERLAP Dynamic overlap adjust #PARANOIA_CB_FIXUP_DROPPED Fixed dropped bytes #PARANOIA_CB_FIXUP_DUPED Fixed duplicate bytes #PARANOIA_CB_READERR Hard read error #log format is similar to cdda2wav's def __str__(self): return ", ".join(["%%(%s)d %s" % (field, field) for field in ("rderr", "skip", "atom", "edge", "drop", "dup", "drift")]) % \ {"edge": self.get(2, 0), "atom": self.get(3, 0), "skip": self.get(6, 0), "drift": self.get(7, 0), "drop": self.get(10, 0), "dup": self.get(11, 0), "rderr": self.get(12, 0)} class CDTrackReader(PCMReader): """A PCMReader-compatible object which reads from CDDA.""" def __init__(self, cdda, track_number, perform_logging=True): """cdda is a cdio.CDDA object. track_number is offset from 1.""" PCMReader.__init__( self, None, sample_rate=44100, channels=2, channel_mask=int(ChannelMask.from_fields(front_left=True, front_right=True)), bits_per_sample=16) self.cdda = cdda self.track_number = track_number (self.start, self.end) = cdda.track_offsets(track_number) self.position = self.start self.cursor_placed = False self.perform_logging = perform_logging self.rip_log = CDTrackLog() def offset(self): """Returns this track's CD offset, in CD frames.""" return self.start + 150 def length(self): """Returns this track's length, in CD frames.""" return self.end - self.start + 1 def log(self, i, v): """Adds a log entry to the track's rip_log. This is meant to be called from CD reading callbacks.""" if v in self.rip_log: self.rip_log[v] += 1 else: self.rip_log[v] = 1 def __read_sectors__(self, sectors): #if we haven't moved CDDA to the track start yet, do it now if (not self.cursor_placed): self.cdda.seek(self.start) if (self.perform_logging): cdio.set_read_callback(self.log) self.position = self.start self.cursor_placed = True if (self.position <= self.end): s = self.cdda.read_sectors(min( sectors, self.end - self.position + 1)) self.position += sectors return s else: return pcm.from_list([], 2, 16, True) def read(self, bytes): """Try to read a pcm.FrameList of size "bytes". For CD reading, this will be a sector-aligned number.""" #returns a sector-aligned number of bytes #(divisible by 2352 bytes, basically) #or at least 1 sector's worth, if "bytes" is too small return self.__read_sectors__(max(bytes / 2352, 1)) def close(self): """Closes the CD track for reading.""" self.position = self.start self.cursor_placed = False #returns the value in item_list which occurs most often def __most_numerous__(item_list): counts = {} if (len(item_list) == 0): return "" for item in item_list: counts.setdefault(item, []).append(item) return sorted([(item, len(counts[item])) for item in counts.keys()], lambda x, y: cmp(x[1], y[1]))[-1][0] from __freedb__ import * from __musicbrainz__ import * def read_metadata_file(filename): """Returns an AlbumMetaDataFile-compatible file from a filename string. Raises a MetaDataFileException exception if an error occurs during reading. """ try: data = file(filename, 'rb').read() except IOError, msg: raise MetaDataFileException(str(msg)) #try XMCD first try: return XMCD.from_string(data) except XMCDException: pass #then try MusicBrainz try: return MusicBrainzReleaseXML.from_string(data) except MBXMLException: pass #otherwise, throw exception raise MetaDataFileException(filename) ####################### #Multiple Jobs Handling ####################### class ExecQueue: """A class for running multiple jobs in parallel.""" def __init__(self): self.todo = [] self.return_values = set([]) def execute(self, function, args, kwargs=None): """Queues the given function with argument list and kwargs dict.""" self.todo.append((function, args, kwargs)) def __run__(self, function, args, kwargs): pid = os.fork() if (pid > 0): # parent return pid else: # child if (kwargs is not None): function(*args, **kwargs) else: function(*args) sys.exit(0) def run(self, max_processes=1): """Performs the queued functions in separate subprocesses. This runs "max_processes" number of functions at a time. It works by spawning a new child process for each function, executing it and spawning a new child as each one exits. Therefore, any side effects beyond altering files on disk do not propogate back to the parent.""" max_processes = max(max_processes, 1) process_pool = set([]) #fill the process_pool to the limit while ((len(self.todo) > 0) and (len(process_pool) < max_processes)): (function, args, kwargs) = self.todo.pop(0) process_pool.add(self.__run__(function, args, kwargs)) #print "Filling %s" % (repr(process_pool)) #as processes end, keep adding new ones to the pool #until we run out of queued jobs while (len(self.todo) > 0): try: (pid, return_value) = os.waitpid(0, 0) process_pool.remove(pid) self.return_values.add(return_value) (function, args, kwargs) = self.todo.pop(0) process_pool.add(self.__run__(function, args, kwargs)) #print "Resuming %s" % (repr(process_pool)) except KeyError: continue #finally, wait for the running jobs to finish while (len(process_pool) > 0): try: (pid, return_value) = os.waitpid(0, 0) process_pool.remove(pid) self.return_values.add(return_value) #print "Emptying %s" % (repr(process_pool)) except KeyError: continue class ExecQueue2: """A class for running multiple jobs and accumulating results.""" def __init__(self): self.todo = [] self.return_values = set([]) #a dict of reader->pid values as returned by __run__() self.process_pool = {} def execute(self, function, args, kwargs=None): self.todo.append((function, args, kwargs)) def __run__(self, function, args, kwargs): """executes the given function and arguments in a child job returns a (pid, reader) tuple where pid is an int of the child job and reader is a file object containing its piped data""" (pipe_read, pipe_write) = os.pipe() pid = os.fork() if (pid > 0): # parent os.close(pipe_write) reader = os.fdopen(pipe_read, 'r') return (pid, reader) else: # child os.close(pipe_read) writer = os.fdopen(pipe_write, 'w') if (kwargs is not None): cPickle.dump(function(*args, **kwargs), writer) else: cPickle.dump(function(*args), writer) sys.exit(0) def __add_job__(self): """removes a queued function and adds it to our running pool""" (function, args, kwargs) = self.todo.pop(0) (pid, file_pointer) = self.__run__(function, args, kwargs) self.process_pool[file_pointer] = pid def __await_jobs__(self): """yields a reader file object per finished job If the child job exited properly, that reader will have the pickled contents of the completed Python function and it can be used to find the child's PID to be waited for via the process pool. In addition, the returned values of finished child processes are added to our "return_values" attribute.""" import select (readable, writeable, exceptional) = select.select(list(self.process_pool.keys()), [], []) for reader in readable: try: result = cPickle.load(reader) except EOFError: result = None (pid, return_value) = os.waitpid(self.process_pool[reader], 0) self.return_values.add(return_value) yield (reader, result) def run(self, max_processes=1): """execute all queued functions Yields the result of each executed function as they complete.""" max_processes = max(max_processes, 1) #fill the process pool to the limit while ((len(self.todo) > 0) and (len(self.process_pool) < max_processes)): self.__add_job__() #as processes end, keep adding new ones to the pool #until we run out of queued jobs while (len(self.todo) > 0): for (reader, result) in self.__await_jobs__(): del(self.process_pool[reader]) if (len(self.todo) > 0): self.__add_job__() yield result #finally, wait for the running jobs to finish while (len(self.process_pool) > 0): for (reader, result) in self.__await_jobs__(): del(self.process_pool[reader]) yield result class ProgressJobQueueComplete(Exception): pass class ExecProgressQueue: """A class for running multiple jobs in parallel with progress updates.""" def __init__(self, progress_display): """Takes a ProgressDisplay object.""" self.progress_display = progress_display self.queued_jobs = [] self.max_job_id = 0 self.running_job_pool = {} self.results = {} self.cached_exception = None def execute(self, function, progress_text=None, completion_output=None, *args, **kwargs): """Queues the given function and arguments to be run in parallel. progress_text should be a unicode string to be displayed while running completion_output is either a unicode string, or a function which takes the result of the queued function and returns a unicode string for display once the queued function is complete """ self.queued_jobs.append((self.max_job_id, progress_text, completion_output, function, args, kwargs)) self.max_job_id += 1 def execute_next_job(self): """Executes the next queued job.""" #pull job off queue (job_id, progress_text, completion_output, function, args, kwargs) = self.queued_jobs.pop(0) #add job to progress display if (progress_text is not None): self.progress_display.add_row(job_id, progress_text) #spawn subprocess and add it to pool self.running_job_pool[job_id] = __ExecProgressQueueJob__.spawn( job_id, completion_output, function, args, kwargs) def completed(self, job_id, result): """Handles the completion of the given job and its result.""" #remove job from progress display, if present self.progress_display.delete_row(job_id) self.progress_display.clear() #add result to results self.results[job_id] = result #clean up job from pool self.running_job_pool[job_id].join() #display output text, if any completion_output = self.running_job_pool[job_id].completion_output if (completion_output is not None): if (callable(completion_output)): self.progress_display.messenger.info( unicode(completion_output(result))) else: self.progress_display.messenger.info( unicode(completion_output)) #remove job from pool del(self.running_job_pool[job_id]) if (len(self.queued_jobs) > 0): #if there are jobs in the queue, run another one self.execute_next_job() elif (len(self.running_job_pool) == 0): #otherwise, raise ProgressJobQueueComplete to signal completion raise ProgressJobQueueComplete() def exception(self, job_id, exception): """Handles an exception caused by the given job.""" #clean up job that raised exception self.running_job_pool[job_id].join() del(self.running_job_pool[job_id]) #clean out job queue while (len(self.queued_jobs) > 0): self.queued_jobs.pop(0) #and raise exception which will bubble-up through run() raise exception def progress(self, job_id, current, total): """Updates the progress display of the given job.""" self.progress_display.update_row(job_id, current, total) def run(self, max_processes=1): """Runs all the queued jobs in parallel.""" import select if (len(self.queued_jobs) == 0): return for i in xrange(min(max_processes, len(self.queued_jobs))): self.execute_next_job() try: while (True): (rlist, wlist, xlist) = select.select([job.output for job in self.running_job_pool.values()], [], []) for reader in rlist: (command, args) = cPickle.load(reader) getattr(self, command)(*args) except ProgressJobQueueComplete: if (self.cached_exception is not None): raise self.cached_exception else: return class __ExecProgressQueueJob__: def __init__(self, pid, output, completion_output): self.pid = pid self.output = output self.completion_output = completion_output def join(self): self.output.close() return os.waitpid(self.pid, 0) @classmethod def spawn(cls, job_id, completion_output, function, args, kwargs): (read_end, write_end) = os.pipe() pid = os.fork() if (pid > 0): os.close(write_end) return cls(pid, os.fdopen(read_end, 'rb'), completion_output) else: os.close(read_end) output = os.fdopen(write_end, 'wb') try: try: result = function( *args, progress=__JobProgress__(job_id, output).progress, **kwargs) cPickle.dump(("completed", [job_id, result]), output, cPickle.HIGHEST_PROTOCOL) except Exception, e: cPickle.dump(("exception", [job_id, e]), output, cPickle.HIGHEST_PROTOCOL) finally: sys.exit(0) class __JobProgress__: def __init__(self, job_id, output): self.job_id = job_id self.output = output def progress(self, current, total): cPickle.dump(("progress", [self.job_id, current, total]), self.output) self.output.flush() #***ApeAudio temporarily removed*** #Without a legal alternative to mac-port, I shall have to re-implement #Monkey's Audio with my own code in order to make it available again. #Yet another reason to avoid that unpleasant file format... AVAILABLE_TYPES = (FlacAudio, OggFlacAudio, MP3Audio, MP2Audio, WaveAudio, VorbisAudio, SpeexAudio, AiffAudio, AuAudio, M4AAudio, AACAudio, ALACAudio, WavPackAudio, ShortenAudio) TYPE_MAP = dict([(track_type.NAME, track_type) for track_type in AVAILABLE_TYPES if track_type.has_binaries(BIN)]) DEFAULT_QUALITY = dict([(track_type.NAME, config.get_default("Quality", track_type.NAME, track_type.DEFAULT_COMPRESSION)) for track_type in AVAILABLE_TYPES if (len(track_type.COMPRESSION_MODES) > 1)]) if (DEFAULT_TYPE not in TYPE_MAP.keys()): DEFAULT_TYPE = "wav"