zifferjs
Version:
Zifferjs - Typescript variant of Ziffers
609 lines (519 loc) • 19.7 kB
text/typescript
import { MODIFIERS, NOTES_TO_INTERVALS, getScale, getScaleLength } from "./defaults";
import { isScale, CHORDS, CIRCLE_OF_FIFTHS, INTERVALS_TO_NOTES, ROMANS } from "./defaults";
import { parse as parseScala } from "./parser/scalaParser";
import { Pitch } from "./types";
import { safeMod } from "./utils";
// TODO: Change parameter to this type
export type PitchType = {
pitch?: number,
originalPitch?: number,
octave?: number,
addedOctave?: number,
pitchOctave?: number,
modifier?: number,
key?: number,
scaleName?: string,
parsedScale?: number[],
degrees?: boolean
}
export const resolvePitchClass = (
root: number | string,
pitchClass: number,
scale: string | Array<number>,
octave: number = 0,
modifier: number = 0,
degrees: boolean = false
): {note: number, pitch: number, octave: number, pitchOctave: number, pitchBend?: number, modifier: number, root: number, parsedScale: number[]} => {
// Initialization
pitchClass = degrees && pitchClass > 0 ? pitchClass - 1 : pitchClass;
root = typeof root === 'string' ? noteNameToMidi(root) : root;
const intervals = typeof scale === 'string' ? safeScale(scale) : scale;
const scale_length = intervals.length;
let pitchOctave = 0;
// Resolve pitch classes to the scale and calculate octave
if (pitchClass >= scale_length || pitchClass < 0) {
pitchOctave += Math.floor(pitchClass / scale_length);
pitchClass = pitchClass < 0 ? scale_length - (Math.abs(pitchClass) % scale_length) : pitchClass % scale_length;
if (pitchClass === scale_length) {
pitchClass = 0;
}
}
// Computing the result
let note = root + intervals.slice(0, pitchClass).reduce((a, b) => a + b, 0);
note = note + (octave + pitchOctave) * intervals.reduce((a, b) => a + b, 0) + modifier;
if (Number.isInteger(note)) {
return {note, pitch: pitchClass, octave, pitchOctave: pitchOctave, modifier, root, parsedScale: intervals}
} else {
const bend = resolvePitchBend(note);
return {note, pitch: pitchClass, octave, pitchOctave: pitchOctave, pitchBend: bend[1], modifier, root, parsedScale: intervals}
}
}
export const noteFromPc = (
root: number | string,
pitch_class: number,
scale: string | Array<number>,
octave: number = 0,
modifier: number = 0,
degrees: boolean = false
): [number, number | undefined] => {
const result = resolvePitchClass(root, pitch_class, scale, octave, modifier, degrees);
return [result.note, result.pitchBend];
}
export const noteNameToMidi = (name: string, defaultOctave: number = 4): number => {
const items = name.match(/^([a-gA-G])([#bs])?([1-9])?$/);
if (items === null) {
return 60; // Default MIDI note C4 if the input is invalid
}
const [, noteName, modifierSymbol, octaveStr] = items;
const octave = octaveStr ? parseInt(octaveStr, 10) : defaultOctave;
const modifier = MODIFIERS[modifierSymbol] || 0;
const interval = NOTES_TO_INTERVALS[noteName.toUpperCase()];
return 12 + octave * 12 + interval + modifier;
}
export const resolvePitchBend = (note_value: number, semitones: number = 1): [number, number] => {
let midi_bend_value = 8192;
if (note_value % 1 !== 0) {
const start_value = note_value > Math.round(note_value) ? note_value : Math.round(note_value);
const end_value = note_value > Math.round(note_value) ? Math.round(note_value) : note_value;
const bend_diff = midiToFreq(start_value) / midiToFreq(end_value);
const bend_target = 1200 * Math.log2(bend_diff);
// https://www.cs.cmu.edu/~rbd/doc/cmt/part7.html
midi_bend_value = midi_bend_value + Math.floor(8191 * (bend_target / (100 * semitones)));
}
return [note_value, midi_bend_value];
}
export const midiToFreq = (note: number): number => {
const freq = 440 // Frequency of A
return (freq / 32) * (2 ** ((note - 9) / 12))
}
export const freqToMidi = (freq: number): number => {
return (12 / Math.log(2)) * Math.log(freq / 440) + 69
}
export const ratioToCents = (ratio: number): number => {
return 1200.0 * Math.log2(ratio);
}
export const primeSieve = function* () {
const sieve: { [key: number]: number[] } = {};
let current = 2;
while (true) {
if (!(current in sieve)) {
yield current;
sieve[current * current] = [current];
} else {
for (const composite of sieve[current]) {
(sieve[composite + current] ||= []).push(composite);
}
delete sieve[current];
}
current++;
}
}
export const getPrimes = (n: number): number[] => {
const primeGenerator: Generator<number> = primeSieve();
return Array.from({ length: n }, () => primeGenerator.next().value);
}
export const monzoToCents = (monzo: number[]): number => {
const maxIndex = monzo.length;
const primes = getPrimes(maxIndex+1);
let ratio = 1;
for (let i = 0; i < maxIndex; i++) {
ratio *= Math.pow(primes[i], monzo[i]);
}
return ratioToCents(ratio);
}
export const centsToSemitones = (cents: number[]): number[] => {
if (cents[0] !== 0) {
cents = [0, ...cents];
}
const semitoneScale: number[] = [];
for (let i = 0; i < cents.length - 1; i++) {
const semitoneInterval = (cents[i + 1] - cents[i]) / 100;
semitoneScale.push(semitoneInterval);
}
return semitoneScale;
}
export const ratiosToCents = (ratios: number[]): number[] => {
return ratios.map(ratioToCents);
}
export const ratiosToSemitones = (ratios: number[]): number[] => {
return centsToSemitones(ratiosToCents(ratios));
}
export const scaleLength = (scale: string|number[]): number => {
return typeof scale === 'string' ? getScale(scale).length : scale.length;
}
export const stepsToScale = (steps: number[]) => {
return [0,...steps].reduce((scale: number[], step: number, index: number) => {
const value = index === 0 ? 0 : step + scale[index-1];
return [...scale, value];
}, []);
}
export const scaleToSteps = (pcs: number[]): number[] => {
const pc_int = (a: number, b: number): number => {
const r = (b - a) % 12;
return r < 0 ? r+12 : r;
}
return pcs.map((pc, i) => pc_int(pc, pcs[(i + 1) % pcs.length]));
}
export const numberToScale = (number: number): number[] => {
if (number < 0 || number > 4095) {
console.log("Input number must be odd and between 0 and 4095. Using major (2741) instead.");
number = 2741;
}
if(number % 2 === 0) {
console.log("Even numbers doesnt create a 'real' scale");
}
const arr = (number >>> 0).toString(2).padStart(12, '0').split('');
return arr.reduce((acc, bit, i) => bit === '1' ? [11 - i, ...acc] : acc, [] as number[]);
}
export function scaleToNumber(pcs: number[]): number {
if (pcs.length > 0 && pcs[pcs.length - 1] === 12) {
pcs.pop(); // Remove the last value if 12
}
let number = 0;
for (const pc of pcs) {
number |= (1 << pc);
}
return number;
}
export const parseScalaScale = (scala: string): number[] => {
try {
return parseScala(scala) as number[];
} catch (error) {
return [];
}
}
export const safeScale = (scale: string|number|number[]): number[] => {
if(typeof scale === 'string') {
if(isScale(scale)) {
return getScale(scale);
} else {
const scalaScale = parseScalaScale(scale) as number[];
if(scalaScale && scalaScale.length > 0) {
return scalaScale;
} else {
return getScale('MAJOR');
}
}
} else if(typeof scale === 'number') {
return scaleToSteps(numberToScale(scale));
}
// TODO: Check for valid intervals?
return scale;
}
export const edoToCents = (edo: number, intervals: string|number[] = new Array(edo).fill(1)) => {
intervals = safeScale(intervals);
const centsInSemitone = (12 / edo) * 100;
const scale = stepsToScale(intervals);
return scale.map((pc) => pc * centsInSemitone);
}
export const edoToSemitones = (edo: number, intervals: string|number[] = new Array(edo).fill(1)) => {
const scaleInCents = edoToCents(edo, intervals);
return centsToSemitones(scaleInCents);
}
export const namedChordFromDegree = (
degree: number,
name: string = "major",
root: number|string = 60,
scale: string = "CHROMATIC",
numOctaves: number = 1
): number[] => {
const intervals: number[] = CHORDS[name] || CHORDS["major"];
root = typeof root === "string" ? noteNameToMidi(root) : root;
const scaleDegree: number = getScaleNotes(scale, root)[degree - 1];
const notes: number[] = [];
for (let curOct = 0; curOct <= numOctaves; curOct++) {
for (const interval of intervals) {
notes.push(scaleDegree + interval + curOct * 12);
}
}
return notes;
}
export const getPitchesFromNamedChord = (
name: string = "major",
root: number|string = 60,
scale: string = "MAJOR",
numOctaves: number = 1,
duration: number
): Pitch[] => {
const notes = namedChordFromDegree(1, name, root, "CHROMATIC", numOctaves);
const parsedScale = typeof scale === "string" ? getScale(scale) : scale;
const pitches: Pitch[] = notes.map(note => {
const pitch = midiToPitchClass(note, root, scale);
return new Pitch({text: pitch.text, note: note, pitch: pitch.pc, octave: pitch.octave, add: pitch.add, duration: duration, scaleName: scale, parsedScale: parsedScale, key: root});
});
return pitches;
}
export const getScaleNotes = (
name: string|number|number[],
root: number|string = 60,
numOctaves: number = 1
): number[] => {
const scale = safeScale(name);
let scaleRoot = typeof root === "string" ? noteNameToMidi(root) : root;
const scaleNotes: number[] = [scaleRoot];
for (let i = 0; i < numOctaves; i++) {
for (const semitone of scale) {
scaleRoot += semitone;
scaleNotes.push(scaleRoot);
}
}
return scaleNotes;
}
/* Get all scale notes, defaults for full sized keyboard from 21 to 108 */
export const getAllScaleNotes = (
name: string|number|number[],
key: string|number = "C",
from: number = 21,
to: number = 108
): number[] => {
const scale = safeScale(name);
const scaleNotes: number[] = [];
let scaleRoot = typeof key === "string" ? noteNameToMidi(key, 0) : key;
for (let i = 0; i < 9; i++) {
for (const semitone of scale) {
scaleRoot += semitone;
scaleNotes.push(scaleRoot);
}
}
return scaleNotes.filter(note => note >= from && note <= to);
}
export const chordFromDegree = (
degree: number,
scale: string = "MAJOR",
root: string | number = 60,
numOctaves: number = 1,
name: string | undefined = undefined,
): number[] => {
const rootMidi: number = typeof root === "string" ? noteNameToMidi(root) : root;
if (
!name &&
typeof scale === "string" &&
scale.toUpperCase() === "CHROMATIC"
) { name = "major"; }
if (name) {
return namedChordFromDegree(degree, name, rootMidi, scale, numOctaves);
} else {
return getChordFromScale(degree, rootMidi, scale);
}
}
export const getChordFromScale = (
degree: number,
root: number = 60,
scale: string | number[] = "Major",
numNotes: number = 3,
skip: number = 2
): number[] => {
const scaleLength: number = typeof scale === "string" ? getScaleLength(scale) : scale.length;
const numOctaves: number = Math.floor((numNotes * skip + degree - 1) / scaleLength) + 1;
const scaleNotes: number[] = getScaleNotes(scale, root, numOctaves);
const chord: number[] = [];
for (let i = degree - 1; chord.length < numNotes && i < scaleNotes.length; i += skip) {
chord.push(scaleNotes[i]);
}
return chord;
}
export const chord = (name: string): number[] => {
// Parse chord name from notation scientific notation + chord name
// For example Cmaj or C7
const parsedChord = name.match(/([a-gA-G][#bs]?)([0-9])?([a-zA-Z0-9]+)/);
if (parsedChord === null) {
// C major chord by default
return [60, 64, 67];
}
let [, root, oct, chordName] = parsedChord;
const rootMidi = noteNameToMidi(root);
const octave = oct ? parseInt(oct, 10) : 0;
const namedChord = namedChordFromDegree(1, chordName, rootMidi, "CHROMATIC", octave);
return namedChord;
}
export const parseRoman = (numeral: string): number => {
const values: number[] = numeral.split('').map(val => ROMANS[val]);
return values.reduce((result, current, index, array) => {
if (index < array.length - 1 && current < array[index + 1]) {
return result - current;
} else {
return result + current;
}
}, 0);
}
export const accidentalsFromNoteName = (name: string): number => {
if (!CIRCLE_OF_FIFTHS.includes(name)) {
name = midiToNoteName(noteNameToMidi(name));
}
const idx: number = CIRCLE_OF_FIFTHS.indexOf(name);
return idx - 6;
}
export const midiToNoteName = (midi: number): string => {
return INTERVALS_TO_NOTES[midi % 12];
}
export const accidentalsFromMidiNote = (note: number): number => {
const name: string = midiToNoteName(note);
return accidentalsFromNoteName(name);
}
export const chordToPcSet = (chord: number[]): number[] => {
/**
* Convert a chord to a pitch class set
* @param {number[]} chord - The chord in midi notes
* @returns {number[]} The unique pitch class set
*/
return chord.map((note) => note % 12)
.filter((value, index, self) => self.indexOf(value) === index)
.sort((a, b) => a - b);
}
export const midiToTpc = (note: number, key: string | number): number => {
let acc: number;
if (typeof key === "string") {
acc = accidentalsFromNoteName(key[0]);
} else {
acc = accidentalsFromMidiNote(key);
}
return ((note * 7 + 26 - (11 + acc)) % 12 + (11 + acc)) as number;
}
export const midiToOctave = (note: number, key: number = 60): number => {
return note <= 0 ? 0 : Math.floor((note - key) / 12);
}
type PitchClass = {
text: string,
pc: number,
octave: number,
add: number,
}
export const midiToPitchClass = (note: number, key: string | number = 60, scale: string = "MAJOR"): PitchClass => {
function repeatSign(num: number): string {
return num > 0 ? "^".repeat(num) : num < 0 ? "_".repeat(Math.abs(num)) : "";
}
const keyNumber = typeof key == "number" ? key : noteNameToMidi(key);
const pitchClass: number = safeMod(note - keyNumber, 12);
const octave: number = midiToOctave(note, keyNumber);
if(typeof scale === "string" && scale.toUpperCase() === "CHROMATIC") {
return {
text: pitchClass.toString(),
pc: pitchClass,
octave: octave,
add: 0
};
}
const sharps: string[] = ["0", "#0", "1", "#1", "2", "3", "#3", "4", "#4", "5", "#5", "6"];
const flats: string[] = ["0", "b1", "1", "b2", "2", "3", "b4", "4", "b5", "5", "b6", "6"];
const tpc: number = midiToTpc(note, key);
let npc: string;
if ((tpc >= 6 && tpc <= 12 && flats[pitchClass].length === 2)) {
npc = flats[pitchClass];
} else {
npc = sharps[pitchClass];
}
if (npc.length > 1) {
const modifier: number = npc[0] === "#" ? 1 : -1;
return {
text: repeatSign(octave) + npc,
pc: parseInt(npc[1]),
octave: octave,
add: modifier,
};
}
return {
text: repeatSign(octave) + npc,
pc: parseInt(npc),
octave: octave,
add: 0
};
}
export const noteNameToPitchClass = (name: string, key: string, scale: string): PitchClass => {
const midiNote = noteNameToMidi(name);
return midiToPitchClass(midiNote, key, scale);
}
// Dmitri Tymoczko voice leading algorithm
export const octaveTransform = (inputChord: number[], root: number): number[] => {
return inputChord.map(x => root + (x % 12)).sort((a, b) => a - b);
}
export const tMatrix = (chordA: number[], chordB: number[]): (number|undefined)[] => {
const root = chordA[0];
const transformedA = octaveTransform(chordA, root);
const transformedB = octaveTransform(chordB, root);
return transformedA.map((a, index) => transformedB[index] ? transformedB[index] - a : undefined);
}
export const voiceLead = (chordA: number[], chordB: number[]): number[] => {
const root = chordA[0];
const aLeadings = chordA.map(x => [x,
octaveTransform(chordA, root).indexOf(root + (x % 12))
]);
const tMatrixResult = tMatrix(chordA, chordB);
const bVoicing = aLeadings.map(([x, y]) => {
return tMatrixResult[y] ? x + tMatrixResult[y]! : x;
});
// TODO: Check octave for extra notes in chordB?
return bVoicing;
}
export const voiceLeadChords = (inputChords: number[][]): number[][] => {
// Initialize the result array with the first chord as-is
const voiceLedChords: number[][] = [inputChords[0]];
// Iterate through each subsequent chord and voice lead it to the previous chord
for (let i = 1; i < inputChords.length; i++) {
const voiceLedChord = voiceLead(inputChords[i], voiceLedChords[i - 1]);
voiceLedChords.push(voiceLedChord);
}
return voiceLedChords;
}
export function transpose(pitches: number[], interval: number, base: number = 12): number[] {
return pitches.map((x) => (x + interval) % base);
}
export function invert(pitches: number[], axis: number = 0, base: number = 12): number[] {
return pitches.map((x) => { return (axis - x) % base });
}
export function multiply(pitches: number[], m: number = 5, base: number = 12): number[] {
return pitches.map((x) => (x * m) % base);
}
export function zero(pitches: number[], base: number = 12): number[] {
return transpose(pitches, -pitches[0], base);
}
export function cycles(pitches: number[]) {
const sortedPitches = pitches.slice().sort((a, b) => a - b);
const cyclicVariations = [];
for (let i = 0; i < sortedPitches.length; i++) {
cyclicVariations.push(sortedPitches.slice(i).concat(sortedPitches.slice(0, i)));
}
return cyclicVariations;
}
export function normalForm(pitches: number[], base: number = 12): number[] {
return mostLeftCompact(cycles(pitches), base);
}
export function prime(pitches: number[], base: number = 12): number[] {
return mostLeftCompact([normalForm(pitches, base), normalForm(invert(pitches, 0, base), base)], base);
}
export function arrayToBinary(array: number[]): number {
return array.reduce((sum, n) => sum + 2 ** n, 0);
}
export function mostLeftCompact(pcsetArray: number[][], base: number): number[] {
if (!pcsetArray.every((pcs) => pcs.length === pcsetArray[0].length)) {
throw new Error("Format error: All pitch sets must have the same cardinality");
}
const zeroedPitchArrays = pcsetArray.map((pcs) => zero(pcs, base));
const binaries = zeroedPitchArrays.map((array) => arrayToBinary(array));
const minBinary = Math.min(...binaries);
const winners = pcsetArray.filter((_, i) => binaries[i] === minBinary);
return winners.sort()[0];
}
export function nearScales(scale: number): number[] {
const near: number[] = [];
for (let i = 1; i < 12; i++) {
let copy = scale;
if (scale & (1 << i)) {
// Tone off
const off = copy ^ (1 << i);
near.push(off);
// Down one semitone
copy = off | (1 << (i - 1));
near.push(copy);
// Up one semitone but not octave
if (i !== 11) {
copy = off | (1 << (i + 1));
near.push(copy);
}
} else {
copy = copy | (1 << i);
near.push(copy);
}
}
const uniq = Array.from(new Set(near));
return uniq;
}