Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Consonance Field — A Terrain for Evaluating Pitch

Conchordal’s perception core (the Landscape) listens to the habitat bus, transforms it into log-frequency space, and computes two potentials: roughness (sensory dissonance from amplitude fluctuations within critical bands) and harmonicity (periodicity and template matching). Their combination is the Consonance Field: an evaluation terrain over frequency that placement, movement, prediction, and survival all read from.

Because the field is computed from the habitat bus, every voice routed to that bus deforms the terrain for every other voice. A presentation-only voice does not. That feedback loop — not a chord chart — is where harmony comes from. See Voice and Landscape — Sound–Environment Feedback for the relationship between potential, score, level, mass, and density.

Placing into the field: consonance, dissonance, edge, gap

A field-relative placement names a target — a region of the field — and is realized either as a cloud (the default) or as a deterministic extremum with .peak(). The targets are:

  • consonance — high-consonance positions (harmonic centers, fusion).
  • dissonance — low-consonance positions (tension, clusters, color).
  • edge — the consonance/dissonance boundary (the metastable middle).
  • gap — empty registers (fill the room, avoid masking).

consonance(root) takes a harmonic window around a root (multiples via range()); every target also takes an absolute (min_hz, max_hz) range.

Range bounds must be finite and within the synthesizer’s 1–20000 Hz range; reversed bounds are accepted. Initial placement stays inside those bounds, including jitter and all-zero or fully occupied fallbacks. Field targets use the intersection with the analysis band (currently 55–8000 Hz). If there is no intersection, placement falls back to log-uniform sampling in the requested range because no field evidence is available there. random() samples the requested range directly. Placement does not constrain subsequent seek_consonance() movement to that range.

let anchor = harmonic()
    .brain("drone")
    .amp(0.06)
    .sustain()
    .anchor();

let voice = harmonic()
    .amp(0.04)
    .sustain();

section("field placement", || {
    place(anchor, at(110.0));
    wait(1.0);

    place(voice, consonance(110.0).peak().range(1.0, 4.0).count(6).spacing(0.9));
    wait(6.0);
});

A target with no modifier is a density cloud: not “random but harmonic” but a normalized distribution derived from the consonance model, well-defined inside the range. Placement is independent of behavior — enter at dissonance and anchor() for a held cluster, or enter at dissonance and seek_consonance() for a resolution gesture.

let cloud = harmonic().amp(0.035).sustain();

place(cloud, consonance(90.0, 1200.0).count(10).spacing(0.8));
wait(8.0);

Placement tension: tension(τ)

Without tension, Consonance Placement uses its ordinary realization: .peak() chooses the strongest peak, while the default density produces the ordinary Consonance cloud. tension(τ) biases that Placement toward a field-score step below the maximum. τ ∈ [0, 1] is the tension degree; 0 leaves the ordinary realization unchanged, while larger values target progressively weaker, metastable steps (target = L_max − τ·(L_max − L_min) over the range, in field score). It is the placement twin of movement’s search temperature — the dial for how resolved a spawn should sit — and it reads the field’s score directly, so the degree rides the terrain’s own scale rather than the count of peaks that happen to be sounding. With .peak() it snaps to the nearest step; as a cloud it concentrates the distribution around the target.

let tense = harmonic().amp(0.035).sustain();

// A metastable step below the strongest peak — placed, not resolved.
place(tense, consonance(110.0, 1200.0).peak().tension(0.4).count(6).spacing(0.8));
wait(6.0);

The field-agnostic placements are random(min_hz, max_hz) (log-uniform) and the geometric at(hz) and line(start_hz, end_hz).

Naming frequencies: ratios of a root

at(hz) and line(start_hz, end_hz) take absolute frequencies, and the canonical idiom for filling them in is to name one sounding root in Hz and derive every other pitch as a ratio of it: root_hz * 1.5 for a fifth, root_hz * 4.0/3.0 for a fourth, root_hz * 2.0 for a register lift. A ratio is the physical quantity the field itself reads, so writing intervals this way keeps the thinking in frequency relationships.

Writing an equal-tempered decimal instead — 146.83 for a D3 — still runs, but it re-imports the twelve-tone symbol grid through the back door: the number stands in for a note name rather than describing a relationship to a sounding partial. Prefer ratios of a root that is actually sounding in the scene.

