9 Chord Geometry — Music Theory as Group Action
Western music’s 12 pitch classes form the cyclic group \(\mathbb{Z}/12\mathbb{Z}\). Chords are subsets. Two chords related by transposition — shifting all notes by the same interval — are the “same type”: C major and D major are both “major.” Chord types are orbits under the group action.
This notebook uses group actions to classify chords, connecting abstract algebra to something every musician knows intuitively.
(ns harmonica-book.chord-geometry
(:require
[scicloj.harmonica :as hm]
[scicloj.harmonica.linalg.complex :as cx]
[harmonica-book.book-helpers :refer [allclose?]]
[tech.v3.datatype :as dtype]
[tablecloth.api :as tc]
[scicloj.tableplot.v1.plotly :as plotly]
[scicloj.kindly.v4.kind :as kind]))Audio Helpers
To hear what group actions do to chords, we need a simple synthesizer. Each note is a sum of harmonics with an exponential decay envelope.
(def sample-rate 44100.0)(defn midi->freq
"MIDI note number to frequency. A4 (69) = 440 Hz."
[midi]
(* 440.0 (Math/pow 2.0 (/ (- midi 69.0) 12.0))))(defn chord->samples
"Render a chord (collection of MIDI notes) as audio samples."
[midi-notes duration]
(let [n-samples (long (* duration sample-rate))
amp (/ 2500.0 (count midi-notes))
attack (long (* 0.02 sample-rate))
release (long (* 0.1 sample-rate))]
(dtype/make-reader
:float32
n-samples
(let [env (cond
(< idx attack) (/ (double idx) attack)
(> idx (- n-samples release))
(/ (double (- n-samples idx)) release)
:else (Math/exp (* -1.0 (/ (double (- idx attack)) n-samples))))
phase (/ (double idx) sample-rate)
wave (reduce + (map (fn [m]
(let [f (midi->freq m)]
(+ (* 0.65 (Math/sin (* 2.0 Math/PI f phase)))
(* 0.25 (Math/sin (* 2.0 Math/PI 2.0 f phase)))
(* 0.10 (Math/sin (* 2.0 Math/PI 3.0 f phase))))))
midi-notes))]
(float (* amp env wave))))))(defn chord-sequence->samples
"Render a sequence of chords as audio. Each chord is a collection of MIDI notes."
[chords chord-dur]
(let [n-chord (long (* chord-dur sample-rate))
n-total (* (count chords) n-chord)
amp-per-note 2500.0
attack (long (* 0.015 sample-rate))
sounding (long (* 0.85 n-chord))
release (long (* 0.06 sample-rate))]
(dtype/make-reader
:float32
n-total
(let [chord-idx (quot idx n-chord)
t (rem idx n-chord)
midi-notes (nth chords chord-idx)
n-notes (count midi-notes)
amp (/ amp-per-note n-notes)]
(if (>= t sounding)
(float 0.0)
(let [env (cond
(< t attack) (/ (double t) attack)
(> t (- sounding release))
(* (Math/exp (* -1.5 (/ (double (- t attack)) sounding)))
(/ (double (- sounding t)) release))
:else (Math/exp (* -1.5 (/ (double (- t attack)) sounding))))
phase (/ (double t) sample-rate)
wave (reduce + (map (fn [m]
(let [f (midi->freq m)]
(+ (* 0.65 (Math/sin (* 2.0 Math/PI f phase)))
(* 0.25 (Math/sin (* 2.0 Math/PI 2.0 f phase)))
(* 0.10 (Math/sin (* 2.0 Math/PI 3.0 f phase))))))
midi-notes))]
(float (* amp env wave))))))))(defn play-chord
"Play a chord given as pitch-class numbers (0-11). Octave is C4 (MIDI 60)."
[pcs]
(let [midi (mapv #(+ 60 %) (sort pcs))]
(kind/audio {:samples (chord->samples midi 1.5)
:sample-rate sample-rate})))(defn play-chords
"Play a sequence of chords. Each chord is a set of pitch-class numbers."
[chord-seq]
(let [midi-chords (mapv (fn [pcs] (mapv #(+ 60 %) (sort pcs))) chord-seq)]
(kind/audio {:samples (chord-sequence->samples midi-chords 0.6)
:sample-rate sample-rate})))Pitch Classes on the Clock
The 12 pitch classes {C, C#, D, …, B} form a circle, like hours on a clock. We label them 0 through 11:
(def pitch-names
["C" "C#" "D" "D#" "E" "F" "F#" "G" "G#" "A" "A#" "B"])A chord is a subset of this circle. For example, C major = {C, E, G} = {0, 4, 7}. On the clock face, it’s a triangle inscribed in the circle.
(defn chord-plot
"Draw a chord as a polygon on the pitch class circle."
[pcs title]
(let [n 12
angles (mapv (fn [i] (- (* 2 Math/PI (/ i (double n))) (/ Math/PI 2))) (range n))
xs (mapv #(Math/cos %) angles)
ys (mapv #(Math/sin %) angles)
pcs-sorted (vec (sort pcs))
chord-xs (mapv (fn [i] (xs i)) pcs-sorted)
chord-ys (mapv (fn [i] (ys i)) pcs-sorted)
colors (mapv (fn [i] (if ((set pcs) i) "#e74c3c" "#bdc3c7")) (range n))
sizes (mapv (fn [i] (if ((set pcs) i) 14 8)) (range n))]
(kind/plotly
{:data [{:type "scatter" :mode "markers+text"
:x (vec xs) :y (vec ys)
:text (vec pitch-names) :textposition "top center"
:marker {:size (vec sizes) :color (vec colors)}
:showlegend false}
{:type "scatter" :mode "lines"
:x (conj chord-xs (first chord-xs))
:y (conj chord-ys (first chord-ys))
:line {:color "#e74c3c" :width 2}
:fill "toself" :fillcolor "rgba(231,76,60,0.15)"
:showlegend false}]
:layout {:title title
:xaxis {:visible false :scaleanchor "y"}
:yaxis {:visible false}
:width 350 :height 350
:margin {:t 40 :b 10 :l 10 :r 10}}})))(chord-plot #{0 4 7} "C major on the pitch class circle")(play-chord #{0 4 7})Transposition as Group Action
Transposition by \(k\) semitones shifts every note: \(T_k(x) = x + k \pmod{12}\). The 12 transpositions form the cyclic group \(C_{12}\).
C major = {0, 4, 7}. Transposing by 2 gives D major = {2, 6, 9}. All major chords are in the same orbit under \(C_{12}\).
(let [c-major [0 4 7]
orbit (mapv (fn [k]
(let [transposed (sort (mapv #(mod (+ % k) 12) c-major))]
{:transposition k
:notes (str (mapv pitch-names transposed))}))
(range 12))]
(kind/table
{:column-names ["Transposition" "Chord"]
:row-vectors (mapv (fn [{:keys [transposition notes]}]
[transposition notes])
orbit)}))| Transposition | Chord |
|---|---|
| 0 | ["C" "E" "G"] |
| 1 | ["C#" "F" "G#"] |
| 2 | ["D" "F#" "A"] |
| 3 | ["D#" "G" "A#"] |
| 4 | ["E" "G#" "B"] |
| 5 | ["C" "F" "A"] |
| 6 | ["C#" "F#" "A#"] |
| 7 | ["D" "G" "B"] |
| 8 | ["C" "D#" "G#"] |
| 9 | ["C#" "E" "A"] |
| 10 | ["D" "F" "A#"] |
| 11 | ["D#" "F#" "B"] |
Hear the first four transpositions — same shape, different pitch:
(play-chords [[0 4 7] [1 5 8] [2 6 9] [3 7 10]])All 12 results are different — major chords form a single orbit of size 12.
Classifying Trichords
A trichord is any 3-note subset of \(\mathbb{Z}/12\mathbb{Z}\). There are \(\binom{12}{3} = 220\) trichords in total. How many distinct types are there up to transposition?
(let [G (hm/cyclic-group 12)
act (fn [g x] (mod (+ x g) 12))
{:keys [domain] act-sub :act} (hm/subset-action act (range 12) 3)
orbs (hm/orbits G act-sub domain)]
(count orbs)) clojure.core/eval core.clj: 3232
...
harmonica-book.chord-geometry/eval122238 REPL Input:
scicloj.harmonica.action/orbits action.clj: 35
scicloj.harmonica.action/orbit action.clj: 24
scicloj.harmonica.protocols/eval102277/fn/G protocols.clj: 14
clojure.core/-cache-protocol-fn core_deftype.clj: 585
java.lang.IllegalArgumentException: No implementation of method: :elements of protocol: #'scicloj.harmonica.protocols/FiniteGroup found for class: scicloj.harmonica.group.cyclic.CyclicGroup