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:
@@ -175,6 +175,60 @@ def bridge_state_preset_b64(channel=None, plugin_path=None) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def _expression_events(notes, bpm, sample_rate, grid=64):
|
||||
"""Sinh CC11 events sample-accurate từ expression_curve mỗi note.
|
||||
|
||||
curve: [{position_ratio 0..1, value 0..127}] — nội suy tuyến tính, grid
|
||||
cố định (mặc định 64 samples ≈ 1.3ms @48k) → step CC <= 1-2, không zipper.
|
||||
Event shape {sample, kind:2, a:11, b:value} — khớp RenderJob.cpp Ev.
|
||||
"""
|
||||
if not notes or not bpm:
|
||||
return []
|
||||
beat_sec = 60.0 / float(bpm)
|
||||
events = []
|
||||
for n in notes:
|
||||
curve = n.get("expression_curve") if isinstance(n, dict) else None
|
||||
if not curve:
|
||||
continue
|
||||
try:
|
||||
pts = sorted((float(p["position_ratio"]), int(round(float(p["value"])))) for p in curve if p)
|
||||
except Exception:
|
||||
continue
|
||||
if not pts:
|
||||
continue
|
||||
start_sample = int(round(float(n.get("start_beat", 0)) * beat_sec * float(sample_rate)))
|
||||
dur_samples = int(round(float(n.get("duration_beats", 1)) * beat_sec * float(sample_rate)))
|
||||
if dur_samples <= 0:
|
||||
continue
|
||||
|
||||
def interp(ratio):
|
||||
ratio = max(0.0, min(1.0, ratio))
|
||||
if len(pts) == 1:
|
||||
return pts[0][1]
|
||||
if ratio <= pts[0][0]:
|
||||
return pts[0][1]
|
||||
if ratio >= pts[-1][0]:
|
||||
return pts[-1][1]
|
||||
for i in range(1, len(pts)):
|
||||
if ratio <= pts[i][0]:
|
||||
a, b = pts[i - 1], pts[i]
|
||||
t = (ratio - a[0]) / (b[0] - a[0]) if b[0] > a[0] else 0.0
|
||||
return int(round(a[1] + (b[1] - a[1]) * t))
|
||||
return pts[-1][1]
|
||||
|
||||
pos = 0
|
||||
while pos <= dur_samples:
|
||||
ratio = pos / float(dur_samples)
|
||||
events.append({
|
||||
"sample": start_sample + int(pos),
|
||||
"kind": 2,
|
||||
"a": 11,
|
||||
"b": max(0, min(127, interp(ratio))),
|
||||
})
|
||||
pos += max(1, int(grid))
|
||||
events.sort(key=lambda e: e["sample"])
|
||||
return events
|
||||
|
||||
def _run_notes_job(job: dict, label: str) -> tuple:
|
||||
"""Chạy job MIDI render qua native_bridge --render → (out_path, duration_sec).
|
||||
|
||||
@@ -279,6 +333,9 @@ def render_offline(instrument_id, notes, bpm, sample_rate, preset_id=None,
|
||||
job["soundfont_bank"] = int(soundfont_bank) & 0x7F
|
||||
if soundfont_program is not None:
|
||||
job["soundfont_program"] = int(soundfont_program) & 0x7F
|
||||
events = _expression_events(notes, bpm, sample_rate)
|
||||
if events:
|
||||
job["events"] = events
|
||||
return _run_notes_job(job, instrument_id)
|
||||
|
||||
|
||||
@@ -303,6 +360,9 @@ def render_soundfont_offline(sf_path, notes, bpm, sample_rate, bank=0, program=0
|
||||
"duration_beats": float(n.get("duration_beats", 1)),
|
||||
} for n in notes],
|
||||
}
|
||||
events = _expression_events(notes, bpm, sample_rate)
|
||||
if events:
|
||||
job["events"] = events
|
||||
return _run_notes_job(job, os.fspath(sf_path))
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user