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 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] 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 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) 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.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) == 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.