1#!/usr/bin/python
2
3# Audio Tools, a module and set of tools for manipulating audio data
4# Copyright (C) 2007-2014  Brian Langenberger
5
6# This program is free software; you can redistribute it and/or modify
7# it under the terms of the GNU General Public License as published by
8# the Free Software Foundation; either version 2 of the License, or
9# (at your option) any later version.
10
11# This program is distributed in the hope that it will be useful,
12# but WITHOUT ANY WARRANTY; without even the implied warranty of
13# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14# GNU General Public License for more details.
15
16# You should have received a copy of the GNU General Public License
17# along with this program; if not, write to the Free Software
18# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
19
20"""the core Python Audio Tools module"""
21
22import sys
23import re
24import os
25import os.path
26import audiotools.pcm as pcm
27from functools import total_ordering
28
29
30PY3 = sys.version_info[0] >= 3
31PY2 = not PY3
32
33
34try:
35    from configparser import RawConfigParser
36except ImportError:
37    from ConfigParser import RawConfigParser
38
39
40class RawConfigParser(RawConfigParser):
41    """extends RawConfigParser to provide additional methods"""
42
43    def get_default(self, section, option, default):
44        """returns a default if option is not found in section"""
45
46        try:
47            from configparser import NoSectionError, NoOptionError
48        except ImportError:
49            from ConfigParser import NoSectionError, NoOptionError
50
51        try:
52            return self.get(section, option)
53        except NoSectionError:
54            return default
55        except NoOptionError:
56            return default
57
58    def set_default(self, section, option, value):
59        try:
60            from configparser import NoSectionError
61        except ImportError:
62            from ConfigParser import NoSectionError
63
64        try:
65            self.set(section, option, value)
66        except NoSectionError:
67            self.add_section(section)
68            self.set(section, option, value)
69
70    def getint_default(self, section, option, default):
71        """returns a default int if option is not found in section"""
72
73        try:
74            from configparser import NoSectionError, NoOptionError
75        except ImportError:
76            from ConfigParser import NoSectionError, NoOptionError
77
78        try:
79            return self.getint(section, option)
80        except NoSectionError:
81            return default
82        except NoOptionError:
83            return default
84
85    def getboolean_default(self, section, option, default):
86        """returns a default boolean if option is not found in section"""
87
88        try:
89            from configparser import NoSectionError, NoOptionError
90        except ImportError:
91            from ConfigParser import NoSectionError, NoOptionError
92
93        try:
94            return self.getboolean(section, option)
95        except NoSectionError:
96            return default
97        except NoOptionError:
98            return default
99
100
101config = RawConfigParser()
102config.read([os.path.join("/etc", "audiotools.cfg"),
103             os.path.join(sys.prefix, "etc", "audiotools.cfg"),
104             os.path.expanduser('~/.audiotools.cfg')])
105
106BUFFER_SIZE = 0x100000
107FRAMELIST_SIZE = 0x100000 // 4
108
109
110class __system_binaries__(object):
111    def __init__(self, config):
112        self.config = config
113
114    def __getitem__(self, command):
115        try:
116            from configparser import NoSectionError, NoOptionError
117        except ImportError:
118            from ConfigParser import NoSectionError, NoOptionError
119
120        try:
121            return self.config.get("Binaries", command)
122        except NoSectionError:
123            return command
124        except NoOptionError:
125            return command
126
127    def can_execute(self, command):
128        if os.sep in command:
129            return os.access(command, os.X_OK)
130        else:
131            for path in os.environ.get('PATH', os.defpath).split(os.pathsep):
132                if os.access(os.path.join(path, command), os.X_OK):
133                    return True
134            return False
135
136BIN = __system_binaries__(config)
137
138DEFAULT_CDROM = config.get_default("System", "cdrom", "/dev/cdrom")
139
140FREEDB_SERVICE = config.getboolean_default("FreeDB", "service", True)
141FREEDB_SERVER = config.get_default("FreeDB", "server", "us.freedb.org")
142FREEDB_PORT = config.getint_default("FreeDB", "port", 80)
143
144MUSICBRAINZ_SERVICE = config.getboolean_default("MusicBrainz", "service", True)
145MUSICBRAINZ_SERVER = config.get_default("MusicBrainz", "server",
146                                        "musicbrainz.org")
147MUSICBRAINZ_PORT = config.getint_default("MusicBrainz", "port", 80)
148
149ADD_REPLAYGAIN = config.getboolean_default("ReplayGain", "add_by_default",
150                                           True)
151
152VERSION = "3.0"
153
154DEFAULT_FILENAME_FORMAT = '%(track_number)2.2d - %(track_name)s.%(suffix)s'
155FILENAME_FORMAT = config.get_default("Filenames", "format",
156                                     DEFAULT_FILENAME_FORMAT)
157
158FS_ENCODING = config.get_default("System", "fs_encoding",
159                                 sys.getfilesystemencoding())
160if FS_ENCODING is None:
161    FS_ENCODING = 'UTF-8'
162
163VERBOSITY_LEVELS = ("quiet", "normal", "debug")
164DEFAULT_VERBOSITY = config.get_default("Defaults", "verbosity", "normal")
165if DEFAULT_VERBOSITY not in VERBOSITY_LEVELS:
166    DEFAULT_VERBOSITY = "normal"
167
168DEFAULT_TYPE = config.get_default("System", "default_type", "wav")
169
170
171# field name -> (field string, text description) mapping
172def __format_fields__():
173    from audiotools.text import (METADATA_TRACK_NAME,
174                                 METADATA_TRACK_NUMBER,
175                                 METADATA_TRACK_TOTAL,
176                                 METADATA_ALBUM_NAME,
177                                 METADATA_ARTIST_NAME,
178                                 METADATA_PERFORMER_NAME,
179                                 METADATA_COMPOSER_NAME,
180                                 METADATA_CONDUCTOR_NAME,
181                                 METADATA_MEDIA,
182                                 METADATA_ISRC,
183                                 METADATA_CATALOG,
184                                 METADATA_COPYRIGHT,
185                                 METADATA_PUBLISHER,
186                                 METADATA_YEAR,
187                                 METADATA_DATE,
188                                 METADATA_ALBUM_NUMBER,
189                                 METADATA_ALBUM_TOTAL,
190                                 METADATA_COMMENT,
191                                 METADATA_SUFFIX,
192                                 METADATA_ALBUM_TRACK_NUMBER,
193                                 METADATA_BASENAME)
194    return {u"track_name": (u"%(track_name)s",
195                            METADATA_TRACK_NAME),
196            u"track_number": (u"%(track_number)2.2d",
197                              METADATA_TRACK_NUMBER),
198            u"track_total": (u"%(track_total)d",
199                             METADATA_TRACK_TOTAL),
200            u"album_name": (u"%(album_name)s",
201                            METADATA_ALBUM_NAME),
202            u"artist_name": (u"%(artist_name)s",
203                             METADATA_ARTIST_NAME),
204            u"performer_name": (u"%(performer_name)s",
205                                METADATA_PERFORMER_NAME),
206            u"composer_name": (u"%(composer_name)s",
207                               METADATA_COMPOSER_NAME),
208            u"conductor_name": (u"%(conductor_name)s",
209                                METADATA_CONDUCTOR_NAME),
210            u"media": (u"%(media)s",
211                       METADATA_MEDIA),
212            u"ISRC": (u"%(ISRC)s",
213                      METADATA_ISRC),
214            u"catalog": (u"%(catalog)s",
215                         METADATA_CATALOG),
216            u"copyright": (u"%(copyright)s",
217                           METADATA_COPYRIGHT),
218            u"publisher": (u"%(publisher)s",
219                           METADATA_PUBLISHER),
220            u"year": (u"%(year)s",
221                      METADATA_YEAR),
222            u"date": (u"%(date)s",
223                      METADATA_DATE),
224            u"album_number": (u"%(album_number)d",
225                              METADATA_ALBUM_NUMBER),
226            u"album_total": (u"%(album_total)d",
227                             METADATA_ALBUM_TOTAL),
228            u"comment": (u"%(comment)s",
229                         METADATA_COMMENT),
230            u"suffix": (u"%(suffix)s",
231                        METADATA_SUFFIX),
232            u"album_track_number": (u"%(album_track_number)s",
233                                    METADATA_ALBUM_TRACK_NUMBER),
234            u"basename": (u"%(basename)s",
235                          METADATA_BASENAME)}
236
237FORMAT_FIELDS = __format_fields__()
238FORMAT_FIELD_ORDER = (u"track_name",
239                      u"artist_name",
240                      u"album_name",
241                      u"track_number",
242                      u"track_total",
243                      u"album_number",
244                      u"album_total",
245                      u"performer_name",
246                      u"composer_name",
247                      u"conductor_name",
248                      u"catalog",
249                      u"ISRC",
250                      u"publisher",
251                      u"media",
252                      u"year",
253                      u"date",
254                      u"copyright",
255                      u"comment",
256                      u"suffix",
257                      u"album_track_number",
258                      u"basename")
259
260
261def __default_quality__(audio_type):
262    quality = DEFAULT_QUALITY.get(audio_type, "")
263    try:
264        if quality not in TYPE_MAP[audio_type].COMPRESSION_MODES:
265            return TYPE_MAP[audio_type].DEFAULT_COMPRESSION
266        else:
267            return quality
268    except KeyError:
269        return ""
270
271
272if config.has_option("System", "maximum_jobs"):
273    MAX_JOBS = config.getint_default("System", "maximum_jobs", 1)
274else:
275    try:
276        import multiprocessing
277        MAX_JOBS = multiprocessing.cpucount()
278    except (ImportError, AttributeError):
279        MAX_JOBS = 1
280
281
282class Messenger(object):
283    """this class is for displaying formatted output in a consistent way"""
284
285    def __init__(self, silent=False):
286        """executable is a unicode string of what script is being run
287
288        this is typically for use by the usage() method"""
289
290        self.__stdout__ = sys.stdout
291        self.__stderr__ = sys.stderr
292        if silent:
293            self.__print__ = self.__print_silent__
294        elif PY3:
295            self.__print__ = self.__print_py3__
296        else:
297            self.__print__ = self.__print_py2__
298
299    def output_isatty(self):
300        return self.__stdout__.isatty()
301
302    def info_isatty(self):
303        return self.__stderr__.isatty()
304
305    def error_isatty(self):
306        return self.__stderr__.isatty()
307
308    def __print_silent__(self, string, stream, add_newline, flush):
309        """prints unicode string to the given stream
310        and if 'add_newline' is True, appends a newline
311        if 'flush' is True, flushes the stream"""
312
313        assert(isinstance(string, str if PY3 else unicode))
314        # do nothing
315        pass
316
317    def __print_py2__(self, string, stream, add_newline, flush):
318        """prints unicode string to the given stream
319        and if 'add_newline' is True, appends a newline
320        if 'flush' is True, flushes the stream"""
321
322        assert(isinstance(string, unicode))
323        # we can't output unicode strings directly to streams
324        # because non-TTYs will raise UnicodeEncodeErrors
325        stream.write(string.encode("UTF-8", "replace"))
326        if add_newline:
327            stream.write(os.linesep)
328        if flush:
329            stream.flush()
330
331    def __print_py3__(self, string, stream, add_newline, flush):
332        """prints unicode string to the given stream
333        and if 'add_newline' is True, appends a newline
334        if 'flush' is True, flushes the stream"""
335
336        assert(isinstance(string, str))
337        stream.write(string)
338        if add_newline:
339            stream.write(os.linesep)
340        if flush:
341            stream.flush()
342
343    def output(self, s):
344        """displays an output message unicode string to stdout
345
346        this appends a newline to that message"""
347
348        self.__print__(string=s,
349                       stream=self.__stdout__,
350                       add_newline=True,
351                       flush=False)
352
353    def partial_output(self, s):
354        """displays a partial output message unicode string to stdout
355
356        this flushes output so that message is displayed"""
357
358        self.__print__(string=s,
359                       stream=self.__stdout__,
360                       add_newline=False,
361                       flush=True)
362
363    def info(self, s):
364        """displays an informative message unicode string to stderr
365
366        this appends a newline to that message"""
367
368        self.__print__(string=s,
369                       stream=self.__stderr__,
370                       add_newline=True,
371                       flush=False)
372
373    def partial_info(self, s):
374        """displays a partial informative message unicode string to stdout
375
376        this flushes output so that message is displayed"""
377
378        self.__print__(string=s,
379                       stream=self.__stderr__,
380                       add_newline=False,
381                       flush=True)
382
383    # what's the difference between output() and info() ?
384    # output() is for a program's primary data
385    # info() is for incidental information
386    # for example, trackinfo(1) should use output() for what it displays
387    # since that output is its primary function
388    # but track2track should use info() for its lines of progress
389    # since its primary function is converting audio
390    # and tty output is purely incidental
391
392    def error(self, s):
393        """displays an error message unicode string to stderr
394
395        this appends a newline to that message"""
396
397        self.__print__(string=u"*** Error: %s" % (s,),
398                       stream=self.__stderr__,
399                       add_newline=True,
400                       flush=False)
401
402    def os_error(self, oserror):
403        """displays an properly formatted OSError exception to stderr
404
405        this appends a newline to that message"""
406
407        self.error(u"[Errno %d] %s: '%s'" %
408                   (oserror.errno,
409                    oserror.strerror,
410                    Filename(oserror.filename)))
411
412    def warning(self, s):
413        """displays a warning message unicode string to stderr
414
415        this appends a newline to that message"""
416
417        self.__print__(string=u"*** Warning: %s" % (s,),
418                       stream=self.__stderr__,
419                       add_newline=True,
420                       flush=False)
421
422    def ansi_clearline(self):
423        """generates a set of clear line ANSI escape codes to stdout
424
425        this works only if stdout is a tty.  Otherwise, it does nothing
426        for example:
427        >>> msg = Messenger("audiotools")
428        >>> msg.partial_output(u"working")
429        >>> time.sleep(1)
430        >>> msg.ansi_clearline()
431        >>> msg.output(u"done")
432        """
433
434        if self.output_isatty():
435            self.partial_output((u"\u001B[0G" +  # move cursor to column 0
436                                 # clear everything after cursor
437                                 u"\u001B[0K"))
438
439    def ansi_uplines(self, lines):
440        """moves the cursor up by the given number of lines"""
441
442        if self.output_isatty():
443            self.partial_output(u"\u001B[%dA" % (lines))
444
445    def ansi_cleardown(self):
446        """clears the remainder of the screen from the cursor downward"""
447
448        if self.output_isatty():
449            self.partial_output(u"\u001B[0J")
450
451    def ansi_clearscreen(self):
452        """clears the entire screen and moves cursor to upper left corner"""
453
454        if self.output_isatty():
455            self.partial_output(u"\u001B[2J" + u"\u001B[1;1H")
456
457    def terminal_size(self, fd):
458        """returns the current terminal size as (height, width)"""
459
460        import fcntl
461        import termios
462        import struct
463
464        # this isn't all that portable, but will have to do
465        return struct.unpack('hh', fcntl.ioctl(fd, termios.TIOCGWINSZ, '1234'))
466
467
468class SilentMessenger(Messenger):
469    def __init__(self):
470        Messenger.__init__(self, silent=True)
471
472
473def khz(hz):
474    """given an integer sample rate value in Hz,
475    returns a unicode kHz value with suffix
476
477    the string is typically 7-8 characters wide"""
478
479    num = hz // 1000
480    den = (hz % 1000) // 100
481    if den == 0:
482        return u"%dkHz" % (num)
483    else:
484        return u"%d.%dkHz" % (num, den)
485
486
487def hex_string(byte_string):
488    """given a string of bytes, returns a Unicode string encoded as hex"""
489
490    if PY3:
491        hex_digits = [u"%2.2X" % (b,) for b in byte_string]
492    else:
493        hex_digits = [u"%2.2X" % (ord(b)) for b in byte_string]
494
495    return u"".join(hex_digits)
496
497
498class output_text(tuple):
499    """a class for formatting unicode strings for display"""
500
501    COLORS = {"black",
502              "red",
503              "green",
504              "yellow",
505              "blue",
506              "magenta",
507              "cyan",
508              "white"}
509
510    STYLES = {"bold",
511              "underline",
512              "blink",
513              "inverse"}
514
515    def __new__(cls, unicode_string, fg_color=None, bg_color=None, style=None):
516        """unicode_string is the text to be displayed
517
518        fg_color and bg_color may be one of:
519        'black', 'red', 'green', 'yellow',
520        'blue', 'magenta', 'cyan', 'white'
521
522        style may be one of:
523        'bold', 'underline', 'blink', 'inverse'
524        """
525
526        import unicodedata
527
528        assert(isinstance(unicode_string, str if PY3 else unicode))
529
530        string = unicodedata.normalize("NFC", unicode_string)
531
532        CHAR_WIDTHS = {"Na": 1,
533                       "A": 1,
534                       "W": 2,
535                       "F": 2,
536                       "N": 1,
537                       "H": 1}
538
539        return cls.__construct__(
540            unicode_string=string,
541            char_widths=[CHAR_WIDTHS.get(
542                         unicodedata.east_asian_width(char), 1)
543                         for char in string],
544            fg_color=fg_color,
545            bg_color=bg_color,
546            style=style,
547            open_codes=cls.__open_codes__(fg_color, bg_color, style),
548            close_codes=cls.__close_codes__(fg_color, bg_color, style))
549
550    @classmethod
551    def __construct__(cls,
552                      unicode_string,
553                      char_widths,
554                      fg_color,
555                      bg_color,
556                      style,
557                      open_codes,
558                      close_codes):
559        """
560        | unicode_string | unicode | output string                     |
561        | char_widths    | [int]   | width of each unicode_string char |
562        | fg_color       | str     | foreground color, or None         |
563        | bg_color       | str     | background color, or None         |
564        | style          | str     | text style, or None               |
565        | open_codes     | unicode | ANSI escape codes                 |
566        | close_codes    | unicode | ANSI escape codes                 |
567        """
568
569        assert(len(unicode_string) == len(char_widths))
570
571        return tuple.__new__(cls,
572                             [unicode_string,      # 0
573                              tuple(char_widths),  # 1
574                              sum(char_widths),    # 2
575                              fg_color,            # 3
576                              bg_color,            # 4
577                              style,               # 5
578                              open_codes,          # 6
579                              close_codes          # 7
580                              ])
581
582    def __repr__(self):
583        # the other fields can be derived
584        return "%s(%s, %s, %s, %s)" % \
585            (self.__class__.__name__,
586             repr(self[0]),
587             repr(self.fg_color()),
588             repr(self.bg_color()),
589             repr(self.style()))
590
591    @classmethod
592    def __open_codes__(cls, fg_color, bg_color, style):
593        open_codes = []
594
595        if fg_color is not None:
596            if fg_color not in cls.COLORS:
597                raise ValueError("invalid fg_color %s" % (repr(fg_color)))
598            else:
599                open_codes.append({"black": u"30",
600                                   "red": u"31",
601                                   "green": u"32",
602                                   "yellow": u"33",
603                                   "blue": u"34",
604                                   "magenta": u"35",
605                                   "cyan": u"36",
606                                   "white": u"37"}[fg_color])
607
608        if bg_color is not None:
609            if bg_color not in cls.COLORS:
610                raise ValueError("invalid bg_color %s" % (repr(bg_color)))
611            else:
612                open_codes.append({"black": u"40",
613                                   "red": u"41",
614                                   "green": u"42",
615                                   "yellow": u"43",
616                                   "blue": u"44",
617                                   "magenta": u"45",
618                                   "cyan": u"46",
619                                   "white": u"47"}[bg_color])
620
621        if style is not None:
622            if style not in cls.STYLES:
623                raise ValueError("invalid style %s" % (repr(style)))
624            else:
625                open_codes.append({"bold": u"1",
626                                   "underline": u"4",
627                                   "blink": u"5",
628                                   "inverse": u"7"}[style])
629
630        if len(open_codes) > 0:
631            return u"\u001B[%sm" % (u";".join(open_codes))
632        else:
633            return u""
634
635    @classmethod
636    def __close_codes__(cls, fg_color, bg_color, style):
637        close_codes = []
638
639        if fg_color is not None:
640            if fg_color not in cls.COLORS:
641                raise ValueError("invalid fg_color %s" % (repr(fg_color)))
642            else:
643                close_codes.append(u"39")
644
645        if bg_color is not None:
646            if bg_color not in cls.COLORS:
647                raise ValueError("invalid bg_color %s" % (repr(bg_color)))
648            else:
649                close_codes.append(u"49")
650
651        if style is not None:
652            if style not in cls.STYLES:
653                raise ValueError("invalid style %s" % (repr(style)))
654            else:
655                close_codes.append({"bold": u"22",
656                                    "underline": u"24",
657                                    "blink": u"25",
658                                    "inverse": u"27"}[style])
659
660        if len(close_codes) > 0:
661            return u"\u001B[%sm" % (u";".join(close_codes))
662        else:
663            return u""
664
665    if PY3:
666        def __str__(self):
667            return self.__unicode__()
668    else:
669        def __str__(self):
670            return self.__unicode__().encode('utf-8')
671
672    def __unicode__(self):
673        return self[0]
674
675    def char_widths(self):
676        """yields a (char, width) for each character in string"""
677
678        for pair in zip(self[0], self[1]):
679            yield pair
680
681    def __len__(self):
682        return self[2]
683
684    def fg_color(self):
685        """returns the foreground color as a string, or None"""
686
687        return self[3]
688
689    def bg_color(self):
690        """returns the background color as a string, or None"""
691
692        return self[4]
693
694    def style(self):
695        """returns the style as a string, or None"""
696
697        return self[5]
698
699    def set_string(self, unicode_string, char_widths):
700        """returns a new output_text with the given string"""
701
702        assert(len(unicode_string) == len(char_widths))
703        return output_text.__construct__(
704            unicode_string=unicode_string,
705            char_widths=char_widths,
706            fg_color=self[3],
707            bg_color=self[4],
708            style=self[5],
709            open_codes=self[6],
710            close_codes=self[7])
711
712    def set_format(self, fg_color=None, bg_color=None, style=None):
713        """returns a new output_text with the given format"""
714
715        return output_text.__construct__(
716            unicode_string=self[0],
717            char_widths=self[1],
718            fg_color=fg_color,
719            bg_color=bg_color,
720            style=style,
721            open_codes=output_text.__open_codes__(fg_color,
722                                                  bg_color,
723                                                  style),
724            close_codes=output_text.__close_codes__(fg_color,
725                                                    bg_color,
726                                                    style))
727
728    def has_formatting(self):
729        """returns True if the text has formatting set"""
730
731        return ((self[3] is not None) or
732                (self[4] is not None) or
733                (self[5] is not None))
734
735    def format(self, is_tty=False):
736        """returns unicode text formatted depending on is_tty"""
737
738        if is_tty and self.has_formatting():
739            return u"%s%s%s" % (self[6], self[0], self[7])
740        else:
741            return self[0]
742
743    def head(self, display_characters):
744        """returns a text object truncated to the given length
745
746        characters at the end of the string are removed as needed
747
748        due to double-width characters,
749        the size of the string may be smaller than requested"""
750
751        if display_characters < 0:
752            raise ValueError("display characters must be >= 0")
753
754        output_chars = []
755        output_widths = []
756
757        for (char, width) in self.char_widths():
758            if width <= display_characters:
759                output_chars.append(char)
760                output_widths.append(width)
761                display_characters -= width
762            else:
763                break
764
765        return self.set_string(
766            unicode_string=u"".join(output_chars),
767            char_widths=output_widths)
768
769    def tail(self, display_characters):
770        """returns a text object truncated to the given length
771
772        characters at the beginning of the string are removed as needed
773
774        due to double-width characters,
775        the size of the string may be smaller than requested"""
776
777        if display_characters < 0:
778            raise ValueError("display characters must be >= 0")
779
780        output_chars = []
781        output_widths = []
782
783        for (char, width) in reversed(list(self.char_widths())):
784            if width <= display_characters:
785                output_chars.append(char)
786                output_widths.append(width)
787                display_characters -= width
788            else:
789                break
790
791        output_chars.reverse()
792        output_widths.reverse()
793
794        return self.set_string(
795            unicode_string=u"".join(output_chars),
796            char_widths=output_widths)
797
798    def split(self, display_characters):
799        """returns a tuple of text objects
800
801        the first is up to 'display_characters' in length
802        the second contains the remainder of the string
803
804        due to double-width characters,
805        the first string may be smaller than requested"""
806
807        if display_characters < 0:
808            raise ValueError("display characters must be >= 0")
809
810        head_chars = []
811        head_widths = []
812        tail_chars = []
813        tail_widths = []
814        for (char, width) in self.char_widths():
815            if width <= display_characters:
816                head_chars.append(char)
817                head_widths.append(width)
818                display_characters -= width
819            else:
820                tail_chars.append(char)
821                tail_widths.append(width)
822                display_characters = -1
823
824        return (self.set_string(unicode_string=u"".join(head_chars),
825                                char_widths=head_widths),
826                self.set_string(unicode_string=u"".join(tail_chars),
827                                char_widths=tail_widths))
828
829    def join(self, output_texts):
830        """returns output_list joined by our formatted text"""
831
832        def join_iter(texts):
833            for (i, text) in enumerate(texts):
834                if i > 0:
835                    yield self
836                yield text
837
838        return output_list(join_iter(output_texts))
839
840
841class output_list(output_text):
842    """a class for formatting multiple unicode strings as a unit
843
844    Note that a styled list enclosing styled text isn't likely
845    to nest as expected since styles are reset to the terminal default
846    rather than to what they were initially.
847
848    So it's best to either style the internal elements
849    or style the list, but not both."""
850
851    def __new__(cls, output_texts, fg_color=None, bg_color=None, style=None):
852        """output_texts is an iterable of output_text objects or unicode
853
854        fg_color and bg_color may be one of:
855        'black', 'red', 'green', 'yellow',
856        'blue', 'magenta', 'cyan', 'white'
857
858        style may be one of:
859        'bold', 'underline', 'blink', 'inverse'
860        """
861
862        return cls.__construct__(
863            output_texts=[t if isinstance(t, output_text) else
864                          output_text(t) for t in output_texts],
865            fg_color=fg_color,
866            bg_color=bg_color,
867            style=style,
868            open_codes=cls.__open_codes__(fg_color, bg_color, style),
869            close_codes=cls.__close_codes__(fg_color, bg_color, style))
870
871    @classmethod
872    def __construct__(cls,
873                      output_texts,
874                      fg_color,
875                      bg_color,
876                      style,
877                      open_codes,
878                      close_codes):
879        """
880        | output_texts | [output_text] | output texts              |
881        | fg_color     | str           | foreground color, or None |
882        | bg_color     | str           | background color, or None |
883        | style        | str           | text style, or None       |
884        | open_codes   | unicode       | ANSI escape codes         |
885        | close_codes  | unicode       | ANSI escape codes         |
886        """
887
888        return tuple.__new__(cls,
889                             [tuple(output_texts),          # 0
890                              sum(map(len, output_texts)),  # 1
891                              fg_color,                     # 2
892                              bg_color,                     # 3
893                              style,                        # 4
894                              open_codes,                   # 5
895                              close_codes                   # 6
896                              ])
897
898    # use __repr__ from parent
899
900    # use __str__ from parent
901
902    def __unicode__(self):
903        return u"".join(s[0] for s in self[0])
904
905    def char_widths(self):
906        """yields a (char, width) for each character in list"""
907
908        for output_text in self[0]:
909            for pair in output_text.char_widths():
910                yield pair
911
912    def __len__(self):
913        return self[1]
914
915    def fg_color(self):
916        """returns the foreground color as a string"""
917
918        return self[2]
919
920    def bg_color(self):
921        """returns the background color as a string"""
922
923        return self[3]
924
925    def style(self):
926        """returns the style as a string"""
927
928        return self[4]
929
930    def set_string(self, output_texts):
931        """returns a new output_list with the given texts"""
932
933        return output_list.__construct__(
934            output_texts=[t if isinstance(t, output_text) else
935                          output_text(t) for t in output_texts],
936            fg_color=self[2],
937            bg_color=self[3],
938            style=self[4],
939            open_codes=self[5],
940            close_codes=self[6])
941
942    def set_format(self, fg_color=None, bg_color=None, style=None):
943        """returns a new output_list with the given format"""
944
945        return output_list.__construct__(
946            output_texts=self[0],
947            fg_color=fg_color,
948            bg_color=bg_color,
949            style=style,
950            open_codes=output_list.__open_codes__(fg_color,
951                                                  bg_color,
952                                                  style),
953            close_codes=output_list.__close_codes__(fg_color,
954                                                    bg_color,
955                                                    style))
956
957    def has_formatting(self):
958        """returns True if the output_list itself has formatting set"""
959
960        return ((self[2] is not None) or
961                (self[3] is not None) or
962                (self[4] is not None))
963
964    def format(self, is_tty=False):
965        """returns unicode text formatted depending on is_tty"""
966
967        # display escape codes around entire list
968        # or on individual text items
969        # but not both
970
971        if is_tty and self.has_formatting():
972            return u"%s%s%s" % (
973                self[5],
974                u"".join(t.format(False) for t in self[0]),
975                self[6])
976        else:
977            return u"".join(t.format(is_tty) for t in self[0])
978
979    def head(self, display_characters):
980        """returns a text object truncated to the given length
981
982        characters at the end of the string are removed as needed
983
984        due to double-width characters,
985        the size of the string may be smaller than requested"""
986
987        if display_characters < 0:
988            raise ValueError("display characters must be >= 0")
989
990        output_texts = []
991
992        for text in self[0]:
993            if len(text) <= display_characters:
994                output_texts.append(text)
995                display_characters -= len(text)
996            else:
997                output_texts.append(text.head(display_characters))
998                break
999
1000        return self.set_string(output_texts=output_texts)
1001
1002    def tail(self, display_characters):
1003        """returns a text object truncated to the given length
1004
1005        characters at the beginning of the string are removed as needed
1006
1007        due to double-width characters,
1008        the size of the string may be smaller than requested"""
1009
1010        if display_characters < 0:
1011            raise ValueError("display characters must be >= 0")
1012
1013        output_texts = []
1014
1015        for text in reversed(self[0]):
1016            if len(text) <= display_characters:
1017                output_texts.append(text)
1018                display_characters -= len(text)
1019            else:
1020                output_texts.append(text.tail(display_characters))
1021                break
1022
1023        return self.set_string(output_texts=reversed(output_texts))
1024
1025    def split(self, display_characters):
1026        """returns a tuple of text objects
1027
1028        the first is up to 'display_characters' in length
1029        the second contains the remainder of the string
1030
1031        due to double-width characters,
1032        the first string may be smaller than requested
1033        """
1034
1035        if display_characters < 0:
1036            raise ValueError("display characters must be >= 0")
1037
1038        head_texts = []
1039        tail_texts = []
1040
1041        for text in self[0]:
1042            if len(text) <= display_characters:
1043                head_texts.append(text)
1044                display_characters -= len(text)
1045            elif display_characters >= 0:
1046                (head, tail) = text.split(display_characters)
1047                head_texts.append(head)
1048                tail_texts.append(tail)
1049                display_characters = -1
1050            else:
1051                tail_texts.append(text)
1052
1053        return (self.set_string(output_texts=head_texts),
1054                self.set_string(output_texts=tail_texts))
1055
1056
1057class output_table(object):
1058    def __init__(self):
1059        """a class for formatting rows for display"""
1060
1061        self.__rows__ = []
1062
1063    def row(self):
1064        """returns a output_table_row object which columns can be added to"""
1065
1066        row = output_table_row()
1067        self.__rows__.append(row)
1068        return row
1069
1070    def blank_row(self):
1071        """inserts a blank table row with no output"""
1072
1073        self.__rows__.append(output_table_blank())
1074
1075    def divider_row(self, dividers):
1076        """adds a row of unicode divider characters
1077
1078        there should be one character in dividers per output column"""
1079
1080        self.__rows__.append(output_table_divider(dividers))
1081
1082    def format(self, is_tty=False):
1083        """yields one unicode formatted string per row depending on is_tty"""
1084
1085        if len(self.__rows__) == 0:
1086            # no rows, so do nothing
1087            return
1088
1089        row_columns = {len(r) for r in self.__rows__ if not r.blank()}
1090
1091        if len(row_columns) == 0:
1092            # all rows are blank
1093            for row in self.__rows__:
1094                # blank rows ignore column widths
1095                yield row.format(None, is_tty)
1096        elif len(row_columns) == 1:
1097            column_widths = [
1098                max(row.column_width(col) for row in self.__rows__)
1099                for col in range(row_columns.pop())]
1100
1101            for row in self.__rows__:
1102                yield row.format(column_widths, is_tty)
1103        else:
1104            raise ValueError("all rows must have same number of columns")
1105
1106
1107class output_table_blank(object):
1108    """a class for an empty table row"""
1109
1110    def __init__(self):
1111        pass
1112
1113    def blank(self):
1114        return True
1115
1116    def column_width(self, column):
1117        return 0
1118
1119    def format(self, column_widths, is_tty=False):
1120        """returns formatted row as unicode"""
1121
1122        return u""
1123
1124
1125class output_table_divider(output_table_blank):
1126    """a class for formatting a row of divider characters"""
1127
1128    def __init__(self, dividers):
1129        self.__dividers__ = dividers[:]
1130
1131    def blank(self):
1132        return False
1133
1134    def __len__(self):
1135        return len(self.__dividers__)
1136
1137    def column_width(self, column):
1138        return 0
1139
1140    def format(self, column_widths, is_tty=False):
1141        """returns formatted row as unicode"""
1142
1143        assert(len(column_widths) == len(self.__dividers__))
1144
1145        return u"".join([divider * width
1146                         for (divider, width) in
1147                         zip(self.__dividers__, column_widths)]).rstrip()
1148
1149
1150class output_table_row(output_table_divider):
1151    def __init__(self):
1152        """a class for formatting columns for display"""
1153
1154        self.__columns__ = []
1155
1156    def __len__(self):
1157        return len(self.__columns__)
1158
1159    def column_width(self, column):
1160        return self.__columns__[column].minimum_width()
1161
1162    def format(self, column_widths, is_tty=False):
1163        """returns formatted row as unicode"""
1164
1165        assert(len(column_widths) == len(self.__columns__))
1166
1167        return u"".join([column.format(width, is_tty)
1168                         for (column, width) in
1169                         zip(self.__columns__, column_widths)]).rstrip()
1170
1171    def add_column(self, text, alignment="left", colspan=1):
1172        """adds text, which may be unicode or a formatted output_text object
1173
1174        alignment may be 'left', 'center', 'right'"""
1175
1176        if alignment not in {"left", "center", "right"}:
1177            raise ValueError("alignment must be 'left', 'center', or 'right'")
1178        if colspan == 1:
1179            self.__columns__.append(output_table_col(text, alignment))
1180        elif colspan > 1:
1181            accumulators = [output_table_multicol_accumulator()
1182                            for i in range(colspan - 1)]
1183            self.__columns__.extend(accumulators)
1184            self.__columns__.append(output_table_multicol(
1185                accumulators, text, alignment))
1186        else:
1187            raise ValueError("colspan must be >= 1")
1188
1189
1190class output_table_col(object):
1191    def __init__(self, text, alignment="left"):
1192        """text is an output_text or unicode object,
1193        alignment is 'left', 'center', or 'right'
1194        """
1195
1196        if isinstance(text, output_text):
1197            self.__text__ = text
1198        else:
1199            self.__text__ = output_text(text)
1200
1201        if alignment == "left":
1202            self.format = self.__format_left__
1203        elif alignment == "center":
1204            self.format = self.__format_center__
1205        elif alignment == "right":
1206            self.format = self.__format_right__
1207        else:
1208            raise ValueError("alignment must be 'left', 'center', or 'right'")
1209
1210    def minimum_width(self):
1211        return len(self.__text__)
1212
1213    def __format_left__(self, column_width, is_tty):
1214        padding = column_width - len(self.__text__)
1215        if padding > 0:
1216            return self.__text__.format(is_tty) + u" " * padding
1217        elif padding == 0:
1218            return self.__text__.format(is_tty)
1219        else:
1220            truncated = self.__text__.head(column_width)
1221            return (truncated.format(is_tty) +
1222                    u" " * (column_width - len(truncated)))
1223
1224    def __format_center__(self, column_width, is_tty):
1225        left_padding = (column_width - len(self.__text__)) // 2
1226        right_padding = column_width - (left_padding + len(self.__text__))
1227
1228        if (left_padding > 0) or (right_padding > 0):
1229            return (u" " * left_padding +
1230                    self.__text__.format(is_tty) +
1231                    u" " * right_padding)
1232        elif (left_padding == 0) and (right_padding == 0):
1233            return self.__text__.format(is_tty)
1234        else:
1235            truncated = self.__text__.head(column_width)
1236            return (truncated.format(is_tty) +
1237                    u" " * (column_width - len(truncated)))
1238
1239    def __format_right__(self, column_width, is_tty):
1240        padding = column_width - len(self.__text__)
1241        if padding > 0:
1242            return u" " * padding + self.__text__.format(is_tty)
1243        elif padding == 0:
1244            return self.__text__.format(is_tty)
1245        else:
1246            truncated = self.__text__.tail(column_width)
1247            return (u" " * (column_width - len(truncated)) +
1248                    truncated.format(is_tty))
1249
1250
1251class output_table_multicol_accumulator(output_table_col):
1252    def __init__(self):
1253        self.__actual_width__ = 0
1254
1255    def minimum_width(self):
1256        return 0
1257
1258    def actual_width(self):
1259        return self.__actual_width__
1260
1261    def format(self, column_width, is_tty):
1262        self.__actual_width__ = column_width
1263        return u""
1264
1265
1266class output_table_multicol(output_table_col):
1267    def __init__(self, accumulators, text, alignment="left"):
1268        self.__base_col__ = output_table_col(text, alignment)
1269        self.__accumulators__ = accumulators
1270
1271    def minimum_width(self):
1272        return 0
1273
1274    def format(self, column_width, is_tty):
1275        return self.__base_col__.format(
1276            sum([a.actual_width() for a in self.__accumulators__]) +
1277            column_width, is_tty)
1278
1279
1280class ProgressDisplay(object):
1281    """a class for displaying incremental progress updates to the screen"""
1282
1283    def __init__(self, messenger):
1284        """takes a Messenger object for displaying output"""
1285
1286        from collections import deque
1287
1288        self.messenger = messenger
1289        self.progress_rows = []
1290        self.empty_slots = deque()
1291        self.displayed_rows = 0
1292
1293    def add_row(self, output_line):
1294        """returns ProgressRow to be displayed
1295
1296        output_line is a unicode string"""
1297
1298        if len(self.empty_slots) == 0:
1299            # no slots to reuse, so append new row
1300            index = len(self.progress_rows)
1301            row = ProgressRow(self, index, output_line)
1302            self.progress_rows.append(row)
1303            return row
1304        else:
1305            # reuse first available slot
1306            index = self.empty_slots.popleft()
1307            row = ProgressRow(self, index, output_line)
1308            self.progress_rows[index] = row
1309            return row
1310
1311    def remove_row(self, row_index):
1312        """removes the given row index and frees the slot for reuse"""
1313
1314        self.empty_slots.append(row_index)
1315        self.progress_rows[row_index] = None
1316
1317    def display_rows(self):
1318        """outputs the current state of all progress rows"""
1319
1320        if sys.stdout.isatty():
1321            (screen_height,
1322             screen_width) = self.messenger.terminal_size(sys.stdout)
1323
1324            for row in self.progress_rows:
1325                if (((row is not None) and
1326                     (self.displayed_rows < screen_height))):
1327                    self.messenger.output(row.unicode(screen_width))
1328                    self.displayed_rows += 1
1329
1330    def clear_rows(self):
1331        """clears all previously displayed output rows, if any"""
1332
1333        if sys.stdout.isatty() and (self.displayed_rows > 0):
1334            self.messenger.ansi_clearline()
1335            self.messenger.ansi_uplines(self.displayed_rows)
1336            self.messenger.ansi_cleardown()
1337            self.displayed_rows = 0
1338
1339    def output_line(self, line):
1340        """displays a line of text to the Messenger's output
1341        after any previous rows have been cleared
1342        and before any new rows have been displayed"""
1343
1344        self.messenger.output(line)
1345
1346
1347class ProgressRow(object):
1348    """a class for displaying a single row of progress output
1349
1350    it should be returned from ProgressDisplay.add_row()
1351    rather than instantiated directly"""
1352
1353    def __init__(self, progress_display, row_index, output_line):
1354        """progress_display is a ProgressDisplay object
1355
1356        row_index is this row's output index
1357
1358        output_line is a unicode string"""
1359
1360        from time import time
1361
1362        self.progress_display = progress_display
1363        self.row_index = row_index
1364        self.output_line = output_text(output_line)
1365        self.current = 0
1366        self.total = 1
1367        self.start_time = time()
1368
1369    def update(self, current, total):
1370        """updates our row with the current progress values"""
1371
1372        self.current = min(current, total)
1373        self.total = total
1374
1375    def finish(self):
1376        """indicate output is finished and the row will no longer be needed"""
1377
1378        self.progress_display.remove_row(self.row_index)
1379
1380    def unicode(self, width):
1381        """returns a unicode string formatted to the given width"""
1382
1383        from time import time
1384
1385        try:
1386            time_spent = time() - self.start_time
1387
1388            split_point = (width * self.current) // self.total
1389            estimated_total_time = (time_spent * self.total) / self.current
1390            estimated_time_remaining = int(round(estimated_total_time -
1391                                                 time_spent))
1392            time_remaining = u" %2.1d:%2.2d" % (estimated_time_remaining // 60,
1393                                                estimated_time_remaining % 60)
1394        except ZeroDivisionError:
1395            split_point = 0
1396            time_remaining = u" --:--"
1397
1398        if len(self.output_line) + len(time_remaining) > width:
1399            # truncate output line and append time remaining
1400            truncated = self.output_line.tail(
1401                width - (len(time_remaining) + 1))
1402            combined_line = output_list(
1403                # note that "truncated" may be smaller than expected
1404                # so pad with more ellipsises if needed
1405                [u"\u2026" * (width - (len(truncated) +
1406                                       len(time_remaining))),
1407                 truncated,
1408                 time_remaining])
1409        else:
1410            # add padding between output line and time remaining
1411            combined_line = output_list(
1412                [self.output_line,
1413                 u" " * (width - (len(self.output_line) +
1414                                  len(time_remaining))),
1415                 time_remaining])
1416
1417        # turn whole line into progress bar
1418        (head, tail) = combined_line.split(split_point)
1419
1420        return (head.set_format(fg_color="white",
1421                                bg_color="blue").format(True) +
1422                tail.format(True))
1423
1424
1425class SingleProgressDisplay(ProgressDisplay):
1426    """a specialized ProgressDisplay for handling a single line of output"""
1427
1428    def __init__(self, messenger, progress_text):
1429        """takes a Messenger class and unicode string for output"""
1430
1431        ProgressDisplay.__init__(self, messenger)
1432        self.row = self.add_row(progress_text)
1433
1434        from time import time
1435
1436        self.time = time
1437        self.last_updated = 0
1438
1439    def update(self, current, total):
1440        """updates the output line with new current and total values"""
1441
1442        now = self.time()
1443        if (now - self.last_updated) > 0.25:
1444            self.clear_rows()
1445            self.row.update(current, total)
1446            self.display_rows()
1447            self.last_updated = now
1448
1449
1450class ReplayGainProgressDisplay(ProgressDisplay):
1451    """a specialized ProgressDisplay for handling ReplayGain application"""
1452
1453    def __init__(self, messenger):
1454        """takes a Messenger and whether ReplayGain is lossless or not"""
1455
1456        ProgressDisplay.__init__(self, messenger)
1457
1458        from time import time
1459        from audiotools.text import RG_ADDING_REPLAYGAIN
1460
1461        self.time = time
1462        self.last_updated = 0
1463
1464        self.row = self.add_row(RG_ADDING_REPLAYGAIN)
1465
1466        if sys.stdout.isatty():
1467            self.initial_message = self.initial_message_tty
1468            self.update = self.update_tty
1469            self.final_message = self.final_message_tty
1470        else:
1471            self.initial_message = self.initial_message_nontty
1472            self.update = self.update_nontty
1473            self.final_message = self.final_message_nontty
1474
1475    def initial_message_tty(self):
1476        """displays a message that ReplayGain application has started"""
1477
1478        pass
1479
1480    def initial_message_nontty(self):
1481        """displays a message that ReplayGain application has started"""
1482
1483        from audiotools.text import RG_ADDING_REPLAYGAIN_WAIT
1484
1485        self.messenger.info(RG_ADDING_REPLAYGAIN_WAIT)
1486
1487    def update_tty(self, current, total):
1488        """updates the current status of ReplayGain application"""
1489
1490        now = self.time()
1491        if (now - self.last_updated) > 0.25:
1492            self.clear_rows()
1493            self.row.update(current, total)
1494            self.display_rows()
1495            self.last_updated = now
1496
1497    def update_nontty(self, current, total):
1498        """updates the current status of ReplayGain application"""
1499
1500        pass
1501
1502    def final_message_tty(self):
1503        """displays a message that ReplayGain application is complete"""
1504
1505        from audiotools.text import RG_REPLAYGAIN_ADDED
1506
1507        self.clear_rows()
1508        self.messenger.info(RG_REPLAYGAIN_ADDED)
1509
1510    def final_message_nontty(self):
1511        """displays a message that ReplayGain application is complete"""
1512
1513        pass
1514
1515
1516class UnsupportedFile(Exception):
1517    """raised by open() if the file can be opened but not identified"""
1518
1519    pass
1520
1521
1522class InvalidFile(Exception):
1523    """raised during initialization if the file is invalid in some way"""
1524
1525    pass
1526
1527
1528class EncodingError(IOError):
1529    """raised if an audio file cannot be created correctly from from_pcm()
1530    due to an error by the encoder"""
1531
1532    def __init__(self, error_message):
1533        IOError.__init__(self, error_message)
1534        self.error_message = error_message
1535
1536
1537class UnsupportedChannelMask(EncodingError):
1538    """raised if the encoder does not support the file's channel mask"""
1539
1540    def __init__(self, filename, mask):
1541        from audiotools.text import ERR_UNSUPPORTED_CHANNEL_MASK
1542
1543        EncodingError.__init__(
1544            self,
1545            ERR_UNSUPPORTED_CHANNEL_MASK %
1546            {"target_filename": Filename(filename),
1547             "assignment": ChannelMask(mask)})
1548
1549
1550class UnsupportedChannelCount(EncodingError):
1551    """raised if the encoder does not support the file's channel count"""
1552
1553    def __init__(self, filename, count):
1554        from audiotools.text import ERR_UNSUPPORTED_CHANNEL_COUNT
1555
1556        EncodingError.__init__(
1557            self,
1558            ERR_UNSUPPORTED_CHANNEL_COUNT %
1559            {"target_filename": Filename(filename),
1560             "channels": count})
1561
1562
1563class UnsupportedBitsPerSample(EncodingError):
1564    """raised if the encoder does not support the file's bits-per-sample"""
1565
1566    def __init__(self, filename, bits_per_sample):
1567        from audiotools.text import ERR_UNSUPPORTED_BITS_PER_SAMPLE
1568
1569        EncodingError.__init__(
1570            self,
1571            ERR_UNSUPPORTED_BITS_PER_SAMPLE %
1572            {"target_filename": Filename(filename),
1573             "bps": bits_per_sample})
1574
1575
1576class DecodingError(IOError):
1577    """raised if the decoder exits with an error
1578
1579    typically, a from_pcm() method will catch this error
1580    and raise EncodingError"""
1581
1582    def __init__(self, error_message):
1583        IOError.__init__(self, error_message)
1584
1585
1586def file_type(file):
1587    """given a seekable file stream
1588    returns an AudioFile-compatible class that stream is a type of
1589    or None of the stream's type is unknown
1590
1591    the AudioFile class is not guaranteed to be available"""
1592
1593    start = file.tell()
1594    header = file.read(37)
1595    file.seek(start, 0)
1596
1597    if ((header[4:8] == b"ftyp") and (header[8:12] in (b"mp41",
1598                                                       b"mp42",
1599                                                       b"M4A ",
1600                                                       b"M4B "))):
1601
1602        # possibly ALAC or M4A
1603
1604        from audiotools.bitstream import BitstreamReader
1605        from audiotools.m4a import get_m4a_atom
1606
1607        reader = BitstreamReader(file, False)
1608
1609        # so get contents of moov->trak->mdia->minf->stbl->stsd atom
1610        try:
1611            stsd = get_m4a_atom(reader,
1612                                b"moov", b"trak", b"mdia",
1613                                b"minf", b"stbl", b"stsd")[1]
1614            (stsd_version,
1615             descriptions,
1616             atom_size,
1617             atom_type) = stsd.parse("8u 24p 32u 32u 4b")
1618
1619            if atom_type == b"alac":
1620                # if first description is "alac" atom, it's an ALAC
1621                return ALACAudio
1622            elif atom_type == b"mp4a":
1623                # if first description is "mp4a" atom, it's M4A
1624                return M4AAudio
1625            else:
1626                # otherwise, it's unknown
1627                return None
1628        except KeyError:
1629            # no stsd atom, so unknown
1630            return None
1631        except IOError:
1632            # error reading atom, so unknown
1633            return None
1634    elif (header[0:4] == b"FORM") and (header[8:12] == b"AIFF"):
1635        return AiffAudio
1636    elif header[0:4] == b".snd":
1637        return AuAudio
1638    elif header[0:4] == b"fLaC":
1639        return FlacAudio
1640    elif (len(header) >= 4) and (header[0:1] == b"\xFF"):
1641        # possibly MP3 or MP2
1642
1643        from audiotools.bitstream import parse
1644
1645        # header is at least 32 bits, so no IOError is possible
1646        (frame_sync,
1647         mpeg_id,
1648         layer_description,
1649         protection,
1650         bitrate,
1651         sample_rate,
1652         pad,
1653         private,
1654         channels,
1655         mode_extension,
1656         copy,
1657         original,
1658         emphasis) = parse("11u 2u 2u 1u 4u 2u 1u " +
1659                           "1u 2u 2u 1u 1u 2u", False, header)
1660        if (((frame_sync == 0x7FF) and
1661             (mpeg_id == 3) and
1662             (layer_description == 1) and
1663             (bitrate != 0xF) and
1664             (sample_rate != 3) and
1665             (emphasis != 2))):
1666            # MP3s are MPEG-1, Layer-III
1667            return MP3Audio
1668        elif ((frame_sync == 0x7FF) and
1669              (mpeg_id == 3) and
1670              (layer_description == 2) and
1671              (bitrate != 0xF) and
1672              (sample_rate != 3) and
1673              (emphasis != 2)):
1674            # MP2s are MPEG-1, Layer-II
1675            return MP2Audio
1676        else:
1677            # nothing else starts with an initial byte of 0xFF
1678            # so the file is unknown
1679            return None
1680    elif header[0:4] == b"OggS":
1681        # possibly Ogg FLAC, Ogg Vorbis or Ogg Opus
1682        if header[0x1C:0x21] == b"\x7FFLAC":
1683            return OggFlacAudio
1684        elif header[0x1C:0x23] == b"\x01vorbis":
1685            return VorbisAudio
1686        elif header[0x1C:0x26] == b"OpusHead\x01":
1687            return OpusAudio
1688        else:
1689            return None
1690    elif header[0:5] == b"ajkg\x02":
1691        return ShortenAudio
1692    elif header[0:4] == b"wvpk":
1693        return WavPackAudio
1694    elif (header[0:4] == b"RIFF") and (header[8:12] == b"WAVE"):
1695        return WaveAudio
1696    elif ((len(header) >= 10) and
1697          (header[0:3] == b"ID3") and
1698          (header[3:4] in {b"\x02", b"\x03", b"\x04"})):
1699        # file contains ID3v2 tag
1700        # so it may be MP3, MP2, FLAC or TTA
1701
1702        from audiotools.bitstream import parse
1703
1704        # determine sync-safe tag size and skip entire tag
1705        tag_size = 0
1706        for b in parse("1p 7u" * 4, False, header[6:10]):
1707            tag_size = (tag_size << 7) | b
1708        file.seek(start + 10 + tag_size, 0)
1709
1710        t = file_type(file)
1711        # only return type which might be wrapped in ID3v2 tags
1712        if (((t is None) or
1713             (t is MP3Audio) or
1714             (t is MP2Audio) or
1715             (t is FlacAudio) or
1716             (t is TrueAudio))):
1717            return t
1718        else:
1719            return None
1720    elif header[0:4] == b"TTA1":
1721        return TrueAudio
1722    else:
1723        return None
1724
1725
1726# save a reference to Python's regular open function
1727__open__ = open
1728
1729
1730def open(filename):
1731    """returns an AudioFile located at the given filename path
1732
1733    this works solely by examining the file's contents
1734    after opening it
1735    raises UnsupportedFile if it's not a file we support based on its headers
1736    raises InvalidFile if the file appears to be something we support,
1737    but has errors of some sort
1738    raises IOError if some problem occurs attempting to open the file
1739    """
1740
1741    f = __open__(filename, "rb")
1742    try:
1743        audio_class = file_type(f)
1744        if (audio_class is not None) and audio_class.available(BIN):
1745            return audio_class(filename)
1746        else:
1747            raise UnsupportedFile(filename)
1748    finally:
1749        f.close()
1750
1751
1752class DuplicateFile(Exception):
1753    """raised if the same file is included more than once"""
1754
1755    def __init__(self, filename):
1756        """filename is a Filename object"""
1757
1758        from audiotools.text import ERR_DUPLICATE_FILE
1759        Exception.__init__(self, ERR_DUPLICATE_FILE % (filename,))
1760        self.filename = filename
1761
1762
1763class DuplicateOutputFile(Exception):
1764    """raised if the same output file is generated more than once"""
1765
1766    def __init__(self, filename):
1767        """filename is a Filename object"""
1768
1769        from audiotools.text import ERR_DUPLICATE_OUTPUT_FILE
1770        Exception.__init__(self, ERR_DUPLICATE_OUTPUT_FILE % (filename,))
1771        self.filename = filename
1772
1773
1774class OutputFileIsInput(Exception):
1775    """raised if an output file is the same as an input file"""
1776
1777    def __init__(self, filename):
1778        """filename is a Filename object"""
1779
1780        from audiotools.text import ERR_OUTPUT_IS_INPUT
1781        Exception.__init__(self, ERR_OUTPUT_IS_INPUT % (filename,))
1782        self.filename = filename
1783
1784
1785class Filename(tuple):
1786    def __new__(cls, filename):
1787        """filename is a string of the file on disk"""
1788
1789        # under Python 2, filename should be a plain string
1790        # under Python 3, filename should be a unicode string
1791
1792        if isinstance(filename, cls):
1793            return filename
1794        else:
1795            assert(isinstance(filename, str))
1796            try:
1797                stat = os.stat(filename)
1798                return tuple.__new__(cls, [os.path.normpath(filename),
1799                                           stat.st_dev,
1800                                           stat.st_ino])
1801            except OSError:
1802                return tuple.__new__(cls, [os.path.normpath(filename),
1803                                           None,
1804                                           None])
1805
1806    @classmethod
1807    def from_unicode(cls, unicode_string):
1808        """given a unicode string for a given path,
1809        returns a Filename object"""
1810
1811        return cls(unicode_string if PY3 else
1812                   unicode_string.encode(FS_ENCODING))
1813
1814    def open(self, mode):
1815        """returns a file object of this filename opened
1816        with the given mode"""
1817
1818        return __open__(self[0], mode)
1819
1820    def disk_file(self):
1821        """returns True if the file exists on disk"""
1822
1823        return (self[1] is not None) and (self[2] is not None)
1824
1825    def dirname(self):
1826        """returns the directory name (no filename) of this file"""
1827
1828        return Filename(os.path.dirname(self[0]))
1829
1830    def basename(self):
1831        """returns the basename (no directory) of this file"""
1832
1833        return Filename(os.path.basename(self[0]))
1834
1835    def expanduser(self):
1836        """returns a Filename object with user directory expanded"""
1837
1838        return Filename(os.path.expanduser(self[0]))
1839
1840    def abspath(self):
1841        """returns the Filename's absolute path as a Filename object"""
1842
1843        return Filename(os.path.abspath(self[0]))
1844
1845    def __repr__(self):
1846        return "Filename(%s, %s, %s)" % \
1847            (repr(self[0]), repr(self[1]), repr(self[2]))
1848
1849    def __eq__(self, filename):
1850        if isinstance(filename, Filename):
1851            if self.disk_file() and filename.disk_file():
1852                # both exist on disk,
1853                # so they compare equally if st_dev and st_ino match
1854                return (self[1] == filename[1]) and (self[2] == filename[2])
1855            elif (not self.disk_file()) and (not filename.disk_file()):
1856                # neither exist on disk,
1857                # so they compare equally if their paths match
1858                return self[0] == filename[0]
1859            else:
1860                # one or the other exists on disk
1861                # but not both, so they never match
1862                return False
1863        else:
1864            return False
1865
1866    def __ne__(self, filename):
1867        return not self == filename
1868
1869    def __hash__(self):
1870        if self.disk_file():
1871            return hash((None, self[1], self[2]))
1872        else:
1873            return hash((self[0], self[1], self[2]))
1874
1875    def __str__(self):
1876        return self[0]
1877
1878    if PY3:
1879        def __unicode__(self):
1880            return self[0]
1881    else:
1882        def __unicode__(self):
1883            return self[0].decode(FS_ENCODING, "replace")
1884
1885
1886def sorted_tracks(audiofiles):
1887    """given a list of AudioFile objects
1888    returns a list of them sorted
1889    by track_number and album_number, if found
1890    """
1891
1892    return sorted(audiofiles, key=lambda f: f.__sort_key__())
1893
1894
1895def open_files(filename_list, sorted=True, messenger=None,
1896               no_duplicates=False, warn_duplicates=False,
1897               opened_files=None, unsupported_formats=None):
1898    """returns a list of AudioFile objects
1899    from a list of filename strings or Filename objects
1900
1901    if "sorted" is True, files are sorted by album number then track number
1902
1903    if "messenger" is given, warnings and errors when opening files
1904    are sent to the given Messenger-compatible object
1905
1906    if "no_duplicates" is True, including the same file twice
1907    raises a DuplicateFile whose filename value
1908    is the first duplicate filename as a Filename object
1909
1910    if "warn_duplicates" is True, including the same file twice
1911    results in a warning message to the messenger object, if given
1912
1913    "opened_files" is a set object containing previously opened
1914    Filename objects and which newly opened Filename objects are added to
1915
1916    "unsupported_formats" is a set object containing the .NAME strings
1917    of AudioFile objects which have already been displayed
1918    as unsupported in order to avoid displaying duplicate messages
1919    """
1920
1921    from audiotools.text import (ERR_DUPLICATE_FILE,
1922                                 ERR_OPEN_IOERROR)
1923
1924    if opened_files is None:
1925        opened_files = set()
1926    if unsupported_formats is None:
1927        unsupported_formats = set()
1928
1929    to_return = []
1930
1931    for filename in map(Filename, filename_list):
1932        if filename not in opened_files:
1933            opened_files.add(filename)
1934        else:
1935            if no_duplicates:
1936                raise DuplicateFile(filename)
1937            elif warn_duplicates and (messenger is not None):
1938                messenger.warning(ERR_DUPLICATE_FILE % (filename,))
1939
1940        try:
1941            with __open__(str(filename), "rb") as f:
1942                audio_class = file_type(f)
1943
1944            if audio_class is not None:
1945                if audio_class.available(BIN):
1946                    # is a supported audio type with needed binaries
1947                    to_return.append(audio_class(str(filename)))
1948                elif ((messenger is not None) and
1949                      (audio_class.NAME not in unsupported_formats)):
1950                    # is a supported audio type without needed binaries
1951                    # or libraries
1952                    audio_class.missing_components(messenger)
1953
1954                    # but only display format binaries message once
1955                    unsupported_formats.add(audio_class.NAME)
1956            else:
1957                # not a support audio type
1958                pass
1959        except IOError as err:
1960            if messenger is not None:
1961                messenger.warning(ERR_OPEN_IOERROR % (filename,))
1962        except InvalidFile as err:
1963            if messenger is not None:
1964                messenger.error(str(err))
1965
1966    return (sorted_tracks(to_return) if sorted else to_return)
1967
1968
1969def open_directory(directory, sorted=True, messenger=None):
1970    """yields an AudioFile via a recursive search of directory
1971
1972    files are sorted by album number/track number by default,
1973    on a per-directory basis
1974    any unsupported files are filtered out
1975    error messages are sent to messenger, if given
1976    """
1977
1978    for (basedir, subdirs, filenames) in os.walk(directory):
1979        if sorted:
1980            subdirs.sort()
1981        for audiofile in open_files([os.path.join(basedir, filename)
1982                                     for filename in filenames],
1983                                    sorted=sorted,
1984                                    messenger=messenger):
1985            yield audiofile
1986
1987
1988def group_tracks(tracks):
1989    """takes an iterable collection of tracks
1990
1991    yields list of tracks grouped by album
1992    where their album_name and album_number match, if possible"""
1993
1994    collection = {}
1995    for track in tracks:
1996        metadata = track.get_metadata()
1997        if metadata is not None:
1998            collection.setdefault(
1999                (metadata.album_number if
2000                 metadata.album_number is not None else
2001                 -(2 ** 31),
2002                 metadata.album_name if
2003                 metadata.album_name is not None else
2004                 u""), []).append(track)
2005        else:
2006            collection.setdefault(None, []).append(track)
2007
2008    if None in collection:
2009        yield collection[None]
2010    for key in sorted([key for key in collection.keys() if key is not None]):
2011        yield collection[key]
2012
2013
2014class UnknownAudioType(Exception):
2015    """raised if filename_to_type finds no possibilities"""
2016
2017    def __init__(self, suffix):
2018        self.suffix = suffix
2019
2020    def error_msg(self, messenger):
2021        from audiotools.text import ERR_UNSUPPORTED_AUDIO_TYPE
2022
2023        messenger.error(ERR_UNSUPPORTED_AUDIO_TYPE % (self.suffix,))
2024
2025
2026class AmbiguousAudioType(UnknownAudioType):
2027    """raised if filename_to_type finds more than one possibility"""
2028
2029    def __init__(self, suffix, type_list):
2030        self.suffix = suffix
2031        self.type_list = type_list
2032
2033    def error_msg(self, messenger):
2034        from audiotools.text import (ERR_AMBIGUOUS_AUDIO_TYPE,
2035                                     LAB_T_OPTIONS)
2036
2037        messenger.error(ERR_AMBIGUOUS_AUDIO_TYPE % (self.suffix,))
2038        messenger.info(LAB_T_OPTIONS %
2039                       (u" or ".join([u"\"%s\"" % (t.NAME.decode('ascii'))
2040                                      for t in self.type_list])))
2041
2042
2043def filename_to_type(path):
2044    """given a path to a file, return its audio type based on suffix
2045
2046    for example:
2047    >>> filename_to_type("/foo/file.flac")
2048    <class audiotools.__flac__.FlacAudio at 0x7fc8456d55f0>
2049
2050    raises an UnknownAudioType exception if the type is unknown
2051    raise AmbiguousAudioType exception if the type is ambiguous
2052    """
2053
2054    (path, ext) = os.path.splitext(path)
2055    if len(ext) > 0:
2056        ext = ext[1:]   # remove the "."
2057        SUFFIX_MAP = {}
2058        for audio_type in TYPE_MAP.values():
2059            SUFFIX_MAP.setdefault(audio_type.SUFFIX, []).append(audio_type)
2060        if ext in SUFFIX_MAP.keys():
2061            if len(SUFFIX_MAP[ext]) == 1:
2062                return SUFFIX_MAP[ext][0]
2063            else:
2064                raise AmbiguousAudioType(ext, SUFFIX_MAP[ext])
2065        else:
2066            raise UnknownAudioType(ext)
2067    else:
2068        raise UnknownAudioType(ext)
2069
2070
2071class ChannelMask(object):
2072    """an integer-like class that abstracts a PCMReader's channel assignments
2073
2074    all channels in a FrameList will be in RIFF WAVE order
2075    as a sensible convention
2076    but which channel corresponds to which speaker is decided by this mask
2077    for example, a 4 channel PCMReader with the channel mask 0x33
2078    corresponds to the bits 00110011
2079    reading those bits from right to left (least significant first)
2080    the "front_left", "front_right", "back_left", "back_right"
2081    speakers are set
2082
2083    therefore, the PCMReader's 4 channel FrameLists are laid out as follows:
2084
2085    channel 0 -> front_left
2086    channel 1 -> front_right
2087    channel 2 -> back_left
2088    channel 3 -> back_right
2089
2090    since the "front_center" and "low_frequency" bits are not set,
2091    those channels are skipped in the returned FrameLists
2092
2093    many formats store their channels internally in a different order
2094    their PCMReaders will be expected to reorder channels
2095    and set a ChannelMask matching this convention
2096    and, their from_pcm() functions will be expected to reverse the process
2097
2098    a ChannelMask of 0 is "undefined",
2099    which means that channels aren't assigned to *any* speaker
2100    this is an ugly last resort for handling formats
2101    where multi-channel assignments aren't properly defined
2102    in this case, a from_pcm() method is free to assign the undefined channels
2103    any way it likes, and is under no obligation to keep them undefined
2104    when passing back out to to_pcm()
2105    """
2106
2107    SPEAKER_TO_MASK = {"front_left": 0x1,
2108                       "front_right": 0x2,
2109                       "front_center": 0x4,
2110                       "low_frequency": 0x8,
2111                       "back_left": 0x10,
2112                       "back_right": 0x20,
2113                       "front_left_of_center": 0x40,
2114                       "front_right_of_center": 0x80,
2115                       "back_center": 0x100,
2116                       "side_left": 0x200,
2117                       "side_right": 0x400,
2118                       "top_center": 0x800,
2119                       "top_front_left": 0x1000,
2120                       "top_front_center": 0x2000,
2121                       "top_front_right": 0x4000,
2122                       "top_back_left": 0x8000,
2123                       "top_back_center": 0x10000,
2124                       "top_back_right": 0x20000}
2125
2126    MASK_TO_SPEAKER = dict(map(reversed, map(list, SPEAKER_TO_MASK.items())))
2127
2128    from audiotools.text import (MASK_FRONT_LEFT,
2129                                 MASK_FRONT_RIGHT,
2130                                 MASK_FRONT_CENTER,
2131                                 MASK_LFE,
2132                                 MASK_BACK_LEFT,
2133                                 MASK_BACK_RIGHT,
2134                                 MASK_FRONT_RIGHT_OF_CENTER,
2135                                 MASK_FRONT_LEFT_OF_CENTER,
2136                                 MASK_BACK_CENTER,
2137                                 MASK_SIDE_LEFT,
2138                                 MASK_SIDE_RIGHT,
2139                                 MASK_TOP_CENTER,
2140                                 MASK_TOP_FRONT_LEFT,
2141                                 MASK_TOP_FRONT_CENTER,
2142                                 MASK_TOP_FRONT_RIGHT,
2143                                 MASK_TOP_BACK_LEFT,
2144                                 MASK_TOP_BACK_CENTER,
2145                                 MASK_TOP_BACK_RIGHT)
2146
2147    MASK_TO_NAME = {0x1: MASK_FRONT_LEFT,
2148                    0x2: MASK_FRONT_RIGHT,
2149                    0x4: MASK_FRONT_CENTER,
2150                    0x8: MASK_LFE,
2151                    0x10: MASK_BACK_LEFT,
2152                    0x20: MASK_BACK_RIGHT,
2153                    0x40: MASK_FRONT_RIGHT_OF_CENTER,
2154                    0x80: MASK_FRONT_LEFT_OF_CENTER,
2155                    0x100: MASK_BACK_CENTER,
2156                    0x200: MASK_SIDE_LEFT,
2157                    0x400: MASK_SIDE_RIGHT,
2158                    0x800: MASK_TOP_CENTER,
2159                    0x1000: MASK_TOP_FRONT_LEFT,
2160                    0x2000: MASK_TOP_FRONT_CENTER,
2161                    0x4000: MASK_TOP_FRONT_RIGHT,
2162                    0x8000: MASK_TOP_BACK_LEFT,
2163                    0x10000: MASK_TOP_BACK_CENTER,
2164                    0x20000: MASK_TOP_BACK_RIGHT}
2165
2166    def __init__(self, mask):
2167        """mask should be an integer channel mask value"""
2168
2169        mask = int(mask)
2170
2171        for (speaker, speaker_mask) in self.SPEAKER_TO_MASK.items():
2172            setattr(self, speaker, (mask & speaker_mask) != 0)
2173
2174    def __repr__(self):
2175        return "ChannelMask(%s)" % \
2176            ",".join(["%s=%s" % (field, getattr(self, field))
2177                      for field in self.SPEAKER_TO_MASK.keys()
2178                      if (getattr(self, field))])
2179
2180    if PY3:
2181        def __str__(self):
2182            return self.__unicode__()
2183    else:
2184        def __str__(self):
2185            return self.__unicode__().encode('utf-8')
2186
2187    def __unicode__(self):
2188        current_mask = int(self)
2189
2190        return u",".join(ChannelMask.MASK_TO_NAME[mask]
2191                         for mask in sorted(ChannelMask.MASK_TO_NAME.keys())
2192                         if mask & current_mask)
2193
2194    def __int__(self):
2195        from operator import or_
2196        from functools import reduce
2197
2198        return reduce(or_,
2199                      [self.SPEAKER_TO_MASK[field] for field in
2200                       self.SPEAKER_TO_MASK.keys()
2201                       if getattr(self, field)],
2202                      0)
2203
2204    def __eq__(self, v):
2205        return int(self) == int(v)
2206
2207    def __ne__(self, v):
2208        return int(self) != int(v)
2209
2210    def __len__(self):
2211        return sum([1 for field in self.SPEAKER_TO_MASK.keys()
2212                    if getattr(self, field)])
2213
2214    def defined(self):
2215        """returns True if this ChannelMask is defined"""
2216
2217        return int(self) != 0
2218
2219    def undefined(self):
2220        """returns True if this ChannelMask is undefined"""
2221
2222        return int(self) == 0
2223
2224    def channels(self):
2225        """returns a list of speaker strings this mask contains
2226
2227        returned in the order in which they should appear
2228        in the PCM stream
2229        """
2230
2231        c = []
2232        for (mask, speaker) in sorted(self.MASK_TO_SPEAKER.items(),
2233                                      key=lambda pair: pair[0]):
2234            if getattr(self, speaker):
2235                c.append(speaker)
2236
2237        return c
2238
2239    def index(self, channel_name):
2240        """returns the index of the given channel name within this mask
2241
2242        for example, given the mask 0xB (fL, fR, LFE, but no fC)
2243        index("low_frequency") will return 2
2244        if the channel is not in this mask, raises ValueError"""
2245
2246        return self.channels().index(channel_name)
2247
2248    @classmethod
2249    def from_fields(cls, **fields):
2250        """given a set of channel arguments, returns a new ChannelMask
2251
2252        for example:
2253        >>> ChannelMask.from_fields(front_left=True,front_right=True)
2254        channelMask(front_right=True,front_left=True)
2255        """
2256
2257        mask = cls(0)
2258
2259        for (key, value) in fields.items():
2260            if key in cls.SPEAKER_TO_MASK.keys():
2261                setattr(mask, key, bool(value))
2262            else:
2263                raise KeyError(key)
2264
2265        return mask
2266
2267    @classmethod
2268    def from_channels(cls, channel_count):
2269        """given a channel count, returns a new ChannelMask
2270
2271        this is only valid for channel counts 1 and 2
2272        all other values trigger a ValueError"""
2273
2274        if channel_count == 2:
2275            return cls(0x3)
2276        elif channel_count == 1:
2277            return cls(0x4)
2278        else:
2279            raise ValueError("ambiguous channel assignment")
2280
2281
2282class PCMReader(object):
2283    def __init__(self, sample_rate, channels, channel_mask, bits_per_sample):
2284        """fields are as follows:
2285
2286        sample rate     - integer number of Hz
2287        channels        - integer channel count
2288        channel_mask    - integer channel mask value
2289        bits_per_sample - number number of bits-per-sample
2290
2291        where channel mask is a logical OR of the following:
2292
2293        | channel               |   value |
2294        |-----------------------+---------|
2295        | front left            |     0x1 |
2296        | front right           |     0x2 |
2297        | front center          |     0x4 |
2298        | low frequency         |     0x8 |
2299        | back left             |    0x10 |
2300        | back right            |    0x20 |
2301        | front left of center  |    0x40 |
2302        | front right of center |    0x80 |
2303        | back center           |   0x100 |
2304        | side left             |   0x200 |
2305        | side right            |   0x400 |
2306        | top center            |   0x800 |
2307        | top front left        |  0x1000 |
2308        | top front center      |  0x2000 |
2309        | top front right       |  0x4000 |
2310        | top back left         |  0x8000 |
2311        | top back center       | 0x10000 |
2312        | top back right        | 0x20000 |
2313        |-----------------------+---------|
2314        """
2315
2316        self.sample_rate = sample_rate
2317        self.channels = channels
2318        self.channel_mask = channel_mask
2319        self.bits_per_sample = bits_per_sample
2320
2321    def __enter__(self):
2322        return self
2323
2324    def __exit__(self, exc_type, exc_value, traceback):
2325        self.close()
2326
2327    def read(self, pcm_frames):
2328        """try to read the given number of PCM frames from the stream
2329        as a FrameList object
2330
2331        this is *not* guaranteed to read exactly that number of frames
2332        it may return less (at the end of the stream, especially)
2333        it may return more
2334        however, it must always return a non-empty FrameList until the
2335        end of the PCM stream is reached
2336
2337        may raise IOError if unable to read the input file,
2338        or ValueError if the input file has some sort of error
2339        """
2340
2341        raise NotImplementedError()
2342
2343    def close(self):
2344        """closes the stream for reading
2345
2346        subsequent calls to read() raise ValueError"""
2347
2348        raise NotImplementedError()
2349
2350
2351class PCMFileReader(PCMReader):
2352    """a class that wraps around a file object and generates pcm.FrameLists"""
2353
2354    def __init__(self, file,
2355                 sample_rate, channels, channel_mask, bits_per_sample,
2356                 process=None, signed=True, big_endian=False):
2357        """fields are as follows:
2358
2359        file            - a file-like object with read() and close() methods
2360        sample_rate     - an integer number of Hz
2361        channels        - an integer number of channels
2362        channel_mask    - an integer channel mask value
2363        bits_per_sample - an integer number of bits per sample
2364        process         - an optional subprocess object
2365        signed          - True if the file's samples are signed integers
2366        big_endian      - True if the file's samples are stored big-endian
2367
2368        the process, signed and big_endian arguments are optional
2369        PCMReader-compatible objects need only expose the
2370        sample_rate, channels, channel_mask and bits_per_sample fields
2371        along with the read() and close() methods
2372        """
2373
2374        PCMReader.__init__(self,
2375                           sample_rate=sample_rate,
2376                           channels=channels,
2377                           channel_mask=channel_mask,
2378                           bits_per_sample=bits_per_sample)
2379        self.file = file
2380        self.process = process
2381        self.signed = signed
2382        self.big_endian = big_endian
2383        self.bytes_per_frame = self.channels * (self.bits_per_sample // 8)
2384
2385    def read(self, pcm_frames):
2386        """try to read the given number of PCM frames from the stream
2387
2388        this is *not* guaranteed to read exactly that number of frames
2389        it may return less (at the end of the stream, especially)
2390        it may return more
2391        however, it must always return a non-empty FrameList until the
2392        end of the PCM stream is reached
2393
2394        may raise IOError if unable to read the input file,
2395        or ValueError if the input file has some sort of error
2396        """
2397
2398        framelist = pcm.FrameList(
2399            self.file.read(max(pcm_frames, 1) * self.bytes_per_frame),
2400            self.channels,
2401            self.bits_per_sample,
2402            self.big_endian,
2403            self.signed)
2404        if framelist.frames > 0:
2405            return framelist
2406        elif self.process is not None:
2407            if self.process.wait() == 0:
2408                self.process = None
2409                return framelist
2410            else:
2411                self.process = None
2412                raise ValueError(u"subprocess exited with error")
2413        else:
2414            return framelist
2415
2416    def close(self):
2417        """closes the stream for reading
2418
2419        subsequent calls to read() raise ValueError"""
2420
2421        self.file.close()
2422        if self.process is not None:
2423            self.process.wait()
2424            self.process = None
2425
2426    def __del__(self):
2427        if self.process is not None:
2428            self.process.kill()
2429            self.process = None
2430
2431
2432class PCMReaderError(PCMReader):
2433    """a dummy PCMReader which automatically raises ValueError
2434
2435    this is to be returned by an AudioFile's to_pcm() method
2436    if some error occurs when initializing a decoder"""
2437
2438    def __init__(self, error_message,
2439                 sample_rate, channels, channel_mask, bits_per_sample):
2440        PCMReader.__init__(self,
2441                           sample_rate=sample_rate,
2442                           channels=channels,
2443                           channel_mask=channel_mask,
2444                           bits_per_sample=bits_per_sample)
2445        self.error_message = error_message
2446
2447    def read(self, pcm_frames):
2448        """always raises a ValueError"""
2449
2450        raise ValueError(self.error_message)
2451
2452    def close(self):
2453        """does nothing"""
2454
2455        pass
2456
2457
2458def to_pcm_progress(audiofile, progress):
2459    if callable(progress):
2460        return PCMReaderProgress(audiofile.to_pcm(),
2461                                 audiofile.total_frames(),
2462                                 progress)
2463    else:
2464        return audiofile.to_pcm()
2465
2466
2467class PCMReaderProgress(PCMReader):
2468    def __init__(self, pcmreader, total_frames, progress, current_frames=0):
2469        """pcmreader is a PCMReader compatible object
2470        total_frames is the total PCM frames of the stream
2471        progress(current, total) is a callable function
2472        current_frames is the current amount of PCM frames in the stream"""
2473
2474        PCMReader.__init__(self,
2475                           sample_rate=pcmreader.sample_rate,
2476                           channels=pcmreader.channels,
2477                           channel_mask=pcmreader.channel_mask,
2478                           bits_per_sample=pcmreader.bits_per_sample)
2479
2480        self.pcmreader = pcmreader
2481        self.current_frames = current_frames
2482        self.total_frames = total_frames
2483        if callable(progress):
2484            self.progress = progress
2485        else:
2486            self.progress = lambda current_frames, total_frames: None
2487
2488    def read(self, pcm_frames):
2489        frame = self.pcmreader.read(pcm_frames)
2490        self.current_frames += frame.frames
2491        self.progress(self.current_frames, self.total_frames)
2492        return frame
2493
2494    def close(self):
2495        self.pcmreader.close()
2496
2497
2498class ReorderedPCMReader(PCMReader):
2499    """a PCMReader wrapper which reorders its output channels"""
2500
2501    def __init__(self, pcmreader, channel_order, channel_mask=None):
2502        """initialized with a PCMReader and list of channel number integers
2503
2504        for example, to swap the channels of a stereo stream:
2505        >>> ReorderedPCMReader(reader,[1,0])
2506
2507        may raise ValueError if the number of channels specified by
2508        channel_order doesn't match the given channel mask
2509        if channel mask is nonzero
2510        """
2511
2512        PCMReader.__init__(self,
2513                           sample_rate=pcmreader.sample_rate,
2514                           channels=len(channel_order),
2515                           channel_mask=(pcmreader.channel_mask
2516                                         if (channel_mask is None) else
2517                                         channel_mask),
2518                           bits_per_sample=pcmreader.bits_per_sample)
2519
2520        self.pcmreader = pcmreader
2521        self.channel_order = channel_order
2522
2523        if (((self.channel_mask != 0) and
2524             (len(ChannelMask(self.channel_mask)) != self.channels))):
2525            # channel_mask is defined but has a different number of channels
2526            # than the channel count attribute
2527            from audiotools.text import ERR_CHANNEL_COUNT_MASK_MISMATCH
2528            raise ValueError(ERR_CHANNEL_COUNT_MASK_MISMATCH)
2529
2530    def read(self, pcm_frames):
2531        """try to read a pcm.FrameList with the given number of frames"""
2532
2533        framelist = self.pcmreader.read(pcm_frames)
2534
2535        return pcm.from_channels([framelist.channel(channel)
2536                                  for channel in self.channel_order])
2537
2538    def close(self):
2539        """closes the stream"""
2540
2541        self.pcmreader.close()
2542
2543
2544class ThreadedPCMReader(PCMReader):
2545    """a PCMReader which decodes all output in the background
2546
2547    It will queue *all* output from its contained PCMReader
2548    as fast as possible in a separate thread.
2549    This may be a problem if PCMReader's total output is very large
2550    or has no upper bound.
2551    """
2552
2553    def __init__(self, pcmreader):
2554        try:
2555            from queue import Queue
2556        except ImportError:
2557            from Queue import Queue
2558        from threading import (Thread, Event)
2559
2560        PCMReader.__init__(self,
2561                           sample_rate=pcmreader.sample_rate,
2562                           channels=pcmreader.channels,
2563                           channel_mask=pcmreader.channel_mask,
2564                           bits_per_sample=pcmreader.bits_per_sample)
2565
2566        def transfer_data(pcmreader, queue, stop_event):
2567            """transfers everything from pcmreader to queue
2568            until stop_event is set or the data is exhausted"""
2569
2570            try:
2571                framelist = pcmreader.read(4096)
2572                while ((len(framelist) > 0) and
2573                       (not stop_event.is_set())):
2574                    queue.put((False, framelist))
2575                    framelist = pcmreader.read(4096)
2576            except (IOError, ValueError) as err:
2577                queue.put((True, err))
2578
2579        self.__pcmreader__ = pcmreader
2580        self.__queue__ = Queue()
2581        self.__stop_event__ = Event()
2582        self.__thread__ = Thread(target=transfer_data,
2583                                 args=(pcmreader,
2584                                       self.__queue__,
2585                                       self.__stop_event__))
2586        self.__thread__.daemon = True
2587        self.__thread__.start()
2588        self.__closed__ = False
2589        self.__finished__ = False
2590
2591    def read(self, pcm_frames):
2592        if self.__closed__:
2593            raise ValueError("stream is closed")
2594        if self.__finished__:
2595            # previous read returned empty FrameList
2596            # so continue to return empty FrameLists
2597            from audiotools.pcm import empty_framelist
2598            return empty_framelist(self.channels, self.bits_per_sample)
2599
2600        (error, value) = self.__queue__.get()
2601        if not error:
2602            if len(value) == 0:
2603                self.__finished__ = True
2604            return value
2605        else:
2606            # some exception raised during transfer_data
2607            raise value
2608
2609    def close(self):
2610        # tell decoder to finish if it is still operating
2611        self.__stop_event__.set()
2612        # collect finished thread
2613        self.__thread__.join()
2614        # close our contained PCMReader
2615        self.__pcmreader__.close()
2616        # mark stream as closed
2617        self.__closed__ = True
2618
2619
2620def transfer_data(from_function, to_function):
2621    """sends BUFFER_SIZE strings from from_function to to_function
2622
2623    this continues until an empty string is returned from from_function"""
2624
2625    try:
2626        s = from_function(BUFFER_SIZE)
2627        while len(s) > 0:
2628            to_function(s)
2629            s = from_function(BUFFER_SIZE)
2630    except IOError:
2631        # this usually means a broken pipe, so we can only hope
2632        # the data reader is closing down correctly
2633        pass
2634
2635
2636def transfer_framelist_data(pcmreader, to_function,
2637                            signed=True, big_endian=False):
2638    """sends pcm.FrameLists from pcmreader to to_function
2639
2640    frameLists are converted to strings using the signed and big_endian
2641    arguments.  This continues until an empty FrameList is returned
2642    from pcmreader
2643
2644    the pcmreader is closed when decoding is complete or fails with an error
2645
2646    may raise IOError or ValueError if a problem occurs during decoding
2647    """
2648
2649    try:
2650        f = pcmreader.read(FRAMELIST_SIZE)
2651        while len(f) > 0:
2652            to_function(f.to_bytes(big_endian, signed))
2653            f = pcmreader.read(FRAMELIST_SIZE)
2654    finally:
2655        pcmreader.close()
2656
2657
2658def pcm_cmp(pcmreader1, pcmreader2):
2659    """returns True if the PCM data in pcmreader1 equals pcmreader2
2660
2661    both streams are closed once comparison is completed
2662
2663    may raise IOError or ValueError if problems occur
2664    when reading PCM streams
2665    """
2666
2667    return (pcm_frame_cmp(pcmreader1, pcmreader2) is None)
2668
2669
2670def pcm_frame_cmp(pcmreader1, pcmreader2):
2671    """returns the PCM Frame number of the first mismatch
2672
2673    if the two streams match completely, returns None
2674
2675    both streams are closed once comparison is completed
2676
2677    may raise IOError or ValueError if problems occur
2678    when reading PCM streams
2679    """
2680
2681    if (((pcmreader1.sample_rate != pcmreader2.sample_rate) or
2682         (pcmreader1.channels != pcmreader2.channels) or
2683         (pcmreader1.bits_per_sample != pcmreader2.bits_per_sample))):
2684        pcmreader1.close()
2685        pcmreader2.close()
2686        return 0
2687
2688    if (((pcmreader1.channel_mask != 0) and
2689         (pcmreader2.channel_mask != 0) and
2690         (pcmreader1.channel_mask != pcmreader2.channel_mask))):
2691        pcmreader1.close()
2692        pcmreader2.close()
2693        return 0
2694
2695    frame_number = 0
2696    reader1 = BufferedPCMReader(pcmreader1)
2697    reader2 = BufferedPCMReader(pcmreader2)
2698    try:
2699        framelist1 = reader1.read(FRAMELIST_SIZE)
2700        framelist2 = reader2.read(FRAMELIST_SIZE)
2701
2702        # so long as both framelists contain data
2703        while (len(framelist1) > 0) and (len(framelist2) > 0):
2704            # compare both entire framelists
2705            if framelist1 == framelist2:
2706                # if they both match, continue to the next pair
2707                frame_number += framelist1.frames
2708                framelist1 = reader1.read(FRAMELIST_SIZE)
2709                framelist2 = reader2.read(FRAMELIST_SIZE)
2710            else:
2711                # if there's a mismatch, determine the exact frame
2712                for i in range(min(framelist1.frames, framelist2.frames)):
2713                    if framelist1.frame(i) != framelist2.frame(i):
2714                        return frame_number + i
2715                else:
2716                    return frame_number + i
2717        else:
2718            # at least one framelist is empty
2719            if (len(framelist1) == 0) and (len(framelist2) == 0):
2720                # if they're both empty, there's no mismatch
2721                return None
2722            else:
2723                # if only one is empty, return as far as we've gotten
2724                return frame_number
2725    finally:
2726        reader1.close()
2727        reader2.close()
2728
2729
2730class PCMCat(PCMReader):
2731    """a PCMReader for concatenating several PCMReaders"""
2732
2733    def __init__(self, pcmreaders):
2734        """pcmreaders is a list of PCMReader objects
2735
2736        all must have the same stream attributes"""
2737
2738        self.index = 0
2739        self.pcmreaders = list(pcmreaders)
2740
2741        if len(self.pcmreaders) == 0:
2742            from audiotools.text import ERR_NO_PCMREADERS
2743            raise ValueError(ERR_NO_PCMREADERS)
2744
2745        if len({r.sample_rate for r in self.pcmreaders}) != 1:
2746            from audiotools.text import ERR_SAMPLE_RATE_MISMATCH
2747            raise ValueError(ERR_SAMPLE_RATE_MISMATCH)
2748
2749        if len({r.channels for r in self.pcmreaders}) != 1:
2750            from audiotools.text import ERR_CHANNEL_COUNT_MISMATCH
2751            raise ValueError(ERR_CHANNEL_COUNT_MISMATCH)
2752
2753        if len({r.bits_per_sample for r in self.pcmreaders}) != 1:
2754            from audiotools.text import ERR_BPS_MISMATCH
2755            raise ValueError(ERR_BPS_MISMATCH)
2756
2757        first_reader = self.pcmreaders[self.index]
2758        PCMReader.__init__(self,
2759                           sample_rate=first_reader.sample_rate,
2760                           channels=first_reader.channels,
2761                           channel_mask=first_reader.channel_mask,
2762                           bits_per_sample=first_reader.bits_per_sample)
2763
2764    def read(self, pcm_frames):
2765        """try to read a pcm.FrameList with the given number of frames
2766
2767        raises ValueError if any of the streams is mismatched"""
2768
2769        if self.index < len(self.pcmreaders):
2770            # read a FrameList from the current PCMReader
2771            framelist = self.pcmreaders[self.index].read(pcm_frames)
2772            if len(framelist) > 0:
2773                # if it has data, return it
2774                return framelist
2775            else:
2776                # otherwise, move on to next pcmreader
2777                self.index += 1
2778                return self.read(pcm_frames)
2779        else:
2780            # all PCMReaders exhausted, so return empty FrameList
2781            return pcm.empty_framelist(self.channels, self.bits_per_sample)
2782
2783    def read_closed(self, pcm_frames):
2784        raise ValueError()
2785
2786    def close(self):
2787        """closes the stream for reading"""
2788
2789        self.read = self.read_closed
2790        for reader in self.pcmreaders:
2791            reader.close()
2792
2793
2794class BufferedPCMReader(PCMReader):
2795    """a PCMReader which reads exact counts of PCM frames"""
2796
2797    def __init__(self, pcmreader):
2798        """pcmreader is a regular PCMReader object"""
2799
2800        PCMReader.__init__(self,
2801                           sample_rate=pcmreader.sample_rate,
2802                           channels=pcmreader.channels,
2803                           channel_mask=pcmreader.channel_mask,
2804                           bits_per_sample=pcmreader.bits_per_sample)
2805
2806        self.pcmreader = pcmreader
2807        self.buffer = pcm.empty_framelist(self.channels,
2808                                          self.bits_per_sample)
2809
2810    def read(self, pcm_frames):
2811        """reads the given number of PCM frames
2812
2813        this may return fewer than the given number
2814        at the end of a stream
2815        but will never return more than requested
2816        """
2817
2818        # fill our buffer to at least "pcm_frames", possibly more
2819        while self.buffer.frames < pcm_frames:
2820            frame = self.pcmreader.read(FRAMELIST_SIZE)
2821            if len(frame):
2822                self.buffer += frame
2823            else:
2824                break
2825
2826        # chop off the preceding number of PCM frames and return them
2827        (output, self.buffer) = self.buffer.split(pcm_frames)
2828
2829        return output
2830
2831    def read_closed(self, pcm_frames):
2832        raise ValueError()
2833
2834    def close(self):
2835        """closes the sub-pcmreader and frees our internal buffer"""
2836
2837        self.pcmreader.close()
2838        self.read = self.read_closed
2839
2840
2841class CounterPCMReader(PCMReader):
2842    """a PCMReader which counts bytes and frames written"""
2843
2844    def __init__(self, pcmreader):
2845        PCMReader.__init__(self,
2846                           sample_rate=pcmreader.sample_rate,
2847                           channels=pcmreader.channels,
2848                           channel_mask=pcmreader.channel_mask,
2849                           bits_per_sample=pcmreader.bits_per_sample)
2850        self.pcmreader = pcmreader
2851        self.frames_written = 0
2852
2853    def bytes_written(self):
2854        return (self.frames_written *
2855                self.channels *
2856                (self.bits_per_sample // 8))
2857
2858    def read(self, pcm_frames):
2859        frame = self.pcmreader.read(pcm_frames)
2860        self.frames_written += frame.frames
2861        return frame
2862
2863    def close(self):
2864        self.pcmreader.close()
2865
2866
2867class LimitedFileReader(object):
2868    def __init__(self, file, total_bytes):
2869        self.__file__ = file
2870        self.__total_bytes__ = total_bytes
2871
2872    def read(self, x):
2873        if self.__total_bytes__ > 0:
2874            s = self.__file__.read(x)
2875            if len(s) <= self.__total_bytes__:
2876                self.__total_bytes__ -= len(s)
2877                return s
2878            else:
2879                s = s[0:self.__total_bytes__]
2880                self.__total_bytes__ = 0
2881                return s
2882        else:
2883            return ""
2884
2885    def close(self):
2886        self.__file__.close()
2887
2888
2889class LimitedPCMReader(PCMReader):
2890    def __init__(self, buffered_pcmreader, total_pcm_frames):
2891        """buffered_pcmreader should be a BufferedPCMReader
2892
2893        which ensures we won't pull more frames off the reader
2894        than necessary upon calls to read()"""
2895
2896        PCMReader.__init__(
2897            self,
2898            sample_rate=buffered_pcmreader.sample_rate,
2899            channels=buffered_pcmreader.channels,
2900            channel_mask=buffered_pcmreader.channel_mask,
2901            bits_per_sample=buffered_pcmreader.bits_per_sample)
2902
2903        self.pcmreader = buffered_pcmreader
2904        self.total_pcm_frames = total_pcm_frames
2905
2906    def read(self, pcm_frames):
2907        if self.total_pcm_frames > 0:
2908            frame = self.pcmreader.read(min(pcm_frames, self.total_pcm_frames))
2909            self.total_pcm_frames -= frame.frames
2910            return frame
2911        else:
2912            return pcm.empty_framelist(self.channels, self.bits_per_sample)
2913
2914    def read_closed(self, pcm_frames):
2915        raise ValueError()
2916
2917    def close(self):
2918        self.read = self.read_closed
2919
2920
2921def PCMConverter(pcmreader,
2922                 sample_rate,
2923                 channels,
2924                 channel_mask,
2925                 bits_per_sample):
2926    """a PCMReader wrapper for converting attributes
2927
2928    for example, this can be used to alter sample_rate, bits_per_sample,
2929    channel_mask, channel count, or any combination of those
2930    attributes.  It resamples, downsamples, etc. to achieve the proper
2931    output
2932
2933    may raise ValueError if any of the attributes are unsupported
2934    or invalid
2935    """
2936
2937    if sample_rate <= 0:
2938        from audiotools.text import ERR_INVALID_SAMPLE_RATE
2939        raise ValueError(ERR_INVALID_SAMPLE_RATE)
2940    elif channels <= 0:
2941        from audiotools.text import ERR_INVALID_CHANNEL_COUNT
2942        raise ValueError(ERR_INVALID_CHANNEL_COUNT)
2943    elif bits_per_sample not in (8, 16, 24):
2944        from audiotools.text import ERR_INVALID_BITS_PER_SAMPLE
2945        raise ValueError(ERR_INVALID_BITS_PER_SAMPLE)
2946
2947    if (channel_mask != 0) and (len(ChannelMask(channel_mask)) != channels):
2948        # channel_mask is defined but has a different number of channels
2949        # than the channel count attribute
2950        from audiotools.text import ERR_CHANNEL_COUNT_MASK_MISMATCH
2951        raise ValueError(ERR_CHANNEL_COUNT_MASK_MISMATCH)
2952
2953    if pcmreader.channels > channels:
2954        if (channels == 1) and (channel_mask in (0, 0x4)):
2955            if pcmreader.channels > 2:
2956                # reduce channel count through downmixing
2957                # followed by averaging
2958                from .pcmconverter import (Averager, Downmixer)
2959                pcmreader = Averager(Downmixer(pcmreader))
2960            else:
2961                # pcmreader.channels == 2
2962                # so reduce channel count through averaging
2963                from .pcmconverter import Averager
2964                pcmreader = Averager(pcmreader)
2965        elif (channels == 2) and (channel_mask in (0, 0x3)):
2966            # reduce channel count through downmixing
2967            from .pcmconverter import Downmixer
2968            pcmreader = Downmixer(pcmreader)
2969        else:
2970            # unusual channel count/mask combination
2971            pcmreader = RemaskedPCMReader(pcmreader,
2972                                          channels,
2973                                          channel_mask)
2974    elif pcmreader.channels < channels:
2975        # increase channel count by duplicating first channel
2976        # (this is usually just going from mono to stereo
2977        #  since there's no way to summon surround channels
2978        #  out of thin air)
2979        pcmreader = ReorderedPCMReader(pcmreader,
2980                                       list(range(pcmreader.channels)) +
2981                                       [0] * (channels - pcmreader.channels),
2982                                       channel_mask)
2983
2984    if pcmreader.sample_rate != sample_rate:
2985        # convert sample rate through resampling
2986        from .pcmconverter import Resampler
2987        pcmreader = Resampler(pcmreader, sample_rate)
2988
2989    if pcmreader.bits_per_sample != bits_per_sample:
2990        # use bitshifts/dithering to adjust bits-per-sample
2991        from .pcmconverter import BPSConverter
2992        pcmreader = BPSConverter(pcmreader, bits_per_sample)
2993
2994    return pcmreader
2995
2996
2997class ReplayGainCalculator:
2998    def __init__(self, sample_rate):
2999        from audiotools.replaygain import ReplayGain
3000
3001        self.__replaygain__ = ReplayGain(sample_rate)
3002        self.__tracks__ = []
3003
3004    def sample_rate(self):
3005        return self.__replaygain__.sample_rate
3006
3007    def __iter__(self):
3008        try:
3009            album_gain = self.__replaygain__.album_gain()
3010        except ValueError:
3011            album_gain = 0.0
3012        album_peak = self.__replaygain__.album_peak()
3013
3014        for t in self.__tracks__:
3015            title_gain = t.title_gain()
3016            title_peak = t.title_peak()
3017            yield (title_gain, title_peak, album_gain, album_peak)
3018
3019    def to_pcm(self, pcmreader):
3020        """given a PCMReader, returns a ReplayGainCalculatorReader
3021        which can be used to calculate the ReplayGain
3022        for the contents of that reader"""
3023
3024        if pcmreader.sample_rate != self.sample_rate():
3025            raise ValueError(
3026                "sample rate mismatch, %d != %d" %
3027                (pcmreader.sample_rate, self.sample_rate()))
3028        reader = ReplayGainCalculatorReader(self.__replaygain__, pcmreader)
3029        self.__tracks__.append(reader)
3030        return reader
3031
3032
3033class ReplayGainCalculatorReader(PCMReader):
3034    def __init__(self, replaygain, pcmreader):
3035        PCMReader.__init__(self,
3036                           sample_rate=pcmreader.sample_rate,
3037                           channels=pcmreader.channels,
3038                           channel_mask=pcmreader.channel_mask,
3039                           bits_per_sample=pcmreader.bits_per_sample)
3040        self.__replaygain__ = replaygain
3041        self.__pcmreader__ = pcmreader
3042        self.__title_gain__ = None
3043        self.__title_peak__ = None
3044
3045    def read(self, pcm_frames):
3046        framelist = self.__pcmreader__.read(pcm_frames)
3047        self.__replaygain__.update(framelist)
3048        return framelist
3049
3050    def close(self):
3051        self.__pcmreader__.close()
3052        try:
3053            self.__title_gain__ = self.__replaygain__.title_gain()
3054        except ValueError:
3055            self.__title_gain__ = 0.0
3056
3057        self.__title_peak__ = self.__replaygain__.title_peak()
3058        self.__replaygain__.next_title()
3059
3060    def title_gain(self):
3061        if self.__title_gain__ is not None:
3062            return self.__title_gain__
3063        else:
3064            raise ValueError("cannot get title_gain before closing pcmreader")
3065
3066    def title_peak(self):
3067        if self.__title_peak__ is not None:
3068            return self.__title_peak__
3069        else:
3070            raise ValueError("cannot get title_peak before closing pcmreader")
3071
3072
3073def resampled_frame_count(initial_frame_count,
3074                          initial_sample_rate,
3075                          new_sample_rate):
3076    """given an initial PCM frame count, initial sample rate
3077    and new sample rate, returns the new PCM frame count
3078    once the stream has been resampled"""
3079
3080    if initial_sample_rate == new_sample_rate:
3081        return initial_frame_count
3082    else:
3083        from decimal import (Decimal, ROUND_DOWN)
3084        new_frame_count = ((Decimal(initial_frame_count) *
3085                            Decimal(new_sample_rate)) /
3086                           Decimal(initial_sample_rate))
3087        return int(new_frame_count.quantize(Decimal("1."),
3088                                            rounding=ROUND_DOWN))
3089
3090
3091def calculate_replay_gain(tracks, progress=None):
3092    """yields (track, track_gain, track_peak, album_gain, album_peak)
3093    for each AudioFile in the list of tracks
3094
3095    raises ValueError if a problem occurs during calculation"""
3096
3097    if len(tracks) == 0:
3098        return
3099
3100    from bisect import bisect
3101
3102    SUPPORTED_RATES = [8000,  11025,  12000,  16000,  18900,  22050, 24000,
3103                       32000, 37800,  44100,  48000,  56000,  64000, 88200,
3104                       96000, 112000, 128000, 144000, 176400, 192000]
3105
3106    target_rate = ([SUPPORTED_RATES[0]] + SUPPORTED_RATES)[
3107        bisect(SUPPORTED_RATES, most_numerous([track.sample_rate()
3108                                               for track in tracks]))]
3109
3110    track_frames = [resampled_frame_count(track.total_frames(),
3111                                          track.sample_rate(),
3112                                          target_rate)
3113                    for track in tracks]
3114    current_frames = 0
3115    total_frames = sum(track_frames)
3116
3117    rg = ReplayGainCalculator(target_rate)
3118
3119    for (track, track_frames) in zip(tracks, track_frames):
3120        pcm = track.to_pcm()
3121
3122        # perform calculation by decoding through ReplayGain
3123        with rg.to_pcm(
3124            PCMReaderProgress(
3125                PCMConverter(pcm,
3126                             target_rate,
3127                             pcm.channels,
3128                             pcm.channel_mask,
3129                             pcm.bits_per_sample),
3130                total_frames,
3131                progress,
3132                current_frames)) as pcmreader:
3133            transfer_data(pcmreader.read, lambda f: None)
3134            current_frames += track_frames
3135
3136    # yield a set of accumulated track and album gains
3137    for (track, (track_gain, track_peak,
3138                 album_gain, album_peak)) in zip(tracks, rg):
3139        yield (track, track_gain, track_peak, album_gain, album_peak)
3140
3141
3142def add_replay_gain(tracks, progress=None):
3143    """given an iterable set of AudioFile objects
3144    and optional progress function
3145    calculates the ReplayGain for them and adds it
3146    via their set_replay_gain method"""
3147
3148    for (track,
3149         track_gain,
3150         track_peak,
3151         album_gain,
3152         album_peak) in calculate_replay_gain(tracks, progress):
3153        track.set_replay_gain(ReplayGain(track_gain=track_gain,
3154                                         track_peak=track_peak,
3155                                         album_gain=album_gain,
3156                                         album_peak=album_peak))
3157
3158
3159def ignore_sigint():
3160    """sets the SIGINT signal to SIG_IGN
3161
3162    some encoder executables require this in order for
3163    interruptableReader to work correctly since we
3164    want to catch SIGINT ourselves in that case and perform
3165    a proper shutdown"""
3166
3167    import signal
3168
3169    signal.signal(signal.SIGINT, signal.SIG_IGN)
3170
3171
3172def make_dirs(destination_path):
3173    """ensures all directories leading to destination_path are created
3174
3175    raises OSError if a problem occurs during directory creation
3176    """
3177
3178    dirname = os.path.dirname(destination_path)
3179    if (dirname != '') and (not os.path.isdir(dirname)):
3180        os.makedirs(dirname)
3181
3182
3183class MetaData(object):
3184    """the base class for storing textual AudioFile metadata
3185
3186    Fields may be None, indicating they're not present
3187    in the underlying metadata implementation.
3188
3189    Changing a field to a new value will update the underlying metadata
3190    (e.g. vorbiscomment.track_name = u"Foo"
3191    will set a Vorbis comment's "TITLE" field to "Foo")
3192
3193    Updating the underlying metadata will change the metadata's fields
3194    (e.g. setting a Vorbis comment's "TITLE" field to "bar"
3195    will update vorbiscomment.title_name to u"bar")
3196
3197    Deleting a field or setting it to None
3198    will remove it from the underlying metadata
3199    (e.g. del(vorbiscomment.track_name) will delete the "TITLE" field)
3200    """
3201
3202    FIELDS = ("track_name",
3203              "track_number",
3204              "track_total",
3205              "album_name",
3206              "artist_name",
3207              "performer_name",
3208              "composer_name",
3209              "conductor_name",
3210              "media",
3211              "ISRC",
3212              "catalog",
3213              "copyright",
3214              "publisher",
3215              "year",
3216              "date",
3217              "album_number",
3218              "album_total",
3219              "comment")
3220
3221    INTEGER_FIELDS = ("track_number",
3222                      "track_total",
3223                      "album_number",
3224                      "album_total")
3225
3226    # this is the order fields should be presented to the user
3227    # to ensure consistency across utilities
3228    FIELD_ORDER = ("track_name",
3229                   "artist_name",
3230                   "album_name",
3231                   "track_number",
3232                   "track_total",
3233                   "album_number",
3234                   "album_total",
3235                   "performer_name",
3236                   "composer_name",
3237                   "conductor_name",
3238                   "catalog",
3239                   "ISRC",
3240                   "publisher",
3241                   "media",
3242                   "year",
3243                   "date",
3244                   "copyright",
3245                   "comment")
3246
3247    # this is the name fields should use when presented to the user
3248    # also to ensure constency across utilities
3249    from audiotools.text import (METADATA_TRACK_NAME,
3250                                 METADATA_TRACK_NUMBER,
3251                                 METADATA_TRACK_TOTAL,
3252                                 METADATA_ALBUM_NAME,
3253                                 METADATA_ARTIST_NAME,
3254                                 METADATA_PERFORMER_NAME,
3255                                 METADATA_COMPOSER_NAME,
3256                                 METADATA_CONDUCTOR_NAME,
3257                                 METADATA_MEDIA,
3258                                 METADATA_ISRC,
3259                                 METADATA_CATALOG,
3260                                 METADATA_COPYRIGHT,
3261                                 METADATA_PUBLISHER,
3262                                 METADATA_YEAR,
3263                                 METADATA_DATE,
3264                                 METADATA_ALBUM_NUMBER,
3265                                 METADATA_ALBUM_TOTAL,
3266                                 METADATA_COMMENT)
3267
3268    FIELD_NAMES = {"track_name": METADATA_TRACK_NAME,
3269                   "track_number": METADATA_TRACK_NUMBER,
3270                   "track_total": METADATA_TRACK_TOTAL,
3271                   "album_name": METADATA_ALBUM_NAME,
3272                   "artist_name": METADATA_ARTIST_NAME,
3273                   "performer_name": METADATA_PERFORMER_NAME,
3274                   "composer_name": METADATA_COMPOSER_NAME,
3275                   "conductor_name": METADATA_CONDUCTOR_NAME,
3276                   "media": METADATA_MEDIA,
3277                   "ISRC": METADATA_ISRC,
3278                   "catalog": METADATA_CATALOG,
3279                   "copyright": METADATA_COPYRIGHT,
3280                   "publisher": METADATA_PUBLISHER,
3281                   "year": METADATA_YEAR,
3282                   "date": METADATA_DATE,
3283                   "album_number": METADATA_ALBUM_NUMBER,
3284                   "album_total": METADATA_ALBUM_TOTAL,
3285                   "comment": METADATA_COMMENT}
3286
3287    def __init__(self,
3288                 track_name=None,
3289                 track_number=None,
3290                 track_total=None,
3291                 album_name=None,
3292                 artist_name=None,
3293                 performer_name=None,
3294                 composer_name=None,
3295                 conductor_name=None,
3296                 media=None,
3297                 ISRC=None,
3298                 catalog=None,
3299                 copyright=None,
3300                 publisher=None,
3301                 year=None,
3302                 date=None,
3303                 album_number=None,
3304                 album_total=None,
3305                 comment=None,
3306                 images=None):
3307        """
3308| field          | type    | meaning                              |
3309|----------------+---------+--------------------------------------|
3310| track_name     | unicode | the name of this individual track    |
3311| track_number   | integer | the number of this track             |
3312| track_total    | integer | the total number of tracks           |
3313| album_name     | unicode | the name of this track's album       |
3314| artist_name    | unicode | the song's original creator/composer |
3315| performer_name | unicode | the song's performing artist         |
3316| composer_name  | unicode | the song's composer name             |
3317| conductor_name | unicode | the song's conductor's name          |
3318| media          | unicode | the album's media type               |
3319| ISRC           | unicode | the song's ISRC                      |
3320| catalog        | unicode | the album's catalog number           |
3321| copyright      | unicode | the song's copyright information     |
3322| publisher      | unicode | the album's publisher                |
3323| year           | unicode | the album's release year             |
3324| date           | unicode | the original recording date          |
3325| album_number   | integer | the disc's volume number             |
3326| album_total    | integer | the total number of discs            |
3327| comment        | unicode | the track's comment string           |
3328| images         | list    | list of Image objects                |
3329|----------------+---------+--------------------------------------|
3330"""
3331
3332        # we're avoiding self.foo = foo because
3333        # __setattr__ might need to be redefined
3334        # which could lead to unwelcome side-effects
3335        MetaData.__setattr__(self, "track_name", track_name)
3336        MetaData.__setattr__(self, "track_number", track_number)
3337        MetaData.__setattr__(self, "track_total", track_total)
3338        MetaData.__setattr__(self, "album_name", album_name)
3339        MetaData.__setattr__(self, "artist_name", artist_name)
3340        MetaData.__setattr__(self, "performer_name", performer_name)
3341        MetaData.__setattr__(self, "composer_name", composer_name)
3342        MetaData.__setattr__(self, "conductor_name", conductor_name)
3343        MetaData.__setattr__(self, "media", media)
3344        MetaData.__setattr__(self, "ISRC", ISRC)
3345        MetaData.__setattr__(self, "catalog", catalog)
3346        MetaData.__setattr__(self, "copyright", copyright)
3347        MetaData.__setattr__(self, "publisher", publisher)
3348        MetaData.__setattr__(self, "year", year)
3349        MetaData.__setattr__(self, "date", date)
3350        MetaData.__setattr__(self, "album_number", album_number)
3351        MetaData.__setattr__(self, "album_total", album_total)
3352        MetaData.__setattr__(self, "comment", comment)
3353
3354        if images is not None:
3355            MetaData.__setattr__(self, "__images__", list(images))
3356        else:
3357            MetaData.__setattr__(self, "__images__", list())
3358
3359    def __repr__(self):
3360        fields = ["%s=%s" % (field, repr(getattr(self, field)))
3361                  for field in MetaData.FIELDS]
3362        return ("MetaData(%s)" % (
3363                ",".join(["%s"] * (len(MetaData.FIELDS))))) % tuple(fields)
3364
3365    def __delattr__(self, field):
3366        if field in self.FIELDS:
3367            MetaData.__setattr__(self, field, None)
3368        else:
3369            try:
3370                object.__delattr__(self, field)
3371            except KeyError:
3372                raise AttributeError(field)
3373
3374    def fields(self):
3375        """yields an (attr, value) tuple per MetaData field"""
3376
3377        for attr in self.FIELDS:
3378            yield (attr, getattr(self, attr))
3379
3380    def filled_fields(self):
3381        """yields an (attr, value) tuple per MetaData field
3382        which is not blank"""
3383
3384        for (attr, field) in self.fields():
3385            if field is not None:
3386                yield (attr, field)
3387
3388    def empty_fields(self):
3389        """yields an (attr, value) tuple per MetaData field
3390        which is blank"""
3391
3392        for (attr, field) in self.fields():
3393            if field is None:
3394                yield (attr, field)
3395
3396    if PY3:
3397        def __str__(self):
3398            return self.__unicode__()
3399    else:
3400        def __str__(self):
3401            return self.__unicode__().encode('utf-8')
3402
3403    def __unicode__(self):
3404        table = output_table()
3405
3406        SEPARATOR = u" : "
3407
3408        for attr in self.FIELD_ORDER:
3409            if attr == "track_number":
3410                # combine track number/track total into single field
3411                track_number = self.track_number
3412                track_total = self.track_total
3413                if (track_number is None) and (track_total is None):
3414                    # nothing to display
3415                    pass
3416                elif (track_number is not None) and (track_total is None):
3417                    row = table.row()
3418                    row.add_column(self.FIELD_NAMES[attr], "right")
3419                    row.add_column(SEPARATOR)
3420                    row.add_column(u"%d" % (track_number))
3421                elif (track_number is None) and (track_total is not None):
3422                    row = table.row()
3423                    row.add_column(self.FIELD_NAMES[attr], "right")
3424                    row.add_column(SEPARATOR)
3425                    row.add_column(u"?/%d" % (track_total,))
3426                else:
3427                    # neither track_number or track_total is None
3428                    row = table.row()
3429                    row.add_column(self.FIELD_NAMES[attr], "right")
3430                    row.add_column(SEPARATOR)
3431                    row.add_column(u"%d/%d" % (track_number, track_total))
3432            elif attr == "track_total":
3433                pass
3434            elif attr == "album_number":
3435                # combine album number/album total into single field
3436                album_number = self.album_number
3437                album_total = self.album_total
3438                if (album_number is None) and (album_total is None):
3439                    # nothing to display
3440                    pass
3441                elif (album_number is not None) and (album_total is None):
3442                    row = table.row()
3443                    row.add_column(self.FIELD_NAMES[attr], "right")
3444                    row.add_column(SEPARATOR)
3445                    row.add_column(u"%d" % (track_number))
3446                elif (album_number is None) and (album_total is not None):
3447                    row = table.row()
3448                    row.add_column(self.FIELD_NAMES[attr], "right")
3449                    row.add_column(SEPARATOR)
3450                    row.add_column(u"?/%d" % (album_total,))
3451                else:
3452                    # neither album_number or album_total is None
3453                    row = table.row()
3454                    row.add_column(self.FIELD_NAMES[attr], "right")
3455                    row.add_column(SEPARATOR)
3456                    row.add_column(u"%d/%d" % (album_number, album_total))
3457            elif attr == "album_total":
3458                pass
3459            elif getattr(self, attr) is not None:
3460                row = table.row()
3461                row.add_column(self.FIELD_NAMES[attr], "right")
3462                row.add_column(SEPARATOR)
3463                row.add_column(getattr(self, attr))
3464
3465        # append image data, if necessary
3466        from audiotools.text import LAB_PICTURE
3467
3468        for image in self.images():
3469            row = table.row()
3470            row.add_column(LAB_PICTURE, "right")
3471            row.add_column(SEPARATOR)
3472            row.add_column(image.__unicode__())
3473
3474        return os.linesep.join(table.format())
3475
3476    def raw_info(self):
3477        """returns a Unicode string of low-level MetaData information
3478
3479        whereas __unicode__ is meant to contain complete information
3480        at a very high level
3481        raw_info() should be more developer-specific and with
3482        very little adjustment or reordering to the data itself
3483        """
3484
3485        raise NotImplementedError()
3486
3487    def __eq__(self, metadata):
3488        for attr in MetaData.FIELDS:
3489            if ((not hasattr(metadata, attr)) or (getattr(self, attr) !=
3490                                                  getattr(metadata, attr))):
3491                return False
3492        else:
3493            return True
3494
3495    def __ne__(self, metadata):
3496        return not self.__eq__(metadata)
3497
3498    @classmethod
3499    def converted(cls, metadata):
3500        """converts metadata from another class to this one, if necessary
3501
3502        takes a MetaData-compatible object (or None)
3503        and returns a new MetaData subclass with the data fields converted
3504        or None if metadata is None or conversion isn't possible
3505        for instance, VorbisComment.converted() returns a VorbisComment
3506        class.  This way, AudioFiles can offload metadata conversions
3507        """
3508
3509        if metadata is not None:
3510            fields = {field: getattr(metadata, field)
3511                      for field in cls.FIELDS}
3512            fields["images"] = metadata.images()
3513            return MetaData(**fields)
3514        else:
3515            return None
3516
3517    @classmethod
3518    def supports_images(cls):
3519        """returns True if this MetaData class supports embedded images"""
3520
3521        return True
3522
3523    def images(self):
3524        """returns a list of embedded Image objects"""
3525
3526        # must return a copy of our internal array
3527        # otherwise this will likely not act as expected when deleting
3528        return self.__images__[:]
3529
3530    def front_covers(self):
3531        """returns a subset of images() which are front covers"""
3532
3533        return [i for i in self.images() if i.type == FRONT_COVER]
3534
3535    def back_covers(self):
3536        """returns a subset of images() which are back covers"""
3537
3538        return [i for i in self.images() if i.type == BACK_COVER]
3539
3540    def leaflet_pages(self):
3541        """returns a subset of images() which are leaflet pages"""
3542
3543        return [i for i in self.images() if i.type == LEAFLET_PAGE]
3544
3545    def media_images(self):
3546        """returns a subset of images() which are media images"""
3547
3548        return [i for i in self.images() if i.type == MEDIA]
3549
3550    def other_images(self):
3551        """returns a subset of images() which are other images"""
3552
3553        return [i for i in self.images() if i.type == OTHER]
3554
3555    def add_image(self, image):
3556        """embeds an Image object in this metadata
3557
3558        implementations of this method should also affect
3559        the underlying metadata value
3560        (e.g. adding a new Image to FlacMetaData should add another
3561        METADATA_BLOCK_PICTURE block to the metadata)
3562        """
3563
3564        if self.supports_images():
3565            self.__images__.append(image)
3566        else:
3567            from audiotools.text import ERR_PICTURES_UNSUPPORTED
3568            raise ValueError(ERR_PICTURES_UNSUPPORTED)
3569
3570    def delete_image(self, image):
3571        """deletes an Image object from this metadata
3572
3573        implementations of this method should also affect
3574        the underlying metadata value
3575        (e.g. removing an existing Image from FlacMetaData should
3576        remove that same METADATA_BLOCK_PICTURE block from the metadata)
3577        """
3578
3579        if self.supports_images():
3580            self.__images__.pop(self.__images__.index(image))
3581        else:
3582            from audiotools.text import ERR_PICTURES_UNSUPPORTED
3583            raise ValueError(ERR_PICTURES_UNSUPPORTED)
3584
3585    def clean(self):
3586        """returns a (MetaData, fixes_performed) tuple
3587        where MetaData is an object that's been cleaned of problems
3588        an fixes_performed is a list of Unicode strings.
3589        Problems include:
3590
3591        * Remove leading or trailing whitespace from text fields
3592        * Remove empty fields
3593        * Remove leading zeroes from numerical fields
3594          (except when requested, in the case of ID3v2)
3595        * Fix incorrectly labeled image metadata fields
3596        """
3597
3598        return (MetaData(**{field: getattr(self, field)
3599                            for field in MetaData.FIELDS}), [])
3600
3601
3602(FRONT_COVER, BACK_COVER, LEAFLET_PAGE, MEDIA, OTHER) = range(5)
3603
3604
3605class Image(object):
3606    """an image data container"""
3607
3608    def __init__(self, data, mime_type, width, height,
3609                 color_depth, color_count, description, type):
3610        """fields are as follows:
3611
3612        data        - plain string of the actual binary image data
3613        mime_type   - unicode string of the image's MIME type
3614        width       - width of image, as integer number of pixels
3615        height      - height of image, as integer number of pixels
3616        color_depth - color depth of image (24 for JPEG, 8 for GIF, etc.)
3617        color_count - number of palette colors, or 0
3618        description - a unicode string
3619        type - an integer type whose values are one of:
3620               FRONT_COVER
3621               BACK_COVER
3622               LEAFLET_PAGE
3623               MEDIA
3624               OTHER
3625        """
3626
3627        assert(isinstance(data, bytes))
3628        assert(isinstance(mime_type, str if PY3 else unicode))
3629        assert(isinstance(width, int))
3630        assert(isinstance(height, int))
3631        assert(isinstance(color_depth, int))
3632        assert(isinstance(color_count, int))
3633        assert(isinstance(description, str if PY3 else unicode))
3634        assert(isinstance(type, int))
3635
3636        self.data = data
3637        self.mime_type = mime_type
3638        self.width = width
3639        self.height = height
3640        self.color_depth = color_depth
3641        self.color_count = color_count
3642        self.description = description
3643        self.type = type
3644
3645    def suffix(self):
3646        """returns the image's recommended suffix as a plain string
3647
3648        for example, an image with mime_type "image/jpeg" return "jpg"
3649        """
3650
3651        return {"image/jpeg": "jpg",
3652                "image/jpg": "jpg",
3653                "image/gif": "gif",
3654                "image/png": "png",
3655                "image/x-ms-bmp": "bmp",
3656                "image/tiff": "tiff"}.get(self.mime_type, "bin")
3657
3658    def type_string(self):
3659        """returns the image's type as a human readable plain string
3660
3661        for example, an image of type 0 returns "Front Cover"
3662        """
3663
3664        return {FRONT_COVER: "Front Cover",
3665                BACK_COVER: "Back Cover",
3666                LEAFLET_PAGE: "Leaflet Page",
3667                MEDIA: "Media",
3668                OTHER: "Other"}.get(self.type, "Other")
3669
3670    def __repr__(self):
3671        fields = ["%s=%s" % (attr, getattr(self, attr))
3672                  for attr in ["mime_type",
3673                               "width",
3674                               "height",
3675                               "color_depth",
3676                               "color_count",
3677                               "description",
3678                               "type"]]
3679        return "Image(%s)" % (",".join(fields))
3680
3681    if PY3:
3682        def __str__(self):
3683            return self.__unicode__()
3684    else:
3685        def __str__(self):
3686            return self.__unicode__().encode('utf-8')
3687
3688    def __unicode__(self):
3689        return u"%s (%d\u00D7%d,'%s')" % \
3690               (self.type_string(),
3691                self.width, self.height, self.mime_type)
3692
3693    @classmethod
3694    def new(cls, image_data, description, type):
3695        """builds a new Image object from raw data
3696
3697        image_data is a plain string of binary image data
3698        description is a unicode string
3699        type as an image type integer
3700
3701        the width, height, color_depth and color_count fields
3702        are determined by parsing the binary image data
3703        raises InvalidImage if some error occurs during parsing
3704        """
3705
3706        from audiotools.image import image_metrics
3707
3708        img = image_metrics(image_data)
3709
3710        return Image(data=image_data,
3711                     mime_type=img.mime_type,
3712                     width=img.width,
3713                     height=img.height,
3714                     color_depth=img.bits_per_pixel,
3715                     color_count=img.color_count,
3716                     description=description,
3717                     type=type)
3718
3719    def __eq__(self, image):
3720        if image is not None:
3721            if hasattr(image, "data"):
3722                return self.data == image.data
3723            else:
3724                return False
3725        else:
3726            return False
3727
3728    def __ne__(self, image):
3729        return not self.__eq__(image)
3730
3731
3732class InvalidImage(Exception):
3733    """raised if an image cannot be parsed correctly"""
3734
3735
3736class ReplayGain(object):
3737    """a container for ReplayGain data"""
3738
3739    def __init__(self, track_gain, track_peak, album_gain, album_peak):
3740        """values are:
3741
3742        track_gain - a dB float value
3743        track_peak - the highest absolute value PCM sample, as a float
3744        album_gain - a dB float value
3745        album_peak - the highest absolute value PCM sample, as a float
3746
3747        they are also attributes
3748        """
3749
3750        self.track_gain = float(track_gain)
3751        self.track_peak = float(track_peak)
3752        self.album_gain = float(album_gain)
3753        self.album_peak = float(album_peak)
3754
3755    def __repr__(self):
3756        return "ReplayGain(%s,%s,%s,%s)" % \
3757            (self.track_gain, self.track_peak,
3758             self.album_gain, self.album_peak)
3759
3760    def __eq__(self, rg):
3761        if isinstance(rg, ReplayGain):
3762            return ((self.track_gain == rg.track_gain) and
3763                    (self.track_peak == rg.track_peak) and
3764                    (self.album_gain == rg.album_gain) and
3765                    (self.album_peak == rg.album_peak))
3766        else:
3767            return False
3768
3769    def __ne__(self, rg):
3770        return not self.__eq__(rg)
3771
3772
3773class UnsupportedTracknameField(Exception):
3774    """raised by AudioFile.track_name()
3775    if its format string contains unknown fields"""
3776
3777    def __init__(self, field):
3778        self.field = field
3779
3780    def error_msg(self, messenger):
3781        from audiotools.text import (ERR_UNKNOWN_FIELD,
3782                                     LAB_SUPPORTED_FIELDS)
3783
3784        messenger.error(ERR_UNKNOWN_FIELD % (self.field,))
3785        messenger.info(LAB_SUPPORTED_FIELDS)
3786        for field in sorted(MetaData.FIELDS +
3787                            ("album_track_number", "suffix")):
3788            if field == 'track_number':
3789                messenger.info(u"%(track_number)2.2d")
3790            else:
3791                messenger.info(u"%%(%s)s" % (field))
3792
3793        messenger.info(u"%(basename)s")
3794
3795
3796class InvalidFilenameFormat(Exception):
3797    """raised by AudioFile.track_name()
3798    if its format string contains broken fields"""
3799
3800    def __init__(self, *args):
3801        from audiotools.text import ERR_INVALID_FILENAME_FORMAT
3802        Exception.__init__(self, ERR_INVALID_FILENAME_FORMAT)
3803
3804
3805class AudioFile(object):
3806    """an abstract class representing audio files on disk
3807
3808    this class should be extended to handle different audio
3809    file formats"""
3810
3811    SUFFIX = ""
3812    NAME = ""
3813    DESCRIPTION = u""
3814    DEFAULT_COMPRESSION = ""
3815    COMPRESSION_MODES = ("",)
3816    COMPRESSION_DESCRIPTIONS = {}
3817    BINARIES = tuple()
3818    BINARY_URLS = {}
3819    REPLAYGAIN_BINARIES = tuple()
3820
3821    def __init__(self, filename):
3822        """filename is a plain string
3823
3824        raises InvalidFile or subclass if the file is invalid in some way"""
3825
3826        self.filename = filename
3827
3828    # AudioFiles support a sorting rich compare
3829    # which prioritizes album_number, track_number and then filename
3830    # missing fields sort before non-missing fields
3831    # use pcm_frame_cmp to compare the contents of two files
3832
3833    def __sort_key__(self):
3834        metadata = self.get_metadata()
3835        return ((metadata.album_number if
3836                 ((metadata is not None) and
3837                  (metadata.album_number is not None)) else -(2 ** 31)),
3838                (metadata.track_number if
3839                 ((metadata is not None) and
3840                  (metadata.track_number is not None)) else -(2 ** 31)),
3841                self.filename)
3842
3843    def __eq__(self, audiofile):
3844        if isinstance(audiofile, AudioFile):
3845            return (self.__sort_key__() == audiofile.__sort_key__())
3846        else:
3847            raise TypeError("cannot compare %s and %s" %
3848                            (repr(self), repr(audiofile)))
3849
3850    def __ne__(self, audiofile):
3851        return not self.__eq__(audiofile)
3852
3853    def __lt__(self, audiofile):
3854        if isinstance(audiofile, AudioFile):
3855            return (self.__sort_key__() < audiofile.__sort_key__())
3856        else:
3857            raise TypeError("cannot compare %s and %s" %
3858                            (repr(self), repr(audiofile)))
3859
3860    def __le__(self, audiofile):
3861        if isinstance(audiofile, AudioFile):
3862            return (self.__sort_key__() <= audiofile.__sort_key__())
3863        else:
3864            raise TypeError("cannot compare %s and %s" %
3865                            (repr(self), repr(audiofile)))
3866
3867    def __gt__(self, audiofile):
3868        if isinstance(audiofile, AudioFile):
3869            return (self.__sort_key__() > audiofile.__sort_key__())
3870        else:
3871            raise TypeError("cannot compare %s and %s" %
3872                            (repr(self), repr(audiofile)))
3873
3874    def __ge__(self, audiofile):
3875        if isinstance(audiofile, AudioFile):
3876            return (self.__sort_key__() >= audiofile.__sort_key__())
3877        else:
3878            raise TypeError("cannot compare %s and %s" %
3879                            (repr(self), repr(audiofile)))
3880
3881    def __gt__(self, audiofile):
3882        if isinstance(audiofile, AudioFile):
3883            return (self.__sort_key__() > audiofile.__sort_key__())
3884        else:
3885            raise TypeError("cannot compare %s and %s" %
3886                            (repr(self), repr(audiofile)))
3887
3888    def bits_per_sample(self):
3889        """returns an integer number of bits-per-sample this track contains"""
3890
3891        raise NotImplementedError()
3892
3893    def channels(self):
3894        """returns an integer number of channels this track contains"""
3895
3896        raise NotImplementedError()
3897
3898    def channel_mask(self):
3899        """returns a ChannelMask object of this track's channel layout"""
3900
3901        # WARNING - This only returns valid masks for 1 and 2 channel audio
3902        # anything over 2 channels raises a ValueError
3903        # since there isn't any standard on what those channels should be.
3904        # AudioFiles that support more than 2 channels should override
3905        # this method with one that returns the proper mask.
3906        return ChannelMask.from_channels(self.channels())
3907
3908    def lossless(self):
3909        """returns True if this track's data is stored losslessly"""
3910
3911        raise NotImplementedError()
3912
3913    @classmethod
3914    def supports_metadata(cls):
3915        """returns True if this audio type supports MetaData"""
3916
3917        return False
3918
3919    def update_metadata(self, metadata):
3920        """takes this track's current MetaData object
3921        as returned by get_metadata() and sets this track's metadata
3922        with any fields updated in that object
3923
3924        raises IOError if unable to write the file
3925        """
3926
3927        # this is a sort of low-level implementation
3928        # which assumes higher-level routines have
3929        # modified metadata properly
3930
3931        if metadata is not None:
3932            raise NotImplementedError()
3933        else:
3934            raise ValueError(ERR_FOREIGN_METADATA)
3935
3936    def set_metadata(self, metadata):
3937        """takes a MetaData object and sets this track's metadata
3938
3939        this metadata includes track name, album name, and so on
3940        raises IOError if unable to write the file"""
3941
3942        # this is a higher-level implementation
3943        # which assumes metadata is from a different audio file
3944        # or constructed from scratch and converts it accordingly
3945        # before passing it on to update_metadata()
3946
3947        pass
3948
3949    def get_metadata(self):
3950        """returns a MetaData object, or None
3951
3952        raises IOError if unable to read the file"""
3953
3954        return None
3955
3956    def delete_metadata(self):
3957        """deletes the track's MetaData
3958
3959        this removes or unsets tags as necessary in order to remove all data
3960        raises IOError if unable to write the file"""
3961
3962        pass
3963
3964    def total_frames(self):
3965        """returns the total PCM frames of the track as an integer"""
3966
3967        raise NotImplementedError()
3968
3969    def cd_frames(self):
3970        """returns the total length of the track in CD frames
3971
3972        each CD frame is 1/75th of a second"""
3973
3974        try:
3975            return (self.total_frames() * 75) // self.sample_rate()
3976        except ZeroDivisionError:
3977            return 0
3978
3979    def seconds_length(self):
3980        """returns the length of the track as a Fraction number of seconds"""
3981
3982        from fractions import Fraction
3983
3984        if self.sample_rate() > 0:
3985            return Fraction(self.total_frames(), self.sample_rate())
3986        else:
3987            # this shouldn't happen, but just in case
3988            return Fraction(0, 1)
3989
3990    def sample_rate(self):
3991        """returns the rate of the track's audio as an integer number of Hz"""
3992
3993        raise NotImplementedError()
3994
3995    def to_pcm(self):
3996        """returns a PCMReader object containing the track's PCM data
3997
3998        if an error occurs initializing a decoder, this should
3999        return a PCMReaderError with an appropriate error message"""
4000
4001        raise NotImplementedError()
4002
4003    @classmethod
4004    def from_pcm(cls, filename, pcmreader,
4005                 compression=None,
4006                 total_pcm_frames=None):
4007        """encodes a new file from PCM data
4008
4009        takes a filename string, PCMReader object
4010        optional compression level string,
4011        and optional total_pcm_frames integer
4012        encodes a new audio file from pcmreader's data
4013        at the given filename with the specified compression level
4014        and returns a new AudioFile-compatible object
4015
4016        specifying total_pcm_frames, when the number is known in advance,
4017        may allow the encoder to work more efficiently but is never required
4018
4019        for example, to encode the FlacAudio file "file.flac" from "file.wav"
4020        at compression level "5":
4021
4022        >>> flac = FlacAudio.from_pcm("file.flac",
4023        ...                           WaveAudio("file.wav").to_pcm(),
4024        ...                           "5")
4025
4026        may raise EncodingError if some problem occurs when
4027        encoding the input file.  This includes an error
4028        in the input stream, a problem writing the output file,
4029        or even an EncodingError subclass such as
4030        "UnsupportedBitsPerSample" if the input stream
4031        is formatted in a way this class is unable to support
4032        """
4033
4034        raise NotImplementedError()
4035
4036    def convert(self, target_path, target_class,
4037                compression=None, progress=None):
4038        """encodes a new AudioFile from existing AudioFile
4039
4040        take a filename string, target class and optional compression string
4041        encodes a new AudioFile in the target class and returns
4042        the resulting object
4043        may raise EncodingError if some problem occurs during encoding"""
4044
4045        return target_class.from_pcm(
4046            target_path,
4047            to_pcm_progress(self, progress),
4048            compression,
4049            total_pcm_frames=(self.total_frames() if self.lossless()
4050                              else None))
4051
4052    def seekable(self):
4053        """returns True if the file is seekable
4054
4055        that is, if its PCMReader has a .seek() method
4056        and that method supports some sort of fine-grained seeking
4057        when the PCMReader is working from on-disk files"""
4058
4059        return False
4060
4061    @classmethod
4062    def __unlink__(cls, filename):
4063        try:
4064            os.unlink(filename)
4065        except OSError:
4066            pass
4067
4068    @classmethod
4069    def track_name(cls, file_path, track_metadata=None, format=None,
4070                   suffix=None):
4071        """constructs a new filename string
4072
4073        given a string to an existing path,
4074        a MetaData-compatible object (or None),
4075        a Python format string
4076        and a suffix string (such as "mp3")
4077        returns a string of a new filename with format's
4078        fields filled-in
4079
4080        raises UnsupportedTracknameField if the format string
4081        contains invalid template fields
4082
4083        raises InvalidFilenameFormat if the format string
4084        has broken template fields"""
4085
4086        # these should be traditional strings under
4087        # both Python 2 and 3
4088        assert((format is None) or isinstance(format, str))
4089        assert((suffix is None) or isinstance(suffix, str))
4090
4091        # handle defaults
4092        if format is None:
4093            format = FILENAME_FORMAT
4094        if suffix is None:
4095            suffix = cls.SUFFIX
4096
4097        # convert arguments to unicode to simplify everything internally
4098        if PY2:
4099            format = format.decode("UTF-8", "replace")
4100            suffix = suffix.decode("UTF-8", "replace")
4101
4102        # grab numeric arguments from MetaData, if any
4103        if track_metadata is not None:
4104            track_number = (track_metadata.track_number
4105                            if track_metadata.track_number is not None
4106                            else 0)
4107            album_number = (track_metadata.album_number
4108                            if track_metadata.album_number is not None
4109                            else 0)
4110            track_total = (track_metadata.track_total
4111                           if track_metadata.track_total is not None
4112                           else 0)
4113            album_total = (track_metadata.album_total
4114                           if track_metadata.album_total is not None
4115                           else 0)
4116        else:
4117            track_number = 0
4118            album_number = 0
4119            track_total = 0
4120            album_total = 0
4121
4122        # setup preliminary format dictionary
4123        format_dict = {u"track_number": track_number,
4124                       u"album_number": album_number,
4125                       u"track_total": track_total,
4126                       u"album_total": album_total,
4127                       u"suffix": suffix}
4128
4129        if album_number == 0:
4130            format_dict[u"album_track_number"] = u"%2.2d" % (track_number)
4131        else:
4132            album_digits = len(str(album_total))
4133
4134            format_dict[u"album_track_number"] = (
4135                u"%%%(album_digits)d.%(album_digits)dd%%2.2d" %
4136                {u"album_digits": album_digits} %
4137                (album_number, track_number))
4138
4139        if PY2:
4140            format_dict[u"basename"] = os.path.splitext(
4141                os.path.basename(file_path))[0].decode("UTF-8", "replace")
4142        else:
4143            format_dict[u"basename"] = os.path.splitext(
4144                os.path.basename(file_path))[0]
4145
4146        # populate remainder of format dictionary
4147        # with fields from MetaData, if any
4148        for field in MetaData.FIELDS:
4149            if field in MetaData.INTEGER_FIELDS:
4150                continue
4151
4152            if PY2:
4153                field_name = field.decode("ascii")
4154            else:
4155                field_name = field
4156
4157            if track_metadata is not None:
4158                attr = getattr(track_metadata, field)
4159                if attr is None:
4160                    attr = u""
4161            else:
4162                attr = u""
4163
4164            format_dict[field_name] = \
4165                attr.replace(u"/", u"-").replace(u"\x00", u" ")
4166
4167        try:
4168            # apply format dictionary
4169            if PY2:
4170                formatted_filename = (format % format_dict).encode("UTF-8",
4171                                                                   "replace")
4172            else:
4173                formatted_filename = (format % format_dict)
4174        except KeyError as error:
4175            raise UnsupportedTracknameField(error.args[0])
4176        except TypeError:
4177            raise InvalidFilenameFormat()
4178        except ValueError:
4179            raise InvalidFilenameFormat()
4180
4181        # ensure filename isn't absoluate
4182        return formatted_filename.lstrip(os.sep)
4183
4184    @classmethod
4185    def supports_replay_gain(cls):
4186        """returns True if this class supports ReplayGain"""
4187
4188        # implement this in subclass if necessary
4189        return False
4190
4191    def get_replay_gain(self):
4192        """returns a ReplayGain object of our ReplayGain values
4193
4194        returns None if we have no values
4195
4196        may raise IOError if unable to read the file"""
4197
4198        # implement this in subclass if necessary
4199        return None
4200
4201    def set_replay_gain(self, replaygain):
4202        """given a ReplayGain object, sets the track's gain to those values
4203
4204        may raise IOError if unable to modify the file"""
4205
4206        # implement this in subclass if necessary
4207        pass
4208
4209    def delete_replay_gain(self):
4210        """removes ReplayGain values from file, if any
4211
4212        may raise IOError if unable to modify the file"""
4213
4214        # implement this in subclass if necessary
4215        pass
4216
4217    @classmethod
4218    def supports_cuesheet(self):
4219        """returns True if the audio format supports embedded Sheet objects"""
4220
4221        return False
4222
4223    def set_cuesheet(self, cuesheet):
4224        """imports cuesheet data from a Sheet object
4225
4226        Raises IOError if an error occurs setting the cuesheet"""
4227
4228        pass
4229
4230    def get_cuesheet(self):
4231        """returns the embedded Cuesheet-compatible object, or None
4232
4233        Raises IOError if a problem occurs when reading the file"""
4234
4235        return None
4236
4237    def delete_cuesheet(self):
4238        """deletes embedded Sheet object, if any
4239
4240        Raises IOError if a problem occurs when updating the file"""
4241
4242        pass
4243
4244    def verify(self, progress=None):
4245        """verifies the current file for correctness
4246
4247        returns True if the file is okay
4248        raises an InvalidFile with an error message if there is
4249        some problem with the file"""
4250
4251        total_frames = self.total_frames()
4252        pcm_frame_count = 0
4253        with self.to_pcm() as decoder:
4254            try:
4255                framelist = decoder.read(FRAMELIST_SIZE)
4256                while len(framelist) > 0:
4257                    pcm_frame_count += framelist.frames
4258                    if progress is not None:
4259                        progress(pcm_frame_count, total_frames)
4260                    framelist = decoder.read(FRAMELIST_SIZE)
4261            except (IOError, ValueError) as err:
4262                raise InvalidFile(str(err))
4263
4264        if self.lossless():
4265            if pcm_frame_count == total_frames:
4266                return True
4267            else:
4268                raise InvalidFile("incorrect PCM frame count")
4269        else:
4270            return True
4271
4272    @classmethod
4273    def available(cls, system_binaries):
4274        """returns True if all necessary compenents are available
4275        to support format"""
4276
4277        for command in cls.BINARIES:
4278            if not system_binaries.can_execute(system_binaries[command]):
4279                return False
4280        else:
4281            return True
4282
4283    @classmethod
4284    def missing_components(cls, messenger):
4285        """given a Messenger object, displays missing binaries or libraries
4286        needed to support this format and where to get them"""
4287
4288        binaries = cls.BINARIES
4289        urls = cls.BINARY_URLS
4290        format_ = cls.NAME.decode('ascii')
4291
4292        if len(binaries) == 0:
4293            # no binaries, so they can't be missing, so nothing to display
4294            pass
4295        elif len(binaries) == 1:
4296            # one binary has only a single URL to display
4297            from audiotools.text import (ERR_PROGRAM_NEEDED,
4298                                         ERR_PROGRAM_DOWNLOAD_URL,
4299                                         ERR_PROGRAM_PACKAGE_MANAGER)
4300            messenger.info(
4301                ERR_PROGRAM_NEEDED %
4302                {"program": u"\"%s\"" % (binaries[0].decode('ascii')),
4303                 "format": format_})
4304            messenger.info(
4305                ERR_PROGRAM_DOWNLOAD_URL %
4306                {"program": binaries[0].decode('ascii'),
4307                 "url": urls[binaries[0]]})
4308            messenger.info(ERR_PROGRAM_PACKAGE_MANAGER)
4309        else:
4310            # multiple binaries may have one or more URLs to display
4311            from audiotools.text import (ERR_PROGRAMS_NEEDED,
4312                                         ERR_PROGRAMS_DOWNLOAD_URL,
4313                                         ERR_PROGRAM_DOWNLOAD_URL,
4314                                         ERR_PROGRAM_PACKAGE_MANAGER)
4315            messenger.info(
4316                ERR_PROGRAMS_NEEDED %
4317                {"programs": u", ".join([u"\"%s\"" % (b.decode('ascii'))
4318                                         for b in binaries]),
4319                 "format": format_})
4320            if len({urls[b] for b in binaries}) == 1:
4321                # if they all come from one URL (like Vorbis tools)
4322                # display only that URL
4323                messenger.info(
4324                    ERR_PROGRAMS_DOWNLOAD_URL % {"url": urls[binaries[0]]})
4325            else:
4326                # otherwise, display the URL for each binary
4327                for b in binaries:
4328                    messenger.info(
4329                        ERR_PROGRAM_DOWNLOAD_URL %
4330                        {"program": b.decode('ascii'),
4331                         "url": urls[b]})
4332            messenger.info(ERR_PROGRAM_PACKAGE_MANAGER)
4333
4334    def clean(self, output_filename=None):
4335        """cleans the file of known data and metadata problems
4336
4337        output_filename is an optional filename of the fixed file
4338        if present, a new AudioFile is written to that path
4339        otherwise, only a dry-run is performed and no new file is written
4340
4341        return list of fixes performed as Unicode strings
4342
4343        raises IOError if unable to write the file or its metadata
4344        raises ValueError if the file has errors of some sort
4345        """
4346
4347        if output_filename is None:
4348            # dry run only
4349            metadata = self.get_metadata()
4350            if metadata is not None:
4351                (metadata, fixes) = metadata.clean()
4352                return fixes
4353            else:
4354                return []
4355        else:
4356            # perform full fix
4357            input_f = __open__(self.filename, "rb")
4358            output_f = __open__(output_filename, "wb")
4359            try:
4360                transfer_data(input_f.read, output_f.write)
4361            finally:
4362                input_f.close()
4363                output_f.close()
4364
4365            new_track = open(output_filename)
4366            metadata = self.get_metadata()
4367            if metadata is not None:
4368                (metadata, fixes) = metadata.clean()
4369                new_track.set_metadata(metadata)
4370                return fixes
4371            else:
4372                return []
4373
4374
4375class WaveContainer(AudioFile):
4376    def has_foreign_wave_chunks(self):
4377        """returns True if the file has RIFF chunks
4378        other than 'fmt ' and 'data'
4379        which must be preserved during conversion"""
4380
4381        raise NotImplementedError()
4382
4383    def wave_header_footer(self):
4384        """returns (header, footer) tuple of strings
4385        containing all data before and after the PCM stream
4386
4387        may raise ValueError if there's a problem with
4388        the header or footer data
4389        may raise IOError if there's a problem reading
4390        header or footer data from the file
4391        """
4392
4393        raise NotImplementedError()
4394
4395    @classmethod
4396    def from_wave(cls, filename, header, pcmreader, footer, compression=None):
4397        """encodes a new file from wave data
4398
4399        takes a filename string, header string,
4400        PCMReader object, footer string
4401        and optional compression level string
4402        encodes a new audio file from pcmreader's data
4403        at the given filename with the specified compression level
4404        and returns a new WaveAudio object
4405
4406        header + pcm data + footer should always result
4407        in the original wave file being restored
4408        without need for any padding bytes
4409
4410        may raise EncodingError if some problem occurs when
4411        encoding the input file"""
4412
4413        raise NotImplementedError()
4414
4415    def convert(self, target_path, target_class, compression=None,
4416                progress=None):
4417        """encodes a new AudioFile from existing AudioFile
4418
4419        take a filename string, target class and optional compression string
4420        encodes a new AudioFile in the target class and returns
4421        the resulting object
4422        may raise EncodingError if some problem occurs during encoding"""
4423
4424        if ((self.has_foreign_wave_chunks() and
4425             hasattr(target_class, "from_wave") and
4426             callable(target_class.from_wave))):
4427            # transfer header and footer when performing PCM conversion
4428            try:
4429                (header, footer) = self.wave_header_footer()
4430            except (ValueError, IOError) as err:
4431                raise EncodingError(err)
4432
4433            return target_class.from_wave(target_path,
4434                                          header,
4435                                          to_pcm_progress(self, progress),
4436                                          footer,
4437                                          compression)
4438        else:
4439            # perform standard PCM conversion instead
4440            return target_class.from_pcm(
4441                target_path,
4442                to_pcm_progress(self, progress),
4443                compression,
4444                total_pcm_frames=(self.total_frames() if self.lossless()
4445                                  else None))
4446
4447
4448class AiffContainer(AudioFile):
4449    def has_foreign_aiff_chunks(self):
4450        """returns True if the file has AIFF chunks
4451        other than 'COMM' and 'SSND'
4452        which must be preserved during conversion"""
4453
4454        raise NotImplementedError()
4455
4456    def aiff_header_footer(self):
4457        """returns (header, footer) tuple of strings
4458        containing all data before and after the PCM stream
4459
4460        may raise ValueError if there's a problem with
4461        the header or footer data
4462        may raise IOError if there's a problem reading
4463        header or footer data from the file"""
4464
4465        raise NotImplementedError()
4466
4467    @classmethod
4468    def from_aiff(cls, filename, header, pcmreader, footer, compression=None):
4469        """encodes a new file from AIFF data
4470
4471        takes a filename string, header string,
4472        PCMReader object, footer string
4473        and optional compression level string
4474        encodes a new audio file from pcmreader's data
4475        at the given filename with the specified compression level
4476        and returns a new AiffAudio object
4477
4478        header + pcm data + footer should always result
4479        in the original AIFF file being restored
4480        without need for any padding bytes
4481
4482        may raise EncodingError if some problem occurs when
4483        encoding the input file"""
4484
4485        raise NotImplementedError()
4486
4487    def convert(self, target_path, target_class, compression=None,
4488                progress=None):
4489        """encodes a new AudioFile from existing AudioFile
4490
4491        take a filename string, target class and optional compression string
4492        encodes a new AudioFile in the target class and returns
4493        the resulting object
4494        may raise EncodingError if some problem occurs during encoding"""
4495
4496        if ((self.has_foreign_aiff_chunks() and
4497             hasattr(target_class, "from_aiff") and
4498             callable(target_class.from_aiff))):
4499            # transfer header and footer when performing PCM conversion
4500
4501            try:
4502                (header, footer) = self.aiff_header_footer()
4503            except (ValueError, IOError) as err:
4504                raise EncodingError(err)
4505
4506            return target_class.from_aiff(target_path,
4507                                          header,
4508                                          to_pcm_progress(self, progress),
4509                                          footer,
4510                                          compression)
4511        else:
4512            # perform standard PCM conversion instead
4513            return target_class.from_pcm(
4514                target_path,
4515                to_pcm_progress(self, progress),
4516                compression,
4517                total_pcm_frames=(self.total_frames() if self.lossless()
4518                                  else None))
4519
4520
4521class SheetException(ValueError):
4522    """a parent exception for CueException and TOCException"""
4523
4524    pass
4525
4526
4527def read_sheet(filename):
4528    """returns Sheet-compatible object from a .cue or .toc file
4529
4530    may raise a SheetException if the file cannot be parsed correctly"""
4531
4532    try:
4533        with __open__(filename, "rb") as f:
4534            return read_sheet_string(f.read().decode("UTF-8", "replace"))
4535    except IOError:
4536        from audiotools.text import ERR_CUE_IOERROR
4537        raise SheetException(ERR_CUE_IOERROR)
4538
4539
4540def read_sheet_string(sheet_string):
4541    """given a string of cuesheet data, returns a Sheet-compatible object
4542
4543    may raise a SheetException if the file cannot be parsed correctly"""
4544
4545    str_type = str if PY3 else unicode
4546
4547    assert(isinstance(sheet_string, str_type))
4548
4549    if u"CD_DA" in sheet_string:
4550        from audiotools.toc import read_tocfile_string
4551
4552        return read_tocfile_string(sheet_string)
4553    else:
4554        from audiotools.cue import read_cuesheet_string
4555
4556        return read_cuesheet_string(sheet_string)
4557
4558
4559class Sheet(object):
4560    """an object representing a CDDA layout
4561    such as provided by a .cue or .toc file"""
4562
4563    def __init__(self, sheet_tracks, metadata=None):
4564        """sheet_tracks is a list of SheetTrack objects
4565        metadata is a MetaData object, or None"""
4566
4567        self.__sheet_tracks__ = sheet_tracks
4568        self.__metadata__ = metadata
4569
4570    @classmethod
4571    def converted(cls, sheet):
4572        """given a Sheet-compatible object, returns a Sheet"""
4573
4574        return cls(sheet_tracks=[SheetTrack.converted(t) for t in sheet],
4575                   metadata=sheet.get_metadata())
4576
4577    def __repr__(self):
4578        return "Sheet(sheet_tracks=%s, metadata=%s)" % \
4579            (repr(self.__sheet_tracks__), repr(self.__metadata__))
4580
4581    def __len__(self):
4582        return len(self.__sheet_tracks__)
4583
4584    def __getitem__(self, index):
4585        return self.__sheet_tracks__[index]
4586
4587    def __eq__(self, sheet):
4588        try:
4589            if self.get_metadata() != sheet.get_metadata():
4590                return False
4591            if len(self) != len(sheet):
4592                return False
4593            for (t1, t2) in zip(self, sheet):
4594                if t1 != t2:
4595                    return False
4596            else:
4597                return True
4598        except (AttributeError, TypeError):
4599            return False
4600
4601    def track_numbers(self):
4602        """returns a list of all track numbers in the sheet"""
4603
4604        return [track.number() for track in self]
4605
4606    def track(self, track_number):
4607        """given a track_number (typically starting from 1),
4608        returns a SheetTrack object or raises KeyError if not found"""
4609
4610        for track in self:
4611            if track_number == track.number():
4612                return track
4613        else:
4614            raise KeyError(track_number)
4615
4616    def pre_gap(self):
4617        """returns the pre-gap of the entire disc
4618        as a Fraction number of seconds"""
4619
4620        indexes = self.track(1)
4621        if (indexes[0].number() == 0) and (indexes[1].number() == 1):
4622            return (indexes[1].offset() - indexes[0].offset())
4623        else:
4624            from fractions import Fraction
4625            return Fraction(0, 1)
4626
4627    def track_offset(self, track_number):
4628        """given a track_number (typically starting from 1)
4629        returns the offset to that track from the start of the stream
4630        as a Fraction number of seconds
4631
4632        may raise KeyError if the track is not found"""
4633
4634        return self.track(track_number).index(1).offset()
4635
4636    def track_length(self, track_number):
4637        """given a track_number (typically starting from 1)
4638        returns the length of the track as a Fraction number of seconds
4639        or None if the length is to the remainder of the stream
4640        (typically for the last track in the album)
4641
4642        may raise KeyError if the track is not found"""
4643
4644        initial_track = self.track(track_number)
4645        if (track_number + 1) in self.track_numbers():
4646            next_track = self.track(track_number + 1)
4647            return (next_track.index(1).offset() -
4648                    initial_track.index(1).offset())
4649        else:
4650            # no next track, so total length is unknown
4651            return None
4652
4653    def image_formatted(self):
4654        """returns True if all tracks are for the same file
4655        and have ascending index points"""
4656
4657        initial_filename = None
4658        previous_index = None
4659        for track in self:
4660            if initial_filename is None:
4661                initial_filename = track.filename()
4662            elif initial_filename != track.filename():
4663                return False
4664            for index in track:
4665                if previous_index is None:
4666                    previous_index = index.offset()
4667                elif previous_index >= index.offset():
4668                    return False
4669                else:
4670                    previous_index = index.offset()
4671        else:
4672            return True
4673
4674    def get_metadata(self):
4675        """returns MetaData of Sheet, or None
4676        this metadata often contains information such as catalog number
4677        or CD-TEXT values"""
4678
4679        return self.__metadata__
4680
4681
4682class SheetTrack(object):
4683    def __init__(self, number,
4684                 track_indexes,
4685                 metadata=None,
4686                 filename=u"CDImage.wav",
4687                 is_audio=True,
4688                 pre_emphasis=False,
4689                 copy_permitted=False):
4690        """
4691| argument       | type         | value                                 |
4692|----------------+--------------+---------------------------------------|
4693| number         | int          | track number, starting from 1         |
4694| track_indexes  | [SheetIndex] | list of SheetIndex objects            |
4695| metadata       | MetaData     | track's metadata, or None             |
4696| filename       | unicode      | track's filename on disc              |
4697| is_audio       | boolean      | whether track contains audio data     |
4698| pre_emphasis   | boolean      | whether track has pre-emphasis        |
4699| copy_permitted | boolean      | whether copying is permitted          |
4700        """
4701
4702        assert(isinstance(number, int))
4703        assert(isinstance(filename, str if PY3 else unicode))
4704
4705        self.__number__ = number
4706        self.__track_indexes__ = list(track_indexes)
4707        self.__metadata__ = metadata
4708        self.__filename__ = filename
4709        self.__is_audio__ = is_audio
4710        self.__pre_emphasis__ = pre_emphasis
4711        self.__copy_permitted__ = copy_permitted
4712
4713    @classmethod
4714    def converted(cls, sheet_track):
4715        """Given a SheetTrack-compatible object, returns a SheetTrack"""
4716
4717        return cls(
4718            number=sheet_track.number(),
4719            track_indexes=[SheetIndex.converted(i) for i in sheet_track],
4720            metadata=sheet_track.get_metadata(),
4721            filename=sheet_track.filename(),
4722            is_audio=sheet_track.is_audio(),
4723            pre_emphasis=sheet_track.pre_emphasis(),
4724            copy_permitted=sheet_track.copy_permitted())
4725
4726    def __repr__(self):
4727        return "SheetTrack(%s)" % \
4728            ", ".join(["%s=%s" % (attr,
4729                                  repr(getattr(self, "__" + attr + "__")))
4730                       for attr in ["number",
4731                                    "track_indexes",
4732                                    "metadata",
4733                                    "filename",
4734                                    "is_audio",
4735                                    "pre_emphasis",
4736                                    "copy_permitted"]])
4737
4738    def __len__(self):
4739        return len(self.__track_indexes__)
4740
4741    def __getitem__(self, i):
4742        return self.__track_indexes__[i]
4743
4744    def indexes(self):
4745        """returns a list of all indexes in the current track"""
4746
4747        return [index.number() for index in self]
4748
4749    def index(self, index_number):
4750        """given an index_number (0 for pre-gap, 1 for track start, etc.)
4751        returns a SheetIndex object or raises KeyError if not found"""
4752
4753        for sheet_index in self:
4754            if index_number == sheet_index.number():
4755                return sheet_index
4756        else:
4757            raise KeyError(index_number)
4758
4759    def __eq__(self, sheet_track):
4760        try:
4761            for method in ["number",
4762                           "is_audio",
4763                           "pre_emphasis",
4764                           "copy_permitted"]:
4765                if getattr(self, method)() != getattr(sheet_track, method)():
4766                    return False
4767
4768            if len(self) != len(sheet_track):
4769                return False
4770            for (t1, t2) in zip(self, sheet_track):
4771                if t1 != t2:
4772                    return False
4773            else:
4774                return True
4775        except (AttributeError, TypeError):
4776            return False
4777
4778    def __ne__(self, sheet_track):
4779        return not self.__eq__(sheet_track)
4780
4781    def number(self):
4782        """return SheetTrack's number, starting from 1"""
4783
4784        return self.__number__
4785
4786    def get_metadata(self):
4787        """returns SheetTrack's MetaData, or None"""
4788
4789        return self.__metadata__
4790
4791    def filename(self):
4792        """returns SheetTrack's filename as unicode"""
4793
4794        return self.__filename__
4795
4796    def is_audio(self):
4797        """returns whether SheetTrack contains audio data"""
4798
4799        return self.__is_audio__
4800
4801    def pre_emphasis(self):
4802        """returns whether SheetTrack has pre-emphasis"""
4803
4804        return self.__pre_emphasis__
4805
4806    def copy_permitted(self):
4807        """returns whether copying is permitted"""
4808
4809        return self.__copy_permitted__
4810
4811
4812class SheetIndex(object):
4813    def __init__(self, number, offset):
4814        """number is the index number, 0 for pre-gap index
4815
4816        offset is the offset from the start of the stream
4817        as a Fraction number of seconds"""
4818
4819        self.__number__ = number
4820        self.__offset__ = offset
4821
4822    @classmethod
4823    def converted(cls, sheet_index):
4824        """given a SheetIndex-compatible object, returns a SheetIndex"""
4825
4826        return cls(number=sheet_index.number(),
4827                   offset=sheet_index.offset())
4828
4829    def __repr__(self):
4830        return "SheetIndex(number=%s, offset=%s)" % \
4831            (repr(self.__number__), repr(self.__offset__))
4832
4833    def __eq__(self, sheet_index):
4834        try:
4835            return ((self.number() == sheet_index.number()) and
4836                    (self.offset() == sheet_index.offset()))
4837        except (TypeError, AttributeError):
4838            return False
4839
4840    def __ne__(self, sheet_index):
4841        return not self.__eq__(sheet_index)
4842
4843    def number(self):
4844        return self.__number__
4845
4846    def offset(self):
4847        return self.__offset__
4848
4849
4850def iter_first(iterator):
4851    """yields a (is_first, item) per item in the iterator
4852
4853    where is_first indicates whether the item is the first one
4854
4855    if the iterator has no items, yields (True, None)
4856    """
4857
4858    for (i, v) in enumerate(iterator):
4859        yield ((i == 0), v)
4860
4861
4862def iter_last(iterator):
4863    """yields a (is_last, item) per item in the iterator
4864
4865    where is_last indicates whether the item is the final one
4866
4867    if the iterator has no items, yields (True, None)
4868    """
4869
4870    iterator = iter(iterator)
4871
4872    try:
4873        cached_item = next(iterator)
4874    except StopIteration:
4875        return
4876
4877    while True:
4878        try:
4879            next_item = next(iterator)
4880            yield (False, cached_item)
4881            cached_item = next_item
4882        except StopIteration:
4883            yield (True, cached_item)
4884            return
4885
4886
4887def PCMReaderWindow(pcmreader, initial_offset, pcm_frames, forward_close=True):
4888    """pcmreader is the parent stream
4889
4890    initial offset is the offset of the stream's beginning,
4891    which may be negative
4892
4893    pcm_frames is the total length of the stream
4894
4895    if forward_close is True, calls to .close() are forwarded
4896    to the parent stream, otherwise the parent is left as-is"""
4897
4898    if initial_offset == 0:
4899        return PCMReaderHead(
4900            pcmreader=pcmreader,
4901            pcm_frames=pcm_frames,
4902            forward_close=forward_close)
4903    else:
4904        return PCMReaderHead(
4905            pcmreader=PCMReaderDeHead(pcmreader=pcmreader,
4906                                      pcm_frames=initial_offset,
4907                                      forward_close=forward_close),
4908            pcm_frames=pcm_frames,
4909            forward_close=forward_close)
4910
4911
4912class PCMReaderHead(PCMReader):
4913    """a wrapper around PCMReader for truncating a stream's ending"""
4914
4915    def __init__(self, pcmreader, pcm_frames, forward_close=True):
4916        """pcmreader is a PCMReader object
4917        pcm_frames is the total number of PCM frames in the stream
4918
4919        if pcm_frames is shorter than the pcmreader's stream,
4920        the stream will be truncated
4921
4922        if pcm_frames is longer than the pcmreader's stream,
4923        the stream will be extended with additional empty frames
4924
4925        if forward_close is True, calls to .close() are forwarded
4926        to the parent stream, otherwise the parent is left as-is
4927        """
4928
4929        if pcm_frames < 0:
4930            raise ValueError("invalid pcm_frames value")
4931
4932        PCMReader.__init__(self,
4933                           sample_rate=pcmreader.sample_rate,
4934                           channels=pcmreader.channels,
4935                           channel_mask=pcmreader.channel_mask,
4936                           bits_per_sample=pcmreader.bits_per_sample)
4937
4938        self.pcmreader = pcmreader
4939        self.pcm_frames = pcm_frames
4940        self.forward_close = forward_close
4941
4942    def __repr__(self):
4943        return "PCMReaderHead(%s, %s)" % (repr(self.pcmreader),
4944                                          self.pcm_frames)
4945
4946    def read(self, pcm_frames):
4947        if self.pcm_frames > 0:
4948            # data left in window
4949            # so try to read an additional frame from PCMReader
4950            frame = self.pcmreader.read(pcm_frames)
4951            if frame.frames == 0:
4952                # no additional data in PCMReader,
4953                # so return empty frames leftover in window
4954                # and close window
4955                frame = pcm.from_list([0] * (self.pcm_frames * self.channels),
4956                                      self.channels,
4957                                      self.bits_per_sample,
4958                                      True)
4959                self.pcm_frames -= frame.frames
4960                return frame
4961            elif frame.frames <= self.pcm_frames:
4962                # frame is shorter than remaining window,
4963                # so shrink window and return frame unaltered
4964                self.pcm_frames -= frame.frames
4965                return frame
4966            else:
4967                # frame is larger than remaining window,
4968                # so cut off end of frame
4969                # close window and return shrunk frame
4970                frame = frame.split(self.pcm_frames)[0]
4971                self.pcm_frames -= frame.frames
4972                return frame
4973        else:
4974            # window exhausted, so return empty framelist
4975            return pcm.empty_framelist(self.channels, self.bits_per_sample)
4976
4977    def read_closed(self, pcm_frames):
4978        raise ValueError()
4979
4980    def close(self):
4981        if self.forward_close:
4982            self.pcmreader.close()
4983        self.read = self.read_closed
4984
4985
4986class PCMReaderDeHead(PCMReader):
4987    """a wrapper around PCMReader for truncating a stream's beginning"""
4988
4989    def __init__(self, pcmreader, pcm_frames, forward_close=True):
4990        """pcmreader is a PCMReader object
4991        pcm_frames is the total number of PCM frames to remove
4992
4993        if pcm_frames is positive, that amount of frames will be
4994        removed from the beginning of the stream
4995
4996        if pcm_frames is negative, the stream will be padded
4997        with that many PCM frames
4998
4999        if forward_close is True, calls to .close() are forwarded
5000        to the parent stream, otherwise the parent is left as-is
5001        """
5002
5003        PCMReader.__init__(self,
5004                           sample_rate=pcmreader.sample_rate,
5005                           channels=pcmreader.channels,
5006                           channel_mask=pcmreader.channel_mask,
5007                           bits_per_sample=pcmreader.bits_per_sample)
5008
5009        self.pcmreader = pcmreader
5010        self.pcm_frames = pcm_frames
5011        self.forward_close = forward_close
5012
5013    def __repr__(self):
5014        return "PCMReaderDeHead(%s, %s)" % (repr(self.pcmreader),
5015                                            self.pcm_frames)
5016
5017    def read(self, pcm_frames):
5018        if self.pcm_frames == 0:
5019            # no truncation or padding, so return framelists as-is
5020            return self.pcmreader.read(pcm_frames)
5021        elif self.pcm_frames > 0:
5022            # remove PCM frames from beginning of stream
5023            # until all truncation is accounted for
5024            while self.pcm_frames > 0:
5025                frame = self.pcmreader.read(pcm_frames)
5026                if frame.frames == 0:
5027                    # truncation longer than entire stream
5028                    # so don't try to truncate it any further
5029                    self.pcm_frames = 0
5030                    return frame
5031                elif frame.frames <= self.pcm_frames:
5032                    self.pcm_frames -= frame.frames
5033                else:
5034                    (head, tail) = frame.split(self.pcm_frames)
5035                    self.pcm_frames -= head.frames
5036                    assert(self.pcm_frames == 0)
5037                    assert(tail.frames > 0)
5038                    return tail
5039            else:
5040                return self.pcmreader.read(pcm_frames)
5041        else:
5042            # pad beginning of stream with empty PCM frames
5043            frame = pcm.from_list([0] *
5044                                  (-self.pcm_frames) * self.channels,
5045                                  self.channels,
5046                                  self.bits_per_sample,
5047                                  True)
5048            assert(frame.frames == -self.pcm_frames)
5049            self.pcm_frames = 0
5050            return frame
5051
5052    def read_closed(self, pcm_frames):
5053        raise ValueError()
5054
5055    def close(self):
5056        if self.forward_close:
5057            self.pcmreader.close()
5058        self.read = self.read_closed
5059
5060
5061# returns the value in item_list which occurs most often
5062def most_numerous(item_list, empty_list=None, all_differ=None):
5063    """returns the value in the item list which occurs most often
5064    if list has no items, returns 'empty_list'
5065    if all items differ, returns 'all_differ'"""
5066
5067    counts = {}
5068
5069    if len(item_list) == 0:
5070        return empty_list
5071
5072    for item in item_list:
5073        counts.setdefault(item, []).append(item)
5074
5075    (item,
5076     max_count) = sorted([(item, len(counts[item])) for item in counts.keys()],
5077                         key=lambda pair: pair[1])[-1]
5078    if (max_count < len(item_list)) and (max_count == 1):
5079        return all_differ
5080    else:
5081        return item
5082
5083
5084def metadata_lookup(musicbrainz_disc_id,
5085                    freedb_disc_id,
5086                    musicbrainz_server="musicbrainz.org",
5087                    musicbrainz_port=80,
5088                    freedb_server="us.freedb.org",
5089                    freedb_port=80,
5090                    use_musicbrainz=True,
5091                    use_freedb=True):
5092    """generates a set of MetaData objects from CD
5093
5094    first_track_number and last_track_number are positive ints
5095    offsets is a list of track offsets, in CD frames
5096    lead_out_offset is the offset of the "lead-out" track, in CD frames
5097    total_length is the total length of the disc, in CD frames
5098
5099    returns a metadata[c][t] list of lists
5100    where 'c' is a possible choice
5101    and 't' is the MetaData for a given track (starting from 0)
5102
5103    this will always return at least one choice,
5104    which may be a list of largely empty MetaData objects
5105    if no match can be found for the CD
5106    """
5107
5108    assert(musicbrainz_disc_id.offsets == freedb_disc_id.offsets)
5109
5110    matches = []
5111
5112    # MusicBrainz takes precedence over FreeDB
5113    if use_musicbrainz:
5114        import audiotools.musicbrainz as musicbrainz
5115        try:
5116            from urllib.request import HTTPError
5117        except ImportError:
5118            from urllib2 import HTTPError
5119        from xml.parsers.expat import ExpatError
5120        try:
5121            matches.extend(
5122                musicbrainz.perform_lookup(
5123                    disc_id=musicbrainz_disc_id,
5124                    musicbrainz_server=musicbrainz_server,
5125                    musicbrainz_port=musicbrainz_port))
5126        except (HTTPError, ExpatError):
5127            pass
5128
5129    if use_freedb:
5130        import audiotools.freedb as freedb
5131        try:
5132            from urllib.request import HTTPError
5133        except ImportError:
5134            from urllib2 import HTTPError
5135        try:
5136            matches.extend(
5137                freedb.perform_lookup(
5138                    disc_id=freedb_disc_id,
5139                    freedb_server=freedb_server,
5140                    freedb_port=freedb_port))
5141        except (HTTPError, ValueError):
5142            pass
5143
5144    if len(matches) == 0:
5145        # no matches, so build a set of dummy metadata
5146        track_count = len(musicbrainz_disc_id.offsets)
5147        return [[MetaData(track_number=i, track_total=track_count)
5148                 for i in range(1, track_count + 1)]]
5149    else:
5150        return matches
5151
5152
5153def cddareader_metadata_lookup(cddareader,
5154                               musicbrainz_server="musicbrainz.org",
5155                               musicbrainz_port=80,
5156                               freedb_server="us.freedb.org",
5157                               freedb_port=80,
5158                               use_musicbrainz=True,
5159                               use_freedb=True):
5160    """given a CDDAReader object
5161    returns a metadata[c][t] list of lists
5162    where 'c' is a possible choice
5163    and 't' is the MetaData for a given track (starting from 0)
5164
5165    this will always return at least once choice,
5166    which may be a list of largely empty MetaData objects
5167    if no match can be found for the CD
5168    """
5169
5170    from audiotools.freedb import DiscID as FDiscID
5171    from audiotools.musicbrainz import DiscID as MDiscID
5172
5173    return metadata_lookup(
5174        freedb_disc_id=FDiscID.from_cddareader(cddareader),
5175        musicbrainz_disc_id=MDiscID.from_cddareader(cddareader),
5176        musicbrainz_server=musicbrainz_server,
5177        musicbrainz_port=musicbrainz_port,
5178        freedb_server=freedb_server,
5179        freedb_port=freedb_port,
5180        use_musicbrainz=use_musicbrainz,
5181        use_freedb=use_freedb)
5182
5183
5184def track_metadata_lookup(audiofiles,
5185                          musicbrainz_server="musicbrainz.org",
5186                          musicbrainz_port=80,
5187                          freedb_server="us.freedb.org",
5188                          freedb_port=80,
5189                          use_musicbrainz=True,
5190                          use_freedb=True):
5191    """given a list of AudioFile objects,
5192    this treats them as a single CD
5193    and generates a set of MetaData objects pulled from lookup services
5194
5195    returns a metadata[c][t] list of lists
5196    where 'c' is a possible choice
5197    and 't' is the MetaData for a given track (starting from 0)
5198
5199    this will always return at least one choice,
5200    which may be a list of largely empty MetaData objects
5201    if no match can be found for the CD
5202    """
5203
5204    from audiotools.freedb import DiscID as FDiscID
5205    from audiotools.musicbrainz import DiscID as MDiscID
5206
5207    return metadata_lookup(
5208        freedb_disc_id=FDiscID.from_tracks(audiofiles),
5209        musicbrainz_disc_id=MDiscID.from_tracks(audiofiles),
5210        musicbrainz_server=musicbrainz_server,
5211        musicbrainz_port=musicbrainz_port,
5212        freedb_server=freedb_server,
5213        freedb_port=freedb_port,
5214        use_musicbrainz=use_musicbrainz,
5215        use_freedb=use_freedb)
5216
5217
5218def sheet_metadata_lookup(sheet,
5219                          total_pcm_frames,
5220                          sample_rate,
5221                          musicbrainz_server="musicbrainz.org",
5222                          musicbrainz_port=80,
5223                          freedb_server="us.freedb.org",
5224                          freedb_port=80,
5225                          use_musicbrainz=True,
5226                          use_freedb=True):
5227    """given a Sheet object,
5228    length of the album in PCM frames
5229    and sample rate of the disc,
5230
5231    returns a metadata[c][t] list of lists
5232    where 'c' is a possible choice
5233    and 't' is the MetaData for a given track (starting from 0)
5234
5235    this will always return at least one choice,
5236    which may be a list of largely empty MetaData objects
5237    if no match can be found for the CD
5238    """
5239
5240    from audiotools.freedb import DiscID as FDiscID
5241    from audiotools.musicbrainz import DiscID as MDiscID
5242
5243    return metadata_lookup(
5244        freedb_disc_id=FDiscID.from_sheet(sheet,
5245                                          total_pcm_frames,
5246                                          sample_rate),
5247        musicbrainz_disc_id=MDiscID.from_sheet(sheet,
5248                                               total_pcm_frames,
5249                                               sample_rate),
5250        musicbrainz_server=musicbrainz_server,
5251        musicbrainz_port=musicbrainz_port,
5252        freedb_server=freedb_server,
5253        freedb_port=freedb_port,
5254        use_musicbrainz=use_musicbrainz,
5255        use_freedb=use_freedb)
5256
5257
5258def accuraterip_lookup(sorted_tracks,
5259                       accuraterip_server="www.accuraterip.com",
5260                       accuraterip_port=80):
5261    """given a list of sorted AudioFile objects
5262    and optional AccurateRip server and port
5263    returns a dict of
5264    {track_number:[(confidence, crc, crc2), ...], ...}
5265    where track_number starts from 1
5266
5267    may return a dict of empty lists if no AccurateRip entry is found
5268
5269    may raise urllib2.HTTPError if an error occurs querying the server
5270    """
5271
5272    if len(sorted_tracks) == 0:
5273        return {}
5274    else:
5275        from audiotools.accuraterip import DiscID, perform_lookup
5276
5277        return perform_lookup(DiscID.from_tracks(sorted_tracks),
5278                              accuraterip_server,
5279                              accuraterip_port)
5280
5281
5282def accuraterip_sheet_lookup(sheet, total_pcm_frames, sample_rate,
5283                             accuraterip_server="www.accuraterip.com",
5284                             accuraterip_port=80):
5285    """given a Sheet object, total number of PCM frames and sample rate
5286    returns a dict of
5287    {track_number:[(confidence, crc, crc2), ...], ...}
5288    where track_number starts from 1
5289
5290    may return a dict of empty lists if no AccurateRip entry is found
5291
5292    may raise urllib2.HTTPError if an error occurs querying the server
5293    """
5294
5295    from audiotools.accuraterip import DiscID, perform_lookup
5296
5297    return perform_lookup(DiscID.from_sheet(sheet,
5298                                            total_pcm_frames,
5299                                            sample_rate),
5300                          accuraterip_server,
5301                          accuraterip_port)
5302
5303
5304def output_progress(u, current, total):
5305    """given a unicode string and current/total integers,
5306    returns a u'[<current>/<total>]  <string>'  unicode string
5307    indicating the current progress"""
5308
5309    if total > 1:
5310        return u"[%%%d.d/%%d]  %%s" % (len(str(total))) % (current, total, u)
5311    else:
5312        return u
5313
5314
5315class ExecProgressQueue(object):
5316    """a class for running multiple jobs in parallel with progress updates"""
5317
5318    def __init__(self, messenger):
5319        """takes a Messenger object"""
5320
5321        from collections import deque
5322
5323        self.messenger = messenger
5324        self.__displayed_rows__ = {}
5325        self.__queued_jobs__ = deque()
5326        self.__raised_exception__ = None
5327
5328    def execute(self, function,
5329                progress_text=None,
5330                completion_output=None,
5331                *args, **kwargs):
5332        """queues the given function and arguments to be run in parallel
5333
5334        function must have an additional "progress" argument
5335        not present in "*args" or "**kwargs" which is called
5336        with (current, total) integer arguments by the function
5337        on a regular basis to update its progress
5338        similar to:  function(*args, progress=prog(current, total), **kwargs)
5339
5340        progress_text should be a unicode string to be displayed while running
5341
5342        completion_output is either a unicode string,
5343        or a function which takes the result of the queued function
5344        and returns a unicode string for display
5345        once the queued function is complete
5346        """
5347
5348        self.__queued_jobs__.append((len(self.__queued_jobs__),
5349                                     progress_text,
5350                                     completion_output,
5351                                     function,
5352                                     args,
5353                                     kwargs))
5354
5355    def run(self, max_processes=1):
5356        """runs all the queued jobs"""
5357
5358        if (max_processes == 1) or (len(self.__queued_jobs__) == 1):
5359            return self.__run_serial__()
5360        else:
5361            return self.__run_parallel__(max_processes=max_processes)
5362
5363    def __run_serial__(self):
5364        """runs all the queued jobs in serial"""
5365
5366        results = []
5367        total_jobs = len(self.__queued_jobs__)
5368
5369        # pull parameters from job queue
5370        for (completed_job_number,
5371             (job_index,
5372              progress_text,
5373              completion_output,
5374              function,
5375              args,
5376              kwargs)) in enumerate(self.__queued_jobs__, 1):
5377            # add job to progress display, if any text to display
5378            if progress_text is not None:
5379                progress_display = SingleProgressDisplay(self.messenger,
5380                                                         progress_text)
5381
5382                # execute job with displayed progress
5383                result = function(*args,
5384                                  progress=progress_display.update,
5385                                  **kwargs)
5386
5387                # add result to results list
5388                results.append(result)
5389
5390                # remove job from progress display, if present
5391                progress_display.clear_rows()
5392            else:
5393                result = function(*args, **kwargs)
5394
5395            # display any output message attached to job
5396            if callable(completion_output):
5397                output = completion_output(result)
5398            else:
5399                output = completion_output
5400
5401            self.messenger.output(output_progress(output,
5402                                                  completed_job_number,
5403                                                  total_jobs))
5404
5405        self.__queued_jobs__.clear()
5406        return results
5407
5408    def __run_parallel__(self, max_processes=1):
5409        """runs all the queued jobs in parallel"""
5410
5411        from select import select
5412
5413        def execute_next_job(progress_display):
5414            """pulls the next job from the queue and returns a
5415            (Process, Array, Connection, progress_text, completed_text) tuple
5416            where Process is the subprocess
5417            Array is shared memory of the current progress
5418            Connection is the listening end of a pipe
5419            progress_text is unicode to display during progress
5420            and completed_text is unicode to display when finished"""
5421
5422            # pull parameters from job queue
5423            (job_index,
5424             progress_text,
5425             completion_output,
5426             function,
5427             args,
5428             kwargs) = self.__queued_jobs__.popleft()
5429
5430            # spawn new __ProgressQueueJob__ object
5431            job = __ProgressQueueJob__.spawn(
5432                job_index=job_index,
5433                function=function,
5434                args=args,
5435                kwargs=kwargs,
5436                progress_text=progress_text,
5437                completion_output=completion_output)
5438
5439            # add job to progress display, if any text to display
5440            if progress_text is not None:
5441                self.__displayed_rows__[job.job_fd()] = \
5442                    progress_display.add_row(progress_text)
5443
5444            return job
5445
5446        progress_display = ProgressDisplay(self.messenger)
5447
5448        # variables for X/Y output display
5449        # Note that the order a job is inserted into the queue
5450        # (as captured by its job_index value)
5451        # may differ from the order in which it is completed.
5452        total_jobs = len(self.__queued_jobs__)
5453        completed_job_number = 1
5454
5455        # a dict of job file descriptors -> __ProgressQueueJob__ objects
5456        job_pool = {}
5457
5458        # return values from the executed functions
5459        results = [None] * total_jobs
5460
5461        if total_jobs == 0:
5462            # nothing to do
5463            return results
5464
5465        # populate job pool up to "max_processes" number of jobs
5466        for i in range(min(max_processes, len(self.__queued_jobs__))):
5467            job = execute_next_job(progress_display)
5468            job_pool[job.job_fd()] = job
5469
5470        # while the pool still contains running jobs
5471        try:
5472            while len(job_pool) > 0:
5473                # wait for zero or more jobs to finish (may timeout)
5474                (rlist,
5475                 wlist,
5476                 elist) = select(job_pool.keys(), [], [], 0.25)
5477
5478                # clear out old display
5479                progress_display.clear_rows()
5480
5481                for finished_job in [job_pool[fd] for fd in rlist]:
5482                    job_fd = finished_job.job_fd()
5483
5484                    (exception, result) = finished_job.result()
5485
5486                    if not exception:
5487                        # job completed successfully
5488
5489                        # display any output message attached to job
5490                        completion_output = finished_job.completion_output
5491                        if callable(completion_output):
5492                            output = completion_output(result)
5493                        else:
5494                            output = completion_output
5495
5496                        if output is not None:
5497                            progress_display.output_line(
5498                                output_progress(output,
5499                                                completed_job_number,
5500                                                total_jobs))
5501
5502                        # attach result to output in the order it was received
5503                        results[finished_job.job_index] = result
5504                    else:
5505                        # job raised an exception
5506
5507                        # remove all other jobs from queue
5508                        # then raise exception to caller
5509                        # once working jobs are finished
5510                        self.__raised_exception__ = result
5511                        while len(self.__queued_jobs__) > 0:
5512                            self.__queued_jobs__.popleft()
5513
5514                    # remove job from pool
5515                    del(job_pool[job_fd])
5516
5517                    # remove job from progress display, if present
5518                    if job_fd in self.__displayed_rows__:
5519                        self.__displayed_rows__[job_fd].finish()
5520
5521                    # add new jobs from the job queue, if any
5522                    if len(self.__queued_jobs__) > 0:
5523                        job = execute_next_job(progress_display)
5524                        job_pool[job.job_fd()] = job
5525
5526                    # updated completed job number for X/Y display
5527                    completed_job_number += 1
5528
5529                # update progress rows with progress taken from shared memory
5530                for job in job_pool.values():
5531                    if job.job_fd() in self.__displayed_rows__:
5532                        self.__displayed_rows__[job.job_fd()].update(
5533                            job.current(), job.total())
5534
5535                # display new set of progress rows
5536                progress_display.display_rows()
5537        except:
5538            # an exception occurred (perhaps KeyboardInterrupt)
5539            # so kill any running child jobs
5540            # clear any progress rows
5541            progress_display.clear_rows()
5542            # and pass exception to caller
5543            raise
5544
5545        # if any jobs have raised an exception,
5546        # re-raise it in the main process
5547        if self.__raised_exception__ is not None:
5548            raise self.__raised_exception__
5549        else:
5550            # otherwise, return results in the order they were queued
5551            return results
5552
5553
5554class __ProgressQueueJob__(object):
5555    """this class is a the parent process end of a running child job"""
5556
5557    def __init__(self, job_index, process, progress, result_pipe,
5558                 progress_text, completion_output):
5559        """job_index is the order this job was inserted into the queue
5560
5561        process is the Process object of the running child
5562
5563        progress is an Array object of [current, total] progress status
5564
5565        result_pipe is a Connection object which will be read for data
5566
5567        progress_text is unicode to display while the job is in progress
5568
5569        completion_output is either unicode or a callable function
5570        to be displayed when the job finishes
5571        """
5572
5573        self.job_index = job_index
5574        self.process = process
5575        self.progress = progress
5576        self.result_pipe = result_pipe
5577        self.progress_text = progress_text
5578        self.completion_output = completion_output
5579
5580    def job_fd(self):
5581        """returns file descriptor of parent-side result pipe"""
5582
5583        return self.result_pipe.fileno()
5584
5585    def current(self):
5586        return self.progress[0]
5587
5588    def total(self):
5589        return self.progress[1]
5590
5591    @classmethod
5592    def spawn(cls, job_index,
5593              function, args, kwargs, progress_text, completion_output):
5594        """spawns a subprocess and returns the parent-side
5595        __ProgressQueueJob__ object
5596
5597        job_index is the order this jhob was inserted into the queue
5598
5599        function is the function to execute
5600
5601        args is a tuple of positional arguments
5602
5603        kwargs is a dict of keyword arguments
5604
5605        progress_text is unicode to display while the job is in progress
5606
5607        completion_output is either unicode or a callable function
5608        to be displayed when the job finishes
5609        """
5610
5611        def execute_job(function, args, kwargs, progress, result_pipe):
5612            try:
5613                result_pipe.send((False, function(*args,
5614                                                  progress=progress,
5615                                                  **kwargs)))
5616            except Exception as exception:
5617                result_pipe.send((True, exception))
5618
5619            result_pipe.close()
5620
5621        from multiprocessing import Process, Array, Pipe
5622
5623        # construct shared memory array to store progress
5624        progress = Array("L", [0, 0])
5625
5626        # construct one-way pipe to collect result
5627        (parent_conn, child_conn) = Pipe(False)
5628
5629        # build child job to execute function
5630        process = Process(target=execute_job,
5631                          args=(function,
5632                                args,
5633                                kwargs,
5634                                __progress__(progress).update,
5635                                child_conn))
5636
5637        # start child job
5638        process.start()
5639
5640        # return populated __ProgressQueueJob__ object
5641        return cls(job_index=job_index,
5642                   process=process,
5643                   progress=progress,
5644                   result_pipe=parent_conn,
5645                   progress_text=progress_text,
5646                   completion_output=completion_output)
5647
5648    def result(self):
5649        """returns (exception, result) from parent-side pipe
5650        where exception is True if result is an exception
5651        or False if it's the result of the called child function"""
5652
5653        (exception, result) = self.result_pipe.recv()
5654        self.result_pipe.close()
5655        self.process.join()
5656        return (exception, result)
5657
5658
5659class __progress__(object):
5660    def __init__(self, memory):
5661        self.memory = memory
5662
5663    def update(self, current, total):
5664        self.memory[0] = current
5665        self.memory[1] = total
5666
5667
5668class TemporaryFile(object):
5669    """a class for staging file rewrites"""
5670
5671    def __init__(self, original_filename):
5672        """original_filename is the path of the file
5673        to be rewritten with new data"""
5674
5675        from tempfile import mkstemp
5676
5677        self.__original_filename__ = original_filename
5678        (dirname, basename) = os.path.split(original_filename)
5679        (fd, self.__temp_path__) = mkstemp(prefix="." + basename,
5680                                           dir=dirname)
5681        self.__temp_file__ = os.fdopen(fd, "wb")
5682
5683    def __del__(self):
5684        if (((self.__temp_path__ is not None) and
5685             os.path.isfile(self.__temp_path__))):
5686            os.unlink(self.__temp_path__)
5687
5688    def write(self, data):
5689        """writes the given data string to the temporary file"""
5690
5691        self.__temp_file__.write(data)
5692
5693    def flush(self):
5694        """flushes pending data to stream"""
5695
5696        self.__temp_file__.flush()
5697
5698    def tell(self):
5699        """returns current file position"""
5700
5701        return self.__temp_file__.tell()
5702
5703    def seek(self, offset, whence=None):
5704        """move to new file position"""
5705
5706        if whence is not None:
5707            self.__temp_file__.seek(offset, whence)
5708        else:
5709            self.__temp_file__.seek(offset)
5710
5711    def close(self):
5712        """commits all staged changes
5713
5714        the original file is overwritten, its file mode is preserved
5715        and the temporary file is closed and deleted"""
5716
5717        self.__temp_file__.close()
5718        original_mode = os.stat(self.__original_filename__).st_mode
5719        try:
5720            os.rename(self.__temp_path__, self.__original_filename__)
5721            os.chmod(self.__original_filename__, original_mode)
5722            self.__temp_path__ = None
5723        except OSError as err:
5724            os.unlink(self.__temp_path__)
5725            raise err
5726
5727
5728from audiotools.au import AuAudio
5729from audiotools.wav import WaveAudio
5730from audiotools.aiff import AiffAudio
5731from audiotools.flac import FlacAudio
5732from audiotools.flac import OggFlacAudio
5733from audiotools.wavpack import WavPackAudio
5734from audiotools.shn import ShortenAudio
5735from audiotools.mp3 import MP3Audio
5736from audiotools.mp3 import MP2Audio
5737from audiotools.vorbis import VorbisAudio
5738from audiotools.m4a import M4AAudio
5739from audiotools.m4a import ALACAudio
5740from audiotools.opus import OpusAudio
5741from audiotools.tta import TrueAudio
5742
5743from audiotools.ape import ApeTag
5744from audiotools.flac import FlacMetaData
5745from audiotools.id3 import ID3CommentPair
5746from audiotools.id3v1 import ID3v1Comment
5747from audiotools.id3 import ID3v22Comment
5748from audiotools.id3 import ID3v23Comment
5749from audiotools.id3 import ID3v24Comment
5750from audiotools.m4a_atoms import M4A_META_Atom
5751from audiotools.vorbiscomment import VorbisComment
5752
5753AVAILABLE_TYPES = (FlacAudio,
5754                   OggFlacAudio,
5755                   MP3Audio,
5756                   MP2Audio,
5757                   WaveAudio,
5758                   VorbisAudio,
5759                   AiffAudio,
5760                   AuAudio,
5761                   M4AAudio,
5762                   ALACAudio,
5763                   WavPackAudio,
5764                   ShortenAudio,
5765                   OpusAudio,
5766                   TrueAudio)
5767
5768TYPE_MAP = {track_type.NAME: track_type
5769            for track_type in AVAILABLE_TYPES
5770            if track_type.available(BIN)}
5771
5772DEFAULT_QUALITY = {track_type.NAME:
5773                   config.get_default("Quality",
5774                                      track_type.NAME,
5775                                      track_type.DEFAULT_COMPRESSION)
5776                   for track_type in AVAILABLE_TYPES
5777                   if (len(track_type.COMPRESSION_MODES) > 1)}
5778
5779if DEFAULT_TYPE not in TYPE_MAP.keys():
5780    DEFAULT_TYPE = "wav"
5781