Meloming
· 48 min read

확장 프로그램 없이 브라우저에서 영상 키 조절하기

조현우
조현우

CEO & Fullstack Engineer

1. 키를 바꾼다는 것

노래방에서 키를 올리고 내리는 버튼은 익숙합니다. 노래책 콘솔에도 같은 기능이 필요했습니다. 스트리머가 방송 중에 반주를 틀어놓고 자기 음역에 맞게 키를 몇 반음 올리거나 내립니다.

문제는 이 반주가 브라우저의 <video> 요소에서 재생되고 있다는 점입니다. 서버에서 미리 키를 바꾼 파일을 여러 벌 만들어두는 방식은 쓸 수 없었습니다. 조합이 곡 수의 13배로 늘어나고 스트리머는 방송 중에 실시간으로 조절하고 싶어 하기 때문입니다.

그래서 브라우저에서 실시간으로 처리해야 했습니다.

여기서 먼저 짚어야 할 것은 키 조절이 재생 속도 조절과 다른 문제라는 점입니다. playbackRate를 올리면 음이 같이 올라가지만 노래도 빨라집니다. 반대로 음정만 올리려면 길이는 그대로 두고 주파수만 옮겨야 합니다. 이것을 피치 시프팅이라고 합니다. 시간축과 주파수축을 분리해야 하므로 단순한 배속 조절보다 훨씬 어렵습니다.

재미있는 것은 같은 코드베이스 안에 playbackRate라는 이름이 세 곳에 있는데 셋 다 의미가 다르다는 점입니다.

위치코드실제 의미
클립 플레이어videoEl.playbackRate = r속도만 바꾸고 음정은 유지 (브라우저 preservesPitch 기본값에 의존)
콘솔 키 조절pitchNode.pitchSemitones.value = n음정만 바꾸고 길이는 유지
가사 패널anchorMs + elapsed * playbackRate오디오가 아니라 가사 시간 보간 계수

프론트엔드 코드베이스 전체를 뒤져도 preservesPitch를 명시적으로 설정하는 코드는 한 줄도 없습니다. 클립 플레이어는 브라우저 기본값(음정 유지)에 그냥 올라타서 배속 기능을 공짜로 얻습니다. 콘솔이 하려는 것은 정확히 그 반대이고 브라우저는 그쪽을 공짜로 주지 않습니다.


2. 첫 구현: 이미 “검증된” 위상 보코더

처음 고른 것은 위상 보코더(phase vocoder) 방식이었습니다. 신호를 짧은 구간으로 잘라 FFT로 주파수 영역에 올리고 각 구간의 위상을 보정하면서 재합성하는 고전적인 방법입니다. 교과서적이고 이론적으로 옳습니다.

무엇보다 새로 쓸 필요가 없었습니다. 크롬 확장에서 이미 배포해 돌리고 있던 위상 보코더 AudioWorklet(FFT 2048, hop 512, Hann 윈도)을 그대로 가져다 쓰면 같은 DSP를 두 곳에서 쓰게 됩니다.

실제로 public/audio-worklets/phase-vocoder-processor.js는 크롬 확장의 같은 이름 파일과 바이트 단위로 동일했습니다. diff 결과가 0줄입니다. 이미 배포되어 돌아가고 있는 DSP를 그대로 가져왔습니다.

구현은 stretch-then-resample 방식입니다. 분석 홉과 합성 홉을 다르게 잡아 시간축을 늘린 뒤, 출력을 다시 리샘플링해 원래 길이로 되돌립니다. 분석과 합성 양쪽에 Hann 윈도를 걸고 완전히 겹친 구간만 읽습니다.

JavaScript
/**
 * Phase Vocoder AudioWorklet Processor
 *
 * Pitch shifting via stretch-then-resample:
 *   1. Time-stretch: analysis hop=HOP_SIZE, synthesis hop=HOP_SIZE*pf
 *   2. Resample output at pf rate to restore original duration
 *
 * Uses Hann window on both analysis and synthesis with per-position
 * normalization tracking. Only reads from fully-overlapped regions.
 */

const FFT_SIZE = 2048;
const HOP_SIZE = 512;

핵심 루프는 이렇습니다. 위상 편차를 언랩해서 순간 주파수를 구하고 합성 홉만큼 위상을 누적한 뒤 크기는 그대로 두고 다시 복소수로 되돌립니다.

JavaScript
let dp = ph - ch.lastPhIn[k] - EXP_PHASE[k];
ch.lastPhIn[k] = ph;
dp -= Math.round(dp / (2 * Math.PI)) * 2 * Math.PI;

const instFreq = dp / HOP_SIZE + (2 * Math.PI * k) / FFT_SIZE;
ch.lastPhOut[k] += Hs * instFreq;

ch.re[k] = mag * Math.cos(ch.lastPhOut[k]);
ch.im[k] = mag * Math.sin(ch.lastPhOut[k]);

그다음 합성 쪽에서 Hann 윈도를 다시 곱해 누산하고, 윈도 제곱합으로 정규화한 뒤 완전히 겹친 구간만 출력 버퍼로 옮깁니다.

JavaScript
const p = (ch.olaW + i) % olaSz;
ch.olaBuf[p] += ch.re[i] * HANN[i];
ch.olaNorm[p] += HANN[i] * HANN[i];
// ...
if (ch.olaNorm[p] > normThreshold) {
  ch.outBuf[(ch.outW + transferred) % outSz] = ch.olaBuf[p] / ch.olaNorm[p];
} else {
  ch.outBuf[(ch.outW + transferred) % outSz] = ch.olaBuf[p];
}

마지막으로 늘어난 길이를 되돌리기 위해 출력을 pf 배속으로 분수 리샘플링합니다. 링 버퍼 세 개(inBuf, olaBuf, outBuf)와 부동소수점 읽기 포인터 outR이 서로 다른 속도로 감기는 구조입니다. 여기가 나중에 문제가 됩니다.


3. 지지직, 그리고 오진 세 번

도입하자마자 사용자 신고가 들어왔습니다. “지지직”거리는 소리가 계속 섞인다는 것이었습니다.

위상 보코더를 완전히 걷어내기까지 가설 셋을 지나갔고 그중 둘은 틀렸습니다.

처음에는 배선을 의심했고, 그것이 틀려서 되돌린 뒤 0 반음 통과 경로를 물리적으로 우회했고, 그래도 잡음이 남아 마지막에 알고리즘 자체를 갈아치웠습니다. 그사이 잡음을 못 고친 채로 feature flag를 붙여 기능을 차단해둔 구간이 있었습니다. 기능 탐지 코드가 콘솔 전체를 크래시시킨 건과 첫 클릭에 오디오 그래프를 연결하던 코드를 걷어낸 건도 이 사이에 끼어 있는데, 이 둘은 6절과 7절에서 따로 다룹니다.

가설 1. 소리가 두 경로로 나가고 있다

