본문으로 건너뛰기

[openclaw] Node.js 오디오 코덱 성능 최적화: TypedArray를 활용한 효율적인 PCM 처리

PR 링크: openclaw/openclaw#86856 상태: Merged | 변경: +77 / -21

들어가며

이번 PR은 Node.js 환경에서 오디오 코덱의 성능을 향상시키는 중요한 개선 사항을 담고 있습니다. 특히, 오디오 데이터 처리의 핵심 경로(hot paths)에서 Buffer의 직접적인 접근 대신 TypedArray (구체적으로 Int16Array)를 활용하여 PCM(Pulse Code Modulation) 데이터의 리샘플링 및 mu-law 변환 과정을 최적화합니다. 기존 코드에서는 BufferreadInt16LEwriteInt16LE 메서드를 반복적으로 호출하며 샘플 단위로 데이터를 읽고 썼는데, 이는 특히 데이터 정렬(alignment)이 맞지 않거나 버퍼의 시작 오프셋이 Int16Array의 요소 크기(BYTES_PER_ELEMENT)의 배수가 아닐 경우 비효율적일 수 있습니다. 이 PR은 이러한 비효율을 제거하고, 가능한 경우 Int16Array 뷰를 직접 사용하여 메모리 접근을 간소화함으로써 성능을 개선합니다.

또한, 정렬되지 않은(unaligned) 버퍼에 대한 안전한 처리 로직을 유지하고, 리틀 엔디안(little-endian)이 아닌 경우에 대한 대비책도 마련하여 호환성을 유지합니다. 이 변경은 특히 실시간 오디오 처리와 같이 지연 시간에 민감한 애플리케이션에서 상당한 성능 향상을 기대할 수 있습니다.

코드 분석

1. src/talk/audio-codec.ts 파일 변경 분석

이 파일은 오디오 코덱의 핵심 로직을 담당하며, 이번 PR에서 가장 큰 변화가 일어난 곳입니다.

1.1. TypedArray 뷰 활용을 위한 헬퍼 함수 추가

+const HOST_IS_LITTLE_ENDIAN = new Uint16Array(new Uint8Array([1, 0]).buffer)[0] === 1;
+
 function clamp16(value: number): number {
   return Math.max(-32768, Math.min(32767, value));
 }
