diff --git a/packages/chord-mark/src/parser/getAllKeysInSong.js b/packages/chord-mark/src/parser/getAllKeysInSong.js index b1b7897c..bc4148fb 100644 --- a/packages/chord-mark/src/parser/getAllKeysInSong.js +++ b/packages/chord-mark/src/parser/getAllKeysInSong.js @@ -14,16 +14,18 @@ export default function getAllKeysInSong(allLines, allChords) { explicit: [], }; - const autoDetectedKey = guessKey(allChords); - if (autoDetectedKey) { - allKeys.auto = autoDetectedKey; - } - allLines.forEach((line) => { if (line.type === lineTypes.KEY_DECLARATION) { allKeys.explicit.push(_cloneDeep(line.model)); } }); + if (allKeys.explicit.length === 0) { + const autoDetectedKey = guessKey(allChords); + if (autoDetectedKey) { + allKeys.auto = autoDetectedKey; + } + } + return allKeys; } diff --git a/packages/chord-mark/src/parser/helper/keyHelpers.js b/packages/chord-mark/src/parser/helper/keyHelpers.js index 10faae43..f1c43446 100644 --- a/packages/chord-mark/src/parser/helper/keyHelpers.js +++ b/packages/chord-mark/src/parser/helper/keyHelpers.js @@ -226,3 +226,32 @@ function chord2Key(chord) { return keyString; } + +/** + * Return the number of semitones between two keys notes + * @param {string} key1 + * @param {string} key2 + * @returns {Number} + */ +export function getSemitonesBetweenKeys(key1, key2) { + if (!key1 || !key2) return 0; + + return getSemitonesBetweenNotes( + key1.replace('m', ''), + key2.replace('m', '') + ); +} + +function getSemitonesBetweenNotes(note1, note2) { + const noteSharp1 = flatsToSharps[note1] || note1; + const noteSharp2 = flatsToSharps[note2] || note2; + + const indexNote1 = allNotesSharp.indexOf(noteSharp1); + const indexNote2 = allNotesSharp.indexOf(noteSharp2); + + if (indexNote1 === -1 || indexNote2 === -1) return 0; + + const semitones = indexNote2 - indexNote1; + + return semitones < 0 ? semitones + 12 : semitones; +} diff --git a/packages/chord-mark/src/renderer/components/renderSong.js b/packages/chord-mark/src/renderer/components/renderSong.js index fcc45c8a..b462ceb9 100644 --- a/packages/chord-mark/src/renderer/components/renderSong.js +++ b/packages/chord-mark/src/renderer/components/renderSong.js @@ -13,11 +13,8 @@ import renderSectionLabelLine from './renderSectionLabel'; import renderTimeSignature from './renderTimeSignature'; import songTpl from './tpl/song.js'; -import getChordSymbol from '../helpers/getChordSymbol'; import renderAllSectionsLabels from '../helpers/renderAllSectionLabels'; -import { transposeKey } from '../../parser/helper/keyHelpers'; - -import { chordRendererFactory } from 'chord-symbol'; +import renderAllChords from '../helpers/renderAllChords'; import lineTypes from '../../parser/lineTypes'; import replaceRepeatedBars from '../replaceRepeatedBars'; @@ -78,19 +75,13 @@ export default function renderSong( let contextTimeSignature = defaultTimeSignature.string; let previousBarTimeSignature; - let currentKey; - - if (allKeys.auto) { - currentKey = transposeKey( - allKeys.auto, - transposeValue, - accidentalsType - ); - } - let renderChord = getChordSymbolRenderer(); - - allLines = allLines - .map(renderChords) + allLines = renderAllChords(allLines, allKeys.auto, { + transposeValue, + accidentalsType, + chordSymbolRenderer, + simplifyChords, + useShortNamings, + }) .map(addPrintChordsDurationsFlag) .map(addPrintBarTimeSignatureFlag) .filter(shouldRenderLine) @@ -120,45 +111,6 @@ export default function renderSong( return songTpl({ song: allRenderedLines.join('') }); } - function renderChords(line) { - if (line.type === lineTypes.KEY_DECLARATION) { - currentKey = transposeKey( - line.model, - transposeValue, - accidentalsType - ); - - renderChord = getChordSymbolRenderer(); - line.symbol = currentKey.string; - } else if (line.type === lineTypes.CHORD) { - line.model.allBars.forEach((bar) => { - bar.allChords.forEach((chord) => { - chord.symbol = getChordSymbol(chord.model, renderChord); - }); - }); - } - return line; - } - - function getChordSymbolRenderer() { - if (typeof chordSymbolRenderer === 'function') { - return chordSymbolRenderer; - } - const accidental = - accidentalsType === 'auto' - ? currentKey - ? currentKey.accidental - : 'sharp' - : accidentalsType; - - return chordRendererFactory({ - simplify: simplifyChords, - useShortNamings, - transposeValue, - accidental, - }); - } - function getSectionWrapperClasses(line) { return [ 'cmSection', diff --git a/packages/chord-mark/src/renderer/helpers/renderAllChords.js b/packages/chord-mark/src/renderer/helpers/renderAllChords.js new file mode 100644 index 00000000..0181615d --- /dev/null +++ b/packages/chord-mark/src/renderer/helpers/renderAllChords.js @@ -0,0 +1,90 @@ +import { chordRendererFactory } from 'chord-symbol'; +import getChordSymbol from '../helpers/getChordSymbol'; + +import lineTypes from '../../parser/lineTypes'; +import { + transposeKey, + getSemitonesBetweenKeys, +} from '../../parser/helper/keyHelpers'; + +// eslint-disable-next-line max-lines-per-function +export default function renderAllChords( + allLines, + detectedKey, + { + transposeValue, + accidentalsType, + chordSymbolRenderer, + simplifyChords, + useShortNamings, + } +) { + let currentKey; + let baseKey; + + if (detectedKey) { + currentKey = transposeKey(detectedKey, transposeValue, accidentalsType); + } + + let renderChord = getChordSymbolRenderer(); + + function renderChords(line) { + if (line.type === lineTypes.KEY_DECLARATION) { + currentKey = transposeKey( + line.model, + transposeValue, + accidentalsType + ); + line.symbol = currentKey.string; + + if (!baseKey) { + baseKey = currentKey; + } + } else if (line.type === lineTypes.CHORD) { + let transposeOffSet = 0; + if (shouldTransposeRepeatedChords(line)) { + transposeOffSet = getSemitonesBetweenKeys( + baseKey && baseKey.string, + currentKey && currentKey.string + ); + } + renderChord = getChordSymbolRenderer(transposeOffSet); + + line.model.allBars.forEach((bar) => { + bar.allChords.forEach((chord) => { + chord.symbol = getChordSymbol(chord.model, renderChord); + }); + }); + } + return line; + } + + function shouldTransposeRepeatedChords(line) { + return ( + line.isFromAutoRepeatChords || + line.isFromSectionCopy || + line.isFromChordLineRepeater + ); + } + + function getChordSymbolRenderer(transposeOffSet) { + if (typeof chordSymbolRenderer === 'function') { + return chordSymbolRenderer; + } + const accidental = + accidentalsType === 'auto' + ? currentKey + ? currentKey.accidental + : 'sharp' + : accidentalsType; + + return chordRendererFactory({ + simplify: simplifyChords, + useShortNamings, + transposeValue: transposeValue + transposeOffSet, + accidental, + }); + } + + return allLines.map(renderChords); +} diff --git a/packages/chord-mark/tests/integration/parser/parseSong.spec.js b/packages/chord-mark/tests/integration/parser/parseSong.spec.js index 5adf3128..91c54b7d 100644 --- a/packages/chord-mark/tests/integration/parser/parseSong.spec.js +++ b/packages/chord-mark/tests/integration/parser/parseSong.spec.js @@ -321,10 +321,7 @@ key Am`; }, ], allKeys: { - auto: { - string: 'C', - accidental: 'flat', - }, + auto: undefined, explicit: [ { string: 'G', diff --git a/packages/chord-mark/tests/integration/renderer/components/renderSong/data/song5-input.txt b/packages/chord-mark/tests/integration/renderer/components/renderSong/data/song5-input.txt new file mode 100644 index 00000000..9ae2af0c --- /dev/null +++ b/packages/chord-mark/tests/integration/renderer/components/renderSong/data/song5-input.txt @@ -0,0 +1,33 @@ +key C + +#v +C % F G +my 1st verse + +#c +Dm G7 C % +my 1st chorus + +key D + +#v +my 2nd verse + +key E + +#v +my 3rd verse + +key Bb + +#v + +#o +key G + +#c +my second chorus + +#b +% +%% diff --git a/packages/chord-mark/tests/integration/renderer/components/renderSong/data/song5-output-key-transposition.txt b/packages/chord-mark/tests/integration/renderer/components/renderSong/data/song5-output-key-transposition.txt new file mode 100644 index 00000000..4a8f92bd --- /dev/null +++ b/packages/chord-mark/tests/integration/renderer/components/renderSong/data/song5-output-key-transposition.txt @@ -0,0 +1,38 @@ +key: C + +Verse 1 +|C |% |F |G | +my 1st verse + +Chorus 1 +|Dm |G7 |C |% | +my 1st chorus + +key: D + +Verse 2 +|D |% |G |A | +my 2nd verse + +key: E + +Verse 3 +|E |% |A |B | +my 3rd verse + +key: Bb + +Verse 4 +|Bb |% |Eb |F | +my 1st verse + +Outro +key: G + +Chorus 2 +|Am |D7 |G |% | +my second chorus + +Bridge +|Am |D7 |G |% | +|G |% |C |D | diff --git a/packages/chord-mark/tests/integration/renderer/components/renderSong/renderSong.spec.js b/packages/chord-mark/tests/integration/renderer/components/renderSong/renderSong.spec.js index 3333509e..1a67f293 100644 --- a/packages/chord-mark/tests/integration/renderer/components/renderSong/renderSong.spec.js +++ b/packages/chord-mark/tests/integration/renderer/components/renderSong/renderSong.spec.js @@ -124,6 +124,11 @@ describe.each([ 'song4-output-no-inline-time-signatures.txt', { printInlineTimeSignatures: false }, ], + [ + 'chord transposition based on key declaration', + 'song5-input.txt', + 'song5-output-key-transposition.txt', + ], ])('Render components: %s', (title, inputFile, outputFile, options) => { test('produces expected rendering', () => { const input = removeLastLine( diff --git a/packages/chord-mark/tests/unit/parser/getAllKeysInSong.spec.js b/packages/chord-mark/tests/unit/parser/getAllKeysInSong.spec.js index 9ca82fc8..959132ab 100644 --- a/packages/chord-mark/tests/unit/parser/getAllKeysInSong.spec.js +++ b/packages/chord-mark/tests/unit/parser/getAllKeysInSong.spec.js @@ -46,10 +46,7 @@ describe('getAllKeysInSong()', () => { const allKeys = getAllKeysInSong(allLines, allChords); const expectedAllKeys = { - auto: { - string: 'C', - accidental: 'flat', - }, + auto: undefined, // detection is disabled as soon as an explicit key is declared explicit: [ { string: 'C', diff --git a/packages/chord-mark/tests/unit/parser/helpers/keyHelpers.spec.js b/packages/chord-mark/tests/unit/parser/helpers/keyHelpers.spec.js index 66b94d17..093c684a 100644 --- a/packages/chord-mark/tests/unit/parser/helpers/keyHelpers.spec.js +++ b/packages/chord-mark/tests/unit/parser/helpers/keyHelpers.spec.js @@ -2,6 +2,7 @@ import { getKeyAccidental, transposeKey, guessKey, + getSemitonesBetweenKeys, } from '../../../../src/parser/helper/keyHelpers'; import parseSong from '../../../../src/parser/parseSong'; @@ -10,6 +11,7 @@ describe('keyHelpers', () => { expect(typeof getKeyAccidental).toBe('function'); expect(typeof transposeKey).toBe('function'); expect(typeof guessKey).toBe('function'); + expect(typeof getSemitonesBetweenKeys).toBe('function'); }); }); @@ -220,3 +222,32 @@ describe.each([ expect(guessKey(parsed.allChords)).toBeUndefined(); }); }); + +describe('getSemitonesBetweenKeys', () => { + describe.each([ + [undefined, undefined, 0], + ['C', undefined, 0], + [undefined, 'D', 0], + + ['C', 'C', 0], + ['C', 'C#', 1], + ['C', 'Db', 1], + ['C', 'G#', 8], + ['C', 'Ab', 8], + ['C', 'A', 9], + ['C', 'B', 11], + + ['C', 'Cm', 0], + ['Cm', 'C', 0], + ['C', 'Dbm', 1], + ['C', 'G#m', 8], + + ['C', 'B#', 0], + ['B#', 'A', 0], + ['B#', 'E#', 0], + ])('getSemitonesBetweenKeys(%s, %s) => %s', (key1, key2, semitones) => { + test('correctly returns semitones between keys', () => { + expect(getSemitonesBetweenKeys(key1, key2)).toBe(semitones); + }); + }); +}); diff --git a/packages/chord-mark/tests/unit/renderer/components/renderSong.spec.js b/packages/chord-mark/tests/unit/renderer/components/renderSong.spec.js index 18c19442..cfb45b96 100644 --- a/packages/chord-mark/tests/unit/renderer/components/renderSong.spec.js +++ b/packages/chord-mark/tests/unit/renderer/components/renderSong.spec.js @@ -1014,6 +1014,58 @@ describe('Keys, accidental & transpose', () => { '|Cm7 |G7 |Db |', { accidentalsType: 'auto', transposeValue: -1 }, ], + [ + 'Transpose repeated section chords', + 'key C\n' + + '#v\n' + + 'Dm7 G7 C %\n' + + 'The first verse is in the key of C\n' + + 'key G\n' + + '#v\n' + + 'And the second one in the key of G!\n', + 'key: C\n' + + 'Verse 1\n' + + '|Dm7 |G7 |C |% |\n' + + 'The first verse is in the key of C\n' + + 'key: G\n' + + 'Verse 2\n' + + '|Am7 |D7 |G |% |\n' + + 'And the second one in the key of G!\n', + ], + [ + 'Transpose repeated chord lines', + 'key C\n' + + 'Dm7 G7 C %\n' + + 'G7 % C %\n' + + 'key G\n' + + '%%\n' + + '%\n', + 'key: C\n' + + '|Dm7 |G7 |C |% |\n' + + '|G7 |% |C |% |\n' + + 'key: G\n' + + '|Am7 |D7 |G |% |\n' + + '|D7 |% |G |% |\n', + ], + [ + 'Transpose repeated sections', + 'key C\n' + + '#v\n' + + 'Dm7 G7 C %\n' + + 'myVerse\n' + + '#b\n' + + 'key G\n' + + '#v\n', + 'key: C\n' + + 'Verse 1\n' + + '|Dm7 |G7 |C |% |\n' + + 'myVerse\n' + + 'Bridge\n' + + 'key: G\n' + + 'Verse 2\n' + + '|Am7 |D7 |G |% |\n' + + 'myVerse\n', + ], ])('%s', (title, song, expected, options = {}) => { test('renders with correct accidental', () => { const rendered = renderSongText(song, { diff --git a/packages/documentation/docs/reference/keys.mdx b/packages/documentation/docs/reference/keys.mdx index 8e850e17..a6ac1111 100644 --- a/packages/documentation/docs/reference/keys.mdx +++ b/packages/documentation/docs/reference/keys.mdx @@ -9,11 +9,26 @@ It is possible to declare the key of any part of the song using they `key` keywo -There are at least two reasons why you might want to do this. +Appart from bringing clarity to the chord chart's reader, which is always a good thing, declaring one or multiple keys will bring at least 3 benefits. -### Reason 1: proper rendering of accidentals +### 1/ Automatic transposition -The first reason relates to the rendering of chord accidentals (`#` and `b`). If your source file contains mixed accidental, then declaring a key will allow to automatically normalize the accidentals choosing the proper symbol +The first benefit is - in my opinion - one of the coolest features of ! +Whenever there is a key change during a song, and when using automatic repetition of chords accross sections, will automatically transpose the repeated chords based on the new key declaration. +See the example below: + + + +The base key from which the transposition is done is always the first one explicitely declared, so make sure to always define a key prior to the first chord line. + +### 2/ Proper rendering of accidentals + +The second benefit relates to the rendering of chord accidentals (`#` and `b`). +If your source file contains mixed accidental, then declaring a key will allow to automatically normalize the accidentals choosing the proper symbol -### Reason 2: harmonic analysis +### 3/ Harmonic analysis -The second reason is if you want to help with harmonic analysis by rendering the chord symbols as [roman numeral symbols](https://en.wikipedia.org/wiki/Roman_numeral_analysis). +The third benefit of using keys lies in abilities to help with harmonic analysis by rendering the chord symbols as [roman numeral symbols](https://en.wikipedia.org/wiki/Roman_numeral_analysis). This can only be done if a key is explicitly set, otherwise all symbols will be rendered as `I`, `i` or `?`. The approach to convert chords from notes to numerals symbols is very basic and naive: based on the declared key, will just detect if the song's chord are diatonic to the key, or diatonic to the parallel major/minor key (e.g. "borrowed" chords).