mirror of
https://github.com/got-feedBack/feedBack-desktop.git
synced 2026-09-11 02:44: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/AddonContext.cpp
|
||||||
addon/ChainOps.cpp
|
addon/ChainOps.cpp
|
||||||
addon/EditorWindows.cpp
|
addon/EditorWindows.cpp
|
||||||
|
addon/DeviceBindings.cpp
|
||||||
|
addon/ControlBindings.cpp
|
||||||
|
addon/DetectionBindings.cpp
|
||||||
|
addon/ChainBindings.cpp
|
||||||
|
addon/BackingBindings.cpp
|
||||||
SourceChain.cpp
|
SourceChain.cpp
|
||||||
SignalChain.cpp
|
SignalChain.cpp
|
||||||
VSTHost.cpp
|
VSTHost.cpp
|
||||||
|
|||||||
+89
-2022
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