Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 62 additions & 2 deletions Sources/LumeEngineCore/Session/PlayerSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,26 @@ public struct PlayerConfiguration: Sendable {
public var enableVideo = true
public var enableAudio = true
public var muted = false
/// Ordered, ALREADY-NORMALISED bare language tags supplied by the caller
/// (`["de", "en"]`); empty means use the container default.
///
/// The engine does no normalisation of its own — it never reads the device
/// locale and never invents a preference — but it does normalise the
/// *container's* tags, which arrive as ISO 639-2/B (`ger`), /T (`deu`),
/// 639-1, or a regional variant (`de-AT`). Applied while the pipeline is
/// built, before the demuxer streams a byte (see `TrackLanguageMatcher`).
///
/// Also ranks the forced subtitle track when
/// `autoEnableForcedSubtitlesForForeignAudio` is on.
public var preferredAudioLanguages: [String] = []
/// Turn a forced subtitle track on by itself when the audio that ends up
/// selected matches none of `preferredAudioLanguages`.
///
/// Off by default: whether untranslated dialogue should be subtitled is a
/// viewer-facing policy the host owns. The mechanism lives here because it
/// has to run while the pipeline is built — attaching the lane after
/// `open()` would miss cues and route through a seek.
public var autoEnableForcedSubtitlesForForeignAudio = false
/// Seconds without clock progress (while expected) before `.stalled` fires.
public var stallThreshold: Double = 8

Expand Down Expand Up @@ -183,12 +203,20 @@ public actor PlayerSession {
guard let demuxer else { throw EngineError(code: .invalidState, message: "no demuxer") }
self.info = info

// Default track selection: container default flag, else first of kind.
// Track selection: the app's ordered language preference first, then
// the container default flag, then the first track of the kind. The
// preference is resolved *here*, while the pipeline is being built —
// selecting after open would route through `seek(to: position)` with
// position still 0, discarding `startPosition` on VOD resume and
// seeking live sources that do not survive one (see
// `TrackLanguageMatcher`). An empty preference list matches nothing,
// so a default configuration behaves exactly as it did before.
let videoTrack = configuration.enableVideo
? info.videoTracks.first(where: \.isDefault) ?? info.videoTracks.first
: nil
let audioTrack = configuration.enableAudio
? info.audioTracks.first(where: \.isDefault) ?? info.audioTracks.first
? TrackLanguageMatcher.bestMatch(in: info.audioTracks, preferring: configuration.preferredAudioLanguages)
?? info.audioTracks.first(where: \.isDefault) ?? info.audioTracks.first
: nil

guard videoTrack != nil || audioTrack != nil else {
Expand Down Expand Up @@ -240,6 +268,26 @@ public actor PlayerSession {
decoder.start()
}

// The one case where the engine turns subtitles on by itself, and only
// when the host asked for it: the audio the viewer will hear is foreign
// to them (it matched none of their preferred audio languages) and the
// source carries a forced track for the untranslated dialogue.
// Attaching here rather than after open keeps it on the same no-seek
// path as the audio choice, and the demuxer has not read a packet yet,
// so no cue is missed.
if configuration.autoEnableForcedSubtitlesForForeignAudio,
let audioTrack,
!configuration.preferredAudioLanguages.isEmpty,
!TrackLanguageMatcher.matches(audioTrack, preferring: configuration.preferredAudioLanguages),
let forced = forcedSubtitleTrack(in: info),
let parameters = demuxer.codecParameters(forStream: forced.index) {
let packets = Channel<Packet>(capacity: 128)
demuxer.attach(channel: packets, toStream: forced.index)
let decoder = SubtitleDecoder(parameters: parameters, input: packets, store: subtitles)
installSubtitleLane(trackIndex: forced.index, packets: packets, decoder: decoder)
decoder.start()
}

renderer.onRenderFailure = { [weak self] error in
guard let self else { return }
Task { await self.handleRenderFailure(error) }
Expand All @@ -263,6 +311,18 @@ public actor PlayerSession {
}
}

/// The forced track to show under foreign audio, ranked by the viewer's
/// audio preference (someone who wants German audio reads German signs)
/// and otherwise whatever forced track exists — a forced track is short,
/// on-screen only for dialogue the soundtrack leaves untranslated, and is
/// what the mux author expected to be shown.
private func forcedSubtitleTrack(in info: MediaInfo) -> TrackInfo? {
let forced = info.subtitleTracks.filter(\.isForced)
guard !forced.isEmpty else { return nil }
return TrackLanguageMatcher.bestMatch(in: forced, preferring: configuration.preferredAudioLanguages)
?? forced.first
}

/// Timeline origin: media start time plus any requested start position.
private var startTimeline: Int64 {
info?.startTime ?? 0
Expand Down
190 changes: 190 additions & 0 deletions Sources/LumeEngineCore/Session/TrackLanguageMatcher.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
import Foundation

/// Ordered language-preference matching over container tracks.
///
/// Used at open time only (`PlayerSession.buildPipeline`): the track a viewer
/// wants must be chosen *while the pipeline is being built*, never by opening
/// on one track and switching afterwards. Switching after open re-syncs every
/// lane through `seek(to: position)`, and at open `position` is still 0 —
/// which would silently discard `PlayerConfiguration.startPosition` on VOD
/// resume, and issue a seek against live IPTV endpoints that report
/// `isSeekable` but drop the connection when one arrives.
///
/// Contract with the app (PLAN.md §3.8 — configuration in, no global knobs):
/// preferences arrive **already normalised** as ordered bare language tags
/// (`["de", "en"]`). The engine never consults the device locale and never
/// invents a preference; an empty list means "leave the container's own
/// choice alone", which is what every pre-existing session gets.
///
/// Container tags are whatever the muxer wrote, so the *track* side does need
/// normalising: ISO 639-2/B (`ger`), /T (`deu`), 639-1 (`de`), a BCP-47
/// variant (`de-AT`), or nothing at all with the language spelled out in the
/// title instead.
enum TrackLanguageMatcher {
/// Ranked quality of one track↔preference hit. Lower is better, and the
/// fields are compared in declaration order:
///
/// 1. `rank` — position in the caller's ordered preference list.
/// 2. `penalty` — commentary / audio-description labels lose to a plain
/// track of the same language (a viewer asking for German means the
/// German feature audio, never the director's commentary).
/// 3. `quality` — an exact bare-code tag beats a regional variant
/// (`de` over `de-AT`), which beats a language code found in the free
/// text of the title.
/// 4. `order` — container order; first match wins all else being equal.
private struct Score: Comparable {
let rank: Int
let penalty: Int
let quality: Int
let order: Int

static func < (lhs: Score, rhs: Score) -> Bool {
(lhs.rank, lhs.penalty, lhs.quality, lhs.order)
< (rhs.rank, rhs.penalty, rhs.quality, rhs.order)
}
}

/// Best track for an ordered preference list, or `nil` when nothing
/// matches. `nil` means *do nothing* — callers keep the container's own
/// selection rather than falling back to some arbitrary track.
static func bestMatch(in tracks: [TrackInfo], preferring languages: [String]) -> TrackInfo? {
guard !tracks.isEmpty else { return nil }
let wanted = normalizedPreferences(languages)
guard !wanted.isEmpty else { return nil }

var best: (score: Score, track: TrackInfo)?
for (order, track) in tracks.enumerated() {
guard let hit = score(track, order: order, against: wanted) else { continue }
if best == nil || hit < best!.score {
best = (hit, track)
}
}
return best?.track
}

/// True when the track is one the viewer asked for. Used to decide
/// whether the audio that ended up selected is *foreign* to the viewer,
/// which is what gates forced-subtitle auto-enable. An empty preference
/// list makes every track "wanted", so nothing is ever foreign — the
/// default configuration cannot reach the forced branch at all.
static func matches(_ track: TrackInfo, preferring languages: [String]) -> Bool {
let wanted = normalizedPreferences(languages)
guard !wanted.isEmpty else { return true }
return score(track, order: 0, against: wanted) != nil
}

// MARK: Scoring

private static func score(_ track: TrackInfo, order: Int, against wanted: [String]) -> Score? {
let penalty = isDeprioritized(track) ? 1 : 0

if let tag = track.language, let canonical = canonicalize(tag) {
if let rank = wanted.firstIndex(of: canonical) {
return Score(rank: rank, penalty: penalty, quality: hasSubtags(tag) ? 1 : 0, order: order)
}
}

// Free-text titles ("ger 5.1", "DE"). Matched per whitespace/punctuation
// token, never by substring: a bare "de" occurs inside half the Romance
// titles ever written and would match everything.
if let title = track.title {
for token in tokens(of: title) {
guard let canonical = canonicalize(token), let rank = wanted.firstIndex(of: canonical) else { continue }
return Score(rank: rank, penalty: penalty, quality: 2, order: order)
}
}

return nil
}

/// Labels that mean "not the feature audio": director commentary and
/// audio description. Matched on the title, since `TrackInfo` carries no
/// FFmpeg comment/visual-impaired disposition.
private static func isDeprioritized(_ track: TrackInfo) -> Bool {
guard let title = track.title?.lowercased() else { return false }
for phrase in commentaryPhrases where title.contains(phrase) {
return true
}
// "English AD" — an abbreviation only as a standalone token.
return tokens(of: title).contains("ad")
}

private static let commentaryPhrases = [
"commentary", "commentaire", "kommentar", "comentario", "comentário",
"commento", "audio description", "audiodescription", "audiodescricao",
"audiodescrição", "audiodescripcion", "audiodescripción", "descriptive",
"described", "hörfilm",
]

// MARK: Language tags

/// Ordered, de-duplicated canonical forms of the caller's preferences.
private static func normalizedPreferences(_ languages: [String]) -> [String] {
var seen = Set<String>()
var result: [String] = []
for language in languages {
guard let canonical = canonicalize(language), seen.insert(canonical).inserted else { continue }
result.append(canonical)
}
return result
}

/// Reduces a tag to a comparable primary language subtag, or `nil` when it
/// carries no language information at all.
///
/// `Locale.LanguageCode` covers the ISO 639-2/T codes (`deu` → `de`) but
/// returns nil for the bibliographic /B forms that MKV and MPEG-TS
/// actually carry (`ger`, `fre`, `chi`, `cze`, `dut`, `gre`, …), so those
/// need the table below. Three-letter codes with no two-letter form at all
/// (`fil`, `haw`) stay as they are and still compare against each other.
static func canonicalize(_ tag: String) -> String? {
let trimmed = tag.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
guard !trimmed.isEmpty else { return nil }
let primary = trimmed.split(whereSeparator: { $0 == "-" || $0 == "_" }).first.map(String.init) ?? trimmed
guard primary.allSatisfy(\.isLetter) else { return nil }
guard !placeholders.contains(primary) else { return nil }

switch primary.count {
case 2:
return primary
case 3:
if let alpha2 = bibliographicAlpha2[primary] { return alpha2 }
if let alpha2 = Locale.LanguageCode(primary).identifier(.alpha2), alpha2.count == 2 {
return alpha2
}
return primary
default:
return nil
}
}

/// True when the tag names a region/script beyond the language itself
/// (`de-AT`), which loses to an exact `de` when both are present.
private static func hasSubtags(_ tag: String) -> Bool {
tag.contains("-") || tag.contains("_")
}

/// Tags that assert the absence of a single language. `vo` is French
/// broadcast shorthand for "version originale" (not Volapük, which no
/// IPTV provider has ever muxed), and `multi` is the IPTV convention for
/// a multiplexed multi-language track. All of them mean "no match" — the
/// container's own choice stands.
private static let placeholders: Set<String> = [
"und", "mul", "mis", "zxx", "vo", "vos", "vost", "multi", "original", "unknown",
]

/// ISO 639-2/B → 639-1, i.e. exactly the codes `Locale.LanguageCode`
/// cannot resolve because it only knows the terminological forms.
private static let bibliographicAlpha2: [String: String] = [
"alb": "sq", "arm": "hy", "baq": "eu", "bur": "my", "chi": "zh",
"cze": "cs", "dut": "nl", "fre": "fr", "geo": "ka", "ger": "de",
"gre": "el", "ice": "is", "mac": "mk", "may": "ms", "mao": "mi",
"per": "fa", "rum": "ro", "slo": "sk", "tib": "bo", "wel": "cy",
]

private static func tokens(of text: String) -> [String] {
text.lowercased()
.split(whereSeparator: { !$0.isLetter })
.map(String.init)
}
}
37 changes: 37 additions & 0 deletions Tests/LumeEngineCoreTests/Fixtures/generate-fixtures.sh
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
# basic.mp4 — H.264 + AAC, 10 s VOD
# wrap.ts — MPEG-TS whose raw 33-bit timestamps wrap mid-file
# multitrack.mkv — video + 2 audio languages + SRT subtitles + chapters
# multilang.mkv — video + eng (default disposition) + ger audio
# forcedsubs.mkv — video + eng audio + a FORCED eng subtitle track
# surround71.mkv — FLAC 7.1 audio-only
# truehd.mkv — TrueHD 5.1 audio-only (40-sample access units)
# interlaced.ts — 1080i-style MPEG-TS, field-coded, TFF (deinterlacer input)
Expand Down Expand Up @@ -70,6 +72,41 @@ gen interlaced.ts \
-vf "interlace=scan=tff" \
-c:v mpeg2video -flags +ilme+ildct -top 1 -g 25 -f mpegts

# Two audio languages where the container's own default flag (eng) is the
# *wrong* answer for a German-preferring viewer: the ordered preference must
# beat the disposition, and an empty/unmatched preference must leave it alone.
gen multilang.mkv \
-f lavfi -i "testsrc2=duration=5:size=320x180:rate=25" \
-f lavfi -i "sine=frequency=440:duration=5" \
-f lavfi -i "sine=frequency=880:duration=5" \
-map 0:v -map 1:a -map 2:a \
-c:v libx264 -preset ultrafast -pix_fmt yuv420p -c:a aac \
-metadata:s:a:0 language=eng -metadata:s:a:1 language=ger \
-disposition:a:0 default -f matroska

# A forced subtitle track under English-only audio: the one case where the
# engine enables subtitles by itself (viewer prefers another audio language,
# so the dialogue they get is foreign and the signs/foreign-speech track is
# what the mux author meant them to see).
if [ ! -f "$OUT/forcedsubs.mkv" ]; then
cat > "$OUT/forced.srt" <<'SRT'
1
00:00:01,000 --> 00:00:03,000
[speaking another language]
SRT
"$FFMPEG" -hide_banner -loglevel error -y \
-f lavfi -i "testsrc2=duration=5:size=320x180:rate=25" \
-f lavfi -i "sine=frequency=440:duration=5" \
-i "$OUT/forced.srt" \
-map 0:v -map 1:a -map 2:s \
-c:v libx264 -preset ultrafast -pix_fmt yuv420p \
-c:a aac -c:s srt \
-metadata:s:a:0 language=eng -metadata:s:s:0 language=eng \
-disposition:s:0 forced \
"$OUT/forcedsubs.mkv"
echo "generated: $OUT/forcedsubs.mkv"
fi

if [ ! -f "$OUT/multitrack.mkv" ]; then
cat > "$OUT/subs.srt" <<'SRT'
1
Expand Down
Loading
Loading