Audio and text
mattebox@0.14.0
This chapter covers track selection, alternate audio, subtitles, captions, and timed metadata.
engine.tracks
Every selectable stream is a track. Audio and video always have one selected. Text and metadata can be deselected.
const t = engine.tracks;
t.available; // every track the manifest declared
t.selectable('a:ac3'); // false when nothing here can play it
t.active('audio'); // the selected audio track, or null
t.select('audio:de'); // switch by track id
t.deselect('text'); // stop the subtitle pipeline and clear its cues
A track is listed but not selectable until a stage handles its content
type. Text tracks need a text stage. A track the browser cannot decode,
such as AC-3 audio in a browser without AC-3, is listed but not selectable
either. A menu lists the tracks selectable answers true for.
You label a track in a menu from these fields.
| Field | Meaning | HLS source | DASH source |
|---|---|---|---|
lang |
Language tag | LANGUAGE |
@lang |
role |
main or alternate in HLS; the first role in DASH |
DEFAULT |
first Role |
roles |
Every role, such as main, dub, commentary |
none | every Role |
characteristics |
Media characteristic tags, such as audio description | CHARACTERISTICS |
Accessibility AudioPurposeCS 1 and 2, as the HLS tags |
forced |
true on a forced-subtitle track |
FORCED=YES |
Role forced-subtitle |
The engine copies HLS CHARACTERISTICS as written, such as
public.accessibility.describes-video (audio description) or
public.original-content (original-language audio).
These functions answer from the standard tags and roles. A tag outside them
stays a string in characteristics, and you filter on it yourself.
| Function | True for |
|---|---|
isAudioDescription |
public.accessibility.describes-video |
isEnhancedSpeech |
public.accessibility.enhances-speech-intelligibility |
isSdh |
transcribes-spoken-dialog with describes-music-and-sound, or the DASH role caption |
isOriginal |
public.original-content |
import { isAudioDescription } from 'mattebox';
const described = engine.tracks.available.find(isAudioDescription);
A switch through select takes effect from the segment under the playhead:
the previous track is flushed from there and the new one refills it. A group
switch that follows a video rendition change lets the previous group play out
to a segment boundary ahead instead, so the switch never rebuffers.
The alt-audio stage
The kernel already plays a separate audio track. The alt-audio stage adds
the switching: when a video rendition change needs a different audio group,
the audio track follows and keeps the viewer’s language. It requires
codec-switch.
import altAudio from 'mattebox/stages/alt-audio';
import codecSwitch from 'mattebox/stages/codec-switch';
const engine = mattebox({ stages: [hlsCmaf(), codecSwitch(), altAudio()] });
A language chosen through engine.tracks.select is remembered and re-applied
after every group switch. A track offered in the new group too stays
selected: the kernel plays its rendition in that group.
Track names
Each track carries what the manifest says about it:
lang: the language as written (HLSLANGUAGE, DASHlang).name: the display name (HLSNAME, DASHLabel).autoselect: HLSAUTOSELECT=YES, a primary rendition.
Each audio rendition carries its channels, for a label such as “English (5.1)”:
channels: the channel count (HLSCHANNELS, DASHAudioChannelConfiguration). 2 is stereo, 6 is 5.1.audioObjects: the object coding from HLSCHANNELS, such asJOCfor Dolby Atmos.
A track with several renditions plays the first one the browser decodes. You
find it by leaving out the ids in
engine.quality.constraints.get('codecs')?.excludeIds.
An HLS stream can also give localized names in a dictionary
(_hls.localized-rendition-names, HLS 2nd Edition Appendix E). The
rendition-names stage fetches it and answers a track’s name for a
locale. It is in full; add it to another preset with stages.
import renditionNames from 'mattebox/stages/rendition-names';
const engine = hls({ stages: [renditionNames()] });
const display = new Intl.DisplayNames([locale], { type: 'language' });
const label = (track) =>
engine.renditionNames.nameOf(track, locale, (language) => display.of(language)) ??
track.lang ??
track.id;
nameOf follows Appendix E: the dictionary’s translation of the track’s
NAME, then, for a primary rendition, the language’s name in the locale
from the function you pass, then the NAME. The engine calls no Intl
API. Intl.DisplayNames needs Chromium 81: on Tizen 6 and webOS 6, test
for it and pass nothing, and nameOf answers the NAME.
WebVTT subtitles
Text is a third pipeline next to audio and video, with its own fetching and
buffer goal. Cues go to a native TextTrack, so the browser renders them
and lists them in its caption menu.
| Stage | Handles |
|---|---|
text-webvtt |
WebVTT files, DASH WebVTT segments, and WebVTT in fMP4 (wvtt) |
text-webvtt-segmented |
HLS subtitle playlists, where each segment carries an X-TIMESTAMP-MAP offset |
Load both for HLS, only text-webvtt for DASH. A DASH subtitle
representation that is one WebVTT file at a BaseURL is read as one segment
covering the period.
import textWebvtt from 'mattebox/stages/text-webvtt';
import textWebvttSegmented from 'mattebox/stages/text-webvtt-segmented';
const engine = mattebox({ stages: [hlsCmaf(), textWebvtt(), textWebvttSegmented()] });
Select a subtitle track like any other.
const german = engine.tracks.available.find((t) => t.contentType === 'text' && t.lang === 'de');
if (german) engine.tracks.select(german.id);
Every text track in the manifest gets a TextTrack on the element as soon
as the manifest loads. Selection is synced both ways: selecting in the
engine sets that native track to showing and the others to disabled, and
picking in the browser’s caption menu selects in the engine. Turning
captions off there deselects. Listen for tracks:selected to update your
own menu.
TTML subtitles
text-ttml reads TTML in the IMSC1 Text Profile, which covers EBU-TT-D. It
is in full; add it to another preset with stages.
| Format | Where it comes from |
|---|---|
application/ttml+xml |
A DASH file at a BaseURL for the period, or one document per segment |
application/mp4 with stpp |
TTML in fMP4 segments: DASH codecs="stpp.ttml.im1t", or HLS with stpp in CODECS |
import dash from 'mattebox/presets/dash';
import textTtml from 'mattebox/stages/text-ttml';
const engine = dash({ stages: [textTtml()] });
A TTML track is a text track like a WebVTT one: select it with
engine.tracks, and the browser’s caption menu lists it.
- Timing follows TTML:
begin,end, anddurnest throughdiv,p, andspan, in clock, frame, and tick time. - Italics, bold, and underline show. The region sets the cue’s position, width, and line.
- Colors and fonts do not show: a
VTTCuecarries them only through page CSS. Each cue’spayloadproperty holds its resolved styles, for a player that draws captions itself. - The IMSC1 Image Profile is not supported.
Each subtitle format has a parser of its own, chosen by the track’s format. A track whose format no stage reads is listed but not selectable.
Forced subtitles
A forced subtitle track carries text the audio does not, such as translated
signs. The forced-subtitles stage shows it while no regular subtitle is
selected, Off included. It follows Apple’s rule:
- It shows the forced track whose language matches the audio, then one
whose primary language matches (
enforen-US), then the first forced track. - It switches the forced track when the audio language changes.
- A regular subtitle you select replaces it.
- It picks only a format a loaded stage plays.
Every preset includes it. To keep forced subtitles off, pass
enabled: false. To switch them at runtime, set
engine.forcedSubtitles.enabled.
import forcedSubtitles from 'mattebox/stages/forced-subtitles';
const engine = mattebox({
stages: [hlsCmaf(), textWebvtt(), textWebvttSegmented(), forcedSubtitles({ enabled: false })],
});
engine.forcedSubtitles.enabled = true;
While a forced track shows, engine.tracks.active('text') returns it, with
forced: true. A subtitle menu lists the tracks without forced and shows
Off for a forced track.
CEA-608 captions
CEA-608 captions are inside the video bitstream. text-cea608 decodes them
and requires a source stage that finds them: nal-scan or ts-transmux.
Either one is enough. Every preset includes nal-scan and text-cea608.
| Content | Source stage |
|---|---|
| Fragmented MP4, H.264 or HEVC | nal-scan |
| MPEG-TS, H.264 | ts-transmux |
Load both sources when a stream mixes TS and fMP4 renditions.
import nalScan from 'mattebox/stages/nal-scan';
import textCea608 from 'mattebox/stages/text-cea608';
const engine = mattebox({ stages: [hlsCmaf(), nalScan(), textCea608()] });
The captions are a text track like any subtitle, with role: 'caption'
and mimeType: 'application/cea-608'. Select it with engine.tracks, and
a menu lists it.
- The manifest declares it: HLS
EXT-X-MEDIA:TYPE=CLOSED-CAPTIONSwithINSTREAM-ID="CC1", or DASHAccessibilitywithurn:scte:dash:cc:cea-608:2015. - A channel the manifest does not declare gets the track
cea608:CC1tocea608:CC4when its first caption arrives, with atracks:changedevent. - All four channels are decoded: CC1 and CC2 on field 1, CC3 and CC4 on
field 2. CEA-708 services need
text-cea708(next section).
On the element, each channel’s cues go to a native caption track labelled
CC1 to CC4. It
shows while the caption track is selected, and a pick in the browser’s
caption menu selects it in the engine. If your packager can emit WebVTT
sidecars instead, skip these stages.
CEA-708 captions
CEA-708 captions ride in the same video data as CEA-608. text-cea708
decodes them, with the same source stages as text-cea608. It is in
full; add it to another preset with stages.
import hls from 'mattebox/presets/hls';
import textCea708 from 'mattebox/stages/text-cea708';
const engine = hls({ stages: [textCea708()] });
Each service is a text track with role: 'caption' and mimeType: 'application/cea-708'.
- The manifest declares it: HLS
INSTREAM-ID="SERVICE1"to"SERVICE63", or DASHAccessibilitywithurn:scte:dash:cc:cea-708:2015. - A service the manifest does not declare gets the track
cea708:SERVICEnwhen its first caption arrives. - A stream that carries both formats lists the 608 channels and the 708 services side by side. Your menu decides which to offer.
Each visible caption window becomes a cue on a native caption track
labelled SERVICE1 to SERVICE63. The cue sits where the window sits:
its anchor sets line and position, its width sets size, and its
justification sets align. Italics and underline show. Colors, edges, and
fonts do not: a VTTCue carries no color without page CSS. Each cue keeps
the window layout in a cea708 property, for a player that draws captions
itself.
Timed metadata
The timed-metadata stage collects timed metadata from every source into
one list. It is in full; add it to another preset with stages.
| Source | Where it comes from |
|---|---|
HLS EXT-X-DATERANGE |
The media playlists, merged by ID |
DASH EventStream |
The MPD, per period |
DASH and CMAF emsg |
The fMP4 segments, versions 0 and 1 |
| ID3 in MPEG-TS | The stream of type 0x15, with ts-transmux |
| ID3 metadata rendition | application/id3 segments, with meta-id3 |
import hls from 'mattebox/presets/hls';
import timedMetadata from 'mattebox/stages/timed-metadata';
const engine = hls({ stages: [timedMetadata()] });
engine.on('metadata:enter', ({ id }) => {
const event = engine.metadata.events.find((e) => e.id === id);
if (event?.scte35?.outOfNetwork) showAdBadge(event.end);
});
engine.on('metadata:exit', ({ id }) => hideAdBadge(id));
engine.metadata.events lists every record of the current source, sorted
by start. engine.metadata.at(time) returns the records that span time.
startandendare presentation seconds.endequalsstartfor an instant, and isnullwhile a span is open, such as a splice out with no in yet.attributesholds a date range’s attributes as written,X-ones included.dataholds the bytes: the SCTE-35 section, the emsg message, the Event body, or the ID3 tag.framesholds the decoded ID3 frames.scte35summarizes an SCTE-35 section: the command type, the event id, the out-of-network flag, the break duration, and each segmentation’s type and duration.
The stage emits three events, each with the record’s id:
| Event | When |
|---|---|
metadata:added |
A record appears, or a later sighting changes it |
metadata:enter |
The playhead reaches its start |
metadata:exit |
The playhead passes its end |
Playback across an instant emits enter and then exit. A seek emits
exit for the spans it leaves and enter for the spans it lands in, and
skips the instants in between.
The stage also writes every record as a cue on a native metadata text
track labelled metadata, with the record as JSON in the cue text. Use it
when your page already reads textTracks.
The stage does not play HLS interstitials. Their date ranges are records
like any other, with X-ASSET-URI in attributes.
Example
An HLS player with a language menu and a subtitle menu.
import { mattebox } from 'mattebox';
import hlsCmaf from 'mattebox/protocols/hls-cmaf';
import altAudio from 'mattebox/stages/alt-audio';
import codecSwitch from 'mattebox/stages/codec-switch';
import textWebvtt from 'mattebox/stages/text-webvtt';
import textWebvttSegmented from 'mattebox/stages/text-webvtt-segmented';
const engine = mattebox({
stages: [hlsCmaf(), codecSwitch(), altAudio(), textWebvtt(), textWebvttSegmented()],
});
function fill(select, contentType) {
select.replaceChildren();
for (const track of engine.tracks.available) {
if (track.contentType !== contentType) continue;
const selected = engine.tracks.active(contentType)?.id === track.id;
select.add(new Option(track.lang ?? track.id, track.id, false, selected));
}
}
engine.on('tracks:changed', () => {
fill(audioMenu, 'audio');
fill(subtitleMenu, 'text');
});
audioMenu.onchange = () => engine.tracks.select(audioMenu.value);
subtitleMenu.onchange = () => engine.tracks.select(subtitleMenu.value);
Next: 07 DRM.