From fb37d430c4d719b7893879c9a3de66440a13b3f3 Mon Sep 17 00:00:00 2001 From: Tanvir Redoy Date: Tue, 7 Apr 2026 09:45:27 -0500 Subject: [PATCH 1/4] feat(client): expose filtered playback state metrics Add live edge, playback position, live offset, current group, pending switch target, and metadata state to player metrics. Propagate the new fields through ABR metrics, CSV logging, the metrics panel, and client tests. --- .../client-js/src/components/MetricsPanel.tsx | 51 ++++++++++++++++ apps/client-js/src/lib/abr/AbrController.ts | 21 +++++++ .../lib/abr/__tests__/AbrController.test.ts | 7 +++ .../src/lib/metrics/MetricsCollector.ts | 24 ++++++-- .../__tests__/MetricsCollector.test.ts | 15 ++++- apps/client-js/src/lib/metrics/types.ts | 7 +++ apps/client-js/src/lib/player.ts | 58 ++++++++++++++----- 7 files changed, 162 insertions(+), 21 deletions(-) diff --git a/apps/client-js/src/components/MetricsPanel.tsx b/apps/client-js/src/components/MetricsPanel.tsx index 6680c34c..74cd8ccd 100644 --- a/apps/client-js/src/components/MetricsPanel.tsx +++ b/apps/client-js/src/components/MetricsPanel.tsx @@ -83,6 +83,22 @@ const METRIC_DEFS: MetricDef[] = [ color: '#14b8a6', extract: s => s.deliveryTimeMs, }, + { + key: 'liveOffset', + label: 'Live Offset', + unit: 's', + unitLabel: 'Seconds', + color: '#f97316', + extract: s => s.liveOffsetSeconds ?? 0, + }, + { + key: 'metadataDelay', + label: 'Metadata Delay', + unit: 'ms', + unitLabel: 'ms', + color: '#8b5cf6', + extract: s => s.metadataDelayMs, + }, ]; const METRIC_MAP = new Map(METRIC_DEFS.map(d => [d.key, d])); @@ -309,6 +325,41 @@ export function MetricsPanel({ metrics, snapshot, tracks }: MetricsPanelProps) { activeMetrics={activeMetrics} onToggle={toggle} /> + + + + + + + {/* ── Playout State ── */} +
+

+ Playout State +

+
+ + + + +
diff --git a/apps/client-js/src/lib/abr/AbrController.ts b/apps/client-js/src/lib/abr/AbrController.ts index ec925444..e1dc2e09 100644 --- a/apps/client-js/src/lib/abr/AbrController.ts +++ b/apps/client-js/src/lib/abr/AbrController.ts @@ -20,6 +20,13 @@ export interface AbrMetrics { playbackRate: number; deliveryTimeMs: number; lastObjectBytes: number; + liveEdgeTime: number | null; + playbackTime: number | null; + liveOffsetSeconds: number | null; + currentVideoGroup: string | null; + pendingSwitchTrack: string | null; + metadataReady: boolean; + metadataDelayMs: number; switchHistory: SwitchEvent[]; mode: 'auto' | 'manual'; switching: boolean; @@ -105,6 +112,13 @@ export class AbrController { playbackRate, deliveryTimeMs, lastObjectBytes, + liveEdgeTime, + playbackTime, + liveOffsetSeconds, + currentVideoGroup, + pendingSwitchTrack, + metadataReady, + metadataDelayMs, } = raw; // Find the active track index in the sorted tracks array @@ -124,6 +138,13 @@ export class AbrController { playbackRate, deliveryTimeMs, lastObjectBytes, + liveEdgeTime, + playbackTime, + liveOffsetSeconds, + currentVideoGroup, + pendingSwitchTrack, + metadataReady, + metadataDelayMs, switchHistory: [...this.#switchHistory], mode, switching: this.#switching, diff --git a/apps/client-js/src/lib/abr/__tests__/AbrController.test.ts b/apps/client-js/src/lib/abr/__tests__/AbrController.test.ts index a5421576..3a22fb0b 100644 --- a/apps/client-js/src/lib/abr/__tests__/AbrController.test.ts +++ b/apps/client-js/src/lib/abr/__tests__/AbrController.test.ts @@ -43,6 +43,13 @@ function makePlayerMetrics(overrides: Partial { - beforeEach(() => { vi.useFakeTimers(); }); - afterEach(() => { vi.useRealTimers(); }); + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); it('starts empty', () => { const player = makeMockPlayer(); diff --git a/apps/client-js/src/lib/metrics/types.ts b/apps/client-js/src/lib/metrics/types.ts index 85cdadfb..d6df4d55 100644 --- a/apps/client-js/src/lib/metrics/types.ts +++ b/apps/client-js/src/lib/metrics/types.ts @@ -9,6 +9,13 @@ export interface MetricsSample { totalFrames: number; playbackRate: number; deliveryTimeMs: number; + liveEdgeTime: number | null; + playbackTime: number | null; + liveOffsetSeconds: number | null; + currentVideoGroup: string | null; + pendingSwitchTrack: string | null; + metadataReady: boolean; + metadataDelayMs: number; } export interface MetricsSnapshot { diff --git a/apps/client-js/src/lib/player.ts b/apps/client-js/src/lib/player.ts index 791a2775..58109766 100644 --- a/apps/client-js/src/lib/player.ts +++ b/apps/client-js/src/lib/player.ts @@ -77,6 +77,26 @@ const DefaultOptions = { } satisfies Required> & Pick; +export interface PlayerMetrics { + bandwidthBps: number; + fastEmaBps: number; + slowEmaBps: number; + bufferSeconds: number; + activeTrack: string | null; + droppedFrames: number; + totalFrames: number; + playbackRate: number; + deliveryTimeMs: number; + lastObjectBytes: number; + liveEdgeTime: number | null; + playbackTime: number | null; + liveOffsetSeconds: number | null; + currentVideoGroup: string | null; + pendingSwitchTrack: string | null; + metadataReady: boolean; + metadataDelayMs: number; +} + export class Player { catalog: CMSFCatalog | null = null; client: MOQtailClient | null = null; @@ -84,6 +104,8 @@ export class Player { #element: HTMLVideoElement | null = null; #mse?: MediaSource; #streams: MOQStreamStruct[] = []; + #metadataReady = true; + #metadataDelayMs = 0; #options: Required> & Pick; @@ -402,24 +424,15 @@ export class Player { } } - getMetrics(): { - bandwidthBps: number; - fastEmaBps: number; - slowEmaBps: number; - bufferSeconds: number; - activeTrack: string | null; - droppedFrames: number; - totalFrames: number; - playbackRate: number; - deliveryTimeMs: number; - lastObjectBytes: number; - } { + getMetrics(): PlayerMetrics { const videoStruct = this.#streams.find(s => this.catalog?.getRole(s.trackName) === 'video'); const buffered = this.#element?.buffered; + const liveEdgeTime = buffered && buffered.length > 0 ? buffered.end(buffered.length - 1) : null; + const playbackTime = this.#element ? this.#element.currentTime : null; const bufferSeconds = - buffered && buffered.length > 0 && this.#element - ? Math.max(0, buffered.end(buffered.length - 1) - this.#element.currentTime) - : 0; + liveEdgeTime !== null && playbackTime !== null ? Math.max(0, liveEdgeTime - playbackTime) : 0; + const currentVideoGroup = + videoStruct && videoStruct.lastGroupId >= 0n ? videoStruct.lastGroupId.toString() : null; const quality = this.#element?.getVideoPlaybackQuality?.(); return { bandwidthBps: videoStruct?.tracker.getBandwidthBps() ?? 0, @@ -432,6 +445,16 @@ export class Player { playbackRate: this.#element?.playbackRate ?? 1, deliveryTimeMs: videoStruct?.tracker.getLastDeliveryTimeMs() ?? 0, lastObjectBytes: videoStruct?.tracker.getLastObjectBytes() ?? 0, + liveEdgeTime, + playbackTime, + liveOffsetSeconds: + liveEdgeTime !== null && playbackTime !== null + ? Math.max(0, liveEdgeTime - playbackTime) + : null, + currentVideoGroup, + pendingSwitchTrack: videoStruct?.pendingSwitch?.trackName ?? null, + metadataReady: this.#metadataReady, + metadataDelayMs: this.#metadataDelayMs, }; } @@ -440,6 +463,11 @@ export class Player { videoStruct?.tracker.setAlphas(alphaFast, alphaSlow); } + setMetadataState(ready: boolean, delayMs: number): void { + this.#metadataReady = ready; + this.#metadataDelayMs = Math.max(0, delayMs); + } + /** Poll WebTransport stats for the active video track's goodput tracker. */ async pollGoodput(): Promise { const videoStruct = this.#streams.find(s => this.catalog?.getRole(s.trackName) === 'video'); From 4679b26daddbfee0f6f1a95c87d354a1455cbbeb Mon Sep 17 00:00:00 2001 From: Tanvir Redoy Date: Tue, 7 Apr 2026 09:45:40 -0500 Subject: [PATCH 2/4] feat(client): add synthetic moderation playback gating Add a filtered playback mode that simulates moderation latency per video group. Provide fixed and variable delay controls in the client sidebar. Pause playout until the synthetic metadata decision is ready, then resume playback. --- apps/client-js/src/app.tsx | 188 ++++++++++++++++++++++++++++++++++++- 1 file changed, 186 insertions(+), 2 deletions(-) diff --git a/apps/client-js/src/app.tsx b/apps/client-js/src/app.tsx index 259f467b..0a333484 100644 --- a/apps/client-js/src/app.tsx +++ b/apps/client-js/src/app.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import { useState, useRef, useCallback } from 'preact/hooks'; +import { useState, useRef, useCallback, useEffect } from 'preact/hooks'; import type { ComponentChildren } from 'preact'; import { Player } from '@/lib/player'; import { cn } from '@/lib/utils'; @@ -29,6 +29,12 @@ import { MetricsPanel } from '@/components/MetricsPanel'; type Track = CMSF['tracks'][number]; type Status = 'idle' | 'connecting' | 'ready' | 'restarting' | 'playing' | 'error'; +type ModerationDelayMode = 'fixed' | 'variable'; + +interface GroupModerationDecision { + delayMs: number; + readyAt: number; +} const GITHUB_REPO = 'moqtail/moqtail'; @@ -227,11 +233,40 @@ export function App() { const [abrSettings, setAbrSettings] = useState(DEFAULT_ABR_SETTINGS); const [abrMetrics, setAbrMetrics] = useState(null); const [metricsSnapshot, setMetricsSnapshot] = useState(null); + const [filteredPlaybackEnabled, setFilteredPlaybackEnabled] = useState(false); + const [moderationDelayMode, setModerationDelayMode] = useState('fixed'); + const [fixedModerationDelayMs, setFixedModerationDelayMs] = useState(1200); + const [variableDelayMinMs, setVariableDelayMinMs] = useState(800); + const [variableDelayMaxMs, setVariableDelayMaxMs] = useState(2000); const abrRef = useRef(null); const rulesRef = useRef(null); const metricsRef = useRef(null); + const moderationDecisionsRef = useRef>(new Map()); + const moderationTimersRef = useRef>(new Map()); + const gatePausedPlaybackRef = useRef(false); + + const clearModerationSimulation = useCallback(() => { + for (const timeoutId of moderationTimersRef.current.values()) { + window.clearTimeout(timeoutId); + } + moderationTimersRef.current.clear(); + moderationDecisionsRef.current.clear(); + gatePausedPlaybackRef.current = false; + playerRef.current?.setMetadataState(true, 0); + }, []); + + const getSyntheticModerationDelayMs = useCallback(() => { + if (moderationDelayMode === 'fixed') { + return Math.max(0, fixedModerationDelayMs); + } + + const minMs = Math.max(0, Math.min(variableDelayMinMs, variableDelayMaxMs)); + const maxMs = Math.max(minMs, Math.max(variableDelayMinMs, variableDelayMaxMs)); + return minMs + Math.round(Math.random() * (maxMs - minMs)); + }, [fixedModerationDelayMs, moderationDelayMode, variableDelayMaxMs, variableDelayMinMs]); const disposePlayer = useCallback(async () => { + clearModerationSimulation(); if (abrRef.current) { abrRef.current.stop(); abrRef.current = null; @@ -255,7 +290,79 @@ export function App() { } catch {} bufferRef.current = null; } - }, []); + }, [clearModerationSimulation]); + + useEffect(() => { + return () => { + clearModerationSimulation(); + }; + }, [clearModerationSimulation]); + + useEffect(() => { + clearModerationSimulation(); + }, [ + clearModerationSimulation, + filteredPlaybackEnabled, + moderationDelayMode, + fixedModerationDelayMs, + variableDelayMinMs, + variableDelayMaxMs, + ]); + + useEffect(() => { + const player = playerRef.current; + const video = videoRef.current; + if (!player) return; + + if (!filteredPlaybackEnabled || status !== 'playing') { + player.setMetadataState(true, 0); + if (gatePausedPlaybackRef.current && video) { + gatePausedPlaybackRef.current = false; + void video.play().catch(() => {}); + } + return; + } + + const currentGroup = abrMetrics?.currentVideoGroup; + if (!currentGroup) { + player.setMetadataState(true, 0); + return; + } + + let decision = moderationDecisionsRef.current.get(currentGroup); + if (!decision) { + const delayMs = getSyntheticModerationDelayMs(); + decision = { + delayMs, + readyAt: Date.now() + delayMs, + }; + moderationDecisionsRef.current.set(currentGroup, decision); + + const timeoutId = window.setTimeout(() => { + moderationTimersRef.current.delete(currentGroup); + }, delayMs + 100); + moderationTimersRef.current.set(currentGroup, timeoutId); + } + + const remainingDelayMs = Math.max(0, decision.readyAt - Date.now()); + const metadataReady = remainingDelayMs === 0; + player.setMetadataState(metadataReady, remainingDelayMs); + + if (!video) return; + + if (!metadataReady) { + if (!video.paused) { + video.pause(); + gatePausedPlaybackRef.current = true; + } + return; + } + + if (gatePausedPlaybackRef.current) { + gatePausedPlaybackRef.current = false; + void video.play().catch(() => {}); + } + }, [abrMetrics, filteredPlaybackEnabled, getSyntheticModerationDelayMs, status]); const handleConnect = useCallback(async () => { if (!videoRef.current) return; @@ -543,6 +650,83 @@ export function App() { )} +
+
+
+

+ Filtered Playback +

+

+ Simulate moderation latency by pausing playout until metadata is ready for the current video group. +

+
+ +
+ + + + + + {moderationDelayMode === 'fixed' ? ( + + + setFixedModerationDelayMs(Number((e.target as HTMLInputElement).value) || 0) + } + disabled={!filteredPlaybackEnabled} + class={inputCls} + /> + + ) : ( +
+ + + setVariableDelayMinMs(Number((e.target as HTMLInputElement).value) || 0) + } + disabled={!filteredPlaybackEnabled} + class={inputCls} + /> + + + + setVariableDelayMaxMs(Number((e.target as HTMLInputElement).value) || 0) + } + disabled={!filteredPlaybackEnabled} + class={inputCls} + /> + +
+ )} +
+ {/* Tracks */} {hasTracks && (
From 5b14bfcf9d21818bbba1e20a8683816cc3616c5f Mon Sep 17 00:00:00 2001 From: Tanvir Redoy Date: Wed, 8 Apr 2026 14:31:15 -0500 Subject: [PATCH 3/4] fix(tsconfig): migrate deprecated TS options Remove deprecated baseUrl usage and migrate moqtail-ts moduleResolution off node10 behavior. This resolves TS deprecation diagnostics without relying on ignoreDeprecations settings that break current TS builds. --- apps/client-js/tsconfig.app.json | 1 - libs/moqtail-ts/tsconfig.json | 3 +-- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/client-js/tsconfig.app.json b/apps/client-js/tsconfig.app.json index c8d5ca6f..aa0170fd 100644 --- a/apps/client-js/tsconfig.app.json +++ b/apps/client-js/tsconfig.app.json @@ -7,7 +7,6 @@ "lib": ["ES2022", "DOM", "DOM.Iterable"], "types": ["vite/client"], "skipLibCheck": true, - "baseUrl": ".", "paths": { "react": ["./node_modules/preact/compat/"], "react-dom": ["./node_modules/preact/compat/"], diff --git a/libs/moqtail-ts/tsconfig.json b/libs/moqtail-ts/tsconfig.json index 1b4cecf9..b67c0c4f 100644 --- a/libs/moqtail-ts/tsconfig.json +++ b/libs/moqtail-ts/tsconfig.json @@ -4,7 +4,7 @@ "rootDir": ".", "declaration": false, "noUnusedLocals": false, - "moduleResolution": "node", + "moduleResolution": "bundler", "experimentalDecorators": true, "allowSyntheticDefaultImports": true, "importHelpers": true, @@ -15,7 +15,6 @@ "skipDefaultLibCheck": true, "checkJs": false, "types": ["vitest/importMeta"], - "baseUrl": ".", "paths": { "@": ["./src"], "@/*": ["./src/*"] From 754696c071aaf207ded2a919a0483c5bc60d053f Mon Sep 17 00:00:00 2001 From: Tanvir Redoy Date: Thu, 9 Apr 2026 02:02:49 -0500 Subject: [PATCH 4/4] feat(client-js): add switch baseline telemetry and experiment tooling --- .../docs/experiment-quick-reference.md | 108 ++++++ .../docs/filtered-playback-experiments.md | 341 ++++++++++++++++++ apps/client-js/package.json | 3 +- .../scripts/analyze-switch-baseline.mjs | 337 +++++++++++++++++ apps/client-js/src/app.tsx | 61 ++-- .../client-js/src/components/MetricsPanel.tsx | 68 ++++ apps/client-js/src/lib/abr/AbrController.ts | 48 +++ .../lib/abr/__tests__/AbrController.test.ts | 16 + .../src/lib/metrics/MetricsCollector.ts | 34 +- .../__tests__/MetricsCollector.test.ts | 16 + apps/client-js/src/lib/metrics/types.ts | 16 + apps/client-js/src/lib/player.ts | 181 ++++++++++ apps/client-js/vite.config.ts | 33 +- 13 files changed, 1217 insertions(+), 45 deletions(-) create mode 100644 apps/client-js/docs/experiment-quick-reference.md create mode 100644 apps/client-js/docs/filtered-playback-experiments.md create mode 100644 apps/client-js/scripts/analyze-switch-baseline.mjs diff --git a/apps/client-js/docs/experiment-quick-reference.md b/apps/client-js/docs/experiment-quick-reference.md new file mode 100644 index 00000000..e5585a16 --- /dev/null +++ b/apps/client-js/docs/experiment-quick-reference.md @@ -0,0 +1,108 @@ +# Experiment Quick Reference + +Copy-paste commands and checklist for running filtered playback baseline experiments. + +## Quick Start (Terminal A) + +```bash +cd ~/Documents/Baylor/Spring\ 26/CSI_5v92_AF/BaylorMultimediaLab/moqtail +mkdir -p logs analysis + +cd apps/client-js +npm run dev +``` + +Wait for output: + +``` + VITE v7.3.1 ready in 500 ms + ➜ Local: http://localhost:5173/ +``` + +## Browser: Connect & Configure + +1. Go to `http://localhost:5173` +2. Relay URL: `https://localhost:4433` +3. Namespace: `moqtail` +4. Click **Connect**, wait for tracks to load +5. Toggle **Filtered Playback** ON +6. Metadata Delay Mode: **Fixed delay** +7. Fixed Metadata Delay: **1200 ms** + +## Browser: Capture (5 min) + +1. Click first video track to start +2. Wait 30 s for stabilization +3. Trigger 3–5 track switches (click different tracks in sidebar every 10 s) +4. Note file timestamp printed in console: `client-metrics_YYYY-MM-DD_HH-MM-SS.csv` + +## Terminal B (or new tab): Analyze + +```bash +# Go to client-js root +cd apps/client-js + +# List captured CSV +ls -lh ../logs/ + +# Short report (replace YYYY-MM-DD_HH-MM-SS with your timestamp) +npm run analyze:switch-baseline -- --csv ../logs/client-metrics_YYYY-MM-DD_HH-MM-SS.csv + +# Full JSON report +npm run analyze:switch-baseline -- \ + --csv ../logs/client-metrics_YYYY-MM-DD_HH-MM-SS.csv \ + --output-json ./analysis/run1_fixed-1200ms.json +``` + +## Variants (repeat capture + analysis for each) + +### 600 ms Fixed + +```bash +# Browser: Set Fixed Metadata Delay to 600 ms +# Capture 5 min, note timestamp +npm run analyze:switch-baseline -- \ + --csv ../logs/client-metrics_.csv \ + --output-json ./analysis/run2_fixed-600ms.json +``` + +### 2000 ms Fixed + +```bash +# Browser: Set Fixed Metadata Delay to 2000 ms +# Capture 5 min, note timestamp +npm run analyze:switch-baseline -- \ + --csv ../logs/client-metrics_.csv \ + --output-json ./analysis/run3_fixed-2000ms.json +``` + +### Variable (800–2000 ms) + +```bash +# Browser: +# - Metadata Delay Mode: Variable delay +# - Min: 800 ms, Max: 2000 ms +# Capture 5 min, note timestamp +npm run analyze:switch-baseline -- \ + --csv ../logs/client-metrics_.csv \ + --output-json ./analysis/run4_variable-800-2000ms.json +``` + +## Summary Table (after all runs) + +| Delay Config | Total Events | Success | Rejected | Error | Jump-FWD | Jump-BCK | Misalign | Discontin | +| ------------ | ------------ | ------- | -------- | ----- | -------- | -------- | -------- | --------- | +| 600 ms | ? | ? | ? | ? | ? | ? | ? | ? | +| 1200 ms | ? | ? | ? | ? | ? | ? | ? | ? | +| 2000 ms | ? | ? | ? | ? | ? | ? | ? | ? | +| Variable | ? | ? | ? | ? | ? | ? | ? | ? | + +## Notes + +- **Jump-forward**: playback delta > +0.35 s +- **Jump-backward**: playback delta < -0.35 s +- **Misalignment**: alignment error > ±0.4 s +- **Discontinuity**: error outcome OR group regression OR live-offset delta > ±1.25 s +- **Behind-live threshold**: 1.5 s (only events when live_offset_s ≥ 1.5 are counted) + +All CSV files auto-saved to `logs/`; all JSON reports in `analysis/`. diff --git a/apps/client-js/docs/filtered-playback-experiments.md b/apps/client-js/docs/filtered-playback-experiments.md new file mode 100644 index 00000000..01bf7de4 --- /dev/null +++ b/apps/client-js/docs/filtered-playback-experiments.md @@ -0,0 +1,341 @@ +# Filtered Playback Baseline Experiments + +This guide reproduces switch behavior while playback is behind live and captures evidence for: + +- jump-forward (positive playback-time discontinuity) +- jump-backward (negative playback-time discontinuity) +- misalignment (large alignment error between switches) +- discontinuity (error outcomes, group regressions, or large live-offset deltas) + +## Prerequisites + +1. **Running relay and publisher:** Ensure you have a moqtail relay running and a publisher streaming to it (e.g., on `https://localhost:4433` in the namespace `moqtail`). +2. **Working directory:** You must be in the moqtail project root. + +## 0. Create Analysis Directory + +```bash +mkdir -p logs analysis +``` + +## 1. Start the Player (Terminal A) + +From project root, navigate to client and start dev: + +```bash +cd apps/client-js +npm run dev +``` + +**Wait for output:** + +``` + VITE v7.3.1 ready in 500 ms + + ➜ Local: http://localhost:5173/ + ➜ Network: use --host to expose +``` + +The Vite middleware now logs all metrics to `logs/client-metrics_YYYY-MM-DD_HH-MM-SS.csv` automatically. + +## 2. Open Player UI (Terminal B, or Browser) + +Open **`http://localhost:5173`** in your browser. + +You should see the MOQtail player sidebar with: + +- Connection fields (Relay URL, Namespace) +- Connect button +- Filtered Playback control section (currently OFF) + +## 3. Configure Connection + +In the browser sidebar: + +1. **Relay URL:** `https://localhost:4433` (or your relay endpoint) +2. **Namespace:** `moqtail` (or your namespace) +3. Click **Connect** + +Wait ~2–5 seconds. You should see: + +- Status dot change to **green** (Catalog loaded) +- Video and Audio track lists populate in the sidebar + +## 4. Enable Filtered Playback + +Once tracks are loaded: + +1. Toggle **Filtered Playback** checkbox to **ON** +2. Select **Metadata Delay Mode:** `Fixed delay` (for determinism) +3. Set **Fixed Metadata Delay:** `1200` ms (recommended starting point) +4. Keep ABR **auto-switch enabled** (do not disable for baseline) + +**Now ready to capture Run 1.** + +## 5. Run Capture — Run 1: 1200 ms Fixed Delay (5 minutes) + +**Goal:** Auto-switch baseline + forced manual switches while behind live. + +### Steps: + +1. **Start playback:** Click the first video track in the sidebar to start playback. + - Observe the video player begin (black background until buffered). + - Wait **30 seconds** for metrics to stabilize. Watch **Playout State** and **Switch Baseline** panels. + +2. **Observe behind-live:** After stabilization, you should see: + - `Live Offset`: ~1–3 seconds (you are behind live edge) + - `Metadata Delay`: increases to ~1200 ms when a group is detected + - `Metadata Ready`: toggles between Yes/No as delay counts down + - Video may pause periodically (gating). + +3. **Trigger switches (Minute 1–4):** Force 3–5 track changes while behind live: + - Disable auto-switch (in Settings panel if you have access, or rely on natural ABR event). + - Click a different **Video** track in the sidebar (e.g., 720p → 1080p → 720p). + - Wait 10 s between each switch. + - **Observe Switch Baseline panel:** You should see fields populate with switch_outcome, playback_delta, alignment_error, etc. + +4. **Note anomalies:** Watch for: + - Video **stalls** (video paused while metadata gate is active) + - **Jumps** in the progress bar (jump-forward or jump-backward) + - **Repeated metadata delays** (indicates regrouping) + +5. **End capture (Minute 5):** Click a track again to stop playback or just let it run. + - **Do not close the browser or stop the dev server yet.** + +### Metrics are now in: `logs/client-metrics_YYYY-MM-DD_HH-MM-SS.csv` + +Note the **timestamp** of the file (you'll need it for analysis). + +## 6. Analyze Run 1 (Terminal B, while dev server is running or after) + +### Step 6a: List captured CSVs + +```bash +ls -lh ../../logs/ +``` + +You should see something like: + +``` +-rw-r--r-- client-metrics_2026-04-09_14-30-45.csv (50–100 KB) +``` + +### Step 6b: Run short report (console output) + +Replace `` with your actual file timestamp: + +```bash +npm run analyze:switch-baseline -- --csv ../../logs/client-metrics_2026-04-09_14-30-45.csv +``` + +You will see a summary like: + +``` +=== Filtered Playback Switch Baseline Report === +CSV: ../../logs/client-metrics_2026-04-09_14-30-45.csv +Events (behind-live only): 8 + +Outcome counts: + success: 5 + rejected: 2 + error: 1 + +Finding counts: + jump-forward: 3 + jump-backward: 2 + misalignment: 4 + discontinuity: 1 + +Sample events: + [1] 2026-04-09T14:30:50.000Z outcome=success issues=jump-forward|misalignment ... + ... +``` + +### Step 6c: Generate full JSON report + +```bash +npm run analyze:switch-baseline -- \ + --csv ../../logs/client-metrics_2026-04-09_14-30-45.csv \ + --output-json ./analysis/run1_fixed-1200ms.json +``` + +This creates `analysis/run1_fixed-1200ms.json` with all events and metadata. + +## 7. (Optional) Run Additional Variants + +Repeat steps 3–6 with different delay configurations to build a comparison matrix. + +### Variant A: 600 ms Delay (Low) + +In browser sidebar: + +- Fixed Metadata Delay: `600` ms +- Repeat capture (~5 min) + +**Analysis:** + +```bash +npm run analyze:switch-baseline -- \ + --csv ../../logs/client-metrics_.csv \ + --output-json ./analysis/run2_fixed-600ms.json +``` + +### Variant B: 2000 ms Delay (High) + +In browser sidebar: + +- Fixed Metadata Delay: `2000` ms +- Repeat capture (~5 min) + +**Analysis:** + +```bash +npm run analyze:switch-baseline -- \ + --csv ../../logs/client-metrics_.csv \ + --output-json ./analysis/run3_fixed-2000ms.json +``` + +### Variant C: Variable Delay (800–2000 ms) + +In browser sidebar: + +- Metadata Delay Mode: `Variable delay` +- Min Delay: `800` ms +- Max Delay: `2000` ms +- Repeat capture (~5 min) + +**Analysis:** + +```bash +npm run analyze:switch-baseline -- \ + --csv ../../logs/client-metrics_.csv \ + --output-json ./analysis/run4_variable-800-2000ms.json +``` + +## 8. Interpretation Rules + +The analyzer **deduplicates** terminal switch events by signature (outcome + track pairs + timestamps). It reports one final event per unique switch **that occurred behind live** (live_offset_s ≥ behind-live-threshold, default 1.5 s). + +**Classification logic:** + +| Issue | Condition | +| --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `jump-forward` | `switch_playback_delta_s > jump-threshold` (default 0.35 s) | +| `jump-backward` | `switch_playback_delta_s < -jump-threshold` (default -0.35 s) | +| `misalignment` | `\|switch_alignment_error_s\| > misalignment-threshold` (default 0.4 s) | +| `discontinuity` | Any of: outcome is `rejected` \| `error`, OR group regression (`switch_group_delta < 0`), OR large live-offset shift (`\|switch_live_offset_delta_s\| > threshold`, default 1.25 s) | + +**Thresholds are tunable** if you want to adjust sensitivity: + +```bash +npm run analyze:switch-baseline -- \ + --csv ../../logs/client-metrics_.csv \ + --jump-threshold 0.50 \ + --misalignment-threshold 0.50 \ + --output-json ./analysis/run1_custom-thresholds.json +``` + +## 9. Summary and Next Steps + +After running one or more variants: + +### Step 9a: Review JSON Reports + +Each JSON report (`analysis/run*.json`) contains: + +```json +{ + "inputCsv": "...", + "thresholds": { ... }, + "summary": { + "totalEvents": 8, + "outcomes": { "success": 5, "rejected": 2, "error": 1, "other": 0 }, + "findings": { "jumpForward": 3, "jumpBackward": 2, "misalignment": 4, "discontinuity": 1 } + }, + "events": [ { "outcome": "success", "issues": ["jump-forward", "misalignment"], ... }, ... ] +} +``` + +### Step 9b: Fill in Findings Summary + +Use this template for your investigation notes: + +```text +EXPERIMENT RESULTS — Filtered Playback Behind-Live Baseline + +Trial 1: 1200 ms Fixed Delay + CSV: logs/client-metrics_2026-04-09_14-30-45.csv + Total Behind-Live Switch Events: 8 + Outcomes: success=5, rejected=2, error=1 + + Findings: + jump-forward: 3 events + jump-backward: 2 events + misalignment: 4 events + discontinuity: 1 event + + Example Events: + - (success) 720→1080 @ playback 18.0s, delta=+0.9s, alignErr=0.9s → JUMP-FORWARD + MISALIGNMENT + - (error) 1080→720 @ playback 17.2s, delta=-0.8s, groupDelta=-3 → JUMP-BACKWARD + DISCONTINUITY + +Trial 2: 600 ms Fixed Delay + CSV: logs/client-metrics_.csv + Total Behind-Live Switch Events: 4 + Outcomes: success=3, rejected=1 + + [Similar format...] + +Trial 3: 2000 ms Fixed Delay + [...] + +COMPARATIVE ANALYSIS: +- Delay = 600 ms: 4 events, [breakdown] +- Delay = 1200 ms: 8 events, [breakdown] +- Delay = 2000 ms: [pending] +- Delay = Variable: [pending] + +HYPOTHESIS: +- Longer delays correlate with more switch events (higher behind-live duration). +- Jump-forward occurs [X] conditions; jump-backward [Y]. +- Primary failure mode: [describe pattern]. + +NEXT MITIGATION: +- Proposal A: [describe Milestone 4 time-alignment strategy] +- Proposal B: [alternative] +``` + +### Step 9c: Inspect Raw CSV (Optional) + +View the full CSV if you need to inspect specific rows: + +```bash +head -20 ../../logs/client-metrics_2026-04-09_14-30-45.csv # First 20 rows (header + samples) +tail -20 ../../logs/client-metrics_2026-04-09_14-30-45.csv # Last 20 rows +wc -l ../../logs/client-metrics_2026-04-09_14-30-45.csv # Total row count +``` + +### Step 9d: Aggregate Multiple Runs (Bash) + +Combine all JSON reports into a single matrix: + +```bash +cd analysis +echo "Run summaries:" > combined-findings.txt +for f in *.json; do + echo "" >> combined-findings.txt + echo "=== $f ===" >> combined-findings.txt + cat "$f" | grep -A 10 '"summary"' >> combined-findings.txt +done +cat combined-findings.txt +``` + +## 10. Summary + +You now have a complete reproducible baseline. Key deliverables: + +1. **CSV logs** in `logs/` with all switch telemetry (raw data). +2. **JSON reports** in `analysis/` with classification and statistics. +3. **Findings** written up per template (for investigation notes). +4. **Thresholds** tunable if classification is too loose or tight. + +**Next phase (Milestone 4):** Use these findings to design and validate time-aligned switching logic that avoids jump-forward/jump-backward during behind-live filtering. diff --git a/apps/client-js/package.json b/apps/client-js/package.json index 3c0e2dac..bea61839 100644 --- a/apps/client-js/package.json +++ b/apps/client-js/package.json @@ -9,7 +9,8 @@ "preview": "vite preview", "format": "prettier --check .", "test": "vitest run", - "test:watch": "vitest" + "test:watch": "vitest", + "analyze:switch-baseline": "node scripts/analyze-switch-baseline.mjs" }, "dependencies": { "@tailwindcss/vite": "^4.2.1", diff --git a/apps/client-js/scripts/analyze-switch-baseline.mjs b/apps/client-js/scripts/analyze-switch-baseline.mjs new file mode 100644 index 00000000..2e1eb7ad --- /dev/null +++ b/apps/client-js/scripts/analyze-switch-baseline.mjs @@ -0,0 +1,337 @@ +#!/usr/bin/env node +import fs from 'node:fs'; +import path from 'node:path'; + +function parseArgs(argv) { + const args = { + csv: null, + help: false, + behindLiveThreshold: 1.5, + jumpThreshold: 0.35, + misalignmentThreshold: 0.4, + discontinuityLiveOffsetThreshold: 1.25, + outputJson: null, + }; + + for (let i = 2; i < argv.length; i++) { + const current = argv[i]; + const next = argv[i + 1]; + + if (current === '--csv' && next) { + args.csv = next; + i++; + continue; + } + if (current === '--help' || current === '-h') { + args.help = true; + continue; + } + if (current === '--behind-live-threshold' && next) { + args.behindLiveThreshold = Number(next); + i++; + continue; + } + if (current === '--jump-threshold' && next) { + args.jumpThreshold = Number(next); + i++; + continue; + } + if (current === '--misalignment-threshold' && next) { + args.misalignmentThreshold = Number(next); + i++; + continue; + } + if (current === '--discontinuity-live-offset-threshold' && next) { + args.discontinuityLiveOffsetThreshold = Number(next); + i++; + continue; + } + if (current === '--output-json' && next) { + args.outputJson = next; + i++; + continue; + } + } + + return args; +} + +function parseCsv(csvText) { + const lines = csvText + .split(/\r?\n/) + .map(line => line.trim()) + .filter(Boolean); + + if (lines.length < 2) { + return []; + } + + const headers = lines[0].split(','); + const rows = []; + for (let i = 1; i < lines.length; i++) { + const values = lines[i].split(','); + const row = {}; + for (let j = 0; j < headers.length; j++) { + row[headers[j]] = values[j] ?? ''; + } + rows.push(row); + } + + return rows; +} + +function num(row, key) { + const raw = row[key]; + if (raw === undefined || raw === null || raw === '') return null; + const value = Number(raw); + return Number.isFinite(value) ? value : null; +} + +function text(row, key) { + const value = row[key]; + return value === undefined || value === null || value === '' ? null : value; +} + +function makeSignature(row) { + const fields = [ + row.switch_outcome ?? '', + row.switch_from_track ?? '', + row.switch_to_track ?? '', + row.switch_requested_at_ms ?? '', + row.switch_settled_at_ms ?? '', + row.switch_duration_ms ?? '', + row.switch_from_group ?? '', + row.switch_to_group ?? '', + row.switch_group_delta ?? '', + row.switch_playback_delta_s ?? '', + row.switch_alignment_error_s ?? '', + ]; + return fields.join('|'); +} + +function classifyEvent(row, thresholds) { + const issues = []; + const playbackDelta = num(row, 'switch_playback_delta_s'); + const alignmentError = num(row, 'switch_alignment_error_s'); + const liveOffsetDelta = num(row, 'switch_live_offset_delta_s'); + const groupDelta = num(row, 'switch_group_delta'); + const outcome = text(row, 'switch_outcome'); + + if (playbackDelta !== null && playbackDelta > thresholds.jumpThreshold) { + issues.push('jump-forward'); + } + if (playbackDelta !== null && playbackDelta < -thresholds.jumpThreshold) { + issues.push('jump-backward'); + } + if (alignmentError !== null && Math.abs(alignmentError) > thresholds.misalignmentThreshold) { + issues.push('misalignment'); + } + + const discontinuity = + outcome === 'error' || + outcome === 'rejected' || + (groupDelta !== null && groupDelta < 0) || + (liveOffsetDelta !== null && + Math.abs(liveOffsetDelta) > thresholds.discontinuityLiveOffsetThreshold); + + if (discontinuity) { + issues.push('discontinuity'); + } + + return { + outcome, + issues, + playbackDelta, + alignmentError, + liveOffsetDelta, + groupDelta, + fromTrack: text(row, 'switch_from_track'), + toTrack: text(row, 'switch_to_track'), + fromGroup: text(row, 'switch_from_group'), + toGroup: text(row, 'switch_to_group'), + switchDurationMs: num(row, 'switch_duration_ms'), + switchRequestedAtMs: num(row, 'switch_requested_at_ms'), + switchSettledAtMs: num(row, 'switch_settled_at_ms'), + liveOffsetAtSwitch: num(row, 'switch_from_live_offset_s'), + timestamp: text(row, 'timestamp'), + }; +} + +function extractTerminalSwitchEvents(rows, behindLiveThreshold) { + const events = []; + let lastSignature = null; + + for (const row of rows) { + const outcome = text(row, 'switch_outcome'); + if (outcome !== 'success' && outcome !== 'rejected' && outcome !== 'error') { + continue; + } + + const liveOffsetAtSwitch = num(row, 'switch_from_live_offset_s'); + if (liveOffsetAtSwitch === null || liveOffsetAtSwitch < behindLiveThreshold) { + continue; + } + + const signature = makeSignature(row); + if (signature === lastSignature) { + continue; + } + + events.push(row); + lastSignature = signature; + } + + return events; +} + +function summarize(events) { + const summary = { + totalEvents: events.length, + outcomes: { + success: 0, + rejected: 0, + error: 0, + other: 0, + }, + findings: { + jumpForward: 0, + jumpBackward: 0, + misalignment: 0, + discontinuity: 0, + }, + }; + + for (const event of events) { + const outcome = event.outcome ?? 'other'; + if (outcome === 'success' || outcome === 'rejected' || outcome === 'error') { + summary.outcomes[outcome]++; + } else { + summary.outcomes.other++; + } + + if (event.issues.includes('jump-forward')) summary.findings.jumpForward++; + if (event.issues.includes('jump-backward')) summary.findings.jumpBackward++; + if (event.issues.includes('misalignment')) summary.findings.misalignment++; + if (event.issues.includes('discontinuity')) summary.findings.discontinuity++; + } + + return summary; +} + +function printReport(inputCsvPath, thresholds, summary, events) { + console.log('=== Filtered Playback Switch Baseline Report ==='); + console.log(`CSV: ${inputCsvPath}`); + console.log(`Events (behind-live only): ${summary.totalEvents}`); + console.log(''); + console.log('Thresholds:'); + console.log(` behindLiveThreshold: ${thresholds.behindLiveThreshold.toFixed(2)} s`); + console.log(` jumpThreshold: ${thresholds.jumpThreshold.toFixed(2)} s`); + console.log(` misalignmentThreshold: ${thresholds.misalignmentThreshold.toFixed(2)} s`); + console.log( + ` discontinuityLiveOffsetThreshold: ${thresholds.discontinuityLiveOffsetThreshold.toFixed(2)} s`, + ); + console.log(''); + + console.log('Outcome counts:'); + console.log(` success: ${summary.outcomes.success}`); + console.log(` rejected: ${summary.outcomes.rejected}`); + console.log(` error: ${summary.outcomes.error}`); + if (summary.outcomes.other > 0) { + console.log(` other: ${summary.outcomes.other}`); + } + console.log(''); + + console.log('Finding counts:'); + console.log(` jump-forward: ${summary.findings.jumpForward}`); + console.log(` jump-backward: ${summary.findings.jumpBackward}`); + console.log(` misalignment: ${summary.findings.misalignment}`); + console.log(` discontinuity: ${summary.findings.discontinuity}`); + console.log(''); + + if (events.length > 0) { + console.log('Sample events:'); + const sampleSize = Math.min(10, events.length); + for (let i = 0; i < sampleSize; i++) { + const event = events[i]; + console.log( + [ + ` [${i + 1}]`, + event.timestamp ?? '-', + `outcome=${event.outcome ?? '-'}`, + `issues=${event.issues.join('|') || 'none'}`, + `from=${event.fromTrack ?? '-'}(${event.fromGroup ?? '-'})`, + `to=${event.toTrack ?? '-'}(${event.toGroup ?? '-'})`, + `durationMs=${event.switchDurationMs ?? '-'}`, + `playbackDelta=${event.playbackDelta ?? '-'}s`, + `alignErr=${event.alignmentError ?? '-'}s`, + `liveOffsetDelta=${event.liveOffsetDelta ?? '-'}s`, + ].join(' '), + ); + } + } +} + +function usage() { + console.log('Usage: node scripts/analyze-switch-baseline.mjs --csv [options]'); + console.log(''); + console.log('Options:'); + console.log(' --behind-live-threshold default: 1.5'); + console.log(' --jump-threshold default: 0.35'); + console.log(' --misalignment-threshold default: 0.4'); + console.log(' --discontinuity-live-offset-threshold default: 1.25'); + console.log(' --output-json write full report JSON'); +} + +function main() { + const args = parseArgs(process.argv); + if (args.help) { + usage(); + process.exit(0); + } + + if (!args.csv) { + usage(); + process.exit(0); + } + + const csvPath = path.resolve(process.cwd(), args.csv); + if (!fs.existsSync(csvPath)) { + console.error(`CSV file not found: ${csvPath}`); + process.exit(1); + } + + const csv = fs.readFileSync(csvPath, 'utf8'); + const rows = parseCsv(csv); + const terminalRows = extractTerminalSwitchEvents(rows, args.behindLiveThreshold); + const events = terminalRows.map(row => + classifyEvent(row, { + jumpThreshold: args.jumpThreshold, + misalignmentThreshold: args.misalignmentThreshold, + discontinuityLiveOffsetThreshold: args.discontinuityLiveOffsetThreshold, + }), + ); + const summary = summarize(events); + + printReport(csvPath, args, summary, events); + + if (args.outputJson) { + const outputPath = path.resolve(process.cwd(), args.outputJson); + const payload = { + inputCsv: csvPath, + thresholds: { + behindLiveThreshold: args.behindLiveThreshold, + jumpThreshold: args.jumpThreshold, + misalignmentThreshold: args.misalignmentThreshold, + discontinuityLiveOffsetThreshold: args.discontinuityLiveOffsetThreshold, + }, + summary, + events, + }; + fs.mkdirSync(path.dirname(outputPath), { recursive: true }); + fs.writeFileSync(outputPath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8'); + console.log(''); + console.log(`Wrote JSON report: ${outputPath}`); + } +} + +main(); diff --git a/apps/client-js/src/app.tsx b/apps/client-js/src/app.tsx index 0a333484..85f04c55 100644 --- a/apps/client-js/src/app.tsx +++ b/apps/client-js/src/app.tsx @@ -89,36 +89,30 @@ function Checkbox({ onChange: (checked: boolean) => void; }) { return ( - - {/* Real input — kept 1 px so it remains in the accessibility tree without position:absolute */} - onChange((e.target as HTMLInputElement).checked)} - /> - {/* Visual indicator */} - - + ); } @@ -657,7 +651,8 @@ export function App() { Filtered Playback

- Simulate moderation latency by pausing playout until metadata is ready for the current video group. + Simulate moderation latency by pausing playout until metadata is ready for the + current video group.

- setModerationDelayMode((e.target as HTMLSelectElement).value as ModerationDelayMode) + setModerationDelayMode( + (e.target as HTMLSelectElement).value as ModerationDelayMode, + ) } disabled={!filteredPlaybackEnabled} class={inputCls} diff --git a/apps/client-js/src/components/MetricsPanel.tsx b/apps/client-js/src/components/MetricsPanel.tsx index 74cd8ccd..afca57d7 100644 --- a/apps/client-js/src/components/MetricsPanel.tsx +++ b/apps/client-js/src/components/MetricsPanel.tsx @@ -99,6 +99,22 @@ const METRIC_DEFS: MetricDef[] = [ color: '#8b5cf6', extract: s => s.metadataDelayMs, }, + { + key: 'switchDuration', + label: 'Switch Duration', + unit: 'ms', + unitLabel: 'ms', + color: '#f43f5e', + extract: s => s.switchDurationMs ?? 0, + }, + { + key: 'switchAlignmentError', + label: 'Switch Alignment Error', + unit: 's', + unitLabel: 'Seconds', + color: '#10b981', + extract: s => s.switchAlignmentErrorSeconds ?? 0, + }, ]; const METRIC_MAP = new Map(METRIC_DEFS.map(d => [d.key, d])); @@ -363,6 +379,58 @@ export function MetricsPanel({ metrics, snapshot, tracks }: MetricsPanelProps) { + {/* ── Switch Baseline ── */} +
+

+ Switch Baseline +

+
+ + + + + + + + + + +
+
+ {/* ── Transport Metrics ── */}

diff --git a/apps/client-js/src/lib/abr/AbrController.ts b/apps/client-js/src/lib/abr/AbrController.ts index e1dc2e09..53cfa81c 100644 --- a/apps/client-js/src/lib/abr/AbrController.ts +++ b/apps/client-js/src/lib/abr/AbrController.ts @@ -27,6 +27,22 @@ export interface AbrMetrics { pendingSwitchTrack: string | null; metadataReady: boolean; metadataDelayMs: number; + switchOutcome: 'idle' | 'pending' | 'success' | 'rejected' | 'error'; + switchFromTrack: string | null; + switchToTrack: string | null; + switchRequestedAtMs: number | null; + switchSettledAtMs: number | null; + switchDurationMs: number | null; + switchFromPlaybackTime: number | null; + switchToPlaybackTime: number | null; + switchPlaybackDeltaSeconds: number | null; + switchFromLiveOffsetSeconds: number | null; + switchToLiveOffsetSeconds: number | null; + switchLiveOffsetDeltaSeconds: number | null; + switchFromGroup: string | null; + switchToGroup: string | null; + switchGroupDelta: number | null; + switchAlignmentErrorSeconds: number | null; switchHistory: SwitchEvent[]; mode: 'auto' | 'manual'; switching: boolean; @@ -119,6 +135,22 @@ export class AbrController { pendingSwitchTrack, metadataReady, metadataDelayMs, + switchOutcome, + switchFromTrack, + switchToTrack, + switchRequestedAtMs, + switchSettledAtMs, + switchDurationMs, + switchFromPlaybackTime, + switchToPlaybackTime, + switchPlaybackDeltaSeconds, + switchFromLiveOffsetSeconds, + switchToLiveOffsetSeconds, + switchLiveOffsetDeltaSeconds, + switchFromGroup, + switchToGroup, + switchGroupDelta, + switchAlignmentErrorSeconds, } = raw; // Find the active track index in the sorted tracks array @@ -145,6 +177,22 @@ export class AbrController { pendingSwitchTrack, metadataReady, metadataDelayMs, + switchOutcome, + switchFromTrack, + switchToTrack, + switchRequestedAtMs, + switchSettledAtMs, + switchDurationMs, + switchFromPlaybackTime, + switchToPlaybackTime, + switchPlaybackDeltaSeconds, + switchFromLiveOffsetSeconds, + switchToLiveOffsetSeconds, + switchLiveOffsetDeltaSeconds, + switchFromGroup, + switchToGroup, + switchGroupDelta, + switchAlignmentErrorSeconds, switchHistory: [...this.#switchHistory], mode, switching: this.#switching, diff --git a/apps/client-js/src/lib/abr/__tests__/AbrController.test.ts b/apps/client-js/src/lib/abr/__tests__/AbrController.test.ts index 3a22fb0b..b2258392 100644 --- a/apps/client-js/src/lib/abr/__tests__/AbrController.test.ts +++ b/apps/client-js/src/lib/abr/__tests__/AbrController.test.ts @@ -50,6 +50,22 @@ function makePlayerMetrics(overrides: Partial; @@ -95,6 +116,22 @@ export interface PlayerMetrics { pendingSwitchTrack: string | null; metadataReady: boolean; metadataDelayMs: number; + switchOutcome: SwitchOutcome; + switchFromTrack: string | null; + switchToTrack: string | null; + switchRequestedAtMs: number | null; + switchSettledAtMs: number | null; + switchDurationMs: number | null; + switchFromPlaybackTime: number | null; + switchToPlaybackTime: number | null; + switchPlaybackDeltaSeconds: number | null; + switchFromLiveOffsetSeconds: number | null; + switchToLiveOffsetSeconds: number | null; + switchLiveOffsetDeltaSeconds: number | null; + switchFromGroup: string | null; + switchToGroup: string | null; + switchGroupDelta: number | null; + switchAlignmentErrorSeconds: number | null; } export class Player { @@ -106,6 +143,24 @@ export class Player { #streams: MOQStreamStruct[] = []; #metadataReady = true; #metadataDelayMs = 0; + #switchTelemetry: SwitchTelemetry = { + outcome: 'idle', + fromTrack: null, + toTrack: null, + requestedAtMs: null, + settledAtMs: null, + durationMs: null, + fromPlaybackTime: null, + toPlaybackTime: null, + playbackDeltaSeconds: null, + fromLiveOffsetSeconds: null, + toLiveOffsetSeconds: null, + liveOffsetDeltaSeconds: null, + fromGroup: null, + toGroup: null, + groupDelta: null, + alignmentErrorSeconds: null, + }; #options: Required> & Pick; @@ -183,6 +238,24 @@ export class Player { this.#element = null; this.#mse = undefined; this.#streams = []; + this.#switchTelemetry = { + outcome: 'idle', + fromTrack: null, + toTrack: null, + requestedAtMs: null, + settledAtMs: null, + durationMs: null, + fromPlaybackTime: null, + toPlaybackTime: null, + playbackDeltaSeconds: null, + fromLiveOffsetSeconds: null, + toLiveOffsetSeconds: null, + liveOffsetDeltaSeconds: null, + fromGroup: null, + toGroup: null, + groupDelta: null, + alignmentErrorSeconds: null, + }; } async attachMedia(element: HTMLVideoElement) { @@ -342,6 +415,50 @@ export class Player { struct.trackName = newTrackName; struct.pendingSwitch = null; + const settledAtMs = Date.now(); + const toPlaybackTime = this.#element ? this.#element.currentTime : null; + const toLiveEdgeTime = + this.#element?.buffered && this.#element.buffered.length > 0 + ? this.#element.buffered.end(this.#element.buffered.length - 1) + : null; + const toLiveOffsetSeconds = + toLiveEdgeTime !== null && toPlaybackTime !== null + ? Math.max(0, toLiveEdgeTime - toPlaybackTime) + : null; + const fromGroupBigInt = + this.#switchTelemetry.fromGroup !== null + ? BigInt(this.#switchTelemetry.fromGroup) + : null; + const groupDelta = + fromGroupBigInt !== null ? Number(object.location.group - fromGroupBigInt) : null; + const playbackDeltaSeconds = + this.#switchTelemetry.fromPlaybackTime !== null && toPlaybackTime !== null + ? toPlaybackTime - this.#switchTelemetry.fromPlaybackTime + : null; + const liveOffsetDeltaSeconds = + this.#switchTelemetry.fromLiveOffsetSeconds !== null && toLiveOffsetSeconds !== null + ? toLiveOffsetSeconds - this.#switchTelemetry.fromLiveOffsetSeconds + : null; + const durationMs = + this.#switchTelemetry.requestedAtMs !== null + ? settledAtMs - this.#switchTelemetry.requestedAtMs + : null; + + this.#switchTelemetry = { + ...this.#switchTelemetry, + outcome: 'success', + settledAtMs, + durationMs, + toPlaybackTime, + playbackDeltaSeconds, + toLiveOffsetSeconds, + liveOffsetDeltaSeconds, + toGroup: object.location.group.toString(), + groupDelta, + alignmentErrorSeconds: + playbackDeltaSeconds !== null ? Math.abs(playbackDeltaSeconds) : null, + }; + // changeType() must not be called while the SourceBuffer is updating if (sourceBuffer.updating) await waitForBufferUpdate(sourceBuffer); try { @@ -354,6 +471,11 @@ export class Player { `switchTrack: failed to apply init segment for ${newTrackName}:`, switchError, ); + this.#switchTelemetry = { + ...this.#switchTelemetry, + outcome: 'error', + settledAtMs: Date.now(), + }; // Release the guard and abort the write stream — the source buffer // may be in an inconsistent state after a partial changeType/append. this.#options.onTrackSwitched?.(newTrackName); @@ -455,6 +577,22 @@ export class Player { pendingSwitchTrack: videoStruct?.pendingSwitch?.trackName ?? null, metadataReady: this.#metadataReady, metadataDelayMs: this.#metadataDelayMs, + switchOutcome: this.#switchTelemetry.outcome, + switchFromTrack: this.#switchTelemetry.fromTrack, + switchToTrack: this.#switchTelemetry.toTrack, + switchRequestedAtMs: this.#switchTelemetry.requestedAtMs, + switchSettledAtMs: this.#switchTelemetry.settledAtMs, + switchDurationMs: this.#switchTelemetry.durationMs, + switchFromPlaybackTime: this.#switchTelemetry.fromPlaybackTime, + switchToPlaybackTime: this.#switchTelemetry.toPlaybackTime, + switchPlaybackDeltaSeconds: this.#switchTelemetry.playbackDeltaSeconds, + switchFromLiveOffsetSeconds: this.#switchTelemetry.fromLiveOffsetSeconds, + switchToLiveOffsetSeconds: this.#switchTelemetry.toLiveOffsetSeconds, + switchLiveOffsetDeltaSeconds: this.#switchTelemetry.liveOffsetDeltaSeconds, + switchFromGroup: this.#switchTelemetry.fromGroup, + switchToGroup: this.#switchTelemetry.toGroup, + switchGroupDelta: this.#switchTelemetry.groupDelta, + switchAlignmentErrorSeconds: this.#switchTelemetry.alignmentErrorSeconds, }; } @@ -506,6 +644,11 @@ export class Player { if (!initData || !role || !codec) { logger.error('media', `switchTrack: missing catalog data for track ${trackName}`); + this.#switchTelemetry = { + ...this.#switchTelemetry, + outcome: 'error', + settledAtMs: Date.now(), + }; this.#options.onTrackSwitched?.(videoStruct.trackName); return; } @@ -524,6 +667,11 @@ export class Player { `switchTrack: SWITCH rejected for ${trackName}:`, result.errorReason.phrase, ); + this.#switchTelemetry = { + ...this.#switchTelemetry, + outcome: 'rejected', + settledAtMs: Date.now(), + }; this.#options.onTrackSwitched?.(videoStruct.trackName); return; } @@ -540,9 +688,42 @@ export class Player { // guard) is NOT called here — it fires in the write handler AFTER the relay // has actually delivered data on the new track. This prevents rapid // consecutive SWITCH messages that corrupt the relay's switch context. + const requestedAtMs = Date.now(); + const fromPlaybackTime = this.#element ? this.#element.currentTime : null; + const fromLiveEdgeTime = + this.#element?.buffered && this.#element.buffered.length > 0 + ? this.#element.buffered.end(this.#element.buffered.length - 1) + : null; + const fromLiveOffsetSeconds = + fromLiveEdgeTime !== null && fromPlaybackTime !== null + ? Math.max(0, fromLiveEdgeTime - fromPlaybackTime) + : null; videoStruct.pendingSwitch = { trackName, initData: initData.buffer as ArrayBuffer, mimeType }; + this.#switchTelemetry = { + outcome: 'pending', + fromTrack: videoStruct.trackName, + toTrack: trackName, + requestedAtMs, + settledAtMs: null, + durationMs: null, + fromPlaybackTime, + toPlaybackTime: null, + playbackDeltaSeconds: null, + fromLiveOffsetSeconds, + toLiveOffsetSeconds: null, + liveOffsetDeltaSeconds: null, + fromGroup: videoStruct.lastGroupId >= 0n ? videoStruct.lastGroupId.toString() : null, + toGroup: null, + groupDelta: null, + alignmentErrorSeconds: null, + }; } catch (error) { logger.error('media', 'switchTrack: unexpected error', error); + this.#switchTelemetry = { + ...this.#switchTelemetry, + outcome: 'error', + settledAtMs: Date.now(), + }; this.#options.onTrackSwitched?.(videoStruct.trackName); } } diff --git a/apps/client-js/vite.config.ts b/apps/client-js/vite.config.ts index 9cbc4138..213dba45 100644 --- a/apps/client-js/vite.config.ts +++ b/apps/client-js/vite.config.ts @@ -25,25 +25,36 @@ function metricsLogPlugin(): Plugin { } let body = ''; - req.on('data', (chunk: Buffer) => { body += chunk.toString(); }); + req.on('data', (chunk: Buffer) => { + body += chunk.toString(); + }); req.on('end', () => { if (!logPath) { fs.mkdirSync(logDir, { recursive: true }); - const ts = new Date().toISOString().replace(/[:.]/g, '-').replace('T', '_').slice(0, 19); + const ts = new Date() + .toISOString() + .replace(/[:.]/g, '-') + .replace('T', '_') + .slice(0, 19); logPath = path.join(logDir, `client-metrics_${ts}.csv`); } - if (!headerWritten) { - const header = body.split('\n')[0]; - if (header) { - fs.appendFileSync(logPath, header + '\n'); - headerWritten = true; - } + const lines = body.split('\n'); + const firstLine = lines[0]?.trim() ?? ''; + + // First line is treated as header only when it looks like a CSV header row. + const firstLineIsHeader = firstLine.startsWith('timestamp,'); + if (firstLineIsHeader && !headerWritten && firstLine) { + fs.appendFileSync(logPath, firstLine + '\n'); + headerWritten = true; } - // Append data lines (skip header if present) - const lines = body.split('\n'); - const dataLines = lines.slice(headerWritten ? 1 : 0).filter(l => l.length > 0); + // Append only data lines. If this payload starts with a header, skip exactly that line. + const dataLines = lines + .slice(firstLineIsHeader ? 1 : 0) + .map(line => line.trim()) + .filter(line => line.length > 0); + if (dataLines.length > 0) { fs.appendFileSync(logPath, dataLines.join('\n') + '\n'); }