첫 의심은 DSP가 아니라 배선이었습니다. createMediaElementSource()를 호출하면 스펙상 요소의 기본 출력은 그래프로 리다이렉트되어야 합니다. 그런데 실제로는 그렇지 않은 조합이 있습니다. 일부 브라우저와 샘플레이트 조합에서는 요소의 기본 출력과 그래프 출력이 둘 다 스피커로 새어 나갑니다.

두 경로가 미세하게 위상이 어긋난 채로 동시에 스피커에 도달하면 그 자체로 빗질하는 듯한 잡음이 됩니다. 그럴듯한 가설이었고 조치는 간단했습니다. 그래프를 연결한 뒤에 video.muted = true를 걸어 원본 경로를 죽였습니다.

가설 1을 되돌리다

그 변경은 곧 되돌렸습니다. 더 나빠졌기 때문입니다.

mute가 스피커가 아니라 그래프로 들어가는 입력을 끄는 브라우저가 있었습니다. 그래프는 계속 소리를 내보내는데 그 입력이 무음이 되니, 신호는 사라지고 워클릿 내부의 노이즈 플로어만 남았습니다. 잡음을 지우려던 조치가 잡음만 남긴 셈입니다.

이 조치를 왜 되돌렸는지까지 함께 남겨두지 않으면 mute는 다음에도 그럴듯한 첫 조치로 다시 올라옵니다.

가설 2. 0 반음에서도 워클릿을 통과하고 있다

되돌리는 과정에서 두 번째 발견이 나왔습니다. 위상 보코더에는 pf === 1.0이면 입력을 그대로 복사하는 경로가 있었는데 그것으로는 충분하지 않았습니다. 그 통과 경로도 매 quantum마다 FFT와 IFFT를 지나기 때문에 pitchFactor가 정확히 1.0이어도 부동소수점 오차와 위상 오차가 쌓입니다. 그래서 기능이 꺼져 있거나 0 반음일 때는 워클릿을 그래프에서 아예 빼도록 바꿨습니다.

이 변경은 옳았고 지금까지 남아 있습니다. 다만 지지직은 여전히 사라지지 않았습니다.

가설 3. 알고리즘 자체

그다음에는 문제를 못 고친 상태로 feature flag를 붙였습니다. 음질 회귀가 미해결인 채로 프로덕션에서는 기존 동작만 확인할 수 있도록 기능 자체를 차단할 수 있게 만든 것입니다.

그리고 결국 알고리즘을 통째로 교체했습니다. 판단의 근거는 잡음의 성질이었습니다.

키를 크게 올릴수록 심해지는 것이 아니라 키 설정값과 무관하게 일정하게 들렸습니다. 위상 보코더 특유의 뭉개짐(phasiness)이라면 변위가 클수록 나빠져야 합니다. 그렇지 않다는 것은 알고리즘의 정확도가 아니라 버퍼 인덱싱의 결정론적 불연속을 가리킵니다. 앞서 본 링 버퍼 세 개와 부동소수점 읽기 포인터가 서로 다른 속도로 감기는 구조가 바로 그런 결함이 살기 좋은 곳입니다. 잡음이 얼마나 큰지보다 설정값을 바꿨을 때 잡음이 어떻게 반응하는지가 원인의 위치를 훨씬 빨리 좁혀줬습니다.

여기서 선택지는 둘이었습니다. 그 구조를 계속 파고들어 고치거나, 요구에 맞는 다른 알고리즘으로 가거나. 이 기능에 필요한 것은 스펙트럼을 정밀하게 다루는 능력이 아니라 사람 목소리와 반주에서 잡음 없이 안정적으로 도는 것이었습니다. 그래서 시간 영역에서 동작하는 WSOLA 계열 구현인 SoundTouchJS(@soundtouchjs/audio-worklet 1.0.10, LGPL-2.1 동적 링크)로 갈아탔습니다.

교체 후 잡음은 사라졌습니다.

“이미 검증된 것”이라는 딱지가 붙은 코드를 가져온 것이 시작이었습니다. 크롬 확장에서 돌던 파일이 콘솔에서도 돌 것이라는 가정 자체는 합리적이었습니다. 다만 검증됐다는 딱지는 그 코드가 검증된 맥락까지 같이 옮겨주지는 않습니다.


4. WSOLA는 무엇을 다르게 하나

교체한 워클릿은 931줄짜리 번들 하나입니다. 실제로 열어보면 위상을 아예 다루지 않는다는 것이 바로 보입니다.

파이프라인 두 개를 순서만 바꿔 씁니다

SoundTouch는 스테이지 두 개로 되어 있습니다. RateTransposer는 선형 보간으로 리샘플링만 하고(길이와 음정이 같이 변합니다), Stretch가 WSOLA로 길이만 되돌립니다. 반음 값은 이렇게 분해됩니다.

JavaScript
set pitchSemitones(pitchSemitones) {
  this.pitchOctaves = pitchSemitones / 12;
}
set pitchOctaves(pitchOctaves) {
  this.pitch = Math.exp(0.69314718056 * pitchOctaves);
}
// ...
this._tempo = this.virtualTempo / this.virtualPitch;
this._rate = this.virtualRate * this.virtualPitch;

키를 2 반음 올리면 pitch = 1.1225, 따라서 rate = 1.1225(1.1225배로 리샘플링해 음이 올라가고 길이가 줄어듦)이고 tempo = 0.8909(WSOLA로 길이를 다시 늘림)입니다. 흥미로운 것은 두 스테이지의 순서가 방향에 따라 뒤집힌다는 점입니다.

flowchart LR
    subgraph U["rate 가 1 보다 클 때, 즉 키를 올릴 때"]
      I1["input FIFO"] --> S1["Stretch<br/>WSOLA, tempo = 1/pitch"] --> M1["intermediate FIFO"] --> T1["RateTransposer<br/>rate = pitch"] --> O1["output FIFO"]
    end
    subgraph D["rate 가 1 보다 작을 때, 즉 키를 내릴 때"]
      I2["input FIFO"] --> T2["RateTransposer<br/>rate = pitch"] --> M2["intermediate FIFO"] --> S2["Stretch<br/>WSOLA, tempo = 1/pitch"] --> O2["output FIFO"]
    end

배선을 바꾸는 코드는 이렇게 생겼습니다.

JavaScript
if (this._rate > 1) {
  if (this._outputBuffer !== this.transposer.outputBuffer) {
    this.stretch.inputBuffer = this._inputBuffer;
    this.stretch.outputBuffer = this._intermediateBuffer;
    this.transposer.inputBuffer = this._intermediateBuffer;
    this.transposer.outputBuffer = this._outputBuffer;
  }
} else if (this._outputBuffer !== this.stretch.outputBuffer) {
  this.transposer.inputBuffer = this._inputBuffer;
  this.transposer.outputBuffer = this._intermediateBuffer;
  this.stretch.inputBuffer = this._intermediateBuffer;
  this.stretch.outputBuffer = this._outputBuffer;
}

