From 95dc9346ef75c16147a8dbbd576d75a386e16d10 Mon Sep 17 00:00:00 2001 From: 3dtours Date: Sun, 19 Jul 2026 10:19:25 +0700 Subject: [PATCH] =?UTF-8?q?fix&feat:=20hi=E1=BB=83n=20th=E1=BB=8B=20tools?= =?UTF-8?q?=20ch=E1=BB=89nh=20s=E1=BB=ADa=20audioclip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- 14_FIX_SUBTAB.md | 283 +++ app/core/sub_tab_dsp.py | 51 + app/templates/index.html | 153 +- index.html | 153 +- index.html.recovered | 4506 +++++++++++++++++++++++++++++++++++++ tests/test_sub_tab_dsp.py | 36 + 6 files changed, 5146 insertions(+), 36 deletions(-) create mode 100644 14_FIX_SUBTAB.md create mode 100644 index.html.recovered diff --git a/14_FIX_SUBTAB.md b/14_FIX_SUBTAB.md new file mode 100644 index 0000000..9360a02 --- /dev/null +++ b/14_FIX_SUBTAB.md @@ -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) + +``` \ No newline at end of file diff --git a/app/core/sub_tab_dsp.py b/app/core/sub_tab_dsp.py index 434eb33..7ef8bb8 100644 --- a/app/core/sub_tab_dsp.py +++ b/app/core/sub_tab_dsp.py @@ -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, diff --git a/app/templates/index.html b/app/templates/index.html index f8b51c4..e9abffb 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -779,6 +779,41 @@ ); }; + const SubTabToolbar = ({ st, activeTool, setActiveTool, handleSubTabNormalizeWithValue, handleSubTabGainWithValue, handleSubTabPitch, handleSubTabStretch, handleSubTabFade }) => ( +
+
+ + + + +
+ +
+
+ Normalize: + handleSubTabNormalizeWithValue(st.id, parseFloat(e.target.value))} className="w-20 h-1.5"/> +
+
+ Gain: + handleSubTabGainWithValue(st.id, parseFloat(e.target.value))} className="w-20 h-1.5"/> +
+
+ Pitch: + handleSubTabPitch(st.id, parseFloat(e.target.value))} className="w-20 h-1.5"/> +
+
+ Stretch: + handleSubTabStretch(st.id, parseInt(e.target.value))} className="w-20 h-1.5"/> +
+
+ +
+ + +
+
+ ); + 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, @@ -1466,14 +1502,58 @@ const fadeSamples = Math.min(edData.length, Math.floor(fx.fadeInMs / 1000 * sr)); for (let i = 0; i < fadeSamples; i++) edData[i] = edData[i] * (i / fadeSamples); } - // Fade out - if (fx.fadeOutMs > 0) { - const sr = edBuffer.sampleRate; - const fadeSamples = Math.min(edData.length, Math.floor(fx.fadeOutMs / 1000 * sr)); - for (let i = edData.length - fadeSamples; i < edData.length; i++) { - edData[i] = edData[i] * ((edData.length - 1 - i) / fadeSamples); - } - } +// Fade out + if (fx.fadeOutMs > 0) { + const sr = edBuffer.sampleRate; + const fadeSamples = Math.min(edData.length, Math.floor(fx.fadeOutMs / 1000 * sr)); + for (let i = edData.length - fadeSamples; i < edData.length; i++) { + edData[i] = edData[i] * ((edData.length - 1 - i) / fadeSamples); + } + } + // 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 @@
+ ( +
+
+ + + + +
+ +
+
+ Normalize: + handleSubTabNormalizeWithValue(st.id, parseFloat(e.target.value))} className="w-20 h-1.5"/> +
+
+ Gain: + handleSubTabGainWithValue(st.id, parseFloat(e.target.value))} className="w-20 h-1.5"/> +
+
+ Pitch: + handleSubTabPitch(st.id, parseFloat(e.target.value))} className="w-20 h-1.5"/> +
+
+ Stretch: + handleSubTabStretch(st.id, parseInt(e.target.value))} className="w-20 h-1.5"/> +
+
+ +
+ + +
+
+ ); + 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, @@ -1466,14 +1502,58 @@ const fadeSamples = Math.min(edData.length, Math.floor(fx.fadeInMs / 1000 * sr)); for (let i = 0; i < fadeSamples; i++) edData[i] = edData[i] * (i / fadeSamples); } - // Fade out - if (fx.fadeOutMs > 0) { - const sr = edBuffer.sampleRate; - const fadeSamples = Math.min(edData.length, Math.floor(fx.fadeOutMs / 1000 * sr)); - for (let i = edData.length - fadeSamples; i < edData.length; i++) { - edData[i] = edData[i] * ((edData.length - 1 - i) / fadeSamples); - } - } +// Fade out + if (fx.fadeOutMs > 0) { + const sr = edBuffer.sampleRate; + const fadeSamples = Math.min(edData.length, Math.floor(fx.fadeOutMs / 1000 * sr)); + for (let i = edData.length - fadeSamples; i < edData.length; i++) { + edData[i] = edData[i] * ((edData.length - 1 - i) / fadeSamples); + } + } + // 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 @@
+ + + + + + SonicForge Studio - Professional DAW Editor + + + + + + + + +
+ + + + \ No newline at end of file diff --git a/tests/test_sub_tab_dsp.py b/tests/test_sub_tab_dsp.py index 6b25b56..7c66277 100644 --- a/tests/test_sub_tab_dsp.py +++ b/tests/test_sub_tab_dsp.py @@ -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) +