@joewinke/jatui 0.12.0 → 0.14.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/dist/components/VideoPlayer.svelte +332 -8
- package/dist/components/VideoPlayer.svelte.d.ts +76 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +4 -0
- package/dist/video/README.md +6 -5
- package/dist/video/audio/graph.d.ts +115 -0
- package/dist/video/audio/graph.js +400 -0
- package/dist/video/audio/rider.d.ts +134 -0
- package/dist/video/audio/rider.js +187 -0
- package/dist/video/audio.d.ts +30 -19
- package/dist/video/audio.js +30 -19
- package/dist/video/rate/ads.d.ts +36 -0
- package/dist/video/rate/ads.js +59 -0
- package/dist/video/rate/compose.d.ts +82 -0
- package/dist/video/rate/compose.js +114 -0
- package/dist/video/rate/silence.d.ts +27 -0
- package/dist/video/rate/silence.js +48 -0
- package/dist/video/rate/speaker.d.ts +54 -0
- package/dist/video/rate/speaker.js +94 -0
- package/dist/video/rate/tick.d.ts +38 -0
- package/dist/video/rate/tick.js +15 -0
- package/dist/video/rate.d.ts +20 -63
- package/dist/video/rate.js +20 -82
- package/dist/video/settings.js +16 -2
- package/package.json +1 -1
|
@@ -15,6 +15,10 @@
|
|
|
15
15
|
export type { VideoMarker } from '../video/band/markers';
|
|
16
16
|
// mos-5lzns.33 — night dimming.
|
|
17
17
|
export type { NightMode, NightWindow } from '../video/night/clock';
|
|
18
|
+
// mos-5lzns.31 — audio graph.
|
|
19
|
+
export type { AudioState, AudioStatus, AudioBrokenReason } from '../video/audio/graph';
|
|
20
|
+
// mos-5lzns.32 — rate composer (smart speed, speaker pace, skip ad reads).
|
|
21
|
+
export type { VideoAdSpan, RateReport } from '../video/rate';
|
|
18
22
|
</script>
|
|
19
23
|
|
|
20
24
|
<script lang="ts">
|
|
@@ -135,7 +139,25 @@
|
|
|
135
139
|
import { dragCancel, dragEnd, dragMove, dragStart, type DragState } from '../video/band/drag';
|
|
136
140
|
import { createBandLayerCache } from '../video/band/memo';
|
|
137
141
|
import { BAND_KEYS, PLAYER_KEYS, chapterJump, FRAME_STEP_SEC } from '../video/keys';
|
|
138
|
-
import {
|
|
142
|
+
import {
|
|
143
|
+
adMaxStep,
|
|
144
|
+
adSkipTarget,
|
|
145
|
+
adviseRate,
|
|
146
|
+
advisorsActive,
|
|
147
|
+
composeRate,
|
|
148
|
+
formatSpeedLabel,
|
|
149
|
+
normalizeAdSpans,
|
|
150
|
+
RATE_TICK_MS,
|
|
151
|
+
shouldWriteRate,
|
|
152
|
+
silenceMapFor,
|
|
153
|
+
smartSpeedLabel,
|
|
154
|
+
speakerRateMap,
|
|
155
|
+
stepRate,
|
|
156
|
+
toRateSegments,
|
|
157
|
+
toRateTurns,
|
|
158
|
+
type RateReport,
|
|
159
|
+
type VideoAdSpan
|
|
160
|
+
} from '../video/rate';
|
|
139
161
|
import { readTimeParam, WRITE_THROTTLE_MS, writeTimeParam } from '../video/deepLink';
|
|
140
162
|
import {
|
|
141
163
|
biasCells,
|
|
@@ -157,6 +179,13 @@
|
|
|
157
179
|
type NightFrame,
|
|
158
180
|
type NightState
|
|
159
181
|
} from '../video/night/track';
|
|
182
|
+
import {
|
|
183
|
+
createAudioSession,
|
|
184
|
+
routeSafety,
|
|
185
|
+
AUDIO_TARGET_DEFAULT,
|
|
186
|
+
type AudioBrokenReason,
|
|
187
|
+
type AudioState
|
|
188
|
+
} from '../video/audio';
|
|
160
189
|
|
|
161
190
|
let {
|
|
162
191
|
src,
|
|
@@ -214,6 +243,18 @@
|
|
|
214
243
|
strobeGuard = true,
|
|
215
244
|
onNightModeChange = undefined,
|
|
216
245
|
onNightBlind = undefined,
|
|
246
|
+
loudness = undefined,
|
|
247
|
+
loudnessTarget = AUDIO_TARGET_DEFAULT,
|
|
248
|
+
quietListening = undefined,
|
|
249
|
+
limiter = undefined,
|
|
250
|
+
onAudioState = undefined,
|
|
251
|
+
onAudioBroken = undefined,
|
|
252
|
+
smartSpeed = false,
|
|
253
|
+
speakerPace = false,
|
|
254
|
+
speakerRates = null,
|
|
255
|
+
adSpans = null,
|
|
256
|
+
skipAds = false,
|
|
257
|
+
onRateChange = undefined,
|
|
217
258
|
class: className = ''
|
|
218
259
|
}: {
|
|
219
260
|
src: string;
|
|
@@ -350,6 +391,78 @@
|
|
|
350
391
|
onNightModeChange?: (mode: NightMode) => void;
|
|
351
392
|
/** Frame readback was refused (CORS-tainted canvas) — night fell back to a fixed level. */
|
|
352
393
|
onNightBlind?: () => void;
|
|
394
|
+
/**
|
|
395
|
+
* Audio graph (mos-5lzns.31) — all three default OFF (absent = the
|
|
396
|
+
* feature does not exist on this player and nothing is ever routed).
|
|
397
|
+
*
|
|
398
|
+
* `loudness`: a gated RMS meter rides the gain slowly toward
|
|
399
|
+
* `loudnessTarget` (dBFS, −30…−12, default −20) so a whispered video and
|
|
400
|
+
* a blaring one arrive at the same level; clamped 0.3–1.35×, it rescues a
|
|
401
|
+
* quiet mix by a few dB and never becomes an amplifier.
|
|
402
|
+
* `quietListening`: loudness-compensated tone that rises as the volume
|
|
403
|
+
* drops (and sits a little on while night is armed).
|
|
404
|
+
* `limiter`: catches peaks above −3 dBFS that the slow rider can't.
|
|
405
|
+
*
|
|
406
|
+
* ⚠ Routing into WebAudio is permanent for the element, and a source
|
|
407
|
+
* that isn't CORS-clean routes to SILENCE. So the player routes only
|
|
408
|
+
* when the src is blob:/data: or `crossorigin` is set ("anonymous" /
|
|
409
|
+
* "use-credentials", with the server answering Access-Control-Allow-
|
|
410
|
+
* Origin) — a same-origin URL is NOT enough, since it may redirect to
|
|
411
|
+
* storage on another origin. Anything else is refused and plays
|
|
412
|
+
* unprocessed (`status: 'cors'`). It also waits for the AudioContext to
|
|
413
|
+
* be `running` (a user gesture); until then native audio plays.
|
|
414
|
+
*
|
|
415
|
+
* After routing it cannot be undone, so the player reports
|
|
416
|
+
* `status: 'broken'` and calls `onAudioBroken(reason)`:
|
|
417
|
+
* · 'unsafe-src' — the src later changed to one that isn't route-safe
|
|
418
|
+
* (or `crossorigin` was removed). Remount the player (a fresh element)
|
|
419
|
+
* for that source.
|
|
420
|
+
* · 'silent' — bit-exact silence for 4s while the video plays audibly
|
|
421
|
+
* AND the browser shows decoded audio flowing (a video with no audio
|
|
422
|
+
* track never fires). Show a notice and/or turn the audio features
|
|
423
|
+
* off for that src — do NOT remount; it would loop.
|
|
424
|
+
* · 'build-failed' — construction threw: plain audio for the session.
|
|
425
|
+
*/
|
|
426
|
+
loudness?: boolean;
|
|
427
|
+
loudnessTarget?: number;
|
|
428
|
+
quietListening?: boolean;
|
|
429
|
+
limiter?: boolean;
|
|
430
|
+
/** Graph status + the rider's live gain (≤5×/s while matching acts). */
|
|
431
|
+
onAudioState?: (state: AudioState) => void;
|
|
432
|
+
/** Entered `broken`: 'build-failed' | 'unsafe-src' | 'silent' (see above). */
|
|
433
|
+
onAudioBroken?: (reason: AudioBrokenReason) => void;
|
|
434
|
+
/**
|
|
435
|
+
* Rate composer (mos-5lzns.32): `rate = base × speaker × silence`. The
|
|
436
|
+
* speed readout only ever reports `base` — the rate a human chose — so it
|
|
437
|
+
* keeps saying `1×` while a gap is taken at 2.2×. Every advisor is off by
|
|
438
|
+
* default and collapses to 1 until its data arrives: off, the element
|
|
439
|
+
* plays at exactly `base`.
|
|
440
|
+
*
|
|
441
|
+
* Smart speed: run the silences between sentences at 2.2×, found off the
|
|
442
|
+
* `waveform` envelope. Speech stays at `base`.
|
|
443
|
+
*/
|
|
444
|
+
smartSpeed?: boolean;
|
|
445
|
+
/**
|
|
446
|
+
* Per-speaker pace: level everyone to the median words-per-minute,
|
|
447
|
+
* measured from the diarised `transcript` + `speakers`. Ad reads and
|
|
448
|
+
* gaps between turns play at `base`.
|
|
449
|
+
*/
|
|
450
|
+
speakerPace?: boolean;
|
|
451
|
+
/**
|
|
452
|
+
* Hand-set speaker rates keyed by speaker NAME (the legend's display
|
|
453
|
+
* name). They always beat measured ones, and like them apply only while
|
|
454
|
+
* `speakerPace` is on — off means off. Range 0.0625–16.
|
|
455
|
+
*/
|
|
456
|
+
speakerRates?: Record<string, number> | null;
|
|
457
|
+
/** Host-detected ad reads. Merged with any `ad: true` speaker turns. */
|
|
458
|
+
adSpans?: VideoAdSpan[] | null;
|
|
459
|
+
/**
|
|
460
|
+
* Skip ad reads: when playback runs into an ad span it jumps to its end
|
|
461
|
+
* (the OSD says so). A seek that lands inside an ad is honoured.
|
|
462
|
+
*/
|
|
463
|
+
skipAds?: boolean;
|
|
464
|
+
/** The composed rate changed (real change only). `base` is what the UI shows. */
|
|
465
|
+
onRateChange?: (report: RateReport) => void;
|
|
353
466
|
class?: string;
|
|
354
467
|
} = $props();
|
|
355
468
|
|
|
@@ -600,25 +713,236 @@
|
|
|
600
713
|
if (videoEl) videoEl.currentTime = t;
|
|
601
714
|
}
|
|
602
715
|
|
|
603
|
-
// ── Speed
|
|
716
|
+
// ── Speed + rate composer (video/rate/*, mos-5lzns.32) ──────────────────
|
|
717
|
+
// rate = base × speaker × silence. `base` is the only rate a human chose
|
|
718
|
+
// and the only one the OSD reports; the advisors multiply into the APPLIED
|
|
719
|
+
// rate from their own 120ms loop, which runs only while one of them (or ad
|
|
720
|
+
// skipping) can act. Off, every factor is 1 and the element plays at
|
|
721
|
+
// exactly `base` — the contract in video/rate/compose.ts.
|
|
722
|
+
const rateTurns = $derived(toRateTurns(speakers));
|
|
723
|
+
const rateSegments = $derived(toRateSegments(transcript));
|
|
724
|
+
const rateAdvisors = $derived({
|
|
725
|
+
speakerRates: speakerPace
|
|
726
|
+
? speakerRateMap({ turns: rateTurns, segments: rateSegments, manual: speakerRates, measure: true })
|
|
727
|
+
: null,
|
|
728
|
+
turns: rateTurns,
|
|
729
|
+
silence: smartSpeed ? silenceMapFor(waveform, effectiveDuration) : null
|
|
730
|
+
});
|
|
731
|
+
const adRanges = $derived(skipAds ? normalizeAdSpans(adSpans, speakers) : []);
|
|
732
|
+
const rateLoopOn = $derived(advisorsActive(rateAdvisors) || adRanges.length > 0);
|
|
733
|
+
/** Playing in a visible tab — the only time the 120ms loop has anything to do. */
|
|
734
|
+
let rateRunning = $state(false);
|
|
735
|
+
// Plain (non-reactive): written and read inside one tick.
|
|
736
|
+
let rateSpeaker = 1;
|
|
737
|
+
let rateSilence = 1;
|
|
604
738
|
/**
|
|
605
|
-
*
|
|
606
|
-
*
|
|
607
|
-
*
|
|
608
|
-
* reporting `base` on the OSD — the contract in video/rate.ts.
|
|
739
|
+
* `ratechange` events this component still owes itself: every write that
|
|
740
|
+
* changes the value fires exactly one. Anything beyond them is a human (the
|
|
741
|
+
* native speed menu) — even one that picks the very value we last wrote.
|
|
609
742
|
*/
|
|
743
|
+
let rateSelfWrites = 0;
|
|
744
|
+
let reportedBase = Number.NaN;
|
|
745
|
+
let reportedRate = Number.NaN;
|
|
746
|
+
/** Last playhead sample while playing; null after a seek/pause ⇒ never an ad skip. */
|
|
747
|
+
let adPrev: number | null = null;
|
|
748
|
+
let adPrevAt = 0;
|
|
749
|
+
|
|
610
750
|
function applyRate() {
|
|
611
751
|
const v = videoEl;
|
|
612
752
|
if (!v) return;
|
|
613
|
-
const { rate } = composeRate(baseRate,
|
|
753
|
+
const { rate } = composeRate(baseRate, rateSpeaker, rateSilence);
|
|
614
754
|
// Only real changes are written — playbackRate is a live audio parameter.
|
|
615
|
-
if (v.playbackRate
|
|
755
|
+
if (shouldWriteRate(v.playbackRate, rate, baseRate)) {
|
|
756
|
+
rateSelfWrites++;
|
|
757
|
+
v.playbackRate = rate;
|
|
758
|
+
}
|
|
759
|
+
if (baseRate !== reportedBase || rate !== reportedRate) {
|
|
760
|
+
reportedBase = baseRate;
|
|
761
|
+
reportedRate = rate;
|
|
762
|
+
onRateChange?.({ base: baseRate, speaker: rateSpeaker, silence: rateSilence, rate });
|
|
763
|
+
}
|
|
616
764
|
}
|
|
617
765
|
function stepSpeed(direction: 1 | -1) {
|
|
618
766
|
baseRate = stepRate(baseRate, direction);
|
|
619
767
|
applyRate();
|
|
620
768
|
flashOsd(formatSpeedLabel(baseRate));
|
|
621
769
|
}
|
|
770
|
+
function rateTick() {
|
|
771
|
+
const v = videoEl;
|
|
772
|
+
if (!v) return;
|
|
773
|
+
const t = v.currentTime || 0;
|
|
774
|
+
if (v.paused || v.seeking || adRanges.length === 0) adPrev = null;
|
|
775
|
+
else {
|
|
776
|
+
const now = performance.now();
|
|
777
|
+
const hop = adSkipTarget(adPrev, t, adRanges, adMaxStep(now - adPrevAt, v.playbackRate));
|
|
778
|
+
adPrevAt = now;
|
|
779
|
+
adPrev = t;
|
|
780
|
+
if (hop) {
|
|
781
|
+
adPrev = null;
|
|
782
|
+
seekTo(hop.to);
|
|
783
|
+
flashOsd(`Skipped ad · ${formatDuration(hop.to - hop.from)}`);
|
|
784
|
+
return;
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
const a = adviseRate(baseRate, t, rateAdvisors);
|
|
788
|
+
rateSpeaker = a.speaker;
|
|
789
|
+
rateSilence = a.silence;
|
|
790
|
+
applyRate();
|
|
791
|
+
}
|
|
792
|
+
$effect(() => {
|
|
793
|
+
const v = videoEl;
|
|
794
|
+
if (!v) return;
|
|
795
|
+
rateSelfWrites = 0;
|
|
796
|
+
const syncRunning = () => {
|
|
797
|
+
const on = !v.paused && !v.ended && !document.hidden;
|
|
798
|
+
if (rateRunning !== on) rateRunning = on;
|
|
799
|
+
};
|
|
800
|
+
const onSeeking = () => (adPrev = null);
|
|
801
|
+
// A paused scrub re-advises once, so the frame you land on already
|
|
802
|
+
// carries its rate when play resumes (the loop itself is stopped).
|
|
803
|
+
const onSeeked = () => {
|
|
804
|
+
if (rateLoopOn && !rateRunning) rateTick();
|
|
805
|
+
};
|
|
806
|
+
// A rate this component didn't write came from a human (the native
|
|
807
|
+
// controls' speed menu): it becomes the new base, so the advisors keep
|
|
808
|
+
// multiplying on top of it instead of fighting it.
|
|
809
|
+
const onNativeRate = () => {
|
|
810
|
+
if (rateSelfWrites > 0) {
|
|
811
|
+
rateSelfWrites--;
|
|
812
|
+
return;
|
|
813
|
+
}
|
|
814
|
+
const r = v.playbackRate;
|
|
815
|
+
if (!Number.isFinite(r) || r < 0) return;
|
|
816
|
+
baseRate = r;
|
|
817
|
+
applyRate();
|
|
818
|
+
};
|
|
819
|
+
syncRunning();
|
|
820
|
+
for (const ev of ['play', 'pause', 'ended']) v.addEventListener(ev, syncRunning);
|
|
821
|
+
document.addEventListener('visibilitychange', syncRunning);
|
|
822
|
+
v.addEventListener('seeking', onSeeking);
|
|
823
|
+
v.addEventListener('seeked', onSeeked);
|
|
824
|
+
v.addEventListener('ratechange', onNativeRate);
|
|
825
|
+
return () => {
|
|
826
|
+
for (const ev of ['play', 'pause', 'ended']) v.removeEventListener(ev, syncRunning);
|
|
827
|
+
document.removeEventListener('visibilitychange', syncRunning);
|
|
828
|
+
v.removeEventListener('seeking', onSeeking);
|
|
829
|
+
v.removeEventListener('seeked', onSeeked);
|
|
830
|
+
v.removeEventListener('ratechange', onNativeRate);
|
|
831
|
+
};
|
|
832
|
+
});
|
|
833
|
+
// Armed: advise once now (so the first frame after play is already right),
|
|
834
|
+
// and off leaves no trace — every factor back to 1, the element to base.
|
|
835
|
+
$effect(() => {
|
|
836
|
+
if (!videoEl || !rateLoopOn) return;
|
|
837
|
+
untrack(rateTick);
|
|
838
|
+
return () => {
|
|
839
|
+
untrack(() => {
|
|
840
|
+
rateSpeaker = 1;
|
|
841
|
+
rateSilence = 1;
|
|
842
|
+
adPrev = null;
|
|
843
|
+
applyRate();
|
|
844
|
+
});
|
|
845
|
+
};
|
|
846
|
+
});
|
|
847
|
+
// The 120ms loop runs only while armed AND playing in a visible tab —
|
|
848
|
+
// paused, ended or hidden, there is no rate to steer.
|
|
849
|
+
$effect(() => {
|
|
850
|
+
if (!videoEl || !rateLoopOn || !rateRunning) return;
|
|
851
|
+
const id = setInterval(rateTick, RATE_TICK_MS);
|
|
852
|
+
untrack(rateTick);
|
|
853
|
+
return () => {
|
|
854
|
+
clearInterval(id);
|
|
855
|
+
adPrev = null;
|
|
856
|
+
};
|
|
857
|
+
});
|
|
858
|
+
// Nothing acts without something the user can see: an advisor announces
|
|
859
|
+
// itself once on the OSD when it arms (and again only after it disarms).
|
|
860
|
+
let rateAnnounced = { silence: false, speaker: false };
|
|
861
|
+
$effect(() => {
|
|
862
|
+
const { silence, speakerRates: table } = rateAdvisors;
|
|
863
|
+
const levelled = table ? Object.values(table).filter((r) => r !== 1).length : 0;
|
|
864
|
+
untrack(() => {
|
|
865
|
+
if (!silence) rateAnnounced.silence = false;
|
|
866
|
+
else if (!rateAnnounced.silence) {
|
|
867
|
+
rateAnnounced.silence = true;
|
|
868
|
+
const label = smartSpeedLabel(silence);
|
|
869
|
+
if (label) flashOsd(label);
|
|
870
|
+
}
|
|
871
|
+
if (!levelled) rateAnnounced.speaker = false;
|
|
872
|
+
else if (!rateAnnounced.speaker) {
|
|
873
|
+
rateAnnounced.speaker = true;
|
|
874
|
+
flashOsd(`Speaker pace · ${levelled} adjusted`);
|
|
875
|
+
}
|
|
876
|
+
});
|
|
877
|
+
});
|
|
878
|
+
|
|
879
|
+
// ── Audio (video/audio/*) ───────────────────────────────────────────────
|
|
880
|
+
// The session owns the graph and its one-way door; this block only hands
|
|
881
|
+
// it the element (inside `route`) and re-applies on every input it reads.
|
|
882
|
+
// Inert until a feature is on: no context, no routing. Gestures anywhere
|
|
883
|
+
// on the page resume a suspended context and retry a pending route.
|
|
884
|
+
let audio: ReturnType<typeof createAudioSession> | null = null;
|
|
885
|
+
$effect(() => {
|
|
886
|
+
const v = videoEl;
|
|
887
|
+
if (!v) return;
|
|
888
|
+
const s = createAudioSession({
|
|
889
|
+
// The attribute is what will load next; currentSrc lags it until loadstart.
|
|
890
|
+
routable: () => routeSafety(v.getAttribute('src') || v.currentSrc, v.getAttribute('crossorigin')),
|
|
891
|
+
context: () => new AudioContext(),
|
|
892
|
+
route: (ctx) => (ctx as AudioContext).createMediaElementSource(v),
|
|
893
|
+
playback: () => {
|
|
894
|
+
const m = v as HTMLVideoElement & {
|
|
895
|
+
webkitAudioDecodedByteCount?: number;
|
|
896
|
+
mozHasAudio?: boolean;
|
|
897
|
+
audioTracks?: { length: number };
|
|
898
|
+
};
|
|
899
|
+
return {
|
|
900
|
+
playing: !v.paused && !v.ended,
|
|
901
|
+
hidden: document.hidden,
|
|
902
|
+
audible: !v.muted && v.volume > 0,
|
|
903
|
+
time: v.currentTime,
|
|
904
|
+
audioDecoded: typeof m.webkitAudioDecodedByteCount === 'number' ? m.webkitAudioDecodedByteCount : null,
|
|
905
|
+
audioPresent:
|
|
906
|
+
typeof m.mozHasAudio === 'boolean' ? m.mozHasAudio : m.audioTracks ? m.audioTracks.length > 0 : null
|
|
907
|
+
};
|
|
908
|
+
},
|
|
909
|
+
onState: (st) => onAudioState?.(st),
|
|
910
|
+
onBroken: (reason) => onAudioBroken?.(reason)
|
|
911
|
+
});
|
|
912
|
+
audio = s;
|
|
913
|
+
const sync = () => untrack(() => s.apply(audioOpts(v)));
|
|
914
|
+
const gesture = () => s.gesture();
|
|
915
|
+
const media = ['volumechange', 'loadstart', 'loadedmetadata'];
|
|
916
|
+
const gestures = ['pointerdown', 'pointerup', 'keydown', 'touchend'];
|
|
917
|
+
for (const e of media) v.addEventListener(e, sync);
|
|
918
|
+
v.addEventListener('play', gesture);
|
|
919
|
+
for (const e of gestures) window.addEventListener(e, gesture, { capture: true, passive: true });
|
|
920
|
+
return () => {
|
|
921
|
+
for (const e of media) v.removeEventListener(e, sync);
|
|
922
|
+
v.removeEventListener('play', gesture);
|
|
923
|
+
for (const e of gestures) window.removeEventListener(e, gesture, { capture: true });
|
|
924
|
+
s.dispose();
|
|
925
|
+
audio = null;
|
|
926
|
+
};
|
|
927
|
+
});
|
|
928
|
+
function audioOpts(v: HTMLVideoElement) {
|
|
929
|
+
return {
|
|
930
|
+
level: loudness === true,
|
|
931
|
+
target: loudnessTarget,
|
|
932
|
+
limiter: limiter === true,
|
|
933
|
+
quiet: quietListening === true,
|
|
934
|
+
volume: v.volume,
|
|
935
|
+
muted: v.muted,
|
|
936
|
+
night: nightArmedNow
|
|
937
|
+
};
|
|
938
|
+
}
|
|
939
|
+
$effect(() => {
|
|
940
|
+
const v = videoEl;
|
|
941
|
+
void crossorigin; // a CORS opt-in clears a 'cors' refusal; removing it after routing is 'unsafe-src'
|
|
942
|
+
void src;
|
|
943
|
+
const o = v ? audioOpts(v) : null;
|
|
944
|
+
if (o) untrack(() => audio?.apply(o));
|
|
945
|
+
});
|
|
622
946
|
|
|
623
947
|
// ── Night (video/night/*) ───────────────────────────────────────────────
|
|
624
948
|
// The modules decide; this block samples the frame and applies the answer
|
|
@@ -3,11 +3,15 @@ export type { BandAxis, BandState } from '../video/band/axis';
|
|
|
3
3
|
export type { VideoLumaTrack } from '../video/band/luma';
|
|
4
4
|
export type { VideoMarker } from '../video/band/markers';
|
|
5
5
|
export type { NightMode, NightWindow } from '../video/night/clock';
|
|
6
|
+
export type { AudioState, AudioStatus, AudioBrokenReason } from '../video/audio/graph';
|
|
7
|
+
export type { VideoAdSpan, RateReport } from '../video/rate';
|
|
6
8
|
import type { VideoBookmark, VideoChapter, VideoSpeakerTalkTime, VideoSpeakerTurn, VideoTranscriptSegment, VideoWaveform } from '../video/types';
|
|
7
9
|
import { type BandState } from '../video/band/axis';
|
|
8
10
|
import { type VideoLumaTrack } from '../video/band/luma';
|
|
9
11
|
import { type VideoMarker } from '../video/band/markers';
|
|
12
|
+
import { type RateReport, type VideoAdSpan } from '../video/rate';
|
|
10
13
|
import { type NightMode, type NightWindow } from '../video/night/clock';
|
|
14
|
+
import { type AudioBrokenReason, type AudioState } from '../video/audio';
|
|
11
15
|
type $$ComponentProps = {
|
|
12
16
|
src: string;
|
|
13
17
|
poster?: string | null;
|
|
@@ -138,6 +142,78 @@ type $$ComponentProps = {
|
|
|
138
142
|
onNightModeChange?: (mode: NightMode) => void;
|
|
139
143
|
/** Frame readback was refused (CORS-tainted canvas) — night fell back to a fixed level. */
|
|
140
144
|
onNightBlind?: () => void;
|
|
145
|
+
/**
|
|
146
|
+
* Audio graph (mos-5lzns.31) — all three default OFF (absent = the
|
|
147
|
+
* feature does not exist on this player and nothing is ever routed).
|
|
148
|
+
*
|
|
149
|
+
* `loudness`: a gated RMS meter rides the gain slowly toward
|
|
150
|
+
* `loudnessTarget` (dBFS, −30…−12, default −20) so a whispered video and
|
|
151
|
+
* a blaring one arrive at the same level; clamped 0.3–1.35×, it rescues a
|
|
152
|
+
* quiet mix by a few dB and never becomes an amplifier.
|
|
153
|
+
* `quietListening`: loudness-compensated tone that rises as the volume
|
|
154
|
+
* drops (and sits a little on while night is armed).
|
|
155
|
+
* `limiter`: catches peaks above −3 dBFS that the slow rider can't.
|
|
156
|
+
*
|
|
157
|
+
* ⚠ Routing into WebAudio is permanent for the element, and a source
|
|
158
|
+
* that isn't CORS-clean routes to SILENCE. So the player routes only
|
|
159
|
+
* when the src is blob:/data: or `crossorigin` is set ("anonymous" /
|
|
160
|
+
* "use-credentials", with the server answering Access-Control-Allow-
|
|
161
|
+
* Origin) — a same-origin URL is NOT enough, since it may redirect to
|
|
162
|
+
* storage on another origin. Anything else is refused and plays
|
|
163
|
+
* unprocessed (`status: 'cors'`). It also waits for the AudioContext to
|
|
164
|
+
* be `running` (a user gesture); until then native audio plays.
|
|
165
|
+
*
|
|
166
|
+
* After routing it cannot be undone, so the player reports
|
|
167
|
+
* `status: 'broken'` and calls `onAudioBroken(reason)`:
|
|
168
|
+
* · 'unsafe-src' — the src later changed to one that isn't route-safe
|
|
169
|
+
* (or `crossorigin` was removed). Remount the player (a fresh element)
|
|
170
|
+
* for that source.
|
|
171
|
+
* · 'silent' — bit-exact silence for 4s while the video plays audibly
|
|
172
|
+
* AND the browser shows decoded audio flowing (a video with no audio
|
|
173
|
+
* track never fires). Show a notice and/or turn the audio features
|
|
174
|
+
* off for that src — do NOT remount; it would loop.
|
|
175
|
+
* · 'build-failed' — construction threw: plain audio for the session.
|
|
176
|
+
*/
|
|
177
|
+
loudness?: boolean;
|
|
178
|
+
loudnessTarget?: number;
|
|
179
|
+
quietListening?: boolean;
|
|
180
|
+
limiter?: boolean;
|
|
181
|
+
/** Graph status + the rider's live gain (≤5×/s while matching acts). */
|
|
182
|
+
onAudioState?: (state: AudioState) => void;
|
|
183
|
+
/** Entered `broken`: 'build-failed' | 'unsafe-src' | 'silent' (see above). */
|
|
184
|
+
onAudioBroken?: (reason: AudioBrokenReason) => void;
|
|
185
|
+
/**
|
|
186
|
+
* Rate composer (mos-5lzns.32): `rate = base × speaker × silence`. The
|
|
187
|
+
* speed readout only ever reports `base` — the rate a human chose — so it
|
|
188
|
+
* keeps saying `1×` while a gap is taken at 2.2×. Every advisor is off by
|
|
189
|
+
* default and collapses to 1 until its data arrives: off, the element
|
|
190
|
+
* plays at exactly `base`.
|
|
191
|
+
*
|
|
192
|
+
* Smart speed: run the silences between sentences at 2.2×, found off the
|
|
193
|
+
* `waveform` envelope. Speech stays at `base`.
|
|
194
|
+
*/
|
|
195
|
+
smartSpeed?: boolean;
|
|
196
|
+
/**
|
|
197
|
+
* Per-speaker pace: level everyone to the median words-per-minute,
|
|
198
|
+
* measured from the diarised `transcript` + `speakers`. Ad reads and
|
|
199
|
+
* gaps between turns play at `base`.
|
|
200
|
+
*/
|
|
201
|
+
speakerPace?: boolean;
|
|
202
|
+
/**
|
|
203
|
+
* Hand-set speaker rates keyed by speaker NAME (the legend's display
|
|
204
|
+
* name). They always beat measured ones, and like them apply only while
|
|
205
|
+
* `speakerPace` is on — off means off. Range 0.0625–16.
|
|
206
|
+
*/
|
|
207
|
+
speakerRates?: Record<string, number> | null;
|
|
208
|
+
/** Host-detected ad reads. Merged with any `ad: true` speaker turns. */
|
|
209
|
+
adSpans?: VideoAdSpan[] | null;
|
|
210
|
+
/**
|
|
211
|
+
* Skip ad reads: when playback runs into an ad span it jumps to its end
|
|
212
|
+
* (the OSD says so). A seek that lands inside an ad is honoured.
|
|
213
|
+
*/
|
|
214
|
+
skipAds?: boolean;
|
|
215
|
+
/** The composed rate changed (real change only). `base` is what the UI shows. */
|
|
216
|
+
onRateChange?: (report: RateReport) => void;
|
|
141
217
|
class?: string;
|
|
142
218
|
};
|
|
143
219
|
declare const VideoPlayer: import("svelte").Component<$$ComponentProps, {
|
package/dist/index.d.ts
CHANGED
|
@@ -12,7 +12,8 @@ export type { ChapterBlock } from './components/replay/ChapterTimeline.svelte';
|
|
|
12
12
|
export { default as TranscriptPlayer } from './components/TranscriptPlayer.svelte';
|
|
13
13
|
export type { TranscriptSegment, TranscriptBookmark, TranscriptChapter, TranscriptSpeakerTalkTime, } from './components/TranscriptPlayer.svelte';
|
|
14
14
|
export { default as VideoPlayer } from './components/VideoPlayer.svelte';
|
|
15
|
-
export type { VideoChapter, VideoSpeakerTurn, VideoTranscriptSegment, VideoBookmark, VideoWaveform, VideoSpeakerTalkTime, VideoLumaTrack, VideoMarker, BandState, BandAxis, NightMode, NightWindow, } from './components/VideoPlayer.svelte';
|
|
15
|
+
export type { VideoChapter, VideoSpeakerTurn, VideoTranscriptSegment, VideoBookmark, VideoWaveform, VideoSpeakerTalkTime, VideoLumaTrack, VideoMarker, BandState, BandAxis, NightMode, NightWindow, AudioState, AudioStatus, AudioBrokenReason, VideoAdSpan, RateReport, } from './components/VideoPlayer.svelte';
|
|
16
|
+
export { speakerRateMap, toRateTurns, toRateSegments } from './video/rate';
|
|
16
17
|
export { buildSilenceMap, boostAt, estimateSaving, type SilenceMap, type SilenceWave, type SilenceOpts, type BoostOpts } from './video/advisors/smart-speed';
|
|
17
18
|
export { speakerPace, suggestRates, rateAt, describeRates, type SpeakerPace, type SpeakerPaceRow, type RateMap, type RateTurn, type RateSegment, type SuggestOpts } from './video/advisors/speaker-rate';
|
|
18
19
|
export { snapResume, resumeLabel, type ResumePoint, type ResumeReason, type ResumeCtx, type ResumeOpts } from './video/advisors/resume-point';
|
package/dist/index.js
CHANGED
|
@@ -13,6 +13,10 @@ export { default as TranscriptPlayer } from './components/TranscriptPlayer.svelt
|
|
|
13
13
|
// optional so a bare mp4 still gets the full playback UX. TranscriptPlayer
|
|
14
14
|
// stays exported unchanged — meetings already depends on it.
|
|
15
15
|
export { default as VideoPlayer } from './components/VideoPlayer.svelte';
|
|
16
|
+
// Rate composer (mos-5lzns.32): the same name → rate table the player applies
|
|
17
|
+
// under `speakerPace` (manual beats measured), for a host drawing its own
|
|
18
|
+
// per-speaker speed UI.
|
|
19
|
+
export { speakerRateMap, toRateTurns, toRateSegments } from './video/rate';
|
|
16
20
|
// Pure advisors (mos-5lzns.27): the extension-library self-test modules,
|
|
17
21
|
// ported verbatim. They measure and advise; the player's feature tasks
|
|
18
22
|
// (.31 audio / .32 rate / .34 presence) consume them. qrCanvas is the one
|
package/dist/video/README.md
CHANGED
|
@@ -17,17 +17,17 @@ props + callback surfaces.
|
|
|
17
17
|
| `band.ts` | .28 (done) — extended by **.30** | The timeline band: zoom window (`BandWindow`, `zoomAt`, `followPlayhead`), hit tests (`segmentAt`, `chapterAt`, `biasToSegmentStart`, `nearestBookmark`), and `paintBand` (canvas). .30 adds one-axis arbitration + the light ribbon; its state lives HERE. |
|
|
18
18
|
| `band/` | **.30** (done) | `window.ts` — the zoom-window math (moved out of band.ts, re-exported there; shared by every band layer without an import cycle). `axis.ts` — one-axis four-state arbitration (`resolveAxis`, `BandState`, the churn-guarded microtask `createBandReporter`), `playheadX` (the one "where you are" mapping — ≤0.2px agreement) and `overviewBracket`. `luma.ts` — the light track (`VideoLumaTrack`, the sidecar verbatim) → ribbon (`v² × 0.55`, max-per-interval). `markers.ts` — `VideoMarker` comments (clustered dots) + like heat. |
|
|
19
19
|
| `keys.ts` | .28 (done) | The keyboard map — `PLAYER_KEYS` (the `?` overlay renders it), `chapterJump`, `FRAME_STEP_SEC`. Every key that exists is in the map; a key not in the map doesn't exist. |
|
|
20
|
-
| `rate.ts` | .28
|
|
20
|
+
| `rate.ts` + `rate/` | .28 contract — **.32** (done) | `rate.ts` is the barrel. `compose.ts` — the contract (`composeRate`, clamp [0.0625, 16], `shouldWriteRate`'s 0.001 deadband with an exact return to base), the `SPEED_STEPS` staircase, `formatSpeedLabel`. `speaker.ts` — props → `advisors/speaker-rate`; manual rates keyed by NAME always beat measured. `silence.ts` — waveform → `advisors/smart-speed` map + boost + the OSD saving claim. `ads.ts` — `adSpans` ∪ `ad: true` turns; skip only when PLAYBACK runs into an ad, never when a seek lands in one. `tick.ts` — the component's own 120ms loop's one-tick decision. None of them write `playbackRate`; they advise. |
|
|
21
21
|
| `deepLink.ts` | .28 (done) | `?t=` parse/format/read/write (replaceState, throttled, other params preserved). |
|
|
22
22
|
| `settings.ts` | shared — knobs added by .30–.34 | `coerce` (never throws, never junk) + `VIDEO_SETTINGS` registry. A knob lands WITH its reader. |
|
|
23
|
-
| `audio.ts` | **.31** |
|
|
23
|
+
| `audio.ts` + `audio/` | **.31** (done) | `audio.ts` is the barrel. `rider.ts` — pure: RMS dBFS (silence → null), the 12 dB relative-gated loud estimate (rise 0.08 / forget 0.01), want-gain clamped 0.3–1.35, the asymmetric ride (down 0.15 / up 0.02), limiter constants + `compressorMakeupDb`/`limiterTrim` (DynamicsCompressor's automatic makeup, cancelled by a trim node), `routeSafety` (only blob:/data: or a `crossorigin` load — a same-origin URL may redirect cross-origin and route to SILENCE). `graph.ts` — `createAudioSession`: source → gain → quiet chain → limiter → trim → destination, analyser on the source. Context first, route last: the element is taken only when a feature is on, the source is route-safe and `ctx.state === 'running'` (gestures resume + retry; never navigator.userActivation). After routing it watches: an unsafe src or bit-exact zeros while audibly playing ⇒ `broken` (`unsafe-src` / `silent`) via `onBroken`. A construction throw is a permanent `build-failed` (context closed if the element wasn't taken; kept with source → destination if it was). Level off cancels before it sets; limiter moves are ramped. The element never enters the module — the component passes a `route` closure. |
|
|
24
24
|
| `night.ts` + `night/` | **.33** (done) | `night.ts` is the barrel. `meter.ts` — 32×18 frame bytes → Rec.709 luma + blown share, asymmetric smoothing, bias-light cells from the same bytes. `levels.ts` — luma → brightness/saturation/warmth/vignette off `NIGHT_FLOOR` 0.34, blind fixed level, the light-theme surround. `clock.ts` — local-clock `auto` window + the `N` auto/on/off cycle (no hyprsunset). `track.ts` — prediction (only ever adds dimming), flash intervals, `composeNight` (darker at once, brighter released per tick — never a jump). |
|
|
25
25
|
| `presence.ts` | **.34** | Wind-down (Idle Detection), resume-at-sentence, FLIP open. |
|
|
26
26
|
| `advisors/` | **.27 (done)** | The five extension-library pure advisors, ported verbatim: `smart-speed` (silence map + boost), `speaker-rate` (measured wpm + suggested rates), `resume-point` (sentence-snapping + the label), `quiet-listening` (tone-only chain + `amountFor`), `qr` (ISO 18004 encoder v1–10). Each has its own README row in `advisors/README.md`; the `--self-test` suites live on as vitest in `tests/unit/advisors/` (`npm run test:advisors`). |
|
|
27
27
|
|
|
28
28
|
Sub-task map: **.30** scrubbing/one-axis/ribbon/markers → `band.ts` (+ its own
|
|
29
29
|
paint layers); **.31** audio graph → `audio.ts`; **.32** rate composer →
|
|
30
|
-
`rate.ts`
|
|
30
|
+
`rate.ts` + `rate/`; **.33** night → `night.ts`; **.34** presence/wind-down →
|
|
31
31
|
`presence.ts`. **.27** (landed) ports the advisors themselves — .31's chain
|
|
32
32
|
builder and `amountFor` live in `advisors/quiet-listening`, .32's two advisor
|
|
33
33
|
modules in `advisors/smart-speed` + `advisors/speaker-rate`, .34's snapping in
|
|
@@ -44,8 +44,9 @@ feature task.
|
|
|
44
44
|
the prompt).
|
|
45
45
|
3. **Measuring code is an advisor; the component decides and applies.**
|
|
46
46
|
Modules under `src/lib/video/` read no settings and touch no `<video>`
|
|
47
|
-
(the audio graph builder is the one exception
|
|
48
|
-
|
|
47
|
+
(the audio graph builder is the one exception: it touches AudioContext,
|
|
48
|
+
but takes the element only through a `route` closure the component owns
|
|
49
|
+
— never the element itself). Knobs arrive as an
|
|
49
50
|
`opts` argument; a bad argument returns a safe neutral value rather than
|
|
50
51
|
throwing. This is what keeps every module node-testable under
|
|
51
52
|
`npm run test:unit` (the advisor vitest suites run under
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/** What the host asks for. Booleans are "feature on"; volume/muted/night feed quiet listening's amount. */
|
|
2
|
+
export interface AudioOpts {
|
|
3
|
+
level: boolean;
|
|
4
|
+
target?: number;
|
|
5
|
+
limiter: boolean;
|
|
6
|
+
quiet: boolean;
|
|
7
|
+
volume?: number;
|
|
8
|
+
muted?: boolean;
|
|
9
|
+
night?: boolean;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* · 'off' — nothing routed (no feature on, no source yet, or the context is
|
|
13
|
+
* still waiting for a gesture to start). Native audio plays.
|
|
14
|
+
* · 'live' — routed and processing.
|
|
15
|
+
* · 'cors' — a feature is on but the source isn't route-safe: refused,
|
|
16
|
+
* audio plays unprocessed. Clears when the source does.
|
|
17
|
+
* · 'broken' — see `reason`.
|
|
18
|
+
*/
|
|
19
|
+
export type AudioStatus = 'off' | 'live' | 'cors' | 'broken';
|
|
20
|
+
/**
|
|
21
|
+
* · 'build-failed' — construction threw; plain audio for the session (permanent).
|
|
22
|
+
* · 'unsafe-src' — routed, then the src/crossorigin changed to one that is
|
|
23
|
+
* not route-safe: this element may now be SILENT.
|
|
24
|
+
* · 'silent' — routed, playing audibly, clock advancing, decoded audio
|
|
25
|
+
* evidently flowing, and the analyser reads bit-exact
|
|
26
|
+
* zeros for SILENT_AFTER_MS. Show a notice / disable the
|
|
27
|
+
* features for this src — do NOT remount (it would loop).
|
|
28
|
+
*/
|
|
29
|
+
export type AudioBrokenReason = 'build-failed' | 'unsafe-src' | 'silent';
|
|
30
|
+
export interface AudioState {
|
|
31
|
+
status: AudioStatus;
|
|
32
|
+
/** Why `status` is 'broken'; null otherwise. */
|
|
33
|
+
reason: AudioBrokenReason | null;
|
|
34
|
+
/** The rider's current gain, dB (0 when level matching is off or unmeasured). */
|
|
35
|
+
gainDb: number;
|
|
36
|
+
/** The gated loud-level estimate, dBFS — null until something has been heard. */
|
|
37
|
+
loudDb: number | null;
|
|
38
|
+
/** Quiet listening's current amount, 0..1. */
|
|
39
|
+
quiet: number;
|
|
40
|
+
}
|
|
41
|
+
interface ParamLike {
|
|
42
|
+
value: number;
|
|
43
|
+
setTargetAtTime?(v: number, t: number, tau: number): unknown;
|
|
44
|
+
setValueAtTime?(v: number, t: number): unknown;
|
|
45
|
+
cancelScheduledValues?(t: number): unknown;
|
|
46
|
+
}
|
|
47
|
+
interface NodeLike {
|
|
48
|
+
connect(dest: unknown): unknown;
|
|
49
|
+
disconnect?(): unknown;
|
|
50
|
+
gain?: ParamLike;
|
|
51
|
+
threshold?: ParamLike;
|
|
52
|
+
knee?: ParamLike;
|
|
53
|
+
ratio?: ParamLike;
|
|
54
|
+
attack?: ParamLike;
|
|
55
|
+
release?: ParamLike;
|
|
56
|
+
fftSize?: number;
|
|
57
|
+
getFloatTimeDomainData?(buf: Float32Array): void;
|
|
58
|
+
}
|
|
59
|
+
export interface AudioContextLike {
|
|
60
|
+
currentTime: number;
|
|
61
|
+
destination: unknown;
|
|
62
|
+
/** 'suspended' | 'running' | 'closed' — absent (a test double) reads as running. */
|
|
63
|
+
state?: string;
|
|
64
|
+
createGain(): NodeLike;
|
|
65
|
+
createDynamicsCompressor(): NodeLike;
|
|
66
|
+
createAnalyser(): NodeLike;
|
|
67
|
+
createBiquadFilter?(): unknown;
|
|
68
|
+
resume?(): unknown;
|
|
69
|
+
close?(): unknown;
|
|
70
|
+
addEventListener?(type: string, fn: () => void): void;
|
|
71
|
+
}
|
|
72
|
+
export interface AudioPlayback {
|
|
73
|
+
/** Not paused and not ended. */
|
|
74
|
+
playing: boolean;
|
|
75
|
+
/** Tab hidden — the rider holds its estimate. */
|
|
76
|
+
hidden: boolean;
|
|
77
|
+
/** Not muted and volume > 0 — silence would be a fault, not a choice. */
|
|
78
|
+
audible: boolean;
|
|
79
|
+
/** currentTime, seconds. */
|
|
80
|
+
time: number;
|
|
81
|
+
/**
|
|
82
|
+
* Evidence that the element HAS decoded audio. `audioDecoded`: Chromium's
|
|
83
|
+
* webkitAudioDecodedByteCount (must be RISING to count); `audioPresent`:
|
|
84
|
+
* Gecko's mozHasAudio or WebKit's audioTracks.length > 0. null = unknown.
|
|
85
|
+
* With no positive evidence the silence watchdog never fires.
|
|
86
|
+
*/
|
|
87
|
+
audioDecoded?: number | null;
|
|
88
|
+
audioPresent?: boolean | null;
|
|
89
|
+
}
|
|
90
|
+
export interface AudioSessionDeps {
|
|
91
|
+
/** Is the element's current source route-safe? (rider.ts `routeSafety`) */
|
|
92
|
+
routable(): 'ok' | 'wait' | 'cors';
|
|
93
|
+
/** Create the AudioContext. Does NOT touch the element. */
|
|
94
|
+
context(): AudioContextLike;
|
|
95
|
+
/** Take the element: createMediaElementSource. The one-way door — called at most once. */
|
|
96
|
+
route(ctx: AudioContextLike): NodeLike;
|
|
97
|
+
playback(): AudioPlayback;
|
|
98
|
+
/** State changes + each rider tick. */
|
|
99
|
+
onState?(state: AudioState): void;
|
|
100
|
+
/** Entered `broken` (or its reason changed). */
|
|
101
|
+
onBroken?(reason: AudioBrokenReason): void;
|
|
102
|
+
/** Timer seam (tests). Defaults to setInterval/clearInterval. */
|
|
103
|
+
every?(ms: number, fn: () => void): () => void;
|
|
104
|
+
}
|
|
105
|
+
export interface AudioSession {
|
|
106
|
+
apply(opts: AudioOpts): void;
|
|
107
|
+
/** A user gesture (or play): resume the context, retry a pending route. */
|
|
108
|
+
gesture(): void;
|
|
109
|
+
/** One watchdog + rider step (the session's own timer calls this; exposed for tests). */
|
|
110
|
+
tick(): void;
|
|
111
|
+
state(): AudioState;
|
|
112
|
+
dispose(): void;
|
|
113
|
+
}
|
|
114
|
+
export declare function createAudioSession(deps: AudioSessionDeps): AudioSession;
|
|
115
|
+
export {};
|