fix: piano roll zoom/scroll + AI copilot + SF cache + auto-scroll drag
This commit is contained in:
+1
-5
@@ -22,8 +22,4 @@ app/storage/processed/*
|
||||
.vscode/
|
||||
*.log
|
||||
celerybeat-schedule
|
||||
<<<<<<< Updated upstream
|
||||
node_modules/
|
||||
=======
|
||||
node_modules
|
||||
>>>>>>> Stashed changes
|
||||
node_modules
|
||||
@@ -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
@@ -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:
|
||||
|
||||
+289
-68
@@ -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: fast‑forward 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));
|
||||
if (window.SonicSF) {
|
||||
const ctx = getAudioContext();
|
||||
window.SonicSF.playNote(n.pitch, newVel * 127, 300, ctx.currentTime, st.instrumentProgram, null);
|
||||
}
|
||||
return { ...n, velocity: newVel };
|
||||
}));
|
||||
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();
|
||||
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 val = Math.max(0, Math.min(1, (h - y) / h));
|
||||
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"
|
||||
@@ -5272,9 +5473,9 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
|
||||
onScroll: handleScroll,
|
||||
className: "flex-1 overflow-auto bg-[#141414] min-w-0"
|
||||
}, /*#__PURE__*/React.createElement("div", {
|
||||
style: {
|
||||
width: `${drawWidth}px`,
|
||||
height: `${(128 - PITCH_START) * NoteHeight}px`
|
||||
style: {
|
||||
width: `${cssWidth}px`,
|
||||
height: `${(128 - PITCH_START) * NoteHeight}px`
|
||||
},
|
||||
className: "relative"
|
||||
}, /*#__PURE__*/React.createElement("canvas", {
|
||||
@@ -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
@@ -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.
|
||||
Reference in New Issue
Block a user