fix(orbit): stamp host positionAt with the position it belongs to

The host's currentTime updates in coarse ~5.6 s steps (decode/progress
granularity) while the 2.5 s state-write tick runs steadily. Stamping
positionAt=now every tick while positionMs sat frozen made the guest's
extrapolation (posMs + (now - posAt)) stall, then jump ~5.6 s on the next
update — so the measured drift oscillated ±5 s and no controller could track
it (the real root behind the bang-bang/proportional swinging).

Pair the timestamp with the position: while playing and the position is
unchanged, keep the original (positionMs, positionAt) so the guest
extrapolates smoothly from the last real measurement. Extracted as a tested
makeHostPositionStamper; the drift jump at a position update drops from
~5600 ms to ~570 ms (under the deadband).
This commit is contained in:
Psychotoxical
2026-06-23 02:30:33 +02:00
parent f66ad3b578
commit 61947ee5a7
4 changed files with 108 additions and 2 deletions
+13 -2
View File
@@ -10,6 +10,7 @@ import {
maybeShuffleQueue,
effectiveShuffleIntervalMs,
makeCoalescedRunner,
makeHostPositionStamper,
readOrbitTransitionSettings,
suggestionKey,
} from '../utils/orbit';
@@ -57,13 +58,23 @@ export function useOrbitHost(): void {
useEffect(() => {
if (!active || !sessionPlaylistId) return;
// Pairs `positionAt` with the position it actually belongs to, so a frozen
// (coarse-updating) host `currentTime` doesn't make the guest's
// extrapolation stall-then-jump. See `makeHostPositionStamper`.
const stampPosition = makeHostPositionStamper();
const snapshotPlayerPatch = (hostUsername: string): Partial<OrbitState> => {
const p = usePlayerStore.getState();
const now = Date.now();
const { positionMs, positionAt } = stampPosition(
Math.round((p.currentTime ?? 0) * 1000),
p.isPlaying,
now,
);
return {
isPlaying: p.isPlaying,
positionMs: Math.round((p.currentTime ?? 0) * 1000),
positionAt: now,
positionMs,
positionAt,
currentTrack: p.currentTrack
? {
trackId: p.currentTrack.id,
+5
View File
@@ -33,6 +33,11 @@ export {
suggestionKey,
} from './orbit/helpers';
export { isOrbitPlaybackSyncActive } from './orbit/sessionActive';
export {
makeHostPositionStamper,
type HostPositionStamper,
type PositionStamp,
} from './orbit/hostPositionStamp';
export {
buildGuestPreloadRefs,
syncGuestPreloadQueue,
+54
View File
@@ -0,0 +1,54 @@
import { describe, expect, it } from 'vitest';
import { makeHostPositionStamper } from './hostPositionStamp';
describe('makeHostPositionStamper', () => {
it('stamps now when the position advances', () => {
const stamp = makeHostPositionStamper();
expect(stamp(1000, true, 100)).toEqual({ positionMs: 1000, positionAt: 100 });
expect(stamp(1500, true, 600)).toEqual({ positionMs: 1500, positionAt: 600 });
});
it('keeps the original timestamp while a playing position is frozen', () => {
const stamp = makeHostPositionStamper();
stamp(124664, true, 1000); // real measurement
// Next ticks: position frozen (coarse update), wall-clock advances.
expect(stamp(124664, true, 3500)).toEqual({ positionMs: 124664, positionAt: 1000 });
expect(stamp(124664, true, 6000)).toEqual({ positionMs: 124664, positionAt: 1000 });
});
it('re-stamps when the position finally jumps', () => {
const stamp = makeHostPositionStamper();
stamp(124664, true, 1000);
stamp(124664, true, 3500);
expect(stamp(130236, true, 6000)).toEqual({ positionMs: 130236, positionAt: 6000 });
});
it('keeps the guest extrapolation continuous across a coarse position jump', () => {
// Host plays 1.0×, so posMs tracks wall-clock. currentTime updates in coarse
// steps: frozen at 100000 for three 2.5 s ticks, then jumps to 107500 at
// t=7500. The guest extrapolates `posMs + (now - posAt)`.
const stamp = makeHostPositionStamper();
stamp(100000, true, 0);
stamp(100000, true, 2500);
const frozen = stamp(100000, true, 5000);
const jumped = stamp(107500, true, 7500);
const extrapolate = (s: { positionMs: number; positionAt: number }, now: number) =>
s.positionMs + (now - s.positionAt);
// At the moment of the jump (t=7500) the old frozen stamp and the fresh one
// agree exactly — no leap. Without the fix `frozen` would carry posAt=5000,
// read 102500, and then jump 5 s to 107500.
expect(extrapolate(frozen, 7500)).toBe(107500);
expect(extrapolate(jumped, 7500)).toBe(107500);
});
it('always stamps now when paused (no extrapolation happens then anyway)', () => {
const stamp = makeHostPositionStamper();
stamp(5000, false, 100);
// Same position while paused → still stamps fresh now (guest stops
// extrapolating on pause, so consistency isn't needed).
expect(stamp(5000, false, 600)).toEqual({ positionMs: 5000, positionAt: 600 });
});
});
+36
View File
@@ -0,0 +1,36 @@
/**
* Orbit host — consistent playback-position stamping.
*
* The host's `currentTime` updates in coarse steps (decode / progress
* granularity gives ~5.6 s jumps) while the state-write tick runs every 2.5 s.
* Stamping `positionAt = now` on every tick while `positionMs` is frozen breaks
* the guest's extrapolation `posMs + (now - posAt)`: it stalls while the
* position is frozen, then jumps ~5.6 s when it finally updates — and the
* measured drift oscillates ±5 s, which no controller can track.
*
* The stamper keeps the timestamp paired with the *position it belongs to*:
* while playing and the position is unchanged, it returns the original
* `(positionMs, positionAt)` pair, so the guest extrapolates smoothly from the
* last real measurement.
*/
export interface PositionStamp {
positionMs: number;
positionAt: number;
}
export type HostPositionStamper = (curMs: number, isPlaying: boolean, now: number) => PositionStamp;
export function makeHostPositionStamper(): HostPositionStamper {
let lastPosMs = -1;
let lastPosAt = 0;
return (curMs, isPlaying, now) => {
if (isPlaying && curMs === lastPosMs) {
// Frozen this tick — keep the stamp from when the position was real.
return { positionMs: curMs, positionAt: lastPosAt };
}
lastPosMs = curMs;
lastPosAt = now;
return { positionMs: curMs, positionAt: now };
};
}