fix: normalize peak audio render offline chống clip polyphony; TCP wheel điều chỉnh volume/pan
This commit is contained in:
@@ -1048,6 +1048,8 @@ async def soundfont_render(req: SoundfontRenderRequest, current_user: dict = Dep
|
|||||||
bank=req.bank, program=req.program,
|
bank=req.bank, program=req.program,
|
||||||
sr=req.sample_rate, bpm=req.bpm,
|
sr=req.sample_rate, bpm=req.bpm,
|
||||||
)
|
)
|
||||||
|
from app.core import native_render
|
||||||
|
audio = native_render.normalize_audio_peak(audio)
|
||||||
sf.write(out_path, audio.T, req.sample_rate)
|
sf.write(out_path, audio.T, req.sample_rate)
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
|
|||||||
@@ -39,6 +39,35 @@ def find_bridge_exe() -> str:
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_audio_peak(audio, target_db=-3.0):
|
||||||
|
"""Scale audio (numpy array, peak) về target_db nếu vượt; giữ nguyên nếu dưới.
|
||||||
|
|
||||||
|
Chống clip do note MIDI chồng (polyphony) — spec midi_polyphony_loudness §II:
|
||||||
|
render offline nhiều note đồng thời → tổng biên độ vượt 0dBFS → WAV vỡ đỉnh
|
||||||
|
(hard clip, méo harmonic) trước khi tới client; limiter master không cứu được.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
import numpy as np
|
||||||
|
peak = float(np.max(np.abs(audio))) if audio.size else 0.0
|
||||||
|
limit = 10 ** (target_db / 20)
|
||||||
|
if peak > limit and peak > 1e-9:
|
||||||
|
return audio * (limit / peak)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return audio
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_peak_file(path, target_db=-3.0):
|
||||||
|
"""Post-render WAV: scan peak, vượt target_db → scale toàn file (giữ format)."""
|
||||||
|
try:
|
||||||
|
info = sf.info(path)
|
||||||
|
data, sr = sf.read(path, dtype="float32", always_2d=True)
|
||||||
|
data = normalize_audio_peak(data, target_db)
|
||||||
|
sf.write(path, data, sr, subtype=info.subtype)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def classify_instrument(path: str) -> int:
|
def classify_instrument(path: str) -> int:
|
||||||
"""InstrumentType enum của bridge: 0=VST3, 1=VST2, 2=SF2/SF3, 3=SFZ."""
|
"""InstrumentType enum của bridge: 0=VST3, 1=VST2, 2=SF2/SF3, 3=SFZ."""
|
||||||
low = path.lower()
|
low = path.lower()
|
||||||
@@ -139,6 +168,7 @@ def render_offline(instrument_id, notes, bpm, sample_rate, preset_id=None,
|
|||||||
detail=f"Bridge không load được plugin {instrument_id}. {log[-500:]}")
|
detail=f"Bridge không load được plugin {instrument_id}. {log[-500:]}")
|
||||||
raise HTTPException(status_code=500,
|
raise HTTPException(status_code=500,
|
||||||
detail=f"Render thất bại (rc={r.returncode}). {log[-500:]}")
|
detail=f"Render thất bại (rc={r.returncode}). {log[-500:]}")
|
||||||
|
normalize_peak_file(out_path)
|
||||||
return out_path, sf.info(out_path).frames / float(sample_rate)
|
return out_path, sf.info(out_path).frames / float(sample_rate)
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
|
|||||||
+258
-175
@@ -243,57 +243,227 @@ const stopAllNativeSfNotes = () => {
|
|||||||
try { Object.keys(_nativeSfPreviews).forEach(function (k) { stopNativeSfNote(k); }); } catch (e) {}
|
try { Object.keys(_nativeSfPreviews).forEach(function (k) { stopNativeSfNote(k); }); } catch (e) {}
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Native soundfont item render (standalone, transport) ──────────────────
|
// ── Native track render (standalone, transport) ──────────────────────────────────────────────────────
|
||||||
// Render TOÀN BỘ MIDI item bằng native FluidSynth → decode AudioBuffer →
|
// Render TOÀN BỘ MIDI items của track bằng 1 request (native FluidSynth cho
|
||||||
// schedule nguồn audio đúng vị trí item (giống audio clip). opts:
|
// soundfont, native_bridge --render cho VSTi) → decode → 1 BufferSource duy
|
||||||
|
// nhất cho cả track (như audio clip). Thay thế render per-item: RENDER_LOCK
|
||||||
|
// server serialize từng item → project nhiều item = delay vài chục giây.
|
||||||
|
// opts:
|
||||||
// baseOffsetSec: offset thêm (section: secStart) | limitSec: chặn tại secEnd
|
// baseOffsetSec: offset thêm (section: secStart) | limitSec: chặn tại secEnd
|
||||||
// isActive(): guard stop giữa chừng | sources: mảng nguồn để stop.
|
// isActive(): guard stop giữa chừng | sources: mảng nguồn để stop
|
||||||
const scheduleNativeSfItem = (track, item, offsetTime, context, destNode, bpm, opts) => {
|
// playheadNow(): playhead THẬT tại thời điểm resolve — render trễ vài chục
|
||||||
|
// giây → item vẫn schedule đúng vị trí playhead hiện tại (không chồng
|
||||||
|
// âm/wall-of-sound như dùng offsetTime cũ).
|
||||||
|
// notesOverride: dùng thay track.midiItems (piano roll tab).
|
||||||
|
// Epoch guard: stop/rebuild tăng _nativePlayEpoch → mọi render pending của
|
||||||
|
// lần play cũ resolve nhưng bị skip (không duplicate schedule).
|
||||||
|
let _nativePlayEpoch = 0;
|
||||||
|
const _nativeTrackUrls = {};
|
||||||
|
const _nativeTrackPending = {};
|
||||||
|
// ── MIDI overlap cleanup + polyphony gain (spec midi_polyphony_loudness) ─
|
||||||
|
// Dedup: note cùng pitch + cùng start_beat → gộp 1, giữ velocity cao hơn
|
||||||
|
// (tránh synth instantiate nhiều voice cùng pitch — spec §II.5).
|
||||||
|
// Legato cleanup: note trước cùng pitch kéo dài qua start_beat note sau → cắt
|
||||||
|
// 0.01 beats trước Note-On mới (tránh duplicate voice allocation).
|
||||||
|
const _cleanNativeNotes = (notes) => {
|
||||||
|
const out = [];
|
||||||
|
notes.forEach(function (n) {
|
||||||
|
const pitch = n.pitch || 60;
|
||||||
|
const start = n.start_beat || 0;
|
||||||
|
const dup = out.find(function (o) { return o.pitch === pitch && o.start_beat === start; });
|
||||||
|
if (dup) {
|
||||||
|
dup.velocity = Math.max(dup.velocity, n.velocity != null ? n.velocity : 0.8);
|
||||||
|
dup.duration_beats = Math.max(dup.duration_beats, n.duration_beats || 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
out.push({ pitch: pitch, start_beat: start, duration_beats: n.duration_beats || 1, velocity: n.velocity != null ? n.velocity : 0.8 });
|
||||||
|
});
|
||||||
|
out.forEach(function (n) {
|
||||||
|
let nextStart = Infinity;
|
||||||
|
out.forEach(function (m) {
|
||||||
|
if (m === n || m.pitch !== n.pitch || m.start_beat <= n.start_beat) return;
|
||||||
|
if (m.start_beat < nextStart) nextStart = m.start_beat;
|
||||||
|
});
|
||||||
|
if (nextStart < n.start_beat + n.duration_beats) {
|
||||||
|
n.duration_beats = Math.max(0.01, nextStart - n.start_beat - 0.01);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
// Polyphony gain compensation (spec §II.1): scale velocity theo số voice đồng
|
||||||
|
// thời tại thời điểm note bắt đầu — Equal Power G = 1/sqrt(N). Solo N=1 → giữ
|
||||||
|
// nguyên; chord 8 note → -9dB mỗi voice, tổng peak không vượt trần.
|
||||||
|
const _scaleNativePolyphony = (notes) => {
|
||||||
|
notes.forEach(function (n) {
|
||||||
|
const nStart = n.start_beat || 0;
|
||||||
|
let active = 0;
|
||||||
|
notes.forEach(function (m) {
|
||||||
|
const mEnd = (m.start_beat || 0) + (m.duration_beats || 1);
|
||||||
|
if ((m.start_beat || 0) <= nStart && nStart < mEnd) active++;
|
||||||
|
});
|
||||||
|
if (active > 1) {
|
||||||
|
const g = 1 / Math.sqrt(active);
|
||||||
|
n.velocity = (n.velocity != null ? n.velocity : 0.8) * g;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return notes;
|
||||||
|
};
|
||||||
|
const _buildNativeTrackNotes = (track, bpm, baseOffsetSec, notesOverride) => {
|
||||||
|
const secPerBeat = 60.0 / (parseInt(bpm) || 120);
|
||||||
|
const items = notesOverride ? [{ startTime: 0, notes: notesOverride }] : ((track && track.midiItems) || []);
|
||||||
|
let trackStartAbs = Infinity;
|
||||||
|
const absNotes = [];
|
||||||
|
items.forEach(function (item) {
|
||||||
|
const itemStartAbs = (baseOffsetSec || 0) + (item.startTime || 0);
|
||||||
|
if (itemStartAbs < trackStartAbs) trackStartAbs = itemStartAbs;
|
||||||
|
(item.notes || []).forEach(function (n) {
|
||||||
|
absNotes.push({
|
||||||
|
pitch: n.pitch || 60,
|
||||||
|
start_beat: itemStartAbs / secPerBeat + (n.start_beat || 0),
|
||||||
|
duration_beats: n.duration_beats || 1,
|
||||||
|
velocity: n.velocity != null ? n.velocity : 0.8,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return { absNotes: _scaleNativePolyphony(_cleanNativeNotes(absNotes)), trackStartAbs: trackStartAbs };
|
||||||
|
};
|
||||||
|
const _nativeTrackKey = (track, bpm, baseOffsetSec, notesOverride) => {
|
||||||
try {
|
try {
|
||||||
const eng = track && track.synth_engine;
|
const eng = track && track.synth_engine;
|
||||||
const sfId = eng && eng.soundfont_id;
|
const instrumentId = (eng && eng.plugin_id) || (track && track.instrumentId) || '';
|
||||||
if (!sfId) return;
|
const sfId = (eng && eng.soundfont_id) || '';
|
||||||
const notes = (item && item.notes) || [];
|
const data = _buildNativeTrackNotes(track, bpm, baseOffsetSec, notesOverride);
|
||||||
if (!notes.length) return;
|
const nk = data.absNotes.map(function (n) {
|
||||||
const secPerBeat = 60.0 / (parseInt(bpm) || 120);
|
return n.pitch + ':' + n.start_beat + ':' + n.duration_beats + ':' + n.velocity;
|
||||||
const baseOffsetSec = (opts && opts.baseOffsetSec) || 0;
|
}).join(',');
|
||||||
const itemStartAbs = baseOffsetSec + (item.startTime || 0);
|
return [track && track.id ? track.id : '', instrumentId, sfId, (eng && eng.preset_id) || '', (eng && eng.preset_path) || '', parseFloat(bpm) || 120, baseOffsetSec || 0, nk].join('|');
|
||||||
let itemEndAbs = itemStartAbs + 0.05;
|
} catch (e) { return ''; }
|
||||||
notes.forEach(function (n) {
|
};
|
||||||
const end = itemStartAbs + ((n.start_beat || 0) + (n.duration_beats || 1)) * secPerBeat;
|
const _renderNativeTrack = (track, bpm, baseOffsetSec, notesOverride) => {
|
||||||
if (end > itemEndAbs) itemEndAbs = end;
|
try {
|
||||||
});
|
const eng = track && track.synth_engine;
|
||||||
window.SonicAPI.soundfontRender({
|
const data = _buildNativeTrackNotes(track, bpm, baseOffsetSec, notesOverride);
|
||||||
soundfont_id: sfId,
|
if (!data.absNotes.length) return Promise.resolve(null);
|
||||||
bank: (eng && eng.soundfont_bank) || 0,
|
if (isSfTrackEngine(eng)) {
|
||||||
program: (eng && eng.soundfont_program) || 0,
|
const sfId = eng && eng.soundfont_id;
|
||||||
|
if (!sfId) return Promise.resolve(null);
|
||||||
|
return window.SonicAPI.soundfontRender({
|
||||||
|
soundfont_id: sfId,
|
||||||
|
bank: (eng && eng.soundfont_bank) || 0,
|
||||||
|
program: (eng && eng.soundfont_program) || 0,
|
||||||
|
bpm: parseFloat(bpm) || 120,
|
||||||
|
notes: data.absNotes,
|
||||||
|
}).then(function (res) {
|
||||||
|
if (!res || !res.success || !res.url) return null;
|
||||||
|
return API_BASE_URL + res.url;
|
||||||
|
}).catch(function () { return null; });
|
||||||
|
}
|
||||||
|
const instrumentId = (eng && eng.plugin_id) || (track && track.instrumentId);
|
||||||
|
if (!instrumentId || !isVstTrackEngine(eng)) return Promise.resolve(null);
|
||||||
|
return window.SonicAPI.midiRender({
|
||||||
|
instrument_id: instrumentId,
|
||||||
|
notes: data.absNotes,
|
||||||
bpm: parseFloat(bpm) || 120,
|
bpm: parseFloat(bpm) || 120,
|
||||||
notes: notes.map(function (n) { return { pitch: n.pitch || 60, start_beat: n.start_beat || 0, duration_beats: n.duration_beats || 1, velocity: n.velocity != null ? n.velocity : 0.8 }; }),
|
preset_id: (eng && eng.preset_id) || undefined,
|
||||||
|
preset_path: (eng && eng.preset_path) || undefined,
|
||||||
|
preset_data: (eng && eng.preset_data) || undefined,
|
||||||
}).then(function (res) {
|
}).then(function (res) {
|
||||||
if (!res || !res.success || !res.url) return;
|
if (!res || !res.success || !res.url) return null;
|
||||||
fetch(API_BASE_URL + res.url).then(function (r) { return r.arrayBuffer(); }).then(function (buf) {
|
return API_BASE_URL + res.url;
|
||||||
context.decodeAudioData(buf, function (audioBuf) {
|
}).catch(function () { return null; });
|
||||||
|
} catch (e) { return Promise.resolve(null); }
|
||||||
|
};
|
||||||
|
// Dedupe in-flight render + cache WAV: play lặp/re-schedule trong lúc render
|
||||||
|
// đang chạy → chờ cùng promise, không bắn duplicate request (tránh queue sau
|
||||||
|
// RENDER_LOCK + render 2 lần cùng nội dung).
|
||||||
|
const ensureRenderNativeTrack = (track, bpm, baseOffsetSec, notesOverride) => {
|
||||||
|
const key = _nativeTrackKey(track, bpm, baseOffsetSec, notesOverride);
|
||||||
|
if (!key) return _renderNativeTrack(track, bpm, baseOffsetSec, notesOverride);
|
||||||
|
if (_nativeTrackUrls[key]) return Promise.resolve(_nativeTrackUrls[key]);
|
||||||
|
if (_nativeTrackPending[key]) return _nativeTrackPending[key];
|
||||||
|
const p = _renderNativeTrack(track, bpm, baseOffsetSec, notesOverride).then(function (url) {
|
||||||
|
if (url) _nativeTrackUrls[key] = url;
|
||||||
|
return url;
|
||||||
|
}).then(function (url) {
|
||||||
|
delete _nativeTrackPending[key];
|
||||||
|
return url;
|
||||||
|
});
|
||||||
|
_nativeTrackPending[key] = p;
|
||||||
|
return p;
|
||||||
|
};
|
||||||
|
// Giới hạn decodeAudioData song song (max 3) — nhiều WAV fetch về cùng lúc →
|
||||||
|
// main thread nghẽn → UI đơ, Stop không phản hồi.
|
||||||
|
let _nativeDecodeActive = 0;
|
||||||
|
const _nativeDecodeQueue = [];
|
||||||
|
const _pumpNativeDecodes = () => {
|
||||||
|
while (_nativeDecodeActive < 3 && _nativeDecodeQueue.length) {
|
||||||
|
const job = _nativeDecodeQueue.shift();
|
||||||
|
_nativeDecodeActive++;
|
||||||
|
try {
|
||||||
|
job.ctx.decodeAudioData(job.buf, function (ab) {
|
||||||
|
_nativeDecodeActive--;
|
||||||
|
job.cb(ab);
|
||||||
|
_pumpNativeDecodes();
|
||||||
|
}, function () {
|
||||||
|
_nativeDecodeActive--;
|
||||||
|
job.cb(null);
|
||||||
|
_pumpNativeDecodes();
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
_nativeDecodeActive--;
|
||||||
|
job.cb(null);
|
||||||
|
_pumpNativeDecodes();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const _decodeNativeQueue = (ctx, buf, cb) => {
|
||||||
|
_nativeDecodeQueue.push({ ctx: ctx, buf: buf, cb: cb });
|
||||||
|
_pumpNativeDecodes();
|
||||||
|
};
|
||||||
|
const scheduleNativeTrack = (track, offsetTime, context, destNode, bpm, opts) => {
|
||||||
|
try {
|
||||||
|
const eng = track && track.synth_engine;
|
||||||
|
if (!isSfTrackEngine(eng) && !isVstTrackEngine(eng)) return;
|
||||||
|
const baseOffsetSec = (opts && opts.baseOffsetSec) || 0;
|
||||||
|
const notesOverride = (opts && opts.notesOverride) || null;
|
||||||
|
const data = _buildNativeTrackNotes(track, bpm, baseOffsetSec, notesOverride);
|
||||||
|
if (!data.absNotes.length) return;
|
||||||
|
const key = _nativeTrackKey(track, bpm, baseOffsetSec, notesOverride);
|
||||||
|
const epoch = _nativePlayEpoch;
|
||||||
|
const scheduleUrl = function (url) {
|
||||||
|
if (!url) return;
|
||||||
|
fetch(url).then(function (r) { return r.arrayBuffer(); }).then(function (buf) {
|
||||||
|
_decodeNativeQueue(context, buf, function (audioBuf) {
|
||||||
try {
|
try {
|
||||||
|
if (!audioBuf) return;
|
||||||
|
if (epoch !== _nativePlayEpoch) return; // stale play (stop/rebuild)
|
||||||
if (opts && typeof opts.isActive === 'function' && !opts.isActive()) return;
|
if (opts && typeof opts.isActive === 'function' && !opts.isActive()) return;
|
||||||
|
const ph = (opts && typeof opts.playheadNow === 'function') ? opts.playheadNow() : offsetTime;
|
||||||
const src = context.createBufferSource();
|
const src = context.createBufferSource();
|
||||||
src.buffer = audioBuf;
|
src.buffer = audioBuf;
|
||||||
src.connect(destNode);
|
src.connect(destNode);
|
||||||
if (offsetTime < itemStartAbs) {
|
const trackEndAbs = data.trackStartAbs + audioBuf.duration;
|
||||||
src.start(context.currentTime + (itemStartAbs - offsetTime), 0);
|
if (ph < data.trackStartAbs) {
|
||||||
} else if (offsetTime < itemEndAbs) {
|
src.start(context.currentTime + (data.trackStartAbs - ph), 0);
|
||||||
src.start(context.currentTime, Math.min(offsetTime - itemStartAbs, Math.max(0, audioBuf.duration - 0.05)));
|
} else if (ph < trackEndAbs) {
|
||||||
|
src.start(context.currentTime, Math.min(ph - data.trackStartAbs, Math.max(0, audioBuf.duration - 0.05)));
|
||||||
} else {
|
} else {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (opts && opts.limitSec && opts.limitSec < itemStartAbs + audioBuf.duration) {
|
if (opts && opts.limitSec && opts.limitSec < trackEndAbs) {
|
||||||
src.stop(context.currentTime + Math.max(0.02, opts.limitSec - Math.max(offsetTime, itemStartAbs)));
|
src.stop(context.currentTime + Math.max(0.02, opts.limitSec - Math.max(ph, data.trackStartAbs)));
|
||||||
}
|
}
|
||||||
if (opts && opts.sources && opts.sources.push) opts.sources.push(src);
|
if (opts && opts.sources && opts.sources.push) opts.sources.push(src);
|
||||||
} catch (e) { console.warn('[NativeSF] schedule decode error:', e); }
|
} catch (e) { console.warn('[NativeTrack] schedule decode error:', e); }
|
||||||
}, function () {});
|
});
|
||||||
}).catch(function () {});
|
}).catch(function () {});
|
||||||
}).catch(function () {});
|
};
|
||||||
} catch (e) { console.warn('[NativeSF] scheduleNativeSfItem error:', e); }
|
if (key && _nativeTrackUrls[key]) { scheduleUrl(_nativeTrackUrls[key]); return; }
|
||||||
|
ensureRenderNativeTrack(track, bpm, baseOffsetSec, notesOverride).then(function (url) {
|
||||||
|
if (!url) return;
|
||||||
|
scheduleUrl(url);
|
||||||
|
});
|
||||||
|
} catch (e) { console.warn('[NativeTrack] scheduleNativeTrack error:', e); }
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Native VSTi preview (standalone, bridge inactive) ─────────────────────
|
// ── Native VSTi preview (standalone, bridge inactive) ─────────────────────
|
||||||
@@ -344,131 +514,29 @@ const stopNativeVstNote = (key) => {
|
|||||||
const stopAllNativeVstNotes = () => {
|
const stopAllNativeVstNotes = () => {
|
||||||
try { Object.keys(_nativeVstPreviews).forEach(function (k) { stopNativeVstNote(k); }); } catch (e) {}
|
try { Object.keys(_nativeVstPreviews).forEach(function (k) { stopNativeVstNote(k); }); } catch (e) {}
|
||||||
};
|
};
|
||||||
// URL cache cho midi-render: key = fingerprint (item+instrument+preset+notes)
|
// ── Native track pre-render (standalone) ──────────────────────────────────────────────────────────────
|
||||||
// → re-play cùng item dùng lại WAV đã render, không chờ round-trip.
|
// Render CẢ TRACK 1 request khi project load → play lần đầu không chờ render.
|
||||||
const _nativeVstItemUrls = {};
|
let _prerenderNativeTracksTimer = null;
|
||||||
const _nativeVstItemKey = (track, item, bpm) => {
|
const prerenderNativeTracks = (tracks, bpm) => {
|
||||||
try {
|
|
||||||
const eng = track && track.synth_engine;
|
|
||||||
const instrumentId = (eng && eng.plugin_id) || (track && track.instrumentId) || '';
|
|
||||||
const notes = (item && item.notes) || [];
|
|
||||||
const nk = notes.map(function (n) {
|
|
||||||
return (n.pitch || 60) + ':' + (n.start_beat || 0) + ':' + (n.duration_beats || 1) + ':' + (n.velocity != null ? n.velocity : 0.8);
|
|
||||||
}).join(',');
|
|
||||||
return [item && item.id ? item.id : (instrumentId + ':' + nk), instrumentId, (eng && eng.preset_id) || '', (eng && eng.preset_path) || '', bpm].join('|') + '|' + nk;
|
|
||||||
} catch (e) { return ''; }
|
|
||||||
};
|
|
||||||
const _renderNativeVstItem = (track, item, bpm) => {
|
|
||||||
try {
|
|
||||||
const eng = track && track.synth_engine;
|
|
||||||
const instrumentId = (eng && eng.plugin_id) || (track && track.instrumentId);
|
|
||||||
if (!instrumentId || !isVstTrackEngine(eng)) return Promise.resolve(null);
|
|
||||||
const notes = (item && item.notes) || [];
|
|
||||||
if (!notes.length) return Promise.resolve(null);
|
|
||||||
return window.SonicAPI.midiRender({
|
|
||||||
instrument_id: instrumentId,
|
|
||||||
notes: notes.map(function (n) { return { pitch: n.pitch || 60, start_beat: n.start_beat || 0, duration_beats: n.duration_beats || 1, velocity: n.velocity != null ? n.velocity : 0.8 }; }),
|
|
||||||
bpm: parseFloat(bpm) || 120,
|
|
||||||
preset_id: (eng && eng.preset_id) || undefined,
|
|
||||||
preset_path: (eng && eng.preset_path) || undefined,
|
|
||||||
preset_data: (eng && eng.preset_data) || undefined,
|
|
||||||
}).then(function (res) {
|
|
||||||
if (!res || !res.success || !res.url) return null;
|
|
||||||
return API_BASE_URL + res.url;
|
|
||||||
}).catch(function () { return null; });
|
|
||||||
} catch (e) { return Promise.resolve(null); }
|
|
||||||
};
|
|
||||||
// Dedupe in-flight render: play trong lúc pre-render đang chạy → chờ cùng promise,
|
|
||||||
// không bắn duplicate request (tránh queue sau RENDER_LOCK + render 2 lần).
|
|
||||||
const _nativeVstItemPending = {};
|
|
||||||
const ensureRenderNativeVstItem = (track, item, bpm) => {
|
|
||||||
const key = _nativeVstItemKey(track, item, bpm);
|
|
||||||
if (!key) return _renderNativeVstItem(track, item, bpm);
|
|
||||||
if (_nativeVstItemUrls[key]) return Promise.resolve(_nativeVstItemUrls[key]);
|
|
||||||
if (_nativeVstItemPending[key]) return _nativeVstItemPending[key];
|
|
||||||
const p = _renderNativeVstItem(track, item, bpm).then(function (url) {
|
|
||||||
if (url) _nativeVstItemUrls[key] = url;
|
|
||||||
return url;
|
|
||||||
}).then(function (url) {
|
|
||||||
delete _nativeVstItemPending[key];
|
|
||||||
return url;
|
|
||||||
});
|
|
||||||
_nativeVstItemPending[key] = p;
|
|
||||||
return p;
|
|
||||||
};
|
|
||||||
let _prerenderNativeVstTimer = null;
|
|
||||||
const prerenderNativeVstItems = (tracks, bpm) => {
|
|
||||||
try {
|
try {
|
||||||
if (!isStandaloneSf()) return;
|
if (!isStandaloneSf()) return;
|
||||||
if (_prerenderNativeVstTimer) { clearTimeout(_prerenderNativeVstTimer); _prerenderNativeVstTimer = null; }
|
if (_prerenderNativeTracksTimer) { clearTimeout(_prerenderNativeTracksTimer); _prerenderNativeTracksTimer = null; }
|
||||||
_prerenderNativeVstTimer = setTimeout(function () {
|
_prerenderNativeTracksTimer = setTimeout(function () {
|
||||||
_prerenderNativeVstTimer = null;
|
_prerenderNativeTracksTimer = null;
|
||||||
const seen = {};
|
const seen = {};
|
||||||
(tracks || []).forEach(function (track) {
|
(tracks || []).forEach(function (track) {
|
||||||
const nativeVst = isStandaloneSf() && isVstTrackEngine(track && track.synth_engine) && !(window.SonicMidiRouter && window.SonicMidiRouter.isBridgeActive());
|
const eng = track && track.synth_engine;
|
||||||
if (!nativeVst) return;
|
const native = isStandaloneSf() && (isVstTrackEngine(eng) || isSfTrackEngine(eng)) && !(window.SonicMidiRouter && window.SonicMidiRouter.isBridgeActive());
|
||||||
(track.midiItems || []).forEach(function (item) {
|
if (!native) return;
|
||||||
const key = _nativeVstItemKey(track, item, bpm);
|
const key = _nativeTrackKey(track, bpm, 0, null);
|
||||||
if (!key || seen[key] || _nativeVstItemUrls[key]) return;
|
if (!key || seen[key] || _nativeTrackUrls[key]) return;
|
||||||
seen[key] = true;
|
seen[key] = true;
|
||||||
ensureRenderNativeVstItem(track, item, bpm).then(function () {});
|
ensureRenderNativeTrack(track, bpm, 0, null).then(function () {});
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}, 800);
|
}, 800);
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Native VSTi item render (standalone, transport) ────────────────────────
|
|
||||||
// Render TOÀN BỘ MIDI item bằng pedalboard (VST3) → decode AudioBuffer →
|
|
||||||
// schedule giống scheduleNativeSfItem. opts giống hệt nativeSf.
|
|
||||||
const scheduleNativeVstItem = (track, item, offsetTime, context, destNode, bpm, opts) => {
|
|
||||||
try {
|
|
||||||
const eng = track && track.synth_engine;
|
|
||||||
const instrumentId = (eng && eng.plugin_id) || (track && track.instrumentId);
|
|
||||||
if (!instrumentId || !isVstTrackEngine(eng)) return;
|
|
||||||
const notes = (item && item.notes) || [];
|
|
||||||
if (!notes.length) return;
|
|
||||||
const secPerBeat = 60.0 / (parseInt(bpm) || 120);
|
|
||||||
const baseOffsetSec = (opts && opts.baseOffsetSec) || 0;
|
|
||||||
const itemStartAbs = baseOffsetSec + (item.startTime || 0);
|
|
||||||
let itemEndAbs = itemStartAbs + 0.05;
|
|
||||||
notes.forEach(function (n) {
|
|
||||||
const end = itemStartAbs + ((n.start_beat || 0) + (n.duration_beats || 1)) * secPerBeat;
|
|
||||||
if (end > itemEndAbs) itemEndAbs = end;
|
|
||||||
});
|
|
||||||
const key = _nativeVstItemKey(track, item, bpm);
|
|
||||||
const scheduleUrl = function (url) {
|
|
||||||
if (!url) return;
|
|
||||||
fetch(url).then(function (r) { return r.arrayBuffer(); }).then(function (buf) {
|
|
||||||
context.decodeAudioData(buf, function (audioBuf) {
|
|
||||||
try {
|
|
||||||
if (opts && typeof opts.isActive === 'function' && !opts.isActive()) return;
|
|
||||||
const src = context.createBufferSource();
|
|
||||||
src.buffer = audioBuf;
|
|
||||||
src.connect(destNode);
|
|
||||||
if (offsetTime < itemStartAbs) {
|
|
||||||
src.start(context.currentTime + (itemStartAbs - offsetTime), 0);
|
|
||||||
} else if (offsetTime < itemEndAbs) {
|
|
||||||
src.start(context.currentTime, Math.min(offsetTime - itemStartAbs, Math.max(0, audioBuf.duration - 0.05)));
|
|
||||||
} else {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (opts && opts.limitSec && opts.limitSec < itemStartAbs + audioBuf.duration) {
|
|
||||||
src.stop(context.currentTime + Math.max(0.02, opts.limitSec - Math.max(offsetTime, itemStartAbs)));
|
|
||||||
}
|
|
||||||
if (opts && opts.sources && opts.sources.push) opts.sources.push(src);
|
|
||||||
} catch (e) { console.warn('[NativeVST] schedule decode error:', e); }
|
|
||||||
}, function () {});
|
|
||||||
}).catch(function () {});
|
|
||||||
};
|
|
||||||
if (key && _nativeVstItemUrls[key]) { scheduleUrl(_nativeVstItemUrls[key]); return; }
|
|
||||||
ensureRenderNativeVstItem(track, item, bpm).then(function (url) {
|
|
||||||
if (!url) return;
|
|
||||||
scheduleUrl(url);
|
|
||||||
});
|
|
||||||
} catch (e) { console.warn('[NativeVST] scheduleNativeVstItem error:', e); }
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle ?sfs=<encoded> from double-clicking a .sfs file (opens domain -> loads project)
|
// Handle ?sfs=<encoded> from double-clicking a .sfs file (opens domain -> loads project)
|
||||||
(function handleSfsDeepLink() {
|
(function handleSfsDeepLink() {
|
||||||
try {
|
try {
|
||||||
@@ -17589,7 +17657,7 @@ const App = () => {
|
|||||||
loadAudioBuffersForTracks(restoredTracks).catch(function(err) { console.warn('loadAudioBuffersForTracks error:', err); });
|
loadAudioBuffersForTracks(restoredTracks).catch(function(err) { console.warn('loadAudioBuffersForTracks error:', err); });
|
||||||
trackMidiChannelsRef.current = {};
|
trackMidiChannelsRef.current = {};
|
||||||
preloadTrackInstruments(restoredTracks).catch(function(err) { console.warn('preloadTrackInstruments error:', err); });
|
preloadTrackInstruments(restoredTracks).catch(function(err) { console.warn('preloadTrackInstruments error:', err); });
|
||||||
prerenderNativeVstItems(restoredTracks, restoredBpm);
|
prerenderNativeTracks(restoredTracks, restoredBpm);
|
||||||
setBpm(restoredBpm.toString());
|
setBpm(restoredBpm.toString());
|
||||||
setSelectedTrackId(restoredTracks[0]?.id || '1');
|
setSelectedTrackId(restoredTracks[0]?.id || '1');
|
||||||
setProjectName(lastName);
|
setProjectName(lastName);
|
||||||
@@ -17728,7 +17796,7 @@ const App = () => {
|
|||||||
if (restoredTracks.length > 0) {
|
if (restoredTracks.length > 0) {
|
||||||
setTracks(restoredTracks);
|
setTracks(restoredTracks);
|
||||||
setBpm(restoredBpm.toString());
|
setBpm(restoredBpm.toString());
|
||||||
prerenderNativeVstItems(restoredTracks, restoredBpm);
|
prerenderNativeTracks(restoredTracks, restoredBpm);
|
||||||
if (restoredSessionTabs.length > 0) {
|
if (restoredSessionTabs.length > 0) {
|
||||||
setSessionTabs(restoredSessionTabs);
|
setSessionTabs(restoredSessionTabs);
|
||||||
}
|
}
|
||||||
@@ -22281,10 +22349,8 @@ const App = () => {
|
|||||||
const nativeSf = isStandaloneSf() && !routeCarla && isSfTrackEngine(track.synth_engine) && !(window.SonicMidiRouter && window.SonicMidiRouter.isBridgeActive());
|
const nativeSf = isStandaloneSf() && !routeCarla && isSfTrackEngine(track.synth_engine) && !(window.SonicMidiRouter && window.SonicMidiRouter.isBridgeActive());
|
||||||
const nativeVst = isStandaloneSf() && !routeCarla && isVstTrackEngine(track.synth_engine) && !(window.SonicMidiRouter && window.SonicMidiRouter.isBridgeActive());
|
const nativeVst = isStandaloneSf() && !routeCarla && isVstTrackEngine(track.synth_engine) && !(window.SonicMidiRouter && window.SonicMidiRouter.isBridgeActive());
|
||||||
if ((track.type === 'MIDI' || midiItems.length > 0) && (window.SonicSF || routeCarla || nativeSf || nativeVst)) {
|
if ((track.type === 'MIDI' || midiItems.length > 0) && (window.SonicSF || routeCarla || nativeSf || nativeVst)) {
|
||||||
if (nativeSf) {
|
if (nativeSf || nativeVst) {
|
||||||
midiItems.forEach(item => scheduleNativeSfItem(track, item, offsetTime, context, gainNode, bpm, { sources: activeSourcesRef.current, isActive: () => !!isPlayingRef.current }));
|
scheduleNativeTrack(track, offsetTime, context, gainNode, bpm, { playheadNow: () => startOffsetTimeRef.current + (getAudioContext().currentTime - startAudioTimeRef.current), sources: activeSourcesRef.current, isActive: () => !!isPlayingRef.current });
|
||||||
} else if (nativeVst) {
|
|
||||||
midiItems.forEach(item => scheduleNativeVstItem(track, item, offsetTime, context, gainNode, bpm, { sources: activeSourcesRef.current, isActive: () => !!isPlayingRef.current }));
|
|
||||||
} else {
|
} else {
|
||||||
// Preview cache: capture the soundfont (pre track-FX) for fast offline export
|
// Preview cache: capture the soundfont (pre track-FX) for fast offline export
|
||||||
ensureMidiCapture(track, activeTrackNodesRef.current[track.id]);
|
ensureMidiCapture(track, activeTrackNodesRef.current[track.id]);
|
||||||
@@ -22447,12 +22513,8 @@ const App = () => {
|
|||||||
const subNativeSf = isStandaloneSf() && !subRouteCarla && isSfTrackEngine(subTrack.synth_engine) && !(window.SonicMidiRouter && window.SonicMidiRouter.isBridgeActive());
|
const subNativeSf = isStandaloneSf() && !subRouteCarla && isSfTrackEngine(subTrack.synth_engine) && !(window.SonicMidiRouter && window.SonicMidiRouter.isBridgeActive());
|
||||||
const subNativeVst = isStandaloneSf() && !subRouteCarla && isVstTrackEngine(subTrack.synth_engine) && !(window.SonicMidiRouter && window.SonicMidiRouter.isBridgeActive());
|
const subNativeVst = isStandaloneSf() && !subRouteCarla && isVstTrackEngine(subTrack.synth_engine) && !(window.SonicMidiRouter && window.SonicMidiRouter.isBridgeActive());
|
||||||
if (subMidiItems.length > 0 && (window.SonicSF || subRouteCarla || subNativeSf || subNativeVst)) {
|
if (subMidiItems.length > 0 && (window.SonicSF || subRouteCarla || subNativeSf || subNativeVst)) {
|
||||||
if (subNativeSf) {
|
if (subNativeSf || subNativeVst) {
|
||||||
subMidiItems.forEach(item => scheduleNativeSfItem(subTrack, item, offsetTime, context, subNode, bpm, { baseOffsetSec: secStart, limitSec: secEnd, sources: activeSourcesRef.current, isActive: () => !!isPlayingRef.current }));
|
scheduleNativeTrack(subTrack, offsetTime, context, subNode, bpm, { baseOffsetSec: secStart, limitSec: secEnd, playheadNow: () => startOffsetTimeRef.current + (getAudioContext().currentTime - startAudioTimeRef.current), sources: activeSourcesRef.current, isActive: () => !!isPlayingRef.current });
|
||||||
return; // native render → audio buffer (không qua WASM/Carla per-note)
|
|
||||||
}
|
|
||||||
if (subNativeVst) {
|
|
||||||
subMidiItems.forEach(item => scheduleNativeVstItem(subTrack, item, offsetTime, context, subNode, bpm, { baseOffsetSec: secStart, limitSec: secEnd, sources: activeSourcesRef.current, isActive: () => !!isPlayingRef.current }));
|
|
||||||
return; // native render → audio buffer (không qua WASM/Carla per-note)
|
return; // native render → audio buffer (không qua WASM/Carla per-note)
|
||||||
}
|
}
|
||||||
subMidiItems.forEach(item => {
|
subMidiItems.forEach(item => {
|
||||||
@@ -22585,12 +22647,8 @@ const App = () => {
|
|||||||
const nativeSf = isStandaloneSf() && !routeCarla && isSfTrackEngine(track.synth_engine) && !(window.SonicMidiRouter && window.SonicMidiRouter.isBridgeActive());
|
const nativeSf = isStandaloneSf() && !routeCarla && isSfTrackEngine(track.synth_engine) && !(window.SonicMidiRouter && window.SonicMidiRouter.isBridgeActive());
|
||||||
const nativeVst = isStandaloneSf() && !routeCarla && isVstTrackEngine(track.synth_engine) && !(window.SonicMidiRouter && window.SonicMidiRouter.isBridgeActive());
|
const nativeVst = isStandaloneSf() && !routeCarla && isVstTrackEngine(track.synth_engine) && !(window.SonicMidiRouter && window.SonicMidiRouter.isBridgeActive());
|
||||||
if ((track.type === 'MIDI' || midiItems.length > 0) && (window.SonicSF || routeCarla || nativeSf || nativeVst)) {
|
if ((track.type === 'MIDI' || midiItems.length > 0) && (window.SonicSF || routeCarla || nativeSf || nativeVst)) {
|
||||||
if (nativeSf) {
|
if (nativeSf || nativeVst) {
|
||||||
midiItems.forEach(item => scheduleNativeSfItem(track, item, offsetTime, context, gainNode, bpm, { sources: activeSourcesRef.current, isActive: () => !!isPlayingRef.current }));
|
scheduleNativeTrack(track, offsetTime, context, gainNode, bpm, { playheadNow: () => startOffsetTimeRef.current + (getAudioContext().currentTime - startAudioTimeRef.current), sources: activeSourcesRef.current, isActive: () => !!isPlayingRef.current });
|
||||||
return; // native render → audio buffer (không qua WASM/Carla per-note)
|
|
||||||
}
|
|
||||||
if (nativeVst) {
|
|
||||||
midiItems.forEach(item => scheduleNativeVstItem(track, item, offsetTime, context, gainNode, bpm, { sources: activeSourcesRef.current, isActive: () => !!isPlayingRef.current }));
|
|
||||||
return; // native render → audio buffer (không qua WASM/Carla per-note)
|
return; // native render → audio buffer (không qua WASM/Carla per-note)
|
||||||
}
|
}
|
||||||
var lcCh = assignTrackMidiChannel(track, tracks);
|
var lcCh = assignTrackMidiChannel(track, tracks);
|
||||||
@@ -22686,12 +22744,8 @@ const App = () => {
|
|||||||
// future (silence when pressing play). Ghost notes are already relative to
|
// future (silence when pressing play). Ghost notes are already relative to
|
||||||
// the item window, so no absolute-session offset is applied anywhere here.
|
// the item window, so no absolute-session offset is applied anywhere here.
|
||||||
const routeCarla = shouldRouteCarla(synthEngine);
|
const routeCarla = shouldRouteCarla(synthEngine);
|
||||||
if (isStandaloneSf() && !routeCarla && isSfTrackEngine(synthEngine) && !(window.SonicMidiRouter && window.SonicMidiRouter.isBridgeActive())) {
|
if (isStandaloneSf() && !routeCarla && (isSfTrackEngine(synthEngine) || isVstTrackEngine(synthEngine)) && !(window.SonicMidiRouter && window.SonicMidiRouter.isBridgeActive())) {
|
||||||
scheduleNativeSfItem(track, { startTime: 0, notes: midiNotes }, offsetSeconds, context, destNode, bpm, { sources: activeSourcesRef.current, isActive: () => !!isPlayingRef.current });
|
scheduleNativeTrack(track, offsetSeconds, context, destNode, bpm, { notesOverride: midiNotes, playheadNow: () => startOffsetTimeRef.current + (getAudioContext().currentTime - startAudioTimeRef.current), sources: activeSourcesRef.current, isActive: () => !!isPlayingRef.current });
|
||||||
return; // native render cả tab → audio buffer (không per-note WASM/Carla)
|
|
||||||
}
|
|
||||||
if (isStandaloneSf() && !routeCarla && isVstTrackEngine(synthEngine) && !(window.SonicMidiRouter && window.SonicMidiRouter.isBridgeActive())) {
|
|
||||||
scheduleNativeVstItem(track, { startTime: 0, notes: midiNotes }, offsetSeconds, context, destNode, bpm, { sources: activeSourcesRef.current, isActive: () => !!isPlayingRef.current });
|
|
||||||
return; // native render cả tab → audio buffer (không per-note WASM/Carla)
|
return; // native render cả tab → audio buffer (không per-note WASM/Carla)
|
||||||
}
|
}
|
||||||
midiNotes.forEach(note => {
|
midiNotes.forEach(note => {
|
||||||
@@ -22857,6 +22911,7 @@ const App = () => {
|
|||||||
const stopAllPlayback = () => {
|
const stopAllPlayback = () => {
|
||||||
try {
|
try {
|
||||||
isPlayingRef.current = false;
|
isPlayingRef.current = false;
|
||||||
|
_nativePlayEpoch++; // hủy mọi render/decode pending của lần play cũ
|
||||||
// V11: hủy mọi note timer của lần play cũ (stop/seek/loop restart) — timer
|
// V11: hủy mọi note timer của lần play cũ (stop/seek/loop restart) — timer
|
||||||
// cũ bắn vào vòng mới = NOTE_OFF cắt note đang kêu → crack (xem ref).
|
// cũ bắn vào vòng mới = NOTE_OFF cắt note đang kêu → crack (xem ref).
|
||||||
bridgeNoteEpochRef.current++;
|
bridgeNoteEpochRef.current++;
|
||||||
@@ -30124,6 +30179,13 @@ STRICT CONSTRAINTS:
|
|||||||
step: "0.5",
|
step: "0.5",
|
||||||
value: track.volumeDb ?? 0,
|
value: track.volumeDb ?? 0,
|
||||||
onChange: e => updateTrackVolumeDb(track.id, parseFloat(e.target.value)),
|
onChange: e => updateTrackVolumeDb(track.id, parseFloat(e.target.value)),
|
||||||
|
onWheel: e => {
|
||||||
|
e.preventDefault();
|
||||||
|
const step = e.deltaY > 0 ? -0.5 : 0.5;
|
||||||
|
const nv = Math.max(-50, Math.min(7, (track.volumeDb ?? 0) + step));
|
||||||
|
updateTrackVolumeDb(track.id, nv);
|
||||||
|
},
|
||||||
|
onDoubleClick: e => { e.stopPropagation(); updateTrackVolumeDb(track.id, 0); },
|
||||||
className: "flex-1 h-1 bg-zinc-700 rounded-lg appearance-none cursor-pointer accent-cyan-500",
|
className: "flex-1 h-1 bg-zinc-700 rounded-lg appearance-none cursor-pointer accent-cyan-500",
|
||||||
style: {
|
style: {
|
||||||
height: '4px'
|
height: '4px'
|
||||||
@@ -30141,6 +30203,13 @@ STRICT CONSTRAINTS:
|
|||||||
step: "1",
|
step: "1",
|
||||||
value: track.pan ?? 0,
|
value: track.pan ?? 0,
|
||||||
onChange: e => updateTrackPan(track.id, parseInt(e.target.value)),
|
onChange: e => updateTrackPan(track.id, parseInt(e.target.value)),
|
||||||
|
onWheel: e => {
|
||||||
|
e.preventDefault();
|
||||||
|
const step = e.deltaY > 0 ? -5 : 5;
|
||||||
|
const np = Math.max(-100, Math.min(100, (track.pan ?? 0) + step));
|
||||||
|
updateTrackPan(track.id, np);
|
||||||
|
},
|
||||||
|
onDoubleClick: e => { e.stopPropagation(); updateTrackPan(track.id, 0); },
|
||||||
className: "flex-1 h-1 bg-zinc-700 rounded-lg appearance-none cursor-pointer accent-purple-500",
|
className: "flex-1 h-1 bg-zinc-700 rounded-lg appearance-none cursor-pointer accent-purple-500",
|
||||||
style: {
|
style: {
|
||||||
height: '4px'
|
height: '4px'
|
||||||
@@ -30665,6 +30734,13 @@ STRICT CONSTRAINTS:
|
|||||||
id: `tcp-vol-${st.id}`,
|
id: `tcp-vol-${st.id}`,
|
||||||
value: vTrack.volumeDb ?? 0,
|
value: vTrack.volumeDb ?? 0,
|
||||||
onChange: e => updateTrackVolumeDb(vTrack.id, parseFloat(e.target.value)),
|
onChange: e => updateTrackVolumeDb(vTrack.id, parseFloat(e.target.value)),
|
||||||
|
onWheel: e => {
|
||||||
|
e.preventDefault();
|
||||||
|
const step = e.deltaY > 0 ? -0.5 : 0.5;
|
||||||
|
const nv = Math.max(-50, Math.min(7, (vTrack.volumeDb ?? 0) + step));
|
||||||
|
updateTrackVolumeDb(vTrack.id, nv);
|
||||||
|
},
|
||||||
|
onDoubleClick: e => { e.stopPropagation(); updateTrackVolumeDb(vTrack.id, 0); },
|
||||||
className: "flex-1 h-1 bg-zinc-700 rounded-lg appearance-none cursor-pointer accent-cyan-500",
|
className: "flex-1 h-1 bg-zinc-700 rounded-lg appearance-none cursor-pointer accent-cyan-500",
|
||||||
style: {
|
style: {
|
||||||
height: '4px'
|
height: '4px'
|
||||||
@@ -30684,6 +30760,13 @@ STRICT CONSTRAINTS:
|
|||||||
id: `tcp-pan-${st.id}`,
|
id: `tcp-pan-${st.id}`,
|
||||||
value: vTrack.pan ?? 0,
|
value: vTrack.pan ?? 0,
|
||||||
onChange: e => updateTrackPan(vTrack.id, parseInt(e.target.value)),
|
onChange: e => updateTrackPan(vTrack.id, parseInt(e.target.value)),
|
||||||
|
onWheel: e => {
|
||||||
|
e.preventDefault();
|
||||||
|
const step = e.deltaY > 0 ? -5 : 5;
|
||||||
|
const np = Math.max(-100, Math.min(100, (vTrack.pan ?? 0) + step));
|
||||||
|
updateTrackPan(vTrack.id, np);
|
||||||
|
},
|
||||||
|
onDoubleClick: e => { e.stopPropagation(); updateTrackPan(vTrack.id, 0); },
|
||||||
className: "flex-1 h-1 bg-zinc-700 rounded-lg appearance-none cursor-pointer accent-purple-500",
|
className: "flex-1 h-1 bg-zinc-700 rounded-lg appearance-none cursor-pointer accent-purple-500",
|
||||||
style: {
|
style: {
|
||||||
height: '4px'
|
height: '4px'
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -50,7 +50,7 @@
|
|||||||
<script src="/static/js/services/midiExtractor.js?v=202607281052"></script>
|
<script src="/static/js/services/midiExtractor.js?v=202607281052"></script>
|
||||||
<script src="/static/js/services/promptTemplateManager.js?v=202607281039"></script>
|
<script src="/static/js/services/promptTemplateManager.js?v=202607281039"></script>
|
||||||
<script src="/static/js/services/undoRedoEngine.js?v=202607290941"></script>
|
<script src="/static/js/services/undoRedoEngine.js?v=202607290941"></script>
|
||||||
<script src="/static/js/app.precompiled.js?v=202608152100" defer></script>
|
<script src="/static/js/app.precompiled.js?v=20260817185530" defer></script>
|
||||||
<link rel="stylesheet" href="/static/css/styles.css?v=202607271016">
|
<link rel="stylesheet" href="/static/css/styles.css?v=202607271016">
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
|
|||||||
Reference in New Issue
Block a user