feat: cài đặt menu ngữ cảnh cho track nhạc
This commit is contained in:
+144
@@ -0,0 +1,144 @@
|
|||||||
|
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, sơ đồ luồng ASCII và các công thức toán học dạng LaTeX:
|
||||||
|
|
||||||
|
# Đặc Tả Kỹ Thuật: Biên Tập Cục Bộ, Cơ Chế Tab Tạm Thời & Hoàn Tác (Undo/Redo)
|
||||||
|
|
||||||
|
Tài liệu này phân tích chi tiết cơ chế tương tác đồ họa và xử lý tín hiệu âm thanh dựa trên giao diện DAW chuẩn hóa trong hình `image_e076cb.png`. Mục tiêu là cung cấp tài liệu thiết kế hệ thống và giải thuật để port trực tiếp sang ứng dụng Python chạy trên Docker.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Phân Tích Trạng Thái Track Hoạt Động (Active Track State) & Khung Vùng Chọn Cục Bộ
|
||||||
|
|
||||||
|
Dựa trên hình `image_e076cb.png`, hệ thống sử dụng cơ chế *Local Waveform Selection* (Chọn vùng cục bộ trên từng kênh) thay vì phủ bóng toàn bộ các kênh trên dòng thời gian.
|
||||||
|
|
||||||
|
### 1.1. Trạng thái Track Active
|
||||||
|
|
||||||
|
* **Hành động:** Khi người dùng click chuột vào vùng hiển thị của một Track (ví dụ: Track 1), hệ thống sẽ gán trạng thái `ACTIVE` cho track đó.
|
||||||
|
* **Hiển thị hình ảnh:**
|
||||||
|
* Nền của Track active sẽ chuyển sang màu xám sáng (`#2a2a2a` hoặc `#333333`), trong khi các track không active ở trạng thái chờ với màu tối hơn (`#181818`).
|
||||||
|
* Toàn bộ đường viền quanh track được highlight nhẹ bằng một viền sáng mờ.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### 1.2. Khung Chọn Cục Bộ (Local Selection Highlight)
|
||||||
|
|
||||||
|
* **Quy luật hiển thị:** Khung màu sáng (Overlay màu xám bạc trong hình `image_e076cb.png`) chỉ được vẽ đè lên dạng sóng (Waveform) của riêng track đang active, giới hạn trục ngang từ $T_{\text{start}}$ đến $T_{\text{end}}$.
|
||||||
|
* **Ràng buộc đồ họa:** Các track nằm dưới (ví dụ: Track 2) sẽ hoàn toàn không bị phủ bóng xám, dù nằm cùng khoảng thời gian $T_{\text{start}} \rightarrow T_{\text{end}}$.
|
||||||
|
|
||||||
|
> **Khai báo an toàn khi Port sang Python (Tránh Crash):**
|
||||||
|
> * Luôn kiểm tra tính hợp lệ của mốc thời gian: $0 \le T_{\text{start}} < T_{\text{end}} \le T_{\text{max}}$.
|
||||||
|
> * Chặn lỗi vượt quá giới hạn mảng mẫu (*Index Out of Bounds*) khi ánh xạ từ Pixel sang mẫu âm thanh số:
|
||||||
|
>
|
||||||
|
>
|
||||||
|
> $$\text{Sample}_{\text{start}} = \text{clamp}(0, \lfloor T_{\text{start}} \times \text{Sample Rate} \rfloor, \text{Total Samples})$$
|
||||||
|
>
|
||||||
|
>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Quy Trình Biên Tập Trong Tab Tạm Thời (Temporary Edit Tab Workflow)
|
||||||
|
|
||||||
|
Đây là tính năng biên tập không phá hủy (*Non-destructive*) nâng cao, cho phép cô lập phân đoạn âm thanh để xử lý chuyên sâu trước khi gộp lại vào bản phối chính.
|
||||||
|
|
||||||
|
```text
|
||||||
|
[Bản Phối Chính] ──► Chọn đoạn (T_start -> T_end) ──► Nhấn "Edit in Temp Tab"
|
||||||
|
│
|
||||||
|
┌────────────────────────────────────────────────────────────┘
|
||||||
|
▼
|
||||||
|
[Khởi tạo Tab Tạm Thời]
|
||||||
|
├── Trích xuất mảng mẫu phụ (Audio Sub-segment Buffer)
|
||||||
|
├── Hiển thị dạng sóng cô lập (Thời gian chạy từ 0 đến T_duration)
|
||||||
|
├── Người dùng thực hiện các hiệu ứng: Reverse, Gain, Pitch Shift, Fade...
|
||||||
|
└── Nhấn "Áp dụng (Apply)"
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
[Hòa nhập lại Bản Phối]
|
||||||
|
├── Tính toán khớp Zero-crossing tại hai đầu biên ghép nối.
|
||||||
|
├── Áp dụng hiệu ứng mờ biên (Micro-crossfades) để chống tiếng Click/Pop.
|
||||||
|
└── Thay thế mảng mẫu mới vào vị trí cũ và dọn dẹp Tab tạm.
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.1. Trích xuất sang Tab Tạm Thời (Export to Temporary Tab)
|
||||||
|
|
||||||
|
Khi người dùng chọn vùng trên Track Active và nhấn "Edit in Temp Tab", hệ thống sẽ tách đoạn âm thanh này thành một thực thể đệm độc lập (`Sub-segment AudioBuffer`).
|
||||||
|
|
||||||
|
* Một tab mới (Ví dụ: `Tab: sẤit tiá...n` trong hình `image_e076cb.png`) xuất hiện ngay phía trên dòng thời gian.
|
||||||
|
* Trong tab này, trục thời gian của Ruler sẽ được đặt lại (*Reset*) bắt đầu từ `00:00:00.000` cho đến độ dài của đoạn được cắt:
|
||||||
|
|
||||||
|
$$T_{\text{duration}} = T_{\text{end}} - T_{\text{start}}$$
|
||||||
|
|
||||||
|
### 2.2. Hòa nhập lại Track Chính (Apply & Merge Back)
|
||||||
|
|
||||||
|
Khi người dùng hoàn tất chỉnh sửa trên Tab tạm và nhấn *Apply*, hệ thống Python/Docker Backend thực hiện quy trình DSP ghép nối sau để tránh hiện tượng vấp âm (*Click/Pop*):
|
||||||
|
|
||||||
|
1. **Tìm điểm Zero-Crossing lân cận:** Hệ thống tự động dịch nhẹ mốc nối $T_{\text{start}}$ và $T_{\text{end}}$ một vài mẫu ($5 \rightarrow 10$ samples) để đảm bảo biên độ tại điểm ghép nối bằng $0$.
|
||||||
|
2. **Áp dụng Micro-Crossfade:** Tạo một cửa sổ chuyển tiếp cực ngắn ($w = 10\text{ ms}$) giữa file gốc và file sửa đổi tại điểm ráp nối để triệt tiêu hoàn toàn sự thay đổi đột ngột của pha:
|
||||||
|
|
||||||
|
$$\text{Final}_{\text{audio}}(t) = (1 - \alpha(t)) \cdot \text{Original}(t) + \alpha(t) \cdot \text{Edited}(t - T_{\text{start}})$$
|
||||||
|
|
||||||
|
*Trong đó:* $\alpha(t) = \frac{t - T_{\text{start}}}{w}$ với $T_{\text{start}} \le t \le T_{\text{start}} + w$.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Cơ Chế Đồng Bộ Hóa Con Trỏ Phát Nhạc (Playhead Tracking)
|
||||||
|
|
||||||
|
* **Hành vi tương tác:** Khi phát nhạc (*Play*), kim phát nhạc (*Playhead line* màu đỏ) phải di chuyển liên tục, mượt mà dọc theo trục ngang của dạng sóng.
|
||||||
|
* **Thuật toán đồng bộ hóa (Client-Server):**
|
||||||
|
* Tốc độ di chuyển của Playhead dựa trên thời gian thực tế của luồng phát âm thanh (`AudioContext.currentTime` ở Client hoặc đồng hồ xung của card âm thanh phía Server).
|
||||||
|
* Vị trí hoành độ $X$ (Pixel) của con trỏ tại thời điểm $t$ được tính bằng công thức:
|
||||||
|
|
||||||
|
|
||||||
|
$$X(t) = t \times \text{Zoom Level}$$
|
||||||
|
|
||||||
|
|
||||||
|
* **Khi Loop hoạt động:** Khi $t \ge T_{\text{end}}$, luồng âm thanh lập tức chuyển hướng phát về $T_{\text{start}}$, đồng thời biến thời gian hiển thị con trỏ được đặt lại ngay lập tức: $t = T_{\text{start}}$ mà không dừng luồng phần cứng.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Kiến Trúc Hoàn Tác & Làm Lại (Undo / Redo Engine: Ctrl-Z & Ctrl-Y)
|
||||||
|
|
||||||
|
Để đảm bảo hiệu năng tối ưu trên Docker Server (tránh việc lưu đi lưu lại các tệp tin WAV nặng hàng trăm Megabytes vào bộ nhớ), hệ thống sử dụng Kiến trúc Hoàn tác dựa trên Delta (*State Delta-based Undo/Redo*).
|
||||||
|
|
||||||
|
### 4.1. Cấu trúc lưu trữ lịch sử (History Stack Node)
|
||||||
|
|
||||||
|
Mỗi hành động của người dùng (Cắt, ghép, thay đổi volume, fade, chỉnh sửa trong tab tạm) được đóng gói thành một đối tượng `ActionNode`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import time
|
||||||
|
|
||||||
|
class ActionNode:
|
||||||
|
def __init__(self, action_type: str, track_id: str):
|
||||||
|
self.action_type = action_type # 'SPLIT', 'VOLUME_CHANGE', 'TEMP_TAB_EDIT', etc.
|
||||||
|
self.track_id = track_id
|
||||||
|
self.timestamp = time.time()
|
||||||
|
|
||||||
|
# Lưu thông tin delta để khôi phục thay vì lưu cả file nhạc
|
||||||
|
self.before_state = {} # Trạng thái trước khi sửa
|
||||||
|
self.after_state = {} # Trạng thái sau khi sửa
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2. Logic Hoàn tác (Undo - `Ctrl + Z`)
|
||||||
|
|
||||||
|
Khi người dùng nhấn tổ hợp phím `Ctrl + Z`:
|
||||||
|
|
||||||
|
1. Lấy hành động mới nhất từ *Undo Stack*.
|
||||||
|
2. Thực thi hàm nghịch đảo của hành động đó để đưa track về trạng thái `before_state`.
|
||||||
|
3. Đẩy hành động này sang *Redo Stack* để có thể làm lại.
|
||||||
|
4. Vẽ lại dạng sóng trên Canvas tương ứng.
|
||||||
|
|
||||||
|
### 4.3. Logic Làm lại (Redo - `Ctrl + Y`)
|
||||||
|
|
||||||
|
Khi người dùng nhấn tổ hợp phím `Ctrl + Y`:
|
||||||
|
|
||||||
|
1. Lấy hành động mới nhất từ *Redo Stack*.
|
||||||
|
2. Áp dụng trạng thái `after_state` lên track đích.
|
||||||
|
3. Đẩy ngược hành động này về lại *Undo Stack*.
|
||||||
|
4. Cập nhật đồ họa hiển thị.
|
||||||
|
|
||||||
|
### 4.4. Quản lý bộ nhớ tối ưu (Garbage Collection)
|
||||||
|
|
||||||
|
* Giới hạn kích thước tối đa của Stack hoàn tác (Ví dụ: tối đa 30 hành động) để tránh tràn bộ nhớ RAM của Docker Container.
|
||||||
|
* Các đoạn âm thanh bị thay thế bởi thao tác chỉnh sửa sẽ được lưu trữ dưới dạng các tệp nhị phân tạm thời (`.tmp`) trong thư mục `/app/storage/temp/` và tự động dọn dẹp khi phiên làm việc (Session) kết thúc.
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
# Technical Specification: Tabbed Interface, Configurations & Strict Looping Constraints
|
||||||
|
|
||||||
|
This document details the software design specification for the tabbed multi-project structure, top-level application configurations, and low-latency transport loop engine boundaries for *SonicForge Studio*, referencing the professional DAW layout in `image_e0e462.png`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Multi-Tab Architecture: Main vs. Sub (Temporary) Tabs
|
||||||
|
|
||||||
|
As illustrated in `image_e0e462.png` (indicated by the red arrows pointing to the tab bar), the application supports multiple active document spaces running in parallel.
|
||||||
|
|
||||||
|
```text
|
||||||
|
+---------------------------------------------------------------------------------+
|
||||||
|
| File Edit View Insert Track Options Actions Extensions Help |
|
||||||
|
+---------------------------------------------------------------------------------+
|
||||||
|
| [*Main_Session.rpp] | [Sub_Tab_Isolated_Edit] | |
|
||||||
|
+-----------------------------------------+---------------------------------------+
|
||||||
|
| | |
|
||||||
|
| [Main Tab: Multitrack Workspace] | [Sub Tab: Isolated Sample Editor] |
|
||||||
|
| - Multi-channel arrangements | - Destructive audio processing |
|
||||||
|
| - Level mixing & panning | - Focus on selected sub-region |
|
||||||
|
| - Real-time plugin chains | - Apply specialized DSP / AI |
|
||||||
|
| | |
|
||||||
|
+-----------------------------------------+---------------------------------------+
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.1. Main Tab (Multitrack Mixing)
|
||||||
|
|
||||||
|
* **Scope:** Hosts the global arrangement canvas with multiple tracks stacked vertically.
|
||||||
|
* **Function:** Used for complex operations including track leveling, master mixdowns, track synchronization, and timeline-based multi-channel volume automation.
|
||||||
|
|
||||||
|
### 1.2. Sub Tab (Temporary Isolated Clip Editor)
|
||||||
|
|
||||||
|
* **Scope:** A sandbox workspace containing only the isolated audio buffer extracted from a specific track's selection clip.
|
||||||
|
* **Function:** Contains standard sample-level editing tools (trimming, phase inversion, amplification, and precision AI noise reduction).
|
||||||
|
* **Workflow Sync:**
|
||||||
|
* Modifying data inside the Sub Tab operates on a temporary audio buffer.
|
||||||
|
* Clicking *Apply* triggers a non-destructive or destructive overwrite back into the Main Tab's parent track at the exact source offset coordinates.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Top-Level Menu Bar & Application Configurations
|
||||||
|
|
||||||
|
To support both basic user preferences and deep AI/system configurations, a global Menu Bar is placed at the absolute top of the frame (matching the menu path: `File` `Edit` `View` `Insert` `Item` `Track` `Options` `Actions` `Extensions` `Help` in `image_e0e462.png`).
|
||||||
|
|
||||||
|
### 2.1. Configuration Architecture
|
||||||
|
|
||||||
|
These menus map directly to local configurations and server APIs on the Docker backend:
|
||||||
|
|
||||||
|
* **File:** Session operations (*New*, *Open*, *Save Session*) and Offline Audio Mixdown export settings (*Sample Rate*, *Bit-Depth*, *Format*).
|
||||||
|
* **Options -> Audio Device Settings:** Defines client/server hardware routing, sample buffer frame size ($64 \rightarrow 512$ samples) to control playback latency, and audio API endpoints (*ASIO*, *CoreAudio*, *ALSA*).
|
||||||
|
* **Options -> AI Integration Settings:**
|
||||||
|
* *Endpoint Configuration:* Sets API Gateway URLs (OpenAI-compatible server endpoint).
|
||||||
|
* *Authentication:* API keys, model parameters, and target model configurations (e.g., `gpt-4o-mini`, local `ollama` endpoints).
|
||||||
|
|
||||||
|
|
||||||
|
* **Actions -> Admin Dashboard:** Admin-only access panel to manage user accounts, disk quota limits ($S_{\text{limit}}$), active socket connections, and toggle system Feature Flags.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Playhead Tracking & Looping Synchronicities
|
||||||
|
|
||||||
|
The transport engine must manage low-latency coordinate translations to ensure the playhead red line exactly mirrors the hardware audio clocks during loop operations.
|
||||||
|
|
||||||
|
### 3.1. Looping Playhead Movement Logic
|
||||||
|
|
||||||
|
* **Seamless Loop Synchronization:** When loop play is triggered, the playhead coordinates on the visual timeline must instantly align with the active audio buffers.
|
||||||
|
* **Immediate Reset on Cycle:** When the current audio timestamp $t$ reaches the loop end point $T_{\text{end}}$, the playhead must immediately reset to the loop start point $T_{\text{start}}$ without lagging or disappearing from the viewport.
|
||||||
|
* **Visual Refresh Coordination:** The browser animation loop (`requestAnimationFrame`) or Python GUI timer must query the audio hardware clock directly, avoiding UI-driven clock drift:
|
||||||
|
|
||||||
|
$$t_{\text{playhead}} = T_{\text{start}} + \left( (t_{\text{system}} - t_{\text{trigger}}) \pmod{T_{\text{end}} - T_{\text{start}}} \right)$$
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Strict Playhead Looping Constraints & Escape Mechanism
|
||||||
|
|
||||||
|
```text
|
||||||
|
Strict Loop State Locked (Spacebar toggles within boundary)
|
||||||
|
+-------------------------------------------------+
|
||||||
|
| |
|
||||||
|
▼ | (Loop Repeat)
|
||||||
|
[ T_start ] ==============> [ Playhead (t) ] ======> [ T_end ]
|
||||||
|
│
|
||||||
|
│ (User clicks outside selection region)
|
||||||
|
▼
|
||||||
|
[ Escape Loop Triggered ]
|
||||||
|
│
|
||||||
|
▼ (Press SPACEBAR)
|
||||||
|
[ Linear Playback Active ] ===> Playhead continues past T_end indefinitely
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.1. Strict Boundary Constraint (Active Loop)
|
||||||
|
|
||||||
|
When a time selection $[T_{\text{start}}, T_{\text{end}}]$ is active and looping is turned on, the playhead is locked to the interval:
|
||||||
|
|
||||||
|
$$t \in [T_{\text{start}}, T_{\text{end}}]$$
|
||||||
|
|
||||||
|
Under no circumstances can the playhead drift past $T_{\text{end}}$. If the audio thread finishes rendering the buffer slice corresponding to $T_{\text{end}}$, it must seamlessly jump back to $T_{\text{start}}$.
|
||||||
|
|
||||||
|
### 4.2. Escape Loop Mechanism
|
||||||
|
|
||||||
|
To leave the loop and return to continuous, non-repeating playback, the user must perform the following actions:
|
||||||
|
|
||||||
|
1. **Clear Selection Focus:** The user clicks outside the selection box on an empty area of the timeline ruler or track lane.
|
||||||
|
2. **Deregister Selection Boundaries:** The variables $T_{\text{start}}$ and $T_{\text{end}}$ are cleared (set to `null` or $0$ and $T_{\text{max}}$ respectively).
|
||||||
|
3. **Resume Linear Playback:** Pressing the `Spacebar` key triggers the transport to play continuously through and past the old boundary marker.
|
||||||
|
|
||||||
|
### 4.3. Keyboard Mapping Matrix (Python GUI Translation)
|
||||||
|
|
||||||
|
```python
|
||||||
|
# PyQt6 / PySide6 Key Event Hook Simulation
|
||||||
|
|
||||||
|
def keyPressEvent(self, event):
|
||||||
|
if event.key() == Qt.Key.Key_Space:
|
||||||
|
if self.transport.is_playing:
|
||||||
|
self.transport.pause()
|
||||||
|
else:
|
||||||
|
# If selection was cleared, it continues linear playback past T_end
|
||||||
|
if self.session.selection_cleared:
|
||||||
|
self.transport.play_linear(from_time=self.playhead.current_time)
|
||||||
|
else:
|
||||||
|
self.transport.play_looped(
|
||||||
|
start=self.session.selection_start,
|
||||||
|
end=self.session.selection_end
|
||||||
|
)
|
||||||
|
|
||||||
|
```
|
||||||
+758
-40
@@ -152,6 +152,7 @@
|
|||||||
localSelLeft,
|
localSelLeft,
|
||||||
localSelRight,
|
localSelRight,
|
||||||
onTrackLaneMouseDown,
|
onTrackLaneMouseDown,
|
||||||
|
onContextMenu,
|
||||||
}) => {
|
}) => {
|
||||||
const canvasRef = useRef(null);
|
const canvasRef = useRef(null);
|
||||||
|
|
||||||
@@ -258,6 +259,8 @@
|
|||||||
ref={canvasRef}
|
ref={canvasRef}
|
||||||
className="w-full h-full cursor-crosshair"
|
className="w-full h-full cursor-crosshair"
|
||||||
onMouseDown={(e) => {
|
onMouseDown={(e) => {
|
||||||
|
// Ignore right-click for local selection drag (context menu handles it)
|
||||||
|
if (e.button === 2) return;
|
||||||
const wrapper = canvasRef.current?.parentElement?.parentElement;
|
const wrapper = canvasRef.current?.parentElement?.parentElement;
|
||||||
const scrollLeft = wrapper ? wrapper.scrollLeft : 0;
|
const scrollLeft = wrapper ? wrapper.scrollLeft : 0;
|
||||||
const rect = canvasRef.current.getBoundingClientRect();
|
const rect = canvasRef.current.getBoundingClientRect();
|
||||||
@@ -270,10 +273,59 @@
|
|||||||
}
|
}
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
}}
|
}}
|
||||||
|
onContextMenu={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
onSelectTrack(track.id);
|
||||||
|
if (onContextMenu) onContextMenu(e, track.id);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ── Sub-Tab Waveform Component (LOOP_EDITOR_2.md §1.2) ──
|
||||||
|
const SubTabWaveform = ({ buffer }) => {
|
||||||
|
const canvasRef = useRef(null);
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas || !buffer) return;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
canvas.width = rect.width * dpr;
|
||||||
|
canvas.height = rect.height * dpr;
|
||||||
|
ctx.scale(dpr, dpr);
|
||||||
|
|
||||||
|
const w = rect.width;
|
||||||
|
const h = rect.height;
|
||||||
|
ctx.fillStyle = '#181818';
|
||||||
|
ctx.fillRect(0, 0, w, h);
|
||||||
|
|
||||||
|
const data = buffer.getChannelData(0);
|
||||||
|
const len = data.length;
|
||||||
|
if (len === 0) return;
|
||||||
|
|
||||||
|
ctx.strokeStyle = '#6ee7b7';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
for (let px = 0; px < w; px++) {
|
||||||
|
const start = Math.floor((px / w) * len);
|
||||||
|
const end = Math.floor(((px + 1) / w) * len);
|
||||||
|
let maxVal = 0;
|
||||||
|
for (let i = start; i < end && i < len; i++) {
|
||||||
|
const abs = Math.abs(data[i]);
|
||||||
|
if (abs > maxVal) maxVal = abs;
|
||||||
|
}
|
||||||
|
const mid = h / 2;
|
||||||
|
const peakHeight = maxVal * (h * 0.4);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(px, mid - peakHeight);
|
||||||
|
ctx.lineTo(px, mid + peakHeight);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
}, [buffer]);
|
||||||
|
return <canvas ref={canvasRef} className="w-full h-full rounded border border-zinc-800"></canvas>;
|
||||||
|
};
|
||||||
|
|
||||||
const App = () => {
|
const App = () => {
|
||||||
// ── State Definitions ──
|
// ── State Definitions ──
|
||||||
const [tracks, setTracks] = useState([
|
const [tracks, setTracks] = useState([
|
||||||
@@ -311,6 +363,91 @@
|
|||||||
format: 'wav',
|
format: 'wav',
|
||||||
});
|
});
|
||||||
const [serverStatus, setServerStatus] = useState('checking...');
|
const [serverStatus, setServerStatus] = useState('checking...');
|
||||||
|
const [menuOpen, setMenuOpen] = useState(null);
|
||||||
|
|
||||||
|
// ── Context Menu & Clipboard ──
|
||||||
|
const [contextMenu, setContextMenu] = useState(null); // { x, y, trackId }
|
||||||
|
const clipboardRef = useRef(null); // { buffer, name, volume, color } for copy/paste
|
||||||
|
|
||||||
|
// ── Undo/Redo Engine (LOOP_EDITOR.md §4) ──
|
||||||
|
const [undoStack, setUndoStack] = useState([]);
|
||||||
|
const [redoStack, setRedoStack] = useState([]);
|
||||||
|
const MAX_UNDO = 30;
|
||||||
|
|
||||||
|
const pushAction = (actionType, trackId, beforeState, afterState) => {
|
||||||
|
const node = {
|
||||||
|
action_type: actionType,
|
||||||
|
track_id: trackId,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
before_state: beforeState,
|
||||||
|
after_state: afterState,
|
||||||
|
};
|
||||||
|
setUndoStack(prev => {
|
||||||
|
const next = [...prev, node];
|
||||||
|
if (next.length > MAX_UNDO) next.shift();
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setRedoStack([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUndo = () => {
|
||||||
|
if (undoStack.length === 0) return;
|
||||||
|
const last = undoStack[undoStack.length - 1];
|
||||||
|
setUndoStack(prev => prev.slice(0, -1));
|
||||||
|
setRedoStack(prev => [...prev, last]);
|
||||||
|
applyTrackState(last.track_id, last.before_state);
|
||||||
|
showToast(`Undo: ${last.action_type}`, 'info');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRedo = () => {
|
||||||
|
if (redoStack.length === 0) return;
|
||||||
|
const last = redoStack[redoStack.length - 1];
|
||||||
|
setRedoStack(prev => prev.slice(0, -1));
|
||||||
|
setUndoStack(prev => [...prev, last]);
|
||||||
|
applyTrackState(last.track_id, last.after_state);
|
||||||
|
showToast(`Redo: ${last.action_type}`, 'info');
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyTrackState = (trackId, state) => {
|
||||||
|
setTracks(prev => prev.map(t => {
|
||||||
|
if (t.id !== trackId) return t;
|
||||||
|
return { ...t, ...state };
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const captureTrackSnapshot = (trackId) => {
|
||||||
|
const track = tracks.find(t => t.id === trackId);
|
||||||
|
if (!track) return null;
|
||||||
|
return {
|
||||||
|
volume: track.volume,
|
||||||
|
muted: track.muted,
|
||||||
|
name: track.name,
|
||||||
|
markers: JSON.parse(JSON.stringify(track.markers || [])),
|
||||||
|
// buffer is captured via reference copy for undo; we store a clone for redo
|
||||||
|
buffer: track.buffer,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Tab System (LOOP_EDITOR_2.md §1) ──
|
||||||
|
const [activeTab, setActiveTab] = useState('main');
|
||||||
|
const [subTabs, setSubTabs] = useState([]); // [{id, label, trackId, startTime, endTime, buffer}, ...]
|
||||||
|
const [selectionCleared, setSelectionCleared] = useState(false); // LOOP_EDITOR_2.md §4.2
|
||||||
|
|
||||||
|
// ── Temp Edit Tab (LOOP_EDITOR_2.md §1.2 Sub Tab) ──
|
||||||
|
const [tempTabActive, setTempTabActive] = useState(false);
|
||||||
|
const [tempTabBuffer, setTempTabBuffer] = useState(null);
|
||||||
|
const [tempTabTrackId, setTempTabTrackId] = useState(null);
|
||||||
|
const [tempTabOrigStart, setTempTabOrigStart] = useState(0);
|
||||||
|
const [tempTabOrigEnd, setTempTabOrigEnd] = useState(0);
|
||||||
|
const tempTabCanvasRef = useRef(null);
|
||||||
|
|
||||||
|
// Effect parameters for temp tab
|
||||||
|
const [tempTabEffects, setTempTabEffects] = useState({
|
||||||
|
reverse: false,
|
||||||
|
gainDb: 0,
|
||||||
|
fadeInMs: 0,
|
||||||
|
fadeOutMs: 0,
|
||||||
|
});
|
||||||
|
|
||||||
const timelineWrapperRef = useRef(null);
|
const timelineWrapperRef = useRef(null);
|
||||||
const rulerRef = useRef(null);
|
const rulerRef = useRef(null);
|
||||||
@@ -321,6 +458,360 @@
|
|||||||
const toastTimeoutRef = useRef(null);
|
const toastTimeoutRef = useRef(null);
|
||||||
const rulerDragStartRef = useRef(null);
|
const rulerDragStartRef = useRef(null);
|
||||||
const isDraggingRulerRef = useRef(false);
|
const isDraggingRulerRef = useRef(false);
|
||||||
|
const handlePlayPauseRef = useRef(null);
|
||||||
|
|
||||||
|
// ── Keyboard Shortcuts (LOOP_EDITOR.md §4, LOOP_EDITOR_2.md §4.3) ──
|
||||||
|
const handleUndoRef = useRef(handleUndo);
|
||||||
|
const handleRedoRef = useRef(handleRedo);
|
||||||
|
handleUndoRef.current = handleUndo;
|
||||||
|
handleRedoRef.current = handleRedo;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e) => {
|
||||||
|
// Space key: toggle play/pause (LOOP_EDITOR_2.md §4.3)
|
||||||
|
if (e.key === ' ' || e.code === 'Space') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (handlePlayPauseRef.current) handlePlayPauseRef.current();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Space key: toggle play/pause (LOOP_EDITOR_2.md §4.3)
|
||||||
|
if (e.key === ' ' || e.code === 'Space') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (handlePlayPauseRef.current) handlePlayPauseRef.current();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.ctrlKey || e.metaKey) {
|
||||||
|
if (e.key === 'z' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleUndoRef.current();
|
||||||
|
} else if (e.key === 'y' || (e.key === 'z' && e.shiftKey)) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleRedoRef.current();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, []); // empty deps: refs avoid stale closure
|
||||||
|
|
||||||
|
// ── Temp Tab: draw isolated waveform ──
|
||||||
|
useEffect(() => {
|
||||||
|
if (!tempTabActive || !tempTabBuffer || !tempTabCanvasRef.current) return;
|
||||||
|
const canvas = tempTabCanvasRef.current;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
canvas.width = rect.width * dpr;
|
||||||
|
canvas.height = rect.height * dpr;
|
||||||
|
ctx.scale(dpr, dpr);
|
||||||
|
|
||||||
|
const w = rect.width;
|
||||||
|
const h = rect.height;
|
||||||
|
ctx.fillStyle = '#181818';
|
||||||
|
ctx.fillRect(0, 0, w, h);
|
||||||
|
|
||||||
|
const data = tempTabBuffer.getChannelData(0);
|
||||||
|
const sr = tempTabBuffer.sampleRate;
|
||||||
|
const totalSamples = data.length;
|
||||||
|
if (totalSamples === 0) return;
|
||||||
|
|
||||||
|
ctx.strokeStyle = '#6ee7b7';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
|
||||||
|
for (let px = 0; px < w; px++) {
|
||||||
|
const startSample = Math.floor((px / w) * totalSamples);
|
||||||
|
const endSample = Math.floor(((px + 1) / w) * totalSamples);
|
||||||
|
let maxVal = 0;
|
||||||
|
for (let i = startSample; i < endSample && i < totalSamples; i++) {
|
||||||
|
const abs = Math.abs(data[i]);
|
||||||
|
if (abs > maxVal) maxVal = abs;
|
||||||
|
}
|
||||||
|
const mid = h / 2;
|
||||||
|
const peakHeight = maxVal * (h * 0.4);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(px, mid - peakHeight);
|
||||||
|
ctx.lineTo(px, mid + peakHeight);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
}, [tempTabActive, tempTabBuffer]);
|
||||||
|
|
||||||
|
// ── Sub Tab: open as new tab instead of modal (LOOP_EDITOR_2.md §1) ──
|
||||||
|
const openTempTab = () => {
|
||||||
|
const useLocal = selectionMode === 'local' && localSelectionTrackId;
|
||||||
|
const trackId = useLocal ? localSelectionTrackId : selectedTrackId;
|
||||||
|
const t = tracks.find(x => x.id === trackId);
|
||||||
|
if (!t || !t.buffer) {
|
||||||
|
showToast('Vui lòng chọn track có dữ liệu âm thanh.', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (selLeft === null || selRight === null || selRight <= selLeft) {
|
||||||
|
showToast('Vui lòng chọn một khoảng thời gian trên sóng âm.', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const sr = t.buffer.sampleRate;
|
||||||
|
const startSample = Math.max(0, Math.floor(selLeft * sr));
|
||||||
|
const endSample = Math.min(t.buffer.length, Math.floor(selRight * sr));
|
||||||
|
const len = endSample - startSample;
|
||||||
|
if (len < 100) {
|
||||||
|
showToast('Khoảng chọn quá ngắn.', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ctx = getAudioContext();
|
||||||
|
const subBuffer = ctx.createBuffer(1, len, sr);
|
||||||
|
subBuffer.copyToChannel(t.buffer.getChannelData(0).subarray(startSample, endSample), 0);
|
||||||
|
|
||||||
|
const tabId = 'subtab_' + Date.now();
|
||||||
|
const tabLabel = `Edit_${t.name.replace('.wav','').slice(0,10)}_${selLeft.toFixed(1)}s`;
|
||||||
|
|
||||||
|
setSubTabs(prev => [...prev, {
|
||||||
|
id: tabId,
|
||||||
|
label: tabLabel,
|
||||||
|
trackId: trackId,
|
||||||
|
startTime: selLeft,
|
||||||
|
endTime: selRight,
|
||||||
|
buffer: subBuffer,
|
||||||
|
effects: { reverse: false, gainDb: 0, fadeInMs: 0, fadeOutMs: 0 },
|
||||||
|
}]);
|
||||||
|
setActiveTab(tabId);
|
||||||
|
showToast(`Đã tạo Sub Tab: ${tabLabel}. Chỉnh sửa và Apply để gộp lại.`, 'info');
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Sub Tab: apply effects & merge back (LOOP_EDITOR_2.md §1.2) ──
|
||||||
|
const applySubTab = (tabId) => {
|
||||||
|
const subTab = subTabs.find(s => s.id === tabId);
|
||||||
|
if (!subTab || !subTab.buffer) return;
|
||||||
|
const track = tracks.find(t => t.id === subTab.trackId);
|
||||||
|
if (!track || !track.buffer) return;
|
||||||
|
|
||||||
|
const beforeSnap = captureTrackSnapshot(subTab.trackId);
|
||||||
|
|
||||||
|
// Clone buffer and apply effects
|
||||||
|
const ctx = getAudioContext();
|
||||||
|
const eff = subTab.buffer.getChannelData(0);
|
||||||
|
const edBuffer = ctx.createBuffer(1, eff.length, subTab.buffer.sampleRate);
|
||||||
|
const edData = edBuffer.getChannelData(0);
|
||||||
|
edData.set(eff);
|
||||||
|
|
||||||
|
// Apply effects inline
|
||||||
|
const fx = subTab.effects || {};
|
||||||
|
// Reverse
|
||||||
|
if (fx.reverse) {
|
||||||
|
const reversed = new Float32Array(edData);
|
||||||
|
for (let i = 0; i < edData.length; i++) reversed[i] = edData[edData.length - 1 - i];
|
||||||
|
edBuffer.copyToChannel(reversed, 0);
|
||||||
|
}
|
||||||
|
// Gain
|
||||||
|
if (fx.gainDb !== 0) {
|
||||||
|
const gain = Math.pow(10, fx.gainDb / 20);
|
||||||
|
for (let i = 0; i < edData.length; i++) edData[i] = Math.max(-1, Math.min(1, edData[i] * gain));
|
||||||
|
}
|
||||||
|
// Fade in
|
||||||
|
if (fx.fadeInMs > 0) {
|
||||||
|
const sr = edBuffer.sampleRate;
|
||||||
|
const fadeSamples = Math.min(edData.length, Math.floor(fx.fadeInMs / 1000 * sr));
|
||||||
|
for (let i = 0; i < fadeSamples; i++) edData[i] = edData[i] * (i / fadeSamples);
|
||||||
|
}
|
||||||
|
// Fade out
|
||||||
|
if (fx.fadeOutMs > 0) {
|
||||||
|
const sr = edBuffer.sampleRate;
|
||||||
|
const fadeSamples = Math.min(edData.length, Math.floor(fx.fadeOutMs / 1000 * sr));
|
||||||
|
for (let i = edData.length - fadeSamples; i < edData.length; i++) {
|
||||||
|
edData[i] = edData[i] * ((edData.length - 1 - i) / fadeSamples);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Crossfade merge into original track (§2.2)
|
||||||
|
const sr = track.buffer.sampleRate;
|
||||||
|
const origData = track.buffer.getChannelData(0);
|
||||||
|
const startSample = Math.floor(subTab.startTime * sr);
|
||||||
|
const endSample = Math.floor(subTab.endTime * sr);
|
||||||
|
const crossfadeLen = Math.min(100, Math.floor(0.01 * sr)); // 10ms
|
||||||
|
|
||||||
|
const mergedBuffer = ctx.createBuffer(1, track.buffer.length, sr);
|
||||||
|
const mergedData = mergedBuffer.getChannelData(0);
|
||||||
|
for (let i = 0; i < startSample; i++) mergedData[i] = origData[i];
|
||||||
|
for (let i = endSample; i < track.buffer.length; i++) mergedData[i] = origData[i];
|
||||||
|
|
||||||
|
for (let i = 0; i < edData.length; i++) {
|
||||||
|
const globalIdx = startSample + i;
|
||||||
|
let val = edData[i];
|
||||||
|
if (i < crossfadeLen) {
|
||||||
|
const alpha = i / crossfadeLen;
|
||||||
|
val = (1 - alpha) * (origData[globalIdx] || 0) + alpha * edData[i];
|
||||||
|
} else if (i > edData.length - crossfadeLen) {
|
||||||
|
const distFromEnd = edData.length - 1 - i;
|
||||||
|
const alpha = distFromEnd / crossfadeLen;
|
||||||
|
const origEndIdx = endSample - (edData.length - i);
|
||||||
|
val = alpha * (origEndIdx >= 0 ? origData[origEndIdx] : 0) + (1 - alpha) * edData[i];
|
||||||
|
}
|
||||||
|
mergedData[globalIdx] = val;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTracks(prev => prev.map(t => {
|
||||||
|
if (t.id !== subTab.trackId) return t;
|
||||||
|
return { ...t, buffer: mergedBuffer, name: t.name + ' (edited)' };
|
||||||
|
}));
|
||||||
|
|
||||||
|
const afterSnap = captureTrackSnapshot(subTab.trackId);
|
||||||
|
pushAction('EDIT_TAB', subTab.trackId, beforeSnap, afterSnap);
|
||||||
|
|
||||||
|
closeSubTab(tabId);
|
||||||
|
showToast('Đã áp dụng chỉnh sửa vào track chính với crossfade.', 'success');
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeSubTab = (tabId) => {
|
||||||
|
setSubTabs(prev => prev.filter(s => s.id !== tabId));
|
||||||
|
if (activeTab === tabId) setActiveTab('main');
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateSubTabEffects = (tabId, effects) => {
|
||||||
|
setSubTabs(prev => prev.map(s => s.id === tabId ? { ...s, effects: { ...s.effects, ...effects } } : s));
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Context Menu Handlers ──
|
||||||
|
const handleContextMenu = (e, trackId) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setContextMenu({ x: e.clientX, y: e.clientY, trackId });
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeContextMenu = () => setContextMenu(null);
|
||||||
|
|
||||||
|
// Close context menu on any click outside
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = () => { if (contextMenu) closeContextMenu(); };
|
||||||
|
if (contextMenu) {
|
||||||
|
window.addEventListener('click', handler);
|
||||||
|
return () => window.removeEventListener('click', handler);
|
||||||
|
}
|
||||||
|
}, [contextMenu]);
|
||||||
|
|
||||||
|
const contextMenuEdit = () => {
|
||||||
|
const track = tracks.find(t => t.id === contextMenu.trackId);
|
||||||
|
if (track) setSelectedTrackId(contextMenu.trackId);
|
||||||
|
closeContextMenu();
|
||||||
|
openTempTab();
|
||||||
|
};
|
||||||
|
|
||||||
|
const contextMenuSplit = () => {
|
||||||
|
closeContextMenu();
|
||||||
|
handleSplitTrack(contextMenu.trackId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const contextMenuDelete = () => {
|
||||||
|
const tid = contextMenu.trackId;
|
||||||
|
const beforeSnap = captureTrackSnapshot(tid);
|
||||||
|
setTracks(prev => prev.filter(t => t.id !== tid));
|
||||||
|
const afterSnap = captureTrackSnapshot(tid);
|
||||||
|
pushAction('DELETE', tid, beforeSnap, afterSnap);
|
||||||
|
if (selectedTrackId === tid) {
|
||||||
|
setSelectedTrackId(tracks.filter(t => t.id !== tid)[0]?.id || '1');
|
||||||
|
}
|
||||||
|
closeContextMenu();
|
||||||
|
showToast('Đã xoá track.', 'info');
|
||||||
|
};
|
||||||
|
|
||||||
|
const contextMenuCopy = () => {
|
||||||
|
const track = tracks.find(t => t.id === contextMenu.trackId);
|
||||||
|
if (!track) return;
|
||||||
|
clipboardRef.current = {
|
||||||
|
buffer: track.buffer,
|
||||||
|
name: track.name,
|
||||||
|
volume: track.volume,
|
||||||
|
color: track.color,
|
||||||
|
};
|
||||||
|
closeContextMenu();
|
||||||
|
showToast('Đã sao chép track vào clipboard.', 'info');
|
||||||
|
};
|
||||||
|
|
||||||
|
const contextMenuCut = () => {
|
||||||
|
contextMenuCopy();
|
||||||
|
contextMenuDelete();
|
||||||
|
};
|
||||||
|
|
||||||
|
const contextMenuPaste = () => {
|
||||||
|
if (!clipboardRef.current) {
|
||||||
|
showToast('Clipboard trống.', 'warning');
|
||||||
|
closeContextMenu();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { buffer, name, volume, color } = clipboardRef.current;
|
||||||
|
if (!buffer) {
|
||||||
|
showToast('Clipboard không có dữ liệu âm thanh.', 'warning');
|
||||||
|
closeContextMenu();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Create a new buffer copy
|
||||||
|
const ctx = getAudioContext();
|
||||||
|
const newBuffer = ctx.createBuffer(1, buffer.length, buffer.sampleRate);
|
||||||
|
newBuffer.copyToChannel(buffer.getChannelData(0), 0);
|
||||||
|
const colors = ['#0f766e', '#1d4ed8', '#701a75', '#a21caf', '#b45309'];
|
||||||
|
const newId = 'track_pasted_' + Date.now();
|
||||||
|
setTracks(prev => [...prev, {
|
||||||
|
id: newId,
|
||||||
|
name: `Pasted_${name || 'track'}`,
|
||||||
|
buffer: newBuffer,
|
||||||
|
volume: volume || 0.8,
|
||||||
|
muted: false,
|
||||||
|
solo: false,
|
||||||
|
color: color || colors[prev.length % colors.length],
|
||||||
|
markers: [],
|
||||||
|
serverFileId: null,
|
||||||
|
}]);
|
||||||
|
setSelectedTrackId(newId);
|
||||||
|
closeContextMenu();
|
||||||
|
showToast('Đã dán track từ clipboard.', 'success');
|
||||||
|
};
|
||||||
|
|
||||||
|
const contextMenuMerge = () => {
|
||||||
|
const activeTracks = tracks.filter(t => t.buffer && !t.muted);
|
||||||
|
if (activeTracks.length < 2) {
|
||||||
|
showToast('Cần ít nhất 2 track có dữ liệu để merge.', 'warning');
|
||||||
|
closeContextMenu();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ctx = getAudioContext();
|
||||||
|
const maxDur = Math.max(...activeTracks.map(t => t.buffer.duration));
|
||||||
|
const sr = activeTracks[0].buffer.sampleRate;
|
||||||
|
const merged = ctx.createBuffer(1, Math.ceil(maxDur * sr), sr);
|
||||||
|
const mergedData = merged.getChannelData(0);
|
||||||
|
activeTracks.forEach(t => {
|
||||||
|
const data = t.buffer.getChannelData(0);
|
||||||
|
for (let i = 0; i < data.length; i++) {
|
||||||
|
mergedData[i] += data[i] * t.volume;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Normalize
|
||||||
|
let maxPeak = 0;
|
||||||
|
for (let i = 0; i < mergedData.length; i++) {
|
||||||
|
const abs = Math.abs(mergedData[i]);
|
||||||
|
if (abs > maxPeak) maxPeak = abs;
|
||||||
|
}
|
||||||
|
if (maxPeak > 1.0) {
|
||||||
|
for (let i = 0; i < mergedData.length; i++) mergedData[i] /= maxPeak;
|
||||||
|
}
|
||||||
|
const colors = ['#0f766e', '#1d4ed8', '#701a75', '#a21caf', '#b45309'];
|
||||||
|
const newId = 'track_merged_' + Date.now();
|
||||||
|
const names = activeTracks.map(t => t.name).join('+').slice(0, 30);
|
||||||
|
setTracks(prev => {
|
||||||
|
const keep = prev.filter(t => !t.buffer || t.muted || t.id === contextMenu.trackId);
|
||||||
|
return [...keep, {
|
||||||
|
id: newId,
|
||||||
|
name: `Merged_${names}.wav`,
|
||||||
|
buffer: merged,
|
||||||
|
volume: 0.8,
|
||||||
|
muted: false,
|
||||||
|
solo: false,
|
||||||
|
color: colors[prev.length % colors.length],
|
||||||
|
markers: [],
|
||||||
|
serverFileId: null,
|
||||||
|
}];
|
||||||
|
});
|
||||||
|
setSelectedTrackId(newId);
|
||||||
|
closeContextMenu();
|
||||||
|
showToast(`Đã merge ${activeTracks.length} tracks.`, 'success');
|
||||||
|
};
|
||||||
|
|
||||||
// ── Server Health Check ──
|
// ── Server Health Check ──
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -460,8 +951,9 @@
|
|||||||
const elapsed = context.currentTime - startAudioTimeRef.current;
|
const elapsed = context.currentTime - startAudioTimeRef.current;
|
||||||
const updatedTime = startOffsetTimeRef.current + elapsed;
|
const updatedTime = startOffsetTimeRef.current + elapsed;
|
||||||
|
|
||||||
// Selection Loop - LOOP_MAKER.md spec: local vs global behavior
|
// Selection Loop - LOOP_MAKER.md + LOOP_EDITOR_2.md §4.2
|
||||||
if (isLoopingSelection && selLeft !== null && selRight !== null) {
|
// If selection cleared by user, play linearly (don't loop)
|
||||||
|
if (!selectionCleared && isLoopingSelection && selLeft !== null && selRight !== null) {
|
||||||
if (selRight > selLeft && updatedTime >= selRight) {
|
if (selRight > selLeft && updatedTime >= selRight) {
|
||||||
if (selectionMode === 'local') {
|
if (selectionMode === 'local') {
|
||||||
// Local Solo Loop: only restart the selected track
|
// Local Solo Loop: only restart the selected track
|
||||||
@@ -470,6 +962,7 @@
|
|||||||
startAudioTimeRef.current = context.currentTime;
|
startAudioTimeRef.current = context.currentTime;
|
||||||
startLocalTrackPlayback(localSelectionTrackId, selLeft);
|
startLocalTrackPlayback(localSelectionTrackId, selLeft);
|
||||||
setCurrentTime(selLeft);
|
setCurrentTime(selLeft);
|
||||||
|
setIsPlaying(true);
|
||||||
} else {
|
} else {
|
||||||
// Global Master Loop: restart all tracks
|
// Global Master Loop: restart all tracks
|
||||||
stopAllPlayback();
|
stopAllPlayback();
|
||||||
@@ -477,6 +970,7 @@
|
|||||||
startAudioTimeRef.current = context.currentTime;
|
startAudioTimeRef.current = context.currentTime;
|
||||||
startTrackPlayback(selLeft);
|
startTrackPlayback(selLeft);
|
||||||
setCurrentTime(selLeft);
|
setCurrentTime(selLeft);
|
||||||
|
setIsPlaying(true);
|
||||||
}
|
}
|
||||||
animationFrameIdRef.current = requestAnimationFrame(updatePlayhead);
|
animationFrameIdRef.current = requestAnimationFrame(updatePlayhead);
|
||||||
return;
|
return;
|
||||||
@@ -500,7 +994,7 @@
|
|||||||
cancelAnimationFrame(animationFrameIdRef.current);
|
cancelAnimationFrame(animationFrameIdRef.current);
|
||||||
}
|
}
|
||||||
return () => cancelAnimationFrame(animationFrameIdRef.current);
|
return () => cancelAnimationFrame(animationFrameIdRef.current);
|
||||||
}, [isPlaying, isLoopingSelection, selLeft, selRight, selectionMode, localSelectionTrackId]);
|
}, [isPlaying, isLoopingSelection, selLeft, selRight, selectionMode, localSelectionTrackId, selectionCleared]);
|
||||||
|
|
||||||
// ── Playback ──
|
// ── Playback ──
|
||||||
const startTrackPlayback = (offsetTime) => {
|
const startTrackPlayback = (offsetTime) => {
|
||||||
@@ -564,6 +1058,7 @@
|
|||||||
setIsPlaying(true);
|
setIsPlaying(true);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
handlePlayPauseRef.current = handlePlayPause;
|
||||||
|
|
||||||
const handlePause = () => {
|
const handlePause = () => {
|
||||||
if (isPlaying) stopAllPlayback();
|
if (isPlaying) stopAllPlayback();
|
||||||
@@ -691,6 +1186,8 @@
|
|||||||
} else {
|
} else {
|
||||||
setSelectionEnd(cleanEnd);
|
setSelectionEnd(cleanEnd);
|
||||||
}
|
}
|
||||||
|
// LOOP_EDITOR_2.md §4.2: new selection = enable looping
|
||||||
|
setSelectionCleared(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSelectionInputChange = (field, val) => {
|
const handleSelectionInputChange = (field, val) => {
|
||||||
@@ -825,11 +1322,35 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const toggleTrackMute = (trackId) => {
|
const toggleTrackMute = (trackId) => {
|
||||||
|
const beforeSnap = captureTrackSnapshot(trackId);
|
||||||
setTracks(prev => prev.map(t => t.id === trackId ? { ...t, muted: !t.muted } : t));
|
setTracks(prev => prev.map(t => t.id === trackId ? { ...t, muted: !t.muted } : t));
|
||||||
|
setUndoStack(prev => {
|
||||||
|
const next = [...prev, {
|
||||||
|
action_type: 'MUTE',
|
||||||
|
track_id: trackId,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
before_state: beforeSnap,
|
||||||
|
after_state: captureTrackSnapshot(trackId),
|
||||||
|
}];
|
||||||
|
if (next.length > MAX_UNDO) next.shift();
|
||||||
|
return next;
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateTrackVolume = (trackId, val) => {
|
const updateTrackVolume = (trackId, val) => {
|
||||||
|
const beforeSnap = captureTrackSnapshot(trackId);
|
||||||
setTracks(prev => prev.map(t => t.id === trackId ? { ...t, volume: val } : t));
|
setTracks(prev => prev.map(t => t.id === trackId ? { ...t, volume: val } : t));
|
||||||
|
setUndoStack(prev => {
|
||||||
|
const next = [...prev, {
|
||||||
|
action_type: 'VOLUME_CHANGE',
|
||||||
|
track_id: trackId,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
before_state: beforeSnap,
|
||||||
|
after_state: captureTrackSnapshot(trackId),
|
||||||
|
}];
|
||||||
|
if (next.length > MAX_UNDO) next.shift();
|
||||||
|
return next;
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Load File on Track (with server upload) ──
|
// ── Load File on Track (with server upload) ──
|
||||||
@@ -1320,43 +1841,122 @@
|
|||||||
return (
|
return (
|
||||||
<div className="h-full w-full flex flex-col bg-[#1e1e1e]">
|
<div className="h-full w-full flex flex-col bg-[#1e1e1e]">
|
||||||
{/* ── Header ── */}
|
{/* ── Header ── */}
|
||||||
<header className="h-11 bg-[#2e2e2e] border-b border-[#181818] flex items-center justify-between px-4 shrink-0 select-none">
|
{/* ── Menu Bar ── */}
|
||||||
<div className="flex items-center gap-3">
|
<header className="h-9 bg-[#2e2e2e] border-b border-[#181818] flex items-center px-1 shrink-0 select-none">
|
||||||
<h1 className="text-sm font-bold text-zinc-100 flex items-center gap-2">
|
{[
|
||||||
<i data-lucide="music" className="w-5 h-5 text-cyan-400"></i>
|
{ label: 'File', items: [
|
||||||
SonicForge Studio
|
{ label: 'New Project', icon: 'file-plus', action: () => { setTracks([{ id:'1', name:'Track 01', buffer:null, volume:0.8, muted:false, solo:false, color:'#0f766e', markers:[], serverFileId:null }, { id:'2', name:'Track 02', buffer:null, volume:0.8, muted:false, solo:false, color:'#1d4ed8', markers:[], serverFileId:null }]); setSelectedTrackId('1'); showToast('New project created','info'); } },
|
||||||
</h1>
|
{ label: 'Open Project...', icon: 'folder-open', action: () => showToast('Open project dialog','info') },
|
||||||
<span className="text-[10px] text-zinc-500 uppercase tracking-wider border-l border-zinc-700 pl-3">
|
{ label: 'Save Project', icon: 'save', action: () => showToast('Project saved','success') },
|
||||||
Professional DAW Editor
|
{ label: 'Save As...', icon: 'save', action: () => showToast('Save as dialog','info') },
|
||||||
</span>
|
{ label: 'Save to Cloud', icon: 'upload-cloud', action: () => showToast('Saving to cloud...','info') },
|
||||||
|
{ sep: true },
|
||||||
|
{ label: 'Import Audio...', icon: 'file-input', action: () => { const input = document.createElement('input'); input.type='file'; input.accept='audio/*'; input.onchange=async (e)=>{ if(e.target.files[0]){ addNewTrack(); const newId=(tracks.length+1).toString(); setTimeout(()=>loadFileOnTrack(newId,e.target.files[0]),100); } }; input.click(); showToast('Import audio','info'); } },
|
||||||
|
{ label: 'Export Mix...', icon: 'file-output', action: () => triggerWavExport() },
|
||||||
|
{ sep: true },
|
||||||
|
{ label: 'Logout', icon: 'log-out', action: () => showToast('Logged out','info') },
|
||||||
|
]},
|
||||||
|
{ label: 'Edit', items: [
|
||||||
|
{ label: 'Insert New Track', icon: 'plus', action: addNewTrack },
|
||||||
|
{ label: 'Insert Music to Track', icon: 'music', action: () => showToast('Select music file to insert','info') },
|
||||||
|
{ sep: true },
|
||||||
|
{ label: 'Edit in New Tab', icon: 'file-edit', action: () => openTempTab() },
|
||||||
|
{ label: 'Split at Playhead', icon: 'scissors', action: () => handleSplitTrack(selectedTrackId) },
|
||||||
|
{ label: 'Merge Tracks', icon: 'combine', action: () => { setContextMenu({x:0,y:0,trackId:selectedTrackId}); contextMenuMerge(); } },
|
||||||
|
{ sep: true },
|
||||||
|
{ label: 'Copy', icon: 'copy', action: () => { setContextMenu({x:0,y:0,trackId:selectedTrackId}); contextMenuCopy(); } },
|
||||||
|
{ label: 'Cut', icon: 'scissors', action: () => { setContextMenu({x:0,y:0,trackId:selectedTrackId}); contextMenuCut(); } },
|
||||||
|
{ label: 'Paste', icon: 'clipboard', action: contextMenuPaste },
|
||||||
|
{ sep: true },
|
||||||
|
{ label: 'Delete Track', icon: 'trash-2', action: () => { setContextMenu({x:0,y:0,trackId:selectedTrackId}); contextMenuDelete(); } },
|
||||||
|
]},
|
||||||
|
{ label: 'View', items: [
|
||||||
|
{ label: 'Master Track', icon: 'disc', action: () => showToast('Master track view','info') },
|
||||||
|
{ label: 'Maker View', icon: 'layout', action: () => showToast('Maker view','info') },
|
||||||
|
{ label: 'Mixer', icon: 'sliders', action: () => showToast('Mixer panel','info') },
|
||||||
|
{ label: 'Tempo Track', icon: 'timer', action: () => showToast('Tempo track','info') },
|
||||||
|
{ label: 'Video', icon: 'film', action: () => showToast('Video panel','info') },
|
||||||
|
{ label: 'Media Explorer', icon: 'folder-search', action: () => showToast('Media explorer','info') },
|
||||||
|
]},
|
||||||
|
{ label: 'Tools', items: [
|
||||||
|
{ label: 'Config', icon: 'settings', action: () => setShowAIConfig(true) },
|
||||||
|
]},
|
||||||
|
{ label: 'Help', items: [
|
||||||
|
{ label: 'About SonicForge', icon: 'info', action: () => showToast('SonicForge Studio v1.0 - Professional DAW','info') },
|
||||||
|
{ label: 'Keyboard Shortcuts', icon: 'keyboard', action: () => showToast('Ctrl+Z: Undo | Ctrl+Y: Redo | Space: Play/Pause','info') },
|
||||||
|
]},
|
||||||
|
].map(menu => (
|
||||||
|
<div key={menu.label} className="relative">
|
||||||
|
<button
|
||||||
|
onClick={() => setMenuOpen(menuOpen === menu.label ? null : menu.label)}
|
||||||
|
className={`px-3 py-1 text-[11px] font-medium transition rounded ${
|
||||||
|
menuOpen === menu.label ? 'bg-zinc-700 text-zinc-100' : 'text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{menu.label}
|
||||||
|
</button>
|
||||||
|
{menuOpen === menu.label && (
|
||||||
|
<div className="absolute top-full left-0 mt-0.5 bg-[#2a2a2a] border border-zinc-700 rounded-lg shadow-2xl py-1 w-52 z-50"
|
||||||
|
onClick={() => setMenuOpen(null)}>
|
||||||
|
{menu.items.map((item, i) => item.sep ? (
|
||||||
|
<div key={i} className="h-px bg-zinc-700 my-1"></div>
|
||||||
|
) : (
|
||||||
|
<button key={item.label} onClick={(e) => { e.stopPropagation(); item.action(); setMenuOpen(null); }}
|
||||||
|
className="w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2">
|
||||||
|
<i data-lucide={item.icon} className="w-3.5 h-3.5 text-zinc-500"></i> {item.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="flex-1"></div>
|
||||||
|
<div className="flex items-center gap-2 px-2">
|
||||||
<span className={`text-[9px] font-bold uppercase px-1.5 py-0.5 rounded ${
|
<span className={`text-[9px] font-bold uppercase px-1.5 py-0.5 rounded ${
|
||||||
serverStatus === 'connected' ? 'bg-emerald-950 text-emerald-400' :
|
serverStatus === 'connected' ? 'bg-emerald-950 text-emerald-400' :
|
||||||
serverStatus === 'checking' ? 'bg-amber-950 text-amber-400' :
|
serverStatus === 'checking' ? 'bg-amber-950 text-amber-400' :
|
||||||
'bg-red-950 text-red-400'
|
'bg-red-950 text-red-400'
|
||||||
}`}>
|
}`}>Server: {serverStatus}</span>
|
||||||
Server: {serverStatus}
|
<button onClick={() => setShowAIConfig(!showAIConfig)}
|
||||||
</span>
|
className={`px-1.5 py-0.5 rounded text-[10px] border transition ${
|
||||||
</div>
|
showAIConfig ? 'bg-purple-900 text-purple-200 border-purple-700' : 'bg-zinc-800 text-zinc-400 border-zinc-700 hover:text-zinc-200'
|
||||||
|
}`}>
|
||||||
<div className="flex items-center gap-2">
|
<i data-lucide="cpu" className="w-3 h-3"></i>
|
||||||
<button
|
|
||||||
onClick={addNewTrack}
|
|
||||||
className="px-2 py-1 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded text-[11px] flex items-center gap-1 border border-zinc-700 transition"
|
|
||||||
>
|
|
||||||
<i data-lucide="plus" className="w-3.5 h-3.5"></i> Thêm Track
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setShowAIConfig(!showAIConfig)}
|
|
||||||
className={`px-2 py-1 rounded text-[11px] flex items-center gap-1 border transition ${
|
|
||||||
showAIConfig
|
|
||||||
? 'bg-purple-900 text-purple-200 border-purple-700'
|
|
||||||
: 'bg-zinc-800 text-zinc-400 border-zinc-700 hover:text-zinc-200'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<i data-lucide="cpu" className="w-3.5 h-3.5"></i> AI Config
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
{/* Close menu on outside click */}
|
||||||
|
{menuOpen && <div className="fixed inset-0 z-40" onClick={() => setMenuOpen(null)}></div>}
|
||||||
|
|
||||||
|
{/* ── Tab Bar (LOOP_EDITOR_2.md §1) ── */}
|
||||||
|
<div className="h-7 bg-[#222] border-b border-zinc-800 flex items-stretch px-2 gap-0.5 shrink-0 overflow-x-auto">
|
||||||
|
<button onClick={() => setActiveTab('main')}
|
||||||
|
className={`px-3 text-[10px] font-bold uppercase tracking-wider border-b-2 transition flex items-center gap-1 ${
|
||||||
|
activeTab === 'main'
|
||||||
|
? 'text-cyan-400 border-cyan-500 bg-zinc-800/50'
|
||||||
|
: 'text-zinc-500 border-transparent hover:text-zinc-300 hover:bg-zinc-800/30'
|
||||||
|
}`}>
|
||||||
|
<i data-lucide="layout-dashboard" className="w-3 h-3"></i> Main Session
|
||||||
|
</button>
|
||||||
|
{subTabs.map(st => (
|
||||||
|
<div key={st.id} className="flex items-stretch">
|
||||||
|
<button onClick={() => setActiveTab(st.id)}
|
||||||
|
className={`px-2 text-[10px] font-medium border-b-2 transition flex items-center gap-1 ${
|
||||||
|
activeTab === st.id
|
||||||
|
? 'text-amber-400 border-amber-500 bg-zinc-800/50'
|
||||||
|
: 'text-zinc-500 border-transparent hover:text-zinc-300 hover:bg-zinc-800/30'
|
||||||
|
}`}>
|
||||||
|
<i data-lucide="file-edit" className="w-3 h-3"></i>
|
||||||
|
<span className="max-w-[100px] truncate">{st.label}</span>
|
||||||
|
</button>
|
||||||
|
<button onClick={() => closeSubTab(st.id)}
|
||||||
|
className="px-1 text-zinc-600 hover:text-red-400 transition text-[9px]"
|
||||||
|
title="Close tab">
|
||||||
|
<i data-lucide="x" className="w-3 h-3"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* ── AI Config Drawer ── */}
|
{/* ── AI Config Drawer ── */}
|
||||||
{showAIConfig && (
|
{showAIConfig && (
|
||||||
@@ -1400,7 +2000,7 @@
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── Workspace ── */}
|
{/* ── Workspace ── */}
|
||||||
<div className="flex-1 flex overflow-y-auto select-none daw-bg relative">
|
<div className="flex-1 flex overflow-y-auto select-none daw-bg relative" style={{ display: activeTab !== 'main' ? 'none' : '' }}>
|
||||||
{/* TCP Left Column */}
|
{/* TCP Left Column */}
|
||||||
<div className="w-[300px] flex flex-col daw-panel border-r border-zinc-900 z-10 select-none shrink-0">
|
<div className="w-[300px] flex flex-col daw-panel border-r border-zinc-900 z-10 select-none shrink-0">
|
||||||
<div className="h-8 border-b border-zinc-900 bg-[#242424] flex items-center px-4 justify-between sticky top-0 z-30">
|
<div className="h-8 border-b border-zinc-900 bg-[#242424] flex items-center px-4 justify-between sticky top-0 z-30">
|
||||||
@@ -1532,6 +2132,7 @@
|
|||||||
onSelectRange={handleSelectRange} onPlayheadSet={setCurrentTime}
|
onSelectRange={handleSelectRange} onPlayheadSet={setCurrentTime}
|
||||||
isSelected={isSelected} onSelectTrack={setSelectedTrackId} markers={track.markers}
|
isSelected={isSelected} onSelectTrack={setSelectedTrackId} markers={track.markers}
|
||||||
onTrackLaneMouseDown={handleTrackLaneMouseDown}
|
onTrackLaneMouseDown={handleTrackLaneMouseDown}
|
||||||
|
onContextMenu={handleContextMenu}
|
||||||
selectionMode={selectionMode}
|
selectionMode={selectionMode}
|
||||||
localSelectionTrackId={localSelectionTrackId}
|
localSelectionTrackId={localSelectionTrackId}
|
||||||
localSelLeft={localSelectionStart !== null && localSelectionEnd !== null ? Math.min(localSelectionStart, localSelectionEnd) : null}
|
localSelLeft={localSelectionStart !== null && localSelectionEnd !== null ? Math.min(localSelectionStart, localSelectionEnd) : null}
|
||||||
@@ -1550,8 +2151,8 @@
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{/* Selection Overlay */}
|
{/* Selection Overlay - only for Global mode (LOOP_EDITOR.md §1.2: local draws on canvas per-track) */}
|
||||||
{selLeft !== null && selRight !== null && selRight > selLeft && (
|
{selectionMode !== 'local' && selLeft !== null && selRight !== null && selRight > selLeft && (
|
||||||
<div className="absolute top-8 bottom-0 border-l border-r border-amber-500 bg-amber-500/10 z-20 selection-interactive-box cursor-grab active:cursor-grabbing"
|
<div className="absolute top-8 bottom-0 border-l border-r border-amber-500 bg-amber-500/10 z-20 selection-interactive-box cursor-grab active:cursor-grabbing"
|
||||||
style={{ left: `${selLeft * zoom}px`, width: `${(selRight - selLeft) * zoom}px` }}
|
style={{ left: `${selLeft * zoom}px`, width: `${(selRight - selLeft) * zoom}px` }}
|
||||||
onMouseDown={handleSelectionBodyDragStart}
|
onMouseDown={handleSelectionBodyDragStart}
|
||||||
@@ -1581,7 +2182,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Footer ── */}
|
{/* ── Footer ── */}
|
||||||
<footer className="h-44 bg-[#1c1c1c] border-t border-zinc-900 p-4 grid grid-cols-1 md:grid-cols-12 gap-4 text-xs shrink-0 select-none">
|
<footer className="h-44 bg-[#1c1c1c] border-t border-zinc-900 p-4 grid grid-cols-1 md:grid-cols-12 gap-4 text-xs shrink-0 select-none" style={{ display: activeTab !== 'main' ? 'none' : '' }}>
|
||||||
|
|
||||||
{/* Export Section */}
|
{/* Export Section */}
|
||||||
<section className="md:col-span-4 bg-[#262626] border border-zinc-800 rounded p-3 flex flex-col justify-between">
|
<section className="md:col-span-4 bg-[#262626] border border-zinc-800 rounded p-3 flex flex-col justify-between">
|
||||||
@@ -1694,11 +2295,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* AI Analysis Section */}
|
{/* AI Analysis & Edit Section */}
|
||||||
<section className="md:col-span-4 bg-[#262626] border border-zinc-800 rounded p-3 flex flex-col justify-between">
|
<section className="md:col-span-4 bg-[#262626] border border-zinc-800 rounded p-3 flex flex-col justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-bold text-zinc-200 flex items-center gap-1.5 mb-1">
|
<h3 className="font-bold text-zinc-200 flex items-center gap-1.5 mb-1">
|
||||||
<i data-lucide="cpu" className="text-purple-400 w-4 h-4"></i> AI Analysis Engine
|
<i data-lucide="cpu" className="text-purple-400 w-4 h-4"></i> Edit & AI Engine
|
||||||
</h3>
|
</h3>
|
||||||
<div className="p-1.5 bg-[#141414] rounded border border-zinc-800 text-[10px] font-mono min-h-[46px] flex flex-col justify-center">
|
<div className="p-1.5 bg-[#141414] rounded border border-zinc-800 text-[10px] font-mono min-h-[46px] flex flex-col justify-center">
|
||||||
<div className="text-zinc-500">// Status: <span className="text-zinc-300">{analysisState.status}</span></div>
|
<div className="text-zinc-500">// Status: <span className="text-zinc-300">{analysisState.status}</span></div>
|
||||||
@@ -1727,11 +2328,29 @@
|
|||||||
<i data-lucide="sparkles" className="w-4 h-4"></i>
|
<i data-lucide="sparkles" className="w-4 h-4"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex gap-2 mt-1">
|
||||||
|
<button onClick={openTempTab}
|
||||||
|
className="flex-1 py-1 px-1 bg-amber-800 hover:bg-amber-700 text-amber-100 font-bold rounded flex items-center justify-center gap-1 border border-amber-700 transition text-[11px]"
|
||||||
|
title={selLeft !== null ? "Edit in Temp Tab (LOOP_EDITOR.md §2)" : "Select a region first"}>
|
||||||
|
<i data-lucide="file-edit" className="w-3.5 h-3.5"></i> Edit in Temp Tab
|
||||||
|
</button>
|
||||||
|
<button onClick={handleUndo} disabled={undoStack.length === 0}
|
||||||
|
className="px-2 py-1 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded border border-zinc-700 transition text-[11px]"
|
||||||
|
title="Undo (Ctrl+Z)">
|
||||||
|
<i data-lucide="undo" className="w-3.5 h-3.5"></i>
|
||||||
|
</button>
|
||||||
|
<button onClick={handleRedo} disabled={redoStack.length === 0}
|
||||||
|
className="px-2 py-1 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded border border-zinc-700 transition text-[11px]"
|
||||||
|
title="Redo (Ctrl+Y)">
|
||||||
|
<i data-lucide="redo" className="w-3.5 h-3.5"></i>
|
||||||
|
</button>
|
||||||
|
<span className="text-[9px] text-zinc-600 flex items-center font-mono">{undoStack.length}/{MAX_UNDO}</span>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
{/* ── Status Bar ── */}
|
{/* ── Status Bar ── */}
|
||||||
<div className="h-6 bg-[#141414] border-t border-zinc-900 px-4 flex items-center justify-between text-[10px] text-zinc-500 select-none shrink-0">
|
<div className="h-6 bg-[#141414] border-t border-zinc-900 px-4 flex items-center justify-between text-[10px] text-zinc-500 select-none shrink-0" style={{ display: activeTab !== 'main' ? 'none' : '' }}>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<span>Status: {isPlaying ? 'Playing' : 'Stopped'}</span>
|
<span>Status: {isPlaying ? 'Playing' : 'Stopped'}</span>
|
||||||
<span className="text-cyan-400 font-semibold uppercase">Track: ID {selectedTrackId}</span>
|
<span className="text-cyan-400 font-semibold uppercase">Track: ID {selectedTrackId}</span>
|
||||||
@@ -1752,6 +2371,105 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ── Sub-Tab Editor Panel (replaces workspace when active) ── */}
|
||||||
|
{activeTab !== 'main' && (() => {
|
||||||
|
const st = subTabs.find(s => s.id === activeTab);
|
||||||
|
if (!st) return null;
|
||||||
|
const fx = st.effects || {};
|
||||||
|
return (
|
||||||
|
<div className="flex-1 flex flex-col bg-[#1e1e1e]">
|
||||||
|
<div className="h-8 bg-[#2a2a2a] border-b border-zinc-700 flex items-center px-3 gap-2 shrink-0">
|
||||||
|
<i data-lucide="file-edit" className="w-4 h-4 text-amber-400"></i>
|
||||||
|
<span className="text-xs font-bold text-zinc-200">{st.label}</span>
|
||||||
|
<span className="text-[10px] text-zinc-500 font-mono">
|
||||||
|
({formatTime(st.startTime)} - {formatTime(st.endTime)})
|
||||||
|
| {st.buffer ? formatTime(st.buffer.duration) : '0s'}
|
||||||
|
| {st.buffer ? st.buffer.sampleRate : 0} Hz
|
||||||
|
</span>
|
||||||
|
<div className="flex-1"></div>
|
||||||
|
<button onClick={() => closeSubTab(st.id)}
|
||||||
|
className="px-2 py-0.5 bg-zinc-800 hover:bg-zinc-700 text-zinc-400 rounded text-[10px] border border-zinc-700 transition">
|
||||||
|
<i data-lucide="x" className="w-3 h-3"></i> Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 flex flex-col p-3 gap-3 overflow-y-auto">
|
||||||
|
<SubTabWaveform buffer={st.buffer} />
|
||||||
|
<div className="grid grid-cols-4 gap-3 max-w-2xl">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<span className="text-[9px] text-zinc-500 font-bold uppercase">Reverse</span>
|
||||||
|
<button onClick={() => updateSubTabEffects(st.id, { reverse: !fx.reverse })}
|
||||||
|
className={`py-2 rounded text-xs font-bold border transition ${
|
||||||
|
fx.reverse
|
||||||
|
? 'bg-amber-800 text-amber-100 border-amber-600'
|
||||||
|
: 'bg-zinc-800 text-zinc-400 border-zinc-700 hover:text-zinc-200'
|
||||||
|
}`}>
|
||||||
|
<i data-lucide="arrow-left-right" className="w-4 h-4 mx-auto"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<span className="text-[9px] text-zinc-500 font-bold uppercase">Gain (dB)</span>
|
||||||
|
<input type="number" step="0.5" value={fx.gainDb || 0}
|
||||||
|
onChange={(e) => updateSubTabEffects(st.id, { gainDb: parseFloat(e.target.value) || 0 })}
|
||||||
|
className="bg-zinc-800 text-zinc-200 text-center font-mono text-xs rounded border border-zinc-700 p-1.5 focus:outline-none focus:border-amber-600" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<span className="text-[9px] text-zinc-500 font-bold uppercase">Fade In (ms)</span>
|
||||||
|
<input type="number" step="10" min="0" value={fx.fadeInMs || 0}
|
||||||
|
onChange={(e) => updateSubTabEffects(st.id, { fadeInMs: parseInt(e.target.value) || 0 })}
|
||||||
|
className="bg-zinc-800 text-zinc-200 text-center font-mono text-xs rounded border border-zinc-700 p-1.5 focus:outline-none focus:border-amber-600" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<span className="text-[9px] text-zinc-500 font-bold uppercase">Fade Out (ms)</span>
|
||||||
|
<input type="number" step="10" min="0" value={fx.fadeOutMs || 0}
|
||||||
|
onChange={(e) => updateSubTabEffects(st.id, { fadeOutMs: parseInt(e.target.value) || 0 })}
|
||||||
|
className="bg-zinc-800 text-zinc-200 text-center font-mono text-xs rounded border border-zinc-700 p-1.5 focus:outline-none focus:border-amber-600" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="h-10 bg-[#2a2a2a] border-t border-zinc-700 flex items-center justify-end px-3 gap-2 shrink-0">
|
||||||
|
<button onClick={() => closeSubTab(st.id)}
|
||||||
|
className="px-3 py-1.5 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded text-xs font-bold border border-zinc-700 transition">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button onClick={() => applySubTab(st.id)}
|
||||||
|
className="px-3 py-1.5 bg-amber-700 hover:bg-amber-600 text-white rounded text-xs font-bold transition shadow-md">
|
||||||
|
<i data-lucide="check" className="w-3.5 h-3.5 inline mr-1"></i> Apply & Merge
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{/* ── Context Menu ── */}
|
||||||
|
{contextMenu && (
|
||||||
|
<div className="fixed z-[60] bg-[#2a2a2a] border border-zinc-700 rounded-lg shadow-2xl py-1 w-44" style={{ left: contextMenu.x, top: contextMenu.y }}
|
||||||
|
onClick={(e) => e.stopPropagation()}>
|
||||||
|
<button onClick={contextMenuEdit} className="w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2">
|
||||||
|
<i data-lucide="file-edit" className="w-3.5 h-3.5 text-amber-400"></i> Edit
|
||||||
|
</button>
|
||||||
|
<button onClick={contextMenuSplit} className="w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2">
|
||||||
|
<i data-lucide="scissors" className="w-3.5 h-3.5 text-cyan-400"></i> Split
|
||||||
|
</button>
|
||||||
|
<button onClick={contextMenuMerge} className="w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2">
|
||||||
|
<i data-lucide="combine" className="w-3.5 h-3.5 text-purple-400"></i> Merge
|
||||||
|
</button>
|
||||||
|
<div className="h-px bg-zinc-700 my-1"></div>
|
||||||
|
<button onClick={contextMenuCopy} className="w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2">
|
||||||
|
<i data-lucide="copy" className="w-3.5 h-3.5 text-zinc-400"></i> Copy
|
||||||
|
</button>
|
||||||
|
<button onClick={contextMenuCut} className="w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2">
|
||||||
|
<i data-lucide="scissors" className="w-3.5 h-3.5 text-rose-400"></i> Cut
|
||||||
|
</button>
|
||||||
|
<button onClick={contextMenuPaste} className="w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2">
|
||||||
|
<i data-lucide="clipboard" className="w-3.5 h-3.5 text-emerald-400"></i> Paste
|
||||||
|
</button>
|
||||||
|
<div className="h-px bg-zinc-700 my-1"></div>
|
||||||
|
<button onClick={contextMenuDelete} className="w-full px-3 py-1.5 text-xs text-red-300 hover:bg-red-950 text-left flex items-center gap-2">
|
||||||
|
<i data-lucide="trash-2" className="w-3.5 h-3.5 text-red-400"></i> Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ── Toast ── */}
|
{/* ── Toast ── */}
|
||||||
{toastMessage && (
|
{toastMessage && (
|
||||||
<div className="absolute top-14 right-4 z-50 px-4 py-2.5 rounded shadow-lg text-xs font-semibold flex items-center gap-2 border bg-zinc-900 text-zinc-100 border-zinc-800">
|
<div className="absolute top-14 right-4 z-50 px-4 py-2.5 rounded shadow-lg text-xs font-semibold flex items-center gap-2 border bg-zinc-900 text-zinc-100 border-zinc-800">
|
||||||
|
|||||||
Reference in New Issue
Block a user