/** * 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 organ from "./lib/organ"; 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 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; requestAudioContext(() => { const output = getOutput(); document.body.appendChild(grid); kalimba.load(output); organ.load(output); sampler.load(output, function ready() { instrument = sampler; }); build(); bind(); }); 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 (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); trigger(note); 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); } } 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: () => { div.parentNode && div.parentNode.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 ba_gcd = gcd(a_inv, b_inv); let a_disp = a_inv / ba_gcd; let 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 .close") .addEventListener("click", () => document.querySelector("#help").classList.remove("visible"), ); document .querySelector("#help-button") .addEventListener("click", () => document.querySelector("#help").classList.toggle("visible"), ); document .querySelector("#pythagorean") .addEventListener("click", () => (scale = scales.pythagorean)); // 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 191: // ? document.querySelector("#help").classList.toggle("visible"); break; case 189: // - e.preventDefault(); if (e.altKey || e.metaKey) { root = clamp(root - (e.shiftKey ? 10 : 1), 1, 200000); organ.setRoot(root); showMessage(`Root: ${root} hz`); } else { scaleMode = mod(scaleMode - 1, scales.length); scale = scales[scaleMode]; rebuild(); showMessage(scale.name); } break; case 187: // = e.preventDefault(); if (e.altKey || e.metaKey) { root = clamp(root + (e.shiftKey ? 10 : 1), 1, 200000); organ.setRoot(root); showMessage(`Root: ${root} hz`); } else { scaleMode = mod(scaleMode + 1, scales.length); scale = scales[scaleMode]; rebuild(); showMessage(scale.name); } break; } } 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, }); }