@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.
@@ -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 { composeRate, formatSpeedLabel, stepRate } from '../video/rate';
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
- * Apply the base rate. No advisors exist yet (mos-5lzns.32 fills the
606
- * speaker/silence ones) — composeRate collapses them to 1, so today this is
607
- * exactly `base`. When they land, the composer multiplies and this keeps
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, 1, 1);
753
+ const { rate } = composeRate(baseRate, rateSpeaker, rateSilence);
614
754
  // Only real changes are written — playbackRate is a live audio parameter.
615
- if (v.playbackRate !== rate) v.playbackRate = rate;
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
@@ -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 (done) — filled by **.32** | The rate composer contract (`composeRate`, deadband, clamp), the `SPEED_STEPS` staircase, `formatSpeedLabel`. .32 adds the speaker-pace and silence-map advisors — they never write `playbackRate`; they advise. |
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** | Loudness matching, quiet listening, limiter — audio-graph contract is in the file header. |
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` advisors; **.33** night → `night.ts`; **.34** presence/wind-down →
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, and it takes a
48
- MediaElementAudioSourceNode, never the element). Knobs arrive as an
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 {};