@joewinke/jatui 0.10.0 → 0.12.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.
@@ -0,0 +1,292 @@
1
+ /**
2
+ * "Night ears": loudness-compensated quiet listening. Ported verbatim from
3
+ * extension-library `lib/quiet-listening.js` (mos-5lzns.27). The graph builder
4
+ * lands here as mos-5lzns.31's contract promised (audio.ts is that task's
5
+ * wiring target); `amountFor` is the piece every caller can already use.
6
+ *
7
+ * This module owns TONE, never LEVEL. The host's loudness matching already
8
+ * rides the gain toward a target and a limiter catches the peaks; nothing in
9
+ * here adds makeup gain, and the fact that it doesn't is the whole reason the
10
+ * two can run at once without fighting. If this file ever grows a gain node
11
+ * that isn't pinned at unity, the rider downstream will chase it and you'll get
12
+ * the pumping that loudness matching was written to stop.
13
+ *
14
+ * The problem it does solve: your ears are not flat, and they get less flat the
15
+ * quieter things are. Equal-loudness contours (Fletcher–Munson, and the ISO 226
16
+ * revision that corrected them) fall off at both ends as level drops — at 2am
17
+ * at a volume that won't wake anyone, the bass has gone and the air on top has
18
+ * gone with it, and what's left is a midrange smear where dialogue used to be.
19
+ * The usual response is to keep nudging the volume up for the quiet line and
20
+ * back down for the next loud one. This tilts the spectrum back instead, and
21
+ * tightens the dynamics just enough that a whispered line rides above the room
22
+ * noise of a fridge and a fan.
23
+ *
24
+ * Rejected: a real ISO 226 inverse curve per SPL. It needs the absolute
25
+ * playback SPL to mean anything, and a browser cannot know the amp, the
26
+ * speakers, or the room — a curve derived from a number we can't measure is
27
+ * theatre. Two shelves and a presence bump are the honest version of the same
28
+ * idea: right shape, calibration left as one dial the listener can hear.
29
+ *
30
+ * createQuietChain(ctx) -> { input, output, setAmount, dispose }
31
+ * amountFor(volume, nightOn, opts) -> 0..1
32
+ *
33
+ * Module rules (src/lib/video/README.md): reads no settings, touches no
34
+ * <video> — the graph builder is the audio graph's one sanctioned exception
35
+ * and it takes an AudioContext(-shaped) object, never the element.
36
+ */
37
+ // ── the shape ───────────────────────────────────────────────────────────────
38
+ // Every number below is a maximum, reached only at amount 1, and every one of
39
+ // them is deliberately modest. This effect is wrong if you can point at it.
40
+ const LOW_HZ = 110; // under the fundamental of most speech (male ~85–180Hz), so
41
+ // the shelf lifts weight and room without thickening vowels
42
+ // — a 200Hz shelf muddies exactly what we're trying to keep
43
+ const LOW_DB = 4.5; // the tilt an equal-loudness contour asks for over roughly
44
+ // 20 phon of level drop. Past ~6dB it stops reading as
45
+ // "correct" and starts reading as "bass boost".
46
+ const HIGH_HZ = 7000; // sibilance and cymbal air live above here; below ~5kHz a
47
+ // shelf starts lifting the presence region twice, since
48
+ // the peak below already owns it
49
+ const HIGH_DB = 4.5; // matched to the low shelf on purpose: the compensation is
50
+ // a symmetric smile, not a tone preference
51
+ const PRES_HZ = 2500; // consonant band — the t/k/s/f transients that carry
52
+ // intelligibility, and the first thing lost when a mix is
53
+ // played quietly through small speakers
54
+ const PRES_DB = 2; // half the shelves, and capped hard: this region is where
55
+ // "harsh" lives, and 3dB+ here makes long viewing tiring
56
+ const PRES_Q = 0.9; // wide (~1.5 octaves). A narrow, surgical peak sounds like
57
+ // a formant and follows the voice around; a wide one just
58
+ // tips the band
59
+ const COMP_THRESHOLD = -28; // low, because the material we care about IS quiet.
60
+ // A -12dB threshold only ever touches the loud half
61
+ // and does nothing for the mumbled line.
62
+ const COMP_RATIO = 3; // gentle. Above ~4:1 you can hear the level moving, and a
63
+ // dynamics effect you can hear is worse than the problem.
64
+ const COMP_ATTACK = 0.01; // 10ms: catches a shouted word without clipping the
65
+ // leading transient of a consonant, which is what
66
+ // makes fast-attack compression sound lispy
67
+ const COMP_RELEASE = 0.3; // ~one syllable group. Faster pumps on speech rhythm;
68
+ // much slower and the compressor is still holding the
69
+ // level down through the quiet line it should lift
70
+ const COMP_KNEE = 12; // very soft — the transition into compression should be
71
+ // inaudible, and with ratio 1 (amount 0) knee is inert
72
+ // anyway, so a wide one costs nothing at rest
73
+ // Ramp constant for every parameter move. ~150ms is under the threshold where a
74
+ // tone change reads as an event and over the one where it clicks.
75
+ //
76
+ // Deliberately NOT shortened under prefers-reduced-motion. That preference is
77
+ // about visual and vestibular motion; applying it here would mean stepping gain
78
+ // and filter coefficients instantly, which is an audible click — the
79
+ // accessibility trade runs the wrong way. Motion this module can be blamed for
80
+ // is all in the host's UI, and it honors the preference there.
81
+ const RAMP_TAU = 0.15;
82
+ // A chain that passes audio through untouched — what we degrade to rather than
83
+ // throwing or handing the host a null it will splice into its graph. A missing
84
+ // feature is a shrug; a broken audio graph is silence with no way back, because
85
+ // createMediaElementSource is irreversible once the host has used it.
86
+ function wireChain(ctx) {
87
+ try {
88
+ // Bound call — ctx.createGain() not `const make = ctx.createGain; make()`.
89
+ // WebIDL methods (a real AudioContext's) enforce the receiver and an
90
+ // unbound call throws "Illegal invocation" — which would degrade a perfectly
91
+ // good context to the null wire on the partial-graph teardown path below.
92
+ // (Node's plain-object test doubles don't enforce receivers, which is why
93
+ // the arrow-function stand-ins never caught this; the class-based one in
94
+ // the suite does.)
95
+ const node = ctx?.createGain?.();
96
+ if (node?.gain)
97
+ node.gain.value = 1;
98
+ return {
99
+ input: node ?? null,
100
+ output: node ?? null,
101
+ setAmount() { },
102
+ dispose() {
103
+ try {
104
+ node?.disconnect?.();
105
+ }
106
+ catch {
107
+ /* already disconnected */
108
+ }
109
+ },
110
+ };
111
+ }
112
+ catch {
113
+ return { input: null, output: null, setAmount() { }, dispose() { } };
114
+ }
115
+ }
116
+ export function createQuietChain(ctx) {
117
+ // duck-typed, not instanceof: OfflineAudioContext, a vendor-prefixed context
118
+ // and a test double are all legitimate here, and instanceof would reject the
119
+ // first two for no reason
120
+ if (!ctx ||
121
+ typeof ctx.createBiquadFilter !== "function" ||
122
+ typeof ctx.createGain !== "function") {
123
+ return wireChain(ctx);
124
+ }
125
+ const anyCtx = ctx;
126
+ let low, pres, high, comp, input, output;
127
+ try {
128
+ // Unity gain nodes at both ends are splice points, not level controls, and
129
+ // they never move off 1. They exist so the host can connect once and keep
130
+ // its references while everything between them is re-tuned — and so a
131
+ // future reorder inside this file can't invalidate the host's graph.
132
+ input = anyCtx.createGain();
133
+ output = anyCtx.createGain();
134
+ input.gain.value = 1;
135
+ output.gain.value = 1;
136
+ // The per-property `if (x.param)` guards below are deliberate (the
137
+ // extension just assigns — it trusts the real WebAudio factories). Here the
138
+ // degrade-to-wire contract is the whole point: a stubbed or incomplete
139
+ // context must still yield a working chain, never a throw from assigning
140
+ // into a missing AudioParam. Every param the returned closure touches is
141
+ // read through the same guards, and setAmount's ramp() no-ops per-param, so
142
+ // a node missing (say) its Q param degrades that one knob, not the graph.
143
+ low = anyCtx.createBiquadFilter();
144
+ low.type = "lowshelf";
145
+ if (low.frequency)
146
+ low.frequency.value = LOW_HZ;
147
+ if (low.gain)
148
+ low.gain.value = 0;
149
+ pres = anyCtx.createBiquadFilter();
150
+ pres.type = "peaking";
151
+ if (pres.frequency)
152
+ pres.frequency.value = PRES_HZ;
153
+ if (pres.Q)
154
+ pres.Q.value = PRES_Q;
155
+ if (pres.gain)
156
+ pres.gain.value = 0;
157
+ high = anyCtx.createBiquadFilter();
158
+ high.type = "highshelf";
159
+ if (high.frequency)
160
+ high.frequency.value = HIGH_HZ;
161
+ if (high.gain)
162
+ high.gain.value = 0;
163
+ comp = anyCtx.createDynamicsCompressor();
164
+ // At rest: threshold 0dB and ratio 1:1 — the compressor's transfer function
165
+ // is the identity line, so it applies no reduction anywhere. (Honest
166
+ // caveat: WebAudio's compressor keeps its fixed internal lookahead delay
167
+ // even at ratio 1, so "bit-transparent" here means no gain and no tone
168
+ // change, not sample-aligned. Removing that delay would mean building and
169
+ // tearing down the node on every amount crossing, and rebuilding a live
170
+ // graph mid-playback clicks — a constant few ms of latency on a video whose
171
+ // audio track is already routed through WebAudio is the cheaper trade.)
172
+ if (comp.threshold)
173
+ comp.threshold.value = 0;
174
+ if (comp.ratio)
175
+ comp.ratio.value = 1;
176
+ if (comp.knee)
177
+ comp.knee.value = COMP_KNEE;
178
+ if (comp.attack)
179
+ comp.attack.value = COMP_ATTACK;
180
+ if (comp.release)
181
+ comp.release.value = COMP_RELEASE;
182
+ // EQ before compression, on purpose: the compressor should react to the
183
+ // spectrum you'll actually hear. Compressing first and tilting after means
184
+ // the shelves lift material the compressor has already decided was quiet.
185
+ input.connect(low);
186
+ low.connect(pres);
187
+ pres.connect(high);
188
+ high.connect(comp);
189
+ comp.connect(output);
190
+ }
191
+ catch {
192
+ // a partially-built graph is worse than none — tear down whatever exists
193
+ for (const n of [input, low, pres, high, comp, output]) {
194
+ try {
195
+ n?.disconnect?.();
196
+ }
197
+ catch {
198
+ /* never throw out of teardown */
199
+ }
200
+ }
201
+ return wireChain(ctx);
202
+ }
203
+ let disposed = false;
204
+ const ramp = (param, value) => {
205
+ if (!param)
206
+ return;
207
+ const now = Number.isFinite(anyCtx.currentTime) ? anyCtx.currentTime : 0;
208
+ // setTargetAtTime, not linearRampToValueAtTime: an exponential approach has
209
+ // no corner at either end, and it needs no scheduled end time — which
210
+ // matters because setAmount is called from a volume drag, many times a
211
+ // second, and each call must simply retarget the one already in flight
212
+ // rather than queue another segment behind it.
213
+ try {
214
+ param.setTargetAtTime(value, now, RAMP_TAU);
215
+ }
216
+ catch {
217
+ try {
218
+ param.value = value;
219
+ }
220
+ catch {
221
+ /* a read-only param is a shrug — the tone just doesn't move */
222
+ }
223
+ }
224
+ };
225
+ return {
226
+ input,
227
+ output,
228
+ setAmount(a) {
229
+ if (disposed)
230
+ return;
231
+ // garbage in reads as "off", never as an exception: this is called from a
232
+ // volume handler, and a throw there would take the player's UI with it
233
+ const n = typeof a === "number" && Number.isFinite(a)
234
+ ? Math.min(1, Math.max(0, a))
235
+ : 0;
236
+ ramp(low.gain, LOW_DB * n);
237
+ ramp(high.gain, HIGH_DB * n);
238
+ ramp(pres.gain, PRES_DB * n);
239
+ // Linear in dB for the threshold (0 → -28) and linear in ratio (1 → 3).
240
+ // Both curves are wrong in theory and right in practice: what the ear
241
+ // tracks is how much reduction is happening, and reduction is the product
242
+ // of the two, so the perceived effect already comes out gently
243
+ // accelerating without shaping either one.
244
+ ramp(comp.threshold, COMP_THRESHOLD * n);
245
+ ramp(comp.ratio, 1 + (COMP_RATIO - 1) * n);
246
+ },
247
+ dispose() {
248
+ if (disposed)
249
+ return;
250
+ disposed = true;
251
+ for (const n of [input, low, pres, high, comp, output]) {
252
+ try {
253
+ n.disconnect?.();
254
+ }
255
+ catch {
256
+ /* never throw out of teardown */
257
+ }
258
+ }
259
+ },
260
+ };
261
+ }
262
+ // ── how much ────────────────────────────────────────────────────────────────
263
+ const KNEE_DEFAULT = 0.35; // above about a third of the way up, a normal room
264
+ // is loud enough that the contours are near flat and
265
+ // compensation would just be a tone control
266
+ const NIGHT_FLOOR = 0.35; // "a little on after dark" — enough to hear on a
267
+ export function amountFor(volume, nightOn, opts) {
268
+ const floor = nightOn ? NIGHT_FLOOR : 0;
269
+ // knee comes from a caller and may be anything; an unusable one falls back
270
+ // rather than producing a divide-by-zero amount
271
+ let knee = KNEE_DEFAULT;
272
+ if (opts &&
273
+ typeof opts === "object" &&
274
+ typeof opts.knee === "number" &&
275
+ Number.isFinite(opts.knee) &&
276
+ opts.knee > 0) {
277
+ knee = Math.min(1, opts.knee);
278
+ }
279
+ // an unreadable volume means we know nothing about how loud this is, so we
280
+ // apply only what the time of day justifies — guessing "very quiet" from NaN
281
+ // would slam full compensation on at an unknown level
282
+ if (typeof volume !== "number" || !Number.isFinite(volume))
283
+ return floor;
284
+ const v = Math.min(1, Math.max(0, volume));
285
+ const t = Math.min(1, Math.max(0, 1 - v / knee)); // 1 at silence, 0 at/above knee
286
+ // smoothstep, not linear: a linear taper has a corner exactly where you cross
287
+ // the knee while dragging the volume, and that corner is audible as a lurch
288
+ // in tone at the one moment you're paying attention to level. This one leaves
289
+ // and arrives with zero slope, so crossing the knee is a non-event.
290
+ const taper = t * t * (3 - 2 * t);
291
+ return Math.min(1, Math.max(taper, floor));
292
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Resume at a sentence, never at a second. Ported verbatim from
3
+ * extension-library `lib/resume-point.js` (mos-5lzns.27).
4
+ *
5
+ * Reopening a video drops you back at the exact float you left on, which is
6
+ * almost always mid-word, so the first thing anyone does is scrub backwards a
7
+ * few seconds to get their bearings. That scrub is the bug. The data to avoid
8
+ * it is already on disk — transcript segment starts, diarized turns, chapter
9
+ * marks — so snap the stored time back to the nearest real boundary at or
10
+ * before it and hand the caller a line it can say out loud.
11
+ *
12
+ * snapResume(seconds, ctx, opts) -> { at, reason, label }
13
+ * resumeLabel(seconds, ctx) -> the line alone
14
+ *
15
+ * reason is one of "exact" | "start" | "sentence" | "turn" | "chapter", and
16
+ * only "exact" is a promise: at === seconds, nothing moved, safe for a
17
+ * caller to skip the seek entirely. The two near-the-beginning guards used
18
+ * to hand back "exact" while quietly relocating the playhead to 0 or to the
19
+ * maxBack floor — a caller trusting the label sat stuck wherever the clamp
20
+ * put it. They report "start" now: you are at the beginning of the video,
21
+ * not resuming, which is a true statement whether or not the number moved.
22
+ *
23
+ * ctx = { transcript, speakers, chapters } — any may be null/empty/unsorted
24
+ * opts = { maxBack } — the most it may ever rewind. A hard ceiling, not a
25
+ * hint: a resume that lands a minute early is worse than one that lands
26
+ * mid-word, because it silently replays content you already watched and you
27
+ * don't find out until you recognise it.
28
+ *
29
+ * CLOSEST WINS, not most-specific-wins. An earlier ranking tried sentence >
30
+ * turn > chapter outright and it was wrong in the common case: a chapter mark
31
+ * 0.3s back is obviously the right place to land, and burning 9s to reach a
32
+ * sentence start instead just to honour a hierarchy throws away the thing the
33
+ * user actually recognises. Specificity only breaks ties.
34
+ *
35
+ * No motion lives here — this returns a number, and whether the player seeks
36
+ * hard or eases is the player's call. A module that decides *where* to land
37
+ * should not also decide how the landing animates.
38
+ *
39
+ * Input shapes (read structurally, extension spellings): transcript entries
40
+ * carry `start`/`end`/`text`/`speaker`; speaker turns carry
41
+ * `start`/`end`/`name`/`ad`; chapters carry `start`/`title`. mos-5lzns.34 owns
42
+ * the component wiring (resume-at-sentence) — persistence there is a
43
+ * host-supplied state adapter, never a jatui API call.
44
+ */
45
+ export type ResumeReason = "exact" | "start" | "sentence" | "turn" | "chapter";
46
+ export interface ResumePoint {
47
+ at: number;
48
+ reason: ResumeReason;
49
+ label: string;
50
+ }
51
+ export interface ResumeCtx {
52
+ /** Transcript segments: { start, end, text, speaker? }. */
53
+ transcript?: unknown;
54
+ /** Diarized turns: { start, end, name, ad? }. */
55
+ speakers?: unknown;
56
+ /** Chapter marks: { start, title? }. */
57
+ chapters?: unknown;
58
+ }
59
+ export interface ResumeOpts {
60
+ maxBack?: number;
61
+ }
62
+ /**
63
+ * Snap a stored resume time back to the nearest natural boundary.
64
+ * Returns { at, reason, label } — always, never throws.
65
+ */
66
+ export declare function snapResume(seconds: unknown, ctx?: ResumeCtx | null, opts?: ResumeOpts | null): ResumePoint;
67
+ /**
68
+ * The human line alone: who was talking and roughly what about.
69
+ * Returns "" when there is nothing worth saying.
70
+ */
71
+ export declare function resumeLabel(seconds: unknown, ctx?: ResumeCtx | null): string;
@@ -0,0 +1,302 @@
1
+ /**
2
+ * Resume at a sentence, never at a second. Ported verbatim from
3
+ * extension-library `lib/resume-point.js` (mos-5lzns.27).
4
+ *
5
+ * Reopening a video drops you back at the exact float you left on, which is
6
+ * almost always mid-word, so the first thing anyone does is scrub backwards a
7
+ * few seconds to get their bearings. That scrub is the bug. The data to avoid
8
+ * it is already on disk — transcript segment starts, diarized turns, chapter
9
+ * marks — so snap the stored time back to the nearest real boundary at or
10
+ * before it and hand the caller a line it can say out loud.
11
+ *
12
+ * snapResume(seconds, ctx, opts) -> { at, reason, label }
13
+ * resumeLabel(seconds, ctx) -> the line alone
14
+ *
15
+ * reason is one of "exact" | "start" | "sentence" | "turn" | "chapter", and
16
+ * only "exact" is a promise: at === seconds, nothing moved, safe for a
17
+ * caller to skip the seek entirely. The two near-the-beginning guards used
18
+ * to hand back "exact" while quietly relocating the playhead to 0 or to the
19
+ * maxBack floor — a caller trusting the label sat stuck wherever the clamp
20
+ * put it. They report "start" now: you are at the beginning of the video,
21
+ * not resuming, which is a true statement whether or not the number moved.
22
+ *
23
+ * ctx = { transcript, speakers, chapters } — any may be null/empty/unsorted
24
+ * opts = { maxBack } — the most it may ever rewind. A hard ceiling, not a
25
+ * hint: a resume that lands a minute early is worse than one that lands
26
+ * mid-word, because it silently replays content you already watched and you
27
+ * don't find out until you recognise it.
28
+ *
29
+ * CLOSEST WINS, not most-specific-wins. An earlier ranking tried sentence >
30
+ * turn > chapter outright and it was wrong in the common case: a chapter mark
31
+ * 0.3s back is obviously the right place to land, and burning 9s to reach a
32
+ * sentence start instead just to honour a hierarchy throws away the thing the
33
+ * user actually recognises. Specificity only breaks ties.
34
+ *
35
+ * No motion lives here — this returns a number, and whether the player seeks
36
+ * hard or eases is the player's call. A module that decides *where* to land
37
+ * should not also decide how the landing animates.
38
+ *
39
+ * Input shapes (read structurally, extension spellings): transcript entries
40
+ * carry `start`/`end`/`text`/`speaker`; speaker turns carry
41
+ * `start`/`end`/`name`/`ad`; chapters carry `start`/`title`. mos-5lzns.34 owns
42
+ * the component wiring (resume-at-sentence) — persistence there is a
43
+ * host-supplied state adapter, never a jatui API call.
44
+ */
45
+ const START_FLOOR = 2; // s — below this you are at the beginning, not resuming
46
+ const DEFAULT_MAX_BACK = 15; // s
47
+ const EPS = 1e-3; // float slop; boundaries arrive as parsed JSON floats
48
+ const LABEL_WORDS = 8;
49
+ // below this a "snap" is a rounding artefact, not a landing worth announcing
50
+ const MIN_MOVE = 0.25;
51
+ // Raw diarizer labels are not names. "picking up where SPEAKER_01 was saying"
52
+ // is worse than saying nothing, so an unresolved label is treated as absent.
53
+ const RAW_LABEL = /^SPEAKER[_\s-]?\d+$/i;
54
+ function sortedByStart(list) {
55
+ if (!Array.isArray(list))
56
+ return [];
57
+ const out = [];
58
+ for (const e of list) {
59
+ if (!e || typeof e !== "object")
60
+ continue;
61
+ const start = Number(e.start);
62
+ if (!Number.isFinite(start))
63
+ continue;
64
+ const s = Math.max(0, start);
65
+ const rawEnd = Number(e.end);
66
+ // a missing/degenerate end means the entry covers only its own instant —
67
+ // never extrapolate one, an invented end makes resumeLabel claim coverage
68
+ // over silence it knows nothing about
69
+ out.push({
70
+ start: s,
71
+ end: Number.isFinite(rawEnd) && rawEnd > s ? rawEnd : s,
72
+ src: e,
73
+ });
74
+ }
75
+ out.sort((a, b) => a.start - b.start);
76
+ return out;
77
+ }
78
+ function lastAtOrBefore(list, t) {
79
+ let lo = 0;
80
+ let hi = list.length - 1;
81
+ let ans = -1;
82
+ while (lo <= hi) {
83
+ const mid = (lo + hi) >> 1;
84
+ if (list[mid].start <= t + EPS) {
85
+ ans = mid;
86
+ lo = mid + 1;
87
+ }
88
+ else {
89
+ hi = mid - 1;
90
+ }
91
+ }
92
+ return ans;
93
+ }
94
+ // Does this text finish a thought? Trailing quotes and brackets are stripped
95
+ // first so `he said "we're done."` still counts — the punctuation that matters
96
+ // is under the wrapper, and diarized transcripts are full of wrappers.
97
+ //
98
+ // An ellipsis is deliberately NOT terminal: "so anyway…" is the middle of a
99
+ // sentence that the speaker trailed out of, and landing there is exactly the
100
+ // mid-thought drop this module exists to prevent. It has to be excluded
101
+ // explicitly because "..." ends in a period.
102
+ function endsSentence(text) {
103
+ if (typeof text !== "string")
104
+ return false; // no text is no evidence, not a boundary
105
+ const t = text.trim().replace(/["'”’»)\]]+$/, "");
106
+ if (!t)
107
+ return false;
108
+ if (/(\.\.\.|…)$/.test(t))
109
+ return false;
110
+ return /[.?!]$/.test(t);
111
+ }
112
+ // A segment start is only a SENTENCE start if the segment before it closed one.
113
+ // Segment boundaries are decoder chunk edges, not grammar — an ASR pass will
114
+ // happily split "if you drag the / trim handles over here" across two of them,
115
+ // and snapping to the second is the same mid-word landing with extra steps.
116
+ // Index 0 is always a sentence start: nothing precedes it.
117
+ function isSentenceStart(segs, i) {
118
+ if (i <= 0)
119
+ return i === 0;
120
+ return endsSentence(segs[i - 1].src.text);
121
+ }
122
+ function sentenceCandidate(segs, seconds, floor) {
123
+ // Walk back from the latest segment at-or-before `seconds` until one is a
124
+ // real sentence start. The walk is bounded by the same window as everything
125
+ // else, so a long unbroken monologue simply yields no sentence candidate
126
+ // rather than rewinding to wherever the last full stop happened to be.
127
+ for (let i = lastAtOrBefore(segs, seconds); i >= 0; i--) {
128
+ if (segs[i].start < floor - EPS)
129
+ break;
130
+ if (isSentenceStart(segs, i))
131
+ return { at: segs[i].start, reason: "sentence", rank: 3 };
132
+ }
133
+ return null;
134
+ }
135
+ function turnCandidate(turns, seconds, floor) {
136
+ // Ad reads are not landing points: "picking up where the sponsor was
137
+ // reading" is never what you wanted. The walk STOPS at one rather than
138
+ // stepping over it — the comment here used to claim that and the code did
139
+ // the opposite (`continue`, not `break`), so a resume a few seconds into a
140
+ // sponsor read walked back past the whole thing and made you sit through it
141
+ // again. Landing before an ad you had already got past is the one outcome
142
+ // worse than landing mid-word.
143
+ for (let i = lastAtOrBefore(turns, seconds); i >= 0; i--) {
144
+ if (turns[i].start < floor - EPS)
145
+ break;
146
+ if (turns[i].src.ad === true)
147
+ return null;
148
+ return { at: turns[i].start, reason: "turn", rank: 2 };
149
+ }
150
+ return null;
151
+ }
152
+ // The ad ranges, for the two candidate paths that have no idea speakers exist.
153
+ // Ads live in the SPEAKERS lane, not the transcript, so without this a resume
154
+ // inside a sponsor read falls straight through to the sentence path, lands on
155
+ // the ad's first sentence, and quotes the sponsor back to you in the toast.
156
+ function adRanges(turns) {
157
+ const out = [];
158
+ for (const t of turns) {
159
+ if (t.src.ad === true && Number.isFinite(t.end) && t.end > t.start)
160
+ out.push([t.start, t.end]);
161
+ }
162
+ return out;
163
+ }
164
+ const insideAd = (t, ads) => ads.some(([a, b]) => t >= a - EPS && t <= b + EPS);
165
+ function chapterCandidate(chaps, seconds, floor) {
166
+ const i = lastAtOrBefore(chaps, seconds);
167
+ if (i < 0 || chaps[i].start < floor - EPS)
168
+ return null;
169
+ return { at: chaps[i].start, reason: "chapter", rank: 1 };
170
+ }
171
+ /**
172
+ * Snap a stored resume time back to the nearest natural boundary.
173
+ * Returns { at, reason, label } — always, never throws.
174
+ */
175
+ export function snapResume(seconds, ctx, opts) {
176
+ const t = Number(seconds);
177
+ const o = opts && typeof opts === "object" ? opts : {};
178
+ const rawBack = Number(o.maxBack);
179
+ // an unreadable maxBack falls back to the default rather than to zero: the
180
+ // caller clearly wanted snapping, and silently disabling it produces a bug
181
+ // that looks exactly like "the feature doesn't work"
182
+ const maxBack = Number.isFinite(rawBack)
183
+ ? Math.max(0, rawBack)
184
+ : DEFAULT_MAX_BACK;
185
+ // Garbage input (NaN, undefined, Infinity, negative) has no honest place to
186
+ // resume to, so the safe neutral value is 0 — the house rule of "bad input
187
+ // returns a neutral value" applied to a timeline. Reason is "start", not
188
+ // "exact": for anything other than a literal 0 this still moved the number
189
+ // (a negative or non-finite `seconds` is never equal to the 0 we hand
190
+ // back), and "exact" is reserved for at === seconds.
191
+ if (!Number.isFinite(t) || t <= 0)
192
+ return { at: 0, reason: "start", label: "" };
193
+ const c = ctx && typeof ctx === "object" ? ctx : {};
194
+ const floor = Math.max(0, t - maxBack);
195
+ // Near the top of a video there is nothing to pick up from — you have not
196
+ // watched enough to have lost your place, and any snap here is just 0 with
197
+ // extra reasoning attached. Still clamped to the window: with the default
198
+ // maxBack this is plain 0, but the two rules genuinely collide when a caller
199
+ // passes maxBack < 2, and the ceiling is the one that was called a hard
200
+ // promise, so it wins.
201
+ //
202
+ // Found by adversarial review: this used to report "exact" here, which is
203
+ // wrong whenever the window clamp actually bites — snapResume(1.5, ctx,
204
+ // {maxBack: 0.5}) moved the playhead a full half second (floor = 1) and
205
+ // called itself unchanged. Callers key off "exact" to mean "skip the seek",
206
+ // so that lie meant a real half-second seek silently got dropped. "start"
207
+ // says what is actually true: you're at the beginning, not resuming —
208
+ // regardless of whether `floor` happened to land on 0 or on the window edge.
209
+ if (t < START_FLOOR)
210
+ return { at: floor, reason: "start", label: "" };
211
+ const turns = sortedByStart(c.speakers);
212
+ const ads = adRanges(turns);
213
+ // Already inside a sponsor read: every rewind from here lands in it or
214
+ // before it, and both mean listening to it again. Stay exactly where you
215
+ // are and say nothing — the one case where mid-word is the right answer.
216
+ if (insideAd(t, ads))
217
+ return { at: t, reason: "exact", label: "" };
218
+ const found = [
219
+ sentenceCandidate(sortedByStart(c.transcript), t, floor),
220
+ turnCandidate(turns, t, floor),
221
+ chapterCandidate(sortedByStart(c.chapters), t, floor),
222
+ ];
223
+ let best = null;
224
+ for (const cand of found) {
225
+ if (!cand)
226
+ continue;
227
+ if (insideAd(cand.at, ads))
228
+ continue; // never land in an ad, whatever found it
229
+ if (!best || cand.at > best.at + EPS)
230
+ best = cand;
231
+ else if (Math.abs(cand.at - best.at) <= EPS && cand.rank > best.rank)
232
+ best = cand;
233
+ }
234
+ if (!best)
235
+ return { at: t, reason: "exact", label: "" };
236
+ const at = Math.min(t, Math.max(floor, best.at)); // belt-and-braces on the contract
237
+ // No label when nothing moved — and this is now enforced rather than merely
238
+ // described. A toast that fires on a no-op trains you to ignore toasts, and
239
+ // "picking up where X was saying" is a lie when the playhead has not moved.
240
+ const moved = at < t - MIN_MOVE;
241
+ return { at, reason: best.reason, label: moved ? resumeLabel(at, c) : "" };
242
+ }
243
+ // ── the line ────────────────────────────────────────────────────────────────
244
+ function coveringAt(list, t) {
245
+ const i = lastAtOrBefore(list, t);
246
+ if (i < 0)
247
+ return null;
248
+ return t <= list[i].end + EPS ? list[i] : null; // a gap covers nothing
249
+ }
250
+ function displayName(raw) {
251
+ if (typeof raw !== "string")
252
+ return "";
253
+ const n = raw.trim();
254
+ return !n || RAW_LABEL.test(n) ? "" : n;
255
+ }
256
+ // Words a quote should never END on. Cutting at a fixed count lands on "…it
257
+ // snaps to the…" about a third of the time, which reads as a glitch rather
258
+ // than a truncation; dropping the dangling function words costs nothing and
259
+ // the line ends on something that carries meaning. Only applied when the quote
260
+ // was actually cut, and never below three words.
261
+ const DANGLING = /^(a|an|the|and|but|or|of|to|in|on|at|for|with|that|is|was|it|as|so)$/i;
262
+ function firstWords(text, n) {
263
+ if (typeof text !== "string")
264
+ return "";
265
+ const words = text.trim().replace(/\s+/g, " ").split(" ").filter(Boolean);
266
+ if (!words.length)
267
+ return "";
268
+ if (words.length <= n)
269
+ return words.join(" ");
270
+ const head = words.slice(0, n);
271
+ while (head.length > 3 && DANGLING.test(head[head.length - 1]))
272
+ head.pop();
273
+ // a trailing comma or dash before an ellipsis reads as a typo, not a cut
274
+ return head.join(" ").replace(/[,;:—–-]+$/, "") + "…";
275
+ }
276
+ /**
277
+ * The human line alone: who was talking and roughly what about.
278
+ * Returns "" when there is nothing worth saying.
279
+ */
280
+ export function resumeLabel(seconds, ctx) {
281
+ const t = Number(seconds);
282
+ if (!Number.isFinite(t) || t < 0)
283
+ return "";
284
+ const c = ctx && typeof ctx === "object" ? ctx : {};
285
+ const seg = coveringAt(sortedByStart(c.transcript), t);
286
+ // Turns are the better source (they carry resolved names and survive a
287
+ // rename), but a segment's own speaker field is the fallback for libraries
288
+ // that were transcribed before diarization ran.
289
+ const name = displayName(coveringAt(sortedByStart(c.speakers), t)?.src.name) ||
290
+ displayName(seg?.src.speaker);
291
+ const words = firstWords(seg?.src.text, LABEL_WORDS);
292
+ // Quoted, never paraphrased. Paraphrasing needs a model, and a summary that
293
+ // is subtly wrong about what you were watching is more disorienting than the
294
+ // mid-word drop this whole module is fixing.
295
+ if (name && words)
296
+ return `picking up where ${name} was saying “${words}”`;
297
+ if (words)
298
+ return `picking up at “${words}”`;
299
+ if (name)
300
+ return `picking up where ${name} was talking`;
301
+ return "";
302
+ }