aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore1
-rw-r--r--README.md14
-rwxr-xr-xbin/watch.sh16
-rw-r--r--flake.lock42
-rw-r--r--flake.nix21
-rw-r--r--public/index.html10
-rw-r--r--public/main.css67
-rw-r--r--public/sounds/kick.opusbin0 -> 1743 bytes
-rw-r--r--src/lib/h.ts34
-rw-r--r--src/lib/time.ts8
-rw-r--r--src/main.ts9
-rw-r--r--src/sounds.ts29
-rw-r--r--src/view/sequencer.ts60
-rw-r--r--src/view/sequencer/addRemoveBeat.ts32
-rw-r--r--src/view/sequencer/block.ts28
-rw-r--r--src/view/sequencer/play.ts68
-rw-r--r--tsconfig.json13
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
new file mode 100644
index 0000000..40a8d60
--- /dev/null
+++ b/public/sounds/kick.opus
Binary files differ
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/**/*"]
+}