Music Theory 2018-05-07

By Max Woerner Chase

In this post:


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.

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:

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.