코드는 순서를 뒤집는다는 사실만 말하고 그 이유는 말하지 않습니다. 원본 C++ SoundTouch의 리샘플링 단계에는 안티에일리어싱 FIR 필터(AAFilter)가 들어 있습니다. 반면 이 JS 포팅의 RateTransposer.transpose()에는 그 필터가 없고 선형 보간만 남아 있습니다.

JavaScript
dest[destOffset + 2 * i] = (1 - this.slopeCount) * src[srcIndex] + this.slopeCount * src[srcIndex + 2];

그래서 이 구현에서 순서가 남기는 확실한 효과는 하나입니다. 중간 버퍼에 항상 늘어난 쪽 데이터가 놓이고, 어느 스테이지가 원본 프레임 수를 그대로 보는지가 방향에 따라 달라집니다. 이것이 실제로 눈에 보이는 곳은 뒤에서 계산할 지연입니다. 키를 올릴 때는 WSOLA가 원본 입력을 직접 받지만 키를 내릴 때는 리샘플러가 먼저 프레임을 늘려놓기 때문에 같은 sampleReq를 채우는 데 필요한 원본 오디오 길이가 달라집니다.

파라미터는 자동으로 계산됩니다

Stretch의 기본값은 이렇습니다.

JavaScript
var DEFAULT_SEQUENCE_MS = 0;
var DEFAULT_SEEKWINDOW_MS = 0;
var DEFAULT_OVERLAP_MS = 8;

앞의 두 개가 0인 것은 “지정하지 않음”을 뜻합니다. 그러면 autoSeqSettingautoSeekSetting이 켜져서 tempo에 따라 자동 계산됩니다.

JavaScript
var AUTOSEQ_TEMPO_LOW = 0.25;
var AUTOSEQ_TEMPO_TOP = 4;
var AUTOSEQ_AT_MIN = 125;   // tempo 0.25 일 때 시퀀스 125ms
var AUTOSEQ_AT_MAX = 50;    // tempo 4 일 때 시퀀스 50ms
var AUTOSEEK_AT_MIN = 25;
var AUTOSEEK_AT_MAX = 15;

즉 시퀀스 길이는 130 - 20 * tempo (50에서 125 사이로 클램프), 탐색 창은 25.67 - 2.67 * tempo (15에서 25 사이)입니다. 우리가 쓰는 범위(-6 ~ +6 반음)에서는 tempo가 0.707에서 1.414 사이이므로 시퀀스는 항상 102~116ms에 들어옵니다.

여기서 코드를 읽다가 발견한 것이 하나 있습니다. Stretch 생성자가 샘플레이트를 하드코딩합니다.

JavaScript
constructor(createBuffers) {
  super(createBuffers);
  // ...
  this.setParameters(44100, DEFAULT_SEQUENCE_MS, DEFAULT_SEEKWINDOW_MS, DEFAULT_OVERLAP_MS);
}

AudioWorkletGlobalScope에는 실제 컨텍스트의 sampleRate 전역이 있지만 이 워클릿의 process()는 그것을 한 번도 읽지 않습니다. setParameters를 다시 호출하는 곳도 없습니다. 그래서 macOS나 Windows에서 흔한 48kHz 컨텍스트에 물리면 라이브러리가 “112ms 시퀀스”라고 계산한 4939 샘플이 실제로는 102.9ms가 됩니다. 8ms로 의도된 오버랩 352 샘플도 실제로는 7.33ms입니다.

이것이 소리를 망가뜨리지는 않습니다. WSOLA의 정확도는 비율(tempo, rate)로만 결정되고 창 길이는 품질 튜닝 파라미터라 8.8% 짧아도 티가 나지 않습니다. 다만 라이브러리가 말하는 밀리초 값과 실제 밀리초 값이 다르다는 것은 알고 쓰는 편이 낫습니다. 뒤에서 보겠지만 편집기 쪽에도 정확히 같은 뿌리의 제약이 있습니다.

실제 지연

Stretch.process()는 입력 FIFO에 sampleReq 프레임이 쌓이기 전에는 아무것도 내보내지 않습니다.

JavaScript
set tempo(newTempo) {
  this._tempo = newTempo;
  this.calculateSequenceParameters();
  this.nominalSkip = this._tempo * (this.seekWindowLength - this.overlapLength);
  this.skipFract = 0;
  const intskip = Math.floor(this.nominalSkip + 0.5);
  this.sampleReq = Math.max(intskip + this.overlapLength, this.seekWindowLength) + this.seekLength;
}

상수를 그대로 대입해 계산하면 이렇게 나옵니다. 마지막 열은 48kHz 컨텍스트에서 첫 출력 블록이 나오기까지 채워야 하는 원본 오디오의 길이입니다. 키를 내릴 때는 RateTransposer가 먼저 돌기 때문에 sampleReqrate로 환산했습니다.

반음pitch = ratetemposequenceMsseekWindowMsseekWindowLengthsampleReq48kHz 선행 버퍼
-60.70711.41421022244987185106 ms
-40.79371.25991052246306712111 ms
-20.89091.12251082347626316117 ms
-10.94391.05951092348066085120 ms
+11.05950.94391112348955909123 ms
+21.12250.89091122349395953124 ms
+41.25990.79371142450276085127 ms
+61.41420.70711162451156173129 ms

100ms대의 선행 버퍼가 필요하다는 뜻입니다. 오버랩은 반음 값과 무관하게 항상 352 샘플로 고정입니다(오버랩만 DEFAULT_OVERLAP_MS = 8로 명시되어 있어 자동 계산 대상이 아닙니다).

이 지연은 사용자에게 두 가지로 나타납니다. 첫째, 키를 처음 켜는 순간 100ms 남짓의 무음이 지나갑니다. 워클릿이 출력 버퍼에 있는 만큼만 쓰고 나머지는 0으로 채우기 때문입니다.

JavaScript
for (let i = toExtract; i < frameCount; i++) {
  leftOutput[i] = 0;
  rightOutput[i] = 0;
}

둘째, 키가 켜져 있는 동안 오디오가 영상보다 그만큼 뒤에 갑니다. 노래방 반주 용도에서는 화면의 가사와 소리가 100ms 어긋나도 문제가 되지 않아 보정하지 않았습니다. 립싱크가 중요한 콘텐츠였다면 <video> 쪽에 같은 크기의 지연을 걸어 맞춰야 했을 것입니다.

FIFO가 오디오 스레드에서 메모리를 할당하지 않습니다

버퍼는 ES2024 resizable ArrayBuffer로 잡혀 있습니다.

JavaScript
var DEFAULT_MAX_FRAMES = 131072;
// ...
this._buffer = new ArrayBuffer(0, { maxByteLength: maxFrames * BYTES_PER_FRAME });

131072 프레임은 48kHz에서 약 2.73초, 버퍼당 최대 1MiB입니다. 읽기 포인터가 앞으로 밀리면 재할당 대신 rewind()로 미처리 샘플을 앞으로 당겨옵니다.

JavaScript
rewind() {
  if (this._position > 0) {
    this._vector.set(this._vector.subarray(this.startIndex, this.endIndex));
    this._position = 0;
  }
}

