Melodia/Melodia/resources/audiotools/__flac__.py

2151 lines
83 KiB
Python

#!/usr/bin/python
#Audio Tools, a module and set of tools for manipulating audio data
#Copyright (C) 2007-2011 Brian Langenberger
#This program is free software; you can redistribute it and/or modify
#it under the terms of the GNU General Public License as published by
#the Free Software Foundation; either version 2 of the License, or
#(at your option) any later version.
#This program is distributed in the hope that it will be useful,
#but WITHOUT ANY WARRANTY; without even the implied warranty of
#MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
#GNU General Public License for more details.
#You should have received a copy of the GNU General Public License
#along with this program; if not, write to the Free Software
#Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
from audiotools import (AudioFile, MetaData, InvalidFile, PCMReader,
Con, transfer_data, transfer_framelist_data,
subprocess, BIN, BUFFER_SIZE, cStringIO,
os, open_files, Image, sys, WaveAudio, AiffAudio,
ReplayGain, ignore_sigint, sheet_to_unicode,
EncodingError, UnsupportedChannelMask, DecodingError,
UnsupportedChannelCount, analyze_frames,
Messenger, BufferedPCMReader, calculate_replay_gain,
ChannelMask, PCMReaderError, __default_quality__,
WaveContainer, AiffContainer, to_pcm_progress)
from __vorbiscomment__ import *
from __id3__ import ID3v2Comment
from __vorbis__ import OggStreamReader, OggStreamWriter
import gettext
gettext.install("audiotools", unicode=True)
#######################
#FLAC
#######################
class InvalidFLAC(InvalidFile):
pass
class FlacMetaDataBlockTooLarge(Exception):
"""Raised if one attempts to build a FlacMetaDataBlock too large."""
pass
class FlacMetaDataBlock:
"""A container for FLAC metadata blocks."""
def __init__(self, type, data):
"""Initialized with a type integer and data binary string."""
self.type = type
self.data = data
def build_block(self, last=0):
"""Returns the entire block as a string, including header.
last is a bit indicating this is the last block before audio data.
Raises FlacMetaDataBlockTooLarge if data is too large to fit
in a single FLAC metadata block."""
if (len(self.data) > (1 << 24)):
raise FlacMetaDataBlockTooLarge()
return FlacAudio.METADATA_BLOCK_HEADER.build(
Con.Container(last_block=last,
block_type=self.type,
block_length=len(self.data))) + self.data
class FlacMetaData(MetaData):
"""A container for a FLAC file's list of metadata blocks."""
def __init__(self, blocks):
"""blocks is a list of plain FlacMetaDataBlock objects.
These are converted internally into MetaData/ImageMetaData fields
as needed, depending on the type.
"""
#IMPORTANT!
#Externally converted FlacMetaData likely won't have a valid STREAMINFO
#so set_metadata() must override this value with the current
#FLAC's streaminfo before setting the metadata blocks.
self.__dict__['streaminfo'] = None
#Don't use an external SEEKTABLE, either.
self.__dict__['seektable'] = None
self.__dict__['vorbis_comment'] = None
self.__dict__['cuesheet'] = None
self.__dict__['image_blocks'] = []
self.__dict__['extra_blocks'] = []
for block in blocks:
#metadata block data cannot exceed 2^24 bits
if (len(block.data) > (1 << 24)):
continue
if ((block.type == 0) and (self.streaminfo is None)):
#only one STREAMINFO allowed
self.__dict__['streaminfo'] = block
elif ((block.type == 4) and (self.vorbis_comment is None)):
#only one VORBIS_COMMENT allowed
comments = {}
comment_container = FlacVorbisComment.VORBIS_COMMENT.parse(
block.data)
for comment in comment_container.value:
try:
key = comment[0:comment.index("=")].upper()
value = comment[comment.index("=") + 1:].decode(
'utf-8')
comments.setdefault(key, []).append(value)
except ValueError:
pass
self.__dict__['vorbis_comment'] = FlacVorbisComment(
comments, comment_container.vendor_string)
elif ((block.type == 5) and (self.cuesheet is None)):
#only one CUESHEET allowed
self.__dict__['cuesheet'] = FlacCueSheet(
FlacCueSheet.CUESHEET.parse(block.data),
FlacAudio.STREAMINFO.parse(
self.streaminfo.data).samplerate)
elif ((block.type == 3) and (self.seektable is None)):
#only one SEEKTABLE allowed
self.__dict__['seektable'] = FlacSeektable(
[FlacSeekpoint(sample_number=point.sample_number,
byte_offset=point.byte_offset,
frame_samples=point.frame_samples)
for point in FlacSeektable.SEEKTABLE.parse(block.data)])
elif (block.type == 6):
#multiple PICTURE blocks are ok
image = FlacAudio.PICTURE_COMMENT.parse(block.data)
self.__dict__['image_blocks'].append(FlacPictureComment(
type=image.type,
mime_type=image.mime_type.decode('ascii', 'replace'),
description=image.description.decode('utf-8', 'replace'),
width=image.width,
height=image.height,
color_depth=image.color_depth,
color_count=image.color_count,
data=image.data))
elif (block.type != 1):
#everything but the padding is stored as extra
self.__dict__['extra_blocks'].append(block)
if (self.vorbis_comment is None):
self.vorbis_comment = FlacVorbisComment({})
def __comment_name__(self):
return u'FLAC'
def __comment_pairs__(self):
return self.vorbis_comment.__comment_pairs__()
def __unicode__(self):
if (self.cuesheet is None):
return MetaData.__unicode__(self)
else:
return u"%s%sCuesheet:\n%s" % (MetaData.__unicode__(self),
os.linesep * 2,
unicode(self.cuesheet))
def __setattr__(self, key, value):
# self.__dict__[key] = value
# setattr(self.vorbis_comment, key, value)
if (key in self.__FIELDS__):
setattr(self.vorbis_comment, key, value)
else:
self.__dict__[key] = value
def __getattr__(self, key):
if (key in self.__FIELDS__):
return getattr(self.vorbis_comment, key)
else:
try:
return self.__dict__[key]
except KeyError:
raise AttributeError(key)
def __delattr__(self, key):
if (key in self.__FIELDS__):
delattr(self.vorbis_comment, key)
else:
try:
del(self.__dict__[key])
except KeyError:
raise AttributeError(key)
@classmethod
def converted(cls, metadata):
"""Takes a MetaData object and returns a FlacMetaData object."""
if ((metadata is None) or (isinstance(metadata, FlacMetaData))):
return metadata
else:
blocks = []
try:
blocks.append(FlacMetaDataBlock(
type=4,
data=FlacVorbisComment.converted(metadata).build()))
except FlacMetaDataBlockTooLarge:
pass
for image in metadata.images():
try:
blocks.append(FlacMetaDataBlock(
type=6,
data=FlacPictureComment.converted(image).build()))
except FlacMetaDataBlockTooLarge:
pass
return FlacMetaData(blocks)
def merge(self, metadata):
"""Updates any currently empty entries from metadata's values."""
self.vorbis_comment.merge(metadata)
if (len(self.images()) == 0):
for image in metadata.images():
self.add_image(image)
def add_image(self, image):
"""Embeds an Image object in this metadata."""
self.__dict__['image_blocks'].append(
FlacPictureComment.converted(image))
def delete_image(self, image):
"""Deletes an Image object from this metadata."""
image_blocks = self.__dict__['image_blocks']
if (image in image_blocks):
image_blocks.pop(image_blocks.index(image))
def images(self):
"""Returns a list of embedded Image objects."""
return self.__dict__['image_blocks'][:]
def metadata_blocks(self):
"""Yields all current blocks as FlacMetaDataBlock-compatible objects.
Note that any padding block is not returned.
"""
yield self.streaminfo
yield self.vorbis_comment
if (self.seektable is not None):
yield self.seektable
if (self.cuesheet is not None):
yield self.cuesheet
for image in self.images():
yield image
for extra in self.extra_blocks:
yield extra
def build(self, padding_size=4096):
"""Returns all of a FLAC file's metadata as a binary string.
padding_size indicates the side of the PADDING block to append
(not counting its 32 bit header).
"""
built_blocks = []
blocks = self.metadata_blocks()
#STREAMINFO must always be first and is always a fixed size
built_blocks.append(blocks.next().build_block())
#then come the rest of the blocks in any order
for block in blocks:
try:
built_blocks.append(block.build_block())
except FlacMetaDataBlockTooLarge:
if (isinstance(block, VorbisComment)):
#if VORBISCOMMENT is too large, substitute a blank one
#(this only happens when one pushes over 16MB(!) of text
# into a comment, which simply isn't going to happen
# accidentcally)
built_blocks.append(FlacVorbisComment(
vorbis_data={},
vendor_string=block.vendor_string).build_block())
#finally, append a fresh PADDING block
built_blocks.append(
FlacMetaDataBlock(type=1,
data=chr(0) * padding_size).build_block(last=1))
return "".join(built_blocks)
@classmethod
def supports_images(cls):
"""Returns True."""
return True
class FlacVorbisComment(VorbisComment):
"""A slight variation of VorbisComment without the framing bit.
Also includes a build_block() method for FlacMetaDataBlock
compatiblity."""
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"))))
def build_block(self, last=0):
"""Returns the entire block as a string, including header.
last is a bit indicating this is the last block before audio data.
Raises FlacMetaDataBlockTooLarge if data is too large to fit
in a single FLAC metadata block."""
block = self.build()
if (len(block) > (1 << 24)):
raise FlacMetaDataBlockTooLarge()
return FlacAudio.METADATA_BLOCK_HEADER.build(
Con.Container(last_block=last,
block_type=4,
block_length=len(block))) + block
@classmethod
def converted(cls, metadata):
"""Converts metadata from another class to FlacVorbisComment."""
if ((metadata is None) or (isinstance(metadata, FlacVorbisComment))):
return metadata
elif (isinstance(metadata, FlacMetaData)):
return metadata.vorbis_comment
elif (isinstance(metadata, VorbisComment)):
return FlacVorbisComment(metadata, metadata.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 FlacVorbisComment(values)
class FlacPictureComment(Image):
"""This is a container for FLAC's PICTURE metadata blocks."""
def __init__(self, type, mime_type, description,
width, height, color_depth, color_count, data):
"""Initialization fields are as follows:
type - an integer type whose values are:
0 - front cover
1 - back cover
2 - leaflet page
3 - media
4 - other
mime_type - unicode string of the image's MIME type
description - a unicode string
width - width of image, as integer number of pixels
height - height of image, as integer number of pixels
color_depth - color depth of image (24 for JPEG, 8 for GIF, etc.)
color_count - number of palette colors, or 0
data - plain string of the actual binary image data
"""
Image.__init__(self,
data=data,
mime_type=mime_type,
width=width,
height=height,
color_depth=color_depth,
color_count=color_count,
description=description,
type={3: 0, 4: 1, 5: 2, 6: 3}.get(type, 4))
self.flac_type = type
@classmethod
def converted(cls, image):
"""Converts an Image object to a FlacPictureComment."""
return FlacPictureComment(
type={0: 3, 1: 4, 2: 5, 3: 6}.get(image.type, 0),
mime_type=image.mime_type,
description=image.description,
width=image.width,
height=image.height,
color_depth=image.color_depth,
color_count=image.color_count,
data=image.data)
def type_string(self):
"""Returns the image's type as a human readable plain string.
For example, an image of type 0 returns "Front Cover".
"""
return {0: "Other",
1: "File icon",
2: "Other file icon",
3: "Cover (front)",
4: "Cover (back)",
5: "Leaflet page",
6: "Media",
7: "Lead artist / lead performer / soloist",
8: "Artist / Performer",
9: "Conductor",
10: "Band / Orchestra",
11: "Composer",
12: "Lyricist / Text writer",
13: "Recording Location",
14: "During recording",
15: "During performance",
16: "Movie / Video screen capture",
17: "A bright colored fish",
18: "Illustration",
19: "Band/Artist logotype",
20: "Publisher / Studio logotype"}.get(self.flac_type, "Other")
def __repr__(self):
return ("FlacPictureComment(type=%s,mime_type=%s,description=%s," +
"width=%s,height=%s,...)") % \
(repr(self.flac_type), repr(self.mime_type),
repr(self.description),
repr(self.width), repr(self.height))
def build(self):
"""Returns a PICTURE comment as a raw string.
This does not include the 32 bit header."""
if (len(self.data) > (1 << 24)):
raise FlacMetaDataBlockTooLarge()
return FlacAudio.PICTURE_COMMENT.build(
Con.Container(type=self.flac_type,
mime_type=self.mime_type.encode('ascii'),
description=self.description.encode('utf-8'),
width=self.width,
height=self.height,
color_depth=self.color_depth,
color_count=self.color_count,
data=self.data))
def build_block(self, last=0):
"""Returns the entire block as a string, including header.
last is a bit indicating this is the last block before audio data.
Raises FlacMetaDataBlockTooLarge if data is too large to fit
in a single FLAC metadata block."""
block = self.build()
if (len(block) > (1 << 24)):
#why check both here and in build()?
#because while the raw image data itself might be small enough
#additional info like "description" could push it over
#the metadata block size limit
raise FlacMetaDataBlockTooLarge()
return FlacAudio.METADATA_BLOCK_HEADER.build(
Con.Container(last_block=last,
block_type=6,
block_length=len(block))) + block
class FlacCueSheet:
"""A container for FLAC CUESHEET metadata blocks."""
CUESHEET = Con.Struct(
"flac_cuesheet",
Con.String("catalog_number", 128),
Con.UBInt64("lead_in_samples"),
Con.Embed(Con.BitStruct("flags",
Con.Flag("is_cd"),
Con.Padding(7))), # reserved
Con.Padding(258), # reserved
Con.PrefixedArray(
length_field=Con.Byte("count"),
subcon=Con.Struct("cuesheet_tracks",
Con.UBInt64("track_offset"),
Con.Byte("track_number"),
Con.String("ISRC", 12),
Con.Embed(Con.BitStruct("sub_flags",
Con.Flag("non_audio"),
Con.Flag("pre_emphasis"),
Con.Padding(6))),
Con.Padding(13),
Con.PrefixedArray(
length_field=Con.Byte("count"),
subcon=Con.Struct("cuesheet_track_index",
Con.UBInt64("offset"),
Con.Byte("point_number"),
Con.Padding(3)))))) # reserved
def __init__(self, container, sample_rate=44100):
"""container is a Container object returned by CUESHEET.parse()."""
self.type = 5
self.container = container
self.sample_rate = sample_rate
def build_block(self, last=0):
"""Returns the entire block as a string, including header.
last is a bit indicating this is the last block before audio data."""
#the largest possible CUESHEET cannot exceed the metadata block size
#so no need to test for it
block = self.CUESHEET.build(self.container)
return FlacAudio.METADATA_BLOCK_HEADER.build(
Con.Container(last_block=last,
block_type=5,
block_length=len(block))) + block
@classmethod
def converted(cls, sheet, total_frames, sample_rate=44100):
"""Converts a cuesheet compatible object to FlacCueSheet objects.
A total_frames integer (in PCM frames) is also required.
"""
#number is the track number integer
#ISRC is a 12 byte string, or None
#indexes is a list of indexes()-compatible index points
#(i.e. given incrementally as CD frames)
#returns a Container
def track_container(number, ISRC, indexes):
if (ISRC is None):
ISRC = chr(0) * 12
if (len(indexes) == 1):
base_number = 1
else:
base_number = 0
return Con.Container(
track_offset=indexes[0] * sample_rate / 75,
track_number=number,
ISRC=ISRC,
non_audio=False,
pre_emphasis=False, # FIXME, check for this
cuesheet_track_index=[Con.Container(
offset=((index - indexes[0]) * sample_rate / 75),
point_number=point_number + base_number)
for (point_number, index) in
enumerate(indexes)])
catalog_number = sheet.catalog()
if (catalog_number is None):
catalog_number = ""
ISRCs = sheet.ISRCs()
return cls(Con.Container(
catalog_number=catalog_number + \
(chr(0) * (128 - len(catalog_number))),
lead_in_samples=sample_rate * 2,
is_cd=True,
cuesheet_tracks=[track_container(i + 1,
ISRCs.get(i + 1, None),
indexes)
for (i, indexes) in
enumerate(sheet.indexes())] + \
[Con.Container(track_offset=total_frames,
track_number=170,
ISRC=chr(0) * 12,
non_audio=False,
pre_emphasis=False,
cuesheet_track_index=[])]),
sample_rate)
def catalog(self):
"""Returns the cuesheet's catalog number as a plain string."""
if (len(self.container.catalog_number.rstrip(chr(0))) > 0):
return self.container.catalog_number.rstrip(chr(0))
else:
return None
def ISRCs(self):
"""Returns a list of ISRC values as plain strings."""
return dict([(track.track_number, track.ISRC) for track in
self.container.cuesheet_tracks
if ((track.track_number != 170) and
(len(track.ISRC.strip(chr(0))) > 0))])
def indexes(self):
"""Returns a list of (start, end) integer tuples."""
return [tuple([(index.offset + track.track_offset) * 75 / \
self.sample_rate
for index in
sorted(track.cuesheet_track_index,
lambda i1, i2: cmp(i1.point_number,
i2.point_number))])
for track in
sorted(self.container.cuesheet_tracks,
lambda t1, t2: cmp(t1.track_number,
t2.track_number))
if (track.track_number != 170)]
def pcm_lengths(self, total_length):
"""Returns a list of PCM lengths for all cuesheet audio tracks.
Note that the total length variable is only for compatibility.
It is not necessary for FlacCueSheets.
"""
if (len(self.container.cuesheet_tracks) > 0):
return [(current.track_offset +
max([i.offset for i in
current.cuesheet_track_index] + [0])) -
((previous.track_offset +
max([i.offset for i in
previous.cuesheet_track_index] + [0])))
for (previous, current) in
zip(self.container.cuesheet_tracks,
self.container.cuesheet_tracks[1:])]
else:
return []
def __unicode__(self):
return sheet_to_unicode(self, None)
class FlacSeektable:
SEEKTABLE = Con.GreedyRepeater(
Con.Struct("seekpoint",
Con.UBInt64("sample_number"),
Con.UBInt64("byte_offset"),
Con.UBInt16("frame_samples")))
def __init__(self, seekpoints):
self.seekpoints = seekpoints
def __repr__(self):
return "FlacSeektable(%s)" % (self.seekpoints)
def build_block(self, last=0):
seektable_data = FlacSeektable.SEEKTABLE.build(
[Con.Container(sample_number=point.sample_number,
byte_offset=point.byte_offset,
frame_samples=point.frame_samples)
for point in self.seekpoints])
return FlacAudio.METADATA_BLOCK_HEADER.build(
Con.Container(last_block=last,
block_type=3,
block_length=len(seektable_data))) + seektable_data
class FlacSeekpoint:
def __init__(self, sample_number, byte_offset, frame_samples):
self.sample_number = sample_number
self.byte_offset = byte_offset
self.frame_samples = frame_samples
def __repr__(self):
return "FLacSeekpoint(%s, %s, %s)" % \
(self.sample_number,
self.byte_offset,
self.frame_samples)
class FlacAudio(WaveContainer, AiffContainer):
"""A Free Lossless Audio Codec file."""
SUFFIX = "flac"
NAME = SUFFIX
DEFAULT_COMPRESSION = "8"
COMPRESSION_MODES = tuple(map(str, range(0, 9)))
COMPRESSION_DESCRIPTIONS = {"0": _(u"least amount of compresson, " +
u"fastest compression speed"),
"8": _(u"most amount of compression, " +
u"slowest compression speed")}
METADATA_BLOCK_HEADER = Con.BitStruct("metadata_block_header",
Con.Bit("last_block"),
Con.Bits("block_type", 7),
Con.Bits("block_length", 24))
STREAMINFO = Con.Struct("flac_streaminfo",
Con.UBInt16("minimum_blocksize"),
Con.UBInt16("maximum_blocksize"),
Con.Embed(Con.BitStruct("flags",
Con.Bits("minimum_framesize", 24),
Con.Bits("maximum_framesize", 24),
Con.Bits("samplerate", 20),
Con.Bits("channels", 3),
Con.Bits("bits_per_sample", 5),
Con.Bits("total_samples", 36))),
Con.StrictRepeater(16, Con.Byte("md5")))
PICTURE_COMMENT = Con.Struct("picture_comment",
Con.UBInt32("type"),
Con.PascalString(
"mime_type",
length_field=Con.UBInt32("mime_type_length")),
Con.PascalString(
"description",
length_field=Con.UBInt32("description_length")),
Con.UBInt32("width"),
Con.UBInt32("height"),
Con.UBInt32("color_depth"),
Con.UBInt32("color_count"),
Con.PascalString(
"data",
length_field=Con.UBInt32("data_length")))
def __init__(self, filename):
"""filename is a plain string."""
AudioFile.__init__(self, filename)
self.__samplerate__ = 0
self.__channels__ = 0
self.__bitspersample__ = 0
self.__total_frames__ = 0
try:
self.__read_streaminfo__()
except IOError, msg:
raise InvalidFLAC(str(msg))
except (Con.FieldError, Con.ArrayError):
raise InvalidFLAC("invalid STREAMINFO block")
@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."""
if (file.read(4) == 'fLaC'):
try:
block_ids = list(cls.__block_ids__(file))
except Con.FieldError:
return False
if ((len(block_ids) == 0) or (0 not in block_ids)):
messenger = Messenger("audiotools", None)
messenger.error(_(u"STREAMINFO block not found"))
elif (block_ids[0] != 0):
messenger = Messenger("audiotools", None)
messenger.error(_(u"STREAMINFO not first metadata block. " +
u"Please fix with tracklint(1)"))
else:
return True
else:
#I've seen FLAC files tagged with ID3v2 comments.
#Though the official flac binaries grudgingly accept these,
#such tags are unnecessary and outside the specification
#so I will encourage people to remove them.
try:
file.seek(-4, 1)
except IOError:
return False
ID3v2Comment.skip(file)
if (file.read(4) == 'fLaC'):
messenger = Messenger("audiotools", None)
messenger.error(_(u"ID3v2 tag found at start of FLAC file. " +
u"Please remove with tracklint(1)"))
return False
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:
vorbis_comment = self.get_metadata().vorbis_comment
if ("WAVEFORMATEXTENSIBLE_CHANNEL_MASK" in vorbis_comment.keys()):
try:
return ChannelMask(
int(vorbis_comment[
"WAVEFORMATEXTENSIBLE_CHANNEL_MASK"][0], 16))
except ValueError:
pass
#if there is no WAVEFORMATEXTENSIBLE_CHANNEL_MASK
#or it's not an integer, use FLAC's default mask based on channels
if (self.channels() == 3):
return ChannelMask.from_fields(
front_left=True, front_right=True, front_center=True)
elif (self.channels() == 4):
return ChannelMask.from_fields(
front_left=True, front_right=True,
back_left=True, back_right=True)
elif (self.channels() == 5):
return ChannelMask.from_fields(
front_left=True, front_right=True, front_center=True,
back_left=True, back_right=True)
elif (self.channels() == 6):
return ChannelMask.from_fields(
front_left=True, front_right=True, front_center=True,
back_left=True, back_right=True,
low_frequency=True)
else:
return ChannelMask(0)
def lossless(self):
"""Returns True."""
return True
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(4) != 'fLaC'):
raise InvalidFLAC(_(u'Invalid FLAC file'))
blocks = []
while (True):
header = FlacAudio.METADATA_BLOCK_HEADER.parse_stream(f)
blocks.append(FlacMetaDataBlock(
type=header.block_type,
data=f.read(header.block_length)))
if (header.last_block == 1):
break
return FlacMetaData(blocks)
finally:
f.close()
def set_metadata(self, metadata):
"""Takes a MetaData object and sets this track's metadata.
This metadata includes track name, album name, and so on.
Raises IOError if unable to write the file."""
metadata = FlacMetaData.converted(metadata)
if (metadata is None):
return
old_metadata = self.get_metadata()
#if metadata's STREAMINFO block matches old_metadata's STREAMINFO
#we're almost certainly setting a modified version
#of our original metadata
#in that case, we skip the metadata block porting
#and assume higher-level routines know what they're doing
if ((old_metadata.streaminfo is not None) and
(metadata.streaminfo is not None) and
(old_metadata.streaminfo.data == metadata.streaminfo.data)):
#do nothing
pass
else:
#port over the old STREAMINFO and SEEKTABLE blocks
old_streaminfo = old_metadata.streaminfo
old_seektable = old_metadata.seektable
metadata.streaminfo = old_streaminfo
if (old_seektable is not None):
metadata.seektable = old_seektable
#grab "WAVEFORMATEXTENSIBLE_CHANNEL_MASK" from existing file
#(if any)
if ((self.channels() > 2) or (self.bits_per_sample() > 16)):
metadata.vorbis_comment[
"WAVEFORMATEXTENSIBLE_CHANNEL_MASK"] = [
u"0x%.4x" % (int(self.channel_mask()))]
#APPLICATION blocks should stay with the existing file (if any)
metadata.extra_blocks = [block for block in metadata.extra_blocks
if (block.type != 2)]
for block in old_metadata.extra_blocks:
if (block.type == 2):
metadata.extra_blocks.append(block)
#always grab "vendor_string" from the existing file
vendor_string = old_metadata.vorbis_comment.vendor_string
metadata.vorbis_comment.vendor_string = vendor_string
minimum_metadata_length = len(metadata.build(padding_size=0)) + 4
current_metadata_length = self.metadata_length()
if ((minimum_metadata_length <= current_metadata_length) and
((current_metadata_length - minimum_metadata_length) <
(4096 * 2))):
#if the FLAC file's metadata + padding is large enough
#to accomodate the new chunk of metadata,
#simply overwrite the beginning of the file
stream = file(self.filename, 'r+b')
stream.write('fLaC')
stream.write(metadata.build(
padding_size=current_metadata_length - \
minimum_metadata_length))
stream.close()
else:
#if the new metadata is too large to fit in the current file,
#or if the padding gets unnecessarily large,
#rewrite the entire file using a temporary file for storage
import tempfile
stream = file(self.filename, 'rb')
if (stream.read(4) != 'fLaC'):
raise InvalidFLAC(_(u'Invalid FLAC file'))
block = FlacAudio.METADATA_BLOCK_HEADER.parse_stream(stream)
while (block.last_block == 0):
stream.seek(block.block_length, 1)
block = FlacAudio.METADATA_BLOCK_HEADER.parse_stream(stream)
stream.seek(block.block_length, 1)
file_data = tempfile.TemporaryFile()
transfer_data(stream.read, file_data.write)
file_data.seek(0, 0)
stream = file(self.filename, 'wb')
stream.write('fLaC')
stream.write(metadata.build())
transfer_data(file_data.read, stream.write)
file_data.close()
stream.close()
def metadata_length(self):
"""Returns the length of all FLAC metadata blocks as an integer.
This includes the 4 byte "fLaC" file header."""
f = file(self.filename, 'rb')
try:
if (f.read(4) != 'fLaC'):
raise InvalidFLAC(_(u'Invalid FLAC file'))
header = FlacAudio.METADATA_BLOCK_HEADER.parse_stream(f)
f.seek(header.block_length, 1)
while (header.last_block == 0):
header = FlacAudio.METADATA_BLOCK_HEADER.parse_stream(f)
f.seek(header.block_length, 1)
return f.tell()
finally:
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."""
self.set_metadata(MetaData())
@classmethod
def __read_flac_header__(cls, flacfile):
p = FlacAudio.METADATA_BLOCK_HEADER.parse(flacfile.read(4))
return (p.last_block, p.block_type, p.block_length)
@classmethod
def __block_ids__(cls, flacfile):
p = Con.Container(last_block=False,
block_type=None,
block_length=0)
while (not p.last_block):
p = FlacAudio.METADATA_BLOCK_HEADER.parse_stream(flacfile)
yield p.block_type
flacfile.seek(p.block_length, 1)
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."""
if (cuesheet is None):
return
metadata = self.get_metadata()
if (metadata is None):
metadata = FlacMetaData.converted(MetaData())
metadata.cuesheet = FlacCueSheet.converted(
cuesheet, self.total_frames(), self.sample_rate())
self.set_metadata(metadata)
def get_cuesheet(self):
"""Returns the embedded Cuesheet-compatible object, or None.
Raises IOError if a problem occurs when reading the file."""
metadata = self.get_metadata()
if (metadata is not None):
return metadata.cuesheet
else:
return None
def to_pcm(self):
"""Returns a PCMReader object containing the track's PCM data."""
from . import decoders
try:
return decoders.FlacDecoder(self.filename,
self.channel_mask())
except (IOError, ValueError), msg:
#The only time this is likely to occur is
#if the FLAC is modified between when FlacAudio
#is initialized and when to_pcm() is called.
return PCMReaderError(error_message=str(msg),
sample_rate=self.sample_rate(),
channels=self.channels(),
channel_mask=int(self.channel_mask()),
bits_per_sample=self.bits_per_sample())
@classmethod
def from_pcm(cls, filename, pcmreader, compression=None):
"""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 FlacAudio object."""
from . import encoders
if ((compression is None) or
(compression not in cls.COMPRESSION_MODES)):
compression = __default_quality__(cls.NAME)
encoding_options = {"0": {"block_size": 1152,
"max_lpc_order": 0,
"min_residual_partition_order": 0,
"max_residual_partition_order": 3},
"1": {"block_size": 1152,
"max_lpc_order": 0,
"adaptive_mid_side": True,
"min_residual_partition_order": 0,
"max_residual_partition_order": 3},
"2": {"block_size": 1152,
"max_lpc_order": 0,
"exhaustive_model_search": True,
"min_residual_partition_order": 0,
"max_residual_partition_order": 3},
"3": {"block_size": 4096,
"max_lpc_order": 6,
"min_residual_partition_order": 0,
"max_residual_partition_order": 4},
"4": {"block_size": 4096,
"max_lpc_order": 8,
"adaptive_mid_side": True,
"min_residual_partition_order": 0,
"max_residual_partition_order": 4},
"5": {"block_size": 4096,
"max_lpc_order": 8,
"mid_side": True,
"min_residual_partition_order": 0,
"max_residual_partition_order": 5},
"6": {"block_size": 4096,
"max_lpc_order": 8,
"mid_side": True,
"min_residual_partition_order": 0,
"max_residual_partition_order": 6},
"7": {"block_size": 4096,
"max_lpc_order": 8,
"mid_side": True,
"exhaustive_model_search": True,
"min_residual_partition_order": 0,
"max_residual_partition_order": 6},
"8": {"block_size": 4096,
"max_lpc_order": 12,
"mid_side": True,
"exhaustive_model_search": True,
"min_residual_partition_order": 0,
"max_residual_partition_order": 6}}[
compression]
if (pcmreader.channels > 8):
raise UnsupportedChannelCount(filename, pcmreader.channels)
if (int(pcmreader.channel_mask) == 0):
if (pcmreader.channels <= 6):
channel_mask = {1: 0x0004,
2: 0x0003,
3: 0x0007,
4: 0x0033,
5: 0x0037,
6: 0x003F}[pcmreader.channels]
else:
channel_mask = 0
elif (int(pcmreader.channel_mask) not in
(0x0001, # 1ch - mono
0x0004, # 1ch - mono
0x0003, # 2ch - left, right
0x0007, # 3ch - left, right, center
0x0033, # 4ch - left, right, back left, back right
0x0603, # 4ch - left, right, side left, side right
0x0037, # 5ch - L, R, C, back left, back right
0x0607, # 5ch - L, R, C, side left, side right
0x003F, # 6ch - L, R, C, LFE, back left, back right
0x060F)): # 6 ch - L, R, C, LFE, side left, side right
raise UnsupportedChannelMask(filename,
int(pcmreader.channel_mask))
else:
channel_mask = int(pcmreader.channel_mask)
try:
offsets = encoders.encode_flac(
filename,
pcmreader=BufferedPCMReader(pcmreader),
**encoding_options)
flac = FlacAudio(filename)
metadata = flac.get_metadata()
#generate SEEKTABLE from encoder offsets and add it to metadata
from bisect import bisect_right
metadata_length = flac.metadata_length()
seekpoint_interval = pcmreader.sample_rate * 10
total_samples = 0
all_frames = {}
sample_offsets = []
for (byte_offset, pcm_frames) in offsets:
all_frames[total_samples] = (byte_offset - metadata_length,
pcm_frames)
sample_offsets.append(total_samples)
total_samples += pcm_frames
seekpoints = []
for pcm_frame in xrange(0,
flac.total_frames(),
seekpoint_interval):
flac_frame = bisect_right(sample_offsets, pcm_frame) - 1
seekpoints.append(
FlacSeekpoint(
sample_number=sample_offsets[flac_frame],
byte_offset=all_frames[sample_offsets[flac_frame]][0],
frame_samples=all_frames[sample_offsets[flac_frame]][1]
))
metadata.seektable = FlacSeektable(seekpoints)
#if channels or bps is too high,
#automatically generate and add channel mask
if (((pcmreader.channels > 2) or
(pcmreader.bits_per_sample > 16)) and
(channel_mask != 0)):
metadata.vorbis_comment[
"WAVEFORMATEXTENSIBLE_CHANNEL_MASK"] = [
u"0x%.4x" % (channel_mask)]
flac.set_metadata(metadata)
return flac
except (IOError, ValueError), err:
cls.__unlink__(filename)
raise EncodingError(str(err))
except Exception, err:
cls.__unlink__(filename)
raise err
def has_foreign_riff_chunks(self):
"""Returns True if the audio file contains non-audio RIFF chunks.
During transcoding, if the source audio file has foreign RIFF chunks
and the target audio format supports foreign RIFF chunks,
conversion should be routed through .wav conversion
to avoid losing those chunks."""
return 'riff' in [block.data[0:4] for block in
self.get_metadata().extra_blocks
if block.type == 2]
def riff_wave_chunks(self, progress=None):
"""Generate a set of (chunk_id,chunk_data tuples)
These are for use by WaveAudio.from_chunks
and are taken from "riff" APPLICATION blocks
or generated from our PCM data."""
for application_block in [block.data for block in
self.get_metadata().extra_blocks
if (block.data.startswith("riff"))]:
(chunk_id, chunk_data) = (application_block[4:8],
application_block[12:])
if (chunk_id == 'RIFF'):
continue
elif (chunk_id == 'data'):
#FIXME - this is a lot more inefficient than it should be
data = cStringIO.StringIO()
pcm = to_pcm_progress(self, progress)
if (self.bits_per_sample > 8):
transfer_framelist_data(pcm, data.write, True, False)
else:
transfer_framelist_data(pcm, data.write, False, False)
pcm.close()
yield (chunk_id, data.getvalue())
data.close()
else:
yield (chunk_id, chunk_data)
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 (self.has_foreign_riff_chunks()):
WaveAudio.wave_from_chunks(wave_filename,
self.riff_wave_chunks(progress))
else:
WaveAudio.from_pcm(wave_filename, to_pcm_progress(self, progress))
@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 FlacAudio object."""
if ((compression is None) or
(compression not in cls.COMPRESSION_MODES)):
compression = __default_quality__(cls.NAME)
if (WaveAudio(wave_filename).has_foreign_riff_chunks()):
flac = cls.from_pcm(filename,
to_pcm_progress(WaveAudio(wave_filename),
progress),
compression=compression)
metadata = flac.get_metadata()
wav = file(wave_filename, 'rb')
try:
wav_header = wav.read(12)
metadata.extra_blocks.append(
FlacMetaDataBlock(2, "riff" + wav_header))
total_size = WaveAudio.WAVE_HEADER.parse(
wav_header).wave_size - 4
while (total_size > 0):
chunk_header = WaveAudio.CHUNK_HEADER.parse(wav.read(8))
if (chunk_header.chunk_id != 'data'):
metadata.extra_blocks.append(
FlacMetaDataBlock(2, "riff" +
WaveAudio.CHUNK_HEADER.build(
chunk_header) +
wav.read(
chunk_header.chunk_length)))
else:
metadata.extra_blocks.append(
FlacMetaDataBlock(2, "riff" +
WaveAudio.CHUNK_HEADER.build(
chunk_header)))
wav.seek(chunk_header.chunk_length, 1)
total_size -= (chunk_header.chunk_length + 8)
flac.set_metadata(metadata)
return flac
finally:
wav.close()
else:
return cls.from_pcm(filename,
to_pcm_progress(WaveAudio(wave_filename),
progress),
compression=compression)
def has_foreign_aiff_chunks(self):
"""Returns True if the audio file contains non-audio AIFF chunks."""
return 'aiff' in [block.data[0:4] for block in
self.get_metadata().extra_blocks
if block.type == 2]
@classmethod
def from_aiff(cls, filename, aiff_filename, compression=None,
progress=None):
"""Encodes a new AudioFile from an existing .aiff file.
Takes a filename string, aiff_filename string
of an existing AiffAudio file
and an optional compression level string.
Encodes a new audio file from the wave's data
at the given filename with the specified compression level
and returns a new FlacAudio object."""
if ((compression is None) or
(compression not in cls.COMPRESSION_MODES)):
compression = __default_quality__(cls.NAME)
if (AiffAudio(aiff_filename).has_foreign_aiff_chunks()):
flac = cls.from_pcm(filename,
to_pcm_progress(AiffAudio(aiff_filename),
progress),
compression=compression)
metadata = flac.get_metadata()
aiff = file(aiff_filename, 'rb')
try:
aiff_header = aiff.read(12)
metadata.extra_blocks.append(
FlacMetaDataBlock(2, "aiff" + aiff_header))
total_size = AiffAudio.AIFF_HEADER.parse(
aiff_header).aiff_size - 4
while (total_size > 0):
chunk_header = AiffAudio.CHUNK_HEADER.parse(aiff.read(8))
if (chunk_header.chunk_id != 'SSND'):
metadata.extra_blocks.append(
FlacMetaDataBlock(2, "aiff" +
AiffAudio.CHUNK_HEADER.build(
chunk_header) +
aiff.read(
chunk_header.chunk_length)))
else:
metadata.extra_blocks.append(
FlacMetaDataBlock(2, "aiff" +
AiffAudio.CHUNK_HEADER.build(
chunk_header) +
aiff.read(8)))
aiff.seek(chunk_header.chunk_length - 8, 1)
total_size -= (chunk_header.chunk_length + 8)
flac.set_metadata(metadata)
return flac
finally:
aiff.close()
else:
return cls.from_pcm(filename,
to_pcm_progress(AiffAudio(aiff_filename),
progress),
compression=compression)
def to_aiff(self, aiff_filename, progress=None):
if (self.has_foreign_aiff_chunks()):
AiffAudio.aiff_from_chunks(aiff_filename,
self.aiff_chunks(progress))
else:
AiffAudio.from_pcm(aiff_filename, to_pcm_progress(self, progress))
def aiff_chunks(self, progress=None):
"""Generate a set of (chunk_id,chunk_data tuples)
These are for use by AiffAudio.from_chunks
and are taken from "aiff" APPLICATION blocks
or generated from our PCM data."""
for application_block in [block.data for block in
self.get_metadata().extra_blocks
if (block.data.startswith("aiff"))]:
(chunk_id, chunk_data) = (application_block[4:8],
application_block[12:])
if (chunk_id == 'FORM'):
continue
elif (chunk_id == 'SSND'):
#FIXME - this is a lot more inefficient than it should be
data = cStringIO.StringIO()
data.write(chunk_data)
pcm = to_pcm_progress(self, progress)
transfer_framelist_data(pcm, data.write, True, True)
pcm.close()
yield (chunk_id, data.getvalue())
data.close()
else:
yield (chunk_id, chunk_data)
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 a FLAC has embedded RIFF *and* embedded AIFF chunks,
#RIFF takes precedence if the target format supports both.
#It's hard to envision a scenario in which that would happen.
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 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__
def __read_streaminfo__(self):
f = file(self.filename, "rb")
if (f.read(4) != "fLaC"):
raise InvalidFLAC(_(u"Not a FLAC file"))
(stop, header_type, length) = FlacAudio.__read_flac_header__(f)
if (header_type != 0):
raise InvalidFLAC(_(u"STREAMINFO not first metadata block"))
p = FlacAudio.STREAMINFO.parse(f.read(length))
md5sum = "".join(["%.2X" % (x) for x in p.md5]).lower()
self.__samplerate__ = p.samplerate
self.__channels__ = p.channels + 1
self.__bitspersample__ = p.bits_per_sample + 1
self.__total_frames__ = p.total_samples
self.__md5__ = "".join([chr(c) for c in p.md5])
f.close()
def seektable(self, pcm_frames):
"""Returns a new FlacSeektable block from this file's data."""
from bisect import bisect_right
def seekpoints(reader, metadata_length):
total_samples = 0
for frame in analyze_frames(reader):
yield (total_samples, frame['offset'] - metadata_length,
frame['block_size'])
total_samples += frame['block_size']
all_frames = dict([(point[0], (point[1], point[2]))
for point in seekpoints(self.to_pcm(),
self.metadata_length())])
sample_offsets = all_frames.keys()
sample_offsets.sort()
seekpoints = []
for pcm_frame in xrange(0, self.total_frames(), pcm_frames):
flac_frame = bisect_right(sample_offsets, pcm_frame) - 1
seekpoints.append(
FlacSeekpoint(
sample_number=sample_offsets[flac_frame],
byte_offset=all_frames[sample_offsets[flac_frame]][0],
frame_samples=all_frames[sample_offsets[flac_frame]][1]))
return FlacSeektable(seekpoints)
@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 (hasattr(metadata, "vorbis_comment")):
comment = metadata.vorbis_comment
comment["REPLAYGAIN_TRACK_GAIN"] = [
"%1.2f dB" % (track_gain)]
comment["REPLAYGAIN_TRACK_PEAK"] = [
"%1.8f" % (track_peak)]
comment["REPLAYGAIN_ALBUM_GAIN"] = [
"%1.2f dB" % (album_gain)]
comment["REPLAYGAIN_ALBUM_PEAK"] = ["%1.8f" % (album_peak)]
comment["REPLAYGAIN_REFERENCE_LOUDNESS"] = [u"89.0 dB"]
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."""
vorbis_metadata = self.get_metadata().vorbis_comment
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 __eq__(self, audiofile):
if (isinstance(audiofile, FlacAudio)):
return self.__md5__ == audiofile.__md5__
elif (isinstance(audiofile, AudioFile)):
try:
from hashlib import md5
except ImportError:
from md5 import new as md5
p = audiofile.to_pcm()
m = md5()
s = p.read(BUFFER_SIZE)
while (len(s) > 0):
m.update(s.to_bytes(False, True))
s = p.read(BUFFER_SIZE)
p.close()
return m.digest() == self.__md5__
else:
return False
def sub_pcm_tracks(self):
"""Yields a PCMReader object per cuesheet track."""
metadata = self.get_metadata()
if ((metadata is not None) and (metadata.cuesheet is not None)):
indexes = [(track.track_number,
[index.point_number for index in
sorted(track.cuesheet_track_index,
lambda i1, i2: cmp(i1.point_number,
i2.point_number))])
for track in
metadata.cuesheet.container.cuesheet_tracks]
if (len(indexes) > 0):
for ((cur_tracknum, cur_indexes),
(next_tracknum, next_indexes)) in zip(indexes,
indexes[1:]):
if (next_tracknum != 170):
cuepoint = "%s.%s-%s.%s" % (cur_tracknum,
max(cur_indexes),
next_tracknum,
max(next_indexes))
else:
cuepoint = "%s.%s-%s.0" % (cur_tracknum,
max(cur_indexes),
next_tracknum)
sub = subprocess.Popen([BIN['flac'], "-s", "-d", "-c",
"--force-raw-format",
"--endian=little",
"--sign=signed",
"--cue=%s" % (cuepoint),
self.filename],
stdout=subprocess.PIPE)
yield PCMReader(sub.stdout,
sample_rate=self.__samplerate__,
channels=self.__channels__,
bits_per_sample=self.__bitspersample__,
process=sub)
#######################
#Ogg FLAC
#######################
class OggFlacAudio(AudioFile):
"""A Free Lossless Audio Codec file inside an Ogg container."""
SUFFIX = "oga"
NAME = SUFFIX
DEFAULT_COMPRESSION = "8"
COMPRESSION_MODES = tuple(map(str, range(0, 9)))
COMPRESSION_DESCRIPTIONS = {"0": _(u"least amount of compresson, " +
u"fastest compression speed"),
"8": _(u"most amount of compression, " +
u"slowest compression speed")}
BINARIES = ("flac",)
OGGFLAC_STREAMINFO = Con.Struct('oggflac_streaminfo',
Con.Const(Con.Byte('packet_byte'),
0x7F),
Con.Const(Con.String('signature', 4),
'FLAC'),
Con.Byte('major_version'),
Con.Byte('minor_version'),
Con.UBInt16('header_packets'),
Con.Const(Con.String('flac_signature', 4),
'fLaC'),
Con.Embed(
FlacAudio.METADATA_BLOCK_HEADER),
Con.Embed(
FlacAudio.STREAMINFO))
def __init__(self, filename):
"""filename is a plain string."""
AudioFile.__init__(self, filename)
self.__samplerate__ = 0
self.__channels__ = 0
self.__bitspersample__ = 0
self.__total_frames__ = 0
try:
self.__read_streaminfo__()
except IOError, msg:
raise InvalidFLAC(str(msg))
except (Con.FieldError, Con.ArrayError):
raise InvalidFLAC("invalid STREAMINFO block")
@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:0x21] == '\x7FFLAC')
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__
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:
vorbis_comment = self.get_metadata().vorbis_comment
if ("WAVEFORMATEXTENSIBLE_CHANNEL_MASK" in vorbis_comment.keys()):
try:
return ChannelMask(
int(vorbis_comment[
"WAVEFORMATEXTENSIBLE_CHANNEL_MASK"][0], 16))
except ValueError:
pass
#if there is no WAVEFORMATEXTENSIBLE_CHANNEL_MASK
#or it's not an integer, use FLAC's default mask based on channels
if (self.channels() == 3):
return ChannelMask.from_fields(
front_left=True, front_right=True, front_center=True)
elif (self.channels() == 4):
return ChannelMask.from_fields(
front_left=True, front_right=True,
back_left=True, back_right=True)
elif (self.channels() == 5):
return ChannelMask.from_fields(
front_left=True, front_right=True, front_center=True,
back_left=True, back_right=True)
elif (self.channels() == 6):
return ChannelMask.from_fields(
front_left=True, front_right=True, front_center=True,
back_left=True, back_right=True,
low_frequency=True)
else:
return ChannelMask(0)
def lossless(self):
"""Returns True."""
return True
def get_metadata(self):
"""Returns a MetaData object, or None.
Raises IOError if unable to read the file."""
stream = OggStreamReader(file(self.filename, "rb"))
try:
packets = stream.packets()
blocks = [FlacMetaDataBlock(
type=0,
data=FlacAudio.STREAMINFO.build(
self.OGGFLAC_STREAMINFO.parse(packets.next())))]
while (True):
block = packets.next()
header = FlacAudio.METADATA_BLOCK_HEADER.parse(
block[0:FlacAudio.METADATA_BLOCK_HEADER.sizeof()])
blocks.append(
FlacMetaDataBlock(
type=header.block_type,
data=block[FlacAudio.METADATA_BLOCK_HEADER.sizeof():]))
if (header.last_block == 1):
break
return FlacMetaData(blocks)
finally:
stream.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."""
import tempfile
comment = FlacMetaData.converted(metadata)
#port over the old STREAMINFO and SEEKTABLE blocks
if (comment is None):
return
old_metadata = self.get_metadata()
old_streaminfo = old_metadata.streaminfo
old_seektable = old_metadata.seektable
comment.streaminfo = old_streaminfo
if (old_seektable is not None):
comment.seektable = old_seektable
#grab "vendor_string" from the existing file
vendor_string = old_metadata.vorbis_comment.vendor_string
comment.vorbis_comment.vendor_string = vendor_string
#grab "WAVEFORMATEXTENSIBLE_CHANNEL_MASK" from existing file
#(if any)
if ((self.channels() > 2) or (self.bits_per_sample() > 16)):
comment.vorbis_comment[
"WAVEFORMATEXTENSIBLE_CHANNEL_MASK"] = [
u"0x%.4x" % (int(self.channel_mask()))]
reader = OggStreamReader(file(self.filename, 'rb'))
new_file = tempfile.TemporaryFile()
writer = OggStreamWriter(new_file)
#grab the serial number from the old file's current header
pages = reader.pages()
(header_page, header_data) = pages.next()
serial_number = header_page.bitstream_serial_number
del(pages)
#skip the metadata packets in the old file
packets = reader.packets(from_beginning=False)
while (True):
block = packets.next()
header = FlacAudio.METADATA_BLOCK_HEADER.parse(
block[0:FlacAudio.METADATA_BLOCK_HEADER.sizeof()])
if (header.last_block == 1):
break
del(packets)
#write our new comment blocks to the new file
blocks = list(comment.metadata_blocks())
#oggflac_streaminfo is a Container for STREAMINFO data
#Ogg FLAC STREAMINFO differs from FLAC STREAMINFO,
#so some fields need to be filled-in
oggflac_streaminfo = FlacAudio.STREAMINFO.parse(blocks.pop(0).data)
oggflac_streaminfo.packet_byte = 0x7F
oggflac_streaminfo.signature = 'FLAC'
oggflac_streaminfo.major_version = 0x1
oggflac_streaminfo.minor_version = 0x0
oggflac_streaminfo.header_packets = len(blocks) + 1 # +1 for padding
oggflac_streaminfo.flac_signature = 'fLaC'
oggflac_streaminfo.last_block = 0
oggflac_streaminfo.block_type = 0
oggflac_streaminfo.block_length = FlacAudio.STREAMINFO.sizeof()
sequence_number = 0
for (page_header, page_data) in OggStreamWriter.build_pages(
0, serial_number, sequence_number,
OggFlacAudio.OGGFLAC_STREAMINFO.build(oggflac_streaminfo),
header_type=0x2):
writer.write_page(page_header, page_data)
sequence_number += 1
#the non-STREAMINFO blocks are the same as FLAC, so write them out
for block in blocks:
try:
for (page_header, page_data) in OggStreamWriter.build_pages(
0, serial_number, sequence_number,
block.build_block()):
writer.write_page(page_header, page_data)
sequence_number += 1
except FlacMetaDataBlockTooLarge:
if (isinstance(block, VorbisComment)):
#VORBISCOMMENT can't be skipped, so build an empty one
for (page_header,
page_data) in OggStreamWriter.build_pages(
0, serial_number, sequence_number,
FlacVorbisComment(
vorbis_data={},
vendor_string=block.vendor_string).build_block()):
writer.write_page(page_header, page_data)
sequence_number += 1
else:
pass
#finally, write out a padding block
for (page_header, page_data) in OggStreamWriter.build_pages(
0, serial_number, sequence_number,
FlacMetaDataBlock(type=1,
data=chr(0) * 4096).build_block(last=1)):
writer.write_page(page_header, page_data)
sequence_number += 1
#now write the rest of the old pages to the new file,
#re-sequenced and re-checksummed
for (page, data) in reader.pages(from_beginning=False):
page.page_sequence_number = sequence_number
page.checksum = OggStreamReader.calculate_ogg_checksum(page, data)
writer.write_page(page, data)
sequence_number += 1
reader.close()
#re-write the file with our new data in "new_file"
f = file(self.filename, "wb")
new_file.seek(0, 0)
transfer_data(new_file.read, f.write)
new_file.close()
f.close()
writer.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."""
self.set_metadata(MetaData())
def metadata_length(self):
"""Returns None."""
return None
def __read_streaminfo__(self):
stream = OggStreamReader(file(self.filename, "rb"))
try:
packets = stream.packets()
try:
header = self.OGGFLAC_STREAMINFO.parse(packets.next())
except Con.ConstError:
raise InvalidFLAC(_(u'Invalid Ogg FLAC streaminfo'))
except StopIteration:
raise InvalidFLAC(_(u'Invalid Ogg FLAC streaminfo'))
self.__samplerate__ = header.samplerate
self.__channels__ = header.channels + 1
self.__bitspersample__ = header.bits_per_sample + 1
self.__total_frames__ = header.total_samples
self.__header_packets__ = header.header_packets
self.__md5__ = "".join([chr(c) for c in header.md5])
del(packets)
finally:
stream.close()
def to_pcm(self):
"""Returns a PCMReader object containing the track's PCM data."""
sub = subprocess.Popen([BIN['flac'], "-s", "--ogg", "-d", "-c",
"--force-raw-format",
"--endian=little",
"--sign=signed",
self.filename],
stdout=subprocess.PIPE,
stderr=file(os.devnull, 'ab'))
return PCMReader(sub.stdout,
sample_rate=self.__samplerate__,
channels=self.__channels__,
bits_per_sample=self.__bitspersample__,
channel_mask=int(self.channel_mask()),
process=sub,
signed=True,
big_endian=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 OggFlacAudio object."""
SUBSTREAM_SAMPLE_RATES = frozenset([
8000, 16000, 22050, 24000, 32000,
44100, 48000, 96000])
SUBSTREAM_BITS = frozenset([8, 12, 16, 20, 24])
if ((compression is None) or
(compression not in cls.COMPRESSION_MODES)):
compression = __default_quality__(cls.NAME)
if ((pcmreader.sample_rate in SUBSTREAM_SAMPLE_RATES) and
(pcmreader.bits_per_sample in SUBSTREAM_BITS)):
lax = []
else:
lax = ["--lax"]
if (pcmreader.channels > 8):
raise UnsupportedChannelCount(filename, pcmreader.channels)
if (int(pcmreader.channel_mask) == 0):
if (pcmreader.channels <= 6):
channel_mask = {1: 0x0004,
2: 0x0003,
3: 0x0007,
4: 0x0033,
5: 0x0037,
6: 0x003F}[pcmreader.channels]
else:
channel_mask = 0
elif (int(pcmreader.channel_mask) not in
(0x0001, # 1ch - mono
0x0004, # 1ch - mono
0x0003, # 2ch - left, right
0x0007, # 3ch - left, right, center
0x0033, # 4ch - left, right, back left, back right
0x0603, # 4ch - left, right, side left, side right
0x0037, # 5ch - L, R, C, back left, back right
0x0607, # 5ch - L, R, C, side left, side right
0x003F, # 6ch - L, R, C, LFE, back left, back right
0x060F)): # 6ch - L, R, C, LFE, side left, side right
raise UnsupportedChannelMask(filename,
int(pcmreader.channel_mask))
else:
channel_mask = int(pcmreader.channel_mask)
devnull = file(os.devnull, 'ab')
sub = subprocess.Popen([BIN['flac']] + lax + \
["-s", "-f", "-%s" % (compression),
"-V", "--ogg",
"--endian=little",
"--channels=%d" % (pcmreader.channels),
"--bps=%d" % (pcmreader.bits_per_sample),
"--sample-rate=%d" % (pcmreader.sample_rate),
"--sign=signed",
"--force-raw-format",
"-o", 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:
raise EncodingError(err.error_message)
sub.stdin.close()
devnull.close()
if (sub.wait() == 0):
oggflac = OggFlacAudio(filename)
if (((pcmreader.channels > 2) or
(pcmreader.bits_per_sample > 16)) and
(channel_mask != 0)):
metadata = oggflac.get_metadata()
metadata.vorbis_comment[
"WAVEFORMATEXTENSIBLE_CHANNEL_MASK"] = [
u"0x%.4x" % (channel_mask)]
oggflac.set_metadata(metadata)
return oggflac
else:
raise EncodingError(u"error encoding file with flac")
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."""
if (cuesheet is None):
return
metadata = self.get_metadata()
if (metadata is None):
metadata = FlacMetaData.converted(MetaData())
metadata.cuesheet = FlacCueSheet.converted(
cuesheet, self.total_frames(), self.sample_rate())
self.set_metadata(metadata)
def get_cuesheet(self):
"""Returns the embedded Cuesheet-compatible object, or None.
Raises IOError if a problem occurs when reading the file."""
metadata = self.get_metadata()
if (metadata is not None):
return metadata.cuesheet
else:
return None
def sub_pcm_tracks(self):
"""Yields a PCMReader object per cuesheet track.
This currently does nothing since the FLAC reference
decoder has limited support for Ogg FLAC.
"""
return iter([])
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."""
from audiotools import verify_ogg_stream
#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 InvalidFLAC(str(err))
try:
try:
result = verify_ogg_stream(f)
if (progress is not None):
progress(1, 1)
return result
except (IOError, ValueError), err:
raise InvalidFLAC(str(err))
finally:
f.close()