counterpoint-engine 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- counterpoint_engine/__init__.py +92 -0
- counterpoint_engine/exceptions.py +213 -0
- counterpoint_engine/generator.py +1477 -0
- counterpoint_engine/laman_counterpoint.py +137 -0
- counterpoint_engine/rules.py +627 -0
- counterpoint_engine/tensor_output.py +310 -0
- counterpoint_engine-0.1.0.dist-info/METADATA +258 -0
- counterpoint_engine-0.1.0.dist-info/RECORD +10 -0
- counterpoint_engine-0.1.0.dist-info/WHEEL +5 -0
- counterpoint_engine-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""
|
|
2
|
+
counterpoint_engine — Species counterpoint as constraint satisfaction.
|
|
3
|
+
|
|
4
|
+
Proves the core thesis: counterpoint = constraint satisfaction = Laman rigidity.
|
|
5
|
+
|
|
6
|
+
Each rule returns SAT or UNSAT.
|
|
7
|
+
Each voice is a vertex in a Laman graph.
|
|
8
|
+
Each contrapuntal constraint is an edge.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from .rules import (
|
|
12
|
+
no_parallel_fifths,
|
|
13
|
+
no_parallel_octaves,
|
|
14
|
+
proper_resolution,
|
|
15
|
+
max_leap_seventh,
|
|
16
|
+
consonant_interval,
|
|
17
|
+
voice_independence,
|
|
18
|
+
SAT,
|
|
19
|
+
UNSAT,
|
|
20
|
+
Satisfiability,
|
|
21
|
+
)
|
|
22
|
+
from .laman_counterpoint import (
|
|
23
|
+
CounterpointGraph,
|
|
24
|
+
henneberg_construct,
|
|
25
|
+
verify_rigidity,
|
|
26
|
+
)
|
|
27
|
+
from .generator import (
|
|
28
|
+
CounterpointGenerator,
|
|
29
|
+
CounterpointResult,
|
|
30
|
+
Species,
|
|
31
|
+
VoiceRange,
|
|
32
|
+
Scale,
|
|
33
|
+
)
|
|
34
|
+
from .tensor_output import (
|
|
35
|
+
TensorMIDIEvent,
|
|
36
|
+
voices_to_tensor_events,
|
|
37
|
+
voice_leading_to_sidechannels,
|
|
38
|
+
)
|
|
39
|
+
from .exceptions import (
|
|
40
|
+
CounterpointError,
|
|
41
|
+
ConstraintViolationError,
|
|
42
|
+
ParallelFifthsError,
|
|
43
|
+
ParallelOctavesError,
|
|
44
|
+
VoiceCrossingError,
|
|
45
|
+
RangeViolationError,
|
|
46
|
+
ResolutionError,
|
|
47
|
+
LeapViolationError,
|
|
48
|
+
InvalidInputError,
|
|
49
|
+
GenerationError,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
__version__ = "0.1.0"
|
|
53
|
+
|
|
54
|
+
__all__ = [
|
|
55
|
+
# Rules
|
|
56
|
+
"no_parallel_fifths",
|
|
57
|
+
"no_parallel_octaves",
|
|
58
|
+
"proper_resolution",
|
|
59
|
+
"max_leap_seventh",
|
|
60
|
+
"consonant_interval",
|
|
61
|
+
"voice_independence",
|
|
62
|
+
"SAT",
|
|
63
|
+
"UNSAT",
|
|
64
|
+
"Satisfiability",
|
|
65
|
+
# Laman
|
|
66
|
+
"CounterpointGraph",
|
|
67
|
+
"henneberg_construct",
|
|
68
|
+
"verify_rigidity",
|
|
69
|
+
# Generator
|
|
70
|
+
"CounterpointGenerator",
|
|
71
|
+
"CounterpointResult",
|
|
72
|
+
"Species",
|
|
73
|
+
"VoiceRange",
|
|
74
|
+
"Scale",
|
|
75
|
+
# Tensor output
|
|
76
|
+
"TensorMIDIEvent",
|
|
77
|
+
"voices_to_tensor_events",
|
|
78
|
+
"voice_leading_to_sidechannels",
|
|
79
|
+
# Exceptions
|
|
80
|
+
"CounterpointError",
|
|
81
|
+
"ConstraintViolationError",
|
|
82
|
+
"ParallelFifthsError",
|
|
83
|
+
"ParallelOctavesError",
|
|
84
|
+
"VoiceCrossingError",
|
|
85
|
+
"RangeViolationError",
|
|
86
|
+
"ResolutionError",
|
|
87
|
+
"LeapViolationError",
|
|
88
|
+
"InvalidInputError",
|
|
89
|
+
"GenerationError",
|
|
90
|
+
# Meta
|
|
91
|
+
"__version__",
|
|
92
|
+
]
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""Custom exceptions for counterpoint-specific errors.
|
|
2
|
+
|
|
3
|
+
These exceptions provide structured error information for constraint
|
|
4
|
+
violations (parallel fifths/octaves, voice crossing, range violations)
|
|
5
|
+
and input validation failures.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Optional, Sequence, Tuple
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class CounterpointError(Exception):
|
|
14
|
+
"""Base exception for all counterpoint-engine errors."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, message: str, *, detail: str = "") -> None:
|
|
17
|
+
self.detail = detail
|
|
18
|
+
super().__init__(message)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# ---------------------------------------------------------------------------
|
|
22
|
+
# Constraint violations
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
class ConstraintViolationError(CounterpointError):
|
|
26
|
+
"""A contrapuntal constraint was violated during generation or checking."""
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
message: str,
|
|
31
|
+
*,
|
|
32
|
+
constraint: str = "",
|
|
33
|
+
beat: int = -1,
|
|
34
|
+
voices: Optional[Sequence[int]] = None,
|
|
35
|
+
detail: str = "",
|
|
36
|
+
) -> None:
|
|
37
|
+
self.constraint = constraint
|
|
38
|
+
self.beat = beat
|
|
39
|
+
self.voices = list(voices) if voices is not None else []
|
|
40
|
+
super().__init__(message, detail=detail)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class ParallelFifthsError(ConstraintViolationError):
|
|
44
|
+
"""Parallel perfect fifths detected between two voices."""
|
|
45
|
+
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
voice_a: Sequence[int],
|
|
49
|
+
voice_b: Sequence[int],
|
|
50
|
+
beat_prev: int,
|
|
51
|
+
beat_curr: int,
|
|
52
|
+
) -> None:
|
|
53
|
+
self.voice_a = list(voice_a)
|
|
54
|
+
self.voice_b = list(voice_b)
|
|
55
|
+
self.beat_prev = beat_prev
|
|
56
|
+
self.beat_curr = beat_curr
|
|
57
|
+
super().__init__(
|
|
58
|
+
f"Parallel fifths between beats {beat_prev} and {beat_curr}",
|
|
59
|
+
constraint="no_parallel_fifths",
|
|
60
|
+
beat=beat_curr,
|
|
61
|
+
detail=(
|
|
62
|
+
f"voice_a[{beat_prev}]={voice_a[beat_prev]}, "
|
|
63
|
+
f"voice_a[{beat_curr}]={voice_a[beat_curr]}, "
|
|
64
|
+
f"voice_b[{beat_prev}]={voice_b[beat_prev]}, "
|
|
65
|
+
f"voice_b[{beat_curr}]={voice_b[beat_curr]}"
|
|
66
|
+
),
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class ParallelOctavesError(ConstraintViolationError):
|
|
71
|
+
"""Parallel perfect octaves detected between two voices."""
|
|
72
|
+
|
|
73
|
+
def __init__(
|
|
74
|
+
self,
|
|
75
|
+
voice_a: Sequence[int],
|
|
76
|
+
voice_b: Sequence[int],
|
|
77
|
+
beat_prev: int,
|
|
78
|
+
beat_curr: int,
|
|
79
|
+
) -> None:
|
|
80
|
+
self.voice_a = list(voice_a)
|
|
81
|
+
self.voice_b = list(voice_b)
|
|
82
|
+
self.beat_prev = beat_prev
|
|
83
|
+
self.beat_curr = beat_curr
|
|
84
|
+
super().__init__(
|
|
85
|
+
f"Parallel octaves between beats {beat_prev} and {beat_curr}",
|
|
86
|
+
constraint="no_parallel_octaves",
|
|
87
|
+
beat=beat_curr,
|
|
88
|
+
detail=(
|
|
89
|
+
f"voice_a[{beat_prev}]={voice_a[beat_prev]}, "
|
|
90
|
+
f"voice_a[{beat_curr}]={voice_a[beat_curr]}, "
|
|
91
|
+
f"voice_b[{beat_prev}]={voice_b[beat_prev]}, "
|
|
92
|
+
f"voice_b[{beat_curr}]={voice_b[beat_curr]}"
|
|
93
|
+
),
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class VoiceCrossingError(ConstraintViolationError):
|
|
98
|
+
"""Voice crossing: a lower-numbered voice is above a higher-numbered one."""
|
|
99
|
+
|
|
100
|
+
def __init__(
|
|
101
|
+
self,
|
|
102
|
+
voice_upper: int,
|
|
103
|
+
voice_lower: int,
|
|
104
|
+
beat: int,
|
|
105
|
+
pitch_upper: int,
|
|
106
|
+
pitch_lower: int,
|
|
107
|
+
) -> None:
|
|
108
|
+
self.voice_upper_idx = voice_upper
|
|
109
|
+
self.voice_lower_idx = voice_lower
|
|
110
|
+
self.pitch_upper = pitch_upper
|
|
111
|
+
self.pitch_lower = pitch_lower
|
|
112
|
+
super().__init__(
|
|
113
|
+
f"Voice crossing at beat {beat}: voice {voice_upper} "
|
|
114
|
+
f"({pitch_upper}) below voice {voice_lower} ({pitch_lower})",
|
|
115
|
+
constraint="voice_crossing",
|
|
116
|
+
beat=beat,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class RangeViolationError(ConstraintViolationError):
|
|
121
|
+
"""A pitch falls outside the allowed range for its voice."""
|
|
122
|
+
|
|
123
|
+
def __init__(
|
|
124
|
+
self,
|
|
125
|
+
pitch: int,
|
|
126
|
+
min_pitch: int,
|
|
127
|
+
max_pitch: int,
|
|
128
|
+
voice_index: int = -1,
|
|
129
|
+
beat: int = -1,
|
|
130
|
+
) -> None:
|
|
131
|
+
self.pitch = pitch
|
|
132
|
+
self.min_pitch = min_pitch
|
|
133
|
+
self.max_pitch = max_pitch
|
|
134
|
+
self.voice_index = voice_index
|
|
135
|
+
super().__init__(
|
|
136
|
+
f"Pitch {pitch} out of range [{min_pitch}, {max_pitch}]"
|
|
137
|
+
+ (f" for voice {voice_index}" if voice_index >= 0 else "")
|
|
138
|
+
+ (f" at beat {beat}" if beat >= 0 else ""),
|
|
139
|
+
constraint="voice_range",
|
|
140
|
+
beat=beat,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class ResolutionError(ConstraintViolationError):
|
|
145
|
+
"""Leading tone failed to resolve to the tonic."""
|
|
146
|
+
|
|
147
|
+
def __init__(
|
|
148
|
+
self,
|
|
149
|
+
voice: Sequence[int],
|
|
150
|
+
beat: int,
|
|
151
|
+
leading_tone: int,
|
|
152
|
+
tonic: int,
|
|
153
|
+
) -> None:
|
|
154
|
+
self.leading_tone = leading_tone
|
|
155
|
+
self.tonic = tonic
|
|
156
|
+
super().__init__(
|
|
157
|
+
f"Leading tone at beat {beat - 1} ({voice[beat - 1]}) "
|
|
158
|
+
f"did not resolve to tonic; got {voice[beat]} at beat {beat}",
|
|
159
|
+
constraint="proper_resolution",
|
|
160
|
+
beat=beat,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class LeapViolationError(ConstraintViolationError):
|
|
165
|
+
"""Melodic leap exceeds the maximum allowed interval."""
|
|
166
|
+
|
|
167
|
+
def __init__(
|
|
168
|
+
self,
|
|
169
|
+
prev_pitch: int,
|
|
170
|
+
curr_pitch: int,
|
|
171
|
+
max_leap: int,
|
|
172
|
+
beat: int,
|
|
173
|
+
) -> None:
|
|
174
|
+
self.prev_pitch = prev_pitch
|
|
175
|
+
self.curr_pitch = curr_pitch
|
|
176
|
+
self.actual_leap = abs(curr_pitch - prev_pitch)
|
|
177
|
+
self.max_leap = max_leap
|
|
178
|
+
super().__init__(
|
|
179
|
+
f"Leap of {self.actual_leap} semitones at beat {beat} "
|
|
180
|
+
f"exceeds maximum {max_leap}",
|
|
181
|
+
constraint="max_leap_seventh",
|
|
182
|
+
beat=beat,
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# ---------------------------------------------------------------------------
|
|
187
|
+
# Input validation errors
|
|
188
|
+
# ---------------------------------------------------------------------------
|
|
189
|
+
|
|
190
|
+
class InvalidInputError(CounterpointError):
|
|
191
|
+
"""Raised when generator input fails validation."""
|
|
192
|
+
|
|
193
|
+
def __init__(self, parameter: str, value: object, reason: str) -> None:
|
|
194
|
+
self.parameter = parameter
|
|
195
|
+
self.value = value
|
|
196
|
+
super().__init__(f"Invalid {parameter}={value!r}: {reason}")
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
class GenerationError(CounterpointError):
|
|
200
|
+
"""Raised when counterpoint generation fails entirely."""
|
|
201
|
+
|
|
202
|
+
def __init__(
|
|
203
|
+
self,
|
|
204
|
+
message: str,
|
|
205
|
+
*,
|
|
206
|
+
species: int = 0,
|
|
207
|
+
n_voices: int = 0,
|
|
208
|
+
cantus_length: int = 0,
|
|
209
|
+
) -> None:
|
|
210
|
+
self.species = species
|
|
211
|
+
self.n_voices = n_voices
|
|
212
|
+
self.cantus_length = cantus_length
|
|
213
|
+
super().__init__(message)
|