2151 lines
83 KiB
Python
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()
|