Skip to content

Instantly share code, notes, and snippets.

@lmiller1990
Created October 10, 2022 13:24
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 lmiller1990/4c7294f028542df3659a4a1fc11de8b5 to your computer and use it in GitHub Desktop.
Save lmiller1990/4c7294f028542df3659a4a1fc11de8b5 to your computer and use it in GitHub Desktop.
Simple Rhythm Engine
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script src="/index.js"></script>
<title>Demo</title>
<style>
#gameplay {
position: relative;
}
#target {
position: absolute;
top: 0;
left: 0;
width: 100px;
height: 25px;
border: 2px solid;
}
.note {
position: absolute;
width: 100px;
height: 25px;
background: darkcyan;
}
#timing {
position: absolute;
left: 100px;
list-style: none;
}
</style>
</head>
<body>
<p>Rhythm engine demo.</p>
<ul>
<li>Click <b>Start</b> to start.</li>
<li>Press <b>J</b> to hit notes when they overlap the rectangle.</li>
</ul>
<div>
<button onclick="start()">Start</button>
<hr>
</div>
<div id="gameplay">
<div id="target"></div>
<ul id="timing">
</ul>
</div>
</body>
</html>
// @ts-check
/**
* @param {string} url
* @returns {Promise<() => { audioContext: AudioContext, startTime?: number }>}
*/
async function fetchAudio(url) {
const audioContext = new window.AudioContext();
const res = await window.fetch(url);
const buf = await res.arrayBuffer();
const buffer = await audioContext.decodeAudioData(buf);
const gainNode = audioContext.createGain();
gainNode.gain.value = 1.0;
gainNode.connect(audioContext.destination);
return () => {
const source = audioContext.createBufferSource();
source.buffer = buffer;
source.connect(gainNode);
source.start(0);
const startTime = audioContext.getOutputTimestamp().performanceTime;
return { audioContext, startTime };
};
}
/**
* A simple "chart"
* 4th note at 135 bpm
* 60/135 = 0.444...
* A note every 0.444ms
*
* @returns {Array<{ el: HTMLDivElement; ms: number, timing?: number }>}
*/
function createChart() {
const offsetMs = 60;
const _4th = 60 / 135;
const notes = [];
const $gameplay = document.querySelector("#gameplay");
for (let i = 1; i < 16; i++) {
const el = document.createElement("div");
el.className = "note";
$gameplay.appendChild(el);
notes.push({ ms: i * _4th * 1000 + offsetMs, el });
}
console.log(JSON.stringify(notes, null, 2))
return notes;
}
/**
* @param {Array<{ el: HTMLDivElement; ms: number, timing?: number }>} chart
* @param {AudioContext} audioContext
* @param {number} startTime
* @param {InputManager} inputManager
*/
function gameLoop(chart, audioContext, startTime, inputManager) {
const elapsed = audioContext.getOutputTimestamp().performanceTime - startTime;
inputManager.process(chart);
// calculate new position based on playback
for (const note of chart) {
note.el.style.top = `${note.ms - elapsed}px`;
}
inputManager.clear();
requestAnimationFrame(() =>
gameLoop(chart, audioContext, startTime, inputManager)
);
}
class InputManager {
/** @type {number | undefined} */
#input;
/**
* @param {number} t0
*/
constructor(t0) {
window.addEventListener("keydown", (event) => {
if (event.code !== "KeyJ") {
return;
}
this.#input = event.timeStamp - t0;
});
}
clear() {
this.#input = undefined;
}
/**
* @param {Array<{ el: HTMLDivElement, ms: number, timing?: number }>} notes
*/
process(notes) {
if (!this.#input) {
return;
}
const $ul = document.querySelector("#timing");
if (!$ul) {
return;
}
$ul.innerHTML = "";
for (let i = 0; i < notes.length; i++) {
const note = notes[i];
if (Math.abs(note.ms - this.#input) < 100) {
// close enough to be considered hit!
note.timing = note.ms - this.#input;
}
const $li = document.createElement("li");
const content = note.timing ? `${note.timing.toFixed(0)}ms` : "-";
const id = i.toString().padStart(2, "0");
$li.textContent = `Note #${id}: ${content}`;
$ul.appendChild($li);
}
}
}
async function start() {
const play = await fetchAudio("/135bpm.wav");
const chart = createChart();
const { startTime, audioContext } = play();
const inputManager = new InputManager(startTime);
gameLoop(chart, audioContext, startTime, inputManager);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment