feat: CC11 expression automation (spec midi_note_cc11_expression)

- MIDINote schema + expression_curve (position_ratio 0..1, value 0..127)
- Piano roll CC lane mode Expression (CC11): draw/move/del anchors
- Offline: _expression_events grid 64 samples -> job.events -> RenderJob.cpp sample-accurate CC
- Realtime: scheduleNoteExpressionCC11 grid 16ms via bridge pushEvent / SonicSF fallback, epoch-guarded, cleared on stop
- tests/test_cc11_expression.py 6/6 (unit interp/grid/clamp + integration energy follows curve)
- rebuild daw_vst_bridge (narrowing fix) + sync binaries/dist, babel rebuild + sync 5 copies
This commit is contained in:
2026-08-26 11:14:56 +07:00
parent c146d3eb14
commit 2c96a223af
11 changed files with 482 additions and 32 deletions
+237 -22
View File
@@ -719,6 +719,62 @@ function sendMidiVolumeCC7(t, list, audible) {
} catch (e) {}
}
// ── CC11 expression (realtime) ──────────────────────────────────────────
// Mỗi note có expression_curve → chuỗi CC11 theo grid 16ms. Timer đăng ký
// vào _cc11Timers để stopAllPlayback hủy sạch; epoch guard (_nativePlayEpoch)
// chống timer stale sau stop/loop restart (pattern scheduleMidiNoteDispatch).
let _cc11Timers = new Set();
const clearAllCC11Timers = () => {
_cc11Timers.forEach(t => { try { clearTimeout(t); } catch (e) {} });
_cc11Timers.clear();
};
const _cc11ValueAt = (curve, ratio) => {
if (!curve || !curve.length) return 127;
if (curve.length === 1) return curve[0].value;
const pts = curve.slice().sort((a, b) => a.position_ratio - b.position_ratio);
if (ratio <= pts[0].position_ratio) return pts[0].value;
if (ratio >= pts[pts.length - 1].position_ratio) return pts[pts.length - 1].value;
for (let i = 1; i < pts.length; i++) {
if (ratio <= pts[i].position_ratio) {
const a = pts[i - 1], b = pts[i];
const t = (ratio - a.position_ratio) / (b.position_ratio - a.position_ratio || 1);
return Math.round(a.value + (b.value - a.value) * t);
}
}
return pts[pts.length - 1].value;
};
// ponytail: duplicate interp cc11ValueAt (component) — consolidate khi curve
// thành field track-level.
const scheduleNoteExpressionCC11 = (track, note, ch, startWallTime, startSec, durSec) => {
try {
const curve = Array.isArray(note && note.expression_curve) ? note.expression_curve : [];
if (!curve || !curve.length) return;
const epoch = _nativePlayEpoch;
const isPerc = !!(track && track.synth_engine && track.synth_engine.soundfont_bank === 128) || !!(track && track.is_percussion);
const ctx = getAudioContext();
const GRID_MS = 16;
const n = Math.max(1, Math.ceil((durSec * 1000) / GRID_MS));
const stepMs = (durSec * 1000) / n;
for (let i = 0; i <= n; i++) {
const ratio = Math.min(1, i / n);
const wallMs = (startWallTime + startSec) * 1000 + i * stepMs;
const delayMs = Math.max(0, wallMs - ctx.currentTime * 1000);
const val = _cc11ValueAt(curve, ratio);
const timer = setTimeout(() => {
if (epoch !== _nativePlayEpoch) return;
if (window.SonicMidiRouter && window.SonicMidiRouter.isBridgeActive && window.SonicMidiRouter.isBridgeActive()) {
window.SonicMidiRouter.pushEvent({ cmd: 'CC', channel: track ? track.id : 0, pitch: 11, velocity: 0, data2: val, percussion: isPerc });
return;
}
if (window.SonicSF && typeof window.SonicSF.controllerChange === 'function') {
window.SonicSF.controllerChange(ch, 11, val);
}
}, delayMs);
_cc11Timers.add(timer);
}
} catch (e) { console.warn('[CC11] schedule error:', e); }
};
// MAIN SESSION end-time (seconds): endtime of the items ON the session's own
// tracks only — audio clips, MIDI items, and section-item bounds. Section-TAB
// content is deliberately IGNORED here: when played inside the main session the
@@ -9595,29 +9651,67 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, activeTracks, onClos
ctx.lineTo(viewWidth, h / 2);
ctx.stroke();
notes.forEach((note) => {
const x = (renderBeatOffset + note.start_beat) * pixelsPerBeat;
const isSelected = selectedNoteIds.includes(note.id);
let val = note.velocity !== undefined ? note.velocity : 0.8;
if (ccMode === 'pan') {
val = (note.pan !== undefined ? note.pan : 0.0) * 0.5 + 0.5;
}
if (ccMode === 'cc11') {
// CC11 expression: curve anchors {position_ratio 0..1, value 0..127}
// ve cho note dang chon. Rong -> flat 127 (khong automation).
const yOf = v => h - ((v / 127) * (h - 20) + 10);
notes.forEach((note) => {
if (!selectedNoteIds.includes(note.id)) return;
const x0 = (renderBeatOffset + note.start_beat) * pixelsPerBeat;
const x1 = (renderBeatOffset + note.start_beat + (note.duration_beats || 1)) * pixelsPerBeat;
const curve = (Array.isArray(note.expression_curve) ? note.expression_curve : []).slice().sort((a, b) => a.position_ratio - b.position_ratio);
ctx.strokeStyle = '#34d399';
ctx.lineWidth = 2;
ctx.beginPath();
if (!curve.length) {
ctx.setLineDash([4, 4]);
ctx.moveTo(x0, yOf(127));
ctx.lineTo(x1, yOf(127));
ctx.setLineDash([]);
} else if (curve.length === 1) {
ctx.moveTo(x0, yOf(curve[0].value));
ctx.lineTo(x1, yOf(curve[0].value));
} else {
curve.forEach((p, i) => {
const x = x0 + p.position_ratio * (x1 - x0);
const y = yOf(p.value);
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
});
}
ctx.stroke();
curve.forEach((p) => {
const x = x0 + p.position_ratio * (x1 - x0);
ctx.fillStyle = '#a7f3d0';
ctx.beginPath();
ctx.arc(x, yOf(p.value), 4, 0, 2 * Math.PI);
ctx.fill();
});
});
} else {
notes.forEach((note) => {
const x = (renderBeatOffset + note.start_beat) * pixelsPerBeat;
const isSelected = selectedNoteIds.includes(note.id);
let val = note.velocity !== undefined ? note.velocity : 0.8;
if (ccMode === 'pan') {
val = (note.pan !== undefined ? note.pan : 0.0) * 0.5 + 0.5;
}
const stemH = val * (h - 20) + 10;
const y = h - stemH;
const stemH = val * (h - 20) + 10;
const y = h - stemH;
ctx.strokeStyle = ccMode === 'pan' ? (isSelected ? '#60a5fa' : '#a78bfa') : (isSelected ? '#3b82f6' : '#fbbf24');
ctx.lineWidth = 2.5;
ctx.beginPath();
ctx.moveTo(x, h);
ctx.lineTo(x, y);
ctx.stroke();
ctx.strokeStyle = ccMode === 'pan' ? (isSelected ? '#60a5fa' : '#a78bfa') : (isSelected ? '#3b82f6' : '#fbbf24');
ctx.lineWidth = 2.5;
ctx.beginPath();
ctx.moveTo(x, h);
ctx.lineTo(x, y);
ctx.stroke();
ctx.fillStyle = ccMode === 'pan' ? (isSelected ? '#3b82f6' : '#c084fc') : (isSelected ? '#3b82f6' : '#fbbf24');
ctx.beginPath();
ctx.arc(x, y, 3.5, 0, 2 * Math.PI);
ctx.fill();
});
ctx.fillStyle = ccMode === 'pan' ? (isSelected ? '#3b82f6' : '#c084fc') : (isSelected ? '#3b82f6' : '#fbbf24');
ctx.beginPath();
ctx.arc(x, y, 3.5, 0, 2 * Math.PI);
ctx.fill();
});
}
}, [notes, ccMode, rollZoom, viewWidth, selectedNoteIds, renderBeatOffset, ccHeight, showCC]);
// Follow playhead: khi PLAY — playhead luôn ở GIỮA view, notes trôi sang
@@ -10353,7 +10447,103 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, activeTracks, onClos
return out;
};
// ── CC11 EXPRESSION CURVE (piano roll lane) ──
const getCC11Curve = (n) => (Array.isArray(n && n.expression_curve) ? n.expression_curve : []);
const cc11ValueAt = (curve, ratio) => {
if (!curve || !curve.length) return 127;
if (curve.length === 1) return curve[0].value;
const pts = curve.slice().sort((a, b) => a.position_ratio - b.position_ratio);
if (ratio <= pts[0].position_ratio) return pts[0].value;
if (ratio >= pts[pts.length - 1].position_ratio) return pts[pts.length - 1].value;
for (let i = 1; i < pts.length; i++) {
if (ratio <= pts[i].position_ratio) {
const a = pts[i - 1], b = pts[i];
const t = (ratio - a.position_ratio) / (b.position_ratio - a.position_ratio || 1);
return Math.round(a.value + (b.value - a.value) * t);
}
}
return pts[pts.length - 1].value;
};
const findCC11NoteAtBeat = (b) => {
const snapped = getSnapBeat(b, snapValue);
let best = -1, bestDist = Infinity;
notes.forEach((n, idx) => {
if (snapped >= n.start_beat && snapped <= n.start_beat + (n.duration_beats || 1)) {
const d = Math.min(Math.abs(snapped - n.start_beat), Math.abs(snapped - (n.start_beat + (n.duration_beats || 1))));
if (d < bestDist) { bestDist = d; best = idx; }
}
});
return best;
};
const handleCC11MouseDown = (e) => {
const canvas = ccCanvasRef.current;
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const h = rect.height;
const beat = x / pixelsPerBeat - renderBeatOffset;
const noteIdx = findCC11NoteAtBeat(beat);
if (noteIdx === -1) { ccDragRef.current = null; return; }
const note = notes[noteIdx];
const dur = note.duration_beats || 1;
const ratio = Math.max(0, Math.min(1, (beat - note.start_beat) / dur));
const val = Math.max(0, Math.min(127, Math.round(((h - y) / h) * 127)));
const curve = getCC11Curve(note).slice();
const x0 = (renderBeatOffset + note.start_beat) * pixelsPerBeat;
const x1 = x0 + dur * pixelsPerBeat;
const yOf = v => h - ((v / 127) * (h - 20) + 10);
let anchorIdx = -1;
curve.forEach((p, i) => {
const ax = x0 + p.position_ratio * (x1 - x0);
const ay = yOf(p.value);
if (Math.hypot(ax - x, ay - y) <= 8) anchorIdx = i;
});
if (anchorIdx === -1) {
const dup = curve.findIndex(p => Math.abs(p.position_ratio - ratio) < 1e-6);
if (dup !== -1) { curve[dup] = { position_ratio: ratio, value: val }; anchorIdx = dup; }
else { curve.push({ position_ratio: ratio, value: val }); anchorIdx = curve.length - 1; }
} else {
curve[anchorIdx] = { position_ratio: ratio, value: val };
}
const updatedNotes = notes.map((n, i) => (i === noteIdx ? { ...n, expression_curve: curve } : n));
setNotes(updatedNotes);
if (isPlaying && onRescheduleMidi) onRescheduleMidi(updatedNotes);
ccDragRef.current = { mode: 'cc11', active: true, noteIdx, anchorIdx, noteStart: note.start_beat, noteDur: dur };
};
const handleCC11ContextMenu = (e) => {
if (ccMode !== 'cc11') return;
const canvas = ccCanvasRef.current;
if (!canvas) return;
e.preventDefault();
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const h = rect.height;
const beat = x / pixelsPerBeat - renderBeatOffset;
const noteIdx = findCC11NoteAtBeat(beat);
if (noteIdx === -1) return;
const note = notes[noteIdx];
const dur = note.duration_beats || 1;
const x0 = (renderBeatOffset + note.start_beat) * pixelsPerBeat;
const x1 = x0 + dur * pixelsPerBeat;
const yOf = v => h - ((v / 127) * (h - 20) + 10);
const curve = getCC11Curve(note).slice();
let delIdx = -1, delDist = Infinity;
curve.forEach((p, i) => {
const ax = x0 + p.position_ratio * (x1 - x0);
const ay = yOf(p.value);
const d = Math.hypot(ax - x, ay - y);
if (d < delDist) { delDist = d; delIdx = i; }
});
if (delIdx === -1 || delDist > 8) return;
curve.splice(delIdx, 1);
const updatedNotes = notes.map((n, i) => (i === noteIdx ? { ...n, expression_curve: curve } : n));
setNotes(updatedNotes);
if (isPlaying && onRescheduleMidi) onRescheduleMidi(updatedNotes);
};
const handleCCMouseDown = (e) => {
if (ccMode === 'cc11') { handleCC11MouseDown(e); return; }
const canvas = ccCanvasRef.current;
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
@@ -10408,6 +10598,28 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, activeTracks, onClos
const handleCCMouseMove = (e) => {
if (!ccDragRef.current || !ccDragRef.current.active) return;
if (ccDragRef.current.mode === 'cc11') {
const drag = ccDragRef.current;
const canvas = ccCanvasRef.current;
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const h = rect.height;
const beat = x / pixelsPerBeat - renderBeatOffset;
const note = notes[drag.noteIdx];
if (!note) return;
const dur = drag.noteDur || note.duration_beats || 1;
const ratio = Math.max(0, Math.min(1, (beat - drag.noteStart) / dur));
const val = Math.max(0, Math.min(127, Math.round(((h - y) / h) * 127)));
const curve = getCC11Curve(note).slice();
const idx = Math.min(drag.anchorIdx, curve.length - 1);
curve[idx] = { position_ratio: ratio, value: val };
const updatedNotes = notes.map((n, i) => (i === drag.noteIdx ? { ...n, expression_curve: curve } : n));
setNotes(updatedNotes);
if (isPlaying && onRescheduleMidi) onRescheduleMidi(updatedNotes);
return;
}
const canvas = ccCanvasRef.current;
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
@@ -10991,10 +11203,10 @@ const beatSec = 60.0 / (parseInt(bpm) || 120);
value: ccMode,
onChange: e => setCcMode(e.target.value),
className: "bg-zinc-800 text-zinc-200 border border-zinc-700 rounded px-1.5 py-1 text-xs capitalize cursor-pointer"
}, React.createElement("option", { value: "velocity" }, "Velocity"), React.createElement("option", { value: "sustain" }, "Sustain"), React.createElement("option", { value: "modulation" }, "Modulation"), React.createElement("option", { value: "pitch_bend" }, "Pitch Bend"), React.createElement("option", { value: "pan" }, "Pan"))), React.createElement("button", {
}, React.createElement("option", { value: "velocity" }, "Velocity"), React.createElement("option", { value: "sustain" }, "Sustain"), React.createElement("option", { value: "modulation" }, "Modulation"), React.createElement("option", { value: "pitch_bend" }, "Pitch Bend"), React.createElement("option", { value: "pan" }, "Pan"), React.createElement("option", { value: "cc11" }, "Expression (CC11)"))), React.createElement("button", {
onClick: () => setShowCC(!showCC),
className: `px-2 py-1 rounded text-xs ${showCC ? 'bg-purple-900/60 text-purple-300 border border-purple-700' : 'text-zinc-500 hover:text-zinc-300'}`
}, ccMode === 'velocity' ? 'Vel' : ccMode === 'sustain' ? 'Sus' : ccMode === 'modulation' ? 'Mod' : ccMode === 'pitch_bend' ? 'Bend' : ccMode === 'pan' ? 'Pan' : 'CC'), React.createElement("button", {
}, ccMode === 'velocity' ? 'Vel' : ccMode === 'sustain' ? 'Sus' : ccMode === 'modulation' ? 'Mod' : ccMode === 'pitch_bend' ? 'Bend' : ccMode === 'pan' ? 'Pan' : ccMode === 'cc11' ? 'Expr' : 'CC'), React.createElement("button", {
onClick: function() { setSessionSyncMode(function(p) { return !p; }); },
className: function() {
var base = 'px-2 py-1 rounded text-xs ';
@@ -11455,6 +11667,7 @@ const beatSec = 60.0 / (parseInt(bpm) || 120);
ref: ccCanvasRef,
onMouseDown: handleCCMouseDown,
onMouseMove: handleCCMouseMove,
onContextMenu: handleCC11ContextMenu,
onMouseUp: () => { if (ccDragRef.current) ccDragRef.current.selectedMode = false; ccDragRef.current = null; },
onMouseLeave: () => { if (ccDragRef.current) ccDragRef.current.selectedMode = false; ccDragRef.current = null; },
className: "absolute inset-0"
@@ -25102,6 +25315,7 @@ const App = () => {
if (window.SonicSF) {
scheduleMidiNoteDispatch(track, note.pitch || 60, note.velocity || 0.8, durMs, scheduledTime, instrumentProgram, destNode, mainCh, synthEngine);
}
scheduleNoteExpressionCC11(track, note, mainCh, startWallTime, effectiveStart, effectiveDur);
}
});
// Play ghost notes from active tracks
@@ -25256,6 +25470,7 @@ const App = () => {
// cũ bắn vào vòng mới = NOTE_OFF cắt note đang kêu → crack (xem ref).
bridgeNoteEpochRef.current++;
try { clearAllPendingNoteOffs(); } catch (e) {}
try { clearAllCC11Timers(); } catch (e) {}
try { if (subTabsRef.current) subTabsRef.current = subTabsRef.current.map(s => ({ ...s, isPlaying: false })); } catch (e) {}
// Fade-out khi user nhấn Stop/Pause (fade=true): ramp gain track + bridge
// xuống 0 trong FADE_MS rồi mới hard-stop — hết click, âm ngân mượt.
File diff suppressed because one or more lines are too long