fix&feat: hiển thị tools chỉnh sửa audioclip
This commit is contained in:
@@ -0,0 +1,283 @@
|
||||
# Technical Specification: Advanced Editing Toolset & Graph-Based Continuous Waveform Painting on Sub-Tab
|
||||
|
||||
This document defines the interactive layout design, the configuration of the toolbar button arrays, and the signal processing routines for compiling a Graph-based Continuous Waveform graph optimized for the microscopic viewports inside the isolated temporary document workspace (Sub-tab), referencing the structural paradigms of `image_5ec2e5.png` and `image_5ec363.png`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Target Selection Scope
|
||||
|
||||
The toolset within the Sub-tab environment supports two target operational boundaries:
|
||||
|
||||
* **Global Clip:** When no specific timeline selection highlighted mask is present, all active DSP effects apply uniformly across the entire length of the extracted Audio Clip.
|
||||
* **Selected Range:** When an explicit timeline segment $[T_{\text{start}}, T_{\text{end}}]$ is highlighted by the user, DSP routines calculate changes exclusively inside those boundaries. Splice junctions automatically compute crossfades to mitigate transient click/pop anomalies.
|
||||
|
||||
---
|
||||
|
||||
## 2. Ruler-Based Tools
|
||||
|
||||
These utilities display as intuitive, linear slider scales (Sliders/Rulers) embedded in the top toolbar row:
|
||||
|
||||
```text
|
||||
[ Normalize: |======o======| 0 dB ] [ Gain: |====o====| +3 dB ] [ Pitch: |==o==| -2 Semi ]
|
||||
|
||||
```
|
||||
|
||||
### 2.1. Peak Normalization
|
||||
|
||||
* **UI Layout:** A slide scale control allowing users to configure target amplitude thresholds variable from $-12\text{ dBFS}$ down to $0\text{ dBFS}$.
|
||||
* **DSP Math Algorithm:** Locate the maximum absolute peak amplitude value $A_{\text{max}}$ within the targeted area, then multiply all active samples by a static scalar gain multiplier $G$:
|
||||
|
||||
$$G = \frac{10^{\frac{\text{Target\_dB}}{20}}}{A_{\text{max}}}$$
|
||||
|
||||
|
||||
|
||||
### 2.2. Volume Up / Down (Quick Gain)
|
||||
|
||||
* **UI Layout:** A linear sliding ruler modulating the overall absolute gain structure of the focused segment.
|
||||
* **Operational Range:** Adjustable from $-\infty\text{ dB}$ (complete mute attenuation) up to $+12\text{ dB}$ of linear amplification.
|
||||
|
||||
### 2.3. Pitch Shifting
|
||||
|
||||
* **UI Layout:** A calibrated slider modifying the project's fundamental frequencies discrete in semitones or cents.
|
||||
* **Operational Range:** Boundaries map from $-12\text{ semitones}$ (one octave down) to $+12\text{ semitones}$ (one octave up).
|
||||
* **DSP Engine Routine:** Employs a spectral Phase Vocoder to shift frequencies without affecting the physical, real-time duration layout of the segment.
|
||||
|
||||
---
|
||||
|
||||
## 3. Graph-Based Fades
|
||||
|
||||
Fading curves overlay graphically directly onto the highlighted waveform canvas region, enabling precise boundary amplitude adjustments:
|
||||
|
||||
```text
|
||||
Linear Fade-In Exponential Fade-Out
|
||||
+───────────────────────────+ +───────────────────────────+
|
||||
| /███████████████| |███████████\ |
|
||||
| / ███████████████| |███████████ \ |
|
||||
| / ███████████████| |███████████ \___ |
|
||||
| / ███████████████| |███████████ \______|
|
||||
+───────────────────────────+ +───────────────────────────+
|
||||
|<──────── Fade-In ────────>| |<─────── Fade-Out ────────>|
|
||||
|
||||
```
|
||||
|
||||
* **Fade-In:** Multiplies an ascending amplitude ramp from $0.0$ to $1.0$ at the starting index profile of the selection region. Users can toggle seamlessly between **Linear** or **Exponential** curves to achieve a smoother, more psychoacoustically natural volume build-up.
|
||||
* **Fade-Out:** Multiplies a descending amplitude decay ramp from $1.0$ down to $0.0$ at the trailing boundary edge of the selection range.
|
||||
|
||||
---
|
||||
|
||||
## 4. Ruler Percentage Stretch Tool
|
||||
|
||||
A dedicated percentage metric scale control (`Ruler %`) sitting on the control toolbar dictates time-stretching and playback velocity parameters:
|
||||
|
||||
```text
|
||||
[ Speed Stretch %: |========o========| 100% (Native) ] -> Range: 50% - 200%
|
||||
|
||||
```
|
||||
|
||||
* **Interaction Mapping:** Users drag the percentage slider node or hold down the `Alt` key and drag the rightmost boundary edge of the clip along the horizontal axis to change this scale metric.
|
||||
* **Sync Formula:** Let $D$ map to the unscaled native duration value, and $D'$ map to the target modified duration footprint. The resulting structural playback speed ratio percentage ($S$) is given by:
|
||||
|
||||
$$S = \frac{D}{D'} \times 100\%$$
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 5. Continuous Graph-Based Waveform Painting & Microscopic Viewports
|
||||
|
||||
The waveform graph inside the Sub-tab is compiled as a unified, continuous line vector (Continuous Line Graph) that flows seamlessly along the timeline axis, mapping the literal physical phase displacements of the underlying audio signal.
|
||||
|
||||
### 5.1. Logarithmic Amplitude Axis Grid Layout
|
||||
|
||||
Following the professional paradigm established in `image_5ec2e5.png`, the waveform painting canvas is divided by a symmetrical layout grid reflecting both positive and negative polarity limits of the central horizontal axis:
|
||||
|
||||
```text
|
||||
+6.0 dB ───────────────────────────────────────────────────────────────────
|
||||
~ ~ ~ ~ ~ ~ ~ ~ (Sub-division Grid Line) ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~
|
||||
-6.0 dB ───────────────────────────────────────────────────────────────────
|
||||
\ / \ / \
|
||||
-Inf dB ─○───────────/───────────────○───────────/───────────────○───────── (Zero-Line Axis)
|
||||
\ / \ / \
|
||||
-6.0 dB ───────────────────────────────────────────────────────────────────
|
||||
+6.0 dB ───────────────────────────────────────────────────────────────────
|
||||
|
||||
```
|
||||
|
||||
* **Visual Bounding Thresholds:**
|
||||
* **Central Zero Axis (-Inf. dB):** Maps the absolute baseline $0\text{V}$ electrical reference (complete absence of audio signal / absolute silence).
|
||||
* **Symmetrical Decibel Grids:** Project accurate scale metrics tracking normalized peak levels (the inner $-6.0\text{ dB}$ sub-grid marks a $50.1\%$ amplitude ceiling, while the outermost physical frame boundary aligns to $+6.0\text{ dB}$ or $0\text{ dBFS}$).
|
||||
|
||||
|
||||
|
||||
### 5.2. Standard Workspace View vs. Ultra Zoom Viewport Scaling
|
||||
|
||||
The drawing engine dynamically hot-swaps its rendering calculations (Rendering Routine) depending on the active pixel compression metric $Z$ (pixels/second):
|
||||
|
||||
* **Standard View Mode ($Z < 500\text{ pixels/second}$):** The system deploys a structural peak compression layout algorithm (**Peak Waveform**—as referenced in `image_5ec2e5.png`). It connects the maximum absolute upper peak bounding indices (Max) with the lower minimum value ranges (Min) passing through a common pixel column into a unified vector line, generating an organic, aliases-free continuous waveform silhouette.
|
||||
* **Micro Viewport Zoom-In ($Z \ge 500\text{ pixels/second}$—as referenced in `image_5ec363.png`):** Once viewport stretching scales past this threshold, the framework transitions into a **Single Continuous Sine Polyline** loop. Chronologically sequential acoustic sample addresses ($x[i]$, $x[i+1]$) map as discrete vector coordinate indices bound together by thin lines (using sharp smooth polyline vectors or linear/cubic spline interpolation loops), charting pristine, individual sinusoidal phases explicitly.
|
||||
|
||||
### 5.3. Zero-Crossing Alignment within Ultra Zoom Viewports
|
||||
|
||||
When performing rapid cursor tracking edits (Scrub/Drag Selection), the alignment routine locks the selection boundary marker coordinates onto the nearest baseline sample offset exhibiting a complete algebraic phase conversion (sign inversion):
|
||||
|
||||
|
||||
$$x[i] \cdot x[i+1] \le 0$$
|
||||
|
||||
---
|
||||
|
||||
## 6. Top Duration Timeline
|
||||
|
||||
Directly above the isolated sub-tab waveform canvas lane, a dedicated horizontal measuring ruler tracks clip timing data:
|
||||
|
||||
```text
|
||||
| 0:00.000 | 0:01.000 | 0:02.000 | 0:03.000 | 0:04.000 (Duration: 4.152s)
|
||||
+───────────────────────────────────────────────────────────────────────────────────────+
|
||||
| [==================== VÙNG QUÉT CHỌN (RANGE SELECTION) ====================] |
|
||||
+───────────────────────────────────────────────────────────────────────────────────────+
|
||||
|
||||
```
|
||||
|
||||
* **Total Duration Monitoring:** Renders the absolute, precise time extent of the isolated audio block in the right-hand corner of the timeline ruler layout (e.g., `Duration: 12.450s`).
|
||||
* **Duration Selection Drag:** Left-clicking and dragging horizontally inside this top duration bar defines a highlighted selection overlay window. This range indicator automatically projects down into the waveform lane underneath.
|
||||
|
||||
---
|
||||
|
||||
## 7. Bottom Transport Panel & Master Tools
|
||||
|
||||
A comprehensive control framework containing expanded navigation buttons and deep session processing controls anchors the bottom row of the sub-tab environment, matching the layout structure in `image_5ec2e5.png`:
|
||||
|
||||
```text
|
||||
+─────────────────────────────────────────────────────────────────────────────────────────────+
|
||||
| [● Rec] [◀◀ Back] [▶ Play] [|| Pause] [■ Stop] | Rate: |====o====| 0.00 | Loop: [X] |
|
||||
|---------------------------------------------------------------------------------------------|
|
||||
| [Volume Pencil Tool] [AI Analysis Tool] | Active Asset: linh_ngua_powerup.wav |
|
||||
+─────────────────────────────────────────────────────────────────────────────────────────────+
|
||||
|
||||
```
|
||||
|
||||
### 7.1. Functional Mapping Matrix:
|
||||
|
||||
* **Record (● Red Indicator):** Drives live microphone capture sequences targeted straight into the isolated sub-tab data matrix.
|
||||
* **Back (◀◀ Rewind):** Resets the timeline playhead position index back to the absolute starting point ($t = 0.0\text{ s}$).
|
||||
* **Play / Pause / Stop:** Coordinates low-latency runtime audio execution tracking locked onto the sub-tab's RAM cache blocks.
|
||||
* **Rate Slider:** Adjusts the global monitoring playback pitch speed metrics in real time without overwriting source asset length (calibrated step ranges variable from `-1.00` scaling up to `+1.00`).
|
||||
* **Loop Toggle:** Toggles continuous cycle loops over the highlighted section or the whole clip.
|
||||
* **Volume Pencil Tool:** Engages the drawing framework to map point nodes for automated amplitude envelopes.
|
||||
* **AI Analysis Tool:** Instructs the dockerized engine to evaluate rhythmic transient markers and pitch tracking grids.
|
||||
|
||||
---
|
||||
|
||||
## 8. Volume Automation Envelope (Pen Tool)
|
||||
|
||||
This advanced timeline automation layer allows audio designers to draw custom gain curves over the background waveform graphics.
|
||||
|
||||
```text
|
||||
VOLUME AUTOMATION ENVELOPE (PEN TOOL)
|
||||
+3 dB ──────────────────────────────────────────────────────────────
|
||||
\ Node 1 Node 3
|
||||
\ ○ ○
|
||||
0 dB ───\────/─\─────────────────────────────────────/─\─────────── (0 dB Unity Gain Axis)
|
||||
\ / \ / \
|
||||
\/ \ / \
|
||||
○ \_______________________________/ \________
|
||||
Node 2 Node 4
|
||||
-30 dB ──────────────────────────────────────────────────────────────
|
||||
|<─────────────────── Horizontal Axis (Time) ─────────────────────>|
|
||||
|
||||
```
|
||||
|
||||
### 8.1. Pen Tool Interaction Mechanics
|
||||
|
||||
* **Activation:** Clicking the designated Pencil Tool icon in the control panel modifies the pointer device presentation into a pencil graphic.
|
||||
* **Envelope Initialization:** Activating the Pen Tool generates a solid horizontal neon green line representing $0\text{ dB}$ (Unity Gain) across the track workspace, acting as the baseline master axis.
|
||||
* **Drawing Automation Curves:**
|
||||
* Left-clicking anywhere along this line creates an adjustable anchor point (**Control Node**).
|
||||
* Dragging an initialized control node upward increases signal amplitude (up to a maximal ceiling boundary of $+3\text{ dB}$).
|
||||
* Dragging a control node downward reduces signal amplitude (down to a lower attenuation floor of $-30\text{ dB}$).
|
||||
* The graphics framework automatically updates straight vector paths between sequential nodes utilizing simple linear interpolation.
|
||||
|
||||
|
||||
|
||||
### 8.2. DSP Volume Envelope Math
|
||||
|
||||
Given two chronologically adjacent drawn points $P_1(t_1, V_1)$ and $P_2(t_2, V_2)$, the targeted instantaneous decibel gain variable $V_{\text{dB}}(t)$ at an arbitrary time index $t$ ($t_1 \le t \le t_2$) matches the following linear equation:
|
||||
|
||||
|
||||
$$V_{\text{dB}}(t) = V_1 + (t - t_1) \cdot \frac{V_2 - V_1}{t_2 - t_1}$$
|
||||
|
||||
This decibel value must be translated into a standard linear gain scalar coefficient $G_{\text{linear}}(t)$ to multiply it into the core audio sample stream values:
|
||||
|
||||
|
||||
$$G_{\text{linear}}(t) = 10^{\frac{\text{V}_{\text{dB}}(t)}{20}}$$
|
||||
|
||||
$$x_{\text{automation}}[n] = x[n] \cdot G_{\text{linear}}\left( \frac{n}{\text{Sample Rate}} \right)$$
|
||||
|
||||
---
|
||||
|
||||
## 9. Porting Guidelines for Python Desktop Layouts (PyQt6 QPainter Context)
|
||||
|
||||
When translating the polyline vector engine and the symmetrical decibel gridding lines into a containerized desktop application using the native `QPainter` canvas inside PyQt6, leveraging a structured `QPainterPath` prevents rendering lag when mapping high-density signal segments:
|
||||
|
||||
```python
|
||||
# [PYTHON PORTING BLUEPRINT] - Continuous Polyline Waveform Rendering via QPainterPath
|
||||
from PyQt6.QtGui import QPainter, QPainterPath, QPen, QColor
|
||||
from PyQt6.QtCore import QPointF, Qt
|
||||
import numpy as np
|
||||
|
||||
def paint_continuous_waveform_path(painter: QPainter, rect_width: int, rect_height: int, y: np.ndarray, zoom_level: float):
|
||||
"""
|
||||
Renders a unified continuous single polyline path tracing absolute physical signal transitions.
|
||||
y: A 1D NumPy float32 array tracking raw sample amplitudes bounded within [-1.0, 1.0].
|
||||
zoom_level: The scale allocation mapping physical drawing pixels per second of audio data.
|
||||
"""
|
||||
if len(y) == 0:
|
||||
return
|
||||
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
|
||||
mid_y = rect_height / 2.0
|
||||
|
||||
# 1. Compile background Decibel reference grids (-6.0 dB, -Inf. dB, -6.0 dB)
|
||||
grid_pen = QPen(QColor(45, 45, 45), 1, Qt.PenStyle.DashLine)
|
||||
painter.setPen(grid_pen)
|
||||
|
||||
# A threshold of -6.0 dB maps approximately to an absolute scalar amplitude index of 0.501
|
||||
y_6db_top = mid_y - (0.501 * (rect_height * 0.42))
|
||||
y_6db_bottom = mid_y + (0.501 * (rect_height * 0.42))
|
||||
|
||||
painter.drawLine(0, int(y_6db_top), rect_width, int(y_6db_top))
|
||||
painter.drawLine(0, int(y_6db_bottom), rect_width, int(y_6db_bottom))
|
||||
|
||||
# Paint the absolute Zero-Line horizontal center axis (-Inf. dB)
|
||||
center_pen = QPen(QColor(60, 60, 60), 1, Qt.PenStyle.SolidLine)
|
||||
painter.setPen(center_pen)
|
||||
painter.drawLine(0, int(mid_y), rect_width, int(mid_y))
|
||||
|
||||
# 2. Initialize the Continuous Vector Polyline Route Layout Block
|
||||
wave_path = QPainterPath()
|
||||
wave_pen = QPen(QColor(100, 149, 237), 1.2, Qt.PenStyle.SolidLine) # Professional Cornflower Blue
|
||||
painter.setPen(wave_pen)
|
||||
|
||||
# Map raw buffer indexes into structural coordinate pixels
|
||||
start_point_set = False
|
||||
for x_pixel in range(rect_width):
|
||||
# Translate current canvas pixel offset back to timeline seconds metrics
|
||||
time_at_pixel = x_pixel / zoom_level
|
||||
|
||||
# Calculate target array element offset
|
||||
sample_index = int(time_at_pixel * 44100) # Assuming project sample rate baseline at 44.1kHz
|
||||
if sample_index >= len(y):
|
||||
break
|
||||
|
||||
amplitude = y[sample_index]
|
||||
y_pixel = mid_y + (amplitude * (rect_height * 0.42))
|
||||
|
||||
if not start_point_set:
|
||||
wave_path.moveTo(float(x_pixel), y_pixel)
|
||||
start_point_set = True
|
||||
else:
|
||||
wave_path.lineTo(float(x_pixel), y_pixel)
|
||||
|
||||
# Draw the continuous vector polyline overlay onto the viewport canvas
|
||||
painter.drawPath(wave_path)
|
||||
|
||||
```
|
||||
@@ -31,6 +31,57 @@ class SubTabDSPEngine:
|
||||
gain = target_amplitude / max_amplitude
|
||||
return y * gain
|
||||
|
||||
@staticmethod
|
||||
def apply_volume_automation_envelope(y: np.ndarray, sr: int, nodes: list) -> np.ndarray:
|
||||
"""
|
||||
Applies a user-drawn volume automation envelope onto an acoustic signal NumPy array.
|
||||
nodes: A list of point dictionaries, e.g., [{"time": 0.0, "db": 0.0}, {"time": 2.5, "db": -12.0}, ...]
|
||||
"""
|
||||
if not nodes:
|
||||
return y
|
||||
|
||||
# Sort envelope nodes chronologically by time axis
|
||||
nodes = sorted(nodes, key=lambda x: x["time"])
|
||||
|
||||
# 1. Map node variables into distinct coordinates arrays
|
||||
node_times = np.array([node["time"] for node in nodes])
|
||||
node_dbs = np.array([node["db"] for node in nodes])
|
||||
|
||||
# Hard-clamp boundary constraints matching the operational floor [-30.0dB, +3.0dB]
|
||||
node_dbs = np.clip(node_dbs, -30.0, 3.0)
|
||||
|
||||
# 2. Evaluate absolute timeline timestamps for every index position inside the signal array
|
||||
total_samples = len(y)
|
||||
sample_times = np.arange(total_samples) / sr
|
||||
|
||||
# 3. Linearly interpolate localized decibel thresholds across every single sample step
|
||||
# Handle edge cases for interpolation: if sample_times is outside node_times range,
|
||||
# np.interp uses the first/last value of node_dbs.
|
||||
interpolated_dbs = np.interp(sample_times, node_times, node_dbs, left=node_dbs[0], right=node_dbs[-1])
|
||||
|
||||
# 4. Map logarithmic values into standard linear gain scale arrays
|
||||
linear_gains = 10.0 ** (interpolated_dbs / 20.0)
|
||||
|
||||
# 5. Multiply the raw amplitude vector array by the linear gain modifier mask
|
||||
return y * linear_gains
|
||||
|
||||
@staticmethod
|
||||
def pitch_shift(y: np.ndarray, sr: int, n_steps: float) -> np.ndarray:
|
||||
"""
|
||||
Shift the pitch of an audio signal by a specified number of semitones.
|
||||
|
||||
Args:
|
||||
y: Input audio signal
|
||||
sr: Sample rate
|
||||
n_steps: Number of semitones to shift (positive = higher pitch, negative = lower pitch)
|
||||
|
||||
Returns:
|
||||
Pitch-shifted audio signal
|
||||
"""
|
||||
if n_steps == 0:
|
||||
return y
|
||||
return librosa.effects.pitch_shift(y, sr=sr, n_steps=n_steps)
|
||||
|
||||
@staticmethod
|
||||
def merge_back_to_parent(
|
||||
parent_track_audio: np.ndarray,
|
||||
|
||||
+127
-10
@@ -779,6 +779,41 @@
|
||||
);
|
||||
};
|
||||
|
||||
const SubTabToolbar = ({ st, activeTool, setActiveTool, handleSubTabNormalizeWithValue, handleSubTabGainWithValue, handleSubTabPitch, handleSubTabStretch, handleSubTabFade }) => (
|
||||
<div className="daw-header flex h-16 items-center px-4 border-b daw-border gap-3 bg-zinc-800">
|
||||
<div className="flex items-center space-x-1 border-r border-zinc-700 pr-3">
|
||||
<button className={`p-1.5 rounded ${activeTool === 'select' ? 'bg-cyan-700' : 'bg-zinc-700'}`} onClick={() => setActiveTool('select')} title="Select"><i data-lucide="mouse-pointer" className="w-4 h-4"></i></button>
|
||||
<button className={`p-1.5 rounded ${activeTool === 'grab' ? 'bg-cyan-700' : 'bg-zinc-700'}`} onClick={() => setActiveTool('grab')} title="Grab"><i data-lucide="hand" className="w-4 h-4"></i></button>
|
||||
<button className={`p-1.5 rounded ${activeTool === 'razor' ? 'bg-cyan-700' : 'bg-zinc-700'}`} onClick={() => setActiveTool('razor')} title="Razor"><i data-lucide="scissors" className="w-4 h-4"></i></button>
|
||||
<button className={`p-1.5 rounded ${activeTool === 'pen' ? 'bg-cyan-700' : 'bg-zinc-700'}`} onClick={() => setActiveTool('pen')} title="Pen Tool"><i data-lucide="pen-tool" className="w-4 h-4"></i></button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3 flex-1 overflow-x-auto no-scrollbar">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-[10px] text-zinc-400">Normalize:</span>
|
||||
<input type="range" min="-12" max="0" value={st.effects?.normalizeDb || 0} step="0.1" onChange={(e) => handleSubTabNormalizeWithValue(st.id, parseFloat(e.target.value))} className="w-20 h-1.5"/>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-[10px] text-zinc-400">Gain:</span>
|
||||
<input type="range" min="-40" max="24" value={st.effects?.gainDb || 0} step="0.1" onChange={(e) => handleSubTabGainWithValue(st.id, parseFloat(e.target.value))} className="w-20 h-1.5"/>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-[10px] text-zinc-400">Pitch:</span>
|
||||
<input type="range" min="-12" max="12" value={st.effects?.pitch || 0} step="0.1" onChange={(e) => handleSubTabPitch(st.id, parseFloat(e.target.value))} className="w-20 h-1.5"/>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-[10px] text-zinc-400">Stretch:</span>
|
||||
<input type="range" min="50" max="200" value={st.effects?.speedStretch || 100} step="1" onChange={(e) => handleSubTabStretch(st.id, parseInt(e.target.value))} className="w-20 h-1.5"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-1 pl-3 border-l border-zinc-700">
|
||||
<button onClick={() => handleSubTabFade(st.id, 'in')} className="px-2 py-1 text-[10px] rounded hover:bg-zinc-700">Fade In</button>
|
||||
<button onClick={() => handleSubTabFade(st.id, 'out')} className="px-2 py-1 text-[10px] rounded hover:bg-zinc-700">Fade Out</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const App = () => {
|
||||
// ── State Definitions ──
|
||||
const [tracks, setTracks] = useState([
|
||||
@@ -810,14 +845,6 @@
|
||||
snapValueRef.current = snapValue;
|
||||
const bpmRef = useRef(bpm);
|
||||
bpmRef.current = bpm;
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
if (window.lucide) {
|
||||
window.lucide.createIcons();
|
||||
}
|
||||
}, 50);
|
||||
}, [activeTool]);
|
||||
// BMP for Tempo Track - LOOP_EDITOR_2.md §6
|
||||
const [selectedTrackId, setSelectedTrackId] = useState('1');
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
@@ -945,6 +972,15 @@
|
||||
fadeOutMs: 0,
|
||||
});
|
||||
|
||||
// Lucide icons initialization
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
if (window.lucide) {
|
||||
window.lucide.createIcons();
|
||||
}
|
||||
}, 50);
|
||||
}, [activeTool, activeTab]);
|
||||
|
||||
const timelineWrapperRef = useRef(null);
|
||||
const tcpContainerRef = useRef(null);
|
||||
|
||||
@@ -1380,7 +1416,7 @@
|
||||
startTime: selLeft,
|
||||
endTime: selRight,
|
||||
buffer: subBuffer,
|
||||
effects: { reverse: false, gainDb: 0, fadeInMs: 0, fadeOutMs: 0 },
|
||||
effects: { reverse: false, gainDb: 0, fadeInMs: 0, fadeOutMs: 0, normalizeDb: 0, pitch: 0, speedStretch: 100 },
|
||||
currentTime: 0,
|
||||
selectionStart: null,
|
||||
selectionEnd: null,
|
||||
@@ -1421,7 +1457,7 @@
|
||||
startTime: clip.startTime,
|
||||
endTime: clip.startTime + clip.buffer.duration,
|
||||
buffer: subBuffer,
|
||||
effects: { reverse: false, gainDb: 0, fadeInMs: 0, fadeOutMs: 0 },
|
||||
effects: { reverse: false, gainDb: 0, fadeInMs: 0, fadeOutMs: 0, normalizeDb: 0, pitch: 0, speedStretch: 100 },
|
||||
currentTime: 0,
|
||||
selectionStart: null,
|
||||
selectionEnd: null,
|
||||
@@ -1474,6 +1510,50 @@
|
||||
edData[i] = edData[i] * ((edData.length - 1 - i) / fadeSamples);
|
||||
}
|
||||
}
|
||||
// Normalize (apply to selection or entire buffer)
|
||||
if (fx.normalizeDb !== 0) {
|
||||
const targetDb = fx.normalizeDb;
|
||||
let maxVal = 0;
|
||||
for (let i = 0; i < edData.length; i++) {
|
||||
const abs = Math.abs(edData[i]);
|
||||
if (abs > maxVal) maxVal = abs;
|
||||
}
|
||||
if (maxVal > 0) {
|
||||
const targetAmp = Math.pow(10, targetDb / 20);
|
||||
const scale = targetAmp / maxVal;
|
||||
for (let i = 0; i < edData.length; i++) {
|
||||
edData[i] = Math.max(-1, Math.min(1, edData[i] * scale));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Pitch shift (simple resample)
|
||||
if (fx.pitch !== 0) {
|
||||
const ratio = Math.pow(2, fx.pitch / 12);
|
||||
const newLength = Math.round(edData.length / ratio);
|
||||
const newData = new Float32Array(newLength);
|
||||
for (let i = 0; i < newLength; i++) {
|
||||
const srcIdx = i * ratio;
|
||||
const idx0 = Math.floor(srcIdx);
|
||||
const idx1 = Math.min(idx0 + 1, edData.length - 1);
|
||||
const frac = srcIdx - idx0;
|
||||
newData[i] = edData[idx0] * (1 - frac) + edData[idx1] * frac;
|
||||
}
|
||||
edData.set(newData);
|
||||
}
|
||||
// Speed stretch
|
||||
if (fx.speedStretch !== 100) {
|
||||
const ratio = fx.speedStretch / 100;
|
||||
const newLength = Math.round(edData.length * ratio);
|
||||
const newData = new Float32Array(newLength);
|
||||
for (let i = 0; i < newLength; i++) {
|
||||
const srcIdx = i / ratio;
|
||||
const idx0 = Math.floor(srcIdx);
|
||||
const idx1 = Math.min(idx0 + 1, edData.length - 1);
|
||||
const frac = srcIdx - idx0;
|
||||
newData[i] = edData[idx0] * (1 - frac) + edData[idx1] * frac;
|
||||
}
|
||||
edData.set(newData);
|
||||
}
|
||||
|
||||
if (subTab.clipId) {
|
||||
// Clip-based merge
|
||||
@@ -1549,6 +1629,33 @@
|
||||
setSubTabs(prev => prev.map(s => s.id === tabId ? { ...s, effects: { ...s.effects, ...effects } } : s));
|
||||
};
|
||||
|
||||
const handleSubTabNormalizeWithValue = (tabId, normalizeDb) => {
|
||||
updateSubTabEffects(tabId, { normalizeDb });
|
||||
};
|
||||
|
||||
const handleSubTabGainWithValue = (tabId, gainDb) => {
|
||||
updateSubTabEffects(tabId, { gainDb });
|
||||
};
|
||||
|
||||
const handleSubTabPitch = (tabId, pitch) => {
|
||||
updateSubTabEffects(tabId, { pitch });
|
||||
};
|
||||
|
||||
const handleSubTabStretch = (tabId, speedStretch) => {
|
||||
updateSubTabEffects(tabId, { speedStretch });
|
||||
};
|
||||
|
||||
const handleSubTabFadeState = (tabId, type) => {
|
||||
const st = subTabs.find(s => s.id === tabId);
|
||||
if (!st) return;
|
||||
const fx = st.effects || {};
|
||||
if (type === 'in') {
|
||||
updateSubTabEffects(tabId, { fadeInMs: (fx.fadeInMs || 0) + 10 });
|
||||
} else if (type === 'out') {
|
||||
updateSubTabEffects(tabId, { fadeOutMs: (fx.fadeOutMs || 0) + 10 });
|
||||
}
|
||||
};
|
||||
|
||||
// ── Context Menu Handlers ──
|
||||
const handleContextMenu = (e, trackId, clickTime) => {
|
||||
e.preventDefault();
|
||||
@@ -4318,6 +4425,16 @@
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col p-3 gap-3 overflow-y-auto">
|
||||
<SubTabToolbar
|
||||
st={st}
|
||||
activeTool={activeTool}
|
||||
setActiveTool={setActiveTool}
|
||||
handleSubTabNormalizeWithValue={handleSubTabNormalizeWithValue}
|
||||
handleSubTabGainWithValue={handleSubTabGainWithValue}
|
||||
handleSubTabPitch={handleSubTabPitch}
|
||||
handleSubTabStretch={handleSubTabStretch}
|
||||
handleSubTabFade={handleSubTabFadeState}
|
||||
/>
|
||||
<SubTabWaveform
|
||||
buffer={st.buffer}
|
||||
subTabId={st.id}
|
||||
|
||||
+127
-10
@@ -779,6 +779,41 @@
|
||||
);
|
||||
};
|
||||
|
||||
const SubTabToolbar = ({ st, activeTool, setActiveTool, handleSubTabNormalizeWithValue, handleSubTabGainWithValue, handleSubTabPitch, handleSubTabStretch, handleSubTabFade }) => (
|
||||
<div className="daw-header flex h-16 items-center px-4 border-b daw-border gap-3 bg-zinc-800">
|
||||
<div className="flex items-center space-x-1 border-r border-zinc-700 pr-3">
|
||||
<button className={`p-1.5 rounded ${activeTool === 'select' ? 'bg-cyan-700' : 'bg-zinc-700'}`} onClick={() => setActiveTool('select')} title="Select"><i data-lucide="mouse-pointer" className="w-4 h-4"></i></button>
|
||||
<button className={`p-1.5 rounded ${activeTool === 'grab' ? 'bg-cyan-700' : 'bg-zinc-700'}`} onClick={() => setActiveTool('grab')} title="Grab"><i data-lucide="hand" className="w-4 h-4"></i></button>
|
||||
<button className={`p-1.5 rounded ${activeTool === 'razor' ? 'bg-cyan-700' : 'bg-zinc-700'}`} onClick={() => setActiveTool('razor')} title="Razor"><i data-lucide="scissors" className="w-4 h-4"></i></button>
|
||||
<button className={`p-1.5 rounded ${activeTool === 'pen' ? 'bg-cyan-700' : 'bg-zinc-700'}`} onClick={() => setActiveTool('pen')} title="Pen Tool"><i data-lucide="pen-tool" className="w-4 h-4"></i></button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3 flex-1 overflow-x-auto no-scrollbar">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-[10px] text-zinc-400">Normalize:</span>
|
||||
<input type="range" min="-12" max="0" value={st.effects?.normalizeDb || 0} step="0.1" onChange={(e) => handleSubTabNormalizeWithValue(st.id, parseFloat(e.target.value))} className="w-20 h-1.5"/>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-[10px] text-zinc-400">Gain:</span>
|
||||
<input type="range" min="-40" max="24" value={st.effects?.gainDb || 0} step="0.1" onChange={(e) => handleSubTabGainWithValue(st.id, parseFloat(e.target.value))} className="w-20 h-1.5"/>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-[10px] text-zinc-400">Pitch:</span>
|
||||
<input type="range" min="-12" max="12" value={st.effects?.pitch || 0} step="0.1" onChange={(e) => handleSubTabPitch(st.id, parseFloat(e.target.value))} className="w-20 h-1.5"/>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-[10px] text-zinc-400">Stretch:</span>
|
||||
<input type="range" min="50" max="200" value={st.effects?.speedStretch || 100} step="1" onChange={(e) => handleSubTabStretch(st.id, parseInt(e.target.value))} className="w-20 h-1.5"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-1 pl-3 border-l border-zinc-700">
|
||||
<button onClick={() => handleSubTabFade(st.id, 'in')} className="px-2 py-1 text-[10px] rounded hover:bg-zinc-700">Fade In</button>
|
||||
<button onClick={() => handleSubTabFade(st.id, 'out')} className="px-2 py-1 text-[10px] rounded hover:bg-zinc-700">Fade Out</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const App = () => {
|
||||
// ── State Definitions ──
|
||||
const [tracks, setTracks] = useState([
|
||||
@@ -810,14 +845,6 @@
|
||||
snapValueRef.current = snapValue;
|
||||
const bpmRef = useRef(bpm);
|
||||
bpmRef.current = bpm;
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
if (window.lucide) {
|
||||
window.lucide.createIcons();
|
||||
}
|
||||
}, 50);
|
||||
}, [activeTool]);
|
||||
// BMP for Tempo Track - LOOP_EDITOR_2.md §6
|
||||
const [selectedTrackId, setSelectedTrackId] = useState('1');
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
@@ -945,6 +972,15 @@
|
||||
fadeOutMs: 0,
|
||||
});
|
||||
|
||||
// Lucide icons initialization
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
if (window.lucide) {
|
||||
window.lucide.createIcons();
|
||||
}
|
||||
}, 50);
|
||||
}, [activeTool, activeTab]);
|
||||
|
||||
const timelineWrapperRef = useRef(null);
|
||||
const tcpContainerRef = useRef(null);
|
||||
|
||||
@@ -1380,7 +1416,7 @@
|
||||
startTime: selLeft,
|
||||
endTime: selRight,
|
||||
buffer: subBuffer,
|
||||
effects: { reverse: false, gainDb: 0, fadeInMs: 0, fadeOutMs: 0 },
|
||||
effects: { reverse: false, gainDb: 0, fadeInMs: 0, fadeOutMs: 0, normalizeDb: 0, pitch: 0, speedStretch: 100 },
|
||||
currentTime: 0,
|
||||
selectionStart: null,
|
||||
selectionEnd: null,
|
||||
@@ -1421,7 +1457,7 @@
|
||||
startTime: clip.startTime,
|
||||
endTime: clip.startTime + clip.buffer.duration,
|
||||
buffer: subBuffer,
|
||||
effects: { reverse: false, gainDb: 0, fadeInMs: 0, fadeOutMs: 0 },
|
||||
effects: { reverse: false, gainDb: 0, fadeInMs: 0, fadeOutMs: 0, normalizeDb: 0, pitch: 0, speedStretch: 100 },
|
||||
currentTime: 0,
|
||||
selectionStart: null,
|
||||
selectionEnd: null,
|
||||
@@ -1474,6 +1510,50 @@
|
||||
edData[i] = edData[i] * ((edData.length - 1 - i) / fadeSamples);
|
||||
}
|
||||
}
|
||||
// Normalize (apply to selection or entire buffer)
|
||||
if (fx.normalizeDb !== 0) {
|
||||
const targetDb = fx.normalizeDb;
|
||||
let maxVal = 0;
|
||||
for (let i = 0; i < edData.length; i++) {
|
||||
const abs = Math.abs(edData[i]);
|
||||
if (abs > maxVal) maxVal = abs;
|
||||
}
|
||||
if (maxVal > 0) {
|
||||
const targetAmp = Math.pow(10, targetDb / 20);
|
||||
const scale = targetAmp / maxVal;
|
||||
for (let i = 0; i < edData.length; i++) {
|
||||
edData[i] = Math.max(-1, Math.min(1, edData[i] * scale));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Pitch shift (simple resample)
|
||||
if (fx.pitch !== 0) {
|
||||
const ratio = Math.pow(2, fx.pitch / 12);
|
||||
const newLength = Math.round(edData.length / ratio);
|
||||
const newData = new Float32Array(newLength);
|
||||
for (let i = 0; i < newLength; i++) {
|
||||
const srcIdx = i * ratio;
|
||||
const idx0 = Math.floor(srcIdx);
|
||||
const idx1 = Math.min(idx0 + 1, edData.length - 1);
|
||||
const frac = srcIdx - idx0;
|
||||
newData[i] = edData[idx0] * (1 - frac) + edData[idx1] * frac;
|
||||
}
|
||||
edData.set(newData);
|
||||
}
|
||||
// Speed stretch
|
||||
if (fx.speedStretch !== 100) {
|
||||
const ratio = fx.speedStretch / 100;
|
||||
const newLength = Math.round(edData.length * ratio);
|
||||
const newData = new Float32Array(newLength);
|
||||
for (let i = 0; i < newLength; i++) {
|
||||
const srcIdx = i / ratio;
|
||||
const idx0 = Math.floor(srcIdx);
|
||||
const idx1 = Math.min(idx0 + 1, edData.length - 1);
|
||||
const frac = srcIdx - idx0;
|
||||
newData[i] = edData[idx0] * (1 - frac) + edData[idx1] * frac;
|
||||
}
|
||||
edData.set(newData);
|
||||
}
|
||||
|
||||
if (subTab.clipId) {
|
||||
// Clip-based merge
|
||||
@@ -1549,6 +1629,33 @@
|
||||
setSubTabs(prev => prev.map(s => s.id === tabId ? { ...s, effects: { ...s.effects, ...effects } } : s));
|
||||
};
|
||||
|
||||
const handleSubTabNormalizeWithValue = (tabId, normalizeDb) => {
|
||||
updateSubTabEffects(tabId, { normalizeDb });
|
||||
};
|
||||
|
||||
const handleSubTabGainWithValue = (tabId, gainDb) => {
|
||||
updateSubTabEffects(tabId, { gainDb });
|
||||
};
|
||||
|
||||
const handleSubTabPitch = (tabId, pitch) => {
|
||||
updateSubTabEffects(tabId, { pitch });
|
||||
};
|
||||
|
||||
const handleSubTabStretch = (tabId, speedStretch) => {
|
||||
updateSubTabEffects(tabId, { speedStretch });
|
||||
};
|
||||
|
||||
const handleSubTabFadeState = (tabId, type) => {
|
||||
const st = subTabs.find(s => s.id === tabId);
|
||||
if (!st) return;
|
||||
const fx = st.effects || {};
|
||||
if (type === 'in') {
|
||||
updateSubTabEffects(tabId, { fadeInMs: (fx.fadeInMs || 0) + 10 });
|
||||
} else if (type === 'out') {
|
||||
updateSubTabEffects(tabId, { fadeOutMs: (fx.fadeOutMs || 0) + 10 });
|
||||
}
|
||||
};
|
||||
|
||||
// ── Context Menu Handlers ──
|
||||
const handleContextMenu = (e, trackId, clickTime) => {
|
||||
e.preventDefault();
|
||||
@@ -4318,6 +4425,16 @@
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col p-3 gap-3 overflow-y-auto">
|
||||
<SubTabToolbar
|
||||
st={st}
|
||||
activeTool={activeTool}
|
||||
setActiveTool={setActiveTool}
|
||||
handleSubTabNormalizeWithValue={handleSubTabNormalizeWithValue}
|
||||
handleSubTabGainWithValue={handleSubTabGainWithValue}
|
||||
handleSubTabPitch={handleSubTabPitch}
|
||||
handleSubTabStretch={handleSubTabStretch}
|
||||
handleSubTabFade={handleSubTabFadeState}
|
||||
/>
|
||||
<SubTabWaveform
|
||||
buffer={st.buffer}
|
||||
subTabId={st.id}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -42,3 +42,39 @@ def test_merge_back_to_parent():
|
||||
assert np.allclose(res[:900], 1.0)
|
||||
# Inside the edited range should be zero (except crossfades)
|
||||
assert np.allclose(res[1100:2900], 0.0)
|
||||
|
||||
def test_apply_volume_automation_envelope():
|
||||
sr = 44100
|
||||
duration = 2.0
|
||||
y = np.ones(int(sr * duration), dtype=np.float32) * 0.5 # Constant signal at -6dB
|
||||
|
||||
# Simple fade in from -inf to 0dB over 1 second
|
||||
nodes = [
|
||||
{"time": 0.0, "db": -60.0}, # Effectively -inf
|
||||
{"time": 1.0, "db": 0.0},
|
||||
{"time": 2.0, "db": 0.0},
|
||||
]
|
||||
|
||||
y_automated = SubTabDSPEngine.apply_volume_automation_envelope(y, sr, nodes)
|
||||
|
||||
y_automated = SubTabDSPEngine.apply_volume_automation_envelope(y, sr, nodes)
|
||||
|
||||
# Check start: should be 0.5 * 10**(-30/20) due to clipping
|
||||
expected_start_val = 0.5 * (10**(-30/20.0))
|
||||
assert np.isclose(y_automated[0], expected_start_val, atol=1e-5)
|
||||
|
||||
# Check at 0.5 seconds: interpolated to -15dB (halfway between -30dB and 0dB)
|
||||
# y * (10 ** (-15 / 20))
|
||||
expected_mid_val = 0.5 * (10**(-15/20.0))
|
||||
assert np.isclose(y_automated[int(0.5 * sr)], expected_mid_val, atol=1e-5)
|
||||
|
||||
# Check at 1.0 seconds: should be 0.5 * (10**(0/20)) = 0.5
|
||||
assert np.isclose(y_automated[int(1.0 * sr)], 0.5, atol=1e-5)
|
||||
|
||||
# Check at end: should be 0.5
|
||||
assert np.isclose(y_automated[-1], 0.5, atol=1e-5)
|
||||
|
||||
# Test with empty nodes
|
||||
y_no_nodes = SubTabDSPEngine.apply_volume_automation_envelope(y, sr, [])
|
||||
assert np.array_equal(y_no_nodes, y)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user