Shaka Player 파헤치기 - MPD 매니페스트에서 오디오 트랙은 어떻게 선정되는가

들어가며 — 여러 오디오 트랙 중 무엇이 먼저 재생되는가

DASH로 서비스되는 콘텐츠의 MPD 매니페스트를 열어보면, 하나의 영상에 여러 개의 오디오 트랙이 딸려 있는 경우가 흔합니다. 한국어와 영어, 5.1 채널과 2.0 채널, 감독 코멘터리 같은 대체 트랙까지 — 매니페스트는 "선택지"를 나열할 뿐, 무엇을 먼저 틀지는 말해주지 않습니다.

그 결정을 내리는 쪽은 플레이어입니다. Shaka Player는 파싱된 매니페스트에서 재생 가능한 변형(Variant) 목록을 받아, 그중 초기 재생에 쓸 오디오를 우선순위 규칙에 따라 골라냅니다. 이 글에서는 그 규칙이 코드 어디에 있고, 어떤 순서로 트랙을 걸러내는지를 따라가 봅니다. 특히 preferredAudioLanguage 옵션이 있을 때없을 때 동작이 어떻게 갈리는지를 중심으로 살펴보겠습니다.

INFO

Shaka Player에서 오디오와 비디오는 따로 노는 것이 아니라 Variant라는 한 쌍으로 묶여 다룹니다. 오디오 트랙을 고른다는 것은 결국 "어떤 Variant 집합을 초기 후보로 삼을 것인가"를 정하는 일입니다.

오디오 트랙 선정은 어디서 일어나는가

시작점은 PlayerchooseVariant_입니다. 이 메서드는 재생할 Variant를 고르기 위해 먼저 ABR 매니저에게 넘길 후보군을 갱신하는데, 그 갱신 과정이 updateAbrManagerVariants_입니다.

1// player.js — updateAbrManagerVariants_ (발췌)
2const playableVariants = shaka.util.StreamUtils.getPlayableVariants(
3    this.manifest_.variants);
4
5// Update the abr manager with newly filtered variants.
6const adaptationSet = this.currentAdaptationSetCriteria_.create(
7    playableVariants);
8const ret = this.abrManager_.setVariants(
9    Array.from(adaptationSet.values()), isLowLatency);

재생 가능한 전체 Variant 목록(playableVariants)을 currentAdaptationSetCriteria_.create()에 통째로 넘기고, 그 결과로 돌아온 AdaptationSet을 ABR 매니저에게 후보로 등록합니다. 즉 "어떤 오디오를 쓸지"를 실제로 판단하는 주체는 currentAdaptationSetCriteria_이고, 그 기본 구현이 바로 PreferenceBasedCriteria입니다.

1// player_configuration.js — 기본 설정
2adaptationSetCriteriaFactory:
3    (...args) => new shaka.media.PreferenceBasedCriteria(...args),

이름 그대로 선호도(preference) 기반으로 후보를 좁혀나가는 구현체입니다. 여기서부터가 오디오 트랙 우선순위의 심장부입니다.

PreferenceBasedCriteria — 우선순위의 심장부

create()가 하는 일은 크게 세 단계입니다.

1// preference_based_criteria.js — create()
2create(variants) {
3  const Class = shaka.media.PreferenceBasedCriteria;
4
5  // 1. 오디오 선호도 적용
6  let current = Class.applyAudioPreferences_(
7      variants, this.config_.preferredAudio,
8      this.config_.audioCodec, this.config_.activeAudioCodec);
9
10  // 2. 비디오 선호도 적용
11  current = Class.applyVideoPreferences_(
12      current, this.config_.preferredVideo);
13
14  // 3. 첫 번째 변형을 루트 삼아 AdaptationSet 조립
15  this.lastAdaptationSet_ = new shaka.media.AdaptationSet(current[0], current,
16      !supportsSmoothCodecTransitions);
17
18  return this.lastAdaptationSet_;
19}

먼저 오디오 선호도로 후보를 좁히고(applyAudioPreferences_), 그 결과에 비디오 선호도를 다시 적용한 뒤(applyVideoPreferences_), 남은 목록의 첫 번째 변형(current[0])을 기준점으로 삼아 AdaptationSet을 만듭니다. 이 글의 주제인 오디오 트랙은 1단계에서 결정되므로, applyAudioPreferences_를 자세히 들여다봅니다.

