Music Theory 2018-05-14
In this post:
- I changed one field type.
- I rewrote the rest of the dang code.
Okay, step one of this rewrite: change "tied_to_previous" into an enum-valued field.
I then... kind of just went through the whole rewrite. Highlights include moving analysis logic down into lower-level classes, adding a higher-level validator, adding helper methods for the validators, and fully handling rests.
Here's where things stand:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 | """Data structures for representing note durations."""
import enum
import fractions
import typing
class NoteType(enum.Enum):
"""Enum of simple note values."""
START = enum.auto()
TIED = enum.auto()
REST = enum.auto()
def clap(self, beat_string: str) -> str:
"""Return the beat notation for this note type, given beat number."""
if self == NoteType.START:
return 'CLAP'
elif self == NoteType.TIED:
return beat_string
assert self == NoteType.REST
return f'({beat_string})'
class NoteDuration(typing.NamedTuple):
"""Class representing a single note as written."""
base_duration: fractions.Fraction = fractions.Fraction(1)
augmentation_dots: int = 0
note_type: NoteType = NoteType.START
@property
def full_duration(self):
"""Return the duration of the note, after augmentation."""
fraction_of_whole = self.base_duration
fraction_to_add = fraction_of_whole
for _ in range(self.augmentation_dots):
fraction_to_add /= 2
fraction_of_whole += fraction_to_add
return fraction_of_whole
def beats_as_written(self, beats_per_whole: int = 4) -> fractions.Fraction:
"""Return the number of beats in the note, given number in whole."""
return self.full_duration * beats_per_whole
@classmethod
def per_whole(cls, beats_per_whole: int):
"""Return the note corresponding to given division of a whole note."""
return cls(fractions.Fraction(1, beats_per_whole))
def subdivide(
self,
beats_per_whole: int,
offset: fractions.Fraction
) -> typing.Tuple[typing.List['NoteDuration'], fractions.Fraction]:
"""Convert this note into beat-aligned notes tied together."""
notes = []
duration = self.beats_as_written(beats_per_whole)
remaining = fractions.Fraction(1) - offset
first_duration = min(remaining, duration)
notes.append(self._replace( # pylint: disable=no-member
base_duration=first_duration / beats_per_whole,
augmentation_dots=0))
if self.note_type == NoteType.REST:
note_type = NoteType.REST
else:
note_type = NoteType.TIED
offset += first_duration
duration -= first_duration
while duration:
offset = min(offset, duration)
notes.append(NoteDuration(
base_duration=offset / beats_per_whole,
note_type=note_type))
duration -= offset
if offset == 1:
offset = fractions.Fraction()
return notes, offset
def as_tied(self):
"""Return this note, but tied to the previous note."""
# pylint: disable=no-member
return self._replace(note_type=NoteType.TIED)
def as_rest(self):
"""Return this note, but as a rest."""
# pylint: disable=no-member
return self._replace(note_type=NoteType.REST)
BREVE = NoteDuration(fractions.Fraction(2))
WHOLE = NoteDuration()
# pylint doesn't know how classes work, sometimes.
HALF = NoteDuration.per_whole(2) # pylint: disable=no-member
QUARTER = NoteDuration.per_whole(4) # pylint: disable=no-member
EIGHTH = NoteDuration.per_whole(8) # pylint: disable=no-member
SIXTEENTH = NoteDuration.per_whole(16) # pylint: disable=no-member
THIRTY_SECOND = NoteDuration.per_whole(32) # pylint: disable=no-member
DOTTED_HALF = HALF._replace(augmentation_dots=1)
DOTTED_QUARTER = QUARTER._replace(augmentation_dots=1)
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 | """Data structures for representing measures."""
import fractions
import typing
from . import note
class Measure(typing.NamedTuple):
"""Class representing a measure as a collection of notes."""
notes: typing.Tuple[note.NoteDuration, ...]
time_signature: typing.Tuple[int, int] = (4, 4)
@property
def is_valid(self) -> bool:
"""Return whether the notes given fit exactly into the measure."""
beats_total, beats_per_whole = self.time_signature
if sum(
note.beats_as_written(beats_per_whole)
for note in self.notes) != beats_total:
return False
for fst, snd in zip(self.notes, self.notes[1:]):
if (fst.note_type == note.NoteType.REST) and (
snd.note_type == note.NoteType.TIED):
return False
return True
@property
def final_rest(self) -> bool:
"""Return whether the final note in the measure is a rest."""
return self.notes[-1].note_type == note.NoteType.REST
@property
def initial_tie(self) -> bool:
"""Return whether the initial note in the measure is tied."""
return self.notes[0].note_type == note.NoteType.TIED
@property
def notes_merged(self) -> "Measure":
"""Return a measure that sounds the same, but with the least notes."""
notes = [self.notes[0]]
for note_ in self.notes[1:]:
last_note = notes[-1]
if (note_.note_type == note.NoteType.TIED) or (
last_note.note_type == note.NoteType.REST
and note_.note_type == note.NoteType.REST):
notes[-1] = note.NoteDuration(
last_note.full_duration + note_.full_duration,
note_type=last_note.note_type)
else:
notes.append(note_)
return self._replace(notes=tuple(notes)) # pylint: disable=no-member
@property
def beat_separated(self) -> "Measure":
"""Return a measure that sounds the same, but with defined beats."""
notes = []
offset = fractions.Fraction()
beats_per_whole = self.time_signature[1]
for note_ in self.notes_merged.notes:
new_notes, offset = note_.subdivide(beats_per_whole, offset)
notes.extend(new_notes)
return self._replace(notes=tuple(notes)) # pylint: disable=no-member
def beats(self) -> typing.Iterator[typing.Tuple[note.NoteDuration, ...]]:
"""Yield each individual beat as a tuple of notes."""
beat_target = fractions.Fraction(1, self.time_signature[1])
beats = []
for note_ in self.beat_separated.notes:
beats.append(note_)
if sum(note__.base_duration for note__ in beats) == beat_target:
yield tuple(beats)
beats = []
def clap_strs(self) -> typing.Iterator[str]:
"""Yield the clap strings associated with the measure."""
beats = tuple(self.beats())
for beat, notes in enumerate(beats):
beat += 1 # Account for indexing conventions
realizations = []
realizations.append(notes[0].note_type.clap(str(beat)))
for note_ in notes[1:]:
# Assume we only do half-divisions
assert note_.beats_as_written(
self.time_signature[1]) == 0.5, notes
realizations.append(note_.note_type.clap('and'))
yield '-'.join(realizations)
class MultiMeasure(typing.NamedTuple):
"""Class representing several measures in sequence."""
measures: typing.Tuple[Measure, ...]
@property
def is_valid(self) -> bool:
"""Return whether the overall collection of measures is valid."""
for measure in self.measures:
if not measure.is_valid:
return False
for fst, snd in zip(self.measures, self.measures[1:]):
if fst.final_rest and snd.initial_tie:
return False
return True
@property
def clap_str(self) -> str:
"""Return the clap pattern for the measures."""
accumulator: typing.List[str] = []
for measure in self.measures:
accumulator.extend(measure.clap_strs())
return ' '.join(accumulator)
|
Getting pylint happy with the NoteType.clap function was bad enough that I'm considering turning my visitor thing from Homunculus into some kind of library.
I'm not totally happy with some of the interfaces here, but I'm not sure what to do to improve it. I might need to get further into the details of notes to find something good.
Next week, I'll get more into time signatures, probably turn that 2-tuple up there into its own type.