@joewinke/jatui 0.13.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 +209 -8
- package/dist/components/VideoPlayer.svelte.d.ts +34 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +4 -0
- package/dist/video/README.md +2 -2
- 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 +8 -1
- package/package.json +1 -1
|
@@ -17,6 +17,8 @@
|
|
|
17
17
|
export type { NightMode, NightWindow } from '../video/night/clock';
|
|
18
18
|
// mos-5lzns.31 — audio graph.
|
|
19
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';
|
|
20
22
|
</script>
|
|
21
23
|
|
|
22
24
|
<script lang="ts">
|
|
@@ -137,7 +139,25 @@
|
|
|
137
139
|
import { dragCancel, dragEnd, dragMove, dragStart, type DragState } from '../video/band/drag';
|
|
138
140
|
import { createBandLayerCache } from '../video/band/memo';
|
|
139
141
|
import { BAND_KEYS, PLAYER_KEYS, chapterJump, FRAME_STEP_SEC } from '../video/keys';
|
|
140
|
-
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';
|
|
141
161
|
import { readTimeParam, WRITE_THROTTLE_MS, writeTimeParam } from '../video/deepLink';
|
|
142
162
|
import {
|
|
143
163
|
biasCells,
|
|
@@ -229,6 +249,12 @@
|
|
|
229
249
|
limiter = undefined,
|
|
230
250
|
onAudioState = undefined,
|
|
231
251
|
onAudioBroken = undefined,
|
|
252
|
+
smartSpeed = false,
|
|
253
|
+
speakerPace = false,
|
|
254
|
+
speakerRates = null,
|
|
255
|
+
adSpans = null,
|
|
256
|
+
skipAds = false,
|
|
257
|
+
onRateChange = undefined,
|
|
232
258
|
class: className = ''
|
|
233
259
|
}: {
|
|
234
260
|
src: string;
|
|
@@ -405,6 +431,38 @@
|
|
|
405
431
|
onAudioState?: (state: AudioState) => void;
|
|
406
432
|
/** Entered `broken`: 'build-failed' | 'unsafe-src' | 'silent' (see above). */
|
|
407
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;
|
|
408
466
|
class?: string;
|
|
409
467
|
} = $props();
|
|
410
468
|
|
|
@@ -655,25 +713,168 @@
|
|
|
655
713
|
if (videoEl) videoEl.currentTime = t;
|
|
656
714
|
}
|
|
657
715
|
|
|
658
|
-
// ── 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;
|
|
659
738
|
/**
|
|
660
|
-
*
|
|
661
|
-
*
|
|
662
|
-
*
|
|
663
|
-
* 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.
|
|
664
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
|
+
|
|
665
750
|
function applyRate() {
|
|
666
751
|
const v = videoEl;
|
|
667
752
|
if (!v) return;
|
|
668
|
-
const { rate } = composeRate(baseRate,
|
|
753
|
+
const { rate } = composeRate(baseRate, rateSpeaker, rateSilence);
|
|
669
754
|
// Only real changes are written — playbackRate is a live audio parameter.
|
|
670
|
-
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
|
+
}
|
|
671
764
|
}
|
|
672
765
|
function stepSpeed(direction: 1 | -1) {
|
|
673
766
|
baseRate = stepRate(baseRate, direction);
|
|
674
767
|
applyRate();
|
|
675
768
|
flashOsd(formatSpeedLabel(baseRate));
|
|
676
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
|
+
});
|
|
677
878
|
|
|
678
879
|
// ── Audio (video/audio/*) ───────────────────────────────────────────────
|
|
679
880
|
// The session owns the graph and its one-way door; this block only hands
|
|
@@ -4,10 +4,12 @@ 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
6
|
export type { AudioState, AudioStatus, AudioBrokenReason } from '../video/audio/graph';
|
|
7
|
+
export type { VideoAdSpan, RateReport } from '../video/rate';
|
|
7
8
|
import type { VideoBookmark, VideoChapter, VideoSpeakerTalkTime, VideoSpeakerTurn, VideoTranscriptSegment, VideoWaveform } from '../video/types';
|
|
8
9
|
import { type BandState } from '../video/band/axis';
|
|
9
10
|
import { type VideoLumaTrack } from '../video/band/luma';
|
|
10
11
|
import { type VideoMarker } from '../video/band/markers';
|
|
12
|
+
import { type RateReport, type VideoAdSpan } from '../video/rate';
|
|
11
13
|
import { type NightMode, type NightWindow } from '../video/night/clock';
|
|
12
14
|
import { type AudioBrokenReason, type AudioState } from '../video/audio';
|
|
13
15
|
type $$ComponentProps = {
|
|
@@ -180,6 +182,38 @@ type $$ComponentProps = {
|
|
|
180
182
|
onAudioState?: (state: AudioState) => void;
|
|
181
183
|
/** Entered `broken`: 'build-failed' | 'unsafe-src' | 'silent' (see above). */
|
|
182
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;
|
|
183
217
|
class?: string;
|
|
184
218
|
};
|
|
185
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, AudioState, AudioStatus, AudioBrokenReason, } 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,7 +17,7 @@ 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
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. |
|
|
@@ -27,7 +27,7 @@ props + callback surfaces.
|
|
|
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
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Skip ad reads (mos-5lzns.32).
|
|
3
|
+
*
|
|
4
|
+
* Ad spans come from two places, merged: the host's `adSpans` prop (MOS fills
|
|
5
|
+
* it from its enrichment pass) and any speaker turn flagged `ad: true` — the
|
|
6
|
+
* band already paints those as not-a-person, so a player that paints an ad
|
|
7
|
+
* and then plays it through with skipping on would contradict itself.
|
|
8
|
+
*
|
|
9
|
+
* THE ONE RULE: only PLAYBACK that runs into an ad is skipped. A seek that
|
|
10
|
+
* lands inside one is a person asking to watch it, and is honoured. The
|
|
11
|
+
* component forgets its previous sample on every `seeking` event; this module
|
|
12
|
+
* additionally refuses any step larger than `maxStep` media-seconds, so even
|
|
13
|
+
* a seek that raced the event can't be mistaken for crossing the start.
|
|
14
|
+
*
|
|
15
|
+
* Pure: no DOM, no <video>, no settings reads (src/lib/video/README.md).
|
|
16
|
+
*/
|
|
17
|
+
import type { VideoSpeakerTurn } from '../types';
|
|
18
|
+
/** A host-detected ad read, `[startSec, endSec)`. */
|
|
19
|
+
export interface VideoAdSpan {
|
|
20
|
+
startSec: number;
|
|
21
|
+
endSec: number;
|
|
22
|
+
}
|
|
23
|
+
/** Sorted, merged, finite `[start, end)` pairs. Touching/overlapping spans become one. */
|
|
24
|
+
export declare function normalizeAdSpans(spans: readonly VideoAdSpan[] | null | undefined, turns?: readonly VideoSpeakerTurn[] | null): [number, number][];
|
|
25
|
+
/**
|
|
26
|
+
* Where to jump, or null. Skips when playback moved forward from `prev`
|
|
27
|
+
* (outside, before the span) to `now` (inside it) in one step of at most
|
|
28
|
+
* `maxStep` seconds. `prev === null` means "no trustworthy previous sample"
|
|
29
|
+
* (just seeked, just started, new source) and never skips.
|
|
30
|
+
*/
|
|
31
|
+
export declare function adSkipTarget(prev: number | null, now: number, spans: readonly [number, number][], maxStep: number): {
|
|
32
|
+
from: number;
|
|
33
|
+
to: number;
|
|
34
|
+
} | null;
|
|
35
|
+
/** Wall-clock tick → the largest media step that still counts as "playing through". */
|
|
36
|
+
export declare function adMaxStep(elapsedMs: number, rate: number): number;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/** Sorted, merged, finite `[start, end)` pairs. Touching/overlapping spans become one. */
|
|
2
|
+
export function normalizeAdSpans(spans, turns) {
|
|
3
|
+
const raw = [];
|
|
4
|
+
const push = (a, b) => {
|
|
5
|
+
if (typeof a !== 'number' || typeof b !== 'number')
|
|
6
|
+
return;
|
|
7
|
+
if (!Number.isFinite(a) || !Number.isFinite(b) || b <= a)
|
|
8
|
+
return;
|
|
9
|
+
raw.push([Math.max(0, a), b]);
|
|
10
|
+
};
|
|
11
|
+
if (Array.isArray(spans))
|
|
12
|
+
for (const s of spans)
|
|
13
|
+
if (s)
|
|
14
|
+
push(s.startSec, s.endSec);
|
|
15
|
+
if (Array.isArray(turns))
|
|
16
|
+
for (const t of turns)
|
|
17
|
+
if (t && t.ad)
|
|
18
|
+
push(t.startSec, t.endSec);
|
|
19
|
+
if (!raw.length)
|
|
20
|
+
return raw;
|
|
21
|
+
raw.sort((x, y) => x[0] - y[0]);
|
|
22
|
+
const out = [[raw[0][0], raw[0][1]]];
|
|
23
|
+
for (let i = 1; i < raw.length; i++) {
|
|
24
|
+
const last = out[out.length - 1];
|
|
25
|
+
const [a, b] = raw[i];
|
|
26
|
+
if (a <= last[1])
|
|
27
|
+
last[1] = Math.max(last[1], b);
|
|
28
|
+
else
|
|
29
|
+
out.push([a, b]);
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Where to jump, or null. Skips when playback moved forward from `prev`
|
|
35
|
+
* (outside, before the span) to `now` (inside it) in one step of at most
|
|
36
|
+
* `maxStep` seconds. `prev === null` means "no trustworthy previous sample"
|
|
37
|
+
* (just seeked, just started, new source) and never skips.
|
|
38
|
+
*/
|
|
39
|
+
export function adSkipTarget(prev, now, spans, maxStep) {
|
|
40
|
+
if (prev == null || !Number.isFinite(prev) || !Number.isFinite(now))
|
|
41
|
+
return null;
|
|
42
|
+
if (now <= prev || now - prev > maxStep)
|
|
43
|
+
return null;
|
|
44
|
+
for (const [a, b] of spans) {
|
|
45
|
+
if (a > now)
|
|
46
|
+
break;
|
|
47
|
+
if (now < b && prev < a)
|
|
48
|
+
return { from: a, to: b };
|
|
49
|
+
}
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
/** Wall-clock tick → the largest media step that still counts as "playing through". */
|
|
53
|
+
export function adMaxStep(elapsedMs, rate) {
|
|
54
|
+
const ms = Number.isFinite(elapsedMs) && elapsedMs > 0 ? elapsedMs : 0;
|
|
55
|
+
const r = Number.isFinite(rate) && rate > 0 ? rate : 1;
|
|
56
|
+
// 1.5× slack for timer jitter, plus a floor so a background-throttled tab
|
|
57
|
+
// (setInterval clamped to ≥1s) still recognises its own playback.
|
|
58
|
+
return (ms / 1000) * r * 1.5 + 0.5;
|
|
59
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Playback-rate arbitration for VideoPlayer (mos-5lzns.28 internal split;
|
|
3
|
+
* moved under rate/ by mos-5lzns.32, which fills the advisors).
|
|
4
|
+
*
|
|
5
|
+
* THE RATE COMPOSER CONTRACT (extension-library FEATURES.md — "The rate
|
|
6
|
+
* composer") — this is the one non-obvious rule, stated up front because it
|
|
7
|
+
* looks like a bug from the outside:
|
|
8
|
+
*
|
|
9
|
+
* Three features want `playbackRate`, and NONE of them writes it:
|
|
10
|
+
* · you — the speed keys (−/=/Backspace, alt+wheel)
|
|
11
|
+
* · speaker — "this person talks fast" (mos-5lzns.32 fills)
|
|
12
|
+
* · silence — "nothing is being said here" (mos-5lzns.32 fills)
|
|
13
|
+
*
|
|
14
|
+
* They contribute to a product: rate = base × speaker × silence
|
|
15
|
+
*
|
|
16
|
+
* `base` is the only one a human chose, and therefore the only one the UI
|
|
17
|
+
* reports. The speed readout must keep saying `1×` while a gap is being taken
|
|
18
|
+
* at 2.2×, or the label becomes a strobe.
|
|
19
|
+
*
|
|
20
|
+
* 1. Both advisors collapse to `1` the moment their setting is off or their
|
|
21
|
+
* data has not arrived — `1` is the multiplicative identity, so the
|
|
22
|
+
* composer runs identically on a video with no transcript, no waveform
|
|
23
|
+
* and no speakers. There is no "feature absent" branch anywhere.
|
|
24
|
+
* 2. `base === 0` is a state, not a level (the frozen-frame end of the
|
|
25
|
+
* staircase) — it returns early, before any multiplication, because no
|
|
26
|
+
* advisor gets to un-pause the video by multiplying its way out of zero.
|
|
27
|
+
* 3. Only real changes are written. `playbackRate` is a live audio
|
|
28
|
+
* parameter: writing a *slightly* different value is audible as a wobble.
|
|
29
|
+
* Hence the 0.001 deadband.
|
|
30
|
+
* 4. The clamp is [0.0625, 16] — the range HTML media elements actually
|
|
31
|
+
* support. A product of three multipliers can leave it; a single one
|
|
32
|
+
* cannot.
|
|
33
|
+
*
|
|
34
|
+
* The SPEED_STEPS staircase and formatSpeedLabel are ported from the
|
|
35
|
+
* extension's theater (linear 0.1 steps below 1×, all the way down to 0 —
|
|
36
|
+
* playbackRate=0 is a genuine frozen frame; above 1× the familiar jumps).
|
|
37
|
+
*/
|
|
38
|
+
/** The speed staircase — one step per press of −/=, Backspace resets to 1. */
|
|
39
|
+
export declare const SPEED_STEPS: number[];
|
|
40
|
+
/**
|
|
41
|
+
* "A quarter speed" reads faster as "−.75" than "0.25×" — below 1× the label
|
|
42
|
+
* is the delta, not the multiplier. The 0 step is the frozen-frame end of the
|
|
43
|
+
* staircase — a state, not a level — and the label says so ("0×"), because a
|
|
44
|
+
* frozen frame labeled "1×" lies. Garbage degrades to `1×`.
|
|
45
|
+
*/
|
|
46
|
+
export declare function formatSpeedLabel(rate: number): string;
|
|
47
|
+
/**
|
|
48
|
+
* One step along the staircase. A rate OFF the staircase (1.25 from the
|
|
49
|
+
* native controls' speed menu, say) steps to its nearest staircase neighbour
|
|
50
|
+
* in the pressed direction — never back to 1×, which from 1.25 would make
|
|
51
|
+
* "faster" slower. Past either end there is no further step: the rate holds.
|
|
52
|
+
* Garbage (NaN, negative) is treated as 1×.
|
|
53
|
+
*/
|
|
54
|
+
export declare function stepRate(current: number, direction: 1 | -1): number;
|
|
55
|
+
/** The clamp the composer applies — the range HTML media elements actually support. */
|
|
56
|
+
export declare const RATE_MIN = 0.0625;
|
|
57
|
+
export declare const RATE_MAX = 16;
|
|
58
|
+
/** Writes only on real change — a slightly-different write is audible as a wobble. */
|
|
59
|
+
export declare const RATE_DEADBAND = 0.001;
|
|
60
|
+
export interface ComposedRate {
|
|
61
|
+
rate: number;
|
|
62
|
+
/** False when the deadband swallowed the write (keep the previous rate). */
|
|
63
|
+
changed: boolean;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* The whole arbitration. `speaker` and `silence` are advisor products in
|
|
67
|
+
* [0.0625, 16] that COLLAPSE TO 1 when their feature is off or its data has
|
|
68
|
+
* not arrived (rate/speaker.ts, rate/silence.ts — mos-5lzns.32); with both
|
|
69
|
+
* at 1 this is exactly `base`.
|
|
70
|
+
*
|
|
71
|
+
* `base === 0` returns early: a frozen frame is a state, not a level.
|
|
72
|
+
*/
|
|
73
|
+
export declare function composeRate(base: number, speaker: number, silence: number): ComposedRate;
|
|
74
|
+
/**
|
|
75
|
+
* Should the component write `next` over the element's `current` rate?
|
|
76
|
+
* Only on a real change (> RATE_DEADBAND) — a slightly-different write is
|
|
77
|
+
* audible as a wobble. The one exception is landing EXACTLY on `base`: when
|
|
78
|
+
* every advisor has collapsed to 1 the applied rate must be the human's rate
|
|
79
|
+
* to the last bit (doctrine 1 — an off feature leaves no trace), so a residue
|
|
80
|
+
* inside the deadband is still cleared.
|
|
81
|
+
*/
|
|
82
|
+
export declare function shouldWriteRate(current: number, next: number, base: number): boolean;
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Playback-rate arbitration for VideoPlayer (mos-5lzns.28 internal split;
|
|
3
|
+
* moved under rate/ by mos-5lzns.32, which fills the advisors).
|
|
4
|
+
*
|
|
5
|
+
* THE RATE COMPOSER CONTRACT (extension-library FEATURES.md — "The rate
|
|
6
|
+
* composer") — this is the one non-obvious rule, stated up front because it
|
|
7
|
+
* looks like a bug from the outside:
|
|
8
|
+
*
|
|
9
|
+
* Three features want `playbackRate`, and NONE of them writes it:
|
|
10
|
+
* · you — the speed keys (−/=/Backspace, alt+wheel)
|
|
11
|
+
* · speaker — "this person talks fast" (mos-5lzns.32 fills)
|
|
12
|
+
* · silence — "nothing is being said here" (mos-5lzns.32 fills)
|
|
13
|
+
*
|
|
14
|
+
* They contribute to a product: rate = base × speaker × silence
|
|
15
|
+
*
|
|
16
|
+
* `base` is the only one a human chose, and therefore the only one the UI
|
|
17
|
+
* reports. The speed readout must keep saying `1×` while a gap is being taken
|
|
18
|
+
* at 2.2×, or the label becomes a strobe.
|
|
19
|
+
*
|
|
20
|
+
* 1. Both advisors collapse to `1` the moment their setting is off or their
|
|
21
|
+
* data has not arrived — `1` is the multiplicative identity, so the
|
|
22
|
+
* composer runs identically on a video with no transcript, no waveform
|
|
23
|
+
* and no speakers. There is no "feature absent" branch anywhere.
|
|
24
|
+
* 2. `base === 0` is a state, not a level (the frozen-frame end of the
|
|
25
|
+
* staircase) — it returns early, before any multiplication, because no
|
|
26
|
+
* advisor gets to un-pause the video by multiplying its way out of zero.
|
|
27
|
+
* 3. Only real changes are written. `playbackRate` is a live audio
|
|
28
|
+
* parameter: writing a *slightly* different value is audible as a wobble.
|
|
29
|
+
* Hence the 0.001 deadband.
|
|
30
|
+
* 4. The clamp is [0.0625, 16] — the range HTML media elements actually
|
|
31
|
+
* support. A product of three multipliers can leave it; a single one
|
|
32
|
+
* cannot.
|
|
33
|
+
*
|
|
34
|
+
* The SPEED_STEPS staircase and formatSpeedLabel are ported from the
|
|
35
|
+
* extension's theater (linear 0.1 steps below 1×, all the way down to 0 —
|
|
36
|
+
* playbackRate=0 is a genuine frozen frame; above 1× the familiar jumps).
|
|
37
|
+
*/
|
|
38
|
+
/** The speed staircase — one step per press of −/=, Backspace resets to 1. */
|
|
39
|
+
export const SPEED_STEPS = [
|
|
40
|
+
0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1, 1.1, 1.2, 1.3, 1.4, 1.5, 1.75, 2, 2.5, 5, 10
|
|
41
|
+
];
|
|
42
|
+
/**
|
|
43
|
+
* "A quarter speed" reads faster as "−.75" than "0.25×" — below 1× the label
|
|
44
|
+
* is the delta, not the multiplier. The 0 step is the frozen-frame end of the
|
|
45
|
+
* staircase — a state, not a level — and the label says so ("0×"), because a
|
|
46
|
+
* frozen frame labeled "1×" lies. Garbage degrades to `1×`.
|
|
47
|
+
*/
|
|
48
|
+
export function formatSpeedLabel(rate) {
|
|
49
|
+
if (!Number.isFinite(rate) || rate < 0)
|
|
50
|
+
return '1×';
|
|
51
|
+
if (rate === 0)
|
|
52
|
+
return '0×';
|
|
53
|
+
if (rate < 1) {
|
|
54
|
+
return (rate - 1).toFixed(2).replace(/\.?0+$/, '').replace(/^-0\./, '-.') + '×';
|
|
55
|
+
}
|
|
56
|
+
return rate.toFixed(2).replace(/\.?0+$/, '') + '×';
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* One step along the staircase. A rate OFF the staircase (1.25 from the
|
|
60
|
+
* native controls' speed menu, say) steps to its nearest staircase neighbour
|
|
61
|
+
* in the pressed direction — never back to 1×, which from 1.25 would make
|
|
62
|
+
* "faster" slower. Past either end there is no further step: the rate holds.
|
|
63
|
+
* Garbage (NaN, negative) is treated as 1×.
|
|
64
|
+
*/
|
|
65
|
+
export function stepRate(current, direction) {
|
|
66
|
+
const cur = Number.isFinite(current) && current >= 0 ? current : 1;
|
|
67
|
+
const i = SPEED_STEPS.indexOf(cur);
|
|
68
|
+
if (i !== -1)
|
|
69
|
+
return SPEED_STEPS[Math.min(SPEED_STEPS.length - 1, Math.max(0, i + direction))];
|
|
70
|
+
if (direction > 0)
|
|
71
|
+
return SPEED_STEPS.find((s) => s > cur) ?? cur;
|
|
72
|
+
for (let j = SPEED_STEPS.length - 1; j >= 0; j--)
|
|
73
|
+
if (SPEED_STEPS[j] < cur)
|
|
74
|
+
return SPEED_STEPS[j];
|
|
75
|
+
return cur;
|
|
76
|
+
}
|
|
77
|
+
/** The clamp the composer applies — the range HTML media elements actually support. */
|
|
78
|
+
export const RATE_MIN = 0.0625;
|
|
79
|
+
export const RATE_MAX = 16;
|
|
80
|
+
/** Writes only on real change — a slightly-different write is audible as a wobble. */
|
|
81
|
+
export const RATE_DEADBAND = 0.001;
|
|
82
|
+
/**
|
|
83
|
+
* The whole arbitration. `speaker` and `silence` are advisor products in
|
|
84
|
+
* [0.0625, 16] that COLLAPSE TO 1 when their feature is off or its data has
|
|
85
|
+
* not arrived (rate/speaker.ts, rate/silence.ts — mos-5lzns.32); with both
|
|
86
|
+
* at 1 this is exactly `base`.
|
|
87
|
+
*
|
|
88
|
+
* `base === 0` returns early: a frozen frame is a state, not a level.
|
|
89
|
+
*/
|
|
90
|
+
export function composeRate(base, speaker, silence) {
|
|
91
|
+
if (!Number.isFinite(base) || base <= 0) {
|
|
92
|
+
return { rate: Math.max(0, Number.isFinite(base) ? base : 0), changed: false };
|
|
93
|
+
}
|
|
94
|
+
const sp = Number.isFinite(speaker) && speaker > 0 ? speaker : 1;
|
|
95
|
+
const si = Number.isFinite(silence) && silence > 0 ? silence : 1;
|
|
96
|
+
const rate = Math.min(RATE_MAX, Math.max(RATE_MIN, base * sp * si));
|
|
97
|
+
const changed = Math.abs(rate - base) > RATE_DEADBAND;
|
|
98
|
+
return { rate: changed ? rate : base, changed };
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Should the component write `next` over the element's `current` rate?
|
|
102
|
+
* Only on a real change (> RATE_DEADBAND) — a slightly-different write is
|
|
103
|
+
* audible as a wobble. The one exception is landing EXACTLY on `base`: when
|
|
104
|
+
* every advisor has collapsed to 1 the applied rate must be the human's rate
|
|
105
|
+
* to the last bit (doctrine 1 — an off feature leaves no trace), so a residue
|
|
106
|
+
* inside the deadband is still cleared.
|
|
107
|
+
*/
|
|
108
|
+
export function shouldWriteRate(current, next, base) {
|
|
109
|
+
if (!Number.isFinite(next) || current === next)
|
|
110
|
+
return false;
|
|
111
|
+
if (!Number.isFinite(current))
|
|
112
|
+
return true;
|
|
113
|
+
return Math.abs(current - next) > RATE_DEADBAND || next === base;
|
|
114
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Smart speed — the `silence` factor of the rate composer (mos-5lzns.32).
|
|
3
|
+
*
|
|
4
|
+
* The measuring is `../advisors/smart-speed` (ported verbatim by .27): hold
|
|
5
|
+
* 1× through speech, run the gaps between sentences at `boost` (2.2× by
|
|
6
|
+
* default), found off the waveform envelope the band already draws. This
|
|
7
|
+
* file adapts the component's `VideoWaveform` prop and collapses to 1 when
|
|
8
|
+
* the feature is off or the envelope has not arrived.
|
|
9
|
+
*
|
|
10
|
+
* Pure: no DOM, no <video>, no settings reads (src/lib/video/README.md).
|
|
11
|
+
*/
|
|
12
|
+
import { type BoostOpts, type SilenceMap } from '../advisors/smart-speed';
|
|
13
|
+
import type { VideoWaveform } from '../types';
|
|
14
|
+
/**
|
|
15
|
+
* The silence map for a waveform, or null when there is nothing to map (no
|
|
16
|
+
* peaks, no known duration, or no qualifying gap at all — a map with zero
|
|
17
|
+
* ranges can never boost, so the caller may as well not hold one).
|
|
18
|
+
*/
|
|
19
|
+
export declare function silenceMapFor(waveform: VideoWaveform | null | undefined, durationSec: number): SilenceMap | null;
|
|
20
|
+
/** The silence factor at time t: `boost` inside a gap, 1 everywhere else. */
|
|
21
|
+
export declare function silenceFactor(t: number, map: SilenceMap | null, opts?: BoostOpts | null): number;
|
|
22
|
+
/**
|
|
23
|
+
* "Smart speed · about 6 min shorter" — the visible surface for an automatic
|
|
24
|
+
* correction (doctrine 2). Empty when the saving would round to nothing, so
|
|
25
|
+
* the caller never flashes a claim it can't back.
|
|
26
|
+
*/
|
|
27
|
+
export declare function smartSpeedLabel(map: SilenceMap | null, opts?: BoostOpts | null): string;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Smart speed — the `silence` factor of the rate composer (mos-5lzns.32).
|
|
3
|
+
*
|
|
4
|
+
* The measuring is `../advisors/smart-speed` (ported verbatim by .27): hold
|
|
5
|
+
* 1× through speech, run the gaps between sentences at `boost` (2.2× by
|
|
6
|
+
* default), found off the waveform envelope the band already draws. This
|
|
7
|
+
* file adapts the component's `VideoWaveform` prop and collapses to 1 when
|
|
8
|
+
* the feature is off or the envelope has not arrived.
|
|
9
|
+
*
|
|
10
|
+
* Pure: no DOM, no <video>, no settings reads (src/lib/video/README.md).
|
|
11
|
+
*/
|
|
12
|
+
import { boostAt, buildSilenceMap, estimateSaving } from '../advisors/smart-speed';
|
|
13
|
+
/**
|
|
14
|
+
* The silence map for a waveform, or null when there is nothing to map (no
|
|
15
|
+
* peaks, no known duration, or no qualifying gap at all — a map with zero
|
|
16
|
+
* ranges can never boost, so the caller may as well not hold one).
|
|
17
|
+
*/
|
|
18
|
+
export function silenceMapFor(waveform, durationSec) {
|
|
19
|
+
if (!waveform || !waveform.peaks || !waveform.peaks.length)
|
|
20
|
+
return null;
|
|
21
|
+
const d = Number.isFinite(waveform.durationSec) && waveform.durationSec > 0
|
|
22
|
+
? waveform.durationSec
|
|
23
|
+
: durationSec;
|
|
24
|
+
if (!(Number.isFinite(d) && d > 0))
|
|
25
|
+
return null;
|
|
26
|
+
const map = buildSilenceMap({ duration: d, peaks: waveform.peaks });
|
|
27
|
+
return map.ranges.length ? map : null;
|
|
28
|
+
}
|
|
29
|
+
/** The silence factor at time t: `boost` inside a gap, 1 everywhere else. */
|
|
30
|
+
export function silenceFactor(t, map, opts) {
|
|
31
|
+
if (!map)
|
|
32
|
+
return 1;
|
|
33
|
+
const b = boostAt(t, map, opts ?? null);
|
|
34
|
+
return Number.isFinite(b) && b >= 1 ? b : 1;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* "Smart speed · about 6 min shorter" — the visible surface for an automatic
|
|
38
|
+
* correction (doctrine 2). Empty when the saving would round to nothing, so
|
|
39
|
+
* the caller never flashes a claim it can't back.
|
|
40
|
+
*/
|
|
41
|
+
export function smartSpeedLabel(map, opts) {
|
|
42
|
+
const saved = map ? estimateSaving(map, opts ?? null) : 0;
|
|
43
|
+
if (!(saved >= 1))
|
|
44
|
+
return '';
|
|
45
|
+
if (saved < 60)
|
|
46
|
+
return `Smart speed · ${Math.round(saved)}s shorter`;
|
|
47
|
+
return `Smart speed · ~${Math.round(saved / 60)} min shorter`;
|
|
48
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-speaker pace — the `speaker` factor of the rate composer (mos-5lzns.32).
|
|
3
|
+
*
|
|
4
|
+
* The measuring is `../advisors/speaker-rate` (ported verbatim by .27); this
|
|
5
|
+
* file is the adapter that feeds it the component's props and decides which
|
|
6
|
+
* rate wins for a name:
|
|
7
|
+
*
|
|
8
|
+
* · MANUAL rates are keyed by speaker NAME (the display name a human sees
|
|
9
|
+
* in the legend, not a diarisation label) and ALWAYS beat measured ones.
|
|
10
|
+
* A person who set "Darren 1.3×" meant it; a measurement that disagrees
|
|
11
|
+
* is the thing that is wrong.
|
|
12
|
+
* · MEASURED rates come from `suggestRates(speakerPace(...))` — words per
|
|
13
|
+
* minute off the diarised transcript, levelled to the median pace — and
|
|
14
|
+
* fill only the names nobody set by hand.
|
|
15
|
+
*
|
|
16
|
+
* The advisor collapses to 1 whenever it has nothing to say: feature off, no
|
|
17
|
+
* turns, a gap between turns, an ad read, or a name with no rate. `1` is the
|
|
18
|
+
* composer's identity, so there is no "feature absent" branch anywhere.
|
|
19
|
+
*
|
|
20
|
+
* Pure: no DOM, no <video>, no settings reads (src/lib/video/README.md).
|
|
21
|
+
*/
|
|
22
|
+
import { type RateMap, type RateSegment, type RateTurn, type SuggestOpts } from '../advisors/speaker-rate';
|
|
23
|
+
import type { VideoSpeakerTurn, VideoTranscriptSegment } from '../types';
|
|
24
|
+
/**
|
|
25
|
+
* The name a rate is keyed by — the display name when there is one, else the
|
|
26
|
+
* identity. Deliberately the same precedence for turns and segments, so a
|
|
27
|
+
* measured row and a manual entry for the same person share a key.
|
|
28
|
+
*/
|
|
29
|
+
export declare function speakerNameOf(s: {
|
|
30
|
+
speakerName?: string | null;
|
|
31
|
+
speakerIdentity?: string | null;
|
|
32
|
+
}): string | null;
|
|
33
|
+
/** Component turns → the advisor's shape. Ad reads keep their flag (never a person). */
|
|
34
|
+
export declare function toRateTurns(turns: readonly VideoSpeakerTurn[] | null | undefined): RateTurn[];
|
|
35
|
+
/** Component transcript → the advisor's shape. */
|
|
36
|
+
export declare function toRateSegments(segments: readonly VideoTranscriptSegment[] | null | undefined): RateSegment[];
|
|
37
|
+
export interface SpeakerRateInput {
|
|
38
|
+
turns: RateTurn[];
|
|
39
|
+
segments: RateSegment[];
|
|
40
|
+
/** Hand-set rates by NAME. Always win. */
|
|
41
|
+
manual?: Record<string, number> | null;
|
|
42
|
+
/** Measure the rest from the transcript (false ⇒ manual only). */
|
|
43
|
+
measure?: boolean;
|
|
44
|
+
opts?: SuggestOpts | null;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* name → rate. Measured first (if asked), then every valid manual entry laid
|
|
48
|
+
* over it — so manual beats measured by construction, not by a comparison.
|
|
49
|
+
* Null-prototype for the same reason the advisor's maps are: a speaker named
|
|
50
|
+
* "__proto__" is a key, not a prototype write.
|
|
51
|
+
*/
|
|
52
|
+
export declare function speakerRateMap(input: SpeakerRateInput): RateMap;
|
|
53
|
+
/** The speaker factor at time t. 1 when nobody (or an ad) is talking, or no rate is known. */
|
|
54
|
+
export declare function speakerFactor(t: number, turns: RateTurn[], rates: RateMap | null): number;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-speaker pace — the `speaker` factor of the rate composer (mos-5lzns.32).
|
|
3
|
+
*
|
|
4
|
+
* The measuring is `../advisors/speaker-rate` (ported verbatim by .27); this
|
|
5
|
+
* file is the adapter that feeds it the component's props and decides which
|
|
6
|
+
* rate wins for a name:
|
|
7
|
+
*
|
|
8
|
+
* · MANUAL rates are keyed by speaker NAME (the display name a human sees
|
|
9
|
+
* in the legend, not a diarisation label) and ALWAYS beat measured ones.
|
|
10
|
+
* A person who set "Darren 1.3×" meant it; a measurement that disagrees
|
|
11
|
+
* is the thing that is wrong.
|
|
12
|
+
* · MEASURED rates come from `suggestRates(speakerPace(...))` — words per
|
|
13
|
+
* minute off the diarised transcript, levelled to the median pace — and
|
|
14
|
+
* fill only the names nobody set by hand.
|
|
15
|
+
*
|
|
16
|
+
* The advisor collapses to 1 whenever it has nothing to say: feature off, no
|
|
17
|
+
* turns, a gap between turns, an ad read, or a name with no rate. `1` is the
|
|
18
|
+
* composer's identity, so there is no "feature absent" branch anywhere.
|
|
19
|
+
*
|
|
20
|
+
* Pure: no DOM, no <video>, no settings reads (src/lib/video/README.md).
|
|
21
|
+
*/
|
|
22
|
+
import { rateAt, speakerPace, suggestRates } from '../advisors/speaker-rate';
|
|
23
|
+
import { RATE_MAX, RATE_MIN } from './compose';
|
|
24
|
+
/**
|
|
25
|
+
* The name a rate is keyed by — the display name when there is one, else the
|
|
26
|
+
* identity. Deliberately the same precedence for turns and segments, so a
|
|
27
|
+
* measured row and a manual entry for the same person share a key.
|
|
28
|
+
*/
|
|
29
|
+
export function speakerNameOf(s) {
|
|
30
|
+
const n = (s.speakerName ?? '').trim() || (s.speakerIdentity ?? '').trim();
|
|
31
|
+
return n || null;
|
|
32
|
+
}
|
|
33
|
+
/** Component turns → the advisor's shape. Ad reads keep their flag (never a person). */
|
|
34
|
+
export function toRateTurns(turns) {
|
|
35
|
+
if (!Array.isArray(turns))
|
|
36
|
+
return [];
|
|
37
|
+
const out = [];
|
|
38
|
+
for (const t of turns) {
|
|
39
|
+
if (!t || typeof t !== 'object')
|
|
40
|
+
continue;
|
|
41
|
+
out.push({ start: t.startSec, end: t.endSec, name: t.ad ? null : speakerNameOf(t), ad: !!t.ad });
|
|
42
|
+
}
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
/** Component transcript → the advisor's shape. */
|
|
46
|
+
export function toRateSegments(segments) {
|
|
47
|
+
if (!Array.isArray(segments))
|
|
48
|
+
return [];
|
|
49
|
+
const out = [];
|
|
50
|
+
for (const s of segments) {
|
|
51
|
+
if (!s || typeof s !== 'object')
|
|
52
|
+
continue;
|
|
53
|
+
out.push({ start: s.startSec, end: s.endSec, text: s.text, speaker: speakerNameOf(s) });
|
|
54
|
+
}
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
/** A usable multiplier, or null. The composer's clamp range is the only sane one. */
|
|
58
|
+
function rateOrNull(v) {
|
|
59
|
+
const n = typeof v === 'number' ? v : Number.NaN;
|
|
60
|
+
return Number.isFinite(n) && n >= RATE_MIN && n <= RATE_MAX ? n : null;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* name → rate. Measured first (if asked), then every valid manual entry laid
|
|
64
|
+
* over it — so manual beats measured by construction, not by a comparison.
|
|
65
|
+
* Null-prototype for the same reason the advisor's maps are: a speaker named
|
|
66
|
+
* "__proto__" is a key, not a prototype write.
|
|
67
|
+
*/
|
|
68
|
+
export function speakerRateMap(input) {
|
|
69
|
+
const out = Object.create(null);
|
|
70
|
+
if (input.measure) {
|
|
71
|
+
const measured = suggestRates(speakerPace(input.segments, input.turns), input.opts ?? null);
|
|
72
|
+
for (const [name, r] of Object.entries(measured)) {
|
|
73
|
+
const v = rateOrNull(r);
|
|
74
|
+
if (v != null)
|
|
75
|
+
out[name] = v;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
const manual = input.manual;
|
|
79
|
+
if (manual && typeof manual === 'object') {
|
|
80
|
+
for (const [name, r] of Object.entries(manual)) {
|
|
81
|
+
const key = name.trim();
|
|
82
|
+
const v = rateOrNull(r);
|
|
83
|
+
if (key && v != null)
|
|
84
|
+
out[key] = v;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return out;
|
|
88
|
+
}
|
|
89
|
+
/** The speaker factor at time t. 1 when nobody (or an ad) is talking, or no rate is known. */
|
|
90
|
+
export function speakerFactor(t, turns, rates) {
|
|
91
|
+
if (!rates || !turns.length)
|
|
92
|
+
return 1;
|
|
93
|
+
return rateOrNull(rateAt(t, turns, rates, 1)) ?? 1;
|
|
94
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rate loop's one-tick decision (mos-5lzns.32).
|
|
3
|
+
*
|
|
4
|
+
* The component runs its OWN 120ms interval for rate — separate from
|
|
5
|
+
* timeupdate (~4Hz, too coarse: a 0.35s gap would be half over before the
|
|
6
|
+
* boost arrived) and from night's ~1s meter. Each tick it reads the playhead
|
|
7
|
+
* and asks this module for the two advisor factors; `composeRate` does the
|
|
8
|
+
* multiplication and `shouldWriteRate` the deadband. This file only bundles
|
|
9
|
+
* the per-tick inputs so the component block stays a few lines long.
|
|
10
|
+
*
|
|
11
|
+
* Pure: no DOM, no <video>, no settings reads (src/lib/video/README.md).
|
|
12
|
+
*/
|
|
13
|
+
import type { BoostOpts, SilenceMap } from '../advisors/smart-speed';
|
|
14
|
+
import type { RateMap, RateTurn } from '../advisors/speaker-rate';
|
|
15
|
+
/** The rate loop's cadence. */
|
|
16
|
+
export declare const RATE_TICK_MS = 120;
|
|
17
|
+
export interface RateAdvisors {
|
|
18
|
+
/** null ⇒ the speaker advisor is off (collapses to 1). */
|
|
19
|
+
speakerRates: RateMap | null;
|
|
20
|
+
turns: RateTurn[];
|
|
21
|
+
/** null ⇒ smart speed is off or has no map yet (collapses to 1). */
|
|
22
|
+
silence: SilenceMap | null;
|
|
23
|
+
boost?: BoostOpts | null;
|
|
24
|
+
}
|
|
25
|
+
export interface RateAdvice {
|
|
26
|
+
speaker: number;
|
|
27
|
+
silence: number;
|
|
28
|
+
/** base × speaker × silence, clamped — what the element should play at. */
|
|
29
|
+
rate: number;
|
|
30
|
+
}
|
|
31
|
+
/** What `onRateChange` reports. `base` is the only factor the UI shows. */
|
|
32
|
+
export interface RateReport extends RateAdvice {
|
|
33
|
+
base: number;
|
|
34
|
+
}
|
|
35
|
+
/** The composed rate at playhead `t` for the human's `base`. */
|
|
36
|
+
export declare function adviseRate(base: number, t: number, a: RateAdvisors): RateAdvice;
|
|
37
|
+
/** Is any advisor able to move the rate? False ⇒ the loop need not run. */
|
|
38
|
+
export declare function advisorsActive(a: RateAdvisors): boolean;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { composeRate } from './compose';
|
|
2
|
+
import { silenceFactor } from './silence';
|
|
3
|
+
import { speakerFactor } from './speaker';
|
|
4
|
+
/** The rate loop's cadence. */
|
|
5
|
+
export const RATE_TICK_MS = 120;
|
|
6
|
+
/** The composed rate at playhead `t` for the human's `base`. */
|
|
7
|
+
export function adviseRate(base, t, a) {
|
|
8
|
+
const speaker = speakerFactor(t, a.turns, a.speakerRates);
|
|
9
|
+
const silence = silenceFactor(t, a.silence, a.boost ?? null);
|
|
10
|
+
return { speaker, silence, rate: composeRate(base, speaker, silence).rate };
|
|
11
|
+
}
|
|
12
|
+
/** Is any advisor able to move the rate? False ⇒ the loop need not run. */
|
|
13
|
+
export function advisorsActive(a) {
|
|
14
|
+
return !!a.silence || (!!a.speakerRates && a.turns.length > 0 && Object.keys(a.speakerRates).length > 0);
|
|
15
|
+
}
|
package/dist/video/rate.d.ts
CHANGED
|
@@ -1,69 +1,26 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Rate module (mos-5lzns.32) — the rate composer and its advisors: smart
|
|
3
|
+
* speed, per-speaker pace, skip ad reads. Ported from extension-library
|
|
4
|
+
* FEATURES.md §The rate composer.
|
|
3
5
|
*
|
|
4
|
-
*
|
|
5
|
-
* composer") — this is the one non-obvious rule, stated up front because it
|
|
6
|
-
* looks like a bug from the outside:
|
|
6
|
+
* rate = base × speaker × silence (clamp [0.0625, 16], 0.001 deadband)
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
* · you — the speed keys (−/=/Backspace, alt+wheel)
|
|
10
|
-
* · speaker — "this person talks fast" (mos-5lzns.32 fills)
|
|
11
|
-
* · silence — "nothing is being said here" (mos-5lzns.32 fills)
|
|
8
|
+
* `base` is the only factor a human chose and the only one the UI reports.
|
|
12
9
|
*
|
|
13
|
-
*
|
|
10
|
+
* Layout (every file pure and node-testable — `npm run test:unit`):
|
|
14
11
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
* staircase) — it returns early, before any multiplication, because no
|
|
25
|
-
* advisor gets to un-pause the video by multiplying its way out of zero.
|
|
26
|
-
* 3. Only real changes are written. `playbackRate` is a live audio
|
|
27
|
-
* parameter: writing a *slightly* different value is audible as a wobble.
|
|
28
|
-
* Hence the 0.001 deadband.
|
|
29
|
-
* 4. The clamp is [0.0625, 16] — the range HTML media elements actually
|
|
30
|
-
* support. A product of three multipliers can leave it; a single one
|
|
31
|
-
* cannot.
|
|
32
|
-
*
|
|
33
|
-
* The SPEED_STEPS staircase and formatSpeedLabel are ported from the
|
|
34
|
-
* extension's theater (linear 0.1 steps below 1×, all the way down to 0 —
|
|
35
|
-
* playbackRate=0 is a genuine frozen frame; above 1× the familiar jumps).
|
|
36
|
-
*/
|
|
37
|
-
/** The speed staircase — one step per press of −/=, Backspace resets to 1. */
|
|
38
|
-
export declare const SPEED_STEPS: number[];
|
|
39
|
-
/**
|
|
40
|
-
* "A quarter speed" reads faster as "−.75" than "0.25×" — below 1× the label
|
|
41
|
-
* is the delta, not the multiplier. The 0 step is the frozen-frame end of the
|
|
42
|
-
* staircase — a state, not a level — and the label says so ("0×"), because a
|
|
43
|
-
* frozen frame labeled "1×" lies. Garbage degrades to `1×`.
|
|
44
|
-
*/
|
|
45
|
-
export declare function formatSpeedLabel(rate: number): string;
|
|
46
|
-
/**
|
|
47
|
-
* One step along the staircase. Unknown current rate snaps to 1 first (a
|
|
48
|
-
* hand-set rate off the staircase still steps from where the user expects).
|
|
49
|
-
*/
|
|
50
|
-
export declare function stepRate(current: number, direction: 1 | -1): number;
|
|
51
|
-
/** The clamp the composer applies — the range HTML media elements actually support. */
|
|
52
|
-
export declare const RATE_MIN = 0.0625;
|
|
53
|
-
export declare const RATE_MAX = 16;
|
|
54
|
-
/** Writes only on real change — a slightly-different write is audible as a wobble. */
|
|
55
|
-
export declare const RATE_DEADBAND = 0.001;
|
|
56
|
-
export interface ComposedRate {
|
|
57
|
-
rate: number;
|
|
58
|
-
/** False when the deadband swallowed the write (keep the previous rate). */
|
|
59
|
-
changed: boolean;
|
|
60
|
-
}
|
|
61
|
-
/**
|
|
62
|
-
* The whole arbitration. `speaker` and `silence` are advisor products in
|
|
63
|
-
* [0.0625, 16] that COLLAPSE TO 1 when their feature is off or its data has
|
|
64
|
-
* not arrived — mos-5lzns.32 fills both; until then they are always 1 and
|
|
65
|
-
* this is exactly `base`.
|
|
66
|
-
*
|
|
67
|
-
* `base === 0` returns early: a frozen frame is a state, not a level.
|
|
12
|
+
* · rate/compose.ts — the contract: SPEED_STEPS staircase, formatSpeedLabel,
|
|
13
|
+
* composeRate (base 0 is a state, advisors collapse to 1, clamp), and
|
|
14
|
+
* shouldWriteRate (deadband, except an exact return to base).
|
|
15
|
+
* · rate/speaker.ts — props → advisors/speaker-rate; manual rates keyed by
|
|
16
|
+
* NAME always beat measured ones.
|
|
17
|
+
* · rate/silence.ts — waveform → advisors/smart-speed map + boost factor.
|
|
18
|
+
* · rate/ads.ts — adSpans ∪ `ad: true` turns; skip only when PLAYBACK
|
|
19
|
+
* runs into an ad, never when a seek lands in one.
|
|
20
|
+
* · rate/tick.ts — the 120ms loop's one-tick decision.
|
|
68
21
|
*/
|
|
69
|
-
export
|
|
22
|
+
export * from './rate/compose';
|
|
23
|
+
export * from './rate/speaker';
|
|
24
|
+
export * from './rate/silence';
|
|
25
|
+
export * from './rate/ads';
|
|
26
|
+
export * from './rate/tick';
|
package/dist/video/rate.js
CHANGED
|
@@ -1,88 +1,26 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Rate module (mos-5lzns.32) — the rate composer and its advisors: smart
|
|
3
|
+
* speed, per-speaker pace, skip ad reads. Ported from extension-library
|
|
4
|
+
* FEATURES.md §The rate composer.
|
|
3
5
|
*
|
|
4
|
-
*
|
|
5
|
-
* composer") — this is the one non-obvious rule, stated up front because it
|
|
6
|
-
* looks like a bug from the outside:
|
|
6
|
+
* rate = base × speaker × silence (clamp [0.0625, 16], 0.001 deadband)
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
* · you — the speed keys (−/=/Backspace, alt+wheel)
|
|
10
|
-
* · speaker — "this person talks fast" (mos-5lzns.32 fills)
|
|
11
|
-
* · silence — "nothing is being said here" (mos-5lzns.32 fills)
|
|
8
|
+
* `base` is the only factor a human chose and the only one the UI reports.
|
|
12
9
|
*
|
|
13
|
-
*
|
|
10
|
+
* Layout (every file pure and node-testable — `npm run test:unit`):
|
|
14
11
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
* staircase) — it returns early, before any multiplication, because no
|
|
25
|
-
* advisor gets to un-pause the video by multiplying its way out of zero.
|
|
26
|
-
* 3. Only real changes are written. `playbackRate` is a live audio
|
|
27
|
-
* parameter: writing a *slightly* different value is audible as a wobble.
|
|
28
|
-
* Hence the 0.001 deadband.
|
|
29
|
-
* 4. The clamp is [0.0625, 16] — the range HTML media elements actually
|
|
30
|
-
* support. A product of three multipliers can leave it; a single one
|
|
31
|
-
* cannot.
|
|
32
|
-
*
|
|
33
|
-
* The SPEED_STEPS staircase and formatSpeedLabel are ported from the
|
|
34
|
-
* extension's theater (linear 0.1 steps below 1×, all the way down to 0 —
|
|
35
|
-
* playbackRate=0 is a genuine frozen frame; above 1× the familiar jumps).
|
|
36
|
-
*/
|
|
37
|
-
/** The speed staircase — one step per press of −/=, Backspace resets to 1. */
|
|
38
|
-
export const SPEED_STEPS = [
|
|
39
|
-
0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1, 1.1, 1.2, 1.3, 1.4, 1.5, 1.75, 2, 2.5, 5, 10
|
|
40
|
-
];
|
|
41
|
-
/**
|
|
42
|
-
* "A quarter speed" reads faster as "−.75" than "0.25×" — below 1× the label
|
|
43
|
-
* is the delta, not the multiplier. The 0 step is the frozen-frame end of the
|
|
44
|
-
* staircase — a state, not a level — and the label says so ("0×"), because a
|
|
45
|
-
* frozen frame labeled "1×" lies. Garbage degrades to `1×`.
|
|
46
|
-
*/
|
|
47
|
-
export function formatSpeedLabel(rate) {
|
|
48
|
-
if (!Number.isFinite(rate) || rate < 0)
|
|
49
|
-
return '1×';
|
|
50
|
-
if (rate === 0)
|
|
51
|
-
return '0×';
|
|
52
|
-
if (rate < 1) {
|
|
53
|
-
return (rate - 1).toFixed(2).replace(/\.?0+$/, '').replace(/^-0\./, '-.') + '×';
|
|
54
|
-
}
|
|
55
|
-
return rate.toFixed(2).replace(/\.?0+$/, '') + '×';
|
|
56
|
-
}
|
|
57
|
-
/**
|
|
58
|
-
* One step along the staircase. Unknown current rate snaps to 1 first (a
|
|
59
|
-
* hand-set rate off the staircase still steps from where the user expects).
|
|
60
|
-
*/
|
|
61
|
-
export function stepRate(current, direction) {
|
|
62
|
-
const i = SPEED_STEPS.indexOf(current);
|
|
63
|
-
const cur = i === -1 ? SPEED_STEPS.indexOf(1) : i;
|
|
64
|
-
return SPEED_STEPS[Math.min(SPEED_STEPS.length - 1, Math.max(0, cur + direction))];
|
|
65
|
-
}
|
|
66
|
-
/** The clamp the composer applies — the range HTML media elements actually support. */
|
|
67
|
-
export const RATE_MIN = 0.0625;
|
|
68
|
-
export const RATE_MAX = 16;
|
|
69
|
-
/** Writes only on real change — a slightly-different write is audible as a wobble. */
|
|
70
|
-
export const RATE_DEADBAND = 0.001;
|
|
71
|
-
/**
|
|
72
|
-
* The whole arbitration. `speaker` and `silence` are advisor products in
|
|
73
|
-
* [0.0625, 16] that COLLAPSE TO 1 when their feature is off or its data has
|
|
74
|
-
* not arrived — mos-5lzns.32 fills both; until then they are always 1 and
|
|
75
|
-
* this is exactly `base`.
|
|
76
|
-
*
|
|
77
|
-
* `base === 0` returns early: a frozen frame is a state, not a level.
|
|
12
|
+
* · rate/compose.ts — the contract: SPEED_STEPS staircase, formatSpeedLabel,
|
|
13
|
+
* composeRate (base 0 is a state, advisors collapse to 1, clamp), and
|
|
14
|
+
* shouldWriteRate (deadband, except an exact return to base).
|
|
15
|
+
* · rate/speaker.ts — props → advisors/speaker-rate; manual rates keyed by
|
|
16
|
+
* NAME always beat measured ones.
|
|
17
|
+
* · rate/silence.ts — waveform → advisors/smart-speed map + boost factor.
|
|
18
|
+
* · rate/ads.ts — adSpans ∪ `ad: true` turns; skip only when PLAYBACK
|
|
19
|
+
* runs into an ad, never when a seek lands in one.
|
|
20
|
+
* · rate/tick.ts — the 120ms loop's one-tick decision.
|
|
78
21
|
*/
|
|
79
|
-
export
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
const si = Number.isFinite(silence) && silence > 0 ? silence : 1;
|
|
85
|
-
const rate = Math.min(RATE_MAX, Math.max(RATE_MIN, base * sp * si));
|
|
86
|
-
const changed = Math.abs(rate - base) > RATE_DEADBAND;
|
|
87
|
-
return { rate: changed ? rate : base, changed };
|
|
88
|
-
}
|
|
22
|
+
export * from './rate/compose';
|
|
23
|
+
export * from './rate/speaker';
|
|
24
|
+
export * from './rate/silence';
|
|
25
|
+
export * from './rate/ads';
|
|
26
|
+
export * from './rate/tick';
|
package/dist/video/settings.js
CHANGED
|
@@ -40,7 +40,14 @@ export const VIDEO_SETTINGS = [
|
|
|
40
40
|
{ id: 'audio.target', type: 'range', default: -20, min: -30, max: -12, step: 2, owner: 'mos-5lzns.31', wired: true },
|
|
41
41
|
{ id: 'audio.quiet', type: 'bool', default: true, owner: 'mos-5lzns.31', wired: true },
|
|
42
42
|
{ id: 'audio.limiter', type: 'bool', default: true, owner: 'mos-5lzns.31', wired: true },
|
|
43
|
-
//
|
|
43
|
+
// Read by VideoPlayer's `smartSpeed` / `speakerPace` (+ `speakerRates`) /
|
|
44
|
+
// `skipAds` (+ `adSpans`) props → video/rate/* (silenceMapFor + silenceFactor,
|
|
45
|
+
// speakerRateMap + speakerFactor, normalizeAdSpans + adSkipTarget), composed
|
|
46
|
+
// by rate/compose.ts composeRate. All default off: an automatic rate change
|
|
47
|
+
// is the host's opt-in.
|
|
48
|
+
{ id: 'player.smartSpeed', type: 'bool', default: false, owner: 'mos-5lzns.32', wired: true },
|
|
49
|
+
{ id: 'player.speakerRate', type: 'bool', default: false, owner: 'mos-5lzns.32', wired: true },
|
|
50
|
+
{ id: 'player.skipAds', type: 'bool', default: false, owner: 'mos-5lzns.32', wired: true },
|
|
44
51
|
// Read by VideoPlayer's `night` / `nightDepth` / `biasLight` / `predictive` /
|
|
45
52
|
// `strobeGuard` props → video/night/* (clock.nightArmed, levels.nightLevels,
|
|
46
53
|
// meter.biasCells, track.lumaAhead / inFlash / composeNight). Unlike the
|