Melodia/Melodia/resources/audiotools/__wavpack__.py

662 lines
24 KiB
Python

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