오디오 렌더 스레드에서 이것이 중요한 이유는 GC 때문입니다. 매 렌더 퀀텀(128 프레임, 48kHz에서 약 2.67ms)마다 할당이 일어나면 언젠가 수거가 돌고 그 순간 데드라인을 놓치면 곧바로 끊김으로 들립니다.

상관 탐색을 20분의 1로 줄입니다

WSOLA의 비용은 “다음에 이어붙일 가장 비슷한 지점”을 찾는 상관 탐색에 있습니다. _quickSeek이 기본 true이고 워클릿은 이 값을 끄지 않습니다. 켜져 있으면 전수 탐색 대신 계단식 4라운드 탐색을 씁니다.

JavaScript
var _SCAN_OFFSETS = [
  [124, 186, 248, ..., 1488, 0],      // 1라운드: 거친 격자 23개
  [-100, -75, -50, -25, 25, 50, 75, 100, 0, ...],  // 2라운드: 8개
  [-20, -15, -10, -5, 5, 10, 15, 20, 0, ...],      // 3라운드: 8개
  [-4, -3, -2, -1, 1, 2, 3, 4, 0, ...],            // 4라운드: 8개
];

+2 반음에서 seekLength는 1014입니다. 전수 탐색이면 상관을 1014번 계산해야 하는데 계단식은 최대 47번입니다. 상관 한 번이 2 * overlapLength = 704 샘플의 곱셈 누산이므로 전수는 약 71만 회, 계단식은 약 3.3만 회입니다.

이 차이가 실제로 중요한 이유는 부하가 균등하지 않기 때문입니다. process()는 매 퀀텀 128 프레임씩 받지만 WSOLA 반복은 nominalSkip(+2 반음에서 4086 프레임)마다 한 번씩만 돕니다. 즉 32개 퀀텀은 거의 아무 일도 하지 않고, 한 퀀텀이 전부를 몰아서 합니다. 그 한 퀀텀의 예산도 똑같이 2.67ms입니다. 전수 탐색이었다면 이 스파이크가 예산의 상당 부분을 먹었을 것입니다.

두 가지 흔적

읽다 보면 라이브러리가 스스로 무엇을 걱정했는지도 보입니다. 하나는 이름과 동작이 반대인 헬퍼입니다.

JavaScript
function testFloatEqual(a, b) {
  return (a > b ? a - b : b - a) > 1e-10;
}
// ...
if (testFloatEqual(this._tempo, previousTempo)) this.stretch.tempo = this._tempo;

이름은 “같은지 확인”인데 실제로는 “다른지 확인”입니다. 호출부가 “값이 바뀌었을 때만 재계산”을 의도하므로 동작은 맞습니다. 매 퀀텀 세 번씩 들어오는 파라미터 설정에서 시퀀스 재계산을 건너뛰게 해주는 가드라 성능상 꽤 중요합니다.

다른 하나는 출력 직전의 위생 처리입니다.

JavaScript
leftOutput[i] = Number.isFinite(l) ? l : 0;
rightOutput[i] = Number.isFinite(r) ? r : 0;

WSOLA 상관 탐색은 오프셋이 음수가 될 수 있고, 그러면 버퍼 범위 밖을 읽어 NaN이 나올 수 있습니다. NaN은 어떤 비교에서도 이기지 못해 최적 오프셋이 되지는 않지만 라이브러리는 그럼에도 스피커로 나가는 마지막 지점에서 한 번 더 막습니다. 오디오에서 NaN 한 샘플은 그 자체로 클릭 잡음입니다.

이 절에서 확인한 것 가운데 README에 적혀 있는 것은 하나도 없습니다. 실패가 곧바로 소리로 들리는 영역에서는 남의 라이브러리라도 한 번은 열어보고 쓰는 편이 낫습니다.


5. 훅이 들고 있는 네 개의 모듈 레벨 상태

use-pitch-shift.ts는 256줄이지만 구조를 이해하는 열쇠는 파일 상단의 모듈 레벨 변수 네 개입니다. 전부 리액트 상태가 아니라 모듈 상태인 데는 각각 다른 이유가 있습니다.

TypeScript
let sharedCtx: AudioContext | null = null;
let workletLoadPromise: Promise<boolean> | null = null;
const elementGraphs = new WeakMap<
  HTMLMediaElement,
  {
    source: MediaElementAudioSourceNode;
    pitchNode: SoundTouchNodeLike;
    gainNode: GainNode;
  }
>();
let soundTouchNodeCtorPromise: Promise<SoundTouchNodeConstructor | null> | null = null;

**sharedCtx**는 AudioContext 싱글턴입니다. 브라우저는 문서당 만들 수 있는 AudioContext 개수에 제한을 두고 컨텍스트마다 하드웨어 오디오 스레드를 하나씩 붙잡습니다. 컴포넌트가 리마운트될 때마다 새로 만들면 곧 한도에 걸립니다.

**workletLoadPromise**는 워클릿 스크립트 로드의 중복 제거입니다. 값이 boolean이 아니라 Promise<boolean>인 것이 핵심입니다. 로드가 진행 중일 때 두 번째 호출이 들어오면 다시 받는 대신 같은 프라미스를 기다립니다. 실패하면 null로 되돌려 다음 시도에서 재시도할 수 있게 합니다.

TypeScript
workletLoadPromise = SoundTouchNode.register(ctx, WORKLET_URL)
  .then(() => true)
  .catch((err) => {
    console.error('[PitchShift] SoundTouch worklet load failed', err);
    workletLoadPromise = null;
    return false;
  });

**soundTouchNodeCtorPromise**는 조금 다릅니다. 워클릿 파일이 아니라 메인 스레드 쪽 npm 모듈의 동적 import를 캐시합니다. 원래는 파일 상단의 정적 import였는데 나중에 함수 안의 import()로 바꿨습니다. 이유는 SSR입니다. @soundtouchjs/audio-worklet은 모듈 평가 시점에 AudioWorkletNode 같은 브라우저 전용 전역을 참조하기 때문에, Next.js 서버 번들에 그래프가 포함되면 평가 단계에서 ReferenceError가 납니다.

**elementGraphs**는 Web Audio의 딱딱한 제약 때문에 있습니다.

하나의 미디어 요소에 대해 createMediaElementSource()는 한 번만 호출할 수 있습니다.

리액트에서는 이게 바로 문제가 됩니다. 컴포넌트가 리마운트될 때마다 훅이 다시 실행되는데, 같은 <video> 요소에 소스를 두 번 만들려고 하면 예외가 납니다. 그래서 요소를 키로 삼아 그래프를 기억해둡니다.

일반 Map이 아니라 WeakMap인 것도 이유가 있습니다. 키가 DOM 요소이기 때문입니다. 일반 Map에 담으면 요소가 화면에서 사라져도 맵이 참조를 붙들고 있어 <video> 요소와 그에 딸린 오디오 노드가 통째로 누수됩니다. 뒤에서 보겠지만 이 콘솔은 곡이 바뀔 때마다 <video>를 새로 만들기 때문에 방송 한 회차에 요소가 수십 개 쌓입니다.


