@joewinke/jatui 0.13.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,25 +1,29 @@
1
1
  /**
2
- * Presence module — CONTRACT ONLY (mos-5lzns.28). Implemented by mos-5lzns.34
3
- * ("wind-down (Idle Detection API), resume-at-sentence, FLIP open").
2
+ * Presence (mos-5lzns.34) — the barrel. Three behaviours about whether you
3
+ * are there and where you were, each an OPTIONAL VideoPlayer prop that is
4
+ * inert when absent (extension-library FEATURES.md §Wind-down, §Resume at a
5
+ * sentence, §Open like an object):
4
6
  *
5
- * What lands here (extension-library FEATURES.md §Wind-down,
6
- * §Resume at a sentence, §Open like an object), as ADVISORS + pure helpers:
7
+ * · `presence/winddown.ts` — pure: the begin decision, fade levels, hint.
8
+ * The desktop says nobody is there → picture and sound fade over 20s and
9
+ * the video pauses. The fade IS the prompt; any input cancels it.
10
+ * · `presence/idle.ts` — the desktop signal: Chromium's Idle Detection API,
11
+ * feature-detected, never prompting unless the host opted in (and then
12
+ * only from a gesture). Elsewhere the player falls back to page presence,
13
+ * reported as `source: 'page'` so the host can label it.
14
+ * · `presence/resume.ts` — resume-at-sentence glue around .27's
15
+ * `advisors/resume-point` (the snapping is NOT duplicated here): the
16
+ * host-supplied store (localStorage default), the data mapping, and the
17
+ * two integration rules — snap when the boundaries land, stand down once
18
+ * the user has taken the wheel.
19
+ * · `presence/flip.ts` — FLIP open/close over WAAPI with the poster painted
20
+ * on every path; a no-op under prefers-reduced-motion except the poster.
7
21
  *
8
- * · wind-down — the desktop says nobody is there (Idle Detection API / user
9
- * activity), the player fades picture and sound over ~20s and pauses. The
10
- * fade IS the prompt — cancelling it is how you come back.
11
- * · resume-at-sentence — snap a resume point back to the nearest sentence /
12
- * speaker turn / chapter within 15s (CLOSEST wins, not most-specific);
13
- * quote the line, never paraphrase; stand down if the user has taken the
14
- * wheel (>2.5s from the stored point). Runs when the transcript lands, not
15
- * at open.
16
- * · FLIP open — the clicked card's rectangle → the player shell, WAAPI, two
17
- * keyframes, transform-origin top-left, poster painted under the video on
18
- * EVERY path (a black rectangle is a bug at any motion setting); no-op
19
- * under prefers-reduced-motion except the poster.
20
- *
21
- * All three are advisors/mechanics: they read no settings, touch no
22
- * <video> outside the callbacks the component passes, and fail toward doing
23
- * nothing.
22
+ * Advisors and mechanics: they read no settings and touch no <video>; the
23
+ * component passes knobs in and applies what comes back, and every one of
24
+ * them fails toward doing nothing.
24
25
  */