+
+function canUseInt16View(buffer: Buffer): boolean {
+  return HOST_IS_LITTLE_ENDIAN && buffer.byteOffset % Int16Array.BYTES_PER_ELEMENT === 0;
+}
+
+function int16View(buffer: Buffer): Int16Array {
+  return new Int16Array(
+    buffer.buffer,
+    buffer.byteOffset,
+    Math.floor(buffer.byteLength / Int16Array.BYTES_PER_ELEMENT),
+  );
+}
+
+function readInt16Samples(buffer: Buffer): Int16Array {
+  if (canUseInt16View(buffer)) {
+    return int16View(buffer);
+  }
+  const samples = new Int16Array(Math.floor(buffer.byteLength / Int16Array.BYTES_PER_ELEMENT));
+  for (let i = 0; i < samples.length; i += 1) {
+    samples[i] = buffer.readInt16LE(i * Int16Array.BYTES_PER_ELEMENT);
+  }
+  return samples;
+}
 
 function sinc(x: number): number {
   if (x === 0) {
@@ -65,8 +90,7 @@ }
 
 function sampleBandlimitedWithCoefficients(
-  input: Buffer,
-  inputSamples: number,
+  input: Int16Array,
   center: number,
   coefficients: Float64Array,
 ): number {
@@ -75,25 +99,24 @@ function sampleBandlimitedWithCoefficients(
 
   for (let tap = -RESAMPLE_HALF_TAPS; tap <= RESAMPLE_HALF_TAPS; tap += 1) {
     const sampleIndex = center + tap;
-    if (sampleIndex < 0 || sampleIndex >= inputSamples) {
+    if (sampleIndex < 0 || sampleIndex >= input.length) {
       continue;
     }
     const coeff = coefficients[tap + RESAMPLE_HALF_TAPS] ?? 0;
-    weighted += input.readInt16LE(sampleIndex * 2) * coeff;
+    weighted += (input[sampleIndex] ?? 0) * coeff;
     weightSum += coeff;
   }
 
   if (weightSum === 0) {
-    const nearest = Math.max(0, Math.min(inputSamples - 1, center));
-    return input.readInt16LE(nearest * 2);
+    const nearest = Math.max(0, Math.min(input.length - 1, center));
+    return input[nearest] ?? 0;
   }
 
   return weighted / weightSum;
 }
 
 function sampleBandlimited(
-  input: Buffer,
-  inputSamples: number,
+  input: Int16Array,
   srcPos: number,
   cutoffCyclesPerSample: number,
 ): number {
@@ -103,20 +126,20 @@ function sampleBandlimited(
 
   for (let tap = -RESAMPLE_HALF_TAPS; tap <= RESAMPLE_HALF_TAPS; tap += 1) {
     const sampleIndex = center + tap;
-    if (sampleIndex < 0 || sampleIndex >= inputSamples) {
+    if (sampleIndex < 0 || sampleIndex >= input.length) {
       continue;
     }
 
     const distance = sampleIndex - srcPos;
     const lowPass = 2 * cutoffCyclesPerSample * sinc(2 * cutoffCyclesPerSample * distance);
     const coeff = lowPass * (RESAMPLE_WINDOW[tap + RESAMPLE_HALF_TAPS] ?? 0);
-    weighted += input.readInt16LE(sampleIndex * 2) * coeff;
+    weighted += (input[sampleIndex] ?? 0) * coeff;
     weightSum += coeff;
   }
 
   if (weightSum === 0) {
-    const nearest = Math.max(0, Math.min(inputSamples - 1, Math.round(srcPos)));
-    return input.readInt16LE(nearest * 2);
+    const nearest = Math.max(0, Math.min(input.length - 1, Math.round(srcPos)));
+    return input[nearest] ?? 0;
   }
 
   return weighted / weightSum;
@@ -143,19 +166,25 @@ export function resamplePcm(
   const cutoffCyclesPerSample = Math.max(0.01, downsampleCutoff * RESAMPLE_CUTOFF_GUARD);
   const kernel = buildResampleKernel(inputSampleRate, outputSampleRate, cutoffCyclesPerSample);
 
+  const inputView = readInt16Samples(input);
+  const outputView = canUseInt16View(output) ? int16View(output) : undefined;
+
   for (let i = 0; i < outputSamples; i += 1) {
     const sample = Math.round(
       kernel
         ? sampleBandlimitedWithCoefficients(
-            input,
-            inputSamples,
+            inputView,
             Math.floor((i * inputSampleRate) / outputSampleRate),
             kernel.coefficients[(i * kernel.inputStep) % kernel.phaseCount] ??
               kernel.coefficients[0],
           )
-        : sampleBandlimited(input, inputSamples, i * ratio, cutoffCyclesPerSample),
+        : sampleBandlimited(inputView, i * ratio, cutoffCyclesPerSample),
     );
-    output.writeInt16LE(clamp16(sample), i * 2);
+    if (outputView) {
+      outputView[i] = clamp16(sample);
+    } else {
+      output.writeInt16LE(clamp16(sample), i * 2);
+    }
   }
 
   return output;
@@ -166,12 +195,11 @@ export function resamplePcmTo8k(input: Buffer, inputSampleRate: number): Buffer
 }
 
 export function pcmToMulaw(pcm: Buffer): Buffer {
-  const samples = Math.floor(pcm.length / 2);
-  const mulaw = Buffer.alloc(samples);
+  const pcmView = readInt16Samples(pcm);
+  const mulaw = Buffer.alloc(pcmView.length);
 
-  for (let i = 0; i < samples; i += 1) {
-    const sample = pcm.readInt16LE(i * 2);
-    mulaw[i] = linearToMulaw(sample);
+  for (let i = 0; i < pcmView.length; i += 1) {
+    mulaw[i] = linearToMulaw(pcmView[i] ?? 0);
   }
 
   return mulaw;
@@ -179,6 +207,14 @@ export function pcmToMulaw(pcm: Buffer): Buffer {
 
 export function mulawToPcm(mulaw: Buffer): Buffer {
   const pcm = Buffer.alloc(mulaw.length * 2);
+  const pcmView = canUseInt16View(pcm) ? int16View(pcm) : undefined;
+  if (pcmView) {
+    for (let i = 0; i < mulaw.length; i += 1) {
+      pcmView[i] = clamp16(mulawToLinear(mulaw[i] ?? 0));
+    }
+    return pcm;
+  }
+
   for (let i = 0; i < mulaw.length; i += 1) {
     pcm.writeInt16LE(clamp16(mulawToLinear(mulaw[i] ?? 0)), i * 2);
   }
  • HOST_IS_LITTLE_ENDIAN: 시스템의 엔디안(endianness)을 확인하는 상수입니다. Int16Array는 일반적으로 리틀 엔디안 바이트 순서를 가정하므로, 이 값이 true인지 확인하는 것이 중요합니다.
  • canUseInt16View(buffer: Buffer): 주어진 BufferInt16Array 뷰로 직접 변환될 수 있는지 여부를 판단합니다. 이는 두 가지 조건을 만족해야 합니다:
    1. 호스트 시스템이 리틀 엔디안이어야 합니다 (HOST_IS_LITTLE_ENDIAN).
    2. 버퍼의 시작 오프셋(buffer.byteOffset)이 Int16Array.BYTES_PER_ELEMENT (즉, 2바이트)의 배수여야 합니다. 이는 데이터가 2바이트 경계에 맞춰 정렬되어 있음을 의미합니다.
  • int16View(buffer: Buffer): BufferInt16Array의 뷰로 변환합니다. 이 뷰는 원본 Buffer의 메모리를 공유하므로, 별도의 데이터 복사가 발생하지 않아 효율적입니다.
  • readInt16Samples(buffer: Buffer): 이 함수는 핵심적인 최적화 지점입니다. 먼저 canUseInt16View를 호출하여 Buffer가 직접 Int16Array 뷰로 사용될 수 있는지 확인합니다. 만약 가능하다면, int16View를 반환하여 Buffer의 메모리를 직접 참조합니다. 그렇지 않은 경우에만, 기존 방식과 유사하게 Buffer.readInt16LE를 사용하여 샘플 단위로 데이터를 읽어 새로운 Int16Array에 복사합니다. 이로써 정렬된 버퍼의 경우 불필요한 readInt16LE 호출을 피하게 됩니다.

1.2. sampleBandlimitedWithCoefficientssampleBandlimited 함수 수정

이 함수들은 리샘플링 과정에서 입력 오디오 샘플을 보간(interpolation)하는 역할을 합니다. 기존에는 Buffer 객체를 직접 받아 readInt16LE를 사용했지만, 이제는 Int16Array를 입력으로 받도록 변경되었습니다.

 function sampleBandlimitedWithCoefficients(
-  input: Buffer,
-  inputSamples: number,
+  input: Int16Array,
   center: number,
   coefficients: Float64Array,
 ): number {
@@ -75,25 +99,24 @@ function sampleBandlimitedWithCoefficients(
 
   for (let tap = -RESAMPLE_HALF_TAPS; tap <= RESAMPLE_HALF_TAPS; tap += 1) {
     const sampleIndex = center + tap;
-    if (sampleIndex < 0 || sampleIndex >= inputSamples) {
+    if (sampleIndex < 0 || sampleIndex >= input.length) {
       continue;
     }
     const coeff = coefficients[tap + RESAMPLE_HALF_TAPS] ?? 0;
-    weighted += input.readInt16LE(sampleIndex * 2) * coeff;
+    weighted += (input[sampleIndex] ?? 0) * coeff;
     weightSum += coeff;
   }
 
   if (weightSum === 0) {
-    const nearest = Math.max(0, Math.min(inputSamples - 1, center));
-    return input.readInt16LE(nearest * 2);
+    const nearest = Math.max(0, Math.min(input.length - 1, center));
+    return input[nearest] ?? 0;
   }
 
   return weighted / weightSum;
 }
 
 function sampleBandlimited(
-  input: Buffer,
-  inputSamples: number,
+  input: Int16Array,
   srcPos: number,
   cutoffCyclesPerSample: number,
 ): number {
@@ -103,20 +126,20 @@ function sampleBandlimited(
 
   for (let tap = -RESAMPLE_HALF_TAPS; tap <= RESAMPLE_HALF_TAPS; tap += 1) {
     const sampleIndex = center + tap;
-    if (sampleIndex < 0 || sampleIndex >= inputSamples) {
+    if (sampleIndex < 0 || sampleIndex >= input.length) {
       continue;
     }
 
     const distance = sampleIndex - srcPos;
     const lowPass = 2 * cutoffCyclesPerSample * sinc(2 * cutoffCyclesPerSample * distance);
     const coeff = lowPass * (RESAMPLE_WINDOW[tap + RESAMPLE_HALF_TAPS] ?? 0);
-    weighted += input.readInt16LE(sampleIndex * 2) * coeff;
+    weighted += (input[sampleIndex] ?? 0) * coeff;
     weightSum += coeff;
   }
 
   if (weightSum === 0) {
-    const nearest = Math.max(0, Math.min(inputSamples - 1, Math.round(srcPos)));
-    return input.readInt16LE(nearest * 2);
+    const nearest = Math.max(0, Math.min(input.length - 1, Math.round(srcPos)));
+    return input[nearest] ?? 0;
   }
 
   return weighted / weightSum;
  • 입력 파라미터가 Buffer에서 Int16Array로 변경되었습니다.
  • input.readInt16LE(sampleIndex * 2) 대신 input[sampleIndex]를 사용하여 Int16Array의 요소에 직접 접근합니다. 이는 Int16Array 뷰가 사용 가능한 경우, BufferreadInt16LE 메서드 호출 오버헤드를 제거하고 메모리 접근을 단순화합니다.
  • 경계 검사(if (sampleIndex < 0 || sampleIndex >= input.length)) 로직이 inputSamples 대신 input.length를 사용하도록 업데이트되었습니다.

1.3. resamplePcm 함수 수정

리샘플링 함수의 핵심 로직이 Int16Array를 활용하도록 수정되었습니다.

 export function resamplePcm(
   // ... (생략)
 ): Buffer {
+  const inputView = readInt16Samples(input);
+  const outputView = canUseInt16View(output) ? int16View(output) : undefined;
+
   for (let i = 0; i < outputSamples; i += 1) {
     const sample = Math.round(
       kernel
         ? sampleBandlimitedWithCoefficients(
-            input,
-            inputSamples,
+            inputView,
             Math.floor((i * inputSampleRate) / outputSampleRate),
             kernel.coefficients[(i * kernel.inputStep) % kernel.phaseCount] ??
               kernel.coefficients[0],
           )
-        : sampleBandlimited(input, inputSamples, i * ratio, cutoffCyclesPerSample),
+        : sampleBandlimited(inputView, i * ratio, cutoffCyclesPerSample),
     );
-    output.writeInt16LE(clamp16(sample), i * 2);
+    if (outputView) {
+      outputView[i] = clamp16(sample);
+    } else {
+      output.writeInt16LE(clamp16(sample), i * 2);
+    }
   }
 
   return output;
  • readInt16Samples(input)를 호출하여 입력 BufferInt16Array 뷰(inputView)로 가져옵니다. 이 과정에서 Buffer가 정렬되어 있다면 효율적인 뷰가 생성되고, 그렇지 않다면 안전한 복사본이 생성됩니다.
  • 출력 Buffer(output)에 대해서도 canUseInt16View를 사용하여 Int16Array 뷰(outputView)를 생성할 수 있는지 확인합니다. 가능하다면 뷰를 사용하고, 그렇지 않으면 undefined로 둡니다.
  • sampleBandlimitedWithCoefficientssampleBandlimited 함수에 inputView를 전달합니다.
  • 계산된 sample 값을 outputView가 존재하는 경우 직접 할당하고, 그렇지 않은 경우에만 output.writeInt16LE를 사용하여 버퍼에 씁니다. 이는 출력 버퍼도 정렬되어 있을 때 쓰기 성능을 향상시킵니다.

1.4. pcmToMulaw 함수 수정

mu-law 변환 함수도 Int16Array를 활용하도록 변경되었습니다.

 export function pcmToMulaw(pcm: Buffer): Buffer {
-  const samples = Math.floor(pcm.length / 2);
-  const mulaw = Buffer.alloc(samples);
+  const pcmView = readInt16Samples(pcm);
+  const mulaw = Buffer.alloc(pcmView.length);
 
-  for (let i = 0; i < samples; i += 1) {
-    const sample = pcm.readInt16LE(i * 2);
-    mulaw[i] = linearToMulaw(sample);
+  for (let i = 0; i < pcmView.length; i += 1) {
+    mulaw[i] = linearToMulaw(pcmView[i] ?? 0);
   }
 
   return mulaw;
  • readInt16Samples(pcm)를 호출하여 입력 PCM 데이터를 Int16Array로 읽어옵니다.
  • 결과 mulaw 버퍼의 크기를 pcmView.length로 설정합니다.
  • 루프 내에서 pcmView[i]를 직접 사용하여 샘플 값을 읽고 linearToMulaw 함수에 전달합니다. 기존의 pcm.readInt16LE(i * 2) 호출이 제거되었습니다.

1.5. mulawToPcm 함수 수정

mu-law에서 PCM으로 변환하는 함수도 유사하게 Int16Array 뷰를 활용하도록 개선되었습니다.

 export function mulawToPcm(mulaw: Buffer): Buffer {
   const pcm = Buffer.alloc(mulaw.length * 2);
+  const pcmView = canUseInt16View(pcm) ? int16View(pcm) : undefined;
+  if (pcmView) {
+    for (let i = 0; i < mulaw.length; i += 1) {
+      pcmView[i] = clamp16(mulawToLinear(mulaw[i] ?? 0));
+    }
+    return pcm;
+  }
+
   for (let i = 0; i < mulaw.length; i += 1) {
     pcm.writeInt16LE(clamp16(mulawToLinear(mulaw[i] ?? 0)), i * 2);
   }
  • 출력 pcm 버퍼에 대해 Int16Array 뷰(pcmView)를 생성할 수 있는지 확인합니다.
  • 뷰 생성이 가능하면, 루프 내에서 pcmView[i]에 직접 값을 쓰고, 불가능한 경우에만 기존의 pcm.writeInt16LE를 사용합니다.

2. extensions/voice-call/src/telephony-audio.test.ts 파일 변경 분석

이 파일은 오디오 관련 기능에 대한 테스트를 포함하며, 이번 PR에서는 새로운 테스트 케이스가 추가되었습니다.

+function unalignedCopy(buffer: Buffer): Buffer {
+  const padded = Buffer.alloc(buffer.length + 1);
+  buffer.copy(padded, 1);
+  return padded.subarray(1);
+}
+
 describe("telephony-audio resamplePcmTo8k", () => {
   // ... (기존 테스트들)
+  it("matches the typed-array path for unaligned input buffers", () => {
+    const input = makeSinePcm(48_000, 1_000, 0.2);
+    const output = resamplePcmTo8k(input, 48_000);
+    const unalignedOutput = resamplePcmTo8k(unalignedCopy(input), 48_000);
+    expect(unalignedOutput.equals(output)).toBe(true);
+  });
 });
 
 describe("telephony-audio convertPcmToMulaw8k", () => {
   // ... (기존 테스트들)
+  it("matches the typed-array path for unaligned pcm buffers", () => {
+    const input = makeSinePcm(8_000, 1_000, 0.2);
+    const mulaw = convertPcmToMulaw8k(input, 8_000);
+    const unalignedMulaw = convertPcmToMulaw8k(unalignedCopy(input), 8_000);
+    expect(unalignedMulaw.equals(mulaw)).toBe(true);
+  });
 });
  • unalignedCopy(buffer: Buffer) 함수 추가: 이 함수는 입력 버퍼 앞에 1바이트를 추가하여 의도적으로 정렬되지 않은 버퍼를 생성합니다. 이는 Buffer.copysubarray를 사용하여 구현되었습니다.
  • 새로운 테스트 케이스 추가: resamplePcmTo8kconvertPcmToMulaw8k 함수에 대해, 원본 버퍼로 처리한 결과와 unalignedCopy 함수를 사용하여 생성한 정렬되지 않은 버퍼로 처리한 결과가 동일함을 검증하는 테스트가 추가되었습니다. 이는 Int16Array 뷰를 사용하지 못하는 예외 상황에서도 기존의 안전한 폴백(fallback) 로직이 올바르게 동작하며, 새로운 최적화된 경로와 동일한 결과를 보장함을 확인합니다.

왜 이게 좋은가?

1. 성능 향상

  • Buffer 접근 오버헤드 제거: Buffer.readInt16LEBuffer.writeInt16LE는 내부적으로 여러 검사를 수행하고 JavaScript 엔진의 최적화 대상에서 벗어날 수 있습니다. 반면, Int16Array의 인덱스 접근(input[sampleIndex])은 훨씬 직접적이고 빠릅니다. 특히 대량의 오디오 데이터를 처리할 때 이 차이는 누적되어 상당한 성능 향상을 가져올 수 있습니다.
  • 메모리 접근 효율성: Int16Array 뷰는 원본 Buffer의 메모리 영역을 직접 참조합니다. 이는 데이터를 별도의 메모리 공간으로 복사하는 오버헤드를 없애줍니다. resamplePcm 함수에서 inputViewoutputView를 사용하는 것이 대표적인 예입니다.
  • CPU 캐시 활용: 연속적인 메모리 영역에 대한 직접적인 접근은 CPU 캐시 효율성을 높여 전반적인 처리 속도를 개선할 수 있습니다.

PR 설명에 따르면, 이 변경은

참고 자료

⚠️ 알림: 이 분석은 AI가 실제 코드 diff를 기반으로 작성했습니다.

댓글

관련 포스트

PR Analysis 의 다른글