feat: thêm soundfont và VSTi cho MIDI
This commit is contained in:
@@ -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
@@ -2,24 +2,13 @@ import os
|
||||
import numpy as np
|
||||
import soundfile as sf
|
||||
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:
|
||||
try:
|
||||
from pedalboard import Pedalboard, Gain
|
||||
@@ -130,14 +119,71 @@ class PythonRenderEngine:
|
||||
|
||||
if midi_events:
|
||||
try:
|
||||
# Synthesize MIDI track notes
|
||||
synth_buffer = render_midi_events_to_audio(
|
||||
midi_events=midi_events,
|
||||
sr=self.sample_rate,
|
||||
bpm=bpm,
|
||||
instrument='synth'
|
||||
)
|
||||
# Add to track buffer
|
||||
instrument_id = track.get("instrument", "")
|
||||
plugin_mgr = PluginManager()
|
||||
vst = plugin_mgr.load_vst(instrument_id) if instrument_id else None
|
||||
|
||||
if vst and HAS_PEDALBOARD:
|
||||
from pedalboard import Pedalboard
|
||||
# Convert MIDI events with precise sample offset
|
||||
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)
|
||||
track_buffer[:, :actual_len] += synth_buffer[:, :actual_len]
|
||||
except Exception as e:
|
||||
|
||||
+131
-31
@@ -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
|
||||
|
||||
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))
|
||||
|
||||
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)
|
||||
max_duration_sec = 2.0
|
||||
|
||||
for event in midi_events:
|
||||
start_beat = event.get('start_beat', 0.0)
|
||||
dur_beats = event.get('duration_beats', 1.0)
|
||||
end_sec = (start_beat + dur_beats) * beat_duration_sec
|
||||
if end_sec > max_duration_sec:
|
||||
max_duration_sec = end_sec
|
||||
|
||||
total_samples = int((max_duration_sec + 0.5) * sr)
|
||||
out_l = np.zeros(total_samples, dtype=np.float32)
|
||||
out_r = np.zeros(total_samples, dtype=np.float32)
|
||||
|
||||
for event in midi_events:
|
||||
note = event.get('note', 60)
|
||||
velocity = event.get('velocity', 100) / 127.0
|
||||
start_beat = event.get('start_beat', 0.0)
|
||||
dur_beats = event.get('duration_beats', 1.0)
|
||||
|
||||
start_sample = int(start_beat * beat_duration_sec * sr)
|
||||
dur_samples = int(dur_beats * beat_duration_sec * sr)
|
||||
end_sample = min(total_samples, start_sample + dur_samples)
|
||||
actual_len = end_sample - start_sample
|
||||
|
||||
if actual_len <= 0 or start_sample >= total_samples:
|
||||
continue
|
||||
|
||||
freq = midi_note_to_freq(note)
|
||||
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)
|
||||
|
||||
# ADSR Envelope
|
||||
attack = min(int(0.01 * 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)
|
||||
if attack > 0:
|
||||
env[:attack] = np.linspace(0.0, 1.0, attack)
|
||||
if release > 0:
|
||||
env[-release:] = np.linspace(1.0, 0.0, release)
|
||||
|
||||
signal = tone * env * velocity
|
||||
|
||||
out_l[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)))
|
||||
if max_peak > 1.0:
|
||||
out_l /= max_peak
|
||||
out_r /= max_peak
|
||||
|
||||
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
|
||||
|
||||
@@ -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.user_config import router as user_config_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
|
||||
|
||||
# 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(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(plugins_router, prefix="/api/v1/plugins", tags=["plugins"])
|
||||
|
||||
# Seed admin user on startup
|
||||
@app.on_event("startup")
|
||||
|
||||
@@ -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"
|
||||
}, 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 = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
@@ -4998,6 +5079,8 @@ const App = () => {
|
||||
const [profileModalOpen, setProfileModalOpen] = useState(false);
|
||||
const [systemManagerModalOpen, setSystemManagerModalOpen] = useState(false);
|
||||
const [aiConfigModalOpen, setAiConfigModalOpen] = useState(false);
|
||||
const [pluginManagerModalOpen, setPluginManagerModalOpen] = useState(false);
|
||||
const [pluginsData, setPluginsData] = useState(null);
|
||||
useEffect(() => {
|
||||
const checkAuthStatus = async () => {
|
||||
const savedToken = localStorage.getItem('sonic_token');
|
||||
@@ -11216,6 +11299,15 @@ const App = () => {
|
||||
label: 'DSP Tools Panel',
|
||||
icon: 'wrench',
|
||||
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',
|
||||
@@ -13676,6 +13768,10 @@ const App = () => {
|
||||
}), /*#__PURE__*/React.createElement(SystemManagerModal, {
|
||||
isOpen: systemManagerModalOpen,
|
||||
onClose: () => setSystemManagerModalOpen(false)
|
||||
}), /*#__PURE__*/React.createElement(PluginManagerModal, {
|
||||
isOpen: pluginManagerModalOpen,
|
||||
onClose: () => setPluginManagerModalOpen(false),
|
||||
pluginsData: pluginsData
|
||||
}));
|
||||
};
|
||||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||
|
||||
@@ -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: '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) {
|
||||
@@ -139,12 +176,13 @@ const AIGateway = (function() {
|
||||
return calls;
|
||||
}
|
||||
|
||||
function buildUserMessage(prompt, context) {
|
||||
function buildUserMessage(prompt, context, systemInstruction = '') {
|
||||
const contextStr = JSON.stringify(context, null, 2);
|
||||
const toolNames = DEFAULT_TOOLS.map(t => ` - ${t.name}: ${t.description}`).join('\n');
|
||||
return [
|
||||
{ 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.
|
||||
${systemInstruction ? `\nHướng dẫn tạo nhạc đặc biệt từ Preset:\n${systemInstruction}\n` : ''}
|
||||
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.
|
||||
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 }) {
|
||||
const messages = buildUserMessage(prompt, dawContext);
|
||||
async function executeAIPrompt({ prompt, provider, model, apiKey, baseUrl, dawContext, tools, systemInstruction }) {
|
||||
const messages = buildUserMessage(prompt, dawContext, systemInstruction);
|
||||
const toolList = tools || DEFAULT_TOOLS;
|
||||
|
||||
const completion = await callLLM({
|
||||
|
||||
@@ -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 }) }),
|
||||
|
||||
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('MODIFY_MIDI_NOTES', (args) => AIGateway.modifyMidiNotes(args));
|
||||
register('PROCESS_AI_DSP', (args) => AIGateway.processAIDSP(args));
|
||||
register('GENERATE_MULTITRACK_MIDI', (args) => api.generateMultitrackMidi(args));
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -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.
@@ -13,6 +13,7 @@
|
||||
<script src="/static/js/services/api.js"></script>
|
||||
<script src="/static/js/services/audioEngine.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/dawCommandDispatcher.js"></script>
|
||||
<script src="/static/js/app.precompiled.js?v=202607231122" defer></script>
|
||||
|
||||
Reference in New Issue
Block a user