@banou/media-player 0.10.20 → 0.10.22

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/README.md CHANGED
@@ -44,6 +44,10 @@ const source = await inputToRemuxerInput({ url: 'https://example.com/episode.mkv
44
44
  const source = await inputToRemuxerInput({ length, read })
45
45
  ```
46
46
 
47
+ A URL's source comes back with `readsDownload: true`, which the player takes beside `read` and `size`: each read of
48
+ it is a download, so the volume normalizer then analyses the audio playback fetched rather than fetching the file
49
+ again.
50
+
47
51
  `usePlayer` reads and drives playback state from anywhere inside a `MediaPlayer`. It is the only hook
48
52
  the chrome uses: the built-in video.js state and this player's own source state (tracks, thumbnails,
49
53
  indexes, readiness) live on one store, so there is no second context to reach for.
@@ -89,8 +93,8 @@ reads as a browser bug rather than a missing asset. Serve both.
89
93
 
90
94
  `libavWorkerUrl` (`libav-wasm/build/worker.js`) is named individually and still copies fine.
91
95
 
92
- `normalizerWorkletUrl` is this package's own `dist/volume-normalizer-worklet.js`, a plain script that
93
- copies fine or imports as a url; see [the volume normalizer](#the-volume-normalizer).
96
+ `normalizerWorkletUrl` is this package's own `dist/volume-normalizer-worklet.js`, one plain module with no imports
97
+ that copies fine or imports as a url; see [the volume normalizer](#the-volume-normalizer).
94
98
 
95
99
  ### jassub's assets are imported, not copied
96
100
 
@@ -241,9 +245,9 @@ until a pointer that hovers moves again.
241
245
 
242
246
  ### The volume normalizer
243
247
 
244
- A "Normalize volume" switch in the settings menu holds the loudness steady, so a music cue comes out no
245
- louder than the dialogue around it. It is offered once you pass the worklet's url, and it is off until
246
- the viewer turns it on or you pass `normalizeVolume`:
248
+ A "Normalize volume" switch in the settings menu keeps the file's own normal level and turns down what goes well over
249
+ it, so a sudden blast comes out near the dialogue and the dialogue is left as it was. It is offered once you pass the
250
+ worklet's url, and it is on unless the viewer turns it off or you pass `normalizeVolume={false}`:
247
251
 
248
252
  ```tsx
249
253
  import normalizerWorkletUrl from '@banou/media-player/volume-normalizer-worklet.js?no-inline&url'
@@ -251,27 +255,35 @@ import normalizerWorkletUrl from '@banou/media-player/volume-normalizer-worklet.
251
255
  <MediaPlayer
252
256
  {...source}
253
257
  normalizerWorkletUrl={normalizerWorkletUrl}
254
- normalizeVolume={saved}
258
+ normalizeVolume={saved ?? true}
255
259
  onNormalizeVolumeChange={save}
256
260
  />
257
261
  ```
258
262
 
259
- The player saves nothing, so an app that wants the choice to stick saves what
260
- `onNormalizeVolumeChange` reports and passes it back.
261
-
262
- It is an AudioWorklet that follows EBU short-term loudness (K-weighted, 3 s) and turns the gain down,
263
- never up, toward -24 LUFS: up to 20 dB, cutting with a 0.25 s time constant and recovering with 1 s. A
264
- cue is brought within a dialogue's level in about 3 s, and it adds no delay. The url has to be a
265
- script your origin serves: a page whose CSP says `script-src 'self'` refuses a worklet from a `blob:`
266
- or `data:` url, and `build.lib` inlines assets as `data:` without `?no-inline`.
267
-
268
- The first time it is on, the `<video>` is routed through Web Audio for the rest of its life, because an
269
- element cannot be handed back; off is a bypass. Routing waits until the AudioContext runs: one that
270
- autoplay rules hold back resumes on the page's next click, tap or key press, and the video plays as it
271
- always did until then. WebKit is refused (`createMediaElementSource` in `@banou/ponyfill/web-audio`),
272
- since a routed element loses its volume and mute there. The refusal comes when routing would, once the
273
- switch is on and the AudioContext runs: until then the switch reads as available, and from then on it
274
- reads Unavailable, with the element never routed and playing as it always did.
263
+ The player saves nothing, so an app that wants the choice to stick saves what `onNormalizeVolumeChange` reports and
264
+ passes it back.
265
+
266
+ It works in two stages. A worker reads the file a second time, through `thumbnailRead` when you pass one and only
267
+ within `downloadedRanges` (for a source that `readsDownload` it reads nothing and takes the audio playback fetched,
268
+ about 30 s ahead of the playhead), decodes its audio with WebCodecs, learns the file's normal level (the median 400 ms
269
+ loudness) and computes a cut with unlimited lookahead, so a cut starts before a spike: every 10 ms of the output is
270
+ held to 10 LU over the normal level and every 50 ms to 8 LU. It analyses what is near the playhead first and then the
271
+ rest of the file, keeping only 1 ms loudness readings, never the audio. Wherever that analysis has not reached the
272
+ playhead, and for a track this browser's WebCodecs cannot decode, an AudioWorklet plays a live stage: a 100 ms detector
273
+ that cuts what goes 5 LU over the level heard so far, and the 10 ms loudness held to 10 LU over it, seen 10 ms ahead.
274
+ Neither stage ever boosts. The sound plays 10 ms behind the picture, on or off, once routed.
275
+
276
+ The url names one file that is both the worklet and the worker's script, so it has to be a script your origin serves:
277
+ a page whose CSP says `script-src 'self'` refuses a worklet or a worker from a `blob:` or `data:` url, and
278
+ `build.lib` inlines assets as `data:` without `?no-inline`. A worker must also be same-origin, so serve it from the
279
+ page's own origin.
280
+
281
+ The first time it is on, the `<video>` is routed through Web Audio for the rest of its life, because an element cannot
282
+ be handed back; off is a bypass. Routing waits until the AudioContext runs: one that autoplay rules hold back resumes
283
+ on the page's next click, tap or key press, and the video plays as it always did until then. WebKit is refused
284
+ (`createMediaElementSource` in `@banou/ponyfill/web-audio`), since a routed element loses its volume and mute there.
285
+ The refusal comes when routing would, once the switch is on and the AudioContext runs: until then the switch reads as
286
+ available, and from then on it reads Unavailable, with the element never routed and playing as it always did.
275
287
 
276
288
  ## Layout
277
289
 
@@ -0,0 +1,38 @@
1
+ import type { PlaybackTap } from './playback-tap';
2
+ /** The file the analysis reads, the same one playback plays. */
3
+ export type LoudnessSource = {
4
+ read: (offset: number, size: number) => Promise<ArrayBuffer>;
5
+ length: number;
6
+ publicPath: string;
7
+ libavWorkerUrl: string;
8
+ /** The input stream index of the audio track playing. */
9
+ audioStreamIndex: number;
10
+ };
11
+ export type LoudnessAnalysis = {
12
+ /**
13
+ * Which bytes can be read without waiting on a download, as [start, end) pairs; undefined when all of them can.
14
+ * Nothing outside them is asked of the source.
15
+ */
16
+ update: (ranges?: [number, number][]) => void;
17
+ stop: () => void;
18
+ };
19
+ /**
20
+ * Analyses `source` ahead of the playhead with a second remuxer, in the worker the normalizer's script starts, and
21
+ * hands the worklet behind `node` the cut it computes. It reads only what `update` says is there, analyses what is
22
+ * nearest the playhead first, then the rest of the file, and drops nothing it learned on a seek. Until a stretch is
23
+ * analysed, and for the whole file where this browser's WebCodecs cannot decode the track, the worklet's live stage
24
+ * plays.
25
+ *
26
+ * Given a `tap` instead, it reads nothing and analyses the audio playback remuxed for `audioStreamIndex`, which runs
27
+ * as far ahead of the playhead as playback buffers. It starts at the next fragment playback fetches, so what was
28
+ * buffered before it attached is not analysed.
29
+ */
30
+ export declare const startLoudnessAnalysis: ({ node, element, scriptUrl, source }: {
31
+ node: AudioWorkletNode;
32
+ element: HTMLMediaElement;
33
+ scriptUrl: string;
34
+ source: LoudnessSource | {
35
+ tap: PlaybackTap;
36
+ audioStreamIndex: number;
37
+ };
38
+ }) => LoudnessAnalysis;
@@ -0,0 +1,11 @@
1
+ import type { PlaybackOutput } from '../engine/playback';
2
+ /**
3
+ * Playback's output, for an analysis that reads nothing of its own: `push` takes what playback's remuxer produced,
4
+ * and `attach` hands a listener what follows for one input audio stream, starting with the last init segment and the
5
+ * run in progress. `join` marks a run some of whose fragments went by before the listener was attached.
6
+ */
7
+ export type PlaybackTap = {
8
+ push: (output: PlaybackOutput) => void;
9
+ attach: (audioStreamIndex: number, listener: (output: PlaybackOutput, join?: boolean) => void) => () => void;
10
+ };
11
+ export declare const createPlaybackTap: () => PlaybackTap;
@@ -1,6 +1,8 @@
1
- /** The worklet's processorOptions, read once when the node is made; its header has the defaults. */
1
+ /**
2
+ * The worklet's processorOptions, read once when the node is made: `enabled` false starts it bypassed, and
3
+ * `inputGainDb` is the gain already applied upstream (the element's volume), which loudness is judged without.
4
+ */
2
5
  export type VolumeNormalizerOptions = {
3
- targetLufs?: number;
4
6
  enabled?: boolean;
5
7
  inputGainDb?: number;
6
8
  };
@@ -1,5 +1,5 @@
1
1
  export { startPlayback, terminateRemuxer, MediaElementError, isMediaElementError, DEFAULT_BUFFER_SIZE } from './playback';
2
- export type { PlaybackOptions, PlaybackController, MediaIndex, MediaChapter, AudioStream } from './playback';
2
+ export type { PlaybackOptions, PlaybackController, PlaybackOutput, MediaIndex, MediaChapter, AudioStream } from './playback';
3
3
  export { createSubtitleRenderer, SUBTITLES_OFF } from './subtitles';
4
4
  export type { SubtitleRenderer, SubtitleRendererOptions, SubtitleStream } from './subtitles';
5
5
  export { createThumbnailGenerator } from './thumbnails';
@@ -1,2 +1,2 @@
1
- import { a as e, c as t, d as n, f as r, i, l as a, n as o, o as s, r as c, s as l, t as u, u as d } from "../engine-C4Gj_4zs.js";
1
+ import { a as e, c as t, d as n, f as r, i, l as a, n as o, o as s, r as c, s as l, t as u, u as d } from "../engine-Cr_7OImR.js";
2
2
  export { i as DEFAULT_BUFFER_SIZE, e as MediaElementError, a as SUBTITLES_OFF, u as createPictureInPicture, d as createSubtitleRenderer, c as createThumbnailGenerator, n as getTimeRanges, s as isMediaElementError, o as pictureInPictureMode, l as startPlayback, t as terminateRemuxer, r as updateSourceBuffer };
@@ -21,6 +21,26 @@ export type MediaChapter = {
21
21
  end: number;
22
22
  title: string;
23
23
  };
24
+ /**
25
+ * What the remuxer produced, in the order it was appended: the init segment and the input audio stream it carries,
26
+ * a run at load and at every remuxer seek (`pts` is the timestampOffset its fragments are appended at), each
27
+ * fragment, and the end of the file once read. The buffers are playback's own and may be appended again, so a
28
+ * consumer copies what it keeps.
29
+ */
30
+ export type PlaybackOutput = {
31
+ type: 'init';
32
+ data: ArrayBuffer;
33
+ audioMimeType: string;
34
+ audioStreamIndex: number;
35
+ } | {
36
+ type: 'run';
37
+ pts: number;
38
+ } | {
39
+ type: 'fragment';
40
+ data: ArrayBuffer;
41
+ } | {
42
+ type: 'end';
43
+ };
24
44
  export type PlaybackOptions = {
25
45
  videoElement: HTMLVideoElement;
26
46
  /**
@@ -49,6 +69,7 @@ export type PlaybackOptions = {
49
69
  onSeek?: (fraction: number) => void;
50
70
  onSubtitleStreams?: (streams: SubtitleStream[]) => void;
51
71
  onAudioStreams?: (streams: AudioStream[], selected: number) => void;
72
+ onOutput?: (output: PlaybackOutput) => void;
52
73
  };
53
74
  export type PlaybackController = {
54
75
  destroy: () => void;