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
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this package are recorded here. The format follows
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions follow
|
|
5
|
+
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
|
+
|
|
7
|
+
While the version is `0.x`, a **minor** release may change the public API — the
|
|
8
|
+
changelog says so under **Changed** whenever it does. A **patch** release never
|
|
9
|
+
does.
|
|
10
|
+
|
|
11
|
+
A version cannot be published without its section here: `prepublishOnly` checks.
|
|
12
|
+
|
|
13
|
+
## [Unreleased]
|
|
14
|
+
|
|
15
|
+
## [0.1.0] - 2026-09-27
|
|
16
|
+
|
|
17
|
+
First release: the TV-side audio description layer extracted from
|
|
18
|
+
[Interstice](https://github.com/CesarRivasP/Interstice), with a Vega OS adapter.
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
|
|
22
|
+
- `MediaAdapter` — the one interface the layer uses to reach a platform: a
|
|
23
|
+
video player, a clip player, the app lifecycle and the video surface.
|
|
24
|
+
- `CueScheduler` — fires each cue once, only inside its dialogue gap, and only
|
|
25
|
+
if the gap has room left for it to finish; skips a late cue with a
|
|
26
|
+
`reason=late` log line rather than speaking over the next scene. `resync()`
|
|
27
|
+
after a settled seek, `coalesce()` for a held D-pad direction.
|
|
28
|
+
- `DescriptionAudio` — ducks the film to 25% over a 200 ms fade, plays the cue
|
|
29
|
+
as a second stream, restores to 100% — including when the clip fails, the
|
|
30
|
+
app is backgrounded, or description is switched off mid-cue.
|
|
31
|
+
- `rampVolumePct` — the fade the platform does not have, timed by elapsed time
|
|
32
|
+
so late timers cannot stretch it.
|
|
33
|
+
- `loadTrack` / `validateTrack` — one track file per verbosity level, validated
|
|
34
|
+
field by field, falling back to `standard` when a level is missing and
|
|
35
|
+
refusing to fall back when one is malformed. `ClipCache`, a bounded cache.
|
|
36
|
+
- `ADControls` — the remote surface: an on/off switch and three verbosity
|
|
37
|
+
levels, every state announced to the screen reader, once. `stateMessage()`
|
|
38
|
+
for the same sentences outside React Native.
|
|
39
|
+
- The word budget for track producers: `wordCeiling`, `wordTarget`,
|
|
40
|
+
`baseWordBudget`, `VERBOSITY_SCALES`.
|
|
41
|
+
- `setLogger()` — every diagnostic line handed to the app, silent by default.
|
|
42
|
+
- `react-native-tv-audio-description/vega` — `createVegaAdapter()` for Vega OS,
|
|
43
|
+
and `SegmentBuffer`, a buffer window of whole segments measured in time.
|
|
44
|
+
Handles, among the rest of [PLATFORM.md](PLATFORM.md): `srcObject` instead
|
|
45
|
+
of a URL, the surface arriving before the player is ready, `sourceopen`
|
|
46
|
+
firing again after the end of stream, a paused clip that never ends.
|
|
47
|
+
- `npm run example` — a minute of film at 20x in a terminal, self-checking.
|
|
48
|
+
- `example/vega` — a Vega app on a Tears of Steel excerpt, run on the Vega
|
|
49
|
+
Virtual Device.
|
|
50
|
+
|
|
51
|
+
[Unreleased]: https://github.com/CesarRivasP/react-native-tv-audio-description/compare/v0.1.0...HEAD
|
|
52
|
+
[0.1.0]: https://github.com/CesarRivasP/react-native-tv-audio-description/releases/tag/v0.1.0
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 César Rivas
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/PLATFORM.md
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
# Platform findings — Vega OS media
|
|
2
|
+
|
|
3
|
+
What this library works around, and how each thing was found. Every entry was
|
|
4
|
+
**measured**, most of them on the way to shipping
|
|
5
|
+
[Interstice](#where-these-come-from) on the Vega Virtual Device; none of it was
|
|
6
|
+
in the platform documentation at the time.
|
|
7
|
+
|
|
8
|
+
**Stack measured against:** Vega SDK 0.24 (0.24.12112), Vega CLI 1.3.4,
|
|
9
|
+
`@amazon-devices/react-native-w3cmedia` 2.3.2, React Native 0.83, the **Vega
|
|
10
|
+
Virtual Device**. Nothing here has been re-measured on a physical Fire TV
|
|
11
|
+
yet; the entries that can only be settled there say so.
|
|
12
|
+
|
|
13
|
+
Each entry: what happens, how it was measured, what it costs you if you do not
|
|
14
|
+
know, and where this library handles it.
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## The six that shaped the design
|
|
19
|
+
|
|
20
|
+
### url_mode_broken
|
|
21
|
+
|
|
22
|
+
**Assigning a URL to `player.src` fails before a single byte is requested.**
|
|
23
|
+
|
|
24
|
+
`MEDIA_ERR_SRC_NOT_SUPPORTED` (code 4), with an empty native message and no
|
|
25
|
+
media log line, for a remote URL, for a packaged `file://` path, and for
|
|
26
|
+
`AudioPlayer` as well as `VideoPlayer`. The HTTP server serving the file logs
|
|
27
|
+
no request at all. `canPlayType()` answers `"probably"` for the same type it
|
|
28
|
+
then refuses, so format negotiation is not where it stops. Same device, same
|
|
29
|
+
session, same footage: `.src` fails, `MediaSource` plays.
|
|
30
|
+
|
|
31
|
+
- **Measured:** 2026-09-25. Reproduced independently by another developer on
|
|
32
|
+
the same stack, in the
|
|
33
|
+
[Amazon developer forum](https://community.amazondeveloper.com/t/audioplayer-url-mode-fails-with-media-err-src-not-supported-on-vvd-app-never-connects-to-com-amazon-media-server-both-official-media-samples-also-fail/29117).
|
|
34
|
+
- **Cost of not knowing:** about four days and six rounds of work, most of it
|
|
35
|
+
spent on hypotheses about codecs, containers and paths that the error message
|
|
36
|
+
invites and that are all wrong.
|
|
37
|
+
- **Here:** every byte is fetched in JavaScript and appended to a
|
|
38
|
+
`MediaSource` attached through `srcObject`, for the film and for each
|
|
39
|
+
description clip (`src/vega/index.tsx`, `src/vega/SegmentBuffer.ts`). The
|
|
40
|
+
`MediaAdapter` interface says no implementation may assume the platform
|
|
41
|
+
fetches anything. A test fails if the adapter ever assigns `src`.
|
|
42
|
+
|
|
43
|
+
### no_range_requests
|
|
44
|
+
|
|
45
|
+
**A `Range` request returns `200` and the whole file.**
|
|
46
|
+
|
|
47
|
+
A `fetch` of a packaged file with `Range: bytes=0-65535` came back with status
|
|
48
|
+
`200`, **2,628,566 bytes** — the entire file — and no `Content-Range` header.
|
|
49
|
+
|
|
50
|
+
- **Measured:** 2026-09-26, on the Virtual Device, against a packaged
|
|
51
|
+
`file:///pkg/...` path.
|
|
52
|
+
- **Cost of not knowing:** a buffer window built on byte ranges *appears to
|
|
53
|
+
work* — playback is fine — while every "chunk" is the whole film. On a
|
|
54
|
+
32-bit Fire TV Stick that is the memory bound failing silently, in the one
|
|
55
|
+
place nobody is looking.
|
|
56
|
+
- **Here:** the asset is cut into whole segments at build time, and
|
|
57
|
+
`SegmentBuffer` keeps a window of whole segments measured in **time, not in
|
|
58
|
+
count**: ffmpeg cuts on keyframes, so segments asked for at 6 s came out
|
|
59
|
+
9.94 s, 4.17 s, 5.29 s and 0.65 s, and "keep five segments" would mean
|
|
60
|
+
anything between 3 and 50 seconds of media.
|
|
61
|
+
|
|
62
|
+
### surface_races_init
|
|
63
|
+
|
|
64
|
+
**`initialize()` and the surface handle arrive in no guaranteed order.**
|
|
65
|
+
|
|
66
|
+
The video surface was handed over **26 ms before** `VideoPlayer.initialize()`
|
|
67
|
+
resolved. Calling `play()` from the surface callback alone plays a player with
|
|
68
|
+
no source yet and yields `MEDIA_ERR_SRC_NOT_SUPPORTED` — which reads exactly
|
|
69
|
+
like an unsupported file, and is not one.
|
|
70
|
+
|
|
71
|
+
- **Measured:** 2026-09-22.
|
|
72
|
+
- **Here:** the adapter tracks both signals; whichever lands second starts
|
|
73
|
+
playback. The surface is part of the adapter (`MediaAdapter.VideoSurface`)
|
|
74
|
+
precisely so a screen cannot wire it up on its own. Tests cover both orders.
|
|
75
|
+
|
|
76
|
+
### waiting_fires_at_start
|
|
77
|
+
|
|
78
|
+
**MSE emits `waiting` during normal startup.**
|
|
79
|
+
|
|
80
|
+
2 ms after `play()` resolved, on a clip that then played to the end.
|
|
81
|
+
|
|
82
|
+
- **Measured:** 2026-09-25. No fake emits a spurious `waiting`, so no unit test
|
|
83
|
+
could have found this; it took running on the device.
|
|
84
|
+
- **Cost of not knowing:** a screen that treats a stall as terminal announces
|
|
85
|
+
"Buffering" over a film that is playing perfectly — to a viewer who cannot
|
|
86
|
+
see that it is.
|
|
87
|
+
- **Here:** `onStalled` is a state playback can *leave*: the interface exposes
|
|
88
|
+
`onPlaying` as its exit and says so.
|
|
89
|
+
|
|
90
|
+
### no_volume_ramp
|
|
91
|
+
|
|
92
|
+
**The volume setter is instantaneous. There is no fade anywhere in the API.**
|
|
93
|
+
|
|
94
|
+
- **Measured:** 2026-09-22, from the package's own type declarations: no
|
|
95
|
+
ramp parameter exists anywhere in the media surface.
|
|
96
|
+
- **Cost of not knowing:** a duck that jumps from 100% to 25% in a single
|
|
97
|
+
step on every cue, because the platform will not fade it for you.
|
|
98
|
+
- **Here:** the fade is stepped in JavaScript, once, in `src/duck.ts`, and is
|
|
99
|
+
cancellable so it cannot outlive the screen that started it.
|
|
100
|
+
`VideoPlayer.setVolumePct` deliberately takes no `rampMs`: accepting one
|
|
101
|
+
would invite every platform implementation to rewrite the same loop, and
|
|
102
|
+
imply a capability no platform here has.
|
|
103
|
+
|
|
104
|
+
### no_js_console
|
|
105
|
+
|
|
106
|
+
**There is no readable JavaScript console on the device.**
|
|
107
|
+
|
|
108
|
+
`console.log` reaches `vega device start-log-stream` in neither Release nor
|
|
109
|
+
Debug builds, and since React Native 0.73 Metro prints "JavaScript logs have
|
|
110
|
+
moved" and routes them to React Native DevTools, which needs a browser. There
|
|
111
|
+
is also no way to take a screenshot.
|
|
112
|
+
|
|
113
|
+
- **Measured:** 2026-09-22.
|
|
114
|
+
- **What works:** an HTTP beacon to the development host, over the reverse
|
|
115
|
+
port forwarding the device already uses for Metro
|
|
116
|
+
(`vega device start-port-forwarding --port 8099 --forward false`), with a
|
|
117
|
+
tiny server on the host printing each request.
|
|
118
|
+
- **Here:** the library writes nothing by default; `setLogger()` hands you
|
|
119
|
+
every diagnostic line, and where they go is your app's decision.
|
|
120
|
+
|
|
121
|
+
---
|
|
122
|
+
|
|
123
|
+
## Found by running this package on the Virtual Device
|
|
124
|
+
|
|
125
|
+
Measured 2026-09-27 with `example/vega`, on the stack above. None of these
|
|
126
|
+
shows up in a unit test against a mock — each needed the device.
|
|
127
|
+
|
|
128
|
+
### sourceopen_refires
|
|
129
|
+
|
|
130
|
+
**`sourceopen` fires again after `endOfStream()`, whenever the buffer changes.**
|
|
131
|
+
|
|
132
|
+
This one is the MSE specification, not a Vega defect: `appendBuffer()` or
|
|
133
|
+
`remove()` on a source whose `readyState` is `ended` moves it back to `open`
|
|
134
|
+
and fires `sourceopen` again. Evicting old media behind the playhead once the
|
|
135
|
+
last segment is in does exactly that. A `sourceopen` handler that builds the
|
|
136
|
+
`SourceBuffer` therefore runs twice. On the device that built a second buffer,
|
|
137
|
+
re-appended the whole asset, evicted again, reopened again — a loop at the end
|
|
138
|
+
of every film, with `endOfStream()` throwing *"exception when updating
|
|
139
|
+
attribute is true"* as the two chains collided.
|
|
140
|
+
|
|
141
|
+
- **Here:** the adapter handles `sourceopen` once per `open()`, and
|
|
142
|
+
`endOfStream()` is only called when no buffer operation is in flight.
|
|
143
|
+
|
|
144
|
+
### coarse_timers
|
|
145
|
+
|
|
146
|
+
**`setTimeout(fn, 16)` fires late.** A fade written as 13 steps of 16 ms took
|
|
147
|
+
about **510 ms** instead of 200. With the fade down, the clip start and the
|
|
148
|
+
fade back up all stretched, three of four cues brought the film back up after
|
|
149
|
+
their window had closed — by up to 0.57 s.
|
|
150
|
+
|
|
151
|
+
- **Here:** `rampVolumePct` sets each step's level from the time *elapsed*,
|
|
152
|
+
not from its step number, so the fade ends on time however late the timer
|
|
153
|
+
fires. Measured after the change: fades of 200–260 ms.
|
|
154
|
+
|
|
155
|
+
### paused_never_ends
|
|
156
|
+
|
|
157
|
+
**A paused `AudioPlayer` never emits `ended`.** Stopping a cue by pausing its
|
|
158
|
+
clip player left the promise waiting for `ended` pending forever — so the
|
|
159
|
+
player was never torn down: one leaked player per interrupted cue.
|
|
160
|
+
|
|
161
|
+
- **Here:** `clips.stop()` settles the clip's promise itself, and teardown
|
|
162
|
+
(`deinitialize()`, ~120 ms on the device) runs without holding up the
|
|
163
|
+
restore of the film.
|
|
164
|
+
|
|
165
|
+
### What a cue costs, end to end
|
|
166
|
+
|
|
167
|
+
From the scheduler firing a cue to the film being back at full, on the
|
|
168
|
+
Virtual Device with the changes above: **fade down + clip start 250–290 ms**,
|
|
169
|
+
then the speech, then **fade up 200–260 ms**. `CueScheduler` will not fire a
|
|
170
|
+
cue that its window cannot hold — reached late, say, because description was
|
|
171
|
+
switched back on mid-gap — and skips it with a `reason=late` log line instead.
|
|
172
|
+
|
|
173
|
+
---
|
|
174
|
+
|
|
175
|
+
## Also measured, and worth knowing
|
|
176
|
+
|
|
177
|
+
### concurrent_streams — a second stream over the film works
|
|
178
|
+
|
|
179
|
+
A `VideoPlayer` fed by one `MediaSource` and an `AudioPlayer` built with
|
|
180
|
+
`(CONTENT_TYPE_SPEECH, USAGE_ACCESSIBILITY)` fed by another played
|
|
181
|
+
**simultaneously**: the cue ran its full 4.50 s while the video reported
|
|
182
|
+
`paused=false` and zero dropped frames. Ducking the film to `volume = 0.25`
|
|
183
|
+
disturbed neither. This is what makes the library possible.
|
|
184
|
+
**Not measured:** that a listener *hears* it — there is no audio capture path
|
|
185
|
+
off the Virtual Device. What is measured is that both pipelines ran and the
|
|
186
|
+
volume was applied. Audible ducking needs a physical Fire TV.
|
|
187
|
+
|
|
188
|
+
### mse_path — fragmented MP4, full codec string
|
|
189
|
+
|
|
190
|
+
The media must be **fragmented MP4**
|
|
191
|
+
(`ffmpeg -movflags +frag_keyframe+empty_moov+default_base_moof`), and
|
|
192
|
+
`addSourceBuffer` rejects a bare `video/mp4`: it needs the full parameters,
|
|
193
|
+
e.g. `video/mp4; codecs="avc1.42C01E,mp4a.40.2"`. Pass yours as
|
|
194
|
+
`createVegaAdapter({ videoMime })`. An MP3 cannot be appended to a
|
|
195
|
+
`SourceBuffer` at all, so description clips need repackaging as fragmented
|
|
196
|
+
AAC.
|
|
197
|
+
|
|
198
|
+
### media_process_is_separate — what JS can reach, the player may not
|
|
199
|
+
|
|
200
|
+
JavaScript fetched `http://localhost:8100/clip.mp4` over the reverse port
|
|
201
|
+
forward and got `200`; the player rejected the same URL and the server logged
|
|
202
|
+
no request from it. The media pipeline runs in its own process
|
|
203
|
+
(`com.amazon.media.server`). Host-localhost forwarding works for the app and
|
|
204
|
+
not for playback, and the two look identical from JavaScript. (With
|
|
205
|
+
`url_mode_broken` this is moot for this library — JS does all the fetching —
|
|
206
|
+
but it will bite anything that hands the player a URL.)
|
|
207
|
+
|
|
208
|
+
### manifest services — missing ones fail silently
|
|
209
|
+
|
|
210
|
+
Media services must be declared under `[wants]` in `manifest.toml`. Missing,
|
|
211
|
+
the result is no video **and no error**.
|
|
212
|
+
|
|
213
|
+
### video_width_unreported — `videoWidth` stays 0
|
|
214
|
+
|
|
215
|
+
`videoWidth` and `videoHeight` stay 0 through `loadedmetadata`, `playing` and
|
|
216
|
+
beyond, and the platform's `resize` event carries `w=0 h=0` — while 253 frames
|
|
217
|
+
decode with 0 dropped. Do not gate "is video playing" on the width; use
|
|
218
|
+
`getVideoPlaybackQuality().totalVideoFrames`.
|
|
219
|
+
|
|
220
|
+
### voiceview_not_enablable — screen reader testing needs hardware
|
|
221
|
+
|
|
222
|
+
VoiceView cannot be turned on on the Virtual Device by any developer route:
|
|
223
|
+
the config key is readable but `vdcm set` returns "No permission for
|
|
224
|
+
operation" from both `vega device run-cmd` and `vega device shell`. Another
|
|
225
|
+
developer on the same platform reports the other two documented routes failing
|
|
226
|
+
too: the Virtual Device's Settings app has no Accessibility section, and the
|
|
227
|
+
Back+Menu chord does not fire. So `ADControls`' accessibility contract is unit-tested by
|
|
228
|
+
what it **announces**, and has not been exercised under VoiceView itself.
|
|
229
|
+
|
|
230
|
+
### run_cmd_is_sandboxed — "does not exist" means nothing
|
|
231
|
+
|
|
232
|
+
`vega device run-cmd` runs as an app user inside an app context: `ps` lists two
|
|
233
|
+
processes and `/dev/input` "does not exist" while input injection through the
|
|
234
|
+
same shell works. **Any probe run from there that reports a resource absent has
|
|
235
|
+
reported nothing.** Key injection that does work:
|
|
236
|
+
`vega device run-cmd -c 'inputd-cli button_press KEY_ENTER'`.
|
|
237
|
+
|
|
238
|
+
### build_packages_assets_dir — `assets/` ships, all of it
|
|
239
|
+
|
|
240
|
+
`react-native build-vega` copies the entire project-root `assets/` directory
|
|
241
|
+
into the package whether or not anything references it. A 372 MB source video
|
|
242
|
+
left there made a 497 MB package, with nothing in the build output to say so.
|
|
243
|
+
Keep host-side media somewhere else.
|
|
244
|
+
|
|
245
|
+
### Kepler types and `noUncheckedIndexedAccess`
|
|
246
|
+
|
|
247
|
+
`@amazon-devices/react-native-kepler` ships `.ts` sources behind its type
|
|
248
|
+
entry point, so `skipLibCheck` does not cover them, and
|
|
249
|
+
`KeplerBackHandler.ts` fails with TS2722 under `noUncheckedIndexedAccess`. A
|
|
250
|
+
project that imports the w3cmedia types with that flag on cannot type-check.
|
|
251
|
+
This library leaves the flag off for that reason.
|
|
252
|
+
|
|
253
|
+
---
|
|
254
|
+
|
|
255
|
+
## Where these come from
|
|
256
|
+
|
|
257
|
+
This library was extracted from **Interstice**, an AI audio description app
|
|
258
|
+
for Fire TV built for the Amazon Developer Hackathon 2026. Its specification
|
|
259
|
+
keeps every one of these as a measured entry with date and evidence, and the
|
|
260
|
+
dates above are those measurements. If you re-measure any of them on another
|
|
261
|
+
SDK version or on hardware and get a different answer, an issue saying so is
|
|
262
|
+
the most useful contribution this file can get.
|
package/README.md
ADDED
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
# react-native-tv-audio-description
|
|
2
|
+
|
|
3
|
+
Speak audio description in the gaps between dialogue, **on the TV itself**:
|
|
4
|
+
lower the film, say what is on screen, bring the film back — before anyone
|
|
5
|
+
speaks again. Driven from the remote, with three verbosity levels, and every
|
|
6
|
+
state announced to the screen reader.
|
|
7
|
+
|
|
8
|
+
For blind and low-vision viewers most films have no audio description track
|
|
9
|
+
at all. Tracks can now be generated — subtitles give the dialogue gaps, a
|
|
10
|
+
vision model describes the frames, text-to-speech voices it. What was missing
|
|
11
|
+
is the part that plays such a track **on a television**, where the media stack
|
|
12
|
+
is not a browser's and a remote is the only input. That is this library.
|
|
13
|
+
|
|
14
|
+
It was extracted from **Interstice**, an entry to the Amazon Developer
|
|
15
|
+
Hackathon 2026 (Fire TV, Vega OS), and ships a **Vega OS adapter** along with
|
|
16
|
+
the [platform findings](PLATFORM.md) it took to make one work.
|
|
17
|
+
|
|
18
|
+
```sh
|
|
19
|
+
git clone https://github.com/CesarRivasP/react-native-tv-audio-description.git
|
|
20
|
+
cd react-native-tv-audio-description
|
|
21
|
+
npm install
|
|
22
|
+
npm run example
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
`npm run example` plays a minute of film at 20x in your terminal — no device,
|
|
26
|
+
no SDK — through the library's real scheduler, audio layer and track loader,
|
|
27
|
+
and **checks what it shows**: it exits non-zero if a cue fires outside its
|
|
28
|
+
gap, is still speaking when dialogue resumes, or leaves the film ducked.
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
## What it does
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
dialogue ██████████░░░░░░░░░░░░░░░░████████████░░░░░░░░░░░██████
|
|
36
|
+
gaps [ cue c1 ) [ cue c2 )
|
|
37
|
+
film volume 100% ─╮ ╭─ 100% ───╮ ╭─ 100%
|
|
38
|
+
╰──── 25% ───────╯ ╰── 25% ────╯
|
|
39
|
+
description "A keeper climbs the stairs…" "Waves. An empty boat…"
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
- **Fires a cue only inside its own gap, and only if it can finish there.** A
|
|
43
|
+
cue whose gap has passed, or whose gap has too little left — description
|
|
44
|
+
switched back on mid-gap — is skipped, never played late: a late cue talks
|
|
45
|
+
over dialogue, which is worse than no description.
|
|
46
|
+
- **Ducks, speaks, restores** — the film goes to 25% over a 200 ms fade, the
|
|
47
|
+
clip plays as a second stream over it, and the film always comes back to
|
|
48
|
+
100%, including when the clip fails and when the app is backgrounded.
|
|
49
|
+
- **Seeks cleanly.** A held D-pad direction is collapsed into one settled
|
|
50
|
+
seek, and the scheduler resyncs once, so jumping through a film does not
|
|
51
|
+
fire a burst of descriptions for scenes the viewer skipped.
|
|
52
|
+
- **Three verbosity levels**, one track file each, falling back to `standard`
|
|
53
|
+
when a level was never generated.
|
|
54
|
+
- **Refuses to be silent.** A missing or malformed track produces a spoken
|
|
55
|
+
sentence — "Playback continues without description" — not a quiet screen
|
|
56
|
+
that a blind viewer cannot tell from a quiet scene.
|
|
57
|
+
|
|
58
|
+
## Install
|
|
59
|
+
|
|
60
|
+
Not on npm yet — `0.1.0` is on its way. Until then, from GitHub:
|
|
61
|
+
|
|
62
|
+
```sh
|
|
63
|
+
npm install github:CesarRivasP/react-native-tv-audio-description
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Peer dependencies: `react` ^19.2 and `react-native` ^0.83. For the Vega
|
|
67
|
+
adapter, also `@amazon-devices/react-native-w3cmedia ~2.3.2` and
|
|
68
|
+
`@amazon-devices/react-native-kepler ~4.0.0` — pin those with `~`, not `^`:
|
|
69
|
+
they are system-distributed libraries, and the Kepler linter fails the build
|
|
70
|
+
when they float.
|
|
71
|
+
|
|
72
|
+
## Usage
|
|
73
|
+
|
|
74
|
+
On Vega OS, a player screen:
|
|
75
|
+
|
|
76
|
+
```tsx
|
|
77
|
+
import React, { useEffect, useMemo, useState } from 'react';
|
|
78
|
+
import { View } from 'react-native';
|
|
79
|
+
import {
|
|
80
|
+
ADControls,
|
|
81
|
+
CueScheduler,
|
|
82
|
+
DescriptionAudio,
|
|
83
|
+
loadTrack,
|
|
84
|
+
type ADState,
|
|
85
|
+
type AssetSource,
|
|
86
|
+
type Verbosity,
|
|
87
|
+
} from 'react-native-tv-audio-description';
|
|
88
|
+
import { createVegaAdapter } from 'react-native-tv-audio-description/vega';
|
|
89
|
+
|
|
90
|
+
// Vega's player cannot be pointed at a path, but fetch can read a packaged file.
|
|
91
|
+
const fetchJson = async (path: string): Promise<unknown> => (await fetch(path)).json();
|
|
92
|
+
|
|
93
|
+
interface PlayerProps {
|
|
94
|
+
asset: AssetSource; // the film, cut into fragmented-MP4 segments
|
|
95
|
+
trackDir: string; // where <assetId>.<verbosity>.track.json live
|
|
96
|
+
assetId: string;
|
|
97
|
+
readJson?: (path: string) => Promise<unknown>; // how to read a track file
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function Player({ asset, trackDir, assetId, readJson = fetchJson }: PlayerProps) {
|
|
101
|
+
// One adapter, one audio layer, one scheduler per screen.
|
|
102
|
+
const media = useMemo(() => createVegaAdapter(), []);
|
|
103
|
+
const audio = useMemo(() => new DescriptionAudio(media), [media]);
|
|
104
|
+
const scheduler = useMemo(
|
|
105
|
+
() => new CueScheduler({ onFire: (cue) => void audio.speak(cue) }),
|
|
106
|
+
[audio],
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
const [verbosity, setVerbosity] = useState<Verbosity>('standard');
|
|
110
|
+
const [enabled, setEnabled] = useState(true);
|
|
111
|
+
const [state, setState] = useState<ADState>({ kind: 'missing', detail: 'loading' });
|
|
112
|
+
|
|
113
|
+
// Position drives the scheduler; a settled seek resyncs it.
|
|
114
|
+
useEffect(() => {
|
|
115
|
+
const offs = [
|
|
116
|
+
media.video.onPosition((ms) => scheduler.tick(ms)),
|
|
117
|
+
media.video.onSeek((ms) => scheduler.resync(ms)),
|
|
118
|
+
];
|
|
119
|
+
void media.video.open(asset).then(() => media.video.play());
|
|
120
|
+
return () => {
|
|
121
|
+
offs.forEach((off) => off());
|
|
122
|
+
void audio.stop();
|
|
123
|
+
void media.video.destroy();
|
|
124
|
+
};
|
|
125
|
+
}, [media, audio, scheduler, asset]);
|
|
126
|
+
|
|
127
|
+
// A level switch loads that level's file, falling back to standard.
|
|
128
|
+
useEffect(() => {
|
|
129
|
+
void loadTrack(readJson, trackDir, assetId, verbosity).then((result) => {
|
|
130
|
+
if (!result.ok) {
|
|
131
|
+
scheduler.load([]);
|
|
132
|
+
setState({ kind: result.reason, detail: result.detail });
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
scheduler.load(result.track.cues);
|
|
136
|
+
scheduler.resync(media.video.positionMs());
|
|
137
|
+
setState({
|
|
138
|
+
kind: 'ready',
|
|
139
|
+
enabled,
|
|
140
|
+
verbosity: result.loaded_verbosity,
|
|
141
|
+
cues: result.track.cues.length,
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
// `enabled` is applied separately below; reloading on a toggle is not needed
|
|
145
|
+
}, [media, scheduler, trackDir, assetId, verbosity, readJson]);
|
|
146
|
+
|
|
147
|
+
const onToggle = (on: boolean) => {
|
|
148
|
+
setEnabled(on);
|
|
149
|
+
scheduler.setEnabled(on); // the film is never touched by a toggle
|
|
150
|
+
if (!on) void audio.stop(); // cuts a cue mid-sentence and restores the film
|
|
151
|
+
setState((s) => (s.kind === 'ready' ? { ...s, enabled: on } : s));
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
return (
|
|
155
|
+
<View style={{ flex: 1 }}>
|
|
156
|
+
<media.VideoSurface style={{ flex: 1 }} />
|
|
157
|
+
<ADControls state={state} onToggle={onToggle} onVerbosity={setVerbosity} />
|
|
158
|
+
</View>
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
This is [`example/vega/src/Player.tsx`](example/vega/src/Player.tsx), which
|
|
164
|
+
`npm run typecheck` compiles against the library — if the API changes, this
|
|
165
|
+
README's example stops compiling. A real screen will also want to say
|
|
166
|
+
something on `media.video.onStalled` (and stop saying it on `onPlaying` —
|
|
167
|
+
see [`waiting_fires_at_start`](PLATFORM.md#waiting_fires_at_start)).
|
|
168
|
+
|
|
169
|
+
## The track
|
|
170
|
+
|
|
171
|
+
One JSON file per verbosity level, named `<asset_id>.<verbosity>.track.json`:
|
|
172
|
+
|
|
173
|
+
```json
|
|
174
|
+
{
|
|
175
|
+
"version": "1",
|
|
176
|
+
"asset_id": "lighthouse",
|
|
177
|
+
"generated_at": "2026-09-27T00:00:00.000Z",
|
|
178
|
+
"source_subtitles": "lighthouse.en.srt",
|
|
179
|
+
"verbosity": "standard",
|
|
180
|
+
"model_id": "…",
|
|
181
|
+
"cues": [
|
|
182
|
+
{
|
|
183
|
+
"id": "c1",
|
|
184
|
+
"start_ms": 2000,
|
|
185
|
+
"end_ms": 6500,
|
|
186
|
+
"words": 10,
|
|
187
|
+
"text": "A lighthouse keeper climbs the spiral stairs, lantern in hand.",
|
|
188
|
+
"audio_uri": "audio/c1.standard.m4a",
|
|
189
|
+
"source_frames_ms": [4250],
|
|
190
|
+
"status": "ok"
|
|
191
|
+
}
|
|
192
|
+
]
|
|
193
|
+
}
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
- `start_ms`/`end_ms` is the dialogue gap the cue may speak in; `end_ms` is
|
|
197
|
+
exclusive.
|
|
198
|
+
- A cue that could not be produced is **written** with `"status": "failed"`
|
|
199
|
+
and empty `text` and `audio_uri`, not dropped. It is never scheduled, but the
|
|
200
|
+
track still says the gap was considered.
|
|
201
|
+
- The levels do **not** share a cue list: a gap that holds a useful sentence at
|
|
202
|
+
`detailed` may hold nothing useful at `concise`. In the film this was built
|
|
203
|
+
on, the three levels came out at 47, 55 and 60 cues.
|
|
204
|
+
- `validateTrack` checks every field; `loadTrack` treats a missing file and a
|
|
205
|
+
malformed one differently — missing falls back to `standard`, malformed does
|
|
206
|
+
not, because silently loading a different file would hide a broken producer.
|
|
207
|
+
|
|
208
|
+
Full examples in [`example/assets/`](example/assets/).
|
|
209
|
+
|
|
210
|
+
### Producing tracks: the word budget
|
|
211
|
+
|
|
212
|
+
A cue has to finish before dialogue resumes. At 160 words per minute, with
|
|
213
|
+
300 ms kept back for the fade:
|
|
214
|
+
|
|
215
|
+
```ts
|
|
216
|
+
import { wordCeiling, wordTarget } from 'react-native-tv-audio-description';
|
|
217
|
+
|
|
218
|
+
wordCeiling(4500); // 11 — never exceed this, at any level
|
|
219
|
+
wordTarget(4500, 'concise'); // 6 — what to ask a describer for
|
|
220
|
+
wordTarget(4500, 'detailed'); // 11
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
Levels are fractions of the ceiling (0.6 / 0.85 / 1.0), never multipliers, so
|
|
224
|
+
no level can ask for more words than the gap holds. `npm run example` checks
|
|
225
|
+
its own track against this before playing it.
|
|
226
|
+
|
|
227
|
+
## The adapter
|
|
228
|
+
|
|
229
|
+
Everything above is written against one interface,
|
|
230
|
+
[`MediaAdapter`](src/MediaAdapter.ts), and never against a platform package —
|
|
231
|
+
a test fails if that stops being true. Two rules in it matter if you write
|
|
232
|
+
your own:
|
|
233
|
+
|
|
234
|
+
- **Do not assume the platform fetches anything.** On Vega it will not, so
|
|
235
|
+
the Vega adapter reads every byte itself. The interface takes an
|
|
236
|
+
`AssetSource` — a list of segments, even for a single file — so a short clip
|
|
237
|
+
and a feature film run the same code path.
|
|
238
|
+
- **`setVolumePct` is immediate and takes no ramp.** The fade lives in the
|
|
239
|
+
library, once. An interface that took `rampMs` would make every platform
|
|
240
|
+
rewrite it.
|
|
241
|
+
|
|
242
|
+
### `createVegaAdapter(options?)`
|
|
243
|
+
|
|
244
|
+
| option | default | |
|
|
245
|
+
|---|---|---|
|
|
246
|
+
| `videoMime` | `video/mp4; codecs="avc1.42C01E,mp4a.40.2"` | **Set this to your asset's codecs.** A bare `video/mp4` is rejected. |
|
|
247
|
+
| `clipMime` | `audio/mp4; codecs="mp4a.40.2"` | Description clips, fragmented AAC. |
|
|
248
|
+
| `window` | `{ aheadMs: 30000, behindMs: 10000 }` | Media held around the playhead, in time. |
|
|
249
|
+
|
|
250
|
+
It handles, so you do not have to: `srcObject` instead of `src`, the surface
|
|
251
|
+
arriving before the player is ready, a buffer window of whole segments that
|
|
252
|
+
never holds the whole film, one `SourceBuffer` operation at a time, and clips
|
|
253
|
+
on an `AudioPlayer` built for accessibility speech. Each is explained in
|
|
254
|
+
[PLATFORM.md](PLATFORM.md).
|
|
255
|
+
|
|
256
|
+
Another platform: implement `MediaAdapter` (the simulated one in
|
|
257
|
+
[`example/node/simAdapter.ts`](example/node/simAdapter.ts) is a complete,
|
|
258
|
+
small example) and everything else works unchanged.
|
|
259
|
+
|
|
260
|
+
## Seeing what it does on a device
|
|
261
|
+
|
|
262
|
+
Nothing is logged by default. On Vega, `console.log` goes nowhere you can read
|
|
263
|
+
([`no_js_console`](PLATFORM.md#no_js_console)), so the library hands every
|
|
264
|
+
diagnostic line to you instead:
|
|
265
|
+
|
|
266
|
+
```ts
|
|
267
|
+
import { setLogger } from 'react-native-tv-audio-description';
|
|
268
|
+
|
|
269
|
+
setLogger((line) => {
|
|
270
|
+
// e.g. "scheduler.fire id=c1 pos_ms=2004 window=[2000,6500)"
|
|
271
|
+
void fetch(`http://localhost:8099/?m=${encodeURIComponent(line)}`).catch(() => {});
|
|
272
|
+
});
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
## Platform findings
|
|
276
|
+
|
|
277
|
+
What the Vega adapter works around, each one measured — the short version:
|
|
278
|
+
|
|
279
|
+
| | what happens | cost if you do not know |
|
|
280
|
+
|---|---|---|
|
|
281
|
+
| [`url_mode_broken`](PLATFORM.md#url_mode_broken) | `player.src = url` fails before a byte is requested | days, chasing codecs that are fine |
|
|
282
|
+
| [`no_range_requests`](PLATFORM.md#no_range_requests) | a `Range` request returns `200` and the whole file | a "bounded" buffer that holds the entire film |
|
|
283
|
+
| [`surface_races_init`](PLATFORM.md#surface_races_init) | the surface can arrive before `initialize()` resolves | an error that reads as "unsupported file" |
|
|
284
|
+
| [`waiting_fires_at_start`](PLATFORM.md#waiting_fires_at_start) | MSE emits `waiting` 2 ms into normal playback | "Buffering" announced over a film that plays |
|
|
285
|
+
| [`no_volume_ramp`](PLATFORM.md#no_volume_ramp) | volume changes are instantaneous, no fade exists | a duck that jumps instead of fading |
|
|
286
|
+
| [`no_js_console`](PLATFORM.md#no_js_console) | no readable JS console, in Release or Debug | an app you cannot observe |
|
|
287
|
+
|
|
288
|
+
And a dozen smaller ones — fragmented MP4 only, a separate media process,
|
|
289
|
+
silent manifest failures, `videoWidth` stuck at 0, VoiceView unavailable on the
|
|
290
|
+
Virtual Device — in [PLATFORM.md](PLATFORM.md).
|
|
291
|
+
|
|
292
|
+
## What is verified, and what is not
|
|
293
|
+
|
|
294
|
+
- **Unit tests** covering every component and the adapter against a
|
|
295
|
+
mock of the platform package. `npm test`.
|
|
296
|
+
- **The example** — end to end on a simulated adapter, self-checking.
|
|
297
|
+
- **On the Vega Virtual Device**, with [`example/vega`](example/vega): the
|
|
298
|
+
packaged library plays a segmented excerpt of Tears of Steel with four
|
|
299
|
+
cues, each firing inside its window, ducking to 25% and restoring before the
|
|
300
|
+
window closes; the remote switches description off mid-cue, on again, and
|
|
301
|
+
to `concise`. What those runs found — and fixed — is in
|
|
302
|
+
[PLATFORM.md § Found by running this package](PLATFORM.md#sourceopen_refires).
|
|
303
|
+
- **Not yet verified, and needing a physical Fire TV:** that the duck is
|
|
304
|
+
*audible* (the Virtual Device has no audio capture path), the controls under
|
|
305
|
+
VoiceView (it cannot be enabled on the Virtual Device), and the memory bound
|
|
306
|
+
over a feature-length film (eviction has run once, at the end of the 20 s
|
|
307
|
+
excerpt — not across a film long enough for the bound to matter).
|
|
308
|
+
|
|
309
|
+
## Versions
|
|
310
|
+
|
|
311
|
+
[Semantic Versioning](https://semver.org), recorded in
|
|
312
|
+
[CHANGELOG.md](CHANGELOG.md). While the version is `0.x`, a minor release may
|
|
313
|
+
change the API and says so under **Changed**; a patch release never does.
|
|
314
|
+
Every published version is a tagged commit (`v0.1.0` …): `npm publish` refuses
|
|
315
|
+
to run unless HEAD carries the tag, the tree is clean, the changelog has the
|
|
316
|
+
version's section, and the typecheck, tests and build pass.
|
|
317
|
+
|
|
318
|
+
To release: move the **Unreleased** notes under a new dated heading, then
|
|
319
|
+
`npm version <major|minor|patch>` (which checks the changelog, commits and
|
|
320
|
+
tags), `git push --follow-tags`, `npm publish`.
|
|
321
|
+
|
|
322
|
+
## License
|
|
323
|
+
|
|
324
|
+
MIT © César Rivas
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { View } from 'react-native';
|
|
3
|
+
import type { Verbosity } from './track';
|
|
4
|
+
import { type ADState } from './messages';
|
|
5
|
+
export { stateMessage, type ADState } from './messages';
|
|
6
|
+
/**
|
|
7
|
+
* The remote surface, and every failure said out loud.
|
|
8
|
+
*
|
|
9
|
+
* This is for blind and low-vision viewers. A control surface that shows an
|
|
10
|
+
* error and says nothing is the same as silence, so every assertion in its
|
|
11
|
+
* test suite is about what is ANNOUNCED, not about what is rendered.
|
|
12
|
+
*
|
|
13
|
+
* It is deliberately unstyled: bring your own look. What it guarantees is the
|
|
14
|
+
* accessibility contract — roles, labels, state, and one announcement per
|
|
15
|
+
* state change.
|
|
16
|
+
*/
|
|
17
|
+
export interface ADControlsProps {
|
|
18
|
+
state: ADState;
|
|
19
|
+
onToggle: (enabled: boolean) => void;
|
|
20
|
+
onVerbosity: (v: Verbosity) => void;
|
|
21
|
+
/** hand focus here when the player screen mounts */
|
|
22
|
+
focusRef?: React.Ref<View>;
|
|
23
|
+
}
|
|
24
|
+
export declare function ADControls({ state, onToggle, onVerbosity, focusRef }: ADControlsProps): React.JSX.Element;
|