Files
SonicForgeStudio/tests/test_cc11_expression.py
T
admin 2c96a223af 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
2026-08-26 11:14:56 +07:00

119 lines
5.3 KiB
Python

"""CC11 expression curve (spec midi_note_cc11_expression).
Unit: _expression_events — grid 64 samples, nội suy tuyến tính, clamp 0..127.
Integration: render_soundfont_offline qua native_bridge — năng lượng output
theo curve (CC11 thấp -> âm nhỏ hơn rõ rệt). Skip nếu thiếu bridge/SF2."""
import os
import tempfile
import numpy as np
import pytest
import soundfile as sf
from app.core import native_render
SF2 = r"C:\Users\locpham\SonicForgeStudio\app\storage\soundfonts\518e850f-a5d3-4790-b1f9-0c90c203c524.sf2"
def _has_bridge():
try:
return bool(native_render.find_bridge_exe())
except Exception:
return False
# ── Unit: _expression_events ─────────────────────────────────────────────
def test_no_curve_no_events():
assert native_render._expression_events([], 120, 48000) == []
assert native_render._expression_events(None, 120, 48000) == []
assert native_render._expression_events([{"pitch": 60, "start_beat": 0.0, "duration_beats": 1.0}], 120, 48000) == []
def test_flat_curve_grid():
# bpm 120, 1 beat = 0.5s; @48k -> 24000 samples; grid 64 -> 376 points (0..24000 inclusive)
events = native_render._expression_events(
[{"start_beat": 0.0, "duration_beats": 1.0, "expression_curve": [{"position_ratio": 0.0, "value": 127}]}],
120, 48000)
assert len(events) == 24000 // 64 + 1
assert events[0] == {"sample": 0, "kind": 2, "a": 11, "b": 127}
assert events[-1]["sample"] == 24000
assert all(e["kind"] == 2 and e["a"] == 11 and e["b"] == 127 for e in events)
assert all(events[i]["sample"] < events[i + 1]["sample"] for i in range(len(events) - 1))
def test_ramp_interp():
# curve 0 -> 127; @48k grid 64: tỉ lệ 0.5 tại sample 12000 -> value ~64
events = native_render._expression_events(
[{"start_beat": 0.0, "duration_beats": 1.0,
"expression_curve": [{"position_ratio": 0.0, "value": 0}, {"position_ratio": 1.0, "value": 127}]}],
120, 48000, grid=240) # 240 chia hết 24000 -> sample 12000 (ratio 0.5) nằm trên grid
by_sample = {e["sample"]: e["b"] for e in events}
assert by_sample[0] == 0
assert by_sample[24000] == 127
assert abs(by_sample[12000] - 64) <= 1 # nội suy tuyến tính tại giữa
def test_curve_offset_and_clamp():
# note bắt đầu beat 2 (sample 48000) + grid custom 128; value clamp 0..127
events = native_render._expression_events(
[{"start_beat": 2.0, "duration_beats": 0.5,
"expression_curve": [{"position_ratio": 0.0, "value": 200}, {"position_ratio": 1.0, "value": -5}]}],
120, 48000, grid=128)
assert events[0]["sample"] == 48000
assert events[0]["b"] == 127 # clamp trên
# 12000 / 128 = 93.75 -> điểm cuối 93*128 = 11904 (pos += grid; 12032 > 12000 dừng)
assert events[-1]["sample"] == 48000 + 11904
assert events[-1]["b"] == 0 # clamp dưới
assert all(0 <= e["b"] <= 127 for e in events)
def test_curve_sorted_by_sample_with_notes():
# nhiều note cùng curve — events toàn cục sorted theo sample, không trùng key kind/a
events = native_render._expression_events([
{"start_beat": 0.0, "duration_beats": 1.0,
"expression_curve": [{"position_ratio": 0.0, "value": 100}]},
{"start_beat": 4.0, "duration_beats": 1.0,
"expression_curve": [{"position_ratio": 0.0, "value": 50}]},
], 120, 48000, grid=1024)
assert events
assert all(events[i]["sample"] <= events[i + 1]["sample"] for i in range(len(events) - 1))
assert events[0]["b"] == 100
assert events[-1]["b"] == 50
# ── Integration: render_soundfont_offline, năng lượng theo curve ─────────
def test_render_energy_follows_curve():
if not _has_bridge():
pytest.skip("bridge exe missing")
if not os.path.isfile(SF2):
pytest.skip("SF2 fixture missing")
with tempfile.TemporaryDirectory() as td:
base = os.path.join(td, "n.wav")
jobs = [
("flat", base + ".flat.wav", [{"position_ratio": 0.0, "value": 127}]),
("low", base + ".low.wav", [{"position_ratio": 0.0, "value": 20}]),
("ramp", base + ".ramp.wav",
[{"position_ratio": 0.0, "value": 0}, {"position_ratio": 1.0, "value": 127}]),
]
rms = {}
for name, out, curve in jobs:
notes = [{"pitch": 60, "velocity": 0.8, "start_beat": 0.0, "duration_beats": 1.0,
"expression_curve": curve}]
out_path, _dur = native_render.render_soundfont_offline(
SF2, notes, bpm=120.0, sample_rate=44100)
try:
data, sr = sf.read(out_path, always_2d=True)
rms[name] = float(np.sqrt(np.mean(data ** 2)))
finally:
try:
os.remove(out_path)
except OSError:
pass
assert rms["flat"] > 0.001
# CC11 thấp phải giảm năng lượng rõ rệt (>3x RMS)
assert rms["flat"] > 3 * rms["low"], f"flat={rms['flat']:.5f} low={rms['low']:.5f}"
# ramp (0 -> 127) yếu ở đầu -> năng lượng trung bình thấp hơn flat
assert rms["flat"] > rms["ramp"], f"flat={rms['flat']:.5f} ramp={rms['ramp']:.5f}"