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