diff options
author | Joris | 2022-06-11 16:42:33 +0200 |
---|---|---|
committer | Joris | 2022-06-11 16:42:33 +0200 |
commit | 03197b1ab992540b951fcbc6f841cfcd42a757f3 (patch) | |
tree | 2eb5277462b8dfef41e901a945f251725fb7ad8f /src | |
parent | 70c672535f36edaeaf1d63d4637830b564271c34 (diff) |
Add kick sequencer
Diffstat (limited to 'src')
-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 |
8 files changed, 268 insertions, 0 deletions
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 + ) + } + ) + ) + ) +} |