@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.
- package/CLAUDE.md +10 -0
- package/dist/components/VideoPlayer.svelte +504 -8
- package/dist/components/VideoPlayer.svelte.d.ts +42 -1
- package/dist/index.d.ts +6 -1
- package/dist/index.js +10 -0
- package/dist/video/README.md +18 -4
- package/dist/video/advisors/README.md +20 -0
- package/dist/video/advisors/qr.d.ts +95 -0
- package/dist/video/advisors/qr.js +546 -0
- package/dist/video/advisors/quiet-listening.d.ts +73 -0
- package/dist/video/advisors/quiet-listening.js +292 -0
- package/dist/video/advisors/resume-point.d.ts +71 -0
- package/dist/video/advisors/resume-point.js +302 -0
- package/dist/video/advisors/smart-speed.d.ts +82 -0
- package/dist/video/advisors/smart-speed.js +314 -0
- package/dist/video/advisors/speaker-rate.d.ts +74 -0
- package/dist/video/advisors/speaker-rate.js +322 -0
- package/dist/video/keys.d.ts +4 -4
- package/dist/video/keys.js +4 -3
- package/dist/video/night/clock.d.ts +31 -0
- package/dist/video/night/clock.js +58 -0
- package/dist/video/night/levels.d.ts +78 -0
- package/dist/video/night/levels.js +117 -0
- package/dist/video/night/meter.d.ts +53 -0
- package/dist/video/night/meter.js +115 -0
- package/dist/video/night/track.d.ts +105 -0
- package/dist/video/night/track.js +138 -0
- package/dist/video/night.d.ts +25 -20
- package/dist/video/night.js +25 -20
- package/dist/video/settings.js +10 -1
- package/package.json +3 -2
|
@@ -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
|
+
}
|