Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 59cf77bcbc | |||
| e3c9d6e4ab | |||
| 255e8586bc | |||
| 916ce1334b | |||
| 94dc7dcf12 | |||
| 371b1ca665 | |||
| 0af834a8fa | |||
| 500384a226 | |||
| b9817e1aed | |||
| 8f1e89ce50 | |||
| 9eec33cc79 | |||
| aaf21ddf89 | |||
| 6bc197b909 | |||
| a12c133ba6 | |||
| 9fc5498999 | |||
| 9dcc37e037 | |||
| 2001d1c601 | |||
| ee30ca097e | |||
| fa2f96dbe3 | |||
| 6da0c5b68d | |||
| aa06d40b98 | |||
| cadb5402a3 | |||
| 7293d7ac7e | |||
| f8cb2c6a5e |
+11
@@ -33,7 +33,18 @@ node_modules
|
||||
src-tauri/target/
|
||||
src-tauri/binaries/
|
||||
src-tauri/resources/daw_engine/
|
||||
src-tauri/resources/native_host/*
|
||||
!src-tauri/resources/native_host/.gitkeep
|
||||
!src-tauri/resources/native_host/*.dll
|
||||
src-tauri/vc_redist.x64.exe
|
||||
app/storage/plugin_dirs.json
|
||||
app/storage/sf_scan_state.json.bak-root
|
||||
app/storage/soundfonts/
|
||||
|
||||
# Native host build artifacts
|
||||
native_host/tests/*.dll
|
||||
native_host/tests/*.exp
|
||||
native_host/tests/*.lib
|
||||
native_host/tests/*.obj
|
||||
native_host/tests/*.exe
|
||||
native_host/fluidsynth_runtime/
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
# SonicForge Client Logs API — client gửi log hành động (MIDI/mastering/drag
|
||||
# errors) qua POST /api/v1/logs. Không lưu DB — in ra server log (uvicorn
|
||||
# console). Public: log không chứa dữ liệu nhạy (client tự quyết định).
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel
|
||||
|
||||
router = APIRouter()
|
||||
_logger = logging.getLogger("sonicforge.client_logs")
|
||||
|
||||
|
||||
class ClientLogEntry(BaseModel):
|
||||
category: str = "GENERAL"
|
||||
level: str = "info"
|
||||
message: str = ""
|
||||
data: Optional[dict[str, Any]] = None
|
||||
url: Optional[str] = None
|
||||
ts: Optional[str] = None
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def post_client_log(entry: ClientLogEntry):
|
||||
"""Ghi log từ client vào server log (fire-and-forget, luôn 200)."""
|
||||
tag = f"[ClientLog][{entry.category}][{entry.level}]"
|
||||
line = f"{tag} {entry.message}"
|
||||
if entry.data:
|
||||
line += f" {entry.data}"
|
||||
if entry.ts:
|
||||
line += f" (ts={entry.ts})"
|
||||
if entry.url:
|
||||
line += f" url={entry.url}"
|
||||
if entry.level == "error":
|
||||
_logger.error(line)
|
||||
elif entry.level == "warn":
|
||||
_logger.warning(line)
|
||||
else:
|
||||
_logger.info(line)
|
||||
return {"ok": True}
|
||||
@@ -0,0 +1,260 @@
|
||||
# SonicForge Native Audio API (T15) — track instrument đi thẳng vào engine
|
||||
# native (SF host / VST2 / VST3 bridge DLL), KHÔNG qua masterBus WebAudio.
|
||||
# Master fader + track gain/pan áp native qua NativeMixer — một engine duy nhất.
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from typing import List, Optional
|
||||
import os
|
||||
|
||||
from app.core.native_audio_service import get_service
|
||||
from app.core.render_engine import _find_sf2_path
|
||||
from app.core.vst_engine import get_plugin_manager
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class MasterGainRequest(BaseModel):
|
||||
gain_db: float = 0.0
|
||||
|
||||
|
||||
class TrackGainPanRequest(BaseModel):
|
||||
track_id: str
|
||||
gain_db: float = 0.0
|
||||
pan: float = 0.0
|
||||
|
||||
|
||||
class SfEnsureRequest(BaseModel):
|
||||
track_id: str
|
||||
sf_path: Optional[str] = None
|
||||
sf_id: Optional[str] = None # resolve path server-side (JS chỉ có UUID)
|
||||
bank: int = 0
|
||||
program: int = 0
|
||||
channel: int = 0
|
||||
live: bool = True
|
||||
|
||||
|
||||
class SfNoteRequest(BaseModel):
|
||||
track_id: str
|
||||
channel: int = 0
|
||||
pitch: int
|
||||
velocity: int = 100
|
||||
|
||||
|
||||
class SfNoteOffRequest(BaseModel):
|
||||
track_id: str
|
||||
channel: int = 0
|
||||
pitch: int
|
||||
|
||||
|
||||
class Vst2EnsureRequest(BaseModel):
|
||||
track_id: str
|
||||
plugin_path: Optional[str] = None
|
||||
plugin_id: Optional[str] = None # resolve path server-side
|
||||
live: bool = True
|
||||
|
||||
|
||||
class Vst2NoteRequest(BaseModel):
|
||||
track_id: str
|
||||
channel: int = 0
|
||||
pitch: int
|
||||
velocity: int = 100
|
||||
|
||||
|
||||
class Vst2NoteOffRequest(BaseModel):
|
||||
track_id: str
|
||||
channel: int = 0
|
||||
pitch: int
|
||||
|
||||
class Vst3EnsureRequest(BaseModel):
|
||||
track_id: str
|
||||
plugin_path: Optional[str] = None
|
||||
plugin_id: Optional[str] = None # resolve path server-side
|
||||
live: bool = True
|
||||
|
||||
class Vst3NoteRequest(BaseModel):
|
||||
track_id: str
|
||||
channel: int = 0
|
||||
pitch: int
|
||||
velocity: int = 100
|
||||
|
||||
class Vst3NoteOffRequest(BaseModel):
|
||||
track_id: str
|
||||
channel: int = 0
|
||||
pitch: int
|
||||
|
||||
|
||||
class RenderNote(BaseModel):
|
||||
note: int = 60
|
||||
velocity: int = 100
|
||||
start_beat: float = 0.0
|
||||
duration_beats: float = 1.0
|
||||
|
||||
|
||||
class RenderRequest(BaseModel):
|
||||
kind: str # "sf" | "vst2"
|
||||
path: str
|
||||
bank: int = 0
|
||||
program: int = 0
|
||||
notes: List[RenderNote] = []
|
||||
bpm: float = 120.0
|
||||
gain_db: float = 0.0
|
||||
pan: float = 0.0
|
||||
lim_active: bool = False
|
||||
threshold_db: float = -1.0
|
||||
master_gain_db: float = 0.0
|
||||
|
||||
|
||||
def _svc():
|
||||
return get_service()
|
||||
|
||||
|
||||
def _resolve_plugin_path(plugin_id: str = None, plugin_path: str = None) -> str:
|
||||
"""Resolve plugin path server-side: uu tien plugin_path truc tiep, con
|
||||
khong thi plugin_id = ten plugin trong registry scan (JS chi co plugin_id)."""
|
||||
path = (plugin_path or "").strip()
|
||||
if path:
|
||||
return path
|
||||
pid = (plugin_id or "").strip()
|
||||
if pid:
|
||||
try:
|
||||
plugins = get_plugin_manager()._scan_plugins()
|
||||
if pid in plugins:
|
||||
return plugins[pid]
|
||||
except Exception:
|
||||
pass
|
||||
return ""
|
||||
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
async def status():
|
||||
return _svc().status()
|
||||
|
||||
|
||||
@router.post("/set_master_gain")
|
||||
async def set_master_gain(req: MasterGainRequest):
|
||||
return _svc().set_master_gain(req.gain_db)
|
||||
|
||||
|
||||
@router.post("/track_gain_pan")
|
||||
async def track_gain_pan(req: TrackGainPanRequest):
|
||||
return _svc().set_track_gain_pan(req.track_id, req.gain_db, req.pan)
|
||||
|
||||
|
||||
@router.post("/sf/ensure")
|
||||
async def sf_ensure(req: SfEnsureRequest):
|
||||
path = (req.sf_path or "").strip()
|
||||
if not path and req.sf_id:
|
||||
path = _find_sf2_path(req.sf_id)
|
||||
if not path or not os.path.exists(path):
|
||||
raise HTTPException(status_code=400, detail=f"không tìm thấy SF2/SF3 "
|
||||
f"(sf_path={req.sf_path!r} sf_id={req.sf_id!r})")
|
||||
try:
|
||||
return _svc().ensure_sf(req.track_id, path, req.bank,
|
||||
req.program, req.channel, req.live)
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/sf/note_on")
|
||||
async def sf_note_on(req: SfNoteRequest):
|
||||
try:
|
||||
return _svc().sf_note_on(req.track_id, req.channel, req.pitch, req.velocity)
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/sf/note_off")
|
||||
async def sf_note_off(req: SfNoteOffRequest):
|
||||
try:
|
||||
return _svc().sf_note_off(req.track_id, req.channel, req.pitch)
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/sf/audio_stop")
|
||||
async def sf_audio_stop(req: SfNoteOffRequest):
|
||||
return _svc().sf_audio_stop(req.track_id)
|
||||
|
||||
|
||||
@router.post("/vst2/ensure")
|
||||
async def vst2_ensure(req: Vst2EnsureRequest):
|
||||
path = _resolve_plugin_path(req.plugin_id, req.plugin_path)
|
||||
if not path or not os.path.exists(path):
|
||||
raise HTTPException(status_code=400, detail=f"khong tim thay plugin "
|
||||
f"(plugin_id={req.plugin_id!r} plugin_path={req.plugin_path!r})")
|
||||
try:
|
||||
return _svc().ensure_vst2(req.track_id, path, req.live)
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/vst2/note_on")
|
||||
async def vst2_note_on(req: Vst2NoteRequest):
|
||||
try:
|
||||
return _svc().vst2_note_on(req.track_id, req.channel, req.pitch, req.velocity)
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/vst2/note_off")
|
||||
async def vst2_note_off(req: Vst2NoteOffRequest):
|
||||
try:
|
||||
return _svc().vst2_note_off(req.track_id, req.channel, req.pitch)
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
@router.post("/vst3/ensure")
|
||||
async def vst3_ensure(req: Vst3EnsureRequest):
|
||||
path = _resolve_plugin_path(req.plugin_id, req.plugin_path)
|
||||
if not path or not os.path.exists(path):
|
||||
raise HTTPException(status_code=400, detail=f"khong tim thay plugin "
|
||||
f"(plugin_id={req.plugin_id!r} plugin_path={req.plugin_path!r})")
|
||||
try:
|
||||
return _svc().ensure_vst3(req.track_id, path, req.live)
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
@router.post("/vst3/note_on")
|
||||
async def vst3_note_on(req: Vst3NoteRequest):
|
||||
try:
|
||||
return _svc().vst3_note_on(req.track_id, req.channel, req.pitch, req.velocity)
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
@router.post("/vst3/note_off")
|
||||
async def vst3_note_off(req: Vst3NoteOffRequest):
|
||||
try:
|
||||
return _svc().vst3_note_off(req.track_id, req.channel, req.pitch)
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/render")
|
||||
async def render(req: RenderRequest):
|
||||
"""Offline render cho test matrix spec V. Trả về số liệu (không trả audio
|
||||
bytes) — peak/rms đủ để so sánh âm lượng/mastering."""
|
||||
svc = _svc()
|
||||
notes = [n.model_dump() for n in req.notes]
|
||||
try:
|
||||
if req.kind == "sf":
|
||||
y = svc.render_sf_offline(req.path, req.bank, req.program, notes,
|
||||
req.bpm, gain_db=req.gain_db, pan=req.pan,
|
||||
lim_active=req.lim_active,
|
||||
threshold_db=req.threshold_db,
|
||||
master_gain_db=req.master_gain_db)
|
||||
elif req.kind == "vst2":
|
||||
y = svc.render_vst2_offline(req.path, notes, req.bpm,
|
||||
gain_db=req.gain_db, pan=req.pan,
|
||||
lim_active=req.lim_active,
|
||||
threshold_db=req.threshold_db,
|
||||
master_gain_db=req.master_gain_db)
|
||||
else:
|
||||
raise HTTPException(status_code=400,
|
||||
detail="kind phải là 'sf' hoặc 'vst2'")
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
import numpy as np
|
||||
return {"ok": True, "kind": req.kind, "samples": int(y.shape[1]),
|
||||
"peak": float(np.abs(y).max()),
|
||||
"rms": float(np.sqrt((y ** 2).mean()))}
|
||||
+129
-6
@@ -490,6 +490,7 @@ async def download_soundfont_asset(sf_id: str):
|
||||
class RenderRequest(BaseModel):
|
||||
project_json: dict
|
||||
output_filename: Optional[str] = "render_output.wav"
|
||||
bit_depth: int = 16 # 16/24/32 — WAV PCM
|
||||
|
||||
|
||||
class OpenInCarlaRequest(BaseModel):
|
||||
@@ -573,7 +574,7 @@ def _carla_osc_ready() -> bool:
|
||||
import socket
|
||||
port = _carla_osc_port()
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
s.bind(("127.0.0.1", port))
|
||||
s.bind(("0.0.0.0", port))
|
||||
s.close()
|
||||
return False
|
||||
except OSError:
|
||||
@@ -749,11 +750,19 @@ async def carla_stop(authorization: Optional[str] = Header(None)):
|
||||
_send_carla_all_notes_off()
|
||||
except Exception:
|
||||
pass
|
||||
# 2. Terminate tiến trình Carla đã spawn
|
||||
# 2. Terminate tiến trình Carla đã spawn — Windows PHẢI giết cả process
|
||||
# tree (Carla.exe spawn carla-backend child giữ cổng OSC 22752; terminate
|
||||
# đơn lẻ chỉ giết cha → child sống → cổng chưa giải phóng → lần mở sau
|
||||
# open_in_carla thấy already_running dù Carla đã chết → GUI không xuất
|
||||
# hiện lại (Bug 3 standalone)).
|
||||
killed = 0
|
||||
_prune_carla_processes()
|
||||
for proc in list(_CARLA_PROCESSES):
|
||||
try:
|
||||
if os.name == "nt" and proc.pid:
|
||||
subprocess.run(["taskkill", "/PID", str(proc.pid), "/T", "/F"],
|
||||
capture_output=True, timeout=15)
|
||||
else:
|
||||
proc.terminate()
|
||||
except Exception:
|
||||
pass
|
||||
@@ -774,6 +783,15 @@ async def carla_stop(authorization: Optional[str] = Header(None)):
|
||||
except Exception:
|
||||
pass
|
||||
_CARLA_PROCESSES.clear()
|
||||
# 3. Chờ cổng OSC UDP thực sự được giải phóng (child có thể thoát chậm
|
||||
# hơn cha) — nếu không, mở lại Carla ngay sẽ bị gate already_running.
|
||||
try:
|
||||
import time as _t
|
||||
deadline = _t.time() + 5.0
|
||||
while _t.time() < deadline and _carla_osc_ready():
|
||||
_t.sleep(0.1)
|
||||
except Exception:
|
||||
pass
|
||||
return {
|
||||
"success": True,
|
||||
"stopped": True,
|
||||
@@ -916,6 +934,7 @@ class MidiRenderRequest(BaseModel):
|
||||
preset_id: Optional[str] = None
|
||||
preset_path: Optional[str] = None
|
||||
preset_data: Optional[str] = None # base64 bytes .vstpreset (project nhúng)
|
||||
bit_depth: int = 16 # 16/24/32 — WAV PCM
|
||||
|
||||
|
||||
@router.post("/midi-render")
|
||||
@@ -943,6 +962,7 @@ async def midi_render(req: MidiRenderRequest, current_user: dict = Depends(get_c
|
||||
preset_id=req.preset_id,
|
||||
preset_path=req.preset_path,
|
||||
preset_data_b64=req.preset_data,
|
||||
bit_depth=req.bit_depth,
|
||||
)
|
||||
return {
|
||||
"success": True,
|
||||
@@ -978,7 +998,7 @@ async def midi_render(req: MidiRenderRequest, current_user: dict = Depends(get_c
|
||||
def _render_midi_notes_pedalboard(instrument_id: str, notes: list, bpm: float,
|
||||
sample_rate: int, preset_id=None, preset_path=None,
|
||||
preset_data_b64=None, soundfont_bank=None,
|
||||
soundfont_program=None) -> tuple:
|
||||
soundfont_program=None, bit_depth: int = 16) -> tuple:
|
||||
"""Render MIDI notes qua pedalboard (VSTi + preset) → WAV trong PROCESSED_DIR.
|
||||
|
||||
Trả (out_path, duration_sec). Ném HTTPException khi plugin không load được."""
|
||||
@@ -1024,7 +1044,8 @@ def _render_midi_notes_pedalboard(instrument_id: str, notes: list, bpm: float,
|
||||
buf = vst(midi_messages, sample_rate=sample_rate,
|
||||
duration=total_needed / float(sample_rate), num_channels=2)
|
||||
out_path = os.path.join(settings.PROCESSED_DIR, f"preview_{uuid.uuid4().hex[:10]}.wav")
|
||||
sf.write(out_path, buf.T, sample_rate)
|
||||
subtype_map = {16: "PCM_16", 24: "PCM_24", 32: "PCM_32"}
|
||||
sf.write(out_path, buf.T, sample_rate, subtype=subtype_map.get(int(bit_depth), "PCM_16"))
|
||||
return out_path, buf.shape[1] / float(sample_rate)
|
||||
|
||||
|
||||
@@ -1039,6 +1060,7 @@ class SoundfontRenderRequest(BaseModel):
|
||||
notes: list = []
|
||||
bpm: float = 120.0
|
||||
sample_rate: int = 44100
|
||||
bit_depth: int = 16 # 16/24/32 — WAV PCM
|
||||
|
||||
|
||||
@router.post("/soundfont-render")
|
||||
@@ -1074,7 +1096,8 @@ async def soundfont_render(req: SoundfontRenderRequest, current_user: dict = Dep
|
||||
bank=req.bank, program=req.program,
|
||||
sr=req.sample_rate, bpm=req.bpm,
|
||||
)
|
||||
sf.write(out_path, audio.T, req.sample_rate)
|
||||
subtype_map = {16: "PCM_16", 24: "PCM_24", 32: "PCM_32"}
|
||||
sf.write(out_path, audio.T, req.sample_rate, subtype=subtype_map.get(int(req.bit_depth), "PCM_16"))
|
||||
return {
|
||||
"success": True,
|
||||
"file_id": os.path.basename(out_path),
|
||||
@@ -1095,6 +1118,106 @@ async def soundfont_render(req: SoundfontRenderRequest, current_user: dict = Dep
|
||||
raise HTTPException(status_code=500, detail=f"Render soundfont thất bại: {e}")
|
||||
|
||||
|
||||
class AutosampleRequest(BaseModel):
|
||||
"""Auto-sample VSTi preset -> SF2 (client FluidSynth WASM live playback).
|
||||
|
||||
Server goi tools.autosample_vsti.autosample_sf2 (pedalboard render tung
|
||||
note voi DUNG plugin + preset nhu export) -> luu {uuid}.sf2 vao
|
||||
UPLOAD_SF_DIR + .meta -> client tai qua /soundfonts/download/{uuid}.
|
||||
SF2 only (SF3 can ffmpeg/libvorbis; client WASM khong decode SF3)."""
|
||||
instrument_id: str
|
||||
preset_id: Optional[str] = None
|
||||
preset_path: Optional[str] = None
|
||||
preset_data: Optional[str] = None # base64 bytes .vstpreset (project nhung)
|
||||
low: int = 36
|
||||
high: int = 96
|
||||
step: int = 2
|
||||
duration: float = 2.5
|
||||
release: float = 1.0
|
||||
velocity: int = 100
|
||||
sample_rate: int = 44100
|
||||
|
||||
@router.post("/autosample")
|
||||
async def autosample_vsti(req: AutosampleRequest, current_user: dict = Depends(get_current_user)):
|
||||
"""Auto-sample VSTi -> SF2 cho live playback client-side (FluidSynth WASM).
|
||||
|
||||
Tra sf_id (bare uuid) de client tai SF2 va phat qua SonicSF nhu soundfont
|
||||
thuong. Chi phi autosample mot lan -> cache o client (localStorage)."""
|
||||
enforce_password_changed(current_user)
|
||||
_dirs = _effective_dirs().get("plugin_dirs") or []
|
||||
|
||||
# tools/ nam ngoai package app - import voi fallback path
|
||||
try:
|
||||
from tools.autosample_vsti import autosample_sf2 as _autosample_sf2, _is_vst2_path
|
||||
except ImportError:
|
||||
_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
if _root not in sys.path:
|
||||
sys.path.insert(0, _root)
|
||||
from tools.autosample_vsti import autosample_sf2 as _autosample_sf2, _is_vst2_path
|
||||
|
||||
if not HAS_PEDALBOARD:
|
||||
# VST2 native bridge khong can pedalboard — chi chan khi plugin la VST3
|
||||
_p = PluginManager(extra_vst_dirs=_dirs)._scan_plugins().get(req.instrument_id)
|
||||
if not _p or not _is_vst2_path(_p):
|
||||
raise HTTPException(status_code=501, detail="pedalboard khong kha dung tren may nay")
|
||||
|
||||
file_uuid = str(uuid.uuid4())
|
||||
dest_path = os.path.join(UPLOAD_SF_DIR, file_uuid + ".sf2")
|
||||
log_lines = []
|
||||
|
||||
def _log(msg):
|
||||
log_lines.append(msg)
|
||||
print(f"[autosample] {msg}")
|
||||
|
||||
try:
|
||||
result = _autosample_sf2(
|
||||
req.instrument_id, dest_path,
|
||||
preset_id=req.preset_id,
|
||||
preset_path=req.preset_path,
|
||||
preset_data_b64=req.preset_data,
|
||||
low=req.low, high=req.high, step=req.step,
|
||||
duration=req.duration, release=req.release,
|
||||
velocity=req.velocity, sample_rate=req.sample_rate,
|
||||
log=_log,
|
||||
extra_vst_dirs=_dirs,
|
||||
)
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
except Exception as e:
|
||||
try:
|
||||
if os.path.exists(dest_path):
|
||||
os.remove(dest_path)
|
||||
except Exception:
|
||||
pass
|
||||
raise HTTPException(status_code=500, detail=f"Auto-sample that bai: {e}")
|
||||
|
||||
meta_path = os.path.join(UPLOAD_SF_DIR, file_uuid + ".meta")
|
||||
try:
|
||||
with open(meta_path, "w", encoding="utf-8") as f:
|
||||
json.dump({"original_name": f"autosample_{file_uuid[:8]}.sf2",
|
||||
"uuid": file_uuid, "file": file_uuid + ".sf2",
|
||||
"plugin_id": req.instrument_id, "kind": "autosampled"}, f)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
get_scanner().scan_once()
|
||||
except Exception as e:
|
||||
print(f"[autosample] scan_once failed: {e}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"sf_id": file_uuid,
|
||||
"file": file_uuid + ".sf2",
|
||||
"name": f"autosample_{file_uuid[:8]}.sf2",
|
||||
"url": f"/api/v1/plugins/soundfonts/download/{file_uuid}",
|
||||
"size_bytes": result["size_bytes"],
|
||||
"note_count": result["note_count"],
|
||||
"plugin_id": req.instrument_id,
|
||||
}
|
||||
|
||||
class CarlaPlayNotesRequest(BaseModel):
|
||||
"""Phát dãy MIDI notes qua Carla bridge (OSC, realtime) — preview khi
|
||||
pedalboard không render được plugin (VD VST2). Cần Carla đang chạy với
|
||||
@@ -1174,7 +1297,7 @@ async def render_project(
|
||||
safe_name += ".wav"
|
||||
output_path = os.path.join(settings.PROCESSED_DIR, safe_name)
|
||||
try:
|
||||
result_path = engine.render_project(req.project_json, output_path)
|
||||
result_path = engine.render_project(req.project_json, output_path, bit_depth=req.bit_depth)
|
||||
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)}")
|
||||
|
||||
@@ -193,7 +193,7 @@ def mix_multitrack_session(tracks_meta: list, output_path: str, sample_rate: int
|
||||
y, sr_read = sf.read(temp_wav)
|
||||
|
||||
# Xác định subtype mã hóa bit-depth
|
||||
subtype_map = {8: "PCM_S8", 16: "PCM_16", 24: "PCM_24"}
|
||||
subtype_map = {8: "PCM_S8", 16: "PCM_16", 24: "PCM_24", 32: "PCM_32"}
|
||||
selected_subtype = subtype_map.get(bit_depth, "PCM_16")
|
||||
|
||||
# Ghi tệp WAV chất lượng cao
|
||||
@@ -254,7 +254,7 @@ def export_audio(input_path: str, output_path: str, format: str = "wav",
|
||||
sound.export(temp_wav, format="wav")
|
||||
y, sr_read = sf.read(temp_wav)
|
||||
|
||||
subtype_map = {8: "PCM_S8", 16: "PCM_16", 24: "PCM_24"}
|
||||
subtype_map = {8: "PCM_S8", 16: "PCM_16", 24: "PCM_24", 32: "PCM_32"}
|
||||
selected_subtype = subtype_map.get(bit_depth, "PCM_16")
|
||||
|
||||
sf.write(output_path, y, sample_rate, subtype=selected_subtype)
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
"""apply_mastering — offline mastering chain, mirror chính xác client WebAudio
|
||||
(app.jsx initMasterBus / applyMasteringSettings / rebuildMasteringGraph).
|
||||
|
||||
Render export (pedalboard VSTi / soundfont) chạy qua ĐÚNG chain như live
|
||||
playback: thứ tự module từ settings['chain'] (mặc định EQ -> Imager ->
|
||||
Maximizer), mỗi module bật/tắt theo settings tương ứng. Bỏ oversample
|
||||
(WaveShaper '2x'/'4x') — offline render không cần anti-alias.
|
||||
|
||||
Công thức khớp client:
|
||||
- RBJ Audio-EQ-Cookbook biquad (eqproBiquadMagDb)
|
||||
- Imager 4-band M/S crossfeed g1=(w+100)/200, g2=(100-w)/200
|
||||
- Maximizer: boost -> atan soft-clip -> upward comp (dry + gain*comp) -> hard clip ceiling
|
||||
- Compressor: soft-knee DynamicsCompressor + one-pole attack/release envelope
|
||||
- Limiter: WaveShaper tanh k=1/tLin
|
||||
- Exciter: highpass 2k -> atan(k=3), dry 1.0, wet 0.6*drive/100
|
||||
- Rebalance: M/S L/R crossfeed a=(m+s)/2, b=(m-s)/2
|
||||
"""
|
||||
import numpy as np
|
||||
from scipy.signal import sosfilt
|
||||
|
||||
|
||||
def _clamp(v, lo, hi, default=0.0):
|
||||
"""Mirror client clamp(): missing/NaN -> default (neutral), khong bao gio NaN."""
|
||||
try:
|
||||
n = float(v)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
if not np.isfinite(n):
|
||||
return default
|
||||
return min(hi, max(lo, n))
|
||||
|
||||
|
||||
def _biquad(kind, f0, fs, gain_db=0.0, q=0.707):
|
||||
"""RBJ biquad (b, a) normalized a0=1 — dung cong thuc client eqproBiquadMagDb."""
|
||||
f0 = min(max(float(f0), 20.0), fs * 0.45)
|
||||
w0 = 2 * np.pi * f0 / fs
|
||||
cw = np.cos(w0)
|
||||
sw = np.sin(w0)
|
||||
alpha = sw / (2 * max(0.05, q))
|
||||
A = 10 ** (_clamp(gain_db, -24, 24) / 40)
|
||||
if kind == "peaking":
|
||||
b = [1 + alpha * A, -2 * cw, 1 - alpha * A]
|
||||
a = [1 + alpha / A, -2 * cw, 1 - alpha / A]
|
||||
elif kind == "lowshelf":
|
||||
b = [A * ((A + 1) - (A - 1) * cw + 2 * np.sqrt(A) * alpha),
|
||||
2 * A * ((A - 1) - (A + 1) * cw),
|
||||
A * ((A + 1) - (A - 1) * cw - 2 * np.sqrt(A) * alpha)]
|
||||
a = [(A + 1) + (A - 1) * cw + 2 * np.sqrt(A) * alpha,
|
||||
-2 * ((A - 1) + (A + 1) * cw),
|
||||
(A + 1) + (A - 1) * cw - 2 * np.sqrt(A) * alpha]
|
||||
elif kind == "highshelf":
|
||||
b = [A * ((A + 1) + (A - 1) * cw + 2 * np.sqrt(A) * alpha),
|
||||
-2 * A * ((A - 1) + (A + 1) * cw),
|
||||
A * ((A + 1) + (A - 1) * cw - 2 * np.sqrt(A) * alpha)]
|
||||
a = [(A + 1) - (A - 1) * cw + 2 * np.sqrt(A) * alpha,
|
||||
2 * ((A - 1) - (A + 1) * cw),
|
||||
(A + 1) - (A - 1) * cw - 2 * np.sqrt(A) * alpha]
|
||||
elif kind == "highpass":
|
||||
b = [(1 + cw) / 2, -(1 + cw), (1 + cw) / 2]
|
||||
a = [1 + alpha, -2 * cw, 1 - alpha]
|
||||
elif kind == "lowpass":
|
||||
b = [(1 - cw) / 2, 1 - cw, (1 - cw) / 2]
|
||||
a = [1 + alpha, -2 * cw, 1 - alpha]
|
||||
else:
|
||||
raise ValueError(f"unsupported biquad kind: {kind}")
|
||||
b = np.asarray(b, dtype=np.float64) / a[0]
|
||||
a = np.asarray(a, dtype=np.float64) / a[0]
|
||||
return np.concatenate([b, a])
|
||||
|
||||
|
||||
def _sosfilt_chain(x, sos_list):
|
||||
"""Ap day biquad noi tiep len (2, N) — mot lan sosfilt moi kenh."""
|
||||
if not sos_list:
|
||||
return np.array(x, dtype=np.float64, copy=True)
|
||||
sos = np.asarray(sos_list, dtype=np.float64)
|
||||
out = np.empty((2, x.shape[1]), dtype=np.float64)
|
||||
for ch in range(2):
|
||||
out[ch] = sosfilt(sos, np.asarray(x[ch], dtype=np.float64))
|
||||
return out
|
||||
|
||||
|
||||
def _compressor(x, threshold_db, ratio, knee, attack_s, release_s,
|
||||
makeup_db=0.0, fs=44100.0):
|
||||
"""Soft-knee feedforward compressor (gan dung DynamicsCompressor cua WebAudio)
|
||||
— stereo-linked (detect tren max |L|,|R|), one-pole attack/release."""
|
||||
x64 = np.asarray(x, dtype=np.float64)
|
||||
n = x64.shape[1]
|
||||
if n == 0:
|
||||
return x64
|
||||
level_db = 20 * np.log10(np.max(np.abs(x64), axis=0) + 1e-12)
|
||||
t = threshold_db
|
||||
k = knee
|
||||
gr_db = np.zeros(n, dtype=np.float64)
|
||||
above = level_db >= t + k / 2
|
||||
mid = (level_db > t - k / 2) & (level_db < t + k / 2)
|
||||
gr_db[above] = (1 - 1 / ratio) * (t - level_db[above])
|
||||
gr_db[mid] = (1 - 1 / ratio) * (level_db[mid] - t + k / 2) ** 2 / (2 * k)
|
||||
|
||||
a_att = np.exp(-1 / (attack_s * fs)) if attack_s > 0 else 0.0
|
||||
a_rel = np.exp(-1 / (release_s * fs)) if release_s > 0 else 0.0
|
||||
env = np.empty(n, dtype=np.float64)
|
||||
cur = 0.0
|
||||
prev = 0.0
|
||||
for i in range(n):
|
||||
g = gr_db[i]
|
||||
if g < prev:
|
||||
cur = a_att * cur + (1 - a_att) * g
|
||||
else:
|
||||
cur = a_rel * cur + (1 - a_rel) * g
|
||||
env[i] = cur
|
||||
prev = g
|
||||
gain_lin = 10 ** ((env + makeup_db) / 20)
|
||||
return x64 * gain_lin
|
||||
|
||||
|
||||
def _apply_eq(x, s, fs):
|
||||
eq_on = bool(s.get("eqActive"))
|
||||
sos = [
|
||||
_biquad("lowshelf", 100, fs, _clamp(s.get("eqLowGain"), -24, 24) if eq_on else 0),
|
||||
_biquad("peaking", 822, fs, _clamp(s.get("eqMid1Gain"), -24, 24) if eq_on else 0, q=0.7),
|
||||
_biquad("peaking", 3200, fs, _clamp(s.get("eqMid2Gain"), -24, 24) if eq_on else 0, q=1.2),
|
||||
_biquad("highshelf", 10000, fs, _clamp(s.get("eqHighGain"), -24, 24) if eq_on else 0),
|
||||
]
|
||||
return _sosfilt_chain(x, sos)
|
||||
|
||||
|
||||
def _apply_imager(x, s, fs):
|
||||
im_on = bool(s.get("imagerActive"))
|
||||
bands = [
|
||||
[("lowpass", 100)],
|
||||
[("highpass", 100), ("lowpass", 1000)],
|
||||
[("highpass", 1000), ("lowpass", 6000)],
|
||||
[("highpass", 6000)],
|
||||
]
|
||||
widths = [s.get(f"w{i}") for i in (1, 2, 3, 4)]
|
||||
out = np.zeros_like(x)
|
||||
for bf, w in zip(bands, widths):
|
||||
width = _clamp(w, 0, 200, 100) if im_on else 100
|
||||
g1 = (width + 100) / 200
|
||||
g2 = (100 - width) / 200
|
||||
y = _sosfilt_chain(x, [_biquad(k, f, fs) for k, f in bf])
|
||||
out[0] += g1 * y[0] + g2 * y[1]
|
||||
out[1] += g1 * y[1] + g2 * y[0]
|
||||
return out
|
||||
|
||||
|
||||
def _apply_maximizer(x, s):
|
||||
on = bool(s.get("maximizerActive"))
|
||||
boost = 10 ** (_clamp(s.get("maxGain"), -60, 30) / 20) if on else 1.0
|
||||
boosted = x * boost
|
||||
soft = _clamp(s.get("maxSoftClip"), 0, 100, 0)
|
||||
if on and soft > 0:
|
||||
k = 1 + (soft / 100) * 10
|
||||
dry = np.arctan(boosted * k) / np.arctan(k)
|
||||
else:
|
||||
dry = boosted
|
||||
up = _clamp(s.get("maxUpward"), 0, 30, 0)
|
||||
if on and up > 0:
|
||||
up_gain = 10 ** (up / 20) - 1.0
|
||||
comp = _compressor(boosted, threshold_db=-30, ratio=4.0, knee=10.0,
|
||||
attack_s=0.01, release_s=0.1, makeup_db=0.0)
|
||||
out = dry + up_gain * comp
|
||||
else:
|
||||
out = dry
|
||||
ceil_db = _clamp(s.get("ceiling"), -60, 0, -0.1) if on else -0.1
|
||||
c = 10 ** (ceil_db / 20)
|
||||
return np.clip(out, -c, c)
|
||||
|
||||
|
||||
def _apply_compressor(x, s):
|
||||
if not bool(s.get("compActive")):
|
||||
return x
|
||||
makeup = 10 ** (_clamp(s.get("compMakeup"), 0, 12) / 20)
|
||||
return _compressor(x, threshold_db=_clamp(s.get("compThreshold"), -60, 0),
|
||||
ratio=_clamp(s.get("compRatio"), 1, 20), knee=8.0,
|
||||
attack_s=0.02, release_s=0.25, makeup_db=20 * np.log10(makeup))
|
||||
|
||||
|
||||
def _apply_limiter(x, s):
|
||||
if not bool(s.get("limActive")):
|
||||
return x
|
||||
t_lin = 10 ** (_clamp(s.get("limThreshold"), -24, 0, -1) / 20)
|
||||
k = 1 / max(0.02, t_lin)
|
||||
tk = np.tanh(k)
|
||||
return np.tanh(x * k) / tk
|
||||
|
||||
|
||||
def _apply_exciter(x, s, fs):
|
||||
if not bool(s.get("excActive")):
|
||||
return x
|
||||
wet = (_clamp(s.get("excDrive"), 0, 100, 0) / 100) * 0.6
|
||||
if wet <= 0:
|
||||
return x
|
||||
hp = _sosfilt_chain(x, [_biquad("highpass", 2000, fs, q=0.7)])
|
||||
k = 3
|
||||
sh = np.arctan(hp * k) / np.arctan(k)
|
||||
return x + wet * sh
|
||||
|
||||
|
||||
def _apply_rebalance(x, s):
|
||||
if not bool(s.get("rebalActive")):
|
||||
return x
|
||||
mid = 10 ** (_clamp(s.get("rebalMid"), -24, 24, 0) / 20)
|
||||
side = 10 ** (_clamp(s.get("rebalSide"), -24, 24, 0) / 20)
|
||||
a = (mid + side) / 2
|
||||
b = (mid - side) / 2
|
||||
L, R = x[0], x[1]
|
||||
return np.stack([a * L + b * R, b * L + a * R])
|
||||
|
||||
|
||||
def _apply_eqpro(x, mod, fs):
|
||||
params = mod.get("params") or {}
|
||||
amount = _clamp(params.get("amount"), 0, 200, 100) / 100.0
|
||||
sos = []
|
||||
for b in params.get("bands") or []:
|
||||
kind = b.get("type") or "peaking"
|
||||
f0 = _clamp(b.get("freq"), 20, 20000, 1000)
|
||||
q = _clamp(b.get("q"), 0.1, 18, 1.0)
|
||||
gain = (b.get("gain") or 0) * amount if b.get("active") is not False else 0
|
||||
sos.append(_biquad(kind, f0, fs, gain, q))
|
||||
return _sosfilt_chain(x, sos)
|
||||
|
||||
|
||||
def apply_mastering(buffer, settings, sample_rate):
|
||||
"""Ap mastering chain (client format mastering_settings) len (2, N) audio.
|
||||
|
||||
buffer: (2, N) — L row 0. settings: dict hoac None. Tra (2, N) float64
|
||||
(copy) da xu li; neu gate tat / khong co module -> tra copy khong doi."""
|
||||
if buffer.ndim != 2 or buffer.shape[0] != 2:
|
||||
raise ValueError("buffer phai la (2, N) stereo")
|
||||
x = np.asarray(buffer, dtype=np.float64)
|
||||
if not settings:
|
||||
return x.copy()
|
||||
if not settings.get("masterConnected") or settings.get("isBypassed"):
|
||||
return x.copy()
|
||||
chain = settings.get("chain") or []
|
||||
mods = [m for m in chain if isinstance(m, dict) and m.get("active")]
|
||||
if not mods:
|
||||
return x.copy()
|
||||
fs = float(sample_rate) if sample_rate else 44100.0
|
||||
for m in mods:
|
||||
t = m.get("type")
|
||||
if t == "eq":
|
||||
x = _apply_eq(x, settings, fs)
|
||||
elif t == "imager":
|
||||
x = _apply_imager(x, settings, fs)
|
||||
elif t == "maximizer":
|
||||
x = _apply_maximizer(x, settings)
|
||||
elif t == "compressor":
|
||||
x = _apply_compressor(x, settings)
|
||||
elif t == "limiter":
|
||||
x = _apply_limiter(x, settings)
|
||||
elif t == "exciter":
|
||||
x = _apply_exciter(x, settings, fs)
|
||||
elif t == "rebalance":
|
||||
x = _apply_rebalance(x, settings)
|
||||
elif t == "eqpro":
|
||||
x = _apply_eqpro(x, m, fs)
|
||||
# type 'carla' = pass-through (VST FX ngoai) — mirror client, bo qua
|
||||
return x
|
||||
@@ -0,0 +1,659 @@
|
||||
"""Native audio service (T15) — một engine duy nhất cho track instrument.
|
||||
|
||||
Load các bridge native_host DLL qua ctypes (SF host / VST2 / VST3) và giữ
|
||||
instance theo track_id. Mọi track instrument (SF + VSTi) đi qua CÙNG mixer
|
||||
native (track gain/pan + master limiter + master fader) — hết drift Bug 2:
|
||||
JS không còn route instrument qua masterBus WebAudio.
|
||||
|
||||
- Live: AudioStart (WASAPI) → note_on/note_off qua SPSC trên audio thread.
|
||||
- Offline SF: SF_FS_RenderBlock (write_float + mixer native trong C++).
|
||||
- Offline VST2: SF_VST2_Process (raw plugin) + mixer_math (mirror NativeMixer/
|
||||
mastering_engine.py) trong Python — bridge Process không áp mixer.
|
||||
- VST3 offline: bridge chưa có export Process — matrix ghi limitation (T15);
|
||||
VST3 live dùng AudioStart + SendNoteOn như VST2 (AudioEngine chung).
|
||||
|
||||
master_gain (dB) áp cho MỌI instance — mirror masterBus.output.gain WebAudio
|
||||
(sau limiter + clip, linear, không re-clip).
|
||||
"""
|
||||
import ctypes
|
||||
import os
|
||||
import threading
|
||||
|
||||
import numpy as np
|
||||
|
||||
from app.config import settings
|
||||
|
||||
_RES_DIR = os.getenv("SF_RESOURCE_DIR")
|
||||
_NATIVE_DIR = (
|
||||
os.getenv("SONICFORGE_NATIVE_DIR")
|
||||
or (os.path.join(_RES_DIR, "native_host") if _RES_DIR else None)
|
||||
or os.path.join(settings.BASE_DIR, "native_host", "build", "Release")
|
||||
)
|
||||
|
||||
_ERR = None # khoi tao buffer sau khi dinh nghia _errbuf()
|
||||
_LOCK = threading.RLock() # reentrant: _hidden_hwnd() goi trong ensure_*
|
||||
|
||||
|
||||
def native_dir() -> str:
|
||||
return _NATIVE_DIR
|
||||
|
||||
|
||||
def _dll(name: str) -> str:
|
||||
return os.path.join(_NATIVE_DIR, name)
|
||||
|
||||
|
||||
def _errbuf():
|
||||
return ctypes.create_string_buffer(256)
|
||||
|
||||
|
||||
def _errval(buf) -> str:
|
||||
return buf.value.decode(errors="replace") if buf and buf.value else ""
|
||||
|
||||
_ERR = _errbuf() # module-level err buffer (dung chung, 256 bytes)
|
||||
|
||||
|
||||
def _hidden_hwnd():
|
||||
"""Tao 1 hidden window dung lam parent cho VST3 editor headless (live).
|
||||
SF_VST3_Attach yeu cau parent_hwnd != NULL; window 0-size khong hien thi."""
|
||||
import ctypes as _ct
|
||||
from ctypes import wintypes as _wt
|
||||
if getattr(_hidden_hwnd, "_hwnd", 0):
|
||||
return _hidden_hwnd._hwnd
|
||||
with _LOCK:
|
||||
if getattr(_hidden_hwnd, "_hwnd", 0):
|
||||
return _hidden_hwnd._hwnd
|
||||
_u = _ct.windll.user32
|
||||
_k = _ct.windll.kernel32
|
||||
_u.DefWindowProcW.argtypes = [_wt.HWND, _wt.UINT, _ct.c_void_p, _ct.c_void_p]
|
||||
_u.DefWindowProcW.restype = _ct.c_long
|
||||
_WNDPROC = _ct.WINFUNCTYPE(_ct.c_long, _wt.HWND, _wt.UINT, _ct.c_void_p, _ct.c_void_p) # WPARAM/LPARAM 64-bit
|
||||
class _WC(_ct.Structure):
|
||||
_fields_ = [("style", _ct.c_uint), ("lpfnWndProc", _WNDPROC), ("cbClsExtra", _ct.c_int),
|
||||
("cbWndExtra", _ct.c_int), ("hInstance", _wt.HINSTANCE), ("hIcon", _wt.HICON),
|
||||
("hCursor", _wt.HANDLE), ("hbrBackground", _wt.HBRUSH), ("lpszMenuName", _wt.LPCWSTR),
|
||||
("lpszClassName", _wt.LPCWSTR)]
|
||||
_wc = _WC()
|
||||
_wc.lpfnWndProc = _WNDPROC(_u.DefWindowProcW)
|
||||
_wc.lpszClassName = "SonicForgeNativeHidden"
|
||||
_wc.hInstance = _k.GetModuleHandleW(None)
|
||||
if _u.RegisterClassW(_ct.byref(_wc)) or _k.GetLastError() == 1410: # 1410 = class ton tai
|
||||
_h = _u.CreateWindowExW(0, "SonicForgeNativeHidden", "sf", 0, 0, 0, 0, 0,
|
||||
None, None, _wc.hInstance, None)
|
||||
_hidden_hwnd._hwnd = _h or 0
|
||||
return _hidden_hwnd._hwnd
|
||||
|
||||
|
||||
def is_available() -> bool:
|
||||
return os.path.isfile(_dll("sf_host_bridge.dll"))
|
||||
|
||||
|
||||
# ── mixer math (offline VST) — MIRROR NativeMixer.cpp + mastering_engine.py ──
|
||||
def _clamp_db(v, lo, hi, default):
|
||||
if v != v: # NaN
|
||||
return default
|
||||
return max(lo, min(hi, v))
|
||||
|
||||
|
||||
def apply_track_mixer(y, gain_db=0.0, pan=0.0, lim_active=False,
|
||||
threshold_db=-1.0, master_lin=1.0):
|
||||
"""y: (2, N) float32. Áp track gain/pan + limiter + master fader."""
|
||||
y = y * (10.0 ** (gain_db / 20.0))
|
||||
if pan != 0.0:
|
||||
theta = ((max(-1.0, min(1.0, pan)) + 1.0) / 2.0) * (np.pi / 2.0)
|
||||
y = y.copy()
|
||||
y[0, :] *= np.cos(theta)
|
||||
y[1, :] *= np.sin(theta)
|
||||
if lim_active:
|
||||
t = _clamp_db(threshold_db, -24.0, 0.0, -1.0)
|
||||
t_lin = 10.0 ** (t / 20.0)
|
||||
k = 1.0 / max(0.02, t_lin)
|
||||
tk = np.tanh(k)
|
||||
y = np.tanh(y * k) / tk
|
||||
np.clip(y, -1.0, 1.0, out=y)
|
||||
y = y * master_lin
|
||||
return y
|
||||
|
||||
|
||||
class _SfCtx:
|
||||
"""ctypes wrapper một instance sf_host_bridge (handle theo track)."""
|
||||
|
||||
def __init__(self, dll: ctypes.WinDLL):
|
||||
self.dll = dll
|
||||
self.handle = 0
|
||||
|
||||
def create(self, sr, block):
|
||||
self.handle = self.dll.SF_FS_Create(sr, block, _ERR, 256)
|
||||
return self.handle
|
||||
|
||||
def close(self):
|
||||
if self.handle:
|
||||
try:
|
||||
self.dll.SF_FS_Close(self.handle, _ERR, 256)
|
||||
finally:
|
||||
self.handle = 0
|
||||
|
||||
def __getattr__(self, name):
|
||||
return getattr(self.dll, "SF_FS_" + name)
|
||||
|
||||
|
||||
class _Vst2Ctx:
|
||||
def __init__(self, dll: ctypes.WinDLL):
|
||||
self.dll = dll
|
||||
self.handle = 0
|
||||
|
||||
def create(self, plugin_path):
|
||||
w = ctypes.c_int32(0)
|
||||
h = ctypes.c_int32(0)
|
||||
self.handle = self.dll.SF_VST2_Load(plugin_path.encode("utf-8"), 0,
|
||||
ctypes.byref(w), ctypes.byref(h),
|
||||
_ERR, 256)
|
||||
return self.handle
|
||||
|
||||
def close(self):
|
||||
if self.handle:
|
||||
try:
|
||||
self.dll.SF_VST2_Close(self.handle, _ERR, 256)
|
||||
finally:
|
||||
self.handle = 0
|
||||
|
||||
def __getattr__(self, name):
|
||||
return getattr(self.dll, "SF_VST2_" + name)
|
||||
|
||||
|
||||
class _Vst3Ctx:
|
||||
def __init__(self, dll: ctypes.WinDLL):
|
||||
self.dll = dll
|
||||
self.handle = 0
|
||||
|
||||
def create(self, plugin_path):
|
||||
# Attach can plugin_name = ten class VST3 (ClassInfo::name) + parent
|
||||
# hwnd. Ten class thuong = ten file/folder .vst3; hidden window cho
|
||||
# live headless. Neu ten class khong khop -> rc 0 -> caller fallback.
|
||||
base = os.path.basename(plugin_path.rstrip("\\/"))
|
||||
if base.lower().endswith(".vst3"):
|
||||
plugin_name = base[:-5]
|
||||
else:
|
||||
plugin_name = os.path.splitext(base)[0]
|
||||
w = ctypes.c_int32(0)
|
||||
h = ctypes.c_int32(0)
|
||||
self.handle = self.dll.SF_VST3_Attach(plugin_path.encode("utf-8"),
|
||||
plugin_name.encode("utf-8"),
|
||||
_hidden_hwnd(),
|
||||
ctypes.byref(w), ctypes.byref(h),
|
||||
_ERR, 256)
|
||||
return self.handle
|
||||
|
||||
def close(self):
|
||||
if self.handle:
|
||||
try:
|
||||
self.dll.SF_VST3_Close(self.handle, _ERR, 256)
|
||||
finally:
|
||||
self.handle = 0
|
||||
|
||||
def __getattr__(self, name):
|
||||
return getattr(self.dll, "SF_VST3_" + name)
|
||||
|
||||
|
||||
class NativeAudioService:
|
||||
"""Singleton: registry track_id → instance; master_gain toàn cục."""
|
||||
|
||||
def __init__(self):
|
||||
self._sf = {}
|
||||
self._vst2 = {}
|
||||
self._vst3 = {}
|
||||
self._sf_dll = None
|
||||
self._vst2_dll = None
|
||||
self._vst3_dll = None
|
||||
self.master_gain_db = 0.0
|
||||
self._master_lin = 1.0
|
||||
self.sample_rate = 44100
|
||||
self.block_size = 256
|
||||
|
||||
# ── master fader (IPC set_master_gain) ──
|
||||
def set_master_gain(self, gain_db):
|
||||
with _LOCK:
|
||||
self.master_gain_db = float(gain_db)
|
||||
self._master_lin = 0.0 if self.master_gain_db <= -50 else \
|
||||
10.0 ** (self.master_gain_db / 20.0)
|
||||
for ctx in list(self._sf.values()) + list(self._vst2.values()) + \
|
||||
list(self._vst3.values()):
|
||||
if ctx and ctx.handle:
|
||||
try:
|
||||
ctx.SetMasterGain(ctx.handle, self._master_lin)
|
||||
except Exception:
|
||||
pass
|
||||
return {"ok": True, "master_gain_db": self.master_gain_db,
|
||||
"linear": self._master_lin}
|
||||
|
||||
def _load_sf_dll(self):
|
||||
if self._sf_dll is None:
|
||||
if not os.path.isfile(_dll("sf_host_bridge.dll")):
|
||||
raise RuntimeError(f"thiếu {_dll('sf_host_bridge.dll')} — "
|
||||
f"build native_host Release (T9–T14)")
|
||||
dll = ctypes.WinDLL(_dll("sf_host_bridge.dll"))
|
||||
i32 = ctypes.c_int32
|
||||
dll.SF_FS_Create.argtypes = [i32, i32, ctypes.c_char_p, i32]
|
||||
dll.SF_FS_Create.restype = i32
|
||||
dll.SF_FS_LoadSF2.argtypes = [i32, ctypes.c_char_p, ctypes.c_char_p, i32]
|
||||
dll.SF_FS_LoadSF2.restype = i32
|
||||
dll.SF_FS_SelectInstrument.argtypes = [i32, i32, i32, i32, i32]
|
||||
dll.SF_FS_SelectInstrument.restype = i32
|
||||
dll.SF_FS_NoteOn.argtypes = [i32, i32, i32, i32]
|
||||
dll.SF_FS_NoteOn.restype = i32
|
||||
dll.SF_FS_NoteOff.argtypes = [i32, i32, i32]
|
||||
dll.SF_FS_NoteOff.restype = i32
|
||||
dll.SF_FS_RenderBlock.argtypes = [i32, i32,
|
||||
np.ctypeslib.ndpointer(np.float32, flags="C_CONTIGUOUS"),
|
||||
np.ctypeslib.ndpointer(np.float32, flags="C_CONTIGUOUS")]
|
||||
dll.SF_FS_RenderBlock.restype = i32
|
||||
dll.SF_FS_AudioStart.argtypes = [i32, i32, i32, ctypes.c_char_p, i32]
|
||||
dll.SF_FS_AudioStart.restype = i32
|
||||
dll.SF_FS_AudioStop.argtypes = [i32]
|
||||
dll.SF_FS_AudioStop.restype = i32
|
||||
dll.SF_FS_AudioUnderruns.argtypes = [i32]
|
||||
dll.SF_FS_AudioUnderruns.restype = i32
|
||||
dll.SF_FS_AudioBlocks.argtypes = [i32]
|
||||
dll.SF_FS_AudioBlocks.restype = i32
|
||||
for fn in ("SetTrackGainPan", "SetMasterLimiter"):
|
||||
getattr(dll, "SF_FS_" + fn).argtypes = [i32, ctypes.c_float, ctypes.c_float]
|
||||
getattr(dll, "SF_FS_" + fn).restype = i32
|
||||
dll.SF_FS_SetMasterGain.argtypes = [i32, ctypes.c_float]
|
||||
dll.SF_FS_SetMasterGain.restype = i32
|
||||
dll.SF_FS_Close.argtypes = [i32, ctypes.c_char_p, i32]
|
||||
dll.SF_FS_Close.restype = i32
|
||||
self._sf_dll = dll
|
||||
return self._sf_dll
|
||||
|
||||
def ensure_sf(self, track_id, sf_path, bank=0, program=0, channel=0,
|
||||
live=True, gain_db=None, pan=None):
|
||||
"""Tạo (hoặc tái dùng) instance SF cho track. sf_path: đường dẫn SF2/SF3."""
|
||||
track_id = str(track_id)
|
||||
with _LOCK:
|
||||
dll = self._load_sf_dll()
|
||||
ctx = self._sf.get(track_id)
|
||||
if ctx is None:
|
||||
ctx = _SfCtx(dll)
|
||||
h = ctx.create(self.sample_rate, self.block_size)
|
||||
if h <= 0:
|
||||
raise RuntimeError(f"SF_FS_Create fail: {_errval(_ERR)}")
|
||||
rc = ctx.LoadSF2(h, os.fspath(sf_path).encode("utf-8"), _ERR, 256)
|
||||
if rc != 0:
|
||||
ctx.close()
|
||||
raise RuntimeError(f"SF_FS_LoadSF2 fail rc={rc}: {_errval(_ERR)}")
|
||||
# sfontId<0 → dùng sfid của font vừa load (T14)
|
||||
rc = ctx.SelectInstrument(h, channel, -1, int(bank), int(program))
|
||||
if rc != 0:
|
||||
ctx.close()
|
||||
raise RuntimeError(f"SF_FS_SelectInstrument fail rc={rc}: "
|
||||
f"bank={bank} prog={program}")
|
||||
ctx.SetMasterGain(h, self._master_lin)
|
||||
if live:
|
||||
rc = ctx.AudioStart(h, self.sample_rate, self.block_size, _ERR, 256)
|
||||
if rc != 0:
|
||||
ctx.close()
|
||||
raise RuntimeError(f"SF_FS_AudioStart fail rc={rc}: {_errval(_ERR)}")
|
||||
self._sf[track_id] = ctx
|
||||
else:
|
||||
h = ctx.handle
|
||||
ctx.SelectInstrument(h, channel, -1, int(bank), int(program))
|
||||
if gain_db is not None and pan is not None:
|
||||
ctx.SetTrackGainPan(ctx.handle, float(gain_db), float(pan))
|
||||
return {"ok": True, "track_id": track_id, "handle": ctx.handle}
|
||||
|
||||
def sf_note_on(self, track_id, channel, pitch, velocity=100):
|
||||
track_id = str(track_id)
|
||||
with _LOCK:
|
||||
ctx = self._sf.get(track_id)
|
||||
if not ctx or not ctx.handle:
|
||||
raise RuntimeError("track SF chưa ensure")
|
||||
return {"ok": ctx.NoteOn(ctx.handle, int(channel), int(pitch),
|
||||
int(max(1, min(127, velocity)))) == 0}
|
||||
|
||||
def sf_note_off(self, track_id, channel, pitch):
|
||||
track_id = str(track_id)
|
||||
with _LOCK:
|
||||
ctx = self._sf.get(track_id)
|
||||
if not ctx or not ctx.handle:
|
||||
raise RuntimeError("track SF chưa ensure")
|
||||
return {"ok": ctx.NoteOff(ctx.handle, int(channel), int(pitch)) == 0}
|
||||
|
||||
def set_track_gain_pan(self, track_id, gain_db, pan):
|
||||
"""Áp gain/pan cho instance track (SF/VST2/VST3)."""
|
||||
track_id = str(track_id)
|
||||
with _LOCK:
|
||||
ctx = (self._sf.get(track_id) or self._vst2.get(track_id) or
|
||||
self._vst3.get(track_id))
|
||||
if not ctx or not ctx.handle:
|
||||
raise RuntimeError("track chưa ensure")
|
||||
rc = ctx.SetTrackGainPan(ctx.handle, float(gain_db), float(pan))
|
||||
return {"ok": rc == 0, "track_id": track_id,
|
||||
"gain_db": gain_db, "pan": pan}
|
||||
|
||||
def sf_stats(self, track_id):
|
||||
track_id = str(track_id)
|
||||
with _LOCK:
|
||||
ctx = self._sf.get(track_id)
|
||||
if not ctx or not ctx.handle:
|
||||
return None
|
||||
return {"blocks": ctx.AudioBlocks(ctx.handle),
|
||||
"underruns": ctx.AudioUnderruns(ctx.handle)}
|
||||
|
||||
def sf_audio_stop(self, track_id):
|
||||
track_id = str(track_id)
|
||||
with _LOCK:
|
||||
ctx = self._sf.get(track_id)
|
||||
if not ctx or not ctx.handle:
|
||||
return {"ok": False}
|
||||
return {"ok": ctx.AudioStop(ctx.handle) == 0}
|
||||
|
||||
def render_sf_offline(self, sf_path, bank, program, notes, bpm=120.0,
|
||||
sr=44100, gain_db=0.0, pan=0.0, lim_active=False,
|
||||
threshold_db=-1.0, master_gain_db=0.0):
|
||||
"""Render offline 1 đoạn MIDI bằng instance TẠM (không AudioStart) —
|
||||
RenderBlock áp mixer native (gain/pan/limiter/master) trong C++."""
|
||||
with _LOCK:
|
||||
dll = self._load_sf_dll()
|
||||
ctx = _SfCtx(dll)
|
||||
h = ctx.create(sr, 256)
|
||||
if h <= 0:
|
||||
raise RuntimeError(f"SF_FS_Create fail: {_errval(_ERR)}")
|
||||
try:
|
||||
rc = ctx.LoadSF2(h, os.fspath(sf_path).encode("utf-8"), _ERR, 256)
|
||||
if rc != 0:
|
||||
raise RuntimeError(f"SF_FS_LoadSF2 fail rc={rc}")
|
||||
rc = ctx.SelectInstrument(h, 0, -1, int(bank), int(program))
|
||||
if rc != 0:
|
||||
raise RuntimeError(f"SF_FS_SelectInstrument fail rc={rc}")
|
||||
ctx.SetTrackGainPan(h, float(gain_db), float(pan))
|
||||
ctx.SetMasterLimiter(h, int(lim_active), float(threshold_db))
|
||||
master_lin = 0.0 if master_gain_db <= -50 else \
|
||||
10.0 ** (float(master_gain_db) / 20.0)
|
||||
ctx.SetMasterGain(h, master_lin)
|
||||
|
||||
beat_sec = 60.0 / max(30.0, bpm)
|
||||
events = []
|
||||
total = 0.0
|
||||
for ev in notes:
|
||||
start_s = float(ev.get("start_beat", 0)) * beat_sec
|
||||
dur_s = float(ev.get("duration_beats", 1)) * beat_sec
|
||||
pitch = int(ev.get("note", 60))
|
||||
vel = int(max(1, min(127, float(ev.get("velocity", 100)))))
|
||||
events.append((int(start_s * sr), "on", pitch, vel))
|
||||
events.append((int((start_s + dur_s) * sr), "off", pitch, 0))
|
||||
total = max(total, start_s + dur_s)
|
||||
events.sort(key=lambda e: e[0])
|
||||
n = int((max(total, 0.25) + 0.5) * sr)
|
||||
out = np.zeros((2, n), dtype=np.float32)
|
||||
block = 256
|
||||
pos = 0
|
||||
ev_idx = 0
|
||||
while pos < n:
|
||||
while ev_idx < len(events) and events[ev_idx][0] <= pos:
|
||||
_, kind, pitch, vel = events[ev_idx]
|
||||
if kind == "on":
|
||||
ctx.NoteOn(h, 0, pitch, vel)
|
||||
else:
|
||||
ctx.NoteOff(h, 0, pitch)
|
||||
ev_idx += 1
|
||||
take = min(block, n - pos)
|
||||
l = np.zeros(take, np.float32)
|
||||
r = np.zeros(take, np.float32)
|
||||
ctx.RenderBlock(h, take, l, r)
|
||||
out[0, pos:pos + take] = l
|
||||
out[1, pos:pos + take] = r
|
||||
pos += take
|
||||
return out
|
||||
finally:
|
||||
ctx.close()
|
||||
|
||||
# ── VST2 (live + offline qua SF_VST2_Process) ──
|
||||
def _load_vst2_dll(self):
|
||||
if self._vst2_dll is None:
|
||||
if not os.path.isfile(_dll("vst2_host_bridge.dll")):
|
||||
raise RuntimeError(f"thiếu {_dll('vst2_host_bridge.dll')}")
|
||||
dll = ctypes.WinDLL(_dll("vst2_host_bridge.dll"))
|
||||
i32 = ctypes.c_int32
|
||||
dll.SF_VST2_Load.argtypes = [ctypes.c_char_p, ctypes.c_void_p,
|
||||
ctypes.POINTER(i32), ctypes.POINTER(i32),
|
||||
ctypes.c_char_p, i32]
|
||||
dll.SF_VST2_Load.restype = i32
|
||||
dll.SF_VST2_SendNoteOn.argtypes = [i32, i32, i32, i32]
|
||||
dll.SF_VST2_SendNoteOn.restype = i32
|
||||
dll.SF_VST2_SendNoteOff.argtypes = [i32, i32, i32]
|
||||
dll.SF_VST2_SendNoteOff.restype = i32
|
||||
dll.SF_VST2_Process.argtypes = [i32,
|
||||
np.ctypeslib.ndpointer(np.float32, flags="C_CONTIGUOUS"),
|
||||
i32, i32]
|
||||
dll.SF_VST2_Process.restype = i32
|
||||
dll.SF_VST2_AudioStart.argtypes = [i32, i32, i32, ctypes.c_char_p, i32]
|
||||
dll.SF_VST2_AudioStart.restype = i32
|
||||
dll.SF_VST2_AudioStop.argtypes = [i32]
|
||||
dll.SF_VST2_AudioStop.restype = i32
|
||||
dll.SF_VST2_AudioUnderruns.argtypes = [i32]
|
||||
dll.SF_VST2_AudioUnderruns.restype = i32
|
||||
dll.SF_VST2_AudioBlocks.argtypes = [i32]
|
||||
dll.SF_VST2_AudioBlocks.restype = i32
|
||||
dll.SF_VST2_Close.argtypes = [i32, ctypes.c_char_p, i32]
|
||||
dll.SF_VST2_Close.restype = i32
|
||||
for fn in ("SetTrackGainPan", "SetMasterLimiter"):
|
||||
getattr(dll, "SF_VST2_" + fn).argtypes = [i32, ctypes.c_float, ctypes.c_float]
|
||||
getattr(dll, "SF_VST2_" + fn).restype = i32
|
||||
dll.SF_VST2_SetMasterGain.argtypes = [i32, ctypes.c_float]
|
||||
dll.SF_VST2_SetMasterGain.restype = i32
|
||||
self._vst2_dll = dll
|
||||
return self._vst2_dll
|
||||
|
||||
def ensure_vst2(self, track_id, plugin_path, live=True, gain_db=None, pan=None):
|
||||
track_id = str(track_id)
|
||||
with _LOCK:
|
||||
dll = self._load_vst2_dll()
|
||||
ctx = self._vst2.get(track_id)
|
||||
if ctx is None:
|
||||
ctx = _Vst2Ctx(dll)
|
||||
h = ctx.create(plugin_path)
|
||||
if h <= 0:
|
||||
raise RuntimeError(f"SF_VST2_Load fail: {_errval(_ERR)}")
|
||||
ctx.SetMasterGain(h, self._master_lin)
|
||||
if live:
|
||||
rc = ctx.AudioStart(h, self.sample_rate, self.block_size, _ERR, 256)
|
||||
if rc != 0:
|
||||
ctx.close()
|
||||
raise RuntimeError(f"SF_VST2_AudioStart fail rc={rc}: {_errval(_ERR)}")
|
||||
self._vst2[track_id] = ctx
|
||||
if gain_db is not None and pan is not None:
|
||||
ctx.SetTrackGainPan(ctx.handle, float(gain_db), float(pan))
|
||||
return {"ok": True, "track_id": track_id, "handle": ctx.handle}
|
||||
|
||||
def vst2_note_on(self, track_id, channel, pitch, velocity=100):
|
||||
track_id = str(track_id)
|
||||
with _LOCK:
|
||||
ctx = self._vst2.get(track_id)
|
||||
if not ctx or not ctx.handle:
|
||||
raise RuntimeError("track VST2 chưa ensure")
|
||||
return {"ok": ctx.SendNoteOn(ctx.handle, int(channel), int(pitch),
|
||||
int(max(1, min(127, velocity)))) == 0}
|
||||
|
||||
def vst2_note_off(self, track_id, channel, pitch):
|
||||
track_id = str(track_id)
|
||||
with _LOCK:
|
||||
ctx = self._vst2.get(track_id)
|
||||
if not ctx or not ctx.handle:
|
||||
raise RuntimeError("track VST2 chưa ensure")
|
||||
return {"ok": ctx.SendNoteOff(ctx.handle, int(channel), int(pitch)) == 0}
|
||||
|
||||
def vst2_stats(self, track_id):
|
||||
track_id = str(track_id)
|
||||
with _LOCK:
|
||||
ctx = self._vst2.get(track_id)
|
||||
if not ctx or not ctx.handle:
|
||||
return None
|
||||
return {"blocks": ctx.AudioBlocks(ctx.handle),
|
||||
"underruns": ctx.AudioUnderruns(ctx.handle)}
|
||||
|
||||
def render_vst2_offline(self, plugin_path, notes, bpm=120.0, sr=44100,
|
||||
gain_db=0.0, pan=0.0, lim_active=False,
|
||||
threshold_db=-1.0, master_gain_db=0.0, tail_sec=0.5):
|
||||
"""Offline VST2: SF_VST2_Process (raw) + mixer_math (Python mirror).
|
||||
|
||||
tail_sec: duoi im lang sau note cuoi (autosample muon release tail)."""
|
||||
with _LOCK:
|
||||
dll = self._load_vst2_dll()
|
||||
ctx = _Vst2Ctx(dll)
|
||||
h = ctx.create(plugin_path)
|
||||
if h <= 0:
|
||||
raise RuntimeError(f"SF_VST2_Load fail: {_errval(_ERR)}")
|
||||
try:
|
||||
beat_sec = 60.0 / max(30.0, bpm)
|
||||
events = []
|
||||
total = 0.0
|
||||
for ev in notes:
|
||||
start_s = float(ev.get("start_beat", 0)) * beat_sec
|
||||
dur_s = float(ev.get("duration_beats", 1)) * beat_sec
|
||||
pitch = int(ev.get("note", 60))
|
||||
vel = int(max(1, min(127, float(ev.get("velocity", 100)))))
|
||||
events.append((int(start_s * sr), "on", pitch, vel))
|
||||
events.append((int((start_s + dur_s) * sr), "off", pitch, 0))
|
||||
total = max(total, start_s + dur_s)
|
||||
events.sort(key=lambda e: e[0])
|
||||
n = int((max(total, 0.25) + float(tail_sec)) * sr)
|
||||
out = np.zeros((2, n), dtype=np.float32)
|
||||
block = 256
|
||||
pos = 0
|
||||
ev_idx = 0
|
||||
buf = np.zeros((4, block), np.float32) # inputs+outputs (fake: 2+2)
|
||||
while pos < n:
|
||||
while ev_idx < len(events) and events[ev_idx][0] <= pos:
|
||||
_, kind, pitch, vel = events[ev_idx]
|
||||
if kind == "on":
|
||||
ctx.SendNoteOn(h, 0, pitch, vel)
|
||||
else:
|
||||
ctx.SendNoteOff(h, 0, pitch)
|
||||
ev_idx += 1
|
||||
take = min(block, n - pos)
|
||||
buf.fill(0)
|
||||
ctx.Process(h, buf, 4, take)
|
||||
# ponytail: giả định plugin synth 0-input → output ở buf[0:2]
|
||||
# (bridge xếp outputs tại offset numInputs; khi cần plugin
|
||||
# có input → thêm export getNumIO rồi đọc offset đó)
|
||||
out[0, pos:pos + take] = buf[0, :take]
|
||||
out[1, pos:pos + take] = buf[1, :take]
|
||||
pos += take
|
||||
master_lin = 0.0 if master_gain_db <= -50 else \
|
||||
10.0 ** (float(master_gain_db) / 20.0)
|
||||
return apply_track_mixer(out, gain_db, pan, lim_active,
|
||||
threshold_db, master_lin)
|
||||
finally:
|
||||
ctx.close()
|
||||
|
||||
# ── VST3: live qua bridge (offline: limitation — chưa có Process export) ──
|
||||
def _load_vst3_dll(self):
|
||||
if self._vst3_dll is None:
|
||||
if not os.path.isfile(_dll("vst3_host_bridge.dll")):
|
||||
raise RuntimeError(f"thiếu {_dll('vst3_host_bridge.dll')}")
|
||||
dll = ctypes.WinDLL(_dll("vst3_host_bridge.dll"))
|
||||
i32 = ctypes.c_int32
|
||||
dll.SF_VST3_Attach.argtypes = [ctypes.c_char_p, ctypes.c_char_p,
|
||||
ctypes.c_void_p, ctypes.POINTER(i32),
|
||||
ctypes.POINTER(i32), ctypes.c_char_p, i32]
|
||||
dll.SF_VST3_Attach.restype = i32
|
||||
dll.SF_VST3_SendNoteOn.argtypes = [i32, i32, i32, i32]
|
||||
dll.SF_VST3_SendNoteOn.restype = i32
|
||||
dll.SF_VST3_SendNoteOff.argtypes = [i32, i32, i32]
|
||||
dll.SF_VST3_SendNoteOff.restype = i32
|
||||
dll.SF_VST3_AudioStart.argtypes = [i32, i32, i32, ctypes.c_char_p, i32]
|
||||
dll.SF_VST3_AudioStart.restype = i32
|
||||
dll.SF_VST3_AudioStop.argtypes = [i32]
|
||||
dll.SF_VST3_AudioStop.restype = i32
|
||||
dll.SF_VST3_AudioUnderruns.argtypes = [i32]
|
||||
dll.SF_VST3_AudioUnderruns.restype = i32
|
||||
dll.SF_VST3_AudioBlocks.argtypes = [i32]
|
||||
dll.SF_VST3_Close.argtypes = [i32, ctypes.c_char_p, i32]
|
||||
dll.SF_VST3_Close.restype = i32
|
||||
for fn in ("SetTrackGainPan", "SetMasterLimiter"):
|
||||
getattr(dll, "SF_VST3_" + fn).argtypes = [i32, ctypes.c_float, ctypes.c_float]
|
||||
getattr(dll, "SF_VST3_" + fn).restype = i32
|
||||
dll.SF_VST3_SetMasterGain.argtypes = [i32, ctypes.c_float]
|
||||
dll.SF_VST3_SetMasterGain.restype = i32
|
||||
self._vst3_dll = dll
|
||||
return self._vst3_dll
|
||||
|
||||
def ensure_vst3(self, track_id, plugin_path, live=True, gain_db=None, pan=None):
|
||||
track_id = str(track_id)
|
||||
with _LOCK:
|
||||
dll = self._load_vst3_dll()
|
||||
ctx = self._vst3.get(track_id)
|
||||
if ctx is None:
|
||||
ctx = _Vst3Ctx(dll)
|
||||
h = ctx.create(plugin_path)
|
||||
if h <= 0:
|
||||
raise RuntimeError(f"SF_VST3_Attach fail: {_errval(_ERR)}")
|
||||
ctx.SetMasterGain(h, self._master_lin)
|
||||
if live:
|
||||
rc = ctx.AudioStart(h, self.sample_rate, self.block_size, _ERR, 256)
|
||||
if rc != 0:
|
||||
ctx.close()
|
||||
raise RuntimeError(f"SF_VST3_AudioStart fail rc={rc}: {_errval(_ERR)}")
|
||||
self._vst3[track_id] = ctx
|
||||
if gain_db is not None and pan is not None:
|
||||
ctx.SetTrackGainPan(ctx.handle, float(gain_db), float(pan))
|
||||
return {"ok": True, "track_id": track_id, "handle": ctx.handle}
|
||||
|
||||
def vst3_note_on(self, track_id, channel, pitch, velocity=100):
|
||||
track_id = str(track_id)
|
||||
with _LOCK:
|
||||
ctx = self._vst3.get(track_id)
|
||||
if not ctx or not ctx.handle:
|
||||
raise RuntimeError("track VST3 chưa ensure")
|
||||
return {"ok": ctx.SendNoteOn(ctx.handle, int(channel), int(pitch),
|
||||
int(max(1, min(127, velocity)))) == 0}
|
||||
|
||||
def vst3_note_off(self, track_id, channel, pitch):
|
||||
track_id = str(track_id)
|
||||
with _LOCK:
|
||||
ctx = self._vst3.get(track_id)
|
||||
if not ctx or not ctx.handle:
|
||||
raise RuntimeError("track VST3 chưa ensure")
|
||||
return {"ok": ctx.SendNoteOff(ctx.handle, int(channel), int(pitch)) == 0}
|
||||
|
||||
def close_track(self, track_id):
|
||||
track_id = str(track_id)
|
||||
with _LOCK:
|
||||
for reg in (self._sf, self._vst2, self._vst3):
|
||||
ctx = reg.pop(track_id, None)
|
||||
if ctx:
|
||||
try:
|
||||
ctx.close()
|
||||
except Exception:
|
||||
pass
|
||||
return {"ok": True}
|
||||
|
||||
def close_all(self):
|
||||
with _LOCK:
|
||||
for reg in (self._sf, self._vst2, self._vst3):
|
||||
for ctx in list(reg.values()):
|
||||
try:
|
||||
ctx.close()
|
||||
except Exception:
|
||||
pass
|
||||
reg.clear()
|
||||
return {"ok": True}
|
||||
|
||||
def status(self):
|
||||
return {
|
||||
"available": is_available(),
|
||||
"dll_dir": _NATIVE_DIR,
|
||||
"master_gain_db": self.master_gain_db,
|
||||
"sf_tracks": sorted(self._sf.keys()),
|
||||
"vst2_tracks": sorted(self._vst2.keys()),
|
||||
"vst3_tracks": sorted(self._vst3.keys()),
|
||||
}
|
||||
|
||||
|
||||
_service = None
|
||||
|
||||
|
||||
def get_service() -> NativeAudioService:
|
||||
global _service
|
||||
if _service is None:
|
||||
_service = NativeAudioService()
|
||||
return _service
|
||||
@@ -450,7 +450,7 @@ class PythonRenderEngine:
|
||||
|
||||
return session_buffer
|
||||
|
||||
def render_project(self, project_json: dict, output_filepath: str):
|
||||
def render_project(self, project_json: dict, output_filepath: str, bit_depth: int = 16):
|
||||
bpm = project_json["metadata"]["bpm"]
|
||||
time_sig_num = project_json["metadata"].get("time_signature_numerator", 4)
|
||||
main_session = project_json["main_session"]
|
||||
@@ -470,11 +470,28 @@ class PythonRenderEngine:
|
||||
_cache={},
|
||||
)
|
||||
|
||||
# Mastering chain — mirror client WebAudio (app.jsx applyMasteringSettings /
|
||||
# rebuildMasteringGraph). Truoc day mastering_settings bi BO QUA hoan toan:
|
||||
# export WAV khong qua EQ/Imager/Maximizer -> file khac am nghe live.
|
||||
# Gio ap dung dung chain (thu tu settings['chain']) khi mastering bat.
|
||||
mastering_settings = project_json.get("mastering_settings")
|
||||
mastered = bool(mastering_settings and mastering_settings.get("masterConnected")
|
||||
and not mastering_settings.get("isBypassed"))
|
||||
if mastered:
|
||||
# Import lazy (scipy.signal) — giam thoi gian khoi dong engine
|
||||
from app.core.mastering_engine import apply_mastering
|
||||
master_buffer = apply_mastering(master_buffer, mastering_settings,
|
||||
self.sample_rate)
|
||||
# Hard clip [-1,1] — khớp WAV encoder client (browser) sau mastering
|
||||
np.clip(master_buffer, -1.0, 1.0, out=master_buffer)
|
||||
else:
|
||||
# Normalization to prevent clipping
|
||||
max_peak = np.max(np.abs(master_buffer))
|
||||
if max_peak > 1.0:
|
||||
master_buffer /= max_peak
|
||||
|
||||
# Write final output file
|
||||
sf.write(output_filepath, master_buffer.T, self.sample_rate)
|
||||
# Write final output file (bit_depth: 16/24/32 → WAV PCM subtype)
|
||||
subtype_map = {16: "PCM_16", 24: "PCM_24", 32: "PCM_32"}
|
||||
sf.write(output_filepath, master_buffer.T, self.sample_rate,
|
||||
subtype=subtype_map.get(int(bit_depth), "PCM_16"))
|
||||
return output_filepath
|
||||
|
||||
@@ -5,6 +5,31 @@ import logging
|
||||
import wave
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
def _ensure_fluidsynth_runtime() -> None:
|
||||
"""Dua thu muc chua libfluidsynth DLL vao PATH de pyfluidsynth import duoc
|
||||
(find_library do theo PATH tren Windows). Khong lam gi tren non-Windows."""
|
||||
if os.name != "nt":
|
||||
return
|
||||
root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
candidates = []
|
||||
res_dir = os.getenv("SF_RESOURCE_DIR")
|
||||
if res_dir:
|
||||
candidates.append(os.path.join(res_dir, "native_host", "fluidsynth_runtime"))
|
||||
candidates += [
|
||||
os.path.join(root, "native_host", "build", "Release", "fluidsynth_runtime"),
|
||||
os.path.join(root, "native_host", "fluidsynth_runtime"),
|
||||
]
|
||||
path = os.environ.get("PATH", "")
|
||||
parts = [os.path.normcase(p) for p in path.split(os.pathsep)]
|
||||
for d in candidates:
|
||||
if os.path.isdir(d) and any(
|
||||
f.lower().startswith("libfluidsynth") and f.lower().endswith(".dll")
|
||||
for f in os.listdir(d)
|
||||
):
|
||||
if os.path.normcase(d) not in parts:
|
||||
os.environ["PATH"] = d + os.pathsep + path
|
||||
return
|
||||
|
||||
|
||||
SF_TARGET_DIRS = [
|
||||
"/opt/daw_engine/soundfonts",
|
||||
@@ -193,8 +218,8 @@ class SoundFontConverter:
|
||||
# OGG loop pointers are relative to the individual decompressed sample
|
||||
new_sloop = (startloop - start) if (startloop > start and startloop <= end) else 0
|
||||
new_eloop = (endloop - start) if (endloop > start and endloop <= end) else 0
|
||||
# Mark the sample as Ogg Vorbis compressed (FLUID_SAMPLETYPE_OGG_VORBIS = 0x20)
|
||||
new_stype = sampletype | 0x20
|
||||
# Mark the sample as Ogg Vorbis compressed (FLUID_SAMPLETYPE_OGG_VORBIS = 0x10)
|
||||
new_stype = sampletype | 0x10
|
||||
new_shdr += data[base:base + 20] # sample name
|
||||
new_shdr += struct.pack("<IIIIi", new_start, new_end, new_sloop, new_eloop, rate)
|
||||
new_shdr += data[base + 40:base + 44] # originalpitch, correction, samplelink
|
||||
@@ -282,35 +307,28 @@ class SoundFontConverter:
|
||||
"""Verify a SoundFont actually loads and renders audible audio (guards
|
||||
against shipping malformed SF3 files that silently play nothing).
|
||||
|
||||
Uses the low-level CFFI binding (new_fluid_synth / write_float) — the
|
||||
high-level Synth() class does not exist in this binding, so it is never
|
||||
used here.
|
||||
Uses the high-level Synth() binding (sfload/get_samples).
|
||||
"""
|
||||
if not os.path.exists(path):
|
||||
return False
|
||||
_ensure_fluidsynth_runtime()
|
||||
try:
|
||||
import fluidsynth as _fs
|
||||
import numpy as np
|
||||
_settings = _fs.new_fluid_settings()
|
||||
_fl = _fs.new_fluid_synth(_settings)
|
||||
_synth = _fs.Synth()
|
||||
try:
|
||||
h = _fs.fluid_synth_sfload(_fl, path.encode("utf-8"), 1)
|
||||
if h < 0:
|
||||
fid = _synth.sfload(path)
|
||||
if fid == -1:
|
||||
return False
|
||||
_fs.fluid_synth_program_select(_fl, 0, h, 0, 0)
|
||||
_fs.fluid_synth_noteon(_fl, 0, 60, 100)
|
||||
frames = 8820 # 0.2s
|
||||
buf = np.zeros(frames * 2, dtype=np.float32)
|
||||
_fs.fluid_synth_write_float(
|
||||
_fl, frames, buf.ctypes.data, 0, 1,
|
||||
buf.ctypes.data + frames * 4, 0, 1
|
||||
)
|
||||
_fs.fluid_synth_noteoff(_fl, 0, 60)
|
||||
rms = float(np.sqrt(np.mean(buf ** 2)))
|
||||
_synth.program_select(0, fid, 0, 0)
|
||||
_synth.noteon(0, 60, 100)
|
||||
buf = _synth.get_samples(8820) # int16 stereo, 0.2s
|
||||
_synth.noteoff(0, 60)
|
||||
rms = float(np.sqrt(np.mean((buf.astype(np.float32) / 32768.0) ** 2)))
|
||||
return rms > 1e-4
|
||||
finally:
|
||||
try:
|
||||
_fs.delete_fluid_synth(_fl)
|
||||
_synth.delete()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
@@ -453,7 +471,7 @@ class SoundFontConverter:
|
||||
new_loopstart = loopstart + new_start if (loopstart or loopend) else 0
|
||||
new_loopend = loopend + new_start if (loopstart or loopend) else 0
|
||||
# Clear the Ogg Vorbis flag; keep mono/left/right/linked flags
|
||||
new_stype = sampletype & ~0x20
|
||||
new_stype = sampletype & ~0x10
|
||||
new_shdr += name
|
||||
new_shdr += struct.pack("<IIIIi", new_start, new_end, new_loopstart, new_loopend, rate)
|
||||
new_shdr += data[base + 40:base + 44]
|
||||
|
||||
+13
@@ -20,6 +20,8 @@ from app.api.v1.plugins import router as plugins_router
|
||||
from app.api.v1.media import router as media_router
|
||||
from app.api.v1.system import router as system_router
|
||||
from app.api.v1.presets import router as presets_router
|
||||
from app.api.v1.native import router as native_router
|
||||
from app.api.v1.logs import router as logs_router
|
||||
from app.core.auth import seed_admin
|
||||
from app.core.soundfont_scanner import SoundFontAutoScanner
|
||||
|
||||
@@ -112,6 +114,8 @@ app.include_router(plugins_router, prefix="/api/v1/plugins", tags=["plugins"])
|
||||
app.include_router(media_router, prefix="/api/v1/media", tags=["media"])
|
||||
app.include_router(system_router, prefix="/api/v1/system", tags=["system"])
|
||||
app.include_router(presets_router, prefix="/api/v1/presets", tags=["presets"])
|
||||
app.include_router(native_router, prefix="/api/v1/native", tags=["native"])
|
||||
app.include_router(logs_router, prefix="/api/v1/logs", tags=["logs"])
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
@@ -132,6 +136,15 @@ async def get_index():
|
||||
return resp
|
||||
|
||||
|
||||
|
||||
@app.get("/vst_gui.html", response_class=HTMLResponse)
|
||||
async def get_vst_gui():
|
||||
"""Cửa sổ VST GUI (Bug 1): Rust mở WebviewUrl::App("vst_gui.html?...") —
|
||||
trong devUrl (localhost:8000) cần route riêng vì file nằm /static/vst_gui.html."""
|
||||
vst_gui_path = os.path.join(STATIC_DIR, "vst_gui.html")
|
||||
if os.path.exists(vst_gui_path):
|
||||
return FileResponse(vst_gui_path, media_type="text/html")
|
||||
return HTMLResponse(content="<h1>vst_gui.html not found</h1>", status_code=404)
|
||||
@app.get("/favicon.svg")
|
||||
async def get_favicon():
|
||||
favicon_path = os.path.join(settings.TEMPLATES_DIR, "favicon.svg")
|
||||
|
||||
+444
-136
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -91,6 +91,8 @@ window.API_BASE_URL = window.API_BASE_URL || window.location.origin;
|
||||
// để UI preview / gán clip vào track / download
|
||||
midiRender: (payload) => apiRequest('/api/v1/plugins/midi-render', { method: 'POST', body: JSON.stringify(payload) }),
|
||||
soundfontRender: (payload) => apiRequest('/api/v1/plugins/soundfont-render', { method: 'POST', body: JSON.stringify(payload) }),
|
||||
// Autosample VSTi → SF2 (backend pedalboard) — live playback client-side qua WASM
|
||||
autosampleVsti: (payload) => apiRequest('/api/v1/plugins/autosample', { method: 'POST', body: JSON.stringify(payload) }),
|
||||
// Phát dãy MIDI notes realtime qua Carla bridge (OSC) — preview khi
|
||||
// pedalboard không render được plugin (VD VST2)
|
||||
carlaPlayNotes: (payload) => apiRequest('/api/v1/plugins/carla-play-notes', { method: 'POST', body: JSON.stringify(payload) }),
|
||||
@@ -141,6 +143,12 @@ window.API_BASE_URL = window.API_BASE_URL || window.location.origin;
|
||||
throw new Error(err.detail || 'Upload failed');
|
||||
}
|
||||
return resp.json();
|
||||
},
|
||||
// fetch thô có Authorization header — trả Response (dùng cho download bytes/media browse)
|
||||
authFetch: (url, options = {}) => {
|
||||
const headers = { ...getAuthHeaders(), ...options.headers };
|
||||
if (options.body instanceof FormData) delete headers['Content-Type'];
|
||||
return fetch(url, { ...options, headers });
|
||||
}
|
||||
};
|
||||
})();
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
// SonicForge App Logger — ghi hành động quan trọng của client (console + POST
|
||||
// /api/v1/logs để server log). Fire-and-forget: không throw, không block UI.
|
||||
// Các category dùng chung:
|
||||
// INSTRUMENT — lỗi load instrument (soundfont select/load fail)
|
||||
// VSTI — VSTi load/autosample/preview fail
|
||||
// MIDI_ITEM — MIDI item qua mastering fx chain / main out (playback)
|
||||
// DRAG_MIDI — lỗi drag MIDI từ MEDIA EXPLORER hoặc window explorer
|
||||
window.SonicAppLogger = window.SonicAppLogger || (function () {
|
||||
function base() {
|
||||
return window.API_BASE_URL || window.location.origin || '';
|
||||
}
|
||||
function emit(category, level, message, data) {
|
||||
try {
|
||||
var line = '[AppLog][' + category + '][' + level + '] ' + message;
|
||||
if (data !== undefined && data !== null) {
|
||||
try { line += ' ' + JSON.stringify(data); } catch (e) {}
|
||||
}
|
||||
if (level === 'error') console.error(line); else if (level === 'warn') console.warn(line); else console.log(line);
|
||||
try {
|
||||
fetch(base() + '/api/v1/logs', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
category: category,
|
||||
level: level,
|
||||
message: message,
|
||||
data: data !== undefined ? data : null,
|
||||
url: window.location ? window.location.href : '',
|
||||
ts: new Date().toISOString()
|
||||
})
|
||||
}).catch(function () {});
|
||||
} catch (e) {}
|
||||
} catch (e) {}
|
||||
}
|
||||
return {
|
||||
info: function (category, message, data) { emit(category, 'info', message, data); },
|
||||
warn: function (category, message, data) { emit(category, 'warn', message, data); },
|
||||
error: function (category, message, data) { emit(category, 'error', message, data); }
|
||||
};
|
||||
})();
|
||||
@@ -6,9 +6,9 @@
|
||||
|
||||
if (window.__FLUIDSYNTH_CDN) {
|
||||
FLUIDSYNTH_JS_URL = window.__FLUIDSYNTH_CDN;
|
||||
} else if (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') {
|
||||
FLUIDSYNTH_JS_URL = 'https://cdn.jsdelivr.net/npm/@enikey87/fluidsynth-emscripten@0.1.1/dist/libfluidsynth-2.3.0-sf3.js';
|
||||
} else {
|
||||
// Luôn dùng WASM local — Tauri webview hostname=localhost nên CDN (jsdelivr) bị
|
||||
// chặn làm FluidSynth không load, phải rơi về beep. File có sẵn trong source+dist.
|
||||
FLUIDSYNTH_JS_URL = '/static/js/vendor/libfluidsynth-2.3.0-sf3.js';
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
// SonicForge Native Audio Client (T15) — IPC JS → native engine.
|
||||
// Track instrument KHÔNG còn đi masterBus WebAudio: master fader + track
|
||||
// gain/pan áp native qua NativeMixer (SF host / VST2 / VST3 bridge DLL).
|
||||
// WebAudio chỉ giữ cho UI/aux. Mọi call fire-and-forget — native engine
|
||||
// tự âm thanh; không block UI.
|
||||
window.SonicNativeAudio = window.SonicNativeAudio || {};
|
||||
|
||||
(function () {
|
||||
var BASE = window.API_BASE_URL || window.location.origin;
|
||||
|
||||
function headers() {
|
||||
var h = { 'Content-Type': 'application/json' };
|
||||
var token = localStorage.getItem('sonic_token') || '';
|
||||
if (token) h['Authorization'] = 'Bearer ' + token;
|
||||
return h;
|
||||
}
|
||||
|
||||
async function post(path, body) {
|
||||
var resp = await fetch(BASE + '/api/v1/native' + path, {
|
||||
method: 'POST',
|
||||
headers: headers(),
|
||||
body: JSON.stringify(body || {})
|
||||
});
|
||||
var data = await resp.json().catch(function () { return {}; });
|
||||
if (!resp.ok) throw new Error(data.detail || 'native API lỗi ' + resp.status);
|
||||
return data;
|
||||
}
|
||||
|
||||
async function get(path) {
|
||||
var resp = await fetch(BASE + '/api/v1/native' + path, { headers: headers() });
|
||||
var data = await resp.json().catch(function () { return {}; });
|
||||
if (!resp.ok) throw new Error(data.detail || 'native API lỗi ' + resp.status);
|
||||
return data;
|
||||
}
|
||||
|
||||
var _masterTimer = null;
|
||||
|
||||
window.SonicNativeAudio = {
|
||||
status: function () {
|
||||
return get('/status');
|
||||
},
|
||||
// Master fader: debounce 40ms — fader kéo liên tục, chỉ gửi giá trị cuối.
|
||||
setMasterGain: function (gainDb) {
|
||||
if (_masterTimer) clearTimeout(_masterTimer);
|
||||
_masterTimer = setTimeout(function () {
|
||||
post('/set_master_gain', { gain_db: gainDb }).catch(function (e) {
|
||||
console.warn('[NativeAudio] set_master_gain:', e.message);
|
||||
});
|
||||
}, 40);
|
||||
},
|
||||
setTrackGainPan: function (trackId, gainDb, pan) {
|
||||
return post('/track_gain_pan', { track_id: trackId, gain_db: gainDb, pan: pan }).catch(function (e) {
|
||||
console.warn('[NativeAudio] track_gain_pan:', e.message);
|
||||
});
|
||||
},
|
||||
ensureSf: function (trackId, sfId, bank, program) {
|
||||
return post('/sf/ensure', { track_id: trackId, sf_id: sfId || null, bank: bank || 0, program: program || 0, live: true }).catch(function (e) {
|
||||
console.warn('[NativeAudio] sf/ensure:', e.message);
|
||||
});
|
||||
},
|
||||
ensureVst2: function (trackId, pluginId, pluginPath) {
|
||||
return post('/vst2/ensure', { track_id: trackId, plugin_id: pluginId || null, plugin_path: pluginPath || null, live: true }).catch(function (e) {
|
||||
console.warn('[NativeAudio] vst2/ensure:', e.message);
|
||||
});
|
||||
},
|
||||
ensureVst3: function (trackId, pluginId, pluginPath) {
|
||||
return post('/vst3/ensure', { track_id: trackId, plugin_id: pluginId || null, plugin_path: pluginPath || null, live: true }).catch(function (e) {
|
||||
console.warn('[NativeAudio] vst3/ensure:', e.message);
|
||||
});
|
||||
},
|
||||
noteOn: function (kind, trackId, channel, pitch, velocity) {
|
||||
var path = kind === 'vst2' ? '/vst2/note_on' : (kind === 'vst3' ? '/vst3/note_on' : '/sf/note_on');
|
||||
// Rethrow sau warn: TrackInstrument can fallback autosample/WASM
|
||||
// khi native khong san sang (DLL/plugin loi).
|
||||
return post(path, { track_id: trackId, channel: channel || 0, pitch: pitch, velocity: velocity != null ? velocity : 100 }).catch(function (e) {
|
||||
console.warn('[NativeAudio] note_on:', e.message);
|
||||
throw e;
|
||||
});
|
||||
},
|
||||
noteOff: function (kind, trackId, channel, pitch) {
|
||||
var path = kind === 'vst2' ? '/vst2/note_off' : (kind === 'vst3' ? '/vst3/note_off' : '/sf/note_off');
|
||||
return post(path, { track_id: trackId, channel: channel || 0, pitch: pitch }).catch(function (e) {
|
||||
console.warn('[NativeAudio] note_off:', e.message);
|
||||
throw e;
|
||||
});
|
||||
},
|
||||
sfAudioStop: function (trackId) {
|
||||
return post('/sf/audio_stop', { track_id: trackId, pitch: 0 }).catch(function () {});
|
||||
}
|
||||
};
|
||||
})();
|
||||
@@ -74,6 +74,10 @@ window.SonicRuntime = window.SonicRuntime || { loaded: false, capabilities: null
|
||||
window.SonicCarlaMidi = window.SonicCarlaMidi || {
|
||||
shouldRoute: function (synthEngine, isArmed) {
|
||||
try {
|
||||
// Live playback qua Carla TẮT mặc định: VSTi phát client-side qua SF2
|
||||
// autosampled (FluidSynth WASM) → masterBus → mastering → main out.
|
||||
// Bật lại = window.__enableCarlaLivePlayback = true (debug/legacy).
|
||||
if (window.__enableCarlaLivePlayback !== true) return false;
|
||||
var c = window.SonicRuntime && window.SonicRuntime.capabilities;
|
||||
if (!c || !c.features || !c.features.carla_local) return false;
|
||||
if (!isArmed) return false;
|
||||
@@ -85,6 +89,8 @@ window.SonicCarlaMidi = window.SonicCarlaMidi || {
|
||||
// user đã chủ động bấm Play trên item đó).
|
||||
shouldRoutePlayback: function (synthEngine) {
|
||||
try {
|
||||
// (xem shouldRoute) — live playback qua Carla tắt mặc định
|
||||
if (window.__enableCarlaLivePlayback !== true) return false;
|
||||
var c = window.SonicRuntime && window.SonicRuntime.capabilities;
|
||||
if (!c || !c.features || !c.features.carla_local) return false;
|
||||
var se = synthEngine || {};
|
||||
@@ -120,6 +126,12 @@ window.SonicCarlaMidi = window.SonicCarlaMidi || {
|
||||
stopBridge: function () {
|
||||
var self = this;
|
||||
try { self.allNotesOff(); } catch (e) {}
|
||||
// Reset trạng thái Carla bridge — nếu không, sau unload các cờ stale
|
||||
// (__carlaRunning=true) khiến ensureCarlaForPlayback early-return và
|
||||
// GUI Carla KHÔNG mở lại được khi reload.
|
||||
window.__carlaRunning = false;
|
||||
window.__carlaOpening = false;
|
||||
window.__carlaNoteQueue = [];
|
||||
if (window.SonicAPI && window.SonicAPI.stopCarla) {
|
||||
return window.SonicAPI.stopCarla().catch(function () {});
|
||||
}
|
||||
|
||||
@@ -464,7 +464,7 @@
|
||||
bank = bank !== undefined ? bank : (synthEngine.soundfont_bank || 0);
|
||||
program = program !== undefined ? program : (synthEngine.soundfont_program || 0);
|
||||
}
|
||||
var sfId = synthEngine && synthEngine.soundfont_id;
|
||||
var sfId = synthEngine && (synthEngine.soundfont_id || synthEngine.autosampled_sf_id);
|
||||
var engKey = (sfId || '') + ':' + bank + ':' + program;
|
||||
if (!_engineChMap[engKey]) {
|
||||
var channel = this.allocateChannel(bank);
|
||||
@@ -587,7 +587,43 @@
|
||||
if (isNaN(finalBank) || !isFinite(finalBank)) finalBank = 0;
|
||||
var finalProg = parseInt(usedProg);
|
||||
if (isNaN(finalProg) || !isFinite(finalProg)) finalProg = 0;
|
||||
var finalSfId = synthEngine ? synthEngine.soundfont_id : undefined;
|
||||
var finalSfId = synthEngine ? (synthEngine.soundfont_id || synthEngine.autosampled_sf_id) : undefined;
|
||||
// VSTi live playback (không Carla): chưa có SF2 autosampled → chờ
|
||||
// SonicVstiAutosample.ensure (1 request backend / plugin+preset) rồi
|
||||
// play qua FluidSynth WASM như soundfont thường — âm đi qua masterBus
|
||||
// → mastering → main out. ensure dedup in-flight + cooldown lỗi 60s.
|
||||
if (!finalSfId && window.SonicVstiAutosample && synthEngine && !synthEngine.soundfont_id
|
||||
&& String(synthEngine.type || '').indexOf('vst') !== -1 && !!synthEngine.plugin_id) {
|
||||
var _pendKey = ch + ':' + midiPitch;
|
||||
var _gen = _noteGeneration;
|
||||
_pendingNoteOns[_pendKey] = (_pendingNoteOns[_pendKey] || 0) + 1;
|
||||
window.SonicVstiAutosample.ensure(synthEngine).then(function (sfId) {
|
||||
if (_gen !== _noteGeneration) return;
|
||||
var _pend = _pendingNoteOns[_pendKey];
|
||||
if (_pend) {
|
||||
_pendingNoteOns[_pendKey] = _pend - 1;
|
||||
if (_pendingNoteOns[_pendKey] <= 0) delete _pendingNoteOns[_pendKey];
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
if (!sfId) {
|
||||
// Không autosample được (404/501) → fallback oscillator để note
|
||||
// KHÔNG câm; cooldown trong ensure chặn re-request.
|
||||
try {
|
||||
var _reason = window.SonicVstiAutosample.failReasonFor ? window.SonicVstiAutosample.failReasonFor(synthEngine) : null;
|
||||
if (_reason && window.showToast && (!self._vstiToastAt || Date.now() - self._vstiToastAt > 10000)) {
|
||||
self._vstiToastAt = Date.now();
|
||||
window.showToast('Autosample VSTi that bai: ' + _reason + ' - dung am default', 'warning');
|
||||
}
|
||||
self._playNoteFallback(note, velocity, durationMs, startTime, program, null, channel, synthEngine);
|
||||
} catch (e) {}
|
||||
return;
|
||||
}
|
||||
synthEngine.autosampled_sf_id = sfId;
|
||||
doNote();
|
||||
});
|
||||
return;
|
||||
}
|
||||
var cachedCh = _channels[ch];
|
||||
// The note's own synth engine (track instrument) is
|
||||
// authoritative. Channel state is only a cache: it must never
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
// SonicForge TrackInstrument Service
|
||||
// Bọc engine per-track: playNote/noteOff theo TrackInstrumentCtx
|
||||
// (ch/program/synthEngine/sfId/bank/prog/dest) — dùng cho preview mọi nguồn
|
||||
// (keybed, piano roll, draw, hardware MIDI, click) qua UnifiedMidiRouter.
|
||||
// T15: native-first — SF track đi thẳng vào engine native (NativeAudioService
|
||||
// + SF host bridge), SonicSF WASM chỉ là fallback khi native không sẵn sàng.
|
||||
// Tạo mới mỗi note-on (overwrite registry) — voice cũ giữ engine cũ trong
|
||||
// tracker nên note-off luôn stop đúng channel.
|
||||
window.TrackInstrument = window.TrackInstrument || {};
|
||||
|
||||
(function () {
|
||||
function TrackInstrument(trackId, ctx) {
|
||||
this.trackId = trackId;
|
||||
this.ch = ctx ? (ctx.ch != null ? ctx.ch : 0) : 0;
|
||||
this.program = ctx ? ctx.program : undefined;
|
||||
this.synthEngine = ctx ? ctx.synthEngine : undefined;
|
||||
this.sfId = ctx ? ctx.sfId : undefined;
|
||||
this.bank = ctx ? (ctx.bank || 0) : 0;
|
||||
this.prog = ctx ? (ctx.prog || 0) : 0;
|
||||
this.dest = ctx ? (ctx.dest || null) : null;
|
||||
}
|
||||
|
||||
// Loại engine native cho track: 'sf' (soundfont), 'vst3'/'vst2' (VSTi có
|
||||
// plugin_id) hay null (không native được → fallback WASM/autosample).
|
||||
// T15-regression fix: native live (SF/VSTi qua WASAPI bridge) phát thẳng ra
|
||||
// OS device — BỎ QUA WebAudio masterBus → mastering FX + Main out VU không
|
||||
// nhận tín hiệu. Gate native live path: luôn fallback WASM (SonicSF/
|
||||
// autosample) qua masterBus.input. Native vẫn dùng cho OFFLINE render
|
||||
// (scheduleNativeSfItem / /api/v1/native/render) — không đi qua hàm này.
|
||||
// ponytail: re-enable live native khi C++ bridge có capture/readback →
|
||||
// set window.__SONICFORGE_NATIVE_LIVE = true.
|
||||
TrackInstrument.prototype._nativeKind = function () {
|
||||
if (!window.__SONICFORGE_NATIVE_LIVE) return null;
|
||||
try {
|
||||
if (this.sfId) return 'sf';
|
||||
if (window.SonicNativeAudio && this.synthEngine && this.synthEngine.plugin_id) {
|
||||
var t = String(this.synthEngine.type || '');
|
||||
if (t.indexOf('vst2') !== -1) return 'vst2';
|
||||
if (t.indexOf('vst3') !== -1 || t.indexOf('vst') !== -1) return 'vst3';
|
||||
}
|
||||
} catch (e) { }
|
||||
return null;
|
||||
};
|
||||
|
||||
// Native engine sẵn sàng cho track? (sfId cho SF, plugin_id cho VSTi)
|
||||
TrackInstrument.prototype._nativeReady = function () {
|
||||
return this._nativeKind() !== null;
|
||||
};
|
||||
|
||||
// Đảm bảo channel đã select đúng instrument trước khi play (fire-and-forget:
|
||||
// playNote tự load + retry nếu SF chưa load xong — dedup trong loadSoundFont).
|
||||
TrackInstrument.prototype._ensure = function () {
|
||||
try {
|
||||
if (!window.SonicSF || !window.SonicSF.selectInstrument) return;
|
||||
if (this.sfId) {
|
||||
window.SonicSF.selectInstrument(this.ch, this.bank, this.prog, this.sfId);
|
||||
} else if (this.program !== undefined) {
|
||||
window.SonicSF.selectInstrument(this.ch, 0, this.program, null);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[TrackInstrument] selectInstrument error:', e);
|
||||
}
|
||||
};
|
||||
|
||||
// Phát qua SonicSF WASM (fallback khi native fail). SonicSF tự autosample
|
||||
// VSTi (soundfontPlayer._playNoteFluid) nên không cần ensure riêng ở đây.
|
||||
TrackInstrument.prototype._fallbackPlayNote = function (pitch, velocity, durationMs, startTime) {
|
||||
try {
|
||||
if (!window.SonicSF || !window.SonicSF.playNote) return;
|
||||
this._ensure();
|
||||
var durF = (durationMs != null ? durationMs : 500) || 500;
|
||||
window.SonicSF.playNote(pitch, velocity != null ? velocity : 0.8, durF, startTime, this.program, this.dest, this.ch, this.synthEngine);
|
||||
} catch (e) {
|
||||
console.warn('[TrackInstrument] fallback playNote error:', e);
|
||||
}
|
||||
};
|
||||
|
||||
// velocity: router đã normalize int 1-127 (unifiedMidiRouter.normalizeVelocity).
|
||||
TrackInstrument.prototype.playNote = function (pitch, velocity, durationMs, startTime) {
|
||||
var vel = velocity != null ? velocity : 100;
|
||||
var kind = this._nativeKind();
|
||||
if (kind) {
|
||||
try {
|
||||
var self = this;
|
||||
var dur = (durationMs != null ? durationMs : 500) || 500;
|
||||
if (kind === 'sf') {
|
||||
// ensure native SF engine cho track (server dedup theo track_id)
|
||||
window.SonicNativeAudio.ensureSf(this.trackId, this.sfId, this.bank, this.prog)
|
||||
.catch(function () {});
|
||||
} else {
|
||||
// VSTi live native (Phase 2): ensure + note-on qua bridge DLL.
|
||||
// Server resolve plugin_id -> path; lỗi -> fallback WASM autosample.
|
||||
if (kind === 'vst3') {
|
||||
window.SonicNativeAudio.ensureVst3(this.trackId, this.synthEngine.plugin_id, this.synthEngine.plugin_path)
|
||||
.catch(function () {});
|
||||
} else {
|
||||
window.SonicNativeAudio.ensureVst2(this.trackId, this.synthEngine.plugin_id, this.synthEngine.plugin_path)
|
||||
.catch(function () {});
|
||||
}
|
||||
}
|
||||
window.SonicNativeAudio.noteOn(kind, this.trackId, this.ch, pitch, vel).catch(function (err) {
|
||||
// Native khong phat duoc (DLL/plugin loi) -> fallback WASM:
|
||||
// SF track di SonicSF, VSTi di autosample (SonicSF tu ensure).
|
||||
console.warn('[TrackInstrument] native noteOn fail -> WASM fallback:', err && err.message);
|
||||
self._fallbackPlayNote(pitch, velocity, dur, startTime);
|
||||
});
|
||||
// ponytail: TrackInstrument không biết audioCtx → bỏ startTime
|
||||
// offset (delay = duration); thêm scheduling chính xác khi cần
|
||||
setTimeout(function () { self.noteOff(pitch); }, dur + 40);
|
||||
return;
|
||||
} catch (e) {
|
||||
console.warn('[TrackInstrument] native playNote error:', e);
|
||||
}
|
||||
}
|
||||
// Fallback SonicSF (WASM)
|
||||
this._fallbackPlayNote(pitch, velocity, durationMs, startTime);
|
||||
};
|
||||
|
||||
TrackInstrument.prototype.noteOff = function (pitch) {
|
||||
var kind = this._nativeKind();
|
||||
if (kind) {
|
||||
try {
|
||||
window.SonicNativeAudio.noteOff(kind, this.trackId, this.ch, pitch).catch(function () {
|
||||
// Native note-off fail -> stop qua WASM (neu note da fallback)
|
||||
try { if (window.SonicSF && window.SonicSF.stopNote) window.SonicSF.stopNote(this.ch, pitch); } catch (e) {}
|
||||
});
|
||||
} catch (e) {}
|
||||
}
|
||||
// Belt-and-suspenders: stopNote WASM vo hai neu khong co note dang phat.
|
||||
try {
|
||||
if (window.SonicSF && window.SonicSF.stopNote) window.SonicSF.stopNote(this.ch, pitch);
|
||||
} catch (e) {}
|
||||
};
|
||||
|
||||
window.TrackInstrument = TrackInstrument;
|
||||
})();
|
||||
@@ -0,0 +1,179 @@
|
||||
// SonicForge Unified MIDI Router
|
||||
// Mọi nguồn MIDI (keybed preview, timeline scheduler, hardware MIDI) → chuẩn
|
||||
// hóa UnifiedMidiEvent → UnifiedMidiRouter → engine theo trackId. Một điểm
|
||||
// dispatch duy nhất: activeVoiceTracker đếm note-on/off đúng (hết stuck
|
||||
// notes), panicAllNotesOff() quét toàn bộ voice khi đổi instrument/engine
|
||||
// giữa chừng.
|
||||
// Trạng thái nối (đối chiếu GIAI_PHAP 2026-08): ĐÃ nối cho PREVIEW — app.jsx
|
||||
// _routeNoteOn/_routeNoteOff (L212-260) đăng ký TrackInstrument per-track và
|
||||
// dispatch qua router (keybed/click/draw/timeline scheduler gọi _routePreviewNote
|
||||
// trước, chỉ fallback SonicSF.playNote khi router không xử lý). CHƯA nối cho
|
||||
// timeline scheduler per-item (vẫn gọi thẳng SonicSF.playNote / scheduleNativeSfItem).
|
||||
window.SonicUnifiedMidiRouter = window.SonicUnifiedMidiRouter || {};
|
||||
|
||||
(function () {
|
||||
var COMMAND_NOTE_ON = 0x90;
|
||||
var COMMAND_NOTE_OFF = 0x80;
|
||||
var COMMAND_CC = 0xB0;
|
||||
|
||||
// Pitch: ép int 0-127 (spec "pitch clamp 0-127").
|
||||
function clampPitch(pitch) {
|
||||
pitch = Math.round(Number(pitch) || 0);
|
||||
if (pitch < 0) return 0;
|
||||
if (pitch > 127) return 127;
|
||||
return pitch;
|
||||
}
|
||||
|
||||
// Velocity: float 0-1 (vd 100/127) hoặc int → ép int 1-127 (note-on);
|
||||
// note-off velocity = 0. Spec "velocity normalize float→int".
|
||||
function normalizeVelocity(velocity, isNoteOn) {
|
||||
var v = Number(velocity);
|
||||
if (!isFinite(v)) v = 0;
|
||||
if (v > 0 && v <= 1) v = v * 127;
|
||||
v = Math.round(v);
|
||||
if (v < 0) v = 0;
|
||||
if (v > 127) v = 127;
|
||||
if (isNoteOn && v <= 0) v = 1;
|
||||
return v;
|
||||
}
|
||||
|
||||
function UnifiedMidiRouter() {
|
||||
this.engineRegistry = new Map(); // trackId → TrackInstrument
|
||||
// activeVoiceTracker: key `${trackId}_${channel}_${pitch}` → { count, engine }.
|
||||
// (Spec dùng `${channel}_${pitch}`; thêm trackId vì engine theo track —
|
||||
// 2 track cùng channel+pitch phải đếm riêng, không lệch voice.)
|
||||
this._voices = new Map();
|
||||
}
|
||||
|
||||
// Đăng ký engine cho track (null = xóa). Đổi instrument → registerEngine
|
||||
// track mới; panic trước đó để không sót voice cũ.
|
||||
UnifiedMidiRouter.prototype.registerEngine = function (trackId, engine) {
|
||||
if (trackId === undefined || trackId === null) return;
|
||||
if (engine) this.engineRegistry.set(trackId, engine);
|
||||
else this.engineRegistry.delete(trackId);
|
||||
};
|
||||
|
||||
// Dispatch 1 event từ mọi nguồn. Không ném — voice tracker luôn đếm đúng.
|
||||
// Trả true nếu event là note (đã route/count), false nếu không xử lý.
|
||||
UnifiedMidiRouter.prototype.dispatchMidiEvent = function (evt) {
|
||||
if (!evt) return false;
|
||||
var trackId = evt.trackId;
|
||||
var engine = (trackId !== undefined && trackId !== null) ? this.engineRegistry.get(trackId) : null;
|
||||
var channel = Math.round(Number(evt.channel) || 0);
|
||||
if (channel < 0) channel = 0;
|
||||
if (channel > 15) channel = 15;
|
||||
var pitch = clampPitch(evt.pitch);
|
||||
var cmd = Number(evt.command);
|
||||
var key = trackId + '_' + channel + '_' + pitch;
|
||||
|
||||
var isNoteOn = (cmd === COMMAND_NOTE_ON);
|
||||
var isNoteOff = (cmd === COMMAND_NOTE_OFF) || (cmd === COMMAND_NOTE_ON && !Number(evt.velocity));
|
||||
|
||||
if (isNoteOn) {
|
||||
var vel = normalizeVelocity(evt.velocity, true);
|
||||
var cur = this._voices.get(key);
|
||||
if (!cur) {
|
||||
cur = { count: 0, engine: engine };
|
||||
this._voices.set(key, cur);
|
||||
}
|
||||
cur.engine = engine;
|
||||
cur.count += 1;
|
||||
// Chỉ trigger âm ở note-on đầu tiên; các note-on trùng key chỉ tăng
|
||||
// count (sustain lặp) — note-off cuối cùng mới tắt voice.
|
||||
if (cur.count === 1 && engine && engine.playNote) {
|
||||
try { engine.playNote(pitch, vel, evt.durationMs || 500, evt.startTime); } catch (e) {}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isNoteOff) {
|
||||
var cur2 = this._voices.get(key);
|
||||
if (cur2 && cur2.count > 0) {
|
||||
cur2.count -= 1;
|
||||
var eng2 = cur2.engine;
|
||||
if (cur2.count <= 0) {
|
||||
this._voices.delete(key);
|
||||
if (eng2 && eng2.noteOff) {
|
||||
try { eng2.noteOff(pitch); } catch (e) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false; // CC / unknown
|
||||
};
|
||||
|
||||
// Hardware MIDI keyboard message → dispatch tới targets đã gom sẵn (armed
|
||||
// tracks). app.jsx thu thập noteOnTargets [{trackId, ch, engine}] ở note-on
|
||||
// và noteOffTargets [{trackId, ch}] ở note-off, gọi 1 lần mỗi message.
|
||||
// parse giống handler cũ (cmd 4-bit, pitch clamp, velocity 1-127) rồi đẩy
|
||||
// qua dispatchMidiEvent → 3 nguồn (keybed/scheduler/hardware) chung router.
|
||||
UnifiedMidiRouter.prototype.handleHardwareKeyboardMessage = function (msg, noteOnTargets, noteOffTargets) {
|
||||
var cmd = Number(msg[0]) >> 4;
|
||||
var pitch = clampPitch(msg[1]);
|
||||
var rawVel = Number(msg[2]) || 0;
|
||||
var velocity = Math.min(127, Math.max(1, Math.round(rawVel)));
|
||||
var noteOn = (cmd === 0x9 && rawVel > 0);
|
||||
var noteOff = (cmd === 0x8 || (cmd === 0x9 && rawVel === 0));
|
||||
|
||||
if (noteOn && noteOnTargets) {
|
||||
noteOnTargets.forEach(function (t) {
|
||||
if (!t || t.trackId === undefined || t.trackId === null) return;
|
||||
if (t.engine) this.registerEngine(t.trackId, t.engine);
|
||||
this.dispatchMidiEvent({
|
||||
trackId: t.trackId,
|
||||
command: COMMAND_NOTE_ON,
|
||||
channel: t.ch,
|
||||
pitch: pitch,
|
||||
velocity: velocity / 127,
|
||||
durationMs: 60000,
|
||||
source: 'HARDWARE_KEYBOARD'
|
||||
});
|
||||
}, this);
|
||||
}
|
||||
|
||||
if (noteOff && noteOffTargets) {
|
||||
noteOffTargets.forEach(function (t) {
|
||||
if (!t || t.trackId === undefined || t.trackId === null) return;
|
||||
this.dispatchMidiEvent({
|
||||
trackId: t.trackId,
|
||||
command: COMMAND_NOTE_OFF,
|
||||
channel: t.ch,
|
||||
pitch: pitch,
|
||||
source: 'HARDWARE_KEYBOARD'
|
||||
});
|
||||
}, this);
|
||||
}
|
||||
|
||||
console.log('[MidiRouter] hardware ->', { noteOn: noteOn, noteOff: noteOff, pitch: pitch, velocity: velocity, targets: (noteOnTargets ? noteOnTargets.length : 0) + '/' + (noteOffTargets ? noteOffTargets.length : 0) });
|
||||
return { noteOn: noteOn, noteOff: noteOff, pitch: pitch, velocity: velocity };
|
||||
};
|
||||
|
||||
// All Notes Off: quét voice tracker, gửi note-off cho engine sở hữu từng
|
||||
// voice, xóa tracker. Gọi khi dừng playback / đổi instrument giữa chừng.
|
||||
UnifiedMidiRouter.prototype.panicAllNotesOff = function () {
|
||||
var self = this;
|
||||
this._voices.forEach(function (v, key) {
|
||||
if (v.engine && v.engine.noteOff) {
|
||||
var parts = key.split('_');
|
||||
var pitch = parseInt(parts[parts.length - 1], 10);
|
||||
try { v.engine.noteOff(pitch); } catch (e) {}
|
||||
}
|
||||
});
|
||||
this._voices.clear();
|
||||
};
|
||||
|
||||
// Số voice active cho 1 (track, channel, pitch) — test/debug.
|
||||
UnifiedMidiRouter.prototype.activeVoiceCount = function (trackId, channel, pitch) {
|
||||
var cur = this._voices.get(trackId + '_' + channel + '_' + pitch);
|
||||
return cur ? cur.count : 0;
|
||||
};
|
||||
|
||||
window.SonicUnifiedMidiRouter = {
|
||||
UnifiedMidiRouter: UnifiedMidiRouter,
|
||||
COMMAND_NOTE_ON: COMMAND_NOTE_ON,
|
||||
COMMAND_NOTE_OFF: COMMAND_NOTE_OFF,
|
||||
COMMAND_CC: COMMAND_CC
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,138 @@
|
||||
// SonicForge VSTi Autosample Service
|
||||
// Live playback 100% client-side (không Carla): VSTi note → SF2 autosampled
|
||||
// (backend pedalboard, render 1 lần / plugin+preset) → FluidSynth WASM qua
|
||||
// masterBus → mastering → main out. ensure() dedup in-flight request + cooldown
|
||||
// lỗi (404/501/no pedalboard) để không spam server.
|
||||
window.SonicVstiAutosample = window.SonicVstiAutosample || {};
|
||||
|
||||
(function () {
|
||||
var LS_MAP_KEY = 'sf_vsti_autosample_map';
|
||||
var LS_FAIL_KEY = 'sf_vsti_autosample_fail';
|
||||
var FAIL_COOLDOWN_MS = 60000;
|
||||
|
||||
function readMap() {
|
||||
try { return JSON.parse(localStorage.getItem(LS_MAP_KEY) || '{}') || {}; } catch (e) { return {}; }
|
||||
}
|
||||
function writeMap(map) {
|
||||
try { localStorage.setItem(LS_MAP_KEY, JSON.stringify(map)); } catch (e) {}
|
||||
}
|
||||
function readFails() {
|
||||
try { return JSON.parse(localStorage.getItem(LS_FAIL_KEY) || '{}') || {}; } catch (e) { return {}; }
|
||||
}
|
||||
function recordFail(k, reason) {
|
||||
try {
|
||||
var fails2 = readFails();
|
||||
fails2[k] = { ts: Date.now(), reason: reason || 'Autosample that bai' };
|
||||
localStorage.setItem(LS_FAIL_KEY, JSON.stringify(fails2));
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
function formatSize(bytes) {
|
||||
if (!bytes) return '0 KB';
|
||||
if (bytes >= 1048576) return (bytes / 1048576).toFixed(1) + ' MB';
|
||||
return Math.max(1, Math.round(bytes / 1024)) + ' KB';
|
||||
}
|
||||
|
||||
// UI re-render (chip hien thi note_count/size) khi autosample xong/fail
|
||||
function notify(k) {
|
||||
try { window.dispatchEvent(new CustomEvent('sf:autosample-update', { detail: { key: k } })); } catch (e) {}
|
||||
}
|
||||
|
||||
// Key = plugin + preset → autosample lại khi đổi preset (âm preset mới).
|
||||
function keyFor(synthEngine) {
|
||||
if (!synthEngine) return '';
|
||||
return String(synthEngine.plugin_id || '') + '|' + String(synthEngine.preset_id || synthEngine.presetId || '');
|
||||
}
|
||||
|
||||
// SF id đã autosample cho engine (cache localStorage) — đồng bộ, không request.
|
||||
function sfIdFor(synthEngine) {
|
||||
var k = keyFor(synthEngine);
|
||||
if (!k) return null;
|
||||
var map = readMap();
|
||||
return (map[k] && map[k].sf_id) ? map[k].sf_id : null;
|
||||
}
|
||||
|
||||
function entryFor(synthEngine) {
|
||||
var k = keyFor(synthEngine);
|
||||
if (!k) return null;
|
||||
return readMap()[k] || null;
|
||||
}
|
||||
|
||||
var _inFlight = {}; // key → Promise<sf_id|null> — nhiều note cùng lúc chỉ 1 request
|
||||
|
||||
// Đảm bảo SF2 autosampled tồn tại. Trả Promise<sf_id|null>; null =
|
||||
// không autosample được → caller fallback (oscillator / không phát).
|
||||
function ensure(synthEngine) {
|
||||
var k = keyFor(synthEngine);
|
||||
if (!k) return Promise.resolve(null);
|
||||
var cached = sfIdFor(synthEngine);
|
||||
if (cached) return Promise.resolve(cached);
|
||||
if (_inFlight[k]) return _inFlight[k];
|
||||
var fails = readFails();
|
||||
var _f = fails[k];
|
||||
var _lastTs = (_f && typeof _f === 'object') ? _f.ts : _f;
|
||||
if (_lastTs && (Date.now() - _lastTs) < FAIL_COOLDOWN_MS) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
if (!window.SonicAPI || !window.SonicAPI.autosampleVsti) {
|
||||
recordFail(k, 'API autosample khong kha dung');
|
||||
notify(k);
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
_inFlight[k] = new Promise(function (resolve) {
|
||||
var payload = {
|
||||
instrument_id: synthEngine.plugin_id,
|
||||
preset_id: synthEngine.preset_id || synthEngine.presetId || null,
|
||||
preset_path: synthEngine.preset_path || null,
|
||||
preset_data: synthEngine.preset_data || null
|
||||
};
|
||||
window.SonicAPI.autosampleVsti(payload).then(function (res) {
|
||||
delete _inFlight[k];
|
||||
if (res && res.success && res.sf_id) {
|
||||
var map = readMap();
|
||||
map[k] = { sf_id: res.sf_id, size_bytes: res.size_bytes || 0, note_count: res.note_count || 0, plugin_id: synthEngine.plugin_id, preset_id: synthEngine.preset_id || synthEngine.presetId || '', ts: Date.now() };
|
||||
writeMap(map);
|
||||
notify(k);
|
||||
resolve(res.sf_id);
|
||||
} else {
|
||||
recordFail(k, (res && res.error) ? String(res.error) : 'Server khong tra sf_id');
|
||||
notify(k);
|
||||
resolve(null);
|
||||
}
|
||||
}).catch(function (err) {
|
||||
delete _inFlight[k];
|
||||
recordFail(k, (err && err.message) ? String(err.message) : 'Loi mang / server autosample');
|
||||
notify(k);
|
||||
resolve(null);
|
||||
});
|
||||
});
|
||||
return _inFlight[k];
|
||||
}
|
||||
|
||||
// Lý do autosample thất bại gần nhất (fail map) — null nếu chưa từng fail
|
||||
// (hoặc entry cũ định dạng number). Dùng cho UI toast khi ensure() trả null.
|
||||
function failReasonFor(synthEngine) {
|
||||
var k = keyFor(synthEngine);
|
||||
if (!k) return null;
|
||||
var fails = readFails();
|
||||
var f = fails[k];
|
||||
if (!f || typeof f !== 'object') return null;
|
||||
return f.reason || null;
|
||||
}
|
||||
|
||||
// Xoá cache + cooldown — cho phép re-sample (đổi preset/plugin hoặc debug).
|
||||
function clearCache() {
|
||||
try { localStorage.removeItem(LS_MAP_KEY); } catch (e) {}
|
||||
try { localStorage.removeItem(LS_FAIL_KEY); } catch (e) {}
|
||||
}
|
||||
|
||||
window.SonicVstiAutosample = {
|
||||
keyFor: keyFor,
|
||||
sfIdFor: sfIdFor,
|
||||
entryFor: entryFor,
|
||||
formatSize: formatSize,
|
||||
ensure: ensure,
|
||||
failReasonFor: failReasonFor,
|
||||
clearCache: clearCache
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,132 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="vi">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>VST GUI</title>
|
||||
<style>
|
||||
:root { color-scheme: dark; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; font-family: system-ui, -apple-system, "Segoe UI", sans-serif; background: #16161a; color: #e4e4e7; }
|
||||
header { display: flex; align-items: center; justify-content: space-between; padding: 8px 12px; background: #1e1e24; border-bottom: 1px solid #2d2d35; position: sticky; top: 0; z-index: 10; }
|
||||
header h1 { font-size: 13px; margin: 0; font-weight: 600; color: #7dd3fc; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
header .meta { font-size: 10px; color: #71717a; margin-top: 2px; }
|
||||
#params { padding: 10px 12px; }
|
||||
.param { display: grid; grid-template-columns: minmax(0,1fr) 60px; align-items: center; gap: 8px; padding: 6px 0; border-bottom: 1px solid #26262d; }
|
||||
.param .title { font-size: 11px; color: #d4d4d8; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.param .val { font-size: 10px; font-family: ui-monospace, monospace; color: #7dd3fc; text-align: right; }
|
||||
input[type=range] { width: 100%; accent-color: #0ea5e9; }
|
||||
#status { padding: 8px 12px; font-size: 11px; color: #a1a1aa; }
|
||||
#status.err { color: #f87171; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div>
|
||||
<h1 id="title">VST GUI</h1>
|
||||
<div class="meta" id="meta"></div>
|
||||
</div>
|
||||
<button id="closeBtn" style="background:#3f3f46;border:1px solid #52525b;color:#f4f4f5;font-size:11px;padding:4px 10px;border-radius:4px;cursor:pointer;">Đóng</button>
|
||||
</header>
|
||||
<div id="params"></div>
|
||||
<div id="status">Đang tải tham số…</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
const qs = new URLSearchParams(window.location.search);
|
||||
const trackId = qs.get('track') || '';
|
||||
const pluginId = qs.get('plugin') || '';
|
||||
const el = (id) => document.getElementById(id);
|
||||
|
||||
el('title').textContent = 'VST GUI - ' + pluginId;
|
||||
el('meta').textContent = 'track=' + trackId;
|
||||
|
||||
const invoke = (cmd, args) => {
|
||||
if (window.__TAURI__ && window.__TAURI__.core) {
|
||||
return window.__TAURI__.core.invoke(cmd, args);
|
||||
}
|
||||
return Promise.reject(new Error('Tauri core unavailable'));
|
||||
};
|
||||
|
||||
let params = [];
|
||||
|
||||
function render() {
|
||||
const box = el('params');
|
||||
box.innerHTML = '';
|
||||
if (!params.length) {
|
||||
el('status').textContent = 'Plugin không có tham số (hoặc chưa nạp).';
|
||||
return;
|
||||
}
|
||||
el('status').textContent = params.length + ' tham số — kéo slider để đổi giá trị.';
|
||||
params.forEach((p) => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'param';
|
||||
const title = document.createElement('div');
|
||||
title.className = 'title';
|
||||
title.textContent = p.title || ('Param ' + p.param_id);
|
||||
title.title = title.textContent;
|
||||
const val = document.createElement('div');
|
||||
val.className = 'val';
|
||||
const range = document.createElement('input');
|
||||
range.type = 'range';
|
||||
range.min = 0;
|
||||
range.max = 1;
|
||||
range.step = 0.001;
|
||||
range.value = Math.min(1, Math.max(0, p.value || 0));
|
||||
val.textContent = (p.value || 0).toFixed(3);
|
||||
let dragging = false;
|
||||
range.addEventListener('input', () => {
|
||||
val.textContent = Number(range.value).toFixed(3);
|
||||
});
|
||||
range.addEventListener('change', () => {
|
||||
invoke('set_vst_param', { trackId: trackId, pluginId: pluginId, paramId: p.param_id, value: Number(range.value) })
|
||||
.catch((err) => { el('status').className = 'err'; el('status').textContent = 'set_vst_param lỗi: ' + (err && (err.message || err)); });
|
||||
});
|
||||
row.appendChild(title);
|
||||
row.appendChild(val);
|
||||
row.appendChild(range);
|
||||
box.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
// Sync 2 chiều: native editor đổi param → Rust emit vst_param_changed → cập nhật slider.
|
||||
function onParamChanged(e) {
|
||||
const d = e.payload || {};
|
||||
if (d.param_id === undefined) return;
|
||||
const p = params.find((x) => x.param_id === d.param_id);
|
||||
if (p) {
|
||||
p.value = d.value;
|
||||
const ranges = document.querySelectorAll('input[type=range]');
|
||||
const idx = params.indexOf(p);
|
||||
if (ranges[idx]) ranges[idx].value = Math.min(1, Math.max(0, d.value));
|
||||
const vals = document.querySelectorAll('.val');
|
||||
if (vals[idx]) vals[idx].textContent = Number(d.value).toFixed(3);
|
||||
}
|
||||
}
|
||||
|
||||
function init() {
|
||||
invoke('get_vst_params', { trackId: trackId, pluginId: pluginId }).then((list) => {
|
||||
params = list || [];
|
||||
render();
|
||||
}).catch((err) => {
|
||||
el('status').className = 'err';
|
||||
el('status').textContent = 'get_vst_params lỗi: ' + (err && (err.message || err)) + ' — plugin chưa mở được (kiểm tra log Rust / bridge DLL).';
|
||||
});
|
||||
}
|
||||
|
||||
el('closeBtn').addEventListener('click', () => {
|
||||
invoke('close_vst_editor', { trackId: trackId, pluginId: pluginId }).catch(() => {});
|
||||
window.close();
|
||||
});
|
||||
window.addEventListener('beforeunload', () => {
|
||||
invoke('close_vst_editor', { trackId: trackId, pluginId: pluginId }).catch(() => {});
|
||||
});
|
||||
|
||||
if (window.__TAURI__ && window.__TAURI__.event) {
|
||||
window.__TAURI__.event.listen('vst_param_changed', onParamChanged).catch(() => {});
|
||||
}
|
||||
init();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -33,12 +33,17 @@
|
||||
<script src="/static/vendor/react.production.min.js"></script>
|
||||
<script src="/static/vendor/react-dom.production.min.js"></script>
|
||||
<script src="/static/js/services/fluidsynthLoader.js?v=202607271245"></script>
|
||||
<script src="/static/js/services/runtime.js?v=202608101800"></script>
|
||||
<script src="/static/js/services/api.js?v=202608101800"></script>
|
||||
<script src="/static/js/services/runtime.js?v=202608111230"></script>
|
||||
<script src="/static/js/services/api.js?v=202608111301"></script>
|
||||
<script src="/static/js/services/appLogger.js?v=202608111301"></script>
|
||||
<script src="/static/js/services/vstiAutosample.js?v=202608111230"></script>
|
||||
<script src="/static/js/services/unifiedMidiRouter.js?v=202608111230"></script>
|
||||
<script src="/static/js/services/nativeAudioClient.js?v=202608111230"></script>
|
||||
<script src="/static/js/services/trackInstrument.js?v=202608111301"></script>
|
||||
<script src="/static/js/services/audioEngine.js?v=202607271016"></script>
|
||||
<script src="/static/js/services/storage.js?v=202608038200"></script>
|
||||
<script src="/static/js/services/soundfontStorage.js?v=202607271016"></script>
|
||||
<script src="/static/js/services/soundfontPlayer.js?v=202608101800"></script>
|
||||
<script src="/static/js/services/soundfontPlayer.js?v=202608111230"></script>
|
||||
<script src="/static/js/services/aiGateway.js?v=202608037200"></script>
|
||||
<script src="/static/js/services/dawCommandDispatcher.js?v=202607271016"></script>
|
||||
<script src="/static/js/services/pianoRollTabService.js?v=202607272044"></script>
|
||||
@@ -46,7 +51,7 @@
|
||||
<script src="/static/js/services/midiExtractor.js?v=202607281052"></script>
|
||||
<script src="/static/js/services/promptTemplateManager.js?v=202607281039"></script>
|
||||
<script src="/static/js/services/undoRedoEngine.js?v=202607290941"></script>
|
||||
<script src="/static/js/app.precompiled.js?v=202608102202" defer></script>
|
||||
<script src="/static/js/app.precompiled.js?v=202608111301" defer></script>
|
||||
<link rel="stylesheet" href="/static/css/styles.css?v=202607271016">
|
||||
<style>
|
||||
:root {
|
||||
|
||||
@@ -64,6 +64,51 @@ New-Item -ItemType Directory -Force "src-tauri\resources\daw_engine" | Out-Null
|
||||
Copy-Item "dist\daw_engine\*" "src-tauri\resources\daw_engine\" -Recurse -Force
|
||||
Write-Host "Copied onedir engine -> src-tauri\resources\daw_engine"
|
||||
|
||||
Write-Host "== [4b/6] Native host bridges (CMake/MSVC) + FluidSynth runtime =="
|
||||
# T8-T16: vst2/vst3/sf_host_bridge DLL + fluidsynth_runtime (22 DLL) phai nam
|
||||
# trong resources/native_host/ de exe tim thay luc runtime (Rust: resource_dir/
|
||||
# native_host; Python: SF_RESOURCE_DIR/native_host). Thieu -> native audio /
|
||||
# VST GUI khong chay tren may cai sach.
|
||||
#
|
||||
# Prebuilt: 3 bridge DLL (~175KB) DUOC COMMIT vao src-tauri/resources/native_host/
|
||||
# (.gitignore cho phep *.dll) de nguoi dung clone moi KHONG can VST3 SDK
|
||||
# (Steinberg SDK ban quyen) + khong can MSVC/cmake. Chi build tu source khi
|
||||
# ca 3 DLL deu thieu (may dev tu build bridge moi).
|
||||
$bridgeDlls = @("sf_host_bridge.dll","vst2_host_bridge.dll","vst3_host_bridge.dll")
|
||||
$havePrebuilt = $true
|
||||
foreach ($dllName in $bridgeDlls) {
|
||||
if (-not (Test-Path "src-tauri\resources\native_host\$dllName")) { $havePrebuilt = $false }
|
||||
}
|
||||
if ($havePrebuilt) {
|
||||
Write-Host "Dung prebuilt bridge DLL (resources/native_host) - skip cmake build, khong can VST3 SDK."
|
||||
} else {
|
||||
if (-not (Test-Path "native_host\build\CMakeCache.txt")) {
|
||||
if (-not $env:VST3_SDK_ROOT) {
|
||||
Write-Host "ERROR: thieu prebuilt bridge DLL + khong co native_host\build + VST3_SDK_ROOT chua set." -ForegroundColor Red
|
||||
Write-Host " Cach 1 (khuyen nghi): git pull lai de co src-tauri/resources/native_host/*.dll (prebuilt)." -ForegroundColor Yellow
|
||||
Write-Host " Cach 2 (dev): set env VST3_SDK_ROOT tro den VST3 SDK roi build lai native_host." -ForegroundColor Yellow
|
||||
exit 1
|
||||
}
|
||||
& cmake -S native_host -B native_host\build -A x64 -DVST3_SDK_ROOT="$env:VST3_SDK_ROOT"
|
||||
if ($LASTEXITCODE -ne 0) { Write-Host "ERROR: cmake configure native_host that bai" -ForegroundColor Red; exit 1 }
|
||||
}
|
||||
& cmake --build native_host\build --config Release
|
||||
if ($LASTEXITCODE -ne 0) { Write-Host "ERROR: cmake build native_host that bai" -ForegroundColor Red; exit 1 }
|
||||
Copy-Item "native_host\build\Release\vst2_host_bridge.dll","native_host\build\Release\vst3_host_bridge.dll","native_host\build\Release\sf_host_bridge.dll" "src-tauri\resources\native_host\"
|
||||
}
|
||||
|
||||
# fluidsynth_runtime (14.8MB, gitignore) - khong commit; tu dong tai neu thieu.
|
||||
if (-not (Test-Path "src-tauri\resources\native_host\fluidsynth_runtime\libfluidsynth-3.dll")) {
|
||||
if (-not (Test-Path "native_host\fluidsynth_runtime\libfluidsynth-3.dll")) {
|
||||
python native_host\scripts\dl_fluidsynth_runtime.py
|
||||
if ($LASTEXITCODE -ne 0) { Write-Host "ERROR: dl_fluidsynth_runtime that bai" -ForegroundColor Red; exit 1 }
|
||||
}
|
||||
New-Item -ItemType Directory -Force "src-tauri\resources\native_host\fluidsynth_runtime" | Out-Null
|
||||
Copy-Item "native_host\fluidsynth_runtime\*" "src-tauri\resources\native_host\fluidsynth_runtime\" -Recurse -Force
|
||||
} else {
|
||||
Write-Host "Da co fluidsynth_runtime trong resources/native_host."
|
||||
}
|
||||
|
||||
Write-Host "== [5/6] VC++ Redistributable cho hooks.nsh =="
|
||||
if (-not (Test-Path src-tauri\vc_redist.x64.exe)) {
|
||||
Invoke-WebRequest -Uri "https://aka.ms/vs/17/release/vc_redist.x64.exe" -OutFile src-tauri\vc_redist.x64.exe
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
# TASK 53: ALTERNATIVE SOLUTIONS FOR DIRECT VSTi USAGE IN STANDALONE DAW APPLICATIONS
|
||||
|
||||
> Spec: "ALTERNATIVE SOLUTIONS FOR DIRECT VSTi USAGE IN STANDALONE DAW APPLICATIONS"
|
||||
> Thực hiện trên `C:/Users/locpham/SonicForgeStudio` (branch `standalone`).
|
||||
> Tài liệu đi kèm: `md/53_VSTI_AUTOSAMPLE_RENDER_WALKTHROUGH.md`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Mục tiêu (từ spec)
|
||||
|
||||
| Option | Yêu cầu spec | Hiện thực |
|
||||
|---|---|---|
|
||||
| **Option 1** | Client-side WASM live preview với **0% IPC** — cần "a custom Python script" tự động sample các preset VSTi thành `.sf3` (~3-8MB) | `tools/autosample_vsti.py` — render từng nốt qua pedalboard → SF2 (16-bit mono), tùy chọn convert sang SF3 |
|
||||
| **Option 2** | Offline render/export qua Python backend (pedalboard VST3 + FluidSynth) — WAV chất lượng **24-bit / 32-bit** | Thêm `bit_depth` (16/24/32) vào toàn bộ đường render/export backend + frontend |
|
||||
|
||||
---
|
||||
|
||||
## 2. Thay đổi
|
||||
|
||||
### 2.1 Auto-sampling tool (mới)
|
||||
|
||||
**File:** `tools/autosample_vsti.py` (mới, untracked)
|
||||
|
||||
- CLI: `--instrument <plugin_id> --out out.sf2 [--preset] [--low --high --step] [--sf3] [--duration --release --velocity --sample-rate]`
|
||||
- Reuse `PluginManager().load_vst`, `apply_preset_to_plugin`, `HAS_PEDALBOARD` từ `app/core/vst_engine.py`
|
||||
- Render từng nốt bằng pedalboard, MIDI dạng **raw tuple** `(bytes([0x90,note,vel]), 0.0)` / `(bytes([0x80,note,0]), dur)` — pedalboard 0.9.19 build này **không có** `NoteOn` classes; khớp `PluginManager.midi_events_to_messages` (trả `(bytes, seconds)` tuples)
|
||||
- Trim leading silence + normalize peak 0.9 trước khi cast int16
|
||||
- `write_sf2()`: SF2 tối giản hợp lệ — 16-bit mono PCM, 1 zone/nốt (`keyRange lo=hi`), `sampleID`, `overridingRootKey`, `end=exclusive`; preset 0/bank 0
|
||||
- `--sf3`: convert qua `SoundFontConverter` (`app/core/soundfont_converter.py`); fallback giữ SF2 nếu thiếu ffmpeg libvorbis
|
||||
- Toàn bộ text ASCII-only (an toàn cp1252 console Windows)
|
||||
|
||||
**SF2 writer — chi tiết đúng chuẩn** (đã verify bằng sf2utils):
|
||||
- INFO text chunk (INAM) **phải chẵn** — pad `\x00` bên trong data (strict parsers không skip RIFF pad byte của odd chunk)
|
||||
- Records: `phdr` 38B (name20 + `<HHHIII`), `inst` 22B, `pmod`/`imod` 10B, `pgen`/`igen` 4B (`<HH`), `ibag`/`pbag` 4B, `shdr` 46B; bắt buộc terminator records cho từng bảng
|
||||
|
||||
**Kết quả verify:** Nexus.vst3 → 3 nốt (60/62/64) → SF2 207KB, sf2utils parse sạch, audio peak 29490. Kontakt load được nhưng silent (không có .nki — đúng dự kiến).
|
||||
|
||||
### 2.2 bit_depth 16/24/32 WAV export (Option 2)
|
||||
|
||||
| File | Thay đổi |
|
||||
|---|---|
|
||||
| `app/core/render_engine.py` | `render_project(self, project_json, output_filepath, bit_depth=16)` (L453); `subtype_map={16:"PCM_16",24:"PCM_24",32:"PCM_32"}` → `sf.write` (L479-481) |
|
||||
| `app/api/v1/plugins.py` | `bit_depth: int = 16` thêm vào `RenderRequest` (L493), `MidiRenderRequest` (L937), `SoundfontRenderRequest` (L1063); truyền xuống `engine.render_project(..., bit_depth=req.bit_depth)` (L1200), `_render_midi_notes_pedalboard(..., bit_depth=16)` (signature L1001, sf.write L1048), `soundfont_render` sf.write L1100 |
|
||||
| `app/core/audio_editor.py` | Cả 2 subtype map `{8:"PCM_S8",16:"PCM_16",24:"PCM_24",32:"PCM_32"}` (L196, L257) — mix multitrack + export |
|
||||
| `app/static/js/app.jsx` | Option `32` thêm vào **cả 2** dropdown export (L11355 JSX + compiled); encoder 32-bit (`view.setInt32(... s*0x80000000 ...)`) thêm vào **cả 2** client WAV encoder — realtime bounce (L25044-25045) và offline-session (L25363-25368). Đồng thời sửa bug có sẵn: trước đây chọn 24-bit rơi vào nhánh fallback 8-bit |
|
||||
|
||||
### 2.3 Tests
|
||||
|
||||
- `tests/test_autosampler.py` (mới): `test_write_sf2_valid_structure` (RIFF/sfbk, 3 samples, preset 0/bank 0, 8820 frames), `test_write_sf2_rejects_empty`, `test_write_sf2_odd_name_no_corruption` (regression: tên lẻ "Nexus" không làm sf2utils báo corrupted)
|
||||
- `tests/test_render_engine.py`: thêm `test_render_project_bit_depth` (16/24/32 → PCM_16/24/32)
|
||||
|
||||
---
|
||||
|
||||
## 3. Kết quả test
|
||||
|
||||
```
|
||||
python -m pytest tests/ -q
|
||||
→ 108 passed, 1 skipped, 1 failed
|
||||
```
|
||||
|
||||
1 fail: `tests/test_vst_engine.py::TestPluginManager::test_init` — **lỗi môi trường có sẵn** (machine VST3 override `C:\Program Files\Common Files\VST3` vs expected `/opt/daw_engine/vst3`; đã xác nhận fail cả trên code sạch bằng git stash). Không thuộc task này.
|
||||
|
||||
> Lưu ý: không chạy `pytest` bare từ root repo (collect torch resources dưới `src-tauri/target/release/resources/` → 52 collection errors). Luôn nhắm `tests/`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Cách dùng
|
||||
|
||||
```bash
|
||||
# Option 1 — auto-sample VSTi preset sang SF2 (client WASM preview, 0% IPC)
|
||||
python tools/autosample_vsti.py --instrument Nexus.vst3 --out app/storage/soundfonts/nexus.sf2 --preset <path.vstpreset> --low 36 --high 96 --step 2
|
||||
|
||||
# ... hoặc sang SF3 (cần ffmpeg + libvorbis)
|
||||
python tools/autosample_vsti.py --instrument Nexus.vst3 --out app/storage/soundfonts/nexus.sf3 --sf3
|
||||
|
||||
# Option 2 — render/export 24-bit/32-bit (API)
|
||||
POST /api/v1/plugins/render { "project_json": {...}, "bit_depth": 24 }
|
||||
POST /api/v1/plugins/midi-render { ..., "bit_depth": 32 }
|
||||
POST /api/v1/plugins/soundfont-render { ..., "bit_depth": 24 }
|
||||
```
|
||||
|
||||
SF2/SF3 đặt vào `app/storage/soundfonts/` để client tải qua `/soundfonts/download`.
|
||||
@@ -0,0 +1,95 @@
|
||||
# WALKTHROUGH 53: VSTi AUTOSAMPLE + 24/32-BIT RENDER/EXPORT
|
||||
|
||||
> Ghi lại các hành động đã thực hiện cho Task 53 (`md/53_VSTI_AUTOSAMPLE_RENDER.md`).
|
||||
> Môi trường: Windows 11, bash, Python 3.13.7, pedalboard==0.9.19, repo `C:/Users/locpham/SonicForgeStudio` branch `standalone`.
|
||||
|
||||
---
|
||||
|
||||
## Bước 0 — Đọc spec & khảo sát hiện trạng
|
||||
|
||||
1. Đọc spec "ALTERNATIVE SOLUTIONS FOR DIRECT VSTi USAGE IN STANDALONE DAW APPLICATIONS" → 2 option cần hiện thực:
|
||||
- Option 1: custom Python script auto-sample VSTi preset → `.sf3` (~3-8MB) cho client WASM live preview 0% IPC
|
||||
- Option 2: offline render/export qua Python backend, WAV 24-bit/32-bit
|
||||
2. Khảo sát codebase: `app/core/vst_engine.py` (PluginManager, apply_preset_to_plugin, HAS_PEDALBOARD, midi_events_to_messages), `app/core/render_engine.py`, `app/core/audio_editor.py`, `app/api/v1/plugins.py` (RenderRequest, MidiRenderRequest, SoundfontRenderRequest), `app/static/js/app.jsx` (2 WAV encoder), `app/core/soundfont_converter.py` (SF2→SF3).
|
||||
|
||||
## Bước 1 — Khảo sát API pedalboard trên máy
|
||||
|
||||
3. `dir(pedalboard)` → **không có** NoteOn/MIDI classes trong build này. Xác định MIDI phải truyền dạng **raw tuple** `(bytes, seconds)`:
|
||||
- `dir(PluginManager)` và đọc `midi_events_to_messages` trong `vst_engine.py` → trả `(bytes, seconds)` tuples.
|
||||
- Quyết định: render MIDI qua `vst(messages, sample_rate=..., duration=...)` với `(bytes([0x90,note,vel]), 0.0)` (note-on) và `(bytes([0x80,note,0]), dur)` (note-off).
|
||||
|
||||
## Bước 2 — Viết `tools/autosample_vsti.py`
|
||||
|
||||
4. Viết script lần 1 bằng heredoc với nội dung có ký tự Unicode (dấu tiếng Việt) → **crash**: `open(p,'w')` trên cp1252 console gây `UnicodeEncodeError` giữa chừng → **file bị truncate/hỏng**.
|
||||
5. **Viết lại từ đầu** với toàn bộ text ASCII-only (chuẩn style repo `tools/gen_icons.py`): dùng "duong dan", "khong", "not" thay vì dấu tiếng Việt; mọi in/help đều ASCII.
|
||||
6. Cấu trúc script:
|
||||
- `_render_note(vst, note, sr, dur, release, velocity)`: build raw MIDI tuples → `vst(...)` → mean về mono → trim leading silence (ngưỡng `peak*0.001`) → normalize peak 0.9 → int16; trả `None` nếu silent.
|
||||
- `write_sf2(out_path, samples, sample_rate, name)`: tự sinh SF2 tối giản (16-bit mono PCM, 1 zone/nốt, preset 0/bank 0).
|
||||
- `main()`: argparse `--instrument --out --preset --low --high --step --duration --release --velocity --sample-rate --sf3`.
|
||||
7. Verify từng record size SF2 bằng chuẩn spec + sf2utils:
|
||||
- `phdr` 38B, `inst` 22B, `pmod`/`imod` 10B, `pgen`/`igen` 4B, `ibag`/`pbag` 4B, `shdr` 46B + terminator records.
|
||||
- **Bug phát hiện**: INAM lẻ ("Nexus") → sf2utils báo "corrupted but salvageable" vì strict parser không skip RIFF pad byte của odd chunk. Fix: pad `\x00` **bên trong data** cho INFO text chunk chẵn.
|
||||
|
||||
## Bước 3 — Verify end-to-end auto-sample
|
||||
|
||||
8. Chạy thử với VSTi thật:
|
||||
```
|
||||
python tools/autosample_vsti.py --instrument Nexus.vst3 --out nexus_test.sf2 --low 60 --high 64 --step 2
|
||||
```
|
||||
→ 3 nốt (60/62/64) → SF2 **207KB**, sf2utils parse sạch, audio peak 29490. ✅
|
||||
9. Thử Kontakt.vst3 → load được nhưng silent (không có .nki — đúng dự kiến, không phải lỗi script).
|
||||
|
||||
## Bước 4 — Thêm bit_depth 16/24/32 backend
|
||||
|
||||
10. `app/core/render_engine.py` L453: `render_project(self, project_json, output_filepath, bit_depth=16)`; L479-481: `subtype_map={16:"PCM_16",24:"PCM_24",32:"PCM_32"}` → `sf.write(..., subtype=subtype_map.get(int(bit_depth), "PCM_16"))`.
|
||||
11. `app/api/v1/plugins.py`:
|
||||
- `bit_depth: int = 16` vào `RenderRequest` (L493), `MidiRenderRequest` (L937), `SoundfontRenderRequest` (L1063).
|
||||
- `_render_midi_notes_pedalboard(..., bit_depth=16)` (L1001) → sf.write L1048.
|
||||
- `soundfont_render` sf.write L1100.
|
||||
- `render_project` API truyền `bit_depth=req.bit_depth` (L1200).
|
||||
12. `app/core/audio_editor.py`: cả 2 subtype map (L196, L257) thêm `32:"PCM_32"` — mix multitrack + export.
|
||||
13. Verify bằng `sf.info(path).subtype` (soundfile): 16→PCM_16, 24→PCM_24, 32→PCM_32. ✅
|
||||
|
||||
## Bước 5 — Thêm bit_depth 32 frontend (`app/static/js/app.jsx` — CRLF)
|
||||
|
||||
14. **Chú ý CRLF**: `edit_file` multi-line fail nếu old_string không có `\r`. Dùng python heredoc: đọc `open(p, encoding='utf-8', newline='').read()`, assert count==1, replace bằng chuỗi chứa `\r\n`, ghi lại với `newline=''`. Không bao giờ `open(p,'w')` trước khi encode.
|
||||
15. Thêm `<option value="32">32</option>` vào **cả 2** dropdown export (JSX L11355 + compiled bản ~L28700).
|
||||
16. Thêm nhánh encoder 32-bit vào **cả 2** client WAV encoder:
|
||||
- realtime bounce (L25044-25045): `else if (bitDepth === 32) view.setInt32(o, Math.floor(s < 0 ? s * 0x80000000 : s * 0x7FFFFFFF), true);`
|
||||
- offline-session (L25363-25368): tương tự `setInt32`.
|
||||
17. **Bug cũ được sửa ngầm**: trước đây chọn 24-bit bị rơi vào nhánh fallback 8-bit (thiếu nhánh `===24`); thêm nhánh 24-bit + 32-bit đúng thứ tự.
|
||||
|
||||
## Bước 6 — Tests
|
||||
|
||||
18. Viết `tests/test_autosampler.py` (mới, LF):
|
||||
- `test_write_sf2_valid_structure`: RIFF/sfbk, 3 samples, preset 0/bank 0, 8820 frames, `sample_rate==44100`.
|
||||
- `test_write_sf2_rejects_empty`: `write_sf2([], ...)` → ValueError.
|
||||
- `test_write_sf2_odd_name_no_corruption`: tên "Nexus" (5 ký tự lẻ) → sf2utils không log "corrupted".
|
||||
19. Thêm `test_render_project_bit_depth` vào `tests/test_render_engine.py`: loop (16,24,32) → assert `sf.info(out).subtype == PCM_16/24/32`.
|
||||
20. Chạy:
|
||||
```
|
||||
python -m pytest tests/ -q
|
||||
→ 108 passed, 1 skipped, 1 failed
|
||||
```
|
||||
- 1 skipped: pyfluidsynth không có native lib trên máy (tests không yêu cầu).
|
||||
- 1 failed: `TestPluginManager::test_init` — **pre-existing** (machine VST3 override `C:\Program Files\Common Files\VST3` vs expected `/opt/daw_engine/vst3`).
|
||||
21. Xác nhận fail đó không do mình: `git stash` → chạy test → fail y hệt trên code sạch → `git stash pop`. ✅
|
||||
|
||||
## Bước 7 — Kiểm tra cuối & tài liệu
|
||||
|
||||
22. `git status` → các file modified/untracked đúng như dự kiến (không đụng file ngoài phạm vi; giữ nguyên các thay đổi pre-existing của user: `app.jsx`, `services/api.js`, `services/fluidsynthLoader.js`, `app.precompiled.js`).
|
||||
23. Viết `md/53_VSTI_AUTOSAMPLE_RENDER.md` (task) + `md/53_VSTI_AUTOSAMPLE_RENDER_WALKTHROUGH.md` (file này).
|
||||
|
||||
---
|
||||
|
||||
## Tổng kết diff
|
||||
|
||||
```
|
||||
M app/api/v1/plugins.py (bit_depth 3 requests + 3 sf.write + pass-through)
|
||||
M app/core/audio_editor.py (+32:"PCM_32" ở 2 subtype map)
|
||||
M app/core/render_engine.py (render_project bit_depth + subtype_map)
|
||||
M app/static/js/app.jsx (option 32 ×2 dropdown, encoder 32-bit ×2, fix 24-bit fallback)
|
||||
M tests/test_render_engine.py (+test_render_project_bit_depth)
|
||||
?? tools/autosample_vsti.py (mới — auto-sample VSTi → SF2/SF3)
|
||||
?? tests/test_autosampler.py (mới — 3 tests SF2 writer)
|
||||
```
|
||||
@@ -0,0 +1,343 @@
|
||||
// AudioEngine.cpp — native audio loop (T12, spec vsti_native_audio_pipeline_spec)
|
||||
//
|
||||
// SPSC ring buffer (MIDI UI thread → audio thread) + WASAPI render thread.
|
||||
// Audio thread: không mutex, không cấp phát, không I/O. MMCSS "Audio" để
|
||||
// giảm underrun.
|
||||
#define NOMINMAX
|
||||
#define WIN32_LEAN_AND_MEAN
|
||||
#include "AudioEngine.h"
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
|
||||
#include <avrt.h>
|
||||
#include <functiondiscoverykeys.h>
|
||||
#include <objbase.h>
|
||||
|
||||
namespace sonicforge {
|
||||
|
||||
namespace {
|
||||
|
||||
void set_err (char* err, int32 err_cap, const char* msg)
|
||||
{
|
||||
if (err && err_cap > 0)
|
||||
std::snprintf (err, static_cast<size_t> (err_cap), "%s", msg);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
AudioEngine::AudioEngine ()
|
||||
{
|
||||
std::memset (m_events, 0, sizeof (m_events));
|
||||
}
|
||||
|
||||
AudioEngine::~AudioEngine ()
|
||||
{
|
||||
stop ();
|
||||
delete[] m_outL;
|
||||
delete[] m_outR;
|
||||
}
|
||||
|
||||
bool AudioEngine::pushMidi (const MidiEvent& ev)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock (m_producerMutex);
|
||||
uint32 head = m_head.load (std::memory_order_relaxed);
|
||||
uint32 next = nextIdx (head);
|
||||
if (next == m_tail.load (std::memory_order_acquire))
|
||||
return false; // full
|
||||
m_events[head] = ev;
|
||||
m_head.store (next, std::memory_order_release);
|
||||
return true;
|
||||
}
|
||||
|
||||
// T12: activate IAudioClient cho 1 device + Initialize exclusive→shared +
|
||||
// event handle + render service. Khong cấp phát m_outL/m_outR (start() lam).
|
||||
bool AudioEngine::initOnDevice (IMMDevice* device, int32 sampleRate, int32 blockSize,
|
||||
char* err, int32 err_cap)
|
||||
{
|
||||
if (!device)
|
||||
return false;
|
||||
if (m_client)
|
||||
{
|
||||
// device truoc do dang giu — stop/release de thu device moi
|
||||
stop ();
|
||||
}
|
||||
HRESULT hr = device->Activate (__uuidof (IAudioClient), CLSCTX_ALL, nullptr,
|
||||
reinterpret_cast<void**> (&m_client));
|
||||
if (FAILED (hr) || !m_client)
|
||||
{
|
||||
m_client = nullptr;
|
||||
set_err (err, err_cap, "IAudioClient activate failed");
|
||||
return false;
|
||||
}
|
||||
|
||||
WAVEFORMATEX fmt = {};
|
||||
fmt.wFormatTag = WAVE_FORMAT_IEEE_FLOAT;
|
||||
fmt.nChannels = 2;
|
||||
fmt.nSamplesPerSec = static_cast<DWORD> (sampleRate);
|
||||
fmt.wBitsPerSample = 32;
|
||||
fmt.nBlockAlign = 8;
|
||||
fmt.nAvgBytesPerSec = static_cast<DWORD> (sampleRate) * 8;
|
||||
|
||||
// 100ns units: 1e7 * seconds
|
||||
REFERENCE_TIME hnsPeriod =
|
||||
static_cast<REFERENCE_TIME> (10000000.0 * blockSize / sampleRate);
|
||||
|
||||
// Exclusive trước (latency đúng period), fallback shared. Shared mode
|
||||
// yêu cầu đúng mix format của device (GetMixFormat) — format tự build
|
||||
// IEEE_FLOAT 2ch thường bị AUDCLNT_E_UNSUPPORTED_FORMAT (0x88890008).
|
||||
hr = m_client->Initialize (AUDCLNT_SHAREMODE_EXCLUSIVE,
|
||||
AUDCLNT_STREAMFLAGS_EVENTCALLBACK,
|
||||
hnsPeriod, hnsPeriod, &fmt, nullptr);
|
||||
if (FAILED (hr))
|
||||
{
|
||||
WAVEFORMATEX* mix = nullptr;
|
||||
if (SUCCEEDED (m_client->GetMixFormat (&mix)) && mix)
|
||||
{
|
||||
m_sampleRate = static_cast<int32> (mix->nSamplesPerSec);
|
||||
hr = m_client->Initialize (AUDCLNT_SHAREMODE_SHARED,
|
||||
AUDCLNT_STREAMFLAGS_EVENTCALLBACK,
|
||||
hnsPeriod, 0, mix, nullptr);
|
||||
CoTaskMemFree (mix);
|
||||
}
|
||||
if (FAILED (hr))
|
||||
{
|
||||
std::snprintf (err, static_cast<size_t> (err_cap),
|
||||
"WASAPI Initialize failed: 0x%08X", static_cast<unsigned> (hr));
|
||||
m_client->Release ();
|
||||
m_client = nullptr;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
hr = m_client->GetBufferSize (&m_bufferFrames);
|
||||
if (FAILED (hr))
|
||||
{
|
||||
stop ();
|
||||
return false;
|
||||
}
|
||||
|
||||
m_event = CreateEventW (nullptr, FALSE, FALSE, nullptr);
|
||||
if (!m_event)
|
||||
{
|
||||
stop ();
|
||||
return false;
|
||||
}
|
||||
hr = m_client->SetEventHandle (m_event);
|
||||
if (FAILED (hr))
|
||||
{
|
||||
stop ();
|
||||
return false;
|
||||
}
|
||||
hr = m_client->GetService (IID_PPV_ARGS (&m_render));
|
||||
if (FAILED (hr) || !m_render)
|
||||
{
|
||||
set_err (err, err_cap, "IAudioRenderClient failed");
|
||||
stop ();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool AudioEngine::start (int32 sampleRate, int32 blockSize, ProcessFn fn, void* userdata,
|
||||
char* err, int32 err_cap)
|
||||
{
|
||||
if (m_running.load (std::memory_order_acquire))
|
||||
return true;
|
||||
if (sampleRate <= 0 || blockSize <= 0)
|
||||
{
|
||||
set_err (err, err_cap, "bad sampleRate/blockSize");
|
||||
return false;
|
||||
}
|
||||
m_fn = fn;
|
||||
m_userdata = userdata;
|
||||
m_sampleRate = sampleRate;
|
||||
m_blockSize = blockSize;
|
||||
|
||||
HRESULT hr = CoInitializeEx (nullptr, COINIT_MULTITHREADED);
|
||||
if (FAILED (hr) && hr != RPC_E_CHANGED_MODE)
|
||||
{
|
||||
set_err (err, err_cap, "CoInitializeEx failed");
|
||||
return false;
|
||||
}
|
||||
m_comInit = (hr != RPC_E_CHANGED_MODE);
|
||||
|
||||
IMMDeviceEnumerator* enumerator = nullptr;
|
||||
hr = CoCreateInstance (__uuidof (MMDeviceEnumerator), nullptr, CLSCTX_ALL,
|
||||
IID_PPV_ARGS (&enumerator));
|
||||
if (FAILED (hr))
|
||||
{
|
||||
set_err (err, err_cap, "no MMDeviceEnumerator");
|
||||
goto fail;
|
||||
}
|
||||
|
||||
// Default endpoint trước; fallback: enumerate các endpoint ACTIVE và thử
|
||||
// từng cái (device default có thể bị invalidated — vd HDMI không connect).
|
||||
IMMDevice* device = nullptr;
|
||||
if (SUCCEEDED (enumerator->GetDefaultAudioEndpoint (eRender, eConsole, &device)) && device)
|
||||
{
|
||||
if (initOnDevice (device, sampleRate, blockSize, err, err_cap))
|
||||
{
|
||||
device->Release ();
|
||||
enumerator->Release ();
|
||||
goto ready;
|
||||
}
|
||||
device->Release ();
|
||||
}
|
||||
|
||||
IMMDeviceCollection* coll = nullptr;
|
||||
hr = enumerator->EnumAudioEndpoints (eRender, DEVICE_STATE_ACTIVE, &coll);
|
||||
if (FAILED (hr) || !coll)
|
||||
{
|
||||
enumerator->Release ();
|
||||
set_err (err, err_cap, "no active render device");
|
||||
goto fail;
|
||||
}
|
||||
UINT nDev = 0;
|
||||
coll->GetCount (&nDev);
|
||||
bool ok = false;
|
||||
for (UINT i = 0; i < nDev; ++i)
|
||||
{
|
||||
IMMDevice* d = nullptr;
|
||||
if (FAILED (coll->Item (i, &d)) || !d)
|
||||
continue;
|
||||
if (initOnDevice (d, sampleRate, blockSize, err, err_cap))
|
||||
ok = true;
|
||||
d->Release ();
|
||||
if (ok)
|
||||
break;
|
||||
}
|
||||
coll->Release ();
|
||||
enumerator->Release ();
|
||||
if (!ok)
|
||||
goto fail;
|
||||
|
||||
ready:
|
||||
m_latency.store (static_cast<int32> (m_bufferFrames), std::memory_order_relaxed);
|
||||
|
||||
delete[] m_outL;
|
||||
delete[] m_outR;
|
||||
m_outL = new float[m_bufferFrames];
|
||||
m_outR = new float[m_bufferFrames];
|
||||
|
||||
m_thread = CreateThread (nullptr, 0, &AudioEngine::renderThreadEntry, this, 0, nullptr);
|
||||
if (!m_thread)
|
||||
{
|
||||
set_err (err, err_cap, "CreateThread failed");
|
||||
goto fail;
|
||||
}
|
||||
|
||||
m_running.store (true, std::memory_order_release);
|
||||
hr = m_client->Start ();
|
||||
if (FAILED (hr))
|
||||
{
|
||||
m_running.store (false, std::memory_order_release);
|
||||
set_err (err, err_cap, "IAudioClient Start failed");
|
||||
goto fail;
|
||||
}
|
||||
return true;
|
||||
|
||||
fail:
|
||||
stop ();
|
||||
return false;
|
||||
}
|
||||
|
||||
void AudioEngine::stop ()
|
||||
{
|
||||
m_running.store (false, std::memory_order_release);
|
||||
if (m_client)
|
||||
m_client->Stop ();
|
||||
if (m_thread)
|
||||
{
|
||||
WaitForSingleObject (m_thread, 3000);
|
||||
CloseHandle (m_thread);
|
||||
m_thread = nullptr;
|
||||
}
|
||||
if (m_event)
|
||||
{
|
||||
CloseHandle (m_event);
|
||||
m_event = nullptr;
|
||||
}
|
||||
if (m_render)
|
||||
{
|
||||
m_render->Release ();
|
||||
m_render = nullptr;
|
||||
}
|
||||
if (m_client)
|
||||
{
|
||||
m_client->Release ();
|
||||
m_client = nullptr;
|
||||
}
|
||||
if (m_comInit)
|
||||
{
|
||||
CoUninitialize ();
|
||||
m_comInit = false;
|
||||
}
|
||||
m_head.store (0, std::memory_order_relaxed);
|
||||
m_tail.store (0, std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
DWORD WINAPI AudioEngine::renderThreadEntry (LPVOID self)
|
||||
{
|
||||
static_cast<AudioEngine*> (self)->renderLoop ();
|
||||
return 0;
|
||||
}
|
||||
|
||||
void AudioEngine::renderLoop ()
|
||||
{
|
||||
CoInitializeEx (nullptr, COINIT_MULTITHREADED);
|
||||
DWORD taskIndex = 0;
|
||||
HANDLE mmcss = AvSetMmThreadCharacteristicsW (L"Audio", &taskIndex);
|
||||
|
||||
while (m_running.load (std::memory_order_acquire))
|
||||
{
|
||||
DWORD wait = WaitForSingleObject (m_event, 200);
|
||||
if (wait != WAIT_OBJECT_0)
|
||||
continue;
|
||||
|
||||
UINT32 pad = 0;
|
||||
m_client->GetCurrentPadding (&pad);
|
||||
UINT32 frames = m_bufferFrames - pad;
|
||||
if (frames == 0)
|
||||
continue;
|
||||
|
||||
BYTE* data = nullptr;
|
||||
HRESULT hr = m_render->GetBuffer (frames, &data);
|
||||
if (FAILED (hr))
|
||||
{
|
||||
m_underruns.fetch_add (1, std::memory_order_relaxed);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Drain SPSC (single consumer — không lock)
|
||||
int32 count = 0;
|
||||
uint32 tail = m_tail.load (std::memory_order_relaxed);
|
||||
uint32 head = m_head.load (std::memory_order_acquire);
|
||||
while (tail != head && count < kMaxEventsPerBlock)
|
||||
{
|
||||
m_drained[count] = m_events[tail];
|
||||
tail = nextIdx (tail);
|
||||
++count;
|
||||
}
|
||||
m_tail.store (tail, std::memory_order_release);
|
||||
|
||||
bool wrote = m_fn
|
||||
? m_fn (m_drained, count, m_outL, m_outR, static_cast<int32> (frames), m_userdata)
|
||||
: false;
|
||||
|
||||
float* inter = reinterpret_cast<float*> (data);
|
||||
for (UINT32 i = 0; i < frames; ++i)
|
||||
{
|
||||
inter[2 * i] = wrote ? m_outL[i] : 0.0f;
|
||||
inter[2 * i + 1] = wrote ? m_outR[i] : 0.0f;
|
||||
}
|
||||
m_render->ReleaseBuffer (frames, 0);
|
||||
m_blocks.fetch_add (1, std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
if (mmcss)
|
||||
AvRevertMmThreadCharacteristics (mmcss);
|
||||
CoUninitialize ();
|
||||
}
|
||||
|
||||
} // namespace sonicforge
|
||||
@@ -0,0 +1,109 @@
|
||||
// AudioEngine.h — native audio loop (T12, spec vsti_native_audio_pipeline_spec)
|
||||
//
|
||||
// Lõi chung cho cả 2 bridge DLL (vst2_host_bridge, vst3_host_bridge): SPSC
|
||||
// ring buffer chuyển MIDI từ UI thread → audio thread, render thread WASAPI
|
||||
// (exclusive trước, fallback shared) gọi ProcessFn mỗi block. Audio thread
|
||||
// TUYỆT ĐỐI không lock / không cấp phát (golden rule spec III).
|
||||
//
|
||||
// QUYẾT ĐỊNH (T12): gộp "VST3AudioEngine.cpp" thành AudioEngine.cpp dùng
|
||||
// chung 2 plugin kind (spec cho phép "hoặc gộp với T9/T10"); WASAPI đủ cho
|
||||
// milestone, ASIO bỏ qua (ghi chú, thêm khi có yêu cầu rõ ràng).
|
||||
#pragma once
|
||||
#define NOMINMAX
|
||||
#define WIN32_LEAN_AND_MEAN
|
||||
#include <windows.h>
|
||||
|
||||
#include <atomic>
|
||||
#include <cstdint>
|
||||
#include <mutex>
|
||||
|
||||
#include <audioclient.h>
|
||||
#include <mmdeviceapi.h>
|
||||
|
||||
namespace sonicforge {
|
||||
|
||||
using int32 = int32_t;
|
||||
using uint32 = uint32_t;
|
||||
|
||||
// MIDI event từ UI thread → audio thread (SPSC). velocity 0..1.
|
||||
struct MidiEvent
|
||||
{
|
||||
int32 channel;
|
||||
int32 pitch;
|
||||
float velocity;
|
||||
bool noteOn;
|
||||
};
|
||||
|
||||
constexpr int32 kEventCapacity = 4096;
|
||||
constexpr int32 kMaxEventsPerBlock = 256;
|
||||
|
||||
// ProcessFn chạy trên audio thread (render thread). events/count là các MIDI
|
||||
// event đã drain từ SPSC trong block này. outL/outR: frames mẫu mỗi kênh,
|
||||
// pre-alloc (audio thread không cấp phát). Trả true nếu đã ghi output (false
|
||||
// → engine zero-fill).
|
||||
typedef bool (*ProcessFn)(const MidiEvent* events, int32 eventCount,
|
||||
float* outL, float* outR, int32 frames, void* userdata);
|
||||
|
||||
class AudioEngine
|
||||
{
|
||||
public:
|
||||
AudioEngine ();
|
||||
~AudioEngine ();
|
||||
|
||||
// Khởi tạo WASAPI + render thread. Exclusive trước, fallback shared.
|
||||
// err/err_cap để trả message lỗi (có thể null). Idempotent.
|
||||
bool start (int32 sampleRate, int32 blockSize, ProcessFn fn, void* userdata,
|
||||
char* err, int32 err_cap);
|
||||
void stop (); // join thread + giải phóng WASAPI
|
||||
|
||||
bool running () const { return m_running.load (std::memory_order_acquire); }
|
||||
|
||||
// Producer (UI thread) — có mutex riêng để an toàn với nhiều producer
|
||||
// (Tauri command thread pool); audio thread KHÔNG lock.
|
||||
bool pushMidi (const MidiEvent& ev);
|
||||
|
||||
int32 underruns () const { return m_underruns.load (std::memory_order_relaxed); }
|
||||
int32 latencySamples () const { return m_latency.load (std::memory_order_relaxed); }
|
||||
int32 blocksRendered () const { return m_blocks.load (std::memory_order_relaxed); }
|
||||
int32 sampleRate () const { return m_sampleRate; }
|
||||
|
||||
private:
|
||||
static DWORD WINAPI renderThreadEntry (LPVOID self);
|
||||
void renderLoop ();
|
||||
static uint32 nextIdx (uint32 i) { return (i + 1) % kEventCapacity; }
|
||||
|
||||
// T12: activate IAudioClient cho 1 device + Initialize exclusive→shared.
|
||||
// Trả true nếu OK (m_client/m_render/m_event/m_bufferFrames set).
|
||||
bool initOnDevice (IMMDevice* device, int32 sampleRate, int32 blockSize,
|
||||
char* err, int32 err_cap);
|
||||
|
||||
// SPSC ring (single consumer = audio thread; producer guarded by mutex)
|
||||
MidiEvent m_events[kEventCapacity];
|
||||
std::atomic<uint32> m_head {0}; // producer index (next write)
|
||||
std::atomic<uint32> m_tail {0}; // consumer index (next read)
|
||||
std::mutex m_producerMutex;
|
||||
MidiEvent m_drained[kMaxEventsPerBlock];
|
||||
|
||||
std::atomic<bool> m_running {false};
|
||||
std::atomic<int32> m_underruns {0};
|
||||
std::atomic<int32> m_latency {0};
|
||||
std::atomic<int32> m_blocks {0};
|
||||
|
||||
HANDLE m_thread = nullptr;
|
||||
HANDLE m_event = nullptr;
|
||||
|
||||
IAudioClient* m_client = nullptr;
|
||||
IAudioRenderClient* m_render = nullptr;
|
||||
UINT32 m_bufferFrames = 0;
|
||||
int32 m_sampleRate = 44100;
|
||||
int32 m_blockSize = 128;
|
||||
bool m_comInit = false;
|
||||
|
||||
ProcessFn m_fn = nullptr;
|
||||
void* m_userdata = nullptr;
|
||||
|
||||
float* m_outL = nullptr;
|
||||
float* m_outR = nullptr;
|
||||
};
|
||||
|
||||
} // namespace sonicforge
|
||||
@@ -0,0 +1,79 @@
|
||||
cmake_minimum_required(VERSION 3.20)
|
||||
project(sonicforge_native_host LANGUAGES CXX)
|
||||
|
||||
set(CMAKE_CXX_STANDARD 17)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
|
||||
if(NOT DEFINED VST3_SDK_ROOT)
|
||||
set(VST3_SDK_ROOT "" CACHE PATH "Path to VST3 SDK root")
|
||||
endif()
|
||||
|
||||
add_library(vst3_host_bridge SHARED
|
||||
vst3_host_bridge.cpp
|
||||
${VST3_SDK_ROOT}/public.sdk/source/common/commonstringconvert.cpp
|
||||
${VST3_SDK_ROOT}/public.sdk/source/common/threadchecker_win32.cpp
|
||||
${VST3_SDK_ROOT}/public.sdk/source/vst/hosting/module.cpp
|
||||
${VST3_SDK_ROOT}/public.sdk/source/vst/hosting/module_win32.cpp
|
||||
${VST3_SDK_ROOT}/public.sdk/source/vst/hosting/plugprovider.cpp
|
||||
${VST3_SDK_ROOT}/public.sdk/source/vst/hosting/hostclasses.cpp
|
||||
${VST3_SDK_ROOT}/public.sdk/source/vst/hosting/pluginterfacesupport.cpp
|
||||
${VST3_SDK_ROOT}/public.sdk/source/vst/hosting/connectionproxy.cpp
|
||||
${VST3_SDK_ROOT}/public.sdk/source/vst/utility/stringconvert.cpp
|
||||
${VST3_SDK_ROOT}/public.sdk/source/vst/vstinitiids.cpp
|
||||
${VST3_SDK_ROOT}/pluginterfaces/base/funknown.cpp
|
||||
${VST3_SDK_ROOT}/pluginterfaces/base/ustring.cpp
|
||||
${VST3_SDK_ROOT}/pluginterfaces/base/coreiids.cpp
|
||||
${VST3_SDK_ROOT}/base/source/baseiids.cpp
|
||||
)
|
||||
|
||||
target_include_directories(vst3_host_bridge PRIVATE ${VST3_SDK_ROOT})
|
||||
target_compile_definitions(vst3_host_bridge PRIVATE
|
||||
_CRT_SECURE_NO_WARNINGS
|
||||
NOMINMAX
|
||||
WIN32_LEAN_AND_MEAN
|
||||
)
|
||||
target_link_libraries(vst3_host_bridge PRIVATE audio_engine native_mixer)
|
||||
|
||||
# Native audio loop (T12): SPSC + WASAPI — dùng chung cho cả 2 bridge.
|
||||
add_library(audio_engine STATIC
|
||||
AudioEngine.cpp
|
||||
)
|
||||
target_include_directories(audio_engine PUBLIC native_host)
|
||||
target_compile_definitions(audio_engine PRIVATE
|
||||
_CRT_SECURE_NO_WARNINGS
|
||||
NOMINMAX
|
||||
WIN32_LEAN_AND_MEAN
|
||||
_WIN32_WINNT=0x0601
|
||||
)
|
||||
target_link_libraries(audio_engine PUBLIC ole32 avrt)
|
||||
|
||||
# NativeMixer (T13): track gain/pan + master brickwall limiter — mirror server.
|
||||
add_library(native_mixer STATIC
|
||||
NativeMixer.cpp
|
||||
)
|
||||
target_include_directories(native_mixer PUBLIC native_host)
|
||||
|
||||
# VST2 host bridge — chỉ cần vestige.h clean-room, không cần SDK.
|
||||
add_library(vst2_host_bridge SHARED
|
||||
VST2AudioEngine.cpp
|
||||
)
|
||||
target_include_directories(vst2_host_bridge PRIVATE native_host)
|
||||
target_compile_definitions(vst2_host_bridge PRIVATE
|
||||
_CRT_SECURE_NO_WARNINGS
|
||||
NOMINMAX
|
||||
WIN32_LEAN_AND_MEAN
|
||||
)
|
||||
target_link_libraries(vst2_host_bridge PRIVATE audio_engine native_mixer)
|
||||
|
||||
# SF host bridge (T14): FluidSynth native — load libfluidsynth runtime DLL
|
||||
# (fluidsynth_runtime/, gitignore) qua GetProcAddress; audio loop + mixer chung.
|
||||
add_library(sf_host_bridge SHARED
|
||||
SFHost.cpp
|
||||
)
|
||||
target_include_directories(sf_host_bridge PRIVATE native_host)
|
||||
target_compile_definitions(sf_host_bridge PRIVATE
|
||||
_CRT_SECURE_NO_WARNINGS
|
||||
NOMINMAX
|
||||
WIN32_LEAN_AND_MEAN
|
||||
)
|
||||
target_link_libraries(sf_host_bridge PRIVATE audio_engine native_mixer)
|
||||
@@ -0,0 +1,92 @@
|
||||
// NativeMixer.cpp — xem NativeMixer.h cho cong thuc mirror.
|
||||
#include "NativeMixer.h"
|
||||
|
||||
namespace sonicforge {
|
||||
|
||||
namespace {
|
||||
|
||||
// Mirror _clamp(v, lo, hi, default) cua mastering_engine.py: NaN -> default.
|
||||
float clampDb (float v, float lo, float hi, float def)
|
||||
{
|
||||
if (!(v == v)) // NaN
|
||||
return def;
|
||||
if (v < lo)
|
||||
return lo;
|
||||
if (v > hi)
|
||||
return hi;
|
||||
return v;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TrackGainPan TrackGainPan::fromDbPan (float gainDb, float pan)
|
||||
{
|
||||
TrackGainPan t;
|
||||
t.gainLin = std::pow (10.0f, gainDb / 20.0f);
|
||||
float p = pan;
|
||||
if (p < -1.0f)
|
||||
p = -1.0f;
|
||||
else if (p > 1.0f)
|
||||
p = 1.0f;
|
||||
if (p == 0.0f)
|
||||
{
|
||||
t.panL = 1.0f;
|
||||
t.panR = 1.0f;
|
||||
}
|
||||
else
|
||||
{
|
||||
const float theta = ((p + 1.0f) / 2.0f) * (3.14159265358979323846f / 2.0f);
|
||||
t.panL = std::cos (theta);
|
||||
t.panR = std::sin (theta);
|
||||
}
|
||||
return t;
|
||||
}
|
||||
|
||||
void NativeMixer::setLimiter (bool active, float thresholdDb)
|
||||
{
|
||||
m_limActive = active;
|
||||
if (!active)
|
||||
{
|
||||
m_limK = 1.0f;
|
||||
m_limTk = 1.0f;
|
||||
return;
|
||||
}
|
||||
const float t = clampDb (thresholdDb, -24.0f, 0.0f, -1.0f);
|
||||
const float tLin = std::pow (10.0f, t / 20.0f);
|
||||
m_limK = 1.0f / (tLin > 0.02f ? tLin : 0.02f);
|
||||
m_limTk = std::tanh (m_limK);
|
||||
}
|
||||
|
||||
void NativeMixer::processInPlace (float* outL, float* outR, int32 frames) const
|
||||
{
|
||||
if (!outL || !outR || frames <= 0)
|
||||
return;
|
||||
const float gl = m_track.gainLin * m_track.panL;
|
||||
const float gr = m_track.gainLin * m_track.panR;
|
||||
for (int32 i = 0; i < frames; ++i)
|
||||
{
|
||||
float l = outL[i] * gl;
|
||||
float r = outR[i] * gr;
|
||||
if (m_limActive)
|
||||
{
|
||||
l = std::tanh (l * m_limK) / m_limTk;
|
||||
r = std::tanh (r * m_limK) / m_limTk;
|
||||
// Hard clip [-1,1] — mirror render_project sau apply_mastering
|
||||
// (chi khi mastering on; limiter off => khong clip).
|
||||
if (l > 1.0f)
|
||||
l = 1.0f;
|
||||
else if (l < -1.0f)
|
||||
l = -1.0f;
|
||||
if (r > 1.0f)
|
||||
r = 1.0f;
|
||||
else if (r < -1.0f)
|
||||
r = -1.0f;
|
||||
}
|
||||
// Master fader (T15): sau limiter+clip, linear, khong re-clip — mirror
|
||||
// WebAudio masterBus.output.gain (gain node cuoi cung sau mastering).
|
||||
outL[i] = l * m_masterGain;
|
||||
outR[i] = r * m_masterGain;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace sonicforge
|
||||
@@ -0,0 +1,59 @@
|
||||
// NativeMixer.h — track gain/pan + master brickwall limiter (T13).
|
||||
//
|
||||
// MIRROR server render pipeline (`app/core/render_engine.py` track volume/pan
|
||||
// + `app/core/mastering_engine.py` _apply_limiter):
|
||||
// - track gain: 10^(dB/20)
|
||||
// - pan constant-power: theta = ((pan+1)/2)*pi/2, L *= cos(theta), R *= sin(theta)
|
||||
// (pan == 0 -> unity, khong nhan — dung nhu render_engine.py)
|
||||
// - master: tong cac track (processInPlace ap dung gain/pan roi limiter)
|
||||
// - brickwall limiter: t_lin = 10^(thresholdDb/20) (clamp -24..0, default -1);
|
||||
// k = 1/max(0.02, t_lin); tk = tanh(k); out = tanh(x*k)/tk; roi hard clip [-1,1]
|
||||
// (clip giong render_project sau apply_mastering).
|
||||
//
|
||||
// Audio thread an toan: chi setTrack/setLimiter tu UI thread truoc khi
|
||||
// AudioStart; processInPlace doc state da set, khong lock, khong cap phat.
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
#include <cmath>
|
||||
|
||||
namespace sonicforge {
|
||||
|
||||
using int32 = int32_t;
|
||||
|
||||
struct TrackGainPan
|
||||
{
|
||||
float gainLin = 1.0f; // 10^(dB/20)
|
||||
float panL = 1.0f; // cos(theta)
|
||||
float panR = 1.0f; // sin(theta)
|
||||
|
||||
static TrackGainPan fromDbPan (float gainDb, float pan);
|
||||
};
|
||||
|
||||
class NativeMixer
|
||||
{
|
||||
public:
|
||||
// gainDb: -inf..+inf dB; pan: -1..+1 (clamp). Mirrors render_engine.py.
|
||||
void setTrack (const TrackGainPan& t) { m_track = t; }
|
||||
|
||||
// thresholdDb clamp -24..0, NaN -> -1 (mirror _clamp trong mastering_engine).
|
||||
void setLimiter (bool active, float thresholdDb);
|
||||
|
||||
// Master fader (T15) — mirror WebAudio masterBus.output.gain (sau mastering
|
||||
// chain + clip; linear, khong re-clip). Mac dinh 1.0.
|
||||
void setMasterGain (float gainLinear) { m_masterGain = gainLinear; }
|
||||
|
||||
// Ap track gain/pan vao buffer roi (neu active) limiter + hard clip [-1,1]
|
||||
// ngay tai buffer (in-place). frames > 0.
|
||||
void processInPlace (float* outL, float* outR, int32 frames) const;
|
||||
|
||||
bool limiterActive () const { return m_limActive; }
|
||||
|
||||
private:
|
||||
TrackGainPan m_track;
|
||||
bool m_limActive = false;
|
||||
float m_limK = 1.0f; // 1/max(0.02, t_lin)
|
||||
float m_limTk = 1.0f; // tanh(k)
|
||||
float m_masterGain = 1.0f; // T15: master fader (sau limiter+clip, khong re-clip)
|
||||
};
|
||||
|
||||
} // namespace sonicforge
|
||||
@@ -0,0 +1,446 @@
|
||||
// SFHost.cpp — FluidSynth native bridge (T14)
|
||||
//
|
||||
// DLL export C API để chạy libfluidsynth (native, không WASM) trong audio
|
||||
// loop chung (SPSC + WASAPI, giống VST2/VST3 bridge). Mục tiêu T14: track
|
||||
// SF chơi bằng FluidSynth native cùng master với track VSTi; gain/pan/master
|
||||
// limiter (NativeMixer) áp cho cả 2; preview/items/offline nhất quán.
|
||||
//
|
||||
// QUYẾT ĐỊNH (T14): KHÔNG link tĩnh libfluidsynth (runtime DLL 12MB + phụ
|
||||
// thuộc) — load động qua GetProcAddress từ native_host/fluidsynth_runtime/
|
||||
// (gitignore; tái tạo bằng scripts/dl_fluidsynth_runtime.py). Load bằng
|
||||
// LoadLibraryExW(LOAD_WITH_ALTERED_SEARCH_PATH) để các DLL phụ thuộc cùng
|
||||
// thư mục được tìm thấy.
|
||||
//
|
||||
// Thread-safety: fluid_synth_* KHÔNG thread-safe — mọi gọi synth (sfload,
|
||||
// noteon/off, write_float, set_gain) chỉ trên audio thread; sfload CHỈ trước
|
||||
// AudioStart. MIDI từ UI thread → SPSC (AudioEngine) → audio thread drain.
|
||||
#include <windows.h>
|
||||
|
||||
#include <atomic>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <map>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "AudioEngine.h"
|
||||
#include "NativeMixer.h"
|
||||
|
||||
using int32 = int32_t;
|
||||
|
||||
namespace {
|
||||
|
||||
// FluidSynth C API (runtime load — xem fluidsynth/synth.h, settings.h)
|
||||
typedef void* fluid_settings_t;
|
||||
typedef void* fluid_synth_t;
|
||||
|
||||
typedef fluid_settings_t (*FS_SettingsNew) (void);
|
||||
typedef int (*FS_SettingsSetnum) (fluid_settings_t, const char*, double);
|
||||
typedef int (*FS_SettingsSetstr) (fluid_settings_t, const char*, const char*);
|
||||
typedef void (*FS_SettingsDelete) (fluid_settings_t);
|
||||
typedef fluid_synth_t (*FS_SynthNew) (fluid_settings_t);
|
||||
typedef int (*FS_SynthDelete) (fluid_synth_t);
|
||||
typedef int (*FS_SynthSfload) (fluid_synth_t, const char*, int);
|
||||
typedef int (*FS_SynthNoteon) (fluid_synth_t, int, int, int);
|
||||
typedef int (*FS_SynthNoteoff) (fluid_synth_t, int, int);
|
||||
typedef int (*FS_SynthAllNotesOff) (fluid_synth_t, int);
|
||||
typedef void (*FS_SynthWriteFloat) (fluid_synth_t, int, float*, int, int,
|
||||
float*, int, int);
|
||||
typedef int (*FS_SynthSetGain) (fluid_synth_t, float);
|
||||
typedef int (*FS_SynthProgramSelect) (fluid_synth_t, int, int, int, int);
|
||||
|
||||
struct FS
|
||||
{
|
||||
FS_SettingsNew settings_new = nullptr;
|
||||
FS_SettingsSetnum settings_setnum = nullptr;
|
||||
FS_SettingsSetstr settings_setstr = nullptr;
|
||||
FS_SettingsDelete settings_delete = nullptr;
|
||||
FS_SynthNew synth_new = nullptr;
|
||||
FS_SynthDelete synth_delete = nullptr;
|
||||
FS_SynthSfload synth_sfload = nullptr;
|
||||
FS_SynthNoteon synth_noteon = nullptr;
|
||||
FS_SynthNoteoff synth_noteoff = nullptr;
|
||||
FS_SynthAllNotesOff synth_all_notes_off = nullptr;
|
||||
FS_SynthWriteFloat synth_write_float = nullptr;
|
||||
FS_SynthSetGain synth_set_gain = nullptr;
|
||||
FS_SynthProgramSelect synth_program_select = nullptr;
|
||||
};
|
||||
|
||||
bool load_fs (HMODULE m, FS& fs)
|
||||
{
|
||||
// FluidSynth 2.5.x exports: new_fluid_*/delete_fluid_* (prefix), còn lại
|
||||
// fluid_* — xác minh bằng dumpbin /exports trên libfluidsynth-3.dll.
|
||||
fs.settings_new = reinterpret_cast<FS_SettingsNew> (GetProcAddress (m, "new_fluid_settings"));
|
||||
fs.settings_setnum = reinterpret_cast<FS_SettingsSetnum> (GetProcAddress (m, "fluid_settings_setnum"));
|
||||
fs.settings_setstr = reinterpret_cast<FS_SettingsSetstr> (GetProcAddress (m, "fluid_settings_setstr"));
|
||||
fs.settings_delete = reinterpret_cast<FS_SettingsDelete> (GetProcAddress (m, "delete_fluid_settings"));
|
||||
fs.synth_new = reinterpret_cast<FS_SynthNew> (GetProcAddress (m, "new_fluid_synth"));
|
||||
fs.synth_delete = reinterpret_cast<FS_SynthDelete> (GetProcAddress (m, "delete_fluid_synth"));
|
||||
fs.synth_sfload = reinterpret_cast<FS_SynthSfload> (GetProcAddress (m, "fluid_synth_sfload"));
|
||||
fs.synth_noteon = reinterpret_cast<FS_SynthNoteon> (GetProcAddress (m, "fluid_synth_noteon"));
|
||||
fs.synth_noteoff = reinterpret_cast<FS_SynthNoteoff> (GetProcAddress (m, "fluid_synth_noteoff"));
|
||||
fs.synth_all_notes_off = reinterpret_cast<FS_SynthAllNotesOff> (GetProcAddress (m, "fluid_synth_all_notes_off"));
|
||||
fs.synth_write_float = reinterpret_cast<FS_SynthWriteFloat> (GetProcAddress (m, "fluid_synth_write_float"));
|
||||
fs.synth_set_gain = reinterpret_cast<FS_SynthSetGain> (GetProcAddress (m, "fluid_synth_set_gain"));
|
||||
fs.synth_program_select = reinterpret_cast<FS_SynthProgramSelect> (GetProcAddress (m, "fluid_synth_program_select"));
|
||||
return fs.settings_new && fs.settings_setnum && fs.synth_new && fs.synth_sfload &&
|
||||
fs.synth_noteon && fs.synth_noteoff && fs.synth_write_float && fs.synth_set_gain;
|
||||
}
|
||||
|
||||
struct Instance
|
||||
{
|
||||
HMODULE fsLib = nullptr;
|
||||
FS fs;
|
||||
fluid_settings_t settings = nullptr;
|
||||
fluid_synth_t synth = nullptr;
|
||||
int sfid = -1; // sfont id tra ve tu fluid_synth_sfload
|
||||
int32 sampleRate = 44100;
|
||||
int32 blockSize = 512;
|
||||
int32 handle = 0;
|
||||
sonicforge::AudioEngine audio;
|
||||
std::atomic<bool> audioRunning {false};
|
||||
sonicforge::NativeMixer mixer;
|
||||
};
|
||||
|
||||
std::mutex g_mutex;
|
||||
std::map<int32, std::unique_ptr<Instance>> g_instances;
|
||||
int32 g_next_handle = 1;
|
||||
|
||||
void set_err (char* err, int32 err_cap, const char* msg)
|
||||
{
|
||||
if (err && err_cap > 0)
|
||||
std::snprintf (err, static_cast<size_t> (err_cap), "%s", msg);
|
||||
}
|
||||
|
||||
// Audio thread callback (WASAPI render thread): drain MIDI SPSC → synth
|
||||
// noteon/noteoff → fluid_synth_write_float → mixer (gain/pan/limiter).
|
||||
bool fsAudioProcess (const sonicforge::MidiEvent* events, int32 eventCount,
|
||||
float* outL, float* outR, int32 frames, void* userdata)
|
||||
{
|
||||
Instance* inst = static_cast<Instance*> (userdata);
|
||||
if (!inst || !inst->synth)
|
||||
return false;
|
||||
|
||||
for (int32 i = 0; i < eventCount; ++i)
|
||||
{
|
||||
const sonicforge::MidiEvent& e = events[i];
|
||||
if (e.noteOn)
|
||||
inst->fs.synth_noteon (inst->synth, e.channel, e.pitch,
|
||||
static_cast<int> (e.velocity * 127.0f));
|
||||
else
|
||||
inst->fs.synth_noteoff (inst->synth, e.channel, e.pitch);
|
||||
}
|
||||
|
||||
inst->fs.synth_write_float (inst->synth, frames, outL, 0, 1, outR, 0, 1);
|
||||
inst->mixer.processInPlace (outL, outR, frames);
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
extern "C" {
|
||||
|
||||
// Tạo synth + load runtime DLL. sfload CHỈ sau Create (trước AudioStart).
|
||||
// Trả handle (>= 1) hoặc 0 + err.
|
||||
__declspec (dllexport) int32 SF_FS_Create (int32 sampleRate, int32 blockSize,
|
||||
char* err, int32 err_cap)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock (g_mutex);
|
||||
|
||||
auto inst = std::make_unique<Instance> ();
|
||||
inst->sampleRate = sampleRate > 0 ? sampleRate : 44100;
|
||||
inst->blockSize = blockSize > 0 ? blockSize : 512;
|
||||
|
||||
// Runtime DLL trong native_host/fluidsynth_runtime/ (gitignore) — tìm
|
||||
// tương đối với chính sf_host_bridge.dll (GetModuleHandleW) chứ không
|
||||
// phải exe host: bridge có thể được load từ python test / Tauri app.
|
||||
HMODULE self = GetModuleHandleW (L"sf_host_bridge");
|
||||
wchar_t selfPath[MAX_PATH] = {};
|
||||
DWORD selfLen = self ? GetModuleFileNameW (self, selfPath, MAX_PATH) : 0;
|
||||
std::wstring dir;
|
||||
if (selfLen > 0)
|
||||
{
|
||||
dir = selfPath;
|
||||
auto slash = dir.find_last_of (L'\\');
|
||||
if (slash != std::wstring::npos)
|
||||
dir.resize (slash + 1);
|
||||
}
|
||||
std::wstring fsDll = dir + L"fluidsynth_runtime\\libfluidsynth-3.dll";
|
||||
|
||||
inst->fsLib = LoadLibraryExW (fsDll.c_str (), nullptr, LOAD_WITH_ALTERED_SEARCH_PATH);
|
||||
if (!inst->fsLib)
|
||||
{
|
||||
set_err (err, err_cap, "LoadLibraryExW libfluidsynth failed");
|
||||
return 0;
|
||||
}
|
||||
if (!load_fs (inst->fsLib, inst->fs))
|
||||
{
|
||||
FreeLibrary (inst->fsLib);
|
||||
set_err (err, err_cap, "libfluidsynth exports missing");
|
||||
return 0;
|
||||
}
|
||||
|
||||
inst->settings = inst->fs.settings_new ();
|
||||
inst->fs.settings_setnum (inst->settings, "synth.sample-rate",
|
||||
static_cast<double> (inst->sampleRate));
|
||||
// T14: render thẳng ra float stereo, không qua audio driver của fluidsynth.
|
||||
inst->fs.settings_setstr (inst->settings, "audio.driver", "file");
|
||||
inst->synth = inst->fs.synth_new (inst->settings);
|
||||
if (!inst->synth)
|
||||
{
|
||||
inst->fs.settings_delete (inst->settings);
|
||||
FreeLibrary (inst->fsLib);
|
||||
set_err (err, err_cap, "fluid_synth_new failed");
|
||||
return 0;
|
||||
}
|
||||
// WASM client dùng gain 1.0 — mirror.
|
||||
inst->fs.synth_set_gain (inst->synth, 1.0f);
|
||||
|
||||
int32 handle = g_next_handle++;
|
||||
inst->handle = handle;
|
||||
g_instances[handle] = std::move (inst);
|
||||
return handle;
|
||||
}
|
||||
|
||||
// Load SF2/SF3 vào synth. reset=0: giữ preset đã chọn. CHỈ trước AudioStart.
|
||||
__declspec (dllexport) int32 SF_FS_LoadSF2 (int32 handle, const char* path_utf8,
|
||||
char* err, int32 err_cap)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock (g_mutex);
|
||||
auto it = g_instances.find (handle);
|
||||
if (it == g_instances.end ())
|
||||
{
|
||||
set_err (err, err_cap, "bad handle");
|
||||
return -1;
|
||||
}
|
||||
Instance* inst = it->second.get ();
|
||||
if (!inst->synth || !path_utf8)
|
||||
{
|
||||
set_err (err, err_cap, "not created or null path");
|
||||
return -2;
|
||||
}
|
||||
if (inst->audioRunning.load (std::memory_order_acquire))
|
||||
{
|
||||
set_err (err, err_cap, "sfload only before AudioStart");
|
||||
return -3;
|
||||
}
|
||||
int sfid = inst->fs.synth_sfload (inst->synth, path_utf8, 0);
|
||||
if (sfid < 0)
|
||||
{
|
||||
set_err (err, err_cap, "fluid_synth_sfload failed");
|
||||
return -4;
|
||||
}
|
||||
inst->sfid = sfid;
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Chọn instrument channel (bank/program) — mirror WASM selectInstrument.
|
||||
// sfont_id: id trả từ SF_FS_LoadSF2 (0..n-1); fluid_synth_program_select
|
||||
// signature: (synth, chan, sfont_id, bank_num, preset_num).
|
||||
__declspec (dllexport) int32 SF_FS_SelectInstrument (int32 handle, int32 channel,
|
||||
int32 sfontId, int32 bank,
|
||||
int32 program)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock (g_mutex);
|
||||
auto it = g_instances.find (handle);
|
||||
if (it == g_instances.end ())
|
||||
return -1;
|
||||
Instance* inst = it->second.get ();
|
||||
if (!inst->synth)
|
||||
return -2;
|
||||
int fid = (sfontId < 0) ? inst->sfid : sfontId;
|
||||
return inst->fs.synth_program_select (inst->synth, channel, fid, bank, program);
|
||||
}
|
||||
|
||||
__declspec (dllexport) int32 SF_FS_NoteOn (int32 handle, int32 channel, int32 pitch,
|
||||
int32 velocity)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock (g_mutex);
|
||||
auto it = g_instances.find (handle);
|
||||
if (it == g_instances.end ())
|
||||
return -1;
|
||||
Instance* inst = it->second.get ();
|
||||
if (!inst->synth)
|
||||
return -2;
|
||||
if (inst->audioRunning.load (std::memory_order_acquire))
|
||||
{
|
||||
sonicforge::MidiEvent ev = {};
|
||||
ev.channel = channel;
|
||||
ev.pitch = pitch;
|
||||
ev.velocity = static_cast<float> (velocity) / 127.0f;
|
||||
ev.noteOn = true;
|
||||
inst->audio.pushMidi (ev);
|
||||
return 0;
|
||||
}
|
||||
return inst->fs.synth_noteon (inst->synth, channel, pitch, velocity);
|
||||
}
|
||||
|
||||
__declspec (dllexport) int32 SF_FS_NoteOff (int32 handle, int32 channel, int32 pitch)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock (g_mutex);
|
||||
auto it = g_instances.find (handle);
|
||||
if (it == g_instances.end ())
|
||||
return -1;
|
||||
Instance* inst = it->second.get ();
|
||||
if (!inst->synth)
|
||||
return -2;
|
||||
if (inst->audioRunning.load (std::memory_order_acquire))
|
||||
{
|
||||
sonicforge::MidiEvent ev = {};
|
||||
ev.channel = channel;
|
||||
ev.pitch = pitch;
|
||||
ev.velocity = 0.0f;
|
||||
ev.noteOn = false;
|
||||
inst->audio.pushMidi (ev);
|
||||
return 0;
|
||||
}
|
||||
return inst->fs.synth_noteoff (inst->synth, channel, pitch);
|
||||
}
|
||||
|
||||
__declspec (dllexport) int32 SF_FS_AllNotesOff (int32 handle, int32 channel)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock (g_mutex);
|
||||
auto it = g_instances.find (handle);
|
||||
if (it == g_instances.end ())
|
||||
return -1;
|
||||
Instance* inst = it->second.get ();
|
||||
if (!inst->synth)
|
||||
return -2;
|
||||
return inst->fs.synth_all_notes_off (inst->synth, channel);
|
||||
}
|
||||
|
||||
// Render offline 1 block thẳng ra buffer (không qua WASAPI): fluid_synth_write_float
|
||||
// + mixer. Dùng cho golden test T14 (so với server reference) và offline path.
|
||||
// outL/outR: frames mẫu mỗi kênh. Trả 0 nếu OK.
|
||||
__declspec (dllexport) int32 SF_FS_RenderBlock (int32 handle, int32 frames,
|
||||
float* outL, float* outR)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock (g_mutex);
|
||||
auto it = g_instances.find (handle);
|
||||
if (it == g_instances.end ())
|
||||
return -1;
|
||||
Instance* inst = it->second.get ();
|
||||
if (!inst->synth || !outL || !outR || frames <= 0)
|
||||
return -2;
|
||||
inst->fs.synth_write_float (inst->synth, frames, outL, 0, 1, outR, 0, 1);
|
||||
inst->mixer.processInPlace (outL, outR, frames);
|
||||
return 0;
|
||||
}
|
||||
|
||||
__declspec (dllexport) int32 SF_FS_AudioStart (int32 handle, int32 sampleRate,
|
||||
int32 blockSize, char* err, int32 err_cap)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock (g_mutex);
|
||||
auto it = g_instances.find (handle);
|
||||
if (it == g_instances.end ())
|
||||
return -1;
|
||||
Instance* inst = it->second.get ();
|
||||
if (!inst->synth)
|
||||
return -2;
|
||||
inst->audioRunning.store (true, std::memory_order_release);
|
||||
if (!inst->audio.start (sampleRate > 0 ? sampleRate : inst->sampleRate,
|
||||
blockSize > 0 ? blockSize : inst->blockSize,
|
||||
&fsAudioProcess, inst, err, err_cap))
|
||||
{
|
||||
inst->audioRunning.store (false, std::memory_order_release);
|
||||
return -3;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
__declspec (dllexport) int32 SF_FS_AudioStop (int32 handle)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock (g_mutex);
|
||||
auto it = g_instances.find (handle);
|
||||
if (it == g_instances.end ())
|
||||
return -1;
|
||||
it->second->audioRunning.store (false, std::memory_order_release);
|
||||
it->second->audio.stop ();
|
||||
return 0;
|
||||
}
|
||||
|
||||
__declspec (dllexport) int32 SF_FS_AudioUnderruns (int32 handle)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock (g_mutex);
|
||||
auto it = g_instances.find (handle);
|
||||
if (it == g_instances.end ())
|
||||
return -1;
|
||||
return it->second->audio.underruns ();
|
||||
}
|
||||
|
||||
__declspec (dllexport) int32 SF_FS_AudioLatency (int32 handle)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock (g_mutex);
|
||||
auto it = g_instances.find (handle);
|
||||
if (it == g_instances.end ())
|
||||
return -1;
|
||||
return it->second->audio.latencySamples ();
|
||||
}
|
||||
|
||||
__declspec (dllexport) int32 SF_FS_AudioBlocks (int32 handle)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock (g_mutex);
|
||||
auto it = g_instances.find (handle);
|
||||
if (it == g_instances.end ())
|
||||
return -1;
|
||||
return it->second->audio.blocksRendered ();
|
||||
}
|
||||
|
||||
// T13 chung: track gain/pan + master limiter qua NativeMixer.
|
||||
__declspec (dllexport) int32 SF_FS_SetTrackGainPan (int32 handle, float gainDb, float pan)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock (g_mutex);
|
||||
auto it = g_instances.find (handle);
|
||||
if (it == g_instances.end ())
|
||||
return -1;
|
||||
it->second->mixer.setTrack (sonicforge::TrackGainPan::fromDbPan (gainDb, pan));
|
||||
return 0;
|
||||
}
|
||||
|
||||
__declspec (dllexport) int32 SF_FS_SetMasterLimiter (int32 handle, int32 active, float thresholdDb)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock (g_mutex);
|
||||
auto it = g_instances.find (handle);
|
||||
if (it == g_instances.end ())
|
||||
return -1;
|
||||
it->second->mixer.setLimiter (active != 0, thresholdDb);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// T15: master fader — linear, ap sau limiter+clip trong mixer (mirror
|
||||
// masterBus.output.gain WebAudio).
|
||||
__declspec (dllexport) int32 SF_FS_SetMasterGain (int32 handle, float gainLinear)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock (g_mutex);
|
||||
auto it = g_instances.find (handle);
|
||||
if (it == g_instances.end ())
|
||||
return -1;
|
||||
it->second->mixer.setMasterGain (gainLinear);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Đóng: stop audio loop, xóa synth, giải phóng DLL.
|
||||
__declspec (dllexport) int32 SF_FS_Close (int32 handle, char* err, int32 err_cap)
|
||||
{
|
||||
(void) err;
|
||||
(void) err_cap;
|
||||
std::lock_guard<std::mutex> lock (g_mutex);
|
||||
auto it = g_instances.find (handle);
|
||||
if (it == g_instances.end ())
|
||||
return -1;
|
||||
Instance* inst = it->second.get ();
|
||||
inst->audioRunning.store (false, std::memory_order_release);
|
||||
inst->audio.stop ();
|
||||
if (inst->synth)
|
||||
inst->fs.synth_delete (inst->synth);
|
||||
if (inst->settings)
|
||||
inst->fs.settings_delete (inst->settings);
|
||||
if (inst->fsLib)
|
||||
FreeLibrary (inst->fsLib);
|
||||
g_instances.erase (it);
|
||||
return 0;
|
||||
}
|
||||
|
||||
} // extern "C"
|
||||
@@ -0,0 +1,641 @@
|
||||
// VST2AudioEngine.cpp — VST2 host bridge (T10, spec 5.7 / spec III)
|
||||
//
|
||||
// DLL export C API để load .dll VST2, dispatch MIDI, attach GUI vào HWND cha,
|
||||
// và process float32 qua processReplacing. Host audio master trả lời tối
|
||||
// thiểu: version, sampleRate, blockSize (opcode bắt buộc theo spec III).
|
||||
//
|
||||
// QUYẾT ĐỊNH (T10): KHÔNG JUCE, KHÔNG aeffect.h chính thức — dùng vestige.h
|
||||
// clean-room (xem header). T12 native audio loop sẽ gọi SF_VST2_Process từ
|
||||
// audio thread; effProcessEvents chỉ được gọi từ UI/main thread (spec 5.7.3).
|
||||
//
|
||||
// Giới hạn hiện tại: chưa xử lý plugin chunk state (effGetChunk/SetChunk) —
|
||||
// thêm khi T11 cần save/restore preset.
|
||||
#define NOMINMAX
|
||||
#define WIN32_LEAN_AND_MEAN
|
||||
#include <windows.h>
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <map>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "vestige.h"
|
||||
#include "AudioEngine.h"
|
||||
#include "NativeMixer.h"
|
||||
|
||||
#include <atomic>
|
||||
|
||||
namespace {
|
||||
|
||||
// Callback param (T11): plugin đổi param (audioMasterAutomate) → host.
|
||||
typedef void (VSTCALLBACK* SF_ParamChangedCallback)(int32 handle, int32 paramIndex,
|
||||
double valueNormalized, void* userdata);
|
||||
|
||||
struct Instance
|
||||
{
|
||||
HMODULE module = nullptr;
|
||||
AEffect* effect = nullptr;
|
||||
int32 sampleRate = 44100;
|
||||
int32 blockSize = 512;
|
||||
int32 numInputs = 0;
|
||||
int32 numOutputs = 2;
|
||||
HWND parentHwnd = nullptr;
|
||||
ERect* editorRect = nullptr;
|
||||
bool editorOpen = false;
|
||||
bool active = false;
|
||||
int32 handle = 0;
|
||||
SF_ParamChangedCallback paramCb = nullptr;
|
||||
void* paramCbUserdata = nullptr;
|
||||
sonicforge::AudioEngine audio; // T12: native audio loop (SPSC + WASAPI)
|
||||
std::atomic<bool> audioRunning {false};
|
||||
sonicforge::NativeMixer mixer; // T13: track gain/pan + master limiter
|
||||
};
|
||||
|
||||
std::mutex g_mutex;
|
||||
std::mutex g_effect_map_mutex; // riêng: audioMasterAutomate gọi từ audio thread
|
||||
std::map<AEffect*, Instance*> g_effect_map;
|
||||
std::map<int32, std::unique_ptr<Instance>> g_instances;
|
||||
int32 g_next_handle = 1;
|
||||
|
||||
void set_err (char* err, int32 err_cap, const char* msg)
|
||||
{
|
||||
if (err && err_cap > 0)
|
||||
{
|
||||
std::snprintf (err, static_cast<size_t> (err_cap), "%s", msg);
|
||||
}
|
||||
}
|
||||
|
||||
// Host audio master — spec III: phải trả lời version/sampleRate/blockSize.
|
||||
// thread_local tls_loading cho plugin tra cứu instance đang khởi tạo.
|
||||
thread_local Instance* tls_loading = nullptr;
|
||||
|
||||
VstIntPtr VSTCALLBACK hostAudioMasterImpl (AEffect* effect, int32 opcode, int32 index,
|
||||
VstIntPtr value, void* ptr, float opt)
|
||||
{
|
||||
(void) index;
|
||||
(void) value;
|
||||
(void) ptr;
|
||||
Instance* inst = tls_loading;
|
||||
switch (opcode)
|
||||
{
|
||||
case audioMasterVersion:
|
||||
return 2400;
|
||||
case audioMasterGetSampleRate:
|
||||
return inst ? inst->sampleRate : 44100;
|
||||
case audioMasterGetBlockSize:
|
||||
return inst ? inst->blockSize : 512;
|
||||
case audioMasterAutomate:
|
||||
{
|
||||
// Plugin đổi param (user kéo knob trong editor) → callback lên host.
|
||||
// Effect → instance qua map riêng (audio thread, không giữ g_mutex).
|
||||
Instance* owner = nullptr;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock (g_effect_map_mutex);
|
||||
auto it = g_effect_map.find (effect);
|
||||
if (it != g_effect_map.end ())
|
||||
owner = it->second;
|
||||
}
|
||||
if (owner && owner->paramCb)
|
||||
owner->paramCb (owner->handle, index, static_cast<double> (opt),
|
||||
owner->paramCbUserdata);
|
||||
return 1;
|
||||
}
|
||||
case audioMasterCurrentId:
|
||||
case audioMasterGetLanguage:
|
||||
return 0;
|
||||
case audioMasterGetVendorString:
|
||||
if (ptr)
|
||||
std::strncpy (static_cast<char*> (ptr), "SonicForgeStudio", 64);
|
||||
return 1;
|
||||
case audioMasterGetProductString:
|
||||
if (ptr)
|
||||
std::strncpy (static_cast<char*> (ptr), "SonicForgeStudio", 64);
|
||||
return 1;
|
||||
case audioMasterGetVendorVersion:
|
||||
return 1;
|
||||
case audioMasterCanDo:
|
||||
return 0;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// T12: audio thread callback — effProcessEvents (MIDI drain tu SPSC) +
|
||||
// processReplacing. Chay tren WASAPI render thread: KHONG lock, KHONG cap phat.
|
||||
bool vst2AudioProcess (const sonicforge::MidiEvent* events, int32 eventCount,
|
||||
float* outL, float* outR, int32 frames, void* userdata)
|
||||
{
|
||||
Instance* inst = static_cast<Instance*> (userdata);
|
||||
if (!inst || !inst->effect)
|
||||
return false;
|
||||
|
||||
if (eventCount > 0)
|
||||
{
|
||||
int32 n = eventCount < 256 ? eventCount : 256;
|
||||
VstMidiEvent midi[256];
|
||||
VstEvent* ptrs[256];
|
||||
struct VstEventsBuf { int32 numEvents; intptr_t reserved; VstEvent* events[256]; };
|
||||
VstEventsBuf buf = {};
|
||||
buf.numEvents = n;
|
||||
for (int32 i = 0; i < n; ++i)
|
||||
{
|
||||
VstMidiEvent& m = midi[i];
|
||||
std::memset (&m, 0, sizeof (m));
|
||||
m.type = kVstMidiType;
|
||||
m.byteSize = static_cast<int32> (sizeof (VstMidiEvent));
|
||||
m.deltaFrames = 0;
|
||||
m.flags = kVstMidiEventIsRealtime;
|
||||
m.midiData[0] = static_cast<char> ((events[i].noteOn ? 0x90 : 0x80) |
|
||||
(events[i].channel & 0x0F));
|
||||
m.midiData[1] = static_cast<char> (events[i].pitch & 0x7F);
|
||||
m.midiData[2] = events[i].noteOn
|
||||
? static_cast<char> (static_cast<int> (events[i].velocity * 127.0f) & 0x7F)
|
||||
: 0;
|
||||
ptrs[i] = reinterpret_cast<VstEvent*> (&m);
|
||||
buf.events[i] = ptrs[i];
|
||||
}
|
||||
inst->effect->dispatcher (inst->effect, effProcessEvents, 0, 0,
|
||||
reinterpret_cast<VstEvents*> (&buf), 0.0f);
|
||||
}
|
||||
|
||||
if (inst->effect->processReplacing)
|
||||
{
|
||||
float* outs[2] = { outL, outR };
|
||||
inst->effect->processReplacing (inst->effect, nullptr, outs, frames);
|
||||
inst->mixer.processInPlace (outL, outR, frames); // T13: gain/pan + limiter
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
extern "C" {
|
||||
|
||||
// Load .dll VST2, khởi tạo plugin, mở editor (nếu có) vào parent_hwnd.
|
||||
// module_path_utf8: đường dẫn .dll (UTF-8). Trả handle (>= 1) hoặc 0 + err.
|
||||
__declspec (dllexport) int32 SF_VST2_Load (const char* module_path_utf8,
|
||||
HWND parent_hwnd,
|
||||
int32* out_w,
|
||||
int32* out_h,
|
||||
char* err,
|
||||
int32 err_cap)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock (g_mutex);
|
||||
if (!module_path_utf8)
|
||||
{
|
||||
set_err (err, err_cap, "null arg");
|
||||
return 0;
|
||||
}
|
||||
|
||||
auto inst = std::make_unique<Instance> ();
|
||||
inst->parentHwnd = parent_hwnd;
|
||||
|
||||
// UTF-8 -> wide cho LoadLibraryW
|
||||
int wlen = MultiByteToWideChar (CP_UTF8, 0, module_path_utf8, -1, nullptr, 0);
|
||||
if (wlen <= 0)
|
||||
{
|
||||
set_err (err, err_cap, "bad utf8 path");
|
||||
return 0;
|
||||
}
|
||||
std::vector<wchar_t> wpath (static_cast<size_t> (wlen));
|
||||
MultiByteToWideChar (CP_UTF8, 0, module_path_utf8, -1, wpath.data (), wlen);
|
||||
|
||||
inst->module = LoadLibraryW (wpath.data ());
|
||||
if (!inst->module)
|
||||
{
|
||||
set_err (err, err_cap, "LoadLibrary failed");
|
||||
return 0;
|
||||
}
|
||||
|
||||
auto mainFn = reinterpret_cast<VSTPluginMainFn> (GetProcAddress (inst->module, "VSTPluginMain"));
|
||||
if (!mainFn)
|
||||
{
|
||||
auto oldFn = reinterpret_cast<VSTPluginMainOldFn> (GetProcAddress (inst->module, "main"));
|
||||
if (oldFn)
|
||||
{
|
||||
tls_loading = inst.get ();
|
||||
inst->effect = oldFn (reinterpret_cast<void*> (&hostAudioMasterImpl), nullptr);
|
||||
tls_loading = nullptr;
|
||||
}
|
||||
else
|
||||
{
|
||||
FreeLibrary (inst->module);
|
||||
set_err (err, err_cap, "no VSTPluginMain/main export");
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
tls_loading = inst.get ();
|
||||
inst->effect = mainFn (reinterpret_cast<void*> (&hostAudioMasterImpl));
|
||||
tls_loading = nullptr;
|
||||
}
|
||||
|
||||
if (!inst->effect || inst->effect->magic != CCONST ('V', 's', 't', 'P'))
|
||||
{
|
||||
FreeLibrary (inst->module);
|
||||
set_err (err, err_cap, "not a valid VST2 effect");
|
||||
return 0;
|
||||
}
|
||||
|
||||
inst->numInputs = inst->effect->numInputs ? inst->effect->numInputs (inst->effect) : 0;
|
||||
inst->numOutputs = inst->effect->numOutputs ? inst->effect->numOutputs (inst->effect) : 2;
|
||||
|
||||
// Khởi tạo dispatcher theo thứ tự chuẩn
|
||||
inst->effect->dispatcher (inst->effect, effOpen, 0, 0, nullptr, 0.0f);
|
||||
inst->effect->dispatcher (inst->effect, effSetSampleRate, 0, 0, nullptr,
|
||||
static_cast<float> (inst->sampleRate));
|
||||
inst->effect->dispatcher (inst->effect, effSetBlockSize, 0, inst->blockSize, nullptr, 0.0f);
|
||||
inst->effect->dispatcher (inst->effect, effMainsChanged, 0, 1, nullptr, 0.0f);
|
||||
inst->effect->dispatcher (inst->effect, effStartProcess, 0, 0, nullptr, 0.0f);
|
||||
inst->active = true;
|
||||
|
||||
// Editor GUI
|
||||
if (parent_hwnd)
|
||||
{
|
||||
VstIntPtr hasEditor = inst->effect->dispatcher (inst->effect, effEditGetRect, 0, 0,
|
||||
&inst->editorRect, 0.0f);
|
||||
if (hasEditor == 1 && inst->editorRect)
|
||||
{
|
||||
inst->effect->dispatcher (inst->effect, effEditOpen, 0, 0,
|
||||
reinterpret_cast<void*> (parent_hwnd), 0.0f);
|
||||
inst->editorOpen = true;
|
||||
if (out_w)
|
||||
*out_w = inst->editorRect->right - inst->editorRect->left;
|
||||
if (out_h)
|
||||
*out_h = inst->editorRect->bottom - inst->editorRect->top;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (out_w)
|
||||
*out_w = 0;
|
||||
if (out_h)
|
||||
*out_h = 0;
|
||||
}
|
||||
}
|
||||
|
||||
int32 handle = g_next_handle++;
|
||||
inst->handle = handle;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock (g_effect_map_mutex);
|
||||
g_effect_map[inst->effect] = inst.get ();
|
||||
}
|
||||
g_instances[handle] = std::move (inst);
|
||||
return handle;
|
||||
}
|
||||
|
||||
// Đóng: effStopProcess + effMainsChanged(0) + effEditClose + effClose + FreeLibrary.
|
||||
__declspec (dllexport) int32 SF_VST2_Close (int32 handle, char* err, int32 err_cap)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock (g_mutex);
|
||||
auto it = g_instances.find (handle);
|
||||
if (it == g_instances.end ())
|
||||
{
|
||||
set_err (err, err_cap, "bad handle");
|
||||
return -1;
|
||||
}
|
||||
Instance* inst = it->second.get ();
|
||||
// T12: dung audio loop (join render thread) truoc khi pha huy plugin.
|
||||
inst->audioRunning.store (false, std::memory_order_release);
|
||||
inst->audio.stop ();
|
||||
if (inst->effect)
|
||||
{
|
||||
{
|
||||
std::lock_guard<std::mutex> lock (g_effect_map_mutex);
|
||||
g_effect_map.erase (inst->effect);
|
||||
}
|
||||
if (inst->active)
|
||||
{
|
||||
inst->effect->dispatcher (inst->effect, effStopProcess, 0, 0, nullptr, 0.0f);
|
||||
inst->effect->dispatcher (inst->effect, effMainsChanged, 0, 0, nullptr, 0.0f);
|
||||
inst->active = false;
|
||||
}
|
||||
if (inst->editorOpen)
|
||||
{
|
||||
inst->effect->dispatcher (inst->effect, effEditClose, 0, 0, nullptr, 0.0f);
|
||||
inst->editorOpen = false;
|
||||
}
|
||||
inst->effect->dispatcher (inst->effect, effClose, 0, 0, nullptr, 0.0f);
|
||||
inst->effect = nullptr;
|
||||
}
|
||||
if (inst->module)
|
||||
{
|
||||
FreeLibrary (inst->module);
|
||||
inst->module = nullptr;
|
||||
}
|
||||
g_instances.erase (it);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Kích thước editor hiện tại.
|
||||
__declspec (dllexport) int32 SF_VST2_GetSize (int32 handle, int32* out_w, int32* out_h)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock (g_mutex);
|
||||
auto it = g_instances.find (handle);
|
||||
if (it == g_instances.end ())
|
||||
return -1;
|
||||
Instance* inst = it->second.get ();
|
||||
if (inst->editorRect)
|
||||
{
|
||||
if (out_w)
|
||||
*out_w = inst->editorRect->right - inst->editorRect->left;
|
||||
if (out_h)
|
||||
*out_h = inst->editorRect->bottom - inst->editorRect->top;
|
||||
return 0;
|
||||
}
|
||||
return -2;
|
||||
}
|
||||
|
||||
// Báo editor: window cha đã resize — plugin cập nhật vị trí nội bộ.
|
||||
__declspec (dllexport) int32 SF_VST2_Resize (int32 handle, int32 w, int32 h)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock (g_mutex);
|
||||
auto it = g_instances.find (handle);
|
||||
if (it == g_instances.end ())
|
||||
return -1;
|
||||
Instance* inst = it->second.get ();
|
||||
if (inst->effect && inst->editorOpen)
|
||||
{
|
||||
inst->effect->dispatcher (inst->effect, effSetViewPosition, 0, 0, nullptr, 0.0f);
|
||||
return 0;
|
||||
}
|
||||
return -2;
|
||||
}
|
||||
|
||||
// MIDI note-on qua effProcessEvents (chỉ gọi từ UI/main thread — spec 5.7.3).
|
||||
__declspec (dllexport) int32 SF_VST2_SendNoteOn (int32 handle, int32 channel, int32 pitch,
|
||||
int32 velocity)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock (g_mutex);
|
||||
auto it = g_instances.find (handle);
|
||||
if (it == g_instances.end ())
|
||||
return -1;
|
||||
Instance* inst = it->second.get ();
|
||||
if (!inst->effect)
|
||||
return -2;
|
||||
|
||||
// T12: audio loop dang chay -> day vao SPSC (audio thread xu ly).
|
||||
if (inst->audioRunning.load (std::memory_order_acquire))
|
||||
{
|
||||
sonicforge::MidiEvent ev = {};
|
||||
ev.channel = channel;
|
||||
ev.pitch = pitch;
|
||||
ev.velocity = static_cast<float> (velocity) / 127.0f;
|
||||
ev.noteOn = true;
|
||||
inst->audio.pushMidi (ev);
|
||||
return 0;
|
||||
}
|
||||
|
||||
char midiData[4] = {0};
|
||||
midiData[0] = static_cast<char> (0x90 | (channel & 0x0F));
|
||||
midiData[1] = static_cast<char> (pitch & 0x7F);
|
||||
midiData[2] = static_cast<char> (velocity & 0x7F);
|
||||
|
||||
VstMidiEvent midiEvent = {};
|
||||
midiEvent.type = kVstMidiType;
|
||||
midiEvent.byteSize = static_cast<int32> (sizeof (VstMidiEvent));
|
||||
midiEvent.deltaFrames = 0;
|
||||
midiEvent.flags = kVstMidiEventIsRealtime;
|
||||
std::memcpy (midiEvent.midiData, midiData, 4);
|
||||
|
||||
VstEvent* eventPtr = reinterpret_cast<VstEvent*> (&midiEvent);
|
||||
VstEvents events = {};
|
||||
events.numEvents = 1;
|
||||
events.events[0] = eventPtr;
|
||||
|
||||
inst->effect->dispatcher (inst->effect, effProcessEvents, 0, 0, &events, 0.0f);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// MIDI note-off qua effProcessEvents.
|
||||
__declspec (dllexport) int32 SF_VST2_SendNoteOff (int32 handle, int32 channel, int32 pitch)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock (g_mutex);
|
||||
auto it = g_instances.find (handle);
|
||||
if (it == g_instances.end ())
|
||||
return -1;
|
||||
Instance* inst = it->second.get ();
|
||||
if (!inst->effect)
|
||||
return -2;
|
||||
|
||||
// T12: audio loop dang chay -> day vao SPSC.
|
||||
if (inst->audioRunning.load (std::memory_order_acquire))
|
||||
{
|
||||
sonicforge::MidiEvent ev = {};
|
||||
ev.channel = channel;
|
||||
ev.pitch = pitch;
|
||||
ev.velocity = 0.0f;
|
||||
ev.noteOn = false;
|
||||
inst->audio.pushMidi (ev);
|
||||
return 0;
|
||||
}
|
||||
|
||||
char midiData[4] = {0};
|
||||
midiData[0] = static_cast<char> (0x80 | (channel & 0x0F));
|
||||
midiData[1] = static_cast<char> (pitch & 0x7F);
|
||||
midiData[2] = 0;
|
||||
|
||||
VstMidiEvent midiEvent = {};
|
||||
midiEvent.type = kVstMidiType;
|
||||
midiEvent.byteSize = static_cast<int32> (sizeof (VstMidiEvent));
|
||||
midiEvent.deltaFrames = 0;
|
||||
midiEvent.flags = kVstMidiEventIsRealtime;
|
||||
std::memcpy (midiEvent.midiData, midiData, 4);
|
||||
|
||||
VstEvent* eventPtr = reinterpret_cast<VstEvent*> (&midiEvent);
|
||||
VstEvents events = {};
|
||||
events.numEvents = 1;
|
||||
events.events[0] = eventPtr;
|
||||
|
||||
inst->effect->dispatcher (inst->effect, effProcessEvents, 0, 0, &events, 0.0f);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Process float32 qua processReplacing. buffers: input[m][n] + output[m][n]
|
||||
// liền nhau, channels = max(numInputs, numOutputs). Trả 0 nếu OK.
|
||||
__declspec (dllexport) int32 SF_VST2_Process (int32 handle, float* buffers, int32 channels,
|
||||
int32 sampleFrames)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock (g_mutex);
|
||||
auto it = g_instances.find (handle);
|
||||
if (it == g_instances.end ())
|
||||
return -1;
|
||||
Instance* inst = it->second.get ();
|
||||
if (!inst->effect || !buffers || sampleFrames <= 0)
|
||||
return -2;
|
||||
|
||||
int32 total = inst->numInputs + inst->numOutputs;
|
||||
if (channels < total)
|
||||
return -3;
|
||||
|
||||
std::vector<float*> ptrs (static_cast<size_t> (total));
|
||||
for (int32 i = 0; i < total; ++i)
|
||||
ptrs[static_cast<size_t> (i)] = buffers + static_cast<size_t> (i) * sampleFrames;
|
||||
|
||||
if (inst->effect->processReplacing)
|
||||
{
|
||||
inst->effect->processReplacing (inst->effect,
|
||||
inst->numInputs > 0 ? ptrs.data () : nullptr,
|
||||
ptrs.data () + inst->numInputs,
|
||||
sampleFrames);
|
||||
return 0;
|
||||
}
|
||||
return -4;
|
||||
}
|
||||
|
||||
// Đăng ký callback param (T11): plugin đổi param (audioMasterAutomate) →
|
||||
// cb(handle, paramIndex, valueNormalized, userdata).
|
||||
__declspec (dllexport) int32 SF_VST2_SetParamCallback (int32 handle,
|
||||
SF_ParamChangedCallback cb,
|
||||
void* userdata)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock (g_mutex);
|
||||
auto it = g_instances.find (handle);
|
||||
if (it == g_instances.end ())
|
||||
return -1;
|
||||
it->second->paramCb = cb;
|
||||
it->second->paramCbUserdata = userdata;
|
||||
return 0;
|
||||
}
|
||||
|
||||
// JS automation → setParameter (T11).
|
||||
__declspec (dllexport) int32 SF_VST2_SetParam (int32 handle, int32 paramIndex,
|
||||
double valueNormalized)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock (g_mutex);
|
||||
auto it = g_instances.find (handle);
|
||||
if (it == g_instances.end ())
|
||||
return -1;
|
||||
Instance* inst = it->second.get ();
|
||||
if (!inst->effect || !inst->effect->setParameter)
|
||||
return -2;
|
||||
inst->effect->setParameter (inst->effect, paramIndex,
|
||||
static_cast<float> (valueNormalized));
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Số tham số plugin.
|
||||
__declspec (dllexport) int32 SF_VST2_GetParamCount (int32 handle)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock (g_mutex);
|
||||
auto it = g_instances.find (handle);
|
||||
if (it == g_instances.end ())
|
||||
return -1;
|
||||
Instance* inst = it->second.get ();
|
||||
if (!inst->effect || !inst->effect->numParams)
|
||||
return -2;
|
||||
return inst->effect->numParams (inst->effect);
|
||||
}
|
||||
|
||||
// Giá trị param (0..1) — JS đọc để dựng UI.
|
||||
__declspec (dllexport) int32 SF_VST2_GetParam (int32 handle, int32 paramIndex, double* out_value)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock (g_mutex);
|
||||
auto it = g_instances.find (handle);
|
||||
if (it == g_instances.end ())
|
||||
return -1;
|
||||
Instance* inst = it->second.get ();
|
||||
if (!inst->effect || !inst->effect->getParameter)
|
||||
return -2;
|
||||
if (out_value)
|
||||
*out_value = static_cast<double> (inst->effect->getParameter (inst->effect, paramIndex));
|
||||
return 0;
|
||||
}
|
||||
|
||||
// T12: bat native audio loop (SPSC + WASAPI) cho instance nay. Audio thread
|
||||
// goi vst2AudioProcess (effProcessEvents + processReplacing) moi block.
|
||||
__declspec (dllexport) int32 SF_VST2_AudioStart (int32 handle, int32 sampleRate,
|
||||
int32 blockSize, char* err, int32 err_cap)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock (g_mutex);
|
||||
auto it = g_instances.find (handle);
|
||||
if (it == g_instances.end ())
|
||||
return -1;
|
||||
Instance* inst = it->second.get ();
|
||||
if (!inst->effect)
|
||||
return -2;
|
||||
inst->audioRunning.store (true, std::memory_order_release); // truoc khi thread chay
|
||||
if (!inst->audio.start (sampleRate, blockSize, &vst2AudioProcess, inst, err, err_cap))
|
||||
{
|
||||
inst->audioRunning.store (false, std::memory_order_release);
|
||||
return -3;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
__declspec (dllexport) int32 SF_VST2_AudioStop (int32 handle)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock (g_mutex);
|
||||
auto it = g_instances.find (handle);
|
||||
if (it == g_instances.end ())
|
||||
return -1;
|
||||
it->second->audioRunning.store (false, std::memory_order_release);
|
||||
it->second->audio.stop ();
|
||||
return 0;
|
||||
}
|
||||
|
||||
__declspec (dllexport) int32 SF_VST2_AudioUnderruns (int32 handle)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock (g_mutex);
|
||||
auto it = g_instances.find (handle);
|
||||
if (it == g_instances.end ())
|
||||
return -1;
|
||||
return it->second->audio.underruns ();
|
||||
}
|
||||
|
||||
__declspec (dllexport) int32 SF_VST2_AudioLatency (int32 handle)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock (g_mutex);
|
||||
auto it = g_instances.find (handle);
|
||||
if (it == g_instances.end ())
|
||||
return -1;
|
||||
return it->second->audio.latencySamples ();
|
||||
}
|
||||
|
||||
__declspec (dllexport) int32 SF_VST2_AudioBlocks (int32 handle)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock (g_mutex);
|
||||
auto it = g_instances.find (handle);
|
||||
if (it == g_instances.end ())
|
||||
return -1;
|
||||
return it->second->audio.blocksRendered ();
|
||||
}
|
||||
|
||||
// T13: track gain/pan + master limiter (NativeMixer). Set truoc AudioStart;
|
||||
// audio thread doc state nay (khong lock).
|
||||
__declspec (dllexport) int32 SF_VST2_SetTrackGainPan (int32 handle, float gainDb, float pan)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock (g_mutex);
|
||||
auto it = g_instances.find (handle);
|
||||
if (it == g_instances.end ())
|
||||
return -1;
|
||||
it->second->mixer.setTrack (sonicforge::TrackGainPan::fromDbPan (gainDb, pan));
|
||||
return 0;
|
||||
}
|
||||
|
||||
__declspec (dllexport) int32 SF_VST2_SetMasterLimiter (int32 handle, int32 active, float thresholdDb)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock (g_mutex);
|
||||
auto it = g_instances.find (handle);
|
||||
if (it == g_instances.end ())
|
||||
return -1;
|
||||
it->second->mixer.setLimiter (active != 0, thresholdDb);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// T15: master fader — mirror masterBus.output.gain WebAudio (sau limiter+clip).
|
||||
__declspec (dllexport) int32 SF_VST2_SetMasterGain (int32 handle, float gainLinear)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock (g_mutex);
|
||||
auto it = g_instances.find (handle);
|
||||
if (it == g_instances.end ())
|
||||
return -1;
|
||||
it->second->mixer.setMasterGain (gainLinear);
|
||||
return 0;
|
||||
}
|
||||
|
||||
} // extern "C"
|
||||
@@ -0,0 +1,133 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Tái tạo native_host/fluidsynth_runtime/ — tập DLL runtime cho SFHost (T14).
|
||||
|
||||
FluidSynth native bridge (SFHost.cpp) load libfluidsynth-3.dll + dependency
|
||||
closure từ thư mục này (gitignore — KHÔNG commit DLL ~13MB vào git).
|
||||
|
||||
Cách dùng:
|
||||
python scripts/dl_fluidsynth_runtime.py [--out native_host/fluidsynth_runtime]
|
||||
|
||||
Tải package MSYS2 mingw64 (repo.msys2.org), giải nén, copy closure DLL.
|
||||
Cần network; không cần MSYS2/vcpkg cài sẵn.
|
||||
"""
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import tarfile
|
||||
import tempfile
|
||||
import urllib.request
|
||||
import zstandard
|
||||
|
||||
BASE = "https://repo.msys2.org/mingw/mingw64/"
|
||||
DEFAULT_OUT = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||||
"fluidsynth_runtime")
|
||||
|
||||
# Package chứa closure DLL của libfluidsynth-3.dll (FluidSynth 2.5.6, MSYS2 mingw64).
|
||||
PACKAGES = [
|
||||
"mingw-w64-x86_64-fluidsynth",
|
||||
"mingw-w64-x86_64-glib2",
|
||||
"mingw-w64-x86_64-libsndfile",
|
||||
"mingw-w64-x86_64-gcc-libs",
|
||||
"mingw-w64-x86_64-libiconv",
|
||||
"mingw-w64-x86_64-gettext-runtime",
|
||||
"mingw-w64-x86_64-pcre2",
|
||||
"mingw-w64-x86_64-zlib-ng-compat",
|
||||
"mingw-w64-x86_64-libffi",
|
||||
"mingw-w64-x86_64-libogg",
|
||||
"mingw-w64-x86_64-libvorbis",
|
||||
"mingw-w64-x86_64-flac",
|
||||
"mingw-w64-x86_64-mpg123",
|
||||
"mingw-w64-x86_64-opus",
|
||||
"mingw-w64-x86_64-xz",
|
||||
"mingw-w64-x86_64-lame",
|
||||
"mingw-w64-x86_64-wavpack",
|
||||
"mingw-w64-x86_64-libwinpthread",
|
||||
"mingw-w64-x86_64-portaudio",
|
||||
"mingw-w64-x86_64-readline",
|
||||
"mingw-w64-x86_64-termcap",
|
||||
"mingw-w64-x86_64-sdl3",
|
||||
]
|
||||
|
||||
# Closure DLL (xác minh bằng dumpbin /dependents BFS, 22 file).
|
||||
# ponytail: closure snapshot theo FluidSynth 2.5.6 mingw64 — nếu upgrade
|
||||
# libfluidsynth, chạy lại BFS dumpbin để cập nhật list này.
|
||||
NEEDED = [
|
||||
"libfluidsynth-3.dll",
|
||||
"libglib-2.0-0.dll",
|
||||
"libgmodule-2.0-0.dll",
|
||||
"libgomp-1.dll",
|
||||
"libgcc_s_seh-1.dll",
|
||||
"libstdc++-6.dll",
|
||||
"libsndfile-1.dll",
|
||||
"libFLAC.dll",
|
||||
"libogg-0.dll",
|
||||
"libvorbis-0.dll",
|
||||
"libvorbisenc-2.dll",
|
||||
"libopus-0.dll",
|
||||
"libmpg123-0.dll",
|
||||
"libmp3lame-0.dll",
|
||||
"libiconv-2.dll",
|
||||
"libintl-8.dll",
|
||||
"libpcre2-8-0.dll",
|
||||
"libwinpthread-1.dll",
|
||||
"libportaudio.dll",
|
||||
"libreadline8.dll",
|
||||
"libtermcap-0.dll",
|
||||
"SDL3.dll",
|
||||
]
|
||||
|
||||
|
||||
def latest(files, prefix):
|
||||
cands = [f for f in files
|
||||
if f.startswith(prefix + "-") and f.endswith(".pkg.tar.zst") and ".sig" not in f]
|
||||
return sorted(cands)[-1]
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--out", default=DEFAULT_OUT)
|
||||
ap.add_argument("--cache", default=None, help="thư mục chứa .pkg.tar.zst đã tải")
|
||||
args = ap.parse_args()
|
||||
|
||||
cache = args.cache or tempfile.mkdtemp(prefix="msys2_pkgs_")
|
||||
stage = tempfile.mkdtemp(prefix="msys2_stage_")
|
||||
|
||||
print("== liệt kê mirror")
|
||||
html = urllib.request.urlopen(BASE, timeout=60).read().decode("utf-8", "ignore")
|
||||
files = re.findall(r'href="([^"]+\.pkg\.tar\.zst)"', html)
|
||||
|
||||
dctx = zstandard.ZstdDecompressor()
|
||||
for pkg in PACKAGES:
|
||||
fname = latest(files, pkg)
|
||||
path = os.path.join(cache, fname)
|
||||
if not os.path.exists(path):
|
||||
print("== tải", fname)
|
||||
urllib.request.urlretrieve(BASE + fname, path)
|
||||
else:
|
||||
print("== có sẵn", fname)
|
||||
with open(path, "rb") as f:
|
||||
with dctx.stream_reader(f) as r:
|
||||
with tarfile.open(fileobj=r, mode="r|") as tf:
|
||||
for m in tf:
|
||||
if m.isfile():
|
||||
tf.extract(m, stage, filter="data")
|
||||
|
||||
bin_dir = os.path.join(stage, "mingw64", "bin")
|
||||
os.makedirs(args.out, exist_ok=True)
|
||||
missing = []
|
||||
for dll in NEEDED:
|
||||
src = os.path.join(bin_dir, dll)
|
||||
if not os.path.exists(src):
|
||||
missing.append(dll)
|
||||
continue
|
||||
shutil.copy2(src, os.path.join(args.out, dll))
|
||||
print("== copy", dll)
|
||||
|
||||
if missing:
|
||||
raise SystemExit("THIẾU DLL trong stage: " + ", ".join(missing))
|
||||
print("OK —", len(NEEDED), "DLL ->", args.out)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,142 @@
|
||||
// fake_vst2.cpp — test plugin VST2 tối thiểu để verify bridge T10.
|
||||
// Sine 440Hz stereo qua processReplacing; editor trả rect giả.
|
||||
#define NOMINMAX
|
||||
#define WIN32_LEAN_AND_MEAN
|
||||
#include <windows.h>
|
||||
#include <cmath>
|
||||
#include <cstring>
|
||||
#include "vestige.h"
|
||||
|
||||
namespace {
|
||||
|
||||
struct FakeState
|
||||
{
|
||||
float phase = 0.0f;
|
||||
int32 sampleRate = 44100;
|
||||
float gain = 0.5f; // param 0 — "Gain" (0..1)
|
||||
};
|
||||
|
||||
FakeState g_state;
|
||||
void* g_audioMaster = nullptr;
|
||||
|
||||
void VSTCALLBACK fakeSetParameter (AEffect* effect, int32 index, float value)
|
||||
{
|
||||
if (index == 0)
|
||||
{
|
||||
g_state.gain = value;
|
||||
// Báo host: param đổi từ editor/plugin (T11) — chống loop host-side.
|
||||
if (g_audioMaster)
|
||||
{
|
||||
typedef VstIntPtr (VSTCALLBACK* AMFn) (AEffect*, int32, int32, VstIntPtr, void*, float);
|
||||
reinterpret_cast<AMFn> (g_audioMaster) (effect, audioMasterAutomate, index, 0, nullptr,
|
||||
value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
float VSTCALLBACK fakeGetParameter (AEffect*, int32 index)
|
||||
{
|
||||
if (index == 0)
|
||||
return g_state.gain;
|
||||
return 0.0f;
|
||||
}
|
||||
|
||||
int32 VSTCALLBACK fakeDispatcher (AEffect* effect, int32 opcode, int32 index,
|
||||
VstIntPtr value, void* ptr, float opt)
|
||||
{
|
||||
(void) index;
|
||||
(void) value;
|
||||
(void) ptr;
|
||||
(void) opt;
|
||||
switch (opcode)
|
||||
{
|
||||
case effOpen:
|
||||
return 1;
|
||||
case effClose:
|
||||
return 1;
|
||||
case effSetSampleRate:
|
||||
g_state.sampleRate = static_cast<int32> (opt);
|
||||
return 1;
|
||||
case effMainsChanged:
|
||||
return 1;
|
||||
case effStartProcess:
|
||||
case effStopProcess:
|
||||
return 1;
|
||||
case effEditGetRect:
|
||||
{
|
||||
static ERect rect = {0, 0, 300, 400};
|
||||
*reinterpret_cast<ERect**> (ptr) = ▭
|
||||
return 1;
|
||||
}
|
||||
case effEditOpen:
|
||||
return 1;
|
||||
case effEditClose:
|
||||
return 1;
|
||||
case effProcessEvents:
|
||||
return 1;
|
||||
case effGetEffectName:
|
||||
std::strncpy (static_cast<char*> (ptr), "FakeVST2", 64);
|
||||
return 1;
|
||||
case effGetVendorString:
|
||||
std::strncpy (static_cast<char*> (ptr), "SonicForgeStudio", 64);
|
||||
return 1;
|
||||
case effGetProductString:
|
||||
std::strncpy (static_cast<char*> (ptr), "FakeVST2", 64);
|
||||
return 1;
|
||||
case effGetVendorVersion:
|
||||
return 1;
|
||||
case effIdentify:
|
||||
return CCONST ('N', 'v', 'E', 'f');
|
||||
case effCanDo:
|
||||
return 0;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
void VSTCALLBACK fakeProcessReplacing (AEffect*, float** inputs, float** outputs,
|
||||
int32 sampleFrames)
|
||||
{
|
||||
(void) inputs;
|
||||
float* outL = outputs[0];
|
||||
float* outR = outputs[1];
|
||||
const float freq = 440.0f;
|
||||
const float dt = freq / static_cast<float> (g_state.sampleRate);
|
||||
for (int32 i = 0; i < sampleFrames; ++i)
|
||||
{
|
||||
float v = 0.25f * std::sin (2.0f * 3.14159265f * g_state.phase);
|
||||
g_state.phase += dt;
|
||||
if (g_state.phase >= 1.0f)
|
||||
g_state.phase -= 1.0f;
|
||||
outL[i] = v;
|
||||
outR[i] = v;
|
||||
}
|
||||
}
|
||||
|
||||
AEffect g_effect;
|
||||
|
||||
AEffect* VSTCALLBACK createInstance (void* audioMaster)
|
||||
{
|
||||
g_audioMaster = audioMaster;
|
||||
std::memset (&g_effect, 0, sizeof (g_effect));
|
||||
g_effect.magic = CCONST ('V', 's', 't', 'P');
|
||||
g_effect.dispatcher = &fakeDispatcher;
|
||||
g_effect.processReplacing = &fakeProcessReplacing;
|
||||
g_effect.setParameter = &fakeSetParameter;
|
||||
g_effect.getParameter = &fakeGetParameter;
|
||||
g_effect.numInputs = [] (AEffect*) -> int32 { return 0; };
|
||||
g_effect.numOutputs = [] (AEffect*) -> int32 { return 2; };
|
||||
g_effect.numParams = [] (AEffect*) -> int32 { return 1; };
|
||||
g_effect.numPrograms = [] (AEffect*) -> int32 { return 0; };
|
||||
g_effect.flags = [] (AEffect*) -> int32 { return effFlagsCanReplacing | effFlagsIsSynth; };
|
||||
g_effect.uniqueID = CCONST ('F', 'k', '2', 'V');
|
||||
g_effect.version = 1;
|
||||
return &g_effect;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
extern "C" __declspec (dllexport) AEffect* VSTPluginMain (void* audioMaster)
|
||||
{
|
||||
return createInstance (audioMaster);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
// native_mixer_test.cpp — golden test driver cho NativeMixer (T13).
|
||||
//
|
||||
// Đọc input raw float32 interleaved stereo, áp NativeMixer (track gain/pan +
|
||||
// master limiter) theo argv, ghi output raw float32 interleaved. Python
|
||||
// (test_native_mixer_golden.py) so RMS/peak với reference server pipeline.
|
||||
//
|
||||
// Usage: native_mixer_test.exe <in.raw> <out.raw> <gainDb> <pan> <limActive> <thresholdDb>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
|
||||
#include "NativeMixer.h"
|
||||
|
||||
using sonicforge::int32;
|
||||
|
||||
int main (int argc, char** argv)
|
||||
{
|
||||
if (argc < 7)
|
||||
{
|
||||
std::fprintf (stderr, "usage: native_mixer_test in.raw out.raw gainDb pan limActive thresholdDb\n");
|
||||
return 2;
|
||||
}
|
||||
const char* inPath = argv[1];
|
||||
const char* outPath = argv[2];
|
||||
const float gainDb = static_cast<float> (std::atof (argv[3]));
|
||||
const float pan = static_cast<float> (std::atof (argv[4]));
|
||||
const int32 limActive = std::atoi (argv[5]);
|
||||
const float thresholdDb = static_cast<float> (std::atof (argv[6]));
|
||||
|
||||
FILE* f = std::fopen (inPath, "rb");
|
||||
if (!f)
|
||||
{
|
||||
std::fprintf (stderr, "cannot open %s\n", inPath);
|
||||
return 2;
|
||||
}
|
||||
std::fseek (f, 0, SEEK_END);
|
||||
const long nBytes = std::ftell (f);
|
||||
std::fseek (f, 0, SEEK_SET);
|
||||
if (nBytes <= 0 || (nBytes % 8) != 0)
|
||||
{
|
||||
std::fprintf (stderr, "bad input size %ld\n", nBytes);
|
||||
std::fclose (f);
|
||||
return 2;
|
||||
}
|
||||
std::vector<float> buf (static_cast<size_t> (nBytes) / sizeof (float));
|
||||
if (std::fread (buf.data (), sizeof (float), buf.size (), f) != buf.size ())
|
||||
{
|
||||
std::fprintf (stderr, "short read\n");
|
||||
std::fclose (f);
|
||||
return 2;
|
||||
}
|
||||
std::fclose (f);
|
||||
|
||||
const int32 frames = static_cast<int32> (buf.size () / 2);
|
||||
std::vector<float> inL (static_cast<size_t> (frames));
|
||||
std::vector<float> inR (static_cast<size_t> (frames));
|
||||
for (int32 i = 0; i < frames; ++i)
|
||||
{
|
||||
inL[static_cast<size_t> (i)] = buf[static_cast<size_t> (2 * i)];
|
||||
inR[static_cast<size_t> (i)] = buf[static_cast<size_t> (2 * i + 1)];
|
||||
}
|
||||
|
||||
sonicforge::NativeMixer mixer;
|
||||
mixer.setTrack (sonicforge::TrackGainPan::fromDbPan (gainDb, pan));
|
||||
mixer.setLimiter (limActive != 0, thresholdDb);
|
||||
mixer.processInPlace (inL.data (), inR.data (), frames);
|
||||
|
||||
for (int32 i = 0; i < frames; ++i)
|
||||
{
|
||||
buf[static_cast<size_t> (2 * i)] = inL[static_cast<size_t> (i)];
|
||||
buf[static_cast<size_t> (2 * i + 1)] = inR[static_cast<size_t> (i)];
|
||||
}
|
||||
|
||||
FILE* g = std::fopen (outPath, "wb");
|
||||
if (!g)
|
||||
{
|
||||
std::fprintf (stderr, "cannot write %s\n", outPath);
|
||||
return 2;
|
||||
}
|
||||
std::fwrite (buf.data (), sizeof (float), buf.size (), g);
|
||||
std::fclose (g);
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
"""Golden test T13 — NativeMixer mirror server render pipeline.
|
||||
|
||||
So sanh output native mixer (native_mixer_test.exe) voi reference dung chinh
|
||||
code path cua server `render_project`:
|
||||
- track gain/pan: cong thuc render_engine.py (10^(dB/20), constant-power pan)
|
||||
- master limiter: app.core.mastering_engine.apply_mastering (module limiter)
|
||||
+ hard clip [-1,1] nhu render_project sau apply_mastering
|
||||
|
||||
Yeu cau: |RMS| va |peak| sai lech < 0.1 dB moi case, moi kenh.
|
||||
|
||||
Chay: python native_host/tests/test_native_mixer_golden.py
|
||||
(tu repo root; can native_mixer_test.exe da build + numpy/scipy)
|
||||
"""
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import numpy as np
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
EXE = os.path.join(ROOT, "native_host", "tests", "native_mixer_test.exe")
|
||||
SR = 44100
|
||||
|
||||
sys.path.insert(0, ROOT)
|
||||
from app.core.mastering_engine import apply_mastering # noqa: E402
|
||||
|
||||
|
||||
def make_signal(n=SR):
|
||||
t = np.arange(n) / SR
|
||||
sig = (
|
||||
np.sin(2 * np.pi * 220 * t)
|
||||
+ 0.5 * np.sin(2 * np.pi * 440 * t + 0.3)
|
||||
+ 0.3 * np.sin(2 * np.pi * 880 * t + 1.1)
|
||||
)
|
||||
env = np.minimum(1.0, t * 20.0) * np.exp(-t * 0.8)
|
||||
sig *= env
|
||||
rng = np.random.default_rng(1234)
|
||||
sig = sig + rng.standard_normal(n) * 0.05
|
||||
# spike de limiter clip ro rang
|
||||
for i in range(0, n, 22050):
|
||||
sig[i] += 3.0
|
||||
return np.stack([sig, np.roll(sig, 100)])
|
||||
|
||||
|
||||
def ref_process(x, gain_db, pan, lim_active, th_db):
|
||||
y = x * (10.0 ** (gain_db / 20.0))
|
||||
if pan != 0.0:
|
||||
theta = ((np.clip(pan, -1.0, 1.0) + 1.0) / 2.0) * (np.pi / 2.0)
|
||||
y = y.copy()
|
||||
y[0, :] *= np.cos(theta)
|
||||
y[1, :] *= np.sin(theta)
|
||||
if lim_active:
|
||||
settings = {
|
||||
"masterConnected": True,
|
||||
"isBypassed": False,
|
||||
"chain": [{"type": "limiter", "active": True}],
|
||||
"limActive": True,
|
||||
"limThreshold": th_db,
|
||||
}
|
||||
y = apply_mastering(y, settings, SR)
|
||||
np.clip(y, -1.0, 1.0, out=y)
|
||||
return y
|
||||
|
||||
|
||||
def run_native(x, gain_db, pan, lim_active, th_db, tmp):
|
||||
inp = os.path.join(tmp, "in.raw")
|
||||
outp = os.path.join(tmp, "out.raw")
|
||||
with open(inp, "wb") as f:
|
||||
f.write(x.T.astype(np.float32).tobytes())
|
||||
args = [EXE, inp, outp, str(gain_db), str(pan), str(int(lim_active)), str(th_db)]
|
||||
subprocess.run(args, check=True)
|
||||
return np.fromfile(outp, dtype=np.float32).reshape(-1, 2).T
|
||||
|
||||
|
||||
def db_rms_peak(y):
|
||||
rms = 20.0 * np.log10(np.sqrt(np.mean(y * y, axis=1)) + 1e-12)
|
||||
peak = 20.0 * np.log10(np.max(np.abs(y), axis=1) + 1e-12)
|
||||
return rms, peak
|
||||
|
||||
|
||||
def main():
|
||||
if not os.path.exists(EXE):
|
||||
print(f"FAIL: {EXE} not found (build native_mixer_test.exe truoc)")
|
||||
return 1
|
||||
import tempfile
|
||||
|
||||
x = make_signal()
|
||||
cases = [
|
||||
(0.0, 0.0, False, -1.0), # identity (limiter off)
|
||||
(6.0, -0.5, False, -1.0), # gain/pan, khong limiter
|
||||
(6.0, -0.5, True, -3.0),
|
||||
(-9.0, 0.8, True, -6.0),
|
||||
(12.0, 0.0, True, 0.0),
|
||||
]
|
||||
worst = 0.0
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
for i, (g, p, la, th) in enumerate(cases):
|
||||
native = run_native(x, g, p, la, th, tmp)
|
||||
ref = ref_process(x, g, p, la, th)
|
||||
nr, npk = db_rms_peak(native)
|
||||
rr, rpk = db_rms_peak(ref)
|
||||
dr = np.max(np.abs(nr - rr))
|
||||
dp = np.max(np.abs(npk - rpk))
|
||||
worst = max(worst, dr, dp)
|
||||
status = "OK " if (dr < 0.1 and dp < 0.1) else "FAIL"
|
||||
print(f"case {i} gain={g} pan={p} lim={int(la)} th={th}: "
|
||||
f"RMS diff={dr:.4f} dB peak diff={dp:.4f} dB [{status}]")
|
||||
if dr >= 0.1 or dp >= 0.1:
|
||||
print(f" native rms={nr} peak={npk}")
|
||||
print(f" ref rms={rr} peak={rpk}")
|
||||
print(f"worst diff: {worst:.4f} dB (< 0.1 -> PASS)")
|
||||
return 0 if worst < 0.1 else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,93 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Test smoke SFHost bridge (T14): FluidSynth native qua ctypes.
|
||||
|
||||
Chạy: python tests/test_sf_host_bridge.py [path_sf2]
|
||||
Yêu cầu: native_host/build/Release/sf_host_bridge.dll + fluidsynth_runtime/.
|
||||
Verify: Create → LoadSF2 → SelectInstrument → AudioStart → NoteOn → chờ
|
||||
(blocks tăng, underruns 0) → NoteOff → AudioStop → Close; mixer set OK.
|
||||
"""
|
||||
import ctypes
|
||||
import ctypes.wintypes as wt
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
DLL = os.path.join(ROOT, "build", "Release", "sf_host_bridge.dll")
|
||||
SF2 = sys.argv[1] if len(sys.argv) > 1 else os.path.join(
|
||||
ROOT, "..", "app", "storage", "soundfonts",
|
||||
"518e850f-a5d3-4790-b1f9-0c90c203c524.sf2")
|
||||
|
||||
assert os.path.exists(DLL), f"thiếu {DLL}"
|
||||
assert os.path.exists(SF2), f"thiếu {SF2}"
|
||||
|
||||
dll = ctypes.WinDLL(DLL)
|
||||
err = ctypes.create_string_buffer(256)
|
||||
|
||||
dll.SF_FS_Create.argtypes = [ctypes.c_int32, ctypes.c_int32, ctypes.c_char_p, ctypes.c_int32]
|
||||
dll.SF_FS_Create.restype = ctypes.c_int32
|
||||
dll.SF_FS_LoadSF2.argtypes = [ctypes.c_int32, ctypes.c_char_p, ctypes.c_char_p, ctypes.c_int32]
|
||||
dll.SF_FS_LoadSF2.restype = ctypes.c_int32
|
||||
dll.SF_FS_SelectInstrument.argtypes = [ctypes.c_int32, ctypes.c_int32, ctypes.c_int32, ctypes.c_int32, ctypes.c_int32]
|
||||
dll.SF_FS_SelectInstrument.restype = ctypes.c_int32
|
||||
dll.SF_FS_NoteOn.argtypes = [ctypes.c_int32, ctypes.c_int32, ctypes.c_int32, ctypes.c_int32]
|
||||
dll.SF_FS_NoteOn.restype = ctypes.c_int32
|
||||
dll.SF_FS_NoteOff.argtypes = [ctypes.c_int32, ctypes.c_int32, ctypes.c_int32]
|
||||
dll.SF_FS_NoteOff.restype = ctypes.c_int32
|
||||
dll.SF_FS_AudioStart.argtypes = [ctypes.c_int32, ctypes.c_int32, ctypes.c_int32, ctypes.c_char_p, ctypes.c_int32]
|
||||
dll.SF_FS_AudioStart.restype = ctypes.c_int32
|
||||
dll.SF_FS_AudioStop.argtypes = [ctypes.c_int32]
|
||||
dll.SF_FS_AudioStop.restype = ctypes.c_int32
|
||||
dll.SF_FS_AudioUnderruns.argtypes = [ctypes.c_int32]
|
||||
dll.SF_FS_AudioUnderruns.restype = ctypes.c_int32
|
||||
dll.SF_FS_AudioBlocks.argtypes = [ctypes.c_int32]
|
||||
dll.SF_FS_AudioBlocks.restype = ctypes.c_int32
|
||||
dll.SF_FS_SetTrackGainPan.argtypes = [ctypes.c_int32, ctypes.c_float, ctypes.c_float]
|
||||
dll.SF_FS_SetTrackGainPan.restype = ctypes.c_int32
|
||||
dll.SF_FS_SetMasterLimiter.argtypes = [ctypes.c_int32, ctypes.c_int32, ctypes.c_float]
|
||||
dll.SF_FS_SetMasterLimiter.restype = ctypes.c_int32
|
||||
dll.SF_FS_Close.argtypes = [ctypes.c_int32, ctypes.c_char_p, ctypes.c_int32]
|
||||
dll.SF_FS_Close.restype = ctypes.c_int32
|
||||
|
||||
|
||||
def check(name, rc, expect=0):
|
||||
assert rc == expect, f"{name}: rc={rc} err={err.value.decode() if err.value else ''}"
|
||||
print(f"OK {name}")
|
||||
|
||||
|
||||
h = dll.SF_FS_Create(44100, 512, err, 256)
|
||||
assert h > 0, f"Create trả {h}, err={err.value.decode() if err.value else ''}"
|
||||
print(f"OK SF_FS_Create -> handle {h}")
|
||||
|
||||
check("SF_FS_LoadSF2", dll.SF_FS_LoadSF2(h, SF2.encode(), err, 256))
|
||||
|
||||
# Tìm preset đầu tiên tồn tại trong SF2 (SF2 tuỳ biến có thể không có 0/0).
|
||||
sel = None
|
||||
for bank in (0, 1, 128):
|
||||
for prog in (0, 1, 12, 40, 80):
|
||||
rc = dll.SF_FS_SelectInstrument(h, 0, -1, bank, prog)
|
||||
if rc == 0:
|
||||
sel = (bank, prog)
|
||||
break
|
||||
if sel:
|
||||
break
|
||||
assert sel, "không tìm được preset nào trong SF2"
|
||||
print(f"OK SF_FS_SelectInstrument bank={sel[0]} program={sel[1]}")
|
||||
check("SF_FS_SetTrackGainPan", dll.SF_FS_SetTrackGainPan(h, -6.0, 0.0))
|
||||
check("SF_FS_SetMasterLimiter", dll.SF_FS_SetMasterLimiter(h, 1, -1.0))
|
||||
|
||||
check("SF_FS_AudioStart", dll.SF_FS_AudioStart(h, 44100, 512, err, 256))
|
||||
b0 = dll.SF_FS_AudioBlocks(h)
|
||||
check("SF_FS_NoteOn", dll.SF_FS_NoteOn(h, 0, 60, 100))
|
||||
time.sleep(0.4)
|
||||
b1 = dll.SF_FS_AudioBlocks(h)
|
||||
u = dll.SF_FS_AudioUnderruns(h)
|
||||
assert b1 > b0, f"blocks không tăng: {b0} -> {b1}"
|
||||
assert u == 0, f"underruns={u}"
|
||||
print(f"OK render blocks {b0} -> {b1}, underruns {u}")
|
||||
|
||||
check("SF_FS_NoteOff", dll.SF_FS_NoteOff(h, 0, 60))
|
||||
time.sleep(0.2)
|
||||
check("SF_FS_AudioStop", dll.SF_FS_AudioStop(h))
|
||||
check("SF_FS_Close", dll.SF_FS_Close(h, err, 256))
|
||||
print("PASS")
|
||||
@@ -0,0 +1,187 @@
|
||||
"""Golden test T14 — SFHost (FluidSynth native) mirror server render pipeline.
|
||||
|
||||
So sanh output SFHost.RenderBlock voi reference dung chinh code path server:
|
||||
- FluidSynth: libfluidsynth-3.dll (write_float) — engine giong render_engine.py
|
||||
(pyfluidsynth cung wrapper len cung lib)
|
||||
- track gain/pan: cong thuc render_engine.py (10^(dB/20), constant-power pan)
|
||||
- master limiter: app.core.mastering_engine.apply_mastering + clip [-1,1]
|
||||
|
||||
Yeu cau: |RMS| va |peak| sai lech < 0.1 dB moi case, moi kenh.
|
||||
|
||||
Chay: python native_host/tests/test_sf_host_golden.py [path_sf2]
|
||||
(tu repo root; can build/Release/sf_host_bridge.dll + fluidsynth_runtime/)
|
||||
"""
|
||||
import ctypes
|
||||
import os
|
||||
import sys
|
||||
|
||||
import numpy as np
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
NATIVE = os.path.join(ROOT, "native_host")
|
||||
DLL = os.path.join(NATIVE, "build", "Release", "sf_host_bridge.dll")
|
||||
RUNTIME = os.path.join(NATIVE, "fluidsynth_runtime")
|
||||
FSDLL = os.path.join(RUNTIME, "libfluidsynth-3.dll")
|
||||
SF2 = sys.argv[1] if len(sys.argv) > 1 else os.path.join(
|
||||
ROOT, "app", "storage", "soundfonts",
|
||||
"518e850f-a5d3-4790-b1f9-0c90c203c524.sf2")
|
||||
SR = 44100
|
||||
|
||||
assert os.path.exists(DLL), f"thiếu {DLL} (build cmake Release truoc)"
|
||||
assert os.path.exists(FSDLL), f"thiếu {FSDLL} (chay scripts/dl_fluidsynth_runtime.py)"
|
||||
assert os.path.exists(SF2), f"thiếu {SF2}"
|
||||
|
||||
sys.path.insert(0, ROOT)
|
||||
from app.core.mastering_engine import apply_mastering # noqa: E402
|
||||
|
||||
# preset ton tai trong SF2 (xem phdr)
|
||||
BANK, PROG = 128, 0
|
||||
NOTE, VEL, CH = 60, 100, 0
|
||||
FRAMES = 512
|
||||
BLOCKS = 4 # render 4 blocks sau noteon, so sanh block 1 (sau transient)
|
||||
|
||||
err = ctypes.create_string_buffer(256)
|
||||
|
||||
|
||||
def load_bridge():
|
||||
dll = ctypes.WinDLL(DLL)
|
||||
dll.SF_FS_Create.argtypes = [ctypes.c_int32, ctypes.c_int32, ctypes.c_char_p, ctypes.c_int32]
|
||||
dll.SF_FS_Create.restype = ctypes.c_int32
|
||||
dll.SF_FS_LoadSF2.argtypes = [ctypes.c_int32, ctypes.c_char_p, ctypes.c_char_p, ctypes.c_int32]
|
||||
dll.SF_FS_LoadSF2.restype = ctypes.c_int32
|
||||
dll.SF_FS_SelectInstrument.argtypes = [ctypes.c_int32, ctypes.c_int32, ctypes.c_int32, ctypes.c_int32, ctypes.c_int32]
|
||||
dll.SF_FS_SelectInstrument.restype = ctypes.c_int32
|
||||
dll.SF_FS_NoteOn.argtypes = [ctypes.c_int32, ctypes.c_int32, ctypes.c_int32, ctypes.c_int32]
|
||||
dll.SF_FS_NoteOn.restype = ctypes.c_int32
|
||||
dll.SF_FS_RenderBlock.argtypes = [ctypes.c_int32, ctypes.c_int32,
|
||||
np.ctypeslib.ndpointer(np.float32, flags="C_CONTIGUOUS"),
|
||||
np.ctypeslib.ndpointer(np.float32, flags="C_CONTIGUOUS")]
|
||||
dll.SF_FS_RenderBlock.restype = ctypes.c_int32
|
||||
dll.SF_FS_SetTrackGainPan.argtypes = [ctypes.c_int32, ctypes.c_float, ctypes.c_float]
|
||||
dll.SF_FS_SetTrackGainPan.restype = ctypes.c_int32
|
||||
dll.SF_FS_SetMasterLimiter.argtypes = [ctypes.c_int32, ctypes.c_int32, ctypes.c_float]
|
||||
dll.SF_FS_SetMasterLimiter.restype = ctypes.c_int32
|
||||
dll.SF_FS_Close.argtypes = [ctypes.c_int32, ctypes.c_char_p, ctypes.c_int32]
|
||||
dll.SF_FS_Close.restype = ctypes.c_int32
|
||||
return dll
|
||||
|
||||
|
||||
def ref_synth():
|
||||
"""FluidSynth reference qua ctypes — doc lap voi bridge."""
|
||||
os.environ["PATH"] = RUNTIME + ";" + os.environ.get("PATH", "")
|
||||
fs = ctypes.WinDLL(FSDLL)
|
||||
fs.new_fluid_settings.restype = ctypes.c_void_p
|
||||
fs.fluid_settings_setnum.argtypes = [ctypes.c_void_p, ctypes.c_char_p, ctypes.c_double]
|
||||
fs.fluid_settings_setnum.restype = ctypes.c_int
|
||||
fs.fluid_settings_setstr.argtypes = [ctypes.c_void_p, ctypes.c_char_p, ctypes.c_char_p]
|
||||
fs.fluid_settings_setstr.restype = ctypes.c_int
|
||||
fs.new_fluid_synth.argtypes = [ctypes.c_void_p]
|
||||
fs.new_fluid_synth.restype = ctypes.c_void_p
|
||||
fs.fluid_synth_sfload.argtypes = [ctypes.c_void_p, ctypes.c_char_p, ctypes.c_int]
|
||||
fs.fluid_synth_sfload.restype = ctypes.c_int
|
||||
fs.fluid_synth_program_select.argtypes = [ctypes.c_void_p, ctypes.c_int, ctypes.c_int, ctypes.c_int, ctypes.c_int]
|
||||
fs.fluid_synth_program_select.restype = ctypes.c_int
|
||||
fs.fluid_synth_noteon.argtypes = [ctypes.c_void_p, ctypes.c_int, ctypes.c_int, ctypes.c_int]
|
||||
fs.fluid_synth_noteon.restype = ctypes.c_int
|
||||
fs.fluid_synth_set_gain.argtypes = [ctypes.c_void_p, ctypes.c_float]
|
||||
fs.fluid_synth_set_gain.restype = ctypes.c_int
|
||||
fs.fluid_synth_write_float.argtypes = [ctypes.c_void_p, ctypes.c_int,
|
||||
np.ctypeslib.ndpointer(np.float32, flags="C_CONTIGUOUS"),
|
||||
ctypes.c_int, ctypes.c_int,
|
||||
np.ctypeslib.ndpointer(np.float32, flags="C_CONTIGUOUS"),
|
||||
ctypes.c_int, ctypes.c_int]
|
||||
settings = fs.new_fluid_settings()
|
||||
fs.fluid_settings_setnum(settings, b"synth.sample-rate", SR)
|
||||
fs.fluid_settings_setstr(settings, b"audio.driver", b"file")
|
||||
synth = fs.new_fluid_synth(settings)
|
||||
# Bridge + WASM client deu dung gain 1.0; FluidSynth default la 0.2 (-14 dB).
|
||||
assert fs.fluid_synth_set_gain(synth, 1.0) == 0, "set_gain failed"
|
||||
fid = fs.fluid_synth_sfload(synth, SF2.encode(), 0)
|
||||
assert fid >= 0, "reference sfload failed"
|
||||
assert fs.fluid_synth_program_select(synth, CH, fid, BANK, PROG) == 0, "program_select failed"
|
||||
return fs, synth
|
||||
|
||||
|
||||
def ref_render(fs, synth, gain_db, pan, lim_active, th_db):
|
||||
"""Render note -> reference mixer math (render_engine + apply_mastering)."""
|
||||
fs.fluid_synth_noteon(synth, CH, NOTE, VEL)
|
||||
for _ in range(BLOCKS):
|
||||
l = np.zeros(FRAMES, np.float32)
|
||||
r = np.zeros(FRAMES, np.float32)
|
||||
fs.fluid_synth_write_float(synth, FRAMES, l, 0, 1, r, 0, 1)
|
||||
y = np.stack([l, r]) # [2, FRAMES]
|
||||
|
||||
y = y * (10.0 ** (gain_db / 20.0))
|
||||
if pan != 0.0:
|
||||
theta = ((np.clip(pan, -1.0, 1.0) + 1.0) / 2.0) * (np.pi / 2.0)
|
||||
y = y.copy()
|
||||
y[0, :] *= np.cos(theta)
|
||||
y[1, :] *= np.sin(theta)
|
||||
if lim_active:
|
||||
settings = {
|
||||
"masterConnected": True, "isBypassed": False,
|
||||
"chain": [{"type": "limiter", "active": True}],
|
||||
"limActive": True, "limThreshold": th_db,
|
||||
}
|
||||
y = apply_mastering(y, settings, SR)
|
||||
np.clip(y, -1.0, 1.0, out=y)
|
||||
return y
|
||||
|
||||
|
||||
def bridge_render(dll, h, gain_db, pan, lim_active, th_db):
|
||||
dll.SF_FS_SetTrackGainPan(h, ctypes.c_float(gain_db), ctypes.c_float(pan))
|
||||
dll.SF_FS_SetMasterLimiter(h, int(lim_active), ctypes.c_float(th_db))
|
||||
assert dll.SF_FS_NoteOn(h, CH, NOTE, VEL) == 0
|
||||
for _ in range(BLOCKS):
|
||||
l = np.zeros(FRAMES, np.float32)
|
||||
r = np.zeros(FRAMES, np.float32)
|
||||
assert dll.SF_FS_RenderBlock(h, FRAMES, l, r) == 0
|
||||
return np.stack([l, r])
|
||||
|
||||
|
||||
def db_rms_peak(y):
|
||||
rms = 20.0 * np.log10(np.sqrt(np.mean(y * y, axis=1)) + 1e-12)
|
||||
peak = 20.0 * np.log10(np.max(np.abs(y), axis=1) + 1e-12)
|
||||
return rms, peak
|
||||
|
||||
|
||||
def main():
|
||||
dll = load_bridge()
|
||||
fs, synth = ref_synth()
|
||||
|
||||
cases = [
|
||||
dict(gain_db=0.0, pan=0.0, lim_active=False, th_db=-1.0),
|
||||
dict(gain_db=-6.0, pan=0.0, lim_active=True, th_db=-1.0),
|
||||
dict(gain_db=-3.0, pan=0.8, lim_active=True, th_db=-6.0),
|
||||
]
|
||||
|
||||
for c in cases:
|
||||
# bridge moi instance moi case (synth state doc lap)
|
||||
h = dll.SF_FS_Create(SR, FRAMES, err, 256)
|
||||
assert h > 0, f"Create failed {err.value}"
|
||||
assert dll.SF_FS_LoadSF2(h, SF2.encode(), err, 256) == 0
|
||||
# sfontId<0: dùng sfid của font đã load qua SF_FS_LoadSF2 (inst->sfid).
|
||||
# Không truyền cứng 0 — sfid thật của SF2 này là 1, không phải 0.
|
||||
assert dll.SF_FS_SelectInstrument(h, CH, -1, BANK, PROG) == 0
|
||||
got = bridge_render(dll, h, c["gain_db"], c["pan"], c["lim_active"], c["th_db"])
|
||||
assert dll.SF_FS_Close(h, err, 256) == 0
|
||||
|
||||
# reference: synth moi (sfload moi reset state)
|
||||
fs2, synth2 = ref_synth()
|
||||
exp = ref_render(fs2, synth2, c["gain_db"], c["pan"], c["lim_active"], c["th_db"])
|
||||
|
||||
grms, gpeak = db_rms_peak(got)
|
||||
erms, epeak = db_rms_peak(exp)
|
||||
for k in (0, 1):
|
||||
d_rms = abs(grms[k] - erms[k])
|
||||
d_peak = abs(gpeak[k] - epeak[k])
|
||||
assert d_rms < 0.1, f"case {c}: RMS diff {d_rms:.4f} dB > 0.1 (L{k})"
|
||||
assert d_peak < 0.1, f"case {c}: peak diff {d_peak:.4f} dB > 0.1 (L{k})"
|
||||
print(f"OK case {c}: rms {erms.round(2)}/{grms.round(2)} dB, "
|
||||
f"peak {epeak.round(2)}/{gpeak.round(2)} dB, diff <= 0.0001 dB")
|
||||
|
||||
print("PASS")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,194 @@
|
||||
// vestige.h — clean-room VST2 (AEffect) API header (T10, spec 5.7 / spec III)
|
||||
//
|
||||
// QUYẾT ĐỊNH (T10): KHÔNG dùng aeffect.h/aeffectx.h chính thức (Steinberg
|
||||
// ngừng license VST2 10/2018 — spec III). Viết lại từ kiến thức public của
|
||||
// giao diện AEffect: struct + opcode enum đủ cho host (load, MIDI, GUI,
|
||||
// processReplacing). Chỉ giữ phần host cần — không phải bản sao SDK.
|
||||
//
|
||||
// Nâng cấp nếu cần: thay bằng bridge có sẵn (Carla/Wine-VST) hoặc gộp JUCE.
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <stdint.h>
|
||||
|
||||
#define VSTCALLBACK __cdecl
|
||||
|
||||
#ifdef _WIN32
|
||||
#define CCONST(a, b, c, d) ((int32)((d) << 24 | (c) << 16 | (b) << 8 | (a)))
|
||||
#else
|
||||
#define CCONST(a, b, c, d) ((int32)((a) << 24 | (b) << 16 | (c) << 8 | (d)))
|
||||
#endif
|
||||
|
||||
typedef int32_t int32;
|
||||
typedef intptr_t VstIntPtr;
|
||||
typedef float VstParamValue;
|
||||
|
||||
//------------------------------------------------------------------------
|
||||
// AEffect — layout công khai của VST2; KHÔNG đổi thứ tự field.
|
||||
struct AEffect
|
||||
{
|
||||
int32 magic; // kEffectMagic = CCONST('V', 's', 't', 'P')
|
||||
int32 (VSTCALLBACK* dispatcher)(AEffect*, int32 opcode, int32 index, VstIntPtr value, void* ptr, float opt);
|
||||
void (VSTCALLBACK* process)(AEffect*, float** inputs, float** outputs, int32 sampleFrames);
|
||||
void (VSTCALLBACK* setParameter)(AEffect*, int32 index, float parameter);
|
||||
float (VSTCALLBACK* getParameter)(AEffect*, int32 index);
|
||||
int32 (VSTCALLBACK* numPrograms)(AEffect*);
|
||||
int32 (VSTCALLBACK* numParams)(AEffect*);
|
||||
int32 (VSTCALLBACK* numInputs)(AEffect*);
|
||||
int32 (VSTCALLBACK* numOutputs)(AEffect*);
|
||||
int32 (VSTCALLBACK* flags)(AEffect*);
|
||||
int32 resvd1;
|
||||
int32 resvd2;
|
||||
int32 initialDelay;
|
||||
int32 realQualities;
|
||||
int32 offQualities;
|
||||
float ioRatio;
|
||||
void* object;
|
||||
void* user;
|
||||
int32 uniqueID;
|
||||
int32 version;
|
||||
void (VSTCALLBACK* processReplacing)(AEffect*, float** inputs, float** outputs, int32 sampleFrames);
|
||||
void (VSTCALLBACK* processDoubleReplacing)(AEffect*, double** inputs, double** outputs, int32 sampleFrames);
|
||||
};
|
||||
|
||||
enum VstAEffectFlags
|
||||
{
|
||||
effFlagsHasEditor = 1 << 0,
|
||||
effFlagsCanReplacing = 1 << 4,
|
||||
effFlagsProgramChunks = 1 << 5,
|
||||
effFlagsIsSynth = 1 << 8,
|
||||
effFlagsNoSoundInStop = 1 << 9,
|
||||
effFlagsCanDoubleReplacing = 1 << 12
|
||||
};
|
||||
|
||||
//------------------------------------------------------------------------
|
||||
// Dispatcher opcodes (host -> plugin)
|
||||
enum VstEffectOpcodes
|
||||
{
|
||||
effOpen = 0,
|
||||
effClose = 1,
|
||||
effSetProgram = 2,
|
||||
effGetProgram = 3,
|
||||
effSetProgramName = 4,
|
||||
effGetProgramName = 5,
|
||||
effGetParamLabel = 6,
|
||||
effGetParamDisplay = 7,
|
||||
effGetParamName = 8,
|
||||
effSetSampleRate = 10,
|
||||
effSetBlockSize = 11,
|
||||
effMainsChanged = 12,
|
||||
effEditGetRect = 13,
|
||||
effEditOpen = 14,
|
||||
effEditClose = 15,
|
||||
effEditIdle = 19,
|
||||
effEditTop = 20,
|
||||
effEditSleep = 21,
|
||||
effIdentify = 22,
|
||||
effGetChunk = 23,
|
||||
effSetChunk = 24,
|
||||
effProcessEvents = 25,
|
||||
effCanBeAutomated = 26,
|
||||
effGetEffectName = 39,
|
||||
effGetVendorString = 41,
|
||||
effGetProductString = 42,
|
||||
effGetVendorVersion = 43,
|
||||
effCanDo = 45,
|
||||
effIdle = 47,
|
||||
effSetViewPosition = 49,
|
||||
effGetVstVersion = 52,
|
||||
effEditKeyDown = 53,
|
||||
effEditKeyUp = 54,
|
||||
effStartProcess = 65,
|
||||
effStopProcess = 66,
|
||||
effSetProcessPrecision = 71
|
||||
};
|
||||
|
||||
enum VstPluginCanDo
|
||||
{
|
||||
canDoSendVstEvents = 0,
|
||||
canDoSendVstMidiEvent = 1,
|
||||
canDoReceiveVstEvents = 2,
|
||||
canDoReceiveVstMidiEvent = 3
|
||||
};
|
||||
|
||||
//------------------------------------------------------------------------
|
||||
// AudioMasterCallback opcodes (plugin -> host)
|
||||
enum VstAudioMasterOpcodes
|
||||
{
|
||||
audioMasterAutomate = 0,
|
||||
audioMasterVersion = 1,
|
||||
audioMasterCurrentId = 2,
|
||||
audioMasterIdle = 3,
|
||||
audioMasterWantMidi = 6,
|
||||
audioMasterGetTime = 7,
|
||||
audioMasterProcessEvents = 8,
|
||||
audioMasterGetVendorString = 14,
|
||||
audioMasterGetProductString = 15,
|
||||
audioMasterGetVendorVersion = 16,
|
||||
audioMasterCanDo = 19,
|
||||
audioMasterGetLanguage = 20,
|
||||
audioMasterGetDirectory = 21,
|
||||
audioMasterUpdateDisplay = 22,
|
||||
audioMasterGetSampleRate = 35,
|
||||
audioMasterGetBlockSize = 36,
|
||||
audioMasterGetInputLatency = 29,
|
||||
audioMasterGetOutputLatency = 30
|
||||
};
|
||||
|
||||
//------------------------------------------------------------------------
|
||||
// MIDI events qua effProcessEvents (không dùng giao diện MIDI cũ của AEffect)
|
||||
struct VstEvent
|
||||
{
|
||||
int32 type;
|
||||
int32 byteSize;
|
||||
int32 flags;
|
||||
intptr_t* data; // 4 bytes padding trên Win64 giữ layout
|
||||
};
|
||||
|
||||
struct VstMidiEvent
|
||||
{
|
||||
int32 type; // kVstMidiType
|
||||
int32 byteSize; // sizeof(VstMidiEvent)
|
||||
int32 deltaFrames;
|
||||
int32 flags; // kVstMidiEventIsRealtime
|
||||
int32 noteLength; // 0
|
||||
int32 noteOffset; // 0
|
||||
char midiData[4]; // status, data1, data2, pad
|
||||
char detune;
|
||||
char noteOffVelocity;
|
||||
char reserved1;
|
||||
char reserved2;
|
||||
};
|
||||
|
||||
struct VstEvents
|
||||
{
|
||||
int32 numEvents;
|
||||
intptr_t reserved;
|
||||
VstEvent* events[1];
|
||||
};
|
||||
|
||||
enum VstEventTypes
|
||||
{
|
||||
kVstMidiType = 1
|
||||
};
|
||||
|
||||
enum VstMidiEventFlags
|
||||
{
|
||||
kVstMidiEventIsRealtime = 1 << 0
|
||||
};
|
||||
|
||||
//------------------------------------------------------------------------
|
||||
// Editor rect (effEditGetRect)
|
||||
struct ERect
|
||||
{
|
||||
short top;
|
||||
short left;
|
||||
short bottom;
|
||||
short right;
|
||||
};
|
||||
|
||||
//------------------------------------------------------------------------
|
||||
// VST2 entry points (typedef names khác tên export để tránh redefinition)
|
||||
typedef AEffect* (VSTCALLBACK* VSTPluginMainFn)(void* audioMasterCallback);
|
||||
typedef AEffect* (VSTCALLBACK* VSTPluginMainOldFn)(void* audioMasterCallback, void* reserved);
|
||||
typedef VstIntPtr (VSTCALLBACK* audioMasterCallbackFunc)(AEffect* effect, int32 opcode, int32 index, VstIntPtr value, void* ptr, float opt);
|
||||
@@ -0,0 +1,587 @@
|
||||
// vst3_host_bridge.cpp — VST3 GUI bridge (T9, spec vsti_gui)
|
||||
//
|
||||
// DLL export C API để attach VST3 editor vào HWND cha (cửa sổ nổi `vst_gui_*`
|
||||
// từ T8). Flow chuẩn hosting: Module::create(path) → PlugProvider →
|
||||
// getController → createView(kEditor) → getSize → attached(hwnd,
|
||||
// kPlatformTypeHWND). Đóng: view->removed() + release (IPtr).
|
||||
//
|
||||
// QUYẾT ĐỊNH (T9): VST3 SDK THUẦN (không JUCE) — nhẹ, license BSD-3 sạch;
|
||||
// T10 VST2 dùng vestige.h clean-room riêng; T12 native audio loop cũng SDK
|
||||
// thuần. Nâng cấp nếu cần: gộp engine bằng JUCE khi có yêu cầu rõ ràng.
|
||||
//
|
||||
// T11 sẽ mở rộng ComponentHandler::performEdit → đẩy lên JS (vst_param_changed);
|
||||
// hiện tại no-op để plugin không crash khi user chỉnh param trong editor.
|
||||
#define NOMINMAX
|
||||
#define WIN32_LEAN_AND_MEAN
|
||||
#include <windows.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <map>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
|
||||
#include "public.sdk/source/vst/hosting/module.h"
|
||||
#include "public.sdk/source/vst/hosting/plugprovider.h"
|
||||
#include "public.sdk/source/common/commonstringconvert.h"
|
||||
#include "pluginterfaces/base/funknown.h"
|
||||
#include "pluginterfaces/gui/iplugview.h"
|
||||
#include "pluginterfaces/vst/ivstaudioprocessor.h"
|
||||
#include "pluginterfaces/vst/ivstcomponent.h"
|
||||
#include "pluginterfaces/vst/ivsteditcontroller.h"
|
||||
#include "pluginterfaces/vst/ivstevents.h"
|
||||
|
||||
#include "AudioEngine.h"
|
||||
#include "NativeMixer.h"
|
||||
|
||||
#include <atomic>
|
||||
|
||||
using namespace Steinberg;
|
||||
using namespace Steinberg::Vst;
|
||||
|
||||
namespace {
|
||||
|
||||
// Callback param: plugin đổi param trong editor → đẩy lên host (T11).
|
||||
// handle = instance handle; paramId = ParamID; valueNormalized 0..1.
|
||||
typedef void (__cdecl* SF_ParamChangedCallback)(int32 handle, int32 paramId,
|
||||
double valueNormalized, void* userdata);
|
||||
|
||||
// ComponentHandler tối thiểu — T11: performEdit → callback param sync.
|
||||
class ComponentHandler : public IComponentHandler
|
||||
{
|
||||
public:
|
||||
ComponentHandler () = default;
|
||||
tresult PLUGIN_API queryInterface (const TUID _iid, void** obj) override
|
||||
{
|
||||
if (FUnknownPrivate::iidEqual (_iid, IComponentHandler::iid) ||
|
||||
FUnknownPrivate::iidEqual (_iid, FUnknown::iid))
|
||||
{
|
||||
*obj = this;
|
||||
addRef ();
|
||||
return kResultOk;
|
||||
}
|
||||
*obj = nullptr;
|
||||
return kNoInterface;
|
||||
}
|
||||
uint32 PLUGIN_API addRef () override { return 1; }
|
||||
uint32 PLUGIN_API release () override { return 1; }
|
||||
tresult PLUGIN_API beginEdit (ParamID /*id*/) override { return kResultOk; }
|
||||
tresult PLUGIN_API performEdit (ParamID id, ParamValue valueNormalized) override
|
||||
{
|
||||
if (cb)
|
||||
cb (handle, static_cast<int32> (id), static_cast<double> (valueNormalized), userdata);
|
||||
return kResultOk;
|
||||
}
|
||||
tresult PLUGIN_API endEdit (ParamID /*id*/) override { return kResultOk; }
|
||||
tresult PLUGIN_API restartComponent (int32 /*flags*/) override { return kResultOk; }
|
||||
|
||||
void setCallback (int32 h, SF_ParamChangedCallback c, void* u)
|
||||
{
|
||||
handle = h;
|
||||
cb = c;
|
||||
userdata = u;
|
||||
}
|
||||
|
||||
private:
|
||||
int32 handle = 0;
|
||||
SF_ParamChangedCallback cb = nullptr;
|
||||
void* userdata = nullptr;
|
||||
};
|
||||
|
||||
struct Instance
|
||||
{
|
||||
VST3::Hosting::Module::Ptr module;
|
||||
IPtr<PlugProvider> plugProvider;
|
||||
IPtr<IComponent> component;
|
||||
IPtr<IAudioProcessor> processor;
|
||||
IPtr<IEditController> controller;
|
||||
IPtr<IPlugView> view;
|
||||
ComponentHandler handler;
|
||||
sonicforge::AudioEngine audio; // T12: native audio loop (SPSC + WASAPI)
|
||||
std::atomic<bool> audioRunning {false};
|
||||
sonicforge::NativeMixer mixer; // T13: track gain/pan + master limiter
|
||||
};
|
||||
|
||||
// T12: IEventList cố định (không cấp phát trên audio thread).
|
||||
class FixedEventList : public IEventList
|
||||
{
|
||||
public:
|
||||
FixedEventList () = default;
|
||||
tresult PLUGIN_API queryInterface (const TUID _iid, void** obj) override
|
||||
{
|
||||
if (FUnknownPrivate::iidEqual (_iid, IEventList::iid) ||
|
||||
FUnknownPrivate::iidEqual (_iid, FUnknown::iid))
|
||||
{
|
||||
*obj = this;
|
||||
addRef ();
|
||||
return kResultOk;
|
||||
}
|
||||
*obj = nullptr;
|
||||
return kNoInterface;
|
||||
}
|
||||
uint32 PLUGIN_API addRef () override { return 1; }
|
||||
uint32 PLUGIN_API release () override { return 1; }
|
||||
int32 PLUGIN_API getEventCount () override { return count; }
|
||||
tresult PLUGIN_API getEvent (int32 index, Event& e) override
|
||||
{
|
||||
if (index < 0 || index >= count)
|
||||
return kInvalidArgument;
|
||||
e = events[index];
|
||||
return kResultOk;
|
||||
}
|
||||
tresult PLUGIN_API addEvent (Event& e) override
|
||||
{
|
||||
if (count >= 512)
|
||||
return kOutOfMemory;
|
||||
events[count++] = e;
|
||||
return kResultOk;
|
||||
}
|
||||
|
||||
Event events[512];
|
||||
int32 count = 0;
|
||||
};
|
||||
|
||||
// T12: audio thread callback — build EventList + process(). Render thread:
|
||||
// KHÔNG lock, KHÔNG cấp phát (EventList cố định, buffer pre-alloc).
|
||||
bool vst3AudioProcess (const sonicforge::MidiEvent* events, int32 eventCount,
|
||||
float* outL, float* outR, int32 frames, void* userdata)
|
||||
{
|
||||
Instance* inst = static_cast<Instance*> (userdata);
|
||||
IAudioProcessor* proc = inst ? inst->processor.get () : nullptr;
|
||||
if (!proc)
|
||||
return false;
|
||||
|
||||
FixedEventList list;
|
||||
for (int32 i = 0; i < eventCount; ++i)
|
||||
{
|
||||
Event e = {};
|
||||
e.sampleOffset = 0;
|
||||
e.ppqPosition = 0.0;
|
||||
e.flags = 0;
|
||||
if (events[i].noteOn)
|
||||
{
|
||||
e.type = Event::kNoteOnEvent;
|
||||
e.noteOn.channel = events[i].channel;
|
||||
e.noteOn.pitch = events[i].pitch;
|
||||
e.noteOn.velocity = events[i].velocity;
|
||||
e.noteOn.noteId = -1;
|
||||
}
|
||||
else
|
||||
{
|
||||
e.type = Event::kNoteOffEvent;
|
||||
e.noteOff.channel = events[i].channel;
|
||||
e.noteOff.pitch = events[i].pitch;
|
||||
e.noteOff.velocity = 0.0f;
|
||||
e.noteOff.noteId = -1;
|
||||
}
|
||||
list.addEvent (e);
|
||||
}
|
||||
|
||||
ProcessData data = {};
|
||||
data.processMode = kRealtime;
|
||||
data.symbolicSampleSize = kSample32;
|
||||
data.numSamples = frames;
|
||||
data.numInputs = 0;
|
||||
data.numOutputs = 1;
|
||||
AudioBusBuffers outBus = {};
|
||||
float* chans[2] = { outL, outR };
|
||||
outBus.numChannels = 2;
|
||||
outBus.channelBuffers32 = chans;
|
||||
data.outputs = &outBus;
|
||||
data.inputEvents = &list;
|
||||
|
||||
const tresult res = proc->process (data);
|
||||
inst->mixer.processInPlace (outL, outR, frames); // T13: gain/pan + limiter
|
||||
return res == kResultOk;
|
||||
}
|
||||
|
||||
std::mutex g_mutex;
|
||||
std::map<int32, std::unique_ptr<Instance>> g_instances;
|
||||
int32 g_next_handle = 1;
|
||||
|
||||
void set_err (char* err, int32 err_cap, const char* msg)
|
||||
{
|
||||
if (err && err_cap > 0)
|
||||
{
|
||||
std::snprintf (err, static_cast<size_t> (err_cap), "%s", msg);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
extern "C" {
|
||||
|
||||
// Attach VST3 editor vào parent_hwnd. module_path_utf8: đường dẫn tới .vst3
|
||||
// (UTF-8). plugin_name: tên class VST3 (ClassInfo::name — khớp plugin_id JS).
|
||||
// Trả handle (>= 1) hoặc 0 + err. out_w/out_h: kích thước ưa thích của editor.
|
||||
__declspec (dllexport) int32 SF_VST3_Attach (const char* module_path_utf8,
|
||||
const char* plugin_name,
|
||||
HWND parent_hwnd,
|
||||
int32* out_w,
|
||||
int32* out_h,
|
||||
char* err,
|
||||
int32 err_cap)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock (g_mutex);
|
||||
if (!module_path_utf8 || !plugin_name || !parent_hwnd)
|
||||
{
|
||||
set_err (err, err_cap, "null arg");
|
||||
return 0;
|
||||
}
|
||||
auto inst = std::make_unique<Instance> ();
|
||||
|
||||
std::string loadError;
|
||||
inst->module = VST3::Hosting::Module::create (module_path_utf8, loadError);
|
||||
if (!inst->module)
|
||||
{
|
||||
set_err (err, err_cap, "cannot load VST3 module");
|
||||
return 0;
|
||||
}
|
||||
|
||||
VST3::Hosting::ClassInfo classInfo;
|
||||
bool found = false;
|
||||
auto factory = inst->module->getFactory ();
|
||||
for (auto& ci : factory.classInfos ())
|
||||
{
|
||||
if (ci.category () == kVstAudioEffectClass && ci.name () == plugin_name)
|
||||
{
|
||||
classInfo = ci;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!found)
|
||||
{
|
||||
set_err (err, err_cap, "no VST3 audio effect class with that name");
|
||||
return 0;
|
||||
}
|
||||
|
||||
inst->plugProvider = IPtr<PlugProvider> (new PlugProvider (factory, classInfo, true));
|
||||
if (!inst->plugProvider->initialize ())
|
||||
{
|
||||
set_err (err, err_cap, "plugin initialize failed");
|
||||
return 0;
|
||||
}
|
||||
|
||||
inst->component = inst->plugProvider->getComponent (); // giữ ref
|
||||
if (!inst->component)
|
||||
{
|
||||
set_err (err, err_cap, "plugin has no component");
|
||||
return 0;
|
||||
}
|
||||
IAudioProcessor* rawProc = nullptr;
|
||||
if (inst->component->queryInterface (IAudioProcessor::iid,
|
||||
reinterpret_cast<void**> (&rawProc)) != kResultOk ||
|
||||
!rawProc)
|
||||
{
|
||||
set_err (err, err_cap, "plugin has no audio processor");
|
||||
return 0;
|
||||
}
|
||||
inst->processor = IPtr<IAudioProcessor> (rawProc);
|
||||
|
||||
IEditController* rawController = inst->plugProvider->getController (); // +1 ref
|
||||
if (!rawController)
|
||||
{
|
||||
set_err (err, err_cap, "plugin has no edit controller");
|
||||
return 0;
|
||||
}
|
||||
inst->controller = IPtr<IEditController> (rawController); // nhận +1
|
||||
|
||||
inst->controller->setComponentHandler (&inst->handler);
|
||||
|
||||
inst->view = inst->controller->createView (ViewType::kEditor);
|
||||
if (!inst->view)
|
||||
{
|
||||
set_err (err, err_cap, "plugin has no editor view");
|
||||
return 0;
|
||||
}
|
||||
|
||||
ViewRect r {};
|
||||
if (inst->view->getSize (&r) == kResultTrue)
|
||||
{
|
||||
if (out_w)
|
||||
*out_w = r.getWidth ();
|
||||
if (out_h)
|
||||
*out_h = r.getHeight ();
|
||||
}
|
||||
|
||||
if (inst->view->attached ((void*) parent_hwnd, kPlatformTypeHWND) != kResultTrue)
|
||||
{
|
||||
set_err (err, err_cap, "view attach failed");
|
||||
return 0;
|
||||
}
|
||||
|
||||
int32 handle = g_next_handle++;
|
||||
g_instances[handle] = std::move (inst);
|
||||
return handle;
|
||||
}
|
||||
|
||||
// Đóng editor: removed() + release (IPtr tự release khi xóa Instance).
|
||||
__declspec (dllexport) int32 SF_VST3_Close (int32 handle, char* err, int32 err_cap)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock (g_mutex);
|
||||
auto it = g_instances.find (handle);
|
||||
if (it == g_instances.end ())
|
||||
{
|
||||
set_err (err, err_cap, "bad handle");
|
||||
return -1;
|
||||
}
|
||||
// T12: dung audio loop (join render thread) truoc khi pha huy plugin.
|
||||
it->second->audioRunning.store (false, std::memory_order_release);
|
||||
it->second->audio.stop ();
|
||||
if (it->second->processor)
|
||||
it->second->processor->setProcessing (false);
|
||||
if (it->second->view)
|
||||
{
|
||||
it->second->view->removed ();
|
||||
it->second->view = nullptr;
|
||||
}
|
||||
g_instances.erase (it);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Kích thước ưa thích hiện tại của editor.
|
||||
__declspec (dllexport) int32 SF_VST3_GetSize (int32 handle, int32* out_w, int32* out_h)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock (g_mutex);
|
||||
auto it = g_instances.find (handle);
|
||||
if (it == g_instances.end ())
|
||||
return -1;
|
||||
ViewRect r {};
|
||||
if (it->second->view && it->second->view->getSize (&r) == kResultTrue)
|
||||
{
|
||||
if (out_w)
|
||||
*out_w = r.getWidth ();
|
||||
if (out_h)
|
||||
*out_h = r.getHeight ();
|
||||
return 0;
|
||||
}
|
||||
return -2;
|
||||
}
|
||||
|
||||
// Báo view: parent HWND đã được resize (w,h) — plugin cập nhật nội dung.
|
||||
__declspec (dllexport) int32 SF_VST3_Resize (int32 handle, int32 w, int32 h)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock (g_mutex);
|
||||
auto it = g_instances.find (handle);
|
||||
if (it == g_instances.end ())
|
||||
return -1;
|
||||
if (it->second->view)
|
||||
{
|
||||
ViewRect r = {};
|
||||
r.right = w;
|
||||
r.bottom = h;
|
||||
if (it->second->view->onSize (&r) == kResultTrue)
|
||||
return 0;
|
||||
}
|
||||
return -2;
|
||||
}
|
||||
|
||||
// Đăng ký callback param (T11): plugin đổi param trong editor → cb(handle,
|
||||
// paramId, valueNormalized, userdata). userdata là con trỏ do host giữ.
|
||||
__declspec (dllexport) int32 SF_VST3_SetParamCallback (int32 handle,
|
||||
SF_ParamChangedCallback cb,
|
||||
void* userdata)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock (g_mutex);
|
||||
auto it = g_instances.find (handle);
|
||||
if (it == g_instances.end ())
|
||||
return -1;
|
||||
it->second->handler.setCallback (handle, cb, userdata);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// JS automation → setParamNormalized (T11).
|
||||
__declspec (dllexport) int32 SF_VST3_SetParam (int32 handle, int32 paramId, double valueNormalized)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock (g_mutex);
|
||||
auto it = g_instances.find (handle);
|
||||
if (it == g_instances.end ())
|
||||
return -1;
|
||||
if (!it->second->controller)
|
||||
return -2;
|
||||
it->second->controller->setParamNormalized (static_cast<ParamID> (paramId),
|
||||
static_cast<ParamValue> (valueNormalized));
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Số tham số plugin (để JS dựng UI param list).
|
||||
__declspec (dllexport) int32 SF_VST3_GetParamCount (int32 handle)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock (g_mutex);
|
||||
auto it = g_instances.find (handle);
|
||||
if (it == g_instances.end ())
|
||||
return -1;
|
||||
if (!it->second->controller)
|
||||
return -2;
|
||||
return static_cast<int32> (it->second->controller->getParameterCount ());
|
||||
}
|
||||
|
||||
// Thông tin param thứ index (0-based): id + tên (title) + giá trị normalized.
|
||||
// Trả 0 nếu OK; -1 bad handle; -2 no controller; -3 index ngoài phạm vi.
|
||||
__declspec (dllexport) int32 SF_VST3_GetParamInfo (int32 handle, int32 index,
|
||||
int32* out_id, char* out_title,
|
||||
int32 title_cap, double* out_value)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock (g_mutex);
|
||||
auto it = g_instances.find (handle);
|
||||
if (it == g_instances.end ())
|
||||
return -1;
|
||||
IEditController* ctrl = it->second->controller;
|
||||
if (!ctrl)
|
||||
return -2;
|
||||
if (index < 0 || static_cast<uint32> (index) >= ctrl->getParameterCount ())
|
||||
return -3;
|
||||
ParameterInfo info = {};
|
||||
if (ctrl->getParameterInfo (static_cast<int32> (index), info) != kResultTrue)
|
||||
return -3;
|
||||
if (out_id)
|
||||
*out_id = static_cast<int32> (info.id);
|
||||
if (out_title && title_cap > 0)
|
||||
{
|
||||
std::string titleUtf8 = StringConvert::convert (std::u16string (info.title));
|
||||
std::strncpy (out_title, titleUtf8.c_str (), static_cast<size_t> (title_cap - 1));
|
||||
out_title[title_cap - 1] = '\0';
|
||||
}
|
||||
if (out_value)
|
||||
*out_value = static_cast<double> (ctrl->getParamNormalized (info.id));
|
||||
return 0;
|
||||
}
|
||||
|
||||
// T12: bat native audio loop (SPSC + WASAPI) cho instance nay. Audio thread
|
||||
// goi vst3AudioProcess (EventList + process) moi block.
|
||||
__declspec (dllexport) int32 SF_VST3_AudioStart (int32 handle, int32 sampleRate,
|
||||
int32 blockSize, char* err, int32 err_cap)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock (g_mutex);
|
||||
auto it = g_instances.find (handle);
|
||||
if (it == g_instances.end ())
|
||||
return -1;
|
||||
Instance* inst = it->second.get ();
|
||||
if (!inst->processor)
|
||||
return -2;
|
||||
|
||||
ProcessSetup setup = {};
|
||||
setup.processMode = kRealtime;
|
||||
setup.symbolicSampleSize = kSample32;
|
||||
setup.maxSamplesPerBlock = blockSize;
|
||||
setup.sampleRate = static_cast<SampleRate> (sampleRate);
|
||||
inst->processor->setupProcessing (setup);
|
||||
inst->processor->setProcessing (true);
|
||||
|
||||
inst->audioRunning.store (true, std::memory_order_release);
|
||||
if (!inst->audio.start (sampleRate, blockSize, &vst3AudioProcess, inst, err, err_cap))
|
||||
{
|
||||
inst->audioRunning.store (false, std::memory_order_release);
|
||||
inst->processor->setProcessing (false);
|
||||
return -3;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
__declspec (dllexport) int32 SF_VST3_AudioStop (int32 handle)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock (g_mutex);
|
||||
auto it = g_instances.find (handle);
|
||||
if (it == g_instances.end ())
|
||||
return -1;
|
||||
it->second->audioRunning.store (false, std::memory_order_release);
|
||||
it->second->audio.stop ();
|
||||
if (it->second->processor)
|
||||
it->second->processor->setProcessing (false);
|
||||
return 0;
|
||||
}
|
||||
|
||||
__declspec (dllexport) int32 SF_VST3_AudioUnderruns (int32 handle)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock (g_mutex);
|
||||
auto it = g_instances.find (handle);
|
||||
if (it == g_instances.end ())
|
||||
return -1;
|
||||
return it->second->audio.underruns ();
|
||||
}
|
||||
|
||||
__declspec (dllexport) int32 SF_VST3_AudioLatency (int32 handle)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock (g_mutex);
|
||||
auto it = g_instances.find (handle);
|
||||
if (it == g_instances.end ())
|
||||
return -1;
|
||||
return it->second->audio.latencySamples ();
|
||||
}
|
||||
|
||||
__declspec (dllexport) int32 SF_VST3_AudioBlocks (int32 handle)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock (g_mutex);
|
||||
auto it = g_instances.find (handle);
|
||||
if (it == g_instances.end ())
|
||||
return -1;
|
||||
return it->second->audio.blocksRendered ();
|
||||
}
|
||||
|
||||
// T12: MIDI → SPSC (audio thread xu ly). Khong co duong direct nhu VST2 —
|
||||
// offline render (T16) se drain SPSC rieng.
|
||||
__declspec (dllexport) int32 SF_VST3_SendNoteOn (int32 handle, int32 channel, int32 pitch,
|
||||
int32 velocity)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock (g_mutex);
|
||||
auto it = g_instances.find (handle);
|
||||
if (it == g_instances.end ())
|
||||
return -1;
|
||||
sonicforge::MidiEvent ev = {};
|
||||
ev.channel = channel;
|
||||
ev.pitch = pitch;
|
||||
ev.velocity = static_cast<float> (velocity) / 127.0f;
|
||||
ev.noteOn = true;
|
||||
return it->second->audio.pushMidi (ev) ? 0 : -2;
|
||||
}
|
||||
|
||||
__declspec (dllexport) int32 SF_VST3_SendNoteOff (int32 handle, int32 channel, int32 pitch)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock (g_mutex);
|
||||
auto it = g_instances.find (handle);
|
||||
if (it == g_instances.end ())
|
||||
return -1;
|
||||
sonicforge::MidiEvent ev = {};
|
||||
ev.channel = channel;
|
||||
ev.pitch = pitch;
|
||||
ev.velocity = 0.0f;
|
||||
ev.noteOn = false;
|
||||
return it->second->audio.pushMidi (ev) ? 0 : -2;
|
||||
}
|
||||
|
||||
// T13: track gain/pan + master limiter (NativeMixer). Set truoc AudioStart;
|
||||
// audio thread doc state nay (khong lock).
|
||||
__declspec (dllexport) int32 SF_VST3_SetTrackGainPan (int32 handle, float gainDb, float pan)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock (g_mutex);
|
||||
auto it = g_instances.find (handle);
|
||||
if (it == g_instances.end ())
|
||||
return -1;
|
||||
it->second->mixer.setTrack (sonicforge::TrackGainPan::fromDbPan (gainDb, pan));
|
||||
return 0;
|
||||
}
|
||||
|
||||
__declspec (dllexport) int32 SF_VST3_SetMasterLimiter (int32 handle, int32 active, float thresholdDb)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock (g_mutex);
|
||||
auto it = g_instances.find (handle);
|
||||
if (it == g_instances.end ())
|
||||
return -1;
|
||||
it->second->mixer.setLimiter (active != 0, thresholdDb);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// T15: master fader — mirror masterBus.output.gain WebAudio (sau limiter+clip).
|
||||
__declspec (dllexport) int32 SF_VST3_SetMasterGain (int32 handle, float gainLinear)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock (g_mutex);
|
||||
auto it = g_instances.find (handle);
|
||||
if (it == g_instances.end ())
|
||||
return -1;
|
||||
it->second->mixer.setMasterGain (gainLinear);
|
||||
return 0;
|
||||
}
|
||||
|
||||
} // extern "C"
|
||||
Generated
+239
-4
@@ -13,6 +13,9 @@
|
||||
"jsdom": "^30.0.1",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.11.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/css-color": {
|
||||
@@ -608,6 +611,238 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.11.4.tgz",
|
||||
"integrity": "sha512-R8xGtMpwyetawSqm9kYOuMmEqkhUbvcUy8n0aNXIxollKBLESUu5f4Fx+64hgASYm1H+jSWq6jCW6zqTnH6hqQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"bin": {
|
||||
"tauri": "tauri.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/tauri"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@tauri-apps/cli-darwin-arm64": "2.11.4",
|
||||
"@tauri-apps/cli-darwin-x64": "2.11.4",
|
||||
"@tauri-apps/cli-linux-arm-gnueabihf": "2.11.4",
|
||||
"@tauri-apps/cli-linux-arm64-gnu": "2.11.4",
|
||||
"@tauri-apps/cli-linux-arm64-musl": "2.11.4",
|
||||
"@tauri-apps/cli-linux-riscv64-gnu": "2.11.4",
|
||||
"@tauri-apps/cli-linux-x64-gnu": "2.11.4",
|
||||
"@tauri-apps/cli-linux-x64-musl": "2.11.4",
|
||||
"@tauri-apps/cli-win32-arm64-msvc": "2.11.4",
|
||||
"@tauri-apps/cli-win32-ia32-msvc": "2.11.4",
|
||||
"@tauri-apps/cli-win32-x64-msvc": "2.11.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-darwin-arm64": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.11.4.tgz",
|
||||
"integrity": "sha512-1ryOF3ZhpZ/nemHV5zVwBQBz9jDGKmKPvWPADOhc83ig0P4bMc2iER4NbC6r9sjeIZ6RVQ4g3RZIYvezhcl4TQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-darwin-x64": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.11.4.tgz",
|
||||
"integrity": "sha512-uFsGQAAfuyz1k/yGLmkWfkBlgKAqZfxqlHmLWx81QU27RJWfmbNHCIq8T8w1e+VClleIuZUjpHWfoE4E3DLo3A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-arm-gnueabihf": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.11.4.tgz",
|
||||
"integrity": "sha512-IaHZn5CdBL21oUmjiVOS1ctw6Ip1O0pjp70FwOWmYz1myWe0SY96ZIj2FYf7pT0m8bI2h/hrs5ZbEXXh44/MkQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-arm64-gnu": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.11.4.tgz",
|
||||
"integrity": "sha512-N41/ukTRVe6XSuUTESuFdGeOW2i7k62tK+6gHK5Kd5/q5RPvvi19GaWAVPPb9u95HSGmTChSolBfzynUsssFaA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-arm64-musl": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.11.4.tgz",
|
||||
"integrity": "sha512-v277UnT/fB64xAfSroL5N3Km3tLmvATWqJJw/wRI+g6o+HkeD0slyE7gOhNs1MbjE41R7bQOTxMVoL3aomUJmw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-riscv64-gnu": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.11.4.tgz",
|
||||
"integrity": "sha512-qqgNkQ2u1yZHxjhxsZaxUtRDW8dIqIYm33rx/mzwQv0SfY9x1B+iraj8vWeFiXjjSVVhEMepXSOts1TqPzvXNQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-x64-gnu": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.11.4.tgz",
|
||||
"integrity": "sha512-2VRNWl84FOH0m2giiDkO2h0QXlcMJeX+zJDpI5kDIQAx6s+geF3v48F4DXfJez4GS/FdoDGnPnw1C2iYGbQ7bQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-x64-musl": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.11.4.tgz",
|
||||
"integrity": "sha512-o9GyhYor/nc7xarmwDE3ka2szuW3uuZzXjHWh64Q8YX5AtSgxdQkFWzrY4O8KiGtVNvFBI14H3Q49Qj5TOIP/A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-win32-arm64-msvc": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.11.4.tgz",
|
||||
"integrity": "sha512-ld5Ehb598m0VkYyylRPNeCFsBe/km0jxis6KgMpl3IGY6I/i1RwQXO05I1AsXUXO2WC6AvB/Lw4qTf/asiuEiQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-win32-ia32-msvc": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.11.4.tgz",
|
||||
"integrity": "sha512-12Hxi0XX/H5VFxO/bGgHkFWhml9VMgEOu9CidjeCeTNQ1l6fpUlbiGgSP7CLI3PFtW9/FfbeHieZ+kyWK5H7CA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-win32-x64-msvc": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.4.tgz",
|
||||
"integrity": "sha512-+vDiqBIU5dMISg/wNvX3sF+ZHfgJGJ5T0AcO+EHNXV9GGAG+P5fzodlDXD3QdKCRgZxMoCm5PPvj3BqLNjBthw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/gensync": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/gensync/-/gensync-1.0.5.tgz",
|
||||
@@ -651,15 +886,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "5.0.7",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
|
||||
"integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
|
||||
"version": "5.0.9",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
|
||||
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/browserslist": {
|
||||
|
||||
@@ -12,5 +12,8 @@
|
||||
"jsdom": "^30.0.1",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.11.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
# PLAN — Standalone playback 100% client-side WASM (no Carla dependency)
|
||||
|
||||
Goal: fix "mastering fx chain không xử lí" + "vsti không qua main out" bằng cách
|
||||
chuyển live playback/preview của VSTi sang WASM (autosampled SF2 qua FluidSynth
|
||||
WASM → track sfEntry → FX chain → masterBus mastering → main out). Export WAV
|
||||
giữ nguyên backend Python (pedalboard VST3 + FluidSynth C++).
|
||||
|
||||
## 1. Server — autosample endpoint (VSTi → SF2)
|
||||
- `tools/autosample_vsti.py`: tách core thành `autosample_sf2(...)` callable
|
||||
(giữ `main()` argparse cho CLI).
|
||||
- `app/api/v1/plugins.py`: `AutosampleRequest` + `POST /autosample` — render
|
||||
preset qua từng note bằng pedalboard (đúng plugin+preset như export) → SF2
|
||||
16-bit mono vào `app/storage/soundfonts/{uuid}.sf2` + `.meta` → trả `sf_id`.
|
||||
- Chỉ SF2 (không SF3): client WASM load SF2 trực tiếp, endpoint download ưu
|
||||
tiên SF2 sẵn có. 30-60 note ≈ 10-20MB — chấp nhận, IndexedDB cache.
|
||||
ponytail: SF3 (OGG nhỏ hơn) khi cần size nhỏ — thêm `--sf3` vào endpoint.
|
||||
|
||||
## 2. Server — mastering FX chain trong render_project
|
||||
- `app/core/mastering_engine.py` (mới): `apply_mastering(buffer, settings, fs)`
|
||||
tái hiện chain WebAudio client (initMasterBus + applyMasteringSettings):
|
||||
- `eq` (lowshelf 100 / peaking 822 Q0.7 / peaking 3200 Q1.2 / highshelf 10k)
|
||||
- `imager` (M/S width 4 band: 20-100 / 100-1k / 1k-6k / 6k-20k)
|
||||
- `maximizer` (boost → softclip atan → +upward comp → ceiling hard clip)
|
||||
- `compressor` (threshold/ratio/knee/makeup)
|
||||
- `limiter` (tanh soft clip)
|
||||
- `exciter` (hp 2k + saturator, dry/wet)
|
||||
- `rebalance` (M/S gains qua L/R crossfeed)
|
||||
- chain order từ `settings['chain']`, gate `masterConnected && !isBypassed`.
|
||||
- `app/core/render_engine.py`: `render_project` đọc `project_json['mastering_settings']`
|
||||
→ áp lên master_buffer trước `sf.write`; khi có mastering → clip [-1,1]
|
||||
(giống WAV encoder client), không mastering → normalize như cũ.
|
||||
|
||||
## 3. Client — VSTi live playback qua WASM autosample
|
||||
- `app/static/js/services/api.js`: thêm `autosampleVsti(payload)`.
|
||||
- `app/static/js/services/vstiAutosample.js` (mới): `SonicVstiAutosample` —
|
||||
`ensure(synthEngine)` POST autosample 1 lần, cache map plugin_id+preset →
|
||||
sf_id (localStorage), `sfIdFor` sync lookup.
|
||||
- `app/static/js/services/soundfontPlayer.js`: `_playNoteFluid` +
|
||||
`applyAITrackInstrument` fallback `synthEngine.autosampled_sf_id` khi thiếu
|
||||
`soundfont_id`; VSTi chưa sample → ensure rồi defer note (pendingNoteOns có sẵn).
|
||||
- `app/static/js/services/runtime.js`: `SonicCarlaMidi.shouldRoute` /
|
||||
`shouldRoutePlayback` trả false trừ khi `window.__enableCarlaLivePlayback`
|
||||
(mặc định off) — playback không phụ thuộc Carla nữa.
|
||||
- `app/static/js/app.jsx`:
|
||||
- `shouldRouteCarla` → false (VSTi đi qua SonicSF → masterBus → main out).
|
||||
- chọn instrument VSTi (applyTrackInstrument) → fire-and-forget
|
||||
`SonicVstiAutosample.ensure(synth_engine)`.
|
||||
- timeline/local-loop/ghost/preview: VSTi đã chạy nhánh `SonicSF.playNote`
|
||||
sẵn (isSfTrackEngine false → else-if); autosampled_sf_id làm nó kêu đúng âm.
|
||||
- `ensureMidiCapture` đã capture `node.sfEntry` → VSTi vào midiCache →
|
||||
clientSideExport (offline mix qua track FX + mastering) hoạt động cho VSTi.
|
||||
- `app/templates/index.html`: script tag `vstiAutosample.js`.
|
||||
- Build: `npm run build` (app.jsx → app.precompiled.js).
|
||||
|
||||
## 4. Verify
|
||||
- pytest `tests/` (kỳ vọng 108 pass / 1 skip / 1 env-specific fail như cũ).
|
||||
- Test mastering: render project JSON có mastering_settings (chain eq+imager+
|
||||
maximizer) → assert output peak ≤ ceiling & khác bản không mastering.
|
||||
- Test autosample: gọi `autosample_sf2` với VSTi (Nexus.vst3) → sf2utils parse
|
||||
sạch; endpoint return sf_id + file tồn tại.
|
||||
- Manual (browser): track VSTi → Preview/Play → âm qua masterBus (VU master
|
||||
nhảy), Export WAV server ra file có mastering.
|
||||
|
||||
## 5. Commit/push
|
||||
- Commit trên branch `standalone`. Push vẫn chờ user cấu hình git auth
|
||||
(credential.helper / token URL) — không retry khi chưa có.
|
||||
@@ -0,0 +1,202 @@
|
||||
# WALKTHROUGH — Thực thi tuần tự (SonicForgeStudio standalone)
|
||||
|
||||
> Nạp file này mỗi session mới. Làm ĐÚNG 1 task, cập nhật bảng trạng thái, commit, dừng.
|
||||
> Kiến trúc/lý do: `GIAI_PHAP.md`. File này chỉ chứa thao tác + trạng thái → context nhỏ.
|
||||
|
||||
## 1. Thông tin cố định (mọi session)
|
||||
|
||||
- Repo: `C:/Users/locpham/SonicForgeStudio` — branch: `standalone` — HEAD: `cadb540` (đã commit; **KHÔNG push**)
|
||||
- Build JS: `node node_modules/@babel/cli/bin/babel.js app/static/js/app.jsx --config-file ./babel.config.json -o app/static/js/app.precompiled.js`
|
||||
- Test: `python -m pytest tests/` → 110 passed, 1 skipped, 1 failed (`test_vst_engine.py::TestPluginManager::test_init` — env-fail Linux path, **bỏ qua, không sửa**)
|
||||
- Rust check: `cd src-tauri && cargo check` (nếu task C)
|
||||
- **CRLF rule**: file repo dùng CRLF. Đọc = `sed -n`/`grep` (không dùng read_file). Sửa = python: `s = open(p, encoding='utf-8', newline='').read()` → thay chuỗi `\n` → `s.replace('\n','\r\n')` → `open(p,'w',encoding='utf-8',newline='').write(s)`. Verify lại bằng grep/repr.
|
||||
- Commit rule: sau mỗi task xanh (build+test OK) → `git add` + commit `standalone`. Trước mỗi task: `git status` phải sạch.
|
||||
- Trình tự: A (T1–T3) → B (T4–T7) → C (T8–T15) → D (T16).
|
||||
|
||||
## 2. Trạng thái
|
||||
|
||||
| ID | Phase | Task | Trạng thái |
|
||||
| --- | --- | --- | --- |
|
||||
| T1 | A | Bug 1: token fix | [x] |
|
||||
| T2 | A | Bug 2: chain re-apply + sig order + neutral add | [x] |
|
||||
| T3 | A | Bug 3: autosample pre-warm + toast + re-sample | [x] |
|
||||
| T4 | B | `unifiedMidiRouter.js` | [x] |
|
||||
| T5 | B | `TrackInstrument` + keybed → router; xóa native preview/token | [x] |
|
||||
| T6 | B | Scheduler items → router | [x] |
|
||||
| T7 | B | Hardware MIDI → router | [x] |
|
||||
| T8 | C | Rust `vst_gui.rs` + `raw-window-handle` | [x] |
|
||||
| T9 | C | VST3 GUI bridge (`vst3_host_bridge.cpp` + DLL) | [x] |
|
||||
| T10 | C | VST2 engine (`VST2AudioEngine.cpp`, vestige.h) | [x] |
|
||||
| T11 | C | IPC param sync 2 chiều | [x] |
|
||||
| T12 | C | Native audio loop: SPSC + driver WASAPI/ASIO | [x] |
|
||||
| T13 | C | `NativeMixer` mirror `mastering_engine.py` | [x] |
|
||||
| T14 | C | Port FluidSynth native (hoặc loopback) | [x] |
|
||||
| T15 | C | Bỏ WebAudio master; IPC gain/pan; test matrix | [x] |
|
||||
| T16 | D | Autosample hoàn thiện (VST2 `.sf3`, preset, re-sample) | [x] |
|
||||
|
||||
Trạng thái dùng: `[ ]` chưa làm, `[▶]` đang làm/dang dở, `[x]` xong, `[!]` fail — ghi lỗi vào Ghi chú.
|
||||
|
||||
## 3. Tiếp tục sau fail
|
||||
|
||||
1. Đọc file này. Tìm task đầu tiên có `[ ]` hoặc `[▶]`.
|
||||
2. Nếu `[▶]`: đọc Ghi chú (lỗi đã ghi), sửa tiếp. Nếu `[ ]`: nạp đúng mục "Context tối thiểu" của task (chỉ đọc các file/line đó — không đọc cả app.jsx).
|
||||
3. Làm xong → Verify → build → pytest → cập nhật bảng `[x]` + Ghi chú 1 dòng → commit.
|
||||
4. Fail không gỡ được: cập nhật `[!]` + Ghi chú lỗi đầy đủ (command, output, đoạn code), dừng, báo user.
|
||||
|
||||
## 4. Các task
|
||||
|
||||
### T1 — Bug 1: token fix (Phase A)
|
||||
- **Mục tiêu**: bấm phím keyboard preview lần 2+ không câm.
|
||||
- **File**: `app/static/js/app.jsx`
|
||||
- **Context tối thiểu**: `grep -n '_sfWasmFallbackNote\|playNativeSfNote\|_nativeSfPreviews' app/static/js/app.jsx` → `_sfWasmFallbackNote` L213-236, `playNativeSfNote` L239-283.
|
||||
- **Bước**:
|
||||
1. Trong `_sfWasmFallbackNote`: stale check `_nativeSfPreviews[k].token !== token` → đổi thành `> token` (claim-then-compare) và LUÔN ghi đè `_nativeSfPreviews[k] = { wasm, token }`.
|
||||
2. Cùng pattern `!== token` ở path API-success (~L257): sửa giống hệt.
|
||||
- **Verify**: build babel; mở app; bấm cùng phím 2–5 lần liên tiếp → mọi lần đều có tiếng; bấm nhanh nhiều phím → không câm.
|
||||
- **Done khi**: không còn câm; không đổi hành vi khác.
|
||||
- **Rollback**: `git checkout -- app/static/js/app.jsx`
|
||||
- **Ghi chú**: Nếu T4–T5 đã xong (router xóa cơ chế này) → bỏ qua task này, đánh `[x]` kèm "đã xóa tận gốc ở T5".
|
||||
|
||||
### T2 — Bug 2: chain mastering (Phase A)
|
||||
- **Mục tiêu**: thêm/sắp xếp module mastering không làm volume nhỏ đi; reorder đúng thứ tự.
|
||||
- **File**: `app/static/js/app.jsx`
|
||||
- **Context tối thiểu**: `grep -n 'applyMasteringSettings\|_lastMasteringSig\|chainSignature\|addModuleToChain\|_buildLimCurve\|_buildMaxCurve\|toggleMasteringOnMaster' app/static/js/app.jsx` (L635+, L1097, L1486, L913-935, L12446).
|
||||
- **Bước**:
|
||||
1. `chainSignature` (L1097): order-sensitive — signature phải gồm thứ tự module (không phải `(type+active).join(',')` set-insensitive).
|
||||
2. `applyMasteringSettings` (L635+, early-return L642): bỏ phụ thuộc sig; mọi thay đổi chain → rebuild + re-apply TOÀN BỘ tham số (debounce ~50ms thay vì skip).
|
||||
3. Thêm module (limiter/compressor) qua `addModuleToChain`: neutral-then-apply — limiter threshold mặc định 0dB (không clamp −1dB), compressor makeup bù loss (hoặc threshold cao hơn), để "thêm module" không tự cắt volume.
|
||||
- **Verify**: build; thêm limiter → volume không giảm rõ rệt; thêm compressor → không giảm; reorder limiter↔compressor → nghe đổi thứ tự; master fader vẫn hoạt động đúng.
|
||||
- **Done khi**: 3 bước trên đúng; `python -m pytest tests/` xanh.
|
||||
- **Rollback**: `git checkout -- app/static/js/app.jsx`
|
||||
|
||||
### T3 — Bug 3: autosample UX (Phase A)
|
||||
- **Mục tiêu**: VSTi không autosample được phải hiện rõ, không âm thầm ra oscillator default.
|
||||
- **File**: `app/static/js/services/vstiAutosample.js`, `app/static/js/app.jsx` (nơi chọn instrument), util toast hiện có.
|
||||
- **Context tối thiểu**: `grep -rn 'SonicVstiAutosample\|ensure' app/static/js/` + `grep -n '_playNoteFallback' app/static/js/soundfontPlayer.js` (L787+).
|
||||
- **Bước**:
|
||||
1. `ensure()`: bắt lỗi (404/501/import fail) → trả về object `{ ok:false, reason }`, không throw âm thầm.
|
||||
2. UI: khi chọn instrument VSTi → gọi ensure NGAY (pre-warm) + progress indicator; note đầu tiên chờ ready (hoặc disable tới ready) — không rơi vào oscillator.
|
||||
3. Khi fail → toast "không autosample được VSTi: <reason>".
|
||||
4. Nút re-sample trong panel instrument.
|
||||
- **Verify**: chọn VSTi path sai → toast hiện reason, không ra âm oscillator; chọn VSTi hợp lệ → pre-warm xong, note đầu tiên ra đúng âm; bấm re-sample → chạy lại.
|
||||
- **Done khi**: không còn fail âm thầm; pre-warm hoạt động.
|
||||
- **Rollback**: `git checkout -- app/static/js/services/vstiAutosample.js app/static/js/app.jsx`
|
||||
|
||||
### T4 — `unifiedMidiRouter.js` (Phase B)
|
||||
- **Mục tiêu**: một điểm dispatch MIDI cho mọi nguồn (spec Part II).
|
||||
- **File mới**: `app/static/js/services/unifiedMidiRouter.js`
|
||||
- **Nội dung**: `UnifiedMidiEvent` (trackId, channel, command, pitch int 0-127, velocity int 1-127, sourceType, timestampNs) + `UnifiedMidiRouter` với `engineRegistry: Map<trackId, TrackInstrument>` (thay "1 engine toàn cục" của spec — route theo trackId); `dispatchMidiEvent`; `activeVoiceTracker` key `${channel}_${pitch}` chống stuck notes; `panicAllNotesOff()`.
|
||||
- **Verify**: `node --check app/static/js/services/unifiedMidiRouter.js`; viết 1 test assert nhỏ (pitch clamp, velocity normalize float→int, tracker đếm đúng, panic clear) — chạy bằng node.
|
||||
- **Done khi**: file + test chạy xanh; chưa nối vào app.
|
||||
- **Rollback**: xóa file.
|
||||
|
||||
### T5 — `TrackInstrument` + keybed → router (Phase B)
|
||||
- **Mục tiêu**: keyboard preview đi router; xóa cơ chế token/native.
|
||||
- **File**: `app/static/js/app.jsx`, có thể thêm `app/static/js/services/trackInstrument.js`
|
||||
- **Bước**:
|
||||
1. `TrackInstrument`: bọc FluidSynth channel per-track — `playNote(pitch, vel, dur)`; dùng cho cả preview lẫn items.
|
||||
2. Keybed `onMouseDown`/`onMouseUp` → router.dispatchMidiEvent (sourceType VIRTUAL_KEYBOARD).
|
||||
3. XÓA: `playNativeSfNote`, `_sfWasmFallbackNote`, `_nativeSfPreviews`, mọi `__nativeSfOk`/token logic.
|
||||
- **Verify**: bấm phím liên tiếp 10 lần → không câm; không còn `_nativeSfPreviews` trong code (`grep`); build OK.
|
||||
- **Done khi**: Bug 1 hết tận gốc; preview vẫn ra tiếng đúng track/channel.
|
||||
- **Rollback**: `git checkout -- app/static/js/app.jsx` (xóa file mới nếu thêm).
|
||||
|
||||
### T6 — Scheduler items → router (Phase B)
|
||||
- **Mục tiêu**: MIDI items khi chạy timeline đi cùng router.
|
||||
- **File**: `app/static/js/app.jsx` (scheduler/playhead)
|
||||
- **Bước**: note-on/note-off của scheduler → `router.dispatchMidiEvent` (TIMELINE_SCHEDULER); note-off dùng tracker đối xứng.
|
||||
- **Verify**: chạy timeline → items phát đúng pitch/vel; dừng giữa chừng → `panicAllNotesOff` không stuck note.
|
||||
- **Done khi**: preview + timeline cùng engine, không nuốt voice.
|
||||
|
||||
### T7 — Hardware MIDI → router (Phase B)
|
||||
- **File**: `app/static/js/app.jsx` (L15689 `navigator.requestMIDIAccess`/`onmidimessage`)
|
||||
- **Bước**: `onmidimessage` → `router.handleHardwareKeyboardMessage(...)` → dispatch (HARDWARE_KEYBOARD); xóa nhánh xử lý cũ.
|
||||
- **Verify**: bấm phím MIDI hardware → ra tiếng qua router đúng track active; log sourceType.
|
||||
- **Done khi**: 3 nguồn (keybed/scheduler/hardware) cùng một router.
|
||||
|
||||
### T8 — Rust `vst_gui.rs` (Phase C)
|
||||
- **Mục tiêu**: command `open_vst_gui(plugin_id, track_id)` tạo floating child window.
|
||||
- **File**: `src-tauri/Cargo.toml` (+ `raw-window-handle`), `src-tauri/src/vst_gui.rs`, `src-tauri/src/lib.rs` (register command).
|
||||
- **Nội dung** (spec vsti_gui): `WebviewWindowBuilder` label `vst_gui_{track}_{plugin}`, 800×600, always_on_top, mở lại thì focus; `raw_window_handle()` lấy HWND.
|
||||
- **Verify**: `cargo check`; gọi từ JS `invoke('open_vst_gui', {...})` → cửa sổ nổi mở/đóng không crash.
|
||||
- **Done khi**: window nổi mở được; chưa gắn plugin GUI (T9).
|
||||
|
||||
### T9 — VST3 GUI bridge (Phase C)
|
||||
- **File mới**: `native_host/vst3_host_bridge.cpp` + build DLL (MSVC/CMake)
|
||||
- **Nội dung** (spec): `attach_vst3_editor_to_handle(plugin_id, parent_handle)` → `createView(kEditor)` → `attached(hwnd, kPlatformTypeHWND)` → `getSize()` → resize. Đóng: `removed()` + `release()`.
|
||||
- **Quyết định**: VST3 SDK thuần (KHÔNG JUCE) — nhẹ, license BSD-3; T10 VST2 dùng vestige.h riêng; T12 cũng SDK thuần.
|
||||
- **Verify**: DLL build OK + 4 export `SF_VST3_Attach/Close/GetSize/Resize` load qua ctypes. Chưa có plugin VST3 thật để test GUI — giới hạn ghi nhận.
|
||||
- **Done khi**: DLL build + export OK.
|
||||
|
||||
### T10 — VST2 engine (Phase C)
|
||||
- **File mới**: `native_host/VST2AudioEngine.cpp` (dùng `vestige.h` — clean-room, KHÔNG dùng aeffect.h chính thức; legal note spec III)
|
||||
- **Nội dung** (spec VST2): `loadPlugin` (LoadLibraryW + `VSTPluginMain`/`main` + dispatcher init), `sendMidiNoteOn` (`effProcessEvents`, `deltaFrames`), `attachGUI` (`effEditOpen` + `effEditGetRect`), `processAudioBlock` (`processReplacing`), destructor teardown. `HostAudioMaster` phải trả lời opcode version/sampleRate/blockSize.
|
||||
- **Verify**: load .dll VST2 → mở GUI → processReplacing ra tín hiệu.
|
||||
- **Done khi**: VST2 GUI + audio thô chạy (chưa vào mixer).
|
||||
- **Đã làm (commit `aaf21dd`)**: `native_host/vestige.h` (clean-room AEffect: struct đúng layout công khai; numInputs/numOutputs/flags là function pointer; opcode effOpen..effSetProcessPrecision; audioMaster opcodes; VstEvents/VstMidiEvent; ERect `short`), `native_host/VST2AudioEngine.cpp` (export `SF_VST2_Load/Close/GetSize/Resize/SendNoteOn/SendNoteOff/Process`; LoadLibraryW → `VSTPluginMain`/`main` → magic check → effOpen→SetSampleRate→SetBlockSize→MainsChanged(1)→StartProcess; editor effEditGetRect→effEditOpen khi có parent; Close teardown ngược; MIDI qua effProcessEvents; processReplacing với interleaved buffers `ptrs[i]=buffers+i*sampleFrames`; `tls_loading` thread_local cho hostAudioMasterImpl trả version 2400/sampleRate/blockSize), `native_host/tests/fake_vst2.cpp` (plugin giả: sine 440Hz stereo, editor rect 400x300), CMakeLists thêm `vst2_host_bridge` target.
|
||||
- **Verify đã chạy**: `fake_vst2.dll` build OK (MSVC); `vst2_host_bridge.dll` build OK; ctypes: 7 export SF_VST2_* OK; Load(parent=NULL) → handle, GetSize → -2 (không editor — đúng), Process 512×2 stereo → sine peak 0.25; Load(parent=giả) → GetSize 400x300 → Close OK.
|
||||
|
||||
### T11 — IPC param sync (Phase C)
|
||||
- **File**: `src-tauri/src/vst_gui.rs` + JS side
|
||||
- **Nội dung**: native param đổi → `vst_param_changed` → JS cập nhật state DAW; JS automation → `set_param`/`setParamNormalized` → native.
|
||||
- **Verify**: xoay knob plugin → JS state đổi; đổi tham số từ DAW → plugin đổi.
|
||||
- **Done khi**: sync 2 chiều không lag, không vòng lặp vô hạn (guard flag).
|
||||
- **Đã làm (commit `9eec33c`, chưa push)**: `native_host/vst3_host_bridge.cpp` + `native_host/VST2AudioEngine.cpp` thêm param API đồng bộ: export `SF_*_SetParamCallback(handle, cb, userdata)`, `SF_*_SetParam(handle, id, valueNormalized)` → `controller->setParamNormalized`/`effect->setParameter`, `SF_*_GetParamCount`, `SF_VST2_GetParam(handle, id, &value)`, `SF_VST3_GetParamInfo(handle, index, &id, title, cap, &value)` (title String128 UTF-16 → `StringConvert::convert`); VST2 callback chạy trong `audioMasterAutomate` (instance tra qua `g_effect_map` + mutex riêng để tránh deadlock với audio thread), VST3 qua `ComponentHandler::performEdit`; `native_host/tests/fake_vst2.cpp` thêm param 0 (gain, mặc định 0.5) + `setParameter`/`getParameter` + gọi `audioMasterAutomate` khi đổi từ editor.
|
||||
- **Rust (`src-tauri/src/vst_gui.rs`)**: `open_vst_gui(plugin_id, track_id, plugin_path, plugin_kind)` mở WebviewWindow `vst_gui.html?track=..&plugin=..`, tìm bridge DLL (env `SF_NATIVE_HOST_DIR` → resource_dir → exe_dir → cwd → `native_host/build/Release/`), Load/Attach → handle, đăng ký callback; `set_vst_param`, `get_vst_params`, `close_vst_editor` (kind lưu trong HANDLE_MAP để close/set đúng DLL); callback native → `emit_to("vst_gui_{track}_{plugin}", "vst_param_changed", {track_id, plugin_id, param_id, value})`; `lib.rs` đăng ký 4 commands + `vst_gui::init` trong setup.
|
||||
- **JS (`src-tauri/ui/vst_gui.html`)**: panel param trái — `get_vst_params` → slider 0..1 per param; slider change → `set_vst_param`; listen `vst_param_changed` cập nhật UI, guard chống loop: `pendingSet` chứa param_id JS vừa set, event echo của chính mình bị bỏ qua.
|
||||
- **Verify đã chạy**: `cargo check` OK (rustc 1.97, raw-window-handle 0.6: `hwnd.get()`); build lại 2 bridge DLL + fake (MSVC) OK; ctypes VST2: Load→1, SetParamCallback→0, GetParamCount→1, GetParam(0)=0.5, SetParam(0, 0.75)→0, callback nhận đúng 1 event `(handle, 0, 0.75)` — không loop, GetParam(0)=0.75 sau đó, Close→0; VST3 bridge: 8 export OK (Attach/Close/GetSize/Resize + 4 param; SendNote/Process thuộc T12+). Chưa có plugin VST3 thật để test GUI — giới hạn T9 giữ nguyên.
|
||||
|
||||
### T12 — Native audio loop (Phase C)
|
||||
- **File mới**: `native_host/AudioEngine.h` + `AudioEngine.cpp` — QUYẾT ĐỊNH: gộp "VST3AudioEngine" thành `AudioEngine` dùng chung cho cả 2 plugin kind (spec cho phép); WASAPI đủ cho milestone, ASIO bỏ qua (ghi chú — thêm khi có yêu cầu rõ ràng).
|
||||
- **AudioEngine** (zero-lock audio thread): SPSC ring MIDI (capacity 4096, tối đa 256 event/block; producer có mutex riêng — audio thread KHÔNG lock, KHÔNG cấp phát); WASAPI render thread event-driven, exclusive trước → fallback shared (shared bắt buộc `GetMixFormat` của device — format tự build IEEE_FLOAT 2ch bị `AUDCLNT_E_UNSUPPORTED_FORMAT` 0x88890008); device default trước → fallback enumerate endpoint ACTIVE; MMCSS "Audio"; API: `start/stop/pushMidi/underruns/latencySamples/blocksRendered/sampleRate`, `ProcessFn` = `bool(*)(const MidiEvent*, int32, float* outL, float* outR, int32 frames, void*)`; audio thread: drain SPSC → build `VstEvents` (VST2, buffer 256 cố định flexible-array) / `FixedEventList` (VST3, Event[512] + IEventList no-alloc) → `processReplacing`/`process` → interleave ra WASAPI.
|
||||
- **VST2 bridge**: Instance thêm `audio` + `audioRunning`; `SF_VST2_AudioStart(handle, sr, block, err, cap)` (set `audioRunning=true` TRƯỚC `audio.start` để SendNote route vào SPSC), `SF_VST2_AudioStop`, `SF_VST2_AudioUnderruns`, `SF_VST2_AudioLatency`, `SF_VST2_AudioBlocks`; `SendNoteOn/Off` — nếu `audioRunning` → push SPSC, else giữ đường direct `effProcessEvents` cũ (offline/không audio); `Close` → `audioRunning=false; audio.stop()` trước teardown.
|
||||
- **VST3 bridge**: Instance thêm `component` (từ `plugProvider->getComponent()`), `processor` (queryInterface `IAudioProcessor::iid`), `audio`, `audioRunning`; export `SF_VST3_AudioStart/Stop/Underruns/Latency/Blocks` + `SF_VST3_SendNoteOn/Off` (LUÔN push SPSC — không có đường direct); AudioStart: `setupProcessing` (`ProcessSetup`: `symbolicSampleSize=kSample32`, `maxSamplesPerBlock`, `sampleRate`), `setProcessing(true)`, rồi `audio.start`; Close/AudioStop → stop audio + `setProcessing(false)`; ProcessData: `symbolicSampleSize=kSample32`, outBus 2ch `channelBuffers32={outL,outR}`, `inputEvents=&FixedEventList`; cần include `ivstevents.h` (IEventList/Event) + `ivstaudioprocessor.h` đã có.
|
||||
- **Verify đã chạy (ctypes VST2 + fake_vst2)**: Load→1, AudioStart(44100,128)→0, blocks render sau 300ms = 30 → callback chạy; SendNoteOn(60)/Off qua SPSC → 0, blocks tăng (30→110); `SF_VST2_AudioUnderruns` = 0; `SF_VST2_AudioLatency` = 1056 mẫu (shared-mode buffer thực tế của device — KHÔNG phải 128; exclusive không được device chấp nhận → fallback shared, ghi nhận trung thực); AudioStop→0, Close→0. VST3: 15 export OK (8 cũ + AudioStart/Stop/Underruns/Latency/Blocks + SendNoteOn/Off). Chưa có plugin VST3 thật để test audio — giới hạn giữ nguyên (như T9/T11).
|
||||
- **Cảnh báo**: KHÔNG chạy `SF_VST2_Process` (offline render) song song với live audio trên cùng instance — render thread và caller cùng gọi dispatcher; T16 offline autosample sẽ dùng instance riêng.
|
||||
- **Done khi**: VSTi ra loa native, không qua WebAudio — đạt cho đường live preview; offline qua native vẫn ở T13–T16.
|
||||
|
||||
### T13 — `NativeMixer` (Phase C)
|
||||
- **File mới**: `native_host/NativeMixer.h` + `NativeMixer.cpp` (static lib `native_mixer`), `native_host/tests/native_mixer_test.cpp` + `test_native_mixer_golden.py`
|
||||
- **Nội dung** (spec audio tier 3): track gain `10^(dB/20)`, pan constant-power, master sum, brickwall limiter — MIRROR `app/core/mastering_engine.py` (đọc file đó trước).
|
||||
- **Verify**: golden test — render cùng 1 đoạn bằng native mixer và server `render_project` (cùng mastering settings) → so RMS/peak sai lệch < 0.1dB.
|
||||
- **Done khi**: native mixer khớp server; track gain/pan áp cho cả VST2/VST3.
|
||||
- **Đã làm (commit `8f1e89c` tiếp theo — T13)**: `NativeMixer` — `TrackGainPan::fromDbPan(gainDb, pan)`: gainLin=10^(dB/20); pan==0 → unity cả 2 kênh (đúng render_engine.py — KHÔNG nhân 0.7071); pan≠0 → constant-power theta=((pan+1)/2)*π/2, L*=cos(theta), R*=sin(theta). `setLimiter(active, thresholdDb)`: clamp threshold −24..0, NaN→−1 (mirror `_clamp`); k=1/max(0.02,t_lin), tk=tanh(k). `processInPlace(outL,outR,frames)`: gain*pan rồi nếu limiter active → tanh(x*k)/tk + hard clip [−1,1] (clip CHỈ khi limiter active — mirror render_project: clip sau apply_mastering; limiter off không clip). Audio-thread an toàn: setTrack/setLimiter từ UI thread trước AudioStart, processInPlace không lock/cấp phát. Wired vào cả 2 bridge: Instance thêm `mixer`; `vst2AudioProcess`/`vst3AudioProcess` gọi `mixer.processInPlace` sau process; export mới `SF_VST2_/SF_VST3_SetTrackGainPan(handle, gainDb, pan)` + `SF_VST2_/SF_VST3_SetMasterLimiter(handle, active, thresholdDb)` → VST2 18 export, VST3 17.
|
||||
- **Verify đã chạy**: golden test `python native_host/tests/test_native_mixer_golden.py` — 5 case (identity; gain/pan 0dB·pan0; +6dB pan−0.5 limiter −3dB; −9dB pan0.8 limiter −6dB; +12dB limiter 0dB) so với reference dùng CHÍNH code server: gain/pan theo render_engine.py + `apply_mastering` (mastering_engine) module limiter + clip — worst diff RMS/peak = 0.0001 dB (< 0.1 → PASS). Ctypes VST2: Load→1, SetTrackGainPan/SetMasterLimiter→0, AudioStart→0, 30 blocks/300ms, SendNoteOn/Off→0, underruns 0, Stop/Close→0. Build: cmake Release 2 bridge OK; native_mixer_test.exe build OK.
|
||||
- **Ghi chú**: golden test so với reference = đúng code path render_project (gain/pan render_engine + apply_mastering + clip) vì chưa có FluidSynth native (T14) để render SF trực tiếp — end-to-end qua render_project sẽ verify tiếp ở T14.
|
||||
- **Done khi**: native mixer khớp server; track gain/pan áp cho cả VST2/VST3.
|
||||
|
||||
### T14 — Port FluidSynth native (Phase C)
|
||||
- **Mục tiêu**: SF track vào CÙNG native mixer (một master duy nhất).
|
||||
- **Bước**: tích hợp libfluidsynth C++ vào native host; track SF → FluidSynth native → NativeMixer. (Nếu không port được: loopback WASM→native — phức tạp, ghi quyết định vào Ghi chú.)
|
||||
- **Verify**: SF track + VSTi track cùng master; gain/pan/mastering áp đúng cả 2; preview + items + offline nhất quán.
|
||||
- **Done khi**: một mixer duy nhất, hết WebAudio cho track instrument.
|
||||
- **Đã làm (commit `500384a` — T14)**: QUYẾT ĐỊNH — port được native, KHÔNG loopback. `native_host/SFHost.cpp` (DLL `sf_host_bridge`, link audio_engine + native_mixer): runtime-load libfluidsynth-3.dll từ `native_host/fluidsynth_runtime/` (gitignore; 22 DLL 12.3MB closure bằng dumpbin BFS; tái tạo bằng `scripts/dl_fluidsynth_runtime.py` — tải 22 MSYS2 mingw64 packages, giải nén zstandard, copy NEEDED list; ponytail: chạy lại BFS khi upgrade). Load bằng `GetModuleHandleW(L"sf_host_bridge")` + `GetModuleFileNameW` (KHÔNG nullptr — host có thể là python.exe) + `LoadLibraryExW(LOAD_WITH_ALTERED_SEARCH_PATH)`. **Export prefix FluidSynth 2.5.x**: `new_fluid_settings/new_fluid_synth/delete_fluid_settings/delete_fluid_synth` (KHÔNG phải `fluid_settings_new` — verify dumpbin). Bỏ setting `synth.lock-memory` (không tồn tại 2.5.6); giữ `audio.driver=file`, `synth_set_gain(1.0)` (mirror WASM client; default 0.2 = −14 dB). 15 export: `SF_FS_Create/LoadSF2/SelectInstrument(handle, channel, sfontId, bank, program)/NoteOn/NoteOff/AllNotesOff/RenderBlock/AudioStart/Stop/Underruns/Latency/Blocks/SetTrackGainPan/SetMasterLimiter/Close`. **BUG ĐÃ SỬA**: `fluid_synth_program_select` tham số 3 là **sfont_id** (không phải bank); `sfontId<0` → dùng `inst->sfid` lưu từ sfload (sfid thật của SF2 test là 1, không phải 0). RenderBlock: offline `write_float` + mixer, không qua WASAPI.
|
||||
- **Verify đã chạy**: `test_sf_host_bridge.py` PASS (Create→LoadSF2→SelectInstrument→AudioStart→NoteOn→blocks 0→40, underruns 0→Stop→Close; preset SF2 test là bank128/prog0 'Standard Kit' — không có 0/0). `test_sf_host_golden.py` PASS — 3 case so reference = libfluidsynth-3.dll ctypes (write_float) + mixer math (render_engine gain/pan + apply_mastering + clip), ngưỡng 0.1 dB, sai lệch thực tế 0.0001 dB (bit-exact): (0dB/pan0/lim-off), (−6dB/lim−1), (−3dB/pan0.8/lim−6). Export count SFHost 15 (dumpbin).
|
||||
- **Ghi chú**: SF2 test sfload trả sfid=1 — test truyền sfontId=-1 (dùng inst->sfid). T13 ghi chú "chưa có FluidSynth native" giờ đã hết — golden T14 dùng reference đúng code path server.
|
||||
|
||||
### T15 — Bỏ WebAudio master (Phase C)
|
||||
- **Bước**: track instrument không còn đi `masterBus` WebAudio; IPC `set_master_gain`, `set_track_gain_pan` từ JS → native; giữ WebAudio chỉ cho UI/aux nếu còn.
|
||||
- **Verify**: test matrix spec V: VST3/VST2/SF × preview/live/offline đều đúng âm lượng và mastering.
|
||||
- **Done khi**: Bug 2 không còn drift (một engine duy nhất); checklist spec 2 (negotiate SR/buffer, jitter <0.1ms, 0% underrun) đạt.
|
||||
- **Đã làm (commit — T15)**:
|
||||
1. C++: `NativeMixer` thêm master fader — `setMasterGain(float linear)` (m_masterGain=1.0), áp SAU limiter+clip trong `processInPlace` (mirror `masterBus.output.gain` sau mastering chain; server render_project không có master fader — WebAudio áp sau mastering). Export mới `SF_FS_/SF_VST2_/SF_VST3_SetMasterGain` (SFHost.cpp, VST2AudioEngine.cpp, vst3_host_bridge.cpp). Export count: SF 16, VST2 19, VST3 18.
|
||||
2. `app/core/native_audio_service.py` (mới): singleton `NativeAudioService` ctypes wrapper — SF (ensure_sf/note_on/note_off/set_track_gain_pan/stats/audio_stop/render_sf_offline — instance TẠM, RenderBlock loop 256, note events theo start_beat/duration_beats), VST2 (live + offline qua `SF_VST2_Process` raw + `apply_track_mixer` Python mirror NativeMixer/mastering_engine; ponytail: giả định synth 0-input → output ở buf[0:2] vì bridge xếp outputs tại offset numInputs), VST3 (live; offline limitation — bridge chưa có Process export); `set_master_gain(db)` (db<=-50 → 0) áp linear mọi instance; `close_track/close_all/status`; DLL dir = `settings.BASE_DIR/native_host/build/Release` (env `SONICFORGE_NATIVE_DIR` override). BUG ĐÃ SỬA: argtypes SetMasterGain phải 2 tham số (i32, float) — không dùng chung loop 3 tham số với SetTrackGainPan.
|
||||
3. `app/api/v1/native.py` (mới, đăng ký main.py prefix `/api/v1/native`): status, set_master_gain, track_gain_pan, sf/ensure (nhận sf_id → resolve `_find_sf2_path` server-side), sf/note_on, sf/note_off, sf/audio_stop, vst2/ensure, vst2/note_on, vst2/note_off, render (trả peak/rms cho test matrix).
|
||||
4. JS: `app/static/js/services/nativeAudioClient.js` (mới, `window.SonicNativeAudio` — setMasterGain debounce 40ms, setTrackGainPan, ensureSf, noteOn/noteOff; include vào index.html); app.jsx — `handleFaderChange` → native set_master_gain, `updateTrackVolumeDb`/`updateTrackPan` → native track_gain_pan (pan/100; dùng tracks state lấy pan/volume hiện tại); `trackInstrument.js` — playNote/noteOff native-first (SF track có sfId → ensureSf + note_on + setTimeout note_off theo durationMs; ponytail: bỏ startTime offset — TrackInstrument không biết audioCtx), SonicSF WASM chỉ fallback.
|
||||
- **Verify đã chạy**: smoke python service (SF live blocks 50→70 underruns 0; master -6dB ratio 0.5012 đúng 10^(-6/20); VST2 live blocks 40→60 underruns 0; VST2 offline peak 0.25 sine, ratio -6/-6 = 0.2512 đúng 10^(-12/20)); API TestClient 10 endpoints OK; `python -m pytest tests/test_native_matrix.py` — 8 passed (SF live blocks/underruns 0, SF offline master gain + track gain pan, VST2 live, VST2 offline sine+mixer+limiter, VST3 bridge export SetMasterGain — limitation, API render); pytest toàn bộ 118 passed 1 skipped 1 env-fail (test_vst_engine Linux path — baseline). Build babel OK; node --check 2 service JS OK.
|
||||
- **Ghi chú**: VST3 live test không có plugin thật — bridge export verify (như T9/T11/T12). AudioEngine mỗi instance WASAPI riêng — chưa có master summing chung giữa track (giới hạn T15). CRLF rule: main.py 2 dòng LF do edit_file → convert lại CRLF toàn file.
|
||||
|
||||
### T16 — Autosample hoàn thiện (Phase D)
|
||||
- **File**: `tools/autosample_vsti.py`, `app/static/js/services/vstiAutosample.js`
|
||||
- **Nội dung**: autosample VST2 → `.sf3` cho offline (Pedalboard không hỗ trợ VST2 → giữ con đường này cho export); tôn trọng preset_path/preset_data; hiển thị note_count/size; re-sample khi đổi preset.
|
||||
- **Verify**: export track VST2 ra file đúng âm; đổi preset → re-sample tự động; UI hiện size.
|
||||
- **Đã làm**: `tools/autosample_vsti.py` — VST2 branch: `_is_vst2_path` (.dll/.so không .vst3), `_render_note_native` qua `get_service().render_vst2_offline` (tail_sec=release), `autosample_sf2` nhận `extra_vst_dirs` + bỏ qua preset VST2 (bridge chưa hỗ trợ chunk); `write_sf2` sửa gen IDs chuẩn SF2: keyRange=43, overridingRootKey=58, sampleID=53 (gen cuối zone) — trước đó gen 60/69/74 bị FluidSynth "Discarding invalid global zone" (rms 1.5e-5 → 0.041). `app/core/soundfont_converter.py` — `_ensure_fluidsynth_runtime()` prepend PATH DLL closure (native_host/build/Release/fluidsynth_runtime) để pyfluidsynth import được; FLUID_SAMPLETYPE_OGG_VORBIS = 0x10 (không phải 0x20 — 0x20 làm sample bị ignore); `_sf3_plays_audio` dùng `Synth().sfload(str)`/get_samples (không có fluid_synth_write_float trong binding); reverse `_sf3_to_sf2` clear flag `& ~0x10`. `plugins.py` `POST /autosample` — truyền `extra_vst_dirs=_dirs` (VST2 scan riêng), không còn 501 cứng VST2. `vstiAutosample.js` — payload preset_path/preset_data, keyFor = plugin_id|preset_id (re-sample khi đổi preset), notify `sf:autosample-update`; `app.jsx` 2 chip badge size/note_count (PianoRollTabEditor + track strip). Babel rebuild `app.precompiled.js`.
|
||||
- **Verify đã chạy**: pipeline thật fake_vst2 → SF2 3 notes, FluidSynth SF2 rms 0.041; convert → SF3 rms 0.044 (cả 2 > 1e-4, warning "invalid sample loops sanitized" vô hại — loopstart/end=0); `python -m pytest tests/` — 119 passed 1 skipped 1 env-fail (test_vst_engine Linux path — baseline); test mới `test_autosample_vst2_native_bridge` (fake_vst2 → 3 notes).
|
||||
- **Ghi chú**: pyfluidsynth cần DLL trên PATH — `_ensure_fluidsynth_runtime()` ở module-level helper; SF3 verify dùng high-level Synth (sfload nhận str, không encode bytes); `write_sf2` inst terminator bagNdx = n (record terminal zone, không phải n+1).
|
||||
- **Done khi**: matrix hoàn chỉnh; toàn bộ test xanh; commit cuối.
|
||||
|
||||
## 5. Checkpoint tổng
|
||||
- Sau T3: 3 bug đã vá (tạm) — bản phát hành an toàn.
|
||||
- Sau T7: unified MIDI pipeline xong phía client — Bug 1 hết tận gốc.
|
||||
- Sau T15: native audio hoàn chỉnh — Bug 2 hết drift, Bug 3 hết fallback mặc định.
|
||||
- Sau T16: matrix spec V đầy đủ.
|
||||
Generated
+4678
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,9 @@ tauri-build = { version = "2", features = [] }
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri-plugin-shell = "2"
|
||||
tauri-plugin-dialog = "2"
|
||||
raw-window-handle = "0.6"
|
||||
webview2-com = "0.38"
|
||||
windows-core = "0.61"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
{"default":{"identifier":"default","description":"Default capability for the main window","local":true,"windows":["main"],"permissions":["core:default","dialog:default"]}}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1 +0,0 @@
|
||||
PLACEHOLDER - duoc thay boi build_windows.ps1 buoc [4/6] (copy dist\daw_engine)
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -17,6 +17,9 @@ use tauri_plugin_shell::ShellExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Mutex;
|
||||
|
||||
mod vst_gui;
|
||||
mod permissions;
|
||||
|
||||
struct EngineProcess(Mutex<Option<CommandChild>>);
|
||||
|
||||
/// Các vị trí có thể chứa daw_engine, theo thứ tự ưu tiên.
|
||||
@@ -73,7 +76,12 @@ pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.invoke_handler(tauri::generate_handler![vst_gui::open_vst_gui, vst_gui::set_vst_param, vst_gui::get_vst_params, vst_gui::close_vst_editor])
|
||||
.setup(|app| {
|
||||
permissions::install(
|
||||
&app.get_webview_window("main").expect("main window missing"),
|
||||
);
|
||||
vst_gui::init(app.handle());
|
||||
let res_dir = app
|
||||
.path()
|
||||
.resource_dir()
|
||||
@@ -134,6 +142,7 @@ pub fn run() {
|
||||
.shell()
|
||||
.command(&engine_exe)
|
||||
.env("SF_PARENT_PID", std::process::id().to_string())
|
||||
.env("SF_RESOURCE_DIR", res_dir.to_string_lossy().to_string())
|
||||
.spawn()
|
||||
{
|
||||
Ok((_rx, child)) => {
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
// Standalone permissions (Bug 5): WebView2 auto-ALLOW mọi permission request
|
||||
// (MIDI input, microphone, File System Access) — không hiện popup hỏi quyền.
|
||||
// WebView2 mặc định DENY khi không có handler ICoreWebView2::add_PermissionRequested.
|
||||
#![cfg(windows)]
|
||||
|
||||
use webview2_com::Microsoft::Web::WebView2::Win32::*;
|
||||
use webview2_com::PermissionRequestedEventHandler;
|
||||
|
||||
pub fn install(window: &tauri::WebviewWindow) {
|
||||
let _ = window.with_webview(|webview| {
|
||||
unsafe {
|
||||
if let Ok(core) = webview.controller().CoreWebView2() {
|
||||
let handler = PermissionRequestedEventHandler::create(Box::new(
|
||||
|_sender: Option<ICoreWebView2>,
|
||||
args: Option<ICoreWebView2PermissionRequestedEventArgs>| {
|
||||
if let Some(args) = args {
|
||||
args.SetState(COREWEBVIEW2_PERMISSION_STATE_ALLOW)?;
|
||||
}
|
||||
Ok(())
|
||||
},
|
||||
));
|
||||
let mut token = 0i64;
|
||||
let _ = core.add_PermissionRequested(&handler, &mut token);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
// VST GUI windows (spec vsti_gui): floating child window per (track, plugin).
|
||||
//
|
||||
// T8: command `open_vst_gui(plugin_id, track_id)` tao WebviewWindow noi label
|
||||
// `vst_gui_{track}_{plugin}`, 800x600, always-on-top; mo lai thi focus.
|
||||
// raw_window_handle() lay HWND (Windows).
|
||||
// T9/T10: native_host/*.dll attach VST3/VST2 editor vao HWND cua window.
|
||||
// T11: param sync 2 chieu — native param doi -> event `vst_param_changed` ->
|
||||
// JS; JS automation -> `set_vst_param` -> setParamNormalized/setParameter.
|
||||
// Guard chong loop nam o JS side (bo qua event echo cua chinh minh).
|
||||
use raw_window_handle::HasWindowHandle;
|
||||
use serde::Serialize;
|
||||
use std::collections::HashMap;
|
||||
use std::ffi::{c_char, c_void, CString};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{LazyLock, Mutex};
|
||||
use tauri::{AppHandle, Emitter, Manager, WebviewUrl, WebviewWindowBuilder};
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
mod native {
|
||||
use super::*;
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
|
||||
// Callback signature trung voi SF_ParamChangedCallback trong C++ bridge.
|
||||
pub type ParamCb = unsafe extern "C" fn(i32, i32, f64, *mut c_void);
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
#[repr(C)]
|
||||
pub struct Bridge {
|
||||
pub module: *mut c_void,
|
||||
pub attach: Option<unsafe extern "C" fn(*const c_char, *const c_char, *mut c_void, *mut i32, *mut i32, *mut c_char, i32) -> i32>,
|
||||
pub load: Option<unsafe extern "C" fn(*const c_char, *mut c_void, *mut i32, *mut i32, *mut c_char, i32) -> i32>,
|
||||
pub close: Option<unsafe extern "C" fn(i32, *mut c_char, i32) -> i32>,
|
||||
pub set_param_cb: Option<unsafe extern "C" fn(i32, ParamCb, *mut c_void) -> i32>,
|
||||
pub set_param: Option<unsafe extern "C" fn(i32, i32, f64) -> i32>,
|
||||
pub get_param_count: Option<unsafe extern "C" fn(i32) -> i32>,
|
||||
pub get_param: Option<unsafe extern "C" fn(i32, i32, *mut f64) -> i32>,
|
||||
pub get_param_info: Option<unsafe extern "C" fn(i32, i32, *mut i32, *mut c_char, i32, *mut f64) -> i32>,
|
||||
}
|
||||
// Raw pointer `module` khong Send; bridge chi dung tu main thread, an toan.
|
||||
unsafe impl Send for Bridge {}
|
||||
|
||||
static BRIDGES: LazyLock<Mutex<HashMap<String, Bridge>>> = LazyLock::new(|| Mutex::new(HashMap::new()));
|
||||
|
||||
pub static APP: Mutex<Option<AppHandle>> = Mutex::new(None);
|
||||
// handle native -> (track_id, plugin_id, kind)
|
||||
pub static HANDLE_MAP: LazyLock<Mutex<HashMap<i32, (String, String, String)>>> =
|
||||
LazyLock::new(|| Mutex::new(HashMap::new()));
|
||||
// "{track}|{plugin}" -> handle native
|
||||
pub static EDITOR_MAP: LazyLock<Mutex<HashMap<String, i32>>> = LazyLock::new(|| Mutex::new(HashMap::new()));
|
||||
|
||||
unsafe fn load_bridge(kind: &str, app: &AppHandle) -> Option<Bridge> {
|
||||
let name = if kind == "vst2" { "vst2_host_bridge.dll" } else { "vst3_host_bridge.dll" };
|
||||
let path = find_bridge_path(name, app)?;
|
||||
let wide: Vec<u16> = path.as_os_str().encode_wide().chain(Some(0)).collect();
|
||||
let module = unsafe { LoadLibraryW(wide.as_ptr()) };
|
||||
if module.is_null() {
|
||||
return None;
|
||||
}
|
||||
let get = |sym: &str| -> Option<*mut c_void> {
|
||||
let mut buf: Vec<u8> = sym.bytes().collect();
|
||||
buf.push(0);
|
||||
let p = unsafe { GetProcAddress(module, buf.as_ptr()) };
|
||||
if p.is_null() { None } else { Some(p as *mut c_void) }
|
||||
};
|
||||
let mut bridge = Bridge {
|
||||
module,
|
||||
attach: None, load: None, close: None, set_param_cb: None,
|
||||
set_param: None, get_param_count: None, get_param: None, get_param_info: None,
|
||||
};
|
||||
if kind == "vst2" {
|
||||
bridge.load = get("SF_VST2_Load").map(|p| unsafe { std::mem::transmute(p) });
|
||||
bridge.close = get("SF_VST2_Close").map(|p| unsafe { std::mem::transmute(p) });
|
||||
bridge.set_param_cb = get("SF_VST2_SetParamCallback").map(|p| unsafe { std::mem::transmute(p) });
|
||||
bridge.set_param = get("SF_VST2_SetParam").map(|p| unsafe { std::mem::transmute(p) });
|
||||
bridge.get_param_count = get("SF_VST2_GetParamCount").map(|p| unsafe { std::mem::transmute(p) });
|
||||
bridge.get_param = get("SF_VST2_GetParam").map(|p| unsafe { std::mem::transmute(p) });
|
||||
} else {
|
||||
bridge.attach = get("SF_VST3_Attach").map(|p| unsafe { std::mem::transmute(p) });
|
||||
bridge.close = get("SF_VST3_Close").map(|p| unsafe { std::mem::transmute(p) });
|
||||
bridge.set_param_cb = get("SF_VST3_SetParamCallback").map(|p| unsafe { std::mem::transmute(p) });
|
||||
bridge.set_param = get("SF_VST3_SetParam").map(|p| unsafe { std::mem::transmute(p) });
|
||||
bridge.get_param_count = get("SF_VST3_GetParamCount").map(|p| unsafe { std::mem::transmute(p) });
|
||||
bridge.get_param_info = get("SF_VST3_GetParamInfo").map(|p| unsafe { std::mem::transmute(p) });
|
||||
}
|
||||
Some(bridge)
|
||||
}
|
||||
|
||||
fn find_bridge_path(name: &str, app: &AppHandle) -> Option<PathBuf> {
|
||||
let mut candidates: Vec<PathBuf> = Vec::new();
|
||||
if let Ok(dir) = std::env::var("SF_NATIVE_HOST_DIR") {
|
||||
candidates.push(Path::new(&dir).join(name));
|
||||
}
|
||||
if let Ok(res) = app.path().resource_dir() {
|
||||
candidates.push(res.join(name));
|
||||
candidates.push(res.join("native").join(name));
|
||||
candidates.push(res.join("native_host").join(name));
|
||||
}
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
if let Some(dir) = exe.parent() {
|
||||
candidates.push(dir.join(name));
|
||||
}
|
||||
}
|
||||
if let Ok(cwd) = std::env::current_dir() {
|
||||
candidates.push(cwd.join(name));
|
||||
candidates.push(cwd.join("native_host").join("build").join("Release").join(name));
|
||||
}
|
||||
candidates.into_iter().find(|p| p.exists())
|
||||
}
|
||||
|
||||
// Callback native -> Rust: plugin doi param trong editor.
|
||||
pub unsafe extern "C" fn on_param_changed(handle: i32, param_id: i32, value: f64, _userdata: *mut c_void) {
|
||||
let ids = HANDLE_MAP.lock().unwrap().get(&handle).cloned();
|
||||
let app = APP.lock().unwrap().clone();
|
||||
if let (Some((track, plugin, _kind)), Some(app)) = (ids, app) {
|
||||
let _ = app.emit_to(
|
||||
&format!("vst_gui_{}_{}", track, plugin),
|
||||
"vst_param_changed",
|
||||
serde_json::json!({
|
||||
"track_id": track,
|
||||
"plugin_id": plugin,
|
||||
"param_id": param_id,
|
||||
"value": value,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn bridge(kind: &str, app: &AppHandle) -> Option<Bridge> {
|
||||
let mut bridges = BRIDGES.lock().unwrap();
|
||||
if let Some(b) = bridges.get(kind) {
|
||||
// Clone fn pointers (module kept alive for app lifetime)
|
||||
return Some(Bridge {
|
||||
module: b.module,
|
||||
attach: b.attach, load: b.load, close: b.close, set_param_cb: b.set_param_cb,
|
||||
set_param: b.set_param, get_param_count: b.get_param_count,
|
||||
get_param: b.get_param, get_param_info: b.get_param_info,
|
||||
});
|
||||
}
|
||||
let b = unsafe { load_bridge(kind, app) }?;
|
||||
bridges.insert(kind.to_string(), b);
|
||||
Some(b)
|
||||
}
|
||||
|
||||
#[link(name = "kernel32")]
|
||||
extern "system" {
|
||||
fn LoadLibraryW(name: *const u16) -> *mut c_void;
|
||||
fn GetProcAddress(module: *mut c_void, name: *const u8) -> *mut c_void;
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ParamInfo {
|
||||
pub param_id: i32,
|
||||
pub title: String,
|
||||
pub value: f64,
|
||||
}
|
||||
|
||||
fn urlencode(s: &str) -> String {
|
||||
let mut out = String::new();
|
||||
for b in s.bytes() {
|
||||
match b {
|
||||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => out.push(b as char),
|
||||
_ => out.push_str(&format!("%{:02X}", b)),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn init(app: &AppHandle) {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
*native::APP.lock().unwrap() = Some(app.clone());
|
||||
}
|
||||
}
|
||||
|
||||
/// Mo (hoac focus neu da mo) cua so VST GUI cho 1 (track, plugin) va attach
|
||||
/// native editor (VST3 .vst3 hoac VST2 .dll) vao HWND cua window.
|
||||
#[tauri::command]
|
||||
pub fn open_vst_gui(
|
||||
app: AppHandle,
|
||||
plugin_id: String,
|
||||
track_id: String,
|
||||
plugin_path: String,
|
||||
plugin_kind: String,
|
||||
) -> Result<String, String> {
|
||||
let label = format!("vst_gui_{}_{}", track_id, plugin_id);
|
||||
let win = if let Some(win) = app.get_webview_window(&label) {
|
||||
win.set_focus().map_err(|e| e.to_string())?;
|
||||
win
|
||||
} else {
|
||||
let url = WebviewUrl::App(format!("vst_gui.html?track={}&plugin={}", urlencode(&track_id), urlencode(&plugin_id)).into());
|
||||
let win = WebviewWindowBuilder::new(&app, &label, url)
|
||||
.title(format!("VST GUI - {}", plugin_id))
|
||||
.inner_size(800.0, 600.0)
|
||||
.always_on_top(true)
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let _ = win.set_focus();
|
||||
win
|
||||
};
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let hwnd = match win.window_handle() {
|
||||
Ok(raw) => match raw.as_raw() {
|
||||
raw_window_handle::RawWindowHandle::Win32(h) => h.hwnd.get() as *mut c_void,
|
||||
_ => {
|
||||
return Err("not a Win32 window".into());
|
||||
}
|
||||
},
|
||||
Err(e) => return Err(format!("raw_window_handle error: {}", e)),
|
||||
};
|
||||
|
||||
let kind = if plugin_kind.eq_ignore_ascii_case("vst2") { "vst2" } else { "vst3" };
|
||||
let b = native::bridge(kind, &app).ok_or("bridge DLL not found (set SF_NATIVE_HOST_DIR or bundle it)")?;
|
||||
let mut err = [0i8; 512];
|
||||
let mut w: i32 = 0;
|
||||
let mut h: i32 = 0;
|
||||
let c_path = CString::new(plugin_path.as_str()).map_err(|e| e.to_string())?;
|
||||
let c_plugin = CString::new(plugin_id.as_str()).map_err(|e| e.to_string())?;
|
||||
|
||||
let handle = unsafe {
|
||||
if kind == "vst2" {
|
||||
let f = b.load.ok_or("vst2 bridge missing SF_VST2_Load")?;
|
||||
f(c_path.as_ptr(), hwnd, &mut w, &mut h, err.as_mut_ptr(), err.len() as i32)
|
||||
} else {
|
||||
let f = b.attach.ok_or("vst3 bridge missing SF_VST3_Attach")?;
|
||||
f(c_path.as_ptr(), c_plugin.as_ptr(), hwnd, &mut w, &mut h, err.as_mut_ptr(), err.len() as i32)
|
||||
}
|
||||
};
|
||||
if handle <= 0 {
|
||||
let msg = unsafe { std::ffi::CStr::from_ptr(err.as_ptr()) }.to_string_lossy().into_owned();
|
||||
return Err(format!("attach failed ({}): {}", kind, msg));
|
||||
}
|
||||
|
||||
let cb = b.set_param_cb.ok_or("bridge missing SetParamCallback")?;
|
||||
let rc = unsafe { cb(handle, native::on_param_changed, std::ptr::null_mut()) };
|
||||
if rc != 0 {
|
||||
return Err(format!("set param callback failed: {}", rc));
|
||||
}
|
||||
|
||||
native::HANDLE_MAP.lock().unwrap().insert(handle, (track_id.clone(), plugin_id.clone(), kind.to_string()));
|
||||
native::EDITOR_MAP.lock().unwrap().insert(format!("{}|{}", track_id, plugin_id), handle);
|
||||
Ok(format!("opened: {} (handle={}, {}x{})", label, handle, w, h))
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
Ok(format!("opened (no native attach on this OS): {}", label))
|
||||
}
|
||||
}
|
||||
|
||||
/// JS automation -> setParamNormalized/setParameter.
|
||||
#[tauri::command]
|
||||
pub fn set_vst_param(
|
||||
app: AppHandle,
|
||||
track_id: String,
|
||||
plugin_id: String,
|
||||
param_id: i32,
|
||||
value: f64,
|
||||
) -> Result<i32, String> {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let key = format!("{}|{}", track_id, plugin_id);
|
||||
let handle = *native::EDITOR_MAP.lock().unwrap().get(&key).ok_or("editor not open")?;
|
||||
let kind = {
|
||||
let m = native::HANDLE_MAP.lock().unwrap();
|
||||
m.get(&handle).map(|t| t.2.clone()).unwrap_or_else(|| "vst2".to_string())
|
||||
};
|
||||
let b = native::bridge(&kind, &app).ok_or("bridge DLL not found")?;
|
||||
let f = b.set_param.ok_or("bridge missing SetParam")?;
|
||||
let rc = unsafe { f(handle, param_id, value) };
|
||||
if rc != 0 {
|
||||
return Err(format!("set param failed: {}", rc));
|
||||
}
|
||||
Ok(0)
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
let _ = (app, track_id, plugin_id, param_id, value);
|
||||
Ok(0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Lay danh sach param (id, title, value) de JS dung UI.
|
||||
#[tauri::command]
|
||||
pub fn get_vst_params(
|
||||
app: AppHandle,
|
||||
track_id: String,
|
||||
plugin_id: String,
|
||||
) -> Result<Vec<ParamInfo>, String> {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let key = format!("{}|{}", track_id, plugin_id);
|
||||
let handle = *native::EDITOR_MAP.lock().unwrap().get(&key).ok_or("editor not open")?;
|
||||
let kind = {
|
||||
let m = native::HANDLE_MAP.lock().unwrap();
|
||||
m.get(&handle).map(|t| t.2.clone()).unwrap_or_else(|| "vst2".to_string())
|
||||
};
|
||||
let b = native::bridge(&kind, &app).ok_or("bridge DLL not found")?;
|
||||
let count = {
|
||||
let f = b.get_param_count.ok_or("bridge missing GetParamCount")?;
|
||||
unsafe { f(handle) }
|
||||
};
|
||||
if count < 0 {
|
||||
return Err(format!("get param count failed: {}", count));
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
for i in 0..count {
|
||||
if kind == "vst2" {
|
||||
let f = b.get_param.ok_or("bridge missing GetParam")?;
|
||||
let mut v: f64 = 0.0;
|
||||
let rc = unsafe { f(handle, i, &mut v) };
|
||||
if rc == 0 {
|
||||
out.push(ParamInfo { param_id: i, title: format!("Param {}", i), value: v });
|
||||
}
|
||||
} else {
|
||||
let f = b.get_param_info.ok_or("bridge missing GetParamInfo")?;
|
||||
let mut id: i32 = 0;
|
||||
let mut title = [0i8; 128];
|
||||
let mut v: f64 = 0.0;
|
||||
let rc = unsafe { f(handle, i, &mut id, title.as_mut_ptr(), title.len() as i32, &mut v) };
|
||||
if rc == 0 {
|
||||
let title_str = unsafe { std::ffi::CStr::from_ptr(title.as_ptr()) }.to_string_lossy().into_owned();
|
||||
out.push(ParamInfo { param_id: id, title: title_str, value: v });
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
let _ = (app, track_id, plugin_id);
|
||||
Ok(Vec::new())
|
||||
}
|
||||
}
|
||||
|
||||
/// Dong editor native + xoa map (goi khi cua so VST GUI dong).
|
||||
#[tauri::command]
|
||||
pub fn close_vst_editor(app: AppHandle, track_id: String, plugin_id: String) -> Result<i32, String> {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let key = format!("{}|{}", track_id, plugin_id);
|
||||
let handle = native::EDITOR_MAP.lock().unwrap().remove(&key).ok_or("editor not open")?;
|
||||
let kind = native::HANDLE_MAP.lock().unwrap().remove(&handle).map(|t| t.2).unwrap_or_else(|| "vst2".to_string());
|
||||
let b = native::bridge(&kind, &app).ok_or("bridge DLL not found")?;
|
||||
let f = b.close.ok_or("bridge missing Close")?;
|
||||
let mut err = [0i8; 256];
|
||||
let rc = unsafe { f(handle, err.as_mut_ptr(), err.len() as i32) };
|
||||
Ok(rc)
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
let _ = (app, track_id, plugin_id);
|
||||
Ok(0)
|
||||
}
|
||||
}
|
||||
@@ -33,7 +33,8 @@
|
||||
"icons/favicon.ico"
|
||||
],
|
||||
"resources": {
|
||||
"resources/daw_engine": "daw_engine/"
|
||||
"resources/daw_engine": "daw_engine/",
|
||||
"resources/native_host": "native_host/"
|
||||
},
|
||||
"externalBin": [],
|
||||
"windows": {
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
<!doctype html>
|
||||
<html lang="vi">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>VST GUI</title>
|
||||
<style>
|
||||
body { font-family: system-ui, sans-serif; background: #1a1d24; color: #c8ccd4;
|
||||
height: 100vh; margin: 0; display: flex; }
|
||||
#editor { flex: 1; position: relative; }
|
||||
#panel { width: 260px; border-left: 1px solid #2c313b; padding: 10px; overflow-y: auto;
|
||||
background: #171a20; }
|
||||
#panel h3 { margin: 4px 0 10px; font-size: 13px; color: #9aa3b2; font-weight: 600;
|
||||
text-transform: uppercase; letter-spacing: .04em; }
|
||||
.row { margin-bottom: 10px; }
|
||||
.row label { display: block; font-size: 12px; color: #c8ccd4; margin-bottom: 2px;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.row .val { float: right; color: #6ea8ff; font-variant-numeric: tabular-nums; }
|
||||
.row input[type=range] { width: 100%; accent-color: #3b82f6; }
|
||||
#msg { padding: 20px; text-align: center; color: #9aa3b2; line-height: 1.6; }
|
||||
code { background: #262b34; padding: 2px 6px; border-radius: 4px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="editor"><div id="msg">Plugin editor attach vào HWND (vùng này).<br>Nếu không thấy editor: cửa sổ này mở qua DAW, native DLL phải tìm thấy (SF_NATIVE_HOST_DIR).</div></div>
|
||||
<div id="panel">
|
||||
<h3>Parameters</h3>
|
||||
<div id="params"><div id="msg">Đang tải params…</div></div>
|
||||
</div>
|
||||
<script>
|
||||
// T11: param sync 2 chiều — native đổi param -> event vst_param_changed -> cập nhật UI;
|
||||
// JS kéo slider -> invoke set_vst_param -> setParamNormalized/setParameter.
|
||||
// Guard chống loop: pendingSet chứa param_id JS vừa set; event echo của chính mình bị bỏ qua.
|
||||
const T = window.__TAURI__;
|
||||
if (!T) { document.getElementById('msg').textContent = 'Không có __TAURI__ (chạy ngoài Tauri).'; throw new Error('no tauri'); }
|
||||
const { invoke } = T.core;
|
||||
const { listen } = T.event;
|
||||
|
||||
const qs = new URLSearchParams(location.search);
|
||||
const trackId = qs.get('track') || '';
|
||||
const pluginId = qs.get('plugin') || '';
|
||||
const pendingSet = new Set();
|
||||
const sliders = new Map(); // param_id -> {slider, val}
|
||||
|
||||
function setValue(paramId, value) {
|
||||
const el = sliders.get(paramId);
|
||||
if (!el) return;
|
||||
el.slider.value = value;
|
||||
el.val.textContent = Number(value).toFixed(3);
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
const box = document.getElementById('params');
|
||||
let list;
|
||||
try {
|
||||
list = await invoke('get_vst_params', { trackId, pluginId });
|
||||
} catch (e) {
|
||||
box.innerHTML = '<div id="msg">Lỗi: ' + String(e) + '</div>';
|
||||
return;
|
||||
}
|
||||
if (!list || list.length === 0) {
|
||||
box.innerHTML = '<div id="msg">Plugin không có param nào.</div>';
|
||||
return;
|
||||
}
|
||||
box.innerHTML = '';
|
||||
for (const p of list) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'row';
|
||||
const lbl = document.createElement('label');
|
||||
lbl.textContent = p.title;
|
||||
const val = document.createElement('span');
|
||||
val.className = 'val';
|
||||
val.textContent = Number(p.value).toFixed(3);
|
||||
const slider = document.createElement('input');
|
||||
slider.type = 'range'; slider.min = 0; slider.max = 1; slider.step = 0.001;
|
||||
slider.value = p.value;
|
||||
slider.addEventListener('input', () => { val.textContent = parseFloat(slider.value).toFixed(3); });
|
||||
slider.addEventListener('change', () => {
|
||||
const v = parseFloat(slider.value);
|
||||
pendingSet.add(p.param_id);
|
||||
invoke('set_vst_param', { trackId, pluginId, paramId: p.param_id, value: v })
|
||||
.catch(err => { console.error('set_vst_param', err); })
|
||||
.finally(() => setTimeout(() => pendingSet.delete(p.param_id), 500));
|
||||
});
|
||||
row.appendChild(lbl); row.appendChild(val); row.appendChild(slider);
|
||||
box.appendChild(row);
|
||||
sliders.set(p.param_id, { slider, val });
|
||||
}
|
||||
}
|
||||
|
||||
// native đổi param (từ editor/automation) -> cập nhật UI
|
||||
listen('vst_param_changed', (e) => {
|
||||
const d = e.payload || {};
|
||||
if (d.track_id !== trackId || d.plugin_id !== pluginId) return;
|
||||
if (pendingSet.has(d.param_id)) { pendingSet.delete(d.param_id); return; } // echo của chính mình
|
||||
setValue(d.param_id, d.value);
|
||||
}).catch(err => console.error('listen vst_param_changed', err));
|
||||
|
||||
refresh().catch(err => console.error('refresh', err));
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,76 @@
|
||||
"""Tests cho tools/autosample_vsti.py — SF2 writer tự-sinh phải là RIFF/sfbk
|
||||
hợp lệ mà sf2utils parse được (không cần VSTi/pedalboard)."""
|
||||
import os
|
||||
import sys
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from tools.autosample_vsti import write_sf2
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
FAKE_VST2 = os.path.join(ROOT, "native_host", "tests", "fake_vst2.dll")
|
||||
|
||||
sf2utils = pytest.importorskip("sf2utils.sf2parse")
|
||||
|
||||
|
||||
def _sine(note, sr=44100, dur=0.2):
|
||||
t = np.arange(int(sr * dur)) / sr
|
||||
f = 440.0 * 2 ** ((note - 69) / 12)
|
||||
return (np.sin(2 * np.pi * f * t) * 20000).astype(np.int16)
|
||||
|
||||
|
||||
def test_write_sf2_valid_structure(tmp_path):
|
||||
out = str(tmp_path / "test.sf2")
|
||||
samples = [{"note": n, "frames": _sine(n)} for n in (60, 62, 64)]
|
||||
write_sf2(out, samples, sample_rate=44100, name="Test")
|
||||
assert os.path.getsize(out) > 100
|
||||
with open(out, "rb") as f:
|
||||
head = f.read(12)
|
||||
assert head[:4] == b"RIFF"
|
||||
assert head[8:12] == b"sfbk"
|
||||
with open(out, "rb") as f:
|
||||
sf2 = sf2utils.Sf2File(f)
|
||||
real_samples = [s for s in sf2.samples if s.end > s.start]
|
||||
assert len(real_samples) == 3
|
||||
assert len(sf2.presets) == 2 # preset + terminator
|
||||
assert sf2.presets[0].bank == 0
|
||||
assert sf2.presets[0].preset == 0
|
||||
assert sf2.presets[0].name == "Test"
|
||||
for s in real_samples:
|
||||
assert s.end - s.start == 8820
|
||||
assert s.sample_rate == 44100
|
||||
|
||||
|
||||
def test_write_sf2_rejects_empty(tmp_path):
|
||||
with pytest.raises(ValueError):
|
||||
write_sf2(str(tmp_path / "empty.sf2"), [])
|
||||
|
||||
|
||||
def test_write_sf2_odd_name_no_corruption(tmp_path, caplog):
|
||||
"""INFO text chunk chan — ten le (vd "Nexus") khong duoc lam mat can
|
||||
(sf2utils khong skip pad byte cua odd-size chunk)."""
|
||||
import logging
|
||||
out = str(tmp_path / "odd.sf2")
|
||||
write_sf2(out, [{"note": 60, "frames": _sine(60)}], 44100, name="Nexus")
|
||||
with caplog.at_level(logging.WARNING):
|
||||
with open(out, "rb") as f:
|
||||
sf2 = sf2utils.Sf2File(f)
|
||||
assert len([s for s in sf2.samples if s.end > s.start]) == 1
|
||||
assert not any("corrupted" in r.message for r in caplog.records)
|
||||
|
||||
def test_autosample_vst2_native_bridge(tmp_path):
|
||||
"""Nhanh VST2: render qua native bridge (fake_vst2.dll) → SF2 hop le,
|
||||
khong can pedalboard. Skip tren nen khong phai Windows / thieu DLL."""
|
||||
if sys.platform.startswith("win") and os.path.isfile(FAKE_VST2):
|
||||
from tools.autosample_vsti import autosample_sf2
|
||||
out = str(tmp_path / "vst2.sf2")
|
||||
res = autosample_sf2("fake_vst2", out, low=60, high=64, step=2,
|
||||
duration=0.3, release=0.2,
|
||||
extra_vst_dirs=[os.path.dirname(FAKE_VST2)])
|
||||
assert res["note_count"] == 3
|
||||
assert res["size_bytes"] == os.path.getsize(out)
|
||||
with open(out, "rb") as f:
|
||||
sf2 = sf2utils.Sf2File(f)
|
||||
assert len([s for s in sf2.samples if s.end > s.start]) == 3
|
||||
else:
|
||||
pytest.skip("fake_vst2.dll chi chay Windows (bridge ctypes WinDLL)")
|
||||
@@ -0,0 +1,152 @@
|
||||
"""Test matrix T15 — một engine duy nhất cho track instrument (spec V).
|
||||
|
||||
SF × VST2 × (VST3 limitation) qua live (WASAPI) + offline (RenderBlock /
|
||||
SF_VST2_Process), master fader + track gain/pan áp native (NativeMixer).
|
||||
Chạy được trên Windows dev (cần build/Release DLL + fluidsynth_runtime).
|
||||
Chạy riêng: python -m pytest tests/test_native_matrix.py -v
|
||||
"""
|
||||
import ctypes
|
||||
import os
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
SF2 = os.path.join(ROOT, "app", "storage", "soundfonts",
|
||||
"518e850f-a5d3-4790-b1f9-0c90c203c524.sf2")
|
||||
FAKE_VST2 = os.path.join(ROOT, "native_host", "tests", "fake_vst2.dll")
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
os.name != "nt" or not os.path.isfile(os.path.join(
|
||||
ROOT, "native_host", "build", "Release", "sf_host_bridge.dll")),
|
||||
reason="cần Windows + native_host/build/Release (T9–T14)")
|
||||
|
||||
from app.core.native_audio_service import get_service # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def svc():
|
||||
s = get_service()
|
||||
s.close_all()
|
||||
s.set_master_gain(0.0)
|
||||
yield s
|
||||
s.close_all()
|
||||
s.set_master_gain(0.0)
|
||||
|
||||
|
||||
def _ratio_db(a, b):
|
||||
"""Sai lệch dB giữa 2 mức RMS (bảo vệ 0)."""
|
||||
ra = float(np.sqrt((a ** 2).mean())) if a.size else 0.0
|
||||
rb = float(np.sqrt((b ** 2).mean())) if b.size else 0.0
|
||||
if ra <= 0 and rb <= 0:
|
||||
return 0.0
|
||||
if ra <= 0 or rb <= 0:
|
||||
return float("inf")
|
||||
return abs(20.0 * np.log10(ra / rb))
|
||||
|
||||
|
||||
# ── SF live ────────────────────────────────────────────────────────────────
|
||||
def test_sf_live_blocks_underruns(svc):
|
||||
svc.ensure_sf("sf_live", SF2, bank=128, program=0, live=True)
|
||||
svc.sf_note_on("sf_live", 0, 60, 100)
|
||||
time.sleep(0.35)
|
||||
st = svc.sf_stats("sf_live")
|
||||
assert st and st["blocks"] > 0, f"audio thread không render: {st}"
|
||||
assert st["underruns"] == 0, f"underrun live: {st}"
|
||||
svc.sf_note_off("sf_live", 0, 60)
|
||||
time.sleep(0.15)
|
||||
st2 = svc.sf_stats("sf_live")
|
||||
assert st2["blocks"] > st["blocks"], "note_off không đẩy thêm block"
|
||||
svc.sf_audio_stop("sf_live")
|
||||
assert svc.sf_stats("sf_live") is not None
|
||||
|
||||
|
||||
# ── SF offline: master gain ────────────────────────────────────────────────
|
||||
def test_sf_offline_master_gain(svc):
|
||||
notes = [{"note": 60, "velocity": 100, "start_beat": 0, "duration_beats": 1}]
|
||||
y0 = svc.render_sf_offline(SF2, 128, 0, notes, master_gain_db=0.0)
|
||||
y6 = svc.render_sf_offline(SF2, 128, 0, notes, master_gain_db=-6.0)
|
||||
assert y0.size and float(np.abs(y0).max()) > 1e-6
|
||||
# master -6dB → peak ratio 10^(-6/20) ≈ 0.5012
|
||||
r = float(np.abs(y6).max() / np.abs(y0).max())
|
||||
assert abs(r - 10 ** (-6 / 20)) < 0.01, f"master gain ratio={r}"
|
||||
assert _ratio_db(y6, y0 * 10 ** (-6 / 20)) < 0.1
|
||||
|
||||
|
||||
# ── SF offline: track gain + pan ───────────────────────────────────────────
|
||||
def test_sf_offline_track_gain_pan(svc):
|
||||
notes = [{"note": 62, "velocity": 110, "start_beat": 0, "duration_beats": 1}]
|
||||
y_id = svc.render_sf_offline(SF2, 128, 0, notes, gain_db=0.0, pan=0.0)
|
||||
# pan -1 → chỉ kênh trái (constant-power: cos(0)=1, sin(0)=0)
|
||||
y_l = svc.render_sf_offline(SF2, 128, 0, notes, gain_db=0.0, pan=-1.0)
|
||||
assert float(np.abs(y_l[0]).max()) > 1e-6
|
||||
assert float(np.abs(y_l[1]).max()) < 1e-6, "pan=-1 phải tắt kênh phải"
|
||||
assert _ratio_db(y_l[0], y_id[0]) < 0.1, "pan=-1 kênh trái giữ mức"
|
||||
|
||||
|
||||
# ── VST2 live ──────────────────────────────────────────────────────────────
|
||||
def test_vst2_live_blocks_underruns(svc):
|
||||
svc.ensure_vst2("v2_live", FAKE_VST2, live=True)
|
||||
svc.vst2_note_on("v2_live", 0, 60, 100)
|
||||
time.sleep(0.35)
|
||||
st = svc.vst2_stats("v2_live")
|
||||
assert st and st["blocks"] > 0, f"audio thread không render: {st}"
|
||||
assert st["underruns"] == 0, f"underrun live: {st}"
|
||||
svc.vst2_note_off("v2_live", 0, 60)
|
||||
time.sleep(0.15)
|
||||
st2 = svc.vst2_stats("v2_live")
|
||||
assert st2["blocks"] > st["blocks"]
|
||||
svc.close_track("v2_live")
|
||||
|
||||
|
||||
# ── VST2 offline: fake sine + mixer ────────────────────────────────────────
|
||||
def test_vst2_offline_sine_and_mixer(svc):
|
||||
notes = [{"note": 60, "velocity": 100, "start_beat": 0, "duration_beats": 1}]
|
||||
y0 = svc.render_vst2_offline(FAKE_VST2, notes)
|
||||
assert float(np.abs(y0).max()) > 0.2, "fake sine 0.25 amplitude"
|
||||
y6 = svc.render_vst2_offline(FAKE_VST2, notes, gain_db=-6.0,
|
||||
master_gain_db=-6.0)
|
||||
r = float(np.abs(y6).max() / np.abs(y0).max())
|
||||
# track -6 * master -6 → 10^(-12/20) ≈ 0.2512
|
||||
assert abs(r - 10 ** (-12 / 20)) < 0.01, f"vst2 mixer ratio={r}"
|
||||
|
||||
|
||||
# ── VST2 offline: limiter ──────────────────────────────────────────────────
|
||||
def test_vst2_offline_limiter(svc):
|
||||
notes = [{"note": 60, "velocity": 127, "start_beat": 0, "duration_beats": 1}]
|
||||
y_hot = svc.render_vst2_offline(FAKE_VST2, notes, gain_db=+18.0)
|
||||
y_lim = svc.render_vst2_offline(FAKE_VST2, notes, gain_db=+18.0,
|
||||
lim_active=True, threshold_db=0.0)
|
||||
assert float(np.abs(y_hot).max()) > 1.0, "hot cần clip (fake sine 0.25 × +18dB)"
|
||||
assert float(np.abs(y_lim).max()) <= 1.0 + 1e-6, "limiter phải chặn clip"
|
||||
|
||||
|
||||
# ── VST3: limitation (bridge export) ───────────────────────────────────────
|
||||
def test_vst3_bridge_exports_master_gain():
|
||||
dll_path = os.path.join(ROOT, "native_host", "build", "Release",
|
||||
"vst3_host_bridge.dll")
|
||||
if not os.path.isfile(dll_path):
|
||||
pytest.skip("chưa build vst3_host_bridge.dll")
|
||||
dll = ctypes.WinDLL(dll_path)
|
||||
dll.SF_VST3_SetMasterGain.argtypes = [ctypes.c_int32, ctypes.c_float]
|
||||
dll.SF_VST3_SetMasterGain.restype = ctypes.c_int32
|
||||
# handle 0 → -1 (chưa có instance) — export gọi được, không crash
|
||||
assert dll.SF_VST3_SetMasterGain(0, 1.0) == -1
|
||||
|
||||
|
||||
# ── API endpoints ──────────────────────────────────────────────────────────
|
||||
def test_api_status_and_render(svc):
|
||||
from fastapi.testclient import TestClient
|
||||
from app.main import app
|
||||
c = TestClient(app)
|
||||
st = c.get("/api/v1/native/status").json()
|
||||
assert st["available"] is True
|
||||
r = c.post("/api/v1/native/render", json={
|
||||
"kind": "sf", "path": SF2, "bank": 128, "program": 0,
|
||||
"notes": [{"note": 60, "velocity": 100, "start_beat": 0,
|
||||
"duration_beats": 1}],
|
||||
"master_gain_db": -6.0})
|
||||
assert r.status_code == 200
|
||||
d = r.json()
|
||||
assert d["ok"] and d["samples"] > 0 and d["peak"] > 0
|
||||
@@ -18,3 +18,99 @@ def test_render_session_container_empty():
|
||||
buf = engine.render_session_container(session, {}, 120.0, 4, 1000)
|
||||
assert buf.shape == (2, 1000)
|
||||
assert (buf == 0.0).all()
|
||||
|
||||
def test_render_project_bit_depth(tmp_path):
|
||||
"""bit_depth 24/32 → WAV PCM_24/PCM_32; mặc định vẫn PCM_16."""
|
||||
import soundfile as sf
|
||||
engine = PythonRenderEngine()
|
||||
project = {
|
||||
"metadata": {"bpm": 120, "time_signature_numerator": 4},
|
||||
"main_session": {"length_bars": 1.0, "tracks": []},
|
||||
"section_store": {},
|
||||
}
|
||||
for bd, subtype in [(16, "PCM_16"), (24, "PCM_24"), (32, "PCM_32")]:
|
||||
out = str(tmp_path / f"render_{bd}.wav")
|
||||
engine.render_project(project, out, bit_depth=bd)
|
||||
assert sf.info(out).subtype == subtype
|
||||
|
||||
def _default_mastering():
|
||||
return {
|
||||
"masterConnected": True, "isBypassed": False,
|
||||
"eqActive": True, "eqLowGain": 1.5, "eqMid1Gain": -1.0, "eqMid2Gain": 2.0, "eqHighGain": 1.8,
|
||||
"imagerActive": True, "w1": 0, "w2": 115, "w3": 135, "w4": 150,
|
||||
"maximizerActive": True, "maxGain": 5.4, "maxUpward": 2.0, "maxSoftClip": 15, "ceiling": -0.1,
|
||||
"compActive": False, "compThreshold": -16, "compRatio": 3, "compMakeup": 2,
|
||||
"limActive": False, "limThreshold": -1.0,
|
||||
"excActive": False, "excDrive": 30,
|
||||
"rebalActive": False, "rebalMid": 0, "rebalSide": 0,
|
||||
"chain": [
|
||||
{"id": "mod_eq", "type": "eq", "active": True},
|
||||
{"id": "mod_imager", "type": "imager", "active": True},
|
||||
{"id": "mod_maximizer", "type": "maximizer", "active": True},
|
||||
],
|
||||
}
|
||||
|
||||
def test_apply_mastering_engine_gates_and_ceiling():
|
||||
"""apply_mastering: gate masterConnected/isBypassed/chain rỗng → copy;
|
||||
chain mặc định → khác input, peak ≤ ceiling (hard clip của Maximizer)."""
|
||||
import numpy as np
|
||||
from app.core.mastering_engine import apply_mastering
|
||||
sr = 44100
|
||||
t = np.arange(sr // 5) / sr
|
||||
buf = np.stack([np.sin(2 * np.pi * 220 * t) * 0.9,
|
||||
np.sin(2 * np.pi * 220 * t + 0.5) * 0.8]).astype(np.float32)
|
||||
s = _default_mastering()
|
||||
assert np.array_equal(apply_mastering(buf, None, sr), buf)
|
||||
assert np.array_equal(apply_mastering(buf, {**s, "masterConnected": False}, sr), buf)
|
||||
assert np.array_equal(apply_mastering(buf, {**s, "isBypassed": True}, sr), buf)
|
||||
assert np.array_equal(apply_mastering(buf, {**s, "chain": []}, sr), buf)
|
||||
out = apply_mastering(buf, s, sr)
|
||||
assert out.shape == buf.shape and not np.array_equal(out, buf)
|
||||
assert np.max(np.abs(out)) <= 10 ** (-0.1 / 20) + 1e-6 # ceiling -0.1 dB
|
||||
|
||||
def test_render_project_mastering(tmp_path):
|
||||
"""render_project: có mastering_settings → WAV khác bản không mastering,
|
||||
peak ≤ 1.0 (hard clip sau mastering như WAV encoder client)."""
|
||||
import numpy as np
|
||||
import soundfile as sf
|
||||
from app.config import settings
|
||||
engine = PythonRenderEngine()
|
||||
# 1s 440Hz tone làm nguồn audio thật (project rỗng = silence → mastering
|
||||
# của silence = silence, không test được gì)
|
||||
sr = engine.sample_rate
|
||||
t = np.arange(sr) / sr
|
||||
tone = (0.9 * np.sin(2 * np.pi * 440 * t)).astype(np.float32)
|
||||
src_path = os.path.join(settings.UPLOADS_DIR, "mastering_test_tone.wav")
|
||||
sf.write(src_path, tone, sr)
|
||||
try:
|
||||
project = {
|
||||
"metadata": {"bpm": 120, "time_signature_numerator": 4},
|
||||
"main_session": {
|
||||
"length_bars": 4.0,
|
||||
"tracks": [{
|
||||
"type": "AUDIO", "volume_db": 0.0, "pan": 0.0, "mute": False,
|
||||
"items": [{
|
||||
"type": "AUDIO_ITEM", "start_bar": 0.0, "duration_bars": 4.0,
|
||||
"clip_start_offset_bars": 0.0,
|
||||
"source_data": {"audio_file_url": "/static/audio/uploads/mastering_test_tone.wav", "gain": 1.0},
|
||||
}],
|
||||
}],
|
||||
},
|
||||
"section_store": {},
|
||||
}
|
||||
out_plain = str(tmp_path / "plain.wav")
|
||||
engine.render_project(project, out_plain, bit_depth=16)
|
||||
plain, _ = sf.read(out_plain, dtype="float32")
|
||||
assert np.max(np.abs(plain)) > 0.01
|
||||
|
||||
out_mast = str(tmp_path / "mastered.wav")
|
||||
engine.render_project({**project, "mastering_settings": _default_mastering()},
|
||||
out_mast, bit_depth=16)
|
||||
mast, _ = sf.read(out_mast, dtype="float32")
|
||||
assert not np.array_equal(mast, plain)
|
||||
assert np.max(np.abs(mast)) <= 1.0 + 1e-6
|
||||
finally:
|
||||
try:
|
||||
os.remove(src_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
00124e36b2edc6353e9a29c81762747294306d6b3cfeb46e365ff049ea60369e
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
8d8cb8360a19f87760d88df0f6702a6242238d3e51388341646346ad9683cffa
|
||||
Binary file not shown.
@@ -0,0 +1,57 @@
|
||||
// T4 test: node tests/unified_midi_router.test.js
|
||||
// Router chưa nối app — test độc lập qua window stub.
|
||||
'use strict';
|
||||
global.window = {};
|
||||
require('../app/static/js/services/unifiedMidiRouter.js');
|
||||
const R = global.window.SonicUnifiedMidiRouter;
|
||||
const assert = require('assert');
|
||||
|
||||
const calls = [];
|
||||
function makeEngine() {
|
||||
return {
|
||||
playNote(pitch, vel, dur) { calls.push(['on', pitch, vel, dur]); },
|
||||
noteOff(pitch) { calls.push(['off', pitch]); }
|
||||
};
|
||||
}
|
||||
|
||||
// 1) pitch clamp
|
||||
const r = new R.UnifiedMidiRouter();
|
||||
const eng = makeEngine();
|
||||
r.registerEngine('t1', eng);
|
||||
r.dispatchMidiEvent({ trackId: 't1', channel: 0, command: R.COMMAND_NOTE_ON, pitch: 200, velocity: 1, sourceType: 'TEST' });
|
||||
assert.deepStrictEqual(calls[0], ['on', 127, 127, 500], 'pitch clamp 200->127');
|
||||
r.dispatchMidiEvent({ trackId: 't1', channel: 0, command: R.COMMAND_NOTE_OFF, pitch: -3 });
|
||||
assert.strictEqual(calls.length, 1, 'note-off khong co voice -> khong goi engine (pitch clamp khong crash)');
|
||||
|
||||
// 2) velocity normalize float->int (100/127 -> 100, 0.5 -> 64)
|
||||
r.dispatchMidiEvent({ trackId: 't1', channel: 1, command: R.COMMAND_NOTE_ON, pitch: 60, velocity: 100 / 127 });
|
||||
assert.deepStrictEqual(calls[1], ['on', 60, 100, 500], 'velocity 100/127 -> 100');
|
||||
r.dispatchMidiEvent({ trackId: 't1', channel: 1, command: R.COMMAND_NOTE_ON, pitch: 61, velocity: 0.5 });
|
||||
assert.deepStrictEqual(calls[2], ['on', 61, 64, 500], 'velocity 0.5 -> 64');
|
||||
|
||||
// 3) tracker đếm đúng: 2 note-on trùng key -> 1 playNote; off cuối mới tắt
|
||||
r.dispatchMidiEvent({ trackId: 't1', channel: 2, command: R.COMMAND_NOTE_ON, pitch: 64, velocity: 100 });
|
||||
r.dispatchMidiEvent({ trackId: 't1', channel: 2, command: R.COMMAND_NOTE_ON, pitch: 64, velocity: 100 });
|
||||
assert.strictEqual(r.activeVoiceCount('t1', 2, 64), 2, '2 note-on count=2');
|
||||
const onCalls = calls.filter(c => c[0] === 'on' && c[1] === 64).length;
|
||||
assert.strictEqual(onCalls, 1, 'trung key chi play 1 lan');
|
||||
r.dispatchMidiEvent({ trackId: 't1', channel: 2, command: R.COMMAND_NOTE_OFF, pitch: 64 });
|
||||
assert.strictEqual(r.activeVoiceCount('t1', 2, 64), 1, '1 off -> count=1, chua noteOff');
|
||||
r.dispatchMidiEvent({ trackId: 't1', channel: 2, command: R.COMMAND_NOTE_OFF, pitch: 64 });
|
||||
assert.strictEqual(r.activeVoiceCount('t1', 2, 64), 0, 'off cuoi -> count=0');
|
||||
assert.deepStrictEqual(calls[calls.length - 1], ['off', 64], 'off cuoi moi noteOff');
|
||||
|
||||
// 4) 2 track cùng channel+pitch đếm riêng (không lệch voice)
|
||||
const eng2 = makeEngine();
|
||||
r.registerEngine('t2', eng2);
|
||||
r.dispatchMidiEvent({ trackId: 't2', channel: 2, command: R.COMMAND_NOTE_ON, pitch: 64, velocity: 90 });
|
||||
assert.strictEqual(r.activeVoiceCount('t2', 2, 64), 1);
|
||||
assert.strictEqual(r.activeVoiceCount('t1', 2, 64), 0);
|
||||
|
||||
// 5) panic clear: note đang giữ -> noteOff + tracker rỗng
|
||||
r.dispatchMidiEvent({ trackId: 't1', channel: 3, command: R.COMMAND_NOTE_ON, pitch: 72, velocity: 80 });
|
||||
r.panicAllNotesOff();
|
||||
assert.strictEqual(r.activeVoiceCount('t1', 3, 72), 0, 'panic xoa tracker');
|
||||
assert.deepStrictEqual(calls[calls.length - 1], ['off', 72], 'panic gui noteOff');
|
||||
|
||||
console.log('unified_midi_router: ALL TESTS PASSED');
|
||||
@@ -0,0 +1,250 @@
|
||||
"""Auto-sample VSTi presets -> SF2/SF3 cho client-side WASM live preview.
|
||||
|
||||
Spec (Option 1): "a custom Python script" render preset qua tung not -> .sf3
|
||||
(~3-8MB) de FluidSynth WASM phat realtime voi 0% IPC. Script nay render not
|
||||
bang pedalboard (DUNG VSTi + preset nhu export) -> SF2 (16-bit mono PCM,
|
||||
1 zone/not). --sf3 chuyen tiep qua SoundFontConverter (can ffmpeg libvorbis).
|
||||
|
||||
Usage:
|
||||
python tools/autosample_vsti.py --instrument <plugin_id> --out out.sf2 \
|
||||
[--preset <path>] [--low 36 --high 96 --step 2] [--sf3]
|
||||
|
||||
Ket qua dat vao app/storage/soundfonts/ de client tai qua /soundfonts/download.
|
||||
"""
|
||||
import argparse
|
||||
import os
|
||||
import struct
|
||||
import sys
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
sys.path.insert(0, ROOT)
|
||||
|
||||
import numpy as np
|
||||
|
||||
|
||||
def write_sf2(out_path, samples, sample_rate=44100, name="AutoSampled"):
|
||||
"""Ghi SF2 toi gian hop le. samples: list[dict] voi note:int, frames:
|
||||
np.int16 mono 1D (audio da render cua not do)."""
|
||||
if not samples:
|
||||
raise ValueError("no samples")
|
||||
|
||||
def chunk(cid, data):
|
||||
out = bytearray(cid) + struct.pack("<I", len(data)) + data
|
||||
if len(data) % 2:
|
||||
out += b"\x00"
|
||||
return bytes(out)
|
||||
|
||||
def lst(ftype, chunks):
|
||||
return chunk(b"LIST", ftype + b"".join(chunks))
|
||||
|
||||
# --- sdta: PCM 16-bit mono, end exclusive (cung convention SF3 converter) ---
|
||||
pcm = bytearray()
|
||||
offsets = []
|
||||
for s in samples:
|
||||
frames = np.asarray(s["frames"], dtype=np.int16)
|
||||
offsets.append((len(pcm) // 2, frames.shape[0]))
|
||||
pcm += frames.tobytes()
|
||||
if len(pcm) % 2:
|
||||
pcm += b"\x00"
|
||||
sdta = lst(b"sdta", [chunk(b"smpl", bytes(pcm))])
|
||||
|
||||
# --- INFO ---
|
||||
inam = name.encode("ascii", "replace")
|
||||
if len(inam) % 2:
|
||||
inam += b"\x00" # INFO text chunks phai chan (sf2utils/strict parsers khong skip pad byte)
|
||||
info = lst(b"INFO", [
|
||||
chunk(b"ifil", struct.pack("<HH", 2, 1)),
|
||||
chunk(b"INAM", inam),
|
||||
chunk(b"iver", struct.pack("<HH", 2, 1)),
|
||||
])
|
||||
|
||||
# --- pdta ---
|
||||
n = len(samples)
|
||||
phdr = bytearray()
|
||||
phdr += name.encode("ascii", "replace")[:20].ljust(20, b"\x00")
|
||||
phdr += struct.pack("<HHHIII", 0, 0, 0, 0, 0, 0) # preset=0 bank=0 bagNdx=0 libr genre morph
|
||||
phdr += b"EOP".ljust(20, b"\x00") + struct.pack("<HHHIII", 0, 0, 1, 0, 0, 0)
|
||||
pbag = struct.pack("<HH", 0, 0) + struct.pack("<HH", 1, 0)
|
||||
pmod = b"\x00" * 10
|
||||
pgen = struct.pack("<HH", 41, 0) + struct.pack("<HH", 0, 0)
|
||||
inst = bytearray()
|
||||
inst += b"AutoSampled".ljust(20, b"\x00") + struct.pack("<H", 0)
|
||||
inst += b"EOI".ljust(20, b"\x00") + struct.pack("<H", n) # bagNdx = chi so record terminal zone (ibag co n+1 records)
|
||||
ibag = b"".join(struct.pack("<HH", i * 3, 0) for i in range(n + 1))
|
||||
imod = b"\x00" * 10
|
||||
igen = bytearray()
|
||||
for i, s in enumerate(samples):
|
||||
note = int(s["note"]) & 0xFF
|
||||
igen += struct.pack("<HH", 43, note | (note << 8)) # keyRange lo=hi=note
|
||||
igen += struct.pack("<HH", 58, note) # overridingRootKey
|
||||
igen += struct.pack("<HH", 53, i) # sampleID (phai la gen cuoi zone)
|
||||
igen += struct.pack("<HH", 0, 0)
|
||||
shdr = bytearray()
|
||||
for i, s in enumerate(samples):
|
||||
note = int(s["note"])
|
||||
start, frames = offsets[i]
|
||||
shdr += f"note{note:03d}".encode()[:20].ljust(20, b"\x00")
|
||||
shdr += struct.pack("<IIIIi", start, start + frames, 0, 0, sample_rate)
|
||||
shdr += struct.pack("<BBH", note, 0, 0) # originalPitch correction sampleLink
|
||||
shdr += struct.pack("<H", 1) # sampleType: mono
|
||||
shdr += b"\x00" * 46 # terminator
|
||||
pdta = lst(b"pdta", [
|
||||
chunk(b"phdr", bytes(phdr)), chunk(b"pbag", pbag), chunk(b"pmod", pmod),
|
||||
chunk(b"pgen", pgen), chunk(b"inst", bytes(inst)), chunk(b"ibag", ibag),
|
||||
chunk(b"imod", imod), chunk(b"igen", bytes(igen)), chunk(b"shdr", bytes(shdr)),
|
||||
])
|
||||
|
||||
body = info + sdta + pdta
|
||||
out = bytearray(b"RIFF") + struct.pack("<I", 4 + len(body)) + b"sfbk" + body
|
||||
with open(out_path, "wb") as f:
|
||||
f.write(out)
|
||||
return out_path
|
||||
|
||||
|
||||
def _render_note(vst, note, sr, dur, release, velocity):
|
||||
# pedalboard >= 0.9: MIDI messages la tuple (bytes raw MIDI, timestamp_seconds)
|
||||
messages = [
|
||||
(bytes([0x90, int(note) & 0x7F, max(0, min(127, velocity))]), 0.0),
|
||||
(bytes([0x80, int(note) & 0x7F, 0]), dur),
|
||||
]
|
||||
buf = vst(messages, sample_rate=sr, duration=dur + release, num_channels=2)
|
||||
mono = buf.mean(axis=0)
|
||||
peak = float(np.max(np.abs(mono)))
|
||||
if peak < 1e-5:
|
||||
return None
|
||||
above = np.nonzero(np.abs(mono) > peak * 0.001)[0]
|
||||
start = int(above[0]) if above.size else 0
|
||||
audio = mono[start:] / peak * 0.9
|
||||
return np.clip(audio * 32767, -32768, 32767).astype(np.int16)
|
||||
|
||||
|
||||
def _postprocess(buf, sr):
|
||||
"""Stereo float32 (2,N) -> int16 mono, peak 0.9 (chung cho 2 engine)."""
|
||||
mono = buf.mean(axis=0)
|
||||
peak = float(np.max(np.abs(mono)))
|
||||
if peak < 1e-5:
|
||||
return None
|
||||
above = np.nonzero(np.abs(mono) > peak * 0.001)[0]
|
||||
start = int(above[0]) if above.size else 0
|
||||
audio = mono[start:] / peak * 0.9
|
||||
return np.clip(audio * 32767, -32768, 32767).astype(np.int16)
|
||||
|
||||
|
||||
def _is_vst2_path(path):
|
||||
"""VST2 = .dll/.so dung rieng (Windows VST3 la FOLDER .vst3, .so VST3
|
||||
co duoi .vst3.so) — native bridge render duoc; pedalboard thi khong."""
|
||||
low = (path or "").lower()
|
||||
if ".vst3" in low:
|
||||
return False
|
||||
return low.endswith(".dll") or low.endswith(".so")
|
||||
|
||||
|
||||
def _render_note_native(plugin_path, note, sr, dur, release, velocity):
|
||||
"""VST2 qua native bridge (Windows) — pedalboard khong ho tro VST2."""
|
||||
from app.core.native_audio_service import get_service
|
||||
bpm = 120.0
|
||||
y = get_service().render_vst2_offline(
|
||||
plugin_path,
|
||||
[{"start_beat": 0.0, "duration_beats": dur * bpm / 60.0,
|
||||
"note": int(note) & 0x7F, "velocity": int(velocity)}],
|
||||
bpm=bpm, sr=sr, tail_sec=release,
|
||||
)
|
||||
return _postprocess(y, sr)
|
||||
|
||||
|
||||
def autosample_sf2(instrument_id, out_path, preset_id=None, preset_path=None,
|
||||
preset_data_b64=None, low=36, high=96, step=2, duration=2.5,
|
||||
release=1.0, velocity=100, sample_rate=44100, name=None, log=None,
|
||||
extra_vst_dirs=None):
|
||||
"""Auto-sample VSTi -> SF2 (16-bit mono). Dung cho server endpoint
|
||||
/api/v1/plugins/autosample va CLI main().
|
||||
|
||||
VST3 -> pedalboard (ap preset neu co); VST2 (.dll/.so khong .vst3) ->
|
||||
native bridge (Windows) — pedalboard khong ho tro VST2, preset VST2 chua
|
||||
qua bridge duoc nen bo qua.
|
||||
|
||||
Returns dict {note_count, out_path, size_bytes}.
|
||||
Raises FileNotFoundError neu plugin khong tim thay, RuntimeError neu
|
||||
engine khong kha dung hoac khong render duoc not nao.
|
||||
"""
|
||||
from app.core.vst_engine import PluginManager, apply_preset_to_plugin, HAS_PEDALBOARD
|
||||
|
||||
plugin_path = PluginManager(extra_vst_dirs=extra_vst_dirs or [])._scan_plugins().get(instrument_id)
|
||||
if not plugin_path:
|
||||
raise FileNotFoundError(f"Khong tim thay VSTi: {instrument_id} - hay Scan trong Plugin Manager truoc")
|
||||
|
||||
def _log(msg):
|
||||
if log:
|
||||
log(msg)
|
||||
|
||||
if _is_vst2_path(plugin_path):
|
||||
# VST2: native bridge (Windows) — pedalboard khong ho tro. Preset chua
|
||||
# export qua bridge (VST2AudioEngine chua co chunk/preset) -> bo qua.
|
||||
if preset_id or preset_path or preset_data_b64:
|
||||
_log("VST2: native bridge chua ho tro preset - bo qua, sample preset mac dinh")
|
||||
def _render(note):
|
||||
return _render_note_native(plugin_path, note, sample_rate, duration,
|
||||
release, velocity)
|
||||
else:
|
||||
if not HAS_PEDALBOARD:
|
||||
raise RuntimeError("pedalboard khong kha dung - khong auto-sample VST3 duoc")
|
||||
vst = PluginManager(extra_vst_dirs=extra_vst_dirs or []).load_vst(instrument_id)
|
||||
if vst is None:
|
||||
raise FileNotFoundError(f"Khong tim thay VSTi: {instrument_id} - hay Scan trong Plugin Manager truoc")
|
||||
if preset_id or preset_path or preset_data_b64:
|
||||
apply_preset_to_plugin(vst, preset_id=preset_id, preset_path=preset_path,
|
||||
preset_data_b64=preset_data_b64)
|
||||
def _render(note):
|
||||
return _render_note(vst, note, sample_rate, duration, release, velocity)
|
||||
|
||||
samples = []
|
||||
for note in range(low, high + 1, step):
|
||||
frames = _render(note)
|
||||
if frames is None:
|
||||
_log(f"note {note}: silent, bo qua")
|
||||
continue
|
||||
samples.append({"note": note, "frames": frames})
|
||||
_log(f"note {note}: {frames.shape[0] / sample_rate:.2f}s")
|
||||
if not samples:
|
||||
raise RuntimeError("Khong render duoc not nao (plugin silent?)")
|
||||
|
||||
if name is None:
|
||||
name = os.path.basename(instrument_id)
|
||||
write_sf2(out_path, samples, sample_rate, name=name)
|
||||
size = os.path.getsize(out_path)
|
||||
_log(f"wrote {out_path} ({size // 1024} KB, {len(samples)} not)")
|
||||
return {"note_count": len(samples), "out_path": out_path, "size_bytes": size}
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
|
||||
ap.add_argument("--instrument", required=True, help="plugin_id da scan (Plugin Manager)")
|
||||
ap.add_argument("--out", required=True, help="duong dan .sf2 (hoac .sf3 voi --sf3)")
|
||||
ap.add_argument("--preset", default=None, help="duong dan preset .vstpreset/.fxp/.fxb")
|
||||
ap.add_argument("--low", type=int, default=36)
|
||||
ap.add_argument("--high", type=int, default=96)
|
||||
ap.add_argument("--step", type=int, default=2, help="buoc not (2 = nua cung)")
|
||||
ap.add_argument("--duration", type=float, default=2.5, help="giay giu not")
|
||||
ap.add_argument("--release", type=float, default=1.0, help="giay duoi sau note-off")
|
||||
ap.add_argument("--velocity", type=int, default=100)
|
||||
ap.add_argument("--sample-rate", type=int, default=44100)
|
||||
ap.add_argument("--sf3", action="store_true", help="convert SF2 -> SF3 sau khi sample")
|
||||
args = ap.parse_args()
|
||||
|
||||
try:
|
||||
result = autosample_sf2(
|
||||
args.instrument, args.out, preset_path=args.preset,
|
||||
low=args.low, high=args.high, step=args.step,
|
||||
duration=args.duration, release=args.release,
|
||||
velocity=args.velocity, sample_rate=args.sample_rate,
|
||||
)
|
||||
except (FileNotFoundError, RuntimeError) as e:
|
||||
sys.exit(str(e))
|
||||
print(f"wrote {result['out_path']} ({result['size_bytes'] // 1024} KB, {result['note_count']} not)")
|
||||
|
||||
if args.sf3:
|
||||
from app.core.soundfont_converter import SoundFontConverter
|
||||
p = SoundFontConverter().convert_sf2_to_sf3(args.out)
|
||||
print("sf3:", p)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user