유니티 WebGL 안드로이드 백그라운드 진입 시 오디오 OFF 3초 지연

안녕하세요.
유니티 WebGL로 만든 앱을 앱인토스에 연동하고 있습니다.
백그라운드 진입을 웹뷰 이벤트로 감지해서 오디오를 끄는 처리를 했는데요.

그런데 iOS에서는 지연이 없지만 안드로이드에서는 백그라운드 진입 후 약 3초 후에야 오디오가 꺼지는 현상이 발생합니다.
발생한 이벤트는 잘 감지되고 있는 것을 확인했습니다.

현재 가로모드 앱의 경우, 광고를 보면 광고화면이 화면 전체를 덮지 못해서 오디오가 저절로 꺼지지 않는 현상이 있습니다.
그래서 별도로 웹뷰 focus, blur 등 이벤트를 감지해서 수동으로 오디오를 끄는 처리를 하고 있는데요.
이때는 오디오가 지연없이 잘 꺼지고 있습니다.

단지, 안드로이드 홈버튼을 눌러서 앱을 백그라운드로 진입시켰을 때만 오디오 OFF가 3초 지연되고 있습니다.

유니티로 만든 WebGL 앱이 안드로이드 백그라운드로 진입 시 오디오가 3초 후 꺼지는 문제를 해결할 방법이 있을까요?
여러 가지 방법을 다 동원해봤지만 아직 해결하지 못하여 이렇게 도움을 요청 드립니다.

아래는 제가 구현한 코드의 일부입니다.

function __suspendCtx(ctx) {
  try { if (ctx && ctx.state !== 'closed' && 
      ctx.state !== 'suspended' && 
      typeof ctx.suspend === 'function') return ctx.suspend(); } catch (_) {}
}
function __resumeCtx(ctx) {
  try { if (ctx && ctx.state !== 'closed' && 
      ctx.state !== 'running' && 
      typeof ctx.resume === 'function') return ctx.resume(); } catch (_) {}
}


function __setGlobalAudioMuted(muted) {
  muted = !!muted;

  // <audio>/<video> 태그
  try {
    var media = document.querySelectorAll ? document.querySelectorAll('audio, video') : [];
    for (var i = 0; i < media.length; i++) {
      try { media[i].muted = muted; } catch (_) {}
      if (!muted && typeof media[i].play === 'function') {
        try {
          var p = media[i].play();
          if (p && typeof p.catch === 'function') p.catch(function () { });
        } catch (_) {}
      }
    }
  } catch (_) {}

  // WebAudio 컨텍스트 (Unity 포함)
  __collectUnityCtx();
  try {
    if (__ctxSet.forEach) {
      var ops = [];
      __ctxSet.forEach(function (ctx) { ops.push(muted ? __suspendCtx(ctx) : __resumeCtx(ctx)); });
      if (typeof Promise !== 'undefined') {
        (Promise.allSettled ? Promise.allSettled(ops) : Promise.all(ops)).catch(function () { });
      }
    } else {
      for (var j = 0; j < __ctxSet.length; j++) 
        muted ? __suspendCtx(__ctxSet[j]) : __resumeCtx(__ctxSet[j]);
    }
  } catch (_) {}
}


function send(state, evt) {
  var hidden = state === 'hidden';

  try { __setGlobalAudioMuted(hidden); } catch (_) {} // 오디오 ON 또는 OFF 처리

  var payload = JSON.stringify({
    state: state,
    eventType: evt, 
    hidden: hidden,
    ts: Date.now()
  });
  try { SendMessage(goName, method, payload); } catch (e) {}
}


function onVisibility() { send(document.hidden ? 'hidden' : 'visible', 'visibilitychange'); }
function onPageHide()   { send('hidden',  'pagehide'); }
function onPageShow()   { send('visible', 'pageshow'); }
function onBlur()       { send(document.hidden ? 'hidden' : 'visible', 'blur'); }
function onFocus()      { send(document.hidden ? 'hidden' : 'visible', 'focus'); }
function onFreeze()     { send('hidden', 'freeze'); }


