Twelve-Tone Serialism

Twelve-tone technique (serialism), devised by Arnold Schoenberg in the 1920s, is a way of writing music in which no note is allowed to feel like “home.” It sounds intimidating, but the mechanism is one of the simplest in all of music theory — it’s just arithmetic on the numbers 0–11.

The tone row

A tone row is an ordering of all twelve pitch classes (C, C#, D, … B) in which each one appears exactly once. It isn’t a melody — there’s no rhythm — it’s the raw pitch material the piece is built from. Because you cycle through all twelve before any can repeat, no note gets the emphasis that would make it a tonic, and the music floats free of any key.

>>> from pytheory import ToneRow
>>> row = ToneRow.from_names("C", "C#", "E", "D", "F", "D#",
...                          "F#", "A", "G#", "G", "B", "A#")
>>> row
<ToneRow C C# E D F D# F# A G# G B A#>

You can also build one straight from pitch-class numbers (0 = C):

>>> ToneRow([0, 1, 4, 2, 5, 3, 6, 9, 8, 7, 11, 10])
<ToneRow C C# E D F D# F# A G# G B A#>

PyTheory checks that what you pass really is a row — all twelve pitch classes, no repeats — so a typo can’t slip through.

The four operations

From a single row you derive 48 forms: four operations, each at any of the twelve transpositions. Every operation is just list arithmetic:

  • Prime (P) — the row as written.

  • Retrograde (R) — the row backwards.

  • Inversion (I) — every interval flipped upside-down (a step up becomes the same step down).

  • Retrograde-Inversion (RI) — the inversion, backwards.

The number you pass is the pitch class the form begins on, so P(0) starts on C and P(7) starts on G. It defaults to 0, so a bare row.P() is shorthand for the prime form on C:

>>> row.note_names("P0")
['C', 'C#', 'E', 'D', 'F', 'D#', 'F#', 'A', 'G#', 'G', 'B', 'A#']
>>> row.note_names("I0")          # intervals mirrored
['C', 'B', 'G#', 'A#', 'G', 'A', 'F#', 'D#', 'E', 'F', 'C#', 'D']
>>> row.R(0) == row.P(0)[::-1]    # retrograde really is "backwards"
True

Look up any form by label with row.form("P0") (also "I7", "R5", "RI11"), or grab all 48 at once with row.all_forms().

The matrix

The row matrix is a 12×12 grid that holds all 48 forms at once. P0 runs across the top, I0 down the left side, and the rest is filled in by addition. To read it:

  • a row, left → right, is a Prime form (right → left, a Retrograde);

  • a column, top → bottom, is an Inversion (bottom → top, a Retrograde-Inversion).

>>> print(row.matrix_str())
       I0  I1  I4  I2  I5  I3  I6  I9  I8  I7 I11 I10
  P0    C  C#   E   D   F  D#  F#   A  G#   G   B  A#   R0
 P11    B   C  D#  C#   E   D   F  G#   G  F#  A#   A   R11
  P8   G#   A   C  A#  C#   B   D   F   E  D#   G  F#   R8
 P10   A#   B   D   C  D#  C#   E   G  F#   F   A  G#   R10
  P7    G  G#   B   A   C  A#  C#   E  D#   D  F#   F   R7
  P9    A  A#  C#   B   D   C  D#  F#   F   E  G#   G   R9
  P6   F#   G  A#  G#   B   A   C  D#   D  C#   F   E   R6
  P3   D#   E   G   F  G#  F#   A   C   B  A#   D  C#   R3
  P4    E   F  G#  F#   A   G  A#  C#   C   B  D#   D   R4
  P5    F  F#   A   G  A#  G#   B   D  C#   C   E  D#   R5
  P1   C#   D   F  D#  F#   E   G  A#   A  G#   C   B   R1
  P2    D  D#  F#   E   G   F  G#   B  A#   A  C#   C   R2
      RI0 RI1 RI4 RI2 RI5 RI3 RI6 RI9 RI8 RI7RI11RI10

Prefer integers to note names? Pass names=False to print the same grid as pitch-class numbers (0–11): print(row.matrix_str(names=False)).

Why you can trust it

You don’t have to take the theory on faith — the matrix is self-checking. Because every form is a rearrangement of the same twelve numbers, three properties must always hold, and PyTheory’s tests assert exactly these:

>>> m = row.matrix()
>>> all(sorted(r) == list(range(12)) for r in m)        # every row complete
True
>>> all(sorted(c) == list(range(12)) for c in zip(*m))  # every column complete
True
>>> all(m[i][i] == 0 for i in range(12))                # diagonal all zeros
True

If any of those failed, the matrix would be wrong — so a glance confirms it’s right.

All-interval rows

Some rows are special. An all-interval row uses each of the eleven intervals (1–11 semitones) exactly once as it steps from note to note — a prized bit of craftsmanship. row.is_all_interval checks for it, and row.interval_succession shows the intervals:

>>> wedge = ToneRow([0, 11, 1, 10, 2, 9, 3, 8, 4, 7, 5, 6])
>>> wedge.interval_succession
[11, 2, 9, 4, 7, 6, 5, 8, 3, 10, 1]
>>> wedge.is_all_interval
True

From row to a piece

A ToneRow deals only in pitch — it hands back pitch classes and note names, with no rhythm, octave, or sound of its own. To actually hear a row, feed its note names into a Score. note_names() returns octave-less names like "C" and "C#", so tack on an octave to pin down each pitch:

from pytheory import ToneRow, Score, Duration
from pytheory.play import play_score   # for live playback

row = ToneRow.from_names("C", "C#", "E", "D", "F", "D#",
                         "F#", "A", "G#", "G", "B", "A#")

score = Score("4/4", bpm=120)
part = score.part("row", synth="sine", envelope="pluck")
for name in row.note_names("P0"):
    part.add(f"{name}4", Duration.QUARTER)   # the prime in octave 4

score.save_midi("row.mid")        # write a Standard MIDI File
# play_score(score)               # ...or hear it live

From there the whole toolkit opens up: layer P, I, R, and RI forms across separate parts, give each its own rhythm, and render the result. See Sequencing for arranging multi-part scores, and Playback and Export for live playback, MIDI, and notation export (ABC, LilyPond, MusicXML).

That’s the whole technique. Pick twelve notes, agree never to favour one, and let these four operations spin the material into a piece.