Skip to content

Commit 84e2589

Browse files
committed
realigned
1 parent 3126eba commit 84e2589

2 files changed

Lines changed: 139 additions & 61 deletions

File tree

‎MISTAKES.md‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,19 @@
1313
matched the terminal title. When the operator terminal contains the plugin
1414
name, capture by explicit window id (`xwininfo -root -tree` filtered by
1515
PID, then `import -window <id>`).
16+
- 2026-09-14: Commit c48069e ("minor fixes & tweaks") zeroed the gremlin
17+
defaults for `chaos` (0.38), `crunch` (0.18), and `stutter` (0.20) without
18+
re-running the plugin core tests. Those three parameters are what drive the
19+
per-mode timbre differences, so `downspout_gremlin_core_tests` began failing
20+
its mode-separation assertion (Servo vs Ring measured 0.0285 against a 0.035
21+
threshold) and stayed broken on `main` until CI surfaced it. Root cause was
22+
twofold: a parameter-default change was not treated as a behavioural change
23+
worth testing, and the test itself compared modes with a hand-weighted scalar
24+
metric (rms/zero-crossings/side/peak/roughness) whose values for every mode
25+
pair sat in a 0.03-0.04 band -- the threshold was inside the metric's own
26+
scatter, so any default tweak could flip it. Prevention: run the affected
27+
plugin's core tests after changing any parameter default, and prefer
28+
discriminators with real margin. The mode/scene checks now compare coarse
29+
half-octave log-magnitude spectra with a mean-removed cosine distance, which
30+
separates every mode pair by ~2x the threshold and is stable across -O0..-O3
31+
and -ffast-math.

‎plugins/gremlin/tests/gremlin_core_tests.cpp‎

Lines changed: 123 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,26 @@
77

88
namespace {
99

10-
struct ModeSignature {
11-
float rms = 0.0f;
12-
float zeroCrossRate = 0.0f;
13-
float sideRatio = 0.0f;
10+
// Coarse half-octave log-magnitude fingerprint used to assert that the modes and
11+
// scenes stay audibly distinct. Comparing band shape rather than hand-weighted
12+
// scalars (rms/zero-crossings/roughness) keeps the check tied to timbre and
13+
// independent of the shipping parameter defaults, which are a product decision
14+
// and change more often than the synthesis paths do.
15+
constexpr std::size_t kBandCount = 16;
16+
constexpr float kLowestBandHz = 60.0f;
17+
constexpr std::size_t kAnalysisStart = 1024;
18+
constexpr float kMagnitudeFloor = 1.0e-9f;
19+
20+
// Minimum spectral distance required between any two modes, and between the
21+
// musical and extreme scenes. Measured worst-case separation at the shipping
22+
// defaults is ~0.117 for modes and ~0.150 for the scene pair, so this leaves
23+
// roughly a 2x margin rather than sitting inside the metric's own scatter.
24+
constexpr float kModeSeparation = 0.06f;
25+
constexpr float kSceneSeparation = 0.06f;
26+
27+
struct Signature {
28+
std::array<float, kBandCount> bands {};
1429
float peak = 0.0f;
15-
float roughness = 0.0f;
1630
};
1731

1832
bool nearlyEqual(const float a, const float b, const float epsilon = 1.0e-5f)
@@ -48,38 +62,95 @@ bool containsMidi(const downspout::gremlin::MidiMessage* events,
4862
return false;
4963
}
5064

51-
ModeSignature measureRenderedSignature(const std::array<float, 4096>& left, const std::array<float, 4096>& right)
65+
// Goertzel magnitude for one bin over the analysis window.
66+
double bandMagnitude(const std::array<float, 4096>& left,
67+
const std::array<float, 4096>& right,
68+
const double frequencyHz,
69+
const double sampleRate)
5270
{
53-
ModeSignature signature {};
54-
int crossings = 0;
55-
int samples = 0;
56-
float energy = 0.0f;
57-
float side = 0.0f;
58-
float roughness = 0.0f;
59-
float previous = 0.0f;
60-
for (std::size_t i = 1024; i < left.size(); ++i)
71+
const std::size_t count = left.size() - kAnalysisStart;
72+
const double omega = 2.0 * 3.14159265358979323846 * frequencyHz / sampleRate;
73+
const double coeff = 2.0 * std::cos(omega);
74+
75+
double q1 = 0.0;
76+
double q2 = 0.0;
77+
for (std::size_t i = 0; i < count; ++i)
6178
{
62-
const float mono = 0.5f * (left[i] + right[i]);
63-
if (samples > 0 && ((previous < 0.0f && mono >= 0.0f) || (previous > 0.0f && mono <= 0.0f)))
64-
++crossings;
65-
if (samples > 0)
66-
roughness += std::fabs(mono - previous);
67-
previous = mono;
68-
69-
energy += mono * mono;
70-
side += std::fabs(left[i] - right[i]);
79+
const double sample = 0.5 * (static_cast<double>(left[kAnalysisStart + i])
80+
+ static_cast<double>(right[kAnalysisStart + i]));
81+
const double q0 = coeff * q1 - q2 + sample;
82+
q2 = q1;
83+
q1 = q0;
84+
}
85+
86+
const double power = q1 * q1 + q2 * q2 - coeff * q1 * q2;
87+
return std::sqrt(std::max(0.0, power)) / static_cast<double>(count);
88+
}
89+
90+
Signature measureRenderedSignature(const std::array<float, 4096>& left,
91+
const std::array<float, 4096>& right,
92+
const double sampleRate = 48000.0)
93+
{
94+
Signature signature {};
95+
96+
for (std::size_t i = kAnalysisStart; i < left.size(); ++i)
7197
signature.peak = std::max(signature.peak, std::max(std::fabs(left[i]), std::fabs(right[i])));
72-
++samples;
98+
99+
for (std::size_t band = 0; band < kBandCount; ++band)
100+
{
101+
const double frequency = static_cast<double>(kLowestBandHz)
102+
* std::pow(2.0, static_cast<double>(band) * 0.5);
103+
const double magnitude = frequency < sampleRate * 0.45
104+
? bandMagnitude(left, right, frequency, sampleRate)
105+
: 0.0;
106+
signature.bands[band] = static_cast<float>(std::log10(magnitude + kMagnitudeFloor));
73107
}
74108

75-
signature.rms = samples > 0 ? std::sqrt(energy / static_cast<float>(samples)) : 0.0f;
76-
signature.zeroCrossRate = samples > 0 ? static_cast<float>(crossings) / static_cast<float>(samples) : 0.0f;
77-
signature.sideRatio = energy > 0.0f ? side / (std::sqrt(energy) * static_cast<float>(samples)) : 0.0f;
78-
signature.roughness = samples > 0 ? roughness / static_cast<float>(samples) : 0.0f;
79109
return signature;
80110
}
81111

82-
ModeSignature renderModeSignature(const std::size_t mode)
112+
// Mean-removed cosine distance: 0 means identical band shape, larger means more
113+
// timbral difference. Removing the mean discards overall level so the check is
114+
// about spectrum rather than loudness.
115+
float spectralDistance(const Signature& a, const Signature& b)
116+
{
117+
double meanA = 0.0;
118+
double meanB = 0.0;
119+
for (std::size_t i = 0; i < kBandCount; ++i)
120+
{
121+
meanA += a.bands[i];
122+
meanB += b.bands[i];
123+
}
124+
meanA /= static_cast<double>(kBandCount);
125+
meanB /= static_cast<double>(kBandCount);
126+
127+
double dot = 0.0;
128+
double normA = 0.0;
129+
double normB = 0.0;
130+
for (std::size_t i = 0; i < kBandCount; ++i)
131+
{
132+
const double x = static_cast<double>(a.bands[i]) - meanA;
133+
const double y = static_cast<double>(b.bands[i]) - meanB;
134+
dot += x * y;
135+
normA += x * x;
136+
normB += y * y;
137+
}
138+
139+
if (normA <= 0.0 || normB <= 0.0)
140+
return 0.0f;
141+
142+
return static_cast<float>(1.0 - dot / std::sqrt(normA * normB));
143+
}
144+
145+
void printSignature(const char* label, const std::size_t index, const Signature& signature)
146+
{
147+
std::cerr << label << ' ' << index << " peak=" << signature.peak << " bands=[";
148+
for (std::size_t i = 0; i < kBandCount; ++i)
149+
std::cerr << signature.bands[i] << (i + 1 < kBandCount ? ", " : "");
150+
std::cerr << "]\n";
151+
}
152+
153+
Signature renderModeSignature(const std::size_t mode)
83154
{
84155
using downspout::gremlin::LiveParamId;
85156
using downspout::gremlin::MidiMessage;
@@ -102,7 +173,7 @@ ModeSignature renderModeSignature(const std::size_t mode)
102173
return measureRenderedSignature(left, right);
103174
}
104175

105-
ModeSignature renderSceneSignature(const downspout::gremlin::SceneId scene)
176+
Signature renderSceneSignature(const downspout::gremlin::SceneId scene)
106177
{
107178
using downspout::gremlin::MidiMessage;
108179
using downspout::gremlin::Processor;
@@ -123,17 +194,6 @@ ModeSignature renderSceneSignature(const downspout::gremlin::SceneId scene)
123194
return measureRenderedSignature(left, right);
124195
}
125196

126-
float signatureDistance(const ModeSignature& a, const ModeSignature& b)
127-
{
128-
const float rmsA = std::max(a.rms, 1.0e-6f);
129-
const float rmsB = std::max(b.rms, 1.0e-6f);
130-
return std::fabs(std::log(rmsA / rmsB)) * 0.35f
131-
+ std::fabs(a.zeroCrossRate - b.zeroCrossRate) * 2.2f
132-
+ std::fabs(a.sideRatio - b.sideRatio) * 1.4f
133-
+ std::fabs(a.peak - b.peak) * 0.35f
134-
+ std::fabs(a.roughness - b.roughness) * 18.0f;
135-
}
136-
137197
} // namespace
138198

139199
int main()
@@ -218,7 +278,7 @@ int main()
218278
require(modePeak > 0.005f, "gremlin mode should emit audio after note-on");
219279
}
220280

