OpenCut 탐구 5 — Web Audio 재생 엔진, 두 개의 시계와 연착하는 열차들
3편에서 "아무도 부르지 않는 부서" AudioManager를 소개만 하고 지나갔습니다. 이번 편은 그 부서의 내부입니다. 비디오 에디터에서 오디오는 가장 까다로운 파트입니다. 화면은 한 프레임쯤 늦게 그려져도 아무도 모르지만, 오디오는 수십 ms만 어긋나도 귀가 바로 알아챕니다.
이 시스템을 이해하는 데 가장 좋은 비유는 열차 시간표입니다. 오디오 버퍼 하나하나가 열차이고, 각 열차에는 출발 시각(재생 시각)이 정해져 있습니다. 역무원(AudioManager)의 일은 열차를 제시간에 플랫폼(스피커)에 들여보내는 것 — 그리고 진짜 실력은 열차가 연착했을 때 드러납니다.
세 레이어 구조
| 레이어 | 역할 | 핵심 기술 |
|---|---|---|
| PlaybackManager | 타임라인 시계 (UI 동기화) | requestAnimationFrame, performance.now() |
| AudioManager | 오디오 재생 & 싱크 | Web Audio API (AudioContext) |
| mediabunny | 미디어 디코딩 & 인코딩 | WebCodecs 기반 라이브러리 |
PlaybackManager (rAF 기반 타임라인 시계)
├── performance.now() → delta → currentTime 갱신
├── "playback-seek" CustomEvent 발행
└── "playback-update" CustomEvent 발행
│ subscribe / window event
▼
AudioManager (Web Audio 기반 오디오 재생)
├── AudioContext.currentTime 기준 싱크
├── 2초 lookahead + 500ms 주기 스케줄링
├── mediabunny로 디코딩
├── GainNode 볼륨 제어
└── DynamicsCompressor 마스터링 체인
왜 시계가 두 개인가
| 시계 | 용도 | 정밀도 |
|---|---|---|
performance.now() (PlaybackManager) |
UI 갱신, 플레이헤드 이동 | ~1ms (rAF 주기) |
audioContext.currentTime (AudioManager) |
오디오 버퍼 스케줄링 | sample-accurate (44100Hz 기준 ~0.02ms) |
역 대합실의 전광판과 관제실의 원자시계 차이입니다. 플레이헤드가 움직이는 UI는 60fps면 충분하니 rAF 시계로 돌리고, 열차 출발은 샘플 단위 정밀도가 필요하니 오디오 하드웨어의 시계를 씁니다. 재생을 시작하는 순간 두 시계를 맞춰 두고("지금 타임라인 3초 = AudioContext 12.345초"), 이후의 모든 변환은 이 기준점에서 계산합니다.
playbackTime = playbackStartTime + (audioContext.currentTime - playbackStartContextTime)
재생 시작 — 시간표 작성
Play를 누르면 이런 일이 벌어집니다.
PlaybackManager.play()→ rAF 루프 시작, 매 프레임notify()AudioManager.handlePlaybackChange()가 반응ensureAudioContext()— AudioContext 생성 + 마스터링 체인 연결collectAudioClips()— 타임라인에서 모든 오디오 소스 수집- 기준점 기록:
playbackStartTime(타임라인 위치),playbackStartContextTime(AudioContext 시각) scheduleUpcomingClips()— 500ms마다 "현재~현재+2초" 범위의 클립을 찾아 예약
핵심은 6번의 lookahead 스케줄링입니다. "지금 재생할 것"을 지금 준비하면 이미 늦습니다. 2초 앞의 시간표까지 미리 열차를 준비시켜 두는 거죠.
클립 스케줄링 — 두 종류의 열차
클립의 복잡도에 따라 준비 방식이 갈립니다.
shouldUsePreparedClipBuffer()
├── hasCurveRetime(clip) → true면 Prepared
├── hasAnimatedVolume(element) → true면 Prepared
├── shouldMaintainPitch(rate, maintainPitch) → true면 Prepared
└── 모두 false → 스트리밍
경로 A — 스트리밍 (보통 열차). 볼륨 고정, 배속 없음, 피치 변환 없음인 단순 클립. mediabunny의 AudioBufferSink가 AsyncGenerator로 AudioBuffer 청크를 하나씩 yield하고, 청크마다 BufferSourceNode → GainNode → masterGain으로 연결해 node.start(startTimestamp)로 예약합니다. 전체를 디코딩하지 않으니 메모리 효율적입니다.
경로 B — Prepared (특별 편성 열차). 배속·피치 유지·볼륨 키프레임이 걸린 클립은 즉석 편성이 불가능합니다. 전체를 디코딩해서 OfflineAudioContext로 리샘플링하고, 필요하면 soundtouchjs의 PitchShifter로 타임스트레치한 뒤, 완성된 버퍼 하나를 통째로 재생합니다. 볼륨 키프레임은 linearRampToValueAtTime()으로 자동화하고요.
예: 10초 오디오, rate=0.5, maintainPitch=true
→ 타임라인 점유 시간 = 10 / 0.5 = 20초
→ OfflineAudioContext로 44100Hz 리샘플링
→ PitchShifter(tempo=0.5, pitch=1)로 타임스트레치
→ 20초짜리 AudioBuffer 완성 → node.start() 한 번에 재생
Seek — 시간표 전면 개정
Web Audio의 BufferSourceNode는 한 번 출발하면 중간에 위치를 바꿀 수 없습니다(seek 불가). 그래서 재생 중 seek이 발생하면 모든 노드를 stop() → disconnect()하고 새 위치에서 전체를 다시 스케줄링합니다. PlaybackManager가 playback-seek CustomEvent에 목적지 시간을 실어 보내면, AudioManager가 받아서 시간표를 다시 짭니다.
지연 보상 — 연착한 열차를 어떻게 처리할 것인가
디코딩은 비동기이고 CPU가 바쁘면 재생 시계보다 느려집니다. "지금 출발해야 할 열차가 아직 차고에 있는" 상황이죠. 버퍼가 도착할 때마다 예정 시각(startTimestamp)과 현재 시각(audioContext.currentTime)을 비교해 세 갈래로 처리합니다.
startTimestamp = playbackStartContextTime
+ playbackLatencyCompensationSeconds ← 누적 보상값 (기본 0)
+ (timelineTime - playbackStartTime)
Case 1: 정시 도착. startTimestamp가 아직 미래 → node.start(startTimestamp)로 정확히 예약. 연속 드롭 카운터 리셋.
Case 2: 연착했지만 구제 가능. 예정 시각은 지났지만 버퍼 길이 안쪽(offset < buffer.duration) → 놓친 앞부분을 건너뛰고 지금 바로 출발시킵니다. node.start(now, offset). 0.1초 늦은 0.5초 버퍼라면 앞 0.1초를 버리고 0.4초만 재생 — 미세한 끊김은 있지만 싱크는 유지됩니다.
Case 3: 완전히 놓침. 버퍼 전체가 이미 과거 → 운행 취소(드롭)하고 다음 버퍼로. 드롭 카운터 +1.
Resync — 연속 5회 드롭이면 시간표 자체를 수정한다
드롭이 5번 연속되면 개별 구제로는 안 된다고 판단하고 구조적 대응에 들어갑니다.
1. 보상값 갱신: nextCompensation = max(현재값, min(0.25, offset + 0.01))
2. 현재 재생 위치 스냅샷
3. 기존 iterator 폐기 → 새 iterator로 그 위치부터 재시작
보상값(playbackLatencyCompensationSeconds)이 커지면 이후 모든 버퍼의 예정 시각이 그만큼 앞당겨져, 같은 속도로 늦게 도착해도 "아직 미래"로 판정됩니다. 상습 연착 노선의 시간표를 아예 여유 있게 다시 인쇄하는 겁니다. 제약도 섬세합니다.
| 제약 | 값 | 이유 |
|---|---|---|
| 최대 보상값 | 0.25초 | 너무 크면 오디오가 비디오보다 눈에 띄게 앞서감 |
| 최소 증가 단위 | 0.001초 | 미세한 jitter로 불필요한 갱신 방지 |
| 마진 | +0.01초 | offset에 딱 맞추면 다시 드롭될 수 있어 여유 추가 |
배압 제어 — 너무 빨라도 문제다
반대 상황도 제어합니다. 디코딩이 재생보다 1초 이상 앞서 나가면 waitUntilCaughtUp()으로 대기시킵니다(100ms 폴링). 이게 없으면 미사용 AudioBufferSourceNode가 수천 개 쌓여 메모리와 GC를 압박합니다. 열차를 무한정 미리 편성해 차고를 꽉 채우는 꼴이니까요.
세션 ID — 낡은 비동기 작업의 무효화
이 모든 비동기 흐름의 안전핀은 playbackSessionId입니다.
// 매 startPlayback()마다 증가
this.playbackSessionId++;
// 모든 await 뒤에서 체크
if (sessionId !== this.playbackSessionId) return;
재생 → seek → 재시작이 벌어지면 이전 세션의 디코딩 작업이 뒤늦게 완료될 수 있습니다. 그 결과가 새 세션에 끼어들면 엉뚱한 위치의 소리가 납니다. 세션 ID 비교 한 줄이 "구시대의 열차는 폐차한다"를 보장합니다. getAudioSink() await 이후, for await 루프 매 반복, waitUntilCaughtUp() 이후 — 모든 비동기 경계마다 체크합니다.
마스터링 체인 — 마지막 관문
모든 클립의 소리는 스피커에 닿기 전 공통 체인을 통과합니다.
GainNode (input/볼륨)
→ DynamicsCompressorNode (리미터: threshold=-1dB, ratio=20, attack=1ms)
→ GainNode (output: 0.98 헤드룸)
→ destination
ratio 20의 컴프레서는 사실상 브릭월 리미터로, 여러 클립이 겹칠 때의 클리핑(음 찢어짐)을 방지합니다. Export 시에는 같은 체인을 OfflineAudioContext 위에서 돌려 최종 믹스를 만듭니다.
더 깊이: lookahead 스케줄링은 Web Audio의 표준 교과서다
"JS 타이머로 틱을 잡고, 실제 소리는 AudioContext 시계로 예약한다" — OpenCut의 500ms 인터벌 + 2초 lookahead 구조는 Web Audio 커뮤니티에서 오래 정립된 정석 패턴입니다. 원리는 두 시계의 장단점 교환입니다.
setTimeout/setInterval은 유연하지만 부정확합니다. 메인 스레드가 바쁘면 수십 ms씩 밀립니다.AudioContext의 스케줄링(node.start(time))은 정확하지만 유연하지 않습니다. 한 번 예약하면 취소·수정이 제한적입니다.
그래서 부정확한 타이머로 "느슨하게 자주" 깨어나되, 깨어날 때마다 다음 lookahead 구간의 이벤트를 정확한 시계에 예약하는 하이브리드가 답이 됩니다. lookahead가 짧으면 타이머 지연에 취약해지고, 길면 seek 시 버려야 할 예약이 많아집니다. OpenCut의 "2초 미리, 500ms마다 갱신"은 그 사이의 균형점인 셈입니다. 드럼머신, 메트로놈, 게임 오디오 등 시간 정밀도가 필요한 모든 웹 오디오 앱에 같은 구조가 등장합니다.
한 줄 요약 — OpenCut의 오디오 엔진은 UI용 rAF 시계와 sample-accurate한 AudioContext 시계의 분업 위에서, 2초 lookahead로 버퍼를 예약하고 연착한 버퍼를 "부분 재생 → 드롭 → resync"의 3단계로 구제하는 시간표 시스템이다.
스스로 점검할 질문
- 재생 중 seek할 때 왜 기존 노드를 전부 버리고 다시 스케줄링해야 하나요?
- 지연 보상값의 상한이 0.25초인 이유를 사용자 경험 관점에서 설명할 수 있나요?
- 여러분의 코드에서 "오래된 비동기 작업의 결과가 뒤늦게 끼어드는" 문제를 세션 ID 없이 겪은 적이 있나요? 어떻게 해결했나요?
참고 링크