zifferjs
Version:
Zifferjs - Typescript variant of Ziffers
799 lines (743 loc) • 28.6 kB
text/typescript
import { noteFromPc, midiToFreq, scaleLength, safeScale, parseRoman, midiToPitchClass, namedChordFromDegree, noteNameToMidi, getScaleNotes, getChordFromScale } from './scale.ts';
import { OPERATORS, getScale, getRandomScale, DEFAULT_DURATION } from './defaults.ts';
import { deepClone, safeMod } from './utils.ts';
import { Tetrachord, TonnetzSpaces, TriadChord, chordFromTonnetz, seventhsTransform, transform } from 'ts-tonnetz';
export const globalOptionKeys: string[] = ["retrograde"];
export type GlobalOptions = {
retrograde?: boolean;
}
export type Options = {
nodeOptions?: NodeOptions;
defaultDurs?: {[key: string]: number};
}
export type NodeOptions = {
key?: string|number;
add?: number;
scale?: string|number[];
parsedScale?: number[]|undefined;
scaleName?: string;
duration?: number;
octave?: number;
index?: number;
port?: string;
channel?: number;
velocity?: number;
degrees?: boolean;
redo?: number;
seed?: string;
randomSeed?: string;
seededRandom?: Function;
globalOptions?: GlobalOptions;
retrograde?: boolean;
}
export type ChangingOptions = {
octave?: number;
duration?: number;
scale?: string|number[];
key?: string;
subdivisions?: boolean;
inversion?: number;
sound?: string;
soundIndex?: number|RandomPitch;
}
export type Node = NodeOptions & {
// Common
type: string;
text: string;
location: Location;
// Pitch
pitch?: number;
originalPitch?: number;
freq?: number;
note?: number;
bend?: number;
// Sound
sound?: string;
soundIndex?: number;
// Chord
pitches?: Node[];
// List operation
left?: Node[];
right?: Node[];
operation?: string;
// Repeat
times?: number;
item?: Node;
// Random node
min?: number;
max?: number;
}
type Location = {
end: Position;
start: Position;
}
type Position = {
offset: number;
line: number;
column: number;
}
export abstract class Base {
type!: string;
text!: string;
location!: Location;
constructor(data: Partial<Node>) {
this.type = this.constructor.name;
Object.assign(this, data);
}
public clone(): any {
return deepClone(this);
}
collect<K extends keyof Base>(name: K): Base[K] {
return this[name];
}
refresh(): void {
// Overwrite in subclasses
}
// @ts-ignore
evaluate(options: ChangingOptions = {}): Base|Base[]|undefined {
return this;
}
// @ts-ignore
prevaluate(options: ChangingOptions = {}): Base {
return this;
}
evaluateValue(): any {
return this.text;
}
toString(): string {
return this.text;
}
}
export abstract class Event extends Base {
duration!: number;
modifiedEvent: Event|undefined = undefined;
globalOptions!: GlobalOptions;
sound?: string|Base;
soundIndex?: number|RandomPitch;
constructor(data: Partial<Node>) {
super(data);
Object.assign(this, data);
}
collect(name: string): any {
// Overwrite in subclasses
// @ts-ignore
return this[name];
}
sometimesBy(probability: number, func: Function): Event {
if(Math.random() < probability) {
return this.modify(func);
}
return this;
}
sometimes(func: Function): Event {
return this.sometimesBy(0.5, func);
}
rarely(func: Function): Event {
return this.sometimesBy(0.1, func);
}
often(func: Function): Event {
return this.sometimesBy(0.9, func);
}
update(func: Function): Event {
func(this);
this.refresh();
return this;
}
modify(func: Function): Event {
this.modifiedEvent = this.clone();
func(this.modifiedEvent);
this.modifiedEvent!.refresh();
return this.modifiedEvent!;
}
skip(): Event { return this }
scale(_scale: string|number[]): Event { return this }
randomScale(): Event { return this }
retrograde(): Event { return this }
asObject(): object {
const attributes: Record<keyof this, any> = {} as Record<keyof this, any>;
for (const key in this) {
if (Object.prototype.hasOwnProperty.call(this, key)) {
attributes[key as keyof this] = this[key as keyof this];
}
}
return attributes;
}
getExisting(...args: string[]): {[key: string]: any} {
const existing = args.reduce((acc, value) => {
if(Object.prototype.hasOwnProperty.call(this, value)) {
const val = this[value as keyof this];
if(val || val===0) acc[value] = this[value as keyof this];
}
return acc;
}, {} as {[key: string]: any});
return existing;
}
mapExisting(fromKeys: string[], toKeys: string[]): {[key: string]: any} {
const existing = fromKeys.reduce((acc, value, index) => {
if(Object.prototype.hasOwnProperty.call(this, value)) {
const val = this[value as keyof this];
if(val || val===0) acc[toKeys[index]] = this[value as keyof this];
}
return acc;
}, {} as {[key: string]: any});
return existing;
}
}
export class Pitch extends Event {
pitch!: number|RandomPitch;
originalPitch?: number;
add?: number;
freq?: number;
note?: number;
octave?: number;
pitchOctave?: number;
addedOctave?: number;
bend?: number;
key?: string|number;
parsedScale?: number[];
scaleName?: string;
constructor(data: Partial<Node>) {
super(data);
Object.assign(this, data);
}
refresh(): void {
this.evaluate();
}
evaluate(options: ChangingOptions = {}): Pitch {
const clone = deepClone(this);
if(!clone.duration) {
clone.duration = (options.duration || options.duration === 0) ? options.duration : DEFAULT_DURATION;
}
if(options.scale) {
if(typeof options.scale === "string" && clone.scaleName !== options.scale) {
clone.scaleName = options.scale;
}
if(clone.originalPitch) {
clone.pitch = clone.originalPitch;
clone.octave = 0;
clone.pitchOctave = 0;
}
clone.parsedScale = safeScale(options.scale) as number[];
}
if(options.key) clone.key = options.key;
if(options.soundIndex || options.soundIndex===0) {
if(!(typeof options.soundIndex === "number")) {
clone.soundIndex = (options.soundIndex as Event).evaluateValue() as unknown as number;
} else {
clone.soundIndex = options.soundIndex;
}
}
if(options.sound) {
if(!(typeof options.sound === "string")) {
clone.sound = (options.sound as Event).evaluateValue() as unknown as string;
} else {
clone.sound = options.sound;
}
}
if(clone.pitch || clone.pitch === 0) {
if(clone.pitch instanceof RandomPitch) {
clone.pitch = clone.pitch.evaluateValue();
clone.originalPitch = clone.pitch;
} else if(!clone.originalPitch) {
clone.originalPitch = clone.pitch;
}
if(clone.parsedScale && clone.originalPitch >= clone.parsedScale.length) {
clone.pitchOctave = Math.floor(clone.originalPitch / clone.parsedScale.length);
clone.pitch = safeMod(clone.originalPitch, clone.parsedScale.length);
}
if(options.octave || clone.pitchOctave || clone.addedOctave) clone.octave = (options.octave || 0) + (clone.pitchOctave || 0) + (clone.addedOctave || 0);
const [note,bend] = noteFromPc(clone.key!, (clone.pitch as number)!, clone.parsedScale!, clone.octave!);
clone.note = clone.add ? note+clone.add : note;
clone.freq = midiToFreq(clone.note);
if(bend) {
clone.bend = bend;
}
}
if(clone.soundIndex instanceof RandomPitch) {
clone.soundIndex = clone.soundIndex.evaluateValue();
}
return clone;
}
prevaluate() {
if(this.pitch instanceof RandomPitch) {
this.pitch = this.pitch.evaluateValue();
this.originalPitch = this.pitch;
}
return this;
}
collect<K extends keyof Pitch>(name: K): Pitch[K] {
return this[name];
}
scale(name: string): Pitch {
if(this.scaleName!==name) {
this.scaleName = name;
this.parsedScale = getScale(name) as number[];
return this.evaluate();
}
return this;
}
randomScale(): Pitch {
this.parsedScale = getRandomScale();
return this.evaluate();
}
tonnetzChord(chordType: string, tonnetz: TonnetzSpaces = [3,4,5]): Chord {
const chordNotes = chordFromTonnetz(this.note!, chordType, tonnetz);
const pitches = chordNotes.map((note) => {
const rootedNote = note + (typeof this.key == "number" ? note : noteNameToMidi(this.key!)) + ((this.octave||0)*12);
const pitchClass = midiToPitchClass(rootedNote, this.key!, this.scaleName!);
const pitch = new Pitch({
note: rootedNote,
duration: this.duration,
key: this.key,
parsedScale: this.parsedScale,
scaleName: this.scaleName,
pitch: pitchClass.pc,
originalPitch: pitchClass.pc,
octave: (this.octave||0)+pitchClass.octave,
add: pitchClass.add,
text: pitchClass.text
});
return pitch as unknown as Node;
});
return new Chord({pitches: pitches, duration: this.duration});
}
}
export class Sound extends Pitch {
constructor(data: Partial<Node>) {
super(data);
Object.assign(this, data);
}
evaluateValue() {
return this.sound;
}
}
export class SoundEvent extends Event {
item!: Base
constructor(data: Partial<Node>) {
super(data);
Object.assign(this, data);
}
evaluate(options?: ChangingOptions): Event|Event[]|undefined {
let soundValue = this.sound;
if(options) {
options.sound = soundValue as string;
} else {
options = {sound: soundValue as string}
}
const node: Event = this.item.evaluate(options) as Event;
return node;
}
}
export class SoundIndex extends Event {
item!: Base;
constructor(data: Partial<Node>) {
super(data);
Object.assign(this, data);
}
evaluate(options?: ChangingOptions): Event|Event[]|undefined {
if(options) {
options.soundIndex = this.soundIndex;
} else {
options = {soundIndex: this.soundIndex}
}
return this.item.evaluate(options) as Event;
}
}
export class Chord extends Event {
pitches!: Pitch[];
chordName?: string;
inversion?: number;
key?: number|string;
scaleName?: string;
parsedScale?: number[];
constructor(data: Partial<Node>) {
super(data);
Object.assign(this, data);
if(this.pitches && this.pitches.length > 0) {
this.duration = Math.max(...this.pitches.map((pitch) => pitch.duration!));
}
}
static fromPitchClassArray(pcs: number[], key: string|number, scaleName: string): Chord {
const pitches = pcs.map((pc) => {
return new Pitch({originalPitch: pc, pitch: pc, key: key, scaleName: scaleName, parsedScale: safeScale(scaleName)}) as unknown as Node;
});
return new Chord({pitches: pitches});
}
evaluate(options: ChangingOptions = {}): Chord {
const dupChord = deepClone(this);
if(options.scale) {
if(typeof options.scale === "string") dupChord.scaleName = options.scale;
dupChord.parsedScale = safeScale(options.scale) as number[];
}
if(options.inversion || dupChord.inversion) {
dupChord.pitches = dupChord.invert((options.inversion || dupChord.inversion)!, options);
} else {
dupChord.pitches = dupChord.pitches.map((pitch) => pitch.evaluate(options));
}
dupChord.duration = Math.max(...dupChord.pitches.map((pitch) => pitch.duration!));
return dupChord;
}
collect<K extends keyof Pitch>(name: K): Pitch[K] {
const collect = this.pitches.map((pitch: Pitch) => pitch.collect(name)) as unknown as Pitch[K];
return collect;
}
notes(): number[] {
return this.pitches.map((pitch) => pitch.note!) as number[];
}
freqs(): number[] {
return this.pitches.map((pitch) => pitch.freq!) as number[];
}
pcs(): number[] {
return this.pitches.map((pitch) => pitch.pitch!) as number[];
}
midiChord(): {[key: string]: any} {
const params = this.pitches.map((pitch) => pitch.mapExisting(["note","soundIndex"],["note","channel"]));
return params;
}
scale(name: string): Chord {
if(this.scaleName!==name) return this.evaluate({scale: name})
this.pitches.forEach((pitch) => pitch.scale(name));
return this;
}
invert(value: number, options: ChangingOptions = {}): Pitch[] {
if(value===0) return this.pitches;
const newPcs = value < 0 ? this.pitches.reverse() : this.pitches;
for (let i = 0; i < Math.abs(value); i++) {
const pc = newPcs[i % newPcs.length];
if (!pc.addedOctave) pc.addedOctave = 0;
pc.addedOctave += (value <= 0 ? -1 : 1);
//pc.octave = pc.pitchOctave;
}
const a = newPcs.map((pitch) => pitch.evaluate(options));
return a;
}
voiceLeadFromNotes(leadedNotes: number[], options: NodeOptions): void {
this.pitches = this.pitches.map((p: Pitch, i: number) => {
if(leadedNotes[i]) {
const newPitch = midiToPitchClass(leadedNotes[i], options.key, options.scaleName);
const pc = deepClone(p);
pc.pitch = newPitch.pc;
pc.octave = newPitch.octave;
pc.add = newPitch.add;
pc.text = newPitch.text;
pc.note = leadedNotes[i];
pc.freq = midiToFreq(leadedNotes[i]);
return pc;
} else return deepClone(p);
});
}
triadTonnetz(transformationInput: string, tonnetz: TonnetzSpaces = [3,4,5], transformFunc: Function = transform): Chord|Chord[] {
const notes = this.notes();
if(notes.length === 3) {
const splittedTransforms = transformationInput.split(" ");
const allTransforms = splittedTransforms.map((transformation) => {
const transformedChord = (transformFunc(notes as TriadChord, transformation, tonnetz) as TriadChord)?.sort((a,b) => a-b);
if(!transformedChord) return this;
const parsedScale = this.pitches[0].parsedScale!;
const chord = new Chord({pitches: transformedChord.map((pc) => {
const newPC = midiToPitchClass(pc, this.key, this.scaleName);
const newPitch = new Pitch({originalPitch: newPC.pc, pitch: newPC.pc, add: newPC.add, duration: this.duration, key: this.key, scaleName: this.scaleName, parsedScale: parsedScale});
return newPitch as unknown as Node;
})});
return chord.evaluate();
});
return allTransforms;
} else return this;
}
tetraTonnetz(transformationInput: string, tonnetz: TonnetzSpaces = [3,4,5], transformFunc: Function = seventhsTransform): Chord|Chord[] {
const notes = this.notes();
if(notes.length === 4) {
const splittedTransforms = transformationInput.split(" ");
const allTransforms = splittedTransforms.map((transformation) => {
const transformedChord = (transformFunc(notes as Tetrachord, transformation, tonnetz) as Tetrachord)?.sort((a,b) => a-b);
if(!transformedChord) return this;
const parsedScale = this.pitches[0].parsedScale!;
const chord = new Chord({pitches: transformedChord.map((pc) => {
const newPC = midiToPitchClass(pc, this.key, this.scaleName);
const newPitch = new Pitch({originalPitch: newPC.pc, pitch: newPC.pc, add: newPC.add, duration: this.duration, key: this.key, scaleName: this.scaleName, parsedScale: parsedScale});
return newPitch as unknown as Node;
})});
return chord.evaluate();
});
return allTransforms;
} else return this;
}
}
export class Roman extends Chord {
roman!: string;
romanNumeral!: number;
octave?: number;
chordOctave?: number;
constructor(data: Partial<Node>) {
super(data);
Object.assign(this, data);
}
evaluate(options: ChangingOptions = {}): Roman {
const dup = deepClone(this);
if(options.scale && typeof options.scale === "string") {
dup.scaleName = options.scale;
}
dup.romanNumeral = parseRoman(dup.roman);
const key = dup.key || options.key || 60;
const scale = dup.scaleName || "MAJOR";
const parsedScale = safeScale(scale) as number[];
let octave = (dup.chordOctave || 0) + (options.octave || 0);
if(dup.chordName) {
const chord = namedChordFromDegree(dup.romanNumeral, dup.chordName, key, scale, octave);
const pitchObj = chord.map((note) => {
return midiToPitchClass(note,key,scale);
});
dup.pitches = pitchObj.map((pc) => {
const pitchOct = octave+pc.octave;
return new Pitch({originalPitch: pc.pc, pitch: pc.pc, octave: pitchOct, key: key, parsedScale: parsedScale, add: pc.add, duration: this.duration}).evaluate(options);
});
} else {
const scaleNotes: number[] = getScaleNotes(scale, 0, 7);
const chrom_pcs: number[] = getChordFromScale(dup.romanNumeral, 0, scale);
const pcs: number[] = chrom_pcs.map((note) => {
return scaleNotes.indexOf(note);
});
dup.pitches = pcs.map((pc) => {
return new Pitch({originalPitch: pc, pitch: pc, octave: octave, key: key, parsedScale: parsedScale, duration: this.duration}).evaluate(options);
});
}
if(options.inversion || dup.inversion) {
const inversion = options.inversion || dup.inversion;
dup.pitches = dup.invert(inversion!, options);
}
dup.duration = Math.max(...dup.pitches.map((pitch) => pitch.duration!));
return dup;
}
}
export class Rest extends Event {
constructor(data: Partial<Node>) {
super(data);
}
evaluate(options: ChangingOptions = {}): Rest {
if(!this.duration) this.duration = (options.duration || options.duration === 0) ? options.duration : DEFAULT_DURATION;
return this;
}
}
export class RandomPitch extends Pitch {
min!: number;
max!: number;
randomSeed?: string;
seededRandom?: Function;
random: Function;
randomize: boolean = true;
constructor(data: Partial<Node>) {
super(data);
Object.assign(this, data);
if(!data.min) this.min = 0;
if(!data.max) this.max = scaleLength(this.parsedScale!);
if(this.seededRandom) {
this.random = this.seededRandom;
} else {
this.random = Math.random;
}
}
evaluate(options: ChangingOptions = {}): Pitch {
this.pitch = this.evaluateValue();
this.originalPitch = this.pitch;
const pitch = new Pitch({pitch: this.pitch, originalPitch: this.pitch, text: this.pitch.toString()}).evaluate(options);
return pitch;
}
evaluateValue(): number {
return Math.floor(this.random() * (this.max - this.min + 1)) + this.min;
}
}
export class OctaveChange extends Base {
octave!: number;
constructor(data: Partial<Node>) {
super(data);
Object.assign(this, data);
}
evaluate(options: ChangingOptions = {}) {
options.octave = this.octave + (options.octave || 0);
return undefined;
}
}
export class DurationChange extends Base {
duration!: number;
constructor(data: Partial<Node>) {
super(data);
Object.assign(this, data);
}
evaluate(options: ChangingOptions = {}) {
options.duration = this.duration;
return undefined;
}
}
export class Repeat extends Base {
times!: number;
item!: Base[];
constructor(data: Partial<Node>) {
super(data);
Object.assign(this, data);
}
evaluate(options: ChangingOptions = {}): Pitch[] {
const repeated = [...Array(this.times)].map(() => this.item).flat(Infinity) as Base[];
return repeated.map((item) => { return item.evaluate(options) }) as Pitch[];
}
}
export class List extends Base {
items!: Base[];
constructor(data: Partial<Node>) {
super(data);
Object.assign(this, data);
}
evaluate(options: ChangingOptions = {}): (Pitch|Chord)[] {
return this.items.map((item: Base) => { return item.evaluate(options); }).flat(Infinity) as unknown as (Pitch|Chord)[];
}
prevaluate(): List {
this.items.forEach((item) => item.prevaluate());
return this;
}
}
export class Arpeggio extends List {
chord!: Chord|List;
indexes!: List|number[];
constructor(data: Partial<Node>) {
super(data);
Object.assign(this, data);
}
evaluate(options: ChangingOptions = {}): (Pitch|Chord)[] {
let chord = this.chord.evaluate();
if(!Array.isArray(chord)) {
chord = [chord];
}
return chord.map((chord) => {
if(!(chord instanceof Chord)) return chord;
const chordLength = chord.pitches.length;
if(this.indexes instanceof List) {
const pitchIndexes = this.indexes.evaluate(deepClone(options)).filter((item) => item !== undefined);
return pitchIndexes.map((idx: Pitch|Chord) => {
if(idx instanceof Chord) {
const dupChord = idx.clone() as Chord;
dupChord.pitches = dupChord.pitches.map((pc) => {
return chord.pitches[pc.pitch as number % chordLength];
});
return dupChord.evaluate();
} else if (idx instanceof Pitch){
const origPitch = chord.pitches[idx.pitch as number % chordLength];
const dupPitch = idx.clone() as Pitch;
dupPitch.pitch = origPitch.pitch;
dupPitch.pitchOctave = origPitch.pitchOctave;
dupPitch.add = (dupPitch.add||0)+(origPitch.add||0);
dupPitch.key = origPitch.key;
dupPitch.scaleName = origPitch.scaleName;
dupPitch.parsedScale = origPitch.parsedScale;
return dupPitch.evaluate();
}
return idx;
})
} else if(Array.isArray(this.indexes)) {
const pitches = this.indexes.map(i => {
if(Array.isArray(i)) {
const chordPitches = i.map((index) => {
return chord.pitches[index % chordLength];
}) as unknown as Node[];
return new Chord({pitches: chordPitches, duration: chord.duration}).evaluate()
} else {
const pitch = chord.pitches[i % chordLength];
return pitch.evaluate();
}
}) as unknown as (Pitch|Chord)[];
return pitches;
} else {
return [];
}
}).flat(Infinity) as unknown as (Pitch|Chord)[];
}
}
export class Subdivision extends Base {
duration!: number;
items!: (Pitch|Chord|Rest|Subdivision)[];
evaluated!: (Pitch|Chord|Rest|Subdivision)[];
constructor(data: Partial<Node>) {
super(data);
Object.assign(this, data);
}
evaluate(options: ChangingOptions = {}): Subdivision {
options.subdivisions = true;
//const dup = deepClone(this);
this.duration = options.duration || DEFAULT_DURATION;
this.evaluated = this.items.map((item: Base) => { return item.evaluate(options); }).filter((v) => v).flat(Infinity) as unknown as Pitch[];
return this;
}
}
export class RepeatList extends Base {
times!: number;
items!: Base[];
constructor(data: Partial<Node>) {
super(data);
Object.assign(this, data);
}
evaluate(options: ChangingOptions = {}): Pitch|Chord|Rest[] {
const evaluated = this.items.map((item) => { return item.evaluate(options) }) as Pitch|Chord|Rest[];
const repeated = [...Array(this.times)].map(() => evaluated).flat(Infinity) as Pitch|Chord|Rest[];
return repeated;
}
}
export class ListOperation extends Base {
left!: List;
right!: List;
operation!: string;
constructor(data: Partial<Node>) {
super(data);
Object.assign(this, data);
}
evaluate(options: ChangingOptions = {}): Pitch[] {
const left = this.left.evaluate(options).flat(Infinity);
const right = this.right.evaluate(options).flat(Infinity);
// Parse operator from string to javascript operator
const operator = OPERATORS[this.operation];
// Create pairs of elements
const pairs: [Pitch, Pitch][] = right.flatMap((r) => {
return left.map((l) => {
return [l.clone(), r.clone()] as [Pitch, Pitch];
});
});
// Do pairwise operations
const result: Pitch[] = pairs.map((p: [Pitch, Pitch]) => {
p[0].pitch = operator(p[0].originalPitch, p[1].originalPitch);
p[0].originalPitch = p[0].pitch as number;
return p[0].evaluate(options);
});
return result;
}
}
export class Cycle extends Event {
items!: Base[];
index: number;
constructor(data: Partial<Node>) {
super(data);
Object.assign(this, data);
this.items = this.items.filter((item) => item !== undefined);
this.index = 0;
}
nextItem(options: ChangingOptions = {}): Base | Base[] | undefined {
let value = this.items[this.index%this.items.length] as Base|Base[]|undefined;
while(value instanceof Cycle) {
value = value.nextItem(options);
}
this.index = this.index+1;
if(value instanceof Base) {
const test = value.evaluate(options) as Event;
return test;
}
return value;
}
evaluate(options: ChangingOptions = {}): Base | Base[] | undefined {
const value = this.nextItem(options);
return value;
}
evaluateValue(options: ChangingOptions = {}): any {
const next = this.nextItem(options);
if(next instanceof Base) {
return next.evaluateValue();
} else {
return next;
}
}
}