221-
std::array<ModeSignature, downspout::gremlin::kModeCount> signatures {};
281+
std::array<Signature, downspout::gremlin::kModeCount> signatures {};
222282
for (std::size_t mode = 0; mode < downspout::gremlin::kModeCount; ++mode)
223283
signatures[mode] = renderModeSignature(mode);
224284

@@ -230,35 +290,37 @@ int main()
230290
{
231291
if (mode == other)
232292
continue;
233-
const float distance = signatureDistance(signatures[mode], signatures[other]);
293+
const float distance = spectralDistance(signatures[mode], signatures[other]);
234294
if (distance < nearest)
235295
{
236296
nearest = distance;
237297
nearestMode = other;
238298
}
239299
}
240-
if (nearest <= 0.035f)
241-
std::cerr << "nearest signature distance for mode " << mode << " was " << nearest
242-
<< " against mode " << nearestMode << "\nmode " << mode
243-
<< " rms=" << signatures[mode].rms
244-
<< " zc=" << signatures[mode].zeroCrossRate
245-
<< " side=" << signatures[mode].sideRatio
246-
<< " peak=" << signatures[mode].peak
247-
<< " rough=" << signatures[mode].roughness
248-
<< "\nmode " << nearestMode
249-
<< " rms=" << signatures[nearestMode].rms
250-
<< " zc=" << signatures[nearestMode].zeroCrossRate
251-
<< " side=" << signatures[nearestMode].sideRatio
252-
<< " peak=" << signatures[nearestMode].peak
253-
<< " rough=" << signatures[nearestMode].roughness << '\n';
254-
require(nearest > 0.035f, "gremlin mode signatures should remain separated");
300+
if (nearest <= kModeSeparation)
301+
{
302+
std::cerr << "nearest spectral distance for mode " << mode << " was " << nearest
303+
<< " against mode " << nearestMode << " (threshold " << kModeSeparation << ")\n";
304+
printSignature("mode", mode, signatures[mode]);
305+
printSignature("mode", nearestMode, signatures[nearestMode]);
306+
}
307+
require(nearest > kModeSeparation, "gremlin mode signatures should remain separated");
255308
}
256309

257-
const ModeSignature musicalScene = renderSceneSignature(SceneId::melt);
258-
const ModeSignature extremeScene = renderSceneSignature(SceneId::tunnel);
310+
const Signature musicalScene = renderSceneSignature(SceneId::melt);
311+
const Signature extremeScene = renderSceneSignature(SceneId::tunnel);
259312
require(musicalScene.peak > 0.005f, "gremlin musical scene should emit audio");
260313
require(extremeScene.peak > 0.005f, "gremlin extreme scene should emit audio");
261-
require(signatureDistance(musicalScene, extremeScene) > 0.08f,
314+
315+
const float sceneDistance = spectralDistance(musicalScene, extremeScene);
316+
if (sceneDistance <= kSceneSeparation)
317+
{
318+
std::cerr << "musical/extreme scene spectral distance was " << sceneDistance
319+
<< " (threshold " << kSceneSeparation << ")\n";
320+
printSignature("scene", 0, musicalScene);
321+
printSignature("scene", 1, extremeScene);
322+
}
323+
require(sceneDistance > kSceneSeparation,
262324
"gremlin musical and extreme scenes should remain audibly separated");
263325

264326
float monoOnly[128] {};

0 commit comments

Comments
 (0)