# SPDX-License-Identifier: MIT # harmony — mark four-part writing, and calculate the arithmetic of music. # # Two halves that share nothing but the subject. # # MARKING. You supply a piece as facts: its voices (`harmony.Voice`), each with # its place from the bottom and its range, and its notes (`harmony.Note`), each # naming its voice, its beat and its pitch as a MIDI note number, where 60 is # middle C and each step is a semitone. A beat here is one chord: number the # chords 1, 2, 3 and so on, in order. `satb()` records the four voices of a # chorale, and `chord()` writes one chord's four notes: # # import harmony # harmony.satb() # harmony.key("C", 0, "major") # harmony.chord(1, "C", 48, 55, 64, 72) # harmony.chord(2, "C", 50, 57, 65, 74) # print(harmony.faults()) # # The standing rules then conclude, for every note and every pair of voices, # what a teacher marks: consecutive fifths and octaves, crossed voices, # spacing, notes outside a voice's range, notes outside the key, and each # chord's reading from the intervals above its bass. Each conclusion answers # `why`, and withdraws itself when a note it rests on is corrected. Where two # sources disagree about a fact, both values are reasoned about, and # `unsettled()` names what they disagree about. # # RHYTHM. A melody's notes for their rhythm (`harmony.Tone`) and its bars # (`harmony.Bar`), in a unit you choose, such as a sixteenth. The rules check # each bar's total, notes held across the bar line, and syncopation. Bars may # differ in length, which is how a change of metre is written. # # A bar's total is the exception to reasoning over both values, since a sum # adds every value it reads: a tone whose duration a second source writes, # even the same duration, would be counted twice, and a bar both sources fill # exactly would read as miscounted. So while a tone is bound to its bar twice, # as a second duration binds it, the bar is `disputed`: no total and no # verdict on it is concluded, `rhythm_faults()` names it, `filled_of()` # refuses it, and `why_not` on its total names the dispute. Forgetting the # claim that is not to stand settles it. No other query refuses. A tone whose # onset two sources disagree on starts in each bar a value puts it in, so each # of those totals is one source's, and a bar left unmarked is within the source # that starts the tone in it. # # CALCULATION. Value scripts that return a value and write nothing: the # frequency of a note in equal temperament, an interval's size in cents, the # beating of two tones, a pitch-class set's prime form and interval vector, a # twelve-tone row's matrix, and a Euclidean rhythm. import math # ── A piece as facts ───────────────────────────────────────────────────────── class Voice: # A part, by name. `place` is its order from the bottom, 1 for the bass and # up to 4. The rules compare voices by place, so two voices must not share # one. `low` and `high` are its range, as MIDI note numbers. place: int low: int high: int class Key: # A key, by a name you choose: its tonic as a pitch class (0 is C, 1 is C # sharp and so on up to 11, B), and its mode, "major" or "minor". `key()` # writes one with its degrees. tonic: int mode: string class Degree: # A pitch class that belongs to a key. `key()` writes one per pitch class. key: string pc: int class Note: # One note of a chord: its voice, its beat, its pitch as a MIDI note # number, and the key it is read in, if any. The rules conclude `place` # from the voice, and `p1` to `p3`, the pitch the voices at places 1 to 3 # sound on the same beat. From those, `i1` to `i3` are this note's interval # above each of them within an octave, so 7 is a fifth and 0 an octave or a # unison, and `d2` and `d3` its full distance in semitones above places 2 # and 3, which spacing reads. voice: string beat: int pitch: int key: string place: int p1: int p2: int p3: int pc: int = pitch % 12 nextbeat: int = beat + 1 d2: int = pitch - p2 d3: int = pitch - p3 i1: int = (pitch - p1) % 12 i2: int = (pitch - p2) % 12 i3: int = (pitch - p3) % 12 # What the rules conclude about the note. `chord` and `seventh` are # concluded on the bass note, for its chord. spacing: bool range: string chromatic: bool chord: string seventh: string # The faults the rules conclude, each a relation between two notes on the same # beat: `Fifths.a.m` holds when the voice of `a` makes consecutive fifths with # the voice of `m` from that beat to the next. The classes are declared so that # a read of a fault that does not stand answers empty. class Fifths: pass class Octaves: pass class Crossing: pass # ── Rhythm as facts ────────────────────────────────────────────────────────── class Bar: # A bar: where it starts and how long it is, counted from the start of the # piece in the unit you chose, and the length of one beat in it. start: int length: int pulse: int end: int = start + length # What the rules conclude: how much the bar's tones fill, and whether # that is other than its length; and `claims` and `tones`, which # `disputed` compares: a tone bound to the bar twice makes it `disputed`, # and then no total is concluded. claims: int tones: int disputed: bool filled: int miscounted: bool class Tone: # A note of a melody, for its rhythm: where it starts and how long it # lasts, in the bars' unit. A rest is a tone too, since it fills time. # The rules conclude `barstart`, `barend` and `pulse` from the bar it # starts in. onset: int duration: int barstart: int barend: int pulse: int ends: int = onset + duration offbeat: int = (onset - barstart) % pulse nextpulse: int = onset - (onset - barstart) % pulse + pulse across: bool syncopated: bool # ── Standing rules: where each note sounds ─────────────────────────────────── @rule def place_of(): for n in Note, v in Voice: if n.voice == v: n.place = v.place @rule def sounding_1(): for n in Note, m in Note: if m.beat == n.beat and m.place == 1: n.p1 = m.pitch @rule def sounding_2(): for n in Note, m in Note: if m.beat == n.beat and m.place == 2: n.p2 = m.pitch @rule def sounding_3(): for n in Note, m in Note: if m.beat == n.beat and m.place == 3: n.p3 = m.pitch # ── Standing rules: consecutive fifths and octaves ─────────────────────────── # # Two voices a fifth apart on one beat, a fifth apart again on the next, and # both moving: consecutive fifths. The same with an octave or a unison: # consecutive octaves. A compound interval counts as its simple form, and the # rule takes contrary motion too, as strict part-writing does. `a` is the # upper voice's note on the first beat and `m` the lower voice's, so one rule # serves each lower place, and each pair of voices is checked once. @rule def fifths_over_1(): for a in Note, c in Note, m in Note: if a.place > 1 and c.voice == a.voice and c.beat == a.nextbeat and m.place == 1 and m.beat == a.beat and a.i1 == 7 and c.i1 == 7 and a.pitch != c.pitch and a.p1 != c.p1: Fifths.a.m = True @rule def fifths_over_2(): for a in Note, c in Note, m in Note: if a.place > 2 and c.voice == a.voice and c.beat == a.nextbeat and m.place == 2 and m.beat == a.beat and a.i2 == 7 and c.i2 == 7 and a.pitch != c.pitch and a.p2 != c.p2: Fifths.a.m = True @rule def fifths_over_3(): for a in Note, c in Note, m in Note: if a.place > 3 and c.voice == a.voice and c.beat == a.nextbeat and m.place == 3 and m.beat == a.beat and a.i3 == 7 and c.i3 == 7 and a.pitch != c.pitch and a.p3 != c.p3: Fifths.a.m = True @rule def octaves_over_1(): for a in Note, c in Note, m in Note: if a.place > 1 and c.voice == a.voice and c.beat == a.nextbeat and m.place == 1 and m.beat == a.beat and a.i1 == 0 and c.i1 == 0 and a.pitch != c.pitch and a.p1 != c.p1: Octaves.a.m = True @rule def octaves_over_2(): for a in Note, c in Note, m in Note: if a.place > 2 and c.voice == a.voice and c.beat == a.nextbeat and m.place == 2 and m.beat == a.beat and a.i2 == 0 and c.i2 == 0 and a.pitch != c.pitch and a.p2 != c.p2: Octaves.a.m = True @rule def octaves_over_3(): for a in Note, c in Note, m in Note: if a.place > 3 and c.voice == a.voice and c.beat == a.nextbeat and m.place == 3 and m.beat == a.beat and a.i3 == 0 and c.i3 == 0 and a.pitch != c.pitch and a.p3 != c.p3: Octaves.a.m = True # ── Standing rules: crossing, spacing, range and key ───────────────────────── @rule def crossing(): # A voice sounding below a voice placed under it. for a in Note, m in Note: if a.place > m.place and m.beat == a.beat and a.pitch < m.pitch: Crossing.a.m = True @rule def spacing_3(): # More than an octave between the third voice and the second. The bass # may lie further from the tenor, so that pair is not checked. for a in Note: if a.place == 3 and a.d2 > 12: a.spacing = True @rule def spacing_4(): for a in Note: if a.place == 4 and a.d3 > 12: a.spacing = True @rule def below_range(): for n in Note, v in Voice: if n.voice == v and n.pitch < v.low: n.range = "low" @rule def above_range(): for n in Note, v in Voice: if n.voice == v and n.pitch > v.high: n.range = "high" @rule def chromatic(): # A note whose pitch class no degree of its key has. for n in Note, k in Key: if n.key == k and none(d in Degree: d.key == n.key and d.pc == n.pc): n.chromatic = True # ── Standing rules: what each chord is ─────────────────────────────────────── # # A chord is read from the intervals above its bass, one rule for each shape, # and the reading is concluded on the bass note. A rule reads exactly the # intervals it names, so a chord holding more tones also takes the readings of # the triads inside it: a dominant seventh is read as a major triad as well, # and a chord with both a major and a minor third is read as both. @rule def major_root(): for b in Note, x in Note, y in Note: if b.place == 1 and x.beat == b.beat and x.i1 == 4 and y.beat == b.beat and y.i1 == 7: b.chord = "major" @rule def minor_root(): for b in Note, x in Note, y in Note: if b.place == 1 and x.beat == b.beat and x.i1 == 3 and y.beat == b.beat and y.i1 == 7: b.chord = "minor" @rule def diminished_root(): for b in Note, x in Note, y in Note: if b.place == 1 and x.beat == b.beat and x.i1 == 3 and y.beat == b.beat and y.i1 == 6: b.chord = "diminished" @rule def major_first(): for b in Note, x in Note, y in Note: if b.place == 1 and x.beat == b.beat and x.i1 == 3 and y.beat == b.beat and y.i1 == 8: b.chord = "major, first inversion" @rule def minor_first(): for b in Note, x in Note, y in Note: if b.place == 1 and x.beat == b.beat and x.i1 == 4 and y.beat == b.beat and y.i1 == 9: b.chord = "minor, first inversion" @rule def diminished_first(): for b in Note, x in Note, y in Note: if b.place == 1 and x.beat == b.beat and x.i1 == 3 and y.beat == b.beat and y.i1 == 9: b.chord = "diminished, first inversion" @rule def major_second(): for b in Note, x in Note, y in Note: if b.place == 1 and x.beat == b.beat and x.i1 == 5 and y.beat == b.beat and y.i1 == 9: b.chord = "major, second inversion" @rule def minor_second(): for b in Note, x in Note, y in Note: if b.place == 1 and x.beat == b.beat and x.i1 == 5 and y.beat == b.beat and y.i1 == 8: b.chord = "minor, second inversion" @rule def diminished_second(): for b in Note, x in Note, y in Note: if b.place == 1 and x.beat == b.beat and x.i1 == 6 and y.beat == b.beat and y.i1 == 9: b.chord = "diminished, second inversion" @rule def major_open(): # Root and third only, the fifth left out, as a final chord often is. for b in Note, x in Note: if b.place == 1 and x.beat == b.beat and x.i1 == 4 and none(y in Note: y.beat == b.beat and y.i1 != 0 and y.i1 != 4): b.chord = "major, fifth omitted" @rule def minor_open(): for b in Note, x in Note: if b.place == 1 and x.beat == b.beat and x.i1 == 3 and none(y in Note: y.beat == b.beat and y.i1 != 0 and y.i1 != 3): b.chord = "minor, fifth omitted" @rule def seventh_root(): for b in Note, x in Note, y in Note, z in Note: if b.place == 1 and x.beat == b.beat and x.i1 == 4 and y.beat == b.beat and y.i1 == 7 and z.beat == b.beat and z.i1 == 10: b.seventh = "dominant seventh" @rule def seventh_open(): for b in Note, x in Note, z in Note: if b.place == 1 and x.beat == b.beat and x.i1 == 4 and z.beat == b.beat and z.i1 == 10 and none(y in Note: y.beat == b.beat and y.i1 == 7): b.seventh = "dominant seventh, fifth omitted" @rule def seventh_first(): for b in Note, x in Note, y in Note, z in Note: if b.place == 1 and x.beat == b.beat and x.i1 == 3 and y.beat == b.beat and y.i1 == 6 and z.beat == b.beat and z.i1 == 8: b.seventh = "dominant seventh, first inversion" @rule def seventh_second(): for b in Note, x in Note, y in Note, z in Note: if b.place == 1 and x.beat == b.beat and x.i1 == 3 and y.beat == b.beat and y.i1 == 5 and z.beat == b.beat and z.i1 == 9: b.seventh = "dominant seventh, second inversion" @rule def seventh_third(): for b in Note, x in Note, y in Note, z in Note: if b.place == 1 and x.beat == b.beat and x.i1 == 2 and y.beat == b.beat and y.i1 == 6 and z.beat == b.beat and z.i1 == 9: b.seventh = "dominant seventh, third inversion" # ── Standing rules: rhythm ─────────────────────────────────────────────────── @rule def bar_start(): for t in Tone, b in Bar: if t.onset >= b.start and t.onset < b.end: t.barstart = b.start @rule def bar_end(): for t in Tone, b in Bar: if t.onset >= b.start and t.onset < b.end: t.barend = b.end @rule def bar_pulse(): for t in Tone, b in Bar: if t.onset >= b.start and t.onset < b.end: t.pulse = b.pulse @rule def bar_claims(): for b in Bar, t in Tone: if t.onset >= b.start and t.onset < b.end and t.duration > -1000000000000000000: b.claims = count(t) @rule def bar_tones(): for b in Bar, t in Tone: if t.onset >= b.start and t.onset < b.end and t.duration > -1000000000000000000: b.tones = distinct(t) @rule def bar_disputed(): # A sum adds every value it reads, so a tone bound to the bar twice would # be counted twice, which no source gives. for b in Bar: if b.claims > b.tones: b.disputed = True @rule def bar_filled(): # How much of the bar its tones fill, counting each tone in the bar it # starts in. for b in Bar, t in Tone: if t.onset >= b.start and t.onset < b.end and absent(b.disputed): b.filled = sum(t.duration) @rule def bar_miscounted(): for b in Bar: if b.filled != b.length: b.miscounted = True @rule def across_bar(): # A tone that lasts past the end of the bar it starts in. Written music # ties such a note over the bar line. for t in Tone: if t.ends > t.barend: t.across = True @rule def syncopated(): # A tone that starts off the beat and lasts past the next one. for t in Tone: if t.offbeat > 0 and t.ends > t.nextpulse: t.syncopated = True # ── Writing a piece ────────────────────────────────────────────────────────── def satb(): # The four voices of a chorale, bass to soprano: bass E2 to C4, tenor C3 # to G4, alto G3 to D5 and soprano C4 to G5. Textbooks differ by a note or # two; `update` a range to use another, or write `Voice` facts yourself for # other voices. update(Voice("bass").place, 1, "harmony") update(Voice("bass").low, 40, "harmony") update(Voice("bass").high, 60, "harmony") update(Voice("tenor").place, 2, "harmony") update(Voice("tenor").low, 48, "harmony") update(Voice("tenor").high, 67, "harmony") update(Voice("alto").place, 3, "harmony") update(Voice("alto").low, 55, "harmony") update(Voice("alto").high, 74, "harmony") update(Voice("soprano").place, 4, "harmony") update(Voice("soprano").low, 60, "harmony") update(Voice("soprano").high, 79, "harmony") def key(name: string, tonic: int, mode: string): # A key and its degrees. A minor key takes the raised sixth and seventh # as well as its own, as the melodic and harmonic minor do. update(Key(name).tonic, tonic, "harmony") update(Key(name).mode, mode, "harmony") steps = [0, 2, 4, 5, 7, 9, 11] if mode == "minor": steps = [0, 2, 3, 5, 7, 8, 9, 10, 11] for s in steps: pc = (tonic + s) % 12 sname = f"{name}_{pc}" d = Degree(sname) update(d.key, name, "harmony") update(d.pc, pc, "harmony") def chord(beat: int, k: string, bass: int, tenor: int, alto: int, soprano: int): # One chord of a four-part piece in the voices `satb()` writes. The notes # are named for their voice and beat, `bass1` to `soprano1` for beat 1. # Writing a beat again corrects it. voices = ["bass", "tenor", "alto", "soprano"] pitches = [bass, tenor, alto, soprano] for i in range(4): sname = f"{voices[i]}{beat}" n = Note(sname) update(n.voice, voices[i], "harmony") update(n.beat, beat, "harmony") update(n.pitch, pitches[i], "harmony") update(n.key, k, "harmony") def bars(count: int, length: int, pulse: int): # `count` bars of one metre from the start of the piece, `m1` onward, # each `length` units long with a beat of `pulse`. Writing them again # replaces what an earlier call wrote: it forgets `m` and each number from # `count + 1` up to the number of bars the store held. Write `Bar` facts of # other names yourself where the metre changes. before = list_subjects(Bar) for i in range(count): sname = f"m{i + 1}" b = Bar(sname) update(b.start, i * length, "harmony") update(b.length, length, "harmony") update(b.pulse, pulse, "harmony") for i in range(count, len(before)): sname = f"m{i + 1}" b = Bar(sname) forget(b.start) forget(b.length) forget(b.pulse) def melody(durations: list): # A melody's tones end to end from the start of the piece, `t1` onward, # each lasting what `durations` gives it. Writing it again replaces what # an earlier call wrote: it forgets `t` and each number past the new end # up to the number of tones the store held. before = list_subjects(Tone) at = 0 for i in range(len(durations)): sname = f"t{i + 1}" t = Tone(sname) update(t.onset, at, "harmony") update(t.duration, durations[i], "harmony") at = at + durations[i] for i in range(len(durations), len(before)): sname = f"t{i + 1}" t = Tone(sname) forget(t.onset) forget(t.duration) # ── Reading the marks ──────────────────────────────────────────────────────── def voice_of(n: string) -> string: # The voice of note `n`, or "" when it has none. Where sources disagree, # every voice on record, in name order and joined by "or"; `unsettled()` # names the note. vs = list_values(Note.voice) out = [] for v in vs: parts = v.path.split(".") w = str(v.value) if parts[len(parts) - 2] == n and w not in out: out = out + [w] return " or ".join(sorted(out)) def beat_of(n: string) -> int: # The beat of note `n`, or 0 when it has none. Where sources disagree, the # earliest. vs = list_values(Note.beat) out = 0 found = False for v in vs: parts = v.path.split(".") if parts[len(parts) - 2] == n: match v.value: case int as x: if not found or x < out: out = x found = True case default: pass return out def filled_of(b: string) -> int: # How much of bar `b` its tones fill, or 0 when nothing starts in it. # Where sources disagree, the least. It refuses a disputed bar, which has # no total until the dispute is settled. ds = subjects(Bar.disputed, True) for d in ds: if d == b: raise ValueError("bar " + b + " is disputed: a tone of it is bound to it twice; forget the claim that is not to stand") vs = list_values(Bar.filled) out = 0 found = False for v in vs: parts = v.path.split(".") if parts[len(parts) - 2] == b: match v.value: case int as x: if not found or x < out: out = x found = True case default: pass return out def length_of(b: string) -> int: # The length of bar `b`, or 0 when it has none. Where sources disagree, # the least. vs = list_values(Bar.length) out = 0 found = False for v in vs: parts = v.path.split(".") if parts[len(parts) - 2] == b: match v.value: case int as x: if not found or x < out: out = x found = True case default: pass return out def pairs(kind: string) -> list>: out = [] if kind == "fifths": ups = list_subjects(Fifths) for up in ups: los = list_attributes(Fifths.up) for lo in los: out = out + [(up, lo)] if kind == "octaves": ups = list_subjects(Octaves) for up in ups: los = list_attributes(Octaves.up) for lo in los: out = out + [(up, lo)] if kind == "crossing": ups = list_subjects(Crossing) for up in ups: los = list_attributes(Crossing.up) for lo in los: out = out + [(up, lo)] return out def faults() -> list: # Every fault the rules conclude, one line each, sorted. A note whose voice # or beat sources disagree about is named with every value on record, # joined by "or". voices: dict> = {} beats: dict> = {} vs = list_values(Note.voice) for v in vs: parts = v.path.split(".") s = parts[len(parts) - 2] w = str(v.value) if s not in voices: voices[s] = [] if w not in voices[s]: voices[s] = voices[s] + [w] bs = list_values(Note.beat) for v in bs: parts = v.path.split(".") s = parts[len(parts) - 2] match v.value: case int as x: if s not in beats: beats[s] = [] if x not in beats[s]: beats[s] = beats[s] + [x] case default: pass out = [] for kind in ["fifths", "octaves", "crossing"]: ps = pairs(kind) for p in ps: up = p[0] lo = p[1] uv = " or ".join(sorted(voices.get(up, []))) lv = " or ".join(sorted(voices.get(lo, []))) ts = sorted(beats.get(up, [])) bt = " or ".join([str(x) for x in ts]) if kind == "crossing": out = out + [f"crossing: {uv} below {lv}, beat {bt}"] else: bn = " or ".join([str(x + 1) for x in ts]) out = out + [f"consecutive {kind}: {lv} and {uv}, beats {bt} to {bn}"] marked = [] sp = subjects(Note.spacing, True) for n in sp: marked = marked + [(n, "spacing")] lows = subjects(Note.range, "low") for n in lows: marked = marked + [(n, "low")] highs = subjects(Note.range, "high") for n in highs: marked = marked + [(n, "high")] cs = subjects(Note.chromatic, True) for n in cs: marked = marked + [(n, "chromatic")] for m in marked: n = m[0] what = m[1] uv = " or ".join(sorted(voices.get(n, []))) bt = " or ".join([str(x) for x in sorted(beats.get(n, []))]) if what == "spacing": out = out + [f"spacing: {uv} more than an octave above the voice below, beat {bt}"] elif what == "chromatic": out = out + [f"outside the key: {uv}, beat {bt}"] else: out = out + [f"range: {uv} too {what}, beat {bt}"] return sorted(out) def readings(n: string) -> list: # Every reading the rules conclude for the chord whose bass is `n`. out = [] cs = get(Note(n).chord) for c in cs: match c.value: case string as s: out = out + [s] case default: pass ss = get(Note(n).seventh) for c in ss: match c.value: case string as s: out = out + [s] case default: pass return sorted(out) def rhythm_faults() -> list: # Every bar whose tones do not fill it exactly, every tone held across a # bar line, and every syncopation, one line each, sorted. A bar sources # disagree about is given every value on record, joined by "or", and a # disputed bar, which has no total, is named as disputed. fills: dict> = {} lengths: dict> = {} fs = list_values(Bar.filled) for v in fs: parts = v.path.split(".") s = parts[len(parts) - 2] match v.value: case int as x: if s not in fills: fills[s] = [] if x not in fills[s]: fills[s] = fills[s] + [x] case default: pass ls = list_values(Bar.length) for v in ls: parts = v.path.split(".") s = parts[len(parts) - 2] match v.value: case int as x: if s not in lengths: lengths[s] = [] if x not in lengths[s]: lengths[s] = lengths[s] + [x] case default: pass out = [] bs = subjects(Bar.miscounted, True) for b in bs: f = " or ".join([str(x) for x in sorted(fills.get(b, [0]))]) ln = " or ".join([str(x) for x in sorted(lengths.get(b, [0]))]) out = out + [f"bar {b}: {f} of {ln}"] ds = subjects(Bar.disputed, True) for b in ds: out = out + [f"bar {b}: disputed"] xs = subjects(Tone.across, True) for t in xs: out = out + [f"across the bar line: {t}"] ys = subjects(Tone.syncopated, True) for t in ys: out = out + [f"syncopated: {t}"] return sorted(out) def unsettled() -> list: # Every voice, note, bar and tone for which sources hold more than one # value of a field you write, as its kind and name. Each value is reasoned # about, so a mark may rest on either; this is where to settle them. found: dict = {} for f in ["place", "low", "high"]: held: dict = {} vs = list_values(Voice.f) for v in vs: parts = v.path.split(".") s = parts[len(parts) - 2] w = str(v.value) if s in held and held[s] != w: found[f"voice {s}"] = True held[s] = w for f in ["voice", "beat", "pitch", "key"]: held: dict = {} vs = list_values(Note.f) for v in vs: parts = v.path.split(".") s = parts[len(parts) - 2] w = str(v.value) if s in held and held[s] != w: found[f"note {s}"] = True held[s] = w for f in ["start", "length", "pulse"]: held: dict = {} vs = list_values(Bar.f) for v in vs: parts = v.path.split(".") s = parts[len(parts) - 2] w = str(v.value) if s in held and held[s] != w: found[f"bar {s}"] = True held[s] = w for f in ["onset", "duration"]: held: dict = {} vs = list_values(Tone.f) for v in vs: parts = v.path.split(".") s = parts[len(parts) - 2] w = str(v.value) if s in held and held[s] != w: found[f"tone {s}"] = True held[s] = w out = [] for s in found: out = out + [s] return sorted(out) # ── Calculation ────────────────────────────────────────────────────────────── def frequency(midi: int, a4: real = 440.0) -> real: # A note's frequency in equal temperament, with A4, MIDI 69, at `a4` hertz. return a4 * 2.0 ** ((midi - 69) / 12) def cents(ratio: real) -> real: # The size of an interval in cents, 1200 to the octave. return 1200.0 * math.log(ratio, 2.0) def beating(f1: real, h1: int, f2: real, h2: int) -> real: # How many times a second the harmonic `h1` of a tone at `f1` hertz beats # against the harmonic `h2` of a tone at `f2`. return abs(h1 * f1 - h2 * f2) def pcset(pcs: list) -> list: # The distinct pitch classes, sorted. seen = [] for p in pcs: q = p % 12 if q not in seen: seen = seen + [q] return sorted(seen) def rotations(s: list) -> list>: # Each rotation of a sorted set, transposed to start on 0. out = [] n = len(s) for i in range(n): r = [] for j in range(n): r = r + [(s[(i + j) % n] - s[i]) % 12] out = out + [r] return out def packed_key(r: list, right: bool) -> list: # What two candidate orderings are compared by. Packed from the right: # the span, then the interval to the next-to-last element, and so inward. # Packed from the left: the span, then the intervals from the first # element to the second, the third and onward. n = len(r) out = [r[n - 1]] if right: for j in range(n - 2, 0, -1): out = out + [r[j]] else: for j in range(1, n - 1): out = out + [r[j]] return out def most_packed(s: list, right: bool) -> list: best = [] bk = [] rs = rotations(s) for r in rs: k = packed_key(r, right) if len(best) == 0 or k < bk: best = r bk = k return best def prime_form(pcs: list, right: bool = True) -> list: # A set class's prime form: the most packed ordering of the set or its # inversion, transposed to start on 0. The two criteria give different # answers for a few set classes, and published lists follow one or the # other. s = pcset(pcs) if len(s) == 0: return [] inv = [] for p in s: inv = inv + [(12 - p) % 12] a = most_packed(s, right) b = most_packed(pcset(inv), right) if packed_key(b, right) < packed_key(a, right): return b return a def interval_vector(pcs: list) -> list: # How many times each interval class, 1 to 6, occurs between the set's # pitch classes. s = pcset(pcs) v = [0, 0, 0, 0, 0, 0] n = len(s) for i in range(n): for j in range(i + 1, n): d = (s[j] - s[i]) % 12 if d > 6: d = 12 - d v[d - 1] = v[d - 1] + 1 return v def matrix(row: list) -> list>: # A twelve-tone row's matrix. Read left to right, its rows are the row's # transpositions, each starting on a pitch class of the inversion; read # top to bottom, its columns are the inversions. Read backwards, both are # the retrogrades. The first row is the row, and the diagonal its first # pitch class. out = [] n = len(row) for i in range(n): r = [] for j in range(n): r = r + [(row[j] + row[0] - row[i]) % 12] out = out + [r] return out def all_interval(row: list) -> bool: # Whether the row's successive intervals are all different. seen = [] for i in range(len(row) - 1): d = (row[i + 1] - row[i]) % 12 if d in seen: return False seen = seen + [d] return True def euclid(k: int, n: int) -> string: # `k` onsets spread as evenly as they go over `n` steps, by Bjorklund's # algorithm: `x` an onset, `.` a step without one. if k <= 0: return "." * n if k >= n: return "x" * n a = [] for i in range(k): a = a + ["x"] b = [] for i in range(n - k): b = b + ["."] for step in range(n): if len(b) <= 1: break m = min(len(a), len(b)) na = [] for i in range(m): na = na + [a[i] + b[i]] if len(a) > m: nb = a[m:] else: nb = b[m:] a = na b = nb return "".join(a) + "".join(b)