Compare commits

...

13 Commits

Author SHA1 Message Date
Psychotoxical 4c2a5b8853 fix(orbit): re-enable proportional speed correction for live testing
Flip SPEED_CORRECTION_ENABLED back to true so the proportional drift
controller drives the rate again. The disabled fallback (hold 1.0× +
manual Catch-Up button) stays one line away behind the same flag for a
quick switch-back if the pitch-preserving rate changes are audible again.

Restore the proportional planner tests that the disable commit had
rewritten for the held-rate path.
2026-06-23 11:12:21 +02:00
Psychotoxical 0286b9b161 fix(orbit): disable audible speed nudging, keep manual catch-up
Every pitch-preserving rate change was audible in practice (tempo wobble /
DSP distortion), and it only kicked in once a catch-up had pulled the drift
into the correction band — so a catch-up made playback worse until the next
track change. The host's sync at track change plus the manual Catch-Up button
keep the guest aligned well enough without it.

The loop now holds 1.0× and only surfaces the Catch-Up button past a moderate
drift; the proportional controller is kept behind SPEED_CORRECTION_ENABLED for
a future revisit if the preserve-pitch DSP improves.
2026-06-23 03:24:52 +02:00
Psychotoxical 61947ee5a7 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).
2026-06-23 02:30:33 +02:00
Psychotoxical f66ad3b578 fix(orbit): proportional drift correction to stop the bang-bang oscillation
Bang-bang drove the full ±10% until almost caught up, then overshot (the
~3.5 s smoothing/measurement latency) and reversed — it could never lock on.
Now the target rate is proportional to the smoothed drift: gentle near synced,
up to the cap when far, so it converges asymptotically instead of overshooting.
The live rate ramps gradually toward that target (stable now that the backend
restamp keeps speed changes position-stable). Settle is gone — it was only
needed against the old restamp jumps. Smoothing, the manual-catch-up button,
and no-auto-seek stay.
2026-06-23 02:12:18 +02:00
Psychotoxical 8dfe5f9094 fix(orbit): drop auto-seek, surface manual catch-up when drift is uncorrectable
A hard seek mid-track causes an audible dropout and chases the coarse host
position, so the drift loop no longer auto-seeks. When the smoothed drift is
too large to nudge softly it holds 1.0x and sets status 'seek'; the Orbit bar
shows the manual Catch-Up button exactly then (replacing the raw-drift
threshold that flickered on the noisy signal). The host stays the only driver;
the guest takes the jump only on the user's click.
2026-06-23 01:54:33 +02:00
Psychotoxical 42a84932e6 fix(audio): restamp playback position across active/neutral rate toggles
The sample counter maps to song position via a factor of `speed` while the
preserve-pitch effect is active and 1.0 while neutral. A change that flips
active<->neutral (enable/disable, or speed crossing 1.0x) therefore needs the
counter restamped to hold the position — but the condition only fired for
active->active speed changes. So every enable/disable toggle reinterpreted the
counter under the new factor and jumped the position (~raw_secs x dspeed —
e.g. +-18 s at 180 s on a +-10% toggle).

This broke Orbit drift correction, whose bang-bang nudges toggle the effect
on and off with the full +-10% delta. Extract the decision into
`rate_change_needs_restamp` and fire it on any active<->neutral transition (and
active->active speed changes), always re-deriving the counter for the new
config. Tests cover the condition and the position-stable invariant.
2026-06-23 01:54:26 +02:00
Psychotoxical 0d83a6e957 fix(orbit): smooth drift signal and switch to bang-bang correction
The test round showed the v2 ramp didn't converge: the raw drift swings
~1500 ms tick-to-tick (host position lands in coarse ~5 s quanta, and each
rate change perturbs the measured position), so the fine controller chased
its own tail — and the per-step speed switches caused audio artifacts.

- Median-smooth the raw drift before the controller sees it; spikes that
  used to trigger phantom seeks are rejected.
- Bang-bang correction: jump straight to the +-10% cap, hold until caught
  up, then back to 1.0x — two speed switches per cycle, not twenty.
- Settle for a few ticks after every speed change / seek so a correction
  can't read back its own perturbation (the feedback loop that oscillated).
- Seek only on sustained smoothed drift; deadband widened to 1.5 s to match
  the coarse host position cadence. CSV trace now carries raw + smoothed.
2026-06-23 00:49:58 +02:00
Psychotoxical d6a03532b2 feat(orbit): make guests hot-cache capable by mirroring the host queue
A guest loaded every track as a single-item queue, so the hot-cache
prefetcher and crossfade preload (which read queueItems[queueIndex + 1])
never had a next track to warm — every AutoDJ/crossfade switch cold-fetched
over HTTP, stalling the transition and drifting the guest off the host.

- Mirror the host's upcoming queue into the guest's local (invisible)
  playerStore queue as preload-only fodder. Race-free (only once the guest
  is on the host track) and idempotent (writes only on change).
- Guard runNext so a guest never auto-advances through that mirrored queue —
  the host stays the sole driver; at track end the guest waits for the host
  sync. The guest UI keeps rendering state.playQueue, not queueItems.
2026-06-22 23:57:04 +02:00
Psychotoxical 2fd99c7097 feat(orbit): add drift-trace logging and live diagnostics
Observability for the drift correction so we can tell by data whether it's
working, per the test-round request:
- New dense per-tick trace (driftTrace.ts) in its own ring buffer, kept
  separate from the 200-entry event log so 500 ms sampling can't bury the
  readable transitions. Copyable as CSV from the diagnostics popover for
  straight paste into a plot (drift vs rate over time).
- The guest loop samples every tick (drift, rate, target, action, track
  remainder, host/guest position); no re-render, no IPC.
- Diagnostics popover now ticks at 500 ms so the live rate reads in
  near-real-time, plus the CSV copy button.
- Manual catch-up clicks are logged into the same stream so user-driven
  seeks are distinguishable from automatic ones.
2026-06-22 22:54:32 +02:00
Psychotoxical 8ec32d7057 fix(orbit): stop redundant rate IPC while paused and reset status on leave
Self-review follow-ups on the drift correction loop:
- resetRate is now idempotent — a paused or diverged guest hit an abort
  guard every 500 ms tick and re-sent a rate IPC each time; now it no-ops
  once already neutral.
- The loop cleanup now resets the diagnostics status snapshot too, so a
  stale rate/status can't flash in the popover on the next session.
- Drop a dead seek-status assignment, a redundant no-op, and collapse the
  duplicated reset branch.
