mirror of
https://github.com/kennethreitz/pytheory.git
synced 2026-06-05 23:00:20 +00:00
Rewrite chord analysis with musically accurate models
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) <noreply@anthropic.com>
This commit is contained in:
+177
-46
@@ -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):
|
||||
|
||||
+85
-12
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user