Add in the audiotools resources

master
Bradlee Speice 2012-12-10 12:55:06 -05:00
parent 51a861abe1
commit 48906a5513
48 changed files with 26321 additions and 0 deletions

View File

@ -0,0 +1,6 @@
import os, sys
def get_resource_dir():
return os.path.dirname(
os.path.abspath(__file__)
)

View File

@ -0,0 +1,751 @@
#!/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, Con, PCMReader,
__capped_stream_reader__, PCMReaderError,
transfer_data, DecodingError, EncodingError,
ID3v22Comment, BUFFER_SIZE, ChannelMask,
ReorderedPCMReader, pcm,
cStringIO, os, AiffContainer, to_pcm_progress)
import gettext
gettext.install("audiotools", unicode=True)
_HUGE_VAL = 1.79769313486231e+308
class IEEE_Extended(Con.Adapter):
"""A construct for handling 80-bit IEEE-extended values."""
def __init__(self, name):
Con.Adapter.__init__(
self,
Con.Struct(name,
Con.Embed(Con.BitStruct(None,
Con.Flag("signed"),
Con.Bits("exponent", 15))),
Con.UBInt64("mantissa")))
def _encode(self, value, context):
import math
if (value < 0):
signed = True
value *= -1
else:
signed = False
(fmant, exponent) = math.frexp(value)
if ((exponent > 16384) or (fmant >= 1)):
exponent = 0x7FFF
mantissa = 0
else:
exponent += 16382
mantissa = fmant * (2 ** 64)
return Con.Container(signed=signed,
exponent=exponent,
mantissa=mantissa)
def _decode(self, obj, context):
if ((obj.exponent == 0) and (obj.mantissa == 0)):
return 0
else:
if (obj.exponent == 0x7FFF):
return _HUGE_VAL
else:
f = obj.mantissa * (2.0 ** (obj.exponent - 16383 - 63))
return f if not obj.signed else -f
#######################
#AIFF
#######################
class AiffReader(PCMReader):
"""A subclass of PCMReader for reading AIFF file contents."""
def __init__(self, aiff_file,
sample_rate, channels, channel_mask, bits_per_sample,
chunk_length, process=None):
"""aiff_file should be rewound to the start of the SSND chunk."""
alignment = AiffAudio.SSND_ALIGN.parse_stream(aiff_file)
PCMReader.__init__(self,
file=__capped_stream_reader__(
aiff_file,
chunk_length - AiffAudio.SSND_ALIGN.sizeof()),
sample_rate=sample_rate,
channels=channels,
channel_mask=channel_mask,
bits_per_sample=bits_per_sample,
process=process,
signed=True,
big_endian=True)
self.ssnd_chunk_length = chunk_length - 8
standard_channel_mask = ChannelMask(self.channel_mask)
aiff_channel_mask = AIFFChannelMask(standard_channel_mask)
if (channels in (3, 4, 6)):
self.channel_order = [aiff_channel_mask.channels().index(channel)
for channel in
standard_channel_mask.channels()]
else:
self.channel_order = None
def read(self, bytes):
"""Try to read a pcm.FrameList of size "bytes"."""
#align bytes downward if an odd number is read in
bytes -= (bytes % (self.channels * self.bits_per_sample / 8))
pcm_data = self.file.read(
max(bytes, self.channels * self.bits_per_sample / 8))
if ((len(pcm_data) == 0) and (self.ssnd_chunk_length > 0)):
raise IOError("ssnd chunk ends prematurely")
else:
self.ssnd_chunk_length -= len(pcm_data)
try:
framelist = pcm.FrameList(pcm_data,
self.channels,
self.bits_per_sample,
True, True)
if (self.channel_order is not None):
return pcm.from_channels([framelist.channel(channel)
for channel in self.channel_order])
else:
return framelist
except ValueError:
raise IOError("ssnd chunk ends prematurely")
class InvalidAIFF(InvalidFile):
"""Raised if some problem occurs parsing AIFF chunks."""
pass
class AiffAudio(AiffContainer):
"""An AIFF audio file."""
SUFFIX = "aiff"
NAME = SUFFIX
AIFF_HEADER = Con.Struct("aiff_header",
Con.Const(Con.Bytes("aiff_id", 4), "FORM"),
Con.UBInt32("aiff_size"),
Con.Const(Con.Bytes("aiff_type", 4), "AIFF"))
CHUNK_HEADER = Con.Struct("chunk_header",
Con.Bytes("chunk_id", 4),
Con.UBInt32("chunk_length"))
COMM_CHUNK = Con.Struct("comm",
Con.UBInt16("channels"),
Con.UBInt32("total_sample_frames"),
Con.UBInt16("sample_size"),
IEEE_Extended("sample_rate"))
SSND_ALIGN = Con.Struct("ssnd",
Con.UBInt32("offset"),
Con.UBInt32("blocksize"))
PRINTABLE_ASCII = set([chr(i) for i in xrange(0x20, 0x7E + 1)])
def __init__(self, filename):
"""filename is a plain string."""
self.filename = filename
comm_found = False
ssnd_found = False
try:
f = open(self.filename, 'rb')
for (chunk_id, chunk_length, chunk_offset) in self.chunks():
if (chunk_id == 'COMM'):
f.seek(chunk_offset, 0)
comm = self.COMM_CHUNK.parse(f.read(chunk_length))
self.__channels__ = comm.channels
self.__total_sample_frames__ = comm.total_sample_frames
self.__sample_size__ = comm.sample_size
self.__sample_rate__ = int(comm.sample_rate)
comm_found = True
elif (chunk_id == 'SSND'):
f.seek(chunk_offset, 0)
ssnd = self.SSND_ALIGN.parse_stream(f)
ssnd_found = True
elif (not set(chunk_id).issubset(self.PRINTABLE_ASCII)):
raise InvalidAIFF(_("chunk header not ASCII"))
if (not comm_found):
raise InvalidAIFF(_("no COMM chunk found"))
if (not ssnd_found):
raise InvalidAIFF(_("no SSND chunk found"))
f.close()
except IOError, msg:
raise InvalidAIFF(str(msg))
except Con.FieldError:
raise InvalidAIFF(_("invalid COMM or SSND chunk"))
def bits_per_sample(self):
"""Returns an integer number of bits-per-sample this track contains."""
return self.__sample_size__
def channels(self):
"""Returns an integer number of channels this track contains."""
return self.__channels__
def channel_mask(self):
"""Returns a ChannelMask object of this track's channel layout."""
#this unusual arrangement is taken from the AIFF specification
if (self.channels() <= 2):
return ChannelMask.from_channels(self.channels())
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() == 6):
return ChannelMask.from_fields(
front_left=True, side_left=True,
front_center=True, front_right=True,
side_right=True, back_center=True)
else:
return ChannelMask(0)
def lossless(self):
"""Returns True."""
return True
def total_frames(self):
"""Returns the total PCM frames of the track as an integer."""
return self.__total_sample_frames__
def sample_rate(self):
"""Returns the rate of the track's audio as an integer number of Hz."""
return self.__sample_rate__
@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)
return ((header[0:4] == 'FORM') and
(header[8:12] == 'AIFF'))
def chunks(self):
"""Yields a (chunk_id, length, offset) per AIFF chunk."""
f = open(self.filename, 'rb')
try:
aiff_header = self.AIFF_HEADER.parse_stream(f)
except Con.ConstError:
raise InvalidAIFF(_(u"Not an AIFF file"))
except Con.core.FieldError:
raise InvalidAIFF(_(u"Invalid AIFF file"))
total_size = aiff_header.aiff_size - 4
while (total_size > 0):
chunk_header = self.CHUNK_HEADER.parse_stream(f)
total_size -= 8
yield (chunk_header.chunk_id,
chunk_header.chunk_length,
f.tell())
f.seek(chunk_header.chunk_length, 1)
total_size -= chunk_header.chunk_length
f.close()
def comm_chunk(self):
"""Returns (channels, pcm_frames, bits_per_sample, sample_rate) ."""
try:
for (chunk_id, chunk_length, chunk_offset) in self.chunks():
if (chunk_id == 'COMM'):
f = open(self.filename, 'rb')
f.seek(chunk_offset, 0)
comm = self.COMM_CHUNK.parse(f.read(chunk_length))
f.close()
return (comm.channels,
comm.total_sample_frames,
comm.sample_size,
int(comm.sample_rate))
else:
raise InvalidAIFF(_(u"COMM chunk not found"))
except IOError, msg:
raise InvalidAIFF(str(msg))
except Con.FieldError:
raise InvalidAIFF(_(u"invalid COMM chunk"))
def chunk_files(self):
"""Yields a (chunk_id,length,file) per AIFF chunk.
The file object is capped to read only its chunk data."""
f = open(self.filename, 'rb')
try:
aiff_header = self.AIFF_HEADER.parse_stream(f)
except Con.ConstError:
raise InvalidAIFF(_(u"Not an AIFF file"))
except Con.core.FieldError:
raise InvalidAIFF(_(u"Invalid AIFF file"))
total_size = aiff_header.aiff_size - 4
while (total_size > 0):
chunk_header = self.CHUNK_HEADER.parse_stream(f)
total_size -= 8
yield (chunk_header.chunk_id,
chunk_header.chunk_length,
__capped_stream_reader__(f, chunk_header.chunk_length))
total_size -= chunk_header.chunk_length
f.close()
def get_metadata(self):
"""Returns a MetaData object, or None.
Raises IOError if unable to read the file."""
for (chunk_id, chunk_length, chunk_offset) in self.chunks():
if (chunk_id == 'ID3 '):
f = open(self.filename, 'rb')
f.seek(chunk_offset, 0)
id3 = ID3v22Comment.parse(f)
f.close()
return id3
else:
return None
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."""
if (metadata is None):
return
import tempfile
id3_chunk = ID3v22Comment.converted(metadata).build()
new_aiff = tempfile.TemporaryFile()
new_aiff.seek(12, 0)
id3_found = False
for (chunk_id, chunk_length, chunk_file) in self.chunk_files():
if (chunk_id != 'ID3 '):
new_aiff.write(self.CHUNK_HEADER.build(
Con.Container(chunk_id=chunk_id,
chunk_length=chunk_length)))
transfer_data(chunk_file.read, new_aiff.write)
else:
new_aiff.write(self.CHUNK_HEADER.build(
Con.Container(chunk_id='ID3 ',
chunk_length=len(id3_chunk))))
new_aiff.write(id3_chunk)
id3_found = True
if (not id3_found):
new_aiff.write(self.CHUNK_HEADER.build(
Con.Container(chunk_id='ID3 ',
chunk_length=len(id3_chunk))))
new_aiff.write(id3_chunk)
header = Con.Container(
aiff_id='FORM',
aiff_size=new_aiff.tell() - 8,
aiff_type='AIFF')
new_aiff.seek(0, 0)
new_aiff.write(self.AIFF_HEADER.build(header))
new_aiff.seek(0, 0)
f = open(self.filename, 'wb')
transfer_data(new_aiff.read, f.write)
new_aiff.close()
f.close()
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."""
import tempfile
new_aiff = tempfile.TemporaryFile()
new_aiff.seek(12, 0)
for (chunk_id, chunk_length, chunk_file) in self.chunk_files():
if (chunk_id != 'ID3 '):
new_aiff.write(self.CHUNK_HEADER.build(
Con.Container(chunk_id=chunk_id,
chunk_length=chunk_length)))
transfer_data(chunk_file.read, new_aiff.write)
header = Con.Container(
aiff_id='FORM',
aiff_size=new_aiff.tell() - 8,
aiff_type='AIFF')
new_aiff.seek(0, 0)
new_aiff.write(self.AIFF_HEADER.build(header))
new_aiff.seek(0, 0)
f = open(self.filename, 'wb')
transfer_data(new_aiff.read, f.write)
new_aiff.close()
f.close()
def to_pcm(self):
"""Returns a PCMReader object containing the track's PCM data."""
for (chunk_id, chunk_length, chunk_offset) in self.chunks():
if (chunk_id == 'SSND'):
f = open(self.filename, 'rb')
f.seek(chunk_offset, 0)
return AiffReader(f,
self.sample_rate(),
self.channels(),
int(self.channel_mask()),
self.bits_per_sample(),
chunk_length)
else:
return PCMReaderError(u"no SSND chunk found",
self.sample_rate(),
self.channels(),
int(self.channel_mask()),
self.bits_per_sample)
@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 AiffAudio object."""
try:
f = open(filename, 'wb')
except IOError, msg:
raise EncodingError(str(msg))
if (int(pcmreader.channel_mask) in
(0x4, # FC
0x3, # FL, FR
0x7, # FL, FR, FC
0x33, # FL, FR, BL, BR
0x707)): # FL, SL, FC, FR, SR, BC
standard_channel_mask = ChannelMask(pcmreader.channel_mask)
aiff_channel_mask = AIFFChannelMask(standard_channel_mask)
pcmreader = ReorderedPCMReader(
pcmreader,
[standard_channel_mask.channels().index(channel)
for channel in aiff_channel_mask.channels()])
try:
aiff_header = Con.Container(aiff_id='FORM',
aiff_size=4,
aiff_type='AIFF')
comm_chunk = Con.Container(
channels=pcmreader.channels,
total_sample_frames=0,
sample_size=pcmreader.bits_per_sample,
sample_rate=float(pcmreader.sample_rate))
ssnd_header = Con.Container(chunk_id='SSND',
chunk_length=0)
ssnd_alignment = Con.Container(offset=0,
blocksize=0)
#skip ahead to the start of the SSND chunk
f.seek(cls.AIFF_HEADER.sizeof() +
cls.CHUNK_HEADER.sizeof() +
cls.COMM_CHUNK.sizeof() +
cls.CHUNK_HEADER.sizeof(), 0)
#write the SSND alignment info
f.write(cls.SSND_ALIGN.build(ssnd_alignment))
#write big-endian samples to SSND chunk from pcmreader
try:
framelist = pcmreader.read(BUFFER_SIZE)
except (ValueError, IOError), err:
cls.__unlink__(filename)
raise EncodingError(str(err))
except Exception, err:
cls.__unlink__(filename)
raise err
total_pcm_frames = 0
while (len(framelist) > 0):
f.write(framelist.to_bytes(True, True))
total_pcm_frames += framelist.frames
try:
framelist = pcmreader.read(BUFFER_SIZE)
except (ValueError, IOError), err:
cls.__unlink__(filename)
raise EncodingError(str(err))
except Exception, err:
cls.__unlink__(filename)
raise err
total_size = f.tell()
#return to the start of the file
f.seek(0, 0)
#write AIFF header
aiff_header.aiff_size = total_size - 8
f.write(cls.AIFF_HEADER.build(aiff_header))
#write COMM chunk
comm_chunk.total_sample_frames = total_pcm_frames
comm_chunk = cls.COMM_CHUNK.build(comm_chunk)
f.write(cls.CHUNK_HEADER.build(Con.Container(
chunk_id='COMM',
chunk_length=len(comm_chunk))))
f.write(comm_chunk)
#write SSND chunk header
f.write(cls.CHUNK_HEADER.build(Con.Container(
chunk_id='SSND',
chunk_length=(total_pcm_frames *
(pcmreader.bits_per_sample / 8) *
pcmreader.channels) +
cls.SSND_ALIGN.sizeof())))
try:
pcmreader.close()
except DecodingError, err:
raise EncodingError(err.error_message)
finally:
f.close()
return cls(filename)
def to_aiff(self, aiff_filename, progress=None):
"""Writes the contents of this file to the given .aiff filename string.
Raises EncodingError if some error occurs during decoding."""
try:
self.verify()
except InvalidAiff, err:
raise EncodingError(str(err))
try:
output = file(aiff_filename, 'wb')
input = file(self.filename, 'rb')
except IOError, msg:
raise EncodingError(str(msg))
try:
transfer_data(input.read, output.write)
finally:
input.close()
output.close()
@classmethod
def from_aiff(cls, filename, aiff_filename, compression=None,
progress=None):
try:
cls(aiff_filename).verify()
except InvalidAiff, err:
raise EncodingError(unicode(err))
try:
input = file(aiff_filename, 'rb')
output = file(filename, 'wb')
except IOError, err:
raise EncodingError(str(err))
try:
total_bytes = os.path.getsize(aiff_filename)
current_bytes = 0
s = input.read(4096)
while (len(s) > 0):
current_bytes += len(s)
output.write(s)
if (progress is not None):
progress(current_bytes, total_bytes)
s = input.read(4096)
output.flush()
try:
return AiffAudio(filename)
except InvalidFile:
cls.__unlink__(filename)
raise EncodingError(u"invalid AIFF source file")
finally:
input.close()
output.close()
def convert(self, target_path, target_class, compression=None,
progress=None):
"""Encodes a new AudioFile from existing AudioFile.
Take a filename string, target class and optional compression string.
Encodes a new AudioFile in the target class and returns
the resulting object.
May raise EncodingError if some problem occurs during encoding."""
if (hasattr(target_class, "from_aiff")):
return target_class.from_aiff(target_path,
self.filename,
compression=compression,
progress=progress)
else:
return target_class.from_pcm(target_path,
to_pcm_progress(self, progress),
compression)
def pcm_split(self):
"""Returns a pair of data strings before and after PCM data.
The first contains all data before the PCM content of the data chunk.
The second containing all data after the data chunk.
"""
head = cStringIO.StringIO()
tail = cStringIO.StringIO()
current_block = head
aiff_file = open(self.filename, 'rb')
try:
try:
#transfer the 12-bite FORMsizeAIFF header
header = AiffAudio.AIFF_HEADER.parse(aiff_file.read(12))
total_size = header.aiff_size - 4
current_block.write(AiffAudio.AIFF_HEADER.build(header))
except Con.ConstError:
raise InvalidAIFF(_(u"Not an AIFF file"))
except Con.core.FieldError:
raise InvalidAIFF(_(u"Invalid AIFF file"))
while (total_size > 0):
try:
#transfer each chunk header
chunk_header = AiffAudio.CHUNK_HEADER.parse(
aiff_file.read(8))
current_block.write(AiffAudio.CHUNK_HEADER.build(
chunk_header))
total_size -= 8
except Con.core.FieldError:
raise InvalidAiff(_(u"Invalid AIFF file"))
#and transfer the full content of non-ssnd chunks
if (chunk_header.chunk_id != "SSND"):
current_block.write(
aiff_file.read(chunk_header.chunk_length))
else:
#or, the top 8 align bytes of the ssnd chunk
try:
align = AiffAudio.SSND_ALIGN.parse(
aiff_file.read(8))
current_block.write(AiffAudio.SSND_ALIGN.build(
align))
aiff_file.seek(chunk_header.chunk_length - 8,
os.SEEK_CUR)
current_block = tail
except Con.core.FieldError:
raise InvalidAiff(_(u"Invalid AIFF file"))
total_size -= chunk_header.chunk_length
return (head.getvalue(), tail.getvalue())
finally:
aiff_file.close()
@classmethod
def aiff_from_chunks(cls, filename, chunk_iter):
"""Builds a new AIFF file from a chunk data iterator.
filename is the path to the wave file to build.
chunk_iter should yield (chunk_id, chunk_data) tuples.
"""
f = file(filename, 'wb')
header = Con.Container()
header.aiff_id = 'FORM'
header.aiff_type = 'AIFF'
header.aiff_size = 4
#write an unfinished header with an invalid size (for now)
f.write(cls.AIFF_HEADER.build(header))
for (chunk_id, chunk_data) in chunk_iter:
#not sure if I need to fix chunk sizes
#to fall on 16-bit boundaries
chunk_header = cls.CHUNK_HEADER.build(
Con.Container(chunk_id=chunk_id,
chunk_length=len(chunk_data)))
f.write(chunk_header)
header.aiff_size += len(chunk_header)
f.write(chunk_data)
header.aiff_size += len(chunk_data)
#now that the chunks are done, go back and re-write the header
f.seek(0, 0)
f.write(cls.AIFF_HEADER.build(header))
f.close()
def has_foreign_aiff_chunks(self):
return (set(['COMM', 'SSND']) !=
set([chunk[0] for chunk in self.chunks()]))
class AIFFChannelMask(ChannelMask):
"""The AIFF-specific channel mapping."""
def __repr__(self):
return "AIFFChannelMask(%s)" % \
",".join(["%s=%s" % (field, getattr(self, field))
for field in self.SPEAKER_TO_MASK.keys()
if (getattr(self, field))])
def channels(self):
"""Returns a list of speaker strings this mask contains.
Returned in the order in which they should appear
in the PCM stream.
"""
count = len(self)
if (count == 1):
return ["front_center"]
elif (count == 2):
return ["front_left", "front_right"]
elif (count == 3):
return ["front_left", "front_right", "front_center"]
elif (count == 4):
return ["front_left", "front_right",
"back_left", "back_right"]
elif (count == 6):
return ["front_left", "side_left", "front_center",
"front_right", "side_right", "back_center"]
else:
return []

View File

@ -0,0 +1,808 @@
#!/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, WaveAudio, InvalidFile, PCMReader,
Con, transfer_data, subprocess, BIN, MetaData,
os, re, TempWaveReader, Image, cStringIO)
import gettext
gettext.install("audiotools", unicode=True)
#takes a pair of integers for the current and total values
#returns a unicode string of their combined pair
#for example, __number_pair__(2,3) returns u"2/3"
#whereas __number_pair__(4,0) returns u"4"
def __number_pair__(current, total):
if (total == 0):
return u"%d" % (current)
else:
return u"%d/%d" % (current, total)
#######################
#MONKEY'S AUDIO
#######################
class ApeTagItem:
"""A container for APEv2 tag items."""
APEv2_FLAGS = Con.BitStruct("APEv2_FLAGS",
Con.Bits("undefined1", 5),
Con.Flag("read_only"),
Con.Bits("encoding", 2),
Con.Bits("undefined2", 16),
Con.Flag("contains_header"),
Con.Flag("contains_no_footer"),
Con.Flag("is_header"),
Con.Bits("undefined3", 5))
APEv2_TAG = Con.Struct("APEv2_TAG",
Con.ULInt32("length"),
Con.Embed(APEv2_FLAGS),
Con.CString("key"),
Con.MetaField("value",
lambda ctx: ctx["length"]))
def __init__(self, item_type, read_only, key, data):
"""Fields are as follows:
item_type is 0 = UTF-8, 1 = binary, 2 = external, 3 = reserved.
read_only is True if the item is read only.
key is an ASCII string.
data is a binary string of the data itself.
"""
self.type = item_type
self.read_only = read_only
self.key = key
self.data = data
def __repr__(self):
return "ApeTagItem(%s,%s,%s,%s)" % \
(repr(self.type),
repr(self.read_only),
repr(self.key),
repr(self.data))
def __str__(self):
return self.data
def __unicode__(self):
return self.data.rstrip(chr(0)).decode('utf-8', 'replace')
def build(self):
"""Returns this tag as a binary string of data."""
return self.APEv2_TAG.build(
Con.Container(key=self.key,
value=self.data,
length=len(self.data),
encoding=self.type,
undefined1=0,
undefined2=0,
undefined3=0,
read_only=self.read_only,
contains_header=False,
contains_no_footer=False,
is_header=False))
@classmethod
def binary(cls, key, data):
"""Returns an ApeTagItem of binary data.
key is an ASCII string, data is a binary string."""
return cls(1, False, key, data)
@classmethod
def external(cls, key, data):
"""Returns an ApeTagItem of external data.
key is an ASCII string, data is a binary string."""
return cls(2, False, key, data)
@classmethod
def string(cls, key, data):
"""Returns an ApeTagItem of text data.
key is an ASCII string, data is a UTF-8 binary string."""
return cls(0, False, key, data.encode('utf-8', 'replace'))
class ApeTag(MetaData):
"""A complete APEv2 tag."""
ITEM = ApeTagItem
APEv2_FLAGS = Con.BitStruct("APEv2_FLAGS",
Con.Bits("undefined1", 5),
Con.Flag("read_only"),
Con.Bits("encoding", 2),
Con.Bits("undefined2", 16),
Con.Flag("contains_header"),
Con.Flag("contains_no_footer"),
Con.Flag("is_header"),
Con.Bits("undefined3", 5))
APEv2_FOOTER = Con.Struct("APEv2",
Con.String("preamble", 8),
Con.ULInt32("version_number"),
Con.ULInt32("tag_size"),
Con.ULInt32("item_count"),
Con.Embed(APEv2_FLAGS),
Con.ULInt64("reserved"))
APEv2_HEADER = APEv2_FOOTER
APEv2_TAG = ApeTagItem.APEv2_TAG
ATTRIBUTE_MAP = {'track_name': 'Title',
'track_number': 'Track',
'track_total': 'Track',
'album_number': 'Media',
'album_total': 'Media',
'album_name': 'Album',
'artist_name': 'Artist',
#"Performer" is not a defined APEv2 key
#it would be nice to have, yet would not be standard
'performer_name': 'Performer',
'composer_name': 'Composer',
'conductor_name': 'Conductor',
'ISRC': 'ISRC',
'catalog': 'Catalog',
'copyright': 'Copyright',
'publisher': 'Publisher',
'year': 'Year',
'date': 'Record Date',
'comment': 'Comment'}
INTEGER_ITEMS = ('Track', 'Media')
def __init__(self, tags, tag_length=None):
"""Constructs an ApeTag from a list of ApeTagItem objects.
tag_length is an optional total length integer."""
for tag in tags:
if (not isinstance(tag, ApeTagItem)):
raise ValueError("%s is not ApeTag" % (repr(tag)))
self.__dict__["tags"] = tags
self.__dict__["tag_length"] = tag_length
def __eq__(self, metadata):
if (isinstance(metadata, ApeTag)):
if (set(self.keys()) != set(metadata.keys())):
return False
for tag in self.tags:
try:
if (tag.data != metadata[tag.key].data):
return False
except KeyError:
return False
else:
return True
elif (isinstance(metadata, MetaData)):
return MetaData.__eq__(self, metadata)
else:
return False
def keys(self):
return [tag.key for tag in self.tags]
def __getitem__(self, key):
for tag in self.tags:
if (tag.key == key):
return tag
else:
raise KeyError(key)
def get(self, key, default):
try:
return self[key]
except KeyError:
return default
def __setitem__(self, key, value):
for i in xrange(len(self.tags)):
if (self.tags[i].key == key):
self.tags[i] = value
return
else:
self.tags.append(value)
def index(self, key):
for (i, tag) in enumerate(self.tags):
if (tag.key == key):
return i
else:
raise ValueError(key)
def __delitem__(self, key):
for i in xrange(len(self.tags)):
if (self.tags[i].key == key):
del(self.tags[i])
return
#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):
if (key == 'track_number'):
self['Track'] = self.ITEM.string(
'Track', __number_pair__(value, self.track_total))
elif (key == 'track_total'):
self['Track'] = self.ITEM.string(
'Track', __number_pair__(self.track_number, value))
elif (key == 'album_number'):
self['Media'] = self.ITEM.string(
'Media', __number_pair__(value, self.album_total))
elif (key == 'album_total'):
self['Media'] = self.ITEM.string(
'Media', __number_pair__(self.album_number, value))
else:
self[self.ATTRIBUTE_MAP[key]] = self.ITEM.string(
self.ATTRIBUTE_MAP[key], value)
else:
self.__dict__[key] = value
def __getattr__(self, key):
if (key == 'track_number'):
try:
return int(re.findall('\d+',
unicode(self.get("Track", u"0")))[0])
except IndexError:
return 0
elif (key == 'track_total'):
try:
return int(re.findall('\d+/(\d+)',
unicode(self.get("Track", u"0")))[0])
except IndexError:
return 0
elif (key == 'album_number'):
try:
return int(re.findall('\d+',
unicode(self.get("Media", u"0")))[0])
except IndexError:
return 0
elif (key == 'album_total'):
try:
return int(re.findall('\d+/(\d+)',
unicode(self.get("Media", u"0")))[0])
except IndexError:
return 0
elif (key in self.ATTRIBUTE_MAP):
return unicode(self.get(self.ATTRIBUTE_MAP[key], u''))
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['Track'])
elif (key == 'track_total'):
setattr(self, 'track_total', 0)
if ((self.track_number == 0) and (self.track_total == 0)):
del(self['Track'])
elif (key == 'album_number'):
setattr(self, 'album_number', 0)
if ((self.album_number == 0) and (self.album_total == 0)):
del(self['Media'])
elif (key == 'album_total'):
setattr(self, 'album_total', 0)
if ((self.album_number == 0) and (self.album_total == 0)):
del(self['Media'])
elif (key in self.ATTRIBUTE_MAP):
try:
del(self[self.ATTRIBUTE_MAP[key]])
except ValueError:
pass
elif (key in MetaData.__FIELDS__):
pass
else:
try:
del(self.__dict__[key])
except KeyError:
raise AttributeError(key)
@classmethod
def converted(cls, metadata):
"""Converts a MetaData object to an ApeTag object."""
if ((metadata is None) or (isinstance(metadata, ApeTag))):
return metadata
else:
tags = cls([])
for (field, key) in cls.ATTRIBUTE_MAP.items():
if (field not in cls.__INTEGER_FIELDS__):
field = unicode(getattr(metadata, field))
if (len(field) > 0):
tags[key] = cls.ITEM.string(key, field)
if ((metadata.track_number != 0) or
(metadata.track_total != 0)):
tags["Track"] = cls.ITEM.string(
"Track", __number_pair__(metadata.track_number,
metadata.track_total))
if ((metadata.album_number != 0) or
(metadata.album_total != 0)):
tags["Media"] = cls.ITEM.string(
"Media", __number_pair__(metadata.album_number,
metadata.album_total))
for image in metadata.images():
tags.add_image(image)
return tags
def merge(self, metadata):
"""Updates any currently empty entries from metadata's values."""
metadata = self.__class__.converted(metadata)
if (metadata is None):
return
for tag in metadata.tags:
if ((tag.key not in ('Track', 'Media')) and
(len(str(tag)) > 0) and
(len(str(self.get(tag.key, ""))) == 0)):
self[tag.key] = tag
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 __comment_name__(self):
return u'APEv2'
#takes two (key,value) apetag pairs
#returns cmp on the weighted set of them
#(title first, then artist, album, tracknumber)
@classmethod
def __by_pair__(cls, pair1, pair2):
KEY_MAP = {"Title": 1,
"Album": 2,
"Track": 3,
"Media": 4,
"Artist": 5,
"Performer": 6,
"Composer": 7,
"Conductor": 8,
"Catalog": 9,
"Publisher": 10,
"ISRC": 11,
#"Media": 12,
"Year": 13,
"Record Date": 14,
"Copyright": 15}
return cmp((KEY_MAP.get(pair1[0], 16), pair1[0], pair1[1]),
(KEY_MAP.get(pair2[0], 16), pair2[0], pair2[1]))
def __comment_pairs__(self):
items = []
for tag in self.tags:
if (tag.key in ('Cover Art (front)', 'Cover Art (back)')):
pass
elif (tag.type == 0):
items.append((tag.key, unicode(tag)))
else:
if (len(str(tag)) <= 20):
items.append((tag.key, str(tag).encode('hex')))
else:
items.append((tag.key,
str(tag).encode('hex')[0:39].upper() +
u"\u2026"))
return sorted(items, ApeTag.__by_pair__)
@classmethod
def supports_images(cls):
"""Returns True."""
return True
def __parse_image__(self, key, type):
data = cStringIO.StringIO(str(self[key]))
description = Con.CString(None).parse_stream(data).decode('utf-8',
'replace')
data = data.read()
return Image.new(data, description, type)
def add_image(self, image):
"""Embeds an Image object in this metadata."""
if (image.type == 0):
self['Cover Art (front)'] = self.ITEM.external(
'Cover Art (front)',
Con.CString(None).build(image.description.encode(
'utf-8', 'replace')) + image.data)
elif (image.type == 1):
self['Cover Art (back)'] = self.ITEM.binary(
'Cover Art (back)',
Con.CString(None).build(image.description.encode(
'utf-8', 'replace')) + image.data)
def delete_image(self, image):
"""Deletes an Image object from this metadata."""
if ((image.type == 0) and 'Cover Art (front)' in self.keys()):
del(self['Cover Art (front)'])
elif ((image.type == 1) and 'Cover Art (back)' in self.keys()):
del(self['Cover Art (back)'])
def images(self):
"""Returns a list of embedded Image objects."""
#APEv2 supports only one value per key
#so a single front and back cover are all that is possible
img = []
if ('Cover Art (front)' in self.keys()):
img.append(self.__parse_image__('Cover Art (front)', 0))
if ('Cover Art (back)' in self.keys()):
img.append(self.__parse_image__('Cover Art (back)', 1))
return img
@classmethod
def read(cls, apefile):
"""Returns an ApeTag object from an APEv2 tagged file object.
May return None if the file object has no tag."""
apefile.seek(-32, 2)
footer = cls.APEv2_FOOTER.parse(apefile.read(32))
if (footer.preamble != 'APETAGEX'):
return None
apefile.seek(-(footer.tag_size), 2)
return cls([ApeTagItem(item_type=tag.encoding,
read_only=tag.read_only,
key=tag.key,
data=tag.value)
for tag in Con.StrictRepeater(
footer.item_count,
cls.APEv2_TAG).parse(apefile.read())],
tag_length=footer.tag_size + ApeTag.APEv2_FOOTER.sizeof()
if footer.contains_header else
footer.tag_size)
def build(self):
"""Returns an APEv2 tag as a binary string."""
header = Con.Container(preamble='APETAGEX',
version_number=2000,
tag_size=0,
item_count=len(self.tags),
undefined1=0,
undefined2=0,
undefined3=0,
read_only=False,
encoding=0,
contains_header=True,
contains_no_footer=False,
is_header=True,
reserved=0l)
footer = Con.Container(preamble=header.preamble,
version_number=header.version_number,
tag_size=0,
item_count=len(self.tags),
undefined1=0,
undefined2=0,
undefined3=0,
read_only=False,
encoding=0,
contains_header=True,
contains_no_footer=False,
is_header=False,
reserved=0l)
tags = "".join([tag.build() for tag in self.tags])
footer.tag_size = header.tag_size = \
len(tags) + len(ApeTag.APEv2_FOOTER.build(footer))
return ApeTag.APEv2_FOOTER.build(header) + \
tags + \
ApeTag.APEv2_FOOTER.build(footer)
class ApeTaggedAudio:
"""A class for handling audio formats with APEv2 tags.
This class presumes there will be a filename attribute which
can be opened and checked for tags, or written if necessary."""
APE_TAG_CLASS = ApeTag
def get_metadata(self):
"""Returns an ApeTag object, or None.
Raises IOError if unable to read the file."""
f = file(self.filename, 'rb')
try:
return self.APE_TAG_CLASS.read(f)
finally:
f.close()
def set_metadata(self, metadata):
"""Takes a MetaData object and sets this track's metadata.
Raises IOError if unable to write the file."""
apetag = self.APE_TAG_CLASS.converted(metadata)
if (apetag is None):
return
current_metadata = self.get_metadata()
if (current_metadata is not None): # there's existing tags to delete
f = file(self.filename, "rb")
untagged_data = f.read()[0:-current_metadata.tag_length]
f.close()
f = file(self.filename, "wb")
f.write(untagged_data)
f.write(apetag.build())
f.close()
else: # no existing tags
f = file(self.filename, "ab")
f.write(apetag.build())
f.close()
def delete_metadata(self):
"""Deletes the track's MetaData.
Raises IOError if unable to write the file."""
current_metadata = self.get_metadata()
if (current_metadata is not None): # there's existing tags to delete
f = file(self.filename, "rb")
untagged_data = f.read()[0:-current_metadata.tag_length]
f.close()
f = file(self.filename, "wb")
f.write(untagged_data)
f.close()
class ApeAudio(ApeTaggedAudio, AudioFile):
"""A Monkey's Audio file."""
SUFFIX = "ape"
NAME = SUFFIX
DEFAULT_COMPRESSION = "5000"
COMPRESSION_MODES = tuple([str(x * 1000) for x in range(1, 6)])
BINARIES = ("mac",)
FILE_HEAD = Con.Struct("ape_head",
Con.String('id', 4),
Con.ULInt16('version'))
#version >= 3.98
APE_DESCRIPTOR = Con.Struct("ape_descriptor",
Con.ULInt16('padding'),
Con.ULInt32('descriptor_bytes'),
Con.ULInt32('header_bytes'),
Con.ULInt32('seektable_bytes'),
Con.ULInt32('header_data_bytes'),
Con.ULInt32('frame_data_bytes'),
Con.ULInt32('frame_data_bytes_high'),
Con.ULInt32('terminating_data_bytes'),
Con.String('md5', 16))
APE_HEADER = Con.Struct("ape_header",
Con.ULInt16('compression_level'),
Con.ULInt16('format_flags'),
Con.ULInt32('blocks_per_frame'),
Con.ULInt32('final_frame_blocks'),
Con.ULInt32('total_frames'),
Con.ULInt16('bits_per_sample'),
Con.ULInt16('number_of_channels'),
Con.ULInt32('sample_rate'))
#version <= 3.97
APE_HEADER_OLD = Con.Struct("ape_header_old",
Con.ULInt16('compression_level'),
Con.ULInt16('format_flags'),
Con.ULInt16('number_of_channels'),
Con.ULInt32('sample_rate'),
Con.ULInt32('header_bytes'),
Con.ULInt32('terminating_bytes'),
Con.ULInt32('total_frames'),
Con.ULInt32('final_frame_blocks'))
def __init__(self, filename):
"""filename is a plain string."""
AudioFile.__init__(self, filename)
(self.__samplespersec__,
self.__channels__,
self.__bitspersample__,
self.__totalsamples__) = ApeAudio.__ape_info__(filename)
@classmethod
def is_type(cls, file):
"""Returns True if the given file object describes this format.
Takes a seekable file pointer rewound to the start of the file."""
return file.read(4) == "MAC "
def lossless(self):
"""Returns True."""
return True
@classmethod
def supports_foreign_riff_chunks(cls):
"""Returns True."""
return True
def has_foreign_riff_chunks(self):
"""Returns True."""
#FIXME - this isn't strictly true
#I'll need a way to detect foreign chunks in APE's stream
#without decoding it first,
#but since I'm not supporting APE anyway, I'll take the lazy way out
return True
def bits_per_sample(self):
"""Returns an integer number of bits-per-sample this track contains."""
return self.__bitspersample__
def channels(self):
"""Returns an integer number of channels this track contains."""
return self.__channels__
def total_frames(self):
"""Returns the total PCM frames of the track as an integer."""
return self.__totalsamples__
def sample_rate(self):
"""Returns the rate of the track's audio as an integer number of Hz."""
return self.__samplespersec__
@classmethod
def __ape_info__(cls, filename):
f = file(filename, 'rb')
try:
file_head = cls.FILE_HEAD.parse_stream(f)
if (file_head.id != 'MAC '):
raise InvalidFile(_(u"Invalid Monkey's Audio header"))
if (file_head.version >= 3980): # the latest APE file type
descriptor = cls.APE_DESCRIPTOR.parse_stream(f)
header = cls.APE_HEADER.parse_stream(f)
return (header.sample_rate,
header.number_of_channels,
header.bits_per_sample,
((header.total_frames - 1) * \
header.blocks_per_frame) + \
header.final_frame_blocks)
else: # old-style APE file (obsolete)
header = cls.APE_HEADER_OLD.parse_stream(f)
if (file_head.version >= 3950):
blocks_per_frame = 0x48000
elif ((file_head.version >= 3900) or
((file_head.version >= 3800) and
(header.compression_level == 4000))):
blocks_per_frame = 0x12000
else:
blocks_per_frame = 0x2400
if (header.format_flags & 0x01):
bits_per_sample = 8
elif (header.format_flags & 0x08):
bits_per_sample = 24
else:
bits_per_sample = 16
return (header.sample_rate,
header.number_of_channels,
bits_per_sample,
((header.total_frames - 1) * \
blocks_per_frame) + \
header.final_frame_blocks)
finally:
f.close()
def to_wave(self, wave_filename):
"""Writes the contents of this file to the given .wav filename string.
Raises EncodingError if some error occurs during decoding."""
if (self.filename.endswith(".ape")):
devnull = file(os.devnull, "wb")
sub = subprocess.Popen([BIN['mac'],
self.filename,
wave_filename,
'-d'],
stdout=devnull,
stderr=devnull)
sub.wait()
devnull.close()
else:
devnull = file(os.devnull, 'ab')
import tempfile
ape = tempfile.NamedTemporaryFile(suffix='.ape')
f = file(self.filename, 'rb')
transfer_data(f.read, ape.write)
f.close()
ape.flush()
sub = subprocess.Popen([BIN['mac'],
ape.name,
wave_filename,
'-d'],
stdout=devnull,
stderr=devnull)
sub.wait()
ape.close()
devnull.close()
@classmethod
def from_wave(cls, filename, wave_filename, compression=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 ApeAudio object."""
if (str(compression) not in cls.COMPRESSION_MODES):
compression = cls.DEFAULT_COMPRESSION
devnull = file(os.devnull, "wb")
sub = subprocess.Popen([BIN['mac'],
wave_filename,
filename,
"-c%s" % (compression)],
stdout=devnull,
stderr=devnull)
sub.wait()
devnull.close()
return ApeAudio(filename)

View File

@ -0,0 +1,256 @@
#!/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, Con,
transfer_data, InvalidFormat,
__capped_stream_reader__, BUFFER_SIZE,
FILENAME_FORMAT, EncodingError, DecodingError,
ChannelMask)
import audiotools.pcm
import gettext
gettext.install("audiotools", unicode=True)
class InvalidAU(InvalidFile):
pass
#######################
#Sun AU
#######################
class AuReader(PCMReader):
"""A subclass of PCMReader for reading Sun AU file contents."""
def __init__(self, au_file, data_size,
sample_rate, channels, channel_mask, bits_per_sample):
"""au_file is a file, data_size is an integer byte count.
sample_rate, channels, channel_mask and bits_per_sample are ints.
"""
PCMReader.__init__(self,
file=au_file,
sample_rate=sample_rate,
channels=channels,
channel_mask=channel_mask,
bits_per_sample=bits_per_sample)
self.data_size = data_size
def read(self, bytes):
"""Try to read a pcm.FrameList of size "bytes"."""
#align bytes downward if an odd number is read in
bytes -= (bytes % (self.channels * self.bits_per_sample / 8))
bytes = max(bytes, self.channels * self.bits_per_sample / 8)
pcm_data = self.file.read(bytes)
if ((len(pcm_data) == 0) and (self.data_size > 0)):
raise IOError("data ends prematurely")
else:
self.data_size -= len(pcm_data)
try:
return audiotools.pcm.FrameList(pcm_data,
self.channels,
self.bits_per_sample,
True,
True)
except ValueError:
raise IOError("data ends prematurely")
class AuAudio(AudioFile):
"""A Sun AU audio file."""
SUFFIX = "au"
NAME = SUFFIX
AU_HEADER = Con.Struct('header',
Con.Const(Con.String('magic_number', 4), '.snd'),
Con.UBInt32('data_offset'),
Con.UBInt32('data_size'),
Con.UBInt32('encoding_format'),
Con.UBInt32('sample_rate'),
Con.UBInt32('channels'))
def __init__(self, filename):
AudioFile.__init__(self, filename)
try:
f = file(filename, 'rb')
except IOError, msg:
raise InvalidAU(str(msg))
try:
header = AuAudio.AU_HEADER.parse_stream(f)
if (header.encoding_format not in (2, 3, 4)):
raise InvalidFile(_(u"Unsupported Sun AU encoding format"))
self.__bits_per_sample__ = {2: 8, 3: 16, 4: 24}[
header.encoding_format]
self.__channels__ = header.channels
self.__sample_rate__ = header.sample_rate
self.__total_frames__ = header.data_size / \
(self.__bits_per_sample__ / 8) / \
self.__channels__
self.__data_offset__ = header.data_offset
self.__data_size__ = header.data_size
except Con.ConstError:
raise InvalidFile(_(u"Invalid Sun AU header"))
except Con.FieldError:
raise InvalidAU(_(u"Invalid Sun AU header"))
@classmethod
def is_type(cls, file):
"""Returns True if the given file object describes this format.
Takes a seekable file pointer rewound to the start of the file."""
return file.read(4) == ".snd"
def lossless(self):
"""Returns True."""
return True
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 channel_mask(self):
"""Returns a ChannelMask object of this track's channel layout."""
if (self.channels() <= 2):
return ChannelMask.from_channels(self.channels())
else:
return ChannelMask(0)
def sample_rate(self):
"""Returns the rate of the track's audio as an integer number of Hz."""
return self.__sample_rate__
def total_frames(self):
"""Returns the total PCM frames of the track as an integer."""
return self.__total_frames__
def to_pcm(self):
"""Returns a PCMReader object containing the track's PCM data."""
f = file(self.filename, 'rb')
f.seek(self.__data_offset__, 0)
return AuReader(au_file=f,
data_size=self.__data_size__,
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):
"""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 AuAudio object."""
if (pcmreader.bits_per_sample not in (8, 16, 24)):
raise InvalidFormat(
_(u"Unsupported bits per sample %s") % (
pcmreader.bits_per_sample))
bytes_per_sample = pcmreader.bits_per_sample / 8
header = Con.Container(magic_number='.snd',
data_offset=0,
data_size=0,
encoding_format={8: 2, 16: 3, 24: 4}[
pcmreader.bits_per_sample],
sample_rate=pcmreader.sample_rate,
channels=pcmreader.channels)
try:
f = file(filename, 'wb')
except IOError, err:
raise EncodingError(str(err))
try:
#send out a dummy header
f.write(AuAudio.AU_HEADER.build(header))
header.data_offset = f.tell()
#send our big-endian PCM data
#d will be a list of ints, so we can't use transfer_data
try:
framelist = pcmreader.read(BUFFER_SIZE)
while (len(framelist) > 0):
bytes = framelist.to_bytes(True, True)
f.write(bytes)
header.data_size += len(bytes)
framelist = pcmreader.read(BUFFER_SIZE)
except (IOError, ValueError), err:
cls.__unlink__(filename)
raise EncodingError(str(err))
except Exception, err:
cls.__unlink__(filename)
raise err
#send out a complete header
f.seek(0, 0)
f.write(AuAudio.AU_HEADER.build(header))
finally:
f.close()
try:
pcmreader.close()
except DecodingError, err:
raise EncodingError(err.error_message)
return AuAudio(filename)
@classmethod
def track_name(cls, file_path, track_metadata=None, format=None,
suffix=None):
"""Constructs a new filename string.
Given a plain string to an existing path,
a MetaData-compatible object (or None),
a UTF-8-encoded Python format string
and an ASCII-encoded suffix string (such as "mp3")
returns a plain string of a new filename with format's
fields filled-in and encoded as FS_ENCODING.
Raises UnsupportedTracknameField if the format string
contains invalid template fields."""
if (format is None):
format = "track%(track_number)2.2d.au"
return AudioFile.track_name(file_path, track_metadata, format,
suffix=cls.SUFFIX)

View File

@ -0,0 +1,687 @@
#!/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 Con, re, os, pcm, cStringIO, struct
class DVDAudio:
"""An object representing an entire DVD-Audio disc.
A DVDAudio object contains one or more DVDATitle objects
(accessible via the .titlesets attribute).
Typically, only the first DVDTitle is interesting.
Each DVDATitle then contains one or more DVDATrack objects.
"""
SECTOR_SIZE = 2048
PTS_PER_SECOND = 90000
AUDIO_TS_IFO = Con.Struct(
"AUDIO_TS_IFO",
Con.Const(Con.Bytes("identifier", 12), "DVDAUDIO-AMG"),
Con.UBInt32("AMG_start_sector"),
Con.Padding(12),
Con.UBInt32("AMGI_end_sector"),
Con.UBInt16("DVD_version"),
Con.Padding(4),
Con.UBInt16("volume_count"),
Con.UBInt16("volume_number"),
Con.UBInt8("disc_side"),
Con.Padding(4),
Con.UBInt8("autoplay"),
Con.UBInt32("ts_to_sv"),
Con.Padding(10),
Con.UBInt8("video_titlesets"),
Con.UBInt8("audio_titlesets"),
Con.Bytes("provider_identifier", 40))
ATS_XX_S1 = Con.Struct(
"ATS_XX",
Con.Const(Con.String("identifier", 12), "DVDAUDIO-ATS"),
Con.UBInt32("ATS_end_sector"),
Con.Padding(12),
Con.UBInt32("ATSI_end_sector"),
Con.UBInt16("DVD_specification_version"),
Con.UBInt32("VTS_category"),
Con.Padding(90),
Con.UBInt32("ATSI_MAT_end_sector"),
Con.Padding(60),
Con.UBInt32("VTSM_VOBS_start_sector"),
Con.UBInt32("ATST_AOBS_start_sector"),
Con.UBInt32("VTS_PTT_SRPT_start_sector"),
Con.UBInt32("ATS_PGCI_UT_start_sector"),
Con.UBInt32("VTSM_PGCI_UT_start_sector"),
Con.UBInt32("VTS_TMAPT_start_sector"),
Con.UBInt32("VTSM_C_ADT_start_sector"),
Con.UBInt32("VTSM_VOBU_ADMA_start_sector"),
Con.UBInt32("VTS_C_ADT_start_sector"),
Con.UBInt32("VTS_VOBU_ADMAP_start_sector"),
Con.Padding(24))
ATS_XX_S2 = Con.Struct(
"ATS_XX2",
Con.UBInt16("title_count"),
Con.Padding(2),
Con.UBInt32("last_byte_address"),
Con.StrictRepeater(
lambda ctx: ctx['title_count'],
Con.Struct('titles',
Con.UBInt16("unknown1"),
Con.UBInt16("unknown2"),
Con.UBInt32("byte_offset"))))
ATS_TITLE = Con.Struct(
"ATS_title",
Con.Bytes("unknown1", 2),
Con.UBInt8("tracks"),
Con.UBInt8("indexes"),
Con.UBInt32("track_length"),
Con.Bytes("unknown2", 4),
Con.UBInt16("sector_pointers_table"),
Con.Bytes("unknown3", 2),
Con.StrictRepeater(
lambda ctx: ctx["tracks"],
Con.Struct("timestamps",
Con.Bytes("unknown1", 2),
Con.Bytes("unknown2", 2),
Con.UBInt8("index_number"),
Con.Bytes("unknown3", 1),
Con.UBInt32("first_pts"),
Con.UBInt32("pts_length"),
Con.Padding(6))))
ATS_SECTOR_POINTER = Con.Struct(
"sector_pointer",
Con.Const(Con.Bytes("unknown", 4),
'\x01\x00\x00\x00'),
Con.UBInt32("first_sector"),
Con.UBInt32("last_sector"))
PACK_HEADER = Con.Struct(
"pack_header",
Con.Const(Con.UBInt32("sync_bytes"), 0x1BA),
Con.Embed(Con.BitStruct(
"markers",
Con.Const(Con.Bits("marker1", 2), 1),
Con.Bits("system_clock_high", 3),
Con.Const(Con.Bits("marker2", 1), 1),
Con.Bits("system_clock_mid", 15),
Con.Const(Con.Bits("marker3", 1), 1),
Con.Bits("system_clock_low", 15),
Con.Const(Con.Bits("marker4", 1), 1),
Con.Bits("scr_extension", 9),
Con.Const(Con.Bits("marker5", 1), 1),
Con.Bits("bit_rate", 22),
Con.Const(Con.Bits("marker6", 2), 3),
Con.Bits("reserved", 5),
Con.Bits("stuffing_length", 3))),
Con.StrictRepeater(lambda ctx: ctx["stuffing_length"],
Con.UBInt8("stuffing")))
PES_HEADER = Con.Struct(
"pes_header",
Con.Const(Con.Bytes("start_code", 3), "\x00\x00\x01"),
Con.UBInt8("stream_id"),
Con.UBInt16("packet_length"))
PACKET_HEADER = Con.Struct(
"packet_header",
Con.UBInt16("unknown1"),
Con.Byte("pad1_size"),
Con.StrictRepeater(lambda ctx: ctx["pad1_size"],
Con.Byte("pad1")),
Con.Byte("stream_id"),
Con.Byte("crc"),
Con.Byte("padding"),
Con.Switch("info",
lambda ctx: ctx["stream_id"],
{0xA0: Con.Struct( # PCM info
"pcm",
Con.Byte("pad2_size"),
Con.UBInt16("first_audio_frame"),
Con.UBInt8("padding2"),
Con.Embed(Con.BitStruct(
"flags",
Con.Bits("group1_bps", 4),
Con.Bits("group2_bps", 4),
Con.Bits("group1_sample_rate", 4),
Con.Bits("group2_sample_rate", 4))),
Con.UBInt8("padding3"),
Con.UBInt8("channel_assignment")),
0xA1: Con.Struct( # MLP info
"mlp",
Con.Byte("pad2_size"),
Con.StrictRepeater(lambda ctx: ctx["pad2_size"],
Con.Byte("pad2")),
Con.Bytes("mlp_size", 4),
Con.Const(Con.Bytes("sync_words", 3), "\xF8\x72\x6F"),
Con.Const(Con.UBInt8("stream_type"), 0xBB),
Con.Embed(Con.BitStruct(
"flags",
Con.Bits("group1_bps", 4),
Con.Bits("group2_bps", 4),
Con.Bits("group1_sample_rate", 4),
Con.Bits("group2_sample_rate", 4),
Con.Bits("unknown1", 11),
Con.Bits("channel_assignment", 5),
Con.Bits("unknown2", 48))))}))
def __init__(self, audio_ts_path, cdrom_device=None):
"""A DVD-A which contains PCMReader-compatible track objects."""
#an inventory of AUDIO_TS files converted to uppercase keys
self.files = dict([(name.upper(),
os.path.join(audio_ts_path, name))
for name in os.listdir(audio_ts_path)])
titleset_numbers = list(self.__titlesets__())
#for each titleset, read an ATS_XX_0.IFO file
#each titleset contains one or more DVDATitle objects
#and each DVDATitle object contains one or more DVDATrack objects
self.titlesets = [self.__titles__(titleset) for titleset in
titleset_numbers]
#for each titleset, calculate the lengths of the corresponding AOBs
#in terms of 2048 byte sectors
self.aob_sectors = []
for titleset in titleset_numbers:
aob_re = re.compile("ATS_%2.2d_\\d\\.AOB" % (titleset))
titleset_aobs = dict([(key, value) for (key, value) in
self.files.items()
if (aob_re.match(key))])
for aob_length in [os.path.getsize(titleset_aobs[key]) /
DVDAudio.SECTOR_SIZE
for key in sorted(titleset_aobs.keys())]:
if (len(self.aob_sectors) == 0):
self.aob_sectors.append(
(0, aob_length))
else:
self.aob_sectors.append(
(self.aob_sectors[-1][1],
self.aob_sectors[-1][1] + aob_length))
try:
if ((cdrom_device is not None) and
('DVDAUDIO.MKB' in self.files.keys())):
from audiotools.prot import CPPMDecoder
self.unprotector = CPPMDecoder(
cdrom_device, self.files['DVDAUDIO.MKB']).decode
else:
self.unprotector = lambda sector: sector
except ImportError:
self.unprotector = lambda sector: sector
def __getitem__(self, key):
return self.titlesets[key]
def __len__(self):
return len(self.titlesets)
def __titlesets__(self):
"""return valid audio titleset integers from AUDIO_TS.IFO"""
try:
f = open(self.files['AUDIO_TS.IFO'], 'rb')
except (KeyError, IOError):
raise InvalidDVDA(_(u"unable to open AUDIO_TS.IFO"))
try:
try:
for titleset in xrange(
1,
DVDAudio.AUDIO_TS_IFO.parse_stream(f).audio_titlesets + 1):
#ensure there are IFO files and AOBs
#for each valid titleset
if (("ATS_%2.2d_0.IFO" % (titleset) in
self.files.keys()) and
("ATS_%2.2d_1.AOB" % (titleset) in
self.files.keys())):
yield titleset
except Con.ConstError:
raise InvalidDVDA(_(u"invalid AUDIO_TS.IFO"))
finally:
f.close()
def __titles__(self, titleset):
"""returns a list of DVDATitle objects for the given titleset"""
try:
f = open(self.files['ATS_%2.2d_0.IFO' % (titleset)], 'rb')
except (KeyError, IOError):
raise InvalidDVDA(
_(u"unable to open ATS_%2.2d_0.IFO") % (titleset))
try:
try:
#the first sector contains little of interest
#but we'll read it to check the identifier string
DVDAudio.ATS_XX_S1.parse_stream(f)
except Con.ConstError:
raise InvalidDVDA(_(u"invalid ATS_%2.2d_0.IFO") % (titleset))
#then move to the second sector and continue parsing
f.seek(DVDAudio.SECTOR_SIZE, os.SEEK_SET)
#may contain one or more titles
title_records = DVDAudio.ATS_XX_S2.parse_stream(f)
titles = []
for (title_number,
title_offset) in enumerate(title_records.titles):
f.seek(DVDAudio.SECTOR_SIZE +
title_offset.byte_offset,
os.SEEK_SET)
title = DVDAudio.ATS_TITLE.parse_stream(f)
f.seek(DVDAudio.SECTOR_SIZE +
title_offset.byte_offset +
title.sector_pointers_table,
os.SEEK_SET)
sector_pointers = ([None] +
[DVDAudio.ATS_SECTOR_POINTER.parse_stream(f)
for i in xrange(title.indexes)])
dvda_title = DVDATitle(dvdaudio=self,
titleset=titleset,
title=title_number + 1,
pts_length=title.track_length,
tracks=[])
#for each track, determine its first and last sector
#based on the sector pointers between the track's
#initial index and the next track's initial index
for (track_number,
(timestamp, next_timestamp)) in enumerate(zip(
title.timestamps, title.timestamps[1:])):
dvda_title.tracks.append(
DVDATrack(
dvdaudio=self,
titleset=titleset,
title=dvda_title,
track=track_number + 1,
first_pts=timestamp.first_pts,
pts_length=timestamp.pts_length,
first_sector=sector_pointers[
timestamp.index_number].first_sector,
last_sector=sector_pointers[
next_timestamp.index_number - 1].last_sector))
#for the last track, its sector pointers
#simply consume what remains on the list
timestamp = title.timestamps[-1]
dvda_title.tracks.append(
DVDATrack(
dvdaudio=self,
titleset=titleset,
title=dvda_title,
track=len(title.timestamps),
first_pts=timestamp.first_pts,
pts_length=timestamp.pts_length,
first_sector=sector_pointers[
timestamp.index_number].first_sector,
last_sector=sector_pointers[-1].last_sector))
titles.append(dvda_title)
return titles
finally:
f.close()
def sector_reader(self, aob_filename):
if (self.unprotector is None):
return SectorReader(aob_filename)
else:
return UnprotectionSectorReader(aob_filename,
self.unprotector)
class InvalidDVDA(Exception):
pass
class DVDATitle:
"""An object representing a DVD-Audio title.
Contains one or more DVDATrack objects
which may are accessible via __getitem__
"""
def __init__(self, dvdaudio, titleset, title, pts_length, tracks):
"""length is in PTS ticks, tracks is a list of DVDATrack objects"""
self.dvdaudio = dvdaudio
self.titleset = titleset
self.title = title
self.pts_length = pts_length
self.tracks = tracks
def __len__(self):
return len(self.tracks)
def __getitem__(self, index):
return self.tracks[index]
def __repr__(self):
return "DVDATitle(%s)" % \
(",".join(["%s=%s" % (key, getattr(self, key))
for key in ["titleset", "title", "pts_length",
"tracks"]]))
def info(self):
"""returns a (sample_rate, channels, channel_mask, bps, type) tuple"""
#find the AOB file of the title's first track
track_sector = self[0].first_sector
titleset = re.compile("ATS_%2.2d_\\d\\.AOB" % (self.titleset))
for aob_path in sorted([self.dvdaudio.files[key] for key in
self.dvdaudio.files.keys()
if (titleset.match(key))]):
aob_sectors = os.path.getsize(aob_path) / DVDAudio.SECTOR_SIZE
if (track_sector > aob_sectors):
track_sector -= aob_sectors
else:
break
else:
raise ValueError(_(u"unable to find track sector in AOB files"))
#open that AOB file and seek to that track's first sector
aob_file = open(aob_path, 'rb')
try:
aob_file.seek(track_sector * DVDAudio.SECTOR_SIZE)
#read the pack header
DVDAudio.PACK_HEADER.parse_stream(aob_file)
#skip packets until the stream ID 0xBD is found
pes_header = DVDAudio.PES_HEADER.parse_stream(aob_file)
while (pes_header.stream_id != 0xBD):
aob_file.read(pes_header.packet_length)
pes_header = DVDAudio.PES_HEADER.parse_stream(aob_file)
#parse the PCM/MLP header
header = DVDAudio.PACKET_HEADER.parse_stream(aob_file)
#return the values indicated by the header
return (DVDATrack.SAMPLE_RATE[
header.info.group1_sample_rate],
DVDATrack.CHANNELS[
header.info.channel_assignment],
DVDATrack.CHANNEL_MASK[
header.info.channel_assignment],
DVDATrack.BITS_PER_SAMPLE[
header.info.group1_bps],
header.stream_id)
finally:
aob_file.close()
def stream(self):
titleset = re.compile("ATS_%2.2d_\\d\\.AOB" % (self.titleset))
return AOBStream(
aob_files=sorted([self.dvdaudio.files[key]
for key in self.dvdaudio.files.keys()
if (titleset.match(key))]),
first_sector=self[0].first_sector,
last_sector=self[-1].last_sector,
unprotector=self.dvdaudio.unprotector)
def to_pcm(self):
(sample_rate,
channels,
channel_mask,
bits_per_sample,
stream_type) = self.info()
if (stream_type == 0xA1):
from audiotools.decoders import MLPDecoder
return MLPDecoder(IterReader(self.stream().packet_payloads()),
(self.pts_length * sample_rate) /
DVDAudio.PTS_PER_SECOND)
elif (stream_type == 0xA0):
from audiotools.decoders import AOBPCMDecoder
return AOBPCMDecoder(IterReader(self.stream().packet_payloads()),
sample_rate,
channels,
channel_mask,
bits_per_sample)
else:
raise ValueError(_(u"unsupported DVD-Audio stream type"))
class DVDATrack:
"""An object representing an individual DVD-Audio track."""
SAMPLE_RATE = [48000, 96000, 192000, 0, 0, 0, 0, 0,
44100, 88200, 176400, 0, 0, 0, 0, 0]
CHANNELS = [1, 2, 3, 4, 3, 4, 5, 3, 4, 5, 4, 5, 6, 4, 5, 4, 5, 6, 5, 5, 6]
CHANNEL_MASK = [0x4, 0x3, 0x103, 0x33, 0xB, 0x10B, 0x3B, 0x7,
0x107, 0x37, 0xF, 0x10F, 0x3F, 0x107, 0x37, 0xF,
0x10F, 0x3F, 0x3B, 0x37, 0x3F]
BITS_PER_SAMPLE = [16, 20, 24] + [0] * 13
def __init__(self, dvdaudio,
titleset, title, track,
first_pts, pts_length,
first_sector, last_sector):
self.dvdaudio = dvdaudio
self.titleset = titleset
self.title = title
self.track = track
self.first_pts = first_pts
self.pts_length = pts_length
self.first_sector = first_sector
self.last_sector = last_sector
def __repr__(self):
return "DVDATrack(%s)" % \
(", ".join(["%s=%s" % (attr, getattr(self, attr))
for attr in ["titleset",
"title",
"track",
"first_pts",
"pts_length",
"first_sector",
"last_sector"]]))
def sectors(self):
"""iterates (aob_file, start_sector, end_sector)
for each AOB file necessary to extract the track's data
in the order in which they should be read."""
track_sectors = Rangeset(self.first_sector,
self.last_sector + 1)
for (i, (start_sector,
end_sector)) in enumerate(self.dvdaudio.aob_sectors):
aob_sectors = Rangeset(start_sector, end_sector)
intersection = aob_sectors & track_sectors
if (len(intersection)):
yield (self.dvdaudio.files["ATS_%2.2d_%d.AOB" % \
(self.titleset, i + 1)],
intersection.start - start_sector,
intersection.end - start_sector)
class Rangeset:
"""An optimized combination of range() and set()"""
#The purpose of this class is for finding the subset of
#two Rangesets, such as with:
#
# >>> Rangeset(1, 10) & Rangeset(5, 15)
# Rangeset(5, 10)
#
#which returns another Rangeset object.
#This is preferable to performing:
#
# >>> set(range(1, 10)) & set(range(5, 15))
# set([8, 9, 5, 6, 7])
#
#which allocates lots of unnecessary values
#when all we're interested in is the min and max.
def __init__(self, start, end):
self.start = start
self.end = end
def __repr__(self):
return "Rangeset(%s, %s)" % (repr(self.start), repr(self.end))
def __len__(self):
return self.end - self.start
def __getitem__(self, i):
if (i >= 0):
if (i < len(self)):
return self.start + i
else:
raise IndexError(i)
else:
if (-i - 1 < len(self)):
return self.end + i
else:
raise IndexError(i)
def __and__(self, rangeset):
min_point = max(self.start, rangeset.start)
max_point = min(self.end, rangeset.end)
if (min_point <= max_point):
return Rangeset(min_point, max_point)
else:
return Rangeset(0, 0)
class AOBSectorReader:
def __init__(self, aob_files):
self.aob_files = list(aob_files)
self.aob_files.sort()
self.current_file_index = 0
self.current_file = open(self.aob_files[self.current_file_index], 'rb')
def read(self, *args):
s = self.current_file.read(DVDAudio.SECTOR_SIZE)
if (len(s) == DVDAudio.SECTOR_SIZE):
return s
else:
try:
#if we can increment to the next file,
#close the current one and do so
self.current_file.close()
self.current_file_index += 1
self.current_file = open(
self.aob_files[self.current_file_index], 'rb')
return self.read()
except IndexError:
#otherwise, we've reached the end of all the files
return ""
def seek(self, sector):
for self.current_file_index in xrange(len(self.aob_files)):
aob_size = os.path.getsize(
self.aob_files[self.current_file_index]) / DVDAudio.SECTOR_SIZE
if (sector <= aob_size):
self.current_file = open(
self.aob_files[self.current_file_index], 'rb')
if (sector > 0):
self.current_file.seek(sector * DVDAudio.SECTOR_SIZE)
return
else:
sector -= aob_size
def close(self):
self.current_file.close()
del(self.aob_files)
del(self.current_file_index)
del(self.current_file)
class AOBStream:
def __init__(self, aob_files, first_sector, last_sector,
unprotector=lambda sector: sector):
self.aob_files = aob_files
self.first_sector = first_sector
self.last_sector = last_sector
self.unprotector = unprotector
def sectors(self):
first_sector = self.first_sector
last_sector = self.last_sector
reader = AOBSectorReader(self.aob_files)
reader.seek(first_sector)
last_sector -= first_sector
for i in xrange(last_sector + 1):
yield self.unprotector(reader.read())
reader.close()
def packets(self):
packet_header_size = struct.calcsize(">3sBH")
for sector in self.sectors():
assert(sector[0:4] == '\x00\x00\x01\xBA')
stuffing_count = ord(sector[13]) & 0x7
sector_bytes = 2048 - (14 + stuffing_count)
sector = cStringIO.StringIO(sector[-sector_bytes:])
while (sector_bytes > 0):
(start_code,
stream_id,
packet_length) = struct.unpack(
">3sBH", sector.read(packet_header_size))
sector_bytes -= packet_header_size
assert(start_code == '\x00\x00\x01')
if (stream_id == 0xBD):
yield sector.read(packet_length)
else:
sector.read(packet_length)
sector_bytes -= packet_length
def packet_payloads(self):
def payload(packet):
pad1_len = ord(packet[2])
pad2_len = ord(packet[3 + pad1_len + 3])
return packet[3 + pad1_len + 4 + pad2_len:]
for packet in self.packets():
yield payload(packet)
class IterReader:
def __init__(self, iterator):
self.iterator = iterator
def read(self, bytes):
try:
return self.iterator.next()
except StopIteration:
return ""
def close(self):
pass

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,724 @@
#!/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 (VERSION, Con, cStringIO, sys, re, MetaData,
AlbumMetaData, AlbumMetaDataFile, __most_numerous__,
DummyAudioFile, MetaDataFileException)
import StringIO
import gettext
gettext.install("audiotools", unicode=True)
#######################
#XMCD
#######################
class XMCDException(MetaDataFileException):
"""Raised if some error occurs parsing an XMCD file."""
def __unicode__(self):
return _(u"Invalid XMCD file")
class XMCD(AlbumMetaDataFile):
LINE_LENGTH = 78
def __init__(self, fields, comments):
"""fields a dict of key->values. comment is a list of comments.
keys are plain strings. values and comments are unicode."""
self.fields = fields
self.comments = comments
def __getattr__(self, key):
if (key == 'album_name'):
dtitle = self.fields.get('DTITLE', u"")
if (u" / " in dtitle):
return dtitle.split(u" / ", 1)[1]
else:
return dtitle
elif (key == 'artist_name'):
dtitle = self.fields.get('DTITLE', u"")
if (u" / " in dtitle):
return dtitle.split(u" / ", 1)[0]
else:
return u""
elif (key == 'year'):
return self.fields.get('DYEAR', u"")
elif (key == 'catalog'):
return u""
elif (key == 'extra'):
return self.fields.get('EXTD', u"")
else:
try:
return self.__dict__[key]
except KeyError:
raise AttributeError(key)
def __setattr__(self, key, value):
if (key == 'album_name'):
dtitle = self.fields.get('DTITLE', u"")
if (u" / " in dtitle):
artist = dtitle.split(u" / ", 1)[0]
self.fields['DTITLE'] = u"%s / %s" % (artist, value)
else:
self.fields['DTITLE'] = value
elif (key == 'artist_name'):
dtitle = self.fields.get('DTITLE', u"")
if (u" / " in dtitle):
album = dtitle.split(u" / ", 1)[1]
else:
album = dtitle
self.fields['DTITLE'] = u"%s / %s" % (value, album)
elif (key == 'year'):
self.fields['DYEAR'] = value
elif (key == 'catalog'):
pass
elif (key == 'extra'):
self.fields['EXTD'] = value
else:
self.__dict__[key] = value
def __len__(self):
track_field = re.compile(r'(TTITLE|EXTT)(\d+)')
return max(set([int(m.group(2)) for m in
[track_field.match(key) for key in self.fields.keys()]
if m is not None])) + 1
def to_string(self):
def write_field(f, key, value):
chars = list(value)
encoded_value = "%s=" % (key)
while ((len(chars) > 0) and
(len(encoded_value +
chars[0].encode('utf-8', 'replace')) <
XMCD.LINE_LENGTH)):
encoded_value += chars.pop(0).encode('utf-8', 'replace')
f.write("%s\r\n" % (encoded_value))
if (len(chars) > 0):
write_field(f, key, u"".join(chars))
output = cStringIO.StringIO()
for comment in self.comments:
output.write(comment.encode('utf-8'))
output.write('\r\n')
fields = set(self.fields.keys())
for field in ['DISCID', 'DTITLE', 'DYEAR', 'DGENRE']:
if (field in fields):
write_field(output, field, self.fields[field])
fields.remove(field)
for i in xrange(len(self)):
field = 'TTITLE%d' % (i)
if (field in fields):
write_field(output, field, self.fields[field])
fields.remove(field)
if ('EXTD' in fields):
write_field(output, 'EXTD', self.fields['EXTD'])
fields.remove('EXTD')
for i in xrange(len(self)):
field = 'EXTT%d' % (i)
if (field in fields):
write_field(output, field, self.fields[field])
fields.remove(field)
for field in fields:
write_field(output, field, self.fields[field])
return output.getvalue()
@classmethod
def from_string(cls, string):
# try:
# data = string.decode('latin-1')
# except UnicodeDecodeError:
# data = string.decode('utf-8','replace')
#FIXME - handle latin-1 files?
data = string.decode('utf-8', 'replace')
if (not data.startswith(u"# xmcd")):
raise XMCDException()
fields = {}
comments = []
field_line = re.compile(r'([A-Z0-9]+?)=(.*)')
for line in StringIO.StringIO(data):
if (line.startswith(u'#')):
comments.append(line.rstrip('\r\n'))
else:
match = field_line.match(line.rstrip('\r\n'))
if (match is not None):
key = match.group(1).encode('ascii')
value = match.group(2)
if (key in fields):
fields[key] += value
else:
fields[key] = value
return cls(fields, comments)
def get_track(self, index):
try:
ttitle = self.fields['TTITLE%d' % (index)]
track_extra = self.fields['EXTT%d' % (index)]
except KeyError:
return (u"", u"", u"")
if (u' / ' in ttitle):
(track_artist, track_title) = ttitle.split(u' / ', 1)
else:
track_title = ttitle
track_artist = u""
return (track_title, track_artist, track_extra)
def set_track(self, index, name, artist, extra):
if ((index < 0) or (index >= len(self))):
raise IndexError(index)
if (len(artist) > 0):
self.fields["TTITLE%d" % (index)] = u"%s / %s" % (artist, name)
else:
self.fields["TTITLE%d" % (index)] = name
if (len(extra) > 0):
self.fields["EXTT%d" % (index)] = extra
@classmethod
def from_tracks(cls, tracks):
def track_string(track, album_artist, metadata):
if (track.track_number() in metadata.keys()):
metadata = metadata[track.track_number()]
if (metadata.artist_name == album_artist):
return metadata.track_name
else:
return u"%s / %s" % (metadata.artist_name,
metadata.track_name)
else:
return u""
audiofiles = [f for f in tracks if f.track_number() != 0]
audiofiles.sort(lambda t1, t2: cmp(t1.track_number(),
t2.track_number()))
discid = DiscID([track.cd_frames() for track in audiofiles])
metadata = dict([(t.track_number(), t.get_metadata())
for t in audiofiles
if (t.get_metadata() is not None)])
artist_names = [m.artist_name for m in metadata.values()]
if (len(artist_names) == 0):
album_artist = u""
elif ((len(artist_names) > 1) and
(len(set(artist_names)) == len(artist_names))):
#if all track artists are different, don't pick one
album_artist = u"Various"
else:
album_artist = __most_numerous__(artist_names)
return cls(dict([("DISCID", str(discid).decode('ascii')),
("DTITLE", u"%s / %s" % \
(album_artist,
__most_numerous__([m.album_name for m in
metadata.values()]))),
("DYEAR", __most_numerous__([m.year for m in
metadata.values()])),
("EXTDD", u""),
("PLAYORDER", u"")] + \
[("TTITLE%d" % (track.track_number() - 1),
track_string(track, album_artist, metadata))
for track in audiofiles] + \
[("EXTT%d" % (track.track_number() - 1),
u"")
for track in audiofiles]),
[u"# xmcd",
u"#",
u"# Track frame offsets:"] +
[u"#\t%d" % (offset) for offset in discid.offsets()] +
[u"#",
u"# Disc length: %d seconds" % (
(discid.length() / 75) + 2),
u"#"])
#######################
#FREEDB
#######################
class DiscID:
"""An object representing a 32 bit FreeDB disc ID value."""
DISCID = Con.Struct('discid',
Con.UBInt8('digit_sum'),
Con.UBInt16('length'),
Con.UBInt8('track_count'))
def __init__(self, tracks=[], offsets=None, length=None, lead_in=150):
"""Fields are as follows:
tracks - a list of track lengths in CD frames
offsets - a list of track offsets in CD frames
length - the length of the entire disc in CD frames
lead_in - the location of the first track on the CD, in frames
These fields are all optional.
One will presumably fill them with data later in that event.
"""
self.tracks = tracks
self.__offsets__ = offsets
self.__length__ = length
self.__lead_in__ = lead_in
@classmethod
def from_cdda(cls, cdda):
"""Given a CDDA object, returns a populated DiscID.
May raise ValueError if there are no audio tracks on the CD."""
tracks = list(cdda)
if (len(tracks) < 1):
raise ValueError(_(u"no audio tracks in CDDA object"))
return cls(tracks=[t.length() for t in tracks],
offsets=[t.offset() for t in tracks],
length=cdda.last_sector(),
lead_in=tracks[0].offset())
def add(self, track):
"""Adds a new track length, in CD frames."""
self.tracks.append(track)
def offsets(self):
"""Returns a list of calculated offset integers, from track lengths."""
if (self.__offsets__ is None):
offsets = [self.__lead_in__]
for track in self.tracks[0:-1]:
offsets.append(track + offsets[-1])
return offsets
else:
return self.__offsets__
def length(self):
"""Returns the total length of the disc, in seconds."""
if (self.__length__ is None):
return sum(self.tracks)
else:
return self.__length__
def idsuffix(self):
"""Returns a FreeDB disc ID suffix string.
This is for making server queries."""
return str(len(self.tracks)) + " " + \
" ".join([str(offset) for offset in self.offsets()]) + \
" " + str((self.length() + self.__lead_in__) / 75)
def __str__(self):
def __count_digits__(i):
if (i == 0):
return 0
else:
return (i % 10) + __count_digits__(i / 10)
disc_id = Con.Container()
disc_id.track_count = len(self.tracks)
disc_id.length = self.length() / 75
disc_id.digit_sum = sum([__count_digits__(o / 75)
for o in self.offsets()]) % 0xFF
return DiscID.DISCID.build(disc_id).encode('hex')
def freedb_id(self):
"""Returns the entire FreeDB disc ID, including suffix."""
return str(self) + " " + self.idsuffix()
def toxmcd(self, output):
"""Writes a newly created XMCD file to output.
Its values are populated from this DiscID's fields."""
output.write(XMCD.from_tracks(
[DummyAudioFile(length, None, i + 1)
for (i, length) in enumerate(self.tracks)]).to_string())
class FreeDBException(Exception):
"""Raised if some problem occurs during FreeDB querying."""
pass
class FreeDB:
"""A class for performing queries on a FreeDB or compatible server.
This operates using the original FreeDB client-server protocol."""
LINE = re.compile(r'\d\d\d\s.+')
def __init__(self, server, port, messenger):
"""server is a string, port is an int, messenger is a Messenger.
Queries are sent to the server, and output to the messenger."""
self.server = server
self.port = port
self.socket = None
self.r = None
self.w = None
self.messenger = messenger
def connect(self):
"""Performs the initial connection."""
import socket
try:
self.messenger.info(_(u"Connecting to \"%s\"") % (self.server))
self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.socket.connect((self.server, self.port))
self.r = self.socket.makefile("rb")
self.w = self.socket.makefile("wb")
(code, msg) = self.read() # the welcome message
if (code == 201):
self.messenger.info(_(u"Connected ... attempting to login"))
else:
self.r.close()
self.w.close()
self.socket.close()
raise FreeDBException(_(u"Invalid hello message"))
self.write("cddb hello user %s %s %s" % \
(socket.getfqdn(), "audiotools", VERSION))
(code, msg) = self.read() # the handshake successful message
if (code != 200):
self.r.close()
self.w.close()
self.socket.close()
raise FreeDBException(_(u"Handshake unsuccessful"))
self.write("proto 6")
(code, msg) = self.read() # the protocol successful message
if ((code != 200) and (code != 201)):
self.r.close()
self.w.close()
self.socket.close()
raise FreeDBException(_(u"Protocol change unsuccessful"))
except socket.error, err:
raise FreeDBException(err[1])
def close(self):
"""Closes an open connection."""
self.messenger.info(_(u"Closing connection"))
self.write("quit")
(code, msg) = self.read() # the quit successful message
self.r.close()
self.w.close()
self.socket.close()
def write(self, line):
"""Writes a single command line to the server."""
if (self.socket is not None):
self.w.write(line)
self.w.write("\r\n")
self.w.flush()
def read(self):
"""Reads a result line from the server."""
line = self.r.readline()
if (FreeDB.LINE.match(line)):
return (int(line[0:3]), line[4:].rstrip("\r\n"))
else:
return (None, line.rstrip("\r\n"))
def query(self, disc_id):
"""Given a DiscID, performs an album query and returns matches.
Each match is a (category, id) pair, which the user may
need to decide between."""
matches = []
self.messenger.info(
_(u"Sending Disc ID \"%(disc_id)s\" to server \"%(server)s\"") % \
{"disc_id": str(disc_id).decode('ascii'),
"server": self.server.decode('ascii', 'replace')})
self.write("cddb query " + disc_id.freedb_id())
(code, msg) = self.read()
if (code == 200):
matches.append(msg)
elif ((code == 211) or (code == 210)):
while (msg != "."):
(code, msg) = self.read()
if (msg != "."):
matches.append(msg)
if (len(matches) == 1):
self.messenger.info(_(u"1 match found"))
else:
self.messenger.info(_(u"%s matches found") % (len(matches)))
return map(lambda m: m.split(" ", 2), matches)
def read_data(self, category, id, output):
"""Reads the FreeDB entry matching category and id to output.
category and id are raw strings, as returned by query().
output is an open file object.
"""
self.write("cddb read " + category + " " + id)
(code, msg) = self.read()
if (code == 210):
line = self.r.readline()
while (line.strip() != "."):
output.write(line)
line = self.r.readline()
else:
print >> sys.stderr, (code, msg)
class FreeDBWeb(FreeDB):
"""A class for performing queries on a FreeDB or compatible server.
This operates using the FreeDB web-based protocol."""
def __init__(self, server, port, messenger):
"""server is a string, port is an int, messenger is a Messenger.
Queries are sent to the server, and output to the messenger."""
self.server = server
self.port = port
self.connection = None
self.messenger = messenger
def connect(self):
"""Performs the initial connection."""
import httplib
self.connection = httplib.HTTPConnection(self.server, self.port,
timeout=10)
def close(self):
"""Closes an open connection."""
if (self.connection is not None):
self.connection.close()
def write(self, line):
"""Writes a single command line to the server."""
import urllib
import socket
u = urllib.urlencode({"hello": "user %s %s %s" % \
(socket.getfqdn(),
"audiotools",
VERSION),
"proto": str(6),
"cmd": line})
try:
self.connection.request(
"POST",
"/~cddb/cddb.cgi",
u,
{"Content-type": "application/x-www-form-urlencoded",
"Accept": "text/plain"})
except socket.error, msg:
raise FreeDBException(str(msg))
def read(self):
"""Reads a result line from the server."""
response = self.connection.getresponse()
return response.read()
def __parse_line__(self, line):
if (FreeDB.LINE.match(line)):
return (int(line[0:3]), line[4:].rstrip("\r\n"))
else:
return (None, line.rstrip("\r\n"))
def query(self, disc_id):
"""Given a DiscID, performs an album query and returns matches.
Each match is a (category, id) pair, which the user may
need to decide between."""
matches = []
self.messenger.info(
_(u"Sending Disc ID \"%(disc_id)s\" to server \"%(server)s\"") % \
{"disc_id": str(disc_id).decode('ascii'),
"server": self.server.decode('ascii', 'replace')})
self.write("cddb query " + disc_id.freedb_id())
data = cStringIO.StringIO(self.read())
(code, msg) = self.__parse_line__(data.readline())
if (code == 200):
matches.append(msg)
elif ((code == 211) or (code == 210)):
while (msg != "."):
(code, msg) = self.__parse_line__(data.readline())
if (msg != "."):
matches.append(msg)
if (len(matches) == 1):
self.messenger.info(_(u"1 match found"))
else:
self.messenger.info(_(u"%s matches found") % (len(matches)))
return map(lambda m: m.split(" ", 2), matches)
def read_data(self, category, id, output):
"""Reads the FreeDB entry matching category and id to output.
category and id are raw strings, as returned by query().
output is an open file object.
"""
self.write("cddb read " + category + " " + id)
data = cStringIO.StringIO(self.read())
(code, msg) = self.__parse_line__(data.readline())
if (code == 210):
line = data.readline()
while (line.strip() != "."):
output.write(line)
line = data.readline()
else:
print >> sys.stderr, (code, msg)
#matches is a list of (category,disc_id,title) tuples returned from
#FreeDB.query(). If the length of that list is 1, return the first
#item. If the length is greater than one, present the user a list of
#choices and force him/her to pick the closest match for the CD.
#That data can then be sent to FreeDB.read_data()
def __select_match__(matches, messenger):
if (len(matches) == 1):
return matches[0]
elif (len(matches) < 1):
return None
else:
messenger.info(_(u"Please Select the Closest Match:"))
selected = 0
while ((selected < 1) or (selected > len(matches))):
for i in range(len(matches)):
messenger.info(_(u"%(choice)s) [%(genre)s] %(name)s") % \
{"choice": i + 1,
"genre": matches[i][0],
"name": matches[i][2].decode('utf-8',
'replace')})
try:
messenger.partial_info(_(u"Your Selection [1-%s]:") % \
(len(matches)))
selected = int(sys.stdin.readline().strip())
except ValueError:
selected = 0
return matches[selected - 1]
def __select_default_match__(matches, selection):
if (len(matches) < 1):
return None
else:
try:
return matches[selection]
except IndexError:
return matches[0]
def get_xmcd(disc_id, output, freedb_server, freedb_server_port,
messenger, default_selection=None):
"""Runs through the entire FreeDB querying sequence.
Fields are as follows:
disc_id - a DiscID object
output - an open file object for writing
freedb_server - a server name string
freedb_port - a server port int
messenger - a Messenger object
default_selection - if given, the default match to choose
"""
try:
freedb = FreeDBWeb(freedb_server, freedb_server_port, messenger)
freedb.connect()
except FreeDBException, msg:
#if an exception occurs during the opening,
#freedb will auto-close its sockets
raise IOError(str(msg))
try:
matches = freedb.query(disc_id)
#HANDLE MULTIPLE MATCHES, or NO MATCHES
if (len(matches) > 0):
if (default_selection is None):
(category, idstring, title) = __select_match__(
matches, messenger)
else:
(category, idstring, title) = __select_default_match__(
matches, default_selection)
freedb.read_data(category, idstring, output)
output.flush()
freedb.close()
except FreeDBException, msg:
#otherwise, close the sockets manually
freedb.close()
raise IOError(str(msg))
return len(matches)

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,190 @@
#!/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 MetaData, Con, os
class ID3v1Comment(MetaData, list):
"""A complete ID3v1 tag."""
ID3v1 = Con.Struct("id3v1",
Con.Const(Con.String("identifier", 3), 'TAG'),
Con.String("song_title", 30),
Con.String("artist", 30),
Con.String("album", 30),
Con.String("year", 4),
Con.String("comment", 28),
Con.Padding(1),
Con.Byte("track_number"),
Con.Byte("genre"))
ID3v1_NO_TRACKNUMBER = Con.Struct("id3v1_notracknumber",
Con.Const(Con.String("identifier", 3), 'TAG'),
Con.String("song_title", 30),
Con.String("artist", 30),
Con.String("album", 30),
Con.String("year", 4),
Con.String("comment", 30),
Con.Byte("genre"))
ATTRIBUTES = ['track_name',
'artist_name',
'album_name',
'year',
'comment',
'track_number']
@classmethod
def read_id3v1_comment(cls, mp3filename):
"""Reads a ID3v1Comment data from an MP3 filename.
Returns a (song title, artist, album, year, comment, track number)
tuple.
If no ID3v1 tag is present, returns a tuple with those fields blank.
All text is in unicode.
If track number is -1, the id3v1 comment could not be found.
"""
mp3file = file(mp3filename, "rb")
try:
mp3file.seek(-128, 2)
try:
id3v1 = ID3v1Comment.ID3v1.parse(mp3file.read())
except Con.adapters.PaddingError:
mp3file.seek(-128, 2)
id3v1 = ID3v1Comment.ID3v1_NO_TRACKNUMBER.parse(mp3file.read())
id3v1.track_number = 0
except Con.ConstError:
return tuple([u""] * 5 + [-1])
field_list = (id3v1.song_title,
id3v1.artist,
id3v1.album,
id3v1.year,
id3v1.comment)
return tuple(map(lambda t:
t.rstrip('\x00').decode('ascii', 'replace'),
field_list) + [id3v1.track_number])
finally:
mp3file.close()
@classmethod
def build_id3v1(cls, song_title, artist, album, year, comment,
track_number):
"""Turns fields into a complete ID3v1 binary tag string.
All fields are unicode except for track_number, an int."""
def __s_pad__(s, length):
if (len(s) < length):
return s + chr(0) * (length - len(s))
else:
s = s[0:length].rstrip()
return s + chr(0) * (length - len(s))
c = Con.Container()
c.identifier = 'TAG'
c.song_title = __s_pad__(song_title.encode('ascii', 'replace'), 30)
c.artist = __s_pad__(artist.encode('ascii', 'replace'), 30)
c.album = __s_pad__(album.encode('ascii', 'replace'), 30)
c.year = __s_pad__(year.encode('ascii', 'replace'), 4)
c.comment = __s_pad__(comment.encode('ascii', 'replace'), 28)
c.track_number = int(track_number)
c.genre = 0
return ID3v1Comment.ID3v1.build(c)
def __init__(self, metadata):
"""Initialized with a read_id3v1_comment tuple.
Fields are (title,artist,album,year,comment,tracknum)"""
list.__init__(self, metadata)
@classmethod
def supports_images(cls):
"""Returns False."""
return False
#if an attribute is updated (e.g. self.track_name)
#make sure to update the corresponding list item
def __setattr__(self, key, value):
if (key in self.ATTRIBUTES):
if (key != 'track_number'):
self[self.ATTRIBUTES.index(key)] = value
else:
self[self.ATTRIBUTES.index(key)] = int(value)
elif (key in MetaData.__FIELDS__):
pass
else:
self.__dict__[key] = value
def __delattr__(self, key):
if (key == 'track_number'):
setattr(self, key, 0)
elif (key in self.ATTRIBUTES):
setattr(self, key, u"")
def __getattr__(self, key):
if (key in self.ATTRIBUTES):
return self[self.ATTRIBUTES.index(key)]
elif (key in MetaData.__INTEGER_FIELDS__):
return 0
elif (key in MetaData.__FIELDS__):
return u""
else:
raise AttributeError(key)
@classmethod
def converted(cls, metadata):
"""Converts a MetaData object to an ID3v1Comment object."""
if ((metadata is None) or (isinstance(metadata, ID3v1Comment))):
return metadata
return ID3v1Comment((metadata.track_name,
metadata.artist_name,
metadata.album_name,
metadata.year,
metadata.comment,
int(metadata.track_number)))
def __comment_name__(self):
return u'ID3v1'
def __comment_pairs__(self):
return zip(('Title', 'Artist', 'Album', 'Year', 'Comment', 'Tracknum'),
self)
def build_tag(self):
"""Returns a binary string of this tag's data."""
return self.build_id3v1(self.track_name,
self.artist_name,
self.album_name,
self.year,
self.comment,
self.track_number)
def images(self):
"""Returns an empty list of Image objects."""
return []

View File

@ -0,0 +1,538 @@
#!/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 Con
import imghdr
import cStringIO
import gettext
gettext.install("audiotools", unicode=True)
def __jpeg__(h, f):
if (h[0:3] == "FFD8FF".decode('hex')):
return 'jpeg'
else:
return None
imghdr.tests.append(__jpeg__)
def image_metrics(file_data):
"""Returns an ImageMetrics subclass from a string of file data.
Raises InvalidImage if there is an error parsing the file
or its type is unknown."""
header = imghdr.what(None, file_data)
file = cStringIO.StringIO(file_data)
try:
if (header == 'jpeg'):
return __JPEG__.parse(file)
elif (header == 'png'):
return __PNG__.parse(file)
elif (header == 'gif'):
return __GIF__.parse(file)
elif (header == 'bmp'):
return __BMP__.parse(file)
elif (header == 'tiff'):
return __TIFF__.parse(file)
else:
raise InvalidImage(_(u'Unknown image type'))
finally:
file.close()
#######################
#JPEG
#######################
class ImageMetrics:
"""A container for image data."""
def __init__(self, width, height, bits_per_pixel, color_count, mime_type):
"""Fields are as follows:
width - image width as an integer number of pixels
height - image height as an integer number of pixels
bits_per_pixel - the number of bits per pixel as an integer
color_count - for palette-based images, the total number of colors
mime_type - the image's MIME type, as a string
All of the ImageMetrics subclasses implement these fields.
In addition, they all implement a parse() classmethod
used to parse binary string data and return something
ImageMetrics compatible.
"""
self.width = width
self.height = height
self.bits_per_pixel = bits_per_pixel
self.color_count = color_count
self.mime_type = mime_type
def __repr__(self):
return "ImageMetrics(%s,%s,%s,%s,%s)" % \
(repr(self.width),
repr(self.height),
repr(self.bits_per_pixel),
repr(self.color_count),
repr(self.mime_type))
class InvalidImage(Exception):
"""Raised if an image cannot be parsed correctly."""
def __init__(self, err):
self.err = unicode(err)
def __unicode__(self):
return self.err
class InvalidJPEG(InvalidImage):
"""Raised if a JPEG cannot be parsed correctly."""
pass
class __JPEG__(ImageMetrics):
SEGMENT_HEADER = Con.Struct('segment_header',
Con.Const(Con.Byte('header'), 0xFF),
Con.Byte('type'),
Con.If(
lambda ctx: ctx['type'] not in (0xD8, 0xD9),
Con.UBInt16('length')))
APP0 = Con.Struct('JFIF_segment_marker',
Con.String('identifier', 5),
Con.Byte('major_version'),
Con.Byte('minor_version'),
Con.Byte('density_units'),
Con.UBInt16('x_density'),
Con.UBInt16('y_density'),
Con.Byte('thumbnail_width'),
Con.Byte('thumbnail_height'))
SOF = Con.Struct('start_of_frame',
Con.Byte('data_precision'),
Con.UBInt16('image_height'),
Con.UBInt16('image_width'),
Con.Byte('components'))
def __init__(self, width, height, bits_per_pixel):
ImageMetrics.__init__(self, width, height, bits_per_pixel,
0, u'image/jpeg')
@classmethod
def parse(cls, file):
try:
header = cls.SEGMENT_HEADER.parse_stream(file)
if (header.type != 0xD8):
raise InvalidJPEG(_(u'Invalid JPEG header'))
segment = cls.SEGMENT_HEADER.parse_stream(file)
while (segment.type != 0xD9):
if (segment.type == 0xDA):
break
if (segment.type in (0xC0, 0xC1, 0xC2, 0xC3,
0xC5, 0XC5, 0xC6, 0xC7,
0xC9, 0xCA, 0xCB, 0xCD,
0xCE, 0xCF)): # start of frame
segment_data = cStringIO.StringIO(
file.read(segment.length - 2))
frame0 = cls.SOF.parse_stream(segment_data)
segment_data.close()
return __JPEG__(width=frame0.image_width,
height=frame0.image_height,
bits_per_pixel=(frame0.data_precision *
frame0.components))
else:
file.seek(segment.length - 2, 1)
segment = cls.SEGMENT_HEADER.parse_stream(file)
raise InvalidJPEG(_(u'Start of frame not found'))
except Con.ConstError:
raise InvalidJPEG(_(u"Invalid JPEG segment marker at 0x%X") % \
(file.tell()))
#######################
#PNG
#######################
class InvalidPNG(InvalidImage):
"""Raised if a PNG cannot be parsed correctly."""
pass
class __PNG__(ImageMetrics):
HEADER = Con.Const(Con.String('header', 8),
'89504e470d0a1a0a'.decode('hex'))
CHUNK_HEADER = Con.Struct('chunk',
Con.UBInt32('length'),
Con.String('type', 4))
CHUNK_FOOTER = Con.Struct('crc32',
Con.UBInt32('crc'))
IHDR = Con.Struct('IHDR',
Con.UBInt32('width'),
Con.UBInt32('height'),
Con.Byte('bit_depth'),
Con.Byte('color_type'),
Con.Byte('compression_method'),
Con.Byte('filter_method'),
Con.Byte('interlace_method'))
def __init__(self, width, height, bits_per_pixel, color_count):
ImageMetrics.__init__(self, width, height, bits_per_pixel, color_count,
u'image/png')
@classmethod
def parse(cls, file):
ihdr = None
plte = None
try:
header = cls.HEADER.parse_stream(file)
chunk_header = cls.CHUNK_HEADER.parse_stream(file)
data = file.read(chunk_header.length)
chunk_footer = cls.CHUNK_FOOTER.parse_stream(file)
while (chunk_header.type != 'IEND'):
if (chunk_header.type == 'IHDR'):
ihdr = cls.IHDR.parse(data)
elif (chunk_header.type == 'PLTE'):
plte = data
chunk_header = cls.CHUNK_HEADER.parse_stream(file)
data = file.read(chunk_header.length)
chunk_footer = cls.CHUNK_FOOTER.parse_stream(file)
if (ihdr.color_type == 0): # grayscale
bits_per_pixel = ihdr.bit_depth
color_count = 0
elif (ihdr.color_type == 2): # RGB
bits_per_pixel = ihdr.bit_depth * 3
color_count = 0
elif (ihdr.color_type == 3): # palette
bits_per_pixel = 8
if ((len(plte) % 3) != 0):
raise InvalidPNG(_(u'Invalid PLTE chunk length'))
else:
color_count = len(plte) / 3
elif (ihdr.color_type == 4): # grayscale + alpha
bits_per_pixel = ihdr.bit_depth * 2
color_count = 0
elif (ihdr.color_type == 6): # RGB + alpha
bits_per_pixel = ihdr.bit_depth * 4
color_count = 0
return __PNG__(ihdr.width, ihdr.height, bits_per_pixel,
color_count)
except Con.ConstError:
raise InvalidPNG(_(u'Invalid PNG'))
#######################
#BMP
#######################
class InvalidBMP(InvalidImage):
"""Raised if a BMP cannot be parsed correctly."""
pass
class __BMP__(ImageMetrics):
HEADER = Con.Struct('bmp_header',
Con.Const(Con.String('magic_number', 2), 'BM'),
Con.ULInt32('file_size'),
Con.ULInt16('reserved1'),
Con.ULInt16('reserved2'),
Con.ULInt32('bitmap_data_offset'))
INFORMATION = Con.Struct('bmp_information',
Con.ULInt32('header_size'),
Con.ULInt32('width'),
Con.ULInt32('height'),
Con.ULInt16('color_planes'),
Con.ULInt16('bits_per_pixel'),
Con.ULInt32('compression_method'),
Con.ULInt32('image_size'),
Con.ULInt32('horizontal_resolution'),
Con.ULInt32('vertical_resolution'),
Con.ULInt32('colors_used'),
Con.ULInt32('important_colors_used'))
def __init__(self, width, height, bits_per_pixel, color_count):
ImageMetrics.__init__(self, width, height, bits_per_pixel, color_count,
u'image/x-ms-bmp')
@classmethod
def parse(cls, file):
try:
header = cls.HEADER.parse_stream(file)
information = cls.INFORMATION.parse_stream(file)
return __BMP__(information.width, information.height,
information.bits_per_pixel,
information.colors_used)
except Con.ConstError:
raise InvalidBMP(_(u'Invalid BMP'))
#######################
#GIF
#######################
class InvalidGIF(InvalidImage):
"""Raised if a GIF cannot be parsed correctly."""
pass
class __GIF__(ImageMetrics):
HEADER = Con.Struct('header',
Con.Const(Con.String('gif', 3), 'GIF'),
Con.String('version', 3))
SCREEN_DESCRIPTOR = Con.Struct('logical_screen_descriptor',
Con.ULInt16('width'),
Con.ULInt16('height'),
Con.Embed(
Con.BitStruct('packed_fields',
Con.Flag('global_color_table'),
Con.Bits('color_resolution', 3),
Con.Flag('sort'),
Con.Bits('global_color_table_size', 3))),
Con.Byte('background_color_index'),
Con.Byte('pixel_aspect_ratio'))
def __init__(self, width, height, color_count):
ImageMetrics.__init__(self, width, height, 8, color_count,
u'image/gif')
@classmethod
def parse(cls, file):
try:
header = cls.HEADER.parse_stream(file)
descriptor = cls.SCREEN_DESCRIPTOR.parse_stream(file)
return __GIF__(descriptor.width, descriptor.height,
2 ** (descriptor.global_color_table_size + 1))
except Con.ConstError:
raise InvalidGIF(_(u'Invalid GIF'))
#######################
#TIFF
#######################
class InvalidTIFF(InvalidImage):
"""Raised if a TIFF cannot be parsed correctly."""
pass
class __TIFF__(ImageMetrics):
HEADER = Con.Struct('header',
Con.String('byte_order', 2),
Con.Switch('order',
lambda ctx: ctx['byte_order'],
{"II": Con.Embed(
Con.Struct('little_endian',
Con.Const(Con.ULInt16('version'), 42),
Con.ULInt32('offset'))),
"MM": Con.Embed(
Con.Struct('big_endian',
Con.Const(Con.UBInt16('version'), 42),
Con.UBInt32('offset')))}))
L_IFD = Con.Struct('ifd',
Con.PrefixedArray(
length_field=Con.ULInt16('length'),
subcon=Con.Struct('tags',
Con.ULInt16('id'),
Con.ULInt16('type'),
Con.ULInt32('count'),
Con.ULInt32('offset'))),
Con.ULInt32('next'))
B_IFD = Con.Struct('ifd',
Con.PrefixedArray(
length_field=Con.UBInt16('length'),
subcon=Con.Struct('tags',
Con.UBInt16('id'),
Con.UBInt16('type'),
Con.UBInt32('count'),
Con.UBInt32('offset'))),
Con.UBInt32('next'))
def __init__(self, width, height, bits_per_pixel, color_count):
ImageMetrics.__init__(self, width, height,
bits_per_pixel, color_count,
u'image/tiff')
@classmethod
def b_tag_value(cls, file, tag):
subtype = {1: Con.Byte("data"),
2: Con.CString("data"),
3: Con.UBInt16("data"),
4: Con.UBInt32("data"),
5: Con.Struct("data",
Con.UBInt32("high"),
Con.UBInt32("low"))}[tag.type]
data = Con.StrictRepeater(tag.count,
subtype)
if ((tag.type != 2) and (data.sizeof() <= 4)):
return tag.offset
else:
file.seek(tag.offset, 0)
return data.parse_stream(file)
@classmethod
def l_tag_value(cls, file, tag):
subtype = {1: Con.Byte("data"),
2: Con.CString("data"),
3: Con.ULInt16("data"),
4: Con.ULInt32("data"),
5: Con.Struct("data",
Con.ULInt32("high"),
Con.ULInt32("low"))}[tag.type]
data = Con.StrictRepeater(tag.count,
subtype)
if ((tag.type != 2) and (data.sizeof() <= 4)):
return tag.offset
else:
file.seek(tag.offset, 0)
return data.parse_stream(file)
@classmethod
def parse(cls, file):
width = 0
height = 0
bits_per_sample = 0
color_count = 0
try:
header = cls.HEADER.parse_stream(file)
if (header.byte_order == 'II'):
IFD = cls.L_IFD
tag_value = cls.l_tag_value
elif (header.byte_order == 'MM'):
IFD = cls.B_IFD
tag_value = cls.b_tag_value
else:
raise InvalidTIFF(_(u'Invalid byte order'))
file.seek(header.offset, 0)
ifd = IFD.parse_stream(file)
while (True):
for tag in ifd.tags:
if (tag.id == 0x0100):
width = tag_value(file, tag)
elif (tag.id == 0x0101):
height = tag_value(file, tag)
elif (tag.id == 0x0102):
try:
bits_per_sample = sum(tag_value(file, tag))
except TypeError:
bits_per_sample = tag_value(file, tag)
elif (tag.id == 0x0140):
color_count = tag.count / 3
else:
pass
if (ifd.next == 0x00):
break
else:
file.seek(ifd.next, 0)
ifd = IFD.parse_stream(file)
return __TIFF__(width, height, bits_per_sample, color_count)
except Con.ConstError:
raise InvalidTIFF(_(u'Invalid TIFF'))
def can_thumbnail():
"""Returns True if we have the capability to thumbnail images."""
try:
import Image as PIL_Image
return True
except ImportError:
return False
def thumbnail_formats():
"""Returns a list of available thumbnail image formats."""
import Image as PIL_Image
import cStringIO
#performing a dummy save seeds PIL_Image.SAVE with possible save types
PIL_Image.new("RGB", (1, 1)).save(cStringIO.StringIO(), "bmp")
return PIL_Image.SAVE.keys()
def thumbnail_image(image_data, width, height, format):
"""Generates a new, smaller image from a larger one.
image_data is a binary string.
width and height are the requested maximum values.
format as a binary string, such as 'JPEG'.
"""
import cStringIO
import Image as PIL_Image
import ImageFile as PIL_ImageFile
PIL_ImageFile.MAXBLOCK = 0x100000
img = PIL_Image.open(cStringIO.StringIO(image_data)).convert('RGB')
img.thumbnail((width, height), PIL_Image.ANTIALIAS)
output = cStringIO.StringIO()
if (format.upper() == 'JPEG'):
#PIL's default JPEG save quality isn't too great
#so it's best to add a couple of optimizing parameters
#since this is a common case
img.save(output, 'JPEG', quality=90, optimize=True)
else:
img.save(output, format)
return output.getvalue()

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,387 @@
#!/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 Con
#M4A atoms are typically laid on in the file as follows:
# ftyp
# mdat
# moov/
# +mvhd
# +iods
# +trak/
# +-tkhd
# +-mdia/
# +--mdhd
# +--hdlr
# +--minf/
# +---smhd
# +---dinf/
# +----dref
# +---stbl/
# +----stsd
# +----stts
# +----stsz
# +----stsc
# +----stco
# +----ctts
# +udta/
# +-meta
#
#Where atoms ending in / are container atoms and the rest are leaf atoms.
#'mdat' is where the file's audio stream is stored
#the rest are various bits of metadata
def VersionLength(name):
"""A struct for 32 or 64 bit fields, depending on version field."""
return Con.IfThenElse(name,
lambda ctx: ctx["version"] == 0,
Con.UBInt32(None),
Con.UBInt64(None))
class AtomAdapter(Con.Adapter):
"""An adapter which manages a proper size field."""
def _encode(self, obj, context):
obj.size = len(obj.data) + 8
return obj
def _decode(self, obj, context):
del(obj.size)
return obj
def Atom(name):
"""A basic QuickTime atom struct."""
return AtomAdapter(Con.Struct(
name,
Con.UBInt32("size"),
Con.String("type", 4),
Con.String("data", lambda ctx: ctx["size"] - 8)))
class AtomListAdapter(Con.Adapter):
"""An adapter for turning an Atom into a list of atoms.
This works by parsing its data contents with Atom."""
ATOM_LIST = Con.GreedyRepeater(Atom("atoms"))
def _encode(self, obj, context):
obj.data = self.ATOM_LIST.build(obj.data)
return obj
def _decode(self, obj, context):
obj.data = self.ATOM_LIST.parse(obj.data)
return obj
def AtomContainer(name):
"""An instantiation of AtomListAdapter."""
return AtomListAdapter(Atom(name))
class AtomWrapper(Con.Struct):
"""Wraps around an existing sub_atom and automatically handles headers."""
def __init__(self, atom_name, sub_atom):
Con.Struct.__init__(self, atom_name)
self.atom_name = atom_name
self.sub_atom = sub_atom
self.header = Con.Struct(atom_name,
Con.UBInt32("size"),
Con.Const(Con.String("type", 4), atom_name))
def _parse(self, stream, context):
header = self.header.parse_stream(stream)
return self.sub_atom.parse_stream(stream)
def _build(self, obj, stream, context):
data = self.sub_atom.build(obj)
stream.write(self.header.build(Con.Container(type=self.atom_name,
size=len(data) + 8)))
stream.write(data)
def _sizeof(self, context):
return self.sub_atom.sizeof(context) + 8
ATOM_FTYP = Con.Struct(
"ftyp",
Con.String("major_brand", 4),
Con.UBInt32("major_brand_version"),
Con.GreedyRepeater(Con.String("compatible_brands", 4)))
ATOM_MVHD = Con.Struct(
"mvhd",
Con.Byte("version"),
Con.String("flags", 3),
VersionLength("created_mac_UTC_date"),
VersionLength("modified_mac_UTC_date"),
Con.UBInt32("time_scale"),
VersionLength("duration"),
Con.UBInt32("playback_speed"),
Con.UBInt16("user_volume"),
Con.Padding(10),
Con.Struct("windows",
Con.UBInt32("geometry_matrix_a"),
Con.UBInt32("geometry_matrix_b"),
Con.UBInt32("geometry_matrix_u"),
Con.UBInt32("geometry_matrix_c"),
Con.UBInt32("geometry_matrix_d"),
Con.UBInt32("geometry_matrix_v"),
Con.UBInt32("geometry_matrix_x"),
Con.UBInt32("geometry_matrix_y"),
Con.UBInt32("geometry_matrix_w")),
Con.UBInt64("quicktime_preview"),
Con.UBInt32("quicktime_still_poster"),
Con.UBInt64("quicktime_selection_time"),
Con.UBInt32("quicktime_current_time"),
Con.UBInt32("next_track_id"))
ATOM_IODS = Con.Struct(
"iods",
Con.Byte("version"),
Con.String("flags", 3),
Con.Byte("type_tag"),
Con.Switch("descriptor",
lambda ctx: ctx.type_tag,
{0x10: Con.Struct(
None,
Con.StrictRepeater(3, Con.Byte("extended_descriptor_type")),
Con.Byte("descriptor_type_length"),
Con.UBInt16("OD_ID"),
Con.Byte("OD_profile"),
Con.Byte("scene_profile"),
Con.Byte("audio_profile"),
Con.Byte("video_profile"),
Con.Byte("graphics_profile")),
0x0E: Con.Struct(
None,
Con.StrictRepeater(3, Con.Byte("extended_descriptor_type")),
Con.Byte("descriptor_type_length"),
Con.String("track_id", 4))}))
ATOM_TKHD = Con.Struct(
"tkhd",
Con.Byte("version"),
Con.BitStruct("flags",
Con.Padding(20),
Con.Flag("TrackInPoster"),
Con.Flag("TrackInPreview"),
Con.Flag("TrackInMovie"),
Con.Flag("TrackEnabled")),
VersionLength("created_mac_UTC_date"),
VersionLength("modified_mac_UTC_date"),
Con.UBInt32("track_id"),
Con.Padding(4),
VersionLength("duration"),
Con.Padding(8),
Con.UBInt16("video_layer"),
Con.UBInt16("quicktime_alternate"),
Con.UBInt16("volume"),
Con.Padding(2),
Con.Struct("video",
Con.UBInt32("geometry_matrix_a"),
Con.UBInt32("geometry_matrix_b"),
Con.UBInt32("geometry_matrix_u"),
Con.UBInt32("geometry_matrix_c"),
Con.UBInt32("geometry_matrix_d"),
Con.UBInt32("geometry_matrix_v"),
Con.UBInt32("geometry_matrix_x"),
Con.UBInt32("geometry_matrix_y"),
Con.UBInt32("geometry_matrix_w")),
Con.UBInt32("video_width"),
Con.UBInt32("video_height"))
ATOM_MDHD = Con.Struct(
"mdhd",
Con.Byte("version"),
Con.String("flags", 3),
VersionLength("created_mac_UTC_date"),
VersionLength("modified_mac_UTC_date"),
Con.UBInt32("time_scale"),
VersionLength("duration"),
Con.BitStruct("languages",
Con.Padding(1),
Con.StrictRepeater(3,
Con.Bits("language", 5))),
Con.UBInt16("quicktime_quality"))
ATOM_HDLR = Con.Struct(
"hdlr",
Con.Byte("version"),
Con.String("flags", 3),
Con.String("quicktime_type", 4),
Con.String("subtype", 4),
Con.String("quicktime_manufacturer", 4),
Con.UBInt32("quicktime_component_reserved_flags"),
Con.UBInt32("quicktime_component_reserved_flags_mask"),
Con.PascalString("component_name"),
Con.Padding(1))
ATOM_SMHD = Con.Struct(
'smhd',
Con.Byte("version"),
Con.String("flags", 3),
Con.String("audio_balance", 2),
Con.Padding(2))
ATOM_DREF = Con.Struct(
'dref',
Con.Byte("version"),
Con.String("flags", 3),
Con.PrefixedArray(
length_field=Con.UBInt32("num_references"),
subcon=Atom("references")))
ATOM_STSD = Con.Struct(
'stsd',
Con.Byte("version"),
Con.String("flags", 3),
Con.PrefixedArray(
length_field=Con.UBInt32("num_descriptions"),
subcon=Atom("descriptions")))
ATOM_MP4A = Con.Struct(
"mp4a",
Con.Padding(6),
Con.UBInt16("reference_index"),
Con.UBInt16("quicktime_audio_encoding_version"),
Con.UBInt16("quicktime_audio_encoding_revision"),
Con.String("quicktime_audio_encoding_vendor", 4),
Con.UBInt16("channels"),
Con.UBInt16("sample_size"),
Con.UBInt16("audio_compression_id"),
Con.UBInt16("quicktime_audio_packet_size"),
Con.String("sample_rate", 4))
#out of all this mess, the only interesting bits are the _bit_rate fields
#and (maybe) the buffer_size
#everything else is a constant of some kind as far as I can tell
ATOM_ESDS = Con.Struct(
"esds",
Con.Byte("version"),
Con.String("flags", 3),
Con.Byte("ES_descriptor_type"),
Con.StrictRepeater(
3, Con.Byte("extended_descriptor_type_tag")),
Con.Byte("descriptor_type_length"),
Con.UBInt16("ES_ID"),
Con.Byte("stream_priority"),
Con.Byte("decoder_config_descriptor_type"),
Con.StrictRepeater(
3, Con.Byte("extended_descriptor_type_tag2")),
Con.Byte("descriptor_type_length2"),
Con.Byte("object_ID"),
Con.Embed(
Con.BitStruct(None, Con.Bits("stream_type", 6),
Con.Flag("upstream_flag"),
Con.Flag("reserved_flag"),
Con.Bits("buffer_size", 24))),
Con.UBInt32("maximum_bit_rate"),
Con.UBInt32("average_bit_rate"),
Con.Byte('decoder_specific_descriptor_type3'),
Con.StrictRepeater(
3, Con.Byte("extended_descriptor_type_tag2")),
Con.PrefixedArray(
length_field=Con.Byte("ES_header_length"),
subcon=Con.Byte("ES_header_start_codes")),
Con.Byte("SL_config_descriptor_type"),
Con.StrictRepeater(
3, Con.Byte("extended_descriptor_type_tag3")),
Con.Byte("descriptor_type_length3"),
Con.Byte("SL_value"))
ATOM_STTS = Con.Struct(
'stts',
Con.Byte("version"),
Con.String("flags", 3),
Con.PrefixedArray(length_field=Con.UBInt32("total_counts"),
subcon=Con.Struct("frame_size_counts",
Con.UBInt32("frame_count"),
Con.UBInt32("duration"))))
ATOM_STSZ = Con.Struct(
'stsz',
Con.Byte("version"),
Con.String("flags", 3),
Con.UBInt32("block_byte_size"),
Con.PrefixedArray(length_field=Con.UBInt32("total_sizes"),
subcon=Con.UBInt32("block_byte_sizes")))
ATOM_STSC = Con.Struct(
'stsc',
Con.Byte("version"),
Con.String("flags", 3),
Con.PrefixedArray(
length_field=Con.UBInt32("entry_count"),
subcon=Con.Struct("block",
Con.UBInt32("first_chunk"),
Con.UBInt32("samples_per_chunk"),
Con.UBInt32("sample_description_index"))))
ATOM_STCO = Con.Struct(
'stco',
Con.Byte("version"),
Con.String("flags", 3),
Con.PrefixedArray(
length_field=Con.UBInt32("total_offsets"),
subcon=Con.UBInt32("offset")))
ATOM_CTTS = Con.Struct(
'ctts',
Con.Byte("version"),
Con.String("flags", 3),
Con.PrefixedArray(
length_field=Con.UBInt32("entry_count"),
subcon=Con.Struct("sample",
Con.UBInt32("sample_count"),
Con.UBInt32("sample_offset"))))
ATOM_META = Con.Struct(
'meta',
Con.Byte("version"),
Con.String("flags", 3),
Con.GreedyRepeater(Atom("atoms")))
ATOM_ILST = Con.GreedyRepeater(AtomContainer('ilst'))
ATOM_TRKN = Con.Struct(
'trkn',
Con.Padding(2),
Con.UBInt16('track_number'),
Con.UBInt16('total_tracks'),
Con.Padding(2))
ATOM_DISK = Con.Struct(
'disk',
Con.Padding(2),
Con.UBInt16('disk_number'),
Con.UBInt16('total_disks'))

View File

@ -0,0 +1,973 @@
#!/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, BIG_ENDIAN, ApeTag, ReplayGain,
ignore_sigint, open_files, EncodingError,
DecodingError, PCMReaderError, ChannelMask,
__default_quality__, config)
from __id3__ import *
import gettext
gettext.install("audiotools", unicode=True)
#######################
#MP3
#######################
class MPEG_Frame_Header(Con.Adapter):
#mpeg_version->sample_rate bits->Hz
SAMPLE_RATE = [[11025, 12000, 8000, None],
[None, None, None, None],
[22050, 24000, 16000, None],
[44100, 48000, 32000, None]]
#(mpeg_version, layer)->bitrate bits->bits per second
BIT_RATE = {(3, 3): [0, 32000, 64000, 96000,
128000, 160000, 192000, 224000,
256000, 288000, 320000, 352000,
384000, 416000, 448000, None],
(3, 2): [0, 32000, 48000, 56000,
64000, 80000, 96000, 112000,
128000, 160000, 192000, 224000,
256000, 320000, 384000, None],
(3, 1): [0, 32000, 40000, 48000,
56000, 64000, 80000, 96000,
112000, 128000, 160000, 192000,
224000, 256000, 320000, None],
(2, 3): [0, 32000, 48000, 56000,
64000, 80000, 96000, 112000,
128000, 144000, 160000, 176000,
192000, 224000, 256000, None],
(2, 2): [0, 8000, 16000, 24000,
32000, 40000, 48000, 56000,
64000, 80000, 96000, 112000,
128000, 144000, 160000, None]}
#mpeg_version->Hz->sample_rate bits
SAMPLE_RATE_REVERSE = {0: {11025: 0,
12000: 1,
8000: 2},
1: {None: 0},
2: {22050: 0,
24000: 1,
16000: 2,
None: 3},
3: {44100: 0,
48000: 1,
32000: 2,
None: 3}}
BIT_RATE_REVERSE = dict([(key, dict([(rate, i) for (i, rate) in
enumerate(values)]))
for (key, values) in BIT_RATE.items()])
def __init__(self, name):
Con.Adapter.__init__(
self,
Con.BitStruct("mp3_header",
Con.Const(Con.Bits("sync", 11), 0x7FF),
Con.Bits("mpeg_version", 2),
Con.Bits("layer", 2),
Con.Flag("no_crc16", 1),
Con.Bits("bitrate", 4),
Con.Bits("sample_rate", 2),
Con.Bits("pad", 1),
Con.Bits("private", 1),
Con.Bits("channel", 2),
Con.Bits("mode_extension", 2),
Con.Flag("copyright", 1),
Con.Flag("original", 1),
Con.Bits("emphasis", 2)))
def _encode(self, obj, content):
obj.sample_rate = self.SAMPLE_RATE_REVERSE[obj.mpeg_version][
obj.sample_rate]
obj.bitrate = self.BIT_RATE_REVERSE[(obj.mpeg_version, obj.layer)][
obj.bitrate]
return obj
def _decode(self, obj, content):
obj.sample_rate = self.SAMPLE_RATE[obj.mpeg_version][obj.sample_rate]
obj.channel_count = [2, 2, 2, 1][obj.channel]
obj.bitrate = self.BIT_RATE[(obj.mpeg_version, obj.layer)][obj.bitrate]
if (obj.layer == 3):
obj.byte_length = (((12 * obj.bitrate) / obj.sample_rate) +
obj.pad) * 4
else:
obj.byte_length = ((144 * obj.bitrate) / obj.sample_rate) + obj.pad
return obj
def MPEG_crc16(data, total_bits):
def crc16_val(value, crc, total_bits):
value <<= 8
for i in xrange(total_bits):
value <<= 1
crc <<= 1
if (((crc ^ value) & 0x10000)):
crc ^= 0x8005
return crc & 0xFFFF
checksum = 0xFFFF
data = map(ord, data)
while (total_bits >= 8):
checksum = crc16_val(data.pop(0), checksum, 8)
total_bits -= 8
if (total_bits > 0):
return crc16_val(data.pop(0), checksum, total_bits)
else:
return checksum
class InvalidMP3(InvalidFile):
"""Raised by invalid files during MP3 initialization."""
pass
class MP3Audio(AudioFile):
"""An MP3 audio file."""
SUFFIX = "mp3"
NAME = SUFFIX
DEFAULT_COMPRESSION = "2"
#0 is better quality/lower compression
#9 is worse quality/higher compression
COMPRESSION_MODES = ("0", "1", "2", "3", "4", "5", "6",
"medium", "standard", "extreme", "insane")
COMPRESSION_DESCRIPTIONS = {"0": _(u"high quality, larger files, " +
u"corresponds to lame's -V0"),
"6": _(u"lower quality, smaller files, " +
u"corresponds to lame's -V6"),
"medium": _(u"corresponds to lame's " +
u"--preset medium"),
"standard": _(u"corresponds to lame's " +
u"--preset standard"),
"extreme": _(u"corresponds to lame's " +
u"--preset extreme"),
"insane": _(u"corresponds to lame's " +
u"--preset insane")}
BINARIES = ("lame",)
REPLAYGAIN_BINARIES = ("mp3gain", )
#MPEG1, Layer 1
#MPEG1, Layer 2,
#MPEG1, Layer 3,
#MPEG2, Layer 1,
#MPEG2, Layer 2,
#MPEG2, Layer 3
MP3_BITRATE = ((None, None, None, None, None, None),
(32, 32, 32, 32, 8, 8),
(64, 48, 40, 48, 16, 16),
(96, 56, 48, 56, 24, 24),
(128, 64, 56, 64, 32, 32),
(160, 80, 64, 80, 40, 40),
(192, 96, 80, 96, 48, 48),
(224, 112, 96, 112, 56, 56),
(256, 128, 112, 128, 64, 64),
(288, 160, 128, 144, 80, 80),
(320, 192, 160, 160, 96, 96),
(352, 224, 192, 176, 112, 112),
(384, 256, 224, 192, 128, 128),
(416, 320, 256, 224, 144, 144),
(448, 384, 320, 256, 160, 160))
#MPEG1, MPEG2, MPEG2.5
MP3_SAMPLERATE = ((44100, 22050, 11025),
(48000, 24000, 12000),
(32000, 16000, 8000))
MP3_FRAME_HEADER = Con.BitStruct("mp3_header",
Con.Const(Con.Bits("sync", 11), 0x7FF),
Con.Bits("mpeg_version", 2),
Con.Bits("layer", 2),
Con.Flag("protection", 1),
Con.Bits("bitrate", 4),
Con.Bits("sampling_rate", 2),
Con.Bits("padding", 1),
Con.Bits("private", 1),
Con.Bits("channel", 2),
Con.Bits("mode_extension", 2),
Con.Flag("copyright", 1),
Con.Flag("original", 1),
Con.Bits("emphasis", 2))
XING_HEADER = Con.Struct("xing_header",
Con.Bytes("header_id", 4),
Con.Bytes("flags", 4),
Con.UBInt32("num_frames"),
Con.UBInt32("bytes"),
Con.StrictRepeater(100, Con.Byte("toc_entries")),
Con.UBInt32("quality"))
def __init__(self, filename):
"""filename is a plain string."""
AudioFile.__init__(self, filename)
try:
mp3file = file(filename, "rb")
except IOError, msg:
raise InvalidMP3(str(msg))
try:
try:
MP3Audio.__find_next_mp3_frame__(mp3file)
except ValueError:
raise InvalidMP3(_(u"MP3 frame not found"))
fr = MP3Audio.MP3_FRAME_HEADER.parse(mp3file.read(4))
self.__samplerate__ = MP3Audio.__get_mp3_frame_sample_rate__(fr)
self.__channels__ = MP3Audio.__get_mp3_frame_channels__(fr)
self.__framelength__ = self.__length__()
finally:
mp3file.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."""
ID3v2Comment.skip(file)
try:
frame = cls.MP3_FRAME_HEADER.parse_stream(file)
if ((frame.sync == 0x07FF) and
(frame.mpeg_version in (0x03, 0x02, 0x00)) and
(frame.layer in (0x01, 0x03))):
return True
else:
#oddly, MP3s sometimes turn up in RIFF containers
#this isn't a good idea, but can be supported nonetheless
file.seek(-cls.MP3_FRAME_HEADER.sizeof(), 1)
header = file.read(12)
if ((header[0:4] == 'RIFF') and
(header[8:12] == 'RMP3')):
return True
else:
return False
except:
return False
def lossless(self):
"""Returns False."""
return False
def to_pcm(self):
"""Returns a PCMReader object containing the track's PCM data."""
#if mpg123 is available, use that for decoding
if (BIN.can_execute(BIN["mpg123"])):
sub = subprocess.Popen([BIN["mpg123"], "-qs", self.filename],
stdout=subprocess.PIPE,
stderr=file(os.devnull, "a"))
return PCMReader(sub.stdout,
sample_rate=self.sample_rate(),
channels=self.channels(),
bits_per_sample=16,
channel_mask=int(ChannelMask.from_channels(
self.channels())),
process=sub,
big_endian=BIG_ENDIAN)
else:
#if not, use LAME for decoding
if (self.filename.endswith("." + self.SUFFIX)):
if (BIG_ENDIAN):
endian = ['-x']
else:
endian = []
sub = subprocess.Popen([BIN['lame']] + endian + \
["--decode", "-t", "--quiet",
self.filename, "-"],
stdout=subprocess.PIPE)
return PCMReader(
sub.stdout,
sample_rate=self.sample_rate(),
channels=self.channels(),
bits_per_sample=16,
channel_mask=int(self.channel_mask()),
process=sub)
else:
import tempfile
from audiotools import TempWaveReader
#copy our file to one that ends with .mp3
tempmp3 = tempfile.NamedTemporaryFile(suffix='.' + self.SUFFIX)
f = open(self.filename, 'rb')
transfer_data(f.read, tempmp3.write)
f.close()
tempmp3.flush()
#decode the mp3 file to a WAVE file
wave = tempfile.NamedTemporaryFile(suffix='.wav')
returnval = subprocess.call([BIN['lame'], "--decode",
"--quiet",
tempmp3.name, wave.name])
tempmp3.close()
if (returnval == 0):
#return WAVE file as a stream
wave.seek(0, 0)
return TempWaveReader(wave)
else:
return PCMReaderError(
error_message=u"lame exited with error",
sample_rate=self.sample_rate(),
channels=self.channels(),
channel_mask=int(self.channel_mask()),
bits_per_sample=16)
@classmethod
def __help_output__(cls):
import cStringIO
help_data = cStringIO.StringIO()
sub = subprocess.Popen([BIN['lame'], '--help'],
stdout=subprocess.PIPE)
transfer_data(sub.stdout.read, help_data.write)
sub.wait()
return help_data.getvalue()
@classmethod
def __lame_version__(cls):
try:
version = re.findall(r'version \d+\.\d+',
cls.__help_output__())[0]
return tuple(map(int, version[len('version '):].split(".")))
except IndexError:
return (0, 0)
@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 MP3Audio object."""
import decimal
import bisect
if ((compression is None) or
(compression not in cls.COMPRESSION_MODES)):
compression = __default_quality__(cls.NAME)
if ((pcmreader.channels > 2) or
(pcmreader.sample_rate not in (32000, 48000, 44100))):
pcmreader = PCMConverter(
pcmreader,
sample_rate=[32000, 32000, 44100, 48000][bisect.bisect(
[32000, 44100, 48000], pcmreader.sample_rate)],
channels=min(pcmreader.channels, 2),
channel_mask=ChannelMask.from_channels(
min(pcmreader.channels, 2)),
bits_per_sample=16)
if (pcmreader.channels > 1):
mode = "j"
else:
mode = "m"
#FIXME - not sure if all LAME versions support "--little-endian"
# #LAME 3.98 (and up, presumably) handle the byteswap correctly
# #LAME 3.97 always uses -x
# if (BIG_ENDIAN or (cls.__lame_version__() < (3,98))):
# endian = ['-x']
# else:
# endian = []
devnull = file(os.devnull, 'ab')
if (str(compression) in map(str, range(0, 10))):
compression = ["-V" + str(compression)]
else:
compression = ["--preset", str(compression)]
sub = subprocess.Popen([
BIN['lame'], "--quiet",
"-r",
"-s", str(decimal.Decimal(pcmreader.sample_rate) / 1000),
"--bitwidth", str(pcmreader.bits_per_sample),
"--signed", "--little-endian",
"-m", mode] + compression + ["-", filename],
stdin=subprocess.PIPE,
stdout=devnull,
stderr=devnull,
preexec_fn=ignore_sigint)
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:
cls.__unlink__(filename)
raise EncodingError(err.error_message)
sub.stdin.close()
devnull.close()
if (sub.wait() == 0):
return MP3Audio(filename)
else:
cls.__unlink__(filename)
raise EncodingError(u"error encoding file with lame")
def bits_per_sample(self):
"""Returns an integer number of bits-per-sample this track contains."""
return 16
def channels(self):
"""Returns an integer number of channels this track contains."""
return self.__channels__
def sample_rate(self):
"""Returns the rate of the track's audio as an integer number of Hz."""
return self.__samplerate__
def get_metadata(self):
"""Returns a MetaData object, or None.
Raises IOError if unable to read the file."""
f = file(self.filename, "rb")
try:
if (f.read(3) != "ID3"): # no ID3v2 tag, try ID3v1
id3v1 = ID3v1Comment.read_id3v1_comment(self.filename)
if (id3v1[-1] == -1): # no ID3v1 either
return None
else:
return ID3v1Comment(id3v1)
else:
id3v2 = ID3v2Comment.read_id3v2_comment(self.filename)
id3v1 = ID3v1Comment.read_id3v1_comment(self.filename)
if (id3v1[-1] == -1): # only ID3v2, no ID3v1
return id3v2
else: # both ID3v2 and ID3v1
return ID3CommentPair(
id3v2,
ID3v1Comment(id3v1))
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."""
if (metadata is None):
return
if ((not isinstance(metadata, ID3v2Comment)) and
(not isinstance(metadata, ID3v1Comment))):
DEFAULT_ID3V2 = "id3v2.3"
DEFAULT_ID3V1 = "id3v1.1"
id3v2_class = {"id3v2.2": ID3v22Comment,
"id3v2.3": ID3v23Comment,
"id3v2.4": ID3v24Comment,
"none": None}.get(config.get_default("ID3",
"id3v2",
DEFAULT_ID3V2),
DEFAULT_ID3V2)
id3v1_class = {"id3v1.1": ID3v1Comment,
"none": None}.get(config.get_default("ID3",
"id3v1",
DEFAULT_ID3V1))
if ((id3v2_class is not None) and (id3v1_class is not None)):
metadata = ID3CommentPair.converted(metadata,
id3v2_class=id3v2_class,
id3v1_class=id3v1_class)
elif (id3v2_class is not None):
metadata = id3v2_class.converted(metadata)
elif (id3v1_class is not None):
metadata = id3v1_class.converted(metadata)
else:
return
#get the original MP3 data
f = file(self.filename, "rb")
MP3Audio.__find_mp3_start__(f)
data_start = f.tell()
MP3Audio.__find_last_mp3_frame__(f)
data_end = f.tell()
f.seek(data_start, 0)
mp3_data = f.read(data_end - data_start)
f.close()
if (isinstance(metadata, ID3CommentPair)):
id3v2 = metadata.id3v2.build()
id3v1 = metadata.id3v1.build_tag()
elif (isinstance(metadata, ID3v2Comment)):
id3v2 = metadata.build()
id3v1 = ""
elif (isinstance(metadata, ID3v1Comment)):
id3v2 = ""
id3v1 = metadata.build_tag()
#write id3v2 + data + id3v1 to file
f = file(self.filename, "wb")
f.write(id3v2)
f.write(mp3_data)
f.write(id3v1)
f.close()
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."""
#get the original MP3 data
f = file(self.filename, "rb")
MP3Audio.__find_mp3_start__(f)
data_start = f.tell()
MP3Audio.__find_last_mp3_frame__(f)
data_end = f.tell()
f.seek(data_start, 0)
mp3_data = f.read(data_end - data_start)
f.close()
#write data to file
f = file(self.filename, "wb")
f.write(mp3_data)
f.close()
#places mp3file at the position of the next MP3 frame's start
@classmethod
def __find_next_mp3_frame__(cls, mp3file):
#if we're starting at an ID3v2 header, skip it to save a bunch of time
ID3v2Comment.skip(mp3file)
#then find the next mp3 frame
(b1, b2) = mp3file.read(2)
while ((b1 != chr(0xFF)) or ((ord(b2) & 0xE0) != 0xE0)):
mp3file.seek(-1, 1)
(b1, b2) = mp3file.read(2)
mp3file.seek(-2, 1)
#places mp3file at the position of the MP3 file's start
#either at the next frame (most commonly)
#or at the "RIFF????RMP3" header
@classmethod
def __find_mp3_start__(cls, mp3file):
#if we're starting at an ID3v2 header, skip it to save a bunch of time
ID3v2Comment.skip(mp3file)
while (True):
byte = mp3file.read(1)
while ((byte != chr(0xFF)) and (byte != 'R') and (len(byte) > 0)):
byte = mp3file.read(1)
if (byte == chr(0xFF)): # possibly a frame sync
mp3file.seek(-1, 1)
try:
header = cls.MP3_FRAME_HEADER.parse_stream(mp3file)
if ((header.sync == 0x07FF) and
(header.mpeg_version in (0x03, 0x02, 0x00)) and
(header.layer in (0x01, 0x02, 0x03))):
mp3file.seek(-4, 1)
return
else:
mp3file.seek(-3, 1)
except:
continue
elif (byte == 'R'): # possibly a 'RIFF????RMP3' header
header = mp3file.read(11)
if ((header[0:3] == 'IFF') and
(header[7:11] == 'RMP3')):
mp3file.seek(-12, 1)
return
else:
mp3file.seek(-11, 1)
elif (len(byte) == 0): # we've run out of MP3 file
return
#places mp3file at the position of the last MP3 frame's end
#(either the last byte in the file or just before the ID3v1 tag)
#this may not be strictly accurate if ReplayGain data is present,
#since APEv2 tags came before the ID3v1 tag,
#but we're not planning to change that tag anyway
@classmethod
def __find_last_mp3_frame__(cls, mp3file):
mp3file.seek(-128, 2)
if (mp3file.read(3) == 'TAG'):
mp3file.seek(-128, 2)
return
else:
mp3file.seek(0, 2)
return
#header is a Construct parsed from 4 bytes sent to MP3_FRAME_HEADER
#returns the total length of the frame, including the header
#(subtract 4 when doing a seek or read to the next one)
@classmethod
def __mp3_frame_length__(cls, header):
layer = 4 - header.layer # layer 1, 2 or 3
bit_rate = MP3Audio.__get_mp3_frame_bitrate__(header)
if (bit_rate is None):
raise InvalidMP3(_(u"Invalid bit rate"))
sample_rate = MP3Audio.__get_mp3_frame_sample_rate__(header)
if (layer == 1):
return (12 * (bit_rate * 1000) / sample_rate + header.padding) * 4
else:
return 144 * (bit_rate * 1000) / sample_rate + header.padding
#takes a parsed MP3_FRAME_HEADER
#returns the mp3's sample rate based on that information
#(typically 44100)
@classmethod
def __get_mp3_frame_sample_rate__(cls, frame):
try:
if (frame.mpeg_version == 0x00): # MPEG 2.5
return MP3Audio.MP3_SAMPLERATE[frame.sampling_rate][2]
elif (frame.mpeg_version == 0x02): # MPEG 2
return MP3Audio.MP3_SAMPLERATE[frame.sampling_rate][1]
else: # MPEG 1
return MP3Audio.MP3_SAMPLERATE[frame.sampling_rate][0]
except IndexError:
raise InvalidMP3(_(u"Invalid sampling rate"))
@classmethod
def __get_mp3_frame_channels__(cls, frame):
if (frame.channel == 0x03):
return 1
else:
return 2
@classmethod
def __get_mp3_frame_bitrate__(cls, frame):
layer = 4 - frame.layer # layer 1, 2 or 3
try:
if (frame.mpeg_version == 0x00): # MPEG 2.5
return MP3Audio.MP3_BITRATE[frame.bitrate][layer + 2]
elif (frame.mpeg_version == 0x02): # MPEG 2
return MP3Audio.MP3_BITRATE[frame.bitrate][layer + 2]
elif (frame.mpeg_version == 0x03): # MPEG 1
return MP3Audio.MP3_BITRATE[frame.bitrate][layer - 1]
else:
return 0
except IndexError:
raise InvalidMP3(_(u"Invalid bit rate"))
def cd_frames(self):
"""Returns the total length of the track in CD frames.
Each CD frame is 1/75th of a second."""
#calculate length at create-time so that we can
#throw InvalidMP3 as soon as possible
return self.__framelength__
#returns the length of this file in CD frame
#raises InvalidMP3 if any portion of the frame is invalid
def __length__(self):
mp3file = file(self.filename, "rb")
try:
MP3Audio.__find_next_mp3_frame__(mp3file)
start_position = mp3file.tell()
fr = MP3Audio.MP3_FRAME_HEADER.parse(mp3file.read(4))
first_frame = mp3file.read(MP3Audio.__mp3_frame_length__(fr) - 4)
sample_rate = MP3Audio.__get_mp3_frame_sample_rate__(fr)
if (fr.mpeg_version == 0x00): # MPEG 2.5
version = 3
elif (fr.mpeg_version == 0x02): # MPEG 2
version = 3
else: # MPEG 1
version = 0
try:
if (fr.layer == 0x03): # layer 1
frames_per_sample = 384
bit_rate = MP3Audio.MP3_BITRATE[fr.bitrate][version]
elif (fr.layer == 0x02): # layer 2
frames_per_sample = 1152
bit_rate = MP3Audio.MP3_BITRATE[fr.bitrate][version + 1]
elif (fr.layer == 0x01): # layer 3
frames_per_sample = 1152
bit_rate = MP3Audio.MP3_BITRATE[fr.bitrate][version + 2]
else:
raise InvalidMP3(_(u"Unsupported MPEG layer"))
except IndexError:
raise InvalidMP3(_(u"Invalid bit rate"))
if ('Xing' in first_frame):
#the first frame has a Xing header,
#use that to calculate the mp3's length
xing_header = MP3Audio.XING_HEADER.parse(
first_frame[first_frame.index('Xing'):])
return (xing_header.num_frames * frames_per_sample * 75 /
sample_rate)
else:
#no Xing header,
#assume a constant bitrate file
mp3file.seek(-128, 2)
if (mp3file.read(3) == "TAG"):
end_position = mp3file.tell() - 3
else:
mp3file.seek(0, 2)
end_position = mp3file.tell()
return ((end_position - start_position) * 75 * 8 /
(bit_rate * 1000))
finally:
mp3file.close()
def total_frames(self):
"""Returns the total PCM frames of the track as an integer."""
return self.cd_frames() * self.sample_rate() / 75
@classmethod
def can_add_replay_gain(cls):
"""Returns True if we have the necessary binaries to add ReplayGain."""
return BIN.can_execute(BIN['mp3gain'])
@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)
if ((len(track_names) > 0) and (BIN.can_execute(BIN['mp3gain']))):
devnull = file(os.devnull, 'ab')
sub = subprocess.Popen([BIN['mp3gain'], '-f', '-k', '-q', '-r'] + \
track_names,
stdout=devnull,
stderr=devnull)
sub.wait()
devnull.close()
if (progress is not None):
progress(1, 1)
def mpeg_frames(self):
"""Yields (header, data) tuples of the file's contents.
header is an MPEG_Frame_Header Construct.
data is a string of MP3 data."""
header_struct = MPEG_Frame_Header("header")
f = open(self.filename, 'rb')
try:
#FIXME - this won't handle RIFF RMP3 well
#perhaps I should use tracklint to clean those up
MP3Audio.__find_last_mp3_frame__(f)
stop_position = f.tell()
f.seek(0, 0)
MP3Audio.__find_mp3_start__(f)
while (f.tell() < stop_position):
header = header_struct.parse_stream(f)
data = f.read(header.byte_length - 4)
yield (header, data)
finally:
f.close()
def verify(self, progress=None):
from . import verify
try:
f = open(self.filename, 'rb')
except IOError, err:
raise InvalidMP3(str(err))
#MP3 verification is likely to be so fast
#that individual calls to progress() are
#a waste of time.
if (progress is not None):
progress(0, 1)
try:
try:
#skip ID3v2/ID3v1 tags during verification
self.__find_mp3_start__(f)
start = f.tell()
self.__find_last_mp3_frame__(f)
end = f.tell()
f.seek(start, 0)
verify.mpeg(f, start, end)
if (progress is not None):
progress(1, 1)
return True
except (IOError, ValueError), err:
raise InvalidMP3(str(err))
finally:
f.close()
#######################
#MP2 AUDIO
#######################
class MP2Audio(MP3Audio):
"""An MP2 audio file."""
SUFFIX = "mp2"
NAME = SUFFIX
DEFAULT_COMPRESSION = str(192)
COMPRESSION_MODES = tuple(map(str, (64, 96, 112, 128, 160, 192,
224, 256, 320, 384)))
COMPRESSION_DESCRIPTIONS = {"64": _(u"total bitrate of 64kbps"),
"384": _(u"total bitrate of 384kbps")}
BINARIES = ("lame", "twolame")
@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."""
ID3v2Comment.skip(file)
try:
frame = cls.MP3_FRAME_HEADER.parse_stream(file)
return ((frame.sync == 0x07FF) and
(frame.mpeg_version in (0x03, 0x02, 0x00)) and
(frame.layer == 0x02))
except:
return False
@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 MP2Audio object."""
import decimal
import bisect
if ((compression is None) or
(compression not in cls.COMPRESSION_MODES)):
compression = __default_quality__(cls.NAME)
if ((pcmreader.channels > 2) or
(pcmreader.sample_rate not in (32000, 48000, 44100)) or
(pcmreader.bits_per_sample != 16)):
pcmreader = PCMConverter(
pcmreader,
sample_rate=[32000, 32000, 44100, 48000][bisect.bisect(
[32000, 44100, 48000], pcmreader.sample_rate)],
channels=min(pcmreader.channels, 2),
channel_mask=pcmreader.channel_mask,
bits_per_sample=16)
devnull = file(os.devnull, 'ab')
sub = subprocess.Popen([BIN['twolame'], "--quiet",
"-r",
"-s", str(pcmreader.sample_rate),
"--samplesize", str(pcmreader.bits_per_sample),
"-N", str(pcmreader.channels),
"-m", "a",
"-b", compression,
"-",
filename],
stdin=subprocess.PIPE,
stdout=devnull,
stderr=devnull,
preexec_fn=ignore_sigint)
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:
cls.__unlink__(filename)
raise EncodingError(err.error_message)
sub.stdin.close()
devnull.close()
if (sub.wait() == 0):
return MP2Audio(filename)
else:
cls.__unlink__(filename)
raise EncodingError(u"twolame exited with error")

View File

@ -0,0 +1,332 @@
#!/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, InvalidFormat, PCMReader,
PCMConverter, Con, subprocess, BIN, ApeTaggedAudio,
os, TempWaveReader, ignore_sigint, transfer_data,
EncodingError, DecodingError)
from __wav__ import WaveAudio
import gettext
gettext.install("audiotools", unicode=True)
#######################
#Musepack Audio
#######################
class NutValue(Con.Adapter):
"""A construct for Musepack Nut-encoded integer fields."""
def __init__(self, name):
Con.Adapter.__init__(
self,
Con.RepeatUntil(lambda obj, ctx: (obj & 0x80) == 0x00,
Con.UBInt8(name)))
def _encode(self, value, context):
data = [value & 0x7F]
value = value >> 7
while (value != 0):
data.append(0x80 | (value & 0x7F))
value = value >> 7
data.reverse()
return data
def _decode(self, obj, context):
i = 0
for x in obj:
i = (i << 7) | (x & 0x7F)
return i
class Musepack8StreamReader:
"""An object for parsing Musepack SV8 streams."""
NUT_HEADER = Con.Struct('nut_header',
Con.String('key', 2),
NutValue('length'))
def __init__(self, stream):
"""Initialized with a file object."""
self.stream = stream
def packets(self):
"""Yields a set of (key, data) tuples."""
import string
UPPERCASE = frozenset(string.ascii_uppercase)
while (True):
try:
frame_header = self.NUT_HEADER.parse_stream(self.stream)
except Con.core.FieldError:
break
if (not frozenset(frame_header.key).issubset(UPPERCASE)):
break
yield (frame_header.key,
self.stream.read(frame_header.length -
len(self.NUT_HEADER.build(frame_header))))
class MusepackAudio(ApeTaggedAudio, AudioFile):
"""A Musepack audio file."""
SUFFIX = "mpc"
NAME = SUFFIX
DEFAULT_COMPRESSION = "standard"
COMPRESSION_MODES = ("thumb", "radio", "standard", "extreme", "insane")
###Musepack SV7###
#BINARIES = ('mppdec','mppenc')
###Musepack SV8###
BINARIES = ('mpcdec', 'mpcenc')
MUSEPACK8_HEADER = Con.Struct('musepack8_header',
Con.UBInt32('crc32'),
Con.Byte('bitstream_version'),
NutValue('sample_count'),
NutValue('beginning_silence'),
Con.Embed(Con.BitStruct(
'flags',
Con.Bits('sample_frequency', 3),
Con.Bits('max_used_bands', 5),
Con.Bits('channel_count', 4),
Con.Flag('mid_side_used'),
Con.Bits('audio_block_frames', 3))))
#not sure about some of the flag locations
#Musepack 7's header is very unusual
MUSEPACK7_HEADER = Con.Struct('musepack7_header',
Con.Const(Con.String('signature', 3), 'MP+'),
Con.Byte('version'),
Con.ULInt32('frame_count'),
Con.ULInt16('max_level'),
Con.Embed(
Con.BitStruct('flags',
Con.Bits('profile', 4),
Con.Bits('link', 2),
Con.Bits('sample_frequency', 2),
Con.Flag('intensity_stereo'),
Con.Flag('midside_stereo'),
Con.Bits('maxband', 6))),
Con.ULInt16('title_gain'),
Con.ULInt16('title_peak'),
Con.ULInt16('album_gain'),
Con.ULInt16('album_peak'),
Con.Embed(
Con.BitStruct('more_flags',
Con.Bits('unused1', 16),
Con.Bits('last_frame_length_low', 4),
Con.Flag('true_gapless'),
Con.Bits('unused2', 3),
Con.Flag('fast_seeking'),
Con.Bits('last_frame_length_high', 7))),
Con.Bytes('unknown', 3),
Con.Byte('encoder_version'))
def __init__(self, filename):
"""filename is a plain string."""
AudioFile.__init__(self, filename)
f = file(filename, 'rb')
try:
if (f.read(4) == 'MPCK'): # a Musepack 8 stream
for (key, packet) in Musepack8StreamReader(f).packets():
if (key == 'SH'):
header = MusepackAudio.MUSEPACK8_HEADER.parse(packet)
self.__sample_rate__ = (44100, 48000,
37800, 32000)[
header.sample_frequency]
self.__total_frames__ = header.sample_count
self.__channels__ = header.channel_count + 1
break
elif (key == 'SE'):
raise InvalidFile(_(u'No Musepack header found'))
else: # a Musepack 7 stream
f.seek(0, 0)
try:
header = MusepackAudio.MUSEPACK7_HEADER.parse_stream(f)
except Con.ConstError:
raise InvalidFile(_(u'Musepack signature incorrect'))
header.last_frame_length = \
(header.last_frame_length_high << 4) | \
header.last_frame_length_low
self.__sample_rate__ = (44100, 48000,
37800, 32000)[header.sample_frequency]
self.__total_frames__ = (((header.frame_count - 1) * 1152) +
header.last_frame_length)
self.__channels__ = 2
finally:
f.close()
@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 MusepackAudio object."""
import tempfile
import bisect
if (str(compression) not in cls.COMPRESSION_MODES):
compression = cls.DEFAULT_COMPRESSION
if ((pcmreader.channels > 2) or
(pcmreader.sample_rate not in (44100, 48000, 37800, 32000)) or
(pcmreader.bits_per_sample != 16)):
pcmreader = PCMConverter(
pcmreader,
sample_rate=[32000, 32000, 37800, 44100, 48000][bisect.bisect(
[32000, 37800, 44100, 48000], pcmreader.sample_rate)],
channels=min(pcmreader.channels, 2),
bits_per_sample=16)
f = tempfile.NamedTemporaryFile(suffix=".wav")
w = WaveAudio.from_pcm(f.name, pcmreader)
try:
return cls.__from_wave__(filename, f.name, compression)
finally:
del(w)
f.close()
#While Musepack needs to pipe things through WAVE,
#not all WAVEs are acceptable.
#Use the *_pcm() methods first.
def __to_wave__(self, wave_filename):
devnull = file(os.devnull, "wb")
try:
sub = subprocess.Popen([BIN['mpcdec'],
self.filename,
wave_filename],
stdout=devnull,
stderr=devnull)
#FIXME - small files (~5 seconds) result in an error by mpcdec,
#even if they decode correctly.
#Not much we can do except try to workaround its bugs.
if (sub.wait() not in [0, 250]):
raise DecodingError()
finally:
devnull.close()
@classmethod
def __from_wave__(cls, filename, wave_filename, compression=None):
if (str(compression) not in cls.COMPRESSION_MODES):
compression = cls.DEFAULT_COMPRESSION
#mppenc requires files to end with .mpc for some reason
if (not filename.endswith(".mpc")):
import tempfile
actual_filename = filename
tempfile = tempfile.NamedTemporaryFile(suffix=".mpc")
filename = tempfile.name
else:
actual_filename = tempfile = None
###Musepack SV7###
#sub = subprocess.Popen([BIN['mppenc'],
# "--silent",
# "--overwrite",
# "--%s" % (compression),
# wave_filename,
# filename],
# preexec_fn=ignore_sigint)
###Musepack SV8###
sub = subprocess.Popen([BIN['mpcenc'],
"--silent",
"--overwrite",
"--%s" % (compression),
wave_filename,
filename])
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 MusepackAudio(filename)
else:
if (tempfile is not None):
tempfile.close()
raise EncodingError(u"error encoding file with mpcenc")
@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(4)
###Musepack SV7###
#return header == 'MP+\x07'
###Musepack SV8###
return (header == 'MP+\x07') or (header == 'MPCK')
def sample_rate(self):
"""Returns the rate of the track's audio as an integer number of Hz."""
return self.__sample_rate__
def total_frames(self):
"""Returns the total PCM frames of the track as an integer."""
return self.__total_frames__
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 16
def lossless(self):
"""Returns False."""
return False

View File

@ -0,0 +1,638 @@
#!/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 (MetaData, AlbumMetaData, AlbumMetaDataFile,
MetaDataFileException,
__most_numerous__, DummyAudioFile, sys)
import urllib
import gettext
gettext.install("audiotools", unicode=True)
def get_xml_nodes(parent, child_tag):
"""A helper routine for returning all children with the given XML tag."""
return [node for node in parent.childNodes
if (hasattr(node, "tagName") and
(node.tagName == child_tag))]
def walk_xml_tree(parent, *child_tags):
"""A helper routine for walking through several children."""
if (len(child_tags) == 0):
return parent
else:
base_tag = child_tags[0]
remaining_tags = child_tags[1:]
for node in parent.childNodes:
if (hasattr(node, "tagName") and
(node.tagName == base_tag)):
return walk_xml_tree(node, *remaining_tags)
else:
return None
def walk_xml_tree_build(dom, parent, *child_tags):
if (len(child_tags) == 0):
return parent
else:
base_tag = child_tags[0]
remaining_tags = child_tags[1:]
for node in parent.childNodes:
if (hasattr(node, "tagName") and
(node.tagName == base_tag)):
return walk_xml_tree_build(dom, node, *remaining_tags)
else:
new_child = dom.createElement(base_tag)
parent.appendChild(new_child)
return walk_xml_tree_build(dom, new_child, *remaining_tags)
def get_xml_text_node(parent, child_tag):
"""A helper routine for returning the first text child XML node."""
try:
return get_xml_nodes(parent, child_tag)[0].childNodes[0].data.strip()
except IndexError:
return u''
def reorder_xml_children(parent, child_order):
"""Given an XML element with childNodes, reorders them to child_order.
child_order should be a list of unicode tag strings.
"""
if (parent.childNodes is None):
return
child_tags = {}
leftovers = []
for child in parent.childNodes:
if (hasattr(child, "tagName")):
child_tags.setdefault(child.tagName, []).append(child)
else:
leftovers.append(child)
#remove all the old childen from parent
for child in parent.childNodes:
parent.removeChild(child)
#re-add the childen in child_order
for tagName in child_order:
if (tagName in child_tags):
for child in child_tags[tagName]:
parent.appendChild(child)
del(child_tags[tagName])
#re-add any leftover children tags or non-tags
for child_tags in child_tags.values():
for child in child_tags:
parent.appendChild(child)
for child in leftovers:
parent.appendChild(child)
class MBDiscID:
"""A MusicBrainz disc ID."""
def __init__(self, tracks=[], offsets=None, length=None, lead_in=150,
first_track_number=None, last_track_number=None,
lead_out_track_offset=None):
"""Fields are as follows:
tracks - a list of track lengths in CD frames
offsets - a list of track offsets in CD frames
length - the length of the entire disc in CD frames
lead_in - the location of the first track on the CD, in frames
first_track_number, last_track_number and lead_out_track_offset
are integer values.
All fields are optional.
One will presumably fill them with data later in that event.
"""
self.tracks = tracks
self.__offsets__ = offsets
self.__length__ = length
self.__lead_in__ = lead_in
self.first_track_number = first_track_number
self.last_track_number = last_track_number
self.lead_out_track_offset = lead_out_track_offset
@classmethod
def from_cdda(cls, cdda):
"""Given a CDDA object, returns a populated MBDiscID
May raise ValueError if there are no audio tracks on the CD."""
tracks = list(cdda)
if (len(tracks) < 1):
raise ValueError(_(u"no audio tracks in CDDA object"))
return cls(
tracks=[t.length() for t in tracks],
offsets=[t.offset() for t in tracks],
length=cdda.length(),
lead_in=tracks[0].offset(),
lead_out_track_offset=cdda.last_sector() + 150 + 1)
def offsets(self):
"""Returns a list of calculated offset integers, from track lengths."""
if (self.__offsets__ is None):
offsets = [self.__lead_in__]
for track in self.tracks[0:-1]:
offsets.append(track + offsets[-1])
return offsets
else:
return self.__offsets__
def __repr__(self):
return ("MBDiscID(tracks=%s,offsets=%s,length=%s,lead_in=%s," +
"first_track_number=%s,last_track_number=%s," +
"lead_out_track_offset=%s)") % \
(repr(self.tracks),
repr(self.__offsets__),
repr(self.__length__),
repr(self.__lead_in__),
repr(self.first_track_number),
repr(self.last_track_number),
repr(self.lead_out_track_offset))
#returns a MusicBrainz DiscID value as a string
def __str__(self):
from hashlib import sha1
if (self.lead_out_track_offset is None):
if (self.__length__ is None):
lead_out_track_offset = sum(self.tracks) + self.__lead_in__
else:
lead_out_track_offset = self.__length__ + self.__lead_in__
else:
lead_out_track_offset = self.lead_out_track_offset
if (self.first_track_number is None):
first_track_number = 1
else:
first_track_number = self.first_track_number
if (self.last_track_number is None):
last_track_number = len(self.tracks)
else:
last_track_number = self.last_track_number
digest = sha1("%02X%02X%s" % \
(first_track_number,
last_track_number,
"".join(["%08X" % (i) for i in
[lead_out_track_offset] +
self.offsets() +
([0] * (99 - len(self.offsets())))])))
return "".join([{'=': '-', '+': '.', '/': '_'}.get(c, c) for c in
digest.digest().encode('base64').rstrip('\n')])
def toxml(self, output):
"""Writes an XML file to the output file object."""
output.write(MusicBrainzReleaseXML.from_tracks(
[DummyAudioFile(length, None, i + 1)
for (i, length) in enumerate(self.tracks)]).to_string())
class MusicBrainz:
"""A class for performing queries on a MusicBrainz or compatible server."""
def __init__(self, server, port, messenger):
self.server = server
self.port = port
self.connection = None
self.messenger = messenger
def connect(self):
"""Performs the initial connection."""
import httplib
self.connection = httplib.HTTPConnection(self.server, self.port)
def close(self):
"""Closes an open connection."""
if (self.connection is not None):
self.connection.close()
def read_data(self, disc_id, output):
"""Returns a (matches,dom) tuple from a MBDiscID object.
matches is an integer
and dom is a minidom Document object or None."""
from xml.dom.minidom import parseString
from xml.parsers.expat import ExpatError
self.connection.request(
"GET",
"%s?%s" % ("/ws/1/release",
urllib.urlencode({"type": "xml",
"discid": str(disc_id)})))
response = self.connection.getresponse()
#FIXME - check for errors in the HTTP response
data = response.read()
try:
dom = parseString(data)
return (len(dom.getElementsByTagName(u'release')), dom)
except ExpatError:
return (0, None)
class MBXMLException(MetaDataFileException):
"""Raised if MusicBrainzReleaseXML.read() encounters an error."""
def __unicode__(self):
return _(u"Invalid MusicBrainz XML file")
class MusicBrainzReleaseXML(AlbumMetaDataFile):
"""An XML file as returned by MusicBrainz."""
TAG_ORDER = {u"release": [u"title",
u"text-representation",
u"asin",
u"artist",
u"release-group",
u"release-event-list",
u"disc-list",
u"puid-list",
u"track-list",
u"relation-list",
u"tag-list",
u"user-tag-list",
u"rating",
u"user-rating"],
u"artist": [u"name",
u"sort-name",
u"disambiguation",
u"life-span",
u"alias-list",
u"release-list",
u"release-group-list",
u"relation-list",
u"tag-list",
u"user-tag-list",
u"rating"],
u"track": [u"title",
u"duration",
u"isrc-list",
u"artist",
u"release-list",
u"puid-list",
u"relation-list",
u"tag-list",
u"user-tag-list",
u"rating",
u"user-rating"]}
def __init__(self, dom):
self.dom = dom
def __getattr__(self, key):
if (key == 'album_name'):
try:
return get_xml_text_node(
walk_xml_tree(self.dom,
u'metadata', u'release-list', u'release'),
u'title')
except AttributeError:
return u""
elif (key == 'artist_name'):
try:
return get_xml_text_node(
walk_xml_tree(self.dom,
u'metadata', u'release-list', u'release',
u'artist'),
u'name')
except AttributeError:
return u""
elif (key == 'year'):
try:
return walk_xml_tree(
self.dom, u'metadata', u'release-list', u'release',
u'release-event-list',
u'event').getAttribute('date')[0:4]
except (IndexError, AttributeError):
return u""
elif (key == 'catalog'):
try:
return walk_xml_tree(
self.dom, u'metadata', u'release-list', u'release',
u'release-event-list',
u'event').getAttribute('catalog-number')
except (IndexError, AttributeError):
return u""
elif (key == 'extra'):
return u""
else:
try:
return self.__dict__[key]
except KeyError:
raise AttributeError(key)
def __setattr__(self, key, value):
#FIXME - create nodes if they don't exist
if (key == 'album_name'):
title = walk_xml_tree(self.dom, u'metadata', u'release-list',
u'release', u'title')
if (len(title.childNodes) > 0):
title.replaceChild(self.dom.createTextNode(value),
title.firstChild)
else:
title.appendChild(self.dom.createTextNode(value))
elif (key == 'artist_name'):
name = walk_xml_tree(self.dom, u'metadata', u'release-list',
u'release', u'artist', u'name')
if (len(name.childNodes) > 0):
name.replaceChild(self.dom.createTextNode(value),
name.firstChild)
else:
name.appendChild(self.dom.createTextNode(value))
elif (key == 'year'):
walk_xml_tree_build(self.dom, self.dom,
u'metadata', u'release-list',
u'release', u'release-event-list',
u'event').setAttribute(u"date", value)
elif (key == 'catalog'):
walk_xml_tree_build(self.dom, self.dom,
u'metadata', u'release-list',
u'release', u'release-event-list',
u'event').setAttribute(u"catalog-number",
value)
elif (key == 'extra'):
pass
else:
self.__dict__[key] = value
def __len__(self):
return len(self.dom.getElementsByTagName(u'track'))
def to_string(self):
for (tag, order) in MusicBrainzReleaseXML.TAG_ORDER.items():
for parent in self.dom.getElementsByTagName(tag):
reorder_xml_children(parent, order)
return self.dom.toxml(encoding='utf-8')
@classmethod
def from_string(cls, string):
from xml.dom.minidom import parseString
from xml.parsers.expat import ExpatError
try:
return cls(parseString(string))
except ExpatError:
raise MBXMLException("")
def get_track(self, index):
track_node = self.dom.getElementsByTagName(u'track')[index]
track_name = get_xml_text_node(track_node, u'title')
artist_node = walk_xml_tree(track_node, u'artist')
if (artist_node is not None):
artist_name = get_xml_text_node(artist_node, u'name')
if (len(artist_name) == 0):
artist_name = u""
else:
artist_name = u""
return (track_name, artist_name, u"")
def set_track(self, index, name, artist, extra):
track_node = self.dom.getElementsByTagName(u'track')[index]
title = walk_xml_tree(track_node, 'title')
if (len(title.childNodes) > 0):
title.replaceChild(self.dom.createTextNode(name),
title.firstChild)
else:
title.appendChild(self.dom.createTextNode(name))
if (len(artist) > 0):
artist_node = walk_xml_tree_build(self.dom,
track_node,
u'artist', u'name')
if (artist_node.hasChildNodes()):
artist_node.replaceChild(self.dom.createTextNode(artist),
artist_node.firstChild)
else:
artist_node.appendChild(self.dom.createTextNode(artist))
@classmethod
def from_tracks(cls, tracks):
"""Returns a MusicBrainzReleaseXML from a list of AudioFile objects.
These objects are presumably from the same album.
If not, these heuristics may generate something unexpected.
"""
from xml.dom.minidom import parseString
def make_text_node(document, tagname, text):
node = document.createElement(tagname)
node.appendChild(document.createTextNode(text))
return node
tracks.sort(lambda x, y: cmp(x.track_number(), y.track_number()))
#our base DOM to start with
dom = parseString('<?xml version="1.0" encoding="UTF-8"?>' +
'<metadata xmlns="http://musicbrainz.org/' +
'ns/mmd-1.0#" xmlns:ext="http://musicbrainz.org/' +
'ns/ext-1.0#"></metadata>')
release = dom.createElement(u'release')
track_metadata = [t.get_metadata() for t in tracks
if (t.get_metadata() is not None)]
#add album title
release.appendChild(make_text_node(
dom, u'title', unicode(__most_numerous__(
[m.album_name for m in track_metadata]))))
#add album artist
if (len(set([m.artist_name for m in track_metadata])) <
len(track_metadata)):
artist = dom.createElement(u'artist')
album_artist = unicode(__most_numerous__(
[m.artist_name for m in track_metadata]))
artist.appendChild(make_text_node(dom, u'name', album_artist))
release.appendChild(artist)
else:
album_artist = u'' # all track artist names differ
artist = dom.createElement(u'artist')
artist.appendChild(make_text_node(dom, u'name', album_artist))
release.appendChild(artist)
#add release info (catalog number, release date, media, etc.)
event_list = dom.createElement(u'release-event-list')
event = dom.createElement(u'event')
year = unicode(__most_numerous__(
[m.year for m in track_metadata]))
if (year != u""):
event.setAttribute(u'date', year)
catalog_number = unicode(__most_numerous__(
[m.catalog for m in track_metadata]))
if (catalog_number != u""):
event.setAttribute(u'catalog-number', catalog_number)
media = unicode(__most_numerous__(
[m.media for m in track_metadata]))
if (media != u""):
event.setAttribute(u'format', media)
event_list.appendChild(event)
release.appendChild(event_list)
#add tracks
track_list = dom.createElement(u'track-list')
for track in tracks:
node = dom.createElement(u'track')
track_metadata = track.get_metadata()
if (track_metadata is not None):
node.appendChild(make_text_node(
dom, u'title', track_metadata.track_name))
else:
node.appendChild(make_text_node(
dom, u'title', u''))
node.appendChild(make_text_node(
dom, u'duration',
unicode((track.total_frames() * 1000) /
track.sample_rate())))
if (track_metadata is not None):
#add track artist, if different from album artist
if (track_metadata.artist_name != album_artist):
artist = dom.createElement(u'artist')
artist.appendChild(make_text_node(
dom, u'name', track_metadata.artist_name))
node.appendChild(artist)
track_list.appendChild(node)
release.appendChild(track_list)
release_list = dom.createElement(u'release-list')
release_list.appendChild(release)
dom.getElementsByTagName(u'metadata')[0].appendChild(release_list)
return cls(dom)
#takes a Document containing multiple <release> tags
#and a Messenger object to query for output
#returns a modified Document containing only one <release>
def __select_match__(dom, messenger):
messenger.info(_(u"Please Select the Closest Match:"))
matches = dom.getElementsByTagName(u'release')
selected = 0
while ((selected < 1) or (selected > len(matches))):
for i in range(len(matches)):
messenger.info(_(u"%(choice)s) %(name)s") % \
{"choice": i + 1,
"name": get_xml_text_node(matches[i],
u'title')})
try:
messenger.partial_info(_(u"Your Selection [1-%s]:") % \
(len(matches)))
selected = int(sys.stdin.readline().strip())
except ValueError:
selected = 0
for (i, release) in enumerate(dom.getElementsByTagName(u'release')):
if (i != (selected - 1)):
release.parentNode.removeChild(release)
return dom
#takes a Document containing multiple <release> tags
#and a default selection integer
#returns a modified Document containing only one <release>
def __select_default_match__(dom, selection):
for (i, release) in enumerate(dom.getElementsByTagName(u'release')):
if (i != selection):
release.parentNode.removeChild(release)
return dom
def get_mbxml(disc_id, output, musicbrainz_server, musicbrainz_port,
messenger, default_selection=None):
"""Runs through the entire MusicBrainz querying sequence.
Fields are as follows:
disc_id - an MBDiscID object
output - an open file object for writing
musicbrainz_server - a server name string
musicbrainz_port - a server port int
messenger - a Messenger object
default_selection - if given, the default match to choose
"""
mb = MusicBrainz(musicbrainz_server, musicbrainz_port, messenger)
mb.connect()
messenger.info(
_(u"Sending Disc ID \"%(disc_id)s\" to server \"%(server)s\"") % \
{"disc_id": str(disc_id).decode('ascii'),
"server": musicbrainz_server.decode('ascii', 'replace')})
(matches, dom) = mb.read_data(disc_id, output)
mb.close()
if (matches == 1):
messenger.info(_(u"1 match found"))
else:
messenger.info(_(u"%s matches found") % (matches))
if (matches > 1):
if (default_selection is None):
output.write(__select_match__(
dom, messenger).toxml(encoding='utf-8'))
else:
output.write(__select_default_match__(
dom, default_selection).toxml(encoding='utf-8'))
output.flush()
elif (matches == 1):
output.write(dom.toxml(encoding='utf-8'))
output.flush()
else:
return matches

View File

@ -0,0 +1,507 @@
#!/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, ChannelMask, PCMReader,
transfer_framelist_data, WaveAudio,
AiffAudio, cStringIO, EncodingError,
UnsupportedBitsPerSample, InvalidFile,
PCMReaderError,
WaveContainer, AiffContainer, to_pcm_progress)
import audiotools.decoders
import os.path
class InvalidShorten(InvalidFile):
pass
class ShortenAudio(WaveContainer, AiffContainer):
"""A Shorten audio file."""
SUFFIX = "shn"
NAME = SUFFIX
def __init__(self, filename):
"""filename is a plain string."""
AudioFile.__init__(self, filename)
try:
f = open(filename, 'rb')
except IOError, msg:
raise InvalidShorten(str(msg))
try:
if (not ShortenAudio.is_type(f)):
raise InvalidShorten(_(u'Shorten header not detected'))
finally:
f.close()
#Why not call __populate_metadata__ here and raise InvalidShorten
#if it errors out?
#The problem is that __populate_metadata__ needs to walk
#through the *entire* file in order to calculate total PCM frames
#and so on.
#That's an expensive operation to perform at init-time
#so it's better to postpone it to an on-demand fetch.
def __populate_metadata__(self):
#set up some default values
self.__bits_per_sample__ = 16
self.__channels__ = 2
self.__channel_mask__ = 0x3
self.__sample_rate__ = 44100
self.__total_frames__ = 0
self.__blocks__ = []
self.__format__ = None
#grab a few pieces of technical metadata from the Shorten file itself
#which requires a dry-run through the decoder
try:
decoder = audiotools.decoders.SHNDecoder(self.filename)
try:
self.__bits_per_sample__ = decoder.bits_per_sample
self.__channels__ = decoder.channels
(self.__total_frames__,
self.__blocks__) = decoder.metadata()
finally:
decoder.close()
try:
self.__channel_mask__ = ChannelMask.from_channels(
self.__channels__)
except ValueError:
self.__channel_mask__ = 0
except (ValueError, IOError):
#if we hit an error in SHNDecoder while reading
#technical metadata, the default values will have to do
return
#the remainder requires parsing the file's VERBATIM blocks
#which may contain Wave, AIFF or Sun AU info
if (self.__blocks__[0] is not None):
header = cStringIO.StringIO(self.__blocks__[0])
for format in WaveAudio, AiffAudio:
header.seek(0, 0)
if (format.is_type(header)):
self.__format__ = format
break
if (self.__format__ is WaveAudio):
for (chunk_id, chunk_data) in self.__wave_chunks__():
if (chunk_id == 'fmt '):
fmt_chunk = WaveAudio.FMT_CHUNK.parse(chunk_data)
self.__sample_rate__ = fmt_chunk.sample_rate
if (fmt_chunk.compression == 0xFFFE):
self.__channel_mask__ = \
WaveAudio.fmt_chunk_to_channel_mask(
fmt_chunk.channel_mask)
elif (self.__format__ is AiffAudio):
for (chunk_id, chunk_data) in self.__aiff_chunks__():
if (chunk_id == 'COMM'):
comm_chunk = AiffAudio.COMM_CHUNK.parse(chunk_data)
self.__sample_rate__ = comm_chunk.sample_rate
def __wave_chunks__(self):
total_size = sum([len(block) for block in self.__blocks__
if block is not None])
wave_data = cStringIO.StringIO("".join([block for block in
self.__blocks__
if block is not None]))
wave_data.read(12) # skip the RIFFxxxxWAVE header data
total_size -= 12
#iterate over all the non-data chunks
while (total_size > 0):
header = WaveAudio.CHUNK_HEADER.parse_stream(wave_data)
total_size -= 8
if (header.chunk_id != 'data'):
yield (header.chunk_id, wave_data.read(header.chunk_length))
total_size -= header.chunk_length
else:
continue
def __aiff_chunks__(self):
total_size = sum([len(block) for block in self.__blocks__
if block is not None])
aiff_data = cStringIO.StringIO("".join([block for block in
self.__blocks__
if block is not None]))
aiff_data.read(12) # skip the FORMxxxxAIFF header data
total_size -= 12
#iterate over all the chunks
while (total_size > 0):
header = AiffAudio.CHUNK_HEADER.parse_stream(aiff_data)
total_size -= 8
if (header.chunk_id != 'SSND'):
yield (header.chunk_id, aiff_data.read(header.chunk_length))
total_size -= header.chunk_length
else:
#This presumes that audiotools encoded
#the Shorten file from an AIFF source.
#The reference encoder places the 8 alignment
#bytes in the PCM stream itself, which is wrong.
yield (header.chunk_id, aiff_data.read(8))
total_size -= 8
@classmethod
def is_type(cls, file):
"""Returns True if the given file object describes this format.
Takes a seekable file pointer rewound to the start of the file."""
return (file.read(4) == 'ajkg') and (ord(file.read(1)) == 2)
def bits_per_sample(self):
"""Returns an integer number of bits-per-sample this track contains."""
if (not hasattr(self, "__bits_per_sample__")):
self.__populate_metadata__()
return self.__bits_per_sample__
def channels(self):
"""Returns an integer number of channels this track contains."""
if (not hasattr(self, "__channels__")):
self.__populate_metadata__()
return self.__channels__
def channel_mask(self):
"""Returns a ChannelMask object of this track's channel layout."""
if (not hasattr(self, "__channel_mask__")):
self.__populate_metadata__()
return self.__channel_mask__
def lossless(self):
"""Returns True."""
return True
def total_frames(self):
"""Returns the total PCM frames of the track as an integer."""
if (not hasattr(self, "__total_frames__")):
self.__populate_metadata__()
return self.__total_frames__
def sample_rate(self):
"""Returns the rate of the track's audio as an integer number of Hz."""
if (not hasattr(self, "__sample_rate__")):
self.__populate_metadata__()
return self.__sample_rate__
def to_pcm(self):
"""Returns a PCMReader object containing the track's PCM data."""
try:
sample_rate = self.sample_rate()
channels = self.channels()
channel_mask = int(self.channel_mask())
bits_per_sample = self.bits_per_sample()
decoder = audiotools.decoders.SHNDecoder(self.filename)
decoder.sample_rate = sample_rate
decoder.channel_mask = channel_mask
return decoder
except (IOError, ValueError), msg:
#these may not be accurate if the Shorten file is broken
#but if it is broken, there'll be no way to
#cross-check the results anyway
return PCMReaderError(error_message=str(msg),
sample_rate=44100,
channels=2,
channel_mask=0x3,
bits_per_sample=16)
@classmethod
def from_pcm(cls, filename, pcmreader, compression=None,
block_size=256):
"""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 ShortenAudio object."""
if (pcmreader.bits_per_sample not in (8, 16)):
raise UnsupportedBitsPerSample(filename, pcmreader.bits_per_sample)
import tempfile
f = tempfile.NamedTemporaryFile(suffix=".wav")
try:
w = WaveAudio.from_pcm(f.name, pcmreader)
return cls.from_wave(filename, f.name, compression, block_size)
finally:
if (os.path.isfile(f.name)):
f.close()
else:
f.close_called = True
def to_wave(self, wave_filename, progress=None):
"""Writes the contents of this file to the given .wav filename string.
Raises EncodingError if some error occurs during decoding."""
if (not hasattr(self, "__format__")):
try:
self.__populate_metadata__()
except IOError, msg:
raise EncodingError(str(msg))
if (self.__format__ is WaveAudio):
try:
f = open(wave_filename, 'wb')
except IOError, msg:
raise EncodingError(str(msg))
for block in self.__blocks__:
if (block is not None):
f.write(block)
else:
try:
total_frames = self.total_frames()
current_frames = 0
decoder = audiotools.decoders.SHNDecoder(self.filename)
frame = decoder.read(4096)
while (len(frame) > 0):
f.write(frame.to_bytes(False, True))
current_frames += frame.frames
if (progress is not None):
progress(current_frames, total_frames)
frame = decoder.read(4096)
except IOError, msg:
raise EncodingError(str(msg))
else:
WaveAudio.from_pcm(wave_filename, to_pcm_progress(self, progress))
def to_aiff(self, aiff_filename, progress=None):
"""Writes the contents of this file to the given .aiff filename string.
Raises EncodingError if some error occurs during decoding."""
if (not hasattr(self, "__format__")):
try:
self.__populate_metadata__()
except IOError, msg:
raise EncodingError(str(msg))
if (self.__format__ is AiffAudio):
try:
f = open(aiff_filename, 'wb')
except IOError, msg:
raise EncodingError(str(msg))
for block in self.__blocks__:
if (block is not None):
f.write(block)
else:
try:
total_frames = self.total_frames()
current_frames = 0
decoder = audiotools.decoders.SHNDecoder(self.filename)
frame = decoder.read(4096)
while (len(frame) > 0):
f.write(frame.to_bytes(True, True))
current_frames += frame.frames
if (progress is not None):
progress(current_frames, total_frames)
frame = decoder.read(4096)
except IOError, msg:
raise EncodingError(str(msg))
else:
AiffAudio.from_pcm(aiff_filename, to_pcm_progress(self, progress))
@classmethod
def from_wave(cls, filename, wave_filename, compression=None,
block_size=256, 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 ShortenAudio object."""
wave = WaveAudio(wave_filename)
if (wave.bits_per_sample() not in (8, 16)):
raise UnsupportedBitsPerSample(filename, wave.bits_per_sample())
(head, tail) = wave.pcm_split()
if (len(tail) > 0):
blocks = [head, None, tail]
else:
blocks = [head, None]
import audiotools.encoders
try:
audiotools.encoders.encode_shn(
filename=filename,
pcmreader=to_pcm_progress(wave, progress),
block_size=block_size,
file_type={8: 2,
16: 5}[wave.bits_per_sample()],
verbatim_chunks=blocks)
return cls(filename)
except IOError, err:
cls.__unlink__(filename)
raise EncodingError(str(err))
except Exception, err:
cls.__unlink__(filename)
raise err
@classmethod
def from_aiff(cls, filename, aiff_filename, compression=None,
block_size=256, progress=None):
"""Encodes a new AudioFile from an existing .aiff file.
Takes a filename string, aiff_filename string
of an existing WaveAudio file
and an optional compression level string.
Encodes a new audio file from the aiff's data
at the given filename with the specified compression level
and returns a new ShortenAudio object."""
aiff = AiffAudio(aiff_filename)
if (aiff.bits_per_sample() not in (8, 16)):
raise UnsupportedBitsPerSample(filename, aiff.bits_per_sample())
(head, tail) = aiff.pcm_split()
if (len(tail) > 0):
blocks = [head, None, tail]
else:
blocks = [head, None]
import audiotools.encoders
try:
audiotools.encoders.encode_shn(
filename=filename,
pcmreader=to_pcm_progress(aiff, progress),
block_size=block_size,
file_type={8: 1, # 8-bit AIFF seems to be signed
16: 3}[aiff.bits_per_sample()],
verbatim_chunks=blocks)
return cls(filename)
except IOError, err:
cls.__unlink__(filename)
raise EncodingError(str(err))
except Exception, err:
cls.__unlink__(filename)
raise err
def convert(self, target_path, target_class, compression=None,
progress=None):
"""Encodes a new AudioFile from existing AudioFile.
Take a filename string, target class and optional compression string.
Encodes a new AudioFile in the target class and returns
the resulting object.
Metadata is not copied during conversion, but embedded
RIFF chunks are (if any).
May raise EncodingError if some problem occurs during encoding."""
#Note that a Shorten file cannot contain
#both RIFF chunks and AIFF chunks at the same time.
import tempfile
if (target_class == WaveAudio):
self.to_wave(target_path, progress=progress)
return WaveAudio(target_path)
elif (target_class == AiffAudio):
self.to_aiff(target_path, progress=progress)
return AiffAudio(target_path)
elif (self.has_foreign_riff_chunks() and
hasattr(target_class, "from_wave")):
temp_wave = tempfile.NamedTemporaryFile(suffix=".wav")
try:
#we'll only log the second leg of conversion,
#since that's likely to be the slower portion
self.to_wave(temp_wave.name)
return target_class.from_wave(target_path,
temp_wave.name,
compression,
progress=progress)
finally:
temp_wave.close()
elif (self.has_foreign_aiff_chunks() and
hasattr(target_class, "from_aiff")):
temp_aiff = tempfile.NamedTemporaryFile(suffix=".aiff")
try:
self.to_aiff(temp_aiff.name)
return target_class.from_aiff(target_path,
temp_aiff.name,
compression,
progress=progress)
finally:
temp_aiff.close()
else:
return target_class.from_pcm(target_path,
to_pcm_progress(self, progress),
compression)
def has_foreign_riff_chunks(self):
"""Returns True if the audio file contains non-audio RIFF chunks.
During transcoding, if the source audio file has foreign RIFF chunks
and the target audio format supports foreign RIFF chunks,
conversion should be routed through .wav conversion
to avoid losing those chunks."""
if (not hasattr(self, "__format__")):
self.__populate_metadata__()
if (self.__format__ is WaveAudio):
for (chunk_id, chunk_data) in self.__wave_chunks__():
if (chunk_id != 'fmt '):
return True
else:
return False
else:
return False
def has_foreign_aiff_chunks(self):
"""Returns True if the audio file contains non-audio AIFF chunks.
During transcoding, if the source audio file has foreign AIFF chunks
and the target audio format supports foreign AIFF chunks,
conversion should be routed through .aiff conversion
to avoid losing those chunks."""
if (not hasattr(self, "__format__")):
self.__populate_metadata__()
if (self.__format__ is AiffAudio):
for (chunk_id, chunk_data) in self.__aiff_chunks__():
if ((chunk_id != 'COMM') and (chunk_id != 'SSND')):
return True
else:
return False
else:
return False

View File

@ -0,0 +1,268 @@
#!/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, os, ignore_sigint,
EncodingError, DecodingError, ChannelMask,
__default_quality__)
from __vorbis__ import *
#######################
#Speex File
#######################
class InvalidSpeex(InvalidFile):
pass
class UnframedVorbisComment(VorbisComment):
"""An implementation of VorbisComment without the framing bit."""
VORBIS_COMMENT = Con.Struct("vorbis_comment",
Con.PascalString(
"vendor_string",
length_field=Con.ULInt32("length")),
Con.PrefixedArray(
length_field=Con.ULInt32("length"),
subcon=Con.PascalString("value",
length_field=Con.ULInt32("length"))))
class SpeexAudio(VorbisAudio):
"""An Ogg Speex audio file."""
SUFFIX = "spx"
NAME = SUFFIX
DEFAULT_COMPRESSION = "8"
COMPRESSION_MODES = tuple([str(i) for i in range(0, 11)])
COMPRESSION_DESCRIPTIONS = {"0":
_(u"corresponds to speexenc --quality 0"),
"10":
_(u"corresponds to speexenc --quality 10")}
BINARIES = ("speexenc", "speexdec")
REPLAYGAIN_BINARIES = tuple()
SPEEX_HEADER = Con.Struct('speex_header',
Con.String('speex_string', 8),
Con.String('speex_version', 20),
Con.ULInt32('speex_version_id'),
Con.ULInt32('header_size'),
Con.ULInt32('sampling_rate'),
Con.ULInt32('mode'),
Con.ULInt32('mode_bitstream_version'),
Con.ULInt32('channels'),
Con.ULInt32('bitrate'),
Con.ULInt32('frame_size'),
Con.ULInt32('vbr'),
Con.ULInt32('frame_per_packet'),
Con.ULInt32('extra_headers'),
Con.ULInt32('reserved1'),
Con.ULInt32('reserved2'))
def __init__(self, filename):
"""filename is a plain string."""
AudioFile.__init__(self, filename)
try:
self.__read_metadata__()
except IOError, msg:
raise InvalidSpeex(str(msg))
@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(0x23)
return (header.startswith('OggS') and
header[0x1C:0x23] == 'Speex ')
def __read_metadata__(self):
f = OggStreamReader(file(self.filename, "rb"))
packets = f.packets()
try:
#first read the Header packet
try:
header = SpeexAudio.SPEEX_HEADER.parse(packets.next())
except StopIteration:
raise InvalidSpeex(_(u"Header packet not found"))
self.__sample_rate__ = header.sampling_rate
self.__channels__ = header.channels
#the read the Comment packet
comment_packet = packets.next()
self.comment = UnframedVorbisComment.VORBIS_COMMENT.parse(
comment_packet)
finally:
del(packets)
f.close()
del(f)
def to_pcm(self):
"""Returns a PCMReader object containing the track's PCM data."""
devnull = file(os.devnull, 'ab')
sub = subprocess.Popen([BIN['speexdec'], self.filename, '-'],
stdout=subprocess.PIPE,
stderr=devnull)
return PCMReader(
sub.stdout,
sample_rate=self.sample_rate(),
channels=self.channels(),
channel_mask=int(ChannelMask.from_channels(self.channels())),
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 SpeexAudio object."""
import bisect
if ((compression is None) or
(compression not in cls.COMPRESSION_MODES)):
compression = __default_quality__(cls.NAME)
if ((pcmreader.bits_per_sample not in (8, 16)) or
(pcmreader.channels > 2) or
(pcmreader.sample_rate not in (8000, 16000, 32000, 44100))):
pcmreader = PCMConverter(
pcmreader,
sample_rate=[8000, 8000, 16000, 32000, 44100][bisect.bisect(
[8000, 16000, 32000, 44100], pcmreader.sample_rate)],
channels=min(pcmreader.channels, 2),
channel_mask=ChannelMask.from_channels(
min(pcmreader.channels, 2)),
bits_per_sample=min(pcmreader.bits_per_sample, 16))
BITS_PER_SAMPLE = {8: ['--8bit'],
16: ['--16bit']}[pcmreader.bits_per_sample]
CHANNELS = {1: [], 2: ['--stereo']}[pcmreader.channels]
devnull = file(os.devnull, "ab")
sub = subprocess.Popen([BIN['speexenc'],
'--quality', str(compression),
'--rate', str(pcmreader.sample_rate),
'--le'] + \
BITS_PER_SAMPLE + \
CHANNELS + \
['-', filename],
stdin=subprocess.PIPE,
stderr=devnull,
preexec_fn=ignore_sigint)
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()
result = sub.wait()
devnull.close()
if (result == 0):
return SpeexAudio(filename)
else:
raise EncodingError(u"unable to encode file with speexenc")
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."""
comment = VorbisComment.converted(metadata)
if (comment is None):
return
reader = OggStreamReader(file(self.filename, 'rb'))
new_file = cStringIO.StringIO()
writer = OggStreamWriter(new_file)
pages = reader.pages()
#transfer our old header
(header_page, header_data) = pages.next()
writer.write_page(header_page, header_data)
#skip the existing comment packet
(page, data) = pages.next()
while (page.segment_lengths[-1] == 255):
(page, data) = pages.next()
#write the pages for our new comment packet
comment_pages = OggStreamWriter.build_pages(
0,
header_page.bitstream_serial_number,
header_page.page_sequence_number + 1,
comment.build())
for (page, data) in comment_pages:
writer.write_page(page, data)
#write the rest of the pages, re-sequenced and re-checksummed
sequence_number = comment_pages[-1][0].page_sequence_number + 1
for (i, (page, data)) in enumerate(pages):
page.page_sequence_number = i + sequence_number
page.checksum = OggStreamReader.calculate_ogg_checksum(page, data)
writer.write_page(page, data)
reader.close()
#re-write the file with our new data in "new_file"
f = file(self.filename, "wb")
f.write(new_file.getvalue())
f.close()
writer.close()
self.__read_metadata__()
@classmethod
def can_add_replay_gain(cls):
"""Returns False."""
return False

View File

@ -0,0 +1,842 @@
#!/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,
ReorderedPCMReader, Con, transfer_data,
transfer_framelist_data, subprocess, BIN,
cStringIO, open_files, os, ReplayGain,
ignore_sigint, EncodingError, DecodingError,
ChannelMask, UnsupportedChannelMask,
__default_quality__)
from __vorbiscomment__ import *
import gettext
gettext.install("audiotools", unicode=True)
class InvalidVorbis(InvalidFile):
pass
def verify_ogg_stream(stream):
"""Verifies an Ogg stream file object.
This file must be rewound to the start of a page.
Returns True if the file is valid.
Raises IOError or ValueError if there is some problem with the file.
"""
from . import verify
verify.ogg(stream)
return True
class OggStreamReader:
"""A class for walking through an Ogg stream."""
OGGS = Con.Struct(
"oggs",
Con.Const(Con.String("magic_number", 4), "OggS"),
Con.Byte("version"),
Con.Byte("header_type"),
Con.SLInt64("granule_position"),
Con.ULInt32("bitstream_serial_number"),
Con.ULInt32("page_sequence_number"),
Con.ULInt32("checksum"),
Con.Byte("segments"),
Con.MetaRepeater(lambda ctx: ctx["segments"],
Con.Byte("segment_lengths")))
def __init__(self, stream):
"""stream is a file-like object with read() and close() methods."""
self.stream = stream
def close(self):
"""Closes the sub-stream."""
self.stream.close()
def packets(self, from_beginning=True):
"""Yields one fully reassembled Ogg packet per pass.
Packets are returned as binary strings."""
if (from_beginning):
self.stream.seek(0, 0)
segment = cStringIO.StringIO()
while (True):
try:
page = OggStreamReader.OGGS.parse_stream(self.stream)
for length in page.segment_lengths:
if (length == 255):
segment.write(self.stream.read(length))
else:
segment.write(self.stream.read(length))
yield segment.getvalue()
segment = cStringIO.StringIO()
except Con.core.FieldError:
break
except Con.ConstError:
break
def pages(self, from_beginning=True):
"""Yields a (Container,string) tuple per pass.
Container is parsed from OggStreamReader.OGGS.
string is a binary string of combined segments
(which may not be a complete packet)."""
if (from_beginning):
self.stream.seek(0, 0)
while (True):
try:
page = OggStreamReader.OGGS.parse_stream(self.stream)
yield (page, self.stream.read(sum(page.segment_lengths)))
except Con.core.FieldError:
break
except Con.ConstError:
break
@classmethod
def pages_to_packet(cls, pages_iter):
"""Returns a complete packet as a list of (Container,string) tuples.
pages_iter should be an iterator of (Container,string) tuples
as returned from the pages() method.
"""
packet = [pages_iter.next()]
while (packet[-1][0].segment_lengths[-1] == 255):
packet.append(pages_iter.next())
return packet
CRC_LOOKUP = (0x00000000, 0x04c11db7, 0x09823b6e, 0x0d4326d9,
0x130476dc, 0x17c56b6b, 0x1a864db2, 0x1e475005,
0x2608edb8, 0x22c9f00f, 0x2f8ad6d6, 0x2b4bcb61,
0x350c9b64, 0x31cd86d3, 0x3c8ea00a, 0x384fbdbd,
0x4c11db70, 0x48d0c6c7, 0x4593e01e, 0x4152fda9,
0x5f15adac, 0x5bd4b01b, 0x569796c2, 0x52568b75,
0x6a1936c8, 0x6ed82b7f, 0x639b0da6, 0x675a1011,
0x791d4014, 0x7ddc5da3, 0x709f7b7a, 0x745e66cd,
0x9823b6e0, 0x9ce2ab57, 0x91a18d8e, 0x95609039,
0x8b27c03c, 0x8fe6dd8b, 0x82a5fb52, 0x8664e6e5,
0xbe2b5b58, 0xbaea46ef, 0xb7a96036, 0xb3687d81,
0xad2f2d84, 0xa9ee3033, 0xa4ad16ea, 0xa06c0b5d,
0xd4326d90, 0xd0f37027, 0xddb056fe, 0xd9714b49,
0xc7361b4c, 0xc3f706fb, 0xceb42022, 0xca753d95,
0xf23a8028, 0xf6fb9d9f, 0xfbb8bb46, 0xff79a6f1,
0xe13ef6f4, 0xe5ffeb43, 0xe8bccd9a, 0xec7dd02d,
0x34867077, 0x30476dc0, 0x3d044b19, 0x39c556ae,
0x278206ab, 0x23431b1c, 0x2e003dc5, 0x2ac12072,
0x128e9dcf, 0x164f8078, 0x1b0ca6a1, 0x1fcdbb16,
0x018aeb13, 0x054bf6a4, 0x0808d07d, 0x0cc9cdca,
0x7897ab07, 0x7c56b6b0, 0x71159069, 0x75d48dde,
0x6b93dddb, 0x6f52c06c, 0x6211e6b5, 0x66d0fb02,
0x5e9f46bf, 0x5a5e5b08, 0x571d7dd1, 0x53dc6066,
0x4d9b3063, 0x495a2dd4, 0x44190b0d, 0x40d816ba,
0xaca5c697, 0xa864db20, 0xa527fdf9, 0xa1e6e04e,
0xbfa1b04b, 0xbb60adfc, 0xb6238b25, 0xb2e29692,
0x8aad2b2f, 0x8e6c3698, 0x832f1041, 0x87ee0df6,
0x99a95df3, 0x9d684044, 0x902b669d, 0x94ea7b2a,
0xe0b41de7, 0xe4750050, 0xe9362689, 0xedf73b3e,
0xf3b06b3b, 0xf771768c, 0xfa325055, 0xfef34de2,
0xc6bcf05f, 0xc27dede8, 0xcf3ecb31, 0xcbffd686,
0xd5b88683, 0xd1799b34, 0xdc3abded, 0xd8fba05a,
0x690ce0ee, 0x6dcdfd59, 0x608edb80, 0x644fc637,
0x7a089632, 0x7ec98b85, 0x738aad5c, 0x774bb0eb,
0x4f040d56, 0x4bc510e1, 0x46863638, 0x42472b8f,
0x5c007b8a, 0x58c1663d, 0x558240e4, 0x51435d53,
0x251d3b9e, 0x21dc2629, 0x2c9f00f0, 0x285e1d47,
0x36194d42, 0x32d850f5, 0x3f9b762c, 0x3b5a6b9b,
0x0315d626, 0x07d4cb91, 0x0a97ed48, 0x0e56f0ff,
0x1011a0fa, 0x14d0bd4d, 0x19939b94, 0x1d528623,
0xf12f560e, 0xf5ee4bb9, 0xf8ad6d60, 0xfc6c70d7,
0xe22b20d2, 0xe6ea3d65, 0xeba91bbc, 0xef68060b,
0xd727bbb6, 0xd3e6a601, 0xdea580d8, 0xda649d6f,
0xc423cd6a, 0xc0e2d0dd, 0xcda1f604, 0xc960ebb3,
0xbd3e8d7e, 0xb9ff90c9, 0xb4bcb610, 0xb07daba7,
0xae3afba2, 0xaafbe615, 0xa7b8c0cc, 0xa379dd7b,
0x9b3660c6, 0x9ff77d71, 0x92b45ba8, 0x9675461f,
0x8832161a, 0x8cf30bad, 0x81b02d74, 0x857130c3,
0x5d8a9099, 0x594b8d2e, 0x5408abf7, 0x50c9b640,
0x4e8ee645, 0x4a4ffbf2, 0x470cdd2b, 0x43cdc09c,
0x7b827d21, 0x7f436096, 0x7200464f, 0x76c15bf8,
0x68860bfd, 0x6c47164a, 0x61043093, 0x65c52d24,
0x119b4be9, 0x155a565e, 0x18197087, 0x1cd86d30,
0x029f3d35, 0x065e2082, 0x0b1d065b, 0x0fdc1bec,
0x3793a651, 0x3352bbe6, 0x3e119d3f, 0x3ad08088,
0x2497d08d, 0x2056cd3a, 0x2d15ebe3, 0x29d4f654,
0xc5a92679, 0xc1683bce, 0xcc2b1d17, 0xc8ea00a0,
0xd6ad50a5, 0xd26c4d12, 0xdf2f6bcb, 0xdbee767c,
0xe3a1cbc1, 0xe760d676, 0xea23f0af, 0xeee2ed18,
0xf0a5bd1d, 0xf464a0aa, 0xf9278673, 0xfde69bc4,
0x89b8fd09, 0x8d79e0be, 0x803ac667, 0x84fbdbd0,
0x9abc8bd5, 0x9e7d9662, 0x933eb0bb, 0x97ffad0c,
0xafb010b1, 0xab710d06, 0xa6322bdf, 0xa2f33668,
0xbcb4666d, 0xb8757bda, 0xb5365d03, 0xb1f740b4)
@classmethod
def calculate_ogg_checksum(cls, page_header, page_data):
"""Calculates an Ogg checksum integer.
page_header is a Container object parsed through OGGS.
page_data is a string of data contained by the page.
"""
old_checksum = page_header.checksum
try:
page_header.checksum = 0
sum = 0
for c in cls.OGGS.build(page_header) + page_data:
sum = ((sum << 8) ^ \
cls.CRC_LOOKUP[((sum >> 24) & 0xFF) ^ ord(c)]) \
& 0xFFFFFFFF
return sum
finally:
page_header.checksum = old_checksum
class OggStreamWriter:
"""A class for building an Ogg stream."""
def __init__(self, stream):
"""stream is a file-like object with read() and close() methods."""
self.stream = stream
def close(self):
"""Closes the sub-stream."""
self.stream.close()
def write_page(self, page_header, page_data):
"""Writes a complete Ogg page to the stream.
page_header is an OGGS-generated Container with all of the
fields properly set.
page_data is a string containing all of the page's segment data.
"""
self.stream.write(OggStreamReader.OGGS.build(page_header))
self.stream.write(page_data)
@classmethod
def build_pages(cls, granule_position, serial_number,
starting_sequence_number, packet_data,
header_type=0):
"""Constructs an Ogg packet for page data.
takes serial_number, granule_position and starting_sequence_number
integers and a packet_data string.
Returns a list of (page_header,page_data) tuples containing
all of the Ogg pages necessary to contain the packet.
"""
page = Con.Container(magic_number='OggS',
version=0,
header_type=header_type,
granule_position=granule_position,
bitstream_serial_number=serial_number,
page_sequence_number=starting_sequence_number,
checksum=0)
if (len(packet_data) == 0):
#an empty Ogg page, but possibly a continuation
page.segments = 0
page.segment_lengths = []
page.checksum = OggStreamReader.calculate_ogg_checksum(
page, packet_data)
return [(page, "")]
if (len(packet_data) > (255 * 255)):
#if we need more than one Ogg page to store the packet,
#handle that case recursively
page.segments = 255
page.segment_lengths = [255] * 255
page.checksum = OggStreamReader.calculate_ogg_checksum(
page, packet_data[0:255 * 255])
return [(page, packet_data[0:255 * 255])] + \
cls.build_pages(granule_position,
serial_number,
starting_sequence_number + 1,
packet_data[255 * 255:],
header_type)
elif (len(packet_data) == (255 * 255)):
#we need two Ogg pages, one of which is empty
return cls.build_pages(granule_position,
serial_number,
starting_sequence_number,
packet_data,
header_type) + \
cls.build_pages(granule_position,
serial_number,
starting_sequence_number + 1,
"",
header_type)
else:
#we just need one Ogg page
page.segments = len(packet_data) / 255
if ((len(packet_data) % 255) > 0):
page.segments += 1
page.segment_lengths = [255] * (len(packet_data) / 255)
if ((len(packet_data) % 255) > 0):
page.segment_lengths += [len(packet_data) % 255]
page.checksum = OggStreamReader.calculate_ogg_checksum(
page, packet_data)
return [(page, packet_data)]
#######################
#Vorbis File
#######################
class VorbisAudio(AudioFile):
"""An Ogg Vorbis file."""
SUFFIX = "ogg"
NAME = SUFFIX
DEFAULT_COMPRESSION = "3"
COMPRESSION_MODES = tuple([str(i) for i in range(0, 11)])
COMPRESSION_DESCRIPTIONS = {"0": _(u"very low quality, " +
u"corresponds to oggenc -q 0"),
"10": _(u"very high quality, " +
u"corresponds to oggenc -q 10")}
BINARIES = ("oggenc", "oggdec")
REPLAYGAIN_BINARIES = ("vorbisgain", )
OGG_IDENTIFICATION = Con.Struct(
"ogg_id",
Con.ULInt32("vorbis_version"),
Con.Byte("channels"),
Con.ULInt32("sample_rate"),
Con.ULInt32("bitrate_maximum"),
Con.ULInt32("bitrate_nominal"),
Con.ULInt32("bitrate_minimum"),
Con.Embed(Con.BitStruct("flags",
Con.Bits("blocksize_0", 4),
Con.Bits("blocksize_1", 4))),
Con.Byte("framing"))
COMMENT_HEADER = Con.Struct(
"comment_header",
Con.Byte("packet_type"),
Con.String("vorbis", 6))
def __init__(self, filename):
"""filename is a plain string."""
AudioFile.__init__(self, filename)
try:
self.__read_metadata__()
except IOError, msg:
raise InvalidVorbis(str(msg))
@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(0x23)
return (header.startswith('OggS') and
header[0x1C:0x23] == '\x01vorbis')
def __read_metadata__(self):
f = OggStreamReader(file(self.filename, "rb"))
packets = f.packets()
try:
#we'll assume this Vorbis file isn't interleaved
#with any other Ogg stream
#the Identification packet comes first
try:
id_packet = packets.next()
except StopIteration:
raise InvalidVorbis("Vorbis identification packet not found")
header = VorbisAudio.COMMENT_HEADER.parse(
id_packet[0:VorbisAudio.COMMENT_HEADER.sizeof()])
if ((header.packet_type == 0x01) and
(header.vorbis == 'vorbis')):
identification = VorbisAudio.OGG_IDENTIFICATION.parse(
id_packet[VorbisAudio.COMMENT_HEADER.sizeof():])
self.__sample_rate__ = identification.sample_rate
self.__channels__ = identification.channels
else:
raise InvalidVorbis(_(u'First packet is not Vorbis'))
#the Comment packet comes next
comment_packet = packets.next()
header = VorbisAudio.COMMENT_HEADER.parse(
comment_packet[0:VorbisAudio.COMMENT_HEADER.sizeof()])
if ((header.packet_type == 0x03) and
(header.vorbis == 'vorbis')):
self.comment = VorbisComment.VORBIS_COMMENT.parse(
comment_packet[VorbisAudio.COMMENT_HEADER.sizeof():])
finally:
del(packets)
f.close()
del(f)
def lossless(self):
"""Returns False."""
return False
def bits_per_sample(self):
"""Returns an integer number of bits-per-sample this track contains."""
return 16
def channels(self):
"""Returns an integer number of channels this track contains."""
return self.__channels__
def channel_mask(self):
"""Returns a ChannelMask object of this track's channel layout."""
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)
elif (self.channels() == 7):
return ChannelMask.from_fields(
front_left=True, front_right=True,
front_center=True,
side_left=True, side_right=True,
back_center=True, low_frequency=True)
elif (self.channels() == 8):
return ChannelMask.from_fields(
front_left=True, front_right=True,
side_left=True, side_right=True,
back_left=True, back_right=True,
front_center=True, low_frequency=True)
else:
return ChannelMask(0)
def total_frames(self):
"""Returns the total PCM frames of the track as an integer."""
pcm_samples = 0
f = file(self.filename, "rb")
try:
while (True):
try:
page = OggStreamReader.OGGS.parse_stream(f)
pcm_samples = page.granule_position
f.seek(sum(page.segment_lengths), 1)
except Con.core.FieldError:
break
except Con.ConstError:
break
return pcm_samples
finally:
f.close()
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."""
sub = subprocess.Popen([BIN['oggdec'], '-Q',
'-b', str(16),
'-e', str(0),
'-s', str(1),
'-R',
'-o', '-',
self.filename],
stdout=subprocess.PIPE,
stderr=file(os.devnull, "a"))
pcmreader = 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)
if (self.channels() <= 2):
return pcmreader
elif (self.channels() <= 8):
#these mappings transform Vorbis order into ChannelMask order
standard_channel_mask = self.channel_mask()
vorbis_channel_mask = VorbisChannelMask(self.channel_mask())
return ReorderedPCMReader(
pcmreader,
[vorbis_channel_mask.channels().index(channel) for channel in
standard_channel_mask.channels()])
else:
return pcmreader
@classmethod
def from_pcm(cls, filename, pcmreader, compression=None):
"""Returns a PCMReader object containing the track's PCM data."""
if ((compression is None) or
(compression not in cls.COMPRESSION_MODES)):
compression = __default_quality__(cls.NAME)
devnull = file(os.devnull, 'ab')
sub = subprocess.Popen([BIN['oggenc'], '-Q',
'-r',
'-B', str(pcmreader.bits_per_sample),
'-C', str(pcmreader.channels),
'-R', str(pcmreader.sample_rate),
'--raw-endianness', str(0),
'-q', compression,
'-o', filename, '-'],
stdin=subprocess.PIPE,
stdout=devnull,
stderr=devnull,
preexec_fn=ignore_sigint)
if ((pcmreader.channels <= 2) or (int(pcmreader.channel_mask) == 0)):
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
elif (pcmreader.channels <= 8):
if (int(pcmreader.channel_mask) in
(0x7, # FR, FC, FL
0x33, # FR, FL, BR, BL
0x37, # FR, FC, FL, BL, BR
0x3f, # FR, FC, FL, BL, BR, LFE
0x70f, # FL, FC, FR, SL, SR, BC, LFE
0x63f)): # FL, FC, FR, SL, SR, BL, BR, LFE
standard_channel_mask = ChannelMask(pcmreader.channel_mask)
vorbis_channel_mask = VorbisChannelMask(standard_channel_mask)
else:
raise UnsupportedChannelMask(filename,
int(pcmreader.channel_mask))
try:
transfer_framelist_data(ReorderedPCMReader(
pcmreader,
[standard_channel_mask.channels().index(channel)
for channel in vorbis_channel_mask.channels()]),
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
else:
raise UnsupportedChannelMask(filename,
int(pcmreader.channel_mask))
try:
pcmreader.close()
except DecodingError, err:
raise EncodingError(err.error_message)
sub.stdin.close()
devnull.close()
if (sub.wait() == 0):
return VorbisAudio(filename)
else:
raise EncodingError(u"unable to encode file with oggenc")
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 = VorbisComment.converted(metadata)
if (metadata is None):
return
reader = OggStreamReader(file(self.filename, 'rb'))
new_file = cStringIO.StringIO()
writer = OggStreamWriter(new_file)
current_sequence_number = 0
pages = reader.pages()
#transfer our old header
#this must always be the first packet and the first page
(header_page, header_data) = pages.next()
writer.write_page(header_page, header_data)
current_sequence_number += 1
#grab the current "comment" and "setup headers" packets
#these may take one or more pages,
#but will always end on a page boundary
del(pages)
packets = reader.packets(from_beginning=False)
comment_packet = packets.next()
headers_packet = packets.next()
#write the pages for our new "comment" packet
for (page, data) in OggStreamWriter.build_pages(
0,
header_page.bitstream_serial_number,
current_sequence_number,
VorbisAudio.COMMENT_HEADER.build(Con.Container(
packet_type=3,
vorbis='vorbis')) + metadata.build()):
writer.write_page(page, data)
current_sequence_number += 1
#write the pages for the old "setup headers" packet
for (page, data) in OggStreamWriter.build_pages(
0,
header_page.bitstream_serial_number,
current_sequence_number,
headers_packet):
writer.write_page(page, data)
current_sequence_number += 1
#write the rest of the pages, re-sequenced and re-checksummed
del(packets)
pages = reader.pages(from_beginning=False)
for (i, (page, data)) in enumerate(pages):
page.page_sequence_number = i + current_sequence_number
page.checksum = OggStreamReader.calculate_ogg_checksum(page, data)
writer.write_page(page, data)
reader.close()
#re-write the file with our new data in "new_file"
f = file(self.filename, "wb")
f.write(new_file.getvalue())
f.close()
writer.close()
self.__read_metadata__()
def get_metadata(self):
"""Returns a MetaData object, or None.
Raises IOError if unable to read the file."""
self.__read_metadata__()
data = {}
for pair in self.comment.value:
try:
(key, value) = pair.split('=', 1)
data.setdefault(key, []).append(value.decode('utf-8'))
except ValueError:
continue
return VorbisComment(data)
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())
@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)
if ((len(track_names) > 0) and
BIN.can_execute(BIN['vorbisgain'])):
devnull = file(os.devnull, 'ab')
sub = subprocess.Popen([BIN['vorbisgain'],
'-q', '-a'] + track_names,
stdout=devnull,
stderr=devnull)
sub.wait()
devnull.close()
if (progress is not None):
progress(1, 1)
@classmethod
def can_add_replay_gain(cls):
"""Returns True if we have the necessary binaries to add ReplayGain."""
return BIN.can_execute(BIN['vorbisgain'])
@classmethod
def lossless_replay_gain(cls):
"""Returns True."""
return True
def replay_gain(self):
"""Returns a ReplayGain object of our ReplayGain values.
Returns None if we have no values."""
vorbis_metadata = self.get_metadata()
if (set(['REPLAYGAIN_TRACK_PEAK', 'REPLAYGAIN_TRACK_GAIN',
'REPLAYGAIN_ALBUM_PEAK', 'REPLAYGAIN_ALBUM_GAIN']).issubset(
vorbis_metadata.keys())): # we have ReplayGain data
try:
return ReplayGain(
vorbis_metadata['REPLAYGAIN_TRACK_GAIN'][0][0:-len(" dB")],
vorbis_metadata['REPLAYGAIN_TRACK_PEAK'][0],
vorbis_metadata['REPLAYGAIN_ALBUM_GAIN'][0][0:-len(" dB")],
vorbis_metadata['REPLAYGAIN_ALBUM_PEAK'][0])
except ValueError:
return None
else:
return None
def verify(self, progress=None):
"""Verifies the current file for correctness.
Returns True if the file is okay.
Raises an InvalidFile with an error message if there is
some problem with the file."""
#Ogg stream verification is likely to be so fast
#that individual calls to progress() are
#a waste of time.
if (progress is not None):
progress(0, 1)
try:
f = open(self.filename, 'rb')
except IOError, err:
raise InvalidVorbis(str(err))
try:
try:
result = verify_ogg_stream(f)
if (progress is not None):
progress(1, 1)
return result
except (IOError, ValueError), err:
raise InvalidVorbis(str(err))
finally:
f.close()
class VorbisChannelMask(ChannelMask):
"""The Vorbis-specific channel mapping."""
def __repr__(self):
return "VorbisChannelMask(%s)" % \
",".join(["%s=%s" % (field, getattr(self, field))
for field in self.SPEAKER_TO_MASK.keys()
if (getattr(self, field))])
def channels(self):
"""Returns a list of speaker strings this mask contains.
Returned in the order in which they should appear
in the PCM stream.
"""
count = len(self)
if (count == 1):
return ["front_center"]
elif (count == 2):
return ["front_left", "front_right"]
elif (count == 3):
return ["front_left", "front_center", "front_right"]
elif (count == 4):
return ["front_left", "front_right",
"back_left", "back_right"]
elif (count == 5):
return ["front_left", "front_center", "front_right",
"back_left", "back_right"]
elif (count == 6):
return ["front_left", "front_center", "front_right",
"back_left", "back_right", "low_frequency"]
elif (count == 7):
return ["front_left", "front_center", "front_right",
"side_left", "side_right", "back_center",
"low_frequency"]
elif (count == 8):
return ["front_left", "front_center", "front_right",
"side_left", "side_right",
"back_left", "back_right", "low_frequency"]
else:
return []

View File

@ -0,0 +1,294 @@
#!/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 MetaData, Con, VERSION, re
class VorbisComment(MetaData, dict):
"""A complete Vorbis Comment tag."""
VORBIS_COMMENT = Con.Struct(
"vorbis_comment",
Con.PascalString("vendor_string",
length_field=Con.ULInt32("length")),
Con.PrefixedArray(
length_field=Con.ULInt32("length"),
subcon=Con.PascalString("value",
length_field=Con.ULInt32("length"))),
Con.Const(Con.Byte("framing"), 1))
ATTRIBUTE_MAP = {'track_name': 'TITLE',
'track_number': 'TRACKNUMBER',
'track_total': 'TRACKTOTAL',
'album_name': 'ALBUM',
'artist_name': 'ARTIST',
'performer_name': 'PERFORMER',
'composer_name': 'COMPOSER',
'conductor_name': 'CONDUCTOR',
'media': 'SOURCE MEDIUM',
'ISRC': 'ISRC',
'catalog': 'CATALOG',
'copyright': 'COPYRIGHT',
'publisher': 'PUBLISHER',
'year': 'DATE',
'album_number': 'DISCNUMBER',
'album_total': 'DISCTOTAL',
'comment': 'COMMENT'}
ITEM_MAP = dict(map(reversed, ATTRIBUTE_MAP.items()))
def __init__(self, vorbis_data, vendor_string=u""):
"""Initialized with a key->[value1,value2] dict.
keys are generally upper case.
values are unicode string.
vendor_string is an optional unicode string."""
dict.__init__(self, [(key.upper(), values)
for (key, values) in vorbis_data.items()])
self.vendor_string = vendor_string
def __setitem__(self, key, value):
dict.__setitem__(self, key.upper(), value)
def __getattr__(self, key):
if (key == 'track_number'):
match = re.match(r'^\d+$',
self.get('TRACKNUMBER', [u''])[0])
if (match):
return int(match.group(0))
else:
match = re.match('^(\d+)/\d+$',
self.get('TRACKNUMBER', [u''])[0])
if (match):
return int(match.group(1))
else:
return 0
elif (key == 'track_total'):
match = re.match(r'^\d+$',
self.get('TRACKTOTAL', [u''])[0])
if (match):
return int(match.group(0))
else:
match = re.match('^\d+/(\d+)$',
self.get('TRACKNUMBER', [u''])[0])
if (match):
return int(match.group(1))
else:
return 0
elif (key == 'album_number'):
match = re.match(r'^\d+$',
self.get('DISCNUMBER', [u''])[0])
if (match):
return int(match.group(0))
else:
match = re.match('^(\d+)/\d+$',
self.get('DISCNUMBER', [u''])[0])
if (match):
return int(match.group(1))
else:
return 0
elif (key == 'album_total'):
match = re.match(r'^\d+$',
self.get('DISCTOTAL', [u''])[0])
if (match):
return int(match.group(0))
else:
match = re.match('^\d+/(\d+)$',
self.get('DISCNUMBER', [u''])[0])
if (match):
return int(match.group(1))
else:
return 0
elif (key in self.ATTRIBUTE_MAP):
return 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'):
track_number = self.get('TRACKNUMBER', [u''])[0]
if (re.match(r'^\d+$', track_number)):
del(self['TRACKNUMBER'])
elif (re.match('^\d+/(\d+)$', track_number)):
self['TRACKNUMBER'] = u"0/%s" % (
re.match('^\d+/(\d+)$', track_number).group(1))
elif (key == 'track_total'):
track_number = self.get('TRACKNUMBER', [u''])[0]
if (re.match('^(\d+)/\d+$', track_number)):
self['TRACKNUMBER'] = u"%s" % (
re.match('^(\d+)/\d+$', track_number).group(1))
if ('TRACKTOTAL' in self):
del(self['TRACKTOTAL'])
elif (key == 'album_number'):
album_number = self.get('DISCNUMBER', [u''])[0]
if (re.match(r'^\d+$', album_number)):
del(self['DISCNUMBER'])
elif (re.match('^\d+/(\d+)$', album_number)):
self['DISCNUMBER'] = u"0/%s" % (
re.match('^\d+/(\d+)$', album_number).group(1))
elif (key == 'album_total'):
album_number = self.get('DISCNUMBER', [u''])[0]
if (re.match('^(\d+)/\d+$', album_number)):
self['DISCNUMBER'] = u"%s" % (
re.match('^(\d+)/\d+$', album_number).group(1))
if ('DISCTOTAL' in self):
del(self['DISCTOTAL'])
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)
@classmethod
def supports_images(cls):
"""Returns False."""
#There's actually a (proposed?) standard to add embedded covers
#to Vorbis Comments by base64 encoding them.
#This strikes me as messy and convoluted.
#In addition, I'd have to perform a special case of
#image extraction and re-insertion whenever converting
#to FlacMetaData. The whole thought gives me a headache.
return False
def images(self):
"""Returns an empty list of Image objects."""
return list()
#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):
if (key not in MetaData.__INTEGER_FIELDS__):
self[self.ATTRIBUTE_MAP[key]] = [value]
else:
self[self.ATTRIBUTE_MAP[key]] = [unicode(value)]
else:
self.__dict__[key] = value
@classmethod
def converted(cls, metadata):
"""Converts a MetaData object to a VorbisComment object."""
if ((metadata is None) or (isinstance(metadata, VorbisComment))):
return metadata
elif (metadata.__class__.__name__ == 'FlacMetaData'):
return cls(vorbis_data=dict(metadata.vorbis_comment.items()),
vendor_string=metadata.vorbis_comment.vendor_string)
else:
values = {}
for key in cls.ATTRIBUTE_MAP.keys():
if (key in cls.__INTEGER_FIELDS__):
if (getattr(metadata, key) != 0):
values[cls.ATTRIBUTE_MAP[key]] = \
[unicode(getattr(metadata, key))]
elif (getattr(metadata, key) != u""):
values[cls.ATTRIBUTE_MAP[key]] = \
[unicode(getattr(metadata, key))]
return VorbisComment(values)
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 ((len(values) > 0) and
(len(self.get(key, [])) == 0)):
self[key] = values
def __comment_name__(self):
return u'Vorbis'
#takes two (key,value) vorbiscomment pairs
#returns cmp on the weighted set of them
#(title first, then artist, album, tracknumber, ... , replaygain)
@classmethod
def __by_pair__(cls, pair1, pair2):
KEY_MAP = {"TITLE": 1,
"ALBUM": 2,
"TRACKNUMBER": 3,
"TRACKTOTAL": 4,
"DISCNUMBER": 5,
"DISCTOTAL": 6,
"ARTIST": 7,
"PERFORMER": 8,
"COMPOSER": 9,
"CONDUCTOR": 10,
"CATALOG": 11,
"PUBLISHER": 12,
"ISRC": 13,
"SOURCE MEDIUM": 14,
#"YEAR": 15,
"DATE": 16,
"COPYRIGHT": 17,
"REPLAYGAIN_ALBUM_GAIN": 19,
"REPLAYGAIN_ALBUM_PEAK": 19,
"REPLAYGAIN_TRACK_GAIN": 19,
"REPLAYGAIN_TRACK_PEAK": 19,
"REPLAYGAIN_REFERENCE_LOUDNESS": 20}
return cmp((KEY_MAP.get(pair1[0].upper(), 18),
pair1[0].upper(),
pair1[1]),
(KEY_MAP.get(pair2[0].upper(), 18),
pair2[0].upper(),
pair2[1]))
def __comment_pairs__(self):
pairs = []
for (key, values) in self.items():
for value in values:
pairs.append((key, value))
pairs.sort(VorbisComment.__by_pair__)
return pairs
def build(self):
"""Returns this VorbisComment as a binary string."""
comment = Con.Container(vendor_string=self.vendor_string,
framing=1,
value=[])
for (key, values) in self.items():
for value in values:
if ((value != u"") and not
((key in ("TRACKNUMBER", "TRACKTOTAL",
"DISCNUMBER", "DISCTOTAL")) and
(value == u"0"))):
comment.value.append("%s=%s" % (key,
value.encode('utf-8')))
return self.VORBIS_COMMENT.build(comment)

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,661 @@
#!/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, Con, subprocess, BIN,
open_files, os, ReplayGain, ignore_sigint,
transfer_data, transfer_framelist_data,
BufferedPCMReader, Image, MetaData, sheet_to_unicode,
calculate_replay_gain, ApeTagItem,
EncodingError, DecodingError, PCMReaderError,
PCMReader, ChannelMask,
InvalidWave, __default_quality__,
WaveContainer, to_pcm_progress)
from __wav__ import WaveAudio, WaveReader
from __ape__ import ApeTaggedAudio, ApeTag, __number_pair__
import gettext
gettext.install("audiotools", unicode=True)
class InvalidWavPack(InvalidFile):
pass
class __24BitsLE__(Con.Adapter):
def _encode(self, value, context):
return chr(value & 0x0000FF) + \
chr((value & 0x00FF00) >> 8) + \
chr((value & 0xFF0000) >> 16)
def _decode(self, obj, context):
return (ord(obj[2]) << 16) | (ord(obj[1]) << 8) | ord(obj[0])
def ULInt24(name):
return __24BitsLE__(Con.Bytes(name, 3))
def __riff_chunk_ids__(data):
import cStringIO
total_size = len(data)
data = cStringIO.StringIO(data)
header = WaveAudio.WAVE_HEADER.parse_stream(data)
while (data.tell() < total_size):
chunk_header = WaveAudio.CHUNK_HEADER.parse_stream(data)
chunk_size = chunk_header.chunk_length
if ((chunk_size & 1) == 1):
chunk_size += 1
data.seek(chunk_size, 1)
yield chunk_header.chunk_id
#######################
#WavPack APEv2
#######################
class WavPackAPEv2(ApeTag):
"""A WavPack-specific APEv2 implementation with minor differences."""
def __init__(self, tags, tag_length=None, frame_count=0):
"""Constructs an ApeTag from a list of ApeTagItem objects.
tag_length is an optional total length integer.
frame_count is an optional number of PCM frames
to be used by cuesheets."""
ApeTag.__init__(self, tags=tags, tag_length=tag_length)
self.frame_count = frame_count
def __comment_pairs__(self):
return filter(lambda pair: pair[0] != 'Cuesheet',
ApeTag.__comment_pairs__(self))
def __unicode__(self):
if ('Cuesheet' not in self.keys()):
return ApeTag.__unicode__(self)
else:
import cue
try:
return u"%s%sCuesheet:\n%s" % \
(MetaData.__unicode__(self),
os.linesep * 2,
sheet_to_unicode(
cue.parse(
cue.tokens(unicode(self['Cuesheet']).encode(
'ascii', 'replace'))),
self.frame_count))
except cue.CueException:
return ApeTag.__unicode__(self)
@classmethod
def converted(cls, metadata):
"""Converts a MetaData object to a WavPackAPEv2 object."""
if ((metadata is None) or (isinstance(metadata, WavPackAPEv2))):
return metadata
elif (isinstance(metadata, ApeTag)):
return WavPackAPEv2(metadata.tags)
else:
return WavPackAPEv2(ApeTag.converted(metadata).tags)
WavePackAPEv2 = WavPackAPEv2
#######################
#WavPack
#######################
class WavPackAudio(ApeTaggedAudio, WaveContainer):
"""A WavPack audio file."""
SUFFIX = "wv"
NAME = SUFFIX
DEFAULT_COMPRESSION = "standard"
COMPRESSION_MODES = ("veryfast", "fast", "standard", "high", "veryhigh")
COMPRESSION_DESCRIPTIONS = {"veryfast": _(u"fastest encode/decode, " +
u"worst compression"),
"veryhigh": _(u"slowest encode/decode, " +
u"best compression")}
APE_TAG_CLASS = WavPackAPEv2
HEADER = Con.Struct("wavpackheader",
Con.Const(Con.String("id", 4), 'wvpk'),
Con.ULInt32("block_size"),
Con.ULInt16("version"),
Con.ULInt8("track_number"),
Con.ULInt8("index_number"),
Con.ULInt32("total_samples"),
Con.ULInt32("block_index"),
Con.ULInt32("block_samples"),
Con.Embed(
Con.BitStruct("flags",
Con.Flag("floating_point_data"),
Con.Flag("hybrid_noise_shaping"),
Con.Flag("cross_channel_decorrelation"),
Con.Flag("joint_stereo"),
Con.Flag("hybrid_mode"),
Con.Flag("mono_output"),
Con.Bits("bits_per_sample", 2),
Con.Bits("left_shift_data_low", 3),
Con.Flag("final_block_in_sequence"),
Con.Flag("initial_block_in_sequence"),
Con.Flag("hybrid_noise_balanced"),
Con.Flag("hybrid_mode_control_bitrate"),
Con.Flag("extended_size_integers"),
Con.Bit("sampling_rate_low"),
Con.Bits("maximum_magnitude", 5),
Con.Bits("left_shift_data_high", 2),
Con.Flag("reserved2"),
Con.Flag("false_stereo"),
Con.Flag("use_IIR"),
Con.Bits("reserved1", 2),
Con.Bits("sampling_rate_high", 3))),
Con.ULInt32("crc"))
SUB_HEADER = Con.Struct("wavpacksubheader",
Con.Embed(
Con.BitStruct("flags",
Con.Flag("large_block"),
Con.Flag("actual_size_1_less"),
Con.Flag("nondecoder_data"),
Con.Bits("metadata_function", 5))),
Con.IfThenElse('size',
lambda ctx: ctx['large_block'],
ULInt24('s'),
Con.Byte('s')))
BITS_PER_SAMPLE = (8, 16, 24, 32)
SAMPLING_RATE = (6000, 8000, 9600, 11025,
12000, 16000, 22050, 24000,
32000, 44100, 48000, 64000,
88200, 96000, 192000, 0)
__options__ = {"veryfast": {"block_size": 44100,
"joint_stereo": True,
"false_stereo": True,
"wasted_bits": True,
"decorrelation_passes": 1},
"fast": {"block_size": 44100,
"joint_stereo": True,
"false_stereo": True,
"wasted_bits": True,
"decorrelation_passes": 2},
"standard": {"block_size": 44100,
"joint_stereo": True,
"false_stereo": True,
"wasted_bits": True,
"decorrelation_passes": 5},
"high": {"block_size": 44100,
"joint_stereo": True,
"false_stereo": True,
"wasted_bits": True,
"decorrelation_passes": 10},
"veryhigh": {"block_size": 44100,
"joint_stereo": True,
"false_stereo": True,
"wasted_bits": True,
"decorrelation_passes": 16}}
def __init__(self, filename):
"""filename is a plain string."""
self.filename = filename
self.__samplerate__ = 0
self.__channels__ = 0
self.__bitspersample__ = 0
self.__total_frames__ = 0
try:
self.__read_info__()
except IOError, msg:
raise InvalidWavPack(str(msg))
@classmethod
def is_type(cls, file):
"""Returns True if the given file object describes this format.
Takes a seekable file pointer rewound to the start of the file."""
return file.read(4) == 'wvpk'
def lossless(self):
"""Returns True."""
return True
def channel_mask(self):
"""Returns a ChannelMask object of this track's channel layout."""
if ((self.__channels__ == 1) or (self.__channels__ == 2)):
return ChannelMask.from_channels(self.__channels__)
else:
for (block_id, nondecoder, data) in self.sub_frames():
if ((block_id == 0xD) and not nondecoder):
mask = 0
for byte in reversed(map(ord, data[1:])):
mask = (mask << 8) | byte
return ChannelMask(mask)
else:
return ChannelMask(0)
def get_metadata(self):
"""Returns a MetaData object, or None.
Raises IOError if unable to read the file."""
metadata = ApeTaggedAudio.get_metadata(self)
if (metadata is not None):
metadata.frame_count = self.total_frames()
return metadata
def has_foreign_riff_chunks(self):
"""Returns True if the audio file contains non-audio RIFF chunks.
During transcoding, if the source audio file has foreign RIFF chunks
and the target audio format supports foreign RIFF chunks,
conversion should be routed through .wav conversion
to avoid losing those chunks."""
for (sub_header, nondecoder, data) in self.sub_frames():
if ((sub_header == 1) and nondecoder):
if (set(__riff_chunk_ids__(data)) != set(['fmt ', 'data'])):
return True
elif ((sub_header == 2) and nondecoder):
return True
else:
return False
def frames(self):
"""Yields (header, data) tuples of WavPack frames.
header is a Container parsed from WavPackAudio.HEADER.
data is a binary string.
"""
f = file(self.filename)
total_size = os.path.getsize(self.filename)
try:
while (f.tell() < total_size):
try:
header = WavPackAudio.HEADER.parse(f.read(
WavPackAudio.HEADER.sizeof()))
except Con.ConstError:
break
data = f.read(header.block_size - 24)
yield (header, data)
finally:
f.close()
def sub_frames(self):
"""Yields (function,nondecoder,data) tuples.
function is an integer.
nondecoder is a boolean indicating non-decoder data.
data is a binary string.
"""
import cStringIO
for (header, data) in self.frames():
total_size = len(data)
data = cStringIO.StringIO(data)
while (data.tell() < total_size):
sub_header = WavPackAudio.SUB_HEADER.parse_stream(data)
if (sub_header.actual_size_1_less):
yield (sub_header.metadata_function,
sub_header.nondecoder_data,
data.read((sub_header.size * 2) - 1))
data.read(1)
else:
yield (sub_header.metadata_function,
sub_header.nondecoder_data,
data.read(sub_header.size * 2))
def __read_info__(self):
f = file(self.filename)
try:
try:
header = WavPackAudio.HEADER.parse(f.read(
WavPackAudio.HEADER.sizeof()))
except Con.ConstError:
raise InvalidWavPack(_(u'WavPack header ID invalid'))
except Con.FieldError:
raise InvalidWavPack(_(u'WavPack header ID invalid'))
self.__samplerate__ = WavPackAudio.SAMPLING_RATE[
(header.sampling_rate_high << 1) |
header.sampling_rate_low]
if (self.__samplerate__ == 0):
#if unknown, pull from the RIFF WAVE header
for (function, nondecoder, data) in self.sub_frames():
if ((function == 1) and nondecoder):
#fmt chunk must be in the header
#since it must come before the data chunk
import cStringIO
chunks = cStringIO.StringIO(data[12:-8])
try:
while (True):
chunk_header = \
WaveAudio.CHUNK_HEADER.parse_stream(
chunks)
chunk_data = chunks.read(
chunk_header.chunk_length)
if (chunk_header.chunk_id == 'fmt '):
self.__samplerate__ = \
WaveAudio.FMT_CHUNK.parse(
chunk_data).sample_rate
except Con.FieldError:
pass # finished with chunks
self.__bitspersample__ = WavPackAudio.BITS_PER_SAMPLE[
header.bits_per_sample]
self.__total_frames__ = header.total_samples
self.__channels__ = 0
#go through as many headers as necessary
#to count the number of channels
if (header.mono_output):
self.__channels__ += 1
else:
self.__channels__ += 2
while (not header.final_block_in_sequence):
f.seek(header.block_size - 24, 1)
header = WavPackAudio.HEADER.parse(f.read(
WavPackAudio.HEADER.sizeof()))
if (header.mono_output):
self.__channels__ += 1
else:
self.__channels__ += 2
finally:
f.close()
def bits_per_sample(self):
"""Returns an integer number of bits-per-sample this track contains."""
return self.__bitspersample__
def channels(self):
"""Returns an integer number of channels this track contains."""
return self.__channels__
def total_frames(self):
"""Returns the total PCM frames of the track as an integer."""
return self.__total_frames__
def sample_rate(self):
"""Returns the rate of the track's audio as an integer number of Hz."""
return self.__samplerate__
@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 WavPackAudio object."""
from . import encoders
if ((compression is None) or
(compression not in cls.COMPRESSION_MODES)):
compression = __default_quality__(cls.NAME)
try:
encoders.encode_wavpack(filename,
BufferedPCMReader(pcmreader),
**cls.__options__[compression])
return cls(filename)
except (ValueError, IOError), msg:
cls.__unlink__(filename)
raise EncodingError(str(msg))
except Exception, err:
cls.__unlink__(filename)
raise err
def to_wave(self, wave_filename, progress=None):
"""Writes the contents of this file to the given .wav filename string.
Raises EncodingError if some error occurs during decoding."""
from . import decoders
try:
f = open(wave_filename, 'wb')
except IOError, msg:
raise EncodingError(str(msg))
(head, tail) = self.pcm_split()
try:
f.write(head)
total_frames = self.total_frames()
current_frames = 0
decoder = decoders.WavPackDecoder(self.filename)
frame = decoder.read(4096)
while (len(frame) > 0):
f.write(frame.to_bytes(False, self.bits_per_sample() > 8))
current_frames += frame.frames
if (progress is not None):
progress(current_frames, total_frames)
frame = decoder.read(4096)
f.write(tail)
f.close()
except IOError, msg:
self.__unlink__(wave_filename)
raise EncodingError(str(msg))
def to_pcm(self):
"""Returns a PCMReader object containing the track's PCM data."""
from . import decoders
try:
return decoders.WavPackDecoder(self.filename,
self.__samplerate__)
except (IOError, ValueError), msg:
return PCMReaderError(error_message=str(msg),
sample_rate=self.__samplerate__,
channels=self.__channels__,
channel_mask=int(self.channel_mask()),
bits_per_sample=self.__bitspersample__)
@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 WavPackAudio object."""
from . import encoders
if ((compression is None) or
(compression not in cls.COMPRESSION_MODES)):
compression = __default_quality__(cls.NAME)
wave = WaveAudio(wave_filename)
(head, tail) = wave.pcm_split()
try:
encoders.encode_wavpack(filename,
to_pcm_progress(wave, progress),
wave_header=head,
wave_footer=tail,
**cls.__options__[compression])
return cls(filename)
except (ValueError, IOError), msg:
cls.__unlink__(filename)
raise EncodingError(str(msg))
except Exception, err:
cls.__unlink__(filename)
raise err
def pcm_split(self):
"""Returns a pair of data strings before and after PCM data."""
head = ""
tail = ""
for (sub_block_id, nondecoder, data) in self.sub_frames():
if ((sub_block_id == 1) and nondecoder):
head = data
elif ((sub_block_id == 2) and nondecoder):
tail = data
return (head, tail)
@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.
"""
tracks = [track for track in open_files(filenames) if
isinstance(track, cls)]
if (len(tracks) > 0):
for (track,
track_gain,
track_peak,
album_gain,
album_peak) in calculate_replay_gain(tracks, progress):
metadata = track.get_metadata()
if (metadata is None):
metadata = WavPackAPEv2([])
metadata["replaygain_track_gain"] = ApeTagItem.string(
"replaygain_track_gain",
u"%+1.2f dB" % (track_gain))
metadata["replaygain_track_peak"] = ApeTagItem.string(
"replaygain_track_peak",
u"%1.6f" % (track_peak))
metadata["replaygain_album_gain"] = ApeTagItem.string(
"replaygain_album_gain",
u"%+1.2f dB" % (album_gain))
metadata["replaygain_album_peak"] = ApeTagItem.string(
"replaygain_album_peak",
u"%1.6f" % (album_peak))
track.set_metadata(metadata)
@classmethod
def can_add_replay_gain(cls):
"""Returns True."""
return True
@classmethod
def lossless_replay_gain(cls):
"""Returns True."""
return True
def replay_gain(self):
"""Returns a ReplayGain object of our ReplayGain values.
Returns None if we have no values."""
metadata = self.get_metadata()
if (metadata is None):
return None
if (set(['replaygain_track_gain', 'replaygain_track_peak',
'replaygain_album_gain', 'replaygain_album_peak']).issubset(
metadata.keys())): # we have ReplayGain data
try:
return ReplayGain(
unicode(metadata['replaygain_track_gain'])[0:-len(" dB")],
unicode(metadata['replaygain_track_peak']),
unicode(metadata['replaygain_album_gain'])[0:-len(" dB")],
unicode(metadata['replaygain_album_peak']))
except ValueError:
return None
else:
return None
def get_cuesheet(self):
"""Returns the embedded Cuesheet-compatible object, or None.
Raises IOError if a problem occurs when reading the file."""
import cue
metadata = self.get_metadata()
if ((metadata is not None) and ('Cuesheet' in metadata.keys())):
try:
return cue.parse(cue.tokens(
unicode(metadata['Cuesheet']).encode('utf-8',
'replace')))
except cue.CueException:
#unlike FLAC, just because a cuesheet is embedded
#does not mean it is compliant
return None
else:
return None
def set_cuesheet(self, cuesheet):
"""Imports cuesheet data from a Cuesheet-compatible object.
This are objects with catalog(), ISRCs(), indexes(), and pcm_lengths()
methods. Raises IOError if an error occurs setting the cuesheet."""
import os.path
import cue
if (cuesheet is None):
return
metadata = self.get_metadata()
if (metadata is None):
metadata = WavPackAPEv2.converted(MetaData())
metadata['Cuesheet'] = WavPackAPEv2.ITEM.string('Cuesheet',
cue.Cuesheet.file(
cuesheet,
os.path.basename(self.filename)).decode('ascii', 'replace'))
self.set_metadata(metadata)

Binary file not shown.

View File

@ -0,0 +1,115 @@
"""
. #### ####
## #### ## ## #### ###### ##### ## ## #### ###### ## ##
## ## ## ### ## ## ## ## ## ## ## ## ## #### ##
## ## ## ###### ### ## ##### ## ## ## ## ##
## ## ## ## ### ## ## ## ## ## ## ## ## ##
#### #### ## ## #### ## ## ## ##### #### ## ######
Parsing made even more fun (and faster too)
Homepage:
http://construct.wikispaces.com (including online tutorial)
Typical usage:
>>> from construct import *
Hands-on example:
>>> from construct import *
>>>
>>> s = Struct("foo",
... UBInt8("a"),
... UBInt16("b"),
... )
>>>
>>> s.parse("\\x01\\x02\\x03")
Container(a = 1, b = 515)
>>>
>>> print s.parse("\\x01\\x02\\x03")
Container:
a = 1
b = 515
>>>
>>> s.build(Container(a = 1, b = 0x0203))
"\\x01\\x02\\x03"
"""
from core import *
from adapters import *
from macros import *
from debug import Probe, Debugger
#===============================================================================
# meta data
#===============================================================================
__author__ = "tomer filiba (tomerfiliba [at] gmail.com)"
__version__ = "2.04"
#===============================================================================
# shorthands
#===============================================================================
Bits = BitField
Byte = UBInt8
Bytes = Field
Const = ConstAdapter
Tunnel = TunnelAdapter
Embed = Embedded
#===============================================================================
# deprecated names (kept for backward compatibility with RC1)
#===============================================================================
MetaField = Field
MetaBytes = Field
GreedyRepeater = GreedyRange
OptionalGreedyRepeater = OptionalGreedyRange
Repeater = Range
StrictRepeater = Array
MetaRepeater = Array
OneOfValidator = OneOf
NoneOfValidator = NoneOf
#===============================================================================
# exposed names
#===============================================================================
__all__ = [
'AdaptationError', 'Adapter', 'Alias', 'Aligned', 'AlignedStruct',
'Anchor', 'Array', 'ArrayError', 'BFloat32', 'BFloat64', 'Bit', 'BitField',
'BitIntegerAdapter', 'BitIntegerError', 'BitStruct', 'Bits', 'Bitwise',
'Buffered', 'Byte', 'Bytes', 'CString', 'CStringAdapter', 'Const',
'ConstAdapter', 'ConstError', 'Construct', 'ConstructError', 'Container',
'Debugger', 'Embed', 'Embedded', 'EmbeddedBitStruct', 'Enum', 'ExprAdapter',
'Field', 'FieldError', 'Flag', 'FlagsAdapter', 'FlagsContainer',
'FlagsEnum', 'FormatField', 'GreedyRange', 'GreedyRepeater',
'HexDumpAdapter', 'If', 'IfThenElse', 'IndexingAdapter', 'LFloat32',
'LFloat64', 'LazyBound', 'LengthValueAdapter', 'ListContainer',
'MappingAdapter', 'MappingError', 'MetaArray', 'MetaBytes', 'MetaField',
'MetaRepeater', 'NFloat32', 'NFloat64', 'Nibble', 'NoneOf',
'NoneOfValidator', 'Octet', 'OnDemand', 'OnDemandPointer', 'OneOf',
'OneOfValidator', 'OpenRange', 'Optional', 'OptionalGreedyRange',
'OptionalGreedyRepeater', 'PaddedStringAdapter', 'Padding',
'PaddingAdapter', 'PaddingError', 'PascalString', 'Pass', 'Peek',
'Pointer', 'PrefixedArray', 'Probe', 'Range', 'RangeError', 'Reconfig',
'Rename', 'RepeatUntil', 'Repeater', 'Restream', 'SBInt16', 'SBInt32',
'SBInt64', 'SBInt8', 'SLInt16', 'SLInt32', 'SLInt64', 'SLInt8', 'SNInt16',
'SNInt32', 'SNInt64', 'SNInt8', 'Select', 'SelectError', 'Sequence',
'SizeofError', 'SlicingAdapter', 'StaticField', 'StrictRepeater', 'String',
'StringAdapter', 'Struct', 'Subconstruct', 'Switch', 'SwitchError',
'SymmetricMapping', 'Terminator', 'TerminatorError', 'Tunnel',
'TunnelAdapter', 'UBInt16', 'UBInt32', 'UBInt64', 'UBInt8', 'ULInt16',
'ULInt32', 'ULInt64', 'ULInt8', 'UNInt16', 'UNInt32', 'UNInt64', 'UNInt8',
'Union', 'ValidationError', 'Validator', 'Value', "Magic",
]

View File

@ -0,0 +1,482 @@
from core import Adapter, AdaptationError, Pass
from lib import int_to_bin, bin_to_int, swap_bytes, StringIO
from lib import FlagsContainer, HexString
#===============================================================================
# exceptions
#===============================================================================
class BitIntegerError(AdaptationError):
__slots__ = []
class MappingError(AdaptationError):
__slots__ = []
class ConstError(AdaptationError):
__slots__ = []
class ValidationError(AdaptationError):
__slots__ = []
class PaddingError(AdaptationError):
__slots__ = []
#===============================================================================
# adapters
#===============================================================================
class BitIntegerAdapter(Adapter):
"""
Adapter for bit-integers (converts bitstrings to integers, and vice versa).
See BitField.
Parameters:
* subcon - the subcon to adapt
* width - the size of the subcon, in bits
* swapped - whether to swap byte order (little endian/big endian).
default is False (big endian)
* signed - whether the value is signed (two's complement). the default
is False (unsigned)
* bytesize - number of bits per byte, used for byte-swapping (if swapped).
default is 8.
"""
__slots__ = ["width", "swapped", "signed", "bytesize"]
def __init__(self, subcon, width, swapped = False, signed = False,
bytesize = 8):
Adapter.__init__(self, subcon)
self.width = width
self.swapped = swapped
self.signed = signed
self.bytesize = bytesize
def _encode(self, obj, context):
if obj < 0 and not self.signed:
raise BitIntegerError("object is negative, but field is not signed",
obj)
obj2 = int_to_bin(obj, width = self.width)
if self.swapped:
obj2 = swap_bytes(obj2, bytesize = self.bytesize)
return obj2
def _decode(self, obj, context):
if self.swapped:
obj = swap_bytes(obj, bytesize = self.bytesize)
return bin_to_int(obj, signed = self.signed)
class MappingAdapter(Adapter):
"""
Adapter that maps objects to other objects.
See SymmetricMapping and Enum.
Parameters:
* subcon - the subcon to map
* decoding - the decoding (parsing) mapping (a dict)
* encoding - the encoding (building) mapping (a dict)
* decdefault - the default return value when the object is not found
in the decoding mapping. if no object is given, an exception is raised.
if `Pass` is used, the unmapped object will be passed as-is
* encdefault - the default return value when the object is not found
in the encoding mapping. if no object is given, an exception is raised.
if `Pass` is used, the unmapped object will be passed as-is
"""
__slots__ = ["encoding", "decoding", "encdefault", "decdefault"]
def __init__(self, subcon, decoding, encoding,
decdefault = NotImplemented, encdefault = NotImplemented):
Adapter.__init__(self, subcon)
self.decoding = decoding
self.encoding = encoding
self.decdefault = decdefault
self.encdefault = encdefault
def _encode(self, obj, context):
try:
return self.encoding[obj]
except (KeyError, TypeError):
if self.encdefault is NotImplemented:
raise MappingError("no encoding mapping for %r" % (obj,))
if self.encdefault is Pass:
return obj
return self.encdefault
def _decode(self, obj, context):
try:
return self.decoding[obj]
except (KeyError, TypeError):
if self.decdefault is NotImplemented:
raise MappingError("no decoding mapping for %r" % (obj,))
if self.decdefault is Pass:
return obj
return self.decdefault
class FlagsAdapter(Adapter):
"""
Adapter for flag fields. Each flag is extracted from the number, resulting
in a FlagsContainer object. Not intended for direct usage.
See FlagsEnum.
Parameters
* subcon - the subcon to extract
* flags - a dictionary mapping flag-names to their value
"""
__slots__ = ["flags"]
def __init__(self, subcon, flags):
Adapter.__init__(self, subcon)
self.flags = flags
def _encode(self, obj, context):
flags = 0
for name, value in self.flags.iteritems():
if getattr(obj, name, False):
flags |= value
return flags
def _decode(self, obj, context):
obj2 = FlagsContainer()
for name, value in self.flags.iteritems():
setattr(obj2, name, bool(obj & value))
return obj2
class StringAdapter(Adapter):
"""
Adapter for strings. Converts a sequence of characters into a python
string, and optionally handles character encoding.
See String.
Parameters:
* subcon - the subcon to convert
* encoding - the character encoding name (e.g., "utf8"), or None to
return raw bytes (usually 8-bit ASCII).
"""
__slots__ = ["encoding"]
def __init__(self, subcon, encoding = None):
Adapter.__init__(self, subcon)
self.encoding = encoding
def _encode(self, obj, context):
if self.encoding:
obj = obj.encode(self.encoding)
return obj
def _decode(self, obj, context):
obj = "".join(obj)
if self.encoding:
obj = obj.decode(self.encoding)
return obj
class PaddedStringAdapter(Adapter):
r"""
Adapter for padded strings.
See String.
Parameters:
* subcon - the subcon to adapt
* padchar - the padding character. default is "\x00".
* paddir - the direction where padding is placed ("right", "left", or
"center"). the default is "right".
* trimdir - the direction where trimming will take place ("right" or
"left"). the default is "right". trimming is only meaningful for
building, when the given string is too long.
"""
__slots__ = ["padchar", "paddir", "trimdir"]
def __init__(self, subcon, padchar = "\x00", paddir = "right",
trimdir = "right"):
if paddir not in ("right", "left", "center"):
raise ValueError("paddir must be 'right', 'left' or 'center'",
paddir)
if trimdir not in ("right", "left"):
raise ValueError("trimdir must be 'right' or 'left'", trimdir)
Adapter.__init__(self, subcon)
self.padchar = padchar
self.paddir = paddir
self.trimdir = trimdir
def _decode(self, obj, context):
if self.paddir == "right":
obj = obj.rstrip(self.padchar)
elif self.paddir == "left":
obj = obj.lstrip(self.padchar)
else:
obj = obj.strip(self.padchar)
return obj
def _encode(self, obj, context):
size = self._sizeof(context)
if self.paddir == "right":
obj = obj.ljust(size, self.padchar)
elif self.paddir == "left":
obj = obj.rjust(size, self.padchar)
else:
obj = obj.center(size, self.padchar)
if len(obj) > size:
if self.trimdir == "right":
obj = obj[:size]
else:
obj = obj[-size:]
return obj
class LengthValueAdapter(Adapter):
"""
Adapter for length-value pairs. It extracts only the value from the
pair, and calculates the length based on the value.
See PrefixedArray and PascalString.
Parameters:
* subcon - the subcon returning a length-value pair
"""
__slots__ = []
def _encode(self, obj, context):
return (len(obj), obj)
def _decode(self, obj, context):
return obj[1]
class CStringAdapter(StringAdapter):
r"""
Adapter for C-style strings (strings terminated by a terminator char).
Parameters:
* subcon - the subcon to convert
* terminators - a sequence of terminator chars. default is "\x00".
* encoding - the character encoding to use (e.g., "utf8"), or None to
return raw-bytes. the terminator characters are not affected by the
encoding.
"""
__slots__ = ["terminators"]
def __init__(self, subcon, terminators = "\x00", encoding = None):
StringAdapter.__init__(self, subcon, encoding = encoding)
self.terminators = terminators
def _encode(self, obj, context):
return StringAdapter._encode(self, obj, context) + self.terminators[0]
def _decode(self, obj, context):
return StringAdapter._decode(self, obj[:-1], context)
class TunnelAdapter(Adapter):
"""
Adapter for tunneling (as in protocol tunneling). A tunnel is construct
nested upon another (layering). For parsing, the lower layer first parses
the data (note: it must return a string!), then the upper layer is called
to parse that data (bottom-up). For building it works in a top-down manner;
first the upper layer builds the data, then the lower layer takes it and
writes it to the stream.
Parameters:
* subcon - the lower layer subcon
* inner_subcon - the upper layer (tunneled/nested) subcon
Example:
# a pascal string containing compressed data (zlib encoding), so first
# the string is read, decompressed, and finally re-parsed as an array
# of UBInt16
TunnelAdapter(
PascalString("data", encoding = "zlib"),
GreedyRange(UBInt16("elements"))
)
"""
__slots__ = ["inner_subcon"]
def __init__(self, subcon, inner_subcon):
Adapter.__init__(self, subcon)
self.inner_subcon = inner_subcon
def _decode(self, obj, context):
return self.inner_subcon._parse(StringIO(obj), context)
def _encode(self, obj, context):
stream = StringIO()
self.inner_subcon._build(obj, stream, context)
return stream.getvalue()
class ExprAdapter(Adapter):
"""
A generic adapter that accepts 'encoder' and 'decoder' as parameters. You
can use ExprAdapter instead of writing a full-blown class when only a
simple expression is needed.
Parameters:
* subcon - the subcon to adapt
* encoder - a function that takes (obj, context) and returns an encoded
version of obj
* decoder - a function that takes (obj, context) and returns an decoded
version of obj
Example:
ExprAdapter(UBInt8("foo"),
encoder = lambda obj, ctx: obj / 4,
decoder = lambda obj, ctx: obj * 4,
)
"""
__slots__ = ["_encode", "_decode"]
def __init__(self, subcon, encoder, decoder):
Adapter.__init__(self, subcon)
self._encode = encoder
self._decode = decoder
class HexDumpAdapter(Adapter):
"""
Adapter for hex-dumping strings. It returns a HexString, which is a string
"""
__slots__ = ["linesize"]
def __init__(self, subcon, linesize = 16):
Adapter.__init__(self, subcon)
self.linesize = linesize
def _encode(self, obj, context):
return obj
def _decode(self, obj, context):
return HexString(obj, linesize = self.linesize)
class ConstAdapter(Adapter):
"""
Adapter for enforcing a constant value ("magic numbers"). When decoding,
the return value is checked; when building, the value is substituted in.
Parameters:
* subcon - the subcon to validate
* value - the expected value
Example:
Const(Field("signature", 2), "MZ")
"""
__slots__ = ["value"]
def __init__(self, subcon, value):
Adapter.__init__(self, subcon)
self.value = value
def _encode(self, obj, context):
if obj is None or obj == self.value:
return self.value
else:
raise ConstError("expected %r, found %r" % (self.value, obj))
def _decode(self, obj, context):
if obj != self.value:
raise ConstError("expected %r, found %r" % (self.value, obj))
return obj
class SlicingAdapter(Adapter):
"""
Adapter for slicing a list (getting a slice from that list)
Parameters:
* subcon - the subcon to slice
* start - start index
* stop - stop index (or None for up-to-end)
* step - step (or None for every element)
"""
__slots__ = ["start", "stop", "step"]
def __init__(self, subcon, start, stop = None):
Adapter.__init__(self, subcon)
self.start = start
self.stop = stop
def _encode(self, obj, context):
if self.start is None:
return obj
return [None] * self.start + obj
def _decode(self, obj, context):
return obj[self.start:self.stop]
class IndexingAdapter(Adapter):
"""
Adapter for indexing a list (getting a single item from that list)
Parameters:
* subcon - the subcon to index
* index - the index of the list to get
"""
__slots__ = ["index"]
def __init__(self, subcon, index):
Adapter.__init__(self, subcon)
if type(index) is not int:
raise TypeError("index must be an integer", type(index))
self.index = index
def _encode(self, obj, context):
return [None] * self.index + [obj]
def _decode(self, obj, context):
return obj[self.index]
class PaddingAdapter(Adapter):
r"""
Adapter for padding.
Parameters:
* subcon - the subcon to pad
* pattern - the padding pattern (character). default is "\x00"
* strict - whether or not to verify, during parsing, that the given
padding matches the padding pattern. default is False (unstrict)
"""
__slots__ = ["pattern", "strict"]
def __init__(self, subcon, pattern = "\x00", strict = False):
Adapter.__init__(self, subcon)
self.pattern = pattern
self.strict = strict
def _encode(self, obj, context):
return self._sizeof(context) * self.pattern
def _decode(self, obj, context):
if self.strict:
expected = self._sizeof(context) * self.pattern
if obj != expected:
raise PaddingError("expected %r, found %r" % (expected, obj))
return obj
#===============================================================================
# validators
#===============================================================================
class Validator(Adapter):
"""
Abstract class: validates a condition on the encoded/decoded object.
Override _validate(obj, context) in deriving classes.
Parameters:
* subcon - the subcon to validate
"""
__slots__ = []
def _decode(self, obj, context):
if not self._validate(obj, context):
raise ValidationError("invalid object", obj)
return obj
def _encode(self, obj, context):
return self._decode(obj, context)
def _validate(self, obj, context):
raise NotImplementedError()
class OneOf(Validator):
"""
Validates that the value is one of the listed values
Parameters:
* subcon - the subcon to validate
* valids - a set of valid values
"""
__slots__ = ["valids"]
def __init__(self, subcon, valids):
Validator.__init__(self, subcon)
self.valids = valids
def _validate(self, obj, context):
return obj in self.valids
class NoneOf(Validator):
"""
Validates that the value is none of the listed values
Parameters:
* subcon - the subcon to validate
* invalids - a set of invalid values
"""
__slots__ = ["invalids"]
def __init__(self, subcon, invalids):
Validator.__init__(self, subcon)
self.invalids = invalids
def _validate(self, obj, context):
return obj not in self.invalids

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,160 @@
"""
Debugging utilities for constructs
"""
import sys
import traceback
import pdb
import inspect
from core import Construct, Subconstruct
from lib import HexString, Container, ListContainer, AttrDict
class Probe(Construct):
"""
A probe: dumps the context, stack frames, and stream content to the screen
to aid the debugging process.
See also Debugger.
Parameters:
* name - the display name
* show_stream - whether or not to show stream contents. default is True.
the stream must be seekable.
* show_context - whether or not to show the context. default is True.
* show_stack - whether or not to show the upper stack frames. default
is True.
* stream_lookahead - the number of bytes to dump when show_stack is set.
default is 100.
Example:
Struct("foo",
UBInt8("a"),
Probe("between a and b"),
UBInt8("b"),
)
"""
__slots__ = [
"printname", "show_stream", "show_context", "show_stack",
"stream_lookahead"
]
counter = 0
def __init__(self, name = None, show_stream = True,
show_context = True, show_stack = True,
stream_lookahead = 100):
Construct.__init__(self, None)
if name is None:
Probe.counter += 1
name = "<unnamed %d>" % (Probe.counter,)
self.printname = name
self.show_stream = show_stream
self.show_context = show_context
self.show_stack = show_stack
self.stream_lookahead = stream_lookahead
def __repr__(self):
return "%s(%r)" % (self.__class__.__name__, self.printname)
def _parse(self, stream, context):
self.printout(stream, context)
def _build(self, obj, stream, context):
self.printout(stream, context)
def _sizeof(self, context):
return 0
def printout(self, stream, context):
obj = Container()
if self.show_stream:
obj.stream_position = stream.tell()
follows = stream.read(self.stream_lookahead)
if not follows:
obj.following_stream_data = "EOF reached"
else:
stream.seek(-len(follows), 1)
obj.following_stream_data = HexString(follows)
print
if self.show_context:
obj.context = context
if self.show_stack:
obj.stack = ListContainer()
frames = [s[0] for s in inspect.stack()][1:-1]
frames.reverse()
for f in frames:
a = AttrDict()
a.__update__(f.f_locals)
obj.stack.append(a)
print "=" * 80
print "Probe", self.printname
print obj
print "=" * 80
class Debugger(Subconstruct):
"""
A pdb-based debugger. When an exception occurs in the subcon, a debugger
will appear and allow you to debug the error (and even fix on-the-fly).
Parameters:
* subcon - the subcon to debug
Example:
Debugger(
Enum(UBInt8("foo"),
a = 1,
b = 2,
c = 3
)
)
"""
__slots__ = ["retval"]
def _parse(self, stream, context):
try:
return self.subcon._parse(stream, context)
except Exception:
self.retval = NotImplemented
self.handle_exc("(you can set the value of 'self.retval', "
"which will be returned)")
if self.retval is NotImplemented:
raise
else:
return self.retval
def _build(self, obj, stream, context):
try:
self.subcon._build(obj, stream, context)
except Exception:
self.handle_exc()
def handle_exc(self, msg = None):
print "=" * 80
print "Debugging exception of %s:" % (self.subcon,)
print "".join(traceback.format_exception(*sys.exc_info())[1:])
if msg:
print msg
pdb.post_mortem(sys.exc_info()[2])
print "=" * 80

View File

@ -0,0 +1,10 @@
from binary import int_to_bin, bin_to_int, swap_bytes, encode_bin, decode_bin
from bitstream import BitStreamReader, BitStreamWriter
from container import (Container, AttrDict, FlagsContainer,
ListContainer, LazyContainer)
from hex import HexString, hexdump
from utils import Packer, StringIO
from path import drill

View File

@ -0,0 +1,61 @@
def int_to_bin(number, width = 32):
if number < 0:
number += 1 << width
i = width - 1
bits = ["\x00"] * width
while number and i >= 0:
bits[i] = "\x00\x01"[number & 1]
number >>= 1
i -= 1
return "".join(bits)
_bit_values = {"\x00" : 0, "\x01" : 1, "0" : 0, "1" : 1}
def bin_to_int(bits, signed = False):
number = 0
bias = 0
if signed and _bit_values[bits[0]] == 1:
bits = bits[1:]
bias = 1 << len(bits)
for b in bits:
number <<= 1
number |= _bit_values[b]
return number - bias
def swap_bytes(bits, bytesize = 8):
i = 0
l = len(bits)
output = [""] * ((l // bytesize) + 1)
j = len(output) - 1
while i < l:
output[j] = bits[i : i + bytesize]
i += bytesize
j -= 1
return "".join(output)
_char_to_bin = {}
_bin_to_char = {}
for i in range(256):
ch = chr(i)
bin = int_to_bin(i, 8)
_char_to_bin[ch] = bin
_bin_to_char[bin] = ch
_bin_to_char[bin] = ch
def encode_bin(data):
return "".join(_char_to_bin[ch] for ch in data)
def decode_bin(data):
assert len(data) & 7 == 0, "data length must be a multiple of 8"
i = 0
j = 0
l = len(data) // 8
chars = [""] * l
while j < l:
chars[j] = _bin_to_char[data[i:i+8]]
i += 8
j += 1
return "".join(chars)

View File

@ -0,0 +1,80 @@
from binary import encode_bin, decode_bin
class BitStreamReader(object):
__slots__ = ["substream", "buffer", "total_size"]
def __init__(self, substream):
self.substream = substream
self.total_size = 0
self.buffer = ""
def close(self):
if self.total_size % 8 != 0:
raise ValueError("total size of read data must be a multiple of 8",
self.total_size)
def tell(self):
return self.substream.tell()
def seek(self, pos, whence = 0):
self.buffer = ""
self.total_size = 0
self.substream.seek(pos, whence)
def read(self, count):
assert count >= 0
l = len(self.buffer)
if count == 0:
data = ""
elif count <= l:
data = self.buffer[:count]
self.buffer = self.buffer[count:]
else:
data = self.buffer
count -= l
bytes = count // 8
if count & 7:
bytes += 1
buf = encode_bin(self.substream.read(bytes))
data += buf[:count]
self.buffer = buf[count:]
self.total_size += len(data)
return data
class BitStreamWriter(object):
__slots__ = ["substream", "buffer", "pos"]
def __init__(self, substream):
self.substream = substream
self.buffer = []
self.pos = 0
def close(self):
self.flush()
def flush(self):
bytes = decode_bin("".join(self.buffer))
self.substream.write(bytes)
self.buffer = []
self.pos = 0
def tell(self):
return self.substream.tell() + self.pos // 8
def seek(self, pos, whence = 0):
self.flush()
self.substream.seek(pos, whence)
def write(self, data):
if not data:
return
if type(data) is not str:
raise TypeError("data must be a string, not %r" % (type(data),))
self.buffer.append(data)

View File

@ -0,0 +1,275 @@
def recursion_lock(retval, lock_name = "__recursion_lock__"):
def decorator(func):
def wrapper(self, *args, **kw):
if getattr(self, lock_name, False):
return retval
setattr(self, lock_name, True)
try:
return func(self, *args, **kw)
finally:
setattr(self, lock_name, False)
wrapper.__name__ = func.__name__
return wrapper
return decorator
class Container(object):
"""
A generic container of attributes
"""
__slots__ = ["__dict__", "__attrs__"]
def __init__(self, **kw):
self.__dict__.update(kw)
attrs = []
attrs.extend(kw.keys())
object.__setattr__(self, "__attrs__", attrs)
def __eq__(self, other):
try:
return self.__dict__ == other.__dict__
except AttributeError:
return False
def __ne__(self, other):
return not (self == other)
def __delattr__(self, name):
object.__delattr__(self, name)
self.__attrs__.remove(name)
def __setattr__(self, name, value):
d = self.__dict__
if name not in d and not name.startswith("__"):
self.__attrs__.append(name)
d[name] = value
def __getitem__(self, name):
return self.__dict__[name]
def __delitem__(self, name):
self.__delattr__(name)
def __setitem__(self, name, value):
self.__setattr__(name, value)
def __update__(self, obj):
for name in obj.__attrs__:
self[name] = obj[name]
def __copy__(self):
new = self.__class__()
new.__attrs__ = self.__attrs__[:]
new.__dict__ = self.__dict__.copy()
return new
def __iter__(self):
for name in self.__attrs__:
yield name, self.__dict__[name]
@recursion_lock("<...>")
def __repr__(self):
attrs = sorted("%s = %s" % (k, repr(v))
for k, v in self.__dict__.iteritems()
if not k.startswith("_"))
return "%s(%s)" % (self.__class__.__name__, ", ".join(attrs))
def __str__(self):
return self.__pretty_str__()
@recursion_lock("<...>")
def __pretty_str__(self, nesting = 1, indentation = " "):
attrs = []
ind = indentation * nesting
for k, v in self:
if not k.startswith("_"):
text = [ind, k, " = "]
if hasattr(v, "__pretty_str__"):
text.append(v.__pretty_str__(nesting + 1, indentation))
else:
text.append(repr(v))
attrs.append("".join(text))
if not attrs:
return "%s()" % (self.__class__.__name__,)
attrs.insert(0, self.__class__.__name__ + ":")
return "\n".join(attrs)
def __introspect__(self):
for k in self.__attrs__:
v = self.__dict__[k]
if not k.startswith("_"):
yield "kv", (k, v)
class FlagsContainer(Container):
"""
A container providing pretty-printing for flags. Only set flags are
displayed.
"""
def __inspect__(self):
for k in self.__attrs__:
v = self.__dict__[k]
if not k.startswith("_") and v:
yield "kv", (k, v)
def __pretty_str__(self, nesting = 1, indentation = " "):
attrs = []
ind = indentation * nesting
for k in self.__attrs__:
v = self.__dict__[k]
if not k.startswith("_") and v:
attrs.append(ind + k)
if not attrs:
return "%s()" % (self.__class__.__name__,)
attrs.insert(0, self.__class__.__name__+ ":")
return "\n".join(attrs)
class ListContainer(list):
"""
A container for lists
"""
__slots__ = ["__recursion_lock__"]
def __str__(self):
return self.__pretty_str__()
@recursion_lock("[...]")
def __pretty_str__(self, nesting = 1, indentation = " "):
if not self:
return "[]"
ind = indentation * nesting
lines = ["["]
for elem in self:
lines.append("\n")
lines.append(ind)
if hasattr(elem, "__pretty_str__"):
lines.append(elem.__pretty_str__(nesting + 1, indentation))
else:
lines.append(repr(elem))
lines.append("\n")
lines.append(indentation * (nesting - 1))
lines.append("]")
return "".join(lines)
class AttrDict(object):
"""
A dictionary that can be accessed both using indexing and attributes,
i.e.,
x = AttrDict()
x.foo = 5
print x["foo"]
"""
__slots__ = ["__dict__"]
def __init__(self, **kw):
self.__dict__ = kw
def __contains__(self, key):
return key in self.__dict__
def __nonzero__(self):
return bool(self.__dict__)
def __repr__(self):
return repr(self.__dict__)
def __str__(self):
return self.__pretty_str__()
def __pretty_str__(self, nesting = 1, indentation = " "):
if not self:
return "{}"
text = ["{\n"]
ind = nesting * indentation
for k in sorted(self.__dict__.keys()):
v = self.__dict__[k]
text.append(ind)
text.append(repr(k))
text.append(" : ")
if hasattr(v, "__pretty_str__"):
try:
text.append(v.__pretty_str__(nesting+1, indentation))
except Exception:
text.append(repr(v))
else:
text.append(repr(v))
text.append("\n")
text.append((nesting-1) * indentation)
text.append("}")
return "".join(text)
def __delitem__(self, key):
del self.__dict__[key]
def __getitem__(self, key):
return self.__dict__[key]
def __setitem__(self, key, value):
self.__dict__[key] = value
def __copy__(self):
new = self.__class__()
new.__dict__ = self.__dict__.copy()
return new
def __update__(self, other):
if isinstance(other, dict):
self.__dict__.update(other)
else:
self.__dict__.update(other.__dict__)
class LazyContainer(object):
__slots__ = ["subcon", "stream", "pos", "context", "_value"]
def __init__(self, subcon, stream, pos, context):
self.subcon = subcon
self.stream = stream
self.pos = pos
self.context = context
self._value = NotImplemented
def __eq__(self, other):
try:
return self._value == other._value
except AttributeError:
return False
def __ne__(self, other):
return not (self == other)
def __str__(self):
return self.__pretty_str__()
def __pretty_str__(self, nesting = 1, indentation = " "):
if self._value is NotImplemented:
text = "<unread>"
elif hasattr(self._value, "__pretty_str__"):
text = self._value.__pretty_str__(nesting, indentation)
else:
text = repr(self._value)
return "%s: %s" % (self.__class__.__name__, text)
def read(self):
self.stream.seek(self.pos)
return self.subcon._parse(self.stream, self.context)
def dispose(self):
self.subcon = None
self.stream = None
self.context = None
self.pos = None
def _get_value(self):
if self._value is NotImplemented:
self._value = self.read()
return self._value
value = property(_get_value)
has_value = property(lambda self: self._value is not NotImplemented)

View File

@ -0,0 +1,36 @@
_printable = dict((chr(i), ".") for i in range(256))
_printable.update((chr(i), chr(i)) for i in range(32, 128))
def hexdump(data, linesize = 16):
prettylines = []
if len(data) < 65536:
fmt = "%%04X %%-%ds %%s"
else:
fmt = "%%08X %%-%ds %%s"
fmt = fmt % (3 * linesize - 1,)
for i in xrange(0, len(data), linesize):
line = data[i : i + linesize]
hextext = " ".join(b.encode("hex") for b in line)
rawtext = "".join(_printable[b] for b in line)
prettylines.append(fmt % (i, hextext, rawtext))
return prettylines
class HexString(str):
"""
represents a string that will be hex-dumped (only via __pretty_str__).
this class derives of str, and behaves just like a normal string in all
other contexts.
"""
def __init__(self, data, linesize = 16):
str.__init__(self, data)
self.linesize = linesize
def __new__(cls, data, *args, **kwargs):
return str.__new__(cls, data)
def __pretty_str__(self, nesting = 1, indentation = " "):
if not self:
return "''"
sep = "\n" + indentation * nesting
return sep + sep.join(hexdump(self))

View File

@ -0,0 +1,151 @@
from container import Container
def drill(obj, root = "", levels = -1):
if levels == 0:
yield root, obj
return
levels -= 1
if isinstance(obj, Container):
for k, v in obj:
r = "%s.%s" % (root, k)
if levels:
for r2, v2 in drill(v, r, levels):
yield r2, v2
else:
yield r, v
elif isinstance(obj, list):
for i, item in enumerate(obj):
r = "%s[%d]" % (root, i)
if levels:
for r2, v2 in drill(item, r, levels):
yield r2, v2
else:
yield r, item
else:
yield root, obj
if __name__ == "__main__":
from construct import *
c = Struct("foo",
Byte("a"),
Struct("b",
Byte("c"),
UBInt16("d"),
),
Byte("e"),
Array(4,
Struct("f",
Byte("x"),
Byte("y"),
),
),
Byte("g"),
)
o = c.parse("acddexyxyxyxyg")
for lvl in range(4):
for path, value in drill(o, levels = lvl):
print path, value
print "---"
output = """
Container:
a = 97
b = Container:
c = 99
d = 25700
e = 101
f = [
Container:
x = 120
y = 121
Container:
x = 120
y = 121
Container:
x = 120
y = 121
Container:
x = 120
y = 121
]
g = 103
---
.a 97
.b Container:
c = 99
d = 25700
.e 101
.f [
Container:
x = 120
y = 121
Container:
x = 120
y = 121
Container:
x = 120
y = 121
Container:
x = 120
y = 121
]
.g 103
---
.a 97
.b.c 99
.b.d 25700
.e 101
.f[0] Container:
x = 120
y = 121
.f[1] Container:
x = 120
y = 121
.f[2] Container:
x = 120
y = 121
.f[3] Container:
x = 120
y = 121
.g 103
---
.a 97
.b.c 99
.b.d 25700
.e 101
.f[0].x 120
.f[0].y 121
.f[1].x 120
.f[1].y 121
.f[2].x 120
.f[2].y 121
.f[3].x 120
.f[3].y 121
.g 103
---
"""

View File

@ -0,0 +1,22 @@
try:
from cStringIO import StringIO
except ImportError:
from StringIO import StringIO
try:
from struct import Struct as Packer
except ImportError:
from struct import pack, unpack, calcsize
class Packer(object):
__slots__ = ["format", "size"]
def __init__(self, format):
self.format = format
self.size = calcsize(format)
def pack(self, *args):
return pack(self.format, *args)
def unpack(self, data):
return unpack(self.format, data)

View File

@ -0,0 +1,628 @@
from lib import BitStreamReader, BitStreamWriter, encode_bin, decode_bin
from core import *
from adapters import *
#===============================================================================
# fields
#===============================================================================
def Field(name, length):
"""a field
* name - the name of the field
* length - the length of the field. the length can be either an integer
(StaticField), or a function that takes the context as an argument and
returns the length (MetaField)
"""
if callable(length):
return MetaField(name, length)
else:
return StaticField(name, length)
def BitField(name, length, swapped = False, signed = False, bytesize = 8):
"""a bit field; must be enclosed in a BitStruct
* name - the name of the field
* length - the length of the field in bits. the length can be either
an integer, or a function that takes the context as an argument and
returns the length
* swapped - whether the value is byte-swapped (little endian). the
default is False.
* signed - whether the value of the bitfield is a signed integer. the
default is False.
* bytesize - the number of bits in a byte (used for byte-swapping). the
default is 8.
"""
return BitIntegerAdapter(Field(name, length),
length,
swapped = swapped,
signed = signed,
bytesize = bytesize
)
def Padding(length, pattern = "\x00", strict = False):
r"""a padding field (value is discarded)
* length - the length of the field. the length can be either an integer,
or a function that takes the context as an argument and returns the
length
* pattern - the padding pattern (character) to use. default is "\x00"
* strict - whether or not to raise an exception is the actual padding
pattern mismatches the desired pattern. default is False.
"""
return PaddingAdapter(Field(None, length),
pattern = pattern,
strict = strict,
)
def Flag(name, truth = 1, falsehood = 0, default = False):
"""a flag field (True or False)
* name - the name of the field
* truth - the numeric value of truth. the default is 1.
* falsehood - the numeric value of falsehood. the default is 0.
* default - the default value to assume, when the value is neither
`truth` nor `falsehood`. the default is False.
"""
return SymmetricMapping(Field(name, 1),
{True : chr(truth), False : chr(falsehood)},
default = default,
)
#===============================================================================
# field shortcuts
#===============================================================================
def Bit(name):
"""a 1-bit BitField; must be enclosed in a BitStruct"""
return BitField(name, 1)
def Nibble(name):
"""a 4-bit BitField; must be enclosed in a BitStruct"""
return BitField(name, 4)
def Octet(name):
"""an 8-bit BitField; must be enclosed in a BitStruct"""
return BitField(name, 8)
def UBInt8(name):
"""unsigned, big endian 8-bit integer"""
return FormatField(name, ">", "B")
def UBInt16(name):
"""unsigned, big endian 16-bit integer"""
return FormatField(name, ">", "H")
def UBInt32(name):
"""unsigned, big endian 32-bit integer"""
return FormatField(name, ">", "L")
def UBInt64(name):
"""unsigned, big endian 64-bit integer"""
return FormatField(name, ">", "Q")
def SBInt8(name):
"""signed, big endian 8-bit integer"""
return FormatField(name, ">", "b")
def SBInt16(name):
"""signed, big endian 16-bit integer"""
return FormatField(name, ">", "h")
def SBInt32(name):
"""signed, big endian 32-bit integer"""
return FormatField(name, ">", "l")
def SBInt64(name):
"""signed, big endian 64-bit integer"""
return FormatField(name, ">", "q")
def ULInt8(name):
"""unsigned, little endian 8-bit integer"""
return FormatField(name, "<", "B")
def ULInt16(name):
"""unsigned, little endian 16-bit integer"""
return FormatField(name, "<", "H")
def ULInt32(name):
"""unsigned, little endian 32-bit integer"""
return FormatField(name, "<", "L")
def ULInt64(name):
"""unsigned, little endian 64-bit integer"""
return FormatField(name, "<", "Q")
def SLInt8(name):
"""signed, little endian 8-bit integer"""
return FormatField(name, "<", "b")
def SLInt16(name):
"""signed, little endian 16-bit integer"""
return FormatField(name, "<", "h")
def SLInt32(name):
"""signed, little endian 32-bit integer"""
return FormatField(name, "<", "l")
def SLInt64(name):
"""signed, little endian 64-bit integer"""
return FormatField(name, "<", "q")
def UNInt8(name):
"""unsigned, native endianity 8-bit integer"""
return FormatField(name, "=", "B")
def UNInt16(name):
"""unsigned, native endianity 16-bit integer"""
return FormatField(name, "=", "H")
def UNInt32(name):
"""unsigned, native endianity 32-bit integer"""
return FormatField(name, "=", "L")
def UNInt64(name):
"""unsigned, native endianity 64-bit integer"""
return FormatField(name, "=", "Q")
def SNInt8(name):
"""signed, native endianity 8-bit integer"""
return FormatField(name, "=", "b")
def SNInt16(name):
"""signed, native endianity 16-bit integer"""
return FormatField(name, "=", "h")
def SNInt32(name):
"""signed, native endianity 32-bit integer"""
return FormatField(name, "=", "l")
def SNInt64(name):
"""signed, native endianity 64-bit integer"""
return FormatField(name, "=", "q")
def BFloat32(name):
"""big endian, 32-bit IEEE floating point number"""
return FormatField(name, ">", "f")
def LFloat32(name):
"""little endian, 32-bit IEEE floating point number"""
return FormatField(name, "<", "f")
def NFloat32(name):
"""native endianity, 32-bit IEEE floating point number"""
return FormatField(name, "=", "f")
def BFloat64(name):
"""big endian, 64-bit IEEE floating point number"""
return FormatField(name, ">", "d")
def LFloat64(name):
"""little endian, 64-bit IEEE floating point number"""
return FormatField(name, "<", "d")
def NFloat64(name):
"""native endianity, 64-bit IEEE floating point number"""
return FormatField(name, "=", "d")
#===============================================================================
# arrays
#===============================================================================
def Array(count, subcon):
"""
Repeats the given unit a fixed number of times.
:param int count: number of times to repeat
:param ``Construct`` subcon: construct to repeat
>>> c = StrictRepeater(4, UBInt8("foo"))
>>> c
<Repeater('foo')>
>>> c.parse("\\x01\\x02\\x03\\x04")
[1, 2, 3, 4]
>>> c.parse("\\x01\\x02\\x03\\x04\\x05\\x06")
[1, 2, 3, 4]
>>> c.build([5,6,7,8])
'\\x05\\x06\\x07\\x08'
>>> c.build([5,6,7,8,9])
Traceback (most recent call last):
...
construct.core.RepeaterError: expected 4..4, found 5
"""
if callable(count):
con = MetaArray(count, subcon)
else:
con = MetaArray(lambda ctx: count, subcon)
con._clear_flag(con.FLAG_DYNAMIC)
return con
def PrefixedArray(subcon, length_field = UBInt8("length")):
"""an array prefixed by a length field.
* subcon - the subcon to be repeated
* length_field - a construct returning an integer
"""
return LengthValueAdapter(
Sequence(subcon.name,
length_field,
Array(lambda ctx: ctx[length_field.name], subcon),
nested = False
)
)
def OpenRange(mincount, subcon):
from sys import maxint
return Range(mincount, maxint, subcon)
def GreedyRange(subcon):
"""
Repeats the given unit one or more times.
:param ``Construct`` subcon: construct to repeat
>>> from construct import GreedyRepeater, UBInt8
>>> c = GreedyRepeater(UBInt8("foo"))
>>> c.parse("\\x01")
[1]
>>> c.parse("\\x01\\x02\\x03")
[1, 2, 3]
>>> c.parse("\\x01\\x02\\x03\\x04\\x05\\x06")
[1, 2, 3, 4, 5, 6]
>>> c.parse("")
Traceback (most recent call last):
...
construct.core.RepeaterError: expected 1..2147483647, found 0
>>> c.build([1,2])
'\\x01\\x02'
>>> c.build([])
Traceback (most recent call last):
...
construct.core.RepeaterError: expected 1..2147483647, found 0
"""
return OpenRange(1, subcon)
def OptionalGreedyRange(subcon):
"""
Repeats the given unit zero or more times. This repeater can't
fail, as it accepts lists of any length.
:param ``Construct`` subcon: construct to repeat
>>> from construct import OptionalGreedyRepeater, UBInt8
>>> c = OptionalGreedyRepeater(UBInt8("foo"))
>>> c.parse("")
[]
>>> c.parse("\\x01\\x02")
[1, 2]
>>> c.build([])
''
>>> c.build([1,2])
'\\x01\\x02'
"""
return OpenRange(0, subcon)
#===============================================================================
# subconstructs
#===============================================================================
def Optional(subcon):
"""an optional construct. if parsing fails, returns None.
* subcon - the subcon to optionally parse or build
"""
return Select(subcon.name, subcon, Pass)
def Bitwise(subcon):
"""converts the stream to bits, and passes the bitstream to subcon
* subcon - a bitwise construct (usually BitField)
"""
# subcons larger than MAX_BUFFER will be wrapped by Restream instead
# of Buffered. implementation details, don't stick your nose in :)
MAX_BUFFER = 1024 * 8
def resizer(length):
if length & 7:
raise SizeofError("size must be a multiple of 8", length)
return length >> 3
if not subcon._is_flag(subcon.FLAG_DYNAMIC) and subcon.sizeof() < MAX_BUFFER:
con = Buffered(subcon,
encoder = decode_bin,
decoder = encode_bin,
resizer = resizer
)
else:
con = Restream(subcon,
stream_reader = BitStreamReader,
stream_writer = BitStreamWriter,
resizer = resizer)
return con
def Aligned(subcon, modulus = 4, pattern = "\x00"):
r"""aligns subcon to modulus boundary using padding pattern
* subcon - the subcon to align
* modulus - the modulus boundary (default is 4)
* pattern - the padding pattern (default is \x00)
"""
if modulus < 2:
raise ValueError("modulus must be >= 2", modulus)
if modulus in (2, 4, 8, 16, 32, 64, 128, 256, 512, 1024):
def padlength(ctx):
m1 = modulus - 1
return (modulus - (subcon._sizeof(ctx) & m1)) & m1
else:
def padlength(ctx):
return (modulus - (subcon._sizeof(ctx) % modulus)) % modulus
return SeqOfOne(subcon.name,
subcon,
# ??????
# ??????
# ??????
# ??????
Padding(padlength, pattern = pattern),
nested = False,
)
def SeqOfOne(name, *args, **kw):
"""a sequence of one element. only the first element is meaningful, the
rest are discarded
* name - the name of the sequence
* args - subconstructs
* kw - any keyword arguments to Sequence
"""
return IndexingAdapter(Sequence(name, *args, **kw), index = 0)
def Embedded(subcon):
"""embeds a struct into the enclosing struct.
* subcon - the struct to embed
"""
return Reconfig(subcon.name, subcon, subcon.FLAG_EMBED)
def Rename(newname, subcon):
"""renames an existing construct
* newname - the new name
* subcon - the subcon to rename
"""
return Reconfig(newname, subcon)
def Alias(newname, oldname):
"""creates an alias for an existing element in a struct
* newname - the new name
* oldname - the name of an existing element
"""
return Value(newname, lambda ctx: ctx[oldname])
#===============================================================================
# mapping
#===============================================================================
def SymmetricMapping(subcon, mapping, default = NotImplemented):
"""defines a symmetrical mapping: a->b, b->a.
* subcon - the subcon to map
* mapping - the encoding mapping (a dict); the decoding mapping is
achieved by reversing this mapping
* default - the default value to use when no mapping is found. if no
default value is given, and exception is raised. setting to Pass would
return the value "as is" (unmapped)
"""
reversed_mapping = dict((v, k) for k, v in mapping.iteritems())
return MappingAdapter(subcon,
encoding = mapping,
decoding = reversed_mapping,
encdefault = default,
decdefault = default,
)
def Enum(subcon, **kw):
"""a set of named values mapping.
* subcon - the subcon to map
* kw - keyword arguments which serve as the encoding mapping
* _default_ - an optional, keyword-only argument that specifies the
default value to use when the mapping is undefined. if not given,
and exception is raised when the mapping is undefined. use `Pass` to
pass the unmapped value as-is
"""
return SymmetricMapping(subcon, kw, kw.pop("_default_", NotImplemented))
def FlagsEnum(subcon, **kw):
"""a set of flag values mapping.
* subcon - the subcon to map
* kw - keyword arguments which serve as the encoding mapping
"""
return FlagsAdapter(subcon, kw)
#===============================================================================
# structs
#===============================================================================
def AlignedStruct(name, *subcons, **kw):
"""a struct of aligned fields
* name - the name of the struct
* subcons - the subcons that make up this structure
* kw - keyword arguments to pass to Aligned: 'modulus' and 'pattern'
"""
return Struct(name, *(Aligned(sc, **kw) for sc in subcons))
def BitStruct(name, *subcons):
"""a struct of bitwise fields
* name - the name of the struct
* subcons - the subcons that make up this structure
"""
return Bitwise(Struct(name, *subcons))
def EmbeddedBitStruct(*subcons):
"""an embedded BitStruct. no name is necessary.
* subcons - the subcons that make up this structure
"""
return Bitwise(Embedded(Struct(None, *subcons)))
#===============================================================================
# strings
#===============================================================================
def String(name, length, encoding=None, padchar=None, paddir="right",
trimdir="right"):
"""
A configurable, fixed-length string field.
The padding character must be specified for padding and trimming to work.
:param str name: name
:param int length: length, in bytes
:param str encoding: encoding (e.g. "utf8") or None for no encoding
:param str padchar: optional character to pad out strings
:param str paddir: direction to pad out strings; one of "right", "left",
or "both"
:param str trim: direction to trim strings; one of "right", "left"
>>> from construct import String
>>> String("foo", 5).parse("hello")
'hello'
>>>
>>> String("foo", 12, encoding = "utf8").parse("hello joh\\xd4\\x83n")
u'hello joh\\u0503n'
>>>
>>> foo = String("foo", 10, padchar = "X", paddir = "right")
>>> foo.parse("helloXXXXX")
'hello'
>>> foo.build("hello")
'helloXXXXX'
"""
con = StringAdapter(Field(name, length), encoding=encoding)
if padchar is not None:
con = PaddedStringAdapter(con, padchar=padchar, paddir=paddir,
trimdir=trimdir)
return con
def PascalString(name, length_field=UBInt8("length"), encoding=None):
"""
A length-prefixed string.
``PascalString`` is named after the string types of Pascal, which are
length-prefixed. Lisp strings also follow this convention.
The length field will appear in the same ``Container`` as the
``PascalString``, with the given name.
:param str name: name
:param ``Construct`` length_field: a field which will store the length of
the string
:param str encoding: encoding (e.g. "utf8") or None for no encoding
>>> foo = PascalString("foo")
>>> foo.parse("\\x05hello")
'hello'
>>> foo.build("hello world")
'\\x0bhello world'
>>>
>>> foo = PascalString("foo", length_field = UBInt16("length"))
>>> foo.parse("\\x00\\x05hello")
'hello'
>>> foo.build("hello")
'\\x00\\x05hello'
"""
return StringAdapter(
LengthValueAdapter(
Sequence(name,
length_field,
Field("data", lambda ctx: ctx[length_field.name]),
)
),
encoding=encoding,
)
def CString(name, terminators="\x00", encoding=None,
char_field=Field(None, 1)):
"""
A string ending in a terminator.
``CString`` is similar to the strings of C, C++, and other related
programming languages.
By default, the terminator is the NULL byte (0x00).
:param str name: name
:param iterable terminators: sequence of valid terminators, in order of
preference
:param str encoding: encoding (e.g. "utf8") or None for no encoding
:param ``Construct`` char_field: construct representing a single character
>>> foo = CString("foo")
>>>
>>> foo.parse("hello\\x00")
'hello'
>>> foo.build("hello")
'hello\\x00'
>>>
>>> foo = CString("foo", terminators = "XYZ")
>>>
>>> foo.parse("helloX")
'hello'
>>> foo.parse("helloY")
'hello'
>>> foo.parse("helloZ")
'hello'
>>> foo.build("hello")
'helloX'
"""
return Rename(name,
CStringAdapter(
RepeatUntil(lambda obj, ctx: obj in terminators,
char_field,
),
terminators=terminators,
encoding=encoding,
)
)
#===============================================================================
# conditional
#===============================================================================
def IfThenElse(name, predicate, then_subcon, else_subcon):
"""an if-then-else conditional construct: if the predicate indicates True,
`then_subcon` will be used; otherwise `else_subcon`
* name - the name of the construct
* predicate - a function taking the context as an argument and returning
True or False
* then_subcon - the subcon that will be used if the predicate returns True
* else_subcon - the subcon that will be used if the predicate returns False
"""
return Switch(name, lambda ctx: bool(predicate(ctx)),
{
True : then_subcon,
False : else_subcon,
}
)
def If(predicate, subcon, elsevalue = None):
"""an if-then conditional construct: if the predicate indicates True,
subcon will be used; otherwise, `elsevalue` will be returned instead.
* predicate - a function taking the context as an argument and returning
True or False
* subcon - the subcon that will be used if the predicate returns True
* elsevalue - the value that will be used should the predicate return False.
by default this value is None.
"""
return IfThenElse(subcon.name,
predicate,
subcon,
Value("elsevalue", lambda ctx: elsevalue)
)
#===============================================================================
# misc
#===============================================================================
def OnDemandPointer(offsetfunc, subcon, force_build = True):
"""an on-demand pointer.
* offsetfunc - a function taking the context as an argument and returning
the absolute stream position
* subcon - the subcon that will be parsed from the `offsetfunc()` stream
position on demand
* force_build - see OnDemand. by default True.
"""
return OnDemand(Pointer(offsetfunc, subcon),
advance_stream = False,
force_build = force_build
)
def Magic(data):
return ConstAdapter(Field(None, len(data)), data)

View File

@ -0,0 +1,418 @@
#!/usr/bin/python
#Audio Tools, a module and set of tools for manipulating audio data
#Copyright (C) 2008-2011 Brian Langenberger
#This program is free software; you can redistribute it and/or modify
#it under the terms of the GNU General Public License as published by
#the Free Software Foundation; either version 2 of the License, or
#(at your option) any later version.
#This program is distributed in the hope that it will be useful,
#but WITHOUT ANY WARRANTY; without even the implied warranty of
#MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
#GNU General Public License for more details.
#You should have received a copy of the GNU General Public License
#along with this program; if not, write to the Free Software
#Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
"""The cuesheet handling module."""
import re
from audiotools import SheetException, parse_timestamp, build_timestamp
import gettext
gettext.install("audiotools", unicode=True)
###################
#Cue Sheet Parsing
###################
#This method of cuesheet reading involves a tokenizer and parser,
#analagous to lexx/yacc.
#It might be easier to use a line-by-line ad-hoc method for parsing,
#but this brute-force approach should be a bit more thorough.
SPACE = 0x0
TAG = 0x1
NUMBER = 0x2
EOL = 0x4
STRING = 0x8
ISRC = 0x10
TIMESTAMP = 0x20
class CueException(SheetException):
"""Raised by cuesheet parsing errors."""
pass
def tokens(cuedata):
"""Yields (text, token, line) tuples from cuedata stream.
text is a plain string.
token is an integer such as TAG or NUMBER.
line is a line number integer."""
full_length = len(cuedata)
cuedata = cuedata.lstrip('efbbbf'.decode('hex'))
line_number = 1
#This isn't completely accurate since the whitespace requirements
#between tokens aren't enforced.
TOKENS = [(re.compile("^(%s)" % (s)), element) for (s, element) in
[(r'[A-Z]{2}[A-Za-z0-9]{3}[0-9]{7}', ISRC),
(r'[0-9]{1,2}:[0-9]{1,2}:[0-9]{1,2}', TIMESTAMP),
(r'[0-9]+', NUMBER),
(r'[\r\n]+', EOL),
(r'".+?"', STRING),
(r'\S+', STRING),
(r'[ ]+', SPACE)]]
TAGMATCH = re.compile(r'^[A-Z]+$')
while (True):
for (token, element) in TOKENS:
t = token.search(cuedata)
if (t is not None):
cuedata = cuedata[len(t.group()):]
if (element == SPACE):
break
elif (element == NUMBER):
yield (int(t.group()), element, line_number)
elif (element == EOL):
line_number += 1
yield (t.group(), element, line_number)
elif (element == STRING):
if (TAGMATCH.match(t.group())):
yield (t.group(), TAG, line_number)
else:
yield (t.group().strip('"'), element, line_number)
elif (element == TIMESTAMP):
(m, s, f) = map(int, t.group().split(":"))
yield (((m * 60 * 75) + (s * 75) + f),
element, line_number)
else:
yield (t.group(), element, line_number)
break
else:
break
if (len(cuedata) > 0):
raise CueException(_(u"Invalid token at char %d") % \
(full_length - len(cuedata)))
def get_value(tokens, accept, error):
"""Retrieves a specific token from the stream of tokens.
tokens - the token iterator
accept - an "or"ed list of all the tokens we'll accept
error - the string to prepend to the error message
Returns the gotten value which matches one of the accepted tokens
or raises ValueError if the token matches none of them.
"""
(token, element, line_number) = tokens.next()
if ((element & accept) != 0):
return token
else:
raise CueException(_(u"%(error)s at line %(line)d") % \
{"error": error,
"line": line_number})
def parse(tokens):
"""Returns a Cuesheet object from the token iterator stream.
Raises CueException if a parsing error occurs.
"""
def skip_to_eol(tokens):
(token, element, line_number) = tokens.next()
while (element != EOL):
(token, element, line_number) = tokens.next()
cuesheet = Cuesheet()
track = None
try:
while (True):
(token, element, line_number) = tokens.next()
if (element == TAG):
#ignore comment lines
if (token == "REM"):
skip_to_eol(tokens)
#we're moving to a new track
elif (token == 'TRACK'):
if (track is not None):
cuesheet.tracks[track.number] = track
track = Track(get_value(tokens, NUMBER,
_(u"Invalid track number")),
get_value(tokens, TAG | STRING,
_(u"Invalid track type")))
get_value(tokens, EOL, "Excess data")
#if we haven't started on track data yet,
#add attributes to the main cue sheet
elif (track is None):
if (token in ('CATALOG', 'CDTEXTFILE',
'PERFORMER', 'SONGWRITER',
'TITLE')):
cuesheet.attribs[token] = get_value(
tokens,
STRING | TAG | NUMBER | ISRC,
_(u"Missing value"))
get_value(tokens, EOL, _(u"Excess data"))
elif (token == 'FILE'):
filename = get_value(tokens, STRING,
_(u"Missing filename"))
filetype = get_value(tokens, STRING | TAG,
_(u"Missing file type"))
cuesheet.attribs[token] = (filename, filetype)
get_value(tokens, EOL, _(u"Excess data"))
else:
raise CueException(
_(u"Invalid tag %(tag)s at line %(line)d") % \
{"tag": token,
"line": line_number})
#otherwise, we're adding data to the current track
else:
if (token in ('ISRC', 'PERFORMER',
'SONGWRITER', 'TITLE')):
track.attribs[token] = get_value(
tokens,
STRING | TAG | NUMBER | ISRC,
"Missing value")
get_value(tokens, EOL, _(u"Invalid data"))
elif (token == 'FLAGS'):
flags = []
s = get_value(tokens, STRING | TAG | EOL,
_(u"Invalid flag"))
while (('\n' not in s) and ('\r' not in s)):
flags.append(s)
s = get_value(tokens, STRING | TAG | EOL,
_(u"Invalid flag"))
track.attribs[token] = ",".join(flags)
elif (token in ('POSTGAP', 'PREGAP')):
track.attribs[token] = get_value(
tokens, TIMESTAMP,
_(u"Invalid timestamp"))
get_value(tokens, EOL, _(u"Excess data"))
elif (token == 'INDEX'):
index_number = get_value(tokens, NUMBER,
_(u"Invalid index number"))
index_timestamp = get_value(tokens, TIMESTAMP,
_(u"Invalid timestamp"))
track.indexes[index_number] = index_timestamp
get_value(tokens, EOL, _(u"Excess data"))
elif (token in ('FILE',)):
skip_to_eol(tokens)
else:
raise CueException(
_(u"Invalid tag %(tag)s at line %(line)d") % \
{"tag": token,
"line": line_number})
else:
raise CueException(_(u"Missing tag at line %d") % (
line_number))
except StopIteration:
if (track is not None):
cuesheet.tracks[track.number] = track
return cuesheet
def __attrib_str__(attrib):
if (isinstance(attrib, tuple)):
return " ".join([__attrib_str__(a) for a in attrib])
elif (re.match(r'^[A-Z]+$', attrib) is not None):
return attrib
else:
return "\"%s\"" % (attrib)
class Cuesheet:
"""An object representing a cuesheet file."""
def __init__(self):
self.attribs = {}
self.tracks = {}
def __repr__(self):
return "Cuesheet(attribs=%s,tracks=%s)" % \
(repr(self.attribs), repr(self.tracks))
def __str__(self):
return "\r\n".join(["%s %s" % (key, __attrib_str__(value))
for key, value in self.attribs.items()] + \
[str(track) for track in
sorted(self.tracks.values())])
def catalog(self):
"""Returns the cuesheet's CATALOG number as a plain string, or None.
If present, this value is typically a CD's UPC code."""
if ('CATALOG' in self.attribs):
return str(self.attribs['CATALOG'])
else:
return None
def single_file_type(self):
"""Returns True if this cuesheet is formatted for a single file."""
previous = -1
for t in self.indexes():
for index in t:
if (index <= previous):
return False
else:
previous = index
else:
return True
def indexes(self):
"""Yields a set of index lists, one for each track in the file."""
for key in sorted(self.tracks.keys()):
yield tuple(
[self.tracks[key].indexes[k]
for k in sorted(self.tracks[key].indexes.keys())])
def pcm_lengths(self, total_length):
"""Yields a list of PCM lengths for all audio tracks within the file.
total_length is the length of the entire file in PCM frames."""
previous = None
for key in sorted(self.tracks.keys()):
current = self.tracks[key].indexes
if (previous is None):
previous = current
else:
track_length = (current[max(current.keys())] -
previous[max(previous.keys())]) * (44100 / 75)
total_length -= track_length
yield track_length
previous = current
yield total_length
def ISRCs(self):
"""Returns a track_number->ISRC dict of all non-empty tracks."""
return dict([(track.number, track.ISRC()) for track in
self.tracks.values() if track.ISRC() is not None])
@classmethod
def file(cls, sheet, filename):
"""Constructs a new cuesheet string from a compatible object.
sheet must have catalog(), indexes() and ISRCs() methods.
filename is a string to the filename the cuesheet is created for.
Although we don't care whether the filename points to a real file,
other tools sometimes do.
"""
import cStringIO
catalog = sheet.catalog() # a catalog string, or None
indexes = list(sheet.indexes()) # a list of index tuples
ISRCs = sheet.ISRCs() # a track_number->ISRC dict
data = cStringIO.StringIO()
if (catalog is not None):
data.write("CATALOG %s\r\n" % (catalog))
data.write("FILE \"%s\" WAVE\r\n" % (filename))
for (i, current) in enumerate(indexes):
tracknum = i + 1
data.write(" TRACK %2.2d AUDIO\r\n" % (tracknum))
if (tracknum in ISRCs.keys()):
data.write(" ISRC %s\r\n" % (ISRCs[tracknum]))
for (j, index) in enumerate(current):
data.write(" INDEX %2.2d %s\r\n" % (j,
build_timestamp(index)))
return data.getvalue()
class Track:
"""A track inside a Cuesheet object."""
def __init__(self, number, type):
"""number is the track's number on disc, type is a string."""
self.number = number
self.type = type
self.attribs = {}
self.indexes = {}
def __cmp__(self, t):
return cmp(self.number, t.number)
def __repr__(self):
return "Track(%s,%s,attribs=%s,indexes=%s)" % \
(repr(self.number), repr(self.type),
repr(self.attribs), repr(self.indexes))
def __str__(self):
return (" TRACK %2.2d %s\r\n" % (self.number, self.type)) + \
"\r\n".join([" %s %s" % (key, __attrib_str__(value))
for key, value in self.attribs.items()] + \
[" INDEX %2.2d %2.2d:%2.2d:%2.2d" % \
(k, v / 75 / 60, v / 75 % 60, v % 75)
for (k, v) in sorted(self.indexes.items())])
def ISRC(self):
"""Returns the track's ISRC value, or None."""
if ('ISRC' in self.attribs.keys()):
return str(self.attribs['ISRC'])
else:
return None
def read_cuesheet(filename):
"""Returns a Cuesheet from a cuesheet filename on disk.
Raises CueException if some error occurs reading or parsing the file.
"""
try:
f = open(filename, 'r')
except IOError, msg:
raise CueException(unicode(_(u"Unable to read cuesheet")))
try:
sheet = parse(tokens(f.read()))
if (not sheet.single_file_type()):
raise CueException(_(u"Cuesheet not formatted for disc images"))
else:
return sheet
finally:
f.close()

Binary file not shown.

View File

@ -0,0 +1,277 @@
#!/usr/bin/python
#Audio Tools, a module and set of tools for manipulating audio data
#Copyright (C) 2008-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
import sys
from itertools import izip
import bz2
import sqlite3
from hashlib import sha1
import base64
import anydbm
import subprocess
import tempfile
import whichdb
from audiotools import BIN, transfer_data
import cStringIO
class UndoDB:
"""A class for performing undo operations on files.
This stores an undo/redo patch for transforming a file
back to its original value, or forward again to its modified form."""
def __init__(self, filename):
"""filename is the location on disk for this undo database."""
self.db = sqlite3.connect(filename)
self.cursor = self.db.cursor()
self.cursor.execute("""CREATE TABLE IF NOT EXISTS patch (
patch_id INTEGER PRIMARY KEY AUTOINCREMENT,
patch_data BLOB NOT NULL
)""")
self.cursor.execute("""CREATE TABLE IF NOT EXISTS source_file (
source_checksum CHAR(40) PRIMARY KEY,
source_size INTEGER NOT NULL,
target_size INTEGER NOT NULL,
patch_id INTEGER,
FOREIGN KEY (patch_id) REFERENCES patch (patch_id) ON DELETE CASCADE
)""")
def close(self):
"""Closes any open database handles."""
self.cursor.close()
self.db.close()
@classmethod
def build_patch(cls, s1, s2):
"""Given two strings, returns a transformation patch.
This function presumes the two strings will be largely
equal and similar in length. It operates by performing an
XOR operation across both and BZ2 compressing the result."""
if (len(s1) < len(s2)):
s1 += (chr(0) * (len(s2) - len(s1)))
elif (len(s2) < len(s1)):
s2 += (chr(0) * (len(s1) - len(s2)))
patch = bz2.compress("".join([chr(ord(x) ^ ord(y)) for (x, y) in
izip(s1, s2)]))
return patch
@classmethod
def apply_patch(cls, s, patch, new_length):
"""Given a string, patch and new length, restores string.
patch is the same BZ2 compressed output from build_patch().
new_length is the size of the string originally,
which must be stored externally from the patch itself."""
if (len(s) > new_length):
s = s[0:new_length]
elif (len(s) < new_length):
s += (chr(0) * (new_length - len(s)))
return "".join([chr(ord(x) ^ ord(y)) for (x, y) in
izip(s, bz2.decompress(patch))])
def __add__(self, file_data1, file_data2):
#file_data1's target is file_data2 and
#file_data2's target is file_data1
self.cursor.execute(
"INSERT INTO patch (patch_id, patch_data) VALUES (?, ?)",
[None,
base64.b64encode(
UndoDB.build_patch(file_data1,
file_data2)).decode('ascii')])
patch_id = self.cursor.lastrowid
try:
self.cursor.execute("""INSERT INTO source_file (
source_checksum, source_size, target_size, patch_id) values (?, ?, ?, ?)""",
[sha1(file_data1).hexdigest().decode('ascii'),
len(file_data1),
len(file_data2),
patch_id])
self.cursor.execute("""INSERT INTO source_file (
source_checksum, source_size, target_size, patch_id) values (?, ?, ?, ?)""",
[sha1(file_data2).hexdigest().decode('ascii'),
len(file_data2),
len(file_data1),
patch_id])
self.db.commit()
except sqlite3.IntegrityError:
self.db.rollback()
def __undo__(self, file_data):
self.cursor.execute("""SELECT target_size, patch_data FROM
source_file, patch WHERE ((source_checksum = ?) AND
(source_size = ?) AND
(source_file.patch_id = patch.patch_id))""",
[sha1(file_data).hexdigest().decode('ascii'),
len(file_data)])
row = self.cursor.fetchone()
if (row is not None):
(target_size, patch) = row
return UndoDB.apply_patch(
file_data,
base64.b64decode(patch.encode('ascii')),
target_size)
else:
return None
def add(self, old_file, new_file):
"""Adds an undo entry for transforming new_file to old_file.
Both are filename strings."""
old_f = open(old_file, 'rb')
new_f = open(new_file, 'rb')
try:
self.__add__(old_f.read(), new_f.read())
finally:
old_f.close()
new_f.close()
def undo(self, new_file):
"""Updates new_file to its original state,
if present in the undo database.
Returns True if undo performed, False if not."""
new_f = open(new_file, 'rb')
try:
old_data = self.__undo__(new_f.read())
finally:
new_f.close()
if (old_data is not None):
old_f = open(new_file, 'wb')
old_f.write(old_data)
old_f.close()
return True
else:
return False
class OldUndoDB:
"""A class for performing legacy undo operations on files.
This implementation is based on xdelta and requires it to be
installed to function.
"""
def __init__(self, filename):
"""filename is the location on disk for this undo database."""
self.db = anydbm.open(filename, 'c')
def close(self):
"""Closes any open database handles."""
self.db.close()
@classmethod
def checksum(cls, filename):
"""Returns the SHA1 checksum of the filename's contents."""
f = open(filename, "rb")
c = sha1("")
try:
transfer_data(f.read, c.update)
return c.hexdigest()
finally:
f.close()
def add(self, old_file, new_file):
"""Adds an undo entry for transforming new_file to old_file.
Both are filename strings."""
#perform xdelta between old and new track to temporary file
delta_f = tempfile.NamedTemporaryFile(suffix=".delta")
try:
if (subprocess.call([BIN["xdelta"],
"delta",
new_file, old_file, delta_f.name]) != 2):
#store the xdelta in our internal db
f = open(delta_f.name, 'rb')
data = cStringIO.StringIO()
transfer_data(f.read, data.write)
f.close()
self.db[OldUndoDB.checksum(new_file)] = data.getvalue()
else:
raise IOError("error performing xdelta operation")
finally:
delta_f.close()
def undo(self, new_file):
"""Updates new_file to its original state,
if present in the undo database."""
undo_checksum = OldUndoDB.checksum(new_file)
if (undo_checksum in self.db.keys()):
#copy the xdelta to a temporary file
xdelta_f = tempfile.NamedTemporaryFile(suffix=".delta")
xdelta_f.write(self.db[undo_checksum])
xdelta_f.flush()
#patch the existing track to a temporary track
old_track = tempfile.NamedTemporaryFile()
try:
if (subprocess.call([BIN["xdelta"],
"patch",
xdelta_f.name,
new_file,
old_track.name]) == 0):
#copy the temporary track over the existing file
f1 = open(old_track.name, 'rb')
f2 = open(new_file, 'wb')
transfer_data(f1.read, f2.write)
f1.close()
f2.close()
return True
else:
raise IOError("error performing xdelta operation")
finally:
old_track.close()
xdelta_f.close()
else:
return False
def open_db(filename):
"""Given a filename string, returns UndoDB or OldUndoDB.
If the file doesn't exist, this uses UndoDB by default.
Otherwise, detect OldUndoDB if xdelta is installed."""
if (BIN.can_execute(BIN["xdelta"])):
db = whichdb.whichdb(filename)
if ((db is not None) and (db != '')):
return OldUndoDB(filename)
else:
return UndoDB(filename)
else:
return UndoDB(filename)

Binary file not shown.

View File

@ -0,0 +1,715 @@
#!/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
import array
import audiotools
import sys,cStringIO
Con = audiotools.Con
class UTF8(Con.Struct):
@classmethod
def __total_utf8_bytes__(cls, header):
total = 0
for b in header:
if b == '\x01':
total += 1
else:
break
return max(1,total)
@classmethod
def __calculate_utf8_value__(cls, ctx):
import operator
return Con.lib.bin_to_int(ctx.header[ctx.header.index('\x00') + 1:] + \
reduce(operator.concat,
[s[2:] for s in ctx['sub_byte']],
''))
def __init__(self, name):
Con.Struct.__init__(
self,name,
Con.Bytes('header',8),
Con.Value('total_bytes',
lambda ctx: self.__total_utf8_bytes__(ctx['header'])),
Con.MetaRepeater(
lambda ctx: self.__total_utf8_bytes__(ctx['header']) - 1,
Con.Bytes('sub_byte',8)),
Con.Value('value',
lambda ctx: self.__calculate_utf8_value__(ctx)))
class Unary(Con.Adapter):
def __init__(self, name):
Con.Adapter.__init__(
self,
Con.RepeatUntil(lambda obj,ctx: obj == 1,
Con.Byte(name)))
def _encode(self, value, context):
if (value > 0):
return ([0] * (value)) + [1]
else:
return [1]
def _decode(self, obj, context):
return len(obj) - 1
class PlusOne(Con.Adapter):
def _encode(self, value, context):
return value - 1
def _decode(self, obj, context):
return obj + 1
class FlacStreamException(Exception): pass
class FlacReader:
FRAME_HEADER = Con.Struct('frame_header',
Con.Bits('sync',14),
Con.Bits('reserved',2),
Con.Bits('block_size',4),
Con.Bits('sample_rate',4),
Con.Bits('channel_assignment',4),
Con.Bits('bits_per_sample',3),
Con.Padding(1),
Con.IfThenElse(
'total_channels',
lambda ctx1: ctx1['channel_assignment'] <= 7,
Con.Value('c',lambda ctx2: ctx2['channel_assignment'] + 1),
Con.Value('c',lambda ctx3: 2)),
UTF8('frame_number'),
Con.IfThenElse(
'extended_block_size',
lambda ctx1: ctx1['block_size'] == 6,
Con.Bits('b',8),
Con.If(lambda ctx2: ctx2['block_size'] == 7,
Con.Bits('b',16))),
Con.IfThenElse(
'extended_sample_rate',
lambda ctx1: ctx1['sample_rate'] == 12,
Con.Bits('s',8),
Con.If(lambda ctx2: ctx2['sample_rate'] in (13,14),
Con.Bits('s',16))),
Con.Bits('crc8',8))
UNARY = Con.Struct('unary',
Con.RepeatUntil(
lambda obj,ctx: obj == '\x01',
Con.Field('bytes',1)),
Con.Value('value',
lambda ctx: len(ctx['bytes']) - 1)
)
SUBFRAME_HEADER = Con.Struct('subframe_header',
Con.Padding(1),
Con.Bits('subframe_type',6),
Con.Flag('has_wasted_bits_per_sample'),
Con.IfThenElse(
'wasted_bits_per_sample',
lambda ctx: ctx['has_wasted_bits_per_sample'],
PlusOne(Unary('value')),
Con.Value('value',lambda ctx2: 0)))
GET_BLOCKSIZE_FROM_STREAMINFO = -1
GET_8BIT_BLOCKSIZE_FROM_END_OF_HEADER = -2
GET_16BIT_BLOCKSIZE_FROM_END_OF_HEADER = -3
BLOCK_SIZE = (GET_BLOCKSIZE_FROM_STREAMINFO,
192,
576,1152,2304,4608,
GET_8BIT_BLOCKSIZE_FROM_END_OF_HEADER,
GET_16BIT_BLOCKSIZE_FROM_END_OF_HEADER,
256,512,1024,2048,4096,8192,16384,32768)
GET_SAMPLE_SIZE_FROM_STREAMINFO = -1
SAMPLE_SIZE = (GET_SAMPLE_SIZE_FROM_STREAMINFO,
8,12,None,16,20,24,None)
def FIXED0(subframe,residual,i):
subframe.insert(i,
residual[i])
def FIXED1(subframe,residual,i):
subframe.insert(i,
subframe[i - 1] + residual[i])
def FIXED2(subframe,residual,i):
subframe.insert(i,
((2 * subframe[i - 1]) - subframe[i - 2] + \
residual[i]))
def FIXED3(subframe,residual,i):
subframe.insert(i,
((3 * subframe[i - 1]) - (3 * subframe[i - 2]) + \
subframe[i - 3] + residual[i]))
def FIXED4(subframe,residual,i):
subframe.insert(i,
((4 * subframe[i - 1]) - (6 * subframe[i - 2]) + \
(4 * subframe[i - 3]) - subframe[i - 4] + residual[i]))
#iterates over all of the channels, in order
def MERGE_INDEPENDENT(channel_list):
channel_data = [iter(c) for c in channel_list]
while (True):
for channel in channel_data:
yield channel.next()
def MERGE_LEFT(channel_list):
channel_left = iter(channel_list[0])
channel_side = iter(channel_list[1])
while (True):
left = channel_left.next()
side = channel_side.next()
yield left
yield left - side
def MERGE_RIGHT(channel_list):
channel_side = iter(channel_list[0])
channel_right = iter(channel_list[1])
while (True):
side = channel_side.next()
right = channel_right.next()
yield side + right
yield right
def MERGE_MID(channel_list):
channel_mid = iter(channel_list[0])
channel_side = iter(channel_list[1])
while (True):
mid = channel_mid.next()
side = channel_side.next()
mid = mid << 1
mid |= (side & 0x1)
yield (mid + side) >> 1
yield (mid - side) >> 1
CHANNEL_FUNCTIONS = (MERGE_INDEPENDENT,
MERGE_INDEPENDENT,
MERGE_INDEPENDENT,
MERGE_INDEPENDENT,
MERGE_INDEPENDENT,
MERGE_INDEPENDENT,
MERGE_INDEPENDENT,
MERGE_INDEPENDENT,
MERGE_LEFT,
MERGE_RIGHT,
MERGE_MID)
FIXED_FUNCTIONS = (FIXED0,FIXED1,FIXED2,FIXED3,FIXED4)
def __init__(self, flac_stream):
self.stream = BufferedStream(flac_stream)
self.streaminfo = None
self.bitstream = None
#ensure the file starts with 'fLaC'
self.read_stream_marker()
#initialize self.bitstream
self.begin_bitstream()
#find self.streaminfo in case we need it
self.read_metadata_blocks()
def close(self):
if (self.bitstream != None):
self.bitstream.close()
else:
self.stream.close()
def read_stream_marker(self):
if (self.stream.read(4) != 'fLaC'):
raise FlacStreamException('invalid stream marker')
def read_metadata_blocks(self):
block = audiotools.FlacAudio.METADATA_BLOCK_HEADER.parse_stream(self.stream)
while (block.last_block == 0):
if (block.block_type == 0):
self.streaminfo = audiotools.FlacAudio.STREAMINFO.parse_stream(self.stream)
else:
self.stream.seek(block.block_length,1)
block = audiotools.FlacAudio.METADATA_BLOCK_HEADER.parse_stream(self.stream)
self.stream.seek(block.block_length,1)
def begin_bitstream(self):
import bitstream
#self.bitstream = Con.BitStreamReader(self.stream)
self.bitstream = bitstream.BitStreamReader(self.stream)
def read_frame(self):
self.stream.reset_buffer()
try:
header = FlacReader.FRAME_HEADER.parse_stream(self.bitstream)
except Con.core.FieldError:
return ""
if (header.sync != 0x3FFE):
raise FlacStreamException('invalid sync')
if (crc8(self.stream.getvalue()[0:-1]) != header.crc8):
raise FlacStreamException('crc8 checksum failed')
#block_size tells us how many samples we need from each subframe
block_size = FlacReader.BLOCK_SIZE[header.block_size]
if (block_size == self.GET_BLOCKSIZE_FROM_STREAMINFO):
block_size = self.streaminfo.maximum_blocksize
elif ((block_size == self.GET_8BIT_BLOCKSIZE_FROM_END_OF_HEADER) or
(block_size == self.GET_16BIT_BLOCKSIZE_FROM_END_OF_HEADER)):
block_size = header.extended_block_size + 1
#grab subframe data as 32-bit array objects
subframe_data = []
for channel_number in xrange(header.total_channels):
subframe_data.append(
self.read_subframe(header, block_size, channel_number))
crc16sum = crc16(self.stream.getvalue())
#try to byte-align the stream
if (len(self.bitstream.buffer) > 0):
self.bitstream.read(len(self.bitstream.buffer))
if (crc16sum != Con.Bits('crc16',16).parse_stream(self.bitstream)):
raise FlacStreamException('crc16 checksum failed')
#convert our list of subframe data arrays into
#a string of sample data
if (FlacReader.SAMPLE_SIZE[header.bits_per_sample] == 16):
merged_frames = array.array('h',
FlacReader.CHANNEL_FUNCTIONS[
header.channel_assignment](subframe_data))
if (audiotools.BIG_ENDIAN):
merged_frames.byteswap()
return merged_frames.tostring()
elif (FlacReader.SAMPLE_SIZE[header.bits_per_sample] == 8):
merged_frames = array.array('b',
FlacReader.CHANNEL_FUNCTIONS[
header.channel_assignment](subframe_data))
return merged_frames.tostring()
else:
if (FlacReader.SAMPLE_SIZE[header.bits_per_sample] == \
self.GET_SAMPLE_SIZE_FROM_STREAMINFO):
bits_per_sample = self.streaminfo.bits_per_sample + 1
elif (FlacReader.SAMPLE_SIZE[header.bits_per_sample] == None):
raise FlacStreamException('invalid bits per sample')
else:
bits_per_sample = FlacReader.SAMPLE_SIZE[header.bits_per_sample]
stream = Con.GreedyRepeater(
Con.BitStruct('bits',
Con.Bits('value',bits_per_sample,
swapped=True,signed=True)))
return stream.build(
[Con.Container(value=v) for v in
FlacReader.CHANNEL_FUNCTIONS[header.channel_assignment](
subframe_data)])
def read_subframe(self, frame_header, block_size, channel_number):
subframe_header = \
FlacReader.SUBFRAME_HEADER.parse_stream(self.bitstream)
#figure out the bits-per-sample of this subframe
if ((frame_header.channel_assignment == 8) and
(channel_number == 1)):
#if channel is stored as left+difference
#and this is the difference, add 1 bit
bits_per_sample = FlacReader.SAMPLE_SIZE[
frame_header.bits_per_sample] + 1
elif ((frame_header.channel_assignment == 9) and
(channel_number == 0)):
#if channel is stored as difference+right
#and this is the difference, add 1 bit
bits_per_sample = FlacReader.SAMPLE_SIZE[
frame_header.bits_per_sample] + 1
elif ((frame_header.channel_assignment == 10) and
(channel_number == 1)):
#if channel is stored as average+difference
#and this is the difference, add 1 bit
bits_per_sample = FlacReader.SAMPLE_SIZE[
frame_header.bits_per_sample] + 1
else:
#otherwise, use the number from the frame header
bits_per_sample = FlacReader.SAMPLE_SIZE[
frame_header.bits_per_sample]
if (subframe_header.has_wasted_bits_per_sample):
bits_per_sample -= subframe_header.wasted_bits_per_sample
if (subframe_header.subframe_type == 0):
subframe = self.read_subframe_constant(block_size, bits_per_sample)
elif (subframe_header.subframe_type == 1):
subframe = self.read_subframe_verbatim(block_size, bits_per_sample)
elif ((subframe_header.subframe_type & 0x38) == 0x08):
subframe = self.read_subframe_fixed(
subframe_header.subframe_type & 0x07,
block_size,
bits_per_sample)
elif ((subframe_header.subframe_type & 0x20) == 0x20):
subframe = self.read_subframe_lpc(
(subframe_header.subframe_type & 0x1F) + 1,
block_size,
bits_per_sample)
else:
raise FlacStreamException('invalid subframe type')
if (subframe_header.has_wasted_bits_per_sample):
return array.array(
'i',
[i << subframe_header.wasted_bits_per_sample
for i in subframe])
else:
return subframe
def read_subframe_constant(self, block_size, bits_per_sample):
sample = Con.Bits('b',bits_per_sample).parse_stream(
self.bitstream)
subframe = array.array('i',[sample] * block_size)
return subframe
def read_subframe_verbatim(self, block_size, bits_per_sample):
return array.array('i',
Con.StrictRepeater(
block_size,
Con.Bits("samples",
bits_per_sample,
signed=True)).parse_stream(self.bitstream))
def read_subframe_fixed(self, order, block_size, bits_per_sample):
samples = Con.StrictRepeater(
order,
Con.Bits("warm_up_samples",
bits_per_sample,
signed=True))
subframe = array.array('i',
samples.parse_stream(self.bitstream))
residual = self.read_residual(block_size,order)
fixed_func = self.FIXED_FUNCTIONS[order]
for i in xrange(len(subframe),block_size):
fixed_func(subframe,residual,i)
return subframe
def read_subframe_lpc(self, order, block_size, bits_per_sample):
samples = Con.StrictRepeater(
order,
Con.Bits("warm_up_samples",
bits_per_sample,
signed=True))
subframe = array.array('i',
samples.parse_stream(self.bitstream))
lpc_precision = Con.Bits('lpc_precision',
4).parse_stream(self.bitstream) + 1
lpc_shift = Con.Bits('lpc_shift',
5).parse_stream(self.bitstream)
coefficients = array.array('i',
Con.StrictRepeater(
order,
Con.Bits('coefficients',
lpc_precision,
signed=True)).parse_stream(self.bitstream))
residual = self.read_residual(block_size, order)
for i in xrange(len(subframe),block_size):
subframe.insert(i,
(sum(
[coefficients[j] * subframe[i - j - 1] for j in
xrange(0,len(coefficients))]) >> lpc_shift) + \
residual[i])
return subframe
def read_residual(self, block_size, predictor_order):
rice = array.array('i')
#add some dummy rice so that the Rice index matches
#that of the rest of the subframe
for i in xrange(predictor_order):
rice.append(0)
coding_method = self.bitstream.read(2)
if (coding_method == '\x00\x00'):
rice2 = False
elif (coding_method == '\x00\x01'):
rice2 = True
else:
raise FlacStreamException('invalid residual coding method')
partition_order = Con.Bits('partition_order',4).parse_stream(
self.bitstream)
if (partition_order > 0):
total_samples = ((block_size / 2 ** partition_order) -
predictor_order)
rice.extend(self.read_encoded_rice(total_samples,rice2))
for i in xrange(1,2 ** partition_order):
total_samples = (block_size / 2 ** partition_order)
rice.extend(self.read_encoded_rice(total_samples,rice2))
else:
rice.extend(self.read_encoded_rice(block_size - predictor_order,
rice2))
return rice
def read_encoded_rice(self, total_samples, rice2=False):
bin_to_int = Con.lib.binary.bin_to_int
samples = array.array('i')
if (not rice2):
rice_parameter = Con.Bits('rice_parameter',4).parse_stream(
self.bitstream)
else:
rice_parameter = Con.Bits('rice_parameter',5).parse_stream(
self.bitstream)
if (rice_parameter != 0xF):
#a Rice encoded residual
for x in xrange(total_samples):
#count the number of 0 bits before the next 1 bit
#(unary encoding)
#to find our most significant bits
msb = 0
s = self.bitstream.read(1)
while (s != '\x01'):
msb += 1
s = self.bitstream.read(1)
#grab the proper number of least significant bits
lsb = bin_to_int(self.bitstream.read(rice_parameter))
#combine msb and lsb to get the Rice-encoded value
value = (msb << rice_parameter) | lsb
if ((value & 0x1) == 0x1): #negative
samples.append(-(value >> 1) - 1)
else: #positive
samples.append(value >> 1)
else:
#unencoded residual
bits_per_sample = Con.Bits('escape_code',5).parse_stream(
self.bitstream)
sample = Con.Bits("sample",bits_per_sample,signed=True)
for x in xrange(total_samples):
samples.append(sample.parse_stream(self.bitstream))
return samples
###############################
#Checksum calculation functions
###############################
CRC8TABLE = (0x00, 0x07, 0x0E, 0x09, 0x1C, 0x1B, 0x12, 0x15,
0x38, 0x3F, 0x36, 0x31, 0x24, 0x23, 0x2A, 0x2D,
0x70, 0x77, 0x7E, 0x79, 0x6C, 0x6B, 0x62, 0x65,
0x48, 0x4F, 0x46, 0x41, 0x54, 0x53, 0x5A, 0x5D,
0xE0, 0xE7, 0xEE, 0xE9, 0xFC, 0xFB, 0xF2, 0xF5,
0xD8, 0xDF, 0xD6, 0xD1, 0xC4, 0xC3, 0xCA, 0xCD,
0x90, 0x97, 0x9E, 0x99, 0x8C, 0x8B, 0x82, 0x85,
0xA8, 0xAF, 0xA6, 0xA1, 0xB4, 0xB3, 0xBA, 0xBD,
0xC7, 0xC0, 0xC9, 0xCE, 0xDB, 0xDC, 0xD5, 0xD2,
0xFF, 0xF8, 0xF1, 0xF6, 0xE3, 0xE4, 0xED, 0xEA,
0xB7, 0xB0, 0xB9, 0xBE, 0xAB, 0xAC, 0xA5, 0xA2,
0x8F, 0x88, 0x81, 0x86, 0x93, 0x94, 0x9D, 0x9A,
0x27, 0x20, 0x29, 0x2E, 0x3B, 0x3C, 0x35, 0x32,
0x1F, 0x18, 0x11, 0x16, 0x03, 0x04, 0x0D, 0x0A,
0x57, 0x50, 0x59, 0x5E, 0x4B, 0x4C, 0x45, 0x42,
0x6F, 0x68, 0x61, 0x66, 0x73, 0x74, 0x7D, 0x7A,
0x89, 0x8E, 0x87, 0x80, 0x95, 0x92, 0x9B, 0x9C,
0xB1, 0xB6, 0xBF, 0xB8, 0xAD, 0xAA, 0xA3, 0xA4,
0xF9, 0xFE, 0xF7, 0xF0, 0xE5, 0xE2, 0xEB, 0xEC,
0xC1, 0xC6, 0xCF, 0xC8, 0xDD, 0xDA, 0xD3, 0xD4,
0x69, 0x6E, 0x67, 0x60, 0x75, 0x72, 0x7B, 0x7C,
0x51, 0x56, 0x5F, 0x58, 0x4D, 0x4A, 0x43, 0x44,
0x19, 0x1E, 0x17, 0x10, 0x05, 0x02, 0x0B, 0x0C,
0x21, 0x26, 0x2F, 0x28, 0x3D, 0x3A, 0x33, 0x34,
0x4E, 0x49, 0x40, 0x47, 0x52, 0x55, 0x5C, 0x5B,
0x76, 0x71, 0x78, 0x7F, 0x6A, 0x6D, 0x64, 0x63,
0x3E, 0x39, 0x30, 0x37, 0x22, 0x25, 0x2C, 0x2B,
0x06, 0x01, 0x08, 0x0F, 0x1A, 0x1D, 0x14, 0x13,
0xAE, 0xA9, 0xA0, 0xA7, 0xB2, 0xB5, 0xBC, 0xBB,
0x96, 0x91, 0x98, 0x9F, 0x8A, 0x8D, 0x84, 0x83,
0xDE, 0xD9, 0xD0, 0xD7, 0xC2, 0xC5, 0xCC, 0xCB,
0xE6, 0xE1, 0xE8, 0xEF, 0xFA, 0xFD, 0xF4, 0xF3)
def crc8(data, start=0):
value = start
for i in map(ord,data):
value = CRC8TABLE[value ^ i]
return value
CRC16TABLE = (0x0000,0x8005,0x800f,0x000a,0x801b,0x001e,0x0014,0x8011,
0x8033,0x0036,0x003c,0x8039,0x0028,0x802d,0x8027,0x0022,
0x8063,0x0066,0x006c,0x8069,0x0078,0x807d,0x8077,0x0072,
0x0050,0x8055,0x805f,0x005a,0x804b,0x004e,0x0044,0x8041,
0x80c3,0x00c6,0x00cc,0x80c9,0x00d8,0x80dd,0x80d7,0x00d2,
0x00f0,0x80f5,0x80ff,0x00fa,0x80eb,0x00ee,0x00e4,0x80e1,
0x00a0,0x80a5,0x80af,0x00aa,0x80bb,0x00be,0x00b4,0x80b1,
0x8093,0x0096,0x009c,0x8099,0x0088,0x808d,0x8087,0x0082,
0x8183,0x0186,0x018c,0x8189,0x0198,0x819d,0x8197,0x0192,
0x01b0,0x81b5,0x81bf,0x01ba,0x81ab,0x01ae,0x01a4,0x81a1,
0x01e0,0x81e5,0x81ef,0x01ea,0x81fb,0x01fe,0x01f4,0x81f1,
0x81d3,0x01d6,0x01dc,0x81d9,0x01c8,0x81cd,0x81c7,0x01c2,
0x0140,0x8145,0x814f,0x014a,0x815b,0x015e,0x0154,0x8151,
0x8173,0x0176,0x017c,0x8179,0x0168,0x816d,0x8167,0x0162,
0x8123,0x0126,0x012c,0x8129,0x0138,0x813d,0x8137,0x0132,
0x0110,0x8115,0x811f,0x011a,0x810b,0x010e,0x0104,0x8101,
0x8303,0x0306,0x030c,0x8309,0x0318,0x831d,0x8317,0x0312,
0x0330,0x8335,0x833f,0x033a,0x832b,0x032e,0x0324,0x8321,
0x0360,0x8365,0x836f,0x036a,0x837b,0x037e,0x0374,0x8371,
0x8353,0x0356,0x035c,0x8359,0x0348,0x834d,0x8347,0x0342,
0x03c0,0x83c5,0x83cf,0x03ca,0x83db,0x03de,0x03d4,0x83d1,
0x83f3,0x03f6,0x03fc,0x83f9,0x03e8,0x83ed,0x83e7,0x03e2,
0x83a3,0x03a6,0x03ac,0x83a9,0x03b8,0x83bd,0x83b7,0x03b2,
0x0390,0x8395,0x839f,0x039a,0x838b,0x038e,0x0384,0x8381,
0x0280,0x8285,0x828f,0x028a,0x829b,0x029e,0x0294,0x8291,
0x82b3,0x02b6,0x02bc,0x82b9,0x02a8,0x82ad,0x82a7,0x02a2,
0x82e3,0x02e6,0x02ec,0x82e9,0x02f8,0x82fd,0x82f7,0x02f2,
0x02d0,0x82d5,0x82df,0x02da,0x82cb,0x02ce,0x02c4,0x82c1,
0x8243,0x0246,0x024c,0x8249,0x0258,0x825d,0x8257,0x0252,
0x0270,0x8275,0x827f,0x027a,0x826b,0x026e,0x0264,0x8261,
0x0220,0x8225,0x822f,0x022a,0x823b,0x023e,0x0234,0x8231,
0x8213,0x0216,0x021c,0x8219,0x0208,0x820d,0x8207,0x0202)
def crc16(data, start=0):
value = start
for i in map(ord,data):
value = ((value << 8) ^ CRC16TABLE[(value >> 8) ^ i]) & 0xFFFF
return value
#BufferedStream stores the data that passes through read()
#so that checksums can be calculated from it.
#Be sure to reset the buffer as needed.
class BufferedStream:
def __init__(self, stream):
self.stream = stream
self.buffer = cStringIO.StringIO()
def read(self, count):
s = self.stream.read(count)
self.buffer.write(s)
return s
def seek(self, offset, whence=0):
self.stream.seek(offset,whence)
def tell(self):
return self.stream.tell()
def close(self):
self.stream.close()
def reset_buffer(self):
self.buffer.close()
self.buffer = cStringIO.StringIO()
def getvalue(self):
return self.buffer.getvalue()
class FlacPCMReader(audiotools.PCMReader):
#flac_file should be a file-like stream of FLAC data
def __init__(self, flac_file):
self.flacreader = FlacReader(flac_file)
self.sample_rate = self.flacreader.streaminfo.samplerate
self.channels = self.flacreader.streaminfo.channels + 1
self.bits_per_sample = self.flacreader.streaminfo.bits_per_sample + 1
self.process = None
self.buffer = []
#this won't return even close to the expected number of bytes
#(though that won't really break anything)
def read(self, bytes):
return self.flacreader.read_frame()
def close(self):
self.flacreader.close()

Binary file not shown.

View File

@ -0,0 +1,804 @@
#!/usr/bin/bin
#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
import os
import sys
import cPickle
import select
import audiotools
import time
import Queue
import threading
(RG_NO_REPLAYGAIN, RG_TRACK_GAIN, RG_ALBUM_GAIN) = range(3)
class Player:
"""A class for operating an audio player.
The player itself runs in a seperate thread,
which this sends commands to."""
def __init__(self, audio_output,
replay_gain=RG_NO_REPLAYGAIN,
next_track_callback=lambda: None):
"""audio_output is an AudioOutput subclass.
replay_gain is RG_NO_REPLAYGAIN, RG_TRACK_GAIN or RG_ALBUM_GAIN,
indicating how the player should apply ReplayGain.
next_track_callback is a function with no arguments
which is called by the player when the current track is finished."""
self.command_queue = Queue.Queue()
self.worker = PlayerThread(audio_output,
self.command_queue,
replay_gain)
self.thread = threading.Thread(target=self.worker.run,
args=(next_track_callback,))
self.thread.daemon = True
self.thread.start()
def open(self, track):
"""opens the given AudioFile for playing
stops playing the current file, if any"""
self.track = track
self.command_queue.put(("open", [track]))
def play(self):
"""begins or resumes playing an opened AudioFile, if any"""
self.command_queue.put(("play", []))
def set_replay_gain(self, replay_gain):
"""sets the given ReplayGain level to apply during playback
Choose from RG_NO_REPLAYGAIN, RG_TRACK_GAIN or RG_ALBUM_GAIN
ReplayGain cannot be applied mid-playback.
One must stop() and play() a file for it to take effect."""
self.command_queue.put(("set_replay_gain", [replay_gain]))
def pause(self):
"""pauses playback of the current file
Playback may be resumed with play() or toggle_play_pause()"""
self.command_queue.put(("pause", []))
def toggle_play_pause(self):
"""pauses the file if playing, play the file if paused"""
self.command_queue.put(("toggle_play_pause", []))
def stop(self):
"""stops playback of the current file
If play() is called, playback will start from the beginning."""
self.command_queue.put(("stop", []))
def close(self):
"""closes the player for playback
The player thread is halted and the AudioOutput is closed."""
self.command_queue.put(("exit", []))
def progress(self):
"""returns a (pcm_frames_played, pcm_frames_total) tuple
This indicates the current playback status in PCM frames."""
return (self.worker.frames_played, self.worker.total_frames)
(PLAYER_STOPPED, PLAYER_PAUSED, PLAYER_PLAYING) = range(3)
class PlayerThread:
"""The Player class' subthread.
This should not be instantiated directly;
Player will do so automatically."""
def __init__(self, audio_output, command_queue,
replay_gain=RG_NO_REPLAYGAIN):
self.audio_output = audio_output
self.command_queue = command_queue
self.replay_gain = replay_gain
self.track = None
self.pcmconverter = None
self.frames_played = 0
self.total_frames = 0
self.state = PLAYER_STOPPED
def open(self, track):
self.stop()
self.track = track
self.frames_played = 0
self.total_frames = track.total_frames()
def pause(self):
if (self.state == PLAYER_PLAYING):
self.state = PLAYER_PAUSED
def play(self):
if (self.track is not None):
if (self.state == PLAYER_STOPPED):
if (self.replay_gain == RG_TRACK_GAIN):
from audiotools.replaygain import ReplayGainReader
replay_gain = self.track.replay_gain()
if (replay_gain is not None):
pcmreader = ReplayGainReader(
self.track.to_pcm(),
replay_gain.track_gain,
replay_gain.track_peak)
else:
pcmreader = self.track.to_pcm()
elif (self.replay_gain == RG_ALBUM_GAIN):
from audiotools.replaygain import ReplayGainReader
replay_gain = self.track.replay_gain()
if (replay_gain is not None):
pcmreader = ReplayGainReader(
self.track.to_pcm(),
replay_gain.album_gain,
replay_gain.album_peak)
else:
pcmreader = self.track.to_pcm()
else:
pcmreader = self.track.to_pcm()
if (not self.audio_output.compatible(pcmreader)):
self.audio_output.init(
sample_rate=pcmreader.sample_rate,
channels=pcmreader.channels,
channel_mask=pcmreader.channel_mask,
bits_per_sample=pcmreader.bits_per_sample)
self.pcmconverter = ThreadedPCMConverter(
pcmreader,
self.audio_output.framelist_converter())
self.frames_played = 0
self.state = PLAYER_PLAYING
elif (self.state == PLAYER_PAUSED):
self.state = PLAYER_PLAYING
elif (self.state == PLAYER_PLAYING):
pass
def set_replay_gain(self, replay_gain):
self.replay_gain = replay_gain
def toggle_play_pause(self):
if (self.state == PLAYER_PLAYING):
self.pause()
elif ((self.state == PLAYER_PAUSED) or
(self.state == PLAYER_STOPPED)):
self.play()
def stop(self):
if (self.pcmconverter is not None):
self.pcmconverter.close()
del(self.pcmconverter)
self.pcmconverter = None
self.frames_played = 0
self.state = PLAYER_STOPPED
def run(self, next_track_callback=lambda: None):
while (True):
if ((self.state == PLAYER_STOPPED) or
(self.state == PLAYER_PAUSED)):
(command, args) = self.command_queue.get(True)
if (command == "exit"):
self.audio_output.close()
return
else:
getattr(self, command)(*args)
else:
try:
(command, args) = self.command_queue.get_nowait()
if (command == "exit"):
return
else:
getattr(self, command)(*args)
except Queue.Empty:
if (self.frames_played < self.total_frames):
(data, frames) = self.pcmconverter.read()
if (frames > 0):
self.audio_output.play(data)
self.frames_played += frames
if (self.frames_played >= self.total_frames):
next_track_callback()
else:
self.frames_played = self.total_frames
next_track_callback()
else:
self.stop()
class CDPlayer:
"""A class for operating a CDDA player.
The player itself runs in a seperate thread,
which this sends commands to."""
def __init__(self, cdda, audio_output,
next_track_callback=lambda: None):
"""cdda is a audiotools.CDDA object.
audio_output is an AudioOutput subclass.
next_track_callback is a function with no arguments
which is called by the player when the current track is finished."""
self.command_queue = Queue.Queue()
self.worker = CDPlayerThread(cdda,
audio_output,
self.command_queue)
self.thread = threading.Thread(target=self.worker.run,
args=(next_track_callback,))
self.thread.daemon = True
self.thread.start()
def open(self, track_number):
"""track_number indicates which track to open, starting from 1
stops playing the current track, if any"""
self.command_queue.put(("open", [track_number]))
def play(self):
"""begins or resumes playing the currently open track, if any"""
self.command_queue.put(("play", []))
def pause(self):
"""pauses playback of the current track
Playback may be resumed with play() or toggle_play_pause()"""
self.command_queue.put(("pause", []))
def toggle_play_pause(self):
"""pauses the track if playing, play the track if paused"""
self.command_queue.put(("toggle_play_pause", []))
def stop(self):
"""stops playback of the current track
If play() is called, playback will start from the beginning."""
self.command_queue.put(("stop", []))
def close(self):
"""closes the player for playback
The player thread is halted and the AudioOutput is closed."""
self.command_queue.put(("exit", []))
def progress(self):
"""returns a (pcm_frames_played, pcm_frames_total) tuple
This indicates the current playback status in PCM frames."""
return (self.worker.frames_played, self.worker.total_frames)
class CDPlayerThread:
"""The CDPlayer class' subthread.
This should not be instantiated directly;
CDPlayer will do so automatically."""
def __init__(self, cdda, audio_output, command_queue):
self.cdda = cdda
self.audio_output = audio_output
self.command_queue = command_queue
self.audio_output.init(
sample_rate=44100,
channels=2,
channel_mask=3,
bits_per_sample=16)
self.framelist_converter = self.audio_output.framelist_converter()
self.track = None
self.pcmconverter = None
self.frames_played = 0
self.total_frames = 0
self.state = PLAYER_STOPPED
def open(self, track_number):
self.stop()
self.track = self.cdda[track_number]
self.frames_played = 0
self.total_frames = self.track.length() * 44100 / 75
def play(self):
if (self.track is not None):
if (self.state == PLAYER_STOPPED):
self.pcmconverter = ThreadedPCMConverter(
self.track,
self.framelist_converter)
self.frames_played = 0
self.state = PLAYER_PLAYING
elif (self.state == PLAYER_PAUSED):
self.state = PLAYER_PLAYING
elif (self.state == PLAYER_PLAYING):
pass
def pause(self):
if (self.state == PLAYER_PLAYING):
self.state = PLAYER_PAUSED
def toggle_play_pause(self):
if (self.state == PLAYER_PLAYING):
self.pause()
elif ((self.state == PLAYER_PAUSED) or
(self.state == PLAYER_STOPPED)):
self.play()
def stop(self):
if (self.pcmconverter is not None):
self.pcmconverter.close()
del(self.pcmconverter)
self.pcmconverter = None
self.frames_played = 0
self.state = PLAYER_STOPPED
def run(self, next_track_callback=lambda: None):
while (True):
if ((self.state == PLAYER_STOPPED) or
(self.state == PLAYER_PAUSED)):
(command, args) = self.command_queue.get(True)
if (command == "exit"):
self.audio_output.close()
return
else:
getattr(self, command)(*args)
else:
try:
(command, args) = self.command_queue.get_nowait()
if (command == "exit"):
return
else:
getattr(self, command)(*args)
except Queue.Empty:
if (self.frames_played < self.total_frames):
(data, frames) = self.pcmconverter.read()
if (frames > 0):
self.audio_output.play(data)
self.frames_played += frames
if (self.frames_played >= self.total_frames):
next_track_callback()
else:
self.frames_played = self.total_frames
next_track_callback()
else:
self.stop()
class ThreadedPCMConverter:
"""A class for decoding a PCMReader in a seperate thread.
PCMReader's data is queued such that even if decoding and
conversion are relatively time-consuming, read() will
continue smoothly."""
def __init__(self, pcmreader, converter):
"""pcmreader is a PCMReader object.
converter is a function which takes a FrameList
and returns an object suitable for the current AudioOutput object.
Upon conclusion, the PCMReader is automatically closed."""
self.decoded_data = Queue.Queue()
self.stop_decoding = threading.Event()
def convert(pcmreader, buffer_size, converter, decoded_data,
stop_decoding):
try:
frame = pcmreader.read(buffer_size)
while ((not stop_decoding.is_set()) and (len(frame) > 0)):
decoded_data.put((converter(frame), frame.frames))
frame = pcmreader.read(buffer_size)
else:
decoded_data.put((None, 0))
pcmreader.close()
except (ValueError, IOError):
decoded_data.put((None, 0))
pcmreader.close()
buffer_size = (pcmreader.sample_rate *
pcmreader.channels *
(pcmreader.bits_per_sample / 8)) / 20
self.thread = threading.Thread(
target=convert,
args=(pcmreader,
buffer_size,
converter,
self.decoded_data,
self.stop_decoding))
self.thread.daemon = True
self.thread.start()
def read(self):
"""returns a (converted_data, pcm_frame_count) tuple"""
return self.decoded_data.get(True)
def close(self):
"""stops the decoding thread and closes the PCMReader"""
self.stop_decoding.set()
self.thread.join()
class AudioOutput:
"""An abstract parent class for playing audio."""
def __init__(self):
self.sample_rate = 0
self.channels = 0
self.channel_mask = 0
self.bits_per_sample = 0
self.initialized = False
def compatible(self, pcmreader):
"""Returns True if the given pcmreader is compatible.
If False, one is expected to open a new output stream
which is compatible."""
return ((self.sample_rate == pcmreader.sample_rate) and
(self.channels == pcmreader.channels) and
(self.channel_mask == pcmreader.channel_mask) and
(self.bits_per_sample == pcmreader.bits_per_sample))
def framelist_converter(self):
"""Returns a function which converts framelist objects
to objects acceptable by our play() method."""
raise NotImplementedError()
def init(self, sample_rate, channels, channel_mask, bits_per_sample):
"""Initializes the output stream.
This *must* be called prior to play() and close().
The general flow of audio playing is:
>>> pcm = audiofile.to_pcm()
>>> player = AudioOutput()
>>> player.init(pcm.sample_rate,
... pcm.channels,
... pcm.channel_mask,
... pcm.bits_per_sample)
>>> convert = player.framelist_converter()
>>> frame = pcm.read(1024)
>>> while (len(frame) > 0):
... player.play(convert(frame))
... frame = pcm.read(1024)
>>> player.close()
"""
raise NotImplementedError()
def play(self, data):
"""plays a chunk of converted data"""
raise NotImplementedError()
def close(self):
"""closes the output stream"""
raise NotImplementedError()
@classmethod
def available(cls):
"""returns True if the AudioOutput is available on the system"""
return False
class NULLAudioOutput(AudioOutput):
"""An AudioOutput subclass which does not actually play anything.
Although this consumes audio output at the rate it would normally
play, it generates no output."""
NAME = "NULL"
def framelist_converter(self):
"""Returns a function which converts framelist objects
to objects acceptable by our play() method."""
return lambda f: f.frames
def init(self, sample_rate, channels, channel_mask, bits_per_sample):
"""Initializes the output stream.
This *must* be called prior to play() and close()."""
self.sample_rate = sample_rate
self.channels = channels
self.channel_mask = channel_mask
self.bits_per_sample = bits_per_sample
def play(self, data):
"""plays a chunk of converted data"""
time.sleep(float(data) / self.sample_rate)
def close(self):
"""closes the output stream"""
pass
@classmethod
def available(cls):
"""returns True"""
return True
class OSSAudioOutput(AudioOutput):
"""An AudioOutput subclass for OSS output."""
NAME = "OSS"
def init(self, sample_rate, channels, channel_mask, bits_per_sample):
"""Initializes the output stream.
This *must* be called prior to play() and close()."""
if (not self.initialized):
import ossaudiodev
self.sample_rate = sample_rate
self.channels = channels
self.channel_mask = channel_mask
self.bits_per_sample = bits_per_sample
self.ossaudio = ossaudiodev.open('w')
if (self.bits_per_sample == 8):
self.ossaudio.setfmt(ossaudiodev.AFMT_S8_LE)
elif (self.bits_per_sample == 16):
self.ossaudio.setfmt(ossaudiodev.AFMT_S16_LE)
elif (self.bits_per_sample == 24):
self.ossaudio.setfmt(ossaudiodev.AFMT_S16_LE)
else:
raise ValueError("Unsupported bits-per-sample")
self.ossaudio.channels(channels)
self.ossaudio.speed(sample_rate)
self.initialized = True
else:
self.close()
self.init(sample_rate=sample_rate,
channels=channels,
channel_mask=channel_mask,
bits_per_sample=bits_per_sample)
def framelist_converter(self):
"""Returns a function which converts framelist objects
to objects acceptable by our play() method."""
if (self.bits_per_sample == 8):
return lambda f: f.to_bytes(False, True)
elif (self.bits_per_sample == 16):
return lambda f: f.to_bytes(False, True)
elif (self.bits_per_sample == 24):
import audiotools.pcm
return lambda f: audiotools.pcm.from_list(
[i >> 8 for i in list(f)],
self.channels, 16, True).to_bytes(False, True)
else:
raise ValueError("Unsupported bits-per-sample")
def play(self, data):
"""plays a chunk of converted data"""
self.ossaudio.writeall(data)
def close(self):
"""closes the output stream"""
if (self.initialized):
self.initialized = False
self.ossaudio.close()
@classmethod
def available(cls):
"""returns True if OSS output is available on the system"""
try:
import ossaudiodev
return True
except ImportError:
return False
class PulseAudioOutput(AudioOutput):
"""An AudioOutput subclass for PulseAudio output."""
NAME = "PulseAudio"
def init(self, sample_rate, channels, channel_mask, bits_per_sample):
"""Initializes the output stream.
This *must* be called prior to play() and close()."""
if (not self.initialized):
import subprocess
self.sample_rate = sample_rate
self.channels = channels
self.channel_mask = channel_mask
self.bits_per_sample = bits_per_sample
if (bits_per_sample == 8):
format = "u8"
elif (bits_per_sample == 16):
format = "s16le"
elif (bits_per_sample == 24):
format = "s24le"
else:
raise ValueError("Unsupported bits-per-sample")
self.pacat = subprocess.Popen(
[audiotools.BIN["pacat"],
"-n", "Python Audio Tools",
"--rate", str(sample_rate),
"--format", format,
"--channels", str(channels),
"--latency-msec", str(100)],
stdin=subprocess.PIPE)
self.initialized = True
else:
self.close()
self.init(sample_rate=sample_rate,
channels=channels,
channel_mask=channel_mask,
bits_per_sample=bits_per_sample)
def framelist_converter(self):
"""Returns a function which converts framelist objects
to objects acceptable by our play() method."""
if (self.bits_per_sample == 8):
return lambda f: f.to_bytes(True, False)
elif (self.bits_per_sample == 16):
return lambda f: f.to_bytes(False, True)
elif (self.bits_per_sample == 24):
return lambda f: f.to_bytes(False, True)
else:
raise ValueError("Unsupported bits-per-sample")
def play(self, data):
"""plays a chunk of converted data"""
self.pacat.stdin.write(data)
self.pacat.stdin.flush()
def close(self):
"""closes the output stream"""
if (self.initialized):
self.initialized = False
self.pacat.stdin.close()
self.pacat.wait()
@classmethod
def server_alive(cls):
import subprocess
dev = subprocess.Popen([audiotools.BIN["pactl"], "stat"],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE)
dev.stdout.read()
dev.stderr.read()
return (dev.wait() == 0)
@classmethod
def available(cls):
"""returns True if PulseAudio is available and running on the system"""
return (audiotools.BIN.can_execute(audiotools.BIN["pacat"]) and
audiotools.BIN.can_execute(audiotools.BIN["pactl"]) and
cls.server_alive())
class PortAudioOutput(AudioOutput):
"""An AudioOutput subclass for PortAudio output."""
NAME = "PortAudio"
def init(self, sample_rate, channels, channel_mask, bits_per_sample):
"""Initializes the output stream.
This *must* be called prior to play() and close()."""
if (not self.initialized):
import pyaudio
self.sample_rate = sample_rate
self.channels = channels
self.channel_mask = channel_mask
self.bits_per_sample = bits_per_sample
self.pyaudio = pyaudio.PyAudio()
self.stream = self.pyaudio.open(
format=self.pyaudio.get_format_from_width(
self.bits_per_sample / 8, False),
channels=self.channels,
rate=self.sample_rate,
output=True)
self.initialized = True
else:
self.close()
self.init(sample_rate=sample_rate,
channels=channels,
channel_mask=channel_mask,
bits_per_sample=bits_per_sample)
def framelist_converter(self):
"""Returns a function which converts framelist objects
to objects acceptable by our play() method."""
return lambda f: f.to_bytes(False, True)
def play(self, data):
"""plays a chunk of converted data"""
self.stream.write(data)
def close(self):
"""closes the output stream"""
if (self.initialized):
self.stream.close()
self.pyaudio.terminate()
self.initialized = False
@classmethod
def available(cls):
"""returns True if the AudioOutput is available on the system"""
try:
import pyaudio
return True
except ImportError:
return False
AUDIO_OUTPUT = (PulseAudioOutput, OSSAudioOutput,
PortAudioOutput, NULLAudioOutput)

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,259 @@
#!/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
#This is a module for ReplayGain calculation of a given PCM stream.
#It is included as a reference implementation and not as a substitute
#for external ReplayGain calculators.
#The first problem with it is that the results are not identical
#to those of external calculators, by about a 100th of a dB or so.
#This is probably because the C-based implementations use floats
#while Python uses doubles. Thus the difference in rounding errors.
#The second problem with it is it's very, very slow.
#Python is ill-suited to these kinds of rolling loop calculations
#involving thousands of samples per second, so the Python-based
#approach is several times slower than real-time.
import audiotools
import audiotools.pcmstream
from itertools import izip
AYule = ((1.0, -3.8466461711806699, 7.81501653005538, -11.341703551320419, 13.055042193275449, -12.28759895145294, 9.4829380631978992, -5.8725786177599897, 2.7546586187461299, -0.86984376593551005, 0.13919314567432001),
(1.0, -3.4784594855007098, 6.3631777756614802, -8.5475152747187408, 9.4769360780128, -8.8149868137015499, 6.8540154093699801, -4.3947099607955904, 2.1961168489077401, -0.75104302451432003, 0.13149317958807999),
(1.0, -2.3789883497308399, 2.84868151156327, -2.6457717022982501, 2.2369765745171302, -1.67148153367602, 1.0059595480854699, -0.45953458054982999, 0.16378164858596, -0.050320777171309998, 0.023478974070199998),
(1.0, -1.6127316513724701, 1.0797749225997, -0.2565625775407, -0.1627671912044, -0.22638893773905999, 0.39120800788283999, -0.22138138954924999, 0.045002353873520001, 0.020058518065010002, 0.0030243909574099999),
(1.0, -1.4985897936779899, 0.87350271418187997, 0.12205022308084, -0.80774944671437998, 0.47854794562325997, -0.12453458140019, -0.040675101970140001, 0.083337552841070001, -0.042373480257460003, 0.029772073199250002),
(1.0, -0.62820619233671005, 0.29661783706366002, -0.37256372942400001, 0.0021376785712399998, -0.42029820170917997, 0.22199650564824, 0.0061342435068200002, 0.06747620744683, 0.057848203758010003, 0.032227540721730001),
(1.0, -1.0480033512634901, 0.29156311971248999, -0.26806001042946997, 0.0081999964585799997, 0.45054734505007998, -0.33032403314005998, 0.067393683331100004, -0.047842542290329998, 0.016399078361890002, 0.018073643235729998),
(1.0, -0.51035327095184002, -0.31863563325244998, -0.20256413484477001, 0.14728154134329999, 0.38952639978998999, -0.23313271880868, -0.052460190244630001, -0.025059617240530001, 0.02442357316099, 0.01818801111503),
(1.0, -0.25049871956019998, -0.43193942311113998, -0.034246810176749999, -0.046783287842420002, 0.26408300200954998, 0.15113130533215999, -0.17556493366449, -0.18823009262115001, 0.054777204286740003, 0.047044096881200002)
)
BYule = ((0.038575994352000001, -0.021603671841850001, -0.0012339531685100001, -9.2916779589999993e-05, -0.016552603416190002, 0.02161526843274, -0.02074045215285, 0.0059429806512499997, 0.0030642802319099998, 0.00012025322027, 0.0028846368391600001),
(0.054186564064300002, -0.029110078089480001, -0.0084870937985100006, -0.0085116564546900003, -0.0083499090493599996, 0.022452932533390001, -0.025963385129149998, 0.016248649629749999, -0.0024087905158400001, 0.0067461368224699999, -0.00187763777362),
(0.15457299681924, -0.093310490563149995, -0.062478801536530001, 0.021635418887979999, -0.05588393329856, 0.047814766749210001, 0.0022231259774300001, 0.031740925400489998, -0.013905894218979999, 0.00651420667831, -0.0088136273383899993),
(0.30296907319326999, -0.22613988682123001, -0.085873237307719993, 0.032829301726640003, -0.0091570293343400007, -0.02364141202522, -0.0058445603991300003, 0.062761013217490003, -8.2808674800000004e-06, 0.0020586188556400002, -0.029501349832869998),
(0.33642304856131999, -0.25572241425570003, -0.11828570177555001, 0.11921148675203, -0.078344896094790006, -0.0046997791438, -0.0058950022444000001, 0.057242281403510002, 0.0083204398077299999, -0.016353813845399998, -0.017601765681500001),
(0.44915256608449999, -0.14351757464546999, -0.22784394429749, -0.01419140100551, 0.040782627971389998, -0.12398163381747999, 0.04097565135648, 0.10478503600251, -0.01863887810927, -0.031934284389149997, 0.0054190774870700002),
(0.56619470757640999, -0.75464456939302005, 0.16242137742230001, 0.16744243493672001, -0.18901604199609001, 0.30931782841830002, -0.27562961986223999, 0.0064731067724599998, 0.086475037803509999, -0.037889845548399997, -0.0058821544342100001),
(0.58100494960552995, -0.53174909058578002, -0.14289799034253001, 0.17520704835522, 0.02377945217615, 0.15558449135572999, -0.25344790059353001, 0.016284624063329999, 0.069204677639589998, -0.03721611395801, -0.0074961879717200001),
(0.53648789255105001, -0.42163034350695999, -0.0027595361192900001, 0.042678422194150002, -0.10214864179676, 0.14590772289387999, -0.024598648593450002, -0.11202315195388, -0.04060034127, 0.047886655481800003, -0.02217936801134)
)
AButter = ((1.0, -1.9722337291952701, 0.97261396931305999),
(1.0, -1.96977855582618, 0.97022847566350001),
(1.0, -1.9583538097539801, 0.95920349965458995),
(1.0, -1.9500275914987799, 0.95124613669835001),
(1.0, -1.94561023566527, 0.94705070426117999),
(1.0, -1.9278328697703599, 0.93034775234267997),
(1.0, -1.91858953033784, 0.92177618768380998),
(1.0, -1.9154210807478, 0.91885558323625005),
(1.0, -1.88903307939452, 0.89487434461663995))
BButter = ((0.98621192462707996, -1.9724238492541599, 0.98621192462707996),
(0.98500175787241995, -1.9700035157448399, 0.98500175787241995),
(0.97938932735214002, -1.95877865470428, 0.97938932735214002),
(0.97531843204928004, -1.9506368640985701, 0.97531843204928004),
(0.97316523498161001, -1.94633046996323, 0.97316523498161001),
(0.96454515552826003, -1.9290903110565201, 0.96454515552826003),
(0.96009142950541004, -1.9201828590108201, 0.96009142950541004),
(0.95856916599601005, -1.9171383319920301, 0.95856916599601005),
(0.94597685600279002, -1.89195371200558, 0.94597685600279002))
SAMPLE_RATE_MAP = {48000:0,44100:1,32000:2,24000:3,22050:4,
16000:5,12000:6,11025:7,8000:8}
PINK_REF = 64.82
class Filter:
def __init__(self, input_kernel, output_kernel):
self.input_kernel = input_kernel
self.output_kernel = output_kernel
self.unfiltered_samples = [0.0] * len(self.input_kernel)
self.filtered_samples = [0.0] * len(self.output_kernel)
#takes a list of floating point samples
#returns a list of filtered floating point samples
def filter(self, samples):
toreturn = []
input_kernel = tuple(reversed(self.input_kernel))
output_kernel = tuple(reversed(self.output_kernel[1:]))
for s in samples:
self.unfiltered_samples.append(s)
filtered = sum([i * k for i,k in zip(
self.unfiltered_samples[-len(input_kernel):],
input_kernel)]) - \
sum([i * k for i,k in zip(
self.filtered_samples[-len(output_kernel):],
output_kernel)])
self.filtered_samples.append(filtered)
toreturn.append(filtered)
#if we have more filtered and unfiltered samples than we'll need,
#chop off the excess at the beginning
if (len(self.unfiltered_samples) > (len(self.input_kernel))):
self.unfiltered_samples = self.unfiltered_samples[-len(self.input_kernel):]
if (len(self.filtered_samples) > (len(self.output_kernel))):
self.filtered_samples = self.filtered_samples[-len(self.output_kernel):]
return toreturn
MAX_ORDER = 10
class EqualLoudnessFilter(audiotools.PCMReader):
def __init__(self, pcmreader):
if (pcmreader.channels != 2):
raise ValueError("channels must equal 2")
if (pcmreader.sample_rate not in SAMPLE_RATE_MAP.keys()):
raise ValueError("unsupported sample rate")
self.stream = audiotools.pcmstream.PCMStreamReader(
pcmreader,
pcmreader.bits_per_sample / 8,
False,True)
audiotools.PCMReader.__init__(
self,
self.stream,
pcmreader.sample_rate,
2,
pcmreader.bits_per_sample)
self.leftover_samples = []
self.yule_filter_l = Filter(
BYule[SAMPLE_RATE_MAP[self.sample_rate]],
AYule[SAMPLE_RATE_MAP[self.sample_rate]])
self.yule_filter_r = Filter(
BYule[SAMPLE_RATE_MAP[self.sample_rate]],
AYule[SAMPLE_RATE_MAP[self.sample_rate]])
self.butter_filter_l = Filter(
BButter[SAMPLE_RATE_MAP[self.sample_rate]],
AButter[SAMPLE_RATE_MAP[self.sample_rate]])
self.butter_filter_r = Filter(
BButter[SAMPLE_RATE_MAP[self.sample_rate]],
AButter[SAMPLE_RATE_MAP[self.sample_rate]])
def read(self, bytes):
#read in a bunch of floating point samples
(frame_list,self.leftover_samples) = audiotools.FrameList.from_samples(
self.leftover_samples + self.stream.read(bytes),
self.channels)
#convert them to a pair of floating-point channel lists
l_channel = frame_list.channel(0)
r_channel = frame_list.channel(1)
#run our channel lists through the Yule and Butter filters
l_channel = self.butter_filter_l.filter(
self.yule_filter_l.filter(l_channel))
r_channel = self.butter_filter_r.filter(
self.yule_filter_r.filter(r_channel))
#convert our channel lists back to integer samples
multiplier = 1 << (self.bits_per_sample - 1)
return audiotools.pcmstream.pcm_to_string(
audiotools.FrameList.from_channels(
([int(round(s * multiplier)) for s in l_channel],
[int(round(s * multiplier)) for s in r_channel])),
self.bits_per_sample / 8,
False)
#this takes a PCMReader-compatible object
#it yields FrameLists, each 50ms long (1/20th of a second)
#how many PCM frames that is varies depending on the sample rate
def replay_gain_blocks(pcmreader):
unhandled_samples = [] #partial PCM frames
frame_pool = audiotools.FrameList([],pcmreader.channels)
reader = audiotools.pcmstream.PCMStreamReader(pcmreader,
pcmreader.bits_per_sample / 8,
False,False)
(framelist,unhandled_samples) = audiotools.FrameList.from_samples(
unhandled_samples + reader.read(audiotools.BUFFER_SIZE),
pcmreader.channels)
while ((len(framelist) > 0) or (len(unhandled_samples) > 0)):
frame_pool.extend(framelist)
while (frame_pool.total_frames() >= (pcmreader.sample_rate / 20)):
yield audiotools.FrameList(
frame_pool[0:
((pcmreader.sample_rate / 20) * pcmreader.channels)],
pcmreader.channels)
frame_pool = audiotools.FrameList(
frame_pool[((pcmreader.sample_rate / 20) * pcmreader.channels):],
pcmreader.channels)
(framelist,unhandled_samples) = audiotools.FrameList.from_samples(
unhandled_samples + reader.read(audiotools.BUFFER_SIZE),
pcmreader.channels)
reader.close()
#this drops the last block that's not 50ms long
#that's probably the right thing to do
#takes a PCMReader-compatible object with 2 channels and a
#supported sample rate
#returns the stream's ReplayGain value in dB
def calculate_replay_gain(pcmstream):
import math
def __mean__(l):
return sum(l) / len(l)
pcmstream = EqualLoudnessFilter(pcmstream)
db_blocks = []
for block in replay_gain_blocks(pcmstream):
left = __mean__([s ** 2 for s in block.channel(0)])
right = __mean__([s ** 2 for s in block.channel(1)])
db_blocks.append((left + right) / 2)
db_blocks = [10 * math.log10(b + 10 ** -10) for b in db_blocks]
db_blocks.sort()
replay_gain = db_blocks[int(round(len(db_blocks) * 0.95))]
return PINK_REF - replay_gain
if (__name__ == '__main__'):
pass

Binary file not shown.

View File

@ -0,0 +1,246 @@
#!/usr/bin/python
#Audio Tools, a module and set of tools for manipulating audio data
#Copyright (C) 2008-2011 Brian Langenberger
#This program is free software; you can redistribute it and/or modify
#it under the terms of the GNU General Public License as published by
#the Free Software Foundation; either version 2 of the License, or
#(at your option) any later version.
#This program is distributed in the hope that it will be useful,
#but WITHOUT ANY WARRANTY; without even the implied warranty of
#MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
#GNU General Public License for more details.
#You should have received a copy of the GNU General Public License
#along with this program; if not, write to the Free Software
#Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
"""The TOC file handling module."""
import re
from audiotools import SheetException, parse_timestamp, build_timestamp
import gettext
gettext.install("audiotools", unicode=True)
###################
#TOC Parsing
###################
class TOCException(SheetException):
"""Raised by TOC file parsing errors."""
pass
def parse(lines):
"""Returns a TOCFile object from an iterator of lines.
Raises TOCException if some problem occurs parsing the file."""
TRACKLINE = re.compile(r'TRACK AUDIO')
lines = list(lines)
if ('CD_DA' not in [line.strip() for line in lines]):
raise TOCException(_(u"No CD_DA TOC header found"))
lines = iter(lines)
toc = TOCFile()
track = None
track_number = 0
line_number = 0
try:
while (True):
line_number += 1
line = lines.next().strip()
if (len(line) == 0):
pass
elif (TRACKLINE.match(line)):
if (track is not None):
toc.tracks[track.number] = track
track_number += 1
track = Track(track_number)
else:
if (track is not None):
track.lines.append(line)
if (line.startswith('FILE') or
line.startswith('AUDIOFILE')):
if ('"' in line):
track.indexes = map(
parse_timestamp,
re.findall(r'\d+:\d+:\d+|\d+',
line[line.rindex('"') + 1:]))
else:
track.indexes = map(
parse_timestamp,
re.findall(r'\d+:\d+:\d+|\d+',
line))
elif (line.startswith('START')):
track.start = parse_timestamp(line[len('START '):])
else:
toc.lines.append(line)
except StopIteration:
if (track is not None):
toc.tracks[track.number] = track
return toc
class TOCFile:
"""An object representing a TOC file."""
def __init__(self):
self.lines = []
self.tracks = {}
def __repr__(self):
return "TOCFile(lines=%s,tracks=%s)" % (repr(self.lines),
repr(self.tracks))
def catalog(self):
"""Returns the cuesheet's CATALOG number as a plain string, or None.
If present, this value is typically a CD's UPC code."""
for line in self.lines:
if (line.startswith('CATALOG')):
result = re.search(r'"(.+)"', line)
if (result is not None):
return result.group(1)
else:
continue
else:
return None
def indexes(self):
"""Yields a set of index lists, one for each track in the file."""
for track in sorted(self.tracks.values()):
if (track.start != 0):
yield (track.indexes[0], track.indexes[0] + track.start)
else:
yield (track.indexes[0],)
def pcm_lengths(self, total_length):
"""Yields a list of PCM lengths for all audio tracks within the file.
total_length is the length of the entire file in PCM frames."""
previous = None
for current in self.indexes():
if (previous is None):
previous = current
else:
track_length = (max(current) - max(previous)) * (44100 / 75)
total_length -= track_length
yield track_length
previous = current
yield total_length
def ISRCs(self):
"""Returns a track_number->ISRC dict of all non-empty tracks."""
return dict([(track.number, track.ISRC()) for track in
self.tracks.values() if track.ISRC() is not None])
@classmethod
def file(cls, sheet, filename):
"""Constructs a new TOC file string from a compatible object.
sheet must have catalog(), indexes() and ISRCs() methods.
filename is a string to the filename the TOC file is created for.
Although we don't care whether the filename points to a real file,
other tools sometimes do.
"""
import cStringIO
catalog = sheet.catalog() # a catalog string, or None
indexes = list(sheet.indexes()) # a list of index tuples
ISRCs = sheet.ISRCs() # a track_number->ISRC dict
data = cStringIO.StringIO()
data.write("CD_DA\n\n")
if ((catalog is not None) and (len(catalog) > 0)):
data.write("CATALOG \"%s\"\n\n" % (catalog))
for (i, (current, next)) in enumerate(zip(indexes,
indexes[1:] + [None])):
tracknum = i + 1
data.write("TRACK AUDIO\n")
if (tracknum in ISRCs.keys()):
data.write("ISRC \"%s\"\n" % (ISRCs[tracknum]))
if (next is not None):
data.write("AUDIOFILE \"%s\" %s %s\n" % \
(filename,
build_timestamp(current[0]),
build_timestamp(next[0] - current[0])))
else:
data.write("AUDIOFILE \"%s\" %s\n" % \
(filename,
build_timestamp(current[0])))
if (len(current) > 1):
data.write("START %s\n" % \
(build_timestamp(current[-1] - current[0])))
if (next is not None):
data.write("\n")
return data.getvalue()
class Track:
"""A track inside a TOCFile object."""
def __init__(self, number):
self.number = number
self.lines = []
self.indexes = []
self.start = 0
def __cmp__(self, t):
return cmp(self.number, t.number)
def __repr__(self):
return "Track(%s,lines=%s,indexes=%s,start=%s)" % \
(repr(self.number), repr(self.lines),
repr(self.indexes), repr(self.start))
def ISRC(self):
"""Returns the track's ISRC value, or None."""
for line in self.lines:
if (line.startswith('ISRC')):
match = re.search(r'"(.+)"', line)
if (match is not None):
return match.group(1)
else:
return None
def read_tocfile(filename):
"""Returns a TOCFile from a TOC filename on disk.
Raises TOCException if some error occurs reading or parsing the file.
"""
try:
f = open(filename, 'r')
except IOError, msg:
raise TOCException(str(msg))
try:
return parse(iter(f.readlines()))
finally:
f.close()

Binary file not shown.