document.addEventListener('visibilitychange', onVisibility, opts);
window.addEventListener('pagehide', onPageHide, opts);
window.addEventListener('pageshow', onPageShow, opts);
window.addEventListener('blur', onBlur, opts);
window.addEventListener('focus', onFocus, opts);
window.addEventListener('freeze', onFreeze, opts);

저 문제해결에 대한 답변은 아니지만 (죄송합니다.. (__ );
혹시 __collectUnityCtx 함수 구현부 공유 해주실수있을까요?
저희도 개발중에 AudioContext 관련해서 문제가 많이 생겨서, 공유해주시면 도움이 많이 될거같습니다.

전체 코드 첨부 드립니다. @dodreamjy

mergeInto(LibraryManager.library, {
AIT_StartObserveVisibility: function (goPtr, methodPtr) {
var goName = UTF8ToString(goPtr);
var method = UTF8ToString(methodPtr);

// 이전 구독 해제
if (typeof window.__tossVisUnsub === 'function') {
  try { window.__tossVisUnsub(); } catch (e) {}
  window.__tossVisUnsub = null;
}

// ===== 오디오 제어 유틸 =====
var AC = window.AudioContext || window.webkitAudioContext;
var __ctxSet = typeof Set !== 'undefined' ? new Set() : [];

function __trackCtx(ctx) {
  if (!ctx) return;
  try { __ctxSet.add ? __ctxSet.add(ctx) : __ctxSet.push(ctx); } catch (_) {}
}

// 새로 생성되는 AudioContext 래핑(1회)
try {
  if (AC && !window.__AIT_AC_WRAPPED__) {
    var OrigAC = AC;
    var WrappedAC = function () {
      var c = new (Function.prototype.bind.apply(OrigAC, [null].concat([].slice.call(arguments))));
      __trackCtx(c);
      return c;
    };
    WrappedAC.prototype = OrigAC.prototype;
    Object.defineProperty(window, AC === window.AudioContext ? 'AudioContext' : 'webkitAudioContext', {
      configurable: true, writable: true, value: WrappedAC
    });
    window.__AIT_AC_WRAPPED__ = true;
  }
} catch (_) {}

function __collectUnityCtx() {
  try {
    var M = (window.unityInstance && window.unityInstance.Module) || window.Module || {};
    var uc =
         M.webAudioContext ||
         M.audioContext ||
         M.ctx ||
         M._audioContext ||
         // Unity(WebGL/Emscripten)에서 가장 흔한 위치들
         (M.SDL && M.SDL.audioContext) ||
         (M.SDL2 && M.SDL2.audioContext);
    if (uc) __trackCtx(uc);
  } catch (_) {}
}

function __suspendCtx(ctx) {
  try { if (ctx && ctx.state !== 'closed' && ctx.state !== 'suspended' && typeof ctx.suspend === 'function') return ctx.suspend(); } catch (_) {}
}
function __resumeCtx(ctx) {
  try { if (ctx && ctx.state !== 'closed' && ctx.state !== 'running' && typeof ctx.resume === 'function') return ctx.resume(); } catch (_) {}
}

/**
 * 전역 음소거/해제: <audio>/<video> + WebAudio
 */
function __setGlobalAudioMuted(muted) {
  muted = !!muted;

  // <audio>/<video> 태그
  try {
    var media = document.querySelectorAll ? document.querySelectorAll('audio, video') : [];
    for (var i = 0; i < media.length; i++) {
      try { media[i].muted = muted; } catch (_) {}
      if (!muted && typeof media[i].play === 'function') {
        try {
          var p = media[i].play();
          if (p && typeof p.catch === 'function') p.catch(function () { });
        } catch (_) {}
      }
    }
  } catch (_) {}

  // WebAudio 컨텍스트 (Unity 포함)
  __collectUnityCtx();
  try {
    if (__ctxSet.forEach) {
      var ops = [];
      __ctxSet.forEach(function (ctx) { ops.push(muted ? __suspendCtx(ctx) : __resumeCtx(ctx)); });
      if (typeof Promise !== 'undefined') {
        (Promise.allSettled ? Promise.allSettled(ops) : Promise.all(ops)).catch(function () { });
      }
    } else {
      for (var j = 0; j < __ctxSet.length; j++) muted ? __suspendCtx(__ctxSet[j]) : __resumeCtx(__ctxSet[j]);
    }
  } catch (_) {}
}

function send(state, evt) {
  var hidden = state === 'hidden';

  try { __setGlobalAudioMuted(hidden); } catch (_) {}
  
  var payload = JSON.stringify({
    state: state,                        // "visible" | "hidden"
    eventType: evt,                      // "visibilitychange" | "pagehide" | "pageshow" | "blur" | "focus" | "init"
    hidden: hidden,
    ts: Date.now()
  });
  try { SendMessage(goName, method, payload); } catch (e) {}
}

// 캡처 단계로 가장 먼저 잡는다(숨기기 직전에도 최대한 빨리 유니티 호출)
var opts = { capture: true, passive: true };

function onVisibility() { send(document.hidden ? 'hidden' : 'visible', 'visibilitychange'); }
function onPageHide()   { send('hidden',  'pagehide'); }
function onPageShow()   { send('visible', 'pageshow'); }
function onBlur()       { send(document.hidden ? 'hidden' : 'visible', 'blur'); }
function onFocus()      { send(document.hidden ? 'hidden' : 'visible', 'focus'); }
function onFreeze()     { send('hidden', 'freeze'); }


document.addEventListener('visibilitychange', function () {
  if (document.hidden) {
    try { __setGlobalAudioMuted(true); } catch(_){}
    send('hidden', 'visibilitychange');
  } else {
    try { __setGlobalAudioMuted(false); } catch(_){}
    send('visible', 'visibilitychange');
  }
}, { capture: true, passive: true });

window.addEventListener('pagehide', function () {
  try { __setGlobalAudioMuted(true); } catch(_){}
  send('hidden', 'pagehide');
}, { capture: true, passive: true });

window.addEventListener('pageshow', function () {
  try { __setGlobalAudioMuted(false); } catch(_){}
  send('visible', 'pageshow');
}, { capture: true, passive: true });

window.addEventListener('blur', function () {
  try { __setGlobalAudioMuted(true); } catch(_){}
  send('hidden', 'blur');
}, { capture: true, passive: true });

window.addEventListener('focus', function () {
  try { __setGlobalAudioMuted(false); } catch(_){}
  send('visible', 'focus');
}, { capture: true, passive: true });

window.addEventListener('freeze', function () {
  try { __setGlobalAudioMuted(true); } catch(_){}
  send('hidden', 'freeze');
}, { capture: true, passive: true });


//
// document.addEventListener('visibilitychange', onVisibility, opts);
// window.addEventListener('pagehide', onPageHide, opts);
// window.addEventListener('pageshow', onPageShow, opts);
// window.addEventListener('blur', onBlur, opts);
// window.addEventListener('focus', onFocus, opts);
// window.addEventListener('freeze', onFreeze, opts);

// 초기 1회 상태 통지(필요 시)
send(document.hidden ? 'hidden' : 'visible', 'init');

window.__tossVisUnsub = function () {
  document.removeEventListener('visibilitychange', onVisibility, opts);
  window.removeEventListener('pagehide', onPageHide, opts);
  window.removeEventListener('pageshow', onPageShow, opts);
  window.removeEventListener('blur', onBlur, opts);
  window.removeEventListener('focus', onFocus, opts);
  window.removeEventListener('freeze', onFreeze, opts);
};

window.__AIT_forceMute = function (on) {
  try { __setGlobalAudioMuted(!!on); } catch (_) {}
};

// 6) rAF Heartbeat 휴리스틱: 렌더링이 뚝 끊기면 즉시 음소거
(function(){
  var lastRAF = performance.now();
  function rafTick(){ lastRAF = performance.now(); requestAnimationFrame(rafTick); }
  requestAnimationFrame(rafTick);

  var hb = setInterval(function(){
    // 화면이 보이는 상태인데 rAF가 300ms 이상 끊기면 '사실상 백그라운드'로 판단
    if ((document.visibilityState || 'visible') === 'visible') {
      var gap = performance.now() - lastRAF;
      if (gap > 300) { // 0.3초 이상 프레임 정지 → 바로 음소거
        try { window.__AIT_forceMute(true); } catch (_){}
        send('hidden', 'heartbeat');
      }
    }
  }, 200);

  // 필요 시 해제: window.__tossVisUnsub 안에 clearInterval(hb) 추가
})();

}
});

1개의 좋아요

너무 감사합니다! 큰도움이 될거같습니다

2개의 좋아요

이 이슈는 지원이 어려운 내용일까요?
이 이슈때문에 출시가 반려된 상황입니다.

@Dylan

안녕하세요 :slight_smile:
테스트시 잠깐의 지연은 있는데, 3초까지는 지연이 안되는듯 해요 :thinking:

media[i].muted = muted; 후

if (muted) {
    element.pause()
    addLog(`미디어 ${i} 즉시 일시정지 (백그라운드 대응)`)
}

처리해도 동일할까요 ?

+) Promise 없이 ctx.suspend() 처리가 가능할지용 ?

