Music Theory 2018-05-07
In this post:
- I use tied notes and a simpler specification of note lengths to simplify the clapping code.
- I think about implementing rests, but decide not to just yet.
When last we saw our heroic music theory code, the measure analysis functionality looked like this:
class MultiMeasure(typing.NamedTuple):
"""Class representing several measures in sequence."""
measures: typing.Tuple[Measure, ...]
@property
def clap_str(self):
"""Return the clap pattern for the measures."""
accumulator = []
for measure in self.measures:
beats_per_whole = measure.time_signature[1]
beat_count = 1
offbeat = False
prefix = ''
for note_ in measure.notes:
beats_per_note = note_.beats_as_written(beats_per_whole)
if offbeat:
beats_per_note += 0.5
entire_beats = int(beats_per_note)
excess = beats_per_note - entire_beats
for i in range(entire_beats):
if i == 0:
accumulator.append(prefix + 'CLAP')
else:
accumulator.append(str(beat_count))
beat_count += 1
if excess:
assert excess == 0.5
offbeat = True
if entire_beats:
prefix = str(beat_count) + '-'
else:
prefix = 'CLAP-'
else:
offbeat = False
prefix = ''
return ' '.join(accumulator)
... Ew. There's something wrong with the way I'm doing that, quite aside from the fact that there's missing functionality. (This is exactly as it was last week, so it doesn't handle ties at all.) Let's try to break this down differently.
- A measure is made up of beats.
- Beats are space-separated in the output.
- A beat (currently) either starts a note (in which case it gets annotated "CLAP") or continues one (in which case it gets annotated with the beat number (counting from 1))
- If the note ends before the beat does, then the beat contains more notes (annotated "CLAP"), and joined with dashes/hyphens/whatever.
- If the note extends beyond the beat... we don't want that, not really.
Before considering ties, we have to deal with the last bullet. But, with access to ties, we can "re-spell" an arbitrary pattern of notes into notes entirely within a beat, but tied between them.
To accomplish this, we need to add a property to the measure class, to reinterpret it in light of its time signature.
I went and confused myself implementing it, so I need to take another look. My current plan is to accumulate newly created notes into a list, and construct the new measure from that. But there's something I missed: I'm doing all the logic at the measure level currently, when I could be putting some of it on the note. The note class needs a method to say "given a beats per whole and an initial offset into the beat, return a sequence of notes that don't straddle beats, and a new offset"
I then realized that I should be MERGING notes within the measure, and point is, it all works now. It's basically fine. The code now looks like this:
class NoteDuration(typing.NamedTuple):
"""Class representing a single note as written."""
base_duration: fractions.Fraction = fractions.Fraction(1)
augmentation_dots: int = 0
tied_to_previous: bool = False
@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))
offset += first_duration
duration -= first_duration
while duration:
offset = min(offset, duration)
notes.append(NoteDuration(
base_duration=offset / beats_per_whole,
tied_to_previous=True))
duration -= offset
if offset == 1:
offset = fractions.Fraction()
return notes, offset
[...]
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
return sum(
note.beats_as_written(beats_per_whole)
for note in self.notes) == beats_total
@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:]:
if note_.tied_to_previous:
last_note = notes[-1]
notes[-1] = note.NoteDuration(
last_note.full_duration + note_.full_duration,
tied_to_previous=last_note.tied_to_previous)
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):
"""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 = []
class MultiMeasure(typing.NamedTuple):
"""Class representing several measures in sequence."""
measures: typing.Tuple[Measure, ...]
@property
def clap_str(self):
"""Return the clap pattern for the measures."""
accumulator = []
for measure in self.measures:
beats = tuple(measure.beats())
for beat, notes in enumerate(beats):
beat += 1 # Account for indexing conventions
realizations = []
if notes[0].tied_to_previous:
realizations.append(str(beat))
else:
realizations.append('CLAP')
for note_ in notes[1:]:
# Assume we only do half-divisions
assert note_.beats_as_written(
measure.time_signature[1]) == 0.5, notes
realizations.append('CLAP')
accumulator.append('-'.join(realizations))
return ' '.join(accumulator)
Now that I've got ties working, let's see what's next to implement. I'm going to get some tests added from the book to be completely thorough, but I'm feeling good about this code.
The next thing to do to it is add a bunch of ideas that change up the workings of it. Yay.
Introducing rests brings in some necessary complication. Previously, all notes were beginnings and continuations. I think long-term, I'm going to want to add in explicit note values, and intervals from previous notes, but for now, we've got some tri-state logic in here to confound things. I believe I want to represent this with an enum:
- START (CLAP)
- TIED (beat number) (cannot follow a rest)
- REST (how about... beat number or "and" in parens)
Since getting the tie logic working was such a chore (there was a long stretch in there where I was just littering the code with asserts until I could get it to make sense), I'm going to leave the rest logic for next week.
Next week, I rework the logic to handle rests.