cat / goq
clone

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

fix/website-download-wiring
goq / portal / adaptive-feedback.mjs
adaptive-feedback.mjs 10.4K
1export const ADAPTIVE_FEEDBACK_INTERVAL_MS = 1000;2export const ADAPTIVE_FEEDBACK_INTERVAL_MIN_MS = 250;3export const ADAPTIVE_FEEDBACK_INTERVAL_MAX_MS = 5000;4export const ADAPTIVE_QUEUE_DEPTH_MAX = 16;5export const ADAPTIVE_COUNTER_DELTA_MAX = 0xffff_ffff;6export const ADAPTIVE_LATENCY_MS_MAX = 60_000;78function boundedInteger(value, maximum, name) {9 if (!Number.isSafeInteger(value) || value < 0) {10 throw new TypeError(`${name} must be a non-negative safe integer`);11 }12 return Math.min(value, maximum);13}1415function boundedLatency(value, name) {16 if (value === null || value === undefined) return null;17 if (!Number.isFinite(value) || value < 0) {18 throw new TypeError(`${name} must be finite and non-negative`);19 }20 return Math.min(value, ADAPTIVE_LATENCY_MS_MAX);21}2223function boundedQueue(depth, capacity, name) {24 const safeCapacity = boundedInteger(capacity, ADAPTIVE_QUEUE_DEPTH_MAX, `${name} capacity`);25 if (safeCapacity < 1) throw new RangeError(`${name} capacity must be positive`);26 return {27 depth: Math.min(boundedInteger(depth, ADAPTIVE_QUEUE_DEPTH_MAX, `${name} depth`), safeCapacity),28 capacity: safeCapacity,29 };30}3132/**33 * Strictly normalize the small, bounded snapshot that crosses the Tauri34 * command boundary. Cumulative counters never cross the wire; the publisher35 * converts them to saturating interval deltas.36 */37export function normalizeAdaptiveFeedbackSnapshot(snapshot) {38 if (!snapshot || typeof snapshot !== 'object') throw new TypeError('feedback snapshot is required');39 const frontend = boundedQueue(40 snapshot.frontendQueueDepth,41 snapshot.frontendQueueCapacity,42 'frontend queue',43 );44 const decoder = boundedQueue(45 snapshot.decoderQueueDepth,46 snapshot.decoderQueueCapacity,47 'decoder queue',48 );49 const presenter = boundedQueue(50 snapshot.presenterQueueDepth,51 snapshot.presenterQueueCapacity,52 'presenter queue',53 );54 if (typeof snapshot.resyncActive !== 'boolean') {55 throw new TypeError('resyncActive must be a boolean');56 }57 return {58 last_sequence: snapshot.lastSequence === null || snapshot.lastSequence === undefined59 ? null60 : boundedInteger(snapshot.lastSequence, Number.MAX_SAFE_INTEGER, 'last sequence'),61 frontend_queue_depth: frontend.depth,62 frontend_queue_capacity: frontend.capacity,63 decode_queue_depth: decoder.depth,64 decode_queue_capacity: decoder.capacity,65 presenter_queue_depth: presenter.depth,66 presenter_queue_capacity: presenter.capacity,67 transport_dropped_total: boundedInteger(68 snapshot.transportDroppedTotal,69 Number.MAX_SAFE_INTEGER,70 'transport dropped total',71 ),72 frontend_dropped_total: boundedInteger(73 snapshot.frontendDroppedTotal,74 Number.MAX_SAFE_INTEGER,75 'frontend dropped total',76 ),77 decoder_dropped_total: boundedInteger(78 snapshot.decoderDroppedTotal,79 Number.MAX_SAFE_INTEGER,80 'decoder dropped total',81 ),82 presenter_dropped_total: boundedInteger(83 snapshot.presenterDroppedTotal,84 Number.MAX_SAFE_INTEGER,85 'presenter dropped total',86 ),87 transport_delivery_p95_ms: boundedLatency(88 snapshot.transportDeliveryP95Ms,89 'transport delivery p95',90 ),91 decode_p95_ms: boundedLatency(snapshot.decodeLatencyP95Ms, 'decode p95'),92 presentation_p95_ms: boundedLatency(93 snapshot.presentationLatencyP95Ms,94 'presentation p95',95 ),96 resync_active: snapshot.resyncActive,97 };98}99100function saturatingDelta(current, previous) {101 if (current < previous) return 0;102 return Math.min(current - previous, ADAPTIVE_COUNTER_DELTA_MAX);103}104105function counterBaseline(snapshot) {106 return {107 transport: snapshot.transport_dropped_total,108 frontend: snapshot.frontend_dropped_total,109 decoder: snapshot.decoder_dropped_total,110 presenter: snapshot.presenter_dropped_total,111 };112}113114function intervalReport(snapshot, baseline, intervalMs) {115 return {116 interval_ms: intervalMs,117 last_sequence: snapshot.last_sequence,118 frontend_queue_depth: snapshot.frontend_queue_depth,119 frontend_queue_capacity: snapshot.frontend_queue_capacity,120 decode_queue_depth: snapshot.decode_queue_depth,121 decode_queue_capacity: snapshot.decode_queue_capacity,122 presenter_queue_depth: snapshot.presenter_queue_depth,123 presenter_queue_capacity: snapshot.presenter_queue_capacity,124 transport_dropped_delta: saturatingDelta(snapshot.transport_dropped_total, baseline.transport),125 frontend_dropped_delta: saturatingDelta(snapshot.frontend_dropped_total, baseline.frontend),126 decoder_dropped_delta: saturatingDelta(snapshot.decoder_dropped_total, baseline.decoder),127 presenter_dropped_delta: saturatingDelta(snapshot.presenter_dropped_total, baseline.presenter),128 transport_delivery_p95_ms: snapshot.transport_delivery_p95_ms,129 decode_p95_ms: snapshot.decode_p95_ms,130 presentation_p95_ms: snapshot.presentation_p95_ms,131 resync_active: snapshot.resync_active,132 };133}134135/** Generation-scoped, at-most-1 Hz, single-flight adaptive feedback sender. */136export class AdaptiveFeedbackPublisher {137 #invoke;138 #now;139 #generation = null;140 #available = false;141 #inFlight = false;142 #lastStartedAt = Number.NEGATIVE_INFINITY;143 #baselineAt = null;144 #baseline = { transport: 0, frontend: 0, decoder: 0, presenter: 0 };145 #latest = null;146147 constructor({ invokeCommand, now = () => performance.now() }) {148 if (typeof invokeCommand !== 'function') throw new TypeError('invokeCommand must be a function');149 if (typeof now !== 'function') throw new TypeError('now must be a function');150 this.#invoke = invokeCommand;151 this.#now = now;152 }153154 start(generation, available) {155 if (!Number.isSafeInteger(generation) || generation < 1) {156 throw new TypeError('feedback generation must be a positive safe integer');157 }158 if (typeof available !== 'boolean') throw new TypeError('feedback availability must be boolean');159 this.#generation = generation;160 this.#available = available;161 this.#inFlight = false;162 this.#lastStartedAt = Number.NEGATIVE_INFINITY;163 this.#baselineAt = null;164 this.#baseline = { transport: 0, frontend: 0, decoder: 0, presenter: 0 };165 this.#latest = null;166 }167168 stop() {169 this.#generation = null;170 this.#available = false;171 this.#inFlight = false;172 this.#baselineAt = null;173 this.#latest = null;174 }175176 get available() { return this.#available; }177 get inFlight() { return this.#inFlight; }178179 publish(rawSnapshot) {180 if (!this.#available || this.#generation === null) return false;181 const snapshot = normalizeAdaptiveFeedbackSnapshot(rawSnapshot);182 this.#latest = snapshot;183 const now = this.#now();184 if (!Number.isFinite(now)) throw new TypeError('feedback clock must be finite');185 if (this.#baselineAt === null) {186 // Counters can advance while the native connect command is still187 // returning. Establish the first baseline after the generation is live188 // so pre-generation pressure is never mislabeled as a short interval.189 this.#baseline = counterBaseline(snapshot);190 this.#baselineAt = now;191 return false;192 }193 const elapsed = now - this.#baselineAt;194 if (!Number.isFinite(elapsed) || elapsed < 0) {195 throw new TypeError('feedback interval must be finite and non-negative');196 }197 if (this.#inFlight || now - this.#lastStartedAt < ADAPTIVE_FEEDBACK_INTERVAL_MS) return false;198 if (elapsed < ADAPTIVE_FEEDBACK_INTERVAL_MIN_MS) return false;199200 const generation = this.#generation;201 const intervalMs = Math.min(202 Math.max(Math.round(elapsed), ADAPTIVE_FEEDBACK_INTERVAL_MIN_MS),203 ADAPTIVE_FEEDBACK_INTERVAL_MAX_MS,204 );205 const report = intervalReport(snapshot, this.#baseline, intervalMs);206 this.#inFlight = true;207 let invocation;208 try {209 invocation = this.#invoke('iroh_client_send_media_feedback', { generation, report });210 } catch (error) {211 this.#inFlight = false;212 console.warn('adaptive feedback failed:', error);213 return false;214 }215 this.#lastStartedAt = now;216 Promise.resolve(invocation)217 .then((accepted) => {218 if (generation === this.#generation && accepted === true) {219 this.#baseline = counterBaseline(snapshot);220 this.#baselineAt = now;221 }222 })223 .catch((error) => {224 if (generation === this.#generation) console.warn('adaptive feedback failed:', error);225 })226 .finally(() => {227 if (generation === this.#generation) this.#inFlight = false;228 });229 return true;230 }231}232233export function normalizeAdaptiveDecisionEnvelope(payload, generation) {234 if (!payload || typeof payload !== 'object') throw new TypeError('adaptive decision is required');235 if (!Number.isSafeInteger(generation) || generation < 1 || payload.generation !== generation) {236 return null;237 }238 const decision = payload.decision;239 if (!decision || typeof decision !== 'object') throw new TypeError('adaptive decision body is required');240 const contribution = ['steady', 'clean', 'limiting', 'stale'].includes(decision.contribution)241 ? decision.contribution242 : 'unknown';243 const recoveryState = ['active', 'floor-limited', 'detached'].includes(decision.recovery_state)244 ? decision.recovery_state245 : 'unknown';246 return {247 decision_id: boundedInteger(decision.decision_id, Number.MAX_SAFE_INTEGER, 'decision ID'),248 report_id: boundedInteger(decision.report_id, Number.MAX_SAFE_INTEGER, 'report ID'),249 target_kbps: boundedInteger(decision.target_kbps, 100_000, 'target bitrate'),250 floor_kbps: boundedInteger(decision.floor_kbps, 100_000, 'bitrate floor'),251 ceiling_kbps: boundedInteger(decision.ceiling_kbps, 100_000, 'bitrate ceiling'),252 state: ['hold', 'decrease', 'increase'].includes(decision.state) ? decision.state : 'unknown',253 reasons: Array.isArray(decision.reasons)254 ? decision.reasons.filter((reason) => typeof reason === 'string').slice(0, 8)255 : [],256 applied: decision.applied === true,257 contribution,258 recovery_state: recoveryState,259 scope: decision.scope === 'local-viewer' ? 'local-viewer' : 'unknown',260 };261}262263export function formatAdaptiveDecision(decision, available) {264 if (!available) return 'unavailable';265 if (!decision) return 'awaiting report · advisory only';266 const reasons = decision.reasons.length ? decision.reasons.join(', ') : 'no pressure signals';267 const disposition = decision.applied ? 'host reports applied' : 'advisory only (not applied)';268 return `${decision.target_kbps} kbps global · ${decision.state} · local ${decision.contribution} · recovery ${decision.recovery_state} · ${reasons} · ${disposition}`;269}