mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 23:35:44 +00:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4c2a5b8853 | |||
| 0286b9b161 | |||
| 61947ee5a7 | |||
| f66ad3b578 | |||
| 8dfe5f9094 | |||
| 42a84932e6 | |||
| 0d83a6e957 | |||
| d6a03532b2 | |||
| 2fd99c7097 | |||
| 8ec32d7057 | |||
| 38843103b5 | |||
| 688aa5dff6 | |||
| 90b7851785 |
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 1–3 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 5–8 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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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}} 行のイベントをコピーしました',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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}}',
|
||||
|
||||
@@ -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}} 行',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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('');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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