From fade87173afbfdd51534646ed49844efa2d0e530 Mon Sep 17 00:00:00 2001 From: Joris Date: Mon, 4 Jul 2022 11:32:27 +0200 Subject: Play random major and/or minor chords --- .gitignore | 1 + Makefile | 9 +++ README.md | 20 ++++++ bin/dev-server | 5 ++ bin/watch | 16 +++++ description | 1 + flake.lock | 42 +++++++++++++ flake.nix | 21 +++++++ public/fonts/chords.otf | Bin 0 -> 42316 bytes public/index.html | 10 +++ public/main.css | 157 ++++++++++++++++++++++++++++++++++++++++++++++++ src/chord.ts | 14 +++++ src/lib/dom.ts | 15 +++++ src/lib/h.ts | 34 +++++++++++ src/main.ts | 4 ++ src/view/form.ts | 55 +++++++++++++++++ src/view/layout.ts | 13 ++++ src/view/options.ts | 24 ++++++++ src/view/play.ts | 48 +++++++++++++++ tsconfig.json | 13 ++++ 20 files changed, 502 insertions(+) create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 README.md create mode 100755 bin/dev-server create mode 100755 bin/watch create mode 100644 description create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 public/fonts/chords.otf create mode 100644 public/index.html create mode 100644 public/main.css create mode 100644 src/chord.ts create mode 100644 src/lib/dom.ts create mode 100644 src/lib/h.ts create mode 100644 src/main.ts create mode 100644 src/view/form.ts create mode 100644 src/view/layout.ts create mode 100644 src/view/options.ts create mode 100644 src/view/play.ts create mode 100644 tsconfig.json 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/Makefile b/Makefile new file mode 100644 index 0000000..593752d --- /dev/null +++ b/Makefile @@ -0,0 +1,9 @@ +build: + @esbuild \ + --bundle src/main.ts \ + --minify \ + --target=es2017 \ + --outdir=public + +clean: + @rm -f public/main.js diff --git a/README.md b/README.md new file mode 100644 index 0000000..1e0851e --- /dev/null +++ b/README.md @@ -0,0 +1,20 @@ +# Getting started + +Having nix installed, run: + +```sh +bin/dev-server +``` + +Then, open your browser at `http://localhost:8000`. + +# Chord font + +http://www.hummtunes.com/Fonts/chordsfont.html + +# TODO + +- Pressing enter on the form page starts playing +- New chords: + - 7th chord + - 7th major chord diff --git a/bin/dev-server b/bin/dev-server new file mode 100755 index 0000000..e1faf14 --- /dev/null +++ b/bin/dev-server @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail +cd `dirname "$0"`/.. + +nix develop --command bin/watch diff --git a/bin/watch b/bin/watch new file mode 100755 index 0000000..82686ae --- /dev/null +++ b/bin/watch @@ -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=es2017 --outdir=public" +watchexec \ + --clear \ + --watch src \ + -- "$CHECK && $BUILD" diff --git a/description b/description new file mode 100644 index 0000000..8154f02 --- /dev/null +++ b/description @@ -0,0 +1 @@ +Chords 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/fonts/chords.otf b/public/fonts/chords.otf new file mode 100644 index 0000000..fe10d6e Binary files /dev/null and b/public/fonts/chords.otf differ diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..0982d8e --- /dev/null +++ b/public/index.html @@ -0,0 +1,10 @@ + + + + +Chords + + + + + diff --git a/public/main.css b/public/main.css new file mode 100644 index 0000000..61d946f --- /dev/null +++ b/public/main.css @@ -0,0 +1,157 @@ +/* Constants */ + +:root { + --color-header: #333333; + --color-button: #333333; + --color-passed-chord: #AAAAAA; + + --spacing-mouse: 0.25rem; + --spacing-cat: 0.5rem; + --spacing-dog: 1rem; + --spacing-horse: 2rem; + --spacing-elephant: 4rem; + --spacing-whale: 12.8rem; + --spacing-godzilla: 25.6rem; + + --font-size-cat: 0.75rem; + --font-size-dog: 1rem; + --font-size-lion: 1.25rem; + --font-size-bear: 1.5rem; + --font-size-cow: 1.75rem; + --font-size-horse: 2rem; + --font-size-elephant: 4rem; + --font-size-whale: 8rem; + + --border-radius-mouse: 0.4rem; + --border-radius-cat: 0.8rem; + + --border-width-ant: 0.1rem; + --border-width-beetle: 0.2rem; + --border-width-mouse: 0.4rem; + + --header-height: var(--spacing-elephant); + + --shift-delay: 0.3s; +} + +/* Reset */ + +ol { + list-style-type: none; + margin: 0; + padding: 0; +} + +input { + margin: 0; + font-size: inherit; + cursor: pointer; +} + +/* Fonts */ + +@font-face { + font-family: chords; + src: url(fonts/chords.otf); +} + +/* Common */ + +body { + font-family: sans-serif; + margin: 0; + font-size: var(--font-size-bear); + overflow: hidden; + position: relative; +} + +header { + display: flex; + align-items: center; + height: var(--header-height); + padding-left: var(--spacing-dog); + background-color: var(--color-header); + color: white; + font-size: var(--font-size-cow); + cursor: pointer; +} + +/* Form */ + +.g-Form { + display: flex; + margin-top: var(--spacing-elephant); + flex-direction: column; + row-gap: var(--spacing-horse); + align-items: center; +} + +.g-Form label { + display: flex; + align-items: baseline; + cursor: pointer; + width: fit-content; +} + +.g-Form input[type="number"] { + width: var(--spacing-whale); +} + +.g-Form input[type="checkbox"], .g-Form input[type="number"] { + margin-right: var(--spacing-dog); +} + +.g-Form input[type="submit"] { + background-color: var(--color-button); + color: white; + border: none; + padding: var(--spacing-cat); +} + +/* Play */ + +.g-Play { + display: flex; + align-items: center; + height: calc(100vh - var(--header-height)); + position: relative; + font-family: chords; + font-size: var(--font-size-whale); + overflow: visible; +} + +.g-Play--Shift { + animation: Shift var(--shift-delay) ease-in-out; +} + +/* Space between each chord is 35% */ +@keyframes Shift { + 0% {transform: translateX(35%);} + 100% {transform: translateX(0);} +} + +.g-Chord { + position: absolute; + margin-top: -10px; /* Move a bit higher for a better rendering */ + transform: translateX(-50%); + transition: color var(--shift-delay) ease-in-out; +} + +.g-Chord:nth-child(1) { left:-20%; } +.g-Chord:nth-child(2) { left:15%; } +.g-Chord:nth-child(3) { left:50%; } +.g-Chord:nth-child(4) { left:85%; } + +.g-Chord:nth-child(1), .g-Chord:nth-child(2) { + color: var(--color-passed-chord); +} + +.g-Chord--Beat { + animation: Beat 0.2s linear var(--shift-delay); +} + +@keyframes Beat { + 0% {transform: translateX(-50%) scale(100%);} + 30% {transform: translateX(-50%) scale(120%);} + 100% {transform: translateX(-50%) scale(100%);} +} diff --git a/src/chord.ts b/src/chord.ts new file mode 100644 index 0000000..b2645e2 --- /dev/null +++ b/src/chord.ts @@ -0,0 +1,14 @@ +export type Options = { + major: boolean, + minor: boolean +} + +export function generate({ major, minor }: Options): string { + let choices: string[] = [] + if (major) choices = choices.concat(all) + if (minor) choices = choices.concat(all.map(chord => `${chord}m`)) + + return choices[Math.floor(Math.random() * choices.length)] +} + +const all: string[] = [ 'C', 'D♭', 'D', 'E♭', 'E', 'F', 'G♭', 'G', 'A♭', 'A', 'B♭', 'B' ] diff --git a/src/lib/dom.ts b/src/lib/dom.ts new file mode 100644 index 0000000..0b6a0ab --- /dev/null +++ b/src/lib/dom.ts @@ -0,0 +1,15 @@ +export function show(elements: Element[]): void { + document.body.innerHTML = '' + elements.forEach(element => document.body.appendChild(element)) +} + +/* Trigger animation in any case. + * + * Trigger reflow between removing and adding the classname. + * See https://css-tricks.com/restart-css-animation/ + */ +export function triggerAnimation(element: HTMLElement, animation: string) { + element.classList.remove(animation) + void element.offsetWidth + element.classList.add(animation) +} 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/main.ts b/src/main.ts new file mode 100644 index 0000000..aecb754 --- /dev/null +++ b/src/main.ts @@ -0,0 +1,4 @@ +import * as form from 'view/form' +import * as dom from 'lib/dom' + +dom.show(form.view()) diff --git a/src/view/form.ts b/src/view/form.ts new file mode 100644 index 0000000..a74a7de --- /dev/null +++ b/src/view/form.ts @@ -0,0 +1,55 @@ +import h, { classNames } from 'lib/h' +import * as dom from 'lib/dom' +import * as play from 'view/play' +import * as layout from 'view/layout' +import * as chord from 'chord' +import * as options from 'view/options' + +// View + +export function view(): Element[] { + let opts = options.load() + + return layout.view( + h('form', + { + className: 'g-Form', + onsubmit + }, + labelInput({ type: 'checkbox', name: 'major', label: 'Major', checked: opts.major }), + labelInput({ type: 'checkbox', name: 'minor', label: 'Minor', checked: opts.minor }), + labelInput({ type: 'number', name: 'bpm', label: 'BPM', value: opts.bpm.toString() }), + labelInput({ type: 'number', name: 'beatsPerChord', label: 'Beats per Chord', value: opts.beatsPerChord.toString() }), + h('input', { type: 'submit', value: 'Play' }) + ) + ) +} + +function onsubmit(event: Event): void { + event.preventDefault() + let input = (name: String) => document.querySelector(`input[name="${name}"]`) as HTMLInputElement + let opts = { + major: input('major').checked, + minor: input('minor').checked, + bpm: parseInt(input('bpm').value), + beatsPerChord: parseInt(input('beatsPerChord').value) + } + options.save(opts) + dom.show(play.view(opts)) +} + +type LabelInputParams = { + type: string, + name: string, + label: string, + checked?: boolean, + value?: string +} + +function labelInput({ type, name, label, checked, value }: LabelInputParams) { + return h('label', + {}, + h('input', { type, name, checked, value }), + label + ) +} diff --git a/src/view/layout.ts b/src/view/layout.ts new file mode 100644 index 0000000..ac62290 --- /dev/null +++ b/src/view/layout.ts @@ -0,0 +1,13 @@ +import h from 'lib/h' +import * as dom from 'lib/dom' +import * as form from 'view/form' + +export function view(main: Element): Element[] { + return [ + h('header', + { onclick: () => dom.show(form.view()) }, + 'Chords' + ), + main + ] +} diff --git a/src/view/options.ts b/src/view/options.ts new file mode 100644 index 0000000..4c71be2 --- /dev/null +++ b/src/view/options.ts @@ -0,0 +1,24 @@ +export type Options = { + major: boolean, + minor: boolean, + bpm: number, + beatsPerChord: number +} + +let defaultOptions: Options = { + major: true, + minor: false, + bpm: 90, + beatsPerChord: 4 +} + +let key: string = 'options' + +export function load(): Options { + let str = localStorage[key] + return str && JSON.parse(str) || defaultOptions +} + +export function save(options: Options): void { + localStorage[key] = JSON.stringify(options) +} diff --git a/src/view/play.ts b/src/view/play.ts new file mode 100644 index 0000000..26558cd --- /dev/null +++ b/src/view/play.ts @@ -0,0 +1,48 @@ +import h, { classNames } from 'lib/h' +import * as dom from 'lib/dom' +import { Options } from 'view/options' +import * as chord from 'chord' +import * as layout from 'view/layout' + +export function view(options: Options): Element[] { + let chords = h('div', + { className: 'g-Play' }, + chordNode(), + chordNode(), + chordNode(options), + chordNode(options) + ) + + let chordBeat = 1 + + dom.triggerAnimation(chords as HTMLElement, 'g-Play--Shift') + dom.triggerAnimation(chords.children[2] as HTMLElement, 'g-Chord--Beat') + setInterval(() => { + if (chordBeat == options.beatsPerChord) { + shiftChords(chords, options) + chords.children[0].classList.remove('g-Chord--Beat') + dom.triggerAnimation(chords as HTMLElement, 'g-Play--Shift') + dom.triggerAnimation(chords.children[2] as HTMLElement, 'g-Chord--Beat') + chordBeat = 1 + } else { + dom.triggerAnimation(chords.children[2] as HTMLElement, 'g-Chord--Beat') + chordBeat += 1 + } + }, 60 / options.bpm * 1000) + + return layout.view(chords) +} + +/* Shift chords and generate a new random one. + */ +function shiftChords(chords: Element, options: Options) { + chords.removeChild(chords.children[0]) + chords.appendChild(chordNode(options)) +} + +function chordNode(options?: Options): Element { + return h('div', + { className: 'g-Chord' }, + options ? chord.generate(options) : '' + ) +} 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/**/*"] +} -- cgit v1.2.3