Encode lazy-init invariants at the right site, not at read sites

Three spots had the same shape: a field starts None, a later method
populates it, a read site later reads it. Pyright can't track the
cross-method invariant. Rather than spray assertions at the read
sites, fix each site at the structural level:

- `FastAPIWebsocketInputTransport._monitor_websocket` now takes the
  session timeout as an argument. The task-creation site already
  guards on truthiness, so the call can pass the non-None value
  directly and the method's signature tells the truth.
- `FrameProcessorMetrics.task_manager` raises `RuntimeError` instead
  of asserting. Asserts are stripped under `python -O`; a real raise
  keeps the runtime safety net and still narrows the type for pyright.
- `SOXRStreamAudioResampler._maybe_initialize_sox_stream` returns the
  initialized stream. Callers use the return value and never touch
  the Optional `_soxr_stream` attribute, so narrowing stays inside
  the init method where the invariant is established.
This commit is contained in:
Mark Backman
2026-04-22 11:27:25 -04:00
parent 457eb7aa92
commit 5872006d6b
4 changed files with 23 additions and 13 deletions

View File

@@ -2,8 +2,14 @@
"typeCheckingMode": "basic",
"pythonVersion": "3.11",
"pythonPlatform": "All",
"include": ["scripts", "src/pipecat"],
"exclude": ["**/*_pb2.py", "**/__pycache__"],
"include": [
"scripts",
"src/pipecat"
],
"exclude": [
"**/*_pb2.py",
"**/__pycache__"
],
"ignore": [
"tests",
"src/pipecat/adapters/base_llm_adapter.py",
@@ -23,7 +29,6 @@
"src/pipecat/audio/filters/aic_filter.py",
"src/pipecat/audio/filters/krisp_viva_filter.py",
"src/pipecat/audio/filters/rnnoise_filter.py",
"src/pipecat/audio/resamplers/soxr_stream_resampler.py",
"src/pipecat/audio/turn/smart_turn/local_smart_turn_v2.py",
"src/pipecat/audio/turn/smart_turn/local_smart_turn_v3.py",
"src/pipecat/audio/vad/silero.py",
@@ -35,7 +40,6 @@
"src/pipecat/processors/frameworks/rtvi/processor.py",
"src/pipecat/processors/frameworks/strands_agents.py",
"src/pipecat/processors/gstreamer/pipeline_source.py",
"src/pipecat/processors/metrics/frame_processor_metrics.py",
"src/pipecat/services/ai_service.py",
"src/pipecat/services/anthropic/llm.py",
"src/pipecat/services/assemblyai/stt.py",
@@ -130,9 +134,8 @@
"src/pipecat/transports/smallwebrtc/transport.py",
"src/pipecat/transports/tavus/transport.py",
"src/pipecat/transports/websocket/client.py",
"src/pipecat/transports/websocket/fastapi.py",
"src/pipecat/transports/websocket/server.py",
"src/pipecat/transports/whatsapp/client.py"
],
"reportMissingImports": false
}
}

View File

@@ -68,7 +68,7 @@ class SOXRStreamAudioResampler(BaseAudioResampler):
self._soxr_stream.clear()
self._last_resample_time = current_time
def _maybe_initialize_sox_stream(self, in_rate: int, out_rate: int):
def _maybe_initialize_sox_stream(self, in_rate: int, out_rate: int) -> "soxr.ResampleStream":
if self._soxr_stream is None:
self._initialize(in_rate, out_rate)
else:
@@ -80,6 +80,9 @@ class SOXRStreamAudioResampler(BaseAudioResampler):
f"expected {self._in_rate}->{self._out_rate}, got {in_rate}->{out_rate}"
)
assert self._soxr_stream is not None
return self._soxr_stream
async def resample(self, audio: bytes, in_rate: int, out_rate: int) -> bytes:
"""Resample audio data using soxr.ResampleStream resampler library.
@@ -94,8 +97,8 @@ class SOXRStreamAudioResampler(BaseAudioResampler):
if in_rate == out_rate:
return audio
self._maybe_initialize_sox_stream(in_rate, out_rate)
stream = self._maybe_initialize_sox_stream(in_rate, out_rate)
audio_data = np.frombuffer(audio, dtype=np.int16)
resampled_audio = self._soxr_stream.resample_chunk(audio_data)
resampled_audio = stream.resample_chunk(audio_data)
result = resampled_audio.astype(np.int16).tobytes()
return result

View File

@@ -66,6 +66,8 @@ class FrameProcessorMetrics(BaseObject):
Returns:
The task manager instance for async operations.
"""
if self._task_manager is None:
raise RuntimeError("task_manager not set; call setup() first")
return self._task_manager
@property

View File

@@ -255,7 +255,9 @@ class FastAPIWebsocketInputTransport(BaseInputTransport):
if self._params.serializer:
await self._params.serializer.setup(frame)
if not self._monitor_websocket_task and self._params.session_timeout:
self._monitor_websocket_task = self.create_task(self._monitor_websocket())
self._monitor_websocket_task = self.create_task(
self._monitor_websocket(self._params.session_timeout)
)
await self._client.trigger_client_connected()
await self.push_frame(ClientConnectedFrame())
if not self._receive_task:
@@ -322,9 +324,9 @@ class FastAPIWebsocketInputTransport(BaseInputTransport):
if not self._client.is_closing:
await self._client.trigger_client_disconnected()
async def _monitor_websocket(self):
"""Wait for self._params.session_timeout seconds, if the websocket is still open, trigger timeout event."""
await asyncio.sleep(self._params.session_timeout)
async def _monitor_websocket(self, timeout: int):
"""Wait for ``timeout`` seconds, then trigger the client-timeout event if still open."""
await asyncio.sleep(timeout)
await self._client.trigger_client_timeout()