1943 lines
73 KiB
Python
1943 lines
73 KiB
Python
#!/usr/bin/python
|
|
|
|
#Audio Tools, a module and set of tools for manipulating audio data
|
|
#Copyright (C) 2007-2011 Brian Langenberger
|
|
|
|
#This program is free software; you can redistribute it and/or modify
|
|
#it under the terms of the GNU General Public License as published by
|
|
#the Free Software Foundation; either version 2 of the License, or
|
|
#(at your option) any later version.
|
|
|
|
#This program is distributed in the hope that it will be useful,
|
|
#but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
#MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
#GNU General Public License for more details.
|
|
|
|
#You should have received a copy of the GNU General Public License
|
|
#along with this program; if not, write to the Free Software
|
|
#Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
|
|
|
|
|
|
from audiotools import (AudioFile, InvalidFile, PCMReader, PCMConverter,
|
|
Con, transfer_data, transfer_framelist_data,
|
|
subprocess, BIN, cStringIO, MetaData, os,
|
|
Image, InvalidImage, ignore_sigint, InvalidFormat,
|
|
open, open_files, EncodingError, DecodingError,
|
|
WaveAudio, TempWaveReader,
|
|
ChannelMask, UnsupportedBitsPerSample,
|
|
BufferedPCMReader, to_pcm_progress,
|
|
at_a_time, VERSION, PCMReaderError,
|
|
__default_quality__)
|
|
from __m4a_atoms__ import *
|
|
import gettext
|
|
|
|
gettext.install("audiotools", unicode=True)
|
|
|
|
|
|
#######################
|
|
#M4A File
|
|
#######################
|
|
|
|
|
|
class InvalidM4A(InvalidFile):
|
|
pass
|
|
|
|
|
|
#M4A files are made up of QuickTime Atoms
|
|
#some of those Atoms are containers for sub-Atoms
|
|
class __Qt_Atom__:
|
|
CONTAINERS = frozenset(
|
|
['dinf', 'edts', 'imag', 'imap', 'mdia', 'mdra', 'minf',
|
|
'moov', 'rmra', 'stbl', 'trak', 'tref', 'udta', 'vnrp'])
|
|
|
|
STRUCT = Con.Struct("qt_atom",
|
|
Con.UBInt32("size"),
|
|
Con.String("type", 4))
|
|
|
|
def __init__(self, type, data, offset):
|
|
self.type = type
|
|
self.data = data
|
|
self.offset = offset
|
|
|
|
def __repr__(self):
|
|
return "__Qt_Atom__(%s,%s,%s)" % \
|
|
(repr(self.type),
|
|
repr(self.data),
|
|
repr(self.offset))
|
|
|
|
def __eq__(self, o):
|
|
if (hasattr(o, "type") and
|
|
hasattr(o, "data")):
|
|
return ((self.type == o.type) and
|
|
(self.data == o.data))
|
|
else:
|
|
return False
|
|
|
|
#takes an 8 byte string
|
|
#returns an Atom's (type,size) as a tuple
|
|
@classmethod
|
|
def parse(cls, header_data):
|
|
header = cls.STRUCT.parse(header_data)
|
|
return (header.type, header.size)
|
|
|
|
def build(self):
|
|
return __build_qt_atom__(self.type, self.data)
|
|
|
|
#performs a search of all sub-atoms to find the one
|
|
#with the given type, or None if one cannot be found
|
|
def get_atom(self, type):
|
|
if (self.type == type):
|
|
return self
|
|
elif (self.is_container()):
|
|
for atom in self:
|
|
returned_atom = atom.get_atom(type)
|
|
if (returned_atom is not None):
|
|
return returned_atom
|
|
|
|
return None
|
|
|
|
#returns True if the Atom is a container, False if not
|
|
def is_container(self):
|
|
return self.type in self.CONTAINERS
|
|
|
|
def __iter__(self):
|
|
for atom in __parse_qt_atoms__(cStringIO.StringIO(self.data),
|
|
__Qt_Atom__):
|
|
yield atom
|
|
|
|
def __len__(self):
|
|
count = 0
|
|
for atom in self:
|
|
count += 1
|
|
return count
|
|
|
|
def __getitem__(self, type):
|
|
for atom in self:
|
|
if (atom.type == type):
|
|
return atom
|
|
raise KeyError(type)
|
|
|
|
def keys(self):
|
|
return [atom.type for atom in self]
|
|
|
|
|
|
#a stream of __Qt_Atom__ objects
|
|
#though it is an Atom-like container, it has no type of its own
|
|
class __Qt_Atom_Stream__(__Qt_Atom__):
|
|
def __init__(self, stream):
|
|
self.stream = stream
|
|
self.atom_class = __Qt_Atom__
|
|
|
|
__Qt_Atom__.__init__(self, None, None, 0)
|
|
|
|
def is_container(self):
|
|
return True
|
|
|
|
def __iter__(self):
|
|
self.stream.seek(0, 0)
|
|
|
|
for atom in __parse_qt_atoms__(self.stream,
|
|
self.atom_class,
|
|
self.offset):
|
|
yield atom
|
|
|
|
|
|
Qt_Atom_Stream = __Qt_Atom_Stream__
|
|
|
|
|
|
#takes a stream object with a read() method
|
|
#iterates over all of the atoms it contains and yields
|
|
#a series of qt_class objects, which defaults to __Qt_Atom__
|
|
def __parse_qt_atoms__(stream, qt_class=__Qt_Atom__, base_offset=0):
|
|
h = stream.read(8)
|
|
while (len(h) == 8):
|
|
(header_type, header_size) = qt_class.parse(h)
|
|
if (header_size == 0):
|
|
yield qt_class(header_type,
|
|
stream.read(),
|
|
base_offset)
|
|
else:
|
|
yield qt_class(header_type,
|
|
stream.read(header_size - 8),
|
|
base_offset)
|
|
base_offset += header_size
|
|
|
|
h = stream.read(8)
|
|
|
|
|
|
def __build_qt_atom__(atom_type, atom_data):
|
|
con = Con.Container()
|
|
con.type = atom_type
|
|
con.size = len(atom_data) + __Qt_Atom__.STRUCT.sizeof()
|
|
return __Qt_Atom__.STRUCT.build(con) + atom_data
|
|
|
|
|
|
#takes an existing __Qt_Atom__ object (possibly a container)
|
|
#and a __Qt_Atom__ to replace
|
|
#finds all sub-atoms with the same type as new_atom and replaces them
|
|
#returns a string
|
|
def __replace_qt_atom__(qt_atom, new_atom):
|
|
if (qt_atom.type is None):
|
|
return "".join(
|
|
[__replace_qt_atom__(a, new_atom) for a in qt_atom])
|
|
elif (qt_atom.type == new_atom.type):
|
|
#if we've found the atom to replace,
|
|
#build a new atom string from new_atom's data
|
|
return __build_qt_atom__(new_atom.type, new_atom.data)
|
|
else:
|
|
#if we're still looking for the atom to replace
|
|
if (not qt_atom.is_container()):
|
|
#build the old atom string from qt_atom's data
|
|
#if it is not a container
|
|
return __build_qt_atom__(qt_atom.type, qt_atom.data)
|
|
else:
|
|
#recursively build the old atom's data
|
|
#with values from __replace_qt_atom__
|
|
return __build_qt_atom__(qt_atom.type,
|
|
"".join(
|
|
[__replace_qt_atom__(a, new_atom) for a in qt_atom]))
|
|
|
|
|
|
def __remove_qt_atom__(qt_atom, atom_name):
|
|
if (qt_atom.type is None):
|
|
return "".join(
|
|
[__remove_qt_atom__(a, atom_name) for a in qt_atom])
|
|
elif (qt_atom.type == atom_name):
|
|
return ""
|
|
else:
|
|
if (not qt_atom.is_container()):
|
|
return __build_qt_atom__(qt_atom.type, qt_atom.data)
|
|
else:
|
|
return __build_qt_atom__(qt_atom.type,
|
|
"".join(
|
|
[__remove_qt_atom__(a, atom_name) for a in qt_atom]))
|
|
|
|
|
|
class M4AAudio_faac(AudioFile):
|
|
"""An M4A audio file using faac/faad binaries for I/O."""
|
|
|
|
SUFFIX = "m4a"
|
|
NAME = SUFFIX
|
|
DEFAULT_COMPRESSION = "100"
|
|
COMPRESSION_MODES = tuple(["10"] + map(str, range(50, 500, 25)) + ["500"])
|
|
BINARIES = ("faac", "faad")
|
|
|
|
MP4A_ATOM = Con.Struct("mp4a",
|
|
Con.UBInt32("length"),
|
|
Con.String("type", 4),
|
|
Con.String("reserved", 6),
|
|
Con.UBInt16("reference_index"),
|
|
Con.UBInt16("version"),
|
|
Con.UBInt16("revision_level"),
|
|
Con.String("vendor", 4),
|
|
Con.UBInt16("channels"),
|
|
Con.UBInt16("bits_per_sample"))
|
|
|
|
MDHD_ATOM = Con.Struct("mdhd",
|
|
Con.Byte("version"),
|
|
Con.Bytes("flags", 3),
|
|
Con.UBInt32("creation_date"),
|
|
Con.UBInt32("modification_date"),
|
|
Con.UBInt32("sample_rate"),
|
|
Con.UBInt32("track_length"))
|
|
|
|
def __init__(self, filename):
|
|
"""filename is a plain string."""
|
|
|
|
self.filename = filename
|
|
try:
|
|
self.qt_stream = __Qt_Atom_Stream__(file(self.filename, "rb"))
|
|
except IOError, msg:
|
|
raise InvalidM4A(str(msg))
|
|
|
|
try:
|
|
mp4a = M4AAudio.MP4A_ATOM.parse(
|
|
self.qt_stream['moov']['trak']['mdia']['minf']['stbl'][
|
|
'stsd'].data[8:])
|
|
|
|
self.__channels__ = mp4a.channels
|
|
self.__bits_per_sample__ = mp4a.bits_per_sample
|
|
|
|
mdhd = M4AAudio.MDHD_ATOM.parse(
|
|
self.qt_stream['moov']['trak']['mdia']['mdhd'].data)
|
|
|
|
self.__sample_rate__ = mdhd.sample_rate
|
|
self.__length__ = mdhd.track_length
|
|
except KeyError:
|
|
raise InvalidM4A(_(u'Required moov atom not found'))
|
|
|
|
def channel_mask(self):
|
|
"""Returns a ChannelMask object of this track's channel layout."""
|
|
|
|
#M4A seems to use the same channel assignment
|
|
#as old-style RIFF WAVE/FLAC
|
|
if (self.channels() == 1):
|
|
return ChannelMask.from_fields(
|
|
front_center=True)
|
|
elif (self.channels() == 2):
|
|
return ChannelMask.from_fields(
|
|
front_left=True, front_right=True)
|
|
elif (self.channels() == 3):
|
|
return ChannelMask.from_fields(
|
|
front_left=True, front_right=True, front_center=True)
|
|
elif (self.channels() == 4):
|
|
return ChannelMask.from_fields(
|
|
front_left=True, front_right=True,
|
|
back_left=True, back_right=True)
|
|
elif (self.channels() == 5):
|
|
return ChannelMask.from_fields(
|
|
front_left=True, front_right=True, front_center=True,
|
|
back_left=True, back_right=True)
|
|
elif (self.channels() == 6):
|
|
return ChannelMask.from_fields(
|
|
front_left=True, front_right=True, front_center=True,
|
|
back_left=True, back_right=True,
|
|
low_frequency=True)
|
|
else:
|
|
return ChannelMask(0)
|
|
|
|
@classmethod
|
|
def is_type(cls, file):
|
|
"""Returns True if the given file object describes this format.
|
|
|
|
Takes a seekable file pointer rewound to the start of the file."""
|
|
|
|
header = file.read(12)
|
|
|
|
if ((header[4:8] == 'ftyp') and
|
|
(header[8:12] in ('mp41', 'mp42', 'M4A ', 'M4B '))):
|
|
file.seek(0, 0)
|
|
atoms = __Qt_Atom_Stream__(file)
|
|
try:
|
|
return (ATOM_STSD.parse(atoms['moov']['trak']['mdia']['minf']['stbl']['stsd'].data).descriptions[0].type == 'mp4a')
|
|
except (Con.ConstError, Con.FieldError, Con.ArrayError, KeyError,
|
|
IndexError):
|
|
return False
|
|
else:
|
|
return False
|
|
|
|
def lossless(self):
|
|
"""Returns False."""
|
|
|
|
return False
|
|
|
|
def channels(self):
|
|
"""Returns an integer number of channels this track contains."""
|
|
|
|
return self.__channels__
|
|
|
|
def bits_per_sample(self):
|
|
"""Returns an integer number of bits-per-sample this track contains."""
|
|
|
|
return self.__bits_per_sample__
|
|
|
|
def sample_rate(self):
|
|
"""Returns the rate of the track's audio as an integer number of Hz."""
|
|
|
|
return self.__sample_rate__
|
|
|
|
def cd_frames(self):
|
|
"""Returns the total length of the track in CD frames.
|
|
|
|
Each CD frame is 1/75th of a second."""
|
|
|
|
return (self.__length__ - 1024) / self.__sample_rate__ * 75
|
|
|
|
def total_frames(self):
|
|
"""Returns the total PCM frames of the track as an integer."""
|
|
|
|
return self.__length__ - 1024
|
|
|
|
def get_metadata(self):
|
|
"""Returns a MetaData object, or None.
|
|
|
|
Raises IOError if unable to read the file."""
|
|
|
|
f = file(self.filename, 'rb')
|
|
try:
|
|
qt_stream = __Qt_Atom_Stream__(f)
|
|
try:
|
|
meta_atom = ATOM_META.parse(
|
|
qt_stream['moov']['udta']['meta'].data)
|
|
except KeyError:
|
|
return None
|
|
|
|
for atom in meta_atom.atoms:
|
|
if (atom.type == 'ilst'):
|
|
return M4AMetaData([
|
|
ILST_Atom(
|
|
type=ilst_atom.type,
|
|
sub_atoms=[__Qt_Atom__(type=sub_atom.type,
|
|
data=sub_atom.data,
|
|
offset=0)
|
|
for sub_atom in ilst_atom.data])
|
|
for ilst_atom in ATOM_ILST.parse(atom.data)])
|
|
else:
|
|
return None
|
|
finally:
|
|
f.close()
|
|
|
|
def set_metadata(self, metadata):
|
|
"""Takes a MetaData object and sets this track's metadata.
|
|
|
|
This metadata includes track name, album name, and so on.
|
|
Raises IOError if unable to write the file."""
|
|
|
|
metadata = M4AMetaData.converted(metadata)
|
|
if (metadata is None):
|
|
return
|
|
|
|
old_metadata = self.get_metadata()
|
|
if (old_metadata is not None):
|
|
if ('----' in old_metadata.keys()):
|
|
metadata['----'] = old_metadata['----']
|
|
if ('=A9too'.decode('quopri') in old_metadata.keys()):
|
|
metadata['=A9too'.decode('quopri')] = \
|
|
old_metadata['=A9too'.decode('quopri')]
|
|
|
|
new_meta = metadata.to_atom(self.qt_stream['moov']['udta']['meta'])
|
|
|
|
#first, attempt to replace the meta atom by resizing free
|
|
|
|
#check to ensure our file is laid out correctly for that purpose
|
|
if (self.qt_stream.keys() == ['ftyp', 'moov', 'free', 'mdat']):
|
|
old_pre_mdat_size = sum([len(self.qt_stream[atom].data) + 8
|
|
for atom in 'ftyp', 'moov', 'free'])
|
|
|
|
#if so, replace moov's old meta atom with our new one
|
|
new_moov = __replace_qt_atom__(self.qt_stream['moov'],
|
|
new_meta)
|
|
|
|
#and see if we can shrink the free atom enough to fit
|
|
new_pre_mdat_size = (len(self.qt_stream['ftyp'].data) + 8 +
|
|
len(new_moov) + 8)
|
|
|
|
if (new_pre_mdat_size <= old_pre_mdat_size):
|
|
#if we can, replace the start of the file with a new set of
|
|
#ftyp, moov, free atoms while leaving mdat alone
|
|
f = file(self.filename, 'r+b')
|
|
f.write(self.qt_stream['ftyp'].build())
|
|
f.write(new_moov)
|
|
f.write(__build_qt_atom__('free',
|
|
chr(0) * (old_pre_mdat_size -
|
|
new_pre_mdat_size)))
|
|
f.close()
|
|
|
|
f = file(self.filename, "rb")
|
|
self.qt_stream = __Qt_Atom_Stream__(f)
|
|
else:
|
|
self.__set_meta_atom__(new_meta)
|
|
else:
|
|
#otherwise, run a traditional full file replacement
|
|
self.__set_meta_atom__(new_meta)
|
|
|
|
#this updates our old 'meta' atom with a new 'meta' atom
|
|
#where meta_atom is a __Qt_Atom__ object
|
|
def __set_meta_atom__(self, meta_atom):
|
|
#this is a two-pass operation
|
|
#first we replace the contents of the moov->udta->meta atom
|
|
#with our new 'meta' atom
|
|
#this may move the 'mdat' atom, so we must go back
|
|
#and update the contents of
|
|
#moov->trak->mdia->minf->stbl->stco
|
|
#with new offset information
|
|
|
|
stco = ATOM_STCO.parse(
|
|
self.qt_stream['moov']['trak']['mdia']['minf']['stbl']['stco'].data)
|
|
|
|
mdat_offset = stco.offset[0] - self.qt_stream['mdat'].offset
|
|
|
|
new_file = __Qt_Atom_Stream__(cStringIO.StringIO(
|
|
__replace_qt_atom__(self.qt_stream, meta_atom)))
|
|
|
|
mdat_offset = new_file['mdat'].offset + mdat_offset
|
|
|
|
stco.offset = [x - stco.offset[0] + mdat_offset
|
|
for x in stco.offset]
|
|
|
|
new_file = __replace_qt_atom__(new_file,
|
|
__Qt_Atom__('stco',
|
|
ATOM_STCO.build(stco),
|
|
0))
|
|
|
|
f = file(self.filename, "wb")
|
|
f.write(new_file)
|
|
f.close()
|
|
|
|
f = file(self.filename, "rb")
|
|
self.qt_stream = __Qt_Atom_Stream__(f)
|
|
|
|
def delete_metadata(self):
|
|
"""Deletes the track's MetaData.
|
|
|
|
This removes or unsets tags as necessary in order to remove all data.
|
|
Raises IOError if unable to write the file."""
|
|
|
|
self.set_metadata(MetaData())
|
|
|
|
def to_pcm(self):
|
|
"""Returns a PCMReader object containing the track's PCM data."""
|
|
|
|
devnull = file(os.devnull, "ab")
|
|
|
|
sub = subprocess.Popen([BIN['faad'], "-f", str(2), "-w",
|
|
self.filename],
|
|
stdout=subprocess.PIPE,
|
|
stderr=devnull)
|
|
return PCMReader(
|
|
sub.stdout,
|
|
sample_rate=self.sample_rate(),
|
|
channels=self.channels(),
|
|
channel_mask=int(self.channel_mask()),
|
|
bits_per_sample=self.bits_per_sample(),
|
|
process=sub)
|
|
|
|
@classmethod
|
|
def from_pcm(cls, filename, pcmreader, compression=None):
|
|
"""Encodes a new file from PCM data.
|
|
|
|
Takes a filename string, PCMReader object
|
|
and optional compression level string.
|
|
Encodes a new audio file from pcmreader's data
|
|
at the given filename with the specified compression level
|
|
and returns a new M4AAudio object."""
|
|
|
|
if ((compression is None) or
|
|
(compression not in cls.COMPRESSION_MODES)):
|
|
compression = __default_quality__(cls.NAME)
|
|
|
|
if (pcmreader.channels > 2):
|
|
pcmreader = PCMConverter(pcmreader,
|
|
sample_rate=pcmreader.sample_rate,
|
|
channels=2,
|
|
channel_mask=ChannelMask.from_channels(2),
|
|
bits_per_sample=pcmreader.bits_per_sample)
|
|
|
|
#faac requires files to end with .m4a for some reason
|
|
if (not filename.endswith(".m4a")):
|
|
import tempfile
|
|
actual_filename = filename
|
|
tempfile = tempfile.NamedTemporaryFile(suffix=".m4a")
|
|
filename = tempfile.name
|
|
else:
|
|
actual_filename = tempfile = None
|
|
|
|
devnull = file(os.devnull, "ab")
|
|
|
|
sub = subprocess.Popen([BIN['faac'],
|
|
"-q", compression,
|
|
"-P",
|
|
"-R", str(pcmreader.sample_rate),
|
|
"-B", str(pcmreader.bits_per_sample),
|
|
"-C", str(pcmreader.channels),
|
|
"-X",
|
|
"-o", filename,
|
|
"-"],
|
|
stdin=subprocess.PIPE,
|
|
stderr=devnull,
|
|
stdout=devnull,
|
|
preexec_fn=ignore_sigint)
|
|
#Note: faac handles SIGINT on its own,
|
|
#so trying to ignore it doesn't work like on most other encoders.
|
|
|
|
try:
|
|
transfer_framelist_data(pcmreader, sub.stdin.write)
|
|
except (ValueError, IOError), err:
|
|
sub.stdin.close()
|
|
sub.wait()
|
|
cls.__unlink__(filename)
|
|
raise EncodingError(str(err))
|
|
except Exception, err:
|
|
sub.stdin.close()
|
|
sub.wait()
|
|
cls.__unlink__(filename)
|
|
raise err
|
|
|
|
try:
|
|
pcmreader.close()
|
|
except DecodingError, err:
|
|
raise EncodingError(err.error_message)
|
|
sub.stdin.close()
|
|
|
|
if (sub.wait() == 0):
|
|
if (tempfile is not None):
|
|
filename = actual_filename
|
|
f = file(filename, 'wb')
|
|
tempfile.seek(0, 0)
|
|
transfer_data(tempfile.read, f.write)
|
|
f.close()
|
|
tempfile.close()
|
|
|
|
return M4AAudio(filename)
|
|
else:
|
|
if (tempfile is not None):
|
|
tempfile.close()
|
|
raise EncodingError(u"unable to write file with faac")
|
|
|
|
@classmethod
|
|
def can_add_replay_gain(cls):
|
|
"""Returns False."""
|
|
|
|
return False
|
|
|
|
@classmethod
|
|
def lossless_replay_gain(cls):
|
|
"""Returns False."""
|
|
|
|
return False
|
|
|
|
@classmethod
|
|
def add_replay_gain(cls, filenames, progress=None):
|
|
"""Adds ReplayGain values to a list of filename strings.
|
|
|
|
All the filenames must be of this AudioFile type.
|
|
Raises ValueError if some problem occurs during ReplayGain application.
|
|
"""
|
|
|
|
track_names = [track.filename for track in
|
|
open_files(filenames) if
|
|
isinstance(track, cls)]
|
|
|
|
if (progress is not None):
|
|
progress(0, 1)
|
|
|
|
#helpfully, aacgain is flag-for-flag compatible with mp3gain
|
|
if ((len(track_names) > 0) and (BIN.can_execute(BIN['aacgain']))):
|
|
devnull = file(os.devnull, 'ab')
|
|
sub = subprocess.Popen([BIN['aacgain'], '-k', '-q', '-r'] + \
|
|
track_names,
|
|
stdout=devnull,
|
|
stderr=devnull)
|
|
sub.wait()
|
|
|
|
devnull.close()
|
|
|
|
if (progress is not None):
|
|
progress(1, 1)
|
|
|
|
|
|
class M4AAudio_nero(M4AAudio_faac):
|
|
"""An M4A audio file using neroAacEnc/neroAacDec binaries for I/O."""
|
|
|
|
DEFAULT_COMPRESSION = "0.5"
|
|
COMPRESSION_MODES = ("0.0", "0.1", "0.2", "0.3", "0.4", "0.5",
|
|
"0.6", "0.7", "0.8", "0.9", "1.0")
|
|
COMPRESSION_DESCRIPTIONS = {"0.0": _(u"lowest quality, " +
|
|
u"corresponds to neroAacEnc -q 0"),
|
|
"1.0": _(u"highest quality, " +
|
|
u"corresponds to neroAacEnc -q 1")}
|
|
BINARIES = ("neroAacDec", "neroAacEnc")
|
|
|
|
@classmethod
|
|
def from_pcm(cls, filename, pcmreader, compression=None):
|
|
"""Encodes a new file from PCM data.
|
|
|
|
Takes a filename string, PCMReader object
|
|
and optional compression level string.
|
|
Encodes a new audio file from pcmreader's data
|
|
at the given filename with the specified compression level
|
|
and returns a new M4AAudio object."""
|
|
|
|
if ((compression is None) or
|
|
(compression not in cls.COMPRESSION_MODES)):
|
|
compression = __default_quality__(cls.NAME)
|
|
|
|
import tempfile
|
|
tempwavefile = tempfile.NamedTemporaryFile(suffix=".wav")
|
|
try:
|
|
if (pcmreader.sample_rate > 96000):
|
|
tempwave = WaveAudio.from_pcm(
|
|
tempwavefile.name,
|
|
PCMConverter(pcmreader,
|
|
sample_rate=96000,
|
|
channels=pcmreader.channels,
|
|
channel_mask=pcmreader.channel_mask,
|
|
bits_per_sample=pcmreader.bits_per_sample))
|
|
else:
|
|
tempwave = WaveAudio.from_pcm(
|
|
tempwavefile.name,
|
|
pcmreader)
|
|
|
|
cls.__from_wave__(filename, tempwave.filename, compression)
|
|
return cls(filename)
|
|
finally:
|
|
if (os.path.isfile(tempwavefile.name)):
|
|
tempwavefile.close()
|
|
else:
|
|
tempwavefile.close_called = True
|
|
|
|
def to_pcm(self):
|
|
import tempfile
|
|
f = tempfile.NamedTemporaryFile(suffix=".wav")
|
|
try:
|
|
self.to_wave(f.name)
|
|
f.seek(0, 0)
|
|
return TempWaveReader(f)
|
|
except EncodingError, err:
|
|
return PCMReaderError(error_message=err.error_message,
|
|
sample_rate=self.sample_rate(),
|
|
channels=self.channels(),
|
|
channel_mask=int(self.channel_mask()),
|
|
bits_per_sample=self.bits_per_sample())
|
|
|
|
def to_wave(self, wave_file, progress=None):
|
|
"""Writes the contents of this file to the given .wav filename string.
|
|
|
|
Raises EncodingError if some error occurs during decoding."""
|
|
|
|
devnull = file(os.devnull, "w")
|
|
try:
|
|
sub = subprocess.Popen([BIN["neroAacDec"],
|
|
"-if", self.filename,
|
|
"-of", wave_file],
|
|
stdout=devnull,
|
|
stderr=devnull)
|
|
if (sub.wait() != 0):
|
|
raise EncodingError(u"unable to write file with neroAacDec")
|
|
finally:
|
|
devnull.close()
|
|
|
|
@classmethod
|
|
def from_wave(cls, filename, wave_filename, compression=None,
|
|
progress=None):
|
|
"""Encodes a new AudioFile from an existing .wav file.
|
|
|
|
Takes a filename string, wave_filename string
|
|
of an existing WaveAudio file
|
|
and an optional compression level string.
|
|
Encodes a new audio file from the wave's data
|
|
at the given filename with the specified compression level
|
|
and returns a new M4AAudio object."""
|
|
|
|
if ((compression is None) or
|
|
(compression not in cls.COMPRESSION_MODES)):
|
|
compression = __default_quality__(cls.NAME)
|
|
|
|
try:
|
|
wave = WaveAudio(wave_filename)
|
|
wave.verify()
|
|
except InvalidFile:
|
|
raise EncodingError(u"invalid wave file")
|
|
|
|
if (wave.sample_rate > 96000):
|
|
#convert through PCMConverter if sample rate is too high
|
|
import tempfile
|
|
tempwavefile = tempfile.NamedTemporaryFile(suffix=".wav")
|
|
try:
|
|
tempwave = WaveAudio.from_pcm(
|
|
tempwavefile.name,
|
|
PCMConverter(to_pcm_progress(wave, progress),
|
|
sample_rate=96000,
|
|
channels=wave.channels(),
|
|
channel_mask=wave.channel_mask(),
|
|
bits_per_sample=wave.bits_per_sample()))
|
|
return cls.__from_wave__(filename, tempwave.filename,
|
|
compression)
|
|
finally:
|
|
if (os.path.isfile(tempwavefile.name)):
|
|
tempwavefile.close()
|
|
else:
|
|
tempwavefile.close_called = True
|
|
else:
|
|
return cls.__from_wave__(filename, wave_filename, compression)
|
|
|
|
@classmethod
|
|
def __from_wave__(cls, filename, wave_filename, compression):
|
|
devnull = file(os.devnull, "w")
|
|
try:
|
|
sub = subprocess.Popen([BIN["neroAacEnc"],
|
|
"-q", compression,
|
|
"-if", wave_filename,
|
|
"-of", filename],
|
|
stdout=devnull,
|
|
stderr=devnull)
|
|
|
|
if (sub.wait() != 0):
|
|
raise EncodingError(u"neroAacEnc unable to write file")
|
|
else:
|
|
return cls(filename)
|
|
finally:
|
|
devnull.close()
|
|
|
|
if (BIN.can_execute(BIN["neroAacEnc"]) and
|
|
BIN.can_execute(BIN["neroAacDec"])):
|
|
M4AAudio = M4AAudio_nero
|
|
else:
|
|
M4AAudio = M4AAudio_faac
|
|
|
|
|
|
class ILST_Atom:
|
|
"""An ILST sub-atom, which itself is a container for other atoms.
|
|
|
|
For human-readable fields, those will contain a single DATA sub-atom
|
|
containing the data itself.
|
|
For instance:
|
|
|
|
'ilst' atom
|
|
|
|
|
+-'\xa9nam' atom
|
|
|
|
|
+-'data' atom
|
|
|
|
|
+-'\x00\x00\x00\x01\x00\x00\x00\x00Track Name' data
|
|
"""
|
|
|
|
#type is a string
|
|
#sub_atoms is a list of __Qt_Atom__-compatible sub-atom objects
|
|
def __init__(self, type, sub_atoms):
|
|
self.type = type
|
|
self.data = sub_atoms
|
|
|
|
def __eq__(self, o):
|
|
if (hasattr(o, "type") and
|
|
hasattr(o, "data")):
|
|
return ((self.type == o.type) and
|
|
(self.data == o.data))
|
|
else:
|
|
return False
|
|
|
|
def __len__(self):
|
|
return len(self.data)
|
|
|
|
def __repr__(self):
|
|
return "ILST_Atom(%s,%s)" % (repr(self.type),
|
|
repr(self.data))
|
|
|
|
def __unicode__(self):
|
|
for atom in self.data:
|
|
if (atom.type == 'data'):
|
|
if (atom.data.startswith('0000000100000000'.decode('hex'))):
|
|
return atom.data[8:].decode('utf-8')
|
|
elif (self.type == 'trkn'):
|
|
trkn = ATOM_TRKN.parse(atom.data[8:])
|
|
if (trkn.total_tracks > 0):
|
|
return u"%d/%d" % (trkn.track_number,
|
|
trkn.total_tracks)
|
|
else:
|
|
return unicode(trkn.track_number)
|
|
elif (self.type == 'disk'):
|
|
disk = ATOM_DISK.parse(atom.data[8:])
|
|
if (disk.total_disks > 0):
|
|
return u"%d/%d" % (disk.disk_number,
|
|
disk.total_disks)
|
|
else:
|
|
return unicode(disk.disk_number)
|
|
else:
|
|
if (len(atom.data) > 28):
|
|
return unicode(
|
|
atom.data[8:20].encode('hex').upper()) + u"\u2026"
|
|
else:
|
|
return unicode(atom.data[8:].encode('hex'))
|
|
else:
|
|
return u""
|
|
|
|
def __str__(self):
|
|
for atom in self.data:
|
|
if (atom.type == 'data'):
|
|
return atom.data
|
|
else:
|
|
return ""
|
|
|
|
|
|
class M4AMetaData(MetaData, dict):
|
|
"""meta atoms are typically laid out like:
|
|
|
|
meta
|
|
|-hdlr
|
|
|-ilst
|
|
| |- nam
|
|
| | \-data
|
|
| \-trkn
|
|
| \-data
|
|
\-free
|
|
|
|
where the stuff we're interested in is in ilst
|
|
and its data grandchild atoms.
|
|
"""
|
|
# iTunes ID:
|
|
ATTRIBUTE_MAP = {
|
|
'track_name': '=A9nam'.decode('quopri'), # Name
|
|
'artist_name': '=A9ART'.decode('quopri'), # Artist
|
|
'year': '=A9day'.decode('quopri'), # Year
|
|
'track_number': 'trkn', # Track Number
|
|
'track_total': 'trkn',
|
|
'album_name': '=A9alb'.decode('quopri'), # Album
|
|
'album_number': 'disk', # Disc Number
|
|
'album_total': 'disk',
|
|
'composer_name': '=A9wrt'.decode('quopri'), # Composer
|
|
'comment': '=A9cmt'.decode('quopri'), # Comments
|
|
'copyright': 'cprt'} # (not listed)
|
|
|
|
def __init__(self, ilst_atoms):
|
|
dict.__init__(self)
|
|
for ilst_atom in ilst_atoms:
|
|
self.setdefault(ilst_atom.type, []).append(ilst_atom)
|
|
|
|
@classmethod
|
|
def binary_atom(cls, key, value):
|
|
"""Generates a binary ILST_Atom list from key and value strings.
|
|
|
|
The returned list is suitable for adding to our internal dict."""
|
|
|
|
return [ILST_Atom(key,
|
|
[__Qt_Atom__(
|
|
"data",
|
|
value,
|
|
0)])]
|
|
|
|
@classmethod
|
|
def text_atom(cls, key, text):
|
|
"""Generates a text ILST_Atom list from key and text values.
|
|
|
|
key is a binary string, text is a unicode string.
|
|
The returned list is suitable for adding to our internal dict."""
|
|
|
|
return cls.binary_atom(key, '0000000100000000'.decode('hex') + \
|
|
text.encode('utf-8'))
|
|
|
|
@classmethod
|
|
def trkn_atom(cls, track_number, track_total):
|
|
"""Generates a trkn ILST_Atom list from integer values."""
|
|
|
|
return cls.binary_atom('trkn',
|
|
'0000000000000000'.decode('hex') + \
|
|
ATOM_TRKN.build(
|
|
Con.Container(
|
|
track_number=track_number,
|
|
total_tracks=track_total)))
|
|
|
|
@classmethod
|
|
def disk_atom(cls, disk_number, disk_total):
|
|
"""Generates a disk ILST_Atom list from integer values."""
|
|
|
|
return cls.binary_atom('disk',
|
|
'0000000000000000'.decode('hex') + \
|
|
ATOM_DISK.build(
|
|
Con.Container(
|
|
disk_number=disk_number,
|
|
total_disks=disk_total)))
|
|
|
|
@classmethod
|
|
def covr_atom(cls, image_data):
|
|
"""Generates a covr ILST_Atom list from raw image binary data."""
|
|
|
|
return cls.binary_atom('covr',
|
|
'0000000000000000'.decode('hex') + \
|
|
image_data)
|
|
|
|
#if an attribute is updated (e.g. self.track_name)
|
|
#make sure to update the corresponding dict pair
|
|
def __setattr__(self, key, value):
|
|
if (key in self.ATTRIBUTE_MAP.keys()):
|
|
if (key not in MetaData.__INTEGER_FIELDS__):
|
|
self[self.ATTRIBUTE_MAP[key]] = self.__class__.text_atom(
|
|
self.ATTRIBUTE_MAP[key],
|
|
value)
|
|
|
|
elif (key == 'track_number'):
|
|
self[self.ATTRIBUTE_MAP[key]] = self.__class__.trkn_atom(
|
|
int(value), self.track_total)
|
|
|
|
elif (key == 'track_total'):
|
|
self[self.ATTRIBUTE_MAP[key]] = self.__class__.trkn_atom(
|
|
self.track_number, int(value))
|
|
|
|
elif (key == 'album_number'):
|
|
self[self.ATTRIBUTE_MAP[key]] = self.__class__.disk_atom(
|
|
int(value), self.album_total)
|
|
|
|
elif (key == 'album_total'):
|
|
self[self.ATTRIBUTE_MAP[key]] = self.__class__.disk_atom(
|
|
self.album_number, int(value))
|
|
|
|
def __getattr__(self, key):
|
|
if (key == 'track_number'):
|
|
return ATOM_TRKN.parse(
|
|
str(self.get('trkn', [chr(0) * 16])[0])[8:]).track_number
|
|
elif (key == 'track_total'):
|
|
return ATOM_TRKN.parse(
|
|
str(self.get('trkn', [chr(0) * 16])[0])[8:]).total_tracks
|
|
elif (key == 'album_number'):
|
|
return ATOM_DISK.parse(
|
|
str(self.get('disk', [chr(0) * 14])[0])[8:]).disk_number
|
|
elif (key == 'album_total'):
|
|
return ATOM_DISK.parse(
|
|
str(self.get('disk', [chr(0) * 14])[0])[8:]).total_disks
|
|
elif (key in self.ATTRIBUTE_MAP):
|
|
return unicode(self.get(self.ATTRIBUTE_MAP[key], [u''])[0])
|
|
elif (key in MetaData.__FIELDS__):
|
|
return u''
|
|
else:
|
|
try:
|
|
return self.__dict__[key]
|
|
except KeyError:
|
|
raise AttributeError(key)
|
|
|
|
def __delattr__(self, key):
|
|
if (key == 'track_number'):
|
|
setattr(self, 'track_number', 0)
|
|
if ((self.track_number == 0) and (self.track_total == 0)):
|
|
del(self['trkn'])
|
|
elif (key == 'track_total'):
|
|
setattr(self, 'track_total', 0)
|
|
if ((self.track_number == 0) and (self.track_total == 0)):
|
|
del(self['trkn'])
|
|
elif (key == 'album_number'):
|
|
setattr(self, 'album_number', 0)
|
|
if ((self.album_number == 0) and (self.album_total == 0)):
|
|
del(self['disk'])
|
|
elif (key == 'album_total'):
|
|
setattr(self, 'album_total', 0)
|
|
if ((self.album_number == 0) and (self.album_total == 0)):
|
|
del(self['disk'])
|
|
elif (key in self.ATTRIBUTE_MAP):
|
|
if (self.ATTRIBUTE_MAP[key] in self):
|
|
del(self[self.ATTRIBUTE_MAP[key]])
|
|
elif (key in MetaData.__FIELDS__):
|
|
pass
|
|
else:
|
|
try:
|
|
del(self.__dict__[key])
|
|
except KeyError:
|
|
raise AttributeError(key)
|
|
|
|
def images(self):
|
|
"""Returns a list of embedded Image objects."""
|
|
|
|
try:
|
|
return [M4ACovr(str(i)[8:]) for i in self['covr']
|
|
if (len(str(i)) > 8)]
|
|
except KeyError:
|
|
return list()
|
|
|
|
def add_image(self, image):
|
|
"""Embeds an Image object in this metadata."""
|
|
|
|
if (image.type == 0):
|
|
self.setdefault('covr', []).append(self.__class__.covr_atom(
|
|
image.data)[0])
|
|
|
|
def delete_image(self, image):
|
|
"""Deletes an Image object from this metadata."""
|
|
|
|
i = 0
|
|
for image_atom in self.get('covr', []):
|
|
if (str(image_atom)[8:] == image.data):
|
|
del(self['covr'][i])
|
|
break
|
|
|
|
@classmethod
|
|
def converted(cls, metadata):
|
|
"""Converts a MetaData object to a M4AMetaData object."""
|
|
|
|
if ((metadata is None) or (isinstance(metadata, M4AMetaData))):
|
|
return metadata
|
|
|
|
m4a = M4AMetaData([])
|
|
|
|
for (field, key) in cls.ATTRIBUTE_MAP.items():
|
|
value = getattr(metadata, field)
|
|
if (field not in cls.__INTEGER_FIELDS__):
|
|
if (value != u''):
|
|
m4a[key] = cls.text_atom(key, value)
|
|
|
|
if ((metadata.track_number != 0) or
|
|
(metadata.track_total != 0)):
|
|
m4a['trkn'] = cls.trkn_atom(metadata.track_number,
|
|
metadata.track_total)
|
|
|
|
if ((metadata.album_number != 0) or
|
|
(metadata.album_total != 0)):
|
|
m4a['disk'] = cls.disk_atom(metadata.album_number,
|
|
metadata.album_total)
|
|
|
|
if (len(metadata.front_covers()) > 0):
|
|
m4a['covr'] = [cls.covr_atom(i.data)[0]
|
|
for i in metadata.front_covers()]
|
|
|
|
m4a['cpil'] = cls.binary_atom(
|
|
'cpil',
|
|
'0000001500000000'.decode('hex') + chr(1))
|
|
|
|
return m4a
|
|
|
|
def merge(self, metadata):
|
|
"""Updates any currently empty entries from metadata's values."""
|
|
|
|
metadata = self.__class__.converted(metadata)
|
|
if (metadata is None):
|
|
return
|
|
|
|
for (key, values) in metadata.items():
|
|
if ((key not in 'trkn', 'disk') and
|
|
(len(values) > 0) and
|
|
(len(self.get(key, [])) == 0)):
|
|
self[key] = values
|
|
for attr in ("track_number", "track_total",
|
|
"album_number", "album_total"):
|
|
if ((getattr(self, attr) == 0) and
|
|
(getattr(metadata, attr) != 0)):
|
|
setattr(self, attr, getattr(metadata, attr))
|
|
|
|
def to_atom(self, previous_meta):
|
|
"""Returns a 'meta' __Qt_Atom__ object from this M4AMetaData."""
|
|
|
|
previous_meta = ATOM_META.parse(previous_meta.data)
|
|
|
|
new_meta = Con.Container(version=previous_meta.version,
|
|
flags=previous_meta.flags,
|
|
atoms=[])
|
|
|
|
ilst = []
|
|
for values in self.values():
|
|
for ilst_atom in values:
|
|
ilst.append(Con.Container(type=ilst_atom.type,
|
|
data=[
|
|
Con.Container(type=sub_atom.type,
|
|
data=sub_atom.data)
|
|
for sub_atom in ilst_atom.data]))
|
|
|
|
#port the non-ilst atoms from old atom to new atom directly
|
|
#
|
|
for sub_atom in previous_meta.atoms:
|
|
if (sub_atom.type == 'ilst'):
|
|
new_meta.atoms.append(Con.Container(
|
|
type='ilst',
|
|
data=ATOM_ILST.build(ilst)))
|
|
else:
|
|
new_meta.atoms.append(sub_atom)
|
|
|
|
return __Qt_Atom__(
|
|
'meta',
|
|
ATOM_META.build(new_meta),
|
|
0)
|
|
|
|
def __comment_name__(self):
|
|
return u'M4A'
|
|
|
|
@classmethod
|
|
def supports_images(self):
|
|
"""Returns True."""
|
|
|
|
return True
|
|
|
|
@classmethod
|
|
def __by_pair__(cls, pair1, pair2):
|
|
KEY_MAP = {" nam": 1,
|
|
" ART": 6,
|
|
" com": 5,
|
|
" alb": 2,
|
|
"trkn": 3,
|
|
"disk": 4,
|
|
"----": 8}
|
|
|
|
return cmp((KEY_MAP.get(pair1[0], 7), pair1[0], pair1[1]),
|
|
(KEY_MAP.get(pair2[0], 7), pair2[0], pair2[1]))
|
|
|
|
def __comment_pairs__(self):
|
|
pairs = []
|
|
for (key, values) in self.items():
|
|
for value in values:
|
|
pairs.append((key.replace(chr(0xA9), ' '), unicode(value)))
|
|
pairs.sort(M4AMetaData.__by_pair__)
|
|
return pairs
|
|
|
|
|
|
class M4ACovr(Image):
|
|
"""A subclass of Image to store M4A 'covr' atoms."""
|
|
|
|
def __init__(self, image_data):
|
|
self.image_data = image_data
|
|
|
|
img = Image.new(image_data, u'', 0)
|
|
|
|
Image.__init__(self,
|
|
data=image_data,
|
|
mime_type=img.mime_type,
|
|
width=img.width,
|
|
height=img.height,
|
|
color_depth=img.color_depth,
|
|
color_count=img.color_count,
|
|
description=img.description,
|
|
type=img.type)
|
|
|
|
@classmethod
|
|
def converted(cls, image):
|
|
"""Given an Image object, returns an M4ACovr object."""
|
|
|
|
return M4ACovr(image.data)
|
|
|
|
|
|
class __counter__:
|
|
def __init__(self):
|
|
self.val = 0
|
|
|
|
def incr(self):
|
|
self.val += 1
|
|
|
|
def __int__(self):
|
|
return self.val
|
|
|
|
|
|
class InvalidALAC(InvalidFile):
|
|
pass
|
|
|
|
|
|
class ALACAudio(M4AAudio_faac):
|
|
"""An Apple Lossless audio file."""
|
|
|
|
SUFFIX = "m4a"
|
|
NAME = "alac"
|
|
DEFAULT_COMPRESSION = ""
|
|
COMPRESSION_MODES = ("",)
|
|
BINARIES = tuple()
|
|
|
|
ALAC_ATOM = Con.Struct("stsd_alac",
|
|
Con.String("reserved", 6),
|
|
Con.UBInt16("reference_index"),
|
|
Con.UBInt16("version"),
|
|
Con.UBInt16("revision_level"),
|
|
Con.String("vendor", 4),
|
|
Con.UBInt16("channels"),
|
|
Con.UBInt16("bits_per_sample"),
|
|
Con.UBInt16("compression_id"),
|
|
Con.UBInt16("audio_packet_size"),
|
|
#this sample rate always seems to be 0xAC440000
|
|
#no matter what the other sample rate fields are
|
|
Con.Bytes("sample_rate", 4),
|
|
Con.Struct("alac",
|
|
Con.UBInt32("length"),
|
|
Con.Const(Con.String("type", 4),
|
|
'alac'),
|
|
Con.Padding(4),
|
|
Con.UBInt32("max_samples_per_frame"),
|
|
Con.Padding(1),
|
|
Con.UBInt8("sample_size"),
|
|
Con.UBInt8("history_multiplier"),
|
|
Con.UBInt8("initial_history"),
|
|
Con.UBInt8("maximum_k"),
|
|
Con.UBInt8("channels"),
|
|
Con.UBInt16("unknown"),
|
|
Con.UBInt32("max_coded_frame_size"),
|
|
Con.UBInt32("bitrate"),
|
|
Con.UBInt32("sample_rate")))
|
|
|
|
ALAC_FTYP = AtomWrapper("ftyp", ATOM_FTYP)
|
|
|
|
ALAC_MOOV = AtomWrapper(
|
|
"moov", Con.Struct(
|
|
"moov",
|
|
AtomWrapper("mvhd", ATOM_MVHD),
|
|
AtomWrapper("trak", Con.Struct(
|
|
"trak",
|
|
AtomWrapper("tkhd", ATOM_TKHD),
|
|
AtomWrapper("mdia", Con.Struct(
|
|
"mdia",
|
|
AtomWrapper("mdhd", ATOM_MDHD),
|
|
AtomWrapper("hdlr", ATOM_HDLR),
|
|
AtomWrapper("minf", Con.Struct(
|
|
"minf",
|
|
AtomWrapper("smhd", ATOM_SMHD),
|
|
AtomWrapper("dinf", Con.Struct(
|
|
"dinf",
|
|
AtomWrapper("dref", ATOM_DREF))),
|
|
AtomWrapper("stbl", Con.Struct(
|
|
"stbl",
|
|
AtomWrapper("stsd", ATOM_STSD),
|
|
AtomWrapper("stts", ATOM_STTS),
|
|
AtomWrapper("stsc", ATOM_STSC),
|
|
AtomWrapper("stsz", ATOM_STSZ),
|
|
AtomWrapper("stco", ATOM_STCO))))))))),
|
|
AtomWrapper("udta", Con.Struct(
|
|
"udta",
|
|
AtomWrapper("meta", ATOM_META)))))
|
|
|
|
BLOCK_SIZE = 4096
|
|
INITIAL_HISTORY = 10
|
|
HISTORY_MULTIPLIER = 40
|
|
MAXIMUM_K = 14
|
|
|
|
def __init__(self, filename):
|
|
"""filename is a plain string."""
|
|
|
|
self.filename = filename
|
|
try:
|
|
self.qt_stream = __Qt_Atom_Stream__(file(self.filename, "rb"))
|
|
except IOError, msg:
|
|
raise InvalidALAC(str(msg))
|
|
|
|
try:
|
|
alac = ALACAudio.ALAC_ATOM.parse(
|
|
ATOM_STSD.parse(self.qt_stream['moov']['trak']['mdia'][
|
|
'minf']['stbl']['stsd'].data).descriptions[0].data)
|
|
|
|
self.__channels__ = alac.alac.channels
|
|
self.__bits_per_sample__ = alac.bits_per_sample
|
|
self.__sample_rate__ = alac.alac.sample_rate
|
|
|
|
mdhd = M4AAudio.MDHD_ATOM.parse(
|
|
self.qt_stream['moov']['trak']['mdia']['mdhd'].data)
|
|
|
|
self.__length__ = mdhd.track_length
|
|
except KeyError:
|
|
raise InvalidALAC(_(u'Required moov atom not found'))
|
|
|
|
@classmethod
|
|
def is_type(cls, file):
|
|
"""Returns True if the given file object describes this format.
|
|
|
|
Takes a seekable file pointer rewound to the start of the file."""
|
|
|
|
header = file.read(12)
|
|
|
|
if ((header[4:8] == 'ftyp') and
|
|
(header[8:12] in ('mp41', 'mp42', 'M4A ', 'M4B '))):
|
|
file.seek(0, 0)
|
|
atoms = __Qt_Atom_Stream__(file)
|
|
try:
|
|
return (ATOM_STSD.parse(atoms['moov']['trak']['mdia']['minf']['stbl']['stsd'].data).descriptions[0].type == 'alac')
|
|
except (Con.ConstError, Con.FieldError, Con.ArrayError, KeyError,
|
|
IndexError):
|
|
return False
|
|
else:
|
|
return False
|
|
|
|
def total_frames(self):
|
|
"""Returns the total PCM frames of the track as an integer."""
|
|
|
|
return self.__length__
|
|
|
|
def channel_mask(self):
|
|
"""Returns a ChannelMask object of this track's channel layout."""
|
|
|
|
try:
|
|
#FIXME - see if it's possible to find an actual channel mask
|
|
#for multichannel ALAC audio
|
|
return ChannelMask.from_channels(self.channels())
|
|
except ValueError:
|
|
return ChannelMask(0)
|
|
|
|
def cd_frames(self):
|
|
"""Returns the total length of the track in CD frames.
|
|
|
|
Each CD frame is 1/75th of a second."""
|
|
|
|
try:
|
|
return (self.total_frames() * 75) / self.sample_rate()
|
|
except ZeroDivisionError:
|
|
return 0
|
|
|
|
def lossless(self):
|
|
"""Returns True."""
|
|
|
|
return True
|
|
|
|
def to_pcm(self):
|
|
"""Returns a PCMReader object containing the track's PCM data."""
|
|
|
|
import audiotools.decoders
|
|
|
|
try:
|
|
f = file(self.filename, 'rb')
|
|
qt = __Qt_Atom_Stream__(f)
|
|
alac = ALACAudio.ALAC_ATOM.parse(
|
|
ATOM_STSD.parse(
|
|
qt['moov']['trak']['mdia']['minf']['stbl'][
|
|
'stsd'].data).descriptions[0].data).alac
|
|
f.close()
|
|
|
|
return audiotools.decoders.ALACDecoder(
|
|
filename=self.filename,
|
|
sample_rate=alac.sample_rate,
|
|
channels=alac.channels,
|
|
channel_mask=self.channel_mask(),
|
|
bits_per_sample=alac.sample_size,
|
|
total_frames=self.total_frames(),
|
|
max_samples_per_frame=alac.max_samples_per_frame,
|
|
history_multiplier=alac.history_multiplier,
|
|
initial_history=alac.initial_history,
|
|
maximum_k=alac.maximum_k)
|
|
except (Con.FieldError, Con.ArrayError, IOError, ValueError), msg:
|
|
return PCMReaderError(error_message=str(msg),
|
|
sample_rate=self.sample_rate(),
|
|
channels=self.channels(),
|
|
channel_mask=int(self.channel_mask()),
|
|
bits_per_sample=self.bits_per_sample())
|
|
|
|
@classmethod
|
|
def from_pcm(cls, filename, pcmreader, compression=None,
|
|
block_size=4096):
|
|
"""Encodes a new file from PCM data.
|
|
|
|
Takes a filename string, PCMReader object
|
|
and optional compression level string.
|
|
Encodes a new audio file from pcmreader's data
|
|
at the given filename with the specified compression level
|
|
and returns a new ALACAudio object."""
|
|
|
|
if (pcmreader.bits_per_sample not in (16, 24)):
|
|
raise UnsupportedBitsPerSample(filename, pcmreader.bits_per_sample)
|
|
|
|
from . import encoders
|
|
import time
|
|
import tempfile
|
|
|
|
mdat_file = tempfile.TemporaryFile()
|
|
|
|
#perform encode_alac() on pcmreader to our output file
|
|
#which returns a tuple of output values:
|
|
#(framelist, - a list of (frame_samples,frame_size,frame_offset) tuples
|
|
# various fields for the "alac" atom)
|
|
try:
|
|
(frame_sample_sizes,
|
|
frame_byte_sizes,
|
|
frame_file_offsets,
|
|
mdat_size) = encoders.encode_alac(
|
|
file=mdat_file,
|
|
pcmreader=BufferedPCMReader(pcmreader),
|
|
block_size=block_size,
|
|
initial_history=cls.INITIAL_HISTORY,
|
|
history_multiplier=cls.HISTORY_MULTIPLIER,
|
|
maximum_k=cls.MAXIMUM_K)
|
|
except (IOError, ValueError), err:
|
|
raise EncodingError(str(err))
|
|
|
|
#use the fields from encode_alac() to populate our ALAC atoms
|
|
create_date = long(time.time()) + 2082844800
|
|
total_pcm_frames = sum(frame_sample_sizes)
|
|
|
|
stts_frame_counts = {}
|
|
for sample_size in frame_sample_sizes:
|
|
stts_frame_counts.setdefault(sample_size, __counter__()).incr()
|
|
|
|
offsets = frame_file_offsets[:]
|
|
chunks = []
|
|
for frames in at_a_time(len(frame_file_offsets), 5):
|
|
if (frames > 0):
|
|
chunks.append(offsets[0:frames])
|
|
offsets = offsets[frames:]
|
|
del(offsets)
|
|
|
|
#add the size of ftyp + moov + free to our absolute file offsets
|
|
pre_mdat_size = (len(cls.__build_ftyp_atom__()) +
|
|
len(cls.__build_moov_atom__(pcmreader,
|
|
create_date,
|
|
mdat_size,
|
|
total_pcm_frames,
|
|
frame_sample_sizes,
|
|
stts_frame_counts,
|
|
chunks,
|
|
frame_byte_sizes)) +
|
|
len(cls.__build_free_atom__(0x1000)))
|
|
chunks = [[chunk + pre_mdat_size for chunk in chunk_list]
|
|
for chunk_list in chunks]
|
|
|
|
#then regenerate our live ftyp, moov and free atoms
|
|
#with actual data
|
|
ftyp = cls.__build_ftyp_atom__()
|
|
|
|
moov = cls.__build_moov_atom__(pcmreader,
|
|
create_date,
|
|
mdat_size,
|
|
total_pcm_frames,
|
|
frame_sample_sizes,
|
|
stts_frame_counts,
|
|
chunks,
|
|
frame_byte_sizes)
|
|
|
|
free = cls.__build_free_atom__(0x1000)
|
|
|
|
#build our complete output file
|
|
try:
|
|
f = file(filename, 'wb')
|
|
|
|
mdat_file.seek(0, 0)
|
|
f.write(ftyp)
|
|
f.write(moov)
|
|
f.write(free)
|
|
transfer_data(mdat_file.read, f.write)
|
|
f.close()
|
|
mdat_file.close()
|
|
except (IOError), err:
|
|
mdat_file.close()
|
|
raise EncodingError(str(err))
|
|
|
|
return cls(filename)
|
|
|
|
@classmethod
|
|
def __build_ftyp_atom__(cls):
|
|
return cls.ALAC_FTYP.build(
|
|
Con.Container(major_brand='M4A ',
|
|
major_brand_version=0,
|
|
compatible_brands=['M4A ',
|
|
'mp42',
|
|
'isom',
|
|
chr(0) * 4]))
|
|
|
|
@classmethod
|
|
def __build_moov_atom__(cls, pcmreader,
|
|
create_date,
|
|
mdat_size,
|
|
total_pcm_frames,
|
|
frame_sample_sizes,
|
|
stts_frame_counts,
|
|
chunks,
|
|
frame_byte_sizes):
|
|
version = (chr(0) * 3) + chr(1) + (chr(0) * 4) + (
|
|
"Python Audio Tools %s" % (VERSION))
|
|
|
|
tool = Con.Struct('tool',
|
|
Con.UBInt32('size'),
|
|
Con.String('type', 4),
|
|
Con.Struct('data',
|
|
Con.UBInt32('size'),
|
|
Con.String('type', 4),
|
|
Con.String(
|
|
'data',
|
|
lambda ctx: ctx["size"] - 8))).build(
|
|
Con.Container(size=len(version) + 16,
|
|
type=chr(0xa9) + 'too',
|
|
data=Con.Container(size=len(version) + 8,
|
|
type='data',
|
|
data=version)))
|
|
|
|
return cls.ALAC_MOOV.build(
|
|
Con.Container(
|
|
mvhd=Con.Container(version=0,
|
|
flags=chr(0) * 3,
|
|
created_mac_UTC_date=create_date,
|
|
modified_mac_UTC_date=create_date,
|
|
time_scale=pcmreader.sample_rate,
|
|
duration=total_pcm_frames,
|
|
playback_speed=0x10000,
|
|
user_volume=0x100,
|
|
windows=Con.Container(
|
|
geometry_matrix_a=0x10000,
|
|
geometry_matrix_b=0,
|
|
geometry_matrix_u=0,
|
|
geometry_matrix_c=0,
|
|
geometry_matrix_d=0x10000,
|
|
geometry_matrix_v=0,
|
|
geometry_matrix_x=0,
|
|
geometry_matrix_y=0,
|
|
geometry_matrix_w=0x40000000),
|
|
quicktime_preview=0,
|
|
quicktime_still_poster=0,
|
|
quicktime_selection_time=0,
|
|
quicktime_current_time=0,
|
|
next_track_id=2),
|
|
trak=Con.Container(
|
|
tkhd=Con.Container(version=0,
|
|
flags=Con.Container(
|
|
TrackInPoster=0,
|
|
TrackInPreview=1,
|
|
TrackInMovie=1,
|
|
TrackEnabled=1),
|
|
created_mac_UTC_date=create_date,
|
|
modified_mac_UTC_date=create_date,
|
|
track_id=1,
|
|
duration=total_pcm_frames,
|
|
video_layer=0,
|
|
quicktime_alternate=0,
|
|
volume=0x100,
|
|
video=Con.Container(
|
|
geometry_matrix_a=0x10000,
|
|
geometry_matrix_b=0,
|
|
geometry_matrix_u=0,
|
|
geometry_matrix_c=0,
|
|
geometry_matrix_d=0x10000,
|
|
geometry_matrix_v=0,
|
|
geometry_matrix_x=0,
|
|
geometry_matrix_y=0,
|
|
geometry_matrix_w=0x40000000),
|
|
video_width=0,
|
|
video_height=0),
|
|
mdia=Con.Container(
|
|
mdhd=Con.Container(version=0,
|
|
flags=chr(0) * 3,
|
|
created_mac_UTC_date=create_date,
|
|
modified_mac_UTC_date=create_date,
|
|
time_scale=pcmreader.sample_rate,
|
|
duration=total_pcm_frames,
|
|
languages=Con.Container(
|
|
language=[0x15, 0x0E, 0x04]),
|
|
quicktime_quality=0),
|
|
hdlr=Con.Container(
|
|
version=0,
|
|
flags=chr(0) * 3,
|
|
quicktime_type=chr(0) * 4,
|
|
subtype='soun',
|
|
quicktime_manufacturer=chr(0) * 4,
|
|
quicktime_component_reserved_flags=0,
|
|
quicktime_component_reserved_flags_mask=0,
|
|
component_name=""),
|
|
minf=Con.Container(
|
|
smhd=Con.Container(version=0,
|
|
flags=chr(0) * 3,
|
|
audio_balance=chr(0) * 2),
|
|
dinf=Con.Container(dref=Con.Container(
|
|
version=0,
|
|
flags=chr(0) * 3,
|
|
references=[Con.Container(
|
|
size=12,
|
|
type='url ',
|
|
data="\x00\x00\x00\x01")])),
|
|
stbl=Con.Container(stsd=Con.Container(
|
|
version=0,
|
|
flags=chr(0) * 3,
|
|
descriptions=[Con.Container(
|
|
type="alac",
|
|
data=cls.ALAC_ATOM.build(
|
|
Con.Container(
|
|
reserved=chr(0) * 6,
|
|
reference_index=1,
|
|
version=0,
|
|
revision_level=0,
|
|
vendor=chr(0) * 4,
|
|
channels=pcmreader.channels,
|
|
bits_per_sample=pcmreader.bits_per_sample,
|
|
compression_id=0,
|
|
audio_packet_size=0,
|
|
sample_rate=chr(0xAC) + chr(0x44) + chr(0x00) + chr(0x00),
|
|
alac=Con.Container(
|
|
length=36,
|
|
type='alac',
|
|
max_samples_per_frame=max(frame_sample_sizes),
|
|
sample_size=pcmreader.bits_per_sample,
|
|
history_multiplier=cls.HISTORY_MULTIPLIER,
|
|
initial_history=cls.INITIAL_HISTORY,
|
|
maximum_k=cls.MAXIMUM_K,
|
|
channels=pcmreader.channels,
|
|
unknown=0x00FF,
|
|
max_coded_frame_size=max(frame_byte_sizes),
|
|
bitrate=((mdat_size * 8 * pcmreader.sample_rate) / sum(frame_sample_sizes)),
|
|
sample_rate=pcmreader.sample_rate))))]),
|
|
|
|
stts=Con.Container(
|
|
version=0,
|
|
flags=chr(0) * 3,
|
|
frame_size_counts=[
|
|
Con.Container(
|
|
frame_count=int(stts_frame_counts[samples]),
|
|
duration=samples)
|
|
for samples in
|
|
reversed(sorted(stts_frame_counts.keys()))]),
|
|
|
|
stsc=Con.Container(
|
|
version=0,
|
|
flags=chr(0) * 3,
|
|
block=[Con.Container(
|
|
first_chunk=i + 1,
|
|
samples_per_chunk=current,
|
|
sample_description_index=1)
|
|
for (i, (current, previous))
|
|
in enumerate(zip(map(len, chunks),
|
|
[0] + map(len, chunks)))
|
|
if (current != previous)]),
|
|
|
|
stsz=Con.Container(
|
|
version=0,
|
|
flags=chr(0) * 3,
|
|
block_byte_size=0,
|
|
block_byte_sizes=frame_byte_sizes),
|
|
|
|
stco=Con.Container(
|
|
version=0,
|
|
flags=chr(0) * 3,
|
|
offset=[chunk[0] for chunk in chunks]))))),
|
|
udta=Con.Container(
|
|
meta=Con.Container(
|
|
version=0,
|
|
flags=chr(0) * 3,
|
|
atoms=[Con.Container(
|
|
type='hdlr',
|
|
data=ATOM_HDLR.build(
|
|
Con.Container(
|
|
version=0,
|
|
flags=chr(0) * 3,
|
|
quicktime_type=chr(0) * 4,
|
|
subtype='mdir',
|
|
quicktime_manufacturer='appl',
|
|
quicktime_component_reserved_flags=0,
|
|
quicktime_component_reserved_flags_mask=0,
|
|
component_name=""))),
|
|
Con.Container(
|
|
type='ilst',
|
|
data=tool),
|
|
Con.Container(
|
|
type='free',
|
|
data=chr(0) * 1024)]))))
|
|
|
|
@classmethod
|
|
def __build_free_atom__(cls, size):
|
|
return Atom('free').build(Con.Container(
|
|
type='free',
|
|
data=chr(0) * size))
|
|
|
|
#######################
|
|
#AAC File
|
|
#######################
|
|
|
|
|
|
class InvalidAAC(InvalidFile):
|
|
"""Raised if some error occurs parsing AAC audio files."""
|
|
|
|
pass
|
|
|
|
|
|
class AACAudio(AudioFile):
|
|
"""An AAC audio file.
|
|
|
|
This is AAC data inside an ADTS container."""
|
|
|
|
SUFFIX = "aac"
|
|
NAME = SUFFIX
|
|
DEFAULT_COMPRESSION = "100"
|
|
COMPRESSION_MODES = tuple(["10"] + map(str, range(50, 500, 25)) + ["500"])
|
|
BINARIES = ("faac", "faad")
|
|
|
|
AAC_FRAME_HEADER = Con.BitStruct("aac_header",
|
|
Con.Const(Con.Bits("sync", 12),
|
|
0xFFF),
|
|
Con.Bits("mpeg_id", 1),
|
|
Con.Bits("mpeg_layer", 2),
|
|
Con.Flag("protection_absent"),
|
|
Con.Bits("profile", 2),
|
|
Con.Bits("sampling_frequency_index", 4),
|
|
Con.Flag("private"),
|
|
Con.Bits("channel_configuration", 3),
|
|
Con.Bits("original", 1),
|
|
Con.Bits("home", 1),
|
|
Con.Bits("copyright_identification_bit", 1),
|
|
Con.Bits("copyright_identification_start", 1),
|
|
Con.Bits("aac_frame_length", 13),
|
|
Con.Bits("adts_buffer_fullness", 11),
|
|
Con.Bits("no_raw_data_blocks_in_frame", 2),
|
|
Con.If(
|
|
lambda ctx: ctx["protection_absent"] == False,
|
|
Con.Bits("crc_check", 16)))
|
|
|
|
SAMPLE_RATES = [96000, 88200, 64000, 48000,
|
|
44100, 32000, 24000, 22050,
|
|
16000, 12000, 11025, 8000]
|
|
|
|
def __init__(self, filename):
|
|
"""filename is a plain string."""
|
|
|
|
self.filename = filename
|
|
|
|
try:
|
|
f = file(self.filename, "rb")
|
|
except IOError, msg:
|
|
raise InvalidAAC(str(msg))
|
|
try:
|
|
try:
|
|
header = AACAudio.AAC_FRAME_HEADER.parse_stream(f)
|
|
except Con.FieldError:
|
|
raise InvalidAAC(_(u"Invalid ADTS frame header"))
|
|
except Con.ConstError:
|
|
raise InvalidAAC(_(u"Invalid ADTS frame header"))
|
|
f.seek(0, 0)
|
|
self.__channels__ = header.channel_configuration
|
|
self.__bits_per_sample__ = 16 # floating point samples
|
|
self.__sample_rate__ = AACAudio.SAMPLE_RATES[
|
|
header.sampling_frequency_index]
|
|
self.__frame_count__ = AACAudio.aac_frame_count(f)
|
|
finally:
|
|
f.close()
|
|
|
|
@classmethod
|
|
def is_type(cls, file):
|
|
"""Returns True if the given file object describes this format.
|
|
|
|
Takes a seekable file pointer rewound to the start of the file."""
|
|
|
|
try:
|
|
header = AACAudio.AAC_FRAME_HEADER.parse_stream(file)
|
|
return ((header.sync == 0xFFF) and
|
|
(header.mpeg_id == 1) and
|
|
(header.mpeg_layer == 0))
|
|
except:
|
|
return False
|
|
|
|
def bits_per_sample(self):
|
|
"""Returns an integer number of bits-per-sample this track contains."""
|
|
|
|
return self.__bits_per_sample__
|
|
|
|
def channels(self):
|
|
"""Returns an integer number of channels this track contains."""
|
|
|
|
return self.__channels__
|
|
|
|
def lossless(self):
|
|
"""Returns False."""
|
|
|
|
return False
|
|
|
|
def total_frames(self):
|
|
"""Returns the total PCM frames of the track as an integer."""
|
|
|
|
return self.__frame_count__ * 1024
|
|
|
|
def sample_rate(self):
|
|
"""Returns the rate of the track's audio as an integer number of Hz."""
|
|
|
|
return self.__sample_rate__
|
|
|
|
def to_pcm(self):
|
|
"""Returns a PCMReader object containing the track's PCM data."""
|
|
|
|
devnull = file(os.devnull, "ab")
|
|
|
|
sub = subprocess.Popen([BIN['faad'], "-t", "-f", str(2), "-w",
|
|
self.filename],
|
|
stdout=subprocess.PIPE,
|
|
stderr=devnull)
|
|
return PCMReader(sub.stdout,
|
|
sample_rate=self.sample_rate(),
|
|
channels=self.channels(),
|
|
channel_mask=int(self.channel_mask()),
|
|
bits_per_sample=self.bits_per_sample(),
|
|
process=sub)
|
|
|
|
@classmethod
|
|
def from_pcm(cls, filename, pcmreader, compression=None):
|
|
"""Encodes a new file from PCM data.
|
|
|
|
Takes a filename string, PCMReader object
|
|
and optional compression level string.
|
|
Encodes a new audio file from pcmreader's data
|
|
at the given filename with the specified compression level
|
|
and returns a new AACAudio object."""
|
|
|
|
import bisect
|
|
|
|
if ((compression is None) or
|
|
(compression not in cls.COMPRESSION_MODES)):
|
|
compression = __default_quality__(cls.NAME)
|
|
|
|
if (pcmreader.sample_rate not in AACAudio.SAMPLE_RATES):
|
|
sample_rates = list(sorted(AACAudio.SAMPLE_RATES))
|
|
|
|
pcmreader = PCMConverter(
|
|
pcmreader,
|
|
sample_rate=([sample_rates[0]] + sample_rates)[
|
|
bisect.bisect(sample_rates, pcmreader.sample_rate)],
|
|
channels=max(pcmreader.channels, 2),
|
|
channel_mask=ChannelMask.from_channels(
|
|
max(pcmreader.channels, 2)),
|
|
bits_per_sample=pcmreader.bits_per_sample)
|
|
elif (pcmreader.channels > 2):
|
|
pcmreader = PCMConverter(
|
|
pcmreader,
|
|
sample_rate=pcmreader.sample_rate,
|
|
channels=2,
|
|
channel_mask=ChannelMask.from_channels(2),
|
|
bits_per_sample=pcmreader.bits_per_sample)
|
|
|
|
#faac requires files to end with .aac for some reason
|
|
if (not filename.endswith(".aac")):
|
|
import tempfile
|
|
actual_filename = filename
|
|
tempfile = tempfile.NamedTemporaryFile(suffix=".aac")
|
|
filename = tempfile.name
|
|
else:
|
|
actual_filename = tempfile = None
|
|
|
|
devnull = file(os.devnull, "ab")
|
|
|
|
sub = subprocess.Popen([BIN['faac'],
|
|
"-q", compression,
|
|
"-P",
|
|
"-R", str(pcmreader.sample_rate),
|
|
"-B", str(pcmreader.bits_per_sample),
|
|
"-C", str(pcmreader.channels),
|
|
"-X",
|
|
"-o", filename,
|
|
"-"],
|
|
stdin=subprocess.PIPE,
|
|
stderr=devnull,
|
|
preexec_fn=ignore_sigint)
|
|
#Note: faac handles SIGINT on its own,
|
|
#so trying to ignore it doesn't work like on most other encoders.
|
|
|
|
try:
|
|
transfer_framelist_data(pcmreader, sub.stdin.write)
|
|
except (IOError, ValueError), err:
|
|
sub.stdin.close()
|
|
sub.wait()
|
|
cls.__unlink__(filename)
|
|
raise EncodingError(str(err))
|
|
except Exception, err:
|
|
sub.stdin.close()
|
|
sub.wait()
|
|
cls.__unlink__(filename)
|
|
raise err
|
|
|
|
try:
|
|
pcmreader.close()
|
|
except DecodingError, err:
|
|
raise EncodingError(err.error_message)
|
|
sub.stdin.close()
|
|
|
|
if (sub.wait() == 0):
|
|
if (tempfile is not None):
|
|
filename = actual_filename
|
|
f = file(filename, 'wb')
|
|
tempfile.seek(0, 0)
|
|
transfer_data(tempfile.read, f.write)
|
|
f.close()
|
|
tempfile.close()
|
|
|
|
return AACAudio(filename)
|
|
else:
|
|
if (tempfile is not None):
|
|
tempfile.close()
|
|
raise EncodingError(u"error writing file with faac")
|
|
|
|
@classmethod
|
|
def aac_frames(cls, stream):
|
|
"""Takes an open file stream and yields (header, data) tuples.
|
|
|
|
header is a Container parsed from AACAudio.AAC_FRAME_HEADER.
|
|
data is a binary string of frame data."""
|
|
|
|
while (True):
|
|
try:
|
|
header = AACAudio.AAC_FRAME_HEADER.parse_stream(stream)
|
|
except Con.FieldError:
|
|
break
|
|
|
|
if (header.sync != 0xFFF):
|
|
raise InvalidAAC(_(u"Invalid frame sync"))
|
|
|
|
if (header.protection_absent):
|
|
yield (header, stream.read(header.aac_frame_length - 7))
|
|
else:
|
|
yield (header, stream.read(header.aac_frame_length - 9))
|
|
|
|
@classmethod
|
|
def aac_frame_count(cls, stream):
|
|
"""Takes an open file stream and returns the total ADTS frames."""
|
|
|
|
import sys
|
|
total = 0
|
|
while (True):
|
|
try:
|
|
header = AACAudio.AAC_FRAME_HEADER.parse_stream(stream)
|
|
except Con.FieldError:
|
|
break
|
|
|
|
if (header.sync != 0xFFF):
|
|
break
|
|
|
|
total += 1
|
|
|
|
if (header.protection_absent):
|
|
stream.seek(header.aac_frame_length - 7, 1)
|
|
else:
|
|
stream.seek(header.aac_frame_length - 9, 1)
|
|
|
|
return total
|