diff options
-rw-r--r-- | .gitignore | 1 | ||||
-rw-r--r-- | README.md | 14 | ||||
-rwxr-xr-x | bin/watch.sh | 16 | ||||
-rw-r--r-- | flake.lock | 42 | ||||
-rw-r--r-- | flake.nix | 21 | ||||
-rw-r--r-- | public/index.html | 10 | ||||
-rw-r--r-- | public/main.css | 67 | ||||
-rw-r--r-- | public/sounds/kick.opus | bin | 0 -> 1743 bytes | |||
-rw-r--r-- | src/lib/h.ts | 34 | ||||
-rw-r--r-- | src/lib/time.ts | 8 | ||||
-rw-r--r-- | src/main.ts | 9 | ||||
-rw-r--r-- | src/sounds.ts | 29 | ||||
-rw-r--r-- | src/view/sequencer.ts | 60 | ||||
-rw-r--r-- | src/view/sequencer/addRemoveBeat.ts | 32 | ||||
-rw-r--r-- | src/view/sequencer/block.ts | 28 | ||||
-rw-r--r-- | src/view/sequencer/play.ts | 68 | ||||
-rw-r--r-- | tsconfig.json | 13 |
17 files changed, 452 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e07f8e1 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +public/main.js diff --git a/README.md b/README.md new file mode 100644 index 0000000..c939efb --- /dev/null +++ b/README.md @@ -0,0 +1,14 @@ +# Getting started + +Having nix installed, run: + +```sh +nix develop --command bin/watch.sh +``` + +Then, open your browser at `http://localhost:8000`. + +# Todo + +- [ ] Augment the BPM by X after Y cycles +- [ ] Provide more drum sounds diff --git a/bin/watch.sh b/bin/watch.sh new file mode 100755 index 0000000..f93ce18 --- /dev/null +++ b/bin/watch.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Run server + +python -m http.server --directory public 8000 & +trap "fuser -k 8000/tcp" EXIT + +# Watch TypeScript + +CHECK="echo Checking TypeScript… && tsc --checkJs" +BUILD="esbuild --bundle src/main.ts --target=es2016 --outdir=public" +watchexec \ + --clear \ + --watch src \ + -- "$CHECK && $BUILD" diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..89217c5 --- /dev/null +++ b/flake.lock @@ -0,0 +1,42 @@ +{ + "nodes": { + "flake-utils": { + "locked": { + "lastModified": 1653893745, + "narHash": "sha256-0jntwV3Z8//YwuOjzhV2sgJJPt+HY6KhU7VZUL0fKZQ=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "1ed9fb1935d260de5fe1c2f7ee0ebaae17ed2fa1", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1654681126, + "narHash": "sha256-Y6uhgR3HjrHjxXk5k7Mlc9w2/DZSrS23y2gs7H/z8x8=", + "owner": "nixos", + "repo": "nixpkgs", + "rev": "543f9893c851909c59725556e327c722f56a6227", + "type": "github" + }, + "original": { + "owner": "nixos", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..325ed60 --- /dev/null +++ b/flake.nix @@ -0,0 +1,21 @@ +{ + inputs = { + nixpkgs.url = "github:nixos/nixpkgs"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { self, nixpkgs, flake-utils }: + flake-utils.lib.eachDefaultSystem + (system: + let pkgs = nixpkgs.legacyPackages.${system}; + in { devShell = pkgs.mkShell { + buildInputs = with pkgs; [ + nodePackages.typescript + python3 + psmisc # fuser + esbuild + watchexec + ]; + }; } + ); +} diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..5facb9e --- /dev/null +++ b/public/index.html @@ -0,0 +1,10 @@ +<!doctype html> +<html lang="fr"> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width"> +<title>Metronome</title> +<link rel="stylesheet" href="/main.css"> + +<body></body> + +<script src="main.js"></script> diff --git a/public/main.css b/public/main.css new file mode 100644 index 0000000..fbc37c4 --- /dev/null +++ b/public/main.css @@ -0,0 +1,67 @@ +:root { + --color-block: lightgray; + --color-block-checked: lightgreen; + + --spacing-mouse: 0.25rem; + --spacing-cat: 0.5rem; + --spacing-dog: 1rem; + --spacing-horse: 2rem; + --spacing-elephant: 4rem; +} + +body { + margin: var(--spacing-dog); +} + +.g-Bpm { + display: block; + margin-bottom: var(--spacing-dog); +} + +.g-Input { + margin-left: var(--spacing-dog); +} + +.g-PlayStop { + margin-bottom: var(--spacing-dog); +} + +.g-Sequencer { + margin-top: var(--spacing-dog); +} + +.g-Sequencer__Buttons { + margin-bottom: var(--spacing-dog); + display: flex; + gap: var(--spacing-dog); +} + +.g-Sequencer__Blocks { + display: flex; + gap: var(--spacing-cat); +} + +.g-Sequencer__Block { + width: var(--spacing-horse); + height: var(--spacing-horse); + background-color: lightgray; + cursor: pointer; +} + +.g-Sequencer__Block:hover { + filter: brightness(110%); +} + +.g-Sequencer__Block--Checked { + background-color: lightgreen; +} + +.g-Sequencer__Block--Beat { + animation: beat 0.2s linear; +} + +@keyframes beat { + 0% {transform: scale(100%);} + 30% {transform: scale(105%);} + 100% {transform: scale(100%);} +} diff --git a/public/sounds/kick.opus b/public/sounds/kick.opus Binary files differnew file mode 100644 index 0000000..40a8d60 --- /dev/null +++ b/public/sounds/kick.opus diff --git a/src/lib/h.ts b/src/lib/h.ts new file mode 100644 index 0000000..8b1abf3 --- /dev/null +++ b/src/lib/h.ts @@ -0,0 +1,34 @@ +type Child = Element | Text | string | number + +export default function h( + tagName: string, + attrs: object, + ...children: Child[] +): Element { + const isSvg = tagName === 'svg' || tagName === 'path' + + let elem = isSvg + ? document.createElementNS('http://www.w3.org/2000/svg', tagName) + : document.createElement(tagName) + + if (isSvg) { + Object.entries(attrs).forEach(([key, value]) => { + elem.setAttribute(key, value) + }) + } else { + elem = Object.assign(elem, attrs) + } + + for (const child of children) { + if (typeof child === 'number') + elem.append(child.toString()) + else + elem.append(child) + } + + return elem +} + +export function classNames(obj: {[key: string]: boolean }): string { + return Object.keys(obj).filter(k => obj[k]).join(' ') +} diff --git a/src/lib/time.ts b/src/lib/time.ts new file mode 100644 index 0000000..d85e935 --- /dev/null +++ b/src/lib/time.ts @@ -0,0 +1,8 @@ +export function debounce<A extends any[]>(f: (...args: A) => void, timeout: number): (...args: A) => void { + let interval: number | undefined = undefined + + return (...args: A) => { + clearTimeout(interval) + interval = setTimeout(() => f.apply(this, args), timeout) + } +} diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..16d4bb5 --- /dev/null +++ b/src/main.ts @@ -0,0 +1,9 @@ +import h from 'lib/h' +import * as sequencer from 'view/sequencer' + +let view = h('main', {}, + h('h1', { className: 'g-Title' }, 'Metronome'), + sequencer.view() +) + +document.body.appendChild(view) diff --git a/src/sounds.ts b/src/sounds.ts new file mode 100644 index 0000000..9ce8d2e --- /dev/null +++ b/src/sounds.ts @@ -0,0 +1,29 @@ +export interface Sounds { + kick: AudioBuffer +} + +const audioContext = new AudioContext() +let lazy: undefined | Sounds = undefined + +export async function load(): Promise<Sounds> { + if (lazy !== undefined) { + return lazy + } else { + + const kick = await fetch('/sounds/kick.opus') + .then(res => res.arrayBuffer()) + .then(ArrayBuffer => audioContext.decodeAudioData(ArrayBuffer)) + + lazy = { + kick + } + return lazy + } +} + +export function playKick(sounds: Sounds) { + const source = audioContext.createBufferSource() + source.buffer = sounds.kick + source.connect(audioContext.destination) + source.start() +} diff --git a/src/view/sequencer.ts b/src/view/sequencer.ts new file mode 100644 index 0000000..150f89b --- /dev/null +++ b/src/view/sequencer.ts @@ -0,0 +1,60 @@ +import h, { classNames } from 'lib/h' +import * as soundsLib from 'sounds' +import * as play from 'view/sequencer/play' +import * as addRemoveBeat from 'view/sequencer/addRemoveBeat' +import * as block from 'view/sequencer/block' + +export function view() { + let index = -1 + let blocks = [true] + let blocksNode = h('div', + { className: 'g-Sequencer__Blocks' }, + block.view({ + checked: true, + onCheck: checked => blocks[0] = checked + }) + ) + + let onNextStep = (sounds: soundsLib.Sounds) => { + let oldIndex = index + let newIndex = (index + 1) % blocks.length + index = newIndex + + let oldBlock = blocksNode.childNodes[oldIndex] as HTMLElement + if (oldBlock !== undefined) oldBlock.classList.remove('g-Sequencer__Block--Beat') + + let newBlock = blocksNode.childNodes[newIndex] as HTMLElement + newBlock.classList.add('g-Sequencer__Block--Beat') + + if (blocks[newIndex]) soundsLib.playKick(sounds) + } + + let sequencer = h('div', { className: 'g-Sequencer' }, + play.view({ + onNextStep, + onStop: () => { + let block = blocksNode.childNodes[index] as HTMLElement + block.classList.remove('g-Sequencer__Block--Beat') + index = -1 + } + }), + addRemoveBeat.view({ + initBeats: 1, + onRemove: index => { + let lastBlock = blocksNode.childNodes[index] + blocksNode.removeChild(lastBlock) + blocks.pop() + }, + onAdd: index => { + blocks.push(false) + blocksNode.appendChild(block.view({ + checked: false, + onCheck: checked => blocks[index] = checked + })) + } + }), + blocksNode + ) + + return sequencer +} diff --git a/src/view/sequencer/addRemoveBeat.ts b/src/view/sequencer/addRemoveBeat.ts new file mode 100644 index 0000000..e991d3f --- /dev/null +++ b/src/view/sequencer/addRemoveBeat.ts @@ -0,0 +1,32 @@ +import h, { classNames } from 'lib/h' + +interface Params { + initBeats: number, + onRemove: (index: number) => void, + onAdd: (index: number) => void +} + +export function view({ initBeats, onRemove, onAdd }: Params) { + let beats = initBeats + + return h('div', { className: 'g-Sequencer__Buttons' }, + h('button', + { onclick: () => { + if (beats > 1) { + beats -= 1 + onRemove(beats) + } + } + }, + 'Remove Beat' + ), + h('button', + { onclick: () => { + onAdd(beats) + beats += 1 + } + }, + 'Add Beat' + ) + ) +} diff --git a/src/view/sequencer/block.ts b/src/view/sequencer/block.ts new file mode 100644 index 0000000..5776120 --- /dev/null +++ b/src/view/sequencer/block.ts @@ -0,0 +1,28 @@ +import h, { classNames } from 'lib/h' + +interface Params { + checked: boolean, + onCheck: (checked: boolean) => void +} + +export function view({ checked, onCheck }: Params) { + return h('div', + { className: classNames({ + 'g-Sequencer__Block': true, + 'g-Sequencer__Block--Checked': checked + }), + onclick: (e: Event) => { + checked = !checked + onCheck(checked) + let target = e.target as HTMLElement + if (target !== undefined) { + if (checked) { + target.classList.add('g-Sequencer__Block--Checked') + } else { + target.classList.remove('g-Sequencer__Block--Checked') + } + } + } + } + ) +} diff --git a/src/view/sequencer/play.ts b/src/view/sequencer/play.ts new file mode 100644 index 0000000..9ff9c81 --- /dev/null +++ b/src/view/sequencer/play.ts @@ -0,0 +1,68 @@ +import h, { classNames } from 'lib/h' +import * as time from 'lib/time' +import * as soundsLib from 'sounds' + +const MIN_BPM: number = 1 +const MAX_BPM: number = 1000 + +interface Params { + onNextStep: (sounds: soundsLib.Sounds) => void, + onStop: () => void +} + +export function view({ onNextStep, onStop }: Params) { + let bpm = 60 + let isPlaying = false + let lastBeat: undefined | number = undefined + + return h('div', {}, + h('button', + { className: 'g-PlayStop', + onclick: async (e: Event) => { + const target = e.target as HTMLButtonElement + isPlaying = !isPlaying + target.innerText = isPlaying ? '■' : '▶' + + let sounds = await soundsLib.load() + let step = (timestamp: number) => { + if (lastBeat === undefined || timestamp - lastBeat > 1000 * 60 / bpm) { + lastBeat = timestamp + onNextStep(sounds) + } + + if (isPlaying) window.requestAnimationFrame(step) + } + + if (isPlaying) { + window.requestAnimationFrame(step) + } else { + onStop() + } + } + }, + '▶' + ) + , h('label', + { className: 'g-Bpm' }, + 'BPM', + h('input', + { className: 'g-Input', + type: 'number', + value: bpm, + min: MIN_BPM, + max: MAX_BPM, + oninput: time.debounce( + (e: Event) => { + const target = e.target as HTMLInputElement + const n = parseInt(target.value) + if (n >= MIN_BPM && n <= MAX_BPM) { + bpm = n + } + }, + 1000 + ) + } + ) + ) + ) +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..380eab3 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "module": "amd", + "target": "es2017", + "baseUrl": "src", + "outFile": "public/main.js", + "noImplicitAny": true, + "strictNullChecks": true, + "removeComments": true, + "preserveConstEnums": true + }, + "include": ["src/**/*"] +} |