1# -*- coding: utf-8 -*- 2# Copyright (C) 2014 Evan Purkhiser 3# 2014 Ben Ockmore 4# 5# This program is free software; you can redistribute it and/or modify 6# it under the terms of the GNU General Public License as published by 7# the Free Software Foundation; either version 2 of the License, or 8# (at your option) any later version. 9 10"""AIFF audio stream information and tags.""" 11 12import sys 13import struct 14from struct import pack 15 16from ._compat import endswith, text_type, reraise 17from mutagen import StreamInfo, FileType 18 19from mutagen.id3 import ID3 20from mutagen.id3._util import ID3NoHeaderError, error as ID3Error 21from mutagen._util import resize_bytes, delete_bytes, MutagenError, loadfile, \ 22 convert_error 23 24__all__ = ["AIFF", "Open", "delete"] 25 26 27class error(MutagenError): 28 pass 29 30 31class InvalidChunk(error): 32 pass 33 34 35# based on stdlib's aifc 36_HUGE_VAL = 1.79769313486231e+308 37 38 39def is_valid_chunk_id(id): 40 assert isinstance(id, text_type) 41 42 return ((len(id) <= 4) and (min(id) >= u' ') and 43 (max(id) <= u'~')) 44 45 46def assert_valid_chunk_id(id): 47 48 assert isinstance(id, text_type) 49 50 if not is_valid_chunk_id(id): 51 raise ValueError("AIFF key must be four ASCII characters.") 52 53 54def read_float(data): # 10 bytes 55 expon, himant, lomant = struct.unpack('>hLL', data) 56 sign = 1 57 if expon < 0: 58 sign = -1 59 expon = expon + 0x8000 60 if expon == himant == lomant == 0: 61 f = 0.0 62 elif expon == 0x7FFF: 63 f = _HUGE_VAL 64 else: 65 expon = expon - 16383 66 f = (himant * 0x100000000 + lomant) * pow(2.0, expon - 63) 67 return sign * f 68 69 70class IFFChunk(object): 71 """Representation of a single IFF chunk""" 72 73 # Chunk headers are 8 bytes long (4 for ID and 4 for the size) 74 HEADER_SIZE = 8 75 76 def __init__(self, fileobj, parent_chunk=None): 77 self.__fileobj = fileobj 78 self.parent_chunk = parent_chunk 79 self.offset = fileobj.tell() 80 81 header = fileobj.read(self.HEADER_SIZE) 82 if len(header) < self.HEADER_SIZE: 83 raise InvalidChunk() 84 85 self.id, self.data_size = struct.unpack('>4si', header) 86 87 try: 88 self.id = self.id.decode('ascii') 89 except UnicodeDecodeError: 90 raise InvalidChunk() 91 92 if not is_valid_chunk_id(self.id): 93 raise InvalidChunk() 94 95 self.size = self.HEADER_SIZE + self.data_size 96 self.data_offset = fileobj.tell() 97 98 def read(self): 99 """Read the chunks data""" 100 101 self.__fileobj.seek(self.data_offset) 102 return self.__fileobj.read(self.data_size) 103 104 def write(self, data): 105 """Write the chunk data""" 106 107 if len(data) > self.data_size: 108 raise ValueError 109 110 self.__fileobj.seek(self.data_offset) 111 self.__fileobj.write(data) 112 113 def delete(self): 114 """Removes the chunk from the file""" 115 116 delete_bytes(self.__fileobj, self.size, self.offset) 117 if self.parent_chunk is not None: 118 self.parent_chunk._update_size( 119 self.parent_chunk.data_size - self.size) 120 121 def _update_size(self, data_size): 122 """Update the size of the chunk""" 123 124 self.__fileobj.seek(self.offset + 4) 125 self.__fileobj.write(pack('>I', data_size)) 126 if self.parent_chunk is not None: 127 size_diff = self.data_size - data_size 128 self.parent_chunk._update_size( 129 self.parent_chunk.data_size - size_diff) 130 self.data_size = data_size 131 self.size = data_size + self.HEADER_SIZE 132 133 def resize(self, new_data_size): 134 """Resize the file and update the chunk sizes""" 135 136 resize_bytes( 137 self.__fileobj, self.data_size, new_data_size, self.data_offset) 138 self._update_size(new_data_size) 139 140 141class IFFFile(object): 142 """Representation of a IFF file""" 143 144 def __init__(self, fileobj): 145 self.__fileobj = fileobj 146 self.__chunks = {} 147 148 # AIFF Files always start with the FORM chunk which contains a 4 byte 149 # ID before the start of other chunks 150 fileobj.seek(0) 151 self.__chunks[u'FORM'] = IFFChunk(fileobj) 152 153 # Skip past the 4 byte FORM id 154 fileobj.seek(IFFChunk.HEADER_SIZE + 4) 155 156 # Where the next chunk can be located. We need to keep track of this 157 # since the size indicated in the FORM header may not match up with the 158 # offset determined from the size of the last chunk in the file 159 self.__next_offset = fileobj.tell() 160 161 # Load all of the chunks 162 while True: 163 try: 164 chunk = IFFChunk(fileobj, self[u'FORM']) 165 except InvalidChunk: 166 break 167 self.__chunks[chunk.id.strip()] = chunk 168 169 # Calculate the location of the next chunk, 170 # considering the pad byte 171 self.__next_offset = chunk.offset + chunk.size 172 self.__next_offset += self.__next_offset % 2 173 fileobj.seek(self.__next_offset) 174 175 def __contains__(self, id_): 176 """Check if the IFF file contains a specific chunk""" 177 178 assert_valid_chunk_id(id_) 179 180 return id_ in self.__chunks 181 182 def __getitem__(self, id_): 183 """Get a chunk from the IFF file""" 184 185 assert_valid_chunk_id(id_) 186 187 try: 188 return self.__chunks[id_] 189 except KeyError: 190 raise KeyError( 191 "%r has no %r chunk" % (self.__fileobj, id_)) 192 193 def __delitem__(self, id_): 194 """Remove a chunk from the IFF file""" 195 196 assert_valid_chunk_id(id_) 197 198 self.__chunks.pop(id_).delete() 199 200 def insert_chunk(self, id_): 201 """Insert a new chunk at the end of the IFF file""" 202 203 assert_valid_chunk_id(id_) 204 205 self.__fileobj.seek(self.__next_offset) 206 self.__fileobj.write(pack('>4si', id_.ljust(4).encode('ascii'), 0)) 207 self.__fileobj.seek(self.__next_offset) 208 chunk = IFFChunk(self.__fileobj, self[u'FORM']) 209 self[u'FORM']._update_size(self[u'FORM'].data_size + chunk.size) 210 211 self.__chunks[id_] = chunk 212 self.__next_offset = chunk.offset + chunk.size 213 214 215class AIFFInfo(StreamInfo): 216 """AIFFInfo() 217 218 AIFF audio stream information. 219 220 Information is parsed from the COMM chunk of the AIFF file 221 222 Attributes: 223 length (`float`): audio length, in seconds 224 bitrate (`int`): audio bitrate, in bits per second 225 channels (`int`): The number of audio channels 226 sample_rate (`int`): audio sample rate, in Hz 227 sample_size (`int`): The audio sample size 228 """ 229 230 length = 0 231 bitrate = 0 232 channels = 0 233 sample_rate = 0 234 235 @convert_error(IOError, error) 236 def __init__(self, fileobj): 237 """Raises error""" 238 239 iff = IFFFile(fileobj) 240 try: 241 common_chunk = iff[u'COMM'] 242 except KeyError as e: 243 raise error(str(e)) 244 245 data = common_chunk.read() 246 if len(data) < 18: 247 raise error 248 249 info = struct.unpack('>hLh10s', data[:18]) 250 channels, frame_count, sample_size, sample_rate = info 251 252 self.sample_rate = int(read_float(sample_rate)) 253 self.sample_size = sample_size 254 self.channels = channels 255 self.bitrate = channels * sample_size * self.sample_rate 256 self.length = frame_count / float(self.sample_rate) 257 258 def pprint(self): 259 return u"%d channel AIFF @ %d bps, %s Hz, %.2f seconds" % ( 260 self.channels, self.bitrate, self.sample_rate, self.length) 261 262 263class _IFFID3(ID3): 264 """A AIFF file with ID3v2 tags""" 265 266 def _pre_load_header(self, fileobj): 267 try: 268 fileobj.seek(IFFFile(fileobj)[u'ID3'].data_offset) 269 except (InvalidChunk, KeyError): 270 raise ID3NoHeaderError("No ID3 chunk") 271 272 @convert_error(IOError, error) 273 @loadfile(writable=True) 274 def save(self, filething=None, v2_version=4, v23_sep='/', padding=None): 275 """Save ID3v2 data to the AIFF file""" 276 277 fileobj = filething.fileobj 278 279 iff_file = IFFFile(fileobj) 280 281 if u'ID3' not in iff_file: 282 iff_file.insert_chunk(u'ID3') 283 284 chunk = iff_file[u'ID3'] 285 286 try: 287 data = self._prepare_data( 288 fileobj, chunk.data_offset, chunk.data_size, v2_version, 289 v23_sep, padding) 290 except ID3Error as e: 291 reraise(error, e, sys.exc_info()[2]) 292 293 new_size = len(data) 294 new_size += new_size % 2 # pad byte 295 assert new_size % 2 == 0 296 chunk.resize(new_size) 297 data += (new_size - len(data)) * b'\x00' 298 assert new_size == len(data) 299 chunk.write(data) 300 301 @loadfile(writable=True) 302 def delete(self, filething=None): 303 """Completely removes the ID3 chunk from the AIFF file""" 304 305 delete(filething) 306 self.clear() 307 308 309@convert_error(IOError, error) 310@loadfile(method=False, writable=True) 311def delete(filething): 312 """Completely removes the ID3 chunk from the AIFF file""" 313 314 try: 315 del IFFFile(filething.fileobj)[u'ID3'] 316 except KeyError: 317 pass 318 319 320class AIFF(FileType): 321 """AIFF(filething) 322 323 An AIFF audio file. 324 325 Arguments: 326 filething (filething) 327 328 Attributes: 329 tags (`mutagen.id3.ID3`) 330 info (`AIFFInfo`) 331 """ 332 333 _mimes = ["audio/aiff", "audio/x-aiff"] 334 335 @staticmethod 336 def score(filename, fileobj, header): 337 filename = filename.lower() 338 339 return (header.startswith(b"FORM") * 2 + endswith(filename, b".aif") + 340 endswith(filename, b".aiff") + endswith(filename, b".aifc")) 341 342 def add_tags(self): 343 """Add an empty ID3 tag to the file.""" 344 if self.tags is None: 345 self.tags = _IFFID3() 346 else: 347 raise error("an ID3 tag already exists") 348 349 @convert_error(IOError, error) 350 @loadfile() 351 def load(self, filething, **kwargs): 352 """Load stream and tag information from a file.""" 353 354 fileobj = filething.fileobj 355 356 try: 357 self.tags = _IFFID3(fileobj, **kwargs) 358 except ID3NoHeaderError: 359 self.tags = None 360 except ID3Error as e: 361 raise error(e) 362 else: 363 self.tags.filename = self.filename 364 365 fileobj.seek(0, 0) 366 self.info = AIFFInfo(fileobj) 367 368 369Open = AIFF 370