Home Manual Reference Source Test

src/app.js

  1. import { compose } from "./controller/composer.js";
  2. import {
  3. buildParameters,
  4. exportParameters,
  5. importParameters,
  6. } from "./controller/parameters.js";
  7. import { initSolvers, solveModel } from "./controller/solving.js";
  8. import { chordGraph, chordName } from "./model/theory.js";
  9. import { Step1Parts } from "./pipeline/step1parts.js";
  10. import { Step2Chords } from "./pipeline/step2chords.js";
  11. import { Step3Notes } from "./pipeline/step3notes.js";
  12. import { CONTROLS } from "./view/controls.js";
  13. import { submitFeedback } from "./view/feedback.js";
  14. import { downloadBlob } from "./view/download.js";
  15. import { PLOTTING } from "./view/plotting.js";
  16. /** @external {alpinejs} https://www.npmjs.com/package/alpinejs */
  17. /** @external {html-midi-player} https://www.npmjs.com/package/html-midi-player */
  18. /** @external {picocss} https://www.npmjs.com/package/@picocss/pico */
  19. /** Alpine "app" data object / entry point. */
  20. document.addEventListener("alpine:init", () =>
  21. Alpine.data("app", () => ({
  22. // Solver readiness + status line
  23. isMiniZincReady: false,
  24. mznSolver: "",
  25. mznSolvers: [],
  26. statusText: "Initializing...",
  27. // Theme
  28. theme: "auto",
  29. isSystemDark: false,
  30. // Pipeline definition (loaded once at init)
  31. steps: [],
  32. schemas: {},
  33. parameters: {},
  34. // Pipeline outputs (populated by runSolver)
  35. mznModelText: [[], [], []],
  36. mznSolutionText: [[], [], []],
  37. mznStatisticsText: [[], [], []],
  38. musicB64Midi: null,
  39. chordProgressionText: [],
  40. resultTable: null,
  41. // Console + UI transient state
  42. consoleLog: [],
  43. showConsole: false,
  44. importedFileName: "",
  45. // Theory helpers exposed to templates (per-row dynamic chord options).
  46. chordGraph,
  47. chordName,
  48. ...APP_METHODS,
  49. })),
  50. );
  51. /** @private Methods on the Alpine "app" data object; bound to per-instance this. */
  52. const APP_METHODS = {
  53. exportParameters,
  54. ...CONTROLS,
  55. downloadBlob,
  56. submitFeedback,
  57. ...PLOTTING,
  58. /** @desc Alpine lifecycle: detect dark mode, bind the midi-player autoscroll, init MiniZinc + step schemas. */
  59. async init() {
  60. const mq = window.matchMedia("(prefers-color-scheme: dark)");
  61. this.isSystemDark = mq.matches;
  62. mq.addEventListener("change", (e) => (this.isSystemDark = e.matches));
  63. document.getElementById("midi-player")?.addEventListener("start", () => {
  64. const viz = document.getElementById("midi-visualizer");
  65. const sel = ".waterfall-notes-container";
  66. const c = viz?.querySelector(sel) ?? viz?.shadowRoot?.querySelector(sel);
  67. if (c) {
  68. c.scrollTop = c.scrollHeight;
  69. }
  70. if (viz) {
  71. viz.scrollTop = viz.scrollHeight;
  72. }
  73. });
  74. this.statusText = "Initializing MiniZinc...";
  75. try {
  76. const solverList = await initSolvers();
  77. this.mznSolvers = solverList;
  78. this.mznSolver = solverList[0].id;
  79. this.isMiniZincReady = true;
  80. const newSteps = [new Step1Parts(), new Step2Chords(), new Step3Notes()];
  81. Object.assign(this, await buildParameters(newSteps));
  82. this.$watch("parameters.step2.part", () => this.updateGraph());
  83. this.$nextTick(() => this.updateGraph());
  84. document
  85. .getElementById("import-params")
  86. ?.addEventListener("change", async (e) => {
  87. const file = e.target.files[0];
  88. if (!file) {
  89. return;
  90. }
  91. try {
  92. const merged = await importParameters(file);
  93. this.parameters = { ...this.parameters, ...merged };
  94. this.importedFileName = file.name;
  95. } catch (err) {
  96. this.log("Failed to import parameters", err);
  97. }
  98. });
  99. this.log("Ready to compose.");
  100. } catch (e) {
  101. this.log("Failed to load solvers", e);
  102. }
  103. },
  104. /**
  105. * @desc Mirror msg into the status line and append a [HH:MM:SS]-stamped console-log entry;
  106. * optional err is console.error'd and joined into the line as "Error: msg: <detail>".
  107. */
  108. log(msg, err) {
  109. let line = msg;
  110. if (err) {
  111. console.error(msg, err);
  112. const detail =
  113. err?.message ??
  114. (typeof err === "object" ? JSON.stringify(err) : `${err}`);
  115. line = `Error: ${msg}: ${detail}`;
  116. }
  117. this.statusText = line;
  118. const t = new Date().toLocaleTimeString("de-DE");
  119. this.consoleLog.push(`[${t}] ${line}`);
  120. },
  121. /**
  122. * @desc Drive a full 3-step composition run via {@link compose}:
  123. * bind hooks to Alpine state + DOM, then map the result onto the result table,
  124. * voice colors, MIDI player, and status line.
  125. * @listens {progressionLine}
  126. */
  127. async runSolver() {
  128. if (!this.isMiniZincReady) {
  129. return;
  130. }
  131. const btn = document.getElementById("run-optimization-button");
  132. btn.setAttribute("aria-busy", "true");
  133. btn.disabled = true;
  134. window.Tone?.start();
  135. this.log("Running solver for all steps...");
  136. this.resetResults();
  137. try {
  138. const lines = [];
  139. const result = await compose(this.parameters, this.steps, {
  140. log: this.log.bind(this),
  141. loadPreset: this.loadPreset.bind(this),
  142. solveStep: this.solveStep.bind(this),
  143. updateGraph: this.updateGraph.bind(this),
  144. onProgressionLine: (line) => {
  145. lines.push(line);
  146. this.chordProgressionText = [...lines];
  147. },
  148. });
  149. if (!result) {
  150. return;
  151. }
  152. this.resultTable = this.buildResultTable(result, this.parameters.step3);
  153. this.applyVoiceStyles(this.resultTable.voiceLabels);
  154. if (result.midi) {
  155. this.musicB64Midi = result.midi.combinedB64;
  156. await this.$nextTick();
  157. this.wireMidiToPlayer(result.midi);
  158. }
  159. const { parts, failedParts: fp, solvedParts: sp } = result;
  160. const u = [...new Set(fp)].join(", ");
  161. this.log(
  162. fp.length === 0
  163. ? `Done: ${sp}/${parts.length} parts solved.`
  164. : sp > 0
  165. ? `Partial: ${sp}/${parts.length} parts solved. Overconstrained: ${u}. Try again.`
  166. : "No solution: Model may be overconstrained.",
  167. );
  168. } catch (e) {
  169. this.log("Solve failed", e);
  170. } finally {
  171. btn.removeAttribute("aria-busy");
  172. btn.disabled = false;
  173. }
  174. },
  175. /**
  176. * @desc Clear all per-run state (mzn texts, midi, progression, graph, player srcs);
  177. * keep the latest log line.
  178. */
  179. resetResults() {
  180. this.consoleLog = this.consoleLog.slice(-1);
  181. this.mznModelText = [[], [], []];
  182. this.mznSolutionText = [[], [], []];
  183. this.mznStatisticsText = [[], [], []];
  184. this.musicB64Midi = null;
  185. this.chordProgressionText = [];
  186. document.getElementById("graph")?.replaceChildren();
  187. const sel = "midi-visualizer, midi-player";
  188. document.querySelectorAll(sel).forEach((el) => el.removeAttribute("src"));
  189. },
  190. /**
  191. * @desc Hook: Fetch a corpus preset (pi) JSON by id;
  192. * null for "manual" or on fetch failure.
  193. */
  194. async loadPreset(style) {
  195. if (!style || style === "manual") {
  196. return null;
  197. }
  198. try {
  199. return await (await fetch(`scripts/presets/${style}.json`)).json();
  200. } catch {
  201. console.warn(`Could not load preset ${style}`);
  202. return null;
  203. }
  204. },
  205. /**
  206. * @desc Hook: Run one MZN model on the solver;
  207. * record model/solution/stats per step.index for the raw-output tabs.
  208. */
  209. async solveStep(step, partLabel, modelStr) {
  210. const idx = step.index - 1;
  211. const label = partLabel ? `${step.name}: ${partLabel}` : step.name;
  212. const file = `${step.id}_${Date.now()}.mzn`;
  213. this.mznModelText[idx].push({ label, content: modelStr });
  214. const r = await solveModel(file, modelStr, this.mznSolver);
  215. const json = r.solution?.output?.json;
  216. const stats = r.statistics
  217. ? JSON.stringify({ ...r.statistics }, null, 2)
  218. : "{}";
  219. const sol = JSON.stringify(json, null, 2);
  220. this.mznSolutionText[idx].push({ label, content: sol });
  221. this.mznStatisticsText[idx].push({ label, content: stats });
  222. return json;
  223. },
  224. /**
  225. * @desc Hook: Render the chord graph + sync form chord-selectors.
  226. * With no arg: derive from form (mode change).
  227. * With arg: render the compose-hook payload (path-highlighted).
  228. * Also resets each part's start/end chord to the mode's tonic when no longer in activeNodes.
  229. */
  230. updateGraph(svg) {
  231. const parts = this.parameters?.step2?.part;
  232. parts?.forEach((row) => {
  233. const { activeNodes, tonic } = chordGraph(row.mode);
  234. if (!activeNodes.includes(row.start_chord)) {
  235. row.start_chord = tonic;
  236. }
  237. if (!activeNodes.includes(row.end_chord)) {
  238. row.end_chord = tonic;
  239. }
  240. });
  241. const s2 = parts?.[0];
  242. const data =
  243. svg ??
  244. (s2 && {
  245. graph: chordGraph(s2.mode),
  246. path: null,
  247. key: Number(s2.key) || 0,
  248. mode: s2.mode,
  249. });
  250. if (!data) {
  251. return;
  252. }
  253. const el = document.getElementById("graph");
  254. if (el) {
  255. el.innerHTML = this.plotChordGraph(
  256. data.graph,
  257. data.path,
  258. data.key,
  259. data.mode,
  260. );
  261. }
  262. },
  263. };