diff --git a/native_host/CMakeLists.txt b/native_host/CMakeLists.txt index 1983a0e..e103a79 100644 --- a/native_host/CMakeLists.txt +++ b/native_host/CMakeLists.txt @@ -32,7 +32,7 @@ target_compile_definitions(vst3_host_bridge PRIVATE NOMINMAX WIN32_LEAN_AND_MEAN ) -target_link_libraries(vst3_host_bridge PRIVATE audio_engine) +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 @@ -47,6 +47,12 @@ target_compile_definitions(audio_engine PRIVATE ) 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 @@ -57,4 +63,4 @@ target_compile_definitions(vst2_host_bridge PRIVATE NOMINMAX WIN32_LEAN_AND_MEAN ) -target_link_libraries(vst2_host_bridge PRIVATE audio_engine) +target_link_libraries(vst2_host_bridge PRIVATE audio_engine native_mixer) diff --git a/native_host/NativeMixer.cpp b/native_host/NativeMixer.cpp new file mode 100644 index 0000000..4170cf0 --- /dev/null +++ b/native_host/NativeMixer.cpp @@ -0,0 +1,90 @@ +// 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; + } + outL[i] = l; + outR[i] = r; + } +} + +} // namespace sonicforge diff --git a/native_host/NativeMixer.h b/native_host/NativeMixer.h new file mode 100644 index 0000000..1d3efc2 --- /dev/null +++ b/native_host/NativeMixer.h @@ -0,0 +1,54 @@ +// 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 +#include + +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); + + // 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) +}; + +} // namespace sonicforge diff --git a/native_host/VST2AudioEngine.cpp b/native_host/VST2AudioEngine.cpp index fd4c3b8..d05e793 100644 --- a/native_host/VST2AudioEngine.cpp +++ b/native_host/VST2AudioEngine.cpp @@ -24,6 +24,7 @@ #include "vestige.h" #include "AudioEngine.h" +#include "NativeMixer.h" #include @@ -50,6 +51,7 @@ struct Instance void* paramCbUserdata = nullptr; sonicforge::AudioEngine audio; // T12: native audio loop (SPSC + WASAPI) std::atomic audioRunning {false}; + sonicforge::NativeMixer mixer; // T13: track gain/pan + master limiter }; std::mutex g_mutex; @@ -163,6 +165,7 @@ bool vst2AudioProcess (const sonicforge::MidiEvent* events, int32 eventCount, { 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; @@ -602,4 +605,26 @@ __declspec (dllexport) int32 SF_VST2_AudioBlocks (int32 handle) 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 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 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; +} + } // extern "C" diff --git a/native_host/tests/native_mixer_test.cpp b/native_host/tests/native_mixer_test.cpp new file mode 100644 index 0000000..cde634d --- /dev/null +++ b/native_host/tests/native_mixer_test.cpp @@ -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 +#include +#include +#include +#include + +#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 (std::atof (argv[3])); + const float pan = static_cast (std::atof (argv[4])); + const int32 limActive = std::atoi (argv[5]); + const float thresholdDb = static_cast (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 buf (static_cast (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 (buf.size () / 2); + std::vector inL (static_cast (frames)); + std::vector inR (static_cast (frames)); + for (int32 i = 0; i < frames; ++i) + { + inL[static_cast (i)] = buf[static_cast (2 * i)]; + inR[static_cast (i)] = buf[static_cast (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 (2 * i)] = inL[static_cast (i)]; + buf[static_cast (2 * i + 1)] = inR[static_cast (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; +} diff --git a/native_host/tests/test_native_mixer_golden.py b/native_host/tests/test_native_mixer_golden.py new file mode 100644 index 0000000..5b38e27 --- /dev/null +++ b/native_host/tests/test_native_mixer_golden.py @@ -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()) diff --git a/native_host/vst3_host_bridge.cpp b/native_host/vst3_host_bridge.cpp index 1a32053..97690c8 100644 --- a/native_host/vst3_host_bridge.cpp +++ b/native_host/vst3_host_bridge.cpp @@ -33,6 +33,7 @@ #include "pluginterfaces/vst/ivstevents.h" #include "AudioEngine.h" +#include "NativeMixer.h" #include @@ -99,6 +100,7 @@ struct Instance ComponentHandler handler; sonicforge::AudioEngine audio; // T12: native audio loop (SPSC + WASAPI) std::atomic 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). @@ -189,7 +191,9 @@ bool vst3AudioProcess (const sonicforge::MidiEvent* events, int32 eventCount, data.outputs = &outBus; data.inputEvents = &list; - return proc->process (data) == kResultOk; + const tresult res = proc->process (data); + inst->mixer.processInPlace (outL, outR, frames); // T13: gain/pan + limiter + return res == kResultOk; } std::mutex g_mutex; @@ -547,4 +551,26 @@ __declspec (dllexport) int32 SF_VST3_SendNoteOff (int32 handle, int32 channel, i 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 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 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; +} + } // extern "C"