let root_hz = 110.0;
let voice = harmonic().amp(0.04).sustain();

place(voice, at(root_hz * 1.5));
wait(2.0);

Consonance Movement: seek_consonance

Use seek_consonance() when voices should actively seek better field positions. It sets free hill-climb movement with glide defaults. Use glide(tau_sec) when the musical thought is “same movement idea, slower or faster pitch motion”.

let mover = harmonic()
    .amp(0.045)
    .sustain()
    .seek_consonance()
    .glide(0.35)
    .avoid_neighbors(0.6)
    .global_peaks(8, 70.0)
    .ratio_candidates(5);

place(mover, consonance(80.0, 900.0).count(8));
wait(12.0);

avoid_neighbors(strength) adds crowding repulsion so movers spread out instead of collapsing onto the same peak.

The opposite of movement is anchor(): an anchored voice holds its pitch and only deforms the terrain for others. freq() sets an anchored pitch; use anchor() to freeze strategy-placed voices at their settled position.

at(hz) holds a fixed pitch unless the final PopulationSpec explicitly requests seek_consonance() and remains free. For that moving Voice, it sets only the initial position, just as line(hz, hz) does, and keeps the placement count. For example, place(mover, at(220.0)) starts the mover defined above at 220 Hz without cancelling its movement or glide settings. It specifies initial body pitch, not a guaranteed first audible pitch or a promise of movement.

Builder order matters when changing the pitch policy:

Final builder orderMeaning of at(hz)
seek_consonance() then freq()Fixed pitch
freq() then seek_consonance()Initial position
seek_consonance() then anchor()Fixed pitch
anchor() then seek_consonance()Initial position

Without explicit movement, at() still fixes pitch. A live Population’s freq() still locks pitch during performance.

How movement lands is resolved from phonation: sustained movers glide, re-attacking movers (pulse(), metric(), entrained(), steady(), flow()) snap to their new pitch at each onset. pitch_apply_mode("gate_snap") and pitch_apply_mode("glide") override that choice.

For sustained movers without a participation clock, pitch_apply_mode("rearticulate") gives each newly adopted pitch target a fresh attack. The old Tone releases while the new Tone starts directly at the adopted pitch, at the sample when movement begins. Rejected proposals, unchanged targets and intermediate glide updates do not create notes. The proposal interval controls opportunities to choose a target, not a fixed note clock; rearticulation does not wait for a beat.

let moving = harmonic().sustain().seek_consonance()
    .pitch_apply_mode("rearticulate");
let voices = place(moving, line(220.0, 440.0).count(3));
wait(12.0);
voices.pitch_apply_mode("glide");
wait(12.0);

A live switch between glide and rearticulate preserves the sounding Tone and does not trigger a note by itself. Unrendered rearticulation requests are cancelled by that switch; the new mode handles subsequent adoptions. The mode also belongs to the Population template used for replacements. glide(tau) sets the sliding time constant in glide mode and below a rearticulation boundary. Anchored Voices are unaffected by rearticulate.

Rearticulate requires free pitch and Hold phonation. Combining free-pitch Gated phonation with it is a script error, checked on the final placement spec regardless of builder order; an incompatible live call also fails before any state is changed. Use sustain(), or choose gate_snap or glide with Gated phonation. Hold without participation does not offer a separate same-pitch attack request. Sustained participation can re-attack its current body pitch at a sounded opportunity; autonomous per-note selection is not provided.

Choosing how pitch changes become notes

These choices distinguish target adoption from a participation opportunity. The boundary is a composer parameter of the founder Voice’s articulation, not a universal auditory threshold. Values such as 50 or 100 cents are examples; the composer chooses them.

IntentionExplicit formWhen an attack occurs
Legatosustain().pitch_apply_mode("glide")Initial onset; subsequent pitch movement slides
Every adopted targetsustain().pitch_apply_mode("rearticulate")Each adoption, with the boundary omitted or zero
Small moves slidesustain().pitch_apply_mode("rearticulate").rearticulate_above(50)Adoption strictly above 50 cents from the last sounded onset
Sustained participation with slidingsustain().entrained().pitch_apply_mode("glide")A sounded opportunity at its current body pitch; other movement slides
Sustained participation with jumpssustain().flow().pitch_apply_mode("gate_snap")A sounded opportunity consumes the latest pending target
Sustained participation with a movement boundarysustain().entrained().pitch_apply_mode("rearticulate").rearticulate_above(50)Small adoptions slide; large ones wait for a sounded opportunity

