@@ -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
0 commit comments