Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/frame-buffer-byte-offset.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@livekit/rtc-node': patch
---

Fix `AudioFrame` and `VideoFrame` passing native code the start of the backing `ArrayBuffer` instead of their own data when `data` is a view at a non-zero `byteOffset` (e.g. from `subarray()` or a pooled `Buffer`). This affected `AudioResampler`, `AudioSource.captureFrame`, `VideoSource.captureFrame` and `VideoFrame.convert`/`getPlane` (#679).
5 changes: 5 additions & 0 deletions .changeset/fruity-grapes-hammer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@livekit/rtc-node": patch
---

fix(rtc): respect byteOffset when passing frame data to native code
59 changes: 59 additions & 0 deletions packages/livekit-rtc/src/audio_frame.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// SPDX-FileCopyrightText: 2026 LiveKit, Inc.
//
// SPDX-License-Identifier: Apache-2.0
import { describe, expect, it } from 'vitest';
import { AudioFrame } from './audio_frame.js';
import { AudioResampler } from './audio_resampler.js';
import { FfiClient } from './ffi_client.js';

const SAMPLE_RATE = 16000;
const FRAME_SAMPLES = SAMPLE_RATE / 100; // 10ms

const rms = (a: ArrayLike<number>) =>
Math.sqrt(Array.from(a).reduce((s, x) => s + x * x, 0) / a.length);

const resampleAll = (frames: AudioFrame[]): Int16Array => {
const resampler = new AudioResampler(SAMPLE_RATE, 24000);
const out: number[] = [];
for (const frame of frames) {
for (const f of resampler.push(frame)) out.push(...f.data);
}
for (const f of resampler.flush()) out.push(...f.data);
resampler.close();
return Int16Array.from(out);
};

describe('AudioFrame', () => {
it('hands native code its own samples when data is a subarray', () => {
const pcm = Int16Array.from({ length: FRAME_SAMPLES * 2 }, (_, i) => i);
const frame = new AudioFrame(pcm.subarray(FRAME_SAMPLES), SAMPLE_RATE, 1, FRAME_SAMPLES);

const seen = FfiClient.instance.copyBuffer(frame.protoInfo().dataPtr!, frame.data.byteLength);

expect(new Int16Array(seen.buffer, seen.byteOffset, seen.length / 2)).toEqual(frame.data);
});

it('resamples 10ms subarray frames like copied frames', () => {
// 100ms of a rising tone, so that every 10ms frame has different content
const total = FRAME_SAMPLES * 10;
const pcm = Int16Array.from({ length: total }, (_, i) =>
Math.round(((8000 * i) / total) * Math.sin((2 * Math.PI * 440 * i) / SAMPLE_RATE)),
);

const views: AudioFrame[] = [];
const copies: AudioFrame[] = [];
for (let i = 0; i < total; i += FRAME_SAMPLES) {
// subarray() shares pcm's buffer at a non-zero byteOffset; slice() copies to offset 0
views.push(new AudioFrame(pcm.subarray(i, i + FRAME_SAMPLES), SAMPLE_RATE, 1, FRAME_SAMPLES));
copies.push(new AudioFrame(pcm.slice(i, i + FRAME_SAMPLES), SAMPLE_RATE, 1, FRAME_SAMPLES));
}

const fromViews = resampleAll(views);
const fromCopies = resampleAll(copies);

// soxr dithers 16-bit output, so two runs over the same input can differ by a few LSB
expect(fromViews.length).toBe(fromCopies.length);
const residual = Array.from(fromViews, (x, i) => x - fromCopies[i]!);
expect(rms(residual)).toBeLessThan(rms(fromCopies) / 100);
});
});
4 changes: 3 additions & 1 deletion packages/livekit-rtc/src/audio_frame.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,9 @@ export class AudioFrame {
/** @internal */
protoInfo(): AudioFrameBufferInfo {
return new AudioFrameBufferInfo({
dataPtr: FfiClient.instance.retrievePtr(new Uint8Array(this.data.buffer)),
dataPtr: FfiClient.instance.retrievePtr(
new Uint8Array(this.data.buffer, this.data.byteOffset, this.data.byteLength),
),
sampleRate: this.sampleRate,
numChannels: this.channels,
samplesPerChannel: this.samplesPerChannel,
Expand Down
22 changes: 22 additions & 0 deletions packages/livekit-rtc/src/video_frame.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// SPDX-FileCopyrightText: 2026 LiveKit, Inc.
//
// SPDX-License-Identifier: Apache-2.0
import { VideoBufferType } from '@livekit/rtc-ffi-bindings';
import { describe, expect, it } from 'vitest';
import { VideoFrame } from './video_frame.js';

describe('VideoFrame', () => {
it('passes only its own view of the data to native code', () => {
// a 4x4 I420 frame is 16 bytes of Y plus 4 bytes each of U and V
const size = 24;
const backing = new Uint8Array(size * 2);
for (let i = 0; i < size; i++) backing[size + i] = i + 1;

// the frame starts halfway into its backing ArrayBuffer
const frame = new VideoFrame(backing.subarray(size), 4, 4, VideoBufferType.I420);

expect(frame.getPlane(0)).toEqual(backing.subarray(size, size + 16));
expect(frame.getPlane(1)).toEqual(backing.subarray(size + 16, size + 20));
expect(frame.getPlane(2)).toEqual(backing.subarray(size + 20, size + 24));
});
});
4 changes: 3 additions & 1 deletion packages/livekit-rtc/src/video_frame.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@ export class VideoFrame {

/** @internal */
get dataPtr(): bigint {
return FfiClient.instance.retrievePtr(new Uint8Array(this.data.buffer));
return FfiClient.instance.retrievePtr(
new Uint8Array(this.data.buffer, this.data.byteOffset, this.data.byteLength),
);
}

/** @internal */
Expand Down
Loading