def __init__( self, signature: Union[Signature, Tuple[int, int]] = Signature(4, 4), content: Optional[Iterable[Tuple[Note, Union[Signature, Tuple[int, int], None]]]] = None, ): """ Initializes Track object. :param signature: time signature of the track (default: (4, 4)) :param content: optional content of the track as a list of pairs (note, position) (default: None) """ self._signature: Signature if isinstance(signature, Signature): self._signature = signature elif isinstance(signature, Iterable): self._signature = Signature(*signature) else: raise TypeError( 'signature must be a Signature object or a pair of integers') self._max_denominator = 1 self._heap: List[Tuple[Signature, Note]] = [] self._max_position: Signature = Signature(0, 1) self._sorted = True if content is not None: for note, position in content: self.add(note, where=position)
def load(self, file: BinaryIO, channel: Optional[int] = None) -> Track: """ Reads track from the MIDI file. MIDI file can contain up to 16 channels. If ``channel`` is specified, track from this channel is returned. If ``channel`` is omitted, track composed of all channels is returned. :param file: binary file-like object :param channel: channel to read from (default: None) :return: parsed track """ kind = None ticks_per_quarter = None tracks = None for chunk_kind, chunk_data in self._read_chunks(file): if chunk_kind == b'MThd': kind, n_tracks, division = self._parse_header(chunk_data) if kind != 0: raise MIDIParsingError(f'Unsupported MIDI file: format is {kind}') if n_tracks != 1: raise MIDIParsingError('Invalid MIDI file: multiple tracks in a single track file') if division > 0x7FFF: raise MIDIParsingError('Unsupported MIDI file: unsupported division') ticks_per_quarter = division if chunk_kind == b'MTrk': if ticks_per_quarter is None: raise MIDIParsingError('Invalid MIDI file: missing header chunk') tracks = self._parse_track(chunk_data, ticks_per_quarter) if kind is None: raise MIDIParsingError('Invalid MIDI file: missing header chunk') if tracks is None: raise MIDIParsingError('Invalid MIDI file: missing track chunk') if not tracks: return Track(signature=Signature(4, 4)) if channel is None: max_signature = Signature(0, 1) for track in tracks.values(): if track.signature > max_signature: max_signature = track.signature common_track = Track(signature=max_signature) for track in tracks.values(): for position, note in track: common_track.add(note, position) return common_track if channel not in tracks: raise ValueError(f'No channel {channel} found') return tracks[channel]
def add(self, what: Union[Note, Iterable[Note]], where: Union[Signature, Tuple[int, int], None] = None) -> None: """ Adds note or multiple notes to the track to the specified position. In the case of the multiple notes, all the notes will be placed in the same position. If position is not specified, note or notes will be placed at the end of the track (after the right-most ending of the note). :param what: one note or iterable of notes :param where: position where note or multiple notes will be placed (default: None) :return: None """ position: Signature if where is None: position = self._max_position elif isinstance(where, Signature): position = where elif isinstance(where, Iterable): position = Signature(*where) else: raise TypeError( 'where must be a Signature object or a pair of integers') if isinstance(what, Iterable): for x in what: self.add(what=x, where=position) return note: Note = what if position.denominator > self._max_denominator: self._max_denominator = position.denominator self._heap = [(s.to(self._max_denominator), n) for s, n in self._heap] position_transformed = position.to(self._max_denominator) heapq.heappush(self._heap, (position_transformed, note)) self._sorted = False end_position = position_transformed + note.duration if end_position > self._max_position: self._max_position = end_position
def __init__(self, tone: Union[Tone, int, str], duration: Union[Signature, Tuple[int, int]] = Signature(1, 4), velocity: float = 0.75): """ Initializes Note object. Tone can be a Tone object, an integer or a string. In the case of an integer, Tone constructor is called. In the case of a string, Tone.from_notation is called. Duration can be a Signature object or a pair of integers. In the case of a pair of integers Signature constructor is called. Velocity must be a float in the range [0.0, 1.0] where 0.0 is the minimum velocity and 1.0 is the maximum velocity. By default velocity is 0.75 (as in many DAWs). :param tone: tone of the note, must be Tone object, integer or string :param duration: duration of the note, must be Signature or pair of integers (default: (1, 4)) :param velocity: float number in the range [0.0, 1.0] (default: 0.75) """ self._tone: Tone if isinstance(tone, Tone): self._tone = tone elif isinstance(tone, int): self._tone = Tone(tone) elif isinstance(tone, str): self._tone = Tone.from_notation(tone) else: raise TypeError( 'tone must be a Tone object, an integer or a string') self._duration: Signature if isinstance(duration, Signature): self._duration = duration elif isinstance(duration, Iterable): self._duration = Signature(*duration) else: raise TypeError( 'duration must be a Signature object or a pair of integers') if not 0.0 <= velocity <= 1.0: raise ValueError('velocity must be in range [0.0, 1.0]') self._velocity: float = velocity
def _parse_track( self, data: _Reader, ticks_per_quarter: int ) -> Dict[int, Track]: previous_status = None def read_event(status: Optional[int] = None) -> _Event: nonlocal previous_status if status is None: status = MIDIReader._read_8(data) if status == 0xFF: previous_status = status # New meta event kind = MIDIReader._read_8(data) length = MIDIReader._read_var_len(data) if kind == 0x2F: # End of track if length != 0x00: raise MIDIParsingError(f'Invalid MIDI file: end of track event has length {length} != 0') return _EventEndOfTrack() elif kind == 0x20: # Meta channel if length != 0x01: raise MIDIParsingError(f'Invalid MIDI file: meta channel event has length {length} != 3') channel = MIDIReader._read_8(data) return _EventGlobalChannel(channel=channel) elif kind == 0x58: # Time signature if length != 0x04: raise MIDIParsingError(f'Invalid MIDI file: time signature event has length {length} != 3') nn = MIDIReader._read_8(data) dd = MIDIReader._read_8(data) cc = MIDIReader._read_8(data) bb = MIDIReader._read_8(data) return _EventTimeSignature(nn=nn, dd=dd, cc=cc, bb=bb) else: # Skip unsupported meta event body data.read(length) return _EventUnsupported() elif status >= 0x80: previous_status = status # New event kind = status >> 4 channel = status & 0xF if kind == 8: pitch = MIDIReader._read_8(data) velocity = MIDIReader._read_8(data) return _EventNoteOFF(channel=channel, pitch=pitch, velocity=velocity) elif kind == 9: pitch = MIDIReader._read_8(data) velocity = MIDIReader._read_8(data) if velocity == 0: return _EventNoteOFF(channel=channel, pitch=pitch, velocity=velocity) return _EventNoteON(channel=channel, pitch=pitch, velocity=velocity) elif status == 0xF0: # System exclusive value = 0 while value != 0xF7: value = data.read(1) return _EventUnsupported() else: length = _event_lengths[status] if length == -1: raise MIDIParsingError(f'Unknown status {status}') else: data.read(length) return _EventUnsupported() else: # Running status data.shift(-1) return read_event(status=previous_status) channel_events: Dict[int, List[Tuple[int, _Event]]] = defaultdict(list) channel_signatures: Dict[int, Signature] = {} global_channel: int = -1 last_time = 0 while True: delta_time = MIDIReader._read_var_len(data) absolute_time = last_time + delta_time last_time = absolute_time event = read_event() if isinstance(event, (_EventNoteON, _EventNoteOFF)): channel_events[event.channel].append((absolute_time, event)) continue if isinstance(event, _EventEndOfTrack): break if isinstance(event, _EventGlobalChannel): global_channel = event.channel continue if isinstance(event, _EventTimeSignature): channel_signatures[global_channel] = Signature(event.nn, 1 << event.dd) continue # In case there is only signature if not channel_events: channel_events[0].append((0, _EventUnsupported())) result: Dict[int, Track] = {} for channel, events in channel_events.items(): if channel in channel_signatures: signature = channel_signatures[channel] else: signature = channel_signatures.get(-1, Signature(4, 4)) track = Track(signature=signature) running_notes: Dict[int, Tuple[int, _EventNoteON]] = {} for absolute_time, event in events: if isinstance(event, _EventNoteON): if event.pitch in running_notes: continue running_notes[event.pitch] = (absolute_time, event) continue if isinstance(event, _EventNoteOFF): if event.pitch not in running_notes: continue note_beginning, note_on = running_notes[event.pitch] del running_notes[event.pitch] tone: Tone = self._pitch_to_tone(event.pitch) duration: Signature = self._time_to_signature( absolute_time - note_beginning, ticks_per_quarter=ticks_per_quarter ) velocity: float = max(0.0, min(1.0, note_on.velocity / 127.0)) position = self._time_to_signature( note_beginning, ticks_per_quarter=ticks_per_quarter ) track.add(Note(tone, duration, velocity), position) continue result[channel] = track return result
def _time_to_signature(time: int, ticks_per_quarter: int) -> Signature: assert ticks_per_quarter > 0, 'ticks_per_quarter must be positive' return Signature(time * 4096 // ticks_per_quarter, 4 * 4096).normalized()
def _signature_to_pulses(self, signature: Signature) -> int: normalized = signature.normalized() return self._pulses_per_whole * normalized.nominator // normalized.denominator