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:
+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),
|
||||
|
||||
Reference in New Issue
Block a user