fix: piano roll tab có thể play với soundfont
This commit is contained in:
+11
-1
@@ -47,10 +47,20 @@ async def upload_soundfont(
|
||||
if not PluginManager.validate_sf2_header(contents):
|
||||
raise HTTPException(status_code=400, detail="Invalid SoundFont file: missing RIFF/sfbk header")
|
||||
|
||||
file_id = str(uuid.uuid4()) + os.path.splitext(file.filename)[1]
|
||||
file_ext = os.path.splitext(file.filename)[1]
|
||||
file_uuid = str(uuid.uuid4())
|
||||
# Store original name in a sidecar file
|
||||
base_name = os.path.splitext(file.filename)[0].replace('/', '_').replace('\\', '_')
|
||||
file_id = file_uuid + file_ext
|
||||
dest_path = os.path.join(UPLOAD_SF_DIR, file_id)
|
||||
with open(dest_path, "wb") as f:
|
||||
f.write(contents)
|
||||
|
||||
# Save metadata with original name
|
||||
meta_path = os.path.join(UPLOAD_SF_DIR, file_uuid + ".meta")
|
||||
with open(meta_path, "w", encoding="utf-8") as f:
|
||||
import json
|
||||
json.dump({"original_name": file.filename, "uuid": file_uuid, "file": file_id}, f)
|
||||
|
||||
return {"id": file_id, "name": file.filename, "path": dest_path, "size_bytes": len(contents)}
|
||||
|
||||
|
||||
+22
-1
@@ -108,12 +108,33 @@ class PluginManager:
|
||||
dirs = [self.sf_dir]
|
||||
if self.upload_sf_dir and self.upload_sf_dir != self.sf_dir:
|
||||
dirs.append(self.upload_sf_dir)
|
||||
|
||||
# Load metadata cache for upload soundfonts
|
||||
meta_cache = {}
|
||||
if self.upload_sf_dir and os.path.isdir(self.upload_sf_dir):
|
||||
for f in os.listdir(self.upload_sf_dir):
|
||||
if f.endswith(".meta"):
|
||||
try:
|
||||
import json
|
||||
with open(os.path.join(self.upload_sf_dir, f), "r") as mf:
|
||||
meta_cache[os.path.splitext(f)[0]] = json.load(mf)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
for d in dirs:
|
||||
if not os.path.isdir(d):
|
||||
continue
|
||||
for f in os.listdir(d):
|
||||
if f.endswith(".sf2") or f.endswith(".sf3"):
|
||||
sfonts.append({"id": os.path.splitext(f)[0], "name": f, "file": f})
|
||||
base_id = os.path.splitext(f)[0]
|
||||
meta = meta_cache.get(base_id, None)
|
||||
if meta:
|
||||
display_name = meta.get("original_name", f)
|
||||
else:
|
||||
# Generate a friendly name from UUID: truncate to first 8 chars
|
||||
short_id = base_id[:8] if len(base_id) > 8 else base_id
|
||||
display_name = f"SoundFont_{short_id}"
|
||||
sfonts.append({"id": base_id, "name": display_name, "file": f, "display": os.path.splitext(display_name)[0][:40]})
|
||||
return sfonts
|
||||
|
||||
def load_vst(self, plugin_name: str, preset_data: dict = None):
|
||||
|
||||
+363
-98
@@ -3138,10 +3138,11 @@ const PluginManagerModal = ({ isOpen, onClose, pluginsData }) => {
|
||||
const [localData, setLocalData] = React.useState(pluginsData);
|
||||
const [sfUploadStatus, setSfUploadStatus] = React.useState('');
|
||||
React.useEffect(() => {
|
||||
if (isOpen && !localData) {
|
||||
if (isOpen) {
|
||||
window.SonicAPI.listPlugins()
|
||||
.then(data => setLocalData(data))
|
||||
.catch(() => setLocalData({ vst_instruments: [], soundfonts: [] }));
|
||||
setTimeout(() => { try { window.lucide.createIcons(); } catch(e) {} }, 50);
|
||||
}
|
||||
}, [isOpen]);
|
||||
const handleUploadSF = async (e) => {
|
||||
@@ -3158,55 +3159,177 @@ const PluginManagerModal = ({ isOpen, onClose, pluginsData }) => {
|
||||
setSfUploadStatus('Error: ' + err.message);
|
||||
}
|
||||
};
|
||||
const vsts = localData?.vst_instruments || [];
|
||||
const sfs = localData?.soundfonts || [];
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm" onClick={onClose}>
|
||||
<div className="bg-[#262626] border border-[#383838] rounded-xl shadow-2xl w-full max-w-2xl p-6 text-slate-200" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex justify-between items-center pb-4 border-b border-[#383838]">
|
||||
<h3 className="text-lg font-bold text-cyan-400">Plugin Manager (SoundFont / VSTi)</h3>
|
||||
<button onClick={onClose} className="text-slate-400 hover:text-slate-200">✕</button>
|
||||
</div>
|
||||
<div className="mt-4 space-y-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-bold text-purple-400 mb-2 uppercase">VST Instruments</h4>
|
||||
{vsts.length === 0 ? (
|
||||
<p className="text-xs text-zinc-500">No VST instruments available on server.</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-1">
|
||||
{vsts.map((v, i) => (
|
||||
<div key={i} className="flex items-center justify-between bg-[#1e1e1e] px-3 py-2 rounded border border-[#333]">
|
||||
<span className="text-xs font-semibold text-slate-200">{v.id}</span>
|
||||
<span className="text-[10px] text-cyan-400 bg-cyan-950/40 px-1.5 py-0.5 rounded">{v.type}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-bold text-amber-400 mb-2 uppercase">SoundFonts</h4>
|
||||
{sfs.length === 0 ? (
|
||||
<p className="text-xs text-zinc-500">No SoundFonts available.</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{sfs.map((sf, i) => (
|
||||
<div key={i} className="flex items-center justify-between bg-[#1e1e1e] px-3 py-2 rounded border border-[#333]">
|
||||
<span className="text-xs text-slate-200">{sf.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="pt-4 border-t border-[#383838]">
|
||||
<h4 className="text-sm font-bold text-emerald-400 mb-2 uppercase">Upload SoundFont</h4>
|
||||
<input type="file" accept=".sf2,.sf3" onChange={handleUploadSF}
|
||||
className="w-full text-xs text-zinc-400 file:mr-2 file:py-1 file:px-3 file:rounded file:border-0 file:text-xs file:font-semibold file:bg-emerald-700 file:text-white hover:file:bg-emerald-600" />
|
||||
{sfUploadStatus && <p className="text-xs mt-1 text-zinc-400">{sfUploadStatus}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
const [pmTab, setPmTab] = React.useState('soundfont');
|
||||
return React.createElement('div', {
|
||||
className: 'fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm',
|
||||
onClick: onClose
|
||||
}, React.createElement('div', {
|
||||
className: 'bg-[#1e1e1e] border border-[#383838] rounded-xl shadow-2xl w-full max-w-3xl p-0 text-slate-200 overflow-hidden flex flex-col',
|
||||
style: { maxHeight: '80vh' },
|
||||
onClick: e => e.stopPropagation()
|
||||
},
|
||||
// Header
|
||||
React.createElement('div', {
|
||||
className: 'flex items-center justify-between px-5 py-3 bg-[#252525] border-b border-[#383838]'
|
||||
},
|
||||
React.createElement('h3', {
|
||||
className: 'text-base font-bold text-cyan-400 flex items-center gap-2'
|
||||
}, React.createElement('i', { 'data-lucide': 'zap', className: 'w-4 h-4' }), 'Plugin Manager (SoundFont / VSTi)'),
|
||||
React.createElement('button', {
|
||||
onClick: onClose,
|
||||
className: 'text-zinc-500 hover:text-zinc-200 transition'
|
||||
}, React.createElement('i', { 'data-lucide': 'x', className: 'w-4 h-4' }))
|
||||
),
|
||||
// Left-right body
|
||||
React.createElement('div', {
|
||||
className: 'flex flex-1 overflow-hidden',
|
||||
style: { minHeight: '300px' }
|
||||
},
|
||||
// Left sidebar
|
||||
React.createElement('div', {
|
||||
className: 'w-40 shrink-0 border-r border-[#383838] bg-[#1a1a1a] p-3 flex flex-col gap-2'
|
||||
},
|
||||
['vst', 'soundfont'].map(tab =>
|
||||
React.createElement('button', {
|
||||
key: tab,
|
||||
onClick: () => setPmTab(tab),
|
||||
className: `w-full py-2 text-xs font-bold rounded transition border ${pmTab === tab ? (tab === 'vst' ? 'bg-violet-900 border-violet-700 text-violet-200' : 'bg-amber-900 border-amber-700 text-amber-200') : 'bg-zinc-800 border-transparent text-zinc-400 hover:text-zinc-200 hover:bg-zinc-700'} flex items-center gap-2 px-3`
|
||||
}, React.createElement('i', {
|
||||
'data-lucide': tab === 'vst' ? 'cpu' : 'music',
|
||||
className: 'w-3.5 h-3.5'
|
||||
}), tab === 'vst' ? 'VST Instruments' : 'SoundFonts')
|
||||
)
|
||||
),
|
||||
// Right content
|
||||
React.createElement('div', {
|
||||
className: 'flex-1 overflow-y-auto p-4 bg-[#1e1e1e]'
|
||||
}, !localData ?
|
||||
React.createElement('div', { className: 'flex items-center justify-center h-full text-zinc-500 text-xs' }, 'Loading...') :
|
||||
React.createElement('div', { className: 'space-y-2' },
|
||||
pmTab === 'vst' ?
|
||||
(localData.vst_instruments?.length === 0 ?
|
||||
React.createElement('div', { className: 'flex items-center justify-center h-32 text-zinc-500 text-xs' }, 'No VST instruments found on server.') :
|
||||
localData.vst_instruments.map((v, i) =>
|
||||
React.createElement('div', {
|
||||
key: i,
|
||||
className: 'flex items-center justify-between bg-[#252525] px-4 py-3 rounded-lg border border-[#333] hover:border-violet-800/50 transition'
|
||||
},
|
||||
React.createElement('div', { className: 'flex items-center gap-3' },
|
||||
React.createElement('div', { className: 'w-8 h-8 rounded bg-violet-900/30 flex items-center justify-center' },
|
||||
React.createElement('i', { 'data-lucide': 'cpu', className: 'w-4 h-4 text-violet-400' })
|
||||
),
|
||||
React.createElement('div', null,
|
||||
React.createElement('div', { className: 'text-xs font-semibold text-slate-200' }, v.name || v.id),
|
||||
React.createElement('div', { className: 'text-[10px] text-zinc-500' }, v.type || 'VST3')
|
||||
)
|
||||
),
|
||||
React.createElement('span', { className: 'text-[10px] bg-violet-950/40 text-violet-400 px-2 py-0.5 rounded-full border border-violet-800/30' }, v.type || 'VST3')
|
||||
)
|
||||
)
|
||||
) :
|
||||
(localData.soundfonts?.length === 0 ?
|
||||
React.createElement('div', { className: 'flex items-center justify-center h-32 text-zinc-500 text-xs' }, 'No SoundFonts found. Upload one below.') :
|
||||
localData.soundfonts.map((sf, i) =>
|
||||
React.createElement('div', {
|
||||
key: i,
|
||||
className: 'flex items-center justify-between bg-[#252525] px-4 py-3 rounded-lg border border-[#333] hover:border-amber-800/50 transition group'
|
||||
},
|
||||
React.createElement('div', { className: 'flex items-center gap-3' },
|
||||
React.createElement('div', { className: 'w-8 h-8 rounded bg-amber-900/30 flex items-center justify-center' },
|
||||
React.createElement('i', { 'data-lucide': 'music', className: 'w-4 h-4 text-amber-400' })
|
||||
),
|
||||
React.createElement('div', null,
|
||||
React.createElement('div', { className: 'text-xs font-semibold text-slate-200' }, sf.display || sf.name || sf.id),
|
||||
React.createElement('div', { className: 'text-[10px] text-zinc-500' }, sf.file || sf.name)
|
||||
)
|
||||
),
|
||||
React.createElement('div', { className: 'flex items-center gap-2' },
|
||||
React.createElement('button', {
|
||||
onClick: async () => {
|
||||
if (!confirm('Delete SoundFont ' + (sf.display || sf.name) + '?')) return;
|
||||
try {
|
||||
if (window.SonicAPI.deleteSoundFont) {
|
||||
await window.SonicAPI.deleteSoundFont(sf.id);
|
||||
}
|
||||
const data = await window.SonicAPI.listPlugins();
|
||||
setLocalData(data);
|
||||
window.showToast && window.showToast('SoundFont deleted.', 'info');
|
||||
} catch (err) {
|
||||
window.showToast && window.showToast('Delete failed: ' + err.message, 'error');
|
||||
}
|
||||
},
|
||||
className: 'text-[10px] text-zinc-500 hover:text-red-400 opacity-0 group-hover:opacity-100 transition px-2 py-1'
|
||||
}, 'Delete')
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
// Upload section (bottom of right panel)
|
||||
React.createElement('div', {
|
||||
className: 'pt-4 mt-4 border-t border-[#383838]'
|
||||
},
|
||||
React.createElement('h4', { className: 'text-xs font-bold text-zinc-400 mb-3 uppercase' },
|
||||
pmTab === 'vst' ? 'Add VST Directory' : 'Upload SoundFont'
|
||||
),
|
||||
pmTab === 'vst' ?
|
||||
React.createElement('div', { className: 'flex gap-2' },
|
||||
React.createElement('input', {
|
||||
type: 'text',
|
||||
placeholder: '/opt/daw_engine/vst3',
|
||||
className: 'flex-1 bg-zinc-800 border border-zinc-700 rounded px-3 py-2 text-xs text-zinc-300 focus:outline-none focus:border-violet-600'
|
||||
}),
|
||||
React.createElement('button', {
|
||||
className: 'px-4 py-2 bg-violet-800 hover:bg-violet-700 text-white text-xs font-semibold rounded transition',
|
||||
onClick: async () => {
|
||||
try {
|
||||
const data = await window.SonicAPI.listPlugins();
|
||||
setLocalData(data);
|
||||
showToast('Scanned VST directory.', 'info');
|
||||
} catch (err) {
|
||||
showToast('Scan failed: ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
}, 'Scan')
|
||||
) :
|
||||
React.createElement('div', { className: 'space-y-2' },
|
||||
React.createElement('label', {
|
||||
className: 'flex items-center gap-3 px-4 py-3 border-2 border-dashed border-zinc-700 rounded-lg cursor-pointer hover:border-amber-600/50 bg-zinc-800/40 transition'
|
||||
},
|
||||
React.createElement('i', { 'data-lucide': 'upload', className: 'w-5 h-5 text-zinc-400' }),
|
||||
React.createElement('span', { className: 'text-xs text-zinc-400' }, 'Click to upload .sf2 / .sf3 file'),
|
||||
React.createElement('input', {
|
||||
type: 'file',
|
||||
accept: '.sf2,.sf3',
|
||||
onChange: async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
setSfUploadStatus('Uploading...');
|
||||
try {
|
||||
await window.SonicAPI.uploadSoundFont(file);
|
||||
setSfUploadStatus('Uploaded: ' + file.name);
|
||||
const data = await window.SonicAPI.listPlugins();
|
||||
setLocalData(data);
|
||||
} catch (err) {
|
||||
setSfUploadStatus('Error: ' + err.message);
|
||||
}
|
||||
},
|
||||
className: 'hidden'
|
||||
})
|
||||
),
|
||||
sfUploadStatus && React.createElement('p', { className: 'text-[10px] text-zinc-500' }, sfUploadStatus)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
// Status bar at bottom
|
||||
React.createElement('div', {
|
||||
className: 'px-5 py-2 bg-[#1a1a1a] border-t border-[#383838] flex items-center justify-between text-[10px] text-zinc-500'
|
||||
},
|
||||
React.createElement('span', null, 'VST: ', localData?.vst_instruments?.length || 0, ' | SoundFonts: ', localData?.soundfonts?.length || 0),
|
||||
React.createElement('span', null, 'Last scanned: ', new Date().toLocaleTimeString())
|
||||
)
|
||||
));
|
||||
};
|
||||
|
||||
const ProfileModal = ({
|
||||
@@ -4197,6 +4320,8 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
|
||||
const rulerScrollRef = React.useRef(null);
|
||||
|
||||
const NoteHeight = 18;
|
||||
const PITCH_START = 36; // C2
|
||||
const KeybedPixelHeight = (127 - PITCH_START + 1) * NoteHeight;
|
||||
const pixelsPerBeat = rollZoom;
|
||||
const timeSigNum = 4;
|
||||
const totalBeats = (st.duration || 4) * timeSigNum;
|
||||
@@ -4388,7 +4513,21 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
|
||||
ctx.strokeRect(mx, my, mw, mh);
|
||||
ctx.setLineDash([]);
|
||||
}
|
||||
}, [notes, snapVal, rollZoom, selectedNoteIds, selectionMarquee]);
|
||||
|
||||
// Draw playhead
|
||||
if (st.currentTime !== undefined && st.currentTime !== null && st.currentTime >= 0) {
|
||||
const phBeat = st.currentTime / (60.0 / (parseInt(bpm) || 120));
|
||||
const phX = phBeat * pixelsPerBeat;
|
||||
if (phX >= 0 && phX <= drawWidth) {
|
||||
ctx.strokeStyle = '#f59e0b';
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(phX, 0);
|
||||
ctx.lineTo(phX, (127 - PITCH_START) * NoteHeight);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
}, [notes, snapVal, rollZoom, selectedNoteIds, selectionMarquee, st.currentTime, bpm]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const canvas = ccCanvasRef.current;
|
||||
@@ -4436,7 +4575,7 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
|
||||
|
||||
React.useEffect(() => {
|
||||
if (gridScrollRef.current) {
|
||||
gridScrollRef.current.scrollTop = (127 - 60) * NoteHeight - 150;
|
||||
gridScrollRef.current.scrollTop = (127 - 48) * NoteHeight - 180;
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -4466,22 +4605,44 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
|
||||
|
||||
if (e.button !== 0) return; // Only handle left click
|
||||
|
||||
// Ctrl+Click -> Quick draw note!
|
||||
// Check if clicking on an existing note
|
||||
const clickedNoteIdx = notes.findIndex(n => {
|
||||
return pitch === n.pitch && beat >= n.start_beat && beat < n.start_beat + n.duration_beats;
|
||||
});
|
||||
|
||||
// Ctrl+click: selection mode (select notes by touching)
|
||||
if (e.ctrlKey) {
|
||||
const start = getSnapBeat(beat, snapVal);
|
||||
const newNote = {
|
||||
id: 'note_' + Date.now() + Math.random().toString(36).substr(2, 5),
|
||||
pitch: pitch,
|
||||
start_beat: start,
|
||||
duration_beats: getSnapDuration(snapVal),
|
||||
velocity: 0.8,
|
||||
pan: 0.0
|
||||
};
|
||||
setNotes(prev => [...prev, newNote]);
|
||||
showToast('Đã vẽ nhanh nốt mới!', 'info');
|
||||
if (clickedNoteIdx !== -1) {
|
||||
// Ctrl+click on note: toggle selection
|
||||
const clickedNote = notes[clickedNoteIdx];
|
||||
if (selectedNoteIds.includes(clickedNote.id)) {
|
||||
setSelectedNoteIds(prev => prev.filter(id => id !== clickedNote.id));
|
||||
} else {
|
||||
setSelectedNoteIds(prev => [...prev, clickedNote.id]);
|
||||
}
|
||||
// Start drag to select more notes
|
||||
const selectedNotesOffset = notes
|
||||
.filter(n => selectedNoteIds.includes(n.id) || n.id === notes[clickedNoteIdx].id)
|
||||
.map(n => ({ id: n.id, originalStartBeat: n.start_beat, originalPitch: n.pitch }));
|
||||
setDraggedNote({
|
||||
mode: 'move',
|
||||
idx: clickedNoteIdx,
|
||||
startOffsetBeat: beat - notes[clickedNoteIdx].start_beat,
|
||||
startOffsetPitch: pitch - notes[clickedNoteIdx].pitch,
|
||||
selectedNotesOffset: selectedNotesOffset
|
||||
});
|
||||
} else {
|
||||
// Ctrl+click on empty space: start selection marquee
|
||||
setSelectedNoteIds([]);
|
||||
setSelectionMarquee({
|
||||
startBeat: beat, startPitch: pitch,
|
||||
currentBeat: beat, currentPitch: pitch
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Hovered resize edge
|
||||
if (hoveredResizeIdx !== -1) {
|
||||
setDraggedNote({
|
||||
mode: 'resize',
|
||||
@@ -4491,13 +4652,9 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
|
||||
return;
|
||||
}
|
||||
|
||||
const clickedNoteIdx = notes.findIndex(n => {
|
||||
return pitch === n.pitch && beat >= n.start_beat && beat < n.start_beat + n.duration_beats;
|
||||
});
|
||||
|
||||
if (clickedNoteIdx !== -1) {
|
||||
// Click on existing note: drag-move
|
||||
const clickedNote = notes[clickedNoteIdx];
|
||||
// Selection management
|
||||
if (!selectedNoteIds.includes(clickedNote.id)) {
|
||||
if (e.shiftKey) {
|
||||
setSelectedNoteIds(prev => [...prev, clickedNote.id]);
|
||||
@@ -4505,7 +4662,6 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
|
||||
setSelectedNoteIds([clickedNote.id]);
|
||||
}
|
||||
}
|
||||
|
||||
const selectedNotesOffset = notes
|
||||
.filter(n => selectedNoteIds.includes(n.id) || n.id === clickedNote.id)
|
||||
.map(n => ({
|
||||
@@ -4513,7 +4669,6 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
|
||||
originalStartBeat: n.start_beat,
|
||||
originalPitch: n.pitch
|
||||
}));
|
||||
|
||||
setDraggedNote({
|
||||
mode: 'move',
|
||||
idx: clickedNoteIdx,
|
||||
@@ -4522,14 +4677,33 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
|
||||
selectedNotesOffset: selectedNotesOffset
|
||||
});
|
||||
} else {
|
||||
// Clicked in empty space -> Start selection marquee
|
||||
setSelectedNoteIds([]);
|
||||
setSelectionMarquee({
|
||||
startBeat: beat,
|
||||
startPitch: pitch,
|
||||
currentBeat: beat,
|
||||
currentPitch: pitch
|
||||
// Click on empty space: DRAW a new note starting from click position
|
||||
const start = getSnapBeat(beat, snapVal);
|
||||
const initialDur = getSnapDuration(snapVal);
|
||||
const noteId = 'note_' + Date.now() + Math.random().toString(36).substr(2, 5);
|
||||
const newNote = {
|
||||
id: noteId,
|
||||
pitch: pitch,
|
||||
start_beat: start,
|
||||
duration_beats: initialDur,
|
||||
velocity: 0.8,
|
||||
pan: 0.0
|
||||
};
|
||||
setNotes(prev => [...prev, newNote]);
|
||||
setSelectedNoteIds([noteId]);
|
||||
setDraggedNote({
|
||||
mode: 'draw',
|
||||
idx: -1,
|
||||
startOffsetBeat: start,
|
||||
startOffsetPitch: pitch,
|
||||
drawNoteId: noteId,
|
||||
drawDuration: initialDur
|
||||
});
|
||||
// Play the note with SoundFont
|
||||
if (window.SonicSF) {
|
||||
const ctx = getAudioContext();
|
||||
window.SonicSF.playNote(pitch, 0.8, 300, ctx.currentTime, undefined, null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -4586,7 +4760,15 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (draggedNote.mode === 'draw') {
|
||||
const rawDur = beat - draggedNote.startOffsetBeat;
|
||||
const newDur = getSnapBeat(Math.max(0.125, rawDur), snapVal);
|
||||
setNotes(prev => prev.map(n => {
|
||||
if (n.id !== draggedNote.drawNoteId) return n;
|
||||
return { ...n, duration_beats: newDur };
|
||||
}));
|
||||
return;
|
||||
}
|
||||
if (draggedNote.mode === 'resize') {
|
||||
const newDuration = getSnapBeat(Math.max(0.125, beat - draggedNote.originalStart), snapVal);
|
||||
setNotes(prev => prev.map((n, idx) => {
|
||||
@@ -4659,7 +4841,7 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
|
||||
|
||||
const renderKeybed = () => {
|
||||
const keys = [];
|
||||
for (let pitch = 127; pitch >= 0; pitch--) {
|
||||
for (let pitch = 127; pitch >= PITCH_START; pitch--) {
|
||||
const isBlack = [1, 3, 6, 8, 10].includes(pitch % 12);
|
||||
const notesArray = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
|
||||
const octave = Math.floor(pitch / 12) - 1;
|
||||
@@ -4685,6 +4867,11 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
|
||||
rulerScrollRef.current.scrollLeft = e.currentTarget.scrollLeft;
|
||||
}
|
||||
};
|
||||
const handleKeybedScroll = (e) => {
|
||||
if (gridScrollRef.current) {
|
||||
gridScrollRef.current.scrollTop = e.currentTarget.scrollTop;
|
||||
}
|
||||
};
|
||||
|
||||
const renderBarLabels = () => {
|
||||
const labels = [];
|
||||
@@ -4706,9 +4893,12 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
|
||||
return labels;
|
||||
};
|
||||
|
||||
return /*#__PURE__*/React.createElement("div", {
|
||||
const beatSec = 60.0 / (parseInt(bpm) || 120);
|
||||
const playHeadX = (st.currentTime || 0) / beatSec * pixelsPerBeat;
|
||||
|
||||
return React.createElement("div", {
|
||||
className: "flex-1 flex overflow-hidden min-h-0 bg-[#1e1e1e] flex-col"
|
||||
}, /*#__PURE__*/React.createElement("div", {
|
||||
}, React.createElement("div", {
|
||||
className: "h-10 bg-[#282828] border-b border-zinc-900 flex items-center justify-between px-4 shrink-0 text-slate-200"
|
||||
}, /*#__PURE__*/React.createElement("div", {
|
||||
className: "flex items-center gap-4"
|
||||
@@ -4771,7 +4961,8 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
|
||||
className: "flex-1 flex overflow-hidden min-h-0 relative"
|
||||
}, /*#__PURE__*/React.createElement("div", {
|
||||
ref: keybedRef,
|
||||
className: "w-[60px] overflow-hidden flex flex-col border-r border-zinc-900 shrink-0",
|
||||
onScroll: handleKeybedScroll,
|
||||
className: "w-[60px] overflow-y-auto flex flex-col border-r border-zinc-900 shrink-0",
|
||||
style: {
|
||||
scrollbarWidth: 'none',
|
||||
msOverflowStyle: 'none'
|
||||
@@ -4783,7 +4974,7 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
|
||||
}, /*#__PURE__*/React.createElement("div", {
|
||||
style: {
|
||||
width: `${drawWidth}px`,
|
||||
height: `${128 * NoteHeight}px`
|
||||
height: `${(128 - PITCH_START) * NoteHeight}px`
|
||||
},
|
||||
className: "relative"
|
||||
}, /*#__PURE__*/React.createElement("canvas", {
|
||||
@@ -5103,23 +5294,23 @@ const App = () => {
|
||||
"Tinkle Bell","Agogo","Steel Drums","Woodblock","Taiko Drum","Melodic Tom","Synth Drum","Reverse Cymbal",
|
||||
"Guitar Fret Noise","Breath Noise","Seashore","Bird Tweet","Telephone Ring","Helicopter","Applause","Gunshot"
|
||||
];
|
||||
const setTrackInstrumentWithProgram = (trackId, instrumentId, programNumber) => {
|
||||
const setTrackInstrumentWithProgram = (trackId, instrumentId, programNumber, displayName) => {
|
||||
updateActiveTracks(prev => prev.map(t => {
|
||||
if (t.id !== trackId) return t;
|
||||
return { ...t, instrumentId, instrumentProgram: programNumber !== undefined ? programNumber : undefined };
|
||||
return { ...t, instrumentId, instrumentProgram: programNumber !== undefined ? programNumber : undefined, instrumentName: displayName };
|
||||
}));
|
||||
setInstrumentSelectorTrackId(null);
|
||||
setSynthCategory(null);
|
||||
setSelectedSoundFontId(null);
|
||||
setTimeout(() => lucide.createIcons(), 50);
|
||||
};
|
||||
const setTrackInstrument = (trackId, instrumentId) => {
|
||||
const setTrackInstrument = (trackId, instrumentId, displayName) => {
|
||||
if (instrumentId && instrumentId.startsWith('sf_')) {
|
||||
setSelectedSoundFontId(instrumentId);
|
||||
setSynthCategory('soundfont');
|
||||
setInstrumentSelectorTrackId(trackId);
|
||||
} else {
|
||||
setTrackInstrumentWithProgram(trackId, instrumentId);
|
||||
setTrackInstrumentWithProgram(trackId, instrumentId, undefined, displayName);
|
||||
}
|
||||
};
|
||||
const [activeTool, setActiveTool] = useState('select'); // 'select' | 'grab' | 'razor'
|
||||
@@ -6384,6 +6575,8 @@ const App = () => {
|
||||
}
|
||||
const tabId = 'midi_' + Date.now();
|
||||
const tabLabel = `Piano Roll: ${midiItem.name || 'MIDI'}`;
|
||||
const ctx = getAudioContext();
|
||||
const silentBuffer = ctx.createBuffer(1, 128, ctx.sampleRate);
|
||||
const newTab = {
|
||||
id: tabId,
|
||||
label: tabLabel,
|
||||
@@ -6393,6 +6586,11 @@ const App = () => {
|
||||
parent_tab_id: activeTab === 'main' ? null : activeTab,
|
||||
notes: midiItem.notes || [],
|
||||
duration: midiItem.duration || 4,
|
||||
buffer: silentBuffer,
|
||||
currentTime: 0,
|
||||
isPlaying: false,
|
||||
selectionStart: null,
|
||||
selectionEnd: null,
|
||||
viewport_start_bar: 0.0,
|
||||
viewport_bar_width: 8.0,
|
||||
scroll_y_pitch: 60,
|
||||
@@ -7934,7 +8132,18 @@ const App = () => {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (bufferPos >= st.buffer.duration) {
|
||||
const effectiveDuration = st.type === 'PIANO_ROLL' ? (() => {
|
||||
const notes = st.notes || [];
|
||||
const bpmVal = parseInt(bpmRef?.current || bpm) || 120;
|
||||
const beatSec = 60.0 / bpmVal;
|
||||
let maxEnd = 0;
|
||||
notes.forEach(n => {
|
||||
const end = (n.start_beat || 0) + (n.duration_beats || 1);
|
||||
if (end > maxEnd) maxEnd = end;
|
||||
});
|
||||
return maxEnd * beatSec + 1.0;
|
||||
})() : st.buffer.duration;
|
||||
if (bufferPos >= effectiveDuration) {
|
||||
stopAllPlayback();
|
||||
if (isLoopingSelection) {
|
||||
setSubTabs(prev => prev.map(s => s.id === activeTabRef.current ? {
|
||||
@@ -8111,6 +8320,11 @@ const App = () => {
|
||||
});
|
||||
});
|
||||
}
|
||||
// If track has instrumentId set but no MIDI items, create scheduled oscillators
|
||||
if (track.instrumentId && midiItems.length === 0 && track.buffer) {
|
||||
// Play audio normally (the buffer has the audio data)
|
||||
// This block is intentionally empty - audio clips already play above
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -8205,7 +8419,7 @@ const App = () => {
|
||||
if (activeTab !== 'main') {
|
||||
// Sub-tab playback transport
|
||||
const st = subTabs.find(s => s.id === activeTab);
|
||||
if (!st || !st.buffer) return;
|
||||
if (!st || (!st.buffer && st.type !== 'PIANO_ROLL')) return;
|
||||
if (st.isPlaying) {
|
||||
stopAllPlayback();
|
||||
setSubTabs(prev => prev.map(s => s.id === activeTab ? {
|
||||
@@ -8215,7 +8429,48 @@ const App = () => {
|
||||
} else {
|
||||
stopAllPlayback();
|
||||
const startOffset = st.currentTime || 0;
|
||||
startSubTabPlayback(st, startOffset);
|
||||
|
||||
if (st.type === 'PIANO_ROLL') {
|
||||
// Piano Roll: play MIDI notes via SonicSF
|
||||
const context = getAudioContext();
|
||||
const notes = st.notes || [];
|
||||
const bpmVal = parseInt(bpm) || 120;
|
||||
const secondsPerBeat = 60.0 / bpmVal;
|
||||
const startWallTime = context.currentTime;
|
||||
|
||||
// Find the track to get instrument settings
|
||||
const track = activeTracks.find(t => t.id === st.trackId);
|
||||
const instrumentProgram = track ? track.instrumentProgram : undefined;
|
||||
const instrumentName = track ? track.instrumentName : undefined;
|
||||
|
||||
notes.forEach(note => {
|
||||
const noteOnBeat = note.start_beat || 0;
|
||||
const noteDurBeat = note.duration_beats || 1;
|
||||
const noteStartSec = noteOnBeat * secondsPerBeat;
|
||||
const noteDurSec = noteDurBeat * secondsPerBeat;
|
||||
|
||||
if (noteStartSec + noteDurSec > startOffset) {
|
||||
const effectiveStart = Math.max(0, noteStartSec - startOffset);
|
||||
const effectiveDur = noteDurSec - Math.max(0, startOffset - noteStartSec);
|
||||
const scheduledTime = startWallTime + effectiveStart;
|
||||
const durMs = effectiveDur * 1000;
|
||||
|
||||
if (window.SonicSF) {
|
||||
window.SonicSF.playNote(
|
||||
note.pitch || 60,
|
||||
note.velocity || 0.8,
|
||||
durMs,
|
||||
scheduledTime,
|
||||
instrumentProgram,
|
||||
null
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
startSubTabPlayback(st, startOffset);
|
||||
}
|
||||
|
||||
setSubTabs(prev => prev.map(s => s.id === activeTab ? {
|
||||
...s,
|
||||
isPlaying: true,
|
||||
@@ -13312,7 +13567,7 @@ const App = () => {
|
||||
className: `px-1.5 py-0.5 text-[10px] rounded font-mono font-bold border transition flex items-center gap-0.5 ${track.isArmed ? 'bg-red-600 text-white border-red-500 hover:bg-red-500' : 'bg-zinc-800 text-zinc-400 border-transparent hover:text-zinc-200'}`
|
||||
}, /*#__PURE__*/React.createElement("i", { "data-lucide": "circle", className: `w-2.5 h-2.5 ${track.isArmed ? 'fill-white' : ''}` })), /*#__PURE__*/React.createElement("button", {
|
||||
onClick: e => { e.stopPropagation(); openInstrumentSelector(track.id); },
|
||||
title: track.instrumentId ? (track.instrumentProgram !== undefined ? track.instrumentId + ' [' + track.instrumentProgram + ']' : track.instrumentId) : "Synth",
|
||||
title: track.instrumentName || track.instrumentId || "Synth",
|
||||
className: `px-1.5 py-0.5 text-[10px] rounded font-mono font-bold border transition flex items-center gap-0.5 ${track.instrumentId ? 'bg-violet-900 text-violet-300 border-violet-700' : 'bg-zinc-800 text-zinc-500 border-transparent hover:text-zinc-300'}`
|
||||
}, /*#__PURE__*/React.createElement("i", { "data-lucide": "music", className: "w-3 h-3" })), /*#__PURE__*/React.createElement("button", {
|
||||
onClick: e => {
|
||||
@@ -13658,7 +13913,17 @@ const App = () => {
|
||||
buffer: st.buffer,
|
||||
isSubTab: true
|
||||
} : null;
|
||||
const subTabTimelineWidth = Math.max(zoom * (st.buffer ? st.buffer.duration : 0), viewportWidth);
|
||||
const subTabDuration = st.type === 'PIANO_ROLL' ? (() => {
|
||||
const notes = st.notes || [];
|
||||
const beatSec = 60.0 / (parseFloat(bpm) || 120);
|
||||
let maxEnd = 0;
|
||||
notes.forEach(n => {
|
||||
const end = (n.start_beat || 0) + (n.duration_beats || 1);
|
||||
if (end > maxEnd) maxEnd = end;
|
||||
});
|
||||
return maxEnd * beatSec + 1.0;
|
||||
})() : (st.buffer && 'duration' in st.buffer ? st.buffer.duration : 4.0);
|
||||
const subTabTimelineWidth = Math.max(zoom * subTabDuration, viewportWidth);
|
||||
return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("div", {
|
||||
className: "w-[320px] shrink-0 z-30 bg-[#262626] overflow-y-scroll no-scrollbar flex flex-col border-r border-zinc-900",
|
||||
style: {
|
||||
@@ -13906,7 +14171,7 @@ const App = () => {
|
||||
className: "flex items-center justify-between text-[14px] text-zinc-500 mb-1 font-semibold uppercase"
|
||||
}, /*#__PURE__*/React.createElement("span", null, "Duration:"), /*#__PURE__*/React.createElement("span", {
|
||||
className: "font-mono text-zinc-300"
|
||||
}, st.buffer ? formatTime(st.buffer.duration) : '0s')), /*#__PURE__*/React.createElement("div", {
|
||||
}, st.buffer ? formatTime(subTabDuration) : '0s')), /*#__PURE__*/React.createElement("div", {
|
||||
className: "flex items-center justify-between text-[14px] text-zinc-500 mb-2.5 font-semibold uppercase"
|
||||
}, /*#__PURE__*/React.createElement("span", null, "SR:"), /*#__PURE__*/React.createElement("span", {
|
||||
className: "font-mono text-zinc-300"
|
||||
@@ -14512,7 +14777,7 @@ const App = () => {
|
||||
/*#__PURE__*/React.createElement("div", { className: "grid grid-cols-2 gap-0.5" },
|
||||
GM_INSTRUMENTS.map((name, i) => /*#__PURE__*/React.createElement("button", {
|
||||
key: i,
|
||||
onClick: () => setTrackInstrumentWithProgram(instrumentSelectorTrackId, selectedSoundFontId, i),
|
||||
onClick: () => setTrackInstrumentWithProgram(instrumentSelectorTrackId, selectedSoundFontId, i, name),
|
||||
className: "text-left px-2 py-1 text-[10px] rounded bg-zinc-800/60 hover:bg-amber-800 text-zinc-300 truncate"
|
||||
}, i + ". " + name))
|
||||
)
|
||||
@@ -14532,15 +14797,15 @@ const App = () => {
|
||||
instrumentSelectorData?.soundfonts?.map((sf, i) =>
|
||||
/*#__PURE__*/React.createElement("button", {
|
||||
key: "sf_" + i,
|
||||
onClick: () => setTrackInstrument(instrumentSelectorTrackId, "sf_" + sf.id),
|
||||
onClick: () => setTrackInstrument(instrumentSelectorTrackId, "sf_" + sf.id, sf.display || sf.name || sf.id),
|
||||
className: "w-full text-left px-3 py-2 text-xs rounded bg-zinc-800 hover:bg-amber-800 text-zinc-200 flex items-center justify-between"
|
||||
}, /*#__PURE__*/React.createElement("span", null, sf.name || sf.id), /*#__PURE__*/React.createElement("span", { className: "text-[10px] text-amber-400" }, "SoundFont"))
|
||||
}, /*#__PURE__*/React.createElement("span", null, sf.display || sf.name || sf.id), /*#__PURE__*/React.createElement("span", { className: "text-[10px] text-amber-400" }, "SoundFont"))
|
||||
),
|
||||
/*#__PURE__*/React.createElement("div", { className: "text-[10px] text-zinc-500 mt-2 mb-1 uppercase font-bold" }, "VST Instruments"),
|
||||
instrumentSelectorData?.vst_instruments?.map((v, i) =>
|
||||
/*#__PURE__*/React.createElement("button", {
|
||||
key: "vst_" + i,
|
||||
onClick: () => setTrackInstrumentWithProgram(instrumentSelectorTrackId, v.id),
|
||||
onClick: () => setTrackInstrumentWithProgram(instrumentSelectorTrackId, v.id, undefined, v.name || v.id),
|
||||
className: "w-full text-left px-3 py-2 text-xs rounded bg-zinc-800 hover:bg-violet-900 text-zinc-200 flex items-center justify-between"
|
||||
}, /*#__PURE__*/React.createElement("span", null, v.name || v.id), /*#__PURE__*/React.createElement("span", { className: "text-[10px] text-cyan-400" }, v.type))
|
||||
),
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -2,20 +2,30 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// Web Audio API fallback synth
|
||||
let audioCtx = null;
|
||||
let gainNode = null;
|
||||
const activeOscillators = {};
|
||||
|
||||
function getCtx() {
|
||||
if (!audioCtx) {
|
||||
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
gainNode = audioCtx.createGain();
|
||||
gainNode.gain.value = 0.3;
|
||||
gainNode.connect(audioCtx.destination);
|
||||
// Use the shared AudioContext from the main app (lazy init)
|
||||
let __gainNode = null;
|
||||
const getCtx = () => {
|
||||
if (typeof getAudioContext === 'function') {
|
||||
const ctx = getAudioContext();
|
||||
if (!__gainNode) {
|
||||
__gainNode = ctx.createGain();
|
||||
__gainNode.gain.value = 0.3;
|
||||
__gainNode.connect(ctx.destination);
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
return audioCtx;
|
||||
}
|
||||
if (!window.__sharedAudioCtx) {
|
||||
window.__sharedAudioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
}
|
||||
if (!__gainNode) {
|
||||
__gainNode = window.__sharedAudioCtx.createGain();
|
||||
__gainNode.gain.value = 0.3;
|
||||
__gainNode.connect(window.__sharedAudioCtx.destination);
|
||||
}
|
||||
return window.__sharedAudioCtx;
|
||||
};
|
||||
|
||||
const SonicSF = {
|
||||
loadedFonts: {},
|
||||
@@ -131,7 +141,7 @@
|
||||
|
||||
osc.connect(noteGain);
|
||||
|
||||
const dest = destinationNode || gainNode || ctx.destination;
|
||||
const dest = destinationNode || __gainNode || ctx.destination;
|
||||
noteGain.connect(dest);
|
||||
|
||||
osc.start(startAt);
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
{"original_name": "General_GS_57e3", "uuid": "57e324e5-bea6-4937-88e0-cb6e4ef51832", "file": "57e324e5-bea6-4937-88e0-cb6e4ef51832.sf2"}
|
||||
@@ -10,13 +10,13 @@
|
||||
<script src="https://unpkg.com/lucide@latest"></script>
|
||||
<script src="https://unpkg.com/react@18.3.1/umd/react.production.min.js"></script>
|
||||
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js"></script>
|
||||
<script src="/static/js/services/api.js?v=202607231850"></script>
|
||||
<script src="/static/js/services/audioEngine.js?v=202607231850"></script>
|
||||
<script src="/static/js/services/storage.js?v=202607231850"></script>
|
||||
<script src="/static/js/services/soundfontPlayer.js?v=202607231850"></script>
|
||||
<script src="/static/js/services/aiGateway.js?v=202607231850"></script>
|
||||
<script src="/static/js/services/dawCommandDispatcher.js?v=202607231850"></script>
|
||||
<script src="/static/js/app.precompiled.js?v=202607231850" defer></script>
|
||||
<script src="/static/js/services/api.js?v=202607232030"></script>
|
||||
<script src="/static/js/services/audioEngine.js?v=202607232030"></script>
|
||||
<script src="/static/js/services/storage.js?v=202607232030"></script>
|
||||
<script src="/static/js/services/soundfontPlayer.js?v=202607232030"></script>
|
||||
<script src="/static/js/services/aiGateway.js?v=202607232030"></script>
|
||||
<script src="/static/js/services/dawCommandDispatcher.js?v=202607232030"></script>
|
||||
<script src="/static/js/app.precompiled.js?v=202607232030" defer></script>
|
||||
<style>
|
||||
:root {
|
||||
--right-sidebar-width: 320px;
|
||||
|
||||
Reference in New Issue
Block a user