Entrained, steady and flow can each be used in any participation row. Steady keeps only due-time opportunities, including with Glide. See Rhythm for their clocks.

intrinsic_rate(hz) sets the body’s initial scale in any participation row, independently of the pitch-application choice. It does not command an onset interval or place a shared beat. proposal_interval(seconds) still controls pitch proposals, not that bodily scale. See Setting the body’s intrinsic rate.

Without participation, a positive rearticulate_above(cents) measures net displacement from the last actual onset, not accumulated path length. At or below the boundary, the existing Tone glides with glide(tau); above it, a new Tone attacks. That choice lasts until the next adoption. Exact Log2Space bin centers use their bin-distance in cents; off-grid endpoints use the continuous log2 distance, without rounding the target or adding an epsilon. Earlier legal attacks in the same output hop provide the reference, and cancelled requests never replace the last materialized onset.

Without participation, the boundary defaults to zero, retaining every-adoption rearticulation. Negative and nonfinite values become zero; no upper limit is imposed. Changing the boundary live causes no attack and applies at the next adoption. A call outside Rearticulate warns and is ignored. Builder order is independent at placement. Replacement Voices inherit the boundary, with no inherited onset-pitch memory.

let moving = harmonic().sustain().seek_consonance()
    .pitch_apply_mode("rearticulate").rearticulate_above(50).glide(0.03);
let voices = place(moving, consonance(220.0, 660.0).count(2));
wait(3.0);
voices.rearticulate_above(100);
wait(3.0);
release(voices);

With a participation clock, Rearticulate applies the same distance measure to each adoption but uses the clock for attacks. At or below a positive boundary, movement starts gliding immediately without an attack and cancels any older pending target. Above it, an active glide stops, body pitch is held, and the latest target waits for a sounded opportunity. That opportunity consumes the target and attacks there; with tie omitted or zero, it re-attacks the current body pitch without a target, which may have changed during a glide. Waiting and skipping retain sound and pending state while existing glides continue. Both kinds of attack update the last actual onset reference. Same-sample adoption precedes the opportunity. No adoption-time candidate is added, and the boundary does not filter clock attacks. Zero or omission uses the GateSnap behavior, including zero-distance adoptions. See Rhythm for mode switching.

The positive-boundary sustained-participation form also accepts initial-only articulation_persistence(p) to retain Base/Connect across existing events. This changes policy inertia, not the movement boundary or a prescribed legato proportion. See Retaining an articulation policy.

For gate_snap/rearticulate participation, tie(p) can extend a living Tone without a pending target instead of re-attacking. It leaves pending-target attacks intact, including small GateSnap moves. See Extending opportunities.

Sustained synchronization uses the same pending-target rules with a different omission default: free-pitch GateSnap/Rearticulate extends pending-free beats like tie(1). Write tie(0) to attack every legal beat. Fixed pitch and Glide remain unchanged; entrained/steady/flow participation keeps omitted tie at zero. See Sustained synchronization.

Cutting notes and leaving a rest

For sustained participation, rest() adds a finite end to every sounded note and an intentional pause after its sound has finished. Supply the note length with cycles(n) or adaptive_duration() and its modifiers. This length, explicit sustain(), entrained(), steady() or flow(), and rest() are retained independently of builder order. No finite length is supplied implicitly. Explicit while_alive() clears the finite length; placement then fails unless another finite length follows.

The scheduled Off starts the normal release, leaving tails intact. The rest begins only after the renderer confirms that every Tone of that Voice’s source ID and generation has finished. rest() then prohibits new notes for one intrinsic bodily period; rest(periods) uses the composer’s chosen multiple of the period at that completion. A known period with rest(0) waits for the tails only. An unknown period keeps waiting, as does unfinished sound, without a fallback or timeout. Negative and nonfinite arguments become zero; finite nonnegative values have no upper limit.

