feat: thêm soundfont và VSTi cho MIDI
This commit is contained in:
@@ -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")
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user