Compare commits
16 Commits
4d10b9485b
...
7325fbfc45
| Author | SHA1 | Date | |
|---|---|---|---|
| 7325fbfc45 | |||
| 94b2d2ef41 | |||
| f616edce49 | |||
| 6018263044 | |||
| 36edc9daca | |||
| 71278f2aba | |||
| 022ba38a5e | |||
| 71c3bafdb5 | |||
| 34ad02dd6b | |||
| 47b1633bd3 | |||
| 0182abf7ea | |||
| 9b6de7f857 | |||
| 84ab4ae823 | |||
| 25471e6ea5 | |||
| 0dc95386f2 | |||
| d8227904b6 |
+256
-116
@@ -12,6 +12,37 @@ const API_AUDIO = `${API_BASE_URL}/api/v1/audio`;
|
||||
const API_MULTITRACK = `${API_BASE_URL}/api/v1/multitrack`;
|
||||
const API_TASKS = `${API_BASE_URL}/api/v1/audio/tasks`;
|
||||
|
||||
// ── Dedicated per-track MIDI channel allocation ──
|
||||
// FluidSynth has 16 channels; if two tracks share a channel, arming one track
|
||||
// re-selects the other track's program and its instrument changes. Every track
|
||||
// gets its own stable, unique channel (0-15, skipping 9 which is the classic
|
||||
// percussion slot) so multi-track ARM / playback never cross-contaminates
|
||||
// instruments. Module-level so both App and the piano-roll sub-components
|
||||
// (PianoRollTabEditor etc.) allocate the SAME channel for a track.
|
||||
const trackMidiChannelsRef = { current: {} };
|
||||
const ensureTrackMidiChannel = (track, tracks) => {
|
||||
if (!track) return 0;
|
||||
const trackList = tracks || [];
|
||||
const inUse = new Set();
|
||||
trackList.forEach(tr => { if (tr && tr.id !== track.id && tr.midiChannel !== undefined) inUse.add(tr.midiChannel); });
|
||||
const cached = trackMidiChannelsRef.current[track.id];
|
||||
if (cached !== undefined && !inUse.has(cached)) return cached;
|
||||
const preferred = track.midiChannel !== undefined && !inUse.has(track.midiChannel) ? track.midiChannel : null;
|
||||
if (preferred !== null) { trackMidiChannelsRef.current[track.id] = preferred; return preferred; }
|
||||
for (let c = 0; c < 16; c++) {
|
||||
if (c === 9) continue;
|
||||
if (!inUse.has(c)) { trackMidiChannelsRef.current[track.id] = c; return c; }
|
||||
}
|
||||
trackMidiChannelsRef.current[track.id] = 0;
|
||||
return 0;
|
||||
};
|
||||
const assignTrackMidiChannel = (track, tracks) => {
|
||||
const ch = ensureTrackMidiChannel(track, tracks);
|
||||
if (track && track.midiChannel !== ch) track.midiChannel = ch;
|
||||
return ch;
|
||||
};
|
||||
|
||||
|
||||
// Handle ?sfs=<encoded> from double-clicking a .sfs file (opens domain -> loads project)
|
||||
(function handleSfsDeepLink() {
|
||||
try {
|
||||
@@ -44,86 +75,117 @@ function makeDistortionCurve(k) {
|
||||
}
|
||||
|
||||
function applyMasteringSettings(s) {
|
||||
if (!masterBus || !audioCtx) return;
|
||||
if (!masterBus || !audioCtx || !s) return;
|
||||
// getAudioContext() invokes this on EVERY call (stopAll, play, VU, …).
|
||||
// Re-applying identical values in rapid bursts is "fast parameter automation"
|
||||
// and makes Chromium flag the biquad EQ filters as unstable ("state is bad").
|
||||
// Only touch the graph when a value actually changed.
|
||||
const sig = [s.eqActive, s.eqLowGain, s.eqMid1Gain, s.eqMid2Gain, s.eqHighGain, s.imagerActive, s.w1, s.w2, s.w3, s.w4, s.maximizerActive, s.maxGain, s.maxSoftClip, s.maxUpward, s.ceiling].join('|');
|
||||
if (sig === _lastMasteringSig) return;
|
||||
_lastMasteringSig = sig;
|
||||
|
||||
const now = audioCtx.currentTime;
|
||||
|
||||
// Clamp every parameter so a stale/incomplete settings object can never push
|
||||
// NaN or an extreme value into the biquad filters — that puts the master
|
||||
// chain into a bad state and silences ALL audio (the "mất soundfont" symptom).
|
||||
const clamp = (v, lo, hi) => {
|
||||
const n = Number(v);
|
||||
if (!isFinite(n)) return 0; // missing/NaN → neutral, never a filter-breaking value
|
||||
return Math.min(hi, Math.max(lo, n));
|
||||
};
|
||||
|
||||
// 1. EQ Settings
|
||||
masterBus.eqLowFilter.gain.setTargetAtTime(s.eqActive ? s.eqLowGain : 0, now, 0.01);
|
||||
masterBus.eqMid1Filter.gain.setTargetAtTime(s.eqActive ? s.eqMid1Gain : 0, now, 0.01);
|
||||
masterBus.eqMid2Filter.gain.setTargetAtTime(s.eqActive ? s.eqMid2Gain : 0, now, 0.01);
|
||||
masterBus.eqHighFilter.gain.setTargetAtTime(s.eqActive ? s.eqHighGain : 0, now, 0.01);
|
||||
|
||||
// cancelScheduledValues + a slower time constant keeps rapid slider drags
|
||||
// from piling up automation events on the biquad filters (the trigger for
|
||||
// Chromium's "BiquadFilterNode: state is bad").
|
||||
masterBus.eqLowFilter.gain.cancelScheduledValues(now);
|
||||
masterBus.eqLowFilter.gain.setTargetAtTime(s.eqActive ? clamp(s.eqLowGain, -24, 24) : 0, now, 0.05);
|
||||
masterBus.eqMid1Filter.gain.cancelScheduledValues(now);
|
||||
masterBus.eqMid1Filter.gain.setTargetAtTime(s.eqActive ? clamp(s.eqMid1Gain, -24, 24) : 0, now, 0.05);
|
||||
masterBus.eqMid2Filter.gain.cancelScheduledValues(now);
|
||||
masterBus.eqMid2Filter.gain.setTargetAtTime(s.eqActive ? clamp(s.eqMid2Gain, -24, 24) : 0, now, 0.05);
|
||||
masterBus.eqHighFilter.gain.cancelScheduledValues(now);
|
||||
masterBus.eqHighFilter.gain.setTargetAtTime(s.eqActive ? clamp(s.eqHighGain, -24, 24) : 0, now, 0.05);
|
||||
|
||||
// 2. Imager Settings (Mid/Side matrix width control for each band)
|
||||
const updateImagerBand = (w, active, gainLL, gainRL, gainLR, gainRR) => {
|
||||
const widthVal = (s.imagerActive && active) ? w : 0;
|
||||
const widthVal = (s.imagerActive && active) ? clamp(w, -100, 100) : 0;
|
||||
const g1 = 1 + widthVal / 200;
|
||||
const g2 = -widthVal / 200;
|
||||
|
||||
|
||||
gainLL.gain.setTargetAtTime(g1, now, 0.01);
|
||||
gainRR.gain.setTargetAtTime(g1, now, 0.01);
|
||||
gainRL.gain.setTargetAtTime(g2, now, 0.01);
|
||||
gainLR.gain.setTargetAtTime(g2, now, 0.01);
|
||||
};
|
||||
|
||||
|
||||
updateImagerBand(s.w1, true, masterBus.gainLL1, masterBus.gainRL1, masterBus.gainLR1, masterBus.gainRR1);
|
||||
updateImagerBand(s.w2, true, masterBus.gainLL2, masterBus.gainRL2, masterBus.gainLR2, masterBus.gainRR2);
|
||||
updateImagerBand(s.w3, true, masterBus.gainLL3, masterBus.gainRL3, masterBus.gainLR3, masterBus.gainRR3);
|
||||
updateImagerBand(s.w4, true, masterBus.gainLL4, masterBus.gainRL4, masterBus.gainLR4, masterBus.gainRR4);
|
||||
|
||||
|
||||
// 3. Maximizer Settings
|
||||
const boostLinear = (s.maximizerActive) ? Math.pow(10, s.maxGain / 20) : 1.0;
|
||||
const boostLinear = (s.maximizerActive) ? Math.pow(10, clamp(s.maxGain, -60, 30) / 20) : 1.0;
|
||||
masterBus.maximizerBoostGain.gain.setTargetAtTime(boostLinear, now, 0.01);
|
||||
|
||||
// Soft Clipper
|
||||
|
||||
// Soft Clipper (identity passthrough when off — never null curve)
|
||||
if (s.maximizerActive && s.maxSoftClip > 0) {
|
||||
const k = 1 + (s.maxSoftClip / 100) * 10;
|
||||
const k = 1 + (clamp(s.maxSoftClip, 0, 100) / 100) * 10;
|
||||
masterBus.maximizerSoftClipper.curve = makeDistortionCurve(k);
|
||||
} else {
|
||||
masterBus.maximizerSoftClipper.curve = null;
|
||||
masterBus.maximizerSoftClipper.curve = new Float32Array([-1, 1]);
|
||||
}
|
||||
|
||||
|
||||
// Upward Compressor
|
||||
const upwardGainLinear = (s.maximizerActive && s.maxUpward > 0) ? (Math.pow(10, s.maxUpward / 20) - 1.0) : 0.0;
|
||||
const upwardGainLinear = (s.maximizerActive && s.maxUpward > 0) ? (Math.pow(10, clamp(s.maxUpward, 0, 30) / 20) - 1.0) : 0.0;
|
||||
masterBus.upwardGain.gain.setTargetAtTime(upwardGainLinear, now, 0.01);
|
||||
|
||||
|
||||
// Limiter Threshold
|
||||
const ceilingVal = s.maximizerActive ? s.ceiling : -0.1;
|
||||
const ceilingVal = s.maximizerActive ? clamp(s.ceiling, -60, 0) : -0.1;
|
||||
masterBus.maximizerCompressor.threshold.setTargetAtTime(ceilingVal, now, 0.01);
|
||||
}
|
||||
|
||||
function initMasterBus(ctx) {
|
||||
if (masterBus) return masterBus;
|
||||
|
||||
|
||||
// Clamp every filter frequency below Nyquist (0.45 * sampleRate). A biquad
|
||||
// with frequency ≥ Nyquist gets NaN coefficients → "BiquadFilterNode: state
|
||||
// is bad" → the whole mastering chain outputs silence (IN peak yes, OUT no).
|
||||
// Low sample-rate devices (8/11/16 kHz audio drivers) would otherwise break
|
||||
// the 10 kHz highshelf / 6 kHz imager crossover filters.
|
||||
const maxFilterFreq = (ctx.sampleRate || 44100) * 0.45;
|
||||
const clampF = v => Math.max(20, Math.min(v, maxFilterFreq));
|
||||
|
||||
// Create EQ filters
|
||||
const eqLowFilter = ctx.createBiquadFilter();
|
||||
eqLowFilter.type = 'lowshelf';
|
||||
eqLowFilter.frequency.value = 100;
|
||||
eqLowFilter.frequency.value = clampF(100);
|
||||
|
||||
const eqMid1Filter = ctx.createBiquadFilter();
|
||||
eqMid1Filter.type = 'peaking';
|
||||
eqMid1Filter.frequency.value = 822;
|
||||
eqMid1Filter.frequency.value = clampF(822);
|
||||
eqMid1Filter.Q.value = 0.7;
|
||||
|
||||
const eqMid2Filter = ctx.createBiquadFilter();
|
||||
eqMid2Filter.type = 'peaking';
|
||||
eqMid2Filter.frequency.value = 3200;
|
||||
eqMid2Filter.frequency.value = clampF(3200);
|
||||
eqMid2Filter.Q.value = 1.2;
|
||||
|
||||
const eqHighFilter = ctx.createBiquadFilter();
|
||||
eqHighFilter.type = 'highshelf';
|
||||
eqHighFilter.frequency.value = 10000;
|
||||
eqHighFilter.frequency.value = clampF(10000);
|
||||
|
||||
// Create Stereo Imager nodes
|
||||
const imagerInput = ctx.createGain();
|
||||
const imagerOutput = ctx.createGain();
|
||||
|
||||
// Imager Crossover Filters
|
||||
const f1_lp = ctx.createBiquadFilter(); f1_lp.type = 'lowpass'; f1_lp.frequency.value = 100;
|
||||
const f2_hp = ctx.createBiquadFilter(); f2_hp.type = 'highpass'; f2_hp.frequency.value = 100;
|
||||
const f2_lp = ctx.createBiquadFilter(); f2_lp.type = 'lowpass'; f2_lp.frequency.value = 1000;
|
||||
const f3_hp = ctx.createBiquadFilter(); f3_hp.type = 'highpass'; f3_hp.frequency.value = 1000;
|
||||
const f3_lp = ctx.createBiquadFilter(); f3_lp.type = 'lowpass'; f3_lp.frequency.value = 6000;
|
||||
const f4_hp = ctx.createBiquadFilter(); f4_hp.type = 'highpass'; f4_hp.frequency.value = 6000;
|
||||
const f1_lp = ctx.createBiquadFilter(); f1_lp.type = 'lowpass'; f1_lp.frequency.value = clampF(100);
|
||||
const f2_hp = ctx.createBiquadFilter(); f2_hp.type = 'highpass'; f2_hp.frequency.value = clampF(100);
|
||||
const f2_lp = ctx.createBiquadFilter(); f2_lp.type = 'lowpass'; f2_lp.frequency.value = clampF(1000);
|
||||
const f3_hp = ctx.createBiquadFilter(); f3_hp.type = 'highpass'; f3_hp.frequency.value = clampF(1000);
|
||||
const f3_lp = ctx.createBiquadFilter(); f3_lp.type = 'lowpass'; f3_lp.frequency.value = clampF(6000);
|
||||
const f4_hp = ctx.createBiquadFilter(); f4_hp.type = 'highpass'; f4_hp.frequency.value = clampF(6000);
|
||||
|
||||
const split1 = ctx.createChannelSplitter(2);
|
||||
const split2 = ctx.createChannelSplitter(2);
|
||||
@@ -181,7 +243,10 @@ function initMasterBus(ctx) {
|
||||
// Maximizer nodes
|
||||
const maximizerBoostGain = ctx.createGain();
|
||||
const maximizerSoftClipper = ctx.createWaveShaper();
|
||||
maximizerSoftClipper.curve = null;
|
||||
// NEVER leave the curve null: a WaveShaper with a null/identity curve can
|
||||
// output silence in some engines, which would kill the whole mastering path.
|
||||
// Use an explicit linear identity table for passthrough.
|
||||
maximizerSoftClipper.curve = new Float32Array([-1, 1]);
|
||||
maximizerSoftClipper.oversample = '4x';
|
||||
|
||||
const upwardCompressor = ctx.createDynamicsCompressor();
|
||||
@@ -282,6 +347,16 @@ function initMasterBus(ctx) {
|
||||
masterBus.analyser.connect(ctx.destination);
|
||||
|
||||
window.masterBus = masterBus;
|
||||
// Apply mastering once when the chain is first created (and on the
|
||||
// masteringSettings effect for subsequent changes — see useEffect).
|
||||
if (window.currentMasteringSettings) {
|
||||
try {
|
||||
toggleMasteringOnMaster(window.currentMasteringSettings.masterConnected, window.currentMasteringSettings.isBypassed);
|
||||
applyMasteringSettings(window.currentMasteringSettings);
|
||||
} catch (e) {
|
||||
console.warn('initMasterBus apply mastering error:', e);
|
||||
}
|
||||
}
|
||||
return masterBus;
|
||||
}
|
||||
|
||||
@@ -289,22 +364,39 @@ function setMasterVolume(linear) {
|
||||
if (masterBus) masterBus.output.gain.setValueAtTime(linear, audioCtx.currentTime);
|
||||
}
|
||||
|
||||
let _lastMasteringActive = null;
|
||||
let _lastMasteringSig = null;
|
||||
function toggleMasteringOnMaster(activate, isBypassed) {
|
||||
if (!masterBus) return;
|
||||
|
||||
// Disconnect the dynamic junction
|
||||
masterBus.inputAnalyser.disconnect();
|
||||
masterBus.maximizerCompressor.disconnect();
|
||||
|
||||
if (activate && !isBypassed) {
|
||||
// Active routing: inputAnalyser -> EQ -> Imager -> Maximizer -> outputAnalyser
|
||||
masterBus.inputAnalyser.connect(masterBus.eqLowFilter);
|
||||
masterBus.maximizerCompressor.connect(masterBus.outputAnalyser);
|
||||
masterBus.masteringActive = true;
|
||||
} else {
|
||||
// Bypassed routing: inputAnalyser -> outputAnalyser
|
||||
masterBus.inputAnalyser.connect(masterBus.outputAnalyser);
|
||||
masterBus.masteringActive = false;
|
||||
|
||||
const active = !!(activate && !isBypassed);
|
||||
// Idempotent: don't disconnect/reconnect the mastering chain on every call.
|
||||
if (_lastMasteringActive === active && masterBus.masteringActive === active) return;
|
||||
_lastMasteringActive = active;
|
||||
|
||||
// Disconnect + immediately reconnect in one synchronous block so the master
|
||||
// routing can NEVER be left broken (a mid-swap exception would otherwise
|
||||
// disconnect inputAnalyser and silence ALL audio globally).
|
||||
try {
|
||||
masterBus.inputAnalyser.disconnect();
|
||||
masterBus.maximizerCompressor.disconnect();
|
||||
if (active) {
|
||||
masterBus.inputAnalyser.connect(masterBus.eqLowFilter);
|
||||
masterBus.maximizerCompressor.connect(masterBus.outputAnalyser);
|
||||
masterBus.masteringActive = true;
|
||||
} else {
|
||||
masterBus.inputAnalyser.connect(masterBus.outputAnalyser);
|
||||
masterBus.masteringActive = false;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('toggleMasteringOnMaster error:', e);
|
||||
// Restore a guaranteed-valid default routing regardless of the failure.
|
||||
try {
|
||||
masterBus.inputAnalyser.disconnect();
|
||||
masterBus.maximizerCompressor.disconnect();
|
||||
masterBus.inputAnalyser.connect(masterBus.outputAnalyser);
|
||||
masterBus.masteringActive = false;
|
||||
} catch (e2) {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -321,10 +413,12 @@ function getAudioContext() {
|
||||
if (!masterBus) {
|
||||
initMasterBus(audioCtx);
|
||||
}
|
||||
if (window.currentMasteringSettings) {
|
||||
toggleMasteringOnMaster(window.currentMasteringSettings.masterConnected, window.currentMasteringSettings.isBypassed);
|
||||
applyMasteringSettings(window.currentMasteringSettings);
|
||||
}
|
||||
// The mastering chain is ONLY managed by the masteringSettings effect — NOT
|
||||
// here. getAudioContext runs constantly (play, stopAll, VU, double-click…);
|
||||
// re-toggling/re-automating the biquad EQ here in bursts is "fast parameter
|
||||
// automation" that makes Chromium flag the filters as unstable
|
||||
// ("BiquadFilterNode: state is bad") and can leave the master routing broken
|
||||
// → global silence. The React effect applies it once per settings change.
|
||||
if (window.SonicSF && window.SonicSF.init) {
|
||||
window.SonicSF.init(audioCtx);
|
||||
}
|
||||
@@ -4483,6 +4577,27 @@ const PluginManagerModal = ({ isOpen, onClose, pluginsData }) => {
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
// Module-level so both ProfileModal (open project) and App (auto-restore) can
|
||||
// warm the FluidSynth font cache for each track's instrument. This only loads
|
||||
// the soundfonts; the per-track channel re-selection happens at note time.
|
||||
const preloadTrackInstruments = async (tracks) => {
|
||||
if (!window.SonicSF || !window.SonicSF.selectInstrument) return;
|
||||
const list = tracks || [];
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
const t = list[i];
|
||||
const se = t.synth_engine;
|
||||
const sfId = (se && se.soundfont_id) || t.soundfont_id;
|
||||
if (!sfId) continue;
|
||||
const bank = se ? (se.soundfont_bank !== undefined ? se.soundfont_bank : 0) : (t.soundfont_bank !== undefined ? t.soundfont_bank : 0);
|
||||
const prog = se ? (se.soundfont_program !== undefined ? se.soundfont_program : 0) : (t.soundfont_program !== undefined ? t.soundfont_program : 0);
|
||||
const ch = t.midiChannel !== undefined ? t.midiChannel : (i % 16);
|
||||
try {
|
||||
await window.SonicSF.selectInstrument(ch, bank, prog, sfId);
|
||||
} catch (e) {}
|
||||
}
|
||||
};
|
||||
|
||||
const ProfileModal = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
@@ -4494,7 +4609,14 @@ const ProfileModal = ({
|
||||
currentProjectId,
|
||||
setCurrentProjectId,
|
||||
showToast,
|
||||
loadAudioBuffersForTracks
|
||||
loadAudioBuffersForTracks,
|
||||
setAppWarningModal,
|
||||
bpm,
|
||||
setBpm,
|
||||
setMasteringSettings,
|
||||
setSessionTabs,
|
||||
setSubTabs,
|
||||
trackMidiChannelsRef
|
||||
}) => {
|
||||
if (!isOpen) return null;
|
||||
const [activeTab, setActiveTab] = useState('account');
|
||||
@@ -4685,6 +4807,8 @@ const ProfileModal = ({
|
||||
}
|
||||
setTracks(restoredTracks);
|
||||
loadAudioBuffersForTracks(restoredTracks).catch(function(err) { console.warn('loadAudioBuffersForTracks error:', err); });
|
||||
trackMidiChannelsRef.current = {};
|
||||
preloadTrackInstruments(restoredTracks).catch(function(err) { console.warn('preloadTrackInstruments error:', err); });
|
||||
setBpm(restoredBpm.toString());
|
||||
setSelectedTrackId(restoredTracks[0]?.id || '1');
|
||||
setProjectName(proj.name);
|
||||
@@ -6196,7 +6320,7 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, activeTracks, onClos
|
||||
currentBeat < n.start_beat && newBeat >= n.start_beat
|
||||
);
|
||||
var pvTrk = activeTracks.find(function(t) { return t.id === st.trackId; });
|
||||
var pvCh = window.SonicPianoRoll ? window.SonicPianoRoll.getTrackMidiChannel(pvTrk, activeTracks) : (pvTrk ? pvTrk.midiChannel : 0);
|
||||
var pvCh = pvTrk ? assignTrackMidiChannel(pvTrk, activeTracks) : 0;
|
||||
playing.forEach(n => {
|
||||
window.SonicSF.playNote(n.pitch, (n.velocity || 0.8) * 127, 200, ctx.currentTime, st.instrumentProgram, null, pvCh, pvTrk ? pvTrk.synth_engine : undefined);
|
||||
});
|
||||
@@ -6530,7 +6654,7 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, activeTracks, onClos
|
||||
if (window.SonicSF) {
|
||||
const ctx = getAudioContext();
|
||||
var clTrk = activeTracks.find(function(t) { return t.id === st.trackId; });
|
||||
var clCh = window.SonicPianoRoll ? window.SonicPianoRoll.getTrackMidiChannel(clTrk, activeTracks) : (clTrk ? clTrk.midiChannel : 0);
|
||||
var clCh = clTrk ? assignTrackMidiChannel(clTrk, activeTracks) : 0;
|
||||
window.SonicSF.playNote(notes[clickedNoteIdx].pitch, 100, 300, ctx.currentTime, st.instrumentProgram, null, clCh, clTrk ? clTrk.synth_engine : undefined);
|
||||
}
|
||||
}
|
||||
@@ -6754,7 +6878,7 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, activeTracks, onClos
|
||||
if (window.SonicSF && window.SonicSF._playNoteFallback) {
|
||||
const ctx = getAudioContext();
|
||||
var dwTrk = activeTracks.find(function(t) { return t.id === st.trackId; });
|
||||
var dwCh = window.SonicPianoRoll ? window.SonicPianoRoll.getTrackMidiChannel(dwTrk, activeTracks) : (dwTrk ? dwTrk.midiChannel : 0);
|
||||
var dwCh = dwTrk ? assignTrackMidiChannel(dwTrk, activeTracks) : 0;
|
||||
var dwPitch = snapToScaleRef.current ? snapPitchToScale(pitch, selectedScaleRef.current) : pitch;
|
||||
var dwDurMs = Math.max(100, Math.round(initialDur * (60 / bpm) * 1000));
|
||||
var dwNodes = window.SonicSF._playNoteFallback(dwPitch, Math.round(brushVelocityRef.current * 127), dwDurMs, ctx.currentTime, dwTrk ? dwTrk.instrumentProgram : undefined, null, dwCh, dwTrk ? dwTrk.synth_engine : undefined);
|
||||
@@ -6850,7 +6974,7 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, activeTracks, onClos
|
||||
if (window.SonicSF && window.SonicSF._playNoteFallback) {
|
||||
var pvCtx = getAudioContext();
|
||||
var pvTrk = activeTracks.find(function(t) { return t.id === st.trackId; });
|
||||
var pvCh = window.SonicPianoRoll ? window.SonicPianoRoll.getTrackMidiChannel(pvTrk, activeTracks) : (pvTrk ? pvTrk.midiChannel : 0);
|
||||
var pvCh = pvTrk ? assignTrackMidiChannel(pvTrk, activeTracks) : 0;
|
||||
var pvVel = Math.round(brushVelocityRef.current * 127);
|
||||
var pvNodes = window.SonicSF._playNoteFallback(p, pvVel, durMs, pvCtx.currentTime, pvTrk ? pvTrk.instrumentProgram : undefined, null, pvCh, pvTrk ? pvTrk.synth_engine : undefined);
|
||||
if (pvNodes) previewNodesRef.current = pvNodes;
|
||||
@@ -7167,7 +7291,7 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, activeTracks, onClos
|
||||
|
||||
const renderKeybed = () => {
|
||||
var kbTrk = activeTracks.find(function(t) { return t.id === st.trackId; });
|
||||
var kbCh = window.SonicPianoRoll ? window.SonicPianoRoll.getTrackMidiChannel(kbTrk, activeTracks) : (kbTrk ? kbTrk.midiChannel : 0);
|
||||
var kbCh = kbTrk ? assignTrackMidiChannel(kbTrk, activeTracks) : 0;
|
||||
var kbSynth = kbTrk ? kbTrk.synth_engine : undefined;
|
||||
const keys = [];
|
||||
for (let pitch = 127; pitch >= PITCH_START; pitch--) {
|
||||
@@ -7870,6 +7994,7 @@ const serializeTracksList = (tracksList, secondsPerBar) => {
|
||||
soundfont_bank: t.soundfont_bank !== undefined ? t.soundfont_bank : (t.synth_engine ? t.synth_engine.soundfont_bank : null),
|
||||
soundfont_program: t.soundfont_program !== undefined ? t.soundfont_program : (t.synth_engine ? t.synth_engine.soundfont_program : null),
|
||||
synth_engine: t.synth_engine || undefined,
|
||||
midi_channel: t.midiChannel !== undefined ? t.midiChannel : null,
|
||||
server_file_id: t.serverFileId || null,
|
||||
items: items
|
||||
};
|
||||
@@ -8427,6 +8552,16 @@ const MasteringModal = ({ isOpen, onClose, masteringSettings, setMasteringSettin
|
||||
renderMeter(masterBus && masterBus.inputAnalyser, inMeterCanvasRef, 'inPeakText');
|
||||
renderMeter(masterBus && masterBus.outputAnalyser, outMeterCanvasRef, 'outPeakText');
|
||||
|
||||
// Safety watchdog: if the mastering chain is broken (signal in, silence
|
||||
// out — e.g. a biquad in a bad state), fall back to the direct routing so
|
||||
// audio is NEVER globally silent. The user can re-enable mastering after.
|
||||
const masterInPk = getPeakLevel(masterBus && masterBus.inputAnalyser);
|
||||
const masterOutPk = getPeakLevel(masterBus && masterBus.outputAnalyser);
|
||||
if (masterBus && masterBus.masteringActive && masterInPk > 0.01 && masterOutPk < 0.001) {
|
||||
console.warn('[Mastering] Chain broken (signal in, no signal out) — bypassing mastering to restore audio.');
|
||||
toggleMasteringOnMaster(false, false);
|
||||
}
|
||||
|
||||
// Wave Observer Oscilloscope Rendering
|
||||
const woCanvas = woCanvasRef.current;
|
||||
if (woCanvas) {
|
||||
@@ -11074,6 +11209,10 @@ const App = () => {
|
||||
const isSfInstrument = instrumentId && typeof instrumentId === 'string' && instrumentId.startsWith('sf_');
|
||||
const sfBank = bankNumber !== undefined ? bankNumber : (isSfInstrument ? 0 : undefined);
|
||||
const sfProg = programNumber !== undefined ? programNumber : undefined;
|
||||
var mt = activeTracksRef.current || tracks;
|
||||
var curTrk = null;
|
||||
for (var ci = 0; ci < mt.length; ci++) { if (mt[ci].id === trackId) { curTrk = mt[ci]; break; } }
|
||||
var mch = curTrk ? assignTrackMidiChannel(curTrk, mt) : (sfBank === 128 ? 9 : 0);
|
||||
updateActiveTracks(prev => prev.map(t => {
|
||||
if (t.id !== trackId) return t;
|
||||
const hasInstrument = !!instrumentId;
|
||||
@@ -11085,10 +11224,6 @@ const App = () => {
|
||||
soundfont_program: sfProg !== undefined ? sfProg : 0,
|
||||
soundfont_id: isSfInstrument ? instrumentId.replace('sf_', '') : ''
|
||||
} : undefined;
|
||||
var mt = activeTracksRef.current || tracks;
|
||||
var midx = 0;
|
||||
for (var mi = 0; mi < mt.length; mi++) { if (mt[mi].id === trackId) { midx = mi; break; } }
|
||||
var mch = sfBank === 128 ? 9 : (midx % 16);
|
||||
return { ...t, midiChannel: mch, instrumentId, instrumentProgram: sfProg, instrumentName: displayName, soundfont_bank: sfBank, soundfont_program: sfProg, synth_engine: synthEngine, type: hasInstrument ? 'MIDI' : (t.type === 'MIDI' ? 'audio' : t.type) };
|
||||
}));
|
||||
setInstrumentDropdownTrackId(null);
|
||||
@@ -11097,15 +11232,7 @@ const App = () => {
|
||||
// Trigger SpessaSynth load + program change when soundfont instrument selected
|
||||
if (window.SonicSF && window.SonicSF.selectInstrument && instrumentId && isSfInstrument) {
|
||||
const sfId = instrumentId.replace('sf_', '');
|
||||
var allTracks = activeTracksRef.current || tracks;
|
||||
var tidx = 0;
|
||||
for (var i = 0; i < allTracks.length; i++) { if (allTracks[i].id === trackId) { tidx = i; break; } }
|
||||
var ch = sfBank === 128 ? 9 : (tidx % 16);
|
||||
// Store channel for consistent per-track instrument playback
|
||||
if (!allTracks[tidx] || allTracks[tidx].midiChannel === undefined) {
|
||||
updateActiveTracks(prev => prev.map(t => t.id === trackId ? { ...t, midiChannel: ch } : t));
|
||||
}
|
||||
window.SonicSF.selectInstrument(ch, sfBank || 0, sfProg || 0, sfId);
|
||||
window.SonicSF.selectInstrument(mch, sfBank || 0, sfProg || 0, sfId);
|
||||
}
|
||||
setSubTabs(prev => prev.map(s => {
|
||||
if (s.trackId !== trackId) return s;
|
||||
@@ -11150,11 +11277,15 @@ const App = () => {
|
||||
setInstrumentDropdownBtnRect(null);
|
||||
if (instrumentId && instrumentId.startsWith('sf_')) {
|
||||
// Set instrument on track immediately so Synth button shows the name
|
||||
var mt2 = activeTracksRef.current || tracks;
|
||||
var qTrk = null;
|
||||
for (var qi = 0; qi < mt2.length; qi++) { if (mt2[qi].id === trackId) { qTrk = mt2[qi]; break; } }
|
||||
var qch = qTrk ? assignTrackMidiChannel(qTrk, mt2) : 0;
|
||||
updateActiveTracks(prev => prev.map(t => {
|
||||
if (t.id !== trackId) return t;
|
||||
const sfClean = instrumentId.replace('sf_', '');
|
||||
const synthEngine = { type: 'soundfont', plugin_id: instrumentId, soundfont_bank: 0, soundfont_program: 0, soundfont_id: sfClean };
|
||||
return { ...t, instrumentId, instrumentProgram: undefined, instrumentName: displayName, synth_engine: synthEngine };
|
||||
return { ...t, midiChannel: qch, instrumentId, instrumentProgram: undefined, instrumentName: displayName, synth_engine: synthEngine };
|
||||
}));
|
||||
setSelectedSoundFontId(instrumentId);
|
||||
setSynthCategory('soundfont');
|
||||
@@ -11279,7 +11410,7 @@ const App = () => {
|
||||
if (arSubs.length > 0) {
|
||||
arSubs.forEach(function(as) {
|
||||
var asTrk = allTracks.find(function(t) { return t.id === as.trackId; });
|
||||
var asCh = window.SonicPianoRoll ? window.SonicPianoRoll.getTrackMidiChannel(asTrk, allTracks) : (asTrk ? asTrk.midiChannel : 0);
|
||||
var asCh = asTrk ? assignTrackMidiChannel(asTrk, allTracks) : 0;
|
||||
var asProg = as.instrumentProgram;
|
||||
var asSe = as.synth_engine;
|
||||
if (window.triggerMidiVuActivity) {
|
||||
@@ -11290,7 +11421,7 @@ const App = () => {
|
||||
}
|
||||
// Route to ALL armed tracks (not just the first one)
|
||||
armedTracks.forEach(function(at) {
|
||||
var atCh = window.SonicPianoRoll ? window.SonicPianoRoll.getTrackMidiChannel(at, allTracks) : (at.midiChannel !== undefined ? at.midiChannel : 0);
|
||||
var atCh = assignTrackMidiChannel(at, allTracks);
|
||||
var atProg = at.instrumentProgram;
|
||||
var atSe = at.synth_engine;
|
||||
var atDest = activeTrackNodesRef.current[at.id]?.gainNode || null;
|
||||
@@ -11311,13 +11442,17 @@ const App = () => {
|
||||
}
|
||||
// Stop the note on ALL tracks (not just armed) to prevent stuck notes
|
||||
// when ARM is toggled off while a key is held
|
||||
if (window.SonicSF && window.SonicSF.stopNote) {
|
||||
var stopTracks = activeTracksRef.current || [];
|
||||
stopTracks.forEach(function(st) {
|
||||
var stCh = window.SonicPianoRoll ? window.SonicPianoRoll.getTrackMidiChannel(st, stopTracks) : (st.midiChannel !== undefined ? st.midiChannel : 0);
|
||||
window.SonicSF.stopNote(stCh, pitch);
|
||||
});
|
||||
}
|
||||
if (window.SonicSF && window.SonicSF.stopNote) {
|
||||
var stopTracks = activeTracksRef.current || [];
|
||||
stopTracks.forEach(function(st) {
|
||||
// Only stop channels that actually carry this track's notes —
|
||||
// an index-based fallback could hit another track's dedicated
|
||||
// channel and kill its sound.
|
||||
if (!st.synth_engine && st.midiChannel === undefined) return;
|
||||
var stCh = assignTrackMidiChannel(st, stopTracks);
|
||||
window.SonicSF.stopNote(stCh, pitch);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Sustain (CC64), Modulation (CC1), Pitch Bend ──
|
||||
@@ -11330,11 +11465,11 @@ const App = () => {
|
||||
var ccTracks = activeTracksRef.current || [];
|
||||
var hasArmed = ccTracks.some(function(t) { return t.isArmed; });
|
||||
if (hasArmed) {
|
||||
ccTracks.forEach(function(ct) {
|
||||
if (!ct.isArmed) return;
|
||||
var ctCh = window.SonicPianoRoll ? window.SonicPianoRoll.getTrackMidiChannel(ct, ccTracks) : (ct.midiChannel !== undefined ? ct.midiChannel : 0);
|
||||
window.SonicSF.controllerChange(ctCh, cc, val);
|
||||
});
|
||||
ccTracks.forEach(function(ct) {
|
||||
if (!ct.isArmed) return;
|
||||
var ctCh = assignTrackMidiChannel(ct, ccTracks);
|
||||
window.SonicSF.controllerChange(ctCh, cc, val);
|
||||
});
|
||||
} else {
|
||||
window.SonicSF.controllerChange(midiCh, cc, val);
|
||||
}
|
||||
@@ -11348,11 +11483,11 @@ const App = () => {
|
||||
var pbTracks = activeTracksRef.current || [];
|
||||
var hasArmedPB = pbTracks.some(function(t) { return t.isArmed; });
|
||||
if (hasArmedPB) {
|
||||
pbTracks.forEach(function(pt) {
|
||||
if (!pt.isArmed) return;
|
||||
var ptCh = window.SonicPianoRoll ? window.SonicPianoRoll.getTrackMidiChannel(pt, pbTracks) : (pt.midiChannel !== undefined ? pt.midiChannel : 0);
|
||||
window.SonicSF.pitchBend(ptCh, bendVal);
|
||||
});
|
||||
pbTracks.forEach(function(pt) {
|
||||
if (!pt.isArmed) return;
|
||||
var ptCh = assignTrackMidiChannel(pt, pbTracks);
|
||||
window.SonicSF.pitchBend(ptCh, bendVal);
|
||||
});
|
||||
} else {
|
||||
window.SonicSF.pitchBend(midiCh, bendVal);
|
||||
}
|
||||
@@ -12110,6 +12245,8 @@ const App = () => {
|
||||
}
|
||||
setTracks(restoredTracks);
|
||||
loadAudioBuffersForTracks(restoredTracks).catch(function(err) { console.warn('loadAudioBuffersForTracks error:', err); });
|
||||
trackMidiChannelsRef.current = {};
|
||||
preloadTrackInstruments(restoredTracks).catch(function(err) { console.warn('preloadTrackInstruments error:', err); });
|
||||
setBpm(restoredBpm.toString());
|
||||
setSelectedTrackId(restoredTracks[0]?.id || '1');
|
||||
setProjectName(lastName);
|
||||
@@ -13411,9 +13548,9 @@ const App = () => {
|
||||
var sfId = se.soundfont_id;
|
||||
if (sfId) {
|
||||
var allTrks = activeTracksRef.current || activeTracks;
|
||||
var trkIdx = 0;
|
||||
for (var ti = 0; ti < allTrks.length; ti++) { if (allTrks[ti].id === trackId) { trkIdx = ti; break; } }
|
||||
var seCh = (se.soundfont_bank === 128 ? 9 : (trkIdx % 16));
|
||||
// Use the track's dedicated channel (same as every playback path) so the
|
||||
// preload matches where piano-roll notes will actually sound.
|
||||
var seCh = assignTrackMidiChannel(track, allTrks);
|
||||
window.SonicSF.selectInstrument(seCh, se.soundfont_bank || 0, se.soundfont_program || 0, sfId);
|
||||
}
|
||||
}
|
||||
@@ -15313,7 +15450,7 @@ const App = () => {
|
||||
const track = activeTracks.find(t => t.id === trackId);
|
||||
const destNode = getOrCreateTrackNode(track, context);
|
||||
const program = track ? track.instrumentProgram : undefined;
|
||||
var prevCh = window.SonicPianoRoll ? window.SonicPianoRoll.getTrackMidiChannel(track, activeTracks) : (track ? track.midiChannel : 0);
|
||||
var prevCh = track ? assignTrackMidiChannel(track, activeTracks) : 0;
|
||||
window.SonicSF.playNote(
|
||||
pitch,
|
||||
velocity,
|
||||
@@ -15366,7 +15503,7 @@ const App = () => {
|
||||
// MIDI items playback
|
||||
const midiItems = track.midiItems || [];
|
||||
if ((track.type === 'MIDI' || midiItems.length > 0) && window.SonicSF) {
|
||||
var trkCh = window.SonicPianoRoll ? window.SonicPianoRoll.getTrackMidiChannel(track, allPlayTracks) : (track.midiChannel !== undefined ? track.midiChannel : 0);
|
||||
var trkCh = assignTrackMidiChannel(track, allPlayTracks);
|
||||
// Ensure instrument is loaded in FluidSynth
|
||||
if (track.synth_engine && track.synth_engine.type === 'soundfont' && track.synth_engine.soundfont_id) {
|
||||
window.SonicSF.selectInstrument(trkCh, track.synth_engine.soundfont_bank || 0, track.synth_engine.soundfont_program || 0, track.synth_engine.soundfont_id);
|
||||
@@ -15506,7 +15643,7 @@ const App = () => {
|
||||
if (noteStartMain >= secStart && noteStartMain < secEnd && offsetTime < noteEndMain) {
|
||||
const notePlayEndMain = Math.min(noteEndMain, secEnd);
|
||||
const program = subTrack.instrumentProgram;
|
||||
var subCh = window.SonicPianoRoll ? window.SonicPianoRoll.getTrackMidiChannel(subTrack, secTracks) : (subTrack.midiChannel !== undefined ? subTrack.midiChannel : (subIdx % 16));
|
||||
var subCh = assignTrackMidiChannel(subTrack, secTracks);
|
||||
|
||||
if (offsetTime < noteStartMain) {
|
||||
const delay = noteStartMain - offsetTime;
|
||||
@@ -15585,6 +15722,7 @@ const App = () => {
|
||||
// MIDI items playback
|
||||
const midiItems = track.midiItems || [];
|
||||
if ((track.type === 'MIDI' || midiItems.length > 0) && window.SonicSF) {
|
||||
var lcCh = assignTrackMidiChannel(track, tracks);
|
||||
const bpmVal = parseInt(bpm) || 120;
|
||||
const secondsPerBeat = 60.0 / bpmVal;
|
||||
midiItems.forEach(item => {
|
||||
@@ -15605,7 +15743,9 @@ const App = () => {
|
||||
durationMs,
|
||||
startTime,
|
||||
program,
|
||||
gainNode
|
||||
gainNode,
|
||||
lcCh,
|
||||
track.synth_engine
|
||||
);
|
||||
} else {
|
||||
const remainingDurMs = (noteEndSec - offsetTime) * 1000;
|
||||
@@ -15615,7 +15755,9 @@ const App = () => {
|
||||
remainingDurMs,
|
||||
context.currentTime,
|
||||
program,
|
||||
gainNode
|
||||
gainNode,
|
||||
lcCh,
|
||||
track.synth_engine
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -15635,16 +15777,14 @@ const App = () => {
|
||||
const instrumentProgram = track ? track.instrumentProgram : undefined;
|
||||
const synthEngine = track ? track.synth_engine : undefined;
|
||||
var allTracks = activeTracksRef.current || [];
|
||||
var mainCh = window.SonicPianoRoll ? window.SonicPianoRoll.getTrackMidiChannel(track, allTracks) : (track ? track.midiChannel : 0);
|
||||
// Compute session beat offset from target item
|
||||
var sessionBeatOffset = 0;
|
||||
var targetTrk = allTracks.find(function(t) { return t.id === st.trackId; });
|
||||
if (targetTrk) {
|
||||
var targetIt = (targetTrk.midiItems || []).find(function(m) { return m.id === st.target_id; });
|
||||
if (targetIt) sessionBeatOffset = (targetIt.startTime / ((60.0 / bpmVal) * 4)) * 4;
|
||||
}
|
||||
var mainCh = track ? assignTrackMidiChannel(track, allTracks) : 0;
|
||||
// The piano-roll playhead (st.currentTime) and transport are item-relative:
|
||||
// 0 = item start. Notes must be scheduled relative to the item too, otherwise
|
||||
// items placed later in the project play `item.startTime` seconds in the
|
||||
// future (silence when pressing play). Ghost notes are already relative to
|
||||
// the item window, so no absolute-session offset is applied anywhere here.
|
||||
midiNotes.forEach(note => {
|
||||
const noteOnBeat = (note.start_beat || 0) + sessionBeatOffset;
|
||||
const noteOnBeat = note.start_beat || 0;
|
||||
const noteDurBeat = note.duration_beats || 1;
|
||||
const noteStartSec = noteOnBeat * secondsPerBeat;
|
||||
const noteDurSec = noteDurBeat * secondsPerBeat;
|
||||
@@ -15666,16 +15806,9 @@ const App = () => {
|
||||
var ghostSynth = layer.synthEngine || (ghostTrack ? ghostTrack.synth_engine : undefined);
|
||||
if (ghostProg === undefined && !ghostSynth) return;
|
||||
var ghostDest = getOrCreateTrackNode(ghostTrack, context);
|
||||
var ghostCh = 0;
|
||||
if (ghostTrack) {
|
||||
if (ghostTrack.midiChannel !== undefined) {
|
||||
ghostCh = ghostTrack.midiChannel;
|
||||
} else {
|
||||
ghostCh = window.SonicPianoRoll ? window.SonicPianoRoll.getTrackMidiChannel(ghostTrack, allTracks) : (ghostTrack ? ghostTrack.midiChannel : 0);
|
||||
}
|
||||
}
|
||||
var ghostCh = ghostTrack ? assignTrackMidiChannel(ghostTrack, allTracks) : 0;
|
||||
layer.notes.forEach(function(note) {
|
||||
var beat = (note.start_beat || 0) + sessionBeatOffset;
|
||||
var beat = note.start_beat || 0;
|
||||
var dur = note.duration_beats || 1;
|
||||
var startSec = beat * secondsPerBeat;
|
||||
var durSec = dur * secondsPerBeat;
|
||||
@@ -23767,7 +23900,14 @@ const App = () => {
|
||||
currentProjectId: currentProjectId,
|
||||
setCurrentProjectId: setCurrentProjectId,
|
||||
showToast: showToast,
|
||||
loadAudioBuffersForTracks: loadAudioBuffersForTracks
|
||||
loadAudioBuffersForTracks: loadAudioBuffersForTracks,
|
||||
setAppWarningModal: setAppWarningModal,
|
||||
bpm: bpm,
|
||||
setBpm: setBpm,
|
||||
setMasteringSettings: setMasteringSettings,
|
||||
setSessionTabs: setSessionTabs,
|
||||
setSubTabs: setSubTabs,
|
||||
trackMidiChannelsRef: trackMidiChannelsRef
|
||||
}), /*#__PURE__*/React.createElement(SaveProjectModal, {
|
||||
isOpen: saveProjectModalOpen,
|
||||
onClose: () => setSaveProjectModalOpen(false),
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -23,6 +23,8 @@
|
||||
let _activeOscillators = {};
|
||||
let _gainNode = null;
|
||||
let _scheduledNotes = [];
|
||||
let _loadPromises = {};
|
||||
let _sfloadSeq = 0;
|
||||
|
||||
const getCtx = function () {
|
||||
if (_audioCtx) {
|
||||
@@ -84,14 +86,21 @@
|
||||
|
||||
console.log("[SonicSF] AudioCtx state:", _audioCtx.state, "sampleRate:", _audioCtx.sampleRate);
|
||||
|
||||
// ── Renderer selection ──
|
||||
// ScriptProcessor is the default and FINAL choice: it is
|
||||
// pull-based (onaudioprocess is invoked by the audio thread),
|
||||
// so it cannot starve when the main thread is busy (font
|
||||
// loading, WASM decode, UI). The AudioWorklet push model
|
||||
// (setInterval on the main thread) starves under load and
|
||||
// produces SILENCE on this machine — repeatedly confirmed.
|
||||
// The deprecation console warning is purely cosmetic.
|
||||
var _useScriptNode = true;
|
||||
try {
|
||||
await _audioCtx.audioWorklet.addModule('/static/js/worklets/fluidsynth-bridge.js');
|
||||
console.log("[SonicSF] Worklet registered OK");
|
||||
await _audioCtx.audioWorklet.addModule('/static/js/worklets/fluidsynth-bridge.js?v=202608031240');
|
||||
console.log("[SonicSF] Worklet registered OK (unused)");
|
||||
} catch (e) {
|
||||
console.warn("[SonicSF] Worklet reg failed:", e);
|
||||
}
|
||||
console.log("[SonicSF] Using ScriptProcessorNode (forced for debug)");
|
||||
|
||||
console.log("[SonicSF] Initializing FluidSynth WASM Engine...");
|
||||
var TOTAL_MEMORY = 256 * 1024 * 1024;
|
||||
@@ -104,6 +113,11 @@
|
||||
},
|
||||
TOTAL_MEMORY: TOTAL_MEMORY,
|
||||
printErr: function (msg) {
|
||||
// "No preset found on channel" is FluidSynth's
|
||||
// expected notice when a soundfont simply has no
|
||||
// preset for a bank (e.g. bank 128 on a melodic-only
|
||||
// font) — the note is just silent, not an error.
|
||||
if (msg && msg.indexOf('No preset found on channel') !== -1) return;
|
||||
console.warn('[FluidSynth:err]', msg);
|
||||
}
|
||||
});
|
||||
@@ -132,7 +146,15 @@
|
||||
|
||||
if (!_useScriptNode) {
|
||||
try {
|
||||
_workletNode = new AudioWorkletNode(_audioCtx, 'fluidsynth-bridge');
|
||||
// Force stereo output regardless of the device's
|
||||
// channel count — FluidSynth renders stereo, and a
|
||||
// mono output would crash the worklet (out[1] undefined).
|
||||
_workletNode = new AudioWorkletNode(_audioCtx, 'fluidsynth-bridge', {
|
||||
numberOfOutputs: 1,
|
||||
outputChannelCount: [2],
|
||||
channelCount: 2,
|
||||
channelCountMode: 'explicit'
|
||||
});
|
||||
_workletNode.connect(_gainNode);
|
||||
console.log("[SonicSF] AudioWorklet node connected via gain");
|
||||
_startRenderLoop();
|
||||
@@ -186,11 +208,16 @@
|
||||
},
|
||||
|
||||
_tryLoadSFL: function (buf, ext) {
|
||||
var fname = '/' + ext + '_' + Date.now();
|
||||
var fname = '/' + ext + '_' + (++_sfloadSeq) + '_' + Date.now();
|
||||
try { _fluidModule.FS.unlink(fname); } catch (e) {}
|
||||
_fluidModule.FS.writeFile(fname, new Uint8Array(buf));
|
||||
var cPath = this._allocCStr(fname);
|
||||
var handle = _fluidModule._fluid_synth_sfload(_synthPtr, cPath, 1);
|
||||
// reset_presets = 0: loading a NEW soundfont must NOT reset the
|
||||
// presets already selected on other channels. With 1, FluidSynth
|
||||
// re-points every channel to the new font's preset 0, so loading a
|
||||
// second instrument silently changes the first one's sound
|
||||
// (decay/loop envelope…).
|
||||
var handle = _fluidModule._fluid_synth_sfload(_synthPtr, cPath, 0);
|
||||
_fluidModule._free(cPath);
|
||||
try { _fluidModule.FS.unlink(fname); } catch (e) {}
|
||||
return handle;
|
||||
@@ -203,6 +230,24 @@
|
||||
_currentSfId = sfId;
|
||||
return true;
|
||||
}
|
||||
// Deduplicate concurrent loads: rapid key presses (or several armed
|
||||
// tracks) all call loadSoundFont for the same font before the first
|
||||
// load resolves. Without this, the same soundfont is sfload'd several
|
||||
// times (handles 1,2,3,4…) — wasting the 256MB WASM heap and stalling
|
||||
// notes until each load finishes (audible lag, then silence).
|
||||
if (!_loadPromises[sfId]) {
|
||||
_loadPromises[sfId] = this._doLoadSoundFont(sfId).then(function (ok) {
|
||||
// Do NOT cache failures: a transient error (network hiccup,
|
||||
// memory pressure) must not permanently kill the instrument —
|
||||
// the next note retries the load and recovers.
|
||||
if (!ok) delete _loadPromises[sfId];
|
||||
return ok;
|
||||
});
|
||||
}
|
||||
return _loadPromises[sfId];
|
||||
},
|
||||
|
||||
_doLoadSoundFont: async function (sfId) {
|
||||
try {
|
||||
var cache = window.SonicSFStorage;
|
||||
var buf = cache ? await cache.getBuffer(sfId) : null;
|
||||
@@ -258,16 +303,16 @@
|
||||
if (!ok) return;
|
||||
}
|
||||
var engKey = (sfId || '') + ':' + bank + ':' + program;
|
||||
if (!_engineChMap[engKey]) {
|
||||
if (channel === undefined || channel === null) {
|
||||
if (channel === undefined || channel === null) {
|
||||
if (!_engineChMap[engKey]) {
|
||||
var allocCh = this.allocateChannel(bank);
|
||||
_engineChMap[engKey] = allocCh;
|
||||
channel = allocCh;
|
||||
} else {
|
||||
_engineChMap[engKey] = channel;
|
||||
channel = _engineChMap[engKey];
|
||||
}
|
||||
} else {
|
||||
channel = _engineChMap[engKey];
|
||||
} else if (!_engineChMap[engKey]) {
|
||||
_engineChMap[engKey] = channel;
|
||||
}
|
||||
var sfHandle = _sfHandleMap.get(sfId);
|
||||
if (sfHandle !== undefined) {
|
||||
@@ -423,18 +468,30 @@
|
||||
var finalBank = usedBank;
|
||||
var finalProg = usedProg;
|
||||
var finalSfId = synthEngine ? synthEngine.soundfont_id : undefined;
|
||||
if (_channels[ch] && _channels[ch].program !== undefined) {
|
||||
finalBank = _channels[ch].bank;
|
||||
finalProg = _channels[ch].program;
|
||||
if (_channels[ch].sfId !== undefined) {
|
||||
finalSfId = _channels[ch].sfId;
|
||||
}
|
||||
var cachedCh = _channels[ch];
|
||||
// The note's own synth engine (track instrument) is
|
||||
// authoritative. Channel state is only a cache: it must never
|
||||
// mask the track's instrument, otherwise multi-track ARM or a
|
||||
// re-picked instrument plays the wrong soundfont. Without an
|
||||
// engine, fall back to the soundfont configured on the channel.
|
||||
if (!synthEngine && cachedCh && cachedCh.sfId !== undefined) {
|
||||
finalBank = cachedCh.bank;
|
||||
finalProg = cachedCh.program;
|
||||
finalSfId = cachedCh.sfId;
|
||||
}
|
||||
// Ensure the soundfont is actually loaded before the note plays.
|
||||
// Quick instrument pick on a track does not pre-load it, so load
|
||||
// lazily here and retry the note once the font is ready.
|
||||
if (finalSfId && !_sfHandleMap.has(finalSfId)) {
|
||||
self.loadSoundFont(finalSfId).then(function (ok) {
|
||||
if (ok) doNote();
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Program change at note time, not call time — ensures correct
|
||||
// instrument for each item regardless of processing order.
|
||||
// Skip if the channel already has this exact instrument (avoids
|
||||
// per-note soundfont reloads that cause audible crackle/glitches).
|
||||
var cachedCh = _channels[ch];
|
||||
var progAlreadySet = cachedCh && cachedCh.program === finalProg && cachedCh.bank === finalBank && cachedCh.sfId === finalSfId;
|
||||
if ((synthEngine || program !== undefined) && !progAlreadySet) {
|
||||
var sfHandle = finalSfId ? _sfHandleMap.get(finalSfId) : undefined;
|
||||
|
||||
@@ -15,17 +15,24 @@ class FluidSynthBridge extends AudioWorkletProcessor {
|
||||
|
||||
process(inputs, outputs) {
|
||||
const out = outputs[0];
|
||||
if (!out) return true;
|
||||
if (!out || out.length === 0) return true;
|
||||
this.called++;
|
||||
const numCh = out.length;
|
||||
const len = out[0].length;
|
||||
const qL = this.leftQ;
|
||||
const qR = this.rightQ;
|
||||
let fi = 0;
|
||||
let si = 0;
|
||||
// Handle any output channel count (mono devices produce 1 channel, so
|
||||
// out[1] may be undefined — never write into a missing channel).
|
||||
for (let i = 0; i < len; i++) {
|
||||
if (fi >= qL.length) { out[0][i] = 0; out[1][i] = 0; continue; }
|
||||
out[0][i] = qL[fi][si];
|
||||
out[1][i] = qR[fi][si];
|
||||
if (fi >= qL.length) {
|
||||
for (let c = 0; c < numCh; c++) out[c][i] = 0;
|
||||
continue;
|
||||
}
|
||||
for (let c = 0; c < numCh; c++) {
|
||||
out[c][i] = c % 2 === 0 ? qL[fi][si] : qR[fi][si];
|
||||
}
|
||||
si++;
|
||||
if (si >= qL[fi].length) { fi++; si = 0; }
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
<script src="/static/js/services/audioEngine.js?v=202607271016"></script>
|
||||
<script src="/static/js/services/storage.js?v=202607271016"></script>
|
||||
<script src="/static/js/services/soundfontStorage.js?v=202607271016"></script>
|
||||
<script src="/static/js/services/soundfontPlayer.js?v=202607311050"></script>
|
||||
<script src="/static/js/services/soundfontPlayer.js?v=202608031340"></script>
|
||||
<script src="/static/js/services/aiGateway.js?v=202607271016"></script>
|
||||
<script src="/static/js/services/dawCommandDispatcher.js?v=202607271016"></script>
|
||||
<script src="/static/js/services/pianoRollTabService.js?v=202607272044"></script>
|
||||
@@ -24,7 +24,7 @@
|
||||
<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/undoRedoEngine.js?v=202607290941"></script>
|
||||
<script src="/static/js/app.precompiled.js?v=202608022001" defer></script>
|
||||
<script src="/static/js/app.precompiled.js?v=202608031415" defer></script>
|
||||
<link rel="stylesheet" href="/static/css/styles.css?v=202607271016">
|
||||
<style>
|
||||
:root {
|
||||
|
||||
@@ -1194,3 +1194,83 @@
|
||||
- **Tóm tắt thay đổi:** (1) Loop preview giờ chạy liên tục vô hạn cho đến khi nhấn Stop: `startCanvasClock` đọc refs (`isLoopingRef`/`selStartRef`/`selEndRef`) thay vì closure cũ nên việc bật loop giữa lúc đang play được phản ánh ngay, playhead wrap đúng theo `loopStartSec` (trừ offset gốc), không còn tự `stopMediaPlayback()` khi hết selection; `playMidiPreview` dùng `isLoopingRef.current` khi lập lịch interval (trước đây closure `isLooping` cũ → bật loop không tạo interval) và hủy interval khi tắt loop; `toggleLoop` sync `isLoopingRef` ngay + cập nhật `loopStart`/`loopEnd` cho audio đang phát theo selection hiện tại; `playSelected` dùng refs cho loop points/startOffset. (2) Container render canvas thêm `p-0.5` (2px) để quét chọn vùng không vượt ra ngoài khung preview.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`
|
||||
- **Ghi chú/Test (nếu có):** `npm run build` (babel) thành công. Smoke: chọn file audio → quét chọn 1 đoạn → bật Loop → phát liên tục vùng chọn đến khi nhấn Stop (playhead wrap đúng, selection overlay vẫn hiển thị khi rAF redraw nhờ `drawSelStart`/`drawSelEnd`). MIDI: bật loop khi đang preview → interval reschedule vùng chọn.
|
||||
|
||||
### [2026-08-03 10:15] Task: Fix MIDI ARM không phát âm thanh sau khi load instrument
|
||||
- **Tóm tắt thay đổi:** Sửa `soundfontPlayer.js` để phím MIDI trên track ARM phát đúng instrument: (1) `_playNoteFluid` không còn để state mặc định của channel (bank 0, program 0, không sfId) che mất `synth_engine` của track → `finalSfId` luôn đúng; (2) tự load soundfont lười (lazy) ngay trong đường phát note nếu font chưa vào `_sfHandleMap` (track pick nhanh từ dropdown không gọi `selectInstrument` → trước đây rơi vào `bank_select`/`program_change` trên synth không có soundfont → câm lặng); (3) `selectInstrument` không còn đổi hướng channel khi đã truyền channel tường minh (sửa lỗi 2 track dùng chung instrument bị giật channel). Bump version cache `soundfontPlayer.js` trong `index.html`.
|
||||
- **Các file ảnh hưởng:** `app/static/js/services/soundfontPlayer.js`, `app/templates/index.html`
|
||||
- **Ghi chú/Test (nếu có):** Test logic bằng harness `node /tmp/kilo/test_sonicsf_fix.js` (mô phỏng FluidSynth): S1 lazy-load + program_select + noteon, S2 channel cấu hình sẵn không sfload lại, S3 program-only fallback, S4 no-instrument vẫn silent, S5 2 track cùng instrument giữ channel riêng — ALL PASSED. Cần hard-refresh trình duyệt để nạp `soundfontPlayer.js` bản mới.
|
||||
|
||||
### [2026-08-03 10:30] Task: Fix ARM nhiều track - instrument sai theo track (channel state che mất synth_engine)
|
||||
- **Tóm tắt thay đổi:** `_playNoteFluid` trước đây ưu tiên state channel (`_channels[ch].sfId`) làm nguồn instrument cho note → khi ARM nhiều track hoặc track đổi instrument qua dropdown nhanh, note bị phát theo instrument cũ/khác đang "dính" trên channel (leftover state hoặc 2 track trùng channel) → sai instrument từng track. Sửa: `synth_engine` của track là nguồn quyết định; channel state chỉ là cache (chỉ dùng khi note không có engine). Khi note mang engine khác với channel đang giữ, tự động `program_select` lại đúng instrument trước `noteon`; vẫn lazy-load soundfont nếu chưa nạp. Bump version cache `soundfontPlayer.js` trong `index.html`.
|
||||
- **Các file ảnh hưởng:** `app/static/js/services/soundfontPlayer.js`, `app/templates/index.html`
|
||||
- **Ghi chú/Test (nếu có):** Harness `node /tmp/kilo/test_multi_track.js` (mô phỏng routing ARM như app.jsx): (A) 3 track 3 instrument distinct channel → noteon đúng, không re-select thừa; (B) 3 track quick-pick (không midiChannel, channel đang giữ instrument cũ) → mỗi track re-select đúng Q1/Q2/Q3 trước noteon; (C) 2 track trùng channel 0 → track 2 re-select program 40 trước noteon. Harness `test_sonicsf_fix.js` (single-track S1-S5) vẫn ALL PASSED. Hard refresh trình duyệt để nạp bản mới.
|
||||
|
||||
### [2026-08-03 10:40] Task: Fix lag khi ARM sau reload + hết âm thanh sau vài lần nhấn MIDI
|
||||
- **Tóm tắt thay đổi:** (1) **Dedup load soundfont**: `loadSoundFont` trước đây không chặn các lệnh gọi đồng thời — nhấn phím MIDI liên tục (hoặc nhiều track ARM) kích hoạt lazy-load cùng lúc → SGM-V2.01 bị `sfload` 4 lần (handle 1,2,3,4 trong log), mỗi bản ~52MB trong heap WASM 256MB → cạn bộ nhớ, các lần load sau fail → câm sau vài lần nhấn + mỗi note chờ load (lag). Thêm `_loadPromises[sfId]` dedup (1 font = 1 lần load, cache cả kết quả fail) + `_sfloadSeq` cho filename temp duy nhất tránh trùng tên khi load 2 font khác nhau đồng thời. (2) **Preload instrument khi mở/khôi phục dự án**: thêm `preloadTrackInstruments(tracks)` gọi `selectInstrument` cho từng track có soundfont ngay sau khi `setTracks` trong `handleOpenProject` và `restoreLastSessionProject` → nhấn MIDI key đầu tiên không còn lag (font đã nạp sẵn).
|
||||
- **Các file ảnh hưởng:** `app/static/js/services/soundfontPlayer.js`, `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (build lại), `app/templates/index.html`
|
||||
- **Ghi chú/Test (nếu có):** `npm run build` (babel) OK, bundle chứa `preloadTrackInstruments`. Harness `node /tmp/kilo/test_dedup.js`: 8 lần `loadSoundFont` cùng 1 font đồng thời → chỉ 1 `sfload`, 1 lần đọc cache; load lại font đã nạp → 0 sfload; 2 font khác nhau đồng thời → đúng 2 sfload — ALL PASSED. `test_sonicsf_fix.js` + `test_multi_track.js` vẫn ALL PASSED. Hard refresh trình duyệt.
|
||||
|
||||
### [2026-08-03 11:00] Task: ARM multitrack - mỗi track dùng channel MIDI riêng, không làm đổi instrument track khác
|
||||
- **Tóm tắt thay đổi:** FluidSynth có 16 channel; nếu 2 track trùng channel thì ARM track này sẽ `program_select` đè instrument của track kia. Thêm cơ chế channel riêng cho từng track trong app.jsx: `trackMidiChannelsRef` + `ensureTrackMidiChannel`/`assignTrackMidiChannel` cấp channel ổn định, duy nhất (0-15, bỏ qua 9 - slot percussion cổ điển), tự sửa khi 2 track trùng channel được lưu. Áp dụng ở mọi nơi tính channel: `setTrackInstrumentWithProgram`, `setTrackInstrument` (dropdown nhanh), `preloadTrackInstruments`, routing ARM note-on (armedTracks + piano-roll sub-tab), note-off/CC/PitchBend (bỏ fallback `index % 16` — tránh dừng nhầm note của track khác). Lưu `midi_channel` khi save project + restore; reset map channel khi mở/khôi phục dự án. Kết hợp doNote cũ (synth_engine quyết định + re-select trước noteon) → mỗi track luôn phát đúng instrument của nó dù ARM nhiều track, chord/hợp âm đồng thời không còn đổi âm sắc nhau.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (build lại), `app/templates/index.html`
|
||||
- **Ghi chú/Test (nếu có):** `npm run build` OK, bundle chứa `assignTrackMidiChannel` (8 chỗ). Harness `node /tmp/kilo/test_channels.js`: 3 track → channel 0,1,2 duy nhất; gọi lại ổn định; track mới lấy channel trống; track lưu trùng channel được cấp lại; track lưu channel duy nhất được giữ; percussion giữ 9 + track trống khác lấy channel riêng — ALL PASSED. 3 harness cũ (single/multi/dedup) vẫn PASS. Hard refresh.
|
||||
|
||||
### [2026-08-03 11:30] Task: Fix instrument sau làm đổi thông số instrument trước + ReferenceError preloadTrackInstruments + warning channel 9
|
||||
- **Tóm tắt thay đổi:** (1) **sfload reset_presets 1→0** trong `soundfontPlayer._tryLoadSFL`: trước đây mỗi lần load soundfont mới, FluidSynth reset preset của MỌI channel về preset 0 của font mới → load instrument sau làm đổi âm sắc (decay/loop...) của instrument trước dù channel đã `program_select` riêng; giờ channel giữ nguyên instrument, doNote/selectInstrument tự `program_select` rõ ràng. (2) **ReferenceError `preloadTrackInstruments`**: hàm bị đặt nhầm bên trong `ProfileModal` (không cùng scope với `restoreLastSessionProject` trong `App`) → lỗi khi reload. Chuyển `preloadTrackInstruments` lên module-level (self-contained, chỉ warm font cache). Đồng thời sửa lỗi tiềm ẩn của `handleOpenProject` trong ProfileModal: truyền thêm props App-scoped (`setAppWarningModal`, `bpm`, `setBpm`, `setMasteringSettings`, `setSessionTabs`, `setSubTabs`, `trackMidiChannelsRef`) — trước đây click mở dự án trong modal sẽ ReferenceError. (3) **"No preset found on channel 9 [bank=128 prog=0]"**: lọc warning benign trong `printErr` (font không có preset bank 128 thì note chỉ câm, không phải lỗi) + reset=0 giảm nguồn sinh ra nó.
|
||||
- **Các file ảnh hưởng:** `app/static/js/services/soundfontPlayer.js`, `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (build lại), `app/templates/index.html`
|
||||
- **Ghi chú/Test (nếu có):** `npm run build` OK, bundle syntax OK. Harness `node /tmp/kilo/test_reset.js`: sfload reset=0, load B không re-select channel A, noteon ch0/ch1 không kèm program_select thừa — PASSED. 5 harness còn lại (single/multi/dedup/channels) vẫn PASS. Deprecation ScriptProcessorNode giữ nguyên (đổi sang AudioWorklet có rủi ro crackle, cần test audio thật). Hard refresh.
|
||||
|
||||
### [2026-08-03 11:50] Task: Play MIDI item luôn dùng instrument của track chứa item (kể cả item duplicate)
|
||||
- **Tóm tắt thay đổi:** Rà soát mọi đường phát MIDI trong app.jsx để chắc chắn playNote luôn nhận channel riêng của track (`assignTrackMidiChannel`) + `synth_engine` của track đó: (1) **fix bug `startLocalTrackPlayback`** (local selection loop) — trước đây gọi `playNote` KHÔNG truyền channel/synth_engine → rơi về channel 0, phát nhầm instrument của track đang giữ channel 0; giờ truyền `lcCh` + `track.synth_engine`. (2) Đồng nhất các đường còn lại (transport `startTrackPlayback`, section sub-track, `schedulePianoRollMidi`, `playMidiPreviewNote`, piano-roll canvas/click/keybed/brush preview, ghost layers) từ `getTrackMidiChannel` → `assignTrackMidiChannel` để mọi path dùng đúng dedicated channel của track. Vì playback lấy `synth_engine`/`instrumentProgram` từ track CHỨA item (item không lưu instrument, duplicate chỉ copy notes) nên khi duplicate/move MIDI item sang track khác, item sẽ tự động phát instrument đã load ở track đó.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (build lại), `app/templates/index.html`
|
||||
- **Ghi chú/Test (nếu có):** `npm run build` OK, bundle syntax OK, `getTrackMidiChannel` không còn trong app.jsx (0), `assignTrackMidiChannel` có 19 chỗ. Bundle chứa `lcCh=assignTrackMidiChannel(track,tracks)` + playNote truyền `lcCh,track.synth_engine`. 5 harness (single/multi/dedup/channels/reset) vẫn PASS. Hard refresh.
|
||||
|
||||
### [2026-08-03 12:10] Task: Fix ReferenceError assignTrackMidiChannel + bật AudioWorklet thay ScriptProcessor
|
||||
- **Tóm tắt thay đổi:** (1) **ReferenceError `assignTrackMidiChannel is not defined`**: allocator channel (trackMidiChannelsRef/ensureTrackMidiChannel/assignTrackMidiChannel) đặt bên trong `App`, nhưng các sub-component piano roll (`PianoRollTabEditor` — canvas preview, click note, draw/paint brush, keybed) là module-level → không thấy hàm → lỗi khi render/play. Chuyển allocator lên **module-level** (đầu file, trước PianoRollTabEditor), App dùng chung bản đó (bỏ khai báo `useRef` cục bộ trong App; reset `trackMidiChannelsRef.current = {}` khi load dự án vẫn dùng chung object). (2) **Deprecation ScriptProcessorNode**: bật **AudioWorklet** `fluidsynth-bridge` làm mặc định (`_useScriptNode = false`), chỉ fallback ScriptProcessor khi `new AudioWorkletNode`/`addModule` thất bại → hết warning deprecation; `_startRenderLoop` (queue 16 block × 512 sample ~186ms headroom, interval 2× frame rate) vốn đã được thiết kế cho worklet.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (build lại), `app/static/js/services/soundfontPlayer.js`, `app/templates/index.html`
|
||||
- **Ghi chú/Test (nếu có):** `npm run build` OK, bundle syntax OK, bundle load smoke test (stub React/ReactDOM/window) OK. Harness `test_sonicsf_fix.js` với mock AudioWorkletNode + performance: `workletCreated=1 scriptProcCreated=0` (worklet được dùng), S1-S5 vẫn PASS; trước đó (thiếu mock performance) xác nhận fallback ScriptProcessor hoạt động. 4 harness còn lại vẫn PASS. Hard refresh.
|
||||
|
||||
### [2026-08-03 12:30] Task: Fix crash AudioWorklet - "Cannot set properties of undefined (setting '0')" khi output mono
|
||||
- **Tóm tắt thay đổi:** `FluidSynthBridge.process` luôn ghi stereo `out[0]`/`out[1]`; khi thiết bị/context xuất mono (chỉ 1 channel), `out[1]` là `undefined` → `out[1][i] = ...` ném TypeError, dừng audio. Fix: (1) `fluidsynth-bridge.js` xử lý mọi số channel output (vòng lặp theo `out.length`, chẵn=lấy L, lẻ=lấy R, trường hợp cạn queue ghi 0 cho từng channel); (2) `soundfontPlayer.js` tạo `AudioWorkletNode` với `outputChannelCount: [2]`, `channelCount: 2`, `channelCountMode: 'explicit'` để ép stereo (FluidSynth render stereo) thay vì để device quyết định.
|
||||
- **Các file ảnh hưởng:** `app/static/js/worklets/fluidsynth-bridge.js`, `app/static/js/services/soundfontPlayer.js`, `app/templates/index.html`
|
||||
- **Ghi chú/Test (nếu có):** Harness `node /tmp/kilo/test_worklet.js` mô phỏng process(): (1) output mono 1 channel → mirror left, không crash; (2) stereo → L/R đúng; (3) queue cạn → silence. Tất cả PASS. 5 harness khác vẫn PASS. Worklet + soundfontPlayer `node --check` OK. Không cần build bundle (2 file served trực tiếp). Hard refresh.
|
||||
|
||||
### [2026-08-03 12:45] Task: Fix không có âm thanh khi play MIDI item - quay lại ScriptProcessor làm renderer mặc định
|
||||
- **Tóm tắt thay đổi:** Sau khi bật AudioWorklet làm mặc định, audio không phát khi play MIDI item trên track (môi trường user bị 2 lỗi liên tiếp: crash mono rồi câm). ScriptProcessor là đường đã được xác nhận hoạt động ổn định (mọi test trước đó). Quay lại `_useScriptNode = true` (ScriptProcessor mặc định) để đảm bảo âm thanh; giữ code AudioWorklet (đã mono-safe) + thêm cache-busting `?v=202608031240` cho URL `addModule` để khi bật lại không bị cache bản cũ. Deprecation warning quay lại nhưng chỉ là cảnh báo cosmetic.
|
||||
- **Các file ảnh hưởng:** `app/static/js/services/soundfontPlayer.js`, `app/templates/index.html`
|
||||
- **Ghi chú/Test (nếu có):** Harness `node /tmp/kilo/test_render.js` (path ScriptProcessor): workletCreated=0, ScriptProcessor được tạo, playNote → noteon, onaudioprocess render ra audio peak 0.25 — ALL PASSED. `test_sonicsf_fix.js` cập nhật assertion ScriptProcessor mặc định → rc=0. 6 harness còn lại PASS (test_worklet rc báo 1 do glitch shell nhưng result file ghi ALL TESTS PASSED). Không cần build bundle (file served trực tiếp). Hard refresh.
|
||||
|
||||
### [2026-08-03 13:00] Task: Fix piano roll tab không phát âm thanh instrument của track
|
||||
- **Tóm tắt thay đổi:** `schedulePianoRollMidi` cộng `sessionBeatOffset` (vị trí tuyệt đối của item trong project, đơn vị beat) vào `start_beat` của mọi note → note được lên lịch ở **thời điểm tuyệt đối** của project, trong khi playhead/tab piano roll là **item-relative** (0 = đầu item). Item đặt sau vị trí 0 (vd 10s) → nhấn play nghe không có âm thanh trong 10s (note được schedule ở now+10s), sau đó mới kêu → cảm giác "không có âm thanh instrument". Fix: bỏ `sessionBeatOffset` ở cả loop note chính và loop ghost (ghost `relative_start_beat` vốn đã item-relative) — note phát theo vị trí tương đối item, khớp playhead; item ở vị trí 0 không đổi hành vi. `sessionSyncMode` chỉ là chế độ hiển thị ghost/context, không liên quan timing phát.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (build lại), `app/templates/index.html`
|
||||
- **Ghi chú/Test (nếu có):** `npm run build` OK, bundle syntax OK, hết `sessionBeatOffset` (0 chỗ). Harness `node /tmp/kilo/test_pr_timing.js` mô phỏng timing mới: item ở project 10s, nhấn play mới → note đầu phát ngay +0s (trước đây +10s), note 2 ở +2s; resume 1.2s → bỏ note đầu, schedule note sau; item vị trí 0 không đổi — ALL PASSED. 6 harness còn lại PASS. Hard refresh.
|
||||
|
||||
### [2026-08-03 13:15] Task: Fix mất soundfont khi ARM + BiquadFilterNode state is bad
|
||||
- **Tóm tắt thay đổi:** (1) **Master chain bị NaN**: `applyMasteringSettings` chạy mỗi lần `getAudioContext()`; nếu `window.currentMasteringSettings` bị thiếu field/NaN (vd project lưu cũ, slider kéo cực hạn) thì `eqLowFilter.gain.setTargetAtTime(undefined/NaN)` → biquad "state is bad" → master chain im lặng → mọi audio (kể cả MIDI/ARM) biến mất ("mất soundfont"). Thêm `clamp()` chặn mọi tham số EQ (±24dB), imager (±100), maximizer, ceiling — NaN/thiếu → 0 (trung tính), cực hạn → giới hạn an toàn. (2) `toggleMasteringOnMaster` trở nên idempotent (chỉ disconnect/reconnect khi trạng thái đổi) — trước đây gọi lại liên tục từ `getAudioContext()` gây reconnect nhanh → biquad mất ổn định. (3) **loadSoundFont không cache lỗi vĩnh viễn**: lỗi tải thoáng qua (mạng 503, áp lực bộ nhớ) trước đây bị giữ trong `_loadPromises` → instrument câm vĩnh viễn tới khi reload; giờ xóa cache lỗi để note kế tiếp thử lại và tự hồi phục.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (build lại), `app/static/js/services/soundfontPlayer.js`, `app/templates/index.html`
|
||||
- **Ghi chú/Test (nếu có):** `npm run build` OK, bundle syntax OK. Harness `node /tmp/kilo/test_retry.js`: clamp undefined/NaN → 0, cực hạn → giới hạn; load font lỗi 503 lần 1 → lần 2 retry thành công (2 fetches) → note phát được — ALL PASSED. 8 harness còn lại PASS. Deprecation ScriptProcessorNode vẫn còn (giữ ScriptProcessor vì ổn định, worklet từng gây lỗi thiết bị). Hard refresh.
|
||||
|
||||
### [2026-08-03 13:30] Task: Hết ScriptProcessor deprecation (bật AudioWorklet) + hết BiquadFilterNode state is bad (signature guard)
|
||||
- **Tóm tắt thay đổi:** (1) **Deprecation**: bật lại AudioWorklet làm renderer mặc định (`_useScriptNode = false`) — worklet giờ đã mono-safe + ép stereo `outputChannelCount:[2]` + URL `addModule` có cache-busting `?v=` nên luôn nạp bản đã sửa; fallback ScriptProcessor khi tạo worklet thất bại. Trước đây "mất tiếng khi transport" là do worklet cũ (pre-mono-fix) bị cache. (2) **BiquadFilterNode: state is bad**: `applyMasteringSettings` được gọi mỗi lần `getAudioContext()` (stopAll/play/VU…), liên tục `setTargetAtTime` vào EQ biquad = "fast parameter automation" → Chromium báo state bad dù giá trị hợp lệ. Thêm **signature guard**: chỉ áp dụng khi giá trị thực sự đổi → bỏ chùm automation lặp, giữ clamp chống NaN. `toggleMasteringOnMaster` vẫn idempotent.
|
||||
- **Các file ảnh hưởng:** `app/static/js/services/soundfontPlayer.js`, `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (build lại), `app/templates/index.html`
|
||||
- **Ghi chú/Test (nếu có):** `npm run build` OK. Harness `test_render.js` (worklet mặc định): workletCreated=1 scriptProcCreated=0, noteon=1, 26 PCM frames posted, peak 0.25 — ALL PASSED. `test_sonicsf_fix.js` cập nhật assertion worklet → PASS. 8 harness còn lại PASS. Hard refresh (Ctrl+F5) — lần đầu sẽ tải worklet mới. Nếu nghe crackle/mất tiếng khi phát, báo lại để quay lại ScriptProcessor.
|
||||
|
||||
### [2026-08-03 13:40] Task: Fix Lỗi không có âm thanh - quay lại ScriptProcessor (quyết định cuối)
|
||||
- **Tóm tắt thay đổi:** Bật AudioWorklet lần 2 vẫn làm mất tiếng trên máy user. Nguyên nhân thực sự: worklet là push model (`setInterval` trên main thread) — khi main thread bận (load font/WASM decode/UI), vòng lặp nghẽn → worklet hết frame → câm. ScriptProcessor là pull-based (`onaudioprocess` do audio thread gọi) nên không bao giờ đói dữ liệu. **Quyết định cuối: giữ ScriptProcessor làm renderer mặc định vĩnh viễn** (`_useScriptNode = true`); warning deprecation chỉ cosmetic. Worklet (đã mono-safe + stereo-forced + cache-busting) giữ lại như opt-in để test sau.
|
||||
- **Các file ảnh hưởng:** `app/static/js/services/soundfontPlayer.js`, `app/templates/index.html`
|
||||
- **Ghi chú/Test (nếu có):** Harness `test_render.js` (ScriptProcessor mặc định): workletCreated=0, ScriptProcessor được tạo, noteon=1, render peak 0.25 — ALL PASSED. `test_sonicsf_fix.js` assertion ScriptProcessor → PASS. 8 harness còn lại PASS. Hard refresh (Ctrl+F5) — âm thanh hoạt động lại.
|
||||
|
||||
### [2026-08-03 13:45] Task: Fix mất âm thanh khi nhấp đôi MIDI item vào Piano Roll
|
||||
- **Tóm tắt thay đổi:** `handleEditMidiInTab` (nhấp đôi MIDI item → mở PIANO ROLL tab) preload instrument bằng channel cũ `trkIdx % 16`, trong khi MỌI đường phát (schedulePianoRollMidi, transport, ARM) dùng dedicated channel (`assignTrackMidiChannel`). Channel lệch nhau → tab-open `selectInstrument` cấu hình nhầm channel (có thể đè instrument của track khác nếu trùng channel 0-15), khiến âm thanh trong/pianô sau khi mở tab không còn đúng. Sửa: dùng `assignTrackMidiChannel(track, allTrks)` cho preload khi mở tab → khớp đúng channel phát.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (build lại), `app/templates/index.html`
|
||||
- **Ghi chú/Test (nếu có):** `npm run build` OK, bundle syntax OK. Harness `node /tmp/kilo/test_dbl.js` mô phỏng toàn bộ flow double-click (init → tab-open selectInstrument → schedulePianoRollMidi playNote → ScriptProcessor render): noteon đúng channel, render peak 0.25 (không câm) — ALL PASSED. 9 harness còn lại PASS. Nếu vẫn mất âm thanh sau hard refresh, cần xác định rõ: câm toàn cục hay chỉ play trong piano roll, và có console error gì.
|
||||
|
||||
### [2026-08-03 14:00] Task: Fix câm toàn cục sau nhấp đôi MIDI item + BiquadFilterNode state is bad (triệt để)
|
||||
- **Tóm tắt thay đổi:** User xác nhận: sau nhấp đôi MIDI item vào Piano Roll, câm TOÀN CỤC (main cũng hết tiếng) + lỗi `BiquadFilterNode: state is bad` vẫn còn. Nguyên nhân: master chain (EQ/imager biquad) bị `toggleMasteringOnMaster` + `applyMasteringSettings` gọi lại **mỗi lần** `getAudioContext()` (play, stopAll, double-click, VU...) — burst "fast parameter automation" vào biquad làm Chromium báo state bad, và nếu routing master bị ngắt giữa chừng → câm toàn cục. Fix triệt để: (1) **gỡ mastering khỏi getAudioContext** — master chain chỉ được áp dụng 1 lần khi `initMasterBus` tạo (nếu có `currentMasteringSettings`) + qua React effect khi settings đổi; (2) **time constant 0.01 → 0.05** + `cancelScheduledValues` trước mỗi `setTargetAtTime` EQ — hết tích tụ automation event khi kéo slider; (3) `toggleMasteringOnMaster` bọc try/catch, **luôn reconnect lại routing hợp lệ** dù lỗi — không bao giờ để inputAnalyser bị ngắt không nối (gây câm toàn cục).
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (build lại), `app/templates/index.html`
|
||||
- **Ghi chú/Test (nếu có):** `npm run build` OK, bundle syntax OK, `toggleMasteringOnMaster` còn 3 chỗ (def + initMasterBus + effect). 9 harness (single/multi/dedup/reset/channels/render/pr_timing/retry/dbl) vẫn PASS. Hard refresh (Ctrl+F5). Nếu lỗi biquad vẫn hiện khi bật MasteringModal và kéo EQ, đó có thể là false-positive Chromium (parallel biquad) — báo tôi để tôi đổi cấu trúc imager/EQ.
|
||||
|
||||
### [2026-08-03 14:15] Task: Fix câm toàn cục - IN peak có, OUT peak không (master chain chết)
|
||||
- **Tóm tắt thay đổi:** User chẩn đoán: mở Mastering modal, IN peak có tín hiệu nhưng OUT peak trống → tín hiệu chết TRONG master chain. Khắc phục triệt để 3 nguyên nhân có thể làm chain câm: (1) **WaveShaper `curve = null`**: một số engine xuất CÂM khi curve null (identity) — đổi luôn sang identity table `Float32Array([-1,1])` (passthrough chủ động, không bao giờ null) ở cả init và khi maximizer tắt. (2) **Tần số filter vượt Nyquist**: `eqHighFilter` 10000Hz / imager crossover 6000Hz trên thiết bị sample rate thấp (8/11/16kHz) → hệ số biquad NaN → `BiquadFilterNode: state is bad` → chain câm. Thêm `clampF(v) = min(v, sampleRate*0.45)` cho mọi biquad. (3) **Watchdog an toàn**: trong Mastering modal, nếu `masteringActive` mà IN peak > 0.01 còn OUT peak < 0.001 (chain hỏng) → tự `toggleMasteringOnMaster(false)` về routing trực tiếp để âm thanh KHÔNG BAO GIỜ bị câm toàn cục.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (build lại), `app/templates/index.html`
|
||||
- **Ghi chú/Test (nếu có):** `npm run build` OK, bundle chứa `maxFilterFreq`, `Chain broken`, `Float32Array([-1,1])`. 9 harness vẫn PASS. Hard refresh (Ctrl+F5) → thử play (main + piano roll) + bật mastering. Nếu OUT peak vẫn trống, watchdog sẽ tự bypass và log `[Mastering] Chain broken...` — báo tôi message đó.
|
||||
|
||||
Reference in New Issue
Block a user