120 lines
5.3 KiB
Python
120 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
|
|
from app.core.native_render import EXPRESSION_CC
|
|
assert events[0] == {"sample": 0, "kind": 2, "a": EXPRESSION_CC, "b": 127}
|
|
assert events[-1]["sample"] == 24000
|
|
assert all(e["kind"] == 2 and e["a"] == EXPRESSION_CC 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}"
|