Skip to content

Commit 48f9efa

Browse files
fix(krisp): adopt processed frames into the local rtc-node binding (#2130)
Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 5010952 commit 48f9efa

4 files changed

Lines changed: 105 additions & 1 deletion

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@livekit/agents-plugin-krisp': patch
3+
---
4+
5+
Fix Krisp-processed audio being invisible to the rest of the pipeline
6+
7+
The LiveKit Cloud backend is reached through `createRequire`, which resolves the internal
8+
package's `require` condition and so loads the CJS build of `@livekit/rtc-node` next to the
9+
ESM one the framework uses. Frames returned by that backend were instances of the CJS copy's
10+
`AudioFrame`, so every `instanceof AudioFrame` downstream failed. Adaptive interruption saw
11+
zero audio and classified every barge-in as a backchannel, making it impossible to interrupt
12+
an agent that had noise cancellation enabled. Frames are now adopted into the local binding
13+
before leaving the filter, sharing their samples rather than copying them.
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
// SPDX-FileCopyrightText: 2026 LiveKit, Inc.
2+
//
3+
// SPDX-License-Identifier: Apache-2.0
4+
import { AudioFrame } from '@livekit/rtc-node';
5+
import { describe, expect, it } from 'vitest';
6+
import { adoptLocalAudioFrame } from './_frame_identity.js';
7+
8+
/**
9+
* Stands in for the `AudioFrame` of a second copy of `@livekit/rtc-node`: identical shape, same
10+
* constructor name, unrelated identity. That is exactly what the CJS build produces when the
11+
* Cloud backend pulls it in through `createRequire`.
12+
*/
13+
class ForeignAudioFrame {
14+
constructor(
15+
readonly data: Int16Array,
16+
readonly sampleRate: number,
17+
readonly channels: number,
18+
readonly samplesPerChannel: number,
19+
private readonly _userdata: Record<string, unknown> = {},
20+
) {}
21+
22+
get userdata(): Record<string, unknown> {
23+
return this._userdata;
24+
}
25+
}
26+
27+
describe('adoptLocalAudioFrame', () => {
28+
it('adopts a frame built by another copy of rtc-node', () => {
29+
const samples = new Int16Array([1, -2, 3, -4]);
30+
const foreign = new ForeignAudioFrame(samples, 16000, 1, 4, { source: 'krisp' });
31+
32+
// The premise of the bug: this lookalike fails the identity check every consumer relies on.
33+
expect(foreign instanceof AudioFrame).toBe(false);
34+
35+
const adopted = adoptLocalAudioFrame(foreign as unknown as AudioFrame);
36+
37+
expect(adopted instanceof AudioFrame).toBe(true);
38+
expect(adopted.sampleRate).toBe(16000);
39+
expect(adopted.channels).toBe(1);
40+
expect(adopted.samplesPerChannel).toBe(4);
41+
expect(adopted.userdata).toEqual({ source: 'krisp' });
42+
// Adopting must not copy the audio: this runs on every frame of every session.
43+
expect(adopted.data).toBe(samples);
44+
});
45+
46+
it('returns a local frame untouched', () => {
47+
const local = new AudioFrame(new Int16Array([5, 6]), 48000, 1, 2);
48+
49+
expect(adoptLocalAudioFrame(local)).toBe(local);
50+
});
51+
});
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
// SPDX-FileCopyrightText: 2026 LiveKit, Inc.
2+
//
3+
// SPDX-License-Identifier: Apache-2.0
4+
import { AudioFrame } from '@livekit/rtc-node';
5+
6+
/**
7+
* Return a frame that belongs to *this* copy of `@livekit/rtc-node`.
8+
*
9+
* The Cloud backend is reached through `createRequire`, which resolves the internal package's
10+
* `require` condition and so loads the CJS build of `@livekit/rtc-node` next to our ESM one.
11+
* Frames it hands back are instances of that copy's `AudioFrame`, so `instanceof AudioFrame`
12+
* fails everywhere downstream: audio recognition treats them as unrecognised sentinels and
13+
* drops them, and adaptive interruption — never fed any audio — rules every barge-in a
14+
* backchannel. `AudioFrame` is a plain data holder, so adopting one shares its samples rather
15+
* than copying them.
16+
*/
17+
export function adoptLocalAudioFrame(frame: AudioFrame): AudioFrame {
18+
if (frame instanceof AudioFrame) {
19+
return frame;
20+
}
21+
22+
// Statically unreachable — the declared type *is* `AudioFrame`. It is reachable at runtime
23+
// precisely because that type came from a different copy of the module.
24+
const foreign = frame as unknown as {
25+
data: Int16Array;
26+
sampleRate: number;
27+
channels: number;
28+
samplesPerChannel: number;
29+
userdata?: Record<string, unknown>;
30+
};
31+
32+
return new AudioFrame(
33+
foreign.data,
34+
foreign.sampleRate,
35+
foreign.channels,
36+
foreign.samplesPerChannel,
37+
foreign.userdata,
38+
);
39+
}

plugins/krisp/src/viva_filter.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import type {
2222
} from '@livekit/rtc-node';
2323
import { FrameProcessor } from '@livekit/rtc-node';
2424
import { createRequire } from 'node:module';
25+
import { adoptLocalAudioFrame } from './_frame_identity.js';
2526
import { KrispLicenseFrameProcessor } from './_krisp.js';
2627
import {
2728
type AuthProvider,
@@ -182,7 +183,7 @@ export class KrispVivaFilter extends FrameProcessor<AudioFrame> {
182183
}
183184

184185
process(frame: AudioFrame): AudioFrame {
185-
return this.inner.process(frame);
186+
return adoptLocalAudioFrame(this.inner.process(frame));
186187
}
187188

188189
close(): void {

0 commit comments

Comments
 (0)