react-native-tv-audio-description 0.1.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.
Files changed (57) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/LICENSE +21 -0
  3. package/PLATFORM.md +262 -0
  4. package/README.md +324 -0
  5. package/lib/ADControls.d.ts +24 -0
  6. package/lib/ADControls.js +41 -0
  7. package/lib/ADControls.js.map +1 -0
  8. package/lib/CueScheduler.d.ts +49 -0
  9. package/lib/CueScheduler.js +114 -0
  10. package/lib/CueScheduler.js.map +1 -0
  11. package/lib/DescriptionAudio.d.ts +36 -0
  12. package/lib/DescriptionAudio.js +88 -0
  13. package/lib/DescriptionAudio.js.map +1 -0
  14. package/lib/MediaAdapter.d.ts +123 -0
  15. package/lib/MediaAdapter.js +3 -0
  16. package/lib/MediaAdapter.js.map +1 -0
  17. package/lib/TrackLoader.d.ts +48 -0
  18. package/lib/TrackLoader.js +129 -0
  19. package/lib/TrackLoader.js.map +1 -0
  20. package/lib/budget.d.ts +41 -0
  21. package/lib/budget.js +60 -0
  22. package/lib/budget.js.map +1 -0
  23. package/lib/duck.d.ts +21 -0
  24. package/lib/duck.js +40 -0
  25. package/lib/duck.js.map +1 -0
  26. package/lib/index.d.ts +10 -0
  27. package/lib/index.js +33 -0
  28. package/lib/index.js.map +1 -0
  29. package/lib/log.d.ts +17 -0
  30. package/lib/log.js +18 -0
  31. package/lib/log.js.map +1 -0
  32. package/lib/messages.d.ts +21 -0
  33. package/lib/messages.js +17 -0
  34. package/lib/messages.js.map +1 -0
  35. package/lib/track.d.ts +44 -0
  36. package/lib/track.js +22 -0
  37. package/lib/track.js.map +1 -0
  38. package/lib/vega/SegmentBuffer.d.ts +87 -0
  39. package/lib/vega/SegmentBuffer.js +133 -0
  40. package/lib/vega/SegmentBuffer.js.map +1 -0
  41. package/lib/vega/index.d.ts +33 -0
  42. package/lib/vega/index.js +280 -0
  43. package/lib/vega/index.js.map +1 -0
  44. package/package.json +89 -0
  45. package/src/ADControls.tsx +92 -0
  46. package/src/CueScheduler.ts +145 -0
  47. package/src/DescriptionAudio.ts +97 -0
  48. package/src/MediaAdapter.ts +136 -0
  49. package/src/TrackLoader.ts +156 -0
  50. package/src/budget.ts +61 -0
  51. package/src/duck.ts +42 -0
  52. package/src/index.ts +42 -0
  53. package/src/log.ts +30 -0
  54. package/src/messages.ts +27 -0
  55. package/src/track.ts +52 -0
  56. package/src/vega/SegmentBuffer.ts +195 -0
  57. package/src/vega/index.tsx +382 -0
