// 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, };