import re
import collections
from dataclasses import dataclass, field
from fractions import Fraction
from chiptunesak.errors import *
from chiptunesak import constants, key
# Named tuple types for several lists throughout
TimeSignatureEvent = collections.namedtuple('TimeSignature', ['start_time', 'num', 'denom'])
KeySignatureEvent = collections.namedtuple('KeySignature', ['start_time', 'key'])
TempoEvent = collections.namedtuple('Tempo', ['start_time', 'qpm'])
OtherMidiEvent = collections.namedtuple('OtherMidi', ['start_time', 'msg'])
ProgramEvent = collections.namedtuple('Program', ['start_time', 'program'])
Beat = collections.namedtuple('Beat', ['start_time', 'measure', 'beat'])
Rest = collections.namedtuple('Rest', ['start_time', 'duration'])
MeasureMarker = collections.namedtuple('MeasureMarker', ['start_time', 'measure_number'])
@dataclass
class SongMetadata:
ppq: int = constants.DEFAULT_MIDI_PPQN #: PPQ = Pulses Per Quarter = ticks/quarter note
name: str = '' #: Song name
composer: str = '' #: Composer
copyright: str = '' #: Copyright statement
time_signature: TimeSignatureEvent = TimeSignatureEvent(0, 4, 4) #: Starting time signature
key_signature: KeySignatureEvent = KeySignatureEvent(0, key.ChirpKey('C')) #: Starting key signature
qpm: int = 112 #: Tempo in Quarter Notes per Minute (QPM)
extensions: dict = field(default_factory=dict) #: Allows arbitrary state to be passed
[docs]class Triplet:
def __init__(self, start_time=0, duration=0):
self.start_time = start_time #: Start time for the triplet as a whole
self.duration = duration #: Duration for the entire triplet
self.content = [] #: The notes that go inside the triplet
class ChiptuneSAKBase:
@classmethod
def cts_type(cls):
return 'ChiptuneSAKBase'
def __init__(self):
self._options = {}
def get_option(self, arg, default=None):
"""
Get an option
:param arg: option name
:type arg: str
:param default: default value
:type default: type of option
:return: value of option
:rtype: option type
"""
if arg in self._options:
return self._options[arg]
return default
def get_options(self):
"""
Get a dictionary of all current options
:return: options
:rtype: dict
"""
return self._options
def set_options(self, **kwargs):
"""
Set options. All option keywords are converted to lowercase.
:param kwargs: options
:type kwargs: keyword options
"""
for op, val in kwargs.items():
self._options[op.lower()] = val
class ChiptuneSAKIR(ChiptuneSAKBase):
@classmethod
def cts_type(cls):
return 'IR'
def __init__(self):
ChiptuneSAKBase.__init__(self)
def to_chirp(self, **kwargs):
"""
Converts a song to Chirp IR
:param kwargs: Keyword options for the particular IR conversion
:return: chirp song
:rtype: ChirpSong
"""
raise ChiptuneSAKNotImplemented("Conversion to Chirp not implemented")
def to_mchirp(self, **kwargs):
"""
Converts a song to MChirp IR
:param kwargs: Keyword options for the particular IR conversion
:return: chirp song
:rtype: MChirpSong
"""
raise ChiptuneSAKNotImplemented("Conversion to MChirp not implemented")
def to_rchirp(self, **kwargs):
"""
Converts a song to RChirp IR
:param kwargs: Keyword options for the particular IR conversion
:return: chirp song
:rtype: rchirp.RChirpSong
"""
raise ChiptuneSAKNotImplemented("Conversion to RChirp not implemented")
[docs]class ChiptuneSAKIO(ChiptuneSAKBase):
@classmethod
def cts_type(cls):
return 'IO'
def __init__(self):
ChiptuneSAKBase.__init__(self)
[docs] def to_chirp(self, filename, **kwargs):
"""
Imports a file into a ChirpSong
:param filename: filename to import
:type filename: str
:param kwargs: Keyword options for the particular I/O class
:return: Chirp song
:rtype: ChirpSong object
"""
raise ChiptuneSAKNotImplemented(f"Not implemented")
[docs] def to_rchirp(self, filename, **kwargs):
"""
Imports a file into an RChirpSong
:param filename: filename to import
:type filename: str
:param kwargs: Keyword options for the particular I/O class
:return: RChirp song
:rtype: rchirp.RChirpSong object
"""
raise ChiptuneSAKNotImplemented(f"Not implemented")
[docs] def to_mchirp(self, filename, **kwargs):
"""
Imports a file into a ChirpSong
:param filename: filename to import
:type filename: str
:param kwargs: Keyword options for the particular I/O class
:return: MChirp song
:rtype: MChirpSong object
"""
raise ChiptuneSAKNotImplemented(f"Not implemented")
[docs] def to_bin(self, ir_song, **kwargs):
"""
Outputs a song into the desired binary format (which may be ASCII text)
:param ir_song: song to export
:type ir_song: ChirpSong, MChirpSong, or RChirpSong
:param kwargs: Keyword options for the particular I/O class
:return: binary
:rtype: either str or bytearray, depending on the output
"""
raise ChiptuneSAKNotImplemented(f"Not implemented for type {ir_song.cts_type()}")
[docs] def to_file(self, ir_song, filename, **kwargs):
"""
Writes a song to a file
:param ir_song: song to export
:type ir_song: ChirpSong, MChirpSong, or RChirpSong
:param filename: Name of output file
:type filename: str
:param kwargs: Keyword options for the particular I/O class
:return: True on success
:rtype: bool
"""
raise ChiptuneSAKNotImplemented(f"Not implemented for type {ir_song.cts_type()}")
class ChiptuneSAKCompress(ChiptuneSAKBase):
@classmethod
def cts_type(cls):
return 'Compress'
def __init__(self):
ChiptuneSAKBase.__init__(self)
def compress(self, rchirp_song, **kwargs):
"""
Compresses an rchirp song
:param rchirp_song: song to compress
:type rchirp_song: rchirp.RChirpSong
:param kwargs: Keyword options for the particular compression class
:return: rchirp_song with compression
:rtype: rchirp.RChirpSong
"""
raise ChiptuneSAKNotImplemented(f"Not implemented")
# --------------------------------------------------------------------------------------
#
# Utility functions
#
# --------------------------------------------------------------------------------------
def duration_to_note_name(duration, ppq, locale='US'):
"""
Given a ppq (pulses per quarter note) convert a duration to a human readable note length,
e.g., 'eighth'
Works for notes, dotted notes, and triplets down to sixty-fourth notes.
:param duration: a duration in ticks
:type duration: int
:param ppq: pulses per quarter note (e.g. 960)
:type ppq: int
:param locale: 'US' or 'UK'
:type locale: str
:return: note description
:rtype: str
"""
f = Fraction(duration / ppq).limit_denominator(64)
return constants.DURATIONS[locale.upper()].get(f, '<unknown>')
def pitch_to_note_name(note_num, octave_offset=0):
"""
Gets note name for a given MIDI pitch
:param note_num: a midi note number
:type note_num: int
:param octave_offset: value that shifts one or more octaves up or down
:type octave_offset: int
:return: string representation of note and octave
:rtype: str
"""
if not 0 <= note_num <= 127:
raise ChiptuneSAKValueError("Illegal note number %d" % note_num)
octave = (note_num // 12) + octave_offset - 1
pitch = note_num % 12
return "%s%d" % (constants.PITCHES[pitch], octave)
# Regular expression for matching note names
note_name_format = re.compile('^([A-G])(#|##|b|bb)?(-{0,1}[0-7])$')
def note_name_to_pitch(note_name, octave_offset=0):
"""
Returns MIDI note number for a named pitch. C4 = 60
Includes processing of enharmonic notes (double sharps or double flats)
:param note_name: A note name as a string, e.g. C#4
:type note_name: str
:param octave_offset: Octave offset
:type octave_offset: int
:return: Midi note number
:rtype: int
"""
if note_name_format.match(note_name) is None:
raise ChiptuneSAKValueError('Illegal note name: "%s"' % note_name)
m = note_name_format.match(note_name)
note_name = m.group(1)
accidentals = m.group(2)
octave = int(m.group(3)) - octave_offset + 1
note_num = constants.PITCHES.index(note_name) + 12 * octave
if accidentals is not None:
note_num += accidentals.count('#')
note_num -= accidentals.count('b')
return note_num
def decompose_duration(duration, ppq, allowed_durations):
"""
Decomposes a given duration into a sum of allowed durations.
This function uses a greedy algorithm, which iteratively finds the largest allowed duration shorter than
the remaining duration and subtracts it from the remaining duration
:param duration: Duration to be decomposed, in ticks.
:type duration: int
:param ppq: Ticks per quarter note.
:type ppq: int
:param allowed_durations: Dictionary of allowed durations. Allowed durations are expressed as fractions
of a quarter note.
:type allowed_durations: Dictionary (or set) of Fractions
:return: List of decomposed durations
:rtype: list of Fraction
"""
ret_durations = []
min_allowed_duration = min(allowed_durations)
remainder = duration
while remainder > 0:
if remainder < min_allowed_duration * ppq:
raise ChiptuneSAKValueError("Illegal note duration %d" % duration)
for d in sorted(allowed_durations, reverse=True):
if remainder >= d * ppq:
ret_durations.append(d)
remainder -= d * ppq
break
return ret_durations
def is_triplet(note, ppq):
"""
Determine if note is a triplet, which is true if the note length divided by the quarter-note length has a
denominator divisible by 3
:param note: note
:type note: chirp.Note
:param ppq: ppq
:type ppq: int
:return: True of the note is a triplet type
:rtype: bool
"""
f = Fraction(note.duration / ppq).limit_denominator(16)
if f.denominator % 3 == 0:
return True
return False
def start_beat_type(time, ppq):
"""
Gets the beat type that would have to be used to make this note an integral number of beats
from the start of the measure
:param time: Time in ticks from the start of the measure.
:type time: int
:param ppq: ppq for the song
:type ppq: int
:return: Denominator that would have to be used to make this note an integral number of beats
from the start of the measure. If the note is a triplet not starting on the beat it
will be a multiple of 3.
:rtype: int
"""
f = Fraction(time, ppq).limit_denominator(32)
return f.denominator