mirror of
https://github.com/got-feedBack/feedBack-desktop.git
synced 2026-09-11 02:34:10 +00:00
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:
co-authored by
Claude Fable 5
parent
f473aad920
commit
2906d2814b
@@ -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
|
||||
|
||||
+89
-2022
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 = [¬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<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
|
||||
@@ -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 = [¬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<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
|
||||
Reference in New Issue
Block a user