Skip to content

Commit

Permalink
feat: add note generator visualizer
Browse files Browse the repository at this point in the history
  • Loading branch information
ascpixi committed Dec 23, 2024
1 parent f2a1480 commit 3525f0b
Show file tree
Hide file tree
Showing 5 changed files with 194 additions and 5 deletions.
13 changes: 13 additions & 0 deletions src/audioUtil.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,19 @@ export const MIDI_NOTES = {
/** B */ B : 11
} as const;

/** The an array of values of the `MIDI_NOTES` array. */
export const MIDI_NOTE_VALUES: number[] = Object.values(MIDI_NOTES);

/** MIDI note numbers for all sharp (C#, D#, F#...) notes in the chromatic scale. */
export const SHARP_MIDI_NOTES: number[] = [
MIDI_NOTES.Cs, MIDI_NOTES.Ds, MIDI_NOTES.Fs, MIDI_NOTES.Gs, MIDI_NOTES.As
];

/** MIDI note numbers for all natural (C, D, E...) notes in the chromatic scale. */
export const NATURAL_MIDI_NOTES: number[] = [
MIDI_NOTES.C, MIDI_NOTES.D, MIDI_NOTES.E, MIDI_NOTES.F, MIDI_NOTES.G, MIDI_NOTES.A, MIDI_NOTES.B
]

/** Represents all notes in the chromatic scale. */
export const CHROMATIC_SCALE = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"] as const;

Expand Down
56 changes: 56 additions & 0 deletions src/components/MusicalKeyboard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { NATURAL_MIDI_NOTES, SHARP_MIDI_NOTES } from "../audioUtil";
import { range } from "../util"

function isSharp(note: number) {
return SHARP_MIDI_NOTES.includes(note);
}

export function MusicalKeyboard({ octaves, notes }: {
/** Amount of octaves to include in the keyboard. */
octaves: number,

/** An array of MIDI notes to highlight. All values above `octaves * 12` are ignored. */
notes: number[]
}) {
if (notes.some(x => x >= octaves * 12)) {
console.warn(`Some notes are >= octaves * 12 (${octaves * 12})!`, notes.filter(x => x >= octaves * 12));
}

return (
<div className="flex rounded-md overflow-hidden border border-gray-300">
{
range(octaves)
.map(x => notes
.map(y => y - (x * 12))
.filter(y => y >= 0 && y < 12)
)
.map((o, idx) =>
<div key={idx} className="grid grid-cols-1 w-full border-r border-gray-300 last:border-r-0">
<div className="w-full h-16 flex row-start-1 col-start-1">
{
NATURAL_MIDI_NOTES.map(note => (
<div key={note} className={`
h-full w-full border-r border-gray-300 last:border-r-0
${o.includes(note) ? "bg-primary" : "bg-white"}
`}/>
))
}
</div>

<div className="w-full h-1/2 justify-around flex row-start-1 col-start-1 ml-[7%]">
{
NATURAL_MIDI_NOTES.map(note => (
<div key={note} className={
`h-full w-1/12 rounded-b-sm
${o.includes(note + 1) ? "bg-primary" : "bg-black"}
${isSharp(note + 1) ? "" : "invisible"}
`}/>
))
}
</div>
</div>
)
}
</div>
)
}
28 changes: 26 additions & 2 deletions src/nodes/PentatonicChordsNode.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,21 @@ import type { NodeTypeDescriptor } from ".";
import { makeNodeFactory } from "./basis";
import { NOTE_OUTPUT_HID, NoteGeneratorNodeData, PlainNoteGenerator } from "../graph";
import { NodeDataSerializer } from "../serializer";
import { pickRandom, randInt, seedRng } from "../util";
import { allEqualUnordered, pickRandom, randInt, seedRng } from "../util";
import { getHarmony, MAJOR_PENTATONIC, MIDI_NOTES, MINOR_PENTATONIC, ScaleMode } from "../audioUtil";

import { NodePort } from "../components/NodePort";
import { PlainField } from "../components/PlainField";
import { SliderField } from "../components/SliderField";
import { SelectField } from "../components/SelectField";
import { VestigeNodeBase } from "../components/VestigeNodeBase";
import { MusicalKeyboard } from "../components/MusicalKeyboard";