1// preference_based_criteria.js — applyAudioPreferences_ (발췌)
2static applyAudioPreferences_(
3    variants, preferredAudio, audioCodec, activeAudioCodec) {
4  const Class = shaka.media.PreferenceBasedCriteria;
5
6  for (const pref of preferredAudio) {
7    let candidates = variants;
8
9    if (pref.language) {
10      const byLanguage = Class.filterByLanguage_(candidates, pref.language);
11      if (byLanguage.length) {
12        candidates = byLanguage;
13      } else {
14        continue;
15      }
16    }
17
18    if (pref.role) { /* ... 역할로 필터 ... */ }
19    if (pref.label) { /* ... 라벨로 필터 ... */ }
20    if (pref.channelCount) { /* ... 채널 수로 필터 ... */ }
21    // ... codec, spatialAudio 필터 ...
22
23    if (candidates.length) {
24      return candidates;
25    }
26  }
27  // ... 아무것도 매칭되지 않았을 때의 폴백 (뒤에서 설명) ...
28}

구조를 읽는 열쇠는 두 가지입니다.

  • preferredAudio는 배열이고, 항목마다 우선순위가 있습니다. 앞선 항목이 후보를 하나라도 만들어내면 그 자리에서 return하고, 만들어내지 못하면 continue로 다음 항목으로 넘어갑니다. 즉 배열의 앞쪽일수록 우선순위가 높습니다.
  • 한 항목 안에서는 language → role → label → channelCount → codec → spatialAudio 순으로 필터가 AND 결합됩니다. 각 필터는 결과가 비면 continue로 해당 항목 전체를 포기합니다. 조건을 좁혀가다 하나라도 후보를 0개로 만들면 그 선호 항목은 실패로 간주하는 셈입니다.

그렇다면 이 preferredAudio 배열은 어디서 채워질까요? 우리가 흔히 쓰는 preferredAudioLanguage와는 어떻게 연결될까요?

preferredAudio 설정은 어디서 오는가

우리가 자주 쓰는 player.configure({ preferredAudioLanguage: 'ko' }) 형태의 옵션은 사실 레거시 필드입니다. Shaka는 설정을 받아들일 때 이 레거시 필드를 preferredAudio 배열로 변환합니다.

1// player.js — convertLegacyPreferences_ (발췌)
2const audioFields = {
3  'preferredAudioLanguage': 'language',
4  'preferredAudioRole': 'role',
5  'preferredAudioLabel': 'label',
6  'preferredAudioChannelCount': 'channelCount',
7  'preferSpatialAudio': 'spatialAudio',
8};
9const hasLegacyAudio = Object.keys(audioFields).some((f) => f in config);
10if (hasLegacyAudio) {
11  const pref = { language: '', role: '', label: '', channelCount: 0, codec: '' };
12  for (const [oldField, newProp] of Object.entries(audioFields)) {
13    if (oldField in config) {
14      warnLegacy(oldField, 'preferredAudio');
15      pref[newProp] = config[oldField];
16      delete config[oldField];
17    }
18  }
19  if (!config['preferredAudio']) {
20    config['preferredAudio'] = [pref];
21  }
22}

preferredAudioLanguage: 'ko'를 넘기면, 내부적으로는 preferredAudio: [{ language: 'ko', role: '', label: '', channelCount: 0, codec: '' }]라는 한 개짜리 배열로 바뀝니다. 앞 절에서 본 applyAudioPreferences_가 이 배열을 순회하는 것이고요.

그렇다면 아무 설정도 하지 않았을 때의 기본값은 무엇일까요?

1// player_configuration.js — 기본 preferredAudio
2preferredAudio: [{
3  language: '',
4  role: '',
5  label: '',
6  channelCount: 2,
7  codec: '',
8}],

기본값은 비어 있지 않습니다. language는 빈 문자열이지만 channelCount2로 설정되어 있습니다. 이 미묘한 차이가 뒤에서 "언어 옵션이 없을 때"의 동작을 이해하는 열쇠가 됩니다. 이제 두 경우로 나누어 살펴봅니다.

