Skip to content

Use node stream for AudioStream and VideoStream#458

Merged
Shubhrakanti merged 28 commits into
mainfrom
shubhra/abstract-ringqueue-logic
Apr 18, 2025
Merged

Use node stream for AudioStream and VideoStream#458
Shubhrakanti merged 28 commits into
mainfrom
shubhra/abstract-ringqueue-logic

Conversation

@Shubhrakanti

@Shubhrakanti Shubhrakanti commented Apr 4, 2025

Copy link
Copy Markdown

Use node streams to buffer incoming audio and video frames

Shubhrakanti Ganguly added 2 commits April 3, 2025 21:41
@changeset-bot

changeset-bot Bot commented Apr 4, 2025

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 00d4e58

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

Comment thread packages/livekit-rtc/src/utils.ts Outdated
Comment on lines +61 to +64
/**
* Push an item into the buffer. If the buffer is full, the newest item will
* be removed.
*/

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is what the python implementation does. Wouldn't it make sense if the oldest time is evicted?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When thinking about a generic ring buffer I think you're right that it should shift the values to the left by one and insert the newest at the end.
I'm not sure if there are additional considerations in the python implementation though.

Comment thread packages/livekit-rtc/src/utils.ts Outdated
* Get an item from the buffer. If the buffer is empty, this will return a promise
* that resolves when a new item is pushed.
*/
async get(): Promise<T> {

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's best to just move the locking inside here. Are there any downsides to this?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The way it's implemented now doesn't guarantee call order (I believe, not verified), so locking would definitely be better.

Shifting the lock exclusively into here will make it harder to raise errors from within the AudioStream's next handler (we're currently not doing that anyways, but we should keep that in mind in case we ever want to)

@lukasIO lukasIO left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've talked to @nbsp in the past about moving more into the direction of utilising built in node (web) streams for our queues.

See https://github.com/livekit/agents-js/pull/194/files for a first step into that direction.

I'm not entirely sure it's the right call for all our queuing logic, but in general, if there's a way to make this work consistently across the repo with built-in primitives that's what I'd prefer.

Having custom implementations of both RingQueue and AsyncIterableQueue sounds a bit redundant though, esp. when the RingQueue in this PR acts as an async queue and we don't really make use of the capacity parameter.
If we stick to a custom implementation, let's think about merging these two into one.

We've had some bugs in the past around async queuing and having custom implementations adds one more place where things can potentially go wrong.

Comment thread packages/livekit-rtc/src/utils.ts Outdated
Comment on lines +61 to +64
/**
* Push an item into the buffer. If the buffer is full, the newest item will
* be removed.
*/

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When thinking about a generic ring buffer I think you're right that it should shift the values to the left by one and insert the newest at the end.
I'm not sure if there are additional considerations in the python implementation though.

Comment thread packages/livekit-rtc/src/utils.ts Outdated
* Get an item from the buffer. If the buffer is empty, this will return a promise
* that resolves when a new item is pushed.
*/
async get(): Promise<T> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The way it's implemented now doesn't guarantee call order (I believe, not verified), so locking would definitely be better.

Shifting the lock exclusively into here will make it harder to raise errors from within the AudioStream's next handler (we're currently not doing that anyways, but we should keep that in mind in case we ever want to)

@Shubhrakanti

Copy link
Copy Markdown
Author

I've talked to @nbsp in the past about moving more into the direction of utilising built in node (web) streams for our queues.

See https://github.com/livekit/agents-js/pull/194/files for a first step into that direction.

Thanks super helpful context.

I'm not entirely sure it's the right call for all our queuing logic, but in general, if there's a way to make this work consistently across the repo with built-in primitives that's what I'd prefer.

Having custom implementations of both RingQueue and AsyncIterableQueue sounds a bit redundant though, esp. when the RingQueue in this PR acts as an async queue and we don't really make use of the capacity parameter. If we stick to a custom implementation, let's think about merging these two into one.

This makes total sense let me see if I can get this working with node:stream/web.

@Shubhrakanti Shubhrakanti changed the title Refactor Queueing logic for AudioStream and VideoStream Use node stream for AudioStream and VideoStream Apr 10, 2025
Shubhrakanti Ganguly added 3 commits April 9, 2025 22:33
Comment on lines +63 to +67
if (controller.desiredSize && controller.desiredSize > 0) {
controller.enqueue(ev);
} else {
console.warn('Audio stream buffer is full, dropping frame');
}

@Shubhrakanti Shubhrakanti Apr 10, 2025

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As I mentioned earlier...

This is what the python implementation does. Wouldn't it make sense if the oldest time is evicted?

@theomonnom if this needs to be the behavior here as well then maybe node streams are not a good choice because we don't have explicit control over the buffer eviction policy.

@Shubhrakanti Shubhrakanti requested a review from lukasIO April 10, 2025 05:46
Comment thread packages/livekit-rtc/src/audio_stream.ts Outdated
},
);

