tlvdemux
v0.1.8
Published
ARIB MMT/TLV demultiplexer for browsers, workers, and Node.js
Maintainers
Readme
tlvdemux
English | 日本語
tlvdemux is a C++20 incremental demultiplexer for
already-descrambled ARIB MMT/TLV streams. It is designed to emit player-ready
HEVC, AAC-LATM/LOAS and ARIB STD-B62 TTML access units without converting the
stream to MPEG-TS or exposing FFmpeg ABI types.
The current implementation provides the stable public callback API, bounded incremental TLV resynchronization, compressed-IP context isolation, MMTP fragment/aggregation handling, PA/M2/MPT track discovery, descriptor-driven timelines, and HEVC/AAC-LATM/TTML access-unit output.
Build and test
nix-shell
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release
cmake --build build
ctest --test-dir build --output-on-failureZlib is the only non-standard runtime dependency. It is used to turn compressed data-broadcast items into browser-ready virtual files; Emscripten links its zlib port into the single-file WASM build.
Shared-library builds are enabled by default. Linux produces
libtlvdemux.so.0 (with the versioned implementation file), while macOS
produces the corresponding libtlvdemux.0.dylib. Use
-DBUILD_SHARED_LIBS=OFF when a static libtlvdemux.a is preferred.
The exported interface is a C++20 ABI, so dynamically linked consumers should
use a compatible compiler and C++ standard library.
When embedding the project with add_subdirectory(), the diagnostic executable
can be disabled with -DTLVDEMUX_BUILD_TOOLS=OFF. Tests follow CMake's standard
BUILD_TESTING option.
On macOS, the VideoToolbox probe can exercise the complete browser-facing MSE
path without launching a browser. It feeds MMTS through the production
MseRemuxer, validates tfdt/trun continuity and HEVC sample flags, applies
Chromium-style hvc1 conversion, and submits the result to hardware decoding.
The following also repeats the run at sixteen deterministic random byte
landings while pacing samples at 3x:
./build/tlvdemux-videotoolbox-probe demo/8k.mmts \
--mse --rate 3 --inflight 4 --max-au 90 \
--random-seeks 16 --seed 20260731Install the library, public headers and CMake target export with:
cmake --install build --prefix /desired/prefixThe install includes the shared or static library, public headers, the
tlvdemux::tlvdemux CMake package target, the diagnostic tool when enabled,
and the MIT license.
Library usage
Implement tlvdemux::Sink, keep it alive for the lifetime of the demuxer, and
feed arbitrary-sized chunks synchronously:
#include <tlvdemux/demuxer.hpp>
class PlayerSink final : public tlvdemux::Sink {
public:
void onService(const tlvdemux::ServiceInfo& service) override;
void onTrack(const tlvdemux::TrackInfo& track) override;
void onAccessUnit(tlvdemux::AccessUnit&& unit) override;
void onError(const tlvdemux::Error& error) override;
};
PlayerSink sink;
tlvdemux::Demuxer demuxer(sink);
demuxer.push(data, size);
demuxer.flush();push() does not retain the input pointer. Callback payloads own their data,
and malformed stream data is reported through onError() while parsing
continues where recovery is possible. Call reset() when replacing the input
stream; service and track selection policies are retained.
Audio tracks expose their MH audio component metadata through
TrackInfo::audio, including the signalled channel layout, component type,
main-component flag and sampling rate. Select tracks from this metadata rather
than assuming packet IDs remain fixed between programmes.
Demuxer lifecycle
Treat each demuxer instance as one logical input session. The important boundaries are different operations rather than interchangeable ways to clear the parser:
push()accepts arbitrary chunk boundaries. Native callbacks and direct-WASM media/signalling callbacks run synchronously inside the call; callback- lifetime byte views must be consumed before it returns. Direct WASM queues application-resource assembly separately as described below.flush()closes the current contiguous input span. It emits complete buffered access units, reports and drops incomplete fragments, and makes any later media resume at a discontinuity (video waits for a RAP). It does not destroy the demuxer, clear discovered metadata, finalize a recording index, or callMediaSource.endOfStream(). Call it at a real EOF or intentional input boundary, not whenever a live network read is temporarily empty.reposition(offset, true)seeks within the same source. It preserves track selections and the normalized media timeline, usesoffsetas the new absolute input position, waits for a selected video RAP, and marks the first output from each track discontinuous. Passfalseonly when the new position should establish a new timeline origin. In the WASM wrapper, completed application VFS files survive a reposition while incomplete carousel assembly is discarded.reset()replaces the logical source. Parser, service/track catalogue, timeline, application resources, offsets and error counters are cleared, but explicit service/track selections and construction options are retained. An active WASM recording index is restarted empty. Clear or reselect retained track IDs if the replacement source uses different IDs.selectService()is a service-session change, not a media seek. It clears service-scoped tracks, timing and application resources; select new tracks from the subsequentonTrackcallbacks.- At permanent EOF, call
flush(), thenfinalizeIndex()if indexing was enabled. Let asynchronous SourceBuffer/application consumers drain before ending MSE. WASM objects must finally be released withdelete(); in native code theSinkmust outlive theDemuxer.
Browser MSE queue
Browser integrations can reuse the SourceBuffer state machine shipped by the package instead of duplicating append, trim, retry and worker-backpressure logic in each player:
import {
MseAppendQueue,
finalizeMseMediaSource,
} from 'tlvdemux/mse-append-queue';
const videoQueue = new MseAppendQueue(mediaSource, video, videoMime);
videoQueue.append(initSegment);
videoQueue.append(mediaSegment);
await videoQueue.waitBelow(4 * 1024 * 1024);
await finalizeMseMediaSource(mediaSource, [videoQueue, audioQueue], {
// Enable only when the demuxer reported an incomplete physical input tail.
truncateToCommonEnd: incompleteInputTail,
});The queue counts both pending and in-flight bytes, recomputes ownership after
every updateend, serializes appendBuffer() and remove(), retries Chromium
quota pressure, and rejects detached or failed SourceBuffers. The finalizer
waits for both tracks before endOfStream(); for a physically truncated input
it can first remove an unmatched A/V tail and finish on the last common coded
frame. It does not hide malformed-input errors or truncate complete recordings.
Data-broadcast applications and virtual files
Application-resource collection is enabled by default. While media access units
are emitted, the demuxer also combines application signalling, data-directory
tables, asset-management tables and out-of-order data units into complete files.
Sink receives onApplicationState, onApplicationResource, and
onApplicationResourcesReset events. An application becomes Ready when its
AIT entry path is present; other referenced files may continue arriving from the
broadcast carousel.
Collection readiness and broadcast-requested application lifecycle are
independent. state describes virtual-file availability: ready means that
the entry document exists, not that every referenced resource has arrived or
that an HTML runtime is running. lifecycle maps the AIT controlCode:
| controlCode | Reported lifecycle | Receiver responsibility |
| --- | --- | --- |
| 0x01 AUTOSTART | autostart-pending until the entry exists, then autostart-ready | Start only after it is ready and receiver policy permits it. |
| 0x02 PRESENT | present | Make the application available for presentation; this is not proof that it was launched. |
| 0x04 KILL | killed | Stop any active runtime/session. Cached files may remain until a resource reset. |
| 0x05 PREFETCH | prefetching until the entry exists, then prefetched | Collect/cache resources without presenting the application. |
| any other value | unsupported | Do not infer a launch action. |
tlvdemux reports these transitions but never launches, reloads or terminates
the application runtime itself. The host owns that state machine, including
idempotent start/stop behavior, UI policy and the security boundary. In the
WASM API, reset() and selectService() clear the completed VFS and emit
onApplicationResourcesReset; reposition() deliberately retains completed
files. A killed application can therefore still have state: "ready" until
the host closes it or the resource session is reset.
Direct WASM callers must also drive application assembly explicitly. Call
drainApplicationResources(maxEvents) after input batches and schedule another
drain when it returns true; a value of 0 drains all events currently queued.
After flush(), drain until it returns false before reading the final VFS or
deleting the demuxer. The demo worker wrapper performs this scheduling
automatically so large carousel decompression cannot block media input.
Completed bytes are moved to the sink rather than retained indefinitely by the
demuxer. Native hosts can keep them in the thread-safe
ApplicationResourceStore, whose get, list, and waitFor methods are
intended for a loopback HTTP/WebView adapter:
class ReceiverSink final : public tlvdemux::Sink {
public:
tlvdemux::ApplicationResourceStore files;
void onApplicationState(const tlvdemux::ApplicationState& state) override {
files.onApplicationState(state);
}
void onApplicationResource(tlvdemux::ApplicationResource&& resource) override {
files.onApplicationResource(std::move(resource));
}
void onApplicationResourcesReset() override {
files.onApplicationResourcesReset();
}
// Implement the four required media/error callbacks as usual.
};The store contains no socket or HTTP dependency. A native application may bind
a separate server to 127.0.0.1 and answer a request with
files.waitFor(context_id, path, timeout). WASM callers normally keep the same
events in a JavaScript Map and expose them through a Service Worker instead.
Resource collection can be disabled with
Limits::collect_application_resources. Limits also bounds pending item
count/bytes, catalogue size, and decompressed file size so a malformed carousel
cannot grow memory without limit.
Inspect a stream
./build/tlvdemux-inspect --list test.tlv
./build/tlvdemux-inspect --trace-au test.tlv
./build/tlvdemux-inspect --video video.hevc --audio audio.loas \
--subtitle subtitle.ttml test.tlv
./build/tlvdemux-inspect --audio secondary.loas \
--audio-packet-id 0xf311 test.tlvUse Mirakurun's raw 4K path with decode=0 when capturing validation input:
curl 'http://MIRAKURUN/api/services/SERVICE_ID/stream?decode=0' > test.tlvWhen more than one track of a kind is present, the diagnostic dumper writes the
first discovered supported track of that kind. --trace-au still reports every
emitted track.
The library assumes any required B61 descrambling has already happened before
the bytes reach Demuxer::push(). In the validation setup, Mirakurun
decode=0 preserves the MMT/TLV stream while the tuner/frontend path supplies
already-usable media payloads. B61 message-authentication metadata is parsed so
an appended authentication code is not exposed as part of the media payload;
cryptographic verification itself remains the caller's responsibility.
WebAssembly
Install the prebuilt single-file WebAssembly package from npm:
npm install tlvdemuxThe package works with CommonJS directly and with the usual default-import interop in ESM-aware bundlers:
import createTlvDemuxModule from "tlvdemux";
const module = await createTlvDemuxModule();
const demuxer = new module.TlvDemuxer({
onTrack: track => console.log(track),
onAccessUnitView: unit => consumeSynchronously(unit),
onError: error => console.warn(error),
});For MSE players, mseMaxAudioChannels can reject layouts above the browser's
chosen limit without rewriting the AAC configuration. For example, a value of
6 keeps mono through 5.1 tracks and suppresses a 22.2-channel AAC init
segment. Use track.audio.channels in onTrack to select a compatible
alternative track; omitted or zero leaves the remuxer unlimited.
Excluding BS8K 22.2-channel audio
Some BS8K programmes carry 22.2-channel (24-channel) AAC audio, represented by
channel_configuration=13. Chromium-based browsers do not accept this layout
through MSE, which may cause an audio appendBuffer() call to raise a
MediaError. For browser playback, setting the limit to six channels as shown
below allows mono through 5.1 while preventing a 22.2-channel MSE init segment
from being emitted.
let selectedAudio = false;
const demuxer = new module.TlvDemuxer({
mseMaxAudioChannels: 6,
onTrack(track) {
const channels = track.audio?.channels ?? 0;
if (!selectedAudio && track.kind === "audio" &&
(channels === 0 || channels <= 6)) {
selectedAudio = true;
demuxer.selectTrack("audio", track.trackId);
}
},
onMseInit: init => appendInitSegment(init),
onMseSegment: segment => appendMediaSegment(segment),
});This setting does not downmix 22.2-channel audio to 5.1. It is a safeguard that
keeps unsupported audio out of MSE. When multiple audio tracks are available,
use track.audio.channels to choose a 5.1 or stereo alternative. If the option
is omitted or set to 0, no channel-count limit is applied.
TypeScript declarations for the module, callbacks, events, duration probe and
recording index are included. The npm package contains the generated wrapper
with its WebAssembly binary embedded, so consumers do not need Emscripten and
do not make a separate .wasm request.
iOS and iPadOS Safari
The WASM demuxer itself runs on current iOS Safari, including the BigInt
values used by the public API. Player integrations must not assume that the
standard MediaSource constructor exists, however: iOS exposes the compatible
ManagedMediaSource API instead. Select the constructor once and use it for
both capability checks and construction:
const BrowserMediaSource = globalThis.ManagedMediaSource || globalThis.MediaSource;
if (!BrowserMediaSource?.isTypeSupported(mime)) throw new Error(`Unsupported: ${mime}`);
const mediaSource = new BrowserMediaSource();Register the sourceopen listener before assigning the object URL to the video
element, then attach it and begin playback. demo/demo.js implements this
path. demo/ios-compat.html is a small feature and end-to-end diagnostic page;
it reports WASM, HEVC/AAC, MSE/MMS and SourceBuffer results separately.
Do not use the iOS Simulator as the final ManagedMediaSource playback verdict. WebKit bug 266764 documents that the simulator can expose the API but never open the source. Confirm the SourceBuffer stage on physical iPhone/iPad hardware. See also WebKit's ManagedMediaSource integration example:
- https://webkit.org/blog/15036/how-to-use-media-source-extensions-with-airplay/
- https://bugs.webkit.org/show_bug.cgi?id=266764
Build the npm package
Build the browser/worker wrapper with Emscripten:
nix-shell
emcmake cmake -S . -B build-wasm -G Ninja \
-DBUILD_SHARED_LIBS=OFF -DTLVDEMUX_BUILD_TOOLS=OFF
cmake --build build-wasm --target tlvdemux-wasmFrom nix-shell, npm run build performs the same release build and copies the
result to dist/tlvdemux.js. npm pack --dry-run runs the release build and
WASM smoke test before showing the exact files that would be published.
The result is a single self-contained build-wasm/tlvdemux.js; the WebAssembly
binary is embedded and no separate .wasm request is made. Load it as a normal
script and create a demuxer asynchronously:
const module = await createTlvDemuxModule();
const demuxer = new module.TlvDemuxer({
onTrack: track => console.log(track),
onEventInfo: event => console.log(event.title, event.startTimeUnixMilliseconds),
onStreamEvent: event => console.log(event.eventMessageTag, event.messageId),
onAccessUnitView: unit => consumeSynchronously(unit),
onApplicationState: application => console.log(application.state),
onApplicationResourceView: resource => console.log(resource.path),
onError: error => console.warn(error),
});
demuxer.push(chunk); // Uint8Array; copied into WASM memory
demuxer.flush();
demuxer.delete();For loaders that already manage buffers, _malloc, _free, HEAPU8, and
pushFromHeap(address, size) provide a reusable heap-buffer path. JavaScript
receives 64-bit offsets, timestamps, and track IDs as BigInt values.
MH-EIT current/following and schedule entries are reported through
onEventInfo; tableId === 0x8b with sectionNumber 0/1 identifies the
present/following event for the service.
ARIB STD-B60 EMT messages are reported through onStreamEvent. The event
contains the MPT-signalled EMT tag, group/id/version, private bytes, and the raw
time-mode fields so the receiver can ignite timed messages against its playback
clock instead of the demux/read-ahead clock. rawMessageId preserves B60's
16-bit descriptor field; its high octet is exposed as messageId and its low
octet as messageVersion to the B62 application.
onAccessUnitView avoids copying media output, but its data view is valid only
for the duration of the callback and must be consumed synchronously. Use
onAccessUnit instead when the callback needs an owned Uint8Array copy.
onApplicationResourceView has the same callback-only lifetime; use
onApplicationResource for an owned copy.
TlvDemuxer also owns an ApplicationResourceStore. applicationResources()
lists its files, applicationResource(contextId, path) returns an owned file,
applications() reports current application states, and
applicationEntry(contextId) resolves the ready entry document. This keeps
path validation, version replacement, and entry resolution in C++/WASM rather
than duplicating those rules in each browser loader.
Run the application-resource WASM integration test against a captured stream with:
node tests/wasm_application_resources.mjs build-wasm/tlvdemux.js test.tlvDurationProbe drives fast head/tail reads without owning a file or HTTP
client. Start it with the known file size, fulfill each object returned by
nextRange(), and pass the exact bytes to pushRange(). A successful
duration() has status: "complete"; failure remains explicit through
state() and failure() and never falls back to downloading the whole file.
The native tlvdemux-probe INPUT tool exercises the same protocol.
For precise recorded seek, call startIndex(false) before feeding the full
stream and finalizeIndex() at its real EOF. seekPointsFor(targetUs) returns
the surrounding RAP checkpoints. Reposition to first.signallingOffset, feed
from there, decode from the emitted RAP, and present the first frame at or after
the requested time.
The recording index has a lifecycle separate from the demux session. A VOD
scan starts in building; finalizeIndex() makes its duration and seek points
complete. Use startIndex(true) for a growing recording and finalize only when
the source has permanently stopped growing. reposition() preserves the
current index, whereas reset() or selectService() restarts an active index
from empty. flush() alone never finalizes it.
Browser demo
Build the sibling libaribhtml5 receiver SDK and build-wasm/tlvdemux.js, then
serve the repository root and open /demo/:
(cd ../libaribhtml5 && pnpm build:sdk)
node demo/server.mjsThe bundled development server supports the 206 and Content-Range
responses required by duration probing and recorded seek. Python's basic
python3 -m http.server is not suitable for this demo because it does not
provide the required Range behavior.
The demo accepts either a local MMTS file or an HTTP URL, probes its duration,
then plays the selected HEVC and AAC tracks through Media Source Extensions.
Application resources collected by WASM are exposed to a sandboxed data-
broadcast iframe through the same-origin Service Worker VFS shipped by
libaribhtml5. The receiver API, video-plane handling, document preparation,
built-in ROM sounds, and remote-control behavior also come from
libaribhtml5; external application URLs remain blocked.
Local files use Blob.slice(); remote files require validated 206 and
Content-Range responses. Live mode skips duration probing and seeking, uses a
normal streaming GET, and exposes the Media Source as an unbounded timeline.
HTTP URLs that do not return a valid Range response automatically fall back to
Live mode.
The demo contains a deliberately small fMP4/MSE layer and does not depend on
mmts.js at runtime. Browser HEVC MSE support is still required.
Demuxing and fMP4 remuxing run in demo/demux-worker-runtime.js. The main
thread sends input chunks as transferable buffers and receives only MSE init
segments, media segments, subtitle payloads, application files, and small
control events. demo/worker-tlvdemux.js owns the RPC facade, while
demo/demux-worker-protocol.js contains the shared message names. Keeping these
three responsibilities separate makes it possible to change the player UI,
the transport protocol, or the worker-side demux lifecycle independently.
Run the repeatable WASM throughput benchmark with:
npm run benchmark:wasm -- build-wasm/tlvdemux.js test.tlv 268435456It reports demux-only and demux-plus-MSE throughput, callback/segment counts,
output bytes, and maximum observed WASM heap size. See
docs/performance.md for the hot-path ownership map,
measurement guidance, and regression checklist.
Current scope
Version 0.1 supports the ARIB broadcast subset exercised by the validation
streams: all four HCfB compressed-IP modes (0x20, 0x21, 0x60, 0x61),
MMTP signalling and fragmented media, HEVC Annex B, AAC-LATM/LOAS, and ARIB
STD-B62 TTML. The recording helpers provide bounded duration probing, sparse RAP
indexing, and recording-relative repositioning. CAS/descrambling, decoder and
TTML rendering, persistent index serialization, and general-purpose ISO MMT are
outside the library's current scope.
