Music Theory 2018-05-14

By Max Woerner Chase

In this post:


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.