05.28.07
Pitch class sets, version 1.1
I have been studying Pitch Class Sets a bit more. Two books have been very helpful:
- The classic of the field, The Structure of Atonal Music, by Allen Forte (1973).
- A relatively new work, Introduction to Post-Tonal Theory, by Joseph Straus (3rd ed., 2005).
Having read these books, I find I understand the concepts a bit more thoroughly. So, I ended up revising my Python module, pcset.py, as well as its unit testing framework, test_pcset.py.
The complete script for pcset.py 1.1 is given below. Notable changes from version 1.0:
- card() — An obvious function I overlooked; returns the cardinality of the set (number of members). This will turn out to be very important for a later module I’m building.
- Ixy(x,y) — Inversion around an arbitary axis: x maps to y, and y maps to x. The standard inversion is a rotation around the axis from 0 to 6 — F becomes G, and G becomes F. This could also be written as Ixy(’F',’G') or Ixy(5,7).
The complete test code (test_pcset.py 1.1) is also given below; the new 1.1 functions are tested, and the code was refactored in the Empty Operations Test portion using setUp().
All the operations defined in the Pitch Class Set are unary, affecting only the pitch class itself. I am in the process of designing a new, separate module which will implement binary relations and functions (operations on two sets at once).
As a sidenote, if there are any expert Python programmers out there: I think it’d be more convenient if I could load my scripts somewhere else, as distinct files, instead of pasting them here. Sadly, being a musician, I’m aware of many facilities for posting audio — even for posting documents and images — but none for posting arbitrary code with the freedom I’m accustomed to. If you have any advice for me, please leave a comment.
pcset.py: (version 1.1 — compare to version 1.0):
# version 1.1 -- Bruce H. McCosar -- 28-May-2007
__metaclass__ = type
NOTE_TABLE = [
('C','B#'),
('C#','Db'),
('D',),
('D#','Eb'),
('E','Fb'),
('F','E#'),
('F#','Gb'),
('G',),
('G#','Ab'),
('A',),
('A#','Bb'),
('B','Cb')
]
class NoteTranslationError(Exception):
"""
This exception is raised whenever an error occurs in translating
the written representation of a note to its numeric equivalent.
Only single sharps and flats are allowed; note names A-G are
permitted, and must be capitalized. Any strings containing note
names must carefully delimit each name using whitespace.
"""
def pitchclass(notestring):
"""
Translates a single note string to its numeric equivalent.
C is zero, B is 11; single sharps and flats are permitted for
note names in the range A-G.
"""
try:
notestring + ''
except TypeError:
raise TypeError, 'Input must be a string.'
for n, possible_spellings in enumerate(NOTE_TABLE):
if notestring in possible_spellings:
return n
oops = notestring + ' is not a valid note name.'
raise NoteTranslationError, oops
class PitchClassSet:
"""
Pitch Class Set. Once defined, the set can be manipulated
using the supplied standard methods.
Reference:
http://www.jaytomlin.com/music/settheory/help.html
"""
def __init__(self, definition):
try:
"""
Multiple note sets can be input as strings, as long
as they are delimited by whitespace.
"""
terms = definition.split()
redefinition = [pitchclass(note) for note in terms]
except AttributeError:
"""
If the numeric values of a pitch class set are already
known, they can be entered directly as a list of integers.
Float and string entries in the list trigger a TypeError.
"""
def rangegrind(n):
if isinstance(n,float):
raise TypeError, 'Integer lists only.'
return n % 12
redefinition = [rangegrind(note) for note in definition]
self.definition = []
for note in redefinition:
"""
Pitch Classes in the set must be unique (used only once).
"""
if note not in self.definition:
self.definition.append(note)
def toList(self):
"""
Returns a copy of the the internal pitch list.
"""
return self.definition[:]
def toString(self, flats=True):
# Build translation table
tr = []
for spelling in NOTE_TABLE:
choice = spelling[0]
if ('#' in choice) and flats:
choice = spelling[1]
tr.append(choice)
# Build list of note strings
notes = []
for n in self.definition:
notes.append(tr[n])
# A job well done.
return ' '.join(notes)
def card(self):
"""Cardinality -- number of pitch classes in the set."""
return len(self.definition)
def invert(self):
"""Performs an inversion on the Pitch Class Set."""
self.definition = [((12-note) % 12) for note in self.definition]
def transpose(self, n):
"""Transposes notes by the given amount -- up (+) or down (-)."""
self.definition = [((note + n) % 12) for note in self.definition]
def TnI(self,n):
"""
Counterintuitive definition: TnI is first an *inversion*,
then a transposition by n.
"""
self.invert()
self.transpose(n)
def Ixy(self,x,y):
"""
Inverts around an arbitrary pitch axis; x becomes y, and y becomes x.
It is an equivalent operation to TnI(x+y).
The standard inversion is defined around the pitch axis 0 to 6. In
this system, F maps to G and G maps to F. Therefore, the standard
inversion is equivalent to Ixy('F','G'), or Ixy(5,7). Since 5 + 7
= 12, and 12 mod 12 = 0, this is equivalent to TnI(0), or inversion.
"""
try:
amount = x + y + 0
except TypeError:
amount = pitchclass(x) + pitchclass(y)
self.TnI(amount)
def sort(self):
"""
Rearranges the pitches into ascending order, with the lowest
number first.
"""
self.definition.sort()
def zerobase(self):
"""Transposes the set so that the first element is zero."""
try:
self.transpose(-self.definition[0])
except IndexError:
# Empty set!
pass
def _energy(self, pcslist):
"""
Determines the 'energy' of a set using 2^(note) encoding
of the zerobase() form. For equal length sets, this gives
lower 'energy values' for the one that are more 'tightly
packed' toward the low numbers.
"""
zeroform = PitchClassSet(pcslist)
zeroform.zerobase()
energy = 0
for bit in zeroform.toList():
energy += 2 ** bit
return energy
def normalize(self):
"""
Finds the 'Lowest Energy Configuration' for a given set.
This is done by sorting the set, finding the permutations,
then selecting the set with the lowest 'energy' (tightest
configuration to the 'left').
"""
self.sort()
def rotations(pcslist):
rotationlist = []
workingcopy = pcslist[:]
for i in range(len(pcslist)):
workingcopy.insert(0,workingcopy.pop())
rotationlist.append(workingcopy[:])
return rotationlist
for arrangement in rotations(self.definition):
if self._energy(arrangement) < self._energy(self.definition):
self.definition = arrangement
def prime(self):
"""
Finds the prime form of the set. This is defined here as the
normal form of the original or the inverted set having the lowest
'energy' and transposed by zerobase().
"""
original = PitchClassSet(self.definition)
original.normalize()
original.zerobase()
original = original.toList()
inverted = PitchClassSet(self.definition)
inverted.invert()
inverted.normalize()
inverted.zerobase()
inverted = inverted.toList()
if self._energy(original) < self._energy(inverted):
self.definition = original
else:
self.definition = inverted
def ivec(self):
"""
Finds the Interval Vector for the set. This is defined as
a six member vector, with each value representing an
interval group:
[0] = Number of Group 1 inversions (minor 2nd, major 7th)
[1] = Same, but for Group 2 (major 2nd, minor 7th)
... (and so on)
[5] = Same, but for Group 6 (the tritone)
"""
ivec = [0,0,0,0,0,0]
workingcopy = self.definition[:]
workingcopy.sort()
while len(workingcopy) > 1:
note = workingcopy.pop()
for othernote in workingcopy:
intervalclass = (note - othernote) % 12
if intervalclass > 6:
intervalclass = (12 - intervalclass) % 12
ivec[intervalclass-1] += 1
return ivec
def com(self):
"""
Finds the combinatoriality properties of the group.
This is a list of values 'n' for which TnI(n) gives
a different combination of the same group of notes.
"""
tnilist = []
holdingarea = []
for x in self.definition:
for y in self.definition:
holdingarea.append((x + y) % 12)
elements = set(holdingarea)
for value in elements:
if holdingarea.count(value) == len(self.definition):
tnilist.append(value)
return tnilist
test_pcset.py: (version 1.1 — compare to version 1.0):
#!/usr/bin/env python
# test suite version 1.1 -- Bruce H. McCosar
from pcset import PitchClassSet as Pcs
from pcset import pitchclass
from pcset import NoteTranslationError
import unittest
class NoteTranslationTests(unittest.TestCase):
def test_correct_input_sharps(self):
sharplist = ['C','C#','D','D#','E','F','F#','G','G#','A','A#','B']
self.assertEqual([pitchclass(note) for note in sharplist],
range(12))
def test_correct_input_flats(self):
flatlist = ['C','Db','D','Eb','E','F','Gb','G','Ab','A','Bb','B']
self.assertEqual([pitchclass(note) for note in flatlist],
range(12))
def test_incorrect_string_input_H(self):
self.assertRaises(NoteTranslationError,pitchclass,"H")
def test_incorrect_string_input_toosharp(self):
self.assertRaises(NoteTranslationError,pitchclass,"C##")
def test_incorrect_string_input_tooflat(self):
self.assertRaises(NoteTranslationError,pitchclass,"Bbb")
def test_incorrect_string_input_wrongcase(self):
self.assertRaises(NoteTranslationError,pitchclass,"a")
def test_incorrect_string_input_numeric(self):
self.assertRaises(TypeError,pitchclass,4)
class SetDefinitionTests(unittest.TestCase):
def test_from_empty_string(self):
pcs = Pcs('')
self.assertEqual(pcs.toList(),[])
def test_from_string_short(self):
pcs = Pcs("Eb")
self.assertEqual(pcs.toList(),[3])
def test_from_string_medium(self):
pcs = Pcs("C Eb G")
self.assertEqual(pcs.toList(),[0,3,7])
def test_from_string_chromatic(self):
pcs = Pcs("C Db D Eb E F Gb G Ab A Bb B")
self.assertEqual(pcs.toList(),range(12))
def test_from_empty_list(self):
pcs = Pcs([])
self.assertEqual(pcs.toList(),[])
def test_from_list(self):
viennese = [0,1,6]
pcs = Pcs(viennese)
self.assertEqual(pcs.toList(),viennese)
def test_from_list_with_duplicates(self):
pcs = Pcs([0,3,5,3,17])
self.assertEqual(pcs.toList(),[0,3,5])
def test_from_illegal_list_with_float(self):
self.assertRaises(TypeError,Pcs,[0,1,3.1415927])
def test_from_illegal_list_with_string(self):
self.assertRaises(TypeError,Pcs,[0,1,"cookie monster"])
class OperationTests(unittest.TestCase):
def test_toString_flats(self):
pcs = Pcs(range(12))
self.assertEqual(pcs.toString(),"C Db D Eb E F Gb G Ab A Bb B")
def test_toString_sharps(self):
pcs = Pcs(range(12))
self.assertEqual(pcs.toString(flats=False),"C C# D D# E F F# G G# A A# B")
def test_cardinality(self):
pcs = Pcs("C E G B D")
self.assertEqual(pcs.card(),5)
def test_inversion(self):
pcs = Pcs([0,3,5])
pcs.invert()
self.assertEqual(pcs.toList(),[0,9,7])
def test_transpose_up(self):
pcs = Pcs("C E G")
pcs.transpose(9)
self.assertEqual(pcs.toString(flats=False),"A C# E")
def test_transpose_down(self):
pcs = Pcs("C E G")
pcs.transpose(-3)
self.assertEqual(pcs.toString(flats=False),"A C# E")
def test_TnI(self):
pcs = Pcs([1,2,7])
pcs.TnI(3)
self.assertEqual(pcs.toList(),[2,1,8])
def test_Ixy_numeric(self):
pcs = Pcs([3,4,6])
pcs.Ixy(3,4)
self.assertEqual(pcs.toList(),[4,3,1])
def test_Ixy_string(self):
pcs = Pcs("E F G")
pcs.Ixy('F','G')
self.assertEqual(pcs.toString(),"Ab G F")
def test_sort(self):
pcs = Pcs([1,5,3,9,6])
pcs.sort()
self.assertEqual(pcs.toList(),[1,3,5,6,9])
def test_zerobase(self):
pcs = Pcs([3,7,10,2])
pcs.zerobase()
self.assertEqual(pcs.toList(),[0,4,7,11])
def test_normal_form(self):
pcs = Pcs("G B D F")
pcs.normalize()
self.assertEqual(pcs.toList(),[11,2,5,7])
def test_prime_form(self):
pcs = Pcs("D F# A")
pcs.prime()
self.assertEqual(pcs.toList(),[0,3,7])
def test_ivec(self):
pcs = Pcs([2,3,9])
self.assertEqual(pcs.ivec(),[1,0,0,0,1,1])
class CombinatorialityTests(unittest.TestCase):
def test_combinatoriality_single(self):
pcs = Pcs([0,1,2,5,9])
self.assertEqual(pcs.com(),[2])
def test_combinatoriality_multiple(self):
wholetonescale = Pcs([1,3,5,7,9,11])
self.assertEqual(wholetonescale.com(),[0,2,4,6,8,10])
def test_combinatoriality_none(self):
viennese = Pcs([0,1,6])
self.assertEqual(viennese.com(),[])
def test_com_on_major_scale(self):
major = Pcs("C D E F G A B")
ops = major.com()
before = set(major.toList())
for n in ops:
major.TnI(n)
after = set(major.toList())
self.assertEqual(before,after)
class EmptyOperationTests(unittest.TestCase):
def setUp(self):
self.empty = Pcs([])
def test_toString(self):
self.assertEqual(self.empty.toString(),"")
def test_inversion(self):
self.empty.invert()
self.assertEqual(self.empty.toList(),[])
def test_transpose_up(self):
self.empty.transpose(9)
self.assertEqual(self.empty.toString(flats=False),"")
def test_transpose_down(self):
self.empty.transpose(-3)
self.assertEqual(self.empty.toString(flats=False),"")
def test_cardinality(self):
self.assertEqual(self.empty.card(),0)
def test_TnI(self):
self.empty.TnI(30)
self.assertEqual(self.empty.toList(),[])
def test_Ixy_numeric(self):
self.empty.Ixy(4,5)
self.assertEqual(self.empty.toList(),[])
def test_Ixy_string(self):
self.assertRaises(NoteTranslationError,self.empty.Ixy,"","")
def test_sort(self):
self.empty.sort()
self.assertEqual(self.empty.toList(),[])
def test_zerobase(self):
self.empty.zerobase()
self.assertEqual(self.empty.toList(),[])
def test_normal_form(self):
self.empty.normalize()
self.assertEqual(self.empty.toList(),[])
def test_prime_form(self):
self.empty.prime()
self.assertEqual(self.empty.toList(),[])
def test_ivec(self):
self.assertEqual(self.empty.ivec(),[0,0,0,0,0,0])
def test_combinatoriality(self):
self.assertEqual(self.empty.com(),[])
class EncapsulationTests(unittest.TestCase):
"""
Tests for accidental leakage of internal data.
Ideally, only class methods should be able to alter
the list directly. Of course directly accessing
the self.definition is allowable, but I'm worried
about list reference leakage.
"""
def test_alteration_of_initial_list(self):
viennese = [0,1,6]
pcs = Pcs(viennese)
viennese[0] = 11
self.assertEqual(pcs.toList(),[0,1,6])
def test_alteration_of_returned_list(self):
pcs = Pcs([0,1,6])
oops = pcs.toList()
oops[0] = 11
self.assertEqual(pcs.toList(),[0,1,6])
if __name__ == '__main__':
unittest.main()
Software examples on this page are licensed under the CC-GNU GPL.