Skip to content

Commit

Permalink
A clock-synchronized metronome
Browse files Browse the repository at this point in the history
  • Loading branch information
bkeepers committed Apr 25, 2024
0 parents commit b623c7d
Show file tree
Hide file tree
Showing 11 changed files with 1,093 additions and 0 deletions.
12 changes: 12 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "monthly"
open-pull-requests-limit: 100
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "monthly"
open-pull-requests-limit: 100
39 changes: 39 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
name: Deploy Demo

on:
push:
branches: ['main']

permissions:
contents: read
pages: write
id-token: write

concurrency:
group: 'pages'
cancel-in-progress: true

jobs:
deploy:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Node
uses: actions/setup-node@v4
- name: Install dependencies
run: npm install
- name: Build
run: npm run build -- --base=/tuner/
- name: Setup Pages
uses: actions/configure-pages@v4
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
path: './demo'
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
node_modules
dist
demo
package-lock.json
2 changes: 2 additions & 0 deletions .npmignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
.github
mytodo.md
676 changes: 676 additions & 0 deletions LICENSE

Large diffs are not rendered by default.

28 changes: 28 additions & 0 deletions demo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { createMetronome } from "./src"

const tempoEl = document.getElementById("tempo")! as HTMLInputElement
const canvas = document.querySelector('canvas')! as HTMLCanvasElement
const ctx = canvas.getContext('2d')!
ctx.strokeStyle = "#ffffff";
ctx.lineWidth = 2;

let metronome;

function onBeat(note) {
var x = Math.floor(canvas.width / 18);
ctx.clearRect(0, 0, canvas.width, canvas.height);
for (var i = 0; i < 16; i++) {
ctx.fillStyle = (note == i) ?
((note % 4 === 0) ? "red" : "blue") : "gray";
ctx.fillRect(x * (i + 1), x, x / 2, x / 2);
}
}

document.getElementById("start")?.addEventListener("click", () => {
metronome = createMetronome({ tempo: Number(tempoEl.value), onBeat })
metronome.start()
})
document.getElementById("stop")?.addEventListener("click", () => {
metronome?.stop()
metronome = undefined
})
81 changes: 81 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>@chordbook/metronome</title>
<style>
body {
margin: 0;
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
}

main {
display: flex;
flex-direction: column;
height: 100vh;
}

header {
color: white;
background: darkslateblue;
padding: 1rem 2rem;
display: flex;
}

header a {
color: inherit;
}

h1 {
font-size: 1.5rem;
margin: 0;
}

#metronome {
display: flex;
flex-direction: column;
flex: 1;
position: relative;
align-items: center;
justify-content: center;
}

#controls {
display: flex;
flex-direction: row;
gap: 2rem;
}

button, input {
font-size: 1rem;
}
</style>
</head>
<body>
<main>
<header>
<h1><a href="https://github.com/chordbook/metronome">@chordbook/metronome</a></h1>
<a href="https://github.com/chordbook/metronome" style="margin-left: auto;">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="bi bi-github" viewBox="0 0 16 16">
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8"/>
</svg>
</a>
</header>
<div id="metronome">
<div id="controls">
<div>
<button id="start">Start</button>
<button id="stop">Stop</button>
</div>
<div id="tempoBox">
Tempo:
<input id="tempo" type="number" min="30.0" max="160.0" step="1" value="120" style="width: 3rem;">
</div>
</div>
<canvas width="400" height="200"></canvas>
</div>
</main>
<script type="module" src="./demo.ts"></script>
</body>
</html>
45 changes: 45 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
{
"name": "@chordbook/metronome",
"version": "0.0.1",
"license": "GPL-3.0",
"type": "module",
"description": "The metronome used by ChordBook.app",
"module": "dist/index.js",
"main": "dist/index.cjs",
"exports": {
"import": "./dist/index.js",
"require": "./dist/index.cjs"
},
"types": "dist/index.d.ts",
"sideEffects": false,
"scripts": {
"dev": "vite",
"prepare": "tsup src/index.ts --format esm,cjs --minify --dts --sourcemap",
"build": "vite build --outDir demo",
"preview": "vite preview",
"test": "vitest"
},
"repository": {
"type": "git",
"url": "git+https://github.com/chordbook/metronome.git"
},
"keywords": [
"music",
"metronome"
],
"author": "Brandon Keepers",
"bugs": {
"url": "https://github.com/chordbook/metronome/issues"
},
"homepage": "https://github.com/chordbook/metronome#readme",
"engines": {
"node": ">=16"
},
"devDependencies": {
"tsup": "^8.0.2",
"typescript": "^5.4.2",
"vite": "^5.1.6",
"vitest": "^1.4.0"
},
"dependencies": {}
}
160 changes: 160 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import { setInterval, clearInterval } from "./timers"

