diff --git a/packages/tonal/index.ts b/packages/tonal/index.ts index e3258744..ccd9be7b 100644 --- a/packages/tonal/index.ts +++ b/packages/tonal/index.ts @@ -17,6 +17,9 @@ import RomanNumeral from "@tonaljs/roman-numeral"; import Scale from "@tonaljs/scale"; import ScaleType from "@tonaljs/scale-type"; import TimeSignature from "@tonaljs/time-signature"; +import Voicing from "@tonaljs/voicing"; +import VoiceLeading from "@tonaljs/voice-leading"; +import VoicingDictionary from "@tonaljs/voicing-dictionary"; export * from "@tonaljs/core"; diff --git a/packages/tonal/package.json b/packages/tonal/package.json index 6ba577ec..8b3e55f9 100644 --- a/packages/tonal/package.json +++ b/packages/tonal/package.json @@ -36,7 +36,10 @@ "@tonaljs/roman-numeral": "^4.5.1", "@tonaljs/scale": "^4.5.1", "@tonaljs/scale-type": "^4.5.1", - "@tonaljs/time-signature": "^4.5.1" + "@tonaljs/time-signature": "^4.5.1", + "@tonaljs/voice-leading": "^4.5.1", + "@tonaljs/voicing-dictionary": "^4.5.1", + "@tonaljs/voicing": "^4.5.1" }, "author": "danigb@gmail.com", "license": "MIT", diff --git a/packages/voice-leading/LICENSE b/packages/voice-leading/LICENSE new file mode 100644 index 00000000..595645e3 --- /dev/null +++ b/packages/voice-leading/LICENSE @@ -0,0 +1,22 @@ +The MIT License (MIT) + +Copyright (c) 2020 felixroos + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + diff --git a/packages/voice-leading/README.md b/packages/voice-leading/README.md new file mode 100644 index 00000000..55a2065d --- /dev/null +++ b/packages/voice-leading/README.md @@ -0,0 +1,58 @@ +# @tonaljs/voice-leading + +Contains a collection functions to find optimal transitions between chord voicings. Used by [@tonaljs/voicings](../voicings). + +## Usage + +ES6: + +```js +import { VoiceLeading } from '@tonaljs/tonal'; +``` + +Nodejs: + +```js +const { VoiceLeading } = require('@tonaljs/tonal'); +``` + +## API + +### VoiceLeading + +```ts +declare type VoiceLeadingFunction = (voicings: string[][], lastVoicing: string[]) => string[]; +``` + +A function that decides which of a set of voicings is picked as a follow up to lastVoicing. + +Example: + +```ts +const topNoteDiff: VoiceLeadingFunction = (voicings, lastVoicing) => { + if (!lastVoicing || !lastVoicing.length) { + // if no lastVoicing is given + return voicings[0]; + } + const topNoteMidi = (voicing: string[]) => Note.midi(voicing[voicing.length - 1]) || 0; + const diff = (voicing: string[]) => Math.abs(topNoteMidi(lastVoicing) - topNoteMidi(voicing)); + return voicings.sort((a, b) => diff(a) - diff(b))[0]; // return voicing with least diff +}; +``` + +Usage + +```ts +topNoteDiff( + [ + ['F3', 'A3', 'C4', 'E4'], // top note = E4 + ['C4', 'E4', 'F4', 'A4'], // top note = A4 + ], + ['C4', 'E4', 'G4', 'B4'] // top note = B4 +); +// ['C4', 'E4', 'F4', 'A4'] // => A4 is closer to B4 than E4 +``` + +[show available voice leading functions](./index.ts). + +See [@tonaljs/voicings](../voicings) for usage examples. diff --git a/packages/voice-leading/index.ts b/packages/voice-leading/index.ts new file mode 100644 index 00000000..13195f73 --- /dev/null +++ b/packages/voice-leading/index.ts @@ -0,0 +1,22 @@ +import Note from "@tonaljs/note"; + +// A function that decides which of a set of voicings is picked as a follow up to lastVoicing. +export declare type VoiceLeadingFunction = ( + voicings: string[][], + lastVoicing: string[] +) => string[]; + +export const topNoteDiff: VoiceLeadingFunction = (voicings, lastVoicing) => { + if (!lastVoicing || !lastVoicing.length) { + return voicings[0]; + } + const topNoteMidi = (voicing: string[]) => + Note.midi(voicing[voicing.length - 1]) || 0; + const diff = (voicing: string[]) => + Math.abs(topNoteMidi(lastVoicing) - topNoteMidi(voicing)); + return voicings.sort((a, b) => diff(a) - diff(b))[0]; +}; + +export default { + topNoteDiff, +}; diff --git a/packages/voice-leading/package.json b/packages/voice-leading/package.json new file mode 100644 index 00000000..1f3947b1 --- /dev/null +++ b/packages/voice-leading/package.json @@ -0,0 +1,28 @@ +{ + "name": "@tonaljs/voice-leading", + "version": "4.5.1", + "description": "Voice leading logic for transitions between voicings", + "private": true, + "keywords": [ + "chord", + "voicing", + "voicings", + "music", + "theory", + "@tonaljs" + ], + "main": "dist/index.js", + "module": "dist/index.es.js", + "files": [ + "dist" + ], + "types": "dist/index.d.ts", + "dependencies": { + "@tonaljs/note": "^4.5.1" + }, + "author": "felix91@gmail.com", + "license": "MIT", + "publishConfig": { + "access": "public" + } +} diff --git a/packages/voice-leading/test.ts b/packages/voice-leading/test.ts new file mode 100644 index 00000000..d439dfd4 --- /dev/null +++ b/packages/voice-leading/test.ts @@ -0,0 +1,15 @@ +import { topNoteDiff } from "."; + +describe("VoiceLeading", () => { + test("topNoteDiff", () => { + expect( + topNoteDiff( + [ + ["F3", "A3", "C4", "E4"], + ["C4", "E4", "F4", "A4"], + ], + ["C4", "E4", "G4", "B4"] + ) + ).toEqual(["C4", "E4", "F4", "A4"]); + }); +}); diff --git a/packages/voicing-dictionary/LICENSE b/packages/voicing-dictionary/LICENSE new file mode 100644 index 00000000..595645e3 --- /dev/null +++ b/packages/voicing-dictionary/LICENSE @@ -0,0 +1,22 @@ +The MIT License (MIT) + +Copyright (c) 2020 felixroos + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + diff --git a/packages/voicing-dictionary/README.md b/packages/voicing-dictionary/README.md new file mode 100644 index 00000000..4cb7b804 --- /dev/null +++ b/packages/voicing-dictionary/README.md @@ -0,0 +1,44 @@ +# @tonaljs/voicing-dictionary + +Contains dictionaries for many chord voicings. Used by [@tonaljs/voicings](../voicings). + +## Usage + +ES6: + +```js +import { VoicingDictionary } from '@tonaljs/tonal'; +``` + +Nodejs: + +```js +const { VoicingDictionary } = require('@tonaljs/tonal'); +``` + +## API + +### VoicingDictionary + +Maps a chord symbol to a set of voicings: + +```ts +const lefthand = { + 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'], +}; +``` + +[show available dictionaries](./data.ts). + +See [@tonaljs/voicings](../voicings) for usage examples. diff --git a/packages/voicing-dictionary/data.ts b/packages/voicing-dictionary/data.ts new file mode 100644 index 00000000..5486aa75 --- /dev/null +++ b/packages/voicing-dictionary/data.ts @@ -0,0 +1,40 @@ +import { VoicingDictionary } from "."; + +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"], // b9 / b13 + "7b13": ["3M 6m 7m 9m", "7m 9m 10M 13m"], // b9 / b13 + 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"], +}; +export const all: 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"], + 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"], // b9 / b13 + "7b13": ["3M 6m 7m 9m", "7m 9m 10M 13m"], // b9 / b13 + 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"], +}; diff --git a/packages/voicing-dictionary/index.ts b/packages/voicing-dictionary/index.ts new file mode 100644 index 00000000..18dff7d4 --- /dev/null +++ b/packages/voicing-dictionary/index.ts @@ -0,0 +1,31 @@ +import Chord from "@tonaljs/chord"; +import { lefthand, all, triads } from "./data"; + +export declare type VoicingDictionary = { [symbol: string]: string[] }; + +const defaultDictionary: VoicingDictionary = lefthand; + +function lookup( + symbol: string, + dictionary = defaultDictionary +): string[] | undefined { + if (dictionary[symbol]) { + return dictionary[symbol]; + } + const { aliases } = Chord.get("C" + symbol); + // TODO: find other way to get aliases of symbol + const match = + Object.keys(dictionary).find((_symbol) => aliases.includes(_symbol)) || ""; + if (match !== undefined) { + return dictionary[match]; + } + return undefined; +} + +export default { + lookup, + lefthand, + triads, + all, + defaultDictionary, +}; diff --git a/packages/voicing-dictionary/package.json b/packages/voicing-dictionary/package.json new file mode 100644 index 00000000..4872b508 --- /dev/null +++ b/packages/voicing-dictionary/package.json @@ -0,0 +1,30 @@ +{ + "name": "@tonaljs/voicing-dictionary", + "version": "4.5.1", + "description": "Collections of chord voicings", + "private": true, + "keywords": [ + "chord", + "voicing", + "voicings", + "music", + "theory", + "@tonaljs" + ], + "main": "dist/index.js", + "module": "dist/index.es.js", + "files": [ + "dist" + ], + "types": "dist/index.d.ts", + "dependencies": { + "@tonaljs/note": "^4.5.1", + "@tonaljs/chord": "^4.5.1", + "@tonaljs/voice-leading": "^4.5.1" + }, + "author": "felix91@gmail.com", + "license": "MIT", + "publishConfig": { + "access": "public" + } +} diff --git a/packages/voicing-dictionary/test.ts b/packages/voicing-dictionary/test.ts new file mode 100644 index 00000000..50a0935b --- /dev/null +++ b/packages/voicing-dictionary/test.ts @@ -0,0 +1,19 @@ +import VoicingDictionary from "./index"; + +describe("lookup", () => { + test("lookup", () => { + expect(VoicingDictionary.lookup("M", VoicingDictionary.triads)).toEqual([ + "1P 3M 5P", + "3M 5P 8P", + "5P 8P 10M", + ]); + expect(VoicingDictionary.lookup("", VoicingDictionary.triads)).toEqual([ + "1P 3M 5P", + "3M 5P 8P", + "5P 8P 10M", + ]); + expect(VoicingDictionary.lookup("minor", { minor: ["1P 3m 5P"] })).toEqual([ + "1P 3m 5P", + ]); + }); +}); diff --git a/packages/voicing/LICENSE b/packages/voicing/LICENSE new file mode 100644 index 00000000..595645e3 --- /dev/null +++ b/packages/voicing/LICENSE @@ -0,0 +1,22 @@ +The MIT License (MIT) + +Copyright (c) 2020 felixroos + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + diff --git a/packages/voicing/README.md b/packages/voicing/README.md new file mode 100644 index 00000000..bd6f09fa --- /dev/null +++ b/packages/voicing/README.md @@ -0,0 +1,176 @@ +# @tonaljs/voicing + +Contains functions to generate voicings. If you're not sure what voicings are, [watch this video](https://www.youtube.com/watch?v=VR3o45Pwx9Y). + +## Usage + +ES6: + +```js +import { Voicing } from '@tonaljs/tonal'; +``` + +Nodejs: + +```js +const { Voicing } = require('@tonaljs/tonal'); +``` + +## API + +### Voicing.search + +```ts +Voicing.search(chord: string, range?: [string, string], dictionary?: VoicingDictionary): string[][] +``` + +This method returns all possible voicings of the given chord, inside the given range, as defined in the dictionary: + +```ts +Voicing.search('C^7', ['E3', 'D5'], { '^7': ['3M 5P 7M 9M', '7M 9M 10M 12P'] }); +/* => [ + ['E3', 'G3', 'B3', 'D4'], + ['E4', 'G4', 'B4', 'D5'], + ['B3', 'D4', 'E4', 'G4'], +] */ +``` + +The VoicingDictionary param uses the format of [@tonaljs/voicing-dictionary](../voicing-dictionary). Instead of defining your own, you could also use an existing dictionary from there: + +```ts +import { VoicingDictionary } from '@tonaljs/voicing-dictionary'; +Voicing.search('C^7', ['E3', 'D5'], VoicingDictionary.lefthand); +/* => [ + ['E3', 'G3', 'B3', 'D4'], + ['E4', 'G4', 'B4', 'D5'], + ['B3', 'D4', 'E4', 'G4'], +] */ +``` + +If no range and/or dictionary is given, there is a fallback to [default values](./index.ts) (look for defaultRange / defaultDictionary): + +```ts +Voicing.search('C^7'); +/* => [ + ['E3', 'G3', 'B3', 'D4'], + ['E4', 'G4', 'B4', 'D5'], + ['B3', 'D4', 'E4', 'G4'], +] */ +``` + +### Voicing.get + +```ts +Voicing.get( + chord: string, + range?: [string, number], + dictionary?: VoicingDictionary, + voiceLeading?: VoiceLeading, + lastVoicing?: string[] +) => string[]; +``` + +Returns the best voicing for **chord** inside the given **range**, as contained in the **dictionary**, using **voiceLeading** to decide which voicing to pick after **lastVoicing**. Internally calls Voicing.search to generate the available voicings: + +```ts +Voicing.get('Dm7'); +/* ['F3', 'A3', 'C4', 'E4']); */ +Voicing.get('Dm7', ['F3', 'A4'], lefthand, topNoteDiff); +/* ['F3', 'A3', 'C4', 'E4']; */ +const last = ['C4', 'E4', 'G4', 'B4']; +Voicing.get('Dm7', ['F3', 'A4'], lefthand, topNoteDiff, last); +/* ['C4', 'E4', 'F4', 'A4']; */ // => A4 is closest to B4 +``` + +## Optional: Voicing.analyze + +```ts +export declare function analyze( + voicing: string[] +): { + topNote: string; + bottomNote: string; + midiAverage: number; +}; +``` + +Returns some useful info on the given voicing: + +```ts +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 + +```ts +export declare function analyzeTransition( + from: string[], + to: string[] +): { + topNoteDiff: number; + bottomNoteDiff: number; + movement: number; +}; +``` + +Returns some useful info on the given voice transition + +```ts +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.intervalSets + +```ts +export declare function intervalSets(chordSymbol: string, dictionary: VoicingDictionary); +``` + +Get possible interval sets for given chord in given dictionary: + +```ts +expect(Voicing.intervalSets('M7', lefthand)).toEqual([['3M 5P 7M 9M', '7M 9M 10M 12P']]); +// could also be used with chord symbol (ignore root) +expect(Voicing.intervalSets('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. + +## Optional: Voicing.searchSets + +```ts +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: + +```ts +expect( + Voicing.searchSets( + [ + ['1P', '3M', '5P'], + ['3M', '5P', '8P'], + ], + ['C3', 'G4'], + 'C' + ) +).toEqual([ + ['C3', 'E3', 'G3'], + ['E3', 'G3', 'C4'], + ['C4', 'E4', 'G4'], +]); +``` + +changes: + +- renamed to searchSets (similar to Voicing.search) diff --git a/packages/voicing/index.ts b/packages/voicing/index.ts new file mode 100644 index 00000000..e6e3365f --- /dev/null +++ b/packages/voicing/index.ts @@ -0,0 +1,101 @@ +import Chord from "@tonaljs/chord"; +import Note from "@tonaljs/note"; +import Range from "@tonaljs/range"; +import Interval from "@tonaljs/interval"; +import VoicingDictionary from "@tonaljs/voicing-dictionary"; +import VoiceLeading from "@tonaljs/voice-leading"; + +const defaultRange = ["C3", "C5"]; +const defaultDictionary = VoicingDictionary.all; +const defaultVoiceLeading = VoiceLeading.topNoteDiff; + +function get( + chord: string, + range: string[] = defaultRange, + dictionary = defaultDictionary, + voiceLeading = defaultVoiceLeading, + lastVoicing?: string[] +) { + const voicings = search(chord, range, dictionary); + if (!lastVoicing || !lastVoicing.length) { + // notes = voicings[Math.ceil(voicings.length / 2)]; // pick middle voicing.. + return voicings[0]; // pick lowest voicing.. + } else { + // calculates the distance between the last note and the given voicings top note + // sort voicings with differ + return voiceLeading(voicings, lastVoicing); + } +} + +function search( + chord: string, + range = defaultRange, + dictionary = VoicingDictionary.triads +): string[][] { + const [tonic, symbol] = Chord.tokenize(chord); + const sets = VoicingDictionary.lookup(symbol, dictionary); + // find equivalent symbol that is used as a key in dictionary: + if (!sets) { + return []; + } + // resolve array of interval arrays for the wanted symbol + const voicings = sets.map((intervals) => intervals.split(" ")); + const notesInRange = Range.chromatic(range); // gives array of notes inside range + return voicings.reduce((voiced: string[][], voicing: string[]) => { + // transpose intervals relative to first interval (e.g. 3m 5P > 1P 3M) + const relativeIntervals = voicing.map( + (interval) => Interval.substract(interval, voicing[0]) || "" + ); + // get enharmonic correct pitch class the bottom note + const bottomPitchClass = Note.transpose(tonic, voicing[0]); + // get all possible start notes for voicing + const starts = notesInRange + // only get the start notes: + .filter((note) => Note.chroma(note) === Note.chroma(bottomPitchClass)) + // filter out start notes that will overshoot the top end of the range + .filter( + (note) => + (Note.midi( + Note.transpose( + note, + relativeIntervals[relativeIntervals.length - 1] + ) + ) || 0) <= (Note.midi(range[1]) || 0) + ) + // replace Range.chromatic notes with the correct enharmonic equivalents + .map((note) => Note.enharmonic(note, bottomPitchClass)); + // render one voicing for each start note + const notes = starts.map((start) => + relativeIntervals.map((interval) => Note.transpose(start, interval)) + ); + return voiced.concat(notes); + }, []); +} + +function sequence( + chords: string[], + range = defaultRange, + dictionary = defaultDictionary, + voiceLeading = defaultVoiceLeading, + lastVoicing?: string[] +) { + const { voicings } = chords.reduce<{ + voicings: string[][]; + lastVoicing: string[] | undefined; + }>( + ({ voicings, lastVoicing }, chord) => { + const voicing = get(chord, range, dictionary, voiceLeading, lastVoicing); + lastVoicing = voicing; + voicings.push(voicing); + return { voicings, lastVoicing }; + }, + { voicings: [], lastVoicing } + ); + return voicings; +} + +export default { + get, + search, + sequence, +}; diff --git a/packages/voicing/package.json b/packages/voicing/package.json new file mode 100644 index 00000000..eec066ef --- /dev/null +++ b/packages/voicing/package.json @@ -0,0 +1,33 @@ +{ + "name": "@tonaljs/voicing", + "version": "4.5.1", + "description": "Voicings and Voice Leading for Chords", + "private": true, + "keywords": [ + "chord", + "voicing", + "voicings", + "music", + "theory", + "@tonaljs" + ], + "main": "dist/index.js", + "module": "dist/index.es.js", + "files": [ + "dist" + ], + "types": "dist/index.d.ts", + "dependencies": { + "@tonaljs/chord": "^4.5.1", + "@tonaljs/note": "^4.5.1", + "@tonaljs/range": "^4.5.1", + "@tonaljs/interval": "^4.5.1", + "@tonaljs/voicing-dictionary": "^4.5.1", + "@tonaljs/voice-leading": "^4.5.1" + }, + "author": "felix91@gmail.com", + "license": "MIT", + "publishConfig": { + "access": "public" + } +} diff --git a/packages/voicing/test.ts b/packages/voicing/test.ts new file mode 100644 index 00000000..9b5aab24 --- /dev/null +++ b/packages/voicing/test.ts @@ -0,0 +1,70 @@ +import VoicingDictionary from "@tonaljs/voicing-dictionary"; +import { topNoteDiff } from "@tonaljs/voice-leading"; +import Voicing from "./index"; + +const { lefthand, triads } = VoicingDictionary; + +describe("search", () => { + test("C major triad inversions", () => { + expect(Voicing.search("C", ["C3", "C5"], triads)).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", ["Eb3", "D5"], { + minor7: ["3m 5P 7m 9M", "7m 9M 10m 12P"], + }) + ).toEqual([ + ["Eb3", "G3", "Bb3", "D4"], + ["Eb4", "G4", "Bb4", "D5"], + ["Bb3", "D4", "Eb4", "G4"], + ]); + }); +}); + +describe("get", () => { + test("get", () => { + // 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"]); + }); +}); + +test("sequence", () => { + expect( + Voicing.sequence(["C", "F", "G"], ["F3", "A4"], triads, topNoteDiff) + ).toEqual([ + ["C4", "E4", "G4"], // root position + ["A3", "C4", "F4"], // first inversion (F4 closest to G4) + ["B3", "D4", "G4"], // first inversion (G4 closest to F4) + ]); +});