counterpoint-engine
🎵 Species counterpoint as constraint satisfaction — every rule returns SAT/UNSAT, voices form a Laman graph.
What It Does
Generates multi-voice counterpoint against a cantus firmus using backtracking search over musical constraints, then outputs the result as Tensor-MIDI events. Each contrapuntal rule is a predicate returning "SAT" or "UNSAT"; each voice is a vertex in a Laman graph; every constraint is an edge.
Why It Exists
Species counterpoint has been taught as a set of prohibitions for centuries. This library treats those prohibitions as constraint predicates and proves that the constraint graph on N voices is a Laman graph (2N−3 edges, minimally rigid). That guarantees no voice is redundant and every rule is load-bearing. If you remove any edge, the structure gains a degree of freedom — a voice can drift unconstrained.
The math: a set of N points in the plane is rigid iff the bar-and-joint framework on those points is Laman. Counterpoint voices are the points; interval constraints are the bars.
Quick Start
pip install -e .
from counterpoint_engine.generator import CounterpointGenerator, Species, Scale, VoiceRange
# Define a cantus firmus (C major, 8 notes)
cantus = [60, 62, 64, 65, 67, 69, 71, 72] # C D E F G A B C
gen = CounterpointGenerator(
cantus_firmus=cantus,
species=Species.FIRST,
scale=Scale(tonic=0, mode="major"),
voice_range=VoiceRange(min_pitch=48, max_pitch=67),
)
counterpoint = gen.generate()
print(counterpoint)
# → [48, 53, 52, 50, 48, 48, 50, 48]
# Multi-voice — Laman graph guarantees independence
voices = gen.generate_n_voices(n_voices=4)
# voices[0] = cantus firmus, voices[1..3] = generated
# Tensor-MIDI output
from counterpoint_engine.tensor_output import voices_to_tensor_events
tensor_events, midi_events = voices_to_tensor_events(voices)
print(tensor_events[0].to_bytes()) # b'\x3c\x00\x00\x0c'
API Overview
Rules (counterpoint_engine.rules)
Every rule returns the string "SAT" or "UNSAT".
from counterpoint_engine.rules import (
no_parallel_fifths, no_parallel_octaves, proper_resolution,
max_leap_seventh, consonant_interval, voice_independence, SAT, UNSAT
)
voice_a = [60, 62, 64, 65]
voice_b = [67, 69, 67, 69]
beats = [0, 1, 2, 3]
assert no_parallel_fifths(voice_a, voice_b, beats) == SAT
assert consonant_interval(voice_a, voice_b, 0) == SAT
| Function | Signature | What it checks |
|---|---|---|
no_parallel_fifths |
(voice_a, voice_b, beats) → str |
No consecutive perfect fifths in similar motion |
no_parallel_octaves |
(voice_a, voice_b, beats) → str |
No consecutive perfect octaves in similar motion |
proper_resolution |
(voice, beat, key_tonic, key_leading) → str |
Leading tone resolves to tonic |
max_leap_seventh |
(voice, beat, max_leap) → str |
Melodic leap ≤ minor seventh (10 semitones) |
consonant_interval |
(voice_a, voice_b, beat, allowed) → str |
Interval at beat is a consonance |
voice_independence |
(laman_check: bool) → str |
Constraint graph is Laman rigid |
Laman Graphs (counterpoint_engine.laman_counterpoint)
from counterpoint_engine.laman_counterpoint import (
CounterpointGraph, henneberg_construct, verify_rigidity
)
graph = CounterpointGraph(n_voices=4)
print(graph.edges) # [(0,1), (0,2), (1,2), ...]
print(graph.verify_rigidity()) # True
print(graph.edge_count()) # 5 (= 2*4 - 3)
print(graph.is_minimally_rigid()) # True
edges = henneberg_construct(4, seed=42)
assert verify_rigidity(4, edges)
| Class/Function | Description |
|---|---|
CounterpointGraph |
Laman graph with add_constraint(), verify_rigidity(), is_minimally_rigid() |
henneberg_construct(n, seed) |
Build a Laman graph via Henneberg type-I construction |
verify_rigidity(n_voices, edges) |
Check Laman conditions (2N−3 edges + subset condition) |
Generator (counterpoint_engine.generator)
from counterpoint_engine.generator import CounterpointGenerator, Species, Scale, VoiceRange
gen = CounterpointGenerator(
cantus_firmus=[60, 62, 64, 65, 67, 69, 71, 72],
species=Species.FIRST,
scale=Scale(tonic=0, mode="major"),
voice_range=VoiceRange(min_pitch=48, max_pitch=72),
)
# Single voice
counterpoint = gen.generate()
# Multi-voice
voices = gen.generate_n_voices(n_voices=4)
| Class/Enum | Key attributes |
|---|---|
Species |
FIRST, SECOND, THIRD, FOURTH, FIFTH (IntEnum 1–5) |
VoiceRange |
min_pitch, max_pitch, candidates(scale, prev_pitch) |
Scale |
tonic, mode ("major"/"minor"), contains(pitch), pitch_classes() |
CounterpointGenerator |
generate(), generate_n_voices(n, ranges) |
Tensor-MIDI Output (counterpoint_engine.tensor_output)
from counterpoint_engine.tensor_output import (
voices_to_tensor_events, voice_leading_to_sidechannels,
interval_to_flux_vector, voice_intervals_to_flux_vectors,
TensorMIDIEvent
)
tensor_events, midi_events = voices_to_tensor_events(voices)
raw = tensor_events[0].to_bytes() # 4 bytes: cos, sin, beat, state
gestures = voice_leading_to_sidechannels(voices, beat=2)
# {(0,1): "Smile", (0,2): "Nod", (1,2): "Frown"}
fv = interval_to_flux_vector(7) # perfect fifth → FluxVector
| Function | Returns |
|---|---|
voices_to_tensor_events(voices) |
(List[TensorMIDIEvent], List[MidiEvent]) |
voice_leading_to_sidechannels(voices, beat) |
Dict[(i,j), str] — Nod/Smile/Frown |
interval_to_flux_vector(interval) |
FluxVector via A₂ lattice |
voice_intervals_to_flux_vectors(voices, beat) |
List[FluxVector] |
Architecture
┌─────────────────────────────────────────────────────┐
│ counterpoint-engine │
│ │
│ rules.py laman_counterpoint.py │
│ ┌──────────┐ ┌──────────────────┐ │
│ │ SAT/UNSAT│◄─────│ CounterpointGraph│ │
│ │ kernels │ │ henneberg_construct│ │
│ └────┬─────┘ └────────┬─────────┘ │
│ │ │ │
│ ▼ ▼ │
│ generator.py │
│ ┌──────────────────────────────────┐ │
│ │ CounterpointGenerator │ │
│ │ .generate() → List[int] │ │
│ │ .generate_n_voices() → voices │ │
│ └──────────────┬───────────────────┘ │
│ │ │
│ ▼ │
│ tensor_output.py │
│ ┌──────────────────────────────────┐ │
│ │ voices_to_tensor_events() │ │
│ │ voice_leading_to_sidechannels() │ │
│ │ interval_to_flux_vector() │ │
│ └──────────────────────────────────┘ │
│ │
├─────────────────────────────────────────────────────┤
│ Dependencies │
│ constraint-theory-core ─ Laman rigidity, A₂ lattice│
│ flux-tensor-midi ─ FluxVector, MidiEvent types │
└─────────────────────────────────────────────────────┘
Data flow: cantus firmus → generator (backtracking) → voices → tensor_output → TensorMIDIEvent stream
Documentation
- User Guide — Complete usage documentation
- Developer Guide — Contributing and internals
- Examples — Working code examples
Ecosystem
- constraint-theory-core — Laman rigidity, A₂ lattice, dodecet directions
- flux-tensor-midi — FluxVector, MidiEvent, tensor-midi event stream
- plato-room-musician — Music theory room in the PLATO knowledge system
Requirements
- Python ≥ 3.10
constraint-theory-core(from../constraint-theory-core, add toPYTHONPATH)flux-tensor-midi(Tensor-MIDI event types)
Installation
pip install counterpoint-engine
Or install from source with dependencies:
pip install constraint-theory-core flux-tensor-midi
git clone https://github.com/SuperInstance/counterpoint-engine.git
cd counterpoint-engine
pip install -e ".[dev]"
pytest
Status
All 78 tests pass.
License
Apache 2.0
Metadata
Release files for counterpoint-engine 0.3.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| counterpoint_engine-0.3.0.tar.gz | 52.1 kB | Without provenance | Details for counterpoint_engine-0.3.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| counterpoint_engine-0.3.0-py3-none-any.whl | Python 3 | none | any | Details for counterpoint_engine-0.3.0-py3-none-any.whl |
Total release size: 94.0 kB
Release files / counterpoint_engine-0.3.0.tar.gz
| Download URL | counterpoint_engine-0.3.0.tar.gz |
|---|---|
| Size | 52.1 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
b57ca8db48590a467396d1f7e2cb9b9616f61d1769e7d58860fa85551d14b7df
|
|
BLAKE2b-256 checksum How to use checksums |
effee395e17ac07dd4dfe1c34498a0a287f55e75fc2d36c429cf8fbfefc1dde3
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.2.0 CPython/3.10.12
|
Release files / counterpoint_engine-0.3.0-py3-none-any.whl
| Download URL | counterpoint_engine-0.3.0-py3-none-any.whl |
|---|---|
| Size | 41.9 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
665f634faa8795724f345b193c7272e1d2a4317e02e40d5404e42624a27cad96
|
|
BLAKE2b-256 checksum How to use checksums |
0c0e8b332c1d6243a877e438bd6223ff3d9f710338b84415e517b571c094fe6f
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.2.0 CPython/3.10.12
|
Security for counterpoint-engine 0.3.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.