Sangcho.log
dev

OpenCut 탐구 5 — Web Audio 재생 엔진, 두 개의 시계와 연착하는 열차들

11 min read|

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를 누르면 이런 일이 벌어집니다.

  1. PlaybackManager.play() → rAF 루프 시작, 매 프레임 notify()
  2. AudioManager.handlePlaybackChange()가 반응
  3. ensureAudioContext() — AudioContext 생성 + 마스터링 체인 연결
  4. collectAudioClips() — 타임라인에서 모든 오디오 소스 수집
  5. 기준점 기록: playbackStartTime(타임라인 위치), playbackStartContextTime(AudioContext 시각)
  6. 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단계로 구제하는 시간표 시스템이다.

스스로 점검할 질문

  1. 재생 중 seek할 때 왜 기존 노드를 전부 버리고 다시 스케줄링해야 하나요?
  2. 지연 보상값의 상한이 0.25초인 이유를 사용자 경험 관점에서 설명할 수 있나요?
  3. 여러분의 코드에서 "오래된 비동기 작업의 결과가 뒤늦게 끼어드는" 문제를 세션 ID 없이 겪은 적이 있나요? 어떻게 해결했나요?

참고 링크