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,665 @@
1
+ """Compositional form tracking — section sequences, transitions, and lookahead.
2
+
3
+ Defines :class:`SectionInfo` (immutable per-bar snapshot) and
4
+ :class:`FormState` (the stateful form engine that advances through sections).
5
+
6
+ These are registered on the :class:`~subsequence.composition.Composition`
7
+ via :meth:`~subsequence.composition.Composition.form` and read by pattern
8
+ builders through ``p.section``.
9
+
10
+ A form may be a :class:`~subsequence.forms.Form` value (Sections with
11
+ energy/key payloads), a plain list of ``(name, bars)`` tuples or Sections,
12
+ a generator yielding ``(name, bars)`` pairs, or a weighted-graph dict.
13
+ Everything normalises to :class:`~subsequence.forms.Section` internally —
14
+ the payload travels with the section either way.
15
+ """
16
+
17
+ import dataclasses
18
+ import logging
19
+ import random
20
+ import typing
21
+
22
+ import subsequence.forms
23
+ import subsequence.weighted_graph
24
+
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+
29
+ # Form-end behaviours (sequence and generator modes; graphs end via their
30
+ # terminal sections).
31
+ _AT_END_CHOICES: typing.FrozenSet[str] = frozenset({"stop", "hold", "loop"})
32
+
33
+
34
+ @dataclasses.dataclass
35
+ class SectionInfo:
36
+
37
+ """
38
+ An immutable snapshot of the current section in the compositional form.
39
+
40
+ Patterns read ``p.section`` to make context-aware decisions, such as increasing
41
+ intensity as a section progresses or playing variation only in certain blocks.
42
+
43
+ Attributes:
44
+ name: The string name of the section (e.g., "verse").
45
+ bar: The current bar index within this section (0-indexed).
46
+ bars: Total number of bars in this section.
47
+ index: The global index of this section in the form's timeline.
48
+ next_section: The name of the upcoming section (or ``None`` if the
49
+ form will end after this section). This is pre-decided when
50
+ the current section begins, so patterns can plan lead-ins.
51
+ A performer or code can override it with ``composition.form_next()``.
52
+ energy: The section's energy payload (0.5 unless a bound
53
+ :class:`~subsequence.forms.Form` says otherwise; the
54
+ ``composition.energy()`` dict overrides it at read time).
55
+ key: The section's key override, or ``None`` (a higher tier — form
56
+ key, then composition key — supplies it).
57
+ scale: The section's scale/mode override, or ``None`` (falls back
58
+ through the form scale to the composition scale).
59
+
60
+ Example:
61
+ ```python
62
+ @composition.pattern(channel=9)
63
+ def drums (p):
64
+ # Always play a basic kick
65
+ p.hit_steps("kick", [0, 8])
66
+
67
+ # Only add snare and hats during the "chorus"
68
+ if p.section and p.section.name == "chorus":
69
+ p.hit_steps("snare", [4, 12])
70
+
71
+ # Use .progress (0.0 to 1.0) to build a riser
72
+ vel = int(60 + 40 * p.section.progress)
73
+ p.hit_steps("hh", list(range(16)), velocity=vel)
74
+
75
+ # Plan a lead-in on the last bar before a different section
76
+ if p.section and p.section.ending:
77
+ p.hit_steps("snare", [0, 2, 4, 6, 8, 10, 12, 14], velocity=100)
78
+ ```
79
+ """
80
+
81
+ name: str
82
+ bar: int
83
+ bars: int
84
+ index: int
85
+ next_section: typing.Optional[str] = None
86
+ energy: float = 0.5
87
+ key: typing.Optional[str] = None
88
+ scale: typing.Optional[str] = None
89
+
90
+ @property
91
+ def progress (self) -> float:
92
+
93
+ """Return how far through this section we are (0.0 to ~1.0)."""
94
+
95
+ if self.bars <= 0:
96
+ return 0.0
97
+
98
+ return self.bar / self.bars
99
+
100
+ @property
101
+ def first_bar (self) -> bool:
102
+
103
+ """Return True if this is the first bar of the section."""
104
+
105
+ return self.bar == 0
106
+
107
+ @property
108
+ def last_bar (self) -> bool:
109
+
110
+ """Return True if this is the last bar of the section."""
111
+
112
+ return self.bar == self.bars - 1
113
+
114
+ @property
115
+ def ending (self) -> bool:
116
+
117
+ """True on the last bar before a DIFFERENT section.
118
+
119
+ A repeat (verse → verse) is not an ending, and neither is the
120
+ form's end — ``ending`` marks the bars where transition material
121
+ (fills, mutes) belongs.
122
+ """
123
+
124
+ return (
125
+ self.last_bar
126
+ and self.next_section is not None
127
+ and self.next_section != self.name
128
+ )
129
+
130
+
131
+ class FormState:
132
+
133
+ """Track compositional form as a sequence of named sections with bar durations."""
134
+
135
+ def __init__ (
136
+ self,
137
+ sections: typing.Union[
138
+ "subsequence.forms.Form",
139
+ typing.List[typing.Any],
140
+ typing.Iterator[typing.Tuple[str, int]],
141
+ typing.Dict[str, typing.Tuple[int, typing.Optional[typing.List[typing.Tuple[str, int]]]]]
142
+ ],
143
+ loop: bool = False,
144
+ start: typing.Optional[str] = None,
145
+ rng: typing.Optional[random.Random] = None,
146
+ at_end: str = "stop",
147
+ ) -> None:
148
+
149
+ """
150
+ Initialize from a Form, list, iterator, or dict of weighted section transitions.
151
+
152
+ Parameters:
153
+ sections: Form definition. A :class:`~subsequence.forms.Form`
154
+ value, a list of Sections / ``(name, bars)`` tuples, an
155
+ iterator yielding ``(name, bars)`` tuples, or a dictionary
156
+ defining a weighted directed graph for generative progression.
157
+ loop: Sugar for ``at_end="loop"`` (sequence mode).
158
+ start: Name of the starting section when using a graph dict. If omitted,
159
+ it defaults to the first key in the dictionary.
160
+ rng: Optional seeded ``random.Random`` for deterministic graph decisions.
161
+ at_end: What happens when a sequence/generator form runs out —
162
+ ``"stop"`` (the form finishes; default), ``"hold"`` (the
163
+ final section repeats until navigated away from), or
164
+ ``"loop"`` (start over). Graphs end via their terminal
165
+ sections, so a graph only accepts ``"stop"``.
166
+ """
167
+
168
+ if at_end not in _AT_END_CHOICES:
169
+ choices = ", ".join(sorted(_AT_END_CHOICES))
170
+ raise ValueError(f"at_end must be one of {choices}, got {at_end!r}")
171
+
172
+ if loop:
173
+ if at_end not in ("stop", "loop"):
174
+ raise ValueError(f"loop=True conflicts with at_end={at_end!r} — pass one or the other")
175
+ at_end = "loop"
176
+
177
+ self._at_end: str = at_end
178
+ self._current: typing.Optional[subsequence.forms.Section] = None
179
+ self._bar_in_section: int = 0
180
+ self._section_index: int = 0
181
+ self._total_bars: int = 0
182
+ self._finished: bool = False
183
+
184
+ # Graph mode state (only set when sections is a dict).
185
+ self._graph: typing.Optional[subsequence.weighted_graph.WeightedGraph] = None
186
+ self._section_bars: typing.Optional[typing.Dict[str, int]] = None
187
+ self._rng: random.Random = rng or random.Random()
188
+ self._iterator: typing.Optional[typing.Iterator[typing.Tuple[str, int]]] = None
189
+
190
+ # Sequence mode state (list or Form): the whole timeline is known,
191
+ # which is what makes jump_to()/queue_next() navigable.
192
+ self._sequence: typing.Optional[typing.List[subsequence.forms.Section]] = None
193
+ self._position: int = 0
194
+ self._queued_position: typing.Optional[int] = None
195
+
196
+ # Terminal sections (graph mode only): sections with None transitions.
197
+ self._terminal_sections: typing.Set[str] = set()
198
+
199
+ # Next-section lookahead: pre-decided when entering a section so
200
+ # patterns can read p.section.next_section for lead-ins.
201
+ # Overridable at any time via queue_next().
202
+ self._next_section_name: typing.Optional[str] = None
203
+
204
+ # Iterator peek buffer (generator mode only).
205
+ self._peeked: typing.Optional[subsequence.forms.Section] = None
206
+ self._peek_exhausted: bool = False
207
+
208
+ if isinstance(sections, dict):
209
+ # Graph mode: build a WeightedGraph from the dict.
210
+ if at_end != "stop":
211
+ raise ValueError(
212
+ f"at_end={at_end!r} applies to sequence forms — a graph form ends "
213
+ "via its terminal sections (give a section None transitions)"
214
+ )
215
+
216
+ self._graph = subsequence.weighted_graph.WeightedGraph()
217
+ self._section_bars = {}
218
+
219
+ for name, (bars, transitions) in sections.items():
220
+ if bars < 1:
221
+ raise ValueError(f"Section '{name}' must last at least 1 bar, got {bars}")
222
+
223
+ self._section_bars[name] = bars
224
+ if transitions is None:
225
+ self._terminal_sections.add(name)
226
+ else:
227
+ for target, weight in transitions:
228
+ # Validate targets against the whole dict now — a typo
229
+ # would otherwise crash with a bare KeyError at the
230
+ # first section boundary DURING playback, every bar.
231
+ if target not in sections:
232
+ known = ", ".join(sorted(sections))
233
+ raise ValueError(
234
+ f"Section '{name}' transitions to unknown section "
235
+ f"'{target}'. Known sections: {known}"
236
+ )
237
+ self._graph.add_transition(name, target, weight)
238
+
239
+ start_name = start if start is not None else next(iter(sections))
240
+
241
+ if start_name not in self._section_bars:
242
+ raise ValueError(f"Start section '{start_name}' not found in form definition")
243
+
244
+ self._current = subsequence.forms.Section(name = start_name, bars = self._section_bars[start_name])
245
+ self._pick_next()
246
+
247
+ elif isinstance(sections, (subsequence.forms.Form, list)):
248
+ # Sequence mode: the timeline is a known, navigable list.
249
+ elements = sections.sections if isinstance(sections, subsequence.forms.Form) else sections
250
+ self._sequence = [subsequence.forms._coerce_section(element) for element in elements]
251
+
252
+ if self._sequence:
253
+ self._current = self._sequence[0]
254
+ else:
255
+ self._finished = True
256
+
257
+ self._pick_next()
258
+
259
+ else:
260
+ # Generator/iterator mode: use directly.
261
+ if at_end == "loop":
262
+ raise ValueError(
263
+ 'at_end="loop" cannot replay a generator form — pass a list '
264
+ '(or Form) if the form should cycle'
265
+ )
266
+
267
+ self._iterator = sections
268
+
269
+ try:
270
+ self._current = subsequence.forms._coerce_section(next(self._iterator))
271
+ except StopIteration:
272
+ self._finished = True
273
+
274
+ self._peek_iterator()
275
+
276
+ def _pick_next (self) -> None:
277
+
278
+ """Pre-decide the next section so patterns can read it as a lookahead.
279
+
280
+ In graph mode, calls ``choose_next()`` on the weighted graph.
281
+ In sequence mode, reads the timeline (respecting ``at_end``).
282
+ In generator mode, delegates to ``_peek_iterator()``.
283
+ """
284
+
285
+ if self._graph is not None:
286
+ assert self._current is not None
287
+ current_name = self._current.name
288
+
289
+ if current_name in self._terminal_sections:
290
+ self._next_section_name = None
291
+ else:
292
+ self._next_section_name = self._graph.choose_next(current_name, self._rng)
293
+
294
+ elif self._sequence is not None:
295
+ if self._queued_position is not None:
296
+ self._next_section_name = self._sequence[self._queued_position].name
297
+ else:
298
+ upcoming = self._sequence_next_position()
299
+ self._next_section_name = self._sequence[upcoming].name if upcoming is not None else (
300
+ self._current.name if self._at_end == "hold" and self._current is not None else None
301
+ )
302
+
303
+ else:
304
+ self._peek_iterator()
305
+
306
+ def _sequence_next_position (self) -> typing.Optional[int]:
307
+
308
+ """The natural next position in sequence mode, or None at the end.
309
+
310
+ ``at_end="loop"`` wraps; ``"hold"`` and ``"stop"`` both return
311
+ None here — hold's repeat is decided at the boundary in
312
+ :meth:`advance` (the position does not move).
313
+ """
314
+
315
+ assert self._sequence is not None
316
+
317
+ # An empty form has no position to move to — even under "loop".
318
+ if not self._sequence:
319
+ return None
320
+
321
+ following = self._position + 1
322
+
323
+ if following < len(self._sequence):
324
+ return following
325
+
326
+ if self._at_end == "loop":
327
+ return 0
328
+
329
+ return None
330
+
331
+ def _peek_iterator (self) -> None:
332
+
333
+ """Peek the next element from the iterator into a one-element buffer."""
334
+
335
+ if self._peek_exhausted or self._iterator is None:
336
+ self._next_section_name = (
337
+ self._current.name
338
+ if self._at_end == "hold" and self._current is not None and self._peek_exhausted
339
+ else None
340
+ )
341
+ return
342
+
343
+ try:
344
+ self._peeked = subsequence.forms._coerce_section(next(self._iterator))
345
+ self._next_section_name = self._peeked.name
346
+ except StopIteration:
347
+ self._peeked = None
348
+ self._peek_exhausted = True
349
+ self._next_section_name = (
350
+ self._current.name if self._at_end == "hold" and self._current is not None else None
351
+ )
352
+
353
+ def _find_occurrence (self, section_name: str, what: str) -> int:
354
+
355
+ """Find the next occurrence of a name in the sequence, searching forward and wrapping."""
356
+
357
+ assert self._sequence is not None
358
+
359
+ count = len(self._sequence)
360
+
361
+ for step in range(1, count + 1):
362
+ candidate = (self._position + step) % count
363
+ if self._sequence[candidate].name == section_name:
364
+ return candidate
365
+
366
+ known = ", ".join(sorted({section.name for section in self._sequence}))
367
+ raise ValueError(
368
+ f"{what}: section '{section_name}' not found in form. "
369
+ f"Known sections: {known}"
370
+ )
371
+
372
+ def queue_next (self, section_name: str) -> None:
373
+
374
+ """Queue a section to play after the current one ends.
375
+
376
+ Overrides the automatically pre-decided next section. The queued
377
+ section takes effect at the natural section boundary — the current
378
+ section plays to completion first. In sequence mode the form
379
+ continues from the queued occurrence onward.
380
+
381
+ Queuing after the form has finished revives it: the queued section
382
+ starts at the next bar and the form continues from there.
383
+
384
+ Available in graph and sequence (list/Form) modes; a generator form
385
+ cannot be navigated.
386
+
387
+ Args:
388
+ section_name: The section to queue.
389
+
390
+ Raises:
391
+ ValueError: If the form is a generator, or the name is unknown.
392
+ """
393
+
394
+ if self._sequence is not None:
395
+ self._queued_position = self._find_occurrence(section_name, "queue_next")
396
+ self._next_section_name = section_name
397
+ logger.info(f"Form: next → {section_name}")
398
+ return
399
+
400
+ if self._section_bars is None:
401
+ raise ValueError(
402
+ "queue_next() needs a navigable form (a graph dict, a list, or a Form value) — "
403
+ "a generator form cannot be navigated"
404
+ )
405
+
406
+ if section_name not in self._section_bars:
407
+ known = ", ".join(sorted(self._section_bars))
408
+ raise ValueError(
409
+ f"Section '{section_name}' not found in form. "
410
+ f"Known sections: {known}"
411
+ )
412
+
413
+ self._next_section_name = section_name
414
+ logger.info(f"Form: next → {section_name}")
415
+
416
+ def advance (self) -> bool:
417
+
418
+ """Advance one bar, transitioning to the next section when needed, returning True if section changed."""
419
+
420
+ if self._finished:
421
+
422
+ # A queued section revives a finished form at the next bar —
423
+ # queue_next() after the end is a command to play again, not a
424
+ # call to be silently ignored (sequence mode used to trip the
425
+ # not-finished invariant; graph mode ignored it).
426
+ if self._sequence is not None and self._queued_position is not None:
427
+ self._position = self._queued_position
428
+ self._queued_position = None
429
+ self._current = self._sequence[self._position]
430
+ elif self._graph is not None and self._next_section_name is not None:
431
+ assert self._section_bars is not None
432
+ self._current = subsequence.forms.Section(
433
+ name = self._next_section_name,
434
+ bars = self._section_bars[self._next_section_name],
435
+ )
436
+ else:
437
+ return False
438
+
439
+ self._finished = False
440
+ self._section_index += 1
441
+ self._bar_in_section = 0
442
+ self._pick_next()
443
+ return True
444
+
445
+ self._bar_in_section += 1
446
+ self._total_bars += 1
447
+
448
+ assert self._current is not None, "Form state invariant: current should not be None when not finished"
449
+
450
+ if self._bar_in_section >= self._current.bars:
451
+
452
+ if self._graph is not None:
453
+ # Graph mode: consume the pre-decided (or queued) next section.
454
+ if self._next_section_name is None:
455
+ # Terminal section — form ends.
456
+ self._finished = True
457
+ self._current = None
458
+ return True
459
+
460
+ assert self._section_bars is not None
461
+ next_name = self._next_section_name
462
+ self._current = subsequence.forms.Section(name = next_name, bars = self._section_bars[next_name])
463
+ self._section_index += 1
464
+ self._bar_in_section = 0
465
+ self._pick_next()
466
+ return True
467
+
468
+ elif self._sequence is not None:
469
+ # Sequence mode: a queued jump wins; otherwise the timeline
470
+ # (with at_end deciding what happens past the last section).
471
+ if self._queued_position is not None:
472
+ self._position = self._queued_position
473
+ self._queued_position = None
474
+ else:
475
+ following = self._sequence_next_position()
476
+
477
+ if following is None:
478
+ if self._at_end == "hold":
479
+ # The final section repeats (a re-entry: the index
480
+ # bumps so bound material restarts correctly).
481
+ self._section_index += 1
482
+ self._bar_in_section = 0
483
+ self._pick_next()
484
+ return True
485
+
486
+ self._finished = True
487
+ self._current = None
488
+ return True
489
+
490
+ self._position = following
491
+
492
+ self._current = self._sequence[self._position]
493
+ self._section_index += 1
494
+ self._bar_in_section = 0
495
+ self._pick_next()
496
+ return True
497
+
498
+ else:
499
+ # Generator mode: consume from the peek buffer.
500
+ if self._peeked is not None:
501
+ self._current = self._peeked
502
+ self._peeked = None
503
+ self._section_index += 1
504
+ self._bar_in_section = 0
505
+ self._peek_iterator()
506
+ return True
507
+ elif self._at_end == "hold":
508
+ self._section_index += 1
509
+ self._bar_in_section = 0
510
+ return True
511
+ else:
512
+ self._finished = True
513
+ self._current = None
514
+ return True
515
+
516
+ return False
517
+
518
+ def get_section_info (self) -> typing.Optional[SectionInfo]:
519
+
520
+ """Return current section info, or None if the form is exhausted."""
521
+
522
+ if self._finished or self._current is None:
523
+ return None
524
+
525
+ return SectionInfo(
526
+ name = self._current.name,
527
+ bar = self._bar_in_section,
528
+ bars = self._current.bars,
529
+ index = self._section_index,
530
+ next_section = self._next_section_name,
531
+ energy = self._current.energy,
532
+ key = self._current.key,
533
+ scale = self._current.scale,
534
+ )
535
+
536
+ def section_info_at_bar (self, bar: int) -> typing.Optional[SectionInfo]:
537
+
538
+ """Return the section covering a 1-based GLOBAL bar, or ``None``.
539
+
540
+ Available for **sequence** forms only (lists and ``Form`` values — the
541
+ whole timeline is known, so a bar maps to a section by accumulating
542
+ ``Section.bars``). Graph and generator forms have no fixed layout
543
+ ahead of the playhead, so they return ``None`` (callers fall back to
544
+ the playhead section). A looping form wraps; a finite form past its
545
+ end returns ``None``.
546
+
547
+ Used to key a relative ``pin_chord`` to the section that *owns* the
548
+ pinned bar rather than the section at the playhead — they differ when
549
+ the harmonic clock's lookahead projects a pin into a later, possibly
550
+ differently-keyed, section. This is a layout lookup (linear from bar
551
+ 1); live ``form_jump`` is not reflected, which is acceptable for
552
+ pre-set pins (the playhead path stays authoritative for live moves).
553
+ """
554
+
555
+ if self._sequence is None or bar < 1:
556
+ return None
557
+
558
+ total = sum(section.bars for section in self._sequence)
559
+
560
+ if total <= 0:
561
+ return None
562
+
563
+ index0 = bar - 1
564
+
565
+ if index0 >= total:
566
+ if self._at_end == "loop":
567
+ index0 = index0 % total
568
+ else:
569
+ return None
570
+
571
+ cursor = 0
572
+
573
+ for position, section in enumerate(self._sequence):
574
+ if cursor <= index0 < cursor + section.bars:
575
+ # Mirror the playhead path (_sequence_next_position/_pick_next)
576
+ # at the end of the timeline: a looping form's last section
577
+ # leads back to the first, a holding form's repeats itself —
578
+ # so .ending and next_section agree between the two sources.
579
+ if position + 1 < len(self._sequence):
580
+ following: typing.Optional[str] = self._sequence[position + 1].name
581
+ elif self._at_end == "loop":
582
+ following = self._sequence[0].name
583
+ elif self._at_end == "hold":
584
+ following = section.name
585
+ else:
586
+ following = None
587
+
588
+ return SectionInfo(
589
+ name = section.name,
590
+ bar = index0 - cursor,
591
+ bars = section.bars,
592
+ index = position,
593
+ next_section = following,
594
+ energy = section.energy,
595
+ key = section.key,
596
+ scale = section.scale,
597
+ )
598
+ cursor += section.bars
599
+
600
+ return None
601
+
602
+ @property
603
+ def total_bars (self) -> int:
604
+
605
+ """Return the global bar count since the form started."""
606
+
607
+ return self._total_bars
608
+
609
+ def jump_to (self, section_name: str) -> None:
610
+
611
+ """Force the form to a named section immediately.
612
+
613
+ Available in **graph mode** (dict forms) and **sequence mode**
614
+ (list/Form forms — the jump lands on the next occurrence of the
615
+ name, searching forward and wrapping, and the form continues from
616
+ there). A generator form cannot be navigated.
617
+
618
+ The section restarts from bar 0. The musical effect is not heard
619
+ until the *next pattern rebuild cycle*, because already-queued MIDI
620
+ notes are unaffected. This is the same natural quantization that
621
+ applies to all ``composition.data`` writes and
622
+ ``composition.tweak()`` calls.
623
+
624
+ Args:
625
+ section_name: Name of the section to jump to. Must exist in the
626
+ form definition passed to ``composition.form()``.
627
+
628
+ Raises:
629
+ ValueError: If the form is a generator, or the name is unknown.
630
+
631
+ Example::
632
+
633
+ composition.form_jump("chorus") # via Composition helper
634
+ """
635
+
636
+ if self._sequence is not None:
637
+ self._position = self._find_occurrence(section_name, "jump_to")
638
+ self._current = self._sequence[self._position]
639
+ self._bar_in_section = 0
640
+ self._section_index += 1
641
+ self._finished = False
642
+ self._queued_position = None
643
+ self._pick_next()
644
+ logger.info(f"Form: jump → {section_name}")
645
+ return
646
+
647
+ if self._section_bars is None:
648
+ raise ValueError(
649
+ "jump_to() needs a navigable form (a graph dict, a list, or a Form value) — "
650
+ "a generator form cannot be navigated"
651
+ )
652
+
653
+ if section_name not in self._section_bars:
654
+ known = ", ".join(sorted(self._section_bars))
655
+ raise ValueError(
656
+ f"Section '{section_name}' not found in form. "
657
+ f"Known sections: {known}"
658
+ )
659
+
660
+ self._current = subsequence.forms.Section(name = section_name, bars = self._section_bars[section_name])
661
+ self._bar_in_section = 0
662
+ self._section_index += 1
663
+ self._finished = False
664
+ self._pick_next()
665
+ logger.info(f"Form: jump → {section_name}")