Melodia/Melodia/resources/audiotools/__dvda__.py

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