/** * Lambdoma Triangle * @module index.js; */ import gcd from "compute-gcd"; import keys from "./lib/keys"; import color from "./lib/color"; import kalimba from "./lib/kalimba"; import sampler from "./lib/sampler"; import sine from "./lib/sine"; import bandpass from "./lib/bandpass"; import midi from "./lib/midi"; import oktransition from "./vendor/oktransition"; import { getOutput } from "./lib/output"; import { browser, requestAudioContext, clamp, choice, roundInterval, intervalInRange, mod, } from "./lib/util"; import { scales } from "./lib/scales"; let instrument = kalimba; let organ = sine; let grid = document.createElement("grid"); let root = 440; const s = 50; let w, h, ws, hs; const add_on = 0; const mul_on = 1.0; const add_off = 0.1; const mul_off = 0.9; let dragging = false; let erasing = false; let lastNote = 0; let notes = []; let base_x = 0; let base_y = 0; let scale = scales[0]; let scaleMode = 0; let is_split = false; let intervals; function build() { w = window.innerWidth; h = window.innerHeight; ws = Math.ceil(w / s); hs = Math.ceil(h / s); scale = scales[scaleMode % scales.length]; if (scale.reset) { scale.reset(Math.round(base_x), Math.round(base_y), ws, hs); } for (var i = 0; i < ws; i++) { notes[i] = []; for (var j = 0; j < hs; j++) { notes[i][j] = add(i, j); } } log(); } function rebuild() { notes.forEach((row) => row.forEach((note) => note.destroy())); build(); } function log() { const seen = {}; // console.log(notes); for (let i = 0; i < 8; i++) { for (let j = 0; j < 8; j++) { const interval = notes[i][j].interval; const rounded = roundInterval(interval); if (!seen[rounded] && intervalInRange(interval, root)) { seen[rounded] = notes[i][j].interval; } } } intervals = Object.values(seen).sort((a, b) => a - b); // console.log(intervals); console.log(intervals.length, "unique intervals in 8x8"); } function play(note) { if (!organ.isPlaying(note.interval)) { let interval = note.interval; // while (interval < root) { // interval *= 2; // } // while (interval > root) { // interval /= 2; // } const rounded = roundInterval(note.interval); organ.play(interval); notes.forEach((row) => row.forEach( (note) => note.rounded === rounded && note.div.classList.add("playing"), ), ); } } function trigger(note) { if (organ === bandpass) { toggle(note); if (instrument === kalimba) { return; } } if (intervalInRange(note.interval, root)) { instrument.play(note.interval, root); } } function trigger_index(index) { const interval = intervals[index]; if (interval) { instrument.play(interval, root); } } function pause(note) { organ.pause(note.interval); const rounded = roundInterval(note.interval); notes.forEach((row) => row.forEach( (note) => note.rounded === rounded && note.div.classList.remove("playing"), ), ); } function toggle(note) { if (organ.isPlaying(note.rounded) || note.div.classList.contains("playing")) { pause(note); } else { play(note); } } const modes = { sine, bandpass, }; let modus = null; function toggleModus() { let intervals; if (modes[modus]) { intervals = modes[modus].getPlaying(); modes[modus].stop(); document.querySelector(`#modus .${modus}`).classList.remove("visible"); // rebuild(); } modus = modus === "sine" ? "bandpass" : "sine"; organ = modes[modus]; document.querySelector(`#modus .${modus}`).classList.add("visible"); if (intervals) { intervals.forEach((interval) => modes[modus].play(interval)); } } function add(i, j) { const ii = i + Math.round(base_x); const jj = j + Math.round(base_y); const [a, b] = scale.get( ii, jj, i, j, Math.round(base_x), Math.round(base_y), ); const isEqualTemperament = scale.name === "equal"; const div = document.createElement("div"); const interval = isEqualTemperament ? Math.pow(2, a / b) : a / b; // const interval = root * Math.pow(2, ((b / a) % 1) + 1); let add = 0; let frac; div.style.left = i * s + "px"; div.style.top = j * s + "px"; const note = { interval, rounded: roundInterval(interval, root), div, i, j, playing: false, destroy: () => grid.removeChild(div), recolor: (numerator, denominator) => { let aa = a / numerator; let bb = b / denominator; if (aa < bb && aa !== 0) { add = -Math.log(bb / aa) / 3.5; } else { add = Math.log(aa / bb) / 6; } let a_inv = a * denominator; let b_inv = b * numerator; let a_disp, b_disp; if (scale.name === "hyperbolic") { a_inv *= Math.round(base_y) + 1; b_inv *= Math.round(base_y) + 1; let ba_gcd = gcd(Math.round(a_inv), Math.round(b_inv)); a_disp = Math.round(a_inv / ba_gcd); b_disp = Math.round(b_inv / ba_gcd); // a_disp = Math.round(a_inv); // b_disp = Math.round(b_inv); } else { let ba_gcd = gcd(a_inv, b_inv); a_disp = a_inv / ba_gcd; b_disp = b_inv / ba_gcd; } frac = Math.log2(isEqualTemperament ? interval : aa / bb) % 1; let frac_orig = Math.log2(a / b) % 1; if (frac < 0) { frac += 1; } if (frac_orig < 0) { frac += 1; } if (isEqualTemperament) { if (interval % 1 === 0) { div.style.fontWeight = "900"; } else { div.style.fontWeight = "500"; } } else { if (frac_orig === 0) { div.style.fontWeight = "900"; } else { div.style.fontWeight = "500"; } } div.innerHTML = isEqualTemperament ? `
${a}
${b}√2
` : `
${a_disp}
/
${b_disp}
`; if (note.playing) { div.style.backgroundColor = color(frac, add + add_on, mul_on); } else { div.style.backgroundColor = color(frac, add + add_off, mul_off); } if (organ.isPlaying(interval)) { div.classList.add("playing"); } }, }; note.recolor(1, 1); if (browser.isDesktop) { div.addEventListener("mousedown", function (event) { if (event.button === 2) { // rightclick event.preventDefault(); // notes.forEach((row) => row.forEach((note) => note.recolor(a, b))); is_split = [a, b]; toggle(note); return; } div.style.backgroundColor = color(frac, add + add_on, mul_on); dragging = true; trigger(note); }); div.addEventListener("mouseenter", function () { div.style.backgroundColor = color(frac, add + add_on, mul_on); if (dragging) { trigger(note); } }); div.addEventListener("mouseleave", function () { div.style.backgroundColor = color(frac, add + add_off, mul_off); }); div.addEventListener("contextmenu", function (event) { if (!event.ctrlKey || !event.metaKey || !event.altKey) { event.preventDefault(); } }); } else { div.addEventListener("touchstart", function (e) { e.preventDefault(); trigger(note); // erasing = !note.playing; dragging = true; lastNote = note; }); } grid.appendChild(div); return note; } function bind() { if (browser.isDesktop) { document.addEventListener("mousedown", (event) => { if (event.button !== 2) { dragging = true; } }); document.addEventListener("mouseup", () => { dragging = false; }); } else { document.addEventListener("touchstart", (e) => { e.preventDefault(); dragging = true; }); document.addEventListener("touchmove", (e) => { e.preventDefault(); const x = Math.floor(e.touches[0].pageX / s); const y = Math.floor(e.touches[0].pageY / s); if (!(x in notes) || !(y in notes[x])) return; const note = notes[x][y]; if (note !== lastNote) { if (dragging) { trigger(note); } lastNote = note; } }); document.addEventListener("touchend", () => { dragging = false; }); } window.addEventListener("resize", build); window.addEventListener("keydown", keydown, true); keys.listen(trigger_index); // UI document .querySelector("#help-button") .addEventListener("click", () => document.querySelector("#help").classList.toggle("visible"), ); document .querySelector("#root") .addEventListener("click", () => document.querySelector(".root-select").classList.toggle("visible"), ); document.querySelector("#modus").addEventListener("click", toggleModus); Array.from(document.querySelectorAll(".mode")).forEach((el) => { // console.log(el.getAttribute("name")); el.addEventListener("click", (event) => { const name = el.getAttribute("name"); scaleMode = scales.findIndex((scale) => scale.name === name); rebuild(); }); }); toggleModus(); bindRoot(); // Wheel to scroll if (browser.isDesktop) { grid.addEventListener("wheel", (e) => { const new_base_x = Math.max(0, base_x + e.deltaX / 32); const new_base_y = Math.max(0, base_y + e.deltaY / 32); if ( Math.round(base_x) !== Math.round(new_base_x) || Math.round(base_y) !== Math.round(new_base_y) ) { base_x = new_base_x; base_y = new_base_y; rebuild(); } else { base_x = new_base_x; base_y = new_base_y; } }); } } let isReset = false; function keydown(e) { let step = 1; if (e.shiftKey) { step += 4; } console.log(e.keyCode); switch (e.keyCode) { case 27: // esc - PANIC if (isReset) { base_x = 0; base_y = 0; showMessage(`reset!`); } organ.stop(); sampler.stop(); rebuild(); isReset = true; setTimeout(() => (isReset = false), 500); break; case 37: // left if (e.altKey || e.ctrlKey || e.metaKey) return; base_x = Math.max(0, base_x - step); rebuild(); break; case 38: // up if (e.altKey || e.ctrlKey || e.metaKey) return; base_y = Math.max(0, base_y - step); rebuild(); break; case 39: // right if (e.altKey || e.ctrlKey || e.metaKey) return; base_x += step; rebuild(); break; case 40: // down if (e.altKey || e.ctrlKey || e.metaKey) return; base_y += step; rebuild(); break; case 220: // \ midi.enable(trigger_index); break; case 192: // ~ toggleModus(); break; case 191: // ? document.querySelector("#help").classList.toggle("visible"); break; case 189: // - case 173: // - e.preventDefault(); e.stopPropagation(); if (e.altKey || e.metaKey) { setRoot(root - e.shiftKey ? 10 : 1); } else { scaleMode = mod(scaleMode - 1, scales.length); rebuild(); showMessage(scale.name); } break; case 187: // = case 61: // = e.preventDefault(); e.stopPropagation(); if (e.altKey || e.metaKey) { setRoot(root + e.shiftKey ? 10 : 1); } else { scaleMode = mod(scaleMode + 1, scales.length); rebuild(); showMessage(scale.name); } break; } } function setRoot(newRoot) { if (!newRoot) { return; } root = clamp(Math.round(newRoot), 1, 300000); sine.setRoot(root); bandpass.setRoot(root); showMessage(`Root: ${Math.round(root)} hz`); showRoot(root); document.querySelector(".root-select input").value = Math.round(root); } function showRoot(root) { const el = document.querySelector("#root span"); el.style.fontSize = root < 1000 ? "14px" : root < 10000 ? "12px" : "9px"; el.innerHTML = Math.round(root); } function bindRoot() { document.querySelector(".root-select").addEventListener( "click", () => { document.querySelector(".root-select").classList.remove("visible"); }, false, ); document .querySelector(".root-select > div") .addEventListener("click", (event) => { event.stopPropagation(); }); document .querySelector(".root-select input") .addEventListener("input", (event) => { setRoot(parseFloat(event.target.value)); }); Object.entries({ ok: () => document.querySelector(".root-select").classList.remove("visible"), "div-2": () => setRoot(root / 2), "mul-2": () => setRoot(root * 2), "sub-10": () => setRoot(root - 10), "sub-1": () => setRoot(root - 1), "add-1": () => setRoot(root + 1), "add-10": () => setRoot(root + 10), "note-c": () => setRoot(440 * Math.pow(2, -9 / 12) * getOctave(root)), "note-db": () => setRoot(440 * Math.pow(2, -8 / 12) * getOctave(root)), "note-d": () => setRoot(440 * Math.pow(2, -7 / 12) * getOctave(root)), "note-eb": () => setRoot(440 * Math.pow(2, -6 / 12) * getOctave(root)), "note-e": () => setRoot(440 * Math.pow(2, -5 / 12) * getOctave(root)), "note-f": () => setRoot(440 * Math.pow(2, -4 / 12) * getOctave(root)), "note-gb": () => setRoot(440 * Math.pow(2, -3 / 12) * getOctave(root)), "note-g": () => setRoot(440 * Math.pow(2, -2 / 12) * getOctave(root)), "note-ab": () => setRoot(440 * Math.pow(2, -1 / 12) * getOctave(root)), "note-a": () => setRoot(440 * Math.pow(2, 0 / 12) * getOctave(root)), "note-bb": () => setRoot(440 * Math.pow(2, 1 / 12) * getOctave(root)), "note-b": () => setRoot(440 * Math.pow(2, 2 / 12) * getOctave(root)), "note-c2": () => setRoot(440 * Math.pow(2, 3 / 12) * getOctave(root)), }).forEach(([key, fn], index) => { const el = document.querySelector(`.root-select .${key}`); el.addEventListener("click", fn); if (key.startsWith("note")) { el.style.background = color(Math.pow(2, (index - 7) / 12), 0, 1.6); } }); } let C_ROOT = Math.round(440 * Math.pow(2, 3 / 12)); function getOctave(value) { let octave = 0; while (value < C_ROOT) { octave -= 1; value *= 2; } while (value > C_ROOT) { octave += 1; value /= 2; } return Math.pow(2, octave); } let messageTransition; function showMessage(message) { const el = document.getElementById("message"); el.innerHTML = message; el.style.opacity = 1; if (messageTransition) { messageTransition.cancel(); } messageTransition = oktransition.add({ obj: el.style, from: { opacity: 1 }, to: { opacity: 0 }, delay: 1500, duration: 2000, easing: oktransition.easing.circ_out, }); } requestAudioContext(() => { const output = getOutput(); document.body.appendChild(grid); kalimba.load(output); sine.load(output); bandpass.load(output); sampler.load(output, function ready() { instrument = sampler; }); build(); bind(); });