mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 23:35:44 +00:00
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:
@@ -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;
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 40–60 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' };
|
||||
}
|
||||
Reference in New Issue
Block a user