2026-06-22 21:59:04 +02:00
Psychotoxical 38843103b5 feat(orbit): surface drift correction in Orbit diagnostics
Show the live correction rate and status (hold / soft / seek / blend) in
the guest section of the diagnostics popover, alongside the existing drift
readout. Labels translated across all 11 locales; values stay raw like the
neighbouring role / track-id rows for copy-paste into bug reports.
2026-06-22 20:54:20 +02:00
Psychotoxical 688aa5dff6 feat(orbit): drive guest playback rate from drift correction
Guest-side 500 ms loop that plans the correction each tick and steps the
engine rate one 1% increment toward the target, through an Orbit drift-rate
carve-out that is independent of the user's (still suppressed) playback-rate
preference. Resets to 1.0x on track change, seek, pause, blend guard and
leave. Crossfade / AutoDJ smooth-skip near the track end hold at 1.0x so a
nudge can't disturb the blend. Mounted from useOrbitGuest.
2026-06-22 20:54:13 +02:00
Psychotoxical 90b7851785 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).
2026-06-22 20:54:04 +02:00
37 changed files with 1537 additions and 80 deletions
@@ -188,14 +188,13 @@ pub fn audio_set_playback_rate(
state: State<'_, AudioEngine>,
) {
use crate::playback_rate::{
content_position_from_samples, is_effect_active, raw_counter_samples_for_content_position,
uses_preserve_dsp, STRATEGY_PRESERVE_PITCH, STRATEGY_SPEED_CORRECTED,
STRATEGY_VARISPEED,
content_position_from_samples, is_effect_active, rate_change_needs_restamp,
raw_counter_samples_for_content_position, STRATEGY_PRESERVE_PITCH,
STRATEGY_SPEED_CORRECTED, STRATEGY_VARISPEED,
};
let clamped_speed = speed.clamp(0.5, 2.0);
let clamped_pitch = pitch_semitones.clamp(-12.0, 12.0);
let old_enabled = state.playback_rate.enabled.load(Ordering::Relaxed);
let old_strat = state.playback_rate.load_strategy();
let old_speed = state.playback_rate.load_speed();
let was_active = is_effect_active(&state.playback_rate);
@@ -206,24 +205,37 @@ pub fn audio_set_playback_rate(
};
let speed_changed = (clamped_speed - old_speed).abs() > 0.001;
let restamp_content = if was_active
&& enabled == old_enabled
&& uses_preserve_dsp(old_strat)
&& new_strat == old_strat
&& speed_changed
// Will the *new* config leave the rate effect active?
let new_active = enabled
&& match new_strat {
STRATEGY_PRESERVE_PITCH => {
(clamped_speed - 1.0).abs() > 0.001 || clamped_pitch.abs() > 0.001
}
_ => (clamped_speed - 1.0).abs() > 0.001,
};
// Preserve the content (song) position across any change to the
// sample-counter ↔ position mapping: an active↔neutral toggle (enable /
// disable, or speed crossing 1.0×) OR a speed change while active. Scoped to
// the preserve-pitch DSP family and same strategy — varispeed has no
// content/raw factor, and a strategy switch is out of scope here.
//
// The old condition only restamped active→active, so every enable/disable
// toggle reinterpreted `samples_played` under the new factor and jumped the
// position (≈ raw_secs × Δspeed — e.g. ±18 s at the 180 s mark on a ±10%
// toggle; this is what broke Orbit drift correction).
let sample_rate = state.current_sample_rate.load(Ordering::Relaxed);
let channels = state.current_channels.load(Ordering::Relaxed);
let restamp_content = if sample_rate > 0
&& channels > 0
&& rate_change_needs_restamp(old_strat, new_strat, was_active, new_active, speed_changed)
{
let sample_rate = state.current_sample_rate.load(Ordering::Relaxed);
let channels = state.current_channels.load(Ordering::Relaxed);
if sample_rate > 0 && channels > 0 {
Some(content_position_from_samples(
state.samples_played.load(Ordering::Relaxed),
sample_rate,
channels,
&state.playback_rate,
))
} else {
None
}
Some(content_position_from_samples(
state.samples_played.load(Ordering::Relaxed),
sample_rate,
channels,
&state.playback_rate,
))
} else {
None
};
@@ -246,19 +258,19 @@ pub fn audio_set_playback_rate(
.store(clamped_pitch.to_bits(), Ordering::Relaxed);
if let Some(content_secs) = restamp_content {
if is_effect_active(&state.playback_rate) {
let sample_rate = state.current_sample_rate.load(Ordering::Relaxed);
let channels = state.current_channels.load(Ordering::Relaxed);
state.samples_played.store(
raw_counter_samples_for_content_position(
content_secs,
sample_rate,
channels,
&state.playback_rate,
),
Ordering::Relaxed,
);
}
// Always re-derive the counter for the NEW config — including the
// neutral case (raw_counter_… maps content == raw there), which is
// exactly the active↔neutral transition the old is_effect_active gate
// skipped.
state.samples_played.store(
raw_counter_samples_for_content_position(
content_secs,
sample_rate,
channels,
&state.playback_rate,
),
Ordering::Relaxed,
);
}
}
@@ -82,6 +82,27 @@ pub fn is_effect_active(atomics: &PlaybackRateAtomics) -> bool {
}
}
/// Whether a playback-rate config change must restamp the sample counter to keep
/// the content (song) position stable.
///
/// The counter ↔ position factor is `speed` while the preserve-pitch effect is
/// active and `1.0` while neutral (see [`effective_position_secs`]). So any
/// transition that flips active↔neutral, or changes speed while staying active,
/// changes that factor and needs a restamp. Scoped to the preserve-pitch DSP
/// family with an unchanged strategy: varispeed has no content/raw factor, and a
/// strategy switch is handled elsewhere.
pub(crate) fn rate_change_needs_restamp(
old_strategy: u32,
new_strategy: u32,
was_active: bool,
now_active: bool,
speed_changed: bool,
) -> bool {
uses_preserve_dsp(old_strategy)
&& new_strategy == old_strategy
&& (was_active != now_active || (was_active && now_active && speed_changed))
}
/// True when preserve-pitch DSP (background worker) should run for this track.
pub(crate) fn preserve_pitch_will_run(atomics: &PlaybackRateAtomics) -> bool {
atomics.enabled.load(Ordering::Relaxed)
@@ -659,4 +680,55 @@ mod tests {
let after = content_position_from_samples(restamped, 44_100, 2, &atomics);
assert!((after - 30.0).abs() < 0.05);
}
#[test]
fn rate_change_needs_restamp_covers_active_neutral_toggles() {
let sc = STRATEGY_SPEED_CORRECTED;
// Both directions of an active↔neutral toggle need a restamp.
assert!(rate_change_needs_restamp(sc, sc, false, true, true));
assert!(rate_change_needs_restamp(sc, sc, true, false, true));
// Active→active with a speed change needs one too.
assert!(rate_change_needs_restamp(sc, sc, true, true, true));
// Active→active with no speed change, and neutral→neutral, do not.
assert!(!rate_change_needs_restamp(sc, sc, true, true, false));
assert!(!rate_change_needs_restamp(sc, sc, false, false, false));
}
#[test]
fn rate_change_needs_restamp_skips_varispeed_and_strategy_switch() {
let sc = STRATEGY_SPEED_CORRECTED;
let vs = STRATEGY_VARISPEED;
// Varispeed has no content/raw factor → never restamp.
assert!(!rate_change_needs_restamp(vs, vs, false, true, true));
// A strategy switch is out of scope for the restamp path.
assert!(!rate_change_needs_restamp(sc, vs, true, true, true));
}
#[test]
fn restamp_keeps_position_across_active_neutral_toggle() {
// The bug: toggling the effect on/off must not move the song position.
// Start active at 1.10×, sitting at 180 s of content.
let a = PlaybackRateAtomics::new();
a.enabled.store(true, Ordering::Relaxed);
a.strategy.store(STRATEGY_SPEED_CORRECTED, Ordering::Relaxed);
a.speed.store(1.10f32.to_bits(), Ordering::Relaxed);
let samples = raw_counter_samples_for_content_position(180.0, 44_100, 2, &a);
assert!((content_position_from_samples(samples, 44_100, 2, &a) - 180.0).abs() < 0.05);
// Toggle to neutral (disabled). Without a restamp the position would
// jump ~18 s (180 × 0.10); with it, the position is preserved.
let old_content = content_position_from_samples(samples, 44_100, 2, &a);
a.enabled.store(false, Ordering::Relaxed);
let restamped = raw_counter_samples_for_content_position(old_content, 44_100, 2, &a);
let after = content_position_from_samples(restamped, 44_100, 2, &a);
assert!((after - 180.0).abs() < 0.05, "position jumped to {after}");
// Back to active at 0.90× — still stable.
let old_content2 = content_position_from_samples(restamped, 44_100, 2, &a);
a.enabled.store(true, Ordering::Relaxed);
a.speed.store(0.90f32.to_bits(), Ordering::Relaxed);
let restamped2 = raw_counter_samples_for_content_position(old_content2, 44_100, 2, &a);
let after2 = content_position_from_samples(restamped2, 44_100, 2, &a);
assert!((after2 - 180.0).abs() < 0.05, "position jumped to {after2}");
}
}
+52 -4
View File
@@ -1,11 +1,17 @@
import { useEffect, useRef, useState, useSyncExternalStore } from 'react';
import { createPortal } from 'react-dom';
import { Copy, Trash2 } from 'lucide-react';
import { Activity, Copy, Trash2 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useOrbitStore } from '../store/orbitStore';
import { usePlayerStore } from '../store/playerStore';
import { showToast } from '../utils/ui/toast';
import { computeOrbitDriftMs } from '../utils/orbit';
import {
clearDriftTrace,
computeOrbitDriftMs,
driftTraceCount,
formatDriftTraceCsv,
getOrbitDriftStatus,
} from '../utils/orbit';
import {
clearOrbitEvents,
formatOrbitEvents,
@@ -36,10 +42,11 @@ export default function OrbitDiagnosticsPopover({ anchorRef, onClose }: Props) {
const events = useSyncExternalStore(subscribeOrbitEvents, getOrbitEvents, getOrbitEvents);
const formatted = formatOrbitEvents(events);
// Tick the mini-display once a second so drift / position read fresh.
// Tick the mini-display at the drift loop's cadence (500 ms) so the live
// correction rate / drift read in near-real-time, not once a second.
const [nowMs, setNowMs] = useState(() => Date.now());
useEffect(() => {
const id = window.setInterval(() => setNowMs(Date.now()), 1000);
const id = window.setInterval(() => setNowMs(Date.now()), 500);
return () => window.clearInterval(id);
}, []);
@@ -86,6 +93,14 @@ export default function OrbitDiagnosticsPopover({ anchorRef, onClose }: Props) {
const driftMs = sameTrack && state ? computeOrbitDriftMs(state, localPosMs, nowMs) : null;
const hostStateAgeMs = state ? Math.max(0, nowMs - state.positionAt) : null;
// Live drift-correction status — re-read each render; the 1 s nowMs tick above
// already repaints this popover, so the snapshot stays fresh without a subscribe.
const dc = getOrbitDriftStatus();
const dcRateText = dc.action === 'idle' ? '—' : `${dc.currentRate.toFixed(2)}×`;
const dcStatusText = dc.smoothedDriftMs != null
? `${dc.action} · ${(dc.smoothedDriftMs / 1000).toFixed(1)}s`
: dc.action;
const hostPosSec = state ? Math.round(((state.positionMs ?? 0) + (state.isPlaying ? (nowMs - state.positionAt) : 0)) / 1000) : null;
const guestPosSec = Math.round((player.currentTime ?? 0));
@@ -99,8 +114,23 @@ export default function OrbitDiagnosticsPopover({ anchorRef, onClose }: Props) {
}
};
const handleCopyTrace = async () => {
const csv = formatDriftTraceCsv();
if (!csv) {
showToast(t('orbit.diag.traceEmpty'), 2500, 'info');
return;
}
try {
await navigator.clipboard.writeText(csv);
showToast(t('orbit.diag.traceCopied', { count: driftTraceCount() }), 2500, 'info');
} catch {
showToast(t('orbit.diag.copyFailed'), 4000, 'error');
}
};
const handleClear = () => {
clearOrbitEvents();
clearDriftTrace();
showToast(t('orbit.diag.cleared'), 2000, 'info');
};
@@ -135,6 +165,14 @@ export default function OrbitDiagnosticsPopover({ anchorRef, onClose }: Props) {
<span className="orbit-diag-pop__live-label">{t('orbit.diag.drift')}</span>
<span>{driftMs != null ? `${(driftMs / 1000).toFixed(1)}s` : '—'}</span>
</div>
<div className="orbit-diag-pop__live-row">
<span className="orbit-diag-pop__live-label">{t('orbit.diag.driftRate')}</span>
<span>{dcRateText}</span>
</div>
<div className="orbit-diag-pop__live-row">
<span className="orbit-diag-pop__live-label">{t('orbit.diag.driftStatus')}</span>
<span className="orbit-diag-pop__mono">{dcStatusText}</span>
</div>
</>
)}
{hostStateAgeMs != null && (
@@ -158,6 +196,16 @@ export default function OrbitDiagnosticsPopover({ anchorRef, onClose }: Props) {
<Copy size={13} />
<span>{t('orbit.diag.copyLabel')}</span>
</button>
<button
type="button"
className="orbit-diag-pop__btn"
onClick={handleCopyTrace}
data-tooltip={t('orbit.diag.traceTooltip')}
aria-label={t('orbit.diag.traceTooltip')}
>
<Activity size={13} />
<span>{t('orbit.diag.traceLabel')}</span>
</button>
<button
type="button"
className="orbit-diag-pop__btn"
+14 -36
View File
@@ -9,10 +9,11 @@ import { usePlayerStore } from '../store/playerStore';
import {
endOrbitSession,
leaveOrbitSession,
computeOrbitDriftMs,
getOrbitDriftStatus,
effectiveShuffleIntervalMs,
} from '../utils/orbit';
import { estimateLivePosition } from '../api/orbit';
import { pushOrbitEvent } from '../utils/orbitDiag';
import OrbitParticipantsPopover from './OrbitParticipantsPopover';
import OrbitExitModal from './OrbitExitModal';
import OrbitSettingsPopover from './OrbitSettingsPopover';
@@ -33,8 +34,6 @@ import { formatTrackTime } from '../utils/format/formatDuration';
* reshaping the layout.
*/
const CATCH_UP_DRIFT_THRESHOLD_MS = 3_000;
/** `m:ss` countdown from a millisecond value. */
function formatCountdown(ms: number): string {
return formatTrackTime(Math.round(ms / 1000));
@@ -66,51 +65,28 @@ export default function OrbitSessionBar() {
return () => window.clearInterval(id);
}, [state, phase]);
// ── Catch Up button visibility — debounced + hysteresis ───────────────
// The raw drift signal is noisy: guest's `currentTime` updates in coarse
// ~5 s chunks while host's position is extrapolated linearly via
// `(nowMs - posAt)`, so the diff swings between ~1 s and ~8 s every
// tick on a normal session even when both sides are perfectly synced.
// Two-stage filter:
// - **Hidden → shown**: drift must stay over the show-threshold (3 s)
// for 3 s of wall-clock. Filters out brief over-threshold blips.
// - **Shown → hidden**: drift must stay under the hide-threshold
// (1 s) for 1 s of wall-clock. Once visible, the button persists
// through the 13 s "drift back to small" valleys that come from
// guest's currentTime catching up in chunks; otherwise the button
// would vanish too fast to actually click on a high-latency
// session where genuine drift fluctuates around 58 s.
const SHOW_THRESHOLD_MS = CATCH_UP_DRIFT_THRESHOLD_MS;
const HIDE_THRESHOLD_MS = 1_000;
// ── Catch Up button visibility — debounced ────────────────────────────
// Driven by the automatic drift correction, not the raw drift: the loop
// surfaces status 'seek' only when the smoothed drift is too large to nudge
// softly (it handles everything smaller silently). So the manual button
// appears exactly when auto-correction has given up — what cucadmuh asked
// for. Debounced so it persists long enough to click and doesn't flicker.
const SHOW_DEBOUNCE_MS = 3_000;
const HIDE_DEBOUNCE_MS = 1_000;
const [showCatchUp, setShowCatchUp] = useState(false);
const overSinceRef = useRef<number | null>(null);
const underSinceRef = useRef<number | null>(null);
useEffect(() => {
// Note: `state.isPlaying` is *not* a gate. A guest who joined while
// the host was paused still benefits from Catch Up if their sync to
// the host's paused position failed — the only signal that matters
// is "is there drift between us and the host's last reported state".
// `computeOrbitDriftMs` correctly stops time-extrapolation when the
// host is paused, so the formula holds in both states.
if (role !== 'guest' || !state || !state.currentTrack) {
overSinceRef.current = null;
underSinceRef.current = null;
setShowCatchUp(false);
return;
}
const player = usePlayerStore.getState();
const localPositionMs = Math.round((player.currentTime ?? 0) * 1000);
const driftMs = player.currentTrack?.id === state.currentTrack.trackId
? computeOrbitDriftMs(state, localPositionMs, nowMs)
: null;
const absDrift = driftMs == null ? Infinity : Math.abs(driftMs);
const wantShow = getOrbitDriftStatus().action === 'seek';
if (showCatchUp) {
// Currently visible — only hide once drift has been clearly small
// for the full hide-debounce window.
overSinceRef.current = null;
if (absDrift < HIDE_THRESHOLD_MS) {
if (!wantShow) {
if (underSinceRef.current === null) underSinceRef.current = Date.now();
if (Date.now() - underSinceRef.current >= HIDE_DEBOUNCE_MS) {
setShowCatchUp(false);
@@ -120,9 +96,8 @@ export default function OrbitSessionBar() {
underSinceRef.current = null;
}
} else {
// Currently hidden — only show after sustained over-threshold drift.
underSinceRef.current = null;
if (absDrift > SHOW_THRESHOLD_MS) {
if (wantShow) {
if (overSinceRef.current === null) overSinceRef.current = Date.now();
if (Date.now() - overSinceRef.current >= SHOW_DEBOUNCE_MS) {
setShowCatchUp(true);
@@ -172,6 +147,9 @@ export default function OrbitSessionBar() {
if (!state.currentTrack) return;
const trackId = state.currentTrack.trackId;
const targetMs = estimateLivePosition(state, Date.now());
// Mark manual catch-ups in the same log stream as the auto correction, so
// the trace can tell a user-driven seek apart from an automatic one.
pushOrbitEvent('drift-correction', `manual catch-up → seeking to host @ ${Math.round(targetMs / 1000)}s`);
const targetSec = Math.max(0, targetMs / 1000);
const hostPlaying = state.isPlaying;
try {
+12
View File
@@ -13,12 +13,14 @@ import {
planPendingResends,
forgetPendingSuggestion,
resetPendingResendState,
syncGuestPreloadQueue,
} from '../utils/orbit';
import { showToast } from '../utils/ui/toast';
import i18n from '../i18n';
import { estimateLivePosition, type OrbitState } from '../api/orbit';
import { pushOrbitEvent } from '../utils/orbitDiag';
import { useOrbitOutboxHeartbeat } from './useOrbitOutboxHeartbeat';
import { useOrbitGuestDriftCorrection } from './useOrbitGuestDriftCorrection';
/**
* Orbit — guest-side tick hook.
@@ -380,6 +382,12 @@ export function useOrbitGuest(): void {
// flip every tick.
lastAppliedRef.current = { trackId: currentLast.trackId, isPlaying: hostPlaying };
}
// Mirror the host's queue into the local (invisible) playerStore queue so
// the hot-cache prefetcher and crossfade preload have an upcoming track to
// warm. No-op until the guest is actually on the host's track; never
// drives playback — runNext guards a guest's auto-advance.
syncGuestPreloadQueue(state);
};
// Self-scheduling tick: fast-poll (500 ms) while we haven't locked in an
@@ -407,4 +415,8 @@ export function useOrbitGuest(): void {
// Outbox heartbeat — shared with the host hook; the guest's outbox is keyed
// by its own active-server username.
useOrbitOutboxHeartbeat(active, outboxPlaylistId, sessionId, myName);
// Smooth drift correction — pitch-preserving ≤ ±10% nudge toward the host's
// live position instead of hard seeks on every intra-track wobble.
useOrbitGuestDriftCorrection(active);
}
+164
View File
@@ -0,0 +1,164 @@
import { useEffect } from 'react';
import { useOrbitStore } from '../store/orbitStore';
import { usePlayerStore } from '../store/playerStore';
import { useAuthStore } from '../store/authStore';
import { estimateLivePosition } from '../api/orbit';
import {
computeOrbitDriftMs,
planOrbitDriftCorrection,
stepRateToward,
applyOrbitDriftRate,
resetOrbitDriftRate,
setOrbitDriftStatus,
resetOrbitDriftStatus,
pushDriftSample,
makeDriftSmoother,
ORBIT_DRIFT_LOOP_TICK_MS,
ORBIT_DRIFT_SMOOTH_WINDOW,
ORBIT_DRIFT_SMOOTH_MIN_SAMPLES,
} from '../utils/orbit';
import { clampCrossfadeSecs } from '../utils/playback/autodjAutoAdvance';
import { pushOrbitEvent } from '../utils/orbitDiag';
/**
* Orbit — guest-side drift correction (v4: proportional + smooth ramp).
*
* Once per `LOOP_TICK_MS`, while we're an active guest playing the host's track,
* nudge our playback rate toward the host. The raw drift is median-smoothed
* (host position lands in coarse ~5 s quanta). A proportional planner picks a
* target rate — gentle near synced, up to the ±10% cap when far — and the loop
* ramps the live rate one step per tick toward it. Proportional + gradual
* converges without the bang-bang overshoot ("can't lock on"); the speed
* changes are stable now that the backend restamp is fixed.
*
* No auto-seek: past a hard threshold the loop sets status 'seek' and the Orbit
* bar shows the manual Catch-Up button. Mounted from `useOrbitGuest`.
*/
export function useOrbitGuestDriftCorrection(active: boolean): void {
useEffect(() => {
if (!active) return;
let cancelled = false;
let timer: number | null = null;
let currentRate = 1.0;
let lastAction: string | null = null;
const smoother = makeDriftSmoother(ORBIT_DRIFT_SMOOTH_WINDOW, ORBIT_DRIFT_SMOOTH_MIN_SAMPLES);
const note = (action: string, detail: string) => {
if (action !== lastAction) {
pushOrbitEvent('drift-correction', `${action}: ${detail}`);
lastAction = action;
}
};
/** Abort to neutral (pause / track change / teardown). */
const resetToNeutral = (reason: string) => {
if (currentRate === 1.0 && lastAction === null) return;
note('reset', reason);
lastAction = null;
currentRate = 1.0;
smoother.reset();
resetOrbitDriftRate();
resetOrbitDriftStatus();
};
/** Ramp the live rate one step toward `target` and push it to the engine. */
const rampTo = (target: number) => {
currentRate = stepRateToward(currentRate, target);
applyOrbitDriftRate(currentRate);
};
const step = () => {
const state = useOrbitStore.getState().state;
const player = usePlayerStore.getState();
// ── Abort guards → neutral ──
if (!state?.currentTrack || !player.currentTrack) { resetToNeutral('no track'); return; }
const hostTrackId = state.currentTrack.trackId;
if (player.currentTrack.id !== hostTrackId) { resetToNeutral('different track'); return; }
if (!player.isPlaying || !state.isPlaying) { resetToNeutral('paused'); return; }
const now = Date.now();
const durationSec = player.currentTrack.duration;
const trackDurationMs = durationSec * 1000;
const hostPositionMs = estimateLivePosition(state, now);
const tTrackRemSec = (trackDurationMs - hostPositionMs) / 1000;
// ── Blend guard ──
// Ramp to 1.0× through a crossfade / AutoDJ smooth-skip blend near the
// track end. Gapless has no overlap, so no guard.
const a = useAuthStore.getState();
let blendGuardSec = 0;
if (a.crossfadeEnabled) blendGuardSec = clampCrossfadeSecs(a.crossfadeSecs);
if (a.autodjSmoothSkip) blendGuardSec = Math.max(blendGuardSec, 2);
if (blendGuardSec > 0) blendGuardSec += 2;
if (blendGuardSec > 0 && tTrackRemSec <= blendGuardSec) {
rampTo(1.0);
setOrbitDriftStatus({ action: 'blend', currentRate, smoothedDriftMs: smoother.value() });
note('blend-guard', `ramping to 1.0× for blend, ${tTrackRemSec.toFixed(1)}s left`);
return;
}
const guestPosMs = (player.currentTime ?? 0) * 1000;
const rawDrift = computeOrbitDriftMs(state, guestPosMs, now);
smoother.push(rawDrift);
const smoothed = smoother.value();
pushDriftSample({
ts: now,
driftMs: rawDrift,
smoothedMs: smoothed,
rate: currentRate,
action: lastAction ?? 'idle',
trackRemSec: tTrackRemSec,
hostPosMs: hostPositionMs,
guestPosMs,
});
// Window not full yet → ramp to neutral and wait for a stable reading.
if (smoothed === null) {
rampTo(1.0);
setOrbitDriftStatus({ action: 'hold', currentRate, smoothedDriftMs: null });
return;
}
const plan = planOrbitDriftCorrection({ driftMs: smoothed, hostIsPlaying: state.isPlaying });
if (plan.action === 'seek') {
// Too far for a soft nudge — ramp to 1.0× and surface the manual
// Catch-Up button (OrbitSessionBar reads this 'seek' status). Never
// auto-seek; the host stays the driver, the user takes the jump.
rampTo(1.0);
setOrbitDriftStatus({ action: 'seek', currentRate, smoothedDriftMs: smoothed });
note('giveup', `smoothed drift ${Math.round(smoothed)}ms — too far, offering manual catch-up`);
return;
}
if (plan.action === 'correct') {
rampTo(plan.targetRate);
setOrbitDriftStatus({ action: 'correct', currentRate, smoothedDriftMs: smoothed });
note('correct', `smoothed drift ${Math.round(smoothed)}ms → target ${plan.targetRate.toFixed(2)}×`);
} else {
rampTo(1.0);
setOrbitDriftStatus({ action: 'hold', currentRate, smoothedDriftMs: smoothed });
note('hold', `smoothed drift ${Math.round(smoothed)}ms within band`);
}
};
const tick = () => {
timer = null;
if (cancelled) return;
try { step(); } catch { /* best-effort; retry next tick */ }
if (!cancelled) timer = window.setTimeout(tick, ORBIT_DRIFT_LOOP_TICK_MS);
};
void tick();
return () => {
cancelled = true;
if (timer !== null) window.clearTimeout(timer);
resetOrbitDriftRate();
resetOrbitDriftStatus();
};
}, [active]);
}
+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,
+6
View File
@@ -63,12 +63,18 @@ export const orbit = {
guestTrack: 'Meine Track-ID',
guestPos: 'Meine Position',
drift: 'Versatz',
driftRate: 'Korrekturtempo',
driftStatus: 'Korrektur',
stateAge: 'Zustand-Alter',
eventLog: 'Ereignis-Log ({{count}})',
copyLabel: 'Kopieren',
copyTooltip: 'Log in Zwischenablage kopieren',
clearLabel: 'Leeren',
clearTooltip: 'Puffer leeren',
traceLabel: 'CSV',
traceTooltip: 'Drift-Verlauf als CSV kopieren',
traceCopied: '{{count}} Verlaufspunkte kopiert',
traceEmpty: 'Noch keine Drift-Daten',
empty: 'Noch keine Ereignisse — sie erscheinen sobald Orbit synchronisiert.',
hint: 'Vor dem Reproduzieren des Problems öffnen, dann Kopieren klicken und in den Bug-Report einfügen.',
copied: '{{count}} Zeilen kopiert',
+6
View File
@@ -63,12 +63,18 @@ export const orbit = {
guestTrack: 'My track id',
guestPos: 'My position',
drift: 'Drift',
driftRate: 'Correction rate',
driftStatus: 'Correction',
stateAge: 'State age',
eventLog: 'Event log ({{count}})',
copyLabel: 'Copy',
copyTooltip: 'Copy log to clipboard',
clearLabel: 'Clear',
clearTooltip: 'Wipe the buffer',
traceLabel: 'CSV',
traceTooltip: 'Copy drift trace as CSV',
traceCopied: 'Copied {{count}} trace samples',
traceEmpty: 'No drift samples yet',
empty: 'No events yet — events appear as Orbit syncs.',
hint: 'Open this before reproducing a problem, then click Copy and paste into the bug report.',
copied: 'Copied {{count}} event lines',
+6
View File
@@ -63,12 +63,18 @@ export const orbit = {
guestTrack: 'Mi ID de pista',
guestPos: 'Mi posición',
drift: 'Desfase',
driftRate: 'Velocidad de corrección',
driftStatus: 'Corrección',
stateAge: 'Edad del estado',
eventLog: 'Registro de eventos ({{count}})',
copyLabel: 'Copiar',
copyTooltip: 'Copiar registro al portapapeles',
clearLabel: 'Borrar',
clearTooltip: 'Vaciar el búfer',
traceLabel: 'CSV',
traceTooltip: 'Copiar el registro de desfase como CSV',
traceCopied: '{{count}} muestras copiadas',
traceEmpty: 'Aún sin datos de desfase',
empty: 'Aún sin eventos — aparecen cuando Orbit sincroniza.',
hint: 'Abre esto antes de reproducir el problema, luego pulsa Copiar y pega en el reporte.',
copied: '{{count}} líneas copiadas',
+6
View File
@@ -63,12 +63,18 @@ export const orbit = {
guestTrack: 'Mon ID de piste',
guestPos: 'Ma position',
drift: 'Décalage',
driftRate: 'Vitesse de correction',
driftStatus: 'Correction',
stateAge: 'Âge de l\'état',
eventLog: 'Journal d\'événements ({{count}})',
copyLabel: 'Copier',
copyTooltip: 'Copier le journal dans le presse-papiers',
clearLabel: 'Effacer',
clearTooltip: 'Vider le tampon',
traceLabel: 'CSV',
traceTooltip: 'Copier le tracé de dérive en CSV',
traceCopied: '{{count}} points de tracé copiés',
traceEmpty: 'Aucune donnée de dérive',
empty: 'Aucun événement — ils apparaissent dès qu\'Orbit synchronise.',
hint: 'Ouvre ceci avant de reproduire le problème, puis clique Copier et colle dans le rapport.',
copied: '{{count}} lignes copiées',
+6
View File
@@ -63,12 +63,18 @@ export const orbit = {
guestTrack: 'Saját szám-azonosító',
guestPos: 'Saját pozíció',
drift: 'Eltérés',
driftRate: 'Korrekciós ütem',
driftStatus: 'Korrekció',
stateAge: 'Állapot kora',
eventLog: 'Eseménynapló ({{count}})',
copyLabel: 'Másolás',
copyTooltip: 'Napló másolása a vágólapra',
clearLabel: 'Törlés',
clearTooltip: 'Puffer kiürítése',
traceLabel: 'CSV',
traceTooltip: 'Eltérés-napló másolása CSV-ként',
traceCopied: '{{count}} mintapont másolva',
traceEmpty: 'Még nincs eltérési adat',
empty: 'Még nincsenek események — az események akkor jelennek meg, ahogy az Orbit szinkronizál.',
hint: 'Nyisd meg ezt egy probléma reprodukálása előtt, majd kattints a Másolásra, és illeszd be a hibajelentésbe.',
copied: '{{count}} eseménysor másolva',
+6
View File
@@ -63,12 +63,18 @@ export const orbit = {
guestTrack: '自分のトラック ID',
guestPos: '自分の位置',
drift: 'ずれ',
driftRate: '補正レート',
driftStatus: '補正',
stateAge: '状態の経過時間',
eventLog: 'イベントログ ({{count}})',
copyLabel: 'コピー',
copyTooltip: 'ログをクリップボードへコピー',
clearLabel: 'クリア',
clearTooltip: 'バッファを消去',
traceLabel: 'CSV',
traceTooltip: 'ドリフト推移を CSV でコピー',
traceCopied: '{{count}} 件のサンプルをコピーしました',
traceEmpty: 'まだドリフトデータがありません',
empty: 'まだイベントはありません。Orbit が同期するとイベントが表示されます。',
hint: '問題を再現する前にこれを開き、コピーをクリックしてバグ報告へ貼り付けてください。',
copied: '{{count}} 行のイベントをコピーしました',
+6
View File
@@ -63,12 +63,18 @@ export const orbit = {
guestTrack: 'Min spor-ID',
guestPos: 'Min posisjon',
drift: 'Avvik',
driftRate: 'Korrigeringsrate',
driftStatus: 'Korrigering',
stateAge: 'Tilstandens alder',
eventLog: 'Hendelseslogg ({{count}})',
copyLabel: 'Kopier',
copyTooltip: 'Kopier loggen til utklippstavlen',
clearLabel: 'Tøm',
clearTooltip: 'Tøm bufferen',
traceLabel: 'CSV',
traceTooltip: 'Kopier avviksforløp som CSV',
traceCopied: '{{count}} forløpspunkter kopiert',
traceEmpty: 'Ingen avviksdata ennå',
empty: 'Ingen hendelser ennå — de dukker opp når Orbit synkroniserer.',
hint: 'Åpne dette før du reproduserer problemet, klikk Kopier og lim inn i feilrapporten.',
copied: '{{count}} linjer kopiert',
+6
View File
@@ -63,12 +63,18 @@ export const orbit = {
guestTrack: 'Mijn track-ID',
guestPos: 'Mijn positie',
drift: 'Verschil',
driftRate: 'Correctiesnelheid',
driftStatus: 'Correctie',
stateAge: 'Leeftijd van staat',
eventLog: 'Gebeurtenissen-log ({{count}})',
copyLabel: 'Kopiëren',
copyTooltip: 'Log naar klembord kopiëren',
clearLabel: 'Wissen',
clearTooltip: 'Buffer leegmaken',
traceLabel: 'CSV',
traceTooltip: 'Driftverloop als CSV kopiëren',
traceCopied: '{{count}} verloopsamples gekopieerd',
traceEmpty: 'Nog geen driftdata',
empty: 'Nog geen gebeurtenissen — ze verschijnen zodra Orbit synchroniseert.',
hint: 'Open dit voor je het probleem reproduceert, klik dan Kopiëren en plak in de bugmelding.',
copied: '{{count}} regels gekopieerd',
+6
View File
@@ -63,12 +63,18 @@ export const orbit = {
guestTrack: 'Id-ul piesei mele',
guestPos: 'Id-ul poziției mele',
drift: 'Drift',
driftRate: 'Rată corecție',
driftStatus: 'Corecție',
stateAge: 'Status vârstă',
eventLog: 'Log-uri evenimente ({{count}})',
copyLabel: 'Copiază',
copyTooltip: 'Copiază log-urile în clipboard',
clearLabel: 'Golește',
clearTooltip: 'Șterge buffer-ul',
traceLabel: 'CSV',
traceTooltip: 'Copiază traseul de drift ca CSV',
traceCopied: 'S-au copiat {{count}} eșantioane',
traceEmpty: 'Încă niciun eșantion de drift',
empty: 'Niciun eveniment deocamdată — evenimentele apar ca sincronizare Orbit.',
hint: 'Deschide asta înainte de a reproduce o problemă, apoi apasă Copiază și lipește în raportul problemei.',
copied: 'S-au copiat {{count}} linii de eveniment',
+6
View File
@@ -63,12 +63,18 @@ export const orbit = {
guestTrack: 'Мой ID трека',
guestPos: 'Моя позиция',
drift: 'Расхождение',
driftRate: 'Скорость коррекции',
driftStatus: 'Коррекция',
stateAge: 'Возраст состояния',
eventLog: 'Журнал событий ({{count}})',
copyLabel: 'Копировать',
copyTooltip: 'Скопировать журнал в буфер обмена',
clearLabel: 'Очистить',
clearTooltip: 'Очистить буфер',
traceLabel: 'CSV',
traceTooltip: 'Скопировать трассу расхождения в CSV',
traceCopied: 'Скопировано точек: {{count}}',
traceEmpty: 'Пока нет данных о расхождении',
empty: 'Пока нет событий — они появятся когда Orbit начнёт синхронизацию.',
hint: 'Открой это перед воспроизведением проблемы, затем нажми Копировать и вставь в баг-репорт.',
copied: 'Скопировано строк: {{count}}',
+6
View File
@@ -63,12 +63,18 @@ export const orbit = {
guestTrack: '我的曲目 ID',
guestPos: '我的位置',
drift: '偏差',
driftRate: '校正速率',
driftStatus: '校正',
stateAge: '状态时长',
eventLog: '事件日志 ({{count}})',
copyLabel: '复制',
copyTooltip: '将日志复制到剪贴板',
clearLabel: '清除',
clearTooltip: '清空缓冲区',
traceLabel: 'CSV',
traceTooltip: '将漂移轨迹复制为 CSV',
traceCopied: '已复制 {{count}} 个采样点',
traceEmpty: '暂无漂移数据',
empty: '暂无事件 — Orbit 同步时会显示。',
hint: '在重现问题前打开此面板,然后点击复制并粘贴到错误报告中。',
copied: '已复制 {{count}} 行',
+7 -1
View File
@@ -9,7 +9,7 @@ import {
isInfiniteQueueFetching,
setInfiniteQueueFetching,
} from './infiniteQueueState';
import { isInOrbitSession } from './orbitSession';
import { isInOrbitSession, isOrbitGuestSession } from './orbitSession';
import type { PlayerState, QueueItemRef, Track } from './playerStoreTypes';
import { toQueueItemRefs } from '../utils/library/queueItemRef';
import { resolveQueueTrack } from '../utils/library/queueTrackView';
@@ -70,6 +70,12 @@ function appendTracksAndPlayFirst(set: SetState, get: GetState, fresh: Track[]):
* sync to the host's next track.
*/
export function runNext(set: SetState, get: GetState, manual: boolean): void {
// Orbit guests never advance on their own — the host drives every track
// change. The guest mirrors the host's queue locally only to warm the hot
// cache; at track end it waits for `syncToHost` to load the host's next
// track. Without this, the mirrored multi-track queue would let `next()`
// (track-end fallback, media keys) skip the guest off the host.
if (isOrbitGuestSession()) return;
const { queueItems, queueIndex, repeatMode, currentTrack } = get();
applySkipStarOnManualNext(currentTrack, manual);
const nextIdx = queueIndex + 1;
+26 -2
View File
@@ -1,15 +1,19 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
// Control points for the test.
const { inOrbit, getSimilarSongs2, getTopSongs } = vi.hoisted(() => ({
const { inOrbit, guestInOrbit, getSimilarSongs2, getTopSongs } = vi.hoisted(() => ({
inOrbit: { value: false },
guestInOrbit: { value: false },
getSimilarSongs2: vi.fn(() => Promise.resolve([])),
getTopSongs: vi.fn(() => Promise.resolve([])),
}));
vi.mock('../api/subsonicArtists', () => ({ getSimilarSongs2, getTopSongs }));
vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn(() => Promise.resolve()) }));
vi.mock('./orbitSession', () => ({ isInOrbitSession: () => inOrbit.value }));
vi.mock('./orbitSession', () => ({
isInOrbitSession: () => inOrbit.value,
isOrbitGuestSession: () => guestInOrbit.value,
}));
vi.mock('./authStore', () => ({
useAuthStore: { getState: () => ({ infiniteQueueEnabled: false }) },
}));
@@ -62,6 +66,7 @@ function fakeGet() {
beforeEach(() => {
inOrbit.value = false;
guestInOrbit.value = false;
getSimilarSongs2.mockClear();
getTopSongs.mockClear();
});
@@ -81,3 +86,22 @@ describe('runNext — radio proactive top-up Orbit lockout', () => {
expect(getTopSongs).not.toHaveBeenCalled();
});
});
describe('runNext — guest never auto-advances', () => {
it('does not advance (no playTrack) for an Orbit guest', () => {
guestInOrbit.value = true;
const state = fakeGet();
const get = (() => state) as unknown as () => never;
runNext(vi.fn(), get, /* manual */ false);
expect(state.playTrack).not.toHaveBeenCalled();
});
it('still advances for the host (only guests are gated)', () => {
inOrbit.value = true; // host is in a session...
guestInOrbit.value = false; // ...but not a guest
const state = fakeGet();
const get = (() => state) as unknown as () => never;
runNext(vi.fn(), get, /* manual */ false);
expect(state.playTrack).toHaveBeenCalledTimes(1);
});
});
+34 -1
View File
@@ -11,7 +11,7 @@ vi.mock('./orbitStore', () => ({
useOrbitStore: { getState: () => orbitState },
}));
import { isInOrbitSession } from './orbitSession';
import { isInOrbitSession, isOrbitGuestSession } from './orbitSession';
beforeEach(() => {
orbitState.role = null;
@@ -50,3 +50,36 @@ describe('isInOrbitSession', () => {
},
);
});
describe('isOrbitGuestSession', () => {
it.each(['active', 'joining', 'starting'] as const)(
"is true for a guest in phase='%s'",
phase => {
orbitState.role = 'guest';
orbitState.phase = phase;
expect(isOrbitGuestSession()).toBe(true);
},
);
it.each(['active', 'joining', 'starting'] as const)(
"is false for the host in phase='%s' (host drives, only guests are gated)",
phase => {
orbitState.role = 'host';
orbitState.phase = phase;
expect(isOrbitGuestSession()).toBe(false);
},
);
it('is false when not in a session', () => {
expect(isOrbitGuestSession()).toBe(false);
});
it.each(['idle', 'ending', 'ended', 'error'] as const)(
"is false for a guest in inactive phase='%s'",
phase => {
orbitState.role = 'guest';
orbitState.phase = phase;
expect(isOrbitGuestSession()).toBe(false);
},
);
});
+13
View File
@@ -15,3 +15,16 @@ export function isInOrbitSession(): boolean {
if (o.role !== 'host' && o.role !== 'guest') return false;
return o.phase === 'active' || o.phase === 'joining' || o.phase === 'starting';
}
/**
* True when this client is a *guest* in an Orbit session. The host drives every
* track change; a guest must never advance through its queue on its own (it
* only mirrors the host's queue locally as preload fodder for the hot cache).
* `runNext` uses this to no-op the auto-advance at track end the guest waits
* for `syncToHost` to load the host's next track instead of skipping itself.
*/
export function isOrbitGuestSession(): boolean {
const o = useOrbitStore.getState();
return o.role === 'guest'
&& (o.phase === 'active' || o.phase === 'joining' || o.phase === 'starting');
}
+44
View File
@@ -33,6 +33,15 @@ export {
suggestionKey,
} from './orbit/helpers';
export { isOrbitPlaybackSyncActive } from './orbit/sessionActive';
export {
makeHostPositionStamper,
type HostPositionStamper,
type PositionStamp,
} from './orbit/hostPositionStamp';
export {
buildGuestPreloadRefs,
syncGuestPreloadQueue,
} from './orbit/guestPreloadQueue';
export {
applyOutboxSnapshotsToState,
computeOrbitDriftMs,
@@ -87,5 +96,40 @@ export {
planPendingResends,
resetPendingResendState,
} from './orbit/pendingResend';
export {
planOrbitDriftCorrection,
stepRateToward,
type DriftCorrectionInput,
type DriftCorrectionPlan,
} from './orbit/driftCorrectionPlan';
export {
makeDriftSmoother,
median,
type DriftSmoother,
} from './orbit/driftSmoothing';
export {
applyOrbitDriftRate,
orbitDriftRateLastSent,
resetOrbitDriftRate,
} from './orbit/driftRate';
export {
getOrbitDriftStatus,
resetOrbitDriftStatus,
setOrbitDriftStatus,
type DriftCorrectionAction,
type OrbitDriftStatus,
} from './orbit/driftCorrectionStatus';
export {
clearDriftTrace,
driftTraceCount,
formatDriftTraceCsv,
pushDriftSample,
type DriftSample,
} from './orbit/driftTrace';
export {
LOOP_TICK_MS as ORBIT_DRIFT_LOOP_TICK_MS,
DRIFT_SMOOTH_WINDOW as ORBIT_DRIFT_SMOOTH_WINDOW,
DRIFT_SMOOTH_MIN_SAMPLES as ORBIT_DRIFT_SMOOTH_MIN_SAMPLES,
} from './orbit/driftCorrectionConstants';
export { sweepGuestOutboxes } from './orbit/sweep';
export { cleanupOrphanedOrbitPlaylists } from './orbit/cleanup';
@@ -0,0 +1,57 @@
/**
* Orbit drift correction tunable constants (v4: proportional + smooth ramp).
*
* History: v2 stepped a fine ramp but the raw drift was too noisy; v3 went
* bang-bang (full ±10% jumps) to dodge audio artifacts but that was a
* backend restamp bug (now fixed), and bang-bang *overshoots* (it drives the
* full cap until almost caught up, then with the ~3.5 s measurement latency it
* sails past and reverses "can't lock on"). v4:
*
* - Median-smooth the raw drift (host position lands in ~5 s quanta).
* - **Proportional** target rate: the further off, the closer to the ±10%
* cap; the closer to synced, the gentler so it converges asymptotically
* instead of overshooting.
* - Ramp the rate gradually toward that target (no jumps). Pitch-preserving
* speed changes are stable now that the restamp is fixed.
* - No auto-seek: past a hard threshold we surface the manual Catch-Up button.
*/
/**
* Smoothed drift at or below this is left alone sized against the coarse
* (~5 s) host position updates, below which "sync" isn't measurable anyway.
*/
export const DRIFT_DEADBAND_MS = 1000;
/**
* Drift at which the proportional rate reaches the full ±10% cap. Below it the
* rate scales linearly toward 1.0×, so a small drift gets a small nudge. Larger
* = gentler/slower correction.
*/
export const DRIFT_FULL_SCALE_MS = 4000;
/** ±10% product cap on the correction rate. */
export const RATE_MIN = 0.9;
export const RATE_MAX = 1.1;
/** Rate ramps toward the proportional target by this much per tick (gradual). */
export const RATE_STEP = 0.01;
/**
* Beyond this smoothed drift a soft nudge is pointless (real desync after a
* network stall). We don't auto-seek we surface the manual Catch-Up button.
*/
export const DRIFT_SEEK_HARD_MS = 8000;
/**
* With speed correction disabled, the manual Catch-Up button appears once the
* smoothed drift stays above this. Below it the small offset is left alone
* (inaudible on music); above it the user can take a clean catch-up seek.
*/
export const DRIFT_CATCHUP_BUTTON_MS = 3000;
/** Drift-correction loop cadence. Faster than the 2.5 s state poll. */
export const LOOP_TICK_MS = 500;
/** Median window over raw drift samples, and the minimum before acting. */
export const DRIFT_SMOOTH_WINDOW = 5;
export const DRIFT_SMOOTH_MIN_SAMPLES = 3;
@@ -0,0 +1,95 @@
import { describe, expect, it } from 'vitest';
import { planOrbitDriftCorrection, stepRateToward, type DriftCorrectionInput } from './driftCorrectionPlan';
import { RATE_MAX, RATE_MIN, RATE_STEP } from './driftCorrectionConstants';
function input(over: Partial<DriftCorrectionInput> = {}): DriftCorrectionInput {
return { driftMs: 0, hostIsPlaying: true, ...over };
}
// 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: -3000, hostIsPlaying: false }))).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('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('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);
}
}
});
});
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', () => {
let rate = 1.07;
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);
});
});
+93
View File
@@ -0,0 +1,93 @@
/**
* Orbit drift correction pure proportional planner (v4).
*
* From the **smoothed** drift, pick a target rate proportional to how far off we
* are: gentle near synced, up to the ±10% cap when far. The loop then ramps the
* live rate gradually toward that target. Proportional + gradual converges
* asymptotically instead of overshooting (bang-bang's "can't lock on"), and the
* pitch-preserving speed changes are stable now that the backend restamp is
* fixed.
*
* Past a hard threshold (real desync after a stall) we don't auto-seek the
* loop surfaces the manual Catch-Up button instead.
*
* `driftMs` MUST be median-smoothed (see `driftSmoothing`).
*/
import {
DRIFT_CATCHUP_BUTTON_MS,
DRIFT_DEADBAND_MS,
DRIFT_FULL_SCALE_MS,
DRIFT_SEEK_HARD_MS,
RATE_MAX,
RATE_MIN,
RATE_STEP,
} from './driftCorrectionConstants';
/**
* 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 = true;
export interface DriftCorrectionInput {
/** Smoothed, signed drift: `> 0` guest ahead (slow down), `< 0` behind (speed up). */
driftMs: number;
hostIsPlaying: boolean;
}
export type DriftCorrectionPlan =
| { action: 'hold' }
| { action: 'correct'; targetRate: number }
| { action: 'seek' };
const HOLD: DriftCorrectionPlan = { action: 'hold' };
export function planOrbitDriftCorrection(input: DriftCorrectionInput): DriftCorrectionPlan {
const { driftMs, hostIsPlaying } = input;
if (!hostIsPlaying) return HOLD;
const absDrift = Math.abs(driftMs);
if (!SPEED_CORRECTION_ENABLED) {
// Hold 1.0× (no audible nudging) and only surface the manual Catch-Up
// button once the drift is large enough to be worth a clean seek.
return absDrift > DRIFT_CATCHUP_BUTTON_MS ? { action: 'seek' } : HOLD;
}
// ── Proportional speed correction (disabled — kept for a future DSP revisit) ──
// Real desync (e.g. after a network stall) — too far to nudge; offer the
// manual catch-up jump rather than auto-seeking.
if (absDrift > DRIFT_SEEK_HARD_MS) return { action: 'seek' };
// Inside the deadband: acceptable, leave the rate at 1.0×.
if (absDrift <= DRIFT_DEADBAND_MS) return HOLD;
// Proportional magnitude: 0 at the deadband edge, full ±10% at FULL_SCALE.
// Continuous across the deadband boundary (no step in target rate).
const span = Math.max(1, DRIFT_FULL_SCALE_MS - DRIFT_DEADBAND_MS);
const frac = Math.min(1, (absDrift - DRIFT_DEADBAND_MS) / span);
const magnitude = (RATE_MAX - 1) * frac;
// Behind (drift < 0) → speed up; ahead → slow down.
const targetRate = driftMs < 0 ? 1 + magnitude : 1 - magnitude;
return { action: 'correct', targetRate: clampRate(targetRate) };
}
function clampRate(rate: number): number {
return Math.max(RATE_MIN, Math.min(RATE_MAX, rate));
}
/**
* Move `current` one `RATE_STEP` toward `target`, never overshooting. The loop
* calls this once per tick so the rate ramps gradually; snaps exactly to
* `target` once within a step (avoids float dust).
*/
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;
}
+38
View File
@@ -0,0 +1,38 @@
/**
* Orbit drift correction live status snapshot for diagnostics.
*
* The guest drift loop publishes what it is doing here; the diagnostics popover
* reads it on its own 1 s tick. A plain module-level snapshot (not Zustand) so
* the 500 ms loop never triggers a React re-render cascade the reader pulls
* the current value when it repaints.
*/
export type DriftCorrectionAction = 'idle' | 'hold' | 'correct' | 'seek' | 'blend';
export interface OrbitDriftStatus {
action: DriftCorrectionAction;
/** Rate currently sent to the engine (1.0 when not correcting). */
currentRate: number;
/** Smoothed drift (ms) the controller last acted on, or null before the window fills. */
smoothedDriftMs: number | null;
}
const IDLE: OrbitDriftStatus = {
action: 'idle',
currentRate: 1.0,
smoothedDriftMs: null,
};
let status: OrbitDriftStatus = IDLE;
export function setOrbitDriftStatus(next: OrbitDriftStatus): void {
status = next;
}
export function getOrbitDriftStatus(): OrbitDriftStatus {
return status;
}
export function resetOrbitDriftStatus(): void {
status = IDLE;
}
+85
View File
@@ -0,0 +1,85 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const { invoke, syncToRust } = vi.hoisted(() => ({
invoke: vi.fn(() => Promise.resolve()),
syncToRust: vi.fn(),
}));
vi.mock('@tauri-apps/api/core', () => ({ invoke }));
vi.mock('../../store/playbackRateStore', () => ({
usePlaybackRateStore: { getState: () => ({ syncToRust }) },
}));
import {
applyOrbitDriftRate,
orbitDriftRateLastSent,
resetOrbitDriftRate,
} from './driftRate';
beforeEach(() => {
resetOrbitDriftRate(); // clear module-level lastSentRate between tests
invoke.mockClear();
syncToRust.mockClear();
});
describe('applyOrbitDriftRate', () => {
it('sends a pitch-corrected speed for a non-neutral rate', () => {
applyOrbitDriftRate(1.05);
expect(invoke).toHaveBeenCalledTimes(1);
expect(invoke).toHaveBeenCalledWith('audio_set_playback_rate', {
enabled: true,
strategy: 'speed_corrected',
speed: 1.05,
pitchSemitones: 0,
});
expect(orbitDriftRateLastSent()).toBe(1.05);
});
it('disables the DSP at exactly 1.0× (true passthrough)', () => {
applyOrbitDriftRate(1.0);
expect(invoke).toHaveBeenCalledWith('audio_set_playback_rate', {
enabled: false,
strategy: 'speed_corrected',
speed: 1.0,
pitchSemitones: 0,
});
});
it('de-dupes identical consecutive rates (no redundant IPC)', () => {
applyOrbitDriftRate(1.03);
applyOrbitDriftRate(1.03);
applyOrbitDriftRate(1.03);
expect(invoke).toHaveBeenCalledTimes(1);
});
it('sends again when the rate actually changes', () => {
applyOrbitDriftRate(1.01);
applyOrbitDriftRate(1.02);
expect(invoke).toHaveBeenCalledTimes(2);
});
it('clamps to the ±10% product cap', () => {
applyOrbitDriftRate(1.5);
expect(invoke).toHaveBeenLastCalledWith('audio_set_playback_rate', expect.objectContaining({ speed: 1.1 }));
applyOrbitDriftRate(0.5);
expect(invoke).toHaveBeenLastCalledWith('audio_set_playback_rate', expect.objectContaining({ speed: 0.9 }));
});
});
describe('resetOrbitDriftRate', () => {
it('hands rate control back to the store sync and clears tracking', () => {
applyOrbitDriftRate(1.07);
expect(orbitDriftRateLastSent()).toBe(1.07);
resetOrbitDriftRate();
expect(syncToRust).toHaveBeenCalled();
expect(orbitDriftRateLastSent()).toBeNull();
});
it('re-sends after a reset even for a rate sent before it', () => {
applyOrbitDriftRate(1.04);
invoke.mockClear();
resetOrbitDriftRate();
applyOrbitDriftRate(1.04);
expect(invoke).toHaveBeenCalledTimes(1);
});
});
+65
View File
@@ -0,0 +1,65 @@
/**
* Orbit drift-rate channel the carve-out that lets the guest drift loop drive
* `audio_set_playback_rate` *during* a session even though the user's own
* playback-rate preference is suppressed by `syncPlaybackRate`
* (`effectiveEnabled = enabled && !isOrbitPlaybackSyncActive()`).
*
* This path never touches the persisted `playbackRateStore` prefs it sends a
* pitch-corrected speed straight to the engine and, on reset, hands control
* back to the store's own sync (which yields a neutral 1.0× while the session
* is active, or the restored user pref once it has ended).
*
* Calls are de-duplicated against the last value actually sent so the 500 ms
* loop only hits IPC when the rate genuinely changes.
*/
import { invoke } from '@tauri-apps/api/core';
import { usePlaybackRateStore } from '../../store/playbackRateStore';
import { RATE_MAX, RATE_MIN } from './driftCorrectionConstants';
/** Engine strategy for pitch-corrected speed — the same one the user "speed" mode maps to. */
const ORBIT_DRIFT_STRATEGY = 'speed_corrected';
let lastSentRate: number | null = null;
function clampRate(rate: number): number {
return Math.max(RATE_MIN, Math.min(RATE_MAX, rate));
}
/**
* Push a correction rate to the engine. At exactly 1.0× we disable the DSP
* (true passthrough / bit-perfect) rather than running a neutral effect.
* No-ops when the value is unchanged from the last send.
*/
export function applyOrbitDriftRate(rate: number): void {
const clamped = clampRate(rate);
if (lastSentRate !== null && Math.abs(lastSentRate - clamped) < 1e-6) return;
lastSentRate = clamped;
const enabled = Math.abs(clamped - 1) > 1e-9;
invoke('audio_set_playback_rate', {
enabled,
strategy: ORBIT_DRIFT_STRATEGY,
speed: enabled ? clamped : 1.0,
pitchSemitones: 0,
}).catch(() => {});
}
/**
* Relinquish the drift channel and restore the correct steady-state rate.
* Delegates to the store's own `syncToRust`, which sends a neutral 1.0× while
* the Orbit session is still active and the real user pref once it has ended
* so this is safe to call both mid-session (track change, seek, pause) and on
* leave. Idempotent.
*/
export function resetOrbitDriftRate(): void {
// Clear tracking and re-assert the baseline so a stale engine rate from a
// prior correction can't linger. `null` is idempotent if already cleared.
lastSentRate = null;
usePlaybackRateStore.getState().syncToRust();
}
/** Test-only: the last rate pushed to the engine (null after a reset). */
export function orbitDriftRateLastSent(): number | null {
return lastSentRate;
}
+58
View File
@@ -0,0 +1,58 @@
import { describe, expect, it } from 'vitest';
import { makeDriftSmoother, median } from './driftSmoothing';
describe('median', () => {
it('returns the middle of an odd-length list', () => {
expect(median([3, 1, 2])).toBe(2);
});
it('averages the two middle values of an even-length list', () => {
expect(median([1, 2, 3, 4])).toBe(2.5);
});
it('rejects a single spike (the whole point)', () => {
// Four samples around -1000 plus one +480 spike → median stays near -1000.
expect(median([-1008, -1029, 480, -1034, -1008])).toBe(-1008);
});
it('handles an empty list', () => {
expect(median([])).toBe(0);
});
});
describe('makeDriftSmoother', () => {
it('returns null until minSamples have arrived', () => {
const s = makeDriftSmoother(5, 3);
s.push(-1000);
expect(s.value()).toBeNull();
s.push(-1010);
expect(s.value()).toBeNull();
s.push(-990);
expect(s.value()).not.toBeNull();
});
it('smooths the real oscillating sequence to a stable value', () => {
// The exact alternating sequence from the bug log.
const s = makeDriftSmoother(5, 3);
for (const v of [-1008, 483, -1029, 483, -1034]) s.push(v);
// Median of the window ignores the +483 spikes → stays clearly "behind".
expect(s.value()).toBeLessThan(-500);
});
it('drops the oldest sample past the window size', () => {
const s = makeDriftSmoother(3, 1);
s.push(100);
s.push(200);
s.push(300);
s.push(400); // evicts 100
expect(s.size()).toBe(3);
expect(s.value()).toBe(300); // median of [200,300,400]
});
it('reset clears the buffer and re-arms the minSamples gate', () => {
const s = makeDriftSmoother(5, 3);
s.push(1); s.push(2); s.push(3);
expect(s.value()).not.toBeNull();
s.reset();
expect(s.size()).toBe(0);
expect(s.value()).toBeNull();
});
});
+61
View File
@@ -0,0 +1,61 @@
/**
* Orbit drift smoothing turn the noisy raw drift signal into a value the
* controller can act on.
*
* The raw drift (`computeOrbitDriftMs`) is badly noisy: the host's position only
* lands every ~5 s in coarse quanta, and each correction action perturbs the
* measured position, so a single tick can swing ±1500 ms with no real change.
* Acting on each raw value makes the guest chase its own tail (the back-and-forth
* cucadmuh heard). So we feed raw samples through a small **median** window
* median rejects single-tick spikes far better than a mean and only act on the
* stable result.
*
* After a correction action (rate change / seek) the next few samples are
* meaningless until the engine settles, so the smoother is reset and ignored for
* a short settle window (handled by the loop via `reset()`).
*/
/** Median of a non-empty list. Pure; does not mutate the input. */
export function median(values: readonly number[]): number {
if (values.length === 0) return 0;
const sorted = [...values].sort((a, b) => a - b);
const mid = sorted.length >> 1;
return sorted.length % 2 === 0
? (sorted[mid - 1] + sorted[mid]) / 2
: sorted[mid];
}
export interface DriftSmoother {
/** Add a raw drift sample. */
push(sample: number): void;
/** Median of the buffered samples, or null until the window has filled. */
value(): number | null;
/** Drop all buffered samples (after a correction action / track change). */
reset(): void;
/** Test/diagnostics: how many samples are currently buffered. */
size(): number;
}
/**
* Rolling median over the last `windowSize` samples. `value()` returns null
* until at least `minSamples` have arrived, so the controller never acts on a
* half-empty window right after a reset.
*/
export function makeDriftSmoother(windowSize = 5, minSamples = 3): DriftSmoother {
const buf: number[] = [];
return {
push(sample: number) {
buf.push(sample);
if (buf.length > windowSize) buf.splice(0, buf.length - windowSize);
},
value() {
return buf.length >= minSamples ? median(buf) : null;
},
reset() {
buf.length = 0;
},
size() {
return buf.length;
},
};
}
+71
View File
@@ -0,0 +1,71 @@
import { beforeEach, describe, expect, it } from 'vitest';
import {
clearDriftTrace,
driftTraceCount,
formatDriftTraceCsv,
pushDriftSample,
type DriftSample,
} from './driftTrace';
function sample(over: Partial<DriftSample> = {}): DriftSample {
return {
ts: Date.parse('2026-06-22T20:00:00.000Z'),
driftMs: -812.4,
smoothedMs: -790,
rate: 1.1,
action: 'correct',
trackRemSec: 118.7,
hostPosMs: 60_000,
guestPosMs: 59_188,
...over,
};
}
beforeEach(() => clearDriftTrace());
describe('driftTrace', () => {
it('is empty until something is sampled', () => {
expect(driftTraceCount()).toBe(0);
expect(formatDriftTraceCsv()).toBe('');
});
it('formats a header and one rounded row per sample', () => {
pushDriftSample(sample());
const csv = formatDriftTraceCsv();
const [header, row] = csv.split('\n');
expect(header).toBe('iso_ts,raw_ms,smoothed_ms,rate,action,rem_s,host_ms,guest_ms');
// raw/smoothed/positions rounded to whole ms; rate to 2 dp; rem to 1 dp.
expect(row).toBe('2026-06-22T20:00:00.000Z,-812,-790,1.10,correct,118.7,60000,59188');
});
it('leaves smoothed empty before the window fills', () => {
pushDriftSample(sample({ smoothedMs: null }));
const row = formatDriftTraceCsv().split('\n')[1];
expect(row).toBe('2026-06-22T20:00:00.000Z,-812,,1.10,correct,118.7,60000,59188');
});
it('keeps samples in insertion order', () => {
pushDriftSample(sample({ driftMs: -800, action: 'correct' }));
pushDriftSample(sample({ driftMs: -200, action: 'hold' }));
const rows = formatDriftTraceCsv().split('\n').slice(1);
expect(rows[0]).toContain(',correct,');
expect(rows[1]).toContain(',hold,');
});
it('caps the ring at 1200 samples, dropping the oldest', () => {
for (let i = 0; i < 1300; i += 1) pushDriftSample(sample({ driftMs: i }));
expect(driftTraceCount()).toBe(1200);
const rows = formatDriftTraceCsv().split('\n').slice(1);
// First surviving sample is #100 (0..99 dropped), last is #1299.
expect(rows[0]).toContain(',100,');
expect(rows[rows.length - 1]).toContain(',1299,');
});
it('clears the buffer', () => {
pushDriftSample(sample());
clearDriftTrace();
expect(driftTraceCount()).toBe(0);
expect(formatDriftTraceCsv()).toBe('');
});
});
+68
View File
@@ -0,0 +1,68 @@
/**
* Orbit drift correction dense time-series trace.
*
* The Orbit event log (`orbitDiag`) is a 200-entry ring of *readable
* transitions* (`soft→hold`, `seek`, `kicked`). Sampling the drift loop into
* it every 500 ms would overflow it in ~100 s and bury those transitions. So
* the dense per-tick samples live here instead, in their own larger ring, and
* the diagnostics popover offers them as a separate CSV copy that drops
* straight into a spreadsheet/plot the direct answer to "is the correction
* actually working": does `drift` trend to 0 while `rate` is nudged?
*
* Pure in-memory bookkeeping never re-renders, never hits IPC.
*/
/** ~10 min at the 500 ms loop cadence. */
const MAX_SAMPLES = 1200;
export interface DriftSample {
ts: number;
/** Raw drift this tick (noisy). */
driftMs: number;
/** Median-smoothed drift the controller acts on, or null before the window fills. */
smoothedMs: number | null;
rate: number;
action: string;
trackRemSec: number;
hostPosMs: number;
guestPosMs: number;
}
const buffer: DriftSample[] = [];
export function pushDriftSample(sample: DriftSample): void {
buffer.push(sample);
if (buffer.length > MAX_SAMPLES) buffer.splice(0, buffer.length - MAX_SAMPLES);
}
export function clearDriftTrace(): void {
buffer.length = 0;
}
/** Number of samples currently buffered (for the copy-button label). */
export function driftTraceCount(): number {
return buffer.length;
}
/**
* Render the trace as a CSV block, oldest first. Header + one row per sample;
* `rate`/`target` to 2 dp, positions/drift rounded to whole ms. Empty string
* when nothing has been sampled yet.
*/
export function formatDriftTraceCsv(): string {
if (buffer.length === 0) return '';
const header = 'iso_ts,raw_ms,smoothed_ms,rate,action,rem_s,host_ms,guest_ms';
const rows = buffer.map(s =>
[
new Date(s.ts).toISOString(),
Math.round(s.driftMs),
s.smoothedMs === null ? '' : Math.round(s.smoothedMs),
s.rate.toFixed(2),
s.action,
s.trackRemSec.toFixed(1),
Math.round(s.hostPosMs),
Math.round(s.guestPosMs),
].join(','),
);
return [header, ...rows].join('\n');
}
+123
View File
@@ -0,0 +1,123 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { OrbitState } from '../../api/orbit';
import type { QueueItemRef } from '../../store/playerStoreTypes';
const { player, setState } = vi.hoisted(() => {
const player = {
currentTrack: null as { id: string } | null,
queueItems: [] as QueueItemRef[],
queueIndex: 0,
};
const setState = vi.fn((patch: Record<string, unknown>) => {
Object.assign(player, patch);
});
return { player, setState };
});
vi.mock('../../store/playerStore', () => ({
usePlayerStore: { getState: () => player, setState },
}));
import { buildGuestPreloadRefs, syncGuestPreloadQueue } from './guestPreloadQueue';
function orbit(over: Partial<OrbitState> = {}): OrbitState {
return {
currentTrack: { trackId: 't0', addedBy: 'host', addedAt: 0 },
playQueue: [{ trackId: 't1', addedBy: 'host' }, { trackId: 't2', addedBy: 'guest' }],
...over,
} as OrbitState;
}
beforeEach(() => {
player.currentTrack = null;
player.queueItems = [];
player.queueIndex = 0;
setState.mockClear();
});
describe('buildGuestPreloadRefs', () => {
it('puts the host current track first, then the upcoming queue', () => {
const refs = buildGuestPreloadRefs('t0', [{ trackId: 't1' }, { trackId: 't2' }], 'srv');
expect(refs).toEqual([
{ serverId: 'srv', trackId: 't0' },
{ serverId: 'srv', trackId: 't1' },
{ serverId: 'srv', trackId: 't2' },
]);
});
it('de-dupes a track that is both current and in the upcoming queue', () => {
const refs = buildGuestPreloadRefs('t0', [{ trackId: 't0' }, { trackId: 't1' }], 'srv');
expect(refs.map(r => r.trackId)).toEqual(['t0', 't1']);
});
it('handles a missing/empty play queue', () => {
expect(buildGuestPreloadRefs('t0', undefined, 'srv')).toEqual([{ serverId: 'srv', trackId: 't0' }]);
});
});
describe('syncGuestPreloadQueue', () => {
it('mirrors the host queue once the guest is on the host track', () => {
player.currentTrack = { id: 't0' };
player.queueItems = [{ serverId: 'srv', trackId: 't0' }];
syncGuestPreloadQueue(orbit());
expect(setState).toHaveBeenCalledWith({
queueItems: [
{ serverId: 'srv', trackId: 't0' },
{ serverId: 'srv', trackId: 't1' },
{ serverId: 'srv', trackId: 't2' },
],
queueIndex: 0,
});
});
it('does nothing while the track load is still pending (guest not yet on host track)', () => {
player.currentTrack = { id: 'old' }; // host moved to t0, guest still on old
player.queueItems = [{ serverId: 'srv', trackId: 'old' }];
syncGuestPreloadQueue(orbit());
expect(setState).not.toHaveBeenCalled();
});
it('does nothing when the queue is not server-pinned yet', () => {
player.currentTrack = { id: 't0' };
player.queueItems = []; // no ref to source the serverId from
syncGuestPreloadQueue(orbit());
expect(setState).not.toHaveBeenCalled();
});
it('is idempotent — no write when the mirror already matches', () => {
player.currentTrack = { id: 't0' };
player.queueItems = [
{ serverId: 'srv', trackId: 't0' },
{ serverId: 'srv', trackId: 't1' },
{ serverId: 'srv', trackId: 't2' },
];
player.queueIndex = 0;
syncGuestPreloadQueue(orbit());
expect(setState).not.toHaveBeenCalled();
});
it('rewrites when the host queue changed', () => {
player.currentTrack = { id: 't0' };
player.queueItems = [
{ serverId: 'srv', trackId: 't0' },
{ serverId: 'srv', trackId: 't1' },
];
syncGuestPreloadQueue(orbit({ playQueue: [{ trackId: 't1', addedBy: 'host' }, { trackId: 't9', addedBy: 'host' }] }));
expect(setState).toHaveBeenCalledWith({
queueItems: [
{ serverId: 'srv', trackId: 't0' },
{ serverId: 'srv', trackId: 't1' },
{ serverId: 'srv', trackId: 't9' },
],
queueIndex: 0,
});
});
it('does nothing when the host has no current track', () => {
player.currentTrack = { id: 't0' };
player.queueItems = [{ serverId: 'srv', trackId: 't0' }];
syncGuestPreloadQueue(orbit({ currentTrack: null }));
expect(setState).not.toHaveBeenCalled();
});
});
+70
View File
@@ -0,0 +1,70 @@
/**
* Orbit guest mirror the host's queue into the local playerStore queue so the
* hot-cache prefetcher and crossfade preload have an upcoming track to warm.
*
* Why this exists: the prefetcher and crossfade derive "the next track" purely
* from `playerStore.queueItems[queueIndex + 1]`. A guest used to load every
* track as a single-item queue (`playTrack(track, [track])`), so that lookahead
* was always empty nothing was ever prefetched each AutoDJ/crossfade switch
* cold-fetched the next track over HTTP, stalling the transition and drifting
* the guest off the host.
*
* The mirrored queue is **invisible** (the guest's UI renders
* `OrbitGuestQueue` / `state.playQueue`, not `queueItems`) and **never drives
* playback** `runNext` no-ops auto-advance for guests, so the host stays the
* sole driver. This is purely preload fodder.
*/
import type { OrbitState } from '../../api/orbit';
import type { QueueItemRef } from '../../store/playerStoreTypes';
import { usePlayerStore } from '../../store/playerStore';
/**
* Build the local preload refs: the host's current track first, then its
* upcoming queue. De-duplicates by trackId a repeated id would make the
* "next track" lookahead ambiguous and could prefetch the wrong entry.
*/
export function buildGuestPreloadRefs(
hostTrackId: string,
playQueue: ReadonlyArray<{ trackId: string }> | undefined,
serverId: string,
): QueueItemRef[] {
const seen = new Set<string>();
const refs: QueueItemRef[] = [];
for (const trackId of [hostTrackId, ...(playQueue ?? []).map(q => q.trackId)]) {
if (seen.has(trackId)) continue;
seen.add(trackId);
refs.push({ serverId, trackId });
}
return refs;
}
function sameTrackIds(a: readonly QueueItemRef[], b: readonly QueueItemRef[]): boolean {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i += 1) if (a[i].trackId !== b[i].trackId) return false;
return true;
}
/**
* Sync the guest's local queue to the host's. Race-free: only runs once the
* guest is actually playing the host's current track, so it never fights an
* in-flight `syncToHost` `playTrack`. Idempotent: writes only when the ref list
* or index actually changed, so the 2.5 s poll doesn't re-render every tick.
*/
export function syncGuestPreloadQueue(state: OrbitState): void {
const hostTrackId = state.currentTrack?.trackId;
if (!hostTrackId) return;
const player = usePlayerStore.getState();
// Load still pending — let syncToHost land first; we mirror on the next tick.
if (player.currentTrack?.id !== hostTrackId) return;
// Reuse the already-pinned server key from the playing track's ref.
const serverId = player.queueItems[0]?.serverId;
if (!serverId) return;
const refs = buildGuestPreloadRefs(hostTrackId, state.playQueue, serverId);
if (player.queueIndex === 0 && sameTrackIds(player.queueItems, refs)) return;
usePlayerStore.setState({ queueItems: refs, queueIndex: 0 });
}
+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 };
};
}