Skip to content

Commit 3334d8e

Browse files
authored
Add a few more utilities for working with sound frequencies (#495)
1 parent 64ce46a commit 3334d8e

File tree

10 files changed

+137
-13
lines changed

10 files changed

+137
-13
lines changed
Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,27 @@
1+
import { arpeggiate } from "@code-chronicles/util/arpeggiate";
12
import { getConcertPitch } from "@code-chronicles/util/getConcertPitch";
23

4+
const [note1, note2, note3, note4] = arpeggiate(getConcertPitch("C4"), 1);
5+
36
export const config = {
47
soundDurationMs: 300,
58
volumePct: 0.1,
69
boxes: [
710
{
811
color: "red",
9-
frequency: getConcertPitch("C4"),
12+
frequency: note1,
1013
},
1114
{
1215
color: "#0050B5", // cobalt blue
13-
frequency: getConcertPitch("E4"),
16+
frequency: note2,
1417
},
1518
{
1619
color: "green",
17-
frequency: getConcertPitch("G4"),
20+
frequency: note3,
1821
},
1922
{
2023
color: "yellow",
21-
frequency: getConcertPitch("C5"),
24+
frequency: note4,
2225
},
2326
],
2427
};

workspaces/util/src/__tests__/getConcertPitch-test.ts

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,5 +55,45 @@ describe("getConcertPitch", () => {
5555

5656
// TODO: test that note can be uppercase or lowercase
5757

58-
// TODO: test some invalid inputs
58+
it.each([
59+
// Invalid note:
60+
"H4",
61+
"I4",
62+
"Do4",
63+
64+
// Invalid accidental:
65+
"A%4",
66+
"BB4",
67+
"C++11",
68+
69+
// Invalid octave:
70+
"Aa",
71+
72+
// Out-of-order note:
73+
"4A",
74+
"C4#",
75+
76+
// Extra characters in the octave:
77+
"A04",
78+
"A+4",
79+
"A4.",
80+
"A4.0",
81+
82+
// Missing note:
83+
"4",
84+
"#4",
85+
86+
// Missing octave:
87+
"A",
88+
"Ab",
89+
"C#",
90+
91+
// Other fun stuff:
92+
"",
93+
"440",
94+
"Doe, a deer",
95+
"Hello, World!",
96+
])("throws on invalid input %p", (s) => {
97+
expect(() => getConcertPitch(s)).toThrow();
98+
});
5999
});

workspaces/util/src/arpeggiate.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { lastOrThrow } from "@code-chronicles/util/lastOrThrow";
2+
import type { NonEmptyArray } from "@code-chronicles/util/NonEmptyArray";
3+
import { shiftSemitones } from "@code-chronicles/util/shiftSemitones";
4+
import { shiftOctaves } from "@code-chronicles/util/shiftOctaves";
5+
6+
export function arpeggiate(
7+
freq: number,
8+
octaves: number,
9+
): NonEmptyArray<number> {
10+
const res: NonEmptyArray<number> = [freq];
11+
for (let i = 0; i < octaves; ++i) {
12+
const last = lastOrThrow(res);
13+
res.push(
14+
shiftSemitones(last, 4),
15+
shiftSemitones(last, 7),
16+
shiftOctaves(last, 1),
17+
);
18+
}
19+
20+
return res;
21+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import invariant from "invariant";
2+
3+
export function assertIsIntegerString(s: string): number {
4+
const n = parseInt(s, 10);
5+
invariant(
6+
!Number.isNaN(n) && String(n) === s,
7+
"Parsed integer doesn't restringify to the same input.",
8+
);
9+
return n;
10+
}

workspaces/util/src/first.ts

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,20 @@
1-
export function first<T>(array: readonly [T, ...T[]]): T;
1+
import type { NonEmptyArray } from "@code-chronicles/util/NonEmptyArray";
2+
3+
export function first<T>(array: Readonly<NonEmptyArray<T>>): T;
4+
25
export function first<T>(array: readonly T[]): T | undefined;
3-
export function first<T>(array: readonly T[]): T | undefined {
4-
return array[0];
6+
7+
export function first<T>(iterable: Iterable<T>): T | undefined;
8+
9+
export function first<T>(iterable: Iterable<T>): T | undefined {
10+
if (Array.isArray(iterable)) {
11+
return iterable[0];
12+
}
13+
14+
// eslint-disable-next-line no-unreachable-loop -- Intentional single iteration.
15+
for (const element of iterable) {
16+
return element;
17+
}
18+
19+
return undefined;
520
}

workspaces/util/src/getConcertPitch.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1-
import invariant from "invariant";
21
import nullthrows from "nullthrows";
32

3+
import { assertIsIntegerString } from "@code-chronicles/util/assertIsIntegerString";
4+
import { shiftOctaves } from "@code-chronicles/util/shiftOctaves";
5+
import { shiftSemitones } from "@code-chronicles/util/shiftSemitones";
6+
47
const NOTES = {
58
c: -9,
69
d: -7,
@@ -27,9 +30,7 @@ export function getConcertPitch(note: string, a4Pitch: number = 440): number {
2730
semitones += ACCIDENTALS[note[index++] as keyof typeof ACCIDENTALS];
2831
}
2932

30-
const octaveString = note.slice(index);
31-
invariant(/^-?\d+/.test(octaveString), "Invalid octave!");
32-
const octaves = parseInt(octaveString, 10) - 4;
33+
const octaves = assertIsIntegerString(note.slice(index)) - 4;
3334

34-
return a4Pitch * Math.pow(2, octaves + semitones / 12);
35+
return shiftSemitones(shiftOctaves(a4Pitch, octaves), semitones);
3536
}

workspaces/util/src/last.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import type { NonEmptyArray } from "@code-chronicles/util/NonEmptyArray";
2+
3+
export function last<T>(array: Readonly<NonEmptyArray<T>>): T;
4+
5+
export function last<T>(array: readonly T[]): T | undefined;
6+
7+
export function last<T>(iterable: Iterable<T>): T | undefined;
8+
9+
export function last<T>(iterable: Iterable<T>): T | undefined {
10+
if (Array.isArray(iterable)) {
11+
return iterable.at(-1);
12+
}
13+
14+
let res = undefined;
15+
for (const element of iterable) {
16+
res = element;
17+
}
18+
19+
return res;
20+
}

workspaces/util/src/lastOrThrow.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import invariant from "invariant";
2+
3+
export function lastOrThrow<T>(array: readonly T[]): T {
4+
invariant(array.length > 0, "Expected a non-empty array!");
5+
return array.at(-1) as T;
6+
}

workspaces/util/src/shiftOctaves.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export function shiftOctaves(freq: number, octaves: number): number {
2+
return freq * 2 ** octaves;
3+
}

workspaces/util/src/shiftSemitones.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { shiftOctaves } from "@code-chronicles/util/shiftOctaves";
2+
3+
export function shiftSemitones(freq: number, semitones: number): number {
4+
return shiftOctaves(freq, semitones / 12);
5+
}

0 commit comments

Comments
 (0)