fix(app): progression commit-layer tuning — survive fills, replace on evidence (task L-31)
COMMIT_VOTES=2 (unchanged, ~one bar), REPLACE_VOTES=3 (a transient or
flapping detection can never displace a committed loop; section change
replaces one commit later), NULL_CLEAR=6 (post-L-30, nulls only start
~28 commits after the loop last played — traced exactly). Vote ref now
{committedKey, candidateKey, candidateCount}; agreement resets rivals.
Diff = one hunk in the progression useEffect; audio-contract grep
clean. Ride-along: theory.js docstring overclaim softened (L-30 gate
finding b), comment-only. Critic PASS (independent re-trace of all
five scenarios + 3 hostile traces; REPLACE_VOTES=3 proven load-bearing).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+47
-6
@@ -151,24 +151,65 @@ export default function App() {
|
||||
chromaIdxRef.current = 0
|
||||
}, [config.chromaSmooth])
|
||||
|
||||
// ── Detect progression — require 2 consecutive identical results to commit ────
|
||||
// ── Progression commit layer (task L-31) ─────────────────────────────────────
|
||||
// Runs once per chord commit. progressionVoteRef holds
|
||||
// { committedKey, candidateKey, candidateCount } — null until first evidence.
|
||||
//
|
||||
// Thresholds are in chord-commits (one detector run each):
|
||||
// · COMMIT_VOTES = 2 — first commit needs 2 consecutive identical detections
|
||||
// (≈ one bar). Post-L-30 detection is stable on clean loops so this lands
|
||||
// immediately; a noisy 2-rep history flaps through data-faithful sub-cycles
|
||||
// that never repeat twice in a row, so noise can't commit early.
|
||||
// · REPLACE_VOTES = 3 — replacing a committed loop needs 3 consecutive
|
||||
// detections of the SAME new loop: one transient detection (or an
|
||||
// alternating flap) must never displace the loop the musician is still in;
|
||||
// a genuine section change is detected consistently and just lands one
|
||||
// commit later than a first commit would.
|
||||
// · NULL_CLEAR = 6 — the detector only returns null once the loop has
|
||||
// aged out of its 32-commit window (it needs 2 exact in-window
|
||||
// occurrences): traced, that is ~28 commits of foreign material after
|
||||
// the loop last played. Fills, turnarounds and window-boundary resumes
|
||||
// yield non-null sub-cycle detections rather than nulls (traced), so
|
||||
// they can NEVER clear a committed loop — a new established loop
|
||||
// replaces via REPLACE_VOTES instead. A null run therefore means the
|
||||
// jam truly left loop-land ~30 commits ago; 6 more (≈ two bars of
|
||||
// structureless playing) confirms it wasn't a flicker before the
|
||||
// display goes dark. (The old value 4 was sized as if nulls happened
|
||||
// during fills — post-L-30 they don't.)
|
||||
const COMMIT_VOTES = 2
|
||||
const REPLACE_VOTES = 3
|
||||
const NULL_CLEAR = 6
|
||||
useEffect(() => {
|
||||
const detected = detectRepeatingProgression(chordHistory)
|
||||
const vote = progressionVoteRef.current
|
||||
if (!detected) {
|
||||
progressionMissRef.current++
|
||||
// Clear stale loop after 4 chord changes with no pattern found
|
||||
if (progressionMissRef.current >= 4) {
|
||||
if (progressionMissRef.current >= NULL_CLEAR) {
|
||||
setDetectedProgression(null)
|
||||
progressionVoteRef.current = null
|
||||
}
|
||||
return
|
||||
}
|
||||
progressionMissRef.current = 0
|
||||
progressionMissRef.current = 0 // any detected structure keeps the committed loop alive
|
||||
const key = detected.join(',')
|
||||
if (progressionVoteRef.current === key) {
|
||||
|
||||
if (vote && vote.committedKey === key) {
|
||||
// Agreement with the committed loop — refresh it, drop any pending rival.
|
||||
setDetectedProgression(detected)
|
||||
vote.candidateKey = null
|
||||
vote.candidateCount = 0
|
||||
return
|
||||
}
|
||||
|
||||
const committedKey = vote ? vote.committedKey : null
|
||||
if (vote && vote.candidateKey === key) {
|
||||
vote.candidateCount++
|
||||
} else {
|
||||
progressionVoteRef.current = key
|
||||
progressionVoteRef.current = { committedKey, candidateKey: key, candidateCount: 1 }
|
||||
}
|
||||
if (progressionVoteRef.current.candidateCount >= (committedKey ? REPLACE_VOTES : COMMIT_VOTES)) {
|
||||
setDetectedProgression(detected)
|
||||
progressionVoteRef.current = { committedKey: key, candidateKey: null, candidateCount: 0 }
|
||||
}
|
||||
}, [chordHistory])
|
||||
|
||||
|
||||
+4
-2
@@ -600,8 +600,10 @@ function canonicalize(pattern) {
|
||||
* section outscores a longer stale one. Requires ≥2 EXACT occurrences: an
|
||||
* edit-tolerant occurrence corroborates a loop but cannot establish it — a
|
||||
* loop means the sequence came back exactly, and a ghost slice that absorbs a
|
||||
* noise chord into itself occurs exactly only once by construction. Returns
|
||||
* the canonical (rotation-normalised) best pattern.
|
||||
* noise chord into itself rarely recurs exactly (only phase-locked corruption
|
||||
* of the same slot by the same chord can make one recur — and such data is
|
||||
* genuinely periodic at that longer length). Returns the canonical
|
||||
* (rotation-normalised) best pattern.
|
||||
*/
|
||||
export function detectRepeatingProgression(history) {
|
||||
if (!history || history.length < 6) return null
|
||||
|
||||
Reference in New Issue
Block a user