21 KiB
status, date, definition
| status | date | definition |
|---|---|---|
| partial | 2026-05-25 | sketched |
Multi-language audio
Recognize multiple audio renditions from a multivariant HLS playlist, expose them with language metadata, apply a default-selection picker, and support user / programmatic switching of the active audio track — including mid-stream switching with audio-buffer flush at the next-segment boundary.
Status
- Composition: Tier 1 (recognition + default selection) and most of Tier 2 (programmatic selection via
userAudioTrackSelection+ mid-stream switching) implemented increateSimpleHlsEngineandcreateHlsAudioOnlyEngine. A/V sync policy on mid-stream switch is "switch at next-segment boundary" (starting point, area for improvement). Persistence (Tier 2 phase 6) deferred. - Definition depth: sketched — implementation surface populated for Tier 1 and Tier 2; specific phases (A/V sync policy refinement, persistence) remain coarse pending follow-up.
Phases of complexity
| Phase | What | Status |
|---|---|---|
| Tier 1 — Recognition + exposure | Parser surfaces all audio renditions with LANGUAGE, NAME, DEFAULT, AUTOSELECT, CHANNELS, URI, CODECS metadata; engine state exposes the candidate list via presentation.selectionSets. |
Implemented (free — owned architecturally by parseMultivariantPlaylist and surfaced via audio-playback) |
| Tier 1 — Default selection | Three-tier picker: preferredAudioLanguage → DEFAULT=YES → first track. |
Implemented — pickAudioTrack wired as default in selectAudioTrack's AUDIO_TRACK_SELECTION_CONFIG (and shared with switchAudioTrack). preferredAudioLanguage config field active (was inert before this feature) |
| Tier 2 — Programmatic selection | Consumer writes userAudioTrackSelection (Partial<AudioTrack>) to narrow the candidate set; switchAudioTrack reads the filter, re-fires on changes, short-circuits picker when filter narrows to single track, falls back to unfiltered candidates when filter excludes all. |
Implemented — sibling of userVideoTrackSelection; constraint+filter shape matches video precedent. Slot ownership moved from selectAudioTrack to new behavior switchAudioTrack (precursor to audio-abr's switchAudioQuality) |
| Tier 2 — Mid-stream switching | When the audio segment loader receives a load for a different track than the one currently buffered, planTasks compares the new track's language to the buffered track's language (captured on SourceBufferActor.context.initTrackLanguage). On mismatch, the loader emits a { type: 'remove', start: nextBoundary, end: Infinity } task before the new append-init / append-segment tasks. Same split as video: switchVideoQuality writes the slot; segment-loader plans the buffer ops. Loader replan happens naturally via loadAudioSegments reactivity to selectedAudioTrackId. |
Implemented — language-predicate today (covers multi-language audio + text language switching; video ABR is unaffected because video tracks have no language). Cross-codec switching (e.g., AAC → AC-3) is a separate predicate handled by 5.1-surround-selection (DWIM — see Stage 2 below) |
| Tier 2 — A/V sync during switch | Flush range is "next segment boundary at/after playhead." Current segment plays through; new rendition starts at boundary. | Implemented (starting point) — area for improvement: smoother sync via pause / silence-pad alternatives. Stage 2 lifts the predicate / strategy to a per-actor config (see Open questions) |
| Tier 2 — Persistence | Remember user's last audio-track choice across sources or sessions. | Deferred — policy on top of API, not core to feature mechanism. Future work |
What's in scope vs out of scope
In scope (implemented):
- All Tier 1 and Tier 2 phases above except persistence
- HLS spec compliance for
EXT-X-MEDIA:TYPE=AUDIOrendition handling - VoD content
Out of scope (separate candidate features):
- audio-abr (documented; not implemented) — bandwidth-driven switching within an audio rendition group. The slot writer
switchAudioQualitywill replaceselectAudioTrackwhen audio-abr ships, mirroring video'sselectVideoTrack/switchVideoQualityprecedent.userAudioTrackSelectionfilter shape is shared between this feature and audio-abr (single slot serves both bitrate-pinning and language-pinning, per audio-abr's open question pending resolution). - 5.1-surround-selection (coarse, not yet documented) — capability-gated codec selection for audio. Layers on top of multi-language-audio's rendition surfacing. Owns codec-change switching (cross-codec
changeType()or buffer recreation). - audio-only-mode-override (use case; Phase 2 partial — this feature composes in as Phase 2 constituent) — engine variant for audio-only delivery. Different composition concern.
Out of scope (different architectural layer):
- DOM
HTMLMediaElement.audioTracksexposure — mirroringselectedAudioTrackIdintoHTMLMediaElement.audioTracksis not an SPF concern. Adapter / above-the-engine layer may implement. - Persistence — deferred. See Phases of complexity.
Likely cross-cutting impact
Resolved during implementation:
- Track registry primitive — no extraction. The audio multi-writer pattern (Tier 2 programmatic) uses constraint+filter (single slot writer,
userAudioTrackSelectionis consumer intent), not multi-writer.selectedAudioTrackIdstays single-writer. Premature to extract a shared abstraction at 2 data points (text + audio); text uses multi-writer-with-intent (different shape). resolveAudioTrackre-resolution — no extension needed.setupTrackResolution's'presentation-resolved'state effect readsselectedAudioTrackIdreactively; mid-stream switch fires the effect, scheduling a fetch task for the new track. Source-identity state-exit cleanup cancels stale tasks viarunner.abortAll.- Audio buffer flush placement — lives at the
segment-loaderactor'splanTasks(betweenSegmentLoaderActor"what to load" andSourceBufferActor"remove / append" intelligence), NOT inswitchAudioTrack(slot owner) or insetupAudioBufferActors(actor lifecycle). Same split as the video pipeline:switchVideoQualitywrites the slot;segment-loader'splanTasksdecides flush + init + segments. For audio,planTaskscomparesactorCtx.initTrackLanguage(captured fromappend-initmeta) to the newtrack.language— on mismatch, emits aremovetask spanning next-segment-boundary → Infinity ahead of init / segment tasks. Predicate is inline today (language-change). Stage 2 lifts it to a per-actor config (flushPredicate?orflushStrategy?atcreateSegmentLoaderActortime) for cross-codec, viewport-driven, and other variants. SourceBufferActor.contextextended withinitTrackLanguage— captured fromAppendInitMessage.meta.languageon eachappend-initcommit. The downstream consumer issegment-loader'splanTasks.loadAudioSegmentsreplan — no change needed. Already reactive toselectedAudioTrackIdvia segment loader actor's internal effect.- Manifest parser — already surfaces all needed per-track metadata (verified via existing
audio-playbackPhase 1).
Open questions
- Stage 2: pluggable flush predicate / strategy. Stage 1 inlines the language-change predicate in
segment-loader'splanTasks. Stage 2 lifts it to a per-actor config atcreateSegmentLoaderActortime. Shape options:flushPredicate?: (prev: BufferedTrackMeta, next: Track) => boolean— simplest; per-loader policy decision.flushStrategy?: { predicate, range }— separate decision-of-whether and decision-of-where.- A/V sync policy alternatives (pause-then-resume, silence-pad) — currently next-segment-boundary is hardcoded.
- Codec-change branching when 5.1-surround-selection lands (different predicate;
changeType()-aware range).
- Persistence model. When implemented: localStorage / cookie / consumer-supplied storage? Across-source preservation rules? Likely adapter-layer, not engine.
userAudioTrackSelectionslot sharing withswitchAudioQuality. When audio-abr lands, does the slot serve both language-pinning and bitrate-pinning (video's pattern) or do they split? Resolution deferred to audio-abr implementation.
Resolved during this phase's implementation
- A/V sync policy — chose next-segment-boundary as starting point (smoother than playhead-forward flush; cheaper than pause / silence-pad). Stage 2 will lift this to a configurable strategy.
- Programmatic write path — chose constraint+filter via
userAudioTrackSelectionover direct multi-writer write toselectedAudioTrackId. Aligns with video'suserVideoTrackSelectionprecedent; preserves audio-abr's path to introducingswitchAudioQualityas the slot owner. - Mid-stream flush placement — flush logic lives in
segment-loader'splanTasks(between actor planning and source-buffer execution), NOT inswitchAudioTrack(slot owner) orsetupAudioBufferActors(actor lifecycle). This mirrors the video pipeline's split: slot owner writes; loader decides remove / init / segment plan. Two earlier iterations bolted flush onto the wrong host: firstsetupAudioBufferActors(lifecycle violation), thenswitchAudioTrack(blended selection + flush). The final placement keepsswitchAudioTrackpurely a selection-ownership behavior and concentrates buffer-orchestration knowledge in the segment-loader where init / append / remove already get planned together. - Flush predicate — Stage 1 inline check:
actorCtx.initTrackLanguage !== track.language. Naturally covers audio language switching AND text language switching; video ABR is unaffected (video tracks have no language). Stage 2 lifts to a per-actor predicate / strategy config atcreateSegmentLoaderActortime so cross-codec (5.1 surround), per-channel-count, and other cross-rendition shapes can plug in without modifyingplanTasks. - Abstraction shape for
switchAudioTrack— chose to mirrorswitchVideoQuality's abstraction shape (separate helpersetupAudioTrackSwitching, same generic-parameter pattern over selection key + user-selection key + track type, samegetTracks/selectOptimal/pickerconfig points) rather than (a) reusingsetupQualitySwitchingdirectly or (b) shipping an ad-hoc audio-specific structure. The mirrored shape sets up the convergence path: when audio-abr ships, the two helpers either merge (shared generics with optional bandwidth) or stay parallel with minimal divergence. Audio'sselectOptimalisselectAudioCurrent(pin-to-current); audio-abr swaps it for a bandwidth-driven variant matching the video signature. - Track-registry primitive extraction — chose defer. Two data points (text + audio) with different multi-writer shapes don't justify extraction yet.
Implementation surface
Composition: packages/spf/src/playback/engines/hls/engine.ts (default) + packages/spf/src/playback/engines/hls/engine-audio-only.ts (variant). Both replace selectAudioTrack with switchAudioTrack to get filter reactivity + mid-stream flush; engines that want only default-pick-on-load (test setups, future variants) keep composing selectAudioTrack (it remains exported, mutually exclusive with switchAudioTrack).
Behaviors:
| Behavior / Actor | File | Responsibility |
|---|---|---|
switchAudioTrack (new behavior) |
packages/spf/src/playback/behaviors/switch-audio-track.ts |
Slot owner for selectedAudioTrackId, filter-reactive (consumes userAudioTrackSelection). Single effect in 'presentation-resolved' that mirrors switchVideoQuality's pattern: filter narrow → single-candidate short-circuit → initial picker → selectOptimal. Uses helper setupAudioTrackSwitching whose abstraction shape parallels setupQualitySwitching (generic over selection key + user-selection key + track type + getTracks + selectOptimal + picker). Pure selection ownership; no flush concern. Path to switchAudioQuality: audio-abr Phase 3 swaps selectAudioCurrent (pin-to-current) for a bandwidth-driven selectOptimal |
segment-loader actor planTasks (extended) |
packages/spf/src/playback/actors/dom/segment-loader.ts |
Already handled init / append / forward+back-flush task planning. New Stage-1 predicate: when actorCtx.initTrackId !== track.id AND actorCtx.initTrackLanguage !== track.language, emits a { type: 'remove', start: nextBoundary, end: Infinity } task at the front of the task list (before append-init + append-segment). Generic enough to cover audio-language and text-language switches; video ABR doesn't trigger it (no language attribute). Includes language in the emitted append-init meta so downstream tracking can compare next switch |
SourceBufferActor (extended) |
packages/spf/src/playback/actors/dom/source-buffer.ts |
Context now tracks initTrackLanguage? alongside initTrackId. Captured from AppendInitMessage.meta.language on commit; read by segment-loader's planTasks to detect cross-language switches |
selectAudioTrack (unchanged purpose) |
packages/spf/src/playback/behaviors/select-tracks.ts |
Lifecycle-only default selection on presentation-resolved entry; clears on src unload. Uses pickAudioTrack (3-tier) as default picker. Mutually exclusive with switchAudioTrack — engines compose one or the other |
setupAudioBufferActors (unchanged) |
packages/spf/src/playback/behaviors/dom/setup-buffer-actors.ts |
Per-source audio SourceBufferActor + SegmentLoaderActor lifecycle |
State slots:
selectedAudioTrackId— single-writer (switchAudioTrackwhen composed;selectAudioTrackwhen the lighter variant is composed instead — they're mutually exclusive). Constraint+filter pattern keeps writer count at 1; intent flows throughuserAudioTrackSelection.userAudioTrackSelection— new slot inSimpleHlsEngineState+SimpleHlsAudioOnlyEngineState.Partial<AudioTrack>shape. Single-writer (external consumer viashareSignals). Read byswitchAudioTrack.
Actor state:
SourceBufferActorContext.initTrackLanguage— new field; captured fromAppendInitMessage.meta.languageon commit. Read bysegment-loader'splanTasks.AppendInitMessage.meta.language— new field on the message type. Carries language fromplanTasks(which readstrack.language) to the source-buffer actor.
Helpers:
| Helper | File | Status |
|---|---|---|
setupAudioTrackSwitching (new) |
packages/spf/src/playback/behaviors/switch-audio-track.ts |
Filter-reactive slot-management reactor. Mirrors setupQualitySwitching's abstraction shape (generic over selection key + user-selection key + track type, with getTracks / selectOptimal / picker config). Today's audio-only consumer is switchAudioTrack; when audio-abr ships, switchAudioQuality either swaps its selectOptimal for a bandwidth-aware variant or the two helpers merge into a shared setupQualitySwitching |
pickAudioTrack |
packages/spf/src/media/primitives/select-tracks.ts |
Now wired as the default picker in both selectAudioTrack and switchAudioTrack (was inert; required custom-picker override before this feature) |
Composition wiring: Both engine factories swap selectAudioTrack for switchAudioTrack in their behavior list. Engine state types in both engine.ts and engine-audio-only.ts gain userAudioTrackSelection.
Verification
Unit tests (packages/spf/src/playback/behaviors/tests/select-tracks.test.ts — selectAudioTrack describe block, lifecycle-only variant):
picks track matching preferredAudioLanguage when supplied— Tier 1 default selection via language preferencefalls back to DEFAULT=YES track when preferredAudioLanguage does not match— Tier 1 second-tier fallbackfalls back to first track when no language preference and no DEFAULT track— Tier 1 final fallback
Unit tests (packages/spf/src/playback/behaviors/tests/switch-audio-track.test.ts — slot-owner variant; Tier 2):
- Selection lifecycle:
selects the first audio track when no preference or filterpicks track matching preferredAudioLanguage when suppliedclears selectedAudioTrackId on src unload
- Filter reactivity (
userAudioTrackSelection):narrows candidates by filter (language)— Tier 2 programmatic writere-picks on filter change mid-presentation— Tier 2 reactive re-pickfilter narrowing to a single track short-circuits the picker— single-candidate short-circuitempty filter result falls back to unfiltered candidate set— graceful no-match fallback
Unit tests (packages/spf/src/playback/actors/dom/tests/segment-loader.test.ts — cross-rendition flush predicate):
dispatches \remove` from next segment boundary on audio language switch`does not dispatch cross-rendition flush when languages match (audio-abr-style switch)does not dispatch cross-rendition flush on initial load (no prior initTrackId)captures language into append-init meta for downstream tracking
Unit tests (packages/spf/src/playback/actors/dom/tests/source-buffer.test.ts — language tracking):
captures initTrackLanguage from append-init meta for downstream cross-rendition flush detectionleaves initTrackLanguage undefined when append-init meta omits language (video)
Unit tests (packages/spf/src/playback/engines/hls/tests/engine-audio-only.test.ts):
exposes userAudioTrackSelection slot for multi-language-audio Tier 2 writes— variant engine state exposes the filter slot
Out of scope / deferred:
- End-to-end mid-stream-switch verification (browser-level rendition switching) deferred to manual sandbox / E2E pass once a multi-language test source is available.
- Persistence — not implemented; no test coverage.
- A/V sync policy alternatives (pause / silence-pad) — not implemented.
Related features
- audio-playback — single-rendition baseline this feature extends. The "Language-aware default selection" gap there is now resolved.
- subtitles — direct template for the selection-picker shape; multi-writer state slot pattern. Subtitles uses orthogonal multi-writer (
selectTextTrack+ DOMchange); audio uses constraint+filter — different shapes. - video-abr —
userVideoTrackSelectionconstraint+filter precedent. Same shape as audio's newuserAudioTrackSelectionslot. - audio-abr (documented; pending implementation) — destination-architecture sibling. When implemented,
switchAudioQualitywill replaceselectAudioTrackas theselectedAudioTrackIdwriter;userAudioTrackSelectionfilter shape carries over. - 5.1-surround-selection (coarse, not yet documented, candidate) — codec-change extension. Tier 2 mid-stream flush is designed extensible to codec-change routing.
- mse-mms-pipeline — owns the audio
SourceBufferActorand theremove/flushBufferprimitives that mid-stream flush builds on. - buffer-management — audio segment loading already replans on
selectedAudioTrackIdchange via natural reactivity; no extension needed. - audio-only-mode-override (use case; Phase 2 partial) — this feature composes in for multi-language audio selection within the audio-only variant.
Use cases that compose this feature
audio-only-mode-override(Phase 2 partial — landed with this feature) — variant enginecreateHlsAudioOnlyEngineexposesuserAudioTrackSelectionand composes the filter-reactiveselectAudioTrack+ flush-awaresetupAudioBufferActorsunchanged from the default engine.
See also
- subtitles.md — closest selection-shape analog
- video-abr.md —
userVideoTrackSelectionconstraint+filter precedent - audio-abr.md — destination-architecture sibling for
selectedAudioTrackId - conventions/signals.md — multi-writer slot conventions
- conventions/behaviors.md — per-type specialization
- packages/spf/src/playback/behaviors/select-tracks.ts —
selectAudioTrack(lifecycle-only variant) - packages/spf/src/playback/behaviors/switch-audio-track.ts —
switchAudioTrack(slot owner with filter reactivity) +setupAudioTrackSwitchinghelper (mirrorssetupQualitySwitching's shape) - packages/spf/src/playback/actors/dom/segment-loader.ts —
planTaskscross-rendition flush predicate (Stage 1: language-change inline) - packages/spf/src/playback/actors/dom/source-buffer.ts —
initTrackLanguagecontext field +AppendInitMessage.meta.language