Source code for parker.notes

import math

from .constants import LOOKUP_FLATS
from .constants import LOOKUP_SHARPS
from .constants import NOTE_MATCHER
from .constants import NOTE_OFFSETS
from .constants import SIGN_FLAT
from .constants import SIGN_HALF_FLAT
from .constants import SIGN_HALF_SHARP
from .constants import SIGN_SHARP
from .constants import VALID_NOTES
from .mixins import Aug
from .mixins import AugmentDiminishMixin
from .mixins import CommonEqualityMixin
from .mixins import Dim
from .mixins import NotesMixin
from .mixins import TransposeMixin


def is_valid_note(note):
    """
    Determine if a note is valid from a given string representation.
    """
    m = NOTE_MATCHER.match(note)
    if m is not None:
        return True
    return False


[docs]class Accidental(CommonEqualityMixin): def __init__(self, acc=''): self._name = '' self._alter = 0.0 self.set_from_str(acc)
[docs] def set_from_str(self, acc): alter = 0.0 for char in acc: if char == SIGN_SHARP: alter += 1.0 elif char == SIGN_FLAT: alter -= 1.0 elif char == SIGN_HALF_SHARP: alter += 0.5 elif char == SIGN_HALF_FLAT: alter -= 0.5 self.set_from_num(alter)
[docs] def set_from_num(self, alter): self.alter = float(alter) full = '' if self.alter > 0: full = SIGN_SHARP * int(abs(math.floor(self.alter))) else: full = SIGN_FLAT * int(abs(math.ceil(self.alter))) half = '' if self._alter % 1: half = (SIGN_HALF_SHARP if self.alter > 0 else SIGN_HALF_FLAT) self.name = '{0}{1}'.format(full, half)
def __str__(self): return self.name def __repr__(self): return "{0}('{1}')".format(type(self).__name__, str(self)) def __int__(self): return int(self._alter) def __float__(self): return self._alter @property def name(self): return self._name @name.setter def name(self, value): self._name = value @property def alter(self): return self._alter @alter.setter def alter(self, value): self._alter = value
[docs] def set_augment(self): alter = self.alter + 1 self.set_from_num(alter) return self
[docs] def set_diminish(self): alter = self.alter - 1 self.set_from_num(alter) return self
[docs]class Note(TransposeMixin, CommonEqualityMixin, AugmentDiminishMixin): """Representation of a single note.""" _base_name = 'A' _octave = 4 _accidentals = 0 def __init__(self, note=None, use_sharps=True): self._set_note(note, use_sharps=use_sharps) def __str__(self): return "{0}{1}{2:d}".format(self._base_name, self.accidentals, self._octave) def __repr__(self): return "{0}('{1}')".format(type(self).__name__, str(self)) def __int__(self): return int(float(self)) def __float__(self): """ Return the SPN number related to this note. B# and Cb follow SPN rules such that B# is always in the octave below Cb and Cb is always in the octave above B#. Reference: - https://en.wikipedia.org/wiki/Scientific_pitch_notation """ result = (float(self._octave) + 1) * 12 result += float(NOTE_OFFSETS[self._base_name]) result += float(self.accidentals) return result def __eq__(self, other): if not isinstance(other, self.__class__): return False if self.__dict__ == other.__dict__: return True # Notes are identical if their integer value is the same regardless # of the base_name, accidentals, and octave. if float(self) == float(other): return True return False def __le__(self, other): if float(self) <= float(other): return True return False def __lt__(self, other): if float(self) < float(other): return True return False def __ge__(self, other): if float(self) >= float(other): return True return False def __gt__(self, other): if float(self) > float(other): return True return False def __add__(self, other): """ Addition is a function of semitones and simply returns the sum of the semitones of each Note object. """ return float(self) + float(other) def __sub__(self, other): """ Addition is a function of semitones and simply returns the difference of the semitones of each Note object. """ return float(self) - float(other) def _set_note(self, note, use_sharps): if isinstance(note, (int, float)): self._set_from_num(note, use_sharps) elif isinstance(note, str): self._set_from_string(note) elif isinstance(note, Note): self._set_from_note(note) elif note is None: self._set_from_string('A4') def _set_from_num(self, note, use_sharps=True): """ Set the Note from an integer representation Example: 60 should return middle C on a piano integer: 60 octave: 4 offset: 0 base_name: C accidentals: 0 """ self._octave = int((note // 12) - 1) offset = note - (self._octave + 1) * 12 lookup = LOOKUP_SHARPS if use_sharps else LOOKUP_FLATS # The lookup table only works from 0 - 11, so make sure # that the offset matches offset_remainder = offset % 1 offset = math.floor(offset % 12) self._base_name, acc_lookup = lookup[offset] if offset == 11 and self._base_name == 'C': self._octave += 1 self.accidentals = acc_lookup + offset_remainder def _set_from_string(self, note): """ Set the Note from a string representation Example: C4 should return middle C on a piano string: C4 octave: 4 offset: 0 base_name: C accidentals: 0 """ m = NOTE_MATCHER.match(note) if m is not None: name, accidentals, octave = m.group(1), m.group(2), m.group(3) self._base_name = name try: self._octave = int(octave) except (NameError, ValueError): self._octave = 4 self.accidentals = accidentals return raise Exception("Unknown note format: {0}".format(note)) def _set_from_note(self, note): """Set the Note from a Note object""" self._base_name = note._base_name self._octave = note._octave self.accidentals = note.accidentals @property def base_name(self): return self._base_name @base_name.setter def base_name(self, base_name): if base_name in VALID_NOTES: self._base_name = base_name else: raise Exception('Unkown base name: {0}'.format(base_name)) @property def accidentals(self): return self._accidentals @accidentals.setter def accidentals(self, accidentals): if isinstance(accidentals, Accidental): self._accidentals = accidentals elif isinstance(accidentals, (int, float)): acc = Accidental() acc.set_from_num(accidentals) self._accidentals = acc else: self._accidentals = Accidental(accidentals) @property def octave(self): return self._octave @octave.setter def octave(self, octave): if not isinstance(octave, int): try: octave = int(octave) except ValueError: raise Exception("Unknown octave format: {0}".format(octave)) if not (0 < octave < 9): raise Exception("Octave must be 0 through 9") self._octave = octave
[docs] def get_frequency(self, ndigits=None): """ Return the frequency of the note. This uses the forumula f = f0 * (a ** n) f0 - the reference frequency, which is A4 at 440 Hz a - the twelth root of 2, or 2 ** (1/12) n - the number of half steps between notes Should rounding be required you can set the number of digits to round to in the method. Reference: - http://www.phy.mtu.edu/~suits/NoteFreqCalcs.html - https://en.wikipedia.org/wiki/Scientific_pitch_notation - https://en.wikipedia.org/wiki/Music_and_mathematics """ reference = Note('A4') if self == reference: return 440.0 ref_freq = reference.get_frequency() steps = self - reference a = 2.0 ** (1.0 / 12.0) note_freq = ref_freq * (a ** steps) if isinstance(ndigits, int): return round(note_freq, ndigits) return note_freq
[docs] def generalize(self): """ Return the note without the octave component. Example: C4 -> C Cbb4 -> Cbb C###4 -> C### """ return "{0}{1}".format(self._base_name, self.accidentals)
[docs] def normalize(self, use_sharps=None): """ Return the note normalized and without the octave component. Set use_sharps to control the output. Example: C4 -> C Cbb4 -> Bb Cbb4 -> A# (use_sharps=True) C###4 -> D# C###4 => Eb (use_sharps=False) """ if use_sharps is None: if SIGN_FLAT in str(self): use_sharps = False else: use_sharps = True return Note(float(self), use_sharps).generalize()
[docs] def set_transpose(self, amount): """ Modify the note by a given number of semitones. In some instances the letters 't' or 'A' may be used to designate a change of 10 pitch classes. Similarly 'e' or 'B' may be used to designate a change of 11 pitch classes. References: - https://en.wikipedia.org/wiki/Pitch_class - https://en.wikipedia.org/wiki/List_of_chords """ transpose_amount = None if isinstance(amount, (int, float)): transpose_amount = amount elif isinstance(amount, str): if amount in 'tA': transpose_amount = 10 elif amount in 'eB': transpose_amount = 11 else: raise Exception("Cannot transpose from '{0}'".format(amount)) elif isinstance(amount, (Aug, Dim)): transpose_amount = amount.amount else: raise Exception("Cannot transpose from '{0}'".format(amount)) use_sharps = transpose_amount % 12 in [0, 2, 4, 7, 9, 11] self._set_from_num(float(self) + transpose_amount, use_sharps) # The amount to update could given by a Aug or Dim class if isinstance(amount, (Aug, Dim)): amount.update(self) return self
[docs] def set_augment(self): self.accidentals.set_augment() return self
[docs] def set_diminish(self): self.accidentals.set_diminish() return self
def note_from_frequency(freq): """ Return the closest note given a frequency value in Hz This uses the forumula f = f0 * (a ** n) f0 - the reference frequency, which is A4 at 440 Hz a - the twelth root of 2, or 2 ** (1/12) n - the number of half steps between notes Here we want the value of n, or the integer value of half steps distance from the reference note. n = log(f / f0) / log(a) This does not take into account out of tune notes. In the future it might make sense to return the cents above or below the note. """ reference = Note('A4') ref_freq = reference.get_frequency() a = 2.0 ** (1.0 / 12.0) steps = math.log(freq / ref_freq) / math.log(a) steps = int(round(steps)) note_int = reference + steps return Note(note_int)
[docs]class NotesParser(object): """ Parse notes of any type into a list of notes. Valid notes are: Note, NoteGroup, int, str, list, tuple, set. """ @staticmethod
[docs] def parse(notes): if isinstance(notes, Note): return [notes.clone()] elif isinstance(notes, NoteGroup): return notes.clone().notes elif isinstance(notes, (int, float, str)): return [Note(notes)] elif isinstance(notes, (list, tuple, set)): result = [] for n in notes: result.extend(NotesParser.parse(n)) return result elif notes is None: return [] raise Exception("Cannot parse notes: {0}".format(str(notes)))
[docs]class NoteGroupBase(TransposeMixin, CommonEqualityMixin, AugmentDiminishMixin, NotesMixin): """ Representation of a set of notes to be played at the same time. An example of a NoteGroup would be a chord (1, 3, 5) played on a piano. The base class does not let you add notes. """ root = None notes = []
[docs] def set_transpose(self, amount): return self.walk(lambda n: n.set_transpose(amount))
[docs] def set_augment(self): return self.walk(lambda n: n.set_augment())
[docs] def set_diminish(self): return self.walk(lambda n: n.set_diminish())
[docs] def get_notes(self): return sorted(self.notes, key=float)
def __getitem__(self, key): return self.notes[key] def __str__(self): return str(self.notes) def __repr__(self): return "{0}({1})".format(type(self).__name__, str(self)) def __len__(self): return len(self.notes)
[docs]class NoteGroup(NoteGroupBase): """ A mutable set of notes to be played at the same time. """ def __init__(self, notes=None): self.notes = [] self.add(notes) if len(self.notes) > 0: self.root = self.notes[0].clone()
[docs] def add(self, notes): self.notes.extend(NotesParser.parse(notes)) return self
[docs] def append(self, item): return self.add(item)