feat: thêm soundfont và VSTi cho MIDI

This commit is contained in:
2026-07-23 17:47:46 +07:00
parent 0b2382573f
commit 225f23516f
16 changed files with 1232 additions and 60 deletions
+85
View File
@@ -0,0 +1,85 @@
import os
import json
import pytest
from fastapi.testclient import TestClient
from unittest.mock import patch, MagicMock
from app.main import app
from app.core.vst_engine import HAS_PEDALBOARD, HAS_PYFLUIDSYNTH
client = TestClient(app)
def get_admin_token():
resp = client.post("/api/v1/auth/login", json={"username": "admin", "password": "admin123"})
if resp.status_code == 200:
return resp.json()["access_token"]
return None
class TestPluginAPI:
def test_list_plugins_requires_auth(self):
resp = client.get("/api/v1/plugins/available")
assert resp.status_code in (401, 403)
def test_list_plugins_authenticated(self):
token = get_admin_token()
if not token:
pytest.skip("Cannot get admin token")
resp = client.get("/api/v1/plugins/available", headers={"Authorization": f"Bearer {token}"})
assert resp.status_code == 200
data = resp.json()
assert "vst_instruments" in data
assert "soundfonts" in data
def test_list_default_soundfonts(self):
resp = client.get("/api/v1/plugins/default-soundfonts")
assert resp.status_code == 200
data = resp.json()
assert isinstance(data, list)
def test_render_project_invalid_json(self):
token = get_admin_token()
if not token:
pytest.skip("Cannot get admin token")
resp = client.post("/api/v1/plugins/render", headers={"Authorization": f"Bearer {token}"}, json={"project_json": {}})
# Should fail because project is empty, but API should return 500 or error
assert resp.status_code in (400, 422, 500)
def test_upload_soundfont_requires_auth(self):
resp = client.post("/api/v1/plugins/upload-soundfont")
assert resp.status_code in (401, 403, 422)
def test_upload_soundfont_invalid_magic(self):
token = get_admin_token()
if not token:
pytest.skip("Cannot get admin token")
# Upload a file with invalid magic bytes
fake_content = b'XXXX\x00\x00\x00\x00YYYY' * 100
resp = client.post(
"/api/v1/plugins/upload-soundfont",
headers={"Authorization": f"Bearer {token}"},
files={"file": ("fake.sf2", fake_content, "application/octet-stream")}
)
assert resp.status_code == 400
assert "Invalid SoundFont" in resp.json().get("detail", "")
def test_upload_soundfont_valid_magic(self):
token = get_admin_token()
if not token:
pytest.skip("Cannot get admin token")
# Upload a file with valid RIFF + sfbk magic
valid_content = b'RIFF\x00\x00\x00\x00sfbk' + b'\x00' * 200
resp = client.post(
"/api/v1/plugins/upload-soundfont",
headers={"Authorization": f"Bearer {token}"},
files={"file": ("test.sf2", valid_content, "application/octet-stream")}
)
# Should succeed (200) unless auth/permission issues
if resp.status_code == 200:
data = resp.json()
assert "id" in data
assert "size_bytes" in data
assert data["size_bytes"] == len(valid_content)
elif resp.status_code == 403:
pytest.skip("Permission denied for admin user")
+95
View File
@@ -0,0 +1,95 @@
import os
import json
import pytest
import numpy as np
from unittest.mock import patch, MagicMock
from app.core.vst_engine import PluginManager, midi_note_to_freq, render_midi_events_to_audio, HAS_PEDALBOARD
class TestPluginManager:
def test_init(self):
pm = PluginManager()
assert pm.vst_dir == "/opt/daw_engine/vst3"
assert pm.sf_dir == "/opt/daw_engine/soundfonts"
def test_midi_events_to_messages(self):
if not HAS_PEDALBOARD:
pytest.skip("pedalboard not available")
events = [{"note": 60, "start_beat": 0, "duration_beats": 1, "velocity": 100}]
bpm = 120
sr = 44100
msgs = PluginManager.midi_events_to_messages(events, bpm, sr)
beat_sec = 60.0 / 120
expected_note_on_offset = 0
expected_note_off_offset = int(beat_sec * sr)
# Check note_on message
assert msgs[0].sample_offset == expected_note_on_offset
assert msgs[0].note == 60
# Check note_off message
assert msgs[1].sample_offset == expected_note_off_offset
assert msgs[1].note == 60
def test_list_available_empty(self):
pm = PluginManager(vst_dir="/tmp/nonexistent_vst_dir_xyz", sf_dir="/tmp/nonexistent_sf_dir_xyz")
available = pm.list_available()
assert "vst_instruments" in available
assert "soundfonts" in available
assert available["vst_instruments"] == []
assert available["soundfonts"] == []
def test_validate_sf2_header_valid(self):
# Valid RIFF + sfbk header
valid = b'RIFF' + b'\x00' * 4 + b'sfbk' + b'\x00' * 100
assert PluginManager.validate_sf2_header(valid) is True
def test_validate_sf2_header_invalid_no_riff(self):
invalid = b'XXXX' + b'\x00' * 4 + b'sfbk' + b'\x00' * 100
assert PluginManager.validate_sf2_header(invalid) is False
def test_validate_sf2_header_invalid_no_sfbk(self):
invalid = b'RIFF' + b'\x00' * 4 + b'XXXX' + b'\x00' * 100
assert PluginManager.validate_sf2_header(invalid) is False
def test_validate_sf2_header_too_short(self):
assert PluginManager.validate_sf2_header(b'RIFF') is False
class TestMidiNoteToFreq:
def test_a4_440(self):
assert midi_note_to_freq(69) == 440.0
def test_c4(self):
# C4 = MIDI 60 = 261.63
freq = midi_note_to_freq(60)
assert abs(freq - 261.63) < 0.5
def test_note_zero_to_freq(self):
freq = midi_note_to_freq(0)
assert freq > 0 and freq < 10
class TestRenderMidiToAudio:
def test_render_single_note(self):
events = [{"note": 60, "start_beat": 0, "duration_beats": 1, "velocity": 100}]
audio = render_midi_events_to_audio(events, sr=44100, bpm=120)
assert audio.shape[0] == 2 # Stereo
assert audio.shape[1] > 0
# Should have non-zero samples
assert np.max(np.abs(audio)) > 0
def test_render_empty_events(self):
audio = render_midi_events_to_audio([], sr=44100, bpm=120)
assert audio.shape[0] == 2 # Stereo
# Empty events defaults to 2 seconds of silence (minimum length)
assert audio.shape[1] >= 0
def test_render_multiple_notes(self):
events = [
{"note": 60, "start_beat": 0, "duration_beats": 1, "velocity": 100},
{"note": 64, "start_beat": 1, "duration_beats": 1, "velocity": 80},
{"note": 67, "start_beat": 2, "duration_beats": 1, "velocity": 90},
]
audio = render_midi_events_to_audio(events, sr=44100, bpm=120)
assert audio.shape[0] == 2
assert audio.shape[1] > 44100 * 1