subsequence.melodic_state

Persistent melodic context for NIR-guided single-note line generation.

Provides 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 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 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

class subsequence.melodic_state.MelodicState(key: str | None = None, mode: str | None = 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)[source]

Persistent melodic context that applies NIR scoring to single-note lines.

Initialise a melodic state for a given key, mode, and MIDI register.

Parameters:
  • 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").

  • mode – Scale mode name. Accepts any mode registered with scale_pitch_classes() (e.g. "ionian", "aeolian", "dorian"). When omitted, adopts the composition’s scale (falling back to "ionian").

  • low – Lowest MIDI note (inclusive) in the pitch pool.

  • high – Highest MIDI note (inclusive) in the pitch pool.

  • nir_strength – 0.0–1.0. Scales how strongly the NIR rules influence candidate scores. 0.0 = uniform; 1.0 = full boost.

  • chord_weight – 0.0–1.0. Additive multiplier bonus for candidates whose pitch class belongs to the current chord tones.

  • rest_probability – 0.0–1.0. Probability of producing a rest (returning None) at any given step.

  • pitch_diversity – 0.0–1.0. Exponential penalty per recent repetition of the same pitch. Lower values discourage repetition more aggressively.

  • 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).

choose_next(chord_tones: List[int] | None, rng: random.Random, beat: float | None = None, position: float | None = None, contour_target: float | None = None) int | None[source]

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.

clone() MelodicState[source]

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.

configure_defaults(key: str | None, mode: str | None) None[source]

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.

record(pitch: int) None[source]

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.

set_pool(pitches: Sequence[int]) None[source]

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).

class subsequence.melodic_state.ScoringContext[source]

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).

candidate[source]

The candidate MIDI pitch.

history[source]

Recent chosen pitches, oldest first (capped at 4).

chord_tone_pcs[source]

Pitch classes of the current chord tones (empty set when no chord context).

tonic_pc[source]

The key’s tonic pitch class (Rule C’s landing point).

low / high

The register bounds of the pitch pool.

beat[source]

The beat the note will sound on, within its pattern cycle.

position[source]

Normalised 0–1 position through a generated span.

contour_target[source]

Normalised 0–1 target height at position (the contour envelope’s value).

subsequence.melodic_state.chord_tone_factor(state: MelodicState, ctx: ScoringContext) float[source]

Boost candidates whose pitch class belongs to the current chord.

subsequence.melodic_state.contour_factor(state: MelodicState, ctx: ScoringContext) float[source]

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.

subsequence.melodic_state.diversity_factor(state: MelodicState, ctx: ScoringContext) float[source]

Exponential penalty for recently-heard pitches.

subsequence.melodic_state.nir_factor(state: MelodicState, ctx: ScoringContext) float[source]

Narmour expectation: reversal after leaps, continuation after steps, closure on the tonic, preference for proximity — scaled by nir_strength.

subsequence.melodic_state.range_gravity_factor(state: MelodicState, ctx: ScoringContext) float[source]

Penalise notes far from the centre of the register (quadratic).

subsequence.melodic_state.tessitura_factor(state: MelodicState, ctx: ScoringContext) float[source]

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.