src/model/constraints3.js
- import { CHORDS } from "./theory.js";
- export function modelNotes(parameters, time) {
- const pitch = pitchGrid(parameters, time);
- return [
- 'include "globals.mzn";',
- ...varDecls(parameters, time, pitch.varDomains),
- ...dissonanceConstraints(parameters, time, pitch),
- ...range(parameters.voices).flatMap((s) =>
- seedOneVoice(parameters, time, pitch, s),
- ),
- ...forbidUnisons(parameters, time),
- ...restsBudget(parameters, time),
- ...melodicConstraints(parameters, time),
- ...forbidParallels(parameters, time),
- `solve :: ${searchAnnotation(time)} satisfy;`,
- ].join("\n");
- }
- const range = (n) => Array.from({ length: n }, (_, i) => i);
- function pitchGrid(parameters, time) {
- const { voice, voices, mode, key, chords } = parameters;
- const { numSubBeats, activeBeats, subBeatsPerBar } = time;
- const chordTones = { ...CHORDS.major, ...CHORDS.minor };
- const scaleTones = new Set(
- (mode === "major" ? [0, 2, 4, 5, 7, 9, 11] : [0, 2, 3, 5, 7, 8, 10]).map(
- (p) => (p + key) % 12,
- ),
- );
- const tonesPerBeat = range(numSubBeats).map((t) => {
- const base = chordTones[chords[Math.floor(t / subBeatsPerBar)]];
- return base ? new Set(base.map((p) => (p + key) % 12)) : scaleTones;
- });
- const withRest = parameters.rest_pct > 0;
- const varDomains = Object.fromEntries(
- range(numSubBeats).flatMap((t) =>
- voice.slice(0, voices).flatMap((v, s) => {
- if (!activeBeats[s].has(t)) {
- return [];
- }
- const allowed =
- v.chord_tones === "chord" || v.chord_tones === true
- ? tonesPerBeat[t]
- : v.chord_tones === "scale"
- ? scaleTones
- : null;
- const ps = range(v.max_pitch - v.min_pitch + 1)
- .map((k) => v.min_pitch + k)
- .filter((p) => !allowed || allowed.has(p % 12));
- return [[`note_${s}_${t}`, withRest ? [-2, ...ps] : ps]];
- }),
- ),
- );
- return {
- varDomains,
- dissAllowedSet: dissSet(parameters.dissonance, voices),
- seededValues: {},
- };
- }
- function dissSet(dissonance, voices) {
- if (dissonance === 0.5 || voices <= 1) {
- return null;
- }
- const cost = [0, 4, 2, 1, 1, 0, 3, 0, 1, 1, 2, 4];
- const cons = dissonance < 0.5;
- const th = cons ? (dissonance <= 0.2 ? 1 : 2) : dissonance >= 0.8 ? 3 : 2;
- return new Set(
- cost.flatMap((c, i) => ((cons ? c <= th : c >= th) ? [i] : [])),
- );
- }
- function varDecls(parameters, time, varDomains) {
- const { numSubBeats, activeBeats } = time;
- const withRest = parameters.rest_pct > 0;
- return range(numSubBeats).flatMap((t) =>
- activeBeats.map((set, s) => {
- const n = `note_${s}_${t}`;
- if (!set.has(t)) {
- return `var int: ${n} = -2;`;
- }
- const dom = withRest ? [-1, ...varDomains[n]] : varDomains[n];
- return `var {${dom.join(",")}}: ${n};`;
- }),
- );
- }
- function dissonanceConstraints(parameters, time, pitch) {
- const { dissAllowedSet, varDomains } = pitch;
- if (dissAllowedSet === null) {
- return [];
- }
- const setLit = `{${[...dissAllowedSet].join(",")}}`;
- const real = (k, t) => realPitches(varDomains[`note_${k}_${t}`]);
- const pairs = voicePairs(parameters.voices);
- return [
- ...range(time.numSubBeats).flatMap((t) =>
- pairs.flatMap(([a, b]) => {
- const rA = real(a, t);
- const rB = real(b, t);
- if (!rA.length || !rB.length || !anyAllowed(rA, rB, dissAllowedSet)) {
- return [];
- }
- const na = `note_${a}_${t}`;
- const nb = `note_${b}_${t}`;
- const body = `(abs(${na} - ${nb}) mod 12) in ${setLit}`;
- return [guarded(parameters, na, nb, body)];
- }),
- ),
- ];
- }
- const realPitches = (domain) => (domain || []).filter((p) => p >= 0);
- const voicePairs = (voices) =>
- range(voices).flatMap((a) =>
- range(voices - a - 1).map((k) => [a, a + 1 + k]),
- );
- const anyAllowed = (xs, ys, allowed) =>
- xs.some((x) => ys.some((y) => allowed.has(Math.abs(x - y) % 12)));
- const guarded = (parameters, a, b, body) =>
- parameters.rest_pct > 0
- ? `constraint (${a} >= 0 /\\ ${b} >= 0) -> ${body};`
- : `constraint ${body};`;
- function seedOneVoice(parameters, time, pitch, s) {
- const active = [...time.activeBeats[s]].sort((a, b) => a - b);
- const pct = parameters.seed_pct;
- const numSeeds =
- pct > 0 ? Math.max(1, Math.floor((active.length * pct) / 100)) : 0;
- const spacing = Math.max(
- 1,
- Math.floor(active.length / Math.max(1, numSeeds)),
- );
- const positions = new Set(
- range(numSeeds).map(
- (k) => active[Math.min(k * spacing, active.length - 1)],
- ),
- );
- const seedOrder = [];
- return active.flatMap((t) => {
- if (!positions.has(t)) {
- return [];
- }
- const ps = pitch.varDomains[`note_${s}_${t}`];
- if (ps.length < 3) {
- return [];
- }
- const used = pitch.seededValues[t] || {};
- let cs = ps.filter((p) => !Object.values(used).includes(p));
- if (pitch.dissAllowedSet !== null) {
- cs = filterByDissonance(time, pitch, cs, { s, t });
- }
- if (seedOrder.length > 0) {
- const prev = seedOrder.at(-1)[1];
- cs = cs.filter((p) => Math.abs(p - prev) <= parameters.max_interval);
- }
- if (seedOrder.length > 1) {
- const prevPrev = seedOrder.at(-2)[1];
- cs = cs.filter((p) => p !== prevPrev);
- }
- if (cs.length === 0) {
- return [];
- }
- const pick = cs[Math.floor(Math.random() * cs.length)];
- seedOrder.push([t, pick]);
- (pitch.seededValues[t] ||= {})[s] = pick;
- return [`constraint note_${s}_${t} = ${pick};`];
- });
- }
- function filterByDissonance(time, pitch, candidates, subbeat) {
- const { s, t } = subbeat;
- return range(time.activeBeats.length)
- .filter((k) => k !== s && time.activeBeats[k].has(t))
- .reduce((acc, k) => {
- const seeded = pitch.seededValues[t]?.[k];
- const others = realPitches(
- seeded !== undefined ? [seeded] : pitch.varDomains[`note_${k}_${t}`],
- );
- if (others.length === 0) {
- return acc;
- }
- return acc.filter(
- (p) =>
- p < 0 ||
- others.some((q) => pitch.dissAllowedSet.has(Math.abs(p - q) % 12)),
- );
- }, candidates);
- }
- function forbidUnisons(parameters, time) {
- if (!parameters.forbid_unisons || parameters.voices <= 1) {
- return [];
- }
- const { numSubBeats, activeBeats } = time;
- const pairs = voicePairs(parameters.voices);
- return range(numSubBeats)
- .filter((t) => everyVoiceActive(activeBeats, t))
- .flatMap((t) =>
- pairs.map(([a, b]) => {
- const na = `note_${a}_${t}`;
- const nb = `note_${b}_${t}`;
- return guarded(parameters, na, nb, `${na} != ${nb}`);
- }),
- );
- }
- const everyVoiceActive = (activeBeats, t) =>
- activeBeats.every((set) => set.has(t));
- function restsBudget(parameters, time) {
- const pct = parameters.rest_pct;
- if (pct <= 0) {
- return [];
- }
- const sb = time.subBeatsPerBar;
- const term = (s, t) =>
- `bool2int(note_${s}_${t} == -1) + bool2int(note_${s}_${t} == -2)`;
- return range(parameters.voices).flatMap((s) => {
- const active = [...time.activeBeats[s]].sort((a, b) => a - b);
- const maxHR = Math.max(1, Math.floor((active.length * pct) / 100));
- const terms = active.map((t) => term(s, t)).join(" + ");
- const out = [`constraint ${terms} <= ${maxHR};`];
- const downbeats = active.filter((t) => t % sb === 0);
- if (downbeats.length >= 2 && active.length - downbeats.length >= maxHR) {
- const maxDB = Math.max(1, Math.floor(downbeats.length / 4));
- const dbTerms = downbeats.map((t) => term(s, t)).join(" + ");
- out.push(`constraint ${dbTerms} <= ${maxDB};`);
- }
- return out;
- });
- }
- function melodicConstraints(parameters, time) {
- const max = parameters.max_interval;
- return range(time.activeBeats.length).flatMap((s) => {
- const active = [...time.activeBeats[s]].sort((a, b) => a - b);
- const noRep = !parameters.voice[s].allow_repetitions;
- const transitions = active.slice(1).map((t2, k) => [active[k], t2]);
- return transitions.flatMap(([t1, t2]) => {
- const prev = `note_${s}_${t1}`;
- const curr = `note_${s}_${t2}`;
- const m1 = guarded(
- parameters,
- prev,
- curr,
- `abs(${prev} - ${curr}) <= ${max}`,
- );
- const m9 = guarded(parameters, prev, curr, `${prev} != ${curr}`);
- return noRep ? [m1, m9] : [m1];
- });
- });
- }
- function forbidParallels(parameters, time) {
- if (!parameters.forbid_parallels || parameters.voices <= 1) {
- return [];
- }
- const { numSubBeats, activeBeats } = time;
- const shared = range(numSubBeats).filter((t) =>
- everyVoiceActive(activeBeats, t),
- );
- const pairs = voicePairs(parameters.voices);
- const transitions = shared.slice(1).map((t2, k) => [shared[k], t2]);
- const n = (v, t) => `note_${v}_${t}`;
- const line = ({ a, b, t1, t2 }, i) =>
- `constraint not (abs(${n(a, t1)} - ${n(b, t1)}) mod 12 == ${i} /\\ abs(${n(a, t2)} - ${n(b, t2)}) mod 12 == ${i} /\\ (${n(a, t2)} - ${n(a, t1)}) * (${n(b, t2)} - ${n(b, t1)}) > 0);`;
- return transitions.flatMap(([t1, t2]) =>
- pairs.flatMap(([a, b]) => [7, 0].map((i) => line({ a, b, t1, t2 }, i))),
- );
- }
- function searchAnnotation(time) {
- const allVars = range(time.numSubBeats).flatMap((t) =>
- time.activeBeats.flatMap((set, s) =>
- set.has(t) ? [`note_${s}_${t}`] : [],
- ),
- );
- for (let i = allVars.length - 1; i > 0; i--) {
- const j = Math.floor(Math.random() * (i + 1));
- [allVars[i], allVars[j]] = [allVars[j], allVars[i]];
- }
- return `int_search([${allVars.join(", ")}], input_order, indomain_min)`;
- }