감사합니다. Dylan님.

js 코드에는 문제가 없었고, 오히려 유니티 코드의 오디오 제어의 문제였습니다.

자세히 답변해주셔서 다시 한 번 감사 드립니다. @Dylan

혹시 저와 같은 문제를 겪으시는 분들을 위해 댓글 남깁니다.

저는 초기에 오디오 Pause를 AudioListener.volume = 0; 이렇게 처리했었는데, 안드로이드에서 3초 정도 지연이 발생했었습니다.

아래의 코드대로 처리하면 안드로이드에서 홈버튼 등을 눌러서 백그라운드로 진입했을 때 오디오가 지연없이 중지됩니다.

// jslib 파일
mergeInto(LibraryManager.library, {
AIT_StartObserveVisibility: function (goPtr, methodPtr) {
var goName = UTF8ToString(goPtr);
var method = UTF8ToString(methodPtr);

// 이전 구독 해제
if (typeof window.__tossVisUnsub === 'function') {
  try { window.__tossVisUnsub(); } catch (e) {}
  window.__tossVisUnsub = null;
}

function send(state, evt) {
  var payload = JSON.stringify({
    state: state, // "visible" | "hidden"
    eventType: evt, // "visibilitychange" | "pagehide" | "pageshow" | "blur" | "focus" | "init"
    hidden: state === 'hidden',
    ts: Date.now()
  });
  try { SendMessage(goName, method, payload); } catch (e) {}
}

// 캡처 단계로 가장 먼저 잡는다(숨기기 직전에도 최대한 빨리 유니티 호출)
var opts = { capture: true, passive: true };

function onVisibility() { send(document.hidden ? 'hidden' : 'visible', 'visibilitychange'); }
function onPageHide()   { send('hidden',  'pagehide'); }
function onPageShow()   { send('visible', 'pageshow'); }
function onBlur()       { send(document.hidden ? 'hidden' : 'visible', 'blur'); }
function onFocus()      { send(document.hidden ? 'hidden' : 'visible', 'focus'); }
function onFreeze()     { send('hidden', 'freeze'); }

// 이벤트 구독
document.addEventListener('visibilitychange', onVisibility, opts);
window.addEventListener('pagehide', onPageHide, opts);
window.addEventListener('pageshow', onPageShow, opts);
window.addEventListener('blur', onBlur, opts);
window.addEventListener('focus', onFocus, opts);
window.addEventListener('freeze', onFreeze, opts);

// 초기 1회 상태 통지(필요 시)
send(document.hidden ? 'hidden' : 'visible', 'init');

// 이벤트 구독 해제 함수 정의
window.__tossVisUnsub = function () {
  document.removeEventListener('visibilitychange', onVisibility, opts);
  window.removeEventListener('pagehide', onPageHide, opts);
  window.removeEventListener('pageshow', onPageShow, opts);
  window.removeEventListener('blur', onBlur, opts);
  window.removeEventListener('focus', onFocus, opts);
  window.removeEventListener('freeze', onFreeze, opts);
};

}
});

