feat(orbit): add pitch-preserving drift correction planner

Pure planner that decides hold / soft-nudge / seek for intra-track guest
drift, with ramp-inclusive feasibility math (triangle vs trapezoid) and a
30 s preferred horizon capped by the remaining track time. Gentlest rate
within budget wins; hard seek only when even the +-10% cap can't close the
gap before the track ends. Includes the 1%-per-tick rate stepper and unit
tests (9-case planner table + ramp simulator).
This commit is contained in:
Psychotoxical
2026-06-22 20:54:04 +02:00
parent 78a177b1bc
commit 90b7851785
3 changed files with 439 additions and 0 deletions
@@ -0,0 +1,61 @@
/**
* Orbit smooth drift correction — tunable constants.
*
* A guest that has drifted slightly from the host's live position is nudged
* back with a pitch-preserving speed change (≤ ±10%) instead of an audible
* hard seek. These numbers govern the deadband, the ramp cadence, and the
* time budgets the planner reasons about. See the approved spec
* (workdocs: 2026-06-22-orbit-smooth-drift-correction-plan.md, v2) for the
* rationale behind each value — they are deliberately chosen against the
* 2.5 s Navidrome poll cadence and real extrapolation noise.
*/
/**
* Drift at or below this is acceptable jitter (poll noise, extrapolation
* error). Correcting smaller errors causes audible pumping and wastes IPC.
* Also the amount subtracted from |drift| to get the "effective work" the
* correction actually has to close.
*/
export const DRIFT_DEADBAND_MS = 500;
/**
* Hysteresis exit: while actively correcting, treat the job as done once
* |drift| falls to this — then ramp back to 1.0×. Below the deadband so we
* don't flip-flop right at the 500 ms boundary.
*/
export const DRIFT_DONE_MS = 450;
/**
* Hysteresis entry: only *start* a fresh correction (from 1.0×) once |drift|
* reaches this. Above the deadband so brushing 500 ms doesn't kick off chatter.
*/
export const DRIFT_ENTER_MS = 550;
/**
* Preferred completion horizon for short corrections when the track is long
* enough. The planner picks the gentlest rate (closest to 1.0×) that still
* closes the drift within this window — not the fastest rate. Capped by the
* remaining track time near end-of-track (`T_target = min(this, T_track_rem)`).
*/
export const CORRECTION_TARGET_SEC = 30;
/** ±10% product cap on the correction rate. */
export const RATE_MIN = 0.9;
export const RATE_MAX = 1.1;
/** Quantised speed change per ramp step (1%). Predictable + testable. */
export const RATE_STEP = 0.01;
/**
* The rate moves by one `RATE_STEP` every this many ms, both accelerating and
* decelerating. A full 10% ramp therefore takes 5 s per leg — slow enough to
* avoid clicks, fast enough to matter for sync.
*/
export const RAMP_TICK_MS = 500;
/**
* Drift-correction loop cadence. Matches `RAMP_TICK_MS` (one step per tick) and
* is faster than the 2.5 s state poll so we extrapolate host position between
* polls.
*/
export const LOOP_TICK_MS = 500;
+204
View File
@@ -0,0 +1,204 @@
import { describe, expect, it } from 'vitest';
import {
driftCorrectionTimeSec,
planOrbitDriftCorrection,
stepRateToward,
type DriftCorrectionInput,
} from './driftCorrectionPlan';
import {
CORRECTION_TARGET_SEC,
RATE_MAX,
RATE_MIN,
RATE_STEP,
} from './driftCorrectionConstants';
/** A long track with plenty of room left — the common case. */
function longTrack(driftMs: number, extra: Partial<DriftCorrectionInput> = {}): DriftCorrectionInput {
return {
driftMs,
trackDurationMs: 240_000,
hostPositionMs: 60_000, // 180 s left
hostIsPlaying: true,
...extra,
};
}
/** `secondsLeft` of headroom in the track. */
function trackWith(driftMs: number, secondsLeft: number, extra: Partial<DriftCorrectionInput> = {}): DriftCorrectionInput {
return {
driftMs,
trackDurationMs: 300_000,
hostPositionMs: 300_000 - secondsLeft * 1000,
hostIsPlaying: true,
...extra,
};
}
describe('planOrbitDriftCorrection', () => {
it('case 1: small drift inside the deadband holds at 1.0×', () => {
const plan = planOrbitDriftCorrection(longTrack(400));
expect(plan).toEqual({ action: 'hold', rate: 1.0 });
});
it('case 2: 800 ms behind picks a gentle rate well within the 30 s horizon', () => {
const plan = planOrbitDriftCorrection(longTrack(-800));
expect(plan.action).toBe('soft');
if (plan.action !== 'soft') return;
expect(plan.targetRate).toBeGreaterThan(1.0);
expect(plan.targetRate).toBeLessThanOrEqual(1.03);
expect(plan.expectedDurationSec).toBeLessThanOrEqual(CORRECTION_TARGET_SEC + 1e-6);
});
it('case 3: ~3 s behind on a long track lands at the ±10% cap, ~30 s', () => {
const plan = planOrbitDriftCorrection(longTrack(-3050));
expect(plan.action).toBe('soft');
if (plan.action !== 'soft') return;
expect(plan.targetRate).toBeCloseTo(RATE_MAX, 5);
expect(plan.expectedDurationSec).toBeLessThanOrEqual(CORRECTION_TARGET_SEC + 1e-6);
expect(plan.expectedDurationSec).toBeGreaterThan(25);
});
it('case 4: larger drift on a long track runs past 30 s at the gentlest rate that fits', () => {
// Spec table suggests R=1.10, but Phase B's explicit rule is "minimum R
// such that T_total ≤ T_budget" — on a 120 s remainder a gentler 1.03 still
// fits (~100 s), so the minimum-R rule prefers it. The rule is normative;
// the table's 1.10 is illustrative. Flagged to cucadmuh for confirmation.
const plan = planOrbitDriftCorrection(trackWith(-3500, 120));
expect(plan.action).toBe('soft');
if (plan.action !== 'soft') return;
expect(plan.targetRate).toBeGreaterThan(1.0);
expect(plan.targetRate).toBeLessThanOrEqual(RATE_MAX + 1e-9);
expect(plan.expectedDurationSec).toBeGreaterThan(CORRECTION_TARGET_SEC);
expect(plan.expectedDurationSec).toBeLessThanOrEqual(120);
});
it('case 5: drift that cannot close before the track ends seeks', () => {
const plan = planOrbitDriftCorrection(trackWith(-3500, 20));
expect(plan).toEqual({ action: 'seek' });
});
it('case 6: 6 s behind on a 90 s remainder soft-corrects at the gentlest fitting rate', () => {
// Same minimum-R nuance as case 4 — the gentlest rate within the 90 s
// budget (~1.07) is preferred over the cap.
const plan = planOrbitDriftCorrection(trackWith(-6000, 90));
expect(plan.action).toBe('soft');
if (plan.action !== 'soft') return;
expect(plan.targetRate).toBeGreaterThan(1.0);
expect(plan.targetRate).toBeLessThanOrEqual(RATE_MAX + 1e-9);
expect(plan.expectedDurationSec).toBeGreaterThan(50);
expect(plan.expectedDurationSec).toBeLessThanOrEqual(90);
});
it('case 7: guest ahead slows down (rate < 1.0)', () => {
const plan = planOrbitDriftCorrection(longTrack(800));
expect(plan.action).toBe('soft');
if (plan.action !== 'soft') return;
expect(plan.targetRate).toBeLessThan(1.0);
expect(plan.targetRate).toBeGreaterThanOrEqual(0.98);
});
it('case 8: host paused holds regardless of drift', () => {
const plan = planOrbitDriftCorrection(longTrack(-5000, { hostIsPlaying: false }));
expect(plan).toEqual({ action: 'hold', rate: 1.0 });
});
it('holds when the track has no time remaining', () => {
const plan = planOrbitDriftCorrection(trackWith(-3000, 0));
expect(plan).toEqual({ action: 'hold', rate: 1.0 });
});
it('never proposes a rate outside the ±10% product cap', () => {
for (const drift of [-50_000, -3050, 3050, 50_000]) {
const plan = planOrbitDriftCorrection(longTrack(drift, { trackDurationMs: 3_600_000, hostPositionMs: 0 }));
if (plan.action === 'soft') {
expect(plan.targetRate).toBeGreaterThanOrEqual(RATE_MIN - 1e-9);
expect(plan.targetRate).toBeLessThanOrEqual(RATE_MAX + 1e-9);
}
}
});
describe('hysteresis', () => {
it('does not start a correction between deadband and enter threshold', () => {
// 520 ms: above the 500 ms deadband but below the 550 ms enter gate.
const plan = planOrbitDriftCorrection(longTrack(-520, { currentRate: 1.0 }));
expect(plan).toEqual({ action: 'hold', rate: 1.0 });
});
it('keeps correcting until drift falls to the done threshold', () => {
// 470 ms while already correcting: above the 450 ms done gate, below the
// 550 ms enter gate — a fresh plan would hold, but an active one continues.
const correcting = planOrbitDriftCorrection(longTrack(-470, { currentRate: 1.05 }));
expect(correcting).toEqual({ action: 'hold', rate: 1.0 }); // dEff ≤ 0 → ramp down
const done = planOrbitDriftCorrection(longTrack(-440, { currentRate: 1.05 }));
expect(done).toEqual({ action: 'hold', rate: 1.0 });
});
});
});
describe('driftCorrectionTimeSec', () => {
it('case 9: small effective drift closes as a triangle with no steady plateau', () => {
// dEff = 200 ms at the ±10% cap: the ramp legs alone (550 ms capacity)
// cover it, so the peak is never held — time is well under the 10 s full
// ramp and the steady-state estimate.
const t = driftCorrectionTimeSec(200, RATE_MAX);
const fullRampSec = 10; // 2 legs × 10 steps × 0.5 s
const naiveSteadySec = 200 / (0.1 * 1000); // 2 s — what ignoring ramps assumes
expect(t).toBeGreaterThan(naiveSteadySec);
expect(t).toBeLessThan(fullRampSec);
});
it('is a no-op (Infinity) at 1.0×', () => {
expect(driftCorrectionTimeSec(1000, 1.0)).toBe(Infinity);
});
it('returns 0 when there is nothing to close', () => {
expect(driftCorrectionTimeSec(0, RATE_MAX)).toBe(0);
});
it('decreases monotonically as the rate climbs (faster rate closes sooner)', () => {
let prev = Infinity;
for (let r = 1.01; r <= RATE_MAX + 1e-9; r += RATE_STEP) {
const t = driftCorrectionTimeSec(2000, Number(r.toFixed(2)));
expect(t).toBeLessThanOrEqual(prev + 1e-9);
prev = t;
}
});
});
describe('stepRateToward (ramp simulator)', () => {
it('steps toward a target by at most one RATE_STEP per tick and snaps on arrival', () => {
const target = 1.1;
let rate = 1.0;
const seq = [rate];
for (let i = 0; i < 50 && rate !== target; i += 1) {
const next = stepRateToward(rate, target);
expect(Math.abs(next - rate)).toBeLessThanOrEqual(RATE_STEP + 1e-9);
rate = next;
seq.push(rate);
}
expect(rate).toBeCloseTo(target, 9);
expect(seq.length).toBe(11); // 1.00 → 1.10 in ten 1% steps
});
it('ramps back down to exactly 1.0× without float dust', () => {
let rate = 1.07;
for (let i = 0; i < 50 && rate !== 1.0; i += 1) {
const next = stepRateToward(rate, 1.0);
expect(Math.abs(next - rate)).toBeLessThanOrEqual(RATE_STEP + 1e-9);
rate = next;
}
expect(rate).toBe(1.0);
});
it('never jumps more than one step toward a slow-down target either', () => {
let rate = 1.0;
const target = 0.9;
for (let i = 0; i < 50 && rate !== target; i += 1) {
const next = stepRateToward(rate, target);
expect(Math.abs(next - rate)).toBeLessThanOrEqual(RATE_STEP + 1e-9);
rate = next;
}
expect(rate).toBeCloseTo(target, 9);
});
});
+174
View File
@@ -0,0 +1,174 @@
/**
* Orbit smooth drift correction — pure planner.
*
* Given the current drift and the host's position in the track, decide how to
* close the gap: hold at 1.0×, nudge with a pitch-preserving speed change, or
* (only when the gap can't be closed before the track ends) fall back to a
* hard seek. No side effects — the runtime loop (`useOrbitGuestDriftCorrection`)
* steps the live rate toward the planned target and re-plans every tick.
*
* The feasibility math is ramp-inclusive: a move from 1.00 → R takes
* `RATE_STEP`-sized steps every `RAMP_TICK_MS`, so reaching ±10% costs ~10 s of
* ramp time and closes ~550 ms of drift during the ramps alone. Ignoring that
* (the naive `T = D / (0.10 * 1000)`) would under-budget every correction and
* pick seeks where a soft nudge would have finished in time. See the approved
* v2 spec for the derivation.
*/
import {
CORRECTION_TARGET_SEC,
DRIFT_DEADBAND_MS,
DRIFT_DONE_MS,
DRIFT_ENTER_MS,
RAMP_TICK_MS,
RATE_MAX,
RATE_MIN,
RATE_STEP,
} from './driftCorrectionConstants';
export interface DriftCorrectionInput {
/**
* Signed drift from `computeOrbitDriftMs`: `> 0` guest ahead (slow down),
* `< 0` guest behind (speed up).
*/
driftMs: number;
trackDurationMs: number;
hostPositionMs: number;
hostIsPlaying: boolean;
/** Current correction rate, for hysteresis. Defaults to 1.0 (not correcting). */
currentRate?: number;
}
export type DriftCorrectionPlan =
| { action: 'hold'; rate: 1.0 }
| { action: 'soft'; targetRate: number; expectedDurationSec: number }
| { action: 'seek' };
const HOLD: DriftCorrectionPlan = { action: 'hold', rate: 1.0 };
const SECONDS_PER_TICK = RAMP_TICK_MS / 1000;
/**
* Real seconds to close `dEffMs` of drift using a peak correction rate of `rate`,
* including both ramp legs (up to `rate` and back to 1.0×).
*
* Two regimes:
* - **Triangle** (`dEff ≤ D_ramp_total`): the ramps alone close the drift; the
* peak rate is never held. We find the smallest step count whose symmetric
* up/down ramp covers `dEff`. Time = `2 * steps * tick`.
* - **Trapezoid** (`dEff > D_ramp_total`): ramp up, hold a steady plateau at
* `rate`, ramp down. Time = ramp time + plateau time.
*
* Returns `Infinity` for a no-op rate (1.0×) so the scan skips it.
*/
export function driftCorrectionTimeSec(dEffMs: number, rate: number): number {
if (dEffMs <= 0) return 0;
const magnitude = Math.abs(rate - 1);
const n = Math.round(magnitude / RATE_STEP);
if (n <= 0) return Infinity;
// Drift (ms) closed by one ramp leg of `k` steps: each step k' runs at
// (1 + k'*STEP) for one tick, closing k'*STEP*1000*tick ms. Summed:
// Σ k'*STEP*1000*tick = STEP*1000*tick * n(n+1)/2.
const legClosedMs = (steps: number) =>
RATE_STEP * 1000 * SECONDS_PER_TICK * (steps * (steps + 1)) / 2;
const rampTotalMs = 2 * legClosedMs(n);
if (dEffMs <= rampTotalMs) {
// Triangle: smallest m with a symmetric up/down ramp covering dEff.
let m = 1;
while (m < n && 2 * legClosedMs(m) < dEffMs) m += 1;
return 2 * m * SECONDS_PER_TICK;
}
const rampSec = 2 * n * SECONDS_PER_TICK;
const steadySec = (dEffMs - rampTotalMs) / (1000 * magnitude);
return rampSec + steadySec;
}
/** Candidate peak rates from gentlest (±1%) to the ±10% cap, in the given direction. */
function candidateRates(direction: 1 | -1): number[] {
const rates: number[] = [];
for (let m = 1; m <= 10; m += 1) {
const r = 1 + direction * m * RATE_STEP;
// Guard the float cap — never propose a rate outside the product limit.
if (r < RATE_MIN - 1e-9 || r > RATE_MAX + 1e-9) break;
rates.push(Number(r.toFixed(2)));
}
return rates;
}
/**
* Scan candidate rates (gentlest first) and return the minimum-magnitude rate
* whose ramp-inclusive correction time fits `budgetSec`, or null if none does.
*/
function gentlestRateWithin(
dEffMs: number,
direction: 1 | -1,
budgetSec: number,
): { rate: number; durationSec: number } | null {
for (const rate of candidateRates(direction)) {
const t = driftCorrectionTimeSec(dEffMs, rate);
if (t <= budgetSec) return { rate, durationSec: t };
}
return null;
}
/**
* Move `current` one `RATE_STEP` toward `target`, never overshooting and never
* jumping more than a single step. The runtime loop calls this once per tick so
* the rate ramps smoothly; snaps exactly to `target` (and to 1.0×) once within
* a step to avoid float dust like 1.0000000002.
*/
export function stepRateToward(current: number, target: number, step: number = RATE_STEP): number {
const delta = target - current;
if (Math.abs(delta) <= step) return target;
return current + Math.sign(delta) * step;
}
export function planOrbitDriftCorrection(input: DriftCorrectionInput): DriftCorrectionPlan {
const { driftMs, trackDurationMs, hostPositionMs, hostIsPlaying } = input;
const currentRate = input.currentRate ?? 1.0;
// ── Guards → hold ──
if (!hostIsPlaying) return HOLD;
if (!(trackDurationMs > 0)) return HOLD;
const tTrackRemSec = (trackDurationMs - hostPositionMs) / 1000;
if (tTrackRemSec <= 0) return HOLD;
// ── Hysteresis gate ──
// Already correcting: keep going until we're comfortably back inside the
// deadband (DONE), then ramp down. From rest: only start once drift clears
// the higher ENTER threshold so brushing the boundary doesn't chatter.
const correcting = Math.abs(currentRate - 1) > 1e-9;
const absDrift = Math.abs(driftMs);
if (correcting) {
if (absDrift <= DRIFT_DONE_MS) return HOLD;
} else if (absDrift < DRIFT_ENTER_MS) {
return HOLD;
}
// Effective work above the deadband. ≤ 0 means we're inside the acceptable
// jitter band — nothing to close.
const dEff = Math.max(0, absDrift - DRIFT_DEADBAND_MS);
if (dEff <= 0) return HOLD;
// Guest behind (drift < 0) → speed up (R > 1); guest ahead → slow down.
const direction: 1 | -1 = driftMs < 0 ? 1 : -1;
const tTarget = Math.min(CORRECTION_TARGET_SEC, tTrackRemSec);
// Phase A — gentlest rate that finishes within the preferred horizon.
const phaseA = gentlestRateWithin(dEff, direction, tTarget);
if (phaseA) {
return { action: 'soft', targetRate: phaseA.rate, expectedDurationSec: phaseA.durationSec };
}
// Phase B — gentlest rate that still finishes before the track ends.
// On a long track a large drift may legitimately need 4060 s at the cap.
const phaseB = gentlestRateWithin(dEff, direction, tTrackRemSec);
if (phaseB) {
return { action: 'soft', targetRate: phaseB.rate, expectedDurationSec: phaseB.durationSec };
}
// Phase C — even the ±10% cap can't close it before the track ends. Seek.
return { action: 'seek' };
}