24 Commits

Author SHA1 Message Date
admin 59cf77bcbc fix(midi): route MIDI live via masterBus, fix media explorer drag, add action logging
- TrackInstrument: gate native live path (SF/VSTi WASAPI bridge bypassed
  masterBus -> mastering FX + main out VU dead). Fallback to SonicSF WASM
  through masterBus.input; native kept for offline render only.
- MEDIA_LIBRARY_SAMPLES: rename MIDI_Loop_02_Bass/03_Lead/05_Bass to match
  actual /static/midi files (drag was 404 -> parse error).
- Add SonicAppLogger (console + POST /api/v1/logs) wired to: instrument load
  error, VSTi autosample fail, MIDI item -> mastering fx chain, play -> main
  out, drag MIDI errors (media explorer + window explorer).
- Add backend /api/v1/logs endpoint (server-side log, no DB).
2026-08-11 21:48:06 +07:00
admin e3c9d6e4ab build: ship prebuilt native_host bridge DLLs so fresh clones skip VST3 SDK requirement 2026-08-11 20:42:45 +07:00
admin 255e8586bc connect solution into app: VSTi live native (vst3 attach/vst2 bridge) with WASM autosample fallback, VST GUI window (Bug 1), native-first track instrument 2026-08-11 20:35:53 +07:00
admin 916ce1334b build: bundle native_host bridges + fluidsynth runtime vao installer (build_windows.ps1 [4b/6], SF_RESOURCE_DIR fallback, tauri resources native_host) 2026-08-11 18:48:06 +07:00
admin 94dc7dcf12 Sử dụng VST2/VST3 trên window với FluidSynth 2026-08-11 18:05:49 +07:00
admin 371b1ca665 T16: autosample VST2 via native bridge + SF2/SF3 export hoàn chỉnh, UI chip size/note_count, re-sample on preset change, tests 2026-08-11 18:04:34 +07:00
admin 0af834a8fa T15: drop WebAudio master for tracks - native master gain + track gain/pan IPC (SF/VST2/VST3 bridges), NativeAudioService + /api/v1/native router, native-first TrackInstrument, test matrix 8 passed 2026-08-11 17:33:12 +07:00
admin 500384a226 T14: SF host bridge native (FluidSynth runtime DLL) + golden test 2026-08-11 17:11:29 +07:00
admin b9817e1aed T13: NativeMixer mirror server pipeline (track gain/pan constant-power + brickwall limiter tanh, wired VST2/VST3 bridge, golden test 0.0001dB) 2026-08-11 16:53:15 +07:00
admin 8f1e89ce50 T12: native audio loop SPSC + WASAPI (exclusive/shared, zero-lock audio thread, SendNote -> SPSC) 2026-08-11 16:40:30 +07:00
admin 9eec33cc79 T11: IPC param sync 2 chieu native<->JS (setParamNormalized/setParameter, vst_param_changed event, guard chong loop) 2026-08-11 16:27:30 +07:00
admin aaf21ddf89 T10: VST2 engine bridge (vestige.h clean-room, VST2AudioEngine.cpp, fake_vst2 test plugin) 2026-08-11 16:18:45 +07:00
admin 6bc197b909 T9: VST3 GUI bridge DLL (native_host/vst3_host_bridge.cpp, CMakeLists.txt) 2026-08-11 16:09:54 +07:00
admin a12c133ba6 T8: Rust open_vst_gui command - floating window + HWND via raw-window-handle 2026-08-11 16:01:22 +07:00
admin 9fc5498999 T7: hardware MIDI keyboard route qua UnifiedMidiRouter (3 nguon 1 router) 2026-08-11 15:57:12 +07:00
admin 9dcc37e037 T6: scheduler items qua router - note-on/off TIMELINE_SCHEDULER (startTime audio clock support trong router+TrackInstrument); 8 site scheduler (timeline/section/local/piano-roll/ghost) route UnifiedMidiRouter, fallback SonicSF, tracker doi xung qua note-off setTimeout 2026-08-11 15:48:21 +07:00
admin 2001d1c601 T5: unified MIDI routing - thay native preview bang UnifiedMidiRouter + TrackInstrument (per-track FluidSynth channel); keybed/click/draw/hardware MIDI note qua router, fallback SonicSF; _panicMidi stop moi voice; bo _nativeSfPreviews/playNativeSfNote/stopNativeSfNote/stopAllNativeSfNotes/_sfWasmFallbackNote 2026-08-11 15:45:53 +07:00
admin ee30ca097e T4: UnifiedMidiRouter - 1 diem dispatch MIDI moi nguon
UnifiedMidiEvent (trackId, channel, command, pitch clamp 0-127, velocity
normalize float->int 1-127, sourceType, timestampNs) + UnifiedMidiRouter:
engineRegistry Map<trackId, engine> (route theo track), dispatchMidiEvent,
activeVoiceTracker key trackId_channel_pitch (dem note-on/off dung - het
stuck notes, note-off cuoi moi tat voice), panicAllNotesOff. Chua noi
vao app (T5-T7). Test node: pitch clamp, velocity normalize, tracker,
panic clear - xanh.
2026-08-11 15:38:29 +07:00
admin fa2f96dbe3 T3: Autosample UX - fail reason + toast + pre-warm feedback
vstiAutosample: fail map thanh object {ts, reason} (cooldown check
object-aware), recordFail o API-missing/response loi/catch, export
failReasonFor. soundfontPlayer: toast ly do khi ensure null (throttle
10s) roi fallback oscillator. app.jsx: pre-warm VSTi hien thi toast khi
autosample fail.
2026-08-11 15:36:10 +07:00
admin 6da0c5b68d fix: Bug 2 mastering — chainSignature order-sensitive (reorder rebuild graph); applyMasteringSettings sig gom chain; limiter/compressor default neutral (limThreshold 0, compThreshold 0 ratio 1) — them module khong tu cat volume (T2) 2026-08-11 15:33:35 +07:00
admin aa06d40b98 fix: Bug 1 keyboard preview cam lan 2+ — token race: stale check != token -> > token (claim-then-compare) trong _sfWasmFallbackNote + API path (T1) 2026-08-11 15:31:56 +07:00
admin cadb5402a3 feat: VSTi live playback 100% client-side (không Carla) — autosample SF2 backend (pedalboard) → FluidSynth WASM qua masterBus → mastering → main out; SonicVstiAutosample ensure+dedup+cooldown; runtime gating __enableCarlaLivePlayback; âm bắt qua ensureMidiCapture → clientSideExport 2026-08-11 14:28:42 +07:00
admin 7293d7ac7e feat: VSTi autosample -> SF2/SF3 (tools/autosample_vsti.py) + bit_depth 16/24/32 render/export WAV (render_engine, plugins API, audio_editor, app.jsx encoders) + tests + task/walkthrough docs (53) 2026-08-11 12:09:02 +07:00
admin f8cb2c6a5e Sửa lỗi trực tiếp trên window 2026-08-11 09:51:13 +07:00
70 changed files with 18053 additions and 333 deletions
+11
View File
@@ -33,7 +33,18 @@ node_modules
src-tauri/target/ src-tauri/target/
src-tauri/binaries/ src-tauri/binaries/
src-tauri/resources/daw_engine/ 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 src-tauri/vc_redist.x64.exe
app/storage/plugin_dirs.json app/storage/plugin_dirs.json
app/storage/sf_scan_state.json.bak-root app/storage/sf_scan_state.json.bak-root
app/storage/soundfonts/ 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/
+39
View File
@@ -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}
+260
View File
@@ -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()))}
+130 -7
View File
@@ -490,6 +490,7 @@ async def download_soundfont_asset(sf_id: str):
class RenderRequest(BaseModel): class RenderRequest(BaseModel):
project_json: dict project_json: dict
output_filename: Optional[str] = "render_output.wav" output_filename: Optional[str] = "render_output.wav"
bit_depth: int = 16 # 16/24/32 — WAV PCM
class OpenInCarlaRequest(BaseModel): class OpenInCarlaRequest(BaseModel):
@@ -573,7 +574,7 @@ def _carla_osc_ready() -> bool:
import socket import socket
port = _carla_osc_port() port = _carla_osc_port()
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 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() s.close()
return False return False
except OSError: except OSError:
@@ -749,12 +750,20 @@ async def carla_stop(authorization: Optional[str] = Header(None)):
_send_carla_all_notes_off() _send_carla_all_notes_off()
except Exception: except Exception:
pass 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 killed = 0
_prune_carla_processes() _prune_carla_processes()
for proc in list(_CARLA_PROCESSES): for proc in list(_CARLA_PROCESSES):
try: try:
proc.terminate() 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: except Exception:
pass pass
# Chờ tiến trình thoát (tối đa ~2s) rồi kill mạnh nếu còn sống # Chờ tiến trình thoát (tối đa ~2s) rồi kill mạnh nếu còn sống
@@ -774,6 +783,15 @@ async def carla_stop(authorization: Optional[str] = Header(None)):
except Exception: except Exception:
pass pass
_CARLA_PROCESSES.clear() _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 { return {
"success": True, "success": True,
"stopped": True, "stopped": True,
@@ -916,6 +934,7 @@ class MidiRenderRequest(BaseModel):
preset_id: Optional[str] = None preset_id: Optional[str] = None
preset_path: Optional[str] = None preset_path: Optional[str] = None
preset_data: Optional[str] = None # base64 bytes .vstpreset (project nhúng) preset_data: Optional[str] = None # base64 bytes .vstpreset (project nhúng)
bit_depth: int = 16 # 16/24/32 — WAV PCM
@router.post("/midi-render") @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_id=req.preset_id,
preset_path=req.preset_path, preset_path=req.preset_path,
preset_data_b64=req.preset_data, preset_data_b64=req.preset_data,
bit_depth=req.bit_depth,
) )
return { return {
"success": True, "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, def _render_midi_notes_pedalboard(instrument_id: str, notes: list, bpm: float,
sample_rate: int, preset_id=None, preset_path=None, sample_rate: int, preset_id=None, preset_path=None,
preset_data_b64=None, soundfont_bank=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. """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.""" 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, buf = vst(midi_messages, sample_rate=sample_rate,
duration=total_needed / float(sample_rate), num_channels=2) duration=total_needed / float(sample_rate), num_channels=2)
out_path = os.path.join(settings.PROCESSED_DIR, f"preview_{uuid.uuid4().hex[:10]}.wav") 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) return out_path, buf.shape[1] / float(sample_rate)
@@ -1039,6 +1060,7 @@ class SoundfontRenderRequest(BaseModel):
notes: list = [] notes: list = []
bpm: float = 120.0 bpm: float = 120.0
sample_rate: int = 44100 sample_rate: int = 44100
bit_depth: int = 16 # 16/24/32 — WAV PCM
@router.post("/soundfont-render") @router.post("/soundfont-render")
@@ -1074,7 +1096,8 @@ async def soundfont_render(req: SoundfontRenderRequest, current_user: dict = Dep
bank=req.bank, program=req.program, bank=req.bank, program=req.program,
sr=req.sample_rate, bpm=req.bpm, 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 { return {
"success": True, "success": True,
"file_id": os.path.basename(out_path), "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}") 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): class CarlaPlayNotesRequest(BaseModel):
"""Phát dãy MIDI notes qua Carla bridge (OSC, realtime) — preview khi """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 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" safe_name += ".wav"
output_path = os.path.join(settings.PROCESSED_DIR, safe_name) output_path = os.path.join(settings.PROCESSED_DIR, safe_name)
try: 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} return {"url": f"/static/audio/processed/{os.path.basename(result_path)}", "path": result_path}
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=f"Render failed: {str(e)}") raise HTTPException(status_code=500, detail=f"Render failed: {str(e)}")
+2 -2
View File
@@ -193,7 +193,7 @@ def mix_multitrack_session(tracks_meta: list, output_path: str, sample_rate: int
y, sr_read = sf.read(temp_wav) y, sr_read = sf.read(temp_wav)
# Xác định subtype mã hóa bit-depth # 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") selected_subtype = subtype_map.get(bit_depth, "PCM_16")
# Ghi tệp WAV chất lượng cao # 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") sound.export(temp_wav, format="wav")
y, sr_read = sf.read(temp_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") selected_subtype = subtype_map.get(bit_depth, "PCM_16")
sf.write(output_path, y, sample_rate, subtype=selected_subtype) sf.write(output_path, y, sample_rate, subtype=selected_subtype)
+260
View File
@@ -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
+659
View File
@@ -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 (T9T14)")
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
+24 -7
View File
@@ -450,7 +450,7 @@ class PythonRenderEngine:
return session_buffer 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"] bpm = project_json["metadata"]["bpm"]
time_sig_num = project_json["metadata"].get("time_signature_numerator", 4) time_sig_num = project_json["metadata"].get("time_signature_numerator", 4)
main_session = project_json["main_session"] main_session = project_json["main_session"]
@@ -470,11 +470,28 @@ class PythonRenderEngine:
_cache={}, _cache={},
) )
# Normalization to prevent clipping # Mastering chain — mirror client WebAudio (app.jsx applyMasteringSettings /
max_peak = np.max(np.abs(master_buffer)) # rebuildMasteringGraph). Truoc day mastering_settings bi BO QUA hoan toan:
if max_peak > 1.0: # export WAV khong qua EQ/Imager/Maximizer -> file khac am nghe live.
master_buffer /= max_peak # 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 # Write final output file (bit_depth: 16/24/32 → WAV PCM subtype)
sf.write(output_filepath, master_buffer.T, self.sample_rate) 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 return output_filepath
+39 -21
View File
@@ -5,6 +5,31 @@ import logging
import wave import wave
logger = logging.getLogger(__name__) 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 = [ SF_TARGET_DIRS = [
"/opt/daw_engine/soundfonts", "/opt/daw_engine/soundfonts",
@@ -193,8 +218,8 @@ class SoundFontConverter:
# OGG loop pointers are relative to the individual decompressed sample # OGG loop pointers are relative to the individual decompressed sample
new_sloop = (startloop - start) if (startloop > start and startloop <= end) else 0 new_sloop = (startloop - start) if (startloop > start and startloop <= end) else 0
new_eloop = (endloop - start) if (endloop > start and endloop <= 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) # Mark the sample as Ogg Vorbis compressed (FLUID_SAMPLETYPE_OGG_VORBIS = 0x10)
new_stype = sampletype | 0x20 new_stype = sampletype | 0x10
new_shdr += data[base:base + 20] # sample name new_shdr += data[base:base + 20] # sample name
new_shdr += struct.pack("<IIIIi", new_start, new_end, new_sloop, new_eloop, rate) new_shdr += struct.pack("<IIIIi", new_start, new_end, new_sloop, new_eloop, rate)
new_shdr += data[base + 40:base + 44] # originalpitch, correction, samplelink 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 """Verify a SoundFont actually loads and renders audible audio (guards
against shipping malformed SF3 files that silently play nothing). against shipping malformed SF3 files that silently play nothing).
Uses the low-level CFFI binding (new_fluid_synth / write_float) — the Uses the high-level Synth() binding (sfload/get_samples).
high-level Synth() class does not exist in this binding, so it is never
used here.
""" """
if not os.path.exists(path): if not os.path.exists(path):
return False return False
_ensure_fluidsynth_runtime()
try: try:
import fluidsynth as _fs import fluidsynth as _fs
import numpy as np import numpy as np
_settings = _fs.new_fluid_settings() _synth = _fs.Synth()
_fl = _fs.new_fluid_synth(_settings)
try: try:
h = _fs.fluid_synth_sfload(_fl, path.encode("utf-8"), 1) fid = _synth.sfload(path)
if h < 0: if fid == -1:
return False return False
_fs.fluid_synth_program_select(_fl, 0, h, 0, 0) _synth.program_select(0, fid, 0, 0)
_fs.fluid_synth_noteon(_fl, 0, 60, 100) _synth.noteon(0, 60, 100)
frames = 8820 # 0.2s buf = _synth.get_samples(8820) # int16 stereo, 0.2s
buf = np.zeros(frames * 2, dtype=np.float32) _synth.noteoff(0, 60)
_fs.fluid_synth_write_float( rms = float(np.sqrt(np.mean((buf.astype(np.float32) / 32768.0) ** 2)))
_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)))
return rms > 1e-4 return rms > 1e-4
finally: finally:
try: try:
_fs.delete_fluid_synth(_fl) _synth.delete()
except Exception: except Exception:
pass pass
except Exception: except Exception:
@@ -453,7 +471,7 @@ class SoundFontConverter:
new_loopstart = loopstart + new_start if (loopstart or loopend) else 0 new_loopstart = loopstart + new_start if (loopstart or loopend) else 0
new_loopend = loopend + 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 # Clear the Ogg Vorbis flag; keep mono/left/right/linked flags
new_stype = sampletype & ~0x20 new_stype = sampletype & ~0x10
new_shdr += name new_shdr += name
new_shdr += struct.pack("<IIIIi", new_start, new_end, new_loopstart, new_loopend, rate) new_shdr += struct.pack("<IIIIi", new_start, new_end, new_loopstart, new_loopend, rate)
new_shdr += data[base + 40:base + 44] new_shdr += data[base + 40:base + 44]
+13
View File
@@ -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.media import router as media_router
from app.api.v1.system import router as system_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.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.auth import seed_admin
from app.core.soundfont_scanner import SoundFontAutoScanner 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(media_router, prefix="/api/v1/media", tags=["media"])
app.include_router(system_router, prefix="/api/v1/system", tags=["system"]) 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(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") @app.get("/health")
@@ -132,6 +136,15 @@ async def get_index():
return resp 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") @app.get("/favicon.svg")
async def get_favicon(): async def get_favicon():
favicon_path = os.path.join(settings.TEMPLATES_DIR, "favicon.svg") favicon_path = os.path.join(settings.TEMPLATES_DIR, "favicon.svg")
+509 -201
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+8
View File
@@ -91,6 +91,8 @@ window.API_BASE_URL = window.API_BASE_URL || window.location.origin;
// để UI preview / gán clip vào track / download // để UI preview / gán clip vào track / download
midiRender: (payload) => apiRequest('/api/v1/plugins/midi-render', { method: 'POST', body: JSON.stringify(payload) }), 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) }), 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 // Phát dãy MIDI notes realtime qua Carla bridge (OSC) — preview khi
// pedalboard không render được plugin (VD VST2) // pedalboard không render được plugin (VD VST2)
carlaPlayNotes: (payload) => apiRequest('/api/v1/plugins/carla-play-notes', { method: 'POST', body: JSON.stringify(payload) }), 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'); throw new Error(err.detail || 'Upload failed');
} }
return resp.json(); 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 });
} }
}; };
})(); })();
+40
View File
@@ -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); }
};
})();
+2 -2
View File
@@ -6,9 +6,9 @@
if (window.__FLUIDSYNTH_CDN) { if (window.__FLUIDSYNTH_CDN) {
FLUIDSYNTH_JS_URL = 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 { } 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'; 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 () {});
}
};
})();
+12
View File
@@ -74,6 +74,10 @@ window.SonicRuntime = window.SonicRuntime || { loaded: false, capabilities: null
window.SonicCarlaMidi = window.SonicCarlaMidi || { window.SonicCarlaMidi = window.SonicCarlaMidi || {
shouldRoute: function (synthEngine, isArmed) { shouldRoute: function (synthEngine, isArmed) {
try { 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; var c = window.SonicRuntime && window.SonicRuntime.capabilities;
if (!c || !c.features || !c.features.carla_local) return false; if (!c || !c.features || !c.features.carla_local) return false;
if (!isArmed) return false; if (!isArmed) return false;
@@ -85,6 +89,8 @@ window.SonicCarlaMidi = window.SonicCarlaMidi || {
// user đã chủ động bấm Play trên item đó). // user đã chủ động bấm Play trên item đó).
shouldRoutePlayback: function (synthEngine) { shouldRoutePlayback: function (synthEngine) {
try { 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; var c = window.SonicRuntime && window.SonicRuntime.capabilities;
if (!c || !c.features || !c.features.carla_local) return false; if (!c || !c.features || !c.features.carla_local) return false;
var se = synthEngine || {}; var se = synthEngine || {};
@@ -120,6 +126,12 @@ window.SonicCarlaMidi = window.SonicCarlaMidi || {
stopBridge: function () { stopBridge: function () {
var self = this; var self = this;
try { self.allNotesOff(); } catch (e) {} 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) { if (window.SonicAPI && window.SonicAPI.stopCarla) {
return window.SonicAPI.stopCarla().catch(function () {}); return window.SonicAPI.stopCarla().catch(function () {});
} }
+38 -2
View File
@@ -464,7 +464,7 @@
bank = bank !== undefined ? bank : (synthEngine.soundfont_bank || 0); bank = bank !== undefined ? bank : (synthEngine.soundfont_bank || 0);
program = program !== undefined ? program : (synthEngine.soundfont_program || 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; var engKey = (sfId || '') + ':' + bank + ':' + program;
if (!_engineChMap[engKey]) { if (!_engineChMap[engKey]) {
var channel = this.allocateChannel(bank); var channel = this.allocateChannel(bank);
@@ -587,7 +587,43 @@
if (isNaN(finalBank) || !isFinite(finalBank)) finalBank = 0; if (isNaN(finalBank) || !isFinite(finalBank)) finalBank = 0;
var finalProg = parseInt(usedProg); var finalProg = parseInt(usedProg);
if (isNaN(finalProg) || !isFinite(finalProg)) finalProg = 0; 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]; var cachedCh = _channels[ch];
// The note's own synth engine (track instrument) is // The note's own synth engine (track instrument) is
// authoritative. Channel state is only a cache: it must never // authoritative. Channel state is only a cache: it must never
+136
View File
@@ -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;
})();
+179
View File
@@ -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
};
})();
+138
View File
@@ -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
};
})();
+132
View File
@@ -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>
+9 -4
View File
@@ -33,12 +33,17 @@
<script src="/static/vendor/react.production.min.js"></script> <script src="/static/vendor/react.production.min.js"></script>
<script src="/static/vendor/react-dom.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/fluidsynthLoader.js?v=202607271245"></script>
<script src="/static/js/services/runtime.js?v=202608101800"></script> <script src="/static/js/services/runtime.js?v=202608111230"></script>
<script src="/static/js/services/api.js?v=202608101800"></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/audioEngine.js?v=202607271016"></script>
<script src="/static/js/services/storage.js?v=202608038200"></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/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/aiGateway.js?v=202608037200"></script>
<script src="/static/js/services/dawCommandDispatcher.js?v=202607271016"></script> <script src="/static/js/services/dawCommandDispatcher.js?v=202607271016"></script>
<script src="/static/js/services/pianoRollTabService.js?v=202607272044"></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/midiExtractor.js?v=202607281052"></script>
<script src="/static/js/services/promptTemplateManager.js?v=202607281039"></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/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"> <link rel="stylesheet" href="/static/css/styles.css?v=202607271016">
<style> <style>
:root { :root {
+45
View File
@@ -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 Copy-Item "dist\daw_engine\*" "src-tauri\resources\daw_engine\" -Recurse -Force
Write-Host "Copied onedir engine -> src-tauri\resources\daw_engine" 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 ==" Write-Host "== [5/6] VC++ Redistributable cho hooks.nsh =="
if (-not (Test-Path src-tauri\vc_redist.x64.exe)) { 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 Invoke-WebRequest -Uri "https://aka.ms/vs/17/release/vc_redist.x64.exe" -OutFile src-tauri\vc_redist.x64.exe
+82
View File
@@ -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)
```
+343
View File
@@ -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
+109
View File
@@ -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
+79
View File
@@ -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)
+92
View File
@@ -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
+59
View File
@@ -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
+446
View File
@@ -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"
+641
View File
@@ -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()
+142
View File
@@ -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) = &rect;
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);
}
+84
View File
@@ -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())
+93
View File
@@ -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")
+187
View File
@@ -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()
+194
View File
@@ -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);
+587
View File
@@ -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"
+239 -4
View File
@@ -13,6 +13,9 @@
"jsdom": "^30.0.1", "jsdom": "^30.0.1",
"react": "^19.2.8", "react": "^19.2.8",
"react-dom": "^19.2.8" "react-dom": "^19.2.8"
},
"devDependencies": {
"@tauri-apps/cli": "^2.11.4"
} }
}, },
"node_modules/@asamuzakjp/css-color": { "node_modules/@asamuzakjp/css-color": {
@@ -608,6 +611,238 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@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": { "node_modules/@types/gensync": {
"version": "1.0.5", "version": "1.0.5",
"resolved": "https://registry.npmjs.org/@types/gensync/-/gensync-1.0.5.tgz", "resolved": "https://registry.npmjs.org/@types/gensync/-/gensync-1.0.5.tgz",
@@ -651,15 +886,15 @@
} }
}, },
"node_modules/brace-expansion": { "node_modules/brace-expansion": {
"version": "5.0.7", "version": "5.0.9",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
"integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"balanced-match": "^4.0.2" "balanced-match": "^4.0.2"
}, },
"engines": { "engines": {
"node": "18 || 20 || >=22" "node": "20 || >=22"
} }
}, },
"node_modules/browserslist": { "node_modules/browserslist": {
+3
View File
@@ -12,5 +12,8 @@
"jsdom": "^30.0.1", "jsdom": "^30.0.1",
"react": "^19.2.8", "react": "^19.2.8",
"react-dom": "^19.2.8" "react-dom": "^19.2.8"
},
"devDependencies": {
"@tauri-apps/cli": "^2.11.4"
} }
} }
+66
View File
@@ -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ó.
+202
View File
@@ -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 (T1T3) → B (T4T7) → C (T8T15) → 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 25 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 T4T5 đã 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 ở T13T16.
### 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 pan0.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/lim1), (3dB/pan0.8/lim6). 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 đủ.
+4678
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -15,6 +15,9 @@ tauri-build = { version = "2", features = [] }
tauri = { version = "2", features = [] } tauri = { version = "2", features = [] }
tauri-plugin-shell = "2" tauri-plugin-shell = "2"
tauri-plugin-dialog = "2" tauri-plugin-dialog = "2"
raw-window-handle = "0.6"
webview2-com = "0.38"
windows-core = "0.61"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
File diff suppressed because one or more lines are too long
+1
View File
@@ -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.
+9
View File
@@ -17,6 +17,9 @@ use tauri_plugin_shell::ShellExt;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::Mutex; use std::sync::Mutex;
mod vst_gui;
mod permissions;
struct EngineProcess(Mutex<Option<CommandChild>>); struct EngineProcess(Mutex<Option<CommandChild>>);
/// Các vị trí có thể chứa daw_engine, theo thứ tự ưu tiên. /// 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() tauri::Builder::default()
.plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_dialog::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| { .setup(|app| {
permissions::install(
&app.get_webview_window("main").expect("main window missing"),
);
vst_gui::init(app.handle());
let res_dir = app let res_dir = app
.path() .path()
.resource_dir() .resource_dir()
@@ -134,6 +142,7 @@ pub fn run() {
.shell() .shell()
.command(&engine_exe) .command(&engine_exe)
.env("SF_PARENT_PID", std::process::id().to_string()) .env("SF_PARENT_PID", std::process::id().to_string())
.env("SF_RESOURCE_DIR", res_dir.to_string_lossy().to_string())
.spawn() .spawn()
{ {
Ok((_rx, child)) => { Ok((_rx, child)) => {
+27
View File
@@ -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);
}
}
});
}
+357
View File
@@ -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)
}
}
+2 -1
View File
@@ -33,7 +33,8 @@
"icons/favicon.ico" "icons/favicon.ico"
], ],
"resources": { "resources": {
"resources/daw_engine": "daw_engine/" "resources/daw_engine": "daw_engine/",
"resources/native_host": "native_host/"
}, },
"externalBin": [], "externalBin": [],
"windows": { "windows": {
+101
View File
@@ -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>
+76
View File
@@ -0,0 +1,76 @@
"""Tests cho tools/autosample_vsti.py — SF2 writer tự-sinh phải là RIFF/sfbk
hợp lệ 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)")
+152
View File
@@ -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 (T9T14)")
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
+96
View File
@@ -18,3 +18,99 @@ def test_render_session_container_empty():
buf = engine.render_session_container(session, {}, 120.0, 4, 1000) buf = engine.render_session_container(session, {}, 120.0, 4, 1000)
assert buf.shape == (2, 1000) assert buf.shape == (2, 1000)
assert (buf == 0.0).all() 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
+1
View File
@@ -0,0 +1 @@
00124e36b2edc6353e9a29c81762747294306d6b3cfeb46e365ff049ea60369e
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
8d8cb8360a19f87760d88df0f6702a6242238d3e51388341646346ad9683cffa
Binary file not shown.
+57
View File
@@ -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');
+250
View File
@@ -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()