/**
* 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();
});