-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
0 parents
commit b623c7d
Showing
11 changed files
with
1,093 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,4 @@ | ||
node_modules | ||
dist | ||
demo | ||
package-lock.json |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
.github | ||
mytodo.md |
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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": {} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 } | ||
} |
Oops, something went wrong.