
Web Audio를 살아 있게 하는 세 개의 동사
두 달 간격으로 서로 다른 파일에서 나온 두 번의 GeekBye 포인트 릴리스가, 우리의 오디오 코드에 같은 교훈을 정반대의 끝에서 가르쳤습니다: 브라우저의 AudioContext를 일회용처럼 다루기를 멈춰라. 한 릴리스는 macOS가 녹화 도중 조용히 suspend해 버린 컨텍스트를 resume()하는 법을 배웠고; 다른 하나는 연이은 세션들이 Chromium의 대략 여섯 컨텍스트 한계에 들이받는 것을 멈추도록 close() 대신 suspend()하는 법을 배웠습니다. resume, suspend, close — 그것이 줄거리 전부입니다.
대부분의 경우, Web Audio AudioContext는 그저 평범한 객체처럼 굴어요: 하나를 만들고, 노드 몇 개를 배선하고, 그것을 잊습니다. 두 번의 GeekBye 릴리스는 그 가정이 깨졌을 때 일어난 일입니다 — 두 번, 앱의 두 개의 서로 다른 부분에서, 두 달 간격으로. 어느 쪽도 다른 쪽에 대한 후속으로 계획되지 않았습니다. 하지만 둘을 나란히 놓으면 그것들은 하나의 이야기를 들려줍니다: 브라우저의 AudioContext는 일회용 객체가 아닙니다. 그것은 진짜 상태 기계를 가진, 희소하고 운영체제가 소유하는 자원이며, 그 상태 기계의 세 동사 — resume, suspend, close — 가 두 릴리스 모두의 줄거리 전부입니다.
GeekBye에는 두 개의 별개 오디오 파이프라인이 있고, 그것이 같은 교훈을 두 번 배워야 했던 이유입니다. 하나는 비디오 녹화 경로(ScreenRecordingService)로, 마이크와 시스템 오디오를 녹화되는 비디오 파일로 섞기 위한 Web Audio 그래프를 짓습니다. 다른 하나는 실시간 전사 경로(RealtimeAudioCaptureService)로, 음성 인식에 공급하기 위해 자기만의 그래프를 짓습니다. 각각이 자기 AudioContext를 가집니다. 아래의 각 릴리스는 그중 하나를 고칩니다.
v1.8.16: 비디오는 무음이 되었는데, 전사는 그러지 않았다
첫 번째 버그는 분리뇌입니다. 당신은 회의를 녹화하고 있고, 잠깐 다른 앱으로 탭을 옮기고, 그리고 뭔가 미묘한 일이 일어납니다: 라이브 전사가 아무것도 변하지 않은 듯 계속 스크롤되는 동안, 비디오 파일은 당신의 마이크를 잃습니다. 전사는 당신이 말하고 있었다고 맹세합니다. 비디오는 무음입니다. 그것은 미치게 하는 종류의 버그입니다, 왜냐하면 일치해야 할 두 가지 — 「마이크가 당신을 들었는가」 — 가 딱 잘라 어긋나기 때문입니다.
그것을 고치는 커밋은 그 원인을 정확히 이름 붙입니다:
AudioContext can be suspended by macOS when the app loses focus during screen recording. This silences the mic in the video file while the ScriptProcessorNode (STT path) keeps running on the JS main thread, causing a split where transcripts continue but the video has no mic.
메커니즘은 이렇습니다. ScreenRecordingService.setupMonoMixingPipeline()은 하나의 AudioContext, 비디오의 오디오 트랙이 되는 MediaStreamDestination, 음소거용 GainNode, 그리고 오디오 프레임을 VAD/STT 로직으로 밀어 넣는 한 쌍의 ScriptProcessorNode(createScriptProcessor를 통해)를 짓습니다. macOS가 컨텍스트를 서스펜드하면 — 화면 녹화 중 앱이 포커스를 잃을 때 그럴 수 있습니다 — 오디오 하드웨어 클록으로 구동되는 그래프의 부분들이 멈춥니다: MediaStreamDestination은 마이크 오디오를 받기를 멈추고, 그래서 녹화되는 비디오는 조용해집니다. 하지만 ScriptProcessorNode의 onaudioprocess 콜백은 JavaScript 메인 스레드에서 돌고, 그 스레드는 계속 째깍입니다. 그래서 STT 쪽은 마지막에 가지고 있던 무엇으로부터 전사 항목을 계속 만들어 내고, 같은 마이크의 두 소비자는 갈라집니다.
수정은 그 서스펜드를 알아채고 그것을 되돌리는 것입니다:
audioContext.onstatechange = () => {
if (audioContext.state === 'suspended' && this.status === 'recording') {
logger.info('AudioContext suspended during recording, resuming')
audioContext.resume()
}
}
그것이 정확성 변경의 전부입니다: 컨텍스트의 state를 지켜보다가, 우리가 아직 녹화하고 있어야 하는데 그것이 suspended로 떨어지면 resume()을 부른다. 이 릴리스는 또한 마이크의 MediaStreamTrack 위에 onmute, onunmute, onended 리스너를 배선합니다 — 동작을 바꾸기 위해서가 아니라, OS 수준의 음소거나 기기 연결 해제가 무음의 보이지 않는 원인이 되는 대신 로그에 나타나도록 하기 위해서입니다. 버그의 어려움 전부가 「왜 오디오가 멈췄는가」일 때, 오디오가 멈추는 것을 관측 가능하게 만드는 일은 수정의 절반입니다.
몇 분 뒤 후속 커밋이 착지해 그 새 리스너들의 뒤처리를 했습니다: cleanup()은 이제 그래프를 허물기 전에 micTrack.onmute/onunmute/onended를 null로 만들고 audioContext.onstatechange = null을 설정합니다. 커밋 메모는 그것이 「RealtimeAudioCaptureService 클린업 패턴에 맞추는」 것이라고 말합니다 — 앱의 다른 오디오 서비스입니다. 그 세부를 붙들어 두세요, 왜냐하면 두 달 뒤 바로 그 패턴이야말로 다음 릴리스가 다시 써야 했던 것이기 때문입니다.
v2.0.2: 오디오와 전사가 대략 여섯 세션 뒤에 죽는다
두 번째 버그는 다른 파이프라인 RealtimeAudioCaptureService에 살고, 그것은 멋진 모양을 하고 있습니다: 모든 것이 완벽하게 작동하다가, 한 앱 세션 안에서 녹화를 몇 차례 시작하고 멈춘 뒤, 오디오가 그냥… 죽습니다. 먼저 시스템 오디오가 무음이 되고, 그다음 전사가 멈춥니다. 앱을 재시작하면 다시 괜찮습니다 — 또 몇 세션이 지나기 전까지는.
근본 원인 메모는 전문을 읽을 가치가 있습니다, 왜냐하면 그것은 교과서적인 버그 뒤의 버그이기 때문입니다:
Root cause: setupMonoMixingPipeline created a new AudioContext on every start() and cleanup() closed it. Chromium hard-caps hardware AudioContexts at ~6 per page and closed-but-not-yet-GC'd contexts keep their slot, so ~6 back-to-back sessions exhaust the cap. The next context is dead → the silent-monitor passthrough can't play (no system audio) AND the mixer worklet can't capture (no transcript) — the reported 'after 6+ sessions audio dies, then transcript dies'.
함정은, 옛 코드가 「책임 있는」 일을 하고 있었다는 것입니다. 모든 start()가 새 AudioContext를 만들었고; 모든 cleanup()이 그것에 close()를 불렀습니다. 다 쓴 자원을 닫는 것은 바로 그렇게 하라고 배우는 일입니다. 그 휘저음을 정당화하는 인라인 주석마저 있었습니다 — 새 컨텍스트마다 AudioWorklet 모듈을 다시 로드하는 것은 괜찮다, 왜냐하면 「start()마다의 새 컨텍스트는 우리가 언제나 이 비용을 한 번만 치른다는 뜻」이기 때문에.
하지만 Chromium은 단일 페이지가 붙들 수 있는 하드웨어 기반 AudioContext의 수를 제한합니다 — 대략 여섯 — 그리고, 결정적으로, close()는 당신이 부른 그 순간에 자리를 돌려주지 않습니다. 닫힌 컨텍스트는 가비지 컬렉트될 때까지 그 자리를 계속 차지하고, 그것은 JS 엔진이 손이 빌 때 언제든 일어납니다. 그래서 꼼꼼하게 매번 닫는 정책이야말로 정확히 당신을 천장으로 걸어 들이는 것입니다: 여섯 번의 만들기/닫기 순환 뒤, 여섯 자리는 모두 아직 수거되지 않은 시체들에 붙들려 있고, 일곱 번째 new AudioContext()는 죽어서 돌아오며, 당신이 그것에 붙이는 모든 노드는 조용히 아무것도 하지 않습니다.
수정은 컨텍스트를 일회용처럼 다루기를 멈추는 것입니다. 그것을 한 번만 만들고 서비스의 수명 동안 재사용합니다:
setupMonoMixingPipeline에서는, 아직 하나도 없거나 기존의 것이closed일 때에만new AudioContext(...)를 만듭니다. 존재하지만suspended라면, 새것을 만드는 대신await audioContext.resume()합니다.cleanup에서는,audioContext.close()를audioContext.suspend()로 바꿉니다(이미 닫힌 컨텍스트에는 결코 불리지 않도록 가드됩니다). 컨텍스트는 세션을 살아남습니다.- 컨텍스트가 살아남으므로, 로드된
AudioWorklet모듈도 그와 함께 살아남습니다 — 코드는 더 이상 클린업에서 「워클릿 로드됨」 플래그를 리셋하지 않으므로,audioWorklet.addModule(...)은 세션마다가 아니라 서비스 수명 전체에 대해 한 번만 치러집니다.
세션마다의 조각들 — 마이크와 시스템 오디오를 위한 createMediaStreamSource 노드들, 믹서 워클릿, 그리고 Chromium의 시스템 오디오 루프백 탭을 살려 두는 무음 패스스루 — 은 여전히 매 순환마다 지어지고 허물립니다. AudioContext 자체만 존속하므로, 어느 순간에도 기껏해야 하나만 존재합니다. 천장은 닿을 수 없습니다, 왜냐하면 당신은 컨텍스트를 결코 그것에 쌓아 올리지 않기 때문입니다.
커밋이 자기 자신에 대해 남기는 정직한 메모 하나: 이것은 손으로 검증되었습니다 — 「수동으로 검증됨: 8회 이상의 연이은 세션이 시스템 오디오를 들리게 유지하고 전사가 계속 흐른다(세션 7에서 죽어 있었음)」 — 그리고 저자는 Web Audio 수명 주기가 「큰 새 목 하네스 없이는 jsdom에서 유닛 테스트가 불가능하다」는 점에 솔직합니다. 그것이 옳은 종류의 정직함입니다. 그 실패는 진짜 세션들에 걸친 진짜 오디오 기기에 대해서만 나타납니다; 초록색 유닛 스위트는 아무것도 증명하지 못했을 겁니다. 「대략 여섯」과 「세션 7에서 죽음」은 Chromium의 한계가 문 곳에 대한 팀의 관찰이지, 문서화된 상수가 아닙니다 — 코드 자신이 물결표로 얼버무리고 있고, 그것을 되풀이하는 누구든 그래야 합니다.
resume, suspend, close
두 릴리스를 나란히 세우면 그 대칭은 거의 지나치게 깔끔합니다. 둘 다 같은 종류의 객체 위의 같은 세 메서드에 관한 것이고, 정반대 방향에서 다가갑니다:
- v1.8.16은
resume()하는 법을 배웠습니다. 컨텍스트는 OS에 의해 발밑에서suspended되었고, 수정은 그것을 붙잡아 resume하는 것입니다 — 돌아가고 있어야 할 컨텍스트를, 돌아가게 유지한다. - v2.0.2는
close()대신suspend()하는 법을 배웠습니다. 옛 코드는 컨텍스트를 파괴하는 데 너무 성급했고, 수정은 그것을 서스펜드하고 재사용하는 것입니다 — 다시 만들기에 비싼 컨텍스트를, 살려 둔다.
그것들은 하나의 원칙의 두 절반입니다: AudioContext를 버릴 것으로 다루기를 멈춰라. 한 절반은, 그러지 말아야 할 때 그것이 죽게 두지 말라고 말한다; 다른 절반은, 그럴 필요가 없을 때 그것을 죽이지 말라고 말한다. 그리고 타임라인에는 작은 아이러니가 있습니다 — v1.8.16의 클린업은 「RealtimeAudioCaptureService의 클린업 패턴에 맞추도록」 쓰였고, 그다음 v2.0.2가 RealtimeAudioCaptureService 안에서 바로 그 패턴을 다시 썼습니다, 왜냐하면 그 패턴의 매번 close()하는 버릇이야말로 버그로 드러났기 때문입니다. 두 서비스는 한 번에 한 수정씩, 서로의 오디오 수명 주기 처리로부터 계속 배웁니다.
이 릴리스들이 가르친 세 가지
AudioContext는 상태 기계를 가진다 —state를 하중을 견디는 것으로 다뤄라.running,suspended,closed는 잡학이 아닙니다. OS는 당신에게 묻지 않고 컨텍스트를 그것들 사이에서 옮길 수 있고, 당신 그래프의 서로 다른 부분은 서로 다르게 반응합니다(메인 스레드의ScriptProcessorNode는 계속 간다; 하드웨어 클록으로 구동되는 목적지는 그러지 않는다). 만약 당신이onstatechange를 지켜보지 않는다면, 당신은 컨텍스트가 당신이 둔 자리에 머문다고 믿고 있는 것입니다 — 그리고 진짜 데스크톱에서, 포커스 변화와 화면 녹화 아래에서, 그것은 머물지 않을 것입니다.close()는free()가 아니다. 자원을 즉시 닫는 것이 언제나 옳다는 직관이야말로 두 번째 버그를 일으킨 것입니다. 자원이, GC 시점에만 회수되는, 딱딱하게 브라우저가 부과한 풀 안의 한 자리일 때, 공격적인 닫기는 풀을 소진하는 방법입니다. 때로는 옳은 수명 주기가, 쓸 때마다 만들고 파괴하기가 아니라, 한 번 만들고 자주 서스펜드하기입니다.- 어떤 버그는 진짜 하드웨어와 진짜 시간이라는 문을 통해서만 닿는다. 「대략 여섯 세션 뒤에 죽는다」와 「탭을 옮기면 무음이 된다」는 오디오 기기를 목으로 만드는 유닛 테스트로는 잡을 수 없습니다 — 그리고 팀은 가짜로 통과하는 테스트로 그것을 덮는 대신 분명히 그렇게 말했습니다. 당신의 테스트 하네스의 한계에 이름을 붙이세요; 나타나는 데 여덟 번의 진짜 세션이 필요한 버그는, 아무것도 뜻하지 않는 초록색 체크가 아니라, 정직한 「수동으로 검증됨」에 값합니다.
이전 장은 열려 있는 앱과 통화 중을 가려내다(v1.8.15–v1.8.19)를; 그리고 전체 궤적은 소프트웨어를 완벽하게 출시하는 것의 해부학을 보세요.