diff --git a/src/audio/CMakeLists.txt b/src/audio/CMakeLists.txt index 1284418..41489d9 100644 --- a/src/audio/CMakeLists.txt +++ b/src/audio/CMakeLists.txt @@ -14,6 +14,11 @@ set(AUDIO_SOURCES addon/AddonContext.cpp addon/ChainOps.cpp addon/EditorWindows.cpp + addon/DeviceBindings.cpp + addon/ControlBindings.cpp + addon/DetectionBindings.cpp + addon/ChainBindings.cpp + addon/BackingBindings.cpp SourceChain.cpp SignalChain.cpp VSTHost.cpp diff --git a/src/audio/NodeAddon.cpp b/src/audio/NodeAddon.cpp index 9532876..4fe7664 100644 --- a/src/audio/NodeAddon.cpp +++ b/src/audio/NodeAddon.cpp @@ -30,6 +30,7 @@ #include "addon/NapiHelpers.h" #include "addon/ChainOps.h" #include "addon/EditorWindows.h" +#include "addon/Bindings.h" using slopsmith::addon::closeAllPluginEditorWindows; using slopsmith::addon::destroyAllPluginEditorWindowsOnMessageThread; @@ -41,6 +42,94 @@ using slopsmith::addon::LoadNAMModel; using slopsmith::addon::LoadIR; using slopsmith::addon::ReplaceIR; using slopsmith::addon::LoadPreset; +using slopsmith::addon::AddSource; +using slopsmith::addon::BindInputDevice; +using slopsmith::addon::ClearChain; +using slopsmith::addon::ClearStreamOutput; +using slopsmith::addon::DetectNotes; +using slopsmith::addon::EnableFileLogging; +using slopsmith::addon::GetBackingDuration; +using slopsmith::addon::GetBackingLevel; +using slopsmith::addon::GetBackingPosition; +using slopsmith::addon::GetBufferSizes; +using slopsmith::addon::GetChainGeneration; +using slopsmith::addon::GetChainState; +using slopsmith::addon::GetCurrentDevice; +using slopsmith::addon::GetDeviceMetrics; +using slopsmith::addon::GetDeviceTypes; +using slopsmith::addon::GetLevels; +using slopsmith::addon::GetNoteVerdicts; +using slopsmith::addon::GetParameters; +using slopsmith::addon::GetPitchDetection; +using slopsmith::addon::GetRawAudioFrame; +using slopsmith::addon::GetRawPitchDetection; +using slopsmith::addon::GetRendererBusMetrics; +using slopsmith::addon::GetSampleRate; +using slopsmith::addon::GetSampleRates; +using slopsmith::addon::GetSourceLevels; +using slopsmith::addon::GetSourceNoteVerdicts; +using slopsmith::addon::GetSourcePitchDetection; +using slopsmith::addon::GetSourceRawAudioFrame; +using slopsmith::addon::GetSourceRawPitchDetection; +using slopsmith::addon::GetStreamOverflowCount; +using slopsmith::addon::GetStreamSinkLevel; +using slopsmith::addon::GetStreamUnderflowCount; +using slopsmith::addon::IsAudioRunning; +using slopsmith::addon::IsBackingPlaying; +using slopsmith::addon::IsMlNoteDetection; +using slopsmith::addon::IsMonitorMuted; +using slopsmith::addon::IsStreamOutputActive; +using slopsmith::addon::ListInputDevices; +using slopsmith::addon::ListSources; +using slopsmith::addon::LoadBackingTrack; +using slopsmith::addon::LoadNoteModel; +using slopsmith::addon::MoveProcessor; +using slopsmith::addon::ProbeDeviceOptions; +using slopsmith::addon::PushRendererAudio; +using slopsmith::addon::RemoveProcessor; +using slopsmith::addon::RemoveSource; +using slopsmith::addon::ResetPeaks; +using slopsmith::addon::SavePreset; +using slopsmith::addon::ScoreChord; +using slopsmith::addon::ScoreSourceChord; +using slopsmith::addon::SeekBacking; +using slopsmith::addon::SendMidiToSlot; +using slopsmith::addon::SetBackingSpeed; +using slopsmith::addon::SetBranch; +using slopsmith::addon::SetBranchSrc; +using slopsmith::addon::SetBypass; +using slopsmith::addon::SetChart; +using slopsmith::addon::SetDevice; +using slopsmith::addon::SetDeviceType; +using slopsmith::addon::SetGain; +using slopsmith::addon::SetInputChannel; +using slopsmith::addon::SetMonitorKill; +using slopsmith::addon::SetMonitorMute; +using slopsmith::addon::SetMonitorMuteSuppressed; +using slopsmith::addon::SetMultiBypass; +using slopsmith::addon::SetNoiseGate; +using slopsmith::addon::SetNoteDetectionEnabled; +using slopsmith::addon::SetOutputDeviceType; +using slopsmith::addon::SetPan; +using slopsmith::addon::SetParameter; +using slopsmith::addon::SetPostGain; +using slopsmith::addon::SetRendererBus; +using slopsmith::addon::SetSlotState; +using slopsmith::addon::SetSourceChart; +using slopsmith::addon::SetSourceInputChannel; +using slopsmith::addon::SetSourceMonitorMute; +using slopsmith::addon::SetSourceVerifierOffset; +using slopsmith::addon::SetStreamBus; +using slopsmith::addon::SetStreamBusGain; +using slopsmith::addon::SetStreamOutputDevice; +using slopsmith::addon::SetTonePolish; +using slopsmith::addon::StartAudio; +using slopsmith::addon::StartBacking; +using slopsmith::addon::StopAudio; +using slopsmith::addon::StopBacking; +using slopsmith::addon::UnbindInputDevice; +using slopsmith::addon::scoreChordCore; +using slopsmith::addon::setChartCore; // Lifetime/threading moved to addon/AddonContext (TLC phase 6); the usings // keep the 100+ existing binding bodies unchanged. @@ -53,21 +142,6 @@ using slopsmith::addon::cancelAllPendingLoads; using slopsmith::addon::doShutdown; -// Validate a JS source-id argument and return the live source, or nullptr if it is -// missing / not a Number / not a FINITE INTEGER / out of range. The TS bridge already -// validates, but the addon must fail soft on its own: Int32Value() silently coerces -// NaN/Infinity into a valid index (NaN -> 0), which would let a malformed id hit a -// real source (e.g. the default source 0). getSource() does the final -// [0, kMaxSources) + active check; the 4096 guard keeps the cast well-defined. -static SourceChain* getValidatedSource(AudioEngine* eng, const Napi::CallbackInfo& info, size_t argIndex) -{ - if (eng == nullptr || argIndex >= info.Length() || ! info[argIndex].IsNumber()) - return nullptr; - const double raw = info[argIndex].As().DoubleValue(); - if (! std::isfinite(raw) || raw != std::floor(raw) || raw < 0.0 || raw > 4096.0) - return nullptr; - return eng->getSource((int) raw); -} @@ -90,1573 +164,6 @@ static Napi::Value Shutdown(const Napi::CallbackInfo& info) return info.Env().Undefined(); } -// ── Device Enumeration ──────────────────────────────────────────────────────── - -static Napi::Value GetDeviceTypes(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto liveEngine = snapshotEngine(); - if (!liveEngine) return env.Null(); - - // Device types are already scanned during init — safe to read from any thread - auto types = liveEngine->getDeviceTypes(); - - auto result = Napi::Array::New(env, types.size()); - - for (int i = 0; i < types.size(); ++i) - { - auto obj = Napi::Object::New(env); - obj.Set("name", types[i].name.toStdString()); - - auto inputs = Napi::Array::New(env, types[i].inputDevices.size()); - for (int j = 0; j < types[i].inputDevices.size(); ++j) - inputs.Set((uint32_t)j, types[i].inputDevices[j].toStdString()); - obj.Set("inputs", inputs); - - auto outputs = Napi::Array::New(env, types[i].outputDevices.size()); - for (int j = 0; j < types[i].outputDevices.size(); ++j) - outputs.Set((uint32_t)j, types[i].outputDevices[j].toStdString()); - obj.Set("outputs", outputs); - - result.Set((uint32_t)i, obj); - } - - return result; -} - -static Napi::Value GetSampleRates(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto liveEngine = snapshotEngine(); - if (!liveEngine) return Napi::Array::New(env); - - auto rates = liveEngine->getSampleRates(); - auto result = Napi::Array::New(env, rates.size()); - for (int i = 0; i < rates.size(); ++i) - result.Set((uint32_t)i, rates[i]); - return result; -} - -static Napi::Value GetBufferSizes(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto liveEngine = snapshotEngine(); - if (!liveEngine) return Napi::Array::New(env); - - auto sizes = liveEngine->getBufferSizes(); - auto result = Napi::Array::New(env, sizes.size()); - for (int i = 0; i < sizes.size(); ++i) - result.Set((uint32_t)i, sizes[i]); - return result; -} - -static Napi::Value ProbeDeviceOptions(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto liveEngine = snapshotEngine(); - auto obj = Napi::Object::New(env); - // 3-arg legacy (type, input, output) or 4-arg dual (inputType, input, outputType, output). - auto arg0 = info.Length() > 0 && info[0].IsString() ? info[0].As().Utf8Value() : ""; - auto arg1 = info.Length() > 1 && info[1].IsString() ? info[1].As().Utf8Value() : ""; - auto arg2 = info.Length() > 2 && info[2].IsString() ? info[2].As().Utf8Value() : ""; - auto arg3 = info.Length() > 3 && info[3].IsString() ? info[3].As().Utf8Value() : ""; - - std::string inputType = arg0; - std::string inputName = arg1; - std::string outputType; - std::string outputName; - if (info.Length() >= 4) - { - outputType = arg2; - outputName = arg3; - } - else - { - outputType = arg0; - outputName = arg2; - } - - auto ratesArray = Napi::Array::New(env); - auto buffersArray = Napi::Array::New(env); - auto inputChannelsArray = Napi::Array::New(env); - auto outputChannelsArray = Napi::Array::New(env); - - obj.Set("type", inputType); - obj.Set("inputType", inputType); - obj.Set("outputType", outputType); - obj.Set("input", inputName); - obj.Set("output", outputName); - obj.Set("inputChannels", inputChannelsArray); - obj.Set("outputChannels", outputChannelsArray); - obj.Set("sampleRates", ratesArray); - obj.Set("bufferSizes", buffersArray); - obj.Set("compatible", true); - if (!liveEngine) - { - obj.Set("error", "Audio engine not initialized"); - obj.Set("compatible", false); - return obj; - } - - auto options = liveEngine->probeDeviceOptionsDual( - juce::String(inputType), juce::String(inputName), - juce::String(outputType), juce::String(outputName)); - obj.Set("type", options.inputType.toStdString()); // legacy alias - obj.Set("inputType", options.inputType.toStdString()); - obj.Set("outputType", options.outputType.toStdString()); - obj.Set("input", options.input.toStdString()); - obj.Set("output", options.output.toStdString()); - obj.Set("error", options.error.toStdString()); - obj.Set("compatible", options.compatible); - - inputChannelsArray = Napi::Array::New(env, options.inputChannels.size()); - for (int i = 0; i < options.inputChannels.size(); ++i) - inputChannelsArray.Set((uint32_t)i, options.inputChannels[i].toStdString()); - obj.Set("inputChannels", inputChannelsArray); - - outputChannelsArray = Napi::Array::New(env, options.outputChannels.size()); - for (int i = 0; i < options.outputChannels.size(); ++i) - outputChannelsArray.Set((uint32_t)i, options.outputChannels[i].toStdString()); - obj.Set("outputChannels", outputChannelsArray); - - ratesArray = Napi::Array::New(env, options.sampleRates.size()); - for (int i = 0; i < options.sampleRates.size(); ++i) - ratesArray.Set((uint32_t)i, options.sampleRates[i]); - obj.Set("sampleRates", ratesArray); - - buffersArray = Napi::Array::New(env, options.bufferSizes.size()); - for (int i = 0; i < options.bufferSizes.size(); ++i) - buffersArray.Set((uint32_t)i, options.bufferSizes[i]); - obj.Set("bufferSizes", buffersArray); - - return obj; -} - -static Napi::Value GetCurrentDevice(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto liveEngine = snapshotEngine(); - if (!liveEngine) return env.Null(); - - auto obj = Napi::Object::New(env); - const auto inputType = liveEngine->getCurrentInputDeviceType().toStdString(); - const auto outputType = liveEngine->getCurrentOutputDeviceType().toStdString(); - obj.Set("type", inputType); - obj.Set("inputType", inputType); - obj.Set("outputType", outputType); - obj.Set("input", liveEngine->getCurrentInputDevice().toStdString()); - obj.Set("output", liveEngine->getCurrentOutputDevice().toStdString()); - obj.Set("sampleRate", liveEngine->getCurrentSampleRate()); - obj.Set("blockSize", liveEngine->getCurrentBlockSize()); - obj.Set("inputBlockSize", liveEngine->getCurrentInputBlockSize()); - obj.Set("outputBlockSize", liveEngine->getCurrentOutputBlockSize()); - obj.Set("latencyMs", liveEngine->getLatencyMs()); - obj.Set("duplex", liveEngine->isDuplex()); - return obj; -} - -static Napi::Value GetDeviceMetrics(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto liveEngine = snapshotEngine(); - auto obj = Napi::Object::New(env); - if (!liveEngine) - { - obj.Set("duplex", true); - obj.Set("inputOverflowCount", 0.0); - obj.Set("outputUnderflowCount", 0.0); - obj.Set("outputRingFillFrames", 0); - obj.Set("outputRingCapacityFrames", 0); - return obj; - } - const auto m = liveEngine->getDeviceMetrics(); - obj.Set("duplex", m.duplex); - obj.Set("inputOverflowCount", static_cast(m.inputOverflowCount)); - obj.Set("outputUnderflowCount", static_cast(m.outputUnderflowCount)); - obj.Set("outputRingFillFrames", m.outputRingFillFrames); - obj.Set("outputRingCapacityFrames", m.outputRingCapacityFrames); - return obj; -} - -// ── Device Selection ────────────────────────────────────────────────────────── - -static Napi::Value SetDeviceType(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto liveEngine = snapshotEngine(); - if (!liveEngine || info.Length() < 1 || !info[0].IsString()) - return Napi::Boolean::New(env, false); - - auto typeName = info[0].As().Utf8Value(); - bool result = liveEngine->setDeviceType(juce::String(typeName)); - return Napi::Boolean::New(env, result); -} - -static Napi::Value SetOutputDeviceType(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto liveEngine = snapshotEngine(); - if (!liveEngine || info.Length() < 1 || !info[0].IsString()) - return Napi::Boolean::New(env, false); - auto typeName = info[0].As().Utf8Value(); - return Napi::Boolean::New(env, liveEngine->setOutputDeviceType(juce::String(typeName))); -} - -static Napi::Value SetDevice(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto liveEngine = snapshotEngine(); - auto result = Napi::Object::New(env); - result.Set("ok", false); - result.Set("duplex", true); - result.Set("sampleRate", 0.0); - result.Set("inputBlockSize", 0); - result.Set("outputBlockSize", 0); - result.Set("error", ""); - if (!liveEngine) - { - result.Set("error", "Audio engine not initialized"); - return result; - } - - // Object payload: setDevice({inputType, inputDevice, outputType, outputDevice, sampleRate, bufferSize}) - // Legacy positional: setDevice(input, output, sampleRate, bufferSize) - AudioEngine::DeviceConfig cfg; - if (info.Length() > 0 && info[0].IsObject() && !info[0].IsNull() && !info[0].IsArray()) - { - auto obj = info[0].As(); - auto readStr = [&](const char* key) -> std::string { - if (obj.Has(key) && obj.Get(key).IsString()) return obj.Get(key).As().Utf8Value(); - return {}; - }; - // Reject NaN/Infinity at the JS→C boundary so they can't poison - // downstream comparisons (NaN <= 0 is false, so the validation - // fallback in setAudioDevices() wouldn't catch them). Casting a - // non-finite double to int is also UB in C++. - auto readNum = [&](const char* key, double def) -> double { - if (obj.Has(key) && obj.Get(key).IsNumber()) - { - const double v = obj.Get(key).As().DoubleValue(); - if (std::isfinite(v)) return v; - } - return def; - }; - cfg.inputType = juce::String(readStr("inputType")); - cfg.inputDevice = juce::String(readStr("inputDevice")); - if (cfg.inputDevice.isEmpty()) cfg.inputDevice = juce::String(readStr("input")); - cfg.outputType = juce::String(readStr("outputType")); - cfg.outputDevice = juce::String(readStr("outputDevice")); - if (cfg.outputDevice.isEmpty()) cfg.outputDevice = juce::String(readStr("output")); - cfg.sampleRate = readNum("sampleRate", 48000.0); - // Clamp before the double→int cast: finite-but-out-of-range values - // (e.g. a JS-side bug passing 1e18) are UB to convert to int. readNum - // already filtered non-finite; we just need a range check here. - { - const double bsd = readNum("bufferSize", 256.0); - if (bsd >= 1.0 && bsd <= (double) (std::numeric_limits::max) ()) - cfg.bufferSize = (int) bsd; - else - cfg.bufferSize = 256; - } - } - else - { - auto input = info.Length() > 0 && info[0].IsString() ? info[0].As().Utf8Value() : ""; - auto output = info.Length() > 1 && info[1].IsString() ? info[1].As().Utf8Value() : ""; - double sr = info.Length() > 2 && info[2].IsNumber() ? info[2].As().DoubleValue() : 48000.0; - int bs = info.Length() > 3 && info[3].IsNumber() ? info[3].As().Int32Value() : 256; - cfg.inputDevice = juce::String(input); - cfg.outputDevice = juce::String(output); - cfg.sampleRate = sr; - cfg.bufferSize = bs; - } - - // Main thread only — JUCE's ALSA backend deadlocks if called from a worker. - const auto r = liveEngine->setAudioDevices(cfg); - result.Set("ok", r.ok); - result.Set("duplex", r.duplex); - result.Set("sampleRate", r.sampleRate); - result.Set("inputBlockSize", r.inputBlockSize); - result.Set("outputBlockSize", r.outputBlockSize); - result.Set("error", r.error.toStdString()); - return result; -} - -// ── Audio Control ───────────────────────────────────────────────────────────── - -static Napi::Value StartAudio(const Napi::CallbackInfo& info) -{ - if (auto liveEngine = snapshotEngine()) liveEngine->startAudio(); - return info.Env().Undefined(); -} - -static Napi::Value StopAudio(const Napi::CallbackInfo& info) -{ - if (auto liveEngine = snapshotEngine()) liveEngine->stopAudio(); - return info.Env().Undefined(); -} - -static Napi::Value IsAudioRunning(const Napi::CallbackInfo& info) -{ - auto liveEngine = snapshotEngine(); - return Napi::Boolean::New(info.Env(), liveEngine ? liveEngine->isAudioRunning() : false); -} - -// ── Gain ────────────────────────────────────────────────────────────────────── - -static Napi::Value SetGain(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto liveEngine = snapshotEngine(); - if (!liveEngine || info.Length() < 2) return env.Undefined(); - - if (!info[0].IsString()) return env.Undefined(); - auto which = info[0].As().Utf8Value(); - const auto valueOpt = slopsmith::addon::argFiniteFloat(info, 1); - if (!valueOpt) return env.Undefined(); // engine clamps range; NaN/Inf rejected here - const float value = *valueOpt; - - if (which == "input") liveEngine->setInputGain(value); - else if (which == "output") liveEngine->setOutputGain(value); - else if (which == "chain") liveEngine->setChainOutputGain(value); - else if (which == "backing") liveEngine->setBackingVolume(value); - - return env.Undefined(); -} - -static Napi::Value SetInputChannel(const Napi::CallbackInfo& info) -{ - auto liveEngine = snapshotEngine(); - if (liveEngine && info.Length() > 0) - liveEngine->setInputChannel(info[0].As().Int32Value()); - return info.Env().Undefined(); -} - -static Napi::Value SetMonitorMute(const Napi::CallbackInfo& info) -{ - auto liveEngine = snapshotEngine(); - if (liveEngine && info.Length() > 0) - liveEngine->setMonitorMute(info[0].As().Value()); - return info.Env().Undefined(); -} - -// setNoteDetectionEnabled(bool) -> undefined. Arms/suspends the polyphonic ML -// note-detection pipeline across all sources. The renderer (note_detect) calls -// this true only while a consumer actually reads ML notes (native-frame -// detection / non-verifier fallback) and false otherwise — the default -// harmonic-comb verifier path and the always-on home tuner leave ML suspended, -// so the engine runs no ONNX inference when nothing needs it. -static Napi::Value SetNoteDetectionEnabled(const Napi::CallbackInfo& info) -{ - auto liveEngine = snapshotEngine(); - if (liveEngine && info.Length() > 0) - liveEngine->setMlNoteDetectionEnabled(info[0].As().Value()); - return info.Env().Undefined(); -} - -static Napi::Value SetMonitorMuteSuppressed(const Napi::CallbackInfo& info) -{ - // IsBoolean()-guarded so a mismatched renderer build / manual caller - // passing a non-boolean is a clean no-op rather than a hard N-API failure - // (NAPI_DISABLE_CPP_EXCEPTIONS is enabled). Mirrors SetNoiseGate's style. - auto liveEngine = snapshotEngine(); - if (liveEngine && info.Length() > 0 && info[0].IsBoolean()) - liveEngine->setMonitorMuteSuppressed(info[0].As().Value()); - return info.Env().Undefined(); -} - -static Napi::Value SetMonitorKill(const Napi::CallbackInfo& info) -{ - // IsBoolean()-guarded (fail-soft no-op on a downlevel/mismatched caller), - // mirroring SetMonitorMuteSuppressed. - auto liveEngine = snapshotEngine(); - if (liveEngine && info.Length() > 0 && info[0].IsBoolean()) - liveEngine->setMonitorKill(info[0].As().Value()); - return info.Env().Undefined(); -} - -static Napi::Value SetNoiseGate(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto liveEngine = snapshotEngine(); - if (!liveEngine || info.Length() < 1 || !info[0].IsObject()) - return env.Undefined(); - - auto o = info[0].As(); - - bool enabled = false; - if (o.Has("enabled")) - { - auto v = o.Get("enabled"); - if (v.IsBoolean()) - enabled = v.As().Value(); - else if (v.IsNumber()) - enabled = v.As().DoubleValue() != 0.0; - } - - float thresholdDb = -60.0f; - if (o.Has("thresholdDb") && o.Get("thresholdDb").IsNumber()) - thresholdDb = (float)o.Get("thresholdDb").As().DoubleValue(); - - float releaseMs = 100.0f; - if (o.Has("releaseMs") && o.Get("releaseMs").IsNumber()) - releaseMs = (float)o.Get("releaseMs").As().DoubleValue(); - - float depthDb = -60.0f; - if (o.Has("depthDb") && o.Get("depthDb").IsNumber()) - depthDb = (float)o.Get("depthDb").As().DoubleValue(); - - liveEngine->setNoiseGate(enabled, thresholdDb, releaseMs, depthDb); - return env.Undefined(); -} - -static Napi::Value SetTonePolish(const Napi::CallbackInfo& info) -{ - // Tone Polish — { enabled: bool }. Mirrors SetNoiseGate's defensive - // shape so a mismatched renderer build / manual caller passing a - // non-object is a clean no-op rather than a hard N-API failure - // (NAPI_DISABLE_CPP_EXCEPTIONS). - auto env = info.Env(); - auto liveEngine = snapshotEngine(); - if (!liveEngine || info.Length() < 1 || !info[0].IsObject()) - return env.Undefined(); - - auto o = info[0].As(); - - bool enabled = true; - if (o.Has("enabled")) - { - auto v = o.Get("enabled"); - if (v.IsBoolean()) - enabled = v.As().Value(); - else if (v.IsNumber()) - enabled = v.As().DoubleValue() != 0.0; - } - - liveEngine->setTonePolishEnabled(enabled); - return env.Undefined(); -} - -static Napi::Value IsMonitorMuted(const Napi::CallbackInfo& info) -{ - auto liveEngine = snapshotEngine(); - return Napi::Boolean::New(info.Env(), liveEngine ? liveEngine->isMonitorMuted() : true); -} - -// ── Metering (polled — read atomics) ────────────────────────────────────────── - -static Napi::Value GetLevels(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto obj = Napi::Object::New(env); - auto liveEngine = snapshotEngine(); - - if (liveEngine) - { - obj.Set("inputLevel", liveEngine->getInputLevel()); - obj.Set("outputLevel", liveEngine->getOutputLevel()); - obj.Set("inputPeak", liveEngine->getInputPeak()); - obj.Set("outputPeak", liveEngine->getOutputPeak()); - } - else - { - obj.Set("inputLevel", 0.0); - obj.Set("outputLevel", 0.0); - obj.Set("inputPeak", 0.0); - obj.Set("outputPeak", 0.0); - } - - return obj; -} - -// getSourceLevels(sourceId) -> { inputLevel, inputPeak, outputLevel, outputPeak }. -// Per-source INPUT level so a bound detector's silence gate reads ITS OWN device's -// signal (not the global/primary level — which would force-fail every hit on an -// extra device the user is actually playing). Output fields mirror the master and -// are 0 (monitoring is post-mix / engine-global). Bad id -> all zeros. -static Napi::Value GetSourceLevels(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto obj = Napi::Object::New(env); - auto liveEngine = snapshotEngine(); - SourceChain* s = (liveEngine && info.Length() >= 1 && info[0].IsNumber()) - ? getValidatedSource(liveEngine.get(), info, 0) : nullptr; - obj.Set("inputLevel", s ? (double) s->getInputLevel() : 0.0); - obj.Set("inputPeak", s ? (double) s->getInputPeak() : 0.0); - obj.Set("outputLevel", 0.0); - obj.Set("outputPeak", 0.0); - return obj; -} - -static Napi::Value ResetPeaks(const Napi::CallbackInfo& info) -{ - if (auto liveEngine = snapshotEngine()) liveEngine->resetPeaks(); - return info.Env().Undefined(); -} - -// Backing-track mix bus RMS level — the engine's per-block running RMS after -// the backing volume fader but before the output-gain master. Returns 0.0 when -// the engine is unavailable or no backing track is loaded. Reads an atomic so -// it is safe to call from the JS thread without blocking the audio thread. -static Napi::Value GetBackingLevel(const Napi::CallbackInfo& info) -{ - auto liveEngine = snapshotEngine(); - return Napi::Number::New(info.Env(), liveEngine ? liveEngine->getBackingLevel() : 0.0f); -} - -// ── Pitch Detection (polled) ────────────────────────────────────────────────── - -// Load the Basic Pitch ONNX model for the polyphonic ML note detector. -// Called once at startup by audio-bridge.ts with the bundled model path. -// Never throws. Returns "is ML note detection available after this call" — -// a model is loaded with a valid contract. A missing/invalid file does NOT -// tear down an already-loaded model, so it can still return true; it returns -// false when the engine isn't ready or ONNX support isn't compiled in, and -// the engine then keeps using the YIN PitchDetector / ChordScorer -// (Constitution VII). -static Napi::Value LoadNoteModel(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto liveEngine = snapshotEngine(); - if (!liveEngine || info.Length() < 1 || !info[0].IsString()) - return Napi::Boolean::New(env, false); - - const auto path = info[0].As().Utf8Value(); - const bool ok = liveEngine->loadNoteModel(juce::File(juce::String(path))); - return Napi::Boolean::New(env, ok); -} - -// Whether the ML note detector is active (ONNX support compiled in AND a -// model loaded). Lets the renderer / tests tell the ML path from the YIN -// fallback without inferring it from behaviour. -static Napi::Value IsMlNoteDetection(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - // Report readiness, not just model-loaded: the engine only routes - // getPitchDetection()/scoreChord() to ML once the detector has published - // its first snapshot (isReady()). Reporting true during the cold-start - // window would tell the renderer "ML active" while it's still getting the - // YIN fallback. - auto liveEngine = snapshotEngine(); - return Napi::Boolean::New(env, - liveEngine && liveEngine->hasMlNoteDetector() - && liveEngine->getMlNoteDetector().isReady()); -} - -// Raw polyphonic transcription from the ML note detector — the full set of -// currently-active pitches, not just the dominant one. Returns -// `{ notes: [{ midi, confidence, onsetMs, onsetSeq }], sampleRate }`, or null when the ML -// detector isn't active (no model / ONNX support) so the renderer can feature- -// detect and fall back. Never throws. -static Napi::Value DetectNotes(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - // Gate on isReady(): the contract is that callers get null whenever the - // ML detector isn't actively producing notes. isReady() is false with no - // model, after a device stop, and during the cold-start window before the - // first inference publishes — so the renderer feature-detects correctly - // and falls back instead of consuming an empty ML stream. - auto liveEngine = snapshotEngine(); - if (!liveEngine || !liveEngine->getMlNoteDetector().isReady()) - return env.Null(); - - const auto active = liveEngine->getMlNoteDetector().getActiveNotes(); - auto notesArr = Napi::Array::New(env, active.size()); - for (size_t i = 0; i < active.size(); ++i) - { - auto entry = Napi::Object::New(env); - entry.Set("midi", active[i].midi); - entry.Set("confidence", active[i].confidence); - // Milliseconds since this pitch's onset — lets the renderer back-date - // a detection to the true onset instead of poll time. - entry.Set("onsetMs", active[i].onsetAgeMs); - // Monotonic per-pitch onset counter — a change means a new note was - // struck, so the renderer can consume onsets as discrete events. - entry.Set("onsetSeq", active[i].onsetSeq); - notesArr.Set((uint32_t) i, entry); - } - - auto obj = Napi::Object::New(env); - obj.Set("notes", notesArr); - // Normalise the sample rate: getCurrentSampleRate() is 0 when no audio - // device is active — hand the renderer a sane positive value so its - // Hz/time math can't divide by zero. - const double sr = liveEngine->getCurrentSampleRate(); - obj.Set("sampleRate", sr > 0.0 ? sr : 48000.0); - return obj; -} - -static Napi::Value GetPitchDetection(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto obj = Napi::Object::New(env); - auto liveEngine = snapshotEngine(); - - if (liveEngine) - { - // getActiveDetection() returns the polyphonic ML detector's dominant - // pitch when a Basic Pitch model is loaded, else the YIN detector's - // latest result — same shape either way, so the plugin is unchanged. - auto det = liveEngine->getActiveDetection(); - obj.Set("frequency", det.frequency); - obj.Set("confidence", det.confidence); - obj.Set("midiNote", det.midiNote); - obj.Set("cents", det.cents); - obj.Set("noteName", det.noteName.toStdString()); - } - else - { - obj.Set("frequency", -1.0); - obj.Set("confidence", 0.0); - obj.Set("midiNote", -1); - obj.Set("cents", 0.0); - obj.Set("noteName", ""); - } - - return obj; -} - -static Napi::Value GetRawPitchDetection(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto obj = Napi::Object::New(env); - auto liveEngine = snapshotEngine(); - - if (liveEngine) - { - // Always the raw YIN detection — bypasses the ML preference so frequency - // stays continuous (sub-Hz) and cents stays real even with a model loaded. - // Backs the tuner's audio:getRawPitch endpoint. - auto det = liveEngine->getRawPitchDetection(); - obj.Set("frequency", det.frequency); - obj.Set("confidence", det.confidence); - obj.Set("midiNote", det.midiNote); - obj.Set("cents", det.cents); - obj.Set("noteName", det.noteName.toStdString()); - } - else - { - obj.Set("frequency", -1.0); - obj.Set("confidence", 0.0); - obj.Set("midiNote", -1); - obj.Set("cents", 0.0); - obj.Set("noteName", ""); - } - - return obj; -} - -static Napi::Value GetRawAudioFrame(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto liveEngine = snapshotEngine(); - - // Optional sample count; defaults to AudioEngine::getRawAudioFrame's 4096. - // The engine clamps anything above its ring capacity. - int numSamples = 4096; - if (info.Length() > 0 && info[0].IsNumber()) - numSamples = info[0].As().Int32Value(); - - if (!liveEngine || numSamples <= 0) - return Napi::Float32Array::New(env, 0); - - // Post-gate mono snapshot for the tuner's own pitch pipeline. Returns a - // Float32Array of the most-recent N samples (left-zero-padded on cold start). - auto frame = liveEngine->getRawAudioFrame(numSamples); - auto out = Napi::Float32Array::New(env, frame.size()); - float* dst = out.Data(); - for (size_t i = 0; i < frame.size(); ++i) - dst[i] = frame[i]; - return out; -} - -// Score a polyphonic chord against the engine's most recent input -// samples. Renderer (notedetect plugin's matchNotes chord branch) -// supplies the chord context — chart notes plus tuning/arrangement -// metadata — and gets back a `{score, hitStrings, totalStrings, isHit, -// results[]}` object identical in shape to what the JS implementation -// produced. Audio never crosses the N-API boundary, which is the -// whole reason for moving the math here: constitution II says audio -// analysis lives in JUCE, and this is the missing piece. -// -// Request shape. Fields marked `required` must be present and -// internally consistent — the C++ scorer fails closed (all-miss -// result with one entry per requested note) when the validation -// invariants don't hold, rather than silently substituting defaults. -// { -// notes: [{ s, f, ho?, po?, b?, sl?, hm? }, ...], -// // required, each `s` must be in [0, stringCount) -// arrangement?: 'guitar'|'bass', // default 'guitar' — must be one of these two strings -// stringCount?: number, // default 6 — must match the (arrangement, stringCount) -// // table: bass{4,5} or guitar{6,7,8} -// offsets: number[], // required, length must equal stringCount. -// // Pass an array of zeros for standard tuning; -// // the default of `stringCount = 6` only works -// // if you supply 6 offsets. -// numSamples?: number, // analysis window (default 4096, capped at the -// // engine input-ring capacity, currently 8192) -// capo?: number, // default 0 -// pitchCheckCents?: number, // 0 = energy-only chord check (default 0) -// minHitRatio?: number, // default 0.6 -// bypassMl?: boolean, // force the DSP band-energy scorer even -// // when an ML model is loaded (default false) -// harmonicVerify?: boolean, // score each note by harmonic-comb energy -// // (f,2f..5f vs the floor between) instead -// // of band-energy/total (default false) -// harmonicSnr?: number, // min harmonic-to-floor ratio for a hit -// // when harmonicVerify is set (default 3.0) -// fundamentalRatio?: number, // fundamental-presence gate: reject when -// // f0 peak < ratio*strongest partial; lower -// // for bass, <=0 disables (default 0.20) -// } -// Shared core: parse `reqObj` into a ChordScorer::Request and score it against -// `target`'s input ring. `target` is sources[0] for the legacy scoreChord and -// getSource(id) for the source-indexed scoreSourceChord. -static Napi::Value scoreChordCore(Napi::Env env, Napi::Object reqObj, SourceChain* target) -{ - // Hard caps on caller-controlled array lengths. The scorer's - // (arrangement, stringCount) validation only accepts up to 8 - // strings; chord-notes have a natural ceiling at the same value - // (one per string). 32 is a generous headroom that still bounds - // worst-case allocations the renderer could trigger over IPC — - // without these limits, a malformed/malicious payload claiming a - // gigantic JS array length would force a multi-GB reserve before - // the scorer's own validation rejected the request. A request - // that exceeds either cap is treated as outright malformed and - // returns the "no chord requested" failure shape (totalStrings=0); - // every other validation failure goes through the all-miss path - // below so results[] stays in lockstep with notes[]. - static constexpr uint32_t kMaxOffsets = 32; - static constexpr uint32_t kMaxNotes = 32; - - auto noRequestFailure = [&env]() { - auto failure = Napi::Object::New(env); - failure.Set("score", 0.0); - failure.Set("hitStrings", 0); - failure.Set("totalStrings", 0); - failure.Set("isHit", false); - failure.Set("results", Napi::Array::New(env, 0)); - return failure; - }; - - // Capture the notes array up front so every downstream failure - // path can build a per-note all-miss result aligned 1:1 with the - // caller's notes[]. Pre-cap check happens before we even read the - // length into the helper to prevent a payload claiming an enormous - // length from forcing the helper to allocate a huge results array. - Napi::Value notesVal = reqObj.Has("notes") ? reqObj.Get("notes") : env.Null(); - if (!notesVal.IsArray()) return noRequestFailure(); - auto notesArr = notesVal.As(); - if (notesArr.Length() > kMaxNotes) return noRequestFailure(); - const uint32_t noteCount = notesArr.Length(); - - // All-miss result aligned with the caller's notes[]. Walks the - // original JS array so the per-note `s` / `f` echo back in the - // result even when the request fails validation (lets the renderer - // distinguish "this string missed" from "this string wasn't sent"). - // Used by every failure path below except the cap/no-notes case - // above, which doesn't have a coherent notes[] to mirror. - auto buildAllMiss = [&]() { - auto resultsArr = Napi::Array::New(env, noteCount); - for (uint32_t i = 0; i < noteCount; ++i) - { - int s = -1, f = -1; - auto v = notesArr.Get(i); - if (v.IsObject()) - { - auto o = v.As(); - if (o.Has("s") && o.Get("s").IsNumber()) - s = o.Get("s").As().Int32Value(); - if (o.Has("f") && o.Get("f").IsNumber()) - f = o.Get("f").As().Int32Value(); - } - auto entry = Napi::Object::New(env); - entry.Set("s", s); - entry.Set("f", f); - entry.Set("hit", false); - entry.Set("bandEnergy", 0.0); - entry.Set("centsDiff", env.Null()); - entry.Set("centsError", env.Null()); - resultsArr.Set(i, entry); - } - auto out = Napi::Object::New(env); - out.Set("score", 0.0); - out.Set("hitStrings", 0); - out.Set("totalStrings", (int) noteCount); - out.Set("isHit", false); - out.Set("results", resultsArr); - return out; - }; - - ChordScorer::Request req; - if (reqObj.Has("numSamples") && reqObj.Get("numSamples").IsNumber()) - req.numSamples = reqObj.Get("numSamples").As().Int32Value(); - if (reqObj.Has("arrangement") && reqObj.Get("arrangement").IsString()) - req.arrangement = reqObj.Get("arrangement").As().Utf8Value(); - if (reqObj.Has("stringCount") && reqObj.Get("stringCount").IsNumber()) - req.stringCount = reqObj.Get("stringCount").As().Int32Value(); - if (reqObj.Has("capo") && reqObj.Get("capo").IsNumber()) - req.capo = reqObj.Get("capo").As().Int32Value(); - if (reqObj.Has("pitchCheckCents") && reqObj.Get("pitchCheckCents").IsNumber()) - req.pitchCheckCents = reqObj.Get("pitchCheckCents").As().FloatValue(); - if (reqObj.Has("minHitRatio") && reqObj.Get("minHitRatio").IsNumber()) - req.minHitRatio = reqObj.Get("minHitRatio").As().FloatValue(); - if (reqObj.Has("bypassMl") && reqObj.Get("bypassMl").IsBoolean()) - req.bypassMl = reqObj.Get("bypassMl").As().Value(); - if (reqObj.Has("harmonicVerify") && reqObj.Get("harmonicVerify").IsBoolean()) - req.harmonicVerify = reqObj.Get("harmonicVerify").As().Value(); - if (reqObj.Has("harmonicSnr") && reqObj.Get("harmonicSnr").IsNumber()) - req.harmonicSnr = reqObj.Get("harmonicSnr").As().FloatValue(); - if (reqObj.Has("fundamentalRatio") && reqObj.Get("fundamentalRatio").IsNumber()) - { - // Drop NaN/Inf: a non-finite ratio poisons the fundamental-presence - // gate (fundMag >= NaN is always false -> every note false-rejected). - // Keep the safe 0.20 default instead. - const float v = reqObj.Get("fundamentalRatio").As().FloatValue(); - if (std::isfinite(v)) req.fundamentalRatio = v; - } - - if (reqObj.Has("offsets") && reqObj.Get("offsets").IsArray()) - { - auto arr = reqObj.Get("offsets").As(); - if (arr.Length() > kMaxOffsets) return noRequestFailure(); - req.tuningOffsets.reserve(arr.Length()); - for (uint32_t i = 0; i < arr.Length(); ++i) - { - auto v = arr.Get(i); - // Tuning offsets materially shift expected pitch — silently - // substituting 0 for a missing/non-numeric entry would - // produce confidently wrong scores. Fail closed with the - // per-note all-miss shape so the renderer sees the right - // results[] length even when the request is malformed. - if (!v.IsNumber()) return buildAllMiss(); - req.tuningOffsets.push_back(v.As().Int32Value()); - } - } - - req.notes.reserve(noteCount); - for (uint32_t i = 0; i < noteCount; ++i) - { - auto v = notesArr.Get(i); - // For malformed entries (non-object, or missing/non-numeric - // s/f) push a sentinel Note with string = -1. This keeps - // req.notes.size() in lockstep with the incoming notes[] - // length AND guarantees ChordScorer's range check - // (`n.string < 0 || n.string >= stringCount`) trips on the - // sentinel — yielding the same all-miss fail-closed result - // the shape contract advertises, never a false hit on the - // default low-string position. - ChordScorer::Note n{}; - n.string = -1; - n.fret = -1; - if (!v.IsObject()) - { - req.notes.push_back(n); - continue; - } - auto noteObj = v.As(); - const bool hasS = noteObj.Has("s") && noteObj.Get("s").IsNumber(); - const bool hasF = noteObj.Has("f") && noteObj.Get("f").IsNumber(); - if (!hasS || !hasF) - { - req.notes.push_back(n); - continue; - } - n.string = noteObj.Get("s").As().Int32Value(); - n.fret = noteObj.Get("f").As().Int32Value(); - // Technique flags are truthy/falsy in JS; coerce to bool - // here so an unset value cleanly becomes false. - auto truthy = [¬eObj](const char* key) { - if (!noteObj.Has(key)) return false; - auto val = noteObj.Get(key); - return val.ToBoolean().Value(); - }; - n.hammerOn = truthy("ho"); - n.pullOff = truthy("po"); - n.bend = truthy("b"); - n.slide = truthy("sl"); - n.harmonic = truthy("hm"); - req.notes.push_back(n); - } - - auto result = target->scoreChord(req); - - auto out = Napi::Object::New(env); - out.Set("score", result.score); - out.Set("hitStrings", result.hitStrings); - out.Set("totalStrings", result.totalStrings); - out.Set("isHit", result.isHit); - auto resultsArr = Napi::Array::New(env, result.results.size()); - for (size_t i = 0; i < result.results.size(); ++i) - { - const auto& r = result.results[i]; - auto entry = Napi::Object::New(env); - entry.Set("s", r.string); - entry.Set("f", r.fret); - entry.Set("hit", r.hit); - entry.Set("bandEnergy", r.bandEnergy); - // Mirror the JS result shape: when cents weren't measured the - // fields are present-but-null so the renderer can distinguish - // "no pitch check ran" (null) from "pitch check said 0" - // (numeric 0). - if (r.hasCents) - { - entry.Set("centsDiff", r.centsDiff); - entry.Set("centsError", r.centsError); - } - else - { - entry.Set("centsDiff", env.Null()); - entry.Set("centsError", env.Null()); - } - resultsArr.Set(i, entry); - } - out.Set("results", resultsArr); - return out; -} - -// Legacy: scoreChord(req) — targets sources[0]. Backward-compatible. -static Napi::Value ScoreChord(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto liveEngine = snapshotEngine(); - auto noRequestFailure = [&env]() { - auto failure = Napi::Object::New(env); - failure.Set("score", 0.0); - failure.Set("hitStrings", 0); - failure.Set("totalStrings", 0); - failure.Set("isHit", false); - failure.Set("results", Napi::Array::New(env, 0)); - return failure; - }; - if (!liveEngine || info.Length() < 1 || !info[0].IsObject()) - return noRequestFailure(); - return scoreChordCore(env, info[0].As(), liveEngine->getSource(0)); -} - -// Source-indexed: scoreSourceChord(sourceId, req). Bad id / payload -> the -// same "no chord requested" failure shape (totalStrings=0). -static Napi::Value ScoreSourceChord(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto liveEngine = snapshotEngine(); - auto noRequestFailure = [&env]() { - auto failure = Napi::Object::New(env); - failure.Set("score", 0.0); - failure.Set("hitStrings", 0); - failure.Set("totalStrings", 0); - failure.Set("isHit", false); - failure.Set("results", Napi::Array::New(env, 0)); - return failure; - }; - if (!liveEngine || info.Length() < 2 || !info[0].IsNumber() || !info[1].IsObject()) - return noRequestFailure(); - SourceChain* target = getValidatedSource(liveEngine.get(), info, 0); - if (!target) return noRequestFailure(); - return scoreChordCore(env, info[1].As(), target); -} - -// ── Multi-input source management bridge ───────────────────────────────────── -// A source is one independent input chain (own arrangement chart, detection, -// scoring, tone, monitor). sources[0] always exists. The renderer adds a source -// per extra player, binds it to an input channel, and drives its scoring via the -// *Source* methods below; the legacy un-suffixed methods keep targeting source 0. - -// addSource(inputChannel?) -> sourceId (number), or -1 if the pool is full. -static Napi::Value AddSource(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto liveEngine = snapshotEngine(); - if (!liveEngine) return Napi::Number::New(env, -1); - int channel = -1; // default: mono mix of the first pair - if (info.Length() > 0 && info[0].IsNumber()) - channel = info[0].As().Int32Value(); - int deviceKey = 0; // default: primary input device - if (info.Length() > 1 && info[1].IsNumber()) - { - const int k = info[1].As().Int32Value(); - if (k >= 0) deviceKey = k; // negatives ignored → primary - } - return Napi::Number::New(env, liveEngine->addSource(channel, deviceKey)); -} - -// removeSource(sourceId) -> boolean. sources[0] cannot be removed. -static Napi::Value RemoveSource(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto liveEngine = snapshotEngine(); - if (!liveEngine || info.Length() < 1 || !info[0].IsNumber()) - return Napi::Boolean::New(env, false); - return Napi::Boolean::New(env, liveEngine->removeSource(info[0].As().Int32Value())); -} - -// listSources() -> [{ id, inputChannel, active }]. Null on a missing engine. -static Napi::Value ListSources(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto liveEngine = snapshotEngine(); - if (!liveEngine) return env.Null(); - const auto sources = liveEngine->listSources(); - auto arr = Napi::Array::New(env, sources.size()); - for (size_t i = 0; i < sources.size(); ++i) - { - auto entry = Napi::Object::New(env); - entry.Set("id", sources[i].id); - entry.Set("inputChannel", sources[i].inputChannel); - entry.Set("deviceKey", sources[i].deviceKey); - entry.Set("active", sources[i].active); - arr.Set((uint32_t) i, entry); - } - return arr; -} - -// listInputDevices() -> [{ typeName, name }]. Every available capture device the -// renderer can bind to an additional engine input via bindInputDevice. Null on a -// missing engine. -static Napi::Value ListInputDevices(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto liveEngine = snapshotEngine(); - if (!liveEngine) return env.Null(); - const auto devices = liveEngine->getBindableInputDevices(); - auto arr = Napi::Array::New(env); - uint32_t n = 0; - for (const auto& d : devices) - { - auto entry = Napi::Object::New(env); - entry.Set("typeName", d.typeName.toStdString()); - entry.Set("name", d.name.toStdString()); - arr.Set(n++, entry); - } - return arr; -} - -// bindInputDevice(deviceKey, deviceName) -> "" on success, else an error string. -// Opens an ADDITIONAL physical input device (deviceKey 1..N) so sources created -// with addSource(channel, deviceKey) capture from it at its own clock. -static Napi::Value BindInputDevice(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto liveEngine = snapshotEngine(); - if (!liveEngine) return Napi::String::New(env, "no engine"); - if (info.Length() < 2 || !info[0].IsNumber() || !info[1].IsString()) - return Napi::String::New(env, "bindInputDevice(deviceKey:number, deviceName:string)"); - const int deviceKey = info[0].As().Int32Value(); - const std::string name = info[1].As().Utf8Value(); - return Napi::String::New(env, liveEngine->bindInputDevice(deviceKey, name).toStdString()); -} - -// unbindInputDevice(deviceKey) -> boolean. Stops + releases the extra device. -static Napi::Value UnbindInputDevice(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto liveEngine = snapshotEngine(); - if (!liveEngine || info.Length() < 1 || !info[0].IsNumber()) - return Napi::Boolean::New(env, false); - return Napi::Boolean::New(env, liveEngine->unbindInputDevice(info[0].As().Int32Value())); -} - -// ── Streamer mix output (PR1) ─────────────────────────────────────────────── -// setStreamOutputDevice(typeName, deviceName) -> "" on success, else an error. -static Napi::Value SetStreamOutputDevice(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto liveEngine = snapshotEngine(); - if (!liveEngine) return Napi::String::New(env, "no engine"); - if (info.Length() < 2 || !info[0].IsString() || !info[1].IsString()) - return Napi::String::New(env, "setStreamOutputDevice(typeName:string, deviceName:string)"); - const std::string typeName = info[0].As().Utf8Value(); - const std::string devName = info[1].As().Utf8Value(); - return Napi::String::New(env, - liveEngine->setStreamOutputDevice(juce::String(typeName), juce::String(devName)).toStdString()); -} - -// clearStreamOutput() -> undefined -static Napi::Value ClearStreamOutput(const Napi::CallbackInfo& info) -{ - auto liveEngine = snapshotEngine(); - if (liveEngine) liveEngine->clearStreamOutput(); - return info.Env().Undefined(); -} - -// setStreamBus(includeBacking:boolean, includeGuitar:boolean, gain:number) -static Napi::Value SetStreamBus(const Napi::CallbackInfo& info) -{ - auto liveEngine = snapshotEngine(); - if (liveEngine && info.Length() >= 3 && info[0].IsBoolean() && info[1].IsBoolean() && info[2].IsNumber()) - liveEngine->setStreamBus(info[0].As().Value(), - info[1].As().Value(), - (float) info[2].As().DoubleValue()); - return info.Env().Undefined(); -} - -// setStreamBusGain(gain:number) -static Napi::Value SetStreamBusGain(const Napi::CallbackInfo& info) -{ - auto liveEngine = snapshotEngine(); - if (liveEngine && info.Length() >= 1 && info[0].IsNumber()) - liveEngine->setStreamBusGain((float) info[0].As().DoubleValue()); - return info.Env().Undefined(); -} - -// setRendererBus(enabled:boolean, gain:number) -static Napi::Value SetRendererBus(const Napi::CallbackInfo& info) -{ - auto liveEngine = snapshotEngine(); - if (liveEngine && info.Length() >= 2 && info[0].IsBoolean() && info[1].IsNumber()) - liveEngine->setRendererBus(info[0].As().Value(), - (float) info[1].As().DoubleValue()); - return info.Env().Undefined(); -} - -// pushRendererAudio(interleavedLR:Float32Array, sourceRate:number) -> boolean -// Interleaved stereo (L0 R0 L1 R1 …); sourceRate is the renderer's -// AudioContext sample rate. Returns false when the bus is off / engine down / -// malformed args, so the renderer can stop pushing. -static Napi::Value PushRendererAudio(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto liveEngine = snapshotEngine(); - if (!liveEngine || info.Length() < 2 || !info[0].IsTypedArray() || !info[1].IsNumber()) - return Napi::Boolean::New(env, false); - auto ta = info[0].As(); - if (ta.TypedArrayType() != napi_float32_array) - return Napi::Boolean::New(env, false); - auto f32 = info[0].As(); - const size_t samples = f32.ElementLength(); - if (samples < 2) - return Napi::Boolean::New(env, false); - const int frames = (int) (samples / 2); - const bool ok = liveEngine->pushRendererAudio( - f32.Data(), frames, info[1].As().DoubleValue()); - return Napi::Boolean::New(env, ok); -} - -// getRendererBusMetrics() -> {enabled, fillFrames, capacityFrames, -// pushedFrames, consumedFrames, -// underflowCount, overflowCount} -static Napi::Value GetRendererBusMetrics(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto liveEngine = snapshotEngine(); - auto obj = Napi::Object::New(env); - if (!liveEngine) return obj; - const auto m = liveEngine->getRendererBusMetrics(); - obj.Set("enabled", m.enabled); - obj.Set("fillFrames", m.fillFrames); - obj.Set("capacityFrames", m.capacityFrames); - obj.Set("pushedFrames", (double) m.pushedFrames); - obj.Set("consumedFrames", (double) m.consumedFrames); - obj.Set("underflowCount", (double) m.underflowCount); - obj.Set("overflowCount", (double) m.overflowCount); - return obj; -} - -// getStreamSinkLevel() -> number (peak 0..1+) -static Napi::Value GetStreamSinkLevel(const Napi::CallbackInfo& info) -{ - auto liveEngine = snapshotEngine(); - return Napi::Number::New(info.Env(), liveEngine ? liveEngine->getStreamSinkLevel() : 0.0f); -} - -// isStreamOutputActive() -> boolean -static Napi::Value IsStreamOutputActive(const Napi::CallbackInfo& info) -{ - auto liveEngine = snapshotEngine(); - return Napi::Boolean::New(info.Env(), liveEngine ? liveEngine->isStreamOutputActive() : false); -} - -// getStreamUnderflowCount() -> number -static Napi::Value GetStreamUnderflowCount(const Napi::CallbackInfo& info) -{ - auto liveEngine = snapshotEngine(); - return Napi::Number::New(info.Env(), - (double) (liveEngine ? liveEngine->getStreamUnderflowCount() : 0ull)); -} - -// getStreamOverflowCount() -> number (consumer fell a full ring behind; frames dropped) -static Napi::Value GetStreamOverflowCount(const Napi::CallbackInfo& info) -{ - auto liveEngine = snapshotEngine(); - return Napi::Number::New(info.Env(), - (double) (liveEngine ? liveEngine->getStreamOverflowCount() : 0ull)); -} - -// setSourceInputChannel(sourceId, channel) -static Napi::Value SetSourceInputChannel(const Napi::CallbackInfo& info) -{ - auto liveEngine = snapshotEngine(); - if (liveEngine && info.Length() >= 2 && info[0].IsNumber() && info[1].IsNumber()) - if (SourceChain* s = getValidatedSource(liveEngine.get(), info, 0)) - s->setInputChannel(info[1].As().Int32Value()); - return info.Env().Undefined(); -} - -// setSourceVerifierOffset(sourceId, seconds) — per-source capture-latency -// correction the user dials in for an extra input device (the residual offset -// between that device's path and the primary's; not auto-measurable on JACK). -// Positive seconds DELAYS this source's scoring playhead, negative ADVANCES it. -static Napi::Value SetSourceVerifierOffset(const Napi::CallbackInfo& info) -{ - auto liveEngine = snapshotEngine(); - if (liveEngine && info.Length() >= 2 && info[0].IsNumber() && info[1].IsNumber()) - { - const double sec = info[1].As().DoubleValue(); - if (std::isfinite(sec)) - if (SourceChain* s = getValidatedSource(liveEngine.get(), info, 0)) - s->setVerifierUserOffset(sec); - } - return info.Env().Undefined(); -} - -// setSourceMonitorMute(sourceId, mute) -static Napi::Value SetSourceMonitorMute(const Napi::CallbackInfo& info) -{ - auto liveEngine = snapshotEngine(); - if (liveEngine && info.Length() >= 2 && info[0].IsNumber() && info[1].IsBoolean()) - if (SourceChain* s = getValidatedSource(liveEngine.get(), info, 0)) - s->setMonitorMute(info[1].As().Value()); - return info.Env().Undefined(); -} - -// getSourceRawAudioFrame(sourceId, numSamples?) -> Float32Array -static Napi::Value GetSourceRawAudioFrame(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto liveEngine = snapshotEngine(); - if (!liveEngine || info.Length() < 1 || !info[0].IsNumber()) - return Napi::Float32Array::New(env, 0); - SourceChain* s = getValidatedSource(liveEngine.get(), info, 0); - int numSamples = 4096; - if (info.Length() > 1 && info[1].IsNumber()) - numSamples = info[1].As().Int32Value(); - if (!s || numSamples <= 0) - return Napi::Float32Array::New(env, 0); - auto frame = s->getRawAudioFrame(numSamples); - auto out = Napi::Float32Array::New(env, frame.size()); - float* dst = out.Data(); - for (size_t i = 0; i < frame.size(); ++i) - dst[i] = frame[i]; - return out; -} - -// getSourcePitchDetection(sourceId) -> { frequency, confidence, midiNote, cents, -// noteName }. The no-detection shape when the id is bad/inactive. -static Napi::Value GetSourcePitchDetection(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto obj = Napi::Object::New(env); - auto liveEngine = snapshotEngine(); - SourceChain* s = (liveEngine && info.Length() >= 1 && info[0].IsNumber()) - ? getValidatedSource(liveEngine.get(), info, 0) : nullptr; - if (s) - { - auto det = s->getActiveDetection(); - obj.Set("frequency", det.frequency); - obj.Set("confidence", det.confidence); - obj.Set("midiNote", det.midiNote); - obj.Set("cents", det.cents); - obj.Set("noteName", det.noteName.toStdString()); - } - else - { - obj.Set("frequency", -1.0); - obj.Set("confidence", 0.0); - obj.Set("midiNote", -1); - obj.Set("cents", 0.0); - obj.Set("noteName", ""); - } - return obj; -} - -// getSourceRawPitchDetection(sourceId) -> raw YIN detection (bypasses ML), same -// shape as getSourcePitchDetection. Backs the per-source sustain glow / mono path. -static Napi::Value GetSourceRawPitchDetection(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto obj = Napi::Object::New(env); - auto liveEngine = snapshotEngine(); - SourceChain* s = (liveEngine && info.Length() >= 1 && info[0].IsNumber()) - ? getValidatedSource(liveEngine.get(), info, 0) : nullptr; - if (s) - { - auto det = s->getRawPitchDetection(); - obj.Set("frequency", det.frequency); - obj.Set("confidence", det.confidence); - obj.Set("midiNote", det.midiNote); - obj.Set("cents", det.cents); - obj.Set("noteName", det.noteName.toStdString()); - } - else - { - obj.Set("frequency", -1.0); - obj.Set("confidence", 0.0); - obj.Set("midiNote", -1); - obj.Set("cents", 0.0); - obj.Set("noteName", ""); - } - return obj; -} - -// getSourceNoteVerdicts(sourceId, songTime?, playing?) -> verdict array, or null -// on a missing engine / bad id. Folds in the per-source playhead push like the -// legacy getNoteVerdicts. -static Napi::Value GetSourceNoteVerdicts(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto liveEngine = snapshotEngine(); - if (!liveEngine || info.Length() < 1 || !info[0].IsNumber()) - return env.Null(); - SourceChain* s = getValidatedSource(liveEngine.get(), info, 0); - if (!s) return env.Null(); - - if (info.Length() >= 3 && info[1].IsNumber() && info[2].IsBoolean()) - { - const double songTime = info[1].As().DoubleValue(); - if (std::isfinite(songTime)) - s->setPlayhead(songTime, info[2].As().Value()); - } - - const auto verdicts = s->getNoteVerdicts(); - auto arr = Napi::Array::New(env, verdicts.size()); - for (size_t i = 0; i < verdicts.size(); ++i) - { - const auto& v = verdicts[i]; - auto entry = Napi::Object::New(env); - entry.Set("id", v.id); - entry.Set("detected", v.detected); - entry.Set("detectedSongTime", v.detectedSongTime); - entry.Set("centsError", v.centsError); - entry.Set("snr", v.snr); - arr.Set((uint32_t) i, entry); - } - return arr; -} - -// Push the song's note chart into the engine for continuous, background -// verification. The notedetect plugin calls this once per arrangement load; -// the engine's NoteVerifier thread then scores each note's timing window -// against the live playhead and input ring, so the renderer no longer runs a -// per-tick scoreChord IPC loop (which starved during dense passages). -// -// Expected payload: -// { -// arrangement?: 'guitar'|'bass', // default 'guitar' -// stringCount?: number, // default 6 -// tuningOffsets: number[], // length should equal stringCount -// capo?: number, // default 0 -// pitchCheckCents?: number, // default 0 (energy-only) -// harmonicSnr?: number, // default 3.0 -// fundamentalRatio?: number, // fundamental-presence gate, lower for -// // bass, <=0 disables (default 0.20) -// timingTolerance?: number, // seconds, default 0.1 -// notes: [{ id:string, t:number, s:number, f:number, sus:number, -// ho?,po?,b?,sl?,hm?:boolean }, ...] -// } -// Returns true when the chart was accepted, false on a malformed payload or -// when no engine exists. -// Shared core: parse `reqObj` into a ChartUpdate and push it to `target`'s -// verifier. `target` is sources[0] for the legacy setChart and getSource(id) for -// the source-indexed setSourceChart. A malformed payload clears the target's -// chart (so a failed reload can't leave a stale chart scoring) and returns false. -static Napi::Value setChartCore(Napi::Env env, Napi::Object reqObj, SourceChain* target) -{ - // Generous cap on the chart length — a full song's note list is well - // under this, but it bounds the worst-case allocation a malformed payload - // (claiming a gigantic JS array length) could force over IPC. - static constexpr uint32_t kMaxChartNotes = 8192; - - // Rejecting a malformed chart must also drop whatever chart the verifier - // currently holds — otherwise a failed (re)load leaves the previous - // song's chart active and getNoteVerdicts() keeps emitting stale verdicts. - auto reject = [&]() -> Napi::Value { - if (target) target->clearChart(); - return Napi::Boolean::New(env, false); - }; - - NoteVerifier::ChartUpdate chart; - if (reqObj.Has("arrangement") && reqObj.Get("arrangement").IsString()) - chart.arrangement = reqObj.Get("arrangement").As().Utf8Value(); - if (reqObj.Has("stringCount") && reqObj.Get("stringCount").IsNumber()) - chart.stringCount = reqObj.Get("stringCount").As().Int32Value(); - if (reqObj.Has("capo") && reqObj.Get("capo").IsNumber()) - chart.capo = reqObj.Get("capo").As().Int32Value(); - if (reqObj.Has("pitchCheckCents") && reqObj.Get("pitchCheckCents").IsNumber()) - chart.pitchCheckCents = reqObj.Get("pitchCheckCents").As().FloatValue(); - if (reqObj.Has("harmonicSnr") && reqObj.Get("harmonicSnr").IsNumber()) - chart.harmonicSnr = reqObj.Get("harmonicSnr").As().FloatValue(); - if (reqObj.Has("fundamentalRatio") && reqObj.Get("fundamentalRatio").IsNumber()) - { - // Drop NaN/Inf (see ScoreChord): a non-finite ratio poisons the - // fundamental-presence gate; keep the safe 0.20 default. - const float v = reqObj.Get("fundamentalRatio").As().FloatValue(); - if (std::isfinite(v)) chart.fundamentalRatio = v; - } - if (reqObj.Has("presenceRatio") && reqObj.Get("presenceRatio").IsNumber()) - { - // Temporal-persistence floor, clamped to [0,1]. Saturate rather than - // reject an out-of-range value: a stray >1 must NOT silently fall back to - // 0 (legacy ever-present), which would reintroduce the false-accept this - // guards against. Non-finite is ignored (keeps the 0 default). - const float v = reqObj.Get("presenceRatio").As().FloatValue(); - if (std::isfinite(v)) chart.presenceRatio = (v < 0.0f) ? 0.0f : (v > 1.0f ? 1.0f : v); - } - if (reqObj.Has("timingTolerance") && reqObj.Get("timingTolerance").IsNumber()) - chart.timingTolerance = reqObj.Get("timingTolerance").As().DoubleValue(); - - if (reqObj.Has("tuningOffsets") && reqObj.Get("tuningOffsets").IsArray()) - { - auto arr = reqObj.Get("tuningOffsets").As(); - if (arr.Length() > 32) return reject(); - chart.tuningOffsets.reserve(arr.Length()); - for (uint32_t i = 0; i < arr.Length(); ++i) - { - auto v = arr.Get(i); - if (!v.IsNumber()) return reject(); - chart.tuningOffsets.push_back(v.As().Int32Value()); - } - } - - // ChordScorer requires exactly one tuning offset per string and otherwise - // fails every note closed. Reject the chart here so a malformed payload - // surfaces as setChart() == false rather than a silently all-miss session - // the caller believes loaded fine. - if ((int) chart.tuningOffsets.size() != chart.stringCount) - return reject(); - - Napi::Value notesVal = reqObj.Has("notes") ? reqObj.Get("notes") : env.Null(); - if (!notesVal.IsArray()) return reject(); - auto notesArr = notesVal.As(); - if (notesArr.Length() > kMaxChartNotes) return reject(); - - chart.notes.reserve(notesArr.Length()); - for (uint32_t i = 0; i < notesArr.Length(); ++i) - { - auto v = notesArr.Get(i); - if (!v.IsObject()) return reject(); - auto noteObj = v.As(); - - // Every chart note must carry all five required fields with the right - // type. Filling defaults for a missing field would push a bogus - // time-0 note with an empty id — that breaks verdict-by-id alignment - // — so reject the whole chart instead. - const bool validNote = - noteObj.Has("id") && noteObj.Get("id").IsString() && - noteObj.Has("t") && noteObj.Get("t").IsNumber() && - noteObj.Has("s") && noteObj.Get("s").IsNumber() && - noteObj.Has("f") && noteObj.Get("f").IsNumber() && - noteObj.Has("sus") && noteObj.Get("sus").IsNumber(); - if (!validNote) return reject(); - - NoteVerifier::ChartNote n{}; - n.id = noteObj.Get("id").As().Utf8Value(); - n.t = noteObj.Get("t").As().DoubleValue(); - n.string = noteObj.Get("s").As().Int32Value(); - n.fret = noteObj.Get("f").As().Int32Value(); - n.sus = noteObj.Get("sus").As().DoubleValue(); - auto truthy = [¬eObj](const char* key) { - if (!noteObj.Has(key)) return false; - return noteObj.Get(key).ToBoolean().Value(); - }; - n.ho = truthy("ho"); - n.po = truthy("po"); - n.b = truthy("b"); - n.sl = truthy("sl"); - n.hm = truthy("hm"); - chart.notes.push_back(std::move(n)); - } - - target->setChart(chart); - return Napi::Boolean::New(env, true); -} - -// Legacy: setChart(chart) — targets sources[0]. Backward-compatible. -static Napi::Value SetChart(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto liveEngine = snapshotEngine(); - if (!liveEngine || info.Length() < 1 || !info[0].IsObject()) - { - if (liveEngine) liveEngine->clearChart(); - return Napi::Boolean::New(env, false); - } - return setChartCore(env, info[0].As(), liveEngine->getSource(0)); -} - -// Source-indexed: setSourceChart(sourceId, chart). Bad id / payload -> false. -static Napi::Value SetSourceChart(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto liveEngine = snapshotEngine(); - if (!liveEngine || info.Length() < 2 || !info[0].IsNumber() || !info[1].IsObject()) - return Napi::Boolean::New(env, false); - SourceChain* target = getValidatedSource(liveEngine.get(), info, 0); - if (!target) return Napi::Boolean::New(env, false); - return setChartCore(env, info[1].As(), target); -} - -// Drain the verdicts the NoteVerifier thread has finalized since the last -// call. Returns an array of { id, detected, detectedSongTime, centsError, snr }. -// -// Optionally also pushes the renderer's playhead: getNoteVerdicts(songTime, -// playing). The plugin calls this once per detect tick, so folding the push in -// here advances the verifier's clock without a second IPC round-trip. A -// downlevel caller passing no args still just drains. -static Napi::Value GetNoteVerdicts(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - // Null (not an empty array) on a missing engine — the bridge/preload - // contract treats null as "unsupported/unavailable" so the renderer - // feature-detects, matching detectNotes' no-engine path. - auto liveEngine = snapshotEngine(); - if (!liveEngine) return env.Null(); - - // Push the playhead before draining so this tick's verdicts reflect it. - // A JS NaN/Infinity passes IsNumber() — guard with isfinite so a bad - // value can't corrupt the verifier's interpolated timing. - if (info.Length() >= 2 && info[0].IsNumber() && info[1].IsBoolean()) - { - const double songTime = info[0].As().DoubleValue(); - if (std::isfinite(songTime)) - liveEngine->setPlayhead(songTime, info[1].As().Value()); - } - - const auto verdicts = liveEngine->getNoteVerdicts(); - auto arr = Napi::Array::New(env, verdicts.size()); - for (size_t i = 0; i < verdicts.size(); ++i) - { - const auto& v = verdicts[i]; - auto entry = Napi::Object::New(env); - entry.Set("id", v.id); - entry.Set("detected", v.detected); - entry.Set("detectedSongTime", v.detectedSongTime); - entry.Set("centsError", v.centsError); - entry.Set("snr", v.snr); - arr.Set((uint32_t) i, entry); - } - return arr; -} - -// Sample rate the audio device is running at. Notedetect's chord scorer -// needs this to map FFT bins to Hz; on the bridge path there's no -// AudioContext to read it from. Falls back to 48000 if the engine isn't -// ready (matches the historical fallback in screen.js) — and also if -// the engine is initialized but no device is currently active, which -// pins currentSampleRate to 0 internally and would otherwise propagate -// a divide-by-zero into the renderer's FFT-bin→Hz math. -static Napi::Value GetSampleRate(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - constexpr double kFallbackSampleRate = 48000.0; - auto liveEngine = snapshotEngine(); - if (!liveEngine) - return Napi::Number::New(env, kFallbackSampleRate); - const double sr = liveEngine->getCurrentSampleRate(); - if (!std::isfinite(sr) || sr <= 0.0) - return Napi::Number::New(env, kFallbackSampleRate); - return Napi::Number::New(env, sr); -} - // ── VST Plugin Scanning ────────────────────────────────────────────────────── class ScanPluginsWorker : public Napi::AsyncWorker @@ -1806,448 +313,8 @@ static Napi::Value SetVstCrashSentinelPath(const Napi::CallbackInfo& info) return env.Undefined(); } -// ── Signal Chain Management ────────────────────────────────────────────────── - -// Pending in-process loads: each LoadVSTWorker / LoadPresetWorker that's -// currently blocked on `done->wait()` registers its event here. doShutdown -// signals them all so the workers unblock and return a clean "cancelled" -// error instead of hanging forever when the JUCE message thread is about -// to be stopped (and any unfired callback would never arrive). -static Napi::Value RemoveProcessor(const Napi::CallbackInfo& info) -{ - // Typed extractors (addon/NapiHelpers.h): NaN/Inf slot ids used to coerce - // to slot 0 and mutate the wrong slot (deep-read §2) — now a clean no-op. - auto liveEngine = snapshotEngine(); - const auto slotId = slopsmith::addon::argSlotId(info, 0); - if (liveEngine && slotId) - { - std::lock_guard chainLock(slopsmith::addon::chainMutationMutex()); - liveEngine->getSignalChain().removeProcessor(*slotId); - slopsmith::addon::bumpChainGeneration(); - } - return info.Env().Undefined(); -} - -static Napi::Value MoveProcessor(const Napi::CallbackInfo& info) -{ - auto liveEngine = snapshotEngine(); - const auto from = slopsmith::addon::argSlotId(info, 0); - const auto to = slopsmith::addon::argSlotId(info, 1); - if (liveEngine && from && to) - { - std::lock_guard chainLock(slopsmith::addon::chainMutationMutex()); - liveEngine->getSignalChain().moveProcessor(*from, *to); - slopsmith::addon::bumpChainGeneration(); - } - return info.Env().Undefined(); -} - -static Napi::Value SetBypass(const Napi::CallbackInfo& info) -{ - auto liveEngine = snapshotEngine(); - const auto slotId = slopsmith::addon::argSlotId(info, 0); - const auto bypassed = slopsmith::addon::argBool(info, 1); - if (liveEngine && slotId && bypassed) - liveEngine->getSignalChain().setBypass(*slotId, *bypassed); - return info.Env().Undefined(); -} - -// Destroy every open in-process plugin editor window on the message thread and -// block until done. MUST run before any path that frees slot processors -// (ClearChain, LoadPreset's chain rebuild, engine teardown): an editor window -// owns an AudioProcessorEditor bound to its slot's processor, so if the -// processor is freed first the editor's next timer/paint callback dereferences -// freed memory (use-after-free → DEP-execute crash seconds after pause; -// feedBack-desktop#56). Lives in addon/EditorWindows now. - -static Napi::Value ClearChain(const Napi::CallbackInfo& info) -{ - // Tear editors down before their processors are freed just below (#56). - closeAllPluginEditorWindows(); - if (auto liveEngine = snapshotEngine()) - { - // Serialized with the async chain workers (deep-read 1). May block - // briefly behind an in-flight preset/VST load -- that wait IS the fix - // for the interleaved clear-vs-rebuild corruption. - std::lock_guard chainLock(slopsmith::addon::chainMutationMutex()); - liveEngine->getSignalChain().clear(); - slopsmith::addon::bumpChainGeneration(); - } - return info.Env().Undefined(); -} - -// Stereo routing (St-1). setPan(slotId, -1..+1); setBranch(slotId, 0=trunk/>=1). -static Napi::Value SetPan(const Napi::CallbackInfo& info) -{ - auto liveEngine = snapshotEngine(); - if (liveEngine && info.Length() >= 2) - { - const auto slotId = slopsmith::addon::argSlotId(info, 0); - const auto pan = slopsmith::addon::argFiniteFloat(info, 1); - if (slotId && pan) liveEngine->getSignalChain().setPan(*slotId, *pan); - } - return info.Env().Undefined(); -} - -static Napi::Value SetPostGain(const Napi::CallbackInfo& info) -{ - auto liveEngine = snapshotEngine(); - if (liveEngine && info.Length() >= 2) - { - const auto slotId = slopsmith::addon::argSlotId(info, 0); - const auto gain = slopsmith::addon::argFiniteFloat(info, 1); - if (slotId && gain) liveEngine->getSignalChain().setPostGain(*slotId, *gain); - } - return info.Env().Undefined(); -} - -static Napi::Value SetBranch(const Napi::CallbackInfo& info) -{ - auto liveEngine = snapshotEngine(); - if (liveEngine && info.Length() >= 2) - { - const auto slotId = slopsmith::addon::argSlotId(info, 0); - const auto branch = slopsmith::addon::argInt(info, 1); - if (slotId && branch) liveEngine->getSignalChain().setBranch(*slotId, *branch); - } - return info.Env().Undefined(); -} - -// setBranchSrc(slotId, 0=both/1=L/2=R): channel a branch reads from the split. -static Napi::Value SetBranchSrc(const Napi::CallbackInfo& info) -{ - auto liveEngine = snapshotEngine(); - if (liveEngine && info.Length() >= 2) - { - const auto slotId = slopsmith::addon::argSlotId(info, 0); - const auto branchSrc = slopsmith::addon::argInt(info, 1, 0, 2); - if (slotId && branchSrc) liveEngine->getSignalChain().setBranchSrc(*slotId, *branchSrc); - } - return info.Env().Undefined(); -} - -// ── Chain State ─────────────────────────────────────────────────────────────── - -// Monotonic chain-mutation counter (TLC phase 7): JS-side chain owners (the -// audio-effects executor) compare this against the generation their load -// returned to detect that another writer changed the chain under them. -static Napi::Value GetChainGeneration(const Napi::CallbackInfo& info) -{ - return Napi::Number::New(info.Env(), (double) slopsmith::addon::currentChainGeneration()); -} - -static Napi::Value GetChainState(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto result = Napi::Array::New(env); - auto liveEngine = snapshotEngine(); - - if (liveEngine) - { - auto slots = liveEngine->getSignalChain().getAllSlots(); - for (int i = 0; i < slots.size(); ++i) - { - auto obj = Napi::Object::New(env); - obj.Set("id", slots[i]->id); - obj.Set("type", (int)slots[i]->type); - obj.Set("name", slots[i]->name.toStdString()); - obj.Set("path", slots[i]->path.toStdString()); - obj.Set("bypassed", slots[i]->bypassed); - obj.Set("pan", slots[i]->pan); - obj.Set("branch", slots[i]->branch); - obj.Set("branchSrc", slots[i]->branchSrc); - obj.Set("postGain", slots[i]->postGain); - obj.Set("hasEditor", slots[i]->processor && slots[i]->processor->hasEditor()); - result.Set((uint32_t)i, obj); - } - } - - return result; -} - // ── Plugin Editor Window ────────────────────────────────────────────────────── -// ── Parameters ──────────────────────────────────────────────────────────────── - -static Napi::Value GetParameters(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto liveEngine = snapshotEngine(); - if (!liveEngine || info.Length() < 1) return Napi::Array::New(env); - - int slotId = info[0].As().Int32Value(); - auto params = liveEngine->getSignalChain().getParameters(slotId); - auto result = Napi::Array::New(env, params.size()); - - for (int i = 0; i < params.size(); ++i) - { - auto obj = Napi::Object::New(env); - obj.Set("index", params[i].index); - obj.Set("name", params[i].name.toStdString()); - obj.Set("value", params[i].value); - obj.Set("label", params[i].label.toStdString()); - obj.Set("text", params[i].text.toStdString()); - result.Set((uint32_t)i, obj); - } - - return result; -} - -static Napi::Value SetParameter(const Napi::CallbackInfo& info) -{ - auto liveEngine = snapshotEngine(); - const auto slotId = slopsmith::addon::argSlotId(info, 0); - const auto paramIdx = slopsmith::addon::argSlotId(info, 1); - const auto value = slopsmith::addon::argFiniteFloat(info, 2); - if (liveEngine && slotId && paramIdx && value) - liveEngine->getSignalChain().setParameter(*slotId, *paramIdx, *value); - return info.Env().Undefined(); -} - -// Restore a VST slot's full state from a base64 getStateInformation() blob. -static Napi::Value SetSlotState(const Napi::CallbackInfo& info) -{ - // Type-guard both args (NAPI_DISABLE_CPP_EXCEPTIONS): a malformed IPC - // payload is a clean no-op rather than a hard addon failure. - auto liveEngine = snapshotEngine(); - if (liveEngine && info.Length() >= 2 && info[0].IsNumber() && info[1].IsString()) - { - int slotId = info[0].As().Int32Value(); - auto base64 = info[1].As().Utf8Value(); - const auto* slot = liveEngine->getSignalChain().getSlot(slotId); - const bool allowStandard = slot != nullptr - && (slot->type == ProcessorSlot::Type::IR - || slot->type == ProcessorSlot::Type::NAM); - juce::MemoryBlock mb; - if (decodeStateBlob(juce::String(base64), mb, allowStandard)) - liveEngine->getSignalChain().setSlotState(slotId, mb); - } - return info.Env().Undefined(); -} - -// ── MIDI ────────────────────────────────────────────────────────────────────── - -static Napi::Value SendMidiToSlot(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto liveEngine = snapshotEngine(); - if (!liveEngine || info.Length() < 4) - return Napi::Boolean::New(env, false); - - // Typed + range-checked: unclamped channel/program used to trip JUCE - // assertions (deep-read §2). Out-of-range now returns false cleanly. - const auto slotId = slopsmith::addon::argSlotId(info, 0); - const auto msgType = slopsmith::addon::argInt(info, 1, 0, 1); - const auto channel = slopsmith::addon::argMidiChannel(info, 2); - if (!slotId || !msgType || !channel) - return Napi::Boolean::New(env, false); - - juce::MidiMessage midiMsg; - if (*msgType == 0) // Program Change - { - const auto program = slopsmith::addon::argMidiByte(info, 3); - if (!program) return Napi::Boolean::New(env, false); - midiMsg = juce::MidiMessage::programChange(*channel, *program); - } - else // Control Change - { - const auto controller = slopsmith::addon::argMidiByte(info, 3); - if (!controller) return Napi::Boolean::New(env, false); - const auto value = slopsmith::addon::argMidiByte(info, 4); - midiMsg = juce::MidiMessage::controllerEvent(*channel, *controller, value.value_or(0)); - } - - liveEngine->getSignalChain().queueMidiMessage(*slotId, midiMsg); - return Napi::Boolean::New(env, true); -} - -// ── Backing Track ───────────────────────────────────────────────────────────── - -static Napi::Value LoadBackingTrack(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto liveEngine = snapshotEngine(); - if (!liveEngine || info.Length() < 1) return Napi::Boolean::New(env, false); - - auto path = info[0].As().Utf8Value(); - bool result = liveEngine->loadBackingTrack(juce::File(juce::String(path))); - return Napi::Boolean::New(env, result); -} - -static Napi::Value StartBacking(const Napi::CallbackInfo& info) -{ - if (auto liveEngine = snapshotEngine()) liveEngine->startBacking(); - return info.Env().Undefined(); -} - -static Napi::Value StopBacking(const Napi::CallbackInfo& info) -{ - if (auto liveEngine = snapshotEngine()) liveEngine->stopBacking(); - return info.Env().Undefined(); -} - -static Napi::Value SeekBacking(const Napi::CallbackInfo& info) -{ - auto liveEngine = snapshotEngine(); - if (liveEngine && info.Length() > 0) - liveEngine->setBackingPosition(info[0].As().DoubleValue()); - return info.Env().Undefined(); -} - -static Napi::Value GetBackingPosition(const Napi::CallbackInfo& info) -{ - auto liveEngine = snapshotEngine(); - double pos = liveEngine ? liveEngine->getBackingPosition() : 0.0; - return Napi::Number::New(info.Env(), pos); -} - -static Napi::Value GetBackingDuration(const Napi::CallbackInfo& info) -{ - auto liveEngine = snapshotEngine(); - double dur = liveEngine ? liveEngine->getBackingDuration() : 0.0; - return Napi::Number::New(info.Env(), dur); -} - -static Napi::Value IsBackingPlaying(const Napi::CallbackInfo& info) -{ - auto liveEngine = snapshotEngine(); - bool playing = liveEngine ? liveEngine->isBackingPlaying() : false; - return Napi::Boolean::New(info.Env(), playing); -} - -static Napi::Value SetBackingSpeed(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - if (info.Length() < 1 || !info[0].IsNumber()) - { - Napi::TypeError::New(env, "setBackingSpeed(speed) requires a number") - .ThrowAsJavaScriptException(); - return env.Undefined(); - } - // (Was a bare `engine` dereference — the one binding that dodged the - // file's own snapshot rule; surfaced by the phase-6 move.) - if (auto liveEngine = snapshotEngine()) - liveEngine->setBackingSpeed(info[0].As().DoubleValue()); - return env.Undefined(); -} - -// ── Presets ─────────────────────────────────────────────────────────────────── - -static Napi::Value SavePreset(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto liveEngine = snapshotEngine(); - if (!liveEngine) return env.Null(); - auto json = liveEngine->getSignalChain().savePreset(); - return Napi::String::New(env, json.toStdString()); -} - -static Napi::Value SetMultiBypass(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - auto liveEngine = snapshotEngine(); - if (!liveEngine || info.Length() < 1 || !info[0].IsArray()) - return Napi::Boolean::New(env, false); - - auto arr = info[0].As(); - juce::Array> changes; - - for (uint32_t i = 0; i < arr.Length(); i++) - { - // Per-item type guards (deep-read §2): a malformed entry is skipped - // instead of coercing NaN to slot 0. - auto itemVal = arr.Get(i); - if (!itemVal.IsObject()) continue; - auto item = itemVal.As(); - auto slotVal = item.Get("slotId"); - auto bypVal = item.Get("bypassed"); - if (!slotVal.IsNumber() || !bypVal.IsBoolean()) continue; - const double raw = slotVal.As().DoubleValue(); - if (!std::isfinite(raw) || raw != std::floor(raw) || raw < 0.0 || raw > 4096.0) continue; - changes.add({ (int) raw, bypVal.As().Value() }); - } - - liveEngine->getSignalChain().setMultiBypass(changes); - return Napi::Boolean::New(env, true); -} - -// ── Debug file logging ──────────────────────────────────────────────────────── - -// Redirect the C runtime's stderr stream to a file so the native -// [AudioEngine] / [audio-native] diagnostics are captured for a bug report on -// machines with no console (packaged Windows builds). Only invoked when -// SLOPSMITH_DEBUG is set. Returns "" on success, or an error description the -// JS layer logs as an [audio] line. -// -// freopen (not dup2): a packaged GUI-subsystem app has no console, so stderr -// has no valid fd — dup2 onto fileno(stderr) fails. freopen reassigns the -// stream itself and works with or without a console. freopen would close -// stderr before trying the path, so a bad path is ruled out FIRST with a -// throwaway fopen probe (which never touches stderr); only once the path is -// known-writable do we freopen. Append mode so the JS layer's header -// survives; unbuffered so a crash leaves a complete tail. -static Napi::Value EnableFileLogging(const Napi::CallbackInfo& info) -{ - auto env = info.Env(); - if (info.Length() < 1 || !info[0].IsString()) - { - Napi::TypeError::New(env, "enableFileLogging(path) requires a string") - .ThrowAsJavaScriptException(); - return env.Undefined(); - } - -#if defined(_WIN32) - // Widen UTF-16 → wchar_t by value-converting each code unit (not a - // reinterpret_cast — char16_t and wchar_t are distinct types even though - // both are 16-bit on Windows). Wide path so a profile dir with non-ASCII - // characters isn't mangled by the ANSI codepage (cf. src/vst-host/main.cpp, - // which uses the GetEnvironmentVariableW / _wfopen wide path for the same - // reason). - const std::u16string u16 = info[0].As().Utf16Value(); - const std::wstring wpath(u16.begin(), u16.end()); - FILE* probe = _wfopen(wpath.c_str(), L"a"); -#else - const std::string path = info[0].As().Utf8Value(); - FILE* probe = std::fopen(path.c_str(), "a"); -#endif - if (probe == nullptr) - { - // Capture errno before Napi::String::New / std::to_string, which may - // call library code that clobbers it. - const int e = errno; - return Napi::String::New(env, std::string("fopen failed (errno=") - + std::to_string(e) + ")"); - } - std::fclose(probe); // path is writable; stderr never touched on this path - -#if defined(_WIN32) - FILE* fp = _wfreopen(wpath.c_str(), L"a", stderr); -#else - FILE* fp = std::freopen(path.c_str(), "a", stderr); -#endif - if (fp == nullptr) - { - const int e = errno; - // freopen closes stderr before trying the path; on failure it's left - // closed. The probe just verified the path, so this is near-impossible - // — but redirect stderr to the null device so it's a valid sink rather - // than a closed stream that could trip later fprintf(stderr) calls. -#if defined(_WIN32) - std::freopen("NUL", "w", stderr); -#else - std::freopen("/dev/null", "w", stderr); -#endif - return Napi::String::New(env, std::string("freopen failed (errno=") - + std::to_string(e) + ")"); - } - - // Unbuffered: each [AudioEngine] fprintf hits disk immediately, so a - // crash mid-reconfigure still leaves the diagnostic line that explains it. - std::setvbuf(stderr, nullptr, _IONBF, 0); - std::fprintf(stderr, "[audio-native] file logging enabled\n"); - return Napi::String::New(env, ""); // empty = success -} - // ── Module Registration ─────────────────────────────────────────────────────── static Napi::Object InitModule(Napi::Env env, Napi::Object exports) diff --git a/src/audio/addon/BackingBindings.cpp b/src/audio/addon/BackingBindings.cpp new file mode 100644 index 0000000..daa3197 --- /dev/null +++ b/src/audio/addon/BackingBindings.cpp @@ -0,0 +1,91 @@ +// Backing track bindings - moved verbatim from NodeAddon.cpp (TLC phase 7b +// binding split). Registered by NodeAddon's export table via Bindings.h. + +#include "Bindings.h" + +#include "AddonContext.h" +#include "NapiHelpers.h" +#include "ChainOps.h" +#include "../AudioEngine.h" +#include "../VSTHost.h" +#include "../VSTTrace.h" + +#include +#include +#include +#include + +namespace slopsmith::addon { + +// ── Backing Track ───────────────────────────────────────────────────────────── + +Napi::Value LoadBackingTrack(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto liveEngine = snapshotEngine(); + if (!liveEngine || info.Length() < 1) return Napi::Boolean::New(env, false); + + auto path = info[0].As().Utf8Value(); + bool result = liveEngine->loadBackingTrack(juce::File(juce::String(path))); + return Napi::Boolean::New(env, result); +} + +Napi::Value StartBacking(const Napi::CallbackInfo& info) +{ + if (auto liveEngine = snapshotEngine()) liveEngine->startBacking(); + return info.Env().Undefined(); +} + +Napi::Value StopBacking(const Napi::CallbackInfo& info) +{ + if (auto liveEngine = snapshotEngine()) liveEngine->stopBacking(); + return info.Env().Undefined(); +} + +Napi::Value SeekBacking(const Napi::CallbackInfo& info) +{ + auto liveEngine = snapshotEngine(); + if (liveEngine && info.Length() > 0) + liveEngine->setBackingPosition(info[0].As().DoubleValue()); + return info.Env().Undefined(); +} + +Napi::Value GetBackingPosition(const Napi::CallbackInfo& info) +{ + auto liveEngine = snapshotEngine(); + double pos = liveEngine ? liveEngine->getBackingPosition() : 0.0; + return Napi::Number::New(info.Env(), pos); +} + +Napi::Value GetBackingDuration(const Napi::CallbackInfo& info) +{ + auto liveEngine = snapshotEngine(); + double dur = liveEngine ? liveEngine->getBackingDuration() : 0.0; + return Napi::Number::New(info.Env(), dur); +} + +Napi::Value IsBackingPlaying(const Napi::CallbackInfo& info) +{ + auto liveEngine = snapshotEngine(); + bool playing = liveEngine ? liveEngine->isBackingPlaying() : false; + return Napi::Boolean::New(info.Env(), playing); +} + +Napi::Value SetBackingSpeed(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + if (info.Length() < 1 || !info[0].IsNumber()) + { + Napi::TypeError::New(env, "setBackingSpeed(speed) requires a number") + .ThrowAsJavaScriptException(); + return env.Undefined(); + } + // (Was a bare `engine` dereference — the one binding that dodged the + // file's own snapshot rule; surfaced by the phase-6 move.) + if (auto liveEngine = snapshotEngine()) + liveEngine->setBackingSpeed(info[0].As().DoubleValue()); + return env.Undefined(); +} + + +} // namespace slopsmith::addon diff --git a/src/audio/addon/Bindings.h b/src/audio/addon/Bindings.h new file mode 100644 index 0000000..acc79f4 --- /dev/null +++ b/src/audio/addon/Bindings.h @@ -0,0 +1,108 @@ +#pragma once + +// Binding declarations for the split N-API handler files (TLC phase 7b): +// DeviceBindings / ControlBindings / DetectionBindings / ChainBindings / +// BackingBindings. NodeAddon.cpp registers them in its export table. + +#include + +class AudioEngine; +class SourceChain; + +namespace slopsmith::addon { + +// Validate a JS source-id argument and return the live source (nullptr for +// missing / non-Number / non-finite / out-of-range). Shared by the +// source-indexed bindings across the split files. +SourceChain* getValidatedSource(AudioEngine* eng, const Napi::CallbackInfo& info, size_t argIndex); + +Napi::Value AddSource(const Napi::CallbackInfo& info); +Napi::Value BindInputDevice(const Napi::CallbackInfo& info); +Napi::Value ClearChain(const Napi::CallbackInfo& info); +Napi::Value ClearStreamOutput(const Napi::CallbackInfo& info); +Napi::Value DetectNotes(const Napi::CallbackInfo& info); +Napi::Value EnableFileLogging(const Napi::CallbackInfo& info); +Napi::Value GetBackingDuration(const Napi::CallbackInfo& info); +Napi::Value GetBackingLevel(const Napi::CallbackInfo& info); +Napi::Value GetBackingPosition(const Napi::CallbackInfo& info); +Napi::Value GetBufferSizes(const Napi::CallbackInfo& info); +Napi::Value GetChainGeneration(const Napi::CallbackInfo& info); +Napi::Value GetChainState(const Napi::CallbackInfo& info); +Napi::Value GetCurrentDevice(const Napi::CallbackInfo& info); +Napi::Value GetDeviceMetrics(const Napi::CallbackInfo& info); +Napi::Value GetDeviceTypes(const Napi::CallbackInfo& info); +Napi::Value GetLevels(const Napi::CallbackInfo& info); +Napi::Value GetNoteVerdicts(const Napi::CallbackInfo& info); +Napi::Value GetParameters(const Napi::CallbackInfo& info); +Napi::Value GetPitchDetection(const Napi::CallbackInfo& info); +Napi::Value GetRawAudioFrame(const Napi::CallbackInfo& info); +Napi::Value GetRawPitchDetection(const Napi::CallbackInfo& info); +Napi::Value GetRendererBusMetrics(const Napi::CallbackInfo& info); +Napi::Value GetSampleRate(const Napi::CallbackInfo& info); +Napi::Value GetSampleRates(const Napi::CallbackInfo& info); +Napi::Value GetSourceLevels(const Napi::CallbackInfo& info); +Napi::Value GetSourceNoteVerdicts(const Napi::CallbackInfo& info); +Napi::Value GetSourcePitchDetection(const Napi::CallbackInfo& info); +Napi::Value GetSourceRawAudioFrame(const Napi::CallbackInfo& info); +Napi::Value GetSourceRawPitchDetection(const Napi::CallbackInfo& info); +Napi::Value GetStreamOverflowCount(const Napi::CallbackInfo& info); +Napi::Value GetStreamSinkLevel(const Napi::CallbackInfo& info); +Napi::Value GetStreamUnderflowCount(const Napi::CallbackInfo& info); +Napi::Value IsAudioRunning(const Napi::CallbackInfo& info); +Napi::Value IsBackingPlaying(const Napi::CallbackInfo& info); +Napi::Value IsMlNoteDetection(const Napi::CallbackInfo& info); +Napi::Value IsMonitorMuted(const Napi::CallbackInfo& info); +Napi::Value IsStreamOutputActive(const Napi::CallbackInfo& info); +Napi::Value ListInputDevices(const Napi::CallbackInfo& info); +Napi::Value ListSources(const Napi::CallbackInfo& info); +Napi::Value LoadBackingTrack(const Napi::CallbackInfo& info); +Napi::Value LoadNoteModel(const Napi::CallbackInfo& info); +Napi::Value MoveProcessor(const Napi::CallbackInfo& info); +Napi::Value ProbeDeviceOptions(const Napi::CallbackInfo& info); +Napi::Value PushRendererAudio(const Napi::CallbackInfo& info); +Napi::Value RemoveProcessor(const Napi::CallbackInfo& info); +Napi::Value RemoveSource(const Napi::CallbackInfo& info); +Napi::Value ResetPeaks(const Napi::CallbackInfo& info); +Napi::Value SavePreset(const Napi::CallbackInfo& info); +Napi::Value ScoreChord(const Napi::CallbackInfo& info); +Napi::Value ScoreSourceChord(const Napi::CallbackInfo& info); +Napi::Value SeekBacking(const Napi::CallbackInfo& info); +Napi::Value SendMidiToSlot(const Napi::CallbackInfo& info); +Napi::Value SetBackingSpeed(const Napi::CallbackInfo& info); +Napi::Value SetBranch(const Napi::CallbackInfo& info); +Napi::Value SetBranchSrc(const Napi::CallbackInfo& info); +Napi::Value SetBypass(const Napi::CallbackInfo& info); +Napi::Value SetChart(const Napi::CallbackInfo& info); +Napi::Value SetDevice(const Napi::CallbackInfo& info); +Napi::Value SetDeviceType(const Napi::CallbackInfo& info); +Napi::Value SetGain(const Napi::CallbackInfo& info); +Napi::Value SetInputChannel(const Napi::CallbackInfo& info); +Napi::Value SetMonitorKill(const Napi::CallbackInfo& info); +Napi::Value SetMonitorMute(const Napi::CallbackInfo& info); +Napi::Value SetMonitorMuteSuppressed(const Napi::CallbackInfo& info); +Napi::Value SetMultiBypass(const Napi::CallbackInfo& info); +Napi::Value SetNoiseGate(const Napi::CallbackInfo& info); +Napi::Value SetNoteDetectionEnabled(const Napi::CallbackInfo& info); +Napi::Value SetOutputDeviceType(const Napi::CallbackInfo& info); +Napi::Value SetPan(const Napi::CallbackInfo& info); +Napi::Value SetParameter(const Napi::CallbackInfo& info); +Napi::Value SetPostGain(const Napi::CallbackInfo& info); +Napi::Value SetRendererBus(const Napi::CallbackInfo& info); +Napi::Value SetSlotState(const Napi::CallbackInfo& info); +Napi::Value SetSourceChart(const Napi::CallbackInfo& info); +Napi::Value SetSourceInputChannel(const Napi::CallbackInfo& info); +Napi::Value SetSourceMonitorMute(const Napi::CallbackInfo& info); +Napi::Value SetSourceVerifierOffset(const Napi::CallbackInfo& info); +Napi::Value SetStreamBus(const Napi::CallbackInfo& info); +Napi::Value SetStreamBusGain(const Napi::CallbackInfo& info); +Napi::Value SetStreamOutputDevice(const Napi::CallbackInfo& info); +Napi::Value SetTonePolish(const Napi::CallbackInfo& info); +Napi::Value StartAudio(const Napi::CallbackInfo& info); +Napi::Value StartBacking(const Napi::CallbackInfo& info); +Napi::Value StopAudio(const Napi::CallbackInfo& info); +Napi::Value StopBacking(const Napi::CallbackInfo& info); +Napi::Value UnbindInputDevice(const Napi::CallbackInfo& info); +Napi::Value scoreChordCore(const Napi::CallbackInfo& info); +Napi::Value setChartCore(const Napi::CallbackInfo& info); + +} // namespace slopsmith::addon diff --git a/src/audio/addon/ChainBindings.cpp b/src/audio/addon/ChainBindings.cpp new file mode 100644 index 0000000..07ec06b --- /dev/null +++ b/src/audio/addon/ChainBindings.cpp @@ -0,0 +1,279 @@ +// Signal-chain slot/state/preset bindings - moved verbatim from NodeAddon.cpp (TLC phase 7b +// binding split). Registered by NodeAddon's export table via Bindings.h. + +#include "Bindings.h" + +#include "AddonContext.h" +#include "NapiHelpers.h" +#include "ChainOps.h" +#include "EditorWindows.h" +#include "../AudioEngine.h" +#include "../VSTHost.h" +#include "../VSTTrace.h" + +#include +#include +#include +#include + +namespace slopsmith::addon { + +// ── Signal Chain Management ────────────────────────────────────────────────── + +// Pending in-process loads: each LoadVSTWorker / LoadPresetWorker that's +// currently blocked on `done->wait()` registers its event here. doShutdown +// signals them all so the workers unblock and return a clean "cancelled" +// error instead of hanging forever when the JUCE message thread is about +// to be stopped (and any unfired callback would never arrive). +Napi::Value RemoveProcessor(const Napi::CallbackInfo& info) +{ + // Typed extractors (addon/NapiHelpers.h): NaN/Inf slot ids used to coerce + // to slot 0 and mutate the wrong slot (deep-read §2) — now a clean no-op. + auto liveEngine = snapshotEngine(); + const auto slotId = slopsmith::addon::argSlotId(info, 0); + if (liveEngine && slotId) + { + std::lock_guard chainLock(slopsmith::addon::chainMutationMutex()); + liveEngine->getSignalChain().removeProcessor(*slotId); + slopsmith::addon::bumpChainGeneration(); + } + return info.Env().Undefined(); +} + +Napi::Value MoveProcessor(const Napi::CallbackInfo& info) +{ + auto liveEngine = snapshotEngine(); + const auto from = slopsmith::addon::argSlotId(info, 0); + const auto to = slopsmith::addon::argSlotId(info, 1); + if (liveEngine && from && to) + { + std::lock_guard chainLock(slopsmith::addon::chainMutationMutex()); + liveEngine->getSignalChain().moveProcessor(*from, *to); + slopsmith::addon::bumpChainGeneration(); + } + return info.Env().Undefined(); +} + +Napi::Value SetBypass(const Napi::CallbackInfo& info) +{ + auto liveEngine = snapshotEngine(); + const auto slotId = slopsmith::addon::argSlotId(info, 0); + const auto bypassed = slopsmith::addon::argBool(info, 1); + if (liveEngine && slotId && bypassed) + liveEngine->getSignalChain().setBypass(*slotId, *bypassed); + return info.Env().Undefined(); +} + +// Destroy every open in-process plugin editor window on the message thread and +// block until done. MUST run before any path that frees slot processors +// (ClearChain, LoadPreset's chain rebuild, engine teardown): an editor window +// owns an AudioProcessorEditor bound to its slot's processor, so if the +// processor is freed first the editor's next timer/paint callback dereferences +// freed memory (use-after-free → DEP-execute crash seconds after pause; +// feedBack-desktop#56). Lives in addon/EditorWindows now. + +Napi::Value ClearChain(const Napi::CallbackInfo& info) +{ + // Tear editors down before their processors are freed just below (#56). + closeAllPluginEditorWindows(); + if (auto liveEngine = snapshotEngine()) + { + // Serialized with the async chain workers (deep-read 1). May block + // briefly behind an in-flight preset/VST load -- that wait IS the fix + // for the interleaved clear-vs-rebuild corruption. + std::lock_guard chainLock(slopsmith::addon::chainMutationMutex()); + liveEngine->getSignalChain().clear(); + slopsmith::addon::bumpChainGeneration(); + } + return info.Env().Undefined(); +} + +// Stereo routing (St-1). setPan(slotId, -1..+1); setBranch(slotId, 0=trunk/>=1). +Napi::Value SetPan(const Napi::CallbackInfo& info) +{ + auto liveEngine = snapshotEngine(); + if (liveEngine && info.Length() >= 2) + { + const auto slotId = slopsmith::addon::argSlotId(info, 0); + const auto pan = slopsmith::addon::argFiniteFloat(info, 1); + if (slotId && pan) liveEngine->getSignalChain().setPan(*slotId, *pan); + } + return info.Env().Undefined(); +} + +Napi::Value SetPostGain(const Napi::CallbackInfo& info) +{ + auto liveEngine = snapshotEngine(); + if (liveEngine && info.Length() >= 2) + { + const auto slotId = slopsmith::addon::argSlotId(info, 0); + const auto gain = slopsmith::addon::argFiniteFloat(info, 1); + if (slotId && gain) liveEngine->getSignalChain().setPostGain(*slotId, *gain); + } + return info.Env().Undefined(); +} + +Napi::Value SetBranch(const Napi::CallbackInfo& info) +{ + auto liveEngine = snapshotEngine(); + if (liveEngine && info.Length() >= 2) + { + const auto slotId = slopsmith::addon::argSlotId(info, 0); + const auto branch = slopsmith::addon::argInt(info, 1); + if (slotId && branch) liveEngine->getSignalChain().setBranch(*slotId, *branch); + } + return info.Env().Undefined(); +} + +// setBranchSrc(slotId, 0=both/1=L/2=R): channel a branch reads from the split. +Napi::Value SetBranchSrc(const Napi::CallbackInfo& info) +{ + auto liveEngine = snapshotEngine(); + if (liveEngine && info.Length() >= 2) + { + const auto slotId = slopsmith::addon::argSlotId(info, 0); + const auto branchSrc = slopsmith::addon::argInt(info, 1, 0, 2); + if (slotId && branchSrc) liveEngine->getSignalChain().setBranchSrc(*slotId, *branchSrc); + } + return info.Env().Undefined(); +} + +// ── Chain State ─────────────────────────────────────────────────────────────── + +// Monotonic chain-mutation counter (TLC phase 7): JS-side chain owners (the +// audio-effects executor) compare this against the generation their load +// returned to detect that another writer changed the chain under them. +Napi::Value GetChainGeneration(const Napi::CallbackInfo& info) +{ + return Napi::Number::New(info.Env(), (double) slopsmith::addon::currentChainGeneration()); +} + +Napi::Value GetChainState(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto result = Napi::Array::New(env); + auto liveEngine = snapshotEngine(); + + if (liveEngine) + { + auto slots = liveEngine->getSignalChain().getAllSlots(); + for (int i = 0; i < slots.size(); ++i) + { + auto obj = Napi::Object::New(env); + obj.Set("id", slots[i]->id); + obj.Set("type", (int)slots[i]->type); + obj.Set("name", slots[i]->name.toStdString()); + obj.Set("path", slots[i]->path.toStdString()); + obj.Set("bypassed", slots[i]->bypassed); + obj.Set("pan", slots[i]->pan); + obj.Set("branch", slots[i]->branch); + obj.Set("branchSrc", slots[i]->branchSrc); + obj.Set("postGain", slots[i]->postGain); + obj.Set("hasEditor", slots[i]->processor && slots[i]->processor->hasEditor()); + result.Set((uint32_t)i, obj); + } + } + + return result; +} + +// ── Parameters ──────────────────────────────────────────────────────────────── + +Napi::Value GetParameters(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto liveEngine = snapshotEngine(); + if (!liveEngine || info.Length() < 1) return Napi::Array::New(env); + + int slotId = info[0].As().Int32Value(); + auto params = liveEngine->getSignalChain().getParameters(slotId); + auto result = Napi::Array::New(env, params.size()); + + for (int i = 0; i < params.size(); ++i) + { + auto obj = Napi::Object::New(env); + obj.Set("index", params[i].index); + obj.Set("name", params[i].name.toStdString()); + obj.Set("value", params[i].value); + obj.Set("label", params[i].label.toStdString()); + obj.Set("text", params[i].text.toStdString()); + result.Set((uint32_t)i, obj); + } + + return result; +} + +Napi::Value SetParameter(const Napi::CallbackInfo& info) +{ + auto liveEngine = snapshotEngine(); + const auto slotId = slopsmith::addon::argSlotId(info, 0); + const auto paramIdx = slopsmith::addon::argSlotId(info, 1); + const auto value = slopsmith::addon::argFiniteFloat(info, 2); + if (liveEngine && slotId && paramIdx && value) + liveEngine->getSignalChain().setParameter(*slotId, *paramIdx, *value); + return info.Env().Undefined(); +} + +// Restore a VST slot's full state from a base64 getStateInformation() blob. +Napi::Value SetSlotState(const Napi::CallbackInfo& info) +{ + // Type-guard both args (NAPI_DISABLE_CPP_EXCEPTIONS): a malformed IPC + // payload is a clean no-op rather than a hard addon failure. + auto liveEngine = snapshotEngine(); + if (liveEngine && info.Length() >= 2 && info[0].IsNumber() && info[1].IsString()) + { + int slotId = info[0].As().Int32Value(); + auto base64 = info[1].As().Utf8Value(); + const auto* slot = liveEngine->getSignalChain().getSlot(slotId); + const bool allowStandard = slot != nullptr + && (slot->type == ProcessorSlot::Type::IR + || slot->type == ProcessorSlot::Type::NAM); + juce::MemoryBlock mb; + if (decodeStateBlob(juce::String(base64), mb, allowStandard)) + liveEngine->getSignalChain().setSlotState(slotId, mb); + } + return info.Env().Undefined(); +} + +// ── Presets ─────────────────────────────────────────────────────────────────── + +Napi::Value SavePreset(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto liveEngine = snapshotEngine(); + if (!liveEngine) return env.Null(); + auto json = liveEngine->getSignalChain().savePreset(); + return Napi::String::New(env, json.toStdString()); +} + +Napi::Value SetMultiBypass(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto liveEngine = snapshotEngine(); + if (!liveEngine || info.Length() < 1 || !info[0].IsArray()) + return Napi::Boolean::New(env, false); + + auto arr = info[0].As(); + juce::Array> changes; + + for (uint32_t i = 0; i < arr.Length(); i++) + { + // Per-item type guards (deep-read §2): a malformed entry is skipped + // instead of coercing NaN to slot 0. + auto itemVal = arr.Get(i); + if (!itemVal.IsObject()) continue; + auto item = itemVal.As(); + auto slotVal = item.Get("slotId"); + auto bypVal = item.Get("bypassed"); + if (!slotVal.IsNumber() || !bypVal.IsBoolean()) continue; + const double raw = slotVal.As().DoubleValue(); + if (!std::isfinite(raw) || raw != std::floor(raw) || raw < 0.0 || raw > 4096.0) continue; + changes.add({ (int) raw, bypVal.As().Value() }); + } + + liveEngine->getSignalChain().setMultiBypass(changes); + return Napi::Boolean::New(env, true); +} + + +} // namespace slopsmith::addon diff --git a/src/audio/addon/ControlBindings.cpp b/src/audio/addon/ControlBindings.cpp new file mode 100644 index 0000000..fde10a3 --- /dev/null +++ b/src/audio/addon/ControlBindings.cpp @@ -0,0 +1,336 @@ +// Gain/metering/MIDI/debug-logging bindings - moved verbatim from NodeAddon.cpp (TLC phase 7b +// binding split). Registered by NodeAddon's export table via Bindings.h. + +#include "Bindings.h" + +#include "AddonContext.h" +#include "NapiHelpers.h" +#include "ChainOps.h" +#include "../AudioEngine.h" +#include "../VSTHost.h" +#include "../VSTTrace.h" + +#include +#include +#include +#include + +namespace slopsmith::addon { + +// ── Gain ────────────────────────────────────────────────────────────────────── + +Napi::Value SetGain(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto liveEngine = snapshotEngine(); + if (!liveEngine || info.Length() < 2) return env.Undefined(); + + if (!info[0].IsString()) return env.Undefined(); + auto which = info[0].As().Utf8Value(); + const auto valueOpt = slopsmith::addon::argFiniteFloat(info, 1); + if (!valueOpt) return env.Undefined(); // engine clamps range; NaN/Inf rejected here + const float value = *valueOpt; + + if (which == "input") liveEngine->setInputGain(value); + else if (which == "output") liveEngine->setOutputGain(value); + else if (which == "chain") liveEngine->setChainOutputGain(value); + else if (which == "backing") liveEngine->setBackingVolume(value); + + return env.Undefined(); +} + +Napi::Value SetInputChannel(const Napi::CallbackInfo& info) +{ + auto liveEngine = snapshotEngine(); + if (liveEngine && info.Length() > 0) + liveEngine->setInputChannel(info[0].As().Int32Value()); + return info.Env().Undefined(); +} + +Napi::Value SetMonitorMute(const Napi::CallbackInfo& info) +{ + auto liveEngine = snapshotEngine(); + if (liveEngine && info.Length() > 0) + liveEngine->setMonitorMute(info[0].As().Value()); + return info.Env().Undefined(); +} + +// setNoteDetectionEnabled(bool) -> undefined. Arms/suspends the polyphonic ML +// note-detection pipeline across all sources. The renderer (note_detect) calls +// this true only while a consumer actually reads ML notes (native-frame +// detection / non-verifier fallback) and false otherwise — the default +// harmonic-comb verifier path and the always-on home tuner leave ML suspended, +// so the engine runs no ONNX inference when nothing needs it. +Napi::Value SetNoteDetectionEnabled(const Napi::CallbackInfo& info) +{ + auto liveEngine = snapshotEngine(); + if (liveEngine && info.Length() > 0) + liveEngine->setMlNoteDetectionEnabled(info[0].As().Value()); + return info.Env().Undefined(); +} + +Napi::Value SetMonitorMuteSuppressed(const Napi::CallbackInfo& info) +{ + // IsBoolean()-guarded so a mismatched renderer build / manual caller + // passing a non-boolean is a clean no-op rather than a hard N-API failure + // (NAPI_DISABLE_CPP_EXCEPTIONS is enabled). Mirrors SetNoiseGate's style. + auto liveEngine = snapshotEngine(); + if (liveEngine && info.Length() > 0 && info[0].IsBoolean()) + liveEngine->setMonitorMuteSuppressed(info[0].As().Value()); + return info.Env().Undefined(); +} + +Napi::Value SetMonitorKill(const Napi::CallbackInfo& info) +{ + // IsBoolean()-guarded (fail-soft no-op on a downlevel/mismatched caller), + // mirroring SetMonitorMuteSuppressed. + auto liveEngine = snapshotEngine(); + if (liveEngine && info.Length() > 0 && info[0].IsBoolean()) + liveEngine->setMonitorKill(info[0].As().Value()); + return info.Env().Undefined(); +} + +Napi::Value SetNoiseGate(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto liveEngine = snapshotEngine(); + if (!liveEngine || info.Length() < 1 || !info[0].IsObject()) + return env.Undefined(); + + auto o = info[0].As(); + + bool enabled = false; + if (o.Has("enabled")) + { + auto v = o.Get("enabled"); + if (v.IsBoolean()) + enabled = v.As().Value(); + else if (v.IsNumber()) + enabled = v.As().DoubleValue() != 0.0; + } + + float thresholdDb = -60.0f; + if (o.Has("thresholdDb") && o.Get("thresholdDb").IsNumber()) + thresholdDb = (float)o.Get("thresholdDb").As().DoubleValue(); + + float releaseMs = 100.0f; + if (o.Has("releaseMs") && o.Get("releaseMs").IsNumber()) + releaseMs = (float)o.Get("releaseMs").As().DoubleValue(); + + float depthDb = -60.0f; + if (o.Has("depthDb") && o.Get("depthDb").IsNumber()) + depthDb = (float)o.Get("depthDb").As().DoubleValue(); + + liveEngine->setNoiseGate(enabled, thresholdDb, releaseMs, depthDb); + return env.Undefined(); +} + +Napi::Value SetTonePolish(const Napi::CallbackInfo& info) +{ + // Tone Polish — { enabled: bool }. Mirrors SetNoiseGate's defensive + // shape so a mismatched renderer build / manual caller passing a + // non-object is a clean no-op rather than a hard N-API failure + // (NAPI_DISABLE_CPP_EXCEPTIONS). + auto env = info.Env(); + auto liveEngine = snapshotEngine(); + if (!liveEngine || info.Length() < 1 || !info[0].IsObject()) + return env.Undefined(); + + auto o = info[0].As(); + + bool enabled = true; + if (o.Has("enabled")) + { + auto v = o.Get("enabled"); + if (v.IsBoolean()) + enabled = v.As().Value(); + else if (v.IsNumber()) + enabled = v.As().DoubleValue() != 0.0; + } + + liveEngine->setTonePolishEnabled(enabled); + return env.Undefined(); +} + +Napi::Value IsMonitorMuted(const Napi::CallbackInfo& info) +{ + auto liveEngine = snapshotEngine(); + return Napi::Boolean::New(info.Env(), liveEngine ? liveEngine->isMonitorMuted() : true); +} + +// ── Metering (polled — read atomics) ────────────────────────────────────────── + +Napi::Value GetLevels(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto obj = Napi::Object::New(env); + auto liveEngine = snapshotEngine(); + + if (liveEngine) + { + obj.Set("inputLevel", liveEngine->getInputLevel()); + obj.Set("outputLevel", liveEngine->getOutputLevel()); + obj.Set("inputPeak", liveEngine->getInputPeak()); + obj.Set("outputPeak", liveEngine->getOutputPeak()); + } + else + { + obj.Set("inputLevel", 0.0); + obj.Set("outputLevel", 0.0); + obj.Set("inputPeak", 0.0); + obj.Set("outputPeak", 0.0); + } + + return obj; +} + +// getSourceLevels(sourceId) -> { inputLevel, inputPeak, outputLevel, outputPeak }. +// Per-source INPUT level so a bound detector's silence gate reads ITS OWN device's +// signal (not the global/primary level — which would force-fail every hit on an +// extra device the user is actually playing). Output fields mirror the master and +// are 0 (monitoring is post-mix / engine-global). Bad id -> all zeros. +Napi::Value GetSourceLevels(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto obj = Napi::Object::New(env); + auto liveEngine = snapshotEngine(); + SourceChain* s = (liveEngine && info.Length() >= 1 && info[0].IsNumber()) + ? getValidatedSource(liveEngine.get(), info, 0) : nullptr; + obj.Set("inputLevel", s ? (double) s->getInputLevel() : 0.0); + obj.Set("inputPeak", s ? (double) s->getInputPeak() : 0.0); + obj.Set("outputLevel", 0.0); + obj.Set("outputPeak", 0.0); + return obj; +} + +Napi::Value ResetPeaks(const Napi::CallbackInfo& info) +{ + if (auto liveEngine = snapshotEngine()) liveEngine->resetPeaks(); + return info.Env().Undefined(); +} + +// Backing-track mix bus RMS level — the engine's per-block running RMS after +// the backing volume fader but before the output-gain master. Returns 0.0 when +// the engine is unavailable or no backing track is loaded. Reads an atomic so +// it is safe to call from the JS thread without blocking the audio thread. +Napi::Value GetBackingLevel(const Napi::CallbackInfo& info) +{ + auto liveEngine = snapshotEngine(); + return Napi::Number::New(info.Env(), liveEngine ? liveEngine->getBackingLevel() : 0.0f); +} + +// ── MIDI ────────────────────────────────────────────────────────────────────── + +Napi::Value SendMidiToSlot(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto liveEngine = snapshotEngine(); + if (!liveEngine || info.Length() < 4) + return Napi::Boolean::New(env, false); + + // Typed + range-checked: unclamped channel/program used to trip JUCE + // assertions (deep-read §2). Out-of-range now returns false cleanly. + const auto slotId = slopsmith::addon::argSlotId(info, 0); + const auto msgType = slopsmith::addon::argInt(info, 1, 0, 1); + const auto channel = slopsmith::addon::argMidiChannel(info, 2); + if (!slotId || !msgType || !channel) + return Napi::Boolean::New(env, false); + + juce::MidiMessage midiMsg; + if (*msgType == 0) // Program Change + { + const auto program = slopsmith::addon::argMidiByte(info, 3); + if (!program) return Napi::Boolean::New(env, false); + midiMsg = juce::MidiMessage::programChange(*channel, *program); + } + else // Control Change + { + const auto controller = slopsmith::addon::argMidiByte(info, 3); + if (!controller) return Napi::Boolean::New(env, false); + const auto value = slopsmith::addon::argMidiByte(info, 4); + midiMsg = juce::MidiMessage::controllerEvent(*channel, *controller, value.value_or(0)); + } + + liveEngine->getSignalChain().queueMidiMessage(*slotId, midiMsg); + return Napi::Boolean::New(env, true); +} + +// ── Debug file logging ──────────────────────────────────────────────────────── + +// Redirect the C runtime's stderr stream to a file so the native +// [AudioEngine] / [audio-native] diagnostics are captured for a bug report on +// machines with no console (packaged Windows builds). Only invoked when +// SLOPSMITH_DEBUG is set. Returns "" on success, or an error description the +// JS layer logs as an [audio] line. +// +// freopen (not dup2): a packaged GUI-subsystem app has no console, so stderr +// has no valid fd — dup2 onto fileno(stderr) fails. freopen reassigns the +// stream itself and works with or without a console. freopen would close +// stderr before trying the path, so a bad path is ruled out FIRST with a +// throwaway fopen probe (which never touches stderr); only once the path is +// known-writable do we freopen. Append mode so the JS layer's header +// survives; unbuffered so a crash leaves a complete tail. +Napi::Value EnableFileLogging(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + if (info.Length() < 1 || !info[0].IsString()) + { + Napi::TypeError::New(env, "enableFileLogging(path) requires a string") + .ThrowAsJavaScriptException(); + return env.Undefined(); + } + +#if defined(_WIN32) + // Widen UTF-16 → wchar_t by value-converting each code unit (not a + // reinterpret_cast — char16_t and wchar_t are distinct types even though + // both are 16-bit on Windows). Wide path so a profile dir with non-ASCII + // characters isn't mangled by the ANSI codepage (cf. src/vst-host/main.cpp, + // which uses the GetEnvironmentVariableW / _wfopen wide path for the same + // reason). + const std::u16string u16 = info[0].As().Utf16Value(); + const std::wstring wpath(u16.begin(), u16.end()); + FILE* probe = _wfopen(wpath.c_str(), L"a"); +#else + const std::string path = info[0].As().Utf8Value(); + FILE* probe = std::fopen(path.c_str(), "a"); +#endif + if (probe == nullptr) + { + // Capture errno before Napi::String::New / std::to_string, which may + // call library code that clobbers it. + const int e = errno; + return Napi::String::New(env, std::string("fopen failed (errno=") + + std::to_string(e) + ")"); + } + std::fclose(probe); // path is writable; stderr never touched on this path + +#if defined(_WIN32) + FILE* fp = _wfreopen(wpath.c_str(), L"a", stderr); +#else + FILE* fp = std::freopen(path.c_str(), "a", stderr); +#endif + if (fp == nullptr) + { + const int e = errno; + // freopen closes stderr before trying the path; on failure it's left + // closed. The probe just verified the path, so this is near-impossible + // — but redirect stderr to the null device so it's a valid sink rather + // than a closed stream that could trip later fprintf(stderr) calls. +#if defined(_WIN32) + std::freopen("NUL", "w", stderr); +#else + std::freopen("/dev/null", "w", stderr); +#endif + return Napi::String::New(env, std::string("freopen failed (errno=") + + std::to_string(e) + ")"); + } + + // Unbuffered: each [AudioEngine] fprintf hits disk immediately, so a + // crash mid-reconfigure still leaves the diagnostic line that explains it. + std::setvbuf(stderr, nullptr, _IONBF, 0); + std::fprintf(stderr, "[audio-native] file logging enabled\n"); + return Napi::String::New(env, ""); // empty = success +} + + +} // namespace slopsmith::addon diff --git a/src/audio/addon/DetectionBindings.cpp b/src/audio/addon/DetectionBindings.cpp new file mode 100644 index 0000000..e82aeb8 --- /dev/null +++ b/src/audio/addon/DetectionBindings.cpp @@ -0,0 +1,589 @@ +// Pitch detection + source-indexed bindings - moved verbatim from NodeAddon.cpp (TLC phase 7b +// binding split). Registered by NodeAddon's export table via Bindings.h. + +#include "Bindings.h" + +#include "AddonContext.h" +#include "NapiHelpers.h" +#include "ChainOps.h" +#include "../AudioEngine.h" +#include "../VSTHost.h" +#include "../VSTTrace.h" + +#include +#include +#include +#include + +namespace slopsmith::addon { + +// Validate a JS source-id argument and return the live source, or nullptr if it is +// missing / not a Number / not a FINITE INTEGER / out of range. The TS bridge already +// validates, but the addon must fail soft on its own: Int32Value() silently coerces +// NaN/Infinity into a valid index (NaN -> 0), which would let a malformed id hit a +// real source (e.g. the default source 0). getSource() does the final +// [0, kMaxSources) + active check; the 4096 guard keeps the cast well-defined. +SourceChain* getValidatedSource(AudioEngine* eng, const Napi::CallbackInfo& info, size_t argIndex) +{ + if (eng == nullptr || argIndex >= info.Length() || ! info[argIndex].IsNumber()) + return nullptr; + const double raw = info[argIndex].As().DoubleValue(); + if (! std::isfinite(raw) || raw != std::floor(raw) || raw < 0.0 || raw > 4096.0) + return nullptr; + return eng->getSource((int) raw); +} + +// ── Pitch Detection (polled) ────────────────────────────────────────────────── + +// Load the Basic Pitch ONNX model for the polyphonic ML note detector. +// Called once at startup by audio-bridge.ts with the bundled model path. +// Never throws. Returns "is ML note detection available after this call" — +// a model is loaded with a valid contract. A missing/invalid file does NOT +// tear down an already-loaded model, so it can still return true; it returns +// false when the engine isn't ready or ONNX support isn't compiled in, and +// the engine then keeps using the YIN PitchDetector / ChordScorer +// (Constitution VII). +Napi::Value LoadNoteModel(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto liveEngine = snapshotEngine(); + if (!liveEngine || info.Length() < 1 || !info[0].IsString()) + return Napi::Boolean::New(env, false); + + const auto path = info[0].As().Utf8Value(); + const bool ok = liveEngine->loadNoteModel(juce::File(juce::String(path))); + return Napi::Boolean::New(env, ok); +} + +// Whether the ML note detector is active (ONNX support compiled in AND a +// model loaded). Lets the renderer / tests tell the ML path from the YIN +// fallback without inferring it from behaviour. +Napi::Value IsMlNoteDetection(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + // Report readiness, not just model-loaded: the engine only routes + // getPitchDetection()/scoreChord() to ML once the detector has published + // its first snapshot (isReady()). Reporting true during the cold-start + // window would tell the renderer "ML active" while it's still getting the + // YIN fallback. + auto liveEngine = snapshotEngine(); + return Napi::Boolean::New(env, + liveEngine && liveEngine->hasMlNoteDetector() + && liveEngine->getMlNoteDetector().isReady()); +} + +// Raw polyphonic transcription from the ML note detector — the full set of +// currently-active pitches, not just the dominant one. Returns +// `{ notes: [{ midi, confidence, onsetMs, onsetSeq }], sampleRate }`, or null when the ML +// detector isn't active (no model / ONNX support) so the renderer can feature- +// detect and fall back. Never throws. +Napi::Value DetectNotes(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + // Gate on isReady(): the contract is that callers get null whenever the + // ML detector isn't actively producing notes. isReady() is false with no + // model, after a device stop, and during the cold-start window before the + // first inference publishes — so the renderer feature-detects correctly + // and falls back instead of consuming an empty ML stream. + auto liveEngine = snapshotEngine(); + if (!liveEngine || !liveEngine->getMlNoteDetector().isReady()) + return env.Null(); + + const auto active = liveEngine->getMlNoteDetector().getActiveNotes(); + auto notesArr = Napi::Array::New(env, active.size()); + for (size_t i = 0; i < active.size(); ++i) + { + auto entry = Napi::Object::New(env); + entry.Set("midi", active[i].midi); + entry.Set("confidence", active[i].confidence); + // Milliseconds since this pitch's onset — lets the renderer back-date + // a detection to the true onset instead of poll time. + entry.Set("onsetMs", active[i].onsetAgeMs); + // Monotonic per-pitch onset counter — a change means a new note was + // struck, so the renderer can consume onsets as discrete events. + entry.Set("onsetSeq", active[i].onsetSeq); + notesArr.Set((uint32_t) i, entry); + } + + auto obj = Napi::Object::New(env); + obj.Set("notes", notesArr); + // Normalise the sample rate: getCurrentSampleRate() is 0 when no audio + // device is active — hand the renderer a sane positive value so its + // Hz/time math can't divide by zero. + const double sr = liveEngine->getCurrentSampleRate(); + obj.Set("sampleRate", sr > 0.0 ? sr : 48000.0); + return obj; +} + +Napi::Value GetPitchDetection(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto obj = Napi::Object::New(env); + auto liveEngine = snapshotEngine(); + + if (liveEngine) + { + // getActiveDetection() returns the polyphonic ML detector's dominant + // pitch when a Basic Pitch model is loaded, else the YIN detector's + // latest result — same shape either way, so the plugin is unchanged. + auto det = liveEngine->getActiveDetection(); + obj.Set("frequency", det.frequency); + obj.Set("confidence", det.confidence); + obj.Set("midiNote", det.midiNote); + obj.Set("cents", det.cents); + obj.Set("noteName", det.noteName.toStdString()); + } + else + { + obj.Set("frequency", -1.0); + obj.Set("confidence", 0.0); + obj.Set("midiNote", -1); + obj.Set("cents", 0.0); + obj.Set("noteName", ""); + } + + return obj; +} + +Napi::Value GetRawPitchDetection(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto obj = Napi::Object::New(env); + auto liveEngine = snapshotEngine(); + + if (liveEngine) + { + // Always the raw YIN detection — bypasses the ML preference so frequency + // stays continuous (sub-Hz) and cents stays real even with a model loaded. + // Backs the tuner's audio:getRawPitch endpoint. + auto det = liveEngine->getRawPitchDetection(); + obj.Set("frequency", det.frequency); + obj.Set("confidence", det.confidence); + obj.Set("midiNote", det.midiNote); + obj.Set("cents", det.cents); + obj.Set("noteName", det.noteName.toStdString()); + } + else + { + obj.Set("frequency", -1.0); + obj.Set("confidence", 0.0); + obj.Set("midiNote", -1); + obj.Set("cents", 0.0); + obj.Set("noteName", ""); + } + + return obj; +} + +Napi::Value GetRawAudioFrame(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto liveEngine = snapshotEngine(); + + // Optional sample count; defaults to AudioEngine::getRawAudioFrame's 4096. + // The engine clamps anything above its ring capacity. + int numSamples = 4096; + if (info.Length() > 0 && info[0].IsNumber()) + numSamples = info[0].As().Int32Value(); + + if (!liveEngine || numSamples <= 0) + return Napi::Float32Array::New(env, 0); + + // Post-gate mono snapshot for the tuner's own pitch pipeline. Returns a + // Float32Array of the most-recent N samples (left-zero-padded on cold start). + auto frame = liveEngine->getRawAudioFrame(numSamples); + auto out = Napi::Float32Array::New(env, frame.size()); + float* dst = out.Data(); + for (size_t i = 0; i < frame.size(); ++i) + dst[i] = frame[i]; + return out; +} + +// Score a polyphonic chord against the engine's most recent input +// samples. Renderer (notedetect plugin's matchNotes chord branch) +// supplies the chord context — chart notes plus tuning/arrangement +// metadata — and gets back a `{score, hitStrings, totalStrings, isHit, +// results[]}` object identical in shape to what the JS implementation +// produced. Audio never crosses the N-API boundary, which is the +// whole reason for moving the math here: constitution II says audio +// analysis lives in JUCE, and this is the missing piece. +// +// Request shape. Fields marked `required` must be present and +// internally consistent — the C++ scorer fails closed (all-miss +// result with one entry per requested note) when the validation +// invariants don't hold, rather than silently substituting defaults. +// { +// notes: [{ s, f, ho?, po?, b?, sl?, hm? }, ...], +// // required, each `s` must be in [0, stringCount) +// arrangement?: 'guitar'|'bass', // default 'guitar' — must be one of these two strings +// stringCount?: number, // default 6 — must match the (arrangement, stringCount) +// // table: bass{4,5} or guitar{6,7,8} +// offsets: number[], // required, length must equal stringCount. +// // Pass an array of zeros for standard tuning; +// // the default of `stringCount = 6` only works +// // if you supply 6 offsets. +// numSamples?: number, // analysis window (default 4096, capped at the +// // engine input-ring capacity, currently 8192) +// capo?: number, // default 0 +// pitchCheckCents?: number, // 0 = energy-only chord check (default 0) +// minHitRatio?: number, // default 0.6 +// bypassMl?: boolean, // force the DSP band-energy scorer even +// // when an ML model is loaded (default false) +// harmonicVerify?: boolean, // score each note by harmonic-comb energy +// // (f,2f..5f vs the floor between) instead +// // of band-energy/total (default false) +// harmonicSnr?: number, // min harmonic-to-floor ratio for a hit +// // when harmonicVerify is set (default 3.0) +// fundamentalRatio?: number, // fundamental-presence gate: reject when +// // f0 peak < ratio*strongest partial; lower +// // for bass, <=0 disables (default 0.20) +// } +// Shared core: parse `reqObj` into a ChordScorer::Request and score it against +// `target`'s input ring. `target` is sources[0] for the legacy scoreChord and +// getSource(id) for the source-indexed scoreSourceChord. +Napi::Value scoreChordCore(Napi::Env env, Napi::Object reqObj, SourceChain* target) +{ + // Hard caps on caller-controlled array lengths. The scorer's + // (arrangement, stringCount) validation only accepts up to 8 + // strings; chord-notes have a natural ceiling at the same value + // (one per string). 32 is a generous headroom that still bounds + // worst-case allocations the renderer could trigger over IPC — + // without these limits, a malformed/malicious payload claiming a + // gigantic JS array length would force a multi-GB reserve before + // the scorer's own validation rejected the request. A request + // that exceeds either cap is treated as outright malformed and + // returns the "no chord requested" failure shape (totalStrings=0); + // every other validation failure goes through the all-miss path + // below so results[] stays in lockstep with notes[]. + static constexpr uint32_t kMaxOffsets = 32; + static constexpr uint32_t kMaxNotes = 32; + + auto noRequestFailure = [&env]() { + auto failure = Napi::Object::New(env); + failure.Set("score", 0.0); + failure.Set("hitStrings", 0); + failure.Set("totalStrings", 0); + failure.Set("isHit", false); + failure.Set("results", Napi::Array::New(env, 0)); + return failure; + }; + + // Capture the notes array up front so every downstream failure + // path can build a per-note all-miss result aligned 1:1 with the + // caller's notes[]. Pre-cap check happens before we even read the + // length into the helper to prevent a payload claiming an enormous + // length from forcing the helper to allocate a huge results array. + Napi::Value notesVal = reqObj.Has("notes") ? reqObj.Get("notes") : env.Null(); + if (!notesVal.IsArray()) return noRequestFailure(); + auto notesArr = notesVal.As(); + if (notesArr.Length() > kMaxNotes) return noRequestFailure(); + const uint32_t noteCount = notesArr.Length(); + + // All-miss result aligned with the caller's notes[]. Walks the + // original JS array so the per-note `s` / `f` echo back in the + // result even when the request fails validation (lets the renderer + // distinguish "this string missed" from "this string wasn't sent"). + // Used by every failure path below except the cap/no-notes case + // above, which doesn't have a coherent notes[] to mirror. + auto buildAllMiss = [&]() { + auto resultsArr = Napi::Array::New(env, noteCount); + for (uint32_t i = 0; i < noteCount; ++i) + { + int s = -1, f = -1; + auto v = notesArr.Get(i); + if (v.IsObject()) + { + auto o = v.As(); + if (o.Has("s") && o.Get("s").IsNumber()) + s = o.Get("s").As().Int32Value(); + if (o.Has("f") && o.Get("f").IsNumber()) + f = o.Get("f").As().Int32Value(); + } + auto entry = Napi::Object::New(env); + entry.Set("s", s); + entry.Set("f", f); + entry.Set("hit", false); + entry.Set("bandEnergy", 0.0); + entry.Set("centsDiff", env.Null()); + entry.Set("centsError", env.Null()); + resultsArr.Set(i, entry); + } + auto out = Napi::Object::New(env); + out.Set("score", 0.0); + out.Set("hitStrings", 0); + out.Set("totalStrings", (int) noteCount); + out.Set("isHit", false); + out.Set("results", resultsArr); + return out; + }; + + ChordScorer::Request req; + if (reqObj.Has("numSamples") && reqObj.Get("numSamples").IsNumber()) + req.numSamples = reqObj.Get("numSamples").As().Int32Value(); + if (reqObj.Has("arrangement") && reqObj.Get("arrangement").IsString()) + req.arrangement = reqObj.Get("arrangement").As().Utf8Value(); + if (reqObj.Has("stringCount") && reqObj.Get("stringCount").IsNumber()) + req.stringCount = reqObj.Get("stringCount").As().Int32Value(); + if (reqObj.Has("capo") && reqObj.Get("capo").IsNumber()) + req.capo = reqObj.Get("capo").As().Int32Value(); + if (reqObj.Has("pitchCheckCents") && reqObj.Get("pitchCheckCents").IsNumber()) + req.pitchCheckCents = reqObj.Get("pitchCheckCents").As().FloatValue(); + if (reqObj.Has("minHitRatio") && reqObj.Get("minHitRatio").IsNumber()) + req.minHitRatio = reqObj.Get("minHitRatio").As().FloatValue(); + if (reqObj.Has("bypassMl") && reqObj.Get("bypassMl").IsBoolean()) + req.bypassMl = reqObj.Get("bypassMl").As().Value(); + if (reqObj.Has("harmonicVerify") && reqObj.Get("harmonicVerify").IsBoolean()) + req.harmonicVerify = reqObj.Get("harmonicVerify").As().Value(); + if (reqObj.Has("harmonicSnr") && reqObj.Get("harmonicSnr").IsNumber()) + req.harmonicSnr = reqObj.Get("harmonicSnr").As().FloatValue(); + if (reqObj.Has("fundamentalRatio") && reqObj.Get("fundamentalRatio").IsNumber()) + { + // Drop NaN/Inf: a non-finite ratio poisons the fundamental-presence + // gate (fundMag >= NaN is always false -> every note false-rejected). + // Keep the safe 0.20 default instead. + const float v = reqObj.Get("fundamentalRatio").As().FloatValue(); + if (std::isfinite(v)) req.fundamentalRatio = v; + } + + if (reqObj.Has("offsets") && reqObj.Get("offsets").IsArray()) + { + auto arr = reqObj.Get("offsets").As(); + if (arr.Length() > kMaxOffsets) return noRequestFailure(); + req.tuningOffsets.reserve(arr.Length()); + for (uint32_t i = 0; i < arr.Length(); ++i) + { + auto v = arr.Get(i); + // Tuning offsets materially shift expected pitch — silently + // substituting 0 for a missing/non-numeric entry would + // produce confidently wrong scores. Fail closed with the + // per-note all-miss shape so the renderer sees the right + // results[] length even when the request is malformed. + if (!v.IsNumber()) return buildAllMiss(); + req.tuningOffsets.push_back(v.As().Int32Value()); + } + } + + req.notes.reserve(noteCount); + for (uint32_t i = 0; i < noteCount; ++i) + { + auto v = notesArr.Get(i); + // For malformed entries (non-object, or missing/non-numeric + // s/f) push a sentinel Note with string = -1. This keeps + // req.notes.size() in lockstep with the incoming notes[] + // length AND guarantees ChordScorer's range check + // (`n.string < 0 || n.string >= stringCount`) trips on the + // sentinel — yielding the same all-miss fail-closed result + // the shape contract advertises, never a false hit on the + // default low-string position. + ChordScorer::Note n{}; + n.string = -1; + n.fret = -1; + if (!v.IsObject()) + { + req.notes.push_back(n); + continue; + } + auto noteObj = v.As(); + const bool hasS = noteObj.Has("s") && noteObj.Get("s").IsNumber(); + const bool hasF = noteObj.Has("f") && noteObj.Get("f").IsNumber(); + if (!hasS || !hasF) + { + req.notes.push_back(n); + continue; + } + n.string = noteObj.Get("s").As().Int32Value(); + n.fret = noteObj.Get("f").As().Int32Value(); + // Technique flags are truthy/falsy in JS; coerce to bool + // here so an unset value cleanly becomes false. + auto truthy = [¬eObj](const char* key) { + if (!noteObj.Has(key)) return false; + auto val = noteObj.Get(key); + return val.ToBoolean().Value(); + }; + n.hammerOn = truthy("ho"); + n.pullOff = truthy("po"); + n.bend = truthy("b"); + n.slide = truthy("sl"); + n.harmonic = truthy("hm"); + req.notes.push_back(n); + } + + auto result = target->scoreChord(req); + + auto out = Napi::Object::New(env); + out.Set("score", result.score); + out.Set("hitStrings", result.hitStrings); + out.Set("totalStrings", result.totalStrings); + out.Set("isHit", result.isHit); + auto resultsArr = Napi::Array::New(env, result.results.size()); + for (size_t i = 0; i < result.results.size(); ++i) + { + const auto& r = result.results[i]; + auto entry = Napi::Object::New(env); + entry.Set("s", r.string); + entry.Set("f", r.fret); + entry.Set("hit", r.hit); + entry.Set("bandEnergy", r.bandEnergy); + // Mirror the JS result shape: when cents weren't measured the + // fields are present-but-null so the renderer can distinguish + // "no pitch check ran" (null) from "pitch check said 0" + // (numeric 0). + if (r.hasCents) + { + entry.Set("centsDiff", r.centsDiff); + entry.Set("centsError", r.centsError); + } + else + { + entry.Set("centsDiff", env.Null()); + entry.Set("centsError", env.Null()); + } + resultsArr.Set(i, entry); + } + out.Set("results", resultsArr); + return out; +} + +// Legacy: scoreChord(req) — targets sources[0]. Backward-compatible. +Napi::Value ScoreChord(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto liveEngine = snapshotEngine(); + auto noRequestFailure = [&env]() { + auto failure = Napi::Object::New(env); + failure.Set("score", 0.0); + failure.Set("hitStrings", 0); + failure.Set("totalStrings", 0); + failure.Set("isHit", false); + failure.Set("results", Napi::Array::New(env, 0)); + return failure; + }; + if (!liveEngine || info.Length() < 1 || !info[0].IsObject()) + return noRequestFailure(); + return scoreChordCore(env, info[0].As(), liveEngine->getSource(0)); +} + +// Source-indexed: scoreSourceChord(sourceId, req). Bad id / payload -> the +// same "no chord requested" failure shape (totalStrings=0). +Napi::Value ScoreSourceChord(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto liveEngine = snapshotEngine(); + auto noRequestFailure = [&env]() { + auto failure = Napi::Object::New(env); + failure.Set("score", 0.0); + failure.Set("hitStrings", 0); + failure.Set("totalStrings", 0); + failure.Set("isHit", false); + failure.Set("results", Napi::Array::New(env, 0)); + return failure; + }; + if (!liveEngine || info.Length() < 2 || !info[0].IsNumber() || !info[1].IsObject()) + return noRequestFailure(); + SourceChain* target = getValidatedSource(liveEngine.get(), info, 0); + if (!target) return noRequestFailure(); + return scoreChordCore(env, info[1].As(), target); +} + +// ── Multi-input source management bridge ───────────────────────────────────── +// A source is one independent input chain (own arrangement chart, detection, +// scoring, tone, monitor). sources[0] always exists. The renderer adds a source +// per extra player, binds it to an input channel, and drives its scoring via the +// *Source* methods below; the legacy un-suffixed methods keep targeting source 0. + +// addSource(inputChannel?) -> sourceId (number), or -1 if the pool is full. +Napi::Value AddSource(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto liveEngine = snapshotEngine(); + if (!liveEngine) return Napi::Number::New(env, -1); + int channel = -1; // default: mono mix of the first pair + if (info.Length() > 0 && info[0].IsNumber()) + channel = info[0].As().Int32Value(); + int deviceKey = 0; // default: primary input device + if (info.Length() > 1 && info[1].IsNumber()) + { + const int k = info[1].As().Int32Value(); + if (k >= 0) deviceKey = k; // negatives ignored → primary + } + return Napi::Number::New(env, liveEngine->addSource(channel, deviceKey)); +} + +// removeSource(sourceId) -> boolean. sources[0] cannot be removed. +Napi::Value RemoveSource(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto liveEngine = snapshotEngine(); + if (!liveEngine || info.Length() < 1 || !info[0].IsNumber()) + return Napi::Boolean::New(env, false); + return Napi::Boolean::New(env, liveEngine->removeSource(info[0].As().Int32Value())); +} + +// listSources() -> [{ id, inputChannel, active }]. Null on a missing engine. +Napi::Value ListSources(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto liveEngine = snapshotEngine(); + if (!liveEngine) return env.Null(); + const auto sources = liveEngine->listSources(); + auto arr = Napi::Array::New(env, sources.size()); + for (size_t i = 0; i < sources.size(); ++i) + { + auto entry = Napi::Object::New(env); + entry.Set("id", sources[i].id); + entry.Set("inputChannel", sources[i].inputChannel); + entry.Set("deviceKey", sources[i].deviceKey); + entry.Set("active", sources[i].active); + arr.Set((uint32_t) i, entry); + } + return arr; +} + +// listInputDevices() -> [{ typeName, name }]. Every available capture device the +// renderer can bind to an additional engine input via bindInputDevice. Null on a +// missing engine. +Napi::Value ListInputDevices(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto liveEngine = snapshotEngine(); + if (!liveEngine) return env.Null(); + const auto devices = liveEngine->getBindableInputDevices(); + auto arr = Napi::Array::New(env); + uint32_t n = 0; + for (const auto& d : devices) + { + auto entry = Napi::Object::New(env); + entry.Set("typeName", d.typeName.toStdString()); + entry.Set("name", d.name.toStdString()); + arr.Set(n++, entry); + } + return arr; +} + +// bindInputDevice(deviceKey, deviceName) -> "" on success, else an error string. +// Opens an ADDITIONAL physical input device (deviceKey 1..N) so sources created +// with addSource(channel, deviceKey) capture from it at its own clock. +Napi::Value BindInputDevice(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto liveEngine = snapshotEngine(); + if (!liveEngine) return Napi::String::New(env, "no engine"); + if (info.Length() < 2 || !info[0].IsNumber() || !info[1].IsString()) + return Napi::String::New(env, "bindInputDevice(deviceKey:number, deviceName:string)"); + const int deviceKey = info[0].As().Int32Value(); + const std::string name = info[1].As().Utf8Value(); + return Napi::String::New(env, liveEngine->bindInputDevice(deviceKey, name).toStdString()); +} + +// unbindInputDevice(deviceKey) -> boolean. Stops + releases the extra device. +Napi::Value UnbindInputDevice(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto liveEngine = snapshotEngine(); + if (!liveEngine || info.Length() < 1 || !info[0].IsNumber()) + return Napi::Boolean::New(env, false); + return Napi::Boolean::New(env, liveEngine->unbindInputDevice(info[0].As().Int32Value())); +} + + +} // namespace slopsmith::addon diff --git a/src/audio/addon/DeviceBindings.cpp b/src/audio/addon/DeviceBindings.cpp new file mode 100644 index 0000000..0f2fb78 --- /dev/null +++ b/src/audio/addon/DeviceBindings.cpp @@ -0,0 +1,834 @@ +// Device enumeration/selection/control + stream sink bindings - moved verbatim from NodeAddon.cpp (TLC phase 7b +// binding split). Registered by NodeAddon's export table via Bindings.h. + +#include "Bindings.h" + +#include "AddonContext.h" +#include "NapiHelpers.h" +#include "ChainOps.h" +#include "../AudioEngine.h" +#include "../VSTHost.h" +#include "../VSTTrace.h" + +#include +#include +#include +#include + +namespace slopsmith::addon { + +// ── Device Enumeration ──────────────────────────────────────────────────────── + +Napi::Value GetDeviceTypes(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto liveEngine = snapshotEngine(); + if (!liveEngine) return env.Null(); + + // Device types are already scanned during init — safe to read from any thread + auto types = liveEngine->getDeviceTypes(); + + auto result = Napi::Array::New(env, types.size()); + + for (int i = 0; i < types.size(); ++i) + { + auto obj = Napi::Object::New(env); + obj.Set("name", types[i].name.toStdString()); + + auto inputs = Napi::Array::New(env, types[i].inputDevices.size()); + for (int j = 0; j < types[i].inputDevices.size(); ++j) + inputs.Set((uint32_t)j, types[i].inputDevices[j].toStdString()); + obj.Set("inputs", inputs); + + auto outputs = Napi::Array::New(env, types[i].outputDevices.size()); + for (int j = 0; j < types[i].outputDevices.size(); ++j) + outputs.Set((uint32_t)j, types[i].outputDevices[j].toStdString()); + obj.Set("outputs", outputs); + + result.Set((uint32_t)i, obj); + } + + return result; +} + +Napi::Value GetSampleRates(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto liveEngine = snapshotEngine(); + if (!liveEngine) return Napi::Array::New(env); + + auto rates = liveEngine->getSampleRates(); + auto result = Napi::Array::New(env, rates.size()); + for (int i = 0; i < rates.size(); ++i) + result.Set((uint32_t)i, rates[i]); + return result; +} + +Napi::Value GetBufferSizes(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto liveEngine = snapshotEngine(); + if (!liveEngine) return Napi::Array::New(env); + + auto sizes = liveEngine->getBufferSizes(); + auto result = Napi::Array::New(env, sizes.size()); + for (int i = 0; i < sizes.size(); ++i) + result.Set((uint32_t)i, sizes[i]); + return result; +} + +Napi::Value ProbeDeviceOptions(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto liveEngine = snapshotEngine(); + auto obj = Napi::Object::New(env); + // 3-arg legacy (type, input, output) or 4-arg dual (inputType, input, outputType, output). + auto arg0 = info.Length() > 0 && info[0].IsString() ? info[0].As().Utf8Value() : ""; + auto arg1 = info.Length() > 1 && info[1].IsString() ? info[1].As().Utf8Value() : ""; + auto arg2 = info.Length() > 2 && info[2].IsString() ? info[2].As().Utf8Value() : ""; + auto arg3 = info.Length() > 3 && info[3].IsString() ? info[3].As().Utf8Value() : ""; + + std::string inputType = arg0; + std::string inputName = arg1; + std::string outputType; + std::string outputName; + if (info.Length() >= 4) + { + outputType = arg2; + outputName = arg3; + } + else + { + outputType = arg0; + outputName = arg2; + } + + auto ratesArray = Napi::Array::New(env); + auto buffersArray = Napi::Array::New(env); + auto inputChannelsArray = Napi::Array::New(env); + auto outputChannelsArray = Napi::Array::New(env); + + obj.Set("type", inputType); + obj.Set("inputType", inputType); + obj.Set("outputType", outputType); + obj.Set("input", inputName); + obj.Set("output", outputName); + obj.Set("inputChannels", inputChannelsArray); + obj.Set("outputChannels", outputChannelsArray); + obj.Set("sampleRates", ratesArray); + obj.Set("bufferSizes", buffersArray); + obj.Set("compatible", true); + if (!liveEngine) + { + obj.Set("error", "Audio engine not initialized"); + obj.Set("compatible", false); + return obj; + } + + auto options = liveEngine->probeDeviceOptionsDual( + juce::String(inputType), juce::String(inputName), + juce::String(outputType), juce::String(outputName)); + obj.Set("type", options.inputType.toStdString()); // legacy alias + obj.Set("inputType", options.inputType.toStdString()); + obj.Set("outputType", options.outputType.toStdString()); + obj.Set("input", options.input.toStdString()); + obj.Set("output", options.output.toStdString()); + obj.Set("error", options.error.toStdString()); + obj.Set("compatible", options.compatible); + + inputChannelsArray = Napi::Array::New(env, options.inputChannels.size()); + for (int i = 0; i < options.inputChannels.size(); ++i) + inputChannelsArray.Set((uint32_t)i, options.inputChannels[i].toStdString()); + obj.Set("inputChannels", inputChannelsArray); + + outputChannelsArray = Napi::Array::New(env, options.outputChannels.size()); + for (int i = 0; i < options.outputChannels.size(); ++i) + outputChannelsArray.Set((uint32_t)i, options.outputChannels[i].toStdString()); + obj.Set("outputChannels", outputChannelsArray); + + ratesArray = Napi::Array::New(env, options.sampleRates.size()); + for (int i = 0; i < options.sampleRates.size(); ++i) + ratesArray.Set((uint32_t)i, options.sampleRates[i]); + obj.Set("sampleRates", ratesArray); + + buffersArray = Napi::Array::New(env, options.bufferSizes.size()); + for (int i = 0; i < options.bufferSizes.size(); ++i) + buffersArray.Set((uint32_t)i, options.bufferSizes[i]); + obj.Set("bufferSizes", buffersArray); + + return obj; +} + +Napi::Value GetCurrentDevice(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto liveEngine = snapshotEngine(); + if (!liveEngine) return env.Null(); + + auto obj = Napi::Object::New(env); + const auto inputType = liveEngine->getCurrentInputDeviceType().toStdString(); + const auto outputType = liveEngine->getCurrentOutputDeviceType().toStdString(); + obj.Set("type", inputType); + obj.Set("inputType", inputType); + obj.Set("outputType", outputType); + obj.Set("input", liveEngine->getCurrentInputDevice().toStdString()); + obj.Set("output", liveEngine->getCurrentOutputDevice().toStdString()); + obj.Set("sampleRate", liveEngine->getCurrentSampleRate()); + obj.Set("blockSize", liveEngine->getCurrentBlockSize()); + obj.Set("inputBlockSize", liveEngine->getCurrentInputBlockSize()); + obj.Set("outputBlockSize", liveEngine->getCurrentOutputBlockSize()); + obj.Set("latencyMs", liveEngine->getLatencyMs()); + obj.Set("duplex", liveEngine->isDuplex()); + return obj; +} + +Napi::Value GetDeviceMetrics(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto liveEngine = snapshotEngine(); + auto obj = Napi::Object::New(env); + if (!liveEngine) + { + obj.Set("duplex", true); + obj.Set("inputOverflowCount", 0.0); + obj.Set("outputUnderflowCount", 0.0); + obj.Set("outputRingFillFrames", 0); + obj.Set("outputRingCapacityFrames", 0); + return obj; + } + const auto m = liveEngine->getDeviceMetrics(); + obj.Set("duplex", m.duplex); + obj.Set("inputOverflowCount", static_cast(m.inputOverflowCount)); + obj.Set("outputUnderflowCount", static_cast(m.outputUnderflowCount)); + obj.Set("outputRingFillFrames", m.outputRingFillFrames); + obj.Set("outputRingCapacityFrames", m.outputRingCapacityFrames); + return obj; +} + +// ── Device Selection ────────────────────────────────────────────────────────── + +Napi::Value SetDeviceType(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto liveEngine = snapshotEngine(); + if (!liveEngine || info.Length() < 1 || !info[0].IsString()) + return Napi::Boolean::New(env, false); + + auto typeName = info[0].As().Utf8Value(); + bool result = liveEngine->setDeviceType(juce::String(typeName)); + return Napi::Boolean::New(env, result); +} + +Napi::Value SetOutputDeviceType(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto liveEngine = snapshotEngine(); + if (!liveEngine || info.Length() < 1 || !info[0].IsString()) + return Napi::Boolean::New(env, false); + auto typeName = info[0].As().Utf8Value(); + return Napi::Boolean::New(env, liveEngine->setOutputDeviceType(juce::String(typeName))); +} + +Napi::Value SetDevice(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto liveEngine = snapshotEngine(); + auto result = Napi::Object::New(env); + result.Set("ok", false); + result.Set("duplex", true); + result.Set("sampleRate", 0.0); + result.Set("inputBlockSize", 0); + result.Set("outputBlockSize", 0); + result.Set("error", ""); + if (!liveEngine) + { + result.Set("error", "Audio engine not initialized"); + return result; + } + + // Object payload: setDevice({inputType, inputDevice, outputType, outputDevice, sampleRate, bufferSize}) + // Legacy positional: setDevice(input, output, sampleRate, bufferSize) + AudioEngine::DeviceConfig cfg; + if (info.Length() > 0 && info[0].IsObject() && !info[0].IsNull() && !info[0].IsArray()) + { + auto obj = info[0].As(); + auto readStr = [&](const char* key) -> std::string { + if (obj.Has(key) && obj.Get(key).IsString()) return obj.Get(key).As().Utf8Value(); + return {}; + }; + // Reject NaN/Infinity at the JS→C boundary so they can't poison + // downstream comparisons (NaN <= 0 is false, so the validation + // fallback in setAudioDevices() wouldn't catch them). Casting a + // non-finite double to int is also UB in C++. + auto readNum = [&](const char* key, double def) -> double { + if (obj.Has(key) && obj.Get(key).IsNumber()) + { + const double v = obj.Get(key).As().DoubleValue(); + if (std::isfinite(v)) return v; + } + return def; + }; + cfg.inputType = juce::String(readStr("inputType")); + cfg.inputDevice = juce::String(readStr("inputDevice")); + if (cfg.inputDevice.isEmpty()) cfg.inputDevice = juce::String(readStr("input")); + cfg.outputType = juce::String(readStr("outputType")); + cfg.outputDevice = juce::String(readStr("outputDevice")); + if (cfg.outputDevice.isEmpty()) cfg.outputDevice = juce::String(readStr("output")); + cfg.sampleRate = readNum("sampleRate", 48000.0); + // Clamp before the double→int cast: finite-but-out-of-range values + // (e.g. a JS-side bug passing 1e18) are UB to convert to int. readNum + // already filtered non-finite; we just need a range check here. + { + const double bsd = readNum("bufferSize", 256.0); + if (bsd >= 1.0 && bsd <= (double) (std::numeric_limits::max) ()) + cfg.bufferSize = (int) bsd; + else + cfg.bufferSize = 256; + } + } + else + { + auto input = info.Length() > 0 && info[0].IsString() ? info[0].As().Utf8Value() : ""; + auto output = info.Length() > 1 && info[1].IsString() ? info[1].As().Utf8Value() : ""; + double sr = info.Length() > 2 && info[2].IsNumber() ? info[2].As().DoubleValue() : 48000.0; + int bs = info.Length() > 3 && info[3].IsNumber() ? info[3].As().Int32Value() : 256; + cfg.inputDevice = juce::String(input); + cfg.outputDevice = juce::String(output); + cfg.sampleRate = sr; + cfg.bufferSize = bs; + } + + // Main thread only — JUCE's ALSA backend deadlocks if called from a worker. + const auto r = liveEngine->setAudioDevices(cfg); + result.Set("ok", r.ok); + result.Set("duplex", r.duplex); + result.Set("sampleRate", r.sampleRate); + result.Set("inputBlockSize", r.inputBlockSize); + result.Set("outputBlockSize", r.outputBlockSize); + result.Set("error", r.error.toStdString()); + return result; +} + +// ── Audio Control ───────────────────────────────────────────────────────────── + +Napi::Value StartAudio(const Napi::CallbackInfo& info) +{ + if (auto liveEngine = snapshotEngine()) liveEngine->startAudio(); + return info.Env().Undefined(); +} + +Napi::Value StopAudio(const Napi::CallbackInfo& info) +{ + if (auto liveEngine = snapshotEngine()) liveEngine->stopAudio(); + return info.Env().Undefined(); +} + +Napi::Value IsAudioRunning(const Napi::CallbackInfo& info) +{ + auto liveEngine = snapshotEngine(); + return Napi::Boolean::New(info.Env(), liveEngine ? liveEngine->isAudioRunning() : false); +} + +// ── Streamer mix output (PR1) ─────────────────────────────────────────────── +// setStreamOutputDevice(typeName, deviceName) -> "" on success, else an error. +Napi::Value SetStreamOutputDevice(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto liveEngine = snapshotEngine(); + if (!liveEngine) return Napi::String::New(env, "no engine"); + if (info.Length() < 2 || !info[0].IsString() || !info[1].IsString()) + return Napi::String::New(env, "setStreamOutputDevice(typeName:string, deviceName:string)"); + const std::string typeName = info[0].As().Utf8Value(); + const std::string devName = info[1].As().Utf8Value(); + return Napi::String::New(env, + liveEngine->setStreamOutputDevice(juce::String(typeName), juce::String(devName)).toStdString()); +} + +// clearStreamOutput() -> undefined +Napi::Value ClearStreamOutput(const Napi::CallbackInfo& info) +{ + auto liveEngine = snapshotEngine(); + if (liveEngine) liveEngine->clearStreamOutput(); + return info.Env().Undefined(); +} + +// setStreamBus(includeBacking:boolean, includeGuitar:boolean, gain:number) +Napi::Value SetStreamBus(const Napi::CallbackInfo& info) +{ + auto liveEngine = snapshotEngine(); + if (liveEngine && info.Length() >= 3 && info[0].IsBoolean() && info[1].IsBoolean() && info[2].IsNumber()) + liveEngine->setStreamBus(info[0].As().Value(), + info[1].As().Value(), + (float) info[2].As().DoubleValue()); + return info.Env().Undefined(); +} + +// setStreamBusGain(gain:number) +Napi::Value SetStreamBusGain(const Napi::CallbackInfo& info) +{ + auto liveEngine = snapshotEngine(); + if (liveEngine && info.Length() >= 1 && info[0].IsNumber()) + liveEngine->setStreamBusGain((float) info[0].As().DoubleValue()); + return info.Env().Undefined(); +} + +// setRendererBus(enabled:boolean, gain:number) +Napi::Value SetRendererBus(const Napi::CallbackInfo& info) +{ + auto liveEngine = snapshotEngine(); + if (liveEngine && info.Length() >= 2 && info[0].IsBoolean() && info[1].IsNumber()) + liveEngine->setRendererBus(info[0].As().Value(), + (float) info[1].As().DoubleValue()); + return info.Env().Undefined(); +} + +// pushRendererAudio(interleavedLR:Float32Array, sourceRate:number) -> boolean +// Interleaved stereo (L0 R0 L1 R1 …); sourceRate is the renderer's +// AudioContext sample rate. Returns false when the bus is off / engine down / +// malformed args, so the renderer can stop pushing. +Napi::Value PushRendererAudio(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto liveEngine = snapshotEngine(); + if (!liveEngine || info.Length() < 2 || !info[0].IsTypedArray() || !info[1].IsNumber()) + return Napi::Boolean::New(env, false); + auto ta = info[0].As(); + if (ta.TypedArrayType() != napi_float32_array) + return Napi::Boolean::New(env, false); + auto f32 = info[0].As(); + const size_t samples = f32.ElementLength(); + if (samples < 2) + return Napi::Boolean::New(env, false); + const int frames = (int) (samples / 2); + const bool ok = liveEngine->pushRendererAudio( + f32.Data(), frames, info[1].As().DoubleValue()); + return Napi::Boolean::New(env, ok); +} + +// getRendererBusMetrics() -> {enabled, fillFrames, capacityFrames, +// pushedFrames, consumedFrames, +// underflowCount, overflowCount} +Napi::Value GetRendererBusMetrics(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto liveEngine = snapshotEngine(); + auto obj = Napi::Object::New(env); + if (!liveEngine) return obj; + const auto m = liveEngine->getRendererBusMetrics(); + obj.Set("enabled", m.enabled); + obj.Set("fillFrames", m.fillFrames); + obj.Set("capacityFrames", m.capacityFrames); + obj.Set("pushedFrames", (double) m.pushedFrames); + obj.Set("consumedFrames", (double) m.consumedFrames); + obj.Set("underflowCount", (double) m.underflowCount); + obj.Set("overflowCount", (double) m.overflowCount); + return obj; +} + +// getStreamSinkLevel() -> number (peak 0..1+) +Napi::Value GetStreamSinkLevel(const Napi::CallbackInfo& info) +{ + auto liveEngine = snapshotEngine(); + return Napi::Number::New(info.Env(), liveEngine ? liveEngine->getStreamSinkLevel() : 0.0f); +} + +// isStreamOutputActive() -> boolean +Napi::Value IsStreamOutputActive(const Napi::CallbackInfo& info) +{ + auto liveEngine = snapshotEngine(); + return Napi::Boolean::New(info.Env(), liveEngine ? liveEngine->isStreamOutputActive() : false); +} + +// getStreamUnderflowCount() -> number +Napi::Value GetStreamUnderflowCount(const Napi::CallbackInfo& info) +{ + auto liveEngine = snapshotEngine(); + return Napi::Number::New(info.Env(), + (double) (liveEngine ? liveEngine->getStreamUnderflowCount() : 0ull)); +} + +// getStreamOverflowCount() -> number (consumer fell a full ring behind; frames dropped) +Napi::Value GetStreamOverflowCount(const Napi::CallbackInfo& info) +{ + auto liveEngine = snapshotEngine(); + return Napi::Number::New(info.Env(), + (double) (liveEngine ? liveEngine->getStreamOverflowCount() : 0ull)); +} + +// setSourceInputChannel(sourceId, channel) +Napi::Value SetSourceInputChannel(const Napi::CallbackInfo& info) +{ + auto liveEngine = snapshotEngine(); + if (liveEngine && info.Length() >= 2 && info[0].IsNumber() && info[1].IsNumber()) + if (SourceChain* s = getValidatedSource(liveEngine.get(), info, 0)) + s->setInputChannel(info[1].As().Int32Value()); + return info.Env().Undefined(); +} + +// setSourceVerifierOffset(sourceId, seconds) — per-source capture-latency +// correction the user dials in for an extra input device (the residual offset +// between that device's path and the primary's; not auto-measurable on JACK). +// Positive seconds DELAYS this source's scoring playhead, negative ADVANCES it. +Napi::Value SetSourceVerifierOffset(const Napi::CallbackInfo& info) +{ + auto liveEngine = snapshotEngine(); + if (liveEngine && info.Length() >= 2 && info[0].IsNumber() && info[1].IsNumber()) + { + const double sec = info[1].As().DoubleValue(); + if (std::isfinite(sec)) + if (SourceChain* s = getValidatedSource(liveEngine.get(), info, 0)) + s->setVerifierUserOffset(sec); + } + return info.Env().Undefined(); +} + +// setSourceMonitorMute(sourceId, mute) +Napi::Value SetSourceMonitorMute(const Napi::CallbackInfo& info) +{ + auto liveEngine = snapshotEngine(); + if (liveEngine && info.Length() >= 2 && info[0].IsNumber() && info[1].IsBoolean()) + if (SourceChain* s = getValidatedSource(liveEngine.get(), info, 0)) + s->setMonitorMute(info[1].As().Value()); + return info.Env().Undefined(); +} + +// getSourceRawAudioFrame(sourceId, numSamples?) -> Float32Array +Napi::Value GetSourceRawAudioFrame(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto liveEngine = snapshotEngine(); + if (!liveEngine || info.Length() < 1 || !info[0].IsNumber()) + return Napi::Float32Array::New(env, 0); + SourceChain* s = getValidatedSource(liveEngine.get(), info, 0); + int numSamples = 4096; + if (info.Length() > 1 && info[1].IsNumber()) + numSamples = info[1].As().Int32Value(); + if (!s || numSamples <= 0) + return Napi::Float32Array::New(env, 0); + auto frame = s->getRawAudioFrame(numSamples); + auto out = Napi::Float32Array::New(env, frame.size()); + float* dst = out.Data(); + for (size_t i = 0; i < frame.size(); ++i) + dst[i] = frame[i]; + return out; +} + +// getSourcePitchDetection(sourceId) -> { frequency, confidence, midiNote, cents, +// noteName }. The no-detection shape when the id is bad/inactive. +Napi::Value GetSourcePitchDetection(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto obj = Napi::Object::New(env); + auto liveEngine = snapshotEngine(); + SourceChain* s = (liveEngine && info.Length() >= 1 && info[0].IsNumber()) + ? getValidatedSource(liveEngine.get(), info, 0) : nullptr; + if (s) + { + auto det = s->getActiveDetection(); + obj.Set("frequency", det.frequency); + obj.Set("confidence", det.confidence); + obj.Set("midiNote", det.midiNote); + obj.Set("cents", det.cents); + obj.Set("noteName", det.noteName.toStdString()); + } + else + { + obj.Set("frequency", -1.0); + obj.Set("confidence", 0.0); + obj.Set("midiNote", -1); + obj.Set("cents", 0.0); + obj.Set("noteName", ""); + } + return obj; +} + +// getSourceRawPitchDetection(sourceId) -> raw YIN detection (bypasses ML), same +// shape as getSourcePitchDetection. Backs the per-source sustain glow / mono path. +Napi::Value GetSourceRawPitchDetection(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto obj = Napi::Object::New(env); + auto liveEngine = snapshotEngine(); + SourceChain* s = (liveEngine && info.Length() >= 1 && info[0].IsNumber()) + ? getValidatedSource(liveEngine.get(), info, 0) : nullptr; + if (s) + { + auto det = s->getRawPitchDetection(); + obj.Set("frequency", det.frequency); + obj.Set("confidence", det.confidence); + obj.Set("midiNote", det.midiNote); + obj.Set("cents", det.cents); + obj.Set("noteName", det.noteName.toStdString()); + } + else + { + obj.Set("frequency", -1.0); + obj.Set("confidence", 0.0); + obj.Set("midiNote", -1); + obj.Set("cents", 0.0); + obj.Set("noteName", ""); + } + return obj; +} + +// getSourceNoteVerdicts(sourceId, songTime?, playing?) -> verdict array, or null +// on a missing engine / bad id. Folds in the per-source playhead push like the +// legacy getNoteVerdicts. +Napi::Value GetSourceNoteVerdicts(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto liveEngine = snapshotEngine(); + if (!liveEngine || info.Length() < 1 || !info[0].IsNumber()) + return env.Null(); + SourceChain* s = getValidatedSource(liveEngine.get(), info, 0); + if (!s) return env.Null(); + + if (info.Length() >= 3 && info[1].IsNumber() && info[2].IsBoolean()) + { + const double songTime = info[1].As().DoubleValue(); + if (std::isfinite(songTime)) + s->setPlayhead(songTime, info[2].As().Value()); + } + + const auto verdicts = s->getNoteVerdicts(); + auto arr = Napi::Array::New(env, verdicts.size()); + for (size_t i = 0; i < verdicts.size(); ++i) + { + const auto& v = verdicts[i]; + auto entry = Napi::Object::New(env); + entry.Set("id", v.id); + entry.Set("detected", v.detected); + entry.Set("detectedSongTime", v.detectedSongTime); + entry.Set("centsError", v.centsError); + entry.Set("snr", v.snr); + arr.Set((uint32_t) i, entry); + } + return arr; +} + +// Push the song's note chart into the engine for continuous, background +// verification. The notedetect plugin calls this once per arrangement load; +// the engine's NoteVerifier thread then scores each note's timing window +// against the live playhead and input ring, so the renderer no longer runs a +// per-tick scoreChord IPC loop (which starved during dense passages). +// +// Expected payload: +// { +// arrangement?: 'guitar'|'bass', // default 'guitar' +// stringCount?: number, // default 6 +// tuningOffsets: number[], // length should equal stringCount +// capo?: number, // default 0 +// pitchCheckCents?: number, // default 0 (energy-only) +// harmonicSnr?: number, // default 3.0 +// fundamentalRatio?: number, // fundamental-presence gate, lower for +// // bass, <=0 disables (default 0.20) +// timingTolerance?: number, // seconds, default 0.1 +// notes: [{ id:string, t:number, s:number, f:number, sus:number, +// ho?,po?,b?,sl?,hm?:boolean }, ...] +// } +// Returns true when the chart was accepted, false on a malformed payload or +// when no engine exists. +// Shared core: parse `reqObj` into a ChartUpdate and push it to `target`'s +// verifier. `target` is sources[0] for the legacy setChart and getSource(id) for +// the source-indexed setSourceChart. A malformed payload clears the target's +// chart (so a failed reload can't leave a stale chart scoring) and returns false. +Napi::Value setChartCore(Napi::Env env, Napi::Object reqObj, SourceChain* target) +{ + // Generous cap on the chart length — a full song's note list is well + // under this, but it bounds the worst-case allocation a malformed payload + // (claiming a gigantic JS array length) could force over IPC. + static constexpr uint32_t kMaxChartNotes = 8192; + + // Rejecting a malformed chart must also drop whatever chart the verifier + // currently holds — otherwise a failed (re)load leaves the previous + // song's chart active and getNoteVerdicts() keeps emitting stale verdicts. + auto reject = [&]() -> Napi::Value { + if (target) target->clearChart(); + return Napi::Boolean::New(env, false); + }; + + NoteVerifier::ChartUpdate chart; + if (reqObj.Has("arrangement") && reqObj.Get("arrangement").IsString()) + chart.arrangement = reqObj.Get("arrangement").As().Utf8Value(); + if (reqObj.Has("stringCount") && reqObj.Get("stringCount").IsNumber()) + chart.stringCount = reqObj.Get("stringCount").As().Int32Value(); + if (reqObj.Has("capo") && reqObj.Get("capo").IsNumber()) + chart.capo = reqObj.Get("capo").As().Int32Value(); + if (reqObj.Has("pitchCheckCents") && reqObj.Get("pitchCheckCents").IsNumber()) + chart.pitchCheckCents = reqObj.Get("pitchCheckCents").As().FloatValue(); + if (reqObj.Has("harmonicSnr") && reqObj.Get("harmonicSnr").IsNumber()) + chart.harmonicSnr = reqObj.Get("harmonicSnr").As().FloatValue(); + if (reqObj.Has("fundamentalRatio") && reqObj.Get("fundamentalRatio").IsNumber()) + { + // Drop NaN/Inf (see ScoreChord): a non-finite ratio poisons the + // fundamental-presence gate; keep the safe 0.20 default. + const float v = reqObj.Get("fundamentalRatio").As().FloatValue(); + if (std::isfinite(v)) chart.fundamentalRatio = v; + } + if (reqObj.Has("presenceRatio") && reqObj.Get("presenceRatio").IsNumber()) + { + // Temporal-persistence floor, clamped to [0,1]. Saturate rather than + // reject an out-of-range value: a stray >1 must NOT silently fall back to + // 0 (legacy ever-present), which would reintroduce the false-accept this + // guards against. Non-finite is ignored (keeps the 0 default). + const float v = reqObj.Get("presenceRatio").As().FloatValue(); + if (std::isfinite(v)) chart.presenceRatio = (v < 0.0f) ? 0.0f : (v > 1.0f ? 1.0f : v); + } + if (reqObj.Has("timingTolerance") && reqObj.Get("timingTolerance").IsNumber()) + chart.timingTolerance = reqObj.Get("timingTolerance").As().DoubleValue(); + + if (reqObj.Has("tuningOffsets") && reqObj.Get("tuningOffsets").IsArray()) + { + auto arr = reqObj.Get("tuningOffsets").As(); + if (arr.Length() > 32) return reject(); + chart.tuningOffsets.reserve(arr.Length()); + for (uint32_t i = 0; i < arr.Length(); ++i) + { + auto v = arr.Get(i); + if (!v.IsNumber()) return reject(); + chart.tuningOffsets.push_back(v.As().Int32Value()); + } + } + + // ChordScorer requires exactly one tuning offset per string and otherwise + // fails every note closed. Reject the chart here so a malformed payload + // surfaces as setChart() == false rather than a silently all-miss session + // the caller believes loaded fine. + if ((int) chart.tuningOffsets.size() != chart.stringCount) + return reject(); + + Napi::Value notesVal = reqObj.Has("notes") ? reqObj.Get("notes") : env.Null(); + if (!notesVal.IsArray()) return reject(); + auto notesArr = notesVal.As(); + if (notesArr.Length() > kMaxChartNotes) return reject(); + + chart.notes.reserve(notesArr.Length()); + for (uint32_t i = 0; i < notesArr.Length(); ++i) + { + auto v = notesArr.Get(i); + if (!v.IsObject()) return reject(); + auto noteObj = v.As(); + + // Every chart note must carry all five required fields with the right + // type. Filling defaults for a missing field would push a bogus + // time-0 note with an empty id — that breaks verdict-by-id alignment + // — so reject the whole chart instead. + const bool validNote = + noteObj.Has("id") && noteObj.Get("id").IsString() && + noteObj.Has("t") && noteObj.Get("t").IsNumber() && + noteObj.Has("s") && noteObj.Get("s").IsNumber() && + noteObj.Has("f") && noteObj.Get("f").IsNumber() && + noteObj.Has("sus") && noteObj.Get("sus").IsNumber(); + if (!validNote) return reject(); + + NoteVerifier::ChartNote n{}; + n.id = noteObj.Get("id").As().Utf8Value(); + n.t = noteObj.Get("t").As().DoubleValue(); + n.string = noteObj.Get("s").As().Int32Value(); + n.fret = noteObj.Get("f").As().Int32Value(); + n.sus = noteObj.Get("sus").As().DoubleValue(); + auto truthy = [¬eObj](const char* key) { + if (!noteObj.Has(key)) return false; + return noteObj.Get(key).ToBoolean().Value(); + }; + n.ho = truthy("ho"); + n.po = truthy("po"); + n.b = truthy("b"); + n.sl = truthy("sl"); + n.hm = truthy("hm"); + chart.notes.push_back(std::move(n)); + } + + target->setChart(chart); + return Napi::Boolean::New(env, true); +} + +// Legacy: setChart(chart) — targets sources[0]. Backward-compatible. +Napi::Value SetChart(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto liveEngine = snapshotEngine(); + if (!liveEngine || info.Length() < 1 || !info[0].IsObject()) + { + if (liveEngine) liveEngine->clearChart(); + return Napi::Boolean::New(env, false); + } + return setChartCore(env, info[0].As(), liveEngine->getSource(0)); +} + +// Source-indexed: setSourceChart(sourceId, chart). Bad id / payload -> false. +Napi::Value SetSourceChart(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto liveEngine = snapshotEngine(); + if (!liveEngine || info.Length() < 2 || !info[0].IsNumber() || !info[1].IsObject()) + return Napi::Boolean::New(env, false); + SourceChain* target = getValidatedSource(liveEngine.get(), info, 0); + if (!target) return Napi::Boolean::New(env, false); + return setChartCore(env, info[1].As(), target); +} + +// Drain the verdicts the NoteVerifier thread has finalized since the last +// call. Returns an array of { id, detected, detectedSongTime, centsError, snr }. +// +// Optionally also pushes the renderer's playhead: getNoteVerdicts(songTime, +// playing). The plugin calls this once per detect tick, so folding the push in +// here advances the verifier's clock without a second IPC round-trip. A +// downlevel caller passing no args still just drains. +Napi::Value GetNoteVerdicts(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + // Null (not an empty array) on a missing engine — the bridge/preload + // contract treats null as "unsupported/unavailable" so the renderer + // feature-detects, matching detectNotes' no-engine path. + auto liveEngine = snapshotEngine(); + if (!liveEngine) return env.Null(); + + // Push the playhead before draining so this tick's verdicts reflect it. + // A JS NaN/Infinity passes IsNumber() — guard with isfinite so a bad + // value can't corrupt the verifier's interpolated timing. + if (info.Length() >= 2 && info[0].IsNumber() && info[1].IsBoolean()) + { + const double songTime = info[0].As().DoubleValue(); + if (std::isfinite(songTime)) + liveEngine->setPlayhead(songTime, info[1].As().Value()); + } + + const auto verdicts = liveEngine->getNoteVerdicts(); + auto arr = Napi::Array::New(env, verdicts.size()); + for (size_t i = 0; i < verdicts.size(); ++i) + { + const auto& v = verdicts[i]; + auto entry = Napi::Object::New(env); + entry.Set("id", v.id); + entry.Set("detected", v.detected); + entry.Set("detectedSongTime", v.detectedSongTime); + entry.Set("centsError", v.centsError); + entry.Set("snr", v.snr); + arr.Set((uint32_t) i, entry); + } + return arr; +} + +// Sample rate the audio device is running at. Notedetect's chord scorer +// needs this to map FFT bins to Hz; on the bridge path there's no +// AudioContext to read it from. Falls back to 48000 if the engine isn't +// ready (matches the historical fallback in screen.js) — and also if +// the engine is initialized but no device is currently active, which +// pins currentSampleRate to 0 internally and would otherwise propagate +// a divide-by-zero into the renderer's FFT-bin→Hz math. +Napi::Value GetSampleRate(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + constexpr double kFallbackSampleRate = 48000.0; + auto liveEngine = snapshotEngine(); + if (!liveEngine) + return Napi::Number::New(env, kFallbackSampleRate); + const double sr = liveEngine->getCurrentSampleRate(); + if (!std::isfinite(sr) || sr <= 0.0) + return Napi::Number::New(env, kFallbackSampleRate); + return Napi::Number::New(env, sr); +} + + +} // namespace slopsmith::addon