intrinsic_rate(hz) changes this bodily period and the finite note-length grid together. The same cycles or duration_range and rest(periods) therefore produce different note lengths and rests in seconds at different bodily rates, independently of entrained cadence adaptation.

At the end of the rest, the existing participation decision can sound, wait or skip; the end itself does not force an attack. GateSnap retains the latest adopted target through sounding, release and rest, and consumes it at the next sounded opportunity. Rearticulate applies its small-adoption cancellation and large-target deferral during release and rest too. Its last actual onset remains the reference until the next sounded opportunity. Sliding changes body pitch after Off but never retunes released tails. Glide leaves released tails at their released pitch. Release, death and removal take precedence. Replacements inherit controls, with fresh performance state.

let notes = harmonic().sustain().entrained()
    .adaptive_duration().duration_range(0.2, 0.2).rest(2);
let voices = place(notes, at(330.0));
wait(3.0);
release(voices);

Here 0.2 cycles and two periods are example inputs, not recommended values. rest is initial-only, like the note-length controls. It is valid only with explicit sustain plus entrained/steady/flow and a finite note length; Glide, GateSnap and Rearticulate are all available. Incompatible placements fail before state changes. Without rest, the existing rules remain. This form ends every note; choosing to sustain or release each individual note is not implemented by this control.

Mechanism-level controls (pitch_core() and the hill-climb / peak-sampler tuning in the API Reference) remain available for research scripts; prefer seek_consonance() and glide() in curated work.

Consonance Viability and Respawn

Viability makes field fit matter over time. consonance_viability(low, high) defines the consonance window, while recovery(seconds) states how long a full-scale recharge takes: a voice in a well-fitting place is sustained, a voice in a poor place approaches its nominal endurance(seconds).

By default viability uses environment-relative scoring: a voice is evaluated against the field with its own footprint approximately removed. Use viability_scope("total") only when the compositional question is explicitly total-field viability.

Respawn closes the loop into an ecology: when voices die, replacements appear according to a respawn policy. respawn_consonance() selects high field-score peaks with a bias around an energy-weighted living parent; respawn_capacity(count) sets the maximum living membership (defaulting to, and never lower than, the founder count); respawn_settle(placement) adds that Placement to the replacement candidate pool rather than replacing the policy’s baseline candidate.

let settle = consonance(70.0, 1100.0).spacing(0.8);

let ecology = harmonic()
    .amp(0.04)
    .repeat()
    .pulse(1.5)
    .cycles(3)
    .seek_consonance()
    .glide(0.45)
    .endurance(8.0)
    .recovery(4.0)
    .attack_cost_fraction(0.017)
    .attack_recharge_fraction(0.70)
    .consonance_viability(0.32, 0.82)
    .respawn_consonance()
    .respawn_capacity(14)
    .respawn_settle(settle);

place(ecology, consonance(70.0, 1100.0).count(14));
wait(30.0);

The full lifecycle surface (time-domain endurance/recovery and normalized per-attack fractions) and the respawn policies are documented in the API Reference. Population — A Persistent Unit of Voices separates articulation, phonation, pitch behavior, and survival; the Voice and Landscape — Sound–Environment Feedback follows the complete energy and respawn cycle.

Landscape-aware timbre

The field can shape timbre as well as pitch. The modal() body takes a mode pattern. landscape_density_modes() deterministically chooses the strongest separated density-mass positions; landscape_peaks_modes() chooses the strongest separated local Field-level peaks. Thus a bell’s partials can sit where the terrain already supports them.

let shimmer_modes = landscape_density_modes()
    .count(10)
    .range(1.0, 5.5)
    .gamma(1.6)
    .spacing(0.7);

let shimmer = modal()
    .amp(0.025)
    .sustain()
    .seek_consonance()
    .modes(shimmer_modes)
    .brightness(0.7);

place(shimmer, consonance(200.0, 1600.0).count(4));
wait(8.0);

Mode constructors include harmonic_modes(), odd_modes(), power_modes(beta), stiff_string_modes(stiffness), custom_modes([ratios]), modal_table(name), landscape_density_modes(), and landscape_peaks_modes() — see the API Reference.