fix: piano roll tab có thể play với soundfont

This commit is contained in:
2026-07-23 20:34:52 +07:00
parent a21266736c
commit dac661e6c6
8 changed files with 460 additions and 134 deletions
+11 -1
View File
@@ -47,11 +47,21 @@ 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
View File
@@ -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):
+362 -97
View File
@@ -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;
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
+21 -11
View File
@@ -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 audioCtx;
return ctx;
}
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"}
+7 -7
View File
@@ -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;