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
+74
View File
@@ -0,0 +1,74 @@
import os, uuid, json, tempfile
from fastapi import APIRouter, HTTPException, Depends, UploadFile, File
from pydantic import BaseModel
from typing import Optional, Any
from app.config import settings
from app.core.vst_engine import PluginManager, HAS_PEDALBOARD, HAS_PYFLUIDSYNTH
from app.core.render_engine import PythonRenderEngine
from app.api.v1.auth import get_current_user
router = APIRouter()
UPLOAD_SF_DIR = os.path.join(settings.STORAGE_DIR, "soundfonts")
os.makedirs(UPLOAD_SF_DIR, exist_ok=True)
@router.get("/available")
async def list_plugins(current_user: dict = Depends(get_current_user)):
pm = PluginManager()
return pm.list_available()
@router.get("/default-soundfonts")
async def list_default_soundfonts():
static_sf_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "static", "soundfonts")
result = []
if os.path.isdir(static_sf_dir):
for f in os.listdir(static_sf_dir):
if f.endswith(".sf2") or f.endswith(".sf3"):
result.append({
"id": os.path.splitext(f)[0],
"name": f,
"file": f,
"url": f"/soundfonts/{f}"
})
return result
@router.post("/upload-soundfont")
async def upload_soundfont(
file: UploadFile = File(...),
current_user: dict = Depends(get_current_user)
):
if not (file.filename and (file.filename.endswith(".sf2") or file.filename.endswith(".sf3"))):
raise HTTPException(status_code=400, detail="Only .sf2 / .sf3 files are allowed")
contents = await file.read()
if not PluginManager.validate_sf2_header(contents):
raise HTTPException(status_code=400, detail="Invalid SoundFont file: missing RIFF/sfbk header")
file_id = str(uuid.uuid4()) + os.path.splitext(file.filename)[1]
dest_path = os.path.join(UPLOAD_SF_DIR, file_id)
with open(dest_path, "wb") as f:
f.write(contents)
return {"id": file_id, "name": file.filename, "path": dest_path, "size_bytes": len(contents)}
class RenderRequest(BaseModel):
project_json: dict
output_filename: Optional[str] = "render_output.wav"
@router.post("/render")
async def render_project(
req: RenderRequest,
current_user: dict = Depends(get_current_user)
):
engine = PythonRenderEngine()
output_path = os.path.join(settings.PROCESSED_DIR, req.output_filename or "render_output.wav")
try:
result_path = engine.render_project(req.project_json, output_path)
return {"url": f"/static/audio/processed/{os.path.basename(result_path)}", "path": result_path}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Render failed: {str(e)}")
+71 -25
View File
@@ -2,24 +2,13 @@ import os
import numpy as np import numpy as np
import soundfile as sf import soundfile as sf
from app.config import settings from app.config import settings
from app.core.vst_engine import render_midi_events_to_audio from app.core.vst_engine import (
render_midi_events_to_audio,
PluginManager,
HAS_PEDALBOARD,
HAS_PYFLUIDSYNTH,
)
import subprocess
import sys
def check_pedalboard_safe():
try:
res = subprocess.run(
[sys.executable, "-c", "import pedalboard"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
timeout=2.0
)
return res.returncode == 0
except Exception:
return False
HAS_PEDALBOARD = check_pedalboard_safe()
if HAS_PEDALBOARD: if HAS_PEDALBOARD:
try: try:
from pedalboard import Pedalboard, Gain from pedalboard import Pedalboard, Gain
@@ -130,14 +119,71 @@ class PythonRenderEngine:
if midi_events: if midi_events:
try: try:
# Synthesize MIDI track notes instrument_id = track.get("instrument", "")
synth_buffer = render_midi_events_to_audio( plugin_mgr = PluginManager()
midi_events=midi_events, vst = plugin_mgr.load_vst(instrument_id) if instrument_id else None
sr=self.sample_rate,
bpm=bpm, if vst and HAS_PEDALBOARD:
instrument='synth' from pedalboard import Pedalboard
) # Convert MIDI events with precise sample offset
# Add to track buffer midi_messages = PluginManager.midi_events_to_messages(
midi_events, bpm, self.sample_rate
)
total_needed = 0
for ev in midi_events:
end_sec = (ev.get("start_beat", 0) + ev.get("duration_beats", 1)) * (60.0 / bpm)
dur_samples = int(end_sec * self.sample_rate)
if dur_samples > total_needed:
total_needed = dur_samples
total_needed = max(total_needed, 1024)
silent = np.zeros((2, total_needed), dtype=np.float32)
board = Pedalboard([vst])
synth_buffer = board(silent, sample_rate=self.sample_rate, midi_messages=midi_messages)
elif instrument_id and instrument_id.startswith("sf_"):
sf_path = os.path.join(
os.path.dirname(os.path.dirname(__file__)), "static", "soundfonts",
instrument_id.replace("sf_", "") + ".sf2"
)
if os.path.exists(sf_path) and HAS_PYFLUIDSYNTH:
import fluidsynth
fl = fluidsynth.FluidSynth(sample_rate=self.sample_rate, gain=0.5)
fid = fl.sfload(sf_path)
fl.program_select(0, fid, 0, 0)
beat_sec = 60.0 / bpm
total_sec = 0
for ev in midi_events:
end_sec = (ev.get("start_beat", 0) + ev.get("duration_beats", 1)) * beat_sec
if end_sec > total_sec:
total_sec = end_sec
total_samples = int((total_sec + 1.0) * self.sample_rate)
midi_data = np.zeros((2, total_samples), dtype=np.float32)
for ev in midi_events:
note = ev.get("note", 60)
velocity = ev.get("velocity", 100)
start_beat = ev.get("start_beat", 0.0)
dur_beats = ev.get("duration_beats", 1.0)
start_sec = start_beat * beat_sec
dur_sec = dur_beats * beat_sec
fl.noteon(0, note, velocity)
start_s = int(start_sec * self.sample_rate)
dur_s = int(dur_sec * self.sample_rate)
block = fl.get_samples(int(dur_s)) if hasattr(fl, 'get_samples') else np.zeros((2, dur_s), dtype=np.float32)
fl.noteoff(0, note)
if block.shape[1] > 0:
end_s = min(start_s + block.shape[1], total_samples)
actual = end_s - start_s
if actual > 0:
midi_data[:, start_s:end_s] += block[:, :actual]
synth_buffer = midi_data
fl.delete()
else:
synth_buffer = render_midi_events_to_audio(
midi_events=midi_events, sr=self.sample_rate, bpm=bpm, instrument='synth'
)
else:
synth_buffer = render_midi_events_to_audio(
midi_events=midi_events, sr=self.sample_rate, bpm=bpm, instrument='synth'
)
actual_len = min(synth_buffer.shape[1], total_samples) actual_len = min(synth_buffer.shape[1], total_samples)
track_buffer[:, :actual_len] += synth_buffer[:, :actual_len] track_buffer[:, :actual_len] += synth_buffer[:, :actual_len]
except Exception as e: except Exception as e:
+131 -31
View File
@@ -1,77 +1,177 @@
# SonicForge Studio VST / VSTi Engine Service (22_CLIENT_DESK.md §3) # SonicForge Studio VST / VSTi Engine Service
import os
import numpy as np import numpy as np
def midi_note_to_freq(note_number: int) -> float: def midi_note_to_freq(note_number: int) -> float:
"""Quy đổi số nốt MIDI (0 - 127) sang tần số Hertz (Hz)."""
return 440.0 * (2.0 ** ((note_number - 69) / 12.0)) return 440.0 * (2.0 ** ((note_number - 69) / 12.0))
def render_midi_events_to_audio(midi_events: list, sr: int = 44100, bpm: float = 120.0, instrument: str = 'synth') -> np.ndarray: def render_midi_events_to_audio(midi_events: list, sr: int = 44100, bpm: float = 120.0, instrument: str = 'synth') -> np.ndarray:
"""
Tổng hợp mảng âm thanh NumPy Stereo từ sự kiện MIDI Piano Roll (22_CLIENT_DESK.md §3.1 & §3.2).
Args:
midi_events: Danh sách nốt MIDI [{"note": 60, "start_beat": 0, "duration_beats": 1, "velocity": 100}, ...]
sr: Tần số lấy mẫu (Sample Rate)
bpm: Nhịp BPM của dự án
instrument: Loại nhạc cụ tổng hợp
Returns:
np.ndarray: Mảng 2D Stereo Float32 [2, num_samples]
"""
beat_duration_sec = 60.0 / max(30.0, bpm) beat_duration_sec = 60.0 / max(30.0, bpm)
max_duration_sec = 2.0 max_duration_sec = 2.0
for event in midi_events: for event in midi_events:
start_beat = event.get('start_beat', 0.0) start_beat = event.get('start_beat', 0.0)
dur_beats = event.get('duration_beats', 1.0) dur_beats = event.get('duration_beats', 1.0)
end_sec = (start_beat + dur_beats) * beat_duration_sec end_sec = (start_beat + dur_beats) * beat_duration_sec
if end_sec > max_duration_sec: if end_sec > max_duration_sec:
max_duration_sec = end_sec max_duration_sec = end_sec
total_samples = int((max_duration_sec + 0.5) * sr) total_samples = int((max_duration_sec + 0.5) * sr)
out_l = np.zeros(total_samples, dtype=np.float32) out_l = np.zeros(total_samples, dtype=np.float32)
out_r = np.zeros(total_samples, dtype=np.float32) out_r = np.zeros(total_samples, dtype=np.float32)
for event in midi_events: for event in midi_events:
note = event.get('note', 60) note = event.get('note', 60)
velocity = event.get('velocity', 100) / 127.0 velocity = event.get('velocity', 100) / 127.0
start_beat = event.get('start_beat', 0.0) start_beat = event.get('start_beat', 0.0)
dur_beats = event.get('duration_beats', 1.0) dur_beats = event.get('duration_beats', 1.0)
start_sample = int(start_beat * beat_duration_sec * sr) start_sample = int(start_beat * beat_duration_sec * sr)
dur_samples = int(dur_beats * beat_duration_sec * sr) dur_samples = int(dur_beats * beat_duration_sec * sr)
end_sample = min(total_samples, start_sample + dur_samples) end_sample = min(total_samples, start_sample + dur_samples)
actual_len = end_sample - start_sample actual_len = end_sample - start_sample
if actual_len <= 0 or start_sample >= total_samples: if actual_len <= 0 or start_sample >= total_samples:
continue continue
freq = midi_note_to_freq(note) freq = midi_note_to_freq(note)
t = np.arange(actual_len) / float(sr) t = np.arange(actual_len) / float(sr)
# Synth tone + fundamental harmonics
tone = 0.6 * np.sin(2 * np.pi * freq * t) + 0.3 * np.sin(2 * np.pi * freq * 2 * t) + 0.1 * np.sin(2 * np.pi * freq * 3 * t) tone = 0.6 * np.sin(2 * np.pi * freq * t) + 0.3 * np.sin(2 * np.pi * freq * 2 * t) + 0.1 * np.sin(2 * np.pi * freq * 3 * t)
# ADSR Envelope
attack = min(int(0.01 * sr), actual_len // 4) attack = min(int(0.01 * sr), actual_len // 4)
release = min(int(0.05 * sr), actual_len // 4) release = min(int(0.05 * sr), actual_len // 4)
sustain_len = actual_len - attack - release
env = np.ones(actual_len, dtype=np.float32) env = np.ones(actual_len, dtype=np.float32)
if attack > 0: if attack > 0:
env[:attack] = np.linspace(0.0, 1.0, attack) env[:attack] = np.linspace(0.0, 1.0, attack)
if release > 0: if release > 0:
env[-release:] = np.linspace(1.0, 0.0, release) env[-release:] = np.linspace(1.0, 0.0, release)
signal = tone * env * velocity signal = tone * env * velocity
out_l[start_sample:end_sample] += signal out_l[start_sample:end_sample] += signal
out_r[start_sample:end_sample] += signal out_r[start_sample:end_sample] += signal
# Clamping normalization to prevent clipping
max_peak = max(np.max(np.abs(out_l)), np.max(np.abs(out_r))) max_peak = max(np.max(np.abs(out_l)), np.max(np.abs(out_r)))
if max_peak > 1.0: if max_peak > 1.0:
out_l /= max_peak out_l /= max_peak
out_r /= max_peak out_r /= max_peak
return np.vstack([out_l, out_r]) return np.vstack([out_l, out_r])
def check_pedalboard_safe():
import subprocess, sys
try:
res = subprocess.run(
[sys.executable, "-c", "import pedalboard"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=2.0
)
return res.returncode == 0
except Exception:
return False
def check_pyfluidsynth_safe():
import subprocess, sys
try:
res = subprocess.run(
[sys.executable, "-c", "import fluidsynth"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=2.0
)
return res.returncode == 0
except Exception:
return False
HAS_PEDALBOARD = check_pedalboard_safe()
HAS_PYFLUIDSYNTH = check_pyfluidsynth_safe()
if HAS_PEDALBOARD:
try:
from pedalboard import VST3Plugin, Pedalboard, Gain, MidiMessage
except Exception:
HAS_PEDALBOARD = False
if HAS_PYFLUIDSYNTH:
try:
import fluidsynth
except Exception:
HAS_PYFLUIDSYNTH = False
class PluginManager:
def __init__(self, vst_dir="/opt/daw_engine/vst3", sf_dir="/opt/daw_engine/soundfonts"):
self.vst_dir = vst_dir
self.sf_dir = sf_dir
def _scan_plugins(self) -> dict:
plugins = {}
if not os.path.isdir(self.vst_dir):
return plugins
for root, dirs, files in os.walk(self.vst_dir):
for file in files:
if file.endswith(".vst3") or file.endswith(".so"):
plugin_path = os.path.join(root, file)
plugin_name = os.path.splitext(file)[0]
plugins[plugin_name] = plugin_path
return plugins
def _scan_soundfonts(self) -> list:
sfonts = []
if not os.path.isdir(self.sf_dir):
return sfonts
for f in os.listdir(self.sf_dir):
if f.endswith(".sf2") or f.endswith(".sf3"):
sfonts.append({"id": os.path.splitext(f)[0], "name": f, "file": f})
return sfonts
def load_vst(self, plugin_name: str, preset_data: dict = None):
if not HAS_PEDALBOARD:
return None
plugins = self._scan_plugins()
if plugin_name not in plugins:
return None
path = plugins[plugin_name]
vst = VST3Plugin(path)
if preset_data:
for k, v in preset_data.items():
try:
setattr(vst, k, v)
except Exception:
pass
return vst
def load_soundfont(self, path: str):
if not HAS_PYFLUIDSYNTH:
return None
try:
fl = fluidsynth.FluidSynth(sample_rate=44100, gain=0.5)
font_id = fl.sfload(path)
fl.program_select(0, font_id, 0, 0)
return fl
except Exception:
return None
def list_available(self) -> dict:
return {
"vst_instruments": [
{"id": k, "name": k, "type": "VST3", "has_native_support": HAS_PEDALBOARD}
for k in self._scan_plugins().keys()
],
"soundfonts": self._scan_soundfonts()
}
@staticmethod
def midi_events_to_messages(midi_events: list, bpm: float, sr: int) -> list:
if not HAS_PEDALBOARD:
return []
beat_duration_sec = 60.0 / max(30.0, bpm)
messages = []
for ev in midi_events:
note = ev.get("note", 60)
velocity = ev.get("velocity", 100)
start_beat = ev.get("start_beat", 0.0)
dur_beats = ev.get("duration_beats", 1.0)
start_sec = start_beat * beat_duration_sec
dur_sec = dur_beats * beat_duration_sec
sample_offset = int(start_sec * sr)
end_sample_offset = int((start_sec + dur_sec) * sr)
messages.append(MidiMessage(note_on=note, velocity=velocity, sample_offset=sample_offset))
messages.append(MidiMessage(note_off=note, velocity=0, sample_offset=end_sample_offset))
return messages
@staticmethod
def validate_sf2_header(data: bytes) -> bool:
if len(data) < 12:
return False
if data[0:4] != b'RIFF':
return False
if data[8:12] != b'sfbk':
return False
return True
+2
View File
@@ -12,6 +12,7 @@ from app.api.v1.admin import router as admin_router
from app.api.v1.projects import router as projects_router from app.api.v1.projects import router as projects_router
from app.api.v1.user_config import router as user_config_router from app.api.v1.user_config import router as user_config_router
from app.api.v1.ai_proxy import router as ai_proxy_router from app.api.v1.ai_proxy import router as ai_proxy_router
from app.api.v1.plugins import router as plugins_router
from app.core.auth import seed_admin from app.core.auth import seed_admin
# Ensure storage directories exist # Ensure storage directories exist
@@ -47,6 +48,7 @@ app.include_router(admin_router, prefix="/api/v1/admin", tags=["admin"])
app.include_router(projects_router, prefix="/api/v1/projects", tags=["projects"]) app.include_router(projects_router, prefix="/api/v1/projects", tags=["projects"])
app.include_router(user_config_router, prefix="/api/v1/user", tags=["user_config"]) app.include_router(user_config_router, prefix="/api/v1/user", tags=["user_config"])
app.include_router(ai_proxy_router, prefix="/api/v1/ai", tags=["ai"]) app.include_router(ai_proxy_router, prefix="/api/v1/ai", tags=["ai"])
app.include_router(plugins_router, prefix="/api/v1/plugins", tags=["plugins"])
# Seed admin user on startup # Seed admin user on startup
@app.on_event("startup") @app.on_event("startup")
+96
View File
@@ -3132,6 +3132,87 @@ const AIConfigModal = ({
className: "px-4 py-1.5 bg-cyan-600 hover:bg-cyan-500 text-white font-semibold text-xs rounded-lg shadow transition" className: "px-4 py-1.5 bg-cyan-600 hover:bg-cyan-500 text-white font-semibold text-xs rounded-lg shadow transition"
}, loading ? 'Đang lưu...' : 'Lưu Cấu Hình AI')))))); }, loading ? 'Đang lưu...' : 'Lưu Cấu Hình AI'))))));
}; };
const PluginManagerModal = ({ isOpen, onClose, pluginsData }) => {
if (!isOpen) return null;
const [localData, setLocalData] = React.useState(pluginsData);
const [sfUploadStatus, setSfUploadStatus] = React.useState('');
React.useEffect(() => {
if (isOpen && !localData) {
window.SonicAPI.listPlugins()
.then(data => setLocalData(data))
.catch(() => setLocalData({ vst_instruments: [], soundfonts: [] }));
}
}, [isOpen]);
const handleUploadSF = async (e) => {
const file = e.target.files?.[0];
if (!file) return;
setSfUploadStatus('Uploading...');
try {
const result = await window.SonicAPI.uploadSoundFont(file);
setSfUploadStatus('Uploaded: ' + result.name);
// Refresh plugin list
const data = await window.SonicAPI.listPlugins();
setLocalData(data);
} catch (err) {
setSfUploadStatus('Error: ' + err.message);
}
};
const vsts = localData?.vst_instruments || [];
const sfs = localData?.soundfonts || [];
return React.createElement('div', {
className: 'fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm',
onClick: onClose
}, React.createElement('div', {
className: 'bg-[#262626] border border-[#383838] rounded-xl shadow-2xl w-full max-w-2xl p-6 text-slate-200',
onClick: e => e.stopPropagation()
}, React.createElement('div', {
className: 'flex justify-between items-center pb-4 border-b border-[#383838]'
}, React.createElement('h3', {
className: 'text-lg font-bold text-cyan-400'
}, 'Plugin Manager (SoundFont / VSTi)'), React.createElement('button', {
onClick: onClose,
className: 'text-slate-400 hover:text-slate-200'
}, '✕')), React.createElement('div', {
className: 'mt-4 space-y-4'
}, React.createElement('div', null, React.createElement('h4', {
className: 'text-sm font-bold text-purple-400 mb-2 uppercase'
}, 'VST Instruments'), vsts.length === 0 ? React.createElement('p', {
className: 'text-xs text-zinc-500'
}, 'No VST instruments available on server.') : React.createElement('div', {
className: 'grid grid-cols-1 gap-1'
}, vsts.map((v, i) => React.createElement('div', {
key: i,
className: 'flex items-center justify-between bg-[#1e1e1e] px-3 py-2 rounded border border-[#333]'
}, React.createElement('span', {
className: 'text-xs font-semibold text-slate-200'
}, v.id), React.createElement('span', {
className: 'text-[10px] text-cyan-400 bg-cyan-950/40 px-1.5 py-0.5 rounded'
}, v.type))))), React.createElement('div', null, React.createElement('h4', {
className: 'text-sm font-bold text-amber-400 mb-2 uppercase'
}, 'SoundFonts'), sfs.length === 0 ? React.createElement('p', {
className: 'text-xs text-zinc-500'
}, 'No SoundFonts available.') : React.createElement('div', {
className: 'space-y-1'
}, sfs.map((sf, i) => React.createElement('div', {
key: i,
className: 'flex items-center justify-between bg-[#1e1e1e] px-3 py-2 rounded border border-[#333]'
}, React.createElement('span', {
className: 'text-xs text-slate-200'
}, sf.name))))), React.createElement('div', {
className: 'pt-4 border-t border-[#383838]'
}, React.createElement('h4', {
className: 'text-sm font-bold text-emerald-400 mb-2 uppercase'
}, 'Upload SoundFont'), React.createElement('input', {
type: 'file',
accept: '.sf2,.sf3',
onChange: handleUploadSF,
className: 'w-full text-xs text-zinc-400 file:mr-2 file:py-1 file:px-3 file:rounded file:border-0 file:text-xs file:font-semibold file:bg-emerald-700 file:text-white hover:file:bg-emerald-600'
}), sfUploadStatus && React.createElement('p', {
className: 'text-xs mt-1 text-zinc-400'
}, sfUploadStatus))))));
};
const ProfileModal = ({ const ProfileModal = ({
isOpen, isOpen,
onClose, onClose,
@@ -4998,6 +5079,8 @@ const App = () => {
const [profileModalOpen, setProfileModalOpen] = useState(false); const [profileModalOpen, setProfileModalOpen] = useState(false);
const [systemManagerModalOpen, setSystemManagerModalOpen] = useState(false); const [systemManagerModalOpen, setSystemManagerModalOpen] = useState(false);
const [aiConfigModalOpen, setAiConfigModalOpen] = useState(false); const [aiConfigModalOpen, setAiConfigModalOpen] = useState(false);
const [pluginManagerModalOpen, setPluginManagerModalOpen] = useState(false);
const [pluginsData, setPluginsData] = useState(null);
useEffect(() => { useEffect(() => {
const checkAuthStatus = async () => { const checkAuthStatus = async () => {
const savedToken = localStorage.getItem('sonic_token'); const savedToken = localStorage.getItem('sonic_token');
@@ -11216,6 +11299,15 @@ const App = () => {
label: 'DSP Tools Panel', label: 'DSP Tools Panel',
icon: 'wrench', icon: 'wrench',
action: () => openPanel('python_tools') action: () => openPanel('python_tools')
}, {
sep: true
}, {
label: 'Plugin Manager (SoundFont/VSTi)',
icon: 'zap',
action: () => {
setPluginManagerModalOpen(true);
window.SonicAPI.listPlugins().then(data => setPluginsData(data)).catch(() => {});
}
}] }]
}, { }, {
label: 'Help', label: 'Help',
@@ -13676,6 +13768,10 @@ const App = () => {
}), /*#__PURE__*/React.createElement(SystemManagerModal, { }), /*#__PURE__*/React.createElement(SystemManagerModal, {
isOpen: systemManagerModalOpen, isOpen: systemManagerModalOpen,
onClose: () => setSystemManagerModalOpen(false) onClose: () => setSystemManagerModalOpen(false)
}), /*#__PURE__*/React.createElement(PluginManagerModal, {
isOpen: pluginManagerModalOpen,
onClose: () => setPluginManagerModalOpen(false),
pluginsData: pluginsData
})); }));
}; };
const root = ReactDOM.createRoot(document.getElementById('root')); const root = ReactDOM.createRoot(document.getElementById('root'));
+41 -3
View File
@@ -46,6 +46,43 @@ const AIGateway = (function() {
name: 'export_audio', description: 'Xuất file WAV/MP3/OGG và tải về', parameters: { type: 'object', properties: { track_id: { type: 'string' }, format: { type: 'string', enum: ['wav', 'mp3', 'ogg'] }, sample_rate: { type: 'string', enum: ['22500', '44100'] }, bit_depth: { type: 'string', enum: ['8', '16', '24'] }, quality: { type: 'string', enum: ['44khz', 'lossless'] }, channels: { type: 'string', enum: ['mono', 'stereo'] }, start_time: { type: 'number' }, end_time: { type: 'number' }, start_bar: { type: 'number' }, length_bars: { type: 'number' } }, required: ['format'] } name: 'export_audio', description: 'Xuất file WAV/MP3/OGG và tải về', parameters: { type: 'object', properties: { track_id: { type: 'string' }, format: { type: 'string', enum: ['wav', 'mp3', 'ogg'] }, sample_rate: { type: 'string', enum: ['22500', '44100'] }, bit_depth: { type: 'string', enum: ['8', '16', '24'] }, quality: { type: 'string', enum: ['44khz', 'lossless'] }, channels: { type: 'string', enum: ['mono', 'stereo'] }, start_time: { type: 'number' }, end_time: { type: 'number' }, start_bar: { type: 'number' }, length_bars: { type: 'number' } }, required: ['format'] }
}, { }, {
name: 'fade_out', description: 'Fade-out clip (0.5s đến max)', parameters: { type: 'object', properties: { track_id: { type: 'string' }, duration_seconds: { type: 'number' }, clip_index: { type: 'number', description: 'Chỉ số của clip trên track (1-based, ví dụ: 1 cho clip 1, 2 cho clip 2)' }, clip_id: { type: 'string', description: 'ID của clip cụ thể' } } } name: 'fade_out', description: 'Fade-out clip (0.5s đến max)', parameters: { type: 'object', properties: { track_id: { type: 'string' }, duration_seconds: { type: 'number' }, clip_index: { type: 'number', description: 'Chỉ số của clip trên track (1-based, ví dụ: 1 cho clip 1, 2 cho clip 2)' }, clip_id: { type: 'string', description: 'ID của clip cụ thể' } } }
}, {
name: 'generate_multitrack_midi',
description: 'Generates multi-track MIDI data based on genre, bar duration, and requested instruments list.',
parameters: {
type: 'object',
properties: {
composition_title: { type: 'string', description: 'Title of the musical piece (e.g., Epic Orchestra Intro 8-Bars)' },
bpm: { type: 'integer' },
total_bars: { type: 'integer' },
tracks: {
type: 'array',
description: 'Array of instrument tracks along with their corresponding MIDI notes',
items: {
type: 'object',
properties: {
track_name: { type: 'string', description: 'Track name (e.g., String Ensemble, Epic Brass, Taiko Drums)' },
instrument_type: { type: 'string', enum: ['STRINGS', 'BRASS', 'SYNTH', 'PERCUSSION', 'DRUMS'] },
notes: {
type: 'array',
items: {
type: 'object',
properties: {
pitch: { type: 'integer', description: 'MIDI note pitch from 0 to 127 (e.g., C4 = 60, C3 = 48)' },
start_beat: { type: 'number', description: 'Note start position in beats (from 0.0 to total_bars * 4.0)' },
duration_beats: { type: 'number', description: 'Note length in beats (e.g., Quarter note = 1.0, Eighth note = 0.5)' },
velocity: { type: 'number', description: 'Keypress velocity intensity from 0.0 to 1.0' }
},
required: ['pitch', 'start_beat', 'duration_beats', 'velocity']
}
}
},
required: ['track_name', 'instrument_type', 'notes']
}
}
},
required: ['composition_title', 'bpm', 'total_bars', 'tracks']
}
}]; }];
function parseOrigin(urlStr) { function parseOrigin(urlStr) {
@@ -139,12 +176,13 @@ const AIGateway = (function() {
return calls; return calls;
} }
function buildUserMessage(prompt, context) { function buildUserMessage(prompt, context, systemInstruction = '') {
const contextStr = JSON.stringify(context, null, 2); const contextStr = JSON.stringify(context, null, 2);
const toolNames = DEFAULT_TOOLS.map(t => ` - ${t.name}: ${t.description}`).join('\n'); const toolNames = DEFAULT_TOOLS.map(t => ` - ${t.name}: ${t.description}`).join('\n');
return [ return [
{ role: 'system', content: `Bạn là trợ lý điều khiển DAW chuyên nghiệp. { role: 'system', content: `Bạn là trợ lý điều khiển DAW chuyên nghiệp.
Nhiệm vụ của bạn là phân tích yêu cầu của người dùng và chuyển đổi thành danh sách các function calls tương ứng. Nhiệm vụ của bạn là phân tích yêu cầu của người dùng và chuyển đổi thành danh sách các function calls tương ứng.
${systemInstruction ? `\nHướng dẫn tạo nhạc đặc biệt từ Preset:\n${systemInstruction}\n` : ''}
QUAN TRỌNG: QUAN TRỌNG:
1. Bạn đang hoạt động ở chế độ một lượt (one-shot). Hãy trả về TẤT CẢ các function calls cần thiết để thực hiện toàn bộ các bước trong yêu cầu của người dùng trong một phản hồi duy nhất. Đừng thực hiện từng bước qua nhiều lượt chat. 1. Bạn đang hoạt động ở chế độ một lượt (one-shot). Hãy trả về TẤT CẢ các function calls cần thiết để thực hiện toàn bộ các bước trong yêu cầu của người dùng trong một phản hồi duy nhất. Đừng thực hiện từng bước qua nhiều lượt chat.
2. Có thể gọi nhiều function cùng một lúc (gọi song song/nối tiếp). Chúng sẽ được thực thi theo thứ tự bạn trả về. 2. Có thể gọi nhiều function cùng một lúc (gọi song song/nối tiếp). Chúng sẽ được thực thi theo thứ tự bạn trả về.
@@ -187,8 +225,8 @@ Ví dụ: "Hãy chọn và copy từ bar 4 đến bar 12 của track 1 sau đó
}; };
} }
async function executeAIPrompt({ prompt, provider, model, apiKey, baseUrl, dawContext, tools }) { async function executeAIPrompt({ prompt, provider, model, apiKey, baseUrl, dawContext, tools, systemInstruction }) {
const messages = buildUserMessage(prompt, dawContext); const messages = buildUserMessage(prompt, dawContext, systemInstruction);
const toolList = tools || DEFAULT_TOOLS; const toolList = tools || DEFAULT_TOOLS;
const completion = await callLLM({ const completion = await callLLM({
+20 -1
View File
@@ -60,6 +60,25 @@ window.API_BASE_URL = window.API_BASE_URL || window.location.origin;
saveAIConfigs: (providers) => apiRequest('/api/v1/user/config/ai', { method: 'POST', body: JSON.stringify({ providers }) }), saveAIConfigs: (providers) => apiRequest('/api/v1/user/config/ai', { method: 'POST', body: JSON.stringify({ providers }) }),
getPreferences: () => apiRequest('/api/v1/user/preferences', { method: 'GET' }), getPreferences: () => apiRequest('/api/v1/user/preferences', { method: 'GET' }),
savePreferences: (prefs) => apiRequest('/api/v1/user/preferences', { method: 'POST', body: JSON.stringify({ preferences: prefs }) }) savePreferences: (prefs) => apiRequest('/api/v1/user/preferences', { method: 'POST', body: JSON.stringify({ preferences: prefs }) }),
listPlugins: () => apiRequest('/api/v1/plugins/available', { method: 'GET' }),
listDefaultSoundfonts: () => apiRequest('/api/v1/plugins/default-soundfonts', { method: 'GET' }),
renderProject: (projectJson, outputFilename) => apiRequest('/api/v1/plugins/render', { method: 'POST', body: JSON.stringify({ project_json: projectJson, output_filename: outputFilename }) }),
uploadSoundFont: async (file) => {
const formData = new FormData();
formData.append('file', file);
const token = localStorage.getItem('sonic_token') || '';
const resp = await fetch(`${window.API_BASE_URL}/api/v1/plugins/upload-soundfont`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}` },
body: formData
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.detail || 'Upload failed');
}
return resp.json();
}
}; };
})(); })();
@@ -71,6 +71,7 @@ const DAWCommandDispatcher = (function() {
register('CREATE_MIDI_ITEM', (args) => AIGateway.createMidiItem(args)); register('CREATE_MIDI_ITEM', (args) => AIGateway.createMidiItem(args));
register('MODIFY_MIDI_NOTES', (args) => AIGateway.modifyMidiNotes(args)); register('MODIFY_MIDI_NOTES', (args) => AIGateway.modifyMidiNotes(args));
register('PROCESS_AI_DSP', (args) => AIGateway.processAIDSP(args)); register('PROCESS_AI_DSP', (args) => AIGateway.processAIDSP(args));
register('GENERATE_MULTITRACK_MIDI', (args) => api.generateMultitrackMidi(args));
} }
return { return {
+75
View File
@@ -0,0 +1,75 @@
// SonicForge Studio SoundFont Player Service
(function () {
'use strict';
// Web Audio API fallback synth
let audioCtx = null;
let gainNode = null;
const activeOscillators = {};
function getCtx() {
if (!audioCtx) {
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
gainNode = audioCtx.createGain();
gainNode.gain.value = 0.3;
gainNode.connect(audioCtx.destination);
}
return audioCtx;
}
const SonicSF = {
loadedFonts: {},
// Load SoundFont from URL → ArrayBuffer → store in memory
loadSoundFont: async function (url) {
if (this.loadedFonts[url]) return this.loadedFonts[url];
const resp = await fetch(url);
if (!resp.ok) throw new Error('Failed to load SoundFont: ' + url);
const buffer = await resp.arrayBuffer();
this.loadedFonts[url] = buffer;
return buffer;
},
// Play a MIDI note using Web Audio fallback
playNote: function (note, velocity, durationMs) {
const ctx = getCtx();
const freq = 440 * Math.pow(2, (note - 69) / 12);
const osc = ctx.createOscillator();
const noteGain = ctx.createGain();
osc.type = 'triangle';
osc.frequency.value = freq;
noteGain.gain.setValueAtTime(velocity / 127 * 0.3, ctx.currentTime);
noteGain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + durationMs / 1000);
osc.connect(noteGain);
noteGain.connect(ctx.destination);
osc.start(ctx.currentTime);
osc.stop(ctx.currentTime + durationMs / 1000 + 0.05);
activeOscillators[note] = osc;
return osc;
},
stopAll: function () {
Object.values(activeOscillators).forEach(osc => {
try { osc.stop(); } catch (e) { }
});
Object.keys(activeOscillators).forEach(k => delete activeOscillators[k]);
},
// Save user SoundFont to IndexedDB via window.SonicStorage
saveToIndexedDB: async function (name, arrayBuffer) {
if (window.SonicStorage && window.SonicStorage.saveToIndexedDB) {
await window.SonicStorage.saveToIndexedDB('soundfont_' + name, arrayBuffer);
}
},
// Load user SoundFont from IndexedDB
loadFromIndexedDB: async function (name) {
if (window.SonicStorage && window.SonicStorage.loadFromIndexedDB) {
return await window.SonicStorage.loadFromIndexedDB('soundfont_' + name);
}
return null;
}
};
window.SonicSF = SonicSF;
})();
Binary file not shown.
+1
View File
@@ -13,6 +13,7 @@
<script src="/static/js/services/api.js"></script> <script src="/static/js/services/api.js"></script>
<script src="/static/js/services/audioEngine.js"></script> <script src="/static/js/services/audioEngine.js"></script>
<script src="/static/js/services/storage.js"></script> <script src="/static/js/services/storage.js"></script>
<script src="/static/js/services/soundfontPlayer.js"></script>
<script src="/static/js/services/aiGateway.js"></script> <script src="/static/js/services/aiGateway.js"></script>
<script src="/static/js/services/dawCommandDispatcher.js"></script> <script src="/static/js/services/dawCommandDispatcher.js"></script>
<script src="/static/js/app.precompiled.js?v=202607231122" defer></script> <script src="/static/js/app.precompiled.js?v=202607231122" defer></script>
+367
View File
@@ -0,0 +1,367 @@
# TECHNICAL SPECIFICATION: AI MIDI GENERATOR TOOL & PROMPT PRESET SYSTEM
---
## 1. System Overview
The AI Copilot system integrated within the DAW provides two core capabilities:
* **AI Tool Call (Function Calling):** Receives user requests, triggers the automated music generation engine, and returns a list of `Tracks` and `MIDIItems` complying with the JSON schema structure for direct placement onto the DAW Timeline.
* **Prompt Template Engine (Preset Library):** Manages a prompt template directory categorized by genre, mood, and song structure. Upon detecting specific keywords (e.g., *"epic orchestra"*), the engine automatically looks up and expands the raw user prompt into a System Context Standard Prompt containing music theory definitions (pitch range, scales, rhythm patterns, chord progressions, and voicing) prior to dispatching to the LLM.
```text
+-----------------------------------------------------------------------------------+
| USER INTERFACE |
| |
| [ Prompt Bar / Copilot UI ] <---> [ Prompt Template Preset Manager (CRUD) ] |
| | | |
| | Input: "Write 8 bars of | Matched Preset: |
| | epic orchestra MIDI notes..." | "Epic Orchestra Intro Spec" |
| v v |
| +---------------------------------------------------------------------------+ |
| | Prompt Context Expander Engine | |
| +--------------------------------------------------+------------------------+ |
| | |
+------------------------------------------------------|----------------------------+
| Extended Prompt Payload
v
+-----------------------------------------------------------------------------------+
| AI LLM ENGINE |
| |
| Tool Calling Execution: `generate_multitrack_midi()` |
| Output: Structured JSON Payload (Multi-track 8-bar notes) |
+------------------------------------------------------|----------------------------+
| Validated JSON Output
v
+-----------------------------------------------------------------------------------+
| DAW CLIENT STATE ENGINE |
| |
| - Parses JSON Payload |
| - Spawns/Finds Tracks (Strings, Brass, Synth, Percussion, Drums) |
| - Injects `MIDIItem` into `Main Session` / `Section Store` |
| - Re-renders Canvas & Piano Roll UI |
+-----------------------------------------------------------------------------------+
```
---
## 2. Prompt Template & Preset Engine
To eliminate the need to re-type lengthy system instructions, the application provides a Preset Manager stored as JSON format in `LocalStorage` / `IndexedDB` on the client side or within the server database.
### 2.1 Prompt Preset Schema (`preset_schema.json`)
```json
{
"id": "preset_epic_orchestra_intro",
"name": "Epic Orchestra Intro (8 Bars)",
"keywords": ["epic orchestra", "epic orchestral", "hoành tráng", "nhạc phim epic"],
"category": "Orchestral / Film Score",
"default_bars": 8,
"default_bpm": 130,
"default_scale": "C Minor",
"system_instruction_template": "You are a professional Epic Orchestral film composer. Create a powerful, dramatic 8-bar intro composition.\nThe required structure to return via the `generate_multitrack_midi` tool consists of 5 tracks:\n1. Strings Ensemble: Plays staccato 16th notes in the low register (C2, G2) driving the rhythm (Ostinato).\n2. Brass Section: Plays the main swelling melodic theme (Horn/Trumpet swells) in the C3-C5 range.\n3. Epic Percussion / Taiko: Hits heavily on beats 1 and 3, featuring a snare roll accent at bars 4 and 8.\n4. Synth Bass/Pad: Holds smooth octave foundation layers (Legato).\n5. Orchestral Drums/Cymbals: Crashes on bar 1 and bar 5.\nEnsure the duration of each track is precisely 8 bars (32 beats).",
"is_user_defined": false,
"created_at": "2026-07-23T16:00:00Z"
}
```
### 2.2 Intent Detection & Auto-Expansion Flow
When a user submits a prompt message inside the AI Copilot UI:
```javascript
class PromptTemplateManager {
constructor() {
this.presets = [];
this.loadPresets();
}
// Loads preset list from LocalStorage or API
async loadPresets() {
const localData = localStorage.getItem('daw_ai_prompt_presets');
if (localData) {
this.presets = JSON.parse(localData);
} else {
this.presets = DEFAULT_PRESETS; // Developer default fallback templates
this.savePresets();
}
}
// Matches queries against preset keywords automatically
matchPreset(userQuery) {
const queryLower = userQuery.toLowerCase();
for (const preset of this.presets) {
const hasKeyword = preset.keywords.some(kw => queryLower.includes(kw.toLowerCase()));
if (hasKeyword) {
return preset;
}
}
return null; // Fallback to raw user prompt if no keyword matches
}
// CRUD methods for user-defined presets
saveUserPreset(presetObject) {
const index = this.presets.findIndex(p => p.id === presetObject.id);
if (index >= 0) {
this.presets[index] = presetObject;
} else {
this.presets.push(presetObject);
}
this.savePresets();
}
savePresets() {
localStorage.setItem('daw_ai_prompt_presets', JSON.stringify(this.presets));
}
}
```
---
## 3. Function Calling Specification
Defines the Tool/Function schema passed to the LLM API (OpenAI / Local LLM) to enforce strict structured JSON output.
### 3.1 Function Tool Schema (`tools_spec.json`)
```json
{
"type": "function",
"function": {
"name": "generate_multitrack_midi",
"description": "Generates multi-track MIDI data based on genre, bar duration, and requested instruments list.",
"parameters": {
"type": "object",
"properties": {
"composition_title": {
"type": "string",
"description": "Title of the musical piece (e.g., Epic Orchestra Intro 8-Bars)"
},
"bpm": {
"type": "integer",
"default": 120
},
"total_bars": {
"type": "integer",
"default": 8
},
"tracks": {
"type": "array",
"description": "Array of instrument tracks along with their corresponding MIDI notes",
"items": {
"type": "object",
"properties": {
"track_name": {
"type": "string",
"description": "Track name (e.g., String Ensemble, Epic Brass, Taiko Drums)"
},
"instrument_type": {
"type": "string",
"enum": ["STRINGS", "BRASS", "SYNTH", "PERCUSSION", "DRUMS"]
},
"notes": {
"type": "array",
"items": {
"type": "object",
"properties": {
"pitch": {
"type": "integer",
"description": "MIDI note pitch from 0 to 127 (e.g., C4 = 60, C3 = 48)"
},
"start_beat": {
"type": "number",
"description": "Note start position in beats (from 0.0 to total_bars * 4.0)"
},
"duration_beats": {
"type": "number",
"description": "Note length in beats (e.g., Quarter note = 1.0, Eighth note = 0.5)"
},
"velocity": {
"type": "number",
"description": "Keypress velocity intensity from 0.0 to 1.0",
"default": 0.8
}
},
"required": ["pitch", "start_beat", "duration_beats", "velocity"]
}
}
},
"required": ["track_name", "instrument_type", "notes"]
}
}
},
"required": ["composition_title", "bpm", "total_bars", "tracks"]
}
}
}
```
---
## 4. AI Response Payload Example
Below is an example JSON payload returned by the AI following the execution of `generate_multitrack_midi` for an 8-bar Epic Orchestra request:
```json
{
"tool_call": "generate_multitrack_midi",
"result": {
"composition_title": "Epic Orchestra Intro",
"bpm": 130,
"total_bars": 8,
"tracks": [
{
"track_name": "String Ensemble (Staccato)",
"instrument_type": "STRINGS",
"notes": [
{ "pitch": 36, "start_beat": 0.0, "duration_beats": 0.25, "velocity": 0.9 },
{ "pitch": 36, "start_beat": 0.5, "duration_beats": 0.25, "velocity": 0.85 },
{ "pitch": 48, "start_beat": 1.0, "duration_beats": 0.25, "velocity": 0.95 },
{ "pitch": 36, "start_beat": 1.5, "duration_beats": 0.25, "velocity": 0.8 }
]
},
{
"track_name": "French Horns & Brass",
"instrument_type": "BRASS",
"notes": [
{ "pitch": 60, "start_beat": 0.0, "duration_beats": 2.0, "velocity": 0.95 },
{ "pitch": 63, "start_beat": 2.0, "duration_beats": 2.0, "velocity": 0.9 },
{ "pitch": 67, "start_beat": 4.0, "duration_beats": 4.0, "velocity": 1.0 }
]
},
{
"track_name": "Epic Synth Lead",
"instrument_type": "SYNTH",
"notes": [
{ "pitch": 72, "start_beat": 4.0, "duration_beats": 1.0, "velocity": 0.85 },
{ "pitch": 75, "start_beat": 5.0, "duration_beats": 1.0, "velocity": 0.85 }
]
},
{
"track_name": "Taiko & Percussion",
"instrument_type": "PERCUSSION",
"notes": [
{ "pitch": 36, "start_beat": 0.0, "duration_beats": 0.5, "velocity": 1.0 },
{ "pitch": 36, "start_beat": 2.0, "duration_beats": 0.5, "velocity": 0.95 },
{ "pitch": 38, "start_beat": 3.5, "duration_beats": 0.25, "velocity": 0.8 }
]
},
{
"track_name": "Orchestral Cymbals",
"instrument_type": "DRUMS",
"notes": [
{ "pitch": 49, "start_beat": 0.0, "duration_beats": 4.0, "velocity": 0.9 },
{ "pitch": 49, "start_beat": 16.0, "duration_beats": 4.0, "velocity": 1.0 }
]
}
]
}
}
```
---
## 5. DAW State Ingestion Logic
When the client receives the AI JSON payload, the `ingestAIGeneratedMIDI()` function executes the following pipeline:
1. Spawns or matches corresponding tracks within `main_session`.
2. Generates 8-bar `MIDIItem` objects containing the note lists.
3. Triggers UI canvas and timeline re-renders.
```javascript
function ingestAIGeneratedMIDI(aiPayload, sessionState) {
const { composition_title, bpm, total_bars, tracks } = aiPayload.result;
// 1. Update project BPM if specified
if (bpm) sessionState.metadata.bpm = bpm;
// 2. Iterate through generated tracks
tracks.forEach((aiTrack) => {
// Match existing track or instantiate a new one
let targetTrack = sessionState.main_session.tracks.find(
t => t.name.toLowerCase() === aiTrack.track_name.toLowerCase()
);
if (!targetTrack) {
targetTrack = {
id: `track_ai_${Date.now()}_${Math.random().toString(36).substr(2, 5)}`,
name: aiTrack.track_name,
type: "MIDI",
volume_db: 0.0,
pan: 0.0,
mute: false,
solo: false,
items: []
};
sessionState.main_session.tracks.push(targetTrack);
}
// 3. Create 8-bar MIDIItem
const newMidiItem = {
id: `item_ai_${Date.now()}_${Math.random().toString(36).substr(2, 5)}`,
name: `${composition_title} - ${aiTrack.track_name}`,
type: "MIDI_ITEM",
start_bar: 0.0, // Placed at Timeline start or active Playhead position
duration_bars: total_bars,
clip_start_offset_bars: 0.0,
source_data: {
total_buffer_bars: total_bars,
notes: aiTrack.notes.map((note, index) => ({
id: `note_ai_${Date.now()}_${index}`,
pitch: note.pitch,
start_beat: note.start_beat,
duration_beats: note.duration_beats,
velocity: note.velocity,
pan: 0.0
}))
}
};
// 4. Append Item to Track
targetTrack.items.push(newMidiItem);
});
// 5. Trigger Timeline & Piano Roll UI Re-render Event
window.dispatchEvent(new CustomEvent('DAW_STATE_UPDATED', { detail: sessionState }));
}
```
---
## 6. Prompt Preset Manager UI Layout
```text
+-------------------------------------------------------------------------------+
| AI PROMPT PRESET MANAGER [ + New ]|
+-------------------------------------------------------------------------------+
| SEARCH: [ epic orchestra ] FILTER: [ Orchestral v ]|
| |
| Preset Name Keywords matched Default Bars Actions |
| --------------------------------------------------------------------------- |
| [★] Epic Orchestra Intro epic, orchestra, tráng 8 Bars [Edit][Del] |
| [ ] Pop Piano Chords pop, piano, chord 4 Bars [Edit][Del] |
| [ ] Cyberpunk Synth Synth synth, synthwave, 80s 8 Bars [Edit][Del] |
| |
+-------------------------------------------------------------------------------+
| EDIT PRESET: Epic Orchestra Intro |
| |
| Keyword Triggers (comma-separated): |
| [ epic orchestra, hoành tráng, nhạc phim epic ] |
| |
| System Instruction / Music Rules: |
| +---------------------------------------------------------------------------+ |
| | Compose an 8-bar epic orchestral theme featuring Brass, staccato | |
| | Strings, Taiko percussion, and background Synth bass... | |
| +---------------------------------------------------------------------------+ |
| [ CANCEL ] [ SAVE ]
+-------------------------------------------------------------------------------+
```
+171
View File
@@ -0,0 +1,171 @@
# Kế hoạch cài đặt SoundFont / VSTi (32_SF_VSTi.md)
> Dựa trên hiện trạng codebase: `app/core/render_engine.py`, `app/core/vst_engine.py`, `tests/test_render_engine.py`, `app/api/v1/audio.py`, `requirements.txt` (có pedalboard, numpy, soundfile).
---
## 1. Server Backend (Python / FastAPI)
### 1.1 PluginManager (app/core/vst_engine.py — SỬA)
**Hiện trạng:** `vst_engine.py``render_midi_events_to_audio()` synth sine cơ bản. Cần class quản lý VST3.
**Công việc:**
- Thêm class `PluginManager`:
- `__init__(vst_dir="/opt/daw_engine/vst3", sf_dir="/opt/daw_engine/soundfonts")`
- `_scan_plugins()`: quét `.vst3`/`.so` trong `vst_dir`
- `_scan_soundfonts()`: quét `.sf2`/`.sf3` trong `sf_dir`
- `load_vst(name, preset_data=None)`: load VST3 bằng `pedalboard.VST3Plugin`
- `load_soundfont(path)`: load SF2 bằng `pyfluidsynth` (in-memory buffer)
- `list_available()`: trả về `{ vst_instruments: [...], soundfonts: [...] }`
- Import `pedalboard` + `pyfluidsynth` với `try/except`
- **requirements.txt:** thêm `mido>=1.3.0`, `pyfluidsynth>=1.3.0`
**File:** `app/core/vst_engine.py`
---
### 1.2 Render Engine (app/core/render_engine.py — SỬA)
**Hiện trạng:** `PythonRenderEngine` render audio clips + MIDI events. Cần VST3 + SoundFont path.
**Optimization 1 — In-memory SoundFont render (thay subprocess CLI):**
- Dùng `pyfluidsynth` C-bindings thay vì lệnh `fluidsynth` CLI qua subprocess
- Luồng render:
1. `fl = pyfluidsynth.FluidSynth(sample_rate=sr, gain=0.5)`
2. `fl.sfload(path_to_sf2)``fl.program_select(track_id, font_id, bank, preset)`
3. Render từng block MIDI event → numpy array bằng `fl.render_midi(midi_events, sr)`
4. Trả về in-memory buffer, không ghi file tạm
- Loại bỏ hoàn toàn subprocess CLI (tránh I/O disk, race condition, temp file collision)
**Optimization 2 — Sample Offset chính xác cho Pedalboard VST3:**
- Công thức quy đổi beat → sample offset:
```
SampleOffset = BeatPosition × (60 / BPM) × SampleRate
```
- Khi gọi `vst_plugin(array, sample_rate=sr, midi_messages=midi_list)`:
- Mỗi `pedalboard.MidiMessage` phải kèm `sample_offset` chính xác
- Chuyển từng MIDI event: `midi_events[].start_beat``sample_offset`
- Nếu không quy đổi, VSTi dồn toàn bộ nốt vào sample đầu ($0\text{ms}$) → sai nhịp
- Thêm hàm `_midi_events_to_messages(midi_events, bpm, sr) → list[MidiMessage]`
**Công việc:**
- Thêm `render_midi_track_with_vst(midi_events, vst_plugin, sr, bpm)`:
- Gọi `_midi_events_to_messages()` → quy đổi sang sample offset
- Khởi tạo VST3 từ `PluginManager.load_vst()`, process buffer
- Fallback về basic synth nếu VST không available
- Thêm `render_soundfont_track(midi_events, sf_path, sr, bpm)`:
- Dùng `pyfluidsynth.FluidSynth` in-memory
- `fl.render_midi()` → numpy array
- Sửa `render_session_container()` nhận `track["instrument"]` (VST/SF id) mỗi track
**File:** `app/core/render_engine.py`
---
### 1.3 API Endpoints (app/api/v1/plugins.py — MỚI + main.py — SỬA)
- **GET** `/api/v1/plugins/available``PluginManager().list_available()`
- **GET** `/api/v1/plugins/default-soundfonts` → scan `app/static/soundfonts/`
- **POST** `/api/v1/projects/render` → nhận ProjectSchema → render → return URL WAV
- Mount router trong `main.py`:
```python
from app.api.v1.plugins import router
app.include_router(router, prefix="/api/v1/plugins", tags=["plugins"])
```
**Optimization 3 — Validation Magic Bytes cho Upload SoundFont:**
- Endpoint upload `.sf2` không chỉ kiểm tra đuôi file
- Thêm FastAPI Dependency kiểm tra Header/Magic Bytes:
```
RIFF header (4 bytes: 0x52 0x49 0x46 0x46) + sfbk (4 bytes: 0x73 0x66 0x62 0x6B)
```
- Logic validate:
```python
def validate_sf2_header(data: bytes):
if len(data) < 12: return False
if data[0:4] != b'RIFF': return False
if data[8:12] != b'sfbk': return False
return True
```
- Từ chối upload nếu magic bytes không khớp → chặn file độc hại ngay từ API layer
**File mới:** `app/api/v1/plugins.py`
---
### 1.4 Dockerfile / Dependencies
- **requirements.txt:** thêm `mido>=1.3.0`, `pyfluidsynth>=1.3.0`
- **Dockerfile:** cài `fluidsynth` (shared lib cho pyfluidsynth), `libfluidsynth-dev`, `build-essential`
- Tạo `/opt/daw_engine/vst3/``/opt/daw_engine/soundfonts/`
- Tạo `app/static/soundfonts/` cho default SF
---
## 2. Client Frontend (JavaScript)
### 2.1 API Client (app/static/js/services/api.js — SỬA)
Thêm methods:
- `listPlugins()``GET /v1/plugins/available`
- `listDefaultSoundfonts()``GET /v1/plugins/default-soundfonts`
- `renderProject(data)``POST /v1/projects/render`
- `uploadSoundFont(file)``POST /v1/audio/upload-soundfont` (FormData)
---
### 2.2 SoundFont Player (app/static/js/services/soundfontPlayer.js — MỚI)
- `loadSoundFont(url)` → fetch `.sf2` → ArrayBuffer
- `initFluidSynth()` → khởi tạo Was m engine (fallback Web Audio API basic synth)
- `playNote(note, velocity, duration)` → play MIDI note
- Lưu user SF vào IndexedDB qua `window.SonicStorage`
- Global: `window.SonicSF`
---
### 2.3 Synth / Instrument Panel UI (index.html)
- **Instrument Selector:** dropdown chọn VST/SoundFont từ `listPlugins()`
- **SoundFont Upload:** drag & drop `.sf2` → IndexedDB
- **Preset Browser:** load `.vital`/`.fxp`/`.syx` cho VST đang chọn
- **Render to WAV button:** gọi `renderProject()` → progress → play
---
### 2.4 MIDI Track Support (index.html)
- Track model thêm: `type: 'audio' | 'midi'`, `midiEvents: []`, `instrumentId`
- WaveformLane vẽ MIDI notes (rects) thay waveform
- Double-click → piano roll editor (tham khảo `md/Piano_roll_UX.md`)
---
## 3. Tests
| Test | File | Mới/Sửa |
|---|---|---|
| PluginManager init + scan + load | `tests/test_vst_engine.py` | MỚI |
| Render MIDI track with VST (sample offset chính xác) | `tests/test_render_engine.py` | SỬA |
| Render MIDI track with SoundFont (pyfluidsynth in-memory) | `tests/test_render_engine.py` | SỬA |
| Render project mixed audio + MIDI | `tests/test_render_engine.py` | SỬA |
| API plugins/available/render | `tests/test_plugin_api.py` | MỚI |
| SF2 Magic Bytes validation (RIFF+sfbk) | `tests/test_plugin_api.py` | MỚI |
---
## 4. Thứ tự thực hiện
| Bước | Mô tả | Phụ thuộc |
|---|---|---|
| **B1** | Sửa `vst_engine.py`: PluginManager class | |
| **B2** | Thêm `mido`, `pyfluidsynth` vào `requirements.txt` | |
| **B3** | Tạo `app/api/v1/plugins.py`: 3 endpoints + SF2 magic bytes validate | B1 |
| **B4** | Mount router trong `main.py` | B3 |
| **B5** | Sửa `render_engine.py`: VST3 (sample offset) + SF (pyfluidsynth in-memory) | B1 |
| **B6** | Tạo `soundfontPlayer.js` | |
| **B7** | Sửa `api.js`: plugin/SF methods | |
| **B8** | UI Synth Panel + MIDI track (index.html) | B5-B7 |
| **B9** | Tests | B1-B5 |
| **B10** | Dockerfile: fluidsynth lib + VST dirs | |
+2
View File
@@ -12,3 +12,5 @@ jinja2>=3.1.2
httpx>=0.24.0 httpx>=0.24.0
jsonschema>=4.18.0 jsonschema>=4.18.0
pedalboard>=0.8.0 pedalboard>=0.8.0
mido>=1.3.0
pyfluidsynth>=1.3.0
+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