Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8363a46499 | |||
| 1364cbc34b | |||
| 39d222126e | |||
| d408f30b82 | |||
| 50bb87d50a | |||
| f52e9b7bef | |||
| d14a11342a | |||
| 2dbd5828ae | |||
| e0742d013c | |||
| 89f1c14c2e | |||
| 7ee197301d | |||
| c967627558 | |||
| a8fd4546b2 | |||
| cd644a3a1f | |||
| 6402033c93 |
+165
@@ -0,0 +1,165 @@
|
|||||||
|
|
||||||
|
|
||||||
|
# Technical Specification: Grid Snapping System (Grid Snapping Specification)
|
||||||
|
|
||||||
|
This document defines the graphical user interface design and coordinate/signal processing algorithms required to build a synchronized grid snapping feature across both the Web Frontend and the Dockerized Python Desktop Backend.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Toolbar UI Design Upgrade
|
||||||
|
|
||||||
|
The toolbar layout has been expanded to double its physical vertical height. This increase in interactive space allows for larger navigation buttons and the integration of a dedicated Snap controller.
|
||||||
|
|
||||||
|
```text
|
||||||
|
+───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────+
|
||||||
|
| [Pro Toolbar - Height: 64px] |
|
||||||
|
| +──────────+ +──────────+ +──────────+ +──────────+ +───────────────────────────────────────────────────────────────+ |
|
||||||
|
| | Cut | | Copy | | Paste | | Snap: | | Transport Monitor | |
|
||||||
|
| | [Ctrl+X]| | [Ctrl+C]| | [Ctrl+V]| | [1/4 ▼] | | [Tempo: 120 BPM] [Time Signature: 4/4] [Bar:Beat 1.3.00] | |
|
||||||
|
| +──────────+ +──────────+ +──────────+ +──────────+ +───────────────────────────────────────────────────────────────+ |
|
||||||
|
+───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────+
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.1. Snap Dropdown Configuration
|
||||||
|
|
||||||
|
* **Placement:** Located immediately following the *Paste* button on the primary toolbar row.
|
||||||
|
* **UI Syntax:** `Snap: <Dropdown_Widget>`
|
||||||
|
* **Dropdown Option Matrix:**
|
||||||
|
* `free`: Disables snapping; allows unrestricted pixel-by-pixel dragging.
|
||||||
|
* `1`: Snaps to the beginning of each complete measure (Whole Bar / 1/1).
|
||||||
|
* `1/2`: Divides the bar into 2 subdivisions (Half Note).
|
||||||
|
* `1/4`: Divides the bar into 4 subdivisions (Quarter Note / 1 Beat).
|
||||||
|
* `1/8`: Divides the bar into 8 subdivisions (Eighth Note).
|
||||||
|
* `1/16`: Divides the bar into 16 subdivisions (Sixteenth Note).
|
||||||
|
* `1/32`: Divides the bar into 32 subdivisions (Thirty-second Note).
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. DSP Grid Math: Grid Subdivision & Time Interval Calculations
|
||||||
|
|
||||||
|
To calculate the absolute temporal duration between individual grid lanes, the time metrics must be derived dynamically from the project's master tempo.
|
||||||
|
|
||||||
|
Let:
|
||||||
|
|
||||||
|
* $B$ be the project tempo (Beats Per Minute, e.g., $120\text{ BPM}$).
|
||||||
|
* $T_{\text{beat}}$ be the duration of a single beat (seconds).
|
||||||
|
* $T_{\text{bar}}$ be the duration of a full measure/bar (seconds)—assuming a standard $4/4$ time signature (4 beats per bar).
|
||||||
|
|
||||||
|
The foundational constants are established as follows:
|
||||||
|
|
||||||
|
|
||||||
|
$$T_{\text{beat}} = \frac{60}{B} \quad (\text{seconds})$$
|
||||||
|
|
||||||
|
$$T_{\text{bar}} = 4 \times T_{\text{beat}} = \frac{240}{B} \quad (\text{seconds})$$
|
||||||
|
|
||||||
|
*Example:* At a tempo of $120\text{ BPM}$, a single $1\text{ Bar}$ measure spans exactly $2.0\text{ seconds}$.
|
||||||
|
|
||||||
|
### 2.1. Determining Grid Time Interval Modifiers ($\Delta t$)
|
||||||
|
|
||||||
|
Based on the user's active choice inside the snap dropdown selection ($S \in \{\text{free}, 1, 1/2, 1/4, 1/8, 1/16, 1/32\}$), the exact grid time step $\Delta t$ (seconds) is mapped as follows:
|
||||||
|
|
||||||
|
$$\Delta t = \begin{cases} 0 & S = \text{free} \\ T_{\text{bar}} & S = 1 \\ \frac{T_{\text{bar}}}{2} & S = 1/2 \\ \frac{T_{\text{bar}}}{4} = T_{\text{beat}} & S = 1/4 \\ \frac{T_{\text{bar}}}{8} & S = 1/8 \\ \frac{T_{\text{bar}}}{16} & S = 1/16 \\ \frac{T_{\text{bar}}}{32} & S = 1/32 \end{cases}$$
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Snapping Coordinate Calculation
|
||||||
|
|
||||||
|
When a user executes a drag-and-drop event on an audio clip, or updates a timeline marker position, the system continuously converts raw cursor values into aligned coordinates.
|
||||||
|
|
||||||
|
```text
|
||||||
|
Grid Line 1 (k * dt) Grid Line 2 ((k+1) * dt)
|
||||||
|
│ │
|
||||||
|
├───────────────○─────────────┤
|
||||||
|
▲
|
||||||
|
│ [ Cursor dragging action ]
|
||||||
|
Raw Time (t_raw)
|
||||||
|
│
|
||||||
|
▼ [ Apply Snap round() function ]
|
||||||
|
────────────────┼─────────────►
|
||||||
|
Snapped Time (t_snapped)
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.1. Pixel to Snap-Time Translation Workflow
|
||||||
|
|
||||||
|
1. Intercept the actual client-side horizontal cursor position $X_{\text{raw}}$ (pixels).
|
||||||
|
2. Convert it into a raw timeline duration metric $t_{\text{raw}}$ (seconds) utilizing the current scaling zoom factor $Z$ (pixels/second):
|
||||||
|
|
||||||
|
$$t_{\text{raw}} = \frac{X_{\text{raw}}}{Z}$$
|
||||||
|
|
||||||
|
|
||||||
|
3. Apply the rounding constraint formula to lock the raw timestamp to the absolute nearest grid marker:
|
||||||
|
|
||||||
|
$$t_{\text{snapped}} = \begin{cases} t_{\text{raw}} & S = \text{free} \\ \text{round}\left( \frac{t_{\text{raw}}}{\Delta t} \right) \times \Delta t & S \neq \text{free} \end{cases}$$
|
||||||
|
|
||||||
|
|
||||||
|
4. Map the snapped timeline index $t_{\text{snapped}}$ back to the layout canvas system coordinates to paint the element at its snapped visual boundary:
|
||||||
|
|
||||||
|
$$X_{\text{snapped}} = t_{\text{snapped}} \times Z$$
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Python Porting Blueprint
|
||||||
|
|
||||||
|
When translating this architectural logic into a desktop Python core environment using frameworks like PyQt6, the snapping evaluations are tied directly into the tracking loop inside the `mouseMoveEvent` handler.
|
||||||
|
|
||||||
|
```python
|
||||||
|
# [PYTHON PORTING BLUEPRINT] - Integrating the Snap algorithm into Python UI layer
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
class AudioSnapEngine:
|
||||||
|
def __init__(self, bpm: float = 120.0):
|
||||||
|
self.bpm = bpm
|
||||||
|
|
||||||
|
def calculate_grid_step(self, snap_option: str) -> float:
|
||||||
|
"""
|
||||||
|
Calculates the grid's target duration step (seconds) based on Tempo and Snap selection.
|
||||||
|
"""
|
||||||
|
if snap_option == "free":
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
# 1 Bar in a standard 4/4 signature equals 240 / BPM seconds
|
||||||
|
t_bar = 240.0 / self.bpm
|
||||||
|
|
||||||
|
fraction_map = {
|
||||||
|
"1": 1.0,
|
||||||
|
"1/2": 2.0,
|
||||||
|
"1/4": 4.0,
|
||||||
|
"1/8": 8.0,
|
||||||
|
"1/16": 16.0,
|
||||||
|
"1/32": 32.0
|
||||||
|
}
|
||||||
|
|
||||||
|
division = fraction_map.get(snap_option, 4.0)
|
||||||
|
return float(t_bar / division)
|
||||||
|
|
||||||
|
def snap_time(self, raw_time_seconds: float, snap_option: str) -> float:
|
||||||
|
"""
|
||||||
|
Hard-clamps a raw timestamp to the nearest grid milestone. Prevents negative index overflows.
|
||||||
|
"""
|
||||||
|
dt = self.calculate_grid_step(snap_option)
|
||||||
|
if dt == 0.0:
|
||||||
|
return max(0.0, raw_time_seconds)
|
||||||
|
|
||||||
|
# Find the nearest integer index k of the target grid lane: raw_time / dt
|
||||||
|
k = round(raw_time_seconds / dt)
|
||||||
|
snapped_time = k * dt
|
||||||
|
|
||||||
|
return max(0.0, snapped_time)
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Visual Grid Alignment
|
||||||
|
|
||||||
|
To maintain an intuitive environment for multi-channel editing, whenever a snapping constraint value other than `free` is engaged:
|
||||||
|
|
||||||
|
* The rendering engine overlays thin, low-contrast vertical grid lines (`rgba(255, 255, 255, 0.05)`) over the background profile of every active Waveform Lane.
|
||||||
|
* These marker lines are projected onto every timeline axis point that satisfies a whole multiple increment of $\Delta t$.
|
||||||
|
* Displaying these alignment indicators ensures that users can visually anticipate bounding snapping positions before releasing their mouse track buttons.
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
|
||||||
|
```
|
||||||
+132
@@ -0,0 +1,132 @@
|
|||||||
|
# Technical Specification: Bug Fix for Clip Visual Stretching
|
||||||
|
|
||||||
|
This document analyzes the root cause and defines the waveform painting algorithm to fix the bug where a short audio clip is incorrectly stretched to fill the viewport when pasted onto a new track, referencing the real-world visual analysis in `image_f05ca7.png`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Root Cause
|
||||||
|
|
||||||
|
Based on `image_f05ca7.png`, the error occurs because the track lane's canvas render logic utilizes the total viewport width ($W_{\text{viewport}}$) as the bounding milestone to distribute and draw the entire sample count of the buffer.
|
||||||
|
|
||||||
|
* **Bug Mechanism:** The system treats the clip's start point as $0$ and the clip's end point as the end of the screen, completely ignoring the clip's actual duration ($T_{\text{clip}}$) and starting time coordinates ($t_{\text{offset}}$) of the pasted segment.
|
||||||
|
* **Consequence:** The short clip is stretched with an incorrect display frequency, falling completely out of sync with the global Time Ruler at the top.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Technical Solution: Coordinate System Alignment
|
||||||
|
|
||||||
|
To display the audio clip at its correct duration and position, every clip on the timeline must be managed using two core attributes:
|
||||||
|
|
||||||
|
* **$t_{\text{offset}}$ (seconds):** The timeline insertion position where the clip starts (the position of the playhead at the moment of pasting).
|
||||||
|
* **$T_{\text{clip}}$ (seconds):** The actual duration of the sliced audio file ($T_{\text{clip}} = \text{samples} / \text{sample\_rate}$).
|
||||||
|
|
||||||
|
```text
|
||||||
|
Global Timeline
|
||||||
|
+───────────────────────────────────────────────────────────────────────────+
|
||||||
|
│ │
|
||||||
|
│ Track 01: [█████████████████████████████████████████████████████████] │
|
||||||
|
│ │
|
||||||
|
│ Track 02: [██████████████] <--- Render only this range │
|
||||||
|
│ ▲ ▲ │
|
||||||
|
│ │ │ │
|
||||||
|
│ t_offset t_offset + T_clip │
|
||||||
|
+───────────────────────────────────────────────────────────────────────────+
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.1. Pixel Mapping Formula
|
||||||
|
|
||||||
|
Let $Z$ be the current zoom level (the number of display pixels per second of audio). The initial rendering coordinate and the physical width of the clip on the Canvas must strictly follow these formulas:
|
||||||
|
|
||||||
|
* **Starting rendering coordinate ($X_{\text{start}}$):**
|
||||||
|
|
||||||
|
$$X_{\text{start}} = t_{\text{offset}} \times Z$$
|
||||||
|
|
||||||
|
|
||||||
|
* **Physical width of the waveform ($W_{\text{clip}}$):**
|
||||||
|
|
||||||
|
$$W_{\text{clip}} = T_{\text{clip}} \times Z$$
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Safe Waveform Render Algorithm (Python & JS)
|
||||||
|
|
||||||
|
The Render Loop must exclusively calculate and draw peak amplitudes within the bound stretching from $X_{\text{start}}$ to $X_{\text{start}} + W_{\text{clip}}$. Any pixel region outside this interval must be painted with an empty background color (transparent or the track's dark background theme).
|
||||||
|
|
||||||
|
### 3.1. Pseudo-code
|
||||||
|
|
||||||
|
```python
|
||||||
|
def render_track_lane(canvas_width, zoom_level, track_clip):
|
||||||
|
# 1. Compute rendering boundaries based on synchronization formulas
|
||||||
|
x_start = track_clip.offset_seconds * zoom_level
|
||||||
|
w_clip = track_clip.duration_seconds * zoom_level
|
||||||
|
x_end = x_start + w_clip
|
||||||
|
|
||||||
|
# 2. Initialize empty background
|
||||||
|
initialize_background(0, canvas_width)
|
||||||
|
|
||||||
|
# 3. Scan the pixel array and only render within the active clip segment
|
||||||
|
for x in range(0, canvas_width):
|
||||||
|
if x < x_start or x > x_end:
|
||||||
|
# Paint empty background color for region with no data
|
||||||
|
draw_background_pixel(x)
|
||||||
|
else:
|
||||||
|
# Map current pixel x coordinate back to sample index in Buffer
|
||||||
|
time_in_clip = (x - x_start) / zoom_level
|
||||||
|
sample_index = int(time_in_clip * track_clip.sample_rate)
|
||||||
|
|
||||||
|
# Calculate amplitude peak and draw symmetrical vertical line
|
||||||
|
amplitude_peak = get_peak_amplitude(track_clip.buffer, sample_index)
|
||||||
|
draw_waveform_vertical_line(x, amplitude_peak)
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Guarding Array Boundaries on Python Docker Server
|
||||||
|
|
||||||
|
When a user triggers a cut/paste operation on the Frontend, the JSON data structure dispatched to the Python Server must explicitly specify the destination paste coordinates to prevent index calculation errors:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"action": "paste_clip",
|
||||||
|
"source_clip": {
|
||||||
|
"clip_id": "clip_abc123",
|
||||||
|
"duration_seconds": 24.150,
|
||||||
|
"sample_rate": 44100
|
||||||
|
},
|
||||||
|
"destination": {
|
||||||
|
"track_id": "02",
|
||||||
|
"paste_at_seconds": 15.300
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
On the Python backend (utilizing `pydub` or `numpy`), the binary array insertion is executed at the exact time milestone by zero-padding the preceding segment to align perfectly:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
def insert_clip_to_track_array(track_array: np.ndarray, sr: int, clip_array: np.ndarray, paste_sec: float) -> np.ndarray:
|
||||||
|
"""
|
||||||
|
Inserts clip_array into track_array at paste_sec without stretching the signal.
|
||||||
|
"""
|
||||||
|
paste_sample = int(paste_sec * sr)
|
||||||
|
clip_length = len(clip_array)
|
||||||
|
|
||||||
|
# Create a new array with a length covering the entire pasted segment
|
||||||
|
required_length = max(len(track_array), paste_sample + clip_length)
|
||||||
|
output_array = np.zeros(required_length, dtype=np.float32)
|
||||||
|
|
||||||
|
# Copy original track data over
|
||||||
|
output_array[0:len(track_array)] = track_array
|
||||||
|
|
||||||
|
# Overwrite the new clip at the precise real-time coordinate position
|
||||||
|
output_array[paste_sample:paste_sample + clip_length] = clip_array
|
||||||
|
|
||||||
|
return output_array
|
||||||
|
|
||||||
|
```
|
||||||
+125
@@ -0,0 +1,125 @@
|
|||||||
|
# Technical Specification: Minimum Zoom Constraint Specification
|
||||||
|
|
||||||
|
This document defines the algorithm and graphical rendering mechanics (Rendering Logic) to solve the following problem: When zooming out to the absolute minimum, the audio waveforms of all tracks must fit perfectly within the horizontal width of the Editor viewport, as realistically illustrated in `image_f0c1e0.png`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Current State & Design Problem Analysis
|
||||||
|
|
||||||
|
Based on `image_f0c1e0.png`, when a user performs a zoom-out operation to the absolute minimum limit:
|
||||||
|
|
||||||
|
* **Visual Requirement:** The entire audio range from the starting point ($0.00\text{ s}$) to the termination point ($T_{\text{max}}$) must be captured completely within the "Display Width" ($W_{\text{viewport}}$) of the screen.
|
||||||
|
* **Desired Outcomes:**
|
||||||
|
* No redundant horizontal scrollbars appear underneath the timeline.
|
||||||
|
* No massive black voids (dead space) exist on the right side if the track duration is shorter than the viewport bounding container.
|
||||||
|
* All audio tracks are scaled down synchronously in physical size to fully display their respective waveforms from start to finish.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
```text
|
||||||
|
Editor Viewport Width (W_viewport)
|
||||||
|
|<───────────────────────────────────────────────────────────────────────────────────>|
|
||||||
|
+─────────────────────────────────────────────────────────────────────────────────────+
|
||||||
|
| Ruler: 0:00 0:10 0:20 0:30 0:40 0:50 1:00 |
|
||||||
|
+─────────────────────────────────────────────────────────────────────────────────────+
|
||||||
|
| Track 1: [███████████████████████████████████████████████████████████████████████] |
|
||||||
|
| |
|
||||||
|
| Track 2: [███████████████████████████████████████████████████████████████████████] |
|
||||||
|
+─────────────────────────────────────────────────────────────────────────────────────+
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Dynamic Min-Zoom Calculation Algorithm
|
||||||
|
|
||||||
|
To guarantee that the waveforms always fit perfectly even when the user resizes the browser window (or a Python application window), the minimum boundary zoom value ($Z_{\text{min}}$) must be evaluated dynamically.
|
||||||
|
|
||||||
|
Let:
|
||||||
|
|
||||||
|
* **$W_{\text{viewport}}$ (pixels):** The actual horizontal visible width of the timeline container viewport.
|
||||||
|
* **$T_{\text{max}}$ (seconds):** The maximum duration among all active tracks residing on the timeline.
|
||||||
|
* **$Z$ (pixels/second):** The current zoom scale factor (Zoom Level—the number of physical pixels representing $1$ second of audio).
|
||||||
|
|
||||||
|
The bounding minimum zoom level ($Z_{\text{min}}$) is determined by the formula:
|
||||||
|
|
||||||
|
$$Z_{\text{min}} = \frac{W_{\text{viewport}}}{T_{\text{max}}}$$
|
||||||
|
|
||||||
|
### 2.1. Zoom Level Constraints
|
||||||
|
|
||||||
|
Throughout mouse wheel interaction events triggered to adjust $Z$, the system must check bounds and strictly clamp the value within a safe operating spectrum:
|
||||||
|
|
||||||
|
$$Z_{\text{clipped}} = \text{clamp}(Z_{\text{min}}, Z_{\text{target}}, Z_{\text{max}})$$
|
||||||
|
|
||||||
|
*Where:*
|
||||||
|
|
||||||
|
* **$Z_{\text{max}}$:** The upper zoom-in boundary limit (e.g., fixed at $2000\text{ pixels/s}$ to eliminate canvas rendering memory overflow vulnerabilities).
|
||||||
|
* **$Z_{\text{min}}$:** The dynamic lower zoom-out boundary limit (re-calculated based on fluctuations of $W_{\text{viewport}}$ and $T_{\text{max}}$).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Synchronized Implementation Manual (Frontend JS & Python Porting)
|
||||||
|
|
||||||
|
### 3.1. Client-side Integration (JavaScript / React)
|
||||||
|
|
||||||
|
Utilize a `ResizeObserver` to systematically recompute $Z_{\text{min}}$ as soon as the user scales the browser viewport:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Initialize element targeting for the Timeline container frame
|
||||||
|
const timelineWrapper = document.getElementById('timeline-wrapper');
|
||||||
|
|
||||||
|
const resizeObserver = new ResizeObserver(entries => {
|
||||||
|
for (let entry of entries) {
|
||||||
|
const viewportWidth = entry.contentRect.width;
|
||||||
|
|
||||||
|
// Compute dynamic Z_min boundary condition
|
||||||
|
const computedMinZoom = viewportWidth / maxDuration;
|
||||||
|
|
||||||
|
// Update state and instantly clamp current zoom so it doesn't fall below Z_min
|
||||||
|
setZoom(prevZoom => {
|
||||||
|
const nextZoom = Math.max(computedMinZoom, prevZoom);
|
||||||
|
return nextZoom;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
resizeObserver.observe(timelineWrapper);
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2. Server-side / Desktop App Integration (Python PyQt6 / PySide6)
|
||||||
|
|
||||||
|
When porting this layout system and mathematical constraint model to a Python desktop application context, hook into the `resizeEvent` method of the `QWidget` class to handle container adjustments:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from PyQt6.QtWidgets import QWidget
|
||||||
|
from PyQt6.QtCore import QSize
|
||||||
|
|
||||||
|
class TimelineContainerWidget(QWidget):
|
||||||
|
def __init__(self, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.max_duration_seconds = 60.0 # Track duration benchmark (seconds)
|
||||||
|
self.current_zoom = 100.0 # Current scale metric (pixels/second)
|
||||||
|
self.max_zoom = 2000.0 # Strict upper ceiling for zoom-in operations
|
||||||
|
|
||||||
|
def resizeEvent(self, event):
|
||||||
|
"""
|
||||||
|
Intercepts the window/widget resizing event to refresh the minimum zoom bounds.
|
||||||
|
"""
|
||||||
|
viewport_width = self.width()
|
||||||
|
|
||||||
|
# 1. Evaluate the dynamic Z_min constraint from the new physical container width
|
||||||
|
min_zoom = float(viewport_width) / self.max_duration_seconds
|
||||||
|
|
||||||
|
# 2. Hard clamp the active zoom level to prevent dropping underneath min_zoom
|
||||||
|
if self.current_zoom < min_zoom:
|
||||||
|
self.current_zoom = min_zoom
|
||||||
|
|
||||||
|
# 3. Request a graphical redraw of the waveform lanes
|
||||||
|
self.update_waveform_painter()
|
||||||
|
super().resizeEvent(event)
|
||||||
|
|
||||||
|
def update_waveform_painter(self):
|
||||||
|
# Triggers the QPainter paintEvent routine redraw execution block
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
```
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
# Bug Fix Specification: Resolving Critical Row Desynchronization
|
||||||
|
|
||||||
|
This document analyzes the root cause and provides a permanent structural solution to eliminate the vertical row desynchronization and internal horizontal scrolling artifacts occurring between the left Track Control Panel (TCP) and the right waveform lanes, based on the real-world visual analysis
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Visual Symptom Analysis
|
||||||
|
|
||||||
|
The layout engine is suffering from two critical alignment failures indicated by the red arrows:
|
||||||
|
|
||||||
|
```text
|
||||||
|
[ LEFT COLUMN - TCP PANEL ] [ RIGHT COLUMN - TIMELINE GRID ]
|
||||||
|
┌──────────────────────────────┐ ┌──────────────────────────────────────────────┐
|
||||||
|
│ ... Track 05, 06 (Aligned) │ ══════════ │ Waveform 05, 06 (Aligned) │
|
||||||
|
├──────────────────────────────┤ ├──────────────────────────────────────────────┤
|
||||||
|
│ 07 Track 3 (Channel Header) │ [MISALIGNED]│ [EMPTY BLACK DEAD SPACE] (Lower red arrow) │
|
||||||
|
│ [Junk horizontal scrollbar] │ ◄────────── │ ◄── Caused by Waveform 07 dropping height to 0│
|
||||||
|
│ (Upper red arrow) │ ├──────────────────────────────────────────────┤
|
||||||
|
├──────────────────────────────┤ │ Waveform 07 (Pushed down to Track 08's row) │
|
||||||
|
│ 08 Track 3 │ ══════════ │ ... │
|
||||||
|
└──────────────────────────────┘ └──────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.1. Defect Index 1: Spurious Internal Horizontal Scrollbar (Upper Red Arrow)
|
||||||
|
|
||||||
|
* **Symptom:** A small gray horizontal scrollbar emerges directly beneath Track 07 within the left TCP column.
|
||||||
|
* **Root Cause:** The container wrapper for the left TCP column enforces a rigid bounding layout (`fixed width` or missing an explicit `overflow-x: hidden` safety attribute). When inner structural components (such as long text labels, Mute/Solo clusters, or upload file actions) expand horizontally, the browser generates a local scrollbar. This automatically inflates the effective physical height of the left Track 07 by roughly $12\text{ px} \rightarrow 16\text{ px}$.
|
||||||
|
|
||||||
|
### 1.2. Defect Index 2: Vertical Row Desynchronization & Dead Black Space (Lower Red Arrow)
|
||||||
|
|
||||||
|
* **Symptom:** On the right column (Timeline), a massive horizontal empty black gap disrupts the grid layout where Waveform 07 ought to sit. Consequently, all matching waveforms for Track 07 and Track 08 are offset downward, falling entirely out of phase with their corresponding control headers on the left.
|
||||||
|
* **Root Cause:** The system evaluates the target height ($H$) of the left TCP container independently from the right Waveform Lane. When the left Track 07 column expands due to the rendering of the junk scrollbar, the right canvas lane does not dynamically adapt. This triggers a cumulative pixel error along the vertical axis ($Y$), producing progressive, severe desynchronization downstream (the lower the tracks sit, the worse the alignment drifts).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Structural Correction Blueprint
|
||||||
|
|
||||||
|
To prevent this layout defect from recurring—especially when porting the interface to desktop Python using PyQt/PySide—the system must completely decouple from independent height calculations and embrace a **Unified Row Layout** model.
|
||||||
|
|
||||||
|
### 2.1. Standardized HTML / Tailwind CSS Architecture Blueprint
|
||||||
|
|
||||||
|
Instead of splitting the page tree layout into two isolated columns (`Col1: [TCP1, TCP2, TCP3]` and `Col2: [Wave1, Wave2, Wave3]`), the application must encapsulate each matching TCP and Waveform pair within a shared, unified row wrapper:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!-- Wrap the entire track stack inside a single vertical scroll container -->
|
||||||
|
<div class="flex-1 overflow-y-auto bg-[#111111]">
|
||||||
|
|
||||||
|
<!-- UNIFIED TRACK ROW (Enforces strict shared-row geometry) -->
|
||||||
|
<div class="flex h-[96px] w-full min-w-max border-b border-[#141414]">
|
||||||
|
|
||||||
|
<!-- Left Side: TCP (Fixed width; absolute containment of horizontal overflows) -->
|
||||||
|
<div class="w-[300px] shrink-0 bg-[#262626] p-2.5 overflow-hidden flex flex-col justify-between">
|
||||||
|
<!-- TCP Control Content Elements Go Here -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right Side: Waveform Lane (Flexibly fills remaining browser canvas viewport) -->
|
||||||
|
<div class="flex-1 relative overflow-hidden">
|
||||||
|
<!-- Waveform Canvas Engine -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add additional track rows duplicating the exact structural envelope above... -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
### Architectural Advantages:
|
||||||
|
|
||||||
|
* Because both the control deck and the waveform graphic share an identical row container wrapper (`flex row` or `grid row`), any arbitrary height fluctuation on the TCP side (due to text zoom-in behaviors or overflow glitches) will instantly force the right waveform canvas view to mirror the $100\%$ row scale change.
|
||||||
|
* Only one master vertical scrollbar exists on the outer perimeter window to slide all rows simultaneously.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Prevention Guidelines for Python Porting (PyQt6 / PySide6)
|
||||||
|
|
||||||
|
If you attempt to design this DAW interface inside a containerized Python Docker application by leveraging two separate `QScrollArea` nodes for the TCP track column and the timeline canvas, you will inevitably trigger this row alignment defect due to timing delays or scroll tracking errors (`scrollEvent` mismatch).
|
||||||
|
|
||||||
|
### 3.1. Secure Layout Architecture Using Python QWidget
|
||||||
|
|
||||||
|
Implement a nested widget strategy to securely bind the horizontal axes together at all times:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from PyQt6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QScrollArea
|
||||||
|
from PyQt6.QtCore import Qt
|
||||||
|
|
||||||
|
class ProDAWArrangeWindow(QWidget):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.main_layout = QVBoxLayout(self)
|
||||||
|
self.main_layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
self.main_layout.setSpacing(0)
|
||||||
|
|
||||||
|
# 1. Instantiate a single, unified QScrollArea for the absolute Workspace
|
||||||
|
self.workspace_scroll = QScrollArea()
|
||||||
|
self.workspace_scroll.setWidgetResizable(True)
|
||||||
|
self.workspace_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOn)
|
||||||
|
self.workspace_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||||
|
|
||||||
|
# 2. Outer container hosting the multi-channel rows
|
||||||
|
self.container_widget = QWidget()
|
||||||
|
self.container_layout = QVBoxLayout(self.container_widget)
|
||||||
|
self.container_layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
self.container_layout.setSpacing(0)
|
||||||
|
self.container_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
|
||||||
|
|
||||||
|
self.workspace_scroll.setWidget(self.container_widget)
|
||||||
|
self.main_layout.addWidget(self.workspace_scroll)
|
||||||
|
|
||||||
|
def add_track(self, track_id: str):
|
||||||
|
"""
|
||||||
|
Appends a unified track row utilizing QHBoxLayout with a rigid physical height constraint.
|
||||||
|
"""
|
||||||
|
track_row = QWidget()
|
||||||
|
track_row.setFixedHeight(96) # Lock physical pixel height constraints for the row
|
||||||
|
|
||||||
|
row_layout = QHBoxLayout(track_row)
|
||||||
|
row_layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
row_layout.setSpacing(0)
|
||||||
|
|
||||||
|
# Left Panel: Track Control Panel (Enforces a strict rigid width constraint)
|
||||||
|
tcp_widget = QWidget()
|
||||||
|
tcp_widget.setFixedWidth(300)
|
||||||
|
# tcp_widget.setup_ui(...)
|
||||||
|
|
||||||
|
# Right Panel: Waveform Canvas Viewport
|
||||||
|
waveform_widget = QWidget()
|
||||||
|
# waveform_widget.setup_canvas(...)
|
||||||
|
|
||||||
|
# Combine both widgets into the layout block to guarantee row lock
|
||||||
|
row_layout.addWidget(tcp_widget)
|
||||||
|
row_layout.addWidget(waveform_widget)
|
||||||
|
|
||||||
|
self.container_layout.addWidget(track_row)
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2. Concrete Advantages for Python Docker Environments
|
||||||
|
|
||||||
|
* **Zero Alignment Variance:** Row alignment is entirely guaranteed at the OS-level layout engine, bypassing desynchronization issues caused by asynchronous rendering cycles or UI latency.
|
||||||
|
* **Streamlined UI Pipelines:** The environment tracking mechanisms hook into a single scrollbar, reducing memory usage and optimizing the drawing threads for the Docker X11 Server or WebRTC stream pipelines when projecting graphics down to the client.
|
||||||
+133
@@ -0,0 +1,133 @@
|
|||||||
|
|
||||||
|
|
||||||
|
# Bug Fix Specification: Resolving Layout Overlaps & Synchronized Scroll Management (Scroll & Overlap Fix)
|
||||||
|
|
||||||
|
This document defines the technical solution to completely eliminate two critical layout overlap defects occurring during timeline scrolling operations, based on the real-world visual analysis.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Visual Overlap Analysis
|
||||||
|
|
||||||
|
Based on the graphical evidence, the system is experiencing user interface overlap (clipping) defects at two positions indicated by the red arrows:
|
||||||
|
|
||||||
|
### 1.1. Defect Index 1: Playhead and Grid Lines Overflowing Over the TCP
|
||||||
|
|
||||||
|
* **Symptom:** The red playback cursor (Playhead) and the vertical time grid markers (Ruler/Grid lines) render on top of the left Track Control Panel (TCP) during horizontal scrolling.
|
||||||
|
* **Root Cause:** The Timeline bounding container lacks an independent visual clipping boundary (`overflow: hidden`) relative to the TCP column. Alternatively, the TCP lacks a sufficient rendering layer tier (`z-index`) and a solid background color, which allows absolute-positioned elements from the Timeline to float over the TCP stack.
|
||||||
|
|
||||||
|
### 1.2. Defect Index 2: Horizontal Scrollbar Overflowing Underneath the TCP Base
|
||||||
|
|
||||||
|
* **Symptom:** The global horizontal scrollbar at the bottom of the viewport extends across the lower quadrant of the TCP all the way to the far left edge of the screen.
|
||||||
|
* **Root Cause:** The absolute outermost parent container wrapping both the TCP and the Timeline has been assigned horizontal scrolling properties, or the Timeline column is not physically isolated (as adjacent flex columns) from the TCP section.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Structural Architecture: Synchronized Dual-Column Viewports (Split-Container Sync)
|
||||||
|
|
||||||
|
To permanently resolve these defects, the workspace must completely isolate the two main columns into distinct physical viewports while linking their vertical scroll movements using JavaScript or UI event signals:
|
||||||
|
|
||||||
|
```text
|
||||||
|
[ MASTER WORKSPACE - flex h-full overflow-hidden ]
|
||||||
|
┌──────────────────────────────┬──────────────────────────────────────────────┐
|
||||||
|
│ [LEFT COLUMN - TCP PANEL] │ [RIGHT COLUMN - TIMELINE SCROLL VIEWPORT] │
|
||||||
|
│ - Width: 300px (Fixed) │ - flex-1 │
|
||||||
|
│ - overflow: hidden │ - overflow-x: auto (Isolated Horiz. Scroll) │
|
||||||
|
│ - z-index: 20 (Layer Top) │ - overflow-y: auto (Isolated Vert. Scroll) │
|
||||||
|
│ - bg: #262626 (Solid Solid) │ - z-index: 10 │
|
||||||
|
│ │ │
|
||||||
|
│ ┌──────────────────────────┐ │ ┌──────────────────────────────────────────┐ │
|
||||||
|
│ │ TCP Track 01 │ │ │ Waveform Track 01 │ │
|
||||||
|
│ ├──────────────────────────┤ │ ├──────────────────────────────────────────┤ │
|
||||||
|
│ │ TCP Track 02 │ │ │ Waveform Track 02 │ │
|
||||||
|
│ └──────────────────────────┘ │ └──────────────────────────────────────────┘ │
|
||||||
|
└──────────────────────────────┴──────────────────────────────────────────────┘
|
||||||
|
▲ │
|
||||||
|
│ [JS Vertical Scroll Sync Link] │
|
||||||
|
└──────────────────────────────────────▼
|
||||||
|
tcpContainer.scrollTop = timelineContainer.scrollTop
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.1. Rendering Priority and Containment Rules
|
||||||
|
|
||||||
|
* **TCP Panel:** Configured with `position: relative`, `z-index: 20`, and a solid `background-color: #262626`. Consequently, when the Timeline viewport scrolls horizontally to the left, all waveform vectors and the absolute playhead path automatically scroll beneath the TCP panel layer, masking them perfectly from view.
|
||||||
|
* **Timeline Wrapper:** Positioned immediately adjacent to the TCP column, utilizing `overflow-x: auto` and `overflow-y: auto`. The horizontal scrollbar will strictly begin rendering at coordinate $x = 300\text{ px}$ stretching rightward, preventing it from clipping the bottom area of the TCP.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Mouse Wheel Interaction Mechanics
|
||||||
|
|
||||||
|
The translation of scrolling gestures across the timeline canvas depends on the following hardware modifier key bindings:
|
||||||
|
|
||||||
|
### A. Standard Mouse Wheel Rotation (Vertical Scroll)
|
||||||
|
|
||||||
|
* **User Action:** The user rotates the mouse wheel up or down while hovering over the Timeline area.
|
||||||
|
* **Result:** The layout executes native vertical scrolling. The browser triggers the `onScroll` event listener loop, and the synchronization script immediately maps the offset values:
|
||||||
|
|
||||||
|
$$\text{scrollTop}_{\text{TCP}} = \text{scrollTop}_{\text{Timeline}}$$
|
||||||
|
|
||||||
|
This forces both columns to move up and down in absolute physical alignment.
|
||||||
|
|
||||||
|
### B. Shift + Mouse Wheel Rotation (Horizontal Scroll)
|
||||||
|
|
||||||
|
* **User Action:** The user holds down the `Shift` key while rotating the mouse wheel up or down.
|
||||||
|
* **Result:** The system intercepts the input and cross-routes vertical scrolling vectors into the horizontal scroll register:
|
||||||
|
|
||||||
|
$$\text{scrollLeft}_{\text{Timeline}} \mathrel{+}= \Delta y$$
|
||||||
|
|
||||||
|
The Timeline viewport shifts horizontally left or right, letting the editor browse across different segments of the arrangement timeline.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Porting Guidelines for Python Docker Applications (PyQt6 / PySide6)
|
||||||
|
|
||||||
|
When porting this split-container layout blueprint to a containerized Python desktop application, instantiate two independent `QScrollArea` nodes positioned side by side within a horizontal layout (`QHBoxLayout`), then connect their vertical scrollbar signals (`verticalScrollBar`):
|
||||||
|
|
||||||
|
```python
|
||||||
|
from PyQt6.QtWidgets import QWidget, QHBoxLayout, QScrollArea, QVBoxLayout
|
||||||
|
from PyQt6.QtCore import Qt
|
||||||
|
|
||||||
|
class SyncedDAWWorkspace(QWidget):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
layout = QHBoxLayout(self)
|
||||||
|
layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
layout.setSpacing(0)
|
||||||
|
|
||||||
|
# 1. Initialize the Left TCP Scroll Area (Enforce absolute scrollbar concealment)
|
||||||
|
self.tcp_scroll = QScrollArea()
|
||||||
|
self.tcp_scroll.setFixedWidth(300)
|
||||||
|
self.tcp_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||||
|
self.tcp_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||||
|
self.tcp_scroll.setWidgetResizable(True)
|
||||||
|
|
||||||
|
# 2. Initialize the Right Timeline Scroll Area (Enable bidirection scroll mapping)
|
||||||
|
self.timeline_scroll = QScrollArea()
|
||||||
|
self.timeline_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOn)
|
||||||
|
self.timeline_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOn)
|
||||||
|
self.timeline_scroll.setWidgetResizable(True)
|
||||||
|
|
||||||
|
layout.addWidget(self.tcp_scroll)
|
||||||
|
layout.addWidget(self.timeline_scroll)
|
||||||
|
|
||||||
|
# 3. VERTICAL SYNC BINDING: Redirect Timeline scrolling directly to the TCP axis
|
||||||
|
self.timeline_scroll.verticalScrollBar().valueChanged.connect(
|
||||||
|
self.tcp_scroll.verticalScrollBar().setValue
|
||||||
|
)
|
||||||
|
|
||||||
|
def eventFilter(self, obj, event):
|
||||||
|
"""
|
||||||
|
Intercepts WheelEvents on the Timeline view to handle Shift + Horizontal Scrolling.
|
||||||
|
"""
|
||||||
|
if obj == self.timeline_scroll.viewport() and event.type() == event.Type.Wheel:
|
||||||
|
if event.modifiers() & Qt.KeyboardModifier.ShiftModifier:
|
||||||
|
# Convert vertical wheel delta into horizontal scroll offset step increments
|
||||||
|
num_degrees = event.angleDelta().y() / 8
|
||||||
|
num_steps = num_degrees / 15
|
||||||
|
self.timeline_scroll.horizontalScrollBar().setValue(
|
||||||
|
self.timeline_scroll.horizontalScrollBar().value() - num_steps * 30
|
||||||
|
)
|
||||||
|
return True # Halt event propagation as it is now fully handled
|
||||||
|
return super().eventFilter(obj, event)
|
||||||
|
|
||||||
|
```
|
||||||
+191
@@ -0,0 +1,191 @@
|
|||||||
|
|
||||||
|
|
||||||
|
# Technical Specification: Multi-Channel Layout Synchronization & Scroll Management (Unified DAW Layout & Sync Scroll)
|
||||||
|
|
||||||
|
This document analyzes and defines the structural hierarchy of the graphical user interface based on the real-world interface analysis. This specification serves to guide Frontend interface programming and porting to a Python Desktop application running inside a Docker container.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Structural Wireframe
|
||||||
|
|
||||||
|
Based on the visual analysis, the layout composition is split into vertically static and dynamic zones:
|
||||||
|
|
||||||
|
```text
|
||||||
|
+───────────────────────────────────────────────────────────────────────────────+
|
||||||
|
| [ZONE A - STATIC] HEADER ZONE (Sticky - Permanently fixed when scrolling down)|
|
||||||
|
| +────────────────────+──────────────────────────────────────────────────────+ |
|
||||||
|
| | Channels & Tools | Time Ruler Scale | |
|
||||||
|
| |--------------------|------------------------------------------------------| |
|
||||||
|
| | Tempo Track Header | Tempo Grid Lane (120 BPM) | |
|
||||||
|
| +────────────────────+──────────────────────────────────────────────────────+ |
|
||||||
|
+───────────────────────────────────────────────────────────────────────────────+
|
||||||
|
| [ZONE B - DYNAMIC] TRACKS SCROLL WORKSPACE (Synchronized vertical scroll) |
|
||||||
|
| +────────────────────+──────────────────────────────────────────────────────+ |
|
||||||
|
| | TCP - Track 01 | Waveform Lane - Track 01 | |
|
||||||
|
| | TCP - Track 02 | Waveform Lane - Track 02 | |
|
||||||
|
| | TCP - Track 03 | Waveform Lane - Track 03 | |
|
||||||
|
| | ... | ... | |
|
||||||
|
| +────────────────────+──────────────────────────────────────────────────────+ |
|
||||||
|
+───────────────────────────────────────────────────────────────────────────────+ ▲
|
||||||
|
│ [Vertical Scrollbar]
|
||||||
|
│ (Single unified scroll)
|
||||||
|
▼
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Layout Specifications
|
||||||
|
|
||||||
|
### 2.1. Fixed Header Zone (Green Border Area - Sticky Header)
|
||||||
|
|
||||||
|
* **Visual Scope:** Encompasses the toolbar, the time ruler scale, and the Tempo Track Lane (indicated by the green bounding border in `image_fbbd4e.png`).
|
||||||
|
* **Graphical Sticky Behavior:**
|
||||||
|
* When a user adds dozens of tracks and scrolls downward, this entire zone must remain anchored to the top of the screen and is not permitted to slide out of view.
|
||||||
|
* This ensures that users can continuously track the Ruler Seconds and the master project tempo (Tempo BPM) while editing tracks located deeper down the timeline.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### 2.2. Absolute Horizontal Row Alignment (Red Border Area - Row Alignment)
|
||||||
|
|
||||||
|
* **Interaction Scope:** The exact matching pair consisting of the left Track Control Panel (TCP) and the right Waveform Lane of the same track (e.g., Track 4 inside the red border of `image_fbbd4e.png`).
|
||||||
|
* **Row Alignment Rules:**
|
||||||
|
* The corresponding TCP and Waveform Lane must have identical heights ($H = 96\text{ px}$).
|
||||||
|
* These two elements must be wrapped within a single parent row container (`Flex Row` or `Grid Row`) to guarantee that during vertical scrolling, both move simultaneously along the exact same vertical axis coordinate ($Y$).
|
||||||
|
* Row misalignment must be strictly avoided (e.g., situations where the Track 4 TCP sits higher or lower than the Track 4 Waveform lane).
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### 2.3. Single Vertical Scrollbar Mandate
|
||||||
|
|
||||||
|
* **Issue to Avoid:** Separating the TCP into an independent scrollable column and the Timeline into another independent scrollable column. Doing so leads to scroll-position desynchronization errors when a user drags the scrollbar.
|
||||||
|
* **Design Standard:**
|
||||||
|
* Only a single unified Vertical Scrollbar is permitted to appear on the absolute far right of the application window (as directed by the two red arrows in `image_fbbd4e.png`).
|
||||||
|
* This vertical scrollbar moves the entire dynamic wrapper (**Tracks Scroll Workspace**), scrolling both TCPs and Waveform Lanes up or down in sync.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Implementation Guide
|
||||||
|
|
||||||
|
### 3.1. Web Frontend Integration (HTML / Tailwind CSS)
|
||||||
|
|
||||||
|
To group everything into one scrollbar while keeping the Tempo Track anchored at the top, use `position: sticky` and wrap the dynamic track list inside a single container:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!-- Main Container (Entire Editor Wrapper) -->
|
||||||
|
<div class="flex flex-col h-full overflow-hidden">
|
||||||
|
|
||||||
|
<!-- [ZONE A] Top Anchored Sticky Header Zone -->
|
||||||
|
<div class="sticky top-0 z-40 bg-[#242424] border-b border-[#141414] shrink-0">
|
||||||
|
<!-- Toolbar & Time Ruler -->
|
||||||
|
<div class="h-8 flex">
|
||||||
|
<div class="w-[300px] border-r border-zinc-900 px-4 flex items-center">CHANNELS</div>
|
||||||
|
<div class="flex-1 relative h-full">...Ruler Numbers...</div>
|
||||||
|
</div>
|
||||||
|
<!-- Tempo Track (Green Border Area) -->
|
||||||
|
<div class="h-[44px] flex border-t border-zinc-800 bg-[#212121]">
|
||||||
|
<div class="w-[300px] border-r border-zinc-900 px-4 flex items-center justify-between">
|
||||||
|
<span class="font-bold text-zinc-400">Tempo Track</span>
|
||||||
|
<span class="bg-zinc-800 px-1.5 py-0.5 rounded text-[10px]">120 BPM</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1">...Tempo Grid Lines...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- [ZONE B] Dynamic Track Workspace (Single global vertical scrollbar on the far right) -->
|
||||||
|
<div class="flex-1 overflow-y-auto bg-[#1a1a1a]">
|
||||||
|
<div class="flex flex-col divide-y divide-[#141414]">
|
||||||
|
|
||||||
|
<!-- Track Row Container (Absolute Horizontal Row Alignment) -->
|
||||||
|
<div class="h-[96px] flex hover:bg-zinc-800/20 transition-colors">
|
||||||
|
<!-- Left: TCP -->
|
||||||
|
<div class="w-[300px] border-r border-zinc-900 p-2.5 flex-shrink-0">
|
||||||
|
...Controls (Mute, Solo, Volume, File Name)...
|
||||||
|
</div>
|
||||||
|
<!-- Right: Waveform Lane -->
|
||||||
|
<div class="flex-1 relative overflow-hidden">
|
||||||
|
...Waveform Canvas...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add more track rows repeating the structure above... -->
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2. Desktop App Integration (Python PyQt6)
|
||||||
|
|
||||||
|
When engineering this user interface using the Qt framework in Python, utilize a `QScrollArea` to encapsulate a `QWidget` managed by a layout of rows to control the single scrollbar behavior:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from PyQt6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QScrollArea, QLabel
|
||||||
|
from PyQt6.QtCore import Qt
|
||||||
|
|
||||||
|
class MasterDAWWidget(QWidget):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.main_layout = QVBoxLayout(self)
|
||||||
|
self.main_layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
self.main_layout.setSpacing(0)
|
||||||
|
|
||||||
|
# 1. Initialize Fixed Header (Toolbar, Ruler, Tempo)
|
||||||
|
self.header_widget = QWidget()
|
||||||
|
self.header_widget.setFixedHeight(76) # 32px Ruler + 44px Tempo
|
||||||
|
self.setup_header_ui()
|
||||||
|
self.main_layout.addWidget(self.header_widget)
|
||||||
|
|
||||||
|
# 2. Initialize Scroll Area for dynamic track rows
|
||||||
|
self.scroll_area = QScrollArea()
|
||||||
|
self.scroll_area.setWidgetResizable(True)
|
||||||
|
# Force a single vertical scrollbar on the far right
|
||||||
|
self.scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOn)
|
||||||
|
self.scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||||
|
|
||||||
|
# Widget container hosting the track list inside the Scroll Area
|
||||||
|
self.tracks_container = QWidget()
|
||||||
|
self.tracks_layout = QVBoxLayout(self.tracks_container)
|
||||||
|
self.tracks_layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
self.tracks_layout.setSpacing(0)
|
||||||
|
self.tracks_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
|
||||||
|
|
||||||
|
self.scroll_area.setWidget(self.tracks_container)
|
||||||
|
self.main_layout.addWidget(self.scroll_area)
|
||||||
|
|
||||||
|
def add_track_row(self, track_id, track_name):
|
||||||
|
"""
|
||||||
|
Appends a new track row. Uses QHBoxLayout to lock the TCP and Waveform Lane
|
||||||
|
into absolute horizontal sync within the row.
|
||||||
|
"""
|
||||||
|
row_widget = QWidget()
|
||||||
|
row_widget.setFixedHeight(96) # Rigid constraint for the entire row
|
||||||
|
row_layout = QHBoxLayout(row_widget)
|
||||||
|
row_layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
row_layout.setSpacing(0)
|
||||||
|
|
||||||
|
# Left: Track Control Panel (TCP)
|
||||||
|
tcp_widget = QWidget()
|
||||||
|
tcp_widget.setFixedWidth(300)
|
||||||
|
# Setup TCP UI components...
|
||||||
|
row_layout.addWidget(tcp_widget)
|
||||||
|
|
||||||
|
# Right: Waveform Lane
|
||||||
|
waveform_widget = QWidget()
|
||||||
|
# Setup Waveform Canvas Painter...
|
||||||
|
row_layout.addWidget(waveform_widget)
|
||||||
|
|
||||||
|
self.tracks_layout.addWidget(row_widget)
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Layout Architecture Advantages
|
||||||
|
|
||||||
|
* **Fluid User Experience:** Eliminates row-stuttering or scrolling layout shifts between the control panels and audio visuals when a user scrolls through long track stacks rapidly.
|
||||||
|
* **Flawless Python Porting Compatibility:** By wrapping the TCP and the Waveform Canvas inside a common row (`QHBoxLayout` in Qt or `Flex Row` in Web), the core widget tree hierarchy remains incredibly lean. This design removes the need to write custom coordinate bridging code to bind two separate scroll engines together.
|
||||||
|
* **Clean Interface Aesthetics:** Safely protects the pixel rendering mapping ratios of the fixed time grids at the top, precisely matching the professional DAW interface conventions observed
|
||||||
+182
@@ -0,0 +1,182 @@
|
|||||||
|
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:
|
||||||
|
|
||||||
|
# Technical Specification: Sub-Tab Audio Clip Editor & DSP Operations
|
||||||
|
|
||||||
|
This document outlines the software engineering specification for the temporary isolated Sub-Tab Audio Clip Editor. It defines internal clipboard mechanics, DSP algorithms for selection-based operations, Context Menu structures, and main application menu shortcuts.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Clipboard & Cursor-Aligned Insertion Mechanics
|
||||||
|
|
||||||
|
The Sub-Tab workspace features an isolated, low-latency stereo/mono audio buffer. The editor tracks a local virtual playhead position $t_{\text{cursor}}$ and handles clipboard buffers using non-destructive splicing techniques.
|
||||||
|
|
||||||
|
```text
|
||||||
|
Local Timeline Buffer
|
||||||
|
+-------------------------------------------------------+
|
||||||
|
| Track Waveform Segment │ |
|
||||||
|
+-------------------------------┼-----------------------+
|
||||||
|
▲
|
||||||
|
t_cursor (Insertion Point)
|
||||||
|
│
|
||||||
|
▼ [ PASTE TRIGGERED ]
|
||||||
|
+-------------------------------------------------------+
|
||||||
|
| Track Waveform Segment │ CLIPBOARD DATA │ |
|
||||||
|
+-------------------------------------------------------+
|
||||||
|
◄──────────────►
|
||||||
|
clip_duration
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.1. Cursor Paste Action
|
||||||
|
|
||||||
|
When a paste command is issued (either via Context Menu, Application Menu, or Hotkey):
|
||||||
|
|
||||||
|
* **Payload Extraction:** Retrieve the copied `AudioBufferSegment` from the system/application clipboard.
|
||||||
|
* **Splicing Boundary Calculations:** Slice the current active timeline buffer at $t_{\text{cursor}}$.
|
||||||
|
* **Re-allocation & Stitching:**
|
||||||
|
* Compute the new duration: $T_{\text{new}} = T_{\text{original}} + T_{\text{clipboard}}$.
|
||||||
|
* Allocate a new virtual audio array $Y_{\text{new}}$:
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
$$Y_{\text{new}}(t) = \begin{cases} Y_{\text{original}}(t) & 0 \le t < t_{\text{cursor}} \\ Y_{\text{clipboard}}(t - t_{\text{cursor}}) & t_{\text{cursor}} \le t < t_{\text{cursor}} + T_{\text{clipboard}} \\ Y_{\text{original}}(t - T_{\text{clipboard}}) & t_{\text{cursor}} + T_{\text{clipboard}} \le t \le T_{\text{new}} \end{cases}$$
|
||||||
|
|
||||||
|
* **Playhead Update:** Advance the active playhead $t_{\text{cursor}}$ immediately to $t_{\text{cursor}} + T_{\text{clipboard}}$.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Selection Context Menu & DSP Engine
|
||||||
|
|
||||||
|
Right-clicking inside a highlighted region $[T_{\text{start}}, T_{\text{end}}]$ of the Waveform Canvas triggers an overlay context menu containing the following DSP and editing commands.
|
||||||
|
|
||||||
|
```text
|
||||||
|
+---------------------------------------------+
|
||||||
|
| Selection: [ 01:02.100 - 01:05.400 ] |
|
||||||
|
+---------------------------------------------+
|
||||||
|
| Normalize Selection To Peak |
|
||||||
|
| Adjust Gain/Volume... |
|
||||||
|
| Adjust Panning (Stereo Balance)... |
|
||||||
|
| Fade In (Linear/Exponential) |
|
||||||
|
| Fade Out (Linear/Exponential) |
|
||||||
|
|---------------------------------------------|
|
||||||
|
| Cut Ctrl+X |
|
||||||
|
| Copy Ctrl+C |
|
||||||
|
| Paste Ctrl+V |
|
||||||
|
| Delete Selected Segment Del |
|
||||||
|
|---------------------------------------------|
|
||||||
|
| Loop Selection: [ ▲ ] [ 4 ] [ ▼ ] times |
|
||||||
|
+---------------------------------------------+
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.1. Normalize Selection
|
||||||
|
|
||||||
|
Scales the peak amplitude of the selected segment to a target ceiling $A_{\text{target}}$ (defaulting to $1.0$ or $0\text{ dBFS}$):
|
||||||
|
|
||||||
|
$$Y_{\text{norm}}(t) = Y(t) \cdot \frac{A_{\text{target}}}{\max_{u \in [T_{\text{start}}, T_{\text{end}}]} \vert{}Y(u)\vert{}} \quad \text{for } t \in [T_{\text{start}}, T_{\text{end}}]$$
|
||||||
|
|
||||||
|
### 2.2. Volume (Gain dB) Adjustment
|
||||||
|
|
||||||
|
Applies a static linear gain multiplier derived from user-specified decibel scaling values ($\Delta\text{dB}$):
|
||||||
|
|
||||||
|
$$G = 10^{\frac{\Delta\text{dB}}{20}}$$
|
||||||
|
|
||||||
|
$$Y_{\text{gained}}(t) = Y(t) \cdot G \quad \text{for } t \in [T_{\text{start}}, T_{\text{end}}]$$
|
||||||
|
|
||||||
|
### 2.3. Panning (Stereo Balance)
|
||||||
|
|
||||||
|
Applies a constant-power panning law across Left ($L$) and Right ($R$) channels based on the panning angle $\theta \in [0, \pi/2]$, where $\theta = \pi/4$ represents absolute center:
|
||||||
|
|
||||||
|
$$Y_L(t) = Y_{\text{mono}}(t) \cdot \cos(\theta), \quad Y_R(t) = Y_{\text{mono}}(t) \cdot \sin(\theta)$$
|
||||||
|
|
||||||
|
### 2.4. Fade-In and Fade-Out (Linear / Exponential)
|
||||||
|
|
||||||
|
* **Linear Fade-In Curve:**
|
||||||
|
|
||||||
|
$$f_{\text{in}}(t) = \frac{t - T_{\text{start}}}{T_{\text{end}} - T_{\text{start}}} \quad \text{for } t \in [T_{\text{start}}, T_{\text{end}}]$$
|
||||||
|
|
||||||
|
* **Linear Fade-Out Curve:**
|
||||||
|
|
||||||
|
$$f_{\text{out}}(t) = 1.0 - \frac{t - T_{\text{start}}}{T_{\text{end}} - T_{\text{start}}} \quad \text{for } t \in [T_{\text{start}}, T_{\text{end}}]$$
|
||||||
|
|
||||||
|
### 2.5. Delete, Cut, and Copy
|
||||||
|
|
||||||
|
* **Delete:** Erases the selected segment $[T_{\text{start}}, T_{\text{end}}]$ and shifts all subsequent samples leftward.
|
||||||
|
* **Cut:** Copies the selected samples to the clipboard, then executes the *Delete* routine.
|
||||||
|
* **Copy:** Writes the targeted buffer segment to the clip memory without modifying the timeline.
|
||||||
|
|
||||||
|
### 2.6. Segment Looping with Step Multiplier
|
||||||
|
|
||||||
|
Repeats the selected segment $[T_{\text{start}}, T_{\text{end}}]$ consecutively $N$ times. The menu provides a numeric spinner (Up/Down buttons) to adjust $N$:
|
||||||
|
|
||||||
|
1. Extract segment: $Y_{\text{segment}} = Y(t)$ for $t \in [T_{\text{start}}, T_{\text{end}}]$.
|
||||||
|
2. Compute new duration adjustment: $\Delta L = (N - 1) \cdot (T_{\text{end}} - T_{\text{start}})$.
|
||||||
|
3. Duplicate and insert $Y_{\text{segment}}$ array $N-1$ times directly after $T_{\text{end}}$.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Global Menu Bar & Keyboard Shortcut Matrix
|
||||||
|
|
||||||
|
All context-dependent sub-tab actions are mapped directly to the global Menu Bar at the top of the DAW window, as specified in `image_e0e462.png`.
|
||||||
|
|
||||||
|
```text
|
||||||
|
File Edit View Insert Track Options Actions Extensions Help
|
||||||
|
│
|
||||||
|
├── Normalize Selection [Ctrl+Alt+N]
|
||||||
|
├── Adjust Volume... [V]
|
||||||
|
├── Adjust Panning... [P]
|
||||||
|
├── Fade In [F]
|
||||||
|
├── Fade Out [G]
|
||||||
|
├── Cut [Ctrl+X]
|
||||||
|
├── Copy [Ctrl+C]
|
||||||
|
├── Paste [Ctrl+V]
|
||||||
|
├── Delete [Del]
|
||||||
|
└── Loop Clip... [Ctrl+L]
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.1. Keyboard Mapping Table
|
||||||
|
|
||||||
|
To maximize speed and accessibility, the system listens for global key event hooks within the Sub-Tab window focus:
|
||||||
|
|
||||||
|
| Action Command | Main Menu Category | Recommended Keyboard Shortcut | Python Event Trigger (`QKeyEvent`) |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| **Cut** | Edit -> Cut | `Ctrl + X` | `Qt.Key.Key_X` + `ControlModifier` |
|
||||||
|
| **Copy** | Edit -> Copy | `Ctrl + C` | `Qt.Key.Key_C` + `ControlModifier` |
|
||||||
|
| **Paste** | Edit -> Paste | `Ctrl + V` | `Qt.Key.Key_V` + `ControlModifier` |
|
||||||
|
| **Delete** | Edit -> Delete | `Del` / `Backspace` | `Qt.Key.Key_Delete` / `Key_Backspace` |
|
||||||
|
| **Normalize** | Actions -> Normalize | `Ctrl + Alt + N` | `Qt.Key.Key_N` + `ControlModifier` + `AltModifier` |
|
||||||
|
| **Fade In** | Actions -> Fade In | `F` | `Qt.Key.Key_F` |
|
||||||
|
| **Fade Out** | Actions -> Fade Out | `G` | `Qt.Key.Key_G` |
|
||||||
|
| **Loop Segment** | Actions -> Loop... | `Ctrl + L` | `Qt.Key.Key_L` + `ControlModifier` |
|
||||||
|
| **Adjust Volume** | Actions -> Gain... | `V` | `Qt.Key.Key_V` |
|
||||||
|
| **Adjust Panning** | Actions -> Panning... | `P` | `Qt.Key.Key_P` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Python Implementation Notes for Docker Server Porting
|
||||||
|
|
||||||
|
When porting these sub-tab operations to your Python DSP engine (`core/audio_editor.py`), use NumPy slice vectors to perform non-destructive edits on waveforms:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Prototype helper for non-destructive volume adjustment in Python
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
def apply_gain_on_segment(y: np.ndarray, sr: int, start_sec: float, end_sec: float, gain_db: float) -> np.ndarray:
|
||||||
|
"""
|
||||||
|
Applies gain in dB to a selected segment of a mono numpy audio array.
|
||||||
|
"""
|
||||||
|
# 1. Translate time coordinates securely with boundary checking
|
||||||
|
start_sample = max(0, int(start_sec * sr))
|
||||||
|
end_sample = min(len(y), int(end_sec * sr))
|
||||||
|
|
||||||
|
# 2. Convert dB value to linear multiplier
|
||||||
|
multiplier = 10.0 ** (gain_db / 20.0)
|
||||||
|
|
||||||
|
# 3. Create a deep copy and modify segment in-place
|
||||||
|
y_edited = np.copy(y)
|
||||||
|
y_edited[start_sample:end_sample] *= multiplier
|
||||||
|
|
||||||
|
return y_edited
|
||||||
|
|
||||||
|
```
|
||||||
+1835
-172
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,303 @@
|
|||||||
|
<div
|
||||||
|
className="flex-1 flex overflow-hidden select-none daw-bg relative"
|
||||||
|
style={{ display: activeTab !== 'main' ? 'none' : '' }}
|
||||||
|
>
|
||||||
|
{/* ══ LEFT COLUMN: TCP PANEL ══ */}
|
||||||
|
<div
|
||||||
|
ref={tcpContainerRef}
|
||||||
|
className="w-[300px] shrink-0 relative z-20 bg-[#262626] overflow-hidden flex flex-col border-r border-zinc-900"
|
||||||
|
>
|
||||||
|
<span className="text-[10px] font-bold text-zinc-500 uppercase shrink-0 mr-2">Kênh</span>
|
||||||
|
|
||||||
|
{/* Timeline Toolbar */}
|
||||||
|
<div className="flex items-center gap-0.5 bg-zinc-900 border border-zinc-800 rounded px-1 py-0.5 shadow-sm">
|
||||||
|
<button
|
||||||
|
onClick={() => { setActiveTool('select'); showToast('Công cụ chọn (Select Tool) đã kích hoạt.', 'info'); }}
|
||||||
|
className={`p-1 rounded transition text-xs flex items-center justify-center ${activeTool === 'select' ? 'bg-cyan-950/80 text-cyan-400 border border-cyan-800/50 font-bold' : 'text-zinc-400 hover:bg-zinc-800 border border-transparent'}`}
|
||||||
|
title="Select Tool: Chọn khoảng, Đặt playhead (Giữ Alt kéo để di chuyển nhanh clip)"
|
||||||
|
>
|
||||||
|
<i data-lucide="mouse-pointer" className="w-3 h-3"></i>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { setActiveTool('grab'); showToast('Công cụ di chuyển (Hand Tool) đã kích hoạt.', 'info'); }}
|
||||||
|
className={`p-1 rounded transition text-xs flex items-center justify-center ${activeTool === 'grab' ? 'bg-cyan-950/80 text-cyan-400 border border-cyan-700/50 font-bold' : 'text-zinc-400 hover:bg-zinc-800 border border-transparent'}`}
|
||||||
|
title="Grab Tool: Click kéo trực tiếp để di chuyển clip"
|
||||||
|
>
|
||||||
|
<i data-lucide="hand" className="w-3 h-3"></i>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { setActiveTool('razor'); showToast('Công cụ chia đoạn (Razor Tool) đã kích hoạt.', 'info'); }}
|
||||||
|
className={`p-1 rounded transition text-xs flex items-center justify-center ${activeTool === 'razor' ? 'bg-cyan-950/80 text-cyan-400 border border-cyan-700/50 font-bold' : 'text-zinc-400 hover:bg-zinc-800 border border-transparent'}`}
|
||||||
|
title="Razor Tool: Click trên clip để chia nhỏ tại điểm click"
|
||||||
|
>
|
||||||
|
<svg className="w-3 h-3 text-orange-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M6 3h12a2 2 0 0 1 2 2v2a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z"/>
|
||||||
|
<path d="M4 9h16l-3 9H7z"/>
|
||||||
|
<circle cx="12" cy="6" r="1"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Separator */}
|
||||||
|
<div className="w-[1px] h-3 bg-zinc-800 mx-0.5"></div>
|
||||||
|
|
||||||
|
{/* Quick Actions (Glue, Cut, Copy, Paste) */}
|
||||||
|
<button
|
||||||
|
onClick={handleGlueTracks}
|
||||||
|
className="p-0.5 rounded text-zinc-400 hover:bg-zinc-800 hover:text-purple-400 transition"
|
||||||
|
title="Glue: Gộp track hiện tại với track liền dưới"
|
||||||
|
>
|
||||||
|
<i data-lucide="link" className="w-3 h-3"></i>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleCutTrack}
|
||||||
|
className="p-0.5 rounded text-zinc-400 hover:bg-zinc-800 hover:text-red-400 transition"
|
||||||
|
title="Cut Clip (Ctrl+X)"
|
||||||
|
>
|
||||||
|
<i data-lucide="scissors" className="w-3 h-3"></i>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleCopyTrack}
|
||||||
|
className="p-0.5 rounded text-zinc-400 hover:bg-zinc-800 hover:text-blue-400 transition"
|
||||||
|
title="Copy Clip (Ctrl+C)"
|
||||||
|
>
|
||||||
|
<i data-lucide="copy" className="w-3 h-3"></i>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handlePasteTrack}
|
||||||
|
className="p-0.5 rounded text-zinc-400 hover:bg-zinc-800 hover:text-emerald-400 transition"
|
||||||
|
title="Paste Clip (Ctrl+V)"
|
||||||
|
>
|
||||||
|
<i data-lucide="clipboard" className="w-3 h-3"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Snap Section */}
|
||||||
|
<div className="w-[1px] h-3 bg-zinc-800 mx-0.5"></div>
|
||||||
|
<div className="flex items-center gap-1 pl-0.5 select-none">
|
||||||
|
<span className="text-[9px] text-zinc-500 font-bold uppercase">Snap</span>
|
||||||
|
<select
|
||||||
|
value={snapValue}
|
||||||
|
onChange={(e) => setSnapValue(e.target.value)}
|
||||||
|
className="bg-zinc-850 text-zinc-300 text-[10px] px-1 py-0.5 rounded border border-zinc-800 focus:outline-none focus:border-cyan-550 font-mono"
|
||||||
|
>
|
||||||
|
<option value="free">Free</option>
|
||||||
|
<option value="1">1</option>
|
||||||
|
<option value="1/2">1/2</option>
|
||||||
|
<option value="1/4">1/4</option>
|
||||||
|
<option value="1/8">1/8</option>
|
||||||
|
<option value="1/16">1/16</option>
|
||||||
|
<option value="1/32">1/32</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Right Side: Time Ruler */}
|
||||||
|
<div ref={rulerRef} className="flex-1 relative h-full flex items-center select-none cursor-ew-resize overflow-hidden" onMouseDown={handleRulerMouseDown}>
|
||||||
|
{Array.from({ length: Math.ceil(maxDuration) }).map((_, i) => {
|
||||||
|
const sec = i;
|
||||||
|
const x = sec * zoom;
|
||||||
|
return (
|
||||||
|
<div key={i} className="absolute h-full border-l border-zinc-800 pl-1 pt-1 text-[9px] font-mono text-zinc-500 pointer-events-none" style={{ left: `${x}px` }}>
|
||||||
|
{formatTime(sec)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* [ZONE A] Tempo Track Row */}
|
||||||
|
<div className="sticky top-8 z-30 flex h-[40px] border-b border-purple-500/30 bg-[#1a1a2e] shrink-0">
|
||||||
|
{/* Left Side: Tempo TCP */}
|
||||||
|
<div className="w-[300px] sticky left-0 z-50 bg-[#1a1a2e] border-r border-zinc-900 p-2 flex flex-col justify-between border-l-4 border-purple-500 shrink-0">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-[10px] font-bold text-purple-400 font-mono">TM</span>
|
||||||
|
<span className="text-xs font-semibold text-zinc-300">Tempo Track</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={bpm}
|
||||||
|
onChange={(e) => setBpm(e.target.value)}
|
||||||
|
onBlur={() => localStorage.setItem('studio_bpm', bpm)}
|
||||||
|
className="w-12 bg-zinc-800 border border-zinc-700 rounded text-[10px] text-zinc-300 text-center font-mono focus:outline-none focus:border-purple-500"
|
||||||
|
min="40"
|
||||||
|
max="300"
|
||||||
|
/>
|
||||||
|
<span className="text-[9px] text-zinc-500">BPM</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Right Side: Tempo Lane */}
|
||||||
|
<div className="flex-1 relative h-full overflow-hidden bg-[#1a1a2e]">
|
||||||
|
<TempoTrackLane bpm={parseInt(bpm) || 120} zoom={zoom} timelineWidth={timelineWidth}
|
||||||
|
onPlayheadSet={setCurrentTime} snapValue={snapValue} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* [ZONE B] Dynamic Track List Workspace */}
|
||||||
|
<div className="flex-1 flex flex-col divide-y divide-[#141414] relative bg-[#111111] min-h-full">
|
||||||
|
{tracks.map((track, idx) => {
|
||||||
|
const isSelected = selectedTrackId === track.id;
|
||||||
|
return (
|
||||||
|
<div key={track.id} className={`h-[96px] flex hover:bg-zinc-850/5 transition-colors ${isSelected ? 'bg-zinc-800/10' : ''}`}>
|
||||||
|
{/* Left Column: TCP */}
|
||||||
|
<div
|
||||||
|
onClick={() => setSelectedTrackId(track.id)}
|
||||||
|
className={`w-[300px] sticky left-0 z-10 p-2.5 flex flex-col justify-between bg-[#1e1e1e] border-r border-zinc-900 shrink-0 cursor-pointer border-l-4 overflow-hidden ${isSelected ? 'border-cyan-500 bg-[#252525]' : 'border-transparent hover:bg-zinc-800/20'}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-[10px] font-bold text-zinc-500 font-mono">{(idx+1).toString().padStart(2, '0')}</span>
|
||||||
|
<div className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: track.color }} />
|
||||||
|
<span className="text-xs font-semibold text-zinc-300 truncate max-w-[120px]" title={track.name}>
|
||||||
|
{track.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); toggleTrackMute(track.id); }}
|
||||||
|
className={`px-1.5 py-0.5 text-[10px] rounded font-mono font-bold border transition ${
|
||||||
|
track.muted
|
||||||
|
? 'bg-red-950 text-red-400 border-red-700'
|
||||||
|
: 'bg-zinc-800 text-zinc-400 border-transparent hover:text-zinc-200'
|
||||||
|
}`}
|
||||||
|
>M</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); toggleTrackSoloEvaluate(track.id); }}
|
||||||
|
className={`px-1.5 py-0.5 text-[10px] rounded font-mono font-bold border transition ${
|
||||||
|
soloedTrackId === track.id || track.solo
|
||||||
|
? 'bg-amber-950 text-amber-400 border-amber-600'
|
||||||
|
: 'bg-zinc-800 text-zinc-400 border-transparent hover:text-zinc-200'
|
||||||
|
}`}
|
||||||
|
title="Solo nghe thử"
|
||||||
|
>S</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1 text-[9px] text-zinc-400" onClick={e => e.stopPropagation()}>
|
||||||
|
<span className="font-semibold uppercase text-[8px] text-zinc-500">Mô phỏng:</span>
|
||||||
|
<button
|
||||||
|
onClick={() => generateSynthToTrack(track.id, 'kick')}
|
||||||
|
className="px-1 bg-zinc-800 hover:bg-zinc-700 rounded border border-zinc-700"
|
||||||
|
>Kick Drum</button>
|
||||||
|
<button
|
||||||
|
onClick={() => generateSynthToTrack(track.id, 'synth')}
|
||||||
|
className="px-1 bg-zinc-800 hover:bg-zinc-700 rounded border border-zinc-700"
|
||||||
|
>Arpeggiator</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between gap-2" onClick={e => e.stopPropagation()}>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<VolumeKnob value={track.volume} onChange={(v) => updateTrackVolume(track.id, v)} />
|
||||||
|
<span className="text-[10px] font-mono text-zinc-500">Gain: {Math.round(track.volume * 100)}%</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
id={`upload-${track.id}`}
|
||||||
|
accept="audio/*"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => loadFileOnTrack(track.id, e.target.files[0])}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor={`upload-${track.id}`}
|
||||||
|
className="px-2 py-1 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded text-[10px] flex items-center gap-1 cursor-pointer transition border border-zinc-700"
|
||||||
|
>
|
||||||
|
<i data-lucide="upload" className="w-3 h-3"></i> Tải file
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Column: Waveform Lane */}
|
||||||
|
<div
|
||||||
|
className="flex-1 relative overflow-hidden h-full"
|
||||||
|
onDragOver={(e) => e.preventDefault()}
|
||||||
|
onDrop={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (e.dataTransfer.files[0]) {
|
||||||
|
loadFileOnTrack(track.id, e.dataTransfer.files[0]);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => setHoveredTrackId(track.id)}
|
||||||
|
>
|
||||||
|
<WaveformLane track={track} zoom={zoom} timelineWidth={timelineWidth}
|
||||||
|
onSelectRange={handleSelectRange} onPlayheadSet={setCurrentTime}
|
||||||
|
isSelected={isSelected} onSelectTrack={setSelectedTrackId} markers={track.markers}
|
||||||
|
onTrackLaneMouseDown={handleTrackLaneMouseDown}
|
||||||
|
onContextMenu={handleContextMenu}
|
||||||
|
onClipDragStart={handleClipDragStart}
|
||||||
|
activeTool={activeTool}
|
||||||
|
onSplitTrackAtTime={handleSplitTrackAtTime}
|
||||||
|
snapValue={snapValue}
|
||||||
|
bpm={bpm}
|
||||||
|
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">
|
||||||
|
<button onClick={() => handleSplitTrack(track.id)}
|
||||||
|
className="px-1.5 py-0.5 bg-zinc-900/95 text-zinc-300 rounded text-[9px] flex items-center gap-1 border border-zinc-700/50"
|
||||||
|
title="Cắt đoạn tại Playhead">
|
||||||
|
<i data-lucide="scissors" className="w-2.5 h-2.5 text-cyan-400"></i> Cắt
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Bottom Drop Zone to create new track */}
|
||||||
|
<div className="h-[48px] flex border-t border-dashed border-zinc-800">
|
||||||
|
<div className="w-[300px] sticky left-0 z-10 bg-[#1e1e1e]/50 border-r border-zinc-900 shrink-0"></div>
|
||||||
|
<div
|
||||||
|
className="flex-1 flex items-center justify-center text-xs text-zinc-500 hover:bg-zinc-900/20 cursor-pointer select-none"
|
||||||
|
onMouseEnter={() => {
|
||||||
|
if (draggedClipRef.current) {
|
||||||
|
const newId = addNewTrack();
|
||||||
|
setHoveredTrackId(newId);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onClick={addNewTrack}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-1 text-zinc-400">
|
||||||
|
<i data-lucide="plus" className="w-3.5 h-3.5"></i> Kéo clip xuống đây hoặc Click để tạo Track mới
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Selection Overlay - only for Global mode (LOOP_EDITOR.md §1.2: local draws on canvas per-track) */}
|
||||||
|
{selectionMode !== 'local' && selLeft !== null && selRight !== null && selRight > selLeft && (
|
||||||
|
<div className="absolute top-0 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 + 300}px`, width: `${(selRight - selLeft) * zoom}px` }}
|
||||||
|
onMouseDown={handleSelectionBodyDragStart}
|
||||||
|
title="Kéo để di chuyển vùng chọn"
|
||||||
|
>
|
||||||
|
<div className="absolute -left-1.5 top-0 bottom-0 w-3 bg-amber-500 hover:bg-amber-400 cursor-ew-resize flex items-center justify-center z-30 transition-colors"
|
||||||
|
onMouseDown={(e) => handleHandleDragStart(e, 'left')}
|
||||||
|
title="Kéo giãn mốc bắt đầu">
|
||||||
|
<div className="w-[1.5px] h-4 bg-zinc-950/70 rounded"></div>
|
||||||
|
</div>
|
||||||
|
<div className="absolute -right-1.5 top-0 bottom-0 w-3 bg-amber-500 hover:bg-amber-400 cursor-ew-resize flex items-center justify-center z-30 transition-colors"
|
||||||
|
onMouseDown={(e) => handleHandleDragStart(e, 'right')}
|
||||||
|
title="Kéo giãn mốc kết thúc">
|
||||||
|
<div className="w-[1.5px] h-4 bg-zinc-950/70 rounded"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Playhead */}
|
||||||
|
<div className="absolute top-0 bottom-0 w-[2px] bg-[#ef4444] z-20 pointer-events-none"
|
||||||
|
style={{ left: `${playheadLeftPos + 300}px` }}>
|
||||||
|
<div className="w-3 h-3 bg-[#ef4444] rotate-45 transform -translate-x-1/2 -translate-y-1/2 absolute top-0"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Footer ── */}
|
||||||
Reference in New Issue
Block a user