feat: vẽ nhanh MIDI note

This commit is contained in:
2026-07-24 20:33:01 +07:00
parent 17a284ad17
commit b9eb840b11
15 changed files with 759 additions and 28 deletions
+4
View File
@@ -22,4 +22,8 @@ app/storage/processed/*
.vscode/
*.log
celerybeat-schedule
<<<<<<< Updated upstream
node_modules/
=======
node_modules
>>>>>>> Stashed changes
+205
View File
@@ -0,0 +1,205 @@
# Plan: TCP Resizable Width + Instrument Search Dropdown + Section Save Fix
## Task 1: User-Resizable TCP Width
**Files:** `app/static/js/app.jsx`
### Root Cause
TCP containers are hardcoded `w-[320px]` (lines 14253, 14809). Components like Synth button, FX button, volume/pan sliders, input select overflow when content is wide.
### Implementation Steps
**1a — Add TCP width state**
Add near line 6394 (near existing `rightSidebarWidth` state):
```javascript
const [tcpWidth, setTcpWidth] = useState(320);
```
**1b — Add TCP resize handler**
Add near line 6365 (near `startColResize`):
```javascript
const startTcpResize = e => {
e.preventDefault();
const startX = e.clientX;
const startW = tcpWidth;
const onMove = ev => {
const deltaX = ev.clientX - startX;
const newWidth = Math.max(280, Math.min(600, startW + deltaX));
setTcpWidth(newWidth);
};
const onUp = () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
};
```
**1c — Replace `w-[320px]` with dynamic width in main TCP container (line 14253)**
Change `className: "w-[320px] shrink-0 ..."` to `style: { width: tcpWidth + 'px', ... }`.
**1d — Replace `w-[320px]` with dynamic width in sub-tab TCP container (line 14809)**
Same pattern as 1c.
**1e — Add resize handle (right edge of TCP)**
Add a vertical resize handle bar on the right edge of both TCP containers. Pattern:
```jsx
React.createElement("div", {
onMouseDown: startTcpResize,
className: "absolute right-0 top-0 bottom-0 w-1 cursor-col-resize z-40 hover:bg-cyan-500/50 transition-colors",
style: { right: 0 }
})
```
**1f — Ensure the main layout accommodates variable TCP width**
The main timeline area should use `flex-1` so it fills remaining space. Verify existing layout handles this.
### Verification
- Drag TCP right edge → width changes between 280px and 600px
- Components fit properly at various widths
- Timeline area fills remaining space
- Works in section-tab view too
---
## Task 2: Instrument Search Dropdown in TCP
**Files:** `app/static/js/app.jsx`
### Root Cause
Current instrument selector is a modal overlay (lines 15661-15726) with no search/filter. Requires clicking Synth button → modal → scroll to find instrument.
### Implementation Steps
**2a — Add per-track dropdown open/close state**
Add state:
```javascript
const [instrumentDropdownTrackId, setInstrumentDropdownTrackId] = useState(null);
```
This tracks which track's dropdown is open (null = all closed).
**2b — Add search query state**
```javascript
const [instrumentSearchQuery, setInstrumentSearchQuery] = useState('');
```
**2c — Replace Synth button (top toolbar, line 14444-14448) with dropdown toggle**
Convert the icon-only `<button>` into a container that:
1. Shows current instrument name (truncated) + chevron-down icon when assigned
2. Shows "Synth" + chevron-down icon when no instrument
3. Click toggles `instrumentDropdownTrackId` for this track
**2d — Render the dropdown panel (conditional, below the button)**
When `instrumentDropdownTrackId === track.id`, render a dropdown panel:
```jsx
React.createElement("div", {
className: "absolute left-0 top-full mt-0.5 z-50 bg-[#1e1e1e] border border-zinc-700 rounded shadow-xl w-[220px] max-h-[300px] flex flex-col",
onClick: e => e.stopPropagation()
},
// Search input
React.createElement("input", {
type: "text",
placeholder: "Tìm nhạc cụ...",
value: instrumentSearchQuery,
onChange: e => setInstrumentSearchQuery(e.target.value),
className: "w-full bg-black border-b border-zinc-700 px-2 py-1.5 text-xs text-zinc-200 outline-none"
}),
// Filtered list
React.createElement("div", {
className: "flex-1 overflow-y-auto"
},
// Filtered items from instrumentSelectorData
// "None (Default Synth)" always shown first
// Then filtered soundfonts
// Then filtered VST instruments
)
)
```
**2e — Filter logic**
```javascript
const filteredInstruments = useMemo(() => {
if (!instrumentSelectorData) return { soundfonts: [], vst: [] };
const q = instrumentSearchQuery.toLowerCase();
return {
soundfonts: (instrumentSelectorData.soundfonts || []).filter(sf =>
(sf.display || sf.name || sf.id).toLowerCase().includes(q)
),
vst: (instrumentSelectorData.vst_instruments || []).filter(v =>
(v.name || v.id).toLowerCase().includes(q)
)
};
}, [instrumentSearchQuery, instrumentSelectorData]);
```
**2f — Click outside to close**
Add a global click handler that closes the dropdown when clicking outside.
**2g — Preload `instrumentSelectorData` on first TCP mount**
Instead of only loading on `openInstrumentSelector`, preload `listPlugins()` when the first track renders (or on app mount).
**2h — Apply selection**
On click of a dropdown item, call existing `setTrackInstrumentWithProgram` or `setTrackInstrument`. Close dropdown.
### Verification
- Click Synth button → dropdown opens with search input focused
- Type instrument name → list filters in real-time
- Click instrument → dropdown closes, track assigned, Synth button shows name
- Click outside → dropdown closes
---
## Task 3: Section-Tab Save Fix — Replace Instead of Draw On Top
**Files:** `app/static/js/app.jsx`
### Root Cause
`handleEditSectionInTab` (line 7178) clones ALL main-session tracks (empty) into the session-tab when `section.tracks` doesn't exist. `handleSaveSectionTab` (line 7156) saves ALL those empty tracks + edited ones into `s.tracks`. The rendering code (lines 696-791) draws ALL stored tracks inside the section box, creating a cluttered preview with empty/minimal tracks.
### Implementation Steps
**3a — Fix `handleEditSectionInTab` (line 7169) to only initialize relevant track**
Change the fallback cloning (line 7178) from cloning ALL main tracks to creating a minimal set of tracks based on the section's parent track:
```javascript
const clonedTracks = section.tracks ? section.tracks : [{
...tracks.find(tr => tr.id === trackId),
clips: [],
sections: [],
midiItems: [],
markers: [],
isArmed: false,
monitoringEnabled: true,
instrumentId: null,
instrumentProgram: undefined,
instrumentName: null
}];
```
This only clones the track that owns the section, not ALL main tracks.
**3b — Fix `handleSaveSectionTab` (line 7138) to filter non-empty tracks**
After building the updated section, filter `tab.tracks` to only include tracks that have actual content:
```javascript
const contentTracks = tab.tracks.filter(t =>
(t.clips && t.clips.length > 0) ||
(t.midiItems && t.midiItems.length > 0)
);
```
Store `tracks: contentTracks` instead of `tracks: tab.tracks`.
**3c — Improve section-item rendering (lines 694-791)**
The rendering already draws waveform from clips and MIDI notes from midiItems. Ensure:
- Waveform rendering for clips with `clip.buffer` is correct (already done at lines 720-739)
- MIDI note colors are per-track-index (already done at line 783: `noteColors[trackIdx % noteColors.length]`)
- Add a subtle track label inside each sub-track row so users can identify which track is which
**3d — Ensure waveform preview is properly sized**
The section preview currently allocates `subTrackHeight = (height - 24) / maxSubTracks` for each sub-track (line 698). Verify this is sufficient for waveform + MIDI note rendering when there are 1-2 tracks (typical case).
### Verification
- Open a section for editing → session-tab shows only the relevant track(s), not all main tracks
- Add MIDI items, sound clips, soundfonts, FX to tracks
- Save section → section-item shows waveform preview + MIDI note preview (replacing previous content, not appending)
- Open section again → previous edits are loaded correctly
- Multiple save cycles → no doubling of content
- Waveform rendered as background, MIDI notes in distinct colors per track
+53 -1
View File
@@ -1,6 +1,7 @@
import os
import numpy as np
import soundfile as sf
import scipy.signal as signal
from app.config import settings
from app.core.vst_engine import (
render_midi_events_to_audio,
@@ -11,7 +12,7 @@ from app.core.vst_engine import (
if HAS_PEDALBOARD:
try:
from pedalboard import Pedalboard, Gain
from pedalboard import Pedalboard, Gain, Chorus, Reverb
except Exception:
HAS_PEDALBOARD = False
@@ -222,6 +223,57 @@ class PythonRenderEngine:
if mute:
continue
# Apply Track FX (Chorus or Reverb)
fx_type = track.get("fx_type")
if fx_type == "chorus":
if HAS_PEDALBOARD:
try:
board = Pedalboard([Chorus(rate_hz=1.5, depth=0.25)])
track_buffer = board(track_buffer, sample_rate=self.sample_rate)
except Exception as e:
print(f"[RenderEngine] Pedalboard Chorus failed: {e}")
else:
# Fallback chorus using simple LFO delay modulation in scipy/numpy
try:
# 1.5 Hz sine LFO, modulating delay time between 15ms and 25ms (average 20ms)
lfo = 0.020 + 0.005 * np.sin(2 * np.pi * 1.5 * np.arange(total_samples) / self.sample_rate)
dry = track_buffer * 0.6
wet = np.zeros_like(track_buffer)
for ch in range(2):
indices = np.arange(total_samples) - (lfo * self.sample_rate)
indices = np.clip(indices, 0, total_samples - 1).astype(np.int32)
wet[ch, :] = track_buffer[ch, indices]
track_buffer = dry + wet * 0.5
except Exception as e:
print(f"[RenderEngine] Fallback Chorus failed: {e}")
elif fx_type == "reverb":
if HAS_PEDALBOARD:
try:
board = Pedalboard([Reverb(room_size=0.5, wet_level=0.4, dry_level=0.6)])
track_buffer = board(track_buffer, sample_rate=self.sample_rate)
except Exception as e:
print(f"[RenderEngine] Pedalboard Reverb failed: {e}")
else:
# Fallback reverb using exponentially decaying noise room impulse response
try:
# Generate impulse response (decaying noise)
len_ir = int(self.sample_rate * 2.0)
t_ir = np.arange(len_ir) / self.sample_rate
decay = np.exp(-t_ir / 0.5)
ir_l = (np.random.rand(len_ir) * 2 - 1) * decay
ir_r = (np.random.rand(len_ir) * 2 - 1) * decay
dry = track_buffer * 0.6
wet = np.zeros_like(track_buffer)
for ch in range(2):
ir = ir_l if ch == 0 else ir_r
# Convolve
conv = signal.convolve(track_buffer[ch, :], ir, mode='full')[:total_samples]
wet[ch, :] = conv
track_buffer = dry + wet * 0.4
except Exception as e:
print(f"[RenderEngine] Fallback Reverb failed: {e}")
# Process track volume
if HAS_PEDALBOARD:
try:
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
@@ -0,0 +1 @@
{"original_name": "test.sf2", "uuid": "5aeae4f0-6e39-4ffa-b629-727c377326ef", "file": "5aeae4f0-6e39-4ffa-b629-727c377326ef.sf2"}
@@ -0,0 +1 @@
{"original_name": "test.sf2", "uuid": "73f52fd7-6812-4485-bbd8-1df3185bc49a", "file": "73f52fd7-6812-4485-bbd8-1df3185bc49a.sf2"}
@@ -0,0 +1 @@
{"original_name": "test.sf2", "uuid": "7faf18bc-d931-4bde-938d-6cc5bb004643", "file": "7faf18bc-d931-4bde-938d-6cc5bb004643.sf2"}
@@ -0,0 +1 @@
{"original_name": "test.sf2", "uuid": "9b13be7b-e0b8-411e-8394-7f2851954f87", "file": "9b13be7b-e0b8-411e-8394-7f2851954f87.sf2"}
+105
View File
@@ -0,0 +1,105 @@
# Kế hoạch Thực hiện: Tính năng DAW mới (Track FX, Section Preview & Piano Roll Edit Shortcuts)
Bản kế hoạch này mô tả thiết kế kỹ thuật và các bước thực hiện các tính năng nâng cao liên quan đến xử lý hiệu ứng âm thanh (FX Chain) song song giữa Web Audio Client và Python Backend, kết xuất trực quan Section item trên Timeline, và bổ sung các phím tắt biên tập thông minh trong Piano Roll.
## Điểm lưu ý từ Người dùng
1. **Kiến trúc hiệu ứng Song song (Dual FX Architecture)**:
- **Phía Client (Trình duyệt)**: Vẫn phải sử dụng Web Audio API để xử lý hiệu ứng thời gian thực (Real-time Preview) khi nhấn Play nghe thử.
- **Phía Server (Backend)**: Sử dụng các thư viện Python (`Pedalboard`, `pydub`, `pysndfx`, `Librosa`...) để xử lý hiệu ứng khi xuất bản kết xuất (Offline Export / Render).
2. **Timeline Playback**: Khi Playhead trên Main Session quét qua Section item, trình phát nhạc sẽ phát nội dung của Section item đó giống như đang nghe thử trên Section-tab.
3. **Sửa lỗi Release Note**: Khi click chọn nốt mới để kéo thả, các nốt cũ đã chọn trước đó phải được giải phóng hoàn toàn và không di chuyển theo nốt mới.
---
## Phân tích Ưu/Nhược điểm & Giải pháp Tối ưu hóa (Dual FX)
### 1. Ưu điểm (Pros)
* **Trải nghiệm Tức thì (Zero Latency)**: Người dùng nghe thấy hiệu ứng ngay lập tức khi kéo nút hoặc đổi chế độ mà không cần đợi gửi file lên server xử lý.
* **Giảm tải cực lớn cho Server**: Trình duyệt tự gánh vác phần giải mã và xử lý DSP thời gian thực trên thiết bị người dùng, máy chủ chỉ cần chạy xử lý khi người dùng xuất bản (Export).
* **Chất lượng Master tuyệt đối**: Bản xuất ra (Render) trên Backend sử dụng thuật toán C++ tối ưu cao của `pedalboard` hoặc các bộ lọc khoa học của `scipy` giúp chất lượng âm thanh đạt chuẩn phòng thu, không bị méo tiếng do giới hạn tài nguyên của trình duyệt.
### 2. Nhược điểm (Cons)
* **Độ lệch âm thanh (Sound Discrepancy)**: Bản nghe thử trên trình duyệt (Web Audio API) và bản xuất ra (Python Backend) có thể có khác biệt nhỏ về màu sắc âm thanh nếu thuật toán tạo Chorus/Reverb khác nhau.
* **Trùng lặp mã nguồn (Code Duplication)**: Phải viết logic xử lý hiệu ứng ở cả 2 ngôn ngữ (JavaScript trên Client và Python trên Server).
### 3. Giải pháp Tối ưu hóa tốt nhất (Optimal Strategies)
Để tối ưu hóa hiệu suất và chất lượng, chúng ta áp dụng các giải pháp sau:
* **Đồng bộ hóa tham số (Unified Parameters)**: Cả Client và Server đều đọc chung các tham số hiệu ứng từ tệp JSON cấu hình dự án (ví dụ: `chorus_rate = 1.5 Hz`, `reverb_room_size = 0.5`).
* **Đồng hóa thuật toán (DSP Matching)**:
* *Chorus*: Cài đặt LFO sine điều tần thời gian trễ đồng bộ ở cả 2 bên.
* *Reverb*:
* **Client**: Sử dụng `ConvolverNode` nạp bộ đệm xung tự sinh (synthetic impulse response) dài 2 giây từ thuật toán nhiễu trắng phân rã lũy thừa.
* **Server**: Sử dụng `pedalboard.Reverb` hoặc thuật toán chập tín hiệu phân rã tương đương trong Python.
* **Tối ưu hóa tài nguyên phía Server**:
* Không nạp lại SoundFont hoặc thư viện nhiều lần; sử dụng cơ chế singleton hoặc caching cho các module xử lý hiệu ứng của Python.
---
## Đề xuất Thay đổi cụ thể
### 1. Kiến trúc hiệu ứng Track FX (Chorus & Reverb)
#### Phía Client (Web Audio API)
Chúng ta sẽ bổ sung chuỗi liên kết hiệu ứng âm thanh trực tiếp vào nút nguồn của từng track trong [app.jsx](file:///home/locpham/SonicForgeStudio/app/static/js/app.jsx). Bất cứ nguồn âm thanh nào đi qua track (cả clip tiếng động lẫn âm thanh MIDI qua SoundFont) đều chịu tác dụng của hiệu ứng:
- **Chorus**: Sử dụng một dry gain và một wet gain kết nối tới LFO-modulated delay node. LFO dao động hình sin tần số `1.5 Hz` với độ lệch delay `2 ms` tạo tiếng đồng ca.
- **Reverb**: Sử dụng dry gain, wet gain và `ConvolverNode` chứa bộ đệm phản hồi âm thanh nhân tạo tự sinh dài 2 giây từ thuật toán nhiễu trắng phân rã mũ.
- **Giao diện Chọn hiệu ứng**: Khi người dùng nhấn nút **FX: None** trên Track Header, một popup selector sẽ hiển thị để người dùng chọn: **None / Chorus / Reverb**, cập nhật thuộc tính `track.fxType`.
#### Phía Server (Python Rendering)
Khi kết xuất dự án trong [render_engine.py](file:///home/locpham/SonicForgeStudio/app/core/render_engine.py), chúng ta đọc thuộc tính `fx_type` từ track:
- Nếu `track.fx_type == 'chorus'`: Sử dụng lớp `Chorus` của `pedalboard` (nếu có thư viện), hoặc dùng `scipy.signal` để tạo dịch pha điều tần chậm.
- Nếu `track.fx_type == 'reverb'`: Sử dụng lớp `Reverb` của `pedalboard`, hoặc sử dụng chập phản hồi âm để tạo vang.
---
### 2. Vẽ lại trực quan Section item (Waveform nền & Note màu)
Cập nhật thuật toán vẽ Section item trên Canvas Timeline trong [app.jsx](file:///home/locpham/SonicForgeStudio/app/static/js/app.jsx):
- **Phông nền Waveform**: Duyệt qua tối đa 4 track con trong `sec.tracks`. Nếu track con chứa audio clip có `buffer` dữ liệu, lấy mẫu thu gọn 100 peaks và vẽ thành đồ thị cột phân rã đối xứng màu xanh cyan làm nền bên trong phân vùng của Section.
- **Vẽ note MIDI theo màu sắc**: Quét các MIDI item trong các track con. Vẽ các note nhạc đè lên waveform với màu sắc tương trưng cho thứ tự track con (ví dụ: track 1 dùng màu vàng hổ phách `#fbbf24`, track 2 dùng màu tím `#a78bfa`, v.v.), giúp người dùng nhận diện nhanh cấu trúc hòa âm.
- **Phát Section Item trên Main Session**: Đảm bảo thuật toán lập lịch phát âm thanh trong `startTrackPlayback` quét qua toàn bộ clip và nốt nhạc con của Section item để lên lịch phát nhạc đồng bộ khi playhead quét qua.
---
### 3. Phím tắt Nâng cao trong Piano Roll
Chúng ta sẽ chỉnh sửa các hàm xử lý sự kiện chuột của canvas Piano Roll trong [app.jsx](file:///home/locpham/SonicForgeStudio/app/static/js/app.jsx):
#### Giải phóng nốt cũ khi chọn nốt mới (Sửa lỗi Drag & Release)
- Trong `handleGridMouseDown`, khi người dùng click vào một nốt mới nằm ngoài vùng chọn hiện tại, chúng ta sẽ cập nhật danh sách chọn lựa bằng một biến cục bộ đồng bộ `nextSelectedIds = [clickedNote.id]` thay vì sử dụng state bất đồng bộ `selectedNoteIds`. Điều này đảm bảo danh sách nốt bị dịch chuyển (`selectedNotesOffset`) chỉ chứa duy nhất nốt mới click, giải phóng hoàn toàn nốt cũ.
#### Sao chép nốt nhanh (Ctrl + Click + Drag)
- Trong `handleGridMouseDown`, nếu nhấn chuột trái đồng thời đè phím `Ctrl` trên một nốt nhạc:
- Tạo các bản sao nhân bản (cloned copies) của tất cả các nốt đang được chọn với ID ngẫu nhiên mới.
- Thêm các nốt nhân bản này vào danh sách `notes` của tab.
- Chuyển trạng thái `draggedNote` sang chế độ di chuyển (`move`) áp dụng trực tiếp lên các bản sao mới này, giữ nguyên các nốt gốc ở vị trí cũ.
#### Giãn/Thu tỷ lệ thời gian các nốt (Alt + Drag Resize)
- Khi người dùng nhấn giữ phím `Alt` và kéo cạnh phải (resize edge) của một nốt trong nhóm đang được chọn:
- Xác định thời điểm bắt đầu của nốt đầu tiên trong nhóm tuyển chọn (`firstStartBeat`) và điểm kết thúc ban đầu của nốt bị kéo (`originalDraggedEndBeat`).
- Tính toán tỷ lệ co giãn thời gian:
$$\text{scaleFactor} = \frac{\text{newDraggedEndBeat} - \text{firstStartBeat}}{\text{originalDraggedEndBeat} - \text{firstStartBeat}}$$
- Cập nhật thời điểm bắt đầu (`start_beat`) và thời lượng (`duration_beats`) của tất cả các nốt nhạc được chọn có thời điểm bắt đầu nhỏ hơn hoặc bằng điểm kết thúc ban đầu của nốt bị kéo bằng cách nhân với `scaleFactor`.
- Các nốt nhạc nằm sau vị trí kéo (ví dụ nốt thứ 4) sẽ được giữ nguyên không đổi.
#### Vẽ nhiều nốt bằng cách di chuột (Brush/Drag to Draw)
- Khi đang kéo vẽ nốt mới, lưu vết mảng các cao độ (pitch/row) đã đi qua trong `draggedNote.visitedPitches`.
- Khi di chuyển chuột qua cao độ mới, thêm cao độ đó vào danh sách và phân bổ đều tổng khoảng cách kéo (`beat - startOffsetBeat`) thành các nốt nhạc nối tiếp nhau, mỗi nốt có thời lượng bằng `totalSpan / visitedPitches.length`.
---
## Kế hoạch Kiểm thử
### 1. Kiểm thử hiệu ứng âm thanh FX
- **Thời gian thực (Client)**: Kích hoạt Chorus/Reverb trên track, bấm Play nghe thử để xác nhận tiếng vang/tiếng đồng ca chạy mượt mà.
- **Kết xuất (Backend)**: Mixdown dự án có track bật Chorus hoặc Reverb, kiểm tra file wav đầu ra xem hiệu ứng có được áp dụng chuẩn.
### 2. Kiểm thử vẽ trực quan & Phát nhạc Section
- Thêm nốt nhạc và audio clip vào Section, nhấn lưu. Kiểm tra xem Section item hiển thị đúng dạng sóng và nốt màu.
- Phát nhạc trên Main Session, kiểm tra xem khi playhead đi qua Section item thì âm thanh của Section có phát ra đúng nhịp.
### 3. Kiểm thử phím tắt Piano Roll
- Kiểm tra click chọn nốt mới để di chuyển xem nốt cũ có được giải phóng hoàn toàn và không di chuyển theo.
- Giữ `Ctrl` kéo nốt để sao chép.
- Giữ `Alt` kéo giãn nhóm nốt.
- Di chuột chéo để vẽ chuỗi nốt bậc thang (Brush tool).