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
+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),