// 유니티 코드
public void OnFocus(bool isFocus)
{
if (isFocus) // Foreground
{
// 사운드 Resume
AudioListener.pause = false;

    // 게임 Resume
    Time.timeScale = _originTimeScale;
}
else // Background
{
    // 사운드 Pause
    AudioListener.pause = true;
    
    // 게임 Pause
    _originTimeScale = Time.timeScale;
    Time.timeScale = 0;
}

}

3개의 좋아요
mergeInto(LibraryManager.library, {
  AIT_StartObserveVisibility: function (goPtr, methodPtr) {
    var goName = UTF8ToString(goPtr);
    var method = UTF8ToString(methodPtr);

    // 이전 구독 해제
    if (typeof window.__tossVisUnsub === 'function') {
      try { window.__tossVisUnsub(); } catch (e) {}
      window.__tossVisUnsub = null;
    }
    
    function send(state, evt) {
      var payload = JSON.stringify({
        state: state, // "visible" | "hidden"
        eventType: evt, // "visibilitychange" | "pagehide" | "pageshow" | "blur" | "focus" | "init"
        hidden: state === 'hidden',
        ts: Date.now()
      });
      try { SendMessage(goName, method, payload); } catch (e) {}
    }

    // 캡처 단계로 가장 먼저 잡는다(숨기기 직전에도 최대한 빨리 유니티 호출)
    var opts = { capture: true, passive: true };

    function onVisibility() { send(document.hidden ? 'hidden' : 'visible', 'visibilitychange'); }
    function onPageHide()   { send('hidden',  'pagehide'); }
    function onPageShow()   { send('visible', 'pageshow'); }
    function onBlur()       { send(document.hidden ? 'hidden' : 'visible', 'blur'); }
    function onFocus()      { send(document.hidden ? 'hidden' : 'visible', 'focus'); }
    function onFreeze()     { send('hidden', 'freeze'); }
    
    // 이벤트 구독
    document.addEventListener('visibilitychange', onVisibility, opts);
    window.addEventListener('pagehide', onPageHide, opts);
    window.addEventListener('pageshow', onPageShow, opts);
    window.addEventListener('blur', onBlur, opts);
    window.addEventListener('focus', onFocus, opts);
    window.addEventListener('freeze', onFreeze, opts);

    // 초기 1회 상태 통지(필요 시)
    send(document.hidden ? 'hidden' : 'visible', 'init');

    // 이벤트 구독 해제 함수 정의
    window.__tossVisUnsub = function () {
      document.removeEventListener('visibilitychange', onVisibility, opts);
      window.removeEventListener('pagehide', onPageHide, opts);
      window.removeEventListener('pageshow', onPageShow, opts);
      window.removeEventListener('blur', onBlur, opts);
      window.removeEventListener('focus', onFocus, opts);
      window.removeEventListener('freeze', onFreeze, opts);
    };
  }
});

// C# 코드
public void OnFocus(bool isFocus)
{
	DebugUtil.Log($"[SystemManager] OnFocus({isFocus})");
	
	if (isFocus) // Foreground
	{
		// 사운드 Resume
		AudioListener.pause = false;
		
		// 게임 Resume
		Time.timeScale = _originTimeScale;
		
		_onForeground?.Invoke();
	}
	else // Background
	{
		// 사운드 Pause
		AudioListener.pause = true;
		
		// 게임 Pause
		_originTimeScale = Time.timeScale;
		Time.timeScale = 0;
		
		_onBackground?.Invoke();
	}
}       
4개의 좋아요

남겨주신 코드 보고 도움 많이 받았습니다.
다만 AudioListener.pause 로 오디오 제어 시 앱이 백그라운드에 있는 동안 백색소음같은 소음이 들리는데 이런 현상 없으셨는지 궁금합니다.

수정) 좀 기다리면 백색소음도 사라지네요.. 그냥 사용해도 될 것 같습니다.

코드 남겨주셔서 감사합니다 ㅠㅠ 저도 이거땜에 계속 찾고 있었는데.. 당장 해봐야겠네요!!