Skip to content

Instantly share code, notes, and snippets.

@harunpehlivan
Created July 20, 2021 18:27
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save harunpehlivan/9d97d6de91c7eb2765f617d5cba93a1e to your computer and use it in GitHub Desktop.
Save harunpehlivan/9d97d6de91c7eb2765f617d5cba93a1e to your computer and use it in GitHub Desktop.
Piano Phase Planets (CPC Planets)
<div class="info">
<a href="https://en.wikipedia.org/wiki/Piano_Phase#Composition" target="_blank" class="info__link">
?
</a>
<div class="info__popup">
Musical "phasing" generally has two identical lines of music, which begin by playing synchronously, but slowly become out of phase with one another when one of them slightly speeds up. This project is an attempt to visualize that technique. Click for
more specific info!
</div>
</div>
<div id="container" class="container">
<div class="toggle">
<ion-icon name="play" id="toggle"></ion-icon>
</div>
<canvas id="canvas">
</canvas>
<div class="slider-con">
<div class="slider" id="slider"></div>
</div>
<div class="controls">
<div class="controls__phase">
<button id="phase">begin</button>
</div>
</div>
</div>

Piano Phase Planets (CPC Planets)

The Piano Phase Planet for this week's CodePen Challenge! Each orbit corresponds to one of the two lines in Steve Reich's Piano Phase. The planet is beating out the quarter note. Move the slider or click the button to phase!

A Pen by HARUN PEHLİVAN on CodePen.

License.

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();
<script src="https://unpkg.com/ionicons@4.5.5/dist/ionicons.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/wnumb/1.1.0/wNumb.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/noUiSlider/13.1.5/nouislider.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/tone/13.8.11/Tone.min.js"></script>
// vars
$color-primary: rgb(36, 87, 201);
$color-primary-light: rgb(58, 133, 240);
$color-highlight: #af17cb;
$color-highlight-dark: #6f0182;
$color-white: #fff;
$color-black: #000;
$font-main: "Barlow", sans-serif;
.container {
font-family: "Barlow", sans-serif;
width: 100vw;
display: flex;
justify-content: center;
flex-wrap: wrap;
position: relative;
background-color: black;
min-height: 100vh;
}
.info {
position: fixed;
z-index: 1000;
top: 30px;
right: 30px;
&__link {
background-color: $color-highlight;
font-size: 60px;
color: white;
padding: 10px 30px;
border-radius: 50%;
display: flex;
justify-content: center;
align-items: center;
text-decoration: none;
&:hover {
& + .info__popup {
opacity: 1;
visibility: visible;
}
}
}
&__popup {
position: absolute;
top: 120%;
padding: 15px;
border-radius: 5px;
background-color: $color-highlight;
width: 300px;
right: 0;
color: white;
font-size: 16px;
font-family: $font-main;
opacity: 0;
line-height: 150%;
visibility: hidden;
transition: visibility 0s, opacity 0.3s;
}
}
.toggle {
position: absolute;
z-index: 200;
top: 245px;
left: 50%;
transform: translate(-50%, 0);
width: 160px;
height: 160px;
background-color: $color-highlight;
border-radius: 50%;
box-shadow: 5px 5px 5px rgba(0, 0, 0, 0.3);
color: white;
font-size: 100px;
transition: all 0.2s;
&:hover {
top: 242px;
cursor: pointer;
transform: translate(-50%, -1%);
}
& * {
position: absolute;
top: 50%;
left: 55%;
transform: translate(-50%, -50%);
}
}
.controls {
flex: 0 1 80%;
display: flex;
margin: 50px 0 30px 0;
justify-content: center;
& button {
text-transform: uppercase;
color: $color-white;
font-weight: 600;
font-size: 18px;
letter-spacing: 1px;
border: none;
border-radius: 2px;
background-color: $color-primary;
padding: 25px 80px;
transition: all 0.1s;
&:hover {
cursor: pointer;
transform: translateY(-3px);
}
&:disabled {
background-color: darken($color-highlight, 20%) !important;
color: darken($color-white, 20%);
&:hover {
cursor: not-allowed;
transform: none;
}
}
}
}
//slider styling
.slider-con {
padding: 20px 0;
flex: 0 1 80%;
position: relative;
}
.noUi {
&-target {
height: 10px;
color: $color-white;
}
&-connect {
background-color: $color-primary-light;
}
&-handle.noUi-handle-lower {
height: 25px;
width: 25px;
top: -9px;
border-radius: 50%;
background-color: $color-highlight;
box-shadow: none;
border: none;
right: -13px !important;
transition: transform 0.2s;
&:hover {
transform: scale(1.2);
}
&::before {
display: none;
}
&::after {
display: none;
}
}
&-value.noUi-value-horizontal {
margin-top: 10px;
color: white;
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment