From a12f53d51e436a040a2e7cfbf59bc86a3a4b4121 Mon Sep 17 00:00:00 2001 From: Miorel-Lucian Palii Date: Sat, 9 Nov 2024 12:02:00 -0800 Subject: [PATCH] Add a few more utilities for working with sound frequencies --- workspaces/simon-game/src/app/constants.ts | 11 +++-- .../src/__tests__/getConcertPitch-test.ts | 42 ++++++++++++++++++- workspaces/util/src/arpeggiate.ts | 21 ++++++++++ workspaces/util/src/assertIsIntegerString.ts | 10 +++++ workspaces/util/src/first.ts | 21 ++++++++-- workspaces/util/src/getConcertPitch.ts | 11 ++--- workspaces/util/src/last.ts | 20 +++++++++ workspaces/util/src/lastOrThrow.ts | 6 +++ workspaces/util/src/shiftOctaves.ts | 3 ++ workspaces/util/src/shiftSemitones.ts | 5 +++ 10 files changed, 137 insertions(+), 13 deletions(-) create mode 100644 workspaces/util/src/arpeggiate.ts create mode 100644 workspaces/util/src/assertIsIntegerString.ts create mode 100644 workspaces/util/src/last.ts create mode 100644 workspaces/util/src/lastOrThrow.ts create mode 100644 workspaces/util/src/shiftOctaves.ts create mode 100644 workspaces/util/src/shiftSemitones.ts diff --git a/workspaces/simon-game/src/app/constants.ts b/workspaces/simon-game/src/app/constants.ts index 8208badf..f2716ec7 100644 --- a/workspaces/simon-game/src/app/constants.ts +++ b/workspaces/simon-game/src/app/constants.ts @@ -1,24 +1,27 @@ +import { arpeggiate } from "@code-chronicles/util/arpeggiate"; import { getConcertPitch } from "@code-chronicles/util/getConcertPitch"; +const [note1, note2, note3, note4] = arpeggiate(getConcertPitch("C4"), 1); + export const config = { soundDurationMs: 300, volumePct: 0.1, boxes: [ { color: "red", - frequency: getConcertPitch("C4"), + frequency: note1, }, { color: "#0050B5", // cobalt blue - frequency: getConcertPitch("E4"), + frequency: note2, }, { color: "green", - frequency: getConcertPitch("G4"), + frequency: note3, }, { color: "yellow", - frequency: getConcertPitch("C5"), + frequency: note4, }, ], }; diff --git a/workspaces/util/src/__tests__/getConcertPitch-test.ts b/workspaces/util/src/__tests__/getConcertPitch-test.ts index 0017a562..32ab60a1 100644 --- a/workspaces/util/src/__tests__/getConcertPitch-test.ts +++ b/workspaces/util/src/__tests__/getConcertPitch-test.ts @@ -55,5 +55,45 @@ describe("getConcertPitch", () => { // TODO: test that note can be uppercase or lowercase - // TODO: test some invalid inputs + it.each([ + // Invalid note: + "H4", + "I4", + "Do4", + + // Invalid accidental: + "A%4", + "BB4", + "C++11", + + // Invalid octave: + "Aa", + + // Out-of-order note: + "4A", + "C4#", + + // Extra characters in the octave: + "A04", + "A+4", + "A4.", + "A4.0", + + // Missing note: + "4", + "#4", + + // Missing octave: + "A", + "Ab", + "C#", + + // Other fun stuff: + "", + "440", + "Doe, a deer", + "Hello, World!", + ])("throws on invalid input %p", (s) => { + expect(() => getConcertPitch(s)).toThrow(); + }); }); diff --git a/workspaces/util/src/arpeggiate.ts b/workspaces/util/src/arpeggiate.ts new file mode 100644 index 00000000..5ba4c5bb --- /dev/null +++ b/workspaces/util/src/arpeggiate.ts @@ -0,0 +1,21 @@ +import { lastOrThrow } from "@code-chronicles/util/lastOrThrow"; +import type { NonEmptyArray } from "@code-chronicles/util/NonEmptyArray"; +import { shiftSemitones } from "@code-chronicles/util/shiftSemitones"; +import { shiftOctaves } from "@code-chronicles/util/shiftOctaves"; + +export function arpeggiate( + freq: number, + octaves: number, +): NonEmptyArray { + const res: NonEmptyArray = [freq]; + for (let i = 0; i < octaves; ++i) { + const last = lastOrThrow(res); + res.push( + shiftSemitones(last, 4), + shiftSemitones(last, 7), + shiftOctaves(last, 1), + ); + } + + return res; +} diff --git a/workspaces/util/src/assertIsIntegerString.ts b/workspaces/util/src/assertIsIntegerString.ts new file mode 100644 index 00000000..c83831a8 --- /dev/null +++ b/workspaces/util/src/assertIsIntegerString.ts @@ -0,0 +1,10 @@ +import invariant from "invariant"; + +export function assertIsIntegerString(s: string): number { + const n = parseInt(s, 10); + invariant( + !Number.isNaN(n) && String(n) === s, + "Parsed integer doesn't restringify to the same input.", + ); + return n; +} diff --git a/workspaces/util/src/first.ts b/workspaces/util/src/first.ts index 009ce8a3..359e50bb 100644 --- a/workspaces/util/src/first.ts +++ b/workspaces/util/src/first.ts @@ -1,5 +1,20 @@ -export function first(array: readonly [T, ...T[]]): T; +import type { NonEmptyArray } from "@code-chronicles/util/NonEmptyArray"; + +export function first(array: Readonly>): T; + export function first(array: readonly T[]): T | undefined; -export function first(array: readonly T[]): T | undefined { - return array[0]; + +export function first(iterable: Iterable): T | undefined; + +export function first(iterable: Iterable): T | undefined { + if (Array.isArray(iterable)) { + return iterable[0]; + } + + // eslint-disable-next-line no-unreachable-loop -- Intentional single iteration. + for (const element of iterable) { + return element; + } + + return undefined; } diff --git a/workspaces/util/src/getConcertPitch.ts b/workspaces/util/src/getConcertPitch.ts index 92af6f0c..44f64989 100644 --- a/workspaces/util/src/getConcertPitch.ts +++ b/workspaces/util/src/getConcertPitch.ts @@ -1,6 +1,9 @@ -import invariant from "invariant"; import nullthrows from "nullthrows"; +import { assertIsIntegerString } from "@code-chronicles/util/assertIsIntegerString"; +import { shiftOctaves } from "@code-chronicles/util/shiftOctaves"; +import { shiftSemitones } from "@code-chronicles/util/shiftSemitones"; + const NOTES = { c: -9, d: -7, @@ -27,9 +30,7 @@ export function getConcertPitch(note: string, a4Pitch: number = 440): number { semitones += ACCIDENTALS[note[index++] as keyof typeof ACCIDENTALS]; } - const octaveString = note.slice(index); - invariant(/^-?\d+/.test(octaveString), "Invalid octave!"); - const octaves = parseInt(octaveString, 10) - 4; + const octaves = assertIsIntegerString(note.slice(index)) - 4; - return a4Pitch * Math.pow(2, octaves + semitones / 12); + return shiftSemitones(shiftOctaves(a4Pitch, octaves), semitones); } diff --git a/workspaces/util/src/last.ts b/workspaces/util/src/last.ts new file mode 100644 index 00000000..b14b55b3 --- /dev/null +++ b/workspaces/util/src/last.ts @@ -0,0 +1,20 @@ +import type { NonEmptyArray } from "@code-chronicles/util/NonEmptyArray"; + +export function last(array: Readonly>): T; + +export function last(array: readonly T[]): T | undefined; + +export function last(iterable: Iterable): T | undefined; + +export function last(iterable: Iterable): T | undefined { + if (Array.isArray(iterable)) { + return iterable.at(-1); + } + + let res = undefined; + for (const element of iterable) { + res = element; + } + + return res; +} diff --git a/workspaces/util/src/lastOrThrow.ts b/workspaces/util/src/lastOrThrow.ts new file mode 100644 index 00000000..c0f2987f --- /dev/null +++ b/workspaces/util/src/lastOrThrow.ts @@ -0,0 +1,6 @@ +import invariant from "invariant"; + +export function lastOrThrow(array: readonly T[]): T { + invariant(array.length > 0, "Expected a non-empty array!"); + return array.at(-1) as T; +} diff --git a/workspaces/util/src/shiftOctaves.ts b/workspaces/util/src/shiftOctaves.ts new file mode 100644 index 00000000..5bcba7ae --- /dev/null +++ b/workspaces/util/src/shiftOctaves.ts @@ -0,0 +1,3 @@ +export function shiftOctaves(freq: number, octaves: number): number { + return freq * 2 ** octaves; +} diff --git a/workspaces/util/src/shiftSemitones.ts b/workspaces/util/src/shiftSemitones.ts new file mode 100644 index 00000000..48df9eee --- /dev/null +++ b/workspaces/util/src/shiftSemitones.ts @@ -0,0 +1,5 @@ +import { shiftOctaves } from "@code-chronicles/util/shiftOctaves"; + +export function shiftSemitones(freq: number, semitones: number): number { + return shiftOctaves(freq, semitones / 12); +}