this.reader = source.pipeThrough(transformStream).getReader();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we could acquire the reader lazily in next() and also make sure to call releaseLock when done

},
});

const transformStream = new TransformStream<FfiEvent, AudioFrame>(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is the transform actually needed? wondering if we can handle this directly in the ReadableStream?

@Shubhrakanti Shubhrakanti left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TLDR

We can't have Video/AudioStream be a ReadableStream without introducing breaking changes to the current api. Right now we don't automatically close the stream when an iterator finishes. If we extend the ReadableStream interface, this behavior is unavoidable.

Explanation

AsyncIterators in JS have a return method that is invoked when iterator stops or exits due to a break; statement. When this happens, the ReadableStream under the hood will invoke the cancel() method on the source.

For example if we had code like this

const stream = new VideoStream(track);
    let i = 0;
    for await (const frame of stream) {
        i++;
        console.log('loop 1 timestamp', frame.timestampUs);
        if (i > 5) {
            break;
        }
    }
    console.log('finished loop 1');
    for await (const frame of stream) {
      i++;
      console.log('loop 2 timestamp', frame.timestampUs);
      if (i > 10) {
          break;
      }
    }
    console.log('finished loop 2');
   }

Output from existing implementation.

loop 1 timestamp 352996437000n
loop 1 timestamp 352996470000n
loop 1 timestamp 352996527000n
...
finished loop 1 -> no resource clean up
loop 2 timestamp 352996537000n
loop 2 timestamp 352996660000n
loop 2 timestamp 352996957000n
...
finished loop 2

Loop 2 pulls of the stream because there is no resource clean up.

Output from new implementation with ReadableStream

loop 1 timestamp 352996437000n
loop 1 timestamp 352996470000n
loop 1 timestamp 352996527000n
...
finished loop 1 -> under the hood source.close() is called
finished loop 2

Loop 2 is empty because the stream is closed.

I tried to hack around this by doing something like

  class VideoStreamSource implements UnderlyingSource<VideoFrameEvent> {
  ...
  cancel() {
    // do nothing on the inherited cancel method
  }

  close() {
    this.closed = true;
    FfiClient.instance.off(FfiClientEvent.FfiEvent, this.onEvent);
    this.ffiHandle.dispose();
    console.log('+++ cancelled');
  }
}

export class VideoStream extends ReadableStream<VideoFrameEvent> {
  private source: VideoStreamSource;

  constructor(track: Track) {
    const source = new VideoStreamSource(track);
    super(source);
    this.source = source;
  }

  close() {
    this.source.cancel();
  }
}

but then you get an error throw new ERR_INVALID_STATE.TypeError('Controller is already closed');

Proposals

1. Make the breaking change and update the API with a new version.

Ideally we get all the benefits of ReadableStream (like locking, tee(), pipeTo/Through).

I also think the current implementation allows user code to have subtle bugs. For example above, if we do something like

for await (const frame of stream) {
  ...
}

await someReallyLongRunningFunction()

for await (const frame of stream) {
  ...
}

The FFiHandle in the VideoStream will continue to push frames into the buffer even though we're not consuming from the stream. Might cause OOM.

2. Just use ReadableStream inside VideoStream

This makes the current implementation logic cleaner and there are no breaking changes, but we loose the benefits of ReadableStream.

Comment on lines +71 to +72
if (!this.closed) {
this.controller.enqueue(videoFrameEvent);

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#458 (comment)

This is okay to do without checking desiredSize first. The official standard has an example of a push stream with no back pressure support. We should just make a comment making it explicit. If the request arises we should limit the queue size based on memory size in bytes not number of frames.

Comment on lines +24 to +26
private controller?: ReadableStreamDefaultController<VideoFrameEvent>;
private ffiHandle: FfiHandle;
private closed = false;

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

technically making these private and moving them here would not be backward compatible. Any reason they shouldn't be?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Discussed offline. It's okay

@Shubhrakanti Shubhrakanti requested review from lukasIO and nbsp April 16, 2025 07:14

@lukasIO lukasIO left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this looks very clean!

We can tackle backpressure handling (i.e. dropping every nth frame) in a follow up PR

I think you might have accidentally removed the AudioStream implementation?

@Shubhrakanti Shubhrakanti requested a review from lukasIO April 17, 2025 22:00
Shubhrakanti Ganguly added 2 commits April 17, 2025 15:04
[Symbol.asyncIterator](): AudioStream {
return this;
export class AudioStream extends ReadableStream<AudioFrame> {
constructor(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we would want to add the more specific overload constructors here that @typester had added for the AudioStream

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oh right. Whoops.

Shubhrakanti Ganguly added 2 commits April 18, 2025 11:24
@Shubhrakanti Shubhrakanti merged commit 9b753e0 into main Apr 18, 2025
@Shubhrakanti Shubhrakanti deleted the shubhra/abstract-ringqueue-logic branch April 18, 2025 19:03
@github-actions github-actions Bot mentioned this pull request May 6, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants