fix(piano-roll): playhead seek + sound cracking

- Piano roll ruler click during playback now seeks and continues playing
- ADSR envelope uses linearRampToValueAtTime for crack-free release
- stopAll ramps gain to 0 in 20ms before stopping oscillators
This commit is contained in:
2026-07-26 12:03:29 +07:00
parent 1ffdec68c6
commit 4e880850f5
4 changed files with 69 additions and 46 deletions
+47 -33
View File
@@ -4543,7 +4543,7 @@ const AIPresetModal = ({ isOpen, onClose }) => {
}, "Đóng")))); }, "Đóng"))));
}; };
const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNotes, onSaveNotes, setSubTabs, onPlayPause, onStop, isPlaying, playPreviewNote, showToast, midiDevices, recordingState, recTempMidiNotes, onRecord, selectedMidiInputId, onMidiInputSelect, activeMidiPitches, onInstrumentSelect, onRescheduleMidi }) => { const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNotes, onSaveNotes, setSubTabs, onPlayPause, onStop, isPlaying, playPreviewNote, showToast, midiDevices, recordingState, recTempMidiNotes, onRecord, selectedMidiInputId, onMidiInputSelect, activeMidiPitches, onInstrumentSelect, onRescheduleMidi, onSeekPlayhead }) => {
const [activeRollTool, setActiveRollTool] = React.useState('select'); const [activeRollTool, setActiveRollTool] = React.useState('select');
const [snapVal, setSnapVal] = React.useState('1/16'); const [snapVal, setSnapVal] = React.useState('1/16');
const [ccMode, setCcMode] = React.useState('velocity'); const [ccMode, setCcMode] = React.useState('velocity');
@@ -5875,13 +5875,10 @@ const beatSec = 60.0 / (parseInt(bpm) || 120);
return; return;
} }
if (clickTime >= 0) { if (clickTime >= 0) {
setSubTabs(prev => prev.map(s => s.id === st.id ? { ...s, currentTime: clickTime, isPlaying: false } : s)); if (onSeekPlayhead) {
if (isPlaying) { onSeekPlayhead(clickTime);
onStop(); } else {
setTimeout(() => { setSubTabs(prev => prev.map(s => s.id === st.id ? { ...s, currentTime: clickTime } : s));
setSubTabs(prev => prev.map(s => s.id === st.id ? { ...s, currentTime: clickTime } : s));
onPlayPause();
}, 200);
} }
} }
const snappedStartBeat = getSnapBeat(clickBeat, snapVal); const snappedStartBeat = getSnapBeat(clickBeat, snapVal);
@@ -15819,31 +15816,48 @@ const App = () => {
activeMidiPitches: activeMidiPitches, activeMidiPitches: activeMidiPitches,
onInstrumentSelect: (trackId) => { openInstrumentSelector(trackId); }, onInstrumentSelect: (trackId) => { openInstrumentSelector(trackId); },
onRescheduleMidi: (updatedNotes) => { onRescheduleMidi: (updatedNotes) => {
const playingSub = subTabs.find(s => s.id === activeTab && s.type === 'PIANO_ROLL' && s.isPlaying); const playingSub = subTabs.find(s => s.id === activeTab && s.type === 'PIANO_ROLL' && s.isPlaying);
if (playingSub) { if (playingSub) {
const offset = playingSub.currentTime || 0; const offset = playingSub.currentTime || 0;
const ctx = getAudioContext(); const ctx = getAudioContext();
const tNode = activeTrackNodesRef.current[playingSub.trackId]; const tNode = activeTrackNodesRef.current[playingSub.trackId];
if (tNode && tNode.gainNode) { if (tNode && tNode.gainNode) {
tNode.gainNode.gain.setValueAtTime(tNode.gainNode.gain.value || 1, ctx.currentTime); tNode.gainNode.gain.setValueAtTime(tNode.gainNode.gain.value || 1, ctx.currentTime);
tNode.gainNode.gain.linearRampToValueAtTime(0.001, ctx.currentTime + 0.04); tNode.gainNode.gain.linearRampToValueAtTime(0.001, ctx.currentTime + 0.04);
} }
setTimeout(() => { setTimeout(() => {
window.SonicSF.stopAll(); window.SonicSF.stopAll();
if (tNode && tNode.gainNode) { if (tNode && tNode.gainNode) {
const trackData = activeTracksRef.current ? activeTracksRef.current.find(t => t.id === playingSub.trackId) : null; const trackData = activeTracksRef.current ? activeTracksRef.current.find(t => t.id === playingSub.trackId) : null;
const volDb = trackData ? (trackData.volumeDb ?? 0) : 0; const volDb = trackData ? (trackData.volumeDb ?? 0) : 0;
const volLinear = volDb <= -50 ? 0 : Math.pow(10, volDb / 20); const volLinear = volDb <= -50 ? 0 : Math.pow(10, volDb / 20);
tNode.gainNode.gain.setValueAtTime(0.001, ctx.currentTime); tNode.gainNode.gain.setValueAtTime(0.001, ctx.currentTime);
tNode.gainNode.gain.linearRampToValueAtTime(volLinear || 0.8, ctx.currentTime + 0.015); tNode.gainNode.gain.linearRampToValueAtTime(volLinear || 0.8, ctx.currentTime + 0.015);
} }
startOffsetTimeRef.current = offset; startOffsetTimeRef.current = offset;
startAudioTimeRef.current = ctx.currentTime; startAudioTimeRef.current = ctx.currentTime;
startBufferOffsetRef.current = offset * (playingSub.speed || 1.0); startBufferOffsetRef.current = offset * (playingSub.speed || 1.0);
schedulePianoRollMidi(playingSub, offset, updatedNotes); schedulePianoRollMidi(playingSub, offset, updatedNotes);
}, 50); }, 50);
} }
} },
onSeekPlayhead: (clickTime) => {
const seekSt = subTabs.find(s => s.id === activeTab && s.type === 'PIANO_ROLL');
if (!seekSt) return;
if (seekSt.isPlaying) {
stopAllPlayback();
window.SonicSF.stopAll();
setSubTabs(prev => prev.map(s => s.id === seekSt.id ? { ...s, currentTime: clickTime, isPlaying: true } : s));
const ctx = getAudioContext();
startOffsetTimeRef.current = clickTime;
startAudioTimeRef.current = ctx.currentTime;
startBufferOffsetRef.current = clickTime * (seekSt.speed || 1.0);
schedulePianoRollMidi(seekSt, clickTime);
startSubTabPlayback(seekSt, clickTime);
} else {
setSubTabs(prev => prev.map(s => s.id === seekSt.id ? { ...s, currentTime: clickTime } : s));
}
}
}); });
} }
const subTrack = tracks.find(t => t.id === st.trackId); const subTrack = tracks.find(t => t.id === st.trackId);
File diff suppressed because one or more lines are too long
+14 -10
View File
@@ -125,7 +125,7 @@
} }
osc.type = oscType; osc.type = oscType;
osc.frequency.value = freq; osc.frequency.setValueAtTime(freq, 0);
const startAt = startTime !== undefined ? startTime : ctx.currentTime; const startAt = startTime !== undefined ? startTime : ctx.currentTime;
const durSec = durationMs / 1000; const durSec = durationMs / 1000;
@@ -139,9 +139,8 @@
noteGain.gain.linearRampToValueAtTime(targetGain * sustainLevel, startAt + attackTime + decayTime); noteGain.gain.linearRampToValueAtTime(targetGain * sustainLevel, startAt + attackTime + decayTime);
const releaseStart = startAt + Math.max(attackTime + decayTime, durSec); const releaseStart = startAt + Math.max(attackTime + decayTime, durSec);
noteGain.gain.setValueAtTime(targetGain * sustainLevel, releaseStart); noteGain.gain.linearRampToValueAtTime(targetGain * sustainLevel, releaseStart);
const rampEnd = Math.max(0.001, targetGain * sustainLevel * 0.01); noteGain.gain.linearRampToValueAtTime(0, releaseStart + releaseTime);
noteGain.gain.exponentialRampToValueAtTime(rampEnd, releaseStart + releaseTime);
osc.connect(noteGain); osc.connect(noteGain);
@@ -150,7 +149,7 @@
osc.start(startAt); osc.start(startAt);
const stopAt = releaseStart + releaseTime + 0.05; const stopAt = releaseStart + releaseTime + 0.02;
osc.stop(stopAt); osc.stop(stopAt);
const oscId = `${note}_${Date.now()}_${Math.random()}`; const oscId = `${note}_${Date.now()}_${Math.random()}`;
@@ -166,17 +165,22 @@
stopAll: function () { stopAll: function () {
const ctx = getCtx(); const ctx = getCtx();
const now = ctx.currentTime;
Object.values(activeOscillators).forEach(entry => { Object.values(activeOscillators).forEach(entry => {
try { try {
if (entry.gain) { if (entry.gain) {
entry.gain.gain.cancelScheduledValues(ctx.currentTime); entry.gain.gain.cancelScheduledValues(now);
entry.gain.gain.setValueAtTime(entry.gain.gain.value || 1, ctx.currentTime); entry.gain.gain.setValueAtTime(entry.gain.gain.value || 0.8, now);
entry.gain.gain.linearRampToValueAtTime(0.001, ctx.currentTime + 0.008); entry.gain.gain.linearRampToValueAtTime(0, now + 0.02);
}
if (entry.osc) {
try { entry.osc.stop(now + 0.025); } catch (e) { }
} }
if (entry.osc) entry.osc.stop(ctx.currentTime + 0.01);
} catch (e) { } } catch (e) { }
}); });
Object.keys(activeOscillators).forEach(k => delete activeOscillators[k]); setTimeout(() => {
Object.keys(activeOscillators).forEach(k => delete activeOscillators[k]);
}, 50);
}, },
// Save user SoundFont to IndexedDB via window.SonicStorage // Save user SoundFont to IndexedDB via window.SonicStorage
+5
View File
@@ -255,3 +255,8 @@
- **Tóm tắt thay đổi:** (1) Ruler: cursor default, click trong range → drag move (hand cursor → move), click ngoài range → seek/drag tạo selection mới. (2) `soundfontPlayer.js`: `activeOscillators` lưu `{osc, gain}` thay vì `osc`; `stopAll` ramp gain về 0 trong 8ms trước khi `osc.stop(10ms)` — loại bỏ crackling khi realtime update. Cũng xóa `setIsLooping(true)` trong ruler drag cũ. - **Tóm tắt thay đổi:** (1) Ruler: cursor default, click trong range → drag move (hand cursor → move), click ngoài range → seek/drag tạo selection mới. (2) `soundfontPlayer.js`: `activeOscillators` lưu `{osc, gain}` thay vì `osc`; `stopAll` ramp gain về 0 trong 8ms trước khi `osc.stop(10ms)` — loại bỏ crackling khi realtime update. Cũng xóa `setIsLooping(true)` trong ruler drag cũ.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/services/soundfontPlayer.js` - **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/services/soundfontPlayer.js`
- **Ghi chú/Test (nếu có):** `npm run build` pass. - **Ghi chú/Test (nếu có):** `npm run build` pass.
### [2026-07-26 11:56] Task: Fix Piano Roll playhead seek + sound cracking
- **Tóm tắt thay đổi:** (1) Click ruler khi Piano Roll đang play → seek đến vị trí mới và tiếp tục play (không dừng). (2) ADSR envelope trong `soundfontPlayer.js` dùng `linearRampToValueAtTime` thay `setValueAtTime`/`exponentialRampToValueAtTime` để loại bỏ gain jump gây crackling. `stopAll` ramp gain về 0 trong 20ms trước khi stop oscillator.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/services/soundfontPlayer.js`, `app/static/js/app.precompiled.js`
- **Ghi chú/Test (nếu có):** `npm run build` pass.