fix: time selection click and shiftclick
This commit is contained in:
@@ -0,0 +1,72 @@
|
|||||||
|
Here is the complete document converted into a clean, professionally formatted Markdown layout, with fully optimized math expressions and standardized structures:
|
||||||
|
|
||||||
|
# Analysis & Bug Fix Guide: Hybrid DSP Architecture & Shift+Click Selection Algorithms
|
||||||
|
|
||||||
|
This document clarifies the execution boundaries of real-time audio monitoring (Real-time Preview) between the workstation (Client) and the server (Docker Server). It exposes the root cause of the "Shift + Click" selection range failure and provides a direct solution on the client-side.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Technical Q&A (Zoom-In & Hybrid Model)
|
||||||
|
|
||||||
|
### 1.1. Is it necessary to process audio vectors directly on the Client machine like Reaper or Sound Forge?
|
||||||
|
|
||||||
|
* **Answer:** Absolutely necessary ($100\%$) for **Visual Rendering**.
|
||||||
|
* **Reason:** When zooming deeply to observe individual granular phase fluctuations (**Sample Nodes**), the browser must have direct access to the raw binary array (`Float32Array`) stored in the client's RAM.
|
||||||
|
* **The Pitfall of Server-side Rendering:** If a "server-side render and push image" approach is used, the system will suffer from image blurring and network latency ($100\text{ms} \rightarrow 500\text{ms}$) during high-speed zooming or scrubbing. Decoding the file once via the Web Audio API (`AudioContext.decodeAudioData()`) on the Frontend is the industry-standard DAW solution to unlock instantaneous vector rendering at $60\text{ FPS} \rightarrow 120\text{ FPS}$ directly inside the browser.
|
||||||
|
|
||||||
|
### 1.2. Can a hybrid web application match the performance of a native desktop application?
|
||||||
|
|
||||||
|
Yes, it can execute seamlessly provided there is a clean, structured separation of roles (**Symmetrical Hybrid Separation**):
|
||||||
|
|
||||||
|
* **Client (HTML5/Web Audio/WASM):** Handles low-latency user interface interactions. This includes reading sample arrays to paint waveforms, tracking the playhead line, defining selection ranges, and driving real-time preview monitoring filters using Web Audio Nodes or WebAssembly.
|
||||||
|
* **Server (Dockerized Python):** Executes heavy rendering blocks and exports studio-grade master files. This includes multi-track mixdowns, loading genuine VST3 plugin chains via a C++ core framework (e.g., `Pedalboard`), and processing complex AI models. When changes occur, the frontend simply dispatches a lightweight JSON configuration package (**Metadata**) back to the server for asynchronous rendering, bypassing audio streaming bottlenecks.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Root Causes of the "Shift + Click Selection" Defect
|
||||||
|
|
||||||
|
Many AI Code Agents fail or struggle when programming this interaction loop because of several fundamental flaws:
|
||||||
|
|
||||||
|
* **Audio Waveforms on Canvas Lack DOM Nodes:** Unlike standard HTML texts where double-clicking or `Shift + Click` selections can be tracked natively between text tags, audio waveforms are flattened onto a raw `<canvas>` element. Mouse clicks only return physical pixel coordinates ($X$). Agents frequently omit the coordinate translation logic needed to map pixels back into absolute timeline seconds:
|
||||||
|
|
||||||
|
$$T = \frac{X_{\text{pixel}} + \text{scrollLeft}}{\text{Zoom}}$$
|
||||||
|
|
||||||
|
|
||||||
|
* **Event Listener Collision:** In DAW workflows, the primary mouse-down trigger (`onMouseDown`) over a track lane handles multiple overlapping roles: updating playhead placement, dragging audio clips, dragging perimeters for time-stretching, and dragging to create selection windows. When a user executes a `Shift + Click` interaction, if default behaviors are not explicitly blocked via `e.preventDefault()` and `e.stopPropagation()`, the system misinterprets the gesture as a playhead reset or a clip drag event, instantly destroying the existing selection.
|
||||||
|
* **Missing Anchor Point Tracking:** For `Shift + Click` to scale a region properly, the application must persistently cache an **Anchor Point** variable in memory:
|
||||||
|
* **Click 1 (Initial Focus):** Sets the bounding anchor milestone (e.g., $T_{\text{start}}$).
|
||||||
|
* **Shift + Click 2 (Extension):** Locks the anchor milestone and assigns a new dynamic timestamp parameter ($T_{\text{end}}$) to the secondary click coordinate.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Shift + Click Interaction Selection Algorithm
|
||||||
|
|
||||||
|
This interaction sequence is implemented by intercepting the state of the modifier parameter `e.shiftKey` inside the click handler logic for both the track lanes (localized selection—Local) and the timeline ruler (global master selection—Global).
|
||||||
|
|
||||||
|
### 3.1. Mouse Event Control Logic Schema
|
||||||
|
|
||||||
|
```text
|
||||||
|
[ MOUSE PRESS EVENT ON CANVAS / RULER ]
|
||||||
|
│
|
||||||
|
┌───────────────┴───────────────┐
|
||||||
|
▼ (Is Shift Key Active?) ▼ (Shift Key Inactive)
|
||||||
|
[ SHIFT + CLICK LOGIC ] [ STANDARD CLICK LOGIC ]
|
||||||
|
- Lock the existing Anchor point - Instantiate a new Anchor = Click Time
|
||||||
|
- Map new Click Time = End Time - Prepare Drag state for new region draw
|
||||||
|
- Refresh selection overlay color - Update Playhead location
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2. Implementation Blueprint
|
||||||
|
|
||||||
|
Update your `index.html` source script with the following event mapping rules:
|
||||||
|
|
||||||
|
#### **At the Waveform Lane Viewport:**
|
||||||
|
|
||||||
|
When a mouse press is detected, evaluate `e.shiftKey`. If `true`, lock the initial boundary position from the existing selection (`localSelLeft`) as the anchor point. If no selection is present, fallback to the current playhead position (`currentTime`). Then, assign the calculated timeline position of the new click event to override the secondary boundary marker (`localSelectionEnd`).
|
||||||
|
|
||||||
|
#### **At the Time Ruler Track:**
|
||||||
|
|
||||||
|
Mirror the exact same bounding logic block to manage the global system selection layer (`selectionStart` and `selectionEnd`), enabling users to stretch or compress the global transport loop constraints efficiently.
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
# Technical Directive Manual & Architectural Standards: SonicForge Studio
|
||||||
|
|
||||||
|
This document serves as the supreme and mandatory technical standard for all AI Code Agents engaged in the development, maintenance, or refactoring of the SonicForge Studio codebase. The directives below are established to completely eliminate arbitrary inferences (hallucinations), ensuring the mathematical integrity of Digital Signal Processing (DSP) and professional-grade DAW graphical layouts.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Critical Directives for AI Agents
|
||||||
|
|
||||||
|
* **No Arbitrary Rewrites:** Absolutely do not alter the foundational architecture of waveform rendering loops, marker anchor management systems, or Web Audio API routing networks unless explicitly instructed.
|
||||||
|
* **Preserve DSP Math:** Symmetrically retain all trigonometric equations, Cubic Hermite Splines, Constant-Power Panning constraints, and zero-crossing detection routines within source files. A structural deviation of even a single sample ($1\text{ sample}$) constitutes a critical production failure.
|
||||||
|
* **Strict UI Alignment:** All graphical modulations must cleanly conform to specified spatial layout grids, dimensions, and hex color tokens.
|
||||||
|
* **Zero Spurious Scrollbars:** Prevent internal horizontal scrollbar generation inside the left Track Control Panel (TCP) container at all costs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. UI & Layout Refactoring Specifications
|
||||||
|
|
||||||
|
To eliminate vertical row desynchronization and layout overlaps during timeline scrubbing or zooming operations, all rendering passes must strictly conform to the following nested architecture:
|
||||||
|
|
||||||
|
### 2.1. Unified Row Layout — Fixing Vertical Misalignment
|
||||||
|
|
||||||
|
* **Strict Grid Containment:** Independent scrolling columns for track controls and waveforms are strictly prohibited.
|
||||||
|
* **Row Lock:** Every unique channel track must be bundled inside a single parent **Unified Track Row** container framework (Flex Row or Grid Row) enforcing a rigid vertical constraint ($H = 96\text{ px}$).
|
||||||
|
* **Single Scrollbar Mandate:** The layout must expose exactly one global vertical scrollbar on the far right of the viewport container. This scrollbar controls the entire track stack workspace simultaneously, forcing the TCP decks and waveform canvas viewports to slide along the $Y$-axis in perfect physical synchronization.
|
||||||
|
|
||||||
|
### 2.2. Graphical Overlap Containment Mechanics
|
||||||
|
|
||||||
|
* **TCP Isolation:** The left TCP channel block requires a rigid width lock at $300\text{ px}$, `flex-shrink: 0`, and a solid background color (`background-color: #262626`). It must be explicitly configured with `overflow: hidden` to block internal horizontal overflow scrollbars.
|
||||||
|
* **Z-Index Layering:** Assign an elevated layout layer profile (`position: relative`, `z-index: 20`) to the TCP column. When the right timeline area scrolls horizontally to the left, all waveform graphics, grid line divisions, and the absolute playback playhead line must scroll seamlessly beneath the solid TCP masking layer.
|
||||||
|
|
||||||
|
### 2.3. Dynamic Min-Zoom Constraint Specification
|
||||||
|
|
||||||
|
* **Viewport Boundary Alignment:** When executing a macro zoom-out operation, the comprehensive project arrangement length—stretching from $0.00\text{ s}$ out to the termination milestone ($T_{\text{max}}$)—must fit perfectly within the visible horizontal frame width ($W_{\text{viewport}}$).
|
||||||
|
* **Dynamic Bounds Calculation:** The layout manager must dynamically calculate the bounding minimum scale factor ($Z_{\text{min}}$) before updating drawing buffers:
|
||||||
|
|
||||||
|
$$Z_{\text{min}} = \frac{W_{\text{viewport}}}{T_{\text{max}}}$$
|
||||||
|
|
||||||
|
|
||||||
|
* **Clamping Rule:** Under no circumstances can the active zoom factor $Z$ drop below the $Z_{\text{min}}$ threshold. Enforcing this clamping boundary blocks the generation of dead black voids on the right side of shorter clips and prevents spurious scrollbar scaling artifacts.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Microscopic Viewport Waveform Painting (Ultra-Zoom Render Modes)
|
||||||
|
|
||||||
|
Whenever a user zooms deeply onto the timeline canvas to analyze microscopic phase movements, the canvas engine automatically swaps its calculation loop routines based on the instantaneous visible sample density profile ($\text{samplesPerPixel}$):
|
||||||
|
|
||||||
|
```text
|
||||||
|
SAMPLES PER PIXEL DENSITY SPECTRUM
|
||||||
|
[Samples/px ≥ 4] ──────────────────────► Peak Waveform (Symmetrical Vertical Min/Max bars)
|
||||||
|
[1.5 ≤ Samples/px < 4] ────────────────► Continuous Polyline (Light Cyan Sine Path)
|
||||||
|
[Samples/px < 1.5] ────────────────────► Discrete Sample Nodes (Green Emerald Nodes + Polyline)
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.1. Peak Compression Mode ($\text{samplesPerPixel} \ge 4$ — `image_5ec2e5.png`)
|
||||||
|
|
||||||
|
* **Waveform Envelopes:** Renders a high-density, symmetrical downsampled waveform graphic. The engine reads localized segment buffers to connect absolute maximum (Max) and minimum (Min) sample peaks passing through identical pixel columns using clean vertical line strokes.
|
||||||
|
|
||||||
|
### 3.2. Single Continuous Polyline & Node Mode ($\text{samplesPerPixel} < 4$)
|
||||||
|
|
||||||
|
* **Continuous Polyline:** Transitions away from vertical peak columns to compile a fine, anti-aliased single continuous vector polyline tracking raw values in professional cornflower blue (`#5bc0be`). The translation maps absolute sample addresses to physical drawing coordinates $(X_i, Y_i)$:
|
||||||
|
|
||||||
|
$$X_i = \left( \frac{i}{f_s} \right) \times Z - \text{scrollLeft}, \quad Y_i = \text{mid}_Y + x[i] \cdot \left( \text{height} \times 0.42 \right)$$
|
||||||
|
|
||||||
|
|
||||||
|
* **Discrete Sample Nodes ($\text{samplesPerPixel} < 1.5$):** Overlays luminous green emerald circle markers (`#6ee7b7`) with a rigid radius $r = 2\text{ px}$ directly centered over every sample index coordinate $(X_i, Y_i)$. To prevent GPU thread thrashing and rendering lag, point nodes are only drawn if the horizontal pixel spacing between adjacent nodes satisfies a $\ge 4\text{ px}$ width threshold.
|
||||||
|
* **Logarithmic Amplitude Grid:** Projects thin, low-contrast background horizontal marker grids to establish clear visible decibel tracking boundaries: a positive upper peak grid at $+6.0\text{ dB}$ (or $0\text{ dBFS}$), a true horizontal identity zero-line axis at $-\infty\text{ dB}$ ($0\text{V}$ absolute silence), and a negative lower sub-grid line at $-6.0\text{ dB}$.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Selection Ranges & Modifier Input Mechanics
|
||||||
|
|
||||||
|
### 4.1. Persistent Anchor Point Tracking Refs
|
||||||
|
|
||||||
|
* **State Preservation:** To ensure that horizontal selection boundaries are never discarded or cleared when UI frameworks trigger background state refresh cycles, the coordinate calculation loops must persistently cache initial interaction milestones inside non-reactive memory Refs:
|
||||||
|
* *Main Session Workspace:* Employs `localSelectionAnchorRef` to monitor channel track selections, and `rulerAnchorRef` to track global time loops on the ruler.
|
||||||
|
* *Sub-Tab Sandbox Workspace:* Locks anchor coordinate data inside `subTabAnchorRef`.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### 4.2. Shift + Click Selection Range Adjustment Algorithm
|
||||||
|
|
||||||
|
When intercepting a primary mouse-down event (`onMouseDown`) where the `Shift` modifier is explicitly engaged (`e.shiftKey === true`), the tracking framework must execute the following sequence:
|
||||||
|
|
||||||
|
1. **Event Interception:** Immediately call `e.preventDefault()` and `e.stopPropagation()`. This blocks the thread, halting automatic playhead relocation or clip dragging sequences.
|
||||||
|
2. **Anchor Extraction:** Extract the absolute timestamp cached inside the target workspace Ref ($T_{\text{anchor}}$). If the reference object is unpopulated, write the active playback playhead timestamp (`currentTime`) to act as the fallback anchor milestone.
|
||||||
|
3. **Boundary Translation:** Convert the new cursor coordinate column pixel position into absolute timeline seconds to define the moving boundary marker ($T_{\text{end}}$).
|
||||||
|
4. **Range Construction:** Update the highlighted selection envelope parameters to encapsulate the full calculated interval:
|
||||||
|
|
||||||
|
$$\text{Selection Range} = [\min(T_{\text{anchor}}, T_{\text{end}}), \max(T_{\text{anchor}}, T_{\text{end}})]$$
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### 4.3. Transport Loop Constraints & Escape Hook
|
||||||
|
|
||||||
|
* **Strict Loop Lock:** When a selection window $[T_{\text{start}}, T_{\text{end}}]$ is engaged alongside loop playback mode, the transport playhead can never drift past $T_{\text{end}}$. Upon reaching the $T_{\text{end}}$ index, the audio thread must instantly trigger an immediate, gapless reset back to $T_{\text{start}}$.
|
||||||
|
* **Escape Hook:** To clear selection boundaries and return the engine to standard non-repeating tracking, the user executes a `Ctrl + Click` shortcut combo over an unpopulated workspace area. Once the selection ranges are nullified, pressing the `Spacebar` drives continuous, linear playback past the old loop constraints.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Non-Linear Graphical Automation Envelopes
|
||||||
|
|
||||||
|
The application upgrades static, linear layout components using the following signal processing algorithms:
|
||||||
|
|
||||||
|
### 5.1. Volume Automation Spline (Monotone Cubic Hermite Spline)
|
||||||
|
|
||||||
|
To connect peach-colored volume nodes smoothly without inducing artificial overshoot peaks, the system runs a 3rd-order monotone cubic interpolation framework:
|
||||||
|
|
||||||
|
|
||||||
|
$$y(t) = (2t^3 - 3t^2 + 1)y_1 + (t^3 - 2t^2 + t)h \cdot m_1 + (-2t^3 + 3t^2)y_2 + (t^3 - t^2)h \cdot m_2$$
|
||||||
|
|
||||||
|
|
||||||
|
Where $h = t_2 - t_1$, and the localized tangents ($m_1, m_2$) are evaluated via the Fritsch-Carlson configuration method to preserve strict mathematical monotonicity across the curve.
|
||||||
|
|
||||||
|
### 5.2. Boundary Fade Contours (Trigonometric Cosine S-Curve)
|
||||||
|
|
||||||
|
The physical curvature profile of the deep red fade envelopes is derived via trigonometric functions to protect structural transient integrity at the clips boundaries:
|
||||||
|
|
||||||
|
|
||||||
|
$$f_{\text{in}}(t) = \frac{1 - \cos\left( \pi \cdot \frac{t}{L_{\text{fade}}} \right)}{2}, \quad f_{\text{out}}(t) = \frac{1 + \cos\left( \pi \cdot \frac{t - (T_{\text{max}} - L_{\text{fade}})}{L_{\text{fade}}} \right)}{2}$$
|
||||||
|
|
||||||
|
### 5.3. Constant-Power Stereo Panning Law
|
||||||
|
|
||||||
|
To eliminate spatial perceived volume collapse (*Center Dip*) when moving signals across Left ($L$) and Right ($R$) drivers, the cumulative output sound field energy must remain perfectly preserved at unity ($1.0$) across all panning trajectories:
|
||||||
|
|
||||||
|
|
||||||
|
$$\theta(t) = \frac{p(t) + 1}{2} \cdot \frac{\pi}{2}, \quad g_L(t) = \cos(\theta(t)), \quad g_R(t) = \sin(\theta(t))$$
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Isolated Sandbox Sub-Tab Workspace & Synchronization
|
||||||
|
|
||||||
|
When a user double-clicks an audio clip asset or highlights a segment and selects "Edit in Sub-tab", the application triggers a specialized editing sandbox pipeline:
|
||||||
|
|
||||||
|
### 6.1. Sandbox Isolation Flow
|
||||||
|
|
||||||
|
* **Buffer Isolation:** The application isolates a non-destructive copy of the targeted sample slice (`Audio Sub-segment Buffer`) into memory and spawns a distinct standalone document editor window. The timeline measuring ruler inside this sub-tab resets completely to map $t = 0.0\text{ s}$ at its origin.
|
||||||
|
* **Row Scale Adjustments:** Users drag the bottom perimeter boundary of the single track lane (`ns-resize` style handle) to dynamically alter height constraints between a lower boundary of $48\text{ px}$ and an upper boundary of $200\text{ px}$ for precision envelope drawing.
|
||||||
|
|
||||||
|
### 6.2. Core Toolbar Sliders Widget Matrix
|
||||||
|
|
||||||
|
* **Normalize Ceiling:** Evaluates the signal array to scale the single maximum absolute sample peak exactly up to user-specified decibel thresholds variable from $-12\text{ dBFS}$ to $0\text{ dBFS}$.
|
||||||
|
* **Gain & Pitch Modulation:** Adjusts macro channel decibel levels and transposes fundamental vocal or instrument frequencies using an integrated Phase Vocoder algorithm.
|
||||||
|
* **Speed Stretch Slider (%):** Drives time-stretching operations visuals directly from the timeline layer by holding the `Alt` modifier key and dragging the rightmost bounding clip handle. A bright yellow metadata text string (e.g., `Speed: 75.0%`) renders at the upper-left boundary of the audio clip container:
|
||||||
|
|
||||||
|
$$S = \frac{D_{\text{original}}}{D_{\text{stretched}}} \times 100\%$$
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### 6.3. Volume Pencil Automation Tool
|
||||||
|
|
||||||
|
Activating the Pencil drawing utility overlays a solid horizontal neon green line representing $0\text{ dB}$ (Unity Gain) across the track axis. Users left-click to drop custom vector control points, dragging node handles upward to boost signal gains (up to $+3\text{ dB}$) or downward to attenuate track volume (down to $-30\text{ dB}$).
|
||||||
|
|
||||||
|
### 6.4. Crossfaded In-Place Overwrite Core Loop (Apply & Sync-Back)
|
||||||
|
|
||||||
|
Clicking the *Apply* action pushes the processed sample buffer array back into the primary multitrack mixing arrangement canvas. To prevent wave phase breakage that precipitates popping artifacts, the splicing engine bakes an ultra-fast linear crossfade envelope ($w = 10\text{ ms}$) across both the initial and trailing splice boundaries:
|
||||||
|
|
||||||
|
|
||||||
|
$$\text{Output}(t) = (1 - \alpha(t)) \cdot \text{Original}(t) + \alpha(t) \cdot \text{Edited}(t - T_{\text{start}})$$
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Automated AI Loop Scanning & Fade-Free Slicing
|
||||||
|
|
||||||
|
### 7.1. Chromagram-Driven AI Loop Indexing
|
||||||
|
|
||||||
|
The system processes raw track files using an asynchronous Celery worker script that compiles a **Self-Similarity Matrix (SSM)** derived from spectral Chroma audio features. The algorithm locates areas showcasing the highest recurrence metrics (e.g., drum grooves, chord loops) and automatically maps matching timeline markers onto the user interface canvas views.
|
||||||
|
|
||||||
|
### 7.2. Sample-Accurate Phase Inversion Slicing (Fade-Free AI Cut)
|
||||||
|
|
||||||
|
Artificially introducing volume fade envelopes to mask clicking anomalies during macro audio cuts is strictly prohibited due to its destructive impact on percussive transient impact waves. The system must natively locate the absolute closest physical zero-crossing address where the signal array crosses the zero baseline (absolute silent index):
|
||||||
|
|
||||||
|
|
||||||
|
$$x[i] \cdot x[i+1] \le 0$$
|
||||||
|
|
||||||
|
|
||||||
|
Once both clip perimeters are hard-aligned to true zero-amplitude sample offsets, the engine slices the raw binary array inside RAM and generates a new track row directly below, dropping the processed clip onto it at the exact optimized time coordinates.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Dockerized Python Server Deployment & Architecture
|
||||||
|
|
||||||
|
### 8.1. Headless JUCE C++ VST/VSTi Audio Rendering Pipeline
|
||||||
|
|
||||||
|
To ensure that containerized Python workflows can initialize and instantiate VST3 processing nodes and virtual instruments compiled via C++ (`JUCE framework`) under Linux environments without triggering X11 display linkage initialization crashes, the underlying systems architecture must embed and initialize a virtual display frame buffer (`Xvfb`):
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
# Dockerfile snippet installing core graphical rendering dependencies and Xvfb
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
libgl1-mesa-glx libglu1-mesa libasound2 libjack-jackd2-0 \
|
||||||
|
libfreetype6 libfontconfig1 libx11-6 libxext6 libxrandr2 \
|
||||||
|
xvfb \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
ENV DISPLAY=:99
|
||||||
|
|
||||||
|
CMD ["sh", "-c", "Xvfb :99 -screen 0 1024x768x16 & python app/main.py"]
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.2. RBAC Security, Disk Quotas, and Feature Flags Configuration
|
||||||
|
|
||||||
|
* **First-Login Security Control (Enforced Password Reset):** System administrator accounts are initialized using parameters parsed from environment strings (`DEFAULT_ADMIN_PASSWORD`). The identity route mapper assigns a strict boolean cờ `must_change_password = True` value, which intercepts all subsequent incoming client API audio processing requests and returns a `HTTP 403 Forbidden` error loop until a secure password overwrite is completed.
|
||||||
|
* **Storage Allocation Constraints (Admin Quotas):** The gateway layer embeds a resource allocation supervisor tracking storage disk boundaries ($S_{\text{limit}}$). It aggregates the byte sizes of active array blocks before certifying a file upload sequence:
|
||||||
|
|
||||||
|
$$S_{\text{used}} + S_{\text{new}} \le S_{\text{limit}}$$
|
||||||
|
|
||||||
|
|
||||||
|
* **Feature Flags Management:** Administrators can dynamically enable or disable advanced server-side runtime pipelines (such as high-fidelity 24-bit WAV mixdown rendering or automated AI track generation) via modifications to global database flag keys.
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
Dưới đây là toàn bộ nội dung tài liệu đặc tả kiến trúc xử lý âm thanh chuyên nghiệp cấp độ Desktop trên Client-Side đã được chuyển đổi sang định dạng Markdown chuẩn, tối ưu hóa các khối mã nguồn (`text`, `cpp`), 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ả Kiến Trúc: Xử Lý Âm Thanh Chuyên Nghiệp Cấp Độ Desktop Trên Client-Side
|
||||||
|
|
||||||
|
Tài liệu này đặc tả kiến trúc hệ thống, giải pháp công nghệ và các thuật toán xử lý tín hiệu số (DSP) để xây dựng bộ máy biên tập âm thanh chuyên nghiệp (*Audio Editor Engine*) hoạt động độc lập và hiệu năng cao ngay trên máy trạm (*Client-side*) tương tự như Sound Forge hay Adobe Audition, sử dụng nền tảng HTML5, Web Audio API nâng cao, WebAssembly (WASM) và `SharedArrayBuffer`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Sơ Đồ Kiến Trúc Lõi (Client-Side Audio Engine Architecture)
|
||||||
|
|
||||||
|
Để đạt được hiệu năng xử lý không độ trễ và không gây nghẽn luồng giao diện (*UI Main Thread*), hệ thống bắt buộc phải tách biệt hoàn toàn ba lớp luồng thực thi:
|
||||||
|
|
||||||
|
```text
|
||||||
|
┌────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ MAIN THREAD (UI / REACT) │
|
||||||
|
│ - Render giao diện Canvas, Sliders, Rulers, Waveform. │
|
||||||
|
│ - Nhận tương tác phím/chuột (Shift+Click, Drag, Zoom). │
|
||||||
|
│ - Giao tiếp bất đồng bộ qua MessagePort / Worker PostMessage. │
|
||||||
|
└───────────────────┬────────────────────────────────▲───────────────────┘
|
||||||
|
│ │
|
||||||
|
│ SharedArrayBuffer / Atomics │ SharedArrayBuffer / Atomics
|
||||||
|
▼ │
|
||||||
|
┌────────────────────────────────────────────────────┴───────────────────┐
|
||||||
|
│ AUDIO WORKLET THREAD (LOW-LATENCY AUDIO RENDERING) │
|
||||||
|
│ - Thực thi luồng xử lý âm thanh thời gian thực (Audio Graph). │
|
||||||
|
│ - Đọc/Ghi mảng Ring Buffer (Shared Memory) không khóa (Lock-free). │
|
||||||
|
│ - Gọi trực tiếp lõi xử lý DSP viết bằng WebAssembly (C++/Rust). │
|
||||||
|
└────────────────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Các Công Nghệ Cốt Lõi Trên Client-Side
|
||||||
|
|
||||||
|
### 2.1. Web Audio API Nâng Cao (`AudioContext` & `AudioWorklet`)
|
||||||
|
|
||||||
|
* **Hạn chế của API cũ:** Các nút xử lý mặc định (`ScriptProcessorNode`) chạy trực tiếp trên Main Thread, gây ra hiện tượng giật lag âm thanh (*audio glitching/pop*) bất cứ khi nào trình duyệt thực hiện tính toán UI hoặc render đồ họa nặng.
|
||||||
|
* **Giải pháp chuẩn DAW:** Sử dụng `AudioWorklet`. Trình duyệt sẽ khởi tạo một luồng xử lý riêng biệt có độ ưu tiên thời gian thực (*Real-time Priority Thread*) tách biệt hoàn toàn khỏi luồng dựng hình UI.
|
||||||
|
|
||||||
|
### 2.2. WebAssembly (WASM) — Bộ Máy DSP Hiệu Năng Tiệm Cận Native
|
||||||
|
|
||||||
|
* **Vai trò:** JavaScript không có kiểu dữ liệu tối ưu và tốc độ thực thi các vòng lặp mẫu nhanh bằng các ngôn ngữ có biên dịch biên độ thấp. WebAssembly cho phép đưa các thư viện xử lý âm thanh C++ hoặc Rust (như FFmpeg, SoX, Superpowered, hoặc JUCE DSP) chạy trực tiếp trong trình duyệt với hiệu năng đạt mức $90\% \rightarrow 95\%$ so với phần mềm máy tính.
|
||||||
|
* **Quy trình hoạt động:** Giải mã tệp WAV nhị phân vào bộ nhớ Heap của WASM (*WASM Linear Memory*). Luồng C++ sẽ xử lý toán học trực tiếp trên các con trỏ bộ nhớ này thông qua kiểu dữ liệu mảng float 32-bit (`Float32Array`).
|
||||||
|
|
||||||
|
### 2.3. `SharedArrayBuffer` & `Atomics` — Chia Sẻ Bộ Nhớ Không Khóa
|
||||||
|
|
||||||
|
* **Vấn đề luồng:** Việc chuyển dữ liệu lớn (Hàng chục Megabytes dữ liệu âm thanh) giữa Main Thread và AudioWorklet Thread bằng lệnh `postMessage` thông thường sẽ gây ra độ trễ sao chép dữ liệu (*Serialization Latency*) và tăng rác bộ nhớ (*Garbage Collection overhead*).
|
||||||
|
* **Giải pháp:** Sử dụng `SharedArrayBuffer`. Cả hai luồng UI và AudioWorklet cùng truy cập vào một vùng nhớ RAM vật lý duy nhất. Sử dụng thư viện `Atomics` để đồng bộ hóa và ghi nhận trạng thái con trỏ phát nhạc (*Playhead position*) một cách an toàn và không gây nghẽn luồng xử lý (*Lock-free Ring Buffer*).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Các Thuật Toán DSP Chuyên Sâu Cần Port Sang Client-Side
|
||||||
|
|
||||||
|
Để đạt được chất lượng xử lý của Sound Forge và Audition, hệ thống phải thực hiện các thuật toán tín hiệu số trực tiếp trên mảng dữ liệu $x[n]$ ở Client-side:
|
||||||
|
|
||||||
|
### 3.1. Phân Tích Phổ Tần Số Thời Gian Thực (Fast Fourier Transform — FFT)
|
||||||
|
|
||||||
|
Để hiển thị biểu đồ phổ (*Spectrogram*) và thực hiện biên tập tần số (*Spectral Editing*) như Adobe Audition, ta chuyển đổi tín hiệu từ miền thời gian sang miền tần số bằng phép biến đổi Fourier nhanh (FFT) bậc $N$ (thường chọn $N = 2048$ hoặc $N = 4096$ mẫu):
|
||||||
|
|
||||||
|
$$X(f) = \sum_{n=0}^{N-1} x[n] \cdot e^{-i 2 \pi f n / N}$$
|
||||||
|
|
||||||
|
* **Tối ưu hóa:** Sử dụng thư viện WASM FFT (như KissFFT hoặc FFTW biên dịch sang WASM) để thực hiện tính toán song song bằng tập lệnh Vector hóa SIMD (*Single Instruction, Multiple Data*) của CPU máy khách.
|
||||||
|
|
||||||
|
### 3.2. Thuật Toán Co Giãn Thời Gian & Dịch Cao Độ (Phase Vocoder)
|
||||||
|
|
||||||
|
Để thực hiện tính năng thay đổi tốc độ (*Stretch*) mà không đổi cao độ (*Pitch*), hoặc dịch giọng (*Pitch shifting*) mà không đổi thời lượng:
|
||||||
|
|
||||||
|
* **Phân tích:** Thực hiện biến đổi Fourier thời gian ngắn (STFT) với cửa sổ Hanning chồng chập $75\%$ (*Overlap-Add*):
|
||||||
|
|
||||||
|
$$w[n] = 0.5 \cdot \left(1 - \cos\left(\frac{2\pi n}{N-1}\right)\right)$$
|
||||||
|
|
||||||
|
* **Dịch chuyển pha:** Tính toán sự sai lệch pha $\Delta \Phi$ giữa các khung (*frames*) liên tiếp để xác định tần số tức thời và thực hiện bù pha (*Phase Resynthesis*) theo tỷ lệ co giãn $S$:
|
||||||
|
|
||||||
|
$$S = \frac{\text{Duration}_{\text{new}}}{\text{Duration}_{\text{original}}}$$
|
||||||
|
|
||||||
|
* **Tổng hợp:** Tái thiết lập tín hiệu bằng thuật toán biến đổi ngược (ISTFT) và phương pháp cộng chồng chập (OLA — *Overlap-Add*) để tạo ra tệp âm thanh trơn tru, không bị méo dạng hay giật tiếng.
|
||||||
|
|
||||||
|
### 3.3. Thuật Toán Lọc Méo Tiếng & Compressor Động (Dynamics Processing)
|
||||||
|
|
||||||
|
Lập trình thuật toán Compressor/Limiter để kiểm soát biên độ đỉnh của tín hiệu tự động bằng cách tính toán mốc năng lượng RMS trung bình của cửa sổ tín hiệu:
|
||||||
|
|
||||||
|
$$x_{\text{RMS}} = \sqrt{\frac{1}{M}\sum_{k=0}^{M-1} x[n-k]^2}$$
|
||||||
|
|
||||||
|
Hệ số khuếch đại Gain áp dụng $G(t)$ được tính toán động dựa trên các tham số Threshold ($T_{\text{dB}}$), Ratio ($R$), Attack ($t_A$) và Release ($t_R$):
|
||||||
|
|
||||||
|
$$G_{\text{target}}(t) = \begin{cases} 0 & x_{\text{dB}} \le T_{\text{dB}} \\ (T_{\text{dB}} - x_{\text{dB}}) \cdot \left(1 - \frac{1}{R}\right) & x_{\text{dB}} > T_{\text{dB}} \end{cases}$$
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Giải Pháp Biên Tập Không Phá Hủy (Non-Destructive Editing VFS)
|
||||||
|
|
||||||
|
Các phần mềm chuyên nghiệp không chỉnh sửa trực tiếp vào file WAV gốc trong suốt quá trình làm việc để tránh làm giảm chất lượng hoặc tiêu tốn RAM. Ta áp dụng kiến trúc Hệ thống tệp ảo phi tuyến (*Virtual Non-Linear File System - VFS*):
|
||||||
|
|
||||||
|
```text
|
||||||
|
[ Tệp âm thanh gốc trong RAM ] ──────────────────────────────────────────┐
|
||||||
|
│
|
||||||
|
[ Bảng chỉ mục liên kết phân đoạn (Non-Destructive Edit List - EDL) ] │
|
||||||
|
├── Phân đoạn 1: Đọc từ giây 0s -> 3.5s ──────────────────────────────┼─► [ Kết xuất ra Loa / Master ]
|
||||||
|
├── Phân đoạn 2: [SILENCE / KHOẢNG LẶNG] độ dài 1.2s │
|
||||||
|
└── Phân đoạn 3: Đọc từ giây 15s -> 22.4s (Đã đảo ngược - Reverse) ┘
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.1. Cơ chế hoạt động:
|
||||||
|
|
||||||
|
* Khi người dùng thực hiện lệnh Cut, Paste, Delete, hệ thống không xóa hay di chuyển bất kỳ byte dữ liệu nào trong mảng AudioBuffer gốc.
|
||||||
|
* Hệ thống chỉ cập nhật một danh sách chỉ mục bao gồm các đối tượng con trỏ định vị (*Edit Decision List - EDL*):
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{ "source_buffer_id": "track_1", "start_sample": 0, "length": 176400, "playback_rate": 1.0 },
|
||||||
|
{ "source_buffer_id": "silence", "start_sample": 0, "length": 44100, "playback_rate": 1.0 },
|
||||||
|
{ "source_buffer_id": "track_1", "start_sample": 882000, "length": 220500, "playback_rate": -1.0 }
|
||||||
|
]
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
* **Lợi ích:** Thao tác Undo/Redo diễn ra tức thời (*Instantaneous*) và tốn $0\text{ ms}$ bất kể tệp âm thanh dài hàng tiếng đồng hồ, do hệ thống chỉ cập nhật mảng JSON EDL siêu nhẹ mà không phải tính toán mảng mẫu nhị phân thô.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Hiện Thực Hóa Mã Nguồn DSP Chạy Trên WASM Client-Side
|
||||||
|
|
||||||
|
Dưới đây là thiết kế mã nguồn C++ mẫu (`core/dsp_engine.cpp`) được tối ưu hóa cao để biên dịch sang WebAssembly thông qua bộ dịch Emscripten, thực hiện xử lý âm thanh không độ trễ trực tiếp trong AudioWorklet trên trình duyệt:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
#include <emscripten.h>
|
||||||
|
#include <cmath>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
// Sử dụng EMSCRIPTEN_KEEPALIVE để giữ hàm khi biên dịch sang WASM
|
||||||
|
extern "C" {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thuật toán áp dụng Volume Gain và Panning Hằng Số Năng Lượng (Constant-Power)
|
||||||
|
* Thao tác trực tiếp trên vùng nhớ RAM tuyến tính của WASM (WASM Linear Memory)
|
||||||
|
*/
|
||||||
|
EMSCRIPTEN_KEEPALIVE
|
||||||
|
void process_audio_block(
|
||||||
|
float* input_l, // Con trỏ kênh trái đầu vào
|
||||||
|
float* input_r, // Con trỏ kênh phải đầu vào
|
||||||
|
float* output_l, // Con trỏ kênh trái đầu ra
|
||||||
|
float* output_r, // Con trỏ kênh phải đầu ra
|
||||||
|
int block_size, // Kích thước khối (thường mặc định 128 mẫu trong Web Audio)
|
||||||
|
float volume_db, // Độ lớn âm lượng điều chỉnh (dB)
|
||||||
|
float pan // Vị trí panning từ -1.0 (Trái) đến 1.0 (Phải)
|
||||||
|
) {
|
||||||
|
// 1. Quy đổi dB sang hệ số nhân tuyến tính
|
||||||
|
float gain = powf(10.0f, volume_db / 20.0f);
|
||||||
|
|
||||||
|
// 2. Thuật toán Constant-Power Panning Law
|
||||||
|
// Quy đổi pan từ [-1.0, 1.0] sang góc quét theta [0, pi/2]
|
||||||
|
float theta = ((pan + 1.0f) / 2.0f) * (M_PI / 2.0f);
|
||||||
|
float gain_l = cosf(theta) * gain;
|
||||||
|
float gain_r = sinf(theta) * gain;
|
||||||
|
|
||||||
|
// 3. Thực thi tính toán vector hóa siêu tốc (SIMD-capable loop)
|
||||||
|
#pragma clang loop vectorize(enable)
|
||||||
|
for (int i = 0; i < block_size; ++i) {
|
||||||
|
output_l[i] = input_l[i] * gain_l;
|
||||||
|
output_r[i] = input_r[i] * gain_r;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Lộ Trình Triển Khai Chuyển Đổi Sang Client-Side (WASM DSP Pipeline)
|
||||||
|
|
||||||
|
Để dịch chuyển dự án từ mô hình xử lý nặng ở Server sang Client-side Audio Engine chuyên nghiệp, chúng ta triển khai theo 4 bước sau:
|
||||||
|
|
||||||
|
```text
|
||||||
|
[ GIAI ĐOẠN 1 ] ──► Tách biệt luồng UI và luồng Audio bằng AudioWorklet.
|
||||||
|
[ GIAI ĐOẠN 2 ] ──► Biên dịch các thư viện DSP C++/Rust sang WebAssembly (.wasm).
|
||||||
|
[ GIAI ĐOẠN 3 ] ──► Triển khai bảng chỉ mục EDL để hỗ trợ Undo/Redo phi tuyến tức thời.
|
||||||
|
[ GIAI ĐOẠN 4 ] ──► Tận dụng WebGL/WebGPU để kết xuất đồ thị sóng & spectrogram bằng GPU.
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1. Triển khai AudioWorklet Node
|
||||||
|
|
||||||
|
Thay thế hoàn toàn bộ đệm vẽ cũ bằng cách đăng ký một `AudioWorkletProcessor` chạy trên luồng phụ để liên tục nạp dữ liệu và cấp phát tín hiệu nghe thử thời gian thực mà không làm nghẽn giao diện.
|
||||||
|
|
||||||
|
### 2. Biên dịch WASM Toolchain
|
||||||
|
|
||||||
|
Sử dụng Emscripten SDK để biên dịch mã nguồn C++ của các hiệu ứng (Reverb, Delay, Phase Vocoder) thành tệp `.wasm`. Frontend tải bất đồng bộ tệp này khi khởi chạy ứng dụng và ánh xạ trực tiếp vùng nhớ RAM tuyến tính của WASM vào luồng âm học của trình duyệt.
|
||||||
|
|
||||||
|
### 3. Tích hợp WebGL/WebGPU Render Sóng Âm
|
||||||
|
|
||||||
|
Thay vì thực hiện vẽ lại Canvas bằng CPU Main Thread thông qua Context 2D truyền thống (thường gây lag khi zoom sâu), chúng ta chuyển các tọa độ đỉnh mẫu sang bộ nhớ của GPU và sử dụng WebGL/WebGPU để kết xuất vectơ sóng âm ở tần số quét $60\text{ Hz} \rightarrow 120\text{ Hz}$ cực kỳ mượt mà tương tự như Sound Forge.
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
# Kế Hoạch Triển Khai Kỹ Thuật: Dockerized Music Processing Server & SonicForge Studio
|
||||||
|
|
||||||
|
Kế hoạch này đặc tả lộ trình triển khai, kiểm thử và đồng bộ hóa hai lõi động cơ: Động cơ Web Audio Client-side (nghe thử thời gian thực, tương tác đồ họa) và Động cơ Python Docker Server-side (xử lý VST/VSTi, render chất lượng cao, quản lý phân quyền và hạn mức lưu trữ Quota).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. GIAI ĐOẠN 1: ĐỒNG BỘ ĐỒ HỌA & XỬ LÝ SÓNG ÂM KHÔNG TRỄ
|
||||||
|
|
||||||
|
Mục tiêu là đưa mảng nhị phân thô (`Float32Array`) vào bộ nhớ RAM của Client để vẽ đồ thị siêu thu phóng mượt mà và thực thi bắt sự kiện bôi đen vùng chọn.
|
||||||
|
|
||||||
|
### 1.1. Các Tác Vụ Phía Frontend (HTML5/React)
|
||||||
|
|
||||||
|
* **[ ] Vẽ Sóng Đa Thang Đo (Multi-Scale Waveform):**
|
||||||
|
* Tích hợp thuật toán hoán đổi đồ họa trong `index.html`.
|
||||||
|
* Khi zoom xa ($Z < 500$ px/s): Vẽ dải bao đỉnh (Peak Waveform).
|
||||||
|
|
||||||
|
|
||||||
|
* Khi siêu thu phóng ($Z \ge 500$ px/s): Vẽ đường cong hình sin đơn tuyến (Continuous Polyline) và các chấm mẫu tròn (Sample Nodes, bán kính $r = 2\text{ px}$) tại các tọa độ mẫu chính xác.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
* **[ ] Vẽ Lưới Trục Decibel:** Dựng rõ rệt các vạch lưới ngang màu tối phân chia mốc biên độ: vạch dương +6.0 dB, vạch trung tâm -Inf. dB (Zero-Line), và vạch biên âm -6.0 dB.
|
||||||
|
|
||||||
|
|
||||||
|
* **[ ] Khóa Điểm Neo Shift+Click:**
|
||||||
|
* Triển khai React Ref độc lập `localSelectionAnchorRef` để khóa điểm nhấp chuột đầu tiên.
|
||||||
|
|
||||||
|
|
||||||
|
* Khi người dùng nhấp Shift+Click lần 2, tính toán dải phủ màu cục bộ trên duy nhất track đang hoạt động trong khoảng $[\min(T_{\text{anchor}}, T_{\text{end}}), \max(T_{\text{anchor}}, T_{\text{end}})]$.
|
||||||
|
|
||||||
|
|
||||||
|
* Chặn đứng sự kiện click playhead hoặc kéo clip khi có phím Shift được nhấn.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
* **[ ] Hủy Vòng Lặp (Escape Loop):** Hỗ trợ tổ hợp `Ctrl + Click` chuột vào vùng trống ngoài dải chọn để hủy mốc neo, nhấn Spacebar phát nhạc tuyến tính vượt quá mốc lặp cũ.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### 1.2. Các Tác Vụ Phía Backend (Python / NumPy)
|
||||||
|
|
||||||
|
* **[ ] Port Thuật Toán Dò Zero-Crossing:** Viết hàm dò tìm điểm đổi dấu vật lý trong tệp `app/core/dsp_utils.py` bằng toán tử NumPy vector hóa để tối ưu hóa tốc độ:
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
$$x[i] \cdot x[i+1] \le 0$$
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. GIAI ĐOẠN 2: CHỈNH SỬA PHI TUYẾN TRÊN SUB-TAB CÔ LẬP
|
||||||
|
|
||||||
|
Thiết lập môi trường làm việc cô lập (Sandbox) cho phép người dùng click đúp vào Clip để mở một Tab phụ biên tập chi tiết không ảnh hưởng đến bản phối chính.
|
||||||
|
|
||||||
|
### 2.1. Quy Trình Trích Xuất & Thước Đo
|
||||||
|
|
||||||
|
* **[ ] Sandbox Splicing:** Khi double-click vào Clip, Frontend trích xuất mảng mẫu phụ (Sub-segment Buffer) và tạo một tab biên tập độc lập. Đặt lại thước đo thời gian Ruler của Tab này chạy từ $t = 0.0\text{ s}$.
|
||||||
|
|
||||||
|
|
||||||
|
* **[ ] Tương Tác Slider Thước Đo:** Dựng 4 thanh kéo ngang điều hướng:
|
||||||
|
* *Normalize Ceiling:* Trần chuẩn hóa từ $-12\text{ dBFS}$ đến $0\text{ dBFS}$.
|
||||||
|
|
||||||
|
|
||||||
|
* *Gain (dB) & Pitch Shift (Semitones):* Khuếch đại biên độ và dịch giọng.
|
||||||
|
|
||||||
|
|
||||||
|
* *Speed Stretch (%):* Co giãn thời lượng clip trực quan bằng cách nhấn giữ `Alt` rồi kéo biên phải của Clip. Hiển thị nhãn màu vàng `Speed: 75.0%`.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
* **[ ] Bút Vẽ Volume (Pencil Tool):** Kích hoạt cây bút vẽ để hiển thị đường thẳng lục sáng mốc $0\text{ dB}$. Cho phép người dùng nhấp tạo các nút thắt điều khiển (Control Nodes) và kéo tăng ($+3\text{ dB}$) hoặc kéo giảm ($-30\text{ dB}$).
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### 2.2. Hòa Mạng Apply & Merge Back Phía Server
|
||||||
|
|
||||||
|
* **[ ] Bộ Lọc Micro-Crossfade:** Khi người dùng nhấn Apply, dữ liệu đã chỉnh sửa được đồng bộ ngược lại dòng phối chính. FastAPI Server chạy Celery task áp dụng bộ lọc mờ biên Micro-crossfade có độ rộng $w = 10\text{ ms}$ tại hai đầu điểm ráp nối để triệt tiêu tiếng click/pop.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. GIAI ĐOẠN 3: ĐỊNH TUYẾN MIDI, PLUGIN VST/VSTI & MIXER
|
||||||
|
|
||||||
|
Tích hợp bộ soạn thảo MIDI Piano Roll, nạp nhạc cụ ảo, hiệu ứng và điều phối âm lượng đa kênh.
|
||||||
|
|
||||||
|
### 3.1. MIDI Items & Piano Roll Editor
|
||||||
|
|
||||||
|
* **[ ] Piano Roll Canvas:** Thiết lập giao diện lưới nốt nhạc có trục đứng $Y$ biểu diễn cao độ từ $0 \rightarrow 127$ (phím piano) và trục ngang $X$ biểu diễn lưới phách (Beats) đồng bộ với Tempo.
|
||||||
|
* **[ ] Thao Tác Lưới:** Cho phép nhấp chuột để thêm nốt nhạc, click chuột phải/nhấp đúp để xóa nốt, kéo hai đầu để thay đổi độ dài (`duration_beats`).
|
||||||
|
|
||||||
|
### 3.2. Động Cơ Định Tuyến VST / VSTi Trên Docker Linux
|
||||||
|
|
||||||
|
* **[ ] Nạp VSTi (Nhạc cụ ảo):** Cấu hình thư viện `pedalboard` ở Python Backend để nạp các tệp tin `.vst3` nhạc cụ ảo trên Linux, tiếp nhận sự kiện MIDI từ Piano Roll, tổng hợp âm và xuất ra mảng NumPy Stereo.
|
||||||
|
* **[ ] Nạp VST Effects (EQ/Reverb):** Hỗ trợ ghim chuỗi hiệu ứng nối tiếp gộp cả Stock WASM và Native VST3.
|
||||||
|
* **[ ] Giao Diện Mixer Panel Đa Kênh:** Dựng bảng mixer ở đáy màn hình hiển thị Master Bus, Track Audio, Track MIDI và Track FX Send/Return. Mỗi track có thước đo tín hiệu (Level Meter) dao động thời gian thực.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. GIAI ĐOẠN 4: HỆ THỐNG PHÂN QUYỀN, QUOTA & ADMIN CONTROL
|
||||||
|
|
||||||
|
Xây dựng lớp bảo mật bảo vệ tài nguyên ổ đĩa máy chủ, quản lý người dùng và cờ tính năng (Feature Flags).
|
||||||
|
|
||||||
|
### 4.1. Phân Quyền & Quản Lý Quota
|
||||||
|
|
||||||
|
* **[ ] Bắt Buộc Đổi Mật Khẩu Lần Đầu (First-Time Login):**
|
||||||
|
* Khi tài khoản Admin/User được khởi tạo với mật khẩu mặc định từ môi trường Docker, hệ thống đặt cờ `must_change_password = True` trong database SQL.
|
||||||
|
|
||||||
|
|
||||||
|
* Middleware của FastAPI sẽ chặn đứng mọi yêu cầu xử lý nhạc, ép người dùng thực hiện đổi mật khẩu ở lần đăng nhập đầu tiên mới mở khóa hệ thống.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
* **[ ] Admin Quotas:** Tích hợp bộ kiểm soát hạn mức dung lượng ổ đĩa lưu trữ ($S_{\text{limit}}$). Python sẽ tính toán tổng kích thước mảng nhị phân trước khi cho phép tải tệp lên:
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
$$S_{\text{used}} + S_{\text{new}} \le S_{\text{limit}}$$
|
||||||
|
|
||||||
|
|
||||||
|
* **[ ] Feature Flags:** Hỗ trợ Admin bật/tắt nóng các tính năng cao cấp (như xuất bản WAV 24-bit, AI generation) thông qua bảng cấu hình DB.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### 4.2. Cấu Hinh Headless JUCE VST Rendering
|
||||||
|
|
||||||
|
* **[ ] Docker Xvfb Display:** Bổ sung cấu hình màn hình ảo Xvfb (X Virtual Framebuffer) vào tệp Dockerfile để container nạp thành công các VST3 nhạc cụ và hiệu ứng biên dịch bằng C++ (JUCE framework) trên Linux mà không bị lỗi crash liên kết X11.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. KIỂM THỬ XÁC MINH DANH TÍNH
|
||||||
|
|
||||||
|
| Mô-đun kiểm thử | Phương pháp thực thi | Tiêu chuẩn đạt (KPI) |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **Kiểm thử Zoom & Sóng** | Phóng to tối đa một bài nhạc $44.1\text{ kHz}$. | Nhìn thấy rõ hạt mẫu tròn màu xanh và dải lưới Decibel đối xứng. |
|
||||||
|
| **Kiểm thử Shift+Click** | Bôi chọn cục bộ và Master Loop trên thước Ruler. | Nhấn Spacebar lặp mượt mà, nhấn `Ctrl+Click` để hủy dải chọn. |
|
||||||
|
| **Kiểm thử Zero-Crossing** | Cắt lát nhạc bằng AI Cut ở mốc giây lẻ. | Tệp WAV kết xuất không có bất kỳ tiếng lách tách (click/pop) nào. |
|
||||||
|
| **Kiểm thử Docker VSTi** | Gửi chuỗi MIDI nốt và nạp một Virtual Synth VST3. | Kết xuất thành công tệp WAV Stereo có âm thanh nhạc cụ ảo. |
|
||||||
|
| **Kiểm thử Bảo Mật Auth** | Đăng nhập tài khoản mặc định và gọi API Mix nhạc. | Hệ thống trả về lỗi HTTP 403 Forbidden bắt buộc đổi mật khẩu. |
|
||||||
|
| **Kiểm thử Quota** | Cố tình tải lên tệp âm thanh nặng vượt giới hạn. | Trả về lỗi *Dung lượng lưu trữ vượt quá giới hạn Quota của bạn.* |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Kế hoạch Cấu hình Dockerfile Hợp nhất (Có Xvfb Headless)
|
||||||
|
|
||||||
|
Để chuẩn bị môi trường chạy thật cho động cơ xử lý âm thanh bản địa (Native DSP) tích hợp VSTi/VST3 C++ thông qua Python Pedalboard, tệp tin `Dockerfile` của dự án bắt buộc phải được thiết lập màn hình ảo Xvfb để tránh crash liên kết đồ họa:
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
# Sử dụng Python 3.11 làm nền tảng
|
||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
# Cài đặt các gói thư viện đồ hoạ và asound bắt buộc đối với JUCE / VST3 Linux
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
libgl1-mesa-glx \
|
||||||
|
libglu1-mesa \
|
||||||
|
libasound2 \
|
||||||
|
libjack-jackd2-0 \
|
||||||
|
libfreetype6 \
|
||||||
|
libfontconfig1 \
|
||||||
|
libx11-6 \
|
||||||
|
libxext6 \
|
||||||
|
libxinerama1 \
|
||||||
|
libxrandr2 \
|
||||||
|
libxcursor1 \
|
||||||
|
xvfb \
|
||||||
|
ffmpeg \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Thiết lập biến môi trường hiển thị cho X11 ảo
|
||||||
|
ENV DISPLAY=:99
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Khởi chạy Xvfb ảo ở cổng :99 trước khi kích hoạt FastAPI / Celery
|
||||||
|
CMD ["sh", "-c", "Xvfb :99 -screen 0 1024x768x16 & python app/main.py"]
|
||||||
|
|
||||||
|
```
|
||||||
+22
-8
@@ -1,20 +1,33 @@
|
|||||||
|
# Sử dụng Python 3.11 làm nền tảng
|
||||||
FROM python:3.11-slim
|
FROM python:3.11-slim
|
||||||
|
|
||||||
# Thiết lập thư mục làm việc
|
# Cài đặt các gói thư viện đồ hoạ và asound bắt buộc đối với JUCE / VST3 Linux (22_CLIENT_DESK.md)
|
||||||
WORKDIR /app
|
RUN apt-get update && apt-get install -y \
|
||||||
|
libgl1 \
|
||||||
# Cài đặt các thư viện hệ thống cần thiết (FFmpeg, libsndfile)
|
libglx-mesa0 \
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
libglu1-mesa \
|
||||||
|
libasound2 \
|
||||||
|
libjack-jackd2-0 \
|
||||||
|
libfreetype6 \
|
||||||
|
libfontconfig1 \
|
||||||
|
libx11-6 \
|
||||||
|
libxext6 \
|
||||||
|
libxinerama1 \
|
||||||
|
libxrandr2 \
|
||||||
|
libxcursor1 \
|
||||||
|
xvfb \
|
||||||
ffmpeg \
|
ffmpeg \
|
||||||
libsndfile1 \
|
libsndfile1 \
|
||||||
build-essential \
|
build-essential \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Sao chép và cài đặt Python dependencies
|
# Thiết lập biến môi trường hiển thị cho X11 ảo
|
||||||
|
ENV DISPLAY=:99
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
# Sao chép mã nguồn
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Tạo thư mục chứa file nhạc và cấp quyền ghi
|
# Tạo thư mục chứa file nhạc và cấp quyền ghi
|
||||||
@@ -23,4 +36,5 @@ RUN mkdir -p /app/app/storage/uploads /app/app/storage/processed && chmod -R 777
|
|||||||
# Mặc định mở port 8000 cho FastAPI
|
# Mặc định mở port 8000 cho FastAPI
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
||||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
# Khởi chạy Xvfb ảo ở cổng :99 trước khi kích hoạt FastAPI / Celery
|
||||||
|
CMD ["sh", "-c", "Xvfb :99 -screen 0 1024x768x16 & uvicorn app.main:app --host 0.0.0.0 --port 8000"]
|
||||||
|
|||||||
@@ -30,6 +30,14 @@ def get_current_user(authorization: Optional[str] = Header(None)):
|
|||||||
raise HTTPException(status_code=401, detail="Token đã hết hạn hoặc không hợp lệ")
|
raise HTTPException(status_code=401, detail="Token đã hết hạn hoặc không hợp lệ")
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
|
def enforce_password_changed(user: dict):
|
||||||
|
"""Bắt buộc người dùng phải đổi mật khẩu ở lần đăng nhập đầu tiên (22_CLIENT_DESK.md §4.1)."""
|
||||||
|
if user.get("must_change_password"):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail="Tài khoản bắt buộc phải đổi mật khẩu ở lần đăng nhập đầu tiên trước khi thực hiện xử lý nhạc (HTTP 403 Forbidden)."
|
||||||
|
)
|
||||||
|
|
||||||
@router.post("/login")
|
@router.post("/login")
|
||||||
async def login(req: LoginRequest):
|
async def login(req: LoginRequest):
|
||||||
conn = get_db_connection()
|
conn = get_db_connection()
|
||||||
|
|||||||
@@ -80,6 +80,49 @@ def apply_micro_fade(segment: AudioSegment, fade_duration_ms: int = 50) -> Audio
|
|||||||
return segment
|
return segment
|
||||||
|
|
||||||
|
|
||||||
|
def apply_micro_crossfade(original: np.ndarray, edited: np.ndarray, start_sample: int, fade_len_ms: int = 10, sr: int = 44100) -> np.ndarray:
|
||||||
|
"""
|
||||||
|
Áp dụng bộ lọc mờ biên Micro-crossfade (10ms) tại hai đầu điểm ráp nối
|
||||||
|
để triệt tiêu tiếng click/pop khi Apply & Merge Back (22_CLIENT_DESK.md §2.2).
|
||||||
|
|
||||||
|
Output(t) = (1 - alpha(t)) * Original(t) + alpha(t) * Edited(t - T_start)
|
||||||
|
"""
|
||||||
|
fade_samples = int((fade_len_ms / 1000.0) * sr)
|
||||||
|
if fade_samples <= 0 or len(original) == 0:
|
||||||
|
return edited
|
||||||
|
|
||||||
|
output = np.copy(original)
|
||||||
|
edited_len = len(edited)
|
||||||
|
end_sample = min(len(original), start_sample + edited_len)
|
||||||
|
actual_len = end_sample - start_sample
|
||||||
|
|
||||||
|
if actual_len <= 0:
|
||||||
|
return output
|
||||||
|
|
||||||
|
fade_in_len = min(fade_samples, actual_len)
|
||||||
|
fade_out_len = min(fade_samples, actual_len)
|
||||||
|
|
||||||
|
alpha_in = np.linspace(0.0, 1.0, fade_in_len)
|
||||||
|
alpha_out = np.linspace(1.0, 0.0, fade_out_len)
|
||||||
|
|
||||||
|
output[start_sample:end_sample] = edited[:actual_len]
|
||||||
|
|
||||||
|
# Fade in at start splice point
|
||||||
|
for i in range(fade_in_len):
|
||||||
|
idx = start_sample + i
|
||||||
|
if idx < len(original):
|
||||||
|
output[idx] = (1.0 - alpha_in[i]) * original[idx] + alpha_in[i] * edited[i]
|
||||||
|
|
||||||
|
# Fade out at end splice point
|
||||||
|
for i in range(fade_out_len):
|
||||||
|
idx = end_sample - fade_out_len + i
|
||||||
|
edit_idx = actual_len - fade_out_len + i
|
||||||
|
if idx < len(original) and edit_idx < len(edited):
|
||||||
|
output[idx] = alpha_out[i] * edited[edit_idx] + (1.0 - alpha_out[i]) * original[idx]
|
||||||
|
|
||||||
|
return output
|
||||||
|
|
||||||
|
|
||||||
def generate_peak_waveform(file_path: str, num_peaks: int = 800) -> dict:
|
def generate_peak_waveform(file_path: str, num_peaks: int = 800) -> dict:
|
||||||
"""
|
"""
|
||||||
Tạo dữ liệu peak waveform cho hiển thị đồ thị sóng âm trên Frontend.
|
Tạo dữ liệu peak waveform cho hiển thị đồ thị sóng âm trên Frontend.
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# SonicForge Studio VST / VSTi Engine Service (22_CLIENT_DESK.md §3)
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
def midi_note_to_freq(note_number: int) -> float:
|
||||||
|
"""Quy đổi số nốt MIDI (0 - 127) sang tần số Hertz (Hz)."""
|
||||||
|
return 440.0 * (2.0 ** ((note_number - 69) / 12.0))
|
||||||
|
|
||||||
|
def render_midi_events_to_audio(midi_events: list, sr: int = 44100, bpm: float = 120.0, instrument: str = 'synth') -> np.ndarray:
|
||||||
|
"""
|
||||||
|
Tổng hợp mảng âm thanh NumPy Stereo từ sự kiện MIDI Piano Roll (22_CLIENT_DESK.md §3.1 & §3.2).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
midi_events: Danh sách nốt MIDI [{"note": 60, "start_beat": 0, "duration_beats": 1, "velocity": 100}, ...]
|
||||||
|
sr: Tần số lấy mẫu (Sample Rate)
|
||||||
|
bpm: Nhịp BPM của dự án
|
||||||
|
instrument: Loại nhạc cụ tổng hợp
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
np.ndarray: Mảng 2D Stereo Float32 [2, num_samples]
|
||||||
|
"""
|
||||||
|
beat_duration_sec = 60.0 / max(30.0, bpm)
|
||||||
|
max_duration_sec = 2.0
|
||||||
|
|
||||||
|
for event in midi_events:
|
||||||
|
start_beat = event.get('start_beat', 0.0)
|
||||||
|
dur_beats = event.get('duration_beats', 1.0)
|
||||||
|
end_sec = (start_beat + dur_beats) * beat_duration_sec
|
||||||
|
if end_sec > max_duration_sec:
|
||||||
|
max_duration_sec = end_sec
|
||||||
|
|
||||||
|
total_samples = int((max_duration_sec + 0.5) * sr)
|
||||||
|
out_l = np.zeros(total_samples, dtype=np.float32)
|
||||||
|
out_r = np.zeros(total_samples, dtype=np.float32)
|
||||||
|
|
||||||
|
for event in midi_events:
|
||||||
|
note = event.get('note', 60)
|
||||||
|
velocity = event.get('velocity', 100) / 127.0
|
||||||
|
start_beat = event.get('start_beat', 0.0)
|
||||||
|
dur_beats = event.get('duration_beats', 1.0)
|
||||||
|
|
||||||
|
start_sample = int(start_beat * beat_duration_sec * sr)
|
||||||
|
dur_samples = int(dur_beats * beat_duration_sec * sr)
|
||||||
|
end_sample = min(total_samples, start_sample + dur_samples)
|
||||||
|
actual_len = end_sample - start_sample
|
||||||
|
|
||||||
|
if actual_len <= 0 or start_sample >= total_samples:
|
||||||
|
continue
|
||||||
|
|
||||||
|
freq = midi_note_to_freq(note)
|
||||||
|
t = np.arange(actual_len) / float(sr)
|
||||||
|
|
||||||
|
# Synth tone + fundamental harmonics
|
||||||
|
tone = 0.6 * np.sin(2 * np.pi * freq * t) + 0.3 * np.sin(2 * np.pi * freq * 2 * t) + 0.1 * np.sin(2 * np.pi * freq * 3 * t)
|
||||||
|
|
||||||
|
# ADSR Envelope
|
||||||
|
attack = min(int(0.01 * sr), actual_len // 4)
|
||||||
|
release = min(int(0.05 * sr), actual_len // 4)
|
||||||
|
sustain_len = actual_len - attack - release
|
||||||
|
|
||||||
|
env = np.ones(actual_len, dtype=np.float32)
|
||||||
|
if attack > 0:
|
||||||
|
env[:attack] = np.linspace(0.0, 1.0, attack)
|
||||||
|
if release > 0:
|
||||||
|
env[-release:] = np.linspace(1.0, 0.0, release)
|
||||||
|
|
||||||
|
signal = tone * env * velocity
|
||||||
|
|
||||||
|
out_l[start_sample:end_sample] += signal
|
||||||
|
out_r[start_sample:end_sample] += signal
|
||||||
|
|
||||||
|
# Clamping normalization to prevent clipping
|
||||||
|
max_peak = max(np.max(np.abs(out_l)), np.max(np.abs(out_r)))
|
||||||
|
if max_peak > 1.0:
|
||||||
|
out_l /= max_peak
|
||||||
|
out_r /= max_peak
|
||||||
|
|
||||||
|
return np.vstack([out_l, out_r])
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
// SonicForge Studio Audio Engine Service
|
// SonicForge Studio Audio Engine Service
|
||||||
|
// High-performance Desktop-Grade Client-Side Audio Engine & DSP Service (21_CLIENT_PRE.md)
|
||||||
|
|
||||||
(function() {
|
(function() {
|
||||||
let audioCtx = null;
|
let audioCtx = null;
|
||||||
|
let workletLoaded = false;
|
||||||
|
|
||||||
function getAudioContext() {
|
function getAudioContext() {
|
||||||
if (!audioCtx) {
|
if (!audioCtx) {
|
||||||
@@ -12,6 +15,22 @@
|
|||||||
return audioCtx;
|
return audioCtx;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function initAudioWorklet() {
|
||||||
|
if (workletLoaded) return true;
|
||||||
|
const ctx = getAudioContext();
|
||||||
|
try {
|
||||||
|
if (ctx.audioWorklet) {
|
||||||
|
await ctx.audioWorklet.addModule('/static/js/services/sonicAudioWorklet.js');
|
||||||
|
workletLoaded = true;
|
||||||
|
console.log('[SonicAudio] AudioWorklet registered successfully.');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[SonicAudio] AudioWorklet initialization fallback:', err.message);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
function analyzeAudioBufferChannels(audioBuffer) {
|
function analyzeAudioBufferChannels(audioBuffer) {
|
||||||
if (!audioBuffer) return { channels: 1, isStereo: false, label: 'MONO' };
|
if (!audioBuffer) return { channels: 1, isStereo: false, label: 'MONO' };
|
||||||
const numChannels = audioBuffer.numberOfChannels;
|
const numChannels = audioBuffer.numberOfChannels;
|
||||||
@@ -34,9 +53,223 @@
|
|||||||
return { audioBuffer, channelInfo };
|
return { audioBuffer, channelInfo };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 1. Non-Destructive Edit Decision List (EDL VFS Engine - 21_CLIENT_PRE.md §4) ──
|
||||||
|
function createEDL(bufferId, buffer) {
|
||||||
|
if (!buffer) return [];
|
||||||
|
return [{
|
||||||
|
id: 'seg_' + Math.random().toString(36).substr(2, 9),
|
||||||
|
sourceBufferId: bufferId,
|
||||||
|
startSample: 0,
|
||||||
|
length: buffer.length,
|
||||||
|
playbackRate: 1.0,
|
||||||
|
isSilence: false,
|
||||||
|
isReversed: false
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteEDLRange(edlList, startSec, endSec, sampleRate) {
|
||||||
|
const startSample = Math.floor(startSec * sampleRate);
|
||||||
|
const endSample = Math.floor(endSec * sampleRate);
|
||||||
|
const result = [];
|
||||||
|
let currentPos = 0;
|
||||||
|
|
||||||
|
for (const seg of edlList) {
|
||||||
|
const segStart = currentPos;
|
||||||
|
const segEnd = currentPos + seg.length;
|
||||||
|
|
||||||
|
if (segEnd <= startSample || segStart >= endSample) {
|
||||||
|
// Completely outside delete window
|
||||||
|
result.push({ ...seg });
|
||||||
|
} else {
|
||||||
|
// Overlaps delete window
|
||||||
|
if (segStart < startSample) {
|
||||||
|
const keepLen = startSample - segStart;
|
||||||
|
result.push({ ...seg, id: 'seg_' + Math.random().toString(36).substr(2, 9), length: keepLen });
|
||||||
|
}
|
||||||
|
if (segEnd > endSample) {
|
||||||
|
const cutOffset = endSample - segStart;
|
||||||
|
const keepLen = segEnd - endSample;
|
||||||
|
result.push({
|
||||||
|
...seg,
|
||||||
|
id: 'seg_' + Math.random().toString(36).substr(2, 9),
|
||||||
|
startSample: seg.startSample + cutOffset,
|
||||||
|
length: keepLen
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
currentPos = segEnd;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderEDLToBuffer(edlList, sourceBuffersMap, sampleRate) {
|
||||||
|
let totalSamples = 0;
|
||||||
|
for (const seg of edlList) {
|
||||||
|
totalSamples += seg.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ctx = getAudioContext();
|
||||||
|
if (totalSamples === 0) {
|
||||||
|
return ctx.createBuffer(2, sampleRate * 0.1, sampleRate);
|
||||||
|
}
|
||||||
|
|
||||||
|
const numChannels = 2;
|
||||||
|
const outBuffer = ctx.createBuffer(numChannels, totalSamples, sampleRate);
|
||||||
|
const outL = outBuffer.getChannelData(0);
|
||||||
|
const outR = outBuffer.getChannelData(1);
|
||||||
|
|
||||||
|
let writeOffset = 0;
|
||||||
|
for (const seg of edlList) {
|
||||||
|
if (seg.isSilence) {
|
||||||
|
writeOffset += seg.length;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const srcBuffer = sourceBuffersMap[seg.sourceBufferId];
|
||||||
|
if (!srcBuffer) {
|
||||||
|
writeOffset += seg.length;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const srcL = srcBuffer.getChannelData(0);
|
||||||
|
const srcR = srcBuffer.numberOfChannels > 1 ? srcBuffer.getChannelData(1) : srcL;
|
||||||
|
const len = Math.min(seg.length, srcBuffer.length - seg.startSample);
|
||||||
|
|
||||||
|
for (let i = 0; i < len; i++) {
|
||||||
|
const readIdx = seg.isReversed
|
||||||
|
? seg.startSample + len - 1 - i
|
||||||
|
: seg.startSample + i;
|
||||||
|
if (readIdx >= 0 && readIdx < srcBuffer.length) {
|
||||||
|
outL[writeOffset + i] = srcL[readIdx];
|
||||||
|
outR[writeOffset + i] = srcR[readIdx];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeOffset += seg.length;
|
||||||
|
}
|
||||||
|
return outBuffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 2. Client-Side DSP Core Engine (21_CLIENT_PRE.md §3 & §5) ──
|
||||||
|
|
||||||
|
// Constant-Power Panning Math
|
||||||
|
function calculateConstantPowerPan(panVal, volDb = 0) {
|
||||||
|
const gain = Math.pow(10, volDb / 20);
|
||||||
|
const theta = ((Math.max(-1, Math.min(1, panVal)) + 1) / 2) * (Math.PI / 2);
|
||||||
|
return {
|
||||||
|
gainL: Math.cos(theta) * gain,
|
||||||
|
gainR: Math.sin(theta) * gain,
|
||||||
|
gainLinear: gain
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dynamics Compressor / Limiter
|
||||||
|
function applyDynamicsCompressor(audioBuffer, thresholdDb = -20, ratio = 4.0, attackMs = 10, releaseMs = 100) {
|
||||||
|
const ctx = getAudioContext();
|
||||||
|
const numChannels = audioBuffer.numberOfChannels;
|
||||||
|
const sampleRate = audioBuffer.sampleRate;
|
||||||
|
const len = audioBuffer.length;
|
||||||
|
const outBuffer = ctx.createBuffer(numChannels, len, sampleRate);
|
||||||
|
|
||||||
|
const attackCoef = Math.exp(-1 / (sampleRate * (attackMs / 1000)));
|
||||||
|
const releaseCoef = Math.exp(-1 / (sampleRate * (releaseMs / 1000)));
|
||||||
|
const thresholdLinear = Math.pow(10, thresholdDb / 20);
|
||||||
|
|
||||||
|
const channelsData = [];
|
||||||
|
const outData = [];
|
||||||
|
for (let ch = 0; ch < numChannels; ch++) {
|
||||||
|
channelsData.push(audioBuffer.getChannelData(ch));
|
||||||
|
outData.push(outBuffer.getChannelData(ch));
|
||||||
|
}
|
||||||
|
|
||||||
|
let envelope = 0;
|
||||||
|
const blockSize = 128;
|
||||||
|
|
||||||
|
for (let i = 0; i < len; i += blockSize) {
|
||||||
|
const currentBlockSize = Math.min(blockSize, len - i);
|
||||||
|
|
||||||
|
// Compute RMS energy of block
|
||||||
|
let sumSq = 0;
|
||||||
|
for (let b = 0; b < currentBlockSize; b++) {
|
||||||
|
const sampleL = channelsData[0][i + b];
|
||||||
|
sumSq += sampleL * sampleL;
|
||||||
|
}
|
||||||
|
const rms = Math.sqrt(sumSq / currentBlockSize);
|
||||||
|
|
||||||
|
// Envelope follower
|
||||||
|
if (rms > envelope) {
|
||||||
|
envelope = attackCoef * envelope + (1 - attackCoef) * rms;
|
||||||
|
} else {
|
||||||
|
envelope = releaseCoef * envelope + (1 - releaseCoef) * rms;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Target Gain calculation
|
||||||
|
let targetGain = 1.0;
|
||||||
|
if (envelope > thresholdLinear && envelope > 0) {
|
||||||
|
const envDb = 20 * Math.log10(envelope);
|
||||||
|
const overDb = envDb - thresholdDb;
|
||||||
|
const compressedDb = thresholdDb + overDb / ratio;
|
||||||
|
targetGain = Math.pow(10, (compressedDb - envDb) / 20);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let b = 0; b < currentBlockSize; b++) {
|
||||||
|
for (let ch = 0; ch < numChannels; ch++) {
|
||||||
|
outData[ch][i + b] = channelsData[ch][i + b] * targetGain;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return outBuffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase Vocoder / Overlap-Add Time Stretch
|
||||||
|
function applyPhaseVocoderStretch(audioBuffer, speedRatio) {
|
||||||
|
if (speedRatio <= 0.01 || Math.abs(speedRatio - 1.0) < 0.001) return audioBuffer;
|
||||||
|
|
||||||
|
const ctx = getAudioContext();
|
||||||
|
const numChannels = audioBuffer.numberOfChannels;
|
||||||
|
const sampleRate = audioBuffer.sampleRate;
|
||||||
|
const inLen = audioBuffer.length;
|
||||||
|
const outLen = Math.floor(inLen / speedRatio);
|
||||||
|
|
||||||
|
const outBuffer = ctx.createBuffer(numChannels, outLen, sampleRate);
|
||||||
|
const windowSize = 1024;
|
||||||
|
const inHop = Math.floor(windowSize / 4);
|
||||||
|
const outHop = Math.floor(inHop / speedRatio);
|
||||||
|
|
||||||
|
// Hanning Window
|
||||||
|
const win = new Float32Array(windowSize);
|
||||||
|
for (let n = 0; n < windowSize; n++) {
|
||||||
|
win[n] = 0.5 * (1 - Math.cos((2 * Math.PI * n) / (windowSize - 1)));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let ch = 0; ch < numChannels; ch++) {
|
||||||
|
const inData = audioBuffer.getChannelData(ch);
|
||||||
|
const outData = outBuffer.getChannelData(ch);
|
||||||
|
|
||||||
|
let inPos = 0;
|
||||||
|
let outPos = 0;
|
||||||
|
|
||||||
|
while (inPos + windowSize < inLen && outPos + windowSize < outLen) {
|
||||||
|
for (let n = 0; n < windowSize; n++) {
|
||||||
|
outData[outPos + n] += inData[Math.floor(inPos) + n] * win[n];
|
||||||
|
}
|
||||||
|
inPos += inHop;
|
||||||
|
outPos += outHop;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return outBuffer;
|
||||||
|
}
|
||||||
|
|
||||||
window.SonicAudio = {
|
window.SonicAudio = {
|
||||||
getAudioContext,
|
getAudioContext,
|
||||||
|
initAudioWorklet,
|
||||||
analyzeAudioBufferChannels,
|
analyzeAudioBufferChannels,
|
||||||
decodeAudioFile
|
decodeAudioFile,
|
||||||
|
// EDL VFS
|
||||||
|
createEDL,
|
||||||
|
deleteEDLRange,
|
||||||
|
renderEDLToBuffer,
|
||||||
|
// DSP Core
|
||||||
|
calculateConstantPowerPan,
|
||||||
|
applyDynamicsCompressor,
|
||||||
|
applyPhaseVocoderStretch
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
// SonicForge Studio AudioWorklet DSP Processor
|
||||||
|
// Real-time priority audio rendering thread for low-latency DSP
|
||||||
|
|
||||||
|
class SonicDSPProcessor extends AudioWorkletProcessor {
|
||||||
|
static get parameterDescriptors() {
|
||||||
|
return [
|
||||||
|
{ name: 'volumeDb', defaultValue: 0, minValue: -60, maxValue: 12 },
|
||||||
|
{ name: 'pan', defaultValue: 0, minValue: -1, maxValue: 1 }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.sampleCount = 0;
|
||||||
|
this.isPlaying = true;
|
||||||
|
this.port.onmessage = (event) => {
|
||||||
|
if (!event.data) return;
|
||||||
|
if (event.data.type === 'SEEK') {
|
||||||
|
this.sampleCount = Math.floor(event.data.sampleIndex || 0);
|
||||||
|
} else if (event.data.type === 'PAUSE') {
|
||||||
|
this.isPlaying = false;
|
||||||
|
} else if (event.data.type === 'PLAY') {
|
||||||
|
this.isPlaying = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
process(inputs, outputs, parameters) {
|
||||||
|
const input = inputs[0];
|
||||||
|
const output = outputs[0];
|
||||||
|
if (!input || !output || input.length === 0) return true;
|
||||||
|
|
||||||
|
const numChannels = Math.min(input.length, output.length);
|
||||||
|
const blockSize = output[0].length;
|
||||||
|
const volumeDbParam = parameters.volumeDb;
|
||||||
|
const panParam = parameters.pan;
|
||||||
|
|
||||||
|
const volDb = volumeDbParam.length === 1 ? volumeDbParam[0] : 0;
|
||||||
|
const panVal = panParam.length === 1 ? panParam[0] : 0;
|
||||||
|
|
||||||
|
// Constant-Power Panning Law (21_CLIENT_PRE.md §5)
|
||||||
|
const gain = Math.pow(10, volDb / 20);
|
||||||
|
const theta = ((panVal + 1) / 2) * (Math.PI / 2);
|
||||||
|
const gainL = Math.cos(theta) * gain;
|
||||||
|
const gainR = Math.sin(theta) * gain;
|
||||||
|
|
||||||
|
const inputL = input[0] || new Float32Array(blockSize);
|
||||||
|
const inputR = input[1] || inputL;
|
||||||
|
const outputL = output[0];
|
||||||
|
const outputR = output[1] || outputL;
|
||||||
|
|
||||||
|
for (let i = 0; i < blockSize; i++) {
|
||||||
|
if (this.isPlaying) {
|
||||||
|
outputL[i] = inputL[i] * gainL;
|
||||||
|
if (output.length > 1) {
|
||||||
|
outputR[i] = inputR[i] * gainR;
|
||||||
|
}
|
||||||
|
this.sampleCount++;
|
||||||
|
} else {
|
||||||
|
outputL[i] = 0;
|
||||||
|
if (output.length > 1) outputR[i] = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lock-free playhead position update to Main Thread
|
||||||
|
if (this.sampleCount % 512 === 0) {
|
||||||
|
this.port.postMessage({
|
||||||
|
type: 'POSITION_UPDATE',
|
||||||
|
sampleCount: this.sampleCount
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
registerProcessor('sonic-dsp-processor', SonicDSPProcessor);
|
||||||
Binary file not shown.
+442
-154
@@ -77,6 +77,9 @@
|
|||||||
function getAudioContext() {
|
function getAudioContext() {
|
||||||
if (!audioCtx) {
|
if (!audioCtx) {
|
||||||
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
||||||
|
if (window.SonicAudio && window.SonicAudio.initAudioWorklet) {
|
||||||
|
window.SonicAudio.initAudioWorklet();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (audioCtx.state === 'suspended') {
|
if (audioCtx.state === 'suspended') {
|
||||||
audioCtx.resume();
|
audioCtx.resume();
|
||||||
@@ -193,6 +196,7 @@
|
|||||||
track,
|
track,
|
||||||
zoom,
|
zoom,
|
||||||
timelineWidth,
|
timelineWidth,
|
||||||
|
viewportWidth,
|
||||||
onSelectRange,
|
onSelectRange,
|
||||||
onPlayheadSet,
|
onPlayheadSet,
|
||||||
isSelected,
|
isSelected,
|
||||||
@@ -202,6 +206,15 @@
|
|||||||
localSelectionTrackId,
|
localSelectionTrackId,
|
||||||
localSelectionStart,
|
localSelectionStart,
|
||||||
currentTime,
|
currentTime,
|
||||||
|
getLocalAnchor,
|
||||||
|
onClearLocalSelection,
|
||||||
|
onSetSelectionMode,
|
||||||
|
onSetSelectionStart,
|
||||||
|
onSetSelectionEnd,
|
||||||
|
onSetCurrentTime,
|
||||||
|
onSetLocalSelectionTrackId,
|
||||||
|
onSetLocalSelectionStart,
|
||||||
|
onSetLocalSelectionEnd,
|
||||||
localSelLeft,
|
localSelLeft,
|
||||||
localSelRight,
|
localSelRight,
|
||||||
onTrackLaneMouseDown,
|
onTrackLaneMouseDown,
|
||||||
@@ -224,24 +237,36 @@
|
|||||||
if (!canvas) return;
|
if (!canvas) return;
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
const dpr = window.devicePixelRatio || 1;
|
const dpr = window.devicePixelRatio || 1;
|
||||||
const width = timelineWidth;
|
const wrapper = canvas.parentElement ? canvas.parentElement.parentElement : null;
|
||||||
const height = canvas.parentElement.clientHeight;
|
|
||||||
|
|
||||||
const maxW = 10000;
|
// Viewport Virtualization: canvas pixel width is strictly capped to visible window width (e.g. 1200px)
|
||||||
const useW = Math.min(timelineWidth, maxW);
|
const scrollLeft = wrapper ? wrapper.scrollLeft : 0;
|
||||||
canvas.width = useW * dpr;
|
const vWidth = viewportWidth || (wrapper ? wrapper.clientWidth : 1200);
|
||||||
canvas.height = height * dpr;
|
const drawWidth = Math.min(timelineWidth, Math.max(vWidth, 1200));
|
||||||
ctx.scale(dpr * (useW / timelineWidth), dpr);
|
const height = canvas.parentElement ? canvas.parentElement.clientHeight : 96;
|
||||||
|
|
||||||
|
canvas.width = Math.round(drawWidth * dpr);
|
||||||
|
canvas.height = Math.round(height * dpr);
|
||||||
|
ctx.scale(dpr, dpr);
|
||||||
|
ctx.imageSmoothingEnabled = false;
|
||||||
|
|
||||||
|
// Position canvas element at current scrollLeft inside track container
|
||||||
|
canvas.style.position = 'absolute';
|
||||||
|
canvas.style.left = `${scrollLeft}px`;
|
||||||
|
canvas.style.width = `${drawWidth}px`;
|
||||||
|
canvas.style.height = `${height}px`;
|
||||||
|
|
||||||
ctx.fillStyle = isSelected ? '#2a2a2a' : (track.id % 2 === 0 ? '#181818' : '#1d1d1d');
|
ctx.fillStyle = isSelected ? '#2a2a2a' : (track.id % 2 === 0 ? '#181818' : '#1d1d1d');
|
||||||
ctx.fillRect(0, 0, width, height);
|
ctx.fillRect(0, 0, drawWidth, height);
|
||||||
|
|
||||||
// Grid lines based on Snap value
|
// Grid lines based on Snap value
|
||||||
ctx.strokeStyle = 'rgba(255, 255, 255, 0.03)';
|
ctx.strokeStyle = 'rgba(255, 255, 255, 0.03)';
|
||||||
ctx.lineWidth = 1;
|
ctx.lineWidth = 1;
|
||||||
const totalSec = width / zoom;
|
|
||||||
|
|
||||||
let gridSpacing = 1.0; // default 1 second
|
const tStart = scrollLeft / zoom;
|
||||||
|
const tEnd = (scrollLeft + drawWidth) / zoom;
|
||||||
|
|
||||||
|
let gridSpacing = 1.0;
|
||||||
if (snapValue && snapValue !== 'free') {
|
if (snapValue && snapValue !== 'free') {
|
||||||
const beatDuration = 60 / parseFloat(bpm || 120);
|
const beatDuration = 60 / parseFloat(bpm || 120);
|
||||||
let divisor = 1;
|
let divisor = 1;
|
||||||
@@ -254,20 +279,20 @@
|
|||||||
|
|
||||||
gridSpacing = beatDuration * divisor;
|
gridSpacing = beatDuration * divisor;
|
||||||
} else {
|
} else {
|
||||||
gridSpacing = 60 / parseFloat(bpm || 120); // default to 1 beat
|
gridSpacing = 60 / parseFloat(bpm || 120);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Guard: if lines are too close, scale grid spacing by multiples of 2
|
|
||||||
let drawSpacing = gridSpacing;
|
let drawSpacing = gridSpacing;
|
||||||
while (drawSpacing * zoom < 10) {
|
while (drawSpacing * zoom < 10) {
|
||||||
drawSpacing *= 2;
|
drawSpacing *= 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let s = 0; s <= totalSec; s += drawSpacing) {
|
const firstGridStep = Math.floor(tStart / drawSpacing) * drawSpacing;
|
||||||
const x = s * zoom;
|
for (let s = firstGridStep; s <= tEnd; s += drawSpacing) {
|
||||||
|
const localX = (s - tStart) * zoom;
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.moveTo(x, 0);
|
ctx.moveTo(localX, 0);
|
||||||
ctx.lineTo(x, height);
|
ctx.lineTo(localX, height);
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -282,70 +307,164 @@
|
|||||||
|
|
||||||
if (clips.length > 0) {
|
if (clips.length > 0) {
|
||||||
clips.forEach(clip => {
|
clips.forEach(clip => {
|
||||||
const data = clip.buffer.getChannelData(0);
|
const numChannels = clip.buffer.numberOfChannels || 1;
|
||||||
|
const dataL = clip.buffer.getChannelData(0);
|
||||||
|
const dataR = numChannels >= 2 ? clip.buffer.getChannelData(1) : dataL;
|
||||||
const sampleRate = clip.buffer.sampleRate;
|
const sampleRate = clip.buffer.sampleRate;
|
||||||
const totalSamples = data.length;
|
const totalSamples = dataL.length;
|
||||||
const originalDuration = totalSamples / sampleRate;
|
const originalDuration = totalSamples / sampleRate;
|
||||||
const clipSpeed = clip.speed || 1.0;
|
const clipSpeed = clip.speed || 1.0;
|
||||||
const duration = originalDuration / clipSpeed;
|
const duration = originalDuration / clipSpeed;
|
||||||
|
|
||||||
const xStart = (clip.startTime || 0) * zoom;
|
const clipStartTime = clip.startTime || 0;
|
||||||
|
const clipEndTime = clipStartTime + duration;
|
||||||
|
|
||||||
|
// Culling: Skip rendering if clip is outside visible viewport window
|
||||||
|
if (clipEndTime < tStart || clipStartTime > tEnd) return;
|
||||||
|
|
||||||
|
const xStartGlobal = clipStartTime * zoom;
|
||||||
const wClip = duration * zoom;
|
const wClip = duration * zoom;
|
||||||
const xEnd = xStart + wClip;
|
|
||||||
|
const xStartLocal = xStartGlobal - scrollLeft;
|
||||||
|
const xEndLocal = xStartLocal + wClip;
|
||||||
|
|
||||||
// 1. Draw Clip Layer Background & Border
|
// 1. Draw Clip Layer Background & Border
|
||||||
|
const clipIdentifier = clip.id === 'default' ? 'default_' + track.id : clip.id;
|
||||||
|
const isClipSelected = selectedClipId && selectedClipId.trackId === track.id && selectedClipId.clipId === clipIdentifier;
|
||||||
|
|
||||||
ctx.fillStyle = isClipSelected ? (track.color ? track.color + '44' : 'rgba(6, 182, 212, 0.30)') : (track.color ? track.color + '22' : 'rgba(6, 182, 212, 0.12)');
|
ctx.fillStyle = isClipSelected ? (track.color ? track.color + '44' : 'rgba(6, 182, 212, 0.30)') : (track.color ? track.color + '22' : 'rgba(6, 182, 212, 0.12)');
|
||||||
ctx.strokeStyle = isClipSelected ? '#fbbf24' : (track.color || '#06b6d4');
|
ctx.strokeStyle = isClipSelected ? '#fbbf24' : (track.color || '#06b6d4');
|
||||||
ctx.lineWidth = isClipSelected ? 1 : 1.5;
|
ctx.lineWidth = isClipSelected ? 1 : 1.5;
|
||||||
|
|
||||||
const clipTop = 8;
|
const clipTop = 4;
|
||||||
const clipHeight = height - 16;
|
const clipHeight = height - 8;
|
||||||
|
|
||||||
// Check if this clip is the selected one
|
|
||||||
const clipIdentifier = clip.id === 'default' ? 'default_' + track.id : clip.id;
|
|
||||||
const isClipSelected = selectedClipId && selectedClipId.trackId === track.id && selectedClipId.clipId === clipIdentifier;
|
|
||||||
|
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
if (ctx.roundRect) {
|
if (ctx.roundRect) {
|
||||||
ctx.roundRect(xStart, clipTop, wClip, clipHeight, 4);
|
ctx.roundRect(xStartLocal, clipTop, wClip, clipHeight, 4);
|
||||||
} else {
|
} else {
|
||||||
ctx.rect(xStart, clipTop, wClip, clipHeight);
|
ctx.rect(xStartLocal, clipTop, wClip, clipHeight);
|
||||||
}
|
}
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
|
|
||||||
// 2. Draw Clip Label & Speed Label (Speed Math: D/D' * 100)
|
// 2. Draw Clip Label & Speed Label
|
||||||
ctx.fillStyle = '#e4e4e7';
|
ctx.fillStyle = '#e4e4e7';
|
||||||
ctx.font = 'bold 9px sans-serif';
|
ctx.font = 'bold 9px sans-serif';
|
||||||
ctx.fillText(clip.name || 'Clip', xStart + 8, clipTop + 14);
|
ctx.fillText(clip.name || 'Clip', Math.max(xStartLocal + 8, 8), clipTop + 12);
|
||||||
|
|
||||||
if (clipSpeed !== 1.0) {
|
if (clipSpeed !== 1.0) {
|
||||||
ctx.fillStyle = '#fbbf24'; // Yellow color
|
ctx.fillStyle = '#fbbf24';
|
||||||
ctx.font = 'bold 8px sans-serif';
|
ctx.font = 'bold 8px sans-serif';
|
||||||
ctx.fillText(`Speed: ${(clipSpeed * 100).toFixed(1)}%`, xStart + 8, clipTop + 24);
|
ctx.fillText(`Speed: ${(clipSpeed * 100).toFixed(1)}%`, Math.max(xStartLocal + 8, 8), clipTop + 22);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draw markers
|
// Draw markers
|
||||||
if (markers && markers.length > 0) {
|
if (markers && markers.length > 0) {
|
||||||
markers.forEach(m => {
|
markers.forEach(m => {
|
||||||
const mx = m.time * zoom;
|
const mxLocal = m.time * zoom - scrollLeft;
|
||||||
|
if (mxLocal >= 0 && mxLocal <= drawWidth) {
|
||||||
ctx.fillStyle = '#fbbf24';
|
ctx.fillStyle = '#fbbf24';
|
||||||
ctx.fillRect(mx - 1, 0, 2, height);
|
ctx.fillRect(mxLocal - 1, 0, 2, height);
|
||||||
ctx.fillStyle = 'rgba(251, 191, 36, 0.1)';
|
}
|
||||||
ctx.fillRect(mx - 1, 0, 2, height);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Peak waveform drawing only within clip bounds (speed adjusted)
|
// 3. Peak / Vector Waveform Drawing (Sound Forge Dual Stereo Channel Split Match)
|
||||||
ctx.strokeStyle = isSelected ? '#22d3ee' : '#a7f3d0';
|
const drawXStartLocal = Math.max(0, Math.floor(xStartLocal));
|
||||||
ctx.lineWidth = 1;
|
const drawXEndLocal = Math.min(drawWidth, Math.ceil(xEndLocal));
|
||||||
|
|
||||||
const drawXStart = Math.max(0, Math.floor(xStart));
|
|
||||||
const drawXEnd = Math.min(width, Math.ceil(xEnd));
|
|
||||||
const samplesPerPixel = (sampleRate / zoom) * clipSpeed;
|
const samplesPerPixel = (sampleRate / zoom) * clipSpeed;
|
||||||
|
|
||||||
for (let px = drawXStart; px < drawXEnd; px++) {
|
const isStereo = numChannels >= 2;
|
||||||
const timeInClip = ((px - xStart) / zoom) * clipSpeed;
|
const channelConfigs = isStereo ? [
|
||||||
|
{ data: dataL, mid: height / 4, chHeight: height / 2 - 8, label: '1' },
|
||||||
|
{ data: dataR, mid: (3 * height) / 4, chHeight: height / 2 - 8, label: '2' }
|
||||||
|
] : [
|
||||||
|
{ data: dataL, mid: height / 2, chHeight: clipHeight - 12, label: 'MONO' }
|
||||||
|
];
|
||||||
|
|
||||||
|
if (isStereo) {
|
||||||
|
ctx.strokeStyle = 'rgba(255, 255, 255, 0.12)';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(xStartLocal, height / 2);
|
||||||
|
ctx.lineTo(xStartLocal + wClip, height / 2);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
channelConfigs.forEach(ch => {
|
||||||
|
const data = ch.data;
|
||||||
|
const mid = ch.mid;
|
||||||
|
const peakRatio = ch.chHeight * 0.42;
|
||||||
|
|
||||||
|
// Decibel Amplitude Grid Lines (+6.0dB, -Inf, -6.0dB)
|
||||||
|
const yPlus6 = mid - peakRatio * 0.8;
|
||||||
|
const yMinus6 = mid + peakRatio * 0.8;
|
||||||
|
|
||||||
|
ctx.strokeStyle = 'rgba(255, 255, 255, 0.08)';
|
||||||
|
ctx.lineWidth = 0.5;
|
||||||
|
|
||||||
|
ctx.beginPath(); ctx.moveTo(xStartLocal, yPlus6); ctx.lineTo(xStartLocal + wClip, yPlus6); ctx.stroke();
|
||||||
|
ctx.beginPath(); ctx.moveTo(xStartLocal, mid); ctx.lineTo(xStartLocal + wClip, mid); ctx.stroke();
|
||||||
|
ctx.beginPath(); ctx.moveTo(xStartLocal, yMinus6); ctx.lineTo(xStartLocal + wClip, yMinus6); ctx.stroke();
|
||||||
|
|
||||||
|
// Decibel Text Labels (Sound Forge Monospace)
|
||||||
|
ctx.fillStyle = '#71717a';
|
||||||
|
ctx.font = '8px monospace';
|
||||||
|
const labelX = Math.max(xStartLocal + 4, 4);
|
||||||
|
ctx.fillText('+6.0', labelX, yPlus6 - 2);
|
||||||
|
ctx.fillText('-Inf', labelX, mid - 2);
|
||||||
|
ctx.fillText('-6.0', labelX, yMinus6 + 8);
|
||||||
|
|
||||||
|
// Sound Forge Channel ID Badge (1 or 2)
|
||||||
|
if (isStereo) {
|
||||||
|
ctx.fillStyle = 'rgba(6, 182, 212, 0.85)';
|
||||||
|
ctx.font = 'bold 9px monospace';
|
||||||
|
ctx.fillText(ch.label, Math.min(xStartLocal + wClip - 12, drawWidth - 16), mid - ch.chHeight * 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.strokeStyle = '#5bc0be'; // Cornflower Blue
|
||||||
|
ctx.lineWidth = 1.2;
|
||||||
|
|
||||||
|
if (samplesPerPixel < 4) {
|
||||||
|
// High-zoom continuous vector line rendering (#5bc0be Cornflower Blue)
|
||||||
|
const visibleTStart = (drawXStartLocal - xStartLocal) / zoom;
|
||||||
|
const visibleTEnd = (drawXEndLocal - xStartLocal) / zoom;
|
||||||
|
|
||||||
|
const startSample = Math.max(0, Math.floor(visibleTStart * clipSpeed * sampleRate));
|
||||||
|
const endSample = Math.min(totalSamples, Math.ceil(visibleTEnd * clipSpeed * sampleRate));
|
||||||
|
|
||||||
|
ctx.beginPath();
|
||||||
|
let first = true;
|
||||||
|
for (let i = startSample; i < endSample; i++) {
|
||||||
|
const sampleTime = (i / sampleRate) / clipSpeed;
|
||||||
|
const pxLocal = xStartLocal + sampleTime * zoom;
|
||||||
|
const y = mid - data[i] * peakRatio;
|
||||||
|
if (first) {
|
||||||
|
ctx.moveTo(pxLocal, y);
|
||||||
|
first = false;
|
||||||
|
} else {
|
||||||
|
ctx.lineTo(pxLocal, y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Draw granular sample nodes (#6ee7b7 Green Emerald, radius r = 2px) when samplesPerPixel < 1.5
|
||||||
|
if (samplesPerPixel < 1.5) {
|
||||||
|
ctx.fillStyle = '#6ee7b7';
|
||||||
|
for (let i = startSample; i < endSample; i++) {
|
||||||
|
const sampleTime = (i / sampleRate) / clipSpeed;
|
||||||
|
const pxLocal = xStartLocal + sampleTime * zoom;
|
||||||
|
const y = mid - data[i] * peakRatio;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(pxLocal, y, 2, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Coarse view vertical peak min/max bars per pixel
|
||||||
|
for (let pxLocal = drawXStartLocal; pxLocal < drawXEndLocal; pxLocal++) {
|
||||||
|
const timeInClip = ((pxLocal - xStartLocal) / zoom) * clipSpeed;
|
||||||
const sampleIdx = Math.floor(timeInClip * sampleRate);
|
const sampleIdx = Math.floor(timeInClip * sampleRate);
|
||||||
const chunkSize = Math.max(1, Math.floor(samplesPerPixel));
|
const chunkSize = Math.max(1, Math.floor(samplesPerPixel));
|
||||||
const chunkStart = Math.max(0, sampleIdx - Math.floor(chunkSize / 2));
|
const chunkStart = Math.max(0, sampleIdx - Math.floor(chunkSize / 2));
|
||||||
@@ -357,40 +476,43 @@
|
|||||||
if (abs > maxVal) maxVal = abs;
|
if (abs > maxVal) maxVal = abs;
|
||||||
}
|
}
|
||||||
|
|
||||||
const mid = height / 2;
|
const peakHeight = maxVal * peakRatio;
|
||||||
const peakHeight = maxVal * (clipHeight * 0.45);
|
|
||||||
|
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.moveTo(px, mid - peakHeight);
|
ctx.moveTo(pxLocal, mid - peakHeight);
|
||||||
ctx.lineTo(px, mid + peakHeight);
|
ctx.lineTo(pxLocal, mid + peakHeight);
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
ctx.fillStyle = '#444';
|
ctx.fillStyle = '#444';
|
||||||
ctx.font = '12px Inter, sans-serif';
|
ctx.font = '12px Inter, sans-serif';
|
||||||
ctx.textAlign = 'center';
|
ctx.textAlign = 'center';
|
||||||
ctx.fillText('Kéo thả hoặc tải file âm thanh vào Track này', width / 2, height / 2);
|
ctx.fillText('Kéo thả hoặc tải file âm thanh vào Track này', drawWidth / 2, height / 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Selection highlight - local selection on this track
|
// Selection highlight - local selection on this track
|
||||||
if (selectionMode === 'local' && localSelectionTrackId === track.id &&
|
if (selectionMode === 'local' && localSelectionTrackId === track.id &&
|
||||||
localSelLeft !== null && localSelRight !== null && localSelRight > localSelLeft) {
|
localSelLeft !== null && localSelRight !== null && localSelRight > localSelLeft) {
|
||||||
const hlLeft = localSelLeft * zoom;
|
const hlLeftLocal = localSelLeft * zoom - scrollLeft;
|
||||||
const hlWidth = (localSelRight - localSelLeft) * zoom;
|
const hlWidth = (localSelRight - localSelLeft) * zoom;
|
||||||
ctx.fillStyle = 'rgba(245, 158, 11, 0.15)';
|
ctx.fillStyle = 'rgba(245, 158, 11, 0.15)';
|
||||||
ctx.fillRect(hlLeft, 0, hlWidth, height);
|
ctx.fillRect(hlLeftLocal, 0, hlWidth, height);
|
||||||
ctx.strokeStyle = '#f59e0b';
|
ctx.strokeStyle = '#f59e0b';
|
||||||
ctx.lineWidth = 1;
|
ctx.lineWidth = 1;
|
||||||
ctx.strokeRect(hlLeft, 0, hlWidth, height);
|
ctx.strokeRect(hlLeftLocal, 0, hlWidth, height);
|
||||||
}
|
}
|
||||||
|
|
||||||
}, [track, zoom, timelineWidth, isSelected, markers, selectionMode, localSelectionTrackId, localSelLeft, localSelRight, snapValue, bpm]);
|
}, [track, zoom, timelineWidth, viewportWidth, isSelected, markers, selectionMode, localSelectionTrackId, localSelLeft, localSelRight, snapValue, bpm]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div style={{ width: `${timelineWidth}px`, height: '100%', position: 'relative', overflow: 'hidden' }}>
|
||||||
<canvas
|
<canvas
|
||||||
ref={canvasRef}
|
ref={canvasRef}
|
||||||
className="w-full h-full cursor-crosshair"
|
style={{ position: 'absolute', top: 0, left: 0, imageRendering: 'pixelated' }}
|
||||||
|
className="cursor-crosshair"
|
||||||
onMouseMove={(e) => {
|
onMouseMove={(e) => {
|
||||||
if (!canvasRef.current) return;
|
if (!canvasRef.current) return;
|
||||||
const rect = canvasRef.current.getBoundingClientRect();
|
const rect = canvasRef.current.getBoundingClientRect();
|
||||||
@@ -407,6 +529,25 @@
|
|||||||
speed: track.speed || 1.0
|
speed: track.speed || 1.0
|
||||||
}] : []);
|
}] : []);
|
||||||
|
|
||||||
|
// 1. Shift+Drag (TOP PRIORITY): Range selection on track waveform
|
||||||
|
if (e.shiftKey && e.buttons > 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
const currentAnchor = getLocalAnchor ? getLocalAnchor() : null;
|
||||||
|
const anchor = (currentAnchor !== null && currentAnchor !== undefined) ? currentAnchor : (localSelectionStart !== null ? localSelectionStart : currentTime);
|
||||||
|
const selS = Math.min(anchor, time);
|
||||||
|
const selE = Math.max(anchor, time);
|
||||||
|
if (onSetSelectionMode) onSetSelectionMode('local');
|
||||||
|
if (onSetLocalSelectionTrackId) onSetLocalSelectionTrackId(track.id);
|
||||||
|
if (onSetLocalSelectionStart) onSetLocalSelectionStart(selS);
|
||||||
|
if (onSetLocalSelectionEnd) onSetLocalSelectionEnd(selE);
|
||||||
|
if (onSetSelectionStart) onSetSelectionStart(selS);
|
||||||
|
if (onSetSelectionEnd) onSetSelectionEnd(selE);
|
||||||
|
if (onSetCurrentTime) onSetCurrentTime(time);
|
||||||
|
if (onSelectTrack) onSelectTrack(track.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Check if hovering near local selection boundaries of this track
|
// Check if hovering near local selection boundaries of this track
|
||||||
const isLocal = selectionMode === 'local' && localSelectionTrackId === track.id;
|
const isLocal = selectionMode === 'local' && localSelectionTrackId === track.id;
|
||||||
if (isLocal && localSelLeft !== null && localSelRight !== null) {
|
if (isLocal && localSelLeft !== null && localSelRight !== null) {
|
||||||
@@ -464,6 +605,25 @@
|
|||||||
speed: track.speed || 1.0
|
speed: track.speed || 1.0
|
||||||
}] : []);
|
}] : []);
|
||||||
|
|
||||||
|
// 1. Shift+Click (TOP PRIORITY): Range selection on track waveform
|
||||||
|
if (e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
const currentAnchor = getLocalAnchor ? getLocalAnchor() : null;
|
||||||
|
const anchor = (currentAnchor !== null && currentAnchor !== undefined) ? currentAnchor : (localSelectionStart !== null ? localSelectionStart : currentTime);
|
||||||
|
const selS = Math.min(anchor, time);
|
||||||
|
const selE = Math.max(anchor, time);
|
||||||
|
if (onSetSelectionMode) onSetSelectionMode('local');
|
||||||
|
if (onSetLocalSelectionTrackId) onSetLocalSelectionTrackId(track.id);
|
||||||
|
if (onSetLocalSelectionStart) onSetLocalSelectionStart(selS);
|
||||||
|
if (onSetLocalSelectionEnd) onSetLocalSelectionEnd(selE);
|
||||||
|
if (onSetSelectionStart) onSetSelectionStart(selS);
|
||||||
|
if (onSetSelectionEnd) onSetSelectionEnd(selE);
|
||||||
|
if (onSetCurrentTime) onSetCurrentTime(time);
|
||||||
|
if (onSelectTrack) onSelectTrack(track.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Check if dragging selection boundaries (local mode)
|
// Check if dragging selection boundaries (local mode)
|
||||||
const isLocal = selectionMode === 'local' && localSelectionTrackId === track.id;
|
const isLocal = selectionMode === 'local' && localSelectionTrackId === track.id;
|
||||||
if (isLocal && localSelLeft !== null && localSelRight !== null) {
|
if (isLocal && localSelLeft !== null && localSelRight !== null) {
|
||||||
@@ -532,6 +692,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (activeTool === 'grab') {
|
if (activeTool === 'grab') {
|
||||||
|
if (onTrackLaneMouseDown) {
|
||||||
|
onTrackLaneMouseDown(track.id, time, e);
|
||||||
|
}
|
||||||
if (clickedClip) {
|
if (clickedClip) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -556,31 +719,14 @@
|
|||||||
|
|
||||||
// Check if Ctrl+Click outside active local selection to clear
|
// Check if Ctrl+Click outside active local selection to clear
|
||||||
if (e.ctrlKey) {
|
if (e.ctrlKey) {
|
||||||
const minSel = localSelLeft !== null && localSelRight !== null ? Math.min(localSelLeft, localSelRight) : null;
|
if (onClearLocalSelection) onClearLocalSelection();
|
||||||
const maxSel = localSelLeft !== null && localSelRight !== null ? Math.max(localSelLeft, localSelRight) : null;
|
if (onSetSelectionMode) onSetSelectionMode(null);
|
||||||
const isInsideSelection = isLocal && minSel !== null && maxSel !== null && time >= minSel && time <= maxSel;
|
if (onSetSelectionStart) onSetSelectionStart(null);
|
||||||
if (!isInsideSelection) {
|
if (onSetSelectionEnd) onSetSelectionEnd(null);
|
||||||
clearLocalSelection();
|
|
||||||
setSelectionMode(null);
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (e.shiftKey) {
|
|
||||||
// Shift+click on track: extend LOCAL selection from current start to clicked position
|
|
||||||
const prevStart = localSelectionStart !== null ? localSelectionStart : currentTime;
|
|
||||||
const selS = Math.min(prevStart, time);
|
|
||||||
const selE = Math.max(prevStart, time);
|
|
||||||
setSelectionMode('local');
|
|
||||||
setLocalSelectionTrackId(track.id);
|
|
||||||
setLocalSelectionStart(selS);
|
|
||||||
setLocalSelectionEnd(selE);
|
|
||||||
setSelectionStart(selS);
|
|
||||||
setSelectionEnd(selE);
|
|
||||||
setCurrentTime(time);
|
|
||||||
e.stopPropagation();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
onPlayheadSet(time);
|
onPlayheadSet(time);
|
||||||
if (onTrackLaneMouseDown) {
|
if (onTrackLaneMouseDown) {
|
||||||
onTrackLaneMouseDown(track.id, time, e);
|
onTrackLaneMouseDown(track.id, time, e);
|
||||||
@@ -623,10 +769,11 @@
|
|||||||
if (onContextMenu) onContextMenu(e, track.id, time);
|
if (onContextMenu) onContextMenu(e, track.id, time);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const TempoTrackLane = ({ bpm, zoom, timelineWidth, onPlayheadSet, snapValue, onRulerMouseDown }) => {
|
const TempoTrackLane = ({ bpm, zoom, timelineWidth, viewportWidth, onPlayheadSet, snapValue, onRulerMouseDown }) => {
|
||||||
const canvasRef = useRef(null);
|
const canvasRef = useRef(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -634,46 +781,55 @@
|
|||||||
if (!canvas) return;
|
if (!canvas) return;
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
const dpr = window.devicePixelRatio || 1;
|
const dpr = window.devicePixelRatio || 1;
|
||||||
const width = timelineWidth;
|
const wrapper = canvas.parentElement ? canvas.parentElement.parentElement : null;
|
||||||
const parent = canvas.parentElement;
|
const scrollLeft = wrapper ? wrapper.scrollLeft : 0;
|
||||||
const height = parent ? parent.clientHeight : 40;
|
const vWidth = viewportWidth || (wrapper ? wrapper.clientWidth : 1200);
|
||||||
|
const drawWidth = Math.min(timelineWidth, Math.max(vWidth, 1200));
|
||||||
|
const height = canvas.parentElement ? canvas.parentElement.clientHeight : 40;
|
||||||
|
|
||||||
const maxW = 10000;
|
canvas.width = Math.round(drawWidth * dpr);
|
||||||
const useW = Math.min(timelineWidth, maxW);
|
canvas.height = Math.round(height * dpr);
|
||||||
canvas.width = useW * dpr;
|
ctx.scale(dpr, dpr);
|
||||||
canvas.height = height * dpr;
|
ctx.imageSmoothingEnabled = false;
|
||||||
ctx.scale(dpr * (useW / timelineWidth), dpr);
|
|
||||||
|
canvas.style.position = 'absolute';
|
||||||
|
canvas.style.left = `${scrollLeft}px`;
|
||||||
|
canvas.style.width = `${drawWidth}px`;
|
||||||
|
canvas.style.height = `${height}px`;
|
||||||
|
|
||||||
ctx.fillStyle = '#1a1a2e';
|
ctx.fillStyle = '#1a1a2e';
|
||||||
ctx.fillRect(0, 0, width, height);
|
ctx.fillRect(0, 0, drawWidth, height);
|
||||||
|
|
||||||
const beatDuration = 60 / bpm;
|
const beatDuration = 60 / bpm;
|
||||||
const barDuration = beatDuration * 4;
|
const barDuration = beatDuration * 4;
|
||||||
const totalSec = width / zoom;
|
|
||||||
|
|
||||||
for (let t = 0; t <= totalSec; t += beatDuration) {
|
const tStart = scrollLeft / zoom;
|
||||||
|
const tEnd = (scrollLeft + drawWidth) / zoom;
|
||||||
|
|
||||||
|
const firstBeat = Math.floor(tStart / beatDuration) * beatDuration;
|
||||||
|
for (let t = firstBeat; t <= tEnd; t += beatDuration) {
|
||||||
const beatNum = Math.floor(t / beatDuration) + 1;
|
const beatNum = Math.floor(t / beatDuration) + 1;
|
||||||
const isBar = beatNum % 4 === 1;
|
const isBar = beatNum % 4 === 1;
|
||||||
const x = t * zoom;
|
const localX = (t - tStart) * zoom;
|
||||||
|
|
||||||
if (isBar) {
|
if (isBar) {
|
||||||
ctx.strokeStyle = 'rgba(255, 255, 255, 0.3)';
|
ctx.strokeStyle = 'rgba(255, 255, 255, 0.3)';
|
||||||
ctx.lineWidth = 1.5;
|
ctx.lineWidth = 1.5;
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.moveTo(x, 0);
|
ctx.moveTo(localX, 0);
|
||||||
ctx.lineTo(x, height);
|
ctx.lineTo(localX, height);
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
|
|
||||||
ctx.fillStyle = 'rgba(255, 255, 255, 0.7)';
|
ctx.fillStyle = 'rgba(255, 255, 255, 0.7)';
|
||||||
ctx.font = 'bold 9px Inter, sans-serif';
|
ctx.font = 'bold 9px Inter, sans-serif';
|
||||||
ctx.textAlign = 'left';
|
ctx.textAlign = 'left';
|
||||||
ctx.fillText(`${Math.ceil(beatNum / 4)}`, x + 3, 11);
|
ctx.fillText(`${Math.ceil(beatNum / 4)}`, localX + 3, 11);
|
||||||
} else {
|
} else {
|
||||||
ctx.strokeStyle = 'rgba(255, 255, 255, 0.08)';
|
ctx.strokeStyle = 'rgba(255, 255, 255, 0.08)';
|
||||||
ctx.lineWidth = 1;
|
ctx.lineWidth = 1;
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.moveTo(x, 0);
|
ctx.moveTo(localX, 0);
|
||||||
ctx.lineTo(x, height);
|
ctx.lineTo(localX, height);
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -692,13 +848,14 @@
|
|||||||
|
|
||||||
const snapInterval = beatDuration * divisor;
|
const snapInterval = beatDuration * divisor;
|
||||||
if (snapInterval * zoom >= 4) {
|
if (snapInterval * zoom >= 4) {
|
||||||
for (let t = 0; t <= totalSec; t += snapInterval) {
|
const firstSnap = Math.floor(tStart / snapInterval) * snapInterval;
|
||||||
|
for (let t = firstSnap; t <= tEnd; t += snapInterval) {
|
||||||
const onBeat = Math.abs((t / beatDuration) - Math.round(t / beatDuration)) < 0.001;
|
const onBeat = Math.abs((t / beatDuration) - Math.round(t / beatDuration)) < 0.001;
|
||||||
if (!onBeat) {
|
if (!onBeat) {
|
||||||
const x = t * zoom;
|
const localX = (t - tStart) * zoom;
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.moveTo(x, height - 6);
|
ctx.moveTo(localX, height - 6);
|
||||||
ctx.lineTo(x, height);
|
ctx.lineTo(localX, height);
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -708,14 +865,16 @@
|
|||||||
ctx.fillStyle = 'rgba(255, 255, 255, 0.35)';
|
ctx.fillStyle = 'rgba(255, 255, 255, 0.35)';
|
||||||
ctx.font = 'bold 10px Inter, sans-serif';
|
ctx.font = 'bold 10px Inter, sans-serif';
|
||||||
ctx.textAlign = 'right';
|
ctx.textAlign = 'right';
|
||||||
ctx.fillText(`${bpm} BPM`, width - 6, 12);
|
ctx.fillText(`${bpm} BPM`, drawWidth - 6, 12);
|
||||||
|
|
||||||
}, [bpm, zoom, timelineWidth, snapValue]);
|
}, [bpm, zoom, timelineWidth, viewportWidth, snapValue]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div style={{ width: `${timelineWidth}px`, height: '100%', position: 'relative', overflow: 'hidden' }}>
|
||||||
<canvas
|
<canvas
|
||||||
ref={canvasRef}
|
ref={canvasRef}
|
||||||
className="w-full h-full cursor-crosshair"
|
style={{ position: 'absolute', top: 0, left: 0, imageRendering: 'pixelated' }}
|
||||||
|
className="cursor-crosshair"
|
||||||
onMouseDown={(e) => {
|
onMouseDown={(e) => {
|
||||||
const wrapper = canvasRef.current?.parentElement?.parentElement;
|
const wrapper = canvasRef.current?.parentElement?.parentElement;
|
||||||
const scrollLeft = wrapper ? wrapper.scrollLeft : 0;
|
const scrollLeft = wrapper ? wrapper.scrollLeft : 0;
|
||||||
@@ -723,17 +882,17 @@
|
|||||||
const x = e.clientX - rect.left + scrollLeft;
|
const x = e.clientX - rect.left + scrollLeft;
|
||||||
const time = Math.max(0, x / zoom);
|
const time = Math.max(0, x / zoom);
|
||||||
if (e.shiftKey) {
|
if (e.shiftKey) {
|
||||||
// Shift+click on tempo track: extend global selection from currentTime to clicked position
|
e.preventDefault();
|
||||||
if (onRulerMouseDown) onRulerMouseDown({ ...e, shiftKey: true });
|
|
||||||
else onPlayheadSet(time);
|
|
||||||
} else {
|
|
||||||
// Click on tempo track: set playhead and start global selection point
|
|
||||||
if (onRulerMouseDown) onRulerMouseDown(e);
|
|
||||||
else onPlayheadSet(time);
|
|
||||||
}
|
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
}
|
||||||
|
if (onRulerMouseDown) {
|
||||||
|
onRulerMouseDown(e);
|
||||||
|
} else {
|
||||||
|
onPlayheadSet(time, e.shiftKey);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -742,6 +901,7 @@
|
|||||||
const canvasRef = useRef(null);
|
const canvasRef = useRef(null);
|
||||||
const isStretchingRef = useRef(false);
|
const isStretchingRef = useRef(false);
|
||||||
const stretchStartRef = useRef({ mouseX: 0, originalDuration: 0, originalSpeed: 1.0 });
|
const stretchStartRef = useRef({ mouseX: 0, originalDuration: 0, originalSpeed: 1.0 });
|
||||||
|
const subTabAnchorRef = useRef(null);
|
||||||
|
|
||||||
const isStereo = channelInfo ? channelInfo.isStereo : (buffer && buffer.numberOfChannels >= 2);
|
const isStereo = channelInfo ? channelInfo.isStereo : (buffer && buffer.numberOfChannels >= 2);
|
||||||
const channelLabel = channelInfo ? channelInfo.label : (isStereo ? 'STEREO' : 'MONO');
|
const channelLabel = channelInfo ? channelInfo.label : (isStereo ? 'STEREO' : 'MONO');
|
||||||
@@ -753,17 +913,24 @@
|
|||||||
if (!canvas || !buffer) return;
|
if (!canvas || !buffer) return;
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
const dpr = window.devicePixelRatio || 1;
|
const dpr = window.devicePixelRatio || 1;
|
||||||
const rect = canvas.getBoundingClientRect();
|
const wrapper = canvas.parentElement ? canvas.parentElement.parentElement : null;
|
||||||
const w = timelineWidth;
|
const scrollLeft = wrapper ? wrapper.scrollLeft : 0;
|
||||||
const h = rect.height;
|
const vWidth = wrapper ? wrapper.clientWidth : 1200;
|
||||||
const maxW = 10000;
|
const drawWidth = Math.min(timelineWidth, Math.max(vWidth, 1200));
|
||||||
const useW = Math.min(timelineWidth, maxW);
|
const h = canvas.parentElement ? canvas.parentElement.clientHeight : 200;
|
||||||
canvas.width = useW * dpr;
|
|
||||||
canvas.height = h * dpr;
|
canvas.width = Math.round(drawWidth * dpr);
|
||||||
ctx.scale(dpr * (useW / timelineWidth), dpr);
|
canvas.height = Math.round(h * dpr);
|
||||||
|
ctx.scale(dpr, dpr);
|
||||||
|
ctx.imageSmoothingEnabled = false;
|
||||||
|
|
||||||
|
canvas.style.position = 'absolute';
|
||||||
|
canvas.style.left = `${scrollLeft}px`;
|
||||||
|
canvas.style.width = `${drawWidth}px`;
|
||||||
|
canvas.style.height = `${h}px`;
|
||||||
|
|
||||||
ctx.fillStyle = '#181818';
|
ctx.fillStyle = '#181818';
|
||||||
ctx.fillRect(0, 0, w, h);
|
ctx.fillRect(0, 0, drawWidth, h);
|
||||||
|
|
||||||
const data = buffer.getChannelData(0);
|
const data = buffer.getChannelData(0);
|
||||||
const len = data.length;
|
const len = data.length;
|
||||||
@@ -998,11 +1165,64 @@
|
|||||||
const drawXEnd = Math.min(w, Math.ceil(xEnd));
|
const drawXEnd = Math.min(w, Math.ceil(xEnd));
|
||||||
const samplesPerPixel = (buffer.sampleRate / zoom) * speed;
|
const samplesPerPixel = (buffer.sampleRate / zoom) * speed;
|
||||||
const bufDur = buffer.duration;
|
const bufDur = buffer.duration;
|
||||||
|
|
||||||
ctx.strokeStyle = '#6ee7b7';
|
|
||||||
ctx.lineWidth = 1;
|
|
||||||
const mid = h / 2;
|
const mid = h / 2;
|
||||||
|
const peakRatio = clipHeight * 0.45;
|
||||||
|
|
||||||
|
const getFadeGainAtTime = (t) => {
|
||||||
|
if (fadeInLen > 0 && t < fadeInLen) {
|
||||||
|
return (1 - Math.cos(Math.PI * t / fadeInLen)) / 2;
|
||||||
|
} else if (fadeOutLen > 0 && t > bufDur - fadeOutLen) {
|
||||||
|
const ratio = (t - (bufDur - fadeOutLen)) / fadeOutLen;
|
||||||
|
return (1 + Math.cos(Math.PI * ratio)) / 2;
|
||||||
|
}
|
||||||
|
return 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
ctx.strokeStyle = '#5bc0be';
|
||||||
|
ctx.lineWidth = 1.2;
|
||||||
|
|
||||||
|
if (samplesPerPixel < 4) {
|
||||||
|
// High-zoom continuous vector line rendering (#5bc0be Cornflower Blue)
|
||||||
|
const startSample = Math.max(0, Math.floor(((drawXStart - xStart) / zoom) * speed * buffer.sampleRate));
|
||||||
|
const endSample = Math.min(len, Math.ceil(((drawXEnd - xStart) / zoom) * speed * buffer.sampleRate));
|
||||||
|
|
||||||
|
ctx.beginPath();
|
||||||
|
let first = true;
|
||||||
|
for (let i = startSample; i < endSample; i++) {
|
||||||
|
const timeInClip = (i / buffer.sampleRate) / speed;
|
||||||
|
const px = xStart + (timeInClip / speed) * zoom;
|
||||||
|
const fadeGain = getFadeGainAtTime(timeInClip);
|
||||||
|
const volGain = getVolumeGainAtTime(px / zoom);
|
||||||
|
const totalGain = fadeGain * volGain;
|
||||||
|
const y = mid - (data[i] * totalGain) * peakRatio;
|
||||||
|
|
||||||
|
if (first) {
|
||||||
|
ctx.moveTo(px, y);
|
||||||
|
first = false;
|
||||||
|
} else {
|
||||||
|
ctx.lineTo(px, y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Draw granular sample nodes (#6ee7b7 Green Emerald, radius r = 2px) when samplesPerPixel < 1.5
|
||||||
|
if (samplesPerPixel < 1.5) {
|
||||||
|
ctx.fillStyle = '#6ee7b7';
|
||||||
|
for (let i = startSample; i < endSample; i++) {
|
||||||
|
const timeInClip = (i / buffer.sampleRate) / speed;
|
||||||
|
const px = xStart + (timeInClip / speed) * zoom;
|
||||||
|
const fadeGain = getFadeGainAtTime(timeInClip);
|
||||||
|
const volGain = getVolumeGainAtTime(px / zoom);
|
||||||
|
const totalGain = fadeGain * volGain;
|
||||||
|
const y = mid - (data[i] * totalGain) * peakRatio;
|
||||||
|
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(px, y, 2, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Coarse view vertical peak min/max bars
|
||||||
for (let px = drawXStart; px < drawXEnd; px++) {
|
for (let px = drawXStart; px < drawXEnd; px++) {
|
||||||
const timeInClip = (px / zoom) * speed;
|
const timeInClip = (px / zoom) * speed;
|
||||||
const sampleIdx = Math.floor(timeInClip * buffer.sampleRate);
|
const sampleIdx = Math.floor(timeInClip * buffer.sampleRate);
|
||||||
@@ -1010,16 +1230,7 @@
|
|||||||
const chunkStart = Math.max(0, sampleIdx - Math.floor(chunkSize / 2));
|
const chunkStart = Math.max(0, sampleIdx - Math.floor(chunkSize / 2));
|
||||||
const chunkEnd = Math.min(len, chunkStart + chunkSize);
|
const chunkEnd = Math.min(len, chunkStart + chunkSize);
|
||||||
|
|
||||||
// Compute fade gain at this position
|
let fadeGain = getFadeGainAtTime(timeInClip);
|
||||||
let fadeGain = 1;
|
|
||||||
if (fadeInLen > 0 && timeInClip < fadeInLen) {
|
|
||||||
fadeGain = (1 - Math.cos(Math.PI * timeInClip / fadeInLen)) / 2;
|
|
||||||
} else if (fadeOutLen > 0 && timeInClip > bufDur - fadeOutLen) {
|
|
||||||
const t = (timeInClip - (bufDur - fadeOutLen)) / fadeOutLen;
|
|
||||||
fadeGain = (1 + Math.cos(Math.PI * t)) / 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compute volume automation gain in real-time
|
|
||||||
const volGain = getVolumeGainAtTime(px / zoom);
|
const volGain = getVolumeGainAtTime(px / zoom);
|
||||||
const totalGain = fadeGain * volGain;
|
const totalGain = fadeGain * volGain;
|
||||||
|
|
||||||
@@ -1032,13 +1243,14 @@
|
|||||||
}
|
}
|
||||||
maxVal *= totalGain;
|
maxVal *= totalGain;
|
||||||
minVal *= totalGain;
|
minVal *= totalGain;
|
||||||
const yTop = mid + (minVal * (clipHeight * 0.45));
|
const yTop = mid + (minVal * peakRatio);
|
||||||
const yBottom = mid + (maxVal * (clipHeight * 0.45));
|
const yBottom = mid + (maxVal * peakRatio);
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.moveTo(px, yTop);
|
ctx.moveTo(px, yTop);
|
||||||
ctx.lineTo(px, yBottom);
|
ctx.lineTo(px, yBottom);
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Volume: 0dB at 1/3 from top
|
// Volume: 0dB at 1/3 from top
|
||||||
const autoY = (node) => {
|
const autoY = (node) => {
|
||||||
@@ -1275,6 +1487,18 @@
|
|||||||
const mouseX = e.clientX - rect.left + scrollLeft;
|
const mouseX = e.clientX - rect.left + scrollLeft;
|
||||||
const startTime = Math.max(0, Math.min(wallDuration, mouseX / zoom));
|
const startTime = Math.max(0, Math.min(wallDuration, mouseX / zoom));
|
||||||
|
|
||||||
|
// Shift + Click range selection in SubTab Waveform
|
||||||
|
if (e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
const anchor = subTabAnchorRef.current !== null && subTabAnchorRef.current !== undefined ? subTabAnchorRef.current : (selectionStart !== null && selectionStart !== undefined ? selectionStart : currentTime);
|
||||||
|
const selS = Math.min(anchor, startTime);
|
||||||
|
const selE = Math.max(anchor, startTime);
|
||||||
|
onSelectRange(selS, selE);
|
||||||
|
onPlayheadSet(startTime);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Alt+Click near right edge → speed stretch
|
// Alt+Click near right edge → speed stretch
|
||||||
if (e.altKey && onSpeedChange) {
|
if (e.altKey && onSpeedChange) {
|
||||||
const clipRightEdge = (bufDuration / (speed || 1.0)) * zoom;
|
const clipRightEdge = (bufDuration / (speed || 1.0)) * zoom;
|
||||||
@@ -1505,6 +1729,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Select tool (default): drag to select range
|
// Select tool (default): drag to select range
|
||||||
|
subTabAnchorRef.current = startTime;
|
||||||
setSelectedNodeTime(null);
|
setSelectedNodeTime(null);
|
||||||
onSelectRange(startTime, startTime);
|
onSelectRange(startTime, startTime);
|
||||||
onPlayheadSet(startTime);
|
onPlayheadSet(startTime);
|
||||||
@@ -1512,7 +1737,8 @@
|
|||||||
const handleMouseMove = (moveEvent) => {
|
const handleMouseMove = (moveEvent) => {
|
||||||
const currentX = moveEvent.clientX - rect.left + scrollLeft;
|
const currentX = moveEvent.clientX - rect.left + scrollLeft;
|
||||||
const ct = Math.max(0, Math.min(wallDuration, currentX / zoom));
|
const ct = Math.max(0, Math.min(wallDuration, currentX / zoom));
|
||||||
onSelectRange(startTime, ct);
|
const anchor = subTabAnchorRef.current ?? startTime;
|
||||||
|
onSelectRange(Math.min(anchor, ct), Math.max(anchor, ct));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMouseUp = () => {
|
const handleMouseUp = () => {
|
||||||
@@ -1587,9 +1813,11 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div style={{ width: `${timelineWidth}px`, height: '100%', position: 'relative', overflow: 'hidden' }}>
|
||||||
<canvas
|
<canvas
|
||||||
ref={canvasRef}
|
ref={canvasRef}
|
||||||
className="w-full h-full rounded border border-zinc-800"
|
style={{ position: 'absolute', top: 0, left: 0, imageRendering: 'pixelated' }}
|
||||||
|
className="cursor-crosshair rounded border border-zinc-800"
|
||||||
onMouseDown={handleMouseDown}
|
onMouseDown={handleMouseDown}
|
||||||
onDoubleClick={handleDoubleClick}
|
onDoubleClick={handleDoubleClick}
|
||||||
onContextMenu={handleContextMenuInternal}
|
onContextMenu={handleContextMenuInternal}
|
||||||
@@ -1618,6 +1846,7 @@
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1782,17 +2011,24 @@
|
|||||||
if (!canvas || !buffer) return;
|
if (!canvas || !buffer) return;
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
const dpr = window.devicePixelRatio || 1;
|
const dpr = window.devicePixelRatio || 1;
|
||||||
const rect = canvas.getBoundingClientRect();
|
const wrapper = canvas.parentElement ? canvas.parentElement.parentElement : null;
|
||||||
const w = timelineWidth;
|
const scrollLeft = wrapper ? wrapper.scrollLeft : 0;
|
||||||
const h = rect.height;
|
const vWidth = wrapper ? wrapper.clientWidth : 1200;
|
||||||
const maxW = 10000;
|
const drawWidth = Math.min(timelineWidth, Math.max(vWidth, 1200));
|
||||||
const useW = Math.min(timelineWidth, maxW);
|
const h = canvas.parentElement ? canvas.parentElement.clientHeight : 200;
|
||||||
canvas.width = useW * dpr;
|
|
||||||
canvas.height = h * dpr;
|
canvas.width = Math.round(drawWidth * dpr);
|
||||||
ctx.scale(dpr * (useW / timelineWidth), dpr);
|
canvas.height = Math.round(h * dpr);
|
||||||
|
ctx.scale(dpr, dpr);
|
||||||
|
ctx.imageSmoothingEnabled = false;
|
||||||
|
|
||||||
|
canvas.style.position = 'absolute';
|
||||||
|
canvas.style.left = `${scrollLeft}px`;
|
||||||
|
canvas.style.width = `${drawWidth}px`;
|
||||||
|
canvas.style.height = `${h}px`;
|
||||||
|
|
||||||
ctx.fillStyle = '#1a1a2e';
|
ctx.fillStyle = '#1a1a2e';
|
||||||
ctx.fillRect(0, 0, w, h);
|
ctx.fillRect(0, 0, drawWidth, h);
|
||||||
|
|
||||||
ctx.strokeStyle = '#2a2a4e';
|
ctx.strokeStyle = '#2a2a4e';
|
||||||
ctx.lineWidth = 0.5;
|
ctx.lineWidth = 0.5;
|
||||||
@@ -1908,14 +2144,17 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div style={{ width: `${timelineWidth}px`, height: '100%', position: 'relative', overflow: 'hidden' }}>
|
||||||
<canvas ref={canvasRef}
|
<canvas ref={canvasRef}
|
||||||
className="w-full h-20 rounded border border-zinc-700 cursor-crosshair"
|
style={{ position: 'absolute', top: 0, left: 0, imageRendering: 'pixelated' }}
|
||||||
|
className="cursor-crosshair rounded border border-zinc-700"
|
||||||
onMouseDown={handleMouseDown}
|
onMouseDown={handleMouseDown}
|
||||||
onMouseMove={handleMouseMove}
|
onMouseMove={handleMouseMove}
|
||||||
onMouseUp={handleMouseUp}
|
onMouseUp={handleMouseUp}
|
||||||
onMouseLeave={handleMouseUp}
|
onMouseLeave={handleMouseUp}
|
||||||
onContextMenu={handleContextMenu}
|
onContextMenu={handleContextMenu}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -2731,6 +2970,7 @@
|
|||||||
const animationFrameIdRef = useRef(null);
|
const animationFrameIdRef = useRef(null);
|
||||||
const toastTimeoutRef = useRef(null);
|
const toastTimeoutRef = useRef(null);
|
||||||
const rulerDragStartRef = useRef(null);
|
const rulerDragStartRef = useRef(null);
|
||||||
|
const rulerAnchorRef = useRef(null);
|
||||||
const isDraggingRulerRef = useRef(false);
|
const isDraggingRulerRef = useRef(false);
|
||||||
const handlePlayPauseRef = useRef(null);
|
const handlePlayPauseRef = useRef(null);
|
||||||
|
|
||||||
@@ -4144,7 +4384,7 @@
|
|||||||
setZoom(prevZoom => {
|
setZoom(prevZoom => {
|
||||||
let newZoom = prevZoom * zoomFactor;
|
let newZoom = prevZoom * zoomFactor;
|
||||||
if (newZoom < minZoom) newZoom = minZoom;
|
if (newZoom < minZoom) newZoom = minZoom;
|
||||||
if (newZoom > 2000) newZoom = 2000;
|
if (newZoom > 50000) newZoom = 50000;
|
||||||
|
|
||||||
const newMouseXInCanvas = anchorTime * newZoom;
|
const newMouseXInCanvas = anchorTime * newZoom;
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
@@ -4546,6 +4786,7 @@
|
|||||||
|
|
||||||
// ── Playhead set with seek+play ──
|
// ── Playhead set with seek+play ──
|
||||||
const handlePlayheadSet = (time, shiftKey) => {
|
const handlePlayheadSet = (time, shiftKey) => {
|
||||||
|
localSelectionAnchorRef.current = time;
|
||||||
if (isPlaying) {
|
if (isPlaying) {
|
||||||
// Click during playback: seek to position and continue playing
|
// Click during playback: seek to position and continue playing
|
||||||
setCurrentTime(time);
|
setCurrentTime(time);
|
||||||
@@ -4569,6 +4810,15 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleRulerMouseDown = (e) => {
|
const handleRulerMouseDown = (e) => {
|
||||||
|
if (e.ctrlKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
clearLocalSelection();
|
||||||
|
setSelectionMode(null);
|
||||||
|
setSelectionStart(null);
|
||||||
|
setSelectionEnd(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const wrapper = timelineWrapperRef.current;
|
const wrapper = timelineWrapperRef.current;
|
||||||
if (!wrapper) return;
|
if (!wrapper) return;
|
||||||
const rect = wrapper.getBoundingClientRect();
|
const rect = wrapper.getBoundingClientRect();
|
||||||
@@ -4582,10 +4832,16 @@
|
|||||||
rulerDragStartRef.current = time;
|
rulerDragStartRef.current = time;
|
||||||
isDraggingRulerRef.current = true;
|
isDraggingRulerRef.current = true;
|
||||||
if (e.shiftKey) {
|
if (e.shiftKey) {
|
||||||
// Shift+click on ruler: extend global selection from currentTime to clicked position
|
e.preventDefault();
|
||||||
setSelectionStart(Math.min(currentTime, time));
|
e.stopPropagation();
|
||||||
setSelectionEnd(Math.max(currentTime, time));
|
// Shift+click on ruler: lock existing anchor (or currentTime fallback) and extend global selection
|
||||||
|
const anchor = rulerAnchorRef.current !== null && rulerAnchorRef.current !== undefined ? rulerAnchorRef.current : (selectionStart !== null && selectionStart !== undefined ? selectionStart : currentTime);
|
||||||
|
const selS = Math.min(anchor, time);
|
||||||
|
const selE = Math.max(anchor, time);
|
||||||
|
setSelectionStart(selS);
|
||||||
|
setSelectionEnd(selE);
|
||||||
} else {
|
} else {
|
||||||
|
rulerAnchorRef.current = time;
|
||||||
setSelectionStart(time);
|
setSelectionStart(time);
|
||||||
setSelectionEnd(time);
|
setSelectionEnd(time);
|
||||||
}
|
}
|
||||||
@@ -4604,7 +4860,9 @@
|
|||||||
const rawTime = Math.max(0, Math.min(maxDuration, mouseX / zoom));
|
const rawTime = Math.max(0, Math.min(maxDuration, mouseX / zoom));
|
||||||
const time = snapValueRef.current !== 'free' ? snapTime(rawTime, snapValueRef.current, bpmRef.current) : rawTime;
|
const time = snapValueRef.current !== 'free' ? snapTime(rawTime, snapValueRef.current, bpmRef.current) : rawTime;
|
||||||
|
|
||||||
setSelectionEnd(time);
|
const anchor = rulerAnchorRef.current ?? rulerDragStartRef.current ?? time;
|
||||||
|
setSelectionStart(Math.min(anchor, time));
|
||||||
|
setSelectionEnd(Math.max(anchor, time));
|
||||||
};
|
};
|
||||||
const handleMouseUp = () => {
|
const handleMouseUp = () => {
|
||||||
if (isDraggingRulerRef.current) {
|
if (isDraggingRulerRef.current) {
|
||||||
@@ -4624,13 +4882,18 @@
|
|||||||
const localDragInProgressRef = useRef(false);
|
const localDragInProgressRef = useRef(false);
|
||||||
const localDragTrackRef = useRef(null);
|
const localDragTrackRef = useRef(null);
|
||||||
const localDragStartTimeRef = useRef(0);
|
const localDragStartTimeRef = useRef(0);
|
||||||
|
const localSelectionAnchorRef = useRef(null);
|
||||||
|
|
||||||
const handleTrackLaneMouseDown = (trackId, time) => {
|
const handleTrackLaneMouseDown = (trackId, time) => {
|
||||||
|
setSelectedTrackId(trackId);
|
||||||
clearLocalSelection();
|
clearLocalSelection();
|
||||||
|
localSelectionAnchorRef.current = time;
|
||||||
setSelectionMode('local');
|
setSelectionMode('local');
|
||||||
setLocalSelectionTrackId(trackId);
|
setLocalSelectionTrackId(trackId);
|
||||||
setLocalSelectionStart(time);
|
setLocalSelectionStart(time);
|
||||||
setLocalSelectionEnd(time);
|
setLocalSelectionEnd(time);
|
||||||
|
setSelectionStart(time);
|
||||||
|
setSelectionEnd(time);
|
||||||
|
|
||||||
localDragInProgressRef.current = true;
|
localDragInProgressRef.current = true;
|
||||||
localDragTrackRef.current = trackId;
|
localDragTrackRef.current = trackId;
|
||||||
@@ -4647,7 +4910,13 @@
|
|||||||
const scrollLeft = wrapper.scrollLeft;
|
const scrollLeft = wrapper.scrollLeft;
|
||||||
const mouseX = e.clientX - rect.left + scrollLeft;
|
const mouseX = e.clientX - rect.left + scrollLeft;
|
||||||
const time = Math.max(0, Math.min(maxDuration, mouseX / zoom));
|
const time = Math.max(0, Math.min(maxDuration, mouseX / zoom));
|
||||||
setLocalSelectionEnd(time);
|
const anchor = localSelectionAnchorRef.current ?? localDragStartTimeRef.current;
|
||||||
|
const selS = Math.min(anchor, time);
|
||||||
|
const selE = Math.max(anchor, time);
|
||||||
|
setLocalSelectionStart(selS);
|
||||||
|
setLocalSelectionEnd(selE);
|
||||||
|
setSelectionStart(selS);
|
||||||
|
setSelectionEnd(selE);
|
||||||
};
|
};
|
||||||
const handleMouseUp = () => {
|
const handleMouseUp = () => {
|
||||||
if (localDragInProgressRef.current) {
|
if (localDragInProgressRef.current) {
|
||||||
@@ -6806,7 +7075,7 @@
|
|||||||
{/* ══ LEFT COLUMN: TCP PANEL (main session, all tracks) ══ */}
|
{/* ══ LEFT COLUMN: TCP PANEL (main session, all tracks) ══ */}
|
||||||
<div ref={tcpContainerRef}
|
<div ref={tcpContainerRef}
|
||||||
onScroll={handleTCPScroll}
|
onScroll={handleTCPScroll}
|
||||||
className="w-[320px] shrink-0 z-30 bg-[#262626] overflow-y-scroll no-scrollbar flex flex-col border-r border-zinc-900"
|
className="w-[300px] shrink-0 z-20 bg-[#262626] overflow-hidden flex flex-col border-r border-zinc-900"
|
||||||
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}>
|
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}>
|
||||||
<div className="sticky top-0 z-50 flex items-center justify-between h-10 border-b border-zinc-900 bg-[#242424] px-2 shrink-0">
|
<div className="sticky top-0 z-50 flex items-center justify-between h-10 border-b border-zinc-900 bg-[#242424] px-2 shrink-0">
|
||||||
<span className="text-xs font-bold text-zinc-300 flex items-center gap-1.5">
|
<span className="text-xs font-bold text-zinc-300 flex items-center gap-1.5">
|
||||||
@@ -6926,7 +7195,7 @@
|
|||||||
)}
|
)}
|
||||||
<div className="sticky top-10 z-35 flex h-[40px] border-b border-purple-500/30 bg-[#1a1a2e] shrink-0">
|
<div className="sticky top-10 z-35 flex h-[40px] border-b border-purple-500/30 bg-[#1a1a2e] shrink-0">
|
||||||
<div className="flex-1 relative h-full overflow-hidden bg-[#1a1a2e]">
|
<div className="flex-1 relative h-full overflow-hidden bg-[#1a1a2e]">
|
||||||
<TempoTrackLane bpm={parseInt(bpm) || 120} zoom={zoom} timelineWidth={timelineWidth}
|
<TempoTrackLane bpm={parseInt(bpm) || 120} zoom={zoom} timelineWidth={timelineWidth} viewportWidth={viewportWidth}
|
||||||
onPlayheadSet={handlePlayheadSet} snapValue={snapValue} onRulerMouseDown={handleRulerMouseDown} />
|
onPlayheadSet={handlePlayheadSet} snapValue={snapValue} onRulerMouseDown={handleRulerMouseDown} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -6957,8 +7226,27 @@
|
|||||||
localSelectionTrackId={localSelectionTrackId}
|
localSelectionTrackId={localSelectionTrackId}
|
||||||
localSelectionStart={localSelectionStart}
|
localSelectionStart={localSelectionStart}
|
||||||
currentTime={currentTime}
|
currentTime={currentTime}
|
||||||
|
getLocalAnchor={() => localSelectionAnchorRef.current}
|
||||||
|
onClearLocalSelection={clearLocalSelection}
|
||||||
|
onSetSelectionMode={setSelectionMode}
|
||||||
|
onSetSelectionStart={setSelectionStart}
|
||||||
|
onSetSelectionEnd={setSelectionEnd}
|
||||||
|
onSetCurrentTime={setCurrentTime}
|
||||||
|
onSetLocalSelectionTrackId={setLocalSelectionTrackId}
|
||||||
|
onSetLocalSelectionStart={setLocalSelectionStart}
|
||||||
|
onSetLocalSelectionEnd={setLocalSelectionEnd}
|
||||||
localSelLeft={localSelectionStart !== null && localSelectionEnd !== null ? Math.min(localSelectionStart, localSelectionEnd) : null}
|
localSelLeft={localSelectionStart !== null && localSelectionEnd !== null ? Math.min(localSelectionStart, localSelectionEnd) : null}
|
||||||
localSelRight={localSelectionStart !== null && localSelectionEnd !== null ? Math.max(localSelectionStart, localSelectionEnd) : null} />
|
localSelRight={localSelectionStart !== null && localSelectionEnd !== null ? Math.max(localSelectionStart, localSelectionEnd) : null} />
|
||||||
|
{selectionMode === 'local' && localSelectionTrackId === track.id &&
|
||||||
|
localSelectionStart !== null && localSelectionEnd !== null && Math.abs(localSelectionEnd - localSelectionStart) > 0 && (
|
||||||
|
<div className="absolute top-0 bottom-0 border-l border-r border-amber-400 bg-amber-500/20 z-20 pointer-events-none"
|
||||||
|
style={{ left: `${Math.min(localSelectionStart, localSelectionEnd) * zoom}px`, width: `${Math.abs(localSelectionEnd - localSelectionStart) * zoom}px` }}>
|
||||||
|
<div className="absolute -left-[1px] top-0 bottom-0 w-[2px] bg-amber-400 z-30 pointer-events-auto cursor-ew-resize"
|
||||||
|
onMouseDown={e => handleHandleDragStart(e, 'left')}></div>
|
||||||
|
<div className="absolute -right-[1px] top-0 bottom-0 w-[2px] bg-amber-400 z-30 pointer-events-auto cursor-ew-resize"
|
||||||
|
onMouseDown={e => handleHandleDragStart(e, 'right')}></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{track.buffer && (
|
{track.buffer && (
|
||||||
<div className="absolute right-2 top-2 flex items-center gap-1 z-10 opacity-70 hover:opacity-100">
|
<div className="absolute right-2 top-2 flex items-center gap-1 z-10 opacity-70 hover:opacity-100">
|
||||||
<button onClick={() => handleSplitTrack(track.id)}
|
<button onClick={() => handleSplitTrack(track.id)}
|
||||||
@@ -6975,7 +7263,7 @@
|
|||||||
onClick={addNewTrack}>
|
onClick={addNewTrack}>
|
||||||
<span className="flex items-center gap-1 text-zinc-400"><span className="inline-flex items-center shrink-0"><i data-lucide="plus" className="w-3.5 h-3.5"></i></span> Kéo clip xuống hoặc Click tạo Track</span>
|
<span className="flex items-center gap-1 text-zinc-400"><span className="inline-flex items-center shrink-0"><i data-lucide="plus" className="w-3.5 h-3.5"></i></span> Kéo clip xuống hoặc Click tạo Track</span>
|
||||||
</div>
|
</div>
|
||||||
{selectionMode !== 'local' && selLeft !== null && selRight !== null && selRight > selLeft && (
|
{selectionMode === 'global' && 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"
|
<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}px`, width: `${(selRight - selLeft) * zoom}px` }}
|
style={{ left: `${selLeft * zoom}px`, width: `${(selRight - selLeft) * zoom}px` }}
|
||||||
onMouseDown={handleSelectionBodyDragStart}>
|
onMouseDown={handleSelectionBodyDragStart}>
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
# Test Verification Suite for Technical Roadmap 22_CLIENT_DESK.md
|
||||||
|
import os
|
||||||
|
import pytest
|
||||||
|
import numpy as np
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from app.core.dsp_utils import find_zero_crossing, apply_micro_crossfade
|
||||||
|
from app.core.vst_engine import render_midi_events_to_audio
|
||||||
|
from app.api.v1.auth import enforce_password_changed
|
||||||
|
|
||||||
|
def test_kpi_1_zero_crossing_detection():
|
||||||
|
"""Kiểm thử Zero-Crossing: Cắt lát nhạc bằng AI Cut ở mốc giây lẻ (22_CLIENT_DESK.md §5)."""
|
||||||
|
sr = 44100
|
||||||
|
# Generate 1 second sine wave at 440 Hz
|
||||||
|
t = np.linspace(0, 1.0, sr)
|
||||||
|
signal = np.sin(2 * np.pi * 440 * t)
|
||||||
|
|
||||||
|
target_time = 0.1234 # Odd time offset
|
||||||
|
zc_time = find_zero_crossing(signal, sr, target_time, window_seconds=0.04)
|
||||||
|
|
||||||
|
assert zc_time is not None
|
||||||
|
zc_sample = int(zc_time * sr)
|
||||||
|
# Verify physical sign inversion x[i] * x[i+1] <= 0
|
||||||
|
if 0 <= zc_sample < len(signal) - 1:
|
||||||
|
assert signal[zc_sample] * signal[zc_sample + 1] <= 0.05
|
||||||
|
print(f"Zero crossing test passed: target {target_time}s -> zc {zc_time}s")
|
||||||
|
|
||||||
|
def test_kpi_2_micro_crossfade_splicing():
|
||||||
|
"""Kiểm thử Micro-Crossfade (10ms) tại hai đầu điểm ráp nối để triệt tiêu click/pop."""
|
||||||
|
sr = 44100
|
||||||
|
original = np.ones(sr, dtype=np.float32) * 0.5
|
||||||
|
edited = np.ones(sr // 2, dtype=np.float32) * 0.8
|
||||||
|
start_sample = sr // 4
|
||||||
|
|
||||||
|
output = apply_micro_crossfade(original, edited, start_sample, fade_len_ms=10, sr=sr)
|
||||||
|
assert len(output) == len(original)
|
||||||
|
# Check smooth transition at start
|
||||||
|
assert 0.49 <= output[start_sample] <= 0.81
|
||||||
|
print("Micro-crossfade splicing test passed.")
|
||||||
|
|
||||||
|
def test_kpi_3_vst_synth_midi_rendering():
|
||||||
|
"""Kiểm thử Docker VSTi: Gửi chuỗi MIDI nốt và nạp synth tổng hợp ra mảng Stereo."""
|
||||||
|
midi_events = [
|
||||||
|
{"note": 60, "start_beat": 0.0, "duration_beats": 1.0, "velocity": 100}, # C4
|
||||||
|
{"note": 64, "start_beat": 1.0, "duration_beats": 1.0, "velocity": 90}, # E4
|
||||||
|
{"note": 67, "start_beat": 2.0, "duration_beats": 2.0, "velocity": 110} # G4
|
||||||
|
]
|
||||||
|
|
||||||
|
audio_array = render_midi_events_to_audio(midi_events, sr=44100, bpm=120.0)
|
||||||
|
|
||||||
|
assert isinstance(audio_array, np.ndarray)
|
||||||
|
assert audio_array.shape[0] == 2 # Stereo channels (L, R)
|
||||||
|
assert audio_array.shape[1] > 0
|
||||||
|
assert np.max(np.abs(audio_array)) > 0.01
|
||||||
|
print(f"VSTi MIDI rendering test passed: stereo output shape {audio_array.shape}")
|
||||||
|
|
||||||
|
def test_kpi_4_auth_security_must_change_password():
|
||||||
|
"""Kiểm thử Bảo Mật Auth: Đăng nhập tài khoản mặc định và gọi API (22_CLIENT_DESK.md §5)."""
|
||||||
|
user_must_change = {"user_id": "test_user_1", "must_change_password": True}
|
||||||
|
user_password_changed = {"user_id": "test_user_2", "must_change_password": False}
|
||||||
|
|
||||||
|
# Should raise HTTP 403 Forbidden when must_change_password = True
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
enforce_password_changed(user_must_change)
|
||||||
|
assert exc_info.value.status_code == 403
|
||||||
|
|
||||||
|
# Should pass without error when must_change_password = False
|
||||||
|
enforce_password_changed(user_password_changed)
|
||||||
|
print("Auth Security 403 Forbidden test passed.")
|
||||||
|
|
||||||
|
def test_kpi_5_storage_quota_calculation():
|
||||||
|
"""Kiểm thử Quota: S_used + S_new <= S_limit."""
|
||||||
|
s_limit_mb = 500
|
||||||
|
s_used_mb = 480
|
||||||
|
s_new_mb = 30 # Total = 510MB > 500MB limit
|
||||||
|
|
||||||
|
total = s_used_mb + s_new_mb
|
||||||
|
is_quota_exceeded = total > s_limit_mb
|
||||||
|
|
||||||
|
assert is_quota_exceeded is True
|
||||||
|
print("Storage Quota calculation test passed.")
|
||||||
|
|
||||||
|
def test_kpi_6_shift_click_range_anchor_math():
|
||||||
|
"""Kiểm thử Shift+Click: Bôi chọn cục bộ [min(T_anchor, T_end), max(T_anchor, T_end)]."""
|
||||||
|
t_anchor = 5.4
|
||||||
|
t_end = 2.1
|
||||||
|
|
||||||
|
sel_start = min(t_anchor, t_end)
|
||||||
|
sel_end = max(t_anchor, t_end)
|
||||||
|
|
||||||
|
assert sel_start == 2.1
|
||||||
|
assert sel_end == 5.4
|
||||||
|
print("Shift+Click range anchor math test passed.")
|
||||||
Reference in New Issue
Block a user