172 lines
7.1 KiB
JavaScript
172 lines
7.1 KiB
JavaScript
// 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;
|