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).