6. 기능 탐지가 페이지를 죽였습니다

AudioWorklet이 없는 브라우저에서는 이 기능이 동작하지 않습니다. Safari는 14.1에서야 들어왔습니다. 그래서 지원 여부를 먼저 판별해 UI를 비활성화하고 이유를 표시합니다.

첫 구현은 이랬습니다.

TypeScript
const ctxProto = window.AudioContext.prototype as unknown as {
  audioWorklet?: unknown;
};
return ctxProto.audioWorklet !== undefined;

이 판별은 곧바로 고쳤습니다. 콘솔 페이지 전체가 죽었기 때문입니다.

audioWorklet은 프로토타입에 정의된 인스턴스 게터입니다. 프로토타입 객체 자체를 리시버로 두고 값을 꺼내려 하면 게터가 실행되면서 예외가 납니다. 그리고 이 판별은 useState 초기화 시점에 불립니다. 즉 하이드레이션 중에 던져진 예외가 콘솔 페이지 전체를 내렸습니다.

게터를 아예 실행하지 않는 방식으로 고쳤습니다.

TypeScript
function isAudioWorkletSupported(): boolean {
  if (typeof window === 'undefined') return false;
  const Ctor = window.AudioContext;
  if (typeof Ctor !== 'function') return false;
  // Use `in` instead of property access. `audioWorklet` is an instance
  // getter that throws "Illegal invocation" when accessed off the prototype
  // in some Chromium builds.
  try {
    return 'audioWorklet' in Ctor.prototype;
  } catch {
    return false;
  }
}

in 연산자는 프로퍼티 디스크립터의 존재만 확인하고 게터를 호출하지 않습니다. 목적이 존재 확인이므로 그것으로 충분합니다. 바깥의 try/catch는 미래의 다른 브라우저 기벽에 대한 보험입니다.

기능 탐지 코드가 기능 탐지 도중에 터지는 것은 원래 막으려던 것보다 훨씬 나쁩니다. 지원하지 않는 브라우저를 우아하게 다루려던 코드가 지원하는 브라우저에서 페이지 전체를 내렸습니다.


7. 첫 클릭에 미리 연결하면 안 되는 이유

브라우저는 사용자 제스처 없이 오디오를 재생하지 못하게 막습니다. AudioContextsuspended 상태로 만들어지고, 클릭이나 키 입력이 있어야 resume()이 통합니다.

그래서 처음에는 페이지의 첫 클릭에서 컨텍스트를 깨우면서 그래프 연결까지 함께 했습니다. 미리 준비해두면 키를 조절하는 순간 지연이 없기 때문입니다.

이게 전혀 다른 곳에서 버그를 만들었습니다. 증상은 “추천 영상 카드를 눌러도 다음 곡으로 안 넘어간다”였습니다. <video>가 첫 곡에 영원히 고정됐습니다.

한 증상에 원인이 셋이었습니다

이 증상을 잡는 동안 임시 로그를 두 번 심었습니다. 가설이 네 개였고 로그 다섯 지점을 심어 그중 어디서 전파가 끊기는지 좁혀 들어갔습니다. 결과적으로 하나의 증상 뒤에 서로 독립적인 원인이 셋 있었습니다.

원인층위
savingUrl 로컬 state가 스턱되어 모든 카드가 영구 비활성화리액트 상태
첫 클릭에 createMediaElementSource가 요소를 고정Web Audio
진행 중 resolve가 있으면 새 클릭을 조용히 버림비동기 레이스

첫 번째는 뮤테이션 상태를 직접 들고 있다가 어긋난 경우입니다. savingUrlmutationFn 안에서 켜고 onSettled에서만 껐습니다. 콜백이 한 번이라도 불리지 않는 경로가 생기면 그 값이 켜진 채로 남아 모든 카드가 영구히 비활성화됩니다.

세 번째는 조기 반환이 클릭을 삼킨 경우입니다. resolveVideoFromResolver에 이전 resolve가 아직 진행 중이면 그대로 반환하는 가드가 있었습니다. 카드 A를 누르면 resolve 요청이 시작됩니다. 응답이 오기 전 1~2초 사이에 카드 B를 누르면 그 클릭이 이 가드에서 조용히 사라집니다. 카드 B의 videoUrl은 재생 URL 조회까지 도달하지도 못합니다.

셋 다 “카드를 눌러도 반응이 없다”로 똑같이 보입니다. 하나를 고치면 증상이 줄어들 뿐 사라지지 않으니, 고친 사람 입장에서는 “고쳤는데 또 그러네”가 됩니다. 진짜로 끝났다고 말할 수 있었던 것은 로그로 전파 경로를 한 단계씩 확인한 뒤였습니다. 재현 빈도가 줄어드는 것은 원인이 사라졌다는 증거가 아닙니다.

그중 오디오 그래프가 한 일

가운데 원인이 이 글의 주제입니다. usePitchShift의 제스처 리스너가 페이지 어디서든 첫 pointerdown이 들어오는 순간 connect()를 불렀고, 그 안에서 현재 <video>에 대고 createMediaElementSource()가 실행됐습니다. Chromium에서는 이렇게 한 번 묶인 요소가 새 src를 받아들이지 않습니다. 곡 URL은 정확히 저장되고 리액트 상태도 갱신되는데 화면의 영상만 첫 곡에 멈춰 있던 이유입니다.

증상은 영상 전환이고 원인은 오디오 그래프였습니다. 두 기능은 코드상 아무 관계가 없어 보입니다. 게다가 키 조절을 한 번도 쓰지 않은 사용자에게 일어났습니다. 페이지 어딘가를 한 번 클릭하기만 하면 됐기 때문입니다.

해결은 두 겹입니다. 첫째, 제스처 핸들러에서 connect()를 뺍니다.

TypeScript
// Resume an already-created AudioContext on the next user gesture if it
// stays suspended. We deliberately do NOT call connect() here. That would
// bind the <video> element to MediaElementSource on the user's very first
// page click, even when pitch shift is unused. Some browsers then lock the
// bound element to its current src and refuse to load new src values, which
// makes recommended-video swaps appear stuck. Connection now happens only
// through the dedicated effect below when pitch is actually engaged.
useEffect(() => {
  if (state.unsupported) return;
  const handler = () => {
    const ctx = sharedCtx;
    if (ctx && ctx.state === 'suspended') {
      void ctx.resume().catch(() => {});
    }
  };
  window.addEventListener('pointerdown', handler, { once: true });
  window.addEventListener('keydown', handler, { once: true });
  // ...
}, [state.unsupported]);

연결은 키가 실제로 걸릴 때만 합니다.

TypeScript
// Connect when needed: enabled and a non-zero pitch is requested.
useEffect(() => {
  if (!enabled || pitchSemitones === 0) return;
  void connect();
}, [enabled, pitchSemitones, connect]);

