|
class ColorPalette { |
|
constructor() { |
|
this.primary = "rgba(36, 87, 201, 1)"; |
|
this.primaryLight = "rgba(58, 133, 240, 1)"; |
|
this.highlight = "#AF17CB"; |
|
this.highlightDark = "#6F0182"; |
|
} |
|
} |
|
|
|
function drawCircle(ctx, color, radius, width, centerX, centerY) { |
|
ctx.strokeStyle = color; |
|
ctx.lineWidth = width; |
|
ctx.beginPath(); |
|
for (let theta = 0; theta < 2 * Math.PI; theta += Math.PI / 100) { |
|
let x = centerX + radius * Math.cos(theta); |
|
let y = centerY - radius * Math.sin(theta); |
|
|
|
ctx.lineTo(x, y); |
|
} |
|
ctx.closePath(); |
|
ctx.stroke(); |
|
} |
|
|
|
function plotCircle( |
|
radius, |
|
points, |
|
centerX, |
|
centerY, |
|
angleOffset, |
|
drawFunction |
|
) { |
|
let startAngle = angleOffset; |
|
let endAngle = 2 * Math.PI + angleOffset; |
|
for ( |
|
let theta = startAngle, i = 0; |
|
i < points; |
|
theta += 2 * Math.PI / points, i++ |
|
) { |
|
let x = centerX + radius * Math.cos(theta); |
|
let y = centerY - radius * Math.sin(theta); |
|
|
|
drawFunction((x = x), (y = y), (i = i), (theta = theta)); |
|
} |
|
} |
|
|
|
class MusicController { |
|
constructor(parent, bpm) { |
|
this.parent = parent; |
|
this.notes = [ |
|
"E3", |
|
"F#3", |
|
"B3", |
|
"C#4", |
|
"D4", |
|
"F#3", |
|
"E3", |
|
"C#4", |
|
"B3", |
|
"F#3", |
|
"D4", |
|
"C#4" |
|
]; |
|
this.bpm = bpm; |
|
this.msPerBeat = 1 / (this.bpm / 60) * 1000; |
|
this.msPerSixteenth = this.msPerBeat / 4; |
|
this.synth = new Tone.Synth().toMaster(); |
|
this.synth.volume.value = -6; |
|
} |
|
|
|
trigger(note) { |
|
this.synth.triggerAttackRelease(note, "16n"); |
|
} |
|
} |
|
|
|
class MusicNode { |
|
constructor(note, color, angle, centerX, centerY, parent) { |
|
this.note = note; |
|
this.initColor = color; |
|
this.color = color; |
|
this.angle = angle; |
|
this.triggered = false; |
|
this.scale = 1; |
|
this.center = { |
|
x: centerX, |
|
y: centerY |
|
}; |
|
this.parent = parent; |
|
this.flash = { |
|
currentFrame: 0, |
|
maxFrames: 8, |
|
active: false, |
|
intensity: 0 |
|
}; |
|
} |
|
|
|
draw(ctx, canvas, radius) { |
|
// draw ball with text |
|
ctx.fillStyle = this.color; |
|
ctx.beginPath(); |
|
ctx.arc(this.center.x, this.center.y, radius * this.scale, 0, 2 * Math.PI); |
|
ctx.closePath(); |
|
ctx.fill(); |
|
ctx.font = "12px serif"; |
|
ctx.fillStyle = "white"; |
|
let text = ctx.measureText(this.note); |
|
let textWidth = text.width; |
|
ctx.fillText(this.note, this.center.x - textWidth / 2, this.center.y + 4); |
|
|
|
//check if should be flashing |
|
if (this.flash.active) { |
|
//animation logic |
|
|
|
//flash off (50% complete) |
|
if (this.flash.currentFrame > this.flash.maxFrames * 0.2) { |
|
this.scale -= 0.2; |
|
} else { |
|
//flash on |
|
this.scale += 0.6; |
|
} |
|
|
|
//looping logic |
|
this.flash.currentFrame++; |
|
|
|
//loop end |
|
if (this.flash.currentFrame > this.flash.maxFrames) { |
|
this.flash.currentFrame = 0; |
|
this.flash.intensity = 0; |
|
this.scale = 1; |
|
this.color = this.initColor; |
|
this.flash.active = false; |
|
} |
|
} |
|
} |
|
|
|
colliding(checkAngle) { |
|
let startAngle = this.angle - Math.PI / 50; |
|
let endAngle = this.angle; |
|
if (startAngle < 0) { |
|
startAngle = startAngle + 2 * Math.PI; |
|
endAngle = endAngle + 2 * Math.PI; |
|
} |
|
if (checkAngle >= startAngle && checkAngle <= endAngle) { |
|
return true; |
|
} |
|
} |
|
} |
|
|
|
class TriggerNode { |
|
constructor(color, radius) { |
|
this.color = color; |
|
this.radius = radius; |
|
this.currentAngle = 0; |
|
} |
|
|
|
orbit(ctx, canvas, angle, radius) { |
|
//calculate new position |
|
this.currentAngle = angle; |
|
let x = canvas.width / 2 + radius * Math.cos(angle); |
|
let y = canvas.height / 2 - radius * Math.sin(angle); |
|
|
|
//animation |
|
ctx.fillStyle = this.color; |
|
ctx.beginPath(); |
|
ctx.arc(x, y, this.radius, 0, 2 * Math.PI); |
|
ctx.closePath(); |
|
ctx.fill(); |
|
} |
|
} |
|
|
|
class BeatPlanet { |
|
constructor(color, radius) { |
|
this.color = color; |
|
this.radius = radius; |
|
this.initRadius = radius; |
|
this.synth = new Tone.MembraneSynth().toMaster(); |
|
this.synth.volume.value = -4; |
|
this.pulse = { |
|
active: false, |
|
startFrames: 3, |
|
endFrames: 5, |
|
currentFrame: 0, |
|
increment: 4, |
|
startVelocity: null, |
|
endVelocity: null |
|
}; |
|
this.pulse.startVelocity = |
|
this.pulse.startFrames * this.pulse.increment / this.pulse.startFrames; |
|
this.pulse.endVelocity = |
|
this.pulse.startFrames * this.pulse.increment / this.pulse.endFrames; |
|
} |
|
|
|
trigger() { |
|
this.synth.triggerAttackRelease("C2", "8n"); |
|
this.pulse.active = true; |
|
} |
|
|
|
pulseStart() { |
|
if (this.pulse.currentFrame < this.pulse.startFrames) { |
|
this.radius += this.pulse.startVelocity; |
|
this.pulse.currentFrame++; |
|
} else { |
|
this.pulseEnd(); |
|
} |
|
} |
|
|
|
pulseEnd() { |
|
if (this.pulse.currentFrame < this.pulse.endFrames + this.pulse.startFrames) { |
|
this.radius -= this.pulse.endVelocity; |
|
this.pulse.currentFrame++; |
|
} else { |
|
this.radius = Math.round(this.radius); |
|
this.pulse.currentFrame = 0; |
|
this.pulse.active = false; |
|
} |
|
} |
|
|
|
update(ctx, canvas) { |
|
ctx.fillStyle = this.color; |
|
ctx.beginPath(); |
|
ctx.arc(canvas.width / 2, canvas.height / 2, this.radius, 0, 2 * Math.PI); |
|
ctx.closePath(); |
|
ctx.fill(); |
|
|
|
if (this.pulse.active) { |
|
this.pulseStart(); |
|
} |
|
} |
|
} |
|
|
|
class Planet { |
|
constructor(canvas, ctx, radius, dynamic = false, orbitColor = "red") { |
|
//passed in props |
|
this.canvas = canvas; |
|
this.ctx = ctx; |
|
this.radius = radius; |
|
this.dynamic = dynamic; |
|
this.orbitColor = orbitColor; |
|
//class props |
|
this.musicController = new MusicController(this, 100); |
|
this.nodes = []; |
|
this.colors = new ColorPalette(); |
|
|
|
//orbit values |
|
this.triggerColor = this.orbitColor.replace("1)", ".7)"); |
|
this.triggerNode = new TriggerNode(this.triggerColor, 25); |
|
this.orbit = { |
|
elapsedTime: 0, |
|
currentAngle: 0, |
|
currentNote: 0, |
|
complete: false |
|
}; |
|
|
|
//music values |
|
this.currentBeat = 0; |
|
|
|
//phasing logic |
|
this.offset = 0; |
|
|
|
this.controls = { |
|
up: null, |
|
down: null |
|
}; |
|
|
|
this.phasing = { |
|
active: false, |
|
currentNote: 0, |
|
direction: "up", |
|
increment: 2 * Math.PI / 10000, |
|
targetAngle: 0, |
|
positions: [] |
|
}; |
|
|
|
//init methods |
|
this.createNodes(); |
|
|
|
if (this.dynamic) { |
|
this.setListeners(); |
|
} else { |
|
this.beatPlanet = new BeatPlanet(this.colors.primary, 120); |
|
} |
|
} |
|
|
|
setPositions() { |
|
for (let node of this.nodes) { |
|
this.phasing.positions.push(node.angle); |
|
} |
|
} |
|
|
|
createNodes() { |
|
let { canvas } = this; |
|
let mc = this.musicController; |
|
plotCircle( |
|
this.radius, |
|
mc.notes.length, |
|
canvas.width / 2, |
|
canvas.height / 2, |
|
0, |
|
(x, y, i, theta) => { |
|
let newColor = this.orbitColor; |
|
if (i === 0) { |
|
newColor = this.colors.highlight; |
|
} |
|
let newNode = new MusicNode(mc.notes[i], newColor, theta, x, y, this); |
|
this.nodes.push(newNode); |
|
} |
|
); |
|
|
|
this.setPositions(); |
|
} |
|
|
|
updateNodes() { |
|
let { canvas } = this; |
|
let mc = this.musicController; |
|
|
|
plotCircle( |
|
this.radius, |
|
mc.notes.length, |
|
canvas.width / 2, |
|
canvas.height / 2, |
|
this.offset, |
|
(x, y, i, theta) => { |
|
if (theta > 2 * Math.PI) { |
|
theta = theta % (2 * Math.PI); |
|
} |
|
if (theta < 0) { |
|
theta = Math.abs(theta) % (2 * Math.PI); |
|
} |
|
|
|
this.nodes[i].center.x = x; |
|
this.nodes[i].center.y = y; |
|
this.nodes[i].angle = theta; |
|
} |
|
); |
|
} |
|
|
|
drawNodes() { |
|
let { ctx, canvas } = this; |
|
for (let node of this.nodes) { |
|
node.draw(ctx, canvas, 18); |
|
} |
|
} |
|
|
|
phase(direction) { |
|
if (direction === "up") { |
|
this.phasing.direction = "up"; |
|
this.phasing.currentNote++; |
|
if (this.phasing.currentNote === this.nodes.length) { |
|
this.phasing.currentNote = 0; |
|
} |
|
|
|
this.phasing.targetAngle = this.phasing.positions[this.phasing.currentNote]; |
|
|
|
if (this.phasing.targetAngle === 0) { |
|
this.phasing.targetAngle = 2 * Math.PI; |
|
} |
|
|
|
this.phasing.active = true; |
|
} else { |
|
this.phasing.direction = "down"; |
|
this.phasing.currentNote--; |
|
|
|
if (this.phasing.currentNote < 0) { |
|
this.phasing.currentNote = this.nodes.length - 1; |
|
} |
|
|
|
this.phasing.targetAngle = this.phasing.positions[this.phasing.currentNote]; |
|
|
|
if (this.phasing.targetAngle === this.nodes[this.nodes.length - 1].angle) { |
|
this.offset = 2 * Math.PI; |
|
} |
|
|
|
console.log(this.offset, this.phasing.targetAngle); |
|
|
|
this.phasing.active = true; |
|
} |
|
} |
|
|
|
draw(time) { |
|
let { ctx, canvas } = this; |
|
//draw circle + nodes |
|
drawCircle( |
|
ctx, |
|
this.orbitColor, |
|
this.radius, |
|
5, |
|
canvas.width / 2, |
|
canvas.height / 2 |
|
); |
|
this.drawNodes(); |
|
|
|
//draw beat planet |
|
if (!this.dynamic) { |
|
this.beatPlanet.update(this.ctx, this.canvas); |
|
} |
|
|
|
//animate trigger node |
|
|
|
this.orbit.elapsedTime += time; |
|
this.orbit.currentAngle = |
|
this.orbit.elapsedTime / |
|
(this.musicController.msPerSixteenth * 12) * |
|
(2 * Math.PI); |
|
this.triggerNode.orbit(ctx, canvas, this.orbit.currentAngle, this.radius); |
|
if (this.orbit.elapsedTime >= this.musicController.msPerSixteenth * 12) { |
|
this.orbit.elapsedTime = 0; |
|
} |
|
|
|
//collision checks |
|
for (let i = 0; i < this.nodes.length; i++) { |
|
let node = this.nodes[i]; |
|
if (node.colliding(this.orbit.currentAngle)) { |
|
if (i === 0 && !node.triggered) { |
|
setTimeout(() => { |
|
this.nodes[0].triggered = false; |
|
}, this.musicController.msPerSixteenth); |
|
for (let j = 0; j < this.nodes.length; j++) { |
|
this.nodes[j].triggered = false; |
|
} |
|
} |
|
if (!node.triggered) { |
|
this.musicController.trigger(node.note); |
|
node.flash.active = true; |
|
//kick drum check |
|
if (i % 4 === 0 && !this.dynamic) { |
|
// this.beatPlanet.trigger(); |
|
} |
|
this.nodes[i].triggered = true; |
|
} |
|
} |
|
} |
|
|
|
//auto phasing logic |
|
if (this.phasing.active) { |
|
//phase up |
|
if ( |
|
this.offset < this.phasing.targetAngle && |
|
this.phasing.direction === "up" |
|
) { |
|
this.offset += this.phasing.increment; |
|
this.updateNodes(); |
|
} else if (this.phasing.direction === "up") { |
|
this.phasing.targetAngle = this.phasing.positions[this.phasing.currentNote]; |
|
this.offset = this.phasing.positions[this.phasing.currentNote]; |
|
this.updateNodes(); |
|
this.phasing.active = false; |
|
} |
|
|
|
//phase down |
|
if ( |
|
this.offset > this.phasing.targetAngle && |
|
this.phasing.direction === "down" |
|
) { |
|
this.offset -= this.phasing.increment; |
|
this.updateNodes(); |
|
} else if (this.phasing.direction === "down") { |
|
this.phasing.targetAngle = this.phasing.positions[this.phasing.currentNote]; |
|
this.offset = this.phasing.positions[this.phasing.currentNote]; |
|
this.updateNodes(); |
|
this.phasing.active = false; |
|
} |
|
} |
|
} |
|
|
|
setListeners() { |
|
this.controls.up = document.getElementById("phaseup"); |
|
this.controls.down = document.getElementById("phasedown"); |
|
} |
|
} |
|
|
|
class Canvas { |
|
constructor(width, height) { |
|
//declarations |
|
this.width = width; |
|
this.height = height; |
|
this.el = null; |
|
this.ctx = null; |
|
|
|
//init functions |
|
this.create(); |
|
this.reset(); |
|
} |
|
|
|
create() { |
|
this.el = document.getElementById("canvas"); |
|
this.ctx = this.el.getContext("2d"); |
|
this.el.width = this.width; |
|
this.el.height = this.height; |
|
|
|
//fix dpr blur |
|
let scale = window.devicePixelRatio; |
|
|
|
this.el.style.width = this.width + "px"; |
|
this.el.style.height = this.height + "px"; |
|
|
|
this.el.width = this.width * scale; |
|
this.el.height = this.height * scale; |
|
this.ctx.scale(scale, scale); |
|
} |
|
|
|
reset() { |
|
this.ctx.fillStyle = "black"; |
|
this.ctx.fillRect(0, 0, this.width, this.height); |
|
} |
|
} |
|
|
|
class Main { |
|
constructor() { |
|
this.canvas = new Canvas(650, 650); |
|
this.controls = []; |
|
|
|
this.slider = null; |
|
this.sliderHandle = null; |
|
|
|
this.colors = new ColorPalette(); |
|
this.staticPlanet = new Planet( |
|
this.canvas, |
|
this.canvas.ctx, |
|
200, |
|
false, |
|
this.colors.primary |
|
); |
|
this.dynamicPlanet = new Planet( |
|
this.canvas, |
|
this.canvas.ctx, |
|
270, |
|
true, |
|
this.colors.primaryLight |
|
); |
|
// synth stuff |
|
this.dynamicPlanet.musicController.synth.detune.value = 10; |
|
|
|
this.started = false; |
|
this.playing = false; |
|
this.phaseButton = null; |
|
this.phaseButtonText = "begin"; |
|
this.toggleButton = null; |
|
this.toggleButtonIcon = "play"; |
|
|
|
let leftPan = new Tone.PanVol(-1, -8); |
|
let rightPan = new Tone.PanVol(1, -8); |
|
|
|
this.dynamicPlanet.musicController.synth.chain(leftPan, Tone.Master); |
|
this.staticPlanet.musicController.synth.chain(rightPan, Tone.Master); |
|
|
|
this.animationFrame = null; |
|
|
|
//animation timing |
|
this.previousTime = 0; |
|
this.deltaTime = 0; |
|
|
|
//init methods |
|
this.setListeners(); |
|
this.createSlider(); |
|
this.initDraw(); |
|
this.slider.setAttribute("disabled", true); |
|
} |
|
|
|
toggleControls(state) { |
|
if (!state) { |
|
for (let control of this.controls) { |
|
control.setAttribute("disabled", true); |
|
} |
|
} else { |
|
for (let control of this.controls) { |
|
control.removeAttribute("disabled"); |
|
} |
|
} |
|
} |
|
|
|
toggleButtonState() { |
|
if (this.started) { |
|
this.phaseButton.innerText = "phase!"; |
|
this.phaseButton.style.backgroundColor = this.colors.highlight; |
|
} |
|
this.playing = !this.playing; |
|
if (this.playing) { |
|
this.toggleButtonIcon = "pause"; |
|
this.toggleButton.style.left = "80px"; |
|
} else { |
|
this.toggleButtonIcon = "play"; |
|
this.toggleButton.style.left = "90px"; |
|
this.phaseButton.disabled = true; |
|
this.slider.setAttribute("disabled", true); |
|
} |
|
|
|
this.toggleButton.name = this.toggleButtonIcon; |
|
} |
|
|
|
setListeners() { |
|
let slider = document.getElementById("slider"); |
|
this.slider = slider; |
|
this.controls.push(slider); |
|
|
|
this.toggleButton = document.getElementById("toggle"); |
|
this.toggleButton.addEventListener("click", e => { |
|
if (!this.started) { |
|
this.run(); |
|
} else { |
|
if (this.playing) { |
|
this.pause(); |
|
} else { |
|
this.resume(); |
|
} |
|
} |
|
}); |
|
|
|
this.phaseButton = document.getElementById("phase"); |
|
this.controls.push(this.phaseButton); |
|
this.phaseButton.addEventListener("click", e => { |
|
if (!this.started) { |
|
this.run(); |
|
} else { |
|
this.dynamicPlanet.phase("up"); |
|
} |
|
}); |
|
|
|
document.addEventListener("keydown", e => { |
|
if ( |
|
(e.key === "ArrowLeft" || |
|
e.key === "ArrowRight" || |
|
e.key === "ArrowUp" || |
|
e.key === "ArrowDown") && |
|
document.activeElement === this.sliderHandle |
|
) { |
|
this.slider.noUiSlider.set(this.dynamicPlanet.offset / (2 * Math.PI) * 12); |
|
} |
|
}); |
|
} |
|
|
|
createSlider() { |
|
noUiSlider.create(this.slider, { |
|
animate: false, |
|
start: 0, |
|
connect: [true, false], |
|
range: { |
|
min: 0, |
|
max: 12 |
|
}, |
|
pips: { |
|
mode: "count", |
|
values: 13, |
|
density: 1, |
|
format: wNumb({ |
|
prefix: "Phase ", |
|
decimals: 0 |
|
}) |
|
} |
|
}); |
|
|
|
this.sliderHandle = document.getElementsByClassName("noUi-handle-lower")[0]; |
|
|
|
this.slider.noUiSlider.on("slide", e => { |
|
let value = parseFloat(e[0]); |
|
let newOffset = value / 12 * (2 * Math.PI); |
|
this.dynamicPlanet.offset = newOffset; |
|
this.dynamicPlanet.updateNodes(); |
|
if (this.dynamicPlanet.phasing.currentNote !== Math.floor(value)) { |
|
this.dynamicPlanet.phasing.currentNote = Math.floor(value); |
|
} |
|
}); |
|
} |
|
|
|
update = time => { |
|
//calculate delta time |
|
if (!this.previousTime) this.previousTime = time; |
|
this.deltaTime = time - this.previousTime; |
|
this.previousTime = time; |
|
|
|
//reset canvas |
|
let ctx = this.canvas.ctx; |
|
this.canvas.reset(); |
|
|
|
this.staticPlanet.draw(this.deltaTime); |
|
this.dynamicPlanet.draw(this.deltaTime); |
|
|
|
//check if controls should be disabled |
|
if (this.dynamicPlanet.phasing.active) { |
|
this.toggleControls(false); |
|
// slider animation |
|
this.slider.noUiSlider.set(this.dynamicPlanet.offset / (2 * Math.PI) * 12); |
|
} else if (this.dynamicPlanet.phasing.currentNote === 12) { |
|
this.controls[1].disabled = true; |
|
} else { |
|
this.toggleControls(true); |
|
} |
|
|
|
this.animationFrame = requestAnimationFrame(this.update); |
|
}; |
|
|
|
initDraw() { |
|
this.staticPlanet.draw(0); |
|
this.dynamicPlanet.draw(0); |
|
} |
|
|
|
run() { |
|
requestAnimationFrame(this.update); |
|
this.staticPlanet.musicController.trigger("E3"); |
|
this.dynamicPlanet.musicController.trigger("E3"); |
|
// this.staticPlanet.beatPlanet.trigger(); |
|
this.started = true; |
|
this.toggleButtonState(); |
|
} |
|
|
|
resume() { |
|
requestAnimationFrame(this.update); |
|
this.toggleButtonState(); |
|
} |
|
|
|
pause() { |
|
cancelAnimationFrame(this.animationFrame); |
|
for (let i = 0; i < this.dynamicPlanet.nodes.length; i++) { |
|
let dNode = this.dynamicPlanet.nodes[i]; |
|
let sNode = this.staticPlanet.nodes[i]; |
|
dNode.triggered = false; |
|
sNode.triggered = false; |
|
} |
|
this.toggleButtonState(); |
|
} |
|
} |
|
|
|
let app = new Main(); |