tonaljs / tonal

A music theory library for Javascript
https://tonaljs.github.io/tonal/docs
3.81k stars 219 forks source link

Voicing Package #223

Closed felixroos closed 3 years ago

felixroos commented 3 years ago

As mentioned here, it would be cool if tonal could handle voicings. I am stoked that you offered me to contribute :)

I have written about most of my ideas here:

For the start, I would just concentrate on generating voicings from dictionaries, as it is way simpler and I am mostly done implementing it.

UPDATE: further changes in https://github.com/tonaljs/tonal/pull/224

I'll make a rough proposal by writing a pseudo doc + some test for methods that could be available:

VoicingDictionary

export declare type VoicingDictionary = {
  [symbol: string]: string[];
};

Maps a chord symbol to a set of voicings (interval string). The Voicings package could provide a set of common voicings. Could this also be a seperate package?!

export const triads: VoicingDictionary = {
  M: ['1P 3M 5P', '3M 5P 8P', '5P 8P 10M'],
  m: ['1P 3m 5P', '3m 5P 8P', '5P 8P 10m'],
  o: ['1P 3m 5d', '3m 5d 8P', '5d 8P 10m'],
  aug: ['1P 3m 5A', '3m 5A 8P', '5A 8P 10m'],
};

export const lefthand: VoicingDictionary = {
  m7: ['3m 5P 7m 9M', '7m 9M 10m 12P'],
  '7': ['3M 6M 7m 9M', '7m 9M 10M 13M'],
  '^7': ['3M 5P 7M 9M', '7M 9M 10M 12P'],
  '69': ['3M 5P 6A 9M'],
  m7b5: ['3m 5d 7m 8P', '7m 8P 10m 12d'],
  '7b9': ['3M 6m 7m 9m', '7m 9m 10M 13m'],
  '7b13': ['3M 6m 7m 9m', '7m 9m 10M 13m'],
  o7: ['1P 3m 5d 6M', '5d 6M 8P 10m'],
  '7#11': ['7m 9M 11A 13A'],
  '7#9': ['3M 7m 9A'],
  mM7: ['3m 5P 7M 9M', '7M 9M 10m 12P'],
  m6: ['3m 5P 6M 9M', '6M 9M 10m 12P'],
};

Maybe this could also be in array format.

Voicing.search

export declare function search(chord: string, range?: string[], dictionary?: VoicingDictionary): string[][];

This method returns all possible voicings of the given chord, as defined in the dictionary, inside the given range:

test('C major triad inversions', () => {
    // if no range + dictionary given, use defaults (dictionray is this case triads, but the real default should contain all chords)
    expect(Voicing.search('C')).toEqual([
      ['C3', 'E3', 'G3'],
      ['C4', 'E4', 'G4'],
      ['E3', 'G3', 'C4'],
      ['E4', 'G4', 'C5'],
      ['G3', 'C4', 'E4'],
    ])
  })
});
// here, we override range and dictionary
test('C^7 lefthand', () => {
  expect(Voicing.search('C^7', ['E3', 'D5'], lefthand)).toEqual([
    ['E3', 'G3', 'B3', 'D4'],
    ['E4', 'G4', 'B4', 'D5'],
    ['B3', 'D4', 'E4', 'G4'],
  ])
})
// this shows that even symbols that are not part of chord-type could be used, as long as they are present in the dictionary
test('Cminor7 lefthand', () => {
  expect(Voicing.search('Cminor7', ['E3', 'D5'], { 'minor7': ['3m 5P 7m 9M', '7m 9M 10m 12P'] })).toEqual([
    ['Eb3', 'G3', 'Bb3', 'D4'],
    ['Eb4', 'G4', 'Bb4', 'D5'],
    ['Bb3', 'D4', 'Eb4', 'G4'],
  ])
})

changes:

Voicing.get

export declare function get(
  chord: string,
  range?: string[],
  dictionary?: VoicingDictionary,
  voiceLeading?: VoiceLeading,
  lastVoicing?: string[]
): string[];

This method returns the best voicing for chord after the optional lastVoicing, using voiceLeading. Internally calls Voicing.search to generate the available voicings.

test('getBestVoicing', () => {
  // all default => pretty useless but
  expect(Voicing.get('Dm7')).toEqual(['F3', 'A3', 'C4', 'E4']);
  // without lastVoicing
  expect(Voicing.get('Dm7', ['F3', 'A4'], lefthand, topNoteDiff)).toEqual(['F3', 'A3', 'C4', 'E4']);
  // with lastVoicing
  expect(Voicing.get('Dm7', ['F3', 'A4'], lefthand, topNoteDiff, ['C4', 'E4', 'G4', 'B4'])).toEqual([
    'C4',
    'E4',
    'F4',
    'A4',
  ]);
});

changes:

VoiceLeading

export declare type VoiceLeading = (voicings: string[][], lastVoicing?: string[]) => string[];

A function that decides which of a set of voicings is picked as a follow up to lastVoicing.

expect(
  topNoteDiff(
    [
      ['F3', 'A3', 'C4', 'E4'],
      ['C4', 'E4', 'F4', 'A4'],
    ],
    ['C4', 'E4', 'G4', 'B4']
  )
).toEqual(['C4', 'E4', 'F4', 'A4']);

The lib could include some common voice leading strategies:

changes:

VoicingDictionary.lookup

export declare function lookup(chordSymbol: string, dictionary: VoicingDictionary);

Get possible interval sets for given chord in given dictionary:

expect(VoicingDictionary.lookup('M7', lefthand)).toEqual([['3M 5P 7M 9M', '7M 9M 10M 12P']]);
// could also be used with chord symbol (ignore root)
expect(VoicingDictionary.lookup('CM7', lefthand)).toEqual([['3M 5P 7M 9M', '7M 9M 10M 12P']]);