둘째, 이미 연결한 뒤에 곡이 바뀌는 경우에 대비해 <video> 요소 자체를 버립니다.

TSX
<video
  // Re-mount the element on every URL change. createMediaElementSource
  // (used by usePitchShift) permanently locks the bound <video> to
  // its first src in some browsers, so we let React drop the old
  // element and create a fresh one on each playback URL change.
  key={fallbackVideoUrl}
  ref={fallbackVideoRef}
  src={fallbackVideoUrl}
  // ...
/>

key에 URL을 넣으면 리액트가 요소를 재사용하지 않고 새로 만듭니다. 낡은 요소는 낡은 그래프와 함께 통째로 버려지고 WeakMap이 알아서 정리합니다. 5절에서 Map 대신 WeakMap을 쓴 이유가 여기서 회수됩니다.

기능을 쓰지 않는 사용자는 이제 오디오 그래프에 아예 들어오지 않습니다. 미리 준비해두는 최적화가 쓰지도 않는 사용자에게 부작용을 떠넘기고 있었던 셈입니다.


8. 0 반음은 물리적으로 우회합니다

최종 그래프 구조입니다.

flowchart LR
    V["video 요소"] --> S["MediaElementSource"]
    S -->|"키 조절 ON 이고 값이 0 이 아님"| P["SoundTouchNode<br/>AudioWorklet"]
    S -->|"OFF 이거나 0 반음"| G["GainNode"]
    P --> G
    G --> D["destination"]

핵심은 소스에서 나가는 선이 두 갈래라는 점입니다.

TypeScript
const usePitch = enabled && pitchSemitones !== 0;

try {
  graph.source.disconnect();
} catch {
  // disconnect() throws if there were no outgoing edges yet, which is fine.
}
if (usePitch) {
  graph.source.connect(graph.pitchNode);
  graph.pitchNode.pitchSemitones.value = pitchSemitones;
} else {
  graph.source.connect(graph.gainNode);
  graph.pitchNode.pitchSemitones.value = 0;
}

SoundTouch에도 “0 반음이면 그대로 통과”에 해당하는 경로가 있습니다. 그래도 그 경로는 쓰지 않았습니다. 통과 모드에서도 모든 샘플이 WSOLA 버퍼를 그대로 지나가기 때문입니다.

4절의 계산이 이 결정을 뒷받침합니다. pitchSemitones = 0이면 tempo = 1, rate = 1이지만 Stretch.process()는 여전히 돕니다. 시퀀스 110ms, 탐색 창 23ms로 상관 탐색을 하고, 오버랩 352 샘플을 크로스페이드하고, 100ms대의 FIFO 백로그를 유지합니다. 통과 모드가 하는 일은 “아무것도 안 하는 것”이 아니라 “결과가 같아 보이도록 전부 다 하는 것”입니다.

키를 건드리지 않은 사용자는 그 대가를 치를 이유가 없습니다. 원본과 비트 단위로 동일한 소리가 나가야 하고, 그 조건은 라이브러리의 통과 모드가 아니라 그래프 배선으로 보장하는 것이 확실합니다.

노드를 매번 새로 만들지 않고 연결만 바꾸는 것도 의도적입니다.

TypeScript
// Static legs: pitchNode → gain → destination. The source is wired to
// either pitchNode (active) or gainNode directly (bypass) below, so we
// can swap routes without recreating any node.
pitchNode.connect(gainNode);
gainNode.connect(ctx.destination);

pitchNode → gain → destination 구간은 고정해두고 소스의 출력만 옮기기 때문에 방송 중에 키를 바꿔도 노드가 재생성되면서 소리가 끊기지 않습니다.


9. 그래프에 도달하기까지의 관문 세 개

여기까지가 오디오 이야기이고 실제로 이 그래프가 켜지려면 통과해야 하는 관문이 세 개 더 있습니다.

CORS: 오염된 요소는 그래프에 못 들어옵니다

createMediaElementSource()는 CORS로 오염된(tainted) 미디어 요소를 받지 않습니다. 정확히는 받기는 하는데 무음을 내보냅니다. crossOrigin="anonymous"를 붙인 이유입니다.

그런데 이 속성은 곧 조건부로 되돌렸습니다. crossOrigin을 무조건 붙이면 Access-Control-Allow-Origin을 주지 않는 호스트의 영상은 재생 자체가 안 되기 때문입니다. 저장된 반주 URL을 표본 조사해 보니 99.6%가 YouTube이고 나머지 0.4%가 니코니코나 구글 문서 같은 외부 호스트였습니다.

0.4%를 위해 99.6%의 기능을 포기할 이유도, 반대로 0.4%를 조용히 깨뜨릴 이유도 없습니다. 그래서 CORS와 Range를 보장하는 호스트, 즉 직접 운영하는 캐시와 미디어 게이트웨이만 좁은 allowlist로 관리합니다. 목록을 좁게 유지하는 이유는 일반 재생이 되는 것과 Web Audio 그래프에 넣을 수 있는 것이 다르기 때문입니다.

allowlist 밖의 영상은 그래프에 들어가지 않고 UI가 이유를 표시합니다.

TypeScript
unavailableReason={
  !isGatewayPlaybackUrl
    ? '이 영상은 키 조절을 지원하지 않습니다 (외부 호스팅)'
    : null
}

Feature flag: 훅을 조건부로 부를 수 없어서 컴포넌트를 나눴습니다

flag가 꺼져 있으면 AudioContext조차 만들지 않아야 합니다. 그런데 리액트 훅은 조건부로 호출할 수 없습니다. 그래서 훅 호출 자체를 별도 컴포넌트로 밀어내고, flag가 켜져 있을 때만 부모가 그 컴포넌트를 마운트하게 했습니다.

부모는 이렇게 씁니다.

TSX
const isPitchShiftEnabled = useFeatureFlag('consolePitchShift');
// ...
{isPitchShiftEnabled && <PitchShiftSection ... />}

flag 정의는 기본 off입니다. 3절에서 본 것처럼 잡음 문제가 미해결인 상태로 prod의 나머지 변경을 내보내기 위해 만든 장치입니다.

TypeScript
consolePitchShift: { key: 'console-pitch-shift', default: false },

SSR: 같은 문제를 세 층에서 막습니다

오디오 SDK는 모듈 평가 시점에 브라우저 전용 전역을 참조합니다. Next 16 Turbopack은 클라이언트에서 ssr: false로 감싸도 서버 번들에 모듈 그래프를 포함시키기 때문에, 평가 단계에서 ReferenceError가 납니다. 방어가 세 층입니다.

  1. 컴포넌트를 dynamic(..., { ssr: false })로 로드합니다.
  2. npm 모듈을 파일 상단 정적 import 대신 함수 안의 import()로 가져옵니다.
  3. 번들러 레벨에서 서버 조건일 때 빈 stub으로 alias 합니다.
