Skip to content

Commit f20e4da

Browse files
committed
VXTune Updates
1 parent e15cfe3 commit f20e4da

4 files changed

Lines changed: 119 additions & 8 deletions

File tree

‎Source/vxstudio/products/leveler/dsp/VxLevelerDsp.cpp‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -536,7 +536,14 @@ void Dsp::processGeneralMode(juce::AudioBuffer<float>& buffer,
536536
const float mixDecisionTrust = clamp01(signalTrust
537537
* (1.0f - 0.22f * clamp01(params.monoScore))
538538
* (1.0f - 0.18f * compressionPenalty));
539-
const float generalMomentaryCoeff = timeCoeff(sr, 0.40f);
539+
// Spike detection compares momentary against short-term level (overshootDb
540+
// below). At 0.40s this never fully settles between the ~0.4-0.5s repeats
541+
// typical of rhythmic/pulsing program material, so overshootDb reads as
542+
// continuously elevated instead of a brief transient - generalSpikeGain
543+
// then stays pinned near its floor for the whole passage rather than
544+
// ducking only genuine spikes. 0.06s decays between such repeats while
545+
// still smoothing over a handful of samples.
546+
const float generalMomentaryCoeff = timeCoeff(sr, 0.06f);
540547
const float generalShortCoeff = timeCoeff(sr, 3.0f);
541548
const float generalBaselineRiseCoeff = timeCoeff(sr, 4.2f);
542549
const float generalBaselineFallCoeff = timeCoeff(sr, 8.0f);

‎Source/vxstudio/products/tune/dsp/VxTuneCorrectionEngine.h‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,8 +151,17 @@ class CorrectionEngine {
151151
// decompConfidence of exactly zero (true unvoiced) is how it knows
152152
// to forget between phrases; weak-but-nonzero confidence just
153153
// contributes weak evidence rather than forcing a reset.
154+
// Previous frame's segment (segmenter.process() for THIS frame
155+
// hasn't run yet) grounds a small switching-inertia bias: real
156+
// sustain/vibrato should resist a single competing frame, a
157+
// passing/onset/slide segment should not. See
158+
// TargetEstimator::update()'s stabilityBias doc for why this is
159+
// safe (segmenter is independent of the estimator's own choice).
160+
const NoteSegment& previousSegment = segmenter.currentSegment();
161+
const float stabilityBias = previousSegment.behaviourProb[static_cast<int>(Behaviour::sustain)]
162+
+ previousSegment.behaviourProb[static_cast<int>(Behaviour::vibrato)];
154163
const auto target = estimator.update(frame.centreCents, frame.decompConfidence,
155-
scaleMask, natural);
164+
scaleMask, natural, stabilityBias);
156165
lastTarget = {
157166
target.valid,
158167
target.midiNote,

‎Source/vxstudio/products/tune/dsp/VxTuneTargetEstimator.h‎

Lines changed: 48 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ class TargetEstimator {
6262

6363
void reset() noexcept {
6464
count = 0;
65+
lastMidi = -1000000;
6566
}
6667

6768
struct Result {
@@ -72,12 +73,33 @@ class TargetEstimator {
7273
float marginLog = 0.0f; // best score - runner-up score
7374
};
7475

76+
// Bounds how much a single frame's switching-inertia bonus can help the
77+
// previously-held target. Deliberately small relative to kInRangeBonus
78+
// (3.0): this must nudge a close call, never let a stale target beat
79+
// fresh, clearly-better evidence outright.
80+
static constexpr float kSwitchInertiaMax = 0.8f;
81+
7582
// One call per analysis frame, always (even low-confidence/unvoiced —
7683
// that is how the estimator knows to forget between phrases).
7784
// decompConfidence == 0 means "nothing to go on": the pool resets so the
7885
// next phrase starts fresh instead of gliding from a stale target.
86+
//
87+
// stabilityBias (0..1, typically the caller's previous-frame
88+
// behaviourProb[sustain]+[vibrato] from VxTuneSegmenter) grounds a small
89+
// per-frame preference for whichever note won last frame: real
90+
// sustain/vibrato should not flip on a single frame's competing blip,
91+
// while a genuine passing/onset/slide segment (bias near 0) switches as
92+
// fast as it always has. The segmenter computes this independently of
93+
// which note the estimator itself favours (see VxTuneSegmenter.h), so
94+
// this is not the estimator validating its own past choice - the
95+
// failure mode this project already caught once with the reverted
96+
// octave-continuity fix. The bonus is applied only to THIS frame's
97+
// decision, never folded into a candidate's stored score, so it cannot
98+
// accumulate across frames the way stale evidence itself once did (see
99+
// kScoreFloor/kInRangeBonus above).
79100
Result update(const float centreCents, const float decompConfidence,
80-
const std::uint16_t scaleMask, const float natural) noexcept {
101+
const std::uint16_t scaleMask, const float natural,
102+
const float stabilityBias = 0.0f) noexcept {
81103
if (decompConfidence <= 0.0f) {
82104
reset();
83105
return {};
@@ -124,16 +146,34 @@ class TargetEstimator {
124146
if (count == 0)
125147
return {};
126148

149+
const float bias = stabilityBias <= 0.0f
150+
? 0.0f
151+
: (stabilityBias >= 1.0f ? 1.0f : stabilityBias);
152+
const float inertiaBonus = bias * kSwitchInertiaMax;
153+
const auto effective = [this, inertiaBonus](const int i) noexcept {
154+
return candidates[i].score
155+
+ (candidates[i].midi == lastMidi ? inertiaBonus : 0.0f);
156+
};
157+
127158
int bestIdx = 0;
128-
for (int i = 1; i < count; ++i)
129-
if (candidates[i].score > candidates[bestIdx].score)
159+
float bestEffective = effective(0);
160+
for (int i = 1; i < count; ++i) {
161+
const float e = effective(i);
162+
if (e > bestEffective) {
163+
bestEffective = e;
130164
bestIdx = i;
165+
}
166+
}
131167
int runnerIdx = -1;
168+
float runnerEffective = 0.0f;
132169
for (int i = 0; i < count; ++i) {
133170
if (i == bestIdx)
134171
continue;
135-
if (runnerIdx < 0 || candidates[i].score > candidates[runnerIdx].score)
172+
const float e = effective(i);
173+
if (runnerIdx < 0 || e > runnerEffective) {
136174
runnerIdx = i;
175+
runnerEffective = e;
176+
}
137177
}
138178

139179
Result r;
@@ -142,9 +182,10 @@ class TargetEstimator {
142182
r.errorCents = centreCents - 100.0f * static_cast<float>(candidates[bestIdx].midi - 69);
143183
r.runnerUpMidiNote = runnerIdx >= 0 ? candidates[runnerIdx].midi : -1;
144184
r.marginLog = runnerIdx >= 0
145-
? candidates[bestIdx].score - candidates[runnerIdx].score
146-
: candidates[bestIdx].score;
185+
? bestEffective - runnerEffective
186+
: bestEffective;
147187

188+
lastMidi = r.midiNote;
148189
return r;
149190
}
150191

@@ -189,6 +230,7 @@ class TargetEstimator {
189230
float frameDur = 1.0f / 187.5f;
190231
Candidate candidates[kMaxCandidates] {};
191232
int count = 0;
233+
int lastMidi = -1000000;
192234
};
193235

194236
} // namespace vxsuite::tune

‎tests/VxTuneAnalysisTests.cpp‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -488,6 +488,58 @@ void testMusicalAuthorityProtectsUnstableFrames() {
488488
+ std::to_string(settled) + "c)");
489489
}
490490

491+
void testTargetSwitchingInertiaAtBoundary() {
492+
std::printf("Target-switching inertia damps flicker at a semitone "
493+
"boundary without blocking a genuine note change:\n");
494+
// A single-frame competing blip amid a long-held note would never flip
495+
// the estimator's argmax anyway - the incumbent's accumulated evidence
496+
// dwarfs one fresh frame's contribution by design (that is the whole
497+
// point of evidence accumulation). The regime this fix actually targets
498+
// is a noisy real pitch estimate hovering almost exactly on a semitone
499+
// boundary during a genuinely held/sustained note, where per-frame
500+
// jitter can tip the instantaneous argmax back and forth many times a
501+
// second - the F4-round-6 "on/off" flicker bug class, one level up
502+
// (which NOTE is favoured, not just whether correction engages).
503+
constexpr double frameRate = 48000.0 / 256.0;
504+
CorrectionEngine engine;
505+
engine.prepare(frameRate, CorrectionEngine::Config {});
506+
507+
std::mt19937 rng(12345);
508+
std::uniform_real_distribution<float> jitter(-8.0f, 8.0f);
509+
510+
int switches = 0;
511+
int lastMidi = -1000000;
512+
// Hover exactly between A3 (0c) and A#3 (100c) with small per-frame
513+
// jitter, both candidates symmetric at +/-50c.
514+
for (int i = 0; i < 60; ++i) {
515+
auto frame = syntheticFrame(220.0f, 50.0f + jitter(rng), 0.95f,
516+
EstimateReason::nominal);
517+
frame.timeSamples = static_cast<std::int64_t>(i * 256);
518+
engine.process(frame, 1.0f, 0.5f);
519+
const int midi = engine.currentTargetSnapshot().midiNote;
520+
if (lastMidi != -1000000 && midi != lastMidi)
521+
++switches;
522+
lastMidi = midi;
523+
}
524+
check(switches <= 6,
525+
"target does not flicker every frame while hovering at the "
526+
"boundary (got " + std::to_string(switches)
527+
+ " switches over 60 frames)");
528+
529+
// Contrast: a genuine, unambiguous note change (A3 -> C4, three
530+
// semitones, unmistakably nearer the new note every frame) must still
531+
// land promptly - inertia must not create a new stuck-target bug.
532+
for (int i = 0; i < 30; ++i) {
533+
auto frame = syntheticFrame(261.63f, 0.0f, 0.95f, EstimateReason::nominal);
534+
frame.timeSamples = static_cast<std::int64_t>((60 + i) * 256);
535+
engine.process(frame, 1.0f, 0.5f);
536+
}
537+
const int finalMidi = engine.currentTargetSnapshot().midiNote;
538+
check(finalMidi == 60,
539+
"genuine note change to C4 still converges despite switching "
540+
"inertia (got midi " + std::to_string(finalMidi) + ")");
541+
}
542+
491543
// Runs detector -> decomposition -> engine on `audio` and returns the
492544
// dominant behaviour + its probability at the LAST analysed frame -
493545
// i.e. after the whole clip has been seen, matching how a real-time
@@ -684,6 +736,7 @@ int main() {
684736
testDivergenceGuardStopsChasingAGlide();
685737
testDoesNotTouchInTune();
686738
testMusicalAuthorityProtectsUnstableFrames();
739+
testTargetSwitchingInertiaAtBoundary();
687740
testBehaviourDistribution();
688741
testScaleAwareTargets();
689742
testVibratoSurvivesCorrection();

0 commit comments

Comments
 (0)