export interface Beat {
note: number
time: number
}

enum Resolution {
sixteenth = 0,
eighth = 1,
quarter = 2
}

export interface MetronomeConfig {
tempo?: number
meter?: [number, number]
resolution?: Resolution
onBeat?: (note: number) => void
}

const defaultConfig: MetronomeConfig = {
tempo: 120.0,
meter: [4, 4],
resolution: Resolution.quarter
}

export function createMetronome(config: MetronomeConfig = {}) {
config = { ...defaultConfig, ...config }

const audioContext: AudioContext = new AudioContext()

// How frequently to call scheduling function (in milliseconds)
const lookahead = 25;

// How far ahead to schedule audio (sec)
// This is calculated from lookahead, and overlaps with next interval (in case the timer is late)
const scheduleAheadTime = 0.1;

// length of "beep" (in seconds)
const noteLength = 0.04;

// What note was last scheduled?
let currentNote: number;

// the last "box" we drew on the screen
let last16thNoteDrawn = -1;

// the notes that have been put into the web audio, and may or may not have played yet.
let queue: Beat[] = [];

// The interval id for the tick loop
let interval: number | undefined;

// Time in seconds that a bar lasts
const secondsPerBar = 60 / config.tempo! * config.meter![0]

// duration of 16th note in seconds
const secondsPerNote = 60 / config.tempo! / 4

// Returns the next time that the given not can be played. Given synchronized clocks, any metronome
// playing the same tempo will play the same note of a measure at the same time.
//
// note = 0 - 15 (16th notes)
function nextTime(note: number) {
const offset = (Date.now() / 1000) % secondsPerBar
return (secondsPerBar + (note * secondsPerNote) - offset) % secondsPerBar
}

function scheduleNote({ note, time }: Beat) {
// Push the note on the queue for the visualizer
queue.push({ note, time });

// we're not playing non-8th 16th notes
if ((config.resolution === Resolution.eighth) && (note % 2) !== 0) return;

// we're not playing non-quarter 8th notes
if ((config.resolution === Resolution.quarter) && (note % 4) !== 0) return;

// create an oscillator
// FIXME: make this configurable
var osc = audioContext.createOscillator();
const envelope = audioContext.createGain();
let gain = 1;

if (note % 16 === 0) {
// beat 0 == high pitch
osc.frequency.value = 880.0;
} else if (note % 4 === 0) {
// quarter notes = medium pitch
osc.frequency.value = 660.0;
gain = 0.5;
} else {
// other 16th notes = low pitch
osc.frequency.value = 440.0;
gain = 0.1;
}

envelope.gain.exponentialRampToValueAtTime(gain, time + 0.001);
envelope.gain.exponentialRampToValueAtTime(0.001, time + 0.03);
osc.start(time);
osc.stop(time + noteLength);

osc.connect(envelope);
envelope.connect(audioContext.destination);
}

function tick() {
// while there are notes that will need to play before the next interval,
// schedule them and advance the pointer.
while (true) {
const time = audioContext.currentTime + nextTime(currentNote)
if (time > audioContext.currentTime + scheduleAheadTime) break;

scheduleNote({ note: currentNote, time});

// Advance the beat number, wrap to zero
currentNote = (currentNote + 1) % 16;
}
}

function start () {
// play silent buffer to unlock the audio context
const buffer = audioContext.createBuffer(1, 1, 22050);
const node = audioContext.createBufferSource();
node.buffer = buffer;
node.start(0);

currentNote = 0;
interval = setInterval(tick, lookahead)
requestAnimationFrame(draw);
}

function stop() {
clearInterval (interval)
interval = undefined
}

function draw() {
if(!interval) return // stopped

var currentNote = last16thNoteDrawn;
var currentTime = audioContext.currentTime;

while (queue.length && queue[0].time < currentTime) {
currentNote = queue[0].note;
queue.splice(0, 1); // remove note from queue
}

// We only need to draw if the note has moved.
if (last16thNoteDrawn != currentNote) {
config.onBeat?.(currentNote)
last16thNoteDrawn = currentNote;
}

// set up to draw again
requestAnimationFrame(draw);
}

return { start, stop }
}
Loading

0 comments on commit b623c7d

Please sign in to comment.