TypeScript
turbopack: {
  resolveAlias: {
    // audio SDK 는 server 평가 시 AudioWorkletNode 등 browser-only API 를
    // 정적 참조한다. browser condition 에서는 실제 패키지를, 그 외 (server)
    // 에서는 stub 으로 alias 해 SSR ReferenceError 를 차단한다.
    "@soundtouchjs/audio-worklet": {
      browser: "@soundtouchjs/audio-worklet",
      default: "./empty-canvas-stub.js",
    },
  },
},

webpack 블록에도 같은 alias가 중복으로 선언되어 있습니다. Turbopack이 webpack 블록을 해석하지 않고, resolveAlias는 값으로 false를 허용하지 않아 빈 stub 파일을 쓴다는 차이만 있습니다.

한편 워클릿 파일 자체는 번들러를 거치지 않습니다. AudioWorkletGlobalScope는 URL로 스크립트를 받기 때문에 npm 패키지의 dist 파일을 public/으로 복사해두고 그 경로를 넘깁니다.

JSON
"sync-worklets": "cp node_modules/@soundtouchjs/audio-worklet/dist/soundtouch-processor.js public/audio-worklets/soundtouch-processor.js"

10. UI와 저장: ±6, 그리고 사라진 슬라이더

지원 범위와 노출 범위가 다릅니다

훅의 타입은 -12에서 +12를 받고 백엔드 DTO도 같은 범위를 검증합니다.

TypeScript
export interface PitchShiftOptions {
  /** Enable/disable the pitch graph entirely (kill switch / bypass). */
  enabled: boolean;
  /** -12..+12 (UI clamps to ±6). 0 also disables the graph. */
  pitchSemitones: number;
}
TypeScript
@IsOptional()
@IsInt()
@Min(-12)
@Max(12)
preferredPitchSemitones?: number | null;

그런데 UI는 절반만 엽니다.

TypeScript
const MIN_SEMITONES = -6;
const MAX_SEMITONES = 6;
// ...
const clamp = (v: number) =>
  Math.max(MIN_SEMITONES, Math.min(MAX_SEMITONES, v));

한 옥타브를 통째로 옮기는 것은 노래방 키 조절의 용례가 아니고, 변위가 커질수록 WSOLA의 이어붙임 흔적도 드러납니다. 지원 범위를 넓게 두고 노출 범위를 좁히면 나중에 UI 상수 두 개만 바꿔서 열 수 있습니다. 반대 방향(좁게 만들어놓고 넓히기)은 저장된 데이터를 다시 손봐야 합니다.

clamp가 슬라이더의 min/max와 중복인 것처럼 보이지만 버튼 클릭 경로(onChange(clamp(value - 1)))와 값 주입 경로 양쪽을 같은 함수로 막아둔 것입니다.

세 가지 상태를 각각 다르게 보여줍니다

조용히 실패하면 사용자는 버튼을 눌러도 소리가 안 바뀌는 이유를 알 수 없습니다. 그래서 훅이 세 가지 상태를 돌려줍니다.

TypeScript
export interface PitchShiftState {
  /** Worklet loaded and graph connected for the current videoRef. */
  ready: boolean;
  /** AudioWorklet is unavailable in this browser; UI should be disabled. */
  unsupported: boolean;
  error: string | null;
}

UI는 원인별로 다른 문구를 냅니다. 브라우저가 못 하는 것과 이 영상이 안 되는 것은 사용자가 취할 행동이 다르기 때문입니다.

TSX
{unsupported && (
  <p className="text-[11px] text-muted-foreground">
    이 브라우저는 키 조절을 지원하지 않습니다 (AudioWorklet 미지원).
  </p>
)}
{!unsupported && unavailableReason && (
  <p className="text-[11px] text-muted-foreground">{unavailableReason}</p>
)}

error는 워클릿 로드 실패와 그래프 연결 실패를 구분해서 담습니다.

TypeScript
setState({ ready: false, unsupported: true, error: 'worklet 로드 실패' });
// ...
setState({
  ready: false,
  unsupported: false,
  error: `오디오 그래프 연결 실패: ${message}`,
});

한 가지 남은 빚은 이 값에 텔레메트리가 없다는 것입니다. 같은 페이지의 재생 상태 전환은 PostHog로 보내고 있지만 키 조절에는 이벤트가 하나도 붙어 있지 않습니다. 어떤 브라우저에서 unsupported가 얼마나 나오는지, error가 실제로 몇 번 발생하는지 데이터가 없습니다.

곡마다 키를 저장합니다

스트리머가 매번 같은 곡에서 같은 키를 다시 맞추는 것은 낭비입니다. 그래서 저장 기능을 붙였습니다. 저장 위치는 Song.preferredPitchSemitones입니다.

Prisma
/// 콘솔(리모컨) 키 조절(pitch shift) 저장값. 사용자가 영상에서 키 맞춘 뒤 명시적으로 저장한
/// semitone 오프셋. -12..+12 (UI 는 ±6 으로 클램프). 본질적으로 영상-종속이라
/// karaokeUrl 이 바뀌면 자동 null 로 초기화되어야 한다 (영상 캐시 키 컬럼과 동일 패턴).
preferredPitchSemitones Int?           @map("preferred_pitch_semitones")

이 값이 본질적으로 영상에 종속된다는 점이 중요합니다. +2라는 값은 곡의 속성이 아니라 그 반주 영상의 원래 키에 대한 상대값입니다. 다른 반주로 바꾸면 의미가 없어집니다. 그래서 백엔드가 URL 변경을 감지하면 같은 트랜잭션에서 지웁니다.

TypeScript
if (patch.karaokeUrl !== undefined) {
  data.karaokeUrl = patch.karaokeUrl;
  // preferredPitchSemitones 는 아직 Song 단위 저장값이므로 URL 변경 시 초기화.
  data.preferredPitchSemitones = null;
}

이 PATCH는 콘솔 토큰 전용 엔드포인트입니다. 토큰이 유출됐을 때의 blast radius를 제한하려고 허용 필드 화이트리스트로만 받습니다.

저장했는데 슬라이더가 0으로 돌아갔습니다

저장 기능을 넣은 뒤 인시던트가 하나 났습니다. +1을 저장하고 새로고침하면 슬라이더가 잠깐 그 위치로 갔다가 0으로 돌아와 멈춰버렸습니다.

원인은 저장이 아니라 초기값 동기화였습니다. 슬라이더는 savedPitchSemitones가 바뀔 때마다 그 값을 따라가게 되어 있었는데, 소켓 payload와 REST 응답과 큐 재동기화가 같은 상태를 각자 갱신하다 보니 그중 하나가 잠깐 null을 넣는 순간이 있었습니다. 그러면 훅이 슬라이더를 0으로 되돌렸습니다.

수정은 “곡이 바뀌었을 때만 리셋”과 “같은 곡 안에서는 명시적 숫자일 때만 반영”으로 나누는 것이었습니다.

