Home Manual Reference Source Test

src/model/constraints3.js

  1. import { CHORDS } from "./theory.js";
  2. /**
  3. * Builds the MiniZinc CSP for Step 3:
  4. * assigns one note per active sub-beat and voice.
  5. * Encodes T1-T3, R4, R9, H12, H19, M1, M9, M14 from the Thesis.
  6. * @param {Object} parameters - Step-3 form params plus chord progression and tempo; see {@link Step3Notes#schema}.
  7. * @param {{subdivision: number, subBeatsPerBar: number, numSubBeats: number, activeBeats: Set<number>[]}} time
  8. * R4+R9: Time-grid built by Step 3: subdivision (tau), T, per-voice onset sets (omicron).
  9. * @return {string} MiniZinc source.
  10. */
  11. export function modelNotes(parameters, time) {
  12. const pitch = pitchGrid(parameters, time);
  13. return [
  14. 'include "globals.mzn";',
  15. ...varDecls(parameters, time, pitch.varDomains),
  16. ...dissonanceConstraints(parameters, time, pitch),
  17. ...range(parameters.voices).flatMap((s) =>
  18. seedOneVoice(parameters, time, pitch, s),
  19. ),
  20. ...forbidUnisons(parameters, time),
  21. ...restsBudget(parameters, time),
  22. ...melodicConstraints(parameters, time),
  23. ...forbidParallels(parameters, time),
  24. `solve :: ${searchAnnotation(time)} satisfy;`,
  25. ].join("\n");
  26. }
  27. /** @private 0..n-1 index range. */
  28. const range = (n) => Array.from({ length: n }, (_, i) => i);
  29. /**
  30. * @private T1+T2+T3 + H19 setup: pitch domain per (s,t) within ambitus [alpha, beta];
  31. * chord/scale tones from gamma_t; dissonance set.
  32. */
  33. function pitchGrid(parameters, time) {
  34. const { voice, voices, mode, key, chords } = parameters;
  35. const { numSubBeats, activeBeats, subBeatsPerBar } = time;
  36. const chordTones = { ...CHORDS.major, ...CHORDS.minor };
  37. const scaleTones = new Set(
  38. (mode === "major" ? [0, 2, 4, 5, 7, 9, 11] : [0, 2, 3, 5, 7, 8, 10]).map(
  39. (p) => (p + key) % 12,
  40. ),
  41. );
  42. const tonesPerBeat = range(numSubBeats).map((t) => {
  43. const base = chordTones[chords[Math.floor(t / subBeatsPerBar)]];
  44. return base ? new Set(base.map((p) => (p + key) % 12)) : scaleTones;
  45. });
  46. const withRest = parameters.rest_pct > 0;
  47. const varDomains = Object.fromEntries(
  48. range(numSubBeats).flatMap((t) =>
  49. voice.slice(0, voices).flatMap((v, s) => {
  50. if (!activeBeats[s].has(t)) {
  51. return [];
  52. }
  53. const allowed =
  54. v.chord_tones === "chord" || v.chord_tones === true
  55. ? tonesPerBeat[t]
  56. : v.chord_tones === "scale"
  57. ? scaleTones
  58. : null;
  59. const ps = range(v.max_pitch - v.min_pitch + 1)
  60. .map((k) => v.min_pitch + k)
  61. .filter((p) => !allowed || allowed.has(p % 12));
  62. return [[`note_${s}_${t}`, withRest ? [-2, ...ps] : ps]];
  63. }),
  64. ),
  65. );
  66. return {
  67. varDomains,
  68. dissAllowedSet: dissSet(parameters.dissonance, voices),
  69. seededValues: {},
  70. };
  71. }
  72. /** @private H19 setup: dissonance-cost array (chi) thresholded by phi; null = no H19. */
  73. function dissSet(dissonance, voices) {
  74. if (dissonance === 0.5 || voices <= 1) {
  75. return null;
  76. }
  77. const cost = [0, 4, 2, 1, 1, 0, 3, 0, 1, 1, 2, 4];
  78. const cons = dissonance < 0.5;
  79. const th = cons ? (dissonance <= 0.2 ? 1 : 2) : dissonance >= 0.8 ? 3 : 2;
  80. return new Set(
  81. cost.flatMap((c, i) => ((cons ? c <= th : c >= th) ? [i] : [])),
  82. );
  83. }
  84. /** @private Build var declarations note_s_t (varDomains already has hold sentinel when restsEnabled). */
  85. function varDecls(parameters, time, varDomains) {
  86. const { numSubBeats, activeBeats } = time;
  87. const withRest = parameters.rest_pct > 0;
  88. return range(numSubBeats).flatMap((t) =>
  89. activeBeats.map((set, s) => {
  90. const n = `note_${s}_${t}`;
  91. if (!set.has(t)) {
  92. return `var int: ${n} = -2;`;
  93. }
  94. const dom = withRest ? [-1, ...varDomains[n]] : varDomains[n];
  95. return `var {${dom.join(",")}}: ${n};`;
  96. }),
  97. );
  98. }
  99. /** @private H19: per voice-pair, per sub-beat, interval class must be in allowed set. */
  100. function dissonanceConstraints(parameters, time, pitch) {
  101. const { dissAllowedSet, varDomains } = pitch;
  102. if (dissAllowedSet === null) {
  103. return [];
  104. }
  105. const setLit = `{${[...dissAllowedSet].join(",")}}`;
  106. const real = (k, t) => realPitches(varDomains[`note_${k}_${t}`]);
  107. const pairs = voicePairs(parameters.voices);
  108. return [
  109. ...range(time.numSubBeats).flatMap((t) =>
  110. pairs.flatMap(([a, b]) => {
  111. const rA = real(a, t);
  112. const rB = real(b, t);
  113. if (!rA.length || !rB.length || !anyAllowed(rA, rB, dissAllowedSet)) {
  114. return [];
  115. }
  116. const na = `note_${a}_${t}`;
  117. const nb = `note_${b}_${t}`;
  118. const body = `(abs(${na} - ${nb}) mod 12) in ${setLit}`;
  119. return [guarded(parameters, na, nb, body)];
  120. }),
  121. ),
  122. ];
  123. }
  124. /** @private Real-pitch subset of a domain (drops -1/-2 sentinels). */
  125. const realPitches = (domain) => (domain || []).filter((p) => p >= 0);
  126. /** @private Unordered voice index pairs (a < b). */
  127. const voicePairs = (voices) =>
  128. range(voices).flatMap((a) =>
  129. range(voices - a - 1).map((k) => [a, a + 1 + k]),
  130. );
  131. /** @private True iff some pair (x in xs, y in ys) has interval class in allowed set. */
  132. const anyAllowed = (xs, ys, allowed) =>
  133. xs.some((x) => ys.some((y) => allowed.has(Math.abs(x - y) % 12)));
  134. /** @private Wrap MZN body with rest/hold sentinel guard when rest_pct > 0. */
  135. const guarded = (parameters, a, b, body) =>
  136. parameters.rest_pct > 0
  137. ? `constraint (${a} >= 0 /\\ ${b} >= 0) -> ${body};`
  138. : `constraint ${body};`;
  139. /** @private Seed xi-percent of voice s; emits constraints and mutates pitch.seededValues for cross-voice filtering. */
  140. function seedOneVoice(parameters, time, pitch, s) {
  141. const active = [...time.activeBeats[s]].sort((a, b) => a - b);
  142. const pct = parameters.seed_pct;
  143. const numSeeds =
  144. pct > 0 ? Math.max(1, Math.floor((active.length * pct) / 100)) : 0;
  145. const spacing = Math.max(
  146. 1,
  147. Math.floor(active.length / Math.max(1, numSeeds)),
  148. );
  149. const positions = new Set(
  150. range(numSeeds).map(
  151. (k) => active[Math.min(k * spacing, active.length - 1)],
  152. ),
  153. );
  154. const seedOrder = [];
  155. return active.flatMap((t) => {
  156. if (!positions.has(t)) {
  157. return [];
  158. }
  159. const ps = pitch.varDomains[`note_${s}_${t}`];
  160. if (ps.length < 3) {
  161. return [];
  162. }
  163. const used = pitch.seededValues[t] || {};
  164. let cs = ps.filter((p) => !Object.values(used).includes(p));
  165. if (pitch.dissAllowedSet !== null) {
  166. cs = filterByDissonance(time, pitch, cs, { s, t });
  167. }
  168. if (seedOrder.length > 0) {
  169. const prev = seedOrder.at(-1)[1];
  170. cs = cs.filter((p) => Math.abs(p - prev) <= parameters.max_interval);
  171. }
  172. if (seedOrder.length > 1) {
  173. const prevPrev = seedOrder.at(-2)[1];
  174. cs = cs.filter((p) => p !== prevPrev);
  175. }
  176. if (cs.length === 0) {
  177. return [];
  178. }
  179. const pick = cs[Math.floor(Math.random() * cs.length)];
  180. seedOrder.push([t, pick]);
  181. (pitch.seededValues[t] ||= {})[s] = pick;
  182. return [`constraint note_${s}_${t} = ${pick};`];
  183. });
  184. }
  185. /** @private H19-aware filter: drop candidates whose interval class to other voices is forbidden at sub-beat (s, t). */
  186. function filterByDissonance(time, pitch, candidates, subbeat) {
  187. const { s, t } = subbeat;
  188. return range(time.activeBeats.length)
  189. .filter((k) => k !== s && time.activeBeats[k].has(t))
  190. .reduce((acc, k) => {
  191. const seeded = pitch.seededValues[t]?.[k];
  192. const others = realPitches(
  193. seeded !== undefined ? [seeded] : pitch.varDomains[`note_${k}_${t}`],
  194. );
  195. if (others.length === 0) {
  196. return acc;
  197. }
  198. return acc.filter(
  199. (p) =>
  200. p < 0 ||
  201. others.some((q) => pitch.dissAllowedSet.has(Math.abs(p - q) % 12)),
  202. );
  203. }, candidates);
  204. }
  205. /** @private H12: pairwise note_a_t != note_b_t for real pitches (skip holds/rests). */
  206. function forbidUnisons(parameters, time) {
  207. if (!parameters.forbid_unisons || parameters.voices <= 1) {
  208. return [];
  209. }
  210. const { numSubBeats, activeBeats } = time;
  211. const pairs = voicePairs(parameters.voices);
  212. return range(numSubBeats)
  213. .filter((t) => everyVoiceActive(activeBeats, t))
  214. .flatMap((t) =>
  215. pairs.map(([a, b]) => {
  216. const na = `note_${a}_${t}`;
  217. const nb = `note_${b}_${t}`;
  218. return guarded(parameters, na, nb, `${na} != ${nb}`);
  219. }),
  220. );
  221. }
  222. /** @private True iff every voice is active at sub-beat t. */
  223. const everyVoiceActive = (activeBeats, t) =>
  224. activeBeats.every((set) => set.has(t));
  225. /** @private Per-voice holds/rests budget (rest_pct, UI-driven; not a chapter4 constraint). */
  226. function restsBudget(parameters, time) {
  227. const pct = parameters.rest_pct;
  228. if (pct <= 0) {
  229. return [];
  230. }
  231. const sb = time.subBeatsPerBar;
  232. const term = (s, t) =>
  233. `bool2int(note_${s}_${t} == -1) + bool2int(note_${s}_${t} == -2)`;
  234. return range(parameters.voices).flatMap((s) => {
  235. const active = [...time.activeBeats[s]].sort((a, b) => a - b);
  236. const maxHR = Math.max(1, Math.floor((active.length * pct) / 100));
  237. const terms = active.map((t) => term(s, t)).join(" + ");
  238. const out = [`constraint ${terms} <= ${maxHR};`];
  239. const downbeats = active.filter((t) => t % sb === 0);
  240. if (downbeats.length >= 2 && active.length - downbeats.length >= maxHR) {
  241. const maxDB = Math.max(1, Math.floor(downbeats.length / 4));
  242. const dbTerms = downbeats.map((t) => term(s, t)).join(" + ");
  243. out.push(`constraint ${dbTerms} <= ${maxDB};`);
  244. }
  245. return out;
  246. });
  247. }
  248. /** @private M1 (max semitone jump delta) + M9 (per-voice no immediate repetition) per voice. */
  249. function melodicConstraints(parameters, time) {
  250. const max = parameters.max_interval;
  251. return range(time.activeBeats.length).flatMap((s) => {
  252. const active = [...time.activeBeats[s]].sort((a, b) => a - b);
  253. const noRep = !parameters.voice[s].allow_repetitions;
  254. const transitions = active.slice(1).map((t2, k) => [active[k], t2]);
  255. return transitions.flatMap(([t1, t2]) => {
  256. const prev = `note_${s}_${t1}`;
  257. const curr = `note_${s}_${t2}`;
  258. const m1 = guarded(
  259. parameters,
  260. prev,
  261. curr,
  262. `abs(${prev} - ${curr}) <= ${max}`,
  263. );
  264. const m9 = guarded(parameters, prev, curr, `${prev} != ${curr}`);
  265. return noRep ? [m1, m9] : [m1];
  266. });
  267. });
  268. }
  269. /** @private M14: forbid parallel fifths and octaves between consecutive shared-active beats. */
  270. function forbidParallels(parameters, time) {
  271. if (!parameters.forbid_parallels || parameters.voices <= 1) {
  272. return [];
  273. }
  274. const { numSubBeats, activeBeats } = time;
  275. const shared = range(numSubBeats).filter((t) =>
  276. everyVoiceActive(activeBeats, t),
  277. );
  278. const pairs = voicePairs(parameters.voices);
  279. const transitions = shared.slice(1).map((t2, k) => [shared[k], t2]);
  280. const n = (v, t) => `note_${v}_${t}`;
  281. const line = ({ a, b, t1, t2 }, i) =>
  282. `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);`;
  283. return transitions.flatMap(([t1, t2]) =>
  284. pairs.flatMap(([a, b]) => [7, 0].map((i) => line({ a, b, t1, t2 }, i))),
  285. );
  286. }
  287. /** @private Fisher-Yates-shuffled int_search annotation over all active note vars. */
  288. function searchAnnotation(time) {
  289. const allVars = range(time.numSubBeats).flatMap((t) =>
  290. time.activeBeats.flatMap((set, s) =>
  291. set.has(t) ? [`note_${s}_${t}`] : [],
  292. ),
  293. );
  294. for (let i = allVars.length - 1; i > 0; i--) {
  295. const j = Math.floor(Math.random() * (i + 1));
  296. [allVars[i], allVars[j]] = [allVars[j], allVars[i]];
  297. }
  298. return `int_search([${allVars.join(", ")}], input_order, indomain_min)`;
  299. }