hendrix / source /demo /web /src /progress.ts
jackboyla's picture
Deploy checked Hendrix D26 browser demo
b2f2f57 verified
Raw History Blame Contribute Delete
3.56 kB
/**
* Smooths the backend's coarse chunk-completion anchors into a continuous
* progress fraction + ETA.
*
* The backend emits one `{completed, total}` anchor per fixed-size audio chunk
* (plus a `completed: 0` anchor up front). Chunks cover equal *input* time but
* take wildly uneven *wall-clock* time, so we can't predict pace from the audio
* — we have to measure it here. We keep an EMA of per-chunk wall-clock duration
* and use it both to interpolate the bar *within* the current chunk and to
* estimate the time remaining. Before the first chunk finishes there's no EMA
* yet, so the bar creeps along an asymptotic curve that never reaches the next
* anchor.
*/
const EMA_ALPHA = 0.4; // weight on the most recent chunk's duration
const WITHIN_CAP = 0.95; // within-chunk interpolation never reaches the next anchor
const CREEP_TAU_MS = 4000; // time constant for the pre-EMA asymptotic creep
export class ProgressEstimator {
private total = 0;
private completed = 0;
private emaChunkMs: number | null = null;
private lastTickMs = 0; // perf time of the last anchor (incl. the t0 anchor)
private active = false;
/** Start of a fresh transcription — forget everything. */
reset(): void {
this.total = 0;
this.completed = 0;
this.emaChunkMs = null;
this.lastTickMs = 0;
this.active = false;
}
/** Feed a chunk-completion anchor. `now` should be `performance.now()`. */
onAnchor(completed: number, total: number, now: number): void {
this.total = total;
if (this.active && completed > this.completed) {
// Wall-clock spent on the chunk(s) that just finished (usually 1, since
// the web path runs batch_size=1).
const perChunk = (now - this.lastTickMs) / (completed - this.completed);
this.emaChunkMs =
this.emaChunkMs == null
? perChunk
: EMA_ALPHA * perChunk + (1 - EMA_ALPHA) * this.emaChunkMs;
}
this.completed = completed;
this.lastTickMs = now;
this.active = true;
}
/**
* Exact fraction of chunks completed, with no within-chunk smoothing. Unlike
* `fraction`, this only advances when an anchor lands — so it stays in lockstep
* with the notes the backend emits alongside that same anchor.
*/
completedFraction(): number {
if (!this.active || this.total === 0) return 0;
return Math.min(this.completed / this.total, 1);
}
/** Smoothed progress fraction in [0, 1] at time `now`. */
fraction(now: number): number {
if (!this.active || this.total === 0) return 0;
if (this.completed >= this.total) return 1;
const base = this.completed / this.total;
const elapsed = now - this.lastTickMs;
const within =
this.emaChunkMs && this.emaChunkMs > 0
? Math.min(elapsed / this.emaChunkMs, WITHIN_CAP)
: (1 - Math.exp(-elapsed / CREEP_TAU_MS)) * WITHIN_CAP;
return Math.min(base + within / this.total, 0.999);
}
/** Estimated time remaining in ms, or null while still unknown. */
etaMs(now: number): number | null {
if (!this.active) return null;
if (this.completed >= this.total) return 0;
if (!this.emaChunkMs) return null; // no measured pace yet
const remaining = this.total - this.completed;
return Math.max(0, this.emaChunkMs * remaining - (now - this.lastTickMs));
}
}
/** Seconds as a m:ss clock, e.g. 15 → "0:15", 62 → "1:02". */
export function formatClock(seconds: number): string {
const s = Math.max(0, Math.round(seconds));
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
}