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.
Files changed (78) hide show
  1. subsequence/__init__.py +231 -0
  2. subsequence/__main__.py +24 -0
  3. subsequence/assets/web/index.html +345 -0
  4. subsequence/cadences.py +113 -0
  5. subsequence/chord_graphs/__init__.py +100 -0
  6. subsequence/chord_graphs/aeolian_minor.py +158 -0
  7. subsequence/chord_graphs/chromatic_mediant.py +113 -0
  8. subsequence/chord_graphs/diminished.py +97 -0
  9. subsequence/chord_graphs/dorian_minor.py +127 -0
  10. subsequence/chord_graphs/functional_major.py +102 -0
  11. subsequence/chord_graphs/hooktheory_major.py +88 -0
  12. subsequence/chord_graphs/lydian_major.py +130 -0
  13. subsequence/chord_graphs/mixolydian.py +98 -0
  14. subsequence/chord_graphs/phrygian_minor.py +76 -0
  15. subsequence/chord_graphs/suspended.py +109 -0
  16. subsequence/chord_graphs/turnaround_global.py +157 -0
  17. subsequence/chord_graphs/whole_tone.py +77 -0
  18. subsequence/chords.py +419 -0
  19. subsequence/composition.py +6099 -0
  20. subsequence/conductor.py +238 -0
  21. subsequence/constants/__init__.py +24 -0
  22. subsequence/constants/durations.py +37 -0
  23. subsequence/constants/instruments/__init__.py +13 -0
  24. subsequence/constants/instruments/gm_cc.py +46 -0
  25. subsequence/constants/instruments/gm_drums.py +53 -0
  26. subsequence/constants/instruments/gm_instruments.py +32 -0
  27. subsequence/constants/instruments/roland_tr8s.py +320 -0
  28. subsequence/constants/instruments/vermona_drm1_drums.py +87 -0
  29. subsequence/constants/midi_notes.py +29 -0
  30. subsequence/constants/pulses.py +17 -0
  31. subsequence/constants/velocity.py +22 -0
  32. subsequence/definitions.py +232 -0
  33. subsequence/display.py +617 -0
  34. subsequence/easing.py +347 -0
  35. subsequence/event_emitter.py +109 -0
  36. subsequence/form_state.py +665 -0
  37. subsequence/forms.py +257 -0
  38. subsequence/groove.py +323 -0
  39. subsequence/harmonic_rhythm.py +83 -0
  40. subsequence/harmonic_state.py +352 -0
  41. subsequence/harmony.py +197 -0
  42. subsequence/held_notes.py +91 -0
  43. subsequence/helpers/__init__.py +0 -0
  44. subsequence/helpers/network.py +58 -0
  45. subsequence/helpers/wing.py +430 -0
  46. subsequence/intervals.py +436 -0
  47. subsequence/keystroke.py +249 -0
  48. subsequence/link_clock.py +128 -0
  49. subsequence/live_client.py +187 -0
  50. subsequence/live_reloader.py +298 -0
  51. subsequence/live_server.py +161 -0
  52. subsequence/melodic_state.py +483 -0
  53. subsequence/midi.py +97 -0
  54. subsequence/midi_utils.py +329 -0
  55. subsequence/mini_notation.py +164 -0
  56. subsequence/motifs.py +2356 -0
  57. subsequence/osc.py +194 -0
  58. subsequence/pattern.py +363 -0
  59. subsequence/pattern_algorithmic.py +2010 -0
  60. subsequence/pattern_builder.py +2589 -0
  61. subsequence/pattern_midi.py +1208 -0
  62. subsequence/progressions.py +1913 -0
  63. subsequence/py.typed +0 -0
  64. subsequence/roles.py +63 -0
  65. subsequence/sequence_utils.py +3123 -0
  66. subsequence/sequencer.py +2086 -0
  67. subsequence/tuning.py +453 -0
  68. subsequence/voicings.py +151 -0
  69. subsequence/web_ui.py +337 -0
  70. subsequence/weighted_graph.py +156 -0
  71. subsequence-0.6.4.dist-info/METADATA +208 -0
  72. subsequence-0.6.4.dist-info/RECORD +78 -0
  73. subsequence-0.6.4.dist-info/WHEEL +5 -0
  74. subsequence-0.6.4.dist-info/entry_points.txt +2 -0
  75. subsequence-0.6.4.dist-info/licenses/LICENSE +661 -0
  76. subsequence-0.6.4.dist-info/scm_file_list.json +193 -0
  77. subsequence-0.6.4.dist-info/scm_version.json +8 -0
  78. 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)