subsequence.melodic_state ========================= .. py:module:: subsequence.melodic_state .. autoapi-nested-parse:: Persistent melodic context for NIR-guided single-note line generation. Provides :class:`MelodicState`, a stateful object that tracks recent pitch history across bar rebuilds and applies the Narmour Implication-Realization (NIR) model to score candidate pitches. Because pattern builders are recreated fresh each cycle, this state must live at module level (the same pattern as :class:`~subsequence.easing.EasedValue`) so melodic continuity survives bar boundaries. The NIR rules operate on **absolute MIDI pitches** (direct semitone subtraction), not pitch-class modular arithmetic, so registral direction is properly tracked across octaves: a leap from C4 (60) to G4 (67) is +7 upward, not an ambiguous -5. Scoring follows the CHORAL separation: the **hard** constraint is structural and singular (the pitch pool — candidates outside it never exist), while everything *tasteful* is a **soft factor** in :attr:`MelodicState.factors` — a pluggable list of multipliers (NIR expectation, chord-tone pull, range gravity, pitch diversity, contour envelope, tessitura regression), every one a dial and never a law. Replace or extend the list to reshape the generator's taste. Module Contents --------------- .. py:class:: MelodicState(key: Optional[str] = None, mode: Optional[str] = None, low: int = 48, high: int = 72, nir_strength: float = 0.5, chord_weight: float = 0.4, rest_probability: float = 0.0, pitch_diversity: float = 0.6, tessitura_strength: float = 0.0) Persistent melodic context that applies NIR scoring to single-note lines. Initialise a melodic state for a given key, mode, and MIDI register. :param key: Root note of the key (e.g. ``"C"``, ``"F#"``, ``"Bb"``). When omitted, the state adopts the **composition's** key the first time ``p.melody()`` uses it (falling back to ``"C"``). :param mode: Scale mode name. Accepts any mode registered with :func:`~subsequence.intervals.scale_pitch_classes` (e.g. ``"ionian"``, ``"aeolian"``, ``"dorian"``). When omitted, adopts the composition's scale (falling back to ``"ionian"``). :param low: Lowest MIDI note (inclusive) in the pitch pool. :param high: Highest MIDI note (inclusive) in the pitch pool. :param nir_strength: 0.0–1.0. Scales how strongly the NIR rules influence candidate scores. 0.0 = uniform; 1.0 = full boost. :param chord_weight: 0.0–1.0. Additive multiplier bonus for candidates whose pitch class belongs to the current chord tones. :param rest_probability: 0.0–1.0. Probability of producing a rest (returning ``None``) at any given step. :param pitch_diversity: 0.0–1.0. Exponential penalty per recent repetition of the same pitch. Lower values discourage repetition more aggressively. :param tessitura_strength: 0.0–1.0. Regression pull toward the centre of the register after the line strays (off by default; the generate path enables it). .. py:method:: choose_next(chord_tones: Optional[List[int]], rng: random.Random, beat: Optional[float] = None, position: Optional[float] = None, contour_target: Optional[float] = None) -> Optional[int] Score all pitch-pool candidates and return the chosen pitch, or None for a rest. ``beat`` (the note's beat within its cycle), ``position`` (0–1 through a generated span), and ``contour_target`` (the envelope's height there) thread caller context into the scoring factors. .. py:method:: clone() -> MelodicState An independent copy — settings, factors, pool, and history. Value constructors (``Motif.generate``) copy the state they are given and walk the copy, so a module-level live state is never mutated by building a value. .. py:method:: configure_defaults(key: Optional[str], mode: Optional[str]) -> None Adopt the surrounding key/scale where this state left them unset. Called by ``p.melody()`` every build. It **tracks** the builder's current key/scale (which is the section's effective key under a form), so a state placed across sections follows each section's key — its melodic *history* is untouched, only the pitch pool and tonic move. An explicit constructor key/scale or an explicit pool always wins and is never overridden. .. py:method:: record(pitch: int) -> None Append a pitch to the melodic history (capped at 4 entries). Public so pinned notes — chosen by fiat, not by the walk — still enter the NIR context. .. py:method:: set_pool(pitches: Sequence[int]) -> None Replace the pitch pool with explicit MIDI pitches — the experimental seam. Admits sieve output, non-octave organisations, or any hand-picked pool; key/mode no longer constrain candidates (the tonic pitch class, for Rule C, stays the key's). .. py:class:: ScoringContext Everything a scoring factor may read about one candidate. ``beat``, ``position``, and ``contour_target`` are optional threading from the caller — ``None`` when the context does not apply (a factor that needs them returns 1.0 without them). .. attribute:: candidate The candidate MIDI pitch. .. attribute:: history Recent chosen pitches, oldest first (capped at 4). .. attribute:: chord_tone_pcs Pitch classes of the current chord tones (empty set when no chord context). .. attribute:: tonic_pc The key's tonic pitch class (Rule C's landing point). .. attribute:: low / high The register bounds of the pitch pool. .. attribute:: beat The beat the note will sound on, within its pattern cycle. .. attribute:: position Normalised 0–1 position through a generated span. .. attribute:: contour_target Normalised 0–1 target height at *position* (the contour envelope's value). .. py:function:: chord_tone_factor(state: MelodicState, ctx: ScoringContext) -> float Boost candidates whose pitch class belongs to the current chord. .. py:function:: contour_factor(state: MelodicState, ctx: ScoringContext) -> float Pull candidates toward the contour envelope's target height. Active only when the caller threads ``position``/``contour_target`` (the generate path); a melodic walk without an envelope is unshaped. .. py:function:: diversity_factor(state: MelodicState, ctx: ScoringContext) -> float Exponential penalty for recently-heard pitches. .. py:function:: nir_factor(state: MelodicState, ctx: ScoringContext) -> float Narmour expectation: reversal after leaps, continuation after steps, closure on the tonic, preference for proximity — scaled by ``nir_strength``. .. py:function:: range_gravity_factor(state: MelodicState, ctx: ScoringContext) -> float Penalise notes far from the centre of the register (quadratic). .. py:function:: tessitura_factor(state: MelodicState, ctx: ScoringContext) -> float Regression toward the tessitura — von Hippel's reading of post-skip reversal. The further the line has strayed from the register's centre, the more candidates that move back toward it are boosted. Off by default (``tessitura_strength=0``); the generate path turns it on, where it buys gap-fill and post-skip reversal without hard rules.