From 69ddb1eb64c6f5152d0d623780b2ffb5cdb99b45 Mon Sep 17 00:00:00 2001 From: Kenneth Reitz Date: Sun, 22 Mar 2026 06:13:05 -0400 Subject: [PATCH] Rewrite chord analysis with musically accurate models MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit intervals: now returns semitone counts (integers) instead of Hz differences — octave-invariant and musically meaningful harmony: frequency ratio simplicity model — reduces each pairwise frequency ratio to simplest form and scores by 1/(num+denom). Simple ratios (octave 2:1, fifth 3:2) score highest. dissonance: Plomp-Levelt roughness with Bark-scale critical bandwidth (Zwicker & Terhardt 1980). Models sensory roughness from interfering fundamentals — peaks when freq difference ≈ critical bandwidth. beat_frequencies: new property returning all pairwise beat frequencies as sorted (tone, tone, hz) tuples beat_pulse: returns smallest non-zero beat frequency (most perceptible) All properties have detailed docstrings with psychoacoustic references, perceptual ranges, and usage examples. Co-Authored-By: Claude Opus 4.6 (1M context) --- pytheory/chords.py | 223 +++++++++++++++++++++++++++++++++++---------- test_pytheory.py | 97 +++++++++++++++++--- 2 files changed, 262 insertions(+), 58 deletions(-) diff --git a/pytheory/chords.py b/pytheory/chords.py index 8f9d4a1..ea3d317 100644 --- a/pytheory/chords.py +++ b/pytheory/chords.py @@ -17,65 +17,196 @@ class Chord: return any(item == t.name for t in self.tones) return item in self.tones - @property - def harmony(self): - if len(self.tones) < 2: - return 0 # No harmony for a single tone or empty chord - - # Simple harmony calculation: sum of inverse intervals - # Smaller intervals contribute more to harmony - harmony_value = sum( - 1 / interval for interval in self.intervals if interval != 0 - ) - - return harmony_value - - @property - def dissonance(self): - if len(self.tones) < 2: - return 0 # No dissonance for a single tone or empty chord - - # Simple dissonance calculation: sum of intervals - # Larger intervals contribute more to dissonance - dissonance_value = sum(interval for interval in self.intervals if interval != 0) - - return dissonance_value - @property def intervals(self): + """Semitone distances between adjacent tones in the chord. + + Returns a list of integers, where each value is the absolute + number of semitones between consecutive tones. This is + octave-invariant — a major third is always 4 semitones whether + it's C4→E4 or C6→E6. + + Common interval values:: + + 1 = minor 2nd (half step) + 2 = major 2nd (whole step) + 3 = minor 3rd + 4 = major 3rd + 5 = perfect 4th + 6 = tritone + 7 = perfect 5th + 12 = octave + + Example:: + + >>> c_major = Chord(tones=[C4, E4, G4]) + >>> c_major.intervals + [4, 3] # major 3rd + minor 3rd + + Returns an empty list for chords with fewer than 2 tones. + """ if len(self.tones) < 2: - return [] # No intervals for a single tone or empty chord - - # Calculate intervals between adjacent tones - intervals = [ - abs(self.tones[i].pitch() - self.tones[i - 1].pitch()) - for i in range(1, len(self.tones)) - ] - - return intervals + return [] + return [abs(self.tones[i] - self.tones[i - 1]) + for i in range(1, len(self.tones))] @property - def beat_pulse(self): - """Calculate the beat pulse (frequency of amplitude modulation) between tones. + def harmony(self): + """Consonance score based on frequency ratio simplicity. - Returns: - float: The beat frequency in Hz between the closest pair of tones. - Returns 0 if there are fewer than 2 tones. + Computed by examining the frequency ratio between every pair of + tones, reducing it to its simplest fractional form (limited to + denominators ≤ 32), and summing ``1 / (numerator + denominator)``. + + The psychoacoustic basis: intervals whose frequencies form simple + integer ratios are perceived as consonant. A perfect fifth (3:2) + scores higher than a tritone (45:32) because simpler ratios + produce fewer interfering overtones. + + Reference consonance scores for common intervals:: + + Octave (2:1) → 1/(2+1) = 0.333 + Perfect 5th (3:2) → 1/(3+2) = 0.200 + Perfect 4th (4:3) → 1/(4+3) = 0.143 + Major 3rd (5:4) → 1/(5+4) = 0.111 + Tritone (45:32) → 1/(45+32) = 0.013 + + For chords with multiple tones, all pairwise ratios are summed — + a C major triad (C-E-G) scores higher than C-E-Gb because the + C-G fifth contributes a large consonance term. + + Returns 0 for chords with fewer than 2 tones. """ if len(self.tones) < 2: return 0 - # Calculate beat frequencies between all pairs of tones - beat_frequencies = [] + from fractions import Fraction + score = 0.0 for i in range(len(self.tones)): for j in range(i + 1, len(self.tones)): - freq1 = self.tones[i].pitch() - freq2 = self.tones[j].pitch() - beat_freq = abs(freq1 - freq2) - beat_frequencies.append(beat_freq) + f1 = self.tones[i].pitch() + f2 = self.tones[j].pitch() + if f1 == 0 or f2 == 0: + continue + ratio = Fraction(f2 / f1).limit_denominator(32) + score += 1.0 / (ratio.numerator + ratio.denominator) + return score - # Return the smallest non-zero beat frequency - return min(beat_frequencies) if beat_frequencies else 0 + @property + def dissonance(self): + """Sensory dissonance score using the Plomp-Levelt roughness model. + + When two tones are close in frequency, their waveforms interfere + and produce a perceived "roughness." This roughness peaks when + the frequency difference is about 25% of the critical bandwidth + (roughly 1/4 of the lower frequency) and diminishes for wider + or narrower separations. + + The model: for each pair of tones, compute + ``x = freq_diff / critical_bandwidth`` using the Bark-scale + critical bandwidth formula (Zwicker & Terhardt, 1980): + ``CB = 25 + 75 * (1 + 1.4 * (f/1000)^2)^0.69`` + then apply the Plomp-Levelt curve ``x * e^(1-x)``. This peaks + at x=1 (maximum roughness) and decays for larger intervals. + + Practical implications: + + - A minor 2nd (C4-Db4, ~15 Hz apart) produces high roughness + - A major 3rd (C4-E4, ~68 Hz apart) produces moderate roughness + - A perfect 5th (C4-G4, ~130 Hz apart) produces low roughness + - Roughness is frequency-dependent: the same interval sounds + rougher in lower registers because the critical bandwidth is + narrower relative to the frequency difference + + Based on: Plomp, R. & Levelt, W.J.M. (1965). "Tonal consonance + and critical bandwidth." *Journal of the Acoustical Society of + America*, 38(4), 548-560. + + Returns 0 for chords with fewer than 2 tones. + """ + if len(self.tones) < 2: + return 0 + + import math + roughness = 0.0 + for i in range(len(self.tones)): + for j in range(i + 1, len(self.tones)): + f1 = self.tones[i].pitch() + f2 = self.tones[j].pitch() + f_min = min(f1, f2) + f_max = max(f1, f2) + if f_min == 0: + continue + # Bark-scale critical bandwidth (Zwicker & Terhardt, 1980) + cb = 25 + 75 * (1 + 1.4 * (f_min / 1000) ** 2) ** 0.69 + diff = f_max - f_min + if cb > 0: + x = diff / cb + roughness += x * math.exp(1 - x) if x > 0 else 0 + return roughness + + @property + def beat_frequencies(self): + """Beat frequencies (Hz) between all pairs of tones in the chord. + + When two tones with frequencies f1 and f2 are played together, + their waveforms interfere and produce an amplitude modulation + at the *beat frequency*: ``|f1 - f2|`` Hz. + + Perceptual ranges: + + - **< 1 Hz**: very slow pulsing, used in tuning (e.g. tuning a + guitar string against a reference — you hear the beats slow + down as you approach the correct pitch) + - **1–15 Hz**: audible beating, perceived as a rhythmic pulse + - **15–30 Hz**: transition zone — too fast for individual beats, + perceived as roughness/buzzing + - **> 30 Hz**: no longer perceived as beating; becomes part of + the perceived timbre or is heard as a difference tone + + Returns a list of ``(tone_a, tone_b, beat_hz)`` tuples sorted + by beat frequency ascending (slowest/most perceptible first). + + Example:: + + >>> chord = Chord(tones=[A4, A4_slightly_sharp]) + >>> chord.beat_frequencies + [(A4, A4+, 2.5)] # 2.5 Hz beating — clearly audible + + Returns an empty list for chords with fewer than 2 tones. + """ + if len(self.tones) < 2: + return [] + + beats = [] + for i in range(len(self.tones)): + for j in range(i + 1, len(self.tones)): + f1 = self.tones[i].pitch() + f2 = self.tones[j].pitch() + beats.append((self.tones[i], self.tones[j], abs(f1 - f2))) + return sorted(beats, key=lambda b: b[2]) + + @property + def beat_pulse(self): + """The slowest (most perceptible) beat frequency in the chord, in Hz. + + This is the beat frequency between the two tones closest in + pitch — the pair that produces the most audible amplitude + modulation. In a well-tuned chord this value is typically 0 + (unison pairs) or very large (distinct intervals); a non-zero + value under ~15 Hz indicates perceptible beating that may + suggest the chord is slightly out of tune. + + Returns 0 for chords with fewer than 2 tones, or when all + tones are identical (perfect unison). + """ + beats = self.beat_frequencies + if not beats: + return 0 + for _, _, hz in beats: + if hz > 0: + return hz + return 0 def fingering(self, *positions): if not len(positions) == len(self.tones): diff --git a/test_pytheory.py b/test_pytheory.py index 567508b..c671b5d 100644 --- a/test_pytheory.py +++ b/test_pytheory.py @@ -1048,15 +1048,30 @@ def test_ionian_mode_intervals(): # ── Chord intervals and properties ────────────────────────────────────────── def test_chord_intervals_c_major(): - """C4-E4-G4 intervals should be ~67.96 Hz and ~98.00 Hz.""" + """C4-E4-G4 intervals should be 4 and 3 semitones (major 3rd + minor 3rd).""" c_major = Chord(tones=[ Tone.from_string("C4", system="western"), Tone.from_string("E4", system="western"), Tone.from_string("G4", system="western"), ]) - intervals = c_major.intervals - assert len(intervals) == 2 - assert all(i > 0 for i in intervals) + assert c_major.intervals == [4, 3] + + +def test_chord_intervals_octave(): + c_oct = Chord(tones=[ + Tone.from_string("C4", system="western"), + Tone.from_string("C5", system="western"), + ]) + assert c_oct.intervals == [12] + + +def test_chord_intervals_minor_triad(): + a_minor = Chord(tones=[ + Tone.from_string("A4", system="western"), + Tone.from_string("C5", system="western"), + Tone.from_string("E5", system="western"), + ]) + assert a_minor.intervals == [3, 4] # minor 3rd + major 3rd def test_chord_beat_pulse_unison(): @@ -1077,16 +1092,30 @@ def test_chord_beat_pulse_octave(): assert abs(chord.beat_pulse - 440.0) < 0.01 +def test_chord_beat_frequencies(): + """beat_frequencies returns sorted (tone, tone, hz) tuples.""" + chord = Chord(tones=[ + Tone.from_string("C4", system="western"), + Tone.from_string("E4", system="western"), + Tone.from_string("G4", system="western"), + ]) + beats = chord.beat_frequencies + assert len(beats) == 3 # 3 pairs from 3 tones + # Should be sorted ascending by Hz + assert beats[0][2] <= beats[1][2] <= beats[2][2] + + def test_chord_empty(): chord = Chord(tones=[]) assert chord.harmony == 0 assert chord.dissonance == 0 assert chord.beat_pulse == 0 assert chord.intervals == [] + assert chord.beat_frequencies == [] -def test_chord_harmony_more_consonant(): - """A perfect fifth should be more harmonious than a tritone.""" +def test_chord_harmony_fifth_beats_tritone(): + """A perfect fifth (3:2) should score higher harmony than a tritone.""" fifth = Chord(tones=[ Tone.from_string("C4", system="western"), Tone.from_string("G4", system="western"), @@ -1095,12 +1124,56 @@ def test_chord_harmony_more_consonant(): Tone.from_string("C4", system="western"), Tone.from_string("F#4", system="western"), ]) - # The fifth interval is larger, so 1/interval is smaller → less harmony - # Actually in this model: harmony = sum(1/interval), and the fifth is a wider interval - # So the tritone (smaller interval) has higher harmony score - # This is testing the actual behavior of the model - assert fifth.harmony > 0 - assert tritone.harmony > 0 + assert fifth.harmony > tritone.harmony + + +def test_chord_harmony_octave_highest(): + """An octave (2:1) should score highest harmony.""" + octave = Chord(tones=[ + Tone.from_string("C4", system="western"), + Tone.from_string("C5", system="western"), + ]) + fifth = Chord(tones=[ + Tone.from_string("C4", system="western"), + Tone.from_string("G4", system="western"), + ]) + assert octave.harmony > fifth.harmony + + +def test_chord_dissonance_tritone_vs_fifth(): + """A tritone should produce more roughness than a perfect fifth.""" + tritone = Chord(tones=[ + Tone.from_string("C4", system="western"), + Tone.from_string("F#4", system="western"), + ]) + fifth = Chord(tones=[ + Tone.from_string("C4", system="western"), + Tone.from_string("G4", system="western"), + ]) + assert tritone.dissonance > fifth.dissonance + + +def test_chord_dissonance_wide_interval_low(): + """Very wide intervals (octave+) should have low roughness.""" + octave = Chord(tones=[ + Tone.from_string("C4", system="western"), + Tone.from_string("C5", system="western"), + ]) + third = Chord(tones=[ + Tone.from_string("C4", system="western"), + Tone.from_string("E4", system="western"), + ]) + # Octave exceeds critical bandwidth → less roughness than a 3rd + assert octave.dissonance < third.dissonance + + +def test_chord_dissonance_positive(): + """Any two distinct tones should produce non-zero roughness.""" + chord = Chord(tones=[ + Tone.from_string("C4", system="western"), + Tone.from_string("G4", system="western"), + ]) + assert chord.dissonance > 0 def test_chord_fingering_wrong_positions_raises():