export class PentatonicChordsGenerator implements PlainNoteGenerator {
inputs = 0 as const;
offset: number;
seedOffset: number;
lastNotes: number[] = [];

constructor(
public chordLength: number = 6,
Expand Down Expand Up @@ -47,7 +49,7 @@ export class PentatonicChordsGenerator implements PlainNoteGenerator {
const rng = seedRng(seed + this.seedOffset);

// Form the chord from N random notes, between minNotes and maxNotes.
return pickRandom(
return this.lastNotes = pickRandom(
available,
randInt(this.minNotes, this.maxNotes, rng),
rng
Expand Down Expand Up @@ -124,6 +126,23 @@ export const PentatonicChordsNodeRenderer = memo(function PentatonicChordsNodeRe
const [octave, setOctave] = useState(data.generator.octave);
const [pitchRange, setPitchRange] = useState(data.generator.pitchRange);

const [notes, setNotes] = useState<number[]>([]);

const semitonePitchRange = (mode == "MAJOR" ?
MAJOR_PENTATONIC[pitchRange % 5] :
MINOR_PENTATONIC[pitchRange % 5]
) + (12 * Math.floor(pitchRange / 5));

useEffect(() => {
const id = setInterval(() => {
if (!allEqualUnordered(notes, data.generator.lastNotes)) {
setNotes(data.generator.lastNotes);
}
}, 100);

return () => clearInterval(id);
}, [data, notes]);

useEffect(() => {
const gen = data.generator;

Expand Down Expand Up @@ -244,6 +263,11 @@ export const PentatonicChordsNodeRenderer = memo(function PentatonicChordsNodeRe
min={1} max={6} value={octave}
onChange={setOctave}
/>

<MusicalKeyboard
octaves={Math.ceil((semitonePitchRange + rootNote) / 12)}
notes={notes.map(x => x - (octave * 12))}
/>
</div>
</div>
</VestigeNodeBase>
Expand Down
30 changes: 27 additions & 3 deletions src/nodes/PentatonicMelodyNode.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { RiMusicFill } from "@remixicon/react";
import type { NodeTypeDescriptor } from ".";
import { makeNodeFactory } from "./basis";
import { NOTE_OUTPUT_HID, NoteGeneratorNodeData, PlainNoteGenerator } from "../graph";
import { hashify } from "../util";
import { allEqualUnordered, hashify } from "../util";
import { getHarmony, MAJOR_PENTATONIC, MIDI_NOTES, MINOR_PENTATONIC, ScaleMode } from "../audioUtil";
import { NodeDataSerializer } from "../serializer";

Expand All @@ -14,10 +14,12 @@ import { PlainField } from "../components/PlainField";
import { SliderField } from "../components/SliderField";
import { SelectField } from "../components/SelectField";
import { VestigeNodeBase } from "../components/VestigeNodeBase";
import { MusicalKeyboard } from "../components/MusicalKeyboard";

export class PentatonicMelodyGenerator implements PlainNoteGenerator {
inputs = 0 as const;
offset: number;
lastNotes: number[] = [];

constructor(
public density: number = 50,
Expand Down Expand Up @@ -63,7 +65,7 @@ export class PentatonicMelodyGenerator implements PlainNoteGenerator {
break;
}

return played;
return this.lastNotes = played;
}
}

Expand Down Expand Up @@ -133,6 +135,13 @@ export const PentatonicMelodyNodeRenderer = memo(function PentatonicMelodyNodeRe
const [pitchRange, setPitchRange] = useState(data.generator.pitchRange);
const [polyphony, setPolyphony] = useState(data.generator.polyphony);

const [notes, setNotes] = useState<number[]>([]);

const semitonePitchRange = (mode == "MAJOR" ?
MAJOR_PENTATONIC[pitchRange % 5] :
MINOR_PENTATONIC[pitchRange % 5]
) + (12 * Math.floor(pitchRange / 5));

useEffect(() => {
const gen = data.generator;

Expand All @@ -142,7 +151,17 @@ export const PentatonicMelodyNodeRenderer = memo(function PentatonicMelodyNodeRe
gen.octave = octave;
gen.pitchRange = pitchRange;
gen.polyphony = polyphony;
}, [data.generator, rootNote, mode, density, octave, pitchRange, polyphony])
}, [data.generator, rootNote, mode, density, octave, pitchRange, polyphony]);

useEffect(() => {
const id = setInterval(() => {
if (!allEqualUnordered(notes, data.generator.lastNotes)) {
setNotes(data.generator.lastNotes);
}
}, 100);

return () => clearInterval(id);
}, [data, notes]);

return (
<VestigeNodeBase
Expand Down Expand Up @@ -218,6 +237,11 @@ export const PentatonicMelodyNodeRenderer = memo(function PentatonicMelodyNodeRe
min={1} max={4} value={polyphony}
onChange={setPolyphony}
/>

<MusicalKeyboard
octaves={Math.ceil((semitonePitchRange + rootNote) / 12)}
notes={notes.map(x => x - (octave * 12))}
/>
</div>
</div>
</VestigeNodeBase>
Expand Down
72 changes: 72 additions & 0 deletions src/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,4 +234,76 @@ export class Deferred<T> {
assert(this.promise === null, "attempted to get a deferred value before it's finished");
return this.value!;
}
}

/**
* Returns an array with all integers in the range of `[min, max]`, inclusive.
* For example, for `range(0, 4)`, this function returns `[0, 1, 2, 3, 4]`.
*/
export function range(min: number, max: number): number[];

/**
* Returns an array with all integers in the range of `[min, max]`, incrementing
* by `step`. For example, for `range(0, 8, 2)`, this function returns
* `[0, 2, 4, 6, 8]`.
*/
export function range(min: number, max: number, step: number): number[];

/**
* Returns an array, starting from 0, with the given amount of elements.
* For example, for `range(4)`, this function returns `[0, 1, 2, 3]`.
*/
export function range(length: number): number[];

export function range(min: number, max?: number, step?: number): number[] {
if (max === undefined) {
max = min - 1;
min = 0;
}

step ??= 1;

const seq = [];
for (let i = min; i < max + 1; i += step) {
seq.push(i);
}

return seq;
}

/**
* Checks if all numbers from array `a` are equal to the numbers in array `b`,
* with respect to order.
*/
export function allEqual(a: number[], b: number[]) {
if (a.length != b.length)
return false;

for (let i = 0; i < a.length; i++) {
if (a[i] != b[i]) {
return false;
}
}

return true;
}

/**
* Checks if all numbers from array `a` are equal to the numbers in array `b`,
* regardless of order.
*/
export function allEqualUnordered(a: number[], b: number[]) {
if (a.length !== b.length)
return false;

const sortedA = [...a].sort((x, y) => x - y);
const sortedB = [...b].sort((x, y) => x - y);

for (let i = 0; i < sortedA.length; i++) {
if (sortedA[i] !== sortedB[i]) {
return false;
}
}

return true;
}

0 comments on commit 3525f0b

Please sign in to comment.