Four items from one workflow, committed together because their build and CI
wiring genuinely interleaves — nisps/CMakeLists.txt, run-all-tests.sh and
ci.yml each carry hunks from two of them, and the stage renumbering (1/5 ->
1/6) touches every line. Splitting would produce commits that do not build,
which is worse than a commit that does four things and says so.
S26 part 2 — the curve declaration now matches reality. params[].curve stays
the mode-wide DEFAULT; a voice_spaces entry may now be {name, curve_overrides}
declaring only the slots where THAT voice space deviates. The 6 modes with one
voice space are byte-identical. The values were derived MECHANICALLY by a new
codegen/curve-audit.ts that models the four idioms a p[N]*p[N] regex misses
(alias form, memlcelium's implicit-counter sq() lambda, loop-generated indices,
smooth_params_), inlines helpers, and RAISES rather than guessing when it
cannot reduce an expression. A drift gate cross-checks 1179 (voice space x
param) slots against engine source on every run and was proved to fail loudly
on three drift classes. Application stays in the engine: nisps/engines,
nisps/pipeline and nisps/core are untouched, generated output is pure insertion
(755 insertions, 0 deletions), and the rebuilt nisps.wasm was byte-identical.
S4 / 7.2 — firmware reads the active mode's driver config at mode start, and
mic/line is real. My brief assumed the engine owns this; the code disagreed and
the code was right. sound_analysis_midi's EngineT is NoOpEngine — the mic lives
on a separately-composed AnalysisEngine member — so engine-level wiring would
have compiled, passed every gate, and left the one mic mode on line input.
Hence a mode-level seam defaulting to engine().driver_config(). Separately,
DriverConfig's defaults (line_level 0, output_volume 1.0) had drifted from
memllib's actual 3/0.8 because nothing had ever read them; wiring them as-is
would have made every silent mode louder and its line input maximally
insensitive — a behaviour change disguised as plumbing. Now pinned by a test.
Also: GetSysClockSpeed() panic()s on unsupported sample rates and runs on the
first line of setup(), so sample_rate needed a fallback ahead of clock setup.
CI's firmware env list gains soundanalysismidi — it is the only mic variant and
nothing else compiles that path.
Plan 5e — telemetry is real. A loss_history C-API entry across the full 5-layer
chain lets the browser read the per-iteration loss the core already records.
The audit named one fabrication site; there were two — wasm-iml.ts's
synchronous train() published lossHistory: [loss] as well. A third, ctx.loss,
was not merely dead but actively synthetic (fallbacks of prev * 0.82 and a
literal 0.5, rendered by nothing) and is deleted. The firmware buffer stays
untouched, per the L25 call. EngineApi.lossHistory() reads spine state rather
than the MLP handle, because trainAsync() fits on the worker's mirror net and
the handle would give a subtly-wrong second answer.
Plan 5f — engine throughput is measurable. One source compiled twice (CMake
natively, emcc for WASM) so the targets compare directly and no WASM export is
added. Sequencers are driven into a working state, and every row prints its own
working-state evidence so a number produced by an idle engine is visible rather
than plausible. Reports, never asserts: a wall-clock threshold on shared
hardware is meaningless or flaky, same call as the firmware size job.
ALIGNMENT: the telemetry defect is deleted (built, not deferred); the
performance defect is rewritten to what is actually left — these are HOST
numbers, and nothing measures the RP2350 at 150 MHz, which is the target the
mission's constraint is about. Q4 (memllib ownership) and Q5 (legacy feedback
modes) are closed.
Corrections to my own earlier claims, both found by agents contradicting the
brief: manifold/ONBOARDING.md was NOT "now accurate" — its primitives list
still named five deleted primitives and cited a seededGradient() that does not
exist. And the parity harness misses the sequencer engines because it runs 128
frames while their sequencers evaluate every 400-500 samples, NOT because
all-params-0.5 fails to trigger them (it does trigger: 0.5 maps to ratio 2,
firing three times per bar). The fix is a longer window, not different params.
Gates: run-all-tests.sh ALL GREEN — 4/4 ctest, parity PASS, lint clean, curve
drift 1179 slots ok, 39 e2e (was 33). Firmware: 5 envs built including the mic
variant.
885 lines
33 KiB
TypeScript
885 lines
33 KiB
TypeScript
/**
|
|
* curve-audit.ts — mechanically derive, from `nisps/engines/*.hpp` source,
|
|
* which response curve each engine applies to each NN-output slot, per voice
|
|
* space.
|
|
*
|
|
* WHY THIS IS A SOURCE-LEVEL CHECK
|
|
* --------------------------------
|
|
* "The curve" is not observable from engine output. A voice space maps a
|
|
* normalised param into engine state as `base + f(p) * scale` and then that
|
|
* state goes through DSP. Given only audio out you cannot separate `f` from
|
|
* `base`/`scale`/the DSP, and the engines expose no accessor for the mapped
|
|
* state. So the only place the curve exists as a fact is the arithmetic in
|
|
* `apply_*()` / `set_params()` / `process()` — and that is what this module
|
|
* reads.
|
|
*
|
|
* The contract it enforces is deliberately narrow and total:
|
|
*
|
|
* square <=> the engine multiplies the param slot by itself
|
|
* sqrt <=> the engine passes the param slot through std::sqrt
|
|
* linear <=> anything else (including quantisation, sin() combination and
|
|
* stepped lookup tables — those are not expressible in the
|
|
* `Curve` enum and are declared `linear` by definition)
|
|
*
|
|
* Everything it cannot reduce is a HARD ERROR, never a silent "linear". That
|
|
* is the property that makes the derived table trustworthy: a new voice space,
|
|
* a new idiom, or a renamed helper trips the check instead of quietly
|
|
* under-reporting. A regex over `p[N] * p[N]` would miss the `const float v =
|
|
* p[…]; v * v` form (verb_fx), the `sq()` implicit-counter lambda
|
|
* (memlcelium), loop-generated indices (verb_fx) and `smooth_params_[N]`
|
|
* (xiasri) — every one of those is live in this codebase today.
|
|
*
|
|
* Consumed by codegen/tests/curve_drift_test.ts, which asserts the schemas'
|
|
* declared curves (params[].curve + per-voice-space overrides) match.
|
|
*/
|
|
|
|
import { readFileSync, readdirSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
|
|
export type Curve =
|
|
| "linear"
|
|
| "exp"
|
|
| "log"
|
|
| "square"
|
|
| "sqrt"
|
|
| "sigmoid"
|
|
| "cubic";
|
|
|
|
/** Identifiers that alias the NN-output vector inside an engine. */
|
|
const ACCESSORS = ["params", "p", "smooth_params_", "nn_outputs_"] as const;
|
|
const ACCESSOR_RE = new RegExp(`\\b(${ACCESSORS.join("|")})\\s*\\[`);
|
|
|
|
export interface EngineCurves {
|
|
engineId: string;
|
|
file: string;
|
|
nParams: number;
|
|
/** Names from the engine's `kVoiceSpaceNames`, or null when it has none. */
|
|
voiceSpaceNames: string[] | null;
|
|
/** curves[voiceSpaceIndex][paramIndex]. One row when there is no enum. */
|
|
curves: Curve[][];
|
|
}
|
|
|
|
export class CurveAuditError extends Error {}
|
|
|
|
function fail(where: string, msg: string): never {
|
|
throw new CurveAuditError(`[curve-audit] ${where}: ${msg}`);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Lexical helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function stripComments(src: string): string {
|
|
// Block comments first, then line comments. No string literals in these
|
|
// files contain `//` or `/*` (checked: the only literals are identifiers).
|
|
return src.replace(/\/\*[\s\S]*?\*\//g, " ").replace(/\/\/[^\n]*/g, " ");
|
|
}
|
|
|
|
/** Index of the `)`/`}`/`]` matching the opener at `open`. */
|
|
function matchBracket(src: string, open: number): number {
|
|
const pairs: Record<string, string> = { "(": ")", "{": "}", "[": "]" };
|
|
const close = pairs[src[open]!];
|
|
if (!close) fail("matchBracket", `not an opener at ${open}: ${src[open]}`);
|
|
let depth = 0;
|
|
for (let i = open; i < src.length; i++) {
|
|
const c = src[i]!;
|
|
if (c === src[open]) depth++;
|
|
else if (c === close) {
|
|
depth--;
|
|
if (depth === 0) return i;
|
|
}
|
|
}
|
|
fail("matchBracket", `unbalanced ${src[open]} at ${open}`);
|
|
}
|
|
|
|
/** Body text (between braces, exclusive) of `<name>(...) ... { ... }`. */
|
|
function functionBody(src: string, name: string): string | null {
|
|
const re = new RegExp(`\\b${name}\\s*\\(`, "g");
|
|
let m: RegExpExecArray | null;
|
|
while ((m = re.exec(src))) {
|
|
const argOpen = m.index + m[0].length - 1;
|
|
let argClose: number;
|
|
try {
|
|
argClose = matchBracket(src, argOpen);
|
|
} catch {
|
|
continue;
|
|
}
|
|
// Between `)` and `{` only qualifiers may appear (noexcept, const, ->…).
|
|
const between = src.slice(argClose + 1, src.indexOf("{", argClose) + 1);
|
|
if (!/^[\s\w:&*<>,]*\{$/.test(between)) continue;
|
|
const braceOpen = src.indexOf("{", argClose);
|
|
if (braceOpen < 0) continue;
|
|
return src.slice(braceOpen + 1, matchBracket(src, braceOpen));
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** Formal parameter names of `<name>(...)`, in order. */
|
|
function functionParams(src: string, name: string): string[] | null {
|
|
const re = new RegExp(`\\b${name}\\s*\\(`, "g");
|
|
let m: RegExpExecArray | null;
|
|
while ((m = re.exec(src))) {
|
|
const argOpen = m.index + m[0].length - 1;
|
|
let argClose: number;
|
|
try {
|
|
argClose = matchBracket(src, argOpen);
|
|
} catch {
|
|
continue;
|
|
}
|
|
const between = src.slice(argClose + 1, src.indexOf("{", argClose) + 1);
|
|
if (!/^[\s\w:&*<>,]*\{$/.test(between)) continue;
|
|
const args = src.slice(argOpen + 1, argClose).trim();
|
|
if (args === "") return [];
|
|
return args.split(",").map((a) => {
|
|
const t = a.trim().replace(/\[\s*\]$/, "");
|
|
const w = t.match(/([A-Za-z_]\w*)\s*$/);
|
|
return w ? w[1]! : t;
|
|
});
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** Split arguments of a call at top nesting level. */
|
|
function splitArgs(text: string): string[] {
|
|
const out: string[] = [];
|
|
let depth = 0;
|
|
let cur = "";
|
|
for (const c of text) {
|
|
if (c === "(" || c === "[" || c === "{" || c === "<") depth++;
|
|
else if (c === ")" || c === "]" || c === "}" || c === ">") depth--;
|
|
if (c === "," && depth === 0) {
|
|
out.push(cur);
|
|
cur = "";
|
|
} else cur += c;
|
|
}
|
|
if (cur.trim() !== "" || out.length > 0) out.push(cur);
|
|
return out.map((s) => s.trim());
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Integer expression evaluation (array indices, loop bounds, ternary guards)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Evaluate a compile-time integer expression. Constants from the engine
|
|
* (`static constexpr std::size_t kX = …`) are substituted first. Anything the
|
|
* strict character whitelist rejects is a hard error — never a guess.
|
|
*/
|
|
function evalInt(expr: string, consts: Map<string, number>, where: string): number {
|
|
let e = expr;
|
|
for (let pass = 0; pass < 8; pass++) {
|
|
const before = e;
|
|
for (const [k, v] of consts) {
|
|
e = e.replace(new RegExp(`\\b${k}\\b`, "g"), `(${v})`);
|
|
}
|
|
if (e === before) break;
|
|
}
|
|
e = e.replace(/(\d)[uU]\b/g, "$1");
|
|
e = e.replace(/static_cast<[^>]*>/g, "");
|
|
if (!/^[\d\s+\-*/%()]+$/.test(e)) {
|
|
fail(where, `non-constant integer expression ${JSON.stringify(expr)}`);
|
|
}
|
|
// eslint-disable-next-line no-new-func
|
|
const v = Function(`"use strict"; return (${e});`)() as number;
|
|
if (!Number.isInteger(v)) fail(where, `non-integer index ${expr} -> ${v}`);
|
|
return v;
|
|
}
|
|
|
|
/** Evaluate a boolean guard, or null when it is not compile-time constant. */
|
|
function evalBool(expr: string, consts: Map<string, number>): boolean | null {
|
|
let e = expr.trim();
|
|
while (e.startsWith("(") && matchBracket(e, 0) === e.length - 1) {
|
|
e = e.slice(1, -1).trim();
|
|
}
|
|
for (let pass = 0; pass < 8; pass++) {
|
|
const before = e;
|
|
for (const [k, v] of consts) e = e.replace(new RegExp(`\\b${k}\\b`, "g"), `(${v})`);
|
|
if (e === before) break;
|
|
}
|
|
e = e.replace(/(\d)[uU]\b/g, "$1");
|
|
if (!/^[\d\s+\-*/%()<>=!&|]+$/.test(e)) return null;
|
|
try {
|
|
// eslint-disable-next-line no-new-func
|
|
return Boolean(Function(`"use strict"; return (${e});`)());
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Body normalisation: inline helpers -> unroll loops -> flatten braces
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface EngineFile {
|
|
src: string;
|
|
path: string;
|
|
consts: Map<string, number>;
|
|
/** member name -> std::array element count, for range-for unrolling. */
|
|
arrayLens: Map<string, number>;
|
|
}
|
|
|
|
function loadEngine(path: string): EngineFile {
|
|
const src = stripComments(readFileSync(path, "utf8"));
|
|
const consts = new Map<string, number>();
|
|
// `static constexpr std::size_t kFoo = <expr>;` — resolved in declaration
|
|
// order so later constants may refer to earlier ones.
|
|
const cre = /static\s+constexpr\s+std::size_t\s+(\w+)\s*=\s*([^;]+);/g;
|
|
let m: RegExpExecArray | null;
|
|
while ((m = cre.exec(src))) {
|
|
try {
|
|
consts.set(m[1]!, evalInt(m[2]!, consts, path));
|
|
} catch {
|
|
/* not an integer constant we can use; ignore */
|
|
}
|
|
}
|
|
const arrayLens = new Map<string, number>();
|
|
const are = /std::array\s*<\s*[^,<>]+(?:<[^>]*>)?\s*,\s*([^>]+)>\s*(\w+)/g;
|
|
while ((m = are.exec(src))) {
|
|
try {
|
|
arrayLens.set(m[2]!, evalInt(m[1]!, consts, path));
|
|
} catch {
|
|
/* dynamic length; ignore */
|
|
}
|
|
}
|
|
return { src, path, consts, arrayLens };
|
|
}
|
|
|
|
/**
|
|
* Replace `name(args);` statements whose `name` is a method defined in the
|
|
* same file with that method's body, substituting formals for actuals.
|
|
*/
|
|
const KEYWORD_CALLS = new Set(["if", "for", "while", "switch", "return", "sizeof", "static_cast"]);
|
|
|
|
/** Locate the next bare `name(args);` statement whose `name` is defined here. */
|
|
function findInlinableCall(
|
|
body: string,
|
|
eng: EngineFile,
|
|
from: number
|
|
): { start: number; end: number; name: string; args: string } | null {
|
|
const callRe = /(?:^|[;{}])\s*([a-z_]\w*)\s*\(/g;
|
|
callRe.lastIndex = from;
|
|
let m: RegExpExecArray | null;
|
|
while ((m = callRe.exec(body))) {
|
|
const name = m[1]!;
|
|
callRe.lastIndex = m.index + m[0].length - 1;
|
|
if (KEYWORD_CALLS.has(name)) continue;
|
|
const open = m.index + m[0].length - 1;
|
|
let close: number;
|
|
try {
|
|
close = matchBracket(body, open);
|
|
} catch {
|
|
continue;
|
|
}
|
|
const rest = body.slice(close + 1);
|
|
if (rest.trimStart()[0] !== ";") continue;
|
|
if (functionBody(eng.src, name) === null) continue;
|
|
const semi = body.indexOf(";", close);
|
|
return { start: m.index + (/[;{}]/.test(m[0][0]!) ? 1 : 0), end: semi + 1, name, args: body.slice(open + 1, close) };
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function inlineCalls(body: string, eng: EngineFile, seen: Set<string>, where: string): string {
|
|
let text = body;
|
|
for (let guard = 0; guard < 256; guard++) {
|
|
const hit = findInlinableCall(text, eng, 0);
|
|
if (!hit) return text;
|
|
if (seen.has(hit.name)) fail(where, `recursive inline of ${hit.name}()`);
|
|
const callee = functionBody(eng.src, hit.name)!;
|
|
const formals = functionParams(eng.src, hit.name) ?? [];
|
|
const actuals = splitArgs(hit.args);
|
|
let inner = inlineCalls(callee, eng, new Set([...seen, hit.name]), `${where}>${hit.name}`);
|
|
formals.forEach((f, i) => {
|
|
const a = actuals[i];
|
|
if (a === undefined || f === a) return;
|
|
inner = inner.replace(new RegExp(`\\b${f}\\b`, "g"), `(${a})`);
|
|
});
|
|
text = text.slice(0, hit.start) + ` { ${inner} } ` + text.slice(hit.end);
|
|
}
|
|
fail(where, "helper inlining did not converge");
|
|
}
|
|
|
|
/** Unroll `for` loops with compile-time trip counts, brace-stripping bodies. */
|
|
function unrollLoops(body: string, eng: EngineFile, where: string): string {
|
|
let text = body;
|
|
for (let pass = 0; pass < 64; pass++) {
|
|
const idx = text.search(/\bfor\s*\(/);
|
|
if (idx < 0) return text;
|
|
const open = text.indexOf("(", idx);
|
|
const close = matchBracket(text, open);
|
|
const header = text.slice(open + 1, close);
|
|
|
|
// Body: either a braced block or a single statement.
|
|
let bodyStart = close + 1;
|
|
while (/\s/.test(text[bodyStart] ?? "")) bodyStart++;
|
|
let inner: string;
|
|
let bodyEnd: number;
|
|
if (text[bodyStart] === "{") {
|
|
const b = matchBracket(text, bodyStart);
|
|
inner = text.slice(bodyStart + 1, b);
|
|
bodyEnd = b + 1;
|
|
} else {
|
|
const semi = text.indexOf(";", bodyStart);
|
|
if (semi < 0) fail(where, "for-loop body has no terminator");
|
|
inner = text.slice(bodyStart, semi + 1);
|
|
bodyEnd = semi + 1;
|
|
}
|
|
|
|
let expansion = "";
|
|
const counted = header.match(
|
|
/^\s*(?:std::size_t|int|std::uint\d+_t|auto)\s+(\w+)\s*=\s*([^;]+);\s*\1\s*<\s*([^;]+);\s*\+\+\1\s*$/
|
|
);
|
|
const ranged = header.match(/^\s*(?:const\s+)?auto\s*[&*]?\s*\w+\s*:\s*(\w+)\s*$/);
|
|
if (counted) {
|
|
const v = counted[1]!;
|
|
const lo = evalInt(counted[2]!, eng.consts, where);
|
|
const hi = evalInt(counted[3]!, eng.consts, where);
|
|
for (let i = lo; i < hi; i++) {
|
|
expansion += ` ${inner.replace(new RegExp(`\\b${v}\\b`, "g"), `(${i})`)} `;
|
|
}
|
|
} else if (ranged) {
|
|
const n = eng.arrayLens.get(ranged[1]!);
|
|
if (n === undefined) fail(where, `range-for over ${ranged[1]} with unknown length`);
|
|
for (let i = 0; i < n; i++) expansion += ` ${inner} `;
|
|
} else {
|
|
fail(where, `unrecognised for-loop header ${JSON.stringify(header.trim())}`);
|
|
}
|
|
text = text.slice(0, idx) + expansion + text.slice(bodyEnd);
|
|
}
|
|
fail(where, "for-loop unrolling did not converge");
|
|
}
|
|
|
|
/**
|
|
* Remove `switch (voice_space_) { … }` — each voice space is analysed against
|
|
* its own dispatch target, so keeping the switch would merge all of them.
|
|
* Any OTHER switch is deliberately left in place: brace-flattening turns its
|
|
* arms into straight-line statements, and if two arms disagree about a param's
|
|
* curve that surfaces as a conflict rather than a silent pick.
|
|
*/
|
|
function dropVoiceSpaceSwitch(body: string, where: string): string {
|
|
let text = body;
|
|
for (;;) {
|
|
const m = text.match(/\bswitch\s*\(\s*voice_space_\s*\)/);
|
|
if (!m || m.index === undefined) return text;
|
|
const open = text.indexOf("(", m.index);
|
|
const close = matchBracket(text, open);
|
|
let braceOpen = close + 1;
|
|
while (/\s/.test(text[braceOpen] ?? "")) braceOpen++;
|
|
if (text[braceOpen] !== "{") fail(where, "switch (voice_space_) without a block");
|
|
text = text.slice(0, m.index) + " " + text.slice(matchBracket(text, braceOpen) + 1);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Extract `auto NAME = [&]() { const float X = params[i++]; return EXPR; };`
|
|
* lambdas (memlcelium's `sq()`), returning the curve each application yields.
|
|
* Any other lambda shape is a hard error.
|
|
*/
|
|
function extractCounterLambdas(body: string, where: string): { text: string; lambdas: Map<string, Curve> } {
|
|
const lambdas = new Map<string, Curve>();
|
|
let text = body;
|
|
for (;;) {
|
|
const m = text.match(/\bauto\s+(\w+)\s*=\s*\[[^\]]*\]\s*\(/);
|
|
if (!m || m.index === undefined) return { text, lambdas };
|
|
const name = m[1]!;
|
|
const parenOpen = text.indexOf("(", m.index + m[0].length - 1);
|
|
const parenClose = matchBracket(text, parenOpen);
|
|
let braceOpen = parenClose + 1;
|
|
while (/\s/.test(text[braceOpen] ?? "")) braceOpen++;
|
|
if (text[braceOpen] !== "{") fail(where, `lambda ${name} without a body`);
|
|
const braceClose = matchBracket(text, braceOpen);
|
|
const inner = text.slice(braceOpen + 1, braceClose).trim();
|
|
const shape = inner.match(
|
|
/^const\s+float\s+(\w+)\s*=\s*params\s*\[\s*i\+\+\s*\]\s*;\s*return\s+([^;]+);$/
|
|
);
|
|
if (!shape) {
|
|
fail(where, `lambda ${name} has an unrecognised body: ${JSON.stringify(inner)}`);
|
|
}
|
|
const v = shape[1]!;
|
|
const ret = shape[2]!.replace(/\s+/g, " ").trim();
|
|
let curve: Curve;
|
|
if (ret === `${v} * ${v}`) curve = "square";
|
|
else if (ret === `std::sqrt(${v})`) curve = "sqrt";
|
|
else if (ret === v) curve = "linear";
|
|
else fail(where, `lambda ${name} returns an unrecognised form: ${JSON.stringify(ret)}`);
|
|
lambdas.set(name, curve);
|
|
let end = braceClose + 1;
|
|
while (/[\s;]/.test(text[end] ?? "")) end++;
|
|
text = text.slice(0, m.index) + " " + text.slice(end);
|
|
}
|
|
}
|
|
|
|
/** Strip every remaining brace; loops are unrolled and switches dropped by now. */
|
|
function flattenBraces(text: string): string {
|
|
return text.replace(/[{}]/g, " ");
|
|
}
|
|
|
|
function splitStatements(text: string): string[] {
|
|
return text
|
|
.split(";")
|
|
.map((s) => s.replace(/\s+/g, " ").trim())
|
|
.filter((s) => s !== "");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Ternary reduction + accessor classification
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Collapse `cond ? a : b` where `cond` is compile-time constant. */
|
|
function reduceTernaries(stmt: string, consts: Map<string, number>): string {
|
|
let text = stmt;
|
|
for (let pass = 0; pass < 32; pass++) {
|
|
// Innermost `?` first: the one with no further `?` before its `:`.
|
|
const q = text.lastIndexOf("?");
|
|
if (q < 0) return text;
|
|
// Condition: scan left to the nearest unbalanced `(`, or `=`/`,`/start.
|
|
let depth = 0;
|
|
let condStart = 0;
|
|
for (let i = q - 1; i >= 0; i--) {
|
|
const c = text[i]!;
|
|
if (c === ")" || c === "]") depth++;
|
|
else if (c === "(" || c === "[") {
|
|
if (depth === 0) {
|
|
condStart = i + 1;
|
|
break;
|
|
}
|
|
depth--;
|
|
} else if (depth === 0 && (c === "=" || c === ",")) {
|
|
condStart = i + 1;
|
|
break;
|
|
}
|
|
}
|
|
// Matching `:` at the same nesting depth.
|
|
depth = 0;
|
|
let colon = -1;
|
|
for (let i = q + 1; i < text.length; i++) {
|
|
const c = text[i]!;
|
|
if (c === "(" || c === "[") depth++;
|
|
else if (c === ")" || c === "]") {
|
|
if (depth === 0) break;
|
|
depth--;
|
|
} else if (c === ":" && depth === 0 && text[i + 1] !== ":" && text[i - 1] !== ":") {
|
|
colon = i;
|
|
break;
|
|
}
|
|
}
|
|
if (colon < 0) return text;
|
|
// End of the false branch: unbalanced `)`/`]`/`,` or end of statement.
|
|
depth = 0;
|
|
let end = text.length;
|
|
for (let i = colon + 1; i < text.length; i++) {
|
|
const c = text[i]!;
|
|
if (c === "(" || c === "[") depth++;
|
|
else if (c === ")" || c === "]") {
|
|
if (depth === 0) {
|
|
end = i;
|
|
break;
|
|
}
|
|
depth--;
|
|
} else if (c === "," && depth === 0) {
|
|
end = i;
|
|
break;
|
|
}
|
|
}
|
|
const cond = text.slice(condStart, q);
|
|
const t = text.slice(q + 1, colon);
|
|
const f = text.slice(colon + 1, end);
|
|
const v = evalBool(cond, consts);
|
|
// When the guard is not constant, keep BOTH branches: an accessor that is
|
|
// squared in one and sqrt'd in the other then surfaces as a conflict.
|
|
const repl = v === null ? `( ${cond} ) * ( ${t} ) * ( ${f} )` : v ? `( ${t} )` : `( ${f} )`;
|
|
text = text.slice(0, condStart) + repl + text.slice(end);
|
|
}
|
|
return text;
|
|
}
|
|
|
|
/** Operand immediately to the left of `at` (balanced group, or a bare term). */
|
|
function leftOperand(text: string, at: number): { start: number; text: string } | null {
|
|
let i = at - 1;
|
|
while (i >= 0 && /\s/.test(text[i]!)) i--;
|
|
if (i < 0) return null;
|
|
if (text[i] === ")" || text[i] === "]") {
|
|
// Walk back over the balanced group, then over any leading identifier
|
|
// (so `p[3]` and `std::sqrt(x)` come back whole).
|
|
let depth = 0;
|
|
let j = i;
|
|
const open = text[i] === ")" ? "(" : "[";
|
|
for (; j >= 0; j--) {
|
|
if (text[j] === text[i]) depth++;
|
|
else if (text[j] === open) {
|
|
depth--;
|
|
if (depth === 0) break;
|
|
}
|
|
}
|
|
if (j < 0) return null;
|
|
let k = j - 1;
|
|
while (k >= 0 && /[\w:]/.test(text[k]!)) k--;
|
|
return { start: k + 1, text: text.slice(k + 1, i + 1) };
|
|
}
|
|
let j = i;
|
|
while (j >= 0 && /[\w.:]/.test(text[j]!)) j--;
|
|
if (j === i) return null;
|
|
return { start: j + 1, text: text.slice(j + 1, i + 1) };
|
|
}
|
|
|
|
/** Operand immediately to the right of `at`. */
|
|
function rightOperand(text: string, at: number): { end: number; text: string } | null {
|
|
let i = at + 1;
|
|
while (i < text.length && /\s/.test(text[i]!)) i++;
|
|
if (i >= text.length) return null;
|
|
let j = i;
|
|
while (j < text.length && /[\w:]/.test(text[j]!)) j++;
|
|
if (j < text.length && (text[j] === "(" || text[j] === "[")) {
|
|
const close = matchBracket(text, j);
|
|
return { end: close + 1, text: text.slice(i, close + 1) };
|
|
}
|
|
if (j === i) return null;
|
|
return { end: j, text: text.slice(i, j) };
|
|
}
|
|
|
|
/** True when `text` is exactly one param-slot read, modulo parens/whitespace. */
|
|
function isBareAccessor(text: string): boolean {
|
|
let t = text.trim();
|
|
while (t.startsWith("(") && matchBracket(t, 0) === t.length - 1) t = t.slice(1, -1).trim();
|
|
const m = t.match(new RegExp(`^(?:${ACCESSORS.join("|")})\\s*\\[`));
|
|
if (!m) return false;
|
|
return matchBracket(t, t.indexOf("[")) === t.length - 1;
|
|
}
|
|
|
|
/** Every accessor index appearing in `text`. */
|
|
function accessorIndices(text: string, consts: Map<string, number>, where: string): number[] {
|
|
const out: number[] = [];
|
|
const re = new RegExp(`\\b(${ACCESSORS.join("|")})\\s*\\[`, "g");
|
|
let m: RegExpExecArray | null;
|
|
while ((m = re.exec(text))) {
|
|
const open = m.index + m[0].length - 1;
|
|
const close = matchBracket(text, open);
|
|
out.push(evalInt(text.slice(open + 1, close), consts, where));
|
|
re.lastIndex = close;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* Classify every accessor occurrence in one expression.
|
|
*
|
|
* `sqrt` is recognised as `std::sqrt(<expr containing exactly the accessors>)`;
|
|
* `square` as `X * X` for textually identical operands containing accessors.
|
|
* Recognised occurrences are blanked so they are not re-counted as linear.
|
|
*/
|
|
function classifyExpr(expr: string, consts: Map<string, number>, where: string): Map<number, Curve> {
|
|
const found = new Map<number, Curve>();
|
|
const note = (idx: number, c: Curve) => {
|
|
const prev = found.get(idx);
|
|
if (prev !== undefined && prev !== c) {
|
|
fail(where, `param ${idx} is both ${prev} and ${c} in one expression: ${expr}`);
|
|
}
|
|
found.set(idx, c);
|
|
};
|
|
let text = expr;
|
|
|
|
// 1. std::sqrt(...)
|
|
for (;;) {
|
|
const m = text.match(/\bstd::sqrt\s*\(/);
|
|
if (!m || m.index === undefined) break;
|
|
const open = text.indexOf("(", m.index);
|
|
const close = matchBracket(text, open);
|
|
const inner = text.slice(open + 1, close);
|
|
const idxs = accessorIndices(inner, consts, where);
|
|
if (idxs.length > 0) {
|
|
// A sqrt over a compound expression is not a per-param sqrt curve — the
|
|
// same reasoning as the self-product rule below. None exists today, so
|
|
// this is a hard error rather than a silent demotion; if one ever
|
|
// appears the author must decide what the declaration should say.
|
|
if (!isBareAccessor(inner)) fail(where, `std::sqrt over a compound expression: ${inner}`);
|
|
note(idxs[0]!, "sqrt");
|
|
text = text.slice(0, m.index) + " __X__ " + text.slice(close + 1);
|
|
} else {
|
|
text = text.slice(0, m.index) + " __X__ " + text.slice(close + 1);
|
|
}
|
|
}
|
|
|
|
// 2. X * X, where X is one bare param slot (possibly parenthesised because
|
|
// it arrived via an alias). A self-product over a COMPOUND expression —
|
|
// e.g. paf_synth Elderstar's `factor * factor` where
|
|
// `factor = 1.f + (p[17] + p[27] * 0.2f)` — is deliberately NOT a
|
|
// per-param square: no single slot is multiplied by itself, and the
|
|
// `Curve` enum has no way to say "this slot is one term inside a squared
|
|
// sum". Those slots fall through to `linear`, which is what the schemas
|
|
// declare. The rule is explicit, not a silent fallback.
|
|
for (let from = 0; ; ) {
|
|
let hit = false;
|
|
for (let i = from; i < text.length; i++) {
|
|
if (text[i] !== "*") continue;
|
|
const l = leftOperand(text, i);
|
|
const r = rightOperand(text, i);
|
|
if (!l || !r) continue;
|
|
if (l.text.replace(/\s+/g, "") !== r.text.replace(/\s+/g, "")) continue;
|
|
const idxs = accessorIndices(l.text, consts, where);
|
|
if (idxs.length === 0) continue;
|
|
if (idxs.length !== 1 || !isBareAccessor(l.text)) {
|
|
from = i + 1;
|
|
hit = true;
|
|
break;
|
|
}
|
|
note(idxs[0]!, "square");
|
|
text = text.slice(0, l.start) + " __X__ " + text.slice(r.end);
|
|
from = 0;
|
|
hit = true;
|
|
break;
|
|
}
|
|
if (!hit) break;
|
|
}
|
|
|
|
// 3. anything left is linear
|
|
for (const idx of accessorIndices(text, consts, where)) note(idx, "linear");
|
|
return found;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Body analysis
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface Analysis {
|
|
/** assignment target -> curves contributed by the latest write to it. */
|
|
targets: Map<string, Map<number, Curve>>;
|
|
}
|
|
|
|
const ALIAS_DECL = /^(?:static\s+)?const\s+(?:float|std::size_t|int|auto)\s+(\w+)\s*=\s*(.*)$/;
|
|
const COUNTER_DECL = /^(?:std::size_t|int)\s+(\w+)\s*=\s*(\d+)u?$/;
|
|
|
|
function analyseBody(
|
|
rawBody: string,
|
|
eng: EngineFile,
|
|
where: string,
|
|
state: Analysis,
|
|
stmtSeq: { n: number }
|
|
): void {
|
|
let text = inlineCalls(rawBody, eng, new Set(), where);
|
|
text = dropVoiceSpaceSwitch(text, where);
|
|
text = unrollLoops(text, eng, where);
|
|
const { text: noLambda, lambdas } = extractCounterLambdas(text, where);
|
|
text = flattenBraces(noLambda);
|
|
|
|
const aliases = new Map<string, { rhs: string; used: boolean }>();
|
|
let counterName: string | null = null;
|
|
let counter = 0;
|
|
|
|
for (const rawStmt of splitStatements(text)) {
|
|
let stmt = rawStmt;
|
|
|
|
// Sequential dialect: `params[i++]` and counter lambdas consume the next
|
|
// slot, left to right, and are rewritten into the ordinary indexed form.
|
|
if (counterName !== null) {
|
|
const seqRe = new RegExp(
|
|
`params\\s*\\[\\s*${counterName}\\+\\+\\s*\\]|\\b(${[...lambdas.keys()].join("|") || "\\u0000"})\\s*\\(\\s*\\)`,
|
|
"g"
|
|
);
|
|
stmt = stmt.replace(seqRe, (whole, lam?: string) => {
|
|
const idx = counter++;
|
|
if (lam) {
|
|
const c = lambdas.get(lam)!;
|
|
if (c === "square") return `params[${idx}] * params[${idx}]`;
|
|
if (c === "sqrt") return `std::sqrt(params[${idx}])`;
|
|
return `params[${idx}]`;
|
|
}
|
|
return `params[${idx}]`;
|
|
});
|
|
}
|
|
if (/\+\+\s*\]/.test(stmt)) {
|
|
fail(where, `post-increment index with no active counter: ${stmt}`);
|
|
}
|
|
|
|
const counterM = stmt.match(COUNTER_DECL);
|
|
if (counterM && !ACCESSOR_RE.test(stmt)) {
|
|
counterName = counterM[1]!;
|
|
counter = Number(counterM[2]!);
|
|
continue;
|
|
}
|
|
|
|
// Substitute live aliases (longest name first so `p1v` beats `p`).
|
|
for (const [name, a] of [...aliases].sort((x, y) => y[0].length - x[0].length)) {
|
|
const re = new RegExp(`\\b${name}\\b`, "g");
|
|
if (re.test(stmt)) {
|
|
// Not a use if this statement redeclares it.
|
|
const decl = stmt.match(ALIAS_DECL);
|
|
if (decl && decl[1] === name && !new RegExp(`\\b${name}\\b`).test(decl[2]!)) continue;
|
|
stmt = stmt.replace(re, `(${a.rhs})`);
|
|
a.used = true;
|
|
}
|
|
}
|
|
|
|
const aliasM = stmt.match(ALIAS_DECL);
|
|
if (aliasM) {
|
|
const name = aliasM[1]!;
|
|
const prev = aliases.get(name);
|
|
if (prev && !prev.used && ACCESSOR_RE.test(prev.rhs)) {
|
|
fail(where, `alias ${name} carrying a param was shadowed before use`);
|
|
}
|
|
aliases.set(name, { rhs: aliasM[2]!, used: false });
|
|
continue;
|
|
}
|
|
|
|
if (!ACCESSOR_RE.test(stmt)) continue;
|
|
|
|
const reduced = reduceTernaries(stmt, eng.consts);
|
|
const eq = topLevelAssign(reduced);
|
|
if (eq === null) {
|
|
// A call statement or return: unique key, never overwritten.
|
|
state.targets.set(`${where}#${stmtSeq.n++}`, classifyExpr(reduced, eng.consts, where));
|
|
continue;
|
|
}
|
|
const lhs = reduced.slice(0, eq).trim();
|
|
const rhs = reduced.slice(eq + 1);
|
|
// Pure copy between two aliases of the NN vector carries no curve.
|
|
if (isPureCopy(lhs, rhs, eng.consts, where)) continue;
|
|
if (ACCESSOR_RE.test(lhs)) {
|
|
fail(where, `assignment INTO the param vector with arithmetic: ${reduced}`);
|
|
}
|
|
state.targets.set(lhs.replace(/^(?:const\s+)?(?:float|auto)\s+/, ""), classifyExpr(rhs, eng.consts, where));
|
|
}
|
|
|
|
for (const [name, a] of aliases) {
|
|
if (!a.used && ACCESSOR_RE.test(a.rhs)) {
|
|
fail(where, `alias ${name} carrying a param was never used`);
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Index of a top-level `=` that is an assignment (not ==, +=, <=, …). */
|
|
function topLevelAssign(stmt: string): number | null {
|
|
let depth = 0;
|
|
for (let i = 0; i < stmt.length; i++) {
|
|
const c = stmt[i]!;
|
|
if (c === "(" || c === "[") depth++;
|
|
else if (c === ")" || c === "]") depth--;
|
|
else if (c === "=" && depth === 0) {
|
|
if (stmt[i + 1] === "=") return null;
|
|
if ("=!<>+-*/&|%".includes(stmt[i - 1] ?? "")) return null;
|
|
return i;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function isPureCopy(lhs: string, rhs: string, consts: Map<string, number>, where: string): boolean {
|
|
const l = lhs.trim();
|
|
const r = rhs.trim();
|
|
if (!ACCESSOR_RE.test(l) || !ACCESSOR_RE.test(r)) return false;
|
|
const li = accessorIndices(l, consts, where);
|
|
const ri = accessorIndices(r, consts, where);
|
|
if (li.length !== 1 || ri.length !== 1 || li[0] !== ri[0]) return false;
|
|
// RHS must be nothing but the accessor.
|
|
return new RegExp(`^(?:${ACCESSORS.join("|")})\\s*\\[[^\\]]*\\]$`).test(r);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Engine-level extraction
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function parseVoiceSpaces(eng: EngineFile): { enumNames: string[]; displayNames: string[] } | null {
|
|
const em = eng.src.match(/enum\s+class\s+VoiceSpace\s*:[^{]*\{/);
|
|
if (!em || em.index === undefined) return null;
|
|
const open = eng.src.indexOf("{", em.index);
|
|
const inner = eng.src.slice(open + 1, matchBracket(eng.src, open));
|
|
const enumNames = inner
|
|
.split(",")
|
|
.map((s) => s.split("=")[0]!.trim())
|
|
.filter((s) => s !== "" && s !== "Count");
|
|
const nm = eng.src.match(/kVoiceSpaceNames\s*=\s*\{/);
|
|
if (!nm || nm.index === undefined) fail(eng.path, "VoiceSpace enum without kVoiceSpaceNames");
|
|
const nOpen = eng.src.indexOf("{", nm.index);
|
|
const nInner = eng.src.slice(nOpen + 1, matchBracket(eng.src, nOpen));
|
|
const displayNames = [...nInner.matchAll(/"((?:[^"\\]|\\.)*)"/g)].map((m) => m[1]!);
|
|
if (displayNames.length !== enumNames.length) {
|
|
fail(eng.path, `VoiceSpace enum has ${enumNames.length} entries but kVoiceSpaceNames has ${displayNames.length}`);
|
|
}
|
|
return { enumNames, displayNames };
|
|
}
|
|
|
|
/** enum name -> the `apply_*` function the dispatch switch routes it to. */
|
|
function parseDispatch(eng: EngineFile, enumNames: string[]): Map<string, string> {
|
|
const out = new Map<string, string>();
|
|
const re = /case\s+VoiceSpace::(\w+)\s*:\s*(?:\{\s*)?(\w+)\s*\(/g;
|
|
let m: RegExpExecArray | null;
|
|
while ((m = re.exec(eng.src))) {
|
|
if (m[1] === "Count") continue;
|
|
out.set(m[1]!, m[2]!);
|
|
}
|
|
for (const n of enumNames) {
|
|
if (!out.has(n)) fail(eng.path, `VoiceSpace::${n} has no dispatch case`);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function collectCurves(state: Analysis, nParams: number, where: string): Curve[] {
|
|
const merged = new Map<number, Curve>();
|
|
for (const contrib of state.targets.values()) {
|
|
for (const [idx, c] of contrib) {
|
|
if (idx < 0 || idx >= nParams) fail(where, `param index ${idx} out of range (0..${nParams - 1})`);
|
|
const prev = merged.get(idx);
|
|
if (prev !== undefined && prev !== c) {
|
|
fail(where, `param ${idx} is mapped both ${prev} and ${c} within one voice space`);
|
|
}
|
|
merged.set(idx, c);
|
|
}
|
|
}
|
|
const row: Curve[] = new Array(nParams).fill("linear");
|
|
for (const [idx, c] of merged) row[idx] = c;
|
|
return row;
|
|
}
|
|
|
|
export function auditEngine(path: string): EngineCurves {
|
|
const eng = loadEngine(path);
|
|
const idm = eng.src.match(/engine_id\(\)\s*noexcept\s*\{\s*return\s*"([^"]+)"/);
|
|
if (!idm) fail(path, "no engine_id()");
|
|
const engineId = idm[1]!;
|
|
const nParams = eng.consts.get("kNParams") ?? 0;
|
|
|
|
const vs = parseVoiceSpaces(eng);
|
|
const setParams = functionBody(eng.src, "set_params");
|
|
const process = functionBody(eng.src, "process");
|
|
|
|
if (nParams === 0) {
|
|
return { engineId, file: path, nParams: 0, voiceSpaceNames: null, curves: [[]] };
|
|
}
|
|
|
|
if (vs === null) {
|
|
const state: Analysis = { targets: new Map() };
|
|
const seq = { n: 0 };
|
|
if (setParams) analyseBody(setParams, eng, `${engineId}:set_params`, state, seq);
|
|
if (process) analyseBody(process, eng, `${engineId}:process`, state, seq);
|
|
return {
|
|
engineId,
|
|
file: path,
|
|
nParams,
|
|
voiceSpaceNames: null,
|
|
curves: [collectCurves(state, nParams, engineId)],
|
|
};
|
|
}
|
|
|
|
const dispatch = parseDispatch(eng, vs.enumNames);
|
|
const curves: Curve[][] = [];
|
|
for (const name of vs.enumNames) {
|
|
const fn = dispatch.get(name)!;
|
|
const body = functionBody(eng.src, fn);
|
|
if (body === null) fail(path, `dispatch target ${fn}() has no body`);
|
|
const state: Analysis = { targets: new Map() };
|
|
const seq = { n: 0 };
|
|
const where = `${engineId}:${name}`;
|
|
if (setParams) analyseBody(setParams, eng, `${where}/set_params`, state, seq);
|
|
analyseBody(body, eng, where, state, seq);
|
|
curves.push(collectCurves(state, nParams, where));
|
|
}
|
|
return { engineId, file: path, nParams, voiceSpaceNames: vs.displayNames, curves };
|
|
}
|
|
|
|
export function auditAllEngines(enginesDir: string): Map<string, EngineCurves> {
|
|
const out = new Map<string, EngineCurves>();
|
|
for (const f of readdirSync(enginesDir).filter((f) => f.endsWith(".hpp")).sort()) {
|
|
const res = auditEngine(join(enginesDir, f));
|
|
if (out.has(res.engineId)) fail(enginesDir, `duplicate engine_id ${res.engineId}`);
|
|
out.set(res.engineId, res);
|
|
}
|
|
return out;
|
|
}
|