Case 1 — preferredAudioLanguage가 있을 때

preferredAudioLanguage: 'ko'를 설정하면 preferredAudio[0].language'ko'가 되고, applyAudioPreferences_의 첫 항목에서 pref.language가 참이 되어 filterByLanguage_가 호출됩니다.

1// preference_based_criteria.js — filterByLanguage_
2static filterByLanguage_(variants, preferredLanguage) {
3  const LanguageUtils = shaka.util.LanguageUtils;
4
5  const preferredLocale = LanguageUtils.normalize(preferredLanguage);
6
7  const closestLocale = LanguageUtils.findClosestLocale(
8      preferredLocale,
9      variants.map((variant) => LanguageUtils.getLocaleForVariant(variant)));
10
11  // 가까운 로케일이 하나도 없으면 빈 배열
12  if (!closestLocale) {
13    return [];
14  }
15
16  // 가장 가까운 로케일을 쓰는 변형만 추린다
17  return variants.filter((variant) => {
18    return closestLocale == LanguageUtils.getLocaleForVariant(variant);
19  });
20}

여기서 핵심은 정확히 일치하는 언어가 없어도 곧바로 포기하지 않는다는 점입니다. findClosestLocale은 선호 로케일과 "가장 가까운" 후보를 4단계 우선순위로 찾아냅니다.

1// language_utils.js — findClosestLocale (발췌)
2// Preference 1 - 정확히 일치. "en-US" == "en-US"
3for (const option of safeSearchSpace) {
4  if (option == safeTarget) { return option; }
5}
6// Preference 2 - 후보가 타깃의 부모. 선호 "en-US"에 대해 "en"
7for (const option of safeSearchSpace) {
8  if (LanguageUtils.isParentOf(option, safeTarget)) { return option; }
9}
10// Preference 3 - 후보가 타깃의 형제. 선호 "en-US"에 대해 "en-CA"
11for (const option of safeSearchSpace) {
12  if (LanguageUtils.isSiblingOf(option, safeTarget)) { return option; }
13}
14// Preference 4 - 후보가 타깃의 자식. 선호 "en"에 대해 "en-US"
15for (const option of safeSearchSpace) {
16  if (LanguageUtils.isParentOf(safeTarget, option)) { return option; }
17}
18return null; // 아무것도 못 찾음

정리하면 언어 매칭의 우선순위는 정확 일치 → 부모(더 일반적인 언어) → 형제(같은 언어의 다른 지역) → 자식(더 구체적인 지역) 순입니다. 예컨대 preferredAudioLanguage: 'en-US'인데 매니페스트에는 en, en-CA, ko만 있다면, en(부모)이 en-CA(형제)보다 먼저 선택됩니다.

비교에 앞서 두 문자열 모두 LanguageUtils.normalize로 정규화된다는 점도 중요합니다. normalize는 3-letter 코드를 2-letter로 바꾸고(예: korko), 언어는 소문자·지역은 대문자로 통일합니다. 덕분에 KO, kor, ko가 모두 같은 로케일로 취급됩니다.

또 한 가지, 변형의 로케일을 읽는 getLocaleForVariant에도 우선순위가 있습니다.

1// language_utils.js — getLocaleForVariant (발췌)
2// 우선순위: 1. Variant  2. Audio Stream  3. Video Stream
3if (variant.language) { return normalize(variant.language); }
4if (variant.audio && variant.audio.language) { return normalize(variant.audio.language); }
5if (variant.video && variant.video.language) { return normalize(variant.video.language); }
6return 'und'; // 어디에도 언어가 없으면 'und'(undetermined)

언어가 일치하는 변형들로 후보가 좁혀지고 나면, 같은 항목의 뒤쪽 필터(role, label, channelCount 등)가 이어서 적용됩니다. preferredAudioLanguage만 지정한 경우라면 그 뒤 필드는 모두 기본값(빈 문자열/0)이라 사실상 언어 필터만으로 후보가 확정됩니다.

Case 2 — preferredAudioLanguage가 없을 때

