"""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}"