subsequence 0.6.4__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.
- subsequence/__init__.py +231 -0
- subsequence/__main__.py +24 -0
- subsequence/assets/web/index.html +345 -0
- subsequence/cadences.py +113 -0
- subsequence/chord_graphs/__init__.py +100 -0
- subsequence/chord_graphs/aeolian_minor.py +158 -0
- subsequence/chord_graphs/chromatic_mediant.py +113 -0
- subsequence/chord_graphs/diminished.py +97 -0
- subsequence/chord_graphs/dorian_minor.py +127 -0
- subsequence/chord_graphs/functional_major.py +102 -0
- subsequence/chord_graphs/hooktheory_major.py +88 -0
- subsequence/chord_graphs/lydian_major.py +130 -0
- subsequence/chord_graphs/mixolydian.py +98 -0
- subsequence/chord_graphs/phrygian_minor.py +76 -0
- subsequence/chord_graphs/suspended.py +109 -0
- subsequence/chord_graphs/turnaround_global.py +157 -0
- subsequence/chord_graphs/whole_tone.py +77 -0
- subsequence/chords.py +419 -0
- subsequence/composition.py +6099 -0
- subsequence/conductor.py +238 -0
- subsequence/constants/__init__.py +24 -0
- subsequence/constants/durations.py +37 -0
- subsequence/constants/instruments/__init__.py +13 -0
- subsequence/constants/instruments/gm_cc.py +46 -0
- subsequence/constants/instruments/gm_drums.py +53 -0
- subsequence/constants/instruments/gm_instruments.py +32 -0
- subsequence/constants/instruments/roland_tr8s.py +320 -0
- subsequence/constants/instruments/vermona_drm1_drums.py +87 -0
- subsequence/constants/midi_notes.py +29 -0
- subsequence/constants/pulses.py +17 -0
- subsequence/constants/velocity.py +22 -0
- subsequence/definitions.py +232 -0
- subsequence/display.py +617 -0
- subsequence/easing.py +347 -0
- subsequence/event_emitter.py +109 -0
- subsequence/form_state.py +665 -0
- subsequence/forms.py +257 -0
- subsequence/groove.py +323 -0
- subsequence/harmonic_rhythm.py +83 -0
- subsequence/harmonic_state.py +352 -0
- subsequence/harmony.py +197 -0
- subsequence/held_notes.py +91 -0
- subsequence/helpers/__init__.py +0 -0
- subsequence/helpers/network.py +58 -0
- subsequence/helpers/wing.py +430 -0
- subsequence/intervals.py +436 -0
- subsequence/keystroke.py +249 -0
- subsequence/link_clock.py +128 -0
- subsequence/live_client.py +187 -0
- subsequence/live_reloader.py +298 -0
- subsequence/live_server.py +161 -0
- subsequence/melodic_state.py +483 -0
- subsequence/midi.py +97 -0
- subsequence/midi_utils.py +329 -0
- subsequence/mini_notation.py +164 -0
- subsequence/motifs.py +2356 -0
- subsequence/osc.py +194 -0
- subsequence/pattern.py +363 -0
- subsequence/pattern_algorithmic.py +2010 -0
- subsequence/pattern_builder.py +2589 -0
- subsequence/pattern_midi.py +1208 -0
- subsequence/progressions.py +1913 -0
- subsequence/py.typed +0 -0
- subsequence/roles.py +63 -0
- subsequence/sequence_utils.py +3123 -0
- subsequence/sequencer.py +2086 -0
- subsequence/tuning.py +453 -0
- subsequence/voicings.py +151 -0
- subsequence/web_ui.py +337 -0
- subsequence/weighted_graph.py +156 -0
- subsequence-0.6.4.dist-info/METADATA +208 -0
- subsequence-0.6.4.dist-info/RECORD +78 -0
- subsequence-0.6.4.dist-info/WHEEL +5 -0
- subsequence-0.6.4.dist-info/entry_points.txt +2 -0
- subsequence-0.6.4.dist-info/licenses/LICENSE +661 -0
- subsequence-0.6.4.dist-info/scm_file_list.json +193 -0
- subsequence-0.6.4.dist-info/scm_version.json +8 -0
- subsequence-0.6.4.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""TCP eval server for live coding a running composition.
|
|
2
|
+
|
|
3
|
+
Start the server by calling ``composition.live()`` before ``composition.play()``.
|
|
4
|
+
The server listens on a TCP port (default 5555) and accepts Python code from any
|
|
5
|
+
source - the bundled REPL client, an editor plugin, or a raw socket connection.
|
|
6
|
+
|
|
7
|
+
Protocol
|
|
8
|
+
────────
|
|
9
|
+
Messages are delimited by ``\\x04`` (ASCII EOT). The server reads until it
|
|
10
|
+
receives this sentinel, evaluates the code, and sends the result (or error
|
|
11
|
+
traceback) followed by ``\\x04``.
|
|
12
|
+
|
|
13
|
+
Security note: the server binds to ``localhost`` only and is opt-in via
|
|
14
|
+
``composition.live()``. It executes arbitrary Python in the composition's
|
|
15
|
+
process - this is intentional for live coding. Any process on the same
|
|
16
|
+
machine that can connect to the port has full code execution in this
|
|
17
|
+
process, so do not enable live mode on shared or multi-user hosts, and
|
|
18
|
+
never expose the port to a network.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import asyncio
|
|
22
|
+
import logging
|
|
23
|
+
import traceback
|
|
24
|
+
import typing
|
|
25
|
+
|
|
26
|
+
if typing.TYPE_CHECKING:
|
|
27
|
+
from subsequence.composition import Composition
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
SENTINEL = b"\x04"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class LiveServer:
|
|
36
|
+
|
|
37
|
+
"""Async TCP server that evaluates Python code inside a running composition."""
|
|
38
|
+
|
|
39
|
+
def __init__ (self, composition: "Composition", port: int = 5555) -> None:
|
|
40
|
+
|
|
41
|
+
"""Store a reference to the composition and the port to listen on."""
|
|
42
|
+
|
|
43
|
+
self._composition = composition
|
|
44
|
+
self._port = port
|
|
45
|
+
self._server: typing.Optional[asyncio.AbstractServer] = None
|
|
46
|
+
self._namespace: typing.Dict[str, typing.Any] = {}
|
|
47
|
+
|
|
48
|
+
async def start (self) -> None:
|
|
49
|
+
|
|
50
|
+
"""Start listening for connections on localhost."""
|
|
51
|
+
|
|
52
|
+
self._namespace = self._composition._build_live_namespace()
|
|
53
|
+
|
|
54
|
+
self._server = await asyncio.start_server(
|
|
55
|
+
self._handle_connection,
|
|
56
|
+
host = "127.0.0.1",
|
|
57
|
+
port = self._port
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
logger.info(f"Live server listening on 127.0.0.1:{self._port}")
|
|
61
|
+
|
|
62
|
+
async def stop (self) -> None:
|
|
63
|
+
|
|
64
|
+
"""Close the server and wait for it to shut down."""
|
|
65
|
+
|
|
66
|
+
if self._server is not None:
|
|
67
|
+
self._server.close()
|
|
68
|
+
await self._server.wait_closed()
|
|
69
|
+
self._server = None
|
|
70
|
+
logger.info("Live server stopped")
|
|
71
|
+
|
|
72
|
+
async def _handle_connection (self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
|
|
73
|
+
|
|
74
|
+
"""Handle a single client connection with an eval/exec loop."""
|
|
75
|
+
|
|
76
|
+
peer = writer.get_extra_info("peername")
|
|
77
|
+
logger.info(f"Live client connected: {peer}")
|
|
78
|
+
|
|
79
|
+
try:
|
|
80
|
+
|
|
81
|
+
while True:
|
|
82
|
+
|
|
83
|
+
code = await self._read_message(reader)
|
|
84
|
+
|
|
85
|
+
if code is None:
|
|
86
|
+
break
|
|
87
|
+
|
|
88
|
+
response = await asyncio.to_thread(self._evaluate, code)
|
|
89
|
+
writer.write(response.encode() + SENTINEL)
|
|
90
|
+
await writer.drain()
|
|
91
|
+
|
|
92
|
+
except ConnectionResetError:
|
|
93
|
+
logger.info(f"Live client disconnected (reset): {peer}")
|
|
94
|
+
|
|
95
|
+
except Exception as exc:
|
|
96
|
+
logger.warning(f"Live connection error: {exc}")
|
|
97
|
+
|
|
98
|
+
finally:
|
|
99
|
+
writer.close()
|
|
100
|
+
try:
|
|
101
|
+
await writer.wait_closed()
|
|
102
|
+
except Exception:
|
|
103
|
+
pass
|
|
104
|
+
logger.info(f"Live client disconnected: {peer}")
|
|
105
|
+
|
|
106
|
+
async def _read_message (self, reader: asyncio.StreamReader) -> typing.Optional[str]:
|
|
107
|
+
|
|
108
|
+
"""Read bytes until the sentinel or EOF, returning the decoded string or None."""
|
|
109
|
+
|
|
110
|
+
chunks: typing.List[bytes] = []
|
|
111
|
+
|
|
112
|
+
while True:
|
|
113
|
+
|
|
114
|
+
try:
|
|
115
|
+
chunk = await reader.read(4096)
|
|
116
|
+
except ConnectionResetError:
|
|
117
|
+
return None
|
|
118
|
+
|
|
119
|
+
if not chunk:
|
|
120
|
+
return None
|
|
121
|
+
|
|
122
|
+
if SENTINEL in chunk:
|
|
123
|
+
before, _, _ = chunk.partition(SENTINEL)
|
|
124
|
+
chunks.append(before)
|
|
125
|
+
break
|
|
126
|
+
|
|
127
|
+
chunks.append(chunk)
|
|
128
|
+
|
|
129
|
+
data = b"".join(chunks).decode("utf-8").strip()
|
|
130
|
+
|
|
131
|
+
return data if data else None
|
|
132
|
+
|
|
133
|
+
def _evaluate (self, code: str) -> str:
|
|
134
|
+
|
|
135
|
+
"""Validate, then eval/exec the code string. Return the result or error traceback."""
|
|
136
|
+
|
|
137
|
+
# Validate syntax before executing - never run invalid code.
|
|
138
|
+
try:
|
|
139
|
+
compile(code, "<live>", "exec")
|
|
140
|
+
except SyntaxError:
|
|
141
|
+
return traceback.format_exc()
|
|
142
|
+
|
|
143
|
+
# Try as an expression first (returns a value).
|
|
144
|
+
try:
|
|
145
|
+
result = eval(compile(code, "<live>", "eval"), self._namespace)
|
|
146
|
+
return repr(result) if result is not None else "OK"
|
|
147
|
+
except SyntaxError:
|
|
148
|
+
pass
|
|
149
|
+
except SystemExit:
|
|
150
|
+
return "SystemExit is not allowed in live mode."
|
|
151
|
+
except Exception:
|
|
152
|
+
return traceback.format_exc()
|
|
153
|
+
|
|
154
|
+
# Fall back to statement execution.
|
|
155
|
+
try:
|
|
156
|
+
exec(compile(code, "<live>", "exec"), self._namespace)
|
|
157
|
+
return "OK"
|
|
158
|
+
except SystemExit:
|
|
159
|
+
return "SystemExit is not allowed in live mode."
|
|
160
|
+
except Exception:
|
|
161
|
+
return traceback.format_exc()
|
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
"""Persistent melodic context for NIR-guided single-note line generation.
|
|
2
|
+
|
|
3
|
+
Provides :class:`MelodicState`, a stateful object that tracks recent pitch
|
|
4
|
+
history across bar rebuilds and applies the Narmour Implication-Realization
|
|
5
|
+
(NIR) model to score candidate pitches. Because pattern builders are
|
|
6
|
+
recreated fresh each cycle, this state must live at module level (the same
|
|
7
|
+
pattern as :class:`~subsequence.easing.EasedValue`) so melodic continuity
|
|
8
|
+
survives bar boundaries.
|
|
9
|
+
|
|
10
|
+
The NIR rules operate on **absolute MIDI pitches** (direct semitone
|
|
11
|
+
subtraction), not pitch-class modular arithmetic, so registral direction is
|
|
12
|
+
properly tracked across octaves: a leap from C4 (60) to G4 (67) is +7
|
|
13
|
+
upward, not an ambiguous -5.
|
|
14
|
+
|
|
15
|
+
Scoring follows the CHORAL separation: the **hard** constraint is structural
|
|
16
|
+
and singular (the pitch pool — candidates outside it never exist), while
|
|
17
|
+
everything *tasteful* is a **soft factor** in :attr:`MelodicState.factors` —
|
|
18
|
+
a pluggable list of multipliers (NIR expectation, chord-tone pull, range
|
|
19
|
+
gravity, pitch diversity, contour envelope, tessitura regression), every one
|
|
20
|
+
a dial and never a law. Replace or extend the list to reshape the
|
|
21
|
+
generator's taste.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
import dataclasses
|
|
25
|
+
import random
|
|
26
|
+
import typing
|
|
27
|
+
|
|
28
|
+
import subsequence.chords
|
|
29
|
+
import subsequence.intervals
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclasses.dataclass(frozen=True)
|
|
33
|
+
class ScoringContext:
|
|
34
|
+
|
|
35
|
+
"""Everything a scoring factor may read about one candidate.
|
|
36
|
+
|
|
37
|
+
``beat``, ``position``, and ``contour_target`` are optional threading
|
|
38
|
+
from the caller — ``None`` when the context does not apply (a factor
|
|
39
|
+
that needs them returns 1.0 without them).
|
|
40
|
+
|
|
41
|
+
Attributes:
|
|
42
|
+
candidate: The candidate MIDI pitch.
|
|
43
|
+
history: Recent chosen pitches, oldest first (capped at 4).
|
|
44
|
+
chord_tone_pcs: Pitch classes of the current chord tones (empty
|
|
45
|
+
set when no chord context).
|
|
46
|
+
tonic_pc: The key's tonic pitch class (Rule C's landing point).
|
|
47
|
+
low / high: The register bounds of the pitch pool.
|
|
48
|
+
beat: The beat the note will sound on, within its pattern cycle.
|
|
49
|
+
position: Normalised 0–1 position through a generated span.
|
|
50
|
+
contour_target: Normalised 0–1 target height at *position* (the
|
|
51
|
+
contour envelope's value).
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
candidate: int
|
|
55
|
+
history: typing.Tuple[int, ...]
|
|
56
|
+
chord_tone_pcs: typing.FrozenSet[int]
|
|
57
|
+
tonic_pc: int
|
|
58
|
+
low: int
|
|
59
|
+
high: int
|
|
60
|
+
beat: typing.Optional[float] = None
|
|
61
|
+
position: typing.Optional[float] = None
|
|
62
|
+
contour_target: typing.Optional[float] = None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# A scoring factor: reads the state's dials and one candidate's context,
|
|
66
|
+
# returns a multiplier (1.0 = neutral; <1 damps; >1 boosts).
|
|
67
|
+
ScoringFactor = typing.Callable[["MelodicState", ScoringContext], float]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def nir_factor (state: "MelodicState", ctx: ScoringContext) -> float:
|
|
71
|
+
|
|
72
|
+
"""Narmour expectation: reversal after leaps, continuation after steps,
|
|
73
|
+
closure on the tonic, preference for proximity — scaled by ``nir_strength``."""
|
|
74
|
+
|
|
75
|
+
if not ctx.history:
|
|
76
|
+
return 1.0
|
|
77
|
+
|
|
78
|
+
last_note = ctx.history[-1]
|
|
79
|
+
|
|
80
|
+
target_diff = ctx.candidate - last_note
|
|
81
|
+
target_interval = abs(target_diff)
|
|
82
|
+
target_direction = 1 if target_diff > 0 else -1 if target_diff < 0 else 0
|
|
83
|
+
|
|
84
|
+
boost = 0.0
|
|
85
|
+
|
|
86
|
+
# Rules A & B require an Implication context (prev -> last -> candidate).
|
|
87
|
+
if len(ctx.history) >= 2:
|
|
88
|
+
prev_note = ctx.history[-2]
|
|
89
|
+
|
|
90
|
+
prev_diff = last_note - prev_note
|
|
91
|
+
prev_interval = abs(prev_diff)
|
|
92
|
+
prev_direction = 1 if prev_diff > 0 else -1 if prev_diff < 0 else 0
|
|
93
|
+
|
|
94
|
+
# Rule A: Reversal (gap fill) — after a large leap, expect direction change.
|
|
95
|
+
if prev_interval > 4:
|
|
96
|
+
if target_direction != prev_direction and target_direction != 0:
|
|
97
|
+
boost += 0.5
|
|
98
|
+
|
|
99
|
+
if target_interval < 4:
|
|
100
|
+
boost += 0.3
|
|
101
|
+
|
|
102
|
+
# Rule B: Process (continuation) — after a small step, expect more of the same.
|
|
103
|
+
elif 0 < prev_interval < 3:
|
|
104
|
+
if target_direction == prev_direction:
|
|
105
|
+
boost += 0.4
|
|
106
|
+
|
|
107
|
+
if abs(target_interval - prev_interval) <= 1:
|
|
108
|
+
boost += 0.2
|
|
109
|
+
|
|
110
|
+
# Rule C: Closure — the tonic is a cognitively stable landing point.
|
|
111
|
+
if ctx.candidate % 12 == ctx.tonic_pc:
|
|
112
|
+
boost += 0.2
|
|
113
|
+
|
|
114
|
+
# Rule D: Proximity — smaller intervals are generally preferred.
|
|
115
|
+
if 0 < target_interval <= 3:
|
|
116
|
+
boost += 0.3
|
|
117
|
+
|
|
118
|
+
return 1.0 + boost * state.nir_strength
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def chord_tone_factor (state: "MelodicState", ctx: ScoringContext) -> float:
|
|
122
|
+
|
|
123
|
+
"""Boost candidates whose pitch class belongs to the current chord."""
|
|
124
|
+
|
|
125
|
+
if ctx.chord_tone_pcs and ctx.candidate % 12 in ctx.chord_tone_pcs:
|
|
126
|
+
return 1.0 + state.chord_weight
|
|
127
|
+
|
|
128
|
+
return 1.0
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def range_gravity_factor (state: "MelodicState", ctx: ScoringContext) -> float:
|
|
132
|
+
|
|
133
|
+
"""Penalise notes far from the centre of the register (quadratic)."""
|
|
134
|
+
|
|
135
|
+
centre = (ctx.low + ctx.high) / 2.0
|
|
136
|
+
half_range = max(1.0, (ctx.high - ctx.low) / 2.0)
|
|
137
|
+
distance_ratio = abs(ctx.candidate - centre) / half_range
|
|
138
|
+
|
|
139
|
+
return 1.0 - 0.3 * (distance_ratio ** 2)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def diversity_factor (state: "MelodicState", ctx: ScoringContext) -> float:
|
|
143
|
+
|
|
144
|
+
"""Exponential penalty for recently-heard pitches."""
|
|
145
|
+
|
|
146
|
+
recent_occurrences = sum(1 for h in ctx.history if h == ctx.candidate)
|
|
147
|
+
|
|
148
|
+
return state.pitch_diversity ** recent_occurrences
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def contour_factor (state: "MelodicState", ctx: ScoringContext) -> float:
|
|
152
|
+
|
|
153
|
+
"""Pull candidates toward the contour envelope's target height.
|
|
154
|
+
|
|
155
|
+
Active only when the caller threads ``position``/``contour_target``
|
|
156
|
+
(the generate path); a melodic walk without an envelope is unshaped.
|
|
157
|
+
"""
|
|
158
|
+
|
|
159
|
+
if ctx.contour_target is None or ctx.high <= ctx.low:
|
|
160
|
+
return 1.0
|
|
161
|
+
|
|
162
|
+
height = (ctx.candidate - ctx.low) / (ctx.high - ctx.low)
|
|
163
|
+
|
|
164
|
+
# Cubic falloff: strong enough to be heard as a shape, soft enough that
|
|
165
|
+
# NIR/diversity still pick the path along it.
|
|
166
|
+
return max(0.04, (1.0 - abs(height - ctx.contour_target)) ** 3)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def tessitura_factor (state: "MelodicState", ctx: ScoringContext) -> float:
|
|
170
|
+
|
|
171
|
+
"""Regression toward the tessitura — von Hippel's reading of post-skip reversal.
|
|
172
|
+
|
|
173
|
+
The further the line has strayed from the register's centre, the more
|
|
174
|
+
candidates that move back toward it are boosted. Off by default
|
|
175
|
+
(``tessitura_strength=0``); the generate path turns it on, where it
|
|
176
|
+
buys gap-fill and post-skip reversal without hard rules.
|
|
177
|
+
"""
|
|
178
|
+
|
|
179
|
+
if state.tessitura_strength <= 0 or not ctx.history:
|
|
180
|
+
return 1.0
|
|
181
|
+
|
|
182
|
+
centre = (ctx.low + ctx.high) / 2.0
|
|
183
|
+
half_range = max(1.0, (ctx.high - ctx.low) / 2.0)
|
|
184
|
+
displacement = (ctx.history[-1] - centre) / half_range
|
|
185
|
+
|
|
186
|
+
if abs(displacement) < 1e-9:
|
|
187
|
+
return 1.0
|
|
188
|
+
|
|
189
|
+
moves_home = (ctx.candidate - ctx.history[-1]) * (centre - ctx.history[-1]) > 0
|
|
190
|
+
|
|
191
|
+
if moves_home:
|
|
192
|
+
return 1.0 + state.tessitura_strength * min(1.0, abs(displacement)) * 0.6
|
|
193
|
+
|
|
194
|
+
return 1.0
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
DEFAULT_FACTORS: typing.Tuple[ScoringFactor, ...] = (
|
|
198
|
+
nir_factor,
|
|
199
|
+
chord_tone_factor,
|
|
200
|
+
range_gravity_factor,
|
|
201
|
+
diversity_factor,
|
|
202
|
+
contour_factor,
|
|
203
|
+
tessitura_factor,
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
class MelodicState:
|
|
208
|
+
|
|
209
|
+
"""Persistent melodic context that applies NIR scoring to single-note lines."""
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def __init__ (
|
|
213
|
+
self,
|
|
214
|
+
key: typing.Optional[str] = None,
|
|
215
|
+
mode: typing.Optional[str] = None,
|
|
216
|
+
low: int = 48,
|
|
217
|
+
high: int = 72,
|
|
218
|
+
nir_strength: float = 0.5,
|
|
219
|
+
chord_weight: float = 0.4,
|
|
220
|
+
rest_probability: float = 0.0,
|
|
221
|
+
pitch_diversity: float = 0.6,
|
|
222
|
+
tessitura_strength: float = 0.0,
|
|
223
|
+
) -> None:
|
|
224
|
+
|
|
225
|
+
"""Initialise a melodic state for a given key, mode, and MIDI register.
|
|
226
|
+
|
|
227
|
+
Parameters:
|
|
228
|
+
key: Root note of the key (e.g. ``"C"``, ``"F#"``, ``"Bb"``).
|
|
229
|
+
When omitted, the state adopts the **composition's** key the
|
|
230
|
+
first time ``p.melody()`` uses it (falling back to ``"C"``).
|
|
231
|
+
mode: Scale mode name. Accepts any mode registered with
|
|
232
|
+
:func:`~subsequence.intervals.scale_pitch_classes` (e.g.
|
|
233
|
+
``"ionian"``, ``"aeolian"``, ``"dorian"``). When omitted,
|
|
234
|
+
adopts the composition's scale (falling back to ``"ionian"``).
|
|
235
|
+
low: Lowest MIDI note (inclusive) in the pitch pool.
|
|
236
|
+
high: Highest MIDI note (inclusive) in the pitch pool.
|
|
237
|
+
nir_strength: 0.0–1.0. Scales how strongly the NIR rules
|
|
238
|
+
influence candidate scores. 0.0 = uniform; 1.0 = full boost.
|
|
239
|
+
chord_weight: 0.0–1.0. Additive multiplier bonus for candidates
|
|
240
|
+
whose pitch class belongs to the current chord tones.
|
|
241
|
+
rest_probability: 0.0–1.0. Probability of producing a rest
|
|
242
|
+
(returning ``None``) at any given step.
|
|
243
|
+
pitch_diversity: 0.0–1.0. Exponential penalty per recent
|
|
244
|
+
repetition of the same pitch. Lower values discourage
|
|
245
|
+
repetition more aggressively.
|
|
246
|
+
tessitura_strength: 0.0–1.0. Regression pull toward the centre
|
|
247
|
+
of the register after the line strays (off by default; the
|
|
248
|
+
generate path enables it).
|
|
249
|
+
"""
|
|
250
|
+
|
|
251
|
+
if nir_strength < 0 or nir_strength > 1:
|
|
252
|
+
raise ValueError("NIR strength must be between 0 and 1")
|
|
253
|
+
|
|
254
|
+
if rest_probability < 0 or rest_probability > 1:
|
|
255
|
+
raise ValueError("Rest probability must be between 0 and 1")
|
|
256
|
+
|
|
257
|
+
if pitch_diversity < 0 or pitch_diversity > 1:
|
|
258
|
+
raise ValueError("Pitch diversity must be between 0 and 1")
|
|
259
|
+
|
|
260
|
+
if chord_weight < 0 or chord_weight > 1:
|
|
261
|
+
raise ValueError("Chord weight must be between 0 and 1")
|
|
262
|
+
|
|
263
|
+
if tessitura_strength < 0 or tessitura_strength > 1:
|
|
264
|
+
raise ValueError("Tessitura strength must be between 0 and 1")
|
|
265
|
+
|
|
266
|
+
if low >= high:
|
|
267
|
+
raise ValueError("low must be below high")
|
|
268
|
+
|
|
269
|
+
# None defers to the composition (configure_defaults); the fallbacks
|
|
270
|
+
# keep a bare MelodicState() working standalone.
|
|
271
|
+
self._explicit_key = key is not None
|
|
272
|
+
self._explicit_mode = mode is not None
|
|
273
|
+
self._explicit_pool = False
|
|
274
|
+
|
|
275
|
+
self.key = key if key is not None else "C"
|
|
276
|
+
self.mode = mode if mode is not None else "ionian"
|
|
277
|
+
self.low = low
|
|
278
|
+
self.high = high
|
|
279
|
+
self.nir_strength = nir_strength
|
|
280
|
+
self.chord_weight = chord_weight
|
|
281
|
+
self.rest_probability = rest_probability
|
|
282
|
+
self.pitch_diversity = pitch_diversity
|
|
283
|
+
self.tessitura_strength = tessitura_strength
|
|
284
|
+
|
|
285
|
+
# The soft side of the CHORAL separation — replace or extend freely.
|
|
286
|
+
self.factors: typing.List[ScoringFactor] = list(DEFAULT_FACTORS)
|
|
287
|
+
|
|
288
|
+
self._rebuild_pool()
|
|
289
|
+
|
|
290
|
+
# History of last N absolute MIDI pitches (capped at 4, same as HarmonicState).
|
|
291
|
+
self.history: typing.List[int] = []
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _rebuild_pool (self) -> None:
|
|
295
|
+
|
|
296
|
+
"""Derive the pitch pool (the one hard constraint) from key/mode/register."""
|
|
297
|
+
|
|
298
|
+
self._tonic_pc: int = subsequence.chords.key_name_to_pc(self.key)
|
|
299
|
+
|
|
300
|
+
self._pitch_pool: typing.List[int] = subsequence.intervals.scale_notes(
|
|
301
|
+
self.key, self.mode, low=self.low, high=self.high
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def configure_defaults (self, key: typing.Optional[str], mode: typing.Optional[str]) -> None:
|
|
306
|
+
|
|
307
|
+
"""Adopt the surrounding key/scale where this state left them unset.
|
|
308
|
+
|
|
309
|
+
Called by ``p.melody()`` every build. It **tracks** the builder's
|
|
310
|
+
current key/scale (which is the section's effective key under a form),
|
|
311
|
+
so a state placed across sections follows each section's key — its
|
|
312
|
+
melodic *history* is untouched, only the pitch pool and tonic move.
|
|
313
|
+
An explicit constructor key/scale or an explicit pool always wins and
|
|
314
|
+
is never overridden.
|
|
315
|
+
"""
|
|
316
|
+
|
|
317
|
+
if self._explicit_pool:
|
|
318
|
+
return
|
|
319
|
+
|
|
320
|
+
changed = False
|
|
321
|
+
|
|
322
|
+
# Re-track on every call (not just the first): a persistent state used
|
|
323
|
+
# across sections must follow the live key, or the first section to
|
|
324
|
+
# place it would freeze the key forever.
|
|
325
|
+
if not self._explicit_key and key is not None and key != self.key:
|
|
326
|
+
self.key = key
|
|
327
|
+
changed = True
|
|
328
|
+
|
|
329
|
+
if not self._explicit_mode and mode is not None and mode != self.mode:
|
|
330
|
+
self.mode = mode
|
|
331
|
+
changed = True
|
|
332
|
+
|
|
333
|
+
if changed:
|
|
334
|
+
self._rebuild_pool()
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def set_pool (self, pitches: typing.Sequence[int]) -> None:
|
|
338
|
+
|
|
339
|
+
"""Replace the pitch pool with explicit MIDI pitches — the experimental seam.
|
|
340
|
+
|
|
341
|
+
Admits sieve output, non-octave organisations, or any hand-picked
|
|
342
|
+
pool; key/mode no longer constrain candidates (the tonic pitch
|
|
343
|
+
class, for Rule C, stays the key's).
|
|
344
|
+
"""
|
|
345
|
+
|
|
346
|
+
pool = sorted(int(p) for p in pitches)
|
|
347
|
+
|
|
348
|
+
if not pool:
|
|
349
|
+
raise ValueError("set_pool() needs at least one pitch")
|
|
350
|
+
|
|
351
|
+
self._pitch_pool = pool
|
|
352
|
+
self._explicit_pool = True
|
|
353
|
+
self.low = pool[0]
|
|
354
|
+
self.high = max(pool[-1], pool[0] + 1)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def clone (self) -> "MelodicState":
|
|
358
|
+
|
|
359
|
+
"""An independent copy — settings, factors, pool, and history.
|
|
360
|
+
|
|
361
|
+
Value constructors (``Motif.generate``) copy the state they are
|
|
362
|
+
given and walk the copy, so a module-level live state is never
|
|
363
|
+
mutated by building a value.
|
|
364
|
+
"""
|
|
365
|
+
|
|
366
|
+
duplicate = MelodicState(
|
|
367
|
+
key = self.key if self._explicit_key else None,
|
|
368
|
+
mode = self.mode if self._explicit_mode else None,
|
|
369
|
+
low = self.low,
|
|
370
|
+
high = self.high,
|
|
371
|
+
nir_strength = self.nir_strength,
|
|
372
|
+
chord_weight = self.chord_weight,
|
|
373
|
+
rest_probability = self.rest_probability,
|
|
374
|
+
pitch_diversity = self.pitch_diversity,
|
|
375
|
+
tessitura_strength = self.tessitura_strength,
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
duplicate.key = self.key
|
|
379
|
+
duplicate.mode = self.mode
|
|
380
|
+
duplicate._rebuild_pool()
|
|
381
|
+
|
|
382
|
+
if self._explicit_pool:
|
|
383
|
+
duplicate.set_pool(self._pitch_pool)
|
|
384
|
+
|
|
385
|
+
duplicate.factors = list(self.factors)
|
|
386
|
+
duplicate.history = list(self.history)
|
|
387
|
+
|
|
388
|
+
return duplicate
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def choose_next (
|
|
392
|
+
self,
|
|
393
|
+
chord_tones: typing.Optional[typing.List[int]],
|
|
394
|
+
rng: random.Random,
|
|
395
|
+
beat: typing.Optional[float] = None,
|
|
396
|
+
position: typing.Optional[float] = None,
|
|
397
|
+
contour_target: typing.Optional[float] = None,
|
|
398
|
+
) -> typing.Optional[int]:
|
|
399
|
+
|
|
400
|
+
"""Score all pitch-pool candidates and return the chosen pitch, or None for a rest.
|
|
401
|
+
|
|
402
|
+
``beat`` (the note's beat within its cycle), ``position`` (0–1
|
|
403
|
+
through a generated span), and ``contour_target`` (the envelope's
|
|
404
|
+
height there) thread caller context into the scoring factors.
|
|
405
|
+
"""
|
|
406
|
+
|
|
407
|
+
if self.rest_probability > 0.0 and rng.random() < self.rest_probability:
|
|
408
|
+
return None
|
|
409
|
+
|
|
410
|
+
if not self._pitch_pool:
|
|
411
|
+
return None
|
|
412
|
+
|
|
413
|
+
# Resolve chord tones to pitch classes for fast membership testing.
|
|
414
|
+
chord_tone_pcs = {t % 12 for t in chord_tones} if chord_tones else set()
|
|
415
|
+
|
|
416
|
+
scores = [
|
|
417
|
+
self._score_candidate(candidate, chord_tone_pcs, beat=beat, position=position, contour_target=contour_target)
|
|
418
|
+
for candidate in self._pitch_pool
|
|
419
|
+
]
|
|
420
|
+
|
|
421
|
+
# Weighted random choice: select using cumulative score as a probability weight.
|
|
422
|
+
total = sum(scores)
|
|
423
|
+
|
|
424
|
+
if total <= 0.0:
|
|
425
|
+
chosen = rng.choice(self._pitch_pool)
|
|
426
|
+
|
|
427
|
+
else:
|
|
428
|
+
r = rng.uniform(0.0, total)
|
|
429
|
+
cumulative = 0.0
|
|
430
|
+
chosen = self._pitch_pool[-1]
|
|
431
|
+
|
|
432
|
+
for pitch, score in zip(self._pitch_pool, scores):
|
|
433
|
+
cumulative += score
|
|
434
|
+
if r <= cumulative:
|
|
435
|
+
chosen = pitch
|
|
436
|
+
break
|
|
437
|
+
|
|
438
|
+
self.record(chosen)
|
|
439
|
+
|
|
440
|
+
return chosen
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def _score_candidate (
|
|
444
|
+
self,
|
|
445
|
+
candidate: int,
|
|
446
|
+
chord_tone_pcs: typing.Set[int],
|
|
447
|
+
beat: typing.Optional[float] = None,
|
|
448
|
+
position: typing.Optional[float] = None,
|
|
449
|
+
contour_target: typing.Optional[float] = None,
|
|
450
|
+
) -> float:
|
|
451
|
+
|
|
452
|
+
"""Score one candidate: the product of every factor in :attr:`factors`."""
|
|
453
|
+
|
|
454
|
+
ctx = ScoringContext(
|
|
455
|
+
candidate = candidate,
|
|
456
|
+
history = tuple(self.history),
|
|
457
|
+
chord_tone_pcs = frozenset(chord_tone_pcs),
|
|
458
|
+
tonic_pc = self._tonic_pc,
|
|
459
|
+
low = self.low,
|
|
460
|
+
high = self.high,
|
|
461
|
+
beat = beat,
|
|
462
|
+
position = position,
|
|
463
|
+
contour_target = contour_target,
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
score = 1.0
|
|
467
|
+
|
|
468
|
+
for factor in self.factors:
|
|
469
|
+
score *= factor(self, ctx)
|
|
470
|
+
|
|
471
|
+
return max(0.0, score)
|
|
472
|
+
|
|
473
|
+
def record (self, pitch: int) -> None:
|
|
474
|
+
|
|
475
|
+
"""Append a pitch to the melodic history (capped at 4 entries).
|
|
476
|
+
|
|
477
|
+
Public so pinned notes — chosen by fiat, not by the walk — still
|
|
478
|
+
enter the NIR context.
|
|
479
|
+
"""
|
|
480
|
+
|
|
481
|
+
self.history.append(pitch)
|
|
482
|
+
if len(self.history) > 4:
|
|
483
|
+
self.history.pop(0)
|