fix: time selection click and shiftclick
This commit is contained in:
@@ -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ệ")
|
||||
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")
|
||||
async def login(req: LoginRequest):
|
||||
conn = get_db_connection()
|
||||
|
||||
@@ -80,6 +80,49 @@ def apply_micro_fade(segment: AudioSegment, fade_duration_ms: int = 50) -> Audio
|
||||
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:
|
||||
"""
|
||||
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
|
||||
// High-performance Desktop-Grade Client-Side Audio Engine & DSP Service (21_CLIENT_PRE.md)
|
||||
|
||||
(function() {
|
||||
let audioCtx = null;
|
||||
let workletLoaded = false;
|
||||
|
||||
function getAudioContext() {
|
||||
if (!audioCtx) {
|
||||
@@ -12,6 +15,22 @@
|
||||
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) {
|
||||
if (!audioBuffer) return { channels: 1, isStereo: false, label: 'MONO' };
|
||||
const numChannels = audioBuffer.numberOfChannels;
|
||||
@@ -34,9 +53,223 @@
|
||||
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 = {
|
||||
getAudioContext,
|
||||
initAudioWorklet,
|
||||
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.
+572
-284
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user