아무 언어도 지정하지 않으면 preferredAudio[0].language는 빈 문자열입니다. applyAudioPreferences_에서 if (pref.language) 조건이 거짓이 되어 언어 필터 자체가 건너뛰어집니다. 기본값에서 유일하게 유효한 조건은 channelCount: 2뿐이므로, 채널 수 필터만 적용되고 그 결과가 그대로 반환됩니다.

그런데 만약 그 하나 남은 조건마저 후보를 못 만들거나, 애초에 유효한 선호 항목이 하나도 없다면 어떻게 될까요? applyAudioPreferences_의 마지막 폴백 로직이 답을 줍니다.

1// preference_based_criteria.js — applyAudioPreferences_ 폴백부 (발췌)
2// 오디오 선호가 없거나 아무것도 매칭되지 않았을 때
3let current = variants;
4
5// (런타임 코덱 선호가 있으면 먼저 적용 — 생략)
6
7const byPrimary = current.filter((variant) => variant.primary);
8if (byPrimary.length) {
9  return byPrimary;
10}
11return current;

언어로 좁힐 수 없을 때의 우선순위는 이렇게 정리됩니다.

  1. primary 플래그가 붙은 변형이 있으면 그것을 선택. MPD 파서는 매니페스트에서 대표/기본으로 표시된 AdaptationSet을 primary로 마킹하는데, 이것이 "감독이 지정한 기본 트랙" 역할을 합니다.
  2. primary도 없으면 전체 목록을 그대로 반환. 이 경우 실질적으로는 목록의 첫 번째 변형이 선택됩니다.

두 번째 경우, 즉 사용자가 언어를 지정하지도 않았고 매니페스트에 primary 표시도 없다면 Shaka는 사실상 임의의 언어를 고른 것이며, 이를 로그로 경고합니다.

1// player.js (발췌)
2const hasPrimary = this.manifest_.variants.some((v) => v.primary);
3if (!this.config_.preferredAudio.length && !hasPrimary) {
4  shaka.log.warning('No preferred audio language set.  ' +
5      'We have chosen an arbitrary language initially');
6}
WARNING

여러 언어를 담은 콘텐츠에서 preferredAudioLanguage를 지정하지 않고, 매니페스트에도 primary 지정이 없다면, 어떤 언어가 처음 재생될지는 매니페스트에 나열된 순서에 좌우됩니다. 의도한 언어를 보장하려면 언어 옵션을 명시하는 편이 안전합니다.

첫 번째 변형이 곧 기준점

앞서 create()applyAudioPreferences_의 결과 중 첫 변형 current[0]을 AdaptationSet의 루트로 삼는다고 했습니다. 이 "첫 번째"라는 규칙이 왜 중요한지는 channelCount 필터를 보면 드러납니다.

1// preference_based_criteria.js — filterVariantsByAudioChannelCount_ (발췌)
2return variants.filter((variant) => {
3  // 원하는 채널 수보다 많은 변형은 제외
4  if (variant.audio && variant.audio.channelsCount &&
5      variant.audio.channelsCount > channelCount) {
6    return false;
7  }
8  return true;
9}).sort((v1, v2) => {
10  // 원하는 값에 가장 가까운(채널 수가 큰) 변형이 앞에 오도록 정렬
11  // AdaptationSet이 첫 변형을 기준으로 집합을 구성하므로 중요하다
12  if (!v1.audio && !v2.audio) { return 0; }
13  if (!v1.audio) { return -1; }
14  if (!v2.audio) { return 1; }
15  return (v2.audio.channelsCount || 0) - (v1.audio.channelsCount || 0);
16});

필터가 채널 수 기준으로 내림차순 정렬까지 하는 이유가 주석에 그대로 적혀 있습니다. AdaptationSet은 첫 변형을 루트로 삼고, 나머지 변형은 그 루트와 "적응 가능(adaptable)"할 때만 집합에 포함됩니다.

1// adaptation_set.js — areAdaptable (발췌)
2// 언어가 다르면 서로 적응하지 않는다
3if (a.language != b.language) {
4  return false;
5}

루트와 언어가 다른 변형은 아예 같은 AdaptationSet에 들어오지 못합니다. 결국 필터를 통과한 목록의 맨 앞 변형이 그 세션에서 재생될 오디오의 언어·채널 성격을 사실상 확정하는 셈입니다. 그래서 각 필터가 단순히 후보를 걸러내는 데 그치지 않고, "가장 바람직한 것을 맨 앞으로" 정렬하는 데까지 신경 쓰는 것입니다.

