VOLTAreference

Reference / Patterns and harmony

Interweaving melodies

Patterns and harmony

Open in the workbench →
On this page · 6 sections

Write independent melodic lines that work together, and use musical constraints to explore their spacing and motion.

What it is#

Counterpoint and functional-harmony rules as pattern refinements — the Explore choices with constraints machinery grown from unary to relational. Every predicate dual-runs: forward it lints a hand-written line (warn + witness), backward it constrains the hole search. Two new engines feed the search: a seeded CSP (domain filtering + chronological backtracking) for counterpoint-shaped goals, and a roman functional walk for progressions. A third instance of the same solver — the voicing CSP — is not a hole generator at all but an ordinary combinator, voiceLines n, which turns a chord progression into n voice-led lines.

Surface#

key = "d dorian"
cantus = !"d4 f4 e4 d4 g4 f4 a4 g4 f4 e4 d4"

-- a hole: the CSP proposes lines satisfying the whole Fux bundle
counter : Pattern Pitch | species 1 cantus and ambitus 57 81 = ?cp

-- a progression hole: the roman walk proposes, pins are numeral text
prog : Pattern Pitch | onsets 8 and diatonic and smoothVoicing 12 and cadenceTo "i" = ?prog

-- and the voicing CSP: you write the harmony, it writes the four voices
parts = voiceLines 4 !"i iv v i VI III iv V"

Melodic predicates (top voice of each onset-group):

PredicateHolds when
stepwise ffraction of adjacent moves ≤ 2 semitones is ≥ f
leapRecoveryevery leap > 4 semitones is followed by an opposite step (trailing leap exempt)
singlePeakthe melodic maximum sounds exactly once
noRepeats nno immediate-repeat streak longer than n
outlines [cs]outlines between turning points restricted to classes cs; bare = ban {6, 10, 11}
beginOn t / endOn tfirst/last group's pcs ⊆ target t (roman, note, "c:maj" string, or degree)

