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:
2026-08-21 12:35:20 +07:00
parent ec85e7d1a9
commit a667d0354f
6 changed files with 562 additions and 11 deletions
+11 -1
View File
@@ -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
View File
@@ -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
+171
View File
@@ -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);
+2 -1
View File
@@ -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 {