4592 lines
152 KiB
Python
4592 lines
152 KiB
Python
#!/usr/bin/python
|
|
|
|
#Audio Tools, a module and set of tools for manipulating audio data
|
|
#Copyright (C) 2007-2011 Brian Langenberger
|
|
|
|
#This program is free software; you can redistribute it and/or modify
|
|
#it under the terms of the GNU General Public License as published by
|
|
#the Free Software Foundation; either version 2 of the License, or
|
|
#(at your option) any later version.
|
|
|
|
#This program is distributed in the hope that it will be useful,
|
|
#but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
#MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
#GNU General Public License for more details.
|
|
|
|
#You should have received a copy of the GNU General Public License
|
|
#along with this program; if not, write to the Free Software
|
|
#Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
|
|
|
|
"""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"
|