From 500384a226a21ea2ac529f2b57c7e657f72f6366 Mon Sep 17 00:00:00 2001 From: locphamtran Date: Tue, 11 Aug 2026 17:11:29 +0700 Subject: [PATCH] T14: SF host bridge native (FluidSynth runtime DLL) + golden test --- .gitignore | 2 + native_host/CMakeLists.txt | 13 + native_host/SFHost.cpp | 434 +++++++++++++++++++ native_host/scripts/dl_fluidsynth_runtime.py | 133 ++++++ native_host/tests/test_sf_host_bridge.py | 93 ++++ native_host/tests/test_sf_host_golden.py | 187 ++++++++ 6 files changed, 862 insertions(+) create mode 100644 native_host/SFHost.cpp create mode 100644 native_host/scripts/dl_fluidsynth_runtime.py create mode 100644 native_host/tests/test_sf_host_bridge.py create mode 100644 native_host/tests/test_sf_host_golden.py diff --git a/.gitignore b/.gitignore index de61f00..a04a398 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,5 @@ native_host/tests/*.dll native_host/tests/*.exp native_host/tests/*.lib native_host/tests/*.obj +native_host/tests/*.exe +native_host/fluidsynth_runtime/ diff --git a/native_host/CMakeLists.txt b/native_host/CMakeLists.txt index e103a79..a133d55 100644 --- a/native_host/CMakeLists.txt +++ b/native_host/CMakeLists.txt @@ -64,3 +64,16 @@ target_compile_definitions(vst2_host_bridge PRIVATE 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) diff --git a/native_host/SFHost.cpp b/native_host/SFHost.cpp new file mode 100644 index 0000000..cf12bb2 --- /dev/null +++ b/native_host/SFHost.cpp @@ -0,0 +1,434 @@ +// 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 + +#include +#include +#include +#include +#include +#include +#include +#include + +#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 (GetProcAddress (m, "new_fluid_settings")); + fs.settings_setnum = reinterpret_cast (GetProcAddress (m, "fluid_settings_setnum")); + fs.settings_setstr = reinterpret_cast (GetProcAddress (m, "fluid_settings_setstr")); + fs.settings_delete = reinterpret_cast (GetProcAddress (m, "delete_fluid_settings")); + fs.synth_new = reinterpret_cast (GetProcAddress (m, "new_fluid_synth")); + fs.synth_delete = reinterpret_cast (GetProcAddress (m, "delete_fluid_synth")); + fs.synth_sfload = reinterpret_cast (GetProcAddress (m, "fluid_synth_sfload")); + fs.synth_noteon = reinterpret_cast (GetProcAddress (m, "fluid_synth_noteon")); + fs.synth_noteoff = reinterpret_cast (GetProcAddress (m, "fluid_synth_noteoff")); + fs.synth_all_notes_off = reinterpret_cast (GetProcAddress (m, "fluid_synth_all_notes_off")); + fs.synth_write_float = reinterpret_cast (GetProcAddress (m, "fluid_synth_write_float")); + fs.synth_set_gain = reinterpret_cast (GetProcAddress (m, "fluid_synth_set_gain")); + fs.synth_program_select = reinterpret_cast (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 audioRunning {false}; + sonicforge::NativeMixer mixer; +}; + +std::mutex g_mutex; +std::map> 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 (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 (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 (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 lock (g_mutex); + + auto inst = std::make_unique (); + 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 (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 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 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 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 (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 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 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 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 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 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 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 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 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 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 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; +} + +// Đó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 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" diff --git a/native_host/scripts/dl_fluidsynth_runtime.py b/native_host/scripts/dl_fluidsynth_runtime.py new file mode 100644 index 0000000..911d69c --- /dev/null +++ b/native_host/scripts/dl_fluidsynth_runtime.py @@ -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() diff --git a/native_host/tests/test_sf_host_bridge.py b/native_host/tests/test_sf_host_bridge.py new file mode 100644 index 0000000..5c9f360 --- /dev/null +++ b/native_host/tests/test_sf_host_bridge.py @@ -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") diff --git a/native_host/tests/test_sf_host_golden.py b/native_host/tests/test_sf_host_golden.py new file mode 100644 index 0000000..779372b --- /dev/null +++ b/native_host/tests/test_sf_host_golden.py @@ -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()