aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJoris2022-07-04 11:32:27 +0200
committerJoris2022-07-04 19:36:37 +0200
commitfade87173afbfdd51534646ed49844efa2d0e530 (patch)
tree54e8d5d81233fa5f3d1ba60fd8c3085252ebccc4
parentce7722c901776ae8f6a64882e902e8ba851411e0 (diff)
Play random major and/or minor chords
-rw-r--r--.gitignore1
-rw-r--r--Makefile9
-rw-r--r--README.md20
-rwxr-xr-xbin/dev-server5
-rwxr-xr-xbin/watch16
-rw-r--r--description1
-rw-r--r--flake.lock42
-rw-r--r--flake.nix21
-rw-r--r--public/fonts/chords.otfbin0 -> 42316 bytes
-rw-r--r--public/index.html10
-rw-r--r--public/main.css157
-rw-r--r--src/chord.ts14
-rw-r--r--src/lib/dom.ts15
-rw-r--r--src/lib/h.ts34
-rw-r--r--src/main.ts4
-rw-r--r--src/view/form.ts55
-rw-r--r--src/view/layout.ts13
-rw-r--r--src/view/options.ts24
-rw-r--r--src/view/play.ts48
-rw-r--r--tsconfig.json13
20 files changed, 502 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/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
--- /dev/null
+++ b/public/fonts/chords.otf
Binary files 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 @@
+<!doctype html>
+<html lang="fr">
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width">
+<title>Chords</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..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/**/*"]
+}