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.