276 lines
6.4 KiB
JavaScript
276 lines
6.4 KiB
JavaScript
// Chord Engine - Industry-standard chord transposition and parsing
|
|
|
|
const NOTES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"];
|
|
const FLAT_NOTES = [
|
|
"C",
|
|
"Db",
|
|
"D",
|
|
"Eb",
|
|
"E",
|
|
"F",
|
|
"Gb",
|
|
"G",
|
|
"Ab",
|
|
"A",
|
|
"Bb",
|
|
"B",
|
|
];
|
|
|
|
// Keywords to ignore when parsing chords (section markers)
|
|
const SECTION_KEYWORDS = [
|
|
"verse",
|
|
"chorus",
|
|
"pre-chorus",
|
|
"prechorus",
|
|
"bridge",
|
|
"intro",
|
|
"outro",
|
|
"tag",
|
|
"coda",
|
|
"interlude",
|
|
"instrumental",
|
|
"hook",
|
|
"refrain",
|
|
"vamp",
|
|
"ending",
|
|
"turnaround",
|
|
"solo",
|
|
"break",
|
|
];
|
|
|
|
// Chord quality patterns
|
|
const CHORD_QUALITIES = {
|
|
major: "",
|
|
minor: "m",
|
|
diminished: "dim",
|
|
augmented: "aug",
|
|
suspended2: "sus2",
|
|
suspended4: "sus4",
|
|
dominant7: "7",
|
|
major7: "maj7",
|
|
minor7: "m7",
|
|
diminished7: "dim7",
|
|
augmented7: "aug7",
|
|
add9: "add9",
|
|
add11: "add11",
|
|
6: "6",
|
|
m6: "m6",
|
|
9: "9",
|
|
m9: "m9",
|
|
11: "11",
|
|
13: "13",
|
|
};
|
|
|
|
// Regex to match chord patterns
|
|
const CHORD_REGEX =
|
|
/^([A-G][#b]?)(m|min|maj|dim|aug|sus[24]?|add[0-9]+|[0-9]+)?([0-9]*)?(\/[A-G][#b]?)?$/i;
|
|
|
|
/**
|
|
* Parse a potential chord string
|
|
* @param {string} text - Text that might be a chord
|
|
* @returns {object|null} Parsed chord object or null if not a valid chord
|
|
*/
|
|
export function parseChord(text) {
|
|
const trimmed = text.trim();
|
|
|
|
// Check if it's a section keyword
|
|
if (
|
|
SECTION_KEYWORDS.some((keyword) => trimmed.toLowerCase().includes(keyword))
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
const match = trimmed.match(CHORD_REGEX);
|
|
if (!match) return null;
|
|
|
|
const [, root, quality = "", extension = "", bass = ""] = match;
|
|
|
|
return {
|
|
root: normalizeNote(root),
|
|
quality,
|
|
extension,
|
|
bass: bass ? normalizeNote(bass.slice(1)) : null,
|
|
original: trimmed,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Normalize note name (handle enharmonics)
|
|
* @param {string} note - Note name
|
|
* @returns {string} Normalized note name
|
|
*/
|
|
function normalizeNote(note) {
|
|
const upper = note.charAt(0).toUpperCase() + note.slice(1);
|
|
// Convert flats to sharps for internal processing
|
|
const flatIndex = FLAT_NOTES.indexOf(upper);
|
|
if (flatIndex !== -1) {
|
|
return NOTES[flatIndex];
|
|
}
|
|
return upper;
|
|
}
|
|
|
|
/**
|
|
* Get the semitone index of a note
|
|
* @param {string} note - Note name
|
|
* @returns {number} Semitone index (0-11)
|
|
*/
|
|
function getNoteIndex(note) {
|
|
const normalized = normalizeNote(note);
|
|
const index = NOTES.indexOf(normalized);
|
|
return index !== -1 ? index : FLAT_NOTES.indexOf(normalized);
|
|
}
|
|
|
|
/**
|
|
* Transpose a single chord by a number of semitones
|
|
* @param {string} chord - Chord string
|
|
* @param {number} semitones - Number of semitones to transpose
|
|
* @param {boolean} useFlats - Whether to use flats instead of sharps
|
|
* @returns {string} Transposed chord string
|
|
*/
|
|
export function transposeChord(chord, semitones, useFlats = false) {
|
|
const parsed = parseChord(chord);
|
|
if (!parsed) return chord;
|
|
|
|
const noteArray = useFlats ? FLAT_NOTES : NOTES;
|
|
|
|
const rootIndex = getNoteIndex(parsed.root);
|
|
const newRootIndex = (rootIndex + semitones + 12) % 12;
|
|
const newRoot = noteArray[newRootIndex];
|
|
|
|
let result = newRoot + parsed.quality + parsed.extension;
|
|
|
|
if (parsed.bass) {
|
|
const bassIndex = getNoteIndex(parsed.bass);
|
|
const newBassIndex = (bassIndex + semitones + 12) % 12;
|
|
result += "/" + noteArray[newBassIndex];
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Get semitones between two keys
|
|
* @param {string} fromKey - Starting key
|
|
* @param {string} toKey - Target key
|
|
* @returns {number} Number of semitones
|
|
*/
|
|
export function getSemitonesBetween(fromKey, toKey) {
|
|
const fromIndex = getNoteIndex(fromKey);
|
|
const toIndex = getNoteIndex(toKey);
|
|
return (toIndex - fromIndex + 12) % 12;
|
|
}
|
|
|
|
/**
|
|
* Transpose all chords in a lyrics string
|
|
* @param {string} lyrics - Lyrics with chords in brackets [C] or above words
|
|
* @param {number} semitones - Number of semitones to transpose
|
|
* @param {boolean} useFlats - Whether to use flats
|
|
* @returns {string} Transposed lyrics
|
|
*/
|
|
export function transposeLyrics(lyrics, semitones, useFlats = false) {
|
|
// Handle chords in brackets [C]
|
|
const bracketPattern = /\[([^\]]+)\]/g;
|
|
return lyrics.replace(bracketPattern, (match, chord) => {
|
|
const transposed = transposeChord(chord, semitones, useFlats);
|
|
return `[${transposed}]`;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Extract all unique chords from lyrics
|
|
* @param {string} lyrics - Lyrics with chords
|
|
* @returns {string[]} Array of unique chords
|
|
*/
|
|
export function extractChords(lyrics) {
|
|
const bracketPattern = /\[([^\]]+)\]/g;
|
|
const chords = new Set();
|
|
let match;
|
|
|
|
while ((match = bracketPattern.exec(lyrics)) !== null) {
|
|
const chord = match[1];
|
|
if (parseChord(chord)) {
|
|
chords.add(chord);
|
|
}
|
|
}
|
|
|
|
return Array.from(chords);
|
|
}
|
|
|
|
/**
|
|
* Detect the likely key of a song based on its chords
|
|
* @param {string[]} chords - Array of chords
|
|
* @returns {string} Detected key
|
|
*/
|
|
export function detectKey(chords) {
|
|
if (chords.length === 0) return "C";
|
|
|
|
// Simple heuristic: first and last chords often indicate the key
|
|
const firstChord = parseChord(chords[0]);
|
|
const lastChord = parseChord(chords[chords.length - 1]);
|
|
|
|
// If they match, high confidence
|
|
if (firstChord && lastChord && firstChord.root === lastChord.root) {
|
|
return firstChord.root + (firstChord.quality.includes("m") ? "m" : "");
|
|
}
|
|
|
|
// Otherwise use first chord
|
|
if (firstChord) {
|
|
return firstChord.root + (firstChord.quality.includes("m") ? "m" : "");
|
|
}
|
|
|
|
return "C";
|
|
}
|
|
|
|
/**
|
|
* Format chord for display (convert internal format to display format)
|
|
* @param {string} chord - Chord in internal format
|
|
* @param {object} options - Display options
|
|
* @returns {string} Formatted chord
|
|
*/
|
|
export function formatChord(chord, options = {}) {
|
|
const { useFlats = false, useSuperscript = false } = options;
|
|
|
|
let formatted = chord;
|
|
|
|
if (useFlats) {
|
|
NOTES.forEach((sharp, i) => {
|
|
if (sharp.includes("#")) {
|
|
formatted = formatted.replace(sharp, FLAT_NOTES[i]);
|
|
}
|
|
});
|
|
}
|
|
|
|
return formatted;
|
|
}
|
|
|
|
/**
|
|
* Get all keys for key selector
|
|
* @param {boolean} useFlats - Whether to use flats
|
|
* @returns {string[]} Array of keys
|
|
*/
|
|
export function getAllKeys(useFlats = false) {
|
|
const notes = useFlats ? FLAT_NOTES : NOTES;
|
|
const keys = [];
|
|
|
|
notes.forEach((note) => {
|
|
keys.push(note); // Major
|
|
keys.push(note + "m"); // Minor
|
|
});
|
|
|
|
return keys;
|
|
}
|
|
|
|
export default {
|
|
parseChord,
|
|
transposeChord,
|
|
transposeLyrics,
|
|
extractChords,
|
|
detectKey,
|
|
formatChord,
|
|
getAllKeys,
|
|
getSemitonesBetween,
|
|
NOTES,
|
|
FLAT_NOTES,
|
|
SECTION_KEYWORDS,
|
|
};
|