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