우리 채팅 서비스는 TTS 기능을 가지고 있다. 상대방의 채팅을 우리말로 번역한 뒤 해당 텍스트의 TTS 파일을 받아 오디오로 재생시켜주는 기능이다. 이 기능을 개발하던 중 재밌었던 경험이 있어 이를 공유하려 한다.
맨 처음 채팅 페이지에 들어왔을 때, 사용자가 아무것도 만지지 않은 상태에서 새 말풍선이 생겼다고 해 보자. 새 말풍선이 생겼을 때 해당 말풍선 내용이 자동으로 TTS 발화되도록 하려 한다.
// ChatView.vue
<template>
<ChatBox
v-for="chat in chatList"
:key="chat.id"
:is-playing="chat.id === isPlayingChatId"
:tts-file="chat.tts_file"
/>
</template>
<script lang="ts">
const isPlayingChatId = ref<string | null>(null)
</script>
// ChatBox.vue
<template>
<audio ref="audioEl" :src="ttsFile" />
</template>
<script lang="ts">
const { isPlaying, ttsFile } = toRefs(props)
const audioEl = ref<HTMLAudioElement | null>(null)
watch(isPlaying, async (cur) => {
if (!cur) {
if (audioEl.value) {
audioEl.value.pause()
audioEl.value.currentTime = 0
}
return
}
if (!audioEl.value) return
try {
await audioEl.value.play()
} catch (e) {
console.error(e)
setTimeout(() => {
emit('audioEnded')
}, 2000)
}
},
{
immediate: true,
},
)
</script>
각 채팅 말풍선 컴포넌트는 isPlaying
prop을 받아 해당 값이 true면 오디오 파일을 재생한다. 만약 false면 실행을 중지하고 다시 오디오 파일을 처음부터 재생할 수 있도록 currentTime을 초기화한다. 만약 1) 새로 말풍선이 생성되었고, 2) 현재 발화 중인 말풍선이 없을 때 isPlayingChatId
의 값이 새로운 말풍선의 id 값으로 교체되면서 자동으로 말풍선의 오디오가 발화되는 것이다.
자, 이제 새 채팅이 생성되면 TTS가 발화되어야 한다. 하지만 이상하게 자동으로 오디오가 재생되지 않는다. 그 대신 다음과 같은 에러가 발생한다.
NotAllowedError: The request is not allowed by the user agent or the platform in the current context, possibly because the user denied permission.
이는 브라우저에서 미디어의 자동 재생을 일부러 막고 있기 때문이다. 왜...?
자동재생(autoplay)
자동재생은 사용자의 의도적인 재생 요청 없이 오디오를 재생하는 모든 기능을 통틀어 이야기한다. HTML autoplay
속성을 통해 재생하거나 사용자 입력 이벤트를 제외한 코드에서 자바스크립트를 통해 재생하는 경우가 자동 재생에 포함된다. 우리 서비스에서 새 말풍선이 추가되었을 때 해당 말풍선의 tts가 자동으로 발화하도록 하는 기능도 자바스크립트만을 통해 미디어를 재생하는 방식이므로 자동재생이라 보면 된다.
// autoplay 속성을 통한 자동 재생
<audio src="/example.mp3" autoplay />
// play() 메서드를 통한 자동 재생
audioElement.play();
자동재생 성공 여부를 알 수 있는 법
특히 저 HTMLMediaElement.play() 메서드는 자바스크립트를 통해 미디어를 재생시키는 역할을 한다. 프로미스를 반환하는데, 미디어가 성공적으로 재생되면 해당 프로미스가 resolve되고, 그렇지 않으면 reject된다. play()
메서드의 반환 프로미스가 reject 되었는지 여부를 통해 오디오가 제대로 재생되었는지 여부를 판단할 수 있다.
try {
// 자동 재생 성공
await audioElement.play();
} catch(err) {
// 자동 재생 실패
console.error(err)
}
앞선 상황 역시 마찬가지인데, 자동재생 권한 정책이 사용 중지된 경우 사용자 동작 없이 play()
를 호출하면 NotAllowedError
에러와 함께 프로미스가 거부된다(출처). 딱 우리 상황과 동일하다.
자동재생 권한 얻는 법
그렇다면 자동재생 권한이 왜 문제가 될까? 자동 재생을 브라우저에서 의도적으로 막는 이유는 예상하지 못한 미디어가 재생되었을 때 사용자가 불쾌감과 당혹감을 느낄 수 있기 때문이다. 그렇기 때문에 특정 조건이 아닌 이상 비디오나 오디오 등의 미디어는 자동으로 재생되지 않는다. 하지만 우리는 그럼에도 불구하고 자동재생 권한을 얻어야 하므로 그 방법을 알아보자. webkit 공식 문서에서 오디오 혹은 비디오 재생에 대한 언급을 찾아보면 다음과 같다.
Websites should assume any use of
<video>
or<audio>
requires a user gesture click to play.
즉 브라우저에서 오디오 및 비디오의 재생을 위해서는 사용자와 미디어 콘텐츠 간의 클릭이나 터치와 같은 상호작용이 있어야 한다는 뜻이다. MDN에서는 아래의 조건 중 하나라도 만족하지 못한다면 사이트가 자동 재생을 허용하지 않는다고 한다. 그렇게 되면 autoplay
속성과 play()
메서드도 무의미하다.
자동 재생이 가능한 조건
<iframe>
내의 document에서 자동 재생이 허용된 경우브라우저에서 미디어 콘텐츠 자동 재생은 꽤나 널리 알려진 이슈인데, MDN에서는 아예 미디어 자동 재생에 대한 아티클도 따로 있으니 참고하면 좋을 것 같다.
자동으로 말풍선의 오디오 파일을 재생하기 전에 먼저 사용자의 의도적인 상호작용이 있어야 한다는 것을 알게 되었다. 팀원들 간의 논의 끝에 정책을 변경하여 페이지 접속 시 초기 상태는 음소거된 상태로 두고, 사용자가 만약 tts 발화 오디오를 듣고 싶다면 직접 음소거를 해제하도록 하였다. 이 방식은 다음과 같이 크롬 개발자 문서에서도 추천하는 방식이다.
사용자 참여를 유도하는 효과적인 방법 중 하나는 음소거된 자동재생을 사용하여 사용자가 음소거를 해제하도록 하는 것입니다. - Autoplay policy in Chrome
음소거 상태를 제어하기 위해 isMuted
상태를 만들어 채팅 말풍선의 muted 상태를 컨트롤하도록 하였다. 중요한 것은, isMuted
의 초기값은 false로 두어 사용자로 하여금 직접 값을 변경하도록 해야 한다는 것이다. 사용자가 음소거를 직접 해제함으로써 사용자와 <audio>
요소 간의 상호작용이 일어났으므로, 새로 말풍선이 생성되면 자동으로 재생된다.
// ChatView.vue
<template>
<ChatBox
v-for="chat in chatList"
:key="chat.id"
:is-muted="isMuted" // props 추가
:is-playing="chat.id === isPlayingChatId"
:tts-file="chat.tts_file"
/>
<button @click="toggleIsMuted">{{ isMuted ? 'On' : 'Off' }}</button>
</template>
<script lang="ts">
const isPlayingChatId = ref<string | null>(null)
const isMuted = ref(false) // 초기값은 false
const toggleIsMuted = () => {
isMuted.value = !isMuted.value
}
</script>
// ChatBox.vue
<template>
<audio ref="audioEl" :src="ttsFile" :muted="isMuted" />
</template>
<script lang="ts">
const { isMuted, isPlaying, ttsFile } = toRefs(props)
const audioEl = ref<HTMLAudioElement | null>(null)
</script>
이제 제대로 작동되는 것으로 보여서 문제가 끝난 줄 알았는데…
Safari IOS와 삼성 인터넷에서의 문제
모바일에서 문제가 발생했다. 맨 처음 음소거를 해제한 상태에서 말풍선이 생성되면 오디오가 잘 재생되는데, 재생이 종료된 상태에서 조금 시간이 지난 후 다시 새 말풍선이 추가되면 똑같이 NotAllowedError
가 발생하는 것이다. 웹 크롬, 웹 사파리, 모바일 크롬에서는 위의 이슈가 발생하지 않는다. 모바일 사파리와 삼성 인터넷에서만 문제가 되는데, 이를 보면 모바일이 자동 재생에 대한 권한을 웹보다 좀 더 빡세게 관리하는 듯하다.
아마 도중에 오디오 재생이 중지되면 맨 처음 음소거를 해제하면서 얻었던 자동 재생 권한이 초기화되는 듯하다. 물론 사용자가 다시 음소거 버튼을 껐다가 켜면 권한을 얻을 수는 있겠지만 사용자 입장에서 오디오가 끊길 때마다 계속 버튼을 누를 수는 없는 노릇이다. 따라서 맨 처음 얻었던 권한을 계속해서 유지할 수 있는 방법을 찾는 것이 최선이라는 판단을 내렸다.
배경 오디오를 공회전시키기
자동 재생 권한이 끊기지 않고 계속 유지되기 위해 필요한 가장 쉬운 방법은, 맨 처음 권한을 획득한 후부터 오디오 컨텐츠가 계속해서 동작하게 하는 것이다. 그렇다면 채팅 말풍선의 오디오 데이터와는 별개로 배경에서 늘 재생되는 배경 오디오를 만들면 어떨까? 화이트 노이즈를 담은 오디오 파일을 생성해 더미 오디오 요소를 만들고, loop
속성을 통해 항상 재생시키도록 해 보자.
// ChatView.vue
<template>
<ChatBox
v-for="chat in chatList"
:key="chat.id"
:is-muted="isMuted" // props 추가
:is-playing="chat.id === isPlayingChatId"
:tts-file="chat.tts_file"
/>
<button @click="toggleIsMuted">{{ isMuted ? 'On' : 'Off' }}</button>
<audio ref="dummyAudioEl" :src="dummyAudioFileUrl" loop />
</template>
<script lang="ts">
const dummyAudioEl = ref<HTMLAudioElement | null>(null)
const dummyAudioFileUrl = '화이트_노이즈_오디오_파일'
const toggleToMuteAudio = async (): Promise<void> => {
isMuted.value = !isMuted.value
if (dummyAudioEl.value === null) return
if (isMuted.value) {
dummyAudioEl.value.pause()
} else {
await dummyAudioEl.value.play()
}
}
</script>
사용자가 음소거를 해제하면 더미 오디오도 같이 재생된다. loop
속성을 가지고 있으니 다시 음소거를 해 주기 전까지는 정지되지 않고 계속 재생된다. 한 번 자동 재생 권한을 획득한 상태에서 늘 오디오가 재생되고 있으니, 말풍선 TTS 오디오가 정지되어도 권한이 유지될 것이라 생각했다.
하지만 이렇게 해도 문제는 해결되지 않았고, 동일한 에러가 발생했다. 말풍선 오디오가 정지되면 더미 오디오 재생과는 상관 없이 자동 재생 권한도 초기화되는 것이다.
loop 대신 ended 이벤트를 받아 핸들링하기
포기하던 찰나, 같은 팀원 분께서 loop
속성을 사용하지 않고 더미 오디오 재생이 끝날 때마다 다시 플레이하도록 코드를 수정해 주셨다. 그러니 기적적으로 에러가 해결되었다!
// ChatView.vue
<template>
<ChatBox
v-for="chat in chatList"
:key="chat.id"
:is-muted="isMuted" // props 추가
:is-playing="chat.id === isPlayingChatId"
:tts-file="chat.tts_file"
/>
<button @click="toggleIsMuted">{{ isMuted ? 'On' : 'Off' }}</button>
<audio
ref="dummyAudioEl"
:src="dummyAudioFileUrl"
@ended="handleDummyAudioEnded"
/>
</template>
<script lang="ts">
const dummyAudioEl = ref<HTMLAudioElement | null>(null)
const dummyAudioFileUrl = '화이트_노이즈_오디오_파일'
const toggleToMuteAudio = async (): Promise<void> => {
isMuted.value = !isMuted.value
if (dummyAudioEl.value === null) return
if (isMuted.value) {
dummyAudioEl.value.pause()
} else {
await dummyAudioEl.value.play()
}
}
// 오디오 종료될 때마다 다시 재생!
const handleDummyAudioEnded = async (): Promise<void> => {
if (dummyAudioEl.value === null) return
dummyAudioEl.value.currentTime = 0
await dummyAudioEl.value.play()
}
</script>
아마도 브라우저에서 loop
을 통한 컨텐츠의 재생은 사용자 상호작용을 통한 재생으로 보지 않는 듯하다. 이에 반해 ended
이벤트를 통해 play()
메서드를 호출하는 경우, 맨 처음 상호작용으로 획득한 권한이 계속 이어지는 것 같다.
오디오 컨텐츠를 브라우저에서 다뤄 본 경험이 처음이었는데, 브라우저에서 컨텐츠 자동재생을 지양하고 있다는 게 꽤나 흥미로웠다. 생각해 보면 납득이 되는 부분인데, 학생이었을 때 부모님 몰래 컴퓨터를 하다 갑자기 컴퓨터에서 음악이 재생돼 들켰던 기억이 있다 보니… 브라우저의 많은 기능들이 사용자 편의를 고려해서 만들어졌다는 것이 재밌는 지점이었다. 물론 개발하기는 쉽지 않았지만. 어떻게 보면 우회 수단을 사용하여 문제를 해결한 것이다 보니, 다른 서비스의 경우 이런 문제가 발생했을 때 어떻게 조치했는지가 궁금하기도 하다.
Autoplay guide for media and Web Audio APIs - Web media technologies | MDN