TypeScript
// 곡 식별자 추적 ref. 곡 변경 시에만 reset, 같은 곡 안에서는 number 도착
// 시에만 적용. 같은 곡 동안 savedPitchSemitones 가 일시적으로 null/undefined
// 로 들어오는 경우(socket payload glitch / 응답 캐시 race 등)에 0 으로 덮어
// 슬라이더가 stuck 되는 문제 방지.
const lastNowPlayingIdRef = useRef<typeof nowPlayingId>(undefined);
useEffect(() => {
  const songChanged = lastNowPlayingIdRef.current !== nowPlayingId;
  if (songChanged) {
    lastNowPlayingIdRef.current = nowPlayingId;
    setPitchSemitones(savedPitchSemitones ?? 0);
    setPitchBypass(false);
    return;
  }
  // 같은 곡. savedPitchSemitones 가 명시적 number 로 변경됐을 때만 적용.
  // null/undefined 는 응답 미도착 또는 일시적 glitch 로 간주하고 이전 값 유지.
  if (typeof savedPitchSemitones === 'number') {
    setPitchSemitones(savedPitchSemitones);
  }
}, [nowPlayingId, savedPitchSemitones]);

null과 “값이 0”이 서로 다른 의미인데 ?? 0이 둘을 같게 만들어버린 것이 핵심입니다. “값 없음”을 기본값으로 접는 코드는 그 기본값이 유효한 값이기도 할 때 정보를 잃습니다.


11. 같은 라이브러리를 편집기는 다르게 씁니다

같은 제품에는 영상 편집기도 있고 거기에도 SoundTouch가 들어 있습니다. 다만 완전히 다른 방식입니다.

편집기가 쓰는 것은 soundtouchjs 0.3.0이고 타입 선언만 봐도 차이가 드러납니다.

TypeScript
declare module "soundtouchjs" {
  export class PitchShifter {
    constructor(
      context: BaseAudioContext,
      buffer: AudioBuffer,
      bufferSize: number,
      onEnd?: () => void,
    );
    tempo: number;
    pitch: number;
    connect(destination: AudioNode): void;
    off(): void;
  }
}

생성자가 AudioBuffer통째로 받습니다. 이 API는 라이브 <video>에 쓸 수가 없습니다. 재생 중인 요소에는 디코딩된 전체 버퍼라는 것이 존재하지 않기 때문입니다.

실제 사용은 OfflineAudioContext 안에서 렌더링하는 형태입니다.

TypeScript
const stretchCtx = new OfflineAudioContext(
  numChannels,
  outputSamples,
  targetSampleRate,
);
const shifter = new PitchShifter(stretchCtx, resampledBuffer, 4096);
shifter.tempo = rate;
shifter.pitch = 1;
shifter.connect(stretchCtx.destination);
return stretchCtx.startRendering();

tempo = rate, pitch = 1, 즉 길이만 바꾸고 음정은 유지합니다. 콘솔과 정확히 반대 방향입니다. 편집기에서 클립을 2배속으로 늘였을 때 목소리가 다람쥐처럼 변하지 않게 하는 용도입니다.

그리고 편집기에도 4절에서 본 것과 같은 뿌리의 제약이 있습니다. soundtouchjs는 원본 버퍼의 샘플레이트를 존중하지 않고 채널 데이터를 그대로 읽기 때문에, 넘기기 전에 목표 샘플레이트로 먼저 리샘플링해 두어야 합니다.

SoundTouch 계열은 자기가 받는 샘플의 진짜 샘플레이트를 모릅니다. 콘솔 쪽에서는 그것이 Stretch 생성자의 하드코딩된 44100으로, 편집기 쪽에서는 “쓰기 전에 직접 리샘플링해라”는 사전 처리로 나타납니다.

두 사용법을 나란히 놓으면 이렇습니다.

콘솔편집기
패키지@soundtouchjs/audio-worklet 1.0.10soundtouchjs 0.3.0
실행 위치AudioWorklet (오디오 렌더 스레드)OfflineAudioContext (렌더링 잡)
입력라이브 MediaElementSource 스트림디코딩 완료된 AudioBuffer 전체
목적음정만 변경, 길이 유지길이만 변경, 음정 유지
파라미터pitchSemitones (AudioParam, k-rate)tempo = rate, pitch = 1
시간 제약퀀텀당 2.67ms 데드라인없음 (결과를 캐시)
지연100ms대 선행 버퍼무관 (오프라인)
실패 시소리가 끊김렌더가 느려짐

같은 DSP인데 제약이 완전히 다르니 API도 다르고 실패 양상도 다릅니다. 편집기 쪽은 결과 버퍼를 캐시해두고 재생 시에는 그냥 AudioBufferSourceNode로 틀기 때문에, 무거운 연산이 실시간 경로에 아예 들어오지 않습니다. 콘솔은 그렇게 할 수 없어서 워클릿과 데드라인을 감수합니다.


12. 마무리

키 조절은 길이를 그대로 두고 주파수만 옮기는 문제였고 그것을 브라우저 안에서 실시간으로 풀어야 했습니다. 위상 보코더는 이론적으로 옳았지만 이 그래프에서는 잡음이 남았고 시간 영역에서 도는 WSOLA로 옮기고 나서야 소리가 깨끗해졌습니다. 대신 100ms대의 선행 버퍼를 받아들였습니다. 노래방 반주에서는 화면과 소리가 그만큼 어긋나도 문제가 되지 않습니다.

DSP만큼이나 주변이 까다로웠습니다. 요소당 한 번뿐인 createMediaElementSource() 때문에 그래프를 WeakMap에 기억해야 했고 연결 시점은 첫 클릭이 아니라 키가 실제로 걸리는 순간까지 미뤘습니다. 0 반음은 라이브러리의 통과 모드가 아니라 배선으로 우회합니다. 그래프가 켜지려면 CORS 오염, feature flag, SSR이라는 관문 셋을 통과해야 하고 저장된 키 값은 반주 영상이 바뀌면 함께 지워집니다.

아직 없는 것은 데이터입니다. unsupportederror가 어느 브라우저에서 얼마나 나오는지 확인할 방법이 없어서 다음에 손댈 곳은 알고리즘이 아니라 텔레메트리입니다.

이 반주 영상이 어디서 어떻게 흘러나오는지는 유튜브 임베드를 벗어나 직접 재생하기에 정리했습니다.

시리즈 · 노래책과 신청곡

3 / 5

개떡같이 말해도 알아듣고, 키를 바꾸고, 클립으로 남기기까지 노래책을 이루는 조각들.

  1. 1.!신청 사랑하긴 그 긴거 : 개떡같이 말해도 찰떡같이 알아듣는 LLM 기반 신청곡 매칭 로직
  2. 2.노래 제목과 Musixmatch 트랙 매칭하기 - LLM Agent와 HITL(Human-in-the-Loop)
  3. 3.확장 프로그램 없이 브라우저에서 영상 키 조절하기
  4. 4.유튜브 임베드를 벗어나 직접 재생하기 : 광고 없이 video 태그로 유튜브 영상 서빙하기
  5. 5.노래클립 자동생성 파이프라인 - 방송 녹화, STT, Agent 판정