@@ -0,0 +1,97 @@
1
+ import type { MediaAdapter } from './MediaAdapter';
2
+ import { AD } from './budget';
3
+ import type { DescriptionCue } from './track';
4
+ import { rampVolumePct } from './duck';
5
+ import { log } from './log';
6
+
7
+ export interface DescriptionAudioOptions {
8
+ /** main track level while a cue speaks, % of full (default 25) */
9
+ duckTargetPct?: number;
10
+ /** fade length into and out of the duck (default 200) */
11
+ rampMs?: number;
12
+ }
13
+
14
+ /**
15
+ * Duck, speak, restore.
16
+ *
17
+ * The cue plays as a SECOND stream over the film rather than by switching to
18
+ * a pre-mixed track. On Vega that was measured to work: a video player and an
19
+ * audio player built for accessibility speech ran simultaneously with zero
20
+ * dropped frames (PLATFORM.md `concurrent_streams`).
21
+ */
22
+ export class DescriptionAudio {
23
+ private active = false;
24
+ /**
25
+ * Bumped by `stop()`. A cue in flight when the screen unmounts keeps running
26
+ * — its `await` chain does not know the component is gone — and its
27
+ * `finally` would then ramp the volume on a player that has been destroyed,
28
+ * after `stop()` already restored it.
29
+ */
30
+ private generation = 0;
31
+ private readonly duckPct: number;
32
+ private readonly rampMs: number;
33
+
34
+ constructor(
35
+ private readonly media: MediaAdapter,
36
+ options: DescriptionAudioOptions = {},
37
+ ) {
38
+ this.duckPct = options.duckTargetPct ?? AD.DUCK_TARGET_PCT;
39
+ this.rampMs = options.rampMs ?? AD.DUCK_RAMP_MS;
40
+ // Backgrounding during a cue must stop the clip and restore the main
41
+ // level, or the app returns to the foreground ducked and silent.
42
+ this.media.lifecycle.onBackground(() => {
43
+ if (!this.active) return;
44
+ log('audio.background active=true');
45
+ void this.stop();
46
+ });
47
+ }
48
+
49
+ async speak(cue: DescriptionCue): Promise<void> {
50
+ if (this.active) {
51
+ // a cue already speaking is never interrupted by another — but the one
52
+ // that lost is said out loud in the log, not dropped in silence
53
+ log(`audio.busy id=${cue.id}`);
54
+ return;
55
+ }
56
+ this.active = true;
57
+ const mine = this.generation;
58
+ const stale = () => mine !== this.generation;
59
+
60
+ try {
61
+ log(`audio.duck id=${cue.id} to_pct=${this.duckPct}`);
62
+ await rampVolumePct(this.media.video, 100, this.duckPct, this.rampMs, stale);
63
+ await this.media.clips.play(cue.audio_uri);
64
+ log(stale() ? `audio.interrupted id=${cue.id}` : `audio.spoke id=${cue.id} words=${cue.words}`);
65
+ } catch (err) {
66
+ log(`audio.failed id=${cue.id} err=${(err as Error).message}`);
67
+ } finally {
68
+ // The main track ALWAYS returns to full, including on failure. A cue
69
+ // that fails must not leave the film at 25% for the rest of the runtime.
70
+ //
71
+ // Unless stop() already did it: then this cue is stale, the player may
72
+ // be torn down, and ramping again is work against a dead object.
73
+ if (!stale()) {
74
+ await rampVolumePct(this.media.video, this.duckPct, 100, this.rampMs, stale);
75
+ log(`audio.restored id=${cue.id}`);
76
+ this.active = false;
77
+ }
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Stop the cue that is speaking, if any, and bring the film back to full.
83
+ * Safe to call at any time — turning description off, leaving the screen.
84
+ */
85
+ async stop(): Promise<void> {
86
+ this.generation++; // anything in flight is now stale and must not restore
87
+ this.media.clips.stop();
88
+ // Nothing speaking means the film is already at full. Ramping "back" from
89
+ // the duck level anyway would first SET it to the duck level: an audible
90
+ // dip every time description is switched off between cues.
91
+ if (!this.active) return;
92
+ log('audio.stopped');
93
+ await rampVolumePct(this.media.video, this.duckPct, 100, this.rampMs);
94
+ this.active = false;
95
+ log('audio.restored after=stop');
96
+ }
97
+ }
@@ -0,0 +1,136 @@
1
+ import type { ComponentType } from 'react';
2
+ import type { StyleProp, ViewStyle } from 'react-native';
3
+
4
+ /**
5
+ * The only surface this library uses to reach a platform's media stack.
6
+ *
7
+ * Everything outside `src/vega/` is written against this interface and never
8
+ * against a platform package; a test fails if that stops being true. Bring
9
+ * your own implementation for another platform, or use the Vega one:
10
+ *
11
+ * import { createVegaAdapter } from 'react-native-tv-audio-description/vega';
12
+ *
13
+ * Each platform defect the Vega implementation works around is kept OUT of
14
+ * this interface on purpose — a caller that had to know about them would leak
15
+ * one platform's defects into every other. They are listed in PLATFORM.md.
16
+ */
17
+
18
+ export type Unsubscribe = () => void;
19
+
20
+ /** one piece of an asset, with times read from the emitted playlist */
21
+ export interface AssetSegment {
22
+ index: number;
23
+ start_ms: number;
24
+ end_ms: number;
25
+ uri: string;
26
+ }
27
+
28
+ /**
29
+ * What to play, and how it is cut up.
30
+ *
31
+ * ALWAYS A LIST, even for a single file — a whole-file asset is one segment
32
+ * with no init. One code path for both shapes means a short clip and a
33
+ * feature-length film exercise the same buffering logic, rather than the
34
+ * feature-length one taking a path nothing has ever run.
35
+ */
36
+ export interface AssetSource {
37
+ /** appended once, before any segment; absent for a whole-file asset */
38
+ initUri?: string;
39
+ segments: AssetSegment[];
40
+ }
41
+
42
+ export interface VideoPlayer {
43
+ /**
44
+ * Point the player at an asset and get it ready to play.
45
+ *
46
+ * Implementations must NOT assume the platform will fetch anything. On Vega
47
+ * it will not (PLATFORM.md `url_mode_broken`) and the implementation reads
48
+ * the bytes itself — nor can it read PART of a file (`no_range_requests`),
49
+ * which is why the asset arrives already cut into segments.
50
+ */
51
+ open(source: AssetSource): Promise<void>;
52
+ play(): Promise<void>;
53
+ pause(): void;
54
+
55
+ /** current playback position, ms */
56
+ positionMs(): number;
57
+ durationMs(): number;
58
+ isPlaying(): boolean;
59
+
60
+ /**
61
+ * Set the main track volume as a percentage of full, EFFECTIVE IMMEDIATELY.
62
+ *
63
+ * There is no ramp parameter, and its absence is measured rather than
64
+ * chosen: the W3C volume setter is instantaneous and Vega exposes no fade
65
+ * (PLATFORM.md `no_volume_ramp`). The fade is implemented once, in JS, in
66
+ * `duck.ts`. An interface that accepted `rampMs` would invite every
67
+ * implementation to reimplement the same loop and would imply a capability
68
+ * no platform here has.
69
+ */
70
+ setVolumePct(pct: number): void;
71
+
72
+ /** fires on every position update the platform emits ('timeupdate') */
73
+ onPosition(cb: (ms: number) => void): Unsubscribe;
74
+ /** fires after a seek settles, with the new position ('seeked') */
75
+ onSeek(cb: (ms: number) => void): Unsubscribe;
76
+ /**
77
+ * Fires when playback cannot continue because the buffer ran dry
78
+ * ('waiting' / 'stalled').
79
+ *
80
+ * With the app owning byte delivery this is a REACHABLE state and it is NOT
81
+ * an error — no `error` event follows it. A UI that only listens for
82
+ * `onError` shows a frozen picture and says nothing, which for a blind
83
+ * viewer is indistinguishable from a quiet scene.
84
+ */
85
+ onStalled(cb: () => void): Unsubscribe;
86
+ /**
87
+ * Fires when playback is actually running ('playing').
88
+ *
89
+ * The counterpart to `onStalled`, and not optional: MSE emits `waiting` at
90
+ * the START of normal playback while the first frames decode, so a screen
91
+ * that treats stalling as terminal announces "Buffering" over a film that is
92
+ * playing fine (PLATFORM.md `waiting_fires_at_start`).
93
+ */
94
+ onPlaying(cb: () => void): Unsubscribe;
95
+ onEnded(cb: () => void): Unsubscribe;
96
+ onError(cb: (err: Error) => void): Unsubscribe;
97
+
98
+ /** release everything; safe to call twice */
99
+ destroy(): Promise<void>;
100
+ }
101
+
102
+ export interface ClipPlayer {
103
+ /**
104
+ * Plays one description clip to completion; rejects if it cannot be played.
105
+ * Same rule as `VideoPlayer.open`: no implementation may assume the
106
+ * platform fetches the URI.
107
+ */
108
+ play(uri: string): Promise<void>;
109
+ stop(): void;
110
+ }
111
+
112
+ export interface AppLifecycle {
113
+ /** fires when the app leaves the foreground */
114
+ onBackground(cb: () => void): Unsubscribe;
115
+ }
116
+
117
+ export interface VideoSurfaceProps {
118
+ style?: StyleProp<ViewStyle>;
119
+ }
120
+
121
+ export interface MediaAdapter {
122
+ video: VideoPlayer;
123
+ clips: ClipPlayer;
124
+ lifecycle: AppLifecycle;
125
+ /**
126
+ * The platform's own view that decoded pixels render into, already wired to
127
+ * `video`.
128
+ *
129
+ * It is part of the adapter rather than something a screen imports, because
130
+ * mounting it IS platform code: on Vega it is `KeplerVideoSurfaceView` and
131
+ * the handle it hands back has to reach the player — and it has to be
132
+ * tracked against `initialize()`, which races it (PLATFORM.md
133
+ * `surface_races_init`).
134
+ */
135
+ VideoSurface: ComponentType<VideoSurfaceProps>;
136
+ }
@@ -0,0 +1,156 @@
1
+ import type { DescriptionCue, DescriptionTrack, Verbosity } from './track';
2
+ import { trackFileName, VERBOSITY_LEVELS } from './track';
3
+ import { MAX_CLIPS_IN_MEMORY } from './budget';
4
+ import { log } from './log';
5
+
6
+ /**
7
+ * Load a track file and refuse to believe it.
8
+ *
9
+ * Validation here is not defensive decoration. The loader does not trust that
10
+ * the producer was well-behaved, and a malformed or missing track must end in
11
+ * a STATED visible and spoken state, never in silence. A silent app with
12
+ * nothing to say is indistinguishable from a working app in a quiet scene.
13
+ */
14
+
15
+ export type LoadResult =
16
+ | { ok: true; track: DescriptionTrack; loaded_verbosity: Verbosity }
17
+ | { ok: false; reason: 'missing' | 'malformed'; detail: string };
18
+
19
+ export const CUE_KEYS = [
20
+ 'id',
21
+ 'start_ms',
22
+ 'end_ms',
23
+ 'words',
24
+ 'text',
25
+ 'audio_uri',
26
+ 'source_frames_ms',
27
+ 'status',
28
+ ] as const;
29
+
30
+ export const TRACK_KEYS = [
31
+ 'version',
32
+ 'asset_id',
33
+ 'generated_at',
34
+ 'source_subtitles',
35
+ 'verbosity',
36
+ 'model_id',
37
+ 'cues',
38
+ ] as const;
39
+
40
+ function validCue(v: unknown): v is DescriptionCue {
41
+ if (typeof v !== 'object' || v === null) return false;
42
+ const c = v as Record<string, unknown>;
43
+ if (CUE_KEYS.some((k) => !(k in c))) return false;
44
+ return (
45
+ typeof c.id === 'string' &&
46
+ typeof c.start_ms === 'number' &&
47
+ typeof c.end_ms === 'number' &&
48
+ typeof c.words === 'number' &&
49
+ typeof c.text === 'string' &&
50
+ typeof c.audio_uri === 'string' &&
51
+ Array.isArray(c.source_frames_ms) &&
52
+ c.source_frames_ms.every((n) => typeof n === 'number') &&
53
+ (c.status === 'ok' || c.status === 'failed')
54
+ );
55
+ }
56
+
57
+ /** Validates a parsed track field for field. */
58
+ export function validateTrack(raw: unknown): LoadResult {
59
+ if (typeof raw !== 'object' || raw === null) {
60
+ return { ok: false, reason: 'malformed', detail: 'not an object' };
61
+ }
62
+ const t = raw as Record<string, unknown>;
63
+
64
+ const missing = TRACK_KEYS.filter((k) => !(k in t));
65
+ if (missing.length) {
66
+ return { ok: false, reason: 'malformed', detail: `missing ${missing.join(',')}` };
67
+ }
68
+ if (!VERBOSITY_LEVELS.includes(t.verbosity as Verbosity)) {
69
+ return { ok: false, reason: 'malformed', detail: `verbosity=${String(t.verbosity)}` };
70
+ }
71
+ if (!Array.isArray(t.cues) || !t.cues.every(validCue)) {
72
+ return { ok: false, reason: 'malformed', detail: 'cues' };
73
+ }
74
+
75
+ return {
76
+ ok: true,
77
+ track: raw as DescriptionTrack,
78
+ loaded_verbosity: t.verbosity as Verbosity,
79
+ };
80
+ }
81
+
82
+ /**
83
+ * Resolve a level by `<asset_id>.<verbosity>.track.json` beside the asset,
84
+ * FALLING BACK to `standard`. The fallback is what stops a missing `detailed`
85
+ * file from turning the feature off.
86
+ *
87
+ * `readJson` is yours: on Vega a packaged file is read with `fetch`, and the
88
+ * platform's own player cannot be pointed at it (PLATFORM.md
89
+ * `url_mode_broken`), so there is no platform loader to delegate to.
90
+ */
91
+ export async function loadTrack(
92
+ readJson: (path: string) => Promise<unknown>,
93
+ assetDir: string,
94
+ assetId: string,
95
+ verbosity: Verbosity,
96
+ ): Promise<LoadResult> {
97
+ const levels: Verbosity[] =
98
+ verbosity === 'standard' ? [verbosity] : [verbosity, 'standard'];
99
+
100
+ for (const level of levels) {
101
+ const path = `${assetDir}/${trackFileName(assetId, level)}`;
102
+ let raw: unknown;
103
+ try {
104
+ raw = await readJson(path);
105
+ } catch {
106
+ log(`loader.miss path=${path}`);
107
+ continue;
108
+ }
109
+
110
+ const result = validateTrack(raw);
111
+ log(
112
+ `loader.load path=${path} ok=${result.ok}` +
113
+ (result.ok ? ` cues=${result.track.cues.length}` : ` reason=${result.reason}`),
114
+ );
115
+
116
+ // A malformed file is an error, not a reason to fall back: something
117
+ // produced a file that is not a track, and silently loading a different
118
+ // one would hide it.
119
+ return result;
120
+ }
121
+
122
+ return { ok: false, reason: 'missing', detail: trackFileName(assetId, verbosity) };
123
+ }
124
+
125
+ /**
126
+ * A bounded clip cache, oldest out first.
127
+ *
128
+ * It holds BYTES, not URIs, and on Vega that is forced rather than chosen:
129
+ * there is no URI a player will fetch (PLATFORM.md `url_mode_broken`), so
130
+ * every clip is read by the app and appended through a MediaSource.
131
+ */
132
+ export class ClipCache {
133
+ private order: string[] = [];
134
+ private held = new Map<string, ArrayBuffer>();
135
+
136
+ constructor(private readonly max: number = MAX_CLIPS_IN_MEMORY) {}
137
+
138
+ put(uri: string, bytes: ArrayBuffer): void {
139
+ if (this.held.has(uri)) this.order = this.order.filter((u) => u !== uri);
140
+ this.held.set(uri, bytes);
141
+ this.order.push(uri);
142
+ while (this.order.length > this.max) {
143
+ const evicted = this.order.shift()!;
144
+ this.held.delete(evicted);
145
+ log(`cache.evict uri=${evicted} size=${this.held.size}`);
146
+ }
147
+ }
148
+
149
+ get(uri: string): ArrayBuffer | undefined {
150
+ return this.held.get(uri);
151
+ }
152
+
153
+ get size(): number {
154
+ return this.held.size;
155
+ }
156
+ }
package/src/budget.ts ADDED
@@ -0,0 +1,61 @@
1
+ import type { Verbosity } from './track';
2
+
3
+ /**
4
+ * The numbers a player and a track producer have to agree on.
5
+ *
6
+ * The playback half (`DUCK_*`, `MAX_CLIPS_IN_MEMORY`) is what this library
7
+ * uses at runtime. The word budget is exported for whoever PRODUCES tracks: a
8
+ * cue longer than its gap either overruns into dialogue or gets cut off, and
9
+ * both are worse than a shorter sentence.
10
+ */
11
+ export const AD = {
12
+ /** a dialogue gap shorter than this holds nothing worth saying */
13
+ MIN_GAP_MS: 1500,
14
+ /** narration pace used to turn a gap into a word count */
15
+ SPEAKING_RATE_WPM: 160,
16
+ /** how far the film is lowered while a cue speaks, as % of full */
17
+ DUCK_TARGET_PCT: 25,
18
+ /** the fade into and out of the duck; stepped in JS, see `duck.ts` */
19
+ DUCK_RAMP_MS: 200,
20
+ /** subtracted from every gap before budgeting: the duck ramp plus 100 ms */
21
+ BUDGET_MARGIN_MS: 300,
22
+ } as const;
23
+
24
+ /**
25
+ * Word TARGETS as a fraction of the physical ceiling — never multipliers of it.
26
+ * Every value is <= 1.0 by construction, so no level can ask for more words
27
+ * than the gap holds and no clamp is needed.
28
+ */
29
+ export const VERBOSITY_SCALES: Record<Verbosity, number> = {
30
+ concise: 0.6,
31
+ standard: 0.85,
32
+ detailed: 1.0,
33
+ };
34
+
35
+ /**
36
+ * Description clips held in memory at once. A Fire TV Stick is a 32-bit
37
+ * process with a small heap, so only the next few clips are resident.
38
+ */
39
+ export const MAX_CLIPS_IN_MEMORY = 8;
40
+
41
+ function wordsIn(ms: number): number {
42
+ return Math.floor(((ms / 1000) * AD.SPEAKING_RATE_WPM) / 60);
43
+ }
44
+
45
+ /** floor((gap_ms - 300) / 1000 * 160 / 60), never negative */
46
+ export function baseWordBudget(gapMs: number): number {
47
+ return Math.max(0, wordsIn(gapMs - AD.BUDGET_MARGIN_MS));
48
+ }
49
+
50
+ /** the number of words to ASK a describer for at this level */
51
+ export function wordTarget(gapMs: number, verbosity: Verbosity): number {
52
+ return Math.floor(baseWordBudget(gapMs) * VERBOSITY_SCALES[verbosity]);
53
+ }
54
+
55
+ /**
56
+ * The number of words a cue may not exceed, at any level. It does not vary by
57
+ * verbosity: the gap is the gap.
58
+ */
59
+ export function wordCeiling(gapMs: number): number {
60
+ return baseWordBudget(gapMs);
61
+ }
package/src/duck.ts ADDED
@@ -0,0 +1,42 @@
1
+ import type { VideoPlayer } from './MediaAdapter';
2
+ import { AD } from './budget';
3
+
4
+ /** how often the fade writes; a floor, since timers on a TV may fire late */
5
+ const STEP_MS = 16;
6
+
7
+ /**
8
+ * Fade the main track between two volume percentages over `rampMs`.
9
+ *
10
+ * The platform setter is instantaneous (PLATFORM.md `no_volume_ramp`), so the
11
+ * ramp is stepped here. Written once, in one place, because a fade duplicated
12
+ * per platform is a fade that differs per platform — which is also why
13
+ * `VideoPlayer.setVolumePct` takes no `rampMs`.
14
+ *
15
+ * Each step sets the level for the time ELAPSED, not for its step number. On
16
+ * the Vega Virtual Device a fade counted in 13 steps of 16 ms took about
17
+ * 510 ms instead of 200 — timers fire late there — and the film came back up
18
+ * after its dialogue window had closed (PLATFORM.md `coarse_timers`).
19
+ */
20
+ export async function rampVolumePct(
21
+ video: VideoPlayer,
22
+ fromPct: number,
23
+ toPct: number,
24
+ rampMs: number = AD.DUCK_RAMP_MS,
25
+ /**
26
+ * Checked between steps. A fade is a loop that outlives the reason it
27
+ * started: unmount the screen mid-duck and it keeps stepping the volume of a
28
+ * player that is being torn down, toward a target nobody wants any more.
29
+ */
30
+ isCancelled: () => boolean = () => false,
31
+ ): Promise<void> {
32
+ const start = Date.now();
33
+ for (;;) {
34
+ if (isCancelled()) return;
35
+ const t = rampMs > 0 ? Math.min(1, (Date.now() - start) / rampMs) : 1;
36
+ // t reaches exactly 1, so the fade lands exactly on the target: rounding
37
+ // must never leave the film at 99% forever.
38
+ video.setVolumePct(t >= 1 ? toPct : fromPct + (toPct - fromPct) * t);
39
+ if (t >= 1) return;
40
+ await new Promise((r) => setTimeout(r, STEP_MS));
41
+ }
42
+ }
package/src/index.ts ADDED
@@ -0,0 +1,42 @@
1
+ export type {
2
+ AppLifecycle,
3
+ AssetSegment,
4
+ AssetSource,
5
+ ClipPlayer,
6
+ MediaAdapter,
7
+ Unsubscribe,
8
+ VideoPlayer,
9
+ VideoSurfaceProps,
10
+ } from './MediaAdapter';
11
+
12
+ export type { CueStatus, DescriptionCue, DescriptionTrack, Verbosity } from './track';
13
+ export { VERBOSITY_LEVELS, trackFileName } from './track';
14
+
15
+ export {
16
+ AD,
17
+ MAX_CLIPS_IN_MEMORY,
18
+ VERBOSITY_SCALES,
19
+ baseWordBudget,
20
+ wordCeiling,
21
+ wordTarget,
22
+ } from './budget';
23
+
24
+ export {
25
+ CueScheduler,
26
+ coalesce,
27
+ estimateCueMs,
28
+ type SchedulerEvents,
29
+ type SchedulerOptions,
30
+ } from './CueScheduler';
31
+ export { DescriptionAudio, type DescriptionAudioOptions } from './DescriptionAudio';
32
+ export { rampVolumePct } from './duck';
33
+ export {
34
+ ClipCache,
35
+ CUE_KEYS,
36
+ TRACK_KEYS,
37
+ loadTrack,
38
+ validateTrack,
39
+ type LoadResult,
40
+ } from './TrackLoader';
41
+ export { ADControls, stateMessage, type ADControlsProps, type ADState } from './ADControls';
42
+ export { setLogger, type Logger } from './log';
package/src/log.ts ADDED
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Where this library's diagnostic lines go. Nowhere, by default.
3
+ *
4
+ * It is injectable rather than `console.log` because on Vega there is no
5
+ * readable JavaScript console at all — `console.log` reaches neither the
6
+ * device log stream nor the Metro terminal, in Release or in Debug (see
7
+ * PLATFORM.md, `no_js_console`). An app that needs to see these lines on a
8
+ * device has to ship them somewhere itself, usually an HTTP beacon to the
9
+ * development host, and only the app knows where that is.
10
+ *
11
+ * Lines are `area.event key=value ...` with no prefix; add your own.
12
+ *
13
+ * setLogger(line => beacon(`MYAPP.${line}`));
14
+ */
15
+ export type Logger = (line: string) => void;
16
+
17
+ const silent: Logger = () => undefined;
18
+ let sink: Logger = silent;
19
+
20
+ export function setLogger(logger: Logger | null): void {
21
+ sink = logger ?? silent;
22
+ }
23
+
24
+ export function log(line: string): void {
25
+ try {
26
+ sink(line);
27
+ } catch {
28
+ // diagnostics must never break playback
29
+ }
30
+ }
@@ -0,0 +1,27 @@
1
+ import type { Verbosity } from './track';
2
+
3
+ /**
4
+ * What the description layer is doing, as one sentence a screen reader can
5
+ * say. Kept apart from `ADControls` so it has no React Native dependency:
6
+ * any surface — a custom control, a toast, a test, a Node script — can speak
7
+ * the same sentences.
8
+ */
9
+
10
+ export type ADState =
11
+ | { kind: 'ready'; enabled: boolean; verbosity: Verbosity; cues: number }
12
+ | { kind: 'missing'; detail: string }
13
+ | { kind: 'malformed'; detail: string };
14
+
15
+ /** every branch has a sentence, and the sentence is spoken, not only shown */
16
+ export function stateMessage(state: ADState): string {
17
+ switch (state.kind) {
18
+ case 'ready':
19
+ return state.enabled
20
+ ? `Audio description on, ${state.verbosity}, ${state.cues} descriptions`
21
+ : 'Audio description off';
22
+ case 'missing':
23
+ return 'No description track was found for this title. Playback continues without description.';
24
+ case 'malformed':
25
+ return 'The description track for this title could not be read. Playback continues without description.';
26
+ }
27
+ }
package/src/track.ts ADDED
@@ -0,0 +1,52 @@
1
+ /**
2
+ * The description track: what a producer writes and what this library plays.
3
+ *
4
+ * One file per verbosity level, because the levels do not share a cue list: a
5
+ * short gap that holds a useful sentence at `detailed` may hold nothing useful
6
+ * at `concise`, so the concise track has fewer cues, not shorter ones. In the
7
+ * film this was extracted from the three levels came out at 47 / 55 / 60 cues.
8
+ */
9
+
10
+ export type Verbosity = 'concise' | 'standard' | 'detailed';
11
+
12
+ export type CueStatus = 'ok' | 'failed';
13
+
14
+ export interface DescriptionCue {
15
+ id: string;
16
+ /** the dialogue gap this cue may speak in; `end_ms` is exclusive */
17
+ start_ms: number;
18
+ end_ms: number;
19
+ words: number;
20
+ text: string;
21
+ /** the spoken clip; empty for a `failed` cue */
22
+ audio_uri: string;
23
+ /** frames the description was written from, for traceability */
24
+ source_frames_ms: number[];
25
+ /**
26
+ * A cue whose description could not be produced is WRITTEN as `failed`, not
27
+ * dropped: a player can skip a failed cue, but it cannot tell a missing cue
28
+ * from a gap that was never meant to hold one.
29
+ */
30
+ status: CueStatus;
31
+ }
32
+
33
+ export interface DescriptionTrack {
34
+ version: string;
35
+ asset_id: string;
36
+ generated_at: string;
37
+ source_subtitles: string;
38
+ verbosity: Verbosity;
39
+ model_id: string;
40
+ cues: DescriptionCue[];
41
+ }
42
+
43
+ export const VERBOSITY_LEVELS: readonly Verbosity[] = ['concise', 'standard', 'detailed'];
44
+
45
+ /**
46
+ * The lookup rule: one file per level, named `<asset_id>.<verbosity>.track.json`
47
+ * beside the asset. A level switch resolves by this name and falls back to
48
+ * `standard` (see `loadTrack`).
49
+ */
50
+ export function trackFileName(assetId: string, verbosity: Verbosity): string {
51
+ return `${assetId}.${verbosity}.track.json`;
52
+ }