refactor(audio): split N-API bindings into grouped files (phase 7b complete)

Splits the remaining 88 handlers out of NodeAddon.cpp, grouped to match the
preload API sections (plan §3.5): DeviceBindings (enumeration/selection/
audio-control/stream sink), ControlBindings (gain/metering/MIDI/debug
logging), DetectionBindings (pitch/chart/verdict/source-indexed, owns the
shared getValidatedSource), ChainBindings (slot/state/preset), and
BackingBindings. Declarations live in addon/Bindings.h; NodeAddon.cpp keeps
Init/Shutdown and the exports table — which now doubles as the API index the
old 3699-line file lacked — at 459 lines.

This completes the Part IV decomposition: AudioEngine.{h,cpp} 819+3223 →
509+1284 across seven engine/ units; NodeAddon.cpp 3699 → 459 across seven
addon/ units. All gates green (contract-check, storm, arg-fuzz, full suite).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
OmikronApex
2026-07-14 02:33:24 +02:00
co-authored by Claude Fable 5
parent f473aad920
commit 2906d2814b
8 changed files with 2331 additions and 2022 deletions
+5
View File
@@ -14,6 +14,11 @@ set(AUDIO_SOURCES
addon/AddonContext.cpp addon/AddonContext.cpp
addon/ChainOps.cpp addon/ChainOps.cpp
addon/EditorWindows.cpp addon/EditorWindows.cpp
addon/DeviceBindings.cpp
addon/ControlBindings.cpp
addon/DetectionBindings.cpp
addon/ChainBindings.cpp
addon/BackingBindings.cpp
SourceChain.cpp SourceChain.cpp
SignalChain.cpp SignalChain.cpp
VSTHost.cpp VSTHost.cpp
+89 -2022
View File
File diff suppressed because it is too large Load Diff
+91
View File
@@ -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 <cmath>
#include <cstdio>
#include <limits>
#include <string>
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<Napi::String>().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<Napi::Number>().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<Napi::Number>().DoubleValue());
return env.Undefined();
}
} // namespace slopsmith::addon
+108
View File
@@ -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 <napi.h>
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
+279
View File
@@ -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 <cmath>
#include <cstdio>
#include <limits>
#include <string>
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<std::mutex> 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<std::mutex> 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<std::mutex> 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<Napi::Number>().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<Napi::Number>().Int32Value();
auto base64 = info[1].As<Napi::String>().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<Napi::Array>();
juce::Array<std::pair<int, bool>> 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<Napi::Object>();
auto slotVal = item.Get("slotId");
auto bypVal = item.Get("bypassed");
if (!slotVal.IsNumber() || !bypVal.IsBoolean()) continue;
const double raw = slotVal.As<Napi::Number>().DoubleValue();
if (!std::isfinite(raw) || raw != std::floor(raw) || raw < 0.0 || raw > 4096.0) continue;
changes.add({ (int) raw, bypVal.As<Napi::Boolean>().Value() });
}
liveEngine->getSignalChain().setMultiBypass(changes);
return Napi::Boolean::New(env, true);
}
} // namespace slopsmith::addon
+336
View File
@@ -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 <cmath>
#include <cstdio>
#include <limits>
#include <string>
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<Napi::String>().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<Napi::Number>().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<Napi::Boolean>().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<Napi::Boolean>().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<Napi::Boolean>().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<Napi::Boolean>().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<Napi::Object>();
bool enabled = false;
if (o.Has("enabled"))
{
auto v = o.Get("enabled");
if (v.IsBoolean())
enabled = v.As<Napi::Boolean>().Value();
else if (v.IsNumber())
enabled = v.As<Napi::Number>().DoubleValue() != 0.0;
}
float thresholdDb = -60.0f;
if (o.Has("thresholdDb") && o.Get("thresholdDb").IsNumber())
thresholdDb = (float)o.Get("thresholdDb").As<Napi::Number>().DoubleValue();
float releaseMs = 100.0f;
if (o.Has("releaseMs") && o.Get("releaseMs").IsNumber())
releaseMs = (float)o.Get("releaseMs").As<Napi::Number>().DoubleValue();
float depthDb = -60.0f;
if (o.Has("depthDb") && o.Get("depthDb").IsNumber())
depthDb = (float)o.Get("depthDb").As<Napi::Number>().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<Napi::Object>();
bool enabled = true;
if (o.Has("enabled"))
{
auto v = o.Get("enabled");
if (v.IsBoolean())
enabled = v.As<Napi::Boolean>().Value();
else if (v.IsNumber())
enabled = v.As<Napi::Number>().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<Napi::String>().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<Napi::String>().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
+589
View File
@@ -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 <cmath>
#include <cstdio>
#include <limits>
#include <string>
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<Napi::Number>().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<Napi::String>().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<Napi::Number>().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<Napi::Array>();
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<Napi::Object>();
if (o.Has("s") && o.Get("s").IsNumber())
s = o.Get("s").As<Napi::Number>().Int32Value();
if (o.Has("f") && o.Get("f").IsNumber())
f = o.Get("f").As<Napi::Number>().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<Napi::Number>().Int32Value();
if (reqObj.Has("arrangement") && reqObj.Get("arrangement").IsString())
req.arrangement = reqObj.Get("arrangement").As<Napi::String>().Utf8Value();
if (reqObj.Has("stringCount") && reqObj.Get("stringCount").IsNumber())
req.stringCount = reqObj.Get("stringCount").As<Napi::Number>().Int32Value();
if (reqObj.Has("capo") && reqObj.Get("capo").IsNumber())
req.capo = reqObj.Get("capo").As<Napi::Number>().Int32Value();
if (reqObj.Has("pitchCheckCents") && reqObj.Get("pitchCheckCents").IsNumber())
req.pitchCheckCents = reqObj.Get("pitchCheckCents").As<Napi::Number>().FloatValue();
if (reqObj.Has("minHitRatio") && reqObj.Get("minHitRatio").IsNumber())
req.minHitRatio = reqObj.Get("minHitRatio").As<Napi::Number>().FloatValue();
if (reqObj.Has("bypassMl") && reqObj.Get("bypassMl").IsBoolean())
req.bypassMl = reqObj.Get("bypassMl").As<Napi::Boolean>().Value();
if (reqObj.Has("harmonicVerify") && reqObj.Get("harmonicVerify").IsBoolean())
req.harmonicVerify = reqObj.Get("harmonicVerify").As<Napi::Boolean>().Value();
if (reqObj.Has("harmonicSnr") && reqObj.Get("harmonicSnr").IsNumber())
req.harmonicSnr = reqObj.Get("harmonicSnr").As<Napi::Number>().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<Napi::Number>().FloatValue();
if (std::isfinite(v)) req.fundamentalRatio = v;
}
if (reqObj.Has("offsets") && reqObj.Get("offsets").IsArray())
{
auto arr = reqObj.Get("offsets").As<Napi::Array>();
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<Napi::Number>().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<Napi::Object>();
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<Napi::Number>().Int32Value();
n.fret = noteObj.Get("f").As<Napi::Number>().Int32Value();
// Technique flags are truthy/falsy in JS; coerce to bool
// here so an unset value cleanly becomes false.
auto truthy = [&noteObj](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<Napi::Object>(), 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<Napi::Object>(), 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<Napi::Number>().Int32Value();
int deviceKey = 0; // default: primary input device
if (info.Length() > 1 && info[1].IsNumber())
{
const int k = info[1].As<Napi::Number>().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<Napi::Number>().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<Napi::Number>().Int32Value();
const std::string name = info[1].As<Napi::String>().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<Napi::Number>().Int32Value()));
}
} // namespace slopsmith::addon
+834
View File
@@ -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 <cmath>
#include <cstdio>
#include <limits>
#include <string>
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<Napi::String>().Utf8Value() : "";
auto arg1 = info.Length() > 1 && info[1].IsString() ? info[1].As<Napi::String>().Utf8Value() : "";
auto arg2 = info.Length() > 2 && info[2].IsString() ? info[2].As<Napi::String>().Utf8Value() : "";
auto arg3 = info.Length() > 3 && info[3].IsString() ? info[3].As<Napi::String>().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<double>(m.inputOverflowCount));
obj.Set("outputUnderflowCount", static_cast<double>(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<Napi::String>().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<Napi::String>().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<Napi::Object>();
auto readStr = [&](const char* key) -> std::string {
if (obj.Has(key) && obj.Get(key).IsString()) return obj.Get(key).As<Napi::String>().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<Napi::Number>().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<int>::max) ())
cfg.bufferSize = (int) bsd;
else
cfg.bufferSize = 256;
}
}
else
{
auto input = info.Length() > 0 && info[0].IsString() ? info[0].As<Napi::String>().Utf8Value() : "";
auto output = info.Length() > 1 && info[1].IsString() ? info[1].As<Napi::String>().Utf8Value() : "";
double sr = info.Length() > 2 && info[2].IsNumber() ? info[2].As<Napi::Number>().DoubleValue() : 48000.0;
int bs = info.Length() > 3 && info[3].IsNumber() ? info[3].As<Napi::Number>().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<Napi::String>().Utf8Value();
const std::string devName = info[1].As<Napi::String>().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<Napi::Boolean>().Value(),
info[1].As<Napi::Boolean>().Value(),
(float) info[2].As<Napi::Number>().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<Napi::Number>().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<Napi::Boolean>().Value(),
(float) info[1].As<Napi::Number>().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<Napi::TypedArray>();
if (ta.TypedArrayType() != napi_float32_array)
return Napi::Boolean::New(env, false);
auto f32 = info[0].As<Napi::Float32Array>();
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<Napi::Number>().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<Napi::Number>().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<Napi::Number>().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<Napi::Boolean>().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<Napi::Number>().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<Napi::Number>().DoubleValue();
if (std::isfinite(songTime))
s->setPlayhead(songTime, info[2].As<Napi::Boolean>().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<Napi::String>().Utf8Value();
if (reqObj.Has("stringCount") && reqObj.Get("stringCount").IsNumber())
chart.stringCount = reqObj.Get("stringCount").As<Napi::Number>().Int32Value();
if (reqObj.Has("capo") && reqObj.Get("capo").IsNumber())
chart.capo = reqObj.Get("capo").As<Napi::Number>().Int32Value();
if (reqObj.Has("pitchCheckCents") && reqObj.Get("pitchCheckCents").IsNumber())
chart.pitchCheckCents = reqObj.Get("pitchCheckCents").As<Napi::Number>().FloatValue();
if (reqObj.Has("harmonicSnr") && reqObj.Get("harmonicSnr").IsNumber())
chart.harmonicSnr = reqObj.Get("harmonicSnr").As<Napi::Number>().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<Napi::Number>().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<Napi::Number>().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<Napi::Number>().DoubleValue();
if (reqObj.Has("tuningOffsets") && reqObj.Get("tuningOffsets").IsArray())
{
auto arr = reqObj.Get("tuningOffsets").As<Napi::Array>();
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<Napi::Number>().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<Napi::Array>();
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<Napi::Object>();
// 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<Napi::String>().Utf8Value();
n.t = noteObj.Get("t").As<Napi::Number>().DoubleValue();
n.string = noteObj.Get("s").As<Napi::Number>().Int32Value();
n.fret = noteObj.Get("f").As<Napi::Number>().Int32Value();
n.sus = noteObj.Get("sus").As<Napi::Number>().DoubleValue();
auto truthy = [&noteObj](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<Napi::Object>(), 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<Napi::Object>(), 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<Napi::Number>().DoubleValue();
if (std::isfinite(songTime))
liveEngine->setPlayhead(songTime, info[1].As<Napi::Boolean>().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