abc-notation-transposition
Version:
A robust utility for transposing ABC Notation by half steps.
332 lines (296 loc) • 14.2 kB
JavaScript
const {
ACCIDENTAL_NUMBER_PREFERENCES,
SHARPS_OR_FLATS_PREFERENCES,
KEYS,
REGULAR_EXPRESSIONS,
ERROR_MESSAGES
} = require('../constants');
const {ImproperlyFormattedABCNotationError} = require('../classes');
const {transposeKey} = require('./transpose-key');
const {transposePitchByKey} = require('./transpose-pitch-by-key');
const {transposePitchChromatically} = require('./tranpose-pitch-chromatically');
const {includesIgnoreCase, trimNewLines} = require('./string-utils');
const transposeABC = function (abcTune, halfSteps, opts = {
accidentalNumberPreference: ACCIDENTAL_NUMBER_PREFERENCES.PREFER_FEWER,
preferSharpsOrFlats: SHARPS_OR_FLATS_PREFERENCES.PRESERVE_ORIGINAL
}) {
validateInput(abcTune, halfSteps, opts);
const [tuneHead, tuneKeyField, tuneBody] = splitHeadKeyAndBody(abcTune);
const originalKeyStr = getKeyStrFromField(tuneKeyField);
//originalkey returns the original keyStr and undefined if the key is HP, Hp, none or ''
const [originalKey, originalMode] = getKeyObjectAndMode(originalKeyStr);
//returns the key if the original key is HP, Hp, none or ''
const transposedKey = transposeKey(originalKeyStr, halfSteps, opts);
//voice descriptions may be in header
//clef can be described either in the key or in the voice fields
const defaultClef = getClef(tuneKeyField) || 'treble';
const voicesNamesAndClefs = getVoiceNamesAndClefs(tuneHead);
//this will take in a voices object. each voice can have a starting clef
const voices = groupVoices(tuneBody);
const transposedVoices = transposeVoices(voices, originalKey, originalMode, transposedKey, voicesNamesAndClefs, defaultClef, halfSteps, opts);
let sortedVoices = [];
for(let voiceName in transposedVoices) {
const voice = transposedVoices[voiceName];
for(let voiceLineObj of voice) {
sortedVoices.push(voiceLineObj);
}
}
sortedVoices.sort((a,b) => {
return a.originalLine - b.originalLine
});
sortedVoices = sortedVoices.filter(voiceLineObj => {
return voiceLineObj.abcNotation !== '' && voiceLineObj.abcNotation !== '\n'
}).map(voiceLineObj => {
voiceLineObj.abcNotation = trimNewLines(voiceLineObj.abcNotation);
return voiceLineObj.abcNotation;
});
const transposedTuneBody = sortedVoices.join('\n');
let newHeadKeyStr = 'K:';
if(typeof transposedKey === 'string') {
newHeadKeyStr += transposedKey;
} else {
newHeadKeyStr += transposedKey[originalMode] + originalMode;
}
newHeadKeyStr += getInstructionsFromKeyField(tuneKeyField);
newHeadKeyStr += '\n';
return tuneHead + newHeadKeyStr + transposedTuneBody;
}
function validateInput(abcTune, halfSteps, opts) {
if(typeof abcTune !== 'string') throw new TypeError(ERROR_MESSAGES.ABC_NOTATION_TYPE_MISMATCH + typeof abcTune);
if(typeof halfSteps !== 'number') throw new TypeError(ERROR_MESSAGES.HALF_STEPS_TYPE_MISMATCH + typeof halfSteps);
if(!Number.isInteger(halfSteps)) throw new TypeError(ERROR_MESSAGES.HALF_STEPS_TYPE_MISMATCH + 'floating point');
if(typeof opts !== 'object') throw new TypeError(ERROR_MESSAGES.OPTS_OBJECT_TYPE_MISMATCH);
if(!('accidentalNumberPreference' in opts) || !('preferSharpsOrFlats' in opts)) {
throw new TypeError(ERROR_MESSAGES.OPTS_OBJECT_TYPE_MISMATCH);
}
if(
typeof opts.accidentalNumberPreference !== 'number' ||
typeof opts.preferSharpsOrFlats !== 'number'
) throw new TypeError(ERROR_MESSAGES.OPTS_FIELD_TYPE_MISMATCH + typeof opts.accidentalNumberPreference + ' and ' + typeof opts.preferSharpsOrFlats);
const accidentalNumberPreferenceNumType = Number.isInteger(opts.accidentalNumberPreference) ? 'integer' : 'non-integer';
const preferSharpsOrFlatsNumType = Number.isInteger(opts.preferSharpsOrFlats) ? 'integer' : 'non-integer';
if(accidentalNumberPreferenceNumType !== 'integer' || preferSharpsOrFlatsNumType !== 'integer') {
throw new TypeError(ERROR_MESSAGES.OPTS_FIELD_TYPE_MISMATCH + accidentalNumberPreferenceNumType + ' and ' + preferSharpsOrFlatsNumType);
}
if(
opts.accidentalNumberPreference < 0 ||
opts.accidentalNumberPreference > 2 ||
opts.preferSharpsOrFlats < 0 ||
opts.preferSharpsOrFlats > 2
) throw new RangeError(ERROR_MESSAGES.OPTS_FIELD_TYPE_MISMATCH + opts.accidentalNumberPreference + ' and ' + opts.preferSharpsOrFlats);
return;
}
function splitHeadKeyAndBody(abcTune) {
const headerKeyAndBody = abcTune.split(REGULAR_EXPRESSIONS.KEY_FIELD);
if(headerKeyAndBody.length < 3 || headerKeyAndBody[0] === '') {
throw new ImproperlyFormattedABCNotationError(ERROR_MESSAGES.UNABLE_TO_SPLIT_HEAD_KEY_AND_BODY);
}
const header = headerKeyAndBody[0];
const key = headerKeyAndBody[1];
const body = headerKeyAndBody.slice(2).join('');
return [header, key, body];
}
function getKeyStrFromField(field) {
let key = '';
const keyLetterMatches = field.match(REGULAR_EXPRESSIONS.KEY_SIGNATURES);
if(keyLetterMatches) {
key += keyLetterMatches[0];
if(key != 'none' && key != 'HP' && key !== 'Hp') {
//first replace 'middle' so as not to confuse the modes into matching with minor
field = field.replace(REGULAR_EXPRESSIONS.MIDDLE, "");
//then match with modes
const modeMatches = field.match(REGULAR_EXPRESSIONS.MODES);
if(modeMatches && !modeMatches[0].match(REGULAR_EXPRESSIONS.MAJOR_MODE)) { //ignores major / maj
key += modeMatches[0];
}
}
}
return key;
}
function getKeyObjectAndMode(keyStr) {
//return the keyStr and undefined for the mode if the key is one of the following:
//HP or Hp - bagpipe keys. bagpipe music can only be written in a few specific keys, so these will not be transposed.
//none or an empty string - atonal music. transposeVoices needs to know to transpose these voices chromatically, but the mode is undefined.
if(keyStr ==='HP' || keyStr === 'Hp' || keyStr === 'none' || keyStr === '') {
return [keyStr, undefined];
}
//first find the key in the table
let letter = keyStr.replace(REGULAR_EXPRESSIONS.MODES, "");
let mode = 'major';
if(includesIgnoreCase(keyStr,'dor')) {
mode = 'dorian';
} else if(includesIgnoreCase(keyStr, 'phr')) {
mode = 'phrygian';
} else if(includesIgnoreCase(keyStr, 'mix')) {
mode = 'mixolydian';
} else if(includesIgnoreCase(keyStr, 'lyd')) {
mode = 'lydian';
} else if(includesIgnoreCase(keyStr, 'm')) {
mode = 'minor';
} else if(includesIgnoreCase(keyStr, 'aeo')) {
mode = 'aeolian'
} else if(includesIgnoreCase(keyStr, 'loc')) {
mode = 'locrian';
}
const keyObj = KEYS.find(keyPair => {
return (keyPair.findIndex((key) => {
return key[mode] === letter;
}) != -1);
});
if(!keyObj) throw new ImproperlyFormattedABCNotationError(`Key signature ${keyStr} not found in keys. Please check your ABC notation to ensure it contains a valid key.`);
const key = keyObj.find(key => key[mode] === letter);
return [key, mode];
}
function getClef(field) {
const clefMatches = field.match(REGULAR_EXPRESSIONS.CLEFS);
if(!clefMatches) return null;
else {
const clef = clefMatches[0].replace('clef=', '').trim();
return clef;
}
}
function getVoiceNamesAndClefs(tuneHead) {
const voiceFieldMatches = tuneHead.match(REGULAR_EXPRESSIONS.VOICE_FIELD);
if(!voiceFieldMatches) return {};
else {
const voiceObjects = {};
voiceFieldMatches.forEach((voiceField) => {
const voiceName = getVoiceName(voiceField);
const clef = getClef(voiceField);
if(voiceName && clef) {
voiceObjects[voiceName] = clef;
}
});
return voiceObjects;
}
}
function getInstructionsFromKeyField(keyField) {
const instructions = keyField.match(REGULAR_EXPRESSIONS.KEY_FIELD_INSTRUCTIONS);
if(!instructions) return '';
else return ' ' + instructions.join(' ');
}
function groupVoices(tuneBody) {
let voices = {};
const tuneLines = tuneBody.split('\n');
const tuneLinesSplitAtVoices = [];
tuneLines.forEach(line => {
if(line.includes('[V:')) {
line.split(/(\[V:[^\]]+\])/).forEach(split => tuneLinesSplitAtVoices.push(split));
} else tuneLinesSplitAtVoices.push(line);
});
let currentVoiceName = '';
for(let i = 0; i < tuneLinesSplitAtVoices.length; i++) {
const currentLine = tuneLinesSplitAtVoices[i];
const newVoiceName = getVoiceName(currentLine);
if(newVoiceName) currentVoiceName = newVoiceName;
if(!voices[currentVoiceName]) voices[currentVoiceName] = [];
voices[currentVoiceName].push({
originalLine: i,
abcNotation: currentLine
});
}
return voices;
}
function getVoiceName(voiceLine) {
const voiceFieldMatches = voiceLine.match(REGULAR_EXPRESSIONS.VOICE_NAME);
if(!voiceFieldMatches) return null;
const voiceField = voiceFieldMatches[0];
const voiceName = voiceField.split(":")[1].trim();
return voiceName;
}
function transposeVoices(voices, originalStartingKey, originalMode, transposedStartingKey, voiceNamesAndClefs, defaultClef, halfSteps, opts) {
const transposedVoices = {};
Object.keys(voices).forEach(voiceName => {
const voiceState = {
originalKey : originalStartingKey,
mode : originalMode,
transposedKey : transposedStartingKey,
originalAccidentals : undefined,
transposedAccidentals : undefined,
clef : voiceNamesAndClefs[voiceName] || defaultClef
}
resetAccidentals(voiceState);
transposedVoices[voiceName] = voices[voiceName].map(voiceLineObj => {
const voiceLine = voiceLineObj.abcNotation;
const transposedLine = transposeVoiceLine(voiceLine, voiceState, halfSteps, opts)
return {
...voiceLineObj,
abcNotation: transposedLine
}
});
});
return transposedVoices;
}
function resetAccidentals(voiceState) {
if(voiceState.originalKey === 'none' || voiceState.originalKey === '' || voiceState.originalKey === 'HP' || voiceState.originalKey === 'Hp') {
voiceState.originalAccidentals = {A: "=", B: "=", C: "=", D: "=", E: "=", F:"=", G:"="};
voiceState.transposedAccidentals = {A: "=", B: "=", C: "=", D: "=", E: "=", F:"=", G:"="};
}
else {
voiceState.originalAccidentals = Object.assign({}, voiceState.originalKey.keySig);
voiceState.transposedAccidentals = Object.assign({}, voiceState.transposedKey.keySig);
}
}
//probably need current clef too
function transposeVoiceLine(voiceLine, voiceState, halfSteps, opts) {
//do not attempt to transpose if the voice line is an empty string, if the line is a continuation of a field (starts with +),
//if the line is a comment or directive (starts with one or more %), or if the line is a field line
if(!voiceLine.length) return voiceLine;
if(voiceLine.startsWith("+") || voiceLine.startsWith("%")) return voiceLine;
return voiceLine.replace(REGULAR_EXPRESSIONS.FIELD_COMMENT_SYMBOL_NEW_MEASURE_OR_NOTE, str => {
if(str.match(REGULAR_EXPRESSIONS.COMMENT_OR_SYMBOL)) {
return str;
} else if(str.match(REGULAR_EXPRESSIONS.FIELD)) {
if(str[0] === 'V' || str[1] === 'V') {
checkForNewClefAndUpdateState(str, voiceState);
return str;
} else if(str[0] === 'K' || str[1] === 'K') {
checkForNewClefAndUpdateState(str, voiceState);
return handleKeyChange(str, voiceState, halfSteps, opts);
} else return str;
} else if(str.match(REGULAR_EXPRESSIONS.NEW_MEASURE)) {
resetAccidentals(voiceState);
return str;
} else if(voiceState.originalKey === 'HP' || voiceState.originalKey === 'Hp' || voiceState.clef === 'perc') {
return str;
} else if(voiceState.originalKey === 'none' || voiceState.originalKey === '') {
return transposePitchChromatically(str, voiceState, halfSteps);
} else {
return transposePitchByKey(str, voiceState, halfSteps);
}
});
}
function checkForNewClefAndUpdateState(str, voiceState) {
const clef = getClef(str);
if(clef) {
voiceState.clef = clef;
}
}
function handleKeyChange(str, voiceState, halfSteps, opts) {
const keyInstructions = getInstructionsFromKeyField(str);
const keyStr = getKeyStrFromField(str);
[voiceState.originalKey, voiceState.mode] = getKeyObjectAndMode(keyStr);
voiceState.transposedKey = transposeKey(keyStr, halfSteps, opts);
resetAccidentals(voiceState);
let currentTransposedKeyStr;
if(typeof voiceState.transposedKey === 'string') currentTransposedKeyStr = voiceState.transposedKey;
else currentTransposedKeyStr = voiceState.transposedKey[voiceState.mode] + voiceState.mode;
let newKeyField = "K:" + currentTransposedKeyStr + keyInstructions;
if(str[0] === '[') newKeyField = '[' + newKeyField + ']';
return newKeyField;
}
module.exports.transposeABC = transposeABC;
module.exports.validateInput = validateInput;
module.exports.splitHeadKeyAndBody = splitHeadKeyAndBody;
module.exports.getKeyStrFromField = getKeyStrFromField;
module.exports.getKeyObjectAndMode = getKeyObjectAndMode;
module.exports.getClef = getClef;
module.exports.getVoiceNamesAndClefs = getVoiceNamesAndClefs;
module.exports.getInstructionsFromKeyField = getInstructionsFromKeyField;
module.exports.groupVoices = groupVoices;
module.exports.getVoiceName = getVoiceName;
module.exports.transposeVoices = transposeVoices;
module.exports.resetAccidentals = resetAccidentals;
module.exports.transposeVoiceLine = transposeVoiceLine;
module.exports.checkForNewClefAndUpdateState = checkForNewClefAndUpdateState;
module.exports.handleKeyChange = handleKeyChange;