Note that it works, even if the chord symbol "M7" is just an alias of the "^7" symbol used in the dictionary.

changes:

Optional: Voicing.analyze

export declare function analyze(
  voicing: string[]
): {
  topNote: string;
  bottomNote: string;
  midiAverage: number;
};

Returns some useful info on the given voicing:

expect(Voicing.analyze(['C4', 'E4', 'G4', 'B4'])).toEqual({
  topNote: 'B4',
  bottomNote: 'C4',
  midiAverage: 85.4, // did not check :)
  // many more values possible
});

Optional: Voicing.analyzeTransition

export declare function analyzeTransition(from: string[], to: string[]): {
  topNoteDiff: number,
  bottomNoteDiff: number,
  movement: number
}

Returns some useful info on the given voice transition

expect(Voicing.analyzeTransition(['C4', 'E4', 'G4', 'B4'],  ['D4', 'F4', 'A4', 'C5'])).toEqual({
topNoteDiff: 1,
bottomNoteDiff: 2,
movement: 5
})

Could also use intervals instead of semitones (but semitones are easier to compare)

Optional: Voicing.searchSets

export declare function searchSets(intervalSets: string[][], range: string[], root: string);

Renders all sets of notes that represent any of the interval sets inside the given range, relative to the root:

expect(
  Voicing.searchSets(
    [
      ['1P', '3M', '5P'],
      ['3M', '5P', '8P'],
    ],
    ['C3', 'G4'],
    'C'
  )
).toEqual([
  ['C3', 'E3', 'G3'],
  ['E3', 'G3', 'C4'],
  ['C4', 'E4', 'G4'],
]);

changes:

Note.enharmonicEquivalent

export declare function enharmonicEquivalent(note: string, pitchClass: string): string;

For my implementation of Voicing.get to work, I would also need a helper function that returns enharmonic equivalents. As this is a rather general purpose method, I would propose this as part of Note:

test('enharmonicEquivalent', () => {
  expect(Note.enharmonicEquivalent('F2', 'E#')).toBe('E#2');
  expect(Note.enharmonicEquivalent('B2', 'Cb')).toBe('Cb3');
  expect(Note.enharmonicEquivalent('C2', 'B#')).toBe('B#1');
});

edit: this feature is now covered by Note.enharmonic


That's it for a start. What do you think? UPDATE: Of course, I am open to suggestions of any sort!

UPDATE: changed some method names + param orderings (see changes under each section)

felixroos commented 3 years ago

Example usage for voicing multiple chords:

function sequence(chords, range?, dictionary?, voiceLeading?, lastVoicing?) {
  return chords.reduce(
    ({ voicings, lastVoicing }, chord) => {
      lastVoicing = Voicing.get(chord, range, dictionary, topNoteDiff, lastVoicing);
      voicings.push(lastVoicing);
      return { voicings, lastVoicing };
    },
    { voicings: [], lastVoicing }
  ).voicings;
}

const voicings = sequence(['C', 'F', 'G'], ['F3', 'A4'], triads, VoiceLeading.topNoteDiff);
/* [
  [ 'C4', 'E4', 'G4' ], // root position
  [ 'A3', 'C4', 'F4' ], // first inversion (F4 closest to G4)
  [ 'B3', 'D4', 'G4' ] // first inversion (G4 closest to F4)
] */

Maybe, sequence could also be a part of Voicing itself...

danigb commented 3 years ago

Hi @felixroos

I am stoked with your contribution ;-)

I did a quick read, but I think I'll need some time to digest and understand this well.

Anyway, some questions/thoughts arose in this first read:

felixroos commented 3 years ago

What lefthand exactly means?

From my understanding, lefthand voicings are piano voicings that can be played with one hand, which is mostly the left one as the right is commonly used to play a melody above. But you can also play them with the right hand and use the left for a bassline. Also, they rarely contain the root note, as the root is often played by a bass instrument (or the left hand if the right hand plays the "lefthand" voicing). Sometimes they are also called rootless voicings, but I think the term "rootless" is a little more open, where lefthand are mostly a relatively small set of voicings that are first taught to jazz piano beginners.

Some voicings, like the triads one, can be relatively easy to generate from the chord itself. I'm wondering if it would be possible to generate voicings instead of having a dictionary. Or more specifically: to have a dictionary of meta-voicings, how to distribute a given chord into a voicing. Some thing we can apply to the chords to generate all possible?/common? combinations.

Yeah, that was exactly my intention here. But this is a rather complicated topic.. Generally, a chord has a sort of hierarchy of note importance. As a rule of thumb the 3 and 7 are essential, while the rest is optional. Also, you can play as many or as little notes as you like. Plus, there are many "rules" to be aware of, like lower interval limits.. After a LOT of experimentation with the topic, I found that using a "combinatorial search" with flexible rules is a pretty good solution to generate any voicing.

I think Voicing.search and Voincing.get are not named consistenly with the rest of the library. I thing search should be namedget (and probably the opposite :-D). So something to discuss in #224

Yes, it's kind of inconsistent. I found it difficult to name the "search" method, as it starts with Voicing.* but the result contains mutiple VoicingS...

I think it's probably to better separate those things in different modules: voicing-dictionary, voicing, voice-leading, voicing-analyze (not sure about organisation or names, just an idea)

That could be done. On the other hand, I think there are not that many voice-leading algorithms out there. Also, I think the aim for voicing-dictionary should'nt be completeness, as this is just impossible...

felixroos commented 3 years ago

closing this, as #224 exists