import { Chord, Interval, Note } from 'tonal'; export type ChordQuality = 'maj' | 'min' | 'sus2' | 'sus4' | 'power' | 'aug' | 'dim'; export type ChordExtension = | 'b5' | '#5' | '6' | '7' | 'dim7' | 'maj7' | 'b9' | '9' | '#9' | '11' | '#11' | 'b13' | '13'; export interface ChordDescriptor { root: string; quality: ChordQuality; extensions: ChordExtension[]; symbol: string; } const ROOT_PATTERN = /^[A-G](?:#|b)?$/; const EXTENSION_ORDER: ChordExtension[] = ['b5', '#5', '6', '7', 'dim7', 'maj7', 'b9', '9', '#9', '11', '#11', 'b13', '13']; const REMAINING_EXTENSION_ORDER: ChordExtension[] = ['b5', '#5', 'b9', '9', '#9', '11', '#11', 'b13', '13']; const ADD_EXTENSION_ORDER: ChordExtension[] = ['b9', '9', '#9', '11', '#11', 'b13', '13']; const CUSTOM_TOKENS = [ 'maj7#5', 'm7b5', 'dim7', 'sus2', 'sus4', 'aug', 'dim', 'maj7', 'add#11', 'addb13', 'add13', 'add11', 'add#9', 'addb9', 'add9', '#11', 'b13', '#9', 'b9', '9', '13', '11', '#5', 'b5', '7', '6', 'm', '5', ] as const; function normalizeRoot(root: string): string | null { const normalized = root.trim(); if (!ROOT_PATTERN.test(normalized)) { return null; } const parsed = Note.get(normalized); return parsed.empty || parsed.acc.length > 1 ? null : parsed.pc; } function sortExtensions(extensions: Iterable): ChordExtension[] { const unique = new Set(extensions); return EXTENSION_ORDER.filter(extension => unique.has(extension)); } function hasExtension(descriptor: Pick, extension: ChordExtension): boolean { return descriptor.extensions.includes(extension); } function getDescriptorIntervals(descriptor: Pick): string[] { const intervals = ['1P']; switch (descriptor.quality) { case 'maj': intervals.push('3M', '5P'); break; case 'min': intervals.push('3m', '5P'); break; case 'sus2': intervals.push('2M', '5P'); break; case 'sus4': intervals.push('4P', '5P'); break; case 'power': intervals.push('5P'); break; case 'aug': intervals.push('3M', '5A'); break; case 'dim': intervals.push('3m', '5d'); break; } for (const extension of descriptor.extensions) { switch (extension) { case 'b5': if (!intervals.includes('5d')) { intervals.push('5d'); } break; case '#5': if (!intervals.includes('5A')) { intervals.push('5A'); } break; case '6': intervals.push('6M'); break; case '7': intervals.push('7m'); break; case 'dim7': intervals.push('7d'); break; case 'maj7': intervals.push('7M'); break; case 'b9': intervals.push('9m'); break; case '9': intervals.push('9M'); break; case '#9': intervals.push('9A'); break; case '11': intervals.push('11P'); break; case '#11': intervals.push('11A'); break; case 'b13': intervals.push('13m'); break; case '13': intervals.push('13M'); break; } } return Array.from(new Set(intervals)); } function parseIntervalsToDescriptor(root: string, intervals: string[]): ChordDescriptor | null { const intervalSet = new Set(intervals); let quality: ChordQuality | null = null; if (intervalSet.has('5A')) { quality = 'aug'; } else if (intervalSet.has('2M') && !intervalSet.has('3m') && !intervalSet.has('3M') && !intervalSet.has('4P') && intervalSet.has('5P')) { quality = 'sus2'; } else if (intervalSet.has('4P') && !intervalSet.has('3m') && !intervalSet.has('3M') && intervalSet.has('5P')) { quality = 'sus4'; } else if (intervalSet.has('3m') && intervalSet.has('5d')) { quality = 'dim'; } else if (intervalSet.has('3m')) { quality = 'min'; } else if (intervalSet.has('3M')) { quality = 'maj'; } else if (intervalSet.has('5P')) { quality = 'power'; } if (!quality) { return null; } if (intervalSet.has('3A') || intervalSet.has('4d')) { return null; } const extensions = new Set(); if (intervalSet.has('5d')) { extensions.add('b5'); } if (intervalSet.has('5A')) { extensions.add('#5'); } if (intervalSet.has('6M')) { extensions.add('6'); } if (intervalSet.has('7m')) { extensions.add('7'); } if (intervalSet.has('7d')) { extensions.add('dim7'); } if (intervalSet.has('7M')) { extensions.add('maj7'); } if (intervalSet.has('9m') || intervalSet.has('2m')) { extensions.add('b9'); } if (intervalSet.has('9M') || (intervalSet.has('2M') && quality !== 'sus2')) { extensions.add('9'); } if (intervalSet.has('9A')) { extensions.add('#9'); } if (intervalSet.has('11P') && quality !== 'sus4') { extensions.add('11'); } if (intervalSet.has('11A')) { extensions.add('#11'); } if (intervalSet.has('13m') || intervalSet.has('6m')) { extensions.add('b13'); } if (intervalSet.has('13M')) { extensions.add('13'); } const descriptor: ChordDescriptor = { root, quality, extensions: sortExtensions(extensions), symbol: '', }; const symbol = buildChordSymbol(descriptor); if (!symbol) { return null; } return { ...descriptor, symbol, }; } function parseCustomChordSymbol(symbol: string): ChordDescriptor | null { const trimmed = symbol.trim(); const rootMatch = trimmed.match(/^([A-G](?:#|b)?)(.*)$/); if (!rootMatch) { return null; } const root = normalizeRoot(rootMatch[1]); if (!root) { return null; } let remainder = rootMatch[2]; let quality: ChordQuality = 'maj'; const extensions = new Set(); if (remainder.startsWith('m7b5')) { quality = 'dim'; extensions.add('b5'); extensions.add('7'); remainder = remainder.slice(4); } else if (remainder.startsWith('dim7')) { quality = 'dim'; extensions.add('b5'); extensions.add('dim7'); remainder = remainder.slice(4); } else if (remainder.startsWith('maj7#5')) { quality = 'aug'; extensions.add('#5'); extensions.add('maj7'); remainder = remainder.slice(6); } else if (remainder.startsWith('7#5')) { quality = 'aug'; extensions.add('#5'); extensions.add('7'); remainder = remainder.slice(3); } else if (remainder.startsWith('sus2')) { quality = 'sus2'; remainder = remainder.slice(4); } else if (remainder.startsWith('sus4')) { quality = 'sus4'; remainder = remainder.slice(4); } else if (remainder.startsWith('aug')) { quality = 'aug'; remainder = remainder.slice(3); } else if (remainder.startsWith('dim')) { quality = 'dim'; remainder = remainder.slice(3); } else if (remainder.startsWith('m')) { quality = 'min'; remainder = remainder.slice(1); } else if (remainder.startsWith('5')) { quality = 'power'; remainder = remainder.slice(1); } if (remainder.startsWith('maj7')) { extensions.add('maj7'); remainder = remainder.slice(4); } else if (remainder.startsWith('7')) { extensions.add('7'); remainder = remainder.slice(1); } else if (remainder.startsWith('6')) { extensions.add('6'); remainder = remainder.slice(1); } while (remainder.length > 0) { const nextToken = CUSTOM_TOKENS.find(token => remainder.startsWith(token)); if (!nextToken) { return null; } switch (nextToken) { case 'b5': extensions.add('b5'); break; case '#5': extensions.add('#5'); break; case 'b9': case 'addb9': extensions.add('b9'); break; case '9': case 'add9': extensions.add('9'); break; case '#9': case 'add#9': extensions.add('#9'); break; case '11': case 'add11': extensions.add('11'); break; case '#11': case 'add#11': extensions.add('#11'); break; case 'b13': case 'addb13': extensions.add('b13'); break; case '13': case 'add13': extensions.add('13'); break; default: return null; } remainder = remainder.slice(nextToken.length); } const descriptor: ChordDescriptor = { root, quality, extensions: sortExtensions(extensions), symbol: '', }; const canonical = buildChordSymbol(descriptor); if (!canonical) { return null; } return { ...descriptor, symbol: canonical, }; } function tokenizeTonalIntervals(intervals: string[]): string[] | null { const normalized = intervals .map(interval => interval.replace(/^(\d+)([PmMdA])$/, '$1$2').replace(/^(\d+)([dm])$/, '$1$2')) .filter(Boolean); if (normalized.length === 0) { return null; } return normalized; } export function buildChordSymbol(descriptor: Pick): string | null { const root = normalizeRoot(descriptor.root); if (!root) { return null; } const extensions = sortExtensions(descriptor.extensions); const has = (extension: ChordExtension) => extensions.includes(extension); const hasSeventh = has('7') || has('dim7') || has('maj7'); const remainingExtensions = new Set(extensions); let symbol = root; if (descriptor.quality === 'dim' && has('dim7')) { symbol += 'dim7'; remainingExtensions.delete('dim7'); remainingExtensions.delete('b5'); } else if (descriptor.quality === 'dim' && has('7')) { symbol += 'm7b5'; remainingExtensions.delete('7'); remainingExtensions.delete('b5'); } else if (descriptor.quality === 'aug' && has('7')) { symbol += '7#5'; remainingExtensions.delete('7'); remainingExtensions.delete('#5'); } else if (descriptor.quality === 'aug' && has('maj7')) { symbol += 'maj7#5'; remainingExtensions.delete('maj7'); remainingExtensions.delete('#5'); } else { switch (descriptor.quality) { case 'maj': break; case 'min': symbol += 'm'; break; case 'sus2': symbol += 'sus2'; break; case 'sus4': symbol += 'sus4'; break; case 'power': symbol += '5'; break; case 'aug': symbol += 'aug'; remainingExtensions.delete('#5'); break; case 'dim': symbol += 'dim'; remainingExtensions.delete('b5'); break; } if (has('dim7')) { symbol += 'dim7'; remainingExtensions.delete('dim7'); } else if (has('maj7')) { symbol += 'maj7'; remainingExtensions.delete('maj7'); } else if (has('7')) { symbol += '7'; remainingExtensions.delete('7'); } else if (has('6')) { symbol += '6'; remainingExtensions.delete('6'); } } for (const extension of REMAINING_EXTENSION_ORDER) { if (!remainingExtensions.has(extension)) { continue; } if (ADD_EXTENSION_ORDER.includes(extension) && !hasSeventh) { symbol += `add${extension}`; } else { symbol += extension; } } return symbol; } export function parseChordSymbol(symbol: string): ChordDescriptor | null { const trimmed = symbol.trim(); if (!trimmed) { return null; } const tonalChord = Chord.get(trimmed); if (tonalChord && !tonalChord.empty && tonalChord.tonic && !tonalChord.bass && !tonalChord.root) { const root = normalizeRoot(tonalChord.tonic); const intervals = tokenizeTonalIntervals(tonalChord.intervals); if (root && intervals) { const parsed = parseIntervalsToDescriptor(root, intervals); if (parsed) { return parsed; } } } return parseCustomChordSymbol(trimmed); } export function getChordPitchClasses(symbol: string): number[] { const descriptor = parseChordSymbol(symbol); if (!descriptor) { return []; } const root = Note.get(descriptor.root); if (root.empty || root.chroma === undefined) { return []; } return getDescriptorIntervals(descriptor).map((interval) => { const semitones = Interval.semitones(interval); return (root.chroma + semitones + 120) % 12; }); } export function getChordMidiPitches(symbol: string, rootMidi: number): number[] { const descriptor = parseChordSymbol(symbol); if (!descriptor) { return []; } return getDescriptorIntervals(descriptor).map((interval) => rootMidi + Interval.semitones(interval)); } export function formatChordSymbolForDisplay(symbol: string): string { const descriptor = parseChordSymbol(symbol); if (!descriptor) { return symbol; } const { root, quality, extensions } = descriptor; const accidentalDisplay = (value: string) => value.replace(/b/g, '♭').replace(/#/g, '♯'); if (quality === 'dim' && extensions.includes('dim7')) { return `${accidentalDisplay(root)}dim7`; } const baseQuality = (() => { switch (quality) { case 'maj': return ''; case 'min': return 'm'; case 'sus2': return 'sus2'; case 'sus4': return 'sus4'; case 'power': return '5'; case 'aug': return 'aug'; case 'dim': return extensions.includes('dim7') || !extensions.includes('7') ? 'dim' : 'm'; } })(); const inlineExtensions: string[] = []; const parentheticalExtensions: string[] = []; for (const extension of extensions) { if (quality === 'dim' && extension === 'b5' && !extensions.includes('7')) { continue; } if (extension === '7' || extension === 'dim7' || extension === 'maj7' || extension === '6') { inlineExtensions.push(extension); continue; } parentheticalExtensions.push(accidentalDisplay(extension)); } const inlineText = inlineExtensions.join(''); const parentheticalText = parentheticalExtensions.length > 0 ? `(${parentheticalExtensions.join(', ')})` : ''; return `${accidentalDisplay(root)}${baseQuality}${inlineText}${parentheticalText}`; } export const CHORD_QUALITY_OPTIONS: ChordQuality[] = ['maj', 'min', 'sus2', 'sus4', 'power', 'aug', 'dim']; export const CHORD_EXTENSION_OPTIONS: ChordExtension[] = EXTENSION_ORDER;