feat(chords): thêm Chords Panel — chordTheory service + presets/custom/AI tab, insert progression vào timeline, Shift+K/menu/toolbar
This commit is contained in:
+11
-1
@@ -88,7 +88,17 @@
|
||||
- [x] 3 regression cũ PASS: `shm_note_energy` energy 0 (app chưa load instrument — đúng); `test_sf2_ring` energy 12.337→0.046 (= baseline Phase 3); `test_vst2_gui` DUNE host + editor VSTGUI, proc alive.
|
||||
|
||||
## PHASE 6 — CHORDS PANEL
|
||||
(chưa bắt đầu)
|
||||
- [x] Service `app/static/js/services/chordTheory.js` (169 dòng, IIFE global `const ChordTheory` + `module.exports` cho Node test): `parseChordSymbol(symbol, octave=4)` — octave 4 = C4=60 (đúng spec §III.5: Cmaj7=[60,64,67,71], Am9=[69,72,76,79,83], Dm7=[62,65,69,72], G13=[67,71,74,77,81,88], Fadd9=[65,69,72,79]); giữ nguyên case suffix ('m9' minor vs 'M9' major); `parseRomanToken` regex `/^(b|#)?(VII|VI|IV|IX|III|II|V|I)(.*)$/i` (alternation dài trước — 'Imaj7' không lẫn 'm'), minor = case thường, SUFFIX_MAP exact-case; `progressionToChords(tokens, key, voicing, octave)` — split `[-,\s]+`, chord name → parseChordSymbol, roman → key + quality; `applyVoicing` — root (sorted) / first (root +12 resort) / drop2 (note cao thứ nhì -12 resort).
|
||||
- [x] Unit test `app/static/js/services/chordTheory.test.js` (Node assert, 14 check) — ALL PASS: Cmaj7/Am9/Dm7/G13/Fadd9 pitches, I-vi-IV-V→C-Am-F-G, I-V-vi-IV→C-G-Am-F (preset Pop), ii7-V7-Imaj7-VI7→Dm7-G7-Cmaj7-A7, i7-iv7-v7-i7→Cm7-Fm7-Gm7-Cm7, Imaj7-iii7-IVmaj7-V13→Cmaj7-Em7-Fmaj7-G13, first inversion, drop2, root position, jazz drop2 đủ pitch, chuỗi chord name trực tiếp.
|
||||
- [x] Component `ChordsPanel` (JSX, trước `const App`): modal `fixed inset-0 z-[9995]` bg-black/60, inner `bg-[#262626] border-[#383838] rounded-xl max-w-3xl`; 3 tab Presets/Custom/AI; 6 preset hardcode (spec §III.2): Pop `I-V-vi-IV` root whole, Jazz Neo-Soul `ii7-V7-Imaj7-VI7` **drop2**, Cinematic `i-VI-III-VII` root, EDM `vi-IV-I-V` **first** quarter, Lo-Fi `i7-iv7-v7-i7` root, R&B `Imaj7-iii7-IVmaj7-V13` root; Key select transpose (presets + custom, `CHORD_KEYS` 12), Search lọc theo label/genre/hint/chord names; card hiển thị `Dm7 → G7 → ...`, [Preview Sound] (chord đầu: onNoteOn từng pitch vel 0.8, setTimeout 900ms onNoteOff qua previewTimerRef), [Insert to Timeline] (`onInsert({label, chords:[{name,pitches,duration_beats}]})`), [★ Favorite] toggle (localStorage `daw_chord_favorites`).
|
||||
- [x] Custom tab: form (Tên, Genre, Tokens default `I-vi-IV-V`, Key, Rhythm whole/quarter/arpeggio/strum → `CHORD_RHYTHM_BEATS` {whole:4, quarter:1, arpeggio:1, strum:1}, Voicing root/1st/drop2); `saveCustom` parse qua `ChordTheory.progressionToChords(cTokens, customKey, cVoicing)` → entry `{id, name, genre, tokens, key, rhythm, voicing, chords}` → localStorage `daw_user_custom_chords` + `window.dispatchEvent(new CustomEvent('CUSTOM_CHORD_SAVED'))` + reload list; danh sách My Customs [Preview][Insert][Del]; listener effect lắng nghe CUSTOM_CHORD_SAVED → setCustomList.
|
||||
- [x] AI tab: DEFER — message "AI chưa cấu hình...", không gọi aiGateway.
|
||||
- [x] Tích hợp App: state `chordsPanelOpen`; `window.__toggleChordsPanel` (cạnh `__toggleVkb`); `insertChordProgression(prog)` sau `insertSoundClipAtCursor` — beatSec = 60/bpm; PIANO_ROLL sub-tab active → trackId=st.trackId, item=track.midiItems[st.target_id], insertBeat = activeSub.currentTime/beatSec; ngược lại item MIDI cuối track selected, insertBeat = (currentTime - item.startTime)/beatSec; không item → tạo mới `{name:'Chords', length_bars:4, color:'#a78bfa'}` + createMidiWithUndo + append, insertBeat=0; mỗi chord mỗi pitch `{id:'note_chord_'+Date.now()+'_'+ci+'_'+p, pitch, start_beat, duration_beats, velocity:0.8, pan:0.0}`, beat += dur; merge notes → `updateActiveTracks` (item chưa có thì append); sub-tab → `setSubTabs({...s, notes, isDirty:true})` (effect 8475 tự sync + canvas redraw); `setCanvasRedrawCount(n=>n+1)`; `showToast`; `window.__lastChordInsert = {trackId, itemId, notes}` (cho verify).
|
||||
- [x] Menu Insert: item "Chords panel" icon 'guitar' shortcut 'Shift+K' → `window.__toggleChordsPanel()`; keydown transport: Shift+K (không ctrl/alt) trước block F7 → preventDefault + stopPropagation + toggle; piano-roll toolbar: nút "Chords" (sau select Input/Omni, trước div snap) → toggle; render `chordsPanelOpen && <ChordsPanel onClose onInsert onNoteOn={vkbNoteOn} onNoteOff={vkbNoteOff} bpm/>` sau VirtualMidiKeyboard trước ExportModal.
|
||||
- [x] index.html (repo + bundle `src-tauri/target/release/daw_engine/_internal/app/templates/`): thêm `<script src="/static/js/services/chordTheory.js?v=202608211300"></script>` sau undoRedoEngine, bump app.precompiled.js `?v=202608211150`→`?v=202608211300`; copy `chordTheory.js` vào bundle `_internal/app/static/js/services/`.
|
||||
- [x] Build: `node build.mjs` OK (Babel warning >500KB bình thường); copy `app.precompiled.js` sang bundle; restart app (taskkill sonicforge-daw.exe → chạy lại exe, PID mới).
|
||||
- [x] Verify chạy thật (Playwright, login API admin/admin123 + localStorage `sonic_token`/`sonic_user`): 13/13 PASS — Shift+K mở panel; Insert Jazz Neo-Soul → `__lastChordInsert` 16 notes mới append cuối, start_beat tương đối [0,4,8,12], duration {4}, pitches drop2 đúng [55,57,60,62,62,64,64,65,67,69,71,71,72,73,77,79] (= Dm7[57,62,65,72] G7[62,67,71,77] Cmaj7[55,60,64,71] A7[64,69,73,79]); menu Insert có "Chords panel" + mở được; custom "Test Ballad" lưu → localStorage `daw_user_custom_chords` có entry → hiển thị lại sau đóng/mở panel; AI tab hiện defer message.
|
||||
- [x] 3 regression cũ PASS: `shm_note_energy` energy 0 (app chưa load instrument — đúng); `test_sf2_ring` energy 12.337→0.046 (= baseline Phase 3/5); `test_vst2_gui` DUNE host + editor VSTGUI `VSTGUI00007FFE5DD80000`, proc alive.
|
||||
|
||||
## PHASE 7 — BUILD TỔNG & VERIFY
|
||||
(chưa bắt đầu)
|
||||
|
||||
+303
-1
@@ -10436,7 +10436,11 @@ const beatSec = 60.0 / (parseInt(bpm) || 120);
|
||||
value: selectedMidiInputId || '',
|
||||
onChange: e => onMidiInputSelect(e.target.value),
|
||||
className: "bg-zinc-800 border border-zinc-700 text-zinc-300 rounded px-1 py-0.5 text-xs outline-none max-w-[100px]"
|
||||
}, React.createElement("option", { value: "" }, "Input"), React.createElement("option", { value: "ALL" }, "Omni"), (midiDevices || []).map(d => React.createElement("option", { key: d.id, value: d.id }, d.name || d.id))), React.createElement("div", { className: "flex items-center gap-0.5 shrink-0" }, React.createElement("button", {
|
||||
}, React.createElement("option", { value: "" }, "Input"), React.createElement("option", { value: "ALL" }, "Omni"), (midiDevices || []).map(d => React.createElement("option", { key: d.id, value: d.id }, d.name || d.id))), React.createElement("button", {
|
||||
onClick: () => window.__toggleChordsPanel && window.__toggleChordsPanel(),
|
||||
title: "Chords panel (Shift+K)",
|
||||
className: "px-1.5 py-0.5 rounded text-xs font-bold bg-zinc-800 hover:bg-zinc-700 text-amber-400"
|
||||
}, "Chords"), React.createElement("div", { className: "flex items-center gap-0.5 shrink-0" }, React.createElement("button", {
|
||||
onClick: () => onInstrumentSelect && onInstrumentSelect(st.trackId),
|
||||
title: st.instrumentName || "Synth",
|
||||
className: `px-1.5 py-0.5 text-[10px] rounded font-mono font-bold border transition flex items-center gap-0.5 max-w-[70px] ${st.instrumentName ? 'bg-violet-900 text-violet-300 border-violet-700' : 'bg-zinc-800 text-zinc-500 border-transparent hover:text-zinc-300'}`
|
||||
@@ -16422,6 +16426,224 @@ const VirtualMidiKeyboard = ({ settings, onChange, tracks, activePitches, onNote
|
||||
/*#__PURE__*/React.createElement("div", { className: "px-2 pb-1.5 text-[9px] text-zinc-600" }, "QWERTY: Z S X D C V G B H N J M / Q 2 W 3 E R 5 T 6 Y 7 U I")
|
||||
);
|
||||
};
|
||||
// ── Chords Panel (Phase 6) ─────────────────────────────────────────────
|
||||
// Floating chord progression panel — Insert menu / Shift+K / piano-roll
|
||||
// toolbar. Presets theo virtual_keyboard_and_chords_panel_spec.md §III.2.
|
||||
const CHORD_PRESETS = [
|
||||
{ id: 'pop', label: 'Pop', genre: 'Pop', tokens: 'I-V-vi-IV', key: 'C', voicing: 'root', rhythm: 'whole', hint: 'C → G → Am → F' },
|
||||
{ id: 'jazz', label: 'Jazz Neo-Soul', genre: 'Jazz Neo-Soul', tokens: 'ii7-V7-Imaj7-VI7', key: 'C', voicing: 'drop2', rhythm: 'whole', hint: 'Dm7 → G7 → Cmaj7 → A7' },
|
||||
{ id: 'cinematic', label: 'Cinematic', genre: 'Cinematic', tokens: 'i-VI-III-VII', key: 'C', voicing: 'root', rhythm: 'whole', hint: 'Cm → Ab → Eb → Bb' },
|
||||
{ id: 'edm', label: 'EDM', genre: 'EDM', tokens: 'vi-IV-I-V', key: 'C', voicing: 'first', rhythm: 'quarter', hint: 'Am → F → C → G' },
|
||||
{ id: 'lofi', label: 'Lo-Fi', genre: 'Lo-Fi', tokens: 'i7-iv7-v7-i7', key: 'C', voicing: 'root', rhythm: 'whole', hint: 'Cm7 → Fm7 → Gm7 → Cm7' },
|
||||
{ id: 'rnb', label: 'R&B', genre: 'R&B', tokens: 'Imaj7-iii7-IVmaj7-V13', key: 'C', voicing: 'root', rhythm: 'whole', hint: 'Cmaj7 → Em7 → Fmaj7 → G13' }
|
||||
];
|
||||
const CHORD_RHYTHM_BEATS = { whole: 4, quarter: 1, arpeggio: 1, strum: 1 };
|
||||
const CHORD_RHYTHM_LABELS = { whole: 'Whole (4 beats)', quarter: 'Quarter (1 beat)', arpeggio: 'Arpeggio', strum: 'Strum' };
|
||||
const CHORD_KEYS = ['C', 'C#', 'D', 'Eb', 'E', 'F', 'F#', 'G', 'Ab', 'A', 'Bb', 'B'];
|
||||
const CHORD_VOICING_LABELS = { root: 'Root', first: '1st Inv', drop2: 'Drop 2' };
|
||||
|
||||
const ChordsPanel = ({ onClose, onInsert, onNoteOn, onNoteOff, bpm }) => {
|
||||
const [tab, setTab] = useState('preset');
|
||||
const [search, setSearch] = useState('');
|
||||
const [key, setKey] = useState('C');
|
||||
const [favorites, setFavorites] = useState(function () {
|
||||
try { return JSON.parse(localStorage.getItem('daw_chord_favorites') || '[]'); } catch (e) { return []; }
|
||||
});
|
||||
const [customList, setCustomList] = useState(function () {
|
||||
try { return JSON.parse(localStorage.getItem('daw_user_custom_chords') || '[]'); } catch (e) { return []; }
|
||||
});
|
||||
const [cName, setCName] = useState('');
|
||||
const [cGenre, setCGenre] = useState('');
|
||||
const [cTokens, setCTokens] = useState('I-vi-IV-V');
|
||||
const [cRhythm, setCRhythm] = useState('whole');
|
||||
const [cVoicing, setCVoicing] = useState('root');
|
||||
const [customKey, setCustomKey] = useState('C');
|
||||
const previewTimerRef = useRef(null);
|
||||
|
||||
useEffect(function () {
|
||||
const onSaved = function () {
|
||||
try { setCustomList(JSON.parse(localStorage.getItem('daw_user_custom_chords') || '[]')); } catch (e) {}
|
||||
};
|
||||
window.addEventListener('CUSTOM_CHORD_SAVED', onSaved);
|
||||
return function () { window.removeEventListener('CUSTOM_CHORD_SAVED', onSaved); };
|
||||
}, []);
|
||||
|
||||
useEffect(function () {
|
||||
try { localStorage.setItem('daw_chord_favorites', JSON.stringify(favorites)); } catch (e) {}
|
||||
}, [favorites]);
|
||||
|
||||
useEffect(function () { return function () { if (previewTimerRef.current) clearTimeout(previewTimerRef.current); }; }, []);
|
||||
|
||||
const resolveProgression = function (tokens, progKey, progVoicing) {
|
||||
try { return (window.ChordTheory || ChordTheory).progressionToChords(tokens, progKey || key, progVoicing || 'root'); } catch (e) { return []; }
|
||||
};
|
||||
|
||||
const previewChord = function (chord) {
|
||||
if (!chord || !chord.pitches || !chord.pitches.length) return;
|
||||
const vel = 0.8;
|
||||
chord.pitches.forEach(function (p) { try { onNoteOn(p, vel); } catch (e) {} });
|
||||
if (previewTimerRef.current) clearTimeout(previewTimerRef.current);
|
||||
previewTimerRef.current = setTimeout(function () {
|
||||
chord.pitches.forEach(function (p) { try { onNoteOff(p); } catch (e) {} });
|
||||
}, 900);
|
||||
};
|
||||
|
||||
const toggleFavorite = function (id) {
|
||||
setFavorites(function (prev) { return prev.indexOf(id) !== -1 ? prev.filter(function (x) { return x !== id; }) : prev.concat([id]); });
|
||||
};
|
||||
|
||||
const insertProg = function (label, chords, rhythm) {
|
||||
const dur = CHORD_RHYTHM_BEATS[rhythm] != null ? CHORD_RHYTHM_BEATS[rhythm] : 4;
|
||||
const payload = (chords || []).filter(function (c) { return c.pitches && c.pitches.length; }).map(function (c) { return { name: c.name || c.token, pitches: c.pitches, duration_beats: dur }; });
|
||||
if (!payload.length) { if (window.showToast) window.showToast('Không có chord để chèn', 'warning'); return; }
|
||||
onInsert({ label: label, chords: payload });
|
||||
};
|
||||
|
||||
const saveCustom = function () {
|
||||
const tokens = (cTokens || '').trim();
|
||||
if (!tokens) { if (window.showToast) window.showToast('Nhập progression tokens', 'warning'); return; }
|
||||
let chords = [];
|
||||
try { chords = (window.ChordTheory || ChordTheory).progressionToChords(tokens, customKey, cVoicing); } catch (e) {}
|
||||
if (!chords.length || chords.some(function (c) { return !c.pitches || !c.pitches.length; })) { if (window.showToast) window.showToast('Progression không parse được', 'warning'); return; }
|
||||
const entry = { id: 'custom_' + Date.now(), name: (cName || '').trim() || 'My Chord', genre: (cGenre || '').trim() || 'Custom', tokens: tokens, key: customKey, rhythm: cRhythm, voicing: cVoicing, chords: chords };
|
||||
try {
|
||||
const list = JSON.parse(localStorage.getItem('daw_user_custom_chords') || '[]');
|
||||
list.push(entry);
|
||||
localStorage.setItem('daw_user_custom_chords', JSON.stringify(list));
|
||||
setCustomList(list);
|
||||
window.dispatchEvent(new CustomEvent('CUSTOM_CHORD_SAVED'));
|
||||
if (window.showToast) window.showToast('Đã lưu chord custom: ' + entry.name, 'success');
|
||||
} catch (e) { if (window.showToast) window.showToast('Lưu thất bại', 'error'); }
|
||||
};
|
||||
|
||||
const deleteCustom = function (id) {
|
||||
try {
|
||||
const list = JSON.parse(localStorage.getItem('daw_user_custom_chords') || '[]').filter(function (x) { return x.id !== id; });
|
||||
localStorage.setItem('daw_user_custom_chords', JSON.stringify(list));
|
||||
setCustomList(list);
|
||||
window.dispatchEvent(new CustomEvent('CUSTOM_CHORD_SAVED'));
|
||||
} catch (e) {}
|
||||
};
|
||||
|
||||
const q = (search || '').trim().toLowerCase();
|
||||
const filteredPresets = CHORD_PRESETS.filter(function (p) {
|
||||
if (!q) return true;
|
||||
const chords = resolveProgression(p.tokens, key, p.voicing);
|
||||
const hay = (p.label + ' ' + p.genre + ' ' + p.hint + ' ' + chords.map(function (c) { return c.name; }).join(' ')).toLowerCase();
|
||||
return hay.indexOf(q) !== -1;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[9995] flex items-center justify-center bg-black/60 backdrop-blur-sm" onClick={onClose}>
|
||||
<div className="bg-[#262626] border border-[#383838] rounded-xl shadow-2xl w-full max-w-3xl p-5 text-slate-200 max-h-[85vh] overflow-y-auto" onClick={function (e) { e.stopPropagation(); }}>
|
||||
<div className="flex items-center justify-between pb-3 border-b border-[#383838] mb-3">
|
||||
<h3 className="text-sm font-bold text-amber-400">Chords Panel</h3>
|
||||
<button onClick={onClose} className="text-zinc-500 hover:text-red-400 text-xs px-1" title="Close">✕</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mb-3 flex-wrap">
|
||||
<div className="flex gap-1">
|
||||
{[['preset', 'Presets'], ['custom', 'Custom'], ['ai', 'AI']].map(function (t) {
|
||||
return <button key={t[0]} onClick={function () { setTab(t[0]); }} className={"px-3 py-1 rounded text-[11px] font-bold " + (tab === t[0] ? 'bg-amber-700 text-white' : 'bg-zinc-800 text-zinc-400 hover:bg-zinc-700')}>{t[1]}</button>;
|
||||
})}
|
||||
</div>
|
||||
{tab !== 'ai' && (
|
||||
<React.Fragment>
|
||||
<select value={key} onChange={function (e) { setKey(e.target.value); }} className="bg-zinc-800 border border-zinc-700 text-zinc-300 rounded px-2 py-1 text-[11px] outline-none" title="Key (transpose)">
|
||||
{CHORD_KEYS.map(function (k) { return <option key={k} value={k}>{k}</option>; })}
|
||||
</select>
|
||||
{tab === 'preset' && (
|
||||
<input value={search} onChange={function (e) { setSearch(e.target.value); }} placeholder="Search preset..." className="bg-zinc-800 border border-zinc-700 text-zinc-200 rounded px-2 py-1 text-[11px] outline-none focus:border-amber-500 flex-1 min-w-[120px]" />
|
||||
)}
|
||||
</React.Fragment>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{tab === 'preset' && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{filteredPresets.map(function (p) {
|
||||
const chords = resolveProgression(p.tokens, key, p.voicing);
|
||||
const display = chords.map(function (c) { return c.name; }).join(' → ');
|
||||
const fav = favorites.indexOf(p.id) !== -1;
|
||||
return (
|
||||
<div key={p.id} className="border border-[#383838] rounded-lg p-3 bg-[#2a2a2a]">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-xs font-bold text-amber-400">{p.label}</span>
|
||||
<span className="text-[9px] text-zinc-500">{p.genre} · {CHORD_VOICING_LABELS[p.voicing]}</span>
|
||||
</div>
|
||||
<div className="text-[11px] font-mono text-zinc-300 mb-2">{display || p.hint}</div>
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
<button onClick={function () { previewChord(chords[0]); }} disabled={!chords.length} className="px-2 py-1 rounded text-[10px] font-semibold bg-emerald-800 hover:bg-emerald-700 text-emerald-100 disabled:opacity-40">Preview Sound</button>
|
||||
<button onClick={function () { insertProg(p.label, chords, p.rhythm); }} disabled={!chords.length} className="px-2 py-1 rounded text-[10px] font-semibold bg-blue-800 hover:bg-blue-700 text-blue-100 disabled:opacity-40">Insert to Timeline</button>
|
||||
<button onClick={function () { toggleFavorite(p.id); }} className={"px-2 py-1 rounded text-[10px] font-semibold " + (fav ? 'bg-yellow-700 text-yellow-100' : 'bg-zinc-700 text-zinc-300 hover:bg-zinc-600')}>{fav ? '★ Favorite' : '☆ Favorite'}</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{!filteredPresets.length && <div className="col-span-full text-[11px] text-zinc-500">Không tìm thấy preset.</div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'custom' && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<div className="border border-[#383838] rounded-lg p-3 bg-[#2a2a2a]">
|
||||
<div className="text-xs font-bold text-amber-400 mb-2">Build Custom Progression</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<input value={cName} onChange={function (e) { setCName(e.target.value); }} placeholder="Tên (vd: Ballad)" className="bg-zinc-800 border border-zinc-700 text-zinc-200 rounded px-2 py-1 text-xs outline-none focus:border-amber-500" />
|
||||
<input value={cGenre} onChange={function (e) { setCGenre(e.target.value); }} placeholder="Genre (vd: Ballad)" className="bg-zinc-800 border border-zinc-700 text-zinc-200 rounded px-2 py-1 text-xs outline-none focus:border-amber-500" />
|
||||
<input value={cTokens} onChange={function (e) { setCTokens(e.target.value); }} placeholder="Tokens (vd: I-vi-IV-V)" className="bg-zinc-800 border border-zinc-700 text-zinc-200 rounded px-2 py-1 text-xs outline-none focus:border-amber-500" />
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<select value={customKey} onChange={function (e) { setCustomKey(e.target.value); }} className="bg-zinc-800 border border-zinc-700 text-zinc-300 rounded px-2 py-1 text-[11px] outline-none">
|
||||
{CHORD_KEYS.map(function (k) { return <option key={k} value={k}>{k}</option>; })}
|
||||
</select>
|
||||
<select value={cRhythm} onChange={function (e) { setCRhythm(e.target.value); }} className="bg-zinc-800 border border-zinc-700 text-zinc-300 rounded px-2 py-1 text-[11px] outline-none">
|
||||
{Object.keys(CHORD_RHYTHM_BEATS).map(function (r) { return <option key={r} value={r}>{CHORD_RHYTHM_LABELS[r]}</option>; })}
|
||||
</select>
|
||||
<select value={cVoicing} onChange={function (e) { setCVoicing(e.target.value); }} className="bg-zinc-800 border border-zinc-700 text-zinc-300 rounded px-2 py-1 text-[11px] outline-none">
|
||||
{Object.keys(CHORD_VOICING_LABELS).map(function (v) { return <option key={v} value={v}>{CHORD_VOICING_LABELS[v]}</option>; })}
|
||||
</select>
|
||||
</div>
|
||||
<button onClick={saveCustom} className="px-3 py-1.5 rounded text-xs font-bold bg-amber-700 hover:bg-amber-600 text-white">Save Custom</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border border-[#383838] rounded-lg p-3 bg-[#2a2a2a]">
|
||||
<div className="text-xs font-bold text-amber-400 mb-2">My Customs ({customList.length})</div>
|
||||
{customList.length === 0 ? (
|
||||
<div className="text-[10px] text-zinc-500">Chưa có custom nào — tạo bên trái rồi Save.</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2 max-h-48 overflow-y-auto">
|
||||
{customList.map(function (c) {
|
||||
return (
|
||||
<div key={c.id} className="border border-zinc-700 rounded p-2 flex items-center justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[11px] font-bold text-zinc-200">{c.name} <span className="text-[9px] text-zinc-500">({c.genre})</span></div>
|
||||
<div className="text-[10px] font-mono text-zinc-400 truncate">{c.chords.map(function (x) { return x.name; }).join(' → ')}</div>
|
||||
</div>
|
||||
<div className="flex gap-1 shrink-0">
|
||||
<button onClick={function () { previewChord(c.chords[0]); }} className="px-2 py-1 rounded text-[10px] font-semibold bg-emerald-800 hover:bg-emerald-700 text-emerald-100">Preview</button>
|
||||
<button onClick={function () { insertProg(c.name, c.chords, c.rhythm); }} className="px-2 py-1 rounded text-[10px] font-semibold bg-blue-800 hover:bg-blue-700 text-blue-100">Insert</button>
|
||||
<button onClick={function () { deleteCustom(c.id); }} className="px-2 py-1 rounded text-[10px] font-semibold bg-red-900 hover:bg-red-800 text-red-100">Del</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'ai' && (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<div className="text-3xl mb-2">♪</div>
|
||||
<div className="text-sm text-zinc-300 font-semibold mb-1">AI Chord Suggestions</div>
|
||||
<div className="text-[11px] text-zinc-500 max-w-sm">AI chưa cấu hình. Bật AI Gateway trong Settings để nhận gợi ý chord theo genre / cảm xúc.</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
const App = () => {
|
||||
// ── State Definitions ──
|
||||
@@ -17183,6 +17405,7 @@ const App = () => {
|
||||
const [vkbOpen, setVkbOpen] = useState(false);
|
||||
const vkbOpenRef = useRef(false);
|
||||
vkbOpenRef.current = vkbOpen;
|
||||
const [chordsPanelOpen, setChordsPanelOpen] = useState(false);
|
||||
const vkbHeldRef = useRef(new Set());
|
||||
const [vkbSettings, setVkbSettings] = useState(function () {
|
||||
try {
|
||||
@@ -19948,6 +20171,12 @@ const App = () => {
|
||||
handleSplitTrackRef.current(selectedTrackIdRef.current);
|
||||
return;
|
||||
}
|
||||
if (e.shiftKey && !e.ctrlKey && !e.altKey && (e.key === 'K' || e.key === 'k')) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (window.__toggleChordsPanel) window.__toggleChordsPanel();
|
||||
return;
|
||||
}
|
||||
if (e.key === 'F7') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -22003,6 +22232,10 @@ const App = () => {
|
||||
});
|
||||
};
|
||||
|
||||
window.__toggleChordsPanel = function () {
|
||||
setChordsPanelOpen(function (p) { return !p; });
|
||||
};
|
||||
|
||||
// ── Server-side upload ──
|
||||
// WAV encoder tối thiểu — upload buffer-only clip khi LƯU (nếu chưa có
|
||||
// serverFileId — upload sớm thất bại/race → clip mất sau reload).
|
||||
@@ -26567,6 +26800,64 @@ const App = () => {
|
||||
input.click();
|
||||
};
|
||||
|
||||
// ── Chords Panel insert (Phase 6) ───────────────────────────────────────
|
||||
const insertChordProgression = (prog) => {
|
||||
const curTracks = activeTracks;
|
||||
const pr = prog || {};
|
||||
const chordList = pr.chords || [];
|
||||
if (!chordList.length) { showToast('Không có chord để chèn', 'warning'); return; }
|
||||
const beatSec = 60.0 / (parseInt(bpm) || 120);
|
||||
const secondsPerBar = beatSec * 4;
|
||||
const activeSub = activeTab && subTabs.find(s => s.id === activeTab && s.type === 'PIANO_ROLL');
|
||||
let trackId = null;
|
||||
let item = null;
|
||||
let insertBeat = 0;
|
||||
if (activeSub) {
|
||||
trackId = activeSub.trackId;
|
||||
const trk = curTracks.find(t => t.id === trackId);
|
||||
if (trk) item = (trk.midiItems || []).find(m => m.id === activeSub.target_id) || null;
|
||||
insertBeat = Math.max(0, (activeSub.currentTime || 0) / beatSec);
|
||||
} else {
|
||||
trackId = selectedTrackId;
|
||||
const trk = curTracks.find(t => t.id === trackId);
|
||||
if (!trk) { showToast('Chọn track trước', 'warning'); return; }
|
||||
const items = trk.midiItems || [];
|
||||
if (!items.length) {
|
||||
const newItem = { id: 'midi_' + Date.now(), name: 'Chords', startTime: currentTime, duration: secondsPerBar * 4, length_bars: 4, notes: [], color: '#a78bfa' };
|
||||
createMidiWithUndo(trackId, newItem);
|
||||
updateActiveTracks(prev => prev.map(t => t.id === trackId ? { ...t, midiItems: [...(t.midiItems || []), newItem] } : t));
|
||||
item = newItem;
|
||||
insertBeat = 0;
|
||||
} else {
|
||||
item = items[items.length - 1];
|
||||
insertBeat = Math.max(0, (currentTime - (item.startTime || 0)) / beatSec);
|
||||
}
|
||||
}
|
||||
if (!item) { showToast('Không tìm thấy MIDI item', 'warning'); return; }
|
||||
const merged = (item.notes || []).slice();
|
||||
let beat = insertBeat;
|
||||
chordList.forEach(function (ch, ci) {
|
||||
const dur = ch.duration_beats != null ? ch.duration_beats : 4;
|
||||
(ch.pitches || []).forEach(function (p) {
|
||||
merged.push({ id: 'note_chord_' + Date.now() + '_' + ci + '_' + p, pitch: p, start_beat: beat, duration_beats: dur, velocity: 0.8, pan: 0.0 });
|
||||
});
|
||||
beat += dur;
|
||||
});
|
||||
const finalItem = { ...item, notes: merged };
|
||||
updateActiveTracks(prev => prev.map(t => {
|
||||
if (t.id !== trackId) return t;
|
||||
const has = (t.midiItems || []).some(m => m.id === item.id);
|
||||
if (!has) return { ...t, midiItems: [...(t.midiItems || []), finalItem] };
|
||||
return { ...t, midiItems: (t.midiItems || []).map(m => m.id === item.id ? finalItem : m) };
|
||||
}));
|
||||
if (activeSub) {
|
||||
setSubTabs(prev => prev.map(s => s.id === activeSub.id ? { ...s, notes: merged, isDirty: true } : s));
|
||||
}
|
||||
setCanvasRedrawCount(n => n + 1);
|
||||
showToast('Đã chèn ' + chordList.length + ' chord (' + merged.length + ' notes)', 'success');
|
||||
window.__lastChordInsert = { trackId: trackId, itemId: item.id, notes: merged };
|
||||
};
|
||||
|
||||
// ── Server-side Export ──
|
||||
const triggerWavExport = async () => {
|
||||
// TỰ ĐỘNG bounce realtime nếu project có MIDI items: FluidSynth (ScriptProcessor)
|
||||
@@ -29768,6 +30059,11 @@ STRICT CONSTRAINTS:
|
||||
label: 'Insert sound clip',
|
||||
icon: 'file-input',
|
||||
action: insertSoundClipAtCursor
|
||||
}, {
|
||||
label: 'Chords panel',
|
||||
icon: 'guitar',
|
||||
shortcut: 'Shift+K',
|
||||
action: () => window.__toggleChordsPanel()
|
||||
}, {
|
||||
label: 'Insert track',
|
||||
icon: 'plus',
|
||||
@@ -32971,6 +33267,12 @@ STRICT CONSTRAINTS:
|
||||
onNoteOn: vkbNoteOn,
|
||||
onNoteOff: vkbNoteOff,
|
||||
onClose: () => window.__toggleVkb()
|
||||
}), chordsPanelOpen && /*#__PURE__*/React.createElement(ChordsPanel, {
|
||||
onClose: () => window.__toggleChordsPanel(),
|
||||
onInsert: insertChordProgression,
|
||||
onNoteOn: vkbNoteOn,
|
||||
onNoteOff: vkbNoteOff,
|
||||
bpm: bpm
|
||||
}), /*#__PURE__*/React.createElement(ExportModal, {
|
||||
open: showExportPanel,
|
||||
onClose: () => setShowExportPanel(false),
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,171 @@
|
||||
// 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;
|
||||
@@ -0,0 +1,63 @@
|
||||
// SonicForge Studio - chordTheory.js unit test (BIG_PHASE_3.md §6.4)
|
||||
// Node assert, không framework. Chạy: node app/static/js/services/chordTheory.test.js
|
||||
const assert = require('assert');
|
||||
const ChordTheory = require('./chordTheory.js');
|
||||
|
||||
let fails = 0;
|
||||
function check(name, fn) {
|
||||
try { fn(); console.log('ok: ' + name); }
|
||||
catch (e) { fails++; console.error('FAIL: ' + name + ' — ' + e.message); }
|
||||
}
|
||||
function deep(arr, expected) { assert.deepStrictEqual(arr, expected); }
|
||||
|
||||
// 1. Parse chord symbol → pitch array (octave 4 = C4=60)
|
||||
check('Cmaj7 = [60,64,67,71]', () => deep(ChordTheory.parseChordSymbol('Cmaj7'), [60, 64, 67, 71]));
|
||||
check('Am9 = [69,72,76,79,83]', () => deep(ChordTheory.parseChordSymbol('Am9'), [69, 72, 76, 79, 83]));
|
||||
check('Dm7 = [62,65,69,72]', () => deep(ChordTheory.parseChordSymbol('Dm7'), [62, 65, 69, 72]));
|
||||
check('G13 = [67,71,74,77,81,88]', () => deep(ChordTheory.parseChordSymbol('G13'), [67, 71, 74, 77, 81, 88]));
|
||||
check('Fadd9 = [65,69,72,79]', () => deep(ChordTheory.parseChordSymbol('Fadd9'), [65, 69, 72, 79]));
|
||||
|
||||
// 2. Roman numeral progression với key C
|
||||
check('I-vi-IV-V key C → C-Am-F-G', () => {
|
||||
const prog = ChordTheory.progressionToChords('I-vi-IV-V', 'C', 'root');
|
||||
deep(prog.map(c => c.name), ['C', 'Am', 'F', 'G']);
|
||||
deep(prog.map(c => c.pitches), [[60, 64, 67], [69, 72, 76], [65, 69, 72], [67, 71, 74]]);
|
||||
});
|
||||
check('I-V-vi-IV key C → C-G-Am-F (preset Pop)', () => {
|
||||
const prog = ChordTheory.progressionToChords('I-V-vi-IV', 'C', 'root');
|
||||
deep(prog.map(c => c.name), ['C', 'G', 'Am', 'F']);
|
||||
});
|
||||
check('ii7-V7-Imaj7-VI7 key C → Dm7-G7-Cmaj7-A7', () => {
|
||||
const prog = ChordTheory.progressionToChords('ii7-V7-Imaj7-VI7', 'C', 'root');
|
||||
deep(prog.map(c => c.name), ['Dm7', 'G7', 'Cmaj7', 'A7']);
|
||||
deep(prog[0].pitches, [62, 65, 69, 72]); // Dm7
|
||||
deep(prog[2].pitches, [60, 64, 67, 71]); // Cmaj7
|
||||
});
|
||||
check('i7-iv7-v7-i7 key C → Cm7-Fm7-Gm7-Cm7', () => {
|
||||
const prog = ChordTheory.progressionToChords('i7-iv7-v7-i7', 'C', 'root');
|
||||
deep(prog.map(c => c.name), ['Cm7', 'Fm7', 'Gm7', 'Cm7']);
|
||||
});
|
||||
check('Imaj7-iii7-IVmaj7-V13 key C → Cmaj7-Em7-Fmaj7-G13', () => {
|
||||
const prog = ChordTheory.progressionToChords('Imaj7-iii7-IVmaj7-V13', 'C', 'root');
|
||||
deep(prog.map(c => c.name), ['Cmaj7', 'Em7', 'Fmaj7', 'G13']);
|
||||
});
|
||||
|
||||
// 3. Voicing
|
||||
check('first inversion: root +12', () => deep(ChordTheory.applyVoicing([60, 64, 67, 71], 'first'), [64, 67, 71, 72]));
|
||||
check('drop2: second-highest -12', () => deep(ChordTheory.applyVoicing([60, 64, 67, 71], 'drop2'), [55, 60, 64, 71]));
|
||||
check('root position giữ nguyên', () => deep(ChordTheory.applyVoicing([60, 64, 67, 71], 'root'), [60, 64, 67, 71]));
|
||||
check('jazz drop2: Dm7-G7-Cmaj7-A7 ra đủ pitch', () => {
|
||||
const prog = ChordTheory.progressionToChords('ii7-V7-Imaj7-VI7', 'C', 'drop2');
|
||||
deep(prog.map(c => c.name), ['Dm7', 'G7', 'Cmaj7', 'A7']);
|
||||
assert(prog.every(c => c.pitches.length === 4), 'mỗi chord 4 pitch');
|
||||
});
|
||||
|
||||
// 4. Edge cases
|
||||
check('chuỗi chord name trực tiếp Cmaj7-Am9-Fadd9-G13', () => {
|
||||
const prog = ChordTheory.progressionToChords('Cmaj7-Am9-Fadd9-G13', 'C', 'root');
|
||||
deep(prog.map(c => c.name), ['Cmaj7', 'Am9', 'Fadd9', 'G13']);
|
||||
deep(prog[0].pitches, [60, 64, 67, 71]);
|
||||
});
|
||||
|
||||
console.log(fails ? 'FAILED: ' + fails : 'ALL PASS');
|
||||
process.exit(fails ? 1 : 0);
|
||||
@@ -50,7 +50,8 @@
|
||||
<script src="/static/js/services/midiExtractor.js?v=202607281052"></script>
|
||||
<script src="/static/js/services/promptTemplateManager.js?v=202607281039"></script>
|
||||
<script src="/static/js/services/undoRedoEngine.js?v=202607290941"></script>
|
||||
<script src="/static/js/app.precompiled.js?v=202608211150" defer></script>
|
||||
<script src="/static/js/services/chordTheory.js?v=202608211300"></script>
|
||||
<script src="/static/js/app.precompiled.js?v=202608211300" defer></script>
|
||||
<link rel="stylesheet" href="/static/css/styles.css?v=202607271016">
|
||||
<style>
|
||||
:root {
|
||||
|
||||
Reference in New Issue
Block a user