[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 변환 과정을 최적화합니다. 기존 코드에서는 Buffer의 readInt16LE와 writeInt16LE 메서드를 반복적으로 호출하며 샘플 단위로 데이터를 읽고 썼는데, 이는 특히 데이터 정렬(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): 주어진Buffer가Int16Array뷰로 직접 변환될 수 있는지 여부를 판단합니다. 이는 두 가지 조건을 만족해야 합니다:- 호스트 시스템이 리틀 엔디안이어야 합니다 (
HOST_IS_LITTLE_ENDIAN). - 버퍼의 시작 오프셋(
buffer.byteOffset)이Int16Array.BYTES_PER_ELEMENT(즉, 2바이트)의 배수여야 합니다. 이는 데이터가 2바이트 경계에 맞춰 정렬되어 있음을 의미합니다.
- 호스트 시스템이 리틀 엔디안이어야 합니다 (
int16View(buffer: Buffer):Buffer를Int16Array의 뷰로 변환합니다. 이 뷰는 원본Buffer의 메모리를 공유하므로, 별도의 데이터 복사가 발생하지 않아 효율적입니다.readInt16Samples(buffer: Buffer): 이 함수는 핵심적인 최적화 지점입니다. 먼저canUseInt16View를 호출하여Buffer가 직접Int16Array뷰로 사용될 수 있는지 확인합니다. 만약 가능하다면,int16View를 반환하여Buffer의 메모리를 직접 참조합니다. 그렇지 않은 경우에만, 기존 방식과 유사하게Buffer.readInt16LE를 사용하여 샘플 단위로 데이터를 읽어 새로운Int16Array에 복사합니다. 이로써 정렬된 버퍼의 경우 불필요한readInt16LE호출을 피하게 됩니다.
1.2. sampleBandlimitedWithCoefficients 및 sampleBandlimited 함수 수정
이 함수들은 리샘플링 과정에서 입력 오디오 샘플을 보간(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뷰가 사용 가능한 경우,Buffer의readInt16LE메서드 호출 오버헤드를 제거하고 메모리 접근을 단순화합니다.- 경계 검사(
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)를 호출하여 입력Buffer를Int16Array뷰(inputView)로 가져옵니다. 이 과정에서Buffer가 정렬되어 있다면 효율적인 뷰가 생성되고, 그렇지 않다면 안전한 복사본이 생성됩니다.- 출력
Buffer(output)에 대해서도canUseInt16View를 사용하여Int16Array뷰(outputView)를 생성할 수 있는지 확인합니다. 가능하다면 뷰를 사용하고, 그렇지 않으면undefined로 둡니다. sampleBandlimitedWithCoefficients및sampleBandlimited함수에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.copy와subarray를 사용하여 구현되었습니다.- 새로운 테스트 케이스 추가:
resamplePcmTo8k와convertPcmToMulaw8k함수에 대해, 원본 버퍼로 처리한 결과와unalignedCopy함수를 사용하여 생성한 정렬되지 않은 버퍼로 처리한 결과가 동일함을 검증하는 테스트가 추가되었습니다. 이는Int16Array뷰를 사용하지 못하는 예외 상황에서도 기존의 안전한 폴백(fallback) 로직이 올바르게 동작하며, 새로운 최적화된 경로와 동일한 결과를 보장함을 확인합니다.
왜 이게 좋은가?
1. 성능 향상
Buffer접근 오버헤드 제거:Buffer.readInt16LE와Buffer.writeInt16LE는 내부적으로 여러 검사를 수행하고 JavaScript 엔진의 최적화 대상에서 벗어날 수 있습니다. 반면,Int16Array의 인덱스 접근(input[sampleIndex])은 훨씬 직접적이고 빠릅니다. 특히 대량의 오디오 데이터를 처리할 때 이 차이는 누적되어 상당한 성능 향상을 가져올 수 있습니다.- 메모리 접근 효율성:
Int16Array뷰는 원본Buffer의 메모리 영역을 직접 참조합니다. 이는 데이터를 별도의 메모리 공간으로 복사하는 오버헤드를 없애줍니다.resamplePcm함수에서inputView와outputView를 사용하는 것이 대표적인 예입니다. - CPU 캐시 활용: 연속적인 메모리 영역에 대한 직접적인 접근은 CPU 캐시 효율성을 높여 전반적인 처리 속도를 개선할 수 있습니다.
PR 설명에 따르면, 이 변경은
참고 자료
- https://nodejs.org/api/buffer.html#bufferreadint16le-offs
- https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Int16Array
⚠️ 알림: 이 분석은 AI가 실제 코드 diff를 기반으로 작성했습니다.
관련 포스트
- [openclaw] Telegram 메시지 캐시 최적화: 전체 파일 재작성 대신 변경분만 기록하기
- [sglang] SGLang NIXL HiCache 리팩토링 및 O_DIRECT 지원 추가: 성능 향상과 안정성 강화
- [vllm] vLLM, DeepSeek-V3.2 모델의 ROCm 성능 최적화: CPU 측 마이크로 최적화 3가지 분석
- [sglang] SGLang, 레이어별 오프로딩 기본값 설정을 통한 인코더/VAE 성능 최적화
- [vllm] vLLM, DeepSeek V4 모델의 저지연을 위한 RMSNorm과 라우터 GEMV 연산 융합으로 성능 극대화
PR Analysis 의 다른글
- 이전글 [sglang] 성능 최적화의 함정: DeepSeek-V3.2 정확도 붕괴를 막기 위한 SGLang의 긴급 롤백 분석
- 현재글 : [openclaw] Node.js 오디오 코덱 성능 최적화: TypedArray를 활용한 효율적인 PCM 처리
- 다음글 [sglang] Pydantic 유효성 검사 최적화: C 루프를 이용한 API 성능 향상
댓글