Relational predicates (against a named sibling voice; vertical = my top pitch vs. the sibling's sounding top pitch, most-recent-onset-sustains):

PredicateHolds when
intervalsWith p [cs]every vertical interval class (compound-folded) ∈ cs
consonantOn p / consonantOn strong pverticals consonant ({0,3,4,7,8,9}; the fourth is dissonant) at every onset / on meter beats only
noParallels pno consecutive perfect verticals (0/7) with both voices in similar motion
contrary f pcontrary fraction of moving motions ≥ f
noCross pstays on one side of p (side set by first non-unison vertical)
maxSpread n pevery vertical ≤ n semitones
imperfectMostly psoft-only: distance = perfect-consonance density
onsetsOf p / speciesGrid r p / passingOnly pspecies-bundle members: onset-count parity, r:1 grid, dissonance only entered+left by step

Soft goals: any conjunct written and prefer <pred> ranks search candidates but never warns; listed separately on the hole (HoleGoal.soft).

Bundles: species 1|2|3 cantus expands at obligation-collection into the Fux rule set (note-against-note / 2:1 / 4:1); extra conjuncts add to it; species 4–5 add explicit suspension/florid rules and weighted-duration search; see Theory tools and composition workflow for their precise contract and bounds. Downstream (checker, search, cards) sees only the expanded conjuncts.

Dangling sibling names degrade to "can't check" (the onGrid contract). Beat strength follows the module meter (meterMap at bar 0; 4/4 fallback).

The voicing CSP — voiceLines n p#

Chords, scales and harmony §7's per-note voice leading, as an instance of the same solver. voiceLines n p takes a chord progression and returns n voice lines: you write the harmony, it writes the parts.

key   = "d minor"
prog  = !"i iv v i VI III iv V" |> slow 8
parts = voiceLines 4 prog            -- four independent, voice-led lines
  • Variables — the n voices of each chord (a chord = one run of pitched events sharing an onset, exactly as voiceLead groups them).
  • Domains — every chord member at every octave inside that voice's range, enumerated per chord as whole voicings, because the intra-chord rules constrain one slot rather than a pair.
  • Hard constraints
    • no voice crossing — voices strictly ascend, so a doubled member always doubles at the octave, never in unison;
    • upper-voice spacing ≤ an octave — between adjacent upper voices; the bass sits as far below the tenor as it likes;
    • every chord member sounds, root included (a triad missing its third is not that triad). With fewer voices than members the chord thins instead — each voice takes a different member;
    • noParallels between every voice pair — the same rule the linter checks: two perfect verticals (class 0 or 7) joined by similar motion.
  • Soft objective — least total motion, summed over voices and transitions, with two documented weights: the bass counts 0.35 (a leaping bass is idiomatic, not rough voice leading) and carries a flat 6-semitone non-root penalty, so root position is the default and an inversion has to earn its place. The first chord has no predecessor, so it is placed by tessitura instead.
  • Per-voice ranges — derived, not declared: the ensemble is centred on the first chord's mean written pitch, voice centres stacked a fifth apart symmetrically about it, each window ±10 semitones. For a chord written around middle C that lands within a semitone or two of the textbook SATB tessituras, and it follows the register you wrote — voiceLines 4 (transpose 12 prog) voices an octave higher.
  • Voice identity — each solved line is stamped L0 (lowest) … L{n-1}, the same voice_tag the manual voiceLines [a, b, …] stamps, so on a polyphonic glide synth each line glides as its own voice.
  • Ordering — applied after all structural combinators, as Chords, scales and harmony §7 requires: the final chord → per-voice-line step. Rests and non-pitched events pass through untouched.

One name, two forms, told apart by arity. voiceLines [a, b, …] is the manual poly-glide stack (Glide between notes); voiceLines n p is the solved one. Both mean n lines, one pattern, per-line voice identity — one written by hand, the other solved. p |> voiceLines 4 works too (the pipe threads the pattern first; either argument order is accepted).

voiceLead is untouched. It remains the block-octave fold — each chord shifted bodily to sit nearest its predecessor, spelling intact — that the shipped catalogue depends on. The two are different tools, and examples/songs/voicelines.volta runs the same eight numerals through both so the difference is audible.

Failure is a diagnostic, never a panic.

SituationResult
more voices than the ranges can hold non-crossingCompileError::Lower naming the chord, its pitch classes, and the ranges
n outside 1..8, or not a whole numberCompileError::Lower with the accepted form
no voicing chain avoids parallel perfectsnoParallels relaxes to a weighted penalty; a warn-level VoiceLeadingRelaxed diagnostic names the transitions that gave, and the music still lowers

Determinism. No RNG anywhere: the objective totally orders the candidates and ties break on the domains' own ascending order, so a voicing is a pure function of its input.

Adaptive-JI identity survives. A voice only ever takes a written member at another octave, and the emitted event is a clone of that member, so chord_root / just_cents ride along untouched — octaves are pure, the same reason voiceLead's block shifts keep their ratios (Tuning systems). Pinned by voice_lines_preserves_adaptive_ji_identity.

How it's implemented#

  • Predicates: compiler/src/solve.rs (PatternPred, PcTarget, parser, species expansion, prefer marker); verdicts + witnesses in compiler/src/solve/patternpred.rs (one membership function for checker and search — Invariant 1).
  • CSP: compiler/src/solve/csp.rs — per-slot domain filters from vertical/ endpoint rules (an emptied domain attributes its two culprit conjuncts), chronological DFS with seeded value order, windowed pruning (noParallels/noRepeats/leapRecovery/passingOnly + optimistic stepwise/contrary bounds), leaf verification through patternpred::score. Runs as generator "csp" once per page in solve/search.rs::run_search when melodic/relational predicates appear.
  • The two-voice rules are also a small library. patternpred::parallel_perfects(vs) (noParallels) and patternpred::crossings(vs) (noCross) take a run of verticals (time, voice, other) and yield every offending index. The forward checker stops at the first; the workbench's voice-leading view (The harmony Lens § Voices) lists them all. patternpred::leap_exceeds is the maxLeap bound, and the harmony walk's step cost calls it too. patternpred::ic is the compound-folded interval class.
  • Voicing CSP: compiler/src/solve/harmony_gen.rs — per-chord domain enumeration (crossing / spacing / completeness prune during construction), then a chronological left→right walk. Because the objective and the one surviving between-slot rule both look only at adjacent chords, the chain is Markov: one forward pass carrying a per-candidate best-cost column and a back-pointer finds the global optimum exactly — csp.rs's DFS with the backtracking collapsed, so there is no budget and no incumbent to run out of. Two passes: noParallels hard, then relaxed if that admits nothing. Lowering is lower/combinator.rs::pat_voice_lines_options (grouping, range derivation, event rebuild, L{n} stamping); the relaxation advisory rides a thread-local drained in lower.rs, the take_arrange_scale_warnings pattern.
  • Roman walk: solve/search.rs::gen_roman — T→S→D→T over the key's own stacked-third qualities (casing derived per mode: natural minor walks v), voice-aware transition redraws against the smoothVoicing bound, roman cadenceTo pins the tail (ii/IV → V → target). Pins are numeral text (generator "roman"); they re-lower through roman_chord unchanged.
  • Seeding/determinism unchanged from constraint holes: splitmix64 off hash(module seed, hole, reroll); same seed ⇒ byte-identical page.
  • Tests: compiler/tests/counterpoint.rs (~61: linter verdicts, CSP determinism/pin-soundness/unsat, species, roman mode-correctness, and the voicing CSP end-to-end — crossing, spacing, parallels across four keys, motion, ranges, completeness, degenerate diagnostics, JI identity, plus regression pins on the voiceLines list form and voiceLead's output) and solve/harmony_gen.rs's own unit tests (10, including the relaxation ladder); examples examples/songs/counterpoint.volta, examples/songs/voicelines.volta.

The species board (the counterpoint lens)#

The bench dock that draws the rules. Design & staging: Counterpoint Lens (L1–L7, all shipped). It renders verdicts — it never re-derives a musical judgment the compiler already made.

  • Data plane — solve_report (compiler/src/solve/search.rs) returns a SolveReport: per-slot domains, per-predicate verdicts, PredVerdict.anchors (the structured witnesses), and unsat attribution. Reached from Studio through EngineBridge::solve_poll and the shared ui/src/solver.rs adapter. Desktop uses a bounded worker thread; both browser pages route to the existing compiler Worker through solve_editor_snapshot. They use the same Rust rules, search and report conversion, with no remote service or native-only theory dependency. Source, imports, seed, locks, masks and the working line identify each request; stale results cannot replace current candidates. Pending searches show a status and leave the UI usable.
  • Dock — ui/src/counterlens.rs, mounted from ui/src/bench.rs; the board ▸ chip appears on a goal-carrying pattern hole or a pinned rule-carrying literal. Rule chips are labelled by the predicate's verbatim describe() string and render Solid (hard) or Ghost (soft/prefer); masking a chip is session-local.
  • Linter board — one shared-pitch canvas: the sibling voice hollow, the working line filled, candidate ghosts translucent; interval figures and consonance colouring at the vertical midpoint; violation ties drawn from PredReport.anchors; hovering a chip highlights its columns.
  • Domain ribbon — per-slot stripes with per-member ticks in hole mode; an emptied slot is outlined when the CSP goes unsat.
  • Lock and resolve — click the canvas to toggle_lock a slot; adopt a candidate as the working line (re-judged on the next fetch); audition through the chord-pump; pin via the vary-commit idiom (whole-master candidate, analyze-verified, provenance comment).
  • Harmony variant — keyed by SolveReport.harmony: a numeral function row (T/S/D tinting from roman_degree/func_of) plus voice-leading trails and movement figures between adjacent chords. SATB per-voice lanes are still unbuilt — Stage 6b's voicing ships as a combinator, so no solved voicing reaches the SolveReport the board draws from.
  • Persistence & a11y — benchcounter= shell-state save/restore; the VOLTA_BENCH_COUNTER (+ VOLTA_COUNTER_LOCK) shot branch, verified against counterpoint-open.volta; chips and canvas carry accessible labels.

Deferred: per-tie hover witness text (witnesses live on chip hover today), per-note-cell a11y granularity, and SATB lanes.

Notes & limits#

  • Interval classes are 12-TET semitone classes on nominal MIDI (aug4 ≡ dim5).
  • noParallels flags similar motion between two perfect verticals; direct fifths from an imperfect interval are not flagged (open question).
  • Chords in melodic predicates read the top voice. smoothVoicing measures motion two ways: when the stack carries voice identities — the voiceLines solver's stamps, or a hand-written c4::L0 — the assignment is known, so movement is exact, tag by tag. An untagged, hand-spelled stack has no assignment to be exact about and falls back to the greedy nearest-voice proxy, which under-reports (it matches each note to its nearest neighbour, not to its actual successor); a failing proxy verdict says "nearest-voice estimate" in its witness so the two are never confused.
  • Harmony holes use Pattern Pitch; harmony.Chord values also lower to ordinary chord events. Run a progression through voiceLines for parts.
  • voiceLines accepts declarable ranges, spacing, weights, locks and parallel policy. counterLines solves several parts together around a fixed reference rhythm. Theory tools and composition workflow documents both, local repair, species 4–5, imitation and Studio entry points.
  • Arbitrary mutually recursive holes with independent rhythms remain outside the joint solver's public interface.