diff --git a/src/utils/orbit/driftCorrectionPlan.test.ts b/src/utils/orbit/driftCorrectionPlan.test.ts index cc1a57f5..32388198 100644 --- a/src/utils/orbit/driftCorrectionPlan.test.ts +++ b/src/utils/orbit/driftCorrectionPlan.test.ts @@ -1,34 +1,65 @@ import { describe, expect, it } from 'vitest'; import { planOrbitDriftCorrection, stepRateToward, type DriftCorrectionInput } from './driftCorrectionPlan'; -import { RATE_STEP } from './driftCorrectionConstants'; +import { RATE_MAX, RATE_MIN, RATE_STEP } from './driftCorrectionConstants'; function input(over: Partial = {}): DriftCorrectionInput { return { driftMs: 0, hostIsPlaying: true, ...over }; } -// Speed correction is disabled (audible wobble in practice). The planner now -// only holds 1.0× or surfaces the manual Catch-Up button past a moderate drift. -describe('planOrbitDriftCorrection (speed correction disabled)', () => { +// Proportional speed correction is re-enabled (SPEED_CORRECTION_ENABLED = true) +// for another round of live testing. These cover the active controller. +describe('planOrbitDriftCorrection (proportional)', () => { it('holds when the host is paused', () => { - expect(planOrbitDriftCorrection(input({ driftMs: -9000, hostIsPlaying: false }))).toEqual({ action: 'hold' }); + expect(planOrbitDriftCorrection(input({ driftMs: -3000, hostIsPlaying: false }))).toEqual({ action: 'hold' }); }); - it('holds for small and moderate drift — never nudges the rate', () => { - for (const d of [-2900, -1500, -1000, 0, 1000, 2900]) { - expect(planOrbitDriftCorrection(input({ driftMs: d }))).toEqual({ action: 'hold' }); - } + it('holds inside the deadband', () => { + expect(planOrbitDriftCorrection(input({ driftMs: -900 }))).toEqual({ action: 'hold' }); // < 1000 + expect(planOrbitDriftCorrection(input({ driftMs: 900 }))).toEqual({ action: 'hold' }); }); - it('surfaces the catch-up button (seek) once drift passes the threshold', () => { - expect(planOrbitDriftCorrection(input({ driftMs: -3100 }))).toEqual({ action: 'seek' }); - expect(planOrbitDriftCorrection(input({ driftMs: 4000 }))).toEqual({ action: 'seek' }); - expect(planOrbitDriftCorrection(input({ driftMs: 13000 }))).toEqual({ action: 'seek' }); + it('reaches the full cap at the full-scale drift', () => { + // FULL_SCALE = 4000: behind 4 s → +10%, ahead 4 s → −10%. + const behind = planOrbitDriftCorrection(input({ driftMs: -4000 })); + expect(behind).toEqual({ action: 'correct', targetRate: RATE_MAX }); + const ahead = planOrbitDriftCorrection(input({ driftMs: 4000 })); + expect(ahead).toEqual({ action: 'correct', targetRate: RATE_MIN }); }); - it('never proposes a speed correction while disabled', () => { - for (const d of [-50000, -3000, -1600, 1600, 3000, 50000]) { - expect(planOrbitDriftCorrection(input({ driftMs: d })).action).not.toBe('correct'); + it('scales gently for a mid drift (no full whack near synced)', () => { + // Halfway between deadband (1000) and full-scale (4000) → ~half the cap. + const p = planOrbitDriftCorrection(input({ driftMs: -2500 })); + expect(p.action).toBe('correct'); + if (p.action !== 'correct') return; + // (2500-1000)/(4000-1000) = 0.5 → 1 + 0.10×0.5 = 1.05 + expect(p.targetRate).toBeCloseTo(1.05, 5); + }); + + it('is continuous across the deadband edge — a tiny over-deadband drift gets a tiny nudge', () => { + const p = planOrbitDriftCorrection(input({ driftMs: -1001 })); + expect(p.action).toBe('correct'); + if (p.action !== 'correct') return; + expect(p.targetRate).toBeGreaterThan(1.0); + expect(p.targetRate).toBeLessThan(1.001); // essentially neutral right at the edge + }); + + it('clamps beyond full-scale to the cap', () => { + expect(planOrbitDriftCorrection(input({ driftMs: -7000 }))).toEqual({ action: 'correct', targetRate: RATE_MAX }); + }); + + it('seeks (manual button) only past the hard threshold', () => { + expect(planOrbitDriftCorrection(input({ driftMs: -9000 }))).toEqual({ action: 'seek' }); // > 8000 + expect(planOrbitDriftCorrection(input({ driftMs: 9000 }))).toEqual({ action: 'seek' }); + }); + + it('never proposes a rate outside the ±10% cap', () => { + for (const d of [-50000, -3000, 3000, 50000]) { + const p = planOrbitDriftCorrection(input({ driftMs: d })); + if (p.action === 'correct') { + expect(p.targetRate).toBeGreaterThanOrEqual(RATE_MIN - 1e-9); + expect(p.targetRate).toBeLessThanOrEqual(RATE_MAX + 1e-9); + } } }); }); @@ -36,12 +67,15 @@ describe('planOrbitDriftCorrection (speed correction disabled)', () => { describe('stepRateToward', () => { it('moves at most one step and snaps on arrival', () => { let rate = 1.0; + const seq = [rate]; for (let i = 0; i < 50 && rate !== 1.1; i += 1) { const next = stepRateToward(rate, 1.1); expect(Math.abs(next - rate)).toBeLessThanOrEqual(RATE_STEP + 1e-9); rate = next; + seq.push(rate); } expect(rate).toBeCloseTo(1.1, 9); + expect(seq.length).toBe(11); }); it('ramps back to exactly 1.0× without float dust', () => { @@ -49,4 +83,13 @@ describe('stepRateToward', () => { for (let i = 0; i < 50 && rate !== 1.0; i += 1) rate = stepRateToward(rate, 1.0); expect(rate).toBe(1.0); }); + + it('tracks a lowered target mid-ramp (no overshoot)', () => { + // Heading to 1.10 but the target drops to 1.03 — should settle at 1.03. + let rate = 1.0; + rate = stepRateToward(rate, 1.1); // 1.01 + rate = stepRateToward(rate, 1.1); // 1.02 + for (let i = 0; i < 10 && rate !== 1.03; i += 1) rate = stepRateToward(rate, 1.03); + expect(rate).toBeCloseTo(1.03, 9); + }); }); diff --git a/src/utils/orbit/driftCorrectionPlan.ts b/src/utils/orbit/driftCorrectionPlan.ts index 4952c270..d5390524 100644 --- a/src/utils/orbit/driftCorrectionPlan.ts +++ b/src/utils/orbit/driftCorrectionPlan.ts @@ -25,13 +25,13 @@ import { } from './driftCorrectionConstants'; /** - * Automatic speed nudging is **disabled**: in practice every pitch-preserving - * rate change is audible (tempo wobble / DSP distortion), and the host's sync - * at track change plus the manual Catch-Up button keep the guest aligned well - * enough. The proportional controller below is kept behind this flag in case the - * preserve-pitch DSP improves enough to revisit it. + * Toggle for the automatic proportional speed nudging. We disabled it once + * because every pitch-preserving rate change was audible in practice (tempo + * wobble / DSP distortion); it's re-enabled now for another round of live + * testing. Flip to `false` to fall back to "hold 1.0× + manual Catch-Up button + * past DRIFT_CATCHUP_BUTTON_MS" without touching the controller below. */ -const SPEED_CORRECTION_ENABLED = false; +const SPEED_CORRECTION_ENABLED = true; export interface DriftCorrectionInput { /** Smoothed, signed drift: `> 0` guest ahead (slow down), `< 0` behind (speed up). */