// SonicForge Studio - Chord Theory Service (BIG_PHASE_3.md §6.1, virtual_keyboard_and_chords_panel_spec.md §III) // Thuần hàm: parse chord symbol / roman numeral → pitch array; áp voicing. // Dùng được cả browser (script tag) lẫn Node (module.exports) cho unit test. const ChordTheory = (function() { // Root note name → pitch class (C=0 ... B=11) const NOTE_TO_PC = { 'C': 0, 'C#': 1, 'Db': 1, 'D': 2, 'D#': 3, 'Eb': 3, 'E': 4, 'F': 5, 'F#': 6, 'Gb': 6, 'G': 7, 'G#': 8, 'Ab': 8, 'A': 9, 'A#': 10, 'Bb': 10, 'B': 11 }; const ROOT_NAMES = Object.keys(NOTE_TO_PC).sort(function(a, b) { return b.length - a.length; }); // 'C#'/'Bb' trước 'C'/'B' const PC_TO_NAME = ['C', 'C#', 'D', 'Eb', 'E', 'F', 'F#', 'G', 'Ab', 'A', 'Bb', 'B']; // Semitone intervals (ascending, relative to root). Octave = 4 mặc định // (C4=60) — đúng spec §III.5: Pitches(Cmaj7) = [60,64,67,71]. const QUALITY_INTERVALS = { '': [0, 4, 7], 'maj': [0, 4, 7], 'm': [0, 3, 7], 'min': [0, 3, 7], '-': [0, 3, 7], 'maj7': [0, 4, 7, 11], 'M7': [0, 4, 7, 11], '7': [0, 4, 7, 10], 'm7': [0, 3, 7, 10], 'min7': [0, 3, 7, 10], 'maj9': [0, 4, 7, 11, 14], 'M9': [0, 4, 7, 11, 14], '9': [0, 4, 7, 10, 14], 'm9': [0, 3, 7, 10, 14], 'min9': [0, 3, 7, 10, 14], '11': [0, 4, 7, 10, 14, 17], 'm11': [0, 3, 7, 10, 14, 17], '13': [0, 4, 7, 10, 14, 21], 'm13': [0, 3, 7, 10, 14, 21], 'add9': [0, 4, 7, 14], 'sus2': [0, 2, 7], 'sus4': [0, 5, 7], 'sus': [0, 5, 7], '7sus4': [0, 5, 7, 10], 'dim': [0, 3, 6], 'dim7': [0, 3, 6, 9], 'aug': [0, 4, 8], '+': [0, 4, 8], '6': [0, 4, 7, 9], 'm6': [0, 3, 7, 9], 'm7b5': [0, 3, 6, 10], '5': [0, 7] }; const QUALITY_KEYS = Object.keys(QUALITY_INTERVALS).sort(function(a, b) { return b.length - a.length; }); // Roman numeral degree → semitone offset trong major scale (spec: I-V-vi-IV // với key C → C-G-Am-F; vi → A, IV → F, v → G...) const ROMAN_DEGREE = { I: 0, II: 2, III: 4, IV: 5, V: 7, VI: 9, VII: 11 }; function noteToMidi(rootName, octave) { const pc = NOTE_TO_PC[rootName] !== undefined ? NOTE_TO_PC[rootName] : 0; return 12 * ((octave == null ? 4 : octave) + 1) + pc; } // 'Cmaj7' → [60,64,67,71]; 'Am9' → [57,60,64,71]; 'G13' → [67,71,74,77,81,88] function parseChordSymbol(symbol, octave) { if (!symbol) return []; const s = String(symbol).trim(); if (!s) return []; const upper = s.toUpperCase(); const root = ROOT_NAMES.find(function(n) { return upper.indexOf(n) === 0; }); if (!root) return []; // Giữ nguyên case của suffix: 'm' = minor (Am9 → 'm9'), 'M7' = major 7th const suffix = s.slice(root.length); const quality = QUALITY_KEYS.find(function(q) { return suffix === q; }) || ''; const intervals = QUALITY_INTERVALS[quality] || QUALITY_INTERVALS['']; const rootMidi = noteToMidi(root, octave); return intervals.map(function(i) { return rootMidi + i; }); } // Kiểm tra symbol có phải chord name (bắt đầu bằng tên nốt) không function isChordName(token) { const upper = String(token || '').trim().toUpperCase(); return ROOT_NAMES.some(function(n) { return upper.indexOf(n) === 0; }); } // 'ii7' → { degree: 2, minor: true, suffix: '7', accidental: 0 } // 'bVI' → { degree: 9, minor: false, suffix: '', accidental: -1 } function parseRomanToken(token) { const t = String(token || '').trim(); // Alternation dài trước (VII trước VI trước V...); /i nhận cả chữ thường const m = t.match(/^(b|#)?(VII|VI|IV|IX|III|II|V|I)(.*)$/i); if (!m) return null; const accidental = m[1] === 'b' ? -1 : (m[1] === '#' ? 1 : 0); const numeral = m[2].toUpperCase(); const minor = m[2] !== m[2].toUpperCase(); // 'vi' → minor, 'VI' → major if (ROMAN_DEGREE[numeral] === undefined) return null; const suffix = m[3] || ''; let quality = null; const SUFFIX_MAP = { '': function(min) { return min ? 'm' : ''; }, '7': function(min) { return min ? 'm7' : '7'; }, '9': function(min) { return min ? 'm9' : '9'; }, '13': function(min) { return min ? 'm13' : '13'; }, 'maj7': function() { return 'maj7'; }, 'M7': function() { return 'maj7'; }, 'maj9': function() { return 'maj9'; }, 'add9': function() { return 'add9'; }, '6': function(min) { return min ? 'm6' : '6'; }, 'sus4': function() { return 'sus4'; }, 'sus2': function() { return 'sus2'; }, '7sus4': function() { return '7sus4'; }, 'dim': function() { return 'dim'; }, 'dim7': function() { return 'dim7'; }, 'm7b5': function() { return 'm7b5'; }, 'aug': function() { return 'aug'; } }; const key = SUFFIX_MAP[suffix] || SUFFIX_MAP['']; quality = key ? key(minor) : (minor ? 'm' : ''); return { degree: ROMAN_DEGREE[numeral], minor: minor, suffix: suffix, accidental: accidental, quality: quality }; } // Chuỗi tokens 'I-vi-IV-V' hoặc 'Cmaj7-Am9-Fadd9-G13' → [{ name, pitches }] // key dùng cho roman numeral; voicing: 'root' | 'first' | 'drop2'. function progressionToChords(tokens, key, voicing, octave) { if (!tokens) return []; const parts = String(tokens).split(/[-,\s]+/).filter(function(x) { return x.length > 0; }); const keyRoot = key || 'C'; return parts.map(function(token) { if (isChordName(token)) { const pitches = parseChordSymbol(token, octave); return { token: token, name: token, pitches: applyVoicing(pitches, voicing) }; } const parsed = parseRomanToken(token); if (!parsed) return { token: token, name: token, pitches: [] }; const keyPc = NOTE_TO_PC[keyRoot] !== undefined ? NOTE_TO_PC[keyRoot] : 0; const rootPc = (((keyPc + parsed.degree + parsed.accidental) % 12) + 12) % 12; const rootName = PC_TO_NAME[rootPc]; const quality = parsed.quality; const name = rootName + quality; const rootMidi = 12 * ((octave == null ? 4 : octave) + 1) + rootPc; const intervals = QUALITY_INTERVALS[quality] || QUALITY_INTERVALS['']; const pitches = intervals.map(function(i) { return rootMidi + i; }); return { token: token, name: name, pitches: applyVoicing(pitches, voicing) }; }); } // Root position: giữ nguyên (sorted). First inversion: root +12. // Drop-2: note cao thứ nhì -12 (spec §III.5). function applyVoicing(pitches, voicing) { if (!pitches || !pitches.length) return []; const arr = pitches.slice().sort(function(a, b) { return a - b; }); if (voicing === 'first' && arr.length >= 2) { arr[0] = arr[0] + 12; arr.sort(function(a, b) { return a - b; }); return arr; } if (voicing === 'drop2' && arr.length >= 3) { arr[arr.length - 2] = arr[arr.length - 2] - 12; arr.sort(function(a, b) { return a - b; }); return arr; } return arr; } return { parseChordSymbol: parseChordSymbol, parseRomanToken: parseRomanToken, isChordName: isChordName, progressionToChords: progressionToChords, applyVoicing: applyVoicing }; })(); if (typeof module !== 'undefined' && module.exports) module.exports = ChordTheory;