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