Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
6 changes: 6 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,5 +28,11 @@ build-backend = "uv_build"
dev = [
"dotenv>=0.9.9",
"pipecat-ai[google,silero]>=1.1.0",
"pytest>=8.0",
"pytest-asyncio>=0.24",
"uvicorn>=0.46.0",
]

[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
93 changes: 69 additions & 24 deletions src/pipecat_asterisk/transport/flow_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@
#

import asyncio
from loguru import logger
import time
from collections import deque

from loguru import logger
from pipecat.transports.websocket.fastapi import (
FastAPIWebsocketClient,
)
Expand Down Expand Up @@ -48,7 +50,11 @@ def __init__(
self._ptime = ptime # Audio chunk duration. In milliseconds
self._psize = psize # Audio chunk size. In bytes
self._websocket_client = websocket_client
self._local_buffer = bytearray()
# Deque of bytes chunks (head == oldest). Total queued bytes is tracked
# separately so the working-range check stays O(1) without walking the
# deque. See `_pop_bytes` for how sends drain it.
self._local_buffer: deque[bytes] = deque()
self._local_buffer_size: int = 0
self._remote_buffer_low_water = (
self.REMOTE_BUFFER_LOW_WATER * self.REMOTE_BUFFER_SIZE * self._psize
)
Expand All @@ -70,9 +76,12 @@ def __call__(self, chunk: bytes) -> None:
chunk: The audio chunk to add to the local buffer.

"""
self._local_buffer.extend(chunk)
if not chunk:
return
self._local_buffer.append(chunk)
self._local_buffer_size += len(chunk)
logger.trace(
f"Buffered {len(chunk)} bytes to local buffer. Local buffer size: {len(self._local_buffer)} bytes."
f"Buffered {len(chunk)} bytes to local buffer. Local buffer size: {self._local_buffer_size} bytes."
)

async def flow_control(self):
Expand Down Expand Up @@ -100,7 +109,7 @@ async def flow_control(self):

# Flow control logic
# First check if we have something in the local buffer
if len(self._local_buffer) > 0:
if self._local_buffer_size > 0:
# If the remote buffer is under the low water mark we send whatever we have in the local buffer
if self._remote_buffer_utilization < self._remote_buffer_low_water:
await self.send_chunks()
Expand All @@ -110,10 +119,40 @@ async def flow_control(self):
# and we have at least twice as much free space in the remote buffer as the minimum batch size to avoid overfilling the remote buffer and causing audio dropouts on the Asterisk side.
elif (
self._remote_buffer_utilization < self._remote_buffer_high_water - self._min_batch * 2
) and (len(self._local_buffer) >= self._min_batch):
) and (self._local_buffer_size >= self._min_batch):
await self.send_chunks()
# If the remote buffer is above the high water mark we don't send anything and wait for the next tick to see if the remote buffer utilization has decreased enough to send more audio

def _pop_bytes(self, max_bytes: int) -> bytes:
"""Pop up to ``max_bytes`` from the head of the local buffer.

Walks the chunk deque, popping whole chunks while they fit and slicing
the head chunk if the remaining budget falls below its size. The
returned bytes object is allocated once via ``b"".join`` regardless of
how many deque entries we drain. ``_local_buffer_size`` is updated in
lockstep so the working-range check in ``flow_control`` stays O(1).
"""
if max_bytes <= 0 or self._local_buffer_size == 0:
return b""

parts: list[bytes] = []
remaining = max_bytes
while remaining > 0 and self._local_buffer:
head = self._local_buffer[0]
if len(head) <= remaining:
parts.append(self._local_buffer.popleft())
remaining -= len(head)
else:
parts.append(head[:remaining])
self._local_buffer[0] = head[remaining:]
remaining = 0

# `b"".join` allocates once; in the single-part case skip the join to
# avoid the trivial copy and reuse the existing bytes object.
chunk = parts[0] if len(parts) == 1 else b"".join(parts)
self._local_buffer_size -= len(chunk)
return chunk

async def send_chunks(self):
"""Send audio chunks from the local buffer to websocket (effectively to the remote buffer on the Asterisk side).

Expand All @@ -124,42 +163,48 @@ async def send_chunks(self):

# Calculate the number of bytes to send
bytes_to_send = min(
len(self._local_buffer), self.MAX_WS_SEND
self._local_buffer_size, self.MAX_WS_SEND
) # Ensure we don't exceed the websocket maximum message size
if bytes_to_send > 0:
# Take the bytes to send from the local buffer
chunk = bytes(self._local_buffer[:bytes_to_send])
del self._local_buffer[:bytes_to_send]
# Take the bytes to send from the head of the local buffer
chunk = self._pop_bytes(bytes_to_send)

# Send the chunk to the websocket
await self._websocket_client.send(chunk)
# Update the remote buffer utilization
self._remote_buffer_utilization += len(chunk)
logger.debug(
logger.trace(
f"Sent {len(chunk)} bytes to websocket. Remote buffer utilization: {self._remote_buffer_utilization:.0f} bytes, {self._remote_buffer_utilization / (self._psize * self.REMOTE_BUFFER_SIZE) * 100:.1f}%."
)

def close(self, gracefully: bool = False):
"""Cancel the flow control task and optionally wait for the local buffer to be sent before cancelling.
async def close(self, gracefully: bool = False) -> None:
"""Cancel the flow control task, optionally draining the local buffer first.

Args:
gracefully: If True, wait for the local buffer to be sent before cancelling the flow control
gracefully: If True, wait until the local buffer is empty (so the
``flow_control`` task has had a chance to send everything),
then cancel. If False, cancel immediately and drop any pending
audio in the local buffer.
"""
if self._flow_control:
if gracefully:
logger.info(
f"Gracefully closing flow controller. Waiting for local buffer to be sent..."
)
while len(self._local_buffer) > 0:
time.sleep(
self._ptime / 1000
) # Sleep for the duration of one audio chunk to give the flow control loop time to send the remaining audio in the local buffer
self._flow_control.cancel()
if self._flow_control is None:
return
if gracefully:
logger.info(
"Gracefully closing flow controller. Waiting for local buffer to be sent..."
)
# Sleep one chunk-duration at a time on the event loop, allowing
# the flow_control task to keep draining `_local_buffer`. Using
# `time.sleep` here would block the event loop and prevent the
# very draining we're waiting on.
while self._local_buffer_size > 0:

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

One more thing. Could you please add or self._remote_buffer_utilization > 0 in the while loop to wait until the remote buffer is empty as well, because if we close the websocket connection right after we send everything for the buffer, @abalashov found that Asterisk will remove the channel from the bridge right away, so the user will not hear anything that we uploaded to the buffer at the very end.
So if we want to play everything provided by TTS (which is gracefully), we need to wait until the remote buffer is empty.

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.

Curious - are you using self._remote_buffer_utilization as a casual proxy for the status of QUEUE_DRAINED? :)

I started with that before moving onto QUEUE_DRAINED, and it worked about as well. But of course, this is a Pipecat-side calculation...

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

I prefer the pipecat-side calculation because it only depends on the correctness of the calculation algorithm. If we rely on QUEUE_DRAINED events, we still depend on the algorithm remote_buffer_utilization calculation algorithm (based on it, we subscribe to QUEUE_DRAINED every time the remote buffer is empty and we start sending new media), but besides that, we depend on the correctness of many other logical paths and algorithms, including the logic of Asterisk. So I think local(pipecat-side) calculation is more robust.
When (it's planned) REPORT_QUEUE_DRAINED/QUEUE_DRAINED subscription is implemented, we can use QUEUE_DRAINED messages to sync (reset to 0) our locally-calculated remote buffer utilization and measure the drift between the effective and calculated remote buffer utilization.

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.

That's interesting. Yet in my use-case, as you know, QUEUE_DRAINED is considered the final word on "So if we want to play everything provided by TTS", but in this case, no?

I ask the question because it would have been far easier to just trust the calculation algorithm and have the transport expose something from the serialiser to while True: await asyncio.sleep(0.05) until the remote buffer drains. :-) There's a reason neither of us thought that was the best idea, so I am trying to tease out the philosophical difference between that case and this one.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Well, definitely there are more elegant options for waiting for the remote buffer to be empty; for instance, with asyncio.event() and unlocking it from the main loop of the flow_controller. But we will still need to calculate remote buffer utilization to understand the moment when we need to send REPORT_QUEUE_DRAINED. So the accuracy of the logic still boils down to the correctness of the remote buffer utilization algorithm, even if we consider QUEUE_DRAINED the final word.

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 will defer to your wisdom on this. However, it seems to me there is a conflict between two somewhat irreconcilable sources of truth here, and that the system should stick to one or the other.

await asyncio.sleep(self._ptime / 1000)
self._flow_control.cancel()

def drop_buffer(self):
"""Drop any buffered audio in the local buffer and reset remote buffer utilization to zero.

This is used when an interruption/stop/cancel frame is processed to avoid replaying stale audio.
"""
self._local_buffer.clear()
self._local_buffer_size = 0
self._remote_buffer_utilization = 0.0
Empty file added tests/__init__.py
Empty file.
194 changes: 194 additions & 0 deletions tests/test_flow_controller.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
"""Unit tests for FlowController.

Covers the deque-buffer drain semantics, drop_buffer state reset,
and that the async close(gracefully=True) yields the event loop so the
flow_control task can actually drain.
"""

import asyncio
from unittest.mock import AsyncMock, MagicMock

import pytest

from pipecat_asterisk.transport.flow_controller import FlowController


PTIME_MS = 20
PSIZE_BYTES = 640 # slin16 @ 20ms == 16000 Hz * 2 bytes/sample * 0.02 s


def _make_controller() -> FlowController:
"""Build a controller with a mock websocket client.

The controller starts its `flow_control` task in `__init__`, so this
must be called from inside a running event loop.
"""
client = MagicMock()
client.send = AsyncMock()
return FlowController(ptime=PTIME_MS, psize=PSIZE_BYTES, websocket_client=client)


# ---------------------------------------------------------------------------
# __call__ / buffer accounting
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_call_appends_chunk_and_tracks_size():
fc = _make_controller()
try:
fc(b"\x00" * 640)
fc(b"\x01" * 320)
assert fc._local_buffer_size == 960
assert list(fc._local_buffer) == [b"\x00" * 640, b"\x01" * 320]
finally:
await fc.close()


@pytest.mark.asyncio
async def test_call_ignores_empty_chunk():
fc = _make_controller()
try:
fc(b"")
assert fc._local_buffer_size == 0
assert len(fc._local_buffer) == 0
finally:
await fc.close()


# ---------------------------------------------------------------------------
# _pop_bytes drain semantics
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_pop_bytes_drains_whole_chunks():
"""Popping a budget that lines up on chunk boundaries pops chunks whole."""
fc = _make_controller()
try:
fc(b"a" * 100)
fc(b"b" * 100)
fc(b"c" * 100)
out = fc._pop_bytes(200)
assert out == b"a" * 100 + b"b" * 100
assert fc._local_buffer_size == 100
assert list(fc._local_buffer) == [b"c" * 100]
finally:
await fc.close()


@pytest.mark.asyncio
async def test_pop_bytes_slices_head_chunk():
"""A budget smaller than the head chunk slices the head and leaves the tail."""
fc = _make_controller()
try:
fc(b"abcdef")
out = fc._pop_bytes(3)
assert out == b"abc"
assert fc._local_buffer_size == 3
assert list(fc._local_buffer) == [b"def"]
finally:
await fc.close()


@pytest.mark.asyncio
async def test_pop_bytes_handles_mix_of_whole_and_partial():
"""A budget that straddles a chunk boundary pops one whole chunk + slices the next."""
fc = _make_controller()
try:
fc(b"a" * 100)
fc(b"b" * 100)
out = fc._pop_bytes(150)
assert out == b"a" * 100 + b"b" * 50
assert fc._local_buffer_size == 50
assert list(fc._local_buffer) == [b"b" * 50]
finally:
await fc.close()


@pytest.mark.asyncio
async def test_pop_bytes_caps_at_buffer_size():
"""Asking for more bytes than are buffered returns everything available."""
fc = _make_controller()
try:
fc(b"hello")
out = fc._pop_bytes(1_000_000)
assert out == b"hello"
assert fc._local_buffer_size == 0
assert len(fc._local_buffer) == 0
finally:
await fc.close()


@pytest.mark.asyncio
async def test_pop_bytes_returns_empty_when_empty():
fc = _make_controller()
try:
assert fc._pop_bytes(100) == b""
assert fc._pop_bytes(0) == b""
finally:
await fc.close()


# ---------------------------------------------------------------------------
# drop_buffer
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_drop_buffer_resets_deque_size_and_utilization():
fc = _make_controller()
try:
fc(b"a" * 100)
fc(b"b" * 100)
fc._remote_buffer_utilization = 1234.5
fc.drop_buffer()
assert fc._local_buffer_size == 0
assert len(fc._local_buffer) == 0
assert fc._remote_buffer_utilization == 0.0
finally:
await fc.close()


# ---------------------------------------------------------------------------
# close()
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_close_non_graceful_cancels_immediately():
fc = _make_controller()
task = fc._flow_control
fc(b"x" * 1024)
await fc.close(gracefully=False)
with pytest.raises(asyncio.CancelledError):
await task
# Non-graceful close leaves the unsent buffer in place by design.
assert fc._local_buffer_size == 1024


@pytest.mark.asyncio
async def test_close_graceful_yields_loop_and_drains():
"""The bug fix: close(gracefully=True) must `await asyncio.sleep`, not
`time.sleep`, so the flow_control task can actually run and drain the
buffer. We prove this by checking that close returns once
_local_buffer_size hits zero - which can only happen if flow_control
was given CPU during the wait.
"""
fc = _make_controller()
try:
# Buffer one chunk worth of audio. Low water mark logic in
# flow_control will dispatch it on the next tick.
fc(b"\x00" * PSIZE_BYTES)
assert fc._local_buffer_size == PSIZE_BYTES

# If close used time.sleep, this would hang the event loop until
# the cancellation, and flow_control would never get a chance to
# send. Cap the wait so a regression times out instead of hanging.
await asyncio.wait_for(fc.close(gracefully=True), timeout=1.0)

assert fc._local_buffer_size == 0
fc._websocket_client.send.assert_awaited()
finally:
# Already cancelled above; second close() is a no-op safety net.
await fc.close()