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.
- package/CHANGELOG.md +52 -0
- package/LICENSE +21 -0
- package/PLATFORM.md +262 -0
- package/README.md +324 -0
- package/lib/ADControls.d.ts +24 -0
- package/lib/ADControls.js +41 -0
- package/lib/ADControls.js.map +1 -0
- package/lib/CueScheduler.d.ts +49 -0
- package/lib/CueScheduler.js +114 -0
- package/lib/CueScheduler.js.map +1 -0
- package/lib/DescriptionAudio.d.ts +36 -0
- package/lib/DescriptionAudio.js +88 -0
- package/lib/DescriptionAudio.js.map +1 -0
- package/lib/MediaAdapter.d.ts +123 -0
- package/lib/MediaAdapter.js +3 -0
- package/lib/MediaAdapter.js.map +1 -0
- package/lib/TrackLoader.d.ts +48 -0
- package/lib/TrackLoader.js +129 -0
- package/lib/TrackLoader.js.map +1 -0
- package/lib/budget.d.ts +41 -0
- package/lib/budget.js +60 -0
- package/lib/budget.js.map +1 -0
- package/lib/duck.d.ts +21 -0
- package/lib/duck.js +40 -0
- package/lib/duck.js.map +1 -0
- package/lib/index.d.ts +10 -0
- package/lib/index.js +33 -0
- package/lib/index.js.map +1 -0
- package/lib/log.d.ts +17 -0
- package/lib/log.js +18 -0
- package/lib/log.js.map +1 -0
- package/lib/messages.d.ts +21 -0
- package/lib/messages.js +17 -0
- package/lib/messages.js.map +1 -0
- package/lib/track.d.ts +44 -0
- package/lib/track.js +22 -0
- package/lib/track.js.map +1 -0
- package/lib/vega/SegmentBuffer.d.ts +87 -0
- package/lib/vega/SegmentBuffer.js +133 -0
- package/lib/vega/SegmentBuffer.js.map +1 -0
- package/lib/vega/index.d.ts +33 -0
- package/lib/vega/index.js +280 -0
- package/lib/vega/index.js.map +1 -0
- package/package.json +89 -0
- package/src/ADControls.tsx +92 -0
- package/src/CueScheduler.ts +145 -0
- package/src/DescriptionAudio.ts +97 -0
- package/src/MediaAdapter.ts +136 -0
- package/src/TrackLoader.ts +156 -0
- package/src/budget.ts +61 -0
- package/src/duck.ts +42 -0
- package/src/index.ts +42 -0
- package/src/log.ts +30 -0
- package/src/messages.ts +27 -0
- package/src/track.ts +52 -0
- package/src/vega/SegmentBuffer.ts +195 -0
- 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
|
+
}
|
package/src/messages.ts
ADDED
|
@@ -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
|
+
}
|