cat / goq
clone

turns a linux box into a private steam streaming appliance: plug in, tap your key, play from another computer

fix/architecture-review-findings
goq / portal / stream-metrics.mjs
stream-metrics.mjs 11.2K
1export const STREAM_RATE_WINDOW_MS = 1000;2export const MAX_STREAM_RATE_SAMPLES = 240;3export const CADENCE_HITCH_25_MS = 25;4export const CADENCE_HITCH_33_333_MS = 33.333;56/**7 * A bounded, monotonic rolling-rate estimator. Callers supply their own clock8 * so the same implementation is deterministic in tests and uses9 * performance.now() in the webview.10 */11export class RollingRateWindow {12 constructor(windowMs = STREAM_RATE_WINDOW_MS, maxSamples = MAX_STREAM_RATE_SAMPLES) {13 if (!Number.isFinite(windowMs) || windowMs <= 0) throw new RangeError('windowMs must be positive');14 if (!Number.isInteger(maxSamples) || maxSamples < 2) throw new RangeError('maxSamples must be at least two');15 this.windowMs = windowMs;16 this.maxSamples = maxSamples;17 this.samples = [];18 }1920 record(nowMs) {21 if (!Number.isFinite(nowMs)) throw new TypeError('sample time must be finite');22 const previous = this.samples.at(-1);23 if (previous !== undefined && nowMs < previous) throw new RangeError('sample time must be monotonic');24 this.samples.push(nowMs);25 if (this.samples.length > this.maxSamples) {26 this.samples.splice(0, this.samples.length - this.maxSamples);27 }28 this.#prune(nowMs);29 }3031 rate(nowMs) {32 if (!Number.isFinite(nowMs)) throw new TypeError('query time must be finite');33 this.#prune(nowMs);34 if (this.samples.length < 2) return 0;35 const first = this.samples[0];36 const last = this.samples.at(-1);37 const elapsedMs = last - first;38 return elapsedMs > 0 ? ((this.samples.length - 1) * 1000) / elapsedMs : 0;39 }4041 reset() {42 this.samples.length = 0;43 }4445 #prune(nowMs) {46 const cutoff = nowMs - this.windowMs;47 let remove = 0;48 while (remove < this.samples.length && this.samples[remove] < cutoff) remove++;49 if (remove > 0) this.samples.splice(0, remove);50 }51}5253export class BoundedLatencyWindow {54 constructor(windowMs = 2000, maxSamples = 512) {55 if (!Number.isFinite(windowMs) || windowMs <= 0) throw new RangeError('windowMs must be positive');56 if (!Number.isInteger(maxSamples) || maxSamples < 1) throw new RangeError('maxSamples must be positive');57 this.windowMs = windowMs;58 this.maxSamples = maxSamples;59 this.samples = [];60 }6162 record(valueMs, nowMs) {63 if (!Number.isFinite(valueMs) || valueMs < 0) throw new RangeError('latency must be finite and non-negative');64 if (!Number.isFinite(nowMs)) throw new TypeError('sample time must be finite');65 const previous = this.samples.at(-1);66 if (previous && nowMs < previous.time) throw new RangeError('sample time must be monotonic');67 this.samples.push({ time: nowMs, value: valueMs });68 if (this.samples.length > this.maxSamples) {69 this.samples.splice(0, this.samples.length - this.maxSamples);70 }71 this.#prune(nowMs);72 }7374 summary(nowMs) {75 if (!Number.isFinite(nowMs)) throw new TypeError('query time must be finite');76 this.#prune(nowMs);77 if (this.samples.length === 0) {78 return { p50: null, p95: null, p99: null, max: null, count: 0 };79 }80 const values = this.samples.map((sample) => sample.value).sort((a, b) => a - b);81 return {82 p50: values[Math.ceil(values.length * 0.50) - 1],83 p95: values[Math.ceil(values.length * 0.95) - 1],84 p99: values[Math.ceil(values.length * 0.99) - 1],85 max: values.at(-1),86 count: values.length,87 };88 }8990 reset() {91 this.samples.length = 0;92 }9394 #prune(nowMs) {95 const cutoff = nowMs - this.windowMs;96 let remove = 0;97 while (remove < this.samples.length && this.samples[remove].time < cutoff) remove++;98 if (remove > 0) this.samples.splice(0, remove);99 }100}101102export class BoundedValueWindow {103 constructor(windowMs = 5000, maxSamples = 512) {104 if (!Number.isFinite(windowMs) || windowMs <= 0) throw new RangeError('windowMs must be positive');105 if (!Number.isInteger(maxSamples) || maxSamples < 1) throw new RangeError('maxSamples must be positive');106 this.windowMs = windowMs;107 this.maxSamples = maxSamples;108 this.samples = [];109 }110111 record(value, nowMs) {112 if (!Number.isFinite(value)) throw new TypeError('value must be finite');113 if (!Number.isFinite(nowMs)) throw new TypeError('sample time must be finite');114 const previous = this.samples.at(-1);115 if (previous && nowMs < previous.time) throw new RangeError('sample time must be monotonic');116 this.samples.push({ time: nowMs, value });117 if (this.samples.length > this.maxSamples) {118 this.samples.splice(0, this.samples.length - this.maxSamples);119 }120 this.#prune(nowMs);121 }122123 summary(nowMs) {124 if (!Number.isFinite(nowMs)) throw new TypeError('query time must be finite');125 this.#prune(nowMs);126 if (this.samples.length === 0) {127 return { p50: null, p95: null, maxAbsolute: null, count: 0 };128 }129 const values = this.samples.map((sample) => sample.value).sort((a, b) => a - b);130 return {131 p50: values[Math.ceil(values.length * 0.50) - 1],132 p95: values[Math.ceil(values.length * 0.95) - 1],133 maxAbsolute: Math.max(...values.map(Math.abs)),134 count: values.length,135 };136 }137138 reset() {139 this.samples.length = 0;140 }141142 #prune(nowMs) {143 const cutoff = nowMs - this.windowMs;144 let remove = 0;145 while (remove < this.samples.length && this.samples[remove].time < cutoff) remove++;146 if (remove > 0) this.samples.splice(0, remove);147 }148}149150/**151 * A bounded rolling distribution of presentation intervals. The first152 * timestamp establishes the cadence anchor; each later timestamp contributes153 * one interval ending at that timestamp.154 */155export class BoundedCadenceWindow {156 constructor(windowMs = 5000, maxSamples = 512) {157 if (!Number.isFinite(windowMs) || windowMs <= 0) throw new RangeError('windowMs must be positive');158 if (!Number.isInteger(maxSamples) || maxSamples < 1) throw new RangeError('maxSamples must be positive');159 this.windowMs = windowMs;160 this.maxSamples = maxSamples;161 this.samples = [];162 this.lastSampleTimeMs = null;163 }164165 record(nowMs) {166 if (!Number.isFinite(nowMs)) throw new TypeError('sample time must be finite');167 if (this.lastSampleTimeMs !== null && nowMs < this.lastSampleTimeMs) {168 throw new RangeError('sample time must be monotonic');169 }170 if (this.lastSampleTimeMs !== null171 && nowMs - this.lastSampleTimeMs <= this.windowMs) {172 this.samples.push({ time: nowMs, value: nowMs - this.lastSampleTimeMs });173 if (this.samples.length > this.maxSamples) {174 this.samples.splice(0, this.samples.length - this.maxSamples);175 }176 }177 this.lastSampleTimeMs = nowMs;178 this.#prune(nowMs);179 }180181 summary(nowMs) {182 if (!Number.isFinite(nowMs)) throw new TypeError('query time must be finite');183 if (this.lastSampleTimeMs !== null && nowMs < this.lastSampleTimeMs) {184 throw new RangeError('query time must be monotonic');185 }186 this.#prune(nowMs);187 if (this.samples.length === 0) {188 return {189 p50: null,190 p95: null,191 p99: null,192 max: null,193 count: 0,194 over25Ms: 0,195 over33Ms: 0,196 };197 }198 const values = this.samples.map((sample) => sample.value).sort((a, b) => a - b);199 return {200 p50: values[Math.ceil(values.length * 0.50) - 1],201 p95: values[Math.ceil(values.length * 0.95) - 1],202 p99: values[Math.ceil(values.length * 0.99) - 1],203 max: values.at(-1),204 count: values.length,205 over25Ms: values.filter((value) => value > CADENCE_HITCH_25_MS).length,206 over33Ms: values.filter((value) => value > CADENCE_HITCH_33_333_MS).length,207 };208 }209210 reset() {211 this.samples.length = 0;212 this.lastSampleTimeMs = null;213 }214215 #prune(nowMs) {216 const cutoff = nowMs - this.windowMs;217 let remove = 0;218 while (remove < this.samples.length && this.samples[remove].time < cutoff) remove++;219 if (remove > 0) this.samples.splice(0, remove);220 }221}222223export class LatestFramePresenter {224 constructor({225 requestFrame,226 cancelFrame,227 setTimer,228 cancelTimer,229 now,230 fallbackDelayMs = 25,231 draw,232 onPresent = () => {},233 onDrop = () => {},234 }) {235 for (const [name, value] of Object.entries({236 requestFrame,237 cancelFrame,238 setTimer,239 cancelTimer,240 now,241 draw,242 })) {243 if (typeof value !== 'function') throw new TypeError(`${name} must be a function`);244 }245 if (!Number.isFinite(fallbackDelayMs) || fallbackDelayMs < 1 || fallbackDelayMs > 1000) {246 throw new RangeError('fallbackDelayMs must be within 1..1000 ms');247 }248 this.requestFrame = requestFrame;249 this.cancelFrame = cancelFrame;250 this.setTimer = setTimer;251 this.cancelTimer = cancelTimer;252 this.now = now;253 this.fallbackDelayMs = fallbackDelayMs;254 this.draw = draw;255 this.onPresent = onPresent;256 this.onDrop = onDrop;257 // Keep one frame for the next display refresh and one frame of jitter258 // tolerance. Decoder output commonly arrives in pairs even when its259 // average rate matches the display; a single latest-frame slot turns260 // those pairs into an avoidable 30–40 fps presentation cadence. The hard261 // two-frame ceiling still bounds latency, and overload evicts the oldest262 // stale frame first.263 this.pending = [];264 this.activeSchedule = null;265 }266267 enqueue(frame, metadata = null) {268 if (!frame || typeof frame.close !== 'function') throw new TypeError('frame must be closeable');269 if (this.pending.length >= 2) {270 const stale = this.pending.shift();271 stale.frame.close();272 this.onDrop(stale.metadata);273 }274 this.pending.push({ frame, metadata });275 this.#schedule();276 }277278 #schedule() {279 if (this.activeSchedule !== null || this.pending.length === 0) return;280 const schedule = {281 settled: false,282 frameHandle: null,283 timerHandle: null,284 };285 this.activeSchedule = schedule;286 const frameHandle = this.requestFrame((nowMs) => {287 this.#presentScheduled(schedule, 'frame', nowMs);288 });289 if (this.activeSchedule !== schedule || schedule.settled) return;290 schedule.frameHandle = frameHandle;291 const timerHandle = this.setTimer(() => {292 this.#presentScheduled(schedule, 'timer', this.now());293 }, this.fallbackDelayMs);294 if (this.activeSchedule === schedule && !schedule.settled) {295 schedule.timerHandle = timerHandle;296 } else {297 this.cancelTimer(timerHandle);298 }299 }300301 #presentScheduled(schedule, source, nowMs) {302 if (this.activeSchedule !== schedule || schedule.settled) return;303 schedule.settled = true;304 this.activeSchedule = null;305 if (source === 'frame') {306 if (schedule.timerHandle !== null) this.cancelTimer(schedule.timerHandle);307 } else if (schedule.frameHandle !== null) {308 this.cancelFrame(schedule.frameHandle);309 }310 const pending = this.pending.shift();311 if (!pending) return;312 try {313 this.draw(pending.frame);314 this.onPresent(pending.metadata, nowMs);315 } finally {316 pending.frame.close();317 this.#schedule();318 }319 }320321 clear() {322 const schedule = this.activeSchedule;323 this.activeSchedule = null;324 if (schedule !== null) {325 schedule.settled = true;326 if (schedule.frameHandle !== null) this.cancelFrame(schedule.frameHandle);327 if (schedule.timerHandle !== null) this.cancelTimer(schedule.timerHandle);328 }329 for (const pending of this.pending.splice(0)) pending.frame.close();330 }331332 get depth() {333 return this.pending.length;334 }335}