build(spf): build26 from 45504a2b

This commit is contained in:
publish
2026-08-05 18:57:39 +02:00
commit 8920135306
424 changed files with 17907 additions and 0 deletions
+50
View File
@@ -0,0 +1,50 @@
//#region src/network/bandwidth-estimator.d.ts
/**
* Dual EWMA Bandwidth Estimator
*
* Estimates available bandwidth using two EWMA calculations with different
* half-lives, taking the minimum of both. This approach (from Shaka Player):
*
* - **Fast EWMA** (2s half-life): Reacts quickly to bandwidth drops
* - **Slow EWMA** (5s half-life): Provides stability during fluctuations
* - **min(fast, slow)**: Adapts down quickly, up slowly
*
* This naturally provides asymmetric behavior needed for good QoE:
* avoiding stalls (quick downgrade) while preventing oscillation (slow upgrade).
*/
/**
* Bandwidth estimator state.
*
* This state structure will be managed by O1 (State Container).
* Functions in this module operate on this state immutably.
*/
interface BandwidthState {
/** Fast-moving EWMA estimate (raw, uncorrected). */
fastEstimate: number;
/** Total weight accumulated in fast EWMA. */
fastTotalWeight: number;
/** Slow-moving EWMA estimate (raw, uncorrected). */
slowEstimate: number;
/** Total weight accumulated in slow EWMA. */
slowTotalWeight: number;
/** Total bytes sampled across all valid samples. */
bytesSampled: number;
}
/**
* Configuration for bandwidth estimation.
*/
interface BandwidthConfig {
/** Half-life for fast EWMA in seconds. */
fastHalfLife: number;
/** Half-life for slow EWMA in seconds. */
slowHalfLife: number;
/** Minimum total bytes before trusting the estimate. */
minTotalBytes: number;
/** Minimum bytes per sample to count (filters TTFB-dominated samples). */
minBytes: number;
/** Minimum sample duration in ms (filters cached responses). */
minDuration: number;
}
//#endregion
export { BandwidthConfig, BandwidthState };
//# sourceMappingURL=bandwidth-estimator.d.ts.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"bandwidth-estimator.d.ts","names":[],"sources":["../../../src/network/bandwidth-estimator.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;UAsBiB;;EAEf;;EAEA;;EAEA;;EAEA;;EAEA;;;;;UAMe;;EAEf;;EAEA;;EAEA;;EAEA;;EAEA"}
+96
View File
@@ -0,0 +1,96 @@
import { applyZeroFactor, calculateEwma } from "./ewma.js";
//#region src/network/bandwidth-estimator.ts
/**
* Dual EWMA Bandwidth Estimator
*
* Estimates available bandwidth using two EWMA calculations with different
* half-lives, taking the minimum of both. This approach (from Shaka Player):
*
* - **Fast EWMA** (2s half-life): Reacts quickly to bandwidth drops
* - **Slow EWMA** (5s half-life): Provides stability during fluctuations
* - **min(fast, slow)**: Adapts down quickly, up slowly
*
* This naturally provides asymmetric behavior needed for good QoE:
* avoiding stalls (quick downgrade) while preventing oscillation (slow upgrade).
*/
/**
* Default bandwidth estimator configuration.
*
* Values match Shaka Player defaults based on experimentation.
*/
const DEFAULT_BANDWIDTH_CONFIG = {
fastHalfLife: 2,
slowHalfLife: 5,
minTotalBytes: 128e3,
minBytes: 16e3,
minDuration: 5
};
/**
* Add a bandwidth sample from a segment download.
*
* Samples are filtered based on:
* - Minimum bytes (filters TTFB-dominated small segments)
* - Minimum duration (filters cached responses)
*
* Valid samples update both fast and slow EWMA estimates.
*
* @param state - Current estimator state
* @param durationMs - Download duration in milliseconds
* @param numBytes - Number of bytes downloaded
* @param config - Optional estimator configuration (uses defaults if not provided)
* @returns New estimator state with sample incorporated (or unchanged if filtered)
*
* @example
* let state = { fastEstimate: 0, fastTotalWeight: 0, ... };
* // Sample: 1MB in 1 second
* state = sampleBandwidth(state, 1000, 1_000_000);
*/
function sampleBandwidth(state, durationMs, numBytes, config = DEFAULT_BANDWIDTH_CONFIG) {
const updatedBytesSampled = state.bytesSampled + numBytes;
if (numBytes < config.minBytes) return {
...state,
bytesSampled: updatedBytesSampled
};
if (durationMs < config.minDuration) return {
...state,
bytesSampled: updatedBytesSampled
};
const bandwidth = 8e3 * numBytes / durationMs;
const weight = durationMs / 1e3;
return {
fastEstimate: calculateEwma(state.fastEstimate, bandwidth, weight, config.fastHalfLife),
fastTotalWeight: state.fastTotalWeight + weight,
slowEstimate: calculateEwma(state.slowEstimate, bandwidth, weight, config.slowHalfLife),
slowTotalWeight: state.slowTotalWeight + weight,
bytesSampled: updatedBytesSampled
};
}
/**
* Get the current bandwidth estimate.
*
* Returns the **minimum** of the fast and slow EWMA estimates.
* This provides the key asymmetric behavior:
* - When bandwidth drops, fast EWMA reacts first and dominates (quick adaptation)
* - When bandwidth rises, slow EWMA lags behind and dominates (slow adaptation)
*
* Uses default estimate until enough data has been sampled — and when no
* estimator state exists at all (`state === undefined`).
*
* @param state - Current estimator state, or `undefined` before any samples have been collected
* @param defaultEstimate - Fallback estimate before sufficient samples (bps)
* @param config - Optional estimator configuration (uses defaults if not provided)
* @returns Bandwidth estimate in bits per second
*
* @example
* const estimate = getBandwidthEstimate(state, 5_000_000); // 5 Mbps default
*/
function getBandwidthEstimate(state, defaultEstimate, config = DEFAULT_BANDWIDTH_CONFIG) {
if (!state || state.bytesSampled < config.minTotalBytes) return defaultEstimate;
const fastEstimate = applyZeroFactor(state.fastEstimate, state.fastTotalWeight, config.fastHalfLife);
const slowEstimate = applyZeroFactor(state.slowEstimate, state.slowTotalWeight, config.slowHalfLife);
return Math.min(fastEstimate, slowEstimate);
}
//#endregion
export { DEFAULT_BANDWIDTH_CONFIG, getBandwidthEstimate, sampleBandwidth };
//# sourceMappingURL=bandwidth-estimator.js.map
File diff suppressed because one or more lines are too long
+49
View File
@@ -0,0 +1,49 @@
//#region src/network/chunked-stream-iterable.ts
const DEFAULT_MIN_CHUNK_SIZE = 2 ** 17;
/**
* Adapts a `ReadableStream<Uint8Array>` (e.g. `response.body`) into an
* `AsyncIterable<Uint8Array>` that yields chunks no smaller than
* `minChunkSize` bytes. Smaller network chunks are accumulated and yielded
* together once the threshold is met. Any remainder is flushed on stream end.
*
* Errors from the underlying stream propagate naturally — the reader lock is
* always released via `finally`.
*/
var ChunkedStreamIterable = class {
minChunkSize;
#readableStream;
constructor(readableStream, { minChunkSize = DEFAULT_MIN_CHUNK_SIZE } = {}) {
this.#readableStream = readableStream;
this.minChunkSize = minChunkSize;
}
async *[Symbol.asyncIterator]() {
let pending;
const reader = this.#readableStream.getReader();
try {
while (true) {
const { done, value } = await reader.read();
if (done) {
if (pending) yield pending;
break;
}
pending = pending ? concat(pending, value) : value;
if (pending.length >= this.minChunkSize) {
yield pending;
pending = void 0;
}
}
} finally {
reader.releaseLock();
}
}
};
function concat(a, b) {
const result = new Uint8Array(a.length + b.length);
result.set(a);
result.set(b, a.length);
return result;
}
//#endregion
export { ChunkedStreamIterable };
//# sourceMappingURL=chunked-stream-iterable.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"chunked-stream-iterable.js","names":["#readableStream"],"sources":["../../../src/network/chunked-stream-iterable.ts"],"sourcesContent":["const DEFAULT_MIN_CHUNK_SIZE = 2 ** 17; // 128 KB\n\nexport interface ChunkedStreamIterableOptions {\n minChunkSize?: number;\n}\n\n/**\n * Adapts a `ReadableStream<Uint8Array>` (e.g. `response.body`) into an\n * `AsyncIterable<Uint8Array>` that yields chunks no smaller than\n * `minChunkSize` bytes. Smaller network chunks are accumulated and yielded\n * together once the threshold is met. Any remainder is flushed on stream end.\n *\n * Errors from the underlying stream propagate naturally — the reader lock is\n * always released via `finally`.\n */\nexport class ChunkedStreamIterable implements AsyncIterable<Uint8Array> {\n readonly minChunkSize: number;\n #readableStream: ReadableStream<Uint8Array>;\n\n constructor(\n readableStream: ReadableStream<Uint8Array>,\n { minChunkSize = DEFAULT_MIN_CHUNK_SIZE }: ChunkedStreamIterableOptions = {}\n ) {\n this.#readableStream = readableStream;\n this.minChunkSize = minChunkSize;\n }\n\n async *[Symbol.asyncIterator](): AsyncGenerator<Uint8Array> {\n let pending: Uint8Array | undefined;\n const reader = this.#readableStream.getReader();\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) {\n if (pending) yield pending;\n break;\n }\n\n pending = pending ? concat(pending, value) : value;\n\n if (pending.length >= this.minChunkSize) {\n yield pending;\n pending = undefined;\n }\n }\n } finally {\n reader.releaseLock();\n }\n }\n}\n\nfunction concat(a: Uint8Array, b: Uint8Array): Uint8Array {\n const result = new Uint8Array(a.length + b.length);\n result.set(a);\n result.set(b, a.length);\n return result;\n}\n"],"mappings":";AAAA,MAAM,yBAAyB,KAAK;;;;;;;;;;AAepC,IAAa,wBAAb,MAAwE;CACtE;CACA;CAEA,YACE,gBACA,EAAE,eAAe,2BAAyD,CAAC,GAC3E;EACA,KAAKA,kBAAkB;EACvB,KAAK,eAAe;CACtB;CAEA,QAAQ,OAAO,iBAA6C;EAC1D,IAAI;EACJ,MAAM,SAAS,KAAKA,gBAAgB,UAAU;EAC9C,IAAI;GACF,OAAO,MAAM;IACX,MAAM,EAAE,MAAM,UAAU,MAAM,OAAO,KAAK;IAC1C,IAAI,MAAM;KACR,IAAI,SAAS,MAAM;KACnB;IACF;IAEA,UAAU,UAAU,OAAO,SAAS,KAAK,IAAI;IAE7C,IAAI,QAAQ,UAAU,KAAK,cAAc;KACvC,MAAM;KACN,UAAU,KAAA;IACZ;GACF;EACF,UAAU;GACR,OAAO,YAAY;EACrB;CACF;AACF;AAEA,SAAS,OAAO,GAAe,GAA2B;CACxD,MAAM,SAAS,IAAI,WAAW,EAAE,SAAS,EAAE,MAAM;CACjD,OAAO,IAAI,CAAC;CACZ,OAAO,IAAI,GAAG,EAAE,MAAM;CACtB,OAAO;AACT"}
+71
View File
@@ -0,0 +1,71 @@
//#region src/network/ewma.ts
/**
* Exponentially Weighted Moving Average (EWMA)
*
* Pure functional implementation of EWMA calculations.
* Based on Shaka Player's EWMA algorithm.
*/
/**
* Calculate alpha (decay factor) from half-life.
*
* Alpha determines how quickly old data "expires":
* - alpha close to 1 = slow decay (long memory)
* - alpha close to 0 = fast decay (short memory)
*
* @param halfLife - The quantity of prior samples (by weight) that make up
* half of the new estimate. Must be positive.
* @returns Alpha value between 0 and 1
*
* @example
* const alpha = calculateAlpha(2); // ≈ 0.7071 for 2-second half-life
*/
function calculateAlpha(halfLife) {
return Math.exp(Math.log(.5) / halfLife);
}
/**
* Calculate exponentially weighted moving average.
*
* Updates an estimate by blending a new value with the previous estimate,
* weighted by the sample duration. Longer samples have more influence.
*
* @param prevEstimate - Previous EWMA estimate
* @param value - New sample value to incorporate
* @param weight - Sample weight (typically duration in seconds)
* @param halfLife - Half-life for decay (typically 2-5 seconds)
* @returns Updated EWMA estimate
*
* @example
* let estimate = 0;
* estimate = calculateEwma(estimate, 1_000_000, 1, 2); // First sample
* estimate = calculateEwma(estimate, 2_000_000, 1, 2); // Second sample
*/
function calculateEwma(prevEstimate, value, weight, halfLife) {
const adjAlpha = calculateAlpha(halfLife) ** weight;
return value * (1 - adjAlpha) + adjAlpha * prevEstimate;
}
/**
* Apply zero-factor correction to EWMA estimate.
*
* The zero-factor correction compensates for bias when starting from zero.
* Without this correction, early estimates would be artificially low.
*
* As totalWeight increases, the correction factor approaches 1, meaning
* the estimate becomes more reliable and needs less correction.
*
* @param estimate - Raw EWMA estimate (uncorrected)
* @param totalWeight - Accumulated weight from all samples
* @param halfLife - Half-life used in EWMA calculation
* @returns Corrected estimate, or 0 if totalWeight is 0
*
* @example
* const raw = calculateEwma(0, 1_000_000, 1, 2);
* const corrected = applyZeroFactor(raw, 1, 2); // ≈ 1_000_000
*/
function applyZeroFactor(estimate, totalWeight, halfLife) {
if (totalWeight === 0) return 0;
return estimate / (1 - calculateAlpha(halfLife) ** totalWeight);
}
//#endregion
export { applyZeroFactor, calculateAlpha, calculateEwma };
//# sourceMappingURL=ewma.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"ewma.js","names":[],"sources":["../../../src/network/ewma.ts"],"sourcesContent":["/**\n * Exponentially Weighted Moving Average (EWMA)\n *\n * Pure functional implementation of EWMA calculations.\n * Based on Shaka Player's EWMA algorithm.\n */\n\n/**\n * Calculate alpha (decay factor) from half-life.\n *\n * Alpha determines how quickly old data \"expires\":\n * - alpha close to 1 = slow decay (long memory)\n * - alpha close to 0 = fast decay (short memory)\n *\n * @param halfLife - The quantity of prior samples (by weight) that make up\n * half of the new estimate. Must be positive.\n * @returns Alpha value between 0 and 1\n *\n * @example\n * const alpha = calculateAlpha(2); // ≈ 0.7071 for 2-second half-life\n */\nexport function calculateAlpha(halfLife: number): number {\n return Math.exp(Math.log(0.5) / halfLife);\n}\n\n/**\n * Calculate exponentially weighted moving average.\n *\n * Updates an estimate by blending a new value with the previous estimate,\n * weighted by the sample duration. Longer samples have more influence.\n *\n * @param prevEstimate - Previous EWMA estimate\n * @param value - New sample value to incorporate\n * @param weight - Sample weight (typically duration in seconds)\n * @param halfLife - Half-life for decay (typically 2-5 seconds)\n * @returns Updated EWMA estimate\n *\n * @example\n * let estimate = 0;\n * estimate = calculateEwma(estimate, 1_000_000, 1, 2); // First sample\n * estimate = calculateEwma(estimate, 2_000_000, 1, 2); // Second sample\n */\nexport function calculateEwma(prevEstimate: number, value: number, weight: number, halfLife: number): number {\n const alpha = calculateAlpha(halfLife);\n const adjAlpha = alpha ** weight;\n return value * (1 - adjAlpha) + adjAlpha * prevEstimate;\n}\n\n/**\n * Apply zero-factor correction to EWMA estimate.\n *\n * The zero-factor correction compensates for bias when starting from zero.\n * Without this correction, early estimates would be artificially low.\n *\n * As totalWeight increases, the correction factor approaches 1, meaning\n * the estimate becomes more reliable and needs less correction.\n *\n * @param estimate - Raw EWMA estimate (uncorrected)\n * @param totalWeight - Accumulated weight from all samples\n * @param halfLife - Half-life used in EWMA calculation\n * @returns Corrected estimate, or 0 if totalWeight is 0\n *\n * @example\n * const raw = calculateEwma(0, 1_000_000, 1, 2);\n * const corrected = applyZeroFactor(raw, 1, 2); // ≈ 1_000_000\n */\nexport function applyZeroFactor(estimate: number, totalWeight: number, halfLife: number): number {\n if (totalWeight === 0) {\n return 0;\n }\n\n const alpha = calculateAlpha(halfLife);\n const zeroFactor = 1 - alpha ** totalWeight;\n return estimate / zeroFactor;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;AAqBA,SAAgB,eAAe,UAA0B;CACvD,OAAO,KAAK,IAAI,KAAK,IAAI,EAAG,IAAI,QAAQ;AAC1C;;;;;;;;;;;;;;;;;;AAmBA,SAAgB,cAAc,cAAsB,OAAe,QAAgB,UAA0B;CAE3G,MAAM,WADQ,eAAe,QACR,KAAK;CAC1B,OAAO,SAAS,IAAI,YAAY,WAAW;AAC7C;;;;;;;;;;;;;;;;;;;AAoBA,SAAgB,gBAAgB,UAAkB,aAAqB,UAA0B;CAC/F,IAAI,gBAAgB,GAClB,OAAO;CAKT,OAAO,YADY,IADL,eAAe,QACF,KAAK;AAElC"}
+115
View File
@@ -0,0 +1,115 @@
import { sampleBandwidth } from "./bandwidth-estimator.js";
import { ChunkedStreamIterable } from "./chunked-stream-iterable.js";
//#region src/network/fetch.ts
/**
* HTTP Fetch Wrapper
*
* Composable building blocks:
* - fetchResolvable() — fetch a Resource (handles byte ranges); returns Response
* - getResponseText() — extract text from Response
* - fetchResolvableStream() — single-stage async generator over body chunks
* - fetchStream() — two-stage: await connection establishment, then lazily
* iterate body chunks. Use when timing the connection start independently
* of body consumption matters (e.g., observable fetch timing for ABR).
* - createTrackedFetch() — factory for a fetchStream-shape function that
* samples bandwidth (via EWMA) per chunk and notifies via callback.
*/
/**
* Fetch resolvable from a Resource.
*
* Handles byte range requests if byteRange is present.
* Returns native fetch Response for composability (can extract text, stream, etc.).
*
* @param addressable - Resource to fetch (url + optional byteRange)
* @returns Promise resolving to Response
*
* @example
* const response = await fetchResolvable({ url: 'https://example.com/segment.m4s' });
* const text = await getResponseText(response);
*
* @example
* // With byte range
* const response = await fetchResolvable({
* url: 'https://example.com/file.mp4',
* byteRange: { start: 1000, end: 1999 }
* });
*/
async function fetchResolvable(addressable, options) {
const headers = new Headers(options?.headers);
if (addressable.byteRange) {
const { start, end } = addressable.byteRange;
headers.set("Range", `bytes=${start}-${end}`);
}
const request = new Request(addressable.url, {
method: "GET",
headers,
...options
});
return fetch(request);
}
/**
* Extract text from Response.
*
* Accepts minimal Response-like object (just needs text() method).
* Returns promise from response.text().
*
* @param response - Response-like object with text() method
* @returns Promise resolving to text content
*
* @example
* const response = await fetchResolvable(addressable);
* const text = await getResponseText(response);
*/
function getResponseText(response) {
return response.text();
}
/** Default {@link FetchText}: fetch the resource, reject on non-OK, return text. */
const fetchResolvableText = async (addressable, options) => {
const response = await fetchResolvable(addressable, options);
if (!response.ok) throw new Error(`fetchResolvableText: ${response.status} ${response.statusText} for ${addressable.url}`);
return getResponseText(response);
};
async function fetchStream(addressable, options) {
const { minChunkSize, ...fetchOptions } = options ?? {};
const response = await fetchResolvable(addressable, fetchOptions);
if (!response.body) throw new Error("Response has no body");
return new ChunkedStreamIterable(response.body, ...minChunkSize !== void 0 ? [{ minChunkSize }] : []);
}
/**
* Returns a {@link FetchBytes} function that samples bandwidth via EWMA
* per body chunk. The factory captures the running bandwidth state
* internally; per chunk it computes the next state and notifies the
* supplied `onSample` callback.
*
* The factory's internal accumulator is seeded from `initial` and updated
* on every chunk; callers don't need to thread it back in. `onSample`
* receives the *new* state after each chunk — typical use is to bridge
* samples back into engine state for ABR consumers.
*
* @param initial - Starting `BandwidthState` (commonly zeros or the
* engine's current accumulator).
* @param onSample - Called with the new `BandwidthState` after each chunk.
*/
function createTrackedFetch(initial, onSample) {
let state = initial;
return async (addressable, options) => {
const { minChunkSize, ...fetchOptions } = options ?? {};
const response = await fetchResolvable(addressable, fetchOptions);
if (!response.body) throw new Error("Response has no body");
const body = response.body;
return { [Symbol.asyncIterator]: async function* () {
let chunkStart = performance.now();
for await (const chunk of new ChunkedStreamIterable(body, ...minChunkSize !== void 0 ? [{ minChunkSize }] : [])) {
const elapsed = performance.now() - chunkStart;
state = sampleBandwidth(state, elapsed, chunk.byteLength);
onSample(state);
yield chunk;
chunkStart = performance.now();
}
} };
};
}
//#endregion
export { createTrackedFetch, fetchResolvable, fetchResolvableText, fetchStream, getResponseText };
//# sourceMappingURL=fetch.js.map
+1
View File
File diff suppressed because one or more lines are too long