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