mirror of
https://github.com/got-feedBack/feedBack-desktop.git
synced 2026-08-11 03:09:56 +00:00
Clean release snapshot
This commit is contained in:
@@ -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)
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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)
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.")
|
||||
@@ -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
|
||||
|
@@ -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 2–3, 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 0–3 (bars 4–7, 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 8–11, 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 12–14, 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 0–3 (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.)
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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()
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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;
|
||||
}
|
||||
Executable
+55
@@ -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
|
||||
@@ -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(); }
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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
|
||||
≈100–150 ms — the ≤150 ms target is reachable; a 128 ms hop trades latency
|
||||
(~180–200 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.**
|
||||
@@ -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.
Reference in New Issue
Block a user