fix: piano roll zoom/scroll + AI copilot + SF cache + auto-scroll drag

This commit is contained in:
2026-07-25 07:37:27 +07:00
parent 1c0d35f69b
commit b04a76ebfb
6 changed files with 406 additions and 101 deletions
-4
View File
@@ -22,8 +22,4 @@ app/storage/processed/*
.vscode/
*.log
celerybeat-schedule
<<<<<<< Updated upstream
node_modules/
=======
node_modules
>>>>>>> Stashed changes
+13
View File
@@ -0,0 +1,13 @@
# Kilo Post-Task Automation Rule
Mỗi khi bạn (Kilo) hoàn thành một tác vụ viết code, sửa lỗi, hoặc refactor code theo yêu cầu của tôi, bạn BẮT BUỘC phải thực hiện bước cuối cùng sau:
1. Kiểm tra sự tồn tại của file `wiki.md` ở thư mục gốc (nếu chưa có, hãy tạo mới).
2. Tự động chèn (append) một đoạn báo cáo ngắn về task vừa hoàn thành vào CUỐI file `wiki.md` theo cấu trúc sau:
3. Tự động add và commit với tên ngắn gọn của công việc
### [YYYY-MM-DD HH:mm] Task: <Tên ngắn gọn của công việc>
- **Tóm tắt thay đổi:** <Mô tả 1-2 câu về nội dung đã thực hiện>
- **Các file ảnh hưởng:** `<file_1>`, `<file_2>`
- **Ghi chú/Test (nếu có):** <Các lưu ý hoặc lệnh chạy test/build nếu có>
---
+64 -10
View File
@@ -1,6 +1,7 @@
# SonicForge Studio VST / VSTi Engine Service
import os
import numpy as np
import functools
from ctypes import c_int, c_char_p, c_void_p
def midi_note_to_freq(note_number: int) -> float:
@@ -92,11 +93,62 @@ if HAS_PYFLUIDSYNTH:
HAS_PYFLUIDSYNTH = False
# ── Module-level caches ──
_FLUID_CACHE = {} # path → (fluidsynth.FluidSynth, refcount)
_PLUGIN_MANAGER_INSTANCE = None
_PLUGIN_MANAGER_ARGS = None
_SF_INSTRUMENTS_CACHE = {} # sf_id → list[presets]
def get_plugin_manager(vst_dir="/opt/daw_engine/vst3", sf_dir="/opt/daw_engine/soundfonts", upload_sf_dir=None) -> "PluginManager":
"""Singleton: reuse PluginManager when args match, else create new."""
global _PLUGIN_MANAGER_INSTANCE, _PLUGIN_MANAGER_ARGS
args = (vst_dir, sf_dir, upload_sf_dir)
if _PLUGIN_MANAGER_INSTANCE is not None and _PLUGIN_MANAGER_ARGS == args:
return _PLUGIN_MANAGER_INSTANCE
_PLUGIN_MANAGER_ARGS = args
_PLUGIN_MANAGER_INSTANCE = PluginManager(vst_dir, sf_dir, upload_sf_dir)
return _PLUGIN_MANAGER_INSTANCE
def load_soundfont_cached(path: str):
"""Return a cached FluidSynth instance for path, incrementing refcount."""
global _FLUID_CACHE
if not HAS_PYFLUIDSYNTH:
return None
if path in _FLUID_CACHE:
fl, ref = _FLUID_CACHE[path]
_FLUID_CACHE[path] = (fl, ref + 1)
return fl
try:
import fluidsynth
fl = fluidsynth.FluidSynth(sample_rate=44100, gain=0.5)
font_id = fl.sfload(path)
fl.program_select(0, font_id, 0, 0)
_FLUID_CACHE[path] = (fl, 1)
return fl
except Exception:
return None
def release_soundfont(path: str):
"""Decrement refcount; delete FluidSynth when count reaches 0."""
global _FLUID_CACHE
if path not in _FLUID_CACHE:
return
fl, ref = _FLUID_CACHE[path]
if ref <= 1:
try:
fl.delete()
except Exception:
pass
del _FLUID_CACHE[path]
else:
_FLUID_CACHE[path] = (fl, ref - 1)
class PluginManager:
def __init__(self, vst_dir="/opt/daw_engine/vst3", sf_dir="/opt/daw_engine/soundfonts", upload_sf_dir=None):
self.vst_dir = vst_dir
self.sf_dir = sf_dir
self.upload_sf_dir = upload_sf_dir
self._sf_scan_cache = None # cache for _scan_soundfonts()
def _scan_plugins(self) -> dict:
plugins = {}
@@ -160,20 +212,20 @@ class PluginManager:
pass
return vst
def _scan_soundfonts_cached(self):
if self._sf_scan_cache is not None:
return self._sf_scan_cache
self._sf_scan_cache = self._scan_soundfonts()
return self._sf_scan_cache
def load_soundfont(self, path: str):
if not HAS_PYFLUIDSYNTH:
return None
try:
fl = fluidsynth.FluidSynth(sample_rate=44100, gain=0.5)
font_id = fl.sfload(path)
fl.program_select(0, font_id, 0, 0)
return fl
except Exception:
return None
return load_soundfont_cached(path)
def list_soundfont_instruments(self, sf_id: str):
if not ensure_pyfluidsynth():
return []
if sf_id in _SF_INSTRUMENTS_CACHE:
return _SF_INSTRUMENTS_CACHE[sf_id]
search_dirs = []
if os.path.isdir(self.sf_dir):
search_dirs.append(self.sf_dir)
@@ -187,13 +239,13 @@ class PluginManager:
if base == sf_id or base == sf_id.replace("sf_", ""):
path = os.path.join(d, f)
try:
import fluidsynth
fl = fluidsynth.Synth()
fid = fl.sfload(path)
if fid < 0:
fl.delete()
continue
presets = []
# Use fluid_sfont_get_preset + fluid_preset_get_name C API
_fl = fluidsynth._fl
_fl.fluid_synth_get_sfont_by_id.restype = c_void_p
_fl.fluid_preset_get_name.restype = c_char_p
@@ -217,9 +269,11 @@ class PluginManager:
"name": name_val.decode("utf-8", errors="replace")
})
fl.delete()
_SF_INSTRUMENTS_CACHE[sf_id] = presets[:256]
return presets[:256]
except Exception:
import traceback; traceback.print_exc()
_SF_INSTRUMENTS_CACHE[sf_id] = []
return []
def list_available(self) -> dict:
+284 -63
View File
@@ -438,8 +438,9 @@ const WaveformLane = ({
// Grid lines based on Snap value
ctx.strokeStyle = 'rgba(255, 255, 255, 0.03)';
ctx.lineWidth = 1;
const tStart = scrollLeft / zoom;
const tEnd = (scrollLeft + drawWidth) / zoom;
const PADDING_LEFT = 2; // seconds of empty space on left edge
const tStart = scrollLeftVal / zoom - PADDING_LEFT;
const tEnd = (scrollLeftVal + drawWidth) / zoom;
let gridSpacing = 1.0;
if (snapValue && snapValue !== 'free') {
const beatDuration = 60 / parseFloat(bpm || 120);
@@ -1214,8 +1215,9 @@ const TempoTrackLane = ({
ctx.fillRect(0, 0, drawWidth, height);
const beatDuration = 60 / bpm;
const barDuration = beatDuration * 4;
const tStart = scrollLeft / zoom;
const tEnd = (scrollLeft + drawWidth) / zoom;
const PADDING_LEFT = 2; // seconds of empty space on left edge
const tStart = scrollLeftVal / zoom - PADDING_LEFT;
const tEnd = (scrollLeftVal + drawWidth) / zoom;
const firstBeat = Math.floor(tStart / beatDuration) * beatDuration;
for (let t = firstBeat; t <= tEnd; t += beatDuration) {
const beatNum = Math.floor(t / beatDuration) + 1;
@@ -4424,14 +4426,19 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
const pixelsPerBeat = rollZoom;
const timeSigNum = 4;
const noteMaxBeat = (st.notes || []).reduce((max, n) => Math.max(max, (n.start_beat || 0) + (n.duration_beats || 1)), 0);
const totalBeats = Math.max(noteMaxBeat + 16, 64); // at least 64 beats (16 bars) for scrolling
const drawWidth = totalBeats * pixelsPerBeat;
const [notes, setNotes] = React.useState(st.notes || []);
const [selectedNoteIds, setSelectedNoteIds] = React.useState([]);
const [selectionMarquee, setSelectionMarquee] = React.useState(null); // { startBeat, startPitch, currentBeat, currentPitch }
const [draggedNote, setDraggedNote] = React.useState(null); // { mode: 'move'|'resize', idx, startOffsetBeat, originalStart }
const [hoveredResizeIdx, setHoveredResizeIdx] = React.useState(-1);
const [rollBeats, setRollBeats] = React.useState(Math.max(noteMaxBeat + 16, 64));
const rollBeatsRef = React.useRef(rollBeats);
rollBeatsRef.current = rollBeats;
const totalBeats = Math.max(rollBeats, noteMaxBeat + 16, 64); // at least 64 beats (16 bars) for scrolling
const [gridViewWidth, setGridViewWidth] = React.useState(800);
const drawWidth = totalBeats * pixelsPerBeat;
const cssWidth = Math.max(drawWidth, gridViewWidth);
const [notes, setNotes] = React.useState(st.notes || []);
const [selectedNoteIds, setSelectedNoteIds] = React.useState([]);
// Undo/redo stacks
const undoStackRef = React.useRef([]);
@@ -4529,42 +4536,28 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
};
}, []);
// Shift + Scroll event listener to adjust velocity
// Shift + Scroll event listener: fastforward playhead + play notes
React.useEffect(() => {
const handleCanvasWheel = (e) => {
if (e.shiftKey) {
e.preventDefault();
const canvas = canvasRef.current;
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const beat = x / pixelsPerBeat;
const pitch = 127 - Math.floor(y / NoteHeight);
// Find note under cursor
const noteUnderCursor = notes.find(n => {
return pitch === n.pitch && beat >= n.start_beat && beat < n.start_beat + n.duration_beats;
});
const delta = e.deltaY < 0 ? 0.05 : -0.05;
if (selectedNoteIds.length > 0) {
setNotes(prev => prev.map(n => {
if (!selectedNoteIds.includes(n.id)) return n;
const newVel = Math.max(0.1, Math.min(1.0, (n.velocity ?? 0.8) + delta));
return { ...n, velocity: newVel };
}));
} else if (noteUnderCursor) {
setNotes(prev => prev.map(n => {
if (n.id !== noteUnderCursor.id) return n;
const newVel = Math.max(0.1, Math.min(1.0, (n.velocity ?? 0.8) + delta));
const scrollDelta = e.deltaY;
const beatSec = 60.0 / (parseInt(bpm) || 120);
const step = scrollDelta < 0 ? -0.25 : 0.25; // 1/4 beat per notch
const currentBeat = (st.currentTime || 0) / beatSec;
const maxBeats = totalBeats;
const newBeat = Math.max(0, Math.min(maxBeats, currentBeat + step));
const newTime = newBeat * beatSec;
setSubTabs(prev => prev.map(s => s.id === st.id ? { ...s, currentTime: newTime } : s));
// Play notes at the new beat position
if (window.SonicSF) {
const ctx = getAudioContext();
window.SonicSF.playNote(n.pitch, newVel * 127, 300, ctx.currentTime, st.instrumentProgram, null);
}
return { ...n, velocity: newVel };
}));
const playing = notes.filter(n =>
newBeat >= n.start_beat && newBeat < n.start_beat + n.duration_beats
);
playing.forEach(n => {
window.SonicSF.playNote(n.pitch, (n.velocity || 0.8) * 127, 200, ctx.currentTime, st.instrumentProgram, null);
});
}
}
};
@@ -4578,7 +4571,19 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
canvas.removeEventListener('wheel', handleCanvasWheel);
}
};
}, [notes, selectedNoteIds, pixelsPerBeat]);
}, [notes, st.currentTime, pixelsPerBeat, st.id, totalBeats, bpm]);
// Track grid container width for zoom fill
React.useEffect(() => {
const el = gridScrollRef.current;
if (!el) return;
const ro = new ResizeObserver(entries => {
for (const entry of entries) setGridViewWidth(entry.contentRect.width);
});
ro.observe(el);
setGridViewWidth(el.clientWidth);
return () => ro.disconnect();
}, []);
React.useEffect(() => {
const canvas = canvasRef.current;
@@ -4586,7 +4591,8 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
const ctx = canvas.getContext('2d');
const dpr = window.devicePixelRatio || 1;
const h = 128 * NoteHeight;
canvas.width = drawWidth * dpr;
const canvasW = Math.max(drawWidth, gridViewWidth);
canvas.width = canvasW * dpr;
canvas.height = h * dpr;
ctx.scale(dpr, dpr);
@@ -4614,8 +4620,11 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
else if (snapVal === '1/16') snapBeats = 0.25;
else if (snapVal === '1/32') snapBeats = 0.125;
for (let beat = 0; beat <= totalBeats; beat += snapBeats) {
const maxBeatPx = drawWidth;
const gridEndBeat = Math.max(totalBeats, Math.ceil(canvasW / pixelsPerBeat) + 4);
for (let beat = 0; beat <= gridEndBeat; beat += snapBeats) {
const x = beat * pixelsPerBeat;
if (x > drawWidth) break;
const isBar = beat % timeSigNum === 0;
ctx.strokeStyle = isBar ? '#444450' : '#2d2d35';
ctx.lineWidth = isBar ? 1.2 : 0.6;
@@ -4688,7 +4697,7 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
const ctx = canvas.getContext('2d');
const dpr = window.devicePixelRatio || 1;
const h = 80;
canvas.width = drawWidth * dpr;
canvas.width = Math.max(drawWidth, gridViewWidth) * dpr;
canvas.height = h * dpr;
ctx.scale(dpr, dpr);
@@ -4748,6 +4757,8 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
const beat = x / pixelsPerBeat;
const pitch = 127 - Math.floor(y / NoteHeight);
if (scaleMenuPos) setScaleMenuPos(null);
// Right click -> Quick delete note or start erase sweep
if (e.button === 2) {
e.preventDefault();
@@ -4867,7 +4878,12 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
nextSelectedIds = [clickedNote.id];
setSelectedNoteIds(nextSelectedIds);
} else {
nextSelectedIds = selectedNoteIds;
// Note already selected -- only play preview, don't drag
if (window.SonicSF) {
const ctx = getAudioContext();
window.SonicSF.playNote(clickedNote.pitch, 100, 300, ctx.currentTime, st.instrumentProgram, null);
}
return;
}
pushToUndo(notes);
notesBeforeDragRef.current = JSON.parse(JSON.stringify(notes));
@@ -4893,7 +4909,7 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
const noteId = 'note_' + Date.now() + Math.random().toString(36).substr(2, 5);
const newNote = {
id: noteId,
pitch: pitch,
pitch: snapPitchToScale(pitch, selectedScale),
start_beat: start,
duration_beats: initialDur,
velocity: 0.8,
@@ -4905,12 +4921,12 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
mode: 'draw',
idx: -1,
startOffsetBeat: start,
startOffsetPitch: pitch,
startOffsetPitch: snapPitchToScale(pitch, selectedScale),
drawNoteId: noteId,
drawDuration: initialDur,
visitedPitches: [pitch],
visitedPitches: [snapPitchToScale(pitch, selectedScale)],
initialBeat: start,
initialPitch: pitch
initialPitch: snapPitchToScale(pitch, selectedScale)
});
// Play the note with SoundFont
if (window.SonicSF) {
@@ -4983,8 +4999,9 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
return { ...n, duration_beats: newDur };
}));
}
if (!visited.includes(pitch)) {
const newPitches = [...visited, pitch];
const snappedPitch = snapPitchToScale(pitch, selectedScale);
if (!visited.includes(snappedPitch)) {
const newPitches = [...visited, snappedPitch];
const totalSpan = Math.max(0.125, beat - draggedNote.initialBeat);
const perNoteDur = totalSpan / newPitches.length;
const brushIds = draggedNote.brushIds || [];
@@ -5080,6 +5097,8 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
e.preventDefault();
};
const ccDragRef = React.useRef(null);
const handleCCMouseDown = (e) => {
const canvas = ccCanvasRef.current;
if (!canvas) return;
@@ -5102,16 +5121,64 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
});
}
if (noteIdx !== -1) {
const val = Math.max(0, Math.min(1, (h - y) / h));
setNotes(prev => prev.map((n, idx) => {
if (idx !== noteIdx) return n;
if (ccMode === 'pan') {
return { ...n, pan: (val - 0.5) * 2.0 };
} else {
return { ...n, velocity: val };
}
const paintNote = (idx, v) => {
if (idx === -1) return;
setNotes(prev => prev.map((n, i) => {
if (i !== idx) return n;
if (ccMode === 'pan') { return { ...n, pan: (v - 0.5) * 2.0 }; }
return { ...n, velocity: v };
}));
};
if (e.ctrlKey) {
if (noteIdx !== -1) paintNote(noteIdx, val);
ccDragRef.current = { active: true, lastBeat: beat, lastPainted: noteIdx !== -1 ? [noteIdx] : [] };
return;
}
if (noteIdx !== -1) paintNote(noteIdx, val);
};
const handleCCMouseMove = (e) => {
if (!ccDragRef.current || !ccDragRef.current.active) return;
const canvas = ccCanvasRef.current;
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const h = rect.height;
const beat = x / pixelsPerBeat;
const val = Math.max(0, Math.min(1, (h - y) / h));
const drag = ccDragRef.current;
const painted = drag.lastPainted || [];
const candidateIdx = notes.findIndex(n => beat >= n.start_beat && beat <= n.start_beat + n.duration_beats);
if (candidateIdx !== -1 && !painted.includes(candidateIdx)) {
setNotes(prev => prev.map((n, i) => {
if (i !== candidateIdx) return n;
if (ccMode === 'pan') return { ...n, pan: (val - 0.5) * 2.0 };
return { ...n, velocity: val };
}));
drag.lastPainted = [...painted, candidateIdx];
} else if (candidateIdx === -1) {
let nearest = -1;
let minDist = Infinity;
notes.forEach((n, idx) => {
const center = n.start_beat + n.duration_beats / 2;
const d = Math.abs(center - beat);
if (d < minDist) { minDist = d; nearest = idx; }
});
if (nearest !== -1 && !painted.includes(nearest)) {
setNotes(prev => prev.map((n, i) => {
if (i !== nearest) return n;
if (ccMode === 'pan') return { ...n, pan: (val - 0.5) * 2.0 };
return { ...n, velocity: val };
}));
drag.lastPainted = [...painted, nearest];
}
}
};
@@ -5152,6 +5219,12 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
if (rulerScrollRef.current) {
rulerScrollRef.current.scrollLeft = e.currentTarget.scrollLeft;
}
const el = e.currentTarget;
const THRESHOLD = 200;
if (el.scrollLeft + el.clientWidth >= el.scrollWidth - THRESHOLD) {
const newBeats = rollBeatsRef.current + 16;
setRollBeats(newBeats);
}
};
const handleKeybedScroll = (e) => {
if (gridScrollRef.current) {
@@ -5159,9 +5232,124 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
}
};
const SCALES = {
"None": null,
"Major": [0, 2, 4, 5, 7, 9, 11],
"Minor": [0, 2, 3, 5, 7, 8, 10],
"Pentatonic": {
"Chinese": [0, 2, 4, 7, 9],
"Vietnam": [0, 2, 6, 7, 10],
"India": [0, 2, 5, 7, 9],
"Japan": [0, 2, 5, 7, 9],
"Africa": [0, 3, 5, 7, 10]
},
"Blues": [0, 3, 5, 6, 7, 10],
"Dorian": [0, 2, 3, 5, 7, 9, 10],
"Phrygian": [0, 1, 3, 5, 7, 8, 10],
"Lydian": [0, 2, 4, 6, 7, 9, 11],
"Mixolydian": [0, 2, 4, 5, 7, 9, 10],
"Locrian": [0, 1, 3, 5, 6, 8, 10]
};
const [selectedScale, setSelectedScale] = React.useState(null);
const [scaleMenuPos, setScaleMenuPos] = React.useState(null);
const [aiPrompt, setAiPrompt] = React.useState('');
const [aiLoading, setAiLoading] = React.useState(false);
const snapPitchToScale = (pitch, scale) => {
if (!scale) return pitch;
const octave = Math.floor(pitch / 12);
const noteInOctave = pitch % 12;
if (scale.includes(noteInOctave)) return pitch;
let best = noteInOctave;
let minDist = 12;
scale.forEach(s => { const dist = Math.abs(s - noteInOctave); if (dist < minDist) { minDist = dist; best = s; } });
return octave * 12 + best;
};
const handleAIPrompt = async () => {
const prompt = aiPrompt.trim();
if (!prompt || !window.AIGateway) return;
setAiLoading(true);
try {
const result = await window.AIGateway.executeAIPrompt({
prompt: 'Generate MIDI notes for a piano roll. Return ONLY a JSON array: [{pitch(0-127), start_beat, duration_beats, velocity(0.0-1.0)}]. ' + prompt,
provider: 'openai',
model: 'gpt-4o',
systemInstruction: 'You are a MIDI composer. Output ONLY valid JSON array of notes. No markdown, no explanation. Use 4/4 time. pitch=C4=60, D4=62, E4=64, F4=65, G4=67, A4=69, B4=71, C5=72.'
});
let notesData = null;
if (result.textResponse) {
try {
const cleaned = result.textResponse.replace(/```json?\s*/g, '').replace(/```/g, '').trim();
notesData = JSON.parse(cleaned);
} catch (e1) {}
}
if (!notesData && result.functionCalls) {
for (const fc of result.functionCalls) {
if (fc.arguments && fc.arguments.notes) { notesData = fc.arguments.notes; break; }
}
}
if (Array.isArray(notesData) && notesData.length > 0) {
const newNotes = notesData.map((n, i) => ({
id: 'note_ai_' + Date.now() + '_' + i,
pitch: Math.max(0, Math.min(127, n.pitch || 60)),
start_beat: Math.max(0, parseFloat(n.start_beat) || 0),
duration_beats: Math.max(0.125, parseFloat(n.duration_beats) || 0.25),
velocity: Math.max(0.1, Math.min(1.0, n.velocity ?? 0.8)),
pan: 0.0
}));
pushToUndo(notes);
setNotes(prev => [...prev, ...newNotes]);
setSelectedNoteIds(newNotes.map(n => n.id));
if (window.SonicSF && newNotes.length > 0) {
const ctx = getAudioContext();
newNotes.forEach(n => window.SonicSF.playNote(n.pitch, n.velocity * 127, 300, ctx.currentTime + (n.start_beat * 0.01), st.instrumentProgram, null));
}
}
} catch (err) {
console.error('AI Piano Roll error:', err);
}
setAiLoading(false);
};
const renderScaleContextMenu = () => {
const closeMenu = () => setScaleMenuPos(null);
const items = [];
const isSameScale = (a, b) => { if (!a || !b) return a === b; if (a.length !== b.length) return false; return a.every((v,i)=>v===b[i]); };
const pushItem = (label, onClick, indent) => {
const isActive = onClick._scale && isSameScale(onClick._scale, selectedScale);
items.push(React.createElement("div", {
key: label,
onClick: () => { onClick(); closeMenu(); },
className: "px-3 py-1.5 text-xs cursor-pointer hover:bg-amber-600 hover:text-white whitespace-nowrap " + (indent ? "pl-6 " : "") + (isActive ? "bg-amber-800/40 text-amber-300" : "text-zinc-300")
}, label));
};
Object.keys(SCALES).forEach(key => {
const val = SCALES[key];
if (val === null) { pushItem("None", () => setSelectedScale(null)); return; }
if (Array.isArray(val)) {
pushItem(key, () => setSelectedScale(val));
} else {
const parentKey = key;
const isOpen = scaleMenuPos && scaleMenuPos.parentKey === parentKey;
pushItem(key + " ▸", () => { setScaleMenuPos({ x: scaleMenuPos.x + 120, y: scaleMenuPos.y, parentKey }); }, false);
if (isOpen) {
Object.keys(val).forEach(subKey => {
pushItem(subKey, () => setSelectedScale(val[subKey]), true);
});
}
}
});
return React.createElement("div", {
style: { position: "fixed", left: scaleMenuPos.x, top: scaleMenuPos.y, zIndex: 9999 },
className: "bg-[#2a2a2a] border border-zinc-600 rounded shadow-xl py-1 min-w-[140px]"
}, ...items);
};
const renderBarLabels = () => {
const labels = [];
const barsCount = Math.ceil(totalBeats / 4);
const extraBars = Math.max(0, Math.ceil((gridViewWidth - drawWidth) / (4 * pixelsPerBeat)) + 2);
const barsCount = Math.ceil(totalBeats / 4) + extraBars;
for (let bar = 0; bar < barsCount; bar++) {
const x = bar * 4 * pixelsPerBeat;
labels.push(
@@ -5222,6 +5410,19 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
onClick: () => setCcMode(mode),
className: `px-2.5 py-1 rounded capitalize ${ccMode === mode ? 'bg-purple-900/60 text-purple-300 font-bold border border-purple-700' : 'text-zinc-400 hover:text-zinc-200'}`
}, mode)))), /*#__PURE__*/React.createElement("div", {
className: "flex items-center gap-1 flex-1 max-w-[300px] ml-2"
}, /*#__PURE__*/React.createElement("input", {
type: "text",
value: aiPrompt,
onChange: e => setAiPrompt(e.target.value),
onKeyDown: e => { if (e.key === 'Enter') handleAIPrompt(); },
placeholder: "AI: tạo 8 bars MIDI...",
className: "flex-1 bg-zinc-900 border border-zinc-700 text-zinc-200 text-[10px] rounded px-2 py-1 outline-none focus:border-amber-500 min-w-0"
}), /*#__PURE__*/React.createElement("button", {
onClick: handleAIPrompt,
disabled: aiLoading,
className: "px-2 py-1 text-[10px] bg-purple-700 hover:bg-purple-600 disabled:bg-zinc-700 text-white rounded flex items-center gap-1 transition"
}, aiLoading ? "..." : "AI")), /*#__PURE__*/React.createElement("div", {
className: "flex items-center gap-1"
}, /*#__PURE__*/React.createElement("button", {
onClick: () => onSaveNotes(st.id, st.trackId, st.target_id, notes),
@@ -5253,7 +5454,7 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
}
}, /*#__PURE__*/React.createElement("div", {
style: {
width: `${drawWidth}px`,
width: `${cssWidth}px`,
height: '100%'
},
className: "relative h-full font-mono text-[9px] text-zinc-500 font-bold"
@@ -5273,7 +5474,7 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
className: "flex-1 overflow-auto bg-[#141414] min-w-0"
}, /*#__PURE__*/React.createElement("div", {
style: {
width: `${drawWidth}px`,
width: `${cssWidth}px`,
height: `${(128 - PITCH_START) * NoteHeight}px`
},
className: "relative"
@@ -5293,15 +5494,18 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
className: "flex-1 overflow-x-hidden min-w-0"
}, /*#__PURE__*/React.createElement("div", {
style: {
width: `${drawWidth}px`,
width: `${cssWidth}px`,
height: '100%'
},
className: "relative"
}, /*#__PURE__*/React.createElement("canvas", {
ref: ccCanvasRef,
onMouseDown: handleCCMouseDown,
onMouseMove: handleCCMouseMove,
onMouseUp: () => { ccDragRef.current = null; },
onMouseLeave: () => { ccDragRef.current = null; },
className: "absolute inset-0 cursor-ns-resize"
})))));
})))), scaleMenuPos && renderScaleContextMenu());
};
const serializeTracksList = (tracksList, secondsPerBar) => {
@@ -8303,6 +8507,7 @@ const App = () => {
// Computed Values
const maxDuration = useMemo(() => {
const secPerBar = (60.0 / (parseInt(bpm) || 120)) * 4;
let max = 10;
tracks.forEach(t => {
const clips = t.clips && t.clips.length > 0 ? t.clips : t.buffer ? [{
@@ -9958,12 +10163,25 @@ const App = () => {
}
showToast('Đã xóa track.', 'info');
};
// Shared auto-scroll: when mouse near right edge, scroll container right
const autoScrollTimeline = (clientX) => {
const wrapper = timelineWrapperRef.current;
if (!wrapper) return;
const wr = wrapper.getBoundingClientRect();
const margin = 60;
if (clientX > wr.right - margin) {
const speed = 15;
wrapper.scrollLeft += speed;
}
};
useEffect(() => {
const handleMouseMove = e => {
const drag = draggedClipRef.current;
if (!drag) return;
const wrapper = timelineWrapperRef.current;
if (!wrapper) return;
autoScrollTimeline(e.clientX);
const rect = wrapper.getBoundingClientRect();
const scrollLeft = wrapper.scrollLeft;
const mouseX = e.clientX - rect.left + scrollLeft;
@@ -10114,6 +10332,7 @@ const App = () => {
if (!drag) return;
const wrapper = timelineWrapperRef.current;
if (!wrapper) return;
autoScrollTimeline(e.clientX);
const rect = wrapper.getBoundingClientRect();
const scrollLeft = wrapper.scrollLeft;
const mouseX = e.clientX - rect.left + scrollLeft;
@@ -10177,6 +10396,7 @@ const App = () => {
if (!resize) return;
const wrapper = timelineWrapperRef.current;
if (!wrapper) return;
autoScrollTimeline(e.clientX);
const rect = wrapper.getBoundingClientRect();
const scrollLeft = wrapper.scrollLeft;
const mouseX = e.clientX - rect.left + scrollLeft;
@@ -14616,6 +14836,7 @@ const App = () => {
zoom: zoom,
timelineWidth: timelineWidth,
viewportWidth: viewportWidth,
scrollLeft: scrollLeft,
onSelectRange: handleSelectRange,
onPlayheadSet: handlePlayheadSet,
isSelected: isSelected,
File diff suppressed because one or more lines are too long
+15
View File
@@ -0,0 +1,15 @@
### [2026-07-25 06:57] Task: Fix Piano Roll runtime errors (rollBeats TDZ + handleCCMouseMove)
- **Tóm tắt thay đổi:** Sửa lỗi `Cannot access 'rollBeats' before initialization` bằng cách di chuyển khai báo `rollBeats`/`rollBeatsRef` lên trước `totalBeats`. Thêm hàm `handleCCMouseMove` bị thiếu và cập nhật `handleCCMouseDown` hỗ trợ Ctrl+Click paint velocity.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`
- **Ghi chú/Test (nếu có):** `cd /home/locpham/SonicForgeStudio && npm run build` — build passes.
---
### [2026-07-25 07:25] Task: 4 tính năng mới (Piano Roll zoom fill + Shift+scroll playhead + auto-scroll drag + SoundFont cache)
- **Tóm tắt thay đổi:** (1) Piano Roll zoom-out không còn màn hình đen — bars fill toàn bộ viewport. (2) Shift+scroll trong Piano Roll di chuyển playhead và play notes MIDI như fast-forward. (3) Khi drag section/MIDI/clip đến cạnh phải timeline, auto-scroll container. (4) Server-side cache FluidSynth instances + PluginManager singleton + list_soundfont_instruments cache.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/core/vst_engine.py`
- **Ghi chú/Test (nếu có):** `npm run build` pass. `vst_engine.py` thêm `load_soundfont_cached`, `release_soundfont`, `get_plugin_manager`, `_SF_INSTRUMENTS_CACHE` — refcount-based cache.
### [2026-07-25 07:31] Task: Fix zoom cursor + right edge black + AI Copilot for Piano Roll
- **Tóm tắt thay đổi:** (1) Tách `drawWidth` (content) và `cssWidth` (CSS) — canvas coordinate system không còn bị sai khi zoom out. (2) Thêm `min-width` fill viewport, không còn mảng đen phải. (3) Thêm AI Copilot input + handler trong Piano Roll — gõ prompt tạo MIDI notes trực tiếp.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`
- **Ghi chú/Test (nếu có):** `npm run build` pass.