#!/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")
    <class audiotools.__flac__.FlacAudio at 0x7fc8456d55f0>

    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"
