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:
+237
-22
@@ -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
Reference in New Issue
Block a user