Skip to content

Commit ca4ba91

Browse files
committed
feat(core): always name chords even when unknown based on their interval relationships
1 parent b3a8676 commit ca4ba91

File tree

4 files changed

+178
-23
lines changed

4 files changed

+178
-23
lines changed

lib/constants/chords.ts

+33-5
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
// CHORDS
44
// *****************************************************************************
55

6+
import { Interval } from './intervals';
7+
68
/**
79
* Chord Names
810
* @memberof Constants
@@ -229,7 +231,7 @@ export const ChordName = <const>{
229231
* @memberof Types
230232
*/
231233

232-
export type ChordName = typeof ChordName[keyof typeof ChordName];
234+
export type ChordName = (typeof ChordName)[keyof typeof ChordName];
233235

234236
/**
235237
* Chord Symbols
@@ -451,7 +453,7 @@ export const ChordSymbol = {
451453
MajorThirteenth: 'M13',
452454
} as const;
453455

454-
export type ChordSymbol = typeof ChordSymbol[ChordName];
456+
export type ChordSymbol = (typeof ChordSymbol)[ChordName];
455457

456458
/**
457459
* Chord Intervals
@@ -672,7 +674,33 @@ export const ChordIntervals = <const>{
672674
MajorThirteenth: '1P 3M 5P 7M 9M 13M',
673675
};
674676

675-
export type ChordIntervals = typeof ChordIntervals[ChordName];
677+
export type ChordIntervals = (typeof ChordIntervals)[ChordName];
678+
679+
export const ChordIntervalRelations = <const>{
680+
'1P': '',
681+
'2m': 'b9',
682+
'2M': 'sus2',
683+
'3m': 'm',
684+
'3M': 'M',
685+
'4P': 'sus4',
686+
'4A': 'b5',
687+
'5d': 'b5',
688+
'5P': '5',
689+
'5A': 'aug',
690+
'6m': 'aug',
691+
'6M': '6',
692+
'7m': '7',
693+
'7M': 'maj7',
694+
'8P': '',
695+
'9m': 'addb9',
696+
'9M': 'add9',
697+
'11P': '11',
698+
'11A': 'add#11',
699+
'13m': 'b13',
700+
'13M': '13',
701+
};
702+
703+
export type ChordIntervalRelations = (typeof ChordIntervalRelations)[Interval];
676704

677705
/**
678706
* Chord Structure definition
@@ -758,7 +786,7 @@ export const ChordStructure = <const>{
758786
Thirteenth: ['1 3 5 7 9 13'],
759787
};
760788

761-
export type ChordStructure = typeof ChordStructure[keyof typeof ChordStructure];
789+
export type ChordStructure = (typeof ChordStructure)[keyof typeof ChordStructure];
762790

763791
const toDefObj = (name: ChordName, symbol: ChordSymbol, intervals: ChordIntervals, structure: ChordStructure) => ({
764792
name,
@@ -881,7 +909,7 @@ export const ChordDefinition = <const>{
881909
),
882910
};
883911

884-
export type ChordDefinition = typeof ChordDefinition[keyof typeof ChordDefinition];
912+
export type ChordDefinition = (typeof ChordDefinition)[keyof typeof ChordDefinition];
885913

886914
export const SimilarChordsByStructure = new Map<ChordStructure, ChordName[]>([
887915
[ChordStructure.Triad, [ChordName.Major, ChordName.Minor, ChordName.Aug, ChordName.Dim, ChordName.P5]],

lib/constants/intervals.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ export const Semitones = <const>{
3535
'13M': 21,
3636
};
3737

38-
export type Semitones = typeof Semitones[keyof typeof Semitones];
38+
export type Semitones = (typeof Semitones)[keyof typeof Semitones];
3939
export type Interval = keyof typeof Semitones;
4040

4141
export type HarmonicPosition = 1 | 2 | 3 | 4 | 5 | 6 | 7;

lib/core/Chord.ts

+121-9
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,14 @@ import {
1010
ChordDefinition,
1111
ChordName,
1212
ChordIntervals,
13+
ChordIntervalRelations,
1314
} from '../constants/chords';
1415
import { HarmonicPosition, Interval, Semitones } from '../constants/intervals';
1516
import { NoteSymbol } from '../constants/note';
1617
import { ScaleIntervals } from '../constants/scales';
1718
import { deconstructName, findNameFromIntervals, findNameFromSymbol } from './utils';
1819
import { PlayaError, whilst } from '../utils';
19-
import { distance, rotate, choose, random, interval } from '../tools';
20+
import { rotate, choose, random, interval } from '../tools';
2021
import { isDefined, isNull, isUndefined } from '../utils/types-guards';
2122
import assignOctaves from '../utils/octaves';
2223
import { Scale } from './Scale';
@@ -212,7 +213,7 @@ export class Chord extends HarmonyBase {
212213
*/
213214
static fromNotes(notes: NoteSymbol[], octaves?: Octaves): Chord {
214215
const root = notes[0];
215-
const intervalsArray = R.tail(notes.map((note) => distance.interval(root, note)));
216+
const intervalsArray = R.tail(notes.map((note) => interval.between(root, note)));
216217

217218
if (intervalsArray.includes(null)) {
218219
throw new PlayaError('Chord', `Could not recognize a valid chord from these notes: <${notes}>`);
@@ -354,13 +355,15 @@ export class Chord extends HarmonyBase {
354355
static findChordSymbol(chord: ChordIntervals | string): ChordSymbol | string | undefined {
355356
let chordType: ChordSymbol | string | undefined;
356357
const chordIntervals = Object.entries(ChordIntervals);
358+
let intervals = chord.split(' ') as Interval[];
357359

358360
for (const [name, symbol] of chordIntervals) {
359-
// if (chordType) { // this is so we don't get the empty major
360-
// break;
361-
// }
361+
const splitSymbol = symbol.split(' ') as Interval[];
362362

363-
if (symbol === chord) {
363+
// figure out if all the intervals are present in the symbol
364+
const allIntervals = R.all((interval) => R.includes(interval, splitSymbol), intervals);
365+
366+
if (allIntervals) {
364367
chordType = ChordSymbol[name as ChordName];
365368
break;
366369
}
@@ -370,7 +373,6 @@ export class Chord extends HarmonyBase {
370373
return chordType;
371374
}
372375

373-
let intervals = chord.split(' ');
374376
const last = R.last(intervals) as string;
375377

376378
try {
@@ -390,8 +392,118 @@ export class Chord extends HarmonyBase {
390392

391393
chordType = `${chordType}add${last.replace(/\D/g, '')}`;
392394
} catch {
393-
console.error("[findChordSymbol] Couldn't find a chord symbol for", chord);
394-
return;
395+
intervals = chord.split(' ') as Interval[];
396+
397+
const state: Record<string, Interval | null> = {
398+
second: null,
399+
third: null,
400+
fourth: null,
401+
fifth: null,
402+
sixth: null,
403+
seventh: null,
404+
ninth: null,
405+
eleventh: null,
406+
thirteenth: null,
407+
};
408+
409+
// verify state
410+
intervals.forEach((interval) => {
411+
if (['2m', '2M'].includes(interval)) {
412+
state.second = interval;
413+
} else if (['3m', '3M'].includes(interval)) {
414+
state.third = interval;
415+
} else if (['4P'].includes(interval)) {
416+
state.fourth = interval;
417+
} else if (['4A', '5d', '5P', '5A'].includes(interval)) {
418+
state.fifth = interval;
419+
} else if (['6m', '6M'].includes(interval)) {
420+
state.sixth = interval;
421+
} else if (['7m', '7M'].includes(interval)) {
422+
state.seventh = interval;
423+
} else if (['9m', '9M'].includes(interval)) {
424+
state.ninth = interval;
425+
} else if (['11P', '11A'].includes(interval)) {
426+
state.eleventh = interval;
427+
} else if (['13m', '13M'].includes(interval)) {
428+
state.thirteenth = interval;
429+
}
430+
});
431+
432+
const altered: string[] = [];
433+
434+
chordType = '';
435+
436+
// check thirds
437+
if (state.third) {
438+
chordType = ChordIntervalRelations[state.third];
439+
}
440+
441+
if (state.second) {
442+
if (state.second === '2m') {
443+
altered.push(ChordIntervalRelations[state.second]);
444+
} else {
445+
chordType = `${chordType}${ChordIntervalRelations[state.second]}`;
446+
}
447+
}
448+
449+
if (state.fourth) {
450+
chordType = `${chordType}${ChordIntervalRelations[state.fourth]}`;
451+
}
452+
453+
if (state.fifth) {
454+
if (['4A', '5d'].includes(state.fifth)) {
455+
if (!state.third) {
456+
altered.push('#11');
457+
} else {
458+
chordType = `${chordType}${ChordIntervalRelations[state.fifth]}`;
459+
}
460+
}
461+
}
462+
463+
if (state.sixth) {
464+
if (state.seventh) {
465+
state.thirteenth = interval.fromSemitones(Semitones[state.sixth] + 12) as any as Interval;
466+
} else {
467+
chordType = `${chordType}${ChordIntervalRelations[state.sixth]}`;
468+
}
469+
}
470+
471+
const hasTriad = !!state.third || !!state.fifth;
472+
const hasExtensions = !!state.ninth || !!state.eleventh || !!state.thirteenth;
473+
let hasSeventh = false;
474+
475+
if (state.seventh) {
476+
if ((hasTriad && !hasExtensions) || (!hasTriad && hasExtensions)) {
477+
chordType = `${chordType}${ChordIntervalRelations[state.seventh]}`;
478+
hasSeventh = true;
479+
}
480+
}
481+
482+
if (state.ninth) {
483+
if (hasSeventh) {
484+
altered.push(ChordIntervalRelations[state.ninth]);
485+
} else {
486+
chordType = `${chordType}${ChordIntervalRelations[state.ninth]}`;
487+
}
488+
} else if (state.eleventh) {
489+
if (hasSeventh) {
490+
altered.push(ChordIntervalRelations[state.eleventh]);
491+
} else {
492+
chordType = `${chordType}${ChordIntervalRelations[state.eleventh]}`;
493+
}
494+
} else if (state.thirteenth) {
495+
if (hasSeventh) {
496+
altered.push(ChordIntervalRelations[state.thirteenth]);
497+
} else {
498+
chordType = `${chordType}${ChordIntervalRelations[state.thirteenth]}`;
499+
}
500+
}
501+
502+
if (!state.third) {
503+
altered.push('no3');
504+
}
505+
506+
return chordType != '' ? `${chordType}${altered.length > 0 ? `(${altered.join(',')})` : ''}` : undefined;
395507
}
396508

397509
return chordType;

test/core/chord.spec.ts

+23-8
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { describe, it, expect } from 'vitest';
33
import { ScaleIntervals } from '../../lib/constants';
44
import { Chord, Note } from '../../lib/core';
55
import random from '../../lib/tools/random';
6+
import * as interval from '../../lib/tools/interval';
67
import '../matchers';
78

89
describe('Chord Test Suite', () => {
@@ -219,7 +220,7 @@ describe('Chord Test Suite', () => {
219220
expect(chord).toHaveStringNotes(['A3', 'C4', 'E4', 'G4', 'B4', 'F5']);
220221
});
221222

222-
it('should create custom', () => {
223+
it('should create custom simple', () => {
223224
random.setSeed('test');
224225

225226
const chord = Chord.fromIntervals('C', '1P 2M 3M 4A 5P 6m 7m', ['1 3 5 6 7']);
@@ -240,10 +241,10 @@ describe('Chord Test Suite', () => {
240241
const chord = Chord.fromIntervals('D', '1P 2m 3M 4A 5P 6m 7m', ['1 2 6 7']);
241242

242243
expect(chord.root.note).toBe('D');
243-
expect(chord.symbol).toBeUndefined();
244+
expect(chord.symbol).toBe('7(b9,b13,no3)');
244245
expect(chord.intervals).toBe('1P 2m 6m 7m');
245246
expect(chord.structure).toEqual(['1 2 6 7']);
246-
expect(chord.name).toBeUndefined();
247+
expect(chord.name).toBe('D7(b9,b13,no3)');
247248
expect(chord.hasFlats).toBeTruthy();
248249
expect(chord.hasSharps).toBeFalsy();
249250
expect(chord).toHaveStringNotes(['D3', 'Eb3', 'Bb3', 'C4']);
@@ -256,7 +257,9 @@ describe('Chord Test Suite', () => {
256257
Chord.fromIntervals('A', '1P 100P', Chord.Structures.Ninth);
257258
};
258259

259-
expect(toThrow).toThrowErrorMatchingInlineSnapshot(`[[PlayaError <Chord>]: [1P 100P] has unrecognized intervals.]`);
260+
expect(toThrow).toThrowErrorMatchingInlineSnapshot(
261+
`[[PlayaError <Chord>]: [1P 100P] has unrecognized intervals.]`
262+
);
260263
});
261264
});
262265

@@ -319,11 +322,11 @@ describe('Chord Test Suite', () => {
319322
const chord = Chord.fromNotes(['A', 'C', 'F#', 'G']);
320323

321324
expect(chord.root.note).toBe('A');
322-
expect(chord.symbol).toBeUndefined();
325+
expect(chord.symbol).toBe('m13');
323326
expect(chord.toString()).toBe('[object Chord: A3,C4,F#4,G4]');
324327
expect(chord.intervals).toBe('1P 3m 6M 7m');
325328
expect(chord.structure).toBeUndefined();
326-
expect(chord.name).toBeUndefined();
329+
expect(chord.name).toBe('Am13');
327330
expect(chord).toHaveMidiNotes([69, 72, 78, 79]);
328331
});
329332
});
@@ -396,7 +399,9 @@ describe('Chord Test Suite', () => {
396399
chord.noteAt(2);
397400
};
398401

399-
expect(toThrow).toThrowErrorMatchingInlineSnapshot(`[[PlayaError <Chord>]: [1,3,5,7] structure doesn't contain position: 2]`);
402+
expect(toThrow).toThrowErrorMatchingInlineSnapshot(
403+
`[[PlayaError <Chord>]: [1,3,5,7] structure doesn't contain position: 2]`
404+
);
400405
});
401406
});
402407

@@ -413,8 +418,18 @@ describe('Chord Test Suite', () => {
413418
expect(Chord.findChordSymbol('1P 3M 5P 7m 9M 11m')).toBe('9add11');
414419
});
415420

421+
it('should find relationships when missing from corpus', () => {
422+
const intervals = interval.detect('Bb E A')!;
423+
expect(Chord.findChordSymbol(intervals.join(' '))).toBe('maj7(#11,no3)');
424+
});
425+
426+
it('should find relationships when missing from corpus complex', () => {
427+
const intervals = interval.detect('C3 Gb3 Ab3 D4')!;
428+
expect(Chord.findChordSymbol(intervals.join(' '))).toBe('augadd9(#11,no3)');
429+
});
430+
416431
it('should not create for inexisting', () => {
417-
expect(Chord.findChordSymbol('1P 2m 5P 7m 8M 11m')).toBeUndefined();
432+
expect(Chord.findChordSymbol('1P 2m 8M 11M')).toBeUndefined();
418433
});
419434
});
420435
});

0 commit comments

Comments
 (0)