25
- export const PRESENCE_NEUTRAL = 1;
26
+ export { WIND_DOWN_FADE_MS, WIND_DOWN_DEPTH, WIND_DOWN_AFTER_DEFAULT, WIND_DOWN_AFTER_MIN, WIND_DOWN_AFTER_MAX, WIND_DOWN_PROBE_MS, windDownAfterMs, shouldBeginWindDown, desktopPresent, windDownProgress, windDownLevels, windDownHint } from './presence/winddown';
27
+ export { IDLE_THRESHOLD_MIN_MS, idleDetectionSupported, idleDetectionPermission, requestIdleDetection, startDesktopWatch, toDesktopSignal } from './presence/idle';
28
+ export { RESUME_WHEEL_SEC, RESUME_MAX_BACK, RESUME_SAVE_MS, RESUME_FLOOR_SEC, RESUME_TAIL_SEC, localResumeStore, coerceResumeSec, resumeToSave, toResumeCtx, hasBoundaries, planResumeSnap } from './presence/resume';
29
+ export { FLIP_EASE, FLIP_OPEN_MS, FLIP_CLOSE_MS, FLIP_FADE_WITH_POSTER, FLIP_FADE_BARE, FLIP_POSTER_CLASS, flipOpen, flipClose, clearPoster, posterBackground, rectOf, onScreen, flipGeometry, awayFrame, homeFrame } from './presence/flip';
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Skip ad reads (mos-5lzns.32).
3
+ *
4
+ * Ad spans come from two places, merged: the host's `adSpans` prop (MOS fills
5
+ * it from its enrichment pass) and any speaker turn flagged `ad: true` — the
6
+ * band already paints those as not-a-person, so a player that paints an ad
7
+ * and then plays it through with skipping on would contradict itself.
8
+ *
9
+ * THE ONE RULE: only PLAYBACK that runs into an ad is skipped. A seek that
10
+ * lands inside one is a person asking to watch it, and is honoured. The
11
+ * component forgets its previous sample on every `seeking` event; this module
12
+ * additionally refuses any step larger than `maxStep` media-seconds, so even
13
+ * a seek that raced the event can't be mistaken for crossing the start.
14
+ *
15
+ * Pure: no DOM, no <video>, no settings reads (src/lib/video/README.md).
16
+ */
17
+ import type { VideoSpeakerTurn } from '../types';
18
+ /** A host-detected ad read, `[startSec, endSec)`. */
19
+ export interface VideoAdSpan {
20
+ startSec: number;
21
+ endSec: number;
22
+ }
23
+ /** Sorted, merged, finite `[start, end)` pairs. Touching/overlapping spans become one. */
24
+ export declare function normalizeAdSpans(spans: readonly VideoAdSpan[] | null | undefined, turns?: readonly VideoSpeakerTurn[] | null): [number, number][];
25
+ /**
26
+ * Where to jump, or null. Skips when playback moved forward from `prev`
27
+ * (outside, before the span) to `now` (inside it) in one step of at most
28
+ * `maxStep` seconds. `prev === null` means "no trustworthy previous sample"
29
+ * (just seeked, just started, new source) and never skips.
30
+ */
31
+ export declare function adSkipTarget(prev: number | null, now: number, spans: readonly [number, number][], maxStep: number): {
32
+ from: number;
33
+ to: number;
34
+ } | null;
35
+ /** Wall-clock tick → the largest media step that still counts as "playing through". */
36
+ export declare function adMaxStep(elapsedMs: number, rate: number): number;
@@ -0,0 +1,59 @@
1
+ /** Sorted, merged, finite `[start, end)` pairs. Touching/overlapping spans become one. */
2
+ export function normalizeAdSpans(spans, turns) {
3
+ const raw = [];
4
+ const push = (a, b) => {
5
+ if (typeof a !== 'number' || typeof b !== 'number')
6
+ return;
7
+ if (!Number.isFinite(a) || !Number.isFinite(b) || b <= a)
8
+ return;
9
+ raw.push([Math.max(0, a), b]);
10
+ };
11
+ if (Array.isArray(spans))
12
+ for (const s of spans)
13
+ if (s)
14
+ push(s.startSec, s.endSec);
15
+ if (Array.isArray(turns))
16
+ for (const t of turns)
17
+ if (t && t.ad)
18
+ push(t.startSec, t.endSec);
19
+ if (!raw.length)
20
+ return raw;
21
+ raw.sort((x, y) => x[0] - y[0]);
22
+ const out = [[raw[0][0], raw[0][1]]];
23
+ for (let i = 1; i < raw.length; i++) {
24
+ const last = out[out.length - 1];
25
+ const [a, b] = raw[i];
26
+ if (a <= last[1])
27
+ last[1] = Math.max(last[1], b);
28
+ else
29
+ out.push([a, b]);
30
+ }
31
+ return out;
32
+ }
33
+ /**
34
+ * Where to jump, or null. Skips when playback moved forward from `prev`
35
+ * (outside, before the span) to `now` (inside it) in one step of at most
36
+ * `maxStep` seconds. `prev === null` means "no trustworthy previous sample"
37
+ * (just seeked, just started, new source) and never skips.
38
+ */
39
+ export function adSkipTarget(prev, now, spans, maxStep) {
40
+ if (prev == null || !Number.isFinite(prev) || !Number.isFinite(now))
41
+ return null;
42
+ if (now <= prev || now - prev > maxStep)
43
+ return null;
44
+ for (const [a, b] of spans) {
45
+ if (a > now)
46
+ break;
47
+ if (now < b && prev < a)
48
+ return { from: a, to: b };
49
+ }
50
+ return null;
51
+ }
52
+ /** Wall-clock tick → the largest media step that still counts as "playing through". */
53
+ export function adMaxStep(elapsedMs, rate) {
54
+ const ms = Number.isFinite(elapsedMs) && elapsedMs > 0 ? elapsedMs : 0;
55
+ const r = Number.isFinite(rate) && rate > 0 ? rate : 1;
56
+ // 1.5× slack for timer jitter, plus a floor so a background-throttled tab
57
+ // (setInterval clamped to ≥1s) still recognises its own playback.
58
+ return (ms / 1000) * r * 1.5 + 0.5;
59
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Playback-rate arbitration for VideoPlayer (mos-5lzns.28 internal split;
3
+ * moved under rate/ by mos-5lzns.32, which fills the advisors).
4
+ *
5
+ * THE RATE COMPOSER CONTRACT (extension-library FEATURES.md — "The rate
6
+ * composer") — this is the one non-obvious rule, stated up front because it
7
+ * looks like a bug from the outside:
8
+ *
9
+ * Three features want `playbackRate`, and NONE of them writes it:
10
+ * · you — the speed keys (−/=/Backspace, alt+wheel)
11
+ * · speaker — "this person talks fast" (mos-5lzns.32 fills)
12
+ * · silence — "nothing is being said here" (mos-5lzns.32 fills)
13
+ *
14
+ * They contribute to a product: rate = base × speaker × silence
15
+ *
16
+ * `base` is the only one a human chose, and therefore the only one the UI
17
+ * reports. The speed readout must keep saying `1×` while a gap is being taken
18
+ * at 2.2×, or the label becomes a strobe.
19
+ *
20
+ * 1. Both advisors collapse to `1` the moment their setting is off or their
21
+ * data has not arrived — `1` is the multiplicative identity, so the
22
+ * composer runs identically on a video with no transcript, no waveform
23
+ * and no speakers. There is no "feature absent" branch anywhere.
24
+ * 2. `base === 0` is a state, not a level (the frozen-frame end of the
25
+ * staircase) — it returns early, before any multiplication, because no
26
+ * advisor gets to un-pause the video by multiplying its way out of zero.
27
+ * 3. Only real changes are written. `playbackRate` is a live audio
28
+ * parameter: writing a *slightly* different value is audible as a wobble.
29
+ * Hence the 0.001 deadband.
30
+ * 4. The clamp is [0.0625, 16] — the range HTML media elements actually
31
+ * support. A product of three multipliers can leave it; a single one
32
+ * cannot.
33
+ *
34
+ * The SPEED_STEPS staircase and formatSpeedLabel are ported from the
35
+ * extension's theater (linear 0.1 steps below 1×, all the way down to 0 —
36
+ * playbackRate=0 is a genuine frozen frame; above 1× the familiar jumps).
37
+ */
38
+ /** The speed staircase — one step per press of −/=, Backspace resets to 1. */
39
+ export declare const SPEED_STEPS: number[];
40
+ /**
41
+ * "A quarter speed" reads faster as "−.75" than "0.25×" — below 1× the label
42
+ * is the delta, not the multiplier. The 0 step is the frozen-frame end of the
43
+ * staircase — a state, not a level — and the label says so ("0×"), because a
44
+ * frozen frame labeled "1×" lies. Garbage degrades to `1×`.
45
+ */
46
+ export declare function formatSpeedLabel(rate: number): string;
47
+ /**
48
+ * One step along the staircase. A rate OFF the staircase (1.25 from the
49
+ * native controls' speed menu, say) steps to its nearest staircase neighbour
50
+ * in the pressed direction — never back to 1×, which from 1.25 would make
51
+ * "faster" slower. Past either end there is no further step: the rate holds.
52
+ * Garbage (NaN, negative) is treated as 1×.
53
+ */
54
+ export declare function stepRate(current: number, direction: 1 | -1): number;
55
+ /** The clamp the composer applies — the range HTML media elements actually support. */
56
+ export declare const RATE_MIN = 0.0625;
57
+ export declare const RATE_MAX = 16;
58
+ /** Writes only on real change — a slightly-different write is audible as a wobble. */
59
+ export declare const RATE_DEADBAND = 0.001;
60
+ export interface ComposedRate {
61
+ rate: number;
62
+ /** False when the deadband swallowed the write (keep the previous rate). */
63
+ changed: boolean;
64
+ }
65
+ /**
66
+ * The whole arbitration. `speaker` and `silence` are advisor products in
67
+ * [0.0625, 16] that COLLAPSE TO 1 when their feature is off or its data has
68
+ * not arrived (rate/speaker.ts, rate/silence.ts — mos-5lzns.32); with both
69
+ * at 1 this is exactly `base`.
70
+ *
71
+ * `base === 0` returns early: a frozen frame is a state, not a level.
72
+ */
73
+ export declare function composeRate(base: number, speaker: number, silence: number): ComposedRate;
74
+ /**
75
+ * Should the component write `next` over the element's `current` rate?
76
+ * Only on a real change (> RATE_DEADBAND) — a slightly-different write is
77
+ * audible as a wobble. The one exception is landing EXACTLY on `base`: when
78
+ * every advisor has collapsed to 1 the applied rate must be the human's rate
79
+ * to the last bit (doctrine 1 — an off feature leaves no trace), so a residue
80
+ * inside the deadband is still cleared.
81
+ */
82
+ export declare function shouldWriteRate(current: number, next: number, base: number): boolean;
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Playback-rate arbitration for VideoPlayer (mos-5lzns.28 internal split;
3
+ * moved under rate/ by mos-5lzns.32, which fills the advisors).
4
+ *
5
+ * THE RATE COMPOSER CONTRACT (extension-library FEATURES.md — "The rate
6
+ * composer") — this is the one non-obvious rule, stated up front because it
7
+ * looks like a bug from the outside:
8
+ *
9
+ * Three features want `playbackRate`, and NONE of them writes it:
10
+ * · you — the speed keys (−/=/Backspace, alt+wheel)
11
+ * · speaker — "this person talks fast" (mos-5lzns.32 fills)
12
+ * · silence — "nothing is being said here" (mos-5lzns.32 fills)
13
+ *
14
+ * They contribute to a product: rate = base × speaker × silence
15
+ *
16
+ * `base` is the only one a human chose, and therefore the only one the UI
17
+ * reports. The speed readout must keep saying `1×` while a gap is being taken
18
+ * at 2.2×, or the label becomes a strobe.
19
+ *
20
+ * 1. Both advisors collapse to `1` the moment their setting is off or their
21
+ * data has not arrived — `1` is the multiplicative identity, so the
22
+ * composer runs identically on a video with no transcript, no waveform
23
+ * and no speakers. There is no "feature absent" branch anywhere.
24
+ * 2. `base === 0` is a state, not a level (the frozen-frame end of the
25
+ * staircase) — it returns early, before any multiplication, because no
26
+ * advisor gets to un-pause the video by multiplying its way out of zero.
27
+ * 3. Only real changes are written. `playbackRate` is a live audio
28
+ * parameter: writing a *slightly* different value is audible as a wobble.
29
+ * Hence the 0.001 deadband.
30
+ * 4. The clamp is [0.0625, 16] — the range HTML media elements actually
31
+ * support. A product of three multipliers can leave it; a single one
32
+ * cannot.
33
+ *
34
+ * The SPEED_STEPS staircase and formatSpeedLabel are ported from the
35
+ * extension's theater (linear 0.1 steps below 1×, all the way down to 0 —
36
+ * playbackRate=0 is a genuine frozen frame; above 1× the familiar jumps).
37
+ */
38
+ /** The speed staircase — one step per press of −/=, Backspace resets to 1. */
39
+ export const SPEED_STEPS = [
40
+ 0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1, 1.1, 1.2, 1.3, 1.4, 1.5, 1.75, 2, 2.5, 5, 10
41
+ ];
42
+ /**
43
+ * "A quarter speed" reads faster as "−.75" than "0.25×" — below 1× the label
44
+ * is the delta, not the multiplier. The 0 step is the frozen-frame end of the
45
+ * staircase — a state, not a level — and the label says so ("0×"), because a
46
+ * frozen frame labeled "1×" lies. Garbage degrades to `1×`.
47
+ */
48
+ export function formatSpeedLabel(rate) {
49
+ if (!Number.isFinite(rate) || rate < 0)
50
+ return '1×';
51
+ if (rate === 0)
52
+ return '0×';
53
+ if (rate < 1) {
54
+ return (rate - 1).toFixed(2).replace(/\.?0+$/, '').replace(/^-0\./, '-.') + '×';
55
+ }
56
+ return rate.toFixed(2).replace(/\.?0+$/, '') + '×';
57
+ }
58
+ /**
59
+ * One step along the staircase. A rate OFF the staircase (1.25 from the
60
+ * native controls' speed menu, say) steps to its nearest staircase neighbour
61
+ * in the pressed direction — never back to 1×, which from 1.25 would make
62
+ * "faster" slower. Past either end there is no further step: the rate holds.
63
+ * Garbage (NaN, negative) is treated as 1×.
64
+ */
65
+ export function stepRate(current, direction) {
66
+ const cur = Number.isFinite(current) && current >= 0 ? current : 1;
67
+ const i = SPEED_STEPS.indexOf(cur);
68
+ if (i !== -1)
69
+ return SPEED_STEPS[Math.min(SPEED_STEPS.length - 1, Math.max(0, i + direction))];
70
+ if (direction > 0)
71
+ return SPEED_STEPS.find((s) => s > cur) ?? cur;
72
+ for (let j = SPEED_STEPS.length - 1; j >= 0; j--)
73
+ if (SPEED_STEPS[j] < cur)
74
+ return SPEED_STEPS[j];
75
+ return cur;
76
+ }
77
+ /** The clamp the composer applies — the range HTML media elements actually support. */
78
+ export const RATE_MIN = 0.0625;
79
+ export const RATE_MAX = 16;
80
+ /** Writes only on real change — a slightly-different write is audible as a wobble. */
81
+ export const RATE_DEADBAND = 0.001;
82
+ /**
83
+ * The whole arbitration. `speaker` and `silence` are advisor products in
84
+ * [0.0625, 16] that COLLAPSE TO 1 when their feature is off or its data has
85
+ * not arrived (rate/speaker.ts, rate/silence.ts — mos-5lzns.32); with both
86
+ * at 1 this is exactly `base`.
87
+ *
88
+ * `base === 0` returns early: a frozen frame is a state, not a level.
89
+ */
90
+ export function composeRate(base, speaker, silence) {
91
+ if (!Number.isFinite(base) || base <= 0) {
92
+ return { rate: Math.max(0, Number.isFinite(base) ? base : 0), changed: false };
93
+ }
94
+ const sp = Number.isFinite(speaker) && speaker > 0 ? speaker : 1;
95
+ const si = Number.isFinite(silence) && silence > 0 ? silence : 1;
96
+ const rate = Math.min(RATE_MAX, Math.max(RATE_MIN, base * sp * si));
97
+ const changed = Math.abs(rate - base) > RATE_DEADBAND;
98
+ return { rate: changed ? rate : base, changed };
99
+ }
100
+ /**
101
+ * Should the component write `next` over the element's `current` rate?
102
+ * Only on a real change (> RATE_DEADBAND) — a slightly-different write is
103
+ * audible as a wobble. The one exception is landing EXACTLY on `base`: when
104
+ * every advisor has collapsed to 1 the applied rate must be the human's rate
105
+ * to the last bit (doctrine 1 — an off feature leaves no trace), so a residue
106
+ * inside the deadband is still cleared.
107
+ */
108
+ export function shouldWriteRate(current, next, base) {
109
+ if (!Number.isFinite(next) || current === next)
110
+ return false;
111
+ if (!Number.isFinite(current))
112
+ return true;
113
+ return Math.abs(current - next) > RATE_DEADBAND || next === base;
114
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Smart speed — the `silence` factor of the rate composer (mos-5lzns.32).
3
+ *
4
+ * The measuring is `../advisors/smart-speed` (ported verbatim by .27): hold
5
+ * 1× through speech, run the gaps between sentences at `boost` (2.2× by
6
+ * default), found off the waveform envelope the band already draws. This
7
+ * file adapts the component's `VideoWaveform` prop and collapses to 1 when
8
+ * the feature is off or the envelope has not arrived.
9
+ *
10
+ * Pure: no DOM, no <video>, no settings reads (src/lib/video/README.md).
11
+ */
12
+ import { type BoostOpts, type SilenceMap } from '../advisors/smart-speed';
13
+ import type { VideoWaveform } from '../types';
14
+ /**
15
+ * The silence map for a waveform, or null when there is nothing to map (no
16
+ * peaks, no known duration, or no qualifying gap at all — a map with zero
17
+ * ranges can never boost, so the caller may as well not hold one).
18
+ */
19
+ export declare function silenceMapFor(waveform: VideoWaveform | null | undefined, durationSec: number): SilenceMap | null;
20
+ /** The silence factor at time t: `boost` inside a gap, 1 everywhere else. */
21
+ export declare function silenceFactor(t: number, map: SilenceMap | null, opts?: BoostOpts | null): number;
22
+ /**
23
+ * "Smart speed · about 6 min shorter" — the visible surface for an automatic
24
+ * correction (doctrine 2). Empty when the saving would round to nothing, so
25
+ * the caller never flashes a claim it can't back.
26
+ */
27
+ export declare function smartSpeedLabel(map: SilenceMap | null, opts?: BoostOpts | null): string;
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Smart speed — the `silence` factor of the rate composer (mos-5lzns.32).
3
+ *
4
+ * The measuring is `../advisors/smart-speed` (ported verbatim by .27): hold
5
+ * 1× through speech, run the gaps between sentences at `boost` (2.2× by
6
+ * default), found off the waveform envelope the band already draws. This
7
+ * file adapts the component's `VideoWaveform` prop and collapses to 1 when
8
+ * the feature is off or the envelope has not arrived.
9
+ *
10
+ * Pure: no DOM, no <video>, no settings reads (src/lib/video/README.md).
11
+ */
12
+ import { boostAt, buildSilenceMap, estimateSaving } from '../advisors/smart-speed';
13
+ /**
14
+ * The silence map for a waveform, or null when there is nothing to map (no
15
+ * peaks, no known duration, or no qualifying gap at all — a map with zero
16
+ * ranges can never boost, so the caller may as well not hold one).
17
+ */
18
+ export function silenceMapFor(waveform, durationSec) {
19
+ if (!waveform || !waveform.peaks || !waveform.peaks.length)
20
+ return null;
21
+ const d = Number.isFinite(waveform.durationSec) && waveform.durationSec > 0
22
+ ? waveform.durationSec
23
+ : durationSec;
24
+ if (!(Number.isFinite(d) && d > 0))
25
+ return null;
26
+ const map = buildSilenceMap({ duration: d, peaks: waveform.peaks });
27
+ return map.ranges.length ? map : null;
28
+ }
29
+ /** The silence factor at time t: `boost` inside a gap, 1 everywhere else. */
30
+ export function silenceFactor(t, map, opts) {
31
+ if (!map)
32
+ return 1;
33
+ const b = boostAt(t, map, opts ?? null);
34
+ return Number.isFinite(b) && b >= 1 ? b : 1;
35
+ }
36
+ /**
37
+ * "Smart speed · about 6 min shorter" — the visible surface for an automatic
38
+ * correction (doctrine 2). Empty when the saving would round to nothing, so
39
+ * the caller never flashes a claim it can't back.
40
+ */
41
+ export function smartSpeedLabel(map, opts) {
42
+ const saved = map ? estimateSaving(map, opts ?? null) : 0;
43
+ if (!(saved >= 1))
44
+ return '';
45
+ if (saved < 60)
46
+ return `Smart speed · ${Math.round(saved)}s shorter`;
47
+ return `Smart speed · ~${Math.round(saved / 60)} min shorter`;
48
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Per-speaker pace — the `speaker` factor of the rate composer (mos-5lzns.32).
3
+ *
4
+ * The measuring is `../advisors/speaker-rate` (ported verbatim by .27); this
5
+ * file is the adapter that feeds it the component's props and decides which
6
+ * rate wins for a name:
7
+ *
8
+ * · MANUAL rates are keyed by speaker NAME (the display name a human sees
9
+ * in the legend, not a diarisation label) and ALWAYS beat measured ones.
10
+ * A person who set "Darren 1.3×" meant it; a measurement that disagrees
11
+ * is the thing that is wrong.
12
+ * · MEASURED rates come from `suggestRates(speakerPace(...))` — words per
13
+ * minute off the diarised transcript, levelled to the median pace — and
14
+ * fill only the names nobody set by hand.
15
+ *
16
+ * The advisor collapses to 1 whenever it has nothing to say: feature off, no
17
+ * turns, a gap between turns, an ad read, or a name with no rate. `1` is the
18
+ * composer's identity, so there is no "feature absent" branch anywhere.
19
+ *
20
+ * Pure: no DOM, no <video>, no settings reads (src/lib/video/README.md).
21
+ */
22
+ import { type RateMap, type RateSegment, type RateTurn, type SuggestOpts } from '../advisors/speaker-rate';
23
+ import type { VideoSpeakerTurn, VideoTranscriptSegment } from '../types';
24
+ /**
25
+ * The name a rate is keyed by — the display name when there is one, else the
26
+ * identity. Deliberately the same precedence for turns and segments, so a
27
+ * measured row and a manual entry for the same person share a key.
28
+ */
29
+ export declare function speakerNameOf(s: {
30
+ speakerName?: string | null;
31
+ speakerIdentity?: string | null;
32
+ }): string | null;
33
+ /** Component turns → the advisor's shape. Ad reads keep their flag (never a person). */
34
+ export declare function toRateTurns(turns: readonly VideoSpeakerTurn[] | null | undefined): RateTurn[];
35
+ /** Component transcript → the advisor's shape. */
36
+ export declare function toRateSegments(segments: readonly VideoTranscriptSegment[] | null | undefined): RateSegment[];
37
+ export interface SpeakerRateInput {
38
+ turns: RateTurn[];
39
+ segments: RateSegment[];
40
+ /** Hand-set rates by NAME. Always win. */
41
+ manual?: Record<string, number> | null;
42
+ /** Measure the rest from the transcript (false ⇒ manual only). */
43
+ measure?: boolean;
44
+ opts?: SuggestOpts | null;
45
+ }
46
+ /**
47
+ * name → rate. Measured first (if asked), then every valid manual entry laid
48
+ * over it — so manual beats measured by construction, not by a comparison.
49
+ * Null-prototype for the same reason the advisor's maps are: a speaker named
50
+ * "__proto__" is a key, not a prototype write.
51
+ */
52
+ export declare function speakerRateMap(input: SpeakerRateInput): RateMap;
53
+ /** The speaker factor at time t. 1 when nobody (or an ad) is talking, or no rate is known. */
54
+ export declare function speakerFactor(t: number, turns: RateTurn[], rates: RateMap | null): number;
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Per-speaker pace — the `speaker` factor of the rate composer (mos-5lzns.32).
3
+ *
4
+ * The measuring is `../advisors/speaker-rate` (ported verbatim by .27); this
5
+ * file is the adapter that feeds it the component's props and decides which
6
+ * rate wins for a name:
7
+ *
8
+ * · MANUAL rates are keyed by speaker NAME (the display name a human sees
9
+ * in the legend, not a diarisation label) and ALWAYS beat measured ones.
10
+ * A person who set "Darren 1.3×" meant it; a measurement that disagrees
11
+ * is the thing that is wrong.
12
+ * · MEASURED rates come from `suggestRates(speakerPace(...))` — words per
13
+ * minute off the diarised transcript, levelled to the median pace — and
14
+ * fill only the names nobody set by hand.
15
+ *
16
+ * The advisor collapses to 1 whenever it has nothing to say: feature off, no
17
+ * turns, a gap between turns, an ad read, or a name with no rate. `1` is the
18
+ * composer's identity, so there is no "feature absent" branch anywhere.
19
+ *
20
+ * Pure: no DOM, no <video>, no settings reads (src/lib/video/README.md).
21
+ */
22
+ import { rateAt, speakerPace, suggestRates } from '../advisors/speaker-rate';
23
+ import { RATE_MAX, RATE_MIN } from './compose';
24
+ /**
25
+ * The name a rate is keyed by — the display name when there is one, else the
26
+ * identity. Deliberately the same precedence for turns and segments, so a
27
+ * measured row and a manual entry for the same person share a key.
28
+ */
29
+ export function speakerNameOf(s) {
30
+ const n = (s.speakerName ?? '').trim() || (s.speakerIdentity ?? '').trim();
31
+ return n || null;
32
+ }
33
+ /** Component turns → the advisor's shape. Ad reads keep their flag (never a person). */
34
+ export function toRateTurns(turns) {
35
+ if (!Array.isArray(turns))
36
+ return [];
37
+ const out = [];
38
+ for (const t of turns) {
39
+ if (!t || typeof t !== 'object')
40
+ continue;
41
+ out.push({ start: t.startSec, end: t.endSec, name: t.ad ? null : speakerNameOf(t), ad: !!t.ad });
42
+ }
43
+ return out;
44
+ }
45
+ /** Component transcript → the advisor's shape. */
46
+ export function toRateSegments(segments) {
47
+ if (!Array.isArray(segments))
48
+ return [];
49
+ const out = [];
50
+ for (const s of segments) {
51
+ if (!s || typeof s !== 'object')
52
+ continue;
53
+ out.push({ start: s.startSec, end: s.endSec, text: s.text, speaker: speakerNameOf(s) });
54
+ }
55
+ return out;
56
+ }
57
+ /** A usable multiplier, or null. The composer's clamp range is the only sane one. */
58
+ function rateOrNull(v) {
59
+ const n = typeof v === 'number' ? v : Number.NaN;
60
+ return Number.isFinite(n) && n >= RATE_MIN && n <= RATE_MAX ? n : null;
61
+ }
62
+ /**
63
+ * name → rate. Measured first (if asked), then every valid manual entry laid
64
+ * over it — so manual beats measured by construction, not by a comparison.
65
+ * Null-prototype for the same reason the advisor's maps are: a speaker named
66
+ * "__proto__" is a key, not a prototype write.
67
+ */
68
+ export function speakerRateMap(input) {
69
+ const out = Object.create(null);
70
+ if (input.measure) {
71
+ const measured = suggestRates(speakerPace(input.segments, input.turns), input.opts ?? null);
72
+ for (const [name, r] of Object.entries(measured)) {
73
+ const v = rateOrNull(r);
74
+ if (v != null)
75
+ out[name] = v;
76
+ }
77
+ }
78
+ const manual = input.manual;
79
+ if (manual && typeof manual === 'object') {
80
+ for (const [name, r] of Object.entries(manual)) {
81
+ const key = name.trim();
82
+ const v = rateOrNull(r);
83
+ if (key && v != null)
84
+ out[key] = v;
85
+ }
86
+ }
87
+ return out;
88
+ }
89
+ /** The speaker factor at time t. 1 when nobody (or an ad) is talking, or no rate is known. */
90
+ export function speakerFactor(t, turns, rates) {
91
+ if (!rates || !turns.length)
92
+ return 1;
93
+ return rateOrNull(rateAt(t, turns, rates, 1)) ?? 1;
94
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * The rate loop's one-tick decision (mos-5lzns.32).
3
+ *
4
+ * The component runs its OWN 120ms interval for rate — separate from
5
+ * timeupdate (~4Hz, too coarse: a 0.35s gap would be half over before the
6
+ * boost arrived) and from night's ~1s meter. Each tick it reads the playhead
7
+ * and asks this module for the two advisor factors; `composeRate` does the
8
+ * multiplication and `shouldWriteRate` the deadband. This file only bundles
9
+ * the per-tick inputs so the component block stays a few lines long.
10
+ *
11
+ * Pure: no DOM, no <video>, no settings reads (src/lib/video/README.md).
12
+ */
13
+ import type { BoostOpts, SilenceMap } from '../advisors/smart-speed';
14
+ import type { RateMap, RateTurn } from '../advisors/speaker-rate';
15
+ /** The rate loop's cadence. */
16
+ export declare const RATE_TICK_MS = 120;
17
+ export interface RateAdvisors {
18
+ /** null ⇒ the speaker advisor is off (collapses to 1). */
19
+ speakerRates: RateMap | null;
20
+ turns: RateTurn[];
21
+ /** null ⇒ smart speed is off or has no map yet (collapses to 1). */
22
+ silence: SilenceMap | null;
23
+ boost?: BoostOpts | null;
24
+ }
25
+ export interface RateAdvice {
26
+ speaker: number;
27
+ silence: number;
28
+ /** base × speaker × silence, clamped — what the element should play at. */
29
+ rate: number;
30
+ }
31
+ /** What `onRateChange` reports. `base` is the only factor the UI shows. */
32
+ export interface RateReport extends RateAdvice {
33
+ base: number;
34
+ }
35
+ /** The composed rate at playhead `t` for the human's `base`. */
36
+ export declare function adviseRate(base: number, t: number, a: RateAdvisors): RateAdvice;
37
+ /** Is any advisor able to move the rate? False ⇒ the loop need not run. */
38
+ export declare function advisorsActive(a: RateAdvisors): boolean;
@@ -0,0 +1,15 @@
1
+ import { composeRate } from './compose';
2
+ import { silenceFactor } from './silence';
3
+ import { speakerFactor } from './speaker';
4
+ /** The rate loop's cadence. */
5
+ export const RATE_TICK_MS = 120;
6
+ /** The composed rate at playhead `t` for the human's `base`. */
7
+ export function adviseRate(base, t, a) {
8
+ const speaker = speakerFactor(t, a.turns, a.speakerRates);
9
+ const silence = silenceFactor(t, a.silence, a.boost ?? null);
10
+ return { speaker, silence, rate: composeRate(base, speaker, silence).rate };
11
+ }
12
+ /** Is any advisor able to move the rate? False ⇒ the loop need not run. */
13
+ export function advisorsActive(a) {
14
+ return !!a.silence || (!!a.speakerRates && a.turns.length > 0 && Object.keys(a.speakerRates).length > 0);
15
+ }