Files
SonicForgeStudio/app/core/sub_tab_dsp.py
T

139 lines
5.8 KiB
Python

import numpy as np
import scipy.signal as signal
import librosa
class SubTabDSPEngine:
@staticmethod
def change_speed(y: np.ndarray, sr: int, speed_ratio: float, preserve_pitch: bool = True) -> np.ndarray:
"""
Alters the playback velocity (Time-Stretching) of a NumPy signal array.
"""
if speed_ratio == 1.0:
return y
if preserve_pitch:
return librosa.effects.time_stretch(y, rate=speed_ratio)
else:
num_samples_new = int(len(y) / speed_ratio)
return signal.resample(y, num_samples_new)
@staticmethod
def normalize(y: np.ndarray, target_db: float = 0.0) -> np.ndarray:
"""
Performs Peak Normalization on an array to scale it to the target decibel value.
"""
target_amplitude = 10.0 ** (target_db / 20.0)
max_amplitude = np.max(np.abs(y))
if max_amplitude == 0:
return y
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,
sr: int,
edited_sub_audio: np.ndarray,
start_seconds: float,
original_duration_seconds: float
) -> np.ndarray:
"""
Splices the modified audio segment from the Sub-tab back into the parent track array.
Applies a 10ms micro-crossfade at the boundaries to eliminate pop/click noise.
"""
start_sample = int(start_seconds * sr)
original_samples_len = int(original_duration_seconds * sr)
edited_samples_len = len(edited_sub_audio)
crossfade_samples = int(0.01 * sr) # 10ms crossfade window
# 1. Allocate the target output array dimension bounds
new_total_len = len(parent_track_audio) - original_samples_len + edited_samples_len
output_audio = np.zeros(new_total_len, dtype=np.float32)
# 2. Extract leading unedited block
output_audio[:start_sample] = parent_track_audio[:start_sample]
# 3. Stitch the modified audio payload
output_audio[start_sample:start_sample + edited_samples_len] = edited_sub_audio
# 4. Extract trailing unedited block
post_start_original = start_sample + original_samples_len
post_start_new = start_sample + edited_samples_len
output_audio[post_start_new:] = parent_track_audio[post_start_original:]
# 5. Execute micro-crossfade across the initial splice junction
if start_sample > crossfade_samples:
fade_in_ramp = np.linspace(0.0, 1.0, crossfade_samples)
fade_out_ramp = np.linspace(1.0, 0.0, crossfade_samples)
# Smooth 10ms interpolation overlay
output_audio[start_sample : start_sample + crossfade_samples] = (
edited_sub_audio[:crossfade_samples] * fade_in_ramp +
parent_track_audio[start_sample : start_sample + crossfade_samples] * fade_out_ramp
)
# 6. Execute micro-crossfade across the trailing splice junction
if post_start_new + crossfade_samples < len(output_audio):
fade_in_ramp = np.linspace(0.0, 1.0, crossfade_samples)
fade_out_ramp = np.linspace(1.0, 0.0, crossfade_samples)
output_audio[post_start_new : post_start_new + crossfade_samples] = (
parent_track_audio[post_start_original : post_start_original + crossfade_samples] * fade_in_ramp +
edited_sub_audio[-crossfade_samples:] * fade_out_ramp
)
return output_audio