1# -*- coding: utf-8 -*-
2# Copyright 2015 Christoph Reiter
3#
4# This program is free software; you can redistribute it and/or modify
5# it under the terms of the GNU General Public License as published by
6# the Free Software Foundation; either version 2 of the License, or
7# (at your option) any later version.
8
9"""Standard MIDI File (SMF)"""
10
11import struct
12
13from mutagen import StreamInfo, MutagenError
14from mutagen._file import FileType
15from mutagen._util import loadfile
16from mutagen._compat import xrange, endswith
17
18
19class SMFError(MutagenError):
20    pass
21
22
23def _var_int(data, offset=0):
24    val = 0
25    while 1:
26        try:
27            x = data[offset]
28        except IndexError:
29            raise SMFError("Not enough data")
30        offset += 1
31        val = (val << 7) + (x & 0x7F)
32        if not (x & 0x80):
33            return val, offset
34
35
36def _read_track(chunk):
37    """Retuns a list of midi events and tempo change events"""
38
39    TEMPO, MIDI = range(2)
40
41    # Deviations: The running status should be reset on non midi events, but
42    # some files contain meta events inbetween.
43    # TODO: Offset and time signature are not considered.
44
45    tempos = []
46    events = []
47
48    chunk = bytearray(chunk)
49    deltasum = 0
50    status = 0
51    off = 0
52    while off < len(chunk):
53        delta, off = _var_int(chunk, off)
54        deltasum += delta
55        event_type = chunk[off]
56        off += 1
57        if event_type == 0xFF:
58            meta_type = chunk[off]
59            off += 1
60            num, off = _var_int(chunk, off)
61            # TODO: support offset/time signature
62            if meta_type == 0x51:
63                data = chunk[off:off + num]
64                if len(data) != 3:
65                    raise SMFError
66                tempo = struct.unpack(">I", b"\x00" + bytes(data))[0]
67                tempos.append((deltasum, TEMPO, tempo))
68            off += num
69        elif event_type in (0xF0, 0xF7):
70            val, off = _var_int(chunk, off)
71            off += val
72        else:
73            if event_type < 0x80:
74                # if < 0x80 take the type from the previous midi event
75                off += 1
76                event_type = status
77            elif event_type < 0xF0:
78                off += 2
79                status = event_type
80            else:
81                raise SMFError("invalid event")
82
83            if event_type >> 4 in (0xD, 0xC):
84                off -= 1
85
86            events.append((deltasum, MIDI, delta))
87
88    return events, tempos
89
90
91def _read_midi_length(fileobj):
92    """Returns the duration in seconds. Can raise all kind of errors..."""
93
94    TEMPO, MIDI = range(2)
95
96    def read_chunk(fileobj):
97        info = fileobj.read(8)
98        if len(info) != 8:
99            raise SMFError("truncated")
100        chunklen = struct.unpack(">I", info[4:])[0]
101        data = fileobj.read(chunklen)
102        if len(data) != chunklen:
103            raise SMFError("truncated")
104        return info[:4], data
105
106    identifier, chunk = read_chunk(fileobj)
107    if identifier != b"MThd":
108        raise SMFError("Not a MIDI file")
109
110    if len(chunk) != 6:
111        raise SMFError("truncated")
112
113    format_, ntracks, tickdiv = struct.unpack(">HHH", chunk)
114    if format_ > 1:
115        raise SMFError("Not supported format %d" % format_)
116
117    if tickdiv >> 15:
118        # fps = (-(tickdiv >> 8)) & 0xFF
119        # subres = tickdiv & 0xFF
120        # never saw one of those
121        raise SMFError("Not supported timing interval")
122
123    # get a list of events and tempo changes for each track
124    tracks = []
125    first_tempos = None
126    for tracknum in xrange(ntracks):
127        identifier, chunk = read_chunk(fileobj)
128        if identifier != b"MTrk":
129            continue
130        events, tempos = _read_track(chunk)
131
132        # In case of format == 1, copy the first tempo list to all tracks
133        first_tempos = first_tempos or tempos
134        if format_ == 1:
135            tempos = list(first_tempos)
136        events += tempos
137        events.sort()
138        tracks.append(events)
139
140    # calculate the duration of each track
141    durations = []
142    for events in tracks:
143        tempo = 500000
144        parts = []
145        deltasum = 0
146        for (dummy, type_, data) in events:
147            if type_ == TEMPO:
148                parts.append((deltasum, tempo))
149                tempo = data
150                deltasum = 0
151            else:
152                deltasum += data
153        parts.append((deltasum, tempo))
154
155        duration = 0
156        for (deltasum, tempo) in parts:
157            quarter, tpq = deltasum / float(tickdiv), tempo
158            duration += (quarter * tpq)
159        duration /= 10 ** 6
160
161        durations.append(duration)
162
163    # return the longest one
164    return max(durations)
165
166
167class SMFInfo(StreamInfo):
168    """SMFInfo()
169
170    Attributes:
171        length (`float`): Length in seconds
172
173    """
174
175    def __init__(self, fileobj):
176        """Raises SMFError"""
177
178        self.length = _read_midi_length(fileobj)
179
180    def pprint(self):
181        return u"SMF, %.2f seconds" % self.length
182
183
184class SMF(FileType):
185    """SMF(filething)
186
187    Standard MIDI File (SMF)
188
189    Attributes:
190        info (`SMFInfo`)
191        tags: `None`
192    """
193
194    _mimes = ["audio/midi", "audio/x-midi"]
195
196    @loadfile()
197    def load(self, filething):
198        try:
199            self.info = SMFInfo(filething.fileobj)
200        except IOError as e:
201            raise SMFError(e)
202
203    def add_tags(self):
204        raise SMFError("doesn't support tags")
205
206    @staticmethod
207    def score(filename, fileobj, header):
208        filename = filename.lower()
209        return header.startswith(b"MThd") and (
210            endswith(filename, ".mid") or endswith(filename, ".midi"))
211
212
213Open = SMF
214error = SMFError
215
216__all__ = ["SMF"]
217