feat: cài đặt giao diện và tích hợp AI Analysis engine

This commit is contained in:
2026-07-18 15:30:33 +07:00
parent a34b01a035
commit 1968867d46
3 changed files with 541 additions and 45 deletions
+132
View File
@@ -0,0 +1,132 @@
Dưới đây là toàn bộ nội dung tài liệu đặc tả kỹ thuật đã được chuyển đổi sang định dạng Markdown chuẩn, tối ưu hóa các khối mã nguồn (`python`, `text`), căn chỉnh bảng biểu và định dạng các công thức toán học LaTeX:
# Đặc Tả Kỹ Thuật: Hành Động Chọn Vùng & Cơ Chế Phát Lặp (Solo vs. Master Loop)
Tài liệu này đặc tả cơ chế tương tác chuột và logic phát âm thanh tương ứng đối với hai vùng chọn: Chọn cục bộ trên Track (*Local Selection*) và Chọn toàn cục trên Timeline (*Global Selection*) dựa trên thiết kế chuẩn DAW trong hình `image_dfffa3.png`.
Mục tiêu là cung cấp thuật toán xử lý sự kiện chuột (`MouseEvent`) để lập trình viên chuyển đổi (*port*) trực tiếp sang Python (ví dụ sử dụng `QGraphicsView` hoặc `QWidget` tùy biến trong PyQt6/PySide6).
---
## 1. Bản Vẽ Phân Cấp Vùng Tương Tác (Layout Hitboxes)
Dựa trên hình `image_dfffa3.png`, khu vực biên tập bên phải được phân chia thành 2 hitbox tương tác chuột chính yếu:
```text
+-------------------------------------------------------------------------+
| [VÙNG A] TIMELINE RULER (Thước đo thời gian trên cùng - Mũi tên đỏ chỉ) |
+-------------------------------------------------------------------------+
| [VÙNG B] CÁC LÀN TRACK LANE (Xếp chồng dọc) |
| - Track 01 Waveform Area (Hitbox cục bộ 1) |
| - Track 02 Waveform Area (Hitbox cục bộ 2) |
| - Track 03 Waveform Area (Hitbox cục bộ 3) |
+-------------------------------------------------------------------------+
```
---
## 2. Đặc Tả Tương Tác 1: Chọn Cục Bộ & Phát Lặp Độc Lập (Local Track Selection & Solo Loop)
### 2.1. Hành động người dùng (User Action)
* **Click chuột đơn (Mouse Press):** Người dùng nhấp chuột vào một điểm bất kỳ trên dạng sóng của một track (Ví dụ: Track 01).
* *Hệ quả:* Track đó lập tức được highlight (*Active State*), các track khác chuyển sang trạng thái chờ.
* **Kéo chuột (Mouse Drag):** Click và nhấn giữ chuột, kéo sang trái hoặc phải trên phần hiển thị sóng âm của track đó.
* *Hệ quả:* Tạo ra một vùng chọn thời gian giới hạn bởi điểm nhấn đầu và điểm thả chuột cuối.
### 2.2. Cơ chế phát lặp (Loop Playback Logic)
* **Chế độ phát:** Khi nhấn nút *Play Loop*, hệ thống chỉ phát lặp duy nhất đoạn nhạc nằm trong vùng chọn của track đang active.
* **Xử lý Audio Engine ở Python:**
* Tự động kích hoạt cơ chế Mute tạm thời cho tất cả các track khác, hoặc kích hoạt nhanh chế độ Solo cho track đang active trong luồng phát âm thanh.
* Giới hạn mốc thời gian phát trong khoảng $T_{\text{start}}$ đến $T_{\text{end}}$. Khi kim phát phát chạm $T_{\text{end}}$, nhảy ngay lập tức về $T_{\text{start}}$ mà không dừng phát các luồng khác (nếu có).
### 2.3. Ánh xạ mã sự kiện Python (PyQt6 / PySide6 Concept)
```python
# Giả lập xử lý sự kiện trong lớp TrackWaveformWidget(QWidget)
def mousePressEvent(self, event):
if event.button() == Qt.MouseButton.LeftButton:
# 1. Kích hoạt chọn track hiện hành
self.parent_session.set_active_track(self.track_id)
# 2. Ghi nhận điểm mốc thời gian bắt đầu
self.drag_start_time = self.pixel_to_time(event.position().x())
self.is_dragging = True
def mouseMoveEvent(self, event):
if self.is_dragging:
current_time = self.pixel_to_time(event.position().x())
# Cập nhật vùng chọn cục bộ (Local Selection Range)
self.parent_session.update_local_selection(
track_id=self.track_id,
start=min(self.drag_start_time, current_time),
end=max(self.drag_start_time, current_time)
)
```
---
## 3. Đặc Tả Tương Tác 2: Chọn Toàn Cục & Phát Lặp Đa Kênh (Global Timeline Selection & Master Loop)
### 3.1. Hành động người dùng (User Action)
* **Định vị:** Di chuyển con trỏ chuột lên trên cùng, vào thanh chứa Timeline Ruler (Vị trí mũi tên màu đỏ trong hình `image_dfffa3.png`).
* **Kéo chuột (Mouse Drag):** Click chuột vào thanh Ruler và kéo sang trái/phải.
* *Hệ quả:* Một khung màu cam nhạt (*Selection Overlay*) xuất hiện và kéo dọc toàn bộ chiều cao màn hình xuyên qua tất cả các track từ trên xuống dưới (như hiển thị thực tế trong hình `image_dfffa3.png`).
### 3.2. Cơ chế phát lặp (Loop Playback Logic)
* **Chế độ phát:** Khi nhấn nút *Play Loop*, hệ thống sẽ phát đồng thời tất cả các track (*Master Playback*) nhưng giới hạn vòng lặp đồng bộ chỉ nằm trong dải thời gian được bôi màu.
* **Xử lý Audio Engine ở Python:**
* Giữ nguyên trạng thái Mute/Solo hiện tại của các track (không tự động tắt tiếng các track khác).
* Đồng bộ hóa pha của tất cả các luồng phát. Khi Playhead chạm mốc kết thúc vùng chọn $T_{\text{end}}$, toàn bộ các nguồn phát (*Audio Sources*) đang hoạt động đều phải nhảy đồng bộ về vị trí $T_{\text{start}}$.
### 3.3. Ánh xạ mã sự kiện Python (PyQt6 / PySide6 Concept)
```python
# Giả lập xử lý sự kiện trong lớp TimelineRulerWidget(QWidget)
def mousePressEvent(self, event):
if event.button() == Qt.MouseButton.LeftButton:
# Ghi nhận mốc thời gian toàn cục ban đầu
self.global_drag_start = self.pixel_to_time(event.position().x())
self.is_dragging_global = True
# Hủy bỏ các vùng chọn cục bộ (nếu có) để ưu tiên chế độ chọn tổng thể
self.parent_session.clear_all_local_selections()
def mouseMoveEvent(self, event):
if self.is_dragging_global:
current_time = self.pixel_to_time(event.position().x())
# Cập nhật vùng chọn toàn cục xuyên suốt tất cả các làn track
self.parent_session.set_global_selection(
start=min(self.global_drag_start, current_time),
end=max(self.global_drag_start, current_time)
)
```
---
## 4. Tóm Tắt Sự Khác Biệt Giữa 2 Trạng Trạng Thái
Để đảm bảo hệ thống không bị xung đột khi xử lý đa luồng âm thanh trên Python Docker, bộ điều phối âm thanh (*Audio Coordinator*) cần tuân thủ bảng logic sau:
| Thuộc tính | Tương tác trên Track (Local) | Tương tác trên Ruler (Global) |
| --- | --- | --- |
| **Phạm vi hiển thị vùng chọn** | Chỉ hiển thị hoặc sáng rõ tại track đang active. | Kéo dài thẳng đứng, phủ qua tất cả các track (như hình `image_dfffa3.png`). |
| **Phạm vi phát âm thanh** | **Solo Playback:** Chỉ phát duy nhất âm thanh của track được chọn. | **Master Playback:** Phát tất cả các track cùng lúc (không tự động Solo/Mute). |
| **Điểm lặp (Loop Target)** | $T_{\text{start}} \rightarrow T_{\text{end}}$ của riêng track hoạt động. | $T_{\text{start}} \rightarrow T_{\text{end}}$ toàn cục đồng bộ tất cả các track. |
| **Phục vụ AI Cut** | Chỉ cắt tệp tin của riêng track được chọn để đưa sang track mới. | Cho phép render/mixdown gộp tất cả các track trong dải chọn thành tệp mới. |
+173
View File
@@ -0,0 +1,173 @@
# Tài Liệu Thiết Kế: Hệ Thống Phân Quyền, Quản Lý Quota & Admin Control
Tài liệu này đặc tả kiến trúc bảo mật, quản lý người dùng, hạn mức tài nguyên (Quota), cờ tính năng (Feature Flags) và giao diện quản trị cho hệ thống SonicForge Studio.
---
## 1. Sơ Đồ Phân Cấp & Luồng Xác Thực (RBAC & Auth Flow)
Hệ thống sử dụng cơ chế kiểm soát truy cập dựa trên vai trò (*Role-Based Access Control - RBAC*) với 3 nhóm vai trò cơ bản:
* **Super Admin:** Toàn quyền cấu hình hệ thống, quản lý người dùng, hạn mức (Quota), bật/tắt tính năng toàn cục và dọn dẹp tài nguyên vật lý.
* **Premium User:** Người dùng trả phí, có hạn mức dung lượng lưu trữ lớn, không giới hạn số lượng track và được ưu tiên sử dụng AI Engine.
* **Standard User:** Người dùng đăng ký miễn phí, có giới hạn dung lượng lưu trữ, số track tối đa và giới hạn số lượt gọi AI API hàng tháng.
### 1.1. Luồng Đăng Nhập Đầu Tiên với Mật Khẩu Mặc Định
Khi Admin khởi tạo một tài khoản mới hoặc tạo một dự án độc lập, hệ thống sẽ gán một mật khẩu mặc định (*Default Password*) được cấu hình từ môi trường Docker (`DEFAULT_ADMIN_PASSWORD`).
```text
[Người dùng/Admin] ──► Đăng nhập bằng Mật khẩu Mặc định ──► [Server xác thực JWT]
┌───────────────────────────────────────────────────────────────┘
Kiểm tra trạng thái `must_change_password == True`
├── (Đúng) ──► Chặn mọi yêu cầu API thông thường ──► Trả về HTTP 403 (PASSWORD_CHANGE_REQUIRED)
│ yêu cầu đổi mật khẩu ngay lập tức.
└── (Sai) ──► Cho phép truy cập tài nguyên bình thường.
```
---
## 2. Thiết Kế Cơ Sở Dữ Liệu (Database Schema)
Để lưu trữ thông tin phân quyền, cấu hình mã hóa mật khẩu bằng thuật toán băm bảo mật Argon2id hoặc Bcrypt.
```sql
-- Bảng Người dùng (Users)
CREATE TABLE users (
id VARCHAR(36) PRIMARY KEY,
username VARCHAR(50) UNIQUE NOT NULL,
email VARCHAR(100) UNIQUE NOT NULL,
hashed_password VARCHAR(255) NOT NULL,
role VARCHAR(20) DEFAULT 'standard', -- 'admin', 'premium', 'standard'
must_change_password BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
is_active BOOLEAN DEFAULT TRUE
);
-- Bảng Hạn mức tài nguyên (User Quotas)
CREATE TABLE user_quotas (
user_id VARCHAR(36) PRIMARY KEY,
storage_limit_mb INTEGER DEFAULT 500, -- Hạn mức ổ đĩa (Ví dụ: 500MB)
max_tracks_per_project INTEGER DEFAULT 4, -- Số track tối đa trong một project
ai_calls_limit_monthly INTEGER DEFAULT 50, -- Số lần gọi AI tối đa mỗi tháng
ai_calls_used_this_month INTEGER DEFAULT 0,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
-- Bảng Cờ Tính Năng Hệ Thống (Feature Flags - Cho phép Admin bật/tắt nóng tính năng)
CREATE TABLE feature_flags (
flag_key VARCHAR(50) PRIMARY KEY, -- Ví dụ: 'ai_cut_enabled', 'wav_export_24bit'
description VARCHAR(255),
is_enabled BOOLEAN DEFAULT TRUE,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Bảng Siêu dữ liệu Tập tin (Audio Files Meta)
CREATE TABLE audio_files (
id VARCHAR(36) PRIMARY KEY,
user_id VARCHAR(36) NOT NULL,
file_name VARCHAR(255) NOT NULL,
file_path VARCHAR(512) NOT NULL,
file_size_bytes BIGINT NOT NULL,
duration_seconds FLOAT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
```
---
## 3. Thiết Kế Bảo Mật & Logic Python (Backend Implementation)
### 3.1. Hashing Mật Khẩu (Bcrypt)
Mật khẩu bắt buộc phải được mã hóa một chiều bằng salt ngẫu nhiên trước khi lưu vào DB.
```python
from passlib.context import CryptContext
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(password: str) -> str:
return pwd_context.hash(password)
def verify_password(plain_password: str, hashed_password: str) -> bool:
return pwd_context.verify(plain_password, hashed_password)
```
### 3.2. Middleware Ép Đổi Mật Khẩu (Force Password Change Middleware)
Tất cả các API yêu cầu xác thực (ngoại trừ Endpoint đổi mật khẩu `/api/v1/auth/change-password`) đều phải đi qua bộ lọc kiểm tra trạng thái:
```python
from fastapi import HTTPException, status, Depends
from app.models import User
def verify_user_not_flagged(current_user: User = Depends(get_current_active_user)):
if current_user.must_change_password:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={
"error_code": "PASSWORD_CHANGE_REQUIRED",
"message": "Bạn phải đổi mật khẩu mặc định trước khi sử dụng hệ thống."
}
)
return current_user
```
### 3.3. Thuật Toán Kiểm Tra Hạn Mức Dung Lượng (Quota Validation)
Trước khi cho phép người dùng tải lên tệp tin mới, hệ thống tính toán tổng dung lượng tệp tin hiện tại:
$$S_{\text{used}} = \sum_{i=1}^{N} \text{file\_size\_bytes}_i$$
Nếu $S_{\text{used}} + S_{\text{new\_file}} > \text{storage\_limit\_mb} \times 1024 \times 1024$, chặn tải lên ngay tại API Gateway và trả về lỗi `HTTP 400 Bad Request`.
---
## 4. API Endpoints Quản Trị Hệ Thống (Admin APIs)
Admin sẽ được cung cấp bộ API riêng biệt để quản lý toàn cục:
| Phương thức | Endpoint | Phân quyền | Mô tả |
| --- | --- | --- | --- |
| **POST** | `/api/v1/auth/register` | Toàn quyền (Public) | Đăng ký tài khoản người dùng mới (Mặc định `must_change_password = False`). |
| **GET** | `/api/v1/admin/users` | Admin | Lấy danh sách tất cả người dùng kèm thông tin Quotas hiện tại. |
| **PUT** | `/api/v1/admin/users/{user_id}` | Admin | Sửa đổi thông tin người dùng (Đổi vai trò, kích hoạt/vô hiệu hóa tài khoản). |
| **DELETE** | `/api/v1/admin/users/{user_id}` | Admin | Xóa tài khoản người dùng và tự động dọn dẹp tất cả tệp tin liên quan. |
| **PUT** | `/api/v1/admin/quotas/{user_id}` | Admin | Điều chỉnh giới hạn dung lượng (`storage_limit_mb`) và số lượt gọi AI. |
| **GET** | `/api/v1/admin/files` | Admin | Khám phá toàn bộ tệp tin đang lưu trên Server của mọi người dùng. |
| **DELETE** | `/api/v1/admin/files/{file_id}` | Admin | Buộc xóa tệp tin vật lý khỏi ổ đĩa và cập nhật lại quota cho người dùng. |
| **PUT** | `/api/v1/admin/features` | Admin | Thay đổi trạng thái True/False của các Feature Flags hệ thống. |
---
## 5. UI Mockup: Admin Dashboard Sub-Panel
Khi người dùng đăng nhập với vai trò admin, một tab "Admin Dashboard" chuyên dụng sẽ xuất hiện trên thanh điều hướng góc trên cùng:
```text
+-----------------------------------------------------------------------------+
| SONICFORGE STUDIO Pro [Workspace] [AI Settings] [*Admin Dashboard*] |
+-----------------------------------------------------------------------------+
| QUẢN LÝ NGƯỜI DÙNG & TÀI NGUYÊN HỆ THỐNG |
| +-------------------------------------------------------------------------+ |
| | Tên người dùng | Vai trò | Dung lượng (MB) | Đã dùng (MB) | Thao tác | |
| |----------------|------------|------------------|--------------|----------| |
| | admin_01 | Admin | Vô hạn | 14.2 MB | [Sửa] | |
| | user_studio | Premium | 2048 MB | 512.0 MB | [Sửa][Xóa]| |
| | demo_member | Standard | 500 MB | 498.5 MB | [Sửa][Xóa]| |
| +-------------------------------------------------------------------------+ |
| |
| CẤU HÌNH TÍNH NĂNG (FEATURE FLAGS) |
| [X] Kích hoạt AI Cut Engine | [X] Cho phép xuất 24-bit WAV | [ ] Tách vocal |
+-----------------------------------------------------------------------------+
```
+228 -37
View File
@@ -146,7 +146,12 @@
onPlayheadSet,
isSelected,
onSelectTrack,
markers
markers,
selectionMode,
localSelectionTrackId,
localSelLeft,
localSelRight,
onTrackLaneMouseDown,
}) => {
const canvasRef = useRef(null);
@@ -234,26 +239,36 @@
ctx.fillText('Kéo thả hoặc tải file âm thanh vào Track này', width / 2, height / 2);
}
// Selection highlight
// (handled by overlay in parent)
// Selection highlight - local selection on this track
if (selectionMode === 'local' && localSelectionTrackId === track.id &&
localSelLeft !== null && localSelRight !== null && localSelRight > localSelLeft) {
const hlLeft = localSelLeft * zoom;
const hlWidth = (localSelRight - localSelLeft) * zoom;
ctx.fillStyle = 'rgba(245, 158, 11, 0.15)';
ctx.fillRect(hlLeft, 0, hlWidth, height);
ctx.strokeStyle = '#f59e0b';
ctx.lineWidth = 1.5;
ctx.strokeRect(hlLeft, 0, hlWidth, height);
}
}, [track, zoom, timelineWidth, isSelected, markers]);
}, [track, zoom, timelineWidth, isSelected, markers, selectionMode, localSelectionTrackId, localSelLeft, localSelRight]);
return (
<canvas
ref={canvasRef}
className="w-full h-full cursor-crosshair"
onMouseDown={(e) => {
const wrapper = canvasRef.current?.parentElement?.parentElement;
const scrollLeft = wrapper ? wrapper.scrollLeft : 0;
const rect = canvasRef.current.getBoundingClientRect();
const x = e.clientX - rect.left + (canvasRef.current.parentElement?.parentElement?.scrollLeft || 0);
const time = x / zoom;
if (e.shiftKey) {
onSelectRange(0, time, false);
} else {
onSelectRange(time, time, true);
}
const x = e.clientX - rect.left + scrollLeft;
const time = Math.max(0, x / zoom);
onSelectTrack(track.id);
onPlayheadSet(time);
if (onTrackLaneMouseDown) {
onTrackLaneMouseDown(track.id, time, e);
}
e.stopPropagation();
}}
/>
);
@@ -270,6 +285,10 @@
const [isPlaying, setIsPlaying] = useState(false);
const [selectionStart, setSelectionStart] = useState(null);
const [selectionEnd, setSelectionEnd] = useState(null);
const [selectionMode, setSelectionMode] = useState(null); // 'global' (from ruler) | 'local' (from track)
const [localSelectionTrackId, setLocalSelectionTrackId] = useState(null);
const [localSelectionStart, setLocalSelectionStart] = useState(null);
const [localSelectionEnd, setLocalSelectionEnd] = useState(null);
const [zoom, setZoom] = useState(100);
const [isLoopingSelection, setIsLoopingSelection] = useState(false);
const [isExporting, setIsExporting] = useState(false);
@@ -294,11 +313,14 @@
const [serverStatus, setServerStatus] = useState('checking...');
const timelineWrapperRef = useRef(null);
const rulerRef = useRef(null);
const activeSourcesRef = useRef([]);
const startOffsetTimeRef = useRef(0);
const startAudioTimeRef = useRef(0);
const animationFrameIdRef = useRef(null);
const toastTimeoutRef = useRef(null);
const rulerDragStartRef = useRef(null);
const isDraggingRulerRef = useRef(false);
// ── Server Health Check ──
useEffect(() => {
@@ -323,14 +345,20 @@
const playheadLeftPos = useMemo(() => currentTime * zoom, [currentTime, zoom]);
const selLeft = useMemo(() => {
if (selectionMode === 'local' && localSelectionStart !== null && localSelectionEnd !== null) {
return Math.min(localSelectionStart, localSelectionEnd);
}
if (selectionStart === null || selectionEnd === null) return null;
return Math.min(selectionStart, selectionEnd);
}, [selectionStart, selectionEnd]);
}, [selectionStart, selectionEnd, selectionMode, localSelectionStart, localSelectionEnd]);
const selRight = useMemo(() => {
if (selectionMode === 'local' && localSelectionStart !== null && localSelectionEnd !== null) {
return Math.max(localSelectionStart, localSelectionEnd);
}
if (selectionStart === null || selectionEnd === null) return null;
return Math.max(selectionStart, selectionEnd);
}, [selectionStart, selectionEnd]);
}, [selectionStart, selectionEnd, selectionMode, localSelectionStart, localSelectionEnd]);
// ── Toast helper ──
const showToast = (text, type = 'info') => {
@@ -432,14 +460,24 @@
const elapsed = context.currentTime - startAudioTimeRef.current;
const updatedTime = startOffsetTimeRef.current + elapsed;
// Selection Loop
// Selection Loop - LOOP_MAKER.md spec: local vs global behavior
if (isLoopingSelection && selLeft !== null && selRight !== null) {
if (selRight > selLeft && updatedTime >= selRight) {
if (selectionMode === 'local') {
// Local Solo Loop: only restart the selected track
stopAllPlayback();
startOffsetTimeRef.current = selLeft;
startAudioTimeRef.current = context.currentTime;
startLocalTrackPlayback(localSelectionTrackId, selLeft);
setCurrentTime(selLeft);
} else {
// Global Master Loop: restart all tracks
stopAllPlayback();
startOffsetTimeRef.current = selLeft;
startAudioTimeRef.current = context.currentTime;
startTrackPlayback(selLeft);
setCurrentTime(selLeft);
}
animationFrameIdRef.current = requestAnimationFrame(updatePlayhead);
return;
}
@@ -462,7 +500,7 @@
cancelAnimationFrame(animationFrameIdRef.current);
}
return () => cancelAnimationFrame(animationFrameIdRef.current);
}, [isPlaying, isLoopingSelection, selLeft, selRight]);
}, [isPlaying, isLoopingSelection, selLeft, selRight, selectionMode, localSelectionTrackId]);
// ── Playback ──
const startTrackPlayback = (offsetTime) => {
@@ -494,6 +532,27 @@
});
};
// Solo playback for Local Selection Loop (LOOP_MAKER.md §2.2)
const startLocalTrackPlayback = (trackId, offsetTime) => {
const context = getAudioContext();
const track = tracks.find(t => t.id === trackId);
if (!track || !track.buffer) return;
const source = context.createBufferSource();
source.buffer = track.buffer;
const gainNode = context.createGain();
gainNode.gain.setValueAtTime(track.volume, context.currentTime);
source.connect(gainNode);
gainNode.connect(context.destination);
if (offsetTime < track.buffer.duration) {
source.start(0, offsetTime);
activeSourcesRef.current.push(source);
}
};
const handlePlayPause = () => {
const context = getAudioContext();
if (isPlaying) {
@@ -524,6 +583,103 @@
};
// ── Selection ──
const clearLocalSelection = () => {
setSelectionMode(null);
setLocalSelectionTrackId(null);
setLocalSelectionStart(null);
setLocalSelectionEnd(null);
};
const handleRulerMouseDown = (e) => {
const ruler = rulerRef.current;
if (!ruler) return;
const rect = ruler.getBoundingClientRect();
const wrapper = timelineWrapperRef.current;
const scrollLeft = wrapper ? wrapper.scrollLeft : 0;
const mouseX = e.clientX - rect.left + scrollLeft;
const time = mouseX / zoom;
clearLocalSelection();
setSelectionMode('global');
rulerDragStartRef.current = time;
isDraggingRulerRef.current = true;
setSelectionStart(time);
setSelectionEnd(time);
setCurrentTime(time);
};
// Global Ruler mousemove is tracked via document listener set up in useEffect
useEffect(() => {
const handleMouseMove = (e) => {
if (!isDraggingRulerRef.current) return;
const ruler = rulerRef.current;
const wrapper = timelineWrapperRef.current;
if (!ruler || !wrapper) return;
const rect = ruler.getBoundingClientRect();
const scrollLeft = wrapper.scrollLeft;
const mouseX = e.clientX - rect.left + scrollLeft;
const time = Math.max(0, Math.min(maxDuration, mouseX / zoom));
setSelectionEnd(time);
};
const handleMouseUp = () => {
if (isDraggingRulerRef.current) {
isDraggingRulerRef.current = false;
rulerDragStartRef.current = null;
}
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
}, [zoom, maxDuration]);
// ── Track Lane Local Selection Drag ──
const localDragInProgressRef = useRef(false);
const localDragTrackRef = useRef(null);
const localDragStartTimeRef = useRef(0);
const handleTrackLaneMouseDown = (trackId, time) => {
clearLocalSelection();
setSelectionMode('local');
setLocalSelectionTrackId(trackId);
setLocalSelectionStart(time);
setLocalSelectionEnd(time);
localDragInProgressRef.current = true;
localDragTrackRef.current = trackId;
localDragStartTimeRef.current = time;
};
// Document-level mousemove/mouseup for local selection drag
useEffect(() => {
const handleMouseMove = (e) => {
if (!localDragInProgressRef.current) return;
const wrapper = timelineWrapperRef.current;
if (!wrapper) return;
const rect = wrapper.getBoundingClientRect();
const scrollLeft = wrapper.scrollLeft;
const mouseX = e.clientX - rect.left + scrollLeft;
const time = Math.max(0, Math.min(maxDuration, mouseX / zoom));
setLocalSelectionEnd(time);
};
const handleMouseUp = () => {
if (localDragInProgressRef.current) {
localDragInProgressRef.current = false;
localDragTrackRef.current = null;
localDragStartTimeRef.current = 0;
}
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
}, [zoom, maxDuration]);
const handleSelectRange = (start, end, reset) => {
const maxLen = maxDuration;
const cleanStart = Math.max(0, Math.min(maxLen, start));
@@ -539,45 +695,59 @@
const handleSelectionInputChange = (field, val) => {
const numericVal = Math.max(0, parseFloat(val) || 0);
if (selectionMode === 'local') {
// Editing local selection directly
if (field === 'start') {
setLocalSelectionStart(numericVal);
} else {
setLocalSelectionEnd(numericVal);
}
} else {
if (field === 'start') {
setSelectionStart(numericVal);
} else {
setSelectionEnd(numericVal);
}
}
};
const selectionStats = useMemo(() => {
if (selectionStart === null || selectionEnd === null) {
if (selLeft === null || selRight === null) {
return { start: 0, end: 0, length: 0 };
}
const s = Math.min(selectionStart, selectionEnd);
const e = Math.max(selectionStart, selectionEnd);
const s = Math.min(selLeft, selRight);
const e = Math.max(selLeft, selRight);
return {
start: parseFloat(s.toFixed(3)),
end: parseFloat(e.toFixed(3)),
length: parseFloat((e - s).toFixed(3))
};
}, [selectionStart, selectionEnd]);
}, [selLeft, selRight]);
// ── Handle Drag (selection resize) ──
const handleHandleDragStart = (e, side) => {
e.preventDefault();
e.stopPropagation();
const startX = e.clientX;
const initialLeft = Math.min(selectionStart, selectionEnd);
const initialRight = Math.max(selectionStart, selectionEnd);
const useLocal = selectionMode === 'local';
const currentStart = useLocal ? localSelectionStart : selectionStart;
const currentEnd = useLocal ? localSelectionEnd : selectionEnd;
const initialLeft = Math.min(currentStart, currentEnd);
const initialRight = Math.max(currentStart, currentEnd);
const setStart = useLocal ? setLocalSelectionStart : setSelectionStart;
const setEnd = useLocal ? setLocalSelectionEnd : setSelectionEnd;
const handleMouseMove = (moveEvent) => {
const deltaX = moveEvent.clientX - startX;
const deltaSec = deltaX / zoom;
if (side === 'left') {
const newLeft = Math.max(0, Math.min(initialRight - 0.05, initialLeft + deltaSec));
setSelectionStart(newLeft);
setSelectionEnd(initialRight);
setStart(newLeft);
setEnd(initialRight);
} else {
const newRight = Math.max(initialLeft + 0.05, Math.min(maxDuration, initialRight + deltaSec));
setSelectionStart(initialLeft);
setSelectionEnd(newRight);
setStart(initialLeft);
setEnd(newRight);
}
};
@@ -594,9 +764,14 @@
e.preventDefault();
e.stopPropagation();
const startX = e.clientX;
const initialLeft = Math.min(selectionStart, selectionEnd);
const initialRight = Math.max(selectionStart, selectionEnd);
const useLocal = selectionMode === 'local';
const currentStart = useLocal ? localSelectionStart : selectionStart;
const currentEnd = useLocal ? localSelectionEnd : selectionEnd;
const initialLeft = Math.min(currentStart, currentEnd);
const initialRight = Math.max(currentStart, currentEnd);
const widthSec = initialRight - initialLeft;
const setStart = useLocal ? setLocalSelectionStart : setSelectionStart;
const setEnd = useLocal ? setLocalSelectionEnd : setSelectionEnd;
const handleMouseMove = (moveEvent) => {
const deltaX = moveEvent.clientX - startX;
@@ -613,8 +788,8 @@
newLeft = maxDuration - widthSec;
}
setSelectionStart(newLeft);
setSelectionEnd(newRight);
setStart(newLeft);
setEnd(newRight);
};
const handleMouseUp = () => {
@@ -986,7 +1161,7 @@
// ── Mark Selection ──
const handleMarkSelection = () => {
if (selectionStart === null || selectionEnd === null || selectionStats.length === 0) {
if (selLeft === null || selRight === null || selectionStats.length === 0) {
showToast("Vui lòng chọn một khoảng thời gian trên sóng âm trước.", "warning");
return;
}
@@ -997,10 +1172,14 @@
return;
}
// For local selection, force mark on the local-selected track
const markTrackId = selectionMode === 'local' && localSelectionTrackId
? localSelectionTrackId : selectedTrackId;
setTracks(prev => prev.map(t => {
if (t.id !== selectedTrackId) return t;
const snapStart = findZeroCrossing(t.buffer, selectionStats.start);
const snapEnd = findZeroCrossing(t.buffer, selectionStats.end);
if (t.id !== markTrackId) return t;
const snapStart = findZeroCrossing(t.buffer, selLeft);
const snapEnd = findZeroCrossing(t.buffer, selRight);
const newMarkers = [
...t.markers,
@@ -1319,8 +1498,11 @@
>
<div style={{ width: `${timelineWidth}px` }} className="relative flex flex-col h-full">
{/* Ruler */}
<div className="h-8 border-b border-zinc-900 bg-[#242424] sticky top-0 z-30 flex items-center select-none shrink-0">
{/* Ruler - LOOP_MAKER.md VÙNG A: Global Selection hitbox */}
<div ref={rulerRef}
className="h-8 border-b border-zinc-900 bg-[#242424] sticky top-0 z-30 flex items-center select-none shrink-0 cursor-ew-resize"
onMouseDown={handleRulerMouseDown}
>
{Array.from({ length: Math.ceil(maxDuration) }).map((_, i) => {
const sec = i;
const x = sec * zoom;
@@ -1348,7 +1530,12 @@
>
<WaveformLane track={track} zoom={zoom} timelineWidth={timelineWidth}
onSelectRange={handleSelectRange} onPlayheadSet={setCurrentTime}
isSelected={isSelected} onSelectTrack={setSelectedTrackId} markers={track.markers} />
isSelected={isSelected} onSelectTrack={setSelectedTrackId} markers={track.markers}
onTrackLaneMouseDown={handleTrackLaneMouseDown}
selectionMode={selectionMode}
localSelectionTrackId={localSelectionTrackId}
localSelLeft={localSelectionStart !== null && localSelectionEnd !== null ? Math.min(localSelectionStart, localSelectionEnd) : null}
localSelRight={localSelectionStart !== null && localSelectionEnd !== null ? Math.max(localSelectionStart, localSelectionEnd) : null} />
{track.buffer && (
<div className="absolute right-2 top-2 flex items-center gap-1 z-10 opacity-70 hover:opacity-100 transition">
@@ -1548,7 +1735,11 @@
<div className="flex items-center gap-4">
<span>Status: {isPlaying ? 'Playing' : 'Stopped'}</span>
<span className="text-cyan-400 font-semibold uppercase">Track: ID {selectedTrackId}</span>
{selectionMode === 'local' && <span className="text-amber-400 font-semibold uppercase text-[9px]">Local Sel</span>}
{selectionMode === 'global' && <span className="text-purple-400 font-semibold uppercase text-[9px]">Global Sel</span>}
{soloedTrackId && <span className="text-amber-500 font-semibold">Solo: ID {soloedTrackId}</span>}
{isLoopingSelection && selectionMode === 'local' && <span className="text-emerald-400 font-semibold uppercase text-[9px]">Solo Loop</span>}
{isLoopingSelection && selectionMode !== 'local' && <span className="text-cyan-400 font-semibold uppercase text-[9px]">Master Loop</span>}
</div>
<div className="flex items-center gap-3">
<span className="flex items-center gap-1">