HLS(M3U8)에서도 선정 방식은 동일하다

지금까지 살펴본 로직은 모두 DASH의 MPD를 전제로 이야기했지만, 사실 HLS의 M3U8을 재생하더라도 오디오 트랙 선정 방식은 완전히 똑같습니다. 이유는 선정 로직이 파서의 산출물이 아니라 정규화된 공통 모델 위에서만 동작하기 때문입니다.

PreferenceBasedCriteriaLanguageUtils 어디에도 manifest_나 DASH 전용 필드에 대한 참조가 없습니다. 이들이 다루는 것은 오직 shaka.extern.Variant 객체이고, 그 안의 language / primary / audio.channelsCount / audio.roles / audio.label / audio.codecs 같은 필드뿐입니다. DASH 파서HLS 파서든 최종적으로 이 동일한 인터페이스로 결과를 빚어내므로, 그 위에 얹힌 선정 로직은 입력이 어떤 형식에서 왔는지 알 필요가 없습니다.

실제로 HLS 파서도 같은 필드를 채웁니다. 다만 값의 출처만 형식별로 다릅니다.

1// hls_parser.js — EXT-X-MEDIA의 DEFAULT 속성이 primary가 된다
2const primary = defaultAttrValue == 'YES';
  • HLS: EXT-X-MEDIADEFAULT=YESvariant.primary = true
  • DASH: 기본 AdaptationSet / Role=main 지정 → variant.primary = true

language 역시 HLS는 EXT-X-MEDIALANGUAGE 속성에서, DASH는 AdaptationSet의 lang 속성에서 읽어오지만, 파싱을 마치고 나면 둘 다 같은 Variant.language로 수렴합니다. 그래서 앞서 본 Case 1(preferredAudioLanguage 매칭)도, Case 2(폴백 → primary → 임의 선택)도 M3U8에서 한 치의 차이 없이 그대로 흘러갑니다.

TIP

"어떤 매니페스트 형식을 파싱하든 동일한 인터페이스로 변환한다"는 설계가 이런 재사용을 가능하게 합니다. 파서는 형식별 차이를 흡수하는 얇은 층에 그치고, 트랙 선정·ABR·DRM 같은 상위 로직은 형식과 무관하게 한 벌만 존재하는 것이죠.

정리 — 우선순위 결정 트리 한눈에 보기

MPD 매니페스트에서 Shaka Player가 오디오 트랙을 선정하는 규칙을 한 흐름으로 정리하면 다음과 같습니다.

  1. chooseVariant_updateAbrManagerVariants_가 재생 가능한 전체 Variant를 PreferenceBasedCriteria.create()에 넘긴다.
  2. applyAudioPreferences_preferredAudio 배열을 앞에서부터 순회한다. 각 항목은 language → role → label → channelCount → codec → spatialAudio 순으로 AND 필터를 걸고, 후보를 하나라도 남기면 그 자리에서 확정한다.
  3. preferredAudioLanguage가 있으면findClosestLocale정확 → 부모 → 형제 → 자식 순의 언어 매칭을 수행해 가장 가까운 로케일의 변형만 남긴다.
  4. preferredAudioLanguage가 없으면 → 언어 필터는 건너뛰고, 기본 channelCount: 2 조건만 적용된다. 그마저 좁힐 수 없으면 → primary 변형 → 그것도 없으면 → 매니페스트 순서상 첫 변형(임의 선택 + 경고).
  5. 확정된 목록의 첫 번째 변형이 AdaptationSet의 루트가 되어, 그 언어·채널과 호환되는 변형들만 최종 후보로 묶인다.

핵심은 이렇습니다. 의도한 오디오 트랙을 확실히 재생하고 싶다면 preferredAudioLanguage(혹은 최신 preferredAudio)를 명시하라. 지정하지 않으면 매니페스트의 primary 표시, 그마저 없으면 트랙 나열 순서라는, 콘텐츠 제작자 쪽 사정에 재생 언어를 맡기게 됩니다.