Clean release snapshot

This commit is contained in:
Byron Gamatos
2026-06-16 18:48:12 +02:00
commit bd603184d5
291 changed files with 47318 additions and 0 deletions
+20
View File
@@ -0,0 +1,20 @@
# Tests for the sandbox IPC path. The audio-ring loopback is cross-platform;
# the control-channel + posix_spawn smoke tests are POSIX-only (see
# tests/sandbox/CMakeLists.txt). Built by default alongside the addon — opt-out
# with -DSLOPSMITH_BUILD_TESTS=OFF.
#
# Run from the build directory: `ctest --output-on-failure -C Release`.
option(SLOPSMITH_BUILD_TESTS "Build sandbox unit tests" ON)
if(NOT SLOPSMITH_BUILD_TESTS)
return()
endif()
# Pure-helper tests (no JUCE / no platform deps) build everywhere.
add_subdirectory(audio_sanitize)
# Note: enable_testing() lives in the top-level CMakeLists.txt — calling it
# only here would register tests in build/tests/CTestTestfile.cmake but
# `ctest` from the root build directory wouldn't discover them.
add_subdirectory(sandbox)
+392
View File
@@ -0,0 +1,392 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const Module = require('node:module');
const ts = require('typescript');
const ROOT = path.join(__dirname, '..');
const EXECUTOR_TS = path.join(ROOT, 'src', 'main', 'audio-effects-executor.ts');
function loadExecutorModule() {
const source = fs.readFileSync(EXECUTOR_TS, 'utf8');
const compiled = ts.transpileModule(source, {
compilerOptions: {
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2022,
esModuleInterop: true,
},
fileName: EXECUTOR_TS,
}).outputText;
const mod = new Module(EXECUTOR_TS, module);
mod.filename = EXECUTOR_TS;
mod.paths = Module._nodeModulePaths(path.dirname(EXECUTOR_TS));
mod._compile(compiled, EXECUTOR_TS);
return mod.exports;
}
function tempAsset(ext) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'slopsmith-audio-effects-'));
const file = path.join(dir, `asset${ext}`);
fs.writeFileSync(file, 'test');
return file;
}
function plan(overrides = {}) {
return {
schema: 'slopsmith.audio_effects.chain_plan.v1',
planId: 'plan-1',
routeKey: 'desktop-main',
providerId: 'rig-builder',
stages: [
{ stageId: 'pre', kind: 'nam', role: 'pre-pedal', assetRef: 'asset:pre' },
{ stageId: 'cab', kind: 'ir', role: 'cab', assetRef: 'asset:cab', bypassed: true },
],
segments: [{ segmentId: 'lead', stageIds: ['pre'], stageBypass: { pre: true } }],
...overrides,
};
}
test('audio-effects executor validates and loads a trusted chain plan without leaking asset paths', async () => {
const { createAudioEffectsExecutor } = loadExecutorModule();
const calls = [];
const native = {
loadPreset: async presetJson => {
const parsed = JSON.parse(presetJson);
calls.push(parsed.chain);
return { success: true, slotsLoaded: parsed.chain.length };
},
getChainState: () => [{ id: 10 }, { id: 11 }],
setMultiBypass: changes => { calls.push(['multi', changes]); return true; },
setBypass: (slotId, bypassed) => { calls.push(['bypass', slotId, bypassed]); return true; },
setParameter: (slotId, paramIndex, value) => { calls.push(['param', slotId, paramIndex, value]); return true; },
};
const executor = createAudioEffectsExecutor(() => native);
const namPath = tempAsset('.nam');
const irPath = tempAsset('.wav');
const loaded = await executor.loadChainPlan({
authorization: 'playback-session',
plan: plan(),
assets: {
'asset:pre': { kind: 'nam', path: namPath, safeName: 'pre' },
'asset:cab': { kind: 'ir', path: irPath, safeName: 'cab' },
},
});
const inspected = executor.inspectRoute('desktop-main');
const segment = await executor.activateSegment({ routeKey: 'desktop-main', segmentId: 'lead' });
const bypass = await executor.setStageBypass({ routeKey: 'desktop-main', stageId: 'pre', bypassed: true });
const param = await executor.setStageParameter({ routeKey: 'desktop-main', stageId: 'pre', paramIndex: 2, value: 0.75 });
const encoded = JSON.stringify({ loaded, inspected, segment, bypass, param });
assert.equal(loaded.outcome, 'handled');
assert.equal(inspected.payload.route.nativeStageCount, 2);
assert.equal(segment.outcome, 'handled');
assert.equal(bypass.outcome, 'handled');
assert.equal(param.outcome, 'handled');
assert.deepEqual(calls[0].map(stage => stage.type), [1, 2]);
assert.deepEqual(calls[1], ['multi', [{ slotId: 10, bypassed: true }, { slotId: 11, bypassed: true }]]);
assert.equal(encoded.includes(namPath), false);
assert.equal(encoded.includes(irPath), false);
assert.equal(encoded.includes('asset:pre'), false);
});
test('audio-effects executor rejects a plan with duplicate segmentIds', async () => {
const { createAudioEffectsExecutor } = loadExecutorModule();
const native = {
loadPreset: async () => ({ success: true, slotsLoaded: 2 }),
getChainState: () => [{ id: 10 }, { id: 11 }],
};
const executor = createAudioEffectsExecutor(() => native);
const namPath = tempAsset('.nam');
const irPath = tempAsset('.wav');
const loaded = await executor.loadChainPlan({
authorization: 'playback-session',
plan: plan({ segments: [
{ segmentId: 'lead', stageIds: ['pre'] },
{ segmentId: 'lead', stageIds: ['cab'] },
] }),
assets: {
'asset:pre': { kind: 'nam', path: namPath, safeName: 'pre' },
'asset:cab': { kind: 'ir', path: irPath, safeName: 'cab' },
},
});
assert.equal(loaded.outcome, 'failed');
assert.equal(JSON.stringify(loaded.payload.errors).includes('duplicate segmentId'), true);
});
test('audio-effects executor returns failed (with rollback) when native chain-state lookup throws', async () => {
const { createAudioEffectsExecutor } = loadExecutorModule();
const native = {
savePreset: () => 'previous',
loadPreset: async () => ({ success: true, slotsLoaded: 2 }),
getChainState: () => { throw new Error('native chain-state boom'); },
};
const executor = createAudioEffectsExecutor(() => native);
const namPath = tempAsset('.nam');
const irPath = tempAsset('.wav');
const loaded = await executor.loadChainPlan({
authorization: 'playback-session',
plan: plan(),
assets: {
'asset:pre': { kind: 'nam', path: namPath, safeName: 'pre' },
'asset:cab': { kind: 'ir', path: irPath, safeName: 'cab' },
},
});
assert.equal(loaded.outcome, 'failed');
assert.equal(loaded.payload.rollbackApplied, true);
// The route must not be registered when the lookup failed.
assert.equal(executor.inspectRoute('desktop-main').outcome, 'no-target');
});
test('audio-effects executor rolls back to degraded when native slot mapping is incomplete', async () => {
const { createAudioEffectsExecutor } = loadExecutorModule();
const native = {
savePreset: () => 'previous',
loadPreset: async () => ({ success: true, slotsLoaded: 2 }),
// loadPreset reports both stages loaded, but the chain state only maps one valid slot.
getChainState: () => [{ id: 10 }, { id: -1 }],
};
const executor = createAudioEffectsExecutor(() => native);
const namPath = tempAsset('.nam');
const irPath = tempAsset('.wav');
const loaded = await executor.loadChainPlan({
authorization: 'playback-session',
plan: plan(),
assets: {
'asset:pre': { kind: 'nam', path: namPath, safeName: 'pre' },
'asset:cab': { kind: 'ir', path: irPath, safeName: 'cab' },
},
});
assert.equal(loaded.outcome, 'degraded');
assert.equal(loaded.payload.slotsMapped, 1);
assert.equal(executor.inspectRoute('desktop-main').outcome, 'no-target');
});
test('audio-effects executor owns load mute, route gain, start, and release', async () => {
const { createAudioEffectsExecutor } = loadExecutorModule();
const calls = [];
const native = {
savePreset: () => 'previous',
loadPreset: presetJson => { calls.push(['load', JSON.parse(presetJson).chain.length]); return { success: true, slotsLoaded: 2 }; },
clearChain: () => { calls.push(['clear']); return true; },
getChainState: () => [{ id: 10 }, { id: 11 }],
isMonitorMuted: () => { calls.push(['is-muted']); return true; },
setMonitorMute: muted => { calls.push(['monitor', muted]); return true; },
setMonitorMuteSuppressed: suppressed => { calls.push(['suppress', suppressed]); return true; },
setGain: (which, value) => { calls.push(['gain', which, value]); return true; },
startAudio: () => { calls.push(['start']); return true; },
};
const executor = createAudioEffectsExecutor(() => native);
const namPath = tempAsset('.nam');
const irPath = tempAsset('.wav');
const loaded = await executor.loadChainPlan({
authorization: 'playback-session',
plan: plan(),
assets: {
'asset:pre': { kind: 'nam', path: namPath, safeName: 'pre' },
'asset:cab': { kind: 'ir', path: irPath, safeName: 'cab' },
},
options: { preloadMute: { targetGain: 4, holdMs: 0 }, gains: { input: 8, chain: 4 }, startAudio: true },
});
const gained = await executor.setRouteGain({ routeKey: 'desktop-main', gains: { chain: 2 } });
const released = await executor.releaseRoute({ routeKey: 'desktop-main' });
const inspected = executor.inspectRoute('desktop-main');
await new Promise(resolve => setTimeout(resolve, 40));
assert.equal(loaded.outcome, 'handled');
assert.equal(gained.outcome, 'handled');
assert.equal(released.outcome, 'handled');
assert.equal(inspected.outcome, 'no-target');
assert.deepEqual(calls.slice(0, 7), [
['is-muted'],
['gain', 'chain', 0],
['monitor', false],
['load', 2],
['gain', 'input', 8],
['start'],
['gain', 'chain', 2],
]);
assert.equal(calls.some(call => call[0] === 'clear'), true);
assert.equal(calls.some(call => call[0] === 'monitor' && call[1] === true), true);
assert.equal(calls.some(call => call[0] === 'suppress' && call[1] === false), true);
assert.equal(calls.some(call => call[0] === 'gain' && call[1] === 'chain' && call[2] === 4), false);
assert.equal(calls.some(call => call[0] === 'gain' && call[1] === 'chain' && call[2] === 0), true);
});
test('audio-effects executor rejects unauthorised, missing, and raw-path-like plans before native load', async () => {
const { createAudioEffectsExecutor } = loadExecutorModule();
let loadCount = 0;
const executor = createAudioEffectsExecutor(() => ({
loadPreset: () => { loadCount += 1; return { success: true, slotsLoaded: 1 }; },
getChainState: () => [{ id: 1 }],
}));
const noAuth = await executor.loadChainPlan({ plan: plan(), assets: {} });
const missingAsset = await executor.loadChainPlan({ authorization: 'user-action', plan: plan(), assets: {} });
const rawPath = await executor.loadChainPlan({
authorization: 'user-action',
plan: plan({ stages: [{ stageId: 'amp', kind: 'nam', role: 'amp', assetRef: '/Users/example/private/model.nam' }] }),
assets: {},
});
const encoded = JSON.stringify({ noAuth, missingAsset, rawPath });
assert.equal(noAuth.outcome, 'failed');
assert.equal(missingAsset.outcome, 'failed');
assert.equal(rawPath.outcome, 'failed');
assert.equal(loadCount, 0);
assert.equal(encoded.includes('/Users/example'), false);
assert.equal(encoded.includes('model.nam'), false);
});
test('audio-effects executor rejects duplicate stage ids before native load', async () => {
const { createAudioEffectsExecutor } = loadExecutorModule();
let loadCount = 0;
const namPath = tempAsset('.nam');
const irPath = tempAsset('.wav');
const executor = createAudioEffectsExecutor(() => ({
loadPreset: () => { loadCount += 1; return { success: true, slotsLoaded: 2 }; },
getChainState: () => [{ id: 1 }, { id: 2 }],
}));
const result = await executor.loadChainPlan({
authorization: 'user-action',
plan: plan({
stages: [
{ stageId: 'dup', kind: 'nam', role: 'amp', assetRef: 'asset:pre' },
{ stageId: 'dup', kind: 'ir', role: 'cab', assetRef: 'asset:cab' },
],
}),
assets: {
'asset:pre': { kind: 'nam', path: namPath, safeName: 'pre' },
'asset:cab': { kind: 'ir', path: irPath, safeName: 'cab' },
},
});
assert.equal(result.outcome, 'failed');
assert.equal(loadCount, 0);
assert.equal(JSON.stringify(result).includes('Duplicate stageId dup'), true);
});
test('audio-effects executor rolls back and avoids route state on partial native loads', async () => {
const { createAudioEffectsExecutor } = loadExecutorModule();
const calls = [];
const executor = createAudioEffectsExecutor(() => ({
savePreset: () => 'previous-preset',
loadPreset: presetJson => {
calls.push(presetJson);
return { success: true, slotsLoaded: 1 };
},
getChainState: () => [{ id: 10 }],
}));
const namPath = tempAsset('.nam');
const irPath = tempAsset('.wav');
const loaded = await executor.loadChainPlan({
authorization: 'playback-session',
plan: plan(),
assets: {
'asset:pre': { kind: 'nam', path: namPath, safeName: 'pre' },
'asset:cab': { kind: 'ir', path: irPath, safeName: 'cab' },
},
});
const inspected = executor.inspectRoute('desktop-main');
const encoded = JSON.stringify(loaded);
assert.equal(loaded.outcome, 'degraded');
assert.equal(loaded.payload.rollbackApplied, true);
assert.equal(inspected.outcome, 'no-target');
assert.equal(calls.length, 2);
assert.equal(calls[1], 'previous-preset');
assert.equal(encoded.includes(namPath), false);
assert.equal(encoded.includes(irPath), false);
});
test('audio-effects executor reports native control failures without throwing IPC errors', async () => {
const { createAudioEffectsExecutor } = loadExecutorModule();
const namPath = tempAsset('.nam');
const irPath = tempAsset('.wav');
const executor = createAudioEffectsExecutor(() => ({
loadPreset: () => ({ success: true, slotsLoaded: 2 }),
getChainState: () => [{ id: 10 }, { id: 11 }],
setBypass: async () => { throw new Error('plugin crash /Users/example/private.nam'); },
setParameter: async () => ({ success: false }),
setMultiBypass: async () => false,
}));
const loaded = await executor.loadChainPlan({
authorization: 'playback-session',
plan: plan(),
assets: {
'asset:pre': { kind: 'nam', path: namPath, safeName: 'pre' },
'asset:cab': { kind: 'ir', path: irPath, safeName: 'cab' },
},
});
const bypass = await executor.setStageBypass({ routeKey: 'desktop-main', stageId: 'pre', bypassed: true });
const param = await executor.setStageParameter({ routeKey: 'desktop-main', stageId: 'pre', paramIndex: 2, value: 0.75 });
const segment = await executor.activateSegment({ routeKey: 'desktop-main', segmentId: 'lead' });
const encoded = JSON.stringify({ bypass, param, segment });
assert.equal(loaded.outcome, 'handled');
assert.equal(bypass.outcome, 'failed');
assert.equal(bypass.reason, 'Native stage bypass threw');
assert.equal(param.outcome, 'failed');
assert.equal(param.reason, 'Native stage parameter returned failure');
assert.equal(segment.outcome, 'failed');
assert.equal(segment.reason, 'Native multi-bypass returned failure');
assert.equal(encoded.includes('/Users/example'), false);
assert.equal(encoded.includes('private.nam'), false);
});
test('audio-effects executor rejects coerced parameter indices', async () => {
const { createAudioEffectsExecutor } = loadExecutorModule();
const namPath = tempAsset('.nam');
const irPath = tempAsset('.wav');
const calls = [];
const executor = createAudioEffectsExecutor(() => ({
loadPreset: () => ({ success: true, slotsLoaded: 2 }),
getChainState: () => [{ id: 10 }, { id: 11 }],
setParameter: (...args) => { calls.push(args); return true; },
}));
await executor.loadChainPlan({
authorization: 'playback-session',
plan: plan(),
assets: {
'asset:pre': { kind: 'nam', path: namPath, safeName: 'pre' },
'asset:cab': { kind: 'ir', path: irPath, safeName: 'cab' },
},
});
assert.equal((await executor.setStageParameter({ routeKey: 'desktop-main', stageId: 'pre', paramIndex: false, value: 0.75 })).outcome, 'failed');
assert.equal((await executor.setStageParameter({ routeKey: 'desktop-main', stageId: 'pre', paramIndex: '', value: 0.75 })).outcome, 'failed');
assert.equal((await executor.setStageParameter({ routeKey: 'desktop-main', stageId: 'pre', paramIndex: 4096, value: 0.75 })).outcome, 'failed');
assert.equal((await executor.setStageParameter({ routeKey: 'desktop-main', stageId: 'pre', paramIndex: '2', value: '0.75' })).outcome, 'handled');
assert.deepEqual(calls, [[10, 2, 0.75]]);
});
test('preload exposes the trusted audio-effects executor surface', () => {
const preload = fs.readFileSync(path.join(ROOT, 'src', 'main', 'preload.ts'), 'utf8');
const bridge = fs.readFileSync(path.join(ROOT, 'src', 'main', 'audio-bridge.ts'), 'utf8');
assert.equal(preload.includes('audioEffects: {'), true);
for (const method of ['loadChainPlan', 'releaseRoute', 'inspectRoute', 'activateSegment', 'setStageBypass', 'setStageParameter', 'setRouteGain']) {
assert.equal(preload.includes(`${method}:`), true);
}
for (const channel of [
'audio-effects:loadChainPlan',
'audio-effects:releaseRoute',
'audio-effects:inspectRoute',
'audio-effects:activateSegment',
'audio-effects:setStageBypass',
'audio-effects:setStageParameter',
'audio-effects:setRouteGain',
]) {
assert.equal(bridge.includes(channel), true);
}
assert.equal(bridge.includes("ipcMain.handle('audio-effects:loadChainPlan', async"), true);
assert.equal(bridge.includes('vstSlotPaths.clear();\n return await audioEffects.loadChainPlan(request);'), true);
assert.equal(bridge.includes('if (normalizedPayload.inputType !== normalizedPayload.outputType)'), true);
});
+6
View File
@@ -0,0 +1,6 @@
# Pure, JUCE-free unit test for the #403 audio-output containment helper
# (src/audio/AudioSanitize.h). Cross-platform — unlike the Win32-only sandbox
# tests, this builds and runs on Linux/macOS CI too.
add_executable(audio_sanitize_test test.cpp)
target_compile_features(audio_sanitize_test PRIVATE cxx_std_17)
add_test(NAME audio_sanitize COMMAND audio_sanitize_test)
+65
View File
@@ -0,0 +1,65 @@
// Unit test for slopsmith::sanitizeAudioBlock (issue #403 containment).
// JUCE-free and platform-independent — exit 0 = pass.
#include "../../src/audio/AudioSanitize.h"
#include <cassert>
#include <cstdio>
#include <limits>
using slopsmith::sanitizeAudioBlock;
static void test_clean_signal_untouched()
{
float buf[] = { 0.0f, 0.5f, -0.5f, 1.0f, -1.0f, 1.9f, -1.9f };
const int n = (int) (sizeof(buf) / sizeof(buf[0]));
const int fixed = sanitizeAudioBlock(buf, n);
assert(fixed == 0);
assert(buf[1] == 0.5f && buf[3] == 1.0f && buf[5] == 1.9f);
}
static void test_nan_and_inf_become_zero()
{
const float nan = std::numeric_limits<float>::quiet_NaN();
const float inf = std::numeric_limits<float>::infinity();
float buf[] = { nan, inf, -inf, 0.25f };
const int fixed = sanitizeAudioBlock(buf, 4);
assert(fixed == 3);
assert(buf[0] == 0.0f && buf[1] == 0.0f && buf[2] == 0.0f);
assert(buf[3] == 0.25f); // finite, in range — untouched
}
static void test_runaway_clamped_to_ceiling()
{
float buf[] = { 1e30f, -1e30f, 5.0f, -5.0f };
const int fixed = sanitizeAudioBlock(buf, 4, 2.0f);
assert(fixed == 4);
assert(buf[0] == 2.0f && buf[1] == -2.0f);
assert(buf[2] == 2.0f && buf[3] == -2.0f);
}
static void test_ceiling_boundary_inclusive()
{
// Exactly at the ceiling is in range (not > ceiling) — must be untouched.
float buf[] = { 2.0f, -2.0f };
const int fixed = sanitizeAudioBlock(buf, 2, 2.0f);
assert(fixed == 0);
assert(buf[0] == 2.0f && buf[1] == -2.0f);
}
static void test_empty_block_safe()
{
float* p = nullptr;
const int fixed = sanitizeAudioBlock(p, 0);
assert(fixed == 0);
}
int main()
{
test_clean_signal_untouched();
test_nan_and_inf_become_zero();
test_runaway_clamped_to_ceiling();
test_ceiling_boundary_inclusive();
test_empty_block_safe();
std::printf("audio_sanitize: all tests passed\n");
return 0;
}
+5
View File
@@ -0,0 +1,5 @@
# CMake build output
build/
# Generated click track (regenerable from make_calibration.py) plus local bass
# recordings / synthetic renders used for benching — all large binaries.
*.wav
+35
View File
@@ -0,0 +1,35 @@
# Standalone benchmark for the harmonic-comb verifier (ChordScorer +
# OnsetDetector) — the path the live app uses for guitar and bass scoring.
# Not part of the addon build; build explicitly to measure / tune it. Unlike
# mlnd_bench this needs NO ONNX Runtime — it links only JUCE.
#
# cmake -B build
# cmake --build build
# ./build/cs_bench <di-take.wav> <chart.txt> bass 4
# ./build/cs_bench <di-take.wav> <chart.txt> guitar 6 # regression guard
#
# cs_bench replays a DI recording against a known chart so ChordScorer /
# OnsetDetector parameter changes can be measured (recall / timing).
cmake_minimum_required(VERSION 3.22)
project(cs_bench CXX)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(REPO_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/../..")
add_subdirectory("${REPO_ROOT}/JUCE" juce_build EXCLUDE_FROM_ALL)
add_executable(cs_bench
bench.cpp
"${REPO_ROOT}/src/audio/ChordScorer.cpp"
"${REPO_ROOT}/src/audio/OnsetDetector.cpp")
target_include_directories(cs_bench PRIVATE "${REPO_ROOT}/src/audio")
target_compile_definitions(cs_bench PRIVATE
JUCE_STANDALONE_APPLICATION=1
JUCE_USE_CURL=0
JUCE_WEB_BROWSER=0)
target_link_libraries(cs_bench PRIVATE
juce::juce_core
juce::juce_audio_basics
juce::juce_dsp)
+395
View File
@@ -0,0 +1,395 @@
// Controlled benchmark for the harmonic-comb verifier (ChordScorer +
// OnsetDetector) — the path the live app actually uses for guitar AND bass
// (the renderer drives it via setChart with harmonicVerify/bypassMl, NOT the
// ML detector). mlnd_bench measures the unused ML path; THIS measures the real
// one, so a bass-tuning change to ChordScorer/OnsetDetector can be measured
// (recall / timing) instead of guessed from noisy live takes.
//
// It replays a DI recording the way NoteVerifier does: an OnsetDetector pass
// over the whole stream gives pick-attack times; for each chart note, the
// harmonic-comb (ChordScorer, harmonicVerify) is asked "is this pitch present"
// across its timing window, and a note ever-present is a hit. Timing for a hit
// comes from the nearest detected onset, mirroring NoteVerifier.
//
// Build: see CMakeLists.txt. Run:
// ./cs_bench <di-take.wav> <chart.txt> [arrangement] [stringCount] [channel]
// [harmonicSnr] [fundamentalRatio] [pitchCheckCents]
// chart.txt : one "<chartTimeSec> <midi> [sustainSec]" per line
// (jq from a note_detect diagnostic: .events[] | "\(.t) \(.ex)").
// arrangement : bass (default) | guitar
// stringCount : 4 (default for bass) | 5 | 6 | 7 | 8
// channel : mix (default) | left | right
// The verify knobs default to the per-arrangement values the plugin sends;
// pass them to sweep without a rebuild.
//
// The recording and the chart start at an unknown relative offset, so the
// harness searches the offset that best aligns them, then reports at it. Run
// the SAME WAV/chart through with `arrangement guitar` as a regression guard —
// all bass changes are arrangement-gated, so guitar numbers must not move.
#include "ChordScorer.h"
#include "OnsetDetector.h"
#include <algorithm>
#include <cmath>
#include <cstdint>
#include <cstdlib>
#include <cstring>
#include <fstream>
#include <iostream>
#include <sstream>
#include <string>
#include <vector>
namespace
{
uint32_t rdU32(const uint8_t* p) { return p[0] | (p[1]<<8) | (p[2]<<16) | (uint32_t(p[3])<<24); }
uint16_t rdU16(const uint8_t* p) { return uint16_t(p[0] | (p[1]<<8)); }
// Read a 16-bit PCM WAV to mono float. channel: 0 = mix, 1 = left, 2 = right.
// (Verbatim from tests/mlnotedetector/bench.cpp — the canonical reader.)
bool readWav(const std::string& path, int channelMode, std::vector<float>& out, int& sampleRate)
{
std::ifstream f(path, std::ios::binary);
if (!f) return false;
std::vector<uint8_t> buf((std::istreambuf_iterator<char>(f)), std::istreambuf_iterator<char>());
if (buf.size() < 44 || std::memcmp(buf.data(), "RIFF", 4) || std::memcmp(buf.data()+8, "WAVE", 4))
return false;
uint16_t fmt = 0, channels = 0, bits = 0;
uint32_t rate = 0, dataLen = 0;
const uint8_t* data = nullptr;
size_t pos = 12;
while (pos + 8 <= buf.size())
{
const char* id = reinterpret_cast<const char*>(buf.data() + pos);
const uint32_t sz = rdU32(buf.data() + pos + 4);
const uint8_t* body = buf.data() + pos + 8;
if (!std::memcmp(id, "fmt ", 4) && sz >= 16 && pos + 8 + 16 <= buf.size())
{ fmt = rdU16(body); channels = rdU16(body+2); rate = rdU32(body+4); bits = rdU16(body+14); }
else if (!std::memcmp(id, "data", 4))
{ data = body; dataLen = std::min<uint32_t>(sz, uint32_t(buf.size() - (pos + 8))); }
pos += 8 + sz + (sz & 1);
}
if (!data || channels == 0 || rate == 0 || fmt != 1 || bits != 16) return false;
sampleRate = int(rate);
const size_t frames = dataLen / (size_t(2) * channels);
out.resize(frames);
for (size_t i = 0; i < frames; ++i)
{
auto sample = [&](int c) -> double {
return int16_t(rdU16(data + (i * channels + c) * 2)) / 32768.0;
};
double v;
if (channelMode == 1) v = sample(0);
else if (channelMode == 2 && channels > 1) v = sample(1);
else { double a = 0; for (int c = 0; c < channels; ++c) a += sample(c); v = a / channels; }
out[i] = float(v);
}
return true;
}
struct ChartNote { double t; int midi; double sus; int string; int fret; };
// Map a MIDI pitch onto a (string, fret) for the given open-string tuning,
// preferring the lowest playable fret (highest string whose open note is at or
// below the pitch). Returns false when no string reaches it within 24 frets.
bool midiToStringFret(int midi, const std::vector<int>& base, int& outString, int& outFret)
{
int bestString = -1, bestFret = 0;
for (int s = (int) base.size() - 1; s >= 0; --s)
{
const int fret = midi - base[(size_t) s];
if (fret >= 0 && fret <= 24) { bestString = s; bestFret = fret; break; }
}
if (bestString < 0) return false;
outString = bestString;
outFret = bestFret;
return true;
}
} // namespace
int main(int argc, char** argv)
{
if (argc < 3)
{
std::cerr << "usage: cs_bench <di-take.wav> <chart.txt> [arrangement] [stringCount]"
" [mix|left|right] [harmonicSnr] [fundamentalRatio] [pitchCheckCents]\n";
return 2;
}
const std::string arrangement = (argc > 3) ? argv[3] : "bass";
int stringCount = (argc > 4) ? std::atoi(argv[4]) : (arrangement == "bass" ? 4 : 6);
int channelMode = 0;
if (argc > 5)
{
const std::string c = argv[5];
channelMode = (c == "left") ? 1 : (c == "right") ? 2 : 0;
}
// Per-arrangement verify defaults (overridable for sweeps). Bass relaxes the
// fundamental-presence gate (DI fundamental is weak) and widens the cents
// window (low bins resolve pitch coarsely); guitar keeps the shipped values.
const bool bass = (arrangement == "bass");
float harmonicSnr = (argc > 6) ? (float) std::atof(argv[6]) : (bass ? 2.0f : 3.0f);
float fundamentalRatio = (argc > 7) ? (float) std::atof(argv[7]) : (bass ? 0.08f : 0.20f);
float pitchCheckCents = (argc > 8) ? (float) std::atof(argv[8]) : (bass ? 60.0f : 50.0f);
const std::vector<int>* basePtr = ChordScorer::standardMidiFor(arrangement, stringCount);
if (!basePtr)
{ std::cerr << "FAIL: unsupported arrangement/stringCount " << arrangement << "/" << stringCount << "\n"; return 1; }
const std::vector<int>& base = *basePtr;
std::vector<float> wav;
int sampleRate = 0;
if (!readWav(argv[1], channelMode, wav, sampleRate))
{ std::cerr << "FAIL: cannot read 16-bit WAV " << argv[1] << "\n"; return 1; }
const double wavSec = double(wav.size()) / sampleRate;
std::cout << "WAV: " << wav.size() << " samples @ " << sampleRate << " Hz (" << wavSec << " s)\n";
std::cout << "scorer: arrangement=" << arrangement << " strings=" << stringCount
<< " snr=" << harmonicSnr << " fundamentalRatio=" << fundamentalRatio
<< " pitchCents=" << pitchCheckCents << "\n";
// --- Chart: <t> <midi> [sus]; map each pitch to (string,fret) -----------
std::vector<ChartNote> chart;
int unmapped = 0;
{
std::ifstream cf(argv[2]);
if (!cf) { std::cerr << "FAIL: cannot read chart " << argv[2] << "\n"; return 1; }
std::string line;
while (std::getline(cf, line))
{
std::istringstream is(line);
ChartNote n{};
n.sus = 0.0;
if (!(is >> n.t >> n.midi)) continue;
is >> n.sus; // optional
if (!midiToStringFret(n.midi, base, n.string, n.fret)) { ++unmapped; continue; }
chart.push_back(n);
}
}
std::cout << "chart: " << chart.size() << " notes";
if (unmapped) std::cout << " (" << unmapped << " out of tuning range, skipped)";
std::cout << "\n";
if (chart.empty()) { std::cerr << "FAIL: no playable chart notes\n"; return 1; }
// --- Onset pass over the whole WAV (NoteVerifier feeds it the input ring) -
OnsetDetector onset;
onset.prepare((double) sampleRate);
onset.setProfile(bass);
std::vector<double> onsetTimes; // seconds in WAV time
{
const int block = 256;
std::vector<OnsetDetector::Onset> os;
for (size_t i = 0; i < wav.size(); i += (size_t) block)
{
const size_t n = std::min<size_t>((size_t) block, wav.size() - i);
os.clear();
onset.process(wav.data() + i, n, i, os);
for (const auto& o : os) onsetTimes.push_back((double) o.sampleIndex / sampleRate);
}
}
std::sort(onsetTimes.begin(), onsetTimes.end());
std::cout << "onsets: " << onsetTimes.size() << "\n";
// --- Presence of one note at a given chart->WAV offset -------------------
// Mirror NoteVerifier's everPresent: the comb is asked across the note's
// timing window (onset .. onset+sustain), and ANY present tick is a hit.
ChordScorer scorer;
constexpr int kFrame = 4096; // matches NoteVerifier's getInputFrame(4096)
std::vector<float> frame((size_t) kFrame, 0.0f);
auto presentAt = [&](const ChartNote& cn, double audioTime, double spanCap) -> bool
{
// Walk a few analysis frames across the note's sounding span so a
// ringing bass note is caught even if its attack frame is marginal.
// Alignment passes spanCap=0 (attack frame only) so a long sustain scan
// can't blur the offset by catching a same-pitch neighbour late.
const double span = std::min(std::max(cn.sus, 0.0) + 0.10, std::max(spanCap, 0.0));
for (double off = 0.0; off <= span + 1e-9; off += 0.040)
{
const long long end = (long long) std::llround((audioTime + off) * sampleRate);
if (end <= 0 || end > (long long) wav.size()) continue;
const long long start = end - kFrame;
for (int k = 0; k < kFrame; ++k)
{
const long long idx = start + k;
frame[(size_t) k] = (idx >= 0 && idx < (long long) wav.size()) ? wav[(size_t) idx] : 0.0f;
}
ChordScorer::Request req;
req.numSamples = kFrame;
req.arrangement = arrangement;
req.stringCount = stringCount;
req.tuningOffsets.assign((size_t) stringCount, 0);
req.capo = 0;
req.pitchCheckCents = pitchCheckCents;
req.harmonicVerify = true;
req.harmonicSnr = harmonicSnr;
req.fundamentalRatio = fundamentalRatio;
ChordScorer::Note nt{};
nt.string = cn.string;
nt.fret = cn.fret;
req.notes.push_back(nt);
const auto r = scorer.scoreChord(frame.data(), kFrame, (double) sampleRate, req);
if (!r.results.empty() && r.results[0].hit) return true;
}
return false;
};
// Fraction of frames across the note's full span that register present —
// diagnostic for how ROBUSTLY a note is detected (1.0 = every frame hits,
// ~0 = a single marginal frame). A robust false-accept needs a stronger
// per-frame reject; a barely-present one would fall to a frame-fraction gate.
auto presentFrac = [&](const ChartNote& cn, double audioTime) -> double
{
const double span = std::max(cn.sus, 0.0) + 0.10;
int tot = 0, present = 0;
for (double off = 0.0; off <= span + 1e-9; off += 0.040)
{
const long long end = (long long) std::llround((audioTime + off) * sampleRate);
if (end <= 0 || end > (long long) wav.size()) continue;
const long long start = end - kFrame;
for (int k = 0; k < kFrame; ++k)
{
const long long idx = start + k;
frame[(size_t) k] = (idx >= 0 && idx < (long long) wav.size()) ? wav[(size_t) idx] : 0.0f;
}
ChordScorer::Request req;
req.numSamples = kFrame; req.arrangement = arrangement;
req.stringCount = stringCount; req.tuningOffsets.assign((size_t) stringCount, 0);
req.capo = 0; req.pitchCheckCents = pitchCheckCents; req.harmonicVerify = true;
req.harmonicSnr = harmonicSnr; req.fundamentalRatio = fundamentalRatio;
ChordScorer::Note nt{}; nt.string = cn.string; nt.fret = cn.fret;
req.notes.push_back(nt);
const auto r = scorer.scoreChord(frame.data(), kFrame, (double) sampleRate, req);
++tot;
if (!r.results.empty() && r.results[0].hit) ++present;
}
return tot ? (double) present / tot : 0.0;
};
// --- Align: coarse then fine search for the offset maximising hits -------
// The full presence scan is FFT-heavy, so the coarse pass samples up to 300
// evenly-spaced notes; the fine pass and the final report use all notes.
auto hitCount = [&](double delta, size_t stride) -> int
{
int h = 0;
for (size_t i = 0; i < chart.size(); i += stride)
if (presentAt(chart[i], chart[i].t + delta, 0.0)) ++h; // attack frame only
return h;
};
const size_t coarseStride = std::max<size_t>(1, chart.size() / 300);
// Pitch-INDEPENDENT alignment: how many chart note times land within tol of
// a detected onset. Unlike hitCount this does not look at pitch, so it finds
// the true count-in offset even on a wrong-position take (same rhythm, same
// click) — where the pitch-maximising search would instead slide the chart
// until wrong notes coincidentally line up and inflate recall.
auto onsetCoverage = [&](double delta) -> int
{
int c = 0;
for (const auto& cn : chart)
{
const double t = cn.t + delta;
for (double ot : onsetTimes)
if (std::fabs(ot - t) <= 0.10) { ++c; break; }
}
return c;
};
const bool alignOnset = std::getenv("CS_ALIGN_ONSET") != nullptr;
double bestDelta = 0.0;
int bestHits = -1;
// CS_OFFSET pins the chart->WAV offset instead of auto-aligning. The auto
// search maximises hits, which on a WRONG-position take slides the chart in
// time until wrong-pitch notes coincidentally line up — inflating recall.
// For the wrong-position precision test, pin every take to the correct
// take's count-in offset so wrong pitches are scored at the right *times*.
const char* offEnv = std::getenv("CS_OFFSET");
if (offEnv)
{
bestDelta = std::atof(offEnv);
}
else
{
for (double d = -2.0; d <= 5.0; d += 0.100)
{
const int h = alignOnset ? onsetCoverage(d) : hitCount(d, coarseStride);
if (h > bestHits) { bestHits = h; bestDelta = d; }
}
for (double d = bestDelta - 0.150; d <= bestDelta + 0.150; d += 0.010)
{
const int h = alignOnset ? onsetCoverage(d) : hitCount(d, 1);
if (h > bestHits) { bestHits = h; bestDelta = d; }
}
}
std::cout << "\n=== alignment ===\nchart->WAV offset: " << bestDelta << " s"
<< (offEnv ? " (pinned)" : "") << "\n";
// --- Report at the best offset ------------------------------------------
const double tol = 0.10; // ±100 ms timing-match window (mlnd_bench parity)
const bool verbose = std::getenv("CS_BENCH_VERBOSE") != nullptr;
int hits = 0;
std::vector<double> te; // timing errors of hits that claimed an onset
std::vector<const ChartNote*> missed;
std::vector<const ChartNote*> hitNotes;
// CS_FRAC: require the note present in at least this FRACTION of its frames
// (temporal persistence) instead of the default any-frame rule. Correct
// notes ring through ~70-100% of frames; wrong-position false-accepts flicker
// present in only a handful, so a persistence floor separates them.
const char* fracEnv = std::getenv("CS_FRAC");
const double fracThresh = fracEnv ? std::atof(fracEnv) : 0.0;
for (const auto& cn : chart)
{
const double audioT = cn.t + bestDelta;
// Full sounding span for the recall report — a sustained bass note that
// rings into its window is a legitimate hit.
const bool present = fracThresh > 0.0
? presentFrac(cn, audioT) >= fracThresh
: presentAt(cn, audioT, std::max(cn.sus, 0.0) + 0.10);
if (!present) { missed.push_back(&cn); continue; }
++hits;
hitNotes.push_back(&cn);
double best = 1e9;
for (double ot : onsetTimes)
if (std::fabs(ot - audioT) <= tol && std::fabs(ot - audioT) < std::fabs(best))
best = ot - audioT;
if (best < 1e8) te.push_back(best);
}
std::sort(te.begin(), te.end());
std::cout << "\n=== detection quality ===\n";
std::cout << "recall (present): " << hits << " / " << chart.size()
<< " (" << (100.0 * hits / (double) chart.size()) << "%)\n";
std::cout << "onset-timed hits: " << te.size() << " / " << hits
<< " (rest report on chart time — legato or no detected attack)\n";
if (!te.empty())
{
double sum = 0; for (double x : te) sum += x;
std::cout << "timing error: median " << (te[te.size()/2]*1000) << " ms"
<< " p10 " << (te[te.size()/10]*1000)
<< " p90 " << (te[te.size()*9/10]*1000)
<< " mean " << (sum/te.size()*1000) << " ms\n";
}
std::cout << "onsets/note: " << (double(onsetTimes.size()) / (double) chart.size())
<< " (>1 = extra attacks: noise, double-triggers)\n";
static const char* names[12] =
{"C","C#","D","D#","E","F","F#","G","G#","A","A#","B"};
auto dumpNote = [&](const ChartNote* cn) {
const int oct = cn->midi / 12 - 1;
std::cout << " t=" << (cn->t + bestDelta) << "s midi=" << cn->midi
<< " (" << names[cn->midi % 12] << oct << ")"
<< " str=" << cn->string << " fret=" << cn->fret
<< " sus=" << cn->sus << "s"
<< " frac=" << presentFrac(*cn, cn->t + bestDelta) << "\n";
};
if (verbose && !hitNotes.empty())
{
std::cout << "\n=== HIT notes (" << hitNotes.size() << ") ===\n";
for (const ChartNote* cn : hitNotes) dumpNote(cn);
}
if (verbose && !missed.empty())
{
std::cout << "\n=== missed notes (" << missed.size() << ") ===\n";
for (const ChartNote* cn : missed) dumpNote(cn);
}
return 0;
}
+58
View File
@@ -0,0 +1,58 @@
0.0000 28 0.600
0.6667 28 0.600
1.3333 33 0.600
2.0000 33 0.600
2.6667 38 0.600
3.3333 38 0.600
4.0000 43 0.600
4.6667 43 0.600
5.3333 28 0.333
6.0000 29 0.333
6.6667 30 0.333
7.3333 31 0.333
8.0000 33 0.333
8.6667 34 0.333
9.3333 35 0.333
10.0000 36 0.333
10.6667 38 0.333
11.3333 39 0.333
12.0000 40 0.333
12.6667 41 0.333
13.3333 43 0.333
14.0000 44 0.333
14.6667 45 0.333
15.3333 46 0.333
16.0000 28 2.667
18.6667 33 2.667
21.3333 38 2.667
24.0000 43 2.667
26.6667 28 0.300
27.0000 28 0.300
27.3333 28 0.300
27.6667 28 0.300
28.0000 28 0.300
28.3333 28 0.300
28.6667 28 0.300
29.0000 28 0.300
29.3333 33 0.300
29.6667 35 0.300
30.0000 37 0.300
30.3333 38 0.300
30.6667 40 0.300
31.0000 38 0.300
31.3333 37 0.300
31.6667 35 0.300
32.0000 28 0.300
32.3333 40 0.300
32.6667 28 0.300
33.0000 40 0.300
33.3333 28 0.300
33.6667 40 0.300
34.0000 28 0.300
34.3333 40 0.300
36.0000 23 1.333
38.0000 23 0.600
38.6667 23 0.333
39.3333 24 0.333
40.0000 25 0.333
40.6667 26 0.333
@@ -0,0 +1,153 @@
#!/usr/bin/env python3
"""Generate the bass calibration etude: a machine chart for cs_bench, a click
track to lock the player to the chart's timing, and a per-note table.
The chart's note times are absolute (90 BPM, 4/4). cs_bench only searches for a
single start-offset between the recording and the chart, NOT tempo — so the
player MUST play to click.wav (in headphones; the DI stays bass-only). With the
click, every note lands at chart-time + a constant capture latency, which the
offset search removes.
Outputs (in this directory):
chart.txt "<t_seconds> <midi> <sustain_seconds>" per line (cs_bench input)
click.wav 90 BPM click, 4-beat count-in, accented downbeats (play along)
notes.csv t_s, beat, section, string, fret, midi, sus_s, note (for the score)
"""
import csv
import math
import struct
import wave
from pathlib import Path
# Anchor outputs next to this script (as the module docstring promises),
# independent of the caller's working directory.
OUT_DIR = Path(__file__).resolve().parent
BPM = 90
SR = 48000
BEAT = 60.0 / BPM # 0.6667 s
EIGHTH = BEAT / 2.0
COUNT_IN_BEATS = 4 # clicks before the first played note
# 4-string standard tuning, low->high (string index 0 = low E).
TUNING4 = [28, 33, 38, 43] # E1 A1 D2 G2
TUNING_NAMES = ["E", "A", "D", "G"]
def string_fret(midi, tuning):
"""Lowest-fret (string, fret) realising `midi`, or (None, None)."""
for s in range(len(tuning) - 1, -1, -1):
fret = midi - tuning[s]
if 0 <= fret <= 24:
return s, fret
return None, None
# (beat_offset_from_first_note, midi, sustain_beats, section, technique)
events = []
def add(beat, midi, sus_beats, section, tech=""):
events.append((beat, midi, sus_beats, section, tech))
# ── A: open strings (presence + tuning baseline) — quarter notes ────────────
for i, m in enumerate([28, 28, 33, 33]):
add(0 + i, m, 0.9, "A: open strings", "let ring")
for i, m in enumerate([38, 38, 43, 43]):
add(4 + i, m, 0.9, "A: open strings", "let ring")
# ── B: chromatic walk frets 0-3 on each string (pitch precision low range) ──
for s, lo in enumerate([28, 33, 38, 43]):
for f in range(4):
add(8 + s * 4 + f, lo + f, 0.5, "B: chromatic walk", "")
# ── C: long sustains (sustain / ring presence) — whole notes ────────────────
for i, m in enumerate([28, 33, 38, 43]):
add(24 + i * 4, m, 4.0, "C: sustains", "let ring full bar")
# ── D: fast eighth-note runs (onset density) ────────────────────────────────
# repeated open E
for i in range(8):
add(40 + i * 0.5, 28, EIGHTH / BEAT * 0.9, "D: fast eighths", "open E x8")
# A-string melodic eighths (A B C# D E D C# B)
for i, m in enumerate([33, 35, 37, 38, 40, 38, 37, 35]):
add(44 + i * 0.5, m, EIGHTH / BEAT * 0.9, "D: fast eighths", "A walk")
# octave jumps E1<->E2
for i in range(8):
add(48 + i * 0.5, 28 if i % 2 == 0 else 40, EIGHTH / BEAT * 0.9,
"D: fast eighths", "octave jump")
LAST_4STRING_BEAT = 52.0 # everything above is playable on a 4-string
# ── E (OPTIONAL, 5-string only): low B section — skip on a 4-string ─────────
add(54, 23, 2.0, "E: low B (5-string only)", "open low B, let ring")
add(57, 23, 0.9, "E: low B (5-string only)", "")
for i, m in enumerate([23, 24, 25, 26]):
add(58 + i, m, 0.5, "E: low B (5-string only)", "B string frets 0-3")
events.sort(key=lambda e: e[0])
# ── Write the machine chart + notes table ───────────────────────────────────
with open(OUT_DIR / "chart.txt", "w") as cf, \
open(OUT_DIR / "notes.csv", "w", newline="") as nf:
w = csv.writer(nf)
w.writerow(["t_s", "beat", "section", "string", "fret", "midi", "sus_s", "note"])
for beat, midi, sus_beats, section, tech in events:
t = beat * BEAT
sus = sus_beats * BEAT
cf.write(f"{t:.4f} {midi} {sus:.3f}\n")
# tab string/fret only meaningful for the standard 4-string set here;
# the low-B section maps onto a 5-string B string (index -1 → "B").
s, fr = string_fret(midi, TUNING4)
sname = TUNING_NAMES[s] if s is not None else "B"
if s is None: # below E1 → 5-string B string
fr = midi - 23
w.writerow([f"{t:.3f}", f"{beat:g}", section, sname, fr, midi,
f"{sus:.2f}", tech])
# ── Render the click track ──────────────────────────────────────────────────
last_beat = max(e[0] for e in events)
total_beats = COUNT_IN_BEATS + last_beat + 4 # tail so the last note rings
nsamp = int(total_beats * BEAT * SR)
buf = [0.0] * nsamp
def ping(start_s, freq, ms=35, gain=0.5):
n = int(ms / 1000.0 * SR)
s0 = int(start_s * SR)
for i in range(n):
if s0 + i >= nsamp:
break
env = math.exp(-i / (0.010 * SR)) # fast decay click
buf[s0 + i] += gain * env * math.sin(2 * math.pi * freq * i / SR)
# Count-in clicks, then one click per beat; accent (higher) on each bar's beat 1.
first_note_at = COUNT_IN_BEATS * BEAT
beat_idx = 0
t = 0.0
while t < total_beats * BEAT:
# beats relative to the first played note; downbeat = multiple of 4
rel = beat_idx - COUNT_IN_BEATS
downbeat = (rel % 4 == 0)
in_countin = beat_idx < COUNT_IN_BEATS
freq = 1760.0 if (downbeat or in_countin) else 1175.0
gain = 0.6 if (downbeat or in_countin) else 0.35
ping(t, freq, gain=gain)
beat_idx += 1
t = beat_idx * BEAT
pcm = b"".join(struct.pack("<h", max(-32767, min(32767, int(v * 32767))))
for v in buf)
with wave.open(str(OUT_DIR / "click.wav"), "wb") as wv:
wv.setnchannels(1)
wv.setsampwidth(2)
wv.setframerate(SR)
wv.writeframes(pcm)
print(f"chart.txt: {len(events)} notes, {last_beat * BEAT:.1f}s of music")
print(f"click.wav: {total_beats * BEAT:.1f}s, {BPM} BPM, {COUNT_IN_BEATS}-beat count-in, "
f"first note at {first_note_at:.2f}s")
print(f"4-string players: stop after Section D ({LAST_4STRING_BEAT * BEAT:.1f}s "
f"+ ring); Section E is 5-string-only.")
+59
View File
@@ -0,0 +1,59 @@
t_s,beat,section,string,fret,midi,sus_s,note
0.000,0,A: open strings,E,0,28,0.60,let ring
0.667,1,A: open strings,E,0,28,0.60,let ring
1.333,2,A: open strings,A,0,33,0.60,let ring
2.000,3,A: open strings,A,0,33,0.60,let ring
2.667,4,A: open strings,D,0,38,0.60,let ring
3.333,5,A: open strings,D,0,38,0.60,let ring
4.000,6,A: open strings,G,0,43,0.60,let ring
4.667,7,A: open strings,G,0,43,0.60,let ring
5.333,8,B: chromatic walk,E,0,28,0.33,
6.000,9,B: chromatic walk,E,1,29,0.33,
6.667,10,B: chromatic walk,E,2,30,0.33,
7.333,11,B: chromatic walk,E,3,31,0.33,
8.000,12,B: chromatic walk,A,0,33,0.33,
8.667,13,B: chromatic walk,A,1,34,0.33,
9.333,14,B: chromatic walk,A,2,35,0.33,
10.000,15,B: chromatic walk,A,3,36,0.33,
10.667,16,B: chromatic walk,D,0,38,0.33,
11.333,17,B: chromatic walk,D,1,39,0.33,
12.000,18,B: chromatic walk,D,2,40,0.33,
12.667,19,B: chromatic walk,D,3,41,0.33,
13.333,20,B: chromatic walk,G,0,43,0.33,
14.000,21,B: chromatic walk,G,1,44,0.33,
14.667,22,B: chromatic walk,G,2,45,0.33,
15.333,23,B: chromatic walk,G,3,46,0.33,
16.000,24,C: sustains,E,0,28,2.67,let ring full bar
18.667,28,C: sustains,A,0,33,2.67,let ring full bar
21.333,32,C: sustains,D,0,38,2.67,let ring full bar
24.000,36,C: sustains,G,0,43,2.67,let ring full bar
26.667,40,D: fast eighths,E,0,28,0.30,open E x8
27.000,40.5,D: fast eighths,E,0,28,0.30,open E x8
27.333,41,D: fast eighths,E,0,28,0.30,open E x8
27.667,41.5,D: fast eighths,E,0,28,0.30,open E x8
28.000,42,D: fast eighths,E,0,28,0.30,open E x8
28.333,42.5,D: fast eighths,E,0,28,0.30,open E x8
28.667,43,D: fast eighths,E,0,28,0.30,open E x8
29.000,43.5,D: fast eighths,E,0,28,0.30,open E x8
29.333,44,D: fast eighths,A,0,33,0.30,A walk
29.667,44.5,D: fast eighths,A,2,35,0.30,A walk
30.000,45,D: fast eighths,A,4,37,0.30,A walk
30.333,45.5,D: fast eighths,D,0,38,0.30,A walk
30.667,46,D: fast eighths,D,2,40,0.30,A walk
31.000,46.5,D: fast eighths,D,0,38,0.30,A walk
31.333,47,D: fast eighths,A,4,37,0.30,A walk
31.667,47.5,D: fast eighths,A,2,35,0.30,A walk
32.000,48,D: fast eighths,E,0,28,0.30,octave jump
32.333,48.5,D: fast eighths,D,2,40,0.30,octave jump
32.667,49,D: fast eighths,E,0,28,0.30,octave jump
33.000,49.5,D: fast eighths,D,2,40,0.30,octave jump
33.333,50,D: fast eighths,E,0,28,0.30,octave jump
33.667,50.5,D: fast eighths,D,2,40,0.30,octave jump
34.000,51,D: fast eighths,E,0,28,0.30,octave jump
34.333,51.5,D: fast eighths,D,2,40,0.30,octave jump
36.000,54,E: low B (5-string only),B,0,23,1.33,"open low B, let ring"
38.000,57,E: low B (5-string only),B,0,23,0.60,
38.667,58,E: low B (5-string only),B,0,23,0.33,B string frets 0-3
39.333,59,E: low B (5-string only),B,1,24,0.33,B string frets 0-3
40.000,60,E: low B (5-string only),B,2,25,0.33,B string frets 0-3
40.667,61,E: low B (5-string only),B,3,26,0.33,B string frets 0-3
1 t_s beat section string fret midi sus_s note
2 0.000 0 A: open strings E 0 28 0.60 let ring
3 0.667 1 A: open strings E 0 28 0.60 let ring
4 1.333 2 A: open strings A 0 33 0.60 let ring
5 2.000 3 A: open strings A 0 33 0.60 let ring
6 2.667 4 A: open strings D 0 38 0.60 let ring
7 3.333 5 A: open strings D 0 38 0.60 let ring
8 4.000 6 A: open strings G 0 43 0.60 let ring
9 4.667 7 A: open strings G 0 43 0.60 let ring
10 5.333 8 B: chromatic walk E 0 28 0.33
11 6.000 9 B: chromatic walk E 1 29 0.33
12 6.667 10 B: chromatic walk E 2 30 0.33
13 7.333 11 B: chromatic walk E 3 31 0.33
14 8.000 12 B: chromatic walk A 0 33 0.33
15 8.667 13 B: chromatic walk A 1 34 0.33
16 9.333 14 B: chromatic walk A 2 35 0.33
17 10.000 15 B: chromatic walk A 3 36 0.33
18 10.667 16 B: chromatic walk D 0 38 0.33
19 11.333 17 B: chromatic walk D 1 39 0.33
20 12.000 18 B: chromatic walk D 2 40 0.33
21 12.667 19 B: chromatic walk D 3 41 0.33
22 13.333 20 B: chromatic walk G 0 43 0.33
23 14.000 21 B: chromatic walk G 1 44 0.33
24 14.667 22 B: chromatic walk G 2 45 0.33
25 15.333 23 B: chromatic walk G 3 46 0.33
26 16.000 24 C: sustains E 0 28 2.67 let ring full bar
27 18.667 28 C: sustains A 0 33 2.67 let ring full bar
28 21.333 32 C: sustains D 0 38 2.67 let ring full bar
29 24.000 36 C: sustains G 0 43 2.67 let ring full bar
30 26.667 40 D: fast eighths E 0 28 0.30 open E x8
31 27.000 40.5 D: fast eighths E 0 28 0.30 open E x8
32 27.333 41 D: fast eighths E 0 28 0.30 open E x8
33 27.667 41.5 D: fast eighths E 0 28 0.30 open E x8
34 28.000 42 D: fast eighths E 0 28 0.30 open E x8
35 28.333 42.5 D: fast eighths E 0 28 0.30 open E x8
36 28.667 43 D: fast eighths E 0 28 0.30 open E x8
37 29.000 43.5 D: fast eighths E 0 28 0.30 open E x8
38 29.333 44 D: fast eighths A 0 33 0.30 A walk
39 29.667 44.5 D: fast eighths A 2 35 0.30 A walk
40 30.000 45 D: fast eighths A 4 37 0.30 A walk
41 30.333 45.5 D: fast eighths D 0 38 0.30 A walk
42 30.667 46 D: fast eighths D 2 40 0.30 A walk
43 31.000 46.5 D: fast eighths D 0 38 0.30 A walk
44 31.333 47 D: fast eighths A 4 37 0.30 A walk
45 31.667 47.5 D: fast eighths A 2 35 0.30 A walk
46 32.000 48 D: fast eighths E 0 28 0.30 octave jump
47 32.333 48.5 D: fast eighths D 2 40 0.30 octave jump
48 32.667 49 D: fast eighths E 0 28 0.30 octave jump
49 33.000 49.5 D: fast eighths D 2 40 0.30 octave jump
50 33.333 50 D: fast eighths E 0 28 0.30 octave jump
51 33.667 50.5 D: fast eighths D 2 40 0.30 octave jump
52 34.000 51 D: fast eighths E 0 28 0.30 octave jump
53 34.333 51.5 D: fast eighths D 2 40 0.30 octave jump
54 36.000 54 E: low B (5-string only) B 0 23 1.33 open low B, let ring
55 38.000 57 E: low B (5-string only) B 0 23 0.60
56 38.667 58 E: low B (5-string only) B 0 23 0.33 B string frets 0-3
57 39.333 59 E: low B (5-string only) B 1 24 0.33 B string frets 0-3
58 40.000 60 E: low B (5-string only) B 2 25 0.33 B string frets 0-3
59 40.667 61 E: low B (5-string only) B 3 26 0.33 B string frets 0-3
+95
View File
@@ -0,0 +1,95 @@
# Bass detection calibration etude
A ~41-second exercise that stresses every bass weak-spot in the detector: open
low strings (weak fundamentals), a chromatic walk (pitch precision across the
low range), long sustains, a fast eighth-note run (onset density), octave jumps,
and an optional 5-string low-B section.
Play it **locked to `click.wav`** so your notes line up with the reference
chart — the scorer only corrects a single start-offset, not tempo drift.
---
## Recording setup (please follow these)
- **Clean DI only.** Bass → interface, *before* any amp sim / NAM / pedals /
EQ. This is what the detector taps.
- **Play to the click in headphones.** Open `click.wav` in your DAW/player and
monitor it in headphones; keep it out of the recorded DI (a tiny bit of bleed
is harmless — the click is high-pitched and won't match any bass note).
- **Tempo:** 90 BPM, 4/4. The click has a **4-beat count-in**; the **first note
lands on the next downbeat** (≈2.67 s into `click.wav`).
- **Record two passes**, same etude, so I can tune for both attacks:
- `bass_finger.wav` — fingerstyle
- `bass_pick.wav` — with a pick
- Format: WAV, **16-bit PCM**, 48 kHz preferred (44.1/96 fine), mono or stereo.
- Let sustained/ringing notes **ring fully** — don't mute early.
- **4-string bass:** stop after Section D (~35 s). **5-string:** continue into
Section E.
When done, drop the WAV(s) anywhere and tell me the path — I'll run the bench.
---
## The etude
Notation: bass tab, one digit = fret on that string. `·` = that string silent.
"q" = quarter note (one per click), "8th" = two per click. Let ring unless noted.
### Section A — open strings (bars 23, quarter notes)
Pluck each open string twice; **let every note ring**.
```text
Bar 2: E(open) E(open) A(open) A(open)
Bar 3: D(open) D(open) G(open) G(open)
```
### Section B — chromatic walk, frets 03 (bars 47, quarter notes)
One note per click, up each string:
```text
E string: 0 1 2 3
A string: 0 1 2 3
D string: 0 1 2 3
G string: 0 1 2 3
```
### Section C — sustains (bars 811, whole notes — let ring the full bar)
One open string per bar, held for all 4 beats:
```text
E (open) ——ring—— | A (open) ——ring—— | D (open) ——ring—— | G (open) ——ring——
```
### Section D — fast eighth notes (bars 1214, two notes per click)
Bar 12 — open **E** eighths ×8 (steady, even):
```text
E| 0 0 0 0 0 0 0 0
```
Bar 13 — **A-string walk** eighths (A B C# D E D C# B):
```text
A| 0 2 4 · · · 4 2
D| · · · 0 2 0 · ·
```
Bar 14 — **octave jumps** E1↔E2 eighths (alternate, ×8):
```text
D| · 2 · 2 · 2 · 2
E| 0 · 0 · 0 · 0 ·
```
### Section E — low B (5-STRING ONLY — 4-string players skip)
Open low **B** ring, then a B-string chromatic 03 (B C C# D):
```text
B (open) ——ring—— , B(0) , B: 0 1 2 3
```
---
## What I do with it
```bash
cd tests/chordscorer
cmake --build build --target cs_bench
./build/cs_bench <bass_finger.wav> calibration/chart.txt bass 4 # or "bass 5"
./build/cs_bench <bass_pick.wav> calibration/chart.txt bass 5
```
The chart (`chart.txt`) is `"<time_s> <midi> <sustain_s>"` per line, generated
by `make_calibration.py`. I sweep `fundamentalRatio` / onset profile / cents
against your real take, then re-run the **guitar** bench on `di-take.wav` to
prove zero guitar regression.
@@ -0,0 +1,41 @@
# Bass calibration — wrong-position takes (precision / false-accept test)
The first calibration round measured **recall** (do we detect correctly-played
notes?). This round measures **precision** — specifically, do we correctly
*reject* notes played in the **wrong position**? Testers reported ~65% "hits"
while playing 2 frets off, where the honest answer is ~0%.
Record these **three** passes of the **same etude** in `score.md`, all locked to
the **same `click.wav`**, clean DI (before any amp sim/NAM/pedals), 16-bit PCM
WAV, 48 kHz preferred:
| File | How to play it | Expected score |
|-----------------------|------------------------------------------------------------|----------------|
| `bass_correct.wav` | Exactly as written (the right frets) | **high** (recall) |
| `bass_2up.wav` | Every note **+2 frets** (whole step **up**), same rhythm | **~0%** (reject) |
| `bass_2down.wav` | Every note **2 frets** (whole step **down**), same rhythm | **~0%** (reject) |
Notes:
- Keep the **rhythm/timing identical** to the click — only the fretting hand
moves +2 / 2. (On a 4-string, 2 from the open low E isn't playable; just
hold/skip those few open-E notes in the `bass_2down` take — the rest is what
matters.)
- A "correct" take here is the precision baseline (high recall); the two
wrong-position takes are the false-accept test (should score near zero).
- Same recording rules as `score.md` (DI only, play to the click in headphones,
let notes ring, 4-string stop after Section D / 5-string continue to E).
## What it's for
The detector is tuned to chase the result of `cs_bench` on all three:
```bash
./build/cs_bench bass_correct.wav calibration/chart.txt bass # want HIGH recall
./build/cs_bench bass_2up.wav calibration/chart.txt bass # want ~0% (reject)
./build/cs_bench bass_2down.wav calibration/chart.txt bass # want ~0% (reject)
```
The goal is to keep the recall we gained while driving the wrong-position
"recall" toward zero — i.e. a real pitch-discrimination gate, not just a looser
SNR. (The original torture etude only ever played *correct* notes, which is the
blind spot that let the false-accepts ship.)
+121
View File
@@ -0,0 +1,121 @@
// Integration smoke test for the ML note detection addon path.
//
// Loads the built native addon directly (no Electron), verifies the Basic
// Pitch model loads through loadNoteModel(), that isMlNoteDetection() reports
// the ML path, that getPitchDetection()/scoreChord() keep their shapes, and
// that a bad model path fails soft to the YIN fallback (Constitution VII).
//
// Run: node --test tests/ml-note-detection.test.js
// Skips cleanly when the addon hasn't been built yet (npm run build:audio).
//
// WAV→MIDI detection accuracy is covered separately by the Phase 0 spike
// (tests/spike/) which exercises the identical model + ONNX Runtime.
const test = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const fs = require('node:fs');
const ADDON = path.join(__dirname, '..', 'build', 'Release', 'slopsmith_audio.node');
const MODEL = path.join(__dirname, '..', 'resources', 'models', 'basic_pitch.onnx');
let audio = null;
try {
audio = require(ADDON);
} catch (e) {
if (!fs.existsSync(ADDON)) {
// Addon genuinely not built yet — skip cleanly.
test('ml-note-detection (skipped — addon not built)', { skip: true }, () => {});
} else {
// The addon file IS present but failed to load — a real staging
// regression (e.g. a missing/incompatible ONNX Runtime library next
// to it). Surface it as a failure so this smoke test catches it.
test('ml-note-detection addon loads', () => { throw e; });
}
}
if (audio) {
test('ML note detection addon path', async (t) => {
audio.init();
t.after(() => { try { audio.shutdown(); } catch { /* ignore */ } });
await t.test('loadNoteModel loads the bundled Basic Pitch model', () => {
assert.equal(typeof audio.loadNoteModel, 'function',
'addon exposes loadNoteModel');
assert.ok(fs.existsSync(MODEL), `model present at ${MODEL}`);
const ok = audio.loadNoteModel(MODEL);
// ok is false if ONNX support was compiled out — tolerate that.
assert.equal(typeof ok, 'boolean');
// isMlNoteDetection() reports *readiness* — that the detector has
// published an inference snapshot — not merely that a model
// loaded. Readiness needs a running audio device, which this
// smoke test never starts, so it is false here regardless of the
// load result (and also false when ONNX is compiled out).
assert.equal(audio.isMlNoteDetection(), false,
'isMlNoteDetection is false until the detector has run inference');
});
await t.test('loadNoteModel fails soft on a missing file', () => {
// loadNoteModel returns "is ML available after this call" (a model
// is loaded with a valid contract). A missing file never throws
// and never tears down a model that was already loaded, so it
// returns a boolean and leaves detector readiness unchanged.
const readyBefore = audio.isMlNoteDetection();
const ok = audio.loadNoteModel(path.join(__dirname, 'no-such-model.onnx'));
assert.equal(typeof ok, 'boolean', 'missing model returns a boolean, does not throw');
assert.equal(audio.isMlNoteDetection(), readyBefore,
'a failed load must not change detector readiness');
});
await t.test('getPitchDetection keeps its shape', () => {
const d = audio.getPitchDetection();
assert.equal(typeof d, 'object');
for (const k of ['frequency', 'confidence', 'midiNote', 'cents', 'noteName'])
assert.ok(k in d, `detection has ${k}`);
});
await t.test('getRawAudioFrame returns a sized Float32Array', () => {
assert.equal(typeof audio.getRawAudioFrame, 'function',
'addon exposes getRawAudioFrame');
// No device running, so the post-gate ring is empty: cold-start
// returns a zero-filled frame of exactly the requested length.
const frame = audio.getRawAudioFrame(2048);
assert.ok(frame instanceof Float32Array, 'returns a Float32Array');
assert.equal(frame.length, 2048, 'honours the requested sample count');
// Default (no arg) matches the 4096 default.
assert.equal(audio.getRawAudioFrame().length, 4096, 'defaults to 4096 samples');
// Over-large requests clamp to the ring capacity (16384), never throw.
assert.equal(audio.getRawAudioFrame(1 << 20).length, 16384,
'clamps to the ring capacity');
// Non-positive requests yield an empty frame.
assert.equal(audio.getRawAudioFrame(0).length, 0, 'zero samples → empty');
});
await t.test('scoreChord keeps its shape (no device running)', () => {
const res = audio.scoreChord({
arrangement: 'guitar',
stringCount: 6,
offsets: [0, 0, 0, 0, 0, 0],
notes: [{ s: 0, f: 3 }, { s: 1, f: 2 }, { s: 2, f: 0 }],
});
assert.ok(res && typeof res === 'object', 'scoreChord returns an object');
for (const k of ['score', 'hitStrings', 'totalStrings', 'isHit', 'results'])
assert.ok(k in res, `result has ${k}`);
assert.equal(res.results.length, 3, 'one result entry per note');
});
await t.test('detectNotes returns the polyphonic shape or null', () => {
assert.equal(typeof audio.detectNotes, 'function',
'addon exposes detectNotes');
const res = audio.detectNotes();
// null when ONNX support is compiled out / no model — otherwise a
// { notes: [], sampleRate } object.
if (res !== null) {
assert.ok('notes' in res && Array.isArray(res.notes),
'detectNotes result has a notes array');
assert.equal(typeof res.sampleRate, 'number',
'detectNotes result has a numeric sampleRate');
}
});
});
}
+74
View File
@@ -0,0 +1,74 @@
# Standalone native test + benchmark for MlNoteDetector. Not part of the
# addon build — build explicitly to verify / tune the ML detector.
#
# cmake -B build -DONNXRUNTIME_ROOT=/path/to/onnxruntime-<os-arch>-<ver>
# cmake --build build
# ./build/mlnd_test ../../resources/models/basic_pitch.onnx ../spike/test_guitar.wav
# ./build/mlnd_bench ../../resources/models/basic_pitch.onnx <di-take.wav> <chart.txt>
#
# mlnd_bench replays a fixed DI recording against a known chart so detector
# parameter changes can be measured (recall / timing) instead of guessed.
cmake_minimum_required(VERSION 3.22)
project(mlnd_test CXX)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
if(NOT DEFINED ONNXRUNTIME_ROOT)
message(FATAL_ERROR "Set -DONNXRUNTIME_ROOT=/path/to/onnxruntime-<os>-<arch>-<ver>")
endif()
set(REPO_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/../..")
add_subdirectory("${REPO_ROOT}/JUCE" juce_build EXCLUDE_FROM_ALL)
# ONNX Runtime ships a platform-specific library file name — resolve it so
# the documented ONNXRUNTIME_ROOT build works on macOS and Windows too.
if(WIN32)
set(ONNXRUNTIME_LIB "${ONNXRUNTIME_ROOT}/lib/onnxruntime.lib")
elseif(APPLE)
set(ONNXRUNTIME_LIB "${ONNXRUNTIME_ROOT}/lib/libonnxruntime.dylib")
else()
set(ONNXRUNTIME_LIB "${ONNXRUNTIME_ROOT}/lib/libonnxruntime.so")
endif()
foreach(tgt mlnd_test mlnd_bench)
if(tgt STREQUAL mlnd_test)
set(_src test.cpp)
else()
set(_src bench.cpp)
endif()
add_executable(${tgt} ${_src} "${REPO_ROOT}/src/audio/MlNoteDetector.cpp")
target_include_directories(${tgt} PRIVATE
"${REPO_ROOT}/src/audio"
"${ONNXRUNTIME_ROOT}/include")
target_compile_definitions(${tgt} PRIVATE
SLOPSMITH_ONNX_SUPPORT=1
JUCE_STANDALONE_APPLICATION=1
JUCE_USE_CURL=0
JUCE_WEB_BROWSER=0)
target_link_libraries(${tgt} PRIVATE
juce::juce_core
juce::juce_audio_basics
"${ONNXRUNTIME_LIB}")
set_target_properties(${tgt} PROPERTIES
BUILD_RPATH "${ONNXRUNTIME_ROOT}/lib")
# Windows ignores RPATH — copy the ONNX Runtime DLL next to the test
# executable so the documented `./build/mlnd_test ...` run can load it.
if(WIN32)
add_custom_command(TARGET ${tgt} POST_BUILD
COMMAND ${CMAKE_COMMAND} -E copy_if_different
"${ONNXRUNTIME_ROOT}/lib/onnxruntime.dll"
"$<TARGET_FILE_DIR:${tgt}>")
# ONNX Runtime LoadLibrary's the providers_shared stub from the
# runtime directory during session creation — stage it next to the
# test exe too, matching the addon (src/audio/CMakeLists.txt). Guard:
# not every ONNX Runtime layout ships this secondary DLL.
if(EXISTS "${ONNXRUNTIME_ROOT}/lib/onnxruntime_providers_shared.dll")
add_custom_command(TARGET ${tgt} POST_BUILD
COMMAND ${CMAKE_COMMAND} -E copy_if_different
"${ONNXRUNTIME_ROOT}/lib/onnxruntime_providers_shared.dll"
"$<TARGET_FILE_DIR:${tgt}>")
endif()
endif()
endforeach()
+314
View File
@@ -0,0 +1,314 @@
// Controlled detection benchmark for MlNoteDetector.
//
// Replays a fixed DI recording through the REAL MlNoteDetector and scores the
// detected onsets against a known note chart — so a parameter change can be
// measured (recall / timing) instead of guessed from noisy live takes.
//
// Build: see CMakeLists.txt. Run:
// ./mlnd_bench <model.onnx> <di-take.wav> <chart.txt> [channel]
// chart.txt : one "<chartTimeSec> <midi>" per line (jq-extracted from a
// note_detect diagnostic: .events[] | "\(.t) \(.ex)").
// channel : mix (default) | left | right
//
// The recording and the chart start at unknown relative offsets, so the
// harness searches for the time offset that best aligns them, then reports
// recall and the timing-error distribution at that offset.
#include "MlNoteDetector.h"
#include <algorithm>
#include <chrono>
#include <cmath>
#include <cstdint>
#include <cstring>
#include <fstream>
#include <iostream>
#include <map>
#include <sstream>
#include <string>
#include <thread>
#include <vector>
namespace
{
uint32_t rdU32(const uint8_t* p) { return p[0] | (p[1]<<8) | (p[2]<<16) | (uint32_t(p[3])<<24); }
uint16_t rdU16(const uint8_t* p) { return uint16_t(p[0] | (p[1]<<8)); }
// Read a 16-bit PCM WAV to mono float. channel: 0 = mix, 1 = left, 2 = right.
bool readWav(const std::string& path, int channelMode, std::vector<float>& out, int& sampleRate)
{
std::ifstream f(path, std::ios::binary);
if (!f) return false;
std::vector<uint8_t> buf((std::istreambuf_iterator<char>(f)), std::istreambuf_iterator<char>());
if (buf.size() < 44 || std::memcmp(buf.data(), "RIFF", 4) || std::memcmp(buf.data()+8, "WAVE", 4))
return false;
uint16_t fmt = 0, channels = 0, bits = 0;
uint32_t rate = 0, dataLen = 0;
const uint8_t* data = nullptr;
size_t pos = 12;
while (pos + 8 <= buf.size())
{
const char* id = reinterpret_cast<const char*>(buf.data() + pos);
const uint32_t sz = rdU32(buf.data() + pos + 4);
const uint8_t* body = buf.data() + pos + 8;
// Guard the fmt-body reads (up to body+14, i.e. 16 bytes) against a
// truncated file: a declared sz >= 16 doesn't mean 16 bytes are
// actually present.
if (!std::memcmp(id, "fmt ", 4) && sz >= 16 && pos + 8 + 16 <= buf.size())
{ fmt = rdU16(body); channels = rdU16(body+2); rate = rdU32(body+4); bits = rdU16(body+14); }
else if (!std::memcmp(id, "data", 4))
{ data = body; dataLen = std::min<uint32_t>(sz, uint32_t(buf.size() - (pos + 8))); }
pos += 8 + sz + (sz & 1);
}
if (!data || channels == 0 || rate == 0 || fmt != 1 || bits != 16) return false;
sampleRate = int(rate);
const size_t frames = dataLen / (size_t(2) * channels);
out.resize(frames);
for (size_t i = 0; i < frames; ++i)
{
auto sample = [&](int c) -> double {
return int16_t(rdU16(data + (i * channels + c) * 2)) / 32768.0;
};
double v;
if (channelMode == 1) v = sample(0);
else if (channelMode == 2 && channels > 1) v = sample(1);
else { double a = 0; for (int c = 0; c < channels; ++c) a += sample(c); v = a / channels; }
out[i] = float(v);
}
return true;
}
struct ChartNote { double t; int midi; };
struct Onset { double t; int midi; float conf; };
// One bridge poll: the full active-note set, mirroring audio.detectNotes().
struct PollRec { double t; std::vector<MlNoteDetector::ActiveNote> notes; };
} // namespace
int main(int argc, char** argv)
{
if (argc < 4)
{
std::cerr << "usage: mlnd_bench <model.onnx> <di-take.wav> <chart.txt> [mix|left|right]\n";
return 2;
}
int channelMode = 0;
if (argc > 4)
{
const std::string c = argv[4];
channelMode = (c == "left") ? 1 : (c == "right") ? 2 : 0;
}
std::vector<float> wav;
int sampleRate = 0;
if (!readWav(argv[2], channelMode, wav, sampleRate))
{ std::cerr << "FAIL: cannot read 16-bit WAV " << argv[2] << "\n"; return 1; }
const double wavSec = double(wav.size()) / sampleRate;
std::cout << "WAV: " << wav.size() << " samples @ " << sampleRate << " Hz ("
<< wavSec << " s), channel=" << (channelMode==1?"left":channelMode==2?"right":"mix") << "\n";
std::vector<ChartNote> chart;
{
std::ifstream cf(argv[3]);
if (!cf) { std::cerr << "FAIL: cannot read chart " << argv[3] << "\n"; return 1; }
std::string line;
while (std::getline(cf, line))
{
std::istringstream is(line);
ChartNote n{};
if (is >> n.t >> n.midi) chart.push_back(n);
}
}
std::cout << "chart: " << chart.size() << " notes\n";
MlNoteDetector det;
if (!det.loadModel(juce::File(juce::String(argv[1]))))
{ std::cerr << "FAIL: loadModel returned false\n"; return 1; }
det.prepare((double) sampleRate, 256);
// Feed the WAV at real time (1.0x) and poll the active set every 50 ms,
// the same cadence as the plugin. A rising per-pitch onsetSeq is a
// detected onset; back-date it by onsetAgeMs to its true time.
//
// feedRate MUST stay 1.0: onsetAgeMs is measured in wall-clock time inside
// MlNoteDetector, while fedSec is the fed-audio timeline. Feeding faster
// than real time desynchronises the two — a wall-clock age would map to a
// larger span of fed audio — so back-dated onset times would drift later
// than the chart over a long recording. At 1.0x the two timelines agree.
const int block = 256;
const double feedRate = 1.0;
std::map<int, int> lastSeq;
std::vector<Onset> onsets;
std::vector<PollRec> polls; // full detectNotes() stream, one entry per poll
double nextPollSec = 0.0;
for (size_t i = 0; i < wav.size(); i += block)
{
const int n = (int) std::min<size_t>(block, wav.size() - i);
det.pushSamples(wav.data() + i, n);
std::this_thread::sleep_for(std::chrono::microseconds(
(long long) (1e6 * n / sampleRate / feedRate)));
const double fedSec = double(i + n) / sampleRate;
if (fedSec >= nextPollSec)
{
nextPollSec += 0.050;
auto active = det.getActiveNotes();
polls.push_back({ fedSec, active });
for (const auto& a : active)
{
auto it = lastSeq.find(a.midi);
const int prevSeq = (it == lastSeq.end()) ? 0 : it->second;
// onsetSeq == 0 means "no detected onset" (sustained activity),
// so only a strictly-advancing, non-zero counter is a real new
// onset — otherwise back-dating by the sentinel age would forge
// an onset at poll time. Still track the pitch either way.
if (a.onsetSeq > prevSeq && a.onsetSeq > 0)
{
const double age = (a.onsetAgeMs < 1.0e6f) ? a.onsetAgeMs / 1000.0 : 0.0;
onsets.push_back({ fedSec - age, a.midi, a.confidence });
}
lastSeq[a.midi] = a.onsetSeq;
}
}
}
constexpr double kDrainSec = 0.400;
std::this_thread::sleep_for(std::chrono::milliseconds(
(int) (kDrainSec * 1000)));
// One final poll after the drain delay: late onsets / active notes the
// detector only resolved during the trailing inference would otherwise be
// omitted from the metrics and detectstream.json. Wall time has advanced
// by the drain sleep, so the poll timestamp is wavSec + kDrainSec — using
// a bare wavSec would back-date trailing onsets by the drain duration,
// since onsetAgeMs is measured at this (later) wall-clock instant.
{
const double finalPollSec = wavSec + kDrainSec;
auto active = det.getActiveNotes();
polls.push_back({ finalPollSec, active });
for (const auto& a : active)
{
auto it = lastSeq.find(a.midi);
const int prevSeq = (it == lastSeq.end()) ? 0 : it->second;
// See the in-loop poll above: onsetSeq 0 is "no onset", not a hit.
if (a.onsetSeq > prevSeq && a.onsetSeq > 0)
{
const double age = (a.onsetAgeMs < 1.0e6f) ? a.onsetAgeMs / 1000.0 : 0.0;
onsets.push_back({ finalPollSec - age, a.midi, a.confidence });
}
lastSeq[a.midi] = a.onsetSeq;
}
}
det.stop();
std::cout << "detected onsets: " << onsets.size() << "\n";
// --- Align: find the chart->WAV time offset maximising matches ----------
const double tol = 0.10; // ±100 ms match window
auto countMatches = [&](double delta) {
int m = 0;
for (const auto& c : chart)
{
const double target = c.t + delta;
for (const auto& o : onsets)
if (o.midi == c.midi && std::fabs(o.t - target) <= tol) { ++m; break; }
}
return m;
};
double bestDelta = 0.0;
int bestMatches = -1;
for (double d = -2.0; d <= 30.0; d += 0.010)
{
const int m = countMatches(d);
if (m > bestMatches) { bestMatches = m; bestDelta = d; }
}
// --- Dump the detect-stream (chart-aligned) for the JS matching harness --
{
std::ofstream js("detectstream.json");
js << "{\"offset\":" << bestDelta << ",\"polls\":[";
for (size_t pi = 0; pi < polls.size(); ++pi)
{
if (pi) js << ",";
js << "{\"t\":" << (polls[pi].t - bestDelta) << ",\"notes\":[";
for (size_t ni = 0; ni < polls[pi].notes.size(); ++ni)
{
const auto& a = polls[pi].notes[ni];
if (ni) js << ",";
js << "{\"midi\":" << a.midi
<< ",\"confidence\":" << a.confidence
<< ",\"onsetMs\":" << a.onsetAgeMs
<< ",\"onsetSeq\":" << a.onsetSeq << "}";
}
js << "]}";
}
js << "]}";
}
std::cout << "detect-stream: " << polls.size() << " polls -> detectstream.json\n";
// --- Report at the best offset -----------------------------------------
std::vector<double> te; // timing errors of matched notes
int matched = 0;
for (const auto& c : chart)
{
const double target = c.t + bestDelta;
double best = 1e9;
for (const auto& o : onsets)
if (o.midi == c.midi && std::fabs(o.t - target) <= tol)
if (std::fabs(o.t - target) < std::fabs(best)) best = o.t - target;
if (best < 1e8) { ++matched; te.push_back(best); }
}
std::sort(te.begin(), te.end());
auto pct = [&](double p) {
return te.empty() ? 0.0 : te[std::min(te.size()-1, (size_t)(p * te.size()))];
};
std::cout << "\n=== alignment ===\n";
std::cout << "chart->WAV offset: " << bestDelta << " s\n";
std::cout << "\n=== detection quality (±" << (tol*1000) << " ms) ===\n";
std::cout << "recall: " << matched << " / " << chart.size()
<< " (" << (100.0 * matched / std::max<size_t>(1, chart.size())) << "%)\n";
if (!te.empty())
{
double sum = 0; for (double x : te) sum += x;
std::cout << "timing error: median " << (te[te.size()/2]*1000) << " ms"
<< " p10 " << (pct(0.10)*1000) << " p90 " << (pct(0.90)*1000)
<< " mean " << (sum/te.size()*1000) << " ms\n";
}
std::cout << "onsets/note: " << (double(onsets.size()) / std::max<size_t>(1, chart.size()))
<< " (>1 = extra detections: harmonics, noise)\n";
// --- Predicted score: one-onset-one-note greedy matching ----------------
// Mirrors the plugin's fixed matcher — each onset (earliest first) claims
// the nearest still-unclaimed chart note of its pitch within ±tol. This
// predicts the live hit rate, where the "recall" above is the loose
// upper bound (one onset allowed to satisfy many notes).
{
std::vector<Onset> sorted = onsets;
std::sort(sorted.begin(), sorted.end(),
[](const Onset& a, const Onset& b){ return a.t < b.t; });
std::vector<char> claimed(chart.size(), 0);
std::vector<double> hte;
for (const auto& o : sorted)
{
int bestIdx = -1; double bestDist = 1e9;
for (size_t ci = 0; ci < chart.size(); ++ci)
{
if (claimed[ci] || chart[ci].midi != o.midi) continue;
const double d = std::fabs(o.t - (chart[ci].t + bestDelta));
if (d <= tol && d < bestDist) { bestDist = d; bestIdx = (int) ci; }
}
if (bestIdx >= 0)
{ claimed[(size_t) bestIdx] = 1; hte.push_back(o.t - (chart[(size_t) bestIdx].t + bestDelta)); }
}
std::sort(hte.begin(), hte.end());
const size_t hits = hte.size();
std::cout << "\n=== predicted score (one-onset-one-note) ===\n";
std::cout << "hits: " << hits << " / " << chart.size()
<< " (" << (100.0 * hits / std::max<size_t>(1, chart.size())) << "%)\n";
if (!hte.empty())
std::cout << "timing error: median " << (hte[hte.size()/2]*1000) << " ms"
<< " p10 " << (hte[hte.size()/10]*1000)
<< " p90 " << (hte[hte.size()*9/10]*1000) << " ms\n";
}
return 0;
}
+147
View File
@@ -0,0 +1,147 @@
// Native end-to-end test for MlNoteDetector (the production class).
//
// Feeds a known guitar WAV through pushSamples() in 256-sample blocks — the
// same path the audio callback uses — at roughly real time, lets the
// background inference thread resample / window / run Basic Pitch, then asserts
// the active-pitch snapshot matches the WAV's final content (a C-major triad).
//
// This exercises the streaming LagrangeInterpolator resample, the rolling
// 22050 Hz window, the snapshot publishing and threshold logic — everything
// the Phase 0 spike did not. Inference accuracy itself is also re-checked here.
//
// Build: see CMakeLists.txt. Run: ./mlnd_test <model.onnx> <audio.wav>
// Exit 0 = pass.
#include "MlNoteDetector.h"
#include <algorithm>
#include <cstdint>
#include <cstring>
#include <fstream>
#include <iostream>
#include <thread>
#include <vector>
// Minimal WAV reader — 16-bit PCM / 32-bit float, any channels -> mono.
namespace
{
uint32_t rdU32(const uint8_t* p) { return p[0] | (p[1]<<8) | (p[2]<<16) | (uint32_t(p[3])<<24); }
uint16_t rdU16(const uint8_t* p) { return uint16_t(p[0] | (p[1]<<8)); }
bool readWav(const std::string& path, std::vector<float>& out, int& sampleRate)
{
std::ifstream f(path, std::ios::binary);
if (!f) return false;
std::vector<uint8_t> buf((std::istreambuf_iterator<char>(f)), std::istreambuf_iterator<char>());
if (buf.size() < 44 || std::memcmp(buf.data(), "RIFF", 4) || std::memcmp(buf.data()+8, "WAVE", 4))
return false;
uint16_t fmt = 0, channels = 0, bits = 0;
uint32_t rate = 0, dataLen = 0;
const uint8_t* data = nullptr;
size_t pos = 12;
while (pos + 8 <= buf.size())
{
const char* id = reinterpret_cast<const char*>(buf.data() + pos);
const uint32_t sz = rdU32(buf.data() + pos + 4);
const uint8_t* body = buf.data() + pos + 8;
// Guard the fmt-body reads (up to body+14, i.e. 16 bytes) against a
// truncated file: a declared sz >= 16 doesn't mean 16 bytes are
// actually present.
if (!std::memcmp(id, "fmt ", 4) && sz >= 16 && pos + 8 + 16 <= buf.size())
{ fmt = rdU16(body); channels = rdU16(body+2); rate = rdU32(body+4); bits = rdU16(body+14); }
else if (!std::memcmp(id, "data", 4))
{ data = body; dataLen = std::min<uint32_t>(sz, uint32_t(buf.size() - (pos + 8))); }
pos += 8 + sz + (sz & 1);
}
if (!data || channels == 0 || rate == 0 || bits < 8) return false;
sampleRate = int(rate);
const int bps = bits / 8;
const size_t frames = dataLen / (size_t(bps) * channels);
out.resize(frames);
for (size_t i = 0; i < frames; ++i)
{
double acc = 0.0;
for (int c = 0; c < channels; ++c)
{
const uint8_t* s = data + (i * channels + c) * bps;
if (fmt == 3 && bits == 32) { float v; std::memcpy(&v, s, 4); acc += v; }
else if (fmt == 1 && bits == 16) { acc += int16_t(rdU16(s)) / 32768.0; }
else return false;
}
out[i] = float(acc / channels);
}
return true;
}
} // namespace
int main(int argc, char** argv)
{
if (argc < 3) { std::cerr << "usage: mlnd_test <model.onnx> <audio.wav>\n"; return 2; }
std::vector<float> wav;
int sampleRate = 0;
if (!readWav(argv[2], wav, sampleRate))
{ std::cerr << "FAIL: cannot read WAV " << argv[2] << "\n"; return 1; }
std::cout << "WAV: " << wav.size() << " samples @ " << sampleRate << " Hz\n";
MlNoteDetector det;
if (!det.loadModel(juce::File(juce::String(argv[1]))))
{ std::cerr << "FAIL: loadModel('" << argv[1] << "') returned false\n"; return 1; }
std::cout << "model loaded; isAvailable=" << det.isAvailable() << "\n";
det.prepare((double) sampleRate, 256);
// Feed the WAV in 256-sample blocks at ~real time so the background
// inference thread drains the FIFO instead of overflowing it. The
// detector reports "what is sounding now", so we poll the active set
// *during* playback and accumulate every pitch seen while the WAV's
// C-major triad (t≈3.3-4.5 s) is sounding — allowing for the
// hop + inference lag, that window maps to feed time ≈ [3.6, 5.2] s.
const int block = 256;
std::vector<int> seenDuringChord;
auto noteActiveSomewhere = [&](int midi)
{ return std::find(seenDuringChord.begin(), seenDuringChord.end(), midi)
!= seenDuringChord.end(); };
for (size_t i = 0; i < wav.size(); i += block)
{
const int n = (int) std::min<size_t>(block, wav.size() - i);
det.pushSamples(wav.data() + i, n);
std::this_thread::sleep_for(std::chrono::microseconds(
(long long) (1e6 * n / sampleRate)));
const double feedSec = double(i) / sampleRate;
if (feedSec >= 3.6 && feedSec <= 5.2)
for (const auto& nt : det.getActiveNotes())
if (!noteActiveSomewhere(nt.midi))
seenDuringChord.push_back(nt.midi);
}
// Drain: the chord is the WAV's final event, so let the trailing
// inference finish and poll once more before stopping — otherwise the
// last chord's notes can be dropped, making the test nondeterministic.
std::this_thread::sleep_for(std::chrono::milliseconds(400));
for (const auto& nt : det.getActiveNotes())
if (!noteActiveSomewhere(nt.midi))
seenDuringChord.push_back(nt.midi);
det.stop();
std::sort(seenDuringChord.begin(), seenDuringChord.end());
std::cout << "pitches seen active during the chord window (" << seenDuringChord.size() << "):\n";
for (int m : seenDuringChord) std::cout << " midi=" << m << "\n";
// The WAV's final sustained event is a C-major triad: C3=48, E3=52, G3=55.
const int expected[] = { 48, 52, 55 };
int found = 0;
for (int e : expected)
if (noteActiveSomewhere(e)) ++found;
if (found >= 2)
{
std::cout << "PASS: detected " << found << "/3 chord tones\n";
return 0;
}
std::cerr << "FAIL: detected only " << found << "/3 expected chord tones\n";
return 1;
}
+125
View File
@@ -0,0 +1,125 @@
// Multi-input source API smoke test (Phase 1).
//
// Exercises the native source pool + source-indexed scoring API without a live
// audio device: addSource/removeSource/listSources and the *Source* methods keep
// their shapes, the pool is bounded, sources[0] is permanent, and the legacy
// un-suffixed methods still target source 0 (backward compatibility).
const test = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const ADDON = path.join(__dirname, '..', 'build', 'Release', 'slopsmith_audio.node');
let audio;
try {
audio = require(ADDON);
} catch (e) {
test('multi-source (skipped — addon not built)', { skip: true }, () => {});
}
const CHART = {
arrangement: 'guitar',
stringCount: 6,
tuningOffsets: [0, 0, 0, 0, 0, 0],
timingTolerance: 0.1,
notes: [{ id: 'n0', t: 1.0, s: 0, f: 3, sus: 0 }],
};
const SCORE_REQ = {
arrangement: 'guitar',
stringCount: 6,
offsets: [0, 0, 0, 0, 0, 0],
notes: [{ s: 0, f: 3 }, { s: 1, f: 2 }],
};
if (audio) {
test('multi-input source API', async (t) => {
audio.init();
t.after(() => { try { audio.shutdown(); } catch { /* ignore */ } });
await t.test('listSources starts with the permanent source 0', () => {
const list = audio.listSources();
assert.ok(Array.isArray(list), 'listSources returns an array');
assert.equal(list.length, 1, 'only source 0 active at start');
assert.equal(list[0].id, 0);
assert.equal(list[0].active, true);
});
let sid;
await t.test('addSource activates a pooled chain bound to a channel', () => {
sid = audio.addSource(1);
assert.equal(typeof sid, 'number');
assert.ok(sid >= 1, 'new source id is >= 1 (0 is permanent)');
const list = audio.listSources();
assert.equal(list.length, 2, 'two sources active');
const added = list.find((s) => s.id === sid);
assert.ok(added, 'added source is listed');
assert.equal(added.inputChannel, 1, 'bound to requested channel');
});
await t.test('setSourceChart accepts a chart for a valid id, rejects a bad id', () => {
assert.equal(audio.setSourceChart(sid, CHART), true, 'valid id + chart -> true');
assert.equal(audio.setSourceChart(999, CHART), false, 'out-of-range id -> false');
assert.equal(audio.setSourceChart(sid, { stringCount: 6, tuningOffsets: [0], notes: [] }), false,
'malformed chart (offsets != stringCount) -> false');
});
await t.test('scoreSourceChord keeps its shape; bad id -> empty failure', () => {
const res = audio.scoreSourceChord(sid, SCORE_REQ);
assert.ok(res && typeof res === 'object');
for (const k of ['score', 'hitStrings', 'totalStrings', 'isHit', 'results'])
assert.ok(k in res, `result has ${k}`);
assert.equal(res.results.length, 2, 'one result per note');
const bad = audio.scoreSourceChord(999, SCORE_REQ);
assert.equal(bad.totalStrings, 0, 'bad id -> no-request failure shape');
});
await t.test('getSourceNoteVerdicts / getSourceRawAudioFrame / getSourcePitchDetection', () => {
const verdicts = audio.getSourceNoteVerdicts(sid, 1.0, true);
assert.ok(Array.isArray(verdicts), 'verdicts is an array (empty without a device)');
assert.equal(audio.getSourceNoteVerdicts(999), null, 'bad id -> null');
const frame = audio.getSourceRawAudioFrame(sid, 2048);
assert.ok(frame instanceof Float32Array && frame.length === 2048, 'raw frame sized');
assert.equal(audio.getSourceRawAudioFrame(999).length, 0, 'bad id -> empty');
for (const fn of ['getSourcePitchDetection', 'getSourceRawPitchDetection']) {
const det = audio[fn](sid);
for (const k of ['frequency', 'confidence', 'midiNote', 'cents', 'noteName'])
assert.ok(k in det, `${fn} detection has ${k}`);
assert.equal(audio[fn](999).frequency, -1, `${fn} bad id -> no-detection shape`);
}
});
await t.test('the pool is bounded and the 0 source is permanent', () => {
// Fill the remaining slots (pool is 8; source 0 + sid already used).
const added = [];
for (let i = 0; i < 8; i++) {
const id = audio.addSource(-1);
if (id < 0) break;
added.push(id);
}
assert.equal(audio.addSource(-1), -1, 'addSource returns -1 when the pool is full');
assert.equal(audio.removeSource(0), false, 'source 0 cannot be removed');
// Clean the ones we added in this subtest.
for (const id of added) assert.equal(audio.removeSource(id), true, `removeSource(${id})`);
});
await t.test('removeSource deactivates and frees the slot for reuse', () => {
assert.equal(audio.removeSource(sid), true, 'removeSource(sid) -> true');
assert.equal(audio.removeSource(sid), false, 'removing twice -> false');
const list = audio.listSources();
assert.equal(list.length, 1, 'back to just source 0');
const reused = audio.addSource(2);
assert.ok(reused >= 1, 'a freed slot is reusable');
audio.removeSource(reused);
});
await t.test('legacy methods still target source 0 (backward compat)', () => {
assert.equal(audio.setChart(CHART), true, 'legacy setChart works');
const res = audio.scoreChord(SCORE_REQ);
assert.equal(res.results.length, 2, 'legacy scoreChord works');
assert.ok(Array.isArray(audio.getNoteVerdicts()), 'legacy getNoteVerdicts works');
});
});
}
+126
View File
@@ -0,0 +1,126 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const fs = require('node:fs');
const ROOT = path.join(__dirname, '..');
function readJson(relpath) {
return JSON.parse(fs.readFileSync(path.join(ROOT, relpath), 'utf8'));
}
function readText(relpath) {
return fs.readFileSync(path.join(ROOT, relpath), 'utf8');
}
test('renderer manifest declares active playback observer', () => {
const manifest = readJson('src/renderer/plugin.json');
const playback = manifest.capabilities.playback;
assert.deepEqual(playback.roles, ['observer']);
assert.equal(playback.kind, 'lifecycle');
assert.deepEqual(playback.observes, ['loading', 'ready', 'stopped', 'ended']);
assert.equal(playback.mode, 'active');
assert.equal(playback.compatibility, 'shim-allowed');
assert.equal(playback.ownership, 'observer-only');
assert.equal(playback.safety, 'safe');
assert.equal(playback.version, 1);
});
test('renderer uses playback lifecycle instead of global transport wrappers', () => {
const source = readText('src/renderer/screen.js');
assert.equal(source.includes('window.playSong ='), false);
assert.equal(source.includes('window.stopSong ='), false);
assert.equal(source.includes('registerObserver'), true);
assert.equal(source.includes("playback:loading"), true);
assert.equal(source.includes("playback:ready"), true);
assert.equal(source.includes("playback:stopped"), true);
assert.equal(source.includes("playback:ended"), true);
assert.equal(source.includes('settingsKey'), true);
assert.equal(source.includes('_slopsmithPlaybackSettingsKey'), true);
});
test('renderer migrates legacy filename tone mappings to playback settings key', () => {
const source = readText('src/renderer/screen.js');
const docs = readText('docs/CAPABILITY-MIGRATION.md');
assert.equal(source.includes('migrateToneMappingsToPlaybackSettingsKey'), true);
assert.equal(source.includes('window._aeMigrateToneMappingsToPlaybackSettingsKey'), true);
assert.equal(source.includes("migrateBucket('songs')"), true);
assert.equal(source.includes("migrateBucket('midiPC')"), true);
assert.equal(docs.includes('Automatic Migration For Existing Mappings'), true);
assert.equal(docs.includes('Existing `settingsKey` buckets win and are not overwritten.'), true);
});
test('renderer registers native audio-mix fader participants', () => {
const source = readText('src/renderer/screen.js');
assert.equal(source.includes('registerMixParticipant'), true);
assert.equal(source.includes('audio_engine.input_gain'), true);
assert.equal(source.includes('audio_engine.chain_gain'), true);
assert.equal(source.includes("'fader.get-value'"), true);
assert.equal(source.includes("'fader.set-value'"), true);
});
test('chain panel summarizes provider-managed audio effects mappings', () => {
const source = readText('src/renderer/screen.js');
assert.equal(source.includes('fetchAudioEffectMappingsForSong'), true);
assert.equal(source.includes('/api/audio-effects/mappings?'), true);
assert.equal(source.includes('summarizeProviderManagedMappings'), true);
assert.equal(source.includes('rig_builder.effects'), true);
assert.equal(source.includes('Chain Provider'), true);
assert.equal(source.includes('ae-open-rig-builder'), true);
assert.equal(source.includes('hasProviderManagedAudioEffectsChain'), true);
assert.equal(source.includes('window._aeHasProviderManagedChain'), true);
assert.equal(source.includes('function shouldShowPlayerChainButton()'), true);
assert.equal(source.includes("String(inspected?.providerId || '').trim() === 'nam-tone'"), true);
assert.equal(source.includes('window._aeShouldShowPlayerChainButton'), true);
assert.equal(source.includes('window._aeInjectPlayerToneButton = injectPlayerToneButton'), true);
assert.equal(source.includes('function rigBuilderToneOwnershipState()'), true);
assert.equal(source.includes("window.__rbMegaChainSetting === true"), true);
assert.equal(source.includes("state: pending ? 'selected' : failed ? 'fallback' : active ? 'loaded' : 'selected'"), true);
assert.equal(source.includes('function removePlayerChainButton()'), true);
assert.equal(source.includes("document.getElementById('btn-chain-switch')"), true);
assert.equal(source.includes('if (!shouldShowPlayerChainButton())'), true);
assert.equal((source.match(/btn\.id = 'btn-chain-switch'/g) || []).length, 1);
assert.equal(source.includes('refreshChainButtonForRouteOwner'), true);
assert.equal(source.includes('window.slopsmith.on(\'audio-effects:released\', refreshChainButtonForRouteOwner);'), true);
assert.equal(source.includes('if (window._aeInjectPlayerToneButton) window._aeInjectPlayerToneButton();'), true);
assert.equal(source.includes('inspectProviderManagedAudioEffectsRoute'), true);
assert.equal(source.includes('window._aeInspectProviderManagedChain'), true);
assert.equal(source.includes('summarizeActiveProviderManagedRoute'), true);
assert.equal(source.includes('summarizeProviderManagedMappings(await fetchAudioEffectMappingsForSong(songKey)) || summarizeActiveProviderManagedRoute()'), true);
assert.equal(source.includes('window.RbMegaChain'), true);
assert.equal(source.includes("typeof rb.isPending === 'function'"), true);
assert.equal(source.includes("typeof rb.state === 'function'"), true);
assert.equal(source.includes("providerId: 'rig_builder.effects'"), true);
assert.equal(source.includes('const rigBuilderOwner = rigBuilderToneOwnershipState();'), true);
assert.equal(source.includes("['selected', 'resolving', 'resolved', 'loaded', 'degraded', 'loading', 'fallback'].includes(state)"), true);
assert.equal(source.includes("if (inspected.state === 'fallback') label = 'Chain failed'"), true);
assert.equal(source.includes("else if (inspected.state === 'degraded') label = 'Chain degraded'"), true);
assert.equal(source.includes("['selected', 'resolving', 'loading'].includes(inspected.state)"), true);
assert.equal(source.includes("'loading'") && source.includes('Loading chain'), true);
assert.equal(source.includes('Provider-managed audio-effects chain active'), true);
assert.equal(source.includes("const panelMode = providerManaged ? 'provider'"), true);
assert.equal(source.includes('if (!providerManaged && toneNamesOrdered.length > 0)'), true);
assert.equal(source.includes('if (providerChainActive) {'), true);
assert.equal(source.includes('Provider-managed audio-effects chain active — preserving chain, skipping legacy preset preload'), true);
assert.equal(source.includes('aeSetMonitorMuteSuppressed(false);'), true);
assert.equal(source.includes("['selected', 'resolving', 'loading', 'fallback'].includes(providerRoute.state)"), true);
assert.equal(source.includes('let shouldResolveChainRebuildGuard = false;'), true);
assert.equal(source.includes('if (shouldResolveChainRebuildGuard) await resolveChainRebuildGuard();'), true);
});
test('legacy midi_amp tone lookup is guarded by plugin availability', () => {
const source = readText('src/renderer/screen.js');
assert.equal(source.includes('hasMidiAmpSongTonesEndpoint'), true);
assert.equal(source.includes('midiAmpSongTonesUnavailable'), true);
assert.equal(source.includes('midiAmpSongTonesPending'), true);
assert.equal(source.includes('document.querySelector(\'[data-plugin-id="midi_amp"]\')'), true);
assert.equal(source.includes('resp.status === 404'), true);
assert.equal(source.includes('fetchMidiAmpSongTones(key)'), true);
});
+117
View File
@@ -0,0 +1,117 @@
# Sandbox IPC unit tests. Cross-platform: the audio-ring loopback runs
# everywhere; the control-channel loopback and the posix_spawn smoke test are
# POSIX-only (they use the fd-passed socketpair transport). Standalone console
# exes, exit 0 on pass.
#
# Source paths are CMAKE_CURRENT_SOURCE_DIR-relative so this file works both
# when add_subdirectory()'d from the main build (root CMakeLists → tests/) and
# from the JUCE-only standalone harness (tests/sandbox/standalone/) the CI job
# uses — neither needs cmake-js / node-addon-api / ONNX to build.
#
# Optional sanitizer: -DSLOPSMITH_SANITIZE=address|thread applies the sanitizer
# to these targets only (the CI job builds plain + ASan + TSan variants). TSan
# on the threaded audio loopback is the highest-value artifact here — it is
# what validates the arm64 release/acquire memory ordering a Linux-only dev
# can't otherwise exercise.
if(NOT TARGET juce::juce_core OR NOT TARGET juce::juce_audio_basics)
message(FATAL_ERROR "sandbox tests: JUCE targets not in scope; this dir "
"must be configured after JUCE is added.")
endif()
set(SANDBOX_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../../src/audio/Sandbox")
set(AUDIO_INC "${CMAKE_CURRENT_SOURCE_DIR}/../../src/audio")
set(SLOPSMITH_SANITIZE "" CACHE STRING
"Sanitizer for sandbox tests: empty, address, or thread")
# Per-target shared config: include dir, JUCE-headless defs, Release output
# dir, optional sanitizer.
function(slopsmith_configure_sandbox_test target)
target_include_directories(${target} PRIVATE "${AUDIO_INC}")
target_compile_definitions(${target} PRIVATE
JUCE_STANDALONE_APPLICATION=1
JUCE_USE_CURL=0
JUCE_WEB_BROWSER=0
JUCE_DISPLAY_SPLASH_SCREEN=0
JUCE_REPORT_APP_USAGE=0)
set_target_properties(${target} PROPERTIES
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/Release"
RUNTIME_OUTPUT_DIRECTORY_DEBUG "${CMAKE_BINARY_DIR}/Release"
RUNTIME_OUTPUT_DIRECTORY_RELEASE "${CMAKE_BINARY_DIR}/Release"
RUNTIME_OUTPUT_DIRECTORY_RELWITHDEBINFO "${CMAKE_BINARY_DIR}/Release"
RUNTIME_OUTPUT_DIRECTORY_MINSIZEREL "${CMAKE_BINARY_DIR}/Release"
OUTPUT_NAME "${target}")
if(SLOPSMITH_SANITIZE)
# -fsanitize= is GCC/Clang syntax; MSVC uses a different mechanism and
# would choke on these flags. The sanitizer runs are POSIX-only CI jobs,
# so fail loudly rather than emit broken flags on an unsupported toolchain.
if(NOT CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang")
message(FATAL_ERROR
"SLOPSMITH_SANITIZE=${SLOPSMITH_SANITIZE} requires a GCC/Clang "
"compiler (got ${CMAKE_CXX_COMPILER_ID}); unset it on this toolchain.")
endif()
target_compile_options(${target} PRIVATE
-fsanitize=${SLOPSMITH_SANITIZE} -fno-omit-frame-pointer -g)
target_link_options(${target} PRIVATE -fsanitize=${SLOPSMITH_SANITIZE})
endif()
endfunction()
# Platform split of the channel backends.
if(WIN32)
set(AUDIO_CHANNEL_SRC "${SANDBOX_DIR}/AudioChannel_shared.cpp"
"${SANDBOX_DIR}/AudioChannel_win.cpp")
else()
set(AUDIO_CHANNEL_SRC "${SANDBOX_DIR}/AudioChannel_shared.cpp"
"${SANDBOX_DIR}/AudioChannel_posix.cpp")
endif()
# --- audio ring loopback (all platforms) ---------------------------------
add_executable(audio_channel_midi_test
audio_channel_midi_test.cpp
${AUDIO_CHANNEL_SRC}
"${SANDBOX_DIR}/Protocol.cpp")
target_link_libraries(audio_channel_midi_test PRIVATE
juce::juce_audio_basics juce::juce_core)
slopsmith_configure_sandbox_test(audio_channel_midi_test)
add_test(NAME audio_channel_midi_test
COMMAND audio_channel_midi_test
WORKING_DIRECTORY "$<TARGET_FILE_DIR:audio_channel_midi_test>")
# --- control channel + spawn smoke (POSIX only) --------------------------
# The Windows control transport is a named pipe re-opened by name and has no
# in-process fd-handoff path; these loopbacks use connectClientSideFd /
# startPosix, so they are POSIX-only. The Windows transport is covered on the
# Windows CI build of the addon + vst-host.
if(NOT WIN32)
set(CONTROL_SRC "${SANDBOX_DIR}/ControlChannel_shared.cpp"
"${SANDBOX_DIR}/ControlChannel_posix.cpp"
"${SANDBOX_DIR}/Protocol.cpp")
add_executable(control_channel_test
control_channel_test.cpp ${CONTROL_SRC})
target_link_libraries(control_channel_test PRIVATE juce::juce_core)
slopsmith_configure_sandbox_test(control_channel_test)
add_test(NAME control_channel_test
COMMAND control_channel_test
WORKING_DIRECTORY "$<TARGET_FILE_DIR:control_channel_test>")
# Child helper for the spawn smoke test (not a test itself).
add_executable(spawn_smoke_child
spawn_smoke_child.cpp ${CONTROL_SRC})
target_link_libraries(spawn_smoke_child PRIVATE juce::juce_core)
slopsmith_configure_sandbox_test(spawn_smoke_child)
add_executable(spawn_smoke_test
spawn_smoke_test.cpp
"${SANDBOX_DIR}/SubprocessHandle_posix.cpp"
${CONTROL_SRC})
target_link_libraries(spawn_smoke_test PRIVATE juce::juce_core)
slopsmith_configure_sandbox_test(spawn_smoke_test)
target_compile_definitions(spawn_smoke_test PRIVATE
SPAWN_CHILD_PATH="$<TARGET_FILE:spawn_smoke_child>")
add_dependencies(spawn_smoke_test spawn_smoke_child)
add_test(NAME spawn_smoke_test
COMMAND spawn_smoke_test
WORKING_DIRECTORY "$<TARGET_FILE_DIR:spawn_smoke_test>")
endif()
+413
View File
@@ -0,0 +1,413 @@
// audio_channel_midi_test — exercise pushInputBlock / popInputBlock + the
// global midiOverflows counter without spawning a subprocess.
//
// Closes the v2/v3 review-thread concern that the inline-MIDI path had no
// automated coverage (the existing GR6 smoke driver only pushes empty
// MidiBuffers). Both ends of an AudioChannel are opened in the same
// process — createHostSide on one instance, openSandboxSide on a second
// instance using the same Names — so we don't need a real spawn.
//
// Win32-only for the same reason AudioChannel.cpp is.
#include <juce_audio_basics/juce_audio_basics.h>
#include <juce_core/juce_core.h>
#include "../../src/audio/Sandbox/Protocol.h"
#include "../../src/audio/Sandbox/AudioChannel.h"
#include <atomic>
#include <cstdio>
#include <thread>
using namespace slopsmith::sandbox;
namespace {
int g_failed = 0;
int g_passed = 0;
void check(bool cond, const char* what, const char* file, int line)
{
if (cond) { ++g_passed; return; }
++g_failed;
std::fprintf(stderr, " FAIL: %s (%s:%d)\n", what, file, line);
}
#define CHECK(cond) check((cond), #cond, __FILE__, __LINE__)
// REQUIRE = fatal CHECK: bails the current test on failure so a busted
// setup precondition (e.g., HeaderPeek failing to open the mapping) doesn't
// cascade into a NULL deref + a barrage of misleading follow-on failures.
// Use for everything that subsequent test lines dereference / depend on.
#define REQUIRE(cond) \
do { if (!(cond)) { check(false, #cond, __FILE__, __LINE__); return; } } while (0)
// Helper: open a fresh host+sandbox AudioChannel pair with a given dims, run
// a callback against both ends, then tear down. The pair is unique per call
// (suffix-randomised mapping name) so concurrent test runs don't collide.
struct ChannelPair
{
AudioChannel host;
AudioChannel sandbox;
AudioChannel::Names names;
AudioDimensions dims;
juce::String err;
bool ok = false;
explicit ChannelPair(const AudioDimensions& d) : dims(d)
{
ok = host.createHostSide(dims, names, err);
if (!ok)
{
std::fprintf(stderr, " ChannelPair: createHostSide failed: %s\n",
err.toRawUTF8());
return;
}
ok = sandbox.openSandboxSide(names, err);
if (!ok)
{
std::fprintf(stderr, " ChannelPair: openSandboxSide failed: %s\n",
err.toRawUTF8());
// host's named mapping + events are released by AudioChannel's
// destructor when this ChannelPair goes out of scope (sandbox
// first, then host, per reverse-declaration-order rules).
// Names are randomised per ChannelPair so an aborted construct
// doesn't leak into a subsequent test in the same run.
}
}
};
void testRoundtripSmallBuffer()
{
std::printf("test: roundtrip small MidiBuffer (count, frames, bytes)\n");
AudioDimensions dims; // defaults: 4 blocks × 1024 samples × 2 ch
ChannelPair pair{dims};
REQUIRE(pair.ok);
juce::AudioBuffer<float> srcAudio((int)dims.maxChannels, 256);
srcAudio.clear();
juce::MidiBuffer midi;
// 3 events at distinct frames — Note On, CC, Note Off.
midi.addEvent(juce::MidiMessage::noteOn(1, 60, (juce::uint8)100), 0);
midi.addEvent(juce::MidiMessage::controllerEvent(1, 7, 64), 64);
midi.addEvent(juce::MidiMessage::noteOff(1, 60), 200);
const uint64_t overflowsBefore = pair.host.diagMidiOverflows();
REQUIRE(pair.host.pushInputBlock(srcAudio, midi, 256));
juce::AudioBuffer<float> dstAudio((int)dims.maxChannels, 256);
juce::MidiBuffer drained;
REQUIRE(pair.sandbox.popInputBlock(dstAudio, drained, 256, /*timeoutMs*/ 1000));
int n = 0;
int frames[3] = {-1, -1, -1};
juce::uint8 firstByte[3] = {0, 0, 0};
for (const auto& meta : drained)
{
if (n < 3) { frames[n] = meta.samplePosition;
firstByte[n] = meta.getMessage().getRawData()[0]; }
++n;
}
CHECK(n == 3);
CHECK(frames[0] == 0);
CHECK(frames[1] == 64);
CHECK(frames[2] == 200);
// Note On status nibble = 0x90, CC = 0xB0, Note Off = 0x80.
CHECK((firstByte[0] & 0xF0) == 0x90);
CHECK((firstByte[1] & 0xF0) == 0xB0);
CHECK((firstByte[2] & 0xF0) == 0x80);
// No overflows expected on the happy path.
const uint64_t overflowsAfter = pair.host.diagMidiOverflows();
CHECK(overflowsAfter == overflowsBefore);
}
void testSysExBumpsOverflow()
{
std::printf("test: SysEx-sized event drops + bumps midiOverflows\n");
AudioDimensions dims;
ChannelPair pair{dims};
REQUIRE(pair.ok);
juce::AudioBuffer<float> srcAudio((int)dims.maxChannels, 256);
srcAudio.clear();
juce::MidiBuffer midi;
// SysEx — JUCE wraps the payload with F0/F7 framing, so a 3-byte
// payload becomes a 5-byte raw message (> kMidiEventMaxBytes = 4),
// which pushInputBlock should drop and bump midiOverflows.
const juce::uint8 sysexPayload[] = { 0x7E, 0x7F, 0x06 };
midi.addEvent(juce::MidiMessage::createSysExMessage(sysexPayload, 3), 32);
// Plus a normal CC event at frame 100 — should round-trip.
midi.addEvent(juce::MidiMessage::controllerEvent(1, 7, 64), 100);
const uint64_t overflowsBefore = pair.host.diagMidiOverflows();
REQUIRE(pair.host.pushInputBlock(srcAudio, midi, 256));
juce::AudioBuffer<float> dstAudio((int)dims.maxChannels, 256);
juce::MidiBuffer drained;
REQUIRE(pair.sandbox.popInputBlock(dstAudio, drained, 256, 1000));
int n = 0;
for ([[maybe_unused]] const auto& meta : drained) ++n;
CHECK(n == 1); // SysEx dropped, CC survives.
const uint64_t overflowsAfter = pair.host.diagMidiOverflows();
CHECK(overflowsAfter == overflowsBefore + 1);
}
void testOverCapBumpsOverflow()
{
std::printf("test: events past kMidiEventsPerSlot drop + bump overflows\n");
AudioDimensions dims;
ChannelPair pair{dims};
REQUIRE(pair.ok);
juce::AudioBuffer<float> srcAudio((int)dims.maxChannels, 256);
srcAudio.clear();
juce::MidiBuffer midi;
// Push kMidiEventsPerSlot + 8 events — the trailing 8 should be dropped.
constexpr int kExtra = 8;
const int total = (int)kMidiEventsPerSlot + kExtra;
for (int i = 0; i < total; ++i)
midi.addEvent(juce::MidiMessage::controllerEvent(1, 7, i & 0x7F), i % 256);
const uint64_t overflowsBefore = pair.host.diagMidiOverflows();
REQUIRE(pair.host.pushInputBlock(srcAudio, midi, 256));
juce::AudioBuffer<float> dstAudio((int)dims.maxChannels, 256);
juce::MidiBuffer drained;
REQUIRE(pair.sandbox.popInputBlock(dstAudio, drained, 256, 1000));
int n = 0;
for ([[maybe_unused]] const auto& meta : drained) ++n;
CHECK(n == (int)kMidiEventsPerSlot);
const uint64_t overflowsAfter = pair.host.diagMidiOverflows();
CHECK(overflowsAfter == overflowsBefore + (uint64_t)kExtra);
}
void testFramePastSamplesDropped()
{
std::printf("test: events past block samples drop + bump overflows\n");
AudioDimensions dims;
dims.maxBlockSamples = 128;
ChannelPair pair{dims};
REQUIRE(pair.ok);
juce::AudioBuffer<float> srcAudio((int)dims.maxChannels, 128);
srcAudio.clear();
juce::MidiBuffer midi;
// Caller passes numSamples=128 (within cap). Events at frames >= 128
// should DROP rather than clamp into the audible portion (which would
// silently re-time them, the worse failure mode).
midi.addEvent(juce::MidiMessage::noteOn(1, 60, (juce::uint8)100), 50); // in-range
midi.addEvent(juce::MidiMessage::noteOn(1, 61, (juce::uint8)100), 127); // last in-range frame
midi.addEvent(juce::MidiMessage::noteOn(1, 62, (juce::uint8)100), 128); // out-of-range (= samples)
midi.addEvent(juce::MidiMessage::noteOn(1, 63, (juce::uint8)100), 200); // out-of-range
const uint64_t overflowsBefore = pair.host.diagMidiOverflows();
REQUIRE(pair.host.pushInputBlock(srcAudio, midi, 128));
juce::AudioBuffer<float> dstAudio((int)dims.maxChannels, 128);
juce::MidiBuffer drained;
REQUIRE(pair.sandbox.popInputBlock(dstAudio, drained, 128, 1000));
int n = 0;
int lastFrame = -1;
for (const auto& meta : drained) { ++n; lastFrame = meta.samplePosition; }
CHECK(n == 2); // events at 50 and 127
CHECK(lastFrame == 127); // 128 and 200 dropped, NOT clamped to 127
const uint64_t overflowsAfter = pair.host.diagMidiOverflows();
CHECK(overflowsAfter == overflowsBefore + 2);
}
void testNumSamplesOverCapRejected()
{
std::printf("test: numSamples > maxSamples rejected up front\n");
AudioDimensions dims;
dims.maxBlockSamples = 128;
ChannelPair pair{dims};
REQUIRE(pair.ok);
juce::AudioBuffer<float> srcAudio((int)dims.maxChannels, 256);
srcAudio.clear();
juce::MidiBuffer midi;
midi.addEvent(juce::MidiMessage::noteOn(1, 60, (juce::uint8)100), 50);
// Caller passes numSamples=256 but spawn cap is 128. Old behavior was
// silently truncate audio + drop MIDI in [128, 256). New behavior:
// return false up front so the misuse is visible to the caller. No
// shm counter is bumped (caller misuse is a distinct class from
// real-dropout / ring-full, and dropouts/xruns are reserved for
// those — see the comment in pushInputBlock).
CHECK(! pair.host.pushInputBlock(srcAudio, midi, 256));
}
void testSlotReuseAcrossWraparound()
{
// Push/pop more blocks than the ring has slots so each slot is used
// multiple times. Catches a regression in the "count is always
// overwritten on push" invariant — if pushInputBlock ever skipped the
// count store on a slot whose prior cycle had MIDI events, the next
// pop would replay those stale events against the fresh audio.
std::printf("test: slot reuse across ring wrap-around (no MIDI leakage)\n");
AudioDimensions dims;
// Pin maxBlocks explicitly: the modulus-coprime reasoning below depends
// on it. If AudioDimensions{}'s default ever changes, this test would
// silently stop exercising the slot-reuse-with-different-counts property.
constexpr uint32_t kRingSize = 4;
dims.maxBlocks = kRingSize;
ChannelPair pair{dims};
REQUIRE(pair.ok);
juce::AudioBuffer<float> srcAudio((int)dims.maxChannels, 256);
srcAudio.clear();
juce::AudioBuffer<float> dstAudio((int)dims.maxChannels, 256);
// Run enough cycles for every slot to be reused multiple times.
// 3*maxBlocks + 2 = 14 cycles with maxBlocks=4 means each slot is hit
// 3 or 4 times.
const int kCycles = 3 * (int)dims.maxBlocks + 2;
// Vary the MIDI count per block so a leaked stale count from a prior
// cycle on the SAME slot would show up as a wrong-count assertion.
// Modulus must be COPRIME with maxBlocks (4) — using `i % 4` would
// make each slot see the same count on every wrap (defeating the
// test). 5 is coprime with 4: slot 0 across cycles 0/4/8/12 sees
// counts 0/4/3/2, so a stale count from the prior visit would mismatch.
constexpr int kEventCountModulus = 5;
// Real coprimality check (not just oddness — those happen to coincide for
// kRingSize=4 because 4 = 2², but a future bump to e.g. 6 would let
// odd-but-not-coprime values like 9 silently slip through and defeat the
// stale-count detection).
constexpr auto gcd = [](int a, int b)
{
while (b != 0) { a %= b; auto t = a; a = b; b = t; }
return a;
};
static_assert(gcd((int)kRingSize, kEventCountModulus) == 1,
"kEventCountModulus must stay coprime with kRingSize — "
"otherwise each ring slot sees the same MIDI-event count "
"on every wrap and the stale-count regression test "
"becomes trivially-passing.");
for (int i = 0; i < kCycles; ++i)
{
juce::MidiBuffer midi;
const int eventCount = i % kEventCountModulus; // 0, 1, 2, 3, 4, 0, 1, ...
for (int e = 0; e < eventCount; ++e)
midi.addEvent(juce::MidiMessage::controllerEvent(1, 7, e * 16),
e * 32);
REQUIRE(pair.host.pushInputBlock(srcAudio, midi, 256));
juce::MidiBuffer drained;
REQUIRE(pair.sandbox.popInputBlock(dstAudio, drained, 256, 1000));
int n = 0;
for ([[maybe_unused]] const auto& meta : drained) ++n;
CHECK(n == eventCount);
}
}
void testThreadedProducerConsumer()
{
// Cross-thread loopback: a producer thread pushes ordered blocks while a
// consumer thread drains them, both blocking on the real doorbell
// (Win32 auto-reset events / POSIX socketpair). This is the case the
// single-threaded tests above can't cover — the producer/consumer
// happens-before edge runs through the shared atomic write index plus the
// doorbell wake, and is what ThreadSanitizer actually inspects. Each block
// carries a unique audio marker + a varying MIDI count so a torn handoff,
// a dropped/duplicated block, or stale-slot MIDI would surface as a
// mismatch rather than passing silently.
std::printf("test: threaded producer/consumer over the doorbell\n");
AudioDimensions dims; // 4 blocks × 1024 samples × 2 ch
ChannelPair pair{dims};
REQUIRE(pair.ok);
constexpr int kBlocks = 4000;
const int samples = 256;
std::atomic<bool> producerOk{true};
std::atomic<int> mismatches{0};
std::thread producer([&]
{
juce::AudioBuffer<float> src((int)dims.maxChannels, samples);
for (int i = 0; i < kBlocks; ++i)
{
// Unique per-block marker in sample 0 of every channel.
src.clear();
for (int ch = 0; ch < (int)dims.maxChannels; ++ch)
src.setSample(ch, 0, (float)i);
juce::MidiBuffer midi;
const int eventCount = i % 7; // 0..6 events, < kMidiEventsPerSlot
for (int e = 0; e < eventCount; ++e)
midi.addEvent(juce::MidiMessage::controllerEvent(1, 7, e & 0x7F),
e); // frames 0..5 < samples
// The host audio thread would drop on a full ring (xrun); this
// test wants lossless ordering, so spin-retry until the consumer
// frees a slot. yield() keeps it from starving the consumer.
int spins = 0;
while (!pair.host.pushInputBlock(src, midi, samples))
{
std::this_thread::yield();
if (++spins > 50'000'000) { producerOk.store(false); return; }
}
}
});
juce::AudioBuffer<float> dst((int)dims.maxChannels, samples);
for (int i = 0; i < kBlocks; ++i)
{
juce::MidiBuffer drained;
// popInputBlock returns false on a coalesced / spurious doorbell wake
// (it rechecks the ring index, finds nothing new yet, and returns) —
// that is NOT a lost block, just "try again", exactly as the real
// runAudioThread loops. Retry until the real block arrives; the
// doorbell byte is sticky (socket-buffered) so there is no lost-wakeup
// window. A genuine stall (producer died) trips the bounded retry cap.
bool got = false;
for (int tries = 0; tries < 2'000'000 && !got; ++tries)
{
drained.clear();
got = pair.sandbox.popInputBlock(dst, drained, samples, 5000);
if (!got) std::this_thread::yield();
}
if (!got) { ++mismatches; break; }
if (dst.getSample(0, 0) != (float)i) ++mismatches; // ordering / torn handoff
int n = 0;
for ([[maybe_unused]] const auto& meta : drained) ++n;
if (n != i % 7) ++mismatches; // stale-slot MIDI
}
producer.join();
CHECK(producerOk.load());
CHECK(mismatches.load() == 0);
// xruns are EXPECTED here: the spin-retry producer deliberately hammers a
// full ring (the real host audio thread would drop instead), so xruns
// climbing just means back-pressure worked — not asserted. What matters is
// that every block arrived exactly once, in order, with its MIDI intact.
}
} // namespace
int main()
{
std::printf("=== audio_channel_midi_test ===\n");
testRoundtripSmallBuffer();
testSysExBumpsOverflow();
testOverCapBumpsOverflow();
testFramePastSamplesDropped();
testNumSamplesOverCapRejected();
testSlotReuseAcrossWraparound();
testThreadedProducerConsumer();
std::printf("\n%d passed, %d failed\n", g_passed, g_failed);
return g_failed == 0 ? 0 : 1;
}
+218
View File
@@ -0,0 +1,218 @@
// control_channel_test — exercise the ControlChannel request/reply/event
// machinery + transport over an in-process loopback, without spawning a
// subprocess. The "host" (server) and "sandbox" (client) ControlChannels are
// wired together through the POSIX socketpair handoff (createServerSide →
// sandboxFd → connectClientSideFd), so the framing, the poll()-driven I/O
// thread, the pending-promise map, and peer-close detection all run for real.
//
// POSIX-only: it uses connectClientSideFd / sandboxFd (the Windows transport
// is a named pipe re-opened by name and is covered by its own path).
#include <juce_core/juce_core.h>
#include "../../src/audio/Sandbox/Protocol.h"
#include "../../src/audio/Sandbox/ControlChannel.h"
#include <atomic>
#include <chrono>
#include <cstdio>
#include <thread>
using namespace slopsmith::sandbox;
namespace {
int g_failed = 0;
int g_passed = 0;
void check(bool cond, const char* what, const char* file, int line)
{
if (cond) { ++g_passed; return; }
++g_failed;
std::fprintf(stderr, " FAIL: %s (%s:%d)\n", what, file, line);
}
#define CHECK(cond) check((cond), #cond, __FILE__, __LINE__)
#define REQUIRE(cond) \
do { if (!(cond)) { check(false, #cond, __FILE__, __LINE__); return; } } while (0)
// Spin-wait up to timeoutMs for a predicate to hold. Keeps the tests free of
// fixed sleeps that would be either flaky or slow.
template <typename Pred>
bool waitFor(Pred p, int timeoutMs = 2000)
{
const auto deadline = std::chrono::steady_clock::now()
+ std::chrono::milliseconds(timeoutMs);
while (std::chrono::steady_clock::now() < deadline)
{
if (p()) return true;
std::this_thread::sleep_for(std::chrono::milliseconds(1));
}
return p();
}
// A connected host+sandbox ControlChannel pair over a socketpair. The sandbox
// side installs an echo request handler; the host side records events +
// disconnects.
struct ChannelPair
{
ControlChannel host; // server
ControlChannel sandbox; // client
juce::String err;
bool ok = false;
std::atomic<int> hostEventCount{0};
juce::String lastEvent;
std::atomic<int> sandboxRequestCount{0};
std::atomic<bool> hostDisconnected{false};
juce::String hostDisconnectReason;
ChannelPair()
{
juce::String unusedName;
if (!host.createServerSide(unusedName, err)) return;
if (!sandbox.connectClientSideFd(host.sandboxFd(), err)) return;
// Sandbox echoes "ping" args back; rejects anything else; counts
// fire-and-forget "noop". Must be installed before start().
sandbox.setRequestHandler([this](int id, const juce::String& op,
const juce::var& args)
{
++sandboxRequestCount;
if (id < 0) return; // fire-and-forget, no reply
if (op == "ping") sandbox.sendReply(id, true, args);
else sandbox.sendReply(id, false, {}, "unknown op");
});
const bool sb = sandbox.start(
/*onEvent*/ [](const juce::String&, const juce::var&) {},
/*onDisconnect*/ [](const juce::String&) {});
const bool hb = host.start(
[this](const juce::String& ev, const juce::var&)
{
lastEvent = ev;
++hostEventCount;
},
[this](const juce::String& reason)
{
hostDisconnectReason = reason;
hostDisconnected.store(true);
});
ok = sb && hb;
if (!ok)
err = "start failed: host=" + host.getLastStartError()
+ " sandbox=" + sandbox.getLastStartError();
}
~ChannelPair()
{
// Stop both channels (joining their I/O threads) BEFORE the recording
// members below are destroyed — the I/O threads' onEvent/onDisconnect
// callbacks capture `this` and write those members. Stop the host
// first: host.stop() clears `alive`, so a teardown-triggered failWith
// becomes a no-op and never touches our fields. This mirrors the real
// SandboxedProcessor::teardown ordering invariant.
host.stop();
sandbox.stop();
}
};
void testRequestReply()
{
std::printf("test: request/reply round-trip (echo)\n");
ChannelPair pair;
REQUIRE(pair.ok);
juce::DynamicObject::Ptr argObj(new juce::DynamicObject());
argObj->setProperty("n", 42);
argObj->setProperty("s", "hello");
juce::String reqErr;
juce::var result = pair.host.request("ping", juce::var(argObj.get()),
/*timeoutMs*/ 2000, &reqErr);
CHECK(reqErr.isEmpty());
CHECK(result.isObject());
CHECK((int)result.getProperty("n", -1) == 42);
CHECK(result.getProperty("s", "").toString() == "hello");
}
void testRequestError()
{
std::printf("test: request to unknown op returns error\n");
ChannelPair pair;
REQUIRE(pair.ok);
juce::String reqErr;
juce::var result = pair.host.request("nope", juce::var(), 2000, &reqErr);
CHECK(result.isVoid());
CHECK(reqErr == "unknown op");
}
void testEvent()
{
std::printf("test: sandbox-originated event reaches host\n");
ChannelPair pair;
REQUIRE(pair.ok);
juce::DynamicObject::Ptr data(new juce::DynamicObject());
data->setProperty("pluginName", "TestPlug");
CHECK(pair.sandbox.sendEvent(event::kReady, juce::var(data.get())));
CHECK(waitFor([&] { return pair.hostEventCount.load() >= 1; }));
CHECK(pair.lastEvent == juce::String(event::kReady));
}
void testPostNoReply()
{
std::printf("test: fire-and-forget reaches the sandbox handler\n");
ChannelPair pair;
REQUIRE(pair.ok);
CHECK(pair.host.postNoReply("noop", juce::var()));
CHECK(waitFor([&] { return pair.sandboxRequestCount.load() >= 1; }));
}
void testManyRequests()
{
std::printf("test: 500 sequential requests, all matched\n");
ChannelPair pair;
REQUIRE(pair.ok);
int okCount = 0;
for (int i = 0; i < 500; ++i)
{
juce::DynamicObject::Ptr a(new juce::DynamicObject());
a->setProperty("n", i);
juce::String e;
juce::var r = pair.host.request("ping", juce::var(a.get()), 2000, &e);
if (e.isEmpty() && (int)r.getProperty("n", -1) == i) ++okCount;
}
CHECK(okCount == 500);
}
void testPeerClosedDetected()
{
std::printf("test: sandbox close → host sees peer-closed disconnect\n");
ChannelPair pair;
REQUIRE(pair.ok);
// Tear down the sandbox end; the host I/O thread should read EOF and
// classify it as a clean peer close (not a read error).
pair.sandbox.stop();
CHECK(waitFor([&] { return pair.hostDisconnected.load(); }));
CHECK(pair.hostDisconnectReason == ControlChannel::kReasonPeerClosed);
}
} // namespace
int main()
{
std::printf("=== control_channel_test ===\n");
testRequestReply();
testRequestError();
testEvent();
testPostNoReply();
testManyRequests();
testPeerClosedDetected();
std::printf("\n%d passed, %d failed\n", g_passed, g_failed);
return g_failed == 0 ? 0 : 1;
}
+110
View File
@@ -0,0 +1,110 @@
# Standalone end-to-end harness for the out-of-process sandbox runtime.
# JUCE-only (no cmake-js / node-addon-api / ONNX): builds a passthrough VST3
# fixture, the real slopsmith-vst-host child, and a host-side driver that spawns
# the child, loads the plugin, and round-trips audio over the shm ring.
#
# Heavier than tests/sandbox/standalone (it pulls in juce_audio_processors +
# juce_gui_basics + a VST3), so it lives in its own bootstrap / CI job. POSIX
# only — the e2e driver uses the fd-passing host API.
#
# cmake -S tests/sandbox/e2e -B build/e2e -DCMAKE_BUILD_TYPE=Debug
# cmake --build build/e2e
# ctest --test-dir build/e2e --output-on-failure
cmake_minimum_required(VERSION 3.22)
project(slopsmith_sandbox_e2e VERSION 1.0.0 LANGUAGES C CXX)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
get_filename_component(REPO_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/../../.." ABSOLUTE)
if(NOT EXISTS "${REPO_ROOT}/JUCE/CMakeLists.txt")
message(FATAL_ERROR "JUCE submodule not found at ${REPO_ROOT}/JUCE. "
"Run: git submodule update --init --recursive")
endif()
if(WIN32)
message(FATAL_ERROR "The sandbox e2e harness is POSIX-only (fd-passing host API).")
endif()
add_subdirectory("${REPO_ROOT}/JUCE" juce_build)
set(SANDBOX "${REPO_ROOT}/src/audio/Sandbox")
enable_testing()
# --- passthrough VST3 fixture (doubles its input) ---
juce_add_plugin(SlopPassThrough
PRODUCT_NAME "SlopPassThrough"
COMPANY_NAME "Slop"
PLUGIN_MANUFACTURER_CODE Slop
PLUGIN_CODE Sptp
FORMATS VST3
IS_SYNTH FALSE
NEEDS_MIDI_INPUT FALSE
VST3_CATEGORIES Fx)
target_sources(SlopPassThrough PRIVATE passthrough.cpp)
target_compile_definitions(SlopPassThrough PRIVATE
JUCE_WEB_BROWSER=0 JUCE_USE_CURL=0 JUCE_VST3_CAN_REPLACE_VST2=0)
target_link_libraries(SlopPassThrough PRIVATE
juce::juce_audio_utils juce::juce_audio_processors juce::juce_gui_basics
juce::juce_audio_plugin_client)
# --- the real vst-host child (POSIX sources) ---
add_executable(slopsmith-vst-host
"${REPO_ROOT}/src/vst-host/main.cpp"
"${REPO_ROOT}/src/audio/VSTHost.cpp"
"${SANDBOX}/Protocol.cpp"
"${SANDBOX}/ControlChannel_shared.cpp" "${SANDBOX}/ControlChannel_posix.cpp"
"${SANDBOX}/AudioChannel_shared.cpp" "${SANDBOX}/AudioChannel_posix.cpp")
target_include_directories(slopsmith-vst-host PRIVATE "${REPO_ROOT}/src/audio")
target_link_libraries(slopsmith-vst-host PRIVATE
juce::juce_audio_basics juce::juce_audio_devices juce::juce_audio_formats
juce::juce_audio_processors juce::juce_core juce::juce_data_structures
juce::juce_dsp juce::juce_events juce::juce_graphics juce::juce_gui_basics)
target_compile_definitions(slopsmith-vst-host PRIVATE
JUCE_PLUGINHOST_VST3=1 JUCE_PLUGINHOST_AU=0 JUCE_PLUGINHOST_LV2=0
JUCE_WEB_BROWSER=0 JUCE_USE_CURL=0 JUCE_DISPLAY_SPLASH_SCREEN=0
JUCE_MODAL_LOOPS_PERMITTED=1 JUCE_STANDALONE_APPLICATION=1 JUCE_REPORT_APP_USAGE=0)
# main.cpp calls XInitThreads/XSetErrorHandler directly on Linux to install a
# non-fatal X error handler (JUCE only does this for standalone JUCEApplications,
# which this child is not). JUCE itself dlopen()s libX11, but our direct calls
# need it link-time. Mirrors src/vst-host/CMakeLists.txt.
if(UNIX AND NOT APPLE)
find_package(X11 REQUIRED)
target_link_libraries(slopsmith-vst-host PRIVATE ${X11_LIBRARIES})
target_include_directories(slopsmith-vst-host PRIVATE ${X11_INCLUDE_DIR})
endif()
# --- host-side e2e driver ---
add_executable(sandbox_e2e_test
e2e_test.cpp
"${SANDBOX}/SandboxedProcessor.cpp"
"${SANDBOX}/SandboxFactory_shared.cpp" "${SANDBOX}/SandboxFactory_posix.cpp"
"${SANDBOX}/AudioChannel_shared.cpp" "${SANDBOX}/AudioChannel_posix.cpp"
"${SANDBOX}/ControlChannel_shared.cpp" "${SANDBOX}/ControlChannel_posix.cpp"
"${SANDBOX}/SubprocessHandle_posix.cpp"
"${SANDBOX}/Protocol.cpp")
target_include_directories(sandbox_e2e_test PRIVATE "${REPO_ROOT}/src/audio")
target_link_libraries(sandbox_e2e_test PRIVATE
juce::juce_audio_basics juce::juce_audio_devices juce::juce_audio_formats
juce::juce_audio_processors juce::juce_core juce::juce_dsp juce::juce_events)
target_compile_definitions(sandbox_e2e_test PRIVATE
JUCE_PLUGINHOST_VST3=1 JUCE_WEB_BROWSER=0 JUCE_USE_CURL=0
JUCE_STANDALONE_APPLICATION=0 JUCE_REPORT_APP_USAGE=0)
add_dependencies(sandbox_e2e_test slopsmith-vst-host SlopPassThrough_VST3)
# The VST3 bundle lands in <build>/SlopPassThrough_artefacts/<config>/VST3/.
add_test(NAME sandbox_e2e_test
COMMAND sandbox_e2e_test
"$<TARGET_FILE:slopsmith-vst-host>"
"${CMAKE_BINARY_DIR}/SlopPassThrough_artefacts/$<CONFIG>/VST3/SlopPassThrough.vst3")
# Orphan-cleanup regression (issue #265): host crash → child must not orphan.
# Linux-only — the driver's leak path + PR_SET_PDEATHSIG are JUCE_LINUX-gated;
# on macOS the env var is a no-op so this would assert clean-shutdown, not the
# crash path, which would be misleading. (UNIX AND NOT APPLE matches the X11
# linkage block above and excludes any other non-mac POSIX target.)
if(UNIX AND NOT APPLE)
add_test(NAME sandbox_e2e_leak
COMMAND bash "${CMAKE_CURRENT_SOURCE_DIR}/leak_test.sh"
"$<TARGET_FILE:sandbox_e2e_test>"
"$<TARGET_FILE:slopsmith-vst-host>"
"${CMAKE_BINARY_DIR}/SlopPassThrough_artefacts/$<CONFIG>/VST3/SlopPassThrough.vst3")
endif()
+156
View File
@@ -0,0 +1,156 @@
// e2e: drive a real SandboxedProcessor (host side) that spawns the real
// slopsmith-vst-host child, which loads the passthrough VST3 and processes
// audio over the shm ring. Proves the whole POSIX runtime: posix_spawn + fd
// inheritance + ready handshake + prepare + audio round-trip + state + shutdown.
//
// argv[1] = path to slopsmith-vst-host
// argv[2] = path to SlopPassThrough.vst3
#include "Sandbox/SandboxedProcessor.h"
#include <juce_audio_processors/juce_audio_processors.h>
#include <chrono>
#include <cmath>
#include <cstdio>
#include <cstdlib>
#include <thread>
using namespace slopsmith::sandbox;
static int g_pass = 0, g_fail = 0;
static void check(bool c, const char* what, int line)
{
if (c) { ++g_pass; return; }
++g_fail; std::fprintf(stderr, " FAIL: %s (line %d)\n", what, line);
}
#define CHECK(c) check((c), #c, __LINE__)
static bool allClose(const juce::AudioBuffer<float>& b, float v)
{
for (int ch = 0; ch < b.getNumChannels(); ++ch)
for (int i = 0; i < b.getNumSamples(); ++i)
if (std::abs(b.getSample(ch, i) - v) > 1.0e-4f) return false;
return true;
}
int main(int argc, char** argv)
{
if (argc < 3) { std::fprintf(stderr, "usage: e2e_test <vst-host> <plugin.vst3>\n"); return 2; }
SandboxedProcessor::SpawnConfig cfg;
cfg.pluginPath = juce::String::fromUTF8(argv[2]);
cfg.pluginName = "PassThrough";
cfg.sandboxExePath = juce::String::fromUTF8(argv[1]);
cfg.audio.sampleRate = 48000;
cfg.audio.maxBlockSamples = 256;
cfg.audio.maxChannels = 2;
cfg.audio.maxBlocks = 4;
cfg.spawnTimeoutMs = 20000;
std::printf("=== sandbox e2e: spawn → process → state → shutdown ===\n");
juce::String err;
auto sb = SandboxedProcessor::spawn(cfg, err);
CHECK(sb != nullptr);
if (!sb) { std::fprintf(stderr, "spawn failed: %s\n", err.toRawUTF8()); return 1; }
CHECK(sb->isAlive());
#if JUCE_LINUX
// Orphan-cleanup check (issue #265). When SLOPSMITH_E2E_LEAK_TEST is set,
// simulate a host *crash*: exit RIGHT NOW via _Exit, skipping sb's
// destructor — so no `shutdown` op and no SIGTERM→SIGKILL ladder ever runs.
// The child must still die, via PR_SET_PDEATHSIG (installLinuxParentDeathSignal
// in the child). The leak_test.sh wrapper reads the child pid from its log
// and asserts it is gone after this parent vanishes.
if (std::getenv("SLOPSMITH_E2E_LEAK_TEST") != nullptr)
{
std::printf("LEAK_TEST: child alive; crashing host without shutdown\n");
std::fflush(stdout);
std::_Exit(0);
}
#endif
sb->prepareToPlay(48000.0, 256);
juce::AudioBuffer<float> buf(2, 256);
juce::MidiBuffer midi;
// Pace at one block period (256 samples @ 48 kHz ≈ 5.33 ms, rounded up to
// 6 ms) so the host doesn't outrun the sandbox worker — a faster cadence
// would let the host's pop legitimately time out and read silence.
constexpr int kBlockPeriodMs = 6;
// A single constant level feeds both the warm-up and the steady-state loop.
// The sandbox is two independent rings (input, output), so it promises
// *bounded latency*, NOT exact per-block phase: if any block's round-trip
// overruns the pop timeout, the host inserts silence and moves on while the
// worker still produces that block's output, which shifts every later read
// one slot late. A distinct-per-block probe would then read the *previous*
// block's (valid, non-silent) output and flag it as a spurious mismatch —
// observed as a flaky "200 misvalued" on loaded CI runners. A constant
// level is phase-invariant: a lagged read still equals 2×kLevel (correct),
// a timed-out block is still silence (dropout), and a genuine scaling bug
// still produces a wrong value. In-phase slot correctness with distinct
// markers is covered by the deterministic standalone ring unit test.
constexpr float kLevel = 0.3f;
// Warm-up: a plugin's first few processBlock calls (VST3 activation,
// allocation, first-touch) can exceed one block period, so the sandbox
// inserts silence for those by design. Discard a warm-up burst so the
// steady-state assertions aren't measuring cold start.
for (int n = 0; n < 40; ++n)
{
for (int ch = 0; ch < 2; ++ch)
for (int i = 0; i < 256; ++i) buf.setSample(ch, i, kLevel);
sb->processBlock(buf, midi);
std::this_thread::sleep_for(std::chrono::milliseconds(kBlockPeriodMs));
}
// Steady state. Each delivered block MUST be exactly 2×kLevel (a real
// scaling bug surfaces as a wrong non-zero value → `misvalued`, which must
// be zero). A block that times out under load is returned as silence by
// SandboxedProcessor (by design) → counted as a dropout, tolerated in small
// numbers since a shared CI runner can stall a single round-trip past the
// pop timeout even when the runtime is correct.
int correct = 0, dropouts = 0, misvalued = 0;
constexpr int kBlocks = 200;
for (int n = 0; n < kBlocks; ++n)
{
for (int ch = 0; ch < 2; ++ch)
for (int i = 0; i < 256; ++i) buf.setSample(ch, i, kLevel);
sb->processBlock(buf, midi);
if (allClose(buf, kLevel * 2.0f)) ++correct;
else if (allClose(buf, 0.0f)) ++dropouts; // timed-out → silence
else ++misvalued; // wrong value → real bug
std::this_thread::sleep_for(std::chrono::milliseconds(kBlockPeriodMs));
}
std::printf(" steady-state: %d correct, %d dropouts, %d misvalued (of %d)\n",
correct, dropouts, misvalued, kBlocks);
CHECK(misvalued == 0); // every delivered block is exact
CHECK(correct >= kBlocks * 9 / 10); // overwhelmingly delivered (tolerate CI jitter)
// State round-trip: child returns the plugin's getStateInformation blob.
juce::MemoryBlock state;
sb->getStateInformation(state);
CHECK(state.getSize() > 0);
sb->setStateInformation(state.getData(), (int)state.getSize());
CHECK(sb->isAlive()); // setState shouldn't have torn the sandbox down
#if JUCE_MAC || JUCE_LINUX
// Editor open/close protocol: the child opens a floating top-level editor
// window in its own process (NSWindow on macOS, X11 window on Linux via
// JUCE 8's VST3 IRunLoop hosting) and the host tracks only the open bit.
// Proves the kOpenEditor round-trip + editorOpen tracking + kCloseEditor.
// Runs under xvfb on the Linux CI runner; visual focus/DPI is the one thing
// a headless runner can't verify (manual on real hardware).
CHECK(sb->hasEditor());
const bool opened = sb->requestOpenEditor();
CHECK(opened);
CHECK(sb->isEditorOpen());
sb->requestCloseEditor();
CHECK(!sb->isEditorOpen());
CHECK(sb->isAlive()); // open/close must not crash the child
#endif
sb.reset(); // destructor → shutdown op → SIGTERM ladder; must not hang
std::printf("\n%d passed, %d failed\n", g_pass, g_fail);
return g_fail == 0 ? 0 : 1;
}
+55
View File
@@ -0,0 +1,55 @@
#!/usr/bin/env bash
# Orphan-cleanup regression test (issue #265, Linux).
#
# A *crashed* host must not leave the slopsmith-vst-host child running and
# holding the audio device + shm. This drives the e2e driver in
# SLOPSMITH_E2E_LEAK_TEST mode — the driver _Exit()s the instant the child is
# alive, skipping its clean shutdown (no `shutdown` op, no SIGTERM ladder) —
# then asserts the child process is gone. Exercises both cleanup paths together:
# PR_SET_PDEATHSIG (installLinuxParentDeathSignal) and the control-socket
# disconnect teardown.
#
# leak_test.sh <e2e-driver> <vst-host> <plugin.vst3>
set -euo pipefail
E2E="${1:?usage: leak_test.sh <e2e-driver> <vst-host> <plugin.vst3>}"
HOST="${2:?missing vst-host path}"
PLUG="${3:?missing plugin path}"
# Fresh TMPDIR so we only see this run's child log (the child names its log
# $TMPDIR/slopsmith-vst-host-<pid>.log).
TMPDIR_RUN="$(mktemp -d)"
export TMPDIR="$TMPDIR_RUN"
trap 'rm -rf "$TMPDIR_RUN"' EXIT
SLOPSMITH_E2E_LEAK_TEST=1 "$E2E" "$HOST" "$PLUG" >/dev/null 2>&1 || true
# `|| true`: a no-match makes the ls pipeline non-zero, which would trip set -e —
# the empty-LOG case is handled explicitly just below.
LOG=$(ls -t "$TMPDIR_RUN"/slopsmith-vst-host-*.log 2>/dev/null | head -1 || true)
if [[ -z "${LOG:-}" ]]; then
echo "leak_test: FAIL — no child log produced (driver never spawned the host)"
exit 1
fi
# Anchored extract; if the name doesn't match, sed echoes it back unchanged, so
# validate the result is a bare pid — otherwise `kill -0` on garbage would fail
# and the test would PASS for the wrong reason.
CPID=$(basename "$LOG" | sed -E 's/^slopsmith-vst-host-([0-9]+)\.log$/\1/')
if [[ ! "$CPID" =~ ^[0-9]+$ ]]; then
echo "leak_test: FAIL — could not parse a numeric pid from log name '$LOG'"
exit 1
fi
echo "leak_test: host child pid=$CPID; driver has exited without clean shutdown"
# PDEATHSIG / disconnect are near-instant; poll up to ~5s for CI-runner slack.
for _ in $(seq 1 50); do
if ! kill -0 "$CPID" 2>/dev/null; then
echo "leak_test: PASS — child cleaned up after host crash"
exit 0
fi
sleep 0.1
done
echo "leak_test: FAIL — child $CPID still running 5s after host crash (orphan)"
kill -9 "$CPID" 2>/dev/null || true
exit 1
+49
View File
@@ -0,0 +1,49 @@
// Minimal passthrough VST3 fixture for the sandbox e2e test: doubles its
// input (×2) so the test can prove audio flowed host→sandbox→plugin→host,
// stores a 4-byte state blob so getState/setState round-trips are observable,
// and exposes a trivial editor so the editor open/close path is exercisable.
#include <juce_audio_processors/juce_audio_processors.h>
#include <juce_gui_basics/juce_gui_basics.h>
// Trivial fixed-size editor — enough for the sandbox child to create a
// top-level window and round-trip the open/close protocol.
class PassEditor : public juce::AudioProcessorEditor
{
public:
explicit PassEditor(juce::AudioProcessor& p) : juce::AudioProcessorEditor(p)
{ setSize(320, 200); }
void paint(juce::Graphics& g) override { g.fillAll(juce::Colours::black); }
};
class PassThrough : public juce::AudioProcessor
{
public:
PassThrough()
: juce::AudioProcessor(BusesProperties()
.withInput("In", juce::AudioChannelSet::stereo(), true)
.withOutput("Out", juce::AudioChannelSet::stereo(), true)) {}
const juce::String getName() const override { return "SlopPassThrough"; }
void prepareToPlay(double, int) override {}
void releaseResources() override {}
void processBlock(juce::AudioBuffer<float>& b, juce::MidiBuffer&) override
{
b.applyGain(2.0f); // ×2 — the e2e asserts output == 2 * input
}
double getTailLengthSeconds() const override { return 0.0; }
bool acceptsMidi() const override { return false; }
bool producesMidi() const override { return false; }
juce::AudioProcessorEditor* createEditor() override { return new PassEditor(*this); }
bool hasEditor() const override { return true; }
int getNumPrograms() override { return 1; }
int getCurrentProgram() override { return 0; }
void setCurrentProgram(int) override {}
const juce::String getProgramName(int) override { return {}; }
void changeProgramName(int, const juce::String&) override {}
void getStateInformation(juce::MemoryBlock& d) override { d.append("SLOP", 4); }
void setStateInformation(const void*, int) override {}
JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(PassThrough)
};
juce::AudioProcessor* JUCE_CALLTYPE createPluginFilter() { return new PassThrough(); }
+79
View File
@@ -0,0 +1,79 @@
// spawn_smoke_child — the child half of spawn_smoke_test. Stands in for the
// real slopsmith-vst-host (Slice 2) with the bare minimum: adopt the inherited
// control-socket fd, answer a couple of control ops, and exit. Exercises the
// SubprocessHandle POSIX spawn + fd-inheritance + ControlChannel handshake
// end-to-end across a real process boundary.
//
// --control-fd N the dup2()'d socketpair end the parent passed us
//
// Ops it understands (host → child):
// ping → echo args back (round-trip proof)
// exit → reply ok, then exit(0) (clean-shutdown proof)
// abort → std::abort() (crash-detection proof; no reply)
#include <juce_core/juce_core.h>
#include "../../src/audio/Sandbox/Protocol.h"
#include "../../src/audio/Sandbox/ControlChannel.h"
#include <atomic>
#include <chrono>
#include <cstdlib>
#include <thread>
using namespace slopsmith::sandbox;
int main(int argc, char** argv)
{
int controlFd = -1;
for (int i = 1; i < argc - 1; ++i)
if (juce::String(argv[i]) == "--control-fd")
controlFd = juce::String(argv[i + 1]).getIntValue();
if (controlFd < 0)
{
std::fprintf(stderr, "spawn_smoke_child: missing --control-fd\n");
return 2;
}
ControlChannel ctl;
juce::String err;
if (!ctl.connectClientSideFd(controlFd, err))
{
std::fprintf(stderr, "spawn_smoke_child: connect failed: %s\n",
err.toRawUTF8());
return 3;
}
std::atomic<bool> quit{false};
ctl.setRequestHandler([&](int id, const juce::String& op,
const juce::var& args)
{
if (op == "ping") { if (id >= 0) ctl.sendReply(id, true, args); }
else if (op == "exit") { if (id >= 0) ctl.sendReply(id, true, {});
quit.store(true); }
else if (op == "abort") { std::abort(); } // crash on purpose
else { if (id >= 0) ctl.sendReply(id, false, {},
"unknown op"); }
});
if (!ctl.start(/*onEvent*/ [](const juce::String&, const juce::var&) {},
/*onDisconnect*/ [&](const juce::String&) { quit.store(true); }))
{
std::fprintf(stderr, "spawn_smoke_child: start failed: %s\n",
ctl.getLastStartError().toRawUTF8());
return 4;
}
// Announce readiness, then run until told to exit / the parent drops the
// pipe. The 30 s safety deadline keeps a buggy test from leaving a zombie.
ctl.sendEvent(event::kReady, juce::var());
const auto deadline = std::chrono::steady_clock::now()
+ std::chrono::seconds(30);
while (!quit.load() && std::chrono::steady_clock::now() < deadline)
std::this_thread::sleep_for(std::chrono::milliseconds(5));
ctl.stop();
return 0;
}
+192
View File
@@ -0,0 +1,192 @@
// spawn_smoke_test — end-to-end across a real process boundary: posix_spawn a
// child (spawn_smoke_child), hand it the sandbox end of a control socketpair by
// fd inheritance, and drive it over ControlChannel. Validates the pieces the
// in-process loopback tests can't: SubprocessHandle::startPosix, fd
// inheritance, exit-code detection (clean + crash), and SIGPIPE suppression on
// a write to a dead peer.
//
// SPAWN_CHILD_PATH is injected by CMake as the absolute path to the child exe.
#include <juce_core/juce_core.h>
#include "../../src/audio/Sandbox/Protocol.h"
#include "../../src/audio/Sandbox/ControlChannel.h"
#include "../../src/audio/Sandbox/SubprocessHandle.h"
#include <atomic>
#include <chrono>
#include <cstdio>
#include <thread>
using namespace slopsmith::sandbox;
namespace {
int g_failed = 0;
int g_passed = 0;
void check(bool c, const char* what, const char* file, int line)
{
if (c) { ++g_passed; return; }
++g_failed;
std::fprintf(stderr, " FAIL: %s (%s:%d)\n", what, file, line);
}
#define CHECK(cond) check((cond), #cond, __FILE__, __LINE__)
#define REQUIRE(cond) \
do { if (!(cond)) { check(false, #cond, __FILE__, __LINE__); return; } } while (0)
template <typename Pred>
bool waitFor(Pred p, int timeoutMs = 5000)
{
const auto deadline = std::chrono::steady_clock::now()
+ std::chrono::milliseconds(timeoutMs);
while (std::chrono::steady_clock::now() < deadline)
{
if (p()) return true;
std::this_thread::sleep_for(std::chrono::milliseconds(2));
}
return p();
}
#ifndef SPAWN_CHILD_PATH
#error "SPAWN_CHILD_PATH must be defined by the build (path to spawn_smoke_child)."
#endif
// childFd 3 is the first fd past stdin/stdout/stderr; the child reads the
// number from argv.
constexpr int kChildControlFd = 3;
// Bring up the host control channel and spawn a child wired to it, in the
// required order: createServerSide (makes the socketpair) → host.start (begins
// reading) → startPosix (dup2()s the sandbox fd into the child) → closeSandboxFd
// (drop our copy so the host sees EOF on child death). Returns false on
// failure. `onExit` records the child's exit code.
bool spawnChild(ControlChannel& host, SubprocessHandle& sub,
ControlChannel::EventCallback onEvent,
std::function<void(const juce::String&)> onDisconnect,
std::atomic<int>& exitCode, std::atomic<bool>& exited)
{
juce::String unusedName, err;
if (!host.createServerSide(unusedName, err)) return false;
if (!host.start(std::move(onEvent), std::move(onDisconnect)))
{
std::fprintf(stderr, " spawnChild: host.start failed: %s\n",
host.getLastStartError().toRawUTF8());
return false;
}
juce::StringArray args;
args.add("--control-fd");
args.add(juce::String(kChildControlFd));
std::vector<SubprocessHandle::InheritedFd> inherited{
{ kChildControlFd, host.sandboxFd() }
};
const bool ok = sub.startPosix(SPAWN_CHILD_PATH, args, inherited,
[&](int code) { exitCode.store(code); exited.store(true); }, err);
// The host has its own end; close our copy of the child's end so the host
// observes EOF when the child dies (otherwise crash detection never fires).
host.closeSandboxFd();
if (!ok)
std::fprintf(stderr, " spawnChild: startPosix failed: %s\n",
err.toRawUTF8());
return ok;
}
void testSpawnHandshakeAndCleanExit()
{
std::printf("test: spawn → ready handshake → ping round-trip → clean exit\n");
ControlChannel host;
SubprocessHandle sub;
std::atomic<int> exitCode{-999};
std::atomic<bool> exited{false};
std::atomic<bool> gotReady{false};
REQUIRE(spawnChild(host, sub,
[&](const juce::String& ev, const juce::var&)
{ if (ev == juce::String(event::kReady)) gotReady.store(true); },
[](const juce::String&) {},
exitCode, exited));
// fd inheritance + child connect + event delivery across the process line.
CHECK(waitFor([&] { return gotReady.load(); }));
// Bidirectional round-trip over the inherited socket.
juce::DynamicObject::Ptr a(new juce::DynamicObject());
a->setProperty("n", 7);
juce::String e;
juce::var r = host.request("ping", juce::var(a.get()), 3000, &e);
CHECK(e.isEmpty());
CHECK((int)r.getProperty("n", -1) == 7);
// Ask the child to exit cleanly; watcher should report code 0.
host.request("exit", juce::var(), 3000, &e);
CHECK(waitFor([&] { return exited.load(); }));
CHECK(exitCode.load() == 0);
host.stop();
}
void testCrashDetection()
{
std::printf("test: child abort() → watcher reports a non-zero exit\n");
ControlChannel host;
SubprocessHandle sub;
std::atomic<int> exitCode{-999};
std::atomic<bool> exited{false};
std::atomic<bool> disconnected{false};
REQUIRE(spawnChild(host, sub,
[](const juce::String&, const juce::var&) {},
[&](const juce::String&) { disconnected.store(true); },
exitCode, exited));
// Fire-and-forget abort: the child crashes without replying.
host.postNoReply("abort", juce::var());
CHECK(waitFor([&] { return exited.load(); }));
CHECK(exitCode.load() != 0); // SIGABRT → 128 + 6 = 134
// The host's I/O thread should also see the pipe drop.
CHECK(waitFor([&] { return disconnected.load(); }));
host.stop();
}
void testWriteToDeadPeerNoSigpipe()
{
std::printf("test: writing to a dead child returns false, no SIGPIPE\n");
ControlChannel host;
SubprocessHandle sub;
std::atomic<int> exitCode{-999};
std::atomic<bool> exited{false};
REQUIRE(spawnChild(host, sub,
[](const juce::String&, const juce::var&) {},
[](const juce::String&) {},
exitCode, exited));
// Kill the child and wait for the exit to be observed.
host.request("exit", juce::var(), 3000);
CHECK(waitFor([&] { return exited.load(); }));
// Give the host I/O thread a moment to mark the channel not-alive.
waitFor([&] { return !host.isAlive(); }, 2000);
// Writing now must fail gracefully — NOT raise SIGPIPE and kill us. If
// SIGPIPE weren't suppressed this process would have died before here.
const bool posted = host.postNoReply("ping", juce::var());
CHECK(!posted);
std::printf(" (survived the write to a dead peer; SIGPIPE suppressed)\n");
host.stop();
}
} // namespace
int main()
{
std::printf("=== spawn_smoke_test ===\n");
testSpawnHandshakeAndCleanExit();
testCrashDetection();
testWriteToDeadPeerNoSigpipe();
std::printf("\n%d passed, %d failed\n", g_passed, g_failed);
return g_failed == 0 ? 0 : 1;
}
+30
View File
@@ -0,0 +1,30 @@
# Standalone bootstrap for the sandbox IPC tests — JUCE only, no cmake-js /
# node-addon-api / ONNX. Lets a Linux-only developer (and the lightweight
# `sandbox.yml` CI job) build + run the loopback / control / spawn tests
# without configuring the whole native addon.
#
# Usage (from the repo root):
# cmake -S tests/sandbox/standalone -B build/sandbox -DCMAKE_BUILD_TYPE=Debug
# cmake --build build/sandbox
# ctest --test-dir build/sandbox --output-on-failure
#
# Sanitized variants:
# cmake -S tests/sandbox/standalone -B build/sandbox-tsan -DSLOPSMITH_SANITIZE=thread
cmake_minimum_required(VERSION 3.22)
project(slopsmith_sandbox_tests CXX)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
# This file lives at tests/sandbox/standalone — the repo root is three up.
get_filename_component(REPO_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/../../.." ABSOLUTE)
if(NOT EXISTS "${REPO_ROOT}/JUCE/CMakeLists.txt")
message(FATAL_ERROR "JUCE submodule not found at ${REPO_ROOT}/JUCE. "
"Run: git submodule update --init --recursive")
endif()
add_subdirectory("${REPO_ROOT}/JUCE" juce_build)
enable_testing()
# Reuse the single source-of-truth test definitions.
add_subdirectory("${REPO_ROOT}/tests/sandbox" sandbox_tests)
+28
View File
@@ -0,0 +1,28 @@
# Phase 0 de-risk spike — standalone, NOT part of the addon build.
#
# Build:
# cmake -B build -DONNXRUNTIME_ROOT=/path/to/onnxruntime-linux-x64-1.20.1
# cmake --build build
#
# ONNXRUNTIME_ROOT must point at an extracted prebuilt ONNX Runtime release
# (the dir containing include/ and lib/).
cmake_minimum_required(VERSION 3.22)
project(bp_spike CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
if(NOT DEFINED ONNXRUNTIME_ROOT)
message(FATAL_ERROR "Set -DONNXRUNTIME_ROOT=/path/to/onnxruntime-<os>-<arch>-<ver>")
endif()
add_executable(spike main.cpp)
target_include_directories(spike PRIVATE "${ONNXRUNTIME_ROOT}/include")
target_link_directories(spike PRIVATE "${ONNXRUNTIME_ROOT}/lib")
target_link_libraries(spike PRIVATE onnxruntime)
# Load libonnxruntime.so from next to the binary or from ONNXRUNTIME_ROOT/lib.
set_target_properties(spike PROPERTIES
BUILD_RPATH "${ONNXRUNTIME_ROOT}/lib"
INSTALL_RPATH "$ORIGIN")
+63
View File
@@ -0,0 +1,63 @@
# Phase 0 de-risk spike — polyphonic ML note detection
**Throwaway.** Not part of the addon build. Proves Spotify Basic Pitch runs
under ONNX Runtime's C++ API before the real engine integration begins.
## Provenance
- **Model:** `nmp.onnx` from the `basic-pitch` PyPI package, v0.4.0
(`basic_pitch/saved_models/icassp_2022/nmp.onnx`, 230 KB).
Spotify Basic Pitch, **Apache-2.0**. The package ships a clean ONNX export —
no `tf2onnx` conversion needed.
- **ONNX Runtime:** v1.20.1, official prebuilt CPU release.
- Linux x64: `onnxruntime-linux-x64-1.20.1.tgz`
SHA-256 `67db4dc1561f1e3fd42e619575c82c601ef89849afc7ea85a003abbac1a1a105`
- URL pattern: `https://github.com/microsoft/onnxruntime/releases/download/v1.20.1/onnxruntime-<os-arch>-1.20.1.<ext>`
## Build & run
```sh
cmake -B build -DONNXRUNTIME_ROOT=/path/to/onnxruntime-linux-x64-1.20.1
cmake --build build
./build/spike /path/to/nmp.onnx test_guitar.wav
```
`test_guitar.wav` is a 48 kHz synthetic Karplus-Strong guitar clip: single
notes A2 / D3 / G3 at t≈0.3/1.3/2.3 s, then a C-major triad (C3+E3+G3) at
t≈3.3 s.
## Model I/O contract (verified)
- **Input** `serving_default_input_2:0``[batch, 43844, 1]` float32.
43844 = 22050·2 256, a ~2 s mono window at **22050 Hz**.
- **Outputs** (3 posteriorgrams, ~86 frames/s, 172 frames/window):
- `StatefulPartitionedCall:1`**note/frame** `[batch, 172, 88]`
- `StatefulPartitionedCall:2`**onset** `[batch, 172, 88]`
- `StatefulPartitionedCall:0`**contour** `[batch, 172, 264]` (unused)
- 88 pitches = MIDI 21..108 (pitch index `p` → MIDI `21 + p`).
## Post-processing (minimal slice ported to C++)
A note onset = a rising edge of the onset posteriorgram past 0.5, gated by the
frame posteriorgram past 0.3:
`onset[f,p] ≥ 0.5 && onset[f-1,p] < 0.5 && note[f,p] ≥ 0.3`.
This is all the live hit/miss path needs — no full offline note-event
reconstruction.
## Findings
- **Accuracy:** all 4 events detected at the correct MIDI and time; the C-major
triad resolved polyphonically (C3+E3+G3). Zero false positives in the C++ run.
- **Latency:** 33 ms/window inference, single-threaded
(`IntraOpNumThreads=1`), ONNX Runtime 1.20.1, CPU EP. With a 64 ms hop,
end-to-end detection latency (hop + inference + model onset lag) lands
≈100150 ms — the ≤150 ms target is reachable; a 128 ms hop trades latency
(~180200 ms) for lower CPU.
- **Window boundaries:** non-overlapping ~2 s windows can re-onset a sustained
note at a window edge. The production `MlNoteDetector` avoids this with a
rolling 22050 Hz buffer, reading only the freshest frames each hop.
- **Resampling:** the spike uses Catmull-Rom cubic interpolation for
48000→22050; the production detector will use `juce::LagrangeInterpolator`.
**Conclusion: de-risked. Model + ONNX Runtime C++ work; output is
interpretable. Proceed to Phase 1.**
+252
View File
@@ -0,0 +1,252 @@
// Phase 0 de-risk spike — TST-style polyphonic ML note detection.
//
// THROWAWAY. Not wired into the addon build. Proves that Spotify Basic Pitch
// (nmp.onnx) loads and runs under ONNX Runtime's C++ API, that the I/O contract
// matches expectations, and that a minimal onset/frame post-processing yields
// interpretable MIDI notes. See README.md for provenance.
//
// Build: see CMakeLists.txt in this directory.
// Run: ./spike <model.onnx> <audio.wav>
#include <onnxruntime_cxx_api.h>
#include <algorithm>
#include <chrono>
#include <cmath>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <fstream>
#include <iostream>
#include <string>
#include <vector>
// --- Basic Pitch constants (basic_pitch/constants.py) ---------------------
static constexpr int kModelSampleRate = 22050;
static constexpr int kFftHop = 256;
static constexpr int kAudioNSamples = 22050 * 2 - 256; // 43844, ~2 s window
static constexpr int kFramesPerSecond = 22050 / 256; // 86
static constexpr int kNumPitches = 88; // MIDI 21..108
static constexpr int kLowestMidi = 21; // A0, base freq 27.5 Hz
static constexpr float kOnsetThreshold = 0.5f;
static constexpr float kFrameThreshold = 0.3f;
// nmp.onnx tensor names (verified via the Python ONNX Runtime in Phase 0).
static const char* kInputName = "serving_default_input_2:0";
static const char* kNoteOutput = "StatefulPartitionedCall:1"; // frame/note posteriorgram
static const char* kOnsetOutput= "StatefulPartitionedCall:2"; // onset posteriorgram
// --------------------------------------------------------------------------
// Minimal WAV reader: 16-bit PCM or 32-bit float, any channel count -> mono.
// --------------------------------------------------------------------------
struct Wav { std::vector<float> samples; int sampleRate = 0; };
static uint32_t rdU32(const uint8_t* p) { return p[0] | (p[1]<<8) | (p[2]<<16) | (uint32_t(p[3])<<24); }
static uint16_t rdU16(const uint8_t* p) { return uint16_t(p[0] | (p[1]<<8)); }
static bool readWav(const std::string& path, Wav& out)
{
std::ifstream f(path, std::ios::binary);
if (!f) { std::cerr << "cannot open " << path << "\n"; return false; }
std::vector<uint8_t> buf((std::istreambuf_iterator<char>(f)), std::istreambuf_iterator<char>());
if (buf.size() < 44 || std::memcmp(buf.data(), "RIFF", 4) || std::memcmp(buf.data()+8, "WAVE", 4))
{ std::cerr << "not a RIFF/WAVE file\n"; return false; }
uint16_t fmt = 0, channels = 0, bits = 0;
uint32_t rate = 0;
const uint8_t* data = nullptr;
uint32_t dataLen = 0;
size_t pos = 12;
while (pos + 8 <= buf.size())
{
const char* id = reinterpret_cast<const char*>(buf.data() + pos);
uint32_t sz = rdU32(buf.data() + pos + 4);
const uint8_t* body = buf.data() + pos + 8;
// Guard the fmt-body reads (up to body+14, i.e. 16 bytes) against a
// truncated file: a declared sz >= 16 doesn't mean 16 bytes are
// actually present.
if (!std::memcmp(id, "fmt ", 4) && sz >= 16 && pos + 8 + 16 <= buf.size())
{
fmt = rdU16(body); channels = rdU16(body+2); rate = rdU32(body+4); bits = rdU16(body+14);
}
else if (!std::memcmp(id, "data", 4))
{
data = body;
dataLen = std::min<uint32_t>(sz, uint32_t(buf.size() - (pos + 8)));
}
pos += 8 + sz + (sz & 1); // chunks are word-aligned
}
// Guard the header fields before the rate/size math below: a malformed
// header (rate 0, bits 0) would otherwise divide by zero.
if (!data || channels == 0 || rate == 0 || bits < 8)
{ std::cerr << "invalid or missing fmt/data chunk\n"; return false; }
out.sampleRate = int(rate);
const int bytesPerSample = bits / 8;
const size_t frames = dataLen / (bytesPerSample * channels);
out.samples.resize(frames);
for (size_t i = 0; i < frames; ++i)
{
double acc = 0.0;
for (int c = 0; c < channels; ++c)
{
const uint8_t* s = data + (i * channels + c) * bytesPerSample;
if (fmt == 3 && bits == 32) { float v; std::memcpy(&v, s, 4); acc += v; }
else if (fmt == 1 && bits == 16) { acc += int16_t(rdU16(s)) / 32768.0; }
else if (fmt == 1 && bits == 32) { acc += int32_t(rdU32(s)) / 2147483648.0; }
else { std::cerr << "unsupported fmt=" << fmt << " bits=" << bits << "\n"; return false; }
}
out.samples[i] = float(acc / channels);
}
return true;
}
// --------------------------------------------------------------------------
// Resample to 22050 Hz. Catmull-Rom cubic interpolation — adequate for the
// spike; the real MlNoteDetector will use juce::LagrangeInterpolator.
// --------------------------------------------------------------------------
static std::vector<float> resampleTo22050(const std::vector<float>& in, int srcRate)
{
if (srcRate == kModelSampleRate) return in;
const double ratio = double(srcRate) / kModelSampleRate;
const size_t outLen = size_t(in.size() / ratio);
std::vector<float> out(outLen);
auto at = [&](long i) -> float {
if (i < 0) i = 0;
if (i >= long(in.size())) i = long(in.size()) - 1;
return in[size_t(i)];
};
for (size_t n = 0; n < outLen; ++n)
{
const double srcPos = n * ratio;
const long i = long(srcPos);
const float t = float(srcPos - i);
const float p0 = at(i-1), p1 = at(i), p2 = at(i+1), p3 = at(i+2);
out[n] = p1 + 0.5f * t * ((p2 - p0) + t * ((2*p0 - 5*p1 + 4*p2 - p3)
+ t * (3*(p1 - p2) + p3 - p0)));
}
return out;
}
static const char* noteName(int midi)
{
static const char* n[12] = {"C","C#","D","D#","E","F","F#","G","G#","A","A#","B"};
static char buf[8];
std::snprintf(buf, sizeof(buf), "%s%d", n[midi % 12], midi / 12 - 1);
return buf;
}
int main(int argc, char** argv)
{
if (argc < 3) { std::cerr << "usage: spike <model.onnx> <audio.wav>\n"; return 2; }
const std::string modelPath = argv[1];
const std::string wavPath = argv[2];
Wav wav;
if (!readWav(wavPath, wav)) return 1;
std::cout << "WAV: " << wav.samples.size() << " samples @ " << wav.sampleRate << " Hz\n";
const std::vector<float> mono = resampleTo22050(wav.samples, wav.sampleRate);
std::cout << "resampled: " << mono.size() << " samples @ " << kModelSampleRate << " Hz\n";
// --- ONNX Runtime session ---
Ort::Env env(ORT_LOGGING_LEVEL_WARNING, "bp-spike");
Ort::SessionOptions opts;
opts.SetIntraOpNumThreads(1);
opts.SetGraphOptimizationLevel(GraphOptimizationLevel::ORT_ENABLE_ALL);
Ort::Session session(env, modelPath.c_str(), opts);
{
Ort::AllocatorWithDefaultOptions alloc;
std::cout << "model inputs:\n";
for (size_t i = 0; i < session.GetInputCount(); ++i)
{
auto name = session.GetInputNameAllocated(i, alloc);
auto shp = session.GetInputTypeInfo(i).GetTensorTypeAndShapeInfo().GetShape();
std::cout << " " << name.get() << " [";
for (auto d : shp) std::cout << d << " ";
std::cout << "]\n";
}
std::cout << "model outputs:\n";
for (size_t i = 0; i < session.GetOutputCount(); ++i)
{
auto name = session.GetOutputNameAllocated(i, alloc);
auto shp = session.GetOutputTypeInfo(i).GetTensorTypeAndShapeInfo().GetShape();
std::cout << " " << name.get() << " [";
for (auto d : shp) std::cout << d << " ";
std::cout << "]\n";
}
}
auto memInfo = Ort::MemoryInfo::CreateCpu(OrtArenaAllocator, OrtMemTypeDefault);
const char* inNames[] = { kInputName };
const char* outNames[] = { kNoteOutput, kOnsetOutput };
// Process non-overlapping ~2 s windows. (The production detector uses a
// rolling buffer reading only fresh frames; non-overlapping is fine here
// and is the source of the boundary re-onsets noted in README.md.)
struct Hit { double timeSec; int midi; float conf; };
std::vector<Hit> hits;
int windows = 0;
double totalInferMs = 0.0;
for (size_t base = 0; base < mono.size(); base += kAudioNSamples)
{
std::vector<float> window(kAudioNSamples, 0.0f);
const size_t n = std::min<size_t>(kAudioNSamples, mono.size() - base);
std::memcpy(window.data(), mono.data() + base, n * sizeof(float));
const int64_t inShape[3] = { 1, kAudioNSamples, 1 };
Ort::Value inTensor = Ort::Value::CreateTensor<float>(
memInfo, window.data(), window.size(), inShape, 3);
const auto t0 = std::chrono::steady_clock::now();
auto out = session.Run(Ort::RunOptions{nullptr}, inNames, &inTensor, 1, outNames, 2);
const auto t1 = std::chrono::steady_clock::now();
totalInferMs += std::chrono::duration<double, std::milli>(t1 - t0).count();
const float* note = out[0].GetTensorData<float>();
const float* onset = out[1].GetTensorData<float>();
// Validate the output shapes before indexing: both heads must be
// rank-3 [1, frames, kNumPitches] and agree on the frame count, or
// the flat indexing below would read out of bounds.
const auto noteShape = out[0].GetTensorTypeAndShapeInfo().GetShape();
const auto onsetShape = out[1].GetTensorTypeAndShapeInfo().GetShape();
if (noteShape.size() != 3 || onsetShape.size() != 3
|| noteShape[2] != kNumPitches || onsetShape[2] != kNumPitches
|| noteShape[1] != onsetShape[1])
{
std::cerr << "FAIL: unexpected model output shape\n";
return 1;
}
const int frames = int(noteShape[1]); // 172
const double windowStartSec = double(base) / kModelSampleRate;
for (int p = 0; p < kNumPitches; ++p)
for (int f = 1; f < frames; ++f)
{
const float on = onset[f * kNumPitches + p];
const float onPrev = onset[(f-1) * kNumPitches + p];
const float fr = note[f * kNumPitches + p];
if (on >= kOnsetThreshold && onPrev < kOnsetThreshold && fr >= kFrameThreshold)
hits.push_back({ windowStartSec + double(f) / kFramesPerSecond,
kLowestMidi + p, on });
}
++windows;
}
std::sort(hits.begin(), hits.end(),
[](const Hit& a, const Hit& b){ return a.timeSec < b.timeSec; });
std::cout << "\n" << windows << " windows, total inference "
<< totalInferMs << " ms, " << (totalInferMs / std::max(1, windows))
<< " ms/window\n";
std::cout << "\nDETECTED onsets (time_s, midi, note, onset_conf):\n";
for (const auto& h : hits)
std::cout << " t=" << h.timeSec << " midi=" << h.midi
<< " " << noteName(h.midi) << " conf=" << h.conf << "\n";
std::cout << "\